Dockerize Spring Boot: Từ ‘Khổng Lồ’ 800MB xuống 150MB và Tuyệt Chiêu Trị OOM

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

Nỗi khổ khi Dockerize app Java kiểu “ngây thơ”

Anh em làm Java chắc không ít lần méo mặt khi thấy file JAR của Spring Boot nặng vài trăm MB. Tệ hơn, khi tống nó vào một Docker image cơ bản, dung lượng có thể vọt lên cả GB. Hồi mới “nhập môn”, mình cứ lấy image openjdk:17 rồi COPY file JAR vào là xong. Kết quả? Image phình to, build chậm chạp, và khi lên Production thì container thường xuyên bị “đột tử” (OOM) mà chẳng rõ nguyên nhân.

Mình từng quản lý một cluster chạy hơn 30 microservices trên AWS. Việc không tối ưu Dockerfile khiến chi phí lưu trữ ECR và RAM đội lên đáng kể. Sau khi áp dụng Multi-stage Build và tinh chỉnh JVM, mình đã cắt giảm được 40% tài nguyên tiêu thụ. Cụ thể, RAM mỗi instance giảm từ 512MB xuống còn khoảng 300MB mà app vẫn chạy mượt. Bài viết này là checklist đúc rút từ những lần “trả giá” đó.

Tại sao image của bạn lại “béo” đến thế?

Cái bẫy lớn nhất là mang cả môi trường build vào môi trường chạy (Runtime). Để chạy một file JAR, bạn chỉ cần JRE. Nhưng để build nó, bạn lại lôi theo cả JDK, Maven, source code và hàng tá thư viện rác trong thư mục .m2. Điều này không chỉ gây lãng phí mà còn mở ra lỗ hổng bảo mật (attack surface) không đáng có.

Multi-stage Build giải quyết vấn đề này bằng cách chia cuộc chơi thành hai hiệp:

  • Hiệp 1 (Build): Dùng image đầy đủ đồ nghề để compile và đóng gói.
  • Hiệp 2 (Run): Chỉ bốc duy nhất file JAR sang một image JRE cực nhẹ để chạy.

Kết quả là image cuối cùng sẽ cực kỳ nhỏ gọn, giúp bạn tiết kiệm băng thông khi push/pull image và tăng tốc độ deployment đáng kể.

Thực hành: Dockerfile “chuẩn chỉ” cho dự án Spring Boot

Dưới đây là mẫu Dockerfile mình thường dùng cho các dự án Maven. Anh em dùng Gradle chỉ cần thay đổi câu lệnh build tương ứng.

# Stage 1: Build stage
FROM maven:3.8.4-openjdk-17-slim AS build
WORKDIR /app

# Tối ưu layer caching: Download dependencies trước
COPY pom.xml .
RUN mvn dependency:go-offline -B

# Copy source code và build
COPY src ./src
RUN mvn package -DskipTests

# Stage 2: Runtime stage
FROM eclipse-temurin:17-jre-alpine
WORKDIR /app

# Tạo user non-root để tránh bị "hỏi thăm" bảo mật
RUN addgroup -S spring && adduser -S spring -G spring
USER spring:spring

# Copy file jar từ build stage
COPY --from=build /app/target/*.jar app.jar

# Cấu hình JVM tối ưu cho container
ENV JAVA_OPTS="-XX:MaxRAMPercentage=75.0 -XX:+UseContainerSupport -Djava.security.egd=file:/dev/./urandom"

ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar app.jar"]

Giải thích nhanh các kỹ thuật đã dùng:

  1. Layer Caching (Dòng 6-7): Việc tách riêng pom.xml giúp Docker cache lại các thư viện đã tải. Nếu bạn chỉ sửa code mà không thêm thư viện mới, bước này sẽ được bỏ qua, giúp tốc độ build nhanh hơn gấp 5-10 lần.
  2. Image Alpine (Dòng 13): Dùng alpine giúp image nhẹ tênh, chỉ khoảng 120MB thay vì 600MB như bản Ubuntu truyền thống.
  3. Non-root User (Dòng 17-18): Mặc định Docker chạy quyền root. Nếu app bị dính lỗi bảo mật, hacker có thể chiếm luôn quyền điều khiển container. User spring sẽ giới hạn phạm vi phá hoại này.
  4. JVM Tuning (Dòng 24): Đây là “chìa khóa” để trị lỗi OOM Killer trên Production.

Đừng để JVM “cãi” lệnh của Docker

Sai lầm kinh điển là set cứng RAM kiểu -Xmx1g. Trong container, điều này cực kỳ rủi ro. Nếu container chỉ được cấp 1GB RAM (--memory=1g) mà bạn cấu hình JVM dùng đúng 1GB, app chắc chắn sẽ bị hệ điều hành tiêu diệt. Lý do là JVM còn cần bộ nhớ cho Metaspace, Stack và Native Memory nữa.

Các tham số “vàng” mình khuyên dùng:

  • -XX:+UseContainerSupport: Giúp JVM nhận biết chính xác giới hạn tài nguyên từ cgroups của Docker.
  • -XX:MaxRAMPercentage=75.0: Thay vì set cứng, hãy bảo JVM chỉ dùng tối đa 75% lượng RAM được cấp. 25% còn lại để dành cho OS và các thành phần phụ trợ. Đây là con số an toàn nhất qua nhiều bài test thực tế.
  • -Djava.security.egd=file:/dev/./urandom: Giúp Spring Boot khởi động nhanh hơn trên Linux bằng cách tăng tốc độ sinh số ngẫu nhiên cho các token bảo mật.

Con số thực tế không biết nói dối

Sau khi áp dụng bộ giải pháp này, hệ thống của mình đã thay đổi rõ rệt:

  • Dung lượng Image: Giảm ngoạn mục từ 650MB xuống còn 155MB.
  • Thời gian Build: Các lần build sau chỉ mất 40 giây thay vì hơn 4 phút như trước nhờ tận dụng cache tốt.
  • Độ ổn định: Tình trạng Container bị Restart bất thình lình vào lúc 2 giờ sáng gần như biến mất hoàn toàn.

Việc tiết kiệm vài trăm MB RAM nghe có vẻ nhỏ, nhưng ở quy mô 100 container, bạn sẽ tiết kiệm được hàng nghìn USD chi phí hạ tầng mỗi năm. Đó chính là sự khác biệt giữa một developer và một engineer thực thụ.

Chốt hạ

Dockerize Spring Boot không chỉ đơn giản là viết vài dòng lệnh cho chạy được. Nó là sự kết hợp giữa việc tối ưu dung lượng và cấu hình JVM thông minh. Đừng quên dùng docker stats để theo dõi sức khỏe của container thường xuyên. Nếu có điều kiện, hãy setup thêm Prometheus để có cái nhìn tổng thể hơn về hiệu năng app của mình. Chúc anh em tối ưu thành công!

Share: