ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

Linux 定时任务调度实战:cron、at 与 systemd 配置排错

Linux 定时任务调度实战:cron、at 与 systemd 配置排错

凌晨三点服务器上的批处理任务挂了,早上到工位第一件事就是翻日志,这种经历做过运维或写过自动化脚本的人大概都不陌生。cron、at、systemd 这三样东西,几乎是每台 Linux 机器上都会碰到的任务调度基础设施:cron 负责周期性的重复工作,at 处理一次性延后执行,systemd 则把服务管理和调度合到了一起。很多人对它们只停留在“会写个 crontab”的程度,真到出问题的时候——任务没跑、跑了但报错、跑了两遍、换了机器就不生效——就抓瞎了。这篇东西想做的事很具体:把 cron 和 at 的语法、配置位置、环境差异讲透,再手把手带出一个能上生产的简单 systemd 服务,最后把常见的排查路径整理成一张能直接对着查的表。读者对象是已经能登录服务器、会写基本 shell 脚本,但对调度这块只有零散经验的人;如果你平时只是照抄别人给的 crontab 行,那这篇正好能帮你把那些说不清的地方补上。

1. 先捋清楚:三种调度工具的分工到底在哪

动手写配置之前,值得花几分钟想明白这三者的边界。我在实际项目里见过太多“用 cron 硬扛一切”的写法,最后演变成一堆互相依赖的定时任务,谁先跑谁后跑全靠猜。工具本身没有高下之分,关键是场景匹配。

1.1 重复、一次性、常驻:三种需求对应三种工具

先把需求归个类。如果你的任务是“每隔一段时间就要执行一次,而且长期存在”,比如每分钟采集一次指标、每天凌晨打包数据、每周日清理临时文件,这是典型的周期任务,cron 和 systemd timer 都能干。如果任务是“从此刻起,过一段时间执行一次,执行完就结束”,比如半小时后重启某个进程、明早八点触发一次数据回补,这是一次性延后任务,at 是最顺手的。如果任务本身是“需要常驻、要能被监督、挂了要自动拉起来”,那它就不是调度问题,而是服务管理问题,应该交给 systemd 的 service。

这三者的区别不只是功能,还有一层是心态上的。cron 的定位是“到点了扔一条命令出去”,它不关心命令执行得怎么样、跑了多久、有没有并发;systemd 的定位是“管理一个受控的进程”,它会记录退出码、限制资源、处理依赖关系、挂了按策略重启。理解这一点,后面很多设计取舍就顺理成章了。

下面这张表是我自己总结的对照,放在手边查比较方便:

维度cronatsystemd service + timer
主要用途周期任务一次性延后任务常驻服务 / 精确周期任务
精度分钟级分钟级(-t可到秒)秒级
错过执行的处理直接丢弃机器关机则丢失(除非补跑)timer 可配Persistent=true补跑
日志默认邮件,需手动重定向默认邮件自动进 journal
并发控制无,需 flock 等外部手段天然一次性有Type与ExecStart语义约束
依赖与顺序无无有After/Wants/Requires
资源限制无无有 cgroup 级别的 CPU/内存/IO 限制
上手成本极低极低中等

1.2 选型时我实际会问自己的四个问题

光看表格还不够,真到了要选的时候,我一般会按顺序问自己四件事,基本能落到唯一答案上。

第一个问题:这个任务需要跑多久,会不会和下一次重叠?如果一个任务执行时间可能超过周期间隔,cron 会毫不犹豫地再起一个实例,两边争抢同一份文件或者同一个数据库连接,问题很难查。这种情况下我更倾向用 systemd timer,因为Type=oneshot配合 timer 默认不会重叠启动;如果坚持用 cron,必须自己加flock。

第二个问题:机器重启后,错过的任务要不要补?cron 不补,昨天的日报今天不会自动生成。systemd timer 的Persistent=true会记住上次触发时间,开机后补跑一次。数据类、对账类的任务,这个特性非常关键。

第三个问题:任务里的逻辑复杂不复杂?如果只是find ... -delete这种一行命令,直接写进 crontab 完全没问题;如果有十几步、要判断前置条件、要处理异常,那就老老实实写成一个独立脚本,cron 只负责调用脚本。把复杂逻辑塞进 crontab 的一行里,是后期维护灾难的起点。

