
1. 容器里的文件到底存在哪镜像、读写层、数据卷的“三角关系”聊到 Docker 存储驱动和数据卷很多人第一反应就是“挂个 -v 完事”。可一旦上了生产环境持久化和性能优化马上就会教你怎么重新做人。我见过不少人业务跑得好好的突然磁盘满了或者改了配置重启容器发现数据全没了还有数据库容器明明在跑却慢得离谱。这些问题十有八九都出在没搞懂容器存储模型。1.1 镜像层和读写层叠加文件系统到底怎么工作Docker 镜像不是一个大文件而是一堆只读层的叠加。你可以把每一层理解成“上一次命令改动产生的差量”。docker build里每条能产生文件系统变化的指令都会形成一层。容器启动时Docker 在这些只读层上面再加一个可写层所有写入先落在这一层。这个机制的核心叫写时复制copy-on-write。听起来很高端其实很好理解你不动文件就完全复用下层数据一旦要修改某个文件Docker 会先把下层那个文件完整拷贝到可写层再在拷贝上做修改。问题就在这里——一个几百 GB 的数据库文件你可能只改了其中一个页第一次写入也会触发整个文件的复制。虽然现代 overlay2 对很多场景做了优化但大文件、乱序写的放大效应依然存在。这就是为什么我反复强调容器可写层只适合放临时文件、程序运行时的 pid 文件这类东西。任何有状态的数据都不应该长在可写层里。1.2 数据卷为什么天生绕开存储驱动数据卷volume不是普通目录它是在宿主机/var/lib/docker/volumes/下面独立管理的目录然后直接挂载进容器。挂载之后容器里那个路径上的 I/O 就不再经过 overlay2而是直通宿主机文件系统。换句话说存储驱动的 copy-on-write 那一套对你挂载的数据卷完全不起作用。你写在卷里的东西本质上就是宿主机上的普通文件被容器以目录形式看到了而已。这也是卷在性能上经常吊打容器可写层的原因少了一层复制和叠加文件系统语义更干净O_DIRECT、mmap、fallocate 这类操作也能原样传到底层。别小看这一点。数据库引擎对文件系统有很强的依赖比如 MySQL 的 InnoDB 要用 O_DIRECTPostgreSQL 依赖 fsync如果你把这些文件放在容器层等于让数据库和一个实现复杂、语义有损耗的文件系统叠加层打交道性能不稳定是必然的。1.3 最常见的坑把数据库、日志、缓存全写进容器层我接手过不少“容器一删服务恢复不了”的现场。排查到最后发现应用数据全在容器可写层里。有人用docker commit把容器打包成镜像试图“持久化”结果镜像越打越肥动辄几十 GB而且层里塞满了日志和临时文件真正常用的代码反而被掩盖。容器层丢失数据的情形有两个一是容器被docker rm删除可写层直接销毁二是执行docker system prune -a所有停止容器的可写层都可能被清理。想要数据不丢唯一可靠的手段就是把数据放到命名卷或者宿主机挂载目录中和容器生命周期彻底解耦。一个判断标准很简单如果这台容器坏了你能否只用一个镜像加一份数据目录就能在五分钟内重新拉起一个一模一样的环境如果你的答案是需要“进容器里拷文件”那你的持久化设计就是不合格的。2. 存储驱动选型overlay2、fuse-overlayfs、btrfs/zfs 的取舍2.1 overlay2 为什么是生产默认目前绝大多数 Linux 发行版上Docker 默认存储驱动就是 overlay2。它基于内核的 overlayfs 实现在 ext4、xfs 文件系统上都能跑性能和稳定性经过了大规模验证。除非你有特殊需求否则没必要折腾其他驱动。但 overlay2 有一个硬性前提底层文件系统必须支持 d_type。简单说就是文件系统要能返回目录项类型。 ext4 原生支持xfs 则需要开启 ftype1。有些老的 xfs 分区在格式化时用了 ftype0你把 Docker 的>df -T /var/lib/docker xfs_info /var/lib/docker 2/dev/null | grep ftype如果是 xfs 且 ftype0最好的办法是重新格式化分区而不是硬换存储驱动。这属于“建站容易拆站难”的问题等把数据写进去了再迁移成本高很多。2.2 rootless 模式、Docker Desktop 和 fuse-overlayfs 的现实这几年 rootless Docker 越来越流行但在 rootless 模式下用户没有权限直接挂载 overlay2Docker 会自动退到 fuse-overlayfs。fuse 走用户态读改写会经过 FUSE 桥梁开销比内核态 overlayfs 明显。如果你在 rootless 里跑大量小文件读写会明显感觉比 rootful 慢。Docker Desktop 在 Mac 和 Windows 上又是另一回事。容器其实是跑在虚拟机里的 Linux 环境虚拟机内部用 overlay2 没问题但你把宿主机目录 bind mount 进容器时I/O 要穿过虚拟机文件共享层比如老的 osxfs、后来的 gRPC-FUSE、以及新版 virtiofs。这一层才是 Docker Desktop 上 bind mount 慢的根源而不是“存储驱动不行”。如果你的数据对性能敏感优先用命名卷让它待在虚拟机磁盘里别直接 bind mount 宿主机目录。2.3 怎么确认当前驱动改错了会怎样一条命令就能看docker info --format {{.Driver}} / {{.DockerRootDir}}输出的第一部分就是当前存储驱动第二部分是 Docker 数据目录。要改驱动在/etc/docker/daemon.json里写{ storage-driver: overlay2, data-root: /data/docker }然后重启systemctl restart docker。但我要泼一盆冷水千万不要在生产上直接改存储驱动尤其是已有镜像和容器的时候。不同驱动底下的镜像层格式不同overlay2 的目录结构换成 btrfs 驱动后Docker 会认为本地的镜像/容器元数据对不上。轻则重新拉镜像重则容器无法启动。真要做这种底层迁移请先把数据卷全部备份然后把这台机器当新机器处理。提示改>docker volume create nginx-html docker run --rm -v nginx-html:/usr/share/nginx/html nginx:alpine这时候进容器看/usr/share/nginx/html你会发现里面居然有 nginx 默认的index.html。这就是因为 Docker 检测到卷是空的会把镜像该目录中的内容复制到卷里。这个过程发生在容器创建阶段只针对空卷。bind mount 没有这个行为。你把宿主机空目录挂到/usr/share/nginx/html看到的就真的是空目录。这个差异常被用来做“数据卷预置”但也会带来权限坑。比如镜像里的目录属主是 uid 1000卷复制过去之后属主跟着变。如果你用 root 用户启动容器一切正常一旦容器换成非 root 用户运行就可能直接permission denied。所以排查权限问题时要先想一步这个卷里的内容到底是谁复制出来的3.3 卷的备份、迁移、权限处理备份卷我推荐一个非常朴素但可靠的方法起一个临时容器把卷挂进去用 tar 打包再把包写到宿主机目录。docker run --rm \ -v myapp-data:/data \ -v /backup:/backup \ alpine tar czf /backup/myapp-data-$(date %F).tar.gz --numeric-owner -C /data .恢复时再用-v newvolume:/data解包docker run --rm \ -v myapp-data-new:/data \ -v /backup:/backup \ alpine tar xzf /backup/myapp-data.tar.gz -C /data--numeric-owner一定要加。因为用户 ID 不跨机器保证一致不加数字属主恢复后很容易出现混乱的 uid/gid。进程如果必须以 uid 1000 运行而卷恢复成了 root 属主应用连写文件都会失败。还有个大坑不要在容器运行的时候直接去/var/lib/docker/volumes/xxx/_data里 copy。一来 Docker 的目录结构是内部实现随时可能变二来你 copy 的时候可能正好赶上应用在写抓到的是一份不一致的数据。要备份就通过容器挂载让文件系统状态至少是完整的。数据库类应用最好先停写或者用工具做在线备份别拿 cp 当备份方案。3.4 tmpfs 的用法和误区tmpfs 最常见的误用是拿来“代替卷做持久化”。它写的是内存重启宿主机就没了docker 容器重启也默认清空除非指定参数。正确的用法是放那些“活着的时候有用、死了无所谓”的东西tmpfs: - /run:size64m,mode1777 - /tmp:size512m,noexec,nosuid上面这个配置适合对安全要求比较高的服务/tmp不能执行文件避免某些上传目录变成 webshell 的跳板/run限定大小防止进程没完没了写 socket 或 pid 文件把内存打爆。tmpfs 性能确实猛因为它本质就是内存盘。但它不是免费的默认占内存在内存紧张时还会走 swap。生产上给 tmpfs 一定要设 size别让它无限涨。我见过一个应用把日志路径配错到/tmp一个高峰期把宿主机内存全部写满最后 OOM 到宿主机都卡了。4. 性能实测同样一份读写放在不同存储位置差了多少4.1 为什么用 fio 而不是 dd有人喜欢用dd测速结果只能看到顺序大文件读写的快乐数字一到真实业务就现原形。数据库、消息队列、代码仓库这些负载绝大多数是随机小 IO尤其是 4K/8K 的随机读写。测这类负载fio 是更靠谱的工具。你可以在容器里临时装 fio分别把测试目录指向容器可写层、命名卷、bind mount 和 tmpfsdocker run --rm -it --entrypoint sh alpine apk add --no-cache fio然后在容器内分别跑fio --namevolatile-test --rwrandwrite --bs4k --ioenginelibaio --direct1 \ --size1G --numjobs4 --runtime30 --time_based --group_reporting测试时把--directory指到不同的挂载路径。如果你用direct1能绕开页缓存测出真实设备水平如果再跑一组不使用direct的对比出来的差值就是页缓存和叠加层的开销。4.2 overlay2 的 copy-up 和页缓存开销我实测中随机小文件写在容器可写层上经常比命名卷慢 20% 到 50%具体数字取决于文件大小和并发。原因主要有两个第一是 copy-up。修改下层文件时overlay2 要先把文件复制到 upperdir。如果你有很多小文件这个复制开销会成倍放大。比如一个日志目录里有十万个小文件每次追加也只是改其中几个可你在容器层操作时每个文件第一次写都要先整体复制IO 放大了好几倍。第二是页缓存路径更绕。叠加文件系统多了一层查找、映射的逻辑在密集读写时更容易产生不必要的锁竞争。如果你用 O_DIRECT某些老内核上的 overlayfs 还会出现性能回退。所以不仅是“更慢”而且是“不稳定地慢”。命名卷因为是直通宿主机的目录写进去就走底层文件系统省掉了叠加层那一堆操作。 bind mount 同理。它们之间的 IOPS 差异一般不大主要差别来自宿主机目录本身所在的磁盘和文件系统。4.3 tmpfs 的甜点区间tmpfs 的随机写延迟比任何磁盘都低一个到两个数量级。如果你需要高频读写一批临时文件比如 Kafka 的 spill、计算引擎的 shuffle 中间文件、Git 仓库的临时对象tmpfs 几乎是作弊一般的存在。代价就是内存。比如你给/tmp挂 2G tmpfs进程往里写了 1.5G这 1.5G 就蒸发了内存压力一上来宿主机其他服务也会受影响。所以对 tmpfs我总是建议把“最大容量”和“监控告警”一起配好。另一个容易忽略的点是tmpfs 默认开了 swap 后内存盘上的页可能会被换出。如果数据真的需要快速访问却被换到磁盘性能反而比普通文件系统更糟。生产环境只要内存有上限、业务有波峰tmpfs 就要慎重使用。4.4 Docker Desktop 的特殊情况在 Mac 和 Windows 上跑 Docker Desktop性能结论和 Linux 有很大不同。你把宿主机的目录 bind mount 进容器所有读写都要经过虚拟机文件共享。实测中这类共享目录的顺序读写还行随机读写和大量小文件操作经常只有 Linux 上的几分之一。所以在 Docker Desktop 上做开发时我的习惯是代码、配置这类低频小文件用 bind mount跑起来之后产生的数据、缓存、日志全部落命名卷让它们留在虚拟机磁盘里。这样能避免很多莫名其妙的“容器特别慢”问题。发布到 Linux 服务器时再按生产标准重新规划存储。记住Docker Desktop 是开发工具不是生产性能基准。5. 持久化的硬骨头数据库类容器怎么设计存储5.1 数据库的数据目录必须上卷但不止上卷这么简单MySQL、PostgreSQL 这类有状态服务数据目录放到 named volume 已经是共识。但很多人忽略了镜像默认的数据目录位置。以 MySQL 为例官方镜像的数据目录是/var/lib/mysqlPostgreSQL 是/var/lib/postgresql/dataRedis 是/data。你得确认最终落盘的路径确实挂上了卷而不是挂了个上层空目录。我见过一个事故Compose 里写的是- mysql-data:/var/lib/mysql但由于镜像内相同路径上本来有初始化数据卷又是空卷Docker 会把镜像里的初始化数据复制到卷里。这本身没错。问题出在后来有人把卷删了又重建重启 MySQL 时它在新卷里重新初始化了一份全新数据库。如果你没有备份业务方会发现“数据变空了”。所以在数据库容器上卷的删除操作要格外谨慎任何docker system prune --volumes都不能在生产环境瞎跑。数据库文件一旦放到卷里就要考虑底层支持。MySQL 可以启动时加参数让落盘方式更适配宿主文件系统services: mysql: image: mysql:8.0 command: - --innodb-flush-methodO_DIRECT volumes: - mysql-data:/var/lib/mysqlO_DIRECT让 InnoDB 绕过页缓存直接写磁盘适合底层是 SSD 或普通磁盘的环境。但注意如果卷落在某些特殊文件系统上比如网络存储O_DIRECT 不一定能正常工作。这又回到了“先确认底层再调参数”的老话。5.2 Redis 的持久化机制放到 Docker 里第一步是改 dirRedis 的持久化机制大家很熟RDB 快照和 AOF 日志。落到 Docker 里最容易犯的错是把dir留在镜像默认路径上。官方镜像里的 Redis 默认dir /data如果你不挂卷AOF 和 RDB 都写在容器可写层容器一删持久化文件全部蒸发。我给的配置习惯是AOF 开启appendonly yesdir /data并挂一个命名卷到/dataRDB 触发频率按业务调不要把 save 全关掉只靠 AOFAOF rewrite 期间如果磁盘空间吃紧会出现短暂的双倍空间占用监控要跟上。Redis 的持久化文件属于典型的“写少读更少、但绝不能丢”的数据放在命名卷里最合适。不要因为 Redis 有持久化就把它放 tmpfs除非你明确知道这套 Redis 只是缓存重启丢数据无所谓。5.3 SQLite 和嵌入式数据库的卷权限坑SQLite 在容器里也很常见。它的问题在于整个数据库就是几个文件WAL 模式下还会产生-wal和-shm文件。如果卷的属主和容器内运行用户不一致SQLite 可能在第一次写 WAL 时直接报attempt to write a readonly database或者各种 permission denied。排查这类问题时先看容器以什么用户运行再看卷的目录权限docker inspect --format {{.Config.User}} myapp docker run --rm -v mydata:/data alpine ls -ln /data如果容器用 uid 1000卷目录就应该是 uid 1000 所有。修改属主时要注意别在业务运行中直接对百万文件的目录执行chown -R否则可能长时间锁住文件系统。更好的做法是提前在初始化容器里用chown固定一次数据量特别大时可以接受几分钟不可用但一定要在低峰期做。5.4 断电和宿主机重启后的恢复思路宿主机突然断电overlay2 的可写层有一定概率损坏。表现为容器启动不了日志里一堆文件系统错误。很多人的第一反应是重建容器结果以为数据丢了急得团团转。我的恢复顺序是先不碰任何卷先把 Docker 服务停掉对底层文件系统做一次 fsck修复文件系统层面的错误重新启动 Docker看哪些容器能正常起哪些起不来起不来的容器用docker ps -a确认容器 ID如果容器本身损坏直接用同一个镜像和同一个卷重建一个容器数据卷单独挂载到临时容器里验证内容是否完整确认没问题再让业务接入。这套流程救过我好几次。卷里的数据只要底层文件系统没问题基本都能找回。容器层坏了问题不大镜像还在重新跑一个容器就是。如果你发现卷里的数据也不完整那就要考虑 NFS 这类网络存储上的文件锁问题或者底层磁盘已经损坏这时候只能靠备份恢复了。6. 生产环境最终配置按场景给一套能落地的存储方案6.1 daemon.json 里值得改的存储相关参数很多人的 daemon.json 常年是空的。实际上有几个参数对存储和稳定性影响很大{ storage-driver: overlay2, data-root: /data/docker, log-driver: json-file, log-opts: { max-size: 20m, max-file: 5 } }log-opts看似和存储无关但它直接影响磁盘占用的生死。默认情况下容器日志可以无限增长一个写日志特别猛的服务几天就能把整个 Docker 数据分区塞满。设置max-size和max-file是最便宜的磁盘保护。真正的业务日志应当通过 log driver 送到日志采集系统不要指望容器里无限写文件。>services: mysql: image: mysql:8.0 command: - --innodb-flush-methodO_DIRECT - --innodb-buffer-pool-size1G volumes: - mysql-data:/var/lib/mysql - ./backup:/backup tmpfs: - /tmp:size256m,mode1777 logging: driver: json-file options: max-size: 10m max-file: 3 app: image: myapp:latest volumes: - app-data:/app/data - app-logs:/var/log/myapp tmpfs: - /run:size64m,mode1777 logging: driver: json-file options: max-size: 20m max-file: 5 volumes: mysql-data: app-data: app-logs:这个结构里数据库数据、应用业务数据、日志数据都分别独立成卷互不干扰。备份时你可以分别挂载它们按不同策略处理。/tmp和/run这些临时目录用 tmpfs既不落盘又避免容器层膨胀。生产上我还会加一个定时任务周期性地把app-logs卷打包归档而不是让日志文件无限躺在卷里增长。卷不是垃圾桶它也会有满的一天。6.3 最后的运维习惯清单这些习惯都是我拿事故换来的值得刻在工位上用docker system df定期看空间占用别等磁盘 100% 才处理不要在没备份的情况下执行docker system prune -a --volumes这条命令会删掉所有未使用的卷volume 和 bind mount 不要混着乱挂同一套环境尽量统一改存储驱动、改>