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ì.
Không set resource limit trong production ~ rủi ro snowball. Một memory leak nhỏ trong một service không quan trọng có thể kéo sập toàn bộ host — bao gồm database, message queue, reverse proxy. Đừng để một container “nobody care” giết chết infrastructure của bạn.

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-swap bằng --memoryswap = 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
Luôn disable swap cho production container. Swap + container = latency spike không đoán trước. Khi process bị swap ra disk, mọi thứ chậm đi hàng trăm lần. Nếu container cần swap để sống, nó cần được resize memory limit — hoặc fix memory leak.

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ả.

Dùng --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 đến 300.
  • Log shipper, monitoring agent → 500 đến 800 (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ó:

  1. 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.
  2. 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"]
Luôn dùng --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.

Goroutine leak vẫn có thể gây OOM. Dù mỗi goroutine chỉ tốn vài KB stack, nhưng 1 triệu goroutine leak = vài GB memory. Dùng GODEBUG=gctrace=1pprof để 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)
Python container có thể ngốn memory nhanh nếu xử lý file upload lớn hoặc data processing batch. Django debug mode (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ớ:

  1. Luôn set --memory cho mọi container production — không có ngoại lệ.
  2. Disable swap--memory-swap bằng --memory để container không dùng swap.
  3. Dùng --cpus thay vì --cpu-shares cho hard limit — CPU shares chỉ có tác dụng khi contention.
  4. Set oom_score_adj cho service quan trọng (database, API chính) để giảm khả năng bị host OOM kill.
  5. Monitoring — alert ở 80% memory limit, đừng đợi 95%.
  6. Dùng init process (--init, tini, dumb-init) — để SIGTERM hoạt động và zombie được reap.
  7. 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_limitcpus 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.