Welcome to MikeDaDev's Blog – notes on Java, backend & system design.

Explore
MikeDaDev's Blog

docker & container

Tung Bui Thanh (TungDaDev)12 mins read

Docker.jpeg

Câu đùa kinh điển "Code chạy ngon trên máy em, lên Production thì sập" giờ ít nghe hơn hẳn, phần lớn là nhờ containerization.

Nhưng nhiều dev vẫn coi Docker là một kiểu "máy ảo thu nhỏ". Hiểu sai chỗ này thì lên Production hay mắc mấy lỗi quen mặt sau:

  • Nhét nguyên JDK 800MB cùng Maven vào image Production, container thành cái kho CVE vulnerabilities di động.
  • Chạy container bằng root, ngược hẳn nguyên tắc Least Privilege.
  • Rolling update trên Kubernetes thì service bị gián đoạn, vì process Java không nhận được SIGTERM (bẫy PID 1).
  • App Java tự dưng chết với Exit Code 137 (OOMKilled), chỉ vì không nắm cách Cgroups v2 và JVM Container Awareness hoạt động.

Mình sẽ đi từ dưới lên: bắt đầu ở Linux kernel, kết thúc bằng cách đóng gói một app Spring Boot cho đàng hoàng để chạy production.


# container nhìn từ linux kernel

Có một điều nên nhớ thật kỹ: trong Linux kernel không hề có thứ gì tên là "container".

Container thật ra chỉ là một process Linux bình thường, bị giới hạn những gì nó được nhìn thấy và được dùng, dựa trên 3 tính năng của kernel:

# virtual machine với container khác nhau chỗ nào

Tiêu chíMáy ảo (Virtual Machine - KVM/VMware)Linux Container (Docker/containerd)
Kiến trúcChạy nguyên một hệ điều hành khách (Guest OS) trên Hypervisor.Dùng chung Linux kernel với Host OS.
Thời gian khởi động30 giây tới vài phút (boot BIOS, boot OS).Dưới một giây (dựng namespace, cgroup rồi exec() process).
Tốn bộ nhớMất vài GB RAM chỉ để nuôi Guest OS.Gần như bằng 0 (process dùng bao nhiêu thì tốn bấy nhiêu).
Hiệu năng I/OChậm hơn vì phải đi qua lớp ảo hoá Hypervisor.Gần như ngang chạy thẳng trên phần cứng.

# filesystem phân tầng overlayfs & copyOnWrite

Pull một image 500MB rồi chạy 100 container từ nó, liệu máy có tốn 500MB x 100 = 50GB ổ cứng không?

Không. Ổ cứng chỉ tốn đúng 500MB, cộng thêm phần mỗi container tự ghi ra (lúc mới chạy thường chỉ vài KB). Có được vậy là nhờ OverlayFS:

  • Immutable layers: mọi layer lấy từ image đều chỉ đọc. 100 container dùng chung đúng một bản trên đĩa.
  • Copy-on-Write (CoW): khi app muốn sửa một file có sẵn trong base image, OverlayFS chép file đó từ LowerDir lên UpperDir rồi mới cho ghi. Container khác không bị ảnh hưởng gì.

# bẫy pid 1 & graceful shutdown trên kubernetes

Một lỗi rất hay gặp khi chạy Docker trên Kubernetes: process Java bị giết ngang lúc rolling update.

# ĐỊNH DẠNG SAI LẦM PHỔ BIẾN (Shell Form):
ENTRYPOINT java -jar app.jar

# shell form hỏng ở đâu

Viết ENTRYPOINT java -jar app.jar (shell form) thì Docker chạy lệnh qua /bin/sh -c:

PID 1 = /bin/sh  --->  PID 2 (Child) = java -jar app.jar
  • Kubernetes muốn tắt Pod để lên version mới thì gửi SIGTERM tới PID 1.
  • /bin/sh khá "ngây thơ": nó không forward SIGTERM xuống process Java con (PID 2). Thêm nữa, kernel không áp hành vi mặc định "nhận SIGTERM là chết" cho PID 1, mà sh lại không tự cài handler, nên tín hiệu coi như rơi vào khoảng không.
  • Java chẳng hay biết gì, vẫn nhận request mới như thường.
  • Hết 30 giây grace period (terminationGracePeriodSeconds mặc định), Kubernetes hết kiên nhẫn và bắn thẳng SIGKILL (force kill).
  • Kết quả: mọi request đang xử lý dở của khách bị cắt ngang, phía gateway/load balancer thường trả về HTTP 502 Bad Gateway.