第四个问题:出了问题我要怎么知道?cron 默认把输出通过邮件发给任务所属用户,可现在大多数服务器根本不配邮件服务,输出直接进黑洞。所以无论用哪个工具,日志落地这件事必须在设计阶段就定下来,而不是等出事再补。

1.3 时间基准:最容易被忽略的第一道坑

所有调度工具的时间判断都依赖系统时钟,而系统时钟有两层:时区和是否准确。这两个东西任何一层出问题,表现都是“任务跑了,但跑的时间不对”,而且往往过很久才被发现。

先说时区。cron 的表达式是按系统本地时区解释的,容器环境里基础镜像的时区经常是 UTC,你按北京时间写了0 2 * * *,实际会在北京时间上午十点执行。排查方法很简单,timedatectl看一眼Time zone那一行,或者直接date对比一下。systemd timer 也一样,OnCalendar=默认按本地时区解析,但可以在表达式里显式带时区后缀,比如OnCalendar=*-*-* 02:00:00 Asia/Shanghai,这在跨区域部署时很有用,因为它把时区固化在了配置里,不依赖机器设置。

再说时钟准确性。虚拟机的时钟漂移是常态,尤其是长期不重启、负载又高的机器,一天漂个几十秒不稀奇。半年下来累积偏差可能到几分钟,对于“必须在整点前后触发”的业务就有实际影响。所以时间同步服务基本上是必备项,chronyc tracking或者timedatectl status里能看到当前偏差量,超过几十毫秒就该查一下同步源了。

提示:写 cron 表达式时,先在命令行用date -d "next ..."之类的方式验证一下你的理解,比直接上线试错成本低得多。特别是“日”和“星期”两个字段同时非*的情况,它们的逻辑是“或”而不是“与”。

2. cron 实战:从语法到能上生产的配置

cron 大概是 Linux 上被用得最多、也被误解得最多的工具。语法看起来五分钟就能学会,但真正让它稳定工作,需要处理的细节比想象中多。

2.1 五个时间字段到底怎么读

标准 crontab 一行是五个时间字段加一条命令,字段之间用空格或制表符分隔:

位置含义取值范围常见写法示例
1分钟0-590、30、*/5、10-20
2小时0-233、*/6、1,13
3日1-311、*/7、15
4月1-12*、1-6、3,9
5星期0-7(0 和 7 都表示周日)1-5、6,0

几个容易读错的点,我一条条说。

*/5表示“步进”,从字段的起始值开始每 5 个单位取一个。分钟字段*/5是 0、5、10……55,这个没问题;但“日”字段写*/7就有点微妙了,它是从 1 号开始每 7 天,落到 1、8、15、22、29 号,跨月时并不均匀。想做“每 7 天一次”更稳的做法是配合脚本内部记录上次执行时间,而不是指望字段步进。

“日”和“星期”同时被限定的时候,二者是或关系。0 3 1 * 1的含义是“每月 1 号的 3 点”或者“每周一的 3 点”,而不是“每月 1 号且恰好是周一的 3 点”。这个设计在当年可能是为了灵活性,但在实际使用里几乎只会造成困惑。想表达“且”,只能靠脚本里判断。

另外 cron 还提供了一批以@开头的快捷写法:@reboot(开机后执行一次)、@hourly、@daily、@midnight、@weekly、@monthly、@yearly。它们在可读性上确实更好,但要注意@reboot的语义是“cron 守护进程启动后执行”,不是“系统完全就绪后执行”,如果任务依赖网络或某个后端服务,很可能跑得太早而失败。

2.2 用户 crontab 和 /etc/cron.d 的差别

cron 的配置有三个落点,用途不同,混用是常见错误来源。

用户 crontab:用crontab -e编辑,存到/var/spool/cron/下面,每行没有用户字段,因为任务天然以当前用户身份执行。crontab -l查看,crontab -u 用户名 -l看别人的(需要权限)。

/etc/crontab:系统级配置文件,格式比用户 crontab 多一个用户字段,位置在第五个时间字段之后、命令之前:

# 分 时 日 月 周 用户 命令 30 2 * * * root /opt/scripts/rotate.sh

很多人从网上抄配置贴进/etc/crontab结果不生效,就是因为抄来的那行少了这个用户字段,cron 把用户名字段当成了命令的一部分。

