上一篇结尾留了几个没补的洞:nohup ./deploy.sh & 这条命令,中途崩了没人知道、没人拉起,机器重启不会自己回来,日志散在各个文件里要自己切分,要停它还得先找 PID 再 kill——而且如果它是个会拉 worker 的主进程,杀了壳,worker 可能还占着端口。
这些不是 nohup 的缺陷,是它的定位决定的:它只负责“别被 SIGHUP 打死”这一件事。这一篇就看 systemd 是怎么把剩下的活接过去的,以及它对你提出了什么要求。
一个最小的 unit 文件长什么样
systemd 管的东西都叫 unit,管服务的 unit 就是一份 .service 文件。文件名就是服务名,比如新建 /etc/systemd/system/myapi.service:
1[Unit]
2Description=My demo API
3After=network.target
4
5[Service]
6Type=simple
7User=deploy
8WorkingDirectory=/opt/myapi
9ExecStart=/opt/myapi/bin/server --port 8000
10Restart=on-failure
11RestartSec=2
12
13[Install]
14WantedBy=multi-user.target三节各管一件事。
[Unit] 放的是跟“服务本身怎么跑”无关的通用信息。Description 只影响 systemctl status 里那行说明,纯粹给人看。After=network.target 是排序,不是依赖:它的意思是“这套东西就绪之后再拉我”,但网络没起来我也照样能被启动——它保证顺序,不保证前提。
[Service] 是主体,真正决定怎么跑起来的是这里。ExecStart 写要执行的程序和参数,WorkingDirectory 相当于启动前先 cd 过去,User 决定用哪个身份跑(这样就不必让服务带着 root 权限跑)。Type=simple 和 Restart 留到后面单独讲。
[Install] 有点特别:systemd 平时运行根本不读它,它只被 systemctl enable 使用,告诉 enable 该往哪里挂链接。
文件放哪儿也值得记一句。发行版自带的 unit 在 /usr/lib/systemd/system,自己写的放 /etc/systemd/system。同名时 /etc 优先 2,所以包管理器升级时不会覆盖你改过的东西——这也是“想改某个自带服务的配置,先复制到 /etc 再改”这个习惯的由来。
systemd 靠什么知道这个进程还在不在
要理解后面所有事情,先得知道 systemd 手里握着一个你在终端里没有的工具:cgroup。
启动服务时,systemd 把进程放进一个属于这个 unit 的 cgroup,之后这个进程再 fork 出来的子进程、孙进程,都留在这个组里。带来的结果是:
- 它不需要靠 PID 文件“记住”进程。对
Type=simple,ExecStart直接 fork 出来的那个就是主进程,也就是status输出里的 Main PID。 systemctl stop收拾的是整个 cgroup,不是单个 PID。这正是上一篇那个“杀了壳、worker 还在占端口”的问题的对症解法:worker 也在这棵树上,一起被收掉。systemctl status里那几行 CGroup 树,就是这个服务拉起了哪些进程,一眼能看清。
为什么它要求程序在前台运行
关键在于 systemd 怎么判断“启动完成了”。
Type=simple 是默认值:ExecStart 一 fork 出来,systemd 就立刻认为这个 unit 已经启动完成 1。这个判断极其简单粗暴,而恰恰是它决定了“必须前台跑”这件事。
设想一个老式 daemon 的写法:程序启动后 fork 一次、父进程立刻 exit(0),让真正的干活进程留在后台。systemd 眼里的主进程就是那个父进程——它退出了,于是 systemd 认为这个服务已经结束,状态立刻变成 inactive(退出码是 0 的话),哪怕那个躲在后头的真身还在跑。
反过来,如果程序老老实实待在前台一直跑,“进程还在”和“服务是 active”就成了同一件事:进程一退出,systemd 立刻收到通知(它是这个进程的父进程),能马上按你的 Restart= 决定要不要拉起来。前台运行换来的是生命周期被精确看见,这才是它要求的真正原因。
需要说清楚的是,systemd 并非管不了老式后台化的 daemon。设 Type=forking,规则就变成“等 fork 出去的那个父进程退出,就算启动完成”,再配 PIDFile= 帮它认出主进程是哪一个 1。代价是它只知道“父进程退出了”这一件事,没法区分“正常起来了”和“fork 完就立刻失败”。所以 systemd 自己的文档里直接写着 forking 类型不推荐使用,PID 文件这种老做法也应该尽量避免 1。
顺带一个很多人第一次会踩的坑:Type=simple 下 systemctl start 会立刻报成功,哪怕二进制根本不存在、或者 User= 写成一个不存在的用户——因为 fork 已经成功了,systemd 还没走到 execve。想让启动过程真的检查二进制能不能执行,用 Type=exec,它等到 execve 成功才认为启动完成,失败会如实报出来 1。
ExecStart 不是 shell
unit 文件里没有 shell。管道 |、重定向 >、后台 &、通配符展开,这些统统不生效;一行的语法跟 shell 相似,但只认它自己规定的那几个元字符 3。
所以上一篇那条命令:
1nohup ./deploy.sh > /var/log/deploy.log 2>&1 &不能直接搬进 ExecStart。正确做法是把它拆开:程序本身写成 ExecStart=/opt/app/deploy.sh,日志的去处交给日志配置(下面讲),相对路径换成绝对路径。实在需要 shell 语法时,只能写 ExecStart=/bin/sh -c "…",但那等于把进程管理的主动权交回给一个 shell,能不用就不用。
日志为什么在 journalctl 里
上一篇里你必须自己写 > log 2>&1,原因是进程的 stdout/stderr 默认指着终端,而终端会随着 ssh 断掉一起消失。到了 systemd 这里,默认值变了:服务的标准输出和标准错误 默认就连到 journal(StandardOutput/StandardError 的默认值就是 journal)3。也就是说你什么都不配置,程序 print / printf 出来的内容也已经有人收着了。
查看方式是配套的:
systemctl status myapi:状态加上最近十来行日志,快速判断够用;journalctl -u myapi -n 100:看更多历史,-f实时跟;-u就是按 unit 过滤 5。
一个常见困惑是:重启之后 journalctl 看不到上次开机的日志。因为 journal 默认写在 /run/log/journal,那是内存,重启就没了;只有当 /var/log/journal 这个目录存在时,日志才持久化到磁盘 4。机器上没有这个目录,日志就不跨重启,这不是 journalctl 用错了。
日常操作的顺序
1systemctl daemon-reload # 刚新增或改过 unit 文件
2systemctl start myapi # 现在启动
3systemctl status myapi
4systemctl enable myapi # 开机自启(只建链接,不启动)
5systemctl enable --now myapi # 需要的话顺带启动
6systemctl stop myapi
7systemctl disable myapi # 取消自启,不会停掉正在跑的服务daemon-reload 这一步经常被漏掉。systemd 会把 unit 内容缓存在内存里,你在磁盘上改了文件,它却还在用旧的,于是出现“我明明改了怎么就不生效”。改完 unit 文件,先 daemon-reload,再 restart。
enable 做的事很具体:它按 [Install] 里那句 WantedBy=multi-user.target,在 /etc/systemd/system/multi-user.target.wants/ 下建一个指向本 unit 的软链接 25。开机时 systemd 走到 multi-user.target,就把这个 .wants/ 目录里的东西一起拉起来。所以 enable 和 start 是两件独立的事:enable 不改当前状态,disable 也不会把正在跑的服务停掉。
Restart 和“重启太频繁”
最后补一个几乎人人都会撞的现象:
1Restart=on-failure
2RestartSec=2Restart 默认是 no,所以不加这行,进程崩了 systemd 只会把 unit 标记成 failed,不会拉起来。on-failure 是官方给长跑服务的推荐值:非零退出、被信号打死、启动或停止超时,都会重启 1。RestartSec 是重启前等多久,默认 100ms。
但重启有频率上限。systemd 会统计每个 unit 的启动次数,在默认的时间窗内超过允许次数就不再放行,报出 start request repeated too quickly,unit 进入 failed,之后连手动 systemctl start 都不给起,得先 systemctl reset-failed(这两个上限分别是 StartLimitIntervalSec= 和 StartLimitBurst=,默认值来自管理器配置)2。如果你写错一行配置导致服务启动即崩,看到的就是这串字——它不是在阻止你修 bug,而是在防止无限重启把机器拖垮。
回到开头那张缺口清单:没人知道的崩溃、重启后不回来、日志散落、停不干净,systemd 全都接过去了,代价是它要你配合一件事——程序在前台跑,让“进程在不在这件事”直接等于“服务好不好”。理解了这一点,unit 文件里大部分看起来莫名其妙的写法就都有了解释。