Docker 的存储子系统是整个容器引擎里最容易被忽视,却又最直接影响性能与稳定性的模块。它负责把镜像分层、容器可写层、卷挂载等复杂操作收敛成一组统一调用,让上层镜像管理服务不必关心宿主机用的是哪种文件系统。理解存储驱动的代码架构,首先要抓住一条主线:所有差异都被封闭在驱动实现内部,对外只暴露有限的契约方法。

graphdriver 抽象层与核心接口设计
在 Docker 源码中,graphdriver 包定义了存储驱动最顶层的接口。这个接口并不庞大,却足够覆盖镜像和容器生命周期所需的基本动作。核心方法包括 Create、Remove、Get、Put、Exists 以及 Cleanup。其中 Create 用于基于父层 ID 创建新的读写层,Get 返回某层的挂载路径,Put 在引用计数归零后执行清理。这种引用计数模型避免了并发启动容器时底层目录被误删。
接口之所以这样设计,是因为 Docker 早期需要同时支持 AUFS、devicemapper、btrfs 等多种后端。如果让镜像服务直接调用具体文件系统命令,代码会变成难以维护的条件分支。抽象层把“层”的概念标准化:每一层都有一个全局唯一的 ID 和对应的宿主机目录或块设备。驱动实现者只需把文件系统的特性映射到这些方法中。例如 overlay2 驱动在 Create 里创建 upperdir 和 workdir,而 devicemapper 则在 Create 中分配稀疏文件并挂载为循环设备。
除了基础接口,graphdriver 还定义了 Driver 配套的 ProtoDriver 与 DiffDriver。前者处理原生层操作,后者负责层与层之间的差异打包,也就是 Diff 和 Apply 方法。镜像下载后的 tar 包解压、推送前的差异计算都走 DiffDriver。这种拆分让存储驱动既能管理本地挂载,也能参与镜像分发,而不必让网络模块了解底层是 overlay 还是 btrfs。
驱动注册机制与工厂模式
Docker 在启动阶段通过工厂函数完成存储驱动的选型。源码里有一个全局的 drivers 映射表,键是驱动名如 overlay2,值是初始化函数。每个具体驱动在包初始化时调用 graphdriver.Register 把自己塞进这张表。这种典型的工厂加插件模式,使得新增一种文件系统后端不需要改引擎主流程,只要在新包里写 init 逻辑即可。
工厂的优先级决策也值得细看。Docker daemon 配置里可以显式指定 storage-driver,若未指定则按内置支持列表逐个探测:先检查内核模块是否加载、再尝试挂载测试目录。比如 overlay2 会验证 overlay 内核选项和 index=off 兼容性;devicemapper 会确认 dmsetup 工具与瘦池存在。探测成功才返回对应实例,否则继续下一个候选。这样的回退逻辑保障了不同 Linux 发行版开箱即用。
注册过程还涉及能力声明。每个驱动通过 Capabilities 方法告诉上层自己是否支持稀疏写、是否天然层叠、是否要求 root 权限。镜像服务依据这些标志决定能否启用某些优化,例如在支持 DiffDriver 的驱动上直接做块级差异,而不必走通用的 tar 流式对比。下面是一段简化版的注册示例代码:
package overlay2
import (
"github.com/docker/docker/graphdriver"
)
func init() {
graphdriver.Register("overlay2", Init)
}
func Init(home string, options []string) (graphdriver.Driver, error) {
// 检测 overlay2 内核支持并挂载基础目录
d := &Driver{home: home}
if err := d.configure(options); err != nil {
return nil, err
}
return d, nil
}
容器层挂载与读写流程的代码路径
当 docker run 触发创建容器时,存储驱动的工作才真正落到实处。daemon 先调用 Create 为容器生成可写层,随后通过 Get 拿到合并后的挂载点。以 overlay2 为例,Get 内部会拼装 lowerdir(镜像各只读层)、upperdir(容器层)、workdir,然后执行 mount -t overlay 系统调用。挂载完成后,容器进程看到的文件系统就是标准的统一视图。
读写分离是这套架构的性能关键。容器对文件的修改只落在 upperdir,删除操作则通过 whiteout 文件标记,不影响下层镜像。当多个容器基于同一镜像启动时,lowerdir 被共享只读挂载,内存中的页缓存可以复用,极大节省物理内存。代码里用引用计数追踪每层被多少容器依赖,只有计数归零 Put 才会真的卸载并清理 upperdir。
异常处理同样体现在代码架构中。如果挂载过程中宿主机资源不足或权限错误,驱动会返回明确错误,daemon 捕获后回滚已创建的层,防止残留脏数据。在 devicemapper 这类块设备驱动中,还会涉及瘦池元数据的回滚。理解这条从 Create 到 Get 再到 Put 的调用链,有助于在线上排查容器启动慢、磁盘爆满等故障,因为绝大多数存储问题都发生在这些方法的实现细节里。
不同驱动实现间的架构差异与选型
虽然接口统一,但各驱动内部架构差别明显。overlay2 依赖内核 overlay 文件系统,代码轻量,主要工作是目录管理与挂载参数组装;devicemapper 则在用户态维护瘦池与快照映射,代码复杂度高,还要处理循环设备与 dm 表。btrfs 和 zfs 利用原生子卷与快照,差异计算几乎零成本,但对内核版本和文件系统格式化要求苛刻。
从代码维护角度看,overlay2 已成为默认推荐,因为它的抽象成本最低,且社区测试用例最全。在大规模节点上,overlay2 的元数据操作集中在宿主 ext4 或 xfs 上,配合 index=off 能规避重命名带来的卡顿。而 devicemapper 虽然适合隔离性要求高的场景,但稀疏文件容易引发磁盘碎片,源码中大量重试与同步逻辑也增加了排查难度。选型时不能只看接口一致,还要评估驱动背后隐藏的系统调用密度与故障域。
综上,Docker 存储驱动代码架构的本质是用薄抽象封装厚差异。graphdriver 定义契约,工厂模式完成解耦,具体驱动在挂载与差异计算上各显神通。掌握这套分层设计,不仅能读懂 daemon 启动日志里的存储初始化信息,更能在遇到性能瓶颈时快速定位是该换驱动还是该调内核参数。
Dockerstorage_drivergraphdriver修改时间:2026-08-14 11:21:37