Bỏ qua để đến nội dung
Knowledge Hub

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

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.

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.

Một Pod chuyển từ phục vụ sang drain trước khi bị dừng
Một Pod chuyển từ phục vụ sang drain trước khi bị dừngBước 1/4: Pod bắt đầu terminate; grace period bắt đầu và endpoint được đánh dấu không sẵn sàng.KubernetesAppIn-flight
Kubernetes · App
Bước 1/4

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 DB

Quy 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ỳ ý.

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 balancer

Hook 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ếp
CMD ["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.

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.

Chạy mô phỏng trạng thái (không có Pod hay signal thật):

Terminal window
npx --prefix examples ts-node examples/src/graceful-shutdown/shutdown-demo.ts

Output 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.

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.
  1. Dockerfile dùng exec-form (CMD ["node", ...]), không npm start hay shell-form.
  2. preStop chờ vài giây để endpoint được gỡ; terminationGracePeriodSeconds lớn hơn tổng preStop + drain + cleanup.
  3. Handler SIGTERM chỉ chạy một lần (process.once), có chốt chặn thời gian và không gọi process.exit() ngay.
  4. Readiness chuyển sang fail khi bắt đầu shutdown (health probes).
  5. Đóng theo thứ tự: HTTP → consumer/worker → pool → flush log/telemetry.
  6. Outbound call có timeout; thao tác ghi quan trọng có idempotency key (webhook & idempotency).
  7. Test rollout với request chậm và keep-alive connection ở staging, đo thời gian drain và số request bị cắt thành metric.

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?