cron 手动跑没问题定时就不跑:一次 Linux 定时任务排查复盘

备份脚本手动执行完全正常,交给 cron 定时跑却次次失败、还悄无声息。排查梳理:crontab 五个字段、cron 执行环境与登录终端的天壤之别、任务输出默认发邮件要重定向到日志、用 /var/log/cron 分清触发与否、flock 防任务重叠,以及一套定时任务排查纪律。

2024 年,我写了一个数据库备份脚本,要让它每天凌晨 2 点自动跑。脚本本身我反复测过——在终端里手动一执行,备份文件稳稳当当地生成,日志也打得漂漂亮亮,毫无问题。我把它丢进 crontab,设了个 0 2 * * *,心满意足地下班了。第二天一看,该有的备份文件根本没出现。我以为是哪里写错了,又手动跑了一遍——依然完美。可第三天、第四天,凌晨 2 点的 cron 还是什么都没产出。一个脚本,我亲手执行它一百次都成功,交给 cron 在凌晨执行就失败,而且失败得悄无声息、连个错误都不报。这件事逼着我把 Linux 的 cron 定时任务、执行环境、任务调度这一整套彻底理清了。本文复盘这次实战。

问题背景

环境:CentOS 7,一个数据库备份脚本 /opt/scripts/backup.sh
事故现象:
- 手动执行 bash /opt/scripts/backup.sh —— 完全正常
- 放进 crontab 由 cron 执行 —— 到点没有任何产出
- cron 也不报错,失败得悄无声息

现场排查:
# 1. 确认 crontab 配了,语法也对
$ crontab -l
0 2 * * * /opt/scripts/backup.sh

# 2. 确认 cron 服务在跑
$ systemctl status crond
   Active: active (running)               # 服务没问题

# 3. 看 cron 日志,确认 2 点到底有没有触发
$ grep CRON /var/log/cron | grep backup
May 18 02:00:01 host CROND[12345]: (root) CMD (/opt/scripts/backup.sh)
# ★ 触发了!cron 准点执行了,问题在脚本【这次执行】本身

# 4. ★ 关键一步:让 cron 把脚本的输出留下来
#    临时把 crontab 改成:
0 2 * * * /opt/scripts/backup.sh > /tmp/bk.log 2>&1
#    第二天看 /tmp/bk.log:
$ cat /tmp/bk.log
/opt/scripts/backup.sh: line 8: mysqldump: command not found
# ★ 真相:mysqldump 命令找不到!

根因(后来想清楚的):
1. ★ cron 执行任务的环境,和我登录终端的环境【完全不同】。
2. 我手动跑脚本时,shell 加载了 .bash_profile / .bashrc,
   PATH 里有 /usr/local/mysql/bin,所以 mysqldump 找得到。
3. ★ cron 执行任务时,【不加载】我的任何 shell 配置文件,
   它给的 PATH 极短,默认就 /usr/bin:/bin 之类。
4. 于是 cron 环境里,mysqldump 不在 PATH 上 -> command not found。
5. 脚本里没重定向输出,错误信息被 cron 发进了本地邮件,
   我从来不看那个邮箱 -> 失败"悄无声息"。
"手动能跑、cron 不能跑",几乎总是【环境差异】。

修复 1:读懂 crontab——五个字段与基本操作

# === crontab 的基本操作 ===
$ crontab -l                    # 看当前用户的定时任务
$ crontab -e                    # 编辑(改完保存即生效)
$ crontab -r                    # ★ 删除【全部】任务(危险,别手滑)
$ crontab -u www -l             # 看指定用户 www 的任务(需 root)

# === ★ 一条 cron 规则:5 个时间字段 + 命令 ===
# ┌── 分钟 (0-59)
# │ ┌── 小时 (0-23)
# │ │ ┌── 日   (1-31)
# │ │ │ ┌── 月   (1-12)
# │ │ │ │ ┌── 星期 (0-7,0 和 7 都是周日)
# │ │ │ │ │
# * * * * *  要执行的命令
0 2 * * *      /opt/scripts/backup.sh        # 每天 2:00
*/10 * * * *   /opt/scripts/check.sh         # 每 10 分钟
0 9 * * 1-5    /opt/scripts/report.sh        # 周一到周五 9:00
30 3 1 * *     /opt/scripts/monthly.sh       # 每月 1 号 3:30
0 0 * * 0      /opt/scripts/weekly.sh        # 每周日 0:00

