0

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.
    1. Docker chờ hết grace period, mặc định là 10 giây.
    1. 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:
                                                                            
                                                                            ```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

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí