systemd 起一个静态文件服务:unit 文件逐行说明
systemd 起一个静态文件服务:unit 文件逐行说明
很多人第一次写 systemd unit 都是照抄一段 ExecStart 就上线了,能起来就算完。等到某天机器重启后服务没了、或者服务跑了几天突然 start-limit-hit、又或者日志里只有一行 status=203/EXEC 看不懂的时候,才发现那份 unit 文件里几乎每一行都是可以做错的地方。
这篇拿「用 systemd 托管一个静态文件服务」这个最小场景,把一个 unit 文件从 [Unit] 到 [Install] 逐行拆开讲清楚,包括每一行为什么要写、不写会怎样、以及六个真实会踩的坑。
一、为什么不用 nohup / screen / pm2
静态文件服务用 python3 -m http.server、caddy file-server、npx serve 都能起,临时用完全够了。但只要满足下面任意一条,就该交给 systemd:
- 需要开机自启,而不是等人登录后手动起
- 需要崩溃后自动拉起,且不能无限重启把机器打爆
- 需要统一日志出口(
journalctl),而不是散落在几个nohup.out里 - 需要受限于某个系统用户、限制它能碰哪些目录
- 机器是别人在运维,没有你的 shell 会话
nohup ... & 的本质是「挂在一个会退出的 shell 上」,shell 一走、SIGHUP 一来就断;screen 只是把会话留着,但开机不会自己回来,崩溃也不会自己重启。这些是「有人看着」的方案,systemd 是「没人看着」的方案。
二、完整 unit 文件
以 /opt/static-site 目录、用 Caddy 提供静态文件、监听 8080 端口为例,放到 /etc/systemd/system/static-site.service:
[Unit]
Description=Static site server on :8080
Documentation=man:caddy(8)
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
User=www-static
Group=www-static
WorkingDirectory=/opt/static-site
Environment=HOME=/var/lib/static-site
EnvironmentFile=-/etc/static-site/env
ExecStartPre=/usr/bin/test -d /opt/static-site
ExecStart=/usr/local/bin/caddy file-server --root /opt/static-site --listen :8080
ExecReload=/bin/kill -USR1 $MAINPID
Restart=on-failure
RestartSec=3
TimeoutStopSec=15
LimitNOFILE=65535
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
ReadWritePaths=/var/lib/static-site
StandardOutput=journal
StandardError=journal
SyslogIdentifier=static-site
[Install]
WantedBy=multi-user.target
下面逐段说明。
三、[Unit] 段:启动顺序与依赖
Description
出现在 systemctl status 和 journalctl 里的可读描述。看起来无关紧要,但排障时一堆 service 里能一眼认出是哪个,很值。
After=network-online.target 与 Wants=network-online.target
这两个必须成对出现,且含义完全不同:
After=只规定顺序:如果network-online.target也被启动了,就等它完成再启动我。Wants=规定依赖:请把network-online.target也启动起来。
只写 After= 的经典症状是:机器重启后服务起来了,但绑 :8080 时网络栈还没就绪,日志报 bind: cannot assign requested address,几秒后 Restart 把它拉起来又正常了——所以看起来「偶尔开机失败、手动重启就好」。
注意 network.target 和 network-online.target 的区别:前者只表示网络管理服务已启动,网卡可能还没有 IP;后者才是「网络真的可用了」。监听具体端口的服务要等后者。前提是 systemd-networkd-wait-online 或 NetworkManager 的 wait-online 已启用,否则 network-online.target 会被立即判定为完成,等于没等。
四、[Service] 段:进程本身
Type=simple
表示 ExecStart 启动的进程自己就是主进程,systemd 立刻认为启动成功。绝大多数前台运行的服务(Caddy、nginx 前台模式、gunicorn、node)都是 simple。
三个容易混的类型:
forking:进程自己 daemonize 后父进程退出,systemd 靠PIDFile=找主进程。老式守护进程用这个,配错了会表现为「启动成功但systemctl stop停不掉」。notify:进程通过sd_notify()主动告诉 systemd「我准备好了」。最准确,但要求程序支持。oneshot:跑完就退出,常配合RemainAfterExit=yes用于「执行一次初始化」。
User= / Group=
强烈建议写上,不要用 root 跑静态服务。这是整个文件里性价比最高的一行:一个只读文件的 HTTP 服务被拿下,攻击者拿到的就是一个 nologin 用户的权限。
创建这类用户用:
sudo useradd --system --no-create-home --shell /usr/sbin/nologin www-static
WorkingDirectory=
设置进程的当前目录。必须写绝对路径。相对路径在这里是未定义行为,而更隐蔽的问题是:程序里所有相对路径(配置、日志、缓存)都会以它为基准,不写就是 /,于是「本地跑得好好的,部署上去就找不到文件」。
Environment= 与 EnvironmentFile=
单元里的进程不继承你 shell 的环境变量。PATH、HOME、LANG 都可能是空的或极简的默认值。这导致两类问题:
- 命令找不到(
status=203/EXEC,见后文坑 1) - 程序里
os.path.expanduser("~")得到/或报错,因为HOME没设
Environment= 适合少量固定的值;EnvironmentFile= 适合放密钥、端口这类按环境变化的内容。文件格式是每行 KEY=VALUE,不加引号、不加 export。路径前的 - 表示「文件不存在也不报错」,适合可选配置文件。
⚠️ EnvironmentFile 里的值不会做 shell 展开:PATH=/opt/bin:$PATH 会被当成字面量,$PATH 原样保留。要追加 PATH 得写全量路径。
ExecStartPre=
主命令之前跑的检查。/usr/bin/test -d /opt/static-site 这种写法很土但有效:目录不在就启动失败,systemctl status 里直接能看到是前置检查挂了,而不是让主进程报一个含糊的「文件未找到」。
ExecStartPre 失败会阻止 ExecStart 执行,且多次出现的 ExecStartPre 按顺序执行;前面加 - 前缀(如 -ExecStartPre=)表示忽略失败。
ExecStart=
最重要的限制:必须是绝对路径,且不经过 shell。
这意味着这些写法全部无效:
ExecStart=/usr/local/bin/app --flag > /var/log/app.log # 重定向不被解析
ExecStart=cd /opt/app && ./start.sh # && 不被解析
ExecStart=/usr/local/bin/app --port $PORT # $PORT 不会展开
需要 shell 特性时,显式调用 shell:
ExecStart=/bin/sh -c 'exec /usr/local/bin/app --port "$PORT" > /var/log/app.log 2>&1'
ExecReload=
systemctl reload 执行什么。对 Caddy 这类支持平滑重载的程序,kill -USR1 $MAINPID 比 restart 好得多——静态站点重载期间连接不断。注意 $MAINPID 是 systemd 支持的变量,可以放心用。
Restart=on-failure 与 RestartSec=3
on-failure 表示「非零退出、被信号杀死、超时」时重启,正常退出(exit 0)不重启。这个语义对静态服务是对的:你主动 systemctl stop 或程序正常收尾,就不该再被拉起来。
几个取值的实际差别:
| 取值 | 行为 | 适用 |
|---|---|---|
no |
从不重启(默认) | 一次性任务 |
on-failure |
异常退出才重启 | 绝大多数服务 |
always |
任何退出都重启 | 必须常驻、退出即异常的进程 |
on-abnormal |
仅信号/超时/看门狗 | 希望正常退出后保持停止 |
RestartSec= 是重启前等多久。不写默认 100ms,配上必然失败的配置(比如端口被占),会在几秒内把 DefaultStartLimitBurst=5 打满,进 failed 状态并报 start-limit-hit,反而掩盖了真正的错误。给 3~5 秒既避开打满,也够你看日志。
TimeoutStopSec=
systemctl stop 先发 SIGTERM,等这么久还没退再发 SIGKILL。默认 90 秒,对静态服务太长;15 秒足够让在途请求收尾,也不至于让部署脚本卡住。
LimitNOFILE=
打开文件数上限。默认值(常见 1024)对高并发静态服务很容易打满,症状是 too many open files,而 ulimit -n 在 shell 里看是另一个数——因为systemd 托管的进程不读 /etc/security/limits.conf,只认 unit 里的 LimitNOFILE / LimitCORE 等指令。这一点是排查「明明调过 limit 还是不够」的关键。
安全加固五行
NoNewPrivileges=true # 进程及其子进程不能提权(禁 setuid)
PrivateTmp=true # 独立的 /tmp 与 /var/tmp,互相看不见
ProtectSystem=strict # 整个文件系统只读,只有下方列出的可写
ProtectHome=true # /home、/root 不可见
ReadWritePaths=/var/lib/static-site # 白名单里放开可写
ProtectSystem=strict 会让 /usr、/boot、/etc、/ 全部只读,程序如果需要写缓存、写 pid、写上传目录,必须用 ReadWritePaths= 明确放开,否则表现为「启动正常但一写文件就 permission denied」,而用 root 手动跑又是好的——因为手动跑没有这层限制。这个坑非常容易让人怀疑人生。
StandardOutput / StandardError 与 SyslogIdentifier
默认就是进 journal,写出来只是显式声明。真正有用的是 SyslogIdentifier=:它决定 journalctl -t static-site 能否按名字过滤。不写的话标识符会取可执行文件名(这里是 caddy),多个 caddy 实例就分不开了。
五、[Install] 段:开机自启
WantedBy=multi-user.target
这行的作用是:当你执行 systemctl enable static-site 时,systemd 在 multi-user.target.wants/ 目录下建一个指向本 unit 的软链接,从而在进入多用户模式时把它拉起。
关键点:[Install] 段与「现在启动」无关。 写完 unit 文件只做 systemctl start,重启机器后服务不会自己回来。必须:
sudo systemctl daemon-reload # 让 systemd 重新读取 unit 文件
sudo systemctl enable --now static-site
enable --now = enable + start,是最常用的一条。但注意:改了 unit 文件之后只 restart 不 daemon-reload,改动不会生效,systemd 用的是内存里的旧版本。
六、六个真实会踩的坑
坑 1:status=203/EXEC
日志里只有:
static-site.service: Failed at step EXEC spawning /usr/local/bin/caddy: No such file or directory
Main process exited, code=exited, status=203/EXEC
字面意思是「找不到文件」,但它也可能是「解释器找不到」。比如 ExecStart=/opt/app/server.py(靠 shebang #!/usr/bin/env python3 执行),而 unit 里 PATH 不含 python 所在目录,env 就找不到 python3,内核同样报 ENOENT,指向的却是脚本本身。
排查顺序:
ls -l /usr/local/bin/caddy # 文件在不在、有没有 +x
/usr/local/bin/caddy --version # 能不能直接执行
systemctl show static-site -p Environment # 看实际生效的环境变量
如果是 shebang 问题,两个解法:ExecStart=/usr/bin/python3 /opt/app/server.py(显式解释器,最稳),或在 unit 里补 Environment=PATH=/usr/local/bin:/usr/bin:/bin。
坑 2:start-limit-hit
static-site.service: Start request repeated too quickly.
static-site.service: Failed with result 'start-limit'.
这是结果不是原因。真正的错误在更早的日志里,被这行盖住了。看完整时间线:
journalctl -u static-site -n 200 --no-pager
常见根因:端口被占(上一个实例没退干净)、配置文件语法错误、ReadWritePaths 里的目录不存在。定位并修好后:
sudo systemctl reset-failed static-site
sudo systemctl start static-site
reset-failed 是必须的——进入 failed 状态后直接 start 会因为计数没清零而再次 start-limit-hit。
坑 3:ExecStart 里写了 shell 语法
现象:服务启动失败,日志说 No such file or directory,但你复制粘贴到终端里跑完全正常。原因就是前文说的:>、&&、$VAR、管道全都不被解析,systemd 会把它们当成参数传给程序。
如果程序不认识这些参数就会报错;如果它默默忽略,你会得到一个「启动了但没有日志文件」的服务——因为重定向从来没发生过,输出全在 journal 里。
坑 4:端口小于 1024
非 root 用户绑 80/443 会得到 permission denied。三个选择:
- 加
AmbientCapabilities=CAP_NET_BIND_SERVICE(推荐,只给绑定低端口的权限,不给 root) - 用 systemd socket 激活
- 前面放 nginx 反代,服务本身监听 8080
注意 CapabilityBoundingSet= 如果被显式收窄过,会连带把 CAP_NET_BIND_SERVICE 一起去掉,这时要两个都写。
坑 5:reload 与 restart 混淆
systemctl reload 只发信号(走 ExecReload=),不重读 unit 文件、不重建进程。改了 unit 文件必须 daemon-reload + restart。改了程序配置文件则可以 reload。
排查「配置改了没生效」时,先确认自己用的是哪一个:
systemctl show static-site -p ExecMainStartTimestamp # 进程什么时候起来的
stat -c %y /etc/systemd/system/static-site.service # unit 文件什么时候改的
如果文件修改时间晚于进程启动时间,就是这个原因。
坑 6:停服务停不掉、要等 90 秒
systemctl stop 卡住,最后 Job for static-site.service canceled 或等满超时。原因通常是主进程忽略 SIGTERM,或者启动了子进程而子进程没被 KillMode 覆盖。
默认 KillMode=control-group 会杀掉 cgroup 里所有进程,一般够用。如果程序自己 fork 了守护进程且脱离了 cgroup(少见),才需要考虑 KillMode=mixed 或 KillSignal=。先把 TimeoutStopSec 调小,能让部署脚本不至于干等。
七、上线后的验证清单
写完后按这个顺序走一遍,比出了事再查快得多:
# 1. 语法与语义检查(不启动)
sudo systemd-analyze verify /etc/systemd/system/static-site.service
# 2. 加载并启动
sudo systemctl daemon-reload
sudo systemctl enable --now static-site
# 3. 看状态,注意 Active 与下面几行 detail
systemctl status static-site --no-pager
# 4. 看日志(-f 跟踪)
journalctl -u static-site -n 50 --no-pager
# 5. 验证实际监听
ss -tlnp | grep 8080
# 6. 验证它跑在哪个用户下、有哪些限制
systemctl show static-site -p User -p LimitNOFILE -p ProtectSystem
# 7. 最后一定要做的:重启机器验证一次
sudo systemctl reboot
第 7 步最容易被跳过,也正是最容易出问题的一步——开机顺序、网络就绪、只读文件系统、EnvironmentFile 缺失,这些问题只有真正重启一次才会暴露。
八、小结
几个可以带走的原则:
ExecStart不是 shell,要 shell 特性就显式/bin/sh -c- 环境变量不继承,
PATH、HOME都当它不存在,该设就设 After管顺序、Wants管依赖,监听端口的服务两个都要- 改了 unit 必须
daemon-reload,restart不会重读文件 start-limit-hit是结果,往上翻日志找真正的原因,修完要reset-failedProtectSystem=strict之后要ReadWritePaths,否则写文件一律 permission denied- 写完重启机器验一次,这是唯一能验证「开机自启」的方法
一个 30 行的 unit 文件,把上面这些写对了,就等于把一个「需要有人盯着」的临时服务变成了「没人管也不会出事」的系统组件。这大概是用最少字符换最多稳定性的一次投入。
- 点赞
- 收藏
- 关注作者
评论(0)