脚本手动跑得好好的,放进 crontab 就各种失败

举报
phantomxjc 发表于 2026/10/22 08:46:20 2026/10/22
【摘要】 备份脚本手工执行一切正常,加进 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 之前,我现在固定过一遍这五条:

  1. PATH 够不够,所有外部命令能不能找到(脚本里 export PATH=... 最省心)
  2. crontab 里有没有裸的 %,有就 \%,或者直接把逻辑挪进脚本
  3. 需不需要中文或 UTF-8,需要就 export LANG=C.UTF-8
  4. 脚本里有没有依赖相对路径,有就开头 cd 到绝对路径
  5. 失败能不能被发现——日志重定向 + 结果核对任务

前四条解决"跑不起来",第五条解决"跑失败了不知道"。这次踩完,备份这块才算真正不用每天操心。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。