Microservices (Java/Spring) · میکروسرویس سنیورSenior ~52 دقیقه مطالعه~45 min read

اجرا: کانتینر/Kubernetes/Service Mesh و سرورهای legacy جاواRuntime: Containers/Kubernetes/Service Mesh & Legacy Java App Servers

از بسته‌بندی Spring Boot در کانتینر و اجرای آن روی Kubernetes و service mesh تا معماری و مهاجرت سرورهای legacy جاوا (WebLogic، WildFly، WebSphere، Tomcat/Jetty) — یک نقشهٔ کامل runtime برای مهندس سنیور.From packaging Spring Boot into a container and running it on Kubernetes and a service mesh, to the architecture and migration of legacy Java app servers (WebLogic, WildFly, WebSphere, Tomcat/Jetty) — a complete runtime map for the senior engineer.

پیش‌نیاز:Prerequisites: میکروسرویس: مبانی، مرزبندی و کِی/چراMicroservices: Foundations, Boundaries & When/Why


تا اینجا دربارهٔ اینکه چه کدی می‌نویسی حرف زدیم؛ حالا وقتِ سختِ ماجراست: کدت باید یک‌جایی اجرا شود. همان سرویس Spring Boot که روی لپ‌تاپت با mvn spring-boot:run بالا می‌آید، در production باید کنار صد سرویس دیگر، روی ماشین‌هایی که هیچ‌وقت نمی‌بینی، با هزار بار restart و rollout و شبکه‌ای که هر لحظه ممکن است قطع شود، زنده بماند. این فصل دربارهٔ همان لایه است: runtime.

و یک واقعیت که خیلی از دوره‌ها پنهانش می‌کنند: دنیای واقعی دو نیمه دارد. یک نیمه‌اش «مدرن» است — کانتینر، Kubernetes، service mesh، رهیافت 12-factor. نیمهٔ دیگرش «legacy» است — WebLogic، WebSphere، JBoss/WildFly که بیست سال است در بانک‌ها و بیمه‌ها و مخابرات کار می‌کنند و فردا هم کار خواهند کرد. یک سنیور واقعی هر دو نیمه را می‌شناسد، چون کارِ واقعیِ سنیورها اغلب همین است: پُل زدن بین این دو دنیا.

نقشهٔ راه این فصل

۱) اصل ۱۲-factor به‌عنوان قانونِ اساسیِ سرویسِ cloud-native. ۲) کانتینری کردن Spring Boot: layered jar، multi-stage Dockerfile، buildpacks، و رفتار JVM داخل کانتینر. ۳) مبانی Kubernetes برای یک سرویس: Deployment/Service/Ingress، probeها، ConfigMap/Secret، HPA، requests/limits. ۴) Service mesh: sidecar در برابر ambient، mTLS، ztunnel/waypoint، و مدیریت ترافیک (Istio/Linkerd). ۵) استراتژی‌های استقرار: rolling، blue-green، canary. ۶) سرورهای legacy جاوا EE: معماری، JNDI، datasource و connection pool، EJB/JMS/MDB، clustering و session replication، مدل classloading. ۷) مسیر مهاجرت از app server به Spring Boot و اینکه هنوز کجا با این‌ها روبه‌رو می‌شوی.


۰. دو دنیای runtime، یک مهندس

رستوران در برابر فودکورت

یک app server قدیمی مثل یک رستوران بزرگ و باشکوه است: یک ساختمان عظیم که همه‌چیز را خودش دارد — آشپزخانه، انبار، مدیر سالن، سیستم رزرو، برق اضطراری. تو فقط «غذایت» (WAR) را می‌آوری و آن را به این ساختمان تحویل می‌دهی (deploy می‌کنی). ساختمان سنگین است، دیر بالا می‌آید، اما یک‌تنه همه‌چیز را مدیریت می‌کند.

کانتینر + Kubernetes مثل یک فودکورت است: هر غذا در یک غرفهٔ کوچکِ مستقل (کانتینر) پخته می‌شود که فقط همان یک کار را بلد است. مدیریتِ کلِ فودکورت — برق، تمیزی، جای پارک، جابه‌جا کردن غرفه‌ها وقتی یکی خراب می‌شود — کارِ مدیریتِ مرکز خرید (Kubernetes) است، نه کار خودِ غرفه. غرفه سبک است، سریع باز و بسته می‌شود، و اگر یکی آتش گرفت بقیه سالم‌اند.

هیچ‌کدام «بد» نیستند؛ هر کدام برای زمانهٔ خودش ساخته شدند. مهندسِ خوب می‌داند کِی کدام.

فرقِ بنیادی این است: در مدل app server، پلتفرم داخلِ برنامه است (سرور همه‌چیز را فراهم می‌کند و کد تو مهمانِ اوست). در مدل cloud-native، برنامه یک process ساده است و پلتفرم بیرونِ آن است (Kubernetes دورِ process تو حصار می‌کشد). این وارونگیِ مسئولیت، کلِ داستان است.


۱. اصل ۱۲-Factor: قانونِ اساسیِ سرویسِ cloud-native

قبل از هر ابزاری، یک طرزِ فکر لازم است. مانیفستِ The Twelve-Factor App (که مهندسان Heroku نوشتند) دوازده اصل دارد که هنوز بعد از یک دهه، محکِ خوبی برای «آیا این سرویس آمادهٔ کانتینر هست یا نه» است. مهم‌ترین‌هایش برای ما:

  • Config در environment، نه در کد. رمز دیتابیس، آدرس سرویسِ دیگر، feature flag — هیچ‌کدام نباید داخل jar باشند. باید از environment variable یا فایلِ mount‌شده بیایند. چرا؟ چون یک artifact واحد باید بدون rebuild در dev و staging و prod اجرا شود.
  • Processes بی‌حالت (stateless). هیچ چیزِ مهمی نباید در حافظهٔ محلی یا دیسکِ محلیِ process بماند؛ چون آن process هر لحظه می‌تواند کشته شود. state می‌رود به دیتابیس، Redis، یا object storage.
  • Port binding. سرویس خودش HTTP را serve می‌کند (Spring Boot با Tomcat جاسازی‌شده روی یک پورت می‌نشیند) — نیازی به deploy کردن داخلِ یک app serverِ بیرونی نیست.
  • Disposability. سریع بالا بیا، سریع و تمیز خاموش شو (graceful shutdown). این دقیقاً همان چیزی است که Kubernetes موقع rollout به آن تکیه می‌کند.
  • Logs به‌عنوان event stream. لاگ را در stdout بریز، نه در فایل. جمع‌آوری و چرخاندنِ لاگ کارِ پلتفرم است.
قضاوتِ سنیور: ۱۲-factor یک چک‌لیستِ آماده‌سازی است، نه فلسفه

وقتی به یک سرویس نگاه می‌کنی و می‌خواهی بفهمی «آیا این را می‌شود راحت کانتینری کرد؟»، ذهنی از روی ۱۲-factor رد شو. سرویسی که به دیسکِ محلی می‌نویسد، config را از فایلِ application.properties داخلِ jar می‌خواند، یا برای بالا آمدن ۳ دقیقه cache گرم می‌کند — این‌ها پرچم‌های قرمزِ مهاجرت‌اند. اغلب سختیِ واقعیِ «بردن یک برنامهٔ قدیمی به Kubernetes» نه در نوشتن YAML، بلکه در تمیز کردنِ همین نقض‌های ۱۲-factor است.

سؤال: چرا stateless بودنِ سرویس برای Kubernetes حیاتی است؟

جواب: چون Kubernetes پادها (podها) را مثل دامِ گله می‌بیند نه حیوانِ خانگی — هر لحظه می‌تواند یکی را بکشد و جای دیگری بسازد (rollout، scale-down، جابه‌جایی node، preemption). اگر state مهمی در حافظه یا دیسکِ محلیِ پاد باشد، با مرگِ پاد از دست می‌رود. بنابراین state باید بیرون از پاد نگه‌داری شود: session در Redis، دادهٔ کسب‌وکار در دیتابیس، فایل در object storage. سرویس stateless را می‌توان بی‌درد scale، restart و جابه‌جا کرد؛ این آزادی دقیقاً همان چیزی است که elasticity و self-healing کوبرنتیز را ممکن می‌کند. (نکتهٔ سنیور: برای stateful واقعی مثل خودِ دیتابیس، StatefulSet و PersistentVolume هست، اما سرویسِ برنامه را تا جای ممکن stateless نگه دار.)


۲. کانتینری کردن Spring Boot

۲.۱ کانتینر یعنی چه (از صفر)

یک image یک بستهٔ فقط‌خواندنی است شاملِ فایل‌سیستم + دستورِ اجرا. یک container نمونهٔ در حالِ اجرای آن image است — یک process روی هستِ لینوکس که با namespaceها (جداسازیِ دید) و cgroupها (محدودسازیِ منابع) طوری محصور شده که فکر می‌کند تنها ماشین است. کانتینر یک ماشین مجازی نیست؛ kernelِ هست را share می‌کند و برای همین سبک و سریع است.

۲.۲ اشتباهِ کلاسیک: fat jar را مستقیم COPY نکن

اولین Dockerfileِ همه چیزی شبیهِ این است:

FROM eclipse-temurin:21-jre
COPY target/app.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]

کار می‌کند، اما ناکاراست. Spring Boot یک fat jar (uber jar) می‌سازد که همه‌چیز — کدِ خودت + ده‌ها مگابایت وابستگی — در یک فایل است. با هر تغییرِ کوچکِ کد، کلِ آن لایه دوباره ساخته و push و pull می‌شود؛ حتی اگر وابستگی‌ها ذره‌ای تغییر نکرده باشند. در یک تیم با ده‌ها deploy در روز، این یعنی هدررفتِ پهنای باند و کندیِ CI.

۲.۳ راهِ درست ۱: layered jar

Spring Boot می‌تواند jar را به «لایه‌ها»یی برش بزند که بر اساسِ نرخِ تغییر مرتب شده‌اند: وابستگی‌ها (کم‌تغییر) در یک لایه، snapshotها در لایه‌ای دیگر، و کدِ خودت (پرتغییر) در آخرین لایه. ابزارِ layertools این را از دلِ jar بیرون می‌کشد و در Dockerfileِ چندمرحله‌ای، هر لایه یک COPY جداگانه می‌شود — پس Docker می‌تواند لایه‌های کم‌تغییر را cache کند:

# ---- مرحلهٔ ۱: استخراج لایه‌ها ----
FROM eclipse-temurin:21-jre AS builder
WORKDIR /app
COPY target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted

# ---- مرحلهٔ ۲: image نهایی ----
FROM eclipse-temurin:21-jre
WORKDIR /app
# لایه‌ها از کم‌تغییر به پرتغییر؛ ترتیب برای cache مهم است
COPY --from=builder /app/extracted/dependencies/ ./
COPY --from=builder /app/extracted/spring-boot-loader/ ./
COPY --from=builder /app/extracted/snapshot-dependencies/ ./
COPY --from=builder /app/extracted/application/ ./
ENTRYPOINT ["java", "-jar", "app.jar"]
نکتهٔ نسخه: پرچمِ jarmode تغییر کرده

در Spring Boot نسخهٔ ۳٫۳ به بعد (و پایدار در خطِ ۳٫۵ و ۴٫x امروز)، دستورِ استخراجِ لایه‌ها -Djarmode=tools ... extract --layers است. در نسخه‌های قدیمی‌تر -Djarmode=layertools extract بود. اگر با پروژهٔ قدیمی کار می‌کنی، مستنداتِ همان نسخه را چک کن.

۲.۴ راهِ درست ۲: buildpacks (بدون Dockerfile)

اگر اصلاً نمی‌خواهی Dockerfile نگه‌داری کنی، Spring Boot به‌صورت توکار از Cloud Native Buildpacks (پیاده‌سازیِ Paketo) پشتیبانی می‌کند. یک دستور، بدون هیچ Dockerfile:

# Maven
./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=myorg/order-service:1.4.0

# Gradle
./gradlew bootBuildImage --imageName=myorg/order-service:1.4.0

buildpack خودش JDK مناسب را انتخاب می‌کند، layered jar را می‌فهمد، image بهینه و OCI-استاندارد می‌سازد، و حتی SBOM (فهرست مواد نرم‌افزاری) تولید می‌کند. تنظیمِ نسخهٔ جاوا با متغیرِ محیطیِ buildpack انجام می‌شود:

./gradlew bootBuildImage \
  --imageName=myorg/order-service:1.4.0 \
  --builder=paketobuildpacks/builder-jammy-base
# نسخهٔ JVM را می‌شود با env تنظیم کرد: BP_JVM_VERSION=21
قضاوتِ سنیور: Dockerfile یا buildpack؟
  • buildpack را وقتی انتخاب کن که می‌خواهی ده‌ها سرویسِ Spring Boot، یکنواخت و امن و بدون اینکه هر تیم Dockerfileِ خودش را خراب کند، build شوند. مزیتِ بزرگ: patchِ امنیتیِ base image به‌صورت متمرکز و بدون دست‌زدن به کدِ سرویس.
  • Dockerfile چندمرحله‌ای را وقتی انتخاب کن که کنترلِ دقیق می‌خواهی: base imageِ سفارشی (مثلاً distroless برای سطحِ حملهٔ کمتر)، ابزارِ خاص، یا لایه‌بندیِ ویژه. یک الگوی بالغ در سازمان‌ها: یک buildpack یا base imageِ «طلایی» که تیمِ platform نگه می‌دارد و همه از آن ارث می‌برند.

۲.۵ تلهٔ بزرگ: JVM داخلِ کانتینر

پیش از JDK مدرن، JVM حافظه و CPU را اشتباه می‌دید

