# 把程序交给 systemd：unit 文件怎么写，为什么它要求前台运行、日志又去了 journalctl

Type=simple 与 forking 差在“何时算启动完成”，cgroup 让 stop 能收干净整棵进程树，ExecStart 不经 shell，stdout/stderr 默认进 journal

> systemd · 进程管理 · 日志排查 · 约 7 分钟 · 10 月 07 日

## 本篇要点

1. systemd 用一个 unit 文件描述“怎么启动、用什么身份、崩了怎么办、开机要不要拉起来”，`.service` 文件名就是服务名，自建的放 /etc/systemd/system，同名时优先于 /usr/lib/systemd/system 里发行版自带的那份。
2. unit 分三节：[Unit] 里 Description 只是给人看的说明、After= 只管启动顺序不管依赖，[Service] 决定怎么跑，[Install] 平时不被读取，只被 systemctl enable 使用。
3. systemd 启动服务时把进程放进属于这个 unit 的 cgroup，所有子进程都在同一个组里，所以它不需要 PID 文件认主进程，systemctl stop 也能一次收拾干净整棵进程树。
4. “要求前台运行”的真正原因是 systemd 判断启动完成的方式：Type=simple 下 ExecStart 一 fork 出来就算启动完成，若程序自己 fork 后父进程退出，systemd 会认为服务已经结束；前台常驻则让进程的生死直接对应 unit 的 active/inactive。
5. systemd 能配合老式 daemon，用 Type=forking 加 PIDFile=，但只能靠“父进程退出”判断，分不清启动成功与启动即失败，官方文档明确不推荐。
6. Type=simple 下 systemctl start 会立刻报成功，即使二进制缺失或 User= 不存在；需要让启动失败可见就用 Type=exec，它会等到 execve 成功。
7. ExecStart 不经 shell，不支持管道、重定向、后台符和通配符，一个条目就是一个程序加参数，路径应写绝对路径。
8. 服务的标准输出和标准错误默认连到 journal，所以日志用 journalctl -u 查看，不需要自己写重定向；journal 默认存在内存里的 /run/log/journal，只有存在 /var/log/journal 时才跨重启保留。
9. 改完 unit 文件必须先 systemctl daemon-reload，否则 systemd 还在用内存里的旧配置；enable 只是按 WantedBy= 在 multi-user.target.wants/ 下建软链接，与 start 互不影响。
10. Restart 默认是 no，长跑服务推荐 on-failure；启动过于频繁会撞上启动速率限制，报 start request repeated too quickly 并进入 failed，需要 reset-failed 才能再启动。

---

上一篇结尾留了几个没补的洞：`nohup ./deploy.sh &` 这条命令，中途崩了没人知道、没人拉起，机器重启不会自己回来，日志散在各个文件里要自己切分，要停它还得先找 PID 再 `kill`——而且如果它是个会拉 worker 的主进程，杀了壳，worker 可能还占着端口。

这些不是 `nohup` 的缺陷，是它的定位决定的：它只负责“别被 SIGHUP 打死”这一件事。这一篇就看 systemd 是怎么把剩下的活接过去的，以及它对你提出了什么要求。

## 一个最小的 unit 文件长什么样

systemd 管的东西都叫 unit，管服务的 unit 就是一份 `.service` 文件。文件名就是服务名，比如新建 `/etc/systemd/system/myapi.service`：

```ini
[Unit]
Description=My demo API
After=network.target

[Service]
Type=simple
User=deploy
WorkingDirectory=/opt/myapi
ExecStart=/opt/myapi/bin/server --port 8000
Restart=on-failure
RestartSec=2

[Install]
WantedBy=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]。

所以上一篇那条命令：

```
nohup ./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 用错了。

## 日常操作的顺序

```bash
systemctl daemon-reload        # 刚新增或改过 unit 文件
systemctl start myapi          # 现在启动
systemctl status myapi
systemctl enable myapi         # 开机自启（只建链接，不启动）
systemctl enable --now myapi   # 需要的话顺带启动
systemctl stop myapi
systemctl disable myapi        # 取消自启，不会停掉正在跑的服务
```

`daemon-reload` 这一步经常被漏掉。systemd 会把 unit 内容缓存在内存里，你在磁盘上改了文件，它却还在用旧的，于是出现“我明明改了怎么就不生效”。改完 unit 文件，先 `daemon-reload`，再 `restart`。

`enable` 做的事很具体：它按 `[Install]` 里那句 `WantedBy=multi-user.target`，在 `/etc/systemd/system/multi-user.target.wants/` 下建一个指向本 unit 的软链接 [2][5]。开机时 systemd 走到 multi-user.target，就把这个 `.wants/` 目录里的东西一起拉起来。所以 `enable` 和 `start` 是两件独立的事：`enable` 不改当前状态，`disable` 也不会把正在跑的服务停掉。

## Restart 和“重启太频繁”

最后补一个几乎人人都会撞的现象：

```ini
Restart=on-failure
RestartSec=2
```

`Restart` 默认是 `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 文件里大部分看起来莫名其妙的写法就都有了解释。

## 术语表

- unit 文件：一份描述某个东西该怎么被启动和看管的配置，服务用的叫 .service，文件名即服务名。
- cgroup 跟踪：systemd 把服务及其所有后代进程装进同一组，从而能准确知道整棵进程树的存在与消亡。
- Type=simple / exec / forking：三种“什么时候算启动完成”的判定方式，直接决定程序该前台跑还是自己后台化。
- 启动速率限制：同一 unit 在一段时间内启动次数超限就不再放行，用于防止崩溃循环把机器拖垮。
- WantedBy 与 multi-user.target：enable 时把 unit 挂到“开机启动清单”上的方式，实现方式是建软链接。
- journal：systemd 自带的日志收集组件，服务的标准输出和标准错误默认进这里，因此用 journalctl 查看。

## 来源

1. [systemd.service(5) — Type= 各取值的启动完成判定、forking 不推荐、PIDFile= 定位、Restart= 默认值与推荐值](https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html)
2. [systemd.unit(5) — unit 搜索路径与 /etc 优先级、WantedBy、StartLimitIntervalSec= 与 StartLimitBurst=](https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html)
3. [systemd.exec(5) — ExecStart 命令行语法（不支持管道、重定向、后台符）、路径必须绝对、StandardOutput 默认值为 journal](https://www.freedesktop.org/software/systemd/man/latest/systemd.exec.html)
4. [systemd-journald.service(8) — 日志持久化于 /var/log/journal，否则写在 /run/log/journal 且重启丢失](https://www.freedesktop.org/software/systemd/man/latest/systemd-journald.service.html)
5. [systemctl(1) — status 附带最近日志、enable 只建软链接且不启动、daemon-reload 与 reload 的区别](https://www.freedesktop.org/software/systemd/man/latest/systemctl.html)

---

原文：https://pangzhengboyin.com/articles/systemd-unit-file-foreground-and-journalctl-0e005700

> **庞征博引** · 想学的，慢慢都会
>
> 庞征博引是把想学的东西写成连载的 AI 学习工具。说出想学什么，它会先了解你的基础，再把主题写成一篇篇 5–10 分钟能读完的文章；边读边问，接下来学什么跟着你走。这篇就是这样写出来的。
>
> 开始你自己的连载 → https://pangzhengboyin.com
