ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

crontab定时任务不执行:Linux权限模型与排查实战

crontab定时任务不执行:Linux权限模型与排查实战

很多人第一次配 crontab 的感受都差不多:语法看起来简单得不行,五个星号加一条命令,保存退出,然后就等着它跑。结果第二天一看,日志没有、文件没生成、数据库没备份,crontab -l里任务明明还在,系统却像什么都没发生过一样。

这类问题的坑,绝大多数不在“时间表达式写错了”,而在权限上。crontab 是一个把“身份”“权限”“环境”三者绑在一起的东西:任务以谁的身份运行、这个身份能不能执行这个脚本、能不能写这个目录、能不能读到配置文件、能不能在 cron 极简的环境变量里找到你依赖的命令——任何一环断掉,任务就是静默失败。而静默失败恰恰是 cron 最不友好的地方,它不会弹窗,不会报错到你眼前,最多往系统日志里丢一行。

这篇文章就围绕“Linux 权限管理”这条主线,把 crontab 定时任务从头拆一遍。前半部分讲清楚 cron 的身份模型和权限边界,解释为什么很多“明明是 root 配的却不生效”;后半部分是完整可复制的实操:脚本怎么写、权限怎么给、任务怎么配、怎么验证、失败怎么查。读完你应该能做到两件事:第一,配一个定时任务一次就跑通;第二,遇到不执行的任务,能在十分钟内定位到是权限问题、环境问题还是语法问题。

1. 为什么 crontab 总是“配了却不执行”

先说一个很多人没意识到的事实:crontab 的失败绝大多数是“无声的”。你在终端里手敲/opt/app/backup.sh跑得好好的,写进 crontab 就是不工作,这不是 cron 有 bug,而是你在终端里拥有的执行环境和 cron 给任务的环境根本不是同一个。

终端里你有一个完整的登录 shell:PATH 里有一大串目录,~/.bash_profile里的环境变量全部加载,工作目录是你当前所在的路径,LANG、JAVA_HOME、PYTHONPATH一个不少。而 cron 拉起任务时,只给你一个最小环境:通常PATH=/usr/bin:/bin,HOME是用户家目录,SHELL是/bin/sh,工作目录是家目录。你自己在/etc/profile里加的 PATH,在 crontab 任务里根本不存在。

所以“命令找不到”是第一大类故障。你在终端里敲python3 xxx.py或者java -jar xxx.jar,在 crontab 里就变成command not found,而这行报错被吞进了邮件或者系统日志,你不会主动去看。

第二大类是权限。任务默认以“配置它的人”的身份运行,也就是crontab -e那个用户。如果你用普通用户appuser配了一个需要写/var/log/nginx/的任务,它会在写文件那一步被拒绝;如果你用 root 配了任务去调用一个属主是appuser、权限是700的脚本,root 能读能执行,但如果脚本内部又su到了别人,问题就转到了第三层。权限这条链上,任何一段Permission denied,任务就停在那一行。

第三大类才是语法和时间表达式。它确实存在,但排在第三位,说明它并不是最常见的。

这里真正容易踩坑的地方是:这三种失败在表面上长得一模一样——都是“没执行”。所以排查顺序必须是固定的,不能东一榔头西一棒子。后面第 6、7 节我会给出一套顺序化的排查清单。

2. crontab 的权限模型:任务到底以谁的身份在跑

2.1 Linux 权限三要素快速回顾

要把 crontab 讲透,得先把权限这块地基铺平。Linux 文件权限用ls -l一看就明白:-rwxr-xr--这一串,第一个字符是文件类型(-普通文件、d目录、l软链接),后面九位分三组,分别是属主(owner)、属组(group)、其他人(others)的读(r=4)、写(w=2)、执行(x=1)权限。

对文件来说,r是读内容,w是改内容,x是能被当作程序执行。对目录来说,这三位的含义完全不同:r是能列出目录内容(ls),w是能在目录里创建、删除、重命名文件,x是能进入目录(cd)以及访问目录下的已知文件名。这个差别在 cron 场景里非常关键——脚本能执行,不代表它能把输出写进目标目录,写文件需要的是目标目录的w和x。

