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 * * 1 | OnCalendar=Mon *-*-* 00:00:00 |
@reboot | OnBootSec=1min |
| 无重叠保护 | Type=oneshot 天然串行(同一单元不会并发运行) |
>> /var/log/x.log | StandardOutput=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 脚本,需要手动补上信号转发与回收僵尸的职责。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。