Linux 基础体系 · 第 10/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。

systemd 服务管理:Unit、依赖、重启、资源限制和安全沙箱

systemd 是现代 Linux 发行版中常见的系统与服务管理器。它通常由内核以 PID 1 启动,负责在用户空间早期阶段拉起基础服务,并持续管理服务进程、挂载点、套接字、设备、定时器和其他系统对象。

本文围绕五个核心问题展开:

  1. Unit 是什么,systemd 如何描述和跟踪服务
  2. 依赖关系与启动顺序如何建立,为什么 Requires=After= 不是一回事
  3. 服务失败后何时、为何以及如何重启
  4. 如何通过 cgroup 和进程限制控制资源
  5. 如何使用安全沙箱降低服务被攻破后的影响范围

示例面向现代主流 Linux 发行版。不同发行版可能使用不同的默认目录、编译选项、systemd 版本和安全策略;命令通常需要 root 权限,生产环境修改服务前应确认回滚方式。


一、systemd 管理的对象:Unit

1. Unit 是声明式管理对象

Unit 是 systemd 管理的一类对象。每个 Unit 通常由一个以特定后缀结尾的配置文件描述,例如:

类型 后缀 用途
Service .service 管理长期运行的进程或一次性任务
Target .target 将多个 Unit 组织成逻辑阶段或目标
Socket .socket 管理监听套接字,可触发服务启动
Timer .timer 按时间触发某个 Service
Mount .mount 管理文件系统挂载
Automount .automount 按访问需求触发挂载
Path .path 监听文件或目录变化
Device .device 表示内核发现的设备
Scope .scope 管理由外部程序创建、但纳入 systemd 的进程组
Slice .slice 对 cgroup 资源进行分层管理

服务管理最常见的是 .service,但一个服务的完整运行环境可能同时涉及 .socket.mount.slice.target

例如:

sshd.service
sshd.socket
multi-user.target
system.slice

这里:

  • sshd.service 表示 SSH 服务进程;
  • sshd.socket 表示 SSH 监听套接字;
  • multi-user.target 表示一个多用户系统运行阶段;
  • system.slice 是系统服务所在的 cgroup 层级。

Unit 文件本质上是声明式配置。它描述“应该如何启动、停止和约束对象”,而不是只描述一次性的 shell 命令。


2. Unit 文件的查找位置与优先级

系统级 Unit 常见位置如下:

/usr/lib/systemd/system/
 /lib/systemd/system/
 /run/systemd/system/
/etc/systemd/system/

实际使用哪个目录取决于发行版:

  • /usr/lib/systemd/system/ 常用于发行版或软件包提供的 Unit;
  • /lib/systemd/system/ 在部分发行版中仍然存在;
  • /run/systemd/system/ 用于运行时生成的 Unit,重启后通常消失;
  • /etc/systemd/system/ 用于管理员持久化覆盖和自定义 Unit。

对于同名 Unit,管理员配置通常优先于软件包提供的配置。因此,手工编写服务时通常放入:

/etc/systemd/system/example-worker.service

不要直接修改 /usr/lib/systemd/system/ 中的软件包文件。软件包升级可能覆盖这些修改。


3. Unit 的状态不是单一的“运行或停止”

查询服务:

systemctl status example-worker.service

典型输出包含:

Loaded: loaded (/etc/systemd/system/example-worker.service; enabled; preset: enabled)
Active: active (running) since ...
Main PID: 1234 (demo-worker)
Tasks: 3
Memory: 8.0M

这里至少有三类不同信息:

3.1 Loaded 状态:Unit 是否被正确加载

常见结果:

  • loaded:Unit 文件可读且语法基本有效;
  • not-found:找不到 Unit;
  • bad-setting:存在无法解析的配置;
  • masked:Unit 被链接到 /dev/null,禁止启动。

loaded 不代表服务已经运行。

3.2 Active 状态:Unit 当前运行状态

Service 常见状态包括:

  • inactive:未运行;
  • activating:正在启动;
  • active (running):正在运行;
  • active (exited):启动命令已退出,但 Unit 被认为成功完成;
  • deactivating:正在停止;
  • failed:最近一次启动或运行失败。

active (exited) 常见于:

[Service]
Type=oneshot
ExecStart=/usr/local/bin/init-task
RemainAfterExit=yes

这不表示某个进程还在运行,而是表示一次性任务成功完成后,Unit 保持逻辑上的 active 状态。

3.3 Enabled 状态:是否安装到启动目标

查询是否启用:

systemctl is-enabled example-worker.service

可能得到:

enabled
disabled
static
masked

enabled 表示该 Unit 已通过符号链接接入某个 target 的启动关系。它不是运行状态。

例如:

systemctl stop example-worker.service
systemctl is-enabled example-worker.service
systemctl is-active example-worker.service

可能输出:

enabled
inactive

这表示服务已配置为开机启动,但当前被手工停止。


二、一个可运行的 Service 示例

先创建一个会持续运行、能够响应终止信号的示例程序:

sudo install -d -m 0755 /usr/local/libexec

sudo tee /usr/local/libexec/demo-worker >/dev/null <<'EOF'
#!/bin/sh
set -eu

term_received=0

trap 'term_received=1' TERM INT

while [ "$term_received" -eq 0 ]; do
    printf '%s\n' "demo-worker is alive"
    sleep 5
done

printf '%s\n' "demo-worker is stopping"
exit 0
EOF

sudo chmod 0755 /usr/local/libexec/demo-worker

这个程序有两个重要特征:

  1. 主循环持续运行,因此可以观察 Service 的 active (running) 状态;
  2. 收到 SIGTERM 后退出,因此 systemd 停止服务时可以完成优雅退出。

创建 Unit:

sudo tee /etc/systemd/system/demo-worker.service >/dev/null <<'EOF'
[Unit]
Description=Demo worker service
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
ExecStart=/usr/local/libexec/demo-worker
Restart=on-failure
RestartSec=3s

[Install]
WantedBy=multi-user.target
EOF

加载并启动:

sudo systemctl daemon-reload
sudo systemctl enable --now demo-worker.service

这两步分别完成不同工作:

  • daemon-reload:让 systemd 重新读取 Unit 文件;
  • enable:创建开机启动所需的符号链接;
  • --now:在 enable 的同时立即启动。

验证:

systemctl status demo-worker.service
systemctl is-active demo-worker.service
journalctl -u demo-worker.service -n 20 --no-pager

预期可以看到:

active

以及类似日志:

demo-worker is alive

停止服务:

sudo systemctl stop demo-worker.service

因为程序响应 SIGTERM 并正常退出,systemd 通常会将服务标记为 inactive,而不是 failed


三、Service 的生命周期:从启动事务到进程退出

1. systemctl start 不是简单执行一个命令

执行:

systemctl start demo-worker.service

systemd 大致会进行以下处理:

  1. 读取 demo-worker.service
  2. 解析它直接或间接需要的 Unit;
  3. 构造一个启动事务;
  4. 根据依赖关系确定哪些 Unit 必须启动;
  5. 根据排序关系安排启动顺序;
  6. 创建或配置对应的 cgroup;
  7. 设置用户、环境、文件系统视图和资源限制;
  8. 执行 ExecStart=
  9. 根据 Type= 判断何时认为启动成功;
  10. 持续跟踪该服务的状态和进程。

启动事务可能包含多个 Unit,并且相互独立的 Unit 可以并发启动。After= 等排序指令只约束相对顺序,不会自动把另一个 Unit 加入事务。


2. Type= 决定 systemd 如何判断“启动完成”

常见类型如下。

Type=simple

[Service]
Type=simple
ExecStart=/usr/local/libexec/demo-worker

systemd 启动 ExecStart 指定的进程后,通常即可认为服务已经开始启动。它不会等待应用完成端口监听或初始化。

适合前台运行、启动逻辑简单的程序。

Type=exec

[Service]
Type=exec
ExecStart=/usr/local/bin/my-server

Type=execsimple 类似,但更适合区分“进程已创建”和“可执行文件已经成功执行”。如果执行文件不存在等问题导致 execve() 失败,systemd 能更明确地将启动判定为失败。

现代 systemd 支持该类型,但过旧版本可能不支持,部署前应检查:

systemd --version

Type=oneshot

[Service]
Type=oneshot
ExecStart=/usr/local/bin/migrate-database

适合执行一次任务。命令成功退出后,Unit 默认回到 inactive。若希望保持逻辑上的 active 状态:

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

Type=notify

应用通过 sd_notify() 向 systemd 发送:

READY=1

systemd 收到后才认为服务已经就绪:

[Service]
Type=notify
ExecStart=/usr/local/bin/my-server
NotifyAccess=main

这比 Type=simple 更能表达“初始化完成”的语义,但应用必须正确实现通知协议。仅仅配置 Type=notify 而应用不发送 READY=1,服务可能长期停留在 activating

Type=forking

适用于传统 daemon 自己后台化并让父进程退出的程序:

[Service]
Type=forking
PIDFile=/run/my-server.pid
ExecStart=/usr/local/sbin/my-server

如果能修改程序配置,通常更推荐让程序以前台模式运行,并使用 Type=simpleexecnotify。后台化会引入 PID 判断、父子关系和启动完成时机的不确定性。


3. ExecStart=ExecStop= 和 shell 误解

以下配置:

ExecStart=/usr/local/bin/my-server --config /etc/my-server.conf

不是由 shell 解析的。诸如管道、重定向、&& 默认不会按 shell 语法执行。

错误示例:

ExecStart=/usr/local/bin/generator > /tmp/output

这里的 > 通常会被作为参数传给程序,而不是重定向。

如果确实需要 shell 语法,应明确写出:

ExecStart=/bin/sh -c '/usr/local/bin/generator > /tmp/output'

但这会增加 quoting、信号转发和错误处理的复杂度。更可靠的方式通常是把逻辑写进独立脚本,并让脚本使用:

