深入剖析:Kubernetes 与 Istio 优雅退出的“死亡竞速”及解决方案

引言

工作群里经常遇到为啥我这个服务每次发版,都会导致别的接口调用失败,提示503 Service Unavailable 或者 pstream connect error or disconnect/reset before headers


第一部分:我遇到了什么问题?

当进行扩缩容或滚动更新时,常会观察到以下典型故障:

  1. 连接中断错误:业务服务出现大量 503 Service Unavailable 报错。
  2. Istio 特有日志:在 istio-proxy 日志中频繁出现 UNAVAILABLE: upstream connect error or disconnect/reset before headers
  3. 在途请求失效:长耗时请求在 Pod 终止瞬间直接断开。

故障时间线序列图:

sequenceDiagram
    participant User as 客户端/负载均衡器
    participant K8s as Kubernetes 控制面
    participant Proxy as Kube-proxy/网络规则
    participant Sidecar as Istio Sidecar (Envoy)
    participant App as 业务容器 (App)

    K8s->>App: 发送 SIGTERM (开始注销 Pod)
    K8s->>Sidecar: 发送 SIGTERM (开始排空连接)
    K8s->>Proxy: 异步移除 Endpoint
    
    Note over Sidecar: Sidecar 默认仅等待 5s 后退出
    Sidecar-->>App: 停止监听入站流量
    
    rect rgb(255, 200, 200)
    Note right of User: 竞态窗口:流量仍根据旧规则路由
    User->>Sidecar: 持续发送新请求
    Sidecar-->>User: 503 Service Unavailable (Envoy 已关)
    end

    App->>Sidecar: 执行清理逻辑 (如出站通知)
    Sidecar-->>App: 连接失败 (iptables/ipvs 规则重定向至空)
    App->>App: 强制退出 (SIGKILL)

第二部分:竞态条件究竟发生在哪?

这些竞态条件主要源于 Kubernetes 独立并行执行终止操作的机制,以及 Sidecar 代理(Envoy)生命周期与应用程序生命周期之间的依赖关系不同步。

核心竞态条件和问题

在 Kubernetes 和 Istio 环境中,Pod 优雅终止涉及三个独立并行的流程:Kubernetes Endpoints 移除、应用程序容器接收 SIGTERM 信号并启动清理、以及 Sidecar(istio-proxy)启动连接排空(Draining)。由于这些流程
序不保证
相互独立,导致了多种竞态条件。

gantt
    title 容器优雅退出时间
    dateFormat X
    axisFormat %Ss
    Terminating:vert, start, 0, 0
    
    section Service & Endpoint 层
    端点摘除 (kube-proxy)      :done, s1, 0, 2
    ipvs 规则更新           :done, s2, after s1, 3
    停止新流量路由             :active, s3, after s2, 25
    
    section pilot-agent
    接收SIGTERM信号           :done, a1, 1, 1
    通知Envoy开始排水         :done, a2, after a1, 1
    minDrainDuration         :done, minDrainDuration, 0, 5
    exitOnZeroActiveConnections:active, exitOnZeroActiveConnections, after minDrainDuration, 30
    Agent进程退出             :done, a5, after exitOnZeroActiveConnections, 30
    
    section Envoy Proxy
    接收排水信号              :done, e1, 2, 1
    停止接受 Inbound 连接     :e2, after e1, 1
    等待活跃连接完成          :e3, after e2, 30
    Envoy进程退出             :done, e4, after e3, 30
    
    section app容器
    接收SIGTERM信号           :done, c1, 1, 1
    执行preStop钩子           :c2, after c1, 5
    处理进行中请求            :c3, after c2, 8
    清理资源                 :c4, after c3, 3
    发送完成信号              :c5, after c4, 1
    容器进程退出              :c6, after c5, 1
    
    section 系统事件
    Pod Terminating状态      :crit, p1, 0, 30
    terminationGracePeriod   :crit, p2, 0, 30
    SIGKILL(强制终止)        :crit, p3, 30, 1
  • 调用 a.proxy.Drain(false) Envoy admin的接口 http://127.0.0.1:15200/drain_listeners?inboundonly&graceful ^3 排空 Inbound 连接。
  • pilot agent 等待 minDrainDuration[^1] 的时间,然后开始每隔 1s 检查 http://%s/stats?usedonly&filter=downstream_cx_active$ 活动连接是否为0. ^2
  • 为0后,abortCh 通道写入 消息,proxy run的协程收到 abortCh 开始调用 cmd.Process.Kill() kill envoy,并将退出的状态 写入 agent 的 runWait 的 a.statusCh 通道 [^4]
  • agent 根据 a.statusCh 通道收到的退出消息后,输出相应的消息,再退出自己。

