healthy 的僵死进程,和被集体摘除的健康容器

第一起事故的样子是监控全绿、用户报障。登上机器看一眼:

$ docker ps
CONTAINER ID   IMAGE         STATUS                    PORTS                    NAMES
9f31c2a1b04e   shop/api:v3   Up 4 hours (healthy)      0.0.0.0:8080->8080/tcp   api

进程"活着",事件循环卡死在死锁上,accept 队列堆满,谁也连不进来。而健康检查写的是 pgrep -f app——PID 在,检查就过。healthy 证明的全部内容,是"那条命令上一次的退出码是 0",仅此而已。

第二起方向相反。数据库例行维护重启 90 秒,十分钟后告警炸了:所有 api 容器 unhealthy,负载均衡把实例摘了个干净,全站零容量。健康检查写的是 psql -h $DB_HOST -c 'select 1'——DB 停一分钟,等于给了它一张对全网的否决票。

两起事故方向相反,根因相同:把一个单值布尔当成了服务的真相。要写对它,得先把这个布尔什么时候翻转搞清楚。

四个旋钮的精确语义

HEALTHCHECK 的全部参数就四个(默认值:interval 30s、timeout 30s、start-period 0s、retries 3):

HEALTHCHECK --interval=10s --timeout=3s --start-period=30s --retries=3 \
  CMD-SHELL 'curl -fsS http://127.0.0.1:8080/healthz || exit 1'

规则逐条说死,不留模糊空间:

  • 探测按 interval 周期执行,退出码 0 记成功,非 0 记失败;单次执行超过 timeout 直接按失败计。
  • 配了健康检查的容器初始状态是 starting,第一次成功后变 healthy。
  • 连续失败 retries 次才翻成 unhealthy;中间任何一次成功,连续计数清零。
  • start-period 是冷启动宽限:宽限期内的失败不计入 retries;但只要期内成功过一次,宽限提前结束,之后的失败正常计数。
  • 从 unhealthy 翻回 healthy 只需一次成功。跌落要连续失败多次,回升只要一次,这个不对称决定了恢复回流的流量冲击必须由编排层消化,HEALTHCHECK 自己不提供"连续 N 次成功才算恢复"的旋钮。
  • 退出码 2 是保留值,别拿它表达"启动中",语义没有保证。

按这套规则推演一个时间线(interval=10s、start-period=30s、retries=3,服务冷启动需要 45 秒):t=10/20/30 的失败落在宽限期内,不计数;t=40 失败,计 1;t=50 成功,healthy。如果服务永远起不来,t=40/50/60 三连击,t≈60 后状态翻成 unhealthy。也就是说,状态滞后至少是 retries × interval 的量级,LB 的摘除与回流逻辑要按这个窗口预留时间。

两个容易被忽略的细节:Dockerfile 里写多条 HEALTHCHECK 只有最后一条生效,不存在叠加;探测由 daemon 串行调度,上一轮超时还没结束,下一轮不会并发开跑,实际间隔只会更长。

排障时 docker inspect 的 Health.Log 比 docker ps 有用得多,每次探测的时间、退出码和输出都在:

$ docker inspect --format '{{json .State.Health}}' api | jq
{
  "Status": "unhealthy",
  "FailingStreak": 2,
  "Log": [
    {
      "Start": "2026-10-15T09:59:50.112344211+08:00",
      "ExitCode": 7,
      "Output": "curl: (7) Failed to connect to 127.0.0.1 port 8080 after 2 ms"
    }
  ]
}

ExitCode 7 是 curl 的 connection refused——比"容器挂了"这种描述精确一个数量级。

检查什么:自己的端口回 200,到此为止

判断标准一句话:健康检查回答"这个容器自己还能不能服务",不回答"它的世界还好不好"。

把下游写进检查的连锁反应值得推演一遍:DB 抖动 → 每个 app 容器在 retries × interval 内相继 unhealthy → LB 摘光全部实例 → 全站零流量 → DB 恢复后还要等下一轮探测成功才能回流。业务本来只降级 60 秒的故障,被放大成一轮完整的全网摘除与回填,回填瞬间的流量尖峰还可能把没预热完的服务再压垮一次。检查下游依赖,就是把"部分降级"放大成"雪崩式自我攻击"。

