定时任务明明配了却不执行:一次 Linux crontab 排查复盘

每天凌晨的备份脚本连着几天没产出,crontab 里那条任务明明在、语法也对,手动执行一切正常,一交给 cron 就哑火。排查梳理:看懂五个时间字段、给任务加 2>&1 抓报错、cron 环境极其精简 PATH 很短、相对路径与工作目录陷阱、flock 防重叠、systemd timer。

2024 年我们有一个每天凌晨跑的数据备份脚本,某天突然发现连着好几天的备份都没生成。我登上服务器一看,crontab 里那条定时任务明明白白配着,语法也没错,可它就是不执行——或者更准确地说,它"看起来"配了,实际却从来没真正跑成功过。手动执行这个脚本一切正常,一交给 cron 就哑火,这种"人能跑、cron 跑不了"的诡异落差,把我折腾了大半天。事后我把 cron 不执行的所有常见坑都梳理了一遍。本文复盘这次实战,把 Linux 定时任务这套东西讲透。

问题背景

环境:CentOS 7,一个每天凌晨 2 点跑的数据备份脚本
事故现象:
- 连续几天没有生成备份文件
- crontab -l 一看,那条定时任务确实在
- 手动执行 bash backup.sh —— 完全正常,备份成功
- 一交给 cron 自动跑 —— 没有任何产出,像没执行过

现场排查:
# 1. 确认任务在不在
$ crontab -l
0 2 * * * /data/scripts/backup.sh
# —— 在的,时间也对(每天 2:00)

# 2. 确认 cron 服务活着
$ systemctl status crond
# —— active (running),服务正常

# 3. 关键:去看 cron 自己的执行日志
$ grep backup /var/log/cron
# —— cron 确实在 2:00 调起了这个任务!
#    说明 cron 没问题,问题出在【脚本被 cron 调起后】

# 4. 给任务加上输出重定向,抓它的报错(见修复2)

根因(后来定位到的):
脚本里用了 mysqldump、用了相对路径,还依赖几个
环境变量。手动执行时,这些在登录 shell 里都现成有;
但 cron 执行环境极其精简 —— PATH 很短、几乎没有
环境变量、工作目录也不是你以为的那个。
脚本一到 cron 环境里就因为找不到命令而失败了。

修复 1:先读懂 crontab 的五个时间字段

# === crontab 一行 = 五个时间字段 + 一条命令 ===
$ crontab -l
# ┌───── 分钟 (0-59)
# │ ┌─── 小时 (0-23)
# │ │ ┌─ 日   (1-31)
# │ │ │ ┌ 月   (1-12)
# │ │ │ │ ┌ 星期 (0-7,0 和 7 都是周日)
# │ │ │ │ │
# 0 2 * * *  /data/scripts/backup.sh
# 含义:每天 2:00 执行

# === 几个常见写法 ===
# */10 * * * *     每 10 分钟
# 0 * * * *        每小时整点
# 30 3 * * 1       每周一 3:30
# 0 0 1 * *        每月 1 号 0:00
# 0 9-18 * * 1-5   周一到周五的 9 点到 18 点,每小时

# === 编辑 / 查看 / 删除 ===
$ crontab -e        # 编辑当前用户的 crontab
$ crontab -l        # 列出
$ crontab -r        # ★ 删除【全部】任务 —— 危险,别手滑
# -r 和 -e 键挨得近,误删过的人都懂。建议先备份:
$ crontab -l > ~/crontab.bak

# === 一个超容易踩的坑:百分号 % ===
# 在 crontab 里,% 有特殊含义(代表换行),
# 如果命令里要用 %(比如 date +\%Y\%m\%d),必须转义:
$ crontab -e
0 2 * * * /data/scripts/backup.sh > /tmp/bk_$(date +\%Y\%m\%d).log
# 不转义的 % 会让命令在 % 处被截断,行为完全不对

