systemd 服务管理深度指南:从单元文件到故障排查
写在前面
干了八年运维,我对 systemd 的态度经历过三个阶段:刚接触时觉得"这玩意比 init.d 复杂多了",用了两年觉得"还行,比写 shell 启动脚本靠谱",到现在"离了 systemd 反而不习惯"。
这篇文章不讲 systemctl start/stop/restart 这种入门操作——那个你随便搜一篇教程都会。我要讲的是真正在生产环境中踩过的坑:单元文件里那些容易忽略的参数、依赖关系导致的启动顺序问题、cgroups 资源限制没配好引发的故事、还有凌晨三点被叫起来排查服务起不来的完整思路。
一、单元文件:比你以为的复杂
1.1 文件加载优先级
很多人不知道,systemd 的单元文件有加载优先级。同一个服务名,放在不同目录会有覆盖关系:
/etc/systemd/system/ ← 最高优先级(管理员自定义)
/run/systemd/system/ ← 运行时临时配置
/usr/lib/systemd/system/ ← 软件包安装的默认配置
实际坑:有一次改了 /usr/lib/systemd/system/nginx.service,重启后发现配置没生效。原因是 /etc/systemd/system/ 下面还有一个同名的 override 文件,优先级更高。正确做法是用 systemctl edit nginx,它会在 /etc/systemd/system/nginx.service.d/ 下创建 override.conf。
1.2 [Unit] 段:依赖关系是核心
[Unit]
Description=My Application Server
Documentation=https://docs.example.com
After=network-online.target postgresql.service
Wants=network-online.target
Requires=postgresql.service
Requisite=postgresql.service
# 新增:确保目录存在
ConditionPathExists=/data/app/config.yaml
这几个依赖指令的区别,很多人搞混:
| 指令 | 含义 | 启动顺序 | 失败行为 |
|---|---|---|---|
Wants= |
弱依赖,希望也启动 | 不等待对方完成 | 对方失败不影响本服务 |
Requires= |
强依赖,必须一起启动 | 不等待对方完成 | 对方失败则本服务也失败 |
Requisite= |
强依赖,必须已启动 | 不启动对方 | 对方未运行则本服务直接失败 |
BindsTo= |
绑定依赖 | 不等待对方完成 | 对方停止则本服务也停止 |
PartOf= |
组成员关系 | 不影响启动 | 对方重启则本服务也重启 |
关键区别:After= 和 Before= 只控制启动顺序,不建立依赖关系。也就是说,After=network.target 只是说"如果 network.target 要启动,那在我之前启动",但并不会触发 network.target 的启动。你必须同时写 Wants=network.target 才能确保它被启动。
真实案例:一个 Java 服务配了 After=network-online.target 但没写 Wants=,结果在网络还没就绪时就尝试连接数据库,直接启动失败。加上 Wants=network-online.target 后解决。
1.3 [Service] 段:执行配置
[Service]
Type=notify
ExecStart=/opt/app/bin/server --config=/etc/app/config.yaml
ExecStartPre=/opt/app/bin/migrate.sh
ExecStartPost=/usr/bin/curl -sf http://localhost:8080/health
ExecStop=/usr/bin/curl -X POST http://localhost:8080/shutdown
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=5s
StartLimitIntervalSec=60
StartLimitBurst=3
TimeoutStartSec=120
TimeoutStopSec=30
KillMode=mixed
KillSignal=SIGTERM
FinalKillSignal=SIGKILL
# 安全沙箱
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/app /var/log/app
Type 字段详解
这是最容易出错的地方:
simple(默认):ExecStart启动的进程就是主进程,systemd 认为它立即就绪。适合前台运行的简单服务。forking:进程会 fork 子进程然后父进程退出,子进程才是真正的服务。传统 daemon 用这个。需要配合PIDFile=。notify:服务会通过sd_notify()通知 systemd 自己已就绪。这是最可靠的类型,但需要程序支持。oneshot:执行一次性任务,执行完就结束。适合初始化脚本。exec****(systemd 236+):类似simple,但 systemd 会等到execve()成功后才认为启动完成,能捕获"二进制文件不存在"这类错误。
# 传统 daemon 的写法
Type=forking
PIDFile=/run/myapp.pid
ExecStart=/opt/myapp/bin/start.sh
# 现代应用(Go/Java/Python)推荐
Type=notify
# 程序中调用 sd_notify(0, "READY=1")
坑:如果你的服务是 Type=simple 但启动需要 30 秒初始化,systemd 会在 1 秒内就认为服务"已启动"。如果有其他服务依赖它,可能在它还没就绪时就被调用。解决方案:用 Type=notify 或加 ExecStartPost 做健康检查。
Restart 策略
Restart=on-failure # 非正常退出时重启
RestartSec=5s # 重启间隔
StartLimitIntervalSec=60 # 60秒窗口内
StartLimitBurst=3 # 最多重启3次,超过后不再重启
Restart 的几个值:
no:不重启(默认)on-success:正常退出时重启(少见)on-failure:非零退出码、信号终止、超时时重启on-abnormal:信号终止或超时时重启(不包括非零退出码)always:总是重启
生产建议:大部分服务用 on-failure + StartLimitBurst=3。用 always 要小心——如果服务有 bug 导致一启动就崩溃,always 会让 systemd 不停重启,吃满 CPU。
KillMode 和优雅停止
KillMode=mixed
KillSignal=SIGTERM
TimeoutStopSec=30
FinalKillSignal=SIGKILL
KillMode 的值:
control-group(默认):杀掉整个 cgroup 下的所有进程process:只杀主进程mixed:先发 SIGTERM 给主进程,超时后 SIGKILL 整个 cgroup ← 推荐none:不杀任何进程
真实案例:一个 Java 服务用默认的 KillMode=control-group,ExecStop 发了 SIGTERM,但 systemd 同时也向所有子线程发了 SIGKILL,导致 JVM 没机会执行 shutdown hook,数据没落盘。改成 KillMode=mixed 后解决。
1.4 [Install] 段:开机自启
[Install]
WantedBy=multi-user.target
Alias=myapp.service
WantedBy=multi-user.target 表示启用该服务时,会在 multi-user.target.wants/ 下创建符号链接。这就是 systemctl enable 的原理。
二、Target:理解运行级别
传统 SysVinit 的运行级别(runlevel 0-6)在 systemd 中被 target 取代:
| 传统 runlevel | systemd target | 说明 |
|---|---|---|
| 0 | poweroff.target |
关机 |
| 1 | rescue.target |
单用户/救援模式 |
| 3 | multi-user.target |
多用户命令行 |
| 5 | graphical.target |
图形界面 |
| 6 | reboot.target |
重启 |
# 查看默认启动目标
systemctl get-default
# 修改默认启动目标
systemctl set-default multi-user.target
# 切换到救援模式(不会断开 SSH)
systemctl rescue
# 切换到紧急模式(只挂载根文件系统只读)
systemctl emergency
依赖链分析
# 查看 multi-user.target 依赖了哪些服务
systemctl list-dependencies multi-user.target
# 反向查看:哪些服务依赖了网络
systemctl list-dependencies --reverse network.target
排查技巧:当服务启动顺序有问题时,用 systemd-analyze critical-chain 看启动链路:
systemd-analyze critical-chain postgresql.service
输出会显示每个依赖的启动耗时,帮你找到启动慢的瓶颈。
三、cgroups 资源限制
这是 systemd 最被低估的能力。通过单元文件就能做容器级别的资源隔离,不需要 Docker。
3.1 CPU 限制
[Service]
# 方式一:CPU份额(相对权重)
CPUShares=1024
# 方式二:CPU配额(绝对限制)
CPUQuota=200% # 最多用2个核
CPUQuota=50% # 最多用半个核
# 方式三:CPU亲和性(绑定到特定核心)
CPUAffinity=0,1 # 只用第0和第1号CPU
实际用法:一台 8 核服务器跑了 3 个 Java 服务,不限制的话它们会互相抢 CPU。给每个服务配 CPUQuota=200%,确保各自最多用 2 个核,留 2 个核给系统。
3.2 内存限制
[Service]
MemoryLimit=4G # 最大内存4G
MemoryHigh=3G # 软限制,超过后开始限速
MemorySwapMax=2G # 最大swap用量
注意:MemoryLimit 在 cgroups v2 中已被 MemoryMax 替代。较新的 systemd 版本两者都支持。
真实案例:一个 Python 服务有内存泄漏,不限制的话会把整个系统 OOM。加了 MemoryLimit=2G + Restart=on-failure,泄漏到 2G 就被杀掉重启,虽然治标不治本,但至少不影响其他服务。
3.3 IO 限制
[Service]
IOWeight=100 # IO权重(1-10000,默认100)
IOReadBandwidthMax=/dev/sda 50M # 读限制
IOWriteBandwidthMax=/dev/sda 50M # 写限制
3.4 查看资源使用
# 实时查看所有服务的资源使用
systemd-cgtop
# 查看特定服务的 cgroup 信息
systemctl status myapp.service
# 查看进程的 cgroup
cat /proc/$PID/cgroup
四、日志集成:journalctl 进阶
systemd 自带 journald 日志系统,配合 journalctl 非常强大。
# 查看某服务的日志
journalctl -u nginx.service
# 实时跟踪日志
journalctl -u nginx.service -f
# 按时间筛选
journalctl --since "2026-07-06 08:00" --until "2026-07-06 12:00"
journalctl --since "1 hour ago"
# 按优先级筛选(0-7,0最严重)
journalctl -p err # 只看error及以上
journalctl -p warning # warning及以上
# 按进程PID
journalctl _PID=12345
# 查看本次启动的日志
journalctl -b
# 查看上一次启动的日志(崩溃排查神器)
journalctl -b -1
# 按可执行文件筛选
journalctl /usr/bin/nginx
# JSON格式输出(适合脚本处理)
journalctl -u myapp -o json
# 查看日志占用的磁盘空间
journalctl --disk-usage
# 清理超过7天的日志
journalctl --vacuum-time=7d
# 限制日志最大占用500M
journalctl --vacuum-size=500M
journald 持久化配置
默认情况下 journald 日志存在 /run/log/journal/,重启后丢失。开启持久化:
# /etc/systemd/journald.conf
[Journal]
Storage=persistent # 持久化存储
Compress=yes # 压缩旧日志
SystemMaxUse=2G # 最大占用2G
MaxRetentionSec=30day # 保留30天
mkdir -p /var/log/journal
systemctl restart systemd-journald
五、生产环境故障排查实战
5.1 服务启动失败
排查步骤:
# 第一步:看状态
systemctl status myapp.service
# 第二步:看详细日志
journalctl -u myapp.service --since "5 min ago" --no-pager
# 第三步:看启动分析
systemd-analyze verify myapp.service
# 第四步:手动执行 ExecStart 看报错
# 复制 ExecStart 的命令手动执行
常见原因:
- 单元文件语法错误:
systemd-analyze verify能检查出来 - ExecStart 路径不对:systemd 要求所有路径必须是绝对路径
- 权限问题:ExecStart 的二进制文件没有执行权限
- 环境变量缺失:systemd 不继承用户 shell 的环境变量
5.2 环境变量问题
这是最高频的坑。systemd 启动的服务不会加载 ~/.bashrc 或 ~/.bash_profile,也不会读取 /etc/profile。
[Service]
# 方式一:直接在单元文件中定义
Environment="JAVA_HOME=/usr/lib/jvm/java-17"
Environment="PATH=/usr/local/bin:/usr/bin:/bin"
# 方式二:引用环境变量文件
EnvironmentFile=/etc/sysconfig/myapp
# 方式三:通过 shell 启动(不推荐,但有时必须)
ExecStart=/bin/bash -c 'source /etc/profile.d/java.sh && exec /opt/app/bin/start.sh'
排查方法:
# 查看服务的实际环境变量
systemctl show myapp.service -p Environment
# 查看进程的环境变量
cat /proc/$(pidof myapp)/environ | tr '\0' '\n'
5.3 服务被反复杀死
# 查看重启计数
systemctl show myapp.service -p NRestarts
# 查看启动限制是否触发
systemctl status myapp.service
# 如果看到 "start request repeated too quickly" 就是触发了 StartLimitBurst
解决方案:
# 重置失败计数器
systemctl reset-failed myapp.service
# 调整启动限制
# 在单元文件中修改
StartLimitIntervalSec=300 # 5分钟窗口
StartLimitBurst=10 # 允许10次
5.4 模板服务(实例化)
当一个服务需要启动多个实例(比如多个 Redis 实例),用模板单元:
# /etc/systemd/system/redis@.service
[Service]
ExecStart=/usr/bin/redis-server /etc/redis/%i.conf
PIDFile=/var/run/redis/redis-%i.pid
# 启动 redis@6379 和 redis@6380
systemctl start redis@6379
systemctl start redis@6380
systemctl enable redis@6379 redis@6380
%i 会被替换为 @ 后面的实例名。其他常用占位符:
| 占位符 | 含义 |
|---|---|
%i |
实例名(@后面的部分) |
%n |
完整单元名 |
%p |
前缀名(@前面的部分) |
%H |
主机名 |
%T |
/tmp |
%v |
/var |
5.5 定时器替代 cron
systemd timer 比 cron 更强大,支持依赖关系和日志集成:
# /etc/systemd/system/backup.service
[Service]
Type=oneshot
ExecStart=/opt/scripts/backup.sh
# /etc/systemd/system/backup.timer
[Unit]
Description=Daily backup
[Timer]
OnCalendar=*-*-* 02:00:00 # 每天凌晨2点
Persistent=true # 错过了(比如关机)开机后补执行
RandomizedDelaySec=300 # 随机延迟0-300秒,避免任务同时执行
[Install]
WantedBy=timers.target
systemctl enable --now backup.timer
systemctl list-timers # 查看所有定时器
systemctl list-timers --all # 包括未启用的
比 cron 的优势:
- 日志自动记录到 journald
- 支持
OnBootSec=(开机后多久执行) - 支持
Persistent=true(错过后补执行) - 支持依赖关系(等数据库启动后再备份)
systemctl list-timers一目了然
六、安全加固清单
生产环境的服务单元文件,建议加上这些安全选项:
[Service]
# 不允许获取新权限
NoNewPrivileges=true
# 私有 /tmp
PrivateTmp=true
# 私有网络(需要时才开)
# PrivateNetwork=true
# 保护 /home 目录
ProtectHome=true
# 保护 /usr, /boot 只读
ProtectSystem=strict
# 只允许写入指定目录
ReadWritePaths=/var/lib/myapp /var/log/myapp
# 禁止访问设备文件
PrivateDevices=true
# 禁止加载内核模块
ProtectKernelModules=true
# 禁止修改内核变量
ProtectKernelTunables=true
# 禁止修改控制组
ProtectControlGroups=true
# 限制系统调用
SystemCallFilter=@system-service
SystemCallFilter=~@privileged @resources
# 限制能力
CapabilityBoundingSet=
AmbientCapabilities=
# 限制地址空间布局随机化
LockPersonality=true
# 限制实时优先级
RestrictRealtime=true
# 限制命名空间创建
RestrictNamespaces=true
# 运行用户
User=myapp
Group=myapp
验证安全配置:
# 查看服务的安全等级
systemd-analyze security myapp.service
# 输出示例:
# → Overall exposure level for myapp.service: 3.4 SAFE 😀
七、实用技巧合集
7.1 临时修改单元文件
# 编辑完整文件
systemctl edit --full myapp.service
# 只添加 override(推荐)
systemctl edit myapp.service
# 会打开 /etc/systemd/system/myapp.service.d/override.conf
# 修改后必须 reload
systemctl daemon-reload
systemctl restart myapp.service
7.2 查看启动耗时
# 总启动时间
systemd-analyze
# 各服务启动耗时
systemd-analyze blame | head -20
# 关键路径分析
systemd-analyze critical-chain
7.3 调试模式
# 开启 shell 调试日志
systemctl edit myapp.service
# 添加:
# [Service]
# Environment=SYSTEMD_LOG_LEVEL=debug
# 或用 strace 追踪
strace -f -p $(pidof myapp) -e trace=network
# 启动到救援模式排查
systemctl rescue
7.4 查看服务依赖树
# 正向依赖
systemctl list-dependencies myapp.service
# 反向依赖(谁依赖了我)
systemctl list-dependencies --reverse myapp.service
# 检查循环依赖
systemd-analyze verify myapp.service
写在最后
systemd 不是一个简单的服务管理器,它是现代 Linux 的基础设施层。理解单元文件语法、依赖机制、cgroups 资源限制,以及 journalctl 日志系统,是运维工程师从"会用"到"精通"的必经之路。
文中提到的所有配置和排查方法都来自真实生产环境的实战经验。如果你正在维护 Linux 服务,建议把安全加固清单那一节的配置项逐条检查一遍你的服务单元文件——大概率会发现好几个没配的地方。
systemd 的文档虽然厚,但 man systemd.unit、man systemd.service、man systemd.exec 这三个 man page 基本覆盖了 90% 的需求。遇到不确定的参数,查 man page 比搜索引擎靠谱得多。