特殊权限位里,和 cron 关系最大的是 SUID、SGID 和粘滞位。SUID 让程序以文件属主的身份运行(passwd就是这么工作的),SGID 作用于目录时让新建文件继承目录的属组。很多“脚本手动能跑、cron 跑就出错”的案例,就是因为在交互式 shell 里被 sudo 或者 SUID 程序兜住了,而 cron 里没有这层兜底。

还有umask。默认umask 022意味着新建文件权限是644、新建目录是755。cron 任务的 umask 通常是022,但如果你在脚本里生成了含敏感信息的文件(比如数据库备份、含 token 的配置快照),644就意味着同组用户和其他用户都能读。这个细节后面第 8 节还会讲。

小结论:看 cron 失败,先看“谁在跑”,再看“它对目标资源有没有权限”,最后才看时间表达式。

2.2 crontab 的三类位置与身份来源

crontab 在系统里不是一个文件,而是三类位置,身份来源各不相同,混在一起谈就会乱。

第一类是用户级任务,用crontab -e编辑,实际文件存放在/var/spool/cron/<用户名>(RHEL/CentOS 系)或/var/spool/cron/crontabs/<用户名>(Debian/Ubuntu 系)。任务以该用户的身份运行。这个目录本身权限是700,属主是 root,普通用户只能通过crontab命令间接操作自己的文件,不能直接读别人的。这是有意设计的:防止用户互相窥探任务内容(任务里可能含密码、密钥路径)。

第二类是系统级任务,包括/etc/crontab和/etc/cron.d/目录下的文件。这两处的格式比用户级多一个字段——在命令之前要写明以哪个用户运行。这是新手最容易写错的地方:把用户级格式直接粘到/etc/cron.d/里,少了一个用户字段,cron 解析时就全乱套了。

第三类是目录式任务:/etc/cron.hourly/、/etc/cron.daily/、/etc/cron.weekly/、/etc/cron.monthly/。这些目录里的脚本由run-parts按固定周期执行,不需要写时间表达式。注意run-parts对文件名有要求:通常只执行由字母、数字、下划线、连字符组成的文件名,带.sh后缀的脚本在某些发行版上会被跳过(Debian 系的run-parts默认会跳过含点的文件名)。这个坑非常隐蔽,脚本明明在目录里,就是不跑。

三类位置的对比可以看这张表:

