Hình dung hai tài xế được giao cùng một bình xăng nhỏ. Người thứ nhất liếc đồng hồ, biết mình có bao nhiêu, và lên kế hoạch quãng đường vừa đủ. Người thứ hai không nhìn đồng hồ, cứ lái như thể bình đầy — theo trí nhớ về cái bình to ngày xưa — rồi chết máy giữa cao tốc. Cùng một giới hạn, hai kết cục ngược nhau, chỉ vì một người hỏi "ở đây tôi thật sự có bao nhiêu" còn người kia thì đoán. Phần 25 đo được 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ó nhìn đồng hồ không? — và câu trả lời khác nhau giữa JVM với Node, khác nhiều.
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.
Muốn tự kiểm runtime của mình có nhìn đúng hạn mức không, hỏi thẳng nó. Với container Java:
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)"
Con số heap lớn hơn hoặc gần bằng --memory của container nghĩa là bạn đang chờ một lần OOM; và os.cpus bằng số nhân của máy chủ chứ không bằng hạn mức nghĩa là mọi thư viện tự chia worker đang chia theo con số sai.
Mẫu số chung
Bài học đầu tiên, chính là hai tài xế: đừng tin phần tự dò của một runtime là có nhận biết container — nó nhận biết không đồng đều giữa các công cụ, nên với thứ phải vừa một giới hạn cứng, hãy đặt tường minh từ nguồn thật thay vì phó cho nó tự đoán. JVM đọc cgroup và co lại; Node đọc /proc của cả máy rồi fork 16 worker trong container nửa nhân; và ngay cả V8 "có đọc cgroup" vẫn tính ra heap 259 MB lớn hơn container 256 MB — đọc đúng nguồn thôi chưa đủ nếu công thức sai. Cùng cái bẫy "ỷ vào tự-dò" ở khắp nơi: một thư viện tự chọn số luồng theo số nhân "thấy được", một cache tự tính cỡ theo "RAM tổng", một client tự đặt timeout theo mặc định của nền. Nguyên tắc: tiện nghi tự-dò dùng được cho tới khi nó sai — và nó sai mỗi nơi một kiểu; với cái gì buộc phải khớp một trần cứng, khai thẳng con số (MaxRAMPercentage, --max-old-space-size, số worker lấy từ biến môi trường) chứ đừng cược vào việc runtime tự hiểu.
Điều thứ hai, đọc từ hai kiểu chết 137 và 139: một giới hạn chỉ canh đúng cái vùng nó được định nghĩa trên — cùng một sự cạn kiệt hiện ra im lặng hay đọc được tuỳ vùng nào tràn trước. Cấp phát trong heap V8 chạm giới hạn của chính V8 → lỗi "heap out of memory" đọc được, bắt được; cấp phát bằng Buffer (native, ngoài heap) vượt trần container → SIGKILL 137, không một dòng. Và --max-old-space-size chỉ bao heap JS, không bao Buffer/ArrayBuffer/bộ nhớ thư viện native. Cùng chuyện "giới hạn chỉ phủ vùng của nó" ở khắp nơi: heap GC-quản-lý so với bộ nhớ off-heap, ulimit so với cgroup, một rate-limit phủ một endpoint chứ không phải tất cả, RSS gồm nhiều arena. Nguyên tắc: biết ứng dụng của mình có những vùng bộ nhớ nào, mỗi giới hạn phủ vùng nào, và vùng nào khi tràn thì chết im lặng — vì cái trần bạn yên tâm đặt có thể đang bỏ ngỏ đúng cái vùng thật sự phình.
Phần sau bàn về PID 1, tín hiệu, và cách dừng container cho tử tế.