写 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-)方便以后插队。
注意:大多数指令是覆盖,但列表类指令(ExecStart、Environment 等)是
追加。要清空再设,得先写一行空值:
[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:
- 它以谁的身份跑?(
User=) - 它能看到什么环境变量?(
EnvironmentFile=、Environment=) - 它有什么权限?(
AmbientCapabilities=、NoNewPrivileges=)
我踩过的坑里,十有八九答案在这三个问题里,而不在命令本身。