Docker 里的程序是怎么跑起来的:为什么宿主机能跨发行版、还能跨内核版本
相信很多小伙伴都知道,docker 镜像里并没有操作系统内核。 Ubuntu 宿主机上跑起 RHEL 的镜像,甚至能在低版本内核上运行高版本发行版编译的程序,背后是一套非常精巧的分层设计。今天咱们就一起聊一下“应用程序调用内核”的完整链路,解释一下为什么 Docker 能做到 “一次打包,到处运行”。
一、镜像里到底有什么?
我们先看一个典型的 Linux 容器镜像(比如 ubuntu:22.04)里包含了什么:
/bin, /sbin <- ls, cp, bash 等基础命令
/lib, /lib64 <- glibc (C 库), 动态链接器 (ld-linux-x86-64.so.2)
/etc <- 配置文件
/usr <- 用户态程序和资源
不出所料,这里面没有内核文件(如 vmlinuz、initrd.img),也没有内核模块。这是因为容器本质属于操作系统级虚拟化,依托内核实现进程级别的资源与环境隔离,区别于 KVM 等硬件虚拟化方案,它不运行独立内核,而是直接复用宿主机的内核。
因此,当我们说 “RHEL 镜像” 时,准确地说是指RHEL 的用户态根文件系统(rootfs),而非 RHEL 内核。
二、用户态:地址是如何绑定的?
既然镜像里没有内核,那么程序运行时所需的各种依赖是如何解决的?
1. 编译链接:记录符号,而非地址
在程序编译链接阶段,编译器并不会把最终的物理内存地址写死。对于动态链接的程序来说,生成的 ELF 文件中记录的只是符号引用(例如 ” 我需要调用 printf 函数 “),而不是 printf 函数在内存中的具体位置。
2. 运行时加载:动态链接器的工作
当容器启动,程序开始运行前,动态链接器(通常是 /lib64/ld-linux-x86-64.so.2)开始工作。它的核心任务是:
加载依赖:根据程序头部信息,找到并加载所需的 .so 动态库(如 libc.so.6)。
地址分配:由于 Linux 默认开启 ASLR(地址空间布局随机化),每个进程启动时动态库的加载虚拟地址都会独立随机化。动态链接器会将库映射到进程虚拟地址空间的可用位置。
重定位:动态链接器会修改程序中的引用,将 printf 这样的符号名,替换为当前加载地址下的真实函数入口地址。
这就是为什么 Docker 镜像必须自带一套完整的 rootfs。当 Ubuntu 宿主机运行 RHEL 容器时,容器内的程序链接的是镜像里自带的 RHEL 版 glibc,而不是宿主机的 glibc。这种 “自带依赖” 的机制,抹平了不同发行版在用户态库版本上的差异。
三、跨越边界:从用户态到内核态
这是最容易被误解的一环,大家惯性的会想:用户态的程序肯定是通过指针跳转到了内核的代码区吧?实际上,Linux等现代操作系统,使用了更安全的硬件机制。
1. 不是 “直接跳转”,而是 “指令陷入”
用户态程序(及其依赖的 libc)无法直接访问内核的内存地址。内核地址位于虚拟地址空间的高位区域,用户态页表中根本没有映射这些地址(页表权限位 U=0,用户态不可访问)。
那么程序如何发起系统调用?答案是 syscall指令。
以 read 为例,libc 中的封装函数会做如下事情(x86-64 Intel 汇编语法):
mov rax, 0 ; 将系统调用号 0 (read) 放入 rax 寄存器
mov rdi, fd ; 第一个参数 fd 放入 rdi
mov rsi, buf ; 第二个参数 buf 放入 rsi
mov rdx, count ; 第三个参数 count 放入 rdx
syscall ; 执行系统调用指令
2. CPU 的魔法:MSR 寄存器
syscall 是一条特殊的 CPU 特权指令。执行它时,硬件会自动完成以下安全动作:
切换特权级:从用户态(Ring 3)切换到内核态(Ring 0)。
切换栈:从用户栈切换到该进程对应的内核栈。
跳转执行:CPU 不会从用户态内存中查找内核入口,而是读取内核启动时预先配置好的特殊寄存器 ——MSR_LSTAR(Model Specific Register),从中获取内核系统调用入口的内存地址,再跳转执行对应逻辑。
MSR_LSTAR 里存放的地址,是内核在启动时预先写入的,指向内核的系统调用入口函数 entry_SYSCALL_64。
也就是说,用户态程序不需要知道内核代码在哪里,它只需要遵守 ABI 规范(把参数放对寄存器,执行 syscall),剩下的由 CPU 硬件和内核协作完成。
3. 内核地址会变吗?
内核在编译时确实有一个固定的链接基地址,但在启用 KASLR(内核地址空间布局随机化)的现代系统中,内核在启动早期会随机化一次加载基地址,系统运行期间保持固定。但这并不影响系统调用 —— 因为入口地址是由内核自己写入 MSR 寄存器的,用户态对此一无所知,也无需关心。
四、为何能跨内核版本?ABI 的稳定性
现在我们回到最初的问题:为什么在 Kernel 6.11 上构建的镜像,能在 Kernel 6.1 的宿主机上运行?
关键在于 Linux Syscall ABI(应用程序二进制接口)的稳定性。
Linux 内核维护者 Linus Torvalds 有一条铁律:内核可以新增功能,但绝不能破坏用户态程序的 ABI。内核 ABI 严格保证向后兼容:新内核可以正常运行旧程序,但旧内核无法原生支持新程序使用的新增系统调用。
这意味着:
调用约定不变:x86-64 架构下,永远使用 syscall 指令,参数永远通过 rdi, rsi, rdx 等寄存器传递。
系统调用号不变:x86-64 架构下,read 永远是 0 号调用。
结构体布局不变:传递给内核的数据结构(如 struct stat)在旧版本中的布局必须保持兼容。
因此,只要硬件架构相同(都是 x86-64),在 6.11 内核上编译的程序,到了 6.1 内核上,依然能通过 syscall 指令正确地触发内核处理逻辑。
唯一的例外是:如果程序使用了 6.11 新增的某个系统调用(比如 process_mrelease),而宿主机内核是 6.1(尚未实现该调用),那么内核会返回 ENOSYS (Function not implemented) 错误。程序应当优雅地处理这个错误(例如降级使用旧接口),而不是崩溃。
五、关于 C 库的补充说明:glibc vs musl
在 Docker 世界中,除了发行版差异,还有一个常见的坑:C 库的差异。
大多数发行版(Ubuntu, Debian, CentOS)使用 glibc,而 Alpine Linux 使用 musl libc。
共同点:两者都遵循 POSIX 标准,最终都会通过相同的 syscall 指令调用内核。
不同点:两者的用户态C库不同。它们对符号的实现、内存分配器(malloc)、DNS 解析逻辑(glibc 依赖 NSS 服务,musl 直接读取 resolv.conf)的实现都不一样。
这就导致了一个常见问题:如果你在 Ubuntu 上编译了一个动态链接 glibc 的程序,拷贝到 Alpine 容器中,会因为找不到 libc.so.6 且 ABI 不兼容而无法运行。反之同理。
解决方法是静态编译(将所有依赖打包进单一二进制文件)或者在目标环境(如 Alpine)中重新编译。这也再次印证了我们的观点:Docker 通过自带 rootfs 解决了用户态依赖问题,但如果用户态的 “方言”(C 库实现)不同,依然需要注意兼容性。
六、总结
Docker 跨发行版、跨内核版本的奥秘,可以归纳为以下三点:
内核复用:容器属于操作系统级虚拟化,不携带独立内核,直接复用宿主机内核。
用户态隔离:镜像自带完整的 rootfs(包括特定发行版的 libc 与动态链接器),通过动态链接机制在运行时完成地址绑定,消除了发行版间的库依赖差异。
ABI 稳定:Linux 内核严格维护 Syscall ABI 的向后兼容性。用户态程序通过标准的 CPU 指令(syscall)与内核通信,而非直接跳转内核地址,使得新旧内核之间的交互成为可能。
简而言之,Docker 镜像负责 “带好自己的行李”(用户态依赖),宿主机内核负责 “通好道路,通好水电”(稳定的 syscall ABI),两者各司其职,共同实现了容器应用的平滑运行。