目录

Linux-37 init 之争:systemd 为什么赢也被骂

目录

上一篇讲 namespace 改变进程视图、cgroup 约束资源。这一篇回到系统启动的第一个用户态进程:当一台机器有数百个长期服务、设备可能热插拔、网络异步就绪、服务会崩溃重启时,谁来决定先启动什么、失败后怎么办、关闭时怎样收尾?

早期 Unix 的 init 只需启动少量 daemon;SysV init 用运行级别和 shell 脚本把流程标准化。现代 Linux 则面对并行启动、动态硬件、按需激活、服务监督、资源控制、日志关联和桌面会话。systemd 把这些问题放进同一个管理器,因此赢得主流发行版,也因为职责扩张、Linux 绑定、配置语义和集中复杂度长期被批评。

先看五个问题:

  1. PID 1 为什么不能只是普通 shell?它在信号、孤儿进程和系统关机中承担什么特殊责任?
  2. SysV init 的 /etc/init.d 脚本为什么难以可靠表达并行、依赖、状态与崩溃恢复?
  3. systemd 的 unit、job 与 transaction 如何区分“需要谁”和“谁先启动”?为什么 After= 不会自动拉起依赖?
  4. socket activation、Type=notify、cgroup supervision 与 journal 分别解决哪一种 race?
  5. Go 服务应怎样写 unit 才能正确处理 readiness、restart、fd/内存限制、SIGTERM 与 graceful shutdown,而不是制造重启风暴?

1. PID 1 为什么特殊

1.1 内核只启动第一个用户态管理者

内核完成初始化、挂载早期根文件系统后,会尝试执行 init 程序。它成为初始 PID namespace 的 PID 1,随后负责把系统从“只有内核”带到可登录、可提供服务的状态。

firmware
  -> bootloader
  -> Linux kernel
  -> initramfs early userspace
  -> switch/pivot to real root
  -> exec /sbin/init (often systemd)
  -> services, login sessions, workloads

initramfs 中也可能运行一个临时 PID 1,完成磁盘解密、LVM/RAID、根文件系统发现后再 switch_root 到真实系统。

1.2 PID 1 要回收孤儿

父进程退出后,孤儿后代会被 reparent 给 subreaper 或 PID namespace init。子进程退出会留下 zombie,直到父/收养者调用 wait* 获取退出状态。

parent exits
  -> child later exits
  -> PID 1/subreaper must wait
  -> otherwise zombie table entry remains

系统 PID 1 必须可靠 reap;容器里的 PID 1 同样如此。这也是容器直接用不处理子进程的 shell/应用作为 init 时可能积累 zombie 的原因。

1.3 PID 1 的信号语义不同

内核对 PID 1 的某些默认信号 disposition 有特殊保护,避免系统 init 因普通未处理信号意外退出。PID 1 应显式安装 handler 并协调 shutdown;若它崩溃,内核可能 panic 或系统进入不可用状态,具体行为取决于环境和内核。

容器 PID 1 也需要显式处理 SIGTERM。shell-form entrypoint 常让 shell 占 PID 1:

# shell form: /bin/sh -c 包一层
ENTRYPOINT ./server

# exec form: server 直接成为 PID 1
ENTRYPOINT ["/app/server"]

exec form能让停止信号直接到应用;若应用会派生子进程,还可使用轻量 init做转发和回收。

1.4 init 不只是“启动脚本”

系统管理者还要处理:

  • 服务启动、停止、重载和监督
  • 依赖、冲突、目标状态
  • 用户会话、seat 与登录
  • mount、swap、timer、socket、device 等对象
  • cgroup 生命周期和资源设置
  • 系统关机、重启、切换根文件系统
  • 记录状态与诊断信息

争论的核心正是:这些职责应由一个集成系统统一建模,还是由许多可替换小工具组合。


2. 从 rc 脚本到 SysV init

2.1 最简单的启动流程是顺序执行

早期 BSD 风格 /etc/rc 可以是一段固定 shell:

mount_filesystems
configure_network
start_syslog
start_cron
start_sshd

优点是直观、可读、使用已有 Unix 工具;机器和服务少时足够。缺点是状态、依赖和错误都隐含在脚本控制流中,发行版与管理员很难组合修改。

2.2 SysV runlevel 把脚本模块化

SysV init 使用运行级别表达系统模式,例如单用户、多用户、关机/重启。目录中用 S/K 和序号链接控制 start/kill 顺序:

/etc/rc3.d/
  S10network -> ../init.d/network
  S20syslog  -> ../init.d/syslog
  S50sshd    -> ../init.d/sshd

服务脚本约定:

/etc/init.d/sshd start
/etc/init.d/sshd stop
/etc/init.d/sshd restart
/etc/init.d/sshd status

它让包管理器可以安装独立脚本,也给管理员清晰的人工操作入口。

2.3 顺序编号不是依赖图

S50sshd 只告诉你排序编号,不能精确表达:

sshd needs local filesystems
sshd wants logging but logging failure need not block it
sshd should start after network interface configuration
sshd does not require Internet connectivity
sshd conflicts with rescue mode

发行版后来通过 LSB header、辅助工具推导依赖,但 shell 脚本仍可在内部 fork、sleep、轮询文件或吞掉错误,管理器很难知道它何时真正 ready。

2.4 shell 脚本状态不可验证

典型脚本把 PID 写入 /run/foo.pid

start-stop-daemon --start --background --make-pidfile \
  --pidfile /run/foo.pid --exec /usr/sbin/foo

可能的问题:

  • daemon fork 两次,脚本记录错 PID
  • 进程崩溃留下 stale pidfile
  • PID 被复用,stop 杀错进程
  • 子进程逃出管理范围
  • start 返回时 socket 尚未监听
  • shell 返回 0,但实际 daemon 几秒后失败

