# 就绪检查与排空流量

> 让就绪探针在关闭期间返回 503，在旧请求排空时拒绝新请求，并用 shutdown-check 的排空检查验证这两点。

Source: https://shutdown.jscrate.dev/zh/docs/guides/readiness-and-draining
Last updated: 2026-09-23

收到 `SIGTERM` 后，服务应当先停止报告就绪，再去完成进行中的请求。这样在实例排空期间，负载均衡器就能把流量转到其他地方。

## 关闭前如何排空连接？

按以下顺序处理：

1. 设置一个全局的排空状态。
2. 让就绪检查（readiness）路由返回未就绪的状态码。
3. 拒绝新的业务请求。
4. 让之前已经开始的请求处理完。
5. 关闭监听器和共享资源。
6. 在平台的截止时间之前退出。

就绪状态的变化并不会立刻在所有地方生效。负载均衡器可能每隔几秒才探测一次，endpoint 更新也需要时间传播。服务必须处理好这段延迟内到达的请求。

## 选择排空方式

| 方式                             | 收到信号后的就绪检查 | 收到信号后的新请求 | 适用场景                                   |
| -------------------------------- | -------------------- | ------------------ | ------------------------------------------ |
| 立即关闭监听器                   | 连接被拒绝           | 连接被拒绝         | 客户端会重试，且就绪状态没有传播延迟       |
| 继续监听，并短暂返回 `503`       | `503`                | `503`              | 负载均衡器需要时间才能感知就绪状态的变化   |

两种方式都能通过 shutdown-check。返回 `503` 能给客户端更明确的响应，也给流量系统留出一小段时间来更新。

## 编写能排空的服务器

```js title="server.js"
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()` 停止接受连接，并等待原有的工作完成。

## 配置排空检查

```json title="shutdown-check.json"
{
  "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](https://shutdown.jscrate.dev/zh/docs/codes/sc310)。

## newRequests 检查什么

撤回就绪状态后，shutdown-check 会在原有测试请求仍在进行时，向 `newRequests.path` 发送一个 `GET` 请求。

这个新请求必须满足以下任一条件：

- 返回的状态码在 `rejectStatuses` 中
- `allowConnectionRefused` 为 `true` 时，连接被拒绝

如果超时、返回 `200` 或其他不在列表中的状态码，结果是 [SC311](https://shutdown.jscrate.dev/zh/docs/codes/sc311)。如果还没来得及测试新请求，原有的工作就已经结束，结果是 [SC312](https://shutdown.jscrate.dev/zh/docs/codes/sc312)。

请选择一个安全的 `GET` 路由。shutdown-check 会真实调用它，所以它不能创建订单、发送邮件或修改生产数据。

## 看懂通过时的时间线

```text
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 传播，再加上传播所需的时间。

总时间仍然必须满足：

```text
排空延迟 + 最慢的进行中请求 + 清理 < 平台的关闭截止时间
```

`shutdown.deadlineMs` 要覆盖服务端这部分的耗时，并留出安全余量。在 Kubernetes 上，别忘了 `preStop` 的时间也包含在 `terminationGracePeriodSeconds` 之内。

## 安全处理重复的 SIGTERM

信号处理函数应当是幂等的。第一个信号启动排空；之后的信号不应重启定时器、重复执行清理，也不应强制中断进行中的工作。

示例中的 `if (draining) return` 判断就是用来处理这种情况的。启用 `repeatSignalAfterMs` 可以测试这一行为。

## 常见失败

| 失败现象                         | 结果    | 修复方法                                           |
| -------------------------------- | ------- | -------------------------------------------------- |
| 就绪检查一直返回 `200`           | `SC310` | 把切换就绪状态作为信号处理的第一步                 |
| 新请求返回 `200`                 | `SC311` | 在业务路由之前加上排空判断                         |
| 新请求一直挂起                   | `SC311` | 返回明确的拒绝状态码                               |
| 还没测试拒绝，旧的工作就已结束   | `SC312` | 使用耗时更长且可控的工作，或更早撤回就绪状态       |
| 旧请求收到 `503`                 | `SC202` | 只对排空开始后才进入的请求应用排空判断             |
| 进程一直存活                     | `SC300` | HTTP 工作排空后，关闭定时器和共享资源              |

## 相关内容

- [Node.js 优雅关闭](https://shutdown.jscrate.dev/zh/docs/guides/graceful-shutdown-nodejs)
- [Kubernetes 与容器](https://shutdown.jscrate.dev/zh/docs/guides/kubernetes)
- [关闭配置](https://shutdown.jscrate.dev/zh/docs/configuration#shutdown)
- [SC310：未撤回就绪状态](https://shutdown.jscrate.dev/zh/docs/codes/sc310)
- [SC311：排空期间接受了新请求](https://shutdown.jscrate.dev/zh/docs/codes/sc311)
