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:
- Docker gửi
SIGTERM(hoặc signal khai báo trongSTOPSIGNAL) tới PID 1 của container. -
- Docker chờ hết grace period, mặc định là 10 giây.
-
- 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ì.
- Nếu container vẫn còn chạy, Docker gửi
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:
```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"]
```
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:
```bash
# 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
```
## 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:
```bash
#!/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 "$@"
```
```dockerfile
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"]
```
**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:
```javascript
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'));
```
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.
```mermaid
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
All rights reserved