# === 时间字段里的特殊符号 ===
# *      每一个(每分钟/每小时…)
# */N    每隔 N(*/10 = 每 10 分钟)
# A-B    范围(1-5 = 周一到周五)
# A,B,C  枚举(0,30 = 第 0 和第 30 分)

# === ★ 一个经典误区:"每隔 N 小时"不是 0 */N ===
# 0 */5 * * *  不是"每 5 小时",而是在 0、5、10、15、20 点跑,
#   到 20 点之后,下一次要等到次日 0 点 —— 间隔并不均匀!
# cron 的字段是"匹配时刻",不是"间隔计时器"。

# === 系统级 crontab 与用户级的区别 ===
$ cat /etc/crontab              # 系统级:★ 比用户级多一个"用户"字段
# 分 时 日 月 周  用户  命令
  0  2  *  *  *   root  /opt/scripts/backup.sh
$ ls /etc/cron.d/               # 放系统级任务片段的目录
$ ls /etc/cron.{daily,hourly,weekly}/   # 丢脚本进去就会定期跑

修复 2:cron 的执行环境——这次的根因

# === ★ 核心认知:cron 的环境,和你登录终端的环境是两个世界 ===
# 你 ssh 登录,shell 会依次加载:
#   /etc/profile -> ~/.bash_profile -> ~/.bashrc ...
# 这些文件里:设了 PATH、定义了别名、export 了一堆变量。
# ★ 而 cron 执行任务时,【这些文件一个都不加载】。

# === cron 给任务的环境,极其简陋 ===
# - PATH 极短,通常只有 /usr/bin:/bin
# - 没有你 .bashrc 里设的任何变量、别名、函数
# - HOME、SHELL、LOGNAME 有,但别的基本都没有
# - 没有 TTY(所以任何需要交互的命令都会出问题)

# === ★ 亲眼看看 cron 环境长什么样 ===
# 临时加一条 cron,把它的环境 dump 出来:
* * * * * env > /tmp/cron_env.txt 2>&1
$ cat /tmp/cron_env.txt
HOME=/root
LOGNAME=root
PATH=/usr/bin:/bin              # ★ 看,PATH 就这么短!
SHELL=/bin/sh
# 对比你自己终端里 echo $PATH —— 长得多。差异一目了然。

# === ★ 解法 1:脚本里所有命令用【绝对路径】 ===
$ which mysqldump               # 先查到它的绝对路径
/usr/local/mysql/bin/mysqldump
# 脚本里就写全:
# /usr/local/mysql/bin/mysqldump -u...

# === ★ 解法 2:在脚本【开头】自己把 PATH 设全 ===
#!/bin/bash
export PATH=/usr/local/mysql/bin:/usr/local/bin:/usr/bin:/bin
# 后面就能直接用 mysqldump 了。

# === ★ 解法 3:在 crontab 顶部直接定义 PATH ===
$ crontab -e
PATH=/usr/local/mysql/bin:/usr/local/bin:/usr/bin:/bin
0 2 * * * /opt/scripts/backup.sh
# crontab 文件里,命令行之上可以写 VAR=value 形式的环境变量。

# === ★ 解法 4:让脚本主动加载你的环境(看场景)===
0 2 * * * source /root/.bash_profile; /opt/scripts/backup.sh
# 或在脚本里 source。注意:.bashrc 里若有"非交互就 return"
# 的判断,source 它也未必能拿到全部变量 —— 解法 1/2 更稳。

# === 还要注意:cron 默认用 /bin/sh,不是 /bin/bash ===
# 脚本里如果用了 bash 特有语法,第一行务必写 #!/bin/bash,
# 并确保 cron 是"执行脚本"而不是"用 sh 解释它"。

修复 3:cron 任务的输出去哪了——为什么"悄无声息"

# === ★ 这次失败"没声音",是因为我不知道 cron 的输出去哪了 ===

# === cron 的默认行为:把任务的输出【发邮件】 ===
# 任务只要往 stdout / stderr 写了东西,
# cron 就会把这些输出,通过本地邮件,发给任务所属的用户。
$ cat /var/mail/root            # ★ root 用户的本地邮件
# 或 mail 命令进去看 —— 我的 mysqldump 报错,就躺在这里,
# 而我从来不看这个邮箱,所以一切看起来"悄无声息"。

