收到 SIGTERM 后,服务应当先停止报告就绪,再去完成进行中的请求。这样在实例排空期间,负载均衡器就能把流量转到其他地方。
关闭前如何排空连接?
按以下顺序处理:
- 设置一个全局的排空状态。
- 让就绪检查(readiness)路由返回未就绪的状态码。
- 拒绝新的业务请求。
- 让之前已经开始的请求处理完。
- 关闭监听器和共享资源。
- 在平台的截止时间之前退出。
就绪状态的变化并不会立刻在所有地方生效。负载均衡器可能每隔几秒才探测一次,endpoint 更新也需要时间传播。服务必须处理好这段延迟内到达的请求。
选择排空方式
| 方式 | 收到信号后的就绪检查 | 收到信号后的新请求 | 适用场景 |
|---|---|---|---|
| 立即关闭监听器 | 连接被拒绝 | 连接被拒绝 | 客户端会重试,且就绪状态没有传播延迟 |
继续监听,并短暂返回 503 | 503 | 503 | 负载均衡器需要时间才能感知就绪状态的变化 |
两种方式都能通过 shutdown-check。返回 503 能给客户端更明确的响应,也给流量系统留出一小段时间来更新。
编写能排空的服务器
const http = require("node:http");
const port = Number(process.env.PORT ?? 3000);
const drainDelayMs = Number(process.env.DRAIN_DELAY_MS ?? 1000);
let draining = false;
const server = http.createServer((request, response) => {
if (request.url === "/health") {
response.writeHead(draining ? 503 : 200, {
"content-type": "text/plain",
});
response.end(draining ? "draining" : "ok");
return;
}
if (draining) {
response.writeHead(503, {
connection: "close",
"retry-after": "1",
});
response.end("shutting down");
return;
}
if (request.url === "/slow") {
response.writeHead(200, { "content-type": "text/plain" });
response.flushHeaders();
setTimeout(() => response.end("work complete\n"), 2000);
return;
}
if (request.url === "/test/new-work") {
response.writeHead(200).end("accepted");
return;
}
response.writeHead(404).end();
});
server.listen(port, "127.0.0.1");
process.on("SIGTERM", () => {
if (draining) return;
draining = true;
setTimeout(() => {
server.close((error) => {
if (error) {
console.error(error);
process.exitCode = 1;
}
});
}, drainDelayMs);
});已经进入处理函数的请求不会再检查 draining,所以会正常完成。新请求会经过排空判断,收到 503。
这段延迟让监听器保持可用,好让流量系统感知到就绪状态的变化。延迟结束后,server.close() 停止接受连接,并等待原有的工作完成。
配置排空检查
{
"command": ["node", "server.js"],
"baseUrl": "http://127.0.0.1:3000",
"readiness": { "path": "/health", "status": 200 },
"workload": {
"path": "/slow",
"bodyIncludes": "work complete",
"concurrent": 2,
"started": { "type": "response-headers" }
},
"shutdown": {
"deadlineMs": 10000,
"readinessWithdrawal": true,
"newRequests": {
"path": "/test/new-work",
"rejectStatuses": [503],
"allowConnectionRefused": true
},
"repeatSignalAfterMs": 500
}
}readinessWithdrawal 检查什么
发送 SIGTERM 后,shutdown-check 会轮询平常使用的就绪检查路由。只要看到以下任一情况,这项检查就通过:
- 状态码不等于
readiness.status - 连接被拒绝或被重置
请求超时不算,因为超时并不能明确告诉负载均衡器服务已不可用。
如果直到截止时间,就绪检查仍然返回就绪,shutdown-check 会返回 SC310。
newRequests 检查什么
撤回就绪状态后,shutdown-check 会在原有测试请求仍在进行时,向 newRequests.path 发送一个 GET 请求。
这个新请求必须满足以下任一条件:
- 返回的状态码在
rejectStatuses中 allowConnectionRefused为true时,连接被拒绝
如果超时、返回 200 或其他不在列表中的状态码,结果是 SC311。如果还没来得及测试新请求,原有的工作就已经结束,结果是 SC312。
请选择一个安全的 GET 路由。shutdown-check 会真实调用它,所以它不能创建订单、发送邮件或修改生产数据。
看懂通过时的时间线
PASS SC000: Graceful shutdown verified
Timeline:
+ 5 ms process launched — pid=73882
+ 112 ms service ready — HTTP 200
+ 112 ms work request sent — #1 GET /slow
+ 112 ms work request sent — #2 GET /slow
+ 113 ms work confirmed active — 2 response(s) sent headers; bodies still in progress
+ 113 ms signal sent — SIGTERM
+ 114 ms readiness withdrawn — HTTP 503
+ 115 ms new request rejected — HTTP 503
+ 615 ms signal repeated — SIGTERM
+ 2115 ms work request finished — #1 HTTP 200
+ 2115 ms work request finished — #2 HTTP 200
+ 2118 ms process exited — code=0, signal=none
+ 2119 ms shutdown verified — work completed and service exited before deadline关键在于顺序:撤回就绪状态和拒绝新请求,都发生在原有工作完成之前。
选择排空延迟
先用负载均衡器的就绪探测间隔乘以它的失败阈值作为起点。如果涉及 endpoint 传播,再加上传播所需的时间。
总时间仍然必须满足:
排空延迟 + 最慢的进行中请求 + 清理 < 平台的关闭截止时间shutdown.deadlineMs 要覆盖服务端这部分的耗时,并留出安全余量。在 Kubernetes 上,别忘了 preStop 的时间也包含在 terminationGracePeriodSeconds 之内。
安全处理重复的 SIGTERM
信号处理函数应当是幂等的。第一个信号启动排空;之后的信号不应重启定时器、重复执行清理,也不应强制中断进行中的工作。
示例中的 if (draining) return 判断就是用来处理这种情况的。启用 repeatSignalAfterMs 可以测试这一行为。
常见失败
| 失败现象 | 结果 | 修复方法 |
|---|---|---|
就绪检查一直返回 200 | SC310 | 把切换就绪状态作为信号处理的第一步 |
新请求返回 200 | SC311 | 在业务路由之前加上排空判断 |
| 新请求一直挂起 | SC311 | 返回明确的拒绝状态码 |
| 还没测试拒绝,旧的工作就已结束 | SC312 | 使用耗时更长且可控的工作,或更早撤回就绪状态 |
旧请求收到 503 | SC202 | 只对排空开始后才进入的请求应用排空判断 |
| 进程一直存活 | SC300 | HTTP 工作排空后,关闭定时器和共享资源 |