Bạn docker stop myapp, terminal treo 10 giây rồi container mới tắt. Trong log chỉ có một dòng: Killed. Không stack trace, không error message. Đây là kịch bản quen thuộc khi app không handle SIGTERM: Docker gửi SIGTERM, đợi 10 giây (mặc định), app không shutdown, Docker gửi SIGKILL — kết thúc process ngay lập tức. Connection database bị drop giữa chừng, request in-flight biến mất.

Bài này đi sâu vào container lifecycle: từ lúc docker run đến khi container thành exited, Docker làm gì ở mỗi bước, signal truyền thế nào, và làm sao để app shutdown gracefully thay vì bị SIGKILL.


  stateDiagram-v2
    [*] --> created : docker create
    created --> running : docker start
    created --> [*] : docker rm
    running --> paused : docker pause
    paused --> running : docker unpause
    running --> stopped : docker stop (SIGTERM → SIGKILL)
    running --> killed : docker kill (SIGKILL)
    running --> [*] : docker rm -f
    stopped --> running : docker start
    killed --> running : docker start
    stopped --> [*] : docker rm
    killed --> [*] : docker rm
    running --> restarting : restart policy / OOM
    restarting --> running : restart thành công
    restarting --> stopped : restart thất bại (on-failure)

Container states: sáu trạng thái cần biết

Docker định nghĩa 6 trạng thái chính cho container. docker ps mặc định chỉ hiện container đang running — muốn thấy hết phải thêm -a.

# Chỉ thấy container đang chạy
$ docker ps
CONTAINER ID   IMAGE     COMMAND         STATUS
a1b2c3d4e5f6   nginx     "/docker-ent…"  Up 2 hours

# Thấy tất cả — kể cả đã stop, đã exit
$ docker ps -a
CONTAINER ID   IMAGE     STATUS
a1b2c3d4e5f6   nginx     Up 2 hours
b2c3d4e5f6a7   redis     Exited (0) 3 days ago
c3d4e5f6a7b8   postgres  Exited (137) 5 minutes ago
d4e5f6a7b8c9   alpine    Created
e5f6a7b8c9d0   node-app  Restarting (1) 10 seconds ago
StateÝ nghĩaKhi nào xảy ra
createdContainer đã được tạo nhưng chưa chạySau docker create, trước docker start
runningProcess chính đang chạySau docker start hoặc docker run
pausedProcess bị freeze bởi cgroup freezerSau docker pause
restartingĐang trong quá trình restartKhi restart policy kích hoạt (crash, OOM)
exitedProcess chính đã thoátSau docker stop, crash, hoặc process tự exit
deadContainer đã bị xóa một phần (lỗi daemon)Hiếm — daemon không cleanup được

Exit code trong cột STATUS cho biết process chính thoát với mã gì. Exited (0) là bình thường. Exited (137) là bị SIGKILL (128 + 9 = 137). Exited (143) là bị SIGTERM (128 + 15 = 143). Nếu bạn thấy Exited (137) trong docker ps -a, app của bạn nhiều khả năng đã bị Docker kill sau khi không shutdown kịp.

Mẹo debug nhanh: docker inspect <container> --format '{{.State}}' trả về JSON đầy đủ về state, exit code, start time, finish time, PID, và cả error message nếu có. Dùng jq để parse cho dễ đọc: docker inspect <id> | jq '.[0].State'.

docker stop flow: 10 giây định mệnh

Đây là flow quan trọng nhất cần hiểu. Khi bạn chạy docker stop <container>, Docker không kill process ngay lập tức. Nó làm theo trình tự:

  1. Gửi SIGTERM (signal 15) đến process chính (PID 1) trong container.
  2. Đợi process tự shutdown trong khoảng thời gian timeout (mặc định 10 giây).
  3. Nếu hết timeout mà process vẫn chưa thoát → gửi SIGKILL (signal 9), kill ngay lập tức không cho cleanup.
# Stop với timeout mặc định (10 giây)
docker stop my-app

# Tăng timeout lên 30 giây
docker stop --time 30 my-app

# Kill ngay lập tức (bỏ qua SIGTERM, gửi thẳng SIGKILL)
docker kill my-app
# Hoặc: docker stop --time 0 my-app

Tại sao 10 giây? Docker chọn con số này vì nó đủ dài cho hầu hết ứng dụng đóng connection, flush log, hoàn thành request đang xử lý, nhưng đủ ngắn để không block orchestration (Kubernetes, Docker Swarm) quá lâu khi cần scale down hoặc rolling update.

Bạn có thể cấu hình timeout toàn cục khi tạo container:

docker run --stop-timeout 30 my-app

Hoặc trong Docker Compose:

services:
  app:
    image: my-app
    stop_grace_period: 30s
