
1. 为什么我放着cron不用偏要折腾systemd定时器前阵子帮一个朋友收拾一台跑着Red Hat 9.0的旧服务器上面有一堆备份脚本、日志清理脚本、健康检查脚本全是靠crontab撑着的。跑是能跑但问题也不少有的任务执行失败了完全没记录有的任务因为上一次没跑完又叠了一次还有的脚本依赖网络和挂载点开机那会儿cron就急吼吼地拉起来了结果自然是各种报错。我当时的第一个念头就是把这些零散的cron条目统一迁到systemd timer上。这篇内容聊的就是在Red Hat 9.0上从零创建一个systemd定时器的完整过程。systemd定时器本质上是用systemd的单元管理能力来调度任务它由两个文件组成一个.service单元描述“要做什么”一个.timer单元描述“什么时候做”。相比传统的cron它能拿到完整的日志、能做依赖排序、能防止任务重叠、能精确到秒级甚至单调时钟触发还能在系统休眠错过后补跑。适合谁看如果你手上有一台RHEL 9系或者兼容发行版比如各类企业级衍生版本的机器正在被cron的“黑盒执行”折磨或者你单纯想把手上的运维动作做得更规范一点那这套东西值得花半小时学透。我先说清楚一点systemd timer不是银弹简单的“每天凌晨跑一次”用cron一行就完事了没必要上纲上线。但只要你开始关心“任务失败了我要知道”“任务不能并发”“任务要等网络就绪再跑”“任务错过后要补”那timer的优势就出来了。下面我按我实际落地的顺序把设计思路、文件写法、踩坑记录全盘托出。2. 整体设计思路与单元文件拆解2.1 一对单元文件的职责划分systemd的调度模型和cron最大的不同在于它把“任务内容”和“触发时机”彻底解耦了。你要创建的是一个.service加一个.timer的配对命名上建议保持一致比如backup-job.service配backup-job.timer这样systemctl enable --now backup-job.timer的时候systemd能通过同名关系自动关联省得你在timer里再手写一遍Unit。.service单元里写的是Typeoneshot加一个ExecStart意思是“跑一次就退出”的一次性任务。这里有个关键点定时器触发的service不要写成Typesimple的常驻服务否则systemd会认为它一直在运行后续触发逻辑会乱。.timer单元里写的是OnCalendar或者OnBootSec这类触发条件外加一个[Install]段里的WantedBytimers.target这样开机后timers.target被拉起时你的定时器就自动生效了。我为什么强调命名一致因为如果你在timer里不写Unitsystemd默认去找和timer同名的service。命名一致等于少写一行配置、少一个出错点这种“约定优于配置”的便宜不占白不占。2.2 放在/etc还是放在用户目录单元文件的存放位置直接决定了这个定时器是系统级还是用户级。系统级的放/etc/systemd/system/需要root权限管理开机即生效适合备份、清理、监控这类系统维护任务。用户级的放~/.config/systemd/user/用systemctl --user管理不需要root适合个人脚本。我这次处理的是系统维护类任务全部放在/etc/systemd/system/下。这里有个实操细节新建或修改单元文件后必须执行systemctl daemon-reload否则systemd读的还是旧缓存你会遇到“明明改了配置却不生效”的灵异现象。这个坑我踩过不止一次尤其是改完OnCalendar发现触发时间没变八成就是忘了reload。2.3 触发方式的选择逻辑.timer里最常用的触发指令有三个OnCalendar做日历式定时类似cron但语法更灵活OnBootSec做开机后延时触发OnUnitActiveSec做“上次激活后间隔多久再触发”。我这次的任务需要每天固定时间跑所以主用OnCalendar。选OnCalendar而不是cron表达式是因为它的可读性和精度都更好。比如OnCalendar*-*-* 03:30:00表示每天凌晨3点半OnCalendarMon..Fri *-*-* 09:00:00表示工作日早上9点。它支持minutely、hourly、daily这些简写也支持*:0/15这种步进写法表示每15分钟。相比之下cron的*/15 * * * *虽然也简洁但一旦涉及“每月最后一个周五”这种需求cron就得靠脚本绕而OnCalendar能直接表达。2.4 防重叠与失败重试的设计考量这是timer相对cron最实在的增益。cron里如果上一个任务还没跑完下一个时间点到了照样拉起两个进程打架是常有的事。systemd这边只要你的service是Typeoneshot同一个单元在运行期间再次被触发时systemd默认会跳过这次触发准确说是排队但不会并发执行同一个单元实例。如果你希望更严格可以在service里加RemainAfterExitno默认就是no配合timer的AccuracySec1s把触发精度收紧。失败重试方面service里可以配Restarton-failure和RestartSec30但要注意oneshot类型的服务配Restart要谨慎因为oneshot跑完就退出systemd可能把正常退出也当成需要重启的场景。我的做法是不在service里做重试而是在脚本内部处理重试逻辑service只管“跑一次”职责更清晰。3. 核心细节解析与实操要点3.1 service单元的关键参数逐条讲先看我实际写的一个备份任务的service文件路径是/etc/systemd/system/nightly-backup.service[Unit] DescriptionNightly data backup job Afternetwork-online.target Wantsnetwork-online.target [Service] Typeoneshot ExecStart/usr/local/bin/nightly-backup.sh Userbackupuser Groupbackupuser Nice10 IOSchedulingClassbest-effort IOSchedulingPriority7 StandardOutputjournal StandardErrorjournal逐条说。Afternetwork-online.target和Wantsnetwork-online.target是配套的Wants表示“我希望这个目标被拉起”After表示“等它拉起后我再启动”。这两个一起写才能保证脚本执行时网络已经就绪。这里有个坑network-online.target本身需要NetworkManager-wait-online.service或systemd-networkd-wait-online.service配合才会真正“等到网络就绪”如果机器上没启用对应的wait-online服务这个target会瞬间通过等于没等。Red Hat 9.0默认用NetworkManager所以要确认systemctl is-enabled NetworkManager-wait-online.service是enabled状态。User和Group让任务以非root身份跑这是安全实践。备份脚本只需要读数据目录、写备份目录给个专用账号加目录权限就够了没必要root满天飞。Nice10和IOSchedulingClassbest-effort是把任务优先级调低避免备份把IO打满影响线上业务——这个在机械盘或者共享存储上尤其重要我见过备份一跑数据库查询就卡成幻灯片的场景。StandardOutputjournal和StandardErrorjournal让脚本的输出进journal之后用journalctl -u nightly-backup.service就能看到完整日志。这是timer相对cron最大的体验提升cron的输出要么进邮件还得配MTA要么重定向到文件自己管而systemd统一收进journal带时间戳、带单元名查起来非常顺手。3.2 timer单元的时间表达式怎么写才不出错对应的timer文件/etc/systemd/system/nightly-backup.timer[Unit] DescriptionTimer for nightly backup job [Timer] OnCalendar*-*-* 03:30:00 Persistenttrue AccuracySec1min RandomizedDelaySec5min Unitnightly-backup.service [Install] WantedBytimers.targetOnCalendar*-*-* 03:30:00是每天3点半。Persistenttrue是关键如果3点半时机器关机了开机后systemd会检查这个timer上次触发时间发现错过了就立刻补跑一次。这个特性对笔记本、经常重启的测试机特别有用cron做不到这点除非你自己写anacron。AccuracySec1min表示触发精度容忍1分钟误差。systemd默认的AccuracySec是1分钟目的是把多个timer的触发合并到同一个唤醒窗口减少CPU唤醒次数省电。如果你需要秒级精确把它设成1s但代价是可能更频繁唤醒。RandomizedDelaySec5min是加一个0到5分钟的随机延迟防止多台机器在同一时刻同时触发备份把存储打爆——这个在多机部署的场景里是保命参数单机可以不加。Unitnightly-backup.service其实可以省略因为同名但我习惯显式写出来读配置的人一眼就知道这个timer触发的是哪个service不用去猜命名规则。3.3 时间表达式的验证方法OnCalendar写错了systemd不会在reload时报错而是默默不触发这是最坑的地方。所以写完一定要用systemd-analyze calendar验证systemd-analyze calendar *-*-* 03:30:00它会输出这个表达式的规范化形式、下次触发时间、以及人类可读的描述。如果表达式非法它会直接报错。我每次写完timer都会跑一遍这个命令确认“下次触发时间”符合预期再enable。这个习惯帮我避免了好几次“以为配好了结果第二天没跑”的尴尬。3.4 权限与文件属主的细节单元文件本身的权限也有讲究。放在/etc/systemd/system/下的文件属主应该是root:root权限644。如果文件属主不对systemd会拒绝加载并报“unit file is not owned by root”之类的警告。另外脚本文件/usr/local/bin/nightly-backup.sh要有执行权限755且第一行shebang要写对#!/bin/bash否则ExecStart直接执行会失败。还有一个容易忽略的点ExecStart里写的路径必须是绝对路径systemd不接受相对路径也不走shell的PATH查找。如果你习惯在命令行里直接敲脚本名搬到service里一定要补全路径。4. 完整实操流程与现场记录4.1 从零到生效的六步操作我把整个落地过程整理成可复现的步骤假设任务是一个每天清理临时目录的脚本。第一步写脚本并赋权。脚本放/usr/local/bin/cleanup-tmp.sh内容大致是清理/var/tmp下超过7天的文件脚本里加set -euo pipefail让出错即退出。然后chmod 755 /usr/local/bin/cleanup-tmp.sh。第二步创建service文件/etc/systemd/system/cleanup-tmp.service内容参考上一节的模板ExecStart指向脚本Typeoneshot。第三步创建timer文件/etc/systemd/system/cleanup-tmp.timerOnCalendardaily等价于每天0点Persistenttrue。第四步验证时间表达式systemd-analyze calendar daily确认输出正常。第五步重载并启用systemctl daemon-reload systemctl enable --now cleanup-tmp.timerenable --now一步到位既设置开机自启又立即启动timer。注意这里启动的是.timer不是.serviceservice由timer按计划触发你手动startservice只是立刻跑一次任务本身。第六步验证状态systemctl list-timers --all systemctl status cleanup-tmp.timerlist-timers会列出所有timer的下次触发时间、上次触发时间、以及对应的service一眼就能看出你的timer有没有被正确加载。status看timer单元本身的状态如果显示active (waiting)就说明它在正常等待触发。4.2 手动触发一次做冒烟测试配置完别干等先手动跑一次service验证脚本本身没问题systemctl start cleanup-tmp.service journalctl -u cleanup-tmp.service -n 50 --no-pagerstartservice会立刻执行一次任务然后看journal里有没有报错。这一步能把“脚本本身有bug”和“timer配置有问题”两类故障分开排查效率高很多。我一般会故意让脚本先输出几行日志确认journal能收到再去看timer的触发是否正常。4.3 观察真实触发与日志追踪timer生效后等一个触发周期用journalctl -u cleanup-tmp.service --since 1 hour ago看有没有按点执行。如果想实时盯用journalctl -u cleanup-tmp.service -f。另外systemctl list-timers里的LAST列会显示上次触发时间NEXT列显示下次触发时间这两个时间对得上就说明调度正常。有个细节list-timers默认只显示已激活的timer加--all才显示包括未激活在内的全部。排查“timer为什么没跑”时先确认它在不在list-timers输出里不在就说明没enable或者加载失败。4.4 停用与清理的正确姿势要临时停掉一个timer用systemctl stop cleanup-tmp.timer这会取消后续触发但保留开机自启配置。要彻底禁用用systemctl disable --now cleanup-tmp.timer。删除的话先disable再删两个单元文件最后daemon-reload。这里有个顺序问题先disable再删文件。如果先删文件再disablesystemd会因为找不到单元文件而报错虽然不影响功能但看着膈应。另外删完文件一定要reload否则list-timers里可能还残留着已删除的timer条目。5. 常见问题与排查技巧实录5.1 timer不触发的排查路径这是最高频的问题。我整理了一个排查顺序按这个走基本能定位排查项检查命令常见原因timer是否加载systemctl list-timers --all没enable或文件路径错时间表达式是否合法systemd-analyze calendar 表达式语法写错systemd静默忽略是否忘了reloadsystemctl daemon-reload改了文件没重载service能否手动跑通systemctl start xxx.service脚本本身报错依赖是否满足systemctl status xxx.service网络/挂载未就绪是否被条件阻塞journalctl -u xxx.serviceCondition判断为假我遇到过一次timer死活不触发最后发现是OnCalendar里写成了*-*-* 3:30:00小时没补零systemd对时间格式要求严格03和3在某些位置不等价改成03:30:00就好了。这种错误systemd-analyze calendar能直接抓出来所以验证这一步千万别省。5.2 service执行失败但timer显示正常timer显示active (waiting)只代表调度器在正常工作不代表任务执行成功。任务失败要看service的journal。常见失败原因脚本路径写错ExecStart用了相对路径、脚本没有执行权限、脚本里用了交互式命令比如read导致挂起、环境变量缺失systemd启动的进程环境比登录shell干净得多PATH可能不含/usr/local/bin。环境变量这个坑特别隐蔽。cron里也常遇到但systemd更严格它不加载/etc/profile也不读用户的.bashrc。如果脚本依赖某个环境变量要么在service里用Environment显式声明要么在脚本里自己export。我现在的习惯是脚本开头把所有依赖的环境变量都显式设一遍不依赖外部环境。5.3 Persistent补跑带来的意外Persistenttrue在关机错过后会补跑但补跑时机是开机后systemd启动timer时立刻触发。如果你的任务对执行时间敏感比如“必须在凌晨低峰期跑”补跑可能发生在业务高峰期反而添乱。这种情况要么关掉Persistent要么在脚本里加时间判断如果当前时间不在允许窗口内就跳过本次执行并记录日志。我处理过一个日志轮转任务就加了这种保护避免开机补跑时把正在写的日志切了。5.4 多timer同时触发的资源争抢如果机器上有多个timer都配在整点触发容易在同一时刻集中跑把CPU和IO打满。解决办法有两个一是用RandomizedDelaySec给每个timer加不同的随机延迟二是用AccuracySec放宽精度让systemd合并唤醒。我一般给非紧急任务加RandomizedDelaySec10min把触发时间打散实测下来系统负载曲线平滑很多。5.5 用户级timer的特殊注意点如果用的是systemctl --user管理的用户级timer有个前提用户必须保持登录状态或者为该用户开启了linger。否则用户一登出用户级systemd实例就停了timer自然不触发。开启linger的命令是loginctl enable-linger 用户名这个在跑个人定时任务时经常被忘掉导致“我明明配了timer怎么不跑”的困惑。6. 我踩过的坑与几条实操心得先说一个最典型的。早期我把一个需要访问挂载点的任务配成Afterlocal-fs.target以为挂载完就会跑结果那个挂载点是网络存储local-fs.target根本不管网络挂载。后来改成Afterremote-fs.target才对。这个教训是依赖目标要选对本地文件系统和远程文件系统在systemd里是两个不同的target别想当然。第二个心得是关于日志的。journalctl默认会按时间轮转如果任务日志量大可能过几天就查不到了。我的做法是在脚本里把关键结果同时写一份到/var/log/下的专用文件journal只作为辅助。这样既享受了journal的便利又保留了长期可查的记录。第三个是测试习惯。我现在的流程是写完service先systemctl start手动跑一次看journal确认脚本OK再systemd-analyze calendar验证timer表达式然后enable --now最后list-timers确认下次触发时间。这四步走完基本不会出现“配了不跑”的情况。多花两分钟验证省得第二天回来排查半小时。最后一个偏门的技巧如果想让timer在每次开机后固定延时触发比如开机5分钟后跑一次健康检查用OnBootSec5min比OnCalendar更合适因为它基于单调时钟不受系统时间调整影响。而OnCalendar基于墙上时钟如果系统时间被NTP校正触发时间会跟着变。这个区别在时间同步频繁的环境里值得注意。这套东西用熟之后我基本把机器上所有cron条目都迁过来了日志统一、依赖清晰、失败可见维护成本反而降了。如果你手上也有那么几台被cron黑盒折磨的机器不妨挑一个不紧急的任务先试水跑顺了再批量迁。