systemd 单元里几个反复踩到的坑

写 systemd 单元不难,难的是它出问题时症状往往和原因离得很远。下面几条是我反复踩到的,每一条都让我在错误的方向上找过一阵。

服务能跑,手动执行同一条命令却报错

这个最迷惑人。单元文件里写着:

ExecStart=/usr/local/bin/caddy run --config /etc/caddy/Caddyfile

服务好好的。你复制这条命令到终端跑,报配置语法错误。

原因通常是 EnvironmentFile

$ systemctl show caddy -p EnvironmentFiles --value
/etc/foo/secrets/caddy.env (ignore_errors=no)

配置文件里如果用了 {$API_TOKEN} 这类环境变量占位,systemd 启动服务时会先加载这个文件,而你在终端里没有:

$ echo "[${API_TOKEN:-空}]"
[空]

变量展开成空字符串,配置就变成了残缺的语法,于是报出一个和真实原因毫不相干的错误——比如「某指令参数数量不对」。

手动执行前先把环境文件加载进来

set -a
. /etc/foo/secrets/caddy.env
set +a

set -a 让后续赋值自动导出,set +a 关掉。比逐个 export 省事。

用 sudo 执行时 command not found

$ sudo caddy validate --config /etc/caddy/Caddyfile
sudo: caddy: command not found

明明 which caddy 找得到。原因是 sudo 有独立的 secure_path,通常不含 /usr/local/bin。这是个安全设计,防止靠 PATH 劫持提权。

写脚本时别指望 PATH,显式定位:

CADDY_BIN=""
for c in /usr/local/bin/caddy /usr/bin/caddy "$(command -v caddy 2>/dev/null || true)"; do
  [ -n "$c" ] && [ -x "$c" ] && { CADDY_BIN="$c"; break; }
done
[ -n "$CADDY_BIN" ] || { echo "找不到 caddy"; exit 1; }

非 root 服务绑不了 1024 以下的端口

单元里写了 User=caddy,服务要监听 80。启动就失败。

特权端口需要 CAP_NET_BIND_SERVICE。很多人的第一反应是给二进制设文件能力:

setcap cap_net_bind_service=+ep /usr/local/bin/caddy

如果单元里有 NoNewPrivileges=true,这个不会生效。 该选项禁止进程通过 execve 获得任何新特权,文件能力正是其中之一。两者放一起,setcap 白设。

正确做法是让 systemd 直接授予 ambient capability,它在 NoNewPrivileges 生效之前就完成了:

[Service]
AmbientCapabilities=CAP_NET_BIND_SERVICE
CapabilityBoundingSet=CAP_NET_BIND_SERVICE

CapabilityBoundingSet 是上限——把它也收窄成只有这一项,进程就再也拿不到别的 能力了,比不写更安全。

改单元不要直接编辑原文件

发行版或软件包更新会覆盖 /usr/lib/systemd/system/ 下的文件,你的修改就没了。 即使是 /etc/systemd/system/ 下的,直接改也容易在下次排查时忘记自己动过什么。

用 drop-in:

/etc/systemd/system/caddy.service.d/10-bind-privileged-ports.conf
[Service]
AmbientCapabilities=CAP_NET_BIND_SERVICE

systemctl cat 会把主文件和所有 drop-in 按加载顺序列出来,每段前面标着来源路径:

# /etc/systemd/system/caddy.service
...
# /etc/systemd/system/caddy.service.d/10-bind-privileged-ports.conf
AmbientCapabilities=CAP_NET_BIND_SERVICE

一眼就知道哪些是原配置、哪些是自己加的。文件名前缀数字控制顺序,留出间隔 (10-20-)方便以后插队。

注意:大多数指令是覆盖,但列表类指令(ExecStartEnvironment 等)是 追加。要清空再设,得先写一行空值:

[Service]
ExecStart=
ExecStart=/usr/local/bin/newbinary

想知道最终生效的值,别去读文件

配置分散在主文件加若干 drop-in 里,人工合并容易看漏。直接问 systemd:

$ systemctl show caddy -p AmbientCapabilities -p Restart -p User --value
on-failure
cap_net_bind_service
caddy

-p 可以叠加,--value 去掉 KEY= 前缀方便脚本消费。这是合并后的最终值, 包含 drop-in 的效果。

改了能力相关配置,reload 不管用

systemctl reload caddy    # 不够
systemctl restart caddy   # 需要这个

reload 走的是单元里的 ExecReload,通常只是让进程重读配置文件——进程本身 没有重启,能力集是进程创建时确定的,改不了。

判断标准:改的是应用配置就 reload,改的是单元文件就 daemon-reload + restart。

顺带一提,改完单元文件必须先 systemctl daemon-reload,否则 systemd 还在用 内存里的旧版本,你会看到「改了没反应」。

崩溃循环时 is-active 会骗你

$ systemctl is-active caddy
activating

看着像在启动中,其实可能是崩溃后正在自动重启。看这两个字段:

$ systemctl show caddy -p SubState -p NRestarts --value
auto-restart
9

SubState=auto-restart 加上 NRestarts 持续增长,就是崩溃循环。Restart=always 会让它无限重试,日志刷得飞快但服务始终不可用。

排查时先看失败原因,它保留在 Status 里:

systemctl status caddy --no-pager -n 20

Status: 那一行往往就是应用自己报出来的具体错误,比翻日志快。

Restart 策略要按服务性质选

Restart=no           默认,退出就不管
Restart=on-failure   非零退出码或被信号杀死才重启
Restart=always       无论如何都重启,包括正常退出

守护进程用 on-failure 通常够了。always 适合那种「本来就该一直跑,正常退出 也算异常」的常驻循环脚本。

always 时记得:

Restart=always
RestartSec=5
StartLimitIntervalSec=0

RestartSec 避免疯狂重启打满 CPU;StartLimitIntervalSec=0 关掉启动频率限制, 否则短时间内重启太多次会被 systemd 直接放弃,进入 failed 状态不再拉起—— 对一个你希望它永远活着的服务来说,这个默认行为反而有害。

一条经验

服务起不来时,先问三个问题再动 ExecStart

  1. 它以谁的身份跑?(User=
  2. 它能看到什么环境变量?(EnvironmentFile=Environment=
  3. 它有什么权限?(AmbientCapabilities=NoNewPrivileges=

我踩过的坑里,十有八九答案在这三个问题里,而不在命令本身。