exec /usr/local/bin/real-server

exec 会用真正的服务进程替换脚本进程,使 systemd 更直接地跟踪主进程,并减少信号转发问题。


4. 停止、信号与优雅退出

默认情况下,systemd 停止 Service 时通常先发送 SIGTERM,等待 TimeoutStopSec=,超时后再发送强制终止信号。

[Service]
TimeoutStopSec=30s

流程可以抽象为:

systemctl stop
    |
    v
发送 SIGTERM
    |
    |-- 进程正常退出 --> inactive
    |
    |-- 超过 TimeoutStopSec -->
            发送 SIGKILL
            |
            v
        failed 或 stopped

具体结果还受到 KillMode=、服务状态和停止动作影响。

常见 KillMode=

  • control-group:终止 Unit cgroup 中的全部进程,通常是默认且安全的选择;
  • mixed:先对主进程发送 SIGTERM,再对 cgroup 其余进程发送 SIGKILL
  • process:只处理主进程,子进程可能遗留;
  • none:systemd 不主动杀进程,通常不适合普通服务。

如果服务会创建工作进程,使用 KillMode=process 可能导致子进程继续运行,产生端口占用、文件锁、后台任务泄漏等问题。

[Service]
KillMode=control-group

这与 Linux 进程的父子关系并不完全相同。systemd 主要依据 cgroup 管理成员,而不是只依据传统的 PPID。一个子进程即使改变父进程,也仍可能留在原 Unit 的 cgroup 中。

服务仍然应该正确回收自己的子进程。否则子进程退出后可能变成 Zombie;Zombie 已经释放大部分资源,但仍占用进程表项并保留退出状态,直到父进程调用 wait()。systemd 能管理 cgroup,却不能替代应用内部对子进程的回收逻辑。


四、依赖关系:需要谁、先后顺序和故障传播

依赖是 systemd 最容易被误解的部分。必须区分两种关系:

  • 需求关系:某个 Unit 是否必须被拉起;
  • 排序关系:两个 Unit 谁先进入启动或停止流程。

1. Wants=:弱需求关系

[Unit]
Wants=network-online.target

当当前 Unit 被启动时,systemd 会尝试启动 network-online.target

但如果 network-online.target 启动失败,当前 Unit 不一定失败。也就是说:

A Wants B

表达的是:

启动 A 时尝试启动 B

而不是:

B 失败时 A 必须失败

2. Requires=:强需求关系

[Unit]
Requires=database.service

启动当前 Unit 时,systemd 会尝试启动 database.service。如果该依赖无法启动,当前 Unit 的启动事务通常会失败。

但要注意:Requires= 本身不保证启动顺序。下面的配置:

[Unit]
Requires=database.service

只表达“需要数据库”,不表达“数据库必须先于当前服务启动”。

通常应同时写:

[Unit]
Requires=database.service
After=database.service

此时含义是:

  1. 启动当前服务时尝试启动数据库;
  2. 当前服务排在数据库之后;
  3. 数据库启动失败时,当前服务的启动事务受到影响。

3. After=:只排序,不拉起

[Unit]
After=network.target

它只表示:如果两个 Unit 都在同一事务中,当前 Unit 排在 network.target 之后。

它不会自动启动 network.target。因此:

[Unit]
After=database.service

不能替代:

Requires=database.service

也不能替代:

Wants=database.service

这是常见反例:

[Unit]
After=network-online.target

管理员以为这会等待网络在线,但如果没有其他关系把 network-online.target 加入启动事务,这条排序本身可能没有实际效果。

更完整的写法是:

[Unit]
Wants=network-online.target
After=network-online.target

但“网络在线”的准确含义取决于发行版的网络管理器和对应的 wait-online 服务。它不等价于“远端数据库一定可连接”。

4. Before=:排序关系的反向写法

[Unit]
Before=example-worker.service

表示当前 Unit 排在 example-worker.service 之前。它与在对方 Unit 中写:

After=current.service

在排序效果上相近,但仍然不产生需求关系。

5. Requisite=:依赖必须已经激活

[Unit]
Requisite=database.service

Requisite= 不负责拉起依赖。如果数据库没有处于所需状态,当前 Unit 会直接失败。

它适合表达“只允许在某个前置条件已经满足时运行”,而不是普通的服务依赖。

6. BindsTo=PartOf=

BindsTo=Requires= 更强,尤其适用于设备、挂载点等可能突然消失的对象:

[Unit]
BindsTo=data.mount
After=data.mount

如果绑定对象消失或停止,当前 Unit 也会被停止。

PartOf= 主要传播显式的 stop/restart 操作:

[Unit]
PartOf=application.target

application.target 执行停止或重启时,相关 Unit 也会被传播处理;但它不是完整的启动依赖,也不会自动把所有对象拉起。


7. 依赖图与启动事务

可以把 Unit 看作有向图:

  • 节点是 Unit;
  • 需求边表示启动关联;
  • 排序边表示先后约束;
  • systemd 对一次操作构造一个事务,并在事务中处理冲突和失败。

例如:

graph TD
    T[multi-user.target] -->|Wants| A[example-worker.service]
    A -->|Requires| DB[database.service]
    A -->|Wants| N[network-online.target]
    A -.->|After| DB
    A -.->|After| N

其中实线表示拉起关系,虚线表示排序关系。正确解释是:

  1. multi-user.target 拉起 example-worker.service
  2. example-worker.service 要求启动 database.service
  3. 它还会尝试启动 network-online.target
  4. 由于 After=,数据库和网络目标若被纳入事务,就应先于应用完成;
  5. After= 不负责建立启动关联,Requires=Wants= 才负责这一点。

如果依赖图存在排序环,例如:

A.service: After=B.service
B.service: After=A.service

systemd 不能同时满足这两个排序条件,会在启动时报告事务或依赖问题。需求关系中的环也可能造成不可启动的事务,应通过:

systemctl list-dependencies example-worker.service
systemctl list-dependencies --reverse example-worker.service
systemd-analyze dot example-worker.service
systemd-analyze verify /etc/systemd/system/example-worker.service

进行检查。


五、安装、启用、启动和覆盖配置

1. enable 不等于 start

sudo systemctl enable demo-worker.service

只配置开机启动,不会立即启动。

sudo systemctl start demo-worker.service

只启动当前实例,不改变开机启动配置。

sudo systemctl enable --now demo-worker.service

同时完成两件事。

关闭开机启动:

sudo systemctl disable demo-worker.service

禁止任何人启动:

sudo systemctl mask demo-worker.service

解除禁止:

sudo systemctl unmask demo-worker.service

maskdisable 更强。disable 只是不接入默认启动目标,管理员仍可手工 startmask 通常会让启动请求直接失败。


2. 为什么推荐 drop-in,而不是复制整个 Unit

查看实际生效内容:

systemctl cat demo-worker.service
systemctl show demo-worker.service

创建覆盖目录:

sudo systemctl edit demo-worker.service

编辑器中写入:

[Service]
Restart=always
RestartSec=5s
MemoryMax=512M

保存后,systemd 会在类似以下位置生成覆盖文件:

/etc/systemd/system/demo-worker.service.d/override.conf

然后执行:

sudo systemctl daemon-reload
sudo systemctl restart demo-worker.service

drop-in 的优势是只覆盖需要修改的字段,减少与发行版原始 Unit 配置失步的风险。

但要注意,部分设置不是普通追加。例如清空原有命令列表时需要显式赋空值:

[Service]
ExecStart=
ExecStart=/usr/local/bin/new-command

如果直接再写一个 ExecStart=,对于某些可重复字段会得到多个命令;而 ExecStart= 在非 oneshot Service 中通常要求单一主命令,因此清空旧值是必要的。


六、失败检测与自动重启

1. Restart= 的语义

常见配置:

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

Restart= 决定服务进程退出后,systemd 是否重新启动它。

常见取值:

  • no:不自动重启;
  • on-success:成功退出时重启;
  • on-failure:失败退出、异常信号、超时等情况下重启;
  • on-abnormal:异常信号、超时、watchdog 等异常情况重启;
  • on-abort:未捕获信号导致中止时重启;
  • always:无论成功或失败都重启。

Restart=on-failure 常用于长期运行服务,因为正常的手工停止一般不应触发重启,而崩溃或超时应触发重启。

例如:

[Service]
Type=simple
ExecStart=/usr/local/bin/my-server
Restart=on-failure
RestartSec=2s

2. 手工停止通常不会触发 Restart=on-failure

执行:

sudo systemctl stop demo-worker.service

systemd 会记录这是管理员请求的停止,而不是服务自主失败,因此一般不会因为 Restart=on-failure 再次启动服务。

但如果向进程发送信号:

sudo kill -TERM <pid>

这属于服务进程异常退出路径,可能触发自动重启。诊断时必须区分:

systemctl stop service
kill -TERM pid
kill -KILL pid

它们在 systemd 的状态记录和重启判断中不是同一类操作。

3. RestartSec= 不是故障限流的全部

RestartSec=3s

表示每次重启前等待约 3 秒。若程序立即崩溃,systemd 还会通过启动速率限制防止无限快速重启。

现代 systemd 常见相关设置为:

[Unit]
StartLimitIntervalSec=60s
StartLimitBurst=5

含义可以近似理解为:在 60 秒窗口内,允许最多 5 次启动尝试;超过后,Unit 进入启动限制失败状态。

不同 systemd 版本对默认值和废弃别名可能存在差异。可用以下命令查看实际属性:

systemctl show demo-worker.service \
    -p Restart \
    -p RestartUSec \
    -p StartLimitIntervalUSec \
    -p StartLimitBurst

失败后恢复:

sudo systemctl reset-failed demo-worker.service
sudo systemctl start demo-worker.service

reset-failed 只清除失败计数和失败状态,不会修复错误配置、缺少文件或端口冲突。

