别让重试制造第二次故障
目标
让调用外部命令的 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 则失败)。
步骤
- 创建
/root/pyops/sub/runner.py。runner.py run -- <명령...>(占位符为命令)用 subprocess.run 执行命令,把命令的标准输出原样输出到标准输出,并以命令的退出码结束。 - 加上
--timeout <초>(占位符为秒数)。超时后命令被杀死,标准错误中打印一行包含timeout的信息,并以退出码 124 结束。不能有命令进程残留。 - 加上
--retries <n> --backoff <초>(占位符依次为次数与秒数)。如果退出码不是 0,就等待 backoff × 2^(尝试次数-1) 秒,最多再尝试 n 次。超时(124)也视为失败并重试。 - 创建
runner.py apply <이름>(占位符为名称)。如果存在/root/pyops/sub/state/<이름>.done(占位符为名称)标记,就打印already applied并以 0 结束,不调用 apply.sh。如果没有,就调用/opt/fixtures/pyops/bin/apply.sh <이름>,并且只有成功(0)时才创建标记。失败则不留标记,以 1 结束。 apply在/root/pyops/sub/state/lock上获取 fcntl.flock(LOCK_EX | LOCK_NB) 锁。拿不到锁时,往标准错误打印another run in progress,并以退出码 3 结束。- 收到 SIGTERM 时,把 SIGTERM 转发给正在运行的命令,等它结束,然后以退出码 143 结束。不能有命令进程残留。
- 每次尝试都往
/root/pyops/sub/runs.jsonl追加一行 JSON。键是ts(ISO 8601 字符串)、cmd(字符串列表)、attempt(从 1 开始)、rc(整数)、duration_ms(整数)。 apply --dry-run <이름>(占位符为名称)不调用 apply.sh,也不创建标记,而是把要做的事连同名称一起写到标准输出,并以 0 结束。如果该名称已经应用,就写already applied。
参考
- subprocess.run(cmd, timeout=...) 会在抛出 TimeoutExpired 之前杀死子进程并等待。如果直接使用 Popen,就要用
start_new_session=True启动,并用os.killpg(os.getpgid(proc.pid), sig)向整个进程组发送信号——hang.sh 是由 shell 启动sleep 60的,所以如果只杀死 shell,孙进程 sleep 就会握着管道残留下来。 - flock 是加在已打开的文件描述符上的。请在运行结束之前一直保持锁文件打开。进程死亡时,内核会释放它。
- 测试:
FLAKY_STATE=/tmp/f1 python3 runner.py run --retries 3 --backoff 0.2 -- /opt/fixtures/pyops/bin/flaky.sh - 常见错误:用 shell=True 传入字符串,在命令执行前就创建标记,在重试之间以相同的间隔等待。
执行命令并原样返回退出码
创建 /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 可以在加锁之前处理,而不必等到加锁之后——因为它什么也不改变。只根据有没有标记,在两句话中选一句。