Docker存储驱动:overlay2、aufs、devicemapper原理
引言
想象一下,你正在运营一家连锁餐厅。每家分店(容器)都需要一套完整的厨房设备(基础镜像),但如果你给每家分店都买一套全新的厨具,成本将高得离谱。聪明的做法是:所有分店共享一个中央仓库(基础镜像层),每家分店只需要在自己需要改动的地方加上自己的专属设备(容器层)。
这正是Docker存储驱动要解决的核心问题。但在生产环境中,我见过太多因为选错存储驱动导致的惨案:磁盘写满、性能暴跌、甚至内核崩溃。今天,我们就从源码级别剖析 overlay2、aufs、devicemapper 这三种主流存储驱动,帮你彻底搞懂它们的底层原理。
核心概念:先打个比方
存储驱动就像餐厅的"食材档案系统":
- aufs 像是老式档案柜——功能齐全,但每次查档案都要翻遍所有抽屉(性能开销大)
- overlay2 像是现代的"透明文件夹"——用特殊的叠加方式,查找文件时像看透明胶片一样,上层盖住下层
- devicemapper 像是给每个分店分配独立的冷冻库——隔离性最好,但空间利用率低
技术定义上,存储驱动负责管理镜像层(只读层)和容器层(可写层)的联合挂载(Union Mount)。当容器读取文件时,存储驱动需要决定从哪个层读取;当容器写入文件时,需要实现写时复制(Copy-on-Write)。
源码/原理深度分析
overlay2:现代Linux的王者
overlay2 是当前Docker默认的存储驱动(Linux内核4.0+)。它的核心是OverlayFS,由三个目录组成:
- lowerdir:只读的镜像层
- upperdir:可写的容器层
- merged:最终挂载点,容器看到的视图
内核源码中,fs/overlayfs/super.c 定义了挂载逻辑:
// 简化版 overlayfs 挂载流程
static int ovl_mount(struct fs_context *fc)
{
struct ovl_fs *ofs = fc->s_fs_info;
// 解析 lowerdir 和 upperdir 参数
// 关键:overlay2 将多层镜像合并为单个 lowerdir,
// 用冒号分隔,最多支持 500 层
ofs->lowerdir = kstrdup(fc->source, GFP_KERNEL);
// 创建 overlay 文件系统
return ovl_fill_super(fc->sb, ofs);
}关键特性:页缓存共享
overlay2 最大的优势在于,当多个容器共享同一个镜像层时,内核会直接共享页缓存(Page Cache)。这意味着:
- 内存利用率极高
- 文件读取速度接近原生
- 容器启动时间几乎不受镜像大小影响
aufs:老牌劲旅的优雅与局限
aufs(Another Union File System)是Docker早期最常用的驱动,由日本工程师开发。它的设计哲学是"一个挂载点,多层叠加"。
核心数据结构在 fs/aufs/opts.h:
struct au_branch {
struct path ab_path; // 分支的路径
struct au_finfo *ab_finfo; // 文件信息
// ...
int ab_wh; // 白出(whiteout)标志
};白出机制:当容器删除lowerdir中的文件时,aufs不会真正删除,而是在upperdir创建一个"白出"文件(文件名以.wh.开头),标记该文件已被删除。这种机制虽然优雅,但带来了两个问题:
- 删除大量文件时会产生大量白出文件,导致inode耗尽
- 查找文件时需要遍历所有层,性能随层数线性下降
devicemapper:块级别的隔离
devicemapper是Linux内核的块设备映射框架。Docker使用它的thin-provisioning(精简配置)功能实现存储驱动。
核心原理是设备映射:
物理磁盘 -> 卷组(VG) -> 精简池(Thin Pool)
├── 基础设备(Base)
├── 镜像层(Snapshot)
└── 容器层(Thin Volume)快照链:每个容器层都是基于镜像层的可写快照。当容器写入数据时,devicemapper会分配新的块并从父快照复制数据(如果块不存在)。
在 drivers/devmapper/deviceset.go 中,Docker实现了块分配逻辑:
// 从精简池分配新的块
func (d *DeviceSet) registerDevice(id string, hash string, size int64) (*DeviceInfo, error) {
// 创建快照设备
if err := d.createSnapshot(info, baseInfo, deviceId); err != nil {
return nil, err
}
// 初始化设备映射表
// 使用 thin-pool 目标
table := fmt.Sprintf("%s %s %d %d",
d.getDevMapper(), // 设备路径
d.thinPoolName(), // 精简池名称
deviceId, // 设备ID
size) // 设备大小
// 加载设备映射
if err := d.loadDeviceTable(info); err != nil {
return nil, err
}
}致命痛点:devicemapper使用loopback文件时性能极差(额外一层间接IO),且空间利用率低(按块分配,最小64KB)。虽然支持直接使用物理LVM卷,但配置复杂度高。
实战代码:三种驱动的手工实现
示例1:用overlay2手动构建容器文件系统
#!/bin/bash
# 手动模拟overlay2存储驱动的核心流程
# 这个脚本演示了overlay2如何组织镜像层和容器层
set -e
# 1. 准备镜像层(只读层)
# 模拟拉取镜像,创建两个只读层
mkdir -p /tmp/overlay-demo/{lower1,lower2,upper,work,merged}
# lower1:基础镜像层,包含系统核心文件
echo "Ubuntu base" > /tmp/overlay-demo/lower1/etc/os-release
mkdir -p /tmp/overlay-demo/lower1/usr/bin
echo "#!/bin/bash" > /tmp/overlay-demo/lower1/usr/bin/hello
echo "echo 'Hello from lower1'" >> /tmp/overlay-demo/lower1/usr/bin/hello
chmod +x /tmp/overlay-demo/lower1/usr/bin/hello
# lower2:应用镜像层,添加应用配置
mkdir -p /tmp/overlay-demo/lower2/etc
echo "app config v1" > /tmp/overlay-demo/lower2/etc/app.conf
# 2. 准备容器层(可写层)
# upper目录存储容器运行时的修改
echo "container env" > /tmp/overlay-demo/upper/container.env
# 3. 执行overlay挂载
# lowerdir可以指定多个,用冒号分隔,最左边优先级最高
# workdir必须是upperdir在同一文件系统上的空目录
mount -t overlay overlay \
-o lowerdir=/tmp/overlay-demo/lower2:/tmp/overlay-demo/lower1,\
upperdir=/tmp/overlay-demo/upper,\
workdir=/tmp/overlay-demo/work \
/tmp/overlay-demo/merged
# 4. 验证挂载结果
echo "=== 查看合并视图 ==="
ls -la /tmp/overlay-demo/merged/
echo "=== 测试读取lower层文件 ==="
cat /tmp/overlay-demo/merged/etc/os-release
echo "=== 测试写时复制 ==="
# 修改lower2中的文件,会触发copy-up
echo "app config v2" > /tmp/overlay-demo/merged/etc/app.conf
echo "--- 修改后 ---"
cat /tmp/overlay-demo/merged/etc/app.conf
echo "--- lower2中的文件(应该没变)---"
cat /tmp/overlay-demo/lower2/etc/app.conf
echo "--- upper中的新文件 ---"
cat /tmp/overlay-demo/upper/etc/app.conf
# 5. 测试删除lower层的文件(白出)
rm /tmp/overlay-demo/merged/usr/bin/hello
echo "--- 删除后upper目录的内容 ---"
ls -la /tmp/overlay-demo/upper/usr/bin/
# 清理
umount /tmp/overlay-demo/merged示例2:Python实现的aufs风格文件覆盖逻辑
#!/usr/bin/env python3
"""
模拟aufs存储驱动的核心机制
实现多层文件系统的查找、写入和删除逻辑
"""
import os
import shutil
import tempfile
from typing import List, Dict, Optional
class AufsLayer:
"""模拟aufs的一个分支(层)"""
def __init__(self, name: str, path: str, read_only: bool = True):
self.name = name
self.path = path
self.read_only = read_only
os.makedirs(path, exist_ok=True)
def get_path(self, filepath: str) -> str:
"""获取文件在该层的完整路径"""
return os.path.join(self.path, filepath.lstrip('/'))
def contains(self, filepath: str) -> bool:
"""检查该层是否包含指定文件"""
full_path = self.get_path(filepath)
# 检查白出文件(.wh.前缀)
wh_path = self.get_path(os.path.join(
os.path.dirname(filepath),
f".wh.{os.path.basename(filepath)}"
))
return os.path.exists(full_path) and not os.path.exists(wh_path)
class AufsFileSystem:
"""模拟aufs的联合挂载文件系统"""
def __init__(self, branches: List[AufsLayer]):
self.branches = branches
# 第一个可写层是上层
self.upper = branches[0]
def lookup(self, filepath: str) -> Optional[str]:
"""
查找文件:从最上层开始,逐层向下
这就是aufs的性能瓶颈:层数越多,查找越慢
"""
for branch in self.branches:
if branch.contains(filepath):
return branch.get_path(filepath)
return None
def write(self, filepath: str, content: bytes):
"""写入文件:写时复制(Copy-on-Write)"""
if self.upper.read_only:
raise PermissionError("上层目录是只读的")
target_path = self.upper.get_path(filepath)
os.makedirs(os.path.dirname(target_path), exist_ok=True)
# 如果文件在lower层存在,需要先复制到upper层
lower_file = self.lookup(filepath)
if lower_file and lower_file != target_path:
shutil.copy2(lower_file, target_path)
print(f"[COPY-UP] {filepath} 从lower层复制到upper层")
# 写入新内容
with open(target_path, 'wb') as f:
f.write(content)
print(f"[WRITE] {filepath} 已写入到 {self.upper.name} 层")
def delete(self, filepath: str):
"""删除文件:创建白出文件"""
if self.upper.read_only:
raise PermissionError("上层目录是只读的")
# 在upper层创建白出文件
wh_path = self.upper.get_path(os.path.join(
os.path.dirname(filepath),
f".wh.{os.path.basename(filepath)}"
))
os.makedirs(os.path.dirname(wh_path), exist_ok=True)
open(wh_path, 'w').close()
print(f"[WHITEOUT] 在 {self.upper.name} 层创建白出文件 {wh_path}")
def read(self, filepath: str) -> Optional[bytes]:
"""读取文件"""
real_path = self.lookup(filepath)
if real_path:
with open(real_path, 'rb') as f:
return f.read()
return None
# 演示aufs的工作机制
if __name__ == "__main__":
# 创建临时目录
base = tempfile.mkdtemp(prefix="aufs_demo_")
try:
# 构建三层:lower2(基础镜像)、lower1(应用镜像)、upper(容器层)
lower2_path = os.path.join(base, "lower2")
lower1_path = os.path.join(base, "lower1")
upper_path = os.path.join(base, "upper")
# 模拟镜像层
lower2 = AufsLayer("基础镜像", lower2_path, read_only=True)
lower1 = AufsLayer("应用镜像", lower1_path, read_only=True)
upper = AufsLayer("容器层", upper_path, read_only=False)
# 初始化镜像内容
with open(lower2.get_path("etc/os-release"), 'w') as f:
f.write("Ubuntu 22.04")
with open(lower1.get_path("etc/app.conf"), 'w') as f:
f.write("version=1.0")
# 创建文件系统
fs = AufsFileSystem([upper, lower1, lower2])
print("=== 演示文件查找 ===")
print(f"读取 os-release: {fs.read('etc/os-release')}")
print(f"读取 app.conf: {fs.read('etc/app.conf')}")
print("\n=== 演示写时复制 ===")
fs.write("etc/app.conf", b"version=2.0")
print(f"修改后读取: {fs.read('etc/app.conf')}")
print("\n=== 演示文件删除 ===")
fs.delete("etc/os-release")
print(f"删除后读取: {fs.read('etc/os-release')}")
print("\n=== 查看upper层的实际内容 ===")
for root, dirs, files in os.walk(upper_path):
for f in files:
full_path = os.path.join(root, f)
rel_path = os.path.relpath(full_path, upper_path)
print(f" {rel_path}")
finally:
shutil.rmtree(base)示例3:Go实现的devicemapper精简配置原型
//go:build linux
// +build linux
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"syscall"
"unsafe"
)
/*
模拟devicemapper的thin-provisioning机制
演示如何创建精简池和快照设备
*/
// DeviceMapper 模拟设备映射器
type DeviceMapper struct {
poolName string
dataDevice string
metadataDev string
snapshots map[string]*Snapshot
}
// Snapshot 表示一个快照设备
type Snapshot struct {
ID int
Name string
Pool string
Size int64
ParentID int
}
// NewDeviceMapper 创建新的设备映射器
func NewDeviceMapper(dataDev, metadataDev string) *DeviceMapper {
return &DeviceMapper{
poolName: "docker-pool",
dataDevice: dataDev,
metadataDev: metadataDev,
snapshots: make(map[string]*Snapshot),
}
}
// CreateThinPool 创建精简池
// 相当于 devicemapper 的 thin-pool target
func (dm *DeviceMapper) CreateThinPool() error {
// 在真实场景中,这里会调用 dmsetup 创建thin-pool
// 实际命令: dmsetup create docker-pool --table "0 102400 thin-pool metadata data 128 1024"
cmd := exec.Command("dmsetup", "create", dm.poolName, "--table",
fmt.Sprintf("0 102400 thin-pool %s %s 128 1024", dm.metadataDev, dm.dataDevice))
if err := cmd.Run(); err != nil {
return fmt.Errorf("创建精简池失败: %w", err)
}
fmt.Printf("✅ 创建精简池 %s 成功\n", dm.poolName)
return nil
}
// CreateSnapshot 创建快照
// parentID=0 表示基础镜像,否则是基于父快照的容器层
func (dm *DeviceMapper) CreateSnapshot(name string, parentID int, size int64) error {
// 分配新的设备ID
newID := len(dm.snapshots) + 1
snap := &Snapshot{
ID: newID,
Name: name,
Pool: dm.poolName,
Size: size,
ParentID: parentID,
}
// 创建thin设备
// 实际命令: dmsetup message docker-pool 0 "create_thin newID"
cmd := exec.Command("dmsetup", "message", dm.poolName, "0",
fmt.Sprintf("create_thin %d", newID))
if err := cmd.Run(); err != nil {
return fmt.Errorf("创建thin设备失败: %w", err)
}
// 如果是快照,需要激活并挂载
if parentID > 0 {
// 实际命令: dmsetup create snap --table "0 size thin pool parentID"
table := fmt.Sprintf("0 %d thin %s %d", size, dm.poolName, newID)
cmd = exec.Command("dmsetup", "create", name, "--table", table)
if err := cmd.Run(); err != nil {
return fmt.Errorf("激活快照失败: %w", err)
}
}
dm.snapshots[name] = snap
fmt.Printf("✅ 创建快照 %s (ID=%d, 父ID=%d)\n", name, newID, parentID)
return nil
}
// WriteData 模拟写入数据
// 展示写时复制和空间分配
func (dm *DeviceMapper) WriteData(snapName string, offset int64, data []byte) error {
snap, ok := dm.snapshots[snapName]
if !ok {
return fmt.Errorf("快照 %s 不存在", snapName)
}
// 检查是否超出容量
if offset+int64(len(data)) > snap.Size {
return fmt.Errorf("写入超出设备容量")
}
// 在真实场景中,这里会通过设备节点写入
// 由于是演示,我们直接模拟分配
blocksNeeded := (len(data) + 4095) / 4096 // 按4KB块计算
fmt.Printf("📝 写入 %d 字节到 %s,需要 %d 个块\n",
len(data), snapName, blocksNeeded)
// 模拟写时复制:如果块在父快照中存在,需要先复制
if snap.ParentID > 0 {
fmt.Printf("🔄 触发Copy-on-Write,从父快照复制数据\n")
}
return nil
}
// RemoveSnapshot 删除快照
func (dm *DeviceMapper) RemoveSnapshot(name string) error {
snap, ok := dm.snapshots[name]
if !ok {
return fmt.Errorf("快照 %s 不存在", name)
}
// 实际命令: dmsetup remove name
cmd := exec.Command("dmsetup", "remove", name)
if err := cmd.Run(); err != nil {
return fmt.Errorf("删除快照失败: %w", err)
}
// 释放thin设备
cmd = exec.Command("dmsetup", "message", dm.poolName, "0",
fmt.Sprintf("delete_thin %d", snap.ID))
if err := cmd.Run(); err != nil {
return fmt.Errorf("释放thin设备失败: %w", err)
}
delete(dm.snapshots, name)
fmt.Printf("🗑️ 删除快照 %s (ID=%d)\n", name, snap.ID)
return nil
}
func main() {
fmt.Println("=== devicemapper 精简配置演示 ===\n")
// 检查是否有dmsetup工具
if _, err := exec.LookPath("dmsetup"); err != nil {
fmt.Println("⚠️ 未找到dmsetup工具,使用模拟模式")
dm := &DeviceMapper{
poolName: "docker-pool",
dataDevice: "/dev/loop0",
metadataDev: "/dev/loop1",
snapshots: make(map[string]*Snapshot),
}
// 模拟创建基础镜像
dm.snapshots["base"] = &Snapshot{ID: 1, Name: "base", Pool: "docker-pool", Size: 1024 * 1024}
dm.snapshots["container1"] = &Snapshot{ID: 2, Name: "container1", Pool: "docker-pool", Size: 1024 * 1024, ParentID: 1}
// 模拟写入
dm.WriteData("container1", 0, []byte("Hello Docker"))
return
}
// 真实模式
dm := NewDeviceMapper("/dev/loop0", "/dev/loop1")
// 创建精简池
if err := dm.CreateThinPool(); err != nil {
fmt.Printf("创建失败: %v\n", err)
return
}
// 创建基础镜像(相当于docker pull)
if err := dm.CreateSnapshot("base", 0, 10*1024*1024); err != nil {
fmt.Printf("创建基础镜像失败: %v\n", err)
return
}
// 创建容器层(相当于docker run)
if err := dm.CreateSnapshot("container1", 1, 100*1024*1024); err != nil {
fmt.Printf("创建容器失败: %v\n", err)
return
}
// 写入数据
dm.WriteData("container1", 0, []byte("Hello Docker Storage"))
// 清理
dm.RemoveSnapshot("container1")
dm.RemoveSnapshot("base")
}方案对比:三足鼎立的存储驱动
性能对比
| 维度 | overlay2 | aufs | devicemapper |
|------|----------|------|--------------|
| 读性能 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ |
| 写性能 | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐ |
| 内存效率 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐ |
| 空间效率 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ |
| 稳定性 | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ |
选型建议
- 生产环境(Linux 4.0+):首选 overlay2,这是目前最成熟的方案
- 旧内核(< 4.0):只能选 aufs 或 devicemapper
- 需要块级配额:devicemapper 的 dm.thinpool 支持配额,但性能代价大
- K8s 环境:优先 overlay2,其次是 containerd 自带的 native snapshotter
最佳实践与避坑指南
最佳实践
- 使用 xfs 作为底层文件系统,并开启
pquota挂载选项:
mkfs.xfs -f /dev/sdb
mount -o pquota /dev/sdb /var/lib/docker- 定期清理无用镜像和容器:
# 使用docker system prune
docker system prune -a --volumes
# 或者手动清理overlay2目录
du -sh /var/lib/docker/overlay2/*- 监控存储使用情况:
# 查看每个容器的磁盘占用
docker system df -v
# 深入查看overlay2目录
find /var/lib/docker/overlay2 -type f -size +100M -exec ls -lh {} \;常见坑
坑1:inode耗尽
overlay2 的 lowerdir 层共享 inode,但 upperdir 会创建大量新 inode。当容器频繁创建小文件时,可能耗尽磁盘 inode。
解决方案:
# 查看inode使用情况
df -i
# 使用更大的inode比例重新格式化
mkfs.xfs -f -i maxpct=10 /dev/sdb坑2:页缓存脏数据
overlay2 的页缓存共享可能导致内存压力。大量容器同时写入时,脏页比例过高会触发强制回写。
解决方案:调整内核参数
# /etc/sysctl.conf
vm.dirty_ratio = 20
vm.dirty_background_ratio = 5坑3:devicemapper的loopback陷阱
千万不要在生产环境使用 loopback 设备作为 devicemapper 的后端。性能至少下降10倍。
解决方案:使用真实LVM卷
# 创建物理卷和卷组
pvcreate /dev/sdb /dev/sdc
vgcreate docker /dev/sdb /dev/sdc
# 配置Docker使用LVM后端
# /etc/docker/daemon.json
{
"storage-driver": "devicemapper",
"storage-opts": [
"dm.thinpooldev=/dev/mapper/docker-thinpool",
"dm.use_deferred_removal=true"
]
}总结
存储驱动是Docker的基石之一,选对驱动能让你的容器性能提升数倍,选错则可能带来灾难性的后果。回顾今天的核心要点:
- overlay2 是目前的首选方案,它利用内核的OverlayFS实现了高效的页缓存共享和写时复制
- aufs 虽然功能丰富,但性能瓶颈和内核支持问题使其逐渐被淘汰
- devicemapper 的块级隔离特性虽好,但性能和空间效率的劣势让它只适合特定场景
延伸思考:随着 containerd 的普及,native snapshotter 和 stargz 等新技术正在改变容器的存储方式。特别是 stargz(可延迟拉取的镜像格式),它让容器启动时间从秒级降到毫秒级。存储驱动的演进,远未结束。
最后,记住这个简单的判断标准:如果你的内核支持 overlay2,就别犹豫。