/etc/cron.d/目录:里面每个文件都是独立配置片段,格式和/etc/crontab一样带用户字段。这个目录适合打包分发,一个应用带一个文件,卸载时删掉即可。但它有几个硬性约束:文件属主必须是 root,权限不能是组可写或其他人可写,文件名只能包含字母、数字、连字符和下划线,文件最后一行必须以换行符结尾。最后这条坑过不少人——编辑器里不显示末尾换行,缺了它最后一行的任务会被静默忽略。

还有/etc/cron.daily、/etc/cron.hourly这类目录,由run-parts机制驱动,文件名同样有字符限制(带点的文件名会被跳过),比如backup.sh放在里面就不会被执行,得叫backup才行。

2.3 PATH 与环境变量:任务不执行的头号元凶

我自己统计过,cron 相关的问题里,环境差异占了一半以上。

命令在终端里手敲没问题,写进 crontab 就不动了,原因基本是:cron 执行任务时不加载任何登录 shell 的初始化文件。.bashrc、.bash_profile、/etc/profile这些都不会被读取,cron 给你的环境极简——通常是SHELL=/bin/sh、PATH=/usr/bin:/bin、HOME设成用户家目录、LOGNAME设成用户名。你在.bashrc里加的export PATH=$PATH:/usr/local/bin,在 cron 里完全不存在。

对应的处理办法有三层,按推荐程度排序:

第一层,命令和脚本里全部用绝对路径。调用/usr/local/bin/python3、/usr/bin/flock、/bin/rm,不依赖 PATH 查找。这是最稳的一层,因为它把不确定性彻底消除了。

第二层,在 crontab 顶部显式声明环境变量。crontab 支持这种写法:

SHELL=/bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin LANG=C.UTF-8 MAILTO="" HOME=/home/appuser */10 * * * * /opt/scripts/collect.sh >> /var/log/collect.log 2>&1

这样声明出来的变量会注入到所有任务的执行环境里。注意MAILTO=""是显式关闭邮件,不然默认会把输出发给本机用户,一旦本机没配邮件传输代理,输出就到处乱飘。

第三层,在脚本内部自己准备好环境。如果脚本需要复杂的运行时环境(比如 Python 虚拟环境、特定版本的 JDK),更规范的做法是脚本第一行就source一个 env 文件,或者在启动时设置好PATH:

#!/bin/bash set -euo pipefail export PATH=/opt/app/venv/bin:/usr/local/bin:/usr/bin:/bin export LANG=C.UTF-8 cd /opt/app || exit 1 exec python3 /opt/app/jobs/collect.py

set -euo pipefail这行值得单独说:-e让命令失败立即退出,-u让引用未定义变量时报错,-o pipefail让管道中任一环节失败都算失败。加不加这行,脚本的“静默失败”概率差别巨大。

注意:cd这一步经常被漏掉。cron 执行任务时的工作目录是用户家目录(HOME),不是脚本所在目录。如果你的脚本里有相对路径,行为就会和手工执行时完全不同。养成脚本里显式cd的习惯,能省掉很多莫名其妙的 file not found。

2.4 输出重定向、日志与执行时长监控

cron 默认把任务的标准输出和标准错误收集起来,通过邮件发给任务所属用户。服务器上邮件基本是废的,所以这些输出要么进黑洞,要么攒在本地邮件队列里越堆越大。正确做法是显式重定向:

*/5 * * * * /opt/scripts/ping_check.sh >> /var/log/ping_check.log 2>&1

>>是追加,2>&1把标准错误也并到标准输出,两者一起落到同一个文件。如果用>覆盖,那每次执行都会把上次的日志冲掉,出问题时就只剩最后一条记录,基本没法查。

日志文件本身也需要管理。一天跑 288 次的任务,日志增长很快,不加轮转迟早把分区塞满。可以交给 logrotate,写一个配置文件:

/var/log/ping_check.log { daily rotate 14 compress missingok notifempty copytruncate }

copytruncate这一步很关键——它复制完日志后原地清空,而不是重命名文件让程序重开。因为 cron 任务每次启动都是新进程,追加写入的模式下用copytruncate最省事,不需要通知程序重新打开文件句柄。

如果某个任务只关心“有没有失败”,不需要全部输出,可以只在失败时留痕:

*/5 * * * * /opt/scripts/ping_check.sh > /dev/null 2>> /var/log/ping_check.err

这样正常输出丢弃,错误信息才落盘。日志量能降一个数量级。

至于“任务跑了但不知道跑了多久”,这是 cron 天生的短板。我一般会在脚本首尾加时间戳打印,或者干脆包一层time:

*/30 * * * * /usr/bin/time -f "elapsed %E, maxrss %MKB" /opt/scripts/collect.sh >> /var/log/collect.log 2>&1

%E是总耗时,%M是峰值内存(KB)。有了这两项,性能退化能提前发现。

2.5 用 flock 解决任务叠加

cron 没有任何并发保护。一个每 5 分钟跑一次、但偶尔要跑 8 分钟的任务,会稳定地出现两个实例同时在跑的情况。如果它们都在处理同一批文件,轻则重复处理,重则数据损坏。

flock是最轻量的解决办法:

*/5 * * * * /usr/bin/flock -n /var/lock/collect.lock /opt/scripts/collect.sh >> /var/log/collect.log 2>&1

-n表示非阻塞,拿不到锁就立刻退出,不会排队堆积。锁文件路径放在/var/lock或者/tmp都行,但要注意如果多个任务共用同一个锁文件,它们会互相排斥;一个任务一个锁文件,不要图省事复用。

还有一个更细的用法,-w指定等待秒数:

*/5 * * * * /usr/bin/flock -w 60 /var/lock/report.lock /opt/scripts/report.sh

拿不到锁就最多等 60 秒,等到了就执行,等不到就放弃。适合那种“允许稍微延迟,但绝对不能重复”的场景。

flock的锁是文件描述符级别的,进程退出(无论正常还是崩溃)锁自动释放,不需要手动清理。这一点比自己在脚本里写 pid 文件判断可靠得多——pid 文件的经典问题是进程被 kill -9 后留下死文件,后续任务全部被挡住。

2.6 一个完整的 crontab 样板

把上面这些拼起来,我给新项目用的 crontab 模板大致是这样:

SHELL=/bin/bash PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin LANG=C.UTF-8 MAILTO="" # 每 5 分钟采集一次,禁止重叠 */5 * * * * /usr/bin/flock -n /var/lock/collect.lock /opt/app/bin/collect.sh >> /var/log/app/collect.log 2>&1 # 每小时第 7 分钟做一次聚合,避开整点高峰 7 * * * * /opt/app/bin/aggregate.sh >> /var/log/app/aggregate.log 2>&1 # 每天 03:20 全量打包,记录耗时 20 3 * * * /usr/bin/time -f "elapsed %E" /opt/app/bin/pack.sh full >> /var/log/app/pack.log 2>&1 # 每周一 04:00 清理,删 30 天前的产物 0 4 * * 1 /opt/app/bin/cleanup.sh --days 30 >> /var/log/app/cleanup.log 2>&1

几个细节值得留意:聚合任务故意错开整点,是因为整点往往是其他系统批量任务的高峰,IO 和 CPU 都在抢;打包任务带了个full参数,方便同一个脚本支持增量模式;清理任务放在周一凌晨,因为周末积累的产物最多,周一早上看到的空间报告最真实。

3. at:一次性延后执行的正确用法

相比 cron,at 的存在感低很多,但有些场景它就是最合适的工具。比如“给某个用户发一条通知,两小时后执行”,或者“把这个变更在今晚十点自动回滚”,用 cron 做会很别扭,用 at 一行就完事。

3.1 atd 守护进程与安装

at 依赖atd守护进程,很多精简系统默认没装也没启动。用之前先确认:

systemctl status atd

如果提示不存在,装一下包(apt 系是at,rpm 系也是at),然后systemctl enable --now atd。有的系统上叫atd.service,都是同一个东西。

还要检查一个容易忽略的地方:/etc/at.allow和/etc/at.deny。如果at.allow存在,只有里面列出的用户才能用 at;如果只有at.deny,里面列出的用户被禁止;两个文件都不存在的话,行为取决于发行版默认策略。普通用户执行at报 “You do not have permission to use at” 多半就是这个原因。

3.2 时间表达式的几种写法与坑

at 的时间解析由at命令自己实现,支持的自然语言写法相当宽松:

echo "/opt/scripts/rollback.sh" | at now + 2 hours echo "/opt/scripts/notify.sh" | at 22:30 at -f /opt/scripts/rollback.sh 2025-06-01 03:00 at -f /opt/scripts/rollback.sh now + 45 minutes

常用的相对时间单位有minutes、hours、days、weeks,可以组合,比如now + 1 hour + 30 minutes。绝对时间支持HH:MM、HH:MM AM/PM、midnight、noon、teatime(16:00),也支持指定日期。