4. 重启不是修复故障

如果服务因为配置错误退出:

/etc/my-server.conf: permission denied

配置:

Restart=always

只会不断重复同一个失败过程。此时正确顺序是:

systemctl status my-server.service
journalctl -u my-server.service -b --no-pager
systemctl show my-server.service -p ExecMainStatus -p ExecMainCode

确认错误原因,修复配置或权限,再:

sudo systemctl reset-failed my-server.service
sudo systemctl restart my-server.service

无限重启会掩盖根因,也可能造成日志爆炸、依赖服务级联压力和外部系统重复写入。


5. StartLimitAction= 与恢复动作

可配置启动速率耗尽后的动作,例如:

[Unit]
StartLimitIntervalSec=60s
StartLimitBurst=5
StartLimitAction=none

StartLimitAction= 可以在达到限制后触发特定 systemd 动作,但涉及重启、关机等高风险行为时必须谨慎。生产环境应先确认依赖链、监控告警和远程恢复能力,避免错误配置造成系统不可用。


6. Watchdog 与服务自检

如果服务支持 systemd watchdog,可以配置:

[Service]
Type=notify
WatchdogSec=30s

应用需要通过 sd_notify() 定期发送 WATCHDOG=1。若超过 watchdog 周期没有收到心跳,systemd 会认为服务失去响应,并根据失败策略处理。

仅配置 WatchdogSec= 而应用不发送心跳,会导致服务被判定为失效。watchdog 检查的是应用是否按协议报告存活,不等价于业务健康检查;一个死循环程序也可能持续发送心跳。


七、资源限制:systemd 与 Linux cgroup

1. 为什么资源限制主要依赖 cgroup

Linux 进程级 setrlimit() 可以限制单个进程的文件描述符、地址空间等资源,但服务通常包含多个进程。systemd 使用 cgroup 将 Unit 的进程组织在一起,从而对整个服务组实施限制。

逻辑结构可以表示为:

system.slice/
└── demo-worker.service/
    ├── 主进程
    ├── 工作进程
    └── 子进程

只要进程仍属于该 cgroup,限制通常就对整个服务生效,而不只针对主 PID。

查看 cgroup 和资源信息:

systemctl status demo-worker.service
systemctl show demo-worker.service -p ControlGroup -p MemoryCurrent -p TasksCurrent
systemd-cgls

cgroup v1 与 v2 的内核接口不同,但现代发行版大多已使用或逐步转向 cgroup v2。具体可检查:

stat -fc %T /sys/fs/cgroup

输出 cgroup2fs 通常表示 cgroup v2。


2. 内存限制:MemoryMax=

[Service]
MemoryMax=512M

该限制作用于 Unit cgroup 的内存使用。超过限制时,内核可能在该 cgroup 内触发 OOM 处理,杀死一个或多个进程。服务随后可能被 systemd 视为失败并触发重启。

检查:

systemctl show demo-worker.service \
    -p MemoryCurrent \
    -p MemoryPeak \
    -p MemoryMax \
    -p OOMPolicy

重要边界:

  • 内存限制不是“应用永远只分配 512 MB”这么简单;
  • page cache、匿名内存、子进程等是否计入取决于 cgroup 和内核配置;
  • 内存被杀死后,应用可能丢失未持久化状态;
  • Restart=always 可能使 OOM 后服务不断重启。

OOMPolicy= 可以影响 OOM 后 Unit 的处理方式,但其精确行为与 systemd 版本及 cgroup 机制有关。修改前应在目标系统测试。


3. CPU 限制:CPUQuota=

[Service]
CPUQuota=50%

在单个逻辑 CPU 的视角下,50% 大致表示最多使用半个 CPU 的时间;如果配置为:

CPUQuota=200%

则最多使用约两个逻辑 CPU 的时间。

CPU quota 是时间配额,不是固定绑定某个 CPU。它可能造成延迟、超时和吞吐下降,尤其对低延迟服务影响明显。验证不能只看进程瞬时 CPU 百分比,还应观察:

systemctl show demo-worker.service \
    -p CPUUsageNSec \
    -p CPUQuotaPerSecUSec

CPUWeight= 则是相对权重,适合竞争场景;它不是硬上限。两个服务都没有 quota 时,权重更高的服务在 CPU 竞争中获得更多比例,但空闲 CPU 仍可能被使用。


4. 进程数:TasksMax=

[Service]
TasksMax=256

它限制该 Unit cgroup 中的任务数量。Linux 中线程也计入 task,因此这个限制同时约束进程和线程数量。

超过限制时,创建新线程或进程可能失败,应用常见表现包括:

pthread_create: Resource temporarily unavailable
fork: Resource temporarily unavailable

TasksMax= 能缓解 fork bomb 或线程泄漏造成的局部耗尽,但过小的值也会直接阻断正常工作。设置前应统计应用峰值线程数、worker 数和可能创建的辅助进程。


5. 文件描述符和进程级限制:LimitNOFILE=

