砍掉 runc 与 shim:Kiri 如何用 10 MB 定制内核和单线程 Rust PID 1 将容器启动压进 2.55 毫秒

砍掉 runc 与 shim:Kiri 如何用 10 MB 定制内核和单线程 Rust PID 1 将容器启动压进 2.55 毫秒

在桌面端跑容器,为什么一个最简单的 alpine 退出命令在 Docker Desktop 上要耗费 213 毫秒,在号称极速的 OrbStack 上也要 380 毫秒?最近我通读了在 GitHub 上迅速蹿红的 Rust 开源容器运行时 Kiri(名字源自伊博语 ntakịrị,意为“微小”)的全套源码。它用令人惊叹的系统工程克制给出了一份硬核答案:通过彻底砍掉 dockerd、containerd、containerd-shim 与 runc 四层传统抽象,配合 10.2 MB 极限裁剪内核与单线程 Rust PID 1 代理,Kiri 将 Docker Engine API 级别的容器全生命周期耗时压进了 2.55 毫秒,守护进程常驻内存更是压缩到 9.7 MB。

更让我意外的是,Kiri 不只是一套兼容 docker CLI、Compose、Buildx 与单节点 Kubernetes 的跨平台引擎(横跨 macOS、Windows WSL2 与原生 Linux),它在最新版本中还直接内置了零依赖的原生系统 WebKit 界面、38 种语言(含 RTL 排版)以及一套完整替代 Ngrok 的多通道公网隧道协议 kiri share。本文将从代码层面逐一拆解这套系统是如何在毫厘之间压榨出两个数量级的性能差距的。

[!NOTE] 阅读与基准说明
本文所有架构结论、数据指标与代码分析,均基于我拉取审查的 libracoder/kiri 仓库主干源码(包括 crates/、guest/kernel/ 与 bench/results.json)。文中基准数据均来自仓库内记录的同一台 Apple Silicon Mac(macOS 27.0.1)实机测量结果(同台 docker CLI 29.7.2,取 30 次运行中位数 p50)。


一、架构拓扑演进:为什么要彻底消灭 shim 与 runc?

传统桌面容器引擎之所以笨重,核心症结在于把服务端大型集群的抽象原封不动搬到了个人电脑的虚拟机里。在典型架构下,一条 docker run --rm alpine true 命令需要依次穿过:

  1. 宿主机 API 转发层:docker CLI 请求落到客户端守护进程,再转发进虚拟机内部;
  2. 双守护进程层:VM 内常驻的 dockerd(Docker 引擎)与 containerd(容器管理守护进程),两者仅在 Apple Silicon 上就常驻约 216 MB 内存;
  3. 每容器代理层:每启动一个容器,containerd 都会拉起一个独立的 containerd-shim 进程专门负责托管 stdio、收割退出码。我在 bench/results.json 中看到一组惊人的实测数据:每个 shim 平均吃掉 7.9 MB 内存,143 个容器的 shim 进程仅空转就占用了 1133 MB;
  4. 一次性运行时:每次创建容器都要冷启动一次 Go 编写的 runc 二进制,反复进行 fork/exec、命名空间切换、cgroup 配置与外部 iptables/nft 命令调用。

Kiri 的设计哲学非常彻底:既然桌面端虚拟机只由我这一个宿主机守护进程独占控制,为什么要保留服务端多租户调度留下的层层代理?

传统 Docker/OrbStack 容器引擎栈与 Kiri 极简原生运行时拓扑演进对比

通过上面这张前后拓扑对比可以看到,Kiri 直接将架构收敛成“宿主机单守护进程 + vsock 零分配帧总线 + 虚拟机内单线程 Rust PID 1”的三段式直通链路:

核心对比指标 Docker Desktop OrbStack Kiri(实测 p50) 数据来源与说明
API 生命周期 (alpine true) 213.4 ms 379.9 ms 2.55 ms Bridge 网络;--network none 静默环境仅 1.26 ms
完整 docker CLI 往返 243.3 ms 442.2 ms 14.15 ms 其中绝大部分耗时是 Go 编写的 docker CLI 自身冷启动
守护进程常驻内存 — 216 MB (dockerd+containerd) 9.7 MB 宿主机 kiri 9.0 MB + VM 内 kiri-init 0.69 MB
单容器 shim 内存开销 ~8 MB/个 7.9 MB/个 0 MB 无任何 shim 进程,全部由 PID 1 单循环统一监管
VM 冷启动至 Agent 就绪 — — 112 ms 内核到 PID 1 仅 8 ms,PID 1 初始化仅 0.4 ms

