-今日
-本周
-本月
-累计
项目工作台运维 个人项目与实践记录

故障复盘 #003:cron 任务里未转义的 `%` 导致命令被静默截断

静默失败

日期2026-09-13
环境Alibaba Cloud Linux 3 / cronie / bash 4.4
组件cron 定时任务(流量监控告警)
影响**任务静默失败** —— 无报错、无输出、无告警,看起来像"没执行"
发现方式用带唯一标记的验证任务实测 cron 是否真的执行
严重级别**高**(静默失败是运维中最危险的失败模式)
处理耗时约 20 分钟(含两次被推翻的错误结论)
项目内容
日期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/Shanghaicrond 的日志里确实有执行记录

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/bashPATH=...MAILTO="" 这些合法的环境变量设置 误判为"字段不足 7 个,cron 会忽略此行"。

这也是错的。 cron.d 文件里 NAME=value 形式的环境变量赋值是完全合法的语法, 我的检查器规则写得太粗糙。

真正的方法:对照实验

既然"文件有问题"和"执行有问题"两种可能都存在,就必须设计能区分的实验

于是同时放两组任务:

命令特点预期
实验组不含 % 字符应该成功
对照组含未转义 %date "+%F %T"可疑

结果:

── 实验组(不含 %)──
  ✅ 文件已生成
     MARKER=noPct176971789283371
     Sun Sep 13 03:10:01 PM CST 2026
     parent=crond                      ← 关键证据:父进程是 crond
── 对照组(含未转义 %)──
  ✅ 文件未生成 —— 复现了失败现象

结论清晰了:

  1. parent=crond 证明 cron 确实在执行这些任务,cron 机制本身没问题;
  2. 两组唯一的变量是 %,而只有含 % 的那组失败 → % 就是根因

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 会以如下方式存在:

结果就是:你以为流量有监控,实际上没有。 直到某天额度被刷爆才发现。

防范手段:任何自动化任务都必须有"它真的跑过"的可观测证据, 不能只看"配置存在"。

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 任务的正确性不能靠"看起来对",必须有"它跑过"的证据。