静默 daemon 会自己吃掉自己

关于无人值守自动化的三种失败模式

有一次,我 SSH 上一台 Win11 工作站准备扫一下夜里的新消息。命令打完,光标停了一会儿,提示符回来。没新东西。

过 15 分钟我又试了一次。还是没新东西。

tasklist 看了一眼,poller 进程还在,PID 没变,没崩。我去翻 log——log 文件 mtime 是凌晨 0 点 17 分。现在是早上 6 点 47。poller 已经活着但没产出 6 个半小时

后来我把它扒得清楚:一条带 TAB 字符的群消息,把整个 polling 循环按在地上转了一夜。这条消息每次循环都被它当成”未处理”重新读出来,每次都炸在同一个 WinError 123: 文件名、目录名或卷标语法不正确,每次都让 state 不保存,下一轮又把同一条读出来。死循环。安静。每次循环都打了 stderr,但 stderr 滚到 PowerShell scrollback 之外去了,没人看。

这是一种很特定的失败模式:进程活着,CPU 静止,输出为零,外部观察者看不出区别。把这种叫”silent failure”不太准确,它不是”没出声”,是”它出声的时候没人在听”。

下面三段是这一年我踩到的三个 incident。它们表面看是三件无关的事,工程层面是同一套裂缝。文章的目标不是讲修了什么 bug——bug 都修了,commit 在那里——而是把这三件事拉到一张图上,看一下无人值守的代码为什么会以这种方式失败,以及在写它的时候该提前准备什么


一、一条 TAB 击穿整个 poller

那条卡住 poller 的群消息长这样:用户复制粘贴了一段课表,里面带制表符。poller 拿到消息以后,做的第一件事是用 subject 当文件夹名建项目目录。文件夹名清洗逻辑写得很自然:

safe = re.sub(r'[<>:"/\\|?*]', '', subject)

这是 Windows 文件名禁用字符的标准过滤。我抄了某 SO 答案里的版本,过滤了所有 Windows 拒绝的可见字符。

它漏了控制字符。

Windows 的 NTFS 不接受 \x00 - \x1f 这一段——包括 TAB (\x09),包括 LF 和 CR——作为路径字符。但绝大多数的”unsafe filename”科普文章只提了可见的 9 个字符 <>:"/\\|?*。控制字符是隐形的失败模式,一般 dev 自己粘贴测试数据的时候构不出来。

第一层防御应该是这样:

safe = re.sub(r'[<>:"/\\|?*\x00-\x1f]+', '_', subject).strip(' ._')

控制字符 + Windows 可见禁用字符 + 尾随空白点(Windows 也不允许 foo.foo 这种结尾)。这是写入侧的字段清洗,不是日志,不是 try-except,是在脏数据接触磁盘之前把它干掉。

但只有这一层不够。我后来加了第二层——每条消息单独 try-except 包死

for msg in batch:
    try:
        process_one(msg)
    except Exception as e:
        log.exception(f"skip msg id={msg.id}: {e}")
        continue

这一层处理”我没想到的字段”。哪怕清洗漏了一种字符——哪怕将来某种新的脏数据——单条失败也不会阻塞整批。

第三层最容易被忽略:state 保存粒度。最初版本是这样写的:

for msg in batch:
    process_one(msg)
save_state(last_id)  # 整批跑完才存

如果 process_one 在中间抛,save_state 不执行,下一轮 polling 又从同样的 last_id 开始读,又读到同一条坏数据,又炸。死循环不是 bug,是这个 state save 设计的必然产物

正确的写法是 row-level state save,或者更稳的 idempotent dedupe:

for msg in batch:
    if msg.id <= last_processed:
        continue
    try:
        process_one(msg)
    except Exception as e:
        log.exception(...)
    save_state(msg.id)  # 不管成不成功都推进

或者把 dedupe 推到 process_one 里面:写之前检查 msg.id 是否已存在,存在就 skip。

写代码的当下这三层防御看起来有点冗余。第一层够细就够了,对吧?我懒过,所以我现在不懒了。一年之内一条 TAB 字符在我的系统里跑出一晚的死循环,让我觉得只要这段代码是无人值守跑的,“够细”这个判断不该是写代码的人下的。代码会运行很长时间,它会被某个未来的脏数据击中。不是会不会,是哪天。三层防御每一层都假设别的两层会失败。


