systemd 单元与定时器:让脚本成为系统服务

讲解如何用 systemd 的 service 单元托管 Shell 脚本、用 timer 单元替代 cron 做定时调度,覆盖重启策略、依赖与顺序、journald 日志以及与 cron 的取舍。

1. 从 cron 到 systemd

一句话总结: cron 只会「按时间点拉起一个命令」,而 systemd 能表达依赖、顺序、重启、日志与资源约束,是托管长期运行脚本的正确抽象。

1.1 cron 的三个硬伤

# 典型 cron 条目
*/5 * * * * /opt/app/collect.sh >> /var/log/collect.log 2>&1
  • 没有重叠保护:上一次没跑完,下一次照常启动,两个进程抢同一个文件;
  • 没有依赖关系:网络还没就绪、数据库还没起来,脚本照样执行并失败;
  • 日志分散:输出重定向到自己管理的文件,轮转、检索、告警都要另做一套。

一句话总结: 在 cron 里做「不重叠、等依赖、要日志」这三件事,都得靠脚本自己补(比如 flock 做锁、sleep 等依赖),而这三件事恰好是 systemd 的原生能力。

1.2 systemd 的定位

systemd 用「单元(unit)」描述资源:.service 描述进程,.timer 描述时间,.target 描述一组单元的集合,.path、.socket 描述触发条件。

# 单元文件的三类存放位置(优先级从高到低)
# 1. /etc/systemd/system/          管理员手写,最高优先级
# 2. /run/systemd/system/          运行时生成
# 3. /usr/lib/systemd/system/      发行版/软件包安装

sudo systemctl daemon-reload       # 修改单元后必须重载
systemctl cat collect.service      # 查看最终生效的单元内容(含 override)

2. service 单元与重启策略

一句话总结: 一个 service 单元由 [Unit](元信息与依赖)、[Service](怎么跑)、[Install](何时启用)三段组成,Type 与 Restart 是两个最关键的字段。

2.1 最小可用单元

# /etc/systemd/system/collect.service
[Unit]
Description=Collect metrics every run
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
User=app
Group=app
WorkingDirectory=/opt/app
Environment=APP_ENV=prod
EnvironmentFile=-/etc/app/collect.env
ExecStart=/opt/app/collect.sh