# === ★ 正确做法:每条 cron 任务都【显式重定向输出到日志】 ===
0 2 * * * /opt/scripts/backup.sh >> /var/log/backup.log 2>&1
# 拆解这个重定向:
#   >> /var/log/backup.log   把 stdout 追加进日志文件
#   2>&1                     ★ 把 stderr 也【并到】stdout 那条流
# ★ 2>&1 必须放在 >> 文件【之后】,顺序反了不生效。
# 这样,无论成功失败,所有输出都进日志,排查时直接看日志。

# === 不想要任何输出(也不想收邮件)===
0 2 * * * /opt/scripts/job.sh > /dev/null 2>&1
# ★ 但慎用:全扔 /dev/null,出了错你也彻底看不见。
#   至少把 stderr 留下:> /dev/null(只丢正常输出,留错误)。

# === 用 MAILTO 控制邮件发给谁 / 关掉邮件 ===
$ crontab -e
MAILTO=ops@example.com          # 输出邮件改发给这个地址
MAILTO=""                       # ★ 设为空字符串 = 不发邮件
0 2 * * * /opt/scripts/backup.sh

# === ★ 让脚本自己打好带时间戳的日志,最省心 ===
#!/bin/bash
log(){ echo "[$(date '+%F %T')] $*"; }
log "备份开始"
# ...
log "备份结束,文件大小 $(du -h $f)"
# 脚本内部把关键步骤都 log 出来,配合 >> 重定向,
# 排查时一份日志就够了。

修复 4:cron 排查——日志、触发、转义坑

# === ★ 排查 cron 第一问:任务到底有没有被【触发】 ===
$ tail -f /var/log/cron         # CentOS:cron 的日志在这
# Ubuntu/Debian 在 /var/log/syslog 里 grep CRON
$ grep CRON /var/log/cron | grep backup
May 18 02:00:01 host CROND[12345]: (root) CMD (/opt/scripts/backup.sh)
# ★ 有这行 = cron 准点触发了 -> 问题在脚本执行本身(查环境/输出)
# ★ 没这行 = cron 压根没触发 -> 查 crontab 语法 / cron 服务

# === 没被触发,挨个排查 ===
$ systemctl status crond        # ① cron 服务在跑吗
$ crontab -l                    # ② 任务真的存在吗、语法对吗
# ③ crontab 文件★最后一行要有换行符,否则最后一条可能不执行
# ④ 用户对脚本有执行权限吗:ls -l /opt/scripts/backup.sh

# === ★ 一个臭名昭著的坑:% 在 crontab 里是特殊字符 ===
# crontab 里,未转义的 % 会被当成【换行符】!
# 这条想用 date 拼文件名,会直接出错:
0 2 * * * mysqldump db > /backup/db_$(date +%Y%m%d).sql   # ★ 错!
# %Y %m %d 里的 % 全被 cron 特殊处理了。
# ★ 解法:每个 % 前加反斜杠转义:
0 2 * * * mysqldump db > /backup/db_$(date +\%Y\%m\%d).sql
# ★ 更稳的解法:把这种逻辑【写进脚本里】,crontab 只调脚本。
#   脚本内部的 date +%Y%m%d 完全正常,不受这个坑影响。

# === ★ 复现 cron 环境来调试,而不是用你的终端调 ===
# 你的终端调试不出问题,因为环境不一样。要这样复现:
$ env -i /bin/sh -c '/opt/scripts/backup.sh'
# env -i 清空所有环境变量,模拟 cron 那种"干净"环境 ——
# 在这种环境下能跑通,放进 cron 才真的稳。

# === 验证 cron 表达式写得对不对 ===
# 拿不准 "*/15 0-6 * * 1-5" 这种,可以:
#   - 先设成 "* * * * *" 每分钟跑,确认任务本身没问题
#   - 再换回真实表达式
# 调试期把脚本输出重定向到日志,盯几个周期。

修复 5:任务重叠——上一次没跑完,下一次又起来了

# === ★ cron 不管上一次跑完没,到点就【再起一个】 ===
# 设了 */5 每 5 分钟跑一次,但某次任务跑了 8 分钟 ——
# cron 不会等它,5 分钟一到,又起了一个新的。
# 后果:两个、三个实例同时在跑,可能:
#   - 抢同一个文件、同一张表,数据写乱
#   - 一起吃 CPU/IO,机器被自己的定时任务拖垮
#   - 任务越堆越多,雪崩

