docker & container

- Published on
- /11 mins read/
Trong văn hóa phát triển phần mềm hiện đại, câu nói đùa kinh điển: "Code chạy ngon lành trên máy em, nhưng lên Production thì sập" đã hoàn toàn bị xóa sổ bởi cuộc cách mạng Containerization.
Tuy nhiên, với phần lớn các kỹ sư phần mềm, Docker vẫn thường bị hiểu nhầm là một dạng "máy ảo thu nhỏ" (miniature Virtual Machine). Sự ngộ nhận này dẫn đến những sai lầm kiến trúc phổ biến trên môi trường Production:
- Đóng gói toàn bộ JDK 800MB và công cụ build Maven vào Image Production, biến container thành một "quả bom nổ chậm" về lỗ hổng bảo mật (CVE vulnerabilities).
- Chạy container dưới quyền
root, vi phạm nguyên tắc bảo mật tối thiểu (Least Privilege). - Gặp lỗi gián đoạn dịch vụ khi Rolling Update Kubernetes vì tiến trình Java không xử lý được tín hiệu
SIGTERM(Bẫy PID 1). - Ứng dụng Java bị sập đột ngột với mã lỗi Exit Code 137 (OOMKilled) chỉ vì không hiểu cơ chế Cgroups v2 và JVM Container Awareness.
Bài viết này sẽ đưa bạn qua toàn bộ bức tranh kỹ thuật sâu nhất của Docker: từ tầng nhân Linux Kernel đến quy trình đóng gói ứng dụng Spring Boot chuẩn production.
# bản chất container dưới lăng kính linux kernel
Một sự thật nền tảng mà mọi Kỹ sư Hệ thống đều phải khắc cốt ghi tâm: Không hề có một thực thể vật lý nào tên là "Container" tồn tại trong Linux Kernel.
Container thực chất chỉ là một tiến trình Linux thông thường (Normal Linux Process) nhưng bị giới hạn không gian nhìn và tài nguyên thông qua 3 trụ cột phần mềm của nhân Linux:
# so sánh virtual machine vs container
| Tiêu chí | Máy Ảo (Virtual Machine - KVM/VMware) | Linux Container (Docker/containerd) |
|---|---|---|
| Kiến trúc | Chạy một Hệ điều hành khách (Guest OS) hoàn chỉnh trên Hypervisor. | Dùng chung Linux Kernel với Host OS. |
| Thời gian khởi động | 30 giây đến vài phút (Khởi động BIOS/OS). | Mili-giây (Chỉ là lệnh fork() và exec() một process). |
| Tiêu tốn bộ nhớ | Tốn hàng Gigabytes RAM chỉ để nuôi Guest OS. | Gần như bằng 0 (Chỉ tốn đúng dung lượng RAM của process). |
| Hiệu năng I/O | Chậm hơn do phải đi qua tầng ảo hóa Hypervisor. | Tốc độ Native gần như 100% của phần cứng vật lý. |
# hệ thống tệp phân tầng overlayfs & copyOnWrite
Khi bạn pull một Docker Image có dung lượng 500MB và chạy 100 containers từ image đó, liệu máy chủ có tốn 500MB x 100 = 50GB ổ cứng không?
Câu trả lời là: KHÔNG! Ổ cứng chỉ tốn đúng 500MB cộng thêm vài Kilobytes cho mỗi container. Điều kỳ diệu này có được nhờ OverlayFS:
- Tính bất biến (Immutable Layers): Toàn bộ các layer từ Docker Image đều là Read-Only. 100 containers dùng chung một bản sao duy nhất trên ổ đĩa.
- Copy-on-Write (CoW): Khi ứng dụng muốn sửa một file từ Base Image, OverlayFS sẽ copy file đó từ LowerDir lên UpperDir rồi mới cho phép ghi. Các container khác hoàn toàn không bị ảnh hưởng!
# thảm họa pid 1 & graceful shutdown trong kubernetes
Một trong những lỗi nghiêm trọng nhất khi triển khai Docker trên Kubernetes là tiến trình Java bị "giết đột ngột" khi Rolling Update:
# ĐỊNH DẠNG SAI LẦM PHỔ BIẾN (Shell Form):
ENTRYPOINT java -jar app.jar# tại sao shell form gây thảm họa
Khi viết ENTRYPOINT java -jar app.jar (Shell Form), Docker sẽ chạy lệnh này thông qua /bin/sh -c:
PID 1 = /bin/sh ---> PID 2 (Child) = java -jar app.jar- Khi Kubernetes muốn tắt Pod để nâng cấp phiên bản, nó gửi tín hiệu
SIGTERMtới PID 1. /bin/shlà một process thô sơ: nó KHÔNG chuyển tiếp (forward) tín hiệuSIGTERMsang tiến trình Java con (PID 2)!- Java không hề hay biết gì về lệnh dừng, tiếp tục nhận request mới.
- Sau 30 giây chờ đợi (Grace Period), Kubernetes mất kiên nhẫn và bắn thẳng lệnh
SIGKILL(Force Kill). - Hậu quả: Toàn bộ các request đang xử lý dở dang của khách hàng bị ngắt kết nối đột ngột, gây lỗi HTTP 502 Bad Gateway!
# giải pháp chuẩn mực exec form
Luôn sử dụng cú pháp JSON mảng (Exec Form). Lệnh này sẽ biến trực tiếp tiến trình Java thành PID 1, đảm bảo Java nhận trọn vẹn tín hiệu SIGTERM để hoàn tất các request đang chạy trước khi tắt:
# ĐỊNH DẠNG CHUẨN XÁC (Exec Form):
ENTRYPOINT ["java", "-jar", "app.jar"]TIP
Trong Spring Boot, luôn cấu hình server.shutdown=graceful và spring.lifecycle.timeout-per-shutdown-phase=20s trong file application.yml để Tomcat hoàn thành các request in-flight trước khi process thoát.
# jvm container awareness phòng chống exit code 137
Trước phiên bản Java 8u191 và Java 11, JVM hoàn toàn mù quáng trước tài nguyên của Container.
- Khi bạn cấp cho Container:
limits.memory = 1GB. - Nhưng máy chủ vật lý (Host) có
64GB RAM. - JVM đọc tài nguyên hệ thống thông qua file
/proc/meminfocủa Host OS. Nó nghĩ rằng nó đang sở hữu 64GB RAM! - Mặc định, JVM tự động cấu hình Max Heap Size bằng 1/4 RAM của Host:
Max Heap = 64GB / 4 = 16GB - Khi ứng dụng tiêu thụ vượt quá 1GB, cơ chế Linux Cgroup OOM-Killer sẽ lập tức can thiệp và hạ sát tiến trình với mã lỗi Exit Code 137!
# thiết lập tham số jvm chuẩn enterprise
java -XX:+UseContainerSupport \
-XX:MaxRAMPercentage=75.0 \
-XX:InitialRAMPercentage=75.0 \
-XX:+ExitOnOutOfMemoryError \
-jar app.jar-XX:+UseContainerSupport: Bật mặc định từ Java 11+, ép JVM đọc giới hạn tài nguyên từ/sys/fs/cgroup.-XX:MaxRAMPercentage=75.0: Cấp phát tối đa 75% RAM của Container cho Heap. 25% còn lại bắt buộc phải dành cho Metaspace, Stack Threads, Garbage Collector và Native Memory buffers.
# dockerfile multi-stage & distroless hardening
Một Dockerfile nghiệp dư thường có dung lượng từ 600MB - 1GB, chứa đầy đủ trình biên dịch, package manager (apt, apk), và chạy dưới quyền root.
Dưới đây là mẫu Dockerfile chuẩn production tối ưu theo công nghệ Spring Boot 3 Layered JARs kết hợp Distroless Non-Root Image với dung lượng chỉ dưới 150MB:
# STAGE 1: Extract Spring Boot Layered JAR
FROM eclipse-temurin:21-jre-jammy AS extractor
WORKDIR /extracted
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
RUN java -Djarmode=layertools -jar application.jar extract
# STAGE 2: Production Distroless Runtime
# Sử dụng Google Distroless Image: Không chứa shell, không package manager -> Zero 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"]# tại sao kiến trúc dockerfile này tối ưu
- Docker Layer Caching Tối Ưu: Khi bạn sửa 1 dòng code Java, Docker chỉ cần build lại layer
application/(vài Megabytes). Toàn bộ 100MB thư việndependencies/được tái sử dụng từ Cache trong 0.1 giây! - Bảo Mật Tối Thượng (Zero Vulnerabilities): Image Distroless không có
/bin/sh, không cócurl, không cóapt. Kẻ tấn công dù khai thác được lỗ hổng Remote Code Execution (RCE) cũng không thể mở shell để leo thang đặc quyền. - Chạy Non-Root: Bảo vệ an toàn tuyệt đối cho Host OS trong trường hợp xảy ra lỗ hổng vượt rào container (Container Escape).
# ma trận so sánh toàn diện
| Tiêu chuẩn kỹ thuật | Dockerfile Ngây Thơ | Dockerfile Chuẩn Enterprise |
|---|---|---|
| Dung lượng Image | 800MB - 1.2GB | 120MB - 160MB |
| Bảo mật người dùng | Chạy quyền root | Chạy quyền nonroot (UID 65532) |
| Bề mặt tấn công (Attack Surface) | Chứa shell, curl, compilers | Distroless (Không shell, không package manager) |
| Tốc độ CI/CD Build | Phải upload toàn bộ 1GB mỗi lần | Chỉ upload vài MBs nhờ Layered JARs |
| Graceful Shutdown | Lỗi Shell Form (Bị kill sau 30s) | Exec Form (PID 1 nhận tín hiệu SIGTERM) |
| Khả năng quản trị RAM | Nguy cơ OOMKilled 137 | MaxRAMPercentage 75% thích ứng Cgroups |
# tổng kết
Làm chủ Docker ở cấp độ Kỹ sư trưởng không dừng lại ở việc gõ những câu lệnh docker run hay docker-compose up đơn giản:
- Thấu hiểu Linux Kernel: Nhận biết bản chất của tiến trình được cô lập bởi Namespaces, kiểm soát tài nguyên qua Cgroups v2 và cơ chế lưu trữ OverlayFS.
- Tôn trọng vòng đời tiến trình: Sử dụng Exec Form để bảo đảm tính toàn vẹn của dữ liệu trong quá trình Rolling Update.
- Tối ưu hóa đến từng Byte: Ứng dụng Spring Boot Layered JARs và Distroless Base Images để tạo ra những container siêu nhẹ, khởi động tức thì và miễn nhiễm với các cuộc tấn công bảo mật.
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 😎 👍🏻 🚀 🔥.
On this page
- # bản chất container dưới lăng kính linux kernel
- # so sánh virtual machine vs container
- # hệ thống tệp phân tầng overlayfs & copyOnWrite
- # thảm họa pid 1 & graceful shutdown trong kubernetes
- # tại sao shell form gây thảm họa
- # giải pháp chuẩn mực exec form
- # jvm container awareness phòng chống exit code 137
- # thiết lập tham số jvm chuẩn enterprise
- # dockerfile multi-stage & distroless hardening
- # tại sao kiến trúc dockerfile này tối ưu
- # ma trận so sánh toàn diện
- # tổng kết