# Kubernetes 与容器

> Kubernetes 中的 Node.js 优雅关闭：Pod 如何停止、preStop 与宽限期、Docker 中的 PID 1，以及对应的 shutdown-check 配置。

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

在 Kubernetes 中对 Node.js 做优雅关闭（graceful shutdown），服务器必须停止接收流量、完成进行中的请求，并在 Pod 的宽限期（grace period）结束前退出。shutdown-check 在本地或 CI 中测试的是服务端这一部分，不模拟 Kubernetes 控制平面。

## Pod 停止时会发生什么？

在删除 Pod、滚动更新、缩容或节点排空（node drain）时：

1. Kubernetes 把 Pod 标记为 terminating 状态。
2. 开始移除 endpoint，让 Service 和负载均衡器不再把流量路由到这个 Pod。
3. kubelet 执行配置好的 `preStop` 钩子。
4. 容器运行时向容器的主进程发送 `SIGTERM`。
5. Kubernetes 等待容器退出。
6. `terminationGracePeriodSeconds` 到期时，仍在运行的进程会收到 `SIGKILL`。

endpoint 更新和容器终止几乎同时开始。kube-proxy、ingress 控制器和外部负载均衡器都需要一段时间才能感知到变化，所以收到信号之后仍可能有流量进来。

## 服务需要做什么？

服务器应当：

- 立即改变就绪状态
- 拒绝仍然到达的新工作
- 保住排空开始前已经进入的请求
- 等进行中的请求处理函数结束后，再关闭共享资源
- 在宽限期结束前以退出码 `0` 退出

如果每个客户端都会重试，且流量路由更新得很快，立即关闭监听器也许就够了。更稳妥的零停机部署通常会留一个短暂的排空窗口：在关闭监听器之前先返回 `503`。

## 配置就绪探针和 preStop

```yaml title="deployment.yaml"
spec:
  template:
    spec:
      terminationGracePeriodSeconds: 30
      containers:
        - name: api
          image: example/api:1.4.2
          command: ["node", "dist/server.js"]
          ports:
            - containerPort: 3000
          readinessProbe:
            httpGet:
              path: /health
              port: 3000
            periodSeconds: 2
            failureThreshold: 1
          lifecycle:
            preStop:
              exec:
                command: ["sleep", "5"]
```

`preStop` 的延迟让 endpoint 更新有时间在 `SIGTERM` 到来之前传播出去。这段延迟占用的是 30 秒宽限期的一部分，不会额外增加时间。

exec 形式要求镜像里有 `sleep` 可执行文件。部分 Kubernetes 版本提供内置的 sleep 动作。依赖它之前，先确认你的集群支持。

应用层面的排空可以代替 `preStop`，也可以与它配合使用：`SIGTERM` 一到，就绪检查路由就返回 `503`，服务器再继续监听一小段时间，然后才调用 `server.close()`。

## Kubernetes 与 shutdown-check 的配置对应

| Kubernetes 设置或行为         | shutdown-check 设置                   |
| ----------------------------- | ------------------------------------- |
| 容器的 `command`              | `command`                             |
| `readinessProbe.httpGet.path` | `readiness.path`                      |
| Pod 宽限期                    | `shutdown.deadlineMs` 的上限          |
| `preStop` 时长                | 要从服务器可用时间中扣除的时长        |
| 终止期间到达的请求            | `shutdown.newRequests`                |
| 最慢的正常业务请求            | `workload`                            |

假设宽限期是 30 秒、`preStop` 是 5 秒，那么把测试的截止时间设为 20 秒，大约还留有 5 秒的安全余量：

```json title="shutdown-check.json"
{
  "command": ["node", "dist/server.js"],
  "env": { "NODE_ENV": "production", "PORT": "3000" },
  "baseUrl": "http://127.0.0.1:3000",
  "readiness": { "path": "/health" },
  "workload": {
    "path": "/test/slow",
    "bodyIncludes": "work complete",
    "concurrent": 3,
    "started": { "type": "response-headers" }
  },
  "shutdown": {
    "deadlineMs": 20000,
    "readinessWithdrawal": true,
    "newRequests": {
      "path": "/test/new-work",
      "rejectStatuses": [503]
    },
    "repeatSignalAfterMs": 1000
  }
}
```