[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable --now collect.service   # 开机自启并立即运行
systemctl status collect.service              # 查看最近一次运行结果

注意 EnvironmentFile=- 前面的减号表示「文件不存在也不报错」,用于可选配置。

一句话总结: oneshot 适合「跑完就退出」的脚本,simple 适合「一直驻留」的进程,选错 Type 会让状态显示永远停在 activating。

2.2 Type 与重启策略

Type适用场景判定「启动成功」的时机
simple默认,前台常驻进程fork 后立即视为成功
oneshot一次性任务脚本进程退出且退出码为 0
forking传统 daemon 自行后台化父进程退出时
notify支持 sd_notify 的进程收到 READY=1 时
exec想精确等 exec 成功exec 调用成功时
[Service]
Type=simple
ExecStart=/opt/app/server --port 8080
Restart=on-failure
RestartSec=5
StartLimitIntervalSec=60
StartLimitBurst=3
# 失败次数过多时停止重试,避免「疯狂重启」
# 可用 systemctl reset-failed app.service 手动清零

# 资源与安全约束
LimitNOFILE=65535
MemoryMax=512M
NoNewPrivileges=true
ProtectSystem=strict
ReadWritePaths=/var/lib/app

常用 Restart 取值:no(默认)、on-success、on-failure、on-abnormal、always。长期驻留的服务用 always,定时脚本用 no——脚本失败时应该让 systemd 记录失败,而不是无声重启。

3. timer 单元与 OnCalendar

一句话总结: timer 是「触发 service 的闹钟」:.timer 定义何时触发,.service 定义触发后跑什么,两者文件名前缀相同即自动配对。

3.1 配套的 timer 单元

# /etc/systemd/system/collect.timer
[Unit]
Description=Run collect.service every 5 minutes

[Timer]
OnCalendar=*:0/5
Persistent=true
RandomizedDelaySec=30
Unit=collect.service

[Install]
WantedBy=timers.target
# /etc/systemd/system/collect.service(被 timer 触发的部分)
[Unit]
Description=Collect metrics

[Service]
Type=oneshot
User=app
ExecStart=/opt/app/collect.sh
sudo systemctl daemon-reload
sudo systemctl enable --now collect.timer

# 查看所有定时器与下次触发时间
systemctl list-timers --all

# 手动触发一次(等价于 cron 里「立即跑一次」)
sudo systemctl start collect.service

关键点:Unit= 可以省略,此时同名 .service 自动配对;timer 单元不需要 Type=oneshot,oneshot 是 service 的属性。

一句话总结: timer 与 service 分居两个文件,文件名相同即配对,Persistent=true 保证关机期间错过的任务在开机后补跑。

3.2 OnCalendar 表达式

# 用 systemd-analyze 验证表达式(强烈建议每次都验证)
systemd-analyze calendar "Mon..Fri *-*-* 02:30:00"
systemd-analyze calendar "*-*-* 00/6:00:00"
systemd-analyze calendar "hourly" --iterations=3
[Timer]
OnCalendar=*-*-* 03:30:00            # 每天 03:30(等价 cron 的 30 3 * * *)
OnCalendar=Mon..Fri *-*-* *:00:00    # 工作日每小时整点

# 相对时间:开机后 10 分钟、之后每 30 分钟
OnBootSec=10min
OnUnitActiveSec=30min

AccuracySec=1min                     # 触发精度,越大越省电

OnCalendar 用的是「日历事件」语法:星期 日期 时间,* 表示任意,.. 表示区间,/ 表示步进。相比之下 OnBootSec/OnUnitActiveSec 是「单调时钟」,不受系统时间调整影响,适合「每隔 N 分钟」这类需求。

4. 依赖与启动顺序

一句话总结: Requires/Wants 描述「谁需要谁」,After/Before 描述「谁先谁后」,两者是正交的,缺一个都会出错。

4.1 Requires 与 After 的区别

[Unit]
# 依赖关系(拉不拉起对方)
Wants=postgresql.service          # 弱依赖:对方失败我也照跑
Requires=network-online.target    # 强依赖:对方失败我就失败

# 顺序关系(谁先启动)
After=network-online.target postgresql.service
Before=report.service

最常见的错误是只写 Requires 不写 After:systemd 会并行启动两者,你的脚本可能在数据库还没监听端口时就发起连接。

# 排错:查看单元的依赖树与当前状态
systemctl list-dependencies collect.service
systemctl show collect.service -p After -p Requires -p Wants

一句话总结: 记住口诀「依赖用 Requires/Wants,顺序用 After/Before,想同时具备就两个都写」。

4.2 常见依赖组合

[Unit]
Description=App API server
Wants=network-online.target                        # 弱依赖网络
After=network-online.target postgresql.service     # 顺序:网络与数据库之后
Requires=postgresql.service                        # 强依赖数据库
PartOf=postgresql.service                          # 对方重启时本单元也重启

PartOf 是「反向传播」:当 postgresql.service 重启或停止时,本单元也跟着重启。对无状态服务很实用;有状态服务应改为自行重连,避免被动重启造成抖动。

5. 日志与 journald

一句话总结: systemd 默认把服务输出收进 journald,journalctl -u 即可按单元检索,配合 -f、--since、-p 能覆盖绝大多数排错场景。

5.1 查询与过滤

journalctl -u app.service -f                    # 实时跟踪
journalctl -u app.service --since "1 hour ago"  # 时间窗口
journalctl -u app.service --since "2026-10-01 09:00" --until "2026-10-01 10:00"
journalctl -u app.service -p err                # 只看 error 以上
journalctl -u app.service -b                    # 最近一次启动
journalctl -u app.service -o json | jq -r '.MESSAGE'   # 结构化输出

# 从脚本里写结构化日志(systemd 会解析 key=value)
echo "MESSAGE=backup done size=$size" | systemd-cat -t backup -p info

一句话总结: -u 按单元、-p 按级别、--since/--until 按时间、-o json 按结构,四把钥匙打开 journald 的全部内容。

5.2 日志输出与轮转

[Service]
StandardOutput=journal                              # 默认进 journal
StandardOutput=append:/var/log/app/app.log          # 需要给外部采集时追加文件
LogRateLimitIntervalSec=30s                         # 限制日志速率,防刷爆
LogRateLimitBurst=1000
journalctl --disk-usage                 # journald 容量治理
sudo journalctl --vacuum-size=500M
sudo journalctl --vacuum-time=14d
# /etc/systemd/journald.conf 关键项
[Journal]
Storage=persistent        # 落盘,重启后仍可查
SystemMaxUse=1G
MaxRetentionSec=2week

6. 用户级单元

一句话总结: 用户级单元跑在用户自己的 systemd 实例里,无需 root、路径在 ~/.config/systemd/user/,但默认会随用户登出而被杀掉,需要 linger 才能常驻。

6.1 用户实例与常驻

# 用户级单元放在这里
mkdir -p ~/.config/systemd/user/

# 用户实例的操作命令(注意 --user)
systemctl --user daemon-reload
systemctl --user enable --now collect.timer
systemctl --user list-timers

# 让用户的服务在登出后继续运行(关键)
sudo loginctl enable-linger "$USER"

# 查看用户实例状态
systemctl --user status

一句话总结: 没有 enable-linger 时,用户级服务会随最后一个会话结束而停止——这是「本地测试正常、SSH 断开就没了」的头号原因。

6.2 用户单元的写法差异

# ~/.config/systemd/user/collect.timer
[Unit]
Description=Collect metrics (user)

[Timer]
OnCalendar=*:0/10
Persistent=true

[Install]
WantedBy=timers.target
# ~/.config/systemd/user/collect.service
[Unit]
Description=Collect metrics

[Service]
Type=oneshot
ExecStart=%h/bin/collect.sh

[Install]
WantedBy=default.target

用户单元里可以用 %h 展开家目录、%u 展开用户名。注意用户单元不能使用 User=/Group=(进程本来就以该用户身份运行),WantedBy 也通常写 default.target 而非 multi-user.target。

7. 实战:把 cron 任务迁移到 timer

一句话总结: 迁移的核心是三张对照表:时间表达式、重叠保护、日志去向;迁移后立刻用 list-timers 与 journalctl 验证,再删掉 cron 条目。

7.1 迁移对照

cron 写法systemd timer 写法
30 3 * * *OnCalendar=*-*-* 03:30:00
*/5 * * * *OnCalendar=*:0/5
0 0 * * 1OnCalendar=Mon *-*-* 00:00:00
@rebootOnBootSec=1min
无重叠保护Type=oneshot 天然串行(同一单元不会并发运行)
>> /var/log/x.logStandardOutput=journal + journalctl -u
# 迁移四步:写单元 → 重载 → 启用 → 验证
sudo tee /etc/systemd/system/backup.service >/dev/null <<'EOF'
[Unit]
Description=Nightly backup
After=network-online.target
Wants=network-online.target

[Service]
Type=oneshot
User=backup
ExecStart=/opt/backup/run.sh
Nice=10
IOSchedulingClass=idle
EOF

sudo tee /etc/systemd/system/backup.timer >/dev/null <<'EOF'
[Unit]
Description=Nightly backup at 03:30

[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
RandomizedDelaySec=5min

[Install]
WantedBy=timers.target
EOF

sudo systemctl daemon-reload && sudo systemctl enable --now backup.timer
systemctl list-timers backup.timer

RandomizedDelaySec 在多台机器上尤其重要:它能打散「同一时刻一起启动」造成的 IO 尖峰。

一句话总结: Type=oneshot 天然保证同一单元不会并发执行,这是相对 cron 最省心的一点。

7.2 验证与排错

sudo systemd-analyze verify /etc/systemd/system/backup.timer  # 语法与语义校验
systemd-analyze calendar "*-*-* 03:30:00"                     # 确认下次触发时间

sudo systemctl start backup.service && systemctl status backup.service
journalctl -u backup.service -n 50 --no-pager                 # 定位失败原因
sudo systemctl reset-failed backup.service                    # 清理失败状态

常见排错路径:list-timers 里没有条目 → 忘记 enable 或没重载;状态一直是 activating → Type 选错;ExecStart 报 203/EXEC → 路径不对或缺执行位;脚本跑起来但立即失败 → 环境变量缺失(systemd 不加载 ~/.bashrc)。

8. 总结

环节要点
动机cron 缺重叠保护、依赖与日志,systemd 原生具备
service[Unit]/[Service]/[Install] 三段,Type 决定成功判定
重启常驻服务 Restart=always,定时脚本保持 no
timer.timer 与同名 .service 自动配对,Persistent=true 补跑
日历用 systemd-analyze calendar 验证 OnCalendar
依赖Requires/Wants 管依赖,After/Before 管顺序
日志journalctl -u 按单元检索,-o json 接结构化管道
用户级systemctl --user + loginctl enable-linger 才能常驻

systemd 把「脚本怎么被启动、被监控、被记录」从脚本自身剥离成了声明式配置,脚本只需要专注于业务逻辑。这套托管方式在容器环境里同样成立——只不过容器里的「PID 1」不是 systemd,而往往就是我们自己的 entrypoint 脚本,需要手动补上信号转发与回收僵尸的职责。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「shell」更多文章

  1. 任务编排与 Makefile 实战
  2. 文件监控与事件驱动流水线实战
  3. 结构化数据清洗与报表生成实战