crontab 一共只有五个时间字段,写起来不超过一行,但它不出效果的时候,够排查一整个下午。更麻烦的是,很多人把问题归到“时间写错了”,实际上任务压根没执行,或者执行了但脚本里的命令找不到、路径不对、权限不够。这三个原因,全部和 Linux 权限模型绑在一起。
所以这篇不按“先讲 crontab 语法、最后提一句权限”的顺序来。先把 Linux 的文件权限、属主属组、特殊权限位讲清楚,再回到 crontab:它以谁的身份运行、哪些用户被允许创建任务、cron 守护进程本身需要什么权限、脚本文件需要什么权限。这条线串起来之后,很多“玄学不执行”就会变成可解释的现象。
本文会覆盖这几个部分:Linux 权限模型与 chmod/chown/umask 实操;crontab 五个字段的时间表达式与特殊写法;crontab 的 allow/deny 权限控制;定时任务脚本的编写规范与环境变量陷阱;日志查看与故障定位;以及一份可以直接照抄的排查清单。每一节都给可复制的命令和配置,不做纯概念罗列。
适合谁看:刚接手 Linux 服务器、需要写定时备份和日志清理的运维和开发;被 crontab 不执行折磨过、想搞明白底层原因的同学;以及准备考 RHCE、LPIC 或者面试被问“crontab 为什么不执行”的人。读完你应该能独立写出稳定运行三个月的定时任务,并且在它挂掉的时候十分钟内定位原因。
1. Linux 权限模型与 crontab 的关系
1.1 权限三元组到底约束了什么
Linux 上每个文件都有三组权限位,分别对应属主(owner/u)、属组(group/g)、其他人(others/o),每组三个位是读(r)、写(w)、执行(x)。这个模型看起来简单,但它约束的对象是“进程的有效用户 ID 和有效组 ID”,而不是“登录的人”。这一点是理解 crontab 权限问题的前提。
当你手动在终端执行./backup.sh,你的 shell 进程 uid 是你自己,脚本以你的身份运行,能读你 home 下的文件。而 cron 触发任务的时候,cron 守护进程会 fork 一个子进程,然后setuid到 crontab 文件所属用户的 uid,再 exec 你的命令。也就是说,定时任务里的命令,实际身份是“写这条 crontab 的那个用户”,而不是“当前登录的人”。
这就解释了一个常见现象:root 手动跑脚本一切正常,把同一条命令写进 www-data 的 crontab 就报 Permission denied。因为 www-data 读不了 root 建的目录,也写不进 root 拥有的日志文件。看到 Permission denied 不要先怀疑 crontab 本身,先确认执行身份的权限边界。
用id和ls -l就能把这条线确认下来:
# 确认脚本属主和权限位 ls -l /opt/scripts/backup.sh # 确认 crontab 所属用户的身份 id www-data # 以目标用户身份模拟执行一次 sudo -u www-data /opt/scripts/backup.sh第三条命令是关键。手动以目标用户身份跑一遍,能排除掉绝大部分“crontab 权限问题”。如果sudo -u www-data直接跑都失败,那问题百分之百不在 cron 上。
1.2 数字权限、符号权限与 chmod 实操
chmod 有两套写法:数字法和符号法。数字法的规则是 r=4、w=2、x=1,三组相加。755 的含义是属主 rwx(7)、属组 r-x(5)、其他人 r-x(5)。644 是属主读写、其他人只读。600 是只有属主能读写,其他任何人碰不到。
符号法更适合增量调整,格式是[ugoa][+-=][rwxX]。u+x给属主加执行位,g-w去掉属组的写权限,o=r把其他人权限直接设为只读,a+x是所有人都加执行位。X(大写)是个特殊写法,只在目标是目录或者已经带执行位时才加 x,适合批量处理脚本目录。
# 脚本:属主可读写执行,属组和其他人只读执行 chmod 755 /opt/scripts/backup.sh # 配置文件里含数据库密码,必须收紧到只有属主可读 chmod 600 /opt/scripts/.db.env # 符号法:递归给目录树补上属主执行位,但不给普通文件乱加 x chmod -R u+X /opt/scripts/ # 确认结果 stat -c '%A %U:%G %n' /opt/scripts/backup.shstat -c '%A %U:%G %n'这条命令比ls -l更好用,输出是紧凑的权限串加属主属组,适合写进检查脚本。定时任务脚本的执行位是最容易漏的一环:用crontab -e写完直接保存,第二天发现没跑,原因就是脚本是 644,没有 x 位。cron 报错写进邮件或者日志,但很多人根本没配邮件,于是错误信息就消失了。
有个细节值得单独说:如果 crontab 里写的是bash /opt/scripts/backup.sh或者sh /opt/scripts/backup.sh,那脚本不需要执行位也能跑,因为被解释器读取执行。但如果写的是/opt/scripts/backup.sh直接调用,就必须要 x 位,并且 shebang 那一行要正确。这两种写法的权限要求不一样,排查的时候要分清楚。
1.3 chown、chgrp 与属主变更的边界
chown 改属主,chgrp 改属组,chown user:group file一次改两个。普通用户不能把文件送给别人,这是硬限制:只有 root(更准确说是带 CAP_CHOWN 能力的进程)才能改变文件的属主。普通用户可以改属组,但只能改成自己所属的组之一。
这条限制在定时任务里经常被踩到。典型场景是:Web 应用以 www-data 运行,需要往/var/log/myapp/写日志,管理员建目录的时候用的是 root,权限 755,结果 www-data 写不进去。正确做法是把目录属主交给 www-data,或者用 ACL 单独授权。
# 把应用目录整体交给运行用户 chown -R www-data:www-data /var/www/myapp # 只改属组,保持属主不变 chgrp deploy /opt/releases/current # 用 ACL 给单个用户精确授权,不动属主 setfacl -m u:www-data:rwx /var/log/myapp getfacl /var/log/myappACL 是被低估的工具。当你不希望把整个目录的所有权交出去,只是想让某个用户能写一个子目录时,setfacl 比 chown 更精准。注意 ACL 需要文件系统挂载时启用 acl 选项,ext4 默认开启,XFS 也默认支持。用完记得getfacl确认,因为 ACL 的继承规则和默认 ACL 容易配错,配错了比没配更难查。
1.4 umask 与默认权限
umask 决定新建文件的初始权限。规则是:文件基准 666,目录基准 777,减去 umask 得到实际权限。默认 umask 022 的时候,新建文件是 644,新建目录是 755。如果 umask 是 077,新建文件是 600,目录是 700,只有自己能访问。
这个和 crontab 有什么关系?关系在于:定时任务脚本里创建的文件,继承的是 cron 进程的 umask,而 cron 进程的 umask 来自守护进程启动时的设置,通常就是系统默认的 022。这意味着你的备份脚本在 cron 里跑出来的文件是 644,任何人可读。如果备份内容包含数据库导出、密钥、用户数据,这就是一个实打实的信息泄露面。
解决办法是在脚本开头显式设置 umask:
#!/bin/bash set -euo pipefail # 定时任务里创建的文件默认只有属主可读写 umask 077 BACKUP_DIR=/data/backup DATE=$(date +%Y%m%d_%H%M%S) mysqldump --single-transaction mydb > "${BACKUP_DIR}/mydb_${DATE}.sql"umask 077放在脚本最前面,后面所有重定向创建的文件都是 600,mkdir 出来的目录是 700。这个习惯建议固定下来,凡是涉及数据导出的脚本都加上。检查当前 umask 用umask命令,改成 077 用umask 077,但这个只对当前 shell 生效,要持久化得写进/etc/profile或者脚本本身。
1.5 SUID、SGID、Sticky 三个特殊位
这三个位在 crontab 场景里不算高频,但面试和排错都会遇到,值得单独拎出来。SUID 对应数字 4,作用在可执行文件上,进程运行时临时获得文件属主的权限,经典例子是/usr/bin/passwd,它属主是 root 且带 SUID,普通用户执行时能以 root 身份改 shadow 文件。
SGID 对应数字 2,用在可执行文件上时进程获得文件属组的权限;用在目录上时,目录里新建的文件会继承目录的属组,而不是创建者的主组。这个对协作目录非常有用。Sticky 对应数字 1,只对目录有意义,作用是目录里的文件只有属主本人(或 root)能删除,/tmp就是典型的 1777。
# 查看系统里所有带 SUID 的文件,安全审计常用 find / -perm -4000 -type f 2>/dev/null # 给共享目录设置 SGID,新建文件自动继承目录属组 chmod 2775 /srv/shared chgrp deploy /srv/shared # 确认特殊位,s 出现在属主 x 位是 SUID,组 x 位是 SGID ls -ld /srv/shared为什么提这个?因为如果你在排查“为什么我的定时任务创建的文件属组不对”,答案很可能就是父目录没设 SGID。另一个相关点是:绝对不要给自己的脚本加 SUID。脚本加 SUID 在多数现代内核上被直接忽略(出于安全考虑),而且一旦生效就是提权漏洞。需要提权就用 sudo 配 NOPASSWD 并限定命令,并且只对具体命令授权,不要给整个脚本无限制的 root 权限。
2. crontab 定时任务的核心机制
2.1 cron 守护进程与文件布局
cron 是一个常驻的守护进程,每分钟醒来一次,检查所有 crontab 文件里有没有匹配当前时间的任务,匹配就 fork 执行。它的工作方式决定了几个特性:最小粒度是一分钟,不支持秒级;任务之间相互独立,没有依赖关系;上一个任务没跑完,下一个时间点到了照样会再启动一个,这就是为什么需要加锁。
不同发行版的实现和文件位置有差异,写脚本和排错的时候必须知道自己在哪个系统上:
| 发行版 | 服务名 | 用户 crontab 位置 | 日志位置 |
|---|---|---|---|
| CentOS / RHEL / Rocky | crond | /var/spool/cron/<user> | /var/log/cron |
| Ubuntu / Debian | cron | /var/spool/cron/crontabs/<user> | /var/log/syslog 或 journalctl -u cron |
| Alpine | crond | /var/spool/cron/crontabs/<user> | 需手动开启,或看容器标准输出 |
除了用户级 crontab,还有几个系统级入口:/etc/crontab是系统主配置,格式比用户 crontab 多一个“用户名”列;/etc/cron.d/目录下可以放多个片段文件,格式和/etc/crontab一样,也必须带用户名列;/etc/cron.hourly、daily、weekly、monthly是给run-parts用的目录,里面放可执行脚本,不需要写时间表达式。
这里有个高频错误:把/etc/crond.d/下的片段文件写成用户 crontab 的五字段格式,少写了用户名。结果 cron 把命令的第一个词当成用户名,直接报bad username或者静默跳过。检查方式是看日志里有没有(root) CMD之外的异常行,或者用crontab -u逐个确认。
# 系统级片段文件示例:多一个用户名列,这里指定以 root 运行 cat > /etc/cron.d/nightly-backup <<'EOF' SHELL=/bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin MAILTO=ops@example.com 30 2 * * * root /opt/scripts/backup.sh >> /var/log/backup.log 2>&1 EOF # 片段文件权限不当会被 cron 直接忽略 chmod 644 /etc/cron.d/nightly-backup chown root:root /etc/cron.d/nightly-backup注意最后两行。/etc/cron.d/下的文件如果属主不是 root,或者属组可写(比如 666、664 且组是普通组),cron 会拒绝加载。这是有意的安全设计,防止有人往 cron 目录塞恶意任务提权。同理,用户 crontab 文件本身在/var/spool/cron/下的权限也必须是 600,属主是本人,权限被改坏之后 crontab 会报错。
2.2 五个字段与时间表达式
标准 crontab 一行七个部分:分、时、日、月、周、命令(用户级)。时间字段能取的值和符号如下:
| 字段 | 取值范围 | 可用符号 |
|---|---|---|
| 分 | 0-59 | *,-/ |
| 时 | 0-23 | *,-/ |
| 日 | 1-31 | *,-/?(部分实现) |
| 月 | 1-12 或 JAN-DEC | *,-/ |
| 周 | 0-7(0 和 7 都是周日)或 SUN-SAT | *,-/ |
符号的语义:*表示每个值;,是枚举,1,15表示 1 号和 15 号;-是范围,1-5表示周一到周五;/是步长,*/5在分钟位表示每 5 分钟,10-50/10表示 10、20、30、40、50。步长必须跟在*或范围后面,写5/10是无效的,正确写法是5-59/10。
一个必须记住的规则是日和周的联动关系:当日和周的字段都不是*时,两者是“或”的关系。也就是说0 3 1 * 1这条任务会在“每月 1 号”和“每周一”都执行,而不是只在“既是 1 号又是周一”的时候执行。很多人以为这是“与”,结果统计出来的执行次数对不上。想表达“1 号且是周一”做不到,只能在脚本里加判断。
还有几条@开头的快捷写法,可以省掉记字段:
@reboot /opt/scripts/on_boot.sh @hourly /opt/scripts/hourly.sh @daily /opt/scripts/daily.sh @weekly /opt/scripts/weekly.sh @monthly /opt/scripts/monthly.sh@reboot有个坑:它是在 cron 守护进程启动时触发,如果你的服务器重启了但 cron 没启动(或者容器里 cron 压根没在跑),任务就不会执行。另外容器环境里@reboot的含义和宿主机重启不是一回事。生产环境建议还是写完整的时间表达式,语义更清晰。
2.3 cron 的执行环境为什么和你的终端不一样
这是 crontab 最容易踩的坑,没有之一。你在终端里source ~/.bashrc之后有一大堆环境变量,PATH 里可能包含 nvm、conda、自定义的/usr/local/bin。而 cron 启动子进程时,环境是从守护进程继承的一套最小集,PATH 通常只有/usr/bin:/bin,HOME 可能被设置,SHELL 是/bin/sh。
三大症状:第一,python: command not found,因为你的 python 在 conda 环境里,cron 的 PATH 里没有;第二,bash: /opt/app/start.sh: No such file or directory,脚本明明存在,但 shebang 写的是#!/usr/bin/env python,env 找不到 python;第三,脚本里cd $(dirname $0)失效,因为相对路径的基准目录不是你以为的地方。
还有一个非常隐蔽的问题:crontab 里的%有特殊含义,未转义的%会被 cron 当作换行符,后面的内容变成命令的 stdin。如果命令里用date +%Y-%m-%d,实际执行的时候 cron 只看到date +然后一个换行,输出结果是错的。正确写法是给每个%转义:
# 错误写法:% 会被当作换行,date 参数被截断 0 3 * * * /bin/date +%Y-%m-%d > /tmp/today.txt # 正确写法:每个 % 前加反斜杠 0 3 * * * /bin/date +\%Y-\%m-\%d > /tmp/today.txt如果不想处理转义,最稳的做法是把逻辑全部放进脚本文件,crontab 里只写一行调用。转义规则、变量、路径问题,都在脚本里解决,crontab 保持最简。
3. crontab 权限控制与安全边界
3.1 cron.allow 与 cron.deny
谁有资格创建 crontab,由/etc/cron.allow和/etc/cron.deny两个文件控制。规则不复杂,但组合起来容易记混:
- 只有
/etc/cron.allow存在:只有在文件里列出的用户才允许使用 crontab,其他用户全部拒绝。 - 只有
/etc/cron.deny存在:文件里列出的用户被拒绝,其他用户允许。 - 两个文件都不存在:行为取决于发行版,多数实现是只有 root 能用,或者所有用户都能用。
- 两个文件都存在:
allow优先,deny被忽略。
生产环境推荐的做法是只保留cron.allow,把需要跑定时任务的账号明确列进去。这样新加一个用户,默认是没有 crontab 权限的,符合最小权限原则。反过来只靠cron.deny的话,新增用户默认被放行,等于白名单变成了黑名单,容易出现意外。
# 白名单模式:只允许 root 和 deploy 使用 crontab cat > /etc/cron.allow <<'EOF' root deploy EOF chmod 644 /etc/cron.allow chown root:root /etc/cron.allow # 确认 deny 文件不存在,避免混淆 rm -f /etc/cron.deny # 验证:用未授权用户测试,应该被拒绝 su - nobody -s /bin/bash -c 'crontab -l'最后那条验证很关键。改完之后一定要用未授权用户实测一次,确认返回的是You (nobody) are not allowed to use this program,而不是悄悄生效。另外要注意,被拒绝的不只是crontab -e,crontab -l和crontab -r同样会被拦,所以别指望用-l去“确认一下”就能绕过。
3.2 crontab -u 与 root 的越权面
root 可以用crontab -u username读写任何用户的 crontab,这是设计上的管理能力,同时也是风险点。如果 root 的 shell 被攻破,攻击者可以直接crontab -u root -e塞一个反向 shell 进去,而且这种持久化非常隐蔽,藏在/var/spool/cron/root里,不看这个文件根本发现不了。
审计的时候,走进一台服务器先看三样东西:crontab -l(当前用户)、ls -l /var/spool/cron/、cat /etc/crontab和ls -l /etc/cron.d/。把下面这条检查固化成日常巡检脚本的一部分:
#!/bin/bash # 定时任务审计:输出所有用户 crontab 和系统级 cron 配置 echo "=== user crontabs ===" for d in /var/spool/cron /var/spool/cron/crontabs; do [ -d "$d" ] || continue for f in "$d"/*; do [ -f "$f" ] || continue echo "--- $f ---" cat "$f" done done echo "=== /etc/crontab ===" cat /etc/crontab 2>/dev/null echo "=== /etc/cron.d ===" ls -la /etc/cron.d/ 2>/dev/null这个脚本本身也可以放进 cron 定期跑,输出对比上一次的结果,有变化就告警。定时任务被篡改是入侵检测的常见信号,别等到出事才想起来查。
3.3 任务执行的最小权限原则
如果一条任务不需要 root,就不要写进 root 的 crontab。需要读某个目录、写某个日志,优先用 ACL 或改属主,而不是把整条任务提权到 root。真要提权,用 sudo 并且限定到具体命令。
# 只允许 deploy 用户免密执行这一个脚本,不做其他放行 cat > /etc/sudoers.d/deploy-backup <<'EOF' deploy ALL=(root) NOPASSWD: /opt/scripts/backup.sh EOF chmod 440 /etc/sudoers.d/deploy-backup visudo -cvisudo -c是必做的校验步骤,语法错误会导致 sudo 完全不可用,包括 root 自己。有/etc/sudoers.d/目录的系统上,注意主配置文件里有没有@includedir /etc/sudoers.d这一行,没有的话片段文件不会生效。改完用sudo -u deploy sudo -n /opt/scripts/backup.sh实测,确认免密生效且只对这一个命令生效。
另一条原则是敏感信息不落 crontab。数据库密码、API token 不要直接写在时间表达式后面,因为 crontab 文件虽然权限是 600,但crontab -l会把内容完整打印到终端,也会被上面那个审计脚本读出来。正确做法是放进权限 600 的环境文件,脚本里 source 进来。
# /opt/scripts/.env 权限 600,属主是任务执行用户 #!/bin/bash set -euo pipefail umask 077 ENV_FILE=/opt/scripts/.env if [ ! -r "$ENV_FILE" ]; then echo "env file not readable: $ENV_FILE" >&2 exit 1 fi set -a . "$ENV_FILE" set +a mysqldump --single-transaction -u"$DB_USER" -p"$DB_PASS" mydb \ | gzip > "/data/backup/mydb_$(date +%Y%m%d_%H%M%S).sql.gz"这里set -a的作用是把之后定义的变量自动 export,保证子进程能读到。set -euo pipefail让脚本遇到错误立即退出、未定义变量报错、管道中任一环节失败都算失败。这三件套写在脚本头部,能把“任务显示成功但实际没备份”这类静默故障挡住大半。
4. 定时任务脚本的编写与实战
4.1 一个可复用的脚本骨架
把环境变量、路径、错误处理、日志、锁全部在脚本里解决,是整个方案里回报最高的一步。crontab 那一行保持干净,出问题的时候日志也集中。下面这个骨架可以直接拿去改:
#!/bin/bash # 通用定时任务骨架:日志、锁、错误处理、路径固定 set -euo pipefail export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin export LANG=C.UTF-8 umask 077 APP_NAME="daily-backup" BASE_DIR="/opt/scripts" LOG_DIR="/var/log/${APP_NAME}" LOCK_FILE="/var/lock/${APP_NAME}.lock" DATA_DIR="/data/backup" mkdir -p "$LOG_DIR" "$DATA_DIR" LOG_FILE="${LOG_DIR}/$(date +%Y%m%d).log" log() { printf '%s [%s] %s\n' "$(date '+%F %T')" "$$" "$*" >> "$LOG_FILE" } trap 'log "ERROR at line $LINENO, exit code $?"' ERR # 独占锁:上一轮没跑完时直接退出,不排队堆积 exec 9>"$LOCK_FILE" if ! flock -n 9; then log "previous run still active, skip this round" exit 0 fi log "start" # ==== 业务逻辑写在这里 ==== log "business logic placeholder" log "done"几个点值得展开。trap ... ERR配合set -e,能在脚本异常退出时把行号和退出码写进日志,排查的时候不用猜。exec 9>加上flock -n 9是文件描述符锁的写法,比用 PID 文件判断更可靠,因为进程崩溃时内核会自动释放 fd 上的锁,不存在“僵尸锁”问题。
flock 和普通 PID 文件方案的区别值得说清楚。PID 文件方案需要自己判断进程是否存在、进程号有没有被复用、异常退出后要不要清理残留文件,边界情况非常多。而 flock 由内核保证,进程一退出锁就释放,逻辑简单得多。缺点是只在同一台机器同一文件系统内有效,跨机器互斥得用 Redis 或者数据库的行锁。
4.2 实战:数据库备份与保留策略
备份任务是最典型的 cron 场景,但也最容易写出“看着成功实际没用”的脚本。核心要求有三条:备份文件要能恢复验证;要控制磁盘占用;失败要能被发现。
#!/bin/bash set -euo pipefail umask 077 export PATH=/usr/local/bin:/usr/bin:/bin BACKUP_DIR=/data/backup DATE=$(date +%Y%m%d_%H%M%S) KEEP_DAYS=7 TTLOW=$(date +%Y%m%d%H%M)这条命令可以继续展开。重点在于保留策略:find "$BACKUP_DIR" -name "*.sql.gz" -mtime +$KEEP_DAYS -delete是标准写法,-mtime +7表示修改时间超过 7 天。但要注意-delete是不可撤销操作,在生产上第一次跑之前先用-print验证一下匹配的文件列表,确认没多删。
另一个常被忽略的点是备份文件的完整性校验。mysqldump返回 0 不代表备份有效,管道到 gzip 之后pipefail生效能发现 gzip 失败,但数据库连接中途断开、磁盘写满这类情况可能被掩盖。稳妥做法是备份完立刻做一次大小检查,或者定期在测试库上真做一次恢复演练。备份没验证过,等于没备份。
4.3 实战:日志清理与磁盘水位告警
日志清理的坑主要集中在“文件被进程持有”这件事上。直接rm一个正在被 nginx 写入的日志文件,磁盘空间不会释放,因为 inode 还被进程的 fd 引用着,df显示空间依然没降。正确做法是清空内容而不是删文件,或者先 reload 服务再删。
# 日志清理:按天归档 + 清空超过 N 天的文件内容,不删 inode LOG_DIR=/var/log/myapp find "$LOG_DIR" -name "*.log" -mtime +3 -exec truncate -s 0 {} \; # 磁盘水位检查,超过 85% 写告警文件,由另一个监控采集 THRESHOLD=85 USED=$(df -P /data | awk 'NR==2 {gsub("%","",$5); print $5}') if [ "$USED" -ge "$THRESHOLD" ]; then echo "disk usage ${USED}% on /data at $(date '+%F %T')" >> /var/log/disk-alert.log fidf -P的-P参数是 POSIX 输出格式,保证输出始终是单行,避免长设备名导致的换行让awk取错行。这个细节在设备名很长的时候会暴露出来,比如某些 LVM 或云盘设备,不加-P输出会折行,NR==2拿到的就不是根分区的数据。写一次踩一次坑,建议直接固定用-P。
4.4 实战:服务健康检查与自动重启
自动重启类任务必须加防抖和次数限制。不加限制的话,服务真挂了会每分钟重启一次,重启失败就每分钟刷一次日志,还可能触发上游的风控。下面的写法加了失败计数,连续失败 3 次就停手,等人介入。
#!/bin/bash set -euo pipefail export PATH=/usr/bin:/bin SERVICE=myapp STATE_DIR=/var/run/svc-check FAIL_FILE="${STATE_DIR}/${SERVICE}.fail" MAX_FAIL=3 mkdir -p "$STATE_DIR" if systemctl is-active --quiet "$SERVICE"; then rm -f "$FAIL_FILE" exit 0 fi COUNT=$(cat "$FAIL_FILE" 2>/dev/null || echo 0) COUNT=$((COUNT + 1)) echo "$COUNT" > "$FAIL_FILE" if [ "$COUNT" -gt "$MAX_FAIL" ]; then logger -t svc-check "give up restarting ${SERVICE}, fail count ${COUNT}" exit 1 fi logger -t svc-check "restarting ${SERVICE}, attempt ${COUNT}" systemctl restart "$SERVICE"logger -t把消息写进 syslog,和 cron 自己的日志在同一个地方,排查的时候不用在多个文件之间跳。这个写法比直接 echo 到自定义文件更好用,因为可以直接用journalctl -t svc-check过滤出来。
5. 日志查看与故障定位
5.1 各发行版的日志入口
cron 的日志位置是排查的第一分叉点。CentOS 系在/var/log/cron,Ubuntu 系在/var/log/syslog或者走 journald。容器环境通常没有 syslog,得看容器的标准输出或者自己配置 cron 的日志重定向。
# RHEL / CentOS / Rocky tail -f /var/log/cron # Debian / Ubuntu,syslog 方案 grep CRON /var/log/syslog | tail -n 50 # Debian / Ubuntu,systemd 方案 journalctl -u cron --since "1 hour ago" -f # 只看某个用户触发的任务 journalctl -u cron | grep -w "(deploy)"日志里能看到的典型行是CROND[12345]: (root) CMD (/opt/scripts/backup.sh),这行只说明 cron启动了这个命令,不代表命令执行成功。如果这行都没有,说明任务根本没被触发,问题在时间表达式或 crontab 没生效。如果有这行但没有你的业务日志,说明命令启动了但脚本内部失败了,去查脚本自己的日志和 stderr。
5.2 把 stderr 收进文件
cron 默认会把任务的 stdout 和 stderr 通过邮件发给 crontab 的属主。但很多服务器没装 MTA,邮件发不出去,错误信息就此消失。所以每一行任务都应该显式重定向:
# 追加模式,保留历史;2>&1 把 stderr 并入 stdout */10 * * * * /opt/scripts/check.sh >> /var/log/check.log 2>&1 # 不关心输出的时候丢弃,但要注意这会掩盖失败信息 */5 * * * * /opt/scripts/ping.sh > /dev/null 2>&1第二种写法要慎用。> /dev/null 2>&1的问题是它同时吞掉了成功和失败的所有痕迹,出问题的时候完全没有线索。如果确实不需要保留正常输出,至少把退出码写下来:... || echo "fail at $(date)" >> /var/log/ping-fail.log。或者干脆用logger,让消息进 syslog。
还有一个配置项值得知道:MAILTO。在 crontab 顶部写MAILTO=""可以关闭邮件,写具体的邮箱地址则可以把所有任务输出发到邮箱。如果服务器有 MTA,这其实是最省事的告警方案,比自己去写监控脚本快得多。但注意别配成会往外部地址群发的形式,避免把日志里的敏感信息发出去。
5.3 用 strace 定位“启动了但没效果”
当日志里有CMD行,脚本自己却没有任何输出,说明命令启动了但很快退出。这时候最有效的手段是strace跟一下,看它到底卡在哪一步:
# 跟踪一次 cron 触发的执行过程,看 openat 失败在哪 sudo strace -f -e trace=execve,openat,stat -o /tmp/cron-trace.txt \ sudo -u deploy /opt/scripts/backup.sh # 在 trace 文件里找 ENOENT 和 EACCES grep -E "ENOENT|EACCES" /tmp/cron-trace.txt | head -n 20这个方法比猜快得多。你会直接看到它是找不到解释器(ENOENT 打在 execve 上)、读不了配置文件(EACCES 打在 openat 上),还是缺某个共享库。缺点是需要装 strace,部分受限环境里没有权限用它。没有 strace 的时候,退一步用bash -x打开执行跟踪,在 crontab 里临时写成bash -x /opt/scripts/backup.sh >> /tmp/debug.log 2>&1,也能看到大部分问题。
6. 常见问题与排查方法
下面这张表覆盖了绝大多数 crontab 不执行的场景。排查顺序建议从上往下,因为前面几项的概率明显更高。
| 问题现象 | 可能原因 | 排查方式 | 解决方案 |
|---|---|---|---|
| 日志里完全没有 CMD 行 | 服务未启动 | systemctl status cron或crond | 启动服务并设enable |
| 日志里无 CMD 行,服务正常 | 用户不在 cron.allow 中 | cat /etc/cron.allow | 把用户加入白名单 |
保存时报bad minute | 时间字段格式错误 | 用crontab -l回看 | 检查字段数和取值越界 |
| 任务启动了但脚本报 Permission denied | 执行身份无权限 | sudo -u 用户 脚本复现 | 改属主、加 ACL 或用 sudo 授权 |
报command not found | cron 的 PATH 缺目录 | 用env -i模拟最小环境 | 脚本内 export PATH 或写绝对路径 |
日志出现No such file or directory但文件存在 | 换行符是 CRLF 或 shebang 错误 | file或cat -A检查 | 转成 LF,修正 shebang |
| date 命令输出异常 | %未转义 | 看实际输出是否被截断 | %前加反斜杠或封装进脚本 |
文件写不进去、df也不见增长 | 直接 rm 了被占用的日志 | lsof +L1查 deleted 文件 | 用 truncate 清空而非删除 |
| 同一任务被重复执行多次 | 上一轮未结束,或 crontab 写了多份 | ps aux看进程数 | 加 flock 独占锁 |
| 任务在 A 机器正常,B 机器不执行 | 时区或 cron 实现差异 | 比对date和/etc/timezone | 统一时区,确认 cron 服务名 |
/etc/cron.d/下任务不生效 | 文件缺用户名列或权限不对 | ls -l和看日志报错 | 补用户名列,权限设 644 root:root |
| 输出 Mediately 消失 | 没配 MTA,邮件投递失败 | 检查/var/mail/和 MTA 状态 | 显式重定向到文件 |
再补三个表里放不下、但很容易踩到的点。
第一是CRLF 换行。脚本在 Windows 上编辑过再传上去,每行结尾多了一个\r,shebang 变成#!/bin/bash\r,系统去找一个叫bash\r的解释器,自然找不到。表现就是bad interpreter: No such file or directory。用file script.sh会显示with CRLF line terminators,cat -A能看到行尾的^M。修复用sed -i 's/\r$//' script.sh或dos2unix。
第二是crontab 文件末尾的换行。部分 cron 实现(尤其老版本的 vixie-cron)要求 crontab 最后一行必须以换行符结尾,否则最后一条任务会被忽略。这个问题极难发现,因为crontab -l看起来一切正常。养成crontab -e保存的习惯,不要在最后一行按 Ctrl+O 就直接退出。
第三是SELinux。RHEL 系默认开启 SELinux,如果脚本放在非标准路径(比如/root/下),执行时可能被策略拦截,日志里会有denied记录。用ausearch -m avc -ts recent或dmesg | grep -i selinux能看到。检查文件上下文用ls -Z,修复用restorecon -Rv /opt/scripts。如果确认是 SELinux 导致,正确的做法是调整文件上下文或写策略,而不是直接setenforce 0关掉。
7. 最佳实践与上线检查清单
写到最后,把能沉淀成规范的东西列一下。这些不是理论,是踩过之后觉得值得固定下来的做法。
第一条,逻辑进脚本,crontab 保持一行。crontab 里只写/opt/scripts/xxx.sh >> /var/log/xxx.log 2>&1,所有变量、判断、错误处理都在脚本里。这样做的好处是脚本可以本地反复测试、可以版本管理、可以单独跑一次验证,而 crontab 改坏了很难回滚。
第二条,路径全部写绝对路径。命令用/usr/bin/mysqldump而不是mysqldump,目录用/data/backup而不是./backup,日志用绝对路径。cron 的工作目录是不确定的,相对路径的成功与否取决于守护进程怎么启动的,这种不确定性没有任何好处。
第三条,加锁防重叠。任何可能跑超过一分钟的任务都应该加 flock,否则上一轮没结束、下一轮又起来,资源会线性堆积直到把机器拖死。特别是有 IO 或网络等待的任务,时长波动大,不加锁迟早出事。
第四条,第一次上线用高频时间验证,稳定后再改回目标频率。比如先写*/2 * * * *让它两分钟跑一次,观察几轮日志确认行为正确,再改成0 3 * * *。这个习惯能避免“等到凌晨三点才知道配错了”的尴尬。
第五条,上线前跑一遍自检脚本。检查项包括:服务是否 running、脚本是否有执行位、属主是否正确、以目标用户能跑通、日志目录可写、磁盘剩余空间足够、时区是否符合预期。这几项花两分钟,能省掉第二天一上午。
#!/bin/bash # 定时任务上线自检 set -uo pipefail TARGET_USER=deploy SCRIPT=/opt/scripts/backup.sh LOG_DIR=/var/log/daily-backup SVC=$(systemctl list-unit-files | grep -Eq '^crond?\.service' && echo cron || echo cron) fail=0 check() { printf '%-40s %s\n' "$1" "${2:-ok}"; [ -z "${2:-}" ] || fail=1; } systemctl is-active --quiet "$SVC" || check "cron service" "not running" [ -x "$SCRIPT" ] || check "script exec bit" "missing" [ -r "$SCRIPT" ] || check "script readable" "no" sudo -u "$TARGET_USER" test -x "$SCRIPT" || check "run as $TARGET_USER" "denied" [ -w "$LOG_DIR" ] || check "log dir writable" "no" [ "$(date +%Z)" = "CST" ] || check "timezone" "unexpected: $(date +%Z)" echo "----" [ "$fail" -eq 0 ] && echo "all checks passed" || echo "check failed, see above" exit "$fail"第六条,审计和告警要分开。审计解决“任务是否被篡改”,告警解决“任务是否执行成功”。前者靠定期比对各处 cron 配置的哈希,后者靠任务自己写成功标记文件、由另一个任务检查标记的时间戳。两者混在一起写,往往哪个都做不好。
第七条,涉及数据和隐私的任务要明确边界。备份文件里可能有用户数据、密钥、日志中的手机号和身份证号。这类任务必须做到:文件权限 600 或 700、目录不对其他用户开放、保留期到点自动清理、传输走加密通道。如果是公司业务数据,还要确认备份的存放位置符合内部的数据管理要求,不要把含敏感信息的备份随手放到共享目录或者同步到不受控的位置。这条不是技术细节,但比任何一条技术细节都重要。