SIGTERM khi deploy: cho request đang chạy thời gian kết thúc Trung cấp
Thời gian đọc ước tính: 8 phút
Vấn đề
Phần tiêu đề “Vấn đề”Một bản deploy thay Pod API cũ bằng Pod mới. Pod cũ đang xử lý request tạo đơn: đã giữ kết nối database và đang chờ cổng thanh toán. Nếu process bị kill ngay:
- Client nhận kết nối bị ngắt, trong khi payment đã thành công ở downstream.
- Client retry không có idempotency key → nguy cơ thanh toán trùng.
Ngược lại, nếu Pod cũ nhận thêm request và chờ vô hạn, rollout bị kéo dài rồi vẫn bị kill cưỡng bức.
Graceful shutdown là: ngừng nhận việc mới → để việc đang chạy hoàn tất trong một thời hạn → đóng tài nguyên → thoát. Nó không bảo đảm mọi request xong (node chết, crash, hết deadline), nên idempotency ở tầng nghiệp vụ vẫn cần. Nhưng với deploy và scale-down bình thường, nó giảm hẳn số request bị cắt giữa chừng.
Khái niệm cốt lõi
Phần tiêu đề “Khái niệm cốt lõi”Khi Pod bị xoá, hai việc xảy ra song song, không đồng bộ:
| Luồng | Việc xảy ra | Ghi chú |
|---|---|---|
| Traffic | Endpoint của Pod trong EndpointSlice chuyển sang terminating, ready = false → kube-proxy/ingress ngừng gửi traffic mới |
Mất vài giây để lan; client có thể còn giữ kết nối cũ |
| Process | Chạy preStop (nếu có) → gửi SIGTERM → hết grace period thì SIGKILL |
Mặc định terminationGracePeriodSeconds = 30 |
Hệ quả quan trọng nhất: SIGTERM đến trước khi traffic ngừng hẳn. Nếu app đóng listener ngay khi nhận SIGTERM, các request vẫn đang được định tuyến tới Pod sẽ bị connection refused.
Pod bắt đầu terminate; grace period bắt đầu và endpoint được đánh dấu không sẵn sàng.
Ngân sách thời gian: mọi thứ trừ chung một grace period
Phần tiêu đề “Ngân sách thời gian: mọi thứ trừ chung một grace period”preStop không được cộng thêm; nó ăn vào cùng grace period với phần drain. Ví dụ ngân sách 30 giây:
0s 5s 25s 30s├───────────┼────────────────────────────┼──────────┤│ preStop │ SIGTERM → drain request │ cleanup │ SIGKILL│ sleep 5s │ server.close() chờ xong │ đóng pool│└───────────┴────────────────────────────┴──────────┘ traffic cũ ngừng nhận mới, chờ flush log, còn đang đến in-flight về 0 đóng DBQuy tắc: terminationGracePeriodSeconds ≥ preStop + thời gian drain tối đa + margin cleanup. Đo thời gian drain thật (p99 của request dài nhất), đừng đặt số tuỳ ý.
Cách cấu hình đúng
Phần tiêu đề “Cách cấu hình đúng”1. Kubernetes: chờ traffic lan xong rồi mới gửi SIGTERM
spec: terminationGracePeriodSeconds: 30 containers: - name: api lifecycle: preStop: exec: command: ["sleep", "5"] # chờ endpoint được gỡ khỏi load balancerHook sleep dạng exec chạy được khi image có sẵn lệnh sleep. Kubernetes còn có action sleep native (preStop: { sleep: { seconds: 5 } }, kubelet tự chờ, không cần binary trong image): alpha ở v1.29, beta bật mặc định từ v1.30, stable từ v1.34 (feature gate PodLifecycleSleepAction); kiểm tra phiên bản cluster đang dùng.
2. Container: để Node nhận được SIGTERM
# Đúng: exec-form, node là PID 1 và nhận SIGTERM trực tiếpCMD ["node", "dist/main.js"]
# Sai: shell-form hoặc npm start, signal có thể không tới node# CMD npm startĐây là nguyên nhân hay gặp nhất của “graceful shutdown không chạy”: process wrapper (sh -c, npm) không chuyển tiếp SIGTERM, app không bao giờ biết mình sắp bị dừng và bị SIGKILL sau 30 giây. Nếu cần init process để dọn zombie và forward signal, dùng tini hoặc docker run --init.
3. Ứng dụng Node: drain có chốt chặn
process.once('SIGTERM', () => { ready = false; // readiness trả 503 const hardStop = setTimeout(() => process.exit(1), 20_000); hardStop.unref(); // chốt chặn, không giữ process sống
server.close(async () => { // chờ in-flight xong await dbPool.end(); process.exitCode = 0; });});preStop đã lo phần chờ propagation, nên ở đây có thể đóng listener ngay. Nếu không dùng preStop, hãy chờ vài giây trước server.close(). Đoạn mã vẫn là phác thảo: còn thiếu đóng consumer/worker, xử lý lỗi của callback, và xử lý WebSocket (socket đã upgrade cần tự theo dõi và đóng). Với NestJS, dùng enableShutdownHooks() và lifecycle hooks, rồi kiểm chứng thứ tự đóng.
Drain trong Node: hai lệnh cần phân biệt
Phần tiêu đề “Drain trong Node: hai lệnh cần phân biệt”| Lệnh | Làm gì | Khi nào dùng |
|---|---|---|
server.close() |
Ngừng nhận connection mới, chờ request đang chạy xong. Từ Node 19 còn đóng idle connection (keep-alive) | Mặc định khi nhận SIGTERM |
server.closeAllConnections() |
Đóng cưỡng bức các connection, có thể cắt request đang chạy | Chỉ ở deadline cuối, sau close() |
server.close() chỉ đóng HTTP server. Database pool, broker consumer, worker, cron và socket dài hạn cần được dừng riêng, theo thứ tự: ngừng nhận job mới → hoàn tất hoặc nhả job đang giữ → flush log/telemetry → đóng connection. Với queue, ack trước khi ghi bền có thể mất job, ack sau có thể bị giao lại, nên handler vẫn phải idempotent.
Ví dụ thực tế
Phần tiêu đề “Ví dụ thực tế”Chạy mô phỏng trạng thái (không có Pod hay signal thật):
npx --prefix examples ts-node examples/src/graceful-shutdown/shutdown-demo.tsOutput kỳ vọng: request cũ started → shutdown báo ngừng nhận → request mới rejected → request cũ completed → drained. Giảm deadline xuống 5 ms để thấy deadline reached trước khi request cũ xong. Trong demo, deadline chỉ báo trạng thái chứ không kill tác vụ; Kubernetes thật sẽ gửi SIGKILL.
Trade-offs
Phần tiêu đề “Trade-offs”Lợi ích
- Giảm request bị cắt giữa chừng khi rollout hoặc scale-down bình thường.
- Có thời điểm rõ để dừng nhận việc, drain và đóng tài nguyên.
- Đo được thời gian drain để chọn grace period theo dữ liệu.
Giới hạn
- Grace period dài làm rollout chậm và giữ tài nguyên lâu hơn; quá ngắn thì cắt việc.
- Không bảo vệ trước crash, mất node hay kill cưỡng bức.
- HTTP, queue, WebSocket và database có lifecycle riêng; một handler SIGTERM không lo hết.
Checklist production
Phần tiêu đề “Checklist production”- Dockerfile dùng exec-form (
CMD ["node", ...]), khôngnpm starthay shell-form. preStopchờ vài giây để endpoint được gỡ;terminationGracePeriodSecondslớn hơn tổngpreStop+ drain + cleanup.- Handler SIGTERM chỉ chạy một lần (
process.once), có chốt chặn thời gian và không gọiprocess.exit()ngay. - Readiness chuyển sang fail khi bắt đầu shutdown (health probes).
- Đóng theo thứ tự: HTTP → consumer/worker → pool → flush log/telemetry.
- Outbound call có timeout; thao tác ghi quan trọng có idempotency key (webhook & idempotency).
- Test rollout với request chậm và keep-alive connection ở staging, đo
thời gian drainvàsố request bị cắtthành metric.
Sai lầm thường gặp
Phần tiêu đề “Sai lầm thường gặp”Tóm tắt
Phần tiêu đề “Tóm tắt”Tự kiểm tra: (1) preStop có thêm thời gian ngoài grace period không? (2) server.close() đã đóng database pool chưa? (3) Vì sao một request có thể vẫn đến sau khi Pod bắt đầu terminate?