类型位置身份来源格式特点适用场景
用户级/var/spool/cron/<user>crontab -e时的当前用户五段时间 + 命令个人或单个服务的任务
系统级/etc/crontab、/etc/cron.d/*文件里显式指定的用户五段时间 + 用户 + 命令运维统一管理、多用户任务
目录式/etc/cron.{hourly,daily,weekly,monthly}/root无需时间表达式系统例行维护脚本

小结论:用户级任务的身份是“谁配的”,系统级任务的身份是“你写的那个字段”,目录式任务的身份是 root。写错位置,权限判断从第一步就错了。

2.3 cron.allow 与 cron.deny:谁能用 crontab

还有一个更前置的权限控制:某些用户能不能使用crontab命令本身。这由/etc/cron.allow和/etc/cron.deny两个文件决定,规则是:

  • 如果cron.allow存在,只有文件里列出的用户才能使用crontab,cron.deny被忽略。
  • 如果cron.allow不存在而cron.deny存在,文件里列出的用户被禁止,其他用户允许。
  • 两个都不存在时,行为取决于发行版,很多系统默认只有 root 能用。

这个机制的实践意义在于:它是“最小权限原则”在 cron 上的第一道闸门。生产服务器上如果不希望普通业务账号随意配置定时任务(避免有人写个* * * * *的高频任务把机器拖垮),正确做法是建cron.allow,只放行明确需要的账号,而不是靠口头约定。

# 文件路径:/etc/cron.allow # 只允许 root 和运维账号 ops 使用 crontab root ops
# 设置文件属主和权限,避免被普通用户篡改 chown root:root /etc/cron.allow chmod 644 /etc/cron.allow

改完之后要验证:用被禁止的账号执行crontab -l,应该看到类似You (username) are not allowed to use this program (crontab)的提示。这里要注意,已经存在的任务不会因为加了cron.allow就停止执行,它只拦截后续的crontab命令操作。所以如果要彻底下线某个账号的任务,还得先crontab -r清掉。

3. 环境准备与前置检查

实操之前,先把环境确认清楚,能省掉后面一半的排查时间。本文的命令在 CentOS 7/8、Rocky Linux、Ubuntu 20.04/22.04 上都适用,具体版本以你实际的环境为准,重点是思路。

第一件事是确认 cron 服务在跑。RHEL 系的服务名是crond,Debian/Ubuntu 系是cron:

# RHEL / CentOS / Rocky systemctl status crond # Debian / Ubuntu systemctl status cron

如果没启动,systemctl start crond起来,再systemctl enable crond设为开机自启。这一步看起来废话,但容器环境里经常遇到——很多精简镜像默认不装 cron,也不启动它。

第二件事是确认时间正确。cron 是按系统时间触发的,时区错了、NTP 没同步,任务就会在你意想不到的时刻跑或者不跑:

timedatectl # 关注 Time zone 和 System clock synchronized 两项

第三件事是确认日志能看到。RHEL 系 cron 的执行记录在/var/log/cron,Debian/Ubuntu 系默认进/var/log/syslog,systemd 系统上也可以统一用journalctl查:

# RHEL 系 tail -f /var/log/cron # Debian/Ubuntu 系 tail -f /var/log/syslog | grep CRON # systemd 统一方式 journalctl -u crond -f journalctl -u cron -f

如果/var/log/cron不存在,检查/etc/rsyslog.conf里有没有cron.*的日志规则。没有日志就等于闭着眼睛调试,这一步务必先通。

第四件事是确认mail相关配置。cron 的默认行为是把任务的标准输出和标准错误通过本地邮件发给任务属主。很多人不知道这一点,等到某天df -h发现/var/spool/mail/涨到几个 G 才反应过来。生产环境更推荐的做法是在任务里显式重定向输出,后面会反复用到。

4. crontab 时间表达式与命令编写规范

4.1 五个字段到底怎么读

用户级 crontab 的每一行由五个时间字段加一条命令组成,字段之间用空格或制表符分隔:

字段位置含义取值范围允许的特殊符号
第 1 段分钟0-59*,-/
第 2 段小时0-23*,-/
第 3 段日1-31*,-/
第 4 段月1-12 或 JAN-DEC*,-/
第 5 段星期0-7(0 和 7 都表示周日)或 SUN-SAT*,-/

几个必须记住的点:日和星期是**“或”的关系**,不是“与”。写0 3 1 * 1意思是“每月 1 号或者每周一”的凌晨 3 点,而不是“每月 1 号且是周一”。这个语义反直觉,但确实是 POSIX 标准行为。

特殊符号里,*是任意值,,是枚举,-是范围,/是步长。*/5表示“每 5 个单位”,1-10/2表示“1 到 10 之间每隔 2 个”。注意*/5在分钟位是每 5 分钟,在小时位是每 5 小时(0、5、10、15、20 点),不要把两者搞混。

常见写法对照:

# 每天凌晨 2 点 30 分 30 2 * * * /opt/app/scripts/backup.sh # 每 5 分钟一次 */5 * * * * /opt/app/scripts/check.sh # 工作日(周一到周五)上午 9 点 0 9 * * 1-5 /opt/app/scripts/report.sh # 每月 1 号和 15 号的 3 点 0 3 1,15 * * /opt/app/scripts/clean.sh # 每天 0 点、6 点、12 点、18 点 0 */6 * * * /opt/app/scripts/sync.sh

4.2 命令里最容易出错的三件事

第一件是路径必须写绝对路径。cron 的 PATH 很短,backup.sh这种相对路径在当前目录是家目录的情况下根本找不到。命令、脚本、脚本里引用的配置文件,全部用绝对路径最省心。

第二件是百分号需要转义。在 crontab 里%有特殊含义,表示“换行并作为标准输入”,所以date +%Y%m%d这种写法会被 cron 截断。正确写法是date +\%Y\%m\%d。这个问题在日志按日期命名时特别常见:

# 错误:% 会被 crond 当作换行符处理 0 2 * * * /opt/app/scripts/backup.sh > /data/logs/backup_$(date +%Y%m%d).log 2>&1 # 正确:转义 %,并把命令包在 shell 里执行 0 2 * * * /bin/bash -c '/opt/app/scripts/backup.sh > /data/logs/backup_$(date +\%Y\%m\%d).log 2>&1'

更稳妥的做法是把日期逻辑写进脚本内部,crontab 那一行只调用脚本,让脚本自己去算日期、自己写日志。这样可读性和可维护性都更好。

第三件是输出重定向。不重定向的话,任务产生的 stdout/stderr 会触发本地邮件,日积月累撑爆磁盘;重定向了但只写了>没写2>&1,错误信息还是会进邮件。标准写法永远是>> /path/to/file 2>&1,用追加而不是覆盖,方便回溯。

4.3 环境变量:在 crontab 里显式声明

既然 cron 的环境是精简的,最省事的办法就是在 crontab 顶部显式声明需要的变量。用户级 crontab 支持这种写法:

# 文件:crontab -e 编辑内容 SHELL=/bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin LANG=zh_CN.UTF-8 JAVA_HOME=/usr/local/jdk MAILTO="" 0 2 * * * /opt/app/scripts/backup.sh >> /data/logs/backup.log 2>&1

几行配置的作用分别是:SHELL指定用 bash 而不是 sh,避免脚本里的 bash 特有语法(比如[[ ]]、数组)报错;PATH补齐常用目录;LANG处理中文字符,否则脚本输出中文可能变乱码;JAVA_HOME供 Java 程序使用;MAILTO=""关闭邮件通知,避免邮件堆积。

这里有个细节值得说清楚:MAILTO=""是关闭,但如果你想保留邮件告警,可以把MAILTO设成一个真实邮箱,前提是系统配好了 MTA。多数业务服务器没有配置邮件服务,所以更常见的做法是MAILTO=""加日志文件,再用日志监控告警。

小结论:crontab 里能显式写的东西就不要依赖隐式继承环境。多写三行变量声明,能少排查两小时。

5. 完整实操示例:从脚本到定时任务

光讲理论没用,我们用一个真实场景把整条链路跑通:每天凌晨 2 点,把一个应用目录打包备份到/data/backup,保留最近 7 天,同时记录执行日志,并保证同一时刻不会有两个备份任务并发。

5.1 目录与身份规划

先决定“用谁的身份跑”。如果备份的是应用目录/opt/app,最规范的做法是建一个专用账号,而不是直接上 root:

# 创建专用账号和用户组 groupadd -r appops useradd -r -g appops -s /sbin/nologin appops # 建目录结构 mkdir -p /opt/app/scripts /data/backup /data/logs chown -R appops:appops /opt/app/scripts /data/backup /data/logs chmod 750 /opt/app/scripts /data/backup /data/logs

-s /sbin/nologin的意思是这个账号不能登录,只能被 cron 或 su 调用,属于典型的最小权限配置。chmod 750保证属主和属组能读写执行,其他人完全没权限。

这里要提醒一句:如果应用本身是以appuser跑的,备份目录的属主最好也设成能读应用目录的账号。跨账号读文件失败是权限类故障的高发区,chown一下比事后加 sudo 干净得多。

5.2 编写备份脚本

#!/bin/bash # 文件路径:/opt/app/scripts/backup.sh # 功能:备份 /opt/app 到 /data/backup,保留最近 7 天 set -euo pipefail APP_DIR="/opt/app" BACKUP_DIR="/data/backup" LOG_FILE="/data/logs/backup.log" DATE_STR=$(date +%Y%m%d_%H%M%S) TARGET="${BACKUP_DIR}/app_${DATE_STR}.tar.gz" # 统一 umask,避免生成的文件对其他用户可读 umask 027 log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*" >> "${LOG_FILE}" } log "备份开始,目标文件:${TARGET}" if [ ! -d "${APP_DIR}" ]; then log "错误:源目录 ${APP_DIR} 不存在,退出" exit 1 fi if [ ! -w "${BACKUP_DIR}" ]; then log "错误:当前用户 $(id -un) 对 ${BACKUP_DIR} 没有写权限,退出" exit 2 fi tar -czf "${TARGET}" -C "$(dirname "${APP_DIR}")" "$(basename "${APP_DIR}")" 2>> "${LOG_FILE}" log "备份完成,文件大小:$(du -h "${TARGET}" | cut -f1)" # 清理 7 天前的备份 find "${BACKUP_DIR}" -name "app_*.tar.gz" -type f -mtime +7 -delete 2>> "${LOG_FILE}" log "清理完成,当前备份文件数量:$(find "${BACKUP_DIR}" -name "app_*.tar.gz" | wc -l)"