سال‌ها یک باگِ کلاسیک بود: JVM محدودیت‌های cgroupِ کانتینر را نمی‌دید و فکر می‌کرد کلِ حافظه و همهٔ هستهٔ ماشینِ فیزیکی مالِ اوست. نتیجه؟ heap را خیلی بزرگ می‌گرفت و کانتینر با خطای OOMKilled (کدِ خروجِ ۱۳۷) توسطِ kernel کشته می‌شد — بدون هیچ OutOfMemoryError تمیزی در لاگ. این آدم‌ها را دیوانه می‌کرد.

از JDK 10 به بعد (و backport‌شده به 8u191) JVM آگاه به cgroup است و به‌صورت پیش‌فرض MaxRAMPercentage را رعایت می‌کند. اما پیش‌فرضِ آن ۲۵٪ است که اغلب برای کانتینرِ کوچک کم است. در production معمولاً صریح تنظیمش می‌کنند:

JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=50.0"

و حواست باشد: heap تنها بخشی از حافظهٔ JVM است. metaspace، thread stackها، JIT code cache و بافرهای مستقیم هم فضا می‌خورند. اگر limitِ کانتینر را دقیقاً برابرِ heap بگذاری، باز OOMKilled می‌خوری. همیشه سرِ حافظه به‌اندازهٔ non-heap جا بگذار.

نکتهٔ CPU هم مهم است: JVM تعدادِ threadهای GC، سایزِ استخرِ ForkJoin و … را از availableProcessors() می‌گیرد. اگر CPU limit کانتینر را روی مثلاً ۵۰۰m (نصفِ یک هسته) بگذاری، در JDKهای مدرن availableProcessors() این را درست گرد می‌کند، اما در تنظیماتِ مرزی همچنان باید تست کنی. زیرِ حدِ یک هسته کامل نگذار مگر اینکه سرویس واقعاً کم‌بار باشد.

سؤال: کانتینرِ سرویس Spring Boot با کدِ ۱۳۷ می‌میرد بدون هیچ خطای جاوا در لاگ. تشخیصت؟

جواب: کدِ خروجِ ۱۳۷ یعنی process با سیگنالِ ۹ (SIGKILL) کشته شده — تقریباً همیشه OOMKilled توسطِ kernel به‌خاطرِ عبور از memory limit کانتینر، نه یک OutOfMemoryError داخلِ JVM. چون kernel process را ناگهانی می‌کشد، هیچ stack trace تمیزی در لاگِ برنامه نمی‌بینی. چک‌لیستِ من: (۱) kubectl describe pod و دیدنِ Reason: OOMKilled در آخرین state. (۲) بررسیِ نسبتِ memory limit به تنظیماتِ heap — آیا MaxRAMPercentage + non-heap از limit عبور می‌کند؟ (۳) نگاه به native memory با -XX:NativeMemoryTracking یا metrics. (۴) بررسیِ نشتِ direct buffer یا metaspace. راهِ حل معمولاً یا بالا بردنِ limit یا پایین آوردنِ MaxRAMPercentage و گذاشتنِ headroom برای non-heap است.


۳. مبانی Kubernetes برای یک سرویس

Kubernetes (یا k8s) یک سیستم‌عاملِ توزیع‌شده برای کانتینرها است. تو به آن حالتِ مطلوب را می‌گویی («می‌خواهم ۴ نسخه از این image همیشه سالم اجرا شوند») و او با یک حلقهٔ کنترلِ بی‌پایان (reconciliation loop) واقعیت را به آن حالت نزدیک نگه می‌دارد. اگر یک پاد بمیرد، خودش یکی جدید می‌سازد؛ این همان self-healing است.

۳.۱ اجزای بنیادی

  • Pod: کوچک‌ترین واحدِ استقرار؛ یک یا چند کانتینر که شبکه و storage را share می‌کنند. معمولاً یک کانتینرِ اصلی (سرویس تو) + شاید یک sidecar.
  • Deployment: مدیریتِ مجموعه‌ای از پادهای یکسان و بی‌حالت — تعداد replica، استراتژیِ به‌روزرسانی، و rollback.
  • Service: یک نامِ پایدار و یک IP مجازی برای دسترسی به آن پادها (که IPشان دائم عوض می‌شود). load-balancing داخلی روی پادهای سالم انجام می‌شود.
  • Ingress (یا Gateway API): دروازهٔ ورودی از دنیای بیرون؛ مسیریابیِ HTTP بر اساسِ host/path به Serviceهای داخلی + مدیریتِ TLS.
Service مثل شمارهٔ داخلیِ سازمان است

پادها مثل کارمندهایی‌اند که مدام میز و اتاقشان عوض می‌شود (IP عوض می‌شود، پاد می‌میرد و نو می‌شود). اگر بخواهی مستقیم با IPِ یک پاد حرف بزنی، فردا آن آدرس بی‌معناست. Service مثل یک شمارهٔ داخلیِ ثابت است: تو با «داخلیِ ۲۰۴» تماس می‌گیری و مرکزِ تلفن (kube-proxy) تو را به یکی از کارمندهای سالمِ آن دپارتمان وصل می‌کند، فارغ از اینکه امروز کجا نشسته.

نمودارِ توپولوژیِ یک سرویسِ نوعی روی k8s (فارسی: از کاربر تا دیتابیس؛ English: from user to database):

flowchart LR
  User([User]) --> Ingress[Ingress / Gateway]
  Ingress --> Svc[Service: order-svc]
  Svc --> P1[Pod 1]
  Svc --> P2[Pod 2]
  Svc --> P3[Pod 3]
  P1 --> DB[(PostgreSQL)]
  P2 --> DB
  P3 --> DB
  P1 -. reads .-> CM[ConfigMap]
  P1 -. reads .-> SEC[Secret]

۳.۲ یک Deployment واقعی

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  replicas: 3
  selector:
    matchLabels: { app: order-service }
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0     # هیچ ظرفیتی حین rollout کم نشود
      maxSurge: 1           # یکی بیشتر موقتاً بالا بیاید
  template:
    metadata:
      labels: { app: order-service }
    spec:
      containers:
        - name: app
          image: myorg/order-service:1.4.0
          ports:
            - containerPort: 8080
          resources:
            requests: { cpu: "500m", memory: "512Mi" }
            limits:   { memory: "768Mi" }   # روی CPU limit نگذار (پایین‌تر توضیح)
          envFrom:
            - configMapRef: { name: order-config }
            - secretRef:    { name: order-secrets }
روی CPU معمولاً limit نگذار؛ روی memory حتماً بگذار

یک اشتباهِ رایج: گذاشتنِ CPU limit سفت‌وسخت. Kubernetes با cgroup این را به‌صورتِ CPU throttling اعمال می‌کند — سرویس در لحظاتِ اوج مصنوعاً کند می‌شود، latencyها به‌طرزِ مرموزی بالا می‌روند، حتی اگر node بیکار باشد. توصیهٔ رایجِ امروز: روی CPU فقط request بگذار (برای scheduling و سهمِ منصفانه) و limit را حذف کن. اما روی memory حتماً limit بگذار، چون حافظه فشرده‌پذیر نیست و یک پادِ نشت‌کننده می‌تواند کلِ node را ببلعد. memory request را هم برابرِ limit بگذار تا پاد در کلاسِ QoS پایدارِ Guaranteed قرار بگیرد و آخرین قربانیِ eviction باشد.

۳.۳ Probeها: مغزِ self-healing، و جایی که Actuator می‌درخشد

Kubernetes سه نوع probe دارد و فرقشان سؤالِ کلاسیکِ مصاحبه است:

Probe سؤالی که می‌پرسد اگر fail شود
startupProbe آیا بالا آمدنت تمام شد؟ صبر می‌کند؛ تا موفق نشود probeهای دیگر شروع نمی‌شوند
readinessProbe آیا الان آمادهٔ گرفتنِ ترافیک هستی؟ پاد از Service خارج می‌شود (ترافیک قطع، اما پاد نمی‌میرد)
livenessProbe آیا زنده‌ای یا گیر کرده‌ای؟ پاد restart می‌شود

Spring Boot Actuator دقیقاً برای این ساخته شده و endpointهای جدا برای liveness و readiness دارد:

# application.yml
management:
  endpoint:
    health:
      probes:
        enabled: true       # فعال‌سازی /actuator/health/liveness و /readiness
  health:
    livenessstate:
      enabled: true
    readinessstate:
      enabled: true
# در Deployment
          startupProbe:
            httpGet: { path: /actuator/health/readiness, port: 8080 }
            failureThreshold: 30
            periodSeconds: 5      # تا ۱۵۰ ثانیه فرصتِ بالا آمدن
          readinessProbe:
            httpGet: { path: /actuator/health/readiness, port: 8080 }
            periodSeconds: 5
          livenessProbe:
            httpGet: { path: /actuator/health/liveness, port: 8080 }
            periodSeconds: 10
تفاوتِ liveness و readiness را قاطی نکن — این گران‌ترین اشتباهِ probe است

تلهٔ کلاسیک: liveness probe را طوری بنویسی که به دیتابیس هم وصل شود. حالا تصور کن دیتابیس چند دقیقه کند یا در دسترس نیست. همهٔ پادها در liveness fail می‌شوند، Kubernetes همه را هم‌زمان restart می‌کند، و تو یک قطعیِ کاملِ آبشاری داری — درست وقتی که دیتابیس داشت برمی‌گشت. قانون: liveness باید فقط بپرسد «آیا process من گیر کرده؟» (یک بررسیِ داخلیِ سبک، بدون وابستگیِ بیرونی). وابستگی‌های بیرونی (دیتابیس، صف، سرویسِ پایین‌دستی) جایِ readiness هستند — چون می‌خواهی وقتی دیتابیس قطع است، پاد فقط از ترافیک خارج شود (readiness fail)، نه اینکه بی‌رحمانه restart شود. به همین دلیل Spring Boot این دو را از هم جدا کرده.

سؤال: فرقِ liveness، readiness و startup probe؟ کدام به دیتابیس وصل شود؟

جواب: startup برای برنامه‌های کندبالا است؛ تا موفق نشود، دو probeی دیگر معلق می‌مانند تا پاد به‌خاطرِ کندیِ استارت اشتباهاً کشته نشود. readiness می‌گوید «آماده‌ام ترافیک بگیرم»؛ اگر fail شود، پاد از load balancer خارج می‌شود ولی زنده می‌ماند. liveness می‌گوید «فرایندم سالم است»؛ اگر fail شود، پاد restart می‌شود. قانونِ طلایی: وابستگی‌های بیرونی (DB، Kafka، سرویسِ دیگر) فقط باید در readiness چک شوند نه liveness. اگر آن‌ها را در liveness بگذاری، یک قطعیِ گذرای دیتابیس باعثِ restartِ هم‌زمانِ همهٔ پادها و یک outageی آبشاری می‌شود. liveness باید سبک و صرفاً داخلی باشد.

۳.۴ ConfigMap و Secret

طبقِ ۱۲-factor، config از environment می‌آید. در k8s این می‌شود ConfigMap (تنظیماتِ غیرحساس) و Secret (رمزها).

apiVersion: v1
kind: ConfigMap
metadata: { name: order-config }
data:
  SPRING_PROFILES_ACTIVE: "prod"
  SPRING_DATASOURCE_URL: "jdbc:postgresql://pg-primary:5432/orders"
---
apiVersion: v1
kind: Secret
metadata: { name: order-secrets }
type: Opaque
stringData:
  SPRING_DATASOURCE_PASSWORD: "s3cr3t-from-vault"

Spring Boot به‌صورتِ خودکار SPRING_DATASOURCE_URL را به property spring.datasource.url نگاشت می‌کند (relaxed binding). پس بدونِ یک خطِ کدِ اضافه، config از محیط تزریق می‌شود.

Secretِ خامِ k8s فقط base64 است، نه رمزنگاری

یک سوءتفاهمِ خطرناک: خیلی‌ها فکر می‌کنند Secret یعنی «رمزنگاری‌شده». نه — به‌صورتِ پیش‌فرض فقط base64-encoded در etcd ذخیره می‌شود که یعنی هرکس به etcd یا به آبجکتِ Secret دسترسی داشته باشد، متنِ خام را دارد. برای امنیتِ واقعی: (۱) encryption at rest روی etcd را فعال کن، (۲) RBAC سفت‌وسخت روی Secretها، و (۳) در عمل، اغلب سازمان‌های جدی از یک راه‌حلِ بیرونی مثل HashiCorp Vault یا external-secrets operator استفاده می‌کنند و رمز را اصلاً در etcd نگه نمی‌دارند. هرگز Secret را در git (حتی خصوصی) بدونِ رمزنگاری مثل SOPS/sealed-secrets نگذار.

۳.۵ HPA: مقیاس‌گذاریِ خودکار

Horizontal Pod Autoscaler تعدادِ replica را بر اساسِ متریک (CPU، حافظه، یا متریکِ سفارشی مثل طولِ صفِ Kafka) بالا و پایین می‌برد. API استانداردِ امروز autoscaling/v2 است (نسخهٔ v1 منسوخ و فقط تک‌متریکِ CPU بود).

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: order-service }
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: order-service
  minReplicas: 3
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70   # نسبت به CPU request
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300   # پیش‌فرض: صبرِ ۵ دقیقه پیش از کاهش
قضاوتِ سنیور: HPA بدونِ request درست کار نمی‌کند

averageUtilization: 70 یعنی «۷۰٪ از CPU requestِ پاد». اگر request نگذاشته باشی، HPA درصدی برای محاسبه ندارد و یا کار نمی‌کند یا رفتارِ عجیب دارد. دوم: HPA هر ~۱۵ ثانیه چک می‌کند، scale-up سریع اما scale-down با یک پنجرهٔ پایداریِ ۵ دقیقه‌ای (پیش‌فرض) انجام می‌شود تا از نوسانِ عصبی (flapping) جلوگیری شود. سوم: برای بارهای رویدادمحور (event-driven) مثل مصرف‌کنندهٔ Kafka، مقیاس بر اساسِ CPU بی‌معناست؛ آنجا سراغِ KEDA برو که بر اساسِ طولِ صف/lag مقیاس می‌دهد و حتی می‌تواند تا صفر scale کند.