# 反例:把 DB 的生死绑进每个 app 容器的健康状态
HEALTHCHECK CMD-SHELL 'pg_isready -h $DB_HOST -q || exit 1'

# 正例:只证明我自己的端口还能应答
HEALTHCHECK CMD-SHELL 'curl -fsS --max-time 2 http://127.0.0.1:8080/healthz || exit 1'

“DB 不可用时该不该摘我的流量"是 readiness 的语义,交给支持多探针的编排层去做,并且要带独立的超时与阈值。单值 HEALTHCHECK 承载不了这种分层,就只承载 liveness 级别的最小判断。

镜像里没有 curl:三种真实处境

处境一,distroless。没有 shell、没有 curl,CMD-SHELL 本身就跑不起来。最干净的做法是让应用二进制自带 healthcheck 子命令——同一个静态文件,零额外依赖:

FROM golang:1.23 AS build
WORKDIR /src
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app ./cmd/server

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/app /app
# exec form:不经过 shell;distroless 下也只有这一种写法可用
HEALTHCHECK --interval=10s --timeout=3s --start-period=30s --retries=3 \
  CMD ["/app", "healthcheck"]

处境二,alpine。自带 busybox wget,能用,但别按 GNU 的肌肉记忆写:

# busybox wget:--no-verbose/--tries 是 GNU 选项,这里不可靠
HEALTHCHECK CMD-SHELL 'wget -q -T 2 -O /dev/null http://127.0.0.1:8080/healthz || exit 1'

# debian 系装了 GNU wget 的写法则是:
#   wget --no-verbose --tries=1 --spider http://127.0.0.1:8080/healthz || exit 1

处境三,exec form 混进 shell 语法,最隐蔽也最常见。exec form(JSON 数组)不经过 shell,||、&&、> 都只是字符串的一部分:

# 错误:curl 收到一个以 " || exit 1" 结尾的完整字符串当 URL
HEALTHCHECK CMD ["curl", "-fsS", "http://127.0.0.1:8080/healthz || exit 1"]

# 需要 shell 特性就用 CMD-SHELL 字符串形式
HEALTHCHECK CMD-SHELL 'curl -fsS http://127.0.0.1:8080/healthz || exit 1'
# 不需要就老老实实 exec form
HEALTHCHECK CMD ["/app", "healthcheck"]

关于 curl 再补两刀。-f 必须加:不加的话 404/500 都算成功,你在给一个"端口开着但路由全挂"的服务发健康证。--max-time 比依赖 HEALTHCHECK 的 timeout 更早断崖,是防御性写法。另外 shell form 里管道的退出码取决于最后一个命令——curl ... | grep ok 在连接被拒时碰巧也是非零,但那是 grep 拿不到输入的副产物,别依赖巧合,更别在健康检查里写复杂管道。

unhealthy 之后:什么都不会发生

单机 Docker 对 unhealthy 的内置动作清单是空集:不重启、不告警、不断流,只有 docker ps 括号里的单词变了。想要动作,得自己接线:

  • Swarm 是唯一把 HEALTHCHECK 当一等公民的原生编排:unhealthy 任务先被摘出路由,随后被终止并重新调度。
  • 社区方案 autoheal 一类的旁路容器:给容器打 com.autoheal=true 标签,它盯着 unhealthy 状态执行 restart。能用,但重启是否真是对的处置要先想清楚——进程僵死,重启有用;依赖故障,重启只是添乱。
  • Kubernetes 完全无视镜像里的 HEALTHCHECK,kubelet 根本不执行它。副本层面的健康属于 liveness/readiness/startup 探针,配置在 Pod spec 里。把探针写进 Dockerfile 在 k8s 里等于没写,跨环境部署时这个认知差经常咬人。

Compose 是 HEALTHCHECK 真正发光的地方——启动顺序与门禁:

services:
  api:
    image: shop/api:v3
    healthcheck:
      test: ["CMD", "/app", "healthcheck"]
      interval: 10s
      timeout: 3s
      retries: 3
      start_period: 30s
  smoke:
    image: curlimages/curl:8.10.1
    depends_on:
      api:
        condition: service_healthy   # 等"健康",而不是等"起了"
    entrypoint: ["curl", "-fsS", "http://api:8080/healthz"]

docker compose up --detach --wait 同理:全部服务 healthy 才返回,任何一个卡在 starting,–wait-timeout 到点就非零退出。CI 里起依赖跑集成测试,这一行就是门禁。

应用侧:/healthz 把 liveness 和 readiness 分开答

容器内的进程至少要准备两个端点,语义不要混:

var ready atomic.Bool

func registerHealth(mux *http.ServeMux) {
	// liveness:能应答这个请求,事件循环就是活的;绝不检查外部依赖
	mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
	})
	// readiness:预热完成才接流量;暂时不可服务走这里,后果只是摘流量
	mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
		if !ready.Load() {
			http.Error(w, "warming up", http.StatusServiceUnavailable)
			return
		}
		w.WriteHeader(http.StatusOK)
	})
}

liveness 挂了该重启,那是进程级故障;readiness 挂了只该摘流量,那是依赖或预热问题。两者的处置动作完全不同,所以端点必须分开。Docker 的单值 HEALTHCHECK 表达不了这层,默认放 liveness 级别的最小判断;上了 k8s 一定要拆成两个 probe——尤其 liveness,配错了就是重启风暴,破坏力比不配还大。

验证:看着状态翻一次才算数

健康检查没有"静态审查"这条路,必须让状态机在你眼前翻一次。开一个终端盯事件流:

docker events --filter type=container --filter event=health_status
# 形如:
# 2026-10-15T10:00:02+08:00 container health_status: healthy (image=shop/api:v3, name=api-1)
# 2026-10-15T10:07:12+08:00 container health_status: unhealthy (image=shop/api:v3, name=api-1)

然后做两组对照实验:

# 实验一:杀掉 DB,检查只覆盖自身的 api 应该纹丝不动
docker compose stop db && sleep 60 && docker compose ps

# 实验二:把 healthcheck 换成 pg_isready 那一版,再杀一次
# interval=10s、retries=3 → 30~40 秒内全部 unhealthy
docker compose stop db && sleep 45 && docker compose ps
docker compose start db && sleep 15 && docker compose ps   # 一次成功即翻回 healthy

第二组实验里你会看到完整的"自我攻击"链路:DB 停 60 秒,api 自身的服务能力实际还在,LB 侧经历的却是 100% 摘除加 100% 回流。这一眼比读十遍文档直观。

常见坑点

  • exec form 里写 ||、管道、重定向:不经 shell,全部按字面传给命令。
  • distroless 无 shell:CMD-SHELL 起不来,用应用自带的 healthcheck 子命令。
  • curl 不加 -f:4xx/5xx 一律算健康,健康检查退化成端口探测。
  • timeout 不小于 interval:上一轮还没结束下一轮又该跑,状态滞后被拉长;顺手的排序是应用侧 –max-time < timeout < interval。
  • 冷启动慢却不配 start-period:从第一秒开始累计失败,起得慢的服务永远等不到 healthy。
  • 以为 unhealthy 会自动重启(单机 Docker 不会),或以为 k8s 会读镜像 HEALTHCHECK(根本不执行)。
  • 写多条 HEALTHCHECK 想叠加检查:只有最后一条生效。

总结

HEALTHCHECK 的全部产出是一个单词:starting、healthy、unhealthy。它的价值取决于两件事——检查命令是否只验证最小本地能力,以及编排层对这个单词是否有明确的处置动作。把 DB 写进检查,是把局部故障放大成全网自杀;指望 unhealthy 触发自愈,是给没有肌肉的神经下达收缩指令。Docker 给的只是传感器,治疗方案永远在别处。

HEALTHCHECK 是传感器不是免疫系统——报状态的是它,做处置的永远是编排层。