Skip to content

发版停机耗时满 30 秒

01 前置理论与核心机制

1. K8s 的标准容器终止生命周期

  • 终止流程:当 K8s 停止一个 Pod 时(如滚动更新、缩容),Kubelet 会向容器内部的 PID 1(1 号主进程)发送 SIGTERM(终止)信号。
  • 宽限期与强制终止:发送信号后,K8s 会等待一段宽限期(terminationGracePeriodSeconds,默认 30 秒)。如果 30 秒后容器依然未退出,Kubelet 会发送 SIGKILL(强制终止)信号,直接结束进程。

2. 容器单进程模型与 Shell 的信号阻断

  • PID 1 的职责:在容器环境中,占用 PID 1 的进程需要对容器的生命周期负责,必须能够接收并处理操作系统发来的系统信号。
  • Shell 包装器的副作用:在编写 Dockerfile 时,如果采用 ENTRYPOINT sh -c "java -jar app.jar" 的 Shell 模式,会导致 sh 外壳进程占用容器的 PID 1,而真正的 Java 业务进程变成了子进程(如 PID 2)。
  • 信号未转发:sh 或 bash 进程作为 1 号进程时,收到 SIGTERM 后默认既不处理,也不会向子进程转发。底层的业务进程无法收到集群下达的停机指令,因此不会执行数据落盘或连接关闭动作,直到 30 秒宽限期结束被 K8s 强制终止。

02 故障排查与恢复闭环 SOP

遇到每次发版容器退出均固定耗时 30 秒,且观察到业务逻辑中的 @PreDestroy 或 ShutdownHook 完全未执行时,按以下步骤排查:

步骤一:观测停机耗时与确认现象

在执行部署或手动删除 Pod 时,观测退出耗时:

time kubectl delete pod
  • 观测点:如果命令执行完毕耗时恰好在 30 秒左右,通常是 SIGTERM 信号未生效,触发了兜底的 SIGKILL 强制终止逻辑。

步骤二:进入容器检查 PID 1 归属

直接进入容器查看进程树结构:

# 提取容器内的进程列表,重点关注 PID 为 1 的进程
kubectl exec -it  -- ps -ef
  • 观测点:如果看到 PID 1 的进程是 /bin/sh -c java ...,而真正的 java -jar 进程的 PID 是其他数字(如 7),即可确认是 Shell 进程阻断了信号传递链路。

步骤三:修改启动指令使业务进程接管 PID 1

解决该问题的核心原则,是让真实的业务进程直接占用 PID 1 的位置。 修复方案 A:增加 exec 指令 (推荐)

如果启动脚本中必须使用 shell(例如需要解析环境变量),只需在启动命令前加上 exec 关键字:

# 错误写法
# ENTRYPOINT sh -c "java -jar app.jar"

# 正确写法:exec 会用当前业务进程替换原来的 shell 进程
ENTRYPOINT sh -c "exec java -jar app.jar"

修复方案 B:改用 JSON 数组语法 (最佳实践)

在 Dockerfile 中避免使用 Shell 模式,直接采用 Exec 模式(JSON 数组格式):

ENTRYPOINT ["java", "-jar", "app.jar"]

步骤四:重新发布与平滑验证

配置更新后,再次执行停机测试:

# 发送终止信号模拟 Kubelet 行为 (容器环境下)
docker kill --signal=TERM
# 或直接在 K8s 中删除 Pod
kubectl delete pod

  • 验证点:进程能够迅速退出,且在业务日志中可以明确看到 Closing database connection 或 Destroying beans 等优雅退出的清理记录。

03 真实生产故障现象与案例库

案例 1:Spring Boot 优雅停机失效导致数据异常

  • 故障现象:某交易服务发版时,偶发出现账单状态不一致。
  • 排查结果:Spring Boot 已配置 server.shutdown=graceful,但发版停止旧容器始终耗时 30 秒。ps -ef 确认 PID 1 为 /bin/sh。
  • 根因与处置:Kubelet 发出的停机指令被 sh 拦截,Java 进程未收到通知,30 秒后被 SIGKILL 强制终止,导致正在执行的写库事务中断。将启动脚本修改为 exec java -jar 后,事务能够在收到信号后正常完成,消除了数据异常问题。