坑主要在两处。

第一处是“过去时间”。如果你写at 03:00而当前已经过了 3 点,at 会顺延到明天3 点,而不是报错。这个行为本身合理,但容易让人误判——你以为设的是今天,实际执行在明天。想精确控制就用at -t格式:

at -t 202506010300.00 -f /opt/scripts/rollback.sh

-t的参数格式是[[CC]YY]MMDDhhmm[.ss],精确到秒,也不会做任何“智能顺延”,写错了就直接拒绝。生产环境我优先用这个,因为它不含歧义。

第二处是秒级精度。默认情况下 at 只解析到分钟,now + 30 seconds这种写法会被向上取整到下一分钟。如果确实需要秒级,-t是唯一选择。

3.3 队列查看、删除与环境继承

任务提交后,用atq查看待执行队列:

3 Mon Jun 1 03:00:00 2025 a root 5 Mon Jun 1 22:30:00 2025 a appuser

第一列是任务编号,后面是计划时间、队列字母、提交用户。删除用atrm 3。查看任务具体内容用at -c 3,它会把这行要执行的完整脚本打出来,包括 at 自动注入的环境变量部分——这个功能在排查“为什么 at 任务里找不到命令”时非常有用。

关于环境继承,有个和 cron 明显不同的地方值得说:at会继承提交时的当前环境变量,包括你export的那些。这意味着你在终端里export PATH=...之后提交的 at 任务,执行时能看到这个 PATH。听起来很方便,但也带来一个问题——不同人、不同时刻提交的任务,环境可能不一致,同一份脚本表现不同。

我的做法是不依赖继承的环境,在提交时就写清楚:

at -f /opt/scripts/rollback.sh now + 2 hours <<'EOF' PATH=/usr/local/bin:/usr/bin:/bin LANG=C.UTF-8 EOF

这样任务内容里就显式带了变量,at -c查看时一目了然,也不会受提交终端影响。

还有一个细节:at 任务的输出默认通过邮件发给提交者,同样因为没有邮件服务而丢失。所以要么在命令里重定向,要么用-M参数显式禁止发送邮件:

at -M -f /opt/scripts/rollback.sh now + 2 hours

3.4 at 适合干什么、不适合干什么

用了几年下来,我的判断是:at 适合短期的、一次性的、人工触发的动作,不适合承载业务逻辑。

适合的典型场景:变更窗口结束后自动回滚某个开关;给某个临时账号设置定时禁用;批量任务的“延时重试”——第一次失败后两小时再试一次。这类场景的特点是数量少、生命周期短、失效了影响可控。

不适合的场景:任何需要幂等保证、需要执行结果反馈、需要在机器重启后依然有效的任务。at 的队列存在/var/spool/atd/下面,机器重启后 atd 会重新加载未执行的任务,但如果任务本身依赖的内存状态没了,起来也是白跑。而且 at 没有任何重试机制,执行失败就是失败,你不主动去看根本不会知道。

4. 手写一个 Systemd 服务:从 unit 文件到开机自启

到这一步,我们处理的是比“定时跑一行命令”更重的东西:一个需要被系统当作服务来管理的任务。用一个具体例子走完整个流程——一个每晚执行的数据打包任务。

4.1 unit 文件放哪里、怎么命名

systemd 的单元文件按优先级从几个目录加载,常用的有三个:

路径用途是否建议手改
/usr/lib/systemd/system/发行版和软件包自带的单元不建议,升级会被覆盖
/etc/systemd/system/管理员自定义的单元推荐
~/.config/systemd/user/用户级单元,不需要 root适合个人任务

自己写的放/etc/systemd/system/,文件名就是单元名,必须以.service结尾。命名上我习惯用「项目-功能」的形式,比如app-pack.service,避免和系统自带的重名。改完或新建后必须执行:

systemctl daemon-reload

这一步不做,systemd 根本不知道有新文件。改配置之后忘记 reload,然后反复start发现行为没变,是最常见的自我怀疑时刻。

4.2 三段结构逐条拆解

一个 service 文件通常分三段:[Unit]、[Service]、[Install]。很多人抄模板只抄不改,实际上每一行都有它的作用。

[Unit]段管描述和依赖关系:

[Unit] Description=Nightly data pack job Documentation=file:/opt/app/README.md After=network-online.target Wants=network-online.target ConditionPathExists=/opt/app/bin/pack.sh

After和Wants经常一起出现,但含义不同:After只保证启动顺序,对方的成败不影响自己;Wants表示“希望对方也启动”,但对方失败了自己照跑;如果要强依赖,用Requires。对于一个需要访问网络的任务,After=network-online.target+Wants=network-online.target是标准组合,注意是network-online而不是network——后者只表示网络子系统初始化完成,不代表已经拿到地址。

ConditionPathExists是个很实用但少人用的指令:条件不满足时单元直接跳过,不报失败。适合那种“脚本可能不存在”的部署场景。

[Service]段是核心:

[Service] Type=oneshot User=appuser Group=appuser WorkingDirectory=/opt/app Environment=LANG=C.UTF-8 EnvironmentFile=-/etc/app/pack.env ExecStartPre=/usr/bin/test -d /data/out ExecStart=/opt/app/bin/pack.sh --mode full ExecStartPost=/usr/bin/logger -t app-pack "pack finished" TimeoutStartSec=7200 Nice=10 IOSchedulingClass=best-effort IOSchedulingPriority=6 PrivateTmp=true NoNewPrivileges=true ProtectSystem=full StandardOutput=journal StandardError=journal

逐条说几个关键的。Type=oneshot表示这个任务跑完就退出,不常驻,最适合批处理。WorkingDirectory显式指定工作目录,避免脚本里相对路径出问题——这是 cron 里要靠cd手动做的事,systemd 直接给了配置项。

EnvironmentFile=-/etc/app/pack.env前面的减号表示“文件不存在也不算错”,里面可以放KEY=VALUE形式的环境变量,敏感配置和单元文件分离,便于权限控制。

ExecStartPre用来做前置检查,失败就直接中止,不会执行主命令。ExecStartPost做收尾,比如打日志。注意这两个指令失败也会导致整个单元被判定为失败,所以ExecStartPost里别放太脆弱的逻辑。

TimeoutStartSec=7200把启动超时放宽到两小时。Type=oneshot的默认超时不算长,跑得久一点的任务如果不设这个,会被 systemd 直接判超时然后杀进程。

Nice和IOSchedulingClass是资源礼让。夜间批处理虽然跑在低峰,但如果和早上的业务有重叠,压一下优先级能减少互相干扰。

最后那几个PrivateTmp、NoNewPrivileges、ProtectSystem是安全加固项,属于“顺手加上不吃亏”的类型:PrivateTmp给服务一个独立的/tmp,NoNewPrivileges禁止提权,ProtectSystem=full把/usr、/boot、/etc挂成只读。这些在容器化之后更常见,但在宿主机上同样值得加。

[Install]段决定怎么开机自启:

[Install] WantedBy=multi-user.target

WantedBy=multi-user.target意味着systemctl enable时会在multi-user.target.wants目录下创建一个软链接,从而在系统进入多用户模式时被拉起。对于定时任务类型的服务,这一行其实可以省略,因为真正需要 enable 的是 timer 而不是 service。

4.3 Type 的取值怎么选

Type选错会导致 systemd 误判服务状态,进而出现“明明跑完了却一直显示 activating”或者“进程还在但 systemd 认为已退出”的问题。

Type适用场景判定启动成功的时机
simple默认值,前台常驻进程进程 fork 出来即认为启动成功
exec同上,但更严格ExecStart的二进制真正 exec 成功
oneshot跑完就退出的批处理进程退出且退出码为 0
forking自己 daemonize 的老式程序父进程退出后认为启动完成
notify支持 sd_notify 的程序程序主动发通知
idle极少用其他任务都完成后才启动

批处理任务一律用oneshot,这是最没有歧义的选择。如果任务跑完后还有RemainAfterExit=yes的需求(比如“执行一次配置后一直标记为 active”),可以配合使用。

4.4 完整跑通的步骤

把上面的单元文件存成/etc/systemd/system/app-pack.service,属主 root、权限 644,然后按顺序操作:

# 1. 重新加载单元定义 systemctl daemon-reload # 2. 手工跑一次,看是否正常 systemctl start app-pack.service # 3. 看状态和退出码 systemctl status app-pack.service # 4. 看日志 journalctl -u app-pack.service -n 100 --no-pager # 5. 跟随日志实时观察 journalctl -u app-pack.service -f