1. 新流量接收与 Endpoints 移除之间的竞态 (Inbound Traffic)

这是最常见的竞态条件,发生在 Kubernetes 将 Pod 从 Service Endpoints 列表中移除的同时,Kubelet 向容器发送 SIGTERM 信号时。

  • 问题描述: Kubernetes 将 Pod 从 Endpoints 列表中移除(即停止新流量路由到该 Pod)以及更新 Kube-Proxy 或外部负载均衡器的路由规则是需要时间来传播的。然而,这个 Endpoints 移除操作是并行于 Pod 终止流程的。如果应用程序容器在网络规则完全更新前就快速完成优雅关闭(响应 SIGTERM)并退出,客户端仍可能被路由到这个已退出的 Pod
  • 后果: 客户端可能会收到连接错误,例如 “connection refused” 或 HTTP 5xx 错误(如 502 Bad Gateway 或 503 Service Unavailable)。
  • 解决方案/缓解措施: 为了给网络规则传播提供缓冲时间,通常建议在应用容器的 lifecycle 中使用 preStop Hook 引入一个短暂的延迟(例如 sleep 10sleep 15 秒)。

istio lb访问的重试策略

默认是 503 连接失败,会进行redo lb 选择 host (endpoint),如果超出了最大重试次数,才会返回 503 错误,同时flags是UFX

2. Sidecar 过早终止导致业务容器出站失败 (Outbound Traffic)

这是 Istio/Envoy 环境中特有的最关键的竞态条件。

  • 问题描述: 应用程序和 Sidecar(Envoy)同时收到 SIGTERM 信号。Envoy 启动排空(Draining)和关闭流程,而应用程序也启动自身的清理和关闭流程。Istio-agent 默认的 Sidecar 强制终止等待时间 (terminationDrainDuration) 通常默认为 5 秒,这远低于 Kubernetes 的默认宽限期(30 秒)。如果 Envoy 在应用程序完成其清理工作之前退出,应用程序在清理阶段尝试发起的出站连接(Outbound connections,例如通知其他服务、状态同步或数据库清理)将失败。
  • 根本原因: 应用程序的出站流量依赖于 Sidecar 设置的 IPTables 规则将流量重定向给 Sidecar。一旦 Envoy 进程关闭或 Sidecar 容器退出,这些 IPTables 规则就会将流量发送给一个“不存在的 Envoy”,导致请求失败。
  • 后果: 应用程序在关闭过程中对外部服务(或其他服务)的调用失败,导致数据丢失、状态同步失败或客户端收到错误消息,例如 “UNAVAILABLE: upstream connect error or disconnect/reset before headers”。
  • 解决方案/缓解措施:
    • 延长 Sidecar 排空时间: 调整 terminationDrainDuration,使其与应用程序的 terminationGracePeriodSeconds 保持一致,从而确保 Sidecar 不会过早退出。
    • 连接感知退出: 使用 EXIT_ON_ZERO_ACTIVE_CONNECTIONS: "true" (Istio 1.12+),让 Envoy 等待所有活跃的下游连接处理完毕后才退出。这样,Sidecar 的退出时间就与实际的网络活动同步,而不是依赖于固定的超时时间。

3. Kubernetes 宽限期不足导致强制终止

这个竞态条件发生在应用层和 Kubernetes 的时间配置之间。

  • 问题描述: 优雅关闭的总耗时(preStop Hook 延迟 + 应用程序自身清理时间 + Envoy 排空时间)超过 Pod 定义的 terminationGracePeriodSeconds
  • 后果: 如果超过宽限期,Kubernetes 将发送 SIGKILL 信号(信号 9)强制终止所有容器进程,包括 Envoy 和应用程序,导致正在处理的请求被中断、数据丢失或状态不一致。即使 Sidecar 设置了较长的 terminationDrainDuration,如果 Pod 的 terminationGracePeriodSeconds 较短(默认 30 秒),Kubernetes 也会强制终止 Pod。
  • 解决方案/缓解措施: 必须确保 terminationGracePeriodSeconds 的值大于所有优雅关闭步骤所需的时间总和。