案例 2:Nginx 容器无法平滑退出

  • 故障现象:自定义的 Nginx 镜像,在更新配置并重启 Pod 时,旧 Pod 迟迟无法退出。
  • 排查结果:Dockerfile 中配置了 CMD /run.sh。该脚本先执行环境初始化,最后执行 nginx -g "daemon off;"。
  • 根因与处置:Shell 脚本占用了 PID 1。处置方案是在 run.sh 最后一行加上 exec,修改为 exec nginx -g "daemon off;",使 Nginx 进程直接接管 1 号进程位置,恢复对 SIGQUIT/SIGTERM 信号的处理能力。

案例 3:启动脚本复杂场景下引入 Tini 托管

  • 故障现象:业务依赖复杂的启动脚本(涉及大量逻辑分支与后台辅助进程),单纯添加 exec 无法满足多进程管理需求。
  • 排查结果:复杂 Shell 脚本下,不仅 SIGTERM 无法下发,还会产生未被回收的僵尸进程。
  • 根因与处置:引入专用的容器初始化工具 tini。Dockerfile 修改为 ENTRYPOINT ["/sbin/tini", "--", "/run.sh"]。由 tini 占用 PID 1 负责信号转发与僵尸进程回收,规范容器生命周期管理。

04 生产排障速查表(停机超时与 PID 1)

顺序排查层级执行命令 / 观测点异常定性处置动作01现象确认time kubectl delete pod耗时固定在 30 秒左右判定触发了 terminationGracePeriodSeconds 超时强制终止02进程检索kubectl exec <pod> -- ps -o pid,commPID 1 对应的是 sh 或 bash确认为 Shell 进程阻断了终止信号03配置排查检查 Dockerfile / Deployment YAML使用了 command: ["sh", "-c", "..."]定位配置层面引发进程树偏移的具体位置04配置修复修改 YAML 的 command 或启动脚本在启动指令前补充 exec使业务进程替换 shell 进程接管 PID 105验证日志重启后查阅业务服务日志输出连接池关闭、资源注销等日志确认优雅停机逻辑正常生效

05 面试应答参考话术

面试官问:“K8s 每次发版,业务容器都会固定卡 30 秒才退出,而且业务的优雅退出逻辑完全没有执行,这是为什么?”

可以这样回答:这个问题通常与网络延迟无关,也不应该通过调整 K8s 超时时间来解决,它主要涉及容器的单进程模型和系统信号传递机制。核心原因是:K8s 发送的停止信号没有传递到底层的业务进程。 K8s 停止容器时,会向容器内的 PID 1(主进程)发送 SIGTERM 优雅退出信号。如果等待 30 秒(默认宽限期)仍未退出,则会发送 SIGKILL 强制终止。 在实际开发中,如果 Dockerfile 使用了 sh -c "java -jar" 这类 Shell 格式启动,会导致 sh 外壳进程占用容器的 PID 1,而真正的 Java 进程变成了子进程。 由于 sh 进程收到 SIGTERM 信号后默认既不处理,也不会向子进程转发,底层的业务进程无法得知停机指令,也就不会执行清理连接池等优雅退出逻辑。等到 30 秒宽限期结束,Kubelet 直接将容器强制终止,容易引发业务请求中断或数据不一致。解决方法很简单:只需在启动脚本的业务命令前加上 exec 关键字(即 exec java -jar)。exec 会用业务进程直接替换当前的 Shell 进程,使业务进程接管 PID 1。这样在发版停机时,JVM 就能直接收到 SIGTERM 信号并执行清理逻辑,实现平滑退出。

06 面试高频追问与应答预案

追问 1:在 Dockerfile 里,CMD ["java", "-jar", "app.jar"] 和 CMD java -jar app.jar 有什么区别?

  • 回答要点:
  • 前者是 Exec 格式(JSON 数组),不会启动 shell,而是直接运行目标程序,目标程序会直接占用 PID 1,属于推荐的标准写法。
  • 后者是 Shell 格式,底层会自动包裹一层 /bin/sh -c。这会直接导致 PID 1 被 sh 占用,引发停机信号被阻断的问题。