[Service]
LimitNOFILE=65536

这类 Limit*= 指令通常对应进程的 resource limit,例如:

systemctl show demo-worker.service -p LimitNOFILE

应用内部还可能有自己的上限,例如数据库连接池、监听连接数或事件循环限制。提高 LimitNOFILE 不会自动提高应用层限制,也不会解决内核内存、端口或磁盘空间问题。


6. 磁盘与 I/O 限制

现代 systemd 可以通过 IOWeight=IOReadBandwidthMax=IOWriteBandwidthMax= 等指令对块设备 I/O 进行控制,但实际效果依赖 cgroup 版本、内核和底层存储设备。

例如:

[Service]
IOWeight=100

这是相对权重,不等于“每秒 100 MB”。带宽限制则需要同时指定设备和速率,设备路径、单位和 systemd 版本都需要在目标机器验证:

[Service]
IOReadBandwidthMax=/dev/nvme0n1 50M

存储限制配置错误可能造成数据库写入延迟、日志积压或超时级联,因此不应只根据测试环境的结果直接复制到生产。


八、安全沙箱:限制服务能看到和能做什么

安全沙箱不是容器,也不是绝对隔离。它主要通过 Linux namespace、mount namespace、能力集、seccomp、文件系统权限和 cgroup 等机制,缩小服务的权限与可见范围。

如果攻击者已经取得服务进程控制权,沙箱的目标是降低其进一步读取敏感文件、加载内核功能、访问其他服务或改变系统状态的能力。

1. 降低身份权限

[Service]
User=demo
Group=demo

服务不应默认以 root 运行。创建专用账户:

sudo useradd --system --no-create-home --shell /usr/sbin/nologin demo

这只限制 Unix UID/GID 权限。root 进程转为普通用户并不自动发生;必须在启动前由 systemd 设置身份。

DynamicUser=yes 可让 systemd 动态分配临时用户:

[Service]
DynamicUser=yes

它适合不需要固定 UID 持久化文件所有权的服务。若服务需要状态目录,应使用 systemd 管理的目录指令:

[Service]
DynamicUser=yes
StateDirectory=demo-worker
CacheDirectory=demo-worker

systemd 会创建相应目录并将其提供给服务。不要让动态用户依赖写死的 UID。


2. 文件系统保护

[Service]
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes

含义分别是:

  • ProtectSystem=strict:把大部分系统文件系统以只读方式提供给服务;
  • ProtectHome=yes:隐藏或限制 /home/root/run/user 等用户目录;
  • PrivateTmp=yes:给服务提供独立的临时目录视图。

如果服务确实需要写入固定目录,可以显式开放:

[Service]
ProtectSystem=strict
ReadWritePaths=/var/lib/demo-worker /var/log/demo-worker

更推荐配合:

[Service]
StateDirectory=demo-worker
LogsDirectory=demo-worker

而不是随意开放整个 /var/.

注意:ReadWritePaths= 只解决文件系统视图的写入范围,不会自动创建任意路径,也不改变普通 Unix 文件权限。实际路径必须存在或由相应目录指令创建。


3. 禁止获取新权限

[Service]
NoNewPrivileges=yes

该设置使进程及其后代不能通过 setuid、文件 capabilities 等方式获得新的特权。它通常适合作为基础防线,但可能影响需要提权的程序。

例如,一个程序即使以普通用户运行,也可能依赖带 capability 的辅助工具;启用 NoNewPrivileges=yes 后,该路径可能失效。


4. Linux capabilities

Linux 将传统 root 权限拆分成多个 capability。可以限制服务已有和可继承的能力:

[Service]
CapabilityBoundingSet=
AmbientCapabilities=

这两个空值表示清空对应集合。若服务需要绑定低端口而不想使用 root,可以考虑:

[Service]
User=demo
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE

但 capability 不是无害权限。CAP_SYS_ADMIN 等能力范围很大,配置时应避免为了“先跑起来”保留过多能力。

查看进程能力:

grep '^Cap' /proc/$(systemctl show -p MainPID --value demo-worker.service)/status

十六进制结果需要结合 capsh --decode= 等工具解析。权限测试应覆盖实际启动、绑定端口、读写目录和信号操作,而不是只检查 Unit 是否启动成功。


5. 限制设备、网络和内核接口

常见设置:

[Service]
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictNamespaces=yes

它们分别用于减少对设备、内核参数、内核模块、cgroup 控制接口和 namespace 创建能力的访问。

网络相关:

[Service]
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6

这表示只允许服务使用 Unix、IPv4 和 IPv6 套接字地址族。若服务需要其他协议族,例如 AF_NETLINK,过度限制可能导致启动后无法获取网络信息。

完全隔离网络:

[Service]
PrivateNetwork=yes

会为服务创建独立网络命名空间。服务可能只看到回环接口,无法访问宿主机网络,除非额外配置网络连接。数据库客户端、HTTP 服务和 DNS 解析通常会因此失效。


6. 系统调用过滤

