首页 / 资讯动态 / Kubernetes PreStop优雅停机

Kubernetes PreStop优雅停机

Kubernetes 中 Pod 的终止并非瞬间完成,而是一个包含多个阶段的异步流程。很多线上事故的根源,在于开发者误以为发送 SIGTERM 信号后容器就会立刻消失。实际情况是,kubelet 在发起删除 Pod 的请求后,会先向容器主进程发送 SIGTERM,同时启动一个宽限期计时器,默认宽限期为 30 秒。如果容器在这段时间内没有成功退出,kubelet 会直接发送 SIGKILL 强制杀死进程。这就带来一个致命问题:你的应用可能还在处理飞行中的请求,或者正在执行关键的事务,就被强行终止了。PreStop 生命周期钩子正是为了解决这个时间差带来的数据不一致问题而设计的,它允许你在容器收到 SIGTERM 之后、被强制杀死之前,执行一段自定义的清理逻辑。

PreStop 钩子为什么能实现优雅停机

PreStop 钩子本质上是 kubelet 在容器终止流程中插入的一个同步执行点。当 Pod 被标记为 Terminating 状态时,kubelet 会先执行 PreStop 钩子定义的操作,并且只有在 PreStop 执行成功后,才会向容器主进程发送 SIGTERM 信号。这个执行顺序至关重要。如果没有 PreStop,SIGTERM 直接送达应用进程,应用必须自己处理信号、完成排水、关闭连接。但很多遗留应用或者第三方镜像根本不处理 SIGTERM,或者处理得不彻底。PreStop 给了你一个在应用进程收到信号之前,从外部干预的能力。你可以利用这段时间调用应用的排水接口,或者直接修改负载均衡的目标列表,让流量提前摘除。PreStop 执行期间,宽限期计时器已经在倒计时,所以钩子里的操作必须在宽限期内完成,否则依然会被 SIGKILL 截断。

PreStop 的两种执行方式与适用场景

PreStop 支持两种处理方式:exec 命令执行和 httpGet 请求调用。exec 方式直接在容器内部运行一个命令,比如执行一个 shell 脚本,适合需要组合多个操作或者调用本地工具的场景。httpGet 方式则向容器内特定端口发送 HTTP 请求,适合微服务架构下应用已经暴露了管理端口的情况。选择哪种方式取决于你的应用暴露了什么样的停机接口。如果你的应用实现了健康检查之外的优雅停机端点,比如一个 /shutdown 或者 /drain 的 POST 接口,httpGet 是最直接的方案。如果应用没有这样的接口,或者你需要执行更复杂的逻辑,比如从 Redis 中注销实例、通知注册中心、清理临时文件,那么 exec 方式配合脚本会更灵活。需要注意的是,httpGet 请求成功的前提是应用进程仍在正常运行,因为 PreStop 执行时 SIGTERM 还没发送,所以这个条件天然满足。

exec 方式实战:用脚本编排复杂的排水逻辑

假设你有一个基于 Nginx 的反向代理 Pod,或者一个自己开发的 TCP 服务,应用本身不处理任何停机信号。你可以通过 exec 方式注入一个排水脚本。脚本的逻辑通常是:先通知服务发现系统摘除当前实例,然后 sleep 一段安全时间等待流量自然排干,最后再让进程退出。下面是一个典型的 PreStop 配置示例,它利用 curl 调用 Consul 的 API 来注销服务,并等待 15 秒确保已建立的连接处理完毕。

apiVersion: v1
kind: Pod
metadata:
  name: my-service
spec:
  terminationGracePeriodSeconds: 60
  containers:
  - name: app
    image: my-app:latest
    lifecycle:
      preStop:
        exec:
          command:
          - /bin/sh
          - -c
          - |
            curl -X PUT http://consul-server:8500/v1/agent/service/deregister/my-service-id
            sleep 15

这个配置中有几个关键点需要特别注意。terminationGracePeriodSeconds 被设置为 60 秒,比 PreStop 中 sleep 的 15 秒要长得多,这是必须遵守的规则:宽限期必须大于 PreStop 执行时间加上应用自身处理 SIGTERM 的时间,否则 PreStop 没执行完就被 SIGKILL 中断。另外,sleep 的时长需要根据你的负载均衡健康检查间隔和连接保持时间来设定,通常建议至少是健康检查间隔的两倍。如果你的服务使用了 WebSocket 长连接,排水时间可能需要更长,并且需要在应用层配合连接迁移或者心跳超时机制。