# === ★ 解法:用 flock 给任务加"独占锁" ===
$ crontab -e
*/5 * * * * /usr/bin/flock -n /tmp/job.lock /opt/scripts/job.sh
# flock -n /tmp/job.lock :抢这个锁文件的锁
#   -n = 抢不到锁就【立刻放弃】(不等)。
# ★ 效果:上一个实例还在跑(还占着锁),
#   这一次 flock 抢不到锁,直接退出 —— 完美避免重叠。

# === flock 在脚本内部用也行 ===
#!/bin/bash
exec 200>/tmp/job.lock          # 用 fd 200 打开锁文件
flock -n 200 || { echo "上一次还在跑,本次跳过"; exit 0; }
# ... 真正的任务逻辑 ...
# 脚本退出时,fd 关闭,锁自动释放。

# === 任务跑太久,该考虑的事 ===
# - 任务执行时间 > cron 间隔,本身就是个危险信号
# - 要么优化任务让它更快,要么拉长 cron 间隔
# - 长任务加 timeout,别让它无限期卡住:
*/30 * * * * /usr/bin/timeout 1200 /opt/scripts/job.sh
# timeout 1200 :超过 1200 秒强制终止,防止一个卡死的任务
#   占着锁,让后面所有次都被 flock 挡掉。

# === ★ 更现代的选择:systemd timer ===
# 比 cron 强的地方:
#   - 单元化管理,journalctl 直接看日志,不用翻邮件
#   - 默认不会重叠执行(同一 service 不会并发起两份)
#   - 支持 OnCalendar 灵活表达,还能 Persistent 补跑错过的
$ systemctl list-timers         # 看系统里所有的 timer
# 新项目排定时任务,值得优先考虑 systemd timer。

修复 6:定时任务排查纪律

# === 这次事故暴露的认知盲区,定几条纪律 ===

# === 1. ★ "手动能跑、cron 不能跑",先查【环境差异】 ===
$ env -i /bin/sh -c '你的脚本'  # 复现 cron 的极简环境
# 十有八九是 PATH 或某个环境变量,cron 环境里没有。

# === 2. ★ cron 任务里的命令,用绝对路径 / 脚本里设全 PATH ===
$ which 命令                    # 查绝对路径
# 别赌 cron 的 PATH 里有你要的命令 —— 它大概率没有。

# === 3. ★ 每条 cron 任务都重定向输出到日志 ===
0 2 * * * /脚本 >> /var/log/xx.log 2>&1
# 不重定向,输出进了没人看的本地邮件,失败就"悄无声息"。

# === 4. 排查先看 /var/log/cron,分清"没触发"还是"没跑成" ===
$ grep CRON /var/log/cron | grep 任务
# 有 CMD 记录 = 触发了,查脚本;没记录 = 查 crontab/服务。

# === 5. crontab 里的 % 要转义,或把逻辑塞进脚本 ===
# 未转义的 % 会被当换行符。复杂命令一律写进脚本,crontab 只调脚本。

# === 6. 可能跑很久的任务,加 flock 防重叠 ===
*/5 * * * * /usr/bin/flock -n /tmp/x.lock /脚本
# cron 不等上一次跑完,不加锁会多个实例并发。

# === 7. 排查 cron 的命令链 ===
$ crontab -l                    # ① 任务在不在、语法对不对
$ systemctl status crond        # ② cron 服务活着没
$ grep CRON /var/log/cron       # ③ 到点触发了没
$ env -i /bin/sh -c '脚本'      # ④ 复现 cron 环境跑一遍
$ cat 重定向的日志              # ⑤ 看脚本这次执行的真实输出
# 按这个顺序,定时任务问题基本能定位。

命令速查

需求                        命令
=============================================================
看当前用户的定时任务         crontab -l
编辑定时任务                crontab -e
看 cron 服务状态            systemctl status crond
看 cron 触发日志            grep CRON /var/log/cron
复现 cron 的极简环境         env -i /bin/sh -c '脚本'
dump cron 环境              在 crontab 里加 * * * * * env > /tmp/e.txt
查命令的绝对路径            which 命令名
任务输出重定向到日志         /脚本 >> /var/log/x.log 2>&1
防止任务重叠执行            flock -n /tmp/x.lock /脚本
给任务设超时                timeout 1200 /脚本

口诀:手动能跑 cron 不能跑 -> 必是环境差异查 PATH
      每条任务重定向到日志 -> 先看 /var/log/cron 分清触发没

