Phần 25 đo được rằng nproc báo 16 nhân và /proc/meminfo báo 16 GB bất kể bạn khai --cpus 0.5 -m 64m. Bài này hỏi tiếp: runtime của bạn có bị lừa không?
Câu trả lời khác nhau giữa JVM và Node, và khác nhiều.
JVM đọc đúng cả hai
| Cấu hình | availableProcessors() |
Heap tối đa |
|---|---|---|
| không giới hạn | 16 | 3 994 MB |
--cpus 0.5 |
1 | 3 860 MB |
--cpus 1 |
1 | 3 860 MB |
--cpus 2 |
2 | 3 994 MB |
--cpus 4 |
4 | 3 994 MB |
-m 256m |
16 | 121 MB |
-m 512m |
16 | 123 MB |
-m 2g |
16 | 512 MB |
-m 512m --cpus 1 |
1 | 123 MB |
JVM hiện đại đọc cgroup, không đọc /proc. UseContainerSupport bật mặc định từ Java 10.
Hai chi tiết đáng chú ý:
--cpus 0.5 cho ra 1 nhân, không phải 0,5. JVM làm tròn lên. Nghĩa là container nửa nhân vẫn có một JVM cấu hình cho một nhân — số luồng GC, kích thước ForkJoinPool đều tính theo 1. Đó là hành vi hợp lý, nhưng đừng chờ đợi JVM tự co lại thêm nữa.
Tỉ lệ heap đổi theo kích thước. -m 512m cho heap 123 MB (24%), -m 2g cho 512 MB (25%), còn -m 256m cho 121 MB — tức 47%. Dưới một ngưỡng nhất định JVM dùng MinRAMPercentage (50%) thay cho MaxRAMPercentage (25%).
Nghĩa là 75% bộ nhớ container không thuộc về heap. Phần đó dành cho metaspace, ngăn xếp luồng, bộ đệm trực tiếp, và chính JVM. Nếu bạn thấy container Java bị OOM giết dù heap còn trống, đây là chỗ để nhìn.
Chỉnh bằng phần trăm chứ đừng chỉnh bằng số tuyệt đối:
java -XX:MaxRAMPercentage=75 -jar app.jar
-Xmx512m cố định sẽ sai ngay khi ai đó đổi -m của container; MaxRAMPercentage thì tự theo.
Node đọc sai cả hai
| Cấu hình | os.cpus().length |
os.totalmem() |
Giới hạn heap V8 |
|---|---|---|---|
| không giới hạn | 16 | 15 971 MB | 4 144 MB |
--cpus 0.5 |
16 | 15 971 MB | 4 144 MB |
--cpus 1 |
16 | 15 971 MB | 4 144 MB |
--cpus 4 |
16 | 15 971 MB | 4 144 MB |
-m 256m |
16 | 15 971 MB | 259 MB |
-m 512m |
16 | 15 971 MB | 259 MB |
os.cpus().length báo 16 trong mọi trường hợp, kể cả khi container chỉ được nửa nhân. Rất nhiều thư viện dùng đúng con số này để quyết định số worker:
const cluster = require('cluster');
for (let i = 0; i < os.cpus().length; i++) cluster.fork(); // 16 tien trinh
Mười sáu tiến trình Node trong một container nửa nhân, mỗi tiến trình một bản sao V8 và một heap riêng. Đây là công thức chắc chắn dẫn tới OOM.
os.totalmem() cũng báo RAM của cả máy — nên mọi thư viện tự tính kích thước cache theo "phần trăm RAM" sẽ tính theo 16 GB.
259 MB heap trong container 256 MB
Hàng đáng sợ nhất trong bảng: với -m 256m, V8 đặt giới hạn heap là 259 MB — lớn hơn chính container.
V8 có đọc cgroup, nhưng công thức của nó cho ra một con số vượt hạn mức ở kích thước nhỏ. Kết quả là heap được phép lớn tới mức container chết trước khi V8 kịp coi là hết bộ nhớ.
Hai kiểu chết hoàn toàn khác nhau
Đây là phần đáng nhớ nhất. Cùng container 256 MB, hai cách cấp phát:
Cấp phát bằng Buffer.alloc (bộ nhớ native, ngoài heap V8):
ma thoat 137 | OOMKilled=true
dong cuoi trong log: "da cap phat 350 MB"
Bị SIGKILL, không có thông báo lỗi nào. Log dừng giữa chừng.
Cấp phát bằng đối tượng JS (trong heap V8):
ma thoat 139 | OOMKilled=false
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory
V8 chạm giới hạn của chính nó trước, và ném ra một lỗi đọc được, có vết gọi, có thể bắt bằng process.on('uncaughtException').
Khác biệt về vận hành là rất lớn: một bên bạn có thông báo nói thẳng vấn đề, một bên bạn có một container biến mất và phải tự đoán.
Và cờ --max-old-space-size chỉ giúp cho vế thứ hai:
--max-old-space-size=128, cap phat bang Buffer -> 137, OOMKilled=true
--max-old-space-size=128, cap phat bang doi tuong -> 139, "Reached heap limit"
Nó bao heap JS, không bao Buffer, ArrayBuffer, hay bộ nhớ mà thư viện native cấp phát. Nếu ứng dụng của bạn xử lý tệp hay ảnh, phần lớn bộ nhớ nằm ngoài tầm với của cờ này.
Nên đặt gì
Java:
java -XX:MaxRAMPercentage=75 \
-XX:ActiveProcessorCount=2 \
-jar app.jar
ActiveProcessorCount chỉ cần khi bạn muốn ép một con số khác với thứ JVM tự phát hiện — hiếm khi cần, vì bảng ở trên cho thấy nó phát hiện đúng.
Node:
node --max-old-space-size=192 app.js
Đặt khoảng 70–75% hạn mức container, chừa chỗ cho bộ nhớ native. Và trong mã, đừng dùng os.cpus().length:
// SAI trong container
const so = os.cpus().length;
// dung hon: doc thang cgroup, hoac cho khai bang bien moi truong
const so = Number(process.env.SO_WORKER) || 1;
os.availableParallelism() (Node 19+) tôn trọng affinity của tiến trình nhưng không đọc quota cgroup, nên với --cpus nó vẫn báo sai. Cách chắc chắn là truyền vào bằng biến môi trường — Compose và các hệ điều phối đều biết hạn mức thật.
Cách kiểm nhanh cho mọi runtime
Đọc thẳng cgroup từ trong container:
docker exec <ten> sh -c 'cat /sys/fs/cgroup/cpu.max /sys/fs/cgroup/memory.max'
Rồi so với thứ runtime của bạn tự báo. Chênh lệch giữa hai con số đó là khoảng cách mà bạn phải tự lấp bằng cấu hình.
Thử ba mươi giây
Với container Java đang chạy:
docker exec <ten> sh -c 'java -XX:+PrintFlagsFinal -version 2>/dev/null | grep -E "MaxHeapSize|MaxRAMPercentage"'
Với container Node:
docker exec <ten> node -e "const v8=require('v8');console.log('heap V8:',Math.round(v8.getHeapStatistics().heap_size_limit/1048576),'MB | os.cpus:',require('os').cpus().length)"
Nếu con số heap lớn hơn hoặc gần bằng --memory của container, bạn đang chờ một lần OOM. Và nếu os.cpus bằng số nhân của máy chủ chứ không bằng hạn mức, mọi thư viện tự chia worker trong ứng dụng của bạn đang chia theo con số sai.
Phần sau bàn về PID 1, tín hiệu, và cách dừng container cho tử tế.