资讯动态

Docker镜像从只读层到虚悬镜像:清理与瘦身实战

发布时间:2026/9/16 18:10:30 来源:尧图企业网站定制
上周同事甩过来一张截图docker images刷了三十多行其中一半的 REPOSITORY 和 TAG 都是none宿主机的/var/lib/docker已经吃掉 80G。他问了一个特别朴素的问题这些玩意儿到底是啥能不能直接删。我盯着那张图看了一会儿发现真正难回答的不是能不能删而是他根本没搞清楚 Docker Image 是什么东西——在他脑子里镜像约等于一个安装包删了就删了大不了重新拉。这个认知偏差会一路蔓延到拉取、构建、推送、清理的每一个环节最后以磁盘爆满或者no space left on device的形式爆炸。这篇文章想干的事情是把 Docker Image 这个概念和围绕它的一整套命令pull、tag、push、build、rmi、prune、save、load等从会敲推到知道敲下去之后系统里发生了什么。适合已经用过docker run、能看懂基本命令但每次遇到none镜像、rmi删不掉、镜像体积莫名其妙膨胀时就只能靠搜索引擎的人。下面所有内容都是围绕实操展开的每个参数我都会说清楚它动了系统中的哪一部分而不是把--help抄一遍。1. 从磁盘爆满说起镜像其实是一摞只读层先把最容易误解的地方钉死镜像不是一个大压缩包也不是一个虚拟磁盘文件。它是一组按照固定顺序排列的只读层再配上一份描述这份顺序的配置清单。这个认知一旦建立起来后面所有为什么体积对不上为什么删了之后空间没释放的问题都会变得顺理成章。1.1 只读层叠加与可写层容器启动时到底动了什么我在本地拉一个mysql:8.0然后docker run起来。这个过程中发生的事情大致是这样镜像本身由若干层组成比如基础的操作系统文件是一层apt或者yum安装的依赖是一层MySQL 的二进制包是一层配置文件和环境变量是一层最后是启动命令的元数据。这些层全是只读的任何一层都不允许被修改。容器启动时Docker 会在最上面再叠一个可写层容器里所有的写操作——写入数据文件、修改配置、生成日志——都只落在这一层上。底下的所有只读层被多个容器共享这就是为什么你同时跑三个基于同一个node:20的容器磁盘占用不会翻三倍。这里有个新手非常容易踩的坑容器里删掉一个文件磁盘占用不但不会减少反而可能增加。原因是底层只读层的内容不能被真正擦除所谓删除只是在可写层里记了一条这个路径被遮蔽了的白障记录。所以在一个容器里跑完rm -rf /usr/share/doc/*你用docker diff看会看到一堆变化但镜像体积一点没动。1.2 内容寻址digest 为什么比 tag 更值得信任每创建一层Docker 会对这一层的内容做一次 SHA256 计算得到的哈希值就是这一层的身份标识。这种设计叫内容寻址名字tag可以变内容哈希变了就是另一个东西。好处是不同镜像之间只要有一层的哈希相同这一层就只需要存一份跨镜像共享是天然发生的。由此引出一个实操中的关键区别nginx:latest这个 tag 指向什么取决于仓库维护者最后一次推送的是什么而nginxsha256:xxxxxxxx...这个 digest 一旦写下来就永远指向那一份确定的内容。生产环境里如果对可重现性有要求用 digest 引用镜像比用 tag 稳妥得多。你可以用下面这条命令查到一个本地镜像对应的 digestdocker image inspect nginx:latest --format {{index .RepoDigests 0}}注意RepoDigests只有在镜像从仓库拉取或者推送过之后才会有值。如果你是用docker build在本地构建出来的没推过仓库这个字段会是空的这是正常现象。1.3 为什么改一行配置会让镜像胖出几百兆理解了层就能理解这个现象。层的粒度是指令不是文件。Dockerfile 里一条RUN指令产生的所有文件变化会被打包成一层。假设你写了这样两行RUN yum install -y gcc make make build RUN rm -rf /tmp/build-cache第二行的rm只是加了一层遮蔽记录第一层里那几百兆的编译产物、临时文件、构建缓存依然完整地躺在镜像里。最终镜像体积 所有层的总和而不是最后那个文件系统的样子。正确的做法是把会产生临时垃圾的操作和清理操作放进同一条 RUN 指令用串起来RUN yum install -y gcc make \ make build \ rm -rf /tmp/build-cache \ yum remove -y gcc make这样中间产物在生产这一层的过程中就没了不会进入最终镜像。这一个习惯能让一些 Java、Go 项目的镜像体积直接砍掉一半以上属于性价比最高的优化手段之一。2.docker images那张表每一列都在说什么很多人对docker images的输出是扫一眼有没有我要的镜像就完事了其实这张表信息密度很高尤其是当它开始出现异常数据的时候每一列都是排查线索。2.1 REPOSITORY 与 TAG命名规则里藏着的坑REPOSITORY这一列的完整形态其实是[仓库地址/][命名空间/]名称。当你不写仓库地址时默认是 Docker Hub 的官方命名空间写的时候必须符合小写字母、数字、.、_、-的规则大写字母是绝对不合法的。这一点在两种场景下特别容易出事。第一种是本地构建时给镜像起名叫MyApp:1.0docker build -t MyApp:1.0 .会直接报错invalid reference format。第二种是从 Windows 或者 macOS 上复制的代码里带了大小写不一致的路径构建脚本在 Linux 上跑的时候才暴露出来。我的习惯是项目名统一小写加连字符比如order-service从源头上避免这个问题。TAG这一列的默认值是latest但请注意latest在 Docker 里没有任何最新版本的语义它只是没写 tag 时用的那个默认标签。很多人以为docker pull redis拿到的就是最新稳定版实际上拿到的就是仓库维护者给latest打的那个 tag 所指向的版本有时候它可能落后于某个具体版本号。生产环境我强烈建议锁死具体版本比如redis:7.2.4-alpine而不是redis:latest。2.2 IMAGE ID 的由来以及它和 digest 的关系IMAGE ID这一列显示的哈希值是对镜像配置文件config JSON里面记录了环境变量、启动命令、所有层的 diffID 列表等做 SHA256 的结果所以它和RepoDigest是两回事。前者标识这份配置内容后者标识仓库里的那个 manifest。一个经常被问到的现象为什么我本地docker images看到的 IMAGE ID和同事那边看到的不一样可能的原因有三类。一是架构不同一个是 amd64一个是 arm64配置内容自然不同二是构建时间不同导致某些时间戳字段不同三是确实就是两个不同的镜像只是 tag 撞名了。排查的时候不要靠肉眼比对 ID 前缀用docker image inspect对比一下Created和Architecture两个字段更靠谱。2.3none虚悬镜像的三种来历以及要不要删none镜像官方叫 dangling image是新手最迷惑的东西。它的产生路径主要有三条重新构建同名 tag 的镜像。旧的镜像失去了 tag 指向就变成none了。这是最常见的一种比如你反复docker build -t my-app:dev .每构建一次就多一个none。docker pull覆盖了本地已有的同名 tag 镜像。老那份同样会变成虚悬。多阶段构建的中间层镜像。某些构建方式下中间产物会留在本地。先确认它们到底是什么再决定删不删# 只看虚悬镜像 docker images -f danglingtrue # 看它们的创建时间和体积 docker images -f danglingtrue --format table {{.ID}}\t{{.CreatedSince}}\t{{.Size}}判断逻辑很简单如果创建时间是你最近一次构建的时间八成就是被覆盖的旧版本删掉没风险。如果创建时间很古老或者体积异常大建议先用docker image inspect看一眼它的Cmd和Env搞不好是某个还在用的服务的历史版本。不确定就不要批量删这是我在生产机器上踩过教训之后给自己定的规矩。2.4 SIZE 列为什么和磁盘实际占用对不上SIZE列显示的是这个镜像独占层的总和共享层只算一次。所以你把同一台机器上所有镜像的 SIZE 加起来会得到一个比du -sh /var/lib/docker大得多的数字这是正常的不是系统算错了。反过来也有一种情况docker system df显示的总量和du的结果也不一样。原因通常是du会把正在被引用但已经该回收的层也算进去而docker system df只统计有引用的部分。想知道真实的、可回收的空间有多少看这条命令的第二行docker system df -v提示docker system df -v会列出每个镜像、每个容器、每个数据卷的详细占用输出很长但非常值得看一遍。我第一次认真看完之后发现一个已经不用的 Elasticsearch 镜像吞了 6 个 G。3. 拉取、推送与打标签pull/push/tag的真实动作docker pull大概是所有人学的第一条 Docker 命令但它内部做的事远比下载一个文件复杂。理解这个过程能帮你在拉取失败、拉取变慢、拉取到不对的架构时迅速定位到是哪一环出了问题。3.1docker pull背后的 manifest 协商与分层下载一次完整的拉取大致分四步。第一步客户端向仓库请求这个 tag 对应的manifest这是一个 JSON 文档里面列出了这个镜像由哪些层组成、每层的 digest 和体积、以及配置文件的 digest。第二步客户端比对本地已经有哪些层层的 digest 是内容哈希可以精确比对只下载缺的那部分。第三步逐层下载并校验哈希哈希对不上就丢弃重来。第四步把配置文件和层组装成本地镜像并给上 tag。第二步是解释很多玄学现象的钥匙。为什么你换了一台机器拉同一个镜像速度特别慢因为本地一个层都没有全部要下。为什么在同一台机器上拉node:20和node:20-slim第二次感觉快一些因为它们共享了部分基础层。为什么你会看到输出里有Pull complete和Already exists两种状态混在一起Already exists的就是本地已经有的层。多架构镜像也是在这一步暴露的。仓库里的 manifest 可能是一个manifest list里面包含了 amd64、arm64 等多个平台的入口客户端会根据自己当前的平台挑选对应的那一份。这就是为什么你在 M 系列芯片的 Mac 上拉到的镜像是 arm64 版本而同事的笔记本上拉到的是 amd64。想强制指定平台docker pull --platform linux/amd64 mysql:8.03.2tag只是给镜像加了个别名不复制任何数据docker tag这个命令名字起得很误导人它做的事情本质上是在镜像的引用表里增加一条记录让一个新的名字指向同一份内容。不复制层不占额外空间速度是毫秒级的。docker tag mysql:8.0 registry.example.com/infra/mysql:8.0-prod这条命令执行完之后docker images里会多出一行但两行的 IMAGE ID 完全相同SIZE 也相同。真正让数据发生搬运的是下一步的push。这里有个很实用的技巧本地构建的镜像要推到私有仓库之前必须先用tag把名字改成符合仓库规范的完整路径因为docker push推的是 tag不是 IMAGE ID。常见错误denied: requested access to the resource is denied八成就是 tag 里的仓库地址和实际登录的仓库对不上。3.3 推送到私有仓库的路径规范和常见拒绝原因私有仓库的镜像命名是有严格规范的格式是仓库地址/项目名/镜像名:tag。比如registry.example.com/infra/mysql:8.0。如果仓库本身有命名空间或者项目分层这个路径必须完全匹配。推送被拒的几类典型原因按出现频率排报错信息根本原因处理方式denied: requested access to the resource is denied未登录或 tag 路径与仓库不匹配docker login后检查 tag 全路径unauthorized: authentication required凭证过期或没有该项目的写权限重新登录确认账号权限manifest unknown推送时目标 tag 已存在但被仓库策略锁定换 tag或确认仓库是否禁止覆盖blob upload invalid网络中断导致分块上传失败重试检查代理和 MTU最后一条在跨机房推大镜像时特别常见尤其是镜像超过 1G 的时候。我的一般做法是先把镜像推到就近的仓库再由仓库之间做同步而不是从开发机直推远端。3.4 多架构镜像与--platform的使用时机现在不少项目需要同时支持 amd64 和 arm64甚至一些国产化环境下的非 x86 架构。这时候单靠docker build是不够的需要用 buildx 做多平台构建docker buildx build \ --platform linux/amd64,linux/arm64 \ -t registry.example.com/app/order:1.0.0 \ --push .注意--push这个参数。多平台构建的产物无法直接 load 到本地 docker images 里因为本地镜像库一次只能持有当前平台的那一份所以它必须边构建边推送到仓库。如果你写--load就会报错这是一个非常典型的卡点我见过不止一个人在这儿折腾半小时。4. 构建侧的镜像命令build、commit、save、load 各自的适用面前面聊的都是围绕已有镜像的操作这一节聊产生镜像和搬运镜像。这两类命令经常被混用比如有人用docker commit当日常构建手段有人用docker save当备份方案结果都会在某个时刻翻车。4.1docker build的层缓存是提速的关键也是踩坑的重灾区docker build按 Dockerfile 的指令顺序执行每条指令生成一层。如果某条指令的内容和上次构建时完全一致包括指令本身、依赖的文件内容、以及上一层的结果Docker 就直接复用缓存不重新执行。这个机制有个必须记住的特性缓存是链式的一旦某一层失配它后面所有的层都会失效。所以 Dockerfile 里指令的顺序极其重要。把变化频率低的操作放前面变化频率高的放后面。经典的反例是这样COPY . /app RUN npm install每次改一行业务代码COPY层的缓存就失效导致npm install要重跑一遍白白浪费几分钟。正确的写法是先拷依赖清单再装依赖COPY package.json package-lock.json /app/ RUN npm ci COPY . /app.dockerignore也是这一环的重灾区。如果你的构建上下文里有node_modules、.git、target这些目录它们在COPY . /app的时候会被全部塞进构建上下文发给 daemon既慢又容易让缓存失效。一个基本的.dockerignore.git node_modules target *.log .idea .vscode4.2docker commit什么时候它还值得一用docker commit把容器的可写层提交成一个新镜像。官方文档里对它态度很冷淡理由很充分它产生的镜像没有构建历史、不可复现、体积控制全靠运气别人拿到也搞不清楚里面改了什么。但它有一个场景确实好用临时排障。比如生产环境某个容器的配置被改坏了你想先把它冻结成一个镜像保住现场然后再慢慢分析docker commit就是最快的选择。或者你在一个容器里手工调了半天把服务跑通了想把这份状态先存下来当参考也行。docker commit -m 调通了连接池配置 -a me 容器ID debug/pool-fixed:trial我的底线是commit出来的镜像只允许存在于本地打上trial、debug这类明显带临时色彩的 tag绝不推仓库也绝不用在生产部署链路上。4.3save/load与export/import的四组对照这两对命令看起来都是导入导出实际行为差别很大搞混了会丢数据。docker save把一个镜像的所有层、配置、标签打包成一个 tar 文件docker load把它读回来恢复成一个完整的、可以用docker run跑的镜像。这是镜像级别的搬运用于离线环境或者没有仓库可用的场景。docker export把容器的当前文件系统导出成一份扁平的 tardocker import把它导入成一个只有一层的镜像。注意这里的关键差别import出来的镜像没有历史层信息也没有原来的环境变量和启动命令你docker run的时候必须自己指定CMD。对比维度save / loadexport / import操作对象镜像容器保留分层完整保留压成单层保留构建历史保留丢失保留 ENV / CMD保留丢失典型用途离线传输镜像抢救容器内文件、制作极简基础镜像体积通常较大通常较小我个人的使用频率大概是save/load一年用几次export基本只在需要把容器文件系统扒出来看的时候用。4.4 一条完整的构建到推送流水线把上面这些拼起来一个不依赖 CI 系统、纯手工可跑的流程长这样# 1. 构建并打上本地 tag docker build -t order-service:1.0.0 . # 2. 给仓库路径打第二个 tag docker tag order-service:1.0.0 registry.example.com/app/order-service:1.0.0 # 3. 登录并推送 docker login registry.example.com docker push registry.example.com/app/order-service:1.0.0 # 4. 在目标机器上拉取 docker pull registry.example.com/app/order-service:1.0.0如果你是在 IDEA 里用插件打包镜像本质上它执行的就是第 1 步只不过把 Dockerfile 路径和 tag 配置化在界面上。插件的好处是帮你把 build context 缩小到了target目录避免了把整个工程目录发给 daemon 的性能损耗坏处是出问题时你看不到它到底怎么调的命令所以我习惯在插件配置里勾上显示构建命令出问题的时候能对照排查。5. 删除与瘦身rmi和prune的雷区清单这是最容易出事的一节。删镜像这个操作看起来简单实际上要同时满足好几个条件才能真正删掉而且删错了恢复成本很高尤其是那些只在本地存在的、没推过仓库的镜像。5.1rmi删不掉的五种情况逐一处理docker rmi报image is being used by running container或者conflict的时候先别急着加-f。加-f只是强制解除引用很多时候它并不会真正释放空间反而让你误以为删干净了。五种典型情况的处理方式有容器正在使用包括Exited状态的容器。先用docker ps -a --filter ancestor镜像ID找出是哪些容器确认可以删之后docker rm掉容器再删镜像。镜像有多个 tag。docker rmi IMAGE ID会报错必须先按 tag 逐个删或者用docker rmi repo:tag的形式一个个来。镜像被其他镜像当作基础层引用。这种情况删不掉也不该删因为删了会影响上层镜像。真要删得先处理上层。minikube、k3s 这类工具自己的镜像库。你在宿主机上执行docker rmi是没用的得先用eval $(minikube docker-env)切进去。Docker Desktop 环境下的路径错位。Windows 和 macOS 上的 Docker 跑在一层轻量虚拟机里/var/lib/docker是虚拟机内部的路径你在宿主机上找不到它。空间回收要做的是在 Docker Desktop 设置里调整磁盘映像上限或者执行清理命令让虚拟机内部释放。5.2prune系列的语义边界以及最危险的那个参数prune系列有四个成员docker image prune、docker container prune、docker volume prune、docker network prune还有一个全家桶docker system prune。它们的共同点是删除没有被引用的对象但引用的定义各不相同。先说结论docker image prune不带-a只删虚悬镜像相对安全带上-a会把所有没有容器在用的镜像全删掉包括你精心打好 tag 的那几个。这两者的差别有多大用一条命令就能看出来# 只删虚悬镜像看看能回收多少 docker image prune --dry-run # 连没被容器引用的有 tag 镜像一起删 docker image prune -a --dry-run--dry-run是个好东西先看一眼再决定。我自己的习惯是先在开发机上跑一遍 dry-run 看看数量心里有数之后再执行。docker volume prune是最需要警惕的。数据卷被删掉之后里面的数据是真的没了没有回收站。而且 Docker 判断没被引用的依据是没有容器挂载它一个停掉的容器不算引用。所以执行这条命令之前一定要先docker ps -a把所有容器列出来确认你没漏掉任何还需要的容器。注意docker system prune -a --volumes这条命令会同时清掉镜像、容器、网络和数据卷。任何情况下都不要在生产机器上随手执行它也不要把它写进任何一键清理脚本里。5.3 一份可以定期跑的清理清单给一个我自己在用的、有边界的清理套路分三档风险递增每次只往上走一级# 第一档清虚悬镜像和无用网络风险最低 docker image prune -f docker network prune -f # 第二档清所有已停止的容器前提是你确认没有需要保留的现场 docker container prune -f # 第三档清未被任何容器引用的镜像 docker image prune -a -f第三档执行之前一定先跑docker images对着看一遍把那些体积特别大又暂时用不到的基础镜像比如postgres、elasticsearch记下来因为它们一旦被删下次要用就得重新拉几百兆甚至上 G。在带宽紧张的环境里重新拉一遍的时间成本远超你省下来的那点磁盘。除了删还有一条更值得做的瘦身路径用更小的基础镜像。同一套 Java 应用基础镜像从openjdk:17换成eclipse-temurin:17-jre-alpine体积可能从 470M 掉到 180M 左右如果用多阶段构建只留 JRE 和 jar 包还能再压。这个改动的收益是长期的比定期清理靠谱得多。6. 报错排查与跨平台差异从拉取超时到 Desktop 启动失败最后这一节聊几个高频问题。它们的共同点是搜索引擎上一搜一大把答案但很多答案只给操作不给原因照抄完之后下次遇到还是不会。6.1 拉取慢、拉取中断的排查顺序遇到docker pull卡住或者超时按这个顺序排查效率最高先看是卡在哪一步。如果一直停在Pulling from library/xxx说明是拿 manifest 的阶段出了问题通常和网络到仓库的连通性有关如果已经显示具体层在下载但速度极慢那问题在带宽或者链路质量上可以尝试换一个更近的镜像源。再看是不是镜像本身太大。有些基础镜像例如某些带完整工具链的版本单层就有好几个 G。这种情况下可以考虑先拉一个更小的版本比如带-slim后缀的跑通流程之后再换。最后确认平台是否匹配。如果拉取过程中报no matching manifest for linux/arm64 in the manifest list entries说明这个 tag 不支持你当前的架构需要显式指定--platform或者换一个支持多架构的镜像。对于因为架构不匹配导致的运行时报错还有个坑值得说你用--platform linux/amd64在 arm64 机器上拉了一个 amd64 镜像能拉下来也能跑起来但它是靠模拟层跑的性能可能只有原生的几分之一CPU 密集型的任务会明显变慢。能找出原生支持的镜像就别用模拟。6.2 Windows 和 macOS 上的目录、路径与虚拟化问题Docker Desktop 在这两个平台上的行为和 Linux 原生存明显差异最典型的三点第一没有/var/lib/docker这个路径。所有镜像和容器的数据都在一个磁盘映像文件里Windows 下通常是ext4.vhdxmacOS 下是Docker.raw。所以你在网上看到的那些删掉/var/lib/docker/overlay2目录来释放空间的教程在这两个平台上完全不适用照做只会浪费时间。第二文件挂载的性能差异。Windows 上从宿主机挂载目录进容器在 WSL2 后端下如果跨文件系统访问性能会明显下降。把工程代码放在 WSL2 的文件系统内部而不是 Windows 的盘符下构建速度的差别能有好几倍。第三启动时报虚拟化相关的错误。这类报错基本都指向同一个根因底层的虚拟化能力没被正确启用。处理路径通常是先确认 CPU 的虚拟化选项在固件设置里是打开的再检查系统里是否有其他虚拟化组件占用了同一套能力产生冲突。这种情况下重启一次往往能让配置生效。如果你用的是较老的机器还要确认它本身支持所需的虚拟化特性这是硬性前提绕不过去。顺带提一个和镜像体积相关的实际场景。在资源受限的环境里用 MySQL 8.0docker pull mysql:8.0拉下来的镜像本身就有几百兆加上初始化数据目录之后磁盘占用会继续涨。我的做法是给数据目录单独挂一个数据卷镜像本身用mysql:8.0而不是mysql:latest避免某次重建容器时因为 tag 指向变化而被动升级到不兼容的版本。数据库这种组件版本号必须显式锁死。6.3 贴在备忘录里的命令速查表把本文涉及的命令按用途归一下类方便直接抄用途命令说明查看本地镜像docker images加-a显示中间层加-f danglingtrue只看虚悬查看详细元数据docker image inspect 名称查 Created、Architecture、Env、Cmd带格式化输出docker images --format table {{.Repository}}\t{{.Tag}}\t{{.Size}}自定义列适合脚本解析拉取并指定平台docker pull --platform linux/arm64 名称跨架构场景必用打别名docker tag 源 目标全路径不复制数据推送docker push 目标全路径路径必须与仓库规范一致构建docker build -t 名称 .加--no-cache强制不用缓存多平台构建docker buildx build --platform ... --push .必须带--push或--load保存镜像为文件docker save -o img.tar 名称含分层和历史从文件载入docker load -i img.tar与 save 配对导出容器文件系统docker export 容器ID -o fs.tar扁平单层无历史删除镜像docker rmi 名称或ID有容器引用时先删容器清理虚悬镜像docker image prune -f相对安全清理无用镜像docker image prune -a -f会删掉有 tag 但没被引用的镜像查看空间占用docker system df -v明细最全速查表这东西的价值不在于背下来而在于遇到不熟悉的场景时能快速想起有这么个命令存在。我自己的习惯是每解决一个新问题就往表里补一行半年下来这张表就成了最贴合自己工作流的参考。说回开头那位同事的问题。最后我是这么处理的先用docker images -f danglingtrue --format table {{.ID}}\t{{.CreatedSince}}\t{{.Size}}把虚悬镜像按创建时间排出来确认最近三天构建产生的那些直接清掉回收了大概 30 多个 G剩余体积大的几个是他几个月前为了试某个中间件拉的从没跑过容器这种删掉之后下次要用重新拉就行。至于/var/lib/docker剩下那部分占用是正在运行的几个数据库容器的数据卷那个不能碰。整个过程最有价值的不是那几条命令而是在动手之前花五分钟把这个镜像是什么、谁在用、删了怎么恢复这三件事想清楚。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价