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 initsystemd工程含义
依赖模型线性脚本顺序有向图 + 事务计算依赖可并发、可剪枝、可回溯报错
并行度来源脚本作者手写 &图拓扑自动推导启动时间从 O(链长) 降到 O(关键路径)
进程身份PID 文件cgroup 路径彻底消除 PID 复用与进程逃逸
资源隔离外部 ulimit / 手工 cgroup内建 slice 层级资源边界随服务生命周期自动回收
激活方式开机即启动按需激活(socket/bus/path/timer)冷服务零常驻内存

本文沿着"图 → 事务 → 激活 → 隔离"这条链路拆开讲,并给出可以直接跑起来的代码。

一、Unit 依赖图:语义差异才是真正的坑

systemd 的依赖关键字看起来很多,但它们的区别只有两个正交维度:强度(强依赖失败即回滚 vs 弱依赖失败继续)和方向(Requires 是"我要你活",Wants 是"我希望你活")。搞错这两个维度,就是生产事故。

关键字强度触发启动失败时行为
Requires强是本单元失败,且被依赖者被停止
Requisite强否(必须已启动)直接失败,不尝试拉起
Wants弱是被依赖者失败不影响本单元
BindsTo强 + 双向是被依赖者消失,本单元立即停止
PartOfPropagation否停止/重启会沿传播链扩散
Conflicts互斥反向先停掉对方再启动自己
Before / After纯排序否只约束顺序,不隐含启动

最容易踩的三个坑:

  1. After= 不等于 Requires=。After=network.target 只说"我排在你后面",完全不保证网络可用,也不保证 network 服务被拉起。想表达"网络就绪后才启动",正确写法是 After=network-online.target 加 Wants=network-online.target,并且确保 systemd-networkd-wait-online.service 真正被启用——这个 target 默认不会阻塞,很多发行版上它是 no-op。
  2. network.target 与 network-online.target 的语义完全不同。前者只表示"网络配置子系统已启动",后者才表示"至少有一个网卡拿到了可路由地址"。DB 集群、etcd、分布式存储必须等 -online 版本。
  3. 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 配置时固定会过的几条:

  1. 所有长期服务用 Type=notify + sd_notify,而不是 Type=simple 加固定 sleep。simple 下 systemd 认为进程 fork 成功即启动完成,实际监听还没就绪,上游一打进来就 connection refused。
  2. 必须配 Restart=on-failure 且必须配 StartLimitBurst / StartLimitIntervalSec。只写 Restart 不写限流,崩溃循环会把 journald 写满、把 CPU 打满。
  3. 需要网络就绪的服务一律 After=network-online.target + Wants=network-online.target,并确认 wait-online 服务真的启用。
  4. 日志一律走 journald,不要重定向到文件——文件日志绕过了 StandardOutput=journal 带来的结构化字段与自动轮转,排查时丢掉 _PID、_COMM、_SYSTEMD_UNIT 这些关键维度。
  5. 容器内跑 systemd 必须 Delegate=yes,否则内层 cgroup 只读,Kubelet 的资源限制与 systemd 互相覆盖。
  6. CI 里强制跑 systemd-analyze verify,把 unit 文件语法错误拦在上线前。
  7. 给关键服务设 TimeoutStopSec 并与业务优雅退出超时对齐,二者不一致会导致数据面已被 KILL 而控制面还在等。
  8. 冷服务(很少被调用的管理端口、调试端点)改用 socket 或 path 激活,别让它们常驻吃内存。

结论

systemd 的复杂度不在它的命令行,而在它把"启动"这件事从脚本编排重新定义成了带事务语义的图调度 + 基于 cgroup 的身份治理。一旦接受这个模型,很多看似玄学的行为都有唯一解释:启动卡 90 秒是 job 超时、依赖写了不生效是把排序当成了强依赖、服务重启后进程残留是 KillMode 与委托没配对、容器里资源限制失效是 cgroup 没被委托下来。

值得记住的是三句话:After= 只排序不启动,Wants= 失败不回滚,cgroup 才是 systemd 眼里的进程身份。 把这三条刻进 unit 模板,生产环境里由 systemd 引发的事故会少掉一大半。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部