SIGKILL không thể bị bắt (catch), chặn (block), hoặc bỏ qua (ignore). Kernel Linux thực thi SIGKILL ngay lập tức, process không có cơ hội cleanup. Nếu app của bạn nhận SIGKILL, mọi thứ đang dở dang — DB transaction, file write, HTTP response — đều mất. Đừng bao giờ dùng docker kill làm cách stop mặc định trong production.

SIGTERM vs SIGKILL: hai signal, hai số phận

SIGTERM (15)SIGKILL (9)
Có thể bắt được?Có — app có thể cài handlerKhông — kernel kill ngay
Cho phép cleanup?Có — đóng DB, flush log, hoàn thành requestKhông — process chết tức khắc
Mặc định nếu không bắt?Process bị terminate với cleanup mặc định của OSProcess bị kill
Docker dùng khi nào?docker stop (bước 1)docker stop hết timeout (bước 2), docker kill, OOM killer

Cách bắt SIGTERM trong code

Node.js:

// Graceful shutdown trong Node.js
let shuttingDown = false;

process.on("SIGTERM", async () => {
  if (shuttingDown) return;
  shuttingDown = true;
  console.log("Nhận SIGTERM — bắt đầu graceful shutdown...");

  // 1. Ngừng nhận request mới
  server.close();

  // 2. Đợi request hiện tại hoàn thành (tối đa 25s)
  await Promise.race([waitForInflightRequests(), sleep(25000)]);

  // 3. Đóng database connection
  await db.disconnect();

  // 4. Flush log buffer
  await logger.flush();

  console.log("Shutdown hoàn tất");
  process.exit(0);
});

Python:

import signal
import sys

def graceful_shutdown(signum, frame):
    print("Nhận SIGTERM — bắt đầu graceful shutdown...")
    # Đóng connections, flush logs...
    sys.exit(0)

signal.signal(signal.SIGTERM, graceful_shutdown)

Go:

sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT)

go func() {
    <-sigCh
    log.Println("Nhận SIGTERM — bắt đầu graceful shutdown...")
    // Tạo context với timeout 25s
    ctx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
    defer cancel()
    // Shutdown HTTP server
    srv.Shutdown(ctx)
    // Đóng DB pool
    db.Close()
    os.Exit(0)
}()

Mấu chốt là phải cài handler cho SIGTERM. Nếu app của bạn không làm gì với SIGTERM, default behavior của kernel là terminate process — nhưng không có cleanup nào được chạy. Connection vẫn mở, file buffer chưa flush, transaction chưa commit.

Tại sao phải có shuttingDown flag? Trong một số hệ thống, SIGTERM có thể được gửi nhiều lần (Docker gửi một lần, nhưng orchestration layer như Kubernetes có thể gửi thêm). Nếu không có flag, handler chạy lại lần hai trong khi lần một đang dở → race condition. Flag boolean hoặc atomic check đảm bảo shutdown logic chỉ chạy đúng một lần.

PID 1 problem: cái bẫy ít người để ý

Trên Linux, PID 1 có trách nhiệm đặc biệt:

  1. Reap zombie process — khi một child process chết, nó thành zombie cho đến khi parent gọi wait(). Nếu parent là PID 1, kernel tự động reap. Nhưng PID 1 phải cài signal handler cho SIGCHLD để việc này hoạt động.
  2. Không nhận SIGTERM mặc định — kernel Linux không gửi SIGTERM mặc định đến PID 1. Nói cách khác, nếu app của bạn là PID 1 và không cài handler cho SIGTERM, kernel sẽ bỏ qua SIGTERM hoàn toàn — Docker gửi SIGTERM, PID 1 phớt lờ, 10 giây sau SIGKILL.

Vấn đề là: trong container, process chính của bạn PID 1. Không có systemd, không có init. App Node.js, Python, Go của bạn trực tiếp là PID 1.

Hậu quả thực tế:

  • docker stop mất đúng 10 giây (timeout), sau đó bị SIGKILL.
  • App không có cơ hội graceful shutdown.
  • Nếu app fork child process, zombie tích tụ dần — docker stats thấy process count tăng đều theo thời gian.

Giải pháp

Cách 1: Dùng docker run --init

Docker tích hợp sẵn tini — một init process siêu nhẹ, chuyên dụng cho container:

docker run --init my-app

Tini làm PID 1, đảm nhiệm việc reap zombie và forward signal. App của bạn là PID 2 hoặc cao hơn, hoạt động như child process bình thường — nhận SIGTERM đầy đủ.

Cách 2: Cài tini thủ công trong Dockerfile

FROM node:22-alpine

# Cài tini làm init
RUN apk add --no-cache tini

# Tini là ENTRYPOINT, app là CMD
ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]

Cách 3: Dùng dumb-init

FROM python:3.13-slim

RUN pip install dumb-init
ENTRYPOINT ["dumb-init", "--"]
CMD ["python", "app.py"]