脚本可以逐个修补,却缺乏统一、内核可验证的服务身份。

2.5 并行启动很难靠脚本安全推导

顺序启动浪费现代机器 IO/CPU;直接把所有脚本放后台又会打破隐含依赖。若服务只因“网络还没 ready”失败,就在脚本里 sleep 5,启动时间和竞态都会变得不可预测。

这推动了 Upstart 的事件模型、runit/s6 的监督模型,以及 systemd 的声明式 unit/依赖/activation 设计。


3. systemd 的核心:把系统对象建模为 unit

3.1 unit 不只有 service

systemd 用不同 unit type表示可管理对象:

类型 示例 含义
.service nginx.service 进程服务
.socket api.socket 监听 socket 与按需激活
.target multi-user.target 同步点/逻辑目标集合
.mount var-lib.mount 文件系统挂载
.automount data.automount 按访问触发挂载
.timer backup.timer 单次/周期激活
.path import.path 路径变化激活
.device dev-sda.device udev 暴露的设备
.slice app.slice cgroup 层级分组
.scope session scope systemd 未 fork 但纳入管理的进程组

统一模型让 mount、socket 和 service 可参与同一依赖 transaction,而不是各用一套启动脚本。

3.2 unit 文件是声明,不是完整程序

[Unit]
Description=Example API
After=network.target

[Service]
Type=simple
ExecStart=/usr/local/bin/api --config /etc/api/config.yaml
Restart=on-failure

[Install]
WantedBy=multi-user.target

systemd 解析声明,构建 job graph,执行 execve 并跟踪 cgroup。需要条件、环境和沙箱时使用专门 directive,而不是在 shell 中任意编程。

ExecStart= 默认不经 shell:重定向、管道、$VAR 的 shell 语法不会自动生效。若确实需要:

ExecStart=/bin/sh -c 'exec /usr/local/bin/api >>/var/log/api.log 2>&1'

但优先让应用直接输出 stdout/stderr,由 journal/日志系统管理,减少 shell、pid 和 quoting 问题。

3.3 system 与 user manager

系统 PID 1 管 system unit;登录用户还可运行 systemd --user 管 user unit。二者的 unit 搜索路径、权限、环境、cgroup 与生命周期不同:

systemctl status api.service
systemctl --user status editor-agent.service

在错误 manager 中查询会得到“unit not found”或看似未运行。user service 是否在退出登录后继续由 linger 等设置决定。


4. 依赖与顺序:最容易配错的两个维度

4.1 Requires/Wants 是拉入关系

[Unit]
Requires=postgresql.service
Wants=metrics-agent.service

概念上:

  • Requires=:启动本 unit 的 transaction 时强依赖另一个 unit;依赖启动失败会影响当前 job
  • Wants=:弱依赖,尽量一起启动,但对方失败通常不阻止当前 unit

它们主要表达“把谁加入 transaction”。停止传播和运行中故障行为还受 PartOf=BindsTo=、依赖类型与具体状态影响;不要把 Requires 简化成“对方任何时候崩溃我必然退出”。

4.2 Before/After 只表达排序

After=postgresql.service

只在两个 unit 都进入 transaction 时,要求当前 start job排在 PostgreSQL start 后。它不会自动启动 PostgreSQL。完整意图常需组合:

Requires=postgresql.service
After=postgresql.service

同理,network.target 的 After 不代表网络可访问,只表示网络管理栈的一个同步点。

4.3 停止顺序通常反向

若 A After=B,启动时 B 在 A 前,停止时通常 A 在 B 前,避免依赖先消失。这让一个 ordering edge 同时表达对称生命周期。

依赖图必须无排序环。若 A After B、B After A,systemd 会检测 cycle并丢弃某些 job/依赖或使 transaction失败,日志会给出线索。

4.4 network-online.target 也不是互联网承诺

Wants=network-online.target
After=network-online.target

它依赖网络管理器的 wait-online 服务定义“online”,可能只要求某接口配置完成,不保证 DNS、默认路由、VPN、云元数据或特定数据库可达。把所有服务都绑到它会拖慢启动,也不能消灭网络瞬时故障。

网络客户端必须支持运行期断线重连、deadline 与 backoff;启动排序只能减少已知竞态,不能把分布式依赖变成本地静态依赖。

4.5 Condition 与 Assert

systemd 可在启动前检查路径、虚拟化、架构、能力等:

ConditionPathExists=/etc/api/config.yaml
AssertPathIsReadable=/etc/api/secret

Condition 不满足通常跳过 unit而非失败;Assert 不满足会失败。它们适合环境门槛,不应代替应用对动态依赖的错误处理。


5. enable、start 与 preset 不是同一动作

5.1 start 改变当前运行状态

systemctl start api.service
systemctl stop api.service
systemctl restart api.service
systemctl reload api.service

start 提交当前 job;重启后是否自动启动取决于 enable/依赖/activation。

5.2 enable 创建启动关系

unit [Install]

[Install]
WantedBy=multi-user.target

执行:

systemctl enable api.service

通常创建符号链接,把 service 加入 target wants。它不一定立即启动;可用:

systemctl enable --now api.service

没有 [Install] 的 static unit 不能直接 enable,但可被其他 unit依赖或激活,这不表示它“不可用”。

5.3 mask 比 disable 更强

systemctl disable api.service
systemctl mask api.service
  • disable:移除安装关系,仍可手动或被依赖启动
  • mask:把 unit 链接到 /dev/null,阻止启动

排障看到 masked 要查为什么被显式禁止;不要反复 start。

5.4 daemon-reload 重新读取定义

修改 unit 文件后:

systemctl daemon-reload
systemctl restart api.service

daemon-reload 让 manager重新解析 unit/依赖,不会自动重启服务。reload api 则是请求应用重新加载配置,只有定义了 ExecReload 或相应类型才有意义。两种 reload 常被混淆。