脚本里几个设计点值得展开。set -euo pipefail让脚本在出错时立即退出,避免失败后继续往下跑产生半成品;umask 027把新建文件权限压到640、目录压到750,防止备份文件被无关用户读到;两处显式的存在性和权限检查,能把“目录不存在”和“权限不足”这两种错误在日志里区分开,排查时一眼就知道停在哪一步。

给脚本加执行权限,并且要检查一件事:脚本所在路径的每一级目录,执行者都要有x权限,否则照样跑不起来。

chmod 750 /opt/app/scripts/backup.sh chown appops:appops /opt/app/scripts/backup.sh ls -l /opt/app/scripts/backup.sh # 期望输出:-rwxr-x--- 1 appops appops ... backup.sh

先手动以目标身份跑一遍,这是最重要的一步验证:

sudo -u appops /opt/app/scripts/backup.sh tail -n 20 /data/logs/backup.log

手动跑通之后再进 crontab。顺序反了,你就分不清是脚本问题还是 cron 问题。

5.3 配置用户级 crontab

切换到appops身份配置任务:

sudo crontab -u appops -e
# 文件:appops 的 crontab SHELL=/bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin MAILTO="" # 每天凌晨 2 点执行备份,用 flock 防止并发 0 2 * * * /usr/bin/flock -xn /var/lock/app_backup.lock -c '/opt/app/scripts/backup.sh' >> /data/logs/cron_backup.log 2>&1

flock -xn的-x表示排他锁,-n表示拿不到锁就立即返回失败而不是等待。这个组合可以防止上一次备份还没跑完、下一次又启动,导致两个 tar 同时写同一批文件甚至把磁盘撑爆。锁文件放在/var/lock/下,注意这个目录一般只有 root 可写,普通用户需要提前建好文件并改属主:

touch /var/lock/app_backup.lock chown appops:appops /var/lock/app_backup.lock chmod 644 /var/lock/app_backup.lock

如果不想动/var/lock,也可以把锁文件放在自己可控的目录,比如/data/backup/.lock,效果一样。

保存后确认任务写进去了:

sudo crontab -u appops -l

5.4 系统级任务:/etc/cron.d 的正确写法

如果公司要求所有定时任务集中在配置管理里统一分发,更推荐用/etc/cron.d/。它的格式比用户级多一个用户字段,这一点务必记牢:

# 文件路径:/etc/cron.d/app-backup # 格式:分 时 日 月 周 用户 命令 SHELL=/bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin 0 2 * * * appops /opt/app/scripts/backup.sh >> /data/logs/cron_backup.log 2>&1

文件权限必须正确,否则 cron 会拒绝加载(这也是一个高频的“任务没生效”原因):

chown root:root /etc/cron.d/app-backup chmod 644 /etc/cron.d/app-backup

/etc/cron.d/下的文件如果属主不是 root,或者权限是 group/other 可写的,不同发行版的 cron 会直接跳过甚至报错。644加root:root是最保险的组合。

5.5 高频任务的写法:每 5 分钟一次的健康检查

# 文件:appops 的 crontab 追加一行 */5 * * * * /usr/bin/flock -xn /var/lock/app_health.lock -c '/opt/app/scripts/health_check.sh' >> /data/logs/cron_health.log 2>&1

高频任务尤其要注意两点:一是必须加锁,否则脚本执行时间超过 5 分钟就会叠加;二是日志必须做切割,一个每 5 分钟写一次的日志,不做轮转几个月就能到几个 G。日志切割可以交给logrotate,也可以简单地在脚本里控制行数。

6. 运行结果与效果验证

配完不等于跑通,验证要分层做。第一层验证 cron 服务是否真的调度到了这个任务:

# 实时跟踪 cron 日志(RHEL 系) tail -f /var/log/cron # 或者用 journalctl journalctl -u crond --since "10 min ago" | grep appops

任务被调度时,日志里会出现类似这样的记录(不同发行版措辞略有差异):

CROND[12345]: (appops) CMD (/usr/bin/flock -xn /var/lock/app_backup.lock -c '/opt/app/scripts/backup.sh' >> /data/logs/cron_backup.log 2>&1)

