DEV Community

EME GUG
EME GUG

Posted on

Why Your Docker Container Takes 10 Seconds to Stop: PID 1, Signals and Graceful Shutdown Explained

Bài "Docker is weird" đang hot trên Hacker News làm mình nhớ lại một chuyện cũ. Team mình từng mất gần một tuần để tìm hiểu vì sao mỗi lần deploy, request lại bị lỗi 502 lẻ tẻ, còn docker compose down thì lần nào cũng "đứng hình" đúng 10 giây. Hóa ra lỗi không nằm ở code business mà ở một chi tiết rất nhỏ: process của app đang chạy với PID 1 trong container và không hề nhận được signal tắt. Bài này mình giải thích vì sao chuyện đó xảy ra, cách kiểm tra trong 30 giây và cách fix đúng cho cả Dockerfile lẫn code ứng dụng.

docker stop thực sự làm gì?

Khi bạn chạy docker stop (hoặc docker compose down, hay Kubernetes xóa một Pod), quy trình diễn ra như sau:

  1. Docker gửi SIGTERM (hoặc signal khai báo trong STOPSIGNAL) tới PID 1 của container.
  2. Docker chờ hết grace period, mặc định là 10 giây.
  3. Nếu container vẫn còn chạy, Docker gửi SIGKILL. Process chết ngay lập tức, không kịp dọn dẹp gì.
sequenceDiagram
    participant D as Docker Engine
    participant P as PID 1 (container)
    participant A as App process
    D->>P: SIGTERM
    alt PID 1 là app và có handler
        P->>P: đóng connection, flush log
        P-->>D: exit 0 (< 1s)
    else PID 1 là shell / không có handler
        P--xA: không forward signal
        Note over D: chờ 10 giây...
        D->>P: SIGKILL
        P-->>D: exit 137
    end

Nếu docker ps -a hiển thị container exit với code 137 (tức 128 + 9 = SIGKILL), gần như chắc chắn app của bạn đã bị kill cứng, không phải tự tắt. Hậu quả: request đang xử lý bị cắt ngang, transaction dở dang, message trong queue bị xử lý hai lần, log cuối cùng không được flush.

PID 1 là một process "đặc biệt"

Trên Linux, PID 1 (init) được kernel đối xử khác hẳn các process thường:

  • Kernel không áp dụng default signal action cho PID 1. Process thường nhận SIGTERM mà không có handler thì sẽ chết. PID 1 thì ngược lại: không có handler nghĩa là signal bị bỏ qua. Điều này cũng đúng với PID 1 bên trong PID namespace của container.
  • PID 1 có trách nhiệm reap zombie process. Khi một process con chết mà parent không gọi wait(), nó trở thành zombie. Orphan process sẽ được gán lại cho PID 1. Node.js hay Python không được viết để làm init nên không reap gì cả.

Vì vậy, một app Python chạy làm PID 1 mà không đăng ký handler cho SIGTERM sẽ không phản ứng gì với docker stop, dù khi chạy trên máy local bạn bấm Ctrl+C thì nó vẫn tắt bình thường (vì đó là SIGINT, và lúc đó nó cũng không phải PID 1).

Cái bẫy phổ biến thứ hai là shell form trong Dockerfile:

# Shell form: Docker chạy /bin/sh -c "node server.js"
CMD node server.js

# Exec form: node chạy trực tiếp với PID 1
CMD ["node", "server.js"]
Enter fullscreen mode Exit fullscreen mode

Với shell form, PID 1 có thể là /bin/sh. Shell không forward SIGTERM cho process con, nên node không bao giờ biết mình sắp bị tắt. Một số shell có tối ưu exec cho câu lệnh đơn giản, nhưng hành vi này khác nhau giữa bash, dash và busybox ash, nên đừng dựa vào nó. Tương tự, CMD ["npm", "start"] cũng thêm một tầng wrapper (npm → sh → node), và việc forward signal qua các tầng này không đáng tin cậy.

Cách kiểm tra rất nhanh:

# Xem process nào đang là PID 1
docker exec my-app ps -o pid,ppid,comm
# Nếu image không có ps (distroless), đọc thẳng /proc:
docker exec my-app cat /proc/1/cmdline | tr '\0' ' '; echo

# Đo thời gian stop
time docker stop my-app
# ~10s  => signal không tới được app
# < 1s  => OK

docker inspect my-app --format '{{.State.ExitCode}}'
# 137 => bị SIGKILL, 0 / 143 => tắt có kiểm soát
Enter fullscreen mode Exit fullscreen mode

Fix ở tầng container: exec form, exec và tini

Bước 1: Luôn dùng exec form cho CMD và ENTRYPOINT. Đây là quy tắc mà Docker docs cũng khuyến nghị.

Bước 2: Nếu cần entrypoint script (để render config, chờ DB, chạy migration...), dòng cuối cùng phải là exec, để app thay thế shell và kế thừa PID 1:

#!/bin/sh
# docker-entrypoint.sh
set -e

echo "Rendering config for env: ${APP_ENV:-dev}"
envsubst < /app/config.tpl > /app/config.json

if [ "$RUN_MIGRATIONS" = "true" ]; then
  node /app/migrate.js
fi