Cách 4: App tự xử lý signal (chỉ khi app được thiết kế để làm PID 1)

Một số runtime hiện đại đã xử lý PID 1 problem sẵn. Ví dụ Go runtime tự cài SIGTERM handler, Node.js từ phiên bản 16+ nhận SIGTERM bình thường dù là PID 1. Nhưng nếu bạn fork child process từ app, bạn vẫn cần tự reap zombie. Dùng --init vẫn là giải pháp an toàn nhất.

Shell form CMD KHÔNG forward signal. CMD node server.js (không có ngoặc vuông) chạy qua /bin/sh -c. Lúc này /bin/sh là PID 1, không phải Node.js. Shell không forward SIGTERM cho child process. Hậu quả: docker stop gửi SIGTERM → sh nhận → sh không forward → Node.js không biết gì → 10 giây sau SIGKILL. Luôn dùng exec form CMD ["node", "server.js"] để app của bạn nhận signal trực tiếp.

Shell form vs Exec form: khác biệt sống còn

Đây là một trong những bug phổ biến nhất trong Docker — và cực khó phát hiện vì container vẫn chạy bình thường, chỉ có shutdown mới lòi ra.

# Shell form — NGUY HIỂM
CMD node server.js
# Thực tế chạy: /bin/sh -c "node server.js"
# PID 1 = /bin/sh (không forward signal)
# Node.js = child process (không nhận được SIGTERM)

# Exec form — ĐÚNG
CMD ["node", "server.js"]
# Node.js chạy trực tiếp, là PID 1
# Nhận SIGTERM bình thường

Cách kiểm tra nhanh:

# Vào trong container đang chạy
$ docker exec -it my-app ps aux
PID   USER     COMMAND
1     root     /bin/sh -c node server.js    # ← Shell form!
7     root     node server.js

# So với exec form
PID   USER     COMMAND
1     root     node server.js               # ← Đúng rồi

Áp dụng cho cả CMD, ENTRYPOINT, và RUN. Chi tiết hơn về CMD/ENTRYPOINT, xem lại Phần 4: Dockerfile thực chiến.


Restart policies: khi nào nên restart, khi nào nên để crash?

Docker có 4 restart policy, cấu hình qua --restart:

PolicyHành viUse case
noKhông bao giờ tự restart (mặc định)Development local, job chạy một lần
alwaysLuôn restart, kể cả khi daemon rebootProduction web server, API
unless-stoppedNhư always, nhưng không restart nếu bạn chủ động docker stopProduction service, tôn trọng ý định admin
on-failure[:N]Chỉ restart khi exit code != 0, tối đa N lầnBackground worker, cron job
# Production web server
docker run -d --restart unless-stopped nginx

# Worker — restart tối đa 3 lần nếu crash
docker run -d --restart on-failure:3 my-worker

# Job chạy một lần — không restart
docker run --rm --restart no my-job
--restart always có thể che giấu bug. Nếu app của bạn crash ngay sau khi start (ví dụ config sai, thiếu biến môi trường), policy always sẽ tạo restart loop vô hạn. Container cứ restart → crash → restart → crash. Bạn sẽ thấy status Restarting (1) 2 seconds ago liên tục, và có thể không nhận ra app thực sự không hoạt động. Dùng unless-stopped thay vì always cho hầu hết trường hợp — nó cho phép bạn chủ động dừng container để debug mà không bị restart lại.

Cơ chế backoff: Docker không restart ngay lập tức. Sau mỗi lần restart thất bại, nó tăng dần thời gian delay (gấp đôi mỗi lần, tối đa 1 phút). Điều này tránh tình huống CPU 100% vì restart loop.

# Xem restart count
$ docker inspect my-app --format '{{.RestartCount}}'
17

# Xem log restart history
$ docker events --filter container=my-app --filter event=restart

docker pause/unpause: freeze container không kill

docker pause dùng cgroup freezer — một tính năng của Linux cgroup v1/v2 có thể “đóng băng” toàn bộ process group. Process không bị kill, không mất state, không mất memory — nó chỉ bị dừng scheduling.

# Freeze container
docker pause my-container

# Container status chuyển sang (Paused)
$ docker ps
CONTAINER ID   STATUS
a1b2c3d4e5f6   Up 2 hours (Paused)

# Unfreeze
docker unpause my-container

Khi nào dùng?

  • Debug memory leak — freeze container, dump heap, phân tích, rồi unpause.
  • Snapshot file system — pause container để đảm bảo consistency khi backup volume.
  • Rate-limit debugging — pause worker container để test xem queue có hoạt động đúng không.

Điểm cần lưu ý: Container bị pause vẫn chiếm memory, port, và các tài nguyên đã cấp phát. Nó chỉ không dùng CPU. Đừng nhầm với docker stop — pause giữ nguyên state, stop giải phóng state.


