systemd 起一个静态文件服务:unit 文件逐行说明

举报
yd_282015185 发表于 2026/10/28 07:27:33 2026/10/28
【摘要】 一个 30 行的 unit 文件里几乎每行都能做错:ExecStart 不走 shell、环境变量不继承、After 与 Wants 分工不同、改完不 daemon-reload 等于没改、start-limit-hit 只是结果不是原因、ProtectSystem=strict 之后不放开路径就写不了文件。逐段拆解并给出六个真实坑与上线验证清单。

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 都可能是空的或极简的默认值。这导致两类问题:

  1. 命令找不到(status=203/EXEC,见后文坑 1)
  2. 程序里 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。三个选择:

  1. 加 AmbientCapabilities=CAP_NET_BIND_SERVICE(推荐,只给绑定低端口的权限,不给 root)
  2. 用 systemd socket 激活
  3. 前面放 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-failed
  • ProtectSystem=strict 之后要 ReadWritePaths,否则写文件一律 permission denied
  • 写完重启机器验一次,这是唯一能验证「开机自启」的方法

一个 30 行的 unit 文件,把上面这些写对了,就等于把一个「需要有人盯着」的临时服务变成了「没人管也不会出事」的系统组件。这大概是用最少字符换最多稳定性的一次投入。

【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。