为什么自动化脚本要默认演练:六组实测,与"演练报 20、实际改 14"

举报
yd_263929376 发表于 2026/11/02 19:21:55 2026/11/02
【摘要】 为什么自动化脚本要默认演练:六组实测,与"演练报 20、实际改 14"--dry-run 是自动化脚本里最容易被写成安慰剂的一个参数。它通常长这样:扫描一遍、打印"将处理 N 个对象"、退出 0。跑完屏幕上有个数字,人看一眼就放心了。但那个 N 和真正执行时改动的东西之间,可能差着 6 个对象、20 行重复写入和 5 个凭空出现的文件。我在本机用 Python 建了六组夹具,每组都是 20...

为什么自动化脚本要默认演练:六组实测,与"演练报 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。人默认走安全路径,脚本才敢在无人值守时调用。

五、演练输出要能回答三个问题

一份合格的演练报告,不该只有一个总数,而应该是三栏:

  1. 会动哪些对象——带路径和内容指纹,可逐条复核;
  2. 哪些不动、为什么不动——tmp- 前缀、已是最新、目标不存在;
  3. 哪些现在判断不了——被占用、权限未知、依赖上一项的结果。

拿第一节那 20 个配置文件说:如果计划阶段顺手跑一次 os.access,输出就会从"将更新 20 个"变成"可写 14、只读 6、判断不了 0"。同一份数据,后一种写法让人在执行前就知道结果,前一种只是让人以为知道。

第三栏尤其容易被省掉,但它恰恰是最该保留的部分。一个演练如果宣称"判断不了 0",通常意味着它压根没检查,而不是真的没有不确定项。

六、结论

演练的价值不在于让你少改一次,而在于把不可逆的动作变成两个能互相校验的数字:计划里写了什么、执行时确认了什么。做不到这一步的 --dry-run 只是一个打印语句,它甚至会让风险更高——屏幕上那个漂亮的"N 个",给了你一个不该有的安心。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。