5 KB 的脚本被写成 2.6 KB:一次静默截断的排查

脚本提示「已更新」,语法检查却报 unexpected EOF。文件从 5516 字节变成 2600,正好是工具读取的默认上限。长度断言、原子替换、语法门禁三步怎么加。

有一次我在改一个入口层的配置文件,加四行安全响应头。脚本跑完,输出了一句「已更新」,我顺手执行了语法检查:

$ bash -n /opt/ops/finalize.sh
/opt/ops/finalize.sh: line 187: unexpected EOF while looking for matching `"'

一个本来能正常运行的 5 KB 脚本,改完变成 2.6 KB,末尾半句被切断了。

这次没有造成线上故障——脚本只是没人再执行,nginx 也没 reload。但如果我当时在同一个脚本里串了 reload,就会直接把一份语法错误的配置推上去。这件事之后,我把「读-改-写」这种模式彻底重新想了一遍。

一、先看数字:2600 是个可疑的整数

第一反应是脚本逻辑写错了,把某段删多了。但 wc -c 出来的两个数字很有意思:

$ wc -c /opt/ops/finalize.sh /opt/ops/finalize.sh.bak
 2600 /opt/ops/finalize.sh
 5516 /opt/ops/finalize.sh.bak

2600 不是「少了一段」该有的数字,太少、太圆。文件不是被逻辑删掉了一块,而是被截断在某个固定位置。这几乎只有一种可能:读取的时候只拿到了前 2600 个字符。

我们用的运维助手有一段「读取远端文件」的能力,它的默认输出上限就是 2600 字符——为了不让一次工具调用把上下文撑爆。脚本的逻辑是「读全文 → 正则替换 → 写回」,读取环节被 silently 截断,后面的替换和写回都是基于半份文件做的,写完当然是半份。

二、静默截断藏在哪些地方

这次之后我把手上所有「读-改-写」的脚本过了一遍,发现这类截断至少有四个入口,全都不会报错:

入口 默认限制 表现 后果
工具/助手的文件读取 常见 2000–4000 字符 返回内容看起来完整 写回即截断
SSH 远程执行输出 各家 32 KB–1 MB 不等 末尾没有报错 解析不完整
API 列表接口 默认 20/50/100 条 有 has_more 但没人看 只处理了第一页
命令输出 head/tail/管道缓冲 正好在整行边界 少几行但语法通顺

共同点是:截断发生在边界上,不会抛异常。尤其是内容被切在整行时,剩下的部分语法完全合法,你根本看不出来。

判断方法很土但有效:每次读文件后打印长度,和 stat 的结果比一下。

echo "$(wc -c < "$F") bytes"; printf '%s' "$CONTENT" | wc -c

两个数字不一致,就是被截断了。

三、修法:读的时候显式给上限,写之前断言

修的时候没有换工具,而是把「断言」加进了流程。核心是三条:读的时候给足上限、写之前比长度、写之后做语法检查。

def patch_file(path, transform, min_ratio=0.95):
    with open(path, encoding="utf-8") as f:
        origin = f.read()
    raw_len = os.path.getsize(path)

    # 1) 读全了吗?按字节数和字符数双重校验
    if len(origin.encode("utf-8")) < raw_len * min_ratio:
        raise SystemExit("读取不完整:%d / %d 字节,拒绝继续" % (
            len(origin.encode("utf-8")), raw_len))

    new = transform(origin)

    # 2) 改完是不是少了太多?正常修改不该掉 5% 以上
    if len(new) < len(origin) * min_ratio:
        raise SystemExit("结果异常变短:%d -> %d,拒绝写入" % (len(origin), len(new)))

    # 3) 备份 + 原子替换,避免写到一半断电留下半个文件
    bak = path + ".bak." + time.strftime("%Y%m%d-%H%M%S")
    shutil.copy2(path, bak)
    tmp = path + ".tmp"
    with open(tmp, "w", encoding="utf-8") as f:
        f.write(new)
        f.flush()
        os.fsync(f.fileno())
    os.replace(tmp, path)
    return bak

三个断言各有分工:第一个防「读少了」,第二个防「改多了或改少了」,第三个防「写一半」。第三个容易被忽略,但文件系统在断电、磁盘满的时候真的会留下半个文件。

写完之后还有一道门禁,不能省:

bash -n /opt/ops/finalize.sh && nginx -t && nginx -s reload

&& 串起来的意义是:任何一步失败都停在那里,不要继续往下 reload。我见过把 nginx -t 和 reload 用分号串起来的脚本,语法错了照样 reload,然后整站 502。

四、为什么当时没人发现

复盘的时候我一直在想:这个错误为什么会漏过去?三个原因。

第一,脚本输出「已更新」。这个提示是脚本自己打的,不校验任何东西,只要 write() 没抛异常就打。它给人一个「成功了」的错觉。

第二,没有立刻 reload。改动落盘,但没生效,所以线上毫无反应——没反应也就没人查。这反而是运气:如果当时串了 reload,就是一次真实的故障。

第三,没有回滚演练。备份是有的,但没人验证过「用备份能不能恢复」。真出事的时候,你才会发现备份文件是改之前那一刻复制的——正好是好的那一版,这一点算运气好。

五、把「写配置」变成一次可撤销的变更

后来我们把这些脚本统一改成一个模式,叫「一次变更五个动作」:

  1. 读全文,校验长度;
  2. 备份到带时间戳的文件;
  3. 在临时文件里做替换;
  4. 语法检查通过后,原子替换;
  5. 打一行结构化日志(改了什么、从多少字节到多少字节、备份在哪)。

第五步的日志很重要。出了事第一句话就能答上来「改了什么、什么时候改的、回哪一版」,不用去翻 shell history。

另外,我们还给这类脚本加了一个 --dry-run:只打印 diff,不落盘。改配置之前先看一眼 diff,能挡掉大部分手误。

/opt/ops/finalize.sh 5 --dry-run | diff -u /etc/waf/sites-enabled/site-5.conf - | head -40

dry-run 的实现不用很复杂,把「写文件」这一步换成一个内存里的字符串输出就行:

if [ "${DRYRUN:-0}" = "1" ]; then
  printf '%s\n' "$NEW_CONTENT" > /tmp/preview.$$
  diff -u "$CONF" /tmp/preview.$$ || true
  rm -f /tmp/preview.$$
  exit 0
fi

用法上有个习惯值得养成:任何会改生产配置的脚本,先跑一次 dry-run,看过 diff 再真跑。这一步花十秒,能省掉很多「改完才发现改错了」的来回。

六、顺带修掉的两个同类问题

查这个问题的时候,顺手发现两个同源的坑。

一个是 API 分页。我们的一个清理脚本只处理了第一页(默认 20 条),跑了三个月都「正常」,实际上只清了 20/1300 条记录。后来加了 while has_more 循环,并且断言「处理条数 == 接口返回的总数」。

另一个是远程命令输出。有个脚本用 ssh host "cat big.log | grep ERROR" 收集错误,输出超过限制被截断,统计出来的错误数永远是那个上限值。改成先重定向到远端临时文件,再用 rsync 拉回来,长度就完整了。

这两个和配置文件截断是同一类问题:把「有限制的通道」当成了「无损的通道」。凡是跨进程、跨网络、跨工具边界传数据,都要假设它会截断,然后主动校验。

小结

  • 文件突然变成 2600 这种整数长度,先怀疑读取被截断,不要先怀疑逻辑写错。
  • 任何「读-改-写」脚本都要有长度断言:读到的字节数和文件大小比,写回的长度和原文比。
  • 截断不会报错,而且常常切在整行边界,剩下的内容语法完全合法,肉眼看不出来。
  • 备份 + 临时文件 + os.replace 原子替换,比直接 open(path, "w") 安全得多。
  • 语法检查和 reload 必须用 && 串起来,错误就停,不要带着坏配置继续 reload。
  • 跨进程、跨网络、跨工具传数据时,默认假设它会截断:先落文件再拉,或者循环翻页并断言总数。
查看「工程效能」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。

5 KB 的脚本被写成 2.6 KB:一次静默截断的排查 - 起航社区