Node.js 优雅停机与健康检查实战

完整讲解 Node.js 优雅停机与健康检查:SIGTERM 与 SIGINT 信号处理、HTTP 服务器优雅关闭、在途请求排空与超时兜底、liveness 与 readiness 探针设计,以及和 Kubernetes 与 PM2 的配合。

发布时服务"瞬断"、扩缩容时请求被强杀、重启后流量黑洞……这些问题都源于进程没有体面地退出。优雅停机让进程收到信号后先停止接客、排空在途请求再退出;健康检查则让调度系统知道"什么时候该放流量进来"。本文覆盖信号处理、连接排空、探针设计与 K8s/PM2 配合。

1. 为什么需要优雅停机

1.1 强杀请求的代价

发布/重启时 K8s 或 PM2 发 SIGTERM
进程没处理 → 立即退出
在途请求被掐断 → 用户看到 502/重置
数据库写入半途而废 → 数据不一致

1.2 优雅停机的目标

1. 停止接收新请求
2. 排空已接收的在途请求
3. 释放外部资源(数据库连接、队列)
4. 兜底超时后强制退出

一句话:优雅停机 = 先停收新客、再排空在途、最后释放资源;没有它,每次发布都是一次小事故。


2. 信号处理

2.1 常见信号语义

信号来源默认行为
SIGTERMK8s/PM2 停机终止进程
SIGINTCtrl+C终止进程
SIGHUP终端挂断终止进程

Linux 停机信号首选 SIGTERM,进程应在此处理优雅退出;SIGKILL 无法被捕获,是兜底手段。

2.2 注册处理函数

function shutdown(reason) {
  console.log(`收到 ${reason},开始优雅停机`);
  // 此处安排:停新请求 → 排空 → 释放 → 退出
}

process.on('SIGTERM', () => shutdown('SIGTERM'));
process.on('SIGINT', () => shutdown('SIGINT'));

2.3 信号处理原则

只注册一次 → 避免多次触发重复清理
幂等处理   → 第二个信号直接 process.exit(1)
带原因日志 → 便于排查谁发的信号

一句话:SIGTERM/SIGINT 是优雅停机的入口信号;处理函数要幂等,收到重复信号直接退出,避免清理跑两遍。


3. HTTP 服务器优雅关闭

3.1 server.close 的语义

const server = http.createServer(handler);

process.on('SIGTERM', () => {
  server.close(() => {
    console.log('所有连接已关闭,退出');
    process.exit(0);
  });
});

server.listen(3000);

server.close() 停止接受新连接,等待已建立的连接自然结束后回调退出。这是优雅停机的主干。

3.2 keep-alive 连接拖死关闭

默认 keep-alive 的空闲连接会长期挂起,close() 永远等不到回调。要主动掐掉空闲连接:

const server = http.createServer(handler);
server.keepAliveTimeout = 5000;      // 空闲 5 秒断开
server.headersTimeout = 8000;

process.on('SIGTERM', () => {
  server.close(() => process.exit(0));
  // 强关空闲 keep-alive:遍历连接调用 end/destroy
  for (const socket of server.connections ?? []) {
    if (socket.idleTimeout === undefined) socket.end();
  }
});

3.3 两种退出路径

正常路径 → server.close() 等排空 → exit(0)
兜底路径 → 超时强制 destroy 连接 → exit(0)

一句话:server.close() 停收新请求并等连接自然结束;keep-alive 空闲连接必须主动 end/destroy,否则 close 回调永远不来。


4. 在途请求排空

4.1 连接状态追踪

只靠 server.close() 不够——活跃请求仍可能超时。用请求计数器精确排空:

let inflight = 0;
const server = http.createServer(async (req, res) => {
  inflight++;
  try {
    await handle(req, res);
  } finally {
    inflight--;
    if (isShuttingDown && inflight === 0) process.exit(0);
  }
});

4.2 停收新请求后响应

停机后新进来的请求应直接拒绝并给 503:

let isShuttingDown = false;
const server = http.createServer((req, res) => {
  if (isShuttingDown) {
    res.writeHead(503, { 'Retry-After': '5' });
    res.end('正在停机,请稍后重试');
    return;
  }
  handle(req, res);
});

process.on('SIGTERM', () => {
  isShuttingDown = true;
  const timer = setTimeout(() => {
    console.error('排空超时,强制退出');
    process.exit(1);
  }, 30 * 1000);
  timer.unref();           // 定时器不阻塞进程退出
  server.close(() => process.exit(0));
});

4.3 资源释放清单

数据库连接池   → 关闭空闲连接
消息队列消费者 → 停止消费
定时任务       → clearInterval
WebSocket 连接 → 通知后关闭

一句话:排空 = 请求计数 + 停机后新请求回 503 + 超时强制退出;数据库、队列、定时器这些外部资源在退出前统一释放。


5. 健康检查端点

5.1 liveness 与 readiness