看到CMD这一行,说明 cron 已经按时把命令拉起来了,问题不在调度层。如果这一行都没有,那问题在 cron 服务、时间表达式、cron.allow或者 crontab 文件本身,跟脚本无关。

第二层验证脚本本身的执行结果:

tail -n 50 /data/logs/backup.log ls -lh /data/backup/

预期能看到带时间戳的日志行和生成的 tar.gz 文件。判断成功的标准不是“日志里有内容”,而是三条同时满足:日志里有“备份完成”、目标目录里有新文件、文件大小合理(不是 0 字节的残缺包)。

第三层验证权限是否正确落地:

ls -l /data/backup/app_*.tar.gz | head -3 # 期望输出类似:-rw-r----- 1 appops appops ... app_20240115_020001.tar.gz

如果看到的是-rw-r--r--,说明umask 027没生效,检查脚本里umask的位置是不是在创建文件之前。

想临时验证而不想等到凌晨 2 点,最省事的办法是把任务临时改成每分钟跑一次,观察两分钟后改回来。改的时候注意备份原文件:

# 临时验证用,验证完务必改回原表达式 * * * * * /usr/bin/flock -xn /var/lock/app_backup.lock -c '/opt/app/scripts/backup.sh' >> /data/logs/cron_backup.log 2>&1

如果验证失败,第一步应该看哪里?顺序是:先看/var/log/cron有没有CMD记录,再看任务自身的日志文件有没有输出,最后手动以目标身份执行脚本看报什么错。这个顺序能把问题范围从“cron → 脚本 → 命令”逐层缩小。

7. 常见问题与排查思路

下面这张表覆盖了实战中最高频的故障,建议直接收藏备用。