二、深入 Guest PID 1:单线程 poll() 与一次 clone3 的极限压榨

整个 Kiri 虚拟机内部没有 systemd,没有 dockerd,没有 containerd,甚至连 ip、nft、tini 这些外部二进制文件都不存在。所有职责全部压缩在 crates/kiri-init 编译出的 ~650 KB 静态链接 Rust 二进制(直接作为 Linux initramfs 里的 /init,即 PID 1)之中。

我在阅读 crates/kiri-init/src/linux/agent.rs 和 container.rs 时,发现作者做了一个极其反直觉、却堪称点睛之笔的架构决定:Guest Agent 全程保持单线程事件循环。

2.1 为什么单线程比异步/多线程更快、更安全?

在 Linux 内核编程中,当进程需要以 clone3 创建不共享内存空间(即不带 CLONE_VM)的子进程并进入全新命名空间时,多线程程序极易遭遇 fork() 锁死风险(其他线程持有的全局分配器锁或 stdio 锁在子进程中永远无法释放)。

kiri-init 利用这一物理特性,在 agent.rs 的 serve() 函数里用原生 libc::poll() 统一多路复用 14 类核心文件描述符:

  • 宿主机控制通道:vsock 连接(Port 1024)与 usage_wake 异步磁盘统计唤醒管道;
  • 信号与生命周期:signalfd(零延迟收割所有容器与 exec 子进程退出码,无需任何额外线程等待);
  • 网络与服务:DHCP 租约监听、内嵌 DNS 套接字、端口转发、SSH Agent 转发(/run/host-services/ssh-auth.sock)、容器内嵌 Docker API 中继、copy 复制服务、USB/IP 透传;
  • 内存压力感知:监听 Linux cgroup memory.pressure 的 POLLPRI 紧急事件,一旦 Guest 内存吃紧立刻向宿主机发送 Msg::MemoryPressure 请求动态扩容;
  • 所有活跃容器的 stdio:每个正在运行容器的 stdout / stderr 管道与 PTY 主端,以及按需开启 POLLOUT 的 stdin 写入队列。

2.2 一发入魂的 clone3(CLONE_INTO_CGROUP)

在 crates/kiri-init/src/linux/container.rs 的 spawn() 函数中,Kiri 在创建容器前就已经把参数表、环境变量、挂载清单与 /run/kiri/<id> 下的 /etc/hosts、/etc/resolv.conf 全部在 tmpfs 内存中准备完毕,然后通过单次 clone3 系统调用一步到位:

// crates/kiri-init/src/linux/container.rs 核心容器拉起逻辑
let mut flags = (libc::CLONE_NEWNS
    | libc::CLONE_NEWPID
    | libc::CLONE_NEWUTS
    | libc::CLONE_NEWIPC
    | libc::CLONE_NEWCGROUP) as u64
    | CLONE_INTO_CGROUP;

if spec.flags & run_flags::NET_HOST == 0 && prepared.net_join.is_none() {
    flags |= libc::CLONE_NEWNET as u64;
}
let args = CloneArgs {
    flags,
    exit_signal: libc::SIGCHLD as u64,
    cgroup: cgroup_fd.as_raw_fd() as u64,
    ..Default::default()
};

// 单线程环境无锁安全执行 clone3,直接诞生于目标 cgroup v2 与全套新命名空间内
let pid = unsafe { sys::clone3(&args) }.map_err(cleanup)?;

注意其中的 CLONE_INTO_CGROUP 标志:传统容器运行时往往先 fork/clone 出子进程,再由父进程去写 cgroup.procs 文件把进程挪进去,这中间不仅多出系统调用,还存在窗口期资源逃逸。Kiri 直接在诞生瞬间将其钉死在已配置好 --memory、--cpus 与 --pids-limit 的 cgroup v2 节点里,紧接着用 overlayfs 挂接镜像层并执行 pivot_root。