# === 用户 crontab vs 系统 crontab ===
# crontab -e 编辑的是【当前用户】的任务,以该用户身份跑。
# 系统级的在 /etc/crontab 和 /etc/cron.d/ 下,
# 它们【多一个字段】—— 在时间和命令之间多一列"用户名"。
# 别把这两种格式搞混。

修复 2:给任务加输出——别让报错石沉大海

# === cron 排查第一刚需:让任务的输出"留下来" ===
# cron 任务在后台跑,它的 stdout/stderr 你根本看不到。
# 报错全被丢进黑洞 —— 这就是为什么"它好像没执行"。

# === 给任务加上输出重定向,把一切都记下来 ===
$ crontab -e
0 2 * * * /data/scripts/backup.sh >> /var/log/backup.log 2>&1
# >> /var/log/backup.log :标准输出【追加】到日志
# 2>&1 :★ 把标准错误(2)也合并到标准输出(1)里
#        —— 报错信息恰恰走的是 stderr,这个绝不能漏

# === 顺序坑:2>&1 必须写在 >> file 的【后面】===
# 对的: cmd >> file 2>&1   (stderr 跟着 stdout 进 file)
# 错的: cmd 2>&1 >> file   (此时 stdout 还指向终端,
#                            stderr 复制的是终端,没进文件)

# === 改完之后,等它下次跑,看日志 ===
$ cat /var/log/backup.log
/data/scripts/backup.sh: line 8: mysqldump: command not found
# ★ 真相终于现身:cron 环境里找不到 mysqldump 命令!
# 这正是手动能跑、cron 跑不了的根本原因。

# === 临时不想等到凌晨?把任务时间临时改近一点 ===
# 比如改成 "下一分钟",验证完再改回去 ——
# 这是调试 cron 任务最实用的小技巧。

# === cron 还会把任务输出【邮件】给用户 ===
# 如果没做重定向,cron 默认把输出发到本地邮箱:
$ cat /var/mail/$(whoami)
# 没配重定向时,这里有时能翻到任务的报错

修复 3:核心坑——cron 的执行环境极其精简

# === 必须理解的真相:cron 不是你的登录 shell ===
# 你手动登录时,系统会加载 /etc/profile、~/.bashrc 等,
# 这些文件给你设好了一长串 PATH、各种环境变量。
# 但 cron 执行任务时,【几乎什么都不加载】。

# === 亲眼看看 cron 环境有多"穷" ===
# 临时加一条任务,把它的环境打出来:
$ crontab -e
* * * * * env > /tmp/cron_env.txt
# 等一分钟,看结果:
$ cat /tmp/cron_env.txt
HOME=/root
LOGNAME=root
PATH=/usr/bin:/bin          <-- ★ PATH 短得可怜!
SHELL=/bin/sh
# 对比你登录后 echo $PATH —— 通常长得多,
# /usr/local/bin、各种自定义路径都在里面。
# mysqldump 往往装在 /usr/local/bin 或 /usr/local/mysql/bin,
# 而 cron 的 PATH 里根本没有这些目录 -> command not found

# === 解法 A:脚本里命令一律用【绝对路径】===
# 错: mysqldump -u root ...
# 对: /usr/local/mysql/bin/mysqldump -u root ...
$ which mysqldump      # 先查出命令的绝对路径
/usr/local/mysql/bin/mysqldump

# === 解法 B:在脚本开头【自己把 PATH 设全】===
# 在 backup.sh 第一行(shebang 之后)加上:
#!/bin/bash
export PATH=/usr/local/mysql/bin:/usr/local/bin:/usr/bin:/bin

# === 解法 C:在 crontab 顶部直接定义 PATH ===
$ crontab -e
PATH=/usr/local/mysql/bin:/usr/local/bin:/usr/bin:/bin
0 2 * * * /data/scripts/backup.sh >> /var/log/backup.log 2>&1
# crontab 文件里也能写环境变量,写在任务行之前即可

# === 解法 D:让脚本跑在完整的登录环境里 ===
0 2 * * * /bin/bash -lc '/data/scripts/backup.sh'
# -l 让 bash 以登录 shell 启动,会加载 profile,
#    环境变量就和你手动登录时一致了

修复 4:相对路径与工作目录的陷阱

# === 第二个坑:你以为的"当前目录"不是 cron 以为的 ===
# 手动执行时,你通常已经 cd 到脚本所在目录,
# 脚本里写 ./config 或 data/ 这种相对路径都能找到。
# 但 cron 执行任务时,工作目录通常是该用户的【家目录】,
# 不是脚本所在的目录!

# === 反例:这个脚本手动能跑,cron 必失败 ===
#!/bin/bash
mysqldump ... > backup.sql        # 相对路径!
tar -czf backup.tar.gz ./data/   # 相对路径!
# cron 跑它时,工作目录是 /root,
# 于是文件被写到了 /root 下,或者因为 /root/data
# 不存在而直接报错。

# === 解法 1:脚本里的所有路径都用绝对路径 ===
#!/bin/bash
mysqldump ... > /data/backup/backup.sql
tar -czf /data/backup/backup.tar.gz /data/app/data/

# === 解法 2:脚本一开头就 cd 到自己该在的目录 ===
#!/bin/bash
# 这一行能拿到"脚本自身所在的目录",非常通用:
cd "$(dirname "$0")" || exit 1
# 之后再用相对路径就稳了,因为工作目录被你固定住了

# === 解法 3:在 crontab 里先 cd 再执行 ===
0 2 * * * cd /data/scripts && ./backup.sh >> /var/log/backup.log 2>&1

# === 一个连带的坑:脚本要有执行权限 + 正确的 shebang ===
$ chmod +x /data/scripts/backup.sh    # 加执行权限
$ head -1 /data/scripts/backup.sh
#!/bin/bash                            # 第一行声明解释器
# 保险起见,crontab 里直接用 bash 调,绕开权限问题:
0 2 * * * /bin/bash /data/scripts/backup.sh >> /var/log/backup.log 2>&1

修复 5:查 cron 日志 + 防任务重叠

# === cron 自己的日志:确认它到底有没有"调起"任务 ===
$ tail -50 /var/log/cron
May 14 02:00:01 host CROND[8123]: (root) CMD (/data/scripts/backup.sh ...)
# 这行在 = cron 确实在 2:00 调起了任务。
# 这能帮你把问题一分为二:
#   日志里【有】这行 -> cron 没问题,问题在脚本本身
#   日志里【没有】这行 -> 是 cron 没调起(语法错/服务没起)

# === cron 服务本身的检查 ===
$ systemctl status crond     # 服务在不在跑
$ systemctl restart crond    # 改了 /etc/crontab 等可能要重启
# 注意:crontab -e 改完【不需要】重启 crond,会自动生效;
# 但直接改 /etc/crontab 文件,某些系统需要 reload。

# === 另一个隐蔽坑:任务重叠(上一次还没跑完)===
# 备份脚本如果跑了 40 分钟,而你配的是每 30 分钟一次,
# 就会出现"上一个还在跑,下一个又起来了",
# 两个进程一起读写,把备份文件搞坏。
# === 用 flock 加锁,保证同一时间只有一个实例 ===
$ crontab -e
*/30 * * * * /usr/bin/flock -n /tmp/backup.lock /data/scripts/backup.sh
# flock -n /tmp/backup.lock :抢这个锁文件,
#   -n 表示抢不到就【立刻放弃】(说明上一个还在跑)。
# 这样就杜绝了任务重叠。

# === 脚本里也可以自己判断 ===
#!/bin/bash
exec 9> /tmp/backup.lock
flock -n 9 || { echo "上一次还在跑,跳过"; exit 0; }
# ... 真正的备份逻辑 ...

修复 6:更现代的选择——systemd timer

# === crontab 之外,systemd timer 是更强的定时方案 ===
# 它能记录每次执行的日志、能看下次触发时间、
# 不执行时排查也更顺手。

# === 1. 写一个 service 单元:定义"做什么" ===
$ cat /etc/systemd/system/backup.service
[Unit]
Description=Daily Data Backup

[Service]
Type=oneshot
ExecStart=/data/scripts/backup.sh
# systemd 同样是精简环境,需要的话显式指定:
Environment=PATH=/usr/local/bin:/usr/bin:/bin

# === 2. 写一个 timer 单元:定义"什么时候做" ===
$ cat /etc/systemd/system/backup.timer
[Unit]
Description=Run backup daily at 2am

[Timer]
OnCalendar=*-*-* 02:00:00
# Persistent=true:如果到点时机器关着,开机后【补跑】
Persistent=true

[Install]
WantedBy=timers.target

# === 3. 启用 ===
$ systemctl daemon-reload
$ systemctl enable --now backup.timer

# === 排查 timer 比排查 cron 舒服在哪 ===
$ systemctl list-timers          # 看所有 timer 的下次触发时间
$ systemctl status backup.timer
$ journalctl -u backup.service   # ★ 直接能看到每次执行的完整输出
# cron 要自己重定向才有日志,timer 的输出 journald 自动收着 ——
# 这一点对排查"任务没执行"太友好了。

# === 怎么选 ===
# 简单的、临时的定时任务,crontab 够用、上手快。
# 重要的、需要可观测性的生产任务,推荐 systemd timer。

命令速查

需求                        命令 / 写法
=============================================================
编辑当前用户 crontab        crontab -e
查看任务                    crontab -l
备份任务(防误删)          crontab -l > ~/crontab.bak
五字段含义                  分 时 日 月 周  命令
每 10 分钟                  */10 * * * *
每天 2 点                   0 2 * * *
任务加日志(含报错)        cmd >> /var/log/x.log 2>&1
查 cron 是否调起任务        tail -50 /var/log/cron
查 cron 服务                systemctl status crond
查 cron 环境                * * * * * env > /tmp/env.txt
查命令绝对路径              which 命令名
防任务重叠                  flock -n /tmp/x.lock 脚本
看所有 systemd timer        systemctl list-timers
看 timer 任务日志           journalctl -u xxx.service

口诀:先查 /var/log/cron 分清是没触发还是脚本错
      -> 加 2>&1 抓报错 -> 绝对路径 + 设 PATH 治本

避坑清单

  1. 排查 cron 不执行,先查 /var/log/cron,分清是 cron 没触发还是脚本被调起后失败
  2. 任务一定要加 >> log 2>&1,否则 stdout/stderr 进黑洞,报错永远看不到
  3. 2>&1 必须写在 >> file 的后面,顺序写反 stderr 不会进文件
  4. cron 执行环境极其精简,PATH 很短,手动能跑不代表 cron 能跑
  5. 脚本里的命令尽量用绝对路径,或在脚本开头/crontab 顶部把 PATH 设全
  6. cron 任务的工作目录是用户家目录,不是脚本所在目录,相对路径会失效
  7. 脚本开头用 cd "$(dirname "$0")" 把工作目录固定住,相对路径才安全
  8. crontab 里的 % 是特殊字符,date +%Y 这类必须转义成 \%Y
  9. 长任务要用 flock 加锁防止任务重叠,避免两个实例同时跑搞坏数据
  10. 重要的生产定时任务推荐用 systemd timer,日志自动收集、排查更友好

总结

