
容器运行时这个名字太容易招误解了。它既不是一个固定进程也不负责运行容器这种笼统动作把一次容器启动拆到内核层去看它更像一个系统调用编排器——自己不占资源但通过一串有序的clone、mount、pivot_root、execve调用把用户关于隔离、限额、安全的诉求翻译成内核能执行的精确指令。这个系列走到第10篇前面写过镜像层、存储、网络等主题这一篇专门钻进容器运行时内部继续第二部分的下沉从内核视角拆一次容器启动的全过程看清高低层运行时如何分工为什么cgroup、namespace、capabilities这三个概念是所有容器问题排查的钥匙以及真正动手写一个极简运行时和用strace复盘真实启动过程能带来什么认识。1. 先拆开容器运行时这个名字它其实是一整套分工链路1.1 低层运行时真正向内核发号施令的那一段代码很多人聊到容器运行时脑子里浮现的其实是启动容器的那个二进制。但严格说来直接和内核打交道的是一类很窄的程序业内通常叫低层运行时。它的全部职责可以压缩成一句话读一份描述容器配置的文件按照配置中的命名空间、挂载点、资源限制、安全规则把一系列系统调用按顺序执行出来然后一直守着容器主进程直到它退出并把退出码还给上层。低层运行时不知道镜像长什么样也不关心镜像从仓库怎么拉下来。它拿到的输入是已经准备好的rootfs目录加一份配置文件——rootfs就是容器内可见的文件系统根配置文件则写明了进程要运行的命令、环境变量、要挂载的目录、允许的系统调用等等。这种只干活不问来源的设计非常关键它把内核相关的复杂细节收敛到最小范围内让上层可以只关注镜像和编排而不需要懂pivot_root为什么必须在chroot之后几毫秒内完成。我自己排查过的很多容器起不来的问题到最后都落在低层运行时这一环。比如某个镜像层权限不对rootfs目录里某个二进制没有执行权限低层运行时在执行execve时直接返回EACCES再比如配置文件里漏写了/dev/shm相关的挂载项容器跑起来后应用一访问共享内存就段错误。这些都不是高层运行时能感知的问题因为高层在镜像层面看到的全都是正常的。1.2 高层运行时镜像、快照与生命周期的管理员高层运行时站在低层运行时的上一层负责镜像管理、rootfs组装、容器生命周期和面向外界的接口。镜像下载、层校验、解压、通过overlayfs合并成一份rootfs这些动作都属于高层运行时。等rootfs准备好、配置文件生成完毕它才把活儿交给低层运行时。从架构演进的角度看高层运行时是一个典型的为了镜像和进程管理复杂度而存在的层。如果没有它每次启动容器你都要手工准备rootfs目录、手工写配置文件、手工处理容器退出后的资源回收这显然没法支撑大规模场景。高层跑起来之后用户只要提供一个镜像引用它就能自动完成层展开、可写层创建、config.json生成和低层运行时调起这一整套流程。我曾经踩过一个很隐蔽的坑现象是容器启动后某个二进制版本不对。最后排查到高层时发现快照层在合并时因为磁盘空间不足产生了一个截断的层文件高层运行时没有及时发现校验失败依然把它当成正常层暴露给了低层运行时。所以看深水区的内容时务必建立一条判断链镜像问题先找高层配置与内核交互问题再找低层不要上来就怀疑内核。1.3 OCI规范高低层之间的接口说明书高低层能自由组合、替换靠的不是默契而是开放容器标准业界普遍简称OCI下同规定的接口。OCI运行时规范定义了低层运行时收到的配置文件格式进程如何描述、rootfs在哪里、要挂载哪些文件系统、命名空间怎么开、资源限制怎么写、capabilities和seccomp规则放在哪。OCI镜xiang规范则定义了镜像层的内容格式和元数据结构。这些规范的实际意义在于上层实现可以只针对配置格式编程不需要关心换成另一个低层运行时之后行为是否一致反过来底层实现只要严格遵循配置语义也不需要关心上层是哪种编排系统。从软件工程角度看这就是把内核能力和业务编排之间用一份契约解耦。下面用一句话总结两者的边界能力维度低层运行时高层运行时核心操作clone、mount、pivot_root、execve镜像拉取、层解压、快照合并对内核的理解很深入直接使用系统调用基本不接触只需生成合法配置生命周期创建容器进程并等待退出管理容器状态与外界交互出问题时的表现系统调用错误、进程启动失败镜像校验失败、层损坏、状态不一致这个分层视角是后面所有内容的基础。搞清楚了边界再看clone到execve的内核序曲就不会被高层概念干扰。2. 容器进程诞生的内核序曲从clone到execve的完整链2.1 clone3()创建带命名空间的初始进程容器的第一个进程不是凭空出现的而是由一个普通进程通过clone或clone3系统调用创建出来的。老的clone用可变参数传标志位参数位置一旦搞错就会悄无声息地出问题新的clone3把所有参数放进一个结构体语义更清晰也更容易扩展新的CLONE_*标志所以现在的主流低层运行时都在向clone3迁移。对容器来说最重要的标志位是一组CLONE_NEW*。创建PID命名空间用CLONE_NEWPID挂载命名空间用CLONE_NEWNSUTS主机名命名空间用CLONE_NEWUTSIPC用CLONE_NEWIPC网络用CLONE_NEWNET。每次调用带上一个或几个标志内核就会为新进程建立对应的命名空间。这里有个很多人一开始不理解的细节CLONE_NEWPID只是让新进程在PID命名空间内显示为1号进程但它的父进程仍然存在于外层。容器内的1号进程和宿主机init进程地位完全不同——宿主机init需要负责信号处理、孤儿收养等系统级职责而容器内的1号进程只需要做应用层的事情。这也是为什么容器退出时经常出现僵尸进程如果1号进程没有主动回收子进程低层运行时只能在容器外观察到一堆无法收尸的孩子。2.2 挂载编排与pivot_root为什么不是chroot容器进程要看到自己的rootfs必须把文件系统根切换过去。很多人第一反应是chroot但生产级容器不会用。chroot只是把当前进程的根目录换成指定目录没有彻底隔离进程对挂载点和父目录句柄的访问。如果进程已经持有某个外部目录的文件描述符或者后续通过某些操作绕回旧根隔离就会被穿透。pivot_root要严格得多它把当前根文件系统整个迁移到put_old目录再把新的根文件系统迁移到根位置。这意味着旧根不再挂在当前根路径下进程想找也找不到返回路径。低层运行时通常先做一个rootfs的本目录bind mount再调用pivot_root把rootfs变成新根随后立即卸载旧根。下面是一段简化的Go片段示例了核心调用顺序func pivotToRoot(rootfs string) error { // rootfs 必须先以 bind mount 形式挂载一次 if err : unix.Mount(rootfs, rootfs, , unix.MS_BIND|unix.MS_REC, ); err ! nil { return err } pivotDir : filepath.Join(rootfs, .pivot_root) if err : os.MkdirAll(pivotDir, 0o700); err ! nil { return err } // 切换根 if err : unix.PivotRoot(rootfs, pivotDir); err ! nil { return err } // 切换完成后当前进程的工作目录可能还在旧树里必须立刻切到 / if err : unix.Chdir(/); err ! nil { return err } // 卸载旧根挂载点 return unix.Unmount(/.pivot_root, unix.MNT_DETACH) }这段代码有几个容易踩坑的细节。第一pivot_root要求rootfs和put_old在同一个挂载点上所以必须先做bind mount第二调用后如果忘了chdir(/)后续所有相对路径都会落在旧root里轻则配置文件找不到重则旧root卸载失败第三卸载要用MNT_DETACH因为此时可能有进程还持有旧root的引用挂起式卸载才能保证不报EBUSY。2.3 namespace的创建与进入是两个不同动作创建namespace用clone或clone3进入一个已有的namespace则靠setns。调试场景里最常见的操作是进入容器PID命名空间看进程或者进入挂载命名空间检查挂载点。setns需要一个指向目标namespace的文件描述符这些描述符可以通过/proc/pid/ns/目录拿到。比如想进入某个容器的挂载命名空间我会先用nsenter这类工具或者自己写一个小程序做三件事打开/proc/pid/ns/mnt拿到fd调setns进入再exec一个shell。这套操作在排查容器内看不到某个挂载点时非常管用因为它可以在不改变容器运行状态的前提下把观测视角切到容器内部。一定要记住setns只能改变当前进程的namespace归属不能凭空创建一个新namespace这两个动作在内核里的路径完全不同。3. cgroup账本的下发路径容器资源限额到底是怎么生效的3.1 从cgroup v1到v2运行时的工作方式变了早期的cgroup v1按控制器分树CPU一棵树、内存一棵树、IO一棵树互不相干。这种设计灵活但很难做一致性的资源控制。cgroup v2把一切都合并到统一层级所有控制器在同一个目录树下体现同时增加了一个让运行时非常在意的约束no internal processes。这个约束的意思是如果一个cgroup目录里已经有进程了那它的子目录就不能再放进程否则会破坏层级统计的单调性。高层运行时在创建容器子目录时如果没注意这一点把进程写进了父目录再往子目录放容器进程就会直接失败。这种问题在代码层面看不出毛病只有对cgroup v2的结构理解到位才能快速定位。3.2 关键文件与转化公式cgroup v2下运行时需要操作的文件不多但每个都不能写错。下面是几个最常见的控制项和它们对应的数值语义控制文件作用举例说明memory.max内存硬上限单位字节512MB写536870912cpu.weightCPU权重有效范围1-10000权重100约等于尽力而为cpu.maxCPU带宽上限格式配额 周期100000 100000表示单核满配pids.max进程数上限容器内进程数超过后fork返回EAGAINcgroup.procs将进程移入cgroup写入PID后进程立即受到该cgroup控制写文件顺序是刚入门最容易忽略的。我一般会先mkdir创建容器自己的cgroup目录接着把各类限额写进去最后才把容器初始进程的PID写入cgroup.procs。如果顺序反了先放进程再限制资源进程可能在无限制状态下跑一小段时间遇到突发内存消耗时直接触发宿主机级的OOM影响面比容器本身大得多。下面是一段典型的bash操作序列模拟低层运行时创建容器cgroup的过程# 假设运行时准备创建 id 为 c1 的容器 cgdir/sys/fs/cgroup/rt-c1 mkdir -p $cgdir echo 536870912 $cgdir/memory.max echo 100000 100000 $cgdir/cpu.max echo 20 $cgdir/pids.max echo $$ $cgdir/cgroup.procs这里有个值得说明的细节cpu.max的100000 100000表示每个周期100ms内最多使用100ms CPU等价于单核满载。如果要限制为四分之一核可以写25000 100000也就是每100ms周期内最多跑25ms。这种配额式限制在严格PaaS平台里很常见能防止某个容器挤占整台机器。3.3 一个真实的cgroup漏配经验我曾经处理过一个问题某容器的服务经常跑满CPU但配置的权重限制完全没生效。初步怀疑是运行时没写对文件进到容器所在cgroup目录一看cpu.weight文件确实存在但值被写在了父级目录。因为no internal processes约束这个权重只对父目录下直接存在的进程生效而容器进程在子目录中自然被绕开了。更讽刺的是CPU权重本身对cgroup v2的语义是继承的子目录没有单独配置时会继承父目录的权重。但一旦父目录里塞了进程这个权重就污染了整棵子树。修法也简单把容器进程移出父目录清理父目录进程再把权重写到真正对应的容器子目录。这次排查让我形成的习惯是看cgroup永远从叶子目录往根目录逐级看不要只看目标路径。4. 隔离不等于安全运行时安全防线的三层设计4.1 capabilities裁剪最小权限的预演Linux进程在默认情况下拥有root用户的全套权限这些权限被划分成几十个细粒度capabilities。容器运行时要做的事就是把容器进程的capabilities裁剪到一个最小的集合避免容器内的root实际上拥有和宿主机root一样的能力。生产配置中常见的保留项就那么几个允许绑定低端口CAP_NET_BIND_SERVICE、允许改变文件属主CAP_CHOWN、允许设置文件权限CAP_DAC_OVERRIDE等。而像CAP_SYS_ADMIN这种近乎内核管理总开关的权限除非极度特殊否则默认必须拿掉。CAP_SYS_ADMIN一旦保留容器进程几乎可以操作任何挂载、命名空间和内核对象等于把隔离变成纸糊的。运行时在裁剪capabilities时核心系统调用是capset和prctl。先清空继承集合再按配置逐项添加最后在execve前完成设置确保容器主进程一运行就处于受限状态。这里有个顺序陷阱capabilities要在execve之前设置因为执行文件时内核会根据文件的属性进一步收紧权限如果设置晚了某些关键操作就会被高权限抢先完成。4.2 seccomp系统调用层面的熔断capabilities管的是允许调用哪些特权操作而seccomp管的是允许触发哪些系统调用。即使一个容器进程拿到了部分capabilities如果它的系统调用被seccomp过滤掉同样执行不了预期动作。seccomp的规则是编译成BPF程序交给内核执行的运行时的职责是把配置文件里的白名单或黑名单翻译成BPF字节码再通过seccomp系统调用装载。实际使用中我们更倾向用白名单模式枚举出容器应用真正需要的系统调用其余全部拦截。拦截的惩罚动作通常是返回错误或者直接杀死进程取决于配置。区分这两种结果是排查问题的小技巧如果应用得到的是EPERM说明系统调用被seccomp放了进来但权限不足如果是SIGSYS信号导致进程退出说明系统调用在seccomp层就被拦死了。肉眼看不出来用strace一抓就能分辨因为SIGSYS的痕迹非常显眼。4.3 三层防线叠加的正确心态capabilities、seccomp、LSM权限模块是三条互相补充的防线谁也替代不了谁。capabilities控制特权操作seccomp控制系统调用面LSM在安全策略层兜底。生产环境里我不是特别建议把三者全部调到最严因为每条策略都是需要维护的规则集规则越多误杀概率越大。我在实践中的建议是默认用保守的capabilities加一个经测试的seccomp白名单再根据业务需求叠加LSM配置。每次变更安全配置后至少要在隔离测试环境跑一遍应用的主路径看有没有被规则误伤的系统调用。与其追求规则数量不如把规则和实际行为对齐。5. 用160行代码写一个能跑的最小容器运行时5.1 极简实现的边界讲再多的内核概念都不如动手写一个碰真内核的玩具运行时。我们用Go写一个约160行的最小实现只做三件事用clone3风格创建带PID、挂载、UTS命名空间的进程挂载/proc执行用户传入的命令。镜像拉取、网络、日志、资源限制全部省略但每一行代码都直接对应当前主流低层运行时真正的执行路径。package main import ( errors log os os/exec path/filepath syscall ) func pivotToRoot(rootfs string) error { if err : syscall.Mount(rootfs, rootfs, , syscall.MS_BIND|syscall.MS_REC, ); err ! nil { return err } pivotDir : filepath.Join(rootfs, .pivot_root) if err : os.MkdirAll(pivotDir, 0o700); err ! nil { return err } if err : syscall.PivotRoot(rootfs, pivotDir); err ! nil { return err } if err : syscall.Chdir(/); err ! nil { return err } return syscall.Unmount(/.pivot_root, syscall.MNT_DETACH) } func mustRun() error { if len(os.Args) 2 { return errors.New(usage: rt rootfs cmd [args...]) } rootfs : os.Args[1] cmd : exec.Command(os.Args[2], os.Args[3:]...) cmd.Stdin os.Stdin cmd.Stdout os.Stdout cmd.Stderr os.Stderr cmd.SysProcAttr syscall.SysProcAttr{ Cloneflags: syscall.CLONE_NEWPID | syscall.CLONE_NEWNS | syscall.CLONE_NEWUTS, } // 容器内进程的 /proc 由我们在外面直接准备好吗不这里 // 走的是另一种做法等子进程创建后再由子进程自身完成 proc 挂载。 // 为简化下面使用运行时注入 init 逻辑的方式完成。 return cmd.Run() }这只是骨架。真正完整的版本还得处理两个关键问题挂载/proc的时机不能在父进程而要在子进程pivot_root之后以及挂载/proc需要CAP_SYS_ADMIN但新创建的PID命名空间里如果没保留该capability就会失败。我通常在子进程里再执行一段极小的init程序专门做挂载这也是现代低层运行时采用runc init模式的本质原因。5.2 背后省略的困难shim层与生命周期玩具运行时省略掉的最重要模块是运行时与容器之间的中间代理进程。低层运行时不能直接变成容器进程它需要保持一个外部观察者身份以便接收信号、维护标准输入输出、收集退出状态。这个观察者就是常说的shim层。shim存在的意义可以这样理解如果低层运行时直接exec容器进程它自己就没了那么谁来回收集退出状态如果它不exec而只是等待那又需要一个独立进程保持stdout的流转。shim把这两件事都做了它是容器的父进程负责等待和回收它同时也是日志和输入输出的管家。我们极简实现里没有shim所以运行完一次后命令行会被挂住这恰好说明了真实运行时在生命周期处理上的复杂度。5.3 运行结果与经典失败用这个玩具运行时去预告备好的rootfs最常见的失败有两个。第一个是pivot_root返回EINVAL原因是rootfs没有先做bind mount或者put_old不在rootfs挂载点下第二个是挂载/proc时报EPERM因为没保留对应capability或者没进入正确的挂载命名空间。这两种错误在真实低层运行时里也反复出现只是错误信息被封装得更友好。写完这个玩具后我最深的体会是容器运行时不是启动器而是配置翻译器。它把一份描述性配置逐条翻译为内核指令任何一步翻译错误都会在系统调用层面直接暴露。这比调试业务代码要严格得多因为内核不会因为你意图好就容忍一个错误参数。6. 用strace复盘一次真实容器启动把全链路变成可阅读的日志6.1 抓取什么、过滤什么有时候代码读了半天都不如实际strace一把。为了让这部分的结论可复现我用一个预先准备好的bundle做实验用strace跟踪一次完整容器启动把系统调用按时间顺序落盘。strace -f -tt -o /tmp/rt_trace.log \ -e trace%process,%file,%mount,%signal,%memory \ 某个低层运行时的可执行文件 run 某个bundle-f要跟否则只能看到父进程-tt把微秒级时间戳打出来方便分析耗时-e trace按类别过滤可以避免输出被大量无关调用淹没。第一次跑时如果发现日志大到上百MB不要慌先按openat、mount、clone这些关键词做grep把主干挑出来。6.2 启动trace的分段读数把trace按时间切开我通常会看到明显分几个阶段读配置与校验rootfs大量openat、read调用创建容器cgroup目录并写限制文件clone3创建容器初始进程这是一瞬间的事子进程内部做挂载编排密集的mount、mkdir、stat调用pivot_root切换根目录execve执行容器主进程。耗时分布往往让很多人意外真正耗时间的不是clone3而是rootfs相关的大量文件系统调用和挂载编排。我测过几十次冷启动100毫秒上下其中clone3只占不到1毫秒大部分时间花在open目录、读写配置文件和处理overlayfs挂载参数上。阶段主要系统调用通常耗时占比配置读取openatreadstat20%左右cgroup准备mkdiropenatwrite5%-10%命名空间创建clone3setns1%以内挂载编排mountmkdirstat50%以上根切换与启动pivot_rootexecve20%左右这张表带来的工程指导意义非常直接如果容器启动太慢优先优化镜像层数和rootfs文件数量而不是去怀疑内核创建命名空间的开销。减少系统调用次数、缩小rootfs尺寸效果远好过优化内核参数。6.3 三种启动失败的内核侧模样最后分享三个我在strace里亲眼见过的失败形态每个都对应一类真实问题。第一种pivot_root返回EINVAL十有八九是rootfs没有先bind mount或者挂载配置和put_old目录不在同一棵树上。第二种setns返回EPERM多半是当前进程没有足够capability去进入目标命名空间常见于调试工具没加权限就硬闯容器。第三种cgroup写入返回ENOSPC往往是memory.max设得太小内核在写入时直接拒绝或者pids.max已经写满了。这三种失败有一个共同特征错误发生在一次系统调用上但根因可能是上层几层配置的合力。strace的价值不在于让你看到那个错误本身而在于让你看到错误前所有铺垫过的事件顺序从而判断是哪一层搞砸了。我也习惯在每次线上容器问题处理完之后把strace痕迹保留下来写一段简短分析。这个习惯坚持下来之后对内核路径的理解会明显上台阶。容器运行时表面上是软件本质上就是内核行为的一个仔细封装把trace读懂了很多所谓的神秘故障其实都只是配置与内核预期不一致而已。