为了让整个创建流程控制在毫秒级,Kiri 连最常见的网络配置与系统调用过滤也完全拒绝外部命令和臃肿库:

  1. 零外部工具的网络配置(crates/kiri-init/src/linux/netlink.rs):作者直接手写了 AF_NETLINK(NETLINK_ROUTE 与 NETLINK_NETFILTER)报文构造器,用几十字节的 RTM_NEWLINK、RTM_NEWADDR、RTM_NEWROUTE 和大端序 nf_tables 净荷直接在内核态构建网桥、veth 对、IPv6 子网与端口 DNAT/MASQUERADE 规则,连 ip 和 nft 命令都省了;
  2. 极简黑名单 Seccomp 与 SECCOMP_FILTER_FLAG_SPEC_ALLOW(crates/kiri-init/src/linux/seccomp.rs):Docker 默认的 Seccomp 策略是一份包含三百多个允许项的巨大白名单,导致每次系统调用都要扫过冗长的 BPF 指令。Kiri 反向构建了一份精准的黑名单 cBPF 程序(拦截 add_key、keyctl、init_module、bpf、io_uring_*、unshare 等跨命名空间高危调用),更关键的是加上了 SECCOMP_FILTER_FLAG_SPEC_ALLOW。正如源码注释中揭示的:如果不加这个标志,Linux 内核会为受过滤进程强制开启 Speculative Store Bypass 硬件缓解,在虚拟机里每次上下文切换都要触发一次昂贵的固件调用,直接给短生命周期容器平添 0.5 毫秒的纯损耗!
  3. 内置微缩 --init(crates/kiri-init/src/linux/pid1.rs):当用户传入 --init 时,Kiri 无需在镜像里塞入 tini,而是由容器内子进程自身接管信号屏蔽队列与 sigtimedwait 转发,并循环 waitpid(-1, WNOHANG) 收割孤儿进程。

三、10.2 MB 定制内核与 5 项外科级内核补丁

如果把 Guest 用户态压到了极致,虚拟机启动还是卡在 810 毫秒怎么办?答案在 guest/kernel/ 目录里。

Kiri 放弃了发行版动辄 32 MB 的 defconfig 通用内核,改为从 allnoconfig 起步,只编译这台极简虚拟机实际拥有的设备驱动(virtio-pci、virtio-net、virtiofs、vsock、ext4、overlayfs、nftables、cgroup v2),把内核镜像直接压到了 10,264 KB(约 10.2 MB)。配合内核启动参数 rcupdate.rcu_expedited=1(加速 RCU 宽限期,将 cgroup2 挂载耗时从 23 ms 骤降至 0.1 ms)和 swiotlb=noforce(省去无用的 64 MB virtio 弹跳缓冲区),以及 5 项极为精准的内核源码补丁,冷启动耗时一路腰斩:

Kiri 四层纵向系统堆栈与 10.2MB 定制内核 112ms 冷启动削减剖面