Có một chi tiết hơi oái oăm: không phải /bin/sh nào cũng vậy. Busybox sh trên Alpine thấy -c chỉ có đúng một lệnh thì exec thẳng lệnh đó, Java thành PID 1 luôn. Còn dash trên image Debian/Ubuntu (như eclipse-temurin:21-jre-jammy) thì vẫn giữ sh làm PID 1. Đừng trông vào chuyện may rủi này, cứ dùng exec form như dưới đây.

# cách đúng: exec form

Luôn dùng cú pháp mảng JSON (exec form). Như vậy process Java trở thành PID 1 luôn. JVM tự cài handler cho SIGTERM (để chạy các shutdown hook), nên nó nhận được tín hiệu và có thời gian xử lý xong các request đang chạy rồi mới tắt:

# ĐỊNH DẠNG CHUẨN XÁC (Exec Form):
ENTRYPOINT ["java", "-jar", "app.jar"]

Nếu buộc phải chạy qua script shell (ví dụ cần xử lý biến môi trường trước khi chạy), thì dòng cuối script dùng exec java -jar app.jar để Java thay chỗ shell làm PID 1. Cần dọn process zombie thì chạy container với docker run --init (hoặc dùng tini).

TIP

Với Spring Boot, cấu hình server.shutdown=graceful và spring.lifecycle.timeout-per-shutdown-phase=20s trong application.yml để Tomcat xử lý xong các request in-flight trước khi process thoát. Từ Spring Boot 3.4, graceful shutdown đã bật sẵn với timeout mặc định 30s, nên chủ yếu là chỉnh timeout cho nhỏ hơn grace period của Kubernetes. Trên Kubernetes còn một cái bẫy nữa: Pod nhận SIGTERM gần như cùng lúc với việc bị gỡ khỏi Service endpoint, nên vài giây đầu vẫn có request mới chạy tới. Cách hay dùng là thêm preStop hook chờ vài giây (vd sleep 5, hoặc action sleep có sẵn từ Kubernetes 1.30) để traffic kịp chuyển đi.


# jvm container awareness và exit code 137

Trước Java 10 (và bản backport 8u191), JVM không hề biết mình đang chạy trong container. Riêng cgroups v2 thì phải từ Java 15, hoặc các bản backport 11.0.16 và 8u372, JVM mới đọc được. Host đời mới (Ubuntu 22.04+, RHEL 9, Kubernetes bản gần đây) đều đã chuyển sang cgroups v2, nên một bản Java 11/8 cũ chưa cập nhật vẫn "mù" y như thời trước.

  • Bạn cấp cho container limits.memory = 1GB.
  • Nhưng máy host có 64GB RAM.
  • JVM hỏi hệ điều hành xem máy có bao nhiêu RAM, mà trong container thì câu trả lời vẫn là RAM của host (namespace không che phần này), nên nó tưởng mình có tới 64GB RAM.
  • Mặc định JVM lấy 1/4 RAM của host làm Max Heap:
    Max Heap = 64GB / 4 = 16GB
  • App dùng vượt 1GB là Linux Cgroup OOM-Killer nhảy vào giết process ngay, kèm Exit Code 137.

# bộ tham số jvm nên dùng

java -XX:+UseContainerSupport \
     -XX:MaxRAMPercentage=75.0 \
     -XX:InitialRAMPercentage=75.0 \
     -XX:+ExitOnOutOfMemoryError \
     -jar app.jar
  • -XX:+UseContainerSupport: bật sẵn từ Java 10 (và 8u191), bắt JVM đọc giới hạn tài nguyên từ /sys/fs/cgroup. Ghi ra cho rõ ý thôi, bỏ đi cũng không sao.
  • -XX:MaxRAMPercentage=75.0: heap được dùng tối đa 75% RAM của container. Mặc định JVM chỉ lấy 25%, nên container 1GB mà không chỉnh gì thì heap chỉ được 256MB, phí khá nhiều. 25% còn lại phải chừa cho Metaspace, stack của các thread, Garbage Collector và native memory buffer. Container nhỏ (dưới 1GB) hoặc app nhiều thread, dùng nhiều direct buffer thì nên hạ xuống 60-70%.
  • -XX:+ExitOnOutOfMemoryError: hết heap thì JVM thoát hẳn để orchestrator khởi động lại, thay vì sống dở chết dở sau OutOfMemoryError.

# dockerfile multi-stage & distroless hardening

Một Dockerfile viết vội thường cho ra image 600MB - 1GB, chứa đủ compiler, package manager (apt, apk) và chạy bằng root.

