Container là process: namespace, cgroups và JVM
Tiếp đi: container thực chất là gì bên dưới? Có phải một máy ảo nhỏ không?
Không phải. Container là một process Linux bình thường, được kernel "bịt mắt" bằng namespace và "giới hạn khẩu phần" bằng cgroups. Hiểu chỗ này thì mới hiểu được vì sao JVM trong container hay bị kill.
1. Bài toán
Chạy order-service với giới hạn 512MB:
docker run -d --name order-1 -m 512m --cpus 1 -p 8080:8080 order-service:1.0
Chạy được vài phút, tải tăng lên thì container biến mất:
docker ps -a
STATUS: Exited (137)
docker inspect order-1 --format '{{.State.OOMKilled}}'
true
2. Vấn đề
- Exit code 137 = 128 + 9 (SIGKILL). Kernel kill process vì vượt memory limit của cgroup.
- Không có
OutOfMemoryErrortrong log Java: JVM bị giết từ bên ngoài, không kịp ghi gì. - Nguyên nhân thường gặp: heap đặt quá to (
-Xmx512mtrong khi limit 512m), hoặc quên rằng JVM còn dùng bộ nhớ ngoài heap: metaspace, thread stack, code cache, direct buffer (Netty, Redis client).
3. Giải pháp: hiểu namespace + cgroups
Virtual Machine Container
┌──────────────────────────┐ ┌──────────────────────────┐
│ App │ │ App (java) │
│ Guest OS + Guest Kernel │ │ (userland của image) │
├──────────────────────────┤ ├──────────────────────────┤
│ Hypervisor │ │ Host kernel (dùng chung) │
├──────────────────────────┤ │ namespace + cgroups │
│ Hardware │ ├──────────────────────────┤
└──────────────────────────┘ │ Hardware │
└──────────────────────────┘
Namespace = process thấy gì:
| Namespace | Cô lập | Hệ quả |
|---|---|---|
| PID | Danh sách process | java là PID 1 trong container |
| NET | Network interface, port, routing | localhost trong container là chính container |
| MNT | Filesystem | Thấy filesystem của image, không thấy của host |
| UTS | Hostname | Hostname = container ID |
| IPC | Shared memory, semaphore | Không đụng IPC của process khác |
| USER | UID/GID | root trong container có thể map thành user thường trên host |
cgroups = process được dùng bao nhiêu: memory, CPU, số process, I/O. Vượt memory limit thì kernel OOM-kill; vượt CPU quota thì bị throttle (chậm lại), không bị kill.
4. Code từng bước
Bước 1: Thấy container là process trên host (Linux host; Docker Desktop thì nó nằm trong VM Linux của Docker Desktop):
docker top order-1 # process bên trong container
ps aux | grep app.jar # cùng process đó nhìn từ host, PID khác
docker exec order-1 cat /proc/1/cmdline # trong container: PID 1 là java
Bước 2: Đặt limit
docker run -d --name order-1 \
-m 768m --cpus 1.5 \
-p 8080:8080 order-service:1.0
docker stats order-1 # xem CPU %, MEM USAGE / LIMIT
Bước 3: Cho JVM tính heap theo limit của container. Từ Java 10 (và 8u191), JVM có container support (bật mặc định): đọc limit từ cgroup thay vì RAM của cả host. Mặc định heap tối đa chỉ = 25% limit, khá phí. Dùng phần trăm thay vì -Xmx cứng:
FROM eclipse-temurin:17-jre
WORKDIR /app
COPY --from=builder /app/target/order-service-1.0.0.jar app.jar
ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75.0 -XX:+ExitOnOutOfMemoryError"
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]
MaxRAMPercentage=75: heap tối đa 75% limit; 25% còn lại cho metaspace, thread stack, code cache, direct memory.ExitOnOutOfMemoryError: gặp OOM trong heap thì thoát luôn để Docker/K8s restart, thay vì chạy tiếp ở trạng thái hỏng.JAVA_TOOL_OPTIONSđược JVM tự đọc, không cần sửa ENTRYPOINT. Đổi lúc chạy bằng-e.
Bước 4: Kiểm tra JVM thấy gì
docker run --rm -m 768m --cpus 1.5 eclipse-temurin:17-jre \
java -XX:MaxRAMPercentage=75.0 -XshowSettings:vm -version
# Max. Heap Size (Estimated): ~576M
docker run --rm -m 768m --cpus 1.5 eclipse-temurin:17-jre \
java -XX:+PrintFlagsFinal -version | grep -E "ActiveProcessorCount|UseContainerSupport"
Với CPU: JVM dùng số CPU từ cgroup để tính số GC thread, kích thước ForkJoinPool.commonPool... --cpus 1.5 thì JVM thấy 2 CPU. Nếu cần ép: -XX:ActiveProcessorCount=2.
5. Cách hoạt động
docker run -m 768m --cpus 1.5 order-service:1.0
│
▼
containerd/runc tạo process java
│
├── namespaces: PID, NET, MNT, UTS, IPC (cô lập "thấy gì")
└── cgroup: memory.max = 768M, cpu.max = 150000/100000
│
▼
JVM khởi động → đọc cgroup → heap max = 75% × 768M ≈ 576M
│
tổng RSS (heap + metaspace + stacks + direct) > 768M ?
│ có │ không
▼ ▼
kernel OOM-kill (exit 137) chạy bình thường
6. So sánh
| Virtual Machine | Container | |
|---|---|---|
| Kernel | Mỗi VM một kernel riêng | Dùng chung kernel host |
| Khởi động | Hàng chục giây đến phút | Bằng thời gian start process |
| Kích thước | GB | MB |
| Cô lập | Mạnh (hypervisor) | Yếu hơn (cùng kernel) |
| Chạy OS khác | Được (Windows trên Linux) | Phải cùng loại kernel |
| Cách đặt heap | Ưu | Nhược |
|---|---|---|
-Xmx cứng | Rõ ràng | Đổi limit phải sửa cả Xmx, dễ quên → OOMKilled |
| Mặc định (25%) | Không cần cấu hình | Lãng phí 75% RAM được cấp |
MaxRAMPercentage 70–80% | Tự co giãn theo limit | Vẫn phải để dư cho non-heap |
7. Khi nào nên dùng / không nên dùng
- Nên: luôn đặt memory limit cho container production, và dùng
MaxRAMPercentage. - Nên: theo dõi
docker stats/metrics để chỉnh limit dựa trên số đo thực. - Không nên: đặt
-Xmxbằng đúng memory limit; dùng JDK cũ không có container support. - Không nên: đặt CPU limit quá thấp cho app Java có nhiều thread: GC và JIT bị throttle, latency tăng vọt.
- Cần cô lập mạnh (multi-tenant, code không tin cậy) thì cân nhắc VM hoặc sandbox (gVisor, Kata).
Mình đặt -Xmx512m và limit 512MB rồi mà vẫn bị kill, log cũng không có OutOfMemoryError. Tại sao vậy?
Đúng như bạn quan sát, và đó chính là manh mối: có hai loại "hết bộ nhớ" khác nhau.
Memory limit cgroup = 512M
┌───────────────────────────────────────┐
│ Heap (-Xmx512m) 512M │ ← riêng heap đã chiếm hết
├───────────────────────────────────────┤
│ Metaspace ~80M │
│ Thread stacks (200 thread × 1M) ~200M │ ← tràn ra ngoài limit
│ Code cache, GC, direct buffer ~60M │
└───────────────────────────────────────┘
- Heap đầy: JVM ném
java.lang.OutOfMemoryError, có log, process vẫn sống (trừ khi cóExitOnOutOfMemoryError). - Tổng RSS vượt limit cgroup: kernel gửi SIGKILL. JVM không có cơ hội làm gì. Exit 137,
OOMKilled: true.
Sửa: bỏ -Xmx512m, dùng -XX:MaxRAMPercentage=75.0 và tăng limit nếu số đo thực cần. Muốn biết non-heap dùng bao nhiêu: bật -XX:NativeMemoryTracking=summary rồi docker exec order-1 jcmd 1 VM.native_memory summary (cần image có jcmd, tức JDK).
Phỏng vấn hỏi container khác VM thế nào, chạy JVM trong container cần chú ý gì thì trả lời sao?
Q1. Container khác VM thế nào?
🇻🇳 Tiếng Việt:
Container là một process Linux dùng chung kernel với host, được cô lập bằng namespace (PID, network, mount...) và giới hạn tài nguyên bằng cgroups. VM có kernel riêng chạy trên hypervisor. Vì vậy container nhẹ, khởi động nhanh, nhưng cô lập yếu hơn VM.
🇬🇧 English:
A container is a Linux process that shares the host kernel. It is isolated with namespaces such as PID, network and mount, and its resources are limited with cgroups. A VM runs its own kernel on a hypervisor. So containers are lighter and start faster, but their isolation is weaker than a VM.
Q2. Container Java bị exit 137 thì bạn điều tra thế nào?
🇻🇳 Tiếng Việt:
137 là SIGKILL, thường do vượt memory limit của cgroup. Em kiểm tra
OOMKilledtrongdocker inspecthoặc trạng thái pod. Nguyên nhân hay gặp là heap đặt quá sát limit mà quên non-heap. Em dùngMaxRAMPercentagekhoảng 75%, đo bằng Native Memory Tracking rồi chỉnh limit.
🇬🇧 English:
Exit code 137 means SIGKILL, usually because the container exceeded its cgroup memory limit. I check
OOMKilledindocker inspector the pod status. A common cause is a heap sized too close to the limit, ignoring non-heap memory. I useMaxRAMPercentagearound 75%, measure with Native Memory Tracking and then adjust the limit.
Q3. CPU limit ảnh hưởng JVM thế nào?
🇻🇳 Tiếng Việt:
Vượt CPU quota thì container bị throttle, không bị kill, nên biểu hiện là latency tăng. JVM đọc số CPU từ cgroup để chọn số GC thread và kích thước common pool. Limit quá thấp làm GC và JIT chậm; có thể chỉnh bằng
ActiveProcessorCount.
🇬🇧 English:
When a container exceeds its CPU quota it is throttled, not killed, so the symptom is higher latency. The JVM reads the CPU count from the cgroup to size GC threads and the common pool. Very low limits slow down GC and JIT; the count can be overridden with
ActiveProcessorCount.
Cách nhớ ngắn gọn
- Container = process + namespace (thấy gì) + cgroups (dùng bao nhiêu).
- Dùng chung kernel host → nhẹ nhưng cô lập yếu hơn VM.
- Memory vượt → SIGKILL, exit 137. CPU vượt → throttle.
- JVM:
MaxRAMPercentage=75, đừng để-Xmx= limit.
Tự kiểm tra
Namespace và cgroups khác nhau thế nào?
Namespace cô lập những gì process nhìn thấy (PID, network, filesystem). cgroups giới hạn tài nguyên process được dùng (memory, CPU).
Exit code 137 nghĩa là gì?
128 + 9: process bị SIGKILL, thường do OOM killer khi vượt memory limit.
Mặc định JVM trong container lấy heap tối đa bao nhiêu?
Khoảng 25% memory limit của container (MaxRAMPercentage mặc định = 25).
Vì sao không nên đặt -Xmx bằng memory limit?
JVM còn dùng metaspace, thread stack, code cache, direct memory ngoài heap; tổng sẽ vượt limit và bị kernel kill.
Container có chạy được image Windows trên host Linux không?
Không, vì container dùng chung kernel của host. Cần VM nếu muốn kernel khác.