当我点开 guest/kernel/patches/ 下的 5 个 .patch 文件时,不得不佩服作者在底层踩坑与定位根因的功力——每一行改动都直击 macOS 虚拟化与现代 Linux 生态的真实痛点:

  1. 0001-virtio-pci-poll-reset-with-hrtimer-sleep.patch(启动立省 ~30 ms):
    Linux 主线驱动在重置 virtio-pci 设备时调用 msleep(1) 轮询等待。在 HZ=250 的内核时钟下,msleep(1) 会向上取整休眠一整拍(4~8 毫秒)!由于 Apple 的 Virtualization.framework 总是会在第一次轮询之后才完成设备复位,挂载的每个 virtio 设备开机都要白白挨上一整个调度滴答。Kiri 将其改为高精度定时器 usleep_range(10, 50),直接从虚拟机冷启动路径上剃掉了 ~30 毫秒。
  2. 0002-virtio-net-big-tcp-as-gso.patch(解决端口转发软中断瓶颈):
    Apple 的 NAT 网络层会把宿主机 TCP 报文聚合成最高 64 KiB 的超大包递给 Guest,但既不设 DF 位也没有标注 GSO 元数据。如果报文只是发往 VM 自身还算正常,一旦通过 docker run -p 转发进容器 veth 网卡,Linux 内核会把它硬生生切碎成 ~44 个 MTU 大小的 IP 分片再由容器重组,瞬间把单核软中断打满、吞吐卡死在 ~9 Gbit/s。Kiri 在 virtnet_receive_done() 中新增 virtnet_kiri_big_tcp(),识别出这类 64 KiB 聚合 TCP 包并打上 SKB_GSO_TCPV4 与 gso_size = dev->mtu - hlen 标记,让数据包顺畅地以 GSO 大包形态穿过网桥与 veth。
  3. 0003-fuse-create-owner-readable.patch(修复 Node fs.cpSync 报错):
    macOS 的 virtiofs 服务端在执行 lookup 时要求拥有文件读权限。当容器内(例如 libstdc++ 的 copy_file 或 Node.js 的 fs.cpSync)在绑定挂载目录上以 0200(只写不可读)权限调用 open(O_CREAT, 0200) 时,Apple 的 virtiofs 会直接抛出 EACCES 并留下一个空残骸文件(Lima 的 vz 虚拟机也有同款 Bug)。补丁让内核在 FUSE_CREATE 时先附加所有者可读位,打开后再立刻用 fuse_do_setattr 恢复调用方请求的真实权限位。
  4. 0004-gro-frag-list-gso-size.patch:
    引入了上游关于 GRO frag_list 合并的修复,防止线性部大于 gso_size 的聚合包在转发分段时触发 skb_segment() 内核 BUG_ON 崩溃。
  5. 0005-binfmt-elf-hwcap3-only-when-set.patch(拯救 macOS 15 上的 Rosetta x86_64 容器):
    Linux 6.13 及更高版本会在启动任何 ELF 程序时向辅助向量写入 AT_HWCAP3(类型 29),哪怕它的值是 0。然而 macOS 15 自带的 Rosetta for Linux 只要遇到不认识的辅助向量类型就会直接报错终止(rosetta error: unhandled auxillary vector type 29),导致所有 linux/amd64 容器全军覆没。由于 Apple 虚拟化并不提供 MTE/LSFE 特性,ELF_HWCAP3 恒为 0,Kiri 加上了 if (ELF_HWCAP3) 判断,只在非零时注入该字段,从而完美保住了 Apple Silicon 上的跨架构运行能力。

四、宿主机工程品味:vsock、objc2 内存气球与 Unix Socket NFSv3

除了 Guest 内部的狂暴优化,宿主机侧(crates/kiri、crates/kiri-vmm、crates/kiri-proto)的工程细节同样堪称教科书级别。

4.1 零安全隐患的 no_std 通信总线(crates/kiri-proto)

宿主机与 Guest 之间通过 Virtio vsock 通信,实测往返延迟 p50 仅 31 微秒、p99 为 62 微秒。整个协议栈 crates/kiri-proto 强制声明 #![forbid(unsafe_code)] 且 #![cfg_attr(not(test), no_std)]:

  • 稳态零分配:采用 len: u32 LE | kind: u16 LE | payload 紧凑二进制帧,编码写入调用方复用缓冲区,解码直接借用字节切片;
  • 大流量直通:拉取镜像层时,在发送 Msg::LayerBegin 帧后直接向流里裸写 size 字节原始 Tar 流,彻底跳过帧封装与内存拷贝;
  • 4 MiB stdin 信用流控:通过 Msg::InputConsumed 回执实现背压(INPUT_WINDOW = 4 << 20),防止慢消费者把 Guest 内存撑爆;
  • 跨系统硬件 Bug 绕过:针对 docker cp(COPY_PORT = 1028),协议专门设计了 copy_flags::CHUNKED 分块标记——因为源码注释指出,macOS 14 Intel 上的 Virtualization.framework 会把 vsock 的半关闭(shutdown(SHUT_WR))错误地透传成全关闭,导致 Guest 回写 CopyDone 时触发 EPIPE。

4.2 为什么“60 秒闲置关机”比纯内存气球更聪明?

在 crates/kiri/src/daemon/engine.rs 中,Kiri 使用了 Apple 的 VZVirtioTraditionalMemoryBalloonDevice 动态收紧 Guest 内存(保持实际使用量 + 256 MB 裕量)。但作者在代码第 730 行的注释里道出了一个残酷的 macOS 底层真相:

[!IMPORTANT] 内存归还真相(出自 crates/kiri/src/daemon/engine.rs)
Apple 的气球设备(Balloon)虽然能从 Guest 回收内存页面,但并不会把这些物理内存真正释放归还给 macOS 宿主机! 唯一能将全部内存彻底吐回给 macOS 的方式就是关闭 VM。

正因为 Kiri 把冷启动压到了 112 毫秒(加上状态恢复约 0.15 秒),它才敢于设计出“无活跃容器与 API 请求超过 60 秒即自动关机睡眠”的激进策略。平时不跑容器时,VM 占用 0 MB 内存,仅宿主机 kiri daemon 以 ~9 MB 静默守候在 Unix Socket 上;下次敲下 docker ps 或 docker run 的瞬间,VM 在百毫秒级悄然醒来,用户几乎毫无体感延迟。

4.3 零内核扩展的 Finder 容器文件挂载:Unix Socket 上的 NFSv3

如何在不安装 macOS 内核扩展(kext)、不需要管理员权限、也不用 FUSE 的前提下,让用户直接在 Finder 的 ~/Kiri 目录里实时浏览所有运行中容器的文件系统?

Kiri 的做法是在 crates/kiri/src/daemon/nfs.rs 中从零手写了一个轻量级只读 NFSv3 + MOUNT 协议服务器,直接监听在用户私有目录的 Unix Domain Socket(~/.kiri/files/nfs.sock)上,并调用 macOS 原生的 mount_nfs 进行用户态挂载!

为什么不用本地回环 TCP(127.0.0.1)而要坚持走 Unix Socket?因为在同一台 Mac 上任何本地账号都能访问回环端口并伪造 NFS 请求里的 UID;而在 Unix Socket 上,Kiri 可以通过 libc::getpeereid() 让操作系统内核亲自校验对端连接者是不是 root(macOS 的内核 NFS 客户端身份)或当前登录用户本人。同时,由于 APFS 默认大小写不敏感,~/Kiri 与仓库克隆目录 ~/kiri 撞名,挂载前还会严格检查目录是否为空或仅包含旧 Kiri 残留挂载,绝不覆盖用户的个人代码目录。

4.4 原生 WebKit 界面与 NameConstraints 受限本地 CA

  • 拒绝对标 Electron 的臃肿:crates/kiri/src/ui/ 直接通过 objc2 驱动 macOS AppKit 的 WKWebView(Linux 上走 WebKitGTK,Windows 走 WebView2),前端仅是一个内嵌在二进制里的单文件 index.html,没有打包一整套 Chromium,却实现了实时容器日志搜索、嵌入式 xterm.js 终端、多语言实时切换(38 种语言,包含阿拉伯语、波斯语与中库尔德语等原生 RTL 排版)。
  • 防泄漏约束的本地 HTTPS(crates/kiri/src/daemon/tls.rs):为了让 https://<name>.kiri.local 开箱即用,Kiri 在 ~/.kiri/tls 生成了一张本地根证书。但为了防止私钥万一泄露被用于签发任意公网网站或 IP 证书,作者在 rcgen 生成 CA 时严格写入了 X.509 NameConstraints——permitted_subtrees 仅允许 kiri.local 与 kiri.localhost,并用 excluded_subtrees 显式封死了全部 IPv4 (0.0.0.0/0) 与 IPv6 (::/0) 网段!

五、杀手级彩蛋:kiri share 如何用纯 Rust 做出免费的 Ngrok 替代品?

在分析 crates/kiri-share-proto 和 crates/kiri/src/share.rs 时,我发现了 Kiri 除容器引擎之外的另一大王牌:极简内网穿透。只需在终端敲下 kiri 3000 或 kiri share <容器名>,就能立刻为本地端口或容器生成一个 https://<name>.runkiri.cloud 公网加密入口。

内置 Ngrok 替代方案 kiri share 多通道并发隧道与协议流控数据流