Dưới đây là Dockerfile mình dùng cho production, kết hợp Spring Boot Layered JARs (cú pháp dưới cho Spring Boot 3.3 trở lên) với distroless non-root image. Riêng base image distroless Java 21 đã khoảng 200MB trên đĩa (nén trong registry chừng 63MB), cộng thêm dependency của app:

# STAGE 1: Extract Spring Boot Layered JAR
FROM eclipse-temurin:21-jre-jammy AS extractor
WORKDIR /builder
ARG JAR_FILE=target/*.jar
COPY ${JAR_FILE} application.jar
# Tách Fat JAR thành 4 layers độc lập tận dụng Docker Cache
# (Spring Boot < 3.3: java -Djarmode=layertools -jar application.jar extract, layertools đã bị gỡ từ 4.1)
RUN java -Djarmode=tools -jar application.jar extract --layers --launcher --destination /extracted
 
# STAGE 2: Production Distroless Runtime
# Sử dụng Google Distroless Image: Không chứa shell, không package manager -> giảm mạnh attack surface
FROM gcr.io/distroless/java21-debian12:nonroot
WORKDIR /application
 
# Copy từng layer theo thứ tự tần suất thay đổi từ thấp đến cao:
# 1. Thư viện bên thứ 3 (Hiếm khi đổi -> Cache hit 100%)
COPY --from=extractor /extracted/dependencies/ ./
COPY --from=extractor /extracted/spring-boot-loader/ ./
COPY --from=extractor /extracted/snapshot-dependencies/ ./
# 2. Mã nguồn ứng dụng của công ty (Thay đổi liên tục -> Tầng trên cùng)
COPY --from=extractor /extracted/application/ ./
 
# Chạy dưới quyền non-root mặc định (UID 65532 của distroless)
USER nonroot:nonroot
 
# Phơi bày cổng dịch vụ
EXPOSE 8080
 
# Cấu hình JVM Container Support & Exec Form chuẩn mực
ENV JAVA_TOOL_OPTIONS="-XX:+UseContainerSupport -XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"
 
ENTRYPOINT ["java", "org.springframework.boot.loader.launch.JarLauncher"]

# vì sao viết dockerfile kiểu này

  1. Tận dụng layer cache: sửa 1 dòng code Java thì Docker chỉ build lại layer application/ (vài MB). Cả trăm MB thư viện trong dependencies/ lấy lại từ cache gần như tức thì, lúc push/pull cũng chỉ phải đẩy layer đã đổi.
  2. Gần như không có gì để tấn công: image distroless không có /bin/sh, không curl, không apt. Kẻ tấn công có khai thác được lỗ hổng Remote Code Execution (RCE) thì cũng không có sẵn shell hay công cụ để tải thêm đồ nghề, leo quyền khó hơn nhiều. Khó hơn chứ không phải bất khả thi, nên vẫn phải vá lỗ hổng và quét image định kỳ.
  3. Chạy non-root: lỡ có lỗ hổng thoát ra khỏi container (container escape) thì host OS vẫn an toàn hơn nhiều.

# so sánh hai cách viết

Tiêu chíDockerfile viết vộiDockerfile chuẩn production
Dung lượng image800MB - 1.2GB~200-300MB trên đĩa (nén ~100MB)
User chạyChạy quyền rootChạy quyền nonroot (UID 65532)
Bề mặt tấn công (Attack Surface)Có shell, curl, compilerDistroless (không shell, không package manager)
Tốc độ CI/CDMỗi lần upload nguyên 1GBChỉ upload vài MB nhờ Layered JARs
Graceful ShutdownDính shell form (bị kill sau 30s)Exec form (PID 1 nhận được SIGTERM)
Quản lý RAMDễ dính OOMKilled 137MaxRAMPercentage 75%, tự theo cgroups

# tổng kết

Dùng Docker cho tới nơi tới chốn thì không dừng ở mấy lệnh docker run hay docker-compose up:

  • Hiểu Linux kernel bên dưới: container chỉ là process được Namespaces cô lập, Cgroups v2 giới hạn tài nguyên, OverlayFS lo phần lưu trữ.
  • Tôn trọng vòng đời process: dùng exec form, bật graceful shutdown để rolling update không làm hỏng dữ liệu đang xử lý.
  • Đóng gói gọn: dùng Spring Boot Layered JARs và distroless base image để image nhẹ hơn, build/push nhanh hơn và ít thứ cho kẻ tấn công lợi dụng.

Chỉ là những ghi chép cá nhân với hy vọng mang lại chút giá trị. Nếu thấy hữu ích, đừng ngại chia sẻ cho bạn bè & đồng nghiệp nhé!

Happy coding 😎 👍🏻 🚀 🔥.