手动能跑放进 crontab 就失踪:一次 Linux 定时任务排查复盘

一个数据库备份脚本手动跑百分百正常,放进 crontab 设凌晨 3 点跑,一周后备份目录空空如也、一次都没成功。排查梳理:cron 准时启动了脚本却第一条命令就失败、cron 执行环境极简不加载 .bashrc、PATH 只有 /usr/bin:/bin、命令必须用绝对路径、任务输出要重定向收集否则被当邮件石沉大海、/var/log/cron 看有没有触发、env -i 裸环境自测、% 转义与时区坑,以及一套定时任务排查纪律。

2024 年,我写了一个数据库备份脚本,逻辑很简单:把库 dump 出来、打包、扔到 /backup 目录,再删掉七天前的旧包。脚本写好后我在命令行手动跑了好几遍,每次都干净利落地生成一个备份包,毫无问题。于是我把它放进 crontab,设成每天凌晨 3 点自动执行,心想这下高枕无忧了。一周后,某个库出了点问题我想回滚,去 /backup 找备份——目录是空的。一个备份包都没有。crontab 我明明写了,凌晨 3 点这个时间我也反复确认过没错,脚本手动跑更是百分百正常,可它就是一次都没成功执行过。一个"手动跑完全没问题"的脚本,放进 crontab 就集体失踪,这件事把我彻底搞懵了。后来才明白,问题不在脚本,而在我对 cron 这个东西的运行环境一无所知。这件事逼着我把 Linux 的 crontabcron 运行环境、定时任务排查这一整套彻底理清了。本文复盘这次实战。

问题背景

环境:CentOS 7,一个数据库备份脚本 /opt/backup.sh
事故现象:
- 脚本手动执行,完全正常,稳定生成备份包
- 放进 crontab 设每天 3 点跑
- 一周后发现 /backup 目录空空如也,一次都没成功

现场排查:
# 1. 确认 crontab 确实写了,时间也没错
$ crontab -l
0 3 * * * /opt/backup.sh                # ★ 看着没问题

# 2. ★ 关键命令:看 cron 的执行日志
$ grep CRON /var/log/cron | tail -5
... CROND[2891]: (root) CMD (/opt/backup.sh)
# ★ cron 确实"启动"了脚本 —— 说明定时本身没问题!

# 3. 那脚本为什么没产出?去看脚本自己的报错
# 但脚本的输出我没做任何重定向 -> 报错根本没留下来

# 4. ★ 手动模拟 cron 的极简环境跑一遍
$ env -i /bin/sh -c '/opt/backup.sh'
/opt/backup.sh: line 3: mysqldump: command not found    # ★ 真相!
# 手动跑能跑,是因为我的 shell 里 PATH 很全;
# cron 给的 PATH 极简,mysqldump 不在里面 -> 命令找不到

根因(后来想清楚的):
1. ★ cron 执行任务时,给的【环境极简】—— 它不是
   我的登录 shell,没有 .bashrc/.bash_profile,
   PATH 通常只有 /usr/bin:/bin 这么一点点。
2. 我脚本里直接写了 mysqldump、tar 等命令,没写绝对路径。
   手动跑:我的 shell PATH 很全,能找到 -> 正常。
   cron 跑:PATH 极简,mysqldump 不在 -> command not found。
3. ★ 更要命的是:脚本的报错输出我没做任何重定向,
   cron 把它当邮件发给本地用户,我从来不看本地邮件
   -> 错误信息石沉大海,我以为"没动静=没问题"。
4. 于是:cron 准时启动脚本 -> 脚本第一条命令就失败 ->
   错误被默默吞掉 -> 备份目录一直是空的。
crontab 不执行,十有八九不是定时错了,是环境和路径。

修复 1:crontab 是什么——以及 cron 能写在哪几个地方

# === ★ 先认识 cron:它是 Linux 上【按时间自动跑任务】的服务 ===
# 后台有个常驻进程 crond,它每分钟醒来一次,
# 检查"这一分钟有没有该跑的任务",有就跑。

# === 确认 crond 这个服务是活的(★ 最容易被忽略的前提)===
$ systemctl status crond
   Active: active (running)            # ★ 必须是 running
# crond 没起来,你写再多 crontab 都不会执行。
$ systemctl enable --now crond         # 没开就开起来

# === ★ cron 任务其实能写在【好几个地方】,别只知道一个 ===

# --- 地方 1:用户 crontab(最常用)---
$ crontab -e          # 编辑【当前用户】的 crontab
$ crontab -l          # 查看
$ crontab -r          # ★ 删除(危险!整份清空,别手滑)
$ crontab -u www -l   # 看【别的用户】的 crontab
# 用户 crontab 文件实际存在 /var/spool/cron/ 下,按用户名分文件。

# --- 地方 2:系统 crontab /etc/crontab ---
$ cat /etc/crontab
# ★ 注意:它的格式比用户 crontab 【多一列"用户名"】!
# 分 时 日 月 周  用户  命令
0 3 * * *  root  /opt/backup.sh

# --- 地方 3:/etc/cron.d/ 目录 ---
# 往这个目录丢一个文件,格式同 /etc/crontab(也带用户名列)。
# ★ 软件包安装的定时任务,通常就丢在这里。

# --- 地方 4:/etc/cron.{hourly,daily,weekly,monthly}/ ---
# 把【可执行脚本】丢进去,就会按小时/天/周/月跑。不写时间表达式。

# === ★ 一个坑:同一个任务可能在多处定义,排查要都看一遍 ===
$ crontab -l                            # 当前用户的
$ cat /etc/crontab /etc/cron.d/*        # 系统的
$ ls /etc/cron.{hourly,daily,weekly,monthly}/

修复 2:看懂那五个星号——时间表达式

# === ★ crontab 每行前面那五个字段,就是"什么时候跑" ===
# ┌── 分钟 (0-59)
# │ ┌── 小时 (0-23)
# │ │ ┌── 一个月的第几天 (1-31)
# │ │ │ ┌── 月份 (1-12)
# │ │ │ │ ┌── 星期几 (0-7,0 和 7 都是周日)
# │ │ │ │ │
# * * * * *  要执行的命令

# === 几个常见写法,照着抄 ===
0 3 * * *        每天凌晨 3:00
*/10 * * * *     每 10 分钟一次(★ 斜杠 = "每隔多少")
0 */2 * * *      每 2 小时(在整点)
0 9 * * 1-5      周一到周五的 9:00
30 2 1 * *       每月 1 号的 2:30
0 0 * * 0        每周日 0:00
15 14 * * 1,3,5  周一/三/五的 14:15(★ 逗号 = 列举)

# === ★ 一个经典误区:"日"和"周"两个字段同时写 ===
0 3 5 * 1        # 你以为是"5 号【且】是周一才跑"?
# ★ 错!cron 的规则是:日和周都不为 * 时,是【或】的关系 ——
#   "每月 5 号" 或 "每个周一",满足任意一个就跑。
# 想要"既是几号又是周几",cron 表达式做不到,得在脚本里自己判断。

# === 拿不准表达式?先用一个高频任务验证 ===
* * * * * date >> /tmp/cron_test.log    # 每分钟写一次
$ tail -f /tmp/cron_test.log            # 看它是不是真的每分钟在涨
# ★ 验证完记得把这行测试任务删掉。

# === ★ 特殊写法:@reboot 和 @daily ===
@reboot   /opt/init.sh        # 开机后跑一次
@daily    /opt/backup.sh      # 等价于 0 0 * * *
@hourly   /opt/check.sh       # 等价于 0 * * * *
# @reboot 很有用 —— 想"开机自动跑个脚本"又不想写 systemd 单元时。

修复 3:cron 的环境极简——这次踩坑的真正核心

# === ★ 这次事故的根:cron 跑任务时的环境,和你的 shell 完全不同 ===

# === 你登录的 shell:环境很"富裕" ===
# 你 SSH 登录、敲命令时,系统会加载 /etc/profile、
# ~/.bash_profile、~/.bashrc —— 里面设了一长串 PATH、
# 各种环境变量、别名…… 你的命令能跑,靠的是这一身"装备"。

# === ★ cron 跑任务的环境:几乎是"裸奔" ===
# crond 执行你的任务时:
#   - 【不会】加载 .bashrc / .bash_profile / /etc/profile
#   - PATH 通常只有极简的 /usr/bin:/bin
#   - 没有你 shell 里那些自定义环境变量
#   - 工作目录是该用户的家目录
# ★ 所以"我手动能跑"和"cron 能跑"是两码事 ——
#   手动跑你带着全套装备,cron 跑几乎是空手。

# === 亲眼看看 cron 到底给了多穷的环境 ===
# 临时加一行任务,把 cron 环境打印出来:
* * * * * env > /tmp/cron_env.txt
# 等一分钟后:
$ cat /tmp/cron_env.txt
PATH=/usr/bin:/bin                      # ★ 看,就这么点
HOME=/root
SHELL=/bin/sh
# 和你 echo $PATH 看到的那一长串,天差地别。

# === ★ 解法 1:命令一律用【绝对路径】 ===
# 脚本里别写 mysqldump,写全路径:
$ which mysqldump                       # 先查出它在哪
/usr/local/mysql/bin/mysqldump
# 脚本里就写 /usr/local/mysql/bin/mysqldump

# === ★ 解法 2:在 crontab / 脚本顶部,自己把 PATH 设全 ===
# crontab 文件里,命令行之前可以直接定义变量:
$ crontab -e
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
0 3 * * * /opt/backup.sh
# 或者在脚本第二行(shebang 之后)就 export PATH。

# === ★ 解法 3:脚本里主动加载需要的环境 ===
#!/bin/bash
source /etc/profile                     # 把系统环境捞回来
source ~/.bashrc                        # 需要的话也加载用户环境
# ★ 适合那些"严重依赖环境变量"的脚本(比如要用 JAVA_HOME 的)。

# === ★ 黄金法则:脚本要能在"裸环境"下跑 ===
# 写完任务脚本,务必这样自测一遍:
$ env -i /bin/sh -c '/opt/backup.sh'
# env -i = 清空所有环境变量。能在这里跑通,放进 cron 才稳。

修复 4:cron 跑了没、跑成没——怎么看日志

# === ★ 排查 cron,第一件事永远是:看它到底跑没跑 ===

# === 证据 1:/var/log/cron —— cron 自己的执行日志 ===
$ tail -20 /var/log/cron
... CROND[2891]: (root) CMD (/opt/backup.sh)
# ★ 有 CMD 这行 = cron 确实在那个时间点【启动】了你的任务。
# 注意:这只证明"启动了",不证明"跑成功了"。
$ grep backup.sh /var/log/cron          # 只看某个任务
$ grep CROND /var/log/cron | grep 'May 20'   # 看某天的

# === ★ 关键判断:日志里【有没有】你的任务那一行 ===
# - 有 CMD 行  -> cron 没问题,问题在脚本本身(往下查脚本)
# - 没有 CMD 行 -> cron 压根没触发,问题在 crontab/crond
#   (时间表达式写错?crond 没运行?写错文件了?)

# === 证据 2:脚本自己的输出 —— ★ 必须主动重定向收集 ===
# cron 默认会把任务的 stdout/stderr 当【邮件】发给本地用户。
# 你不看本地邮件 = 所有报错石沉大海(这正是我这次的坑)。
# ★ 正确做法:在 crontab 里把输出重定向到日志文件:
0 3 * * * /opt/backup.sh >> /var/log/backup.log 2>&1
#                        ^^^^^^^^^^^^^^^^^^^^^  ^^^^
#          把标准输出追加进日志    把标准错误也并进去
# ★ 2>&1 一定要写 —— 否则报错(stderr)还是会漏掉。

# === 证据 3:实在要看 cron 发的邮件 ===
$ cat /var/spool/mail/root            # 或 mail 命令
# 里面能看到任务输出/报错。但更推荐上面的重定向方案。

# === ★ 一个好习惯:任务脚本自己打带时间戳的日志 ===
#!/bin/bash
log() { echo "[$(date '+%F %T')] $*"; }
log "备份开始"
/usr/local/mysql/bin/mysqldump ... || { log "dump 失败"; exit 1; }
log "备份完成"
# 配合 >> /var/log/backup.log 2>&1,
# 每次跑没跑、第几步挂的,一目了然。

# === 还想确认 crond 进程级别有没有动静 ===
$ journalctl -u crond --since '1 hour ago'
# systemd 下,crond 的运行情况这里也看得到。

修复 5:时区、权限、换行符——其余几个常见坑

# === 坑 1:★ 脚本没有可执行权限 ===
$ ls -l /opt/backup.sh
-rw-r--r-- 1 root root ... /opt/backup.sh    # ★ 没有 x
$ chmod +x /opt/backup.sh
# 没有 x,直接 "/opt/backup.sh" 会执行失败。
# ★ 规避:crontab 里显式带上解释器,就不依赖 x 权限:
0 3 * * * /bin/bash /opt/backup.sh >> /var/log/backup.log 2>&1

# === 坑 2:★ cron 跑的时间和你以为的时区不一样 ===
$ date                                  # 看系统当前时间和时区
$ timedatectl                           # 看时区设置
# crond 用的是【系统时区】。如果系统是 UTC,你以为的"凌晨 3 点"
# 实际是北京时间上午 11 点。改时区:
$ timedatectl set-timezone Asia/Shanghai
$ systemctl restart crond               # ★ 改完时区,重启 crond

# === 坑 3:★ 命令里的 % —— cron 把它当特殊字符 ===
# 在 crontab 里,% 有特殊含义(换行 / 给命令喂输入)。
# 这行想用 date 的 %Y%m%d,会直接出错:
0 3 * * * tar czf /backup/db-`date +%Y%m%d`.tar.gz /data   # ★ 错
# ★ 正解:每个 % 前面加反斜杠转义:
0 3 * * * tar czf /backup/db-`date +\%Y\%m\%d`.tar.gz /data
# ★ 更省心的办法:把复杂命令全塞进脚本,crontab 里只调脚本。
#   脚本里的 % 是正常字符,不用转义 —— 这也是该用脚本的原因。

# === 坑 4:crontab 文件最后一行没有换行符 ===
# ★ crontab 要求【每一行都以换行结尾】,包括最后一行。
# 最后一行没回车,这行任务可能【整个不执行】。
# 用 crontab -e 编辑一般没事(编辑器会补),
# 但你若用 "crontab 某文件" 导入,务必确认文件末尾有空行。

# === 坑 5:被 cron.deny / cron.allow 限制 ===
$ cat /etc/cron.deny /etc/cron.allow 2>/dev/null
# 如果你的用户在 cron.deny 里,或存在 cron.allow 但你不在其中,
# 你的 crontab 根本不会被执行。普通用户排查时留意这个。

# === 坑 6:用 crontab 文件导入,覆盖了原有任务 ===
$ crontab mycron.txt          # ★ 这是【整份覆盖】,不是追加!
# 想保留原有的,先 crontab -l > now.txt 导出、追加、再导入。

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

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

# === 1. ★ 任务不执行,先分清是"没触发"还是"跑了但失败" ===
$ grep 你的脚本名 /var/log/cron
# 有 CMD 行 = 触发了,查脚本;没有 = 没触发,查 crontab/crond。

# === 2. ★ cron 环境极简,命令一律用绝对路径 ===
$ which 命令                    # 查出全路径,脚本里写全路径
# 或在 crontab/脚本顶部把 PATH 设全。

# === 3. ★ 任务输出一定要重定向收集,别让它石沉大海 ===
0 3 * * * /opt/x.sh >> /var/log/x.log 2>&1   # 2>&1 不能少

# === 4. ★ 脚本要能在裸环境下跑通,再放进 cron ===
$ env -i /bin/sh -c '/opt/x.sh'  # 这样能跑通才算稳

# === 5. 复杂命令塞进脚本,crontab 只调脚本 ===
# 避开 % 转义、引号嵌套等一堆 crontab 语法坑。

# === 6. 改时区后重启 crond;确认 crond 服务在运行 ===
$ systemctl status crond

# === 7. 排查 cron 问题的命令链 ===
$ systemctl status crond        # ① crond 服务活着没
$ crontab -l                    # ② 任务写对没、时间对没
$ grep CROND /var/log/cron      # ③ cron 有没有触发任务
$ env -i /bin/sh -c '脚本'      # ④ 裸环境下脚本能否跑通
$ cat /var/log/x.log            # ⑤ 任务自己的输出日志
$ date; timedatectl             # ⑥ 时间和时区对不对
# 按这个顺序,定时任务问题基本能定位。

命令速查

需求                        命令
=============================================================
编辑当前用户 crontab        crontab -e
查看当前用户 crontab        crontab -l
查看别的用户 crontab        crontab -u 用户名 -l
确认 crond 服务在跑         systemctl status crond
看 cron 执行日志            grep CROND /var/log/cron
看某任务有没有被触发        grep 脚本名 /var/log/cron
裸环境模拟 cron 跑脚本      env -i /bin/sh -c '脚本路径'
任务输出重定向到日志        命令 >> /var/log/x.log 2>&1
查命令绝对路径              which 命令名
看/改系统时区               timedatectl

口诀:任务没产出先看 /var/log/cron 有没有 CMD 行
      有就查脚本(绝对路径!) 没有就查 crontab 和 crond

避坑清单

  1. cron 任务不执行,先看 /var/log/cron 有没有 CMD 行,分清没触发还是跑了失败
  2. cron 执行环境极简,不加载 .bashrc,PATH 只有 /usr/bin:/bin
  3. 脚本里命令一律用绝对路径,或在 crontab/脚本顶部把 PATH 设全
  4. 任务输出务必重定向 >> 日志 2>&1,否则报错被当邮件石沉大海
  5. 脚本要先用 env -i 在裸环境下跑通,再放进 cron 才稳
  6. crontab 时间字段日和周同时非星号时是"或"关系,不是"且"
  7. crontab 里 % 是特殊字符要转义,复杂命令应塞进脚本只调脚本
  8. 脚本要有可执行权限,或在 crontab 里显式写 /bin/bash 脚本
  9. crond 用系统时区,改时区后要重启 crond,否则跑的时间不对
  10. crontab 文件导入是整份覆盖不是追加,crontab -r 会清空整份

总结

这次"手动跑没问题的脚本,放进 crontab 集体失踪"的事故,纠正了我一个非常根本、却从来没被我意识到的错误假设。在这次之前,我对"把脚本放进 crontab 定时跑"这件事的理解,简单到只剩一句话:cron 就是一个"闹钟",时间到了,它替我把脚本"敲"一遍——就像我自己坐在终端前,在凌晨 3 点亲手把那条命令打进去、回车一样。在这个假设里,"我手动能跑通"和"cron 能跑通"是完全等价的两件事:既然脚本在我手里跑得好好的,那 cron 替我跑,自然也该一样好好的。正是这个看似天经地义的假设,让我对备份目录的空荡毫无防备。复盘到根上,我才终于看清,cron 替我执行脚本的那个"现场",和我自己登录上去敲命令的那个"现场",根本不是同一个世界。我 SSH 登录、在终端里敲命令时,我并不是"赤手空拳"的——系统在我登录的那一刻,默默替我加载了 /etc/profile~/.bash_profile~/.bashrc 这一整套配置,给我穿上了一身"装备":一个内容丰富、把各种命令目录都囊括进去的 PATH,一堆自定义的环境变量,各种别名。我那个脚本之所以手动跑得顺,靠的根本不只是脚本自己,还有我身上这一整套我从未留意过的"装备"。而 crond 在凌晨 3 点替我执行任务时,它给脚本的环境,几乎是"裸奔"的——它不会去加载 .bashrc,不会加载任何登录脚本,它递给脚本的 PATH,通常只有 /usr/bin:/bin 这么可怜的一点点。我的脚本里直接写了 mysqldump 这个命令,而 mysqldump 的真实位置在 /usr/local/mysql/bin 下——这个目录在我手动登录的丰富 PATH 里,所以手动跑能找到;可它根本不在 cron 那个极简的 PATH 里,所以 cron 一跑,脚本的第一条命令就撞上了 command not found。这就是真相:cron 每天都准时、忠实地启动了我的脚本,一次没落;只是脚本一进门就因为找不到命令而当场失败了。而比"找不到命令"更让我后背发凉的,是第二层认知盲区:这个失败,本来是有声音的——脚本报的那句 command not found,cron 其实老老实实地把它当成一封邮件,投递给了本地用户的邮箱。只是我从来不知道、也从来不去看 Linux 的本地邮件,于是这一周里,每天凌晨都有一封"我失败了"的求救信被准时送达,而我一封都没读到。这件事让我把 cron 任务的两条铁律刻进了脑子。第一条,cron 的运行环境是"裸"的,所以任务脚本必须能在"裸环境"下独立活下来——命令要么写绝对路径,要么在脚本或 crontab 顶部亲手把 PATH 补全;检验的办法也很硬核:用 env -i 清空所有环境变量再跑一遍,能在这种"一无所有"的条件下跑通,放进 cron 才算真的稳。第二条,cron 任务的输出绝不能放任不管——必须用 >> 日志文件 2>&1 把标准输出和标准错误一起重定向到一个我会去看的日志里,让任务的每一次成败都留下白纸黑字的痕迹,而不是变成一封我永远不会拆开的邮件。这次从一个"空荡荡的备份目录"出发,我最大的收获,是终于分清了"我跑一个脚本"和"cron 替我跑一个脚本"这两件事的本质区别——cron 不是坐在我的终端前替我敲命令的"我",它是一个环境极简、沉默寡言、把所有报错都默默写进信封的后台仆人。把一个任务托付给它,我就必须为它准备好一个不依赖任何外部装备、能独立站立的脚本,并且亲手为它接上一根能把哭声传出来的"日志管道"。否则,它会一如既往地准时上工,然后一如既往地、悄无声息地失败下去——直到某天我真的需要那个备份时,才发现一切早已落空。

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

压测 QPS 上不去服务却不忙:一次 Linux 内核参数 sysctl 调优排查复盘

2026-5-20 19:28:15

Linux教程

服务半夜凭空消失日志却很干净:一次 Linux 内存与 OOM Killer 排查复盘

2026-5-20 19:35:07

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