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

失败了,退出码却是 0

别让重试制造第二次故障

在 TT Lab 中继续学习

目标

让调用外部命令的 Python 工具防住“永远不结束的命令”和“被执行两次的命令”——超时、重试、幂等性、锁、信号处理、尝试日志。

为什么重要

重试只有在“命令总会结束”和“再执行一次也安全”这两个前提之上才是安全的。在没有这些前提的情况下加上重试,会把故障放大三倍。subprocess.run 的 timeout 会杀死子进程并等待,标记文件只在成功之后才留下,flock 防止并发运行,SIGTERM 处理器不会留下孤儿进程。本实验把这四点全部放进一个小的执行器里。

素材命令位于 /opt/fixtures/pyops/bin/——flaky.sh(前两次失败,第三次成功;尝试次数保存在环境变量 FLAKY_STATE 所指的文件中)、hang.sh(60 秒都不结束)、apply.sh <이름>(占位符为名称)(每次调用都会往 APPLY_LOG,默认 /root/pyops/sub/applied.log 中追加一行;如果 APPLY_FAIL=1 则失败)。

步骤

  1. 创建 /root/pyops/sub/runner.py。runner.py run -- <명령...>(占位符为命令)用 subprocess.run 执行命令,把命令的标准输出原样输出到标准输出,并以命令的退出码结束。
  2. 加上 --timeout <초>(占位符为秒数)。超时后命令被杀死,标准错误中打印一行包含 timeout 的信息,并以退出码 124 结束。不能有命令进程残留。
  3. 加上 --retries <n> --backoff <초>(占位符依次为次数与秒数)。如果退出码不是 0,就等待 backoff × 2^(尝试次数-1) 秒,最多再尝试 n 次。超时(124)也视为失败并重试。
  4. 创建 runner.py apply <이름>(占位符为名称)。如果存在 /root/pyops/sub/state/<이름>.done(占位符为名称)标记,就打印 already applied 并以 0 结束,不调用 apply.sh。如果没有,就调用 /opt/fixtures/pyops/bin/apply.sh <이름>,并且只有成功(0)时才创建标记。失败则不留标记,以 1 结束。
  5. apply 在 /root/pyops/sub/state/lock 上获取 fcntl.flock(LOCK_EX | LOCK_NB) 锁。拿不到锁时,往标准错误打印 another run in progress,并以退出码 3 结束。
  6. 收到 SIGTERM 时,把 SIGTERM 转发给正在运行的命令,等它结束,然后以退出码 143 结束。不能有命令进程残留。
  7. 每次尝试都往 /root/pyops/sub/runs.jsonl 追加一行 JSON。键是 ts(ISO 8601 字符串)、cmd(字符串列表)、attempt(从 1 开始)、rc(整数)、duration_ms(整数)。
  8. apply --dry-run <이름>(占位符为名称)不调用 apply.sh,也不创建标记,而是把要做的事连同名称一起写到标准输出,并以 0 结束。如果该名称已经应用,就写 already applied。

参考

执行命令并原样返回退出码

创建 /root/pyops/sub/runner.py。runner.py run -- <명령...>(占位符为命令)用 subprocess.run 执行命令,把标准输出原样输出,并以命令的退出码结束。

用 argparse 的 subparsers 创建 run,并用 nargs=argparse.REMAINDER 接收 command。用列表传参,不要使用 shell=True。返回 r.returncode 就行。

让永不结束的命令结束

加上 --timeout <초>(占位符为秒数)。超时后命令被杀死,标准错误中出现一行包含 timeout 的信息,并以退出码 124 结束。不能有命令进程残留。

subprocess.run(timeout=...) 会在抛出 TimeoutExpired 之前杀死子进程并等待。如果使用 Popen,就用 start_new_session=True 启动,并用 os.killpg 杀死进程组——如果 hang.sh 的孙进程 sleep 握着管道,只杀死子进程,communicate() 就不会返回。

指数退避重试

加上 --retries <n> --backoff <초>(占位符依次为次数与秒数)。失败后等待 backoff × 2^(尝试次数-1) 秒,最多再尝试 n 次。超时也视为失败。

用 range(1, retries + 2) 循环,如果 rc == 0 就立即返回。等待的时间每次尝试翻倍。请删掉 FLAKY_STATE 文件,用 flaky.sh 来测试。

用标记实现幂等的 apply

runner.py apply <이름>(占位符为名称)在 /root/pyops/sub/state/<名称>.done 存在时,打印 already applied 并以 0 结束。如果不存在,就调用 apply.sh <名称>,并且只有成功时才创建标记。失败则不留标记,以 1 结束。

标记只在命令以 0 结束之后才用 write_text 写入。如果在执行之前创建,失败的变更就会被留为“已应用”。请用 APPLY_FAIL=1 测试失败路径。

用锁防止并发运行

apply 在 /root/pyops/sub/state/lock 上获取 fcntl.flock(LOCK_EX | LOCK_NB)。拿不到时,往标准错误打印 another run in progress,并以退出码 3 结束。

用 open('w') 打开锁文件并保持打开,然后调用 fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)。如果 OSError 的 errno 是 EACCES 或 EAGAIN,说明有另一个运行持有着锁。

把 SIGTERM 转发给子进程

runner 收到 SIGTERM 时,向正在运行的命令发送 SIGTERM,等它结束,然后以退出码 143 结束。不能有命令进程残留。

用 signal.signal(signal.SIGTERM, handler) 挂上处理器,并在处理器中调用 Popen 对象的 send_signal(signal.SIGTERM)。处理器运行之后,如果 communicate() 返回,就返回 143。

每次尝试一行 JSON

每次尝试都往 /root/pyops/sub/runs.jsonl 追加一行 JSON。键是 ts、cmd(字符串列表)、attempt(从 1 开始)、rc(整数)、duration_ms(整数)。

以追加模式写入 json.dumps(dict) + '\n'。超时(124)也算一次尝试,所以要记录。把 time.monotonic() 的差值乘以 1000,转成整数。

只说要做什么,而不去做

apply --dry-run <이름>(占位符为名称)不调用 apply.sh,也不创建标记,而是把包含名称的计划写到标准输出,并以 0 结束。如果该名称已经应用,就写 already applied。

dry-run 可以在加锁之前处理,而不必等到加锁之后——因为它什么也不改变。只根据有没有标记,在两句话中选一句。