凌晨两点被磁盘告警叫醒登录机器一跑df -h根分区 98%而业务容器的写入量其实很小——这是我这些年遇到过不止一次的场面。容器存储问题之所以让人头疼是因为它不像 CPU、内存那样有直观的监控曲线磁盘是被一点点、一层层、一个日志文件一个日志文件地吃掉的等你发现的时候往往已经到了要停机抢救的地步。这篇内容就想把 Docker 容器存储这件事从头到尾捋一遍镜像分层和可写层到底怎么占空间、volume 和 bind mount 该怎么选、磁盘告警时按什么顺序往下查、日志这个隐形黑洞怎么堵、数据库这类有状态服务放在容器里怎么存才靠谱。不管你是刚把第一个容器跑起来的新手还是已经在管几十个节点老手下面这些排查链路和配置细节应该都能直接用上。1. 镜像分层与可写层空间到底被谁占了很多人对容器存储的误解是从“容器就是一个轻量虚拟机”开始的。实际上容器运行时的文件系统是一层套一层的联合挂载理解了这个结构后面所有的空间异常、数据丢失、性能抖动才有解释的落脚点。我第一次被问到“为什么删了容器磁盘没变小”的时候就是因为没把这套分层模型讲清楚。1.1 OverlayFS 下的一次写入究竟发生了什么以目前主流 Linux 发行版默认的 overlay2 存储驱动为例一个运行中的容器它的文件系统视图是由若干层叠加出来的最下面是只读的镜像层每一层对应 Dockerfile 里的一条指令比如RUN apt install、COPY这些最上面是一层可写的容器层也叫 upperdir。容器里读取一个文件时如果文件在下面某个只读层里就直接读那一层一旦要修改overlay 会把整个文件先复制到可写层然后在可写层里改。这个动作叫写时复制Copy-on-WriteCoW。它带来的第一个反直觉后果是修改一个 500MB 的文件里的一个字节可写层会多出 500MB 的占用而不是 1 字节。第二个后果是容器里删除一个来自镜像层的文件overlay 并不会真的删掉它而是在可写层记一条 whiteout 标记原来的数据依然躺在只读层里占着磁盘。我用一个具体例子说明这个差异有多大。假设有个镜像里带了一个 1.2GB 的数据文件容器启动后业务需要把它解压覆盖成新版本# 容器内执行 ls -lh /data/payload.tar.gz # 1.2G tar -xzf /data/payload.tar.gz -C /data/ # 解压出 3G 内容 rm /data/payload.tar.gz跑完之后docker ps -s里的SIZE列可能会显示 4G 以上因为解压出的 3G 全在可写层而那个被rm掉的 1.2G 压缩包在只读层里依然存在。这就是为什么我一直建议凡是运行时会大量写入的目录一律挂载出去不要留在可写层。可写层是用来放运行时产生的少量临时状态的不是用来放业务数据的。1.2 容器删了空间却没回来问题出在哪第二类高频疑问是容器都docker rm了df显示的空间怎么没降下来。这种情况我一般按三个方向查。第一种是悬空镜像。每次重新构建镜像旧的镜像层就变成了none:none的悬空镜像它们不会再被任何容器引用但仍然占着磁盘。docker image prune能清掉一部分但如果一个镜像的中间层被其他镜像共享了就得等所有引用它的镜像都被清掉才会真正释放。第二种是命名卷named volume。这是最容易被漏掉的因为docker rm删容器时默认不会删卷。数据库容器删了重建旧的卷还静静躺在/var/lib/docker/volumes/下面几十个 G 的数据一点没动。第三种比较隐蔽是被删除但仍然被进程占用的文件。文件在ls里看不见了但某个进程还持有它的文件描述符内核就不会回收这块空间。这种用du是查不出来的得这样找# 找出已被删除但仍被占用的文件按大小排序 lsof L1 2/dev/null | awk {print $7, $2, $1, $9} | sort -rn | head -20我遇到过一次某个容器里的应用把日志文件rm掉了没重启进程文件描述符一直挂着40 多 G 空间凭空消失du -sh /var/lib/docker怎么算都对不上。这种坑靠docker system df是完全看不出来的只能从宿主机层面查。2. volume、bind mount、tmpfs三种挂法怎么选搞清楚可写层的代价之后接下来的问题就是数据该往哪儿放。Docker 给了三种挂载方式很多人是靠习惯选的或者干脆照抄别人的 compose 文件。我见过把数据库数据目录 bind mount 到宿主机家目录的也见过把配置文件塞进 volume 里然后每次改配置都要进容器 vim 的。选型的逻辑其实很清晰只是很少有人讲透。2.1 数据卷的生命周期与命名陷阱volume数据卷是 Docker 自己管理的存储落在宿主机/var/lib/docker/volumes/下由 Docker 负责创建、命名、生命周期。它最大的优势是跟宿主机路径解耦——你不用关心数据具体存在哪个盘、哪个目录迁移和备份都通过 Docker 的接口来做。# 显式创建并挂载推荐显式命名而不是让 Docker 随机生成 docker volume create app-data docker run -d \ --name app \ -v app-data:/var/lib/app/data \ nginx:alpine这里有个关键区别-v app-data:/path和-v /path只写容器内路径是两回事。后者 Docker 会自动创建一个匿名卷名字是一串随机 hash。匿名卷的问题在于你很难记住哪个 hash 对应哪个容器删容器的时候也容易忘记加-v参数结果就是一堆孤儿卷。我自己管理的时候有个硬规矩除了docker run --rm这种临时容器一律显式命名卷禁止匿名卷。还有一个容易被忽视的点是卷的初始化行为。如果挂载的目标路径在镜像里本来有内容第一次挂载一个空卷时Docker 会把镜像里该路径下的内容复制到卷里。这个行为对“预置默认配置”很方便但如果你挂的是一个已经有数据的卷镜像里的内容就会被卷里的内容完全覆盖掉。我曾经因为改了镜像里的默认配置文件却没生效排查了半天原因就是那个路径挂着旧卷卷里的老配置把新的盖住了。2.2 bind mount 的权限与路径错位bind mount绑定挂载是把宿主机上的一个具体路径直接映射进容器。它的优势是直观——你在宿主机上ls就能看到文件改配置不用进容器日志直接落到宿主机目录备份脚本用 rsync 就搞定。开发环境里我基本都用 bind mount因为要频繁改代码改配置。docker run -d \ -v /opt/app/config:/etc/app:ro \ -v /opt/app/logs:/var/log/app \ myapp:latest注意上面那个:ro只读挂载。配置文件这种容器不该改的东西我建议一律挂只读。多容器共享同一份配置时一个容器误写影响到所有实例这种事故我见过。bind mount 的坑集中在权限和路径两点上。权限方面容器内进程的 UID 和宿主机上的 UID 是同一套体系。如果容器里跑的是 UID 1000 的用户往 bind mount 的目录写文件宿主机上生成的文件属主就是 1000。反过来如果宿主机目录权限是750且属主是 root容器里的非 root 用户就写不进去报Permission denied。这个问题在挂载宿主机/etc/或者家目录时特别常见。路径方面最常见的错误是宿主机路径写错。-v有个很坑的行为如果宿主机上指定的路径不存在Docker 会自动创建一个目录。所以你不小心把/opt/app/config.yml写成了/opt/app/confg.ymlDocker 不会报错而是默默建一个叫confg.yml的空目录挂进去然后容器启动失败或者行为异常日志里一堆找不着文件的报错。这个坑我踩过不止一次现在的习惯是bind mount 的宿主机路径在启动前一定先ls确认存在或者干脆改用--mount语法它的行为更严格# --mount 语法下宿主机路径不存在会直接报错不会自动创建 docker run -d \ --mount typebind,source/opt/app/config,target/etc/app,readonly \ myapp:latesttmpfs是第三种数据只存在内存里容器停止就没了。它的使用场景很窄但很关键一些高频读写的临时文件、需要避免落盘的敏感中间数据、或者对写入延迟极敏感的场景。挂 tmpfs 的时候记得用--tmpfs /path:size256m限制大小不限制的话它可能把宿主机内存吃光进而触发 OOM。3. 磁盘告警时的排查链路从 df 到具体文件真正让人焦虑的不是“存储有理论”而是“现在磁盘 95% 了五分钟内告诉我为什么”。我整理了一套固定的排查顺序从外到内一层层剥绝大多数情况十分钟内能定位到具体原因。3.1 docker system df 只是剥洋葱的第一层第一步永远是这三个命令信息量从粗到细df -h # 哪个分区满了 docker system df # Docker 各项占用总览 docker system df -v # 每个镜像、容器、卷的明细docker system df的输出会把占用分成四类Images、Containers、Local Volumes、Build Cache。哪一类占比异常方向就定了。占用分类异常时的典型原因对应处理Images频繁构建导致悬空镜像堆积docker image pruneContainers容器的可写层写了大量数据检查容器内是否有未挂载的写入目录Local Volumes删容器没删卷孤儿卷累积docker volume pruneBuild Cache构建缓存未清理多阶段构建残留docker builder prune这里必须强调docker system df的一个已知局限它的统计口径不完全准确尤其是对容器可写层的估算。我遇到过一次它显示 Containers 只占 2G实际/var/lib/docker/overlay2目录占了 60G 的情况。原因是统计时某些中间层没算进去。所以当真到了要精确定位的时候别只信它直接看宿主机上的实际目录。3.2 一个真实场景的逐层剥洋葱讲个具体的排查过程这类问题我在几台机器上反复遇到过。现象df -h显示/使用率 91%比前一天涨了 40G同期业务量没有明显变化。第一步看 Docker 总占用docker system df # TYPE TOTAL ACTIVE SIZE RECLAIMABLE # Images 38 12 24.6GB 18.2GB (73%) # Containers 15 11 35.1GB 4.2GB (12%) # Local Volumes 22 9 8.4GB 3.1GB (37%) # Build Cache 210 0 6.8GB 6.8GB数字对不上加起来 75G 左右但实际涨了 40G。这时候不能停在这里继续往宿主机层面查。第二步看 overlay2 目录的实际大小du -sh /var/lib/docker/overlay2 # 62G /var/lib/docker/overlay2 # 找出占用最大的几个子目录 du -sh /var/lib/docker/overlay2/* | sort -rh | head -10输出里某个diff目录特别大把它的名字记下来。第三步反查这个目录属于哪个容器# 容器 ID 前缀在目录名里不一定直接可见用 inspect 反查 docker ps -q | while read id; do dir$(docker inspect -f {{.GraphDriver.Data.UpperDir}} $id) echo $id $dir done拿到目录和容器的对应关系之后再进容器里看具体是哪个路径撑大的docker exec -it 容器名 du -sh /* 2/dev/null | sort -rh | head -10这一步经常能揪出真凶有的是应用往/tmp写了大量没清理的中间文件有的是某个进程 core dump 堆在/var/crash还有一次是容器里的应用把日志同时写到了/app/logs和一个没挂载的临时目录。第四步处理。如果是临时文件直接清如果是业务数据就得改挂载方式重新部署。这一步的关键是别急着删先确认数据是不是还需要再动手。提醒一句docker system prune -a --volumes这个命令能一键清空几乎所有未使用的东西但它会连停止状态的容器、未被引用的卷一起删掉。生产环境执行前一定要看清楚--volumes这个参数我见过有人手滑执行把备份卷一起带走的事故。4. 容器日志最容易被忽视的存储黑洞如果让我在所有容器存储问题里选一个最高频的答案一定是日志。json-file 日志驱动默认无限增长一个高频打日志的服务几天就能把磁盘写满而这件事在业务层面上毫无感知。4.1 json-file 的默认行为为什么危险Docker 默认的日志驱动是json-file每个容器的日志写在/var/lib/docker/containers/容器ID/容器ID-json.log。关键点在默认配置不限制文件大小不限制文件数量。也就是说这个文件会一直长直到磁盘满。一个每秒打印 100 行的服务每行平均 200 字节一天下来的日志量是 100 × 200 × 86400 ≈ 1.7GB。如果日志里带了一些 JSON 结构或者堆栈信息行均 500 字节以上一天就是 4G 多。这个数字在测试环境完全无感上了生产就变成定时炸弹。更麻烦的是这个日志文件在容器里是看不到的——它是 Docker 守护进程在宿主机上写的。你在容器里du -sh怎么查都正常问题完全藏在宿主机侧。这也是为什么我排查磁盘问题时/var/lib/docker/containers是必查目录之一du -sh /var/lib/docker/containers/*/*-json.log | sort -rh | head -104.2 全局兜底加单容器覆盖的两层配置日志限制我是这么配的全局设一个保底值特殊服务单独覆盖。全局配置改/etc/docker/daemon.json{ log-driver: json-file, log-opts: { max-size: 100m, max-file: 3 } }这组配置的意思是单个日志文件到 100MB 就轮转最多保留 3 个文件加上正在写的那个单容器日志上限约 300MB 到 400MB。改完之后要重载配置systemctl reload docker # 部分版本用 restart这里有个极其容易踩的点全局配置只对新建的容器生效已经在跑的容器不会应用新配置。改完 daemon.json 之后旧容器必须删掉重建才会生效。我第一次配这个的时候以为改完就万事大吉结果一周后同一个容器又把盘写满了。对个别日志量特别大的服务在启动时单独覆盖docker run -d \ --log-opt max-size200m \ --log-opt max-file5 \ --name gateway \ gateway:latest用 compose 的话写在服务下面services: gateway: logging: driver: json-file options: max-size: 200m max-file: 5除了限制大小还有两件事值得做。一是把日志打到 stdout 的同时通过挂载目录落一份结构化的日志方便集中采集这样 Docker 的 json-file 只是一个缓冲区不再承担长期存储的职责。二是对于确定不需要保留日志的容器直接用--log-drivernone关掉不过这个要慎用出问题的时候没日志会很难受。还有个清理的小技巧。如果某个容器的日志已经很大了不想重启容器又想立刻腾出空间可以用截断的方式truncate -s 0 /var/lib/docker/containers/容器ID/容器ID-json.log注意是truncate -s 0而不是rmrm掉之后 Docker 会继续往已被删除的 inode 上写空间的释放要等到容器重启反而更麻烦。5. 有状态服务数据库放容器里怎么存把数据库跑在容器里存储这一块是绕不过去的坎。我见过太多“开发环境跑得好好的上生产就出问题”的案例核心都在这几件事上数据目录到底放哪、I/O 性能够不够、备份恢复怎么做。5.1 数据目录的落点与性能考量第一原则数据库的数据目录必须挂出来而且是命名卷或独立磁盘的 bind mount绝不能留在可写层。原因不只是持久化。可写层在 overlay2 下走的是 CoW数据库这种随机读写密集的场景每次修改数据页都要触发文件复制性能损耗会非常明显。而且 CoW 带来的文件碎片化会让fsync的延迟变得不可预测。# 数据卷放在宿主机独立的数据盘上 docker volume create --driver local \ --opt typenone \ --opt device/data/mysql \ --opt obind \ mysql-data上面这段是创建一个绑定到指定宿主机目录的卷好处是卷依然由 Docker 管理生命周期、docker volume ls可见但实际数据落在你指定的独立磁盘上。这个方式我在需要单独挂数据盘的生产环境里用得最多。如果不是数据盘而只是普通分区那至少要保证挂载时加上noatime之类的选项减少不必要的元数据写入。另外注意容器里数据库的innodb_flush_method这类参数在 overlay2 上和在裸盘上的最优值是不一样的如果发现写入延迟异常可以先把数据目录 bind mount 到一个普通 ext4 目录对比一下能快速判断是不是存储层的问题。还有一个时长被忽略的点是容量预留。数据库容器的数据卷不做容量限制磁盘写满的时候数据库会直接崩溃甚至损坏数据文件。我的做法是在宿主机上给数据目录单独分区或者用 volumes 的size选项限制上限让写满的时候只是数据库报错而不是整个系统挂掉。5.2 备份这件事必须在容器视角之外做容器化之后备份这件事反而更容易做错。常见的错误做法是docker exec进容器跑mysqldump把结果输出到容器内的路径然后再docker cp出来。这个流程有两处风险一是备份文件临时落在可写层大库备份可能直接把磁盘撑爆二是docker cp是个同步操作大文件期间容器被锁定或者操作中断备份就是残缺的。更稳的做法是让备份直接写到挂载出来的目录docker exec mysql sh -c \ exec mysqldump --single-transaction -uroot -p$MYSQL_ROOT_PASSWORD appdb \ /backup/appdb-$(date %Y%m%d).sql注意这里用宿主机的重定向把 stdout 直接写到宿主机路径完全绕开了容器内的临时文件。--single-transaction保证 InnoDB 表的一致性快照不至于锁表。数据量大的话文件级的物理备份更合适停写、对数据卷做快照或者tar打包。这时候要注意卷目录必须在容器停止写入的状态下打包热备出来的数据文件很可能不一致。我一般的流程是先docker stop数据库容器再tar打卷目录最后重启。对可用性要求高的场景就用主从从库停机备份。恢复的时候同理把备份文件放回卷目录注意文件属主和权限要和原来一致否则数据库会因为权限问题起不来。这个细节我踩过恢复后Permission denied排查了半小时最后发现是tar解包时的umask让文件属主变成了 root。6. 存储驱动的选择与几个绕不开的细节最后聊点偏底层但确实会影响日常的问题。存储驱动这东西绝大多数时候用默认的就对但知道它什么时候会成为瓶颈能帮你少走很多弯路。6.1 overlay2 之外的选择什么时候才需要考虑overlay2是当前 Linux 上的默认驱动兼容性好、性能在通用场景下足够。它不适合的情况主要有两类。一类是大量小文件的高频写入。overlay2 的 CoW 在文件级别的复制对海量小文件场景每次修改都要走一遍层查找开销累积起来很明显。这种场景下把写入目录挂成 volume 就能绕过——volume 是直接绑定到宿主机路径的不经过 overlay 层性能接近原生。另一类是需要精确控制磁盘配额的场景。overlay2 自身不支持对单个容器设置存储配额如果要给不同租户、不同容器分配确定的容量上限就得换用支持配额的文件系统方案比如在 xfs 上开启 project quota 配合 overlay2或者用 btrfs、zfs 驱动。这个成本不低一般只有在多租户平台这类场景才值得投入。要查当前用的是哪个驱动docker info | grep -i storage driver如果想换驱动注意overlay2和devicemapper之间的数据是不兼容的切换意味着原有的镜像和容器都不能用了。所以这件事要么在新装机器的时候做要么就照旧。千万别在生产机器上随手改我见过因为改错了存储驱动导致所有容器起不来的事故。6.2 几个我踩过之后才记住的细节构建缓存比想象中占地方。开启 BuildKit 之后缓存会存在/var/lib/docker/buildkit下面一个反复构建的项目几周下来能攒出十几个 G。docker builder prune可以清加--keep-storage参数能保留最近的一定量docker builder prune --keep-storage 5GBdocker system prune不加-a不会清理未使用的镜像。它的默认行为只清理停止的容器、悬空镜像、未使用的网络镜像本身要加-a才动。这个区别很多文档里写了但实际用的时候很少注意。卷的删除是不可逆的。docker volume rm之后数据就真没了没有回收站。我的习惯是给重要卷定期做宿主机层面的备份卷目录结构其实很简单就是_data子目录直接tar就行tar -czf /backup/volume-app-data.tar.gz -C /var/lib/docker/volumes/app-data _data磁盘 inode 用尽也会表现为“空间不足”。容器里跑一堆小文件服务的时候可能出现df -h显示还有空间但写文件报No space left on device。这时候要查df -iinode 打满了。overlay2 的每个层都会消耗 inode频繁构建镜像的机器尤其容易撞上。Windows 和 macOS 上的 bind mount 性能差异极大。宿主机文件系统通过虚拟机层映射到容器同步大量小文件的 I/O 能慢十倍以上。本地开发如果觉得装依赖特别慢试试把代码目录挂成 volume 而不是 bind mount或者把依赖目录做成命名卷速度能回来不少。这个跟 Linux 上的直觉完全相反是跨平台开发里必须知道的一条。回到最开始那个凌晨的告警。那次最后定位到的原因是某个容器里跑的任务把中间产物堆在了没有挂载的/tmp加上日志没做限制两个因素叠加两天时间吃掉了 40G。处理完之后我把日志限制加进了基础镜像的默认启动参数压测环境也补了磁盘使用的监控告警。存储这事的教训基本都一样问题不在于你不知道某个命令而在于你没把它变成默认动作。挂载该挂的目录、设置日志上限、定期清理悬空资源这三件事做到位八成的容器存储事故都能提前挡掉。