这次定时任务的排查,核心其实就在破解一个看似矛盾的现象:同一个脚本,我手动执行它一切正常、备份成功,可一旦把它交给 cron 自动去跑,就什么产出都没有,像从来没执行过一样。一开始我盯着 crontab -l 反复看,任务在那、时间也对、语法也挑不出毛病,越看越困惑。后来我才明白,这个"人能跑、cron 跑不了"的落差里,藏着理解 cron 的全部钥匙——那就是:cron 执行任务时所处的环境,和你手动登录时所处的环境,是两个截然不同的世界。你手动登录服务器时,系统会默默替你加载 /etc/profile、~/.bashrc 这一串配置文件,它们给你设好了一长串的 PATH、各种各样的环境变量,还把你带到了一个特定的工作目录;你在这样一个"被精心布置过"的环境里执行脚本,脚本里那些没写全路径的命令、那些相对路径,自然都能找到。可 cron 执行任务时,几乎什么都不替你加载,它给的环境精简到近乎贫瘠:PATH 短得可怜,通常就只有 /usr/bin 和 /bin,环境变量寥寥无几,工作目录也不是你以为的脚本所在地、而是用户的家目录。于是,一个在你的登录环境里活得好好的脚本,被丢进 cron 这个陌生而贫瘠的环境里,就因为找不到 mysqldump 这个命令而当场失败了——这就是这次故障最根本的原因。想明白了这一层,排查的方法论也就清晰了。第一件必须做的事,是让任务的输出"留下来"。cron 任务在后台默默地跑,它的标准输出和标准错误,如果你不主动重定向,就会被丢进一个看不见的黑洞,这正是为什么它"看起来从没执行过"——其实它执行了,也报错了,只是那声惨叫你根本听不见。所以给任务追加上 >> 日志文件 2>&1,是排查 cron 问题雷打不动的第一步,而这里那个 2>&1 尤其关键,因为报错信息走的恰恰是标准错误这条道,你必须把它也合并进日志,否则抓到的永远只是无关痛痒的正常输出;而且 2>&1 的位置还有讲究,它必须写在重定向文件的后面,顺序一旦写反,标准错误就进不了文件。第二件事,是学会借助 /var/log/cron 这个 cron 自己的日志,把问题一刀切成两半:如果日志里有那条 CROND 调起任务的记录,就说明 cron 本身、它的服务、它的语法全都没问题,问题百分之百出在脚本被调起之后;如果连这条记录都没有,那才轮到去怀疑语法错误、服务没启动、或者那个极其隐蔽的百分号没转义的坑。这一刀切下去,你的排查范围立刻就收敛了一半。剩下的,就是针对 cron 那个贫瘠环境对症下药:命令一律用绝对路径,或者在脚本开头、在 crontab 顶部把 PATH 亲手设全;脚本里的文件路径也一律用绝对路径,或者在脚本一开头就用 cd 到一个确定的目录、把工作目录牢牢钉死,绝不把希望寄托在"当前目录恰好是对的"这种侥幸上。除此之外,这次复盘还让我重视起两件容易被忽略的事:一是长任务一定要用 flock 加锁,否则一旦某次执行超时、上一个实例还没结束下一个就被拉起来,两个进程一起读写,备份文件就被搅坏了;二是对于真正重要的生产定时任务,与其用 crontab,不如换成 systemd timer,因为它的每一次执行 journald 都会自动替你收好日志,你想排查"任务为什么没跑",直接 journalctl 一看便知,再也不用像 cron 这样得自己费劲做重定向。归根结底,这次排查教给我的最朴素也最重要的一课是:永远不要用"我手动跑得通"来证明"cron 也跑得通",因为这两句话背后,是两个完全不同的环境。你写给 cron 的脚本,必须假设它将在一个一无所有的环境里被执行——不依赖任何现成的 PATH,不依赖任何现成的工作目录,把它需要的一切都明明白白地写进去。一个能在最贫瘠的环境里稳稳跑起来的脚本,才是一个真正可靠的脚本。

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

十万行日志里捞线索:Linux grep/awk/sed 三剑客实战复盘

2026-5-20 17:26:17

Linux教程

Permission denied 到底差在哪:一次 Linux 文件权限排查复盘

2026-5-20 17:31:39

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