别再对着容器发呆了:手把手带你拆解 Docker 源码,从 runc 到 shim 的底层真相

很多朋友学 Docker 停留在“会用 docker run -it xxx”的层面,一旦容器启动报错、运行时卡死,或者想二次开发,就瞬间抓瞎。市面上讲原理的文章不少,但大多浮于表面,画两张架构图就完事。今天这篇,站长不打算给你讲那些虚的,直接用源码路径带你走一遍 Docker 从 CLI 敲下命令到容器真正跑起来的完整链路,把 containerd、runc、shim 这“三驾马车”的源码逻辑掰开揉碎。

一、痛点:为什么你搞不懂 Docker?

💡 推荐阅读:Docker开发环境搭建全指南:三大主流方案横向评测与实战避坑手册

最大的问题在于,你看到的 docker 命令其实是一个“壳中壳”。现代 Docker 引擎(v20.x+)的架构早已拆分为 docker-cli -> dockerd -> containerd -> containerd-shim -> runc 五个层级。如果你把源码全下载下来,面对几十个仓库,根本无从下手。

所以,本文的核心思路是:抓主线,看关键函数调用链。我们不逐行读代码,而是跟着一条命令的生命周期走,搞清楚每一层到底做了什么,以及它们之间如何通信。

二、环境准备:源码别瞎下,先准备好“手术台”

🔥 【限时特惠福利】高性价比独享云服务器限时 1 折起

搭建网站或部署容器推荐选择高稳定性独享云服务器。限时特惠通道开放中,点击即可领取专属满减代金券与特惠折扣:

👉 立刻领取云服务器限时优惠券

💡 延伸阅读:Docker run 一直报错、镜像拉不下来?这份 Docker 入门与开发实战排坑指南,帮你少走三个月弯路

要分析源码,建议准备一台 Linux 服务器(Ubuntu 22.04 或 CentOS 7.9 均可),配置不用太高,4C8G 足够。重点是要有稳定的网络,因为需要拉取大量依赖。如果你还在用 Windows 本地跑虚拟机看源码,体验会大打折扣。

这里也提醒一下,做源码分析时经常要编译、重装系统组件,千万别用自己的生产环境或重要数据盘去折腾。站长建议,如果你手头没有闲置的物理机,最好去搞一台靠谱的云服务器,哪怕是按量付费的抢占式实例,用完就释放,也不心疼。选择云厂商时,务必关注内核版本和网络性能,这对后续编译 runc 影响很大。

1. 拉取核心仓库

我们需要关注三个仓库。注意,不要克隆 docker-ce 那个巨型聚合仓库,直接分拆拉取更清晰:

# 1. 客户端与服务端(含 docker daemon 逻辑)
git clone https://github.com/docker/docker.git
# 2. 容器运行时管理(负责镜像管理、生命周期)
git clone https://github.com/containerd/containerd.git
# 3. 底层容器运行时(真正干活的)
git clone https://github.com/opencontainers/runc.git

如果你只想看核心调用逻辑,docker 仓库里的 daemon/ 目录是重点;而 runc 仓库代码量最少,建议从它入手,最容易建立信心。

三、实操拆解:一条命令的“西天取经”路

💡 深度技术指南:linux服务器总是被黑?高级运维的“排坑”指南:SSH爆破、Rootkit、日志爆满一次讲透

我们以最常见的 docker run -it --name test alpine sh 为例,看看这条命令在源码层是如何被“大卸八块”的。

步骤 1:CLI 层的“传话筒”(docker/client)

入口在 docker 仓库的 cmd/docker/docker.go。这里没什么高深逻辑,无非是使用 Cobra 库解析命令行参数。但你要注意一个细节:CLI 并不是直接通过 gRPC 调用 dockerd 的,而是通过 HTTP API。

关键函数在 cli/command/container/run.go 中,它会调用 client.ContainerCreate()。此时,参数被封装成一个 container.Config 结构体,通过 POST 请求发送给 dockerd 监听的 /v1.41/containers/create 接口。

// 伪代码示意,非真实源码,仅展示调用链
func runContainer(cli *client.Client, config *container.Config) error {
    // 1. 解析镜像名
    // 2. 组装配置
    resp, err := cli.ContainerCreate(ctx, config, hostConfig, nil, nil, name)
    // 3. 拿到容器ID后,发起启动请求
    return cli.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{})
}

步骤 2:dockerd 的“包工头”逻辑(daemon/)

请求到达 dockerd 后,真正的处理逻辑在 daemon/container_operations.godaemon/start.go。这里有两个关键动作:

  • 创建容器:调用 daemon.newContainer(),这只是在内存中初始化了一个 container.Container 对象,并写入到本地磁盘的 /var/lib/docker/containers/<id>/config.v2.json 中。
  • 启动容器:这里有个分水岭。dockerd 自己并不直接调用 runc,而是作为 containerd 的客户端。在 daemon/start.go 中,会调用 daemon.containerd.Start() 方法。

请注意,dockerd 与 containerd 的通信是通过 gRPC 完成的,接口定义在 containerd 仓库的 api/services/tasks/v1/tasks.proto 中。当你看到 tasks.StartTask() 这个 RPC 方法时,就意味着请求已经交给了 containerd。

步骤 3:containerd 的“管家”逻辑(核心重点)

