为什么我们非要啃Docker源码?
💡 推荐阅读:Nginx反向代理配置参数全解:从入门到生产环境调优,看这一篇就够了
很多朋友在后台私信我,说看了InfoQ上不少关于Docker的分享,感觉云里雾里。说实话,InfoQ上的文章质量参差不齐,有些偏向于概念普及,有些则是纯广告软文。真正想搞懂Docker,绕不开的一条路就是直接读源码。
笔者(站长)在早期学习容器技术时,也走过不少弯路。市面上99%的文章都在教你怎么用docker run,但很少有人告诉你,当你敲下这行命令时,操作系统底层究竟发生了什么。今天这篇文章,咱们就顺着Docker的调用链,从源码层面把那些InfoQ上没讲透的细节,掰开揉碎了讲清楚。
第一站:Docker CLI 到底是个什么玩意儿?
很多人以为Docker是一个单体程序,其实不然。Docker采用标准的C/S(客户端/服务器)架构。你平时在终端里敲的docker命令,只是客户端(CLI),它本身不干任何重活,只负责把你的指令翻译成RESTful API请求,然后发给Docker Daemon(守护进程)。
从源码目录来看,CLI部分的代码主要在cli/目录下,入口文件是cmd/docker/docker.go。这里使用了Cobra这个命令行框架来管理所有的子命令。
// 简化自 cmd/docker/docker.go
func main() {
dockerCli, err := command.NewDockerCli()
if err != nil {
logrus.Fatal(err)
}
cmd := &cobra.Command{
Use: "docker",
// 长命令描述...
}
// 注册所有子命令,例如 run, ps, build 等
commands.AddCommands(cmd, dockerCli)
if err := cmd.Execute(); err != nil {
logrus.Fatal(err)
}
}
这里有个细节值得注意:Docker CLI 与 Daemon 的通信协议。默认情况下,它们通过本地的/var/run/docker.sock这个Unix Socket进行通信。如果你配置了远程访问,则会走TCP协议。这意味着,你完全可以在自己的笔记本上,通过设置DOCKER_HOST环境变量,去操作远程服务器上的Docker Daemon。
第二站:Daemon 的启动与模块化设计
接下来是重头戏——Docker Daemon。它的入口在cmd/dockerd/dockerd.go。启动过程非常复杂,涉及配置加载、API路由注册、容器运行时初始化等多个步骤。
在InfoQ的文章里,经常提到Docker的模块化。在源码中,这种模块化体现得淋漓尽致。Daemon 启动时会初始化以下几个关键模块:
- containerd:负责管理容器的完整生命周期(创建、启动、停止、销毁)。
- runc:真正干活的底层工具,负责与内核交互,创建隔离的进程。
- networkd:负责容器网络模式的配置(Bridge、Host、Overlay等)。
- volumes:负责数据卷的管理。
我们以dockerd启动时加载镜像存储驱动为例。Docker支持Overlay2、Aufs、DeviceMapper等多种存储驱动。源码中通过graphdriver.New()函数,根据你的系统配置和内核支持情况,自动选择最优的驱动。
// 简化自 daemon/graphdriver/driver.go
func New(name string, config Config) (Driver, error) {
// 根据 name 选择对应的驱动初始化函数
switch name {
case "overlay2":
return overlay2.Init(config)
case "aufs":
return aufs.Init(config)
// ... 其他驱动
}
}
这里建议大家在测试环境或者生产环境,尽量使用Overlay2驱动,它在性能和稳定性上表现最佳。如果你用的是CentOS 7,记得升级内核到3.10以上,否则只能退而求其次用古老的DeviceMapper。
第三站:深入 containerd 与 runc 的交互
很多初学者分不清Docker、containerd和runc三者的关系。简单来说,Docker是”包工头”,containerd是”项目经理”,runc是”一线工人”。
当我们执行docker run时,调用链是这样的:
- Docker CLI 发送请求给 Docker Daemon。
- Daemon 收到请求后,通过 gRPC 调用 containerd 的接口。
- containerd 接收到任务,从镜像仓库拉取镜像(如果本地没有),然后解压镜像到本地存储。
- containerd 通过
task.Create()调用 runc。 - runc 利用 Linux 内核的 Namespace 和 Cgroups 技术,创建出隔离的进程。
在源码层面,containerd 与 runc 的交互是通过exit code和管道来传递状态的。runc 启动容器后,会监听容器的状态变化,并通过文件锁和信号量通知 containerd。
这里有一个非常核心的概念叫Spec。runc 并不关心镜像层,它只认一个叫做 config.json 的文件。containerd 在调用 runc 之前,会将 Docker 镜像的配置(Entrypoint、Cmd、Env、Mounts等)转换为标准的 OCI(Open Container Initiative)规范,生成 config.json 文件。
// 这是 runc 启动容器时的核心调用逻辑(伪代码)
func startContainer(spec *specs.Spec) error {
// 创建新的进程
process, err := newProcess(spec.Process)
// 设置 Namespace
if err := applyNamespaces(process, spec.Linux.Namespaces); err != nil {
return err
}
// 设置 Cgroups 资源限制
if err := applyCgroups(process, spec.Linux.Resources); err != nil {
return err
}
// 启动进程
return process.Start()
}
看到这里,你应该明白了,所谓容器,其实就是一个被”特殊关照”的普通进程。它并没有虚拟机那样的硬件虚拟化层,而是直接共享宿主机内核。
第四站:镜像层的奥秘——联合文件系统
InfoQ上的文章经常提到Docker镜像的分层结构,但很少解释底层是如何实现的。这就要用到我们上文提到的存储驱动了。
以Overlay2为例,它利用Linux内核的OverlayFS文件系统,将多个目录层叠加在一起。Docker镜像的每一层都是一个只读目录,当你在容器内修改文件时,OverlayFS 会执行写时复制(Copy-on-Write)策略。
具体来说,当容器进程试图修改一个存在于底层只读层的文件时,OverlayFS 会先将该文件复制到最顶层的可写层(容器层),然后再进行修改。这样,底层只读层始终保持不变,多个容器可以安全地共享同一个镜像层,极大地节省了磁盘空间。
在源码中,docker image pull 下载的每一层,都会通过docker save导出的tar包格式存储。如果你好奇,可以手动执行docker history ubuntu:latest,看看这个镜像有多少层。
| 存储驱动 | 底层技术 | 适用场景 | 性能表现 |
|---|---|---|---|
| Overlay2 | OverlayFS | 生产环境首选 | 高 |
| fuse-overlayfs | FUSE | 无特权用户(Rootless) | 中 |
| VFS | 无 | 测试环境 | 极低 |
第五站:动手实践——从源码编译 Docker
光看不练假把式。为了加深理解,站长建议你有机会一定要自己动手编译一次Docker源码。这并不难,但需要一台配置尚可的Linux服务器。
这里顺便提一句,如果你打算深入学习或者跑一些比较吃资源的编译任务,建议不要用本地笔记本,太吃力了。可以搞一台靠谱的云服务器,比如站长的机器,性价比高,网络稳定,编译起来心情也舒畅。毕竟,工欲善其事,必先利其器。
以下是编译步骤:
环境准备
# 安装 Go 语言环境(版本需 >= 1.20)
wget https://go.dev/dl/go1.21.5.linux-amd64.tar.gz
tar -C /usr/local -xzf go1.21.5.linux-amd64.tar.gz
export PATH=$PATH:/usr/local/go/bin
# 克隆 Docker 源码(注意切换到稳定分支)
git clone https://github.com/moby/moby.git
cd moby
git checkout v24.0.7
执行编译
# 使用 Docker 官方提供的构建脚本
# 注意:这里会启动一个容器来编译,需要确保 Docker 已安装
make build
# 编译完成后,二进制文件在 bundles/ 目录下
ls bundles/dynbinary-daemon/dockerd
编译过程中,你会看到大量的Go包下载和编译输出。如果网络不好,可能会失败。此时可以配置一下Go的代理:
export GOPROXY=https://goproxy.cn,direct
编译成功后,你可以停掉系统的Docker服务,用自己编译的dockerd启动试试,那种掌控感是看多少篇文章都体会不到的。
写在最后的思考
读源码是一个枯燥但极其有成就感的过程。Docker的源码量巨大,不要指望一口气全部读完。建议从调用链入手,顺着docker run这条线,逐步深入。你会发现,一旦打通了任督二脉,再看K8s、containerd等其他项目,都会轻松很多。
好了,今天的源码分析就到这里。如果你在阅读过程中遇到什么卡壳的地方,欢迎在评论区留言,我们一起探讨。记住,纸上得来终觉浅,绝知此事要躬行。
延伸阅读
- Nginx反向代理配置参数全解:从入门到生产环境调优,看这一篇就够了
- WordPress 慢成蜗牛?手把手教你用 Redis 让它飞起来(附避坑细节)
- 别再被参数忽悠了,一篇讲透云服务器性能指标,选购不踩坑
- WordPress 提速实战:Redis 与 Memcached 到底怎么选?从原理到配置全拆解
🎁 全套实操配置文件与 AI 提效资料包免费下载
本文涉及的全套 Docker Compose 配置文件、服务器运维避坑手册及 AI 提效指令库已完整打包上传至夸克网盘,可直接免费极速转存:
💡 提示:推荐使用手机【夸克网盘 App】打开保存,新用户首月免费赠送 1TB 超大空间与免流量极速下载特权。