一、目标、边界与最终架构
本文把一个普通的 Linux Web 服务改造成可审计、可限额、可恢复的 systemd 生产单元。示例服务名为 report-api,监听 127.0.0.1:9100,由反向代理对外提供访问。你将完成专用身份、只读文件系统、最小写目录、能力集收缩、资源配额、健康检查、日志定位和安全评分,而不是只写一个“能启动”的 unit。
Client -> Reverse Proxy -> 127.0.0.1:9100
|
report-api.service
|-- DynamicUser
|-- ReadOnlyPaths
|-- StateDirectory
|-- MemoryMax / CPUQuota
|-- Restart policy
`-- journald + systemd-analyze security
适用前提:发行版使用 systemd,应用可以前台进程运行,配置与密钥能从程序目录分离。不要把本文的限制一次性复制到生产;先在预发布环境逐项启用,并为每次失败保留日志证据。
二、准备可回滚的服务目录
先把不可变程序、配置、状态和日志分开。程序放在 /opt/report-api,配置放在 /etc/report-api,运行时状态交给 systemd 创建。这样 ProtectSystem=strict 后仍能明确授权写入位置。
sudo install -d -m 0755 /opt/report-api/bin
sudo install -d -m 0750 /etc/report-api
sudo install -m 0755 ./report-api /opt/report-api/bin/report-api
sudo install -m 0640 ./config.yaml /etc/report-api/config.yaml
sha256sum /opt/report-api/bin/report-api /etc/report-api/config.yaml
建立一个不含真实密钥的环境文件模板。生产密钥应由凭据系统、LoadCredential= 或受控挂载提供,而不是提交到 Git。
# /etc/report-api/report-api.env
APP_ENV=production
HTTP_ADDR=127.0.0.1:9100
LOG_FORMAT=json
sudo chown root:root /etc/report-api/report-api.env
sudo chmod 0640 /etc/report-api/report-api.env
sudo cp -a /etc/report-api /etc/report-api.rollback.$(date +%Y%m%d%H%M%S)
三、先写最小可运行单元
先用最少指令确认启动命令、工作目录和停止语义正确,再加入沙箱。Type=simple 要求主进程保持前台运行;如果应用自行 fork,应改用更合适的类型或关闭 daemonize。
# /etc/systemd/system/report-api.service
[Unit]
Description=Report API service
Documentation=https://docs.example.invalid/report-api/runbook
After=network-online.target
Wants=network-online.target
StartLimitIntervalSec=60
StartLimitBurst=5
[Service]
Type=simple
WorkingDirectory=/opt/report-api
EnvironmentFile=/etc/report-api/report-api.env
ExecStart=/opt/report-api/bin/report-api --config /etc/report-api/config.yaml
ExecReload=/bin/kill -HUP $MAINPID
Restart=on-failure
RestartSec=3s
TimeoutStartSec=30s
TimeoutStopSec=20s
KillSignal=SIGTERM
SyslogIdentifier=report-api
[Install]
WantedBy=multi-user.target
sudo systemd-analyze verify /etc/systemd/system/report-api.service
sudo systemctl daemon-reload
sudo systemctl enable --now report-api.service
systemctl show report-api.service -p MainPID -p ActiveState -p SubState
curl --fail --silent http://127.0.0.1:9100/healthz
若这里失败,不要继续叠加安全参数。先用 systemctl status --no-pager -l 和 journalctl -u report-api -b 修正入口命令、权限或端口。
四、用 DynamicUser 消除长期系统账户
DynamicUser=yes 会为服务分配临时 UID/GID,停止后身份消失,适合不需要固定属主的守护进程。配合 StateDirectory=、CacheDirectory= 和 RuntimeDirectory=,systemd 会创建并授权持久状态、缓存和运行时目录。
[Service]
DynamicUser=yes
StateDirectory=report-api
StateDirectoryMode=0750
CacheDirectory=report-api
CacheDirectoryMode=0750
RuntimeDirectory=report-api
RuntimeDirectoryMode=0750
UMask=0027
应用内路径应改为:持久状态 /var/lib/report-api、缓存 /var/cache/report-api、PID 或 Unix Socket /run/report-api。不要再让程序写 /opt/report-api。验证动态身份和目录:
sudo systemctl restart report-api
pid=$(systemctl show -p MainPID --value report-api)
ps -o pid,user,group,cmd -p "$pid"
namei -l /var/lib/report-api /var/cache/report-api /run/report-api
sudo -u nobody test -w /var/lib/report-api && echo unexpected || echo isolated
如果外部任务必须以固定 UID 访问这些文件,不要强行使用 DynamicUser;改建 SystemUser,并把跨服务共享目录和访问组写入权限契约。
五、收紧文件系统与临时目录
文件系统授权原则
先开启风险较低的隔离,再观察应用实际访问。ProtectSystem=strict 把大部分文件系统设为只读;ReadWritePaths= 只为必要目录开洞。PrivateTmp=yes 为服务提供独立 /tmp,防止与其他服务通过临时文件互相影响。
[Service]
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ReadOnlyPaths=/etc/report-api
ReadWritePaths=/var/lib/report-api /var/cache/report-api /run/report-api
InaccessiblePaths=/boot /media /mnt
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectKernelLogs=yes
ProtectControlGroups=yes
ProtectClock=yes
使用临时 drop-in 逐步验证,避免编辑主文件后难以回滚:
sudo systemctl edit report-api.service
sudo systemd-analyze verify /etc/systemd/system/report-api.service \
/etc/systemd/system/report-api.service.d/override.conf
sudo systemctl daemon-reload
sudo systemctl restart report-api
journalctl -u report-api -b --since '-2 min' --no-pager
出现 Read-only file system 时,先定位程序真正需要写入的路径。优先修改应用路径或增加专用目录,不要用宽泛的 ReadWritePaths=/ 让整个沙箱失效。
六、限制内核接口、权限和系统调用
网络服务通常不需要提权、加载内核模块或创建新命名空间。以下配置是安全起点,但 SystemCallFilter= 与应用运行时关系紧密,必须通过预发布验证。
[Service]
NoNewPrivileges=yes
CapabilityBoundingSet=
AmbientCapabilities=
RestrictSUIDSGID=yes
LockPersonality=yes
RestrictRealtime=yes
RestrictNamespaces=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6
SystemCallArchitectures=native
SystemCallFilter=@system-service
SystemCallErrorNumber=EPERM
MemoryDenyWriteExecute=yes
如果服务要绑定 1024 以下端口,不要以 root 运行整个进程,只添加 CAP_NET_BIND_SERVICE;更好的方案是让反向代理监听公网端口,应用仍监听高位回环端口。
CapabilityBoundingSet=CAP_NET_BIND_SERVICE
AmbientCapabilities=CAP_NET_BIND_SERVICE
JIT、某些数据库驱动或语言运行时可能需要可写后可执行内存,此时 MemoryDenyWriteExecute=yes 会启动失败。必须基于 journalctl 与系统调用证据做例外,而不是看到失败就删除全部加固项。
七、用 cgroup 设置资源护栏
软压力线与硬上限
资源限制的目标不是“越小越安全”,而是让单个实例失控时不拖垮整机。先从监控数据确定正常峰值,再预留容量。示例限制 1.5 个 CPU、768 MiB 内存和 4096 个任务。
[Service]
CPUAccounting=yes
CPUQuota=150%
CPUWeight=100
MemoryAccounting=yes
MemoryHigh=640M
MemoryMax=768M
MemorySwapMax=128M
TasksAccounting=yes
TasksMax=4096
LimitNOFILE=65536
OOMPolicy=stop
MemoryHigh 是主动回收的软压力线,MemoryMax 是硬上限。不要只设 MemoryMax 而不监控高水位,否则只能在 OOM 后才知道容量不足。
systemctl show report-api -p CPUQuotaPerSecUSec -p MemoryHigh -p MemoryMax -p TasksMax
systemd-cgtop --depth=2
systemctl status report-api --no-pager
cat /sys/fs/cgroup/system.slice/report-api.service/memory.current
cat /sys/fs/cgroup/system.slice/report-api.service/memory.events
压测时同步记录吞吐、P95/P99、错误率、内存高水位和重启次数。若延迟先升高而 CPU 已触顶,应该扩容或优化,而不是盲目提高配额。
八、重启、启动抑制与优雅退出
Restart=always 会掩盖配置错误并形成热循环。生产服务更适合 on-failure,配合 StartLimitBurst 抑制连续失败。应用必须处理 SIGTERM:停止接收新请求、等待进行中请求、刷新缓冲区,然后在 TimeoutStopSec 内退出。
sudo systemctl kill --signal=SIGTERM report-api
sleep 2
systemctl show report-api -p NRestarts -p ExecMainStatus -p Result
journalctl -u report-api -b -n 80 --no-pager
若应用支持 watchdog,可加入 WatchdogSec=30s 并周期调用 sd_notify("WATCHDOG=1")。不要把 HTTP 探活脚本塞进 ExecStartPost 当持续健康检查;它只在启动阶段执行一次。
九、日志、诊断与安全评分
结构化日志写 stdout/stderr,由 journald 收集。日志内容要包含 request_id、route、status、latency_ms 和 error_code,但不得记录口令、Token、完整 Cookie 或敏感请求体。
journalctl -u report-api -f -o short-iso
journalctl -u report-api --since today -p warning..alert --no-pager
journalctl -u report-api -o json --since '-10 min' | jq -r '.MESSAGE'
systemd-analyze security report-api.service
systemd-analyze critical-chain report-api.service
安全评分是检查清单,不是绝对风险分。比如纯网络服务关闭设备访问很合理,但需要 GPU 的推理服务就必须精确放行设备。每一个未启用项都应在运行手册写明原因。
十、故障排查顺序
从配置到运行时逐层定位
systemd-analyze verify:先发现语法、路径和依赖错误。systemctl status -l:确认退出码、主进程和最近日志。journalctl -u ... -b:定位权限、只读路径、系统调用和 OOM。systemctl show:确认合并后的有效属性,而非只看源文件。- 临时回退单个 drop-in:一次只改变一个变量,证明因果关系。
systemctl cat report-api.service
systemctl show report-api -p FragmentPath -p DropInPaths -p Environment
sudo SYSTEMD_LOG_LEVEL=debug /usr/bin/systemd-analyze verify \
/etc/systemd/system/report-api.service
sudo cp -a /etc/systemd/system/report-api.service.d \
/root/report-api-dropins.$(date +%s)
典型错误映射:status=203/EXEC 检查可执行文件与解释器;status=200/CHDIR 检查工作目录;EPERM 检查 capability、系统调用和命名空间;SIGKILL 同时检查 memory.events 与内核日志。
十一、发布、回滚与验收清单
发布前先验证新二进制哈希与配置,然后原子替换程序文件,执行 daemon-reload 和 restart。回滚必须同时覆盖二进制、配置与 unit drop-in,不能只换其中一项。
sudo install -m 0755 ./report-api.new /opt/report-api/bin/report-api.new
sudo /opt/report-api/bin/report-api.new --config /etc/report-api/config.yaml --check
sudo mv /opt/report-api/bin/report-api /opt/report-api/bin/report-api.prev
sudo mv /opt/report-api/bin/report-api.new /opt/report-api/bin/report-api
sudo systemctl daemon-reload
sudo systemctl restart report-api
curl --fail http://127.0.0.1:9100/readyz
验收必须同时满足:服务连续稳定运行;健康检查通过;非授权路径不可写;动态用户和目录属主正确;CPU、内存与任务上限生效;SIGTERM 能优雅退出;连续崩溃触发启动抑制;日志没有秘密;安全评分中的剩余暴露已有书面理由。
十二、官方资料与继续验证
- systemd
systemd.exec:执行环境、目录、身份和沙箱指令。 - systemd
systemd.resource-control:cgroup v2 的 CPU、内存、任务与 I/O 控制。 - systemd
systemd.service:服务类型、重启、超时和退出语义。 - systemd
systemd-analyze:unit 校验、启动链和安全分析。
最终原则:先证明服务可运行,再一次启用一个限制;所有例外都要由日志、系统调用或容量数据支持。这样得到的不是一份“看起来很安全”的 unit,而是一套可复测、可回滚的生产运行契约。