httpGet 方式实战:调用应用内置的优雅停机端点

对于 Spring Boot 或者 Go 编写的微服务,通常已经集成了优雅停机能力。Spring Boot 2.3 之后内置了优雅停机支持,Go 的 net/http 包也提供了 Shutdown 方法。你可以通过 httpGet 方式直接触发这些端点。假设你的应用暴露了一个 /actuator/shutdown 端点用于触发停机流程,PreStop 配置可以这样写。

lifecycle:
  preStop:
    httpGet:
      path: /actuator/shutdown
      port: 8080
      scheme: HTTP

这种方式比 exec 更简洁,但前提是应用必须保证这个端点在收到请求后,会先摘除自身流量、等待现有请求处理完毕,再退出进程。如果应用只是简单地调用 os.Exit,那这个 httpGet 方式就和没有 PreStop 一样危险。你需要确认应用框架的具体行为。例如,Spring Boot 的优雅停机在收到关闭请求后,会停止接收新请求,等待配置的超时时间让现有请求完成,然后才关闭 Spring 容器和数据库连接池。这个超时时间需要在应用配置文件中单独设置,并且要和 Kubernetes 的 terminationGracePeriodSeconds 配合好。一个常见的坑是,应用层面的优雅停机超时时间大于 Pod 的宽限期,导致应用还在等待请求处理,就被 kubelet 的 SIGKILL 强制终止了。正确的做法是让应用超时时间略小于 Pod 宽限期减去 PreStop 执行时间。

PreStop 与 Service 端点摘除的时序竞争

很多开发者认为只要配置了 PreStop,流量就会在 PreStop 执行期间被摘除,这是一个危险的误解。Kubernetes 在删除 Pod 时,会同时启动两条并行的路径:一条是执行 PreStop 钩子并最终发送 SIGTERM,另一条是从所有关联 Service 的 Endpoints 列表中移除该 Pod 的 IP。这两条路径之间没有严格的先后顺序保证。也就是说,PreStop 执行期间,kube-proxy 或者 Ingress Controller 可能还没来得及更新转发规则,新的请求仍然可能被路由到这个即将终止的 Pod 上。如果你的 PreStop 只是 sleep 几秒然后退出,而流量摘除因为控制器繁忙延迟了,那么 sleep 结束后应用进程退出,新到达的请求就会失败。

解决这个问题的正确思路是,在 PreStop 中主动触发流量摘除,而不是依赖 Kubernetes 控制面的异步操作。你可以在 PreStop 脚本的第一步就调用注册中心的注销接口,或者修改 Nginx Ingress 的 upstream 状态。如果无法主动摘除,那么 PreStop 中的 sleep 操作就必须足够长,长到能够覆盖 Endpoints 传播到所有节点 iptables 或者 IPVS 规则的最坏情况延迟。通常这个延迟在中小集群中不超过 5 秒,但在大规模集群或者使用外部负载均衡器时,可能需要 10 到 15 秒。这也是为什么很多生产环境的 PreStop 配置中会看到一个看似粗暴的 sleep 20,这不是敷衍,而是对分布式系统最终一致性的一种防御性编程。

处理 PreStop 钩子自身的失败与超时

PreStop 钩子如果执行失败,kubelet 会不断重试直到宽限期耗尽,然后发送 SIGKILL。这意味着如果你的排水脚本因为依赖服务不可用而卡住,Pod 最终仍然会被强制终止,但宽限期会被完全消耗,导致整个删除过程变得非常缓慢。在频繁发布或者节点缩容时,这种慢速终止会拖慢整个调度节奏。因此,PreStop 中的命令必须设置自己的超时时间,不能无限期等待。在 exec 方式中,可以使用 timeout 命令包裹你的脚本,确保即使下游依赖无响应,也能在预设时间内退出。

command:
- /bin/sh
- -c
- |
  timeout 10 curl -X POST http://registry:8500/v1/agent/service/deregister/my-service || true
  sleep 5

这个脚本中,timeout 10 确保 curl 调用最多阻塞 10 秒,无论成功失败都会继续执行后续的 sleep 5。|| true 保证了即使注销失败,脚本也不会因为非零退出码而导致 PreStop 钩子被判定为失败,从而避免 kubelet 的无意义重试。这种防御性写法在生产环境中非常重要,因为注册中心本身也可能在发布过程中被重启,不能因为一个辅助性的排水操作失败就阻塞整个 Pod 的终止流程。

PreStop 与容器内进程的信号转发问题