追问 2:如果容器里既要运行 Java,又要运行一个 Filebeat 收集日志,怎么保证信号正常分发?

  • 回答要点:
  • 首先,容器的标准实践是“单进程模型”,不建议将多个核心进程放入同一个容器。推荐使用 K8s 的 Sidecar 模式,将 Filebeat 拆分到独立的容器中运行。
  • 如果特殊场景必须合并部署,不能使用简单的循环脚本拉起,需引入 Supervisor 或 tini 作为 1 号进程,由专门的进程管理工具负责子进程的启停和系统信号的广播转发。

07 自动巡检脚本(check_sigterm_pid1.sh)

该脚本用于快速扫描宿主机上运行的容器,筛查 PID 1 被 sh/bash 占用、存在优雅停机失效风险的容器。

#!/usr/bin/env bash
# ==============================================================================
# 脚本名称: check_sigterm_pid1.sh
# 功能描述: 扫描本机容器 PID 1 归属,排查 SIGTERM 信号阻断风险 (纯只读)
# 使用方式: 在运行容器的 K8S Node 或宿主机以 root 权限执行
# ==============================================================================

set -o pipefail

RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[0;33m'
BLUE='\033[0;34m'
PLAIN='\033[0m'

echo -e "${BLUE}================================================================${PLAIN}"
echo -e "${BLUE}          容器 PID 1 信号阻断与优雅停机失效风险巡检             ${PLAIN}"
echo -e "${BLUE}================================================================${PLAIN}"

# 依赖检查:支持 crictl 或 docker
CLI_TOOL=""
if command -v crictl &>/dev/null; then
    CLI_TOOL="crictl"
elif command -v docker &>/dev/null; then
    CLI_TOOL="docker"
else
    echo -e "${RED}[ERROR] 未找到 crictl 或 docker 命令,无法执行容器检查。${PLAIN}"
    exit 1
fi

echo -e "${YELLOW}[INFO] 使用容器引擎工具: ${CLI_TOOL}${PLAIN}\n"

CONTAINERS=$($CLI_TOOL ps -q)
RISK_COUNT=0

for cid in $CONTAINERS; do
    # 提取容器内的 PID 1 进程名
    PID1_COMM=$($CLI_TOOL exec "$cid" ps -o comm= -p 1 2>/dev/null | tr -d ' \n\r')

    if [ -z "$PID1_COMM" ]; then
        continue
    fi

    # 检查是否为 sh, bash, ash 等 Shell 进程 (此处修复了乱码)
    if [[ "$PID1_COMM" == "sh" || "$PID1_COMM" == "bash" || "$PID1_COMM" == "ash" ]]; then
        CNAME=$($CLI_TOOL inspect "$cid" | grep -m1 -E '"name"|"Name"' | awk -F'"' '{print $4}' | sed 's/^\///')

        # 进一步检查完整的执行命令
        PID1_ARGS=$($CLI_TOOL exec "$cid" ps -o args= -p 1 2>/dev/null | head -n 1)

        echo -e "${RED}[WARN] 发现信号阻断风险容器: [${CNAME}] (ID: ${cid:0:8})${PLAIN}"
        echo -e "  -> PID 1 进程名: ${PID1_COMM}"
        echo -e "  -> 完整启动命令: ${PID1_ARGS}"
        echo -e "  -> ${YELLOW}[风险] 停机信号将被阻断,发版时会导致 30 秒超时并强制终止业务进程。${PLAIN}"
        echo -e "  -> ${GREEN}[修复建议] 在启动脚本中补充 'exec' 关键字,或修改 Dockerfile 为 Exec JSON 数组模式。\n${PLAIN}"
        ((RISK_COUNT++))
    fi
done

echo -e "${BLUE}================================================================${PLAIN}"
if [ "$RISK_COUNT" -gt 0 ]; then
    echo -e "${RED}[汇总] 发现 $RISK_COUNT 个容器存在 SIGTERM 优雅退出失效隐患。${PLAIN}"
else
    echo -e "${GREEN}[汇总] 巡检通过,本机容器的 PID 1 未被 Shell 进程占用。${PLAIN}"
fi
echo -e "${BLUE}================================================================${PLAIN}"