Ngừng Restart Cục Súc: Hãy Cài Đặt Graceful Shutdown

Ngừng Restart Cục Súc: Hãy Cài Đặt Graceful Shutdown

Đừng để user gặp lỗi 502 mỗi khi bạn deploy code mới. Tìm hiểu cách cài đặt Graceful Shutdown cho Node.js để cập nhật hệ thống không gián đoạn.

Bạn đã bao giờ deploy một bản fix bug nhỏ xíu lên production, để rồi thấy log báo một loạt lỗi 502 Bad Gateway hoặc user than phiền vì bị văng khỏi hệ thống? Nếu ứng dụng của bạn có lượng traffic tương đối, việc khởi động lại (restart) server một cách cục súc là một thảm họa.

Khi Kubernetes, Docker, hay PM2 triển khai một phiên bản mới của ứng dụng, chúng không hề "nhẹ nhàng" yêu cầu container cũ nhường chỗ. Chúng gửi một tín hiệu SIGTERM. Nếu app của bạn không được lập trình để bắt tín hiệu này, nó sẽ bị "giết" ngay lập tức. Bất kỳ user nào đang có request dở dang—có thể là đang thanh toán hoặc upload file—sẽ bị ngắt kết nối đột ngột.

Đó hoàn toàn không phải là "zero-downtime deployment" (deploy không gián đoạn) như chúng ta hay lầm tưởng. Hôm nay, chúng ta sẽ giải quyết triệt để vấn đề kiến trúc này bằng Graceful Shutdown.

Giải Phẫu Một Pha Restart Cục Súc

Hãy nhìn vào một đoạn code Node.js (Express) ngây ngô điển hình:

import express from 'express';
import { connectDB } from './db';

const app = express();

app.get('/api/heavy-task', async (req, res) => {
  // Một task mô phỏng tốn 5 giây để xử lý
  await new Promise(resolve => setTimeout(resolve, 5000));
  res.send({ status: 'done' });
});

app.listen(3000, async () => {
  await connectDB();
  console.log('Server is running on port 3000');
});

Chuyện gì xảy ra khi bạn bấm nút deploy trong lúc có người đang gọi API /api/heavy-task?

  1. Hệ thống điều phối (orchestrator) gửi SIGTERM tới process Node.js.
  2. Hành vi mặc định của Node.js khi nhận SIGTERM là thoát (exit) ngay lập tức.
  3. Task 5 giây kia bị cắt ngang ở giây thứ 2 một cách bạo lực.
  4. Client nhận về một lỗi ngắt kết nối TCP phũ phàng (ECONNRESET).
  5. Nếu task đó đang ghi dữ liệu vào database mà không dùng transaction, bạn vừa tạo ra rác hoặc dữ liệu bị hỏng (corrupted data).

Chúng ta cần dạy cho ứng dụng cách cư xử đúng mực hơn.

Graceful Shutdown Là Gì?

Những chiếc ghế phòng chờ trống tượng trưng cho các kết nối HTTP nhàn rỗi

Graceful shutdown (tắt ứng dụng một cách duyên dáng) tuân theo một kịch bản lịch sự khi nhận được tín hiệu yêu cầu dừng:

  1. Ngừng nhận khách: Không chấp nhận thêm bất kỳ request mới nào từ Load Balancer nữa.
  2. Hoàn thành việc đang dở: Để các request hiện tại tiếp tục xử lý xong và trả response về cho client.
  3. Dọn dẹp tài nguyên: Đóng các connection pool tới database, ghi nốt log, và dọn các background job.
  4. Rời đi: Kết thúc process Node.js một cách an toàn (mã exit bằng 0).

Cài Đặt Graceful Shutdown Trong Node.js

Đồng hồ cát thể hiện thời gian đếm ngược của tiến trình shutdown

Đây là cách bạn thực sự đưa nó vào dự án thực tế. Chúng ta cần lắng nghe các event của process và quản lý instance của HTTP server một cách tường minh.

import express from 'express';
import { connectDB, closeDB } from './db';
import http from 'http';

const app = express();
// ... khai báo routes ở đây ...

const server = http.createServer(app);

server.listen(3000, async () => {
  await connectDB();
  console.log('Server is running on port 3000');
});

// Kịch bản Graceful Shutdown
const shutdown = async (signal: string) => {
  console.log(`Nhận được ${signal}. Bắt đầu graceful shutdown...`);

  // 1. Ngừng nhận request mới
  server.close(async (err) => {
    if (err) {
      console.error('Lỗi khi đóng server:', err);
      process.exit(1);
    }
    
    console.log('HTTP server đã đóng. Đang dọn dẹp tài nguyên...');

    try {
      // 2. Đóng kết nối database an toàn
      await closeDB();
      console.log('Đã đóng kết nối database.');
      
      // 3. Thoát process
      process.exit(0);
    } catch (e) {
      console.error('Lỗi trong quá trình dọn dẹp:', e);
      process.exit(1);
    }
  });

  // Ép buộc tắt nếu đợi quá lâu
  setTimeout(() => {
    console.error('Không thể đóng kết nối kịp thời, tiến hành force shutdown');
    process.exit(1);
  }, 10000); // Timeout 10 giây
};