问题现象可能原因排查方式解决方案
任务完全不执行,日志无CMD记录cron 服务未启动systemctl status crond/cron启动并设为开机自启
任务不执行,crontab -l能看到用户被cron.allow限制检查/etc/cron.allow、/etc/cron.deny放行所需账号或改用系统级任务
/etc/cron.d/下的任务不生效文件格式缺用户字段对照五段 + 用户 + 命令格式补上用户字段
/etc/cron.d/下的任务不生效文件属主或权限不对ls -l /etc/cron.d/chown root:root+chmod 644
报command not foundcron 的 PATH 太短查看任务日志或邮件crontab 顶部声明 PATH,命令用绝对路径
报Permission denied执行身份对脚本或目录无权限id -un确认身份,逐级检查目录x权限调整属主属组或目录权限
脚本手动能跑,cron 报错依赖登录 shell 的环境变量env -i /bin/bash script.sh模拟测试环境变量写进 crontab 或脚本内部
中文输出乱码LANG未设置查看日志中的字符crontab 中设置LANG=zh_CN.UTF-8
日志文件里日期是错的%未转义被 crond 截断检查命令中的%改为\%或把日期逻辑移到脚本内
任务执行两次用户级和系统级重复配置crontab -l与/etc/cron.d/交叉检查删除其中一处
磁盘被撑满日志或备份未清理df -h、du -sh /data/*加日志轮转和过期清理
任务堆积并发执行时间超过间隔,无锁ps -ef看多个实例flock加排他锁
/etc/cron.daily下脚本不跑run-parts跳过带点文件名查看文件名去掉.sh后缀或改文件名
邮件堆积在/var/spool/mail输出未重定向,默认发邮件du -sh /var/spool/mail/重定向输出并设MAILTO=""

针对表里最容易被忽略的两条,再补充说明一下。

第一条是run-parts的命名限制。如果你把backup.sh直接丢进/etc/cron.daily/,在 Debian 系上很可能不会被执行,因为run-parts默认按照/etc/cron.daily的配置只运行名字符合特定正则的文件。稳妥做法是改名为backup(无后缀),或者干脆在 crontab 里显式写时间调用它,不依赖run-parts。

第二条是重复执行。用户级任务和/etc/cron.d/里的任务被配置成同一个时间、同一个脚本,表现就是“一天跑了两遍数据”,这类问题在交接、多人维护的环境里特别常见。排查时不要只crontab -l,一定要把三个位置都过一遍:

# 一次性检查所有定时任务来源 crontab -l ls -l /etc/cron.d/ grep -v '^#' /etc/crontab ls -l /etc/cron.daily/ /etc/cron.hourly/

8. 最佳实践与安全边界

做到能跑,只是第一层。真正决定一套定时任务是否“可长期维护”的,是下面这些工程习惯。

身份最小化。能用专用账号就不要用 root。专用账号只需要对特定目录的读写权限,即使脚本被替换或者出现命令注入,影响范围也被限制在可控目录里。反过来,如果所有任务都是 root,任何一个拼接不当的变量都可能变成灾难。生产环境上的原则是:root 只用来做权限分配和系统级维护,业务备份、日志清理、数据同步一律用业务账号。

脚本必须能独立运行。判断标准很简单:env -i /bin/bash /opt/app/scripts/backup.sh这一条命令能跑通,说明脚本不依赖你登录 shell 里的任何环境变量,cron 里基本不会出问题。env -i会清空所有环境变量,是模拟 cron 环境最直接的方式。

# 用最小环境模拟 cron 执行,能跑通才是真跑通 env -i /bin/bash /opt/app/scripts/backup.sh

输出必须落盘。所有任务统一>> /data/logs/xxx.log 2>&1,并且日志目录要有轮转策略。用logrotate管理的话,配置文件类似这样:

# 文件路径:/etc/logrotate.d/app-cron /data/logs/cron_backup.log /data/logs/cron_health.log { daily rotate 14 compress missingok notifempty copytruncate }

copytruncate适用于程序不会自己重新打开日志文件的场景,先复制再清空,比create模式更安全,不会出现日志句柄失效。

并发控制是默认项,不是可选项。所有可能超时的任务都应该加flock。判断标准是:这个任务的正常执行时间和调度间隔相比,是否可能接近甚至超过。备份、数据同步、批量导出这类任务基本都符合。

敏感信息不要写在 crontab 里。crontab 文件虽然权限受控,但任务会被ps -ef看到完整命令行,管理员和同机用户都能看。数据库密码、API Key 这类内容应该放在脚本能读到的配置文件里,配置文件权限设成600,属主是执行任务的账号。

# 敏感配置文件的标准权限 chown appops:appops /opt/app/conf/db.env chmod 600 /opt/app/conf/db.env ls -l /opt/app/conf/db.env # 期望输出:-rw------- 1 appops appops ... db.env

变更要有回滚路径。改 crontab 之前先导出备份,出问题能立刻恢复:

# 变更前备份 sudo crontab -u appops -l > /data/backup/crontab_appops_$(date +%Y%m%d).bak # 出问题后恢复 sudo crontab -u appops /data/backup/crontab_appops_20240115.bak

系统级的任务同理,/etc/cron.d/下的文件建议纳入配置管理(Git),任何改动走版本对比,比直接vi改安全得多。

监控 cron 本身。任务静默失败是 cron 最大的问题,所以要有一层“反向监控”:不是监控任务有没有成功,而是监控“该出现的东西有没有出现”。比如备份任务,可以再配一个秒级或分钟级的检查任务,判断当天的最新备份文件时间戳是不是在预期范围内,不在就告警。这种基于结果的监控,比基于退出码的监控更能发现真实问题。

最后是安全边界。定时任务本质上是一个以固定身份、按固定节奏执行任意命令的机制,权限就是它的护栏。所以三个原则要守住:一是任务身份只给必要权限,不要图省事直接 root;二是脚本和配置文件的属主、权限要定期审计,find /etc/cron.d /var/spool/cron -type f -exec ls -l {} \;这类检查可以放进例行巡检;三是任何生产环境的时间调整、脚本替换,都先在测试环境验证,确认无并发冲突、无权限不足再上线。

回到最开始那个问题:为什么配了却不执行。答案往往不是 cron 难用,而是我们把交互式 shell 的便利当成了理所当然。把身份想清楚、把路径写绝对、把环境显式声明、把输出重定向,这四件事做到位,crontab 其实是一个非常可靠的定时机制——它朴素、稳定、几乎不依赖额外组件,这也是它在容器化和系统调度满天飞的今天依然被大量使用的真正原因。

下次再遇到不执行的任务,别急着改时间表达式。先敲tail -f /var/log/cron,看看那一行CMD到底有没有出现。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进