这是全篇最核心的部分。containerd 的入口在 services/tasks/service.go。它收到 StartTask 请求后,会做以下几件事:

  • 加载镜像快照:通过 mount 包将镜像层挂载成 rootfs。
  • 构造 OCI Spec:将你 docker run 时的参数(环境变量、Entrypoint、Mounts)通过 oci.SpecOpts 转换成符合 OCI 标准的 specs.Spec 结构体。这个结构体是 JSON 格式,会被保存到 /run/containerd/containerd.sock 对应的临时目录下。
  • 拉起 shim 进程:containerd 不会直接去 fork 容器进程,而是先启动一个 containerd-shim 进程。

源码位置在 runtime/v2/shim.go。这里有一个非常重要的设计:shim 是独立于 containerd 的进程。它存在的意义是为了让 containerd 崩溃或升级时,容器进程不受影响。

// containerd 调用 shim 的核心逻辑简化
func (s *service) Start(ctx context.Context, req *api.StartTaskRequest) (*api.StartTaskResponse, error) {
    // 1. 准备 bundle 目录,包含 config.json
    bundle := s.getBundlePath(req.ContainerID)
    // 2. 启动 shim
    shim, err := s.client.NewShim(ctx, bundle, opts)
    // 3. 通过 shim 调用 runc
    task, err := shim.Create(ctx, &task.CreateTaskRequest{...})
    return &api.StartTaskResponse{Task: task}, nil
}

步骤 4:shim 与 runc 的“最后一公里”

shim 进程(二进制名通常为 containerd-shim-runc-v2)在源码中对应 runtime/v2/runc/v2/service.go。它接收到 create 请求后,会调用 runc 的二进制文件。

注意,这里不是函数调用,而是进程级调用。shim 通过 exec.Command("runc", "create", ...) 来执行。

runc 的源码入口在 main.go,它解析子命令(create/start/run)。runc create 会读取当前目录下的 config.json,然后调用 libcontainer 包中的 factory.Create() 方法。

// runc 源码核心:libcontainer/container_linux.go
func (c *linuxContainer) start() error {
    // 1. 创建 init 进程
    parent, err := c.newParentProcess()
    // 2. 这里是关键!通过管道传递状态
    if err := parent.start(); err != nil {
        return err
    }
    // 3. 等待 init 进程发送就绪信号
    return c.state.transition(&createdState{c: c})
}

newParentProcess() 中,runc 会 fork 出一个子进程,并执行 nsenter 相关的系统调用(setnspivot_root),最终通过 execve 系统调用替换成你指定的 /bin/sh 进程。至此,容器真正跑起来了。

四、避坑清单:源码阅读与调试的“鬼打墙”

站长在啃这些代码时,踩过不少坑,列出来给你提个醒:

坑点 现象 解决方案
版本不匹配 containerd 调用 runc 报 unknown version dockerd、containerd、runc 三者版本必须兼容,查看 vendor.conf 文件锁定版本。
调试断点打错地方 在 dockerd 中打断点,发现根本不会触发 因为启动容器的最终执行在 shim 进程里,需要 attach 到 shim 的进程 ID 上调试。
CGO 依赖问题 编译 runc 时报 seccomp 相关错误 安装 libseccomp-dev,并使用 BUILDTAGS="seccomp" 重新编译。
只看代码不看日志 源码逻辑看懂了,但实际运行行为对不上 打开 debug 模式:dockerd --debug,观察 containerd 的 task.log

实操调试小技巧

如果你想看 runc 到底生成了什么配置,可以在启动容器时临时把 shim 的日志级别调高:

# 手动执行 runc,查看详细日志
mkdir -p /tmp/bundle && cd /tmp/bundle
# 生成默认 spec
runc spec
# 编辑 config.json 修改 process.args 为你的命令
vim config.json
# 直接前台运行
runc run mycontainer

这样你能看到 runc 视角下的完整错误信息,比在 docker 层面看要清晰得多。

五、总结:从“使用者”到“明白人”的跨越

回顾一下,一条 docker run 命令经历了:HTTP 调用(CLI->dockerd)-> gRPC 调用(dockerd->containerd)-> 本地进程间通信(containerd->shim)-> 系统调用(shim->runc->内核)

源码分析不是目的,而是手段。当你理解了 shim 的隔离作用,就能明白为什么生产环境升级 Docker 可以不重启容器;当你读懂了 runc 的 namespaces 初始化逻辑,就能解释为什么容器内看到的内存是宿主机总内存。

最后,站长想说,阅读源码很枯燥,建议你边读边画图,并且务必准备一台可以随时重置的服务器来验证你的猜想。毕竟,纸上得来终觉浅,绝知此事要躬行。希望这篇拆解能帮你捅破那层窗户纸,在容器技术的路上走得更远。

相关技术专题与延伸阅读

📦 【资源免费领】本文全套实操配置文件与避坑手册下载

本文涉及的全套 Docker Compose 配置文件、服务器运维避坑清单及 AI 提效指令库已完整打包,可免费极速转存:

👉 点击前往夸克网盘免费极速转存(手机端领 1TB 空间)

💡 提示:推荐使用手机【夸克网盘 App】打开保存,新用户首月免费赠送 1TB 超大空间与免流量极速下载特权。

滚动至顶部