为什么自动化脚本要默认演练:六组实测,与"演练报 20、实际改 14"
为什么自动化脚本要默认演练:六组实测,与"演练报 20、实际改 14"
--dry-run 是自动化脚本里最容易被写成安慰剂的一个参数。它通常长这样:扫描一遍、打印"将处理 N 个对象"、退出 0。跑完屏幕上有个数字,人看一眼就放心了。但那个 N 和真正执行时改动的东西之间,可能差着 6 个对象、20 行重复写入和 5 个凭空出现的文件。
我在本机用 Python 建了六组夹具,每组都是 20 个文件,把常见写法逐个跑一遍。下面的数字都是实跑的,不是估算。
一、演练说 20,实际只改了 14
夹具:20 个 .cfg,其中后 6 个 os.chmod(p, 0o444) 置为只读。脚本是典型的双分支写法:
def sync(dry):
items = sorted(cfg_dir.glob("*.cfg"))
if dry:
print(f"[dry-run] 将更新 {len(items)} 个配置文件")
return 0
ok = fail = 0
for p in items:
try:
with p.open("a", encoding="utf-8") as f:
f.write("#rev2\n")
ok += 1
except OSError as e:
fail += 1
print(f"! {p.name}: {e.__class__.__name__} errno={e.errno}")
return 0 if fail == 0 else 1
跑出来的结果:
[dry-run] 将更新 20 个配置文件
! app-15.cfg: PermissionError errno=13 Permission denied
...(共 6 条)
[apply] 成功 14 / 失败 6
exit_code dry-run=0 apply=1
6 个对象的落差全部来自"只读"这一个属性,而它在计划阶段完全可见:os.access(p, os.W_OK) 或 p.stat().st_mode 都读得到。不是信息不够,是根本没去读。更值得注意的是退出码——演练退出 0,放进 CI 就是绿灯,而这条流水线下次真跑必然返回 1。
还有一个反直觉的发现。同一批只读文件,用 ctypes 直接调 DeleteFileW,返回 0、GetLastError() 为 5(拒绝访问)、文件仍在;而在我这台机器的运行环境里,os.remove 被包装成了"移入回收站",走的是重命名语义,于是"删除成功 20/20"。同一个动作名,两套语义。这是后面所有分歧的总根源:演练和执行往往不是同一个实现。
二、三种结构性偏差,不是写得不够仔细
过滤条件只写在执行分支里
20 个 .dat,把其中 5 个改名成 tmp- 前缀。dry-run 数 glob("*.dat") 得 20;apply 循环里有一句 if p.name.startswith("tmp-"): continue,实际同步 15 个。
这不是笔误,是结构问题:只要"计划"和"执行"是两段各自遍历的代码,它们迟早会漂移。改了一处忘另一处,间隔越久越难发现。
演练分不清"还没跑"和"已经跑过"
对 20 个文件各追加一行,跑一次总行数 20,再跑一次 40。而两次的 dry-run 输出逐字节相同,都是"将向 20 个文件各追加 1 行"——文件还在、数量没变,演练看的只有数量。
这说明 --dry-run 本身不携带任何幂等性信息。要幂等,判据只能是内容(哈希、版本号、标记行),不能是"有几个文件"。
演练自己就有副作用
为了"只读地检查能不能写",常见写法是 open(p, "a")。实测 20 个已有文件加 5 个不存在的路径,探权限抛错 0 次,目录条目从 20 变成 25:5 个 0 字节文件被创建了出来。追加模式在目标不存在时会新建,这不是检查,是写入。
所以"纯只读的演练"和"能预知失败的演练"之间存在真实矛盾:要预知权限、占用、配额,就得碰真实资源;不碰,就只能印一个乐观的数字。
三、时间差:计划与执行之间世界会变
扫描到 20 个 .json,扫完立刻新建第 21 个,再按原清单执行:处理 20 个,剩 1 个未处理,目录里现在有 21 个。全程没有报错,退出码 0,账面上"全部完成"。
云上版本更常见:列出 20 台实例、逐台关机,中途有人新开一台,或者某一台在列表和执行之间被释放了。前者被漏掉,后者报"资源不存在"——而后者其实是成功。本机 KooCLI 的日志里就躺着这种账:44 行、4704 字节的记录中,6 次失败调用有 5 次是良性的(EIP.7922 已绑定 2 次、VPC.0602 规则不存在 2 次、EIP.7904 资源不存在 1 次),只有 STS5.1106(agency 不存在)是真错,良性占 83.3%。一个只数条数的演练,会把这 5 次算成 5 个待办,执行完却看到 5 个"失败"。
四、把演练做成可校验的计划
要修的不是文案,是数据流:演练产出一个计划文件,执行只消费这个计划,并且逐条校验。
# 1) 演练:只读扫描,把动作和对象指纹一起落盘
plan = [{"name": p.name, "sha": sha256(p)} for p in sorted(d.glob("*.conf"))]
Path("plan.json").write_text(json.dumps(plan, ensure_ascii=False), encoding="utf-8")
# 2) 执行:先校验,指纹不符就拒绝,不猜
applied = refused = 0
for it in plan:
p = d / it["name"]
if not p.exists() or sha256(p) != it["sha"]:
refused += 1 # 计划已过期,交给人判断
continue
p.write_bytes(p.read_bytes() + b"|v2")
applied += 1
sys.exit(3 if refused else 0)
实测:20 条记录、plan.json 1163 字节、20 个哈希互不相同;执行前故意改掉其中 2 个文件,结果是执行 18、拒绝 2、退出码 3。
三个细节决定它能不能长期有效:
- 退出码要有第四种状态。0 全成功、1 全失败、3 部分完成(有拒绝),比"有没有报错"信息量大得多。
- 计划要有指纹。文件名不够,路径加内容哈希才算"同一个对象"。
- 默认顺序反过来。不带参数就是演练,要真改必须显式加
--apply。人默认走安全路径,脚本才敢在无人值守时调用。
五、演练输出要能回答三个问题
一份合格的演练报告,不该只有一个总数,而应该是三栏:
- 会动哪些对象——带路径和内容指纹,可逐条复核;
- 哪些不动、为什么不动——
tmp-前缀、已是最新、目标不存在; - 哪些现在判断不了——被占用、权限未知、依赖上一项的结果。
拿第一节那 20 个配置文件说:如果计划阶段顺手跑一次 os.access,输出就会从"将更新 20 个"变成"可写 14、只读 6、判断不了 0"。同一份数据,后一种写法让人在执行前就知道结果,前一种只是让人以为知道。
第三栏尤其容易被省掉,但它恰恰是最该保留的部分。一个演练如果宣称"判断不了 0",通常意味着它压根没检查,而不是真的没有不确定项。
六、结论
演练的价值不在于让你少改一次,而在于把不可逆的动作变成两个能互相校验的数字:计划里写了什么、执行时确认了什么。做不到这一步的 --dry-run 只是一个打印语句,它甚至会让风险更高——屏幕上那个漂亮的"N 个",给了你一个不该有的安心。
- 点赞
- 收藏
- 关注作者
评论(0)