Deploy bản mới lên VPS, log của load balancer hiện một loạt 502 trong đúng 3 giây. Mở ra coi thì lỗi không nằm ở code mới — nó nằm ở lúc process cũ bị tắt. Container nhận SIGTERM, Node thoát ngay lập tức, và mấy request đang chạy dở bị cắt ngang giữa đường.
Chuyện này mình gặp ở gần như mọi dự án Node: graceful shutdown hay bị xếp vào loại "để sau", tới khi production dạy cho bài học.
SIGTERM và SIGKILL khác nhau chỗ nào
Khi bạn bấm deploy, orchestrator (Docker, K8s, systemd) không tắt process ngay. Nó gửi SIGTERM trước — đây là tín hiệu đề nghị tắt, process muốn xử lý gì thì xử lý. Chờ hết thời gian grace (mặc định Docker là 10 giây, K8s là 30 giây) mà process vẫn chưa thoát, nó mới gửi SIGKILL — tín hiệu này không ai bắt được, process chết cứng.
Nói cách khác: SIGTERM là cơ hội cuối cùng. Không đăng ký handler thì Node dùng hành vi mặc định: thoát luôn, bỏ rơi mọi request đang bay.
Bốn việc phải làm khi nhận SIGTERM
Thứ tự quan trọng, làm sai thứ tự là vẫn rớt request:
- Ngừng nhận kết nối mới — gọi
server.close(), cổng vẫn mở nhưng không nhận connection mới nữa. - Đợi request đang chạy xong — không cắt giữa chừng, kể cả query DB còn đang chạy.
- Đóng socket keep-alive đang nằm không — đây là chỗ nhiều người quên, nói ở dưới.
- Đóng connection DB rồi thoát — trả connection về pool sạch sẽ, đừng để Postgres thấy client biến mất đột ngột.
Bản mình dùng cho một API Express, viết bằng TypeScript:
import http from "node:http";
import { app } from "./app";
import { pool } from "./db";
const PORT = Number(process.env.PORT ?? 3000);
const DRAIN_MS = 15_000;
const server = http.createServer(app);
// để load balancer lớn hơn ta một chút, tránh LB cắt trước
server.keepAliveTimeout = 65_000;
server.headersTimeout = 66_000;
let shuttingDown = false;
server.listen(PORT, () => console.log(`listening on ${PORT}`));
async function shutdown(signal: NodeJS.Signals): Promise<void> {
if (shuttingDown) return;
shuttingDown = true;
console.log(`[${signal}] ngừng nhận request mới, bắt đầu drain`);
// 1 + 2: đóng cổng và chờ request đang chạy xong
const drained = new Promise<void>((resolve) => server.close(() => resolve()));
// 3: đóng socket keep-alive đang idle (Node >= 18)
server.closeIdleConnections();
// hạn cứng, đừng để orchestrator SIGKILL mình trước
const force = setTimeout(() => {
console.warn("drain quá lâu, đóng nốt socket còn lại");
server.closeAllConnections();
}, DRAIN_MS);
await drained;
clearTimeout(force);
await pool.end(); // 4: trả connection về DB gọn gàng
process.exit(0);
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
Cái bẫy keep-alive làm shutdown treo
server.close() chỉ đợi request, không đợi connection. Với HTTP keep-alive, một connection có thể sống hàng chục giây sau khi request cuối trả xong, nằm chờ request kế tiếp. Kết quả: close() callback không bao giờ chạy, process treo tới lúc bị SIGKILL, và bạn tưởng graceful shutdown "không hoạt động".
Node 18.2 trở lên có closeIdleConnections() và closeAllConnections(), dùng là xong. Với bản cũ hơn thì phải tự đếm socket:
const sockets = new Set<import("node:net").Socket>();
server.on("connection", (s) => {
sockets.add(s);
s.on("close", () => sockets.delete(s));
});
function forceCloseSockets(): void {
for (const s of sockets) s.destroy();
sockets.clear();
}
Readiness probe — đừng để nó lừa orchestrator
Trong Kubernetes, chỉ bắt SIGTERM là chưa đủ. Endpoint còn phải báo "tôi sắp nghỉ" trước khi container thực sự ngừng. Cách mình làm: một biến shuttingDown, endpoint /health/ready trả 503 ngay khi cờ bật, còn /health/live vẫn 200 cho tới lúc thoát hẳn. Load balancer thấy 503 sẽ rút pod khỏi vòng xoay trước khi ta đóng cổng.
Thêm một chi tiết nhỏ nhưng cứu nhiều phen: terminationGracePeriodSeconds phải lớn hơn DRAIN_MS. Đặt drain 15 giây mà grace chỉ 10 giây thì K8s SIGKILL trước khi ta kịp làm gì.
Docker và systemd
Docker: docker stop mặc định chờ 10 giây rồi SIGKILL — nhớ tăng bằng docker stop -t 30, hoặc khai báo trong compose:
services:
api:
stop_grace_period: 30s
stop_signal: SIGTERM
systemd thì TimeoutStopSec=30 trong unit file, cùng logic.
Test lại cho chắc
Đừng tin là chạy được chỉ vì code compile. Mình mở hai terminal: một chạy hey hoặc k6 bắn liên tục vào một endpoint có await sleep(2000), terminal còn lại gửi kill -TERM <pid>. Đếm số request lỗi — trước khi sửa là vài chục, sau khi sửa phải là 0. Cách này mất 5 phút mà phát hiện được cả trường hợp request treo vô hạn làm drain không bao giờ xong.
Kết
Graceful shutdown chỉ khoảng 30 dòng code, nhưng nó là ranh giới giữa "deploy êm" và "deploy xong người dùng thấy lỗi". Mình xếp nó ngang hàng với health check — thứ mà mọi service backend nên có trước khi lên production, không phải sau.
Bạn đang để drain bao nhiêu giây, và có ai từng bị SIGKILL giữa lúc đang chạy migration chưa?
📋 Phụ lục thuật ngữ
| Thuật ngữ | Giải thích ngắn |
|---|---|
| SIGTERM | Tín hiệu đề nghị tắt, process bắt được và xử lý trước khi thoát |
| SIGKILL | Tín hiệu buộc tắt, không bắt được, process chết ngay |
| Graceful shutdown | Dừng service theo trình tự: ngừng nhận việc mới, làm xong việc đang dở, rồi thoát |
| Drain | Giai đoạn chờ các request đang chạy hoàn tất |
| Keep-alive | Giữ connection HTTP mở lại dùng nhiều lần thay vì mở mới mỗi request |
| Readiness probe | Endpoint cho orchestrator biết pod đã sẵn sàng nhận traffic hay chưa |
| terminationGracePeriodSeconds | Thời gian K8s chờ giữa SIGTERM và SIGKILL |
| stop_grace_period | Thời gian Docker Compose chờ trước khi SIGKILL container |
| Pool | Tập connection tái sử dụng, ví dụ tới Postgres, tránh mở mới liên tục |