liveness(存活)   → 进程活着就行,挂了重启
readiness(就绪)  → 依赖就绪、能接流量,没就绪不发流量

5.2 实现两个端点

// 只检查进程存活
app.get('/healthz', (req, res) => {
  res.json({ status: 'ok', pid: process.pid });
});

// 检查关键依赖(DB、Redis、队列)
app.get('/readyz', async (req, res) => {
  try {
    await db.ping();
    await redis.ping();
    res.json({ status: 'ready' });
  } catch (err) {
    res.status(503).json({ status: 'not-ready', reason: err.message });
  }
});

5.3 就绪态配合优雅停机

// 停机时 readiness 立即返回 503,调度系统先摘流量
let isShuttingDown = false;
app.get('/readyz', (req, res) => {
  if (isShuttingDown) return res.status(503).json({ status: 'stopping' });
  res.json({ status: 'ready' });
});

一句话:liveness 看存活、readiness 看就绪;停机瞬间 readiness 先转 503,让调度系统把流量摘走,再执行排空。


6. 与 Kubernetes 配合

6.1 探针配置

apiVersion: apps/v1
kind: Deployment
spec:
  template:
    spec:
      containers:
        - name: app
          livenessProbe:
            httpGet: { path: /healthz, port: 3000 }
            initialDelaySeconds: 10
            periodSeconds: 10
          readinessProbe:
            httpGet: { path: /readyz, port: 3000 }
            initialDelaySeconds: 5
            periodSeconds: 5
          terminationGracePeriodSeconds: 30

terminationGracePeriodSeconds 要大于业务排空时间,否则 K8s 等不及直接 SIGKILL。

6.2 停止流程全貌

1. K8s 更新 Pod 状态为 Terminating
2. readiness 探针失败 → 从 Service 摘流量
3. K8s 发 SIGTERM
4. 进程优雅排空
5. 超过 terminationGracePeriodSeconds → SIGKILL

6.3 preStop 钩子

需要"先做点事再停"时(如通知注册中心下线),加 preStop:

lifecycle:
  preStop:
    exec:
      command: ["/bin/sh", "-c", "sleep 5"]

一句话:K8s 配合 = liveness/readiness 探针 + terminationGracePeriodSeconds 大于排空时间;停机流程是「探针失败摘流量 → SIGTERM 排空 → 超时 SIGKILL」。


7. 与 PM2 配合

7.1 kill_timeout 配置

PM2 默认等进程退出也有时间窗,用 kill_timeout 放大排空时间:

module.exports = {
  apps: [{
    name: 'web-api',
    script: './dist/index.js',
    instances: 2,
    kill_timeout: 15000,   // 最多等 15 秒优雅退出
    listen_timeout: 3000,  // 等待新进程监听就绪
  }],
};

7.2 wait_ready 模式

进程启动后依赖就绪再发 ready,避免流量黑洞:

// 依赖初始化完成后
process.send('ready');   // 告诉 PM2:我准备好了

7.3 PM2 停机触发链

pm2 reload → 对旧实例发 SIGINT/SIGTERM
进程监听到信号 → 排空 → 退出
新实例启动 → 依赖就绪 → 发 ready → 接流量

一句话:PM2 配合 = kill_timeout 给足排空时间 + wait_ready 就绪再放流量;配合业务侧 SIGINT 监听实现零停机 reload。


8. 踩坑清单

坑现象对策
不监听信号请求被强杀注册 SIGTERM/SIGINT
keep-alive 拖着close 回调不来主动 destroy 空闲连接
停机后还收新请求刚排空又进新流量停机标识 + 回 503
排空无超时永远卡死等退出超时强制 exit(1)
termination 太短K8s 提前 SIGKILL调大 terminationGracePeriodSeconds
readiness 不摘流量停机瞬间仍被打停机时 readyz 转 503
依赖未就绪就监听流量黑洞wait_ready + listen_timeout
资源不释放连接泄漏数据库/队列/定时器统一清理

9. 总结

环节要点
目标停新请求 → 排空 → 释放 → 退出
信号SIGTERM/SIGINT 入口,幂等处理
关闭server.close + keep-alive 主动掐断
排空请求计数 + 503 拒新 + 超时兜底
健康检查liveness 存活 / readiness 就绪
K8s探针 + terminationGracePeriodSeconds
PM2kill_timeout + wait_ready
兜底SIGKILL 是最后手段,能排空尽量排空

一句话记住:优雅停机是"让进程体面退场"的工程——信号来了先摘流量、排空在途、释放资源,超时兜底强退;配合 liveness/readiness 探针,K8s 与 PM2 就知道何时放流量、何时给时间。做好这两件事,发布从"事故"变成"无感"。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「nodejs」更多文章

  1. Node.js 内存泄漏诊断实战
  2. BullMQ 后台任务队列实战
  3. Node.js LLM 集成实战:OpenAI 与 Anthropic