很多朋友学 Docker 停留在“会用 docker run -it xxx”的层面,一旦容器启动报错、运行时卡死,或者想二次开发,就瞬间抓瞎。市面上讲原理的文章不少,但大多浮于表面,画两张架构图就完事。今天这篇,站长不打算给你讲那些虚的,直接用源码路径带你走一遍 Docker 从 CLI 敲下命令到容器真正跑起来的完整链路,把 containerd、runc、shim 这“三驾马车”的源码逻辑掰开揉碎。
一、痛点:为什么你搞不懂 Docker?
💡 推荐阅读:Docker开发环境搭建全指南:三大主流方案横向评测与实战避坑手册
最大的问题在于,你看到的 docker 命令其实是一个“壳中壳”。现代 Docker 引擎(v20.x+)的架构早已拆分为 docker-cli -> dockerd -> containerd -> containerd-shim -> runc 五个层级。如果你把源码全下载下来,面对几十个仓库,根本无从下手。
所以,本文的核心思路是:抓主线,看关键函数调用链。我们不逐行读代码,而是跟着一条命令的生命周期走,搞清楚每一层到底做了什么,以及它们之间如何通信。
二、环境准备:源码别瞎下,先准备好“手术台”
💡 延伸阅读: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.go 和 daemon/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 相关的系统调用(setns、pivot_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开发环境搭建全指南:三大主流方案横向评测与实战避坑手册
- Docker run 一直报错、镜像拉不下来?这份 Docker 入门与开发实战排坑指南,帮你少走三个月弯路
- linux服务器总是被黑?高级运维的“排坑”指南:SSH爆破、Rootkit、日志爆满一次讲透
- 别再被参数表忽悠了!手把手教你按业务场景选对云服务器配置,少花冤枉钱
📦 【资源免费领】本文全套实操配置文件与避坑手册下载
本文涉及的全套 Docker Compose 配置文件、服务器运维避坑清单及 AI 提效指令库已完整打包,可免费极速转存:
💡 提示:推荐使用手机【夸克网盘 App】打开保存,新用户首月免费赠送 1TB 超大空间与免流量极速下载特权。