# QUAN TRỌNG: exec để node thay thế shell, nhận signal trực tiếp
exec "$@"
Enter fullscreen mode Exit fullscreen mode
FROM node:22-bookworm-slim
RUN apt-get update && apt-get install -y --no-install-recommends tini gettext-base \
    && rm -rf /var/lib/apt/lists/*
WORKDIR /app
COPY . .
RUN npm ci --omit=dev
COPY docker-entrypoint.sh /usr/local/bin/
ENTRYPOINT ["tini", "--", "docker-entrypoint.sh"]
CMD ["node", "server.js"]
Enter fullscreen mode Exit fullscreen mode

Bước 3: Dùng một init nhỏ làm PID 1. tini (bản 0.19.0) chỉ làm hai việc: forward signal cho process con và reap zombie. Có hai cách dùng:

  • Cài vào image như ví dụ trên (apt install tini hoặc apk add tini trên Alpine).
  • Không sửa image: docker run --init ... hoặc init: true trong Compose. Docker sẽ inject docker-init (thực chất chính là tini) vào làm PID 1.

Trên Kubernetes không có --init, nên cách cài sẵn tini trong image là an toàn nhất. Lưu ý thêm: nếu app spawn process con (gọi ffmpeg, chromium, git...), tini là gần như bắt buộc để tránh tích tụ zombie.

Fix ở tầng ứng dụng: xử lý SIGTERM cho đúng

Signal tới được app mới chỉ là một nửa câu chuyện. App còn phải tắt có kiểm soát: ngừng nhận request mới, xử lý xong request đang chạy, đóng connection DB rồi mới exit. Ví dụ với Node.js 22:

const http = require('node:http');

const server = http.createServer(async (req, res) => {
  await new Promise((r) => setTimeout(r, 2000)); // giả lập request chậm
  res.end('ok\n');
});

server.listen(3000, () => console.log('listening on :3000'));

let shuttingDown = false;

function shutdown(signal) {
  if (shuttingDown) return;
  shuttingDown = true;
  console.log(`${signal} received, draining connections...`);

  // Ngừng nhận connection mới, chờ request đang chạy xong
  server.close(async (err) => {
    // await db.end(); await redis.quit(); ...
    console.log('closed cleanly');
    process.exit(err ? 1 : 0);
  });
  server.closeIdleConnections(); // đóng keep-alive idle (Node >= 18.2)

  // Lưới an toàn: phải exit trước khi Docker gửi SIGKILL
  setTimeout(() => {
    console.error('force exit after 8s');
    process.exit(1);
  }, 8000).unref();
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));
Enter fullscreen mode Exit fullscreen mode

Với Python, dùng signal.signal(signal.SIGTERM, handler). Nếu chạy qua Gunicorn hay Uvicorn thì chúng đã tự xử lý graceful shutdown, bạn chỉ cần đảm bảo chúng là PID 1 (hoặc là con trực tiếp của tini).

Vài điểm cần cân chỉnh cho khớp với nhau:

  • Timeout nội bộ < grace period. Docker mặc định 10s, nên đặt force-exit khoảng 8s. Nếu cần lâu hơn: docker stop -t 30, stop_grace_period: 30s trong Compose, hoặc terminationGracePeriodSeconds trên Kubernetes.
  • Trên Kubernetes, endpoint có thể vẫn nhận traffic vài giây sau khi Pod nhận SIGTERM. Thêm preStop sleep 5 giây (từ Kubernetes 1.30 có thể dùng lifecycle.preStop.sleep) để load balancer kịp rút Pod ra trước khi app đóng server.
  • Worker xử lý queue: khi nhận SIGTERM thì ngừng lấy job mới, xử lý xong job hiện tại rồi ack. Đừng exit giữa chừng.
flowchart TD
    A[Container chậm stop / exit 137] --> B{PID 1 là gì?}
    B -->|sh / npm| C[Chuyển sang exec form hoặc thêm exec trong entrypoint]
    B -->|app| D{App có handler SIGTERM?}
    B -->|tini| D
    C --> D
    D -->|Không| E[Thêm handler: close server, đóng DB, exit]
    D -->|Có| F{Shutdown lâu hơn grace period?}
    F -->|Có| G[Tăng stop_grace_period hoặc giảm timeout nội bộ]
    F -->|Không| H[Done]
    E --> F

Kết luận

Docker không "weird" vô cớ. Nó chỉ phơi bày những quy tắc của Linux mà bình thường chúng ta không bao giờ đụng tới. Checklist mình áp dụng cho mọi service:

  1. Chạy time docker stop cho mọi image trước khi lên production. Mất khoảng 10 giây hoặc exit code 137 nghĩa là có vấn đề.
  2. Luôn dùng exec form cho CMD/ENTRYPOINT, không bọc app bằng npm start trong production.
  3. Entrypoint script phải kết thúc bằng exec "$@".
  4. Dùng tini (--init, init: true hoặc cài trong image), đặc biệt khi app có spawn process con.
  5. Code phải xử lý SIGTERM: ngừng nhận request mới, drain, đóng tài nguyên, kèm force-exit timeout ngắn hơn grace period.
  6. Trên Kubernetes, thêm preStop sleep vài giây để tránh lỗi 502 lúc rolling update.

Chỉ mất khoảng 15 phút cho mỗi service, nhưng đổi lại deploy nhanh hơn, không còn 502 lẻ tẻ và không còn job bị xử lý hai lần. Đáng làm ngay tuần này.

Top comments (0)