一、症状:定时任务里成功,登录进去手动跑就失败
几乎每个写过运维脚本的人都遇到过这种反差:同一个脚本,root 登录上去跑得干干净净,放进 crontab 却每天报错;或者反过来,cron 里跑得好好的,一进交互式 shell 就路径找不到。这类问题有个共同的根因:脚本依赖了「当前这个 shell 会话的隐式环境」,而不同执行者提供的隐式环境完全不同。
把环境差异拆开看,其实只有几项:PATH、工作目录、用户与权限、umask、locale(影响排序与字符比较)、以及一些看着无关紧要的变量(HOME、SHELL、TMPDIR)。绝大多数「脚本只在某处能跑」的事故,都能落到这六项里的一项。
二、PATH 是最经典的一项:cron 的 PATH 短得惊人
在 Debian/Ubuntu 上,crontab 执行脚本时的 PATH 通常只有 /usr/bin:/bin,而登录 shell 的 PATH 可能在 /usr/local/bin、/usr/local/sbin、/snap/bin 甚至用户自己的 ~/.local/bin 里。于是 docker、rclone、php、composer 这些装在非标准路径的命令在 cron 里全都「command not found」。
三种修法,按推荐程度排序:
- 脚本内绝对路径。最稳,但要一个个改,且升级路径变化时要跟着改。
- 脚本开头显式设 PATH。
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin写死在脚本顶部,不依赖调用者。 - crontab 顶部统一设置。
PATH=...一行对下面所有任务生效,适合一个 crontab 里挂多个任务的情况。
有个坑值得单独说:不少人用 source ~/.bashrc 来「继承环境」,这在小规模时能用,但 .bashrc 里常有 [ -z "$PS1" ] && return 这类非交互判断,在 cron 下会提前返回,导致后半段的环境配置根本没执行。要用也用一个专门的 /etc/profile.d/site-env.sh,只放环境变量、不放任何交互逻辑。
三、工作目录:cron 默认在你不知道的地方
cron 启动脚本时的工作目录是调用者的 HOME(root 是 /root),而 systemd 启动服务时默认是 /。于是脚本里所有相对路径都成了定时炸弹:./backup.sh、logs/app.log、../config.php 在不同执行者下指向完全不同的位置。
正确姿势只有一条:脚本开头 cd 到它自己所在目录,让所有相对路径都有确定的基准。
#!/usr/bin/env bash
set -euo pipefail
cd "$(dirname "$(readlink -f "$0")")"
readlink -f 是为了处理符号链接(/usr/local/bin/deploy 指向 /opt/site/deploy.sh 的情况),否则 dirname 会给出 /usr/local/bin,cd 到错误目录。这个写法几乎没有成本,应该成为所有脚本的第一行之后的内容。
四、用户与权限:root 跑通不等于 www-data 能跑
调试脚本时大家习惯用 root,但线上任务往往以 www-data、deploy 这类专用用户运行。两者的差别不止文件读写权限,还包括:能不能读 /root 下的配置文件、能不能 bind 1024 以下端口(非 root 需要 CAP_NET_BIND_SERVICE)、能不能 chown(非 root 只能改自己的文件)。
判断方法很直接:用目标用户身份跑一次。
sudo -u www-data -- env -i PATH=/usr/bin:/bin bash -c './脚本.sh'
env -i 会清空所有环境变量,模拟最干净的环境。如果这一步过了,cron 里基本就过了;如果挂了,报错信息会直接告诉你是哪个文件读不到、哪条命令不存在。这个技巧比在 crontab 里反复加日志快得多。
五、umask 与 locale:两个最隐蔽的差异
umask 决定新建文件的默认权限。登录 shell 通常是 022(目录 755、文件 644),但某些服务管理器的默认值可能是 027 或 077,于是脚本生成的日志、上传的文件、缓存的目录权限与线上预期不一致。表现是「脚本说写成功了,但网站 403」。
脚本里如果需要确定的权限,别指望继承,直接在创建后显式设置,或在关键段落里临时改:
umask 022
install -m 644 -o www-data -g www-data /tmp/app.conf /etc/site/app.conf
locale 的影响更冷门但同样常见。LC_ALL=C 与 en_US.UTF-8 下 sort 的顺序不同,grep -i 对非 ASCII 的处理也不同。一个用 sort | diff 做配置比对的脚本,在 cron 下可能天天报「配置被改动」,手动跑却正常。稳妥做法是凡是参与比较、排序、编号的输出,脚本统一设 export LC_ALL=C,中文内容只用 grep 做匹配而不参与排序。
六、交互式与非交互式的另一个差异:stdin、tty 与超时
手动跑的时候终端给你一个 tty,很多命令会「看起来卡住但其实在等待输入」,比如 ssh 第一次连接会问是否接受指纹、apt 会问是否继续、gpg 会等密码。cron 下没有 tty,这些命令会直接读到 EOF 并失败,或者更糟——挂在半路,昨天的任务还没结束,今天的又启动了。
对每条可能交互的命令加对应的非交互参数,并把输入重定向到 /dev/null:
ssh -o BatchMode=yes -o StrictHostKeyChecking=accept-new host 'cmd'
DEBIAN_FRONTEND=noninteractive apt-get -y install pkg
gpg --batch --pinentry-mode loopback --passphrase-file /root/.bk.key ...
再加一层保险:用 timeout 包住可能挂住的命令。timeout 600 rsync ... 比事后发现任务堆叠要好得多。另外建议给每个任务加 flock,防止同一任务并发:
flock -n /var/lock/backup.lock /opt/site/backup.sh
-n 表示拿不到锁就立刻退出,配合退出码 1,能让人在日志里清楚看到「上一次还没跑完」,而不是两个任务互相踩文件。
七、把差异固化:一个自检脚本
与其每次出问题再去猜,不如写一个自检脚本,在任何执行环境下都能打印出关键事实,便于对照:
#!/usr/bin/env bash
echo "user=$(id -un) uid=$(id -u)"
echo "pwd=$(pwd)"
echo "path=$PATH"
echo "umask=$(umask)"
echo "locale=${LC_ALL:-unset}/${LANG:-unset}"
echo "home=${HOME:-unset}"
echo "shell=${SHELL:-unset}"
command -v php nginx mysql tar gpg 2>/dev/null
把这个脚本分别从终端、cron、systemd 各跑一次,把三份输出并排贴在一起看,环境差异一目了然。这个动作花三分钟,能省掉后面几个小时的「为什么这里行那里不行」。
八、迁移建议:cron 到 systemd 时需要重写的地方
如果准备把任务从 cron 迁移到 systemd timer,有几处语义不同必须注意:systemd 不读 .bashrc、不读 crontab 的 PATH=,环境变量要在 unit 里用 Environment= 声明;工作目录用 WorkingDirectory=;此外 % 在 unit 文件里是特殊字符(表示换行),日期格式里含 % 的要写成 %%——这一点在老脚本迁移时最常炸。
[Service]
Type=oneshot
User=www-data
WorkingDirectory=/opt/site
Environment=PATH=/usr/local/bin:/usr/bin:/bin LC_ALL=C
ExecStart=/opt/site/backup.sh
迁移后一定用 systemctl start 单元名 手动触发一次,再 journalctl -u 单元名 -n 50 看输出。systemd 会把 stdout/stderr 收进 journal,这比 cron 里「邮件发不出去就丢失」强很多,也是迁移的主要收益之一。
九、日志与输出:cron 把 stdout 发邮件,systemd 收进 journal
环境差异之外,输出通道是另一处经常被忽略的差异。cron 执行任务时,任何写到 stdout 或 stderr 的内容都会被当成邮件正文,通过本机的 MTA(sendmail/postfix)尝试投递,而这台机器上往往压根没装 MTA 或者没配转发,于是这些输出被静默丢弃。表现是「脚本出错了,但没有任何通知」,人只能靠事后发现数据不对。
正确做法是让脚本自己管理输出:所有关键信息写进固定日志文件,并带上时间戳;crontab 行尾统一加 >> /var/log/任务名.log 2>&1;同时让脚本在失败时返回非零退出码。这样即使邮件通道不通,日志里仍有完整记录。
# crontab 示例
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
0 3 * * * flock -n /var/lock/bk.lock /opt/site/backup.sh >> /var/log/backup.log 2>&1
日志本身也会成为问题:一个每天跑、每次输出几百行的任务,一年下来能吃掉几个 GB,而磁盘写满会连带让数据库和 PHP 一起挂掉。所以写日志的同一天就要配好轮转,logrotate 的配置里注意 copytruncate:如果脚本是常驻进程并且一直持有文件句柄,不写 copytruncate 的话轮转后进程会继续写旧文件,新文件永远是空的,看起来像「日志停了」。
十、时间与时区:跨天任务最典型的坑
定时任务的时间基准是服务器本地时区,而很多云主机的默认时区是 UTC。一个写在凌晨三点执行的任务,在 UTC 服务器上实际是北京时间上午十一点,正好撞上流量高峰。这类问题不会报错,只会让「本来该在没人用的时候跑的重活」跑到白天去。
检查方式一条命令:timedatectl 看 Time zone 一行。如果站点面向国内用户而服务器是 UTC,两种选择:整机改成 Asia/Shanghai(注意改完 MySQL 的时区、PHP 的 date.timezone、已经写入的历史数据不会跟着变,要评估影响),或者保持 UTC、把 crontab 里的时间全部换算成 UTC 并加注释说明。后者更安全,因为不改变任何已存在的行为,新任务按 UTC 排即可。
另一个相关陷阱是脚本内部用 date 生成文件名。如果脚本在 23:50 启动、跨到次日 00:10 结束,中途两次调用 date +%F 会得到两个不同的日期,于是同一次备份生成了两个文件、清理脚本只删掉其中一个。稳妥做法是脚本开头取一次日期存进变量,全程复用:D=$(date +%F),后面所有地方都用 $D。
十一、小结
「脚本只在登录 shell 里能跑」不是玄学,把 PATH、工作目录、执行用户、umask、locale、tty 这六项逐个固定下来,问题自然消失。三行代码能解决其中最关键的两项(set -euo pipefail 和 cd "$(dirname "$(readlink -f "$0")")"),剩下的靠 env -i 复现和自检脚本对照。养成这个习惯之后,新写的脚本第一次进 crontab 就能跑,不用再靠运气。