一个跑了三年的个人站,服务器上大大小小有二十来个服务:主站、图床、两个小工具、监控面板、定时任务、数据库、缓存,还有几个只在周末用一下的脚本。它们当初都是用一条 nohup 或者一个随手写的 systemd unit 拉起来的,跑起来就不管了。等到某天服务器重启,或者半夜 OOM 干掉几个进程,才发现有两三个服务再也没起来,而且没人知道它们是干什么用的。
这篇文章讲一个很具体的问题:怎样让一个「只有在有人登录着的时候才正常」的进程,变成开机自启、崩溃自愈的常驻服务。重点不是抄一份 unit 文件,而是讲清楚 systemd 下用户级服务的几个容易踩错的地方,为什么普通用户的 systemctl --user enable 重启后不生效,以及 linger 这个开关到底做了什么。
一、先确认这个服务该由谁托管
systemd 的服务分两级:系统级(/etc/systemd/system/)以 root 运行,用户级(~/.config/systemd/user/)以登录用户身份运行。选择哪一级,看三件事。
第一,进程要不要绑定 1024 以下的端口。要绑 80、443 这类端口,普通用户没有权限,要么用系统级服务,要么给二进制加 CAP_NET_BIND_SERVICE 能力,要么前面放一个 Nginx 反向代理。个人站最常见的是第三种:应用自己监听 8000 端口,由 Nginx 对外。
第二,进程需不需要访问只有 root 能读的东西。读 /var/log 下的某些文件、操作 iptables、挂载目录,这些都是系统级服务的活。
第三,也是最容易被忽略的一条:这个服务挂了以后,是希望它一直跑着,还是希望它跟着某个会话走。比如一个只在白天开发时用的热重载工具,它本来就应该随终端关闭而退出,硬做成常驻服务反而会留下没人管的进程。
判断完之后再动手。用普通用户跑的服务一律放用户级目录,好处是它永远不会有 root 权限,出错也伤不到系统;坏处就是下面要讲的 linger 问题。
二、用户级服务为什么重启后不启动
用户级服务由一个叫 user@UID.service 的实例管理器负责,而 systemd 默认只在用户「有登录会话」时才为这个用户启动实例管理器。也就是说,你的 systemctl --user enable myservice 确实把软链接建好了,但机器重启之后没有人登录,实例管理器根本没起来,服务自然也不会被拉起。等你 ssh 登录进去,实例管理器启动,服务才跟着起来——这就造成了「我一登录它就好了」的错觉。
打开 linger 就是告诉 systemd:这个用户的实例管理器在开机时就要启动,并且一直保持,与有没有登录会话无关。查看和开启的命令如下:
# 查看当前用户的 linger 状态
loginctl show-user "$USER" -p Linger
# 开启(需要 root 或有 sudo 权限)
sudo loginctl enable-linger "$USER"
# 等价写法
sudo loginctl enable-linger 1000
# 确认
loginctl list-users开启之后,开机时 user@1000.service 会被拉起,用户级服务目录下所有 enabled 的 unit 都会按依赖顺序启动。这一步做完,再配合 enable,服务才真正算得上是常驻。
一个常见的坑:给服务用户开启了 linger,但服务是靠 WantedBy=default.target 挂上去的,而该用户的 default.target 被某些发行版设置成了 graphical-session.target。没有图形会话时这个 target 永远不会被激活,服务也就永远不会启动。systemctl --user status myservice 会显示 inactive (dead) 而不是 failed,看上去像是被主动停掉的。遇到这种情况,把 unit 里的 WantedBy 改成 default.target 并确认 systemctl --user get-default 的输出确实是 default.target。
三、写一份能自愈的 unit
下面是一个实际在用的 PHP 常驻进程配置,放在 ~/.config/systemd/user/ 下。每一行都对应一个具体的失效场景,不是模板凑数:
[Unit]
Description=site worker
After=network-online.target
Wants=network-online.target
# 不限制启动频率,避免重启风暴时被 systemd 放弃
StartLimitIntervalSec=0
[Service]
Type=simple
WorkingDirectory=/home/deploy/site
ExecStart=/usr/bin/php /home/deploy/site/bin/worker.php
# 崩溃后自动拉起,退避 5 秒
Restart=always
RestartSec=5
# 日志走 journal,方便 journalctl 按服务名查
StandardOutput=journal
StandardError=journal
# 让 SIGTERM 有机会保存进度,超时才强杀
KillSignal=SIGTERM
TimeoutStopSec=30
Environment=APP_ENV=production
[Install]
WantedBy=default.target几个参数值得单独说。 Restart=always 和 RestartSec=5 是自愈的核心,前者保证任何退出方式都会被重启(包括正常退出码 0),后者防止进程一启动就崩导致的疯狂循环。如果你希望正常退出时不要重启,用 Restart=on-failure。
StartLimitIntervalSec=0 放在 [Unit] 段而不是 [Service] 段,这是很多人抄错的地方。早期版本这个指令在 [Service] 段里,新版本移到 [Unit] 后没有完全兼容旧的写法,写错位置时 systemd 只会在 journalctl 里给一条 unknown key 警告,功能静默失效。
WorkingDirectory 一定要写。很多脚本靠相对路径读配置文件,用 systemd 启动时工作目录是 /,找不到配置就直接退出,然后被 Restart=always 无限重启,日志里满是「配置文件不存在」,看着像程序有 bug。
TimeoutStopSec 和程序里的信号处理必须配对。如果程序没有处理 SIGTERM 的逻辑,systemd 发完信号等 30 秒后会发 SIGKILL,中间这 30 秒里服务是不可用的。要么把超时改短,要么把信号处理补上。
四、环境变量、路径与权限三个连环坑
从「在终端里能跑」到「用 systemd 能跑」,中间隔着三层环境差异,几乎每次都会踩到其中至少一个。
第一层是 PATH。终端里能直接敲 php、node,是因为 shell 加载了 ~/.bashrc 里的路径。systemd 用户实例读的是 ~/.config/environment.d/*.conf 和 systemctl --user set-environment,完全不看 bashrc。解决办法只有两个:unit 里的 ExecStart 一律写绝对路径;或者在 ~/.config/environment.d/10-path.conf 里补一行 PATH=/usr/local/bin:/usr/bin:/bin。
第二层是用户环境变量。数据库口令、API key 这类东西不该写进 unit 文件(会被 systemctl cat 明文打印出来),用 EnvironmentFile 引用一个 600 权限的文件:
[Service]
EnvironmentFile=/home/deploy/site/.env.service
# 文件内容示例(等号两边不要有空格,值不用加引号)
# DB_HOST=127.0.0.1
# DB_PASS=change-me这个文件的格式和 shell 不一样:不支持 export,不支持命令替换,值里带空格会被整行截断。用 . 或 source 加载一个 .env 再直接指给 EnvironmentFile,大概率启动失败。
第三层是权限。用户级服务以该用户身份运行,如果服务需要写日志到 /var/log 下的某个文件,或者需要读一个 root 拥有的配置,就会静默失败。判断方法很简单:sudo -u deploy /usr/bin/php /home/deploy/site/bin/worker.php 手动跑一次,能跑通说明权限没问题,跑不通就是权限问题,跟 systemd 无关。这条单命令能把「systemd 配置错」和「程序本身跑不起来」两类问题一次分开,是排查时最先做的一步。
五、日志与排查:五个最常用的命令
用户级服务的日志全在 journal 里,按服务名过滤比翻文件快得多。日常只需要记住这几个:
# 最近 100 行,带时间戳跟随
journalctl --user -u site-worker -n 100 -f
# 只看本次启动之后的日志
journalctl --user -u site-worker -b
# 看为什么上次退出
systemctl --user status site-worker
systemctl --user show site-worker -p ExecMainStatus -p NRestarts
# 改了 unit 文件之后必须做的两步
systemctl --user daemon-reload
systemctl --user restart site-workerNRestarts 这个字段特别有用。一个服务如果每小时重启几百次,用 status 看是 active 的,但它其实一直在崩溃循环里。定期看一眼重启次数,比看进程有没有活着有意义得多。
另一个高频现象是服务状态显示 activating (auto-restart),进去看日志只有一行 Main process exited, code=exited, status=1/FAILURE。这时不要急着改 unit,先手动执行一次 ExecStart 里的完整命令。绝大多数情况下问题在程序本身:缺配置文件、端口被占用、数据库连不上。systemd 只是忠实地把失败重放了一遍。
六、整个流程的落地顺序
把上面的东西串起来,一台已有服务的机器上做改造,建议按这个顺序走,每一步都可以单独验证,出问题时能立刻定位到是哪一步。
第一步,把现有进程停掉,记下它是怎么启动的(命令行、工作目录、环境变量)。第二步,在用户目录下写 unit 文件,先用 systemctl --user start 手动启动,确认能跑起来。第三步,enable 并 daemon-reload,然后 stop 再 start 一次,确认 systemd 是唯一的管理者,而不是和手动启动的进程抢端口。第四步,开启 linger,然后真的重启一次机器验证。第五步,用 kill -9 杀掉主进程,看它是否在 5 秒内被拉回来。第六步,把这条命令写进月度检查清单:systemctl --user list-units --state=failed,空的才算健康。
第四步和第五步是整件事里唯一真正有意义的验收。很多人做完前三步就认为完成了,直到某次意外重启才发现服务全都没起来——那时候正在故障中,排查成本高得多。