⏰ 第03章 · 自定义 Unit

本服务器 Unit 解析(WorkingDirectory/Env/Restart)

自定义 Unit

前两章用的是现成服务;这一章动手写一个自己的 unit 文件。你会看到:为什么你的 Node 服务要在 /var/www/bangdream 目录下跑、环境变量怎么注入、宕机后怎么自动拉起来。学完你能把任意一个"手动跑的命令"变成"开机自启 + 崩溃自愈"的守护服务。

unit 文件放在哪

Systemd 会按固定优先级从几个目录找 unit 文件,后面的覆盖前面:

目录 谁放进去 优先级
/etc/systemd/system/ 管理员自定义(你改的重点) 最高
/lib/systemd/system/ 软件包安装时的默认文件 基础
/run/systemd/system/ 运行时临时生成 较特殊
ls -l /etc/systemd/system/*.service          # 看你自定义的服务
ls -l /lib/systemd/system/ | grep mysql      # 看包自带的服务

易错 自己写的 unit 永远放 /etc/systemd/system/。别去改 /lib/systemd/system/ 里的包文件——apt 升级会把你改的冲掉。要覆盖,就复制到 /etc/systemd/system/ 里同名改名。

unit 文件的三大段

一个 .service 文件分为三个段,顺序固定:

[Unit]       ; 描述 + 启动顺序依赖
[Service]    ; 核心:谁来跑、在哪跑、崩溃怎么办
[Install]    ; 开机自启挂到哪个 target

一个最小可用的例子

先看一个能直接跑起来的 unit,以这台机器上的 bangdream.service 为蓝本:

[Unit]
Description=BanG Dream! Character Database API Server
After=network.target mysql.service

[Service]
Type=simple
WorkingDirectory=/var/www/bangdream
ExecStart=/usr/local/bin/node server.js
Restart=always
RestartSec=3
Environment=NODE_ENV=production
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

下面逐个字段讲清楚。

逐个字段解析

[Unit] 段:

字段 作用 本例值
Description 状态显示名,status 里第一行 BanG Dream! ...
After 启动顺序:排在谁后面 network.target mysql.service
Requires/Wants 强/弱依赖(第 4 章细讲) —
Documentation 主页链接 https://github.com/xtls

[Service] 段:

字段 作用 本例值
Type= 如何判断"启动完成"(第 4 章) simple
WorkingDirectory= 运行目录:进程 cwd /var/www/bangdream
ExecStart= 启动命令,建议绝对路径 /usr/local/bin/node server.js
Restart= 崩溃后的重启策略 always
RestartSec= 重启前等待秒数 3
Environment= 注入环境变量(可多条) NODE_ENV=production
StandardOutput= / StandardError= 输出去哪 journal

[Install] 段:

字段 作用 本例值
WantedBy= 挂到哪个 target 实现自启 multi-user.target

WorkingDirectory:为什么服务要在指定目录跑

很多程序会相对路径找资源(比如读取 ./db.sqlite、./static/)。如果 systemd 不指定目录,进程会用默认 cwd(通常 /),相对路径就全错。

WorkingDirectory=/var/www/bangdream 一写,进程的 ls、读配置文件、找静态文件都以这个目录为根,和你在 /var/www/bangdream 手动 node server.js 的效果一致。

cat /proc/$(pgrep -f "node server.js" | head -1)/cwd   # 看真实进程的 cwd
# 输出应为 /var/www/bangdream

易错 每个服务的 WorkingDirectory 都不同。bangdream 用 /var/www/bangdream,dsh 用 /var/www/dsh。写错目录,服务"看起来启动了"却立刻因为找不到文件而退出——而且 status 只显示 exit 码,真相要看 journalctl(第 5 章)。

Environment:给服务注入环境变量

Node/Python 程序常靠环境变量区分生产/开发、嵌入密钥。Environment= 每行一个,可写多条:

Environment=NODE_ENV=production

真实场景:这台机器上的 dsh.service 一口气注入三条:

Environment=NODE_ENV=production
Environment=NODE_OPTIONS=--max-old-space-size=2048      ; 限制 Node 堆内存 2G
Environment=PATH=/usr/local/bin:/usr/bin:/bin:/var/www/dsh/node_modules/.bin
  • NODE_OPTIONS 用 --max-old-space-size=2048 让 Node 堆上限 2GB,防止内存吃满被 OOM;
  • 手动写 PATH 是为了让 pnpm、node 及项目里的二进制都能被找到——systemd 的默认 PATH 往往不含 /usr/local/bin 或项目的 node_modules/.bin。

易错 systemd 默认 PATH 是限制过的,不含 /usr/local/bin。所以你的 ExecStart 和 Environment 记得用绝对路径写 node、python3、pnpm,或显式补一条 Environment=PATH=...,否则会出现"手动跑正常、systemd 跑却 command not found"的灵异现象。

Restart:宕机自动拉起的魔法

这是 systemd 相对老式 init 最大的优势——进程死了它会自己拉起来。

Restart 取值 行为
no 默认,死了不拉
on-failure 非正常退出(非 0 退出码/被信号杀)才重启
always 任何原因退出都重启(正常 stop 也会被拉起)
on-abnormal 被信号/kill 或超时才重启
on-abnormal+on-success 仅在异常或成功退出后重启
on-watchdog 看门狗超时才重启
Restart=always
RestartSec=3

RestartSec=3 是"重启前等 3 秒",避免服务反复崩溃时疯狂重启打满 CPU。

易错 Restart=always 会把手动 systemctl stop 也顶回去。想临时停,要么 systemctl stop 后立刻 systemctl disable,要么改用 on-failure。xray.service 用的就是 on-failure,配合 RestartPreventExitStatus=23 防止特定退出码触发的假死循环。

StandardOutput / StandardError:日志去哪

journal 表示把 stdout/stderr 都交给 journald 记录,这样你能用 journalctl -u bangdream 查到日志。这比让它写死日志文件更省事、更省磁盘。

StandardOutput=journal
StandardError=journal

本服务器另一份真实样例:dsh.service

[Unit]
Description=DeepSeek Harness Web UI
After=network.target

[Service]
Type=simple
WorkingDirectory=/var/www/dsh
ExecStart=/usr/local/bin/pnpm dsh web --no-open --trusted-host dsh.bangdream.xyz
Environment=NODE_ENV=production
Environment=NODE_OPTIONS=--max-old-space-size=2048
Environment=PATH=/usr/local/bin:/usr/bin:/bin:/var/www/dsh/node_modules/.bin
Restart=always
RestartSec=3
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

注意 ExecStart 这一行——/usr/local/bin/pnpm 用绝对路径(尽管 PATH 里也补了),参数用 \ 折行或直接写一行。systemd 允许用反斜杠把一条命令拆成多行,但空格仍是分隔符,别把引号加错。

动手写一个自己的服务

假设你有个脚本 /usr/local/bin/my_app,要在 /opt/myapp 目录下跑,缺内存时别 OOM,崩了自动拉起:

# 1. 创建 unit 文件
cat > /etc/systemd/system/myapp.service <<'EOF'
[Unit]
Description=My App
After=network.target

[Service]
Type=simple
WorkingDirectory=/opt/myapp
ExecStart=/usr/local/bin/my_app --port 9099
Environment=NODE_ENV=production
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target
EOF

# 2. 关键一步:重新加载 unit 表
systemctl daemon-reload

# 3. 启用 + 启动
systemctl enable --now myapp.service

# 4. 验证
systemctl status myapp.service --no-pager

易错 第 3 章最重要的一个坑:写完 unit 文件忘了 daemon-reload。 systemd 只有重新加载后才会看到你新写的文件,否则报 Failed to enable unit: Unit file ... does not exist。改任何已有 unit 的字段,同样必须 reload。

易错点

  • 必须 daemon-reload:新增或修改 unit 文件后,先 reload,否则不生效。
  • ExecStart 用绝对路径:依赖 PATH 不可靠,node 要写 /usr/local/bin/node。
  • Restart=always 会顶回手动 stop:想停就 disable,或用 on-failure。
  • WorkingDirectory 别写错:目录不对,服务秒退且不报明显错,看 journal。
  • 不要改 /lib/systemd/system/:改动会随 apt 升级被覆盖,放 /etc/。
  • 环境变量敏感信息别硬编码进 unit:密钥建议用 EnvironmentFile= 指向一个 chmod 600 的文件,或按本机脚本惯例放 /root/.xxx.conf。

相关命令

systemctl daemon-reload、systemctl edit(安全覆盖单个 unit)、systemctl cat(查看合并后的 unit)、systemctl show(看生效的全部设置)、ls /etc/systemd/system/。