為什麼需要 graceful shutdown

K8s 滾動部署(rolling update)的流程大概是這樣:

1. 部署新版本 pod
2. 新 pod ready 後,舊 pod 開始被終止
3. K8s 送 SIGTERM 給舊 pod
4. 等待 terminationGracePeriodSeconds(預設 30 秒)
5. 時間到了還活著 → 送 SIGKILL 強制殺掉

如果你的 Express server 沒有處理 SIGTERM,收到信號後就直接退出——所有正在處理中的 request 直接被切斷,客戶端收到 connection reset 或 502。

對服務發現說「這個 pod 要下線」和「讓 in-flight request 跑完再下線」之間,有一個關鍵的時間視窗。正確的 graceful shutdown 就是把這個視窗利用好。


bin/www.ts 的完整結構

Proto 的 bin/www.ts 做四件事:

#!/usr/bin/env node
import '../instrumentation'; // OTel 必須在所有 import 之前
 
import app from '../app';
import http from 'http';
import { setupWebSocket } from '../app/realtime/wsHandler';
import { initPubSub } from '../app/realtime/pubsub';
 
// 1. 建立 HTTP server
const port = normalizePort(process.env.PORT || '3000');
app.set('port', port);
const server = http.createServer(app);
 
// 2. 啟動 realtime(WebSocket + Redis Pub/Sub)
initPubSub();
setupWebSocket(server);
 
// 3. 開始監聽
server.listen(port);
server.on('error', onError);
server.on('listening', onListening);

注意最頂部的 import '../instrumentation'——OpenTelemetry SDK 必須在任何其他 module import 之前初始化,才能正確 patch Node.js 的 http、database 等 module。所以它是第一行。


app.ts vs bin/www.ts:為什麼要分開

前一篇提過,但這裡從測試角度再說一次:

// tests/integration/api/user.test.ts
import request from 'supertest';
import app from '../../../src/app'; // ← import 觸發 bootstrap,但不啟動 HTTP server
 
it('GET /api/v1/users 回 200', async () => {
  const res = await request(app).get('/api/v1/users');
  expect(res.status).toBe(200);
});

supertestrequest(app) 內部會自己建一個臨時 HTTP server、發請求、再關掉。整個過程不需要你的 server 在 listen,也不用開任何 port。

如果 app.ts 自己呼叫 server.listen(3000)

  • 每個 test file 被載入時都觸發一次 listen
  • Jest parallel 跑 test 時,多個 test file 會同時 listen 同一個 port → EADDRINUSE

把 listen 搬到 bin/www.ts,測試 import app.ts 不會觸發 listen,問題消失。


Graceful Shutdown 的完整流程

Proto 的 bin/www.ts 有一個沒有直接寫進 repo(但架構上預期你要加)的 graceful shutdown 模板。標準做法是:

// 優雅退出函式
const gracefulShutdown = async (signal: string) => {
  console.log(`\n${signal} received. Starting graceful shutdown...`);
 
  // Step 1:停止接受新請求
  server.close(async () => {
    console.log('HTTP server closed');
 
    try {
      // Step 2:等待進行中的 DB 操作完成,然後關閉 pool
      await sequelize.close();
      console.log('Database connection closed');
 
      // Step 3:等待 Redis pub/sub、ioredis client 關閉
      await redisClient.quit();
      console.log('Redis connection closed');
 
      // Step 4:等待 Bull queue 停止接新 job
      await notificationQueue.close();
      console.log('Queue closed');
 
      process.exit(0);
    } catch (err) {
      console.error('Error during shutdown:', err);
      process.exit(1);
    }
  });
 
  // 超時強制 exit(防止 close() callback 卡住)
  setTimeout(() => {
    console.error('Graceful shutdown timed out, forcing exit');
    process.exit(1);
  }, 25000); // K8s terminationGracePeriodSeconds 30 秒,這裡留 5 秒緩衝
};
 
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
process.on('SIGINT', () => gracefulShutdown('SIGINT'));

