第一次在服务器上敲ps -ef | head,看到 PID 1 那一行的 PPID 写着 0,顺手ls /proc想找那个0目录,结果压根不存在。那一刻我才意识到,Linux 的进程树并不是从 1 开始的,1 前面还站着一个看得见编号、看不见实体的 0。后来又发现ps --ppid 2能刷出一整屏带方括号的内核线程,而 PID 2 自己的 PPID 也是 0,于是"0、1、2 这三个号到底是什么关系"就成了一个绕不过去的问题。这篇就把这条线从头到尾捋一遍:谁创建了谁、为什么顺序不能反、内核在这三个号上做了哪些特殊处理,以及在容器和日常运维里,这三个号会以什么形态咬你一口。内容偏向内核启动流程加实战验证,会看进程、写过 systemd 服务或者被容器 PID 1 坑过的人,读起来应该会顺畅一些。
1. 把 pid 0、pid 1、pid 2 摆到同一张表里:三个元老进程的身份与分工
很多讲进程管理的资料直接从 PID 1 开始讲,把 0 和 2 一笔带过,结果就是读者脑子里始终留着两个疙瘩:为什么ps里 PID 1 的父进程是 0,但/proc/0不存在?为什么内核线程的父进程统一是 2,而不是挂在 systemd 底下?先把这三个号的身份标签贴清楚,后面所有的行为都能对号入座。
| 编号 | 常见名字 | 本质 | 是否出现在 /proc | 父进程 |
|---|---|---|---|---|
| PID 0 | swapper / idle | 静态定义的init_task,空转任务 | 否 | 无(它是所有任务的起点) |
| PID 1 | init → systemd 等 | 用户态第一个进程,命名空间里的 child_reaper | 是 | 0 |
| PID 2 | kthreadd | 所有内核线程的父进程 | 是 | 0 |
1.1 pid 0 不是被 fork 出来的,它是编译期就存在的那个 task_struct
这一点是理解整条启动链的钥匙。任何一个普通进程都来自fork()或clone(),也就是"复制一份现有的 task_struct",但init_task没有这个东西可以复制——它是内核编译期就摆在数据段里的一个静态变量,定义大致长这样:
/* init/init_task.c */ struct task_struct init_task = INIT_TASK(init_task);配套的内核栈也是静态的,那段内存叫init_thread_union。为什么必须静态?因为这个 task_struct 要用的那一刻,kmalloc和 slab 分配器都还没就绪,谁也没法给一个还不存在的分配器申请内存。所以内核只能先"人工造"出一个进程上下文硬编码在镜像里,让start_kernel()有东西可以跑,等内存管理、调度器、中断子系统都初始化完了,再用动态分配去创建后面所有的进程。
INIT_TASK宏里会把comm设成"swapper",这就是为什么老一些的top输出里会出现swapper这么一行——空闲 CPU 的记账被算到了它头上。它的 PID 是 0,准确说是init_task的pid字段初始化为 0。这个任务永远不会退出,也永远不会被调度器挑中去"运行用户代码",它只做一件事:在没有任何可运行任务的时候,占住 CPU 执行cpu_idle循环。
1.2 pid 1 的 task_struct 和 pid 0 是同一套模板造出来的,区别只在最后那次 execve
容易被忽略的一点是:PID 1 最初并不叫 init,它的comm一开始是"swapper"之外的另一个名字——内核里管它叫kernel_init,本质上是一段内核代码,跑在内核态。它是rest_init()里通过kernel_thread()复制init_task得到的,所以天然继承了init_task作为父进程,PPID 就是 0。这就是ps -ef里那一行1 0的来源。
然后它执行自己的函数体kernel_init(),在里面做完一大堆内核收尾工作(下面第 2 节会细讲),最后调用run_init_process(),用kernel_execve把/sbin/init或者/init的可执行文件加载进来,覆盖掉自己现在的地址空间。注意这里是execve,不是fork——进程号不变,还是 1,但代码段、数据段、堆栈全部换成了用户态那个 init 程序。你可以理解成"同一张工牌,同一个工位,人换了"。
这一步决定了 PID 1 的双重身份:在execve之前它是一段内核代码,之后才是用户态的第一个进程。如果你手快,在内核还没走到run_init_process的时候去看ps,理论上是看不到名字叫 init 的进程的,只能看到内核日志里那句Run /sbin/init as init process。
1.3 pid 2 的定位:所有内核线程的"统一入口"
同样在rest_init()里,紧接着创建 PID 1 之后,内核又复制了一份任务出来当kthreadd,拿到 PID 2。它和 PID 1 的关键差异在于:它是一段永远不 exec 的内核代码,一辈子待在内核态。
它的职责非常单一——守在kthread_create_list这个链表上。任何内核模块或者内核子系统要起一个内核线程,就调kthread_create(),把请求塞进链表,kthreadd醒了以后就create_kthread()把真正的线程 fork 出来。因为 fork 的动作是在kthreadd这个上下文里做的,所以所有内核线程的 PPID 都是 2。这就是ps -ef里那些[kworker/0:1]、[ksoftirqd/0]、[rcu_gp]的父进程一栏全是 2 的原因。
顺带说一个历史包袱:PID 2 这个位置在很老的内核上并不是 kthreadd。2.4 时代它通常是keventd(内核事件守护),更早的 2.0/2.2 时代,PID 2、3、4 是被几个固定守护进程占着的,像kflushd、kupdate、kswapd这类。2.5 之后内核引入了kthreadd统一管理内核线程,PID 2 才固定成今天这个样子。所以在很老的资料上看到"PID 2 是什么什么 daemon",不用怀疑自己记错了,那只是版本差异。
2. 启动链拆解:rest_init() 那三行代码决定了整台机器的进程树形状
理解了三个身份,接下来看它们是怎么被生出来的。整条链子的分水岭就是start_kernel()的最后一步转进rest_init(),这台机器的进程树形状在这一刻就被定死了。
2.1 为什么注释里强调"必须先创建 init"
rest_init()的代码不长,但里面藏着一条很关键的因果链。核心片段大致是:
/* init/main.c */ noinline void __ref rest_init(void) { struct task_struct *tsk; int pid; rcu_scheduler_starting(); /* * 必须先创建 init,这样它才能拿到 pid 1; * 但 init 后面又会去创建内核线程, * 如果现在就跑它,会出问题。 */ pid = kernel_thread(kernel_init, NULL, CLONE_FS); rcu_read_lock(); tsk = find_task_by_pid_ns(pid, &init_pid_ns); set_cpus_allowed_ptr(tsk, cpumask_of(smp_processor_id())); rcu_read_unlock(); numa_default_policy(); pid = kernel_thread(kthreadd, NULL, CLONE_FS | CLONE_FILES); rcu_read_lock(); kthreadd_task = find_task_by_pid_ns(pid, &init_pid_ns); rcu_read_unlock(); system_state = SYSTEM_SCHEDULING; complete(&kthreadd_done); schedule_preempt_disabled(); cpu_startup_entry(CPUHP_ONLINE); }先说顺序问题。PID 是按创建顺序发的增量号,谁先被kernel_thread出来谁就是 1。内核明确希望 init 拿 1,所以kernel_init必须排在kthreadd前面。注释里那句"however"的转折说的就是矛盾点:init 拿 1 没问题,但 init 后面要做的事里包含触发 initcall,而很多 initcall 会调kthread_create(),这又依赖kthreadd已经能干活了——如果kthreadd还没创建好,init 一跑就会把自己的请求塞进一个没人消费的链表,然后死等。
另外两句也很值得看。set_cpus_allowed_ptr(tsk, cpumask_of(smp_processor_id()))是把 init 钉在启动 CPU 上,因为sched_init_smp()还没跑,跨 CPU 迁移的机制还不完整,这时候让 init 到处飘是要出事的。kthreadd_task这个全局变量在这里被赋值,也是后面 init 等它用的那个 rendezvous 点。
最后schedule_preempt_disabled()加cpu_startup_entry(CPUHP_ONLINE),意思是当前这段代码执行完之后,这个任务就正式转成了 idle 循环。也就是说,PID 0 的"人生"在这里发生了转折——它前面是那个跑start_kernel的启动上下文,到这里它把自己的角色彻底交给了init_task的空转身份。
2.2 kernel_init 一上来就卡在 kthreadd_done 上
kernel_init被创建出来之后,第一件事不是急着去启动用户态,而是先等kthreadd就绪。这个等待在 5.x 之后的内核里通常写在kernel_init_freeable()的开头:
static noinline void __init kernel_init_freeable(void) { /* 调度器已经完全就绪,可以做阻塞分配了 */ gfp_allowed_mask = __GFP_BITS_MASK; set_mems_allowed(node_states[N_MEMORY]); /* 等 kthreadd 建好 */ wait_for_completion(&kthreadd_done); smp_prepare_cpus(setup_max_cpus); workqueue_init(); ... smp_init(); sched_init_smp(); ... do_basic_setup(); ... }这个wait_for_completion(&kthreadd_done)和rest_init里那句complete(&kthreadd_done)是一对。信号量的配对关系非常清楚:rest_init在把kthreadd_task存好、system_state置成SYSTEM_SCHEDULING之后放行,kernel_init这才能往下走。这个设计其实是在给"内核线程基础设施"做一次握手,保证后面do_basic_setup()里那一大串 initcall 调用kthread_create()的时候,链表的消费者已经在线了。
do_basic_setup()里面会跑各种do_initcalls(),从early_initcall一路到late_initcall。你会看到 PID 3、PID 4、PID 5 这些号被迅速占掉,rcu_gp、rcu_par_gp、kworker之类的内核线程就是在这一批 initcall 里通过kthreadd创建出来的。所以在一个刚起来的系统上ps -e -o pid,ppid,comm,会看到一条非常整齐的规律:PID 1 是 init,PID 2 是 kthreadd,PID 3 往后一大片的内核线程 PPID 全是 2。
2.3 从 /init 到 /sbin/init:init 程序查找顺序与 switch_root 的交接
kernel_init把内核侧的事情做完之后,就会进入查找 init 程序的阶段。这个阶段有几个容易搞混的优先级,我整理成了一张表:
| 优先级 | 来源 | 说明 |
|---|---|---|
| 1 | rdinit=内核参数 | 指定 rootfs 上的 init,默认值是/init |
| 2 | rootfs 上的/init | 存在即用,这是 initramfs 的入口 |
| 3 | init=内核参数 | 显式指定要启动的 init,失败直接 panic |
| 4 | /sbin/init | 找不到就往下一个试 |
| 5 | /etc/init→/bin/init→/bin/sh | 依次尝试,全失败则 panic |
日志里那句Run /sbin/init as init process就是run_init_process()打出来的,pr_info级别,进dmesg。这句日志的价值极高——当你怀疑某个系统到底加载的是 initramfs 里的 init 还是根分区的 init,直接dmesg | grep "as init process"就能看到实际走了哪一条。
有个细节在kernel_init_freeable()里:
if (!ramdisk_execute_command) ramdisk_execute_command = "/init"; if (sys_access((const char __user *) ramdisk_execute_command, 0) != 0) { ramdisk_execute_command = NULL; prepare_namespace(); }它的含义是:内核先假设 rootfs 上可能有个/init,用sys_access探一下;如果没有,就把ramdisk_execute_command清空,转去prepare_namespace()挂真正的根文件系统。所以"有没有 initramfs"这件事,内核是靠"rootfs 上有没有 /init"来判断的,不是靠别的标志位。
那 initramfs 场景下 PID 1 怎么延续?initramfs 里的/init通常是一段 shell 脚本,它负责加载必要的内核模块、挂载真正的根分区,然后调switch_root(util-linux)或者run-init(initramfs-tools)。这两个工具做的事情一样:把新根上的/proc、/sys、/dev迁移过去,chroot到新根,然后exec真正的 init。因为最后一步是exec而不是fork,所以从 initramfs 的 /init 到真正的 /sbin/init,进程号一直是 1。如果你在 initramfs 里ps看到 PID 1 是/init,切根之后再看 PID 1 变成 systemd,不要以为是两个进程,它就是同一张 task_struct 换了张脸。
2.4 用 dmesg 把这条链子原样打印出来
想实际观察这条链,最省事的方式是给内核加initcall_debug参数,然后dmesg。initcall_debug会把每个 initcall 的耗时打出来,你能清晰地看到kthreadd相关的初始化分布在哪一段,也能看到各个 initcall 里 fork 出来的内核线程名字。如果只是想确认 init 走的哪条路,dmesg | grep -i "init process"足够了。
另一个容易被忽略的观察点:/proc/sys/kernel/pid_max在启动早期就已经可读,说明 PID 分配器在很早就初始化完了。PID 号是从kthreadd和 init 之后开始批量消耗的,这个数字的消耗速度本身就是判断系统"起来后干了多少事"的一个粗略指标。
3. 证据链验证:只用 ps 和 /proc 把 pid 0 的存在感找回来
光看代码容易产生一种"这些都是理论"的错觉,实际上你在任何一台 Linux 上都能用现成命令把这三个 PID 的关系验一遍,不需要装任何工具。
3.1 PPid=0 是 pid 0 唯一露脸的地方
ps -eo pid,ppid,comm | head -5典型输出:
PID PPID COMMAND 1 0 systemd 2 0 kthreadd 3 0 rcu_gp # 有的内核版本 PPID 显示为 2,取决于版本细节 4 0 rcu_par_gp这里有个非常有意思的现象:PID 1 和 PID 2 的 PPID 都是 0,也就是它们都"挂在 swapper 底下",但/proc/0不存在,ps -p 0也查不出东西。这就等于内核把 pid 0 的存在感压缩成了一个数字,只在别的进程的 PPID 字段里留下痕迹。
核对一下就能确认这一点:
ls /proc | sort -n | head -3 # 1 # 2 # 3/proc下最小的目录就是 1,没有 0。ps -p 0 -o pid,comm会直接告诉你 "PID 0 不存在"。
3.2 /proc/1/status 与 /proc/2/status 的字段对照
想看得更细,直接读 status:
grep -E '^(Name|Pid|PPid|Uid|NSpid)' /proc/1/status grep -E '^(Name|Pid|PPid|Uid|NSpid)' /proc/2/status在我这台跑 systemd 的机器上,PID 1 是:
Name: systemd Pid: 1 PPid: 0 NSpid: 1PID 2 是:
Name: kthreadd Pid: 2 PPid: 0 NSpid: 2两个进程的Uid都是 0(root),区别在于 PID 1 有完整的用户态身份——/proc/1/exe指向/usr/lib/systemd/systemd,/proc/1/root指向/,/proc/1/cwd也有具体值。而/proc/2/exe这类链接在 kthreadd 身上是空的或者不可用,因为它根本没有用户态地址空间。
还有一条命令特别直观:
readlink /proc/1/exe # /usr/lib/systemd/systemd readlink /proc/2/exe # 报错或者空,kthreadd 没有用户态可执行文件这条差异正好印证了前面说的:PID 1 在某个时刻做过execve,PID 2 从来没有。
3.3 for_each_process() 为什么天然跳过 pid 0
为什么/proc里看不到 0?追到内核里其实一句话就能解释。/proc遍历任务用的宏是for_each_process(),展开之后大致是:
#define for_each_process(p) \ for (p = &init_task ; (p = next_task(p)) != &init_task ; )注意起点是&init_task,然后立刻执行next_task(p),等于从 init_task 的下一个任务开始,而且循环条件又是!= &init_task。所以init_task自己永远落在遍历范围之外——/proc从设计上就不打算把空闲任务暴露出来。这个细节我觉得比"因为它是 idle 所以不显示"这种模糊说法更有说服力。
顺带补一个容易踩的坑:在某个 PID namespace 里读一个不属于该 namespace 的进程的status时,NSpid字段里会出现 0,含义是"这个进程在这一层命名空间里不可见"。这里的 0 是个占位符,跟 idle 任务那个 PID 0 没有任何关系。我见过有人把它当成"这个进程的 PID 是 0",然后排查跑偏了半天。
4. pid 1 的三条硬规矩:孤儿回收、信号豁免、退出即终结命名空间
PID 1 之所以特殊,不是因为它号小,而是内核在它身上写了三段专门的逻辑。这三段逻辑在容器里会被无限放大,先讲清楚原理。
4.1 child_reaper:孤儿进程为什么都跑去认 init 当爹
一个进程的父进程先退出了怎么办?内核不会让它变成"没有父进程的野进程",而是把它过继给一个 reaper。在根命名空间里,这个 reaper 就是 PID 1。
具体实现在kernel/exit.c的find_new_reaper()一带。父进程退出时,内核遍历它的子进程链表,把每个子进程的parent指针改指向 reaper,同时发一个SIGCHLD通知新的父进程。这就是为什么你在一个长期跑的服务里杀掉它的父进程之后,ps -o ppid查出来会变成 1。
从内核 3.4 开始多了一个更细的机制:prctl(PR_SET_CHILD_SUBREAPER, 1)。一个进程可以把自己注册成"子收割者",那么挂在它子树下面的孤儿会被过继给它,而不是一路送到 PID 1。它的实际意义在于:PID 1 要处理整个系统的孤儿,压力大而且语义混乱;一个进程管理器(据我了解,systemd 的用户实例、一些会话管理器都会设置这个标志)在自己的小树里当 reaper,能把回收职责收敛在更近的层级。
这里有个实操层面的注意点:成为 subreaper 意味着你必须自己调wait(),否则僵尸进程就堆在你这里。这个坑下面第 5 节会展开。
4.2 kill -9 1 打不动它,不是权限问题
很多人第一次遇到kill -9 1没反应,第一反应是"权限不够",其实不对称。内核在信号投递路径上做了明确的豁免,核心判断在kernel/signal.c的sig_task_ignored()里,逻辑大致是:
- 如果目标是全局 init(根命名空间的 init),并且信号是 SIGKILL 或 SIGSTOP,直接忽略;
- 如果目标身上带着
SIGNAL_UNKILLABLE标记,且它没有为这个信号注册处理函数,并且不是被强制执行的内核信号,也忽略; - 内核线程只接受内核自己发的特定信号。
SIGNAL_UNKILLABLE会被打在命名空间 init 的signal_struct上。这套规则合起来的效果就是:凡是 init 没有显式注册处理函数的信号,一律被丢掉。所以:
# 容器里以 root 身份执行 kill -TERM 1 # 如果 PID 1 没处理 SIGTERM,等于没发 kill -9 1 # 同一个命名空间内发,被静默丢弃SIGKILL 和 SIGSTOP 无法被捕获,按理说应该"一击必杀",但内核在 init 上又加了例外。这个例外的目的是防止某个脚本手滑把系统或者容器干掉。值得注意的是,这个豁免有边界:来自祖先命名空间的 SIGKILL/SIGSTOP 是可以真正送达的。这正是docker stop超时后docker kill能生效的原因——那个 SIGKILL 是从宿主机(祖先命名空间)发出的,绕过了豁免检查。
4.3 find_child_reaper() 里的两条分支:panic 还是清扫命名空间
PID 1 退出会发生什么?这个逻辑集中在kernel/exit.c的find_child_reaper()里,判断非常干脆:
static struct task_struct *find_child_reaper(struct task_struct *father, ...) { struct pid_namespace *pid_ns = task_active_pid_ns(father); struct task_struct *reaper = pid_ns->child_reaper; if (likely(reaper != father)) return reaper; if (unlikely(pid_ns == &init_pid_ns)) { panic("Attempted to kill init! exitcode=0x%08x\n", father->signal->group_exit_code ?: father->exit_code); } zap_pid_ns_processes(pid_ns); ... }分两种情况。如果退出的是根命名空间的 init,内核直接panic,屏幕上那句Attempted to kill init!就是从这里来的,后面跟一个 exitcode。系统到这一步基本就停在原地了,什么服务都不会再起来。
如果是某个子命名空间的 init 退出(典型场景就是容器),内核转去调zap_pid_ns_processes(pid_ns),它做的事情分三步:先把该命名空间的 PID 分配关掉(disable_pid_allocation(),之后在这个命名空间里fork新进程会失败),然后给里面所有还活着的进程发 SIGKILL,最后由父命名空间的 reaper 把这些已经过继出来的进程收尸。
这条逻辑解释了一个很常见的现象:容器的主进程一退出,容器瞬间"全灭",不是因为 docker 在里面杀了谁,而是内核在清扫整个命名空间。反过来说,如果你在容器里跑一个 shell 然后exit,里面所有后台进程也会跟着消失,这是同一套机制。
5. 容器场景下的 pid 1:三个最常见的坑与对应的处理方式
上面这些规则在普通服务器上很少被感知,因为 systemd 作为 PID 1 已经把所有脏活干得漂漂亮亮。一旦进了容器,PID 1 变成你自己的业务进程,问题就全冒出来了。
5.1 容器里 Ctrl-C 没反应、docker stop 要等满 10 秒的真实链路
先复盘一下现象:docker run -it一个只有 shell 的镜像,敲 Ctrl-C 大概率没反应;docker stop一个不处理 SIGTERM 的服务,固定要等 10 秒才结束。这两个现象其实是同一条因果链的两端。
Ctrl-C 这一侧:你按下的组合键由宿主机侧的 pty 主设备接收,但产生 SIGINT 的终端驱动是绑定在从设备上的,而那个从设备属于容器的命名空间。所以这个 SIGINT 相当于"命名空间内部发出的信号",走到sig_task_ignored()那里,发现 PID 1 是 init、没有注册 SIGINT 处理函数、信号也不是被强制执行的,于是丢掉。整个过程没有任何报错,看起来就像按键失灵了。
docker stop那一侧:docker 先向容器的 PID 1 发 SIGTERM。如果业务进程没处理,按上面的规则被丢弃。等超时(默认 10 秒)之后,docker 从宿主机发 SIGKILL,因为发送者在祖先命名空间,这次绕过豁免,容器 git 立刻死掉,然后内核清理整个命名空间。所以"10 秒"不是 docker 慢,而是它在给一个根本不响应的进程留足面子。
5.2 entrypoint 脚本里那个 exec,决定了信号能不能穿透到应用
比上面更隐蔽的是 shell 包装导致的信号丢失。看这个典型的 Dockerfile:
CMD ["/app/start.sh"]start.sh里写着:
#!/bin/sh /app/server --port 8080这种情况下/bin/sh是 PID 1,/app/server是 PID 1 的子进程。docker 发的 SIGTERM 落在 sh 身上,sh 收到之后……什么也不会做,因为它没有转发信号的义务(而且 sh 作为 init 时很多信号本来就被忽略)。结果就是服务进程压根收不到停止信号,只能等最后的 SIGKILL 强杀,进程没机会做优雅退出,日志没刷盘,连接没关干净。
修法很简单,加一个exec:
#!/bin/sh exec /app/server --port 8080exec会让 sh 用新的可执行文件覆盖自己,PID 保持不变,于是/app/server直接继承了 PID 1 的身份,信号就能直达了。这是我见过最容易改也最容易忘的一处。
判断当前容器有没有踩这个坑,一条命令就够:
docker exec <container> ps -p 1 -o pid,ppid,comm,args如果下面是/bin/sh或者/bin/bash而你的业务进程 PPID 是 1,那就说明包装层没消掉。
5.3 僵尸进程堆积:--init、tini、dumb-init 该怎么选
僵尸进程的问题同样源于 PID 1 的身份。容器里任何子进程死亡,如果它的父进程先走了,就会过继给 PID 1,等着被wait()回收。业务进程通常不会实现"循环 wait 任意子进程"的逻辑(哪怕它写得很好,也只 wait 自己 fork 出来的那几个),于是过继来的僵尸就一直挂着,ps里一堆 Z 状态,堆久了还可能撞上 PID 上限。
三种常见解法,各有取舍:
docker run --init:让 docker 注入一个极小的 init(现版本是 tini)作为 PID 1,业务进程变成它的子进程。改动成本最低,加个参数就行,代价是多一层进程,而且有些依赖"自己必须是 PID 1"的程序要重新验证一下。tini -g -- cmd:在 Dockerfile 的ENTRYPOINT里显式包一层。-g的参数是把信号发给整个进程组,适合自己的服务还会 fork 子进程的场景。tini 本身就是设计来做这件事的,写得很小。dumb-init -- cmd:思路和 tini 类似,早期在有些环境里更常见,现在功能上基本重合,选哪个看团队习惯。
我的经验是:如果只是想让信号正常传递加回收僵尸,--init最省事;如果服务本身会派生进程组,且希望停止时整组一起收,tini -g更可控。另外有极少数程序明确要求自己必须是 PID 1(比如某些自带的 init 模式),这种情况就别掺和,让它自己去当 init,但要确认它真的实现了回收逻辑。
6. 几个容易被误判的细节与我的几条经验
前面把主干讲完了,最后补几个散点。这些细节单独看不重要,但排查问题时它们经常是那个"看起来对不上"的地方。
6.1 NSpid 里的 0 和老内核里 pid 2 的变迁
NSpid字段我前面提过一次,这里再说透一点。它是给跨命名空间观察用的:从读/proc的这个进程所在的命名空间开始,逐层往里列出目标进程在每一层的 PID。如果目标在最外层就不可见(比如一个纯内核线程,只属于根命名空间),对应位置就可能出现 0。所以看到NSpid: 2 0这种值,不要慌,它只是"两层视角,里面那层看不到我"。
另一个历史坑是 PID 2 的名字。如果你手里有很老的运维手册,上面写着"PID 2 是 keventd"或者更早的"PID 2 是 kflushd",那是 2.4 及更早内核的行为。2.6 之后kthreadd接管了这个位置,所有内核线程归它管。翻旧资料对不上时先确认内核版本,别急着怀疑自己。
6.2 pid 分配的上限、RESERVED_PIDS 和 pid 复用
想知道这台机器还能撑多少进程,看这几个数:
cat /proc/sys/kernel/pid_max cat /proc/sys/kernel/threads-max cat /proc/sys/kernel/pid_max > /dev/null # 默认值通常是 32768pid_max默认 32768,上限是 4194304,改大之后 PID 号会一直往上走,好处是复用周期变长,坏处是有些老程序假设 PID 是 16 位整数,撑不住大的号码。改之前建议先在测试环境验证一圈。
还有个冷知识:根命名空间的 PID 分配有一个保留区,内核里定义RESERVED_PIDS为 300,正常情况下新进程不会拿到 300 以下的号(除了启动早期的那批),这是为了避免 PID 复用太快。所以你在容器里看到的 1、2、3 是命名空间视角的编号,跟宿主机上的实际号完全是两套体系,这一点在做日志关联分析时特别容易混。
6.3 我自己的排查顺序清单
被 PID 1 相关问题缠上时,我一般按这个顺序走,基本能定位到八成以上的问题:
- 先确认容器的 PID 1 到底是谁:
ps -p 1 -o pid,ppid,comm,args。是业务进程还是 shell 包装,决定了后面一切的排查方向。 - 看它忽略哪些信号:
grep '^Sig' /proc/1/status,SigIgn位图里有什么,就能知道哪些信号会被吞掉。 - 确认内核实际加载的 init:
dmesg | grep "as init process",在虚拟机/物理机上排查启动问题时极其有效。 - 看僵尸:
ps -eo stat,pid,ppid,comm | grep '^Z',有僵尸就说明 reaper 没干活,回头查 PID 1 有没有 wait 逻辑。 - 看内核线程归属:
ps -eo pid,ppid,comm | awk '$2==2' | head,如果这里大量出现异常增长,说明有内核模块在频繁创建线程。
有一次我在一个自建镜像上排查"容器停不掉",前三步走完就定位到了:PID 1 是/bin/sh,SigIgn位图里 SIGINT/SIGTERM 都在,业务进程 PPID 是 1。改动就是把启动脚本里那一行加上exec,一行代码,问题消失。这种问题的诱人之处在于它看起来像网络或者存储层面的卡顿,实际上根子就在进程号和信号这两件最基础的事情上。