⏰ 第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/。