2.12 systemd 深入——Unit 编写与高级特性
预计阅读时间:9 分钟
📖 目录
学习目标
- 掌握 systemd Unit 文件三段式结构(
[Unit]/[Service]/[Install]) - 区分 5 种
Type=及其适用场景 - 用 Socket Activation 实现按需启动服务
- 用 systemd timer 替代 cron 管理定时任务
- 配置 journald 持久化存储与日志限额
- 用
systemd-analyze诊断与优化系统启动时间
核心知识
| 知识点 | 说明 | 关键命令/文件 |
|---|---|---|
| Service Type | systemd 如何判断服务就绪 | simple / forking / oneshot / notify / dbus |
| 依赖指令 | 顺序 + 需求两种维度 | After= / Requires= / Wants= / BindsTo= |
| Socket Activation | 端口监听 → 按需唤起服务 | .socket + .service 配对 |
| systemd Timer | 日历/相对时间表达式定时任务 | .timer + OnCalendar= / Persistent= |
| journald | 二进制日志系统,支持结构化查询 | /etc/systemd/journald.conf |
| 启动分析 | 测量/优化系统启动耗时 | systemd-analyze blame / critical-chain |
知识关联
- 前置知识:2.7:日志与故障排查 日志与故障排查(journalctl 基础用法)
- 后续影响:3.15:Cron 定时任务 Cron 高级定时任务(systemd timer 是 cron 的现代替代),4.8:集中式日志管理 集中式日志管理(journald 远程转发)
- 配套技术:systemd timer 优势在随机延迟、持久化、资源控制与统一日志;journald 提供比
/var/log/syslog更强大的结构化过滤。
原理讲解
systemd 作为 PID 1,采用并行化与按需启动两大设计原则替代传统 SysV init。其核心抽象是 Unit——每个 Unit 是一个配置文件,描述一个系统资源及其管理方式。
| Unit 类型 | 文件后缀 | 用途 | 示例 |
|---|---|---|---|
| Service | .service | 管理守护进程 | nginx.service |
| Target | .target | 组合单元,类似运行级别 | multi-user.target |
| Socket | .socket | 监听 socket,按需启动服务 | sshd.socket |
| Timer | .timer | 定时触发关联 service | db-backup.timer |
| Path | .path | 监控文件变化触发 service | example.path |
依赖关系维度:
- 顺序(Order):
After=/Before=—— 只控制启动顺序,不强制需求。写After=postgresql.service单独使用,postgresql 启动失败你的服务仍然会启动。 - 需求(Requirement):
Requires=(硬) /Wants=(软) /BindsTo=(强绑定)—— 对方状态影响本服务。 - 最佳组合:
After=+Requires=或After=+Wants=同时声明顺序与依赖。
Target 依赖链——从开机到服务的完整路径
Target 是 systemd 的"分组单元",对应传统 SysV 的运行级别(runlevel),但功能更强:Target 之间也有 Wants= / Requires= / After= 依赖,形成一个从硬件初始化到用户登录的有向无环图。
| Target | 对应运行级别 | 含义 | 典型成员 |
|---|---|---|---|
poweroff.target | runlevel 0 | 关机 | — |
rescue.target | runlevel 1 | 单用户维护模式 | emergency 之外的修复工具 |
multi-user.target | runlevel 3 | 多用户命令行 | sshd、cron、网络服务 |
graphical.target | runlevel 5 | 图形界面 | gdm / sddm 显示管理器 |
reboot.target | runlevel 6 | 重启 | — |
# 查看 multi-user.target 依赖了谁(及其顺序)
systemctl list-dependencies multi-user.target
# 输出(节选):
# multi-user.target
# ├─dbus.service
# ├─network.target
# ├─sshd.service
# ├─cron.service
# └─systemd-logind.service
# 切换运行级别(传统 init 3/5 的现代等价物)
systemctl isolate multi-user.target # 进入命令行模式
systemctl isolate graphical.target # 回到图形界面
# 查看默认启动目标
systemctl get-default
# 输出: graphical.target
📝 依赖检查是递归的
After=network.target 不代表网络已就绪——network.target 本身是"网络服务应已启动"的聚合点,但 DHCP 完成更晚。需要真实网络可用时,用 After=network-online.target + Wants=network-online.target(配合 NetworkManager-wait-online 或 systemd-networkd-wait-online)。这也是"开机启动的网络服务偶尔起不来"的经典根因。systemd.exec 安全字段——让服务"跑在笼子里"
systemd 自带一套类似容器的沙箱能力(不需要 Docker 就能限制服务)。生产环境的 [Service] 段建议按需启用:
| 字段 | 作用 | 典型值 |
|---|---|---|
PrivateTmp= | 为服务提供独立的 /tmp 和 /var/tmp | yes |
PrivateDevices= | 隐藏大部分设备节点,仅保留少数基本设备 | yes |
ProtectSystem= | full 只读挂载 /usr /boot /etc;strict 除 ReadWritePaths 外全部只读 | strict |
ProtectHome= | 隐藏 /home /root(read-only 或 yes) | read-only |
ProtectKernelTunables= | 禁止修改内核参数(/proc/sys 等) | yes |
NoNewPrivileges= | 禁止进程通过 setuid 等获得新权限 | yes |
MemoryMax= | 内存硬上限(cgroups v2) | 1G |
CPUQuota= | CPU 配额百分比 | 200%(2 核) |
RestrictAddressFamilies= | 限制可用的网络地址族 | AF_UNIX AF_INET AF_INET6 |
# 查看某个服务实际生效的安全限制
systemd-analyze security sshd.service
# 输出(节选):
# → Overall exposure level for sshd.service: 5.4 EXPOSED
# → PrivateTmp=yes 0.1
# → PrivateDevices=yes 0.1
# → ProtectSystem=full 0.5
# → NoNewPrivileges=yes 0.2
示例代码
基础 Service Unit
[Unit]
Description=My Custom Application
Documentation=https://docs.example.com
After=network-online.target postgresql.service
Requires=postgresql.service
Wants=redis.service
[Service]
Type=simple
User=appuser
Group=appuser
WorkingDirectory=/opt/myapp
EnvironmentFile=-/etc/myapp/env.conf
ExecStart=/usr/bin/java -jar /opt/myapp/app.jar
ExecStartPre=/usr/local/bin/check-config.sh
ExecStop=/usr/local/bin/graceful-shutdown.sh
ExecReload=/usr/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5
TimeoutStartSec=60
TimeoutStopSec=30
LimitNOFILE=65536
[Install]
WantedBy=multi-user.target
三种 Service Type 对比
# simple — 前台进程,ExecStart 启动即就绪
[Service]
Type=simple
ExecStart=/usr/local/bin/uvicorn main:app
# forking — 先父后子,需要 PIDFile
[Service]
Type=forking
PIDFile=/run/nginx.pid
ExecStartPre=/usr/sbin/nginx -t -q
ExecStart=/usr/sbin/nginx
# oneshot — 执行一次即退出,常与 RemainAfterExit=yes 配合
[Service]
Type=oneshot
RemainAfterExit=yes
ExecStart=/usr/local/bin/init-cluster.sh
ExecStop=/usr/local/bin/cleanup-cluster.sh
# 验证 & 测试
systemd-analyze verify /etc/systemd/system/myapp.service
systemctl daemon-reload
systemctl start myapp.service
systemctl status myapp.service
# 输出: ● myapp.service - My Custom Application
# Loaded: loaded (/etc/systemd/system/myapp.service; disabled)
# Active: active (running) since ...
Timer Unit — 替代 Cron
# /etc/systemd/system/db-backup.service
[Unit]
Description=Database backup
[Service]
Type=oneshot
ExecStart=/usr/local/bin/backup-db.sh
User=backup
# /etc/systemd/system/db-backup.timer
[Unit]
Description=Daily backup at 02:30
[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=300
Persistent=true
[Install]
WantedBy=timers.target
systemctl daemon-reload
systemctl enable --now db-backup.timer
systemctl list-timers --all
# 输出: NEXT LEFT LAST PASSED UNIT
# Fri 2026-07-31 02:30:00 4h 23min n/a n/a db-backup.timer
journald 配置
# /etc/systemd/journald.conf
[Journal]
Storage=persistent
SystemMaxUse=500M
SystemMaxFileSize=100M
MaxRetentionSec=2week
ForwardToSyslog=no
Compress=yes
mkdir -p /var/log/journal && systemctl restart systemd-journald
journalctl --vacuum-size=300M
journalctl -u nginx.service -S "1 hour ago" -p err --no-pager
# 输出(示例):
# Jul 30 10:15:01 host nginx[1234]: 2026/07/30 10:15:01 [error] ...
Socket Activation
# /etc/systemd/system/myapp.socket
[Unit]
Description=My App Activation Socket
[Socket]
ListenStream=0.0.0.0:8080
Accept=no
SocketUser=appuser
SocketMode=0660
[Install]
WantedBy=sockets.target
# /etc/systemd/system/myapp.service 无需 enable
[Service]
Type=simple
User=appuser
ExecStart=/usr/bin/myapp # 应用从 sd_listen_fds(3) 取 socket
systemctl enable --now myapp.socket
systemctl list-sockets
# 输出: LISTEN UNIT ACTIVATES
# 0.0.0.0:8080 myapp.socket myapp.service
生产级安全加固 Service 模板
# /etc/systemd/system/backend.service
[Unit]
Description=Backend API Server
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=backend
Group=backend
WorkingDirectory=/opt/backend
ExecStart=/usr/bin/python3 /opt/backend/server.py
Restart=on-failure
RestartSec=3
# 资源限制
LimitNOFILE=65536
LimitNPROC=4096
MemoryMax=2G
MemoryHigh=1.5G
CPUQuota=150%
TasksMax=256
# 安全沙箱
PrivateTmp=yes
PrivateDevices=yes
ProtectSystem=strict
ProtectHome=read-only
ProtectKernelTunables=yes
ProtectKernelModules=yes
NoNewPrivileges=yes
ReadWritePaths=/var/lib/backend /var/log/backend
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
# 环境
Environment=NODE_ENV=production
EnvironmentFile=-/etc/backend/env.conf
[Install]
WantedBy=multi-user.target
# 验证:用 systemd-analyze 检查配置合法性,再看安全评分
systemd-analyze verify /etc/systemd/system/backend.service
systemd-analyze security backend.service
# 输出: → Overall exposure level for backend.service: 1.4 OK
Path 单元——文件变化触发任务
# /etc/systemd/system/upload-watch.path
[Unit]
Description=Watch upload directory
[Path]
PathExistsGlob=/srv/uploads/*
DirectoryNotEmpty=/srv/uploads
[Install]
WantedBy=multi-user.target
# /etc/systemd/system/upload-watch.service
[Unit]
Description=Process new uploads
[Service]
Type=oneshot
ExecStart=/usr/local/bin/process-uploads.sh
User=appuser
systemctl enable --now upload-watch.path
# 任何文件落入 /srv/uploads 目录时自动触发 process-uploads.sh
systemctl list-units --type=path
# 输出: upload-watch.path loaded active watching Watch upload directory
常见错误
| 错误表现 | 根因 | 正确做法 |
|---|---|---|
服务启动提示 code=exited status=203/EXEC | ExecStart 路径不存在或无可执行权限 | 使用绝对路径;which myapp 确认;chmod +x |
| Type=forking 时服务启动后又立即停止 | PIDFile 路径错误或进程未写入 PID 文件 | 确认 PIDFile 与进程实际写入路径一致;用 ExecStartPost 验证 |
| Timer 到了时间但没有执行 | enable 了 .service 而非 .timer | systemctl enable --now db-backup.timer;list-timers 确认 next 时间 |
journalctl -u 查不到历史日志 | Storage=auto 且 /var/log/journal 目录不存在 | mkdir -p /var/log/journal && systemctl restart systemd-journald |
systemd-analyze verify 报 Unknown lvalue | 指令拼写错误或该节不支持此指令 | 查阅 man systemd.exec / man systemd.service 确认语法 |
| Socket Activation 端口已被占用 | ListenStream 端口与已有服务冲突 | ss -tlnp 查占用进程;改 ListenStream 或停用冲突服务;Accept=no(默认)时端口由 .socket 单元监听属正常状态 |
最佳实践
| 实践 | 原理 | 示例 |
|---|---|---|
Always combine After= + Requires=/Wants= | 单独 After= 只控制顺序不控制需求,容易导致服务启动时依赖没就绪 | After=postgresql.service\nRequires=postgresql.service |
Use Restart=on-failure instead of always | always 在正常 stop 后也重启,违背运维预期;on-failure 只对异常退出生效 | Restart=on-failure\nRestartSec=5 |
Set LimitNOFILE/LimitNPROC for production services | systemd 默认文件描述符限制过低(1024),高并发服务容易报 Too many open files | LimitNOFILE=65536\nLimitNPROC=4096 |
Add PrivateTmp=yes + ProtectSystem=strict | 容器级安全隔离,限制服务对敏感目录的写权限,降低被入侵风险 | PrivateTmp=yes\nProtectSystem=strict\nReadWritePaths=/var/lib/myapp |
Use RandomizedDelaySec on timer | 避免多台机器同时触发造成后端"惊群" | OnCalendar=daily\nRandomizedDelaySec=1800 |
| Enable persistent journal | 默认 Storage=auto 在 /var/log/journal 不存在时只写内存,重启丢失诊断数据 | mkdir -p /var/log/journal && systemctl restart systemd-journald |
练习题
- 概念题:
After=和Requires=有什么区别?写出一个同时使用二者的典型配置段。 - 概念题:systemd timer 相比 cron 的四个主要优势是什么?
- 实操题:为一个 Flask/Gunicorn Web 应用(启动命令
/usr/local/bin/gunicorn -w 4 app:app)编写生产级.service文件,要求:Type=simple、User=webapp、自动重启、限制文件描述符 65536、启用PrivateTmp。验证后用systemctl enable设置开机启动。 - 实操题:配合上题 Web 服务,创建一个
web-healthcheck.timer,每小时执行/usr/local/bin/healthcheck.sh,要求随机延迟 60 秒、错过时间后补执行。验证list-timers输出。 - 🔍 挑战题:你的服务器启动时间从 15s 恶化到 45s。请列出使用
systemd-analyze诊断的完整步骤,并说明每种可能的根因和对应的修复方法。提示:考虑NetworkManager-wait-online.service、磁盘挂载超时、废弃服务残留等。
点击查看答案
After=仅控制启动顺序,不保证依赖服务必须成功;Requires=声明强依赖,依赖失败则本 Unit 也失败。典型组合:After=network-online.target Requires=network-online.target。- ① 支持随机延迟(RandomizedDelaySec)防雪崩;② 错过运行时间后补执行(Persistent=true);③ 统一日志 journald 管理;④ 支持日历表达式与单调定时器混合。
- 参考模板:Type=simple, User=webapp, Restart=always, LimitNOFILE=65536, PrivateTmp=yes。
systemctl enable flask-app后重启验证。 OnCalendar=hourly+RandomizedDelaySec=60+Persistent=true。systemctl list-timers应显示下次触发时间含随机偏移。- 步骤:
systemd-analyze blame看各单元耗时→systemd-analyze critical-chain看关键路径→排查 NetworkManager-wait-online 可加After=network.target跳过等待。
本章总结
速查表
| 场景 | 推荐方案 | 检查命令 |
|---|---|---|
| 启动守护进程 | [Service] + Type=simple / forking | systemctl status |
| 一次性初始化 | Type=oneshot + RemainAfterExit=yes | systemd-analyze verify |
| 按需启动 | .socket + .service 配对 | systemctl list-sockets |
| 定时任务 | .timer + OnCalendar= | systemctl list-timers |
| 日志查询 | journalctl -u <unit> -S "1h ago" -p err | journalctl --verify |
| 启动耗时分析 | systemd-analyze blame | systemd-analyze critical-chain |
学习路径建议:
- 用
systemctl cat查看系统中已有的 unit 文件,逐行理解每个指令 - 仿照本章示例,创建一个你自己的
/etc/systemd/system/test-app.service - 将系统上现有的 cron 任务逐个迁移到 systemd timer,体会二者的差异
- 在测试机上运行
systemd-analyze blame并针对 top 3 做启动优化 - 阅读
man systemd.service/man systemd.timer/man journald.conf
延伸阅读
常见问题
systemd Unit 文件 Type 有什么区别?
simple(默认:ExecStart 启动即视为已启动)、forking(进程 fork 到后台,父进程退出即视为已启动,需要 PIDFile)、oneshot(执行一次就结束,适合一次性任务)、notify(进程通过 sd_notify 通知 systemd 自己已就绪)、dbus(等待 D-Bus 名字出现)。守护进程用 forking,简单脚本用 simple 或 oneshot。
systemd timer 的日历表达式怎么写?
OnCalendar 支持灵活的日期时间表达式:daily(每天 00:00)、*-*-* 03:00(每天 3:00)、Mon..Fri 09:00(工作日 9:00)、*-*-1..7 02:00(每月前 7 天 2:00)。最灵活的是 systemd.time(7) 手册中的完整语法:星期、月份、日期的各种组合都支持。
systemd-analyze 有哪些实用子命令?
systemd-analyze blame 按耗时排序各单元启动时间;critical-chain 显示关键路径;time 总启动时间;plot > boot.svg 生成启动时间 SVG 图(最直观);verify /path/to/service 检查 unit 文件语法。排查启动慢时先看 plot 图找瓶颈。