6. Service Type:何时算“启动完成”

6.1 Type=simple

默认常见行为:systemd fork/exec ExecStart 后,进程一开始执行就认为 start job完成,不等待应用监听端口。

[Service]
Type=simple
ExecStart=/usr/local/bin/api

适合前台运行、不 fork 的 Go 服务。缺点是依赖它的 unit可能在应用初始化完成前启动;如果需要严格 readiness,应使用 notify 或 socket activation,而不是 sleep。

6.2 Type=exec

Type=exec 等到 execve 成功才认为已启动,比 simple 能捕获可执行文件不存在、用户切换失败等 exec 前错误;仍不代表应用完成业务初始化。支持情况取决于 systemd 版本。

6.3 Type=forking 与 pidfile 历史

传统 daemon 自己 fork 到后台,父进程退出表示初始化完成:

[Service]
Type=forking
PIDFile=/run/legacy.pid
ExecStart=/usr/sbin/legacy-daemon

systemd 需要识别 main PID,pidfile错误和 double-fork 会使监督困难。现代服务在 systemd 下应尽量 --foreground,使用 Type=simple/exec/notify;daemonize 已没有“释放终端”的必要。

6.4 Type=oneshot

适合执行一次并退出的设置动作:

[Service]
Type=oneshot
ExecStart=/usr/local/libexec/prepare-data
RemainAfterExit=yes

RemainAfterExit=yes 让命令退出后 unit 保持 active,表示动作产生的状态仍有效。若没有它,oneshot 成功后进入 inactive/dead,不代表失败。

6.5 Type=notify

服务完成真实初始化后通过 sd_notify 发:

READY=1
STATUS=Serving requests
MAINPID=...

systemd 在 READY 前保持 activating,依赖排序能等到真实 ready。还可配 watchdog,让服务周期发送 WATCHDOG=1

Go 可使用兼容 sd_notify protocol 的库,或向 $NOTIFY_SOCKET 发送 Unix datagram。必须正确处理 abstract socket、权限和 unset 环境,优先使用成熟小库而不是手写不完整协议。

6.6 readiness 不是健康永恒保证

READY=1 只是某时刻完成启动。之后数据库断线、磁盘只读或内部 deadlock,unit 仍可能 active。应用需要运行期 metrics、health、watchdog 或外部探针;systemd active 只说明其进程状态符合 unit 模型。


7. Socket activation:先拥有入口,再启动服务

7.1 解决 bind race

传统顺序:

start service
  -> initialize
  -> bind/listen

初始化期间客户端 connection refused

socket activation:

systemd 先 bind/listen socket
  -> client connect 进入内核 accept queue
  -> 激活 service
  -> service 继承已监听 fd
  -> accept 已排队连接

入口可在服务进程启动前存在,也能实现服务重启期间较短窗口的连接排队。

7.2 socket unit 与 service unit

# api.socket
[Socket]
ListenStream=8080
NoDelay=true

[Install]
WantedBy=sockets.target
# api.service
[Service]
ExecStart=/usr/local/bin/api

systemd 按约定把监听 fd 从 3 开始传给 service,并设置 LISTEN_PIDLISTEN_FDS 等环境。应用必须检测并使用继承 fd,不能再次 bind 同一端口。

Go 可把继承 fd 包装成 net.Listener,但要正确验证 PID、fd 数、CLOEXEC 和所有权。os.NewFile/net.FileListener 可能 dup fd,关闭顺序需按库语义处理。

7.3 Accept=no 与 Accept=yes

默认 Accept=no:一个 service接收整个监听 socket,适合现代并发 server。

Accept=yes:每条连接激活一个模板 service 实例,类似 inetd:

connection -> foo@<instance>.service

它适合低频、简单连接服务;高连接率下每连接进程/manager job 成本很高。

7.4 activation 不是无限缓冲

监听 backlog 和 socket receive buffers 都有限。服务长时间启动失败,连接仍会超时或队列满;协议客户端也有 deadline。activation 消除入口 ownership race,不是故障时无限吸收流量。


8. cgroup supervision:为什么不再追一个 pidfile

8.1 每个 service 有自己的 cgroup

systemd 启动 service 时把进程放进对应 cgroup,后代默认留在其中:

system.slice/
  api.service/
    main process
    worker children
    helper process

这样 stop 可以针对整个服务进程集合,不依赖猜 main PID 的所有后代,也不因 fork/daemonize 轻易逃出监督。

systemctl status api.service
systemd-cgls /system.slice/api.service

8.2 KillMode 决定停止范围

常见语义:

  • KillMode=control-group:停止时终止 unit cgroup 全部进程,通常是安全默认
  • mixed:main 先收到正常终止信号,其余在最终 kill 阶段处理
  • process:只处理 main,子进程可能泄漏,通常不推荐
  • none:几乎不管理终止,危险且新版本可能限制

若 worker 本应由主进程优雅收尾,可配置合适信号与 mixed/control-group,并实际测试。不要为“保留 helper”随意改 process;应拆成独立 unit表达生命周期。

8.3 资源限制直接映射 cgroup

[Service]
CPUWeight=200
CPUQuota=200%
MemoryHigh=800M
MemoryMax=1G
TasksMax=512
IOWeight=100

这些设置由 systemd 写入 cgroup controller。LimitNOFILE=LimitCORE= 等则映射 rlimit,不要混淆:

MemoryMax -> cgroup memory.max(整个 unit)
LimitAS   -> RLIMIT_AS(每进程虚拟地址空间,通常不适合作为容器式内存限制)
TasksMax  -> pids.max
LimitNPROC -> RLIMIT_NPROC(按 UID 等语义)

8.4 scope 与 transient unit

不是 systemd fork 的进程也可放进 .scope,例如登录 session、容器或 systemd-run --scope