systemctl status的输出里,最值得看的是Active:那一行的结果码和Process:那一行记录的ExecStart退出状态。如果显示status=203/EXEC,基本可以断定是路径写错或者文件没有执行权限;status=1/FAILURE则是脚本自己返回了非零。

脚本本身的权限和格式也要检查。chmod +x是必须的;另外如果脚本是在 Windows 上编辑后传上去的,行尾可能是 CRLF,bash 会报bad interpreter: /bin/bash^M,用file命令或者cat -A能看出来,dos2unix处理一下即可。

4.5 用 timer 替代 cron

有了 service,再加一个.timer就能实现周期调度。文件名要和 service 对应,比如app-pack.timer搭配app-pack.service:

[Unit] Description=Run app pack job nightly [Timer] OnCalendar=*-*-* 03:20:00 RandomizedDelaySec=300 Persistent=true Unit=app-pack.service [Install] WantedBy=timers.target

OnCalendar的格式是星期 年-月-日 时:分:秒,支持通配和范围。几个常用写法:

表达式含义
*-*-* 03:20:00每天 03:20
Mon-Fri *-*-* 09:00:00周一到周五 09:00
*-*-01 04:00:00每月 1 号 04:00
*:0/15每 15 分钟(整刻起)
hourly每小时

RandomizedDelaySec=300会在计划时间上叠加一个 0 到 300 秒的随机延迟。单机看不出意义,但如果几十台机器都配了同一个定时任务,不加这个就会在同一秒集体打向数据库,这种“惊群”是真实会压垮服务的。

Persistent=true是 timer 相对 cron 最大的优势之一:它会记录上次触发时间,机器关机期间错过的触发,开机后会补跑一次(注意只补一次,不是把错过的每次全补一遍)。

启用方式是 enable timer 而不是 service:

systemctl daemon-reload systemctl enable --now app-pack.timer systemctl list-timers --all

list-timers会显示下一次触发时间、上次触发时间、以及对应的单元,这个视图比翻 crontab 直观得多。手工触发一次用systemctl start app-pack.service,要注意 timer 的Persistent状态不会因为手工触发而更新,这是设计如此。

5. 任务没跑起来?按这张表排查

前面都是“怎么配”,这一节讲“配了不生效怎么办”。我把这些年遇到的问题归了归,绝大多数都能落到下面这张表里。

5.1 常见原因对照表

现象最可能的原因验证方式
cron 任务完全没执行crond 没运行systemctl status crond/crond
某个任务单独不生效配置文件末尾缺换行、权限不对tail -c 20 文件 | xxd、ls -l
手工执行正常,cron 报 command not foundPATH 不含该命令目录脚本里全用绝对路径
脚本报 No such file or directory工作目录不是脚本目录脚本开头cd到固定目录
脚本报 bad interpreter行尾是 CRLFfile 脚本、cat -A
任务跑了两次任务叠加,无并发控制加flock -n
时间对不上时区是 UTC 不是本地时区timedatectl
timer 不触发enable 的是 service 不是 timersystemctl list-timers --all
service 显示 activating 不结束Type选错或TimeoutStartSec未设看systemctl status详情
service 报 203/EXEC路径错误或无执行权限ls -l、file
日志里什么都没有输出没重定向,进了邮件队列检查>>与2>&1
换了机器就不生效配置放错位置或未 daemon-reloadsystemctl cat 单元名

5.2 几个典型场景的复盘

场景一:日志文件一行都没写。这是最常见的“看起来没跑”。第一反应别急着改配置,先确认任务到底有没有被触发。cron 的触发记录在系统日志里,journalctl -u crond -n 50或者翻/var/log/cron,能看到每次调用的时间戳和用户。如果日志里连调用记录都没有,问题在配置层;如果有记录但业务日志为空,问题在执行层,去看输出跑哪儿了。

场景二:脚本里mkdir成功了,但第二天文件不见了。十有八九是相对路径。cron 的工作目录是HOME,你mkdir output创建在了家目录,而检查脚本找的是/opt/app/output。这类问题在加了WorkingDirectory的 systemd 服务里不会出现,这也是我逐渐把重要任务迁到 systemd 的原因之一。