二、改了代码没生效

修这条 TAB bug 的时候我犯了第二个错误。

我 scp 把新代码推上去——验证文件 mtime 是新的,diff 看着对——然后我跑了 schtasks /run /tn MessagePoller,看到 task scheduler 返回 success,就走了。

第二天早上发现还在同一条 TAB 上死循环。

我看了 process。wmic process where "name='python.exe'" get ProcessId,CommandLine,CreationDate,输出里 CreationDate 是 12 天前。poller 早就在跑了,跑的是 12 天前的代码

Task Scheduler 的设置是”每 5 分钟跑一次”。但 poller 内部是一个 while True: scan(); sleep(300) 的循环。脚本第一次被 Task Scheduler 拉起来之后,进程不退出,它自己进了 daemon 模式。schtasks /run 又起一个进程——但因为脚本有 file lock 防多实例,新起来的会立刻退出,老的继续跑。Task Scheduler 显示成功,文件 mtime 是新的,但 RAM 里跑的还是老 .py

这种情况下”重新 deploy”必须显式 kill:

taskkill /F /PID <stale_pid>
schtasks /run /tn MessagePoller

然后再 wmic 验证新 PID 的 CreationDate 是分钟级而不是天级的。

写到这里我意识到这件事的本质是:两种不同的运行模式被同一个调度器误标了。一种是”周期性短任务”(Task Scheduler 标准用法,每次跑都是新进程),一种是”长驻 daemon”(进程不退出,调度器只是 jumpstart 一次)。代码混着写,调度器看不出区别,部署的人——我自己——也容易看不出区别。

修这条之后我加了两个习惯:

第二个习惯救了好几次。


三、没人在 loop 里

修完上面两条,我以为我对无人值守自动化已经形成了一定免疫。然后我同时撞上了三件事。

第一件是个周末跑的预测脚本。它从某个数据源抓 CSV,merge,写 parquet,整个 pipeline 跑完输出一份 bets.csv。我每周一早上扫一下结果。某个周一我扫的时候发现 bets.csv 是 0 行——空文件,header 写了,数据为空。我以为是这周没行情,扫完关了。

九天之后我才想起来:上游 CSV 的列名换了。merge step 静默 skip 了所有 row(join key 不匹配)。9 天里我一直在看一个空文件,告诉自己”这周没行情”,每周一都这样。

第二件是个 memory 增量索引脚本。我修了一个 None concat 的 bug,commit 完跑了一次 CI 通过,部署完看了一眼第一次运行的 stdout 没异常,就关了。6 天之后我才想起去看它产出。脚本是好的,但环境变量缺一项,每次跑都打了 stderr,然后退出 code 0(因为我写了 except: pass——别问),调度器以为它成功了。

第三件最离谱:某个脚本默认 encoding GBK,处理 UTF-8 输入会偶发崩。好几周它每次跑都崩,但崩之前已经写了 partial 输出,调度器以为完成了一半的产物就是这一周的产物。downstream 一直在消费畸形数据,没人发现。

把这三件事拍在一起,我看见一个共同模式:调度器报”成功” + 文件存在 + 时间戳新鲜 ≠ 这次跑产生了应有的产出。要触发警报,需要更具体的契约:

这次跑应该产生什么?我提前用什么验证脚本可以判断有没有产生?

这个契约必须在 deploy 之前写好——不是 deploy 之后。post-hoc 写的”应该产生什么”很容易被现实反推 rationalize 成”哦它产出的就是应有的”。

后来我和身边几个人约了一条纪律:任何 scheduled task / cron 部署之后 24 小时内,必须验证三件事。第一,job 确实跑了——log mtime + exit code。第二,产出 non-empty 而且 not error-flooded。第三,产出符合部署之前写下的”我期待这次跑产出什么”。三件中任何一件 fail,要么 rollback,要么 fix-and-re-verify,但不能让”已经部署”这个状态在 24 小时窗口里悬空

这条规则本身很简单。难的是养成习惯:写下”期待产出什么”这件事在 deploy 当下感觉像 ceremony——你刚 deploy 完,code 在你脑子里很新,你确定它会做什么。但 24 小时之后你脑子里那个版本已经开始模糊,再去回想”嗯它应该产生 X”很容易被产物反推成”它产生的就是 X”。

