原生健康检查与 Distroless 镜像

设计笔记:silo 二进制为何新增 healthcheck 子命令、mc ready 为何必须退役、单二进制 Distroless 镜像如何规划。

状态:P1(子命令,2ff594f4b)与 P2(distroless 镜像 + CI 门禁,4c34d2309)已在 pgsty/silo 落地;P3(Helm 探针)与 P4(文档)待办 · 决策日期:2026-08-06 · 归属pgsty/silo(命令、镜像、Helm chart)与本站(文档)

Silo 将新增一个原生的 silo healthcheck 子命令,并在现有容器镜像之外发布一个新的 Distroless 镜像变体——里面真正要紧的文件只有一个:silo 二进制。本文在动手实现之前把推理过程和设计决策记录下来,让代码有一份可以对照检验的规格,也让"为什么要做成这样"永远有出处可查。

背景

今天的发布镜像(docker.io/pgsty/silo)基于 ubi-micro,装了四样活动部件:silo 服务端、mcli 客户端(带 mc 别名)、一个静态链接的 curl,以及一个 POSIX shell 入口脚本。Compose 示例用镜像内置的客户端做容器健康检查:

healthcheck:
  test: ["CMD", "mc", "ready", "local"]

这套安排继承自上游 MinIO,其脆弱性有案可查:mc 曾短暂从镜像中消失,用户的健康检查随之失效,“除了禁用别无选择”(#9)。上游自己的历史也如出一辙——2023 年 MinIO 切换到 ubi-micro 丢掉 curl 时,维护者的答复是更加依赖 mc ready local(minio/minio#18373、#18389);而上游 minio/minio 如今已经归档,其二进制自始至终只有 server 一个子命令。上游不会有人来修这件事了。

Distroless 镜像把这个问题逼到了台面上。没有 shell、没有 curl、没有 mc——这正是 Distroless 的定义。容器里唯一保证存在的程序就是服务端二进制本身。如果这个镜像还想拥有 Docker 层面的健康检查,就只能由这个二进制自己来提供。

mc ready 为何必须从探针岗位上退役

细读 mc 的实现(cmd/ready-main.go)会发现:现在的健康检查是"碰巧能用",不是设计出来的。四个彼此独立的缺陷:

  1. 它自己永远不报告失败。mc ready 是一个"等到就绪为止"的循环:每 5 秒重试一次,只在成功时以零退出码结束,连接被拒也不会跳出循环。作为 Docker healthcheck 使用时,“unhealthy” 的判定完全来自 Docker 的 timeout 把进程杀掉——探测语义是 SIGKILL 的副作用。
  2. 它检查的尺度是错的。mc ready 请求的是 /minio/health/cluster——全集群写 quorum。于是每个容器的"健康"反映的都是整个集群的状态,这恰好是 Kubernetes 文档明确警告的级联失败反模式:quorum 一丢,所有节点同时被判不健康。
  3. **它有隐藏故障模式。**它需要可写的 ~/.mc 配置目录(只读 rootfs 或 OpenShift 任意 UID 下,服务器明明健康、探测却先失败了);首次运行会向 stdout 打印配置创建噪音;内置的 local 别名硬编码为 http://localhost:9000——一旦启用 TLS 或改了端口就立即失效,上游用户对此公开抱怨过。
  4. 它是镜像里捆绑第二个二进制的最后一个功能性理由。mcli 和钉版本的静态 curl 都有持续的供应链与维护成本(curl 被钉死在 v8.11.0,因为后续版本砍掉了 aarch64 构建),而这些事一个现有二进制的子命令用约 150 行代码就能做完。

决策

三条轨道,刻意解耦:

#决策
D1silo 二进制新增 healthcheck 子命令——服务端现有 /minio/health/* 端点的匿名 HTTP 薄客户端。它随每一个构建发布,所有镜像和裸机安装同时获得这项能力。
D2现有镜像不动mclicurl、shell 入口脚本、mc ready local 示例全部保留。当前镜像的用户若想用新探针,覆盖自己的 healthcheck.test 即可选择加入——不拿走任何东西,不移动任何默认行为。
D3并行发布一个新的 Distroless 变体作为试点:单二进制、无 shell、原生 HEALTHCHECK 内置。试点验证充分后,它将成为推荐默认并完成切换;无论如何,经典镜像都会为兼容性继续保留。

D2 与 D3 回答了那个显而易见的问题——“为什么不直接给主镜像瘦身?"——因为主镜像的内容物本身就是兼容性表面。#9 的存在,正是因为这个表面曾经在用户脚下被抽换过一次。Distroless 镜像用一个新名字承载一份新契约:在它接受检验期间,没有任何人现有的健康检查、docker exec mc 习惯或入口脚本假设会被破坏。

silo healthcheck 命令

silo healthcheck [FLAGS] [CHECK]

CHECK —— 位置参数,与 /minio/health/<path> 一一对应:
  live          进程在提供服务(默认;不触碰任何外部系统)
  ready         live + KMS 与 etcd 可达(若有配置)
  cluster       全集群写 quorum
  cluster-read  全集群读 quorum

FLAGS:
  --address value   探测目标 host:port(EnvVar: MINIO_ADDRESS;默认 ":9000",
                    空 host 补全为 127.0.0.1)
  --url value       完整基址覆盖(http[s]://host:port);优先于 --address
                    与 TLS 自动判定(EnvVar: MINIO_HEALTHCHECK_URL)
  --maintenance     仅 cluster 有效:附加 ?maintenance=true——问"现在把这个
                    节点下线安全吗?"(HTTP 412 = 不安全,会破坏高可用)
  --timeout value   总超时;默认:live/ready 5s,cluster* 15s
  另继承全局旗标:--certs-dir、--config-dir、--json、--quiet

退出码:  0 = 健康 / 可安全操作 · 1 = 其余一切
输出:    单行,例如
  live: ok (200, 2ms)
  cluster: unhealthy (503) server-status=iam-offline write-quorum=3 healing-drives=2

这个形态背后的设计原则:

  1. **薄客户端,单一事实源。**命令永远只是规范健康 API 的 HTTP 客户端,绝不在进程内重新实现任何检查——“健康"的语义只存在于一个地方:服务端处理器。
  2. **CLI 词汇 = API 词汇。**检查名就是端点路径。没有新概念要学,没有第二套词汇要同步。
  3. **共享服务器自己的配置。**端口来自服务器同一份 --address/MINIO_ADDRESS 契约;http 还是 https,由服务器启动时自己执行的那个证书检查(certs 目录下的 public.crt + private.key)来决定。这是 Traefik healthcheck 的模式——最接近的业界先例,它从与服务端相同的静态配置里解析 ping 端点——并把 Traefik 留成 // TODO 的 TLS 处理真正实现掉。这也是对 mc ready“端口靠猜"缺陷的正面修复。
  4. 默认检查是节点本地的。live 回答"这个进程是否在服务”,而这是单容器健康状态唯一应该回答的问题。集群尺度的检查存在,但只放在显式参数之后,并沿用 mc ready--cluster-read/--maintenance 词汇,让运维语言得以延续。
  5. **退出码只有 0 和 1。**Dockerfile 参考手册明文保留退出码 2(vault status 用 2 表示 sealed,是现成的反面教材)。丰富的诊断信息放进那一行输出里——Docker 会把探测输出的前 4096 字节存进 docker inspect,而命令会把服务端的诊断响应头(x-minio-server-statusx-minio-write-quorumx-minio-healing-drives)解码进去;这些正是裸 curl -f 会丢掉的细节。
  6. **跳过 TLS 证书校验,v1 不提供开关。**这是对匿名端点的环回自探,不传输任何数据——而 kubelet 对 HTTPS httpGet 探针的文档行为恰好也是跳过校验。与之对齐意味着同一套 TLS 部署在 Docker 和 Kubernetes 下得到同一个结论;默认校验只会制造假阴性,因为自签服务器证书极少包含 127.0.0.1 的 SAN。

两条从源码里挖出来的实现约束——它们是承重墙,不是风格偏好:

  • **请求必须严格匿名。**健康路由之所以能豁免保留路径守卫,仅限于被服务器判定为匿名的请求;带上 Authorization 头会改变请求的分类,结果不是得到应答而是被拒绝ErrAllAccessDisabled)。
  • **HTTP 传输层必须设置 Proxy: nil。**容器经常继承 HTTP_PROXY 却没有把 127.0.0.1 写进 NO_PROXY;环回探测绝不能被路由进公司代理。(Traefik 的 healthcheck 出于同样的原因特意这样做了。)

还有一个看似随意、实则不然的数字:cluster 检查的默认超时是 15 秒,因为服务端评估集群健康时自身受 10 秒 cluster_deadline 约束——客户端若在 5 秒就放弃,等不到服务器深思熟虑后给出的 503,连同所有诊断头一起丢失。对抗性评审后补充了两个细节:--url 支持环境变量(MINIO_HEALTHCHECK_URL),因为探针进程看不到服务端的命令行——当服务端的地址或 TLS 配置来自 CLI 参数时,这是矫正内置 HEALTHCHECK 的正式途径;另外任何外层(Docker)超时都必须大于探针自身的截止时间,否则探针会在打印诊断行之前先被 SIGKILL。

这些端点到底在做什么

下表对照的是处理器源码,不是文档转述——并且修正了一个常见的误读:

端点返回 200 的条件失败形态备注
/minio/health/live几乎总是——对象层尚未初始化时也返回 200(该状态只通过 x-minio-server-status: offline 响应头传递)请求队列饱和时 503不触碰外部系统;唯一安静到适合高频探测的端点
/minio/health/readylive外加 KMS 能生成密钥、etcd 能应答读取——各自仅在配置了的情况下KMS/etcd 故障;队列饱和没有 KMS 与 etcd 时,readylive 是同一条代码路径
/minio/health/cluster对象层、桶元数据、IAM 均已初始化,且每一个纠删集都有写 quorum503 附带 quorum 诊断头;带 ?maintenance=true 时失败为 412每次评估失败都会在服务端写一条日志——不要高频轮询它
/minio/health/cluster/read上一行的读 quorum 版本同上

值得点破的推论:liveready存活级别的信号——它们不能告诉你节点能否服务对象,只有 cluster 这一对能。这正是 cluster 端点必须远离单容器探针的原因(尺度错位、日志噪音、级联重启),也正是它适合回答运维问题的原因——“我现在可以把这个节点下线吗?"(--maintenance:200 表示安全,412 表示会失去高可用)。

Distroless 变体

基底gcr.io/distroless/static-debian12——够用,因为 siloCGO_ENABLED=0 构建。这个基底恰好带着服务器真正需要 rootfs 提供的四样东西:CA 证书(KMS/webhook/STS 出站 TLS)、tzdata、/tmp,以及带 root/nonroot 条目的 /etc/passwd。没有 shell、没有包管理器、没有 libc。

契约草图:

FROM gcr.io/distroless/static-debian12:latest
COPY silo /usr/bin/silo
COPY LICENSE NOTICE CREDITS /licenses/
ENV HOME=/tmp
# /data 在镜像层内创建、全员可写——见 issue #55:
# 这里已经没有入口脚本可以在运行时修补属主了。
VOLUME ["/data"]
EXPOSE 9000
HEALTHCHECK --interval=30s --timeout=10s --start-period=2m --start-interval=2s --retries=3 \
  CMD ["/usr/bin/silo", "healthcheck", "ready"]
ENTRYPOINT ["/usr/bin/silo"]

草图里折叠的决策:

  • ENTRYPOINT 就是二进制本身。docker run pgsty/silo:distroless server /data——没有 argv 翻译脚本,因为没有 shell 来跑脚本。经典镜像的 MINIO_USERNAME/MINIO_GROUPNAME 降权路径(依赖 GNU chroot 和可写的 /etc/passwd)在此变体中不受支持;受支持的机制是 --user / Kubernetes runAsUser
  • **/data 在镜像层内创建、mode 0777,试点期默认用户保持 root。**Issue #55 证明了:只声明 VOLUME ["/data"] 而不创建它,会让所有非 root 运行方式失败,而且事后没有任何入口脚本能修补——Distroless 里更是压根没有入口脚本。在层内以全员可写方式创建它,是唯一让全部权限模式(包括 --user)都能工作的选项,同时与经典镜像保持即插即用的对等;其暴露面被"镜像只运行一个进程"这一事实所限定。nonroot 默认(uid 65532)的姿态经过考虑后推迟:它会在 UID 不匹配时破坏文档记载的 bind-mount 工作流,而试点的任务是测量摩擦,不是制造摩擦。晋升为默认推荐时再议,可能以 -nonroot 标签的形式出现。
  • **健康检查内置、exec 形式。**Shell 形式的 HEALTHCHECK 字符串需要 /bin/sh,在这里不可能存在;JSON 数组形式是唯一选择。Compose 会自动继承镜像的 HEALTHCHECK(逃生门是 disable: true),所以这个变体的 compose 用户零配置就能得到可用的 depends_on: condition: service_healthy。选 ready 而不是 live,是因为 Docker 健康状态的主要用途是门控(启动顺序),那是就绪语义——况且在没有 KMS/etcd 时两者本就相同。
  • 一项发布前必须完成的验证HEALTHCHECK 是 Docker 扩展,不在 OCI 镜像规范里(opencontainers/image-spec#749 至今开放),OCI 媒体类型的构建会静默丢弃它。发布流水线必须断言推送后的 manifest 上 docker inspect 能看到 Health 配置,否则就调整构建的媒体类型直到能看到为止。
  • 命名docker.io/pgsty/silo:<RELEASE>-distroless,外加滚动的 distroless 标签。服务端仓库新增 Dockerfile.distroless——它没有任何下载阶段,完全离线,因此可以在每次发布的 CI 里真实构建并断言,顺带堵上 #55 记录的那个"门禁测的是合成镜像而非发布镜像"的覆盖缺口。
  • 变体文档里要诚实写明用户失去了什么:没有 docker exec <container> sh 式调试(改用 docker debug / kubectl debug 临时容器);镜像内没有 mc(改用 pgsty/mc 镜像或宿主机安装的 mcli);没有 MINIO_USERNAME 路径(改用 --user)。

Kubernetes 完全不需要镜像配合

值得明说,因为它划定了问题的边界:Kubernetes 完全忽略 Dockerfile 的 HEALTHCHECK——kubelet 探针在 Pod spec 里配置,从容器外部以 httpGet 请求执行。因此两个镜像变体在 Kubernetes 下的探测方式完全相同:

startupProbe:            # 启动预算:5s × 60 = 5 分钟,护住大规模 IAM 加载
  httpGet: { path: /minio/health/live, port: 9000 }
  periodSeconds: 5
  failureThreshold: 60
livenessProbe:           # 何时重启:只看进程级信号
  httpGet: { path: /minio/health/live, port: 9000 }
  periodSeconds: 30
  timeoutSeconds: 5
  failureThreshold: 3
readinessProbe:          # 何时摘除流量:可以包含硬依赖(KMS/etcd)
  httpGet: { path: /minio/health/ready, port: 9000 }
  periodSeconds: 15
  timeoutSeconds: 5
  failureThreshold: 3

任何这类配置旁边都应放上三条警示:cluster 端点永远不进探针(放进 liveness 意味着 quorum 一丢整个集群同时重启;放进 readiness 会与分布式引导互相纠缠——chart 的 headless service 设置 publishNotReadyAddresses: true 正是为此);live 在请求队列持续饱和时会有意返回 503,所以饱和节点约 90 秒后被重启是设计使然;scheme: HTTPS 下 kubelet 跳过证书校验,自签部署无需任何额外配置。

Silo 的 Helm chart 目前一个探针都没有(上游的 chart 也一样,尽管其文档写了探针示例)。把上面三个探针补进 chart 是计划中的独立后续项——它不依赖任何一条镜像轨道,而且相对上游这是差异化优势,不是兼容性风险。

落地路线

阶段范围仓库
P1silo healthcheck 子命令 + 测试;随下一个发布版二进制交付(所有镜像同时继承该能力,镜像行为零变化)pgsty/silo
P2Dockerfile.distroless + CI 构建与健康门禁 + 以试点身份发布 -distroless 标签pgsty/silo
P3Helm chart:补三探针;刷新过期的默认镜像标签pgsty/silo
P4文档:命令参考、探针指南、Distroless 迁移说明;试点反馈 → 决定是否将 Distroless 晋升为推荐默认本站

贯穿所有阶段的兼容性承诺:经典镜像的内容物与示例不变;mc ready local 在今天能用的地方继续能用;健康 HTTP API 不动(子命令纯属增量);/minio/health/* 路径与本分支的其他所有 /minio/* 路由一样,作为兼容性表面继续冻结。

推迟的决定

记录在案,免得将来从零重新争论:

  • --wait 模式(阻塞等待直至健康——mc ready 那个循环唯一真正的正当用途):推迟——仓库内暂无消费者,日后添加完全向后兼容,旗标命名空间已预留。
  • Distroless 镜像默认 nonroot:推迟至晋升默认推荐时再议,理由见上文。
  • --json 的输出 schema:遵循全局旗标惯例;确切 schema 在实现时定稿并写入命令参考。