// Lắng nghe các tín hiệu tắt process
process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT')); // Bắt sự kiện Ctrl+C khi dev ở local

Cái Bẫy "Keep-Alive"

Có một cái bẫy ẩn trong Express và Node.js từng làm đau đầu không biết bao nhiêu lập trình viên. Các kết nối HTTP Keep-Alive có thể giữ cho server của bạn luôn trong trạng thái mở, kể cả khi không có request HTTP nào đang thực sự được xử lý.

Trình duyệt và các Load Balancer (như Nginx hay AWS ALB) thường giữ một socket TCP mở sẵn sau khi request kết thúc, phòng trường hợp chúng cần gửi thêm request ngay sau đó. Vấn đề là hàm server.close() của Node.js sẽ đợi tất cả các kết nối TCP đóng lại thì mới coi là server đã tắt hoàn toàn. Nếu một client nhàn rỗi vẫn đang giữ cờ Keep-Alive, quá trình graceful shutdown của bạn sẽ bị treo cứng cho đến khi kết nối bị timeout.

Ở các bản Node.js cũ, chúng ta phải tự lưu các socket vào một Map và destroy chúng bằng tay. Rất may, Node.js hiện đại (v18.20+ và v20+) đã cung cấp sẵn phương pháp xử lý cực kỳ thanh lịch:

// Bên trong hàm shutdown của bạn:
server.closeIdleConnections();

server.close(async (err) => {
  // ... logic dọn dẹp ...
});

Khi gọi server.closeIdleConnections(), Node.js sẽ ngay lập tức cắt đứt các kết nối TCP đang rảnh rỗi, giúp cho server.close() có thể giải quyết dứt điểm ngay khi các request đang thực sự xử lý xong việc.

Timeout Đóng Vai Trò Sống Còn

Bạn có để ý đoạn setTimeout ở cuối kịch bản shutdown không? Khối code đó mang tính chất sống còn.

Đôi khi, một request từ client bị treo vô thời hạn, hoặc một câu query database bị deadlock. Nếu bạn cứ mù quáng đợi tất cả kết nối tự đóng, app của bạn có thể sẽ treo mãi mãi. Các hệ thống điều phối như Kubernetes không có sự kiên nhẫn vô cực. Nếu pod của bạn không tự tắt trong một khoảng thời gian nhất định (thường mặc định là 30 giây), K8s sẽ tung đòn sát thủ SIGKILL—một tín hiệu không thể bắt, không thể cản phá.

Bằng cách tự set một mức timeout nội bộ (ví dụ: 10 giây), bạn luôn nắm quyền kiểm soát cuộc chơi. Bạn có thể ghi log báo lỗi rằng quá trình shutdown đã quá hạn, giúp bạn dễ dàng debug sau này, thay vì để app biến mất không một dấu vết dưới bàn tay K8s.

Worker Và Background Jobs Thì Sao?

Nếu ứng dụng của bạn không chỉ là API server mà còn chạy các background job (sử dụng BullMQ, RabbitMQ...), bạn bắt buộc phải mang logic shutdown này áp dụng cho cả các worker.

Khi nhận SIGTERM, bạn phải báo cho worker ngừng nhận job mới từ queue. Tuy nhiên, nó vẫn phải xử lý cho xong job đang cầm trên tay trước khi ngắt kết nối với Redis hay RabbitMQ. Việc vứt bỏ một background job đang chạy dở cũng tai hại không kém gì việc chặt đứt HTTP request, thường dẫn đến tình trạng rác dữ liệu hoặc gửi email trùng lặp cho khách hàng.

Lời Kết

Chúng ta bỏ ra hàng giờ đồng hồ để cấu hình CI/CD pipelines, tinh chỉnh load balancers, và thiết lập rolling deployments với mong ước đạt tới cảnh giới zero-downtime thực sự. Nhưng nếu code ứng dụng của bạn vấp ngã ở bước cuối cùng—tắt app một cách sạch sẽ—thì user vẫn sẽ phải hứng chịu những lỗi rớt mạng ngẫu nhiên.

Thêm graceful shutdown chỉ tốn của bạn chưa tới hai mươi dòng code. Đổi lại, nó ngăn chặn hỏng hóc dữ liệu, xóa sổ các lỗi 502 vô duyên mỗi khi deploy, và biến ứng dụng của bạn thành một phần tử chững chạc, đáng tin cậy trong môi trường container hóa. Đừng bao giờ mang app lên production mà quên trang bị kỹ năng này.

NT

viết bởi

Nguyên Tech

0

Phản hồi

Đang tải bình luận…

Lattice.

Một không gian để viết dài, đọc chậm, và trò chuyện thật — không thuật toán, không quảng cáo.

© 2026 · Lattice · Đà Nẵng (16°03′ N, 108°12′ E) · v0.1 · system + ink + indigo