步驟順序的邏輯

  1. server.close() — 停止接受新的 TCP 連線,但已建立的連線還在
  2. server.close() callback 觸發(代表所有 in-flight request 都處理完了)
  3. 依序關閉 DB pool、Redis、Queue——這些都要在 HTTP layer 確認關掉後才動

如果順序反過來,先關 DB 再等 HTTP server close,in-flight 的 request 還在跑,但 DB 已經關了,那些請求就會拋 DB connection error。


SIGTERM vs SIGINT

Signal觸發場景行為
SIGTERMK8s 刪除 pod / kill <pid> / pm2 stop預設行為:終止進程
SIGINTCtrl+C / kill -2 <pid>預設行為:中斷進程

兩個都要處理。生產環境是 SIGTERM(K8s / PM2),本地開發是 SIGINT(Ctrl+C)。

PM2 的特殊行為:PM2 cluster mode 下,pm2 reload 送的是 SIGINT 而不是 SIGTERM(PM2 的設計決策,可以在 ecosystem.config.js 用 kill_timeout 調整)。所以兩個 signal 都掛 handler 比較保險。


normalizePort 的細節

function normalizePort(val: string): number | string | false {
  const portNum = parseInt(val, 10);
  if (isNaN(portNum)) return val;  // named pipe(e.g. '/tmp/app.sock')
  if (portNum >= 0) return portNum;
  return false;
}

PORT 環境變數可以是數字 port(3000)也可以是 Unix socket path(/tmp/express.sock)。normalizePort 處理這兩種情況——返回 string 代表 named pipe,返回 number 代表 port,返回 false 代表無效值。

大多數情況用不到 named pipe,但如果你在 nginx 前面搭 Unix socket 做 IPC,PORT=/tmp/app.sock 就能直接用這個設計。


OTel 為什麼要是第一行

import '../instrumentation'; // OTel — 必須在所有其他 import 之前
import app from '../app';   // ← 這之後才 import app

OpenTelemetry 的 auto-instrumentation 運作原理是 monkey-patching——它把 Node.js 的 httpmysqlredis 等 module 的方法替換成帶 tracing 的版本。

這個 patch 必須在那些 module 被第一次載入之前完成。如果 import app 先執行,app.ts import 了 database.ts,database.ts 內部已經 import { Sequelize } from 'sequelize',Sequelize 的 module 就已經載入了——OTel 再 patch 也來不及。

所以 import '../instrumentation' 永遠是第一行,沒有例外。


整個啟動流程的時序

node bin/www.ts
  │
  ├── import instrumentation    → OTel SDK 初始化,patch http/db
  ├── import app                → 觸發 app.ts → bootstrap(app)
  │     ├── configureMiddleware  (同步)
  │     ├── configureRoutes      (同步)
  │     ├── configureErrorHandlers (同步)
  │     ├── await initializeDatabase  (async,確認 DB 連線)
  │     ├── await initializeRedis     (async,確認 Redis)
  │     └── initializeWorkers    (同步,worker 背景啟動)
  │
  ├── http.createServer(app)
  ├── initPubSub()              → Redis Pub/Sub 初始化
  ├── setupWebSocket(server)    → WebSocket 掛在 HTTP server 上
  └── server.listen(port)       → 開始接受請求

node bin/www.tsserver.listen(port),整個流程是確定性的:每一步完成才進下一步,DB 連線確認後才開 HTTP port。


本系列文章

  • [[backend/framework/express/bootstrap-pattern|[express][M2] Bootstrap 模式與啟動序列]]
  • [express][M2-2] 啟動點與 Graceful Shutdown(本篇)
  • [[backend/framework/express/app-error-class|[express][M3] AppError 類別設計]]
  • [[backend/framework/express/error-handler|[express][M3-2] 全局錯誤處理層]]