Graceful shutdown đúng cách: checklist

Tổng hợp tất cả những gì đã bàn ở trên thành một checklist để app của bạn shutdown đúng cách trong container:

  1. Dùng exec form cho CMD/ENTRYPOINTCMD ["node", "server.js"] thay vì CMD node server.js.
  2. Cài SIGTERM handler — bắt signal, bắt đầu graceful shutdown sequence.
  3. Shutdown sequence chuẩn:
    • Ngừng nhận request mới (health check trả về failing).
    • Đợi request in-flight hoàn thành (có timeout tối đa).
    • Đóng database connection pool.
    • Flush log buffer.
    • process.exit(0) / sys.exit(0).
  4. Dùng --init hoặc tini — đặc biệt nếu app fork child process.
  5. Đặt --stop-timeout phù hợp — 10 giây mặc định có thể không đủ cho app xử lý nhiều connection. Tăng lên 25-30 giây.
  6. Đặt stop_grace_period trong Compose — khớp với --stop-timeout.
  7. Health check trả về failing trong quá trình shutdown — để load balancer / orchestration ngừng gửi traffic đến container đang shutdown.
# Dockerfile mẫu với graceful shutdown đầy đủ
FROM node:22-alpine

RUN apk add --no-cache tini

WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY . .

HEALTHCHECK --interval=10s --timeout=3s --retries=3 \
  CMD wget -qO- http://localhost:3000/health || exit 1

ENTRYPOINT ["/sbin/tini", "--"]
CMD ["node", "server.js"]
# docker-compose.yml mẫu
services:
  api:
    build: .
    stop_grace_period: 30s
    healthcheck:
      test: ["CMD", "wget", "-qO-", "http://localhost:3000/health"]
      interval: 10s
      timeout: 3s
      retries: 3
    deploy:
      update_config:
        order: stop-first

Bài viết chi tiết về graceful shutdown pattern (bao gồm health check integration, Kubernetes pod termination, và connection draining), mình có viết ở bài: Graceful shutdown không mất request.


Tổng kết

Hiểu container lifecycle không phải kiến thức “nice to have” — nó là thứ quyết định app của bạn có mất dữ liệu lúc 2h sáng hay không. Ba điểm quan trọng nhất:

  1. Docker gửi SIGTERM, đợi 10 giây, rồi SIGKILL. App của bạn phải bắt SIGTERM và shutdown trong khoảng thời gian đó. Nếu không, SIGKILL sẽ cắt mọi thứ đang dở.
  2. PID 1 problem là có thật. App chạy trực tiếp trong container không có init system. Dùng --init hoặc tini để tránh zombie process và đảm bảo signal forwarding.
  3. Exec form, không phải shell form. CMD ["node", "server.js"] — ba giây để viết đúng, nhưng tiết kiệm hàng giờ debug shutdown bug.

Câu hỏi hay gặp

Q: Làm sao biết app có đang bị SIGKILL hay không? A: docker ps -a — nếu STATUS là Exited (137), app bị SIGKILL (128 + 9 = 137). Nếu là Exited (143), app bị SIGTERM (128 + 15 = 143). Ngoài ra docker logs <container> thường show log cuối cùng trước khi bị kill — nếu log dừng đột ngột không có “shutting down” message, khả năng cao là SIGKILL.

Q: Tăng --stop-timeout lên bao nhiêu là đủ? A: Tùy app. Một web server xử lý request ngắn (< 1s) có thể shutdown trong 5 giây. Một worker xử lý batch job có thể cần 60-120 giây. Cách xác định: đo thời gian shutdown dài nhất trong điều kiện tải peak, rồi thêm 20% buffer. Đừng đặt quá cao — Kubernetes mặc định terminationGracePeriodSeconds là 30 giây, nếu Docker timeout dài hơn thế, K8s sẽ gửi SIGKILL trước khi Docker kịp làm.

Q: Docker Compose stop_grace_period khác gì với docker run --stop-timeout? A: Cùng một thứ — stop_grace_period trong Compose map sang --stop-timeout của docker stop. Compose dùng format như 30s, 1m, 2m30s. Nếu không set, mặc định là 10 giây.

Q: docker pause có làm mất network connection không? A: Không mất connection, nhưng connection sẽ “treo” — TCP connection giữ nguyên state, nhưng không có data nào được gửi/nhận trong thời gian pause. Khi unpause, mọi thứ tiếp tục như chưa có gì xảy ra, trừ khi TCP keepalive timeout phía client hoặc load balancer đã cắt connection. Đối với HTTP request, client có thể timeout và retry — nên pause chỉ dùng để debug, không phải công cụ vận hành.


Bài tiếp theo (Dữ liệu & Vận hành): Phần 6: Volume, bind mount & tmpfs, persistence model, backup strategy, và cách giữ dữ liệu khi container bị xóa.