TT Lab
开始
学习 学习路径 课程

失败了,退出码却是 0

重试制造了第二次故障

在 TT Lab 中继续学习

一句话总结

调用外部命令的工具,必须事先防住两种事故:永远不结束的命令和被执行两次的命令。超时防的是第一种,幂等性和锁防的是第二种。只有这两者都具备之后,重试才是安全的。

为什么需要它

部署脚本调用 restart-service,却没有响应。负责人加上了“重试”。下一次故障时,脚本对这个没有响应的命令调用了三次,等了 30 分钟,而实际上三次都在后台执行了,服务被重启了三次。是重试造成了第二次故障。问题不在于重试,而在于重试所预设的两样东西不存在——命令总会结束的保证(超时),以及再执行一次结果也相同的保证(幂等性)。

工作原理

subprocess 的 run() 会执行命令并等到它结束,然后返回 CompletedProcess。三个参数是运维工具的骨架。

import subprocess
try:
    r = subprocess.run(cmd, timeout=30, capture_output=True, text=True)
except subprocess.TimeoutExpired:
    return 124            # coreutils timeout 과 같은 코드를 쓰면 셸 사용자가 바로 안다
return r.returncode

重试必须区分失败的种类。因为网络断开而以 1 结束的命令,值得再调用一次,但因为参数错误而以 2 结束的命令,调用一百次也一样。而且等待的间隔要逐渐加长——如果对方是因为过载而失败,以相同间隔重试就会让过载持续下去。像 backoff * 2 ** (attempt - 1) 那样指数式增长,是惯例。

幂等性(idempotency)是“执行一次与执行多次,结果相同”的性质。如果外部命令不是幂等的,工具就要在它前面设置确认步骤——通过标记(marker)文件查看是否已经应用过,如果已经应用就跳过。标记只在命令成功之后才创建。如果失败了却生成了标记,下一次运行就会相信“已经完成了”。

如果同一个工具同时运行了两个,标记的确认就会发生竞争。两个都看到“还没有完成”,于是两个都执行。给 fcntl 的 flock() 传入 LOCK_EX | LOCK_NB,在拿不到锁时就不会等待,而是抛出 OSError(errno 是 EACCES 或 EAGAIN——文档写明,为了可移植性,两者都要检查)。工具把这个异常理解为“有另一个运行正在进行”,然后退出。锁文件在进程死亡时由内核释放,所以不会有遗留的陈旧锁。

还有一个陷阱。如果命令是 shell 脚本,并且在其中启动了 sleep 或其他命令,那么只杀死子进程(shell),孙进程仍会留下。如果孙进程握着标准输出的管道,管道就不会关闭,communicate() 就不会返回。用 Popen(..., start_new_session=True) 把子进程作为新进程组的组长启动,再用 os.killpg() 向整个进程组发送信号,孙进程就会一并被清理。run() 在超时之后只做 wait(),所以回避了这个问题,但孙进程依然留着。

最后是终止信号。当 cron 杀死超时的任务,或 Kubernetes 下线 Pod 时,工具会收到 SIGTERM。用 signal 的 signal.signal(signal.SIGTERM, handler) 挂上处理器,工具就能把同样的信号转发给子进程,等待它结束,然后以退出码 143(128 + 15)结束。如果没有处理器,就只有工具死了,子进程成了孤儿继续运行——这是重启命令被执行三次那起事故的另一种形态。

在现场相遇的样子

用 subprocess.run(cmd, shell=True) 传入字符串的习惯,会招致这两种事故。参数里混入空格或引号就会变成另一条命令,而且触发超时时,被杀死的可能是 shell,而不是 shell 启动的真正命令。要用列表传参,并使用 shell=False(默认值)。第二种是“在命令执行前就记录成功”的顺序错误。标记、日志、DB 更新,必须在成功之后。第三种是不记录重试次数。如果日志里没有“在第三次尝试时成功”这个事实,就没有人知道那条命令其实每次都要失败两次。每次尝试都用一行 JSON 记录下来,这份日志就直接成了指标。

下一项实验要做什么

构建外部命令执行器 runner.py。从执行命令并原样返回退出码开始,逐步加上超时(124)、指数退避重试、用标记文件实现的幂等的 apply、flock 单实例运行锁、SIGTERM 转发、每次尝试留下的 JSON 日志,以及 --dry-run。三个素材命令(前两次失败的 flaky.sh、永不结束的 hang.sh、每次调用都会生效的 apply.sh)位于 /opt/fixtures/pyops/bin/。