干运维这行,最怕的就是半夜收到告警,跑上服务器一看,某个该跑的备份没跑,该清理的日志堆积如山。而排查这类问题,绕不开两个关键词:Linux计划任务和进程。很多人到现在还把“计划任务”理解成一行crontab配置,把“进程”理解成ps出来的一个条目,但实际上,这两者是同一个问题的两面——计划任务是一套调度机制,进程是这套机制落地执行的最小单元。你只有把crond这个守护进程怎么读配置、怎么算时间、怎么拉起子进程这条链路彻底看明白,才能真正搞定那些诡异的“没执行”“重复执行”“进程残留”问题。
这篇文章不打算做教科书式的罗列,我会直接从crond进程的视角切入,讲清计划任务的底层机制、实操配置、环境变量坑、故障排查这四块内容。无论你是刚接触Linux的新手,还是正在被线上cron折腾的运维,都能在十分钟内理清思路,得到可以直接抄作业的方案。
1. 计划任务与进程的关系:别把两者拆开看
1.1 守护进程是计划任务的中枢
linux系统里,cron服务全称是crond,它是一个典型的守护进程。所谓守护进程,说白了就是常驻后台、脱离控制终端、生命周期与用户登录无关的进程。你在终端里跑一条find / -name "*.log",那是普通前台进程,你一关终端它就收到SIGHUP挂了;但crond不行,它必须在系统启动时就拉起,按秒一级的循环感知系统时间变化,到点触发任务,然后继续睡眠等待下一次。
crond的启动方式取决于你用的init系统。CentOS 6及以前走SysVinit脚本,/etc/init.d/crond start;CentOS 7以后统一交给systemd管理,由/usr/lib/systemd/system/crond.service拉起。但不管怎么起,最终进程名都是crond,可以通过ps -ef | grep crond看到,PID通常是固定不变的,因为它就是那个总调度员。
理解这台调度的关键在于:crond本身不执行你的业务逻辑,它只负责“到点把任务进程拉起来”。比如你写了一个备份脚本backup.sh,crontab里定义了每天凌晨2点执行,实际发生的事是:crond进程每分钟醒一次,检查当前时间是否匹配你配置的时间字段,匹配上了就用/bin/sh拉一个子进程去执行backup.sh,子进程跑完,退出码返回给crond,记录到日志里。整个链路上,crond是父进程,sh和backup.sh是子进程。
这就是为什么排查问题时,光看crontab配置不行的原因——配置只是“图纸”,进程才是“施工队”。你得通过进程去看施工队到底有没有到场,跑到哪一步了,是干完了还是干了一半就跑了。
1.2 工具选型:cron不是唯一答案
很多人以为计划任务等于cron,其实还有好几个工具,它们的进程模型和适用场景完全不同。
| 工具 | 常驻进程 | 最小粒度 | 特点 |
|---|---|---|---|
| cron/crond | 有 | 分钟 | 全系统通用,配置简单 |
| atd | 有 | 秒(实际按分钟轮询) | 执行一次性任务,到点执行一次即失效 |
| anacron | 有 | 天 | 适合非7x24小时运行的机器,补执行错过的任务 |
| systemd timer | 依赖systemd | 秒/相对时间 | 支持精确时间、相对时间、日历事件,依赖管理更强 |
选型逻辑很直接:周期性任务用crontab,一次性定点任务用at,机器经常关机导致cron错过的场景用anacron,需要精确到秒或者想用systemd统一管理服务的用timer。我在生产环境中的习惯是,普通脚本任务一律cron,需要跨服务依赖或者要算时间差的任务才上systemd timer,因为它支持OnUnitActiveSec这种相对时间,还能监控失败后的重启行为,cron做不到这一点。
2. 核心细节解析与实操要点:crontab语法和crond的工作机制
2.1 crontab语法:五段时间字段并没有那么神秘
crontab的语法表面看很简单,五段时间加一段命令,但这五段字段的优先级和匹配规则是有讲究的。格式如下:
分 时 日 月 周 命令 * * * * * /usr/local/bin/backup.sh五个字段从左到右分别是分钟(0-59)、小时(0-23)、日(1-31)、月(1-12)、周(0-7,0和7都表示周日)。常见误区有两个:
第一,日和月冲突时的“或”逻辑。如果同时限制了日和月,匹配规则是“满足任意一个条件即可”。比如0 0 15 * 0,意思是“每月15号”或“每周日”零点执行,而不是必须同时满足。很多新手在这里踩坑,以为写了个周日加15号,结果发现每周日都跑了一次。
第二,百分号%的特殊含义。在crontab里,%会被解析成换行符或标准输入的标记,所以命令中出现date +%Y%m%d这种写法直接会报错,必须写成date +\%Y\%m\%d。这也是新手最容易忽略的坑。
# 每天凌晨2点执行备份 0 2 * * * /usr/local/bin/backup.sh # 每五分钟执行一次监控 */5 * * * * /usr/local/bin/check_status.sh # 每月1号和15号早上6点半执行 30 6 1,15 * * /usr/local/bin/report.sh # 工作日上午9点到下午6点,每10分钟执行一次 */10 9-18 * * 1-5 /usr/local/bin/collect_data.sh我个人的习惯是,写好crontab后,先用crontab -l看一下实际解析结果,再用crontab -e编辑前脑子里过一遍所有字段是否包含“或”逻辑,避免双重限制。
2.2 crond的工作机制:从时间计算到任务执行
crond每60秒醒来一次,但它不是只在某个时刻检查一次,而是每分钟对系统里所有任务的配置进行“时间匹配”。匹配过程用到的其实是mktime把当前时间转换成结构体,然后和每个任务条目的五个字段逐一比对。如果系统时间发生跳跃,比如NTP同步导致时间往前跳了30分钟,crond不会立刻把所有匹配上的任务补跑一遍,它只会检查当前时刻是否符合条件,符合就跑,不符合就不跑。所以,时间同步对cron的影响是“漏跑”而不是“补跑”。
crond读取配置的顺序也值得说清楚:
/etc/crontab:系统任务配置文件,字段中间多了一个“用户名”列,比如0 2 * * * root /usr/local/bin/backup.sh。/etc/cron.d/:系统任务配置目录,支持包管理器放的碎片化配置文件。/var/spool/cron/:该目录下按用户名命名的文件,就是各用户通过crontab -e编辑的内容。/etc/cron.hourly/、/etc/cron.daily/、/etc/cron.weekly/、/etc/cron.monthly/:通过cron的run-parts机制周期执行目录下所有脚本。
crond加载完这些配置后,会缓存到内存里。每次修改完配置,不需要手动重启crond,但有个细节要注意:如果你直接编辑/var/spool/cron/root文件(而不是用crontab -e),crond会检测到文件mtime变化并重新加载;如果用crontab -e,它内部会自动通知crond重载配置。所以不建议我们手动vim那个spool文件,容易把权限改错。
2.3 计划任务进程的查看与监控
排查计划任务相关进程时,我最常用的命令顺序是:
# 确认crond本身是否在运行 systemctl status crond # 查看crond的PID和启动时间 ps -p $(pidof crond) -o pid,lstart,cmd # 查看用户所有的cron配置 crontab -l # 查看系统日志里cron的执行记录 grep CROND /var/log/cron | tail -20更进阶的玩法是看进程树。计划任务执行时,crond会以子进程的方式拉起/bin/sh -c再执行你的脚本,所以通过ps --ppid就能看到当前正在运行的cron任务:
# 查看crond的直接子进程 ps --ppid $(pidof crond) -o pid,ppid,cmd这个方法在排查“任务是否还在跑、卡在哪里”时非常高效。有一次我发现任务的日志一直没更新,但通过进程树看到sh还在跑,再用strace -p PID挂上去一看,原来脚本卡在了一个网络请求上没设置超时。没有进程树这一步,光看日志你是发现不了这个问题的。
3. 实操过程与核心环节实现:一个可落地的计划任务全流程
3.1 第一步:写一个靠谱的脚本,而不是先写crontab
很多人习惯直接crontab -e然后写一行命令,比如0 2 * * * mysqldump -u root -p123 db > /data/backup/db.sql。这种做法在临时环境里没问题,但放在生产环境就是埋雷——你不知道cron的执行环境PATH里有没有mysqldump,也没处理日志和锁,失败了你连个输出都看不到。
我的标准做法是:先写一个可独立运行的脚本,在shell里手动执行成功后再交给cron。以MySQL备份为例:
#!/bin/bash # /usr/local/bin/backup_db.sh set -e export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin BACKUP_DIR="/data/backup" KEEP_DAYS=7 DB_USER="backup_user" DB_PASS="$(cat /etc/mysql-backup-pass)" DATE_STAMP=$(date +%Y%m%d_%H%M%S) # 创建备份目录 mkdir -p "${BACKUP_DIR}" # 执行备份 mysqldump --single-transaction -u"${DB_USER}" -p"${DB_PASS}" \ --databases "myapp" | gzip > "${BACKUP_DIR}/myapp-${DATE_STAMP}.sql.gz" # 清理超过保留天数的备份 find "${BACKUP_DIR}" -name "myapp-*.sql.gz" -mtime +${KEEP_DAYS} -delete # 输出执行结果到指定日志 echo "$(date '+%Y-%m-%d %H:%M:%S') backup finished" >> /var/log/backup_db.log脚本里每一行都有存在的原因。export PATH是为了解决cron环境PATH太窄的问题,下个小节细讲。set -e保证脚本在出错时立即退出,避免某个命令失败后继续执行造成不可预期结果。备份目录里的密码文件权限要设成600,别把密码明文直接写进脚本里。
3.2 第二步:配置crontab并验证
脚本准备好后,手动执行一次确认无误,接着配置任务:
crontab -e内容:
# 每天凌晨2点执行MySQL备份 0 2 * * * /usr/local/bin/backup_db.sh >> /var/log/backup_db_cron.log 2>&1注意这里我把标准输出和错误都重定向到了日志,这是一个很多人会忽视的细节。如果不做重定向,脚本的输出会以邮件形式发给本地用户,有时候邮件服务没配置,输出就丢了,出了问题时排查起来无从下手。
配置完成后,可以检查crontab被正确加载:
crontab -l然后等两分钟,看一下日志确认有没有动静。如果想现场测试,把任务临时改成每分钟执行一次,确认没问题了再改回原时间。千万注意别在测试完成后忘了改回来。
3.3 第三步:观察日志与进程,确认任务真正跑起来了
验证一个任务是否真正执行,日志和进程是两条腿,缺一不可。日志方面,CentOS/RHEL系列在/var/log/cron下,Debian/Ubuntu系列在/var/log/syslog里。前者直接grep命令名,后者得用grep CRON /var/log/syslog。
# CentOS/RHEL 查看cron日志 tail -20 /var/log/cron | grep CROND # Debian/Ubuntu grep CRON /var/log/syslog执行完成后,日志会记录类似这样的内容:
CROND[12345]: (root) CMD (/usr/local/bin/backup_db.sh)进程方面,如果你在任务执行窗口期间用ps --ppid $(pidof crond)看到了sh进程,说明crond确实按时拉起了任务。如果日志有记录但sh进程一闪而过,那就是脚本执行很快,正常现象,不用担心。
4. 常见问题与排查技巧实录:那些年我踩过的坑
4.1 计划任务没执行:先从日志和权限一句话定位
“cron没跑”是运维群里最常见的求助帖。我排查时会按以下顺序来,基本几十秒就能定位:
第一,看配置是否加载。crontab -l如果输出的内容跟你编辑的不一致,说明你编辑的文件不对。这里有个冷知识:crontab -e编辑的是当前用户自己的配置,存到/var/spool/cron/用户名;而你编辑/etc/crontab时,那是系统级配置,两者内容不通用。
第二,看crond是否在运行。systemctl status crond,如果显示inactive,那任务自然不跑,这种情况多见于刚迁移完的服务器。
第三,看/var/log/cron里有没有任务执行记录。有记录但脚本没执行成功,问题在脚本本身;完全没有记录,问题在时间配置或crond加载配置的环节。
第四,看权限。有两种权限限制:一是/etc/cron.allow和/etc/cron.deny,只有allow文件里列出的用户名才能使用crontab。二是脚本文件本身要有执行权限。我遇到过好几次,脚本手动跑没问题,但扔给cron就不执行,最后发现是脚本忘记chmod +x。
4.2 时间同步与计划任务的“相爱相杀”
“线上服务器cron任务老是错过时间”这个现象,多半和NTP时间同步有关。热词里提到的“linux查看系统时间同步时间”,在这里就派上用场了。crond是按“绝对时间”来匹配的,如果系统时间和真实时间偏差过大,任务就会在错误的时间点执行。比如你用ntpdate强制把时间往后拨了10秒,那么下一分钟内,所有按分钟匹配的任务可能立即执行一次(因为系统认为时间已经到了那个分钟边界),导致任务提前跑。
我处理这个问题的习惯是:用chronyc tracking检查时钟源和漂移量,确保系统时间长期处于稳定状态,而不是依赖一次性ntpdate强跳。另外,对于重要任务,我会在脚本里容忍5分钟的时间窗口误差,比如判断“当前时间是2点到2点05分之间”才执行,这样即使系统时间有轻微漂移也不至于漏掉。这句话在计划任务里就是:
0 2 * * * /usr/local/bin/backup_db.sh但脚本里加一个判断:
CURRENT_HOUR=$(date +%H) if [ "$CURRENT_HOUR" -eq 2 ] && [ "$(date +%M)" -le 5 ]; then # 在2点到2点05分窗口内执行 /usr/local/bin/backup_db.sh fi4.3 环境变量死角:为什么脚本手动跑正常,交给cron就异常
cron执行脚本时,用的环境变量非常精简,PATH通常是/usr/bin:/bin,很多管理工具装在/usr/local/bin、/opt目录下,cron里直接执行会报“command not found”。这几乎是所有计划任务脚本“手动正常、cron异常”的元凶。
解决方案很简单:在脚本开头强制指定PATH:
export PATH="/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/opt/mysql/bin"另一种情况是脚本里用到Java或Python,宿主机装了多个版本,这时候光有PATH还不够,最好写绝对路径:
0 * * * * /usr/lib/jvm/java-8-openjdk/bin/java -jar /data/app/check.jar >> /var/log/check.log 2>&1还有个小坑是locale环境。cron执行时LANG环境是C/POSIX,如果你的脚本里中文输出或者依赖UTF-8编码的日期格式,建议在脚本开头加:
export LANG=en_US.UTF-84.4 任务重复执行、进程残留与文件锁
任务“重复执行”通常有两种原因:一是前一个执行实例还没结束,cron又拉起了一个新实例,导致同一脚本并发跑多个进程;二是时间配置有误,比如分钟字段写了*/1(其实等价于*),每分钟跑一次,你以为5分钟一次,实际每分钟都在跑。
对于并发重叠问题,最可靠的解决方案是flock文件锁:
#!/bin/bash # 使用flock确保同一时间只跑一个实例 exec 9>/var/lock/backup_db.lock flock -n 9 || { echo "另一个备份进程正在运行,退出"; exit 1; } # 真正的业务逻辑 /usr/local/bin/backup_db.shflock -n的含义是非阻塞获取锁,如果锁被占用则直接退出。这个方案的优点是不需要额外装软件,glibc自带,而且锁文件异常干净——即使进程被kill -9杀掉,锁会自动释放,不会造成死锁。
进程残留的情况则要反过来查:脚本执行完了但子进程没退出,导致僵尸进程或者孤儿进程占用文件描述符。这类问题我一般用ps -ef | grep 脚本名或者pgrep -a 脚本名排查。如果发现已经没用的任务进程,先看看是不是父进程还在跑,没回收子进程;确认后直接kill掉即可。
4.5 排查速查表:一套组合拳走天下
最后整理一张我平时排查计划任务问题的速查表,按故障现象分成几类,每一项都对应命令和判断逻辑:
| 故障现象 | 排查命令 | 可能原因 |
|---|---|---|
| crontab -l有配置但任务不跑 | systemctl status crond | crond未启动或已失效 |
| 有配置、crond在跑,但日志里没有记录 | grep CROND /var/log/cron | 时间字段配置有误,或配置不匹配当前时刻 |
| 日志有记录,但业务没跑成功 | 手动执行脚本看输出 | 脚本PATH问题、权限不足、依赖服务未启动 |
| 任务重复执行,互相踩踏 | ps --ppid $(pidof crond) | 脚本执行超时,未加锁导致并发执行 |
| 任务执行了但没生成预期文件 | 检查脚本里日志重定向路径 | 目录不存在、selinux阻止、磁盘满 |
| 任务执行时间与预想不符 | timedatectl、chrony tracking | 系统时间偏差大、NTP同步跳过窗口 |
| 任务跑完后系统负载飙高 | ps -eo pid,ppid,cmd --sort=-%cpu | 脚本内未限制并发,或命令本身耗时过长 |
这套组合拳几乎覆盖了我线上遇到过的大多数cron问题。实际排障时,我一般按“配置-日志-进程-环境”四层顺序走一遍,很少需要额外扩展。
我个人在实际运维中还有一个习惯:每次新增或修改计划任务,都顺手在脚本里输出一行包含主机名、时间、执行状态的信息到统一日志文件,比如/var/log/cron_task.log。这样即使crond自己的日志轮转或丢失,我仍然可以查看所有任务执行的统一视图。这个习惯帮我排查过很多次“那个任务到底跑没跑”的争论,很管用。