
我先讲个很典型的场景你写了个脚本手动执行一切正常于是高高兴兴把它加进了 crontab设成每天早上 3 点跑。第二天上班一看任务压根没执行。你反复检查语法、检查路径全对但它就是不跑。最后偶然发现原来是脚本里用了一个自定义的环境变量而在 cron 的上下文里根本不存在。这种问题几乎每一个碰过 Linux 计划任务的人都会遇到。很多初学者把 cron 想得太简单觉得“设个时间、写条命令”就完事了。但实际用下来这里面的门道比你想象的多得多。包括时间表达式的坑、环境变量的坑、日志排查的坑还有——更关键的——当 cron 满足不了需求时你还有其他工具能顶上。这篇我会围绕 Linux 计划任务主要是 cron也会提 anacron、systemd timer 和 at写清楚三件事它是怎么工作的、配置时最容易踩哪些坑、以及任务不执行时怎么一步步排查。无论你是刚接触 Linux 的运维新手还是写过 crontab 但被坑过几次的老手这篇文章都能给你一点平时文档里看不到的经验。1. 先从“为什么需要计划任务”说起手动运维的无力感很多刚入门的朋友会问计划任务到底解决什么问题我打个比方如果你的机器是一个24小时营业的便利店那你自己不可能每分每秒守在那里你得雇一个守夜人让他按照你写在纸条上的清单在特定的时间点去做特定的事——开店门、关灯、补货。在 Linux 世界里这个守夜人的角色就是cron准确说是crond守护进程你写的纸条就是 crontab 配置文件。没有计划任务的日子我是真实经历过的。以前我刚管理服务器的时候每天手工跑备份脚本偶尔忘记跑就得过后补日志文件涨到几个 GB 才想起来清理证书快过期了也总靠别人提醒。后来把所有重复性的运维操作全部塞进计划任务才真正从“人肉提醒”里解放出来。那 Linux 下的计划任务都有哪些选择最经典的当然是 cron 家族。再细化一点常见的有这么几类vixie-cron / cronie大多数发行版默认安装的传统 cron 实现。anacron适合不是 7×24 小时开机的机器比如你的笔记本。它不要求系统在指定时间点一定是开着的只要开机后补跑就行。systemd timer如果你用 systemd 系发行版现在绝大多数都是它可以完全替代 cron而且功能更强。at一次性任务比如“5点后帮我重启一下服务”用完即走不重复。其中 cron 和 crontab 的使用场景最广泛网上资料也最多。但恰恰因为它普及大家的理解误区反而更多。老话说得好越是常用的东西越值得把它彻底吃透。2. crontab 的书写规范五个时间字段一个字符都不能错先看最核心的部分crontab 到底怎么写。配置文件的每一行代表一个任务基本格式长这样* * * * * command_to_execute前五个字段是时间分别代表分、时、日、月、星期。注意这个顺序是 分-时-日-月-星期不是 时-分-日-月-星期我见过太多人第一次写的时候把前两个字段搞反导致任务在一个完全不对的时间点执行。为了更清楚地展示我把每个字段的取值范围和含义列在下面字段含义取值范围示例第1字段分钟0-5915 表示每小时的第15分钟第2字段小时0-233 表示凌晨3点第3字段日期1-311 表示每月1号第4字段月份1-126 表示6月第5字段星期0-70和7都代表周日5 表示周五除了直接填数字还有几个特殊符号要熟练运用*代表任意值也就是“每分/每小时/每天”的意思。,列举多个值。例如1,15,30 * * * *表示每小时的第1、15、30分钟各执行一次。-连续范围。例如9-18 * * * *表示每天9点到18点的每一分钟都执行。/步长。例如*/10 * * * *表示每10分钟执行一次。注意*/10是“从0开始每隔10个时间单位”不是“在任意10的倍数分钟”。这里我不厌其烦地强调一个细节第5字段星期的取值范围是 0-70 和 7 都是周日。很多人只知道 0-6结果用 7 表示周日的时候在一些旧的 cron 实现上会有兼容问题虽然现在主流实现都支持但为了保险建议只用 0-6。接下来是实操示例直接抄作业就行# 每天早上 6 点执行备份脚本 0 6 * * * /usr/local/bin/backup.sh # 每 5 分钟拉一次最新库存数据 */5 * * * * /opt/scripts/sync_inventory.py # 每周一、周三、周五的凌晨 2 点清理临时文件 0 2 * * 1,3,5 /usr/local/bin/cleanup_tmp.sh # 每个月 1 号和 15 号的中午 12 点生成统计报表 0 12 1,15 * * /usr/local/bin/report.sh # 每年 6 月 1 日的零点执行年度归档 0 0 1 6 * /usr/local/bin/archive_yearly.sh在真正执行之前我强烈建议你先把整行内容放到一个临时文件里用crontab命令去加载而不是直接在线上环境反复试错。因为你永远不知道一个手误会产生什么后果——比如本想每 5 分钟跑一次结果因为字段写错变成了每 5 秒跑一次虽然 classic cron 最小粒度是分钟但某些 vixie-cron 的变种对星期和日期的交叉逻辑处理得很奇怪抱怨出错的人不在少数。关于日期字段和星期字段同时设置时的语义这里有个很多人误解的点当第3字段日和第5字段星期都被设置成非*时这两个条件是“或”的关系而不是“与”的关系。也就是说0 0 1 * 2的意思是“每月1号执行且每周二执行”即两者满足其一就会执行而不是“1号且同时是周二”才执行。这个特性和绝大多数人的直觉相反我第一次知道这个坑的时候也是相当震惊后来查了 POSIX 标准才确认。如果你的需求真的是“某月的某天且恰好是某种星期”才执行那建议在脚本内部用 date 命令做二次判断。3. 为什么 cron 任务会“慢半拍”底层机制比你想象得简单也更笨很多人用 cron 的时候都在问它到底是什么时候触发任务的答案是——每一分钟。crond 守护进程启动后会在每分钟的开始时刻读取 crontab 文件准确说是读取之后比较文件是否更新过然后判断这一分钟需要执行哪些任务。也就是说cron 的最小调度粒度就是1 分钟。如果你写了一个* * * * *的任务它会在每一分钟的第一秒被执行不会有毫秒级的精确触发。这个事情带来两个后果第一你没法用 cron 精确实现“每 30 秒跑一次”这种需求。网上经常有人问这类问题网上的答案也很统一cron 做不到请换其他工具。如果非要实现可以用两个 crontab 行错开 30 秒例如* * * * * command_A * * * * * sleep 30 command_A但我不推荐把这种 hack 用到生产环境原因很简单第一个任务如果在某分钟执行耗时超过 30 秒两个实例就可能重叠。更合理的做法是使用 systemd timer 加OnUnitActiveSec30s或者直接在脚本内部自己加循环比如你用 nohup 起了个常驻进程让它在 while 循环里 sleep 30 秒。第二crond 并不保证任务执行时间的绝对精确。如果你在系统负载非常高的时候设置了一个精确到分钟的定时任务任务可能会延迟几秒甚至几十秒才开始执行。对于绝大多数运维场景这种误差完全可接受但对某些对时间敏感的任务比如金融交易系统的定时结算你可能需要考虑更精确的方案。再说一个底层细节crond 在识别 crontab 文件是否变化时并不是每次启动都重新读取文件全量解析而是通过比较文件的修改时间mtime来决定是否重新加载。所以如果你修改了 crontab 文件但 mtime 没变比如你用touch -r强行改回时间戳crond 可能压根感知不到你的修改。这个坑比较冷门但如果你有自动化脚本在批量修改 crontab记得不要动时间戳。除了触发机制cron 的环境也非常特殊。它执行任务的时候并不加载/etc/profile、~/.bashrc这类登录 shell 环境它只会设置一个最小化的环境。默认情况下PATH 通常是/usr/bin:/bin可能不包含/usr/local/bin也不包含你自定义的 JAVA_HOME、PYTHONPATH 之类变量。这就是文章开头那个“脚本手动执行正常、放 cron 里就不行”问题的最常见原因。解决办法有两种我一般推荐在脚本里自己加好环境变量而不是把希望寄托在 crontab 的全局配置上在 crontab 顶部显式定义环境变量例如JAVA_HOME/usr/local/java、PATH/usr/local/bin:/usr/bin:/bin。在脚本第一行附近手动 source 需要的环境配置文件或者直接把关键变量写死在脚本里。这里还有个更隐蔽的问题如果 crontab 里命令输出比较多crond 会把这些输出通过邮件mail发给当前用户。很多服务器没有配置邮件服务导致输出被丢弃或者堆积在 mail spool 里。我的建议是非调试阶段所有任务一律重定向输出到日志文件比如0 6 * * * /usr/local/bin/backup.sh /var/log/backup.log 21原因很简单一旦任务失败这些日志就是你排查问题的第一手资料。如果你不重定向等你要排查问题时才发现输出全丢了那才叫欲哭无泪。还有一种更隐蔽的情况cron 执行任务时它的工作目录当前目录默认是用户的主目录而不是脚本所在目录。所以脚本里如果使用了相对路径执行时大概率会报 “No such file or directory”。妥善做法是脚本内部统一使用绝对路径或者在 crontab 行内先cd /your/dir 再执行命令。4. 任务不执行我常用的五步排查链路这部分是真正的干货。我把这些年排查 cron 任务的经验总结成一套固定的排查链路遇到“任务就是没跑”或者“跑了但结果不对”的情况你按这个顺序走一遍大部分问题都能定位。4.1 先确认服务本身活着没很多简化安装的 VPS 或 Docker 容器里crond 服务默认根本没启动。先执行systemctl status crond # 或者 service cron status不同发行版服务名略有差异CentOS/RHEL 系通常是crondDebian/Ubuntu 系通常是cron。如果服务没在运行直接启动并设为开机自启systemctl start crond systemctl enable crond在容器场景下尤其注意很多精简镜像不会自动启动 cron 服务你得在 Dockerfile 或者 entrypoint 脚本里手动把它拉起来否则即使 crontab 配置再正确也是白搭。4.2 查看日志cron 会诚实记录每一次执行确认服务正常后看日志。多数 Linux 发行版上cron 的执行记录会写在/var/log/cronRHEL 系或者/var/log/syslogDebian 系其中筛选 cron 相关记录用 grep。# RHEL/CentOS grep CRON /var/log/cron # Debian/Ubuntu grep CRON /var/log/syslog日志里能看到每一次任务的触发记录、执行用户和具体的命令。如果日志里根本没有你的任务记录说明 crontab 内容可能没加载进去或者 crond 压根没读取到你修改后的配置文件。如果日志里有记录但脚本执行结果不对那问题大概率出在脚本本身——可能是环境变量缺失、权限不足、或者脚本依赖的服务还没就绪。举个例子这是我机器上的一段真实日志片段Jun 10 03:00:01 myhost CROND[12345]: (root) CMD (/usr/local/bin/backup.sh) Jun 10 03:00:02 myhost CROND[12346]: (root) CMD (echo test /tmp/test.log)从时间戳看任务确实在 03:00:01 被触发了。此时你要做的是去检查 backup.sh 有没有产生日志有没有报错输出。4.3 手动执行脚本把环境变量差异暴露出来一旦确认任务被触发了就把脚本拿到命令行手动跑一遍。但注意是用和 cron 一样的环境跑不是用你的登录 shell 环境。su -s /bin/bash www-data -c /usr/local/bin/backup.sh用su切换成 crontab 所属用户执行可以最大程度模拟 cron 的环境。如果你直接在当前用户的登录 shell 里测试环境变量完全不一样测试结果几乎没有参考价值。如果当前用户在 crontab 里的环境变量不足你可以临时在脚本前面 source 一下环境配置文件来做验证。但我不建议这样做——正确做法是让脚本自身脱离对登录环境的依赖。4.4 检查文件权限和脚本解释器这步很多人会漏。cron 在执行任务时会检查当前用户对脚本是否有执行权限。如果脚本没有x权限cron 会直接报错但在日志里不一定会有清晰的错误提示。ls -l /usr/local/bin/backup.sh正确的做法是用绝对路径指定脚本并确保脚本第一行有正确的 shebang#!/bin/bash如果你用crontab -e编辑时写的是sh script.sh那 sh 可能指向 dash在 Debian 系上而你的脚本里用了 bash 专属语法就会报语法错误。这类问题非常隐蔽因为手动执行bash script.sh正常但 cron 调用sh script.sh就崩了。4.5 排除 SELinux、AppArmor 等安全模块干扰如果上面四步都没问题任务还是不正常那就需要考虑安全模块了。有不少 Linux 发行版默认开启了 SELinux比如 CentOS 7 及以后版本即使你用 root 用户配置 cronSELinux 策略也可能阻止 cron 上下文去执行某些脚本。快速验证方法临时把 SELinux 设为 permissive看任务是否恢复正常。setenforce 0如果设成 permissive 后任务正常那基本就是 SELinux 策略挡住了。背后是脚本文件的 SELinux 上下文类型可能不正确。常规做法是给脚本打上bin_t类型标签或者调整布尔值允许相关操作。同样的逻辑也适用于 AppArmor多用于 Ubuntu 系。提示在排查阶段临时关闭 SELinux 可以但记住排查完一定要恢复 enforcing 模式并找到正规的放行方式不要图省事长期关闭那等于把一个安全层整个拆掉了。5. 除了 cron还有哪些计划任务工具值得用cron 能覆盖 80% 以上的定时任务需求但有些场景它确实不给力。比如笔记本会睡眠关机到了预设时间机器没开机任务直接错过再比如任务执行依赖某个服务服务没起来时任务跑了也是白跑。这些情况下下面几个工具能帮你补位。5.1 anacron专为“非全天开机”机器设计anacron 的设计初衷很简单如果系统在计划时间点没开机那么启动之后会尽快补跑错过的任务。注意它只能管理“天”级别的任务不是分钟级的。如果你在/etc/anacrontab里配置任务格式会不太一样# 延迟时间分钟 任务标识 命令 1 5 daily_backup /usr/local/bin/backup.sh含义是开机后延迟 5 分钟执行 daily_backup 这个标识的任务如果当天还没执行过的话。用它的好处是笔记本用户第二天开机时昨天的备份任务不会被吞掉。5.2 systemd timer新时代的推荐选择如果用一句话评价 systemd timer它把 cron 的定时能力和 systemd 服务的完整依赖管理能力结合在了一起。你可以定义某个 timer 单元依赖某个服务启动后再执行可以在任务失败时自动重试还可以精确到秒级周期。一个最小示例先写服务单元/etc/systemd/system/backup.service[Unit] DescriptionMy backup job [Service] Typeoneshot ExecStart/usr/local/bin/backup.sh再写 timer 单元/etc/systemd/system/backup.timer[Unit] DescriptionTrigger backup every night [Timer] OnCalendar*-*-* 03:00:00 Persistenttrue RandomizedDelaySec300 [Install] WantedBytimers.target然后启用 timersystemctl daemon-reload systemctl enable --now backup.timer systemctl list-timers其中有几个参数值得展开说说。Persistenttrue是 systemd timer 对标 anacron 的关键如果系统在设定时间点是关机的下次开机后会立即补跑错过的任务。RandomizedDelaySec300表示触发后随机延迟最多5分钟用于避免大量机器同时跑任务把服务器打爆在集群环境尤其好用。OnCalendar*-*-* 03:00:00的格式和 cron 五段式不同是“年-月-日 时:分:秒”用户需要适应一下但表达能力更强甚至可以写Mon,Fri *-*-* 00:00:00之类的复杂组合。5.3 at临时一次性任务如果你只是“今晚 11 点帮我重启一下这个服务”不想为它建 crontab因为建完还得记得删那么at是更干净的选择echo systemctl restart myapp | at 23:00也可以用交互式方式输入at 23:00回车然后输入要执行的命令最后按 CtrlD 结束。用atq可以查看当前排队的任务atrm 任务号可以取消任务。5.4 几个工具的选型对照表场景推荐工具原因常规重复任务分钟级到周级cron/crontab生态成熟配置简单排除问题资料最多服务器每天固定时间跑任务cron 或 systemd timer都行看团队习惯新项目推荐 systemd timer笔记本/非7×24小时开机anacron 或 systemd timer 的 Persistenttrue能补跑错过的任务精确到秒的循环任务systemd timerOnUnitActiveSec 支持秒级周期一次性任务at用完即走不留残留依赖其他服务就绪后才能跑systemd timer service 依赖原生支持 Unit 依赖关系看到这里你可能会问既然 systemd timer 这么强是不是该彻底抛弃 cron我的观点是别急着迁移。如果你的存量服务器上已经有一堆 crontab它们运行稳定那就继续用没必要为了“新”而“新”。新部署的服务可以考虑用 systemd timer但也要注意团队里是否有人不熟悉 systemd 的语法。说到底计划任务的本质是“在正确的时间做正确的事”工具只是手段稳定可靠才是目标。6. 两个可以直接抄作业的实战案例前面讲的都是基础知识和理论现在来谈谈实际落地。我给两个真实场景的配置拿回去改改路径就能用。6.1 案例一Nginx 访问日志自动切割日志切割用 logrotate 才是正解但很多人会手动写脚本切日志。我用 cron 配合 logrotate 来演示一个完整流程。先确认 logrotate 已安装然后配置/etc/logrotate.d/nginx/var/log/nginx/*.log { daily missingok rotate 14 compress delaycompress notifempty create 0640 nginx adm sharedscripts postrotate [ -f /var/run/nginx.pid ] kill -USR1 cat /var/run/nginx.pid endscript }然后写 crontab让 logrotate 每天凌晨执行0 0 * * * /usr/sbin/logrotate -s /var/lib/logrotate/logrotate.status /etc/logrotate.conf /var/log/logrotate.log 21有些发行版已经默认配置了/etc/cron.daily/logrotate那你不需要再加 crontab。但如果你管理的是自建环境、精简容器很有可能需要手动加。加的时候注意-s指定 status 文件路径否则 logrotate 可能无法正确记录轮转历史。6.2 案例二MySQL 每日自动备份并清理旧备份我用了很多年的一个备份脚本结构很简单核心逻辑就三件事导出、压缩、清理。#!/bin/bash # mysql_backup.sh BACKUP_DIR/data/backups/mysql DB_USERroot DB_PASSyour_secure_password KEEP_DAYS7 DATE$(date %Y%m%d_%H%M%S) mkdir -p $BACKUP_DIR # 导出所有数据库排除系统库 mysqldump -u$DB_USER -p$DB_PASS --all-databases --ignore-tablemysql.event | gzip $BACKUP_DIR/mysql_$DATE.sql.gz # 清理超过保留天数的备份 find $BACKUP_DIR -name mysql_*.sql.gz -mtime $KEEP_DAYS -delete # 记录备份日志 echo $(date %F %T) backup finished /var/log/mysql_backup.logcrontab 这样写0 2 * * * /usr/local/bin/mysql_backup.sh /var/log/mysql_backup_cron.log 21选择凌晨 2 点是因为此时业务访问量低mysqldump 对线上性能影响最小。切记脚本里密码不要裸写在 crontab 里而是放在脚本文件里并设置仅 root 可读chmod 700 /usr/local/bin/mysql_backup.sh chown root:root /usr/local/bin/mysql_backup.sh还有一个经验备份脚本里一定要处理磁盘空间满的情况。我的做法是脚本开头检查磁盘剩余空间低于 20% 时直接发一封邮件通知避免备份把磁盘写爆。AVAIL$(df $BACKUP_DIR | awk NR2 {print $5} | sed s/%//) if [ $AVAIL -gt 80 ]; then echo 磁盘使用率超过80%备份中止 | mail -s MySQL backup disk warning opsexample.com exit 1 fi7. 关于时区、夏令时和 cron 的兼容性提醒计划任务里还有一个容易被忽视的维度时区。cron 读取的时间基准是系统时区不是 UTC 也不是你大脑中的“北京时间”。如果你的服务器时区设置错了所有任务都会按错误的时间执行。检查当前时区timedatectl希望任务在“北京时间每天早上 8 点”跑但系统时区是 UTC那你需要在 crontab 里写0 8 * * *但实际触发时间是 UTC 8 点 北京时间 16 点。解决办法是把系统时区改成东八区timedatectl set-timezone Asia/Shanghai另一个冷门问题跟夏令时有关。部分使用夏令时的国家和地区在时间切换日会出现“每天两个小时没有 02:30”或者“某天有两次 02:30”的情况cron 对这类边界情况的处理在不同实现里并不一致有的会正确执行一次有的会执行两次有的直接跳过。国内没有夏令时这个问题很少有人遇到但如果你管理的是海外服务器还是留个心眼比较好。还有容器场景的额外提醒容器的/etc/localtime可能继承自宿主机也可能因为镜像精简而缺失。如果你的容器里跑 cron进入容器先执行timedatectl或者date确认时间基准对不对。我见过一个很搞笑的线上事故服务器时区正确但容器内时区是 UTC导致所有容器里的 cron 任务都比预期晚了 8 小时才触发。8. 把 crontab 纳入版本管理和审计最后谈一个“运维规范化”层面的经验虽然不算纯技术但我觉得特别值得写下来。很多人管理 crontab 的方式是直接在服务器上crontab -e改了就完事。问题在于crontab 文件没有历史记录哪天误删了一行或者被人悄悄改了你根本不知道。等到任务出问题再回溯源已经无法查证当初的状态。我的建议是把所有 crontab 文件集中管理纳入 Git 仓库。具体做法很灵活我自己的习惯是这样把每台机器的 crontab 导出到统一目录文件名按主机名区分。需要修改时先在该机器上导出当前配置用脚本做 diff 对比没有问题再推上去。用 Git 做变更记录这样每次改了什么、为什么改都有迹可循。导出当前用户 crontab 用crontab -l保存到文件后用crontab 文件名恢复。注意crontab -l在无任务时会报错所以导出前先做个判断。crontab -l /opt/cron_backup/web01.cron 21 || true如果你的服务器数量比较多也可以考虑直接管理/etc/crontab、/etc/cron.d/下的文件配合配置管理工具Ansible、SaltStack 等下发。这样带来的额外好处是新机器初始化时几秒钟就能把整套计划任务同步过去不用一台一台手工敲。我还习惯在 crontab 文件头部写清楚维护人的联系方式以及任务的关键说明。不要小看这几行注释等三个月后你自己回来看这个文件会发现救命之恩全在注释里。# Managed by ops team, contact: opsexample.com # Every day at 02:00, backup mysql databases. Keep 7 days. 0 2 * * * /usr/local/bin/mysql_backup.sh /var/log/mysql_backup_cron.log 21最后分享一个很小的技巧如果你管理的机器上有多个用户的 crontab比如 root 一个、www-data 一个、postgres 一个建议把任务日志的命名统一起来带上用户和任务名。我设了一个约定所有 cron 任务日志都写入/var/log/cron_tasks/{user}_{task_name}.log。这样做的好处是出问题时一条命令就能看到某台机器上所有 cron 任务的状态grep -r /var/log/cron_tasks/ | tail -50比起到每个用户目录下翻邮件、找输出文件这个习惯帮我节省了太多时间。很多人只在意怎么写 crontab却忽略了日志管理等到真的要排查的时候才发现连一点线索都没有。计划任务本身不复杂复杂的往往是你对它是否足够尊重——提前想到环境差异、日志留存、权限边界后面才会少踩一些坑。