سؤال: چرا نباید CPU limit روی سرویسِ حساس به latency گذاشت اما memory limit لازم است؟

جواب: CPU یک منبعِ فشرده‌پذیر (compressible) است: وقتی به limit برسی، Kubernetes به‌جای کشتنِ پاد، آن را throttle می‌کند. نتیجه در عمل latencyهای ناگهانی و مرموز است، حتی وقتی node ظرفیتِ آزاد دارد — چون cgroup اجازه نمی‌دهد از سهمت عبور کنی. برای همین روی سرویس‌های حساس به تأخیر معمولاً CPU limit را حذف و فقط request می‌گذارند. اما memory فشرده‌ناپذیر است: نمی‌شود «کمی کمتر حافظه داد»؛ یا هست یا نیست. بدونِ memory limit، یک پادِ نشت‌کننده می‌تواند حافظهٔ کلِ node را بخورد و باعثِ evictionِ پادهای بی‌گناهِ همسایه شود. پس memory limit یک نردهٔ محافظِ ضروری است.


۴. Service Mesh: وقتی شبکه خودش هوشمند می‌شود

۴.۱ مسئله‌ای که mesh حل می‌کند

فرض کن صد سرویس داری. می‌خواهی همهٔ ترافیقِ بینشان رمزنگاری‌شده (mTLS) باشد، همه retry و timeout و circuit breaker داشته باشند، همه متریک و trace بدهند، و بتوانی ترافیک را برای canary دقیق کنترل کنی. اگر بخواهی این‌ها را در کدِ هر سرویس بگذاری، صد بار همان منطق را (و در زبان‌های مختلف) تکرار کرده‌ای. Service mesh این نگرانی‌ها را از کد بیرون می‌کشد و به لایهٔ شبکه می‌سپارد.

mesh مثل سیستمِ اعصابِ خودکارِ بدن است

تو برای تپیدنِ قلب یا تنظیمِ فشارِ خون فکر نمی‌کنی؛ سیستمِ اعصابِ خودمختار این‌ها را زیرِ پوست انجام می‌دهد و مغزِ آگاهت آزاد می‌ماند برای تصمیم‌های مهم. service mesh همان است: mTLS، retry، مسیریابی، مشاهده‌پذیری — همه زیرِ پوستِ پلتفرم انجام می‌شوند و کدِ کسب‌وکارِ تو (مغز) فقط منطقِ دامنه را می‌نویسد.

۴.۲ دو معماری: sidecar در برابر ambient

مدل کلاسیک (sidecar): کنارِ هر پادِ سرویس، یک پراکسیِ کوچک (معمولاً Envoy) تزریق می‌شود که همهٔ ترافیقِ ورودی و خروجیِ آن پاد از آن رد می‌شود. mTLS، retry، متریک — همه در همان sidecar. قدرتمند، اما هزینه دارد: هر پاد یک کانتینرِ اضافه، مصرفِ CPU/RAM بیشتر، و تأخیرِ اضافیِ hop.

مدل جدید (ambient / بدونِ sidecar): به‌جای یک پراکسی در هر پاد، Istio در حالتِ ambient کار را به دو لایه می‌شکند:

  • ztunnel (یک agent به‌ازای هر node، نه هر پاد): لایهٔ ۴ — mTLS، هویت، تله‌متریِ پایه. سبک.
  • waypoint proxy (اختیاری، فقط وقتی سیاستِ لایهٔ ۷ لازم داری): مسیریابیِ HTTP، load-balancing هوشمند، سیاست‌های پیشرفته.
وضعیتِ امروز: ambient به بلوغ رسیده

تا نسخهٔ Istio ۱٫۲۹ (اوایل ۲۰۲۶)، حالتِ ambient به‌عنوانِ پیش‌فرضِ استقرارهای جدید توصیه می‌شود؛ mTLS را با پروتکلِ HBONE (ترکیبِ HTTP/2 + HTTP CONNECT + mTLS روی پورتِ ۱۵۰۰۸) انجام می‌دهد که برای برنامه کاملاً نامرئی است. sidecar هنوز پشتیبانی می‌شود و برای بعضی نیازهای خاص لازم است، اما مزیتِ ambient این است که با روشن/خاموش کردنِ یک برچسبِ namespace، بدونِ restart یا تزریقِ کانتینر، سرویس‌ها را وارد یا خارجِ mesh می‌کنی. Linkerd هم رقیبِ سبک‌تر و ساده‌تر است (نسخهٔ ۲٫۱۹+ در ۲۰۲۶) که با یک micro-proxyِ اختصاصیِ Rust و mTLS خودکار مبتنی بر SPIFFE کار می‌کند و به‌خاطرِ سادگی و مصرفِ کمترِ منابع محبوب است.

نمودارِ جریانِ یک درخواستِ mTLS در mesh (فارسی: رمزنگاریِ خودکار بینِ دو سرویس؛ English: automatic encryption between two services):

sequenceDiagram
  participant A as order-service
  participant PA as Proxy/ztunnel (source)
  participant PB as Proxy/ztunnel (dest)
  participant B as payment-service
  A->>PA: plain HTTP (localhost)
  PA->>PB: mTLS / HBONE (encrypted, identity-verified)
  Note over PA,PB: certificates auto-issued & rotated
  PB->>B: plain HTTP (localhost)
  B-->>PB: response
  PB-->>PA: mTLS response
  PA-->>A: plain HTTP

نکتهٔ کلیدی: کدِ order-service فکر می‌کند دارد HTTPِ ساده به localhost می‌زند. mTLS، صدورِ گواهی و چرخشِ آن، همه بیرونِ کد اتفاق می‌افتد. این همان جدایی نگرانی‌هاست.

۴.۳ مدیریتِ ترافیک: canary با چند خط YAML

قدرتِ واقعیِ mesh در کنترلِ دقیقِ ترافیک است. با Istio، دو مفهومِ اصلی: DestinationRule (تعریفِ زیرمجموعه‌ها/subsetها) و VirtualService (قانونِ تقسیمِ ترافیک):

apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata: { name: order-service }
spec:
  host: order-service
  subsets:
    - name: v1
      labels: { version: v1 }
    - name: v2
      labels: { version: v2 }
---
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata: { name: order-service }
spec:
  hosts: [ order-service ]
  http:
    - route:
        - destination: { host: order-service, subset: v1 }
          weight: 90        # ۹۰٪ ترافیک به نسخهٔ قدیمی
        - destination: { host: order-service, subset: v2 }
          weight: 10        # ۱۰٪ به نسخهٔ جدید (canary)

با تغییرِ همین وزن‌ها (10 → 30 → 60 → 100) ترافیک را به‌آرامی به نسخهٔ جدید می‌بری، و اگر متریکِ خطا بالا رفت، فوراً برش می‌گردانی — بدونِ redeploy، فقط با تغییرِ قانونِ mesh.

service mesh یک لایهٔ عملیاتیِ سنگین است؛ زودتر از نیاز واردش نشو

mesh جادو نیست؛ یک سیستمِ توزیع‌شدهٔ پیچیدهٔ دیگر است که باید نگه‌داری‌اش کنی. control plane باید ارتقا یابد، پراکسی‌ها منابع می‌خورند، دیباگِ مشکلِ شبکه سخت‌تر می‌شود («مشکل از کدِ من است یا از mesh؟»)، و یک خطای پیکربندیِ mesh می‌تواند کلِ ترافیک را قطع کند. برای یک سیستمِ ۵-۱۰ سرویسی، اغلب یک کتابخانهٔ resilience (مثل Resilience4j) در کد + TLS در ingress کافی است. mesh وقتی صرفه دارد که ده‌ها سرویس، چندزبانه، با نیازِ جدیِ mTLS صفر-اعتماد (zero-trust) و مشاهده‌پذیریِ یکنواخت داری. «اول mesh نصب کن» یک ضدالگوی رایج است.

سؤال: mTLS در service mesh چطور بدونِ تغییرِ کد کار می‌کند و چه چیزی را تضمین می‌کند؟

جواب: پراکسیِ کنارِ هر سرویس (sidecar) یا ztunnelِ هر node، ترافیقِ خروجی را رهگیری می‌کند و پیش از عبور از شبکه، آن را با گواهیِ mTLS رمزنگاری می‌کند؛ سمتِ مقصد، پراکسی آن را رمزگشایی کرده و به‌صورتِ HTTPِ ساده به سرویس تحویل می‌دهد. کدِ برنامه فقط با localhost حرف می‌زند و از این ماجرا بی‌خبر است. control plane (مثلاً istiod) گواهی‌ها را به هر workload بر اساسِ هویتِ آن (مثلاً SPIFFE ID که از ServiceAccount می‌آید) صادر و به‌طورِ خودکار می‌چرخاند. تضمین‌ها: (۱) محرمانگی — ترافیقِ بینِ سرویس‌ها روی شبکه رمزنگاری‌شده است. (۲) احرازِ هویتِ دوطرفه — هر دو طرف هویتِ رمزنگارانه دارند، پس یک سرویسِ جعلی نمی‌تواند خودش را جای دیگری بزند. (۳) پایهٔ authorization مبتنی بر هویت (سیاستِ «فقط order-service اجازه دارد payment-service را صدا کند»). این ستونِ معماریِ zero-trust است.


۵. استراتژی‌های استقرار: rolling، blue-green، canary

تعویضِ لاستیک در حالِ حرکت

هدفِ همهٔ این استراتژی‌ها یک چیز است: عوض کردنِ نسخه بدونِ اینکه کاربر «تکان» را حس کند — مثلِ تعویضِ لاستیکِ ماشین بدونِ توقف. تفاوتشان در چطور و چقدر ریسک است.

استراتژی چطور کار می‌کند مزیت هزینه/ریسک
Rolling پادها یکی‌یکی با نسخهٔ نو جایگزین می‌شوند ساده، پیش‌فرضِ k8s، بدونِ منابعِ دوبرابر دو نسخه هم‌زمان زنده‌اند (سازگاریِ DB لازم)؛ rollback کند
Blue-Green کلِ محیطِ نو (green) کنارِ قدیم (blue) بالا می‌آید، سپس ترافیک یک‌جا سوییچ می‌شود rollback فوری (فقط سوییچ برگردد)، تستِ کامل پیش از سوییچ دو برابر منابع لحظه‌ای؛ مهاجرتِ DB باید backward-compatible باشد
Canary نسخهٔ نو ابتدا فقط بخشِ کوچکی از ترافیک را می‌گیرد، تدریجی زیاد می‌شود کمترین شعاعِ انفجار، تصمیم بر پایهٔ متریکِ واقعی نیاز به ابزارِ کنترلِ ترافیک (mesh/Argo Rollouts) و متریکِ خوب

نمودارِ ماشینِ حالتِ یک canary (فارسی: افزایشِ تدریجیِ ترافیک با دروازهٔ متریک؛ English: gradual traffic ramp with metric gate):

stateDiagram-v2
  [*] --> Deploy_v2_10pct
  Deploy_v2_10pct --> Analyze: watch error rate & latency
  Analyze --> Ramp_50pct: metrics healthy
  Analyze --> Rollback: metrics bad
  Ramp_50pct --> Analyze2: watch again
  Analyze2 --> Promote_100pct: healthy
  Analyze2 --> Rollback: bad
  Promote_100pct --> [*]
  Rollback --> [*]
rolling update، سازگاریِ رو-به-عقبِ اسکیمای دیتابیس را *الزامی* می‌کند

این جایی است که خیلی‌ها می‌سوزند. در rolling update، برای مدتی نسخهٔ قدیم و نسخهٔ جدیدِ کد هم‌زمان روی یک دیتابیس کار می‌کنند. اگر migrationِ نسخهٔ جدید یک ستون را حذف یا rename کند، نسخهٔ قدیم که هنوز زنده است می‌شکند. الگوی امن expand/contract است: اول فقط اضافه کن (ستونِ جدید nullable)، هر دو نسخه با آن سازگار باشند، بعد از اینکه نسخهٔ قدیم کاملاً رفت، در یک deployِ بعدی پاک کن. هیچ‌وقت schema-change شکننده را با کدی که آن را لازم دارد در یک rollout ادغام نکن. همین اصل blue-green را هم امن می‌کند.

سؤال: blue-green و canary چه فرقی دارند و کِی کدام را انتخاب می‌کنی؟

جواب: در blue-green دو محیطِ کاملِ موازی داری و ترافیک را در یک لحظه از قدیم (blue) به جدید (green) سوییچ می‌کنی؛ مزیتش rollback آنی است (سوییچ را برگردان) و اینکه green را کامل تست می‌کنی پیش از اینکه حتی یک کاربر ببیندش. عیبش دو برابر منابع و اینکه در لحظهٔ سوییچ، همهٔ کاربران یک‌جا نسخهٔ نو را می‌گیرند — اگر باگی باشد که تست نگرفته، همه‌ی ترافیک را زده. در canary نسخهٔ نو را تدریجی (۱٪، ۱۰٪، ۵۰٪…) در معرضِ ترافیقِ واقعی می‌گذاری و بر اساسِ متریک تصمیم می‌گیری؛ شعاعِ انفجار حداقلی است چون اگر خراب باشد فقط درصدِ کوچکی آسیب می‌بینند. canary را وقتی انتخاب می‌کنم که ترافیقِ واقعیِ کافی برای قضاوتِ آماری دارم و ابزارِ کنترلِ ترافیک (mesh یا Argo Rollouts) و متریکِ خوب موجود است؛ blue-green را برای تغییراتِ درشت یا وقتی که تفکیکِ ترافیک سخت است و rollback آنی مهم‌ترین نیاز است.