shutdown-check 从 `SIGTERM` 开始计时，所以它既不执行 `preStop` 钩子，也不计算钩子的耗时。截止时间应当设为钩子执行完之后留给应用的时间。

## 为什么 PID 1 很重要？

容器的启动命令会成为 PID 1。PID 1 在信号和子进程方面的行为与普通进程不同，所以要谨慎选择启动命令。

### 使用 exec 形式

推荐的 Dockerfile 写法：

```dockerfile
CMD ["node", "dist/server.js"]
```

避免使用 shell 形式：

```dockerfile
CMD node dist/server.js
```

shell 形式会以 `/bin/sh -c` 作为 PID 1 启动，而 shell 不一定会把 `SIGTERM` 转发给 Node.js。

### 避免不必要的包管理器包装

下面的写法会在 Kubernetes 和服务器之间多出一个进程：

```dockerfile
CMD ["npm", "start"]
```

直接启动 `node`，信号由谁处理就一目了然。如果必须使用包装进程，包装进程就必须转发信号，并等待子进程退出。

### 考虑使用精简的 init 进程

tini 这类 init 进程可以转发信号，并回收成为孤儿的子进程。应用会创建子进程时，它很有用。要明确地配置它，并测试生产环境实际使用的容器启动命令。

## 排查包装进程的问题

在 shutdown-check 中使用生产环境的启动命令。在构建镜像之前，检查结果往往就能暴露问题：

| 现象                                   | 诊断码                     |
| -------------------------------------- | -------------------------- |
| 包装进程已退出，子进程里的服务仍占着端口 | [SC302](https://shutdown.jscrate.dev/zh/docs/codes/sc302) |
| 包装进程被信号杀死                     | [SC301](https://shutdown.jscrate.dev/zh/docs/codes/sc301) |
| 进程一直存活到截止时间                 | [SC300](https://shutdown.jscrate.dev/zh/docs/codes/sc300) |
| 进行中的请求连接断开                   | [SC201](https://shutdown.jscrate.dev/zh/docs/codes/sc201) |

## 在容器内运行 shutdown-check

只要 Linux 镜像里装有 Node.js 22 或更高版本以及这个包，你就可以在镜像内运行这个工具。shutdown-check 会把配置的服务作为子进程启动，并通过回环地址（loopback）连接它。

这样可以验证：

- 构建出的镜像能启动服务
- 运行所需的文件和环境变量都在
- Node.js 进程能处理 `SIGTERM`
- 本地进行中的 HTTP 请求能排空
- 进程退出，端口关闭

这样无法验证：

- Kubernetes 移除 endpoint 的过程
- 真实的 `preStop` 钩子
- ingress 或外部负载均衡器的时序
- sidecar 的终止顺序
- 特定集群中的宽限期行为

这些集成层面的检查，请在预发布（staging）集群中进行。

## 处理 sidecar 和服务网格

sidecar 代理可能独立于 Node.js 进程，自行继续或停止转发流量。shutdown-check 直接与本地服务通信，不模拟 sidecar 的关闭顺序。

使用服务网格时，请确认：

- 就绪状态同时反映代理和应用的状态
- 应用排空期间，代理仍会继续转发已有连接
- sidecar 的终止不会缩短应用的截止时间
- 重试不会掩盖反复出现的失败

## 设置合理的宽限期

把整个终止窗口的时间都算进去：

```text
preStop + 路由更新延迟 + 最慢的进行中请求 + 资源清理 + 余量
```

如果总和超过 `terminationGracePeriodSeconds`，Kubernetes 会在服务完成关闭之前发送 `SIGKILL`。调大宽限期可以是合理的做法，但也要修复那些可能永远挂起的处理函数、查询或清理操作。

## 相关内容

- [就绪检查与排空](https://shutdown.jscrate.dev/zh/docs/guides/readiness-and-draining)
- [Node.js 优雅关闭](https://shutdown.jscrate.dev/zh/docs/guides/graceful-shutdown-nodejs)
- [在 CI 中运行 shutdown-check](https://shutdown.jscrate.dev/zh/docs/ci)
- [兼容性与限制](https://shutdown.jscrate.dev/zh/docs/compatibility)
- [SC302：退出后端口仍未关闭](https://shutdown.jscrate.dev/zh/docs/codes/sc302)
