2024 年,我把一个新写的服务部署到服务器上,按惯例写了一个 systemd 的 service 文件,想让它被 systemctl 管起来,开机自启、挂了自动拉。文件写好,我满怀信心地敲下 systemctl start myapp——命令没有立刻返回,卡了大概一分半钟,然后吐出一行红字:Job for myapp.service failed。我赶紧 systemctl status myapp,屏幕上是冷冰冰的 failed,外加一句我看不太懂的 Result: timeout。可诡异的是,我手动在命令行直接跑那个程序,它跑得好好的,稳稳地常驻在前台。一个手动跑得活蹦乱跳的程序,交给 systemd 管,就启动"失败",而且失败前还要莫名其妙地卡一分半钟——这两件怪事凑在一起,让我百思不得其解。后来才发现,程序本身一点问题没有,问题出在我对 systemd 管理服务的方式、尤其是那个 Type 字段的理解,完全是错的。这件事逼着我把 Linux 的 systemd、unit 文件、journalctl 日志这一整套彻底理清了。本文复盘这次实战。
问题背景
环境:CentOS 7,一个前台常驻运行的自研服务
事故现象:
- 手动在命令行直接跑程序,完全正常,稳定常驻
- 写了 myapp.service 交给 systemd 管
- systemctl start 卡约 90 秒,然后报 failed
- status 显示 failed,Result: timeout
现场排查:
# 1. 看 status,信息有限但有线索
$ systemctl status myapp
Loaded: loaded (/etc/systemd/system/myapp.service; ...)
Active: failed (Result: timeout) # ★ timeout
Process: 9001 ExecStart=/opt/myapp/run (code=killed, signal=TERM)
# ★ 注意:Result 是 timeout,而且进程是被 TERM "killed" 的
# 2. ★ 看完整日志:journalctl -u
$ journalctl -u myapp -n 30
... Starting myapp...
(这里静默了约 90 秒)
... myapp.service: Start operation timed out. Terminating.
... myapp.service: Failed with result 'timeout'.
# ★ systemd 一直在【等待】启动完成,等了 90 秒等不到,
# 认定启动失败,把进程 TERM 掉了
# 3. 看我写的 unit 文件
$ cat /etc/systemd/system/myapp.service
[Service]
Type=forking # ★ 问题就在这一行
ExecStart=/opt/myapp/run
根因(后来想清楚的):
1. ★ Type=forking 的含义是:systemd 期待你的程序
【启动后 fork 出子进程,然后【父进程退出】】——
它把"父进程退出"当作"启动成功"的信号。
2. 但我的程序根本不 fork,它是个【前台常驻】进程,
启动后就一直在前台跑、永远不退出。
3. ★ 于是 systemd 傻等:它等那个"父进程退出"的信号,
可我的进程压根不会退 -> 一直等 -> 等到 90 秒超时。
4. 超时后,systemd 认定"启动失败",把进程 TERM 掉,
报 Result: timeout。
5. ★ 正解:前台常驻的程序,Type 要写 simple(默认),
systemd fork 出进程就【立刻认为启动成功】,不傻等。
unit 的 Type 配错,程序再正常也起不来。
修复 1:systemd 与 unit 文件——服务是怎么被管起来的
# === ★ 先认识 systemd:它是现代 Linux 的"1 号进程" ===
# 开机后第一个起来的进程就是 systemd(PID 1),
# 此后系统上所有服务的启动、停止、监控、重启,都归它管。
# === 一个服务,对应一个 .service "单元(unit)文件" ===
# 你想让 systemd 管理一个程序,就给它写一个 unit 文件。
# 自定义服务,放这里:
$ ls /etc/systemd/system/ # ★ 自己写的放这
# 软件包带的,放这里(★ 别去改这里的,改了会被升级覆盖):
$ ls /usr/lib/systemd/system/
# === ★ 一个最小可用的 unit 文件长这样 ===
$ cat /etc/systemd/system/myapp.service
[Unit]
Description=My App Service # 描述,给人看的
After=network.target # ★ 在网络就绪【之后】再启动
[Service]
Type=simple # ★ 服务类型(下文重点)
ExecStart=/opt/myapp/run # ★ 启动命令(必须绝对路径)
Restart=on-failure # 异常退出就自动重启
User=appuser # 用哪个用户身份跑
[Install]
WantedBy=multi-user.target # enable 时挂到这个目标
# === 管理一个服务的常用命令 ===
$ systemctl start myapp # 启动
$ systemctl stop myapp # 停止
$ systemctl restart myapp # 重启
$ systemctl status myapp # 看状态
$ systemctl enable myapp # ★ 设开机自启
$ systemctl disable myapp # 取消开机自启
$ systemctl enable --now myapp # enable + start 一步到位
# === ★ 一个高频坑:改了 unit 文件,必须 daemon-reload ===
$ vi /etc/systemd/system/myapp.service # 改完
$ systemctl daemon-reload # ★ 不执行这句,改动不生效!
$ systemctl restart myapp
# systemd 把 unit 配置缓存在内存里,改了文件得让它重新加载。
修复 2:systemctl status 怎么读——头部信息量很大
# === ★ systemctl status 那几行,每一行都是排查线索 ===
$ systemctl status myapp
● myapp.service - My App Service
Loaded: loaded (/etc/systemd/system/myapp.service; enabled; ...)
Active: failed (Result: timeout) since Mon 2024-05-20 03:14 ...
Process: 9001 ExecStart=/opt/myapp/run (code=killed, signal=TERM)
Main PID: 9001 (code=killed, signal=TERM)
# === 第一行那个圆点的颜色/状态 ===
# ● 白/灰 = 未启动 ● 绿 = 运行中 ● 红 = 失败
# === Loaded 行:unit 文件【加载】情况 ===
# loaded = 文件找到了、语法没问题
# not-found = ★ 文件没找到(名字敲错?放错目录?)
# bad-setting / error = unit 文件里有语法错误
# 后面的 enabled/disabled = 开机自启有没有设
# === ★ Active 行:服务【当前】状态 —— 最该看的一行 ===
# active (running) 正常运行中
# active (exited) ★ 跑完就退了(oneshot 类型正常如此)
# failed 启动/运行失败
# activating ★ 正在启动中(卡在这 = 启动迟迟不完成)
# 括号里的 Result 告诉你失败原因:
# timeout ★ 启动/停止超时(这次就是它)
# exit-code 程序自己以非 0 退出
# signal 被信号杀死
# === ★ Process / Main PID 行:进程是怎么结束的 ===
# code=exited, status=0 正常退出
# code=exited, status=203 ★ 203/EXEC = ExecStart 那个命令
# 没法执行(路径错?没执行权限?)
# code=exited, status=200+ 各种 systemd 专属错误码
# code=killed, signal=TERM ★ 被 systemd 自己 TERM 掉的
# (常见于超时后被终止)
# === ★ status 只显示最后 10 行日志,不够看 ===
$ systemctl status myapp -l --no-pager # -l 不截断长行
# 但真正完整的日志,要靠 journalctl(下一节)。
修复 3:journalctl——查 systemd 服务日志的正道
# === ★ systemd 管的服务,日志统一进 journal,用 journalctl 查 ===
# 别再到处翻 /var/log/xxx 了 —— 服务的 stdout/stderr,
# systemd 全都收进了 journal。
# === ★ 最常用:看某个服务的日志 ===
$ journalctl -u myapp # 看 myapp 的【全部】日志
$ journalctl -u myapp -n 50 # 只看最后 50 行
$ journalctl -u myapp -f # ★ 实时追踪(像 tail -f)
$ journalctl -u myapp --since '10 min ago' # 最近 10 分钟
$ journalctl -u myapp --since today # 今天的
$ journalctl -u myapp --since '2024-05-20 03:00' --until '03:20'
# === ★ 排查启动失败的黄金命令 ===
$ journalctl -xe
# -e 跳到日志末尾(最新)
# -x ★ 给日志补充"解释和建议" —— 启动失败时常有有用提示
$ journalctl -u myapp -xe # 针对某服务,最常用
# === 按优先级过滤,只看错误 ===
$ journalctl -u myapp -p err # 只看 err 及以上级别
$ journalctl -p err -b # 本次开机以来所有错误
# -p 级别:emerg/alert/crit/err/warning/notice/info/debug
# === 按"第几次开机"过滤 ===
$ journalctl -b # ★ 本次开机以来的日志
$ journalctl -b -1 # 上一次开机的日志
$ journalctl --list-boots # 列出所有开机记录
# === 看内核日志(等价于 dmesg)===
$ journalctl -k # 本次开机的内核日志
# === ★ journal 占了多少磁盘、怎么清 ===
$ journalctl --disk-usage # 看 journal 占用
$ journalctl --vacuum-time=7d # 只保留最近 7 天
$ journalctl --vacuum-size=500M # 只保留 500M
# ★ journal 默认有上限,但不主动收缩,大了可以手动 vacuum。
修复 4:Type 类型——配错就起不来,这次的核心
# === ★ unit 文件里的 Type,决定 systemd 怎么判断"启动成功了" ===
# 这次的坑就在这:Type 配错,程序再正常也"启动失败"。
# === Type=simple(★ 默认,也是最常用的)===
[Service]
Type=simple
ExecStart=/opt/myapp/run
# 含义:ExecStart 拉起的【那个进程本身】就是主进程,
# 它【在前台一直跑、不 fork、不退出】。
# ★ systemd 一旦 fork 出这个进程,【立刻】认为启动成功。
# ★ 绝大多数现代程序(前台常驻的)都该用 simple。
# ——这正是我这次该用、却没用的类型。
# === Type=forking —— 我这次错用的那个 ===
[Service]
Type=forking
ExecStart=/opt/myapp/daemon
PIDFile=/run/myapp.pid
# 含义:systemd 期待你的程序启动后,自己 fork 出子进程
# 去后台常驻,然后【启动它的那个父进程主动退出】。
# ★ systemd 把"父进程退出"当作"启动成功"的信号。
# ★ 这是【传统 daemon】程序的模式(自己做了后台化)。
# ★ 我的程序是前台常驻、根本不退 —— systemd 苦等那个
# "退出信号",等到超时,这就是 90 秒卡死的真相。
# === Type=oneshot —— 跑一次就完事的任务 ===
[Service]
Type=oneshot
ExecStart=/opt/init-data.sh
RemainAfterExit=yes
# 含义:命令跑完就结束,不常驻。适合初始化脚本等。
# 它的 status 正常就是 active (exited),不是 running。
# === Type=notify —— 程序自己"报告"启动好了 ===
# 程序通过 sd_notify 主动告诉 systemd "我就绪了"。
# 最精确,但需要程序代码层面支持。
# === ★ 怎么选:一句话决断 ===
# 程序前台跑、不自己后台化 -> Type=simple(99% 的情况)
# 程序是传统 daemon、自己 fork -> Type=forking,且配 PIDFile
# 跑一次就退出的脚本/任务 -> Type=oneshot
# ★ 拿不准就用 simple —— 让程序前台跑、把后台化交给 systemd,
# 这也是现代服务推荐的写法。
# === 这次的修复:就改一行 ===
$ vi /etc/systemd/system/myapp.service
- Type=forking
+ Type=simple
$ systemctl daemon-reload && systemctl restart myapp
$ systemctl status myapp # Active: active (running) ✓
修复 5:日志持久化——为什么重启后日志全没了
# === ★ 这次排查还撞见一个坑:重启机器后,journal 旧日志没了 ===
# === 原因:journal 默认可能只存在【内存】里 ===
$ cat /etc/systemd/journald.conf | grep -i storage
#Storage=auto
# Storage 的取值:
# volatile ★ 日志只存内存(/run),【一重启就全丢】
# persistent 存磁盘(/var/log/journal),重启也在
# auto ★ 默认:/var/log/journal 目录【存在】就持久化,
# 不存在就只存内存 —— 很多系统这个目录不存在!
# none 不存日志
# === ★ 让 journal 持久化:关键就是建出那个目录 ===
$ mkdir -p /var/log/journal
$ systemctl restart systemd-journald
# 或者在配置里显式写死:
$ vi /etc/systemd/journald.conf
Storage=persistent
$ systemctl restart systemd-journald
# ★ 这之后,journalctl -b -1 才真的能看到上次开机的日志。
# === 确认现在日志存哪了 ===
$ journalctl --disk-usage
Archived and active journals take up 88.0M in /var/log/journal/...
# 路径里是 /var/log/journal = 持久化成功;
# 是 /run/log/journal = 还在内存里,重启会丢。
# === ★ 顺带:别忘了 journal 也有大小上限 ===
$ vi /etc/systemd/journald.conf
SystemMaxUse=1G # journal 最多用 1G 磁盘
SystemMaxFileSize=100M # 单个文件上限
$ systemctl restart systemd-journald
# === 程序自己写的日志文件,不受 journal 管 ===
# 如果你的程序自己写 /var/log/myapp/xxx.log,
# 那是程序自己的事,journalctl 看的是 stdout/stderr。
# ★ 排查时两头都要想到:journalctl -u 服务 + 程序自己的日志。
修复 6:systemd 服务排查纪律
# === 这次事故暴露的认知盲区,定几条纪律 ===
# === 1. ★ 服务起不来,status 看头部 + journalctl 看全程 ===
$ systemctl status myapp # 看 Active / Result / Process
$ journalctl -u myapp -xe # 看完整日志和解释
# === 2. ★ Result/code 是关键线索 ===
# Result: timeout -> 多半 Type 配错,systemd 在傻等
# status=203/EXEC -> ExecStart 路径错 / 没执行权限
# code=killed signal -> 被信号杀(超时被 TERM,或 OOM)
# === 3. ★ Type 要和程序行为匹配 ===
# 前台常驻 -> simple;传统自 fork daemon -> forking;
# 跑一次就退 -> oneshot。拿不准用 simple。
# === 4. ★ 改了 unit 文件,必须 daemon-reload ===
$ systemctl daemon-reload # 不做这步,改动不生效
# === 5. ExecStart 必须绝对路径;注意 User= 的权限 ===
# systemd 环境也很"裸",和 cron 一样别依赖 PATH。
# === 6. 想让 journal 持久化,建 /var/log/journal 目录 ===
$ mkdir -p /var/log/journal && systemctl restart systemd-journald
# === 7. 排查 systemd 服务问题的命令链 ===
$ systemctl status 服务 -l # ① 状态、Result、进程退出码
$ journalctl -u 服务 -xe # ② 完整日志 + 解释
$ systemctl cat 服务 # ③ 看生效的 unit 文件全文
$ systemd-analyze verify 服务 # ④ 校验 unit 文件语法
$ systemctl list-dependencies 服务 # ⑤ 看它依赖了谁
# 按这个顺序,systemd 服务问题基本能定位。
命令速查
需求 命令
=============================================================
启动/停止/重启服务 systemctl start/stop/restart 服务
看服务状态 systemctl status 服务 -l
设/取消开机自启 systemctl enable / disable 服务
改完 unit 文件让其生效 systemctl daemon-reload
看某服务完整日志 journalctl -u 服务
实时追踪服务日志 journalctl -u 服务 -f
排查启动失败黄金命令 journalctl -u 服务 -xe
看本次/上次开机日志 journalctl -b / journalctl -b -1
只看错误级别日志 journalctl -u 服务 -p err
看生效的 unit 文件全文 systemctl cat 服务
校验 unit 文件语法 systemd-analyze verify 服务
看 journal 占用磁盘 journalctl --disk-usage
口诀:服务起不来 status 看 Result -> timeout 多半 Type 配错
改完 unit 必须 daemon-reload;完整日志靠 journalctl -u
避坑清单
- 服务起不来,systemctl status 看头部 Result/code,journalctl -u -xe 看全程
- Result: timeout 多半是 Type 配错,systemd 在傻等一个等不到的启动信号
- Type=simple 给前台常驻程序,forking 给自己 fork 的传统 daemon
- 前台常驻程序错配 Type=forking,systemd 会傻等到超时再判定失败
- status=203/EXEC 是 ExecStart 命令没法执行,查路径和执行权限
- 改了 unit 文件必须 systemctl daemon-reload,否则改动不生效
- ExecStart 必须写绝对路径,systemd 环境很裸不能依赖 PATH
- systemd 服务日志统一进 journal,用 journalctl -u 查,别到处翻 /var/log
- journal 默认可能只存内存,重启就丢,建 /var/log/journal 目录才持久化
- journalctl -xe 的 -x 会补充解释和建议,排查启动失败很有用
总结
这次"手动跑得好好的程序,交给 systemd 就启动失败"的事故,纠正了我一个对 systemd 角色的根本性误解。在这次之前,我心里把 systemd 想象成一个"代跑命令的助手":我写在 ExecStart 里的那条命令,就是我想跑的程序;systemd 不过是替我把这条命令敲下去、回个车而已。在这个理解里,systemd 跑命令和我自己在终端里跑命令,本质上是同一件事——既然我手动跑这个程序它活蹦乱跳,那 systemd 替我跑,自然也该一样。正是这个假设,让我对那个 90 秒的卡顿和最终的 failed 完全无法理解:命令是同一条,程序是同一个,凭什么我跑就成功、它跑就失败?复盘到根上,我才真正明白,systemd 远不只是一个"代跑命令的助手"——它是一个"服务的监护人"。它和我手动跑命令最本质的区别在于:我手动跑一个程序,我只管"把它启动起来",程序起来之后是死是活,我不会一直盯着;而 systemd 接管一个服务,它要负责这个服务的【整个生命周期】——它要知道这个服务"什么时候算启动成功了",要持续监控它"是不是还活着",要在它异常退出时"按策略把它拉起来"。而要扮演好这个监护人的角色,systemd 就必须解决一个我从来没替它想过的难题:它到底【凭什么】判断一个服务"已经启动成功了"?这个问题的答案,恰恰就是 unit 文件里那个我随手填错的 Type 字段。Type 这个字段,本质上是我和 systemd 之间的一份"约定"——它是在告诉 systemd:"我这个程序,是用哪种方式来表示'我启动好了'的。"我填的 Type=forking,这份约定的内容是:"我的程序会在启动后 fork 出一个子进程到后台常驻,然后启动它的那个父进程会主动退出;你 systemd 看到那个父进程退出了,就说明我启动成功了。"这是传统 daemon 程序的行事方式。可我的程序根本不是传统 daemon——它是一个现代的、前台常驻的进程,启动起来就一直在前台跑着,永远不会 fork,更永远不会退出。于是一场漫长的误会就此上演:systemd 严格地遵守着我填下的那份 forking 约定,它启动了我的程序,然后开始【等待】那个"父进程退出"的信号——它在等一个永远不会到来的东西。我的进程在前台尽职尽责地运行着,而 systemd 在旁边痴痴地等着它"退出"。一直等到 90 秒的超时上限,systemd 终于认定"这个服务启动失败了",并且——这一点尤其讽刺——它作为监护人,还"负责任"地把这个它认为"卡住了"的进程给 TERM 掉了。我的程序从头到尾都是健康的,它只是被一份错误的约定给冤杀了。正确的约定本该是 Type=simple:它告诉 systemd,"ExecStart 拉起的那个进程本身就是主进程,它会前台常驻、不 fork、不退出;你只要成功把它 fork 出来,就可以认为它启动成功了。"这次从一个"凭什么我跑就成功"的困惑出发,我最大的收获,是把脑子里"systemd = 代跑命令的助手"这个错误的模型,换成了"systemd = 服务的全生命周期监护人"。而既然它是监护人,它就必须有一套判断服务死活的标准,Type 字段就是我亲手交给它的那把标尺。把这把标尺递错了——把一个前台进程说成是会 fork 退出的 daemon——那么程序本身写得再完美,也会在监护人那套尽职尽责的判断逻辑里,被判一个莫须有的"启动失败"。给 systemd 托管一个服务,第一件要想清楚的事,从来不是 ExecStart 那条命令本身,而是:我的程序,究竟用什么方式,向它的监护人宣告"我已经准备好了"。
—— 别看了 · 2026