避坑清单

  1. cron 执行环境不加载 .bashrc/.profile,PATH 极短,和登录终端完全不同
  2. "手动能跑 cron 不能跑"几乎总是环境差异,用 env -i 复现 cron 环境调试
  3. cron 任务里的命令要用绝对路径,或在脚本开头自己设全 PATH
  4. cron 任务的输出默认发本地邮件,不重定向到日志,失败就悄无声息
  5. 重定向写法 >> 日志 2>&1,2>&1 必须放在文件之后才生效
  6. 排查先看 /var/log/cron,有 CMD 记录是触发了,没记录是没触发
  7. crontab 里未转义的 % 会被当成换行符,复杂命令应写进脚本
  8. 0 */5 不是"每5小时",cron 字段是匹配时刻不是间隔计时器
  9. cron 不等上一次跑完就起新实例,长任务要用 flock 防重叠
  10. 系统级 /etc/crontab 比用户级多一个用户字段,别漏写

总结

这次"手动能跑、cron 不能跑"的事故,纠正了我一个很底层的、关于"程序运行环境"的想当然。在这次之前,我心里默认,一个脚本的运行环境,是"这台机器"决定的——只要是在同一台服务器上,脚本面对的世界就应该是一样的:一样的 PATH、一样的环境变量、一样的一切。所以当那个备份脚本,我亲手在终端里执行一百次都成功,交给 cron 在凌晨执行却次次失败时,我陷入了纯粹的困惑——同一个脚本、同一台机器、同一个 root 用户,凭什么换了个执行者,结果就天差地别?复盘到根上,我才真正想明白:决定一个进程运行环境的,根本不是"机器",而是"它的父进程把什么样的环境传给了它"。我手动执行脚本时,它的父进程是我的登录 shell;而我的登录 shell,在我 ssh 进来的那一刻,就老老实实地、按顺序加载了 /etc/profile~/.bash_profile~/.bashrc 这一连串配置文件——正是这些文件,往 PATH 里塞进了 /usr/local/mysql/bin,定义了一堆变量和别名,把我熟悉的那个"丰满"的环境给搭了起来。而 cron 执行任务时,任务的父进程是 cron 守护进程,它给出的,是一个截然不同的、极其"干瘪"的环境:它【不会】去加载我那些 shell 配置文件中的任何一个,它给任务的 PATH 短得可怜,通常就只有 /usr/bin:/bin。于是,在我的登录终端里,mysqldump 因为 PATH 里有它而能被找到;而在 cron 那个干瘪的环境里,mysqldump 不在 PATH 上——脚本一执行到那一行,就是一句冷冰冰的 command not found。这件事让我彻底记住了一条诊断铁律:当一个脚本"手动能跑、cron 不能跑"时,几乎可以不假思索地断定,问题出在环境差异上,而其中最大、最常见的嫌疑犯,就是 PATH。而验证它的方法也极其简单——用 env -i 清空所有环境变量,在一个模拟 cron 的、同样干瘪的环境里把脚本跑一遍,问题立刻就会暴露。这次事故的另一半教训,关于"失败为什么是悄无声息的"。我一开始觉得这最诡异——脚本失败了,竟然连个错都不报。后来才明白,错根本没有"消失",它只是被送到了一个我从不查看的地方:cron 有一个默认行为,任务只要产生了任何输出,它就会把这些输出通过【本地邮件】发给任务的属主用户。我那句 mysqldump: command not found 的报错,这几天来一直安安静静地躺在 root 用户的本地邮箱里,而我压根不知道、也从不去看那个邮箱。这让我立下了第二条纪律:每一条写进 crontab 的任务,都必须显式地把它的输出——用 >> 日志文件 2>&1 的方式,把 stdout 和 stderr 一起——重定向到一个我会去看的日志文件里。一个不重定向输出的 cron 任务,就是一个把自己的遗言写进漂流瓶的任务,它出了事,你永远不会在第一时间知道。这次从一个"同样的脚本却两样的结果"的困惑出发,我最大的收获,是把"运行环境"这个一直模糊的概念,从"属于机器"修正成了"由父进程沿着进程树传递下来"——一个进程能看到什么 PATH、什么环境变量,取决于是谁把它启动起来的;cron 和我的登录 shell,是两个性格迥异的"启动者",它们交到子进程手上的,是两个完全不同的世界。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
Linux教程

load 飙到 40 但 CPU 几乎空闲:一次 Linux 负载与 CPU 排查复盘

2026-5-20 19:03:18

Linux教程

端口在监听外面却连不上:一次 Linux 防火墙 iptables 与 firewalld 排查复盘

2026-5-20 19:10:49

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索