۶. نیمهٔ دیگرِ دنیا: سرورهای legacy جاوا EE

حالا از فودکورتِ براق برمی‌گردیم به رستورانِ باشکوهِ قدیمی. چون واقعیت این است: بخشِ بزرگی از منطقِ کسب‌وکارِ کره‌ی زمین هنوز روی همین‌ها می‌چرخد — بانک، بیمه، مخابرات، دولت. اگر سنیور می‌شوی، دیر یا زود با WebLogic یا WebSphere یا JBoss روبه‌رو خواهی شد. و ندانستنِ این‌ها یعنی در آن جلسه لال بمانی.

۶.۱ ایدهٔ محوریِ app server

یک application server یک runtimeِ سنگین است که مجموعه‌ای از سرویس‌های سازمانی را به‌صورتِ توکار فراهم می‌کند و برنامه‌های تو (به شکلِ فایلِ WAR یا EAR) را داخلِ خودش deploy و مدیریت می‌کند. این سرویس‌ها همان‌هایی‌اند که در دنیای Spring Boot به‌صورتِ کتابخانه به process تزریق می‌شوند:

  • JNDI — یک دفترچهٔ تلفنِ نام‌گذاری برای پیدا کردنِ منابع (datasource، صف، bean) با نام.
  • Managed datasource + connection pool — سرور استخرِ اتصالِ دیتابیس را می‌سازد و مدیریت می‌کند.
  • EJB — کامپوننت‌های تراکنشیِ سمتِ سرور.
  • JMS / MDB — پیام‌رسانی و مصرف‌کننده‌های پیام‌محورِ مدیریت‌شده.
  • JTA — تراکنشِ توزیع‌شدهٔ دومرحله‌ای (XA) بین چند منبع.
  • Clustering / HA / session replication — چند نمونهٔ سرور که با هم یک خوشه می‌سازند و state را تکرار می‌کنند.
app server مثل یک هتلِ تمام‌خدمات است

در یک هتلِ پنج‌ستاره تو فقط چمدانت (WAR) را می‌آوری؛ اتاق، تختِ مرتب، روم‌سرویس، لاندری، پارکینگ، امنیت — همه از قبل هست و مدیریتِ هتل انجامش می‌دهد. تو فقط «ساکن» می‌شوی. Spring Boot برعکس مثل یک آپارتمانِ خودگردان است: هر چه لازم داری را خودت می‌آوری (embedded Tomcat، connection pool، …) اما در عوض کاملاً مالکِ محیطِ خودتی و به مدیریتِ هتل وابسته نیستی.

۶.۲ JNDI: دفترچهٔ تلفنِ سرور

JNDI (Java Naming and Directory Interface) یک API برای «نام → شیء» است. به‌جای اینکه در کد رمزِ دیتابیس را hard-code کنی، به سرور می‌گویی «یک datasource با نامِ jdbc/OrdersDS بساز» و در کد فقط با نام صدایش می‌زنی:

// سبکِ کلاسیکِ JNDI lookup (Java EE)
InitialContext ctx = new InitialContext();
DataSource ds = (DataSource) ctx.lookup("java:comp/env/jdbc/OrdersDS");
try (Connection c = ds.getConnection()) {
    // ... استفاده از اتصال از استخرِ مدیریت‌شدهٔ سرور
}

فایده‌اش: config (آدرس، رمز، سایزِ pool) در کنسولِ سرور است نه در artifact. همان WAR بدونِ تغییر در dev و prod اجرا می‌شود، چون هر سرور JNDIِ خودش را دارد. (این دقیقاً همان ایدهٔ ۱۲-factor است، فقط بیست سال زودتر و به‌شکلِ سرورمحور.) در Spring Boot معادلِ این، تعریفِ spring.datasource.* از environment است.

۶.۳ Managed datasource و connection pool

باز کردنِ یک اتصالِ TCP به دیتابیس گران است (چند round-trip، احرازِ هویت). connection pool مجموعه‌ای از اتصال‌های ازپیش‌بازشده را نگه می‌دارد و به درخواست‌ها قرض می‌دهد. در app server این pool مدیریت‌شده است و در کنسول تنظیم می‌شود: حداقل/حداکثر اندازه، timeout، و مهم‌تر از همه query اعتبارسنجی که سلامتِ اتصال را قبلِ استفاده چک می‌کند.

اینجا یک تفاوتِ دوگویشیِ SQL که در تنظیمِ همین pool مهم می‌شود — queryِ اعتبارسنجیِ اتصال:

-- PostgreSQL: جدولِ ساختگی لازم نیست
SELECT 1;
-- Oracle: باید از جدولِ سیستمیِ DUAL بخوانی
SELECT 1 FROM DUAL;
نکتهٔ دوگویش: در درایورِ مدرن، بهتر است اصلاً validation query ندهی

در Oracle هر SELECT باید یک FROM داشته باشد، و DUAL یک جدولِ تک‌ردیفهٔ سیستمی است که دقیقاً برای همین «SELECTِ بدونِ جدولِ واقعی» وجود دارد؛ در PostgreSQL نوشتنِ SELECT 1; بدونِ FROM کاملاً مجاز است. اما نکتهٔ سنیورِ امروزی: استخرهای مدرن مثل HikariCP (پیش‌فرضِ Spring Boot) توصیه می‌کنند به‌جای validationQuery از Connection.isValid() جدید JDBC 4 استفاده کنی (یعنی validationQuery را خالی بگذاری)، چون سریع‌تر و مستقل از گویش است. اگر با app serverِ قدیمی و درایورِ قدیمی کار می‌کنی که isValid ندارد، آن وقت گویشِ درست از دو مورد بالا لازم می‌شود.

سؤال: managed datasource در app server چه مزیتی نسبت به datasource درون‌برنامه‌ای دارد و چرا امروز کمتر استفاده می‌شود؟

جواب: مزیتِ تاریخی: جدایی config از کد و مدیریتِ متمرکز. اپراتورِ سرور می‌توانست سایزِ pool، رمز و آدرس را در کنسول عوض کند بدونِ rebuild برنامه، و چند برنامهٔ deploy‌شده روی یک سرور می‌توانستند یک datasource را share کنند. همچنین datasourceهای XA برای تراکنشِ توزیع‌شده به‌صورتِ توکار پشتیبانی می‌شدند. چرا امروز کمتر؟ چون در مدلِ cloud-native همان جداییِ config را با environment variable و ConfigMap/Secret می‌گیریم، pool را با HikariCP داخلِ خودِ process و نزدیک به کد کنترل می‌کنیم (تنظیم و مانیتورینگِ ساده‌تر)، و هر سرویس دیتابیسِ خودش را دارد پس share کردنِ datasource دیگر یک هدف نیست. مدلِ managed برای دنیایی طراحی شده بود که چند برنامه یک سرورِ گران را share می‌کردند؛ آن مفروضات دیگر برقرار نیست.

۶.۴ EJB، JMS و MDB

EJB (Enterprise JavaBeans) کامپوننت‌های سمتِ سرور بودند که سرور برایشان تراکنش، امنیت، pooling و concurrency را خودکار مدیریت می‌کرد. یک Stateless Session Bean ساده:

@Stateless
public class OrderServiceBean {
    @PersistenceContext
    private EntityManager em;

    @TransactionAttribute(TransactionAttributeType.REQUIRED)
    public void placeOrder(Order o) {
        em.persist(o);      // تراکنش را کانتینر مدیریت می‌کند (CMT)
    }
}

آن @TransactionAttribute یعنی «کانتینر خودش تراکنش را باز و commit/rollback کن» — همان Container-Managed Transactions. این دقیقاً همان کاری است که @Transactional در Spring انجام می‌دهد؛ Spring این ایده را وام گرفت و ساده‌تر کرد.

JMS استانداردِ صف/پیام است و MDB (Message-Driven Bean) یک bean است که به‌صورتِ خودکار پیام‌های یک صف را مصرف می‌کند — نیای مفهومیِ @KafkaListener امروز:

@MessageDriven(activationConfig = {
  @ActivationConfigProperty(propertyName = "destination",
                            propertyValue = "jms/OrderQueue")
})
public class OrderConsumer implements MessageListener {
    public void onMessage(Message msg) {
        // پردازشِ پیام؛ تراکنش و ترد را کانتینر می‌دهد
    }
}
قضاوتِ سنیور: مفاهیمِ EJB نمردند، فقط جای‌ عوض کردند

وقتی به این کد نگاه می‌کنی نباید فکر کنی «چیزِ منسوخ». باید ببینی که Spring دقیقاً همین ایده‌ها را برداشت و سبک‌تر کرد: EJB stateless → Spring @Service؛ CMT (@TransactionAttribute) → @Transactional؛ MDB → @JmsListener/@KafkaListener؛ JNDI datasource → spring.datasource. اگر معماریِ EJB را بفهمی، در واقع «چرا Spring این‌طور طراحی شده» را عمیق‌تر می‌فهمی. در مصاحبه، ربط دادنِ این نسل‌ها به هم نشانهٔ بلوغ است.

۶.۵ مدلِ classloading سلسله‌مراتبی — منبعِ درد

"ClassNotFoundException"ای که در Tomcat هست ولی در تستِ محلی نیست

معماریِ classloading در app serverها سلسله‌مراتبی و ایزوله است و منبعِ کلاسیکِ رنج. برخلافِ یک برنامهٔ ساده که یک classpath صاف دارد، سرور چند classloader تودرتو دارد: bootstrap → system/server → shared → و بعد یک classloader مجزا برای هر WAR. هدف: ایزوله کردنِ برنامه‌ها تا دو WAR بتوانند نسخه‌های متفاوتِ یک کتابخانه داشته باشند.

اما این باعثِ دو دستهٔ مشکلِ کابوس‌وار می‌شود: (۱) تعارضِ نسخه — سرور خودش نسخهٔ قدیمِ یک کتابخانه (مثلاً یک XML parser یا logging) را در classloaderِ والد دارد و کلاسِ آن به کلاسِ تو ترجیح داده می‌شود، پس کدت با نسخهٔ اشتباهی اجرا می‌شود. علامتش: NoSuchMethodError یا رفتارِ عجیب که فقط روی سرور رخ می‌دهد. راهِ حل معمولاً تنظیمِ سیاستِ classloading به «child-first» یا «parent-last» است. (۲) نشتِ classloader — موقعِ redeployِ داغِ برنامه، اگر یک ThreadLocal یا thread یا driverِ JDBC به classloaderِ قدیمی رفرنس نگه دارد، آن classloader جمع نمی‌شود و بعد از چند redeploy، PermGen/Metaspace OutOfMemoryError می‌گیری. برای همین در محیطِ سنتی، بسیاری تیم‌ها اصلاً hot-redeploy را در prod ممنوع می‌کردند و به‌جایش سرور را کامل restart می‌کردند.

Spring Boot این پیچیدگی را با یک فلسفهٔ متفاوت حذف می‌کند: یک process، یک classloaderِ (تقریباً) صاف، یک برنامه. هیچ ایزولاسیونِ چند-WAR و هیچ classloaderِ به‌اشتراک‌گذاشته‌ای نیست؛ به‌جای «چند برنامه در یک سرور»، «یک برنامه در یک کانتینر» داریم. کلِ طبقهٔ مشکلاتِ classloader ناپدید می‌شود.

۶.۶ Clustering، HA و session replication

app serverها می‌توانند cluster بسازند: چند نمونهٔ سرور که با هم کار می‌کنند. مهم‌ترین قابلیتِ کلاسیک، HTTP session replication است: اگر کاربر روی سرورِ ۱ لاگین کرده و state در HttpSession نشسته، سرور آن session را به سرورِ ۲ تکرار می‌کند، پس اگر سرورِ ۱ بمیرد، کاربر بدونِ خروج از حساب به سرورِ ۲ منتقل می‌شود (failover). این در دورانی که «چسبِ session» (sticky session) و state در حافظه عادی بود، حیاتی بود.

قضاوتِ سنیور: cloud-native این مسئله را «حذف به‌جای حل» می‌کند

جالبی اینجاست: دنیای مدرن راهِ session replication را دنبال نمی‌کند، بلکه صورتِ مسئله را پاک می‌کند. طبقِ ۱۲-factor، سرویس stateless است و HttpSession در حافظه نگه‌داری نمی‌شود؛ state به یک storeِ بیرونی (Redis با Spring Session، یا یک JWTِ بی‌حالت) می‌رود. حالا هر پاد می‌تواند هر درخواست را جواب دهد، sticky session لازم نیست، و مرگِ یک پاد هیچ session را نمی‌کشد. پس به‌جای مکانیزمِ پیچیده و شبکه‌محورِ replication، با یک تصمیمِ معماری (state را بیرون بگذار) کلِ نیاز از بین می‌رود. این تفاوتِ طرزِ فکرِ دو نسل است: legacy مسئله را با زیرساختِ باهوش‌تر حل می‌کند؛ cloud-native با معماریِ ساده‌تر آن را منتفی می‌کند.

۶.۷ راهنمای بازار: کدام سرور، کجا، در چه حالی

