為什麼需要 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);
});supertest 的 request(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'));步驟順序的邏輯:
server.close()— 停止接受新的 TCP 連線,但已建立的連線還在- 等
server.close()callback 觸發(代表所有 in-flight request 都處理完了) - 依序關閉 DB pool、Redis、Queue——這些都要在 HTTP layer 確認關掉後才動
如果順序反過來,先關 DB 再等 HTTP server close,in-flight 的 request 還在跑,但 DB 已經關了,那些請求就會拋 DB connection error。
SIGTERM vs SIGINT
| Signal | 觸發場景 | 行為 |
|---|---|---|
SIGTERM | K8s 刪除 pod / kill <pid> / pm2 stop | 預設行為:終止進程 |
SIGINT | Ctrl+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 appOpenTelemetry 的 auto-instrumentation 運作原理是 monkey-patching——它把 Node.js 的 http、mysql、redis 等 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.ts 到 server.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] 全局錯誤處理層]]
