凌晨两点半,一台机器重启之后死死卡在启动画面,最后一行日志是内核抛出来的No working init found——init 进程没能起来。前面几百行跑得飞快,ACPI、PCI 枚举、块设备识别全过了,眼瞅着就要进系统,结果在最后一哆嗦的地方断掉。这个场景特别能说明问题:内核本身只是个"硬件驱动加调度器"的集合体,它把 CPU、内存、磁盘、网卡都认全之后,必须把控制权交给用户空间的第一个进程,由这个进程去挂根、拉服务、给你一个能登录的界面。这条交棒链路只在内核最后几十行代码里完成,但它牵扯到根文件系统怎么挂、init 程序从哪儿找、找到之后能不能执行、执行起来之后谁负责给孤儿进程收尸,任何一环断了,系统就停在那行 panic 上。
我做过不少嵌入式最小系统和服务器救援的活儿,遇到启动卡死的情况,第一步永远是判断"卡在内核态还是卡在用户空间",而这条分界线就在 init 进程身上。能把 init 启动这一小段吃透,等于拿到了整个启动流程的钥匙。这篇就围绕 init 进程的诞生现场、三代 init 的设计取舍、手搓一个最小 init 的完整路径、initramfs 里那个更早的 init,以及 systemd 接手之后的排障方法,把我这些年攒下来的东西摊开说。
1. init 进程的诞生现场:内核是在哪一行把控制权交出去的
1.1 start_kernel 末尾那两个特殊线程
start_kernel()是内核 C 语言入口,它把架构相关的初始化、内存管理、调度器、中断、计时器一路配好,到最后会调用rest_init()。这个函数名字起得很诚实——"剩下的初始化"。它干的第一件正经事是创建一个内核线程,也就是后来的 PID 1:
static noinline void __ref rest_init(void) { struct task_struct *tsk; int pid; rcu_scheduler_starting(); pid = kernel_thread(kernel_init, NULL, CLONE_FS); pid = kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES); cpu_startup_entry(CPUHP_ONLINE); }这里有个容易被忽略的细节:创建 init 和创建 kthreadd 的顺序是刻意安排的。init 必须最先创建,才能拿到 PID 1 这个号;但 init 后面会去创建各种内核线程,而创建内核线程的工作实际上要依赖 kthreadd,所以 kthreadd 必须紧跟着建出来,且在 init 真正跑起来之前完成。源码注释里写得很直白:如果先调度 init 再建 kthreadd,会 OOPS。这种"看起来顺序无所谓、实际上顺序要命"的细节,在内核里比比皆是。
更关键的一点:此刻的 PID 1 还只是个内核线程,它有自己的内核栈、跑在内核态、用的是内核页表。它和用户空间的 init 进程只是"将来会变成同一个",现在还不是。
1.2 kernel_init 的收尾清单:从 do_basic_setup 到 mount_root
PID 1 被唤醒后进入kernel_init(),先调kernel_init_freeable(),里面做的事包括do_basic_setup()——把所有__initcall级别的驱动初始化函数跑一遍,这时候各种module_init()注册的东西才真正执行;然后是打开控制台、准备argv_init和envp_init,再通过prepare_namespace()处理根文件系统相关的挂载逻辑。
这一步跑完之后,kernel_init会做几件"过河拆桥"的事:同步所有异步初始化、释放 init 段内存、把内核页表标记成只读。做完这些,内核自己那部分内存就被锁死了,不会再被改动。然后把system_state设成SYSTEM_RUNNING,紧接着开始尝试执行 init 程序。
执行的函数叫run_init_process(),核心只有一句kernel_execve(init_filename, argv_init, envp_init)。这个调用非常特殊:成功时不返回。它不是普通函数调用那样执行完回来继续往下走,而是直接把当前这个内核线程"改装"成用户态进程——换掉地址空间、换掉栈、跳到 ELF 入口点。所以kernel_execve的返回值只有失败时才有意义,成功的话执行流已经在用户空间里了,内核里这条路径就此终结。
我在给学生讲这段的时候喜欢打个比方:内核像是一个把房子装修好的施工队,kernel_execve就是交钥匙那一刻。钥匙递出去,施工队撤场,房子里住进来的人跟施工队再没直接关系了。之后所有的"系统行为",都是这个住户在管。
1.3 查找顺序与 init=、rdinit= 的区别
kernel_init里真正的查找逻辑长这样(不同内核版本细节略有差异,主干一致):
if (ramdisk_execute_command) { ret = run_init_process(ramdisk_execute_command); if (!ret) return 0; pr_err("Failed to execute %s (error %d)\n", ramdisk_execute_command, ret); } if (execute_command) { ret = run_init_process(execute_command); if (!ret) return 0; panic("Requested init %s failed (error %d).", execute_command, ret); } if (!try_to_run_init_process("/sbin/init") || !try_to_run_init_process("/etc/init") || !try_to_run_init_process("/bin/init") || !try_to_run_init_process("/bin/sh")) return 0; panic("No working init found. ...");把这段读透,能得出三个非常实用的结论。
第一,rdinit=和init=不是一回事。rdinit=对应ramdisk_execute_command,只在 initramfs(或 initrd)里有对应文件时才生效,它是给临时根环境用的;init=对应execute_command,是给最终根文件系统用的。两个都写的时候,rdinit=优先。
第二,init=指定的程序一旦执行失败,内核直接 panic,不会继续往下试默认路径。这个行为坑过不少人:有人在 GRUB 里手写了init=/bin/sh,结果 /bin/sh 是个动态链接程序、根还没挂对、缺库,内核就停在Requested init /bin/sh failed上,连退回去试/sbin/init的机会都没有。所以临时改init=之前,先确认那个路径在你当前的根环境里真的存在且可执行。
第三,默认的四条路径是有优先级的:/sbin/init→/etc/init→/bin/init→/bin/sh。绝大多数发行版把/sbin/init做成符号链接指向真正的 init 程序(systemd 就是/lib/systemd/systemd或类似路径)。最后那个/bin/sh是保底手段,注释里明说"可以用 Bourne shell 替代 init 来抢救一台彻底坏掉的机器"——但前提是根文件系统已经挂上、shell 能跑起来。
提示:如果日志里出现
Run /sbin/init as init process这行,说明内核已经成功挂根并找到了 init,接下来的问题一定在用户空间;如果这行之后立刻 panic,问题大概率是动态库缺失或 ELF 格式不匹配,而不是根没挂上。
1.4 PID 1 的特权与代价
变成用户进程之后,PID 1 身上挂了一堆别人没有的特殊规则,这些规则不是内核在"照顾"它,而是因为系统需要一个绝对可靠的兜底者。
信号处理不一样。默认情况下,发给 PID 1 的、它没有显式注册处理函数的信号会被直接丢弃,包括 SIGKILL 和 SIGSTOP。也就是说你在终端里敲kill -9 1,什么都不会发生。这不是 bug,是设计:如果 init 能被随便杀掉,整个系统就没人管了。代价是,如果你的 init 程序自己卡死了,你也没法用常规手段干掉它,只能重启。
孤儿进程的收养者。任何进程的父进程退出之后,它的子进程会被重新挂到 PID 1 名下。这让 init 天然成了进程树的根。现在内核也支持通过prctl(PR_SET_CHILD_SUBREAPER)指定"次级收养者",容器场景里经常这么用,但默认的最终归宿还是 1。
僵尸进程的回收者。子进程退出后如果父进程没调wait(),就会留下僵尸。被 init 收养的那些进程退出后,得由 init 负责收尸。所以一个写得糟糕的 init 脚本很容易攒一堆僵尸,ps一看满屏Z状态,根因往往就是 init 没写循环 wait。自己手写 init 的时候,这一条是必须处理的,后文会给出具体写法。
这三条特权合起来说明一件事:PID 1 不是"第一个普通进程",它在进程模型里的位置是特殊的。理解了这一点,才能理解为什么 systemd 要把那么多东西(cgroup 追踪、服务生命周期)都揽在自己身上——因为它本来就是唯一一个能兜住所有情况的位置。
2. 同样是 PID 1,三代 init 的设计哲学差在哪
2.1 SysV init:运行级别其实就是一串脚本目录
最早的 init 实现思路极其朴素:读/etc/inittab,根据里面的配置决定系统进入哪个运行级别,然后进对应目录跑脚本。
inittab的每一行格式是id:runlevels:action:process,比如:
id:3:initdefault: si::sysinit:/etc/rc.d/rc.sysinit l0:0:wait:/etc/rc.d/rc 0 l3:3:wait:/etc/rc.d/rc 3运行级别 0 到 6 各有含义,0 是关机、1 是单用户、3 是多用户命令行、5 是图形界面、6 是重启(不同发行版略有出入)。选定级别之后,rc脚本会去/etc/rc.d/rc3.d/这类目录里找以S开头的符号链接,按S后面两位数字从小到大依次执行;关机时反过来找K开头的,从大到小执行。
这套机制的可读性其实不错:想知道开机启动了哪些服务,ls /etc/rc3.d/一目了然;想调整顺序,改符号链接的数字前缀就行。但它的硬伤也很明显:
- 纯串行。前一个脚本不退出,后一个永远不开始。某个服务卡在等待网络上,整个启动就停在那儿了。
- 没有依赖表达。两个服务之间的先后关系只能靠数字编号人为约定,数字排错了就会出现"数据库还没起、应用先起来了"这种问题。
- 状态不可查。脚本跑完之后,init 并不知道这个服务的实际状态,
ps里有没有进程只能靠人工看。 - 重启服务麻烦。停一个服务得自己写 stop 逻辑,没有统一的接口。
我在老系统上排查过一类经典问题:某个 rc 脚本里写了sleep 30等网络就绪,结果每次开机都白等 30 秒。想优化?你得先把这个脚本找出来,还得确认没有别的脚本依赖它的执行时机。这种耦合在纯数字编号的体系里几乎无法安全地梳理。
2.2 Upstart:事件驱动补齐了串行的短板
Upstart 的思路是把"开机"从一条固定的流水线改成一串事件。配置文件放在/etc/init/,长这样:
start on started network stop on runlevel [016] respawn exec /usr/sbin/myservice服务不再依赖数字顺序,而是声明"我在什么事件之后启动"。启动过程变成事件驱动的:网络起来了发个事件,等这个事件的服务一起被唤醒,可以并行跑。服务挂了还能respawn自动拉起,这在 SysV 时代要靠额外的守护进程实现。
这个设计在当时确实是进步,但它的问题是只改了一半。事件模型解决了启动并行的问题,却没有建立起完整的服务状态管理;依赖关系用事件名硬编码,事件名一多就变成了另一种形式的意大利面条;而且它没有解决服务之间的资源隔离、日志归集这些配套问题。所以它更像是一个过渡形态,在 systemd 铺开之后很快退场了。
2.3 systemd:依赖图、并行与 cgroup 追踪
systemd 的核心变化不在于"用什么语言写的",而在于它把启动过程建模成了一张有向图,节点是单元(unit),边是依赖和顺序关系。
单元类型主要有几种:.service描述服务,.target描述一组单元的集合(可以理解为"新的运行级别"),.socket描述套接字,.mount描述挂载点,.timer描述定时器。依赖关键字里最常用的几组:
| 关键字 | 含义 | 使用建议 |
|---|---|---|
Requires= | 硬依赖,被依赖单元失败则本单元也失败 | 慎用,容易引发连锁失败 |
Wants= | 软依赖,被依赖单元失败不影响本单元 | 大多数场景应该用这个 |
After=/Before= | 只控制启动顺序,不建立依赖 | 常与Wants搭配 |
Requisite= | 被依赖单元必须已启动,否则直接失败 | 用于严格前置条件 |
这里有个新手最容易踩的坑:以为写了After=network.target就等于"等网络可用"。不是的。After只管顺序,不管结果,network.target起来只代表网络配置流程走完了,不代表网卡真的有地址。想等网络真通,得用network-online.target,而且还要配合Wants=。
并行的实现方式是:systemd 解析完所有单元的依赖,算出一个可执行顺序,然后把没有相互依赖的单元同时拉起。所以开机时间从"所有脚本耗时之和"变成了"关键路径上最长的那条链的耗时"。这也是为什么systemd-analyze critical-chain比blame更有指导意义——它告诉你的才是真正的瓶颈链。
cgroup 的引入则是另一个维度的变化:systemd 把每个服务放进独立的 cgroup,服务 fork 出来的所有子孙进程自动归入同一个组。要停服务,直接对 cgroup 里的所有进程发信号,不用再靠 PID 文件猜。这解决了几十年来"守护进程 fork 之后主进程退出、PID 文件对不上"的顽疾。
三代 init 的对比可以用一句话概括:SysV 是"按剧本走流程",Upstart 是"听广播做动作",systemd 是"先画依赖图再并行调度"。理解了这条演化线,看任何发行版的启动日志都不会迷路。
3. 亲手做一个最小 init:BusyBox 方案的完整复现
光看代码容易虚,我建议每个想搞明白启动流程的人都亲手做一个能跑的最小系统。下面这套流程我在不同架构上跑过很多次,成本很低,一两个小时能出结果。
3.1 准备最小 rootfs 骨架
先建目录树。最小集合是这些:
mkdir -p rootfs/{bin,sbin,etc/init.d,proc,sys,dev,tmp,lib,lib64,root} chmod 1777 rootfs/tmp/proc、/sys、/dev这三个目录必须事先存在,因为后面要往上面挂文件系统,挂载点不存在的话挂载会失败。/tmp给 1777 权限是因为很多程序会往这里写临时文件,权限不对会出现各种莫名其妙的失败。
接着把 BusyBox 拷进去。用静态编译的版本最省事:
cp /path/to/busybox rootfs/bin/ cd rootfs/bin for cmd in sh ls mount umount cat echo ps kill sleep mdev switch_root; do ln -sf busybox $cmd done如果你手上只有动态链接的 BusyBox,那就得把ldd busybox列出的库文件连同动态加载器一起拷到rootfs/lib或rootfs/lib64下,路径必须和原来一致。这里有个隐性坑:加载器路径是编译时写死在 ELF 文件里的(比如/lib/ld-linux-x86-64.so.2),你把它拷到rootfs/usr/lib下面是不管用的,内核照样找不到。我第一次做这个的时候在这上面耗了半个下午。
设备节点方面,如果内核配了CONFIG_DEVTMPFS,挂上 devtmpfs 就自动有了,不用手动mknod。老内核才需要手动创建设备节点,现在基本不必。
3.2 inittab 与 sysinit 脚本
BusyBox 的 init 读/etc/inittab,格式比 SysV 的简化很多:
::sysinit:/etc/init.d/rcS ::respawn:/sbin/getty -L ttyS0 115200 vt100 ::restart:/sbin/init ::ctrlaltdel:/sbin/reboot ::shutdown:/bin/umount -a -r各字段含义是id:runlevels:action:process。sysinit表示系统初始化时执行一次,respawn表示进程退出就重启,restart是 init 自己重启时执行的,shutdown是关机时执行的。
respawn那一行是让系统可登录的关键。getty会在串口ttyS0上开一个登录提示,登录之后跑 shell。如果你是在虚拟机里跑,串口设备名可能是ttyS0也可能是ttyAMA0(ARM 架构常见),写错了就看不到登录界面,但系统其实是起来了——这种情况特别容易误判成"启动失败"。
rcS脚本负责挂载那几个虚拟文件系统:
#!/bin/sh mount -t proc none /proc mount -t sysfs none /sys mount -t devtmpfs none /dev mount -t tmpfs none /tmp hostname minimal顺序有讲究:/proc要最先挂,因为后面有些命令依赖它;devtmpfs挂上之后/dev/console、/dev/null才存在,如果在这之前有程序试图打开/dev/null会失败。/sys挂不挂对最小系统影响不大,但挂上之后ls /sys/class/net这类操作才能用。
3.3 打包 cpio、配内核参数、跑起来
打包命令要注意用newc格式,这是内核认的标准格式:
cd rootfs find . -print0 | cpio --null -ov --format=newc | gzip -9 > ../initramfs.cpio.gz用find . -print0 | cpio --null而不是find . | cpio,是为了避免文件名里带空格时出错。gzip压缩不是必须的,但能显著减小体积,内核支持自动识别。
启动命令(QEMU 环境):
qemu-system-x86_64 \ -kernel bzImage \ -initrd initramfs.cpio.gz \ -append "console=ttyS0 rdinit=/sbin/init" \ -nographic这里用rdinit=而不是init=,因为我们的 rootfs 是作为 initramfs 提供的,内核会把它解压到 rootfs 上,然后执行其中的/sbin/init。如果写成init=,内核会先尝试挂载真正的根设备,没配root=就会失败。
跑起来之后你应该能看到内核日志刷屏,最后停在登录提示上。此刻ps一看,PID 1 是/sbin/init,PID 2 是 kthreadd,这就是完整的进程树起点。
3.4 这套最小系统反推出来的发行版常识
做完这个实验,很多平时模糊的概念会突然清晰:
- 发行版的
/sbin/init为什么是个符号链接?因为它要给用户留一个"换 init 实现"的口子,早期可以在 SysV 和 systemd 之间切换,靠的就是改这个链接。 - 为什么有些服务启动时报"找不到 /dev/xxx"?因为它的启动时机早于 devtmpfs 挂载,或者它所在的命名空间里没有对应设备。
- 为什么容器里的 PID 1 行为怪异?因为容器里的 1 号进程往往是个普通应用,它没有处理信号转发、没有回收僵尸,SIGTERM 发过去直接被忽略,导致
docker stop要等超时才生效。解决办法是给容器加一个专门的小 init(--init参数就是干这个的)。
再补一个实用技巧:自己写的 init 脚本如果想当 PID 1,必须处理僵尸回收,最简单的方式是在主循环里定期wait:
#!/bin/sh trap 'echo "caught signal"' TERM INT while true; do sleep 5 & wait $! done注意wait不带参数会等所有子进程,带上 PID 只等指定的。作为一个合格的 PID 1,你得保证任何子进程退出后都有人收尸,否则僵尸会越积越多。
4. initramfs 里那个更早的 init:为什么要多这一层
4.1 rootfs 与 initramfs 的加载机制
内核在启动早期会创建一个叫rootfs的内存文件系统(基于 ramfs 或 tmpfs),它永远存在、不能被卸载,是整个挂载树的根。如果内核配置了CONFIG_BLK_DEV_INITRD,并且在启动时提供了 initramfs 镜像,内核会把这个 cpio 归档解压到 rootfs 上,然后尝试执行里面的/init。
这一步发生在真正根文件系统挂载之前。也就是说,系统其实有两个 init:initramfs 里的/init是第一个,它跑完之后通过switch_root把控制权交给真正的根文件系统上的/sbin/init,这才是第二个。
为什么要这么绕?因为在很多场景下,内核根本没有能力直接挂上根文件系统:根在 LVM 逻辑卷上、在加密卷里、在软 RAID 上、在网络的 iSCSI 上,或者是根分区的驱动被编译成了模块。这些情况需要先有一套用户空间的工具去激活卷组、解密、组阵列、加载模块,然后才能谈挂根。initramfs 就是这套"先遣队"。
4.2 switch_root 与 pivot_root 的选择
从 initramfs 切换到真实根,有两个系统调用可选。
pivot_root的做法是把当前根挂到某个目录上,然后把新根设为根,再把旧根卸掉。它的限制是:新根必须是一个挂载点,而且当前根不能是共享挂载。在 initramfs 场景里,rootfs 是 ramfs,有些版本的内核不允许对它做 pivot_root,所以实践中很少用。
switch_root是 BusyBox 提供的工具,本质是"清空 rootfs、chroot、exec"。它会删掉 rootfs 上的所有文件(释放内存),然后chroot到新根,最后 exec 新的 init。用法:
mount -t ext4 /dev/sda1 /newroot switch_root /newroot /sbin/init注意switch_root的第二个参数必须是新根下的可执行文件路径,而且切换完成后当前进程会被替换,所以它必须是脚本的最后一条命令。有人习惯在后面再写清理逻辑,那些代码永远不会执行。
4.3 根文件系统挂不上时的排查链路
Unable to mount root fs on unknown-block(x,y)这个报错几乎所有搞过启动的人都见过。我的排查顺序是这样的:
第一步,确认root=参数对不对。在内核命令行里看是/dev/sda1还是UUID=xxx。如果是 UUID,而这个 UUID 对应的设备根本没被识别,那内核会一直等。用root=PARTUUID=有时候更稳,因为分区的 UUID 在某些克隆场景下会重复。
第二步,确认驱动在不在。如果根所在的控制器的驱动是模块(比如某些 SCSI、NVMe、virtio 驱动),而这 个模块又在根文件系统上,这就是先有鸡还是先有蛋的死循环。解决办法是在 initramfs 里预先加载,或者干脆把驱动编进内核。
第三步,确认设备名变了没。加了块盘之后/dev/sda变成/dev/sdb,这种事故太常见了。用 UUID 或 PARTUUID 能规避,但如果 UUID 也变了(重装系统、重新分区),那就只能进救援模式改配置。
第四步,确认文件系统本身没坏。日志里如果出现EXT4-fs error或者corrupt,那就是文件系统层面的事了,需要fsck。这一步要小心,fsck在只读挂载或未挂载状态下跑才安全,挂载着跑容易扩大损坏。
第五步,确认挂载选项。有些系统把根配成只读挂载先检查,有些用了noexec。noexec会让所有可执行文件都跑不起来,现象就是"根挂上了、init 找到了、但执行失败",跟前面说的库缺失表现类似,得靠cat /proc/mounts区分。
注意:如果
root=指向的设备根本不存在,内核默认会 panic;但如果加了rootdelay=或者rootwait,它会等一段时间。调试阶段加上rootwait有时候能看到更多信息,但也可能让你误以为系统"卡住了",实际是在等设备。
5. systemd 接棒后的排障路径:从日志最后一行到第一个 target
5.1 按日志停点做症状分诊
拿到一台起不来的机器,我的第一件事是看日志停在哪一行,这一行基本能定位问题层级。
内核还在打印设备信息就停了,说明卡在硬件探测或驱动初始化,跟 init 无关。出现了Run /sbin/init as init process之后没了,说明 init 已经开始执行但没输出,可能是 init 程序本身崩了、或者控制台没配对(日志输出到了另一个 tty 上)。出现了 systemd 的日志但停在某个 target 之前,问题就在单元依赖上。
systemd 启动到某个阶段会打印Reached target XXX,这是最好的进度条。看到Reached target Local File Systems但没看到Reached target Multi-User System,那问题就在文件系统挂载之后的某个服务上。
5.2 三条救援入口的适用场景与风险
进不去系统的时候有三条路,各有各的适用面。
init=/bin/sh:最直接,跳过 systemd 直接给你一个 shell。风险是根文件系统此时通常是只读挂载的,要改配置文件必须先mount -o remount,rw /。另外这个 shell 没有作业控制、没有服务、没有日志系统,很多命令跑不了。它适合做最紧急的修复,比如改/etc/fstab里写错的一行。
rd.break(或break):断在 initramfs 阶段,此时真实根还没挂上,挂在/sysroot上。这个入口适合处理根文件系统本身的问题,比如fsck、修复被误改的/etc/fstab、重建 initramfs。注意不同的 initramfs 生成工具参数名不同,用错了就是无效参数,内核会忽略。
systemd.unit=rescue.target:让 systemd 只启动到最小状态,挂载本地文件系统、起一个 root shell,不启动网络和多用户服务。这个入口对 systemd 体系最友好,systemctl能用、日志能查。emergency.target更极端,只挂根且只读,连本地文件系统都不挂。
选择逻辑很简单:问题在根文件系统层面用rd.break,问题在系统服务和配置层面用rescue.target,systemd 自己也起不来才用init=/bin/sh。
5.3 用 systemd-analyze 定位慢与阻塞
系统能起来但启动慢,systemd-analyze三件套基本能定位到具体单元:
systemd-analyze time systemd-analyze blame systemd-analyze critical-chaintime给出总体耗时拆解,分成 firmware、loader、kernel、userspace 四段。如果 userspace 占了大头,那就是服务的问题;如果 kernel 段很长,那要去查驱动初始化和硬件探测。
blame按耗时从长到短列出所有单元。这里有个常见误读:排在最前面的单元不一定影响启动时间。如果一个服务是并行启动的、不在关键路径上,它耗时再久也不拖慢整体。所以我更看重critical-chain。
critical-chain打印的是关键路径链,形如:
multi-user.target @3.421s └─network-online.target @3.418s └─NetworkManager-wait-online.service @3.401s +1.2s箭头表示"被谁阻塞",+后面是这个单元自身的耗时。这条链上的每一环都是真正影响开机时间的,优化它们才有意义。比如上面这个例子,NetworkManager-wait-online耗了 1.2 秒,如果你并不真的需要等网络就绪,把这个服务disable掉就能省下这段时间。
还有一个诊断依赖问题的命令:systemd-analyze verify,能检查 unit 文件的语法和依赖是否存在。写新单元的时候先跑一遍,能提前发现拼写错误、路径不存在这类低级问题。
5.4 一张典型故障对照表
| 现象 | 大概率根因 | 处置方向 |
|---|---|---|
No working init found | 根未挂载 / init 不存在 / 不可执行 | 检查root=、init 路径、权限位、ELF 架构 |
Requested init /bin/sh failed | init=指定的程序缺库或格式不对 | 换成静态编译的 shell,或改用rd.break |
卡在A start job is running for ...然后超时 | 某个挂载点或设备等不到 | 检查/etc/fstab里的设备是否真实存在,加nofail |
| 进入 emergency mode | 根以外的文件系统挂载失败 | 从 rescue 模式改 fstab,或临时注释掉可疑条目 |
Ordering cycle found | 单元之间的 After/Before 形成环 | 用systemd-analyze verify定位,打破环 |
服务显示failed但没有日志 | 进程在 exec 之前就失败了 | systemctl status看退出码,journalctl -u看完整日志 |
| 启动完成但没有登录界面 | getty 没起或起在了错误的 tty | 检查getty@tty1状态,检查 console 参数 |
这张表我放在自己的笔记里,遇到问题时先对照一遍,命中率大概七八成。剩下的需要具体看日志,但至少方向不会跑偏。
6. 反复踩到的几个坑与经验补充
6.1 挂载选项与权限位带来的假故障
有一类问题特别迷惑人:所有配置看起来都对,日志也没有明显报错,但就是起不来。我遇到过的原因里有好几个都属于这一类。
一个是noexec。有人在加固系统的时候给/tmp、/var/tmp甚至根分区加了noexec,结果某个服务把自己的可执行文件放在这些路径下,启动时报"Permission denied",看起来像权限问题,实际是挂载选项。查法很简单:findmnt -o TARGET,OPTIONS一眼就看出来。
另一个是 SELinux 或 AppArmor 的上下文。文件拷进去之后没做restorecon,安全模块拦住不让执行,日志里audit会有记录。这种问题在dmesg里找avc: denied就能定位。
还有一个是 shebang 里的解释器路径。脚本第一行写#!/usr/bin/python3,但环境里只有/usr/bin/python,内核 execve 的时候找不到解释器,报的错是ENOENT,看起来像是"文件不存在",实际是解释器不存在。这个错误信息的误导性极强。
6.2 依赖关系写太死导致的连锁失败
我刚用 systemd 的时候特别喜欢写Requires=,觉得"我确实需要这个服务"。结果一次网络服务启动失败,把依赖它的七八个服务全带崩了,系统直接进不了多用户。
后来学乖了:能用Wants=就别用Requires=。Wants是"我希望它在",失败了不影响自己;Requires是"没有它我就不活",一崩崩一片。真正需要硬依赖的场景其实很少,多数时候你要的只是顺序(After=)加上软依赖(Wants=)。
另一个相关的问题是After=的滥用。有人为了"保证顺序",给一堆服务互相加After,最后形成环,systemd 报Ordering cycle found直接拒绝启动。遇到这种情况,systemd-analyze verify会明确指出环上有哪些单元,照着改就行。
6.3 关于信号与僵尸进程的常见误解
最后说两个概念上的误区,这两个我在面试和带新人的时候都遇到过。
误区一:kill -9 1能杀掉 init。不能。前面说过,内核会丢弃发给 PID 1 的、未注册处理函数的信号,包括 SIGKILL。想验证的话可以试,但别在生产机上试,虽然杀不掉,但可能会引起别的异常。
误区二:僵尸进程占资源,必须马上清。僵尸进程本身只占一个 task_struct,不占内存也不占 CPU,它的危害主要是 PID 耗尽。真正该做的是找到它的父进程,让父进程去wait。如果父进程是 init 而且不 wait,那就是 init 写得不合格。容器场景里 PID 1 是应用进程,它通常不处理 SIGCHLD,僵尸就会一直堆着——这也是为什么推荐给容器加--init的原因。
还有个相关的:systemd 服务里如果Type=simple但实际程序会 fork 到后台,systemd 会认为主进程退出了,直接把整个 cgroup 干掉。这种"服务莫名其妙被杀"的问题,改成Type=forking或者干脆让程序前台运行(Type=simple配ExecStart不 fork)就能解决。现代的做法更推荐后者,前台运行、日志走 stdout、交给 journald 收集,比传统的 daemon 化清晰得多。
回头看,init 进程这一小段代码在整个启动流程里占的比重极小,但它承接的是内核与用户空间的分界,前后牵扯到根文件系统、驱动加载、依赖调度、服务生命周期管理一整套东西。把这一段的查找顺序、失败行为、特殊规则记牢,遇到启动问题时判断就能快很多——至少你能一眼看出日志停在哪一层,而不是对着满屏输出瞎猜。