这套自研隧道协议同样保持着极高的工程纯度(#![forbid(unsafe_code)] + no_std):

  1. 单端口 ALPN 智能分流:客户端连接 edge.runkiri.com:443 时在 TLS 握手中携带 ALPN: kiri-share/1,边缘节点凭此在同一个 443 端口上无感知区分隧道控制连接与普通浏览器访客流量;
  2. 抗滥用的构建证明机制(crates/kiri-share-proto/src/attest.rs):官方发布版编译时会嵌入一枚 32 字节构建密钥(通过 XOR 掩码拆存防简单字符串提取)。当客户端设置 FEATURE_ATTEST 发起 Hello 时,Edge 下发一次性随机 nonce 挑战,客户端返回以 "kiri-share attest v1" 为上下文的 HMAC-SHA-256 证明(绑定当前 nonce、Account Token 摘要与 device_id 长度前缀帧),既防止脚本恶意刷滥用流量,又支持单密钥版本吊销;
  3. 多 TCP 通道条带化 + 滑动窗口防阻塞(FEATURE_LANES + FEATURE_WINDOW):单条 TCP 长连接在公网丢包环境下极易受限速与队头阻塞困扰。若服务端在 Welcome 中下发了 lanes > 0 与 lane_key,客户端可并行拉起多条附加连接发送 Msg::Join,将单条流的数据切片为带递增序号 seq 与 fin 标志的 Msg::Chunk(最大 32 KiB)分散到各条信道上并发传输,由 Edge 按序重组;同时每条虚拟流独立维持 INITIAL_WINDOW = 256 KiB 信用窗口流控,确保某一个慢速访客永远不会拖垮整条隧道。

六、跨平台哲学与实用体验边界

为了覆盖完整的个人开发者矩阵,Kiri 在另外两大桌面平台也没有妥协:

  • Linux 原生运行:彻底不启动任何虚拟机,crates/kiri-vmm/src/process.rs 直接把 kiri-init 作为宿主机子进程运行在原生内核之上,同时提供打包完整的 .deb/.rpm(含 Docker 工具链)以及纯引擎版 kiri-lite;
  • Windows WSL2 专有命名管道桥(crates/kiri-win):Windows 端最容易踩坑的是 WSL2 的 localhost 端口转发会吞掉 TCP 半关闭信号,导致 docker run 经常丢失进程末尾输出。Kiri 干脆绕过 TCP 中继,在 Windows 端暴露 npipe:////./pipe/kiri,通过单条 wsl.exe ... kiri bridge 进程标准流把控制帧直接桥接进 WSL2 内的 Linux 引擎,彻底根除了半关闭丢字难题。

当然,在审读 docs/features.md 与 README.md 后,我也注意到了两个当前版本的客观限制,值得在上手前心里有数:

  1. 内置 BuildKit Builder 仍在路线图中:目前 docker build / buildx 依靠在 Kiri 容器内按需拉起 docker-container 驱动的 BuildKit 容器实现(闲置 300 秒后自动暂停);在真正内置 Docker 原生 build 端点之前,若在 BuildKit 构建阶段用到 --add-host name:host-gateway,需要暂时手动指定网关地址(如 Mac 下为 198.19.248.254)或回退 DOCKER_BUILDKIT=0;
  2. macOS 休眠与 USB 透传:虽然 Kiri 已经支持将串口调试器、Zigbee 适配器或开发板通过 USB/IP 透传进容器并在设备重插后自动重连,但 macOS 系统睡眠时会自动断开 USB 总线供电,长时间跑烧录或串口监听任务需要配合 caffeinate -s 保持唤醒。

七、总结与启示

通读 libracoder/kiri 的几十个核心 Rust 模块,我最大的感受是:现代基础软件的性能跃迁,往往不靠更花哨的并发技巧,而靠敢于删除“理所当然存在的中间层”的勇气。

当业界普遍把 dockerd + containerd + shim + runc + 通用发行版内核 当成不可动摇的行业标配时,Kiri 回归到操作系统最本质的原语——Linux 的 clone3、overlayfs、cgroup v2、AF_NETLINK、cBPF seccomp 与宿主机原生虚拟化框架——用几万行高内聚、零冗余的 Rust 代码与 5 个直击病灶的内核补丁,硬生生把容器冷启动打回了 2.55 毫秒的原生进程级体验。对于任何想深入学习 Rust 系统编程、Linux 容器底层隔离原语或跨平台桌面虚拟化工程的开发者来说,kiri 都是一座不可多得的宝藏代码库。


原文链接与参考资料