1. 为什么要啃启动流程这块硬骨头
很多人第一次接触 systemd 就是几条命令:systemctl start nginx、systemctl status sshd,再进阶一点知道写个 service 单元文件。但如果只停在这一层,遇到机器起不来、某个服务把启动时间拖到十几秒、或者依赖关系乱成一锅粥的时候,就只能靠重启碰运气了。
我最早认真研究 systemd 启动流程,是因为某公司一套内部模拟项目X的虚拟机每次开机都要 3 分多钟。当时 A 同学跟我开玩笑说“反正测试机,慢点就慢点”,结果所有联调任务全卡在这台机器上,光等启动就浪费了大量时间。后来我用systemd-analyze blame看了一眼,发现大部分时间并不是消耗在真正干活的业务服务上,而是耗在了一个依赖链极长的挂载服务上,服务之间互相 etc。从那以后我就意识到,不做启动流程分析,出了问题连排查方向都找不到。
这篇文章我计划从 systemd 的设计思路开始讲,然后沿着一条典型的启动路径,把每个关键环节拆开来看,再结合我自己实际排查和优化的经验,给出一套可以直接照做的分析步骤。适合两类人:一类是刚接触 systemd、想系统了解 Linux 启动机制的读者;另一类是已经被启动问题折磨过、手里正攥着一台起不来的机器等救命的运维或开发者。
不管你是哪一类,建议先在自己的一台虚拟机或闲置服务器上动手试一遍,纯读文章的效果远不如亲手跑几条命令来得直接。
2. 先搞懂 systemd 到底是怎么设计的
2.1 Unit 这个核心抽象决定了整个启动模型
在我没认真看文档之前,我以为 systemd 启动就是“一个脚本接着另一个脚本跑”,后来发现这个理解完全是错的。systemd 引入了 Unit(单元)的概念,任何需要被系统管理的资源都被抽象成 Unit,服务是service,挂载点是mount,设备是device,定时任务是timer,网络套接字是socket,还有target、slice、path等若干类型。
Unit 之间不是简单的前后顺序关系,而是通过依赖(Requires、Wants、After、Before)、冲突(Conflicts)、插槽式关联(BindsTo、Requisite)等一系列关系组织成一张有向图。启动过程本质上就是把这整张图从 root 出发,按拓扑序“编译”并行执行。
这种设计带来的直接好处是并行度高。传统 SysV init 是线性的,第一个脚本跑完之前不碰第二个。systemd 的目标是在安全的前提下尽量并行。比如一个服务 A 依赖某个挂载点 M,但只要服务 A 的可执行文件不在 M 上,systemd 在解析完 M 的挂载状态后,就可以同时拉起 M 的挂载动作和 A 的前置初始化动作,不需要傻等。
我在做项目调试时总爱用一句话提醒自己:Unit 是边的节点,依赖是边的方向,启动顺序是边的拓扑。搞清楚这三个概念,启动流程基本就抓住了七成。
2.2 Target 不是目录,更像是一种分组坐标
刚开始我以为multi-user.target是一个“目录”,后来发现完全不是。Target 本身也是一种 Unit,它的作用是把一批其他 Unit 聚合在一起,形成某个稳定状态。你执行systemctl isolate multi-user.target,系统会切换到一个以该 target 为顶层聚合点的状态,和这个 target 无关且非必要的 Unit 会被停掉。
打个不恰当的比方,像一台晚会上的节目单。每个节目(Unit)有自己负责的演员和设备,Target 就是“第几篇章”,进入某个篇章时,系统知道该把哪些节目推上台、把哪些撤下来。
从启动角度讲,内核把控制权交给 systemd 后,systemd 的第一个实例会读取默认 Target,也就是default.target,通常通过软链接指向graphical.target或者multi-user.target。graphical.target依赖multi-user.target,而multi-user.target又依赖basic.target,再往下是sysinit.target。这条依赖链就是一张分层的启动蓝图。
我在实战中碰到的很多“系统起不来”问题,本质都是这张蓝图中有某个节点启动失败,或者某个依赖关系卡死。遇到这种情况,我一般会先把默认 Target 临时切换成rescue.target或emergency.target,先让系统进到一个精简环境,再逐步排查,这个思路后面细说。
2.3 依赖关系越少,启动路径越短
systemd 文档里有一句话我印象很深:不写依赖,就不会有依赖问题。但这句话不是让你反着来,而是提醒你依赖要写得克制。
什么样的依赖才算合理?我自己的经验标准是三条:目标 Unit 所依赖的资源是否真的需要立即就绪;被依赖方是否能被替代(比如用 socket 激活代替常驻服务);依赖方向是否会造成循环。
我在某项目上见过一个非常经典的循环依赖:服务 X 需要网络就绪,网络服务需要某个存储卷挂载,而这个存储卷的挂载动作又依赖服务 X 的某个前置脚本。这种图在纸面上画两分钟就能发现闭环,但实际排查时往往要花几小时,因为报错信息并不会明确告诉你“这里有环”,只会说“Transaction is destructive”。后面我会专门讲如何用工具定位这类问题。
在我参与过的调优场景里,一个循环依赖或者过度依赖,能让启动时间从 2 秒膨胀到 20 秒,最夸张的一次甚至反复 restart。每次看到有人写After=network.target再加上Wants=network.target双重保险,我都要劝一句:这是两个不同维度的东西,After只控制顺序,Wants才控制“要不要拉起来“,别混着用。
3. 启动流程全链路拆解
3.1 从内核到 PID 1 的交接
必须强调一点:systemd 不是内核的一部分,它只是一个用户态程序。内核完成自解压、初始化硬件、挂载初始内存盘之后,会按命令行参数里的init=或rdinit=找到第一个用户态进程。在我们讨论的场景中,这个进程就是/lib/systemd/systemd,它是 PID 1。
PID 1 和普通进程地位不同,内核会特别看待它。PID 1 的退出意味着系统即将失去最后一个常驻进程,内核会立即触发 panic。所以 systemd 从拿到控制权的那一刻起就不能崩,它的内部状态管理也必须格外谨慎。
systemd 会先重新挂载一些关键文件系统,比如把/proc、/sys、/dev调整为正确模式,然后加载自己的配置,包括/etc/systemd/system.conf里的全局参数。这一阶段如果连基础文件系统都挂不对,后续所有服务都起不来。我在调一个内核参数实验时,就因为误改了/etc/fstab中的一个选项,导致 systemd 在初始化阶段反复回退,最后只能进 emergency 模式修复,教训很深刻。
这一阶段排查的关键是看内核日志。
dmesg的前几百行基本就是启动早期的全部现场记录,很多诡异问题都能在这里找到蛛丝马迹。
3.2 解析默认 Target 和生成事务
systemd 启动后要做的事情,并非“读一个列表然后按顺序执行”,而是先“构建启动事务”。它会读取default.target指向哪个 target,再把这个 target 的所有依赖往深处展开,形成一棵由 Unit 和依赖关系组成的树。注意此时还没开始执行,只是在准备计划。
这个过程很像我们做项目管理时先画关键路径:每个 Unit 被分配一个状态值,有 active、inactive、activating、deactivating 等状态;系统会识别哪些 Unit 需要启动、哪些已经处于活跃状态、哪些之间存在顺序要求。
事务构建好之后,systemd 会做一轮“无环校验”和“资源冲突校验”,把所有计划启动的 Unit 放在一起检查。如果发现问题,比如两个 Unit 互相冲突,或者依赖形成了一个环,系统会拒绝本次事务并报错。这种设计避免了“跑了一半才意识到计划是错的”这种灾难。
实操中我建议多试试:
systemd-analyze verify /etc/systemd/system/自定义服务.service这个命令能模拟事务构建过程,把你在单元文件里写错的依赖、引用不存在的 Unit 等问题提前暴露出来。比启动了再去journalctl翻日志省太多时间。
3.3 套接字激活:让服务“懒到极致”
很多 Linux 新手看到 systemd 里有大量的.socket单元,会以为这只是网络服务的封装。其实这恰恰是 systemd 对 SysV 时代的重大创新之一:套接字激活。
举个我实际上的例子。某内部工具用的 web 服务不是常驻内存,早期写法是开机自启并一直待机,但它的访问频率并不高,启动时白白占了内存和依赖链位置。改成套接字激活之后,systemd 先在某个端口上监听,真正有连接进来的一瞬间才把后端服务拉起来。
套接字激活带给启动流程的收益有三层:一是减少启动时的瞬时负载,系统可以更早到达就绪状态;二是依赖关系的表达更灵活,服务 X 只要依赖“某个端口可用”而不是“某个进程已起好”;三是服务崩溃恢复速度更快,监听套接字还在,下次连接能再次触发拉起流程。
我建议所有自研服务先去理解一下Type=notify和Sockets=的组合用法。我自己写过一个模拟数据采集服务,用 socket 激活后,开机启动时间从原来的 1.6 秒降到了 0.2 秒,服务本身常驻内存也大幅减少,这就是实打实的收益。
3.4 挂载与文件系统就绪的判断
文件系统挂载是整个启动流程里最容易出问题的一环,因为它关联了内核、硬件设备、网络、依赖链条等多方面因素。systemd 中每个/etc/fstab条目都会自动转化为一个.mountUnit,挂载成功与否直接影响依赖它的服务能否启动。
最典型的坑是“明明服务没启动,但日志里查不到和它直接相关的错误”。我曾遇到过某服务启动失败,journalctl -u里却显示该服务根本没被拉起,追了半天才发现是它依赖的挂载点迟迟不就绪。
系统对挂载依赖的处理非常严格:如果某个挂载点在等待设备,而设备本身又不支持udevsettle 超时机制,整个依赖链会一直等,最终表现为“系统卡在启动阶段”。
我自己的优化方式是,在能接受的前提下,把非关键挂载点标注为nofail,把网络文件系统挂载改成后台异步模式,同时用x-systemd.automount让某些远程挂载点在第一次访问时才真正挂载。这样能显著缩短启动路径,也把单点故障从启动链路里拆出去。
nofail一定要慎用,它意味着挂载失败时系统不会阻塞,但如果后续服务真的需要那个目录的数据,照样会失败。要结合业务逻辑权衡,不能为了启动快而无脑加。
3.5 日志系统与 early boot 的衔接
很多人忽略的一点是,systemd 启动流程中日志服务的联通也是有关键节点的。systemd-journald会在极早期被拉起,之后各服务产生的 stdout/stderr 都会被收进 journal。如果你的服务启动时报错,但日志里什么都没留下来,十有八九是服务单元文件里没配好StandardOutput=journal或者日志服务本身没起来。
我自己调试服务启动问题时,90% 的时间都在翻 journal:
journalctl -b -1 -u 服务名服务.service --since "1 min ago"这里的-b -1表示上一次启动周期的日志。分析启动问题时,千万别默认去看当前的 journal,很多失败场景发生在上一轮启动过程里,当前周期已经没有现场了。启动阶段的日志配合systemd-analyze critical-chain输出,基本能定位 95% 的常见启动问题。
4. 实操教程:把一台启动 90 秒的机器压到 9 秒内
4.1 先量化现状,再开始优化
我做优化有一个原则:不量化,不优化。没有基线数据,你做的每一步都只是在猜。
第一步,输入命令:
systemd-analyze time输出会包含三部分数值:内核启动时间、用户态初始化到就绪的时间、整体启动时间。比如我看到输出是:
Startup finished in 3.1s (kernel) + 86.9s (userspace) = 90.0s graphical.target reached after 86.9s in userspace这一步就明确了瓶颈在用户态,不在内核。
第二步,查看每个服务的耗时:
systemd-analyze blame这行命令会按各 Unit 启动耗时从多到少排序。你会看到一些长耗时的常见元凶:等待存储设备 settle、慢速网络挂载、无意义的Type=oneshot服务串行执行等。
第三步,看全局关键路径:
systemd-analyze critical-chainblame只告诉你单个服务耗时,critical-chain才是真正让系统到达multi-user.target的关键路径。一个 25 秒的服务如果在非关键路径上,并不会拖慢整体启动;相反,一个本身只有 2 秒的服务如果处在关键路径末端,反而可能成为终点的瓶颈。
4.2 定位真正拖慢系统的元凶
我在实际项目里遇到的经典案例是某内部数据采集服务被设计成了Type=oneshot,并且它RemainAfterExit=yes,脚本里有一个sleep 30等外部依赖超时。blame输出里它就排第一,耗时 30.4 秒。
但光看到它还不行,因为它的启动行为可能并不影响关键路径。我再用critical-chain确认了一下,它确实在关键路径上,原因是有个业务服务After它。优化方向就清晰了:调整顺序,把业务服务对它的依赖改为Wants而不是Requires,同时将内部脚本里的sleep 30改成等待条件循环,缩短等待窗口。
另一个典型案例是systemd-udev-settle.service。这个服务在很多发行版里是空壳,但一旦有硬件环境依赖它,等待时间和存储设备数量直接相关。我处理过一台挂了很多虚拟存储的机器,这个服务的等待时间长达 40 秒,几乎占到整个启动时间的四成。
对这种场景,我的经验是:去掉或注释掉对systemd-udev-settle.service的依赖,改用udevadm settle更精准的用户态等待机制,或者干脆用DeviceAllow缩小设备枚举范围。实测一台机器从 92 秒降到 47 秒。
4.3 优化方案落地和验证
对于服务单元,能并行的就别串行,能延迟的别抢占关键路径。我一般按以下思维顺序来调整:
- 业务无要求的依赖,全部把
Requires降为Wants After只保留“物理上必须在这之后”的,其他全删- 非关键服务统一加
After=network-online.target或更晚的 target,让主链路先走完 - 长期不用的服务,改成 socket 激活以节省资源
调整完之后一定要做一次systemctl daemon-reload,然后重启验证。不要跳过这步直接 reboot,unit 文件改了但没 reload,重启后 systemd 用的还是旧配置,容易产生“为什么我改了没效果”的误会。
还要提醒一句:优化启动速度不是把服务无脑延后,而是把本来集中在启动阶段的串行工作拆散到系统运行期,真正需要时就绪。举个简单例子:一个统计服务每天凌晨跑一次,它完全没必要在开机时加载,用 timer 替代启动自启即可。这种延迟付出的代价很小,收益却很直接。
我自己实测的一次优化:把某个公司内网工具从 90 秒降到 12 秒,靠的就是三招:干掉
udev-settle依赖、把两个串行初始化脚本合并成一个服务、把数据库预热改成按需初始化。全程没有动任何业务代码。
4.4 用 systemd-analyze plot 做可视化与前后对比
如果你习惯看图,可以输出整体启动的矢量时序图:
systemd-analyze plot > boot_plot.svg这个文件能用浏览器直接打开,横轴是时间,纵轴是各 Unit 的服务生命周期,依赖关系通过颜色和块形状展示调整前后的差异。我自己调优时习惯每改一步就生成一版 plot,靠对比图来发现意外引入的依赖。
调优完成后,可以用下面的命令在根目录生成一个比较整齐的报告文件:
systemd-analyze dot --to-pattern='*.target' | dot -Tsvg > boot_dot.svg这需要系统里装了 graphviz。生成的图主要展示 target 之间的关系,虽然日常用不上,但排查循环依赖时非常直观,有环的地方会形成一个明显的闭环。
5. 常见问题与排查技巧实录
5.1 系统卡在启动界面进不去怎么办
我最想分享的一个经验是:永远先区分“卡在内核早期”和“卡在用户态服务”,别再重启死磕。
如果启动时控制台完全没输出,多半是内核阶段的硬件初始化问题,判断方法是看启动时是否出现Reached target ...这一行。如果连Reached target都没有,说明 systemd 还没接管,重点检查内核参数、驱动加载情况。
如果已经能看到部分Reached target,之后卡住,那基本可以断定是某个用户态 Unit 等待超时,常见的是Requires的挂载点一直 Wait 或者某个脚本在前台阻塞。此时用串口或者systemd.log_level=debug启动参数能看到更细的日志,一般足以定位是哪个 Unit 卡住。
另一种偏方是我们团队内部常用的:给内核参数加systemd.unit=rescue.target强制进救援模式。救援模式会跳过绝大多数并行服务,只拉起基础文件系统,只要能进去就能进一步排查。
不要一上来就拔电源。频繁强制断电对文件系统的损伤远大于你想象,有条件先试救援模式,再试 emergency,最后才考虑冷重启。
5.2 服务启动顺序错乱:明明设了 After 还是不对
这个问题的根源多半是After=和Wants=用混了。After=foo.service只保证“如果 foo 要启动,那我等它启动完成”,但完全不影响 foo 是否要启动。如果你想让 foo 必须启动,要配Wants或者Requires。
例如服务 A 启动时需要 NFS 已挂载,但你只写了After=remote-fs.target,没写Wants=remote-fs.target,那么当 remote-fs.target 本身不在当前事务的计划内时,A 可能会先跑,结果就是 A 报错“目录不存在”。
我自己写 service 文件时养成的习惯是:先画一个最小依赖图,然后只写“启动 B 需要 A 存在”的关系,用Requires或Wants表达必要性,用After表达顺序。二者各司其职,缺一不可。
排查排序问题时,直接查:
systemd-analyze critical-chain 服务名.service systemctl list-dependencies --reverse 服务名.service后者可以反过来看有哪些服务在等你,往往依赖错乱就藏在这个反正列表里。
5.3 服务显示 active 但实际没干活
这是开发服务单元时最容易踩的坑:systemctl start 服务名.service后状态是 active,但业务进程根本没在跑,或跑的是一段僵尸 shell。
要区分服务是“进程活着”还是“业务就绪”,需要理解Type=的含义:
Type=simple:systemd 把 fork 出来的主进程作为服务存活标志,进程退它就退,但启动命令执行完不代表业务就绪Type=forking:需要配合PIDFile=,系统会等待主进程 fork 出守护进程并写 pid 文件,如果没有 pid 文件或路径不对,systemd 会误判Type=notify:服务进程通过 sd_notify 主动通知 systemd“我准备好了”,这是最准确的方式,但需要服务端代码配合
如果你写的服务是一个 Python 或 shell 脚本,最简单的是用Type=simple,然后业务内部自行写日志确认运行状态。不要一上来就玩forking,很容错。
5.4 依赖循环导致的事务报错
事务报错长什么样?类似:
Transaction is destructive这一般意味着:想要启动的新集合里有个 Unit,与已激活的某个 Unit 冲突,或者解决方案会导致某些正在运行的东西被停止。还有一种情况是Requires=和After=混在一起形成了环。
我排查这类问题的方法很固定:先禁用掉出问题服务里所有非必要的依赖,逐个恢复,找到最小复现组合。再配合:
systemd-analyze dot --order --requires 服务名.service | dot -Tsvg > dep.svg把这个依赖图导出来用浏览器看,环闭在一起的部分一定就是病灶。多数时候,去掉一条多余的After就能解决。
5.5 救援模式与应急操作速查
每台装 systemd 的机器,都应该提前熟悉三条保命命令:
systemctl rescue systemctl emergency systemctl reboot -f救援模式保留了基本的本地文件系统挂载和高等级的服务,但没有网络和图形界面;emergency 模式则是更底层的 shell,只读挂载根文件系统,适合修复 fstab 或者修改服务单元文件。如果你把某个服务写坏了导致 boot 死循环,可以先进入 emergency,注释掉对应的 Unit 文件,再重启。
我在某次项目实验中,就是因为把一个服务配成
Restart=always又没设置StartLimitInterval,导致它无限重启占满系统资源,最后连 SSH 都连不上。进 emergency 模式后直接屏蔽该服务才救回来。如果再遇到这种“死循环重启”的情况,记得检查StartLimitIntervalSec和StartLimitBurst这两个参数,人家设计出来就是给你兜底的。
6. 工具与收尾心得
6.1 分析启动流程的常用工具清单
工具不必多,每类有一两个好用的就够。我日常会用的工具没有一个是冷门的,但组合起来效率很高。
| 工具 | 用途 | 典型操作 |
|---|---|---|
| systemd-analyze | 时间、拓扑、验证 | systemd-analyze time/blame/critical-chain/verify/plot/dot |
| journalctl | 日志检索与问题定位 | journalctl -b -1 -u 某服务.service |
| systemctl | 单元状态与依赖关系 | systemctl list-dependencies --reverse 某服务.service |
| strace | 服务进程内部调用追踪 | 定位已启动但业务未就绪的进程 |
| perf / bpftrace | 启动热点深入分析 | 适合系统级启动热点了然于胸的场景 |
大多数情况下,前三个工具足够解决 90% 的问题。strace 和 perf 属于锦上添花,建议在系统卡得不明显、需要看进程内部行为时再用。
6.2 我给新手的建议
如果让我给第一次接触 systemd 的人一个指引,我会说:先别急着读几百页手册,把你机器上所有正在跑的 service 列出来,然后盯着一两个看它的依赖关系,再手动 stop/start 一次,感受状态流转。
systemctl list-units --type=service --state=active --no-pager从自己熟悉的服务入手,再逐渐扩展到target、socket、timer,你会发现体系越来越清晰。不要害怕报错,journal 里每一行报错背后都对应一个具体的排查场景,踩过一次就记住了。
最后再分享一个我自己坚持多年的习惯:每次对 systemd 配置做改动,先把改动前的systemd-analyze blame和critical-chain结果存一份,改完之后再做一次对比。这能让你知道每一步改动到底产生了什么影响,而不是“觉得自己优化了,实际上启动了更慢的服务链”。启动流程分析这件事,本质是把看不见的依赖和耗时变成看得见的数据,剩下的,就只是耐心和一点点排查技巧了。