systemd-run --scope -p MemoryMax=1G make -j8

临时 service:

systemd-run --unit=one-shot-report \
  --property=CPUQuota=50% \
  /usr/local/bin/report

这比裸 nohup ... & 更容易追踪日志、退出状态、资源和清理。


9. Restart:监督不是无限重试

9.1 Restart 策略

Restart=on-failure
RestartSec=2s

常见选项概念:

no          不自动重启
on-success  正常退出时重启
on-failure  非零退出、信号、超时、watchdog 等失败时重启
always      不论原因都重启(显式 stop 通常有单独语义)

on-failure 适合长期 daemon;配置错误导致稳定退出失败时也会循环,所以必须有 start rate limit。

9.2 StartLimit 防重启风暴

[Unit]
StartLimitIntervalSec=60
StartLimitBurst=5

[Service]
Restart=on-failure
RestartSec=2s

在窗口内超过启动次数,systemd 标记 start-limit-hit,停止自动尝试。它保护 CPU、日志、下游和端口,避免每秒 crash/restart。

systemctl reset-failed api.service
systemctl start api.service

reset-failed 只清状态/计数,不修复配置;先读日志再重置。

9.3 成功退出码与信号可定制

某些 daemon 用特定退出码表达正常重启/配置变化:

SuccessExitStatus=75 SIGTERM
RestartPreventExitStatus=78
RestartForceExitStatus=SIGABRT

应慎用并写注释/文档,否则退出码语义难以维护。Go 服务通常让正常 SIGTERM handler最终返回 0,真实启动/运行失败返回非零即可。

9.4 restart 会放大外部故障

依赖数据库不可用时应用立即 fatal,systemd不断重启,会制造 DNS/connect/TLS 风暴。更合理的边界:

  • 配置/密钥缺失:快速失败 + rate limit,等待运维修复
  • 暂时下游不可用:进程保持运行,内部有界退避并报告 not-ready/degraded
  • 内部不可恢复不变量:退出,由 supervisor重启

监督器负责进程生命周期,不应代替应用分布式故障策略。


10. Stop 与 graceful shutdown

10.1 默认停止流程

对 service 执行 stop,systemd通常:

发送 KillSignal(默认 SIGTERM)
  -> 等 TimeoutStopSec
  -> 仍未退出则发送 FinalKillSignal(通常 SIGKILL)

SIGKILL 无法捕获,进程没有机会 flush或清理。TimeoutStopSec 应覆盖业务 drain 的合理上限,又不能让机器关机/发布无限卡住。

10.2 ExecStop 不是必须的 kill 命令

若主进程正确处理 SIGTERM,通常无需:

ExecStop=/bin/kill $MAINPID

systemd 自己会发信号。ExecStop= 更适合调用应用管理接口执行同步停止,并且只有 service成功启动时调用的具体行为需查版本文档。命令必须等待停止动作完成,不能只异步触发后立即退出,否则 systemd继续终止流程。

10.3 ExecStopPost 用于无论成功失败的清理

ExecStopPost= 可在服务停止/启动失败后运行清理或记录命令。它不应删除仍需崩溃恢复的数据,也不能假定主服务正常完成。

ExecStopPost=/usr/local/libexec/api-cleanup

清理脚本要幂等、有超时、权限最小,避免自身卡住 shutdown。

10.4 Go HTTP graceful shutdown

ctx, stop := signal.NotifyContext(
    context.Background(),
    syscall.SIGTERM,
    os.Interrupt,
)
defer stop()

srv := &http.Server{Addr: ":8080", Handler: handler}
errc := make(chan error, 1)
go func() {
    err := srv.ListenAndServe()
    if err != nil && !errors.Is(err, http.ErrServerClosed) {
        errc <- err
        return
    }
    errc <- nil
}()

select {
case err := <-errc:
    if err != nil { log.Fatal(err) }
case <-ctx.Done():
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 25*time.Second)
    defer cancel()
    if err := srv.Shutdown(shutdownCtx); err != nil {
        _ = srv.Close()
    }
    if err := <-errc; err != nil {
        log.Printf("server exit: %v", err)
    }
}

若 unit TimeoutStopSec=30s,应用内部 timeout 要稍短,留出日志/cleanup余量。Shutdown 不会等待任意 hijacked connection,WebSocket/自定义连接需单独跟踪。

10.5 reload 与 restart

reload 期望保持主进程/连接,重新读取配置;restart 会停止再启动。应用只有能原子验证/切换配置时才提供 reload:

read new config
-> validate fully
-> build new immutable state
-> atomic swap
-> old in-flight requests finish with old state

读到一半就修改全局字段会留下混合配置。若无法安全 reload,明确使用 restart 比伪装零停机更可靠,前提是负载均衡和多实例承接流量。


11. journald:日志与 unit 生命周期关联

11.1 stdout/stderr 自动进入 journal

system service 默认 stdout/stderr 可由 journald 捕获,并附加:

unit name
PID/UID/GID
boot ID
monotonic/realtime timestamp
cgroup
executable and transport metadata

查询:

journalctl -u api.service
journalctl -u api.service -b
journalctl -u api.service --since '10 minutes ago'

这比每个 daemon 自己管理 pidfile + logfile + rotation 提供统一关联。

11.2 journal 是结构化索引,不只文本文件

应用可使用 syslog/journal API提交 priority、message ID、字段;普通 stdout 一行主要成为 MESSAGE。结构化字段便于过滤,但不要将用户任意 key直接变成 journal field,也要限制高基数字段和敏感信息。

journalctl _SYSTEMD_UNIT=api.service PRIORITY=3
journalctl _PID=1234

11.3 持久化、轮转与速率限制

journal 可配置 volatile(/run)或 persistent(/var/log/journal)存储,并按大小/时间 vacuum。日志不是无限可靠消息队列:高频服务可能触发 rate limit,磁盘满也会丢失或压缩历史。

