Chạy container không set resource limit giống như cho process quyền dùng toàn bộ RAM và CPU của host. Nghe có vẻ vô hại — cho đến khi một container bị memory leak, leo dần từ vài trăm MB lên 32GB, nuốt hết RAM host. Kernel hết memory, OOM killer được gọi. Nó không kill container gây leak — nó kill process đang dùng nhiều RAM nhất tại thời điểm đó. Rất có thể đó là PostgreSQL của bạn, đang hoạt động hoàn toàn bình thường.
Bài này giải thích cách set CPU/memory limit cho container, cơ chế OOM killer hoạt động thế nào với cgroup, init process (tini/dumb-init), và cách tính resource limit hợp lý cho production workload.
Container bị leak thì không nhất thiết container leak bị kill. Kẻ vô tội có thể chịu trận.
Đây chính xác là lý do resource limit không phải là “nice to have” — nó là survival requirement trong production.
Tại sao cần resource limit
Mặc định, Docker container không có bất kỳ giới hạn tài nguyên nào. Một container có thể dùng toàn bộ CPU, toàn bộ RAM, toàn bộ swap của host. Điều này có nghĩa:
- Một container memory leak → host hết RAM → kernel kích hoạt OOM killer → kill random process. Process bị kill có thể là container đó, cũng có thể là database của bạn, SSH daemon, hay bất kỳ thứ gì kernel thấy “nặng” nhất.
- Một container CPU spin (ví dụ infinite loop) → chiếm 100% tất cả cores → các container khác starve → health check timeout → orchestrator restart container → loop tiếp.
- Noisy neighbor problem: container A dùng quá nhiều tài nguyên làm container B chậm, dù B không có lỗi gì.
Nguyên lý cốt lõi: mỗi container phải có ceiling. CPU ceiling. Memory ceiling. Nếu vượt ceiling, container đó chết — không ảnh hưởng đến hàng xóm.
Memory limits
Memory limit là quan trọng nhất, vì memory là tài nguyên không thể chia sẻ (không giống CPU — khi một process không dùng CPU thì process khác được dùng). Memory đã cấp cho container A thì container B không dùng được.
Hard limit: --memory (hay -m)
Đây là giới hạn cứng — container không thể dùng quá con số này. Nếu vượt, kernel sẽ kill process (theo cơ chế OOM bên trong namespace của container đó — khác với host OOM).
# Giới hạn 512MB RAM cho container
docker run -d --memory 512m --name myapp myimage:latest
Đơn vị: b (bytes), k (kilobytes), m (megabytes), g (gigabytes). Nếu không ghi đơn vị, mặc định là bytes.
Soft limit: --memory-reservation
Soft limit thấp hơn hard limit. Kernel cố gắng giữ memory usage dưới soft limit khi host đang thiếu memory. Khi host dư memory, container có thể dùng vượt soft limit (nhưng không vượt hard limit).
# Soft limit 256MB, hard limit 512MB
docker run -d --memory 512m --memory-reservation 256m myapp
Soft limit hữu ích khi bạn muốn “co giãn” — lúc host rảnh thì cho container dùng nhiều hơn, lúc host căng thì kéo về soft limit.
Swap: --memory-swap
--memory-swap kiểm soát tổng memory + swap mà container được dùng. Cách tính:
--memory-swap= tổng (RAM + swap) tối đa.--memory-swapbằng--memory→ swap = 0 → disable swap hoàn toàn cho container.- Không set
--memory-swap→ mặc định swap = 2x--memory. --memory-swap = -1→ swap không giới hạn (giới hạn bởi host).
# 512MB RAM + 512MB swap = 1GB total
docker run -d --memory 512m --memory-swap 1g myapp
# Disable swap hoàn toàn (container chỉ dùng RAM, không swap)
docker run -d --memory 512m --memory-swap 512m myapp
OOM kill disable: --oom-kill-disable
Flag này tắt OOM killer cho container đó — khi container đạt memory limit, kernel không kill process ngay mà… treo nó. Container bị “frozen”, không xử lý được gì, nhưng không bị kill.
docker run -d --memory 512m --oom-kill-disable myapp
--oom-kill-disable là con dao hai lưỡi. Container bị treo nhưng vẫn chiếm memory đó. Không kill được, không restart được. Hầu hết trường hợp bạn không muốn bật flag này. Chỉ dùng khi bạn tuyệt đối không thể mất state trong container (ví dụ: in-memory cache cần graceful eviction), và bạn có monitoring để phát hiện container bị frozen.CPU limits
Khác với memory, CPU là tài nguyên có thể chia sẻ theo thời gian. Khi một container không dùng CPU, container khác được dùng tất cả cores. Nhưng bạn vẫn cần limit để ngăn CPU starvation.
Absolute limit: --cpus
Cách đơn giản và khuyên dùng: giới hạn số CPU cores mà container được dùng.
# Tối đa 1.5 cores
docker run -d --cpus 1.5 myapp
# Tối đa 0.5 core (50% của 1 core)
docker run -d --cpus 0.5 myapp
Đây là hard limit — container không thể vượt qua, kể cả host đang idle. Kernel dùng CFS (Completely Fair Scheduler) để throttle container khi nó vượt quota.
--cpus 1.5 thực chất tương đương với --cpu-period 100000 --cpu-quota 150000. Period = 100ms (mặc định), quota = 150ms → mỗi 100ms container được chạy tối đa 150ms → 1.5 cores.
Relative weight: --cpu-shares
CPU shares là relative weight — chỉ có tác dụng khi CPU contention (nhiều container tranh CPU). Container có shares cao hơn được ưu tiên nhiều CPU time hơn. Mặc định mọi container có 1024 shares.
# Container A: weight cao (critical service)
docker run -d --cpu-shares 2048 --name service-a myapp
# Container B: weight thấp (background job)
docker run -d --cpu-shares 512 --name service-b myapp
Khi CPU contention, A được gấp 4 lần CPU time so với B. Khi CPU dư, cả hai dùng được tất cả.
--cpus cho hard limit, --cpu-shares cho priority. Ví dụ: API server — --cpus 2 (không bao giờ dùng quá 2 cores), background worker — --cpus 1 --cpu-shares 512 (giới hạn 1 core, ưu tiên thấp hơn API khi contention).Pin vào core cụ thể: --cpuset-cpus
Bind container vào một tập CPU cores cụ thể. Hữu ích cho latency-sensitive workload (ví dụ: financial trading, real-time stream processing) cần tránh cache miss do process bị scheduler dời qua core khác.
# Chỉ dùng core 0 và core 1
docker run -d --cpuset-cpus 0-1 myapp
# Chỉ dùng core 2
docker run -d --cpuset-cpus 2 myapp
Đừng lạm dụng — để kernel scheduler tự quyết định thường cho hiệu năng tổng thể tốt hơn.
OOM killer behavior
Khi host hết memory, kernel Linux gọi OOM killer (Out-Of-Memory killer) để chọn một (hoặc vài) process để kill, giải phóng memory. Cơ chế chọn dựa trên OOM score.
OOM score
Mỗi process có một OOM score nằm trong file /proc/<PID>/oom_score. Score càng cao → khả năng bị kill càng cao. Score được kernel tính dựa trên:
- Memory usage: process dùng càng nhiều RAM → score càng cao.
- Runtime: process chạy càng lâu → score càng thấp (kernel “tôn trọng” process lâu đời).
- Process type: root process có score thấp hơn (một chút).
oom_score_adj: giá trị điều chỉnh do người dùng set.
--oom-score-adj: kiểm soát rủi ro bị kill
Docker cho phép bạn set oom_score_adj cho container qua flag --oom-score-adj. Giá trị từ -1000 (không bao giờ bị kill) đến 1000 (luôn bị kill đầu tiên).
# Database: giảm khả năng bị kill (oom_score_adj thấp = ít bị kill)
docker run -d --memory 2g --oom-score-adj -500 --name postgres postgres:16
# Background cron: tăng khả năng bị kill (hy sinh nó để cứu database)
docker run -d --memory 256m --oom-score-adj 500 --name cronjob myapp
Quy tắc vàng cho oom-score-adj trong production:
- Database, message queue →
-500đến-800(cứu bằng mọi giá). - API chính →
-200đến-400. - Background worker, cron →
0đến300. - Log shipper, monitoring agent →
500đến800(hy sinh đầu tiên).
Memory monitoring
Set limit xong không có nghĩa là xong. Bạn cần monitoring để biết khi nào container gần chạm limit, trước khi OOM xảy ra.
docker stats
Cách nhanh nhất để xem real-time resource usage:
docker stats --no-stream
Output cho mỗi container: CONTAINER ID, NAME, CPU %, MEM USAGE / LIMIT, MEM %, NET I/O, BLOCK I/O, PIDS.
Cột MEM USAGE / LIMIT là quan trọng nhất — nếu LIMIT hiển thị giá trị (không phải total host RAM), nghĩa là container đã được set memory limit.
cAdvisor + Prometheus
cAdvisor (container advisor) là công cụ của Google, chạy như một container, thu thập metrics từ tất cả container trên host và expose qua HTTP endpoint. Kết hợp với Prometheus + Grafana:
# docker-compose.yml monitoring stack
services:
cadvisor:
image: gcr.io/cadvisor/cadvisor:latest
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker:/var/lib/docker:ro
ports:
- "8080:8080"
Metric quan trọng từ cAdvisor:
container_memory_usage_bytes— memory thực tế đang dùng.container_memory_working_set_bytes— working set (không bao gồm inactive file cache).container_memory_failcnt— số lần container bị reject memory allocation.container_cpu_usage_seconds_total— total CPU time.
Alert rules
Alert nên được set ở 80% limit (warning) và 95% limit (critical). Đừng đợi đến 100% — lúc đó container đã sắp OOM và bạn không còn thời gian phản ứng.
Ví dụ Prometheus alert rule:
- alert: ContainerMemoryHigh
expr: container_memory_working_set_bytes / container_spec_memory_limit_bytes > 0.8
for: 5m
annotations:
summary: "Container {{ $labels.name }} memory > 80% limit"
Working set vs RSS vs usage — nên alert trên metric nào?
container_memory_usage_bytes: tổng memory bao gồm cả page cache (file cache). Không phản ánh đúng memory thực tế cần cho process — file cache có thể bị reclaim bất cứ lúc nào.container_memory_working_set_bytes: usage trừ đi inactive file cache. Đây là metric gần nhất với “memory không thể reclaim” — tức là memory mà nếu thiếu sẽ OOM. Nên alert trên metric này.- RSS (Resident Set Size): memory vật lý đang dùng, nhưng không có sẵn từ cAdvisor mà phải vào trong container.
Init process: PID 1 và zombie reaping
Đây là một chủ đề thường bị bỏ qua nhưng quan trọng không kém resource limit — đặc biệt khi container chạy shell script spawn nhiều child process.
Vấn đề với PID 1
Trong Linux, PID 1 có hai trách nhiệm đặc biệt mà kernel giao cho nó:
- Signal handling: PID 1 không nhận signal mặc định như process thường. Nếu bạn gửi
SIGTERMđến PID 1 là một app không handle signal, app sẽ không dừng. - Zombie reaping: Khi child process chết, nó trở thành zombie cho đến khi parent gọi
wait(). Nếu parent là PID 1, PID 1 phải reap zombie. App thông thường không làm việc này.
Kết quả: nếu bạn chạy trực tiếp CMD ["node", "server.js"], process Node.js là PID 1. Nó không handle SIGTERM đúng cách (trừ khi bạn code explicit handler), và zombie child process tích tụ dần.
Giải pháp: tini hoặc dumb-init
Cả hai đều là init process siêu nhẹ (vài KB) được thiết kế để làm PID 1 trong container:
- Nhận SIGTERM/SIGINT và forward đúng cách đến app process.
- Reap zombie child process.
Cách 1: docker run --init
Cách đơn giản nhất — Docker engine tự inject tini làm PID 1:
docker run -d --init --memory 512m myapp
Cách 2: Tích hợp vào Dockerfile
# Dùng dumb-init (phổ biến trong Python/Node.js community)
FROM node:22-alpine
RUN apk add --no-cache dumb-init
COPY . /app
WORKDIR /app
CMD ["dumb-init", "node", "server.js"]
hoặc dùng tini:
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y tini
COPY . /app
WORKDIR /app
ENTRYPOINT ["/usr/bin/tini", "--"]
CMD ["/app/server"]
--init hoặc tích hợp init process vào Dockerfile cho production container. Chi phí: vài KB memory, gần như zero CPU overhead. Lợi ích: graceful shutdown hoạt động, không zombie leak, container stop nhanh và sạch.Graceful shutdown trong container
Khi Docker stop container (docker stop), nó gửi SIGTERM đến PID 1, đợi --stop-timeout giây (mặc định 10s), rồi gửi SIGKILL. Với init process đúng cách:
docker stop
→ SIGTERM đến tini/dumb-init (PID 1)
→ tini forward SIGTERM đến app process
→ app graceful shutdown: close connections, flush logs, finish in-flight requests
→ tini forward SIGKILL (nếu timeout)
→ container exits cleanly
Không có init process, SIGTERM có thể bị ignore, và sau 10 giây Docker force-kill — connections bị drop đột ngột, request đang xử lý mất dữ liệu.
Resource limit trong Docker Compose
Trong Compose file (v3+), resource limit được cấu hình dưới key deploy.resources:
services:
api:
image: myapp:latest
deploy:
resources:
limits:
cpus: "2.0"
memory: 1g
reservations:
cpus: "0.5"
memory: 512m
Sự khác biệt:
limits: hard ceiling. Container không thể vượt qua. Với memory, vượt limit = OOM kill. Với CPU, vượt limit = throttle.reservations: soft guarantee. Scheduler đảm bảo container có ít nhất bấy nhiêu tài nguyên. Nếu host không đủ, container không được schedule (trong Swarm mode).
Lưu ý: key deploy chỉ có hiệu lực khi dùng docker stack deploy (Swarm mode). Với docker-compose up, bạn cần dùng cú pháp cũ (compose v2):
services:
api:
image: myapp:latest
mem_limit: 1g
mem_reservation: 512m
cpus: 2.0
Compose v2 keys: mem_limit, mem_reservation, cpus, cpu_shares, cpu_count, v.v. Luôn kiểm tra compose file version bạn đang dùng.
Real-world sizing: mỗi stack một con số
Không có con số “one size fits all”. Nhưng có những guideline thực tế dựa trên đặc tính của từng runtime.
Node.js
Node.js chạy single-thread (với event loop) + libuv thread pool (4 threads mặc định). Memory heap giới hạn mặc định ~1.4GB (64-bit) hoặc ~512MB (32-bit).
Base Node.js process: ~50MB
+ Framework (Express): +20-30MB
+ ORM (Prisma): +50-100MB
+ App code + data buffer: +100-300MB
----------------------------------------
Practical limit: 256MB - 512MB
CPU: 1-2 cores (single-threaded, scale bằng nhiều instance)
Lưu ý: --max-old-space-size để giới hạn heap V8 — set thấp hơn container memory limit để có buffer cho native memory (buffer, C++ addon).
# Container limit 512MB, heap limit 384MB (để 128MB cho native)
CMD ["node", "--max-old-space-size=384", "server.js"]
Java / JVM
JVM có memory model phức tạp hơn: heap + metaspace + thread stacks + native + code cache + GC overhead.
Container memory: 1g
├── Heap (-Xmx): 600MB
├── Metaspace: 128MB
├── Thread stacks: 100MB (250 threads × 400KB)
├── Native + GC overhead: ~172MB
Dùng -XX:MaxRAMPercentage thay vì -Xmx cứng trong container. JVM ≥10 hỗ trợ -XX:MaxRAMPercentage=75.0 — tự động tính heap = 75% container memory limit. Điều này làm image portable hơn: nếu bạn tăng container memory limit, JVM tự điều chỉnh heap.
java -XX:MaxRAMPercentage=75.0 -XX:InitialRAMPercentage=50.0 -jar app.jar
Go
Go có goroutine siêu nhẹ (vài KB mỗi goroutine), GC hiệu quả, và memory footprint nhỏ mặc định.
Base Go binary: ~10-20MB
+ Per-request overhead: vài KB
+ Goroutine leak: vài MB mỗi 100k goroutine
----------------------------------------
Practical limit: 64MB - 256MB
CPU: 0.5-4 cores (GOMAXPROCS = container CPU limit)
Go 1.19+ tự động đọc cgroup CPU quota để set GOMAXPROCS — không cần manual config.
GODEBUG=gctrace=1 và pprof để debug goroutine leak trong production. Một goroutine bị block mãi mãi trên channel chưa bao giờ được đọc/ghi là nguyên nhân phổ biến nhất.Python
WSGI servers (Gunicorn, uWSGI) thường dùng multi-process model. Mỗi worker process là một Python interpreter riêng.
Gunicorn master: ~50MB
+ 4 workers × ~150MB: +600MB
+ Shared libs (CoW): -100MB (shared pages)
----------------------------------------
Practical limit: 512MB - 1GB
CPU: 2-4 cores (tùy số worker)
DEBUG=True) không release query results. Luôn set DEBUG=False trong production và dùng iterator() cho large queryset.Mermaid diagram: OOM killer decision flow
Dưới đây là luồng quyết định của Linux OOM killer khi host hết memory — mô tả cách kernel chọn process để kill trong một host chạy nhiều container:
graph TD
A["Host memory exhausted<br/>Allocation fails repeatedly"] --> B["Kernel invokes OOM killer"]
B --> C["Compute badness score<br/>for every process"]
C --> D{"Process has<br/>oom_score_adj?"}
D -->|"Yes (+500)"| E["High risk group<br/>Background workers, cron"]
D -->|"Yes (-500)"| F["Low risk group<br/>Database, API server"]
D -->|"No (default 0)"| G["Normal computation<br/>Based on: memory usage<br/>+ runtime + privileges"]
E --> H["badness = usage × adj_factor<br/>→ higher score → killed first"]
F --> I["badness = usage × adj_factor<br/>→ lower score → protected"]
G --> J["badness = anon_rss + swap_usage<br/>child mem shared ÷ 2"]
H --> K["OOM killer selects<br/>highest badness process"]
I --> K
J --> K
K --> L["Kill selected process<br/>free its memory"]
L --> M["Host recovers<br/>Remaining containers survive"]
style A fill:#ff6b6b,stroke:#c92a2a,color:#fff
style K fill:#ffd43b,stroke:#fab005,color:#1a1a2e
style M fill:#51cf66,stroke:#2f9e44,color:#fff
Một điểm đáng chú ý từ diagram này: OOM killer hoạt động ở host level — nó kill process, không phải container. Nếu bạn không set memory limit cho từng container, bạn không kiểm soát được việc kernel chọn ai. Database của bạn có thể bị kill vì một container leak ở đâu đó. Ngược lại, nếu mọi container đều có limit, OOM xảy ra bên trong namespace của container vi phạm — chỉ container đó bị kill, hàng xóm an toàn.
Giải thích rõ hơn ở bài về Namespace & cgroup — cgroup là mechanism cho phép kernel áp dụng memory limit và OOM ở mức container thay vì host.
Tổng kết
Resource limit là “hàng rào” giữa container của bạn và phần còn lại của production. Không có nó, một lỗi nhỏ trong một service có thể leo thang thành outage toàn bộ host. Những điểm cần nhớ:
- Luôn set
--memorycho mọi container production — không có ngoại lệ. - Disable swap —
--memory-swapbằng--memoryđể container không dùng swap. - Dùng
--cpusthay vì--cpu-sharescho hard limit — CPU shares chỉ có tác dụng khi contention. - Set
oom_score_adjcho service quan trọng (database, API chính) để giảm khả năng bị host OOM kill. - Monitoring — alert ở 80% memory limit, đừng đợi 95%.
- Dùng init process (
--init, tini, dumb-init) — để SIGTERM hoạt động và zombie được reap. - Resize khi cần — nếu container liên tục dùng >80% limit, tăng limit hoặc tối ưu code. Đừng đợi OOM rồi mới hành động.
Câu hỏi hay gặp
1. Tôi set memory limit 512MB, container vẫn dùng vượt lên 600MB?
Có thể container của bạn đang dùng file cache (page cache). docker stats hiển thị MEM USAGE bao gồm cả cache — nhưng cache có thể bị kernel reclaim khi cần, không tính vào hard limit. Dùng container_memory_working_set_bytes từ cAdvisor để có con số chính xác.
2. docker stats cho thấy memory limit là total host RAM, không phải limit tôi đã set?
Điều này xảy ra khi bạn dùng Docker Compose với key deploy.resources nhưng chạy bằng docker-compose up (Compose v2). Key deploy chỉ có hiệu lực trong Swarm mode (docker stack deploy). Chuyển sang key mem_limit và cpus cho Compose v2.
3. Có nên set memory limit cho database container không?
Có — và đây là container cần limit cẩn thận nhất. PostgreSQL và MySQL dùng shared_buffers / innodb_buffer_pool chiếm phần lớn memory. Set limit cao hơn buffer pool size (ví dụ: buffer pool 2GB → limit 3GB để có room cho connections, sort buffers, v.v.). Kết hợp với oom_score_adj thấp để database ít bị host OOM kill nhất có thể.
4. Container tôi chạy fine ở staging nhưng OOM ở production. Sao lại thế?
Staging thường có ít traffic hơn, ít connections hơn, ít data hơn. Memory profile trong production khác hoàn toàn. Kiểm tra: (a) traffic spike có tạo spike memory không? (b) payload size có lớn hơn không? (c) có background job/batch processing trong production mà staging không có? (d) connection pool có lớn hơn không? Dùng profiling tool cho runtime của bạn (pprof, heapdump, NODE_OPTIONS="–heap-prof") trên production instance thật.
Bài tiếp theo (Production): Phần 14: Docker security: capabilities đến rootless, seccomp, AppArmor, capability dropping, read-only filesystem.