场景三:任务偶尔漏跑,没有规律。先看有没有并发控制,再看执行时长。一个跑 8 分钟的任务配 5 分钟周期,日志里应该能看到重叠的痕迹——开始时间戳密集、结束时间戳交错。另一个可能是有外部锁:多个任务共用同一个flock文件,或者都在抢同一个数据库行锁,导致某些实例拿不到资源直接退出。用flock -n的话拿不到锁就是静默退出,日志里啥都没有,很容易被误认为“没跑”。

场景四:systemd 服务启动后立刻变成 inactive。这在Type=simple配了一个跑完就退出的脚本时特别常见。脚本执行完正常退出,systemd 认为服务结束了,状态变成inactive (dead),退出码 0 所以不算失败。解决办法就是改成Type=oneshot,语义上就对了。

5.3 日志到底去哪儿找

新手最容易卡住的一步就是不知道去哪看。不同工具的日志位置差别很大:

  • cron:任务自身的输出在你重定向的地方;cron 守护进程的调度记录在系统日志里,journalctl -u crond(或-u cron,取决于发行版)。
  • at:任务的执行输出默认邮件;atd的日志同样在系统日志,journalctl -u atd。
  • systemd service:全部进 journal,journalctl -u 单元名。默认可能只保留最近一段,需要长期留存的话得配置SystemMaxUse=或者转发到外部日志系统。

journalctl有几个参数值得记住:-n 100看最后 100 行,-f跟随,--since "1 hour ago"按时间过滤,-p err只看错误级别以上,-o cat去掉元数据只留消息体。排查时用-o cat --since组合,输出最清爽。

6. 上线前我会固定过的几项检查

写了这么多配置,最后还是想落回到一些更“手感”的东西上。这些东西文档里通常不写,但真能省事。

6.1 一份可以直接抄的检查清单

新任务上线前,我固定会过一遍下面这些:

  • 脚本里所有外部命令是否都是绝对路径,或者脚本顶部显式设了PATH。
  • 脚本开头有没有set -euo pipefail,失败会不会被吞掉。
  • 有没有显式cd到工作目录,或者用WorkingDirectory指定。
  • 输出是否重定向到了具体文件,日志有没有配轮转。
  • 有没有并发保护,任务执行时长和周期间隔的关系是否安全。
  • 单独跑一次,日志内容是否符合预期,退出码是不是 0。
  • 把系统时间临时往后调几分钟,验证触发时间点是否符合预期。
  • 重启一次服务或机器,验证开机自启和补跑逻辑。
  • 故意制造一次失败(比如临时改错路径),确认告警或日志能反映出来。

最后一条是我觉得最有价值的:只有你亲手验证过“失败了能被发现”,才算这套调度真的可靠。没验证过的告警,约等于没有告警。

6.2 几个容易被忽略的细节

第一个是时间同步。所有调度都建立在系统时钟准确的前提上,而虚拟机的时钟漂移是常态。定时任务的机器上,时间同步服务应该是标配,timedatectl status里System clock synchronized: yes这一行是必须的。

第二个是任务执行的幂等性。不管用哪个工具,都可能出现重复触发——手动补跑、timer 的Persistent补跑、failover 切换。脚本本身要能承受“同样的输入跑两次”的结果,要么用状态标记,要么用唯一键约束,别指望调度层保证只跑一次。

第三个是不要把所有东西都塞进 crontab。一个 crontab 里堆了二十行互相依赖的任务,谁先谁后没有任何保证,出问题基本靠猜。超过五六个任务,就值得把相关逻辑收敛到一个入口脚本里,让脚本内部按顺序执行并做好错误处理,cron 只留一行调用。这样调度层保持极简,复杂度收敛到一个可测试的地方。

第四个是留一条“手动触发”的路。不管是systemctl start还是直接跑脚本,同一个任务必须有办法在不改配置的情况下手工执行一次。生产上最常见的一类求助就是“数据没出来,能不能现在补一下”,如果补跑要改 crontab 再改回来,风险就高了一个量级。我现在的习惯是所有 systemd service 都写成可以安全手工触发的形式,脚本里带--mode参数区分全量和增量,补数据的时候直接systemctl start xxx -- --mode=full之类的(实际用Environment=或者额外单元更稳妥),比临时改配置安全得多。

最后再分享一个小技巧:如果你不确定一个OnCalendar表达式到底会在什么时候触发,用systemd-analyze calendar "*-*-* 03:20:00"让它自己算出来,包括下一次触发时间和间隔。写复杂表达式的时候先跑一遍,比上线后等结果靠谱。

RELATED READING

延伸阅读

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