journalctl --disk-usage
journalctl --vacuum-time=14d

vacuum 是删除历史的外部影响操作,生产执行前应确认合规/审计保留策略。

11.4 日志重复的来源

应用同时写 stdout 和独立文件、rsyslog 又转发 journal、容器 runtime再收集 stdout,可能造成重复。先画清日志链:

app stdout -> journald -> rsyslog/forwarder -> central store

不要靠多个 agent无差别 tail 同一来源。需要集中式可靠传输时,应有缓冲、backpressure、drop 指标和隐私策略。

11.5 journal 权限

读取系统 journal 可能暴露其他服务日志、环境路径、错误和用户数据。非 root 用户通常通过特定组/ACL访问;不应为方便给应用广泛 journal 权限。服务自身观测最好通过 metrics/tracing 和受控日志出口。


12. Timer activation:cron 的另一种模型

12.1 timer 与 service 分离

# report.timer
[Timer]
OnCalendar=*-*-* 03:00:00
Persistent=true
RandomizedDelaySec=10m

[Install]
WantedBy=timers.target
# report.service
[Service]
Type=oneshot
ExecStart=/usr/local/bin/report

timer 管“何时激活”,service 管“运行什么、资源、用户、日志和失败”。Persistent=true 可在机器离线错过日历触发后补跑,精确语义需结合 timestamp state。

12.2 monotonic 与 realtime timer

  • OnBootSecOnUnitActiveSec:基于 monotonic,相对启动/上次运行
  • OnCalendar:wall clock 日历时间,受时区/DST/校时影响

周期任务要明确是否允许补跑、并发、重复与时间回拨。无论 cron 还是 timer,任务本身都应幂等并使用锁/事务防重。

12.3 随机延迟避免惊群

大量机器整点执行更新/备份会冲击下游。RandomizedDelaySec 把启动分散;还有 accuracy/coalescing 设置帮助节能。调度随机化不是业务重试 jitter 的替代,两层都可能需要。

12.4 timer 不自动并发多实例

同一个 service 已 active 时再次 trigger通常不会启动第二份。oneshot 长时间运行可能让后续触发被合并/跳过,行为需通过 systemctl list-timers 和日志验证。需要排队每次事件应使用真正任务队列,而不是依赖 timer 保存无限触发。


13. Sandboxing:unit 也是最小权限边界

13.1 以非 root 用户运行

[Service]
User=api
Group=api
DynamicUser=yes

DynamicUser=yes 可为无长期身份需求的服务动态分配用户,并与状态/缓存目录 directive组合。需要持久文件 ownership、跨服务共享或固定 UID 的服务可能不适合直接使用。

13.2 文件系统限制

ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths=/var/lib/api /run/api
StateDirectory=api
RuntimeDirectory=api

StateDirectory= 让 systemd 创建并管理合适目录,比 ExecStartPre=mkdir/chown 更声明式。PrivateTmp 给服务私有 /tmp 视图,但不限制其总临时空间,仍需 filesystem/cgroup 管理。

13.3 capability 与 syscall 限制

NoNewPrivileges=yes
CapabilityBoundingSet=
AmbientCapabilities=
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallFilter=@system-service

CapabilityBoundingSet= 去掉 capability。若服务要绑定低端口,可用 socket activation 避免服务自身拥有 CAP_NET_BIND_SERVICE,或仅授予最小 capability。

SystemCallFilter 可能在 Go 版本升级、cgo、profiling、DNS 或 runtime新路径上阻断 syscall;应从发行版推荐 profile出发,在测试中观察 audit,而非复制网上最严模板直接上线。

13.4 namespace 型沙箱 directive

PrivateDevices=yes
PrivateNetwork=yes
ProtectKernelTunables=yes
ProtectControlGroups=yes
RestrictNamespaces=yes

它们使用 namespace、mount 和策略构造服务沙箱。PrivateNetwork=yes 会创建只有 loopback 的 network namespace,网络服务自然无法对外;每条 directive 都改变语义,应按服务需求逐步启用并测试。

13.5 security score 只是提示

systemd-analyze security api.service

它根据 directive给 exposure 分数,帮助发现缺失加固,不理解应用业务,也不是漏洞扫描/合规证明。低分可能不适合某服务,高分也不代表应用无漏洞。


14. 配置来源、覆盖与环境

14.1 unit 搜索与优先级

常见位置:

/usr/lib/systemd/system  vendor/package units
/etc/systemd/system      administrator overrides
/run/systemd/system      runtime/generated units

不应直接修改 /usr/lib 包文件,升级会覆盖。使用 drop-in:

systemctl edit api.service

生成:

/etc/systemd/system/api.service.d/override.conf

查看最终合并:

systemctl cat api.service
systemctl show api.service

14.2 清空列表型 directive

某些 directive可多次出现并累加;要替换 vendor ExecStart,先用空值重置:

[Service]
ExecStart=
ExecStart=/usr/local/bin/api --new-flag

遗漏空行可能报“多个 ExecStart”或保留旧值。不同 directive合并规则不同,使用 systemd-analyze verify 验证。

14.3 system service 没有交互 shell 环境

systemd 不读取你的 .bashrc,PATH、工作目录、locale、代理变量可能不同:

[Service]
WorkingDirectory=/var/lib/api
Environment=APP_ENV=prod
EnvironmentFile=-/etc/api/env
ExecStart=/usr/local/bin/api

使用绝对路径,不依赖 cd、shell alias 或当前终端。环境变量可能出现在 manager introspection、core dump 或子进程,不适合承载高敏感 secret;优先使用受权限保护的 credential/file机制。

14.4 specifier 与环境变量不是一套语法

%n%i%u 是 systemd specifier;${VAR}/$VAR 有 systemd 自己有限的环境展开规则,不等于 shell quoting。复杂命令参数最好让程序从配置文件读取,避免三层转义。


