我要提问
ARTICLE DETAIL

资讯详情

前沿编程新知与开发实战干货的深度解读。

Docker磁盘空间清理、备份恢复与数据目录迁移实战指南

Docker磁盘空间清理、备份恢复与数据目录迁移实战指南 早上九点刚过监控群里弹出一条告警某台业务机的根分区使用率到了 93%。登上去一看/var/lib/docker这一个目录就吃掉了一百二十多个 G而真正跑着的容器加起来不到三个。这不是我第一次碰到这种场面也不会是最后一次。Docker 用久了体积膨胀本质上是个记账问题镜像层、容器可写层、日志文件、构建缓存、命名卷每一笔都有自己的存放位置和生命周期你不管它它就一直在那儿躺着。这篇内容我打算把四件事一次讲透怎么在不误删重要数据的前提下把空间清出来、哪些东西必须先备份再动手、备份怎么恢复回来、Docker 的压缩能力到底压的是什么、在哪些环节用得上、以及把整个数据目录搬到另一块大盘上的完整操作链路。刚接触 Docker 的朋友可以照着命令一步步来已经在一线维护机器的同行也能在里面的排查链路和踩坑记录里找到点参考。1. 先定位再动手那上百个 G 究竟藏在 Docker 的哪个角落清理这件事最容易犯的错是一上来就敲docker system prune -a。命令敲下去确实爽回车之后腾出几十个 G然后第二天发现某个没用latest之外的 tag 推上去的镜像没了某个只跑过一次的临时容器依赖的卷也空了。所以我现在的习惯是任何清理动作之前先花十分钟把占用地图画出来谁大、谁在用、谁可以重建心里有数再动手。1.1 用 du 从文件系统层面圈出体积大户Docker 的所有落盘数据默认都在/var/lib/docker下先在这一层做一次分层统计速度最快、信息也最直观du -h --max-depth1 /var/lib/docker | sort -hr | head -20这条命令跑完基本能看出大头在哪。常见的几个子目录含义如下目录存放内容能否直接删overlay2/镜像层与容器可写层绝对不能手动删删了元数据全乱containers/容器配置与*-json.log日志日志可截断目录不可删volumes/命名卷与匿名卷的真实数据需逐卷确认可能是业务库image/镜像元数据层数据在 overlay2不可手动删buildkit/构建缓存可以用 prune 清tmp/临时导入导出文件可清如果containers/这一层大得离谱那十有八九是容器日志。继续往下钻du -h --max-depth1 /var/lib/docker/containers | sort -hr | head -10 find /var/lib/docker/containers -name *-json.log -size 200M -exec ls -lh {} \;第二条命令会直接把超过 200M 的日志文件列出来带上容器 ID。到这里你就能对上号哪个容器在疯狂打日志。我遇到过最夸张的一次一个容器因为连不上依赖服务在重试循环里每毫秒打一行错误三天写了 68G 的 json 日志而业务方完全不知道。1.2 docker system df 给出的是逻辑账本文件系统层面看的是物理占用Docker 自己还有一套账本两者要对照着看docker system df -v不加-v只给四行汇总Images、Containers、Local Volumes、Build Cache。加了-v会展开到每个镜像、每个容器、每个卷的SIZE和SHARED SIZE。这里有个概念必须先掰清楚否则很容易误判SIZE是这个对象占的总空间SHARED SIZE是和别的对象共用的层UNIQUE SIZE才是它独占的部分。因为镜像分层复用的关系你把几个镜像的 SIZE 加起来得到的数字会远大于实际占用。判断删掉这个镜像能省多少要看 UNIQUE SIZE不是 SIZE。同理docker ps -s里面容器的size是可写层virtual size是它加上所有底层的总和虚得厉害别拿它当清理依据。1.3 动手前先划出能删和碰不得的红线我的红线清单是这样的每次清理前在脑子里过一遍碰不得/var/lib/docker下的任何目录都不用手工rm -rf。overlay2、image、containers 这些目录里存的是软链接和元数据删一半等于把 Docker 的内部数据库撕了救不回来的概率很高。碰不得命名卷里如果是数据库数据目录删卷等于删库。docker volume prune我先看docker volume ls -f danglingtrue的输出确认没有业务卷混在里面。可以动悬空镜像danglingnone:none那种、停止很久的容器、构建缓存、超过阈值的历史日志。需要确认不是悬空但也没容器在用的镜像。这类往往是你留作回滚备用的版本删之前至少要docker image inspect看一眼创建时间。提示清理前把docker ps -a、docker image ls -a、docker volume ls三条命令的输出重定向到文件里存一份命名带上时间戳。真出事了至少知道丢了什么能照着清单重建。2. 分级清场从零风险的缓存到需要停机的镜像瘦身空间大头找到之后清理要按照风险从低到高的顺序推进。我一般分三层第一层完全无损随便清第二层影响运行中的日志和已停容器需要看一眼第三层涉及镜像和卷必须逐个确认。这个顺序的好处是往往清完前两层问题就解决了压根不用冒险动第三层。2.1 第一层构建缓存与悬空镜像清了不影响任何运行构建缓存是纯增量产物删了最多下次 build 慢一点没有任何运行风险docker builder prune --filter until168h--filter until168h表示只清理 7 天前的缓存比全量docker builder prune -a温和得多。如果你的构建机是 CI 环境构建频繁这一条能稳定回收几十个 G。悬空镜像是指那些 TAG 被新构建顶掉、又没有被任何容器引用的中间层docker image prune默认行为就是只删悬空镜像。这里有个经验先docker image prune -a --filter until720h之前一定先用 dry-run 思路看一遍。Docker 的 prune 没有--dry-run我的替代做法是先列出来docker images -f danglingtrue docker images --filter beforeimage:tag顺手提一个常被忽略的点docker container prune清理的是已停止的容器可写层会一起释放。但如果你之前docker stop了某个容器只是想临时停一下prune会把它连元数据一起干掉docker start就回不来了。停超过一周没动的容器我才会清。2.2 第二层容器日志——那个最容易被忽视的隐形黑洞日志文件的清理有两个层面的问题很多人卡在第一个为什么我rm了日志文件df -h显示的空间没变原因是 Linux 的文件引用计数机制。容器里的应用进程还持有这个文件的句柄你把目录项删了inode 仍然被进程引用空间不会释放直到进程退出。所以正确做法是清空内容而不是删除文件truncate -s 0 /var/lib/docker/containers/容器ID/容器ID-json.logtruncate保留 inode只是把长度置零进程继续往同一个文件写df立刻能看到空间回来。这也是为什么我说千万不要用rm之后不管回头du看不到这个文件df却还是满的会让人怀疑人生。第二个层面是治本给后创建的容器加上日志上限从源头卡住。编辑/etc/docker/daemon.json{ log-driver: json-file, log-opts: { max-size: 50m, max-file: 3 } }改完执行systemctl reload docker部分发行版需要restart。注意daemon.json里的日志配置只对之后新建的容器生效已经在跑的容器不会自动套用需要重建。这一点我在文档里踩过坑配置改完以为万事大吉结果老容器的日志照样涨。另外max-size不要设得太小。50m 配合 3 个文件单个容器最多 150m对排查问题足够了。设成 10m 会导致日志轮转太频繁真出问题的时候关键上下文已经被覆盖了。2.3 第三层镜像与卷一条命令之差就是数据事故到了这一层每一条命令我都要先在别的终端确认一遍docker system prune这条是相对安全的版本清的是已停止的容器、未被使用的网络、悬空镜像、悬空构建缓存。加-a之后语义就变了会删掉所有没有被任何容器引用的镜像包括你打好的 tag。加--volumes更狠会连卷一起处理。关于卷这里必须说清楚版本差异不同 Docker 版本对docker volume prune的默认行为有过调整早期版本会把未使用的命名卷也算进去较新版本默认只处理匿名卷需要额外参数才碰命名卷。这种版本差异在紧急清理的时候最容易出事我的做法是永远不依赖默认行为docker volume ls -f danglingtrue docker volume inspect 卷名先列出来逐个inspect看挂载点和创建时间确认是临时数据之后再用精确的docker volume rm 卷名一个个删。慢是慢了点但不会出现清理完发现数据库空了这种事。镜像同理docker rmi比prune可控得多。删除前先看引用docker ps -a --filter ancestor镜像名:tag有输出说明还有容器依赖它删了会报 conflict。这种情况下先处理容器别强行-f。3. 该留的一个都不能丢卷、镜像与容器运行时的备份方案清理之前先备份这是常识但 Docker 场景下的备份具体备什么、备到哪、怎么验证很多人的理解是模糊的。我见过有人以为docker commit出来的镜像就包含了卷里的数据结果恢复的时候发现数据库目录是空的——因为卷的数据根本不在容器文件系统里。所以动手前先把三类东西分开看。3.1 命名卷的冷备份容器停掉tar 打包校验结构卷是唯一存放有状态数据的地方数据库、上传文件、配置。备份的基本原则是先停容器再备份让文件处于一致状态。热备份在文件正在被写入的时候打 tar很容易拿到半截文件尤其是数据库的 WAL、双写日志这类。完整流程如下# 1. 停掉使用该卷的容器 docker stop mysql-prod # 2. 用临时容器把卷挂载出来打包 docker run --rm \ -v mysql-data:/data:ro \ -v /backup/docker:/backup \ alpine:3.19 \ tar -czf /backup/mysql-data-$(date %F).tar.gz -C /data . # 3. 启动容器 docker start mysql-prod几个细节值得展开:ro只读挂载防止打包过程中意外写入-C /data .而不是-C /data *避免漏掉隐藏文件比如.my.cnf之类的点文件临时容器用alpine体积小、启动快不要用业务镜像免得带进额外依赖输出目录用宿主机绑定挂载不要输出到卷里否则等于没备份。备份产出之后一定要校验不能只看命令退出码tar -tzf /backup/docker/mysql-data-2024-06-01.tar.gz | head -20 tar -tzf /backup/docker/mysql-data-2024-06-01.tar.gz | wc -l看一眼目录结构对不对、文件数量级合不合理。文件数量对不上说明源目录少了东西。对于数据库这类有自己备份工具的服务我更推荐做逻辑备份而非文件级拷贝。MySQL 用mysqldumpPostgreSQL 用pg_dump导出的 SQL 文件跨版本兼容性好恢复时也不会因为文件系统差异出问题。文件级 tar 备份适合配置目录 上传附件这类场景。3.2 镜像与整机环境的导出save 与 export 不是一回事镜像的备份用docker save它保留完整的层结构和元数据CMD、ENTRYPOINT、ENV、健康检查docker save -o /backup/docker/app-1.4.2.tar \ registry.local/app:1.4.2 \ registry.local/worker:1.4.2一次 save 多个镜像有个隐藏收益共享层只存一份。分别 save 两个基于同一个基础镜像的服务加起来可能是 1.8G合并成一条命令可能只有 1.1G。这个差异在镜像数量多的时候非常可观。而docker export导出的是容器的文件系统快照丢失所有镜像元数据环境变量、启动命令、数据卷声明全都没了导回去只能docker import成一个新镜像还得手工补 CMD。除非你在做镜像取证或者特殊迁移否则不要用 export 做备份。还有一个容易混淆的点镜像备份不包含卷数据。所以一套完整的恢复包应该是镜像 tar 卷 tar 一份记录启动参数的文本。第三项特别重要我现在的做法是备份目录里放一个run.sh把docker run的完整命令包括所有端口、环境变量、挂载写在里面。半年后恢复的时候你绝对想不起来当初那个容器是怎么起的。3.3 恢复演练把备份灌进一个新卷验证能起来再收工备份不演练等于没备份。我的习惯是每月抽一次把最新的卷备份恢复到一个临时卷上验证目录结构和关键文件# 1. 建一个验证用的新卷 docker volume create verify-restore # 2. 把备份灌进去 docker run --rm \ -v verify-restore:/data \ -v /backup/docker:/backup:ro \ alpine:3.19 \ sh -c cd /data tar -xzf /backup/mysql-data-2024-06-01.tar.gz # 3. 检查内容 docker run --rm -v verify-restore:/data alpine:3.19 \ sh -c ls -la /data du -sh /data # 4. 清理验证卷 docker volume rm verify-restore如果要把正式环境恢复到新卷上还有个顺序问题先创建卷、再填数据、最后启动容器。如果顺序反了容器启动时会因为目录非空而报错有些应用比如某些版本的 MySQL遇到非空数据目录会直接拒绝初始化。恢复镜像则是docker load -i /backup/docker/app-1.4.2.tar docker image ls | grep appdocker load会自动识别 gzip 压缩过的 tar不用先解压。恢复完之后用docker image inspect核对一下 Entrypoint 和 Env 是不是跟备份前一致这个检查只要十秒钟但能省掉后面半小时的排查。注意备份目录本身也要有异地副本。我见过卷备份做得非常规范的团队所有 tar 都在同一个宿主机上的/backup目录然后一次磁盘故障机器和备份一起没了。4. Docker 的压缩到底压的是什么镜像导出、构建产物与存储层压缩这个词在 Docker 语境下其实指向三个完全不同的东西经常被混为一谈一是镜像导出成 tar 文件时的压缩二是构建输出时的压缩三是存储驱动层面的压缩。这三者的适用场景、能省多少空间、怎么操作差别很大。分清楚了你才知道自己需要的到底是哪一种。4.1 docker save 接 gzip离线分发的标准链路最常用也最直接的压缩场景是把镜像传给别人或者归档到对象存储docker save app:1.4.2 | gzip -c app-1.4.2.tar.gz体积收益通常在 40% 到 70% 之间取决于镜像内容。基础镜像里的大量二进制文件压缩率一般不错如果镜像里塞了已经压缩过的资源jar、zip、打包好的前端产物压缩率就有限。更进一步用pigz做并行压缩多核机器上速度能快好几倍docker save app:1.4.2 | pigz -p $(nproc) app-1.4.2.tar.gz加载的时候不用手动解压Docker 自己认得docker load -i app-1.4.2.tar.gz我实测过一个 2.3G 的镜像docker save直出 tar 是 2.3Ggzip 之后 890Mpigz -9能压到 850M 左右但耗时明显更长。日常用默认级别就够了追求极限压缩率的话再加参数。这里的经验是gzip -1 到 -6 之间体积差距很小但耗时差距很大批量归档的时候别迷信-9。一个容易踩的坑docker save -o file.tar和管道写法不要混用。有些人写成docker save app:1.4.2 -o app.tar | gzip app.tar.gz结果得到的是一个空的 gz 文件因为-o已经把输出重定向到文件了管道上什么都没流过去。要么用-o直出要么用管道二选一。4.2 buildx 的输出压缩与镜像瘦身是两码事如果你用 BuildKit 构建输出阶段本身就能控制压缩算法和级别。比如导出一个 OCI 格式的归档docker buildx build \ --output typeoci,destapp-oci.tar,compressionzstd,compression-level5 \ -t app:1.4.2 .或者推到镜像仓库时顺带压docker buildx build \ --output typeregistry,compressionzstd,compression-level3 \ -t registry.local/app:1.4.2 --push .zstd 相比 gzip 在压缩率和速度上都有优势尤其是解压速度对拉取镜像的体验改善明显。前提是目标环境的 Docker 版本足够新、能识别 zstd 压缩的层老版本会报错。做这种改动前先确认下游机器的版本别在灰度环境里试出来问题。但这里要分清的是输出压缩只影响传输和归档体积不影响镜像在本地占用的空间。你把镜像层从 gzip 换成 zstd本地docker system df看到的数字基本不变。真正减小本地占用的是镜像瘦身属于构建阶段的事手段完全不同多阶段构建把编译工具链留在 builder 阶段最终镜像只带运行时同一个RUN里apt-get update和apt-get install以及清理缓存写完避免缓存留在某个中间层优先用精简的基础镜像.dockerignore把node_modules、.git、日志目录排除掉这条最容易被忽略但收益经常是最大的。我见过一个前端镜像因为没写.dockerignore把本地的node_modules整个 COPY 进去了镜像从 180M 涨到 1.2G压缩之后还是 700M。加个忽略规则问题当场解决。4.3 存储驱动的压缩边界overlay2 压不了别抱幻想很多人会问能不能让 Docker 把镜像层压缩着存省点磁盘用默认的 overlay2 驱动时答案是不能。overlay2 是在普通文件系统上做联合挂载镜像层就是一堆普通文件和目录不存在块级压缩的余地。你看到的镜像占 2G那就是实打实的 2G叠加共享层之后。能提供存储压缩的是另一类驱动zfs 和 btrfs它们支持在文件系统层面开启透明压缩。如果你把 Docker 的数据目录放在一个启用了压缩的 zfs 数据集上镜像数据写入时会自动压缩读的时候自动解压。配置大致是这样zfs create -o compressionlz4 docker/data systemctl stop docker # 把现有数据迁过去后文会讲迁移流程lz4是速度优先的选择对容器场景比较合适CPU 开销小zstd压缩率更高但更吃 CPU。要不要折腾这条路取决于你的实际瓶颈如果磁盘 IO 不是问题、只是空间紧张值得考虑如果机器本身 CPU 已经很紧压缩带来的开销可能得不偿失。而且切换存储驱动是高风险操作现有镜像和容器都得重建不能在线切。我在生产环境做过一次评估了两周、停机窗口留了四小时最终数据迁移加验证用了接近三小时。所以对大多数场景清理 迁移到更大磁盘的性价比远高于换驱动。5. 数据目录整体搬家把 /var/lib/docker 迁到大容量磁盘清理只能解一时之困如果业务真的在长数据量迟早还会涨回来。这时候更彻底的办法是把整个数据目录搬到大盘上让系统盘保持轻量。这件事本身不复杂但细节很多尤其是拷贝方式选错、SELinux 上下文没处理、daemon 配置改完没生效这几个坑我都踩过。5.1 迁移前要评估的三件事空间、文件系统、停机窗口第一件是空间。目标盘至少要有源目录 1.2 倍的空闲留出拷贝过程中的临时开销。先量一下du -sh /var/lib/docker df -h /data第二件是文件系统。overlay2 对底层文件系统有要求XFS 需要ftype1较新的发行版默认满足ext4 一般没问题。用xfs_info /data | grep ftype确认一下如果ftype0overlay2 会拒绝启动这个错误信息很晦涩提前查能省不少事。第三件是停机窗口。停止 Docker 服务期间所有容器都不可用必须提前和业务方对齐。如果有多台机器建议先用一台非核心机器完整走一遍流程。停服务的正确姿势是把三个单元都停掉systemctl stop docker docker.socket containerd只停docker不顶用docker.socket会因为有人执行 docker 命令而被激活导致服务在你拷贝过程中被拉起来数据目录一边写一边拷拷出来的是不一致的。顺手确认一下当前的数据目录到底在哪有些机器之前被人改过docker info | grep -i docker root dir同时看一眼存储驱动docker info | grep -i storage driver这里有个新版本要注意的点较新的 Docker 在部分场景下使用 containerd 的镜像存储镜像数据可能落在/var/lib/containerd而不是/var/lib/docker。如果你的docker info显示存储驱动是 containerd 相关的类型迁移时要一并考虑那个目录或者确认它的实际占用不大、不需要搬。5.2 rsync 拷贝加>mkdir -p /data/docker rsync -aHAX --numeric-ids --infoprogress2 \ /var/lib/docker/ /data/docker/参数逐个解释-a归档模式保留权限时间戳-H保留硬链接关键-A保留 ACL-X保留扩展属性--numeric-ids不做用户映射避免容器内 UID 被改。如果开了 SELinux还要处理上下文否则 Docker 启动会各种权限拒绝chcon -R --reference/var/lib/docker /data/docker或者更稳妥的方式是restorecon -Rv /data/docker前提是目录策略已经配好。拷贝完成后做一次差异校验再补一遍增量此时服务仍然停着理论上不会有新数据但校验一下更安心rsync -aHAX --numeric-ids --dry-run --itemize-changes \ /var/lib/docker/ /data/docker/ | head没有输出说明两边完全一致。接下来改配置。编辑/etc/docker/daemon.json{ data-root: /data/docker, log-driver: json-file, log-opts: { max-size: 50m, max-file: 3 } }注意 JSON 语法最后一项后面不能有逗号很多人复制粘贴的时候带了个尾逗号Docker 启动直接失败。启动服务并验证systemctl daemon-reload systemctl start docker docker info | grep -i docker root dir输出应该是/data/docker。如果还是老路径说明配置没被读到检查文件位置和语法journalctl -u docker -n 50能看到解析错误的具体行号。5.3 迁移后的验证清单与回滚路径服务起来不等于迁移成功我一般会按这个清单过一遍验证项命令期望结果数据目录docker info | grep Root Dir指向新路径镜像完整docker image ls数量与迁移前一致容器列表docker ps -a全部容器都在状态未变卷完整docker volume ls卷名与迁移前一致容器可启动逐个docker start启动无报错新数据落盘docker run --rm alpine echo ok成功且新目录体积增长空间释放df -h系统盘使用率下降旧目录一定要留着至少留一周。我一般的做法是先改名而不是删除mv /var/lib/docker /var/lib/docker.bak确认新环境稳定运行一段时间、业务无异常之后再rm -rf /var/lib/docker.bak。这一条看着保守但真的救过命有一次迁移后第三天发现某个容器的卷数据少了一部分追下去发现是拷贝时该容器其实还在写停服务前有个 systemd 单元把它拉起来了靠旧目录把数据捞了回来。回滚路径也很简单把daemon.json里的># /etc/cron.d/docker-gc 0 3 * * 0 root /usr/bin/docker builder prune -f --filter until168h /var/log/docker-gc.log 21 30 3 * * 0 root /usr/bin/docker image prune -f --filter until720h /var/log/docker-gc.log 21注意image prune这里只清悬空镜像不加-a带 tag 的镜像不会被碰。--filter until720h再加一层保险30 天内的悬空镜像也不动万一有正在排查的问题还能回看。日志进/var/log/docker-gc.log是为了事后能查某天某个镜像突然没了翻日志能看到是被清理任务干掉的而不是怀疑存储出问题。另外两条约定俗成的规矩值得在团队里推一下镜像 tag 不用latest固定版本号这样prune时你清楚知道每个 tag 对应什么容器不长期docker run -d手工起用编排文件描述好处是所有挂载、端口、环境变量都有据可查重建的时候不会漏参数。6.2 给磁盘体积加一道监控别等 93% 才被动响应清理和迁移都是事后补救监控才是提前量。最基础的是磁盘使用率告警这个大多数环境都有。但更有价值的是趋势告警不是用了 90%才报而是三天内涨了 20G就报。我常用的一个土办法是每天定时采集一次关键目录的体积写到一个 csv 里配合简单的环比判断#!/bin/bash STAMP$(date %F) SIZE$(du -sb /var/lib/docker | awk {print $1}) echo $STAMP,$SIZE /var/log/docker-size.csv跑上两周就能看出正常增速是多少。某天突然翻倍说明有容器在异常写盘或者有镜像在批量堆积这时候介入成本最低。再进一步docker system df的输出也可以定期存档看镜像数量、卷数量的变化趋势。我遇到过一个问题某个 CI 任务每天构建一个新 tag 但从来不清理镜像数量三个月从 20 个涨到 900 多个每个都占着几百 M 的独占层加起来 200 多 G。这种缓慢增长靠单次告警是抓不到的只有看趋势才能发现。最后分享一个我在实际维护中形成的小习惯每次执行任何带prune或rm的命令之前先把要删的东西列出来看一眼。多花的那三十秒换来的是不用在下班前处理一个数据库没了的紧急电话。空间是省出来的但数据是靠谨慎保住的这两件事在 Docker 这里从来不矛盾。
返回列表