ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Linux crontab定时任务:时间表达式、环境变量与故障排查

Linux crontab定时任务:时间表达式、环境变量与故障排查 1. 先搞清楚 crontab 到底在替我们干什么活凌晨两点被电话叫醒去查一台跑挂了的任务机这种事我干过不止一次。后来把那台机器上所有 cron 配置翻了个底朝天我才真正意识到——Linux 通过 crontab 定时执行脚本任务看着像是五分钟就能学会的东西翻车概率却高得离谱。crontab 这个词你可能在 linux 常用命令 清单里刷到过无数遍但真敢说自己把时间表达式、环境变量、日志输出这一整条链路都吃透的人其实没那么多。说白了crontab 就是 Linux 系统自带的一个闹钟管家。你告诉它每天凌晨三点去执行 /opt/scripts/backup.sh它就会在那个时间点替你把这个脚本拉起来跑一遍跑完就不管了。它解决的是我不想手动敲命令但这件事又必须按时发生这类问题。典型场景包括数据库冷备、日志切割、缓存清理、报表生成、证书到期检查、监控探针上报、数据同步拉取基本你能想到的周期性重复劳动都能往它身上挂。它适合谁我的判断是三类人必须掌握一是刚接手服务器、开始独立维护一台机器的运维新人二是写业务代码但需要自己部署定时任务的后端同学三是做嵌入式或者边缘设备、需要让设备周期性自检的开发。至于老手我建议你别跳过因为 cron 的坑大多藏在细节里越是有经验的人越容易凭记忆写错一个字段。这篇内容我会按机制原理 → 时间表达 → 脚本设计 → 完整实操 → 故障排查 → 进阶玩法这条线走每个环节都会告诉你我踩过的坑和为什么这么设计读完你至少能做到独立写出一个稳定的定时脚本、看懂别人写的五段式表达式、在任务不执行时快速定位到病因。2. crontab 的核心机制与三层调度结构2.1 一个任务从配置到执行经历了什么很多人以为 crontab 是个程序其实它是一套组合拳。真正干活的是一个叫crond有的发行版叫 cron的后台守护进程它开机自启常驻内存每隔一分钟醒一次去扫描所有任务表比对当前时间是否匹配匹配上就 fork 一个子进程去执行命令。这里有个关键认知cron 的调度精度只到分钟级。你写不了每 30 秒执行一次写* * * * *那就是每分钟一次想更细只能靠脚本内部自己循环或者换 systemd timer 那套方案。另外 crond 每分钟扫一次意味着你配置完之后最多等一分钟才会生效不要配置完立刻断言没生效先等过整分钟。任务配置存放位置分三层这个层级关系必须理清否则你会遇到我明明加了任务却不跑的诡异情况层级位置适用对象特点用户级crontab -e编辑实际存于/var/spool/cron/用户名普通用户、运维个人账号以该用户身份执行环境变量极少系统级/etc/crontab、/etc/cron.d/*系统管理员多一个执行用户字段共六段周期目录/etc/cron.hourly、/etc/cron.daily等系统维护脚本放进去就按目录名周期执行不需要写时间新手最容易混淆的是六段式。/etc/crontab里的格式是# 分 时 日 月 周 用户 命令 0 3 * * * root /opt/scripts/backup.sh比用户级多了第五个字段后面的用户。如果你把六段式照抄到crontab -e里cron 会把root当成要执行的命令结果自然是报错或者什么都不发生。2.2 环境变量才是最大的隐形杀手我见过最多的一类问题就是脚本手动跑得好好的挂到 cron 上就报command not found。原因几乎都指向同一个地方——cron 执行时的环境变量和你登录 shell 时完全不一样。cron 启动任务时只带一个极简环境PATH通常只有/usr/bin:/bin这种基础路径。你自己装的东西在/usr/local/bin或者你用 nvm 装的 node、用 pyenv 装的 python全都不在这个 PATH 里。所以脚本里第一件事往往不是写业务逻辑而是把环境补回来#!/bin/bash # 显式声明路径别指望继承 export PATH/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin export LANGzh_CN.UTF-8 export NODE_ENVproduction # 业务逻辑从这里开始 cd /opt/app || exit 1 /usr/local/bin/node /opt/app/task.js /var/log/task.log 21我个人的习惯是所有命令一律写绝对路径不用node而用which node查出来的完整路径。多写几个字符换来的是一劳永逸的稳定。顺带说一句LANG如果脚本要处理中文文件名或者输出含中文的日志不设置编码可能在日志里看到一片乱码。这和不少人遇到的linux 解压文件乱码是同一类根因——编码没对齐跟 cron 本身没关系但排查时容易被误导。2.3 cron 的日志在哪里任务不执行第一反应应该是去看日志而不是反复改表达式。不同发行版日志位置差别挺大我给你一张对照表发行版日志位置查看方式CentOS / RHEL 7/var/log/crontail -f /var/log/cronUbuntu / Debian/var/log/sysloggrep CRON /var/log/syslog使用 systemd 的系统journaljournalctl -u cron -f或journalctl -u crond -f通用兜底用户邮箱mail或者查看/var/spool/mail/用户名journalctl这个命令值得单独记一下它是现代发行版的统一日志入口journalctl -u cron --since 1 hour ago能直接把最近一小时的调度记录捞出来。如果你看到日志里明确写了(CRON) info (running with inotify support)之后紧接着一条CMD (/opt/scripts/xxx.sh)那说明任务被拉起来了问题在脚本内部如果连CMD这行都没有那说明时间表达式根本没匹配上。3. 时间表达式怎么算才不会错3.1 五个字段的顺序和取值范围用户级 crontab 是五段式顺序永远是分 时 日 月 周 0-59 0-23 1-31 1-12 0-70和7都代表周日我教别人的时候喜欢用一个口诀记顺序分时日月周从小到大移。分钟最小放最前周最大放最后。很多人写反了成时 分 日 月 周结果任务在错误的时间跑起来还纳闷为什么。这里有两个容易记混的点。第一周日既可以是 0 也可以是 7两种写法等价取决于你习惯哪种。第二日和周字段同时被限定时是或的关系而不是且。比如0 0 1 * 1表示每月 1 号或者每周一都执行而不是既是 1 号又是周一这个行为跟直觉相反坑过不少人。3.2 特殊符号的语义拆解五个字段里能用的符号就四个但每个都有明确语义*任意值不限制。放在分钟位就是每分钟。,枚举。1,15,30表示第 1、15、30 分钟各执行一次。-区间。9-18表示 9 点到 18 点这个范围。/步长。*/5在分钟位表示从 0 开始每隔 5 分钟即 0、5、10……55。注意步长的起点。10-50/10表示从 10 开始每 10 分钟得到 10、20、30、40、50。而*/2在日字段里是从 1 号开始算还是从 0 开始实际行为是从当月 1 号起每 2 天但月末会跳变比如 31 号的下一次直接到次月 1 号不会补上。这是*/n用在日期字段时的固有缺陷做月末相关任务时要格外小心。另外还有一些以开头的宏写起来更省心宏等价写法含义reboot—每次开机启动后执行一次hourly0 * * * *每小时整点daily0 0 * * *每天零点weekly0 0 * * 0每周日零点monthly0 0 1 * *每月 1 号零点yearly0 0 1 1 *每年 1 月 1 日零点reboot特别实用用来拉起一些需要常驻但又不想配 systemd 的轻量脚本比手动改 rc.local 优雅。3.3 常见需求对照表与手算过程我把工作中最常被问到的几种时间需求整理成表你可以直接抄需求描述cron 表达式手算说明今天 17:40 执行一次40 17 * * *分钟40小时17其余不限每天早上 6 点0 6 * * *分钟0小时6每 5 分钟一次*/5 * * * *分钟步长 5每小时的第 10 和第 40 分钟10,40 * * * *枚举工作日 9 点到 18 点整点0 9-18 * * 1-5区间 周区间每周一凌晨 3 点0 3 * * 1周一对应数字 1每月 1 号和 15 号零点0 0 1,15 * *日字段枚举每季度首月 1 号0 0 1 1,4,7,10 *月份枚举每 30 分钟工作时间内*/30 9-17 * * 1-5步长 区间组合拿今天 17:40 执行这个热搜里高频出现的需求来说手算过程就是先把 17:40 拆成小时 17、分钟 40日、月、周都不限于是40 17 * * *。如果你只想让它今天跑一次、明天不跑那 cron 本身做不到只跑一次得靠脚本内部写个标记文件判断或者干脆用at命令。这是很多人第一次接触时会问的问题值得单独拎出来说清楚。at命令的用法是echo /opt/scripts/once.sh | at 17:40它是一次性闹钟跟 cron 的重复闹钟定位不同。两者互补别硬拿 cron 去做一次性任务绕远路。4. 一个能长期活下去的定时脚本该怎么写4.1 脚本头部必须有的几行我看到过太多能跑就行的脚本挂上去第一周没问题第二周开始出各种幺蛾子。原因通常是缺了保险措施。我推荐的脚本头部模板长这样#!/bin/bash # 遇到错误立即退出未定义变量报错管道中任一环节失败也算失败 set -euo pipefail # 锁定防止上一次还没跑完下一次又起来 LOCK_FILE/tmp/mytask.lock exec 200$LOCK_FILE flock -n 200 || { echo $(date %F %T) 上一次任务仍在运行本次跳过; exit 0; } # 环境变量补全 export PATH/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin export LANGzh_CN.UTF-8 # 统一的日志函数 LOG/var/log/mytask.log log() { echo $(date %F %T) [$$] $* $LOG }set -euo pipefail这一行是精髓。默认情况下 bash 遇到命令失败会继续往下跑导致错误被静默吞掉。加上这行之后任何一步失败都会立刻中断日志里能明确看到卡在哪一步。pipefail尤其重要因为cmd1 | cmd2默认只返回cmd2的状态码cmd1挂了你也发现不了。flock是防重入的关键。假设你的脚本正常跑 3 分钟但某次数据量大跑了 8 分钟而 cron 每 5 分钟触发一次就会有两个实例同时操作同一份数据轻则结果错乱重则文件损坏。flock -n的-n表示拿不到锁就立即退出配合|| exit 0实现跳过本次而不是排队等待。4.2 输出重定向不做这一步迟早出事cron 有一个默认行为任务产生的任何标准输出和标准错误都会尝试通过本地邮件系统发给任务所属用户。如果你没装邮件服务、没配置转发这些输出会堆积在/var/spool/mail/下面日积月累能把磁盘吃满。我亲眼见过一台机器因为一个每秒输出一行的脚本没重定向三个月攒了 40G 邮件文件。所以每条 cron 记录的末尾都应该有明确的重定向0 3 * * * /opt/scripts/backup.sh /var/log/backup.log 21是追加而不是覆盖21把标准错误也导到同一个文件。如果你想让日志自动按大小切分可以配合logrotate在/etc/logrotate.d/下加一个配置/var/log/backup.log { daily rotate 30 compress missingok notifempty copytruncate }copytruncate这个选项很关键它让 logrotate 复制文件后把原文件清空而不是重命名。这样脚本持有的文件描述符不会断不需要重启任何进程。不加这个选项日志切完之后脚本可能还在往那个被重命名的旧文件里写。4.3 脚本内部的错误处理与告警set -e只能保证脚本停止但不能告诉你出事了。我习惯加一个 trap让脚本异常退出时留个痕迹trap log 任务异常退出退出码 $? ERR main() { log 任务开始 # 业务逻辑 /usr/local/bin/your_tool --run $LOG 21 log 任务结束 } main如果想更进一步可以在 trap 里调一个 webhook 或者发一封站内通知把失败这个事件推出去。注意别在 trap 里写太重的逻辑容易死循环。简单可靠优先。还有一个细节脚本里不要用相对路径。cron 执行时的当前工作目录通常是用户的家目录root 是/root普通用户是/home/xxx你在交互式 shell 里cd到某个目录再跑脚本没问题但 cron 不会替你cd。所以脚本里凡是涉及文件的地方要么用绝对路径要么显式cd /opt/app并检查返回值。5. 从零到跑通的完整实操流程5.1 前置检查确认 crond 在跑动手之前先确认守护进程活着这一步能省掉后面一半的排查时间# 现代发行版 systemctl status crond # CentOS / RHEL systemctl status cron # Ubuntu / Debian # 老系统 service crond status ps -ef | grep -E cron|crond | grep -v grep如果显示inactive或者进程不存在直接启动并设为开机自启systemctl enable --now crond顺手确认一下时区因为 cron 的时间完全依赖系统时区。用timedatectl看一眼Time zone那一行对不对。如果机器时区是 UTC 而你以为它是东八区任务就会在你意想不到的时间跑。改时区用timedatectl set-timezone Asia/Shanghai改完记得重启 crond 让它重新读取。5.2 编写并手动验证脚本我习惯把定时脚本统一放到/opt/scripts/权限设成 755属主按需要设置mkdir -p /opt/scripts /var/log/mytask cat /opt/scripts/clean_tmp.sh EOF #!/bin/bash set -euo pipefail export PATH/usr/local/bin:/usr/bin:/bin LOG/var/log/mytask/clean_tmp.log log() { echo $(date %F %T) [$$] $* $LOG; } LOCK/tmp/clean_tmp.lock exec 200$LOCK flock -n 200 || { log 上次未完成跳过; exit 0; } log 开始清理 # 删除 /tmp 下 7 天前的临时文件 DELETED$(find /tmp -type f -mtime 7 -print -delete | wc -l) log 清理完成删除文件数$DELETED # 检查磁盘超过阈值告警 USED$(df -h / | awk NR2 {gsub(%,,$5); print $5}) if [ $USED -gt 85 ]; then log 警告根分区使用率已达 ${USED}% fi EOF chmod 755 /opt/scripts/clean_tmp.sh写完之后必须先手动跑一遍/opt/scripts/clean_tmp.sh echo 退出码$? cat /var/log/mytask/clean_tmp.log手动跑通了才有资格挂 cron。这一步看着简单但它是把脚本问题和cron 问题隔离开的分水岭。脚本手动跑不通挂上去一定跑不通而且日志里只会留一条模糊的失败记录排查起来更费劲。5.3 配置 crontab 并做分钟级验证配置用crontab -e第一次会让你选编辑器建议设成 vimexport EDITORvim crontab -e写入内容# 每分钟写一次心跳用来验证 cron 是否工作 * * * * * /bin/date /tmp/cron_heartbeat.log 21 # 每天凌晨 3 点执行清理 0 3 * * * /opt/scripts/clean_tmp.sh /var/log/mytask/clean_tmp.log 21 # 每周一 9:30 生成周报 30 9 * * 1 /usr/local/bin/python3 /opt/scripts/weekly_report.py /var/log/mytask/report.log 21保存退出后用crontab -l确认内容真的写进去了。然后等两分钟检查心跳文件tail -f /tmp/cron_heartbeat.log看到有内容进来说明调度链路完全打通这时再删掉那条心跳测试记录或者留着也无害。这个心跳验证法是我最推荐的排查第一步它能一票否决环境有问题这个方向把问题范围直接缩小到具体任务上。5.4 从日志里读出一个任务的全生命周期任务跑起来之后日志里会出现这样一组信息。以journalctl -u cron的输出为例Jun 12 03:00:01 host CROND[28451]: (root) CMD (/opt/scripts/clean_tmp.sh /var/log/mytask/clean_tmp.log 21) Jun 12 03:00:01 host CROND[28450]: (root) CMDOUT (开始清理) Jun 12 03:00:04 host CROND[28450]: (root) CMDOUT (清理完成删除文件数128)三行分别说明任务被触发、脚本开始执行、脚本输出内容。如果只有第一行没有后续说明脚本起来了但立刻死在开头这时候直接去看脚本自己的日志文件通常能找到具体报错。如果第一行都没有那就回到时间表达式和 crond 服务状态去查。这里补充一个实用技巧临时把时间改成每分钟验证。你写完一个0 3 * * *的任务理论上要等到明天凌晨才知道对不对这太慢了。我的做法是先在末尾临时加一条* * * * *的同款任务输出重定向到不同文件等一分钟确认能跑再把正式表达式留下、测试条删掉。这个习惯让我避免过好几次等到第二天才发现写错了的尴尬。6. 任务不执行时的排查速查表我把这些年遇到的 cron 故障做了个归类按出现频率从高到低排现象最可能原因排查命令解决方式日志里没有 CMD 记录时间表达式写错 / crond 未启动systemctl status cron修表达式启服务有 CMD 但脚本无输出命令用了相对路径或不在 PATH手动执行对比改绝对路径补 PATH报command not found环境变量缺失echo $PATH对比脚本内 export PATH报权限拒绝脚本没有执行位ls -l /opt/scripts/chmod x日志显示执行但结果不对工作目录不同pwd对比脚本内显式 cd任务偶发失败上次未跑完就被重启查锁文件加 flock配置改了不生效编辑的是别人的 crontabcrontab -l -u 用户名确认用户身份最后一行不执行文件末尾缺换行tail -c 1 文件补一个空行时间对不上系统时区不符timedatectl设置正确时区邮件堆积占满磁盘未重定向输出du -sh /var/spool/mail加重定向并清理表格里最容易被忽略的是最后一行不执行和编辑的是别人的 crontab这两条。前者是因为 cron 解析文件时按换行切分如果最后一行没有以换行符结尾某些版本会直接忽略它。后者常见于用sudo su切换用户之后忘了自己在哪个身份下编辑。再补充几个独家避坑技巧百分号必须转义。cron 会把命令里的%当成换行符处理所以date %F在 cron 里要写成date \%F。这个坑极隐蔽因为手动执行完全正常。如果你的命令里有 date 格式化要么转义要么把逻辑整个塞进脚本文件里让 cron 只负责调脚本。别在 cron 里写复杂管道。虽然技术上可行但可读性和可调试性都很差。超过一个管道的逻辑一律封进.sh文件。用crontab -l backup.txt做备份。改配置前先导出改坏了能立刻恢复。这个动作养成习惯之后我几乎没再因为误删任务而重写过。多台机器批量下发时考虑/etc/cron.d/。这个目录下的文件格式是六段式多一个用户字段好处是可以纳入版本管理、用配置管理工具统一下发而不是挨个crontab -e。7. 进阶玩法与一些个人经验7.1 把任务从 crontab 迁到 systemd timer 的时机crontab 简单直接但它有几个天生短板没有依赖管理、没有重试机制、日志要靠自己重定向、无法方便地查看某次执行的状态。当你发现自己在脚本里手写重试逻辑、手写告警、手写并发控制的时候就该考虑 systemd timer 了。两者对比大致是这样维度crontabsystemd timer最小精度1 分钟秒级还能用单调时钟依赖管理无支持 After/Requires日志需自行重定向journald 自动收集错过补偿无Persistenttrue 可补跑重试手动实现Restart 策略原生支持上手成本极低需要写两个文件我的经验判断是简单的周期性清理、备份、同步用 crontab 就够了别为了高级而高级涉及服务依赖、需要开机补跑、需要精细监控的才值得上 systemd timer。很多团队为了显得规范把几十个 cron 全迁过去结果维护成本反而上升了。7.2 错峰执行这个细节能救一整台机器如果你有几十台机器都配了0 0 * * *的任务那么每天零点整所有机器会同时向同一个数据库或存储发起请求形成典型的惊群。轻则接口超时重则把后端打挂。解决办法是在表达式里加随机分钟。最粗暴的做法是给每台机器的分钟位写死不同的值比如按机器编号取模。更优雅一点的做法是在脚本开头加随机延时# 随机等待 0-300 秒 sleep $((RANDOM % 300))注意RANDOM是 bash 内置变量用sh执行会报错所以脚本 shebang 一定要写#!/bin/bash。这是我踩过的一个小坑#!/bin/sh在某些发行版上确实是 dash没有RANDOM。7.3 关于时间表达式的两个记忆方法写了这么多年我还是会偶尔写错字段顺序所以我给自己定了两个保险动作。第一写完表达式后用在线工具或者man 5 crontab里的示例对照一遍不要凭感觉。第二任何新任务先以每分钟频率试跑确认行为符合预期之后再改成正式表达式这比在脑子里推演可靠得多。还有一个我特别喜欢的习惯在 crontab 每个任务上方写一行注释标明用途、负责人、创建日期。# [备份] 每日数据库冷备 - 张三 - 2024-06-12 0 3 * * * /opt/scripts/db_backup.sh /var/log/db_backup.log 21半年后你或者你的同事再看这个文件能三秒钟搞懂每条任务在干什么不用去翻脚本源码。这种给未来的自己留线索的做法我觉得比任何技巧都值钱。7.4 日志查看的几个高频命令组合最后把这几年用得最顺手的几条组合命令放这里基本都是排查时脱口而出的# 看 cron 最近 200 行调度记录 grep CRON /var/log/syslog | tail -n 200 # 只看某个脚本的执行记录 journalctl -u cron --since today | grep -i clean_tmp # 统计某个任务最近 24 小时执行了多少次 grep clean_tmp /var/log/syslog | grep $(date %b\ %d) | wc -l # 实时跟踪任务自己的日志 tail -f /var/log/mytask/clean_tmp.log # 查看当前用户所有任务 crontab -l # 查看指定用户的任务需要权限 crontab -l -u www-data这几条我几乎每周都会用到尤其是grep CRON配合tail定位到底有没有触发这个问题从不超过十秒。真正让我对 crontab 服气的是有一次线上任务连续三天没出结果我查了两小时时间表达式都没问题最后发现是脚本里调用的某个二进制文件被升级到了/usr/local/bin下的新路径而 cron 的 PATH 里没有这个目录。从那以后我所有的定时脚本第一行就是export PATH再没在这种事上翻过车。定时任务这东西稳定运行的时候你完全感觉不到它存在出问题的时候往往已经是影响业务的时候了所以前期多花十分钟把脚本写扎实、把日志留清楚回报率真的很高。
返回列表