每次按下电源键,从内核交出控制权到系统进入可交互的登录界面,这一整段旅程的调度核心就是systemd。作为几乎所有主流Linux发行版默认的init程序,systemd以一号进程的身份接管了用户态的全部启动工作——从挂载文件系统、拉起设备管理服务,到按依赖关系并行激活数百个单元,再到把登录管理器、网络服务、定时任务逐个带起来。这篇文章想跟你聊聊我对systemd启动流程的分析:核心的单元状态机、target体系、依赖解析逻辑,以及实际调试中常用的分析工具和排障手段。
无论你是刚接触Linux的新手,还是常年跟服务器打交道的运维,把这条启动链看懂,开机慢、服务起不来这类问题就都有了清晰的排查方向。我会尽量把每一步都讲透,包括那些平时文档里不会写、但实际排查时非常关键的经验细节。
1. 内核交接:systemd如何成为系统总管
1.1 从固件到内核:真正的启动起点在哪里
先说清楚边界。大部分文章讲systemd启动流程,动不动就从BIOS、UEFI开始讲,那其实是误导。开机后先经过固件自检、引导程序加载,然后内核被解压运行,接管硬件资源,这些阶段和systemd还没有任何关系。真正的交接点是内核启动过程的最后一步:当根文件系统准备就绪后,内核会在根目录查找init程序,并把控制权交给它。
在systemd之前的年代,这个init可能是传统的System V init脚本体系。现在的主流发行版内核会默认使用“init=/sbin/init”这个逻辑,而/sbin/init通常是指向systemd的符号链接。内核拉起这个进程时,会一并传入引导程序带过来的内核参数,比如systemd.unit=、systemd.log_target=这类。从这一刻起,PID 1这个进程就诞生了。这个身份很特殊:它既是所有用户态进程的祖先,又直接控制整个系统的生命周期。如果你在系统运行中去kill掉PID 1,内核会直接panic,整个机器瞬间不可用。这也是为什么systemd在设计上特别强调“自己不能挂”。
1.2 早期初始化阶段:先把地基打好
systemd作为PID 1启动后,并不会立刻去拉起一堆服务。它要先完成一系列“地基性”工作。这个阶段在systemd内部叫早期启动阶段(early boot),核心任务包括:
- 初始化基础目录结构:挂载proc、sysfs、cgroup等特殊文件系统,创建/dev下的基础设备节点,建立/run目录。
- 重新挂载根文件系统:把根文件系统以读写方式remount,这是在进入常规启动之前必须做的一步。
- 启动设备管理守护进程:也就是systemd-udevd,它负责监听内核事件、创建动态设备节点、加载设备驱动和解析udev规则。
- 加载内核模块:按系统配置加载必要的模块,比如某些文件系统模块或硬件驱动。
- 执行tmpfiles处理:按配置创建或清理临时文件与运行时目录,比如/run/lock、/tmp下的权限修正。
- 初始化基础系统参数:设置主机名、加载随机数种子、生成机器ID等。
这个阶段的特点是非常同步、几乎不能并行。因为后续每一步操作都要依赖这些基础资源已经存在。你可以把它类比成装修开工前的“水电改造”,水电不到位,后面所有工序都是空谈。早期阶段如果出了问题,通常系统会直接停在黑屏或一个非常基础的shell状态,而不是给你一个正常的登录界面。
1.3 单元状态机:读懂启动的最小单位
早期启动阶段完成后,systemd才进入真正意义上的单元(unit)调度阶段。单元是systemd管理一切资源的基本抽象。一个服务、一个挂载点、一个目标、一个套接字,甚至一个定时器,都对应一个unit文件。最常见的可能是.service,所以很多人都把systemd简单理解成“管理服务的”,其实它管理的范围远不止进程。
unit是有状态的。一个单元从被加载到最终运行,要经历inactive、activating、active、deactivating、inactive这样的循环。如果失败,状态会标记为failed。这里有一个新手容易误解的点:在systemd的语境里,“激活”不等于“进程已经跑起来”。比如一个oneshot类型的服务,执行完预设命令后就退出进程,此时它的状态可能是inactive(dead),但如果配置了RemainAfterExit=yes,即使没有常驻进程,也会被标记为active。状态机帮你识别的是“这个单元是否完成了它的职责”,而不只是“有没有进程在跑”。
systemd的调度基于job(任务)。当它决定要启动某个单元时,会创建一个启动任务,然后处理这个任务与其它任务之间的依赖关系,最终形成一张事务图。事务的规则是:要么整张图的所有任务都能成功,要么一个都不做。这个设计避免了“半拉子启动”带来的不确定状态。理解这一点很重要——它解释了为什么有时候你disable掉一个服务,另一个依赖它的服务也会被连锁停掉。
2. 目标体系与依赖解析:并行启动的核心引擎
2.1 单元文件从哪里来,覆盖规则是什么
在讨论依赖调度之前,先交代一下unit文件的存储位置。系统中有多个目录存放unit文件,高优先级目录会覆盖低优先级。优先级从高到低:
- /etc/systemd/system/:系统管理员自定义文件,优先级最高。
- /run/systemd/system/:运行时生成的文件,重启后消失。
- /usr/lib/systemd/system/:软件包安装时带过来的默认文件。
这个优先级设计非常实用。我举个例子:某个软件自带的unit假设了某些环境变量,但你的环境不同,最稳妥的修复方式不是去改/usr/lib下的原始文件,而是把修改后的unit拷到/etc/systemd/system下覆盖。包管理器更新软件时,不会碰你自定义的文件,这是systemd目录设计最友好的地方之一。
实际操作中还有一种更干净的覆盖方式:drop-in目录。也就是在/etc/systemd/system/某个服务名.service.d/下面放一个override.conf,只写你需要覆盖的配置片段,跑systemctl daemon-reload之后生效。这样既不用拷贝整个文件,也不怕软件包更新后原始文件内容变化。用systemctl edit某个服务名,它会自动帮你创建这个目录并打开编辑器,非常省事。
2.2 依赖声明:After、Requires、Wants怎么配合
一个服务的启动时机,并不是由它在命令行中的排列顺序决定的,而是由它声明的依赖关系决定。你会经常在unit文件里看到这几行:
[Unit] Requires=network.target After=network.target Wants=remote-fs.target Before=某服务.service每个字段的作用完全不同:
- Requires是强依赖:本单元启动时,会确保依赖单元也被启动;如果依赖单元启动失败,本单元也会被停止。
- Wants是弱依赖:会尝试拉起依赖单元,但依赖失败不会阻止本单元继续启动。
- After是排序关系:声明“我必须在依赖单元之后启动”。但注意,After本身不负责拉起依赖。
- Before是反向排序:“我必须在某个单元之前启动”。
这里有一个非常常见的误区:很多人以为写了After,服务就会被自动带起来。其实After只表达顺序,不产生任何启动动作。真正把依赖拉起来的是Requires或Wants。两者必须配合使用。如果只写After不写Requires/Wants,而依赖的服务又没有被其它方式触发,你的服务可能跑到依赖服务前面,启动后连接失败,报一堆“网络不可达”或“文件系统不存在”的错误。所以一个规范的服务单元里,After和Requires经常成对出现。
我把这四个字段的关系整理成一句话:Requires决定“要不要拉起”,After决定“什么时候拉”,Wants是“尽力拉一下但别影响我”,Before是“请让我先跑”。
2.3 target体系:从运行级别到目标状态
传统System V init用运行级别(runlevel)来描述系统的不同状态,数字从0到6,分别代表关机、单用户、多用户、图形界面等。systemd对应的概念叫target,但它是一种“目标状态”,而不是逐步递增的序号。
比较核心的几个target:
- poweroff.target:关机。
- rescue.target:单用户维护模式。
- multi-user.target:多用户命令行模式。
- graphical.target:多用户图形模式。
target本身不做具体工作,它的作用是把一堆单元“聚到一起”。你如果用systemctl list-dependencies default.target查看依赖树,会看到一大串单元像树一样展开。默认target被激活时,就是把这些单元按依赖关系全部解析出来,逐个调度。
target体系相比运行级别的一个明显好处是:你可以同时依赖多个target,而传统运行级别只有一个数字,很难表达“既要网络、又要图形”这类复杂组合。另一个实用点是“兼容层”:systemd提供了runlevel*.target这种映射target,让老脚本依然能通过chkconfig之类工具管理服务。如果你在旧系统迁移后发现某个服务没有被正常拉起,可以顺手检查一下它是否还在依赖runlevel 3这种古老写法,改成直接让服务软链到multi-user.target的.wants目录会更稳妥。
2.4 按需激活:socket、dbus与path单元
这是systemd启动流程里最有智慧的部分,也是最容易被忽略的部分。传统SysV启动服务,不管有没有人用,开机全拉起;systemd引入了“按需启动”。
最经典的是socket激活。假设系统里跑了一个低频访问的Web服务,正常情况它会监听一个端口。传统做法是开机就把服务进程拉起来常驻监听。用socket激活后,systemd会在开机阶段只创建一个监听套接字的.socket单元,进程本身不启动。直到有外部连接真正进入这个端口,systemd才动态拉起对应的.service。这样开机时少拉几十个进程,启动时间自然就下来了。
dbus激活的原理类似:某个服务等待dbus请求,当其它程序向总线发出特定调用时,dbus守护进程会通知systemd按需拉起。还有path单元,监控某个目录的变化,比如把文件放入某个目录时触发服务处理。
不过这里有个容易踩的坑:按需激活会让服务的首次访问变慢。首次连接进来,systemd要先创建进程、加载配置,可能需要一两秒甚至更久。如果这个服务对响应时间敏感,比如数据库或核心API,用socket激活反而会招来用户抱怨。我的建议是:按需激活适合低频访问的服务,高频核心服务还是老老实实自启吧。
3. 实战:从systemd自带工具还原整条启动链
3.1 systemd-analyze time:把开机时间切成三段
进入实操部分。分析启动流程,最常用的是systemd自带的分析工具。第一条命令:
systemd-analyze time输出类似这样:
Startup finished in 2.847s (kernel) + 1.241s (initrd) + 8.256s (userspace) = 12.865s它把启动时间切成三段:内核时间、initrd时间、用户态时间。判断问题出在哪一阶段,先看这段就能确定大方向。如果kernel时间特别长,问题在驱动、固件加载;如果initrd时间特别长,问题在早期用户态,比如磁盘解密、根文件系统等待;userspace时间长,才轮到systemd的单元调度背锅。
我在实际排查中一般先跑这条命令,把大方向锁定,再去追细节。如果你发现userspace占了10秒以上,而kernel只有1秒,那基本不用去翻固件设置了,问题就在用户态的单元调度里。
3.2 systemd-analyze blame:找出耗时大户
第二步是常用的“找罪魁祸首”命令:
systemd-analyze blame它会按照单元从开始激活到完成激活的耗时排序。输出片段大概是:
7.533s 某网络服务.service 3.003s 某数据库服务.service 2.514s systemd-udev-settle.service 1.200s 某日志服务.serviceblame的逻辑非常简单:谁花的时间长,谁排在前面。但它有一个明显的误区:它只看单个单元的耗时,不识别这个耗时是否与其它单元并行。一个单元启动花了7秒,但如果它和别的单元并行跑,对整体开机时间的实际影响可能没有7秒那么大。所以blame适合快速发现“异常耗时的单元”,但别急着根据它来优化整体速度。
3.3 systemd-analyze critical-chain:定位真正的关键路径
要判断“哪个单元实际拖慢了开机时间”,应该看critical-chain。这个命令会从default.target一路往前回溯,找出真正存在顺序依赖的链条。输出类似:
graphical.target └─multi-user.target └─某网络服务.service 7.533s └─network-pre.target └─systemd-udevd.service ...critical-chain的价值在于:它展示的是不能并行、必须等前一个完成才能进行下一步的关键路径。这条链上的时间损耗才是直接影响开机时间的东西。blame里的耗时大户如果不在关键链上,那你砍掉它,用户感受到的开机提速可能微乎其微。
我在做开机优化时,都是把blame和critical-chain两个结果交叉着看。blame告诉你哪些单元“吃时间”,critical-chain告诉你哪些单元真正“挡住后面”。两者叠起来,才能找到真正的优化对象。另外提醒一句:如果你在容器或虚拟化环境里跑这些命令,时间会受到虚拟化层影响,参考意义有限,最好在物理机或真实环境里分析。
3.4 journalctl:从日志里还原现场
工具只能给宏观表象,细节藏在日志里。systemd的日志服务(systemd-journald)是开机早期就拉起的服务之一,所以它可以记录到从启动一开始用户态发生的几乎所有事件。
journalctl -b # 本次启动的日志 journalctl -b -1 # 上一次启动的日志 journalctl -b -2 # 上上次,以此类推 journalctl --list-boots # 查看所有启动记录列表需要盯某个服务时:
journalctl -u 某服务.service -b --no-pager或者只看错误级别:
journalctl -p err -b这个-p参数很实用。如果你系统起不来,但又不想被大量info日志淹没,用-p err就能过滤出错误级别及更严重的事件,快速聚焦。如果是服务启动失败,先跑systemctl status看底,再翻journalctl,九成情况能直接对到失败原因。比如某服务报timeout、报cannot find unit、报权限不足,都能在这一步水落石出。
4. 故障现场:启动流程中的典型问题与排查方法
4.1 启动卡住:先用控制台日志锁死嫌疑单元
遇到系统开机卡住,先不要慌着看systemd的东西,判断卡点更重要。如果卡在引导程序界面,那是引导阶段的问题;如果内核日志已经出现但没有任何用户态进度,可能是initrd阶段挂了;如果能看到systemd的日志滚动但卡在某个服务,那才是单元调度的问题。
在systemd单元调度阶段卡住,最直接的手段是重启并按引导程序快捷键进入内核参数编辑,追加:
systemd.log_level=debug systemd.log_target=console这条参数可以让systemd把日志直接打到控制台。启动到卡住的那一步,屏幕上的最后一行日志会明确告诉你它卡在哪个单元、在等什么。比起猜来猜去,这个方法定位效率最高。
我有一次处理一个远程服务器卡启动的问题,就是用这个方法看到了它卡在“等待某块磁盘出现”。后来排查发现是/etc/fstab里挂载了一个不存在的设备,内核和设备管理服务等了一整个超时周期。改掉fstab里的错误条目后,开机立刻恢复正常。
4.2 服务启动失败:标准排查流程
服务失败是另一类高频问题。这里给一个我实际用过很多次的排查套路,按这个顺序走,基本不会漏:
- 先看systemctl status 某服务.service,拿到失败状态和最近的日志尾部。
- 如果信息不够,用journalctl -u 某服务.service -b -n 100看最近100行。
- 分析失败类型:进程退出码非0、启动超时、端口被占用、权限不足、依赖服务没起来。
- 根据失败类型对症处理:端口占用就先停占用方再启动,权限问题查目录属主和安全策略,启动超时则可能要调大TimeoutStartSec。
有一个特别常见的坑值得单独说:Type=oneshot类型的服务,执行完命令就退出,如果没有设置RemainAfterExit=yes,服务状态会立刻变成inactive(dead)。很多人看到状态显示inactive就慌了,以为启动失败,其实那是正常现象,因为oneshot本来就不要求常驻进程。要区分“正常结束”和“异常失败”,看systemctl status里有没有标注failed,远比看是不是active更靠谱。
另外,如果服务配置被改乱了,或者你写错了某个参数,优先用drop-in方式覆盖,而不是直接改原始unit。改完后一定要执行systemctl daemon-reload,否则systemd还拿着旧的配置在跑,改了半天等于没改。
4.3 rescue与emergency:最后的安全屋
当系统已经无法正常进入多用户模式,比如关键服务总是启动失败,或者磁盘挂载有问题,你还有两个后路:rescue.target和emergency.target。rescue模式会拉起最小化的系统环境,挂载基础文件系统,但不启动多用户服务。emergency模式更极端,它几乎只提供一个shell,不挂载多余的文件系统。进入方式是在内核参数里追加:
systemd.unit=rescue.target或者:
systemd.unit=emergency.target这两个模式是排查问题时的“安全屋”。我在处理一次磁盘挂载错误时,系统再也进不去正常模式,先靠emergency模式进入shell,修复了fstab里的错误条目,再重启就恢复了。注意:rescue模式下root密码是必须的,而且某些文件系统是只读状态,修改配置前先确认挂载状态,不然你会发现写了半天都是失败的。
进入rescue或emergency模式后,往往需要手动挂载或重新配置网络来收集诊断信息。可以用mount -o remount,rw /这种方式先让根文件系统可写,然后处理问题。修复完记得exit或让系统继续启动,别在安全屋里待着不动。
4.4 启动流程常见问题速查表
这里整理一张速查表,对应启动流程中最高频的几个故障现象。
| 现象 | 可能原因 | 排查入口 | 处理参考 |
|---|---|---|---|
| 启动卡在某服务,进度条不动 | 服务等待资源(网络、设备)超时 | systemd.log_level=debug console | 调整等待时间或依赖关系 |
| 服务状态是failed | 进程退出码、脚本错误、端口占用 | systemctl status + journalctl | 修复原因后reset-failed再启动 |
| 开机越来越慢 | 自启服务过多、某服务持续重启刷日志 | systemd-analyze blame | 精简自启项,检查Restart策略 |
| 修改unit后不生效 | 缓存未刷新,或改错目录 | systemctl daemon-reload | 优先使用/etc/systemd/system覆盖 |
| disable没效果,服务仍启动 | 被其它单元依赖,或还有别名引用 | systemctl list-dependencies | 查找依赖方或使用mask屏蔽 |
| oneshot显示inactive | 正常,任务已执行完 | systemctl status观察日志 | 不用处理,或加RemainAfterExit=yes |
这张表不是全部答案,但它覆盖了我在一线运维中碰过最高频的分类。记住一点:systemd的所有行为都可以从unit文件和依赖关系里推导出来,日志里的线索永远优先于你的“猜测”。
5. 启动优化的流程级思考
5.1 优化思路:并行、按需、统一管理
最后聊聊优化。优化启动流程的思路,其实也是理解systemd设计思路的三个关键词:并行、按需、统一管理。
并行意味着systemd的消息循环可以同时处理多个启动任务,不存在传统脚本那样“一步一步执行”的限制。但这有个前提:依赖关系必须写清楚,不然并行就乱了。你在优化某个服务时,如果拿不准它和另一个服务有没有潜在冲突,宁可保留After关系,也不要为了抢零点几秒把顺序砍掉。
按需前面已经讲过,把低频服务改成按需启动,是最安全、效果也最好的优化手段。定时任务也是类似道理,.timer单元可以在特定时间点才唤醒对应服务,而不是开机就把服务拉起来占资源。
统一管理则体现在systemd通过cgroup把所有子进程纳管,启动、停止、重启都有标准化流程。这意味着你可以放心地关掉一个服务,它不会留下孤儿进程。这在手动管理机器时非常宝贵,也是我长期用下来觉得systemd最值得依赖的部分。
5.2 两条优化主线:减单元与减等待
启动优化在实践层面就两条线。一条是减少要启动的单元数量:把不用的服务disable掉,尤其是那些你根本不记得装来干嘛的服务。你可以用systemctl list-unit-files看哪些是enabled状态,再逐个确认。另一条是减少等待:如果你确认两个单元没有依赖关系,去掉多余的After;如果是等待网络就绪,考虑调整网络等待配置,而不是让所有服务死等网络完全可用。
平时我还会特别留意那些“启动多次失败又反复重试”的服务。它们虽然不一定会让开机彻底卡住,但会在用户态时间上增加不少开销。检查Restart=和RestartSec=参数,把无谓的重试间隔调大,能减少很多无意义的等待。
但是要注意,放松某个服务的启动超时只会掩盖问题。TimeoutStartSec调大到120秒,问题并不会消失,只是晚点爆出来。排查时优先确认它究竟在等什么,而不是单纯去调大超时。
5.3 常用优化步骤:我的一套实操顺序
我在实际优化中从不追求把开机压缩到极致,因为过度并行会导致后面的服务在启动时竞争磁盘、CPU,反而更慢。合理的优化目标是让开机时间稳定下来,让自动启动的那些服务都是确实需要的。如果非要给一个步骤,我的常用顺序是:
- 先跑systemd-analyze blame和critical-chain,交叉定位真正在关键路径上、且耗时不合理的单元。
- 判断这个单元是否必须开机启动,不是的话disable或改成按需启动。
- 必须启动的话,检查它的启动脚本里有没有可以砍的阻塞点,比如反复sleep、串行等待网络、等待外部资源。
- 用systemctl edit创建drop-in配置,微调单元参数。
- 修改后执行systemctl daemon-reload,然后重启多次验证稳定性和时间波动。
最后说点个人体会。systemd从诞生起一直争议不断,但从启动流程分析的角度看,它的分层设计和依赖解析逻辑,确实把“开机”这件事从黑盒脚本变成了一套可观测、可审计的系统。我刚开始接触时,也经常被各种target、依赖字段搞得头大,但后来发现一个朴素的规律:多看几张unit文件、多跑几次systemd-analyze,再把日志翻出来对照,理解速度会快得多。如果你正被某个启动问题卡住,不必急着去搜一堆杂乱的优化指南,把systemctl和journalctl这两条命令用熟,已经能解决大半问题。