Istio 配置参数的复杂性导致的不一致性

Istio/Envoy 复杂的超时参数集也可能导致配置上的混乱和不一致,进一步加剧竞态条件:

根据 Istio 的一个 Issue [#34855] 所述,有四个控制 Sidecar 终止持续时间的设置,它们之间的关系非常混乱且不一致:

  1. drainDuration:Envoy 的优雅排空持续时间(默认 45 秒)。
  2. parentShutdownDuration:Envoy 父进程关闭延迟(默认 60 秒),但该参数在 Istio 1.10+ 版本中由于 Istio 禁用 Envoy Hot Restart 而不再使用
  3. terminationDrainDurationpilot-agent 在收到 SIGTERM 信号后延迟终止 Envoy 的时间(默认 5 秒)。
  4. terminationGracefulPeriodSeconds:Pod 的 Kubernetes 终止宽限期(默认 30 秒)。

由于这种复杂性和默认配置的不足(例如 terminationDrainDuration 默认只有 5 秒),容易出现 Sidecar 在应用程序完成清理前被强制关闭的问题。有人建议应将 Kubernetes 的 terminationGracefulPeriodSeconds (4) 作为唯一的主要截止时间,并由 agent 协调 Envoy 的排空。


如何构建istio-proxy的优雅退出流程?

方案 1:进阶方案(连接感知退出)

针对长连接或不稳定连接场景,使用 Istio 1.12+ 引入的连接感知关闭机制,让 Sidecar 真正“站完最后一班岗”。

1
2
3
4
5
6
7
8
metadata:
annotations:
proxy.istio.io/config: |
proxyMetadata:
# 当活跃链接数为 0 时才终止 Sidecar
EXIT_ON_ZERO_ACTIVE_CONNECTIONS: "true"
# 在检查链接数前的最小睡眠时间
MINIMUM_DRAIN_DURATION: "10s"

方案 2:平台级方案

  1. Native Sidecar:在 Kubernetes 1.29+ 中使用原生的 Sidecar 容器特性,使 Sidecar 能够更早启动并更晚退出。
  2. 业务自愈:利用 Istio 的 DestinationRule 配置自动重试熔断,确保当个别 Pod 异常退出时,流量能迅速漂移至健康实例。

选择方案决策流程图:

graph TD
    A[开始配置优雅退出] --> B{应用类型?}
    B -- 短耗时 HTTP --> C[方案 1: preStop Sleep 15s]
    B -- 长连接/gRPC --> D[方案 2: EXIT_ON_ZERO_ACTIVE_CONNECTIONS]
    B -- 任务型 Job --> E[手动触发 /quitquitquit]
    C --> F[计算 terminationGracePeriodSeconds]
    D --> F
    E --> F
    F --> G[验证: 零 5xx 错误部署]

如何构建业务容器的优雅退出流程?

构建业务容器的优雅退出(Graceful Shutdown)流程,是确保微服务架构高可用性、防止数据丢失及避免滚动更新期间出现 5xx 错误的关键。在 Kubernetes 与 Istio 环境下,一个完整的优雅退出流程应包含以下几个核心层面的配置与实践:

1. 应用程序层:信号处理与逻辑清理

应用程序必须能够识别并响应 Kubernetes 发出的终止指令。

  • 捕获 SIGTERM 信号:当 Pod 进入终止流程时,Kubelet 会向容器内 PID 1 进程发送 **SIGTERM (信号 15)**。应用程序需要捕获该信号,触发以下逻辑:
    • 停止接收新请求:标记服务为非就绪状态。
    • 完成在途请求(In-flight Requests):允许正在处理的任务执行完毕,而不直接断开连接。
    • 资源清理:保存必要状态、关闭数据库连接、冲刷缓存并通知关联服务。
  • 确保 PID 1 运行:SIGTERM 仅发送给容器内的 PID 1 进程。若通过 shell 脚本启动应用(未使用 exec),应用将无法接收到该信号,导致最终被 SIGKILL 强制杀掉。
  • 利用框架特性:现代框架多有内置支持。例如 Python (FastAPI/Uvicorn) 默认监听 SIGTERM 信号进行优雅停机;Spring Boot 2.3+ 仅需设置 server.shutdown=graceful;Go 的 http.Server 包含 Shutdown() 方法。

2. Kubernetes 层:生命周期协调

由于网络规则(如 Kube-Proxy、Ingress、负载均衡器)的更新与删除事件是异步并行的,必须引入缓冲机制。

  • 配置 preStop Hook:在发送 SIGTERM 之前,Kubelet 会先执行 preStop 钩子。

    • 最佳实践:添加一个 10-15 秒的 sleep 延迟。我们生产实践中用的是 15s。
    • 作用:这段时间用于等待网络组件(如 Kube-Proxy)移除 Pod 的端点(Endpoint)并将规则传播到整个集群,确保新流量不再流向该“濒死”的 Pod。
  • 调整宽限期(terminationGracePeriodSeconds)

    • 这是 Pod 强制关闭前的总时间(默认为 30 秒)。
    • 计算公式:该时长应满足 preStop延迟 + 应用清理耗时 + 侧车排空时间 + 冗余量。对于重型任务或批处理应用,可能需要设置 60-300 秒以上。我们生产设置的是45~60s
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    apiVersion: apps/v1
    kind: Deployment
    metadata:
    name: demo-app
    spec:
    template:
    spec:
    terminationGracePeriodSeconds: 60 # 调整宽限期,建议设置 > preStop + 应用清理耗时
    containers:
    - name: app
    image: my-app:latest
    lifecycle:
    preStop:
    exec:
    command: ["/bin/sh", "-c", "sleep 15"] # preStop 延迟,等待流量切换

3. Istio 层:Sidecar 与业务同步

在 Istio 环境中,Sidecar(Envoy)可能比业务容器更早退出,导致应用在清理期间尝试的出站请求(Outbound)失败。

  • 启用连接感知退出(推荐):设置环境变量 EXIT_ON_ZERO_ACTIVE_CONNECTIONS"true"
    • 机制:开启后,Istio-agent 会每隔 1 秒检查一次活跃连接数。只有当所有下游连接归零后,侧车代理才会真正终止。
  • 调整排空时长:手动调整 terminationDrainDuration(默认仅 5 秒),使其与业务退出的节奏相匹配。
  • 针对 Job 的特殊处理:对于任务型容器(如 CronJob),主容器退出后侧车往往不自动终止。应在侧车的 preStop 中调用 /quitquitquit 接口,强制 Envoy 优雅退出。 这个在引入原生的sidecar后无需做这一步。

4. 进阶保障:数据一致性与幂等性

由于基础设施故障或超时,SIGKILL 强制终止不可避免,因此系统设计需具备容错能力。

  • 实现幂等性(Idempotency):确保所有状态修改操作在客户端重试时不会产生副作用,防止重复扣款或数据损坏。
  • 防熵/协调机制(Reconciliation):引入后台异步修复进程,自动检测并修复因非正常停机产生的孤儿订单或不一致状态。

总结清单

  1. 代码层:应用捕获 SIGTERM 信号并由 PID 1 进程运行。
  2. K8s 资源:设置 preStop 睡眠 15 秒,并合理调优 terminationGracePeriodSeconds
  3. Istio 注解:开启 EXIT_ON_ZERO_ACTIVE_CONNECTIONS 确保代理守护连接直至归零。
  4. 长连接/Job:对于 WebSocket 或任务型应用,考虑彩虹部署(Rainbow Deployment)或显式触发关闭接口。

总结与行动清单

核心要点总结:

  • 网络传播是有代价的:必须使用 preStop 制造时间差,等待路由规则完全移除。
  • 配置必须匹配:确保 terminationGracePeriodSeconds 大于所有排空逻辑的总和,否则 K8s 会暴力执行 SIGKILL
  • 同步生命周期:开启 EXIT_ON_ZERO_ACTIVE_CONNECTIONS 是处理现代微服务复杂连接的最佳实践。

行动检查清单:

  1. [ ] 审计:检查核心业务 Deployment 是否定义了 terminationGracePeriodSeconds(建议至少 60s)。
  2. [ ] 对齐:为所有容器添加 preStop 延迟(推荐 10-15s)以缓冲网络规则更新。
  3. [ ] 升级:在测试环境开启 EXIT_ON_ZERO_ACTIVE_CONNECTIONS 并观察 Pod 退出时长。
  4. [ ] 压测:在进行滚动更新的同时发起压测,验证是否仍存在 upstream connect error 日志。

参考资料

[^4]: a.statusCh <- exitStatus{err: err}