[Service]
SystemCallArchitectures=native

它限制进程使用的系统调用架构。更严格的 SystemCallFilter= 可以按系统调用集合过滤,但系统调用依赖与程序版本、动态库、JIT、容器运行时密切相关。

不要凭经验直接添加大量过滤规则。错误过滤的典型表现是:

Operation not permitted

而日志中未必明确指出是哪个沙箱设置导致。可以通过:

journalctl -u demo-worker.service -b
systemd-analyze security demo-worker.service

检查风险与失败路径。systemd-analyze security 是分析工具,不是安全证明;评分或暴露项不能替代针对业务行为的测试。


九、一个较完整的加固 Unit

下面的示例假设程序:

  • 不需要 root;
  • 只需要写自己的状态目录;
  • 需要访问网络;
  • 以前台模式运行;
  • 能正确响应 SIGTERM
  • 不需要访问用户主目录、设备和内核模块。
[Unit]
Description=Hardened demo worker
Wants=network-online.target
After=network-online.target

[Service]
Type=exec
ExecStart=/usr/local/libexec/demo-worker

User=demo
Group=demo
DynamicUser=no

StateDirectory=demo-worker
LogsDirectory=demo-worker

Restart=on-failure
RestartSec=5s
TimeoutStopSec=30s

NoNewPrivileges=yes
PrivateTmp=yes
ProtectHome=yes
ProtectSystem=strict
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
PrivateDevices=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6

MemoryMax=512M
TasksMax=128
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target

安装步骤:

sudo install -d -m 0755 /etc/systemd/system
sudo tee /etc/systemd/system/demo-worker.service >/dev/null < hardened-demo-worker.service
sudo systemctl daemon-reload
sudo systemctl enable --now demo-worker.service

验证服务实际看到的环境:

systemctl show demo-worker.service \
    -p User \
    -p Group \
    -p MainPID \
    -p ControlGroup \
    -p MemoryMax \
    -p TasksMax

systemd-analyze security demo-worker.service

这个 Unit 不能直接保证程序安全:

  • 程序仍可能存在远程代码执行漏洞;
  • User=demo 仍可访问 demo 用户有权限访问的文件;
  • 网络访问仍然开放;
  • 服务若拥有应用凭据,攻击者可能使用这些凭据;
  • 沙箱规则可能阻断合法功能,也可能遗漏实际攻击路径。

安全加固必须以程序所需能力为基线逐项收紧,而不是一次性复制高强度模板。


十、日志与故障诊断:从状态到证据

systemd 管理生命周期,日志通常由 journald 收集。Service 的标准输出和标准错误通常会进入 journal,除非 Unit 修改了输出目标。

查询服务日志:

journalctl -u demo-worker.service

只看本次启动:

journalctl -u demo-worker.service -b

持续跟踪:

journalctl -u demo-worker.service -f

查看最近 50 行:

journalctl -u demo-worker.service -n 50 --no-pager

查看 systemd 对主进程的退出判断:

systemctl show demo-worker.service \
    -p ActiveState \
    -p SubState \
    -p Result \
    -p ExecMainCode \
    -p ExecMainStatus \
    -p MainPID

常见字段的诊断意义:

  • Result=success:最近一次动作成功;
  • Result=exit-code:程序以非零退出码退出;
  • Result=signal:被信号终止;
  • Result=timeout:启动、停止或 watchdog 超时;
  • Result=oom-kill:受到 cgroup OOM 处理影响;
  • MainPID=0:当前没有主进程,可能处于 stopped 或 oneshot 完成状态。

服务启动失败的检查顺序可以是:

sudo systemd-analyze verify /etc/systemd/system/demo-worker.service
sudo systemctl daemon-reload
sudo systemctl start demo-worker.service
systemctl status demo-worker.service
journalctl -u demo-worker.service -b --no-pager

如果 Unit 根本没有执行到程序,优先检查:

  • ExecStart 路径是否存在;
  • 文件是否可执行;
  • User= 是否有权限;
  • 工作目录是否存在;
  • 沙箱是否隐藏了所需路径;
  • 端口是否已被占用;
  • 依赖 Unit 是否失败;
  • 资源限制是否过小。

十一、常见误解与反例

误解一:After=network.target 表示网络已经可用

反例:

[Unit]
After=network.target

这只是一条排序关系。它不保证:

  • 网卡已经获得地址;
  • 默认路由已经出现;
  • DNS 已经可用;
  • 远程端口已经可连接。

需要网络管理器提供的在线目标时,通常写:

[Unit]
Wants=network-online.target
After=network-online.target

应用仍应实现连接失败重试,因为启动阶段的“在线”不能证明外部服务在整个生命周期内可用。

误解二:After= 会自动启动对方

反例:

[Unit]
After=redis.service

如果没有 Wants=Requires=,systemd 可能根本不启动 redis.service。排序和拉起是两张不同的关系表。

误解三:Restart=always 能保证高可用

反例是程序配置错误:

unknown option '--prod'

