我模拟了一次发版:一个 8 秒的任务跑到第 2 秒,服务端收到 SIGTERM。
然后我盯着客户端,等它告诉我出事了。
它等了 58 秒,报了一句"请求超时"。
那一刻我意识到,服务端早就没了,客户端却还在等。等到它自己的超时用尽,才给出一个"超时"------而这个错误会让人以为"服务只是慢",于是重试,于是又打到另一个正在被杀的实例。
这篇把发版那一刻的事从头跑了一遍:三种关闭方式,同一个 8 秒的任务,看客户端到底收到什么。
结论先放这儿:server.close() 不是"等我一下",是"等连接关";而客户端分不清"服务死了"和"服务慢了"。
先搭一个能观察的靶子
一个 Express + Streamable HTTP 的 MCP Server,一个工具:slow_task,睡 N 毫秒后返回。它内部维护一个 inflight 计数,用来知道"现在还有几个任务在跑"。
javascript
server.registerTool("slow_task", {
title: "耗时任务",
inputSchema: {
durationMs: z.number().min(0).max(60000),
label: z.string().default("task"),
},
}, async ({ durationMs, label }) => {
inflight++;
log(`开始 ${label} 当前并发=${inflight}`);
const started = Date.now();
try {
await new Promise((r) => setTimeout(r, durationMs));
const text = `${label} 完成,耗时 ${Date.now() - started}ms`;
return { content: [{ type: "text", text }] };
} finally {
inflight--;
}
});
服务端用环境变量切换三种关闭策略:
ini
SHUTDOWN=none 什么都不做,靠 Node 默认行为
SHUTDOWN=close-only 只调 httpServer.close()
SHUTDOWN=graceful 摘流量 + 等任务 + 主动断连
客户端是用脚本拉起来的:连接 → 发起一个 8 秒的任务 → 第 2 秒给服务端发 SIGTERM → 看客户端拿到什么、多久拿到。
第一种:什么都不做
这是最常见的写法,因为默认就跑成这样------你没注册 SIGTERM 处理器,Node 收到信号直接退出。
ini
[ 5781ms] slow_task 开始 label=任务1 duration=8000ms 当前并发=1
[ 5710ms] >>> 给服务端发 SIGTERM(此时任务还剩约 6000ms)
[ 5709ms] 服务端进程退出: code=143
进程秒退,任务没了。那客户端呢?
ini
[15711] 任务1 抛错 12003ms: code=-32001 name=McpError
msg=MCP error -32001: Request timed out(距 SIGTERM 约 10002ms)
注意两件事。
第一,它没有立刻报错。服务端在 5.7 秒就死了,客户端到 15.7 秒才拿到异常,中间整整 10 秒,它以为请求还在路上。
第二,报的是 -32001 Request timed out,不是"连接断开" 。连接早就断了,但它说的是"超时"。
我把客户端的超时去掉再跑一次,这次它撑了整整一分钟:
ini
[63508] 任务1 抛错 60001ms: code=-32001
msg=MCP error -32001: Request timed out(距 SIGTERM 约 58000ms)
服务端在第 2 秒退出。客户端为这条已经死掉的连接,白等了 58 秒。
为什么"超时"比"连接断开"糟糕得多
这不是措辞问题,是两个完全不同的信号。
-32001 超时 的含义是:请求发出去了,服务端可能还在处理,只是慢。按这个理解,正确的动作是"重试"或者"再等等"。
连接断开 的含义是:这条链路没了。正确的动作是"换一个实例"。
客户端拿到的信息指向了前者,于是它会重试。而这时候有两种情况:
- 如果原实例还在跑(
close-only那种),你就在另一台机器上又执行了一遍,两条重复记录------这就是上一篇文章里幂等键要解决的问题 - 如果原实例已经死了,重试打到一个还没被摘掉的 Pod,继续超时
我在 10 个并发下重跑了一遍,没有例外:
ini
===== 结果(模式 none,并发 10)=====
成功 0 / 失败 10
任务1 抛错: code=-32001 ... Request timed out(距 SIGTERM 约 10001ms)
...
任务10 抛错: code=-32001 ... Request timed out(距 SIGTERM 约 10006ms)
十个任务,十条"超时",没有一条"连接断开"。
顺带说一句,这个现象不是 SIGTERM 特有的。我后面用服务端主动 closeAllConnections() 断连,客户端报的还是 -32001 超时。底层大概是 fetch 的响应流错误没有冒泡到请求的 promise 上------这一条是我的推测,没有验证到底层,但现象很稳定,你可以自己复现。
第二种:只调 server.close()
知道了问题,第一反应是加个信号处理:
dart
process.on("SIGTERM", () => {
httpServer.close(() => log("http server 已关闭"));
});
结果比想象中好,也比你想象中糟。
ini
[ 2212ms] 收到 SIGTERM,开始退出流程
[ 2213ms] 已调用 server.close(),但不等在跑的任务
[ 8183ms] slow_task 完成 label=任务1 实际耗时=8002ms
[11724] 任务1 成功 8007ms: 任务1 完成,耗时 8002ms
服务端进程退出: (3 秒内未退出)
任务保住了。 因为 server.close() 只是停止接受新连接,已经进来的请求照常处理完。
但注意最后一行:进程没有退出。而且日志里从头到尾没有出现"http server 已关闭"------那个回调根本没被调用。
为什么?因为 close() 的回调要等到所有连接都关闭才触发。客户端还连着,连接就不会关。
我单独做了个更干净的实验:客户端只连接、不调用任何工具,然后发 SIGTERM。
ini
[ 4641ms] 收到 SIGTERM,开始退出流程 mode=close-only
[ 4642ms] 已调用 server.close(),但不等在跑的任务,也不主动断开连接
--- 6 秒后 ---
还活着
--- 又等了 14 秒,客户端主动断开 ---
[22766ms] http server 已关闭(close 回调触发)
已退出
当时一个任务都没有在跑,进程还是赖了 18 秒,只因为客户端保持着一条空闲连接。
这就是 close() 的真实语义:它不等"你的任务",它等"别人的连接"。退出时机完全由客户端决定,不在你手里。
在 Kubernetes 里,后果很具体:Pod 一直是 Terminating,terminationGracePeriodSeconds 默认 30 秒走完,SIGKILL。你以为的"优雅"其实只是"被拖到被强杀"。
第三种:完整版
一版能用的关闭流程,四步,顺序不能错:
scss
async function shutdown() {
draining = true; // 1. healthz 立刻转 503
await sleep(DRAIN_DELAY_MS); // 2. 留时间让负载均衡把你摘掉
httpServer.close(); // 3. 停止监听(不停已有的)
while (inflight > 0 && !timeout) { // 4. 等任务跑完,但有上限
await sleep(100);
}
httpServer.closeAllConnections(); // 5. 主动断开所有连接
for (const { transport } of sessions.values()) {
await transport.close(); // 6. 逐个收掉 MCP 会话
}
process.exit(0);
}
同一个场景(8 秒任务,第 2 秒发 SIGTERM):
ini
[ 2251ms] 收到 SIGTERM,开始退出流程 mode=graceful
[ 8226ms] slow_task 完成 label=任务1 实际耗时=7999ms
[ 8226ms] inflight 归零
[ 8319ms] 等待结束 inflight=0 等待用时=6067ms
[ 8320ms] closeAllConnections 已调用
[ 8320ms] 退出
服务端进程退出: code=0 signal=null
任务成功,进程正常退出,等了 6 秒。
10 个并发也一样:
ini
成功 10 / 失败 0
成功耗时: 8006ms, 8007ms, 8008ms ... 8012ms(十个都在 8 秒上下)
等待结束 inflight=0 等待用时=6067ms
服务端进程退出: code=0
对比一下三种:
| 策略 | 10 并发成功 | 进程退出 | 代价 |
|---|---|---|---|
| 什么都不做 | 0 / 10 | 立刻,code 143 | 任务静默丢失 |
只 close() |
10 / 10 | 由客户端决定,实测拖 18 秒 | 等到被 SIGKILL |
| 完整版 | 10 / 10 | 6.1 秒,code 0 | 慢 6 秒 |
inflight 不能靠手写
上面那个计数是在 handler 里手动 inflight++ / inflight--。这写法有个隐患:新加一个工具忘了写,它就不在等待范围里。
漏掉的那个工具,在发版时会被 closeAllConnections() 直接切断------而其他工具都好好的,你根本不会想到去查它。
与其每个 handler 记得写,不如在注册时统一包一层:
typescript
function withInflightTracking(server: McpServer) {
const raw = server.registerTool.bind(server);
// 覆写注册方法,让每个工具都被自动计数
(server as any).registerTool = (name: string, config: any, handler: any) =>
raw(name, config, async (...args: any[]) => {
inflight++;
try {
return await handler(...args);
} finally {
inflight--;
}
});
return server;
}
之后所有 server.registerTool(...) 自动被计数,新增工具不用改关闭逻辑。我用一个同步工具和一个 200ms 的慢工具验过:调用中 inflight 是 1,结束后回到 0。
一个细节:计数要在 handler 一开始就加 ,不能等 await 之后。中间有 await 的话,事件循环会让出,那个极短的窗口里发版就会漏掉这个任务。
但完整版也有个坑,藏得很深
我第一次跑完整版时,故意让 inflight = 0(没有任务在跑)就发 SIGTERM,看它多久退出:
ini
[ 1598ms] 收到 SIGTERM,开始退出流程 mode=graceful
[ 1598ms] 等待结束 inflight=0 等待用时=0ms
[ 1598ms] closeAllConnections 已调用
[ 1598ms] 退出
0 毫秒。 SIGTERM 到进程消失,中间什么都不剩。
那一刻我明白了上面为什么 curl 拿不到 503:draining = true 确实把 healthz 改成了 503,但没有任何人能查到它------进程在同一个 tick 里就没了。
shell
$ curl -i http://localhost:3016/healthz
curl: (7) Failed to connect to localhost port 3016
这就是优雅关闭最容易被忽略的一步:close() 之前必须先"站一会儿"。
负载均衡不是实时感知的,它靠探针,探针有周期(常见 5~10 秒)。你 healthz 转 503 的瞬间,探针可能刚查过、下一次要等 8 秒。这 8 秒里新请求照样进来,进来就发现连不上。
加上缓冲之后:
ini
draining = true;
await sleep(3000); // DRAIN_DELAY_MS
同一个测试:
ruby
$ curl -i http://localhost:3017/healthz
{"ok":false,"draining":true,"inflight":0}
HTTP 503
$ curl -i -X POST http://localhost:3017/mcp -d '{...initialize...}'
{"jsonrpc":"2.0","error":{"code":-32000,"message":"Server is shutting down"},"id":null}
HTTP 503
服务端日志:
css
[ 1580ms] 收到 SIGTERM,开始退出流程
[ 1580ms] 摘流量缓冲 3000ms(这期间 healthz 返回 503,已有请求照常处理)
[ 2676ms] 已进入退出流程,拒绝新建 session
[ 4581ms] 摘流量缓冲结束
[ 4582ms] 退出
区别不在"有没有返回 503",在于这 3 秒里,负载均衡有机会知道你不该再接客了。
connection refused 和 503 对客户端的意义完全不同:前者是"这里什么都没有",后者是"这里待会儿还有,先去别处"。前者会让客户端放弃整个服务,后者会重试。
还有一个边界:等不完怎么办
上面所有实验都建立在"任务能在 grace 时间里跑完"。跑不完呢?
把 GRACE_MS 设成 3000,任务还是 8 秒:
ini
[ 2125ms] 等待中 inflight=1
[ 5157ms] 等待结束 inflight=1 等待用时=3032ms
[ 5157ms] closeAllConnections 已调用
[ 5158ms] 退出
[15506] 任务1 抛错 12003ms: code=-32001 (距 SIGTERM 约 10002ms)
等满 3 秒,放弃,断连,退出。任务丢了,客户端还是报"超时"。
优雅关闭不是"保证不丢",是"给能跑完的任务一个跑完的机会"。 超过这个时间的,必须靠别的手段兜:
- 客户端侧要有幂等键(上一篇讲过),重试不会产生重复数据
- 长任务不该占着一个同步请求,改成"提交-查询"两段式,发版只影响查询,不影响执行
GRACE_MS必须小于terminationGracePeriodSeconds,否则你的代码还在等,k8s 已经 SIGKILL 了
一个顺带踩到的坑:process.exit 会吞日志
第一次跑完整版时,我盯着日志看了半天:inflight 归零 打出来了,后面"等待结束"、"退出"一个字都没有,但进程确实退了。
原因是 console.log 写到 pipe 时是异步的,process.exit(0) 直接把缓冲区丢了。
arduino
log("退出");
process.exit(0); // 上面这行可能永远到不了终端
发版出问题时最需要日志,而恰恰是退出的那几条最容易被吞。要么在 exit 前 flush,要么用 process.exitCode = 0 让 Node 自己退。
三个动作,一个不要做
动作一:SIGTERM 里第一件事是 draining = true,然后等一下。
不是先 close()。先用健康检查把流量摘掉,再停监听。这个顺序反了,healthz 就等于没写。
动作二:等任务用 inflight 计数,不要用 close() 的回调。
close() 的回调等的是连接,不是任务。你有一个跑了 30 秒的工具,客户端早就断了,回调会在 30 秒时才触发------那时候 k8s 已经把你杀了。
动作三:最后一定要 closeAllConnections()。
SSE 长连接、keep-alive 空闲连接,都会让进程永远退不掉。这一步不是优化,是必须的。
一个不要做的动作:不要指望 server.close() 返回值或者回调告诉你"可以退了"。 它只告诉你"连接都关了"。你的任务在不在跑,它不知道。
一个反方观点
有人会说:我的 MCP Server 就跑在本地,Claude Desktop 启停,哪来的滚动更新?
对,这篇整篇都可以跳过。但只要你的 Server 部署在容器里、前面有负载均衡、会发版,这套就是底线。而且它不只是 MCP 的问题------任何长连接 + 长任务的 HTTP 服务都一样。
还有一点:如果你的工具全是毫秒级返回,graceful 的收益确实不大,close-only 也够。丢单只在"任务执行时间 > 发版间隔"时才发生。先算一下你的工具 P99 耗时,再看要不要做这一步。
总结
- 发版杀掉正在执行的调用后,客户端不会立刻知道:实测服务端第 2 秒退出,客户端第 60 秒才报错,白等 58 秒
- 客户端报的是
-32001 超时,不是"连接断开"。这两者对重试策略的意义完全相反,是发版丢单最隐蔽的一环 server.close()等的是连接关闭,不是任务完成。实测一个空闲连接就让进程拖了 18 秒,退出时机不在你手里- 完整流程是四步:摘流量 → 停监听 → 等 inflight → 主动断连。顺序错了等于没做
draining之后必须留缓冲时间。实测不留缓冲时,SIGTERM 到进程消失是 0 毫秒 ,healthz的 503 没有任何人看得到server.close()不等任务,所以等任务必须自己用inflight计数,并且设上限- 超过 grace 时间的任务一定会丢,这一层只能靠幂等键和"提交-查询"两段式兜住
process.exit()会丢未刷出的日志,而丢的正好是退出时那几条
适用边界 :本地 stdio 的 Server 完全不用管这些------进程是你自己启停的,没有"发版"这回事。部署在容器里、前面有负载均衡、工具里有超过 1 秒的调用,这三条同时成立时,这套流程才是必须的。如果你的工具全都毫秒级返回,close-only 加一个 DRAIN_DELAY 就够了,不必上 inflight 计数。
最后一句:这套东西的价值不在"写得优雅",在于发版时你知道丢了几个、丢在哪一步。没有这套,你只会看到用户说"刚才那个按钮没反应",然后什么都查不到。