Linux 基础体系 · 第 10/85 篇。示例面向现代主流 Linux 发行版;发行版差异、权限和生产风险会明确说明。
systemd 服务管理:Unit、依赖、重启、资源限制和安全沙箱
systemd 是现代 Linux 发行版中常见的系统与服务管理器。它通常由内核以 PID 1 启动,负责在用户空间早期阶段拉起基础服务,并持续管理服务进程、挂载点、套接字、设备、定时器和其他系统对象。
本文围绕五个核心问题展开:
- Unit 是什么,systemd 如何描述和跟踪服务
- 依赖关系与启动顺序如何建立,为什么
Requires=和After=不是一回事 - 服务失败后何时、为何以及如何重启
- 如何通过 cgroup 和进程限制控制资源
- 如何使用安全沙箱降低服务被攻破后的影响范围
示例面向现代主流 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
这个程序有两个重要特征:
- 主循环持续运行,因此可以观察 Service 的
active (running)状态; - 收到
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 大致会进行以下处理:
- 读取
demo-worker.service; - 解析它直接或间接需要的 Unit;
- 构造一个启动事务;
- 根据依赖关系确定哪些 Unit 必须启动;
- 根据排序关系安排启动顺序;
- 创建或配置对应的 cgroup;
- 设置用户、环境、文件系统视图和资源限制;
- 执行
ExecStart=; - 根据
Type=判断何时认为启动成功; - 持续跟踪该服务的状态和进程。
启动事务可能包含多个 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=exec 与 simple 类似,但更适合区分“进程已创建”和“可执行文件已经成功执行”。如果执行文件不存在等问题导致 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=simple、exec 或 notify。后台化会引入 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
此时含义是:
- 启动当前服务时尝试启动数据库;
- 当前服务排在数据库之后;
- 数据库启动失败时,当前服务的启动事务受到影响。
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
其中实线表示拉起关系,虚线表示排序关系。正确解释是:
multi-user.target拉起example-worker.service;example-worker.service要求启动database.service;- 它还会尝试启动
network-online.target; - 由于
After=,数据库和网络目标若被纳入事务,就应先于应用完成; 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
mask 比 disable 更强。disable 只是不接入默认启动目标,管理员仍可手工 start;mask 通常会让启动请求直接失败。
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
可能使依赖网络、设备或写入系统目录的程序立即失效。安全配置应遵循:
- 先明确程序需要访问的文件、网络、设备、系统调用和能力;
- 对每一项权限进行验证;
- 收紧一项后观察日志和功能测试;
- 保留可回滚的 drop-in;
- 在升级程序和动态库后重新验证。
十二、生产修改的验证与恢复
修改 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 start、enable、restart 等命令就不再是孤立的运维操作,而是对一个由 Unit、依赖事务、cgroup 和安全策略共同组成的生命周期模型进行控制。
系列导航与关联阅读
- 系列入口:Linux 完整学习路线:从内核与文件系统到网络、性能和生产运维
- 上一篇:Linux 进程、线程与信号:生命周期、父子关系、Zombie 和优雅退出
- 下一篇:Bash 与 Shell 工程实践:展开、引用、管道、错误处理和脚本测试
- 延伸:Linux 日志体系:journald、rsyslog、轮转、结构化采集和磁盘治理
官方资料
本文依据 Linux 内核、systemd 与主流发行版官方文档重新梳理;正文与实验由 WR BLOG 编写。

评论
0 条讨论