Ảnh Docker của một ứng dụng Java rất dễ phình to. Bài này đo năm cách đóng gói cùng một ứng dụng, và kết quả trải từ 866 megabyte xuống 126.

Cộng một cái bẫy mà tôi gặp ngay trong lúc đo, và nó đặc biệt liên quan tới người viết tiếng Việt.

Năm cách, năm kích thước

  1. JDK đầy đủ + Maven + mã nguồn        : 866 MB
  2. build nhiều tầng, chạy trên JRE      : 519 MB
  3. như trên, JRE bản alpine             : 285 MB
  4. jlink + debian:12-slim               : 197 MB
  5. jlink + distroless                   : 126 MB

Gần bảy lần, cùng một tệp jar.

Cách 1: cái không nên làm

FROM maven:3.9-eclipse-temurin-21
COPY . .
RUN mvn package -DskipTests
CMD ["java","-jar","target/app.jar"]

Ảnh này chứa Maven, JDK đầy đủ, toàn bộ mã nguồn, và cả kho ~/.m2 với mọi phụ thuộc đã tải.

Ngoài chuyện nặng, nó còn là vấn đề bảo mật: mã nguồn của bạn nằm trong ảnh, và ai kéo được ảnh là đọc được. Trình biên dịch có sẵn trong ảnh cũng là công cụ cho kẻ tấn công nếu họ vào được container.

Cách 2: build nhiều tầng

FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn -q dependency:go-offline      # tầng này được đệm lại
COPY src ./src
RUN mvn -q package -DskipTests

FROM eclipse-temurin:21-jre           # ảnh chạy KHÔNG có Maven, không có mã nguồn
COPY --from=build /app/target/app.jar .
CMD ["java","-jar","app.jar"]

866 xuống 519 MB, và ảnh cuối chỉ có JRE cộng một tệp jar.

Chú ý thứ tự hai lệnh COPY. Chép pom.xml trước rồi tải phụ thuộc, sau đó mới chép mã nguồn. Nhờ vậy khi bạn sửa mã mà không đổi phụ thuộc, Docker dùng lại tầng đã đệm và bỏ qua bước tải — thứ tốn nhiều thời gian nhất trong build.

Chép cả . vào trước rồi mới build là mất sạch lợi ích đó: mọi thay đổi nhỏ đều làm hỏng bộ đệm.

Cách 3: đổi ảnh nền

Chỉ đổi eclipse-temurin:21-jre thành eclipse-temurin:21-jre-alpine: 519 xuống 285 MB.

Alpine dùng musl thay cho glibc nên gọn hơn nhiều. Đánh đổi cần biết: musl khác glibc ở vài chỗ, và mã native (JNI) biên dịch cho glibc sẽ không chạy. Với ứng dụng Java thuần thì hiếm khi thành vấn đề, nhưng nếu bạn dùng thư viện có phần native — một số driver, thư viện nén, xử lý ảnh — hãy kiểm kỹ.

Java 9 chia JDK thành module, và jlink cho phép dựng một runtime chỉ chứa những module ứng dụng cần.

RUN jdeps --ignore-missing-deps --print-module-deps --multi-release 21 \
      target/app.jar > /tmp/mods.txt

RUN jlink --add-modules $(cat /tmp/mods.txt) \
      --strip-debug --no-man-pages --no-header-files --compress=2 \
      --output /jre-nho

jdeps phân tích jar và in ra danh sách module. Với ứng dụng thử nghiệm của tôi, nó trả về đúng một dòng:

  java.base

Runtime cắt ra từ đó nhỏ hơn hẳn JRE đầy đủ, và ghép với debian:12-slim cho 197 MB, với distroless cho 126 MB.

Distroless nhỏ vì nó không có shell, không có trình quản lý gói, gần như không có gì ngoài thư viện hệ thống. Đó cũng là điểm mạnh về bảo mật: không có sh thì nhiều kiểu tấn công không thực hiện được. Đổi lại, bạn không docker exec vào xem được — nên hãy chắc rằng log và các endpoint chẩn đoán đủ dùng.

Lưu ý về jdeps: nó chỉ thấy phụ thuộc tĩnh. Mã dùng phản chiếu, ServiceLoader, hay nạp driver JDBC lúc chạy sẽ không được phát hiện, và ứng dụng sẽ chết vì thiếu module. Với ứng dụng thật, hãy thêm tay những module hay cần: java.sql, java.naming, java.management, jdk.crypto.ec (thiếu cái cuối thì HTTPS hỏng theo kiểu rất khó đoán).

Cái bẫy: ảnh nhỏ nhất in sai tiếng Việt

Chạy thử cả năm ảnh, và ảnh số 4 cho ra thế này:

  ảnh 3: ứng dụng chạy, map=1000, khởi động 6 ms
  ảnh 4: ?ng d?ng ch?y, map=1000, kh?i ??ng 13 ms      <- dấu hỏi
  ảnh 5: ứng dụng chạy, map=1000, khởi động 12 ms