سرور نوع مالک/جامعه وضعیتِ ۲۰۲۶ کجا می‌بینی‌اش
Tomcat servlet container Apache Tomcat 11 (Servlet 6.1، Jakarta EE 11، Java 17+) همه‌جا؛ همان چیزی که در Spring Boot embedded است
Jetty servlet container Eclipse Jetty 12 (تا Servlet 6.0، Java 17+) embedded، سبک، ابزارها و پراکسی‌ها
WildFly full Java EE (JBoss) Red Hat / community WildFly 41؛ از نسخهٔ 40 روی Jakarta EE 11 نسخهٔ open-source؛ ابرِ Red Hat/OpenShift
JBoss EAP full Java EE Red Hat (تجاری) مبتنی بر WildFly، با پشتیبانی سازمان‌های Red Hat
WebLogic full Java EE Oracle 14c (14.1.2)، Jakarta EE 8، Java 17/21 بانک، مخابرات، هرجا Oracle DB سنگین است
WebSphere (traditional) full Java EE IBM WAS 9.x؛ سنگین، سنتی سازمان‌های بزرگِ قدیمیِ IBM‌محور
WebSphere Liberty سبک، مدرن، ماژولار IBM نسخهٔ پیوسته (۲۶.۰.۰.x)، تا Java 26 مسیرِ مدرن‌سازیِ مشتریانِ IBM
تفاوتِ کلیدی: servlet container در برابر full Java/Jakarta EE server

Tomcat و Jetty فقط servlet container‌اند: HTTP، Servlet، JSP، WebSocket را می‌دهند، اما EJB، JMS، JTA (تراکنشِ توزیع‌شدهٔ XA)، CDI کاملِ سازمانی را به‌صورتِ توکار ندارند. برای همین سبک و سریع‌اند و دقیقاً به همین دلیل Spring Boot از Tomcat به‌عنوانِ embedded server استفاده می‌کند (تو خودت هر چه از EE لازم داری را با کتابخانه اضافه می‌کنی). WildFly/WebLogic/WebSphere سرورهای full profile هستند و همهٔ آن سرویس‌های سنگینِ سازمانی را دارند. یک سوءتفاهمِ رایج در مصاحبه: گفتن «Tomcat یک application server است». دقیق‌تر: Tomcat یک web/servlet container است، نه یک full Java EE application server.

سؤال: تفاوتِ Tomcat با WebLogic/WildFly چیست؟ چرا Spring Boot سراغِ embedded Tomcat رفت نه یک full server؟

جواب: Tomcat یک servlet container است: پشته‌ی HTTP/Servlet/JSP/WebSocket را می‌دهد اما سرویس‌های سنگینِ Jakarta EE مثل EJB، JTA برای تراکنشِ توزیع‌شدهٔ XA، JMS provider و CDI کاملِ سازمانی را به‌صورتِ توکار ندارد. WebLogic/WildFly سرورهای full-profile هستند و همهٔ این‌ها را دارند. Spring Boot عمداً embedded Tomcat را انتخاب کرد چون فلسفه‌اش وارونهٔ مدلِ app server است: به‌جای اینکه برنامه را در یک سرورِ سنگینِ بیرونی deploy کنی، سرور را به‌شکلِ کتابخانه داخلِ برنامه می‌آوری و یک jar خوداتکا می‌سازی. این عالی با کانتینر می‌خواند (یک process، port binding، استارتِ سریع، بدونِ نصبِ سرورِ جدا)، اکثرِ برنامه‌ها به قابلیت‌های سنگینِ full EE نیاز ندارند، و هر چه لازم باشد (تراکنش با @Transactional، پیام با Kafka/JMS client) به‌صورتِ کتابخانه اضافه می‌شود. نتیجه: سبک‌تر، قابل‌حمل‌تر، و سازگار با ۱۲-factor.


۷. مسیرِ مهاجرت: از app server به Spring Boot

فرض کن یک EAR ده‌ساله روی WebLogic داری و باید مدرنش کنی. رویکردِ «بازنویسیِ بزرگ‌بنگ» تقریباً همیشه شکست می‌خورد — چون چند سال طول می‌کشد، کسب‌وکار در این مدت متوقف نمی‌ماند، و در روزِ سوییچ همه‌چیز هم‌زمان می‌شکند. الگوی درست Strangler Fig است.

انجیرِ خفه‌کننده (Strangler Fig)

در جنگل، انجیرِ خفه‌کننده دورِ یک درختِ کهنسال می‌پیچد، شاخه‌به‌شاخه جایش را می‌گیرد، و سال‌ها بعد درختِ قدیمی از درون می‌پوسد و می‌رود — اما هیچ‌وقت یک لحظهٔ «سقوطِ ناگهانی» نبوده. مهاجرتِ درست همین است: یک نمای جدید (façade/gateway) جلوی سیستمِ قدیمی می‌گذاری، بعد یک قابلیت را در Spring Boot بازمی‌نویسی و ترافیقِ آن مسیر را به سرویسِ نو می‌بری، و همین‌طور تکه‌تکه، تا روزی که از سیستمِ قدیمی چیزی نماند.

نمودارِ الگوی strangler (فارسی: انتقالِ تدریجیِ قابلیت‌ها پشتِ یک gateway؛ English: gradual capability migration behind a gateway):

flowchart LR
  Client([Client]) --> GW[API Gateway / Facade]
  GW -->|legacy paths| WLS[WebLogic EAR - monolith]
  GW -->|/orders migrated| NewOrder[Spring Boot: order-service]
  GW -->|/users migrated| NewUser[Spring Boot: user-service]
  WLS --> ODB[(Oracle DB)]
  NewOrder --> ODB
  NewUser --> ODB

مراحلِ عملیِ یک مهاجرت که در پروژه‌های واقعی جواب می‌دهد:

  1. façade بگذار. یک gateway جلوی monolith تا بتوانی ترافیک را مسیر-به-مسیر جابه‌جا کنی بدونِ اینکه client بفهمد.
  2. کم‌ریسک‌ترین قابلیت را اول ببر. یک ماژولِ حاشیه‌ای و کم‌وابستگی (نه هستهٔ تراکنشی) را انتخاب کن تا تیم مسیرِ CI/CD و observability و deploy را یاد بگیرد.
  3. جایگزین‌های EE را نگاشت کن. JNDI datasource → spring.datasource؛ CMT → @Transactional؛ MDB → @JmsListener؛ JAX-RS → Spring MVC/WebFlux؛ EJB stateless → @Service.
  4. مراقبِ تراکنشِ توزیع‌شده (XA/JTA) باش. این سخت‌ترین قسمت است (پایین‌تر).
  5. دیتابیس را در ابتدا share کن، بعد جدا کن. معمولاً هر دو (قدیم و نو) مدتی به یک دیتابیس وصل‌اند؛ جداسازیِ دیتابیس مرحلهٔ بعدی و مستقل است.
  6. قابلیتِ منتقل‌شده را در legacy خاموش کن تا کدِ مرده نماند.
تراکنشِ توزیع‌شدهٔ XA سخت‌ترین چیزی است که موقعِ خروج از app server از دست می‌دهی

app serverها یک قابلیتِ گران را رایگان می‌دادند: تراکنشِ توزیع‌شدهٔ دومرحله‌ای (XA/JTA) — یعنی می‌توانستی در یک تراکنش هم در دیتابیس بنویسی هم روی یک صفِ JMS پیام بگذاری و هر دو با هم commit یا rollback شوند. وقتی به microservice و Spring Boot می‌روی، این تضمینِ اتمیکِ بین‌منبعی را از دست می‌دهی (XA در محیطِ توزیع‌شده کند، شکننده و ضدِ مقیاس است). راهِ درست دیگر «بردنِ XA» نیست، بلکه بازطراحیِ الگو است: به‌جای تراکنشِ توزیع‌شده، از الگوی outbox (رویداد را در همان تراکنشِ دیتابیس در جدولِ outbox بنویس، بعد یک relay آن را به Kafka می‌فرستد) و saga برای سازگاریِ نهایی (eventual consistency) استفاده کن. اگر تیم این را دستِ‌کم بگیرد و انتظار داشته باشد XA «همان‌طور کار کند»، مهاجرت در همین‌جا گیر می‌کند. این را از روزِ اول در برآورد بگذار.

سؤال: می‌خواهی یک monolithِ روی WebLogic را به Spring Boot ببری. رویکردت چیست؟

جواب: هرگز بازنویسیِ big-bang — ریسکش نجومی است. من Strangler Fig را اجرا می‌کنم: (۱) یک API gateway/façade جلوی monolith می‌گذارم تا مسیریابی در کنترلِ من باشد. (۲) یک قابلیتِ کم‌ریسک و حاشیه‌ای را اول به Spring Boot می‌برم تا مسیرِ کاملِ build/deploy/observe جا بیفتد و اعتماد ساخته شود. (۳) مفاهیمِ EE را نگاشت می‌کنم: JNDI→spring.datasource، CMT→@Transactional، MDB→listener، JAX-RS→Spring MVC. (۴) در ابتدا هر دو سیستم به همان دیتابیس وصل می‌مانند تا داده‌ها همگام باشند؛ جداسازیِ دیتابیس را جداگانه و بعداً انجام می‌دهم. (۵) بزرگ‌ترین خطر را زود شناسایی می‌کنم: تراکنش‌های XA/JTA که app server رایگان می‌داد — این‌ها را با outbox + saga بازطراحی می‌کنم، نه انتقالِ مستقیم. (۶) هر قابلیت که منتقل شد، مسیرش را در gateway به سرویسِ نو می‌بندم و در legacy خاموشش می‌کنم. مزیت: ریسکِ هر قدم کوچک است، همیشه یک سیستمِ کارا داری، و اگر قدمی خراب شد فقط همان مسیر را برمی‌گردانی.

سؤال: چه وقت *نباید* از app server مهاجرت کنی؟

جواب: مهاجرت هدف نیست، ابزار است. اگر سیستمِ روی WebLogic پایدار است، تغییرِ کم دارد، به قابلیت‌های عمیقِ EE (XA سنگین، محصولاتِ خاصِ Oracle) وابسته است، و ارزشِ کسب‌وکاریِ جدیدی از مدرن‌سازی بیرون نمی‌آید، مهاجرت صرفاً ریسک و هزینه است بدونِ بازده. سؤالِ درست این نیست «چطور مهاجرت کنیم» بلکه «چه دردی داریم که مهاجرت درمانش می‌کند؟» — نیاز به deployهای سریع‌تر، مقیاس‌گذاریِ مستقل، جذبِ نیرویی که Spring بلد است، یا فرار از هزینهٔ لایسنس. اگر جوابِ روشنی نیست، strangler را فقط روی همان بخش‌هایی اجرا کن که واقعاً تغییر می‌کنند و بقیهٔ monolithِ پایدار را همان‌جا رها کن. یک سنیورِ بالغ می‌داند که «legacyِ کارا و پایدار» یک بدهیِ فوری نیست؛ گاهی عاقلانه‌ترین تصمیم، دست‌نزدن است.

سؤال: چرا مدلِ «برنامه داخلِ سرور» جایش را به «سرور داخلِ برنامه» داد؟

جواب: به‌خاطرِ وارونگیِ مسئولیت که با کانتینر و cloud منطقی شد. در مدلِ قدیم، یک سرورِ گران و سنگین را نصب می‌کردی و چند برنامه را در آن deploy می‌کردی تا هزینهٔ سخت‌افزار و لایسنس را share کنند؛ مسئولیت‌هایی مثل pooling، تراکنش، clustering مالِ سرور بود. اما این مدل با استقرارِ سریع، مقیاس‌گذاریِ مستقلِ هر سرویس، و ایزولاسیونِ خطا در تضاد بود (یک برنامهٔ بد می‌توانست کلِ سرور را ببرد؛ redeploy کند و پرریسک بود؛ classloading جهنم بود). با ارزان شدنِ منابع و آمدنِ کانتینر، منطقی‌تر شد که هر برنامه یک process خوداتکا با سرورِ embedded باشد که Kubernetes آن را مدیریت کند: استارتِ سریع، یک artifact قابل‌حمل، ایزولاسیونِ کامل، و همخوانی با ۱۲-factor. مسئولیت‌های عملیاتی از داخلِ app server به بیرون (پلتفرم/k8s/mesh) منتقل شد. این همان جابه‌جاییِ بزرگِ یک دههٔ اخیر است.


۸. جمع‌بندی

آنچه یک سنیور از این فصل باید در دست داشته باشد
  • ۱۲-factor قانونِ اساسیِ سرویسِ cloud-native است: config در محیط، process بی‌حالت، log در stdout، خاموشیِ تمیز. این چک‌لیستِ «آمادگیِ کانتینر» است.
  • کانتینری کردنِ Spring Boot را با layered jar یا buildpack انجام بده، نه COPYِ خامِ fat jar. و مراقبِ JVM داخلِ کانتینر باش: MaxRAMPercentage، headroom برای non-heap، و اینکه کدِ ۱۳۷ یعنی OOMKilled نه یک باگِ جاوا.
  • در Kubernetes: سرویس را با Deployment/Service/Ingress بالا بیاور؛ memory limit بگذار، CPU limit نگذار؛ liveness را سبک و بدونِ وابستگیِ بیرونی نگه دار و وابستگی‌ها را در readiness چک کن؛ Secretِ خام رمزنگاری‌شده نیست؛ HPA بدونِ request کار نمی‌کند.
  • Service mesh (Istio ambient/Linkerd) mTLS و مدیریتِ ترافیک را از کد بیرون می‌کشد؛ اما یک لایهٔ عملیاتیِ سنگین است — زودتر از نیاز نصبش نکن.
  • استقرار را با rolling/blue-green/canary انجام بده، اما همه به سازگاریِ رو-به-عقبِ اسکیما (الگوی expand/contract) وابسته‌اند.
  • سرورهای legacy (WebLogic/WildFly/WebSphere) با JNDI، datasourceِ مدیریت‌شده، EJB/JMS/MDB، clustering و classloadingِ سلسله‌مراتبی، نیایِ مفاهیمِ امروزی Spring‌اند — نمردند، جای عوض کردند. Tomcat/Jetty صرفاً servlet container‌اند نه full server.
  • مهاجرت را با Strangler Fig انجام بده نه big-bang؛ بزرگ‌ترین خطر تراکنشِ XA/JTA است که باید با outbox + saga بازطراحی شود؛ و گاهی درست‌ترین تصمیم، اصلاً مهاجرت‌نکردن است.