15. Go 服务的推荐 unit 基线

[Unit]
Description=Example Go API
Documentation=https://example.invalid/runbook/api
Wants=network-online.target
After=network-online.target
StartLimitIntervalSec=60
StartLimitBurst=5

[Service]
Type=notify
NotifyAccess=main
User=api
Group=api
WorkingDirectory=/var/lib/api
EnvironmentFile=-/etc/api/env
ExecStart=/usr/local/bin/api --config=/etc/api/config.yaml
Restart=on-failure
RestartSec=3s
TimeoutStartSec=30s
TimeoutStopSec=35s
KillSignal=SIGTERM
KillMode=mixed
LimitNOFILE=65536
TasksMax=512
MemoryHigh=800M
MemoryMax=1G
CPUQuota=200%
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ReadWritePaths=/var/lib/api

[Install]
WantedBy=multi-user.target

这只是基线,不可原样复制:

  • 应用若不发 READY=1Type=notify 会一直 activating并超时,应改 simple/exec
  • 数据库是否必须先启动不能由 network-online 表达
  • MemoryMax/CPUQuota/TasksMax 要按容量测试
  • KillMode=mixed 是否符合 worker生命周期要验证
  • sandbox 需与 DNS、证书、配置、profile、临时文件路径兼容
  • service监听低端口可用 socket activation或最小 capability

15.1 启动时序

Go 程序应:

加载配置和凭据
-> 验证不可变条件
-> 初始化必要资源
-> 建立 listener(或接管 socket activation fd)
-> 启动 worker
-> 确认可接请求
-> sd_notify READY=1

不要在 READY 前接收却无法处理,也不要等所有可恢复远端依赖永远健康才 ready。readiness 定义取决于流量进来后能否给出符合契约的响应。

15.2 停止时序

收到 SIGTERM
-> 标记 not ready / 停止接受新业务
-> 关闭 listener 或让 LB 停止路由
-> 等待 in-flight 到内部 deadline
-> flush 必须状态
-> close resources
-> exit 0

systemd 本机服务没有 Kubernetes readiness 自动传播,需要通过负载均衡 API、service discovery、socket关闭或应用协议设计协调多实例流量。

15.3 watchdog

unit:

WatchdogSec=20s

服务 READY 后按小于一半间隔发送 WATCHDOG=1。watchdog线程必须能反映真正服务活性;若它独立于事件循环永远 tick,即使主 worker deadlock 也会误报健康。可以让主循环/关键调度路径共同更新健康 generation。


16. 排查启动失败的固定顺序

16.1 先看状态和退出原因

systemctl status api.service --no-pager -l
systemctl show api.service \
  -p ActiveState -p SubState -p Result \
  -p ExecMainCode -p ExecMainStatus -p NRestarts

常见 status

203/EXEC       可执行文件/exec/权限/路径问题
217/USER       User/Group 设置或身份切换问题
start-limit-hit 重启过快被限流
timeout        启动/停止未在期限内完成
signal         被信号终止

确切 exit status 映射应查当前 systemd.exec(5)

16.2 再看本次 boot 的 unit 日志

journalctl -u api.service -b --no-pager
journalctl -u api.service -b -n 200 -o short-monotonic

monotonic 时间便于与启动顺序比较。若服务在当前 boot未启动,去掉 -b 看上一 boot;可用 journalctl --list-boots

16.3 查看最终 unit 与依赖

systemctl cat api.service
systemctl show api.service -p FragmentPath -p DropInPaths
systemctl list-dependencies api.service
systemctl list-dependencies --reverse api.service
systemd-analyze verify /etc/systemd/system/api.service

不要只读 /usr/lib 原文件;drop-in 和 generator可能改变最终定义。

16.4 在相同身份与环境复现

sudo -u api /usr/local/bin/api --config=/etc/api/config.yaml

这仍不完全复制 systemd 的 cgroup、namespace、cwd、umask、environment、fd 和 sandbox。更可靠是使用 transient unit模拟:

systemd-run --wait --pty \
  --uid=api \
  --property=WorkingDirectory=/var/lib/api \
  /usr/local/bin/api --config=/etc/api/config.yaml

生产命令权限与终端语义需谨慎;不要直接带敏感环境到命令行。

16.5 检查 cgroup 与资源事件

systemctl show api.service -p ControlGroup
systemd-cgls /system.slice/api.service

CG=/sys/fs/cgroup/system.slice/api.service
cat "$CG/cpu.stat"
cat "$CG/memory.current" "$CG/memory.events"
cat "$CG/pids.current" "$CG/pids.events"

服务被 SIGKILL 可能是 systemd timeout,也可能是 memcg OOM、管理员操作或内核策略。用 Result、journal、memory.events 和 audit共同确认。


17. 启动性能与关键路径

17.1 blame 不等于根因

systemd-analyze time
systemd-analyze blame
systemd-analyze critical-chain

blame 显示 unit 从 activating 到 active花了多久,但并行启动中一个长 unit未必阻塞默认 target。真正影响启动总时长的是 critical chain 上的 ordering dependency。

17.2 设备和网络可能异步

等待磁盘、cryptsetup、DHCP、远程 mount会延长链。删除依赖可让 target更早 active,却可能把失败推到业务运行时。优化应先明确“系统 ready”的产品定义,再减少不必要串行、改用 activation或让服务容忍动态依赖。

17.3 绘制依赖图

systemd-analyze dot 'api.service' | dot -Tsvg > api-deps.svg

图可能很大,过滤目标 unit;不同 edge颜色表示类型。生成 SVG 是本地操作,公开前检查 unit 名和拓扑是否敏感。

17.4 启动完成不等于业务 ready

