
最初在本机搭了个最小的 http 服务,docker stop 之后它总是要 7 秒才退出,日志里看不到 server.close() 的回调打印。这个 7 秒稳定复现,不是某一次运气不好。最后测下来,卡住的是 http server 自己。
现象:进程不走,也不给任何原因
能看到的只有进程迟迟不退,服务自己一声不响,没有任何输出说明哪一步没走完。它不是偶发,同一套退出流程每次都这样。
影响面:每次发布都多等,还压在 docker 的等待上限上
docker 那边的等待上限是 10 秒(这个默认值取自 docker stop --timeout 的帮助信息,没在本机跑容器实测),7 秒离这条线只剩 3 秒,卡在这个边缘上很难受。
放到真实部署里,受影响的是每一次发布:每个实例都要多耗这 7 秒,滚动发布越滚越慢。超过 10 秒之后 docker 具体怎么收场,本机没有实测过,所以那 3 秒余量到底安不安全,当时也答不上来。
定位:从连接池一路排到 http server
第一个怀疑的对象是连接池,把它整个关掉再跑一遍,还是 7 秒。这段弯路花掉的时间比后面定位到 http server 本身还长。
把默认参数打出来
import http from "node:http";
const d = http.createServer();
console.log("keepAliveTimeout 默认 =", d.keepAliveTimeout, "ms");
console.log("headersTimeout 默认 =", d.headersTimeout, "ms");
console.log("requestTimeout 默认 =", d.requestTimeout, "ms");
console.log("timeout 默认 =", d.timeout, "ms");
console.log("closeIdleConnections / closeAllConnections:", typeof d.closeIdleConnections, "/", typeof d.closeAllConnections);
Node v26.8.1 上的输出:
keepAliveTimeout 默认 = 5000 ms
headersTimeout 默认 = 60000 ms
requestTimeout 默认 = 300000 ms
timeout 默认 = 0 ms
closeIdleConnections / closeAllConnections: function / function
close() 收掉的是「此刻空闲」的连接
第一组实验:客户端用 keep-alive agent 发一个请求,请求做完之后连接挂着等复用,server.getConnections() 报 1。这时调 server.close():
请求结束后存活连接数 = 1
close() 回调: 触发了,0ms
server.listening = false
对已 close 的端口发请求 → ECONNREFUSED
回调立刻触发。这一条跟以前的理解相反,原以为 close() 会等所有 keep-alive 连接自己超时,所以又连测了两遍,结果一样:当前版本的 Node 会把调用那一刻空闲的连接一起收掉。
在途请求跑完之后,那条连接没人管了
第二组换成 600ms 的慢接口,在请求进行中调 close():
在途时存活连接数 = 1
close() 后 150ms(请求还没结束): 回调没触发
请求在第 602 ms 正常完成(响应没被砍)
请求完成后连接数 = 1,回调: 依然没触发
回调最终在第 6605 ms 触发
在途请求能正常跑完,这点没让人失望。但它跑完之后连接变成空闲,已经没人再来收它了,一直挂到服务端的 keepAliveTimeout(默认 5 秒)到点才被断开,回调这才触发。为什么是 6605 而不是 602 加 5000,多出来那一秒没找到对应的计时器,就不编解释了。
docker stop 时正赶上有个请求在跑,这个 7 秒就是这么堆出来的:请求本身耗时加上后面这 5 秒左右,正好凑出来。
改了什么
第一版:请求跑完之后踢掉空闲连接
server.closeIdleConnections() 就是为这种情况准备的:
请求完成、还没踢连接时: 回调没触发
紧接着 closeIdleConnections() → 第 606ms 触发
那在途期间调它会不会误杀?
在途时调 closeIdleConnections(),连接数 = 1(在途连接没被踢)
在途请求结果: 正常返回
它只收拾空闲的,在途的不动。麻烦在于得知道在途请求什么时候跑完,服务里如果没有请求计数,就得靠下面那个写法。
第二版:宽限期加硬断
子进程里跑了五种 SIGTERM 场景,接口耗时 1.2 秒,信号在请求发出 250ms 后到达:
plain 宽限0ms 在途: SIGTERM 后 6966ms 退出 / 客户端拿到: HTTP 200 ok
idle 宽限0ms 在途: SIGTERM 后 6967ms 退出 / 客户端拿到: HTTP 200 ok
grace 宽限2000ms 在途: SIGTERM 后 2012ms 退出 / 客户端拿到: HTTP 200 ok
force 宽限0ms 在途: SIGTERM 后 955ms 退出 / 客户端拿到: 客户端出错: ECONNRESET
plain 宽限0ms 已空闲: SIGTERM 后 2ms 退出
plain 只调 close(),也就是项目里最常见的写法。idle 是信号到达时先调一次 closeIdleConnections(),没用,那一刻连接正在处理请求,不算空闲。force 是立刻 closeAllConnections(),进程退得最快,代价是在途请求的客户端收到 ECONNRESET。grace 是先 close(),起一个 2 秒的宽限计时器,到点再 closeAllConnections(),响应完整送达,进程 2 秒退干净。
import http from "node:http";
async function gracefulClose(srv, { grace = 1500 } = {}) {
await new Promise((resolve) => {
srv.close(resolve); // 停止接收新连接,此刻空闲的连接当场收掉
const timer = setTimeout(() => srv.closeAllConnections(), grace); // 宽限期到,还没走的连接硬断
timer.unref();
});
}
const server = http.createServer((req, res) => res.end("ok"));
server.listen(3000);
process.on("SIGTERM", async () => {
await gracefulClose(server);
process.exit(0);
});
把它原样丢进子进程,又测了两种情形:
在途 / 有 unref: SIGTERM 后 1512ms 退出 / 客户端: HTTP 200 ok
空闲 / 有 unref: SIGTERM 后 10ms 退出 / 客户端: HTTP 200 ok
空闲 / 没有 unref: SIGTERM 后 1513ms 退出 / 客户端: HTTP 200 ok
第三行是把 timer.unref() 注释掉之后的对照组。空闲连接已经被 close() 收掉、进程本来可以立刻退出的场景下,一个没 unref 的计时器会把进程再拖满整个宽限期。这两行的差别就是这一行代码的作用。
宽限期要大于最慢的那个接口,否则那个接口的客户端就会遇到 force 那一行的情况:进程退得干净,请求被砍在半路。现在 grace 设 5 秒,配上 docker stop 的 10 秒默认等待,余量够用。
宽限期这个数是怎么定的
这个值没算出公式来,是按服务本身的耗时分布定的。接口响应时间按天统计了一遍,P99 大概 1.2 秒,最慢的几个批量导出接口在 4 秒上下,所以宽限期给了 5 秒,比最慢的那几个还多一点。如果导出接口偶尔要跑 20 秒,那 5 秒的宽限期就意味着这类请求会被硬断,客户端拿到 ECONNRESET 之后一般会重试,但用户那边会看到一次失败。
另一种做法是不设硬断,停止接收新请求之后一直等在途请求自然结束。遇上卡住的请求,进程可能永远退不掉,最后还得靠 kill -9 兜底,退出时间就变成不可预期的了。我选了可控的那个:给一个明确的上限。
防复发
要在项目里落地的话,结论就三条:退出流程里只写 server.close() 不够,在途请求处理完的那条连接会白等 5 秒;用 close() 加宽限计时器这一组就够,不需要轮询连接数;宽限期按最慢接口的耗时来设,别拍一个 0。
还有一点顺手确认过:close() 之后 server.listening 是 false,新连接直接 ECONNREFUSED,不会出现「决定下线了还在接新请求」的情况,前提是负载均衡不再往这个实例派新请求。
上面所有客户端用的都是 Node 自己的 http.Agent。Nginx 或者云负载均衡那一层的 keep-alive 没有测,只有推理:进程等的是 Node 这边的连接收干,外面那层是否还挂着空闲连接,不影响这个结论。这段是推理,不是实测,遇到反例以实测为准。