So far we've talked about what code you write; now comes the hard part: your code has to run somewhere. That same Spring Boot service that comes up on your laptop with mvn spring-boot:run must, in production, stay alive next to a hundred other services, on machines you'll never see, through a thousand restarts and rollouts, on a network that can drop at any moment. This chapter is about that layer: the runtime.

And here's a truth many courses hide: the real world has two halves. One half is "modern" — containers, Kubernetes, service mesh, the 12-factor mindset. The other half is "legacy" — WebLogic, WebSphere, JBoss/WildFly that have run banks, insurers, and telecoms for twenty years and will run them tomorrow too. A real senior knows both halves, because the real senior job is often exactly this: bridging the two worlds.

Roadmap for this chapter
  1. The 12-factor principles as the constitution of a cloud-native service.
  2. Containerizing Spring Boot: layered jars, multi-stage Dockerfiles, buildpacks, and how the JVM behaves inside a container.
  3. Kubernetes basics for a service: Deployment/Service/Ingress, probes, ConfigMap/Secret, HPA, requests/limits.
  4. Service mesh: sidecar vs ambient, mTLS, ztunnel/waypoint, and traffic management (Istio/Linkerd).
  5. Deployment strategies: rolling, blue-green, canary.
  6. Legacy Java EE servers: architecture, JNDI, datasources & connection pools, EJB/JMS/MDB, clustering & session replication, the classloading model.
  7. The migration path from app server to Spring Boot, and where you still meet these servers.

0. Two runtime worlds, one engineer

The grand restaurant vs. the food court

An old app server is like a grand full-service restaurant: one enormous building that owns everything — kitchen, storeroom, floor manager, reservation system, backup generator. You just bring your "dish" (a WAR) and hand it over (deploy it) to the building. The building is heavy and slow to warm up, but single-handedly manages everything.

Containers + Kubernetes are like a food court: each dish is cooked in a small independent stall (a container) that knows how to do only that one thing. Running the whole food court — power, cleaning, parking, moving a stall when it breaks — is the mall management's (Kubernetes') job, not the stall's. A stall is light, opens and closes fast, and if one catches fire the others are fine.

Neither is "bad"; each was built for its era. A good engineer knows when to reach for which.

The fundamental difference: in the app-server model, the platform is inside the application (the server provides everything and your code is its guest). In the cloud-native model, the application is a plain process and the platform is outside it (Kubernetes builds a fence around your process). This inversion of responsibility is the whole story.


1. The 12-Factor App: constitution of a cloud-native service

Before any tool, you need a mindset. The Twelve-Factor App manifesto (written by Heroku engineers) has twelve principles that, a decade later, are still a great litmus test for "is this service container-ready?" The most important for us:

  • Config in the environment, not in code. DB password, another service's address, a feature flag — none should live inside the jar. They come from environment variables or mounted files. Why? Because one single artifact should run in dev, staging, and prod without a rebuild.
  • Stateless processes. Nothing important should live in the process's local memory or local disk, because that process can be killed at any moment. State goes to the database, Redis, or object storage.
  • Port binding. The service serves HTTP itself (Spring Boot with embedded Tomcat binds a port) — no need to deploy inside an external app server.
  • Disposability. Start fast, shut down fast and cleanly (graceful shutdown). This is exactly what Kubernetes leans on during a rollout.
  • Logs as event streams. Write logs to stdout, not to a file. Collecting and rotating logs is the platform's job.
Senior judgment: 12-factor is a readiness checklist, not philosophy

When you look at a service and want to know "can this be containerized easily?", mentally walk it down the 12-factor list. A service that writes to local disk, reads config from application.properties baked into the jar, or warms a cache for 3 minutes at startup — those are migration red flags. Often the real hardness of "moving an old app to Kubernetes" isn't writing YAML; it's cleaning up these 12-factor violations.

Q: Why is statelessness critical for Kubernetes?

A: Because Kubernetes treats pods like cattle, not pets — it can kill one and create another anywhere at any time (rollout, scale-down, node move, preemption). If important state lives in a pod's memory or local disk, it's lost when the pod dies. So state must live outside the pod: session in Redis, business data in the database, files in object storage. A stateless service can be scaled, restarted, and moved painlessly; that freedom is exactly what enables Kubernetes' elasticity and self-healing. (Senior note: for genuinely stateful things like the database itself there's StatefulSet and PersistentVolume, but keep your application services as stateless as possible.)


2. Containerizing Spring Boot

2.1 What a container is (from scratch)

An image is a read-only bundle of filesystem + a run command. A container is a running instance of that image — a process on a Linux host, walled off by namespaces (isolating what it sees) and cgroups (limiting its resources) so it thinks it's the only machine. A container is not a VM; it shares the host kernel, which is why it's light and fast.

2.2 The classic mistake: don't COPY the fat jar directly

Everyone's first Dockerfile looks like this:

FROM eclipse-temurin:21-jre
COPY target/app.jar /app.jar
ENTRYPOINT ["java", "-jar", "/app.jar"]

It works, but it's inefficient. Spring Boot builds a fat jar (uber jar) where everything — your code + tens of MB of dependencies — is in one file. With every small code change, that entire layer is rebuilt, pushed, and pulled, even if the dependencies didn't change one bit. On a team doing dozens of deploys a day, that's wasted bandwidth and slow CI.

2.3 The right way #1: layered jars

Spring Boot can slice the jar into "layers" ordered by rate of change: dependencies (rarely change) in one layer, snapshots in another, and your own code (changes often) in the last. The layertools tooling extracts these, and in a multi-stage Dockerfile each layer becomes a separate COPY — so Docker can cache the rarely-changing layers:

# ---- Stage 1: extract layers ----
FROM eclipse-temurin:21-jre AS builder
WORKDIR /app
COPY target/*.jar app.jar
RUN java -Djarmode=tools -jar app.jar extract --layers --destination extracted

# ---- Stage 2: final image ----
FROM eclipse-temurin:21-jre
WORKDIR /app
# layers from least- to most-changing; order matters for caching
COPY --from=builder /app/extracted/dependencies/ ./
COPY --from=builder /app/extracted/spring-boot-loader/ ./
COPY --from=builder /app/extracted/snapshot-dependencies/ ./
COPY --from=builder /app/extracted/application/ ./
ENTRYPOINT ["java", "-jar", "app.jar"]
Version note: the jarmode flag changed

In Spring Boot 3.3+ (stable on today's 3.5 and 4.x lines), the layer-extraction command is -Djarmode=tools ... extract --layers. In older versions it was -Djarmode=layertools extract. If you're on an older project, check that version's docs.

2.4 The right way #2: buildpacks (no Dockerfile)

If you'd rather not maintain a Dockerfile at all, Spring Boot has built-in support for Cloud Native Buildpacks (the Paketo implementation). One command, no Dockerfile:

# Maven
./mvnw spring-boot:build-image -Dspring-boot.build-image.imageName=myorg/order-service:1.4.0

# Gradle
./gradlew bootBuildImage --imageName=myorg/order-service:1.4.0

The buildpack picks a suitable JDK, understands the layered jar, produces an optimized OCI-standard image, and even generates an SBOM (software bill of materials). You set the Java version through a buildpack environment variable:

./gradlew bootBuildImage \
  --imageName=myorg/order-service:1.4.0 \
  --builder=paketobuildpacks/builder-jammy-base
# JVM version via env: BP_JVM_VERSION=21
Senior judgment: Dockerfile or buildpack?
  • Choose buildpacks when you want dozens of Spring Boot services built uniformly and securely without every team breaking their own Dockerfile. Big win: centralized base-image security patching without touching service code.
  • Choose a multi-stage Dockerfile when you need fine control: a custom base image (e.g. distroless for a smaller attack surface), special tooling, or bespoke layering. A mature org pattern: a "golden" buildpack or base image the platform team maintains that everyone inherits from.

2.5 The big trap: the JVM inside a container

Before modern JDKs, the JVM saw memory and CPU wrong

For years there was a classic bug: the JVM couldn't see the container's cgroup limits and thought it owned all the memory and every core of the physical machine. The result? It sized the heap too big and the container got OOMKilled (exit code 137) by the kernel — with no clean OutOfMemoryError in the logs. This drove people insane.

Since JDK 10 (backported to 8u191) the JVM is cgroup-aware and honors MaxRAMPercentage by default. But that default is 25%, which is often too low for a small container. In production it's usually set explicitly:

JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=50.0"

And beware: the heap is only part of JVM memory. Metaspace, thread stacks, JIT code cache, and direct buffers also consume space. If you set the container limit exactly equal to the heap, you'll still get OOMKilled. Always leave headroom for non-heap.

The CPU point matters too: the JVM sizes GC threads, the ForkJoin pool, etc. from availableProcessors(). If you set the container CPU limit to, say, 500m (half a core), modern JDKs round this correctly, but at boundary settings you should still test. Don't go below a full core unless the service really is low-traffic.

Q: A Spring Boot container dies with exit code 137 and no Java error in the logs. Your diagnosis?

A: Exit code 137 means the process was killed by signal 9 (SIGKILL) — almost always OOMKilled by the kernel for exceeding the container's memory limit, not an in-JVM OutOfMemoryError. Because the kernel kills the process abruptly, you get no clean stack trace in the application logs. My checklist: (1) kubectl describe pod and look for Reason: OOMKilled in the last state. (2) Check the ratio of the memory limit to the heap settings — do MaxRAMPercentage + non-heap exceed the limit? (3) Look at native memory via -XX:NativeMemoryTracking or metrics. (4) Check for direct-buffer or metaspace leaks. The fix is usually either raising the limit or lowering MaxRAMPercentage and leaving headroom for non-heap.


3. Kubernetes basics for a service

Kubernetes (k8s) is a distributed operating system for containers. You tell it the desired state ("I want 4 healthy instances of this image running at all times") and it, through an endless reconciliation loop, keeps reality converging toward that state. If a pod dies, it creates a new one; that's self-healing.

3.1 The fundamental objects

  • Pod: the smallest deployable unit; one or more containers sharing network and storage. Usually one main container (your service) + maybe a sidecar.
  • Deployment: manages a set of identical, stateless pods — replica count, update strategy, and rollback.
  • Service: a stable name and virtual IP for reaching those pods (whose IPs constantly change). Internal load-balancing across healthy pods.
  • Ingress (or Gateway API): the entry gate from the outside world; HTTP routing by host/path to internal Services + TLS handling.
A Service is like a company extension number

Pods are like employees whose desk and room keep changing (IP changes, pod dies and is reborn). If you talk to a pod's IP directly, tomorrow that address is meaningless. A Service is like a fixed extension number: you call "extension 204" and the switchboard (kube-proxy) connects you to one of the healthy employees in that department, regardless of where they sit today.

Topology of a typical service on k8s (Persian: from user to database; English: from user to database):

flowchart LR
  User([User]) --> Ingress[Ingress / Gateway]
  Ingress --> Svc[Service: order-svc]
  Svc --> P1[Pod 1]
  Svc --> P2[Pod 2]
  Svc --> P3[Pod 3]
  P1 --> DB[(PostgreSQL)]
  P2 --> DB
  P3 --> DB
  P1 -. reads .-> CM[ConfigMap]
  P1 -. reads .-> SEC[Secret]

3.2 A real Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-service
spec:
  replicas: 3
  selector:
    matchLabels: { app: order-service }
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 0     # never drop capacity during rollout
      maxSurge: 1           # bring up one extra temporarily
  template:
    metadata:
      labels: { app: order-service }
    spec:
      containers:
        - name: app
          image: myorg/order-service:1.4.0
          ports:
            - containerPort: 8080
          resources:
            requests: { cpu: "500m", memory: "512Mi" }
            limits:   { memory: "768Mi" }   # no CPU limit (explained below)
          envFrom:
            - configMapRef: { name: order-config }
            - secretRef:    { name: order-secrets }
Usually set NO CPU limit; ALWAYS set a memory limit

A common mistake: setting a hard CPU limit. Kubernetes enforces it via cgroups as CPU throttling — the service artificially slows during peaks, latencies mysteriously spike, even when the node is idle. Today's common advice: set only a CPU request (for scheduling and fair share) and drop the limit. But always set a memory limit, because memory is incompressible and one leaking pod can devour the whole node. Set the memory request equal to the limit so the pod lands in the stable Guaranteed QoS class and is the last to be evicted.

3.3 Probes: the brain of self-healing, and where Actuator shines

Kubernetes has three probe types, and their difference is a classic interview question:

Probe The question it asks If it fails
startupProbe Have you finished starting up? Waits; other probes don't start until this succeeds
readinessProbe Are you right now ready to take traffic? Pod is removed from the Service (traffic cut, but pod not killed)
livenessProbe Are you alive or stuck? Pod is restarted

Spring Boot Actuator is built exactly for this, with separate liveness and readiness endpoints:

# application.yml
management:
  endpoint:
    health:
      probes:
        enabled: true       # enables /actuator/health/liveness and /readiness
  health:
    livenessstate:
      enabled: true
    readinessstate:
      enabled: true
# in the Deployment
          startupProbe:
            httpGet: { path: /actuator/health/readiness, port: 8080 }
            failureThreshold: 30
            periodSeconds: 5      # up to 150s to start
          readinessProbe:
            httpGet: { path: /actuator/health/readiness, port: 8080 }
            periodSeconds: 5
          livenessProbe:
            httpGet: { path: /actuator/health/liveness, port: 8080 }
            periodSeconds: 10
Don't conflate liveness and readiness — the most expensive probe mistake

The classic trap: writing a liveness probe that also checks the database. Now imagine the DB is slow or unreachable for a few minutes. All pods fail liveness, Kubernetes restarts all of them simultaneously, and you have a full cascading outage — right when the DB was recovering. Rule: liveness should only ask "is my process stuck?" (a lightweight internal check, no external dependencies). External dependencies (DB, queue, downstream service) belong in readiness — because when the DB is down you want the pod to just leave rotation (readiness fail), not be mercilessly restarted. That's exactly why Spring Boot separates the two.

Q: Difference between liveness, readiness, and startup probes? Which should touch the DB?

A: startup is for slow-starting apps; until it succeeds, the other two probes are suspended so a slow start doesn't wrongly kill the pod. readiness says "I'm ready to take traffic"; if it fails, the pod is pulled from the load balancer but stays alive. liveness says "my process is healthy"; if it fails, the pod is restarted. Golden rule: external dependencies (DB, Kafka, other services) should be checked only in readiness, never liveness. Put them in liveness and a transient DB blip triggers a simultaneous restart of every pod and a cascading outage. Liveness must be lightweight and purely internal.

3.4 ConfigMap and Secret

Per 12-factor, config comes from the environment. In k8s that's a ConfigMap (non-sensitive settings) and a Secret (credentials).

apiVersion: v1
kind: ConfigMap
metadata: { name: order-config }
data:
  SPRING_PROFILES_ACTIVE: "prod"
  SPRING_DATASOURCE_URL: "jdbc:postgresql://pg-primary:5432/orders"
---
apiVersion: v1
kind: Secret
metadata: { name: order-secrets }
type: Opaque
stringData:
  SPRING_DATASOURCE_PASSWORD: "s3cr3t-from-vault"

Spring Boot automatically maps SPRING_DATASOURCE_URL to the property spring.datasource.url (relaxed binding). So without a single extra line of code, config is injected from the environment.

A raw k8s Secret is only base64, not encryption

A dangerous misconception: many think Secret means "encrypted." It doesn't — by default it's just base64-encoded in etcd, meaning anyone with access to etcd or to the Secret object has the plaintext. For real security: (1) enable encryption at rest on etcd, (2) tight RBAC on Secrets, and (3) in practice, serious orgs use an external solution like HashiCorp Vault or the external-secrets operator and keep the secret out of etcd entirely. Never put a Secret in git (even private) without encryption like SOPS/sealed-secrets.

3.5 HPA: automatic scaling

The Horizontal Pod Autoscaler raises and lowers replica count based on a metric (CPU, memory, or a custom metric like Kafka queue length). Today's standard API is autoscaling/v2 (the v1 version is deprecated and CPU-only).

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata: { name: order-service }
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: order-service
  minReplicas: 3
  maxReplicas: 20
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70   # relative to the CPU request
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300   # default: wait 5 min before scaling down
Senior judgment: HPA doesn't work without requests

averageUtilization: 70 means "70% of the pod's CPU request." If you set no request, HPA has no denominator and either doesn't work or behaves oddly. Second: HPA checks about every 15 seconds, scale-up is fast, but scale-down uses a 5-minute stabilization window (default) to prevent nervous flapping. Third: for event-driven loads like a Kafka consumer, scaling on CPU is meaningless; there you reach for KEDA, which scales on queue length/lag and can even scale to zero.

Q: Why avoid a CPU limit on a latency-sensitive service but require a memory limit?

A: CPU is a compressible resource: when you hit the limit, Kubernetes throttles the pod rather than killing it. In practice that means sudden, mysterious latency spikes even when the node has free capacity — because the cgroup won't let you exceed your share. That's why latency-sensitive services usually drop the CPU limit and keep only a request. Memory is incompressible: you can't "give a bit less memory"; it's there or it isn't. Without a memory limit, one leaking pod can eat the whole node's memory and cause eviction of innocent neighbor pods. So a memory limit is an essential guardrail.


4. Service Mesh: when the network itself gets smart

4.1 The problem the mesh solves

Suppose you have a hundred services. You want all traffic between them encrypted (mTLS), all of them to have retries, timeouts, and circuit breakers, all to emit metrics and traces, and you want to precisely control traffic for canaries. If you put all of this in each service's code, you've repeated the same logic a hundred times (and in different languages). A service mesh pulls these concerns out of the code and hands them to the network layer.

The mesh is like the body's autonomic nervous system

You don't think about making your heart beat or regulating your blood pressure; the autonomic nervous system does it under the skin, freeing your conscious brain for important decisions. A service mesh is the same: mTLS, retries, routing, observability — all done under the platform's skin — so your business code (the brain) only writes domain logic.

4.2 Two architectures: sidecar vs. ambient

The classic model (sidecar): next to each service pod, a small proxy (usually Envoy) is injected, and all of that pod's inbound and outbound traffic flows through it. mTLS, retries, metrics — all in that sidecar. Powerful, but it costs: an extra container per pod, more CPU/RAM, and an added hop of latency.

The new model (ambient / sidecar-less): instead of a proxy per pod, Istio in ambient mode splits the work into two layers:

  • ztunnel (one agent per node, not per pod): Layer 4 — mTLS, identity, basic telemetry. Lightweight.
  • waypoint proxy (optional, only when you need Layer-7 policy): HTTP routing, smart load-balancing, advanced policies.
Today's status: ambient has matured

As of Istio 1.29 (early 2026), ambient mode is recommended as the default for new deployments; it does mTLS with the HBONE protocol (HTTP/2 + HTTP CONNECT + mTLS over port 15008), completely invisible to the application. Sidecars are still supported and needed for some specific requirements, but ambient's advantage is that by toggling a namespace label — with no restart or container injection — you move services into or out of the mesh. Linkerd is the lighter, simpler rival (version 2.19+ in 2026), running a dedicated Rust micro-proxy with automatic SPIFFE-based mTLS, popular for its simplicity and lower resource use.

Flow of an mTLS request in the mesh (Persian: automatic encryption between two services; English: automatic encryption between two services):

sequenceDiagram
  participant A as order-service
  participant PA as Proxy/ztunnel (source)
  participant PB as Proxy/ztunnel (dest)
  participant B as payment-service
  A->>PA: plain HTTP (localhost)
  PA->>PB: mTLS / HBONE (encrypted, identity-verified)
  Note over PA,PB: certificates auto-issued & rotated
  PB->>B: plain HTTP (localhost)
  B-->>PB: response
  PB-->>PA: mTLS response
  PA-->>A: plain HTTP

The key point: the order-service code thinks it's making plain HTTP to localhost. mTLS, certificate issuance, and rotation all happen outside the code. That's separation of concerns.

4.3 Traffic management: canary in a few lines of YAML

The mesh's real power is precise traffic control. With Istio, two core concepts: DestinationRule (defining subsets) and VirtualService (the traffic-splitting rule):

apiVersion: networking.istio.io/v1
kind: DestinationRule
metadata: { name: order-service }
spec:
  host: order-service
  subsets:
    - name: v1
      labels: { version: v1 }
    - name: v2
      labels: { version: v2 }
---
apiVersion: networking.istio.io/v1
kind: VirtualService
metadata: { name: order-service }
spec:
  hosts: [ order-service ]
  http:
    - route:
        - destination: { host: order-service, subset: v1 }
          weight: 90        # 90% of traffic to the old version
        - destination: { host: order-service, subset: v2 }
          weight: 10        # 10% to the new version (canary)

By changing these weights (10 → 30 → 60 → 100) you gradually shift traffic to the new version, and if error metrics rise, you flip it back instantly — no redeploy, just a change to the mesh rule.

A service mesh is a heavy operational layer; don't adopt it before you need it

A mesh isn't magic; it's yet another complex distributed system you must operate. The control plane must be upgraded, proxies consume resources, network debugging gets harder ("is it my code or the mesh?"), and a single mesh misconfiguration can cut all traffic. For a 5–10 service system, a resilience library (like Resilience4j) in code + TLS at the ingress is often enough. A mesh pays off when you have dozens of polyglot services with a serious need for zero-trust mTLS and uniform observability. "Install a mesh first" is a common anti-pattern.

Q: How does mTLS in a service mesh work without code changes, and what does it guarantee?

A: The proxy beside each service (sidecar) or the per-node ztunnel intercepts outbound traffic and encrypts it with an mTLS certificate before it crosses the network; on the destination side the proxy decrypts it and hands plain HTTP to the service. The application code only talks to localhost and is oblivious. The control plane (e.g. istiod) issues certificates to each workload based on its identity (e.g. a SPIFFE ID derived from the ServiceAccount) and automatically rotates them. The guarantees: (1) confidentiality — inter-service traffic is encrypted on the wire. (2) mutual authentication — both sides have cryptographic identities, so a rogue service can't impersonate another. (3) a foundation for identity-based authorization (policy like "only order-service may call payment-service"). This is the pillar of a zero-trust architecture.


5. Deployment strategies: rolling, blue-green, canary

Changing a tire while driving

The goal of all these strategies is one thing: swap versions without the user feeling a "bump" — like changing a car's tire without stopping. They differ in how and how much risk.

Strategy How it works Advantage Cost/risk
Rolling pods replaced one by one with the new version simple, k8s default, no double resources two versions live at once (needs DB compatibility); slow rollback
Blue-Green the whole new env (green) comes up alongside the old (blue), then traffic switches all at once instant rollback (flip the switch back), full testing before switch double resources momentarily; DB migration must be backward-compatible
Canary the new version first takes a small slice of traffic, then increases gradually smallest blast radius, decisions on real metrics needs a traffic-control tool (mesh/Argo Rollouts) and good metrics

State machine of a canary (Persian: gradual traffic ramp with a metric gate; English: gradual traffic ramp with a metric gate):

stateDiagram-v2
  [*] --> Deploy_v2_10pct
  Deploy_v2_10pct --> Analyze: watch error rate & latency
  Analyze --> Ramp_50pct: metrics healthy
  Analyze --> Rollback: metrics bad
  Ramp_50pct --> Analyze2: watch again
  Analyze2 --> Promote_100pct: healthy
  Analyze2 --> Rollback: bad
  Promote_100pct --> [*]
  Rollback --> [*]
Rolling updates *require* backward-compatible schema changes

This is where many people get burned. In a rolling update, for a while the old and new code versions run simultaneously against one database. If the new version's migration drops or renames a column, the still-live old version breaks. The safe pattern is expand/contract: first only add (new nullable column), make both versions compatible with it, and only after the old version is fully gone, drop it in a later deploy. Never bundle a breaking schema change with the code that needs it in one rollout. The same principle keeps blue-green safe.

Q: What's the difference between blue-green and canary, and when do you choose each?

A: In blue-green you have two full parallel environments and switch traffic in one instant from old (blue) to new (green); its advantage is instant rollback (flip the switch back) and full testing of green before a single user sees it. Its downsides: double resources, and at the switch moment all users get the new version at once — if there's an untested bug, it hits all traffic. In canary you expose the new version gradually (1%, 10%, 50%…) to real traffic and decide based on metrics; the blast radius is minimal because if it's broken, only a small percentage is hurt. I pick canary when I have enough real traffic for statistical judgment and a traffic-control tool (mesh or Argo Rollouts) plus good metrics; I pick blue-green for coarse changes, or when splitting traffic is hard and instant rollback is the top priority.


6. The other half of the world: legacy Java EE servers

Now we return from the shiny food court to the grand old restaurant. Because here's reality: a huge portion of the planet's business logic still runs on these — banks, insurers, telecoms, government. If you're becoming a senior, sooner or later you'll face WebLogic or WebSphere or JBoss. And not knowing them means going mute in that meeting.

6.1 The core idea of an app server

An application server is a heavy runtime that provides a set of enterprise services out of the box and deploys and manages your applications (as WAR or EAR files) inside itself. These services are the same ones injected into a process as libraries in the Spring Boot world:

  • JNDI — a naming directory to find resources (datasources, queues, beans) by name.
  • Managed datasource + connection pool — the server creates and manages the database connection pool.
  • EJB — transactional server-side components.
  • JMS / MDB — messaging and managed message-driven consumers.
  • JTA — distributed two-phase (XA) transactions across multiple resources.
  • Clustering / HA / session replication — multiple server instances forming a cluster and replicating state.
An app server is like a full-service hotel

In a five-star hotel you just bring your suitcase (a WAR); the room, the made bed, room service, laundry, parking, security — it's all there and hotel management runs it. You just "reside." Spring Boot is the opposite, like a self-managed apartment: you bring whatever you need yourself (embedded Tomcat, connection pool, …) but in return you completely own your environment and don't depend on hotel management.

6.2 JNDI: the server's phone book

JNDI (Java Naming and Directory Interface) is an API for "name → object." Instead of hard-coding the DB password in code, you tell the server "create a datasource named jdbc/OrdersDS" and in code you just call it by name:

// classic JNDI lookup (Java EE)
InitialContext ctx = new InitialContext();
DataSource ds = (DataSource) ctx.lookup("java:comp/env/jdbc/OrdersDS");
try (Connection c = ds.getConnection()) {
    // ... use a connection from the server-managed pool
}

The benefit: config (address, password, pool size) lives in the server console, not in the artifact. The same WAR runs unchanged in dev and prod because each server has its own JNDI. (This is exactly the 12-factor idea, only twenty years earlier and in server-centric form.) In Spring Boot, the equivalent is defining spring.datasource.* from the environment.

6.3 Managed datasource and connection pool

Opening a TCP connection to a database is expensive (several round-trips, authentication). A connection pool keeps a set of pre-opened connections and lends them to requests. In an app server this pool is managed and configured in the console: min/max size, timeout, and most importantly a validation query that checks a connection's health before use.

Here's a dual-dialect SQL difference that matters in configuring exactly this pool — the connection-validation query:

-- PostgreSQL: no dummy table needed
SELECT 1;
-- Oracle: you must read from the system DUAL table
SELECT 1 FROM DUAL;
Dialect note: with modern drivers, prefer no validation query at all

In Oracle, every SELECT must have a FROM, and DUAL is a single-row system table that exists precisely for this "SELECT without a real table"; in PostgreSQL, SELECT 1; without a FROM is perfectly valid. But the modern senior note: modern pools like HikariCP (Spring Boot's default) recommend using JDBC 4's Connection.isValid() instead of a validationQuery (i.e. leave validationQuery empty), because it's faster and dialect-independent. If you're on an old app server with an old driver lacking isValid, then you need the correct dialect from the two above.

Q: What advantage does a managed datasource in an app server have over an in-app datasource, and why is it used less today?

A: The historical advantage: separation of config from code and centralized management. The server operator could change pool size, password, and address in the console without rebuilding the app, and multiple apps deployed on one server could share a datasource. XA datasources for distributed transactions were also supported out of the box. Why less today? In the cloud-native model we get that same config separation via environment variables and ConfigMaps/Secrets, we control the pool with HikariCP inside the process and close to the code (simpler tuning and monitoring), and each service has its own database so sharing a datasource is no longer a goal. The managed model was designed for a world where several apps shared one expensive server; those assumptions no longer hold.

6.4 EJB, JMS, and MDB

EJB (Enterprise JavaBeans) were server-side components for which the server automatically managed transactions, security, pooling, and concurrency. A simple Stateless Session Bean:

@Stateless
public class OrderServiceBean {
    @PersistenceContext
    private EntityManager em;

    @TransactionAttribute(TransactionAttributeType.REQUIRED)
    public void placeOrder(Order o) {
        em.persist(o);      // transaction managed by the container (CMT)
    }
}

That @TransactionAttribute means "the container opens and commits/rolls back the transaction itself" — that's Container-Managed Transactions. This is exactly what @Transactional does in Spring; Spring borrowed the idea and simplified it.

JMS is the queue/messaging standard, and an MDB (Message-Driven Bean) is a bean that automatically consumes messages from a queue — the conceptual ancestor of today's @KafkaListener:

@MessageDriven(activationConfig = {
  @ActivationConfigProperty(propertyName = "destination",
                            propertyValue = "jms/OrderQueue")
})
public class OrderConsumer implements MessageListener {
    public void onMessage(Message msg) {
        // process the message; transaction and thread provided by the container
    }
}
Senior judgment: EJB concepts didn't die, they just moved

When you look at this code, don't think "obsolete stuff." See that Spring took exactly these ideas and made them lighter: stateless EJB → Spring @Service; CMT (@TransactionAttribute) → @Transactional; MDB → @JmsListener/@KafkaListener; JNDI datasource → spring.datasource. If you understand the EJB architecture, you actually understand why Spring is designed the way it is more deeply. In an interview, connecting these generations signals maturity.

6.5 The hierarchical classloading model — a source of pain

The "ClassNotFoundException" that happens in Tomcat but not in your local test

The classloading architecture in app servers is hierarchical and isolated, a classic source of suffering. Unlike a simple app with a flat classpath, a server has several nested classloaders: bootstrap → system/server → shared → and then a separate classloader per WAR. The goal: isolate applications so two WARs can carry different versions of a library.

But this causes two nightmarish problem classes: (1) version conflict — the server itself carries an old version of a library (say an XML parser or logging) in a parent classloader, and its class is preferred over yours, so your code runs with the wrong version. The symptom: NoSuchMethodError or strange behavior that only happens on the server. The fix is usually setting the classloading policy to "child-first"/"parent-last." (2) classloader leaks — on a hot redeploy, if a ThreadLocal, thread, or JDBC driver keeps a reference to the old classloader, that classloader isn't collected, and after a few redeploys you get PermGen/Metaspace OutOfMemoryError. That's why in traditional environments many teams banned hot-redeploy in prod entirely and did a full server restart instead.

Spring Boot eliminates this complexity with a different philosophy: one process, one (nearly) flat classloader, one application. There's no multi-WAR isolation and no shared classloader; instead of "many apps in one server," we have "one app in one container." The entire class of classloader problems vanishes.

6.6 Clustering, HA, and session replication

App servers can form a cluster: several server instances working together. The most important classic capability is HTTP session replication: if a user logged in on server 1 and state sits in the HttpSession, the server replicates that session to server 2, so if server 1 dies, the user is moved to server 2 without being logged out (failover). In an era when sticky sessions and in-memory state were normal, this was vital.

Senior judgment: cloud-native "dissolves rather than solves" this problem

Here's the interesting bit: the modern world doesn't chase session replication — it erases the question. Per 12-factor, the service is stateless and the HttpSession isn't kept in memory; state moves to an external store (Redis with Spring Session, or a stateless JWT). Now any pod can answer any request, sticky sessions aren't needed, and a pod's death kills no session. So instead of a complex, network-centric replication mechanism, one architectural decision (put state outside) removes the whole need. This is the difference in mindset between two generations: legacy solves the problem with smarter infrastructure; cloud-native obviates it with simpler architecture.

6.7 A field guide: which server, where, in what shape

Server Type Owner/community Status in 2026 Where you meet it
Tomcat servlet container Apache Tomcat 11 (Servlet 6.1, Jakarta EE 11, Java 17+) everywhere; it's what Spring Boot embeds
Jetty servlet container Eclipse Jetty 12 (up to Servlet 6.0, Java 17+) embedded, lightweight, tools and proxies
WildFly full Java EE (JBoss) Red Hat / community WildFly 41; on Jakarta EE 11 since v40 the open-source variant; Red Hat/OpenShift cloud
JBoss EAP full Java EE Red Hat (commercial) based on WildFly, with support Red Hat enterprises
WebLogic full Java EE Oracle 14c (14.1.2), Jakarta EE 8, Java 17/21 banks, telecom, anywhere with heavy Oracle DB
WebSphere (traditional) full Java EE IBM WAS 9.x; heavy, traditional large old IBM-centric enterprises
WebSphere Liberty lightweight, modern, modular IBM continuous delivery (26.0.0.x), up to Java 26 the modernization path for IBM customers
Key difference: servlet container vs. full Java/Jakarta EE server

Tomcat and Jetty are only servlet containers: they provide HTTP, Servlet, JSP, WebSocket, but do not have EJB, JMS, JTA (distributed XA transactions), or full enterprise CDI out of the box. That's why they're light and fast, and precisely why Spring Boot uses Tomcat as its embedded server (you add whatever EE you need via libraries). WildFly/WebLogic/WebSphere are full-profile servers with all those heavy enterprise services. A common interview misconception: saying "Tomcat is an application server." More precisely: Tomcat is a web/servlet container, not a full Java EE application server.

Q: What's the difference between Tomcat and WebLogic/WildFly? Why did Spring Boot choose embedded Tomcat over a full server?

A: Tomcat is a servlet container: it provides the HTTP/Servlet/JSP/WebSocket stack but not the heavy Jakarta EE services like EJB, JTA for distributed XA transactions, a JMS provider, and full enterprise CDI out of the box. WebLogic/WildFly are full-profile servers that have all of these. Spring Boot deliberately chose embedded Tomcat because its philosophy inverts the app-server model: instead of deploying the app into a heavy external server, you bring the server inside the app as a library and build a self-contained jar. This fits containers perfectly (one process, port binding, fast startup, no separate server install), most apps don't need heavy full-EE features, and whatever you do need (transactions via @Transactional, messaging via a Kafka/JMS client) is added as a library. The result: lighter, more portable, and 12-factor-friendly.


7. The migration path: from app server to Spring Boot

Suppose you have a ten-year-old EAR on WebLogic and must modernize it. The "big-bang rewrite" approach almost always fails — it takes years, the business doesn't pause meanwhile, and on switch day everything breaks at once. The right pattern is the Strangler Fig.

The strangler fig

In the jungle, a strangler fig wraps around an old tree, branch by branch takes its place, and years later the old tree rots from within and is gone — but there was never a moment of "sudden collapse." Proper migration is exactly this: you put a new facade/gateway in front of the old system, then rewrite one capability in Spring Boot and route that path's traffic to the new service, and so on piece by piece, until one day nothing of the old system remains.

The strangler pattern (Persian: gradual capability migration behind a gateway; English: gradual capability migration behind a gateway):

flowchart LR
  Client([Client]) --> GW[API Gateway / Facade]
  GW -->|legacy paths| WLS[WebLogic EAR - monolith]
  GW -->|/orders migrated| NewOrder[Spring Boot: order-service]
  GW -->|/users migrated| NewUser[Spring Boot: user-service]
  WLS --> ODB[(Oracle DB)]
  NewOrder --> ODB
  NewUser --> ODB

Practical steps of a migration that works in real projects:

  1. Put a facade in place. A gateway in front of the monolith so you can move traffic path by path without the client noticing.
  2. Move the lowest-risk capability first. Pick a peripheral, low-dependency module (not the transactional core) so the team learns the CI/CD, observability, and deploy path.
  3. Map the EE replacements. JNDI datasource → spring.datasource; CMT → @Transactional; MDB → @JmsListener; JAX-RS → Spring MVC/WebFlux; stateless EJB → @Service.
  4. Watch out for distributed transactions (XA/JTA). This is the hardest part (see below).
  5. Share the database at first, split later. Usually both (old and new) connect to one DB for a while; database splitting is a separate, later step.
  6. Turn the migrated capability off in the legacy so no dead code remains.
XA distributed transactions are the hardest thing you lose when leaving an app server

App servers gave an expensive capability for free: two-phase distributed transactions (XA/JTA) — you could, in one transaction, both write to the database and put a message on a JMS queue and have both commit or roll back together. When you move to microservices and Spring Boot, you lose this cross-resource atomic guarantee (XA is slow, brittle, and anti-scale in a distributed setting). The right approach isn't to "carry XA over," it's to redesign the pattern: instead of a distributed transaction, use the outbox pattern (write the event to an outbox table in the same DB transaction, then a relay ships it to Kafka) and sagas for eventual consistency. If a team underestimates this and expects XA to "just work," the migration gets stuck right here. Put it in the estimate from day one.

Q: You need to move a WebLogic monolith to Spring Boot. What's your approach?

A: Never a big-bang rewrite — the risk is astronomical. I run the Strangler Fig: (1) put an API gateway/facade in front of the monolith so routing is in my control. (2) Move a low-risk, peripheral capability to Spring Boot first so the full build/deploy/observe path is established and trust is built. (3) Map EE concepts: JNDI→spring.datasource, CMT→@Transactional, MDB→listener, JAX-RS→Spring MVC. (4) Keep both systems connected to the same database at first so data stays in sync; I do database splitting separately and later. (5) Identify the biggest risk early: XA/JTA transactions the app server gave for free — I redesign these with outbox + saga, not a direct port. (6) As each capability migrates, I route its path in the gateway to the new service and turn it off in the legacy. The benefit: each step's risk is small, you always have a working system, and if a step breaks you just route that path back.

Q: When should you *not* migrate off an app server?

A: Migration isn't the goal, it's a tool. If the WebLogic system is stable, changes little, depends on deep EE features (heavy XA, Oracle-specific products), and no new business value comes out of modernization, then migration is just risk and cost with no return. The right question isn't "how do we migrate" but "what pain do we have that migration cures?" — the need for faster deploys, independent scaling, hiring people who know Spring, or escaping license cost. If there's no clear answer, run the strangler only on the parts that actually change and leave the rest of the stable monolith where it is. A mature senior knows that "working, stable legacy" is not an urgent debt; sometimes the wisest decision is not to touch it.

Q: Why did the "app inside the server" model give way to "server inside the app"?

A: Because of an inversion of responsibility that made sense with containers and the cloud. In the old model you installed one expensive, heavy server and deployed several apps into it to share hardware and license cost; responsibilities like pooling, transactions, and clustering belonged to the server. But that model conflicted with fast deploys, independent per-service scaling, and fault isolation (one bad app could take down the whole server; redeploy was slow and risky; classloading was hell). As resources got cheap and containers arrived, it became more sensible for each app to be a self-contained process with an embedded server that Kubernetes manages: fast start, one portable artifact, full isolation, and 12-factor alignment. Operational responsibilities moved from inside the app server to outside (the platform/k8s/mesh). That's the big shift of the last decade.


8. Wrap-up

What a senior should walk away with from this chapter
  • 12-factor is the constitution of a cloud-native service: config in the environment, stateless processes, logs to stdout, clean shutdown. It's the "container readiness" checklist.
  • Containerize Spring Boot with a layered jar or a buildpack, not a raw COPY of the fat jar. And mind the JVM inside the container: MaxRAMPercentage, headroom for non-heap, and that exit code 137 means OOMKilled, not a Java bug.
  • In Kubernetes: bring the service up with Deployment/Service/Ingress; set a memory limit, no CPU limit; keep liveness lightweight and dependency-free and check dependencies in readiness; a raw Secret is not encrypted; HPA doesn't work without requests.
  • A service mesh (Istio ambient/Linkerd) pulls mTLS and traffic management out of the code; but it's a heavy operational layer — don't install it before you need it.
  • Do deployments with rolling/blue-green/canary, but they all depend on backward-compatible schema changes (the expand/contract pattern).
  • Legacy servers (WebLogic/WildFly/WebSphere) with JNDI, managed datasources, EJB/JMS/MDB, clustering, and hierarchical classloading are the ancestors of today's Spring concepts — they didn't die, they moved. Tomcat/Jetty are merely servlet containers, not full servers.
  • Do migration with the Strangler Fig, not big-bang; the biggest risk is XA/JTA transactions, which must be redesigned with outbox + saga; and sometimes the most correct decision is not to migrate at all.