2.12 systemd 深入——Unit 编写与高级特性

预计阅读时间:9 分钟

📖 目录

学习目标

  • 掌握 systemd Unit 文件三段式结构([Unit] / [Service] / [Install]
  • 区分 5 种 Type= 及其适用场景
  • 用 Socket Activation 实现按需启动服务
  • 用 systemd timer 替代 cron 管理定时任务
  • 配置 journald 持久化存储与日志限额
  • systemd-analyze 诊断与优化系统启动时间

核心知识

知识点说明关键命令/文件
Service Typesystemd 如何判断服务就绪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定时触发关联 servicedb-backup.timer
Path.path监控文件变化触发 serviceexample.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.targetrunlevel 0关机
rescue.targetrunlevel 1单用户维护模式emergency 之外的修复工具
multi-user.targetrunlevel 3多用户命令行sshd、cron、网络服务
graphical.targetrunlevel 5图形界面gdm / sddm 显示管理器
reboot.targetrunlevel 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/tmpyes
PrivateDevices=隐藏大部分设备节点,仅保留少数基本设备yes
ProtectSystem=full 只读挂载 /usr /boot /etcstrict 除 ReadWritePaths 外全部只读strict
ProtectHome=隐藏 /home /rootread-onlyyesread-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/EXECExecStart 路径不存在或无可执行权限使用绝对路径;which myapp 确认;chmod +x
Type=forking 时服务启动后又立即停止PIDFile 路径错误或进程未写入 PID 文件确认 PIDFile 与进程实际写入路径一致;用 ExecStartPost 验证
Timer 到了时间但没有执行enable 了 .service 而非 .timersystemctl enable --now db-backup.timerlist-timers 确认 next 时间
journalctl -u 查不到历史日志Storage=auto/var/log/journal 目录不存在mkdir -p /var/log/journal && systemctl restart systemd-journald
systemd-analyze verifyUnknown 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 alwaysalways 在正常 stop 后也重启,违背运维预期;on-failure 只对异常退出生效Restart=on-failure\nRestartSec=5
Set LimitNOFILE/LimitNPROC for production servicessystemd 默认文件描述符限制过低(1024),高并发服务容易报 Too many open filesLimitNOFILE=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

练习题

  1. 概念题After=Requires= 有什么区别?写出一个同时使用二者的典型配置段。
  2. 概念题:systemd timer 相比 cron 的四个主要优势是什么?
  3. 实操题:为一个 Flask/Gunicorn Web 应用(启动命令 /usr/local/bin/gunicorn -w 4 app:app)编写生产级 .service 文件,要求:Type=simple、User=webapp、自动重启、限制文件描述符 65536、启用 PrivateTmp。验证后用 systemctl enable 设置开机启动。
  4. 实操题:配合上题 Web 服务,创建一个 web-healthcheck.timer,每小时执行 /usr/local/bin/healthcheck.sh,要求随机延迟 60 秒、错过时间后补执行。验证 list-timers 输出。
  5. 🔍 挑战题:你的服务器启动时间从 15s 恶化到 45s。请列出使用 systemd-analyze 诊断的完整步骤,并说明每种可能的根因和对应的修复方法。提示:考虑 NetworkManager-wait-online.service、磁盘挂载超时、废弃服务残留等。
点击查看答案
  1. After= 仅控制启动顺序,不保证依赖服务必须成功;Requires= 声明强依赖,依赖失败则本 Unit 也失败。典型组合:After=network-online.target Requires=network-online.target
  2. ① 支持随机延迟(RandomizedDelaySec)防雪崩;② 错过运行时间后补执行(Persistent=true);③ 统一日志 journald 管理;④ 支持日历表达式与单调定时器混合。
  3. 参考模板:Type=simple, User=webapp, Restart=always, LimitNOFILE=65536, PrivateTmp=yes。systemctl enable flask-app 后重启验证。
  4. OnCalendar=hourly + RandomizedDelaySec=60 + Persistent=truesystemctl list-timers 应显示下次触发时间含随机偏移。
  5. 步骤:systemd-analyze blame 看各单元耗时→systemd-analyze critical-chain 看关键路径→排查 NetworkManager-wait-online 可加 After=network.target 跳过等待。

本章总结

速查表

场景推荐方案检查命令
启动守护进程[Service] + Type=simple / forkingsystemctl status
一次性初始化Type=oneshot + RemainAfterExit=yessystemd-analyze verify
按需启动.socket + .service 配对systemctl list-sockets
定时任务.timer + OnCalendar=systemctl list-timers
日志查询journalctl -u <unit> -S "1h ago" -p errjournalctl --verify
启动耗时分析systemd-analyze blamesystemd-analyze critical-chain

学习路径建议

  1. systemctl cat 查看系统中已有的 unit 文件,逐行理解每个指令
  2. 仿照本章示例,创建一个你自己的 /etc/systemd/system/test-app.service
  3. 将系统上现有的 cron 任务逐个迁移到 systemd timer,体会二者的差异
  4. 在测试机上运行 systemd-analyze blame 并针对 top 3 做启动优化
  5. 阅读 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 图找瓶颈。
↑ 回到顶部