multi-user.target active 只表示声明式 job完成;应用可能仍在 warming cache,外部 LB 也可能尚未更新。系统级 boot SLA 与服务级 readiness SLO 要分别测量。


18. systemd 为什么赢

18.1 并行和 activation 删除不必要顺序

依赖图允许无关 unit并行,socket/device/path/dbus activation让消费者先建立需求,生产者按需启动,减少脚本 sleep和“先启动但还没 ready”的 race。

18.2 cgroup 给服务稳定身份

进程可 fork、PID 可复用,cgroup仍能表示整个 service进程集合,支持可靠 stop、资源限制、状态查看和日志关联。这比 pidfile本质上更接近内核真实对象。

18.3 统一配置和诊断

发行版可用一致 unit模型管理 daemon、mount、timer、socket和用户 session;管理员有 systemctljournalctlsystemd-analyze 等共同接口。包升级也更容易安装 declarative defaults和管理员 drop-in。

18.4 桌面和服务器动态需求

现代桌面需要设备热插拔、用户 session、seat、电源管理;服务器需要自动监督、按需服务和安全沙箱。systemd 与 udev、logind、resolved、networkd 等项目集成,为发行版提供完整基线。


19. systemd 为什么被骂

19.1 职责范围扩大

批评者认为 init 应专注启动/监督,而 systemd 项目覆盖日志、登录、网络、DNS、时间、临时文件、core dump等,违背“小工具组合”传统,增加单一项目影响范围。

支持者则认为这些组件并非都强制使用,统一协议和生命周期能解决长期跨组件 race。争议本质是集成一致性与模块可替换性的权衡。

19.2 配置声明式但语义很多

Wants/Requires/After/Before/BindsTo/PartOf、多种 Type、kill/restart、specifier与 sandbox directive学习成本高。unit比任意 shell更可分析,但“可声明”不等于“简单”。

错误配置常不会以语法错误出现,而是产生合法但不符合预期的生命周期,例如只写 After=、把 oneshot当 daemon、notify不发 READY。

19.3 Linux 专用与可移植性

systemd 深度依赖 Linux cgroup、namespace、inotify、capability等,不以跨 Unix 可移植为目标。面向 BSD/多平台的软件仍需保留前台运行和标准信号接口,不能把核心逻辑绑死在 sd_notify/journal;集成应是可选适配层。

19.4 二进制 journal 与集中故障观感

journal 的结构化索引、轮转和 metadata有优势,但二进制格式、损坏恢复、权限和工具依赖让习惯文本日志的人不安。最稳妥的工程做法是定义日志保留/转发/导出策略,而不是假定本地 journal是永久唯一副本。

19.5 争论不应替代具体诊断

“systemd 太复杂”不能解释某 unit为什么 timeout;“systemd 已统一一切”也不能证明配置正确。实际工作应回到:job graph、进程/cgroup、fd、signal、日志、resource events和应用协议。


20. 常用命令知识点扩展

命令/短参 英文全称 作用
systemctl -u unit(部分子命令使用) 指定/过滤 unit;以子命令文档为准
systemctl --now now enable/disable 同时立即 start/stop
systemctl --failed failed 列出失败 unit
journalctl -u unit 按 unit过滤日志
journalctl -b boot 只看指定 boot
journalctl -f follow 持续跟随新日志
journalctl -n lines 显示末尾若干条
systemd-analyze blame activation duration 列出 unit激活耗时
systemd-analyze critical-chain critical chain 显示启动关键排序链
systemd-analyze verify verify 验证 unit文件与依赖

场景配方:

# 查看失败服务及本次 boot 证据
systemctl --failed
systemctl status api.service --no-pager -l
journalctl -u api.service -b -n 200 --no-pager

# 查看最终配置、覆盖和关键属性
systemctl cat api.service
systemctl show api.service -p Type -p MainPID -p ControlGroup -p Result

# 查看谁依赖它
systemctl list-dependencies --reverse api.service

# 修改 drop-in 并生效
systemctl edit api.service
systemctl daemon-reload
systemctl restart api.service

21. 面试题

Q:PID 1 为什么不能只是普通进程?

它是初始用户态管理者,需要启动/停止系统服务、收养并 wait 孤儿进程、协调关机;内核还对 PID 1 的部分默认信号处理有特殊规则。容器 PID 1 同样要转发信号和 reap,否则会出现优雅退出失败与 zombie。

Q:SysV init 的核心局限是什么?

运行级别和编号 shell脚本能模块化启动,但顺序数字难表达强/弱依赖、冲突和动态事件;脚本内部 fork/sleep/pidfile让 manager难判断 main PID、ready状态与崩溃。安全并行和统一监督只能靠约定与额外工具修补。

Q:systemd unit 相比启动脚本解决了什么?

unit把 service、socket、mount、device、timer、target等建模为声明对象,manager可构建 dependency transaction、并行无关 job、跟踪 cgroup、设置资源/沙箱并统一日志。它牺牲了一部分任意 shell灵活性,换取可分析生命周期。

Q:Wants/Requires 与 Before/After 有何区别?

Wants/Requires 是 requirement dependency,决定启动当前 unit时是否把另一 unit拉入 transaction及失败强度;Before/After 是 ordering dependency,只在两者都有 job时排序。After=db.service 不会自动启动数据库,通常需再配 Wants或Requires。

Q:After=network-online.target 是否保证数据库网络可达?

不保证。online由网络管理器定义,通常只表示某些接口/地址配置完成,不承诺 DNS、Internet、VPN或特定远端健康。客户端仍必须有 connect deadline、有界重试和运行期断线恢复。

Q:enable 与 start 有什么区别?

start改变当前运行状态;enable根据 [Install] 创建 target wants等持久启动关系,通常不立即启动。enable --now 才两者同时做。static unit无法直接 enable,但可被依赖或 activation启动。

Q:Type=simple、forking、notify 的边界是什么?

