上周我给一批机器推了个配置项,脚本跑完告诉我:120 台里成了 114 台。我扫了一眼那 6 台失败的去查通道问题,剩下的就没管了。
隔了几天我随手抽了几台复查,发现有台机器的配置压根没变。我以为是我记错了机器名,又查了两台,还是没变。这时候我才反应过来:那 114 台里到底有多少是真的改成了,我根本不知道——我只知道脚本说它成功了。
我决定把这件事从头拆一遍。
「成功」这个词其实混着两件事
我以前写批量脚本,判断成功的条件就一行:if ($LASTEXITCODE -eq 0)。这行代码回答的是「命令觉得自己干成了吗」,而我真正想知道的是「设备现在是不是那个状态了」。这两个问题在大多数时候答案一样,所以我一直没出事;一旦不一样,我是最后一个知道的。
要量化这件事,我得先造一批「答案不一样」的机器。我写了个模拟器,120 台,按我这些年见过的比例塞了七种情况:
const plan = [
['ok', 0.60], // 报成功,也真生效
['silent', 0.14], // 报成功,压根没写进去(权限被吞了)
['errbut', 0.08], // 报错了,但其实写进去了
['delay', 0.08], // 报成功,但要等刷新才落地
['rollback', 0.05], // 报成功,也生效了,刷新周期一到被刷回去
['partial', 0.03], // 报成功,五个键只写了三个
['unreachable', 0.02], // 真失败
];真值我定义在「下发完 15 分钟后稳定生效」,短命的成功不算成功。
只看退出码:36 台是假的
第一档,完全按我以前的老写法:
A 只看退出码 报成功 114 | 真生效 82 | 假成功 36 | 假失败 4
B 退出码+输出关键字 报成功 114→107 | 真生效 82 | 假成功 36→29 | 假失败 4114 台「成功」里 36 台是假的,接近三成。这 36 台我全都会标绿,然后在报告里写「本次下发成功率 95%」。
加上输出关键字之后只救回 7 台,就是 partial 那一类——它其实在输出里说了 warning: 2 keys skipped,只是从来没人读。剩下的 29 台,退出码是 0,输出也漂亮,撒谎撒得很整齐。
我在这儿明白了一件事:退出码和输出都是设备自己说的话,不是设备的状态。想在"它说了什么"这一层打补丁,能打的洞很有限。
读回真实状态:我第一版还是错的
那就不听它说,自己去读。下发完立刻把配置读回来,跟目标值比:
C 独立通道立即读回 报成功 78 | 假成功 7 | 假失败 11假成功从 36 掉到 7,但冒出来 11 台假失败。我先以为程序写错了,查完发现是我自己蠢:「立刻读回」和「延迟生效」天生冲突——那 11 台还在待生效队列里,我读得太早了,把它们判成了失败。
更让我后背发凉的是这一档:
C2 同一通道读回 报成功 107 | 假成功 36这是我第一版的真实写法。下发走的是一条远程会话,读回我图省事复用了同一个会话——结果假成功一个没少,还是 36。我等于是问犯人他自己有没有作案。 那条通道既然能把写入失败吞成「成功」,它读回来的一样是这个假象。
等一个刷新周期再读,才终于干净
把读回挪到刷新之后:
D 独立通道延迟读回 300s 报成功 82 | 真生效 82 | 假成功 0 | 假失败 0干净了。我顺手扫了一遍「到底该等多久」:
等 0s 错判合计 18(假成功 7 + 假失败 11)
等 60s 错判合计 18(假成功 7 + 假失败 11)
等 90s 错判合计 11(假成功 0 + 假失败 11)
等 120s 错判合计 0
等 300s 错判合计 0拐点在 120 秒,正好是 max(延迟生效 120s, 回滚 90s)。这两个盲区方向是反的:读早了把「还没落地」当失败,读晚了把「已被刷回」当成功。等待窗口的下界由前一个决定,上界由后一个决定。
用真文件再跑一遍,别只信自己造的模拟器
上面那组数字我自己是不太敢用的,毕竟故障模式是我自己编的,我想让它多假它就多假。所以我又写了个版本:真在磁盘上建 120 个文件,故障也全是真的。
silent 那 22 台,我把它们的目标换成了同名目录,写的时候是真的撞 EISDIR;unreachable 那 2 台的父目录是真的不存在;rollback 是在刷新步骤里真的被另一个文件覆盖回去;partial 是真的只写了一行、少了一行。
A 只看退出码 报成功 114 | 真生效 82 | 假成功 36 | 假失败 4
B 退出码+输出关键字 报成功 107 | 真生效 82 | 假成功 29 | 假失败 4
C 立即读回(t+0) 报成功 78 | 真生效 82 | 假成功 7 | 假失败 11
D 刷新后读回 报成功 82 | 真生效 82 | 假成功 0 | 假失败 0和模拟版一模一样。两套代码、一套内存一套磁盘,最后给同一个答案,我才敢把这篇发出来。
但真实世界的刷新周期是 90 分钟,我等不起
上面那个 90 秒是我自己编的小周期。换成真实的策略刷新周期就崩了:
等 300s 复查 假成功 7 总耗时 5 分钟
等 1800s 复查 假成功 7 总耗时 30 分钟
等 3600s 复查 假成功 7 总耗时 60 分钟
等 7200s 复查 假成功 0 总耗时 120 分钟按微软 MS-GPOL 协议文档 的 Computer Policy Refresh Interval:非域控客户端是 90 分钟 + 0~30 分钟随机偏移,域控制器是 5 分钟;Group Policy processing 那页写的也是同一个数(核对日期 2026-10-23)。也就是说我要拿到真实答案,得挂机两小时。
所以我的做法不是干等,而是主动触发刷新再验:微软那页里写了可以用 gpupdate.exe 或 Invoke-GPUpdate 手动催一次。等不了的场景,我就老老实实把这批标成「未确认」,绝不标成功——宁可报告上挂几台待办,也不要一个漂亮的成功率。
重试救不回来,这是我最后放弃的那条路
读回不通过就重试,是我想当然以为有效的最后一招:
重试上限 0 报成功 82 | 挂起人工 38 | 下发次数 120 | 总耗时 5.1 分钟
重试上限 1 报成功 84 | 挂起人工 36 | 下发次数 158 | 总耗时 10.2 分钟
重试上限 2 报成功 84 | 挂起人工 36 | 下发次数 194 | 总耗时 15.2 分钟
重试上限 3 报成功 84 | 挂起人工 36 | 下发次数 230 | 总耗时 20.2 分钟重试 1 次多救回 2 台(就是那 2 台瞬时不可达的),重试上限从 1 加到 3,一台都没多救回来,白等 10 分钟、白下发 72 次。剩下那 36 台是「根本写不进去」和「写完被策略刷回去」,重来一百次也是同样的结果。
我给重试留了上限 1 次,再不通过就挂起人工。挂起 36 台听着难看,但比假装 114 台成功强得多。
我现在的写法:三段式
// 1. 目标写成「最终应该是什么」,不是「执行哪条命令」
const desired = { config: 'Hardened=1', audit: 'Audit=1' };
// 2. 读回必须走独立通道,且按完整契约比对(不然只改了一半也过)
function verify(host) {
const actual = readViaOtherChannel(host); // 关键:不是下发用的那条路
return deepEqual(pick(actual, Object.keys(desired)), desired);
}
// 3. 不通过 → 重试 1 次 → 还不过就挂起,绝不标成功
async function ensure(host) {
for (let i = 0; i < 2; i++) {
await apply(host);
await triggerRefresh(host);
if (verify(host)) return 'done';
}
return 'pending-human';
}写完这套我才意识到,这就是 PowerShell DSC 那个 Get/Test/Set 三段式——我绕了一大圈,最后撞回了微软早就写好的东西。它的资源创作清单里有一句我特别认同:应用完配置之后要单独调 Get-DscConfiguration 去核,因为「调用 Start-DscConfiguration 时不会出现该区域的问题」;同一篇还要求反复调 Set-TargetResource 验证幂等(核对日期 2026-10-23)。
我的判断
我以前把「命令返回 0」当成成功,本质上是我把执行权交出去了,却把验收权也一起交了出去。下发模块既是运动员又是裁判,它当然说自己跑完了。
现在我看到任何批量操作的结果,第一个问题不再是「成了几台」,而是「这个数字是谁报的」。报数的人如果也能决定自己算不算成功,那这个数就是废纸。
另外那个「同通道回读」的坑我记很久。它不报错、不失败、日志干净,只是让你白验一遍然后继续心安。这类 bug 比报错的 bug 危险得多。