Cùng jar, cùng runtime jlink, chỉ khác ảnh nền. Nguyên nhân nằm ở đây:

  debian:12-slim  : native.encoding = ANSI_X3.4-1968   sun.jnu.encoding = ANSI_X3.4-1968
  distroless      : native.encoding = UTF-8            sun.jnu.encoding = UTF-8

ANSI_X3.4-1968 là tên chính thức của ASCII. Ảnh nền tối giản không có cấu hình vùng, nên Java thấy bảng mã hệ thống là ASCII và mọi ký tự ngoài bảng đó thành dấu hỏi.

Chú ý file.encodingUTF-8cả hai — đó là mặc định từ Java 18 như bài 58 đã nói. Nhưng luồng ra console dùng stdout.encoding, mà cái này theo native.encoding. Nên bạn có thể đọc ghi tệp UTF-8 hoàn hảo trong khi log ra màn hình vẫn hỏng.

Hai cách sửa, tôi thử cả hai và đều chạy:

ENV LANG=C.UTF-8
# hoặc
CMD ["java","-Dsun.stdout.encoding=UTF-8","-Dsun.stderr.encoding=UTF-8","-jar","app.jar"]

Cách thứ nhất gọn hơn và sửa luôn cả tên tệp có dấu.

Kích thước ảnh không làm khởi động nhanh hơn

Đây là chỗ tôi vào với kỳ vọng sai:

  ảnh 2 (519 MB) : 259 ms
  ảnh 3 (285 MB) : 239 ms
  ảnh 5 (126 MB) : 306 ms

Ảnh nhỏ nhất khởi động chậm nhất. Khác biệt nằm trong khoảng nhiễu, và điều đáng nói là: một khi ảnh đã có sẵn trên máy, kích thước gần như không ảnh hưởng thời gian khởi động.

Vậy ảnh nhỏ có ích ở đâu? Thời gian kéo ảnh về — quan trọng khi triển khai, khi mở rộng theo chiều ngang, và khi CI kéo ảnh mỗi lần build. Cộng chi phí lưu trữ ở registry, và bề mặt tấn công nhỏ hơn.

Đó là những lý do đủ tốt. Nhưng đừng bán ảnh nhỏ bằng lý do "khởi động nhanh hơn" — phép đo không ủng hộ.

Tham số JVM trong container

  container 1 GB -> heap tối đa 247 MB, nhân 16

Đúng 25% như bài 82 đã đo. Ba điều cần làm:

Dùng -XX:MaxRAMPercentage, đừng dùng -Xmx cố định. Cùng một ảnh chạy đúng trên mọi kích thước container.

ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=60 -XX:MaxMetaspaceSize=256m"

JAVA_TOOL_OPTIONS được JVM đọc tự động, nên bạn không phải sửa CMD — và người vận hành ghi đè được từ bên ngoài mà không phải dựng lại ảnh.

Đặt TZ. Mặc định trong container là UTC, và bài 62 đã kể chuyện lệch giờ vì điều này. Nhớ cả tzdata nếu ảnh nền tối giản không có.

Đừng chạy bằng root. Thêm một người dùng thường:

RUN useradd -r -u 1001 ungdung
USER 1001

Với distroless thì đã có sẵn người dùng nonroot.

Vài chi tiết đáng thêm

Tín hiệu dừng. Dùng dạng CMD ["java", ...] chứ đừng dùng CMD java .... Dạng thứ hai chạy qua shell, và JVM trở thành tiến trình con — nó không nhận được SIGTERM khi container dừng, nên hook tắt máy không chạy và bạn mất 10 giây chờ SIGKILL.

Jar phân tầng của Spring Boot. Với ứng dụng Spring Boot, tách jar thành các tầng (thư viện, tài nguyên, mã ứng dụng) làm bộ đệm Docker hiệu quả hơn nhiều — sửa mã chỉ dựng lại tầng cuối vài megabyte thay vì cả jar vài chục megabyte.

.dockerignore. Thiếu tệp này thì COPY . . mang theo cả target/, .git/, node_modules/ vào ngữ cảnh build. Tôi từng thấy ngữ cảnh build 2GB chỉ vì lý do đó.

Kiểm tra sức khoẻ nên hỏi một endpoint thật, không chỉ kiểm tiến trình còn sống. JVM còn chạy không có nghĩa ứng dụng còn phục vụ được.

Thử ba mươi giây

Chạy lệnh này với ảnh của bạn:

docker run --rm --entrypoint java <ảnh> -XshowSettings:properties -version 2>&1 \
  | grep -E "encoding|MaxHeapSize"

Nếu native.encoding không phải UTF-8, mọi log tiếng Việt của bạn đang ra dấu hỏi trên máy chủ. Và nếu heap tối đa chỉ bằng 25% RAM container mà bạn không cố ý, đó là 75% bộ nhớ đang nằm không.

Ngày mai: ghi log và giám sát trong sản xuất — log có cấu trúc, chỉ số, truy vết phân tán, và ba thứ phải có trước khi đưa ứng dụng lên.