simple在进程开始执行后即视为 start完成;forking等待传统 daemon父进程退出并常依赖 PIDFile;notify要求服务显式发送 READY=1,能表达真实初始化完成。现代前台 Go服务适合 simple/exec;需要依赖者等待真实 ready时使用正确实现的 notify。

Q:socket activation 解决什么 race?

systemd先 bind/listen并持有入口,客户端连接可在服务初始化/重启时进入有限 backlog;服务启动后继承监听 fd,避免“端口还没绑定”和特权 bind问题。它不是无限队列,应用必须支持 fd传递协议。

Q:systemd 为什么用 cgroup 而不是 pidfile 管服务?

PID会复用且 daemon可 fork多代,pidfile可能 stale或记录错误;cgroup表示整个服务进程集合,后代默认留在其中,可可靠 stop、计量与限制资源,并关联日志。main PID仍有意义,但不再是唯一身份。

Q:Restart=always 为什么可能造成事故?

配置错误或下游故障会让进程快速退出,supervisor无限重启,放大日志、CPU、DNS/connect和下游压力。应使用合适 Restart、RestartSec和StartLimit;应用对暂时外部故障应内部有界退避,而非立即 fatal循环。

Q:systemd stop 一个服务时发生什么?

通常先向按 KillMode确定的进程发送 KillSignal(默认 SIGTERM),等待 TimeoutStopSec,仍存活则发送最终强制信号通常 SIGKILL。应用应在 TERM后停止新请求、限时排空并退出;内部 timeout要短于 unit总期限。

Q:ExecStop 是否必须配置 kill 命令?

通常不必。systemd会对 main/cgroup发送停止信号。ExecStop适合调用应用的同步关闭接口,命令应等到动作完成;仅异步触发会让 manager继续进入终止阶段。清理可用幂等的 ExecStopPost,但不能替代应用一致性协议。

Q:journald 相比普通日志文件有什么优势和边界?

它自动附加 unit、PID、boot、cgroup等 metadata并支持结构化过滤、统一轮转;边界是本地容量、rate limit、权限、二进制工具依赖与并非永久可靠队列。关键日志仍需受控转发和保留策略。

Q:CPUQuota=200% 表示什么?

通常表示 unit平均最多使用约两个 CPU的调度带宽,由 cgroup cpu.max quota/period实现,不是绑定两个固定核。多线程可并行提前耗尽 quota而被 throttle;需要结合 CPUAffinity/cpuset、CPUWeight和 cpu.stat 理解。

Q:MemoryMax 与 LimitAS 有什么区别?

MemoryMax映射 cgroup memory硬上限,对整个 unit的匿名页、cache和被记账内核内存等施加约束,回收失败可触发 memcg OOM;LimitAS是每进程虚拟地址空间 rlimit,对 mmap/Go runtime不等同实际驻留内存,通常不应替代 cgroup内存限制。

Q:如何安全覆盖发行版提供的 unit?

不要修改 /usr/lib/systemd/system;用 systemctl edit 创建 /etc/systemd/system/name.service.d/*.conf drop-in,必要时先用空 directive重置列表,再设置新值。执行 daemon-reload、用 systemctl cat/show确认最终合并,并运行 systemd-analyze verify

Q:systemd-analyze blame 为什么不能直接证明启动瓶颈?

它列出每个 unit处于 activating的时长,但 unit可并行;很慢的 unit若不在默认 target的 ordering critical path,就不决定总启动时间。应看 critical-chain 和依赖图,再结合设备/网络日志分析等待原因。

Q:systemd 被批评的主要设计权衡是什么?

它用深度集成统一 activation、supervision、cgroup、日志、session和系统对象,换来一致性与可诊断性;代价是职责面大、Linux专用、配置语义多、组件集中影响范围和替换成本。评价应落到具体需求,而不是把架构立场当故障结论。


小结

  • PID 1 是系统用户态生命周期根,负责服务编排、孤儿回收与关机;容器 PID 1 同样需要信号转发和 wait
  • SysV init用 runlevel与顺序脚本实现模块化,但依赖、并行、ready状态、pidfile和崩溃监督难以可靠组合
  • systemd将 service、socket、mount、timer、device、target和slice建模为 unit,通过 job transaction分析依赖和排序
  • Wants/Requires表达拉入与失败强度,Before/After只表达 job顺序;After= 不会自动启动对方,network-online也不是远端可达承诺
  • service Type定义何时视为启动完成:simple/exec不等于业务 ready,forking服务难监督,notify可显式发送 READY
  • socket activation先持有监听入口并把 fd传给服务,消除 bind/启动 race和部分特权需求,但受 backlog与客户端 deadline限制
  • systemd用 cgroup跟踪整个服务进程集合,可设置 CPU、memory、IO、tasks资源和安全沙箱,比 pidfile更接近真实生命周期
  • Restart必须配合退避与StartLimit;外部暂时故障不应变成进程级重启风暴
  • 停止流程是 TERM—限时排空—KILL;Go内部 graceful timeout要短于 TimeoutStopSec,并处理 WebSocket/自定义连接
  • journald将日志与 unit/PID/boot/cgroup关联,但仍需容量、rate limit、权限、转发与保留策略
  • unit应通过 drop-in覆盖,明确非交互环境、用户、目录、fd、resource与sandbox,不要原样复制“最安全模板”
  • 排查按 status/result、journal、最终 unit、dependency、相同环境复现、cgroup events和critical chain逐层验证

下一篇讲 观测体系的设计与 eBPF —— metric、log、trace、profile 为什么回答不同问题,传统 /proc、perf、ptrace和内核 tracepoint 各有什么盲区,eBPF 如何安全地在内核事件上运行受验证程序,以及为什么高基数、采样偏差和观测开销比“会写 BPF”更容易毁掉生产系统。