脚本手动跑得好好的,放进 crontab 就各种失败
起因
我在一台跑政务数据同步的机器上写了个备份脚本,每天凌晨把数据库导出来打包传到对象存储。写完自己执行了一遍:
$ /usr/local/bin/backup.sh
备份完成,文件大小 234M
没问题。于是加进 crontab:
0 2 * * * /usr/local/bin/backup.sh
第二天早上看结果,文件是 0 字节。查 cron 日志:
$ journalctl -u cron --since today
... CMD (/usr/local/bin/backup.sh)
... (root) CMDOUT (/usr/local/bin/backup.sh: line 12: mysqldump: command not found)
... (root) CMDOUT (/usr/local/bin/backup.sh: line 12: aws: command not found)
手工跑明明好好的,怎么一进 crontab 就找不到命令了。
第一个坑:cron 的 PATH 只有两个目录
这个问题我踩过不止一次。cron 不会读你的 .bashrc、.profile,它给的是一套极简环境。可以直接验证——临时加一条每分钟执行的环境转储:
* * * * * env > /tmp/cron-env.txt
等一分钟,然后跟当前 shell 的环境对比:
$ cat /tmp/cron-env.txt
SHELL=/bin/sh
PATH=/usr/bin:/bin
PWD=/root
LANG=
HOME=/root
...
看到了,PATH=/usr/bin:/bin。而我装的 mysqldump 在 /usr/local/mysql/bin,aws 是 pip 装的在 /usr/local/bin。这两个目录都不在 cron 的 PATH 里,所以全军覆没。
对比一下自己的:
$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
差了整整一截。
改法有三种,我一般选第二种:
一是命令全写绝对路径,太丑,脚本一长就没法看了。
二是在脚本里显式设 PATH,在 #!/bin/bash 下面加一行:
#!/bin/bash
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:$PATH
这个最省事,而且脚本不管被谁调用(cron、systemd、手动)行为都一致。
三是在 crontab 顶部写 PATH,注意 crontab -e 里可以定义变量:
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
MAILTO=""
0 2 * * * /usr/local/bin/backup.sh
这个只影响当前用户的 crontab,如果你还有其他人在机器上加任务,管不到他们。
记得改完验证,别再等第二天早上:
* * * * * /usr/local/bin/backup.sh >> /tmp/backup-test.log 2>&1
跑一次看日志没问题,再改回正式时间。
第二个坑:% 号会被当成换行
备份脚本里想给文件名加个日期,第一次是这么写的:
0 2 * * * /usr/local/bin/backup.sh > /var/log/backup-$(date +%F).log 2>&1
结果发现日志文件名里没有日期,还多了一堆莫名其妙的报错。原因是 cron 里 % 有特殊含义——它表示命令行到此结束,后面的内容会作为标准输入传给命令。想用字面 % 必须转义:
0 2 * * * /usr/local/bin/backup.sh > /var/log/backup-$(date +\%F).log 2>&1
\% 才是字面量。我后来干脆不折腾,把日期逻辑挪进脚本里:
LOG="/var/log/backup/$(date +%F).log"
crontab 里只留一句 /usr/local/bin/backup.sh,清爽多了。这条也算经验:crontab 里尽量别写复杂表达式,全扔进脚本。
第三个坑:locale 没了,Python 报编码错
修完 PATH 之后,脚本能跑到 Python 那步了,但又报:
UnicodeEncodeError: 'ascii' codec can't encode character '\u4e2d'
中文的行政区划名称导不出来。原因还是环境——cron 给的是 LANG=(空),Python 3 在 locale 为 C/POSIX 时,open() 默认编码会退回 ASCII。
验证:
$ python3 -c "import locale,sys; print(locale.getpreferredencoding(), sys.stdout.encoding)"
ANSI_X3.4-1968 ANSI_X3.4-1968
在 cron 环境下执行同样的命令,输出就是这个。而我自己登录时是 UTF-8 UTF-8。
解法,脚本里加:
export LANG=C.UTF-8
export LC_ALL=C.UTF-8
或者 Python 里别依赖默认编码,打开文件时写死:
with open(path, "w", encoding="utf-8") as f:
...
不过要说清楚:Python 3.7 之后有 PEP 538 的 locale 自动转换,多数发行版上解释器会把 C locale 悄悄改成 C.UTF-8,所以这个报错不是每次都能复现。但换成精简容器镜像、或者显式设了 PYTHONCOERCECLOCALE=0、LC_ALL=C,它就一定冒出来。别赌运气,环境变量和显式 encoding 两边都兜住最稳。
我两个都做了——环境变量保底,代码里显式声明,免得哪天有人换了发行版又出问题。C.UTF-8 比 zh_CN.UTF-8 保险,因为它基本所有系统都自带,不用额外 locale-gen。
第四个坑:工作目录不是你以为的地方
脚本里有一行:
tar -czf backup.tar.gz ./data/
手工执行时我在 /opt/app 目录下,打包正常。cron 执行时工作目录是用户的 home(root 就是 /root),于是打包了个空气。
这个坑排查起来挺费劲,因为它不报错,只是结果不对。加一行 pwd 到日志里就能看出来。
改法就一条——脚本开头先 cd 到固定目录:
#!/bin/bash
set -euo pipefail
cd /opt/app || exit 1
set -euo pipefail 顺手加上,-e 让脚本遇到错误就退出而不是继续跑,-u 让引用未定义变量时报错,-o pipefail 让管道里任意一环失败都算失败。备份脚本尤其需要这个,不然中间一步失败了,最后还把半个包传上去,更坑。
第五个坑:失败了没有任何声音
最折磨人的是第一次那次故障:脚本失败了,但我两天后才发现。cron 默认会把输出发邮件给任务所属用户,可服务器上根本没配邮件,输出直接丢进黑洞。
现在我的 crontab 统一这么写:
MAILTO=""
0 2 * * * /usr/local/bin/backup.sh >> /var/log/backup.log 2>&1
MAILTO="" 是明确告诉 cron 别发邮件(省得它去调 sendmail 卡住),输出全重定向到日志文件。然后另配一个检查——比如用 logrotate 管日志轮转,再加一条每周的核对任务,确认最近一次备份文件的大小和修改时间是否正常:
0 9 * * * /usr/local/bin/check-backup.sh
check-backup.sh 里就干一件事:
#!/bin/bash
F="/data/backup/latest.tar.gz"
AGE=$(( ($(date +%s) - $(stat -c %Y "$F")) / 3600 ))
SZ=$(stat -c %s "$F")
if [ "$AGE" -gt 26 ] || [ "$SZ" -lt 1048576 ]; then
logger -t backup-check "备份异常 age=${AGE}h size=${SZ}"
# 这里接实际告警通道
fi
什么时候该换 systemd timer
cron 够用,但有两个场景我更倾向 timer:任务需要依赖网络已就绪,或者想方便地查上次执行时间和退出码。
# /etc/systemd/system/backup.timer
[Unit]
Description=Daily backup
[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
Persistent=true 的意思是:如果到点机器是关的,开机后补跑一次。cron 做不到(那是 anacron 的活)。RandomizedDelaySec 避免一堆机器同时开跑,政务云那种批量部署的机器特别需要,不然对象存储的带宽全被自己占满。
看执行情况:
systemctl list-timers backup.timer
journalctl -u backup.service --since yesterday
比翻 /var/log/cron 舒服多了。
一份自查清单
脚本进 crontab 之前,我现在固定过一遍这五条:
PATH够不够,所有外部命令能不能找到(脚本里export PATH=...最省心)- crontab 里有没有裸的
%,有就\%,或者直接把逻辑挪进脚本 - 需不需要中文或 UTF-8,需要就
export LANG=C.UTF-8 - 脚本里有没有依赖相对路径,有就开头
cd到绝对路径 - 失败能不能被发现——日志重定向 + 结果核对任务
前四条解决"跑不起来",第五条解决"跑失败了不知道"。这次踩完,备份这块才算真正不用每天操心。
- 点赞
- 收藏
- 关注作者
评论(0)