一个容易被忽视的细节是,容器主进程如果是 PID 1,它在 Linux 内核中具有特殊的信号处理行为。内核会忽略发送给 PID 1 进程的任何没有注册处理函数的信号,这意味着如果你的应用作为 PID 1 运行,并且没有显式捕获 SIGTERM,那么 SIGTERM 会被直接丢弃,应用根本感知不到终止信号。很多开发者发现配置了优雅停机却完全不生效,根因就在这里。PreStop 可以部分缓解这个问题,因为 PreStop 在 SIGTERM 之前执行,你可以通过 PreStop 脚本主动向应用进程发送信号,或者直接调用应用的停机接口。但更根本的解决方案是让应用自己处理 SIGTERM,或者使用 tini 这类轻量级 init 进程作为容器入口,它能够正确转发信号给子进程。

如果你选择在 PreStop 中通过 exec 方式主动杀死应用进程,需要注意发送的信号类型。直接使用 kill 命令默认发送 SIGTERM,如果你的应用作为 PID 1 且没有处理 SIGTERM,这个信号同样会被忽略。此时可以改用 kill -SIGINT 或者其他应用能够响应的信号。更好的做法是在应用启动时通过 STOPSIGNAL 指令在 Dockerfile 中指定一个应用能够处理的信号,或者直接在 Pod spec 中通过 terminationMessagePolicy 等字段配合使用,但最彻底的方案仍然是修复应用本身对信号的响应。

结合 Readiness Probe 实现完整的流量无损切换

PreStop 只解决了停机时的排水问题,但要实现真正的零流量丢失,必须结合 Readiness Probe 一起设计。当 Pod 进入 Terminating 状态时,Kubernetes 会立即将该 Pod 从 Service 的 Endpoints 中标记为不可用,但这个操作的生效速度取决于集群规模和控制器循环周期。如果你的 PreStop 脚本第一步是调用注册中心注销,第二步是 sleep 等待,那么在这段 sleep 期间,Readiness Probe 如果仍然返回成功,kubelet 可能会继续将 Pod 视为就绪状态。为了加速流量摘除,你可以在 PreStop 脚本中创建一个标记文件,同时让 Readiness Probe 检查这个文件是否存在,如果存在则返回失败。

readinessProbe:
  exec:
    command:
    - cat
    - /tmp/terminating
  failureThreshold: 1

PreStop 脚本的第一步就是 touch /tmp/terminating,这样 Readiness Probe 会立即失败,kubelet 会迅速将 Pod 从就绪地址列表中移除。这个技巧利用了 kubelet 本地探针的快速反馈,比依赖控制面的 Endpoints 更新要快得多。需要注意的是,failureThreshold 设置为 1 可以确保一次失败就触发摘除,减少不必要的延迟。这个标记文件的方式在 Istio 或者 Linkerd 这类服务网格环境中同样有效,因为 sidecar 代理也会根据 Readiness 状态来决定是否转发流量。

生产环境中的 PreStop 配置检查清单

在将 PreStop 配置应用到生产环境之前,有几项关键检查必须完成。第一,确认 terminationGracePeriodSeconds 的值大于 PreStop 执行时间加上应用自身优雅停机所需时间的总和,建议至少多出 10 秒作为缓冲。第二,验证 PreStop 脚本中的每个外部调用都有超时控制,避免因为依赖服务不可用导致 Pod 长时间无法终止。第三,确认应用进程能够正确响应 SIGTERM,如果应用作为 PID 1 运行且不处理信号,必须通过 tini 或者 dumb-init 包装启动命令。第四,在 PreStop 中主动执行流量摘除操作,不要依赖 Kubernetes 控制面的异步更新,除非你的业务可以容忍秒级的请求失败。第五,结合 Readiness Probe 的快速失败机制,加速本地流量摘除。第六,对 PreStop 逻辑进行混沌测试,模拟注册中心宕机、网络分区、高并发请求等场景,验证 Pod 是否能够在宽限期内干净地退出。第七,监控 Pod 终止过程中是否出现 SIGKILL 事件,如果频繁出现,说明宽限期不足或者 PreStop 执行时间过长,需要调整配置。

优雅停机不是单个配置项能解决的问题,它需要 PreStop 钩子、应用自身信号处理、Readiness Probe 以及流量控制面四者协同工作。PreStop 是这整个链条中的调度中心,它负责在正确的时间点触发正确的排水动作。理解它的执行时序和局限性,才能设计出真正无损的下线流程。