Shutdown Check

搜索文档

查找页面或章节

让 Node.js 的排空过程与 Pod 终止、容器信号和宽限期相匹配。

在 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

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 设置
容器的 commandcommand
readinessProbe.httpGet.pathreadiness.path
Pod 宽限期shutdown.deadlineMs 的上限
preStop 时长要从服务器可用时间中扣除的时长
终止期间到达的请求shutdown.newRequests
最慢的正常业务请求workload

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

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 写法:

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

避免使用 shell 形式:

CMD node dist/server.js

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

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

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

CMD ["npm", "start"]

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

考虑使用精简的 init 进程

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

排查包装进程的问题

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

现象诊断码
包装进程已退出,子进程里的服务仍占着端口SC302
包装进程被信号杀死SC301
进程一直存活到截止时间SC300
进行中的请求连接断开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 的终止不会缩短应用的截止时间
  • 重试不会掩盖反复出现的失败

设置合理的宽限期

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

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

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