systemd 内部工程深度实战:从 Unit 依赖 DAG 与事务计算、Socket Activation 到 cgroup v2 委托的资源治理全链路
执行摘要
systemd 是工程界最容易被"情绪化评价"的基础设施:有人把它当成"一个启动脚本管理器",有人把它当成"违背 Unix 哲学的庞然大物"。这两种看法都错过了重点。绝大多数人在生产环境遇到的 systemd 问题——服务启动顺序莫名其妙、依赖写了却不生效、重启时卡住 90 秒、容器里的服务拿不到资源隔离、socket 激活后进程不再被拉起——根因都不在配置语法,而在 systemd 内部那套并发事务调度模型。
真正值得理解的是三件事:第一,systemd 的 Unit 依赖不是启动脚本的线性序列,而是一张有向图,systemd 在启动前会对这张图做一次事务(transaction)计算,把"要做什么"解成一组可并发执行的 job,再做一致性剪枝;第二,Socket Activation 是 systemd 打破依赖串行化的核心武器,它把"服务必须先起来"这个约束换成"监听套接字必须先存在",从而让启动阶段的依赖边数量骤降;第三,cgroup 不是 systemd 的附加功能,而是它的身份系统——systemd 用 cgroup 路径作为进程的"真实凭据",PID 会复用、会逃逸,而 cgroup 不会。
| 维度 | SysV init | systemd | 工程含义 |
|---|---|---|---|
| 依赖模型 | 线性脚本顺序 | 有向图 + 事务计算 | 依赖可并发、可剪枝、可回溯报错 |
| 并行度来源 | 脚本作者手写 & | 图拓扑自动推导 | 启动时间从 O(链长) 降到 O(关键路径) |
| 进程身份 | PID 文件 | cgroup 路径 | 彻底消除 PID 复用与进程逃逸 |
| 资源隔离 | 外部 ulimit / 手工 cgroup | 内建 slice 层级 | 资源边界随服务生命周期自动回收 |
| 激活方式 | 开机即启动 | 按需激活(socket/bus/path/timer) | 冷服务零常驻内存 |
本文沿着"图 → 事务 → 激活 → 隔离"这条链路拆开讲,并给出可以直接跑起来的代码。
一、Unit 依赖图:语义差异才是真正的坑
systemd 的依赖关键字看起来很多,但它们的区别只有两个正交维度:强度(强依赖失败即回滚 vs 弱依赖失败继续)和方向(Requires 是"我要你活",Wants 是"我希望你活")。搞错这两个维度,就是生产事故。
| 关键字 | 强度 | 触发启动 | 失败时行为 |
|---|---|---|---|
| Requires | 强 | 是 | 本单元失败,且被依赖者被停止 |
| Requisite | 强 | 否(必须已启动) | 直接失败,不尝试拉起 |
| Wants | 弱 | 是 | 被依赖者失败不影响本单元 |
| BindsTo | 强 + 双向 | 是 | 被依赖者消失,本单元立即停止 |
| PartOf | Propagation | 否 | 停止/重启会沿传播链扩散 |
| Conflicts | 互斥 | 反向 | 先停掉对方再启动自己 |
| Before / After | 纯排序 | 否 | 只约束顺序,不隐含启动 |
最容易踩的三个坑:
After=不等于Requires=。After=network.target只说"我排在你后面",完全不保证网络可用,也不保证 network 服务被拉起。想表达"网络就绪后才启动",正确写法是After=network-online.target加Wants=network-online.target,并且确保systemd-networkd-wait-online.service真正被启用——这个 target 默认不会阻塞,很多发行版上它是 no-op。network.target与network-online.target的语义完全不同。前者只表示"网络配置子系统已启动",后者才表示"至少有一个网卡拿到了可路由地址"。DB 集群、etcd、分布式存储必须等-online版本。Wants=拉不起来的依赖会被静默忽略。这在排障时极其恼人:你写了Wants=redis.service,redis 因为配置错误起不来,你的服务照样启动然后连不上。排查手段是看systemctl show里的Wants与After实际解析结果,而不是看 unit 文件写没写。
一个生产级 unit 的正确骨架长这样:
[Unit]
Description=Order API Service
Documentation=https://internal/order-api
After=network-online.target postgresql.service
Wants=network-online.target
Requires=postgresql.service
# 注意:不要写 After=network.target 就以为网络好了
[Service]
Type=notify
ExecStart=/usr/local/bin/order-api --config /etc/order-api/config.yaml
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=1s
# 关键:限制重启风暴,5 秒内最多重启 3 次
StartLimitIntervalSec=5
StartLimitBurst=3
TimeoutStopSec=20s
KillSignal=SIGTERM
# 停机 20s 后仍未退出才动用 SIGKILL
TimeoutStopSec=20s
[Install]
WantedBy=multi-user.target
二、事务计算:systemd 如何把图变成并发 job
当你敲下 systemctl start foo.service,systemd 做的不是"按依赖顺序挨个启动",而是完整跑一遍事务引擎:
- 展开(expansion):从目标 unit 出发,递归展开所有正向依赖(
Requires/Wants/Requisite),为每个需要状态变更的 unit 创建一个Job(start/stop/restart)。 - 合并(merge):同一个 unit 的重复 job 会被合并,
restart会覆盖已有的start。 - 排序(ordering):把
Before/After边加入 DAG,形成偏序。 - 剪枝与校验(verification):检测环、检测
Conflicts冲突、检测Requisite不满足。任何一项失败,整个事务回滚,一个 job 都不执行。这就是为什么你偶尔会看到Job for x.service canceled——不是启动失败,是事务在校验阶段就被否掉了。 - 执行(dispatch):DAG 上没有前驱约束的 job 全部并发进入队列,按
JobTimeoutSec和全局DefaultTimeoutStartSec(默认 90 秒,这就是"卡 90 秒"的来源)等待。
理解"事务"这个词很关键:它是原子的。所以当你写了一条不可能满足的依赖链,systemd 宁可什么都不做,也不会启动半个系统。这也是为什么排查启动问题时,第一条命令应该是:
# 直接看事务会做什么,但不真的执行(dry-run)
systemd-analyze verify ./order-api.service
# 查看完整依赖展开与排序结果
systemctl list-dependencies --all order-api.service
# 关键路径分析:为什么开机这么慢
systemd-analyze critical-chain order-api.service
# 输出启动阶段每个 unit 的耗时瀑布
systemd-analyze plot > /tmp/boot.svg
systemd-analyze verify 会报出 unit 文件里的语义错误、未知指令、拼写问题,这是 CI 里应该强制跑的一条命令。
三、Socket Activation:用"文件描述符"换掉"启动顺序"
Socket Activation 是 systemd 最被低估的特性,也是它敢宣称"并行启动"的底气。传统模型下,服务 A 依赖服务 B,是因为 A 启动时必须连上 B 的端口——所以 B 必须先完成初始化。Socket Activation 把这个约束彻底换掉:systemd 在启动最早期就自己创建并持有监听套接字,服务的二进制文件等到第一个连接进来才被 fork/exec。
结果是:所有服务的"监听端口"在启动阶段一开始就全部就绪,依赖边从"A 等 B 就绪"降级成"A 等一个 fd",图几乎完全扁平化,启动时间由关键路径决定而非链条总长。
# order-api.socket
[Socket]
ListenStream=8080
# Accept=no(默认):单实例接收全部连接,高性能场景用这个
Accept=no
# 也支持 unix socket 与 systemd 传递 fd
# ListenStream=/run/order-api/order-api.sock
[Install]
WantedBy=sockets.target
# order-api.service
[Unit]
Requires=order-api.socket
After=order-api.socket
[Service]
ExecStart=/usr/local/bin/order-api
StandardInput=socket # 非必须,但便于理解 fd 继承
服务侧需要识别 systemd 传来的 fd,而不是自己 bind():
// C:使用 libsystemd 的 sd_listen_fds 语义
#include <systemd/sd-daemon.h>
#include <netinet/in.h>
int main(void) {
int n = sd_listen_fds(0);
if (n < 0) return 1;
int listen_fd;
if (n > 1) {
return 1; // 只声明了一个 socket,不该收到多个
} else if (n == 1) {
listen_fd = SD_LISTEN_FDS_START + 0; // 固定为 fd 3
} else {
// 没有 systemd 传递:走本地调试路径自己 bind
listen_fd = bind_and_listen(8080);
}
if (sd_is_socket_inet(listen_fd, AF_INET, SOCK_STREAM, 1, 8080) <= 0) {
// 校验 fd 类型,避免被传入非预期描述符
return 1;
}
sd_notify(0, "READY=1"); // 配合 Type=notify
serve(listen_fd);
}
Python 侧更简洁,标准库就有等价实现:
import os, socket, sys
def acquire_systemd_socket():
# LISTEN_FDS: systemd 传入的描述符数量;LISTEN_PID: 校验确为传给本进程
fds = int(os.environ.get("LISTEN_FDS", "0"))
if fds and int(os.environ.get("LISTEN_PID", "0")) == os.getpid():
fd = 3 # SD_LISTEN_FDS_START
s = socket.socket(fileno=fd)
# 注意:不要 close fd,也不要再 setsockopt(SO_REUSEADDR)
return s
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(("0.0.0.0", 8080)); s.listen(128)
return s
if __name__ == "__main__":
srv = acquire_systemd_socket()
print(f"listening on fd {srv.fileno()}", flush=True)
while True:
conn, _ = srv.accept()
conn.sendall(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok")
conn.close()
关键细节:LISTEN_PID 校验必须做。不做的话,任何通过 systemd-run 或第三方 supervisor 传入泄漏 fd 的场景都会让你的服务绑定到错误的套接字上,表现为"端口明明配了 8080 却监听在 9090"。另外 Accept=yes 会为每个连接 fork 一个进程实例(inetd 语义),吞吐敏感的服务不要用。
四、cgroup v2 委托:systemd 的进程身份系统
systemd 与传统 init 最本质的区别在这里:systemd 不做 PID 追踪,它做 cgroup 追踪。MAINPID 只是缓存,真正的成员名单来自 cgroup。这带来三个直接收益——进程 fork 出的子孙无法逃逸(KillMode=control-group 默认会杀干净)、PID 复用不会导致误杀、服务退出后资源边界自动消失不留垃圾。
在容器或需要嵌套管理资源的场景,必须显式开启委托:
[Service]
# 允许服务自己在其 cgroup 下创建子组(containerd / podman / 自建 agent 都需要)
Delegate=yes
# 委托后 systemd 不再自动迁移进程,避免与控制器的 cgroup 操作打架
DelegateSubgroup=system
委托开启后,服务自身的 cgroup 变成可写,内层可以再切分 CPU/IO/内存。用 systemd-run 可以直接验证整条链路:
# 临时跑一个受 CPU 约束的任务,自动进入 system.slice 下的临时 scope
systemd-run --unit=bulk-job --scope \
-p CPUQuota=150% -p MemoryMax=2G -p IOWeight=50 \
/usr/local/bin/bulk-export --out /data/export.parquet
# 实时看资源边界是否生效
systemd-cgtop -m
# 查看某个服务实际落在哪个 cgroup 路径
systemctl show order-api.service -p ControlGroup -p MemoryCurrent -p CPUUsageNSec
CPU 配额的三个参数语义完全不同,别混用:CPUQuota=150% 是硬上限(cgroup v2 的 cpu.max,超出即限流),CPUWeight=100(默认)是竞争时的相对份额(只在 CPU 打满时才起作用),CPUQuotaPeriodSec 控制配额窗口长度(默认 100ms,调小可降低延迟抖动但增加调度开销)。绝大多数"我设了 CPU 限制没生效"的问题,都是因为用了 Weight 却期望得到硬限。
五、生产落地清单
按排查收益排序,这是我 review systemd 配置时固定会过的几条:
- 所有长期服务用
Type=notify+sd_notify,而不是Type=simple加固定 sleep。simple下 systemd 认为进程 fork 成功即启动完成,实际监听还没就绪,上游一打进来就 connection refused。 - 必须配
Restart=on-failure且必须配StartLimitBurst/StartLimitIntervalSec。只写 Restart 不写限流,崩溃循环会把 journald 写满、把 CPU 打满。 - 需要网络就绪的服务一律
After=network-online.target+Wants=network-online.target,并确认 wait-online 服务真的启用。 - 日志一律走 journald,不要重定向到文件——文件日志绕过了
StandardOutput=journal带来的结构化字段与自动轮转,排查时丢掉_PID、_COMM、_SYSTEMD_UNIT这些关键维度。 - 容器内跑 systemd 必须
Delegate=yes,否则内层 cgroup 只读,Kubelet 的资源限制与 systemd 互相覆盖。 - CI 里强制跑
systemd-analyze verify,把 unit 文件语法错误拦在上线前。 - 给关键服务设
TimeoutStopSec并与业务优雅退出超时对齐,二者不一致会导致数据面已被 KILL 而控制面还在等。 - 冷服务(很少被调用的管理端口、调试端点)改用 socket 或 path 激活,别让它们常驻吃内存。
结论
systemd 的复杂度不在它的命令行,而在它把"启动"这件事从脚本编排重新定义成了带事务语义的图调度 + 基于 cgroup 的身份治理。一旦接受这个模型,很多看似玄学的行为都有唯一解释:启动卡 90 秒是 job 超时、依赖写了不生效是把排序当成了强依赖、服务重启后进程残留是 KillMode 与委托没配对、容器里资源限制失效是 cgroup 没被委托下来。
值得记住的是三句话:After= 只排序不启动,Wants= 失败不回滚,cgroup 才是 systemd 眼里的进程身份。 把这三条刻进 unit 模板,生产环境里由 systemd 引发的事故会少掉一大半。

发表评论 取消回复