Libraries & Ecosystem · کتابخانهها و اکوسیستم متوسطIntermediate ~49 دقیقه مطالعه~41 min read
کانتینر و Kubernetes برای اپهای جاواContainers & Kubernetes for Java Apps
راهنمای عمیق و کاربردی برای کانتینریکردن اپهای جاوا و اجرای درستشان روی Kubernetes: از پایههای Docker و ساخت ایمیجهای لایهای و کوچک (multi-stage، لایهبندی Spring Boot، jlink و distroless) تا آگاهی JVM از cgroups و تنظیم حافظه و CPU، خاموشی مؤدبانه، پیکربندی با env و secret، مفاهیم Pod/Deployment/Service، محدودیت منابع، probeها، و بدهبستان image بومی GraalVM.A deep, practical guide to containerizing Java apps and running them right on Kubernetes: from Docker basics and building small layered images (multi-stage, Spring Boot layering, jlink, distroless) to JVM cgroup-awareness and memory/CPU tuning, graceful shutdown, config via env and secrets, Pod/Deployment/Service concepts, resource limits, probes, and the trade-offs of GraalVM native images.
سلام. بگذار همین اول یک واقعیت را روشن کنم: نوشتن یک اپ جاوا که «روی لپتاپ من کار میکند» یک چیز است، و رساندن همان اپ به تولید بهشکلی که پایدار، کوچک، سریع و قابلمقیاس باشد چیزی کاملاً دیگر. بین این دو، دو فناوری ایستادهاند که امروز عملاً استاندارد صنعتاند: کانتینر (container) که اپ و تمام وابستگیهایش را در یک بستهی قابلحمل میپیچد، و Kubernetes که ارتشی از این بستهها را روی دهها ماشین اجرا و مدیریت میکند. اما جاوا در این دنیا یک رفتار عجیب دارد: JVM موجودی است که فکر میکند کل ماشین مال اوست، در حالی که داخل یک کانتینر با سهمیهی محدود زندگی میکند. اگر این تنش را نفهمی، اپت یا با OOMKilled کشته میشود، یا کند بالا میآید، یا موقع خاموشی درخواستهای کاربران را قطع میکند. در این فصل نهفقط دستورها را، بلکه چرایی هرکدام را یاد میگیری.
مسیری که با هم میرویم:
- Docker از صفر — image و container چیستند و چرا لایهایاند.
- ساخت ایمیج درست برای JVM — از یک Dockerfile سادهلوحانه تا multi-stage، لایهبندی Spring Boot، و کش وابستگیها.
- کوچککردن ایمیج —
jlinkبرای runtime سفارشی و پایههای distroless. - آگاهی JVM از کانتینر — cgroups،
MaxRAMPercentage، و ماجرای CPU وavailableProcessors. - چرخهی عمر — خاموشی مؤدبانه (graceful shutdown) و مدیریت سیگنالها.
- پیکربندی — env، ConfigMap و Secret بهجای هاردکد.
- پایههای Kubernetes — Pod، Deployment، Service و محدودیت منابع.
- سلامت — probeهای liveness/readiness/startup.
- زمان بالاآمدن و image بومی GraalVM — بدهبستان بزرگ، و در پایان پرسشهای مصاحبه.
بخش صفر — چند واژه که باید قبل از شروع حسشان کنی
قبل از هر کد، چند اصطلاح در تمام فصل تکرار میشوند. بگذار همین حالا جا بیندازمشان.
- image (ایمیج): یک قالبِ فقطخواندنی و لایهای که همهچیزِ لازم برای اجرای اپ را دارد: سیستمعاملِ حداقلی، runtime جاوا، jar تو و پیکربندی. مثل یک «عکس منجمد» از سیستمِ آمادهبهکار.
- container (کانتینر): یک نمونهی در حال اجرا از یک image. اگر image دستور پخت باشد، container غذای پختهشده روی میز است.
- layer (لایه): هر دستور در Dockerfile یک لایهی جدید میسازد. لایهها کش و بین ایمیجها بهاشتراک گذاشته میشوند؛ این نکته بعداً کلید کوچک و سریعکردن است.
- registry (رجیستری): انبار ایمیجها (مثل Docker Hub یا GitHub Container Registry) که ایمیجها را
pushوpullمیکنی. - cgroups (control groups): سازوکارِ هستهی لینوکس که سهمیهی CPU و حافظهی یک کانتینر را محدود میکند. قهرمانِ پنهانِ این فصل؛ همان چیزی که JVM باید آن را «ببیند».
- orchestrator (هماهنگکننده): سیستمی مثل Kubernetes که تصمیم میگیرد کدام container روی کدام ماشین اجرا شود، آنها را زنده نگه دارد و مقیاس بدهد.
Docker از صفر: چرا اصلاً کانتینر؟
قبل از کانتینرهای استاندارد، بار کشتیها بهشکل بشکه و گونی و جعبههای بیقواره بود؛ هر بندر باید هر محموله را جداگانه دستبهدست میکرد و هرجومرج بود. کانتینرِ فلزیِ استاندارد همهچیز را عوض کرد: هر بار داخل یک جعبهی هماندازه میرود و هر جرثقیل، کشتی و کامیون در هر نقطهی دنیا میداند دقیقاً چطور با آن کار کند. کانتینرِ نرمافزار همین ایده است: اپ تو، runtime جاوا، کتابخانهها و پیکربندی داخل یک جعبهی استاندارد میروند که روی لپتاپ توسعهدهنده، سرور تست و ابرِ تولید یکسان اجرا میشود. «روی ماشین من کار میکند» دیگر بهانه نیست، چون «ماشین من» را هم داخل جعبه فرستادی.
پیش از کانتینر، تفاوتهای محیطی کابوس بود: نسخهی متفاوت جاوا، متغیر محیطیِ گمشده، کتابخانهی سیستمیِ ناموجود. Docker با بستهبندی اپ بههمراه کل محیط اجرایش این تفاوتها را از بین برد. برخلاف ماشین مجازی (VM) که یک سیستمعاملِ کامل را شبیهسازی میکند و سنگین است، کانتینر هستهی سیستمعاملِ میزبان را بهاشتراک میگذارد و فقط فضای اجرایی را ایزوله میکند؛ پس در چند صد میلیثانیه بالا میآید و مصرف منابعش ناچیز است.
قلب کار، فایلی به نام Dockerfile است: دستورالعملِ ساختِ image. بیایید با یک نمونهی سادهلوحانه شروع کنیم که تقریباً همه اولبار مینویسند — و بعد ببینیم چرا اشتباه است.
# نمونهی سادهلوحانه — کار میکند اما بد است
FROM eclipse-temurin:25-jdk
WORKDIR /app
COPY . .
RUN ./mvnw package
CMD ["java", "-jar", "target/app.jar"]
این ایمیج کار میکند، اما سه مشکل بزرگ دارد که در ادامه یکییکی حلشان میکنیم:
- حجیم است. پایهی
jdkکلِ کامپایلر و ابزار توسعه را دارد، در حالی که برای اجرا فقط به runtime نیاز داری. - کش را نابود میکند. با
COPY . .هر تغییر کوچک در کد، تمام لایههای بعدی از جمله دانلود وابستگیهای Maven را باطل میکند؛ هر build کند میشود. - ابزار build داخل ایمیجِ تولید میماند. Maven، کد منبع و کش
.m2بیدلیل در ایمیج نهایی جا خوش میکنند و هم حجم و هم سطح حمله را بالا میبرند.
گام اول: Multi-stage build
راهحلِ مشکلِ «ابزار build داخل ایمیج نهایی»، multi-stage build است: از چند مرحلهی FROM استفاده میکنی؛ در مرحلهی اول (builder) با JDK کامل build میگیری، و در مرحلهی دوم فقط خروجی را به یک پایهی سبک با JRE کپی میکنی. ابزارِ build در ایمیج نهایی نمیماند.
# مرحلهی ۱: build با JDK کامل
FROM eclipse-temurin:25-jdk AS builder
WORKDIR /app
# اول فقط فایلهای وابستگی را کپی کن تا کشِ لایه حفظ شود
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline -B
# حالا کد را کپی کن؛ تغییر کد فقط از اینجا به بعد را باطل میکند
COPY src/ src/
RUN ./mvnw clean package -DskipTests -B
# مرحلهی ۲: اجرا با پایهی سبک (فقط runtime)
FROM eclipse-temurin:25-jre AS runtime
WORKDIR /app
# کاربر غیرِ root بساز؛ never run as root
RUN useradd --system --uid 1001 appuser
USER 1001
COPY --from=builder /app/target/app.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Docker لایهها را از بالا کش میکند و بهمحضِ رسیدن به اولین لایهی تغییرکرده، آن و همهی لایههای بعدی را دوباره میسازد. چون وابستگیها کمتر از کدِ تو تغییر میکنند، آنها را قبل از کدِ منبع کپی کن. با این ترتیب، تا وقتی pom.xml عوض نشده، لایهی سنگینِ دانلودِ وابستگیها از کش میآید و buildهای بعدی چند برابر سریعتر میشوند.
بهصورت پیشفرض، پروسهی داخل کانتینر با کاربر root اجرا میشود. اگر مهاجم از یک آسیبپذیری اپ سوءاستفاده کند، rootِ داخل کانتینر یک قدم به rootِ میزبان نزدیکتر است. همیشه با USER یک کاربر غیرِ root (مثلاً uid 1001) بساز و اپ را با آن اجرا کن. بسیاری از پلتفرمها اجرای container با root را اساساً ممنوع میکنند.
گام دوم: لایهبندیِ درستِ jar در Spring Boot
multi-stage مشکل ابزارِ build را حل کرد، اما یک مشکلِ ظریفِ کش باقی است. یک jar معمولیِ Spring Boot یک «fat jar» است: کدِ خودت بههمراه دهها مگابایت وابستگی، همه در یک فایل. وقتی آن را در یک لایه کپی میکنی، هر تغییرِ کوچک در کدِ خودت باعث میشود کلِ آن لایه — از جمله همهی وابستگیهای تغییرنکرده — دوباره ساخته و دوباره push شود.
Spring Boot از نسخهی ۲.۳ راهحل دارد: layered jar. jarِ اپ بهصورت داخلی به چند لایهی منطقی تقسیم میشود که ترتیبشان بر اساس «چقدر زیاد تغییر میکنند» چیده شده:
dependencies— وابستگیهای release (بهندرت تغییر میکنند).spring-boot-loader— کلاسهای راهاندازِ خودِ Boot (تقریباً هرگز تغییر نمیکنند).snapshot-dependencies— وابستگیهای SNAPSHOT (گاهی تغییر میکنند).application— کدِ خودِ تو (مدام تغییر میکند).
با کپیکردنِ هر لایه در یک COPY جدا، Docker میتواند لایههای وابستگی را از کش نگه دارد و فقط لایهی نازکِ application را دوباره بسازد. در Spring Boot 3.3 به بعد، jarmode مدرن tools این کار را انجام میدهد (که جایگزینِ layertools قدیمی شده):
FROM eclipse-temurin:25-jdk AS builder
WORKDIR /app
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline -B
COPY src/ src/
RUN ./mvnw clean package -DskipTests -B
# jar را به لایههای منطقی استخراج کن
RUN java -Djarmode=tools -jar target/app.jar extract --layers --destination extracted
FROM eclipse-temurin:25-jre
WORKDIR /app
RUN useradd --system --uid 1001 appuser
USER 1001
# هر لایه در یک COPY جدا؛ کمتغییرها اول، پرتغییرها آخر
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/ ./
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
یک deploy معمولی فقط کدِ خودت را عوض میکند، نه وابستگیها را. با لایهبندی، تغییرِ کد فقط لایهی چند مگابایتیِ application را باطل میکند و ۵۰-۲۰۰ مگابایتِ وابستگی از کشِ registry و node میآید. نتیجه: push و pull سریعتر، مصرفِ پهنایباند کمتر، و deploy چابکتر — بهویژه وقتی روزی دهها بار deploy میکنی.
اگر نمیخواهی خودت Dockerfile را نگهداری کنی، Spring Boot با یک دستور میتواند ایمیجِ بهینه بسازد: ./mvnw spring-boot:build-image. این از Cloud Native Buildpacks (پیادهسازیِ Paketo) استفاده میکند و بهصورت خودکار لایهبندی، کاربرِ غیرِ root، و یک «memory calculator» را اعمال میکند — بدون هیچ Dockerfile. قیمتش این است که کنترلِ دقیقِ دستورهای ایمیج را از دست میدهی و به قراردادهای Paketo وابسته میشوی. برای بسیاری از تیمها این معاملهی خوبی است.
گام سوم: کوچککردنِ ایمیج با jlink و distroless
تا اینجا پایهی jre را برداشتیم که خودش ~۱۸۰ مگابایت است و کلِ کتابخانهی استانداردِ جاوا را دارد، حتی ماژولهایی که اپت اصلاً استفاده نمیکند. دو ابزار میتوانند این را بهشدت کوچک کنند.
jlink: runtime سفارشیِ فقط بهاندازهی نیاز
جاوا از نسخهی ۹ ماژولار است. jlink ابزاری است که یک runtime سفارشی میسازد که فقط ماژولهایی را دارد که اپت نیاز دارد. نتیجه معمولاً به ۴۰-۶۰ مگابایت میرسد — در برابر ~۱۸۰ مگابایتِ JRE کامل.
FROM eclipse-temurin:25-jdk AS jre-builder
# ماژولهای موردنیاز اپ را کشف کن (یا دستی فهرست بده)
RUN jlink \
--add-modules java.base,java.logging,java.sql,java.naming,java.desktop,java.management \
--strip-debug --no-man-pages --no-header-files \
--compress zip-9 \
--output /javaruntime
FROM debian:stable-slim
COPY --from=jre-builder /javaruntime /opt/java
ENV PATH="/opt/java/bin:${PATH}"
COPY --from=builder /app/target/app.jar /app/app.jar
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
حدسزدنِ دستیِ فهرست ماژولها خطاخیز است. ابزار jdeps --print-module-deps app.jar وابستگیِ ماژولیِ اپت را استخراج میکند. اما مراقب باش: کدی که با reflection ماژولی را صدا میزند (مثل بخشی از Spring یا درایورهای دیتابیس) ممکن است در تحلیلِ ایستا دیده نشود؛ همیشه ایمیجِ نهایی را واقعاً اجرا و تست کن تا ClassNotFoundException غافلگیرت نکند.
distroless: تصویری بدونِ سیستمعاملِ اضافی
پایههای معمولی (مثل debian یا ubuntu) یک shell، مدیرِ بسته و دهها ابزار دارند که اپ جاوا هیچکدام را لازم ندارد — اما هرکدام یک آسیبپذیریِ بالقوهاند. ایمیجهای distroless گوگل فقط شاملِ چیزهاییاند که برای اجرای برنامه لازم است: بدون shell، بدون apt، بدون bash. این هم حجم و هم سطحِ حمله را کم میکند.
# پایهی distroless مخصوص جاوا — بدون shell، بدون package manager
FROM gcr.io/distroless/java21-debian12:nonroot
COPY --from=builder /app/extracted/dependencies/ /app/
COPY --from=builder /app/extracted/spring-boot-loader/ /app/
COPY --from=builder /app/extracted/snapshot-dependencies/ /app/
COPY --from=builder /app/extracted/application/ /app/
WORKDIR /app
# distroless:nonroot بهصورت پیشفرض با کاربر غیرِ root اجرا میشود
ENTRYPOINT ["java", "-jar", "app.jar"]
چون هیچ shellی وجود ندارد، دیگر نمیتوانی docker exec -it ... bash بزنی تا داخل کانتینر را ببینی. این برای امنیت عالی است اما دیباگ را سخت میکند. راهحل: از تگِ :debug (که یک shell سبک دارد) فقط در محیطِ توسعه استفاده کن، از logها و metricها برای مشاهدهپذیری تکیه کن، و از ابزارهای دیباگِ ephemeral مثل kubectl debug بهره ببر که یک container موقت کنارِ podِ اصلی میچسباند.
بیایید گزینههای پایه را کنار هم بگذاریم:
| پایه | حجم تقریبی | shell/ابزار | امنیت | بهترین کاربرد |
|---|---|---|---|---|
temurin:25-jdk |
~۴۵۰MB | کامل + کامپایلر | پایین | فقط مرحلهی build |
temurin:25-jre |
~۲۸۰MB | shell دارد | متوسط | پیشفرضِ ساده و امن |
jlink + debian-slim |
~۱۲۰MB | shell دارد | خوب | کوچککردنِ runtime |
distroless/java |
~۲۳۰MB (پایه) | بدون shell | عالی | تولیدِ حساس به امنیت |
jlink + distroless |
~۱۰۰MB | بدون shell | عالی | کمینهی حجم و حمله |
هدف کوچکترین ایمیجِ ممکن نیست؛ کوچکترین ایمیجی است که هنوز قابلِ نگهداری و دیباگ باشد. jlink + distroless عالی است اما هزینهی پیچیدگی و سختیِ دیباگ دارد. برای بیشتر تیمها، یک temurin:jre با لایهبندیِ Spring Boot و کاربرِ غیرِ root، تعادلِ درستی بین حجم، امنیت و سادگی است. بهینهسازیِ افراطی وقتی ارزش دارد که واقعاً هزاران pod یا cold-startهای مکرر داری.
آگاهی JVM از کانتینر: بزرگترین دامِ جاوا در کانتینر
حالا به مهمترین بخشِ مخصوصِ جاوا رسیدیم. JVM تاریخاً برای اجرا روی یک ماشینِ اختصاصی طراحی شد. وقتی بالا میآید، از سیستمعامل میپرسد «چقدر RAM و چند CPU داری؟» و اندازهی heap، تعداد تردهای GC، اندازهی استخرهای ترد و خیلی چیزهای دیگر را بر اساسِ آن تنظیم میکند. مشکل: داخلِ کانتینر، این پرسش قدیمیها پاسخِ ماشینِ میزبان را میدادند، نه سهمیهی کانتینر. یعنی روی یک node با ۶۴ گیگ RAM، JVMِ داخلِ یک کانتینرِ ۵۱۲ مگابایتی فکر میکرد ۶۴ گیگ دارد و heap را غولآسا میگرفت — و بعد OOMKilled میشد.
JVM مثل مستأجری است که یک آپارتمانِ کوچک اجاره کرده اما فکر میکند مالکِ کلِ برجِ دهطبقه است. شروع میکند به چیدنِ مبلمانِ دهطبقه در یک آپارتمانِ یکخوابه؛ طبیعتاً همهچیز به هم میریزد و صاحبخانه (cgroup/kernel) او را بیرون میاندازد (OOMKilled). آگاهیِ کانتینر یعنی به مستأجر یادآوری کنی «تو فقط این یک آپارتمان را داری»؛ آنوقت درستاندازه اثاث میچیند.
خبرِ خوب: JVMهای مدرن container-aware شدهاند. پرچمِ -XX:+UseContainerSupport که از JDK 8u191 و در JDK 11 به بعد بهصورت پیشفرض روشن است، باعث میشود JVM سهمیه را از cgroups بخواند، نه از کلِ میزبان. پس روی JDK مدرن، حافظه معمولاً درست تشخیص داده میشود. اما هنوز باید مقدارِ heap را آگاهانه تنظیم کنی.
تنظیم حافظه: چرا Xmx ثابت بد است و MaxRAMPercentage خوب
روشِ قدیمی این بود که heap را با مقدارِ ثابت بدهی: -Xmx512m. اما وقتی محدودیتِ حافظهی کانتینر را در Kubernetes عوض میکنی، این عددِ هاردکدشده دیگر همگام نیست. روشِ درستِ کانتینری، درصدی است:
# JVM حداکثر heap را ۷۵٪ از حافظهی کانتینر میگیرد
java -XX:MaxRAMPercentage=75.0 -jar app.jar
مقدارِ پیشفرضِ -XX:MaxRAMPercentage برابرِ ۲۵٪ است. یعنی اگر کانتینرت ۱ گیگ حافظه دارد، heap فقط ~۲۵۶ مگابایت میشود و ۷۵٪ باقی بیاستفاده میماند (یا برای heapِ کوچکات کافی نیست و مدام GC میکنی). این پیشفرضِ محافظهکارانه برای ماشینِ چند-اپ منطقی است، اما در Kubernetes که یک اپ در هر کانتینر داری، معمولاً میخواهی این را به ۷۰-۷۵٪ ببری تا حافظه هدر نرود. ۲۵٪ باقیمانده برای heapِ غیرِ heap (metaspace، thread stackها، بافرهای native، code cache) لازم است — پس هرگز ۱۰۰٪ نده.
یک اشتباهِ رایج: تصور اینکه «heap = کلِ مصرفِ حافظه». نه! مصرفِ واقعیِ JVM شامل اینهاست: heap + metaspace (کلاسهای بارگذاریشده) + thread stackها (هر ترد ~۱MB) + code cache (کدِ JIT-شده) + بافرهای native (مثلاً NIO/Netty). اگر محدودیتِ کانتینر را دقیقاً برابرِ Xmx بگذاری، این مصرفِ off-heap تو را از محدودیت رد میکند و OOMKilled میگیری. برای همین MaxRAMPercentage=75 معقول است: آن ۲۵٪ سرپناهِ off-heap است.
CPU: داستانی که کمتر کسی درست میفهمد
حافظه نصفِ ماجراست؛ نصفِ دیگر CPU است و اینجا دامها عمیقترند. JVM بر اساسِ Runtime.availableProcessors() تصمیمهای زیادی میگیرد: تعدادِ تردهای GC، اندازهی ForkJoinPool.commonPool()، اندازهی پیشفرضِ استخرهای connection و thread در فریمورکها. اگر این عدد اشتباه باشد، یا اپت تعدادِ بیمعنایی ترد میسازد یا برعکس، کممصرف میماند.
در Kubernetes دو مفهومِ CPU داری که به دو سازوکارِ متفاوتِ cgroup نگاشت میشوند:
- request (درخواست) → به
cpu.sharesنگاشت میشود؛ یعنی «سهمِ نسبیِ من هنگامِ رقابت». این وزن است، نه سقف. - limit (محدودیت) → به
cpu.cfs_quotaنگاشت میشود؛ یک سقفِ سخت: در هر بازهی زمانی حداکثر اینقدر CPU مصرف کن، وگرنه throttle میشوی.
نکتهی حیاتی: JVMِ مدرن availableProcessors() را عمدتاً از CPU quota (همان limit) محاسبه میکند، نه از shares (همان request). این یعنی: اگر برای کانتینرت CPU limit تعیین نکنی، JVM فکر میکند تمامِ هستههای نودِ میزبان مالِ اوست. روی یک نودِ ۶۴-هستهای، این یعنی استخرهای ترد و GC غولآسا، سوییچِ کانتکستِ افراطی، و مصرفِ حافظهی بیدلیل. اگر limit بگذاری اما کسری باشد (مثلاً 500m یعنی نیمِ هسته)، JVM آن را به بالا به ۱ گرد میکند. راهحلِ صریح و قابلِپیشبینی: عددِ هسته را با -XX:ActiveProcessorCount=N دستی پین کن.
# عددِ هسته را صریح و قابلِپیشبینی پین کن
java -XX:MaxRAMPercentage=75.0 -XX:ActiveProcessorCount=2 -jar app.jar
بیایید پرچمهای کلیدیِ حافظه و CPU را کنار هم ببینیم:
| پرچم | چهکار میکند | پیشفرض | توصیهی کانتینری |
|---|---|---|---|
-XX:+UseContainerSupport |
خواندنِ سهمیه از cgroups | روشن (JDK 11+) | همانطور روشن بگذار |
-XX:MaxRAMPercentage |
سقفِ heap بهصورت درصدِ حافظهی کانتینر | ۲۵٪ | ۷۰-۷۵٪ |
-XX:InitialRAMPercentage |
heapِ اولیه بهصورت درصد | ~۱.۵٪ | برابرِ Max برای اجتناب از resize |
-Xmx (مقدار ثابت) |
سقفِ heap با عددِ مطلق | خودکار | فقط اگر واقعاً ثابت میخواهی |
-XX:ActiveProcessorCount |
تعدادِ CPU که JVM فرض میکند | از cgroup quota | برابرِ CPU limit پین کن |
-XX:+ExitOnOutOfMemoryError |
خروجِ سریع بهجای زامبیماندن | خاموش | معمولاً روشن کن |
وقتی OutOfMemoryError رخ میدهد، اپ اغلب در حالتی نیمهمرده گیر میکند: نه واقعاً کار میکند، نه میمیرد. Kubernetes نمیتواند «نیمهمرده» را تشخیص دهد و podِ معیوب زنده میماند. با -XX:+ExitOnOutOfMemoryError میگویی «بهمحضِ OOM، پروسه را بکش». آنوقت container خارج میشود، Kubernetes یک podِ سالمِ تازه بالا میآورد و چرخهی خوددرمانی کار میکند. مرگِ سریع بهتر از زامبیِ کند است.
چرخهی عمر: خاموشیِ مؤدبانه (graceful shutdown)
وقتی Kubernetes میخواهد یک pod را جابهجا یا مقیاسکم کند، آن را نمیکشد بلکه اول یک سیگنالِ SIGTERM میفرستد که یعنی «لطفاً تمیز خودت را جمع کن». اگر اپت این سیگنال را نادیده بگیرد، درخواستهای در حالِ پردازش نیمهکاره قطع میشوند و کاربر خطا میگیرد. خاموشیِ مؤدبانه یعنی: پذیرشِ درخواستهای جدید را قطع کن، اما به درخواستهای در حالِ اجرا اجازه بده تمام شوند، بعد خارج شو.
یک رستورانِ خوب موقعِ بستن، درِ ورودی را قفل میکند تا مشتریِ جدید نیاید، اما میزهایی که همین حالا غذا سفارش دادهاند را رها نمیکند؛ صبر میکند غذایشان را تمام کنند و بعد چراغها را خاموش میکند. خاموشیِ خشن مثل این است که وسطِ غذا برقِ رستوران را قطع کنی و همه را بیرون بریزی. SIGTERM همان اعلامِ «داریم میبندیم» است؛ graceful shutdown یعنی مؤدبانه بستن.
Spring Boot این را داخلی پشتیبانی میکند. کافی است دو تنظیم بدهی:
# application.yml
server:
shutdown: graceful # سرور به حالتِ drain میرود
spring:
lifecycle:
timeout-per-shutdown-phase: 30s # حداکثر صبر برای اتمامِ درخواستهای فعال
اما در Kubernetes یک ظرافتِ حیاتی هست: بین لحظهای که pod حذف میشود و لحظهای که IPِ آن از فهرستِ Service حذف میشود، یک مسابقه وجود دارد. ممکن است SIGTERM برسد اما load balancer هنوز چند میلیثانیه ترافیک بفرستد. راهحلِ استاندارد، یک preStop hook با یک sleep کوتاه است تا این تأخیرِ همگامسازی را بپوشاند:
# در بخشِ container از Deployment
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 5"] # به k8s فرصت بده IP را از Service بردارد
terminationGracePeriodSeconds: 45 # باید > (preStop sleep + timeout درین)
terminationGracePeriodSeconds باید از مجموعِ «مدتِ preStop» و «مهلتِ درینِ Spring» بزرگتر باشد. اگر preStop پنج ثانیه بخوابد و Spring تا سی ثانیه برای اتمامِ درخواستها صبر کند، این مقدار باید دستِکم ۳۵-۴۰ ثانیه باشد. اگر کوچکتر بگذاری، Kubernetes وسطِ درین یک SIGKILL میفرستد و کلِ زحمتِ graceful را باطل میکند — درخواستها قطع میشوند. پیشفرضِ Kubernetes برای این مقدار ۳۰ ثانیه است؛ اغلب باید بالاترش ببری.
اگر بخواهی صریحتر روی سیگنال واکنش نشان دهی (مثلاً بستنِ یک صف یا فلاشِ کش)، میتوانی یک shutdown hook هم اضافه کنی:
@Component
class GracefulCleanup {
private static final Logger log = LoggerFactory.getLogger(GracefulCleanup.class);
@PreDestroy
void onShutdown() {
// این متد وقتی context در حالِ بستهشدن است اجرا میشود
log.info("در حالِ فلاشِ بافرها و بستنِ اتصالها...");
// flush کش، بستنِ producerِ صف، و مانند آن
}
}
پیکربندی: env، ConfigMap و Secret
یکی از اصولِ «Twelve-Factor App» این است که پیکربندی را از کد جدا کن. هرگز رمزِ دیتابیس یا URLِ سرویس را در کد یا داخلِ image هاردکد نکن؛ ایمیجِ تو باید یکسان باشد و رفتارش با پیکربندیِ بیرونی تغییر کند. در دنیای کانتینر، این پیکربندی از متغیرهای محیطی (environment variables) میآید.
Spring Boot بهصورت خودکار متغیرهای محیطی را به property نگاشت میکند (این را «relaxed binding» میگویند): متغیرِ SPRING_DATASOURCE_URL به spring.datasource.url نگاشت میشود.
@ConfigurationProperties(prefix = "app.payment")
public record PaymentProperties(String apiUrl, String apiKey, int timeoutMs) { }
# application.yml — مقادیر از env میآیند، نه هاردکد
app:
payment:
api-url: ${PAYMENT_API_URL}
api-key: ${PAYMENT_API_KEY}
timeout-ms: ${PAYMENT_TIMEOUT_MS:3000} # با مقدارِ پیشفرض
در Kubernetes، پیکربندیِ غیرِ محرمانه در ConfigMap و دادههای محرمانه در Secret میرود. Secretها بهصورتِ base64 ذخیره میشوند (توجه: base64 رمزنگاری نیست، فقط انکودینگ است) و باید با مکانیزمهای واقعیِ رمزنگاری در حالتِ استراحت (encryption at rest) و کنترلِ دسترسیِ RBAC محافظت شوند.
apiVersion: v1
kind: ConfigMap
metadata:
name: payment-config
data:
PAYMENT_API_URL: "https://payments.internal/api"
PAYMENT_TIMEOUT_MS: "3000"
---
apiVersion: v1
kind: Secret
metadata:
name: payment-secret
type: Opaque
stringData: # stringData یعنی خودت base64 نکن
PAYMENT_API_KEY: "sk_live_secret_value"
بعد در Deployment اینها را بهصورتِ env تزریق میکنی:
envFrom:
- configMapRef:
name: payment-config
- secretRef:
name: payment-secret
تازهکارها فکر میکنند چون اسمش Secret است، امن است. base64 را هرکسی در یک ثانیه decode میکند. برای امنیتِ واقعی: (۱) رمزنگاری در حالتِ استراحت را روی etcd فعال کن، (۲) با RBAC دسترسی به Secretها را محدود کن، (۳) هرگز Secret را در git commit نکن، و (۴) برای رازهای حساس از راهکارهای اختصاصی مثل Vault یا External Secrets Operator استفاده کن. Secretِ خامِ Kubernetes حداقلِ کار است، نه راهکارِ نهایی.
پایههای Kubernetes: Pod، Deployment، Service
حالا که ایمیجِ خوبی داریم، بیایید ببینیم Kubernetes چطور اجرایش میکند. سه مفهومِ بنیادی هست که باید تفکیکشان کنی.
تصور کن یک هتل داری. Pod یک خدمهی کاری است — کوچکترین واحدِ اجرا؛ یک یا چند container که با هم زندگی و میمیرند. Deployment مثل مدیری است که میگوید «همیشه باید دقیقاً ۳ خدمه سرِ کار باشند»؛ اگر یکی بیمار شود (crash)، فوراً جایگزین میآورد و موقعِ بهروزرسانی، خدمهها را یکییکی و بدونِ توقفِ کار عوض میکند. Service میزِ پذیرشِ ثابتی است با یک شمارهی داخلیِ پایدار؛ مهمانها با پذیرش کار دارند، نه با خدمهی خاص — چون خدمهها مدام عوض میشوند اما پذیرش ثابت میماند.
- Pod: کوچکترین واحدِ قابلِ اجرا. معمولاً یک container (اپت) اما گاهی چند container که باید کنارِ هم باشند. Podها گذرا (ephemeral) اند: هر لحظه ممکن است بمیرند و با IPِ جدیدی جایگزین شوند. پس هرگز مستقیماً به IPِ یک Pod تکیه نکن.
- Deployment: حالتِ مطلوب را اعلام میکنی («۳ تا از این Pod با این image») و Kubernetes آن را حفظ میکند: Podهای مرده را جایگزین و در بهروزرسانی، rolling update انجام میدهد (Podهای جدید بالا، قدیمیها پایین، بدونِ downtime).
- Service: یک نامِ DNS و IPِ ثابتِ داخلی که ترافیک را بینِ Podهای سالم پخش (load balance) میکند. حتی وقتی Podها عوض میشوند، Service همان میماند.
یک Deploymentِ کامل برای اپ جاوا اینطور است:
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders
spec:
replicas: 3 # همیشه ۳ نسخه
selector:
matchLabels: { app: orders }
template:
metadata:
labels: { app: orders }
spec:
containers:
- name: orders
image: registry.example.com/orders:1.4.2
ports:
- containerPort: 8080
resources:
requests: # حداقلِ تضمینشده
memory: "512Mi"
cpu: "500m"
limits: # سقفِ سخت
memory: "512Mi"
cpu: "1000m"
env:
- name: JAVA_TOOL_OPTIONS
value: "-XX:MaxRAMPercentage=75.0 -XX:ActiveProcessorCount=1"
---
apiVersion: v1
kind: Service
metadata:
name: orders
spec:
selector: { app: orders }
ports:
- port: 80
targetPort: 8080
محدودیتِ منابع: request در برابر limit
این تفکیک قلبِ scheduling و پایداری است:
- request آن چیزی است که scheduler برای جادادنِ Pod روی یک node تضمین میکند. اگر node بهاندازهی request جا نداشته باشد، Pod آنجا نمیرود.
- limit سقفی است که اگر رد شود، برای CPU منجر به throttling (کندشدن) و برای حافظه منجر به OOMKilled (کشتهشدنِ container) میشود.
یک توصیهی سنیور: در جاوا معمولاً بهتر است requestِ حافظه را برابرِ limit بگذاری. چرا؟ چون heapِ جاوا تمایل دارد تا نزدیکِ سقف رشد کند و مصرفش پایدار بماند؛ گذاشتنِ request کمتر از limit این توهم را میسازد که node ظرفیتِ اضافه دارد، در حالی که هر JVM دیر یا زود به سقفش میرسد و آنگاه node تحتِ فشارِ حافظه Podها را evict میکند. با request = limit، Podِ تو در کلاسِ کیفیتِ سرویسِ Guaranteed قرار میگیرد و آخرین قربانیِ فشارِ حافظه است.
برخلافِ حافظه (که رد از limit = مرگ)، ردکردن از CPU limit فقط throttle میکند: هسته به تو داده نمیشود و پروسهات کند میشود. این میتواند به تأخیرهای عجیب و افزایشِ latency منجر شود، بهویژه هنگامِ warm-up یا GC. برخی تیمها آگاهانه CPU limit را حذف میکنند تا throttle نشوند (اما آنگاه باید -XX:ActiveProcessorCount را دستی پین کنی تا JVM هستههای میزبان را نبیند). این یک بحثِ فعال است؛ نکته این است که رفتارِ هرکدام را بفهمی و آگاهانه انتخاب کنی.
سلامت: probeهای liveness، readiness و startup
Kubernetes چطور میفهمد Podِ تو سالم است؟ با probe — بررسیهای دورهای. سه نوع هست و اشتباهگرفتنشان یکی از رایجترین خطاهای تولید است.
تصور کن سرپرستِ کارمندی هستی. liveness یعنی «آیا اصلاً زنده است یا باید بیدارش کنم؟» — اگر مرده، اخراج و جایگزین (restart). readiness یعنی «آیا آمادهی گرفتنِ کارِ جدید است یا سرش شلوغ/در حالِ آمادهشدن است؟» — اگر آماده نیست، فعلاً کار نده (از load balancer کنارش بگذار) اما اخراجش نکن. startup یعنی «تازه رسیده؛ تا وقتی مستقر نشده، با آن دو سوالِ دیگر اذیتش نکن.» این تفکیک حیاتی است: اپی که کند بالا میآید نباید موقعِ استارت بهخاطرِ liveness کشته شود.
- liveness probe: آیا پروسه زنده و سالم است؟ اگر شکست بخورد، Kubernetes container را restart میکند. این را باریک نگهدار: فقط بررسی کن که خودِ اپ گیر (deadlock) نکرده؛ به وابستگیهای بیرونی (دیتابیس، سرویس دیگر) وابستهاش نکن.
- readiness probe: آیا اپ آمادهی گرفتنِ ترافیک است؟ اگر شکست بخورد، Pod از فهرستِ Service برداشته میشود (اما restart نمیشود). اینجا میتوانی وابستگیهای حیاتی را چک کنی.
- startup probe: فقط هنگامِ بالاآمدن فعال است؛ به اپ فرصت میدهد کامل بالا بیاید و تا آن موقع liveness/readiness را معلق نگه میدارد. برای اپهای جاوا که چند ثانیه warm-up دارند حیاتی است.
Spring Boot Actuator اینها را آماده میدهد. کافی است probeها را فعال کنی (در Kubernetes بهصورت خودکار تشخیص داده میشود) تا دو endpoint ظاهر شوند:
# application.yml
management:
endpoint:
health:
probes:
enabled: true
group:
liveness:
include: livenessState # فقط خودِ اپ
readiness:
include: readinessState,db # اپ + وابستگیهای حیاتی
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
این دو مسیر را میسازد: /actuator/health/liveness و /actuator/health/readiness. حالا در Deployment به آنها اشاره میکنی:
startupProbe: # تا ۵ دقیقه فرصتِ بالاآمدن
httpGet: { path: /actuator/health/readiness, port: 8080 }
failureThreshold: 30
periodSeconds: 10
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 8080 }
periodSeconds: 10
failureThreshold: 3
پرتکرارترین اشتباهِ probeها: گذاشتنِ چکِ دیتابیس در liveness. تصور کن دیتابیس یک لحظه کند میشود؛ liveness شکست میخورد، Kubernetes همهی Podها را restart میکند، آنها دوباره بالا میآیند و باز دیتابیسِ کند را میبینند و باز restart میشوند — یک حلقهی مرگ (crash loop) که فشار را بدتر میکند. قانون: liveness فقط livenessState (آیا خودِ اپ گیر کرده؟)؛ وابستگیهای بیرونی فقط در readiness (که Pod را کنار میگذارد اما نمیکشد). این تفکیک، تفاوتِ یک سیستمِ خوددرمان با یک سیستمِ خودتخریب است.
یک اپ Spring Boot ممکن است ۱۵-۳۰ ثانیه برای بالاآمدن نیاز داشته باشد. اگر فقط liveness داشته باشی با initialDelaySeconds کوتاه، Kubernetes وسطِ بالاآمدن آن را میکشد و هرگز بالا نمیآید. startupProbe این را حل میکند: با failureThreshold: 30 و periodSeconds: 10 تا ۵ دقیقه فرصت میدهد، و تا وقتی موفق نشده، liveness و readiness اصلاً اجرا نمیشوند. بهمحضِ موفقیتِ startup، آن دو با ریتمِ تندِ خودشان کار را دست میگیرند.
زمانِ بالاآمدن و image بومیِ GraalVM
اپهای جاوا یک نقطهضعفِ تاریخی در دنیای کانتینر دارند: زمانِ بالاآمدن. JVM باید بالا بیاید، کلاسها را بارگذاری کند، و کدِ داغ را با JIT کامپایل کند تا به سرعتِ اوج برسد. یک اپ Spring Boot ممکن است چند ثانیه طول بکشد تا آماده شود و دهها ثانیه تا به performance اوج برسد. برای سرورِ همیشهروشن این مهم نیست، اما برای serverless/FaaS و مقیاسگذاریِ سریع (scale-to-zero، ترافیکِ ناگهانی) این cold-start گران است.
JVMِ کلاسیک مثل موتورِ دیزلِ سنگین است: روشنشدنش زمان میبرد و باید گرم شود، اما وقتی گرم شد قدرتِ فوقالعاده و پایدار دارد (JIT کدِ داغ را به بهینهترین شکل کامپایل میکند و در طولِ زمان سریعتر میشود). image بومیِ GraalVM مثل موتورِ برقی است: لحظهای که دکمه را میزنی تمامِ گشتاور حاضر است (بالاآمدن در ~۵۰ میلیثانیه)، اما سقفِ گشتاورش ثابت است چون فرصتِ گرمشدن و بهینهسازیِ زماناجرا ندارد.
GraalVM native image رویکردی کاملاً متفاوت است: بهجای کامپایلِ کد در زمانِ اجرا (JIT)، همهچیز را قبل از اجرا (Ahead-of-Time) به یک فایلِ اجراییِ نیتیوِ مستقل کامپایل میکند. نتیجه: بالاآمدن از چند ثانیه به دهها میلیثانیه میرسد و مصرفِ حافظه بهشدت (اغلب چند برابر) کم میشود، چون کلِ زیرساختِ JIT و بخشِ بزرگی از metadata حذف میشود. Spring Boot 3 به بعد با Spring AOT پشتیبانیِ درجهیک از این دارد.
# ساختِ image بومی با پلاگینِ Spring Boot
./mvnw -Pnative native:compile
# یا مستقیم ساختِ ایمیجِ بومیِ کانتینر
./mvnw -Pnative spring-boot:build-image
اما این معجزه رایگان نیست. AOT با یک تحلیلِ ایستا (points-to analysis) کار میکند که در زمانِ build تصمیم میگیرد کدام کد قابلِدسترسی است و بقیه را دور میریزد. هرچیزی که پویا باشد — reflection، proxyهای پویا، بارگذاریِ پویای کلاس، اسکنِ classpath در زمانِ اجرا — باید صریحاً به آن معرفی شود، وگرنه در زمانِ اجرا میشکند. Spring AOT بخشِ بزرگی از این را خودکار میکند، اما کتابخانههای ثالث ممکن است به پیکربندیِ دستی نیاز داشته باشند.
بیایید بدهبستان را روشن ببینیم:
| جنبه | JVM کلاسیک (JIT) | image بومی (GraalVM AOT) |
|---|---|---|
| زمانِ بالاآمدن | ثانیهها | دهها میلیثانیه |
| مصرفِ حافظه | بالاتر | بهمراتب کمتر |
| توانِ اوج (throughput) | بالاترین (JIT بهینه میکند) | خوب اما اغلب کمتر از اوجِ JIT |
| زمانِ build | سریع | کند (دقایق) |
| reflection/proxy پویا | بدونِ دردسر | نیاز به پیکربندی |
| اندازهی فایلِ اجرایی | jar کوچک + JRE | باینریِ بزرگترِ مستقل |
| بهترین کاربرد | سرویسِ همیشهروشنِ پرترافیک | serverless، CLI، scale-to-zero |
image بومی زمانی میدرخشد که cold-start و مصرفِ حافظه مهمترین معیارت باشند: توابعِ serverless، ابزارهای CLI، یا میکروسرویسهایی که مدام scale-to-zero میشوند. اما اگر سرویسی داری که همیشه روشن است و ترافیکِ پایدارِ بالا دارد، JVMِ کلاسیک با JIT اغلب توانِ اوجِ بالاتری میدهد چون در طولِ زمان کدِ داغ را بهینهتر میکند — و پیچیدگیِ build و محدودیتهای reflection را هم نداری. برای اکثریتِ سرویسهای شرکتی، JVMِ کلاسیک هنوز پیشفرضِ درست است؛ native یک ابزارِ تخصصی است، نه جایگزینِ همگانی.
اگر cold-start آزارت میدهد اما نمیخواهی هزینهی native را بپردازی، گزینهی میانی هست: CDS (Class Data Sharing) و بهویژه AppCDS که در جاوای مدرن بهبود یافته. این کار وضعیتِ کلاسهای بارگذاریشده را در یک آرشیو ذخیره میکند تا در بالاآمدنهای بعدی سریعتر خوانده شوند و زمانِ استارتآپ چند ده درصد کم شود — بدونِ محدودیتهای reflection. جالب اینکه ساختارِ لایهبندیشدهی jarmode جدیدِ Spring Boot بهصورتِ ذاتی با CDS سازگار است، چون classpathِ قابلِپیشبینی، کش را مؤثرتر میکند.
بهترین شیوهها
- همیشه multi-stage build بزن تا ابزارِ build در ایمیجِ نهایی نماند؛ و وابستگیها را قبل از کد کپی کن تا کش حفظ شود.
- در Spring Boot از لایهبندیِ jar (
-Djarmode=tools ... extract --layers) استفاده کن و هر لایه را در یکCOPYجدا بگذار. - بهعنوان کاربرِ غیرِ root اجرا کن؛ برای امنیتِ بیشتر، پایهی distroless را بسنج.
- حافظه را با
-XX:MaxRAMPercentage=75تنظیم کن، نه Xmxِ ثابت؛ و به یاد داشته باش off-heap هم حافظه میخورد. - CPU limit بگذار یا
-XX:ActiveProcessorCountرا پین کن تا JVM هستههای میزبان را با هستههای کانتینر اشتباه نگیرد. - برای حافظه
request = limitبگذار تا Pod در کلاسِ Guaranteed قرار گیرد. - graceful shutdown را با
server.shutdown=gracefulروشن کن وterminationGracePeriodSecondsرا از مجموعِ preStop و مهلتِ درین بزرگتر بگذار. - liveness را باریک نگهدار (فقط livenessState) و وابستگیها را در readiness چک کن؛ برای اپهای کند startupProbe بگذار.
- پیکربندی را از env/ConfigMap/Secret بگیر، نه هاردکد؛ و بدان Secretِ خامِ k8s فقط انکود شده، نه رمزنگاریشده.
- قبل از رفتن سراغِ native image، بپرس: «آیا واقعاً cold-start و حافظه مشکلِ اصلیِ من است، یا فقط دنبالِ براقی؟» اگر سرویسِ همیشهروشن است، JVMِ کلاسیک (شاید با CDS) اغلب کافی است.
پرسشهای مصاحبه
image یک قالبِ فقطخواندنیِ لایهای است که همهچیزِ لازم برای اجرا را دارد؛ container یک نمونهی در حالِ اجرا از یک image است. تشبیه: image دستور پخت، container غذای پخته. لایهایبودن یعنی هر دستورِ Dockerfile یک لایه میسازد و لایهها کش و بین ایمیجها بهاشتراک گذاشته میشوند. نکتهی سنیور: اگر لایهها را درست بچینی (وابستگیهای کمتغییر قبل از کدِ پرتغییر)، buildها سریعتر و push/pullها سبکتر میشوند چون فقط لایههای تغییرکرده دوباره منتقل میشوند.
پایهی jdk کامپایلر و ابزارِ توسعه را دارد که فقط برای build لازم است، نه اجرا؛ نگهداشتنِ آنها حجم و سطحِ حمله را بیدلیل بالا میبرد، پس در مرحلهی اجرا از jre یا distroless استفاده کن. اجرای root هم خطرناک است: اگر مهاجم از آسیبپذیریِ اپ سوءاستفاده کند، rootِ کانتینر یک قدم به rootِ میزبان نزدیکتر است. راهحل: multi-stage build برای حذفِ ابزارِ build، و USER برای اجرا با کاربرِ غیرِ root.
یک fat jar کدِ خودت را با دهها مگابایت وابستگی در یک فایل دارد؛ اگر آن را در یک لایهی Docker کپی کنی، هر تغییرِ کوچکِ کد کلِ آن لایه (شاملِ وابستگیهای تغییرنکرده) را باطل و مجبور به rebuild و re-push میکند. لایهبندی، jar را به لایههای منطقی (dependencies، spring-boot-loader، snapshot-dependencies، application) بر اساسِ نرخِ تغییر تقسیم میکند. با کپیِ هر لایه در یک COPY جدا، فقط لایهی نازکِ application دوباره ساخته میشود و وابستگیها از کش میآیند. در Spring Boot 3.3+ دستورِ -Djarmode=tools ... extract --layers این کار را میکند.
JVMِ قدیمی هنگامِ بالاآمدن حافظهی کلِ ماشینِ میزبان را میدید (نه سهمیهی کانتینر) و heap را بر اساسِ آن غولآسا میگرفت؛ روی نودِ بزرگ داخلِ کانتینرِ کوچک، heap از محدودیتِ cgroup رد میشد و kernel با OOMKilled container را میکشت. JDK مدرن با -XX:+UseContainerSupport (پیشفرض روشن از JDK 11) سهمیه را از cgroups میخواند نه از میزبان. اما هنوز باید heap را آگاهانه تنظیم کنی، معمولاً با -XX:MaxRAMPercentage.
-Xmx512m یک عددِ هاردکدشده است؛ وقتی محدودیتِ حافظهی کانتینر را در k8s عوض میکنی، این عدد دیگر همگام نیست و باید دستی بهروزش کنی. -XX:MaxRAMPercentage heap را بهصورتِ درصدی از حافظهی کانتینر تعیین میکند، پس با تغییرِ limit بهصورتِ خودکار مقیاس میگیرد. نکته: پیشفرضش ۲۵٪ است که برای k8s (یک اپ در هر کانتینر) اغلب کم است؛ معمولاً ۷۰-۷۵٪ میگذاری، و آن ۲۵٪ باقی را برای حافظهی off-heap (metaspace، thread stack، بافرهای native) نگه میداری.
JVMِ مدرن availableProcessors() را عمدتاً از CPU quota (همان limit) محاسبه میکند، نه از CPU request (که به shares نگاشت میشود). اگر limit نگذاری، JVM فکر میکند تمامِ هستههای نودِ میزبان مالِ اوست؛ روی نودِ ۶۴-هستهای این یعنی استخرهای ترد و GC غولآسا، سوییچِ کانتکستِ افراطی و مصرفِ حافظهی بیدلیل. راهحل: یا CPU limit بگذار، یا برای صراحت و قابلیتِ پیشبینی، -XX:ActiveProcessorCount=N را دستی پین کن.
graceful shutdown یعنی وقتی سیگنالِ SIGTERM میرسد، اپ پذیرشِ درخواستِ جدید را قطع میکند اما به درخواستهای در حالِ اجرا اجازه میدهد تمام شوند، بعد خارج میشود. در Spring Boot با server.shutdown=graceful و spring.lifecycle.timeout-per-shutdown-phase فعال میشود. در k8s یک ظرافت هست: بین حذفِ Pod و حذفِ IP از Service یک مسابقه وجود دارد، پس یک preStop hook با sleep کوتاه میگذاری. و terminationGracePeriodSeconds باید از مجموعِ preStop و مهلتِ درین بزرگتر باشد، وگرنه k8s وسطِ درین SIGKILL میفرستد.
Pod کوچکترین واحدِ اجراست (یک یا چند container که با هم زندگی و میمیرند) و گذراست — هر لحظه ممکن است با IPِ جدید جایگزین شود. Deployment حالتِ مطلوب را حفظ میکند («۳ نسخه از این image»)، Podهای مرده را جایگزین و در بهروزرسانی rolling update بدونِ downtime انجام میدهد. Service یک نامِ DNS و IPِ ثابتِ داخلی است که ترافیک را بینِ Podهای سالم load balance میکند؛ چون Podها عوض میشوند اما Service ثابت میماند، همیشه به Service وصل شو نه به IPِ Pod.
request آن چیزی است که scheduler برای جادادنِ Pod روی node تضمین میکند؛ limit سقفِ سخت است. ردکردن از limitِ CPU باعثِ throttling (کندشدن) و ردکردن از limitِ حافظه باعثِ OOMKilled میشود. برای حافظهی جاوا معمولاً بهتر است request = limit بگذاری، چون heapِ جاوا تا نزدیکِ سقف رشد میکند؛ برابربودنِ آنها Pod را در کلاسِ کیفیتِ سرویسِ Guaranteed قرار میدهد که آخرین قربانیِ evictionِ فشارِ حافظه است.
liveness: آیا پروسه زنده و بدونِ deadlock است؟ شکستش باعثِ restart میشود؛ باید باریک باشد. readiness: آیا آمادهی گرفتنِ ترافیک است؟ شکستش Pod را از Service برمیدارد اما restart نمیکند؛ اینجا میتوانی وابستگیها را چک کنی. startup: فقط هنگامِ بالاآمدن فعال است و تا وقتی موفق نشده liveness/readiness را معلق نگه میدارد؛ برای اپهای کندِ جاوا حیاتی است تا موقعِ warm-up بهاشتباه کشته نشوند.
چون اگر دیتابیس یک لحظه کند یا موقتاً در دسترس نباشد، liveness شکست میخورد و Kubernetes همهی Podها را restart میکند؛ آنها دوباره بالا میآیند، باز همان دیتابیسِ معیوب را میبینند و باز restart میشوند — یک حلقهی مرگ (crash loop) که فشار را بدتر و در دسترسبودن را نابود میکند. قانون: liveness فقط livenessState (آیا خودِ اپ گیر کرده؟)؛ وابستگیهای بیرونی فقط در readiness که Pod را کنار میگذارد اما نمیکشد، پس وقتی دیتابیس برگشت، Pod بدونِ restart دوباره به Service اضافه میشود.
طبقِ اصلِ Twelve-Factor، پیکربندی را از کد و image جدا کن؛ ایمیج باید یکسان بماند و رفتارش با env تغییر کند. در k8s پیکربندیِ غیرِ محرمانه در ConfigMap و رازها در Secret میرود و بهصورتِ env تزریق میشوند (Spring Boot با relaxed binding مثلاً SPRING_DATASOURCE_URL را میخواند). اما Secretِ خامِ k8s فقط base64 است، نه رمزنگاری — هرکسی decode میکند. برای امنیتِ واقعی: رمزنگاری در حالتِ استراحت روی etcd، محدودسازی با RBAC، هرگز commit نکردن، و برای رازهای حساس ابزارهای اختصاصی مثل Vault یا External Secrets.
native image بهجای JIT در زمانِ اجرا، همهچیز را AOT (قبل از اجرا) به یک باینریِ مستقل کامپایل میکند: بالاآمدن از ثانیهها به دهها میلیثانیه و مصرفِ حافظه بهشدت کم میشود. اما هزینهها: build کند (دقایق)، و چون تحلیلِ ایستا هرچیزِ غیرِقابلِدسترس را دور میریزد، reflection و proxyهای پویا و اسکنِ classpath نیاز به پیکربندیِ صریح دارند (Spring AOT بخشی را خودکار میکند). ضمناً توانِ اوجِ JIT اغلب بالاتر است. پس native برای serverless، CLI و scale-to-zero عالی است؛ برای سرویسِ همیشهروشنِ پرترافیک، JVMِ کلاسیک اغلب انتخابِ بهتری است.
CDS/AppCDS (Class Data Sharing). این وضعیتِ کلاسهای بارگذاریشده را در یک آرشیو ذخیره میکند تا در بالاآمدنهای بعدی بهجای بارگذاری و پارسِ دوباره، مستقیم از آرشیو خوانده شوند؛ نتیجه چند ده درصد کاهشِ زمانِ استارتآپ، بدونِ محدودیتهای reflectionِ native و بدونِ buildِ کند. جالب اینکه ساختارِ لایهبندیشدهی jarmode جدیدِ Spring Boot ذاتاً با CDS سازگار است، چون classpathِ قابلِپیشبینی اثربخشیِ کش را بالا میبرد. برای بسیاری از اپها، CDS تعادلِ عملیِ خوبی بینِ سادگی و سرعتِ بالاآمدن است.
- کانتینر اپ و کلِ محیطش را در یک جعبهی استاندارد و لایهای میپیچد؛ ایمیج قالبِ فقطخواندنی، container نمونهی در حالِ اجرا.
- ایمیجِ درستِ JVM: multi-stage build (حذفِ ابزارِ build)، کپیِ وابستگیها قبل از کد (کش)، لایهبندیِ Spring Boot (
-Djarmode=tools ... extract)، و اجرا با کاربرِ غیرِ root. - کوچککردن: jlink (runtime سفارشی، ~۴۰-۶۰MB) و distroless (بدون shell و package manager)؛ اما کمینهی افراطی هزینهی دیباگ دارد.
- آگاهیِ کانتینر:
-XX:+UseContainerSupport(پیشفرض روشن) سهمیه را از cgroups میخواند؛ heap را با-XX:MaxRAMPercentage=75بده (پیشفرضِ ۲۵٪ کم است) و off-heap را فراموش نکن. - CPU: JVM عددِ هسته را از quota (limit) میگیرد نه shares؛ بدونِ limit، هستههای میزبان را میبیند. با
-XX:ActiveProcessorCountپین کن. - graceful shutdown:
server.shutdown=graceful+preStop sleep+terminationGracePeriodSecondsبزرگتر از مجموعِ درین و preStop. - Kubernetes: Pod (گذرا)، Deployment (حالتِ مطلوب + rolling update)، Service (IPِ ثابت + load balance). برای حافظه
request = limit(کلاسِ Guaranteed). - probeها: liveness باریک (فقط livenessState، وگرنه crash loop)، readiness با وابستگیها، startup برای اپهای کند.
- native image (GraalVM/AOT): cold-start و حافظهی عالی برای serverless؛ اما build کند، محدودیتِ reflection، و توانِ اوجِ کمترِ از JIT. برای سرویسِ همیشهروشن، JVMِ کلاسیک (شاید با CDS) اغلب کافی است.
Let me make one fact clear up front: writing a Java app that "works on my laptop" is one thing; getting that same app into production so it's stable, small, fast, and scalable is another thing entirely. Standing between the two are two technologies that are now effectively the industry standard: containers, which wrap your app and all its dependencies into one portable box, and Kubernetes, which runs and manages an army of those boxes across dozens of machines. But Java behaves oddly in this world: the JVM is a creature that thinks it owns the whole machine, while it actually lives inside a container with a strict quota. If you don't understand that tension, your app either gets OOMKilled, or starts slowly, or drops users' requests on shutdown. In this chapter you'll learn not just the commands, but the why behind each one.
The path we'll walk together:
- Docker from zero — what images and containers are, and why they're layered.
- Building the right JVM image — from a naive Dockerfile to multi-stage, Spring Boot layering, and dependency caching.
- Shrinking the image —
jlinkfor a custom runtime and distroless bases. - JVM container awareness — cgroups,
MaxRAMPercentage, and the CPU /availableProcessorssaga. - Lifecycle — graceful shutdown and signal handling.
- Configuration — env, ConfigMaps, and Secrets instead of hardcoding.
- Kubernetes basics — Pod, Deployment, Service, and resource limits.
- Health — liveness/readiness/startup probes.
- Startup time and GraalVM native images — the big trade-off, and finally interview questions.
Part 0 — a few words you must feel before we start
Before any code, a few terms recur throughout this chapter. Let me plant them now.
- image: a read-only, layered template containing everything needed to run the app: a minimal OS, a Java runtime, your jar, and configuration. Like a "frozen snapshot" of a ready-to-run system.
- container: a running instance of an image. If the image is the recipe, the container is the cooked dish on the table.
- layer: each instruction in a Dockerfile creates a new layer. Layers are cached and shared across images; this fact is later the key to small, fast builds.
- registry: the image warehouse (like Docker Hub or GitHub Container Registry) where you
pushandpullimages. - cgroups (control groups): the Linux kernel mechanism that caps a container's CPU and memory quota. The hidden hero of this chapter — the thing the JVM must "see."
- orchestrator: a system like Kubernetes that decides which container runs on which machine, keeps them alive, and scales them.
Docker from zero: why containers at all?
Before standardized containers, cargo was barrels, sacks, and odd-shaped crates; every port had to handle each load individually and it was chaos. The standard steel container changed everything: every load goes into a uniform box, and every crane, ship, and truck anywhere in the world knows exactly how to handle it. A software container is the same idea: your app, the Java runtime, the libraries, and the config all go into one standard box that runs identically on the developer's laptop, the test server, and the production cloud. "Works on my machine" is no longer an excuse, because you shipped "my machine" inside the box too.
Before containers, environment differences were a nightmare: a different Java version, a missing env var, an absent system library. Docker killed those differences by packaging the app together with its entire runtime environment. Unlike a virtual machine (VM), which emulates a full OS and is heavy, a container shares the host OS kernel and isolates only the execution space; so it boots in a few hundred milliseconds and consumes negligible resources.
At the heart of it is a file called Dockerfile: the recipe for building an image. Let's start with a naive example that almost everyone writes first — and then see why it's wrong.
# Naive example — it works, but it's bad
FROM eclipse-temurin:25-jdk
WORKDIR /app
COPY . .
RUN ./mvnw package
CMD ["java", "-jar", "target/app.jar"]
This image works, but has three big problems we'll fix one by one:
- It's huge. The
jdkbase contains the full compiler and dev tooling, when running only needs a runtime. - It destroys the cache. With
COPY . ., any tiny code change invalidates every subsequent layer, including the Maven dependency download; every build becomes slow. - Build tooling stays in the production image. Maven, source code, and the
.m2cache needlessly live in the final image, inflating both size and attack surface.
Step one: the multi-stage build
The fix for "build tooling in the final image" is the multi-stage build: you use several FROM stages; in the first (builder) you build with the full JDK, and in the second you copy only the output into a slim JRE base. The build tooling never ends up in the final image.
# Stage 1: build with the full JDK
FROM eclipse-temurin:25-jdk AS builder
WORKDIR /app
# Copy only the dependency files first to preserve the layer cache
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline -B
# Now copy the code; a code change only invalidates from here on
COPY src/ src/
RUN ./mvnw clean package -DskipTests -B
# Stage 2: run with a slim base (runtime only)
FROM eclipse-temurin:25-jre AS runtime
WORKDIR /app
# Create a non-root user; never run as root
RUN useradd --system --uid 1001 appuser
USER 1001
COPY --from=builder /app/target/app.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
Docker caches layers top-down and, the moment it reaches the first changed layer, rebuilds it and every layer after it. Since dependencies change less often than your code, copy them before the source. With this ordering, as long as pom.xml hasn't changed, the heavy dependency-download layer comes from cache and subsequent builds are several times faster.
By default, the process inside a container runs as root. If an attacker exploits an app vulnerability, root inside the container is one step closer to root on the host. Always create a non-root user with USER (e.g. uid 1001) and run the app as that user. Many platforms outright forbid running containers as root.
Step two: proper jar layering in Spring Boot
Multi-stage solved the build-tooling problem, but a subtle cache problem remains. A regular Spring Boot jar is a "fat jar": your own code plus tens of megabytes of dependencies, all in one file. When you copy that in a single layer, any tiny change to your own code causes that entire layer — including all the unchanged dependencies — to be rebuilt and re-pushed.
Spring Boot has had a fix since version 2.3: the layered jar. The app jar is internally split into several logical layers ordered by "how often they change":
dependencies— release dependencies (rarely change).spring-boot-loader— Boot's own launcher classes (almost never change).snapshot-dependencies— SNAPSHOT dependencies (occasionally change).application— your own code (changes constantly).
By copying each layer in a separate COPY, Docker can keep the dependency layers cached and rebuild only the thin application layer. In Spring Boot 3.3 onward, the modern tools jarmode does this (superseding the older layertools):
FROM eclipse-temurin:25-jdk AS builder
WORKDIR /app
COPY .mvn/ .mvn/
COPY mvnw pom.xml ./
RUN ./mvnw dependency:go-offline -B
COPY src/ src/
RUN ./mvnw clean package -DskipTests -B
# Extract the jar into logical layers
RUN java -Djarmode=tools -jar target/app.jar extract --layers --destination extracted
FROM eclipse-temurin:25-jre
WORKDIR /app
RUN useradd --system --uid 1001 appuser
USER 1001
# Each layer in a separate COPY; low-churn first, high-churn last
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/ ./
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
A typical deploy changes only your own code, not the dependencies. With layering, a code change invalidates only the few-megabyte application layer, and the 50–200 MB of dependencies come from the registry and node cache. The result: faster push and pull, less bandwidth, and nimbler deploys — especially when you deploy dozens of times a day.
If you don't want to maintain a Dockerfile yourself, Spring Boot can build an optimized image with one command: ./mvnw spring-boot:build-image. It uses Cloud Native Buildpacks (the Paketo implementation) and automatically applies layering, a non-root user, and a "memory calculator" — with no Dockerfile. The price is that you give up fine control over the image instructions and take on a dependency on Paketo conventions. For many teams that's a good trade.
Step three: shrinking the image with jlink and distroless
So far we've dropped to the jre base, which itself is ~180 MB and carries the entire Java standard library, even modules your app never uses. Two tools can shrink this dramatically.
jlink: a custom runtime with only what you need
Java has been modular since version 9. jlink is a tool that builds a custom runtime containing only the modules your app needs. The result typically lands at 40–60 MB — versus the ~180 MB of a full JRE.
FROM eclipse-temurin:25-jdk AS jre-builder
# Discover the modules the app needs (or list them manually)
RUN jlink \
--add-modules java.base,java.logging,java.sql,java.naming,java.desktop,java.management \
--strip-debug --no-man-pages --no-header-files \
--compress zip-9 \
--output /javaruntime
FROM debian:stable-slim
COPY --from=jre-builder /javaruntime /opt/java
ENV PATH="/opt/java/bin:${PATH}"
COPY --from=builder /app/target/app.jar /app/app.jar
ENTRYPOINT ["java", "-jar", "/app/app.jar"]
Guessing the module list by hand is error-prone. The jdeps --print-module-deps app.jar tool extracts your app's module dependencies. But beware: code that invokes a module via reflection (like parts of Spring or database drivers) may not appear in static analysis; always actually run and test the final image so a ClassNotFoundException doesn't surprise you.
distroless: an image with no extra OS
Regular bases (like debian or ubuntu) carry a shell, a package manager, and dozens of tools that a Java app needs none of — yet each is a potential vulnerability. Google's distroless images contain only what's needed to run the program: no shell, no apt, no bash. This cuts both size and attack surface.
# distroless base for Java — no shell, no package manager
FROM gcr.io/distroless/java21-debian12:nonroot
COPY --from=builder /app/extracted/dependencies/ /app/
COPY --from=builder /app/extracted/spring-boot-loader/ /app/
COPY --from=builder /app/extracted/snapshot-dependencies/ /app/
COPY --from=builder /app/extracted/application/ /app/
WORKDIR /app
# distroless:nonroot runs as a non-root user by default
ENTRYPOINT ["java", "-jar", "app.jar"]
Since there's no shell, you can no longer docker exec -it ... bash to poke around inside the container. That's great for security but makes debugging harder. The fix: use the :debug tag (which ships a lightweight shell) only in development, lean on logs and metrics for observability, and use ephemeral debug tooling like kubectl debug, which attaches a temporary container next to the main pod.
Let's line up the base options:
| Base | Approx. size | shell/tools | Security | Best for |
|---|---|---|---|---|
temurin:25-jdk |
~450MB | full + compiler | low | build stage only |
temurin:25-jre |
~280MB | has shell | medium | simple, safe default |
jlink + debian-slim |
~120MB | has shell | good | shrinking the runtime |
distroless/java |
~230MB (base) | no shell | excellent | security-sensitive prod |
jlink + distroless |
~100MB | no shell | excellent | minimal size & attack surface |
The goal is not the smallest possible image; it's the smallest image that's still maintainable and debuggable. jlink + distroless is excellent but costs complexity and debugging pain. For most teams, a temurin:jre with Spring Boot layering and a non-root user is the right balance of size, security, and simplicity. Extreme optimization only pays off when you genuinely have thousands of pods or frequent cold starts.
JVM container awareness: Java's biggest container trap
Now we reach the most Java-specific part. The JVM was historically designed to run on a dedicated machine. When it boots, it asks the OS "how much RAM and how many CPUs do you have?" and sizes the heap, the number of GC threads, thread pool sizes, and much more based on that. The problem: inside a container, those old questions used to return the host machine's answer, not the container's quota. On a node with 64 GB of RAM, a JVM inside a 512 MB container thought it had 64 GB and grabbed a giant heap — and then got OOMKilled.
The JVM is like a tenant who rented a small apartment but thinks he owns the entire ten-story tower. He starts arranging ten stories' worth of furniture in a one-bedroom flat; naturally everything falls apart and the landlord (cgroup/kernel) evicts him (OOMKilled). Container awareness means reminding the tenant "you only have this one apartment"; then he furnishes it to the right size.
The good news: modern JVMs are container-aware. The flag -XX:+UseContainerSupport, which has been on by default since JDK 8u191 and in JDK 11 onward, makes the JVM read the quota from cgroups, not from the whole host. So on a modern JDK, memory is usually detected correctly. But you still need to set the heap size deliberately.
Tuning memory: why a fixed Xmx is bad and MaxRAMPercentage is good
The old way was to give the heap a fixed value: -Xmx512m. But when you change the container's memory limit in Kubernetes, that hardcoded number is no longer in sync. The correct container way is a percentage:
# The JVM takes at most 75% of the container's memory as heap
java -XX:MaxRAMPercentage=75.0 -jar app.jar
The default value of -XX:MaxRAMPercentage is 25%. So if your container has 1 GB of memory, the heap is only ~256 MB and the remaining 75% goes unused (or your small heap keeps GC-ing). That conservative default makes sense on a multi-app machine, but in Kubernetes, where you have one app per container, you usually want to push this to 70–75% so memory isn't wasted. The remaining 25% is needed for non-heap memory (metaspace, thread stacks, native buffers, code cache) — so never give 100%.
A common mistake: assuming "heap = total memory usage." No! The JVM's actual footprint includes: heap + metaspace (loaded classes) + thread stacks (~1 MB each) + code cache (JIT-compiled code) + native buffers (e.g. NIO/Netty). If you set the container limit exactly equal to Xmx, this off-heap usage pushes you over the limit and you get OOMKilled. That's why MaxRAMPercentage=75 is sensible: that 25% is the off-heap headroom.
CPU: the story fewer people get right
Memory is half the story; the other half is CPU, and here the traps run deeper. The JVM makes many decisions based on Runtime.availableProcessors(): the number of GC threads, the size of ForkJoinPool.commonPool(), the default sizes of connection and thread pools in frameworks. If this number is wrong, either your app spins up a nonsensical number of threads, or conversely stays underutilized.
In Kubernetes you have two CPU concepts that map to two different cgroup mechanisms:
- request → maps to
cpu.shares; that is, "my relative share when there's contention." It's a weight, not a cap. - limit → maps to
cpu.cfs_quota; a hard cap: in each time window, use at most this much CPU, or you get throttled.
The critical point: the modern JVM computes availableProcessors() primarily from the CPU quota (the limit), not from shares (the request). This means: if you don't set a CPU limit on your container, the JVM thinks it owns all the host node's cores. On a 64-core node, that means giant thread and GC pools, excessive context switching, and needless memory usage. If you set a fractional limit (e.g. 500m = half a core), the JVM rounds it up to 1. The explicit, predictable fix: pin the core count manually with -XX:ActiveProcessorCount=N.
# Pin the core count explicitly and predictably
java -XX:MaxRAMPercentage=75.0 -XX:ActiveProcessorCount=2 -jar app.jar
Let's line up the key memory and CPU flags:
| Flag | What it does | Default | Container recommendation |
|---|---|---|---|
-XX:+UseContainerSupport |
read quota from cgroups | on (JDK 11+) | leave it on |
-XX:MaxRAMPercentage |
heap cap as a % of container memory | 25% | 70–75% |
-XX:InitialRAMPercentage |
initial heap as a % | ~1.5% | equal to Max to avoid resizing |
-Xmx (fixed value) |
heap cap as an absolute number | automatic | only if you truly want it fixed |
-XX:ActiveProcessorCount |
number of CPUs the JVM assumes | from cgroup quota | pin to the CPU limit |
-XX:+ExitOnOutOfMemoryError |
exit fast instead of lingering | off | usually turn it on |
When an OutOfMemoryError occurs, the app often gets stuck half-dead: neither really working nor dying. Kubernetes can't detect "half-dead," and the broken pod stays alive. With -XX:+ExitOnOutOfMemoryError you say "the moment OOM hits, kill the process." Then the container exits, Kubernetes brings up a fresh healthy pod, and the self-healing cycle works. A fast death beats a slow zombie.
Lifecycle: graceful shutdown
When Kubernetes wants to move or scale down a pod, it doesn't kill it — first it sends a SIGTERM signal, meaning "please clean up nicely." If your app ignores this signal, in-flight requests are cut off half-done and the user gets an error. Graceful shutdown means: stop accepting new requests, but let the in-flight ones finish, then exit.
A good restaurant, at closing, locks the front door so no new customers come in, but doesn't abandon the tables that just ordered; it waits for them to finish their meal and then turns off the lights. A harsh shutdown is like cutting the restaurant's power mid-meal and throwing everyone out. SIGTERM is the "we're closing" announcement; graceful shutdown means closing politely.
Spring Boot supports this natively. You just give two settings:
# application.yml
server:
shutdown: graceful # the server enters drain mode
spring:
lifecycle:
timeout-per-shutdown-phase: 30s # max wait for active requests to finish
But in Kubernetes there's a critical subtlety: between the moment the pod is deleted and the moment its IP is removed from the Service endpoint list, there's a race. SIGTERM may arrive while the load balancer still sends traffic for a few more milliseconds. The standard fix is a preStop hook with a short sleep to cover this synchronization delay:
# in the container section of the Deployment
lifecycle:
preStop:
exec:
command: ["sh", "-c", "sleep 5"] # give k8s time to remove the IP from the Service
terminationGracePeriodSeconds: 45 # must be > (preStop sleep + drain timeout)
terminationGracePeriodSeconds must be greater than the sum of "preStop duration" and "Spring's drain timeout." If preStop sleeps five seconds and Spring waits up to thirty seconds to drain, this value must be at least 35–40 seconds. If you set it smaller, Kubernetes sends a SIGKILL mid-drain and voids all the graceful effort — requests get cut off. The Kubernetes default for this value is 30 seconds; you often need to raise it.
If you want to react to the signal more explicitly (e.g. close a queue or flush a cache), you can add a shutdown hook too:
@Component
class GracefulCleanup {
private static final Logger log = LoggerFactory.getLogger(GracefulCleanup.class);
@PreDestroy
void onShutdown() {
// this method runs when the context is closing
log.info("Flushing buffers and closing connections...");
// flush cache, close queue producer, and so on
}
}
Configuration: env, ConfigMaps, and Secrets
One of the "Twelve-Factor App" principles is: separate config from code. Never hardcode a database password or a service URL in the code or inside the image; your image should be identical and change behavior via external config. In the container world, that config comes from environment variables.
Spring Boot automatically maps environment variables to properties (this is called "relaxed binding"): the variable SPRING_DATASOURCE_URL maps to spring.datasource.url.
@ConfigurationProperties(prefix = "app.payment")
public record PaymentProperties(String apiUrl, String apiKey, int timeoutMs) { }
# application.yml — values come from env, not hardcoded
app:
payment:
api-url: ${PAYMENT_API_URL}
api-key: ${PAYMENT_API_KEY}
timeout-ms: ${PAYMENT_TIMEOUT_MS:3000} # with a default value
In Kubernetes, non-secret config goes in a ConfigMap and secret data goes in a Secret. Secrets are stored as base64 (note: base64 is not encryption, just encoding) and must be protected with real encryption-at-rest and RBAC access control.
apiVersion: v1
kind: ConfigMap
metadata:
name: payment-config
data:
PAYMENT_API_URL: "https://payments.internal/api"
PAYMENT_TIMEOUT_MS: "3000"
---
apiVersion: v1
kind: Secret
metadata:
name: payment-secret
type: Opaque
stringData: # stringData means you don't base64 it yourself
PAYMENT_API_KEY: "sk_live_secret_value"
Then in the Deployment you inject these as env:
envFrom:
- configMapRef:
name: payment-config
- secretRef:
name: payment-secret
Beginners think that because it's called a Secret, it's secure. Anyone can decode base64 in a second. For real security: (1) enable encryption-at-rest on etcd, (2) restrict access to Secrets with RBAC, (3) never commit a Secret to git, and (4) for sensitive secrets, use dedicated solutions like Vault or the External Secrets Operator. A raw Kubernetes Secret is the bare minimum, not the final answer.
Kubernetes basics: Pod, Deployment, Service
Now that we have a good image, let's see how Kubernetes runs it. There are three foundational concepts you must distinguish.
Imagine you run a hotel. A Pod is a work crew — the smallest unit of execution; one or more containers that live and die together. A Deployment is like a manager who says "there must always be exactly 3 crews on duty"; if one gets sick (crashes), it immediately brings a replacement, and on an update it swaps crews one at a time without stopping work. A Service is a fixed front desk with a stable internal number; guests deal with the front desk, not a specific crew — because crews keep changing but the desk stays constant.
- Pod: the smallest runnable unit. Usually one container (your app) but sometimes several that must sit together. Pods are ephemeral: they can die at any moment and be replaced with a new IP. So never rely on a Pod's IP directly.
- Deployment: you declare the desired state ("3 of this Pod with this image") and Kubernetes maintains it: it replaces dead Pods and, on updates, performs a rolling update (new Pods up, old ones down, no downtime).
- Service: a stable internal DNS name and IP that load-balances traffic across healthy Pods. Even as Pods change, the Service stays the same.
A complete Deployment for a Java app looks like this:
apiVersion: apps/v1
kind: Deployment
metadata:
name: orders
spec:
replicas: 3 # always 3 replicas
selector:
matchLabels: { app: orders }
template:
metadata:
labels: { app: orders }
spec:
containers:
- name: orders
image: registry.example.com/orders:1.4.2
ports:
- containerPort: 8080
resources:
requests: # guaranteed minimum
memory: "512Mi"
cpu: "500m"
limits: # hard cap
memory: "512Mi"
cpu: "1000m"
env:
- name: JAVA_TOOL_OPTIONS
value: "-XX:MaxRAMPercentage=75.0 -XX:ActiveProcessorCount=1"
---
apiVersion: v1
kind: Service
metadata:
name: orders
spec:
selector: { app: orders }
ports:
- port: 80
targetPort: 8080
Resource limits: request vs limit
This distinction is the heart of scheduling and stability:
- request is what the scheduler guarantees to fit the Pod on a node. If the node doesn't have
requestworth of room, the Pod won't go there. - limit is a cap that, if exceeded, causes throttling (slowdown) for CPU and OOMKilled (killed container) for memory.
A senior tip: in Java it's usually best to set the memory request equal to the limit. Why? Because the Java heap tends to grow toward its ceiling and stay there; setting request below limit creates the illusion that the node has spare capacity, when every JVM sooner or later reaches its ceiling — and then the node, under memory pressure, evicts Pods. With request = limit, your Pod lands in the Guaranteed quality-of-service class and is the last victim of memory pressure.
Unlike memory (where exceeding the limit = death), exceeding the CPU limit only throttles: the core is withheld and your process slows. This can lead to strange latency spikes, especially during warm-up or GC. Some teams deliberately drop the CPU limit to avoid throttling (but then you must pin -XX:ActiveProcessorCount so the JVM doesn't see the host's cores). This is an active debate; the point is to understand each behavior and choose deliberately.
Health: liveness, readiness, and startup probes
How does Kubernetes know your Pod is healthy? Via probes — periodic checks. There are three kinds, and confusing them is one of the most common production mistakes.
Imagine you supervise an employee. liveness means "is she even alive, or do I need to wake her up?" — if dead, fire and replace (restart). readiness means "is she ready to take new work, or is she busy / still getting ready?" — if not ready, don't give her work for now (take her out of the load balancer) but don't fire her. startup means "she just arrived; until she's settled in, don't pester her with the other two questions." This distinction is critical: an app that boots slowly must not be killed by liveness during startup.
- liveness probe: is the process alive and healthy? If it fails, Kubernetes restarts the container. Keep this narrow: only check that the app itself hasn't deadlocked; don't tie it to external dependencies (database, another service).
- readiness probe: is the app ready to take traffic? If it fails, the Pod is removed from the Service (but not restarted). Here you may check critical dependencies.
- startup probe: active only during boot; it gives the app time to fully come up and holds liveness/readiness suspended until then. Critical for Java apps that need a few seconds of warm-up.
Spring Boot Actuator provides these out of the box. You just enable the probes (auto-detected in Kubernetes) so two endpoints appear:
# application.yml
management:
endpoint:
health:
probes:
enabled: true
group:
liveness:
include: livenessState # just the app itself
readiness:
include: readinessState,db # app + critical dependencies
endpoints:
web:
exposure:
include: health,info,metrics,prometheus
This creates two paths: /actuator/health/liveness and /actuator/health/readiness. Now you point to them in the Deployment:
startupProbe: # up to 5 minutes to boot
httpGet: { path: /actuator/health/readiness, port: 8080 }
failureThreshold: 30
periodSeconds: 10
livenessProbe:
httpGet: { path: /actuator/health/liveness, port: 8080 }
periodSeconds: 10
failureThreshold: 3
readinessProbe:
httpGet: { path: /actuator/health/readiness, port: 8080 }
periodSeconds: 10
failureThreshold: 3
The most common probe mistake: putting a database check in liveness. Imagine the database slows for a moment; liveness fails, Kubernetes restarts all the Pods, they boot back up and again see the slow database and restart again — a crash loop that makes the pressure worse. The rule: liveness only livenessState (has the app itself hung?); external dependencies only in readiness (which sidelines the Pod but doesn't kill it). This distinction is the difference between a self-healing system and a self-destructing one.
A Spring Boot app may need 15–30 seconds to boot. If you only have liveness with a short initialDelaySeconds, Kubernetes kills it mid-boot and it never comes up. The startupProbe solves this: with failureThreshold: 30 and periodSeconds: 10 it grants up to 5 minutes, and until it succeeds, liveness and readiness don't run at all. The moment startup succeeds, those two take over with their tight cadence.
Startup time and GraalVM native images
Java apps have a historical weakness in the container world: startup time. The JVM must boot, load classes, and JIT-compile the hot code to reach peak speed. A Spring Boot app may take a few seconds to be ready and tens of seconds to reach peak performance. For an always-on server this doesn't matter, but for serverless/FaaS and fast scaling (scale-to-zero, sudden traffic) this cold start is expensive.
The classic JVM is like a heavy diesel engine: it takes time to start and must warm up, but once warm it has extraordinary, sustained power (the JIT compiles hot code into the most optimal form and gets faster over time). A GraalVM native image is like an electric motor: the instant you press the button, full torque is present (boot in ~50 ms), but its torque ceiling is fixed because it never gets a chance to warm up and optimize at runtime.
GraalVM native image is a fundamentally different approach: instead of compiling code at runtime (JIT), it compiles everything ahead of time (AOT) into a standalone native executable. The result: boot drops from seconds to tens of milliseconds and memory usage drops dramatically (often several-fold), because the entire JIT infrastructure and much of the metadata are removed. Spring Boot 3 onward has first-class support for this via Spring AOT.
# Build a native image with the Spring Boot plugin
./mvnw -Pnative native:compile
# Or build a native container image directly
./mvnw -Pnative spring-boot:build-image
But this miracle isn't free. AOT works via a static analysis (points-to analysis) that decides at build time which code is reachable and discards the rest. Anything dynamic — reflection, dynamic proxies, dynamic class loading, runtime classpath scanning — must be declared to it explicitly, or it breaks at runtime. Spring AOT automates much of this, but third-party libraries may need manual configuration.
Let's make the trade-off clear:
| Aspect | Classic JVM (JIT) | Native image (GraalVM AOT) |
|---|---|---|
| Startup time | seconds | tens of milliseconds |
| Memory usage | higher | far lower |
| Peak throughput | highest (JIT optimizes) | good, but often below JIT's peak |
| Build time | fast | slow (minutes) |
| dynamic reflection/proxy | painless | needs configuration |
| Executable size | small jar + JRE | larger standalone binary |
| Best for | always-on, high-traffic service | serverless, CLI, scale-to-zero |
Native image shines when cold start and memory usage are your most important metrics: serverless functions, CLI tools, or microservices that constantly scale-to-zero. But if you have a service that's always on with steady high traffic, the classic JVM with JIT often delivers higher peak throughput because it optimizes hot code over time — and you avoid the build complexity and reflection constraints. For the majority of enterprise services, the classic JVM is still the right default; native is a specialized tool, not a universal replacement.
If cold start bothers you but you don't want to pay the native cost, there's a middle option: CDS (Class Data Sharing) and especially AppCDS, improved in modern Java. This stores the state of loaded classes in an archive so subsequent boots read them faster, cutting startup time by tens of percent — without the reflection constraints. Interestingly, Spring Boot's new layered jarmode structure is inherently CDS-friendly, because a predictable classpath makes the cache more effective.
Best practices
- Always use a multi-stage build so build tooling doesn't end up in the final image; and copy dependencies before the code to preserve the cache.
- In Spring Boot, use jar layering (
-Djarmode=tools ... extract --layers) and put each layer in a separateCOPY. - Run as a non-root user; for extra security, evaluate a distroless base.
- Tune memory with
-XX:MaxRAMPercentage=75, not a fixed Xmx; and remember off-heap eats memory too. - Set a CPU limit or pin
-XX:ActiveProcessorCountso the JVM doesn't confuse the host's cores with the container's. - For memory, set
request = limitso the Pod lands in the Guaranteed class. - Enable graceful shutdown with
server.shutdown=gracefuland setterminationGracePeriodSecondslarger than the sum of preStop and the drain timeout. - Keep liveness narrow (just livenessState) and check dependencies in readiness; add a startupProbe for slow apps.
- Get config from env/ConfigMap/Secret, not hardcoding; and know that a raw k8s Secret is only encoded, not encrypted.
- Before reaching for a native image, ask: "is cold start and memory really my main problem, or am I just chasing shiny?" If it's an always-on service, the classic JVM (perhaps with CDS) is often enough.
Interview questions
An image is a read-only, layered template containing everything needed to run; a container is a running instance of an image. Analogy: image is the recipe, container is the cooked dish. Being layered means each Dockerfile instruction creates a layer, and layers are cached and shared across images. Senior point: if you order the layers correctly (low-churn dependencies before high-churn code), builds get faster and push/pull get lighter because only changed layers are re-transferred.
The jdk base carries the compiler and dev tooling that are only needed for the build, not for running; keeping them needlessly inflates size and attack surface, so use jre or distroless in the run stage. Running as root is dangerous too: if an attacker exploits an app vulnerability, container root is one step closer to host root. The fix: a multi-stage build to drop the build tooling, and USER to run as a non-root user.
A fat jar holds your own code plus tens of megabytes of dependencies in one file; if you copy it in a single Docker layer, any tiny code change invalidates that whole layer (including unchanged dependencies) and forces a rebuild and re-push. Layering splits the jar into logical layers (dependencies, spring-boot-loader, snapshot-dependencies, application) by change rate. By copying each layer in a separate COPY, only the thin application layer is rebuilt and dependencies come from cache. In Spring Boot 3.3+, the -Djarmode=tools ... extract --layers command does this.
The old JVM, on boot, saw the whole host machine's memory (not the container's quota) and sized a giant heap accordingly; on a big node inside a small container, the heap exceeded the cgroup limit and the kernel OOMKilled the container. The modern JDK, with -XX:+UseContainerSupport (on by default since JDK 11), reads the quota from cgroups rather than the host. But you still need to set the heap deliberately, usually with -XX:MaxRAMPercentage.
-Xmx512m is a hardcoded number; when you change the container's memory limit in k8s, that number is no longer in sync and you must update it by hand. -XX:MaxRAMPercentage sets the heap as a percentage of container memory, so it scales automatically as the limit changes. Note: its default is 25%, which is often too low for k8s (one app per container); you usually set 70–75%, keeping that remaining 25% for off-heap memory (metaspace, thread stacks, native buffers).
The modern JVM computes availableProcessors() primarily from the CPU quota (the limit), not from the CPU request (which maps to shares). If you don't set a limit, the JVM thinks it owns all the host node's cores; on a 64-core node that means giant thread and GC pools, excessive context switching, and needless memory usage. The fix: either set a CPU limit, or for explicitness and predictability, pin -XX:ActiveProcessorCount=N manually.
Graceful shutdown means that when a SIGTERM arrives, the app stops accepting new requests but lets in-flight ones finish, then exits. In Spring Boot it's enabled with server.shutdown=graceful and spring.lifecycle.timeout-per-shutdown-phase. In k8s there's a subtlety: there's a race between deleting the Pod and removing its IP from the Service, so you add a preStop hook with a short sleep. And terminationGracePeriodSeconds must be larger than the sum of preStop and the drain timeout, or k8s sends a SIGKILL mid-drain.
A Pod is the smallest unit of execution (one or more containers that live and die together) and is ephemeral — it can be replaced at any moment with a new IP. A Deployment maintains the desired state ("3 replicas of this image"), replaces dead Pods, and does a no-downtime rolling update on changes. A Service is a stable internal DNS name and IP that load-balances traffic across healthy Pods; since Pods change but the Service stays constant, always connect to the Service, not a Pod's IP.
request is what the scheduler guarantees to fit the Pod on a node; limit is the hard cap. Exceeding the CPU limit causes throttling (slowdown), and exceeding the memory limit causes OOMKilled. For Java memory it's usually best to set request = limit, because the Java heap grows toward its ceiling; making them equal puts the Pod in the Guaranteed quality-of-service class, which is the last victim of memory-pressure eviction.
liveness: is the process alive and free of deadlock? Failure triggers a restart; it must be narrow. readiness: is it ready to take traffic? Failure removes the Pod from the Service but doesn't restart it; here you can check dependencies. startup: active only during boot and holds liveness/readiness suspended until it succeeds; critical for slow Java apps so they aren't killed by mistake during warm-up.
Because if the database slows or is briefly unavailable, liveness fails and Kubernetes restarts all the Pods; they boot back up, again see the same broken database, and restart again — a crash loop that makes the pressure worse and destroys availability. The rule: liveness only livenessState (has the app itself hung?); external dependencies only in readiness, which sidelines the Pod but doesn't kill it, so when the database returns, the Pod is re-added to the Service without a restart.
Per the Twelve-Factor principle, separate config from code and image; the image should stay identical and change behavior via env. In k8s, non-secret config goes in a ConfigMap and secrets in a Secret, injected as env (Spring Boot's relaxed binding reads e.g. SPRING_DATASOURCE_URL). But a raw k8s Secret is only base64, not encryption — anyone can decode it. For real security: encryption-at-rest on etcd, restriction via RBAC, never committing it, and dedicated tools like Vault or External Secrets for sensitive secrets.
Instead of JIT at runtime, a native image compiles everything AOT (ahead of time) into a standalone binary: boot drops from seconds to tens of milliseconds and memory usage drops sharply. But the costs: slow build (minutes), and because static analysis discards anything unreachable, reflection, dynamic proxies, and classpath scanning need explicit configuration (Spring AOT automates some). Also, JIT's peak throughput is often higher. So native is great for serverless, CLI, and scale-to-zero; for an always-on, high-traffic service, the classic JVM is often the better choice.
CDS/AppCDS (Class Data Sharing). It stores the state of loaded classes in an archive so subsequent boots read straight from the archive instead of loading and parsing again; the result is tens of percent off startup time, without native's reflection constraints and without the slow build. Interestingly, Spring Boot's new layered jarmode structure is inherently CDS-friendly, because a predictable classpath boosts cache effectiveness. For many apps, CDS is a good practical balance between simplicity and boot speed.
- A container wraps the app and its whole environment in a standard, layered box; the image is the read-only template, the container the running instance.
- The right JVM image: multi-stage build (drop build tooling), copy dependencies before code (cache), Spring Boot layering (
-Djarmode=tools ... extract), and run as a non-root user. - Shrinking: jlink (custom runtime, ~40–60MB) and distroless (no shell or package manager); but extreme minimalism costs debugging ease.
- Container awareness:
-XX:+UseContainerSupport(on by default) reads the quota from cgroups; set the heap with-XX:MaxRAMPercentage=75(the 25% default is too low) and don't forget off-heap. - CPU: the JVM derives the core count from the quota (limit), not shares; without a limit it sees the host's cores. Pin it with
-XX:ActiveProcessorCount. - graceful shutdown:
server.shutdown=graceful+preStop sleep+terminationGracePeriodSecondslarger than the sum of drain and preStop. - Kubernetes: Pod (ephemeral), Deployment (desired state + rolling update), Service (stable IP + load balance). For memory,
request = limit(Guaranteed class). - probes: narrow liveness (just livenessState, or you get a crash loop), readiness with dependencies, startup for slow apps.
- native image (GraalVM/AOT): excellent cold start and memory for serverless; but slow build, reflection constraints, and lower peak throughput than JIT. For an always-on service, the classic JVM (perhaps with CDS) is often enough.