2021 年,一次"一个备份脚本,我手动跑【完全正常】,放进 crontab 让它每天自动跑,跑出来的备份文件却【是空的】"的事故,把我对"自动任务"和"运行环境"这两件事的理解,从头到尾翻新了一遍。那台服务器要每天凌晨 2 点把数据库备份一份。我写了个 backup.sh,在命令行里手动 bash backup.sh 跑了一遍——干净利落,生成了一个几百 MB 的备份文件,内容完好。我心想:脚本没问题,挂进 crontab 就行。我 crontab -e,写下 0 2 * * * /opt/scripts/backup.sh,保存。第二天我去看备份目录——文件【在】,时间戳也对,是凌晨 2 点生成的。可那个文件,大小是 0 字节。空的。我懵了。我又手动跑了一遍 backup.sh——又是好端端几百 MB。我盯着这两个文件:【同一个脚本】,我手动跑就是对的,cron 跑就是空的。脚本一个字都没改。难道 cron 跑脚本,和我自己跑脚本,是【两回事】?是同一个脚本、同一台机器、同一个文件——凭什么换一个"人"来按下回车,结果就天差地别?如果 cron 跑出来的"我",和命令行里的"我"不是同一个我——那 cron 里的那个"我",到底【缺了什么】?这件事逼着我把 cron 的运行环境、PATH 与环境变量、为什么它不读 .bashrc、时间字段的"与/或"陷阱,还有"手动能跑、cron 不能跑"这个经典问题的根,彻底理清了。本文复盘这次实战。
问题背景
环境:CentOS 7,一个数据库备份脚本,挂 crontab 每天跑
事故现象:
- backup.sh 手动跑,生成几百 MB 的正常备份文件
- ★ 同一个脚本挂进 crontab,跑出来的备份文件是 0 字节
- 脚本一个字没改,手动对、cron 错
现场排查:
# 1. ★ 先确认 cron 到底有没有跑这个任务
$ grep CROND /var/log/cron | tail
# Mar 12 02:00:01 host CROND[12345]: (root) CMD (/opt/scripts/backup.sh)
# ★ 跑了 —— cron 确实在 2:00 执行了它
# 2. ★★ 关键:让 cron 把脚本的报错"留下来"
# 原来的 crontab 行没有重定向,报错全丢了
$ crontab -e
# 改成:0 2 * * * /opt/scripts/backup.sh > /tmp/backup.log 2>&1
# 第二天看 /tmp/backup.log:
$ cat /tmp/backup.log
/opt/scripts/backup.sh: line 8: mysqldump: command not found # ★★ 找不到命令!
# 3. ★ 可我手动跑,mysqldump 明明是好的
$ which mysqldump
/usr/local/mysql/bin/mysqldump # ★ 它在 /usr/local/mysql/bin 下
# 4. ★★ 关键:看 cron 跑脚本时,PATH 是什么
# 在脚本开头临时加一行 echo "$PATH" 看看
$ cat /tmp/backup.log
PATH=/usr/bin:/bin # ★★ cron 的 PATH 只有这两个!
# ★ 我命令行里的 PATH:
$ echo $PATH
/usr/local/mysql/bin:/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin
# ^^^^^^^^^^^^^^^^^^^ ★ 我自己的 PATH 里有它,cron 的没有
根因(后来想清楚的):
1. ★ cron 执行一个任务时,给它的【运行环境】,和我
在命令行登录后的环境,是【两个完全不同的环境】。
2. ★ 我在命令行里的环境,是登录时 shell 读了
/etc/profile、~/.bash_profile、~/.bashrc 这一
大串文件【加载】出来的 —— PATH 被它们扩充得很全。
3. ★ 而 cron 跑任务,★ 它【不读】这些文件。它给
任务的环境,是一个【极简到吝啬】的环境:PATH
通常只有 /usr/bin:/bin,HOME 等寥寥几个变量。
4. ★ backup.sh 里写的是 mysqldump(没写绝对路径)。
手动跑时,我的 PATH 里有 /usr/local/mysql/bin,
找得到;cron 跑时,PATH 只有 /usr/bin:/bin,
★ 找不到 mysqldump -> command not found。
5. ★ mysqldump 这一步失败了,但脚本【没有 set -e】,
它继续往下走,把 mysqldump 那个【空的输出】
重定向进了备份文件 -> 备份文件 0 字节。
真相:不是脚本错了,是 cron 给脚本的【环境】,和
我手动跑时的环境,根本不是同一个。手动能跑 cron
不能跑,几乎都是环境(尤其 PATH)的差异。
修复 1:cron 任务"像没跑"——先确认它到底跑没跑、错在哪
# === ★ cron 任务出问题,第一步永远是:让它"开口说话" ===
# === ★ cron 任务最大的特点:它在"暗处"运行 ===
# ★ 你手动跑脚本,报错会直接打在你屏幕上,你一眼
# 看见。★ 但 cron 跑任务,是在【后台、没有终端】
# 的环境里跑的 —— 它的所有输出(包括报错),
# 默认【不会显示给任何人】。
# ★ 所以 cron 任务出错,现象往往是【静默的】:
# 任务"像没跑一样",或"跑了但结果不对",而你
# 【看不到任何报错】。排查第一步,是把这些被
# 吞掉的输出"捞回来"。
# === ★ 第一步:确认 cron 到底执行了没有 ===
$ grep CROND /var/log/cron | tail -20
# Mar 12 02:00:01 host CROND[12345]: (root) CMD (/opt/scripts/backup.sh)
# ★ /var/log/cron(有些发行版是 /var/log/syslog)
# 记录了 cron 的每一次调度。看到这行,说明 cron
# 【确实在 2:00 启动了】你的任务。
# ★ 如果这里【没有】你的任务 —— 那问题在 crontab
# 本身(没装上、时间字段写错、用户不对),任务
# 压根没被调度。
# === ★ 第二步:★★ 把任务的输出和报错,重定向到文件 ===
# ★ 这是排查 cron 问题【最重要】的一招。改 crontab:
$ crontab -e
# 原来:0 2 * * * /opt/scripts/backup.sh
# 改成:0 2 * * * /opt/scripts/backup.sh > /tmp/backup.log 2>&1
# ★ > /tmp/backup.log 把标准输出写进文件;
# ★ 2>&1 把标准错误也【并到同一个文件】。
# ★ 这两个加一起 = "这个任务跑出来的一切,不管是
# 正常输出还是报错,全给我存进 /tmp/backup.log"。
# ★ 下次任务跑完,cat 这个文件,真相就在里面。
# === ★ 另一招:让 cron 把输出"邮件"给你 ===
# ★ cron 有个机制:任务如果有任何输出,cron 会把它
# 【邮件】发给任务的属主。在 crontab 顶部写:
# MAILTO="you@example.com"
# ★ 但这要求机器配好了发信,很多服务器没配 —— 所以
# ★ 重定向到文件,是更可靠、更通用的办法。
# === ★ 第三步:看捞回来的报错 ===
$ cat /tmp/backup.log
# /opt/scripts/backup.sh: line 8: mysqldump: command not found
# ★ 真相浮现:command not found。手动跑明明好好的
# 命令,cron 跑就找不到 —— 这是 cron 问题里最最
# 经典的一种。下一节专门讲它。
# === 认知 ===
# ★ cron 任务在【没有终端的后台】运行,它的输出和报错
# 默认被【全部丢弃】,所以出错时现象是静默的。排查
# 第一步永远是让它"开口":① grep CROND /var/log/cron
# 确认任务到底被调度执行了没有;② ★★ 在 crontab 行
# 尾加 > /tmp/x.log 2>&1 把任务的输出和报错全部
# 重定向到文件,下次跑完 cat 它就能看到真相。
修复 2:核心根因——cron 给任务的运行环境,极简到吝啬
# === ★ 这一节是全文的认知核心:cron 的环境 ≠ 你的环境 ===
# === ★ 先想清楚:你"手动跑脚本"时,环境从哪来 ===
# ★ 你 SSH 登录服务器,系统给你开了一个【登录 shell】。
# 这个 shell 启动时,会【依次读取并执行】一串
# 配置文件:
# - /etc/profile (系统级)
# - /etc/profile.d/*.sh (系统级,一堆)
# - ~/.bash_profile 或 ~/.bash_login 或 ~/.profile
# - ~/.bashrc (常被上面那个 source 进来)
# ★ 这一串文件里,写满了 export PATH=...、各种软件
# 的环境变量、别名、函数。它们【一行行执行下来】,
# 把你的环境"布置"得很丰富 —— 你的 PATH 很全,
# 各种变量都在。
# ★ ★ 关键:你习以为常的那个"环境",不是凭空就有
# 的,是这一串配置文件【加载】出来的。
# === ★ 再看:cron 跑任务时,环境从哪来 ===
# ★ ★ cron 是一个【守护进程】,它在系统启动时就跑
# 起来了,它【不是】通过"登录"来执行你的任务的。
# ★ ★★ 所以 cron 执行任务时,★ 根本不会去读
# /etc/profile、不会读 ~/.bash_profile、★ 不会读
# ~/.bashrc —— 这些文件,一个都不读。
# ★ 那 cron 给任务的环境是什么?是一个它自己
# 【内置的、极简的】环境。典型情况下只有:
# - PATH=/usr/bin:/bin ★ 就这俩,极其吝啬
# - HOME=/root(任务属主的家目录)
# - SHELL=/bin/sh
# - LOGNAME / USER
# - 几乎没有别的了。
# === ★ 亲眼看一看:cron 环境到底有多"穷" ===
# ★ 写一个临时任务,把 cron 的环境 dump 出来:
$ crontab -e
# 加一行(* * * * * 表示每分钟跑一次,测完删掉):
# * * * * * env > /tmp/cron_env.txt 2>&1
# 等一分钟,然后:
$ cat /tmp/cron_env.txt
# HOME=/root
# LOGNAME=root
# PATH=/usr/bin:/bin # ★★ 看,就这么点
# SHELL=/bin/sh
# ★ 对比你自己的环境:
$ env | wc -l
# 你命令行里 env 通常有几十行 —— cron 里只有四五行。
# === ★ 于是"手动能跑、cron 不能跑"就解释清楚了 ===
# ★ 你的脚本里写了 mysqldump(只写命令名,靠 PATH
# 去找)。
# ★ 手动跑:你的 PATH 里有 /usr/local/mysql/bin
# -> 找得到 mysqldump -> 正常。
# ★ cron 跑:PATH 只有 /usr/bin:/bin,里面没有
# mysqldump -> command not found -> 失败。
# ★ ★ 同一个脚本,同一台机器 —— 唯一的差别,就是
# 【运行它的环境不同】。这就是这类问题的总根。
# === 认知 ===
# ★ 你"手动跑脚本"时那个丰富的环境(全的 PATH、各种
# 变量),不是天生的 —— 是登录 shell 读了 /etc/profile、
# ~/.bash_profile、~/.bashrc 一串配置文件【加载】出来
# 的。★★ 而 cron 是守护进程,它执行任务时【根本不读
# 这些文件】,只给一个极简环境:PATH 通常只有
# /usr/bin:/bin,加 HOME/SHELL 等寥寥几个。"手动能跑
# cron 不能跑",几乎都是这个环境差异(尤其 PATH)。
修复 3:三种修法——让 cron 里的脚本能找到命令
# === ★ 知道了根因,修法有三种,从糙到正规 ===
# === ★ 修法一:脚本里所有命令,都写【绝对路径】 ===
# ★ 最直接、最可靠。脚本里不要写 mysqldump,写:
# /usr/local/mysql/bin/mysqldump ...
# ★ 命令的绝对路径,可以这样查到:
$ which mysqldump
# /usr/local/mysql/bin/mysqldump
$ command -v mysqldump
# ★ 把脚本里每个"靠 PATH 找"的命令,都换成绝对路径。
# ★ 优点:不依赖任何环境,铁定能找到。
# ★ 缺点:脚本写得啰嗦,换台机器装的路径变了就要改。
# === ★ 修法二:在脚本【开头】自己把 PATH 设好 ===
# ★ 不想每个命令都写绝对路径,就在脚本最上面,
# 把需要的目录【补进 PATH】:
#!/bin/bash
export PATH=/usr/local/mysql/bin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH
# ★ 这样脚本一开头就把 PATH 补全,后面照常写命令名
# 就行。★ 推荐这种 —— 改一行,管整个脚本。
# === ★ 修法三:在 crontab 里直接设 PATH ===
# ★ crontab 文件本身,顶部就可以写环境变量赋值:
$ crontab -e
# PATH=/usr/local/mysql/bin:/usr/local/bin:/usr/bin:/bin
# MAILTO="ops@example.com"
# 0 2 * * * /opt/scripts/backup.sh > /tmp/backup.log 2>&1
# ★ crontab 顶部写的 PATH=...,会作用于这个 crontab
# 里【所有】任务。
# ★ ★ 注意:crontab 里的环境变量赋值,语法是【纯
# 赋值】,★ 不能写 export,不能用 $ 引用别的变量
# (它不是 shell,是 cron 自己的简单解析)。
# === ★ 修法四(进阶):让脚本自己 source 该读的环境 ===
# ★ 如果脚本依赖的环境很复杂(某个软件装了一堆变量),
# 可以在脚本开头,主动把那份环境文件 source 进来:
#!/bin/bash
source /etc/profile # 主动加载系统环境
source ~/.bashrc 2>/dev/null || true # 按需加载自己的
# ★ 但要小心:.bashrc 里若有"非交互就 return"的判断,
# source 它可能不生效;且这样脚本对环境的依赖很重,
# 换机器易出问题。能用修法一/二,就别用这个。
# === ★ 顺便:为什么备份文件是"0 字节"而不是"没生成" ===
# ★ 脚本里大概率是:mysqldump ... > backup.sql
# ★ mysqldump 这条命令 command not found 失败了,
# 但 shell 的重定向 > backup.sql 是【先执行】的 ——
# 它先把 backup.sql 创建(清空)好,再去跑命令。
# ★ 命令失败了,没有任何输出 -> backup.sql 就停在
# "被创建好但没写入"的状态 -> 0 字节。
# ★ ★ 所以脚本一定要加 set -e(任一命令失败就整体
# 退出),别让它"带着错误继续跑完"。
# === 认知 ===
# ★ 让 cron 里脚本找得到命令,三种修法:① 脚本里命令
# 全写绝对路径(which 查路径),最可靠但啰嗦;②【推荐】
# 脚本开头 export PATH=...:$PATH 把需要的目录补进去,
# 改一行管全篇;③ crontab 顶部直接写 PATH=...(注意
# 是纯赋值,不能写 export)。另外脚本务必加 set -e ——
# 否则一条命令失败它还往下跑,会产出 0 字节这种
# "看着成功实则失败"的坏结果。
修复 4:时间字段的陷阱——5 个字段与"日和星期"的或关系
# === ★ cron 第二大坑:时间字段写错,任务在错的时间跑 ===
# === ★ 先把 5 个时间字段记牢 ===
# ★ crontab 一行的格式:★ 5 个时间字段 + 命令
#
# ┌─ 分钟 (0-59)
# │ ┌─ 小时 (0-23)
# │ │ ┌─ 日 (1-31)
# │ │ │ ┌─ 月 (1-12)
# │ │ │ │ ┌─ 星期 (0-7,0 和 7 都是周日)
# │ │ │ │ │
# * * * * * 要执行的命令
#
# ★ 0 2 * * * = 每天 2:00(分=0 时=2,日月周都不限)
# ★ */5 * * * * = 每 5 分钟一次(* /5 是"步长")
# ★ 0 9 * * 1 = 每周一 9:00(星期字段 = 1)
# ★ 0 0 1 * * = 每月 1 号 0:00(日字段 = 1)
# === ★★ 最阴险的陷阱:"日"和"星期"同时设,是【或】 ===
# ★ 直觉:0 0 1 * 1 —— "每月 1 号、并且是周一,才跑"。
# ★ ★★ 错!cron 的规则是:当【日】字段和【星期】
# 字段【都不是 *】时,它俩是【或(OR)】的关系 ——
# ★ "每月 1 号" 跑,【或者】"每个周一" 也跑。
# ★ 所以 0 0 1 * 1 的真实效果是:每月 1 号跑一次,
# 外加每个周一【都】跑 —— 一个月跑了五六次,
# 远远超出你以为的"一次"。
# ★ ★ 记住:想"每月某天",星期字段【必须留 *】;
# 想"每周某天",日字段【必须留 *】。两个一起设,
# 就掉进"或"的陷阱了。
# === ★ 陷阱:步长和范围别写错 ===
$ crontab -l
# */5 9-18 * * 1-5 cmd
# ★ 这行 = 周一到周五(1-5)、9 点到 18 点之间(9-18)、
# 每 5 分钟(*/5)跑一次。范围用 -,步长用 /,
# 多个离散值用逗号:0 0,12 * * * = 每天 0 点和 12 点。
# === ★ 陷阱:分钟字段留空 / 写成 * 的后果 ===
# ★ 新手常犯:想"每天 2 点",写成 * 2 * * * ——
# ★ 这是"2 点这一小时里,【每一分钟】都跑"=
# 一小时跑 60 次!"每天 2 点整"要写 0 2 * * *,
# 分钟字段必须是 0。
# === ★ 用工具验证你的时间字段对不对 ===
# ★ 别靠脑补。写完一个 cron 表达式,去在线的
# crontab 解释器(如 crontab.guru)上,或用
# 下面的办法,确认它真正的含义和"下次执行时间"。
# ★ 也可以临时把任务时间改成"下一分钟",观察
# /var/log/cron 它到底有没有在你预期的点跑。
# === 认知 ===
# ★ crontab 一行 = 5 个时间字段(分 时 日 月 周)+ 命令。
# ★★ 最阴险的坑:当【日】和【星期】两个字段【都不
# 是 *】时,它们是【或】关系不是"与" —— 0 0 1 * 1
# 会"每月 1 号"加"每个周一"都跑。想按月就让星期留
# *,想按周就让日留 *。其他坑:范围用 -、步长用 /、
# 离散值用逗号;想"每天 2 点整"分钟字段必须是 0,
# 写成 * 会一小时跑 60 次。写完务必用工具验证。
修复 5:cron 还有这些容易踩的坑
# === ★ 除了环境和时间,cron 还有几个高频坑 ===
# === ★ 坑 1:命令里的 % 号,有特殊含义 ===
# ★ ★ 在 crontab 里,百分号 % 是个【特殊字符】——
# cron 会把第一个 % 之后的内容,当作"喂给命令的
# 标准输入",并把 % 本身换成换行。
# ★ 最常踩的:用 date 格式化文件名:
# 0 2 * * * tar czf /backup/db-$(date +%Y%m%d).tar.gz /data
# ★★ 这里的 %Y %m %d 会被 cron 当特殊字符处理 ->
# 命令被腰斩,行为完全不对。
# ★ 修法:把每个 % 用反斜杠转义成 \%:
# 0 2 * * * tar czf /backup/db-$(date +\%Y\%m\%d).tar.gz /data
# ★ 或者:把这种逻辑写进【脚本里】,crontab 只调脚本
# —— 脚本内部的 % 没有这个问题。★ 这也是推荐做法。
# === ★ 坑 2:相对路径 —— cron 的工作目录不是你以为的 ===
# ★ cron 跑任务时,当前工作目录(cwd)通常是任务
# 属主的【家目录】(如 /root),不是脚本所在目录。
# ★ 所以脚本里若写了相对路径:
# cp config.ini /backup/ # ★ config.ini 在哪?
# ★ cron 跑时,它会去 /root/ 下找 config.ini —— 找不到。
# ★ ★ 修法:脚本里【一切路径都用绝对路径】;或在
# 脚本开头 cd 到一个确定的目录:
#!/bin/bash
cd /opt/scripts || exit 1 # 先把工作目录钉死
# === ★ 坑 3:crontab 文件末尾,必须有一个换行符 ===
# ★ 老版本的 cron 有个怪癖:crontab 文件【最后一行】
# 如果没有以换行符结尾,那【最后一个任务可能不被
# 执行】。用 crontab -e 正常编辑一般没事,但用
# crontab file 这种方式导入时要注意。
# === ★ 坑 4:用户 crontab vs 系统 crontab,别搞混 ===
# ★ crontab -e 编辑的是【当前用户】的 crontab。root
# 的 crontab 和 appuser 的 crontab 是【两份】。
# ★ 你以 root 配的任务,任务就以 root 身份跑;你
# 想让它以 appuser 跑,要 su 到 appuser 再 crontab -e,
# 或用 crontab -u appuser -e。
# ★ 另外还有 /etc/crontab 和 /etc/cron.d/ 里的【系统
# crontab】—— 它们的格式【多一个字段】:在"星期"
# 和"命令"之间,多一个【用哪个用户跑】的字段:
# # 系统 crontab(/etc/cron.d/xxx):分 时 日 月 周 用户 命令
# 0 2 * * * root /opt/scripts/backup.sh
# ^^^^ ★ 多出来的"用户"字段
# ★ 把这两种格式搞混(用户 crontab 里多写个用户名,
# 或系统 crontab 里漏写)是高频错误。
# === ★ 坑 5:任务跑太久,下一轮又起来了,叠罗汉 ===
# ★ 你设了 */5 每 5 分钟跑一次,但某次任务卡了,跑了
# 20 分钟 —— 这期间 cron 又起了好几个同样的任务,
# 它们【并发】跑,互相打架。
# ★ 修法:用 flock 给任务加个锁,没拿到锁就跳过:
# */5 * * * * flock -n /tmp/myjob.lock /opt/scripts/job.sh
# ★ -n = 拿不到锁就立刻放弃(不等)。保证同一时刻
# 只有一个实例在跑。
# === 认知 ===
# ★ cron 其他高频坑:① 命令里的 % 是特殊字符(会被当
# stdin 分隔),date +%Y 要转义成 +\%Y 或把逻辑挪进
# 脚本;② cron 的工作目录是属主家目录,脚本里别用
# 相对路径,或开头先 cd;③ crontab 文件末尾要有换
# 行符;④ 用户 crontab 和系统 crontab(/etc/cron.d)
# 格式差一个"用户"字段,别搞混;⑤ 任务跑太久会和
# 下一轮并发叠罗汉,用 flock -n 加锁。
修复 6:cron 任务排查纪律
# === 这次事故暴露的认知盲区,定几条纪律 ===
# === 1. ★ cron 任务出错是静默的,第一步永远是给它加输出重定向 ===
$ crontab -e
# 0 2 * * * /脚本 > /tmp/任务.log 2>&1 # 把输出和报错全留下
# === 2. ★ 确认任务到底被调度了没有,看 cron 日志 ===
$ grep CROND /var/log/cron | tail # CentOS
$ grep CRON /var/log/syslog | tail # Debian/Ubuntu
# === 3. ★★ "手动能跑 cron 不能跑",根因几乎都是环境(PATH)差异 ===
# cron 不读 /etc/profile / ~/.bashrc,只给极简环境
# === 4. ★ 脚本里命令写绝对路径,或脚本开头 export PATH 补全 ===
$ which 命令名 # 查命令的绝对路径
# === 5. ★ dump 一下 cron 的真实环境,亲眼对比 ===
# 临时任务:* * * * * env > /tmp/cron_env.txt 2>&1
# === 6. ★ 时间字段:日和星期都不为 * 时是【或】关系,别中招 ===
# === 7. ★ crontab 里 date 的 % 要转义成 \%,或把逻辑写进脚本 ===
# === 8. ★ 脚本里别用相对路径,cron 的工作目录是属主家目录 ===
# === 9. 脚本加 set -e,别让某条命令失败后还往下跑出坏结果 ===
# === 10. 排查"手动能跑 cron 不能跑"的步骤链 ===
$ grep CROND /var/log/cron | tail # ① 任务被调度了吗
$ crontab -e # 加 > /tmp/x.log 2>&1 # ② 把报错重定向出来
$ cat /tmp/x.log # ③ 看真实报错
# command not found -> 是 PATH 问题:命令写绝对路径 / 补 PATH
# No such file -> 是相对路径问题:路径改绝对 / 开头 cd
# Permission denied -> 是权限/运行用户问题:见权限排查
命令速查
需求 命令
=============================================================
编辑当前用户的 crontab crontab -e
查看当前用户的 crontab crontab -l
编辑指定用户的 crontab crontab -u 用户名 -e
看 cron 调度日志 grep CROND /var/log/cron(CentOS)
看 cron 调度日志 grep CRON /var/log/syslog(Debian)
把任务输出报错存进文件 命令 > /tmp/x.log 2>&1
dump cron 的运行环境 * * * * * env > /tmp/env.txt 2>&1
查命令的绝对路径 which 命令 / command -v 命令
给任务加锁防并发 flock -n /tmp/x.lock 脚本
crontab 里 date 转义 date +\%Y\%m\%d
系统 crontab 位置 /etc/crontab /etc/cron.d/
口诀:手动能跑 cron 不能跑 先给任务加 > log 2>&1 看报错
cron 不读你的 .bashrc 环境极简 命令写绝对路径或自己补 PATH
避坑清单
- cron 任务在没有终端的后台运行,输出和报错默认全被丢弃,排查第一步是加重定向到文件
- 先 grep CROND /var/log/cron 确认任务到底被 cron 调度执行了没有,再排别的
- cron 执行任务不读 /etc/profile 也不读 ~/.bashrc,只给一个极简的运行环境
- cron 给的 PATH 通常只有 /usr/bin:/bin,手动能跑 cron 不能跑几乎都是 PATH 差异
- 脚本里命令要写绝对路径,或在脚本开头 export PATH 把需要的目录补进去
- crontab 时间字段里日和星期都不为星号时是或关系不是与,按月就让星期留星号
- 想每天某点整分钟字段必须写 0,写成星号会在那一小时里每分钟都跑一次
- crontab 命令里的百分号是特殊字符,date 的 %Y 要转义成反斜杠 %Y 或把逻辑写进脚本
- cron 的工作目录是任务属主的家目录,脚本里别用相对路径或在开头先 cd 到确定目录
- 任务跑太久会和下一轮并发叠罗汉,用 flock -n 加锁保证同一时刻只有一个实例
总结
这次"手动能跑、cron 跑出来是空的"的事故,纠正了我一个关于"程序"的、藏得极深的错觉。在我过去的脑子里,一个脚本,就是一段【自包含】的逻辑。我写好了 backup.sh,它里面每一行命令都清清楚楚,它该干什么、会得到什么结果,在我看来是【脚本自己决定】的——脚本是因,结果是果,因果之间,严丝合缝,跟"谁来运行它""在什么场合运行它"没有半点关系。所以当同一个脚本,我手动跑是对的、cron 跑是空的,我整个人是崩溃的:同样的因,怎么会有两个果?这违反了我心里"程序是确定的"那条铁律。我甚至一度怀疑是不是 cron 有 bug、是不是脚本被人偷偷改过。直到我把 cron 的环境 env 出来,和我自己的 env 并排一放,我才看见我一直忽略的那半边天:一个脚本,从来都不是"自包含"的。它写的 mysqldump,根本不是一个完整的指令——它是一句【省略了主语的话】,它说的其实是"去某个地方,找一个叫 mysqldump 的东西,执行它"。而"某个地方"在哪,mysqldump 到底指谁,这个脚本【自己根本没说】,它把这件事,完全甩给了运行它的那个【环境】。脚本只是半句话,环境才是另外半句。我手动跑它,我的环境替它补上了"去 /usr/local/mysql/bin 找";cron 跑它,cron 那个吝啬的环境补的是"只在 /usr/bin:/bin 找"——同一句半截话,被两个不同的环境补成了两句完全不同的完整指令,结果当然天差地别。复盘到最深,我意识到我犯的错,是把"环境"这个东西,当成了【透明的、理所当然的、不存在的】背景。我每天 SSH 上去,敲什么命令都好使,我的 PATH 那么全、那么顺手,顺手到我从来没意识到它【是被布置出来的】——是 /etc/profile、是 ~/.bashrc 里那一行行 export,在我登录的那一刻,默默替我把舞台搭好了。我把这个搭好的舞台,当成了"世界本来的样子"。可 cron 提醒了我:那不是世界本来的样子,那只是【登录 shell 给我布置的一个特定场景】。cron 在另一个场景里跑,它没经历过那场"布置",它面对的,才是更接近"素颜"的系统。我那个脚本,是个被宠坏的孩子——它一直在我替它精心布置好的环境里长大,从没自己学会"我要的东西到底在哪",一旦被丢进 cron 那个陌生、简陋的环境,它立刻就不会走路了。这次最大的收获,是我学会了一个新的追问:在我说"这段代码是对的"之前,先问一句——它对,是【它自己对】,还是【它恰好待在一个让它显得对的环境里】?一个程序在我的开发机上跑得好好的,换到测试机就崩,换到容器里就崩,换个用户跑就崩——这些,和我那个 backup.sh 是【同一种病】:代码本身藏着一根根【伸向环境的隐形触手】(它读的环境变量、它依赖的 PATH、它假设的工作目录、它假设存在的某个文件),只要新环境没能接住这些触手,程序就摔了。而这些触手,因为平时总有环境稳稳接着,你根本看不见它们的存在。所以现在,每写一个要"自动运行"的脚本,我都会强迫自己做一遍"剥离测试":假设它将在一个【什么都没有】的环境里运行——没有我的 PATH,没有我的别名,没有我习惯的工作目录,没有我 .bashrc 里那些方便——它还能不能独立地、完整地把活干完?能,它才是真的健壮。cron 教给我的,不是怎么写定时任务,而是一件更根本的事:你以为你在测试你的代码,其实你一直在测试"你的代码 + 你的环境"这个【整体】。想知道代码本身到底行不行,你得先有勇气,把那个一直在背后托着它的环境,抽掉。
—— 别看了 · 2026