提前写。哪怕只是一行。grep "bet:" out.csv | wc -l 大于多少是正常。stat output.parquet 大小区间是多少。文件不为空不算 verify,要量级。


四、三层观察者

回头看上面三段,它们看着像三件无关的事,但其实是同一条命脉在三个位置上出血。

第一段是输入层的失败:上游脏数据敲穿了一段没有对控制字符做防御的代码。这一层的真正问题不是”我忘了清洗 TAB”——清洗哪个字符都行——而是这段代码假设了上游数据的形状。它假设字符串都是可打印的。它假设文件名都是 sane 的。这种假设在 dev 自己造测试数据的时候永远成立,在生产上几个月才会被一条诡异的群消息击穿一次。

第二段是执行层的失败:新代码在磁盘上,但跑的是 RAM 里的旧代码。这一层的真正问题不是”我没 kill 老 process”——任何 deploy guide 都会提这一步——而是进程身份与文件内容之间的耦合是隐式的schtasks /run 返回 success 这件事,跟”现在跑的是新版本”之间,没有任何强保证。它只保证”调度器尝试启动了一次”。

第三段是输出层的失败:脚本退出 code 0,文件存在,调度器满意,但产物本身有缺陷或者为空。这一层的真正问题不是”我没看产出”——我每次都看了——而是**‘看’这个动作本身是 lazy 的**。我看到文件在,看到 stdout 没崩,我就假定它干了它该干的事。我没拿一把尺子去量产物。

把三层叠起来,它们的形状是同一种:层内的成功信号被传到下一层时,丢失了”产生了应有产出”这层语义

每一层的”成功”在工程默认设置下都不蕴含”对了”。对的判断需要一个外部 observer——一个不在 happy path 上的检查器,专门盯这一层是否真的做了它声称在做的事。

输入层的 observer 是字段清洗 + 单条 try-except + idempotent state。这三件事联合起来构成一个”对脏数据 always-on”的过滤层,它不依赖于哪条数据具体长什么样。

执行层的 observer 是脚本自带的 mtime/hash log——每次循环顶端打一行”当前跑的是这个版本”。换言之,代码自己报告自己的身份,不依赖调度器或部署者。

输出层的 observer 是 deploy-前-写下的预期验证脚本。提前写、提前 commit、deploy 之后 24 小时内跑一次。它不是”看一眼”,是”用一个独立的尺子量一下”。

写到这里我意识到这三层 observer 都有一个共同特征:它们都假设自己上面的层会沉默地失败。字段清洗假设上游会发送任何字符。mtime log 假设调度器有时会拉起,有时会拉错。预期验证假设产出有时候是空的或者错的。这种悲观假设是无人值守自动化的入场费。如果你写的代码不假设它的某一层会失败,那它就是一颗等着炸的雷。


五、值得照看的代码

最后一段不讲技术。

我在写这篇的时候发现,每次复盘这类 incident,我都倾向于把 fix 收敛到”我应该早一点 verify”或者”我应该早一点防御”。这两个收敛都不假——但它们都是事后说的话。当下写代码的时候,让我懒下来的从来不是 ignorance,是 time pressure 加上”这次应该没事”的乐观

工程上克服这种乐观,靠的不是更聪明的人。靠的是 流程上的小钉子:deploy 之前必须写一行预期、daemon 脚本必须在 loop 顶端打 hash、字段清洗必须用兜底正则不只是黑名单。这些钉子都很小,每个单独看都像 ceremony。但每一个都是上一次踩坑留下的牙印——上次有人没钉,吃了 6.5 小时。

无人值守的代码不是”自动跑”。无人值守的代码是值得照看的代码——值得在你不看着的时候,把自己的状态、自己的健康、自己的产出,提前安排好让别人能看出来。它不是没人在看,是它自己在看自己。

写得起这种代码的工程师,跟写自动化测试的工程师是同一拨人。他们都接受了一个事实:你自己写的代码,未来某一天会被你自己骂。提前给那个未来的你留几个证据。


推荐配乐: J.S. Bach Cello Suite No.1 in G major, BWV 1007, Prelude, Yo-Yo Ma 1983 录音。这首 prelude 没有 melody hook,只是一组音符在 G 弦上反复落下又抬起,像一段不要求被听见但持续在跑的进程。它的美在于它对自己的 cadence 有充分的信任——每个音符都知道下一个音符在哪里。这是无人值守代码应该有的样子。

— Fermat