systemd 会重复启动、重复失败。高可用还需要:

  • 正确的依赖和健康检查;
  • 数据持久化;
  • 超时和重试策略;
  • 监控告警;
  • 多实例或故障转移设计;
  • 可恢复的配置发布流程。

误解四:systemctl restart 一定会杀掉所有相关进程

如果 Unit 使用了:

KillMode=process

子进程可能不受完整处理。默认 cgroup 管理通常更可靠,但仍应通过:

systemd-cgls
ps -eo pid,ppid,pgid,sid,cmd

确认实际进程归属。

误解五:限制 MemoryMax= 后程序会优雅地收到错误

内存超过 cgroup 限制时,可能由内核 OOM 机制直接杀死进程。应用不一定有机会保存状态或执行清理逻辑。数据库、队列消费者和文件写入服务尤其需要测试 OOM 后的一致性与恢复行为。

误解六:安全沙箱只要配置得越严格越好

例如:

PrivateNetwork=yes
ProtectSystem=strict
PrivateDevices=yes

可能使依赖网络、设备或写入系统目录的程序立即失效。安全配置应遵循:

  1. 先明确程序需要访问的文件、网络、设备、系统调用和能力;
  2. 对每一项权限进行验证;
  3. 收紧一项后观察日志和功能测试;
  4. 保留可回滚的 drop-in;
  5. 在升级程序和动态库后重新验证。

十二、生产修改的验证与恢复

修改 Unit 后,不要只检查“命令没有报错”。至少验证四个层面:

1. 语法与依赖

sudo systemd-analyze verify /etc/systemd/system/demo-worker.service
systemctl list-dependencies demo-worker.service

2. 运行状态与退出原因

systemctl status demo-worker.service
systemctl show demo-worker.service \
    -p ActiveState -p SubState -p Result \
    -p ExecMainStatus -p MainPID

3. 资源与沙箱是否生效

systemctl show demo-worker.service \
    -p ControlGroup \
    -p MemoryMax \
    -p TasksMax \
    -p User \
    -p NoNewPrivileges

systemd-cgls
systemd-analyze security demo-worker.service

4. 业务功能

至少测试:

  • 正常启动;
  • 依赖服务不可用;
  • 配置错误;
  • SIGTERM 优雅停止;
  • 超时停止;
  • 资源超过限制;
  • 日志是否进入预期位置;
  • 服务重启后状态是否可恢复。

如果修改导致服务无法启动,可立即移除 drop-in:

sudo systemctl revert demo-worker.service
sudo systemctl daemon-reload
sudo systemctl restart demo-worker.service

systemctl revert 会删除管理员对该 Unit 的覆盖配置,使其回到软件包或系统原始配置;使用前应确认确实希望撤销全部本地 drop-in。


十三、几个相关 Unit 的组合

systemd 的能力不局限于直接启动进程。

1. Socket 激活

服务可以由套接字 Unit 先监听端口,收到连接时再启动 Service:

# example.socket
[Unit]
Description=Example socket

[Socket]
ListenStream=127.0.0.1:9000

[Install]
WantedBy=sockets.target
# example.service
[Unit]
Description=Example socket service

[Service]
ExecStart=/usr/local/bin/example-server
Accept=no

这种模式的关键是:套接字由 systemd 持有,Service 通过约定的文件描述符接收它。应用必须支持 socket activation 协议;普通只会自行 bind() 的程序不能直接套用。

2. Timer 替代 cron 的一次性任务

# cleanup.service
[Unit]
Description=Cleanup task

[Service]
Type=oneshot
ExecStart=/usr/local/bin/cleanup
# cleanup.timer
[Unit]
Description=Run cleanup daily

[Timer]
OnCalendar=daily
Persistent=true

[Install]
WantedBy=timers.target

启用:

sudo systemctl daemon-reload
sudo systemctl enable --now cleanup.timer
systemctl list-timers cleanup.timer

Persistent=true 表示如果机器在计划时间关机,重新启动后会尝试补执行一次。任务本身的幂等性必须由程序保证,否则补执行可能造成重复处理。


结语:把 Unit 当作生命周期、依赖和权限的统一边界

一个可靠的 systemd Service 不只是:

ExecStart=/path/to/program

它至少需要明确:

  • 程序以前台还是后台方式运行;
  • 什么状态才算启动成功;
  • 收到终止信号后如何退出;
  • 哪些 Unit 是需求依赖,哪些只是排序关系;
  • 失败、超时和 OOM 后是否重启;
  • 重启是否受到启动速率限制;
  • 整个 cgroup 能使用多少内存、CPU、任务和文件描述符;
  • 服务以哪个身份运行;
  • 能读取、写入、联网和调用哪些系统能力;
  • 日志如何采集,以及故障后如何恢复。

理解这些关系后,systemctl startenablerestart 等命令就不再是孤立的运维操作,而是对一个由 Unit、依赖事务、cgroup 和安全策略共同组成的生命周期模型进行控制。


系列导航与关联阅读

官方资料

本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。