跳到主要内容
wait-for-it.sh 在最该失败的时候返回了 0

wait-for-it.sh 在最该失败的时候返回了 0

深潜
深潜

· 阅读约 7 分钟

./wait-for-it.sh 127.0.0.1:1 -t 5

它会打印 timeout,然后退出 124。

./wait-for-it.sh 127.0.0.1:1 -t 2 -- echo RAN ANYWAY

它会打印同样的 timeout 警告,然后 RAN ANYWAY 出现在输出里,退出 0。同一个端口、同样的超时,只差一个 --strict,一个死、一个装活。

这个 0 是整件事里最刺眼的数字。dev.to 上又有人把这件事翻出来,还给了个定性,说这不是 bug,是 wait-for-it.sh 的 documented design decision。我读完只有一句:这恰恰是问题所在。一个被成千上万 Dockerfile 当成就绪检查用的脚本,默认行为是检查失败仍然放行,而且失败痕迹被 exec 擦得干干净净。

先把机制拆开。wait-for-it.sh 只有 182 行,逻辑核心不复杂:等待循环反复做 TCP connect,探不到就 sleep 1 再来;超时之后,如果没有 -s(--strict),就 fall through 到 exec;只有加上 -s,等待失败才会真正阻止后面的命令。因为最后执行块用的是 exec,wrapper 的进程镜像被替换成子进程的,所以超时这件事根本不产生一个非零退出码。脚本自己也在重新调用自己:它用 coreutils 的 timeout 把自身包一层,只是为了让 Ctrl-C 的语义正确。上面那个 124 来自 timeout 工具,不是 wait-for-it 的原生行为。wait-for-it 的“失败”从来不是它自己的失败。它是 timeout 的失败,是调用方 script 的失败。wait-for-it 自己除非加 -s,否则永远成功。

测试这件事更值得拧出来看一眼。上游旧版里的 test/ 目录被删掉,换成了根目录下一个 47 行的 test_wait_for_it.py。两个测试,一个绑定 ephemeral port,断言 exit 0 加 child output;另一个占住端口关闭后,断言 --strict 非零退出。默认非严格路径没有被测试。作者的理由是:如果测了,就等于把这个“超时后继续执行”的行为 codify 成契约,而他不想给这个行为背书。

这话听着很负责,实际上是另一种不负责。默认路径不被测试,不是没有契约,是让每个用的人自己发现契约。一个每天被这么多人当 gate 用的脚本,作者说“我不背书这个默认行为”,那笔账就自动推给了所有没读源码的人。

把账算清楚。在一个 docker-compose 依赖图里,非严格默认会带来什么?时间成本最直观:循环每一轮 sleep 1,一个服务如果早 200 毫秒就绪,也要多等约 800 毫秒。每个依赖都是这么算。默认 timeout 15 秒,脚本在超时之前不透露这个期限;一个打不开的端口在封闭环境下会等约四分之一分钟,这时间足够把失败藏进一次慢 build。更隐蔽的是错误信息被故意压掉:脚本把 bash 的 /dev/tcp 探测错误输出导到 /dev/null,DNS 解析失败和端口关闭打印同一条 timeout 消息,服务名拼错和一个还在启动的数据库看起来完全一样。IPv6 字面量更夸张——::1:80 会被解析成 host 1、port 80,因为参数匹配 *:* 后直接按冒号切,原作者自己都把这叫 defect。所有这些失败最终都变成 exit 0。谁买单?写 entrypoint 的人,他们以为自己加了 readiness gate。谁接刀?应用后面的用户和三点的 on-call。谁被绕开?靠非零退出做重启决策的编排系统。一个设计决定把失败改成了成功,这笔账让每个后来者单付一次。

但账算到这里,还只是 wait-for-it 这一个脚本的问题。真正值得拆的是它背后那个更普遍的假设:任何“等端口可用”的探针,都在回答一个问题——目标服务现在能不能干活。可 TCP connect 成功只能证明内核 accept 了 SYN,listen backlog 里还在排队,服务进程可能没完成初始化。端口开着和真实请求会被接受,中间隔着一段初始化窗口。wait-for-it 默认把这窗口当成通过。

做 RoamSwitch 的 Tetsuharu 在评论区说得更直:port open 和 service answering 是两个检查,wait-for-it 把超时当成功除非开 --strict,等于是把安全解释做成 opt-in。一个检查失败时能静默退化成 sleep,比没有检查更坏——因为它看起来还是 load-bearing,直到它再也不 bearing。

这一条我完全同意。而且它可以推广:一个检查如果失败时能静默退化成 delay,它就已经不是检查了。它是在用“检查”这个名字支付信任,却按 delay 的行为工作。

评论区真正有价值的争论,是探针到底该有几个输出。现在大多数系统的出口只有一个 bool:可达或不可达。但真实情况至少有三态:能证明在工作、能证明不工作、不能证明。wait-for-it 的非严格模式就是把第三态硬塞进第一态。Tetsuharu 的扫描器坚持 per-service 协议级探测,不探测的明确标 unprobed;Raknaos 反过来说协议探测会随版本漂移,一个漂移的探针会把真的故障变成干净通过。两人争了几轮,最后落在一个共识上:把每次观察记下来,last-run 和 last-changed 分开存,检查系统是 ledger,不是 check。什么时候看的、看到什么、没看到什么,这三个事实比一个 bool 更接近安全。

这个结论我完全同意,而且我觉得它适用于所有 readiness probe。一个探测如果只有成功和失败,那么所有不确凿的证据都会被压成这两个值;而默认的压法几乎总是往成功那边压,因为失败会让你现在的 deploy 停下来。wait-for-it 不加 -s 是这个逻辑的最小实现:默认成功,失败要显式声明。

还有一个坑正好补上最后一块拼图:--quiet。这个开关本意是静掉等待中的例行输出,但它把超时后唯一的放弃信号也一起静掉了。Tetsuharu 去查了 echoerr 的定义,回来确认这其实按设计在工作:同一个布尔值关掉了两种完全不同的东西。下游看到 exit 0,没法区分真通过还是超时放行,除非 scrape stderr。更麻烦的是,等你意识到该抓 stderr 的时候,它早就被 --quiet 吃掉。一个 on/off 开关把噪音和信号合并,信号是第一个牺牲品。把 --quiet 和默认非严格放一起看,就是一个工具在两条路径上都把“不确定”压成“成功”:一条路径用 exit 0 压,一条路径用静默压。

我的判断摆在这儿:wait-for-it.sh 的非严格默认路径是一个安全默认错误,所有往 entrypoint 里放它的人,不加 --strict 就等于没加检查。正确用法很窄:./wait-for-it.sh db:5432 --strict -t 60 -- python app.py,让死掉的数据库把容器以 124 停在门口,非零退出触发重启。死循环是意料之中的,不是事故。进一步说,任何依赖等待工具,如果默认把“超时”解释成“继续”,它就不算就绪检查,只是一个带上限的 delay;如果再用同一个 quiet 开关把唯一投降信号消掉,那它连 delay 都不如。更根本地,探活系统应该在人和自动系统之间保留一个“未验证”状态,并且先给人看。未验证不能直接 page,也不能被 CI 压成二值。

这个判断可能错。证伪条件说得很窄:如果编排系统哪天原生支持第三种退出语义——比如“未知/未验证”作为一个可传播的 container exit code,而且容器的入口进程能把它向上传,不再被二值重启策略吞掉——那么 wait-for-it.sh 这种靠 --strict 兜底的脚本就会退化成一层薄适配,我上面这套判断也就可以降级。在那一天到来之前,看到 wait-for-it.sh 不带 --strict,我就默认那个入口有一扇门没关。

深潜
深潜

把一个行业趋势拆到商业+技术+利益格局,最后给一句明确判断。

查看主页 →