故障复盘 #003:cron 任务里未转义的 `%` 导致命令被静默截断
静默失败
| 项目 | 内容 |
|---|---|
| 日期 | 2026-09-13 |
| 环境 | Alibaba Cloud Linux 3 / cronie / bash 4.4 |
| 组件 | cron 定时任务(流量监控告警) |
| 影响 | 任务静默失败 —— 无报错、无输出、无告警,看起来像"没执行" |
| 发现方式 | 用带唯一标记的验证任务实测 cron 是否真的执行 |
| 严重级别 | 高(静默失败是运维中最危险的失败模式) |
| 处理耗时 | 约 20 分钟(含两次被推翻的错误结论) |
1. 现象
我为流量监控写了 cron 任务,为了确认它真的会执行,创建了一个每分钟运行的测试任务, 把当前时间写入一个带唯一标识的标记文件:
* * * * * root /bin/bash -c 'echo "MARKER=$TOKEN" > /tmp/$TOKEN.marker; \
echo "time=$(date "+%F %T")" >> /tmp/$TOKEN.marker; \
echo "parent=$(ps -o comm= -p $PPID)" >> /tmp/$TOKEN.marker'
等待 75 秒后,标记文件始终没有出现。
crond 服务是 active,文件权限是 644 root:root,系统时间是 Asia/Shanghai, crond 的日志里确实有执行记录:
Sep 13 15:09:01 CROND[17641]: (root) CMD (/bin/bash -c 'echo "MARKER=cronproof..." > /tmp/cronproof...
; echo "time=$(date "+)
但命令明显被截断了 —— 在 date "+ 之后就没有了。
2. 排查路径(含两次错误结论)
这次排查最有价值的部分不是结论,而是两次被自己推翻的错误假设。
错误假设 1:「cron 没有执行任务」
第一版验证脚本用 find -newermt '-80 seconds' 查找新生成的报告文件,找到了一个, 于是判定"cron 执行验证通过"。
这个结论是错的,而且是假阳性。 因为那个"新文件"其实是我在配置阶段 手动运行 traffic-check.sh 时产生的,跟 cron 毫无关系。
教训:验证脚本本身也会骗人。 用时间窗口去"找新文件"是脆弱的判断方式, 因为它无法区分"cron 产生的"和"我手动产生的"。 正确做法是用本次运行唯一的标记(唯一 token)。
错误假设 2:「cron 根本不会读取这个文件」
看到任务没执行,自然会怀疑文件本身。我的第二版脚本检查了权限(644 ✅)、 行尾(无 CRLF ✅)、结尾换行(✅),还检查了字段数,甚至把 SHELL=/bin/bash、PATH=...、MAILTO="" 这些合法的环境变量设置 误判为"字段不足 7 个,cron 会忽略此行"。
这也是错的。 cron.d 文件里 NAME=value 形式的环境变量赋值是完全合法的语法, 我的检查器规则写得太粗糙。
真正的方法:对照实验
既然"文件有问题"和"执行有问题"两种可能都存在,就必须设计能区分的实验。
于是同时放两组任务:
| 组 | 命令特点 | 预期 |
|---|---|---|
| 实验组 | 不含 % 字符 | 应该成功 |
| 对照组 | 含未转义 % (date "+%F %T") | 可疑 |
结果:
── 实验组(不含 %)──
✅ 文件已生成
MARKER=noPct176971789283371
Sun Sep 13 03:10:01 PM CST 2026
parent=crond ← 关键证据:父进程是 crond
── 对照组(含未转义 %)──
✅ 文件未生成 —— 复现了失败现象
结论清晰了:
parent=crond证明 cron 确实在执行这些任务,cron 机制本身没问题;- 两组唯一的变量是
%,而只有含%的那组失败 →%就是根因。
3. 根因:crontab 中 % 是特殊字符
这是 crontab 的一条历史悠久但极易被忽略的规则(见 man 5 crontab):
The "sixth" field (the rest of the line) specifies the command to be run. Percent-signs (%) in the command, unless escaped with backslash (\), will be changed into newline characters, and all data after the first % will be sent to the command as standard input.
翻译过来就是:
- 命令中的
%默认被转换成换行符 - 命令从第一个未转义的
%处被截断 %之后的内容变成该命令的标准输入
所以我们那条命令:
/bin/bash -c 'echo "time=$(date "+%F %T")" >> /tmp/x.marker; ...'
被 cron 转换成了:
/bin/bash -c 'echo "time=$(date "
%T")" >> /tmp/x.marker; ... ← 这行变成了标准输入,不再是指令的一部分
结果就是 /bin/bash -c 'echo "time=$(date " —— 一个引号不闭合的残缺命令, bash 直接报语法错误退出,什么都没写。
而因为 cron 默认把输出发邮件(本机没有配置邮件),错误信息被丢弃了, 从外面看就是"任务静默地什么也没做"。
4. 修复
方案 A:转义 %
* * * * * root /bin/bash -c 'echo "time=$(date "+%%F %%T")" >> /tmp/x.marker'
注意:在 crontab 里要写成 \%。如果外面还套了一层 shell 引号, 可能需要双重转义,很容易写错。
方案 B:不在 crontab 命令里使用 %(推荐)
把带 % 的逻辑放进脚本文件,crontab 里只调用脚本:
5 * * * * root /root/ops-lab/bin/traffic-check.sh >> /root/ops-lab/logs/traffic/cron.log 2>&1
脚本内部可以随意用 date +%F,因为 % 的特殊含义只存在于 crontab 文件里, 一旦进入 shell 脚本就恢复正常了。
本项目的两个任务都采用了方案 B,所以没有受影响:
--- ops-lab-daily-report ---
✅ % 已正确转义 (这里用的是 \%Y\%m\%d,因为要生成动态文件名)
--- ops-lab-traffic ---
✅ 不含 % 字符 (全部逻辑在脚本内,crontab 行最干净)
顺便记下另一个重点:crontab 的 % 只在"命令字段"生效
% 问题只影响第六个字段(命令)。前面的时间字段里 % 没有特殊含义 (但时间字段里本来也不该出现 %)。
5. 修复验证
用同样的对照实验确认修复后的写法可用:
实验组(脚本调用,不含 %)→ ✅ 标记文件生成,parent=crond
并且正式任务的转义检查全部通过(见上文输出)。
6. 沉淀的经验
6.1 静默失败是最危险的失败模式
如果这次没有被"验证一下"的念头驱动,这个 bug 会以如下方式存在:
- cron 任务看起来配置正确(文件在、权限对、语法像是对的)
- 系统没有任何报错(错误被 cron 试图邮件发送后丢弃)
- 监控不会告警(因为监控任务自己就是坏的)
结果就是:你以为流量有监控,实际上没有。 直到某天额度被刷爆才发现。
防范手段:任何自动化任务都必须有"它真的跑过"的可观测证据, 不能只看"配置存在"。
6.2 验证脚本本身必须被验证
本次两个错误结论都来自验证脚本的缺陷:
| 版本 | 缺陷 | 后果 |
|---|---|---|
| v1 | 用时间窗口找文件 | 假阳性:把手动运行的结果当成 cron 的 |
| v2 | 字段检查规则过粗 | 误报:把合法的环境变量当语法错误 |
手段:用唯一标识代替时间窗口;对检查规则也要准备正反例。
6.3 对照实验是定位根因的最强工具
当"很多假设都可能成立"时,不要逐个去猜,而是设计一个能区分的实验。 本次同时放两组任务,唯一变量是 %,一次实验就锁定了根因。
这比"先改权限试试、再改行尾试试"的试错法高效得多,而且结论是可信的。
6.4 cron 的经典陷阱清单(本次实践验证)
| 陷阱 | 说明 | 规避 |
|---|---|---|
% 被转成换行 | 命令被截断,静默失败 | 用 \% 或把逻辑放进脚本 |
| PATH 极简 | /usr/local/bin 等不在 PATH 里 | 用绝对路径,或在 cron.d 里设 PATH |
| 环境变量缺失 | 手工运行正常,cron 里失败 | 显式声明所需变量 |
| 工作目录不定 | 相对路径失效 | 一律用绝对路径 |
| 输出被丢弃 | 错误信息看不到 | 重定向到日志文件 |
| cron.d 权限要求 | 可执行或全局可写会被忽略 | 用 644 |
| 缺少结尾换行 | 最后一行可能被忽略 | 确保文件以换行结尾 |
附:可复用的 cron 任务自检命令
# 1. 检查所有 cron.d 文件的基础健康度(权限/行尾/结尾换行)
for f in /etc/cron.d/*; do
printf '%-28s perm=%s ' "$(basename "$f")" "$(stat -c '%a' "$f")"
file "$f" | grep -q CRLF && printf 'CRLF! ' || true
[ -n "$(tail -c 1 "$f")" ] && printf 'no-trailing-newline! ' || true
echo
done
# 2. 找出含未转义 % 的任务行(潜在静默失败)
grep -nE '^[^#]*[^\\]%' /etc/cron.d/* | grep -v '^[^:]*:[0-9]*:[A-Za-z_]*='
# 3. 确认 cron 真的执行过(看 journal)
journalctl -u crond --since today | grep CROND | tail -20
# 4. cron 任务的标准写法模板
cat <<'TEMPLATE'
SHELL=/bin/bash
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=""
# 时间字段 用户 命令(全部绝对路径,重定向输出,% 已转义或不含 %)
5 * * * * root /root/ops-lab/bin/your-script.sh >> /var/log/your-script.log 2>&1
TEMPLATE
核心原则:cron 任务的正确性不能靠"看起来对",必须有"它跑过"的证据。