资讯动态

Docker镜像分层原理与镜像瘦身实践:构建缓存、overlay2与多阶段构建

发布时间:2026/9/18 15:57:09 来源:尧图企业网站定制
上周帮同事定位一个构建问题业务镜像 6.2G他只改了一行配置文件重新构建推送到私有仓库时进度条跑了十几分钟日志里一半的层显示 already exists另一半在缓慢上传。他问我明明只改了一个文件为什么推送量还是这么大这个问题没法用再等等回答它直指 Docker 镜像最核心的设计——镜像分层。Docker 镜像不是一个打包好的大文件而是一叠只读的补丁按顺序叠加出来的结果镜像分层决定了构建要跑多久、推送要传多少、容器启动要挂多少目录、磁盘会被吃掉多少。我把镜像从是什么到在宿主机上怎么落盘整个链路捋一遍包括层怎么生成、缓存怎么失效、overlay2 怎么把层挂起来、以及我这些年踩过的几个和分层有关的坑。刚接触 Docker 的朋友可以照着命令一步步验已经在生产里跑容器的同行也能找到几处能立刻改掉的写法。1. 一次 400MB 的无用推送镜像分层到底解决了什么问题1.1 镜像不是一个文件而是一叠只读的补丁先把最容易被忽略的事情说清楚一个镜像在磁盘上并没有一个叫nginx.tar的实体。它由三部分描述出来——一份 manifest说明这个镜像包含哪些层、基础平台是什么、一份 config JSON记录启动命令、环境变量、工作目录、用户以及每层对应内容的摘要列表、以及若干层文件每层是一个 tar 包通常还做了压缩。层与层之间是增量的关系。第一层可能是精简版 Debian 的根文件系统第二层是在它之上安装了 apt 索引和几个库第三层是拷进去的应用二进制。每一层只描述相对上一层变了什么把三层按顺序叠起来才是容器看到的完整文件系统。这个思路和 Git 的 commit 链几乎一模一样你不会每次提交都存一份完整代码而是只存 diff能复用的对象就复用。区别在于 Git 的 diff 是行级的而镜像是文件级的——一个层里要么有某个完整文件要么没有要么是一个这个文件被删掉了的标记。1.2 为什么要分层共享、缓存、增量传输三件事分层不是为了让架构好看它一次性解决了三个成本问题。第一是存储共享。一台机器上跑十个容器都基于同一个基础镜像那么这十个容器的只读层在磁盘上只有一份内容。容器之间的差异只在各自那个很薄的读写层里。如果镜像是整包拷贝十个容器就是十分磁盘占用这在 CI 构建机上是灾难。第二是构建缓存。构建时Docker 会计算每一层的指纹。指纹没变它就直接复用已有的层不再执行那条指令。所以你第一次构建 Node 项目要三分钟改了业务代码之后再构建可能只要十秒——依赖安装那一层被复用了。这是日常开发里体感最强的一点。第三是增量传输。推送和拉取时registry 会按层的摘要逐个检查这个 blob 我这儿有没有有就跳过没有才上传。所以第二次推送通常只传变化的那一层、一份新的 config加上一份新的 manifest。同事那次推送之所以慢是因为他改动的内容命中了很靠下的一层导致它上面所有层全部重建自然也就全部要重传。1.3 分层带来的第一个反直觉结果删文件不等于变小分层是只读的这是理解一切问题的起点。一个层一旦生成里面的内容就不能再被修改或删除。那如果我删了一个文件呢Docker 会在新层里放一个叫白化文件whiteout的标记文件名形如.wh.被删文件名。联合文件系统看到这个标记就会在最终视图里把这个文件遮住让你看不见它。但下面那层里的文件实体一个字节都没少。这个机制直接推出一个结论镜像只会变大不会变小。# 反面写法镜像反而更大 RUN apt-get update apt-get install -y curl RUN rm -rf /var/lib/apt/lists/*上面两条指令各自成层。第二条虽然删掉了 apt 索引但那几百 MB 的索引实体还完整地待在第一条指令生成的那层里删除只是加了一个白化标记。最终镜像体积 第一层的全部内容 第二层的一堆白化标记。正确写法是把它们放进同一条RUNRUN apt-get update \ apt-get install -y --no-install-recommends curl \ rm -rf /var/lib/apt/lists/*同一条RUN里的所有操作在同一个层里完成文件被写进去又被删掉这个过程不会被记录进层内容——最终这一层里就没有那堆索引。这就是分层给我们上的第一课清理必须和写入在同一层里完成。2. 镜像、层、容器可写层组成结构与三个容易被搞混的 ID2.1 从 config JSON 看镜像的真实组成想确认镜像到底由什么组成直接把它 inspect 出来看最快docker image inspect nginx:1.25 --format {{json .RootFS.Layers}} | python3 -m json.tool你会拿到一串 sha256 值每个对应一层。这里有个细节值得留意RootFS.Layers里的摘要是未压缩 tar 包的摘要而 registry 的 manifest 里列出的层摘要是压缩后的摘要。同一层两个数字完全不一样。排查为什么这个层推不上去的时候如果拿这两组摘要去对很容易对到怀疑人生。另外还可以看到.Config里的Env、Cmd、Entrypoint、WorkingDir、User这些是镜像的运行时配置不属于任何文件系统层。2.2 容器启动时多出来的那一层镜像全是只读层那容器里的文件为什么能改因为启动容器时Docker 会在所有只读层之上再加一个可写层。所有对文件系统的修改——新建文件、修改配置、写日志——都落在这个可写层里。搞清楚这一点很多事情就通顺了容器删掉可写层一起删掉镜像不受任何影响容器重启可写层还在所以容器里改过的文件不会丢容器做docker commit本质就是把可写层打包成一个新层接到原镜像上面变成一个新镜像。这也是为什么我不建议用 commit 做镜像——它会把运行期的临时文件、日志、甚至密码一并固化进去而且这个过程不可追溯、无法复现。用一个真实的 inspect 结果感受一下结构docker run -d --name demo nginx:1.25 docker container inspect demo --format {{json .GraphDriver.Data}} | python3 -m json.tool输出里会出现LowerDir、UpperDir、MergedDir、WorkDir四个路径。LowerDir是一串用冒号连起来的目录对应镜像的每一层UpperDir就是那个可写层MergedDir是给容器看的联合视图。这三个东西就是下一节要展开的 overlay2。2.3 image ID、layer digest、manifest digest 三个 ID 不要混日常操作里被问到最多的问题之一就是我这镜像的 ID 到底是哪个。实际上有三个都能叫ID的东西名称是什么的摘要特点image IDconfig JSON 的内容摘要docker images里显示的短 ID同一份构建产物 ID 相同layer digest单个层 tar 的摘要压缩/未压缩两种registry 按它去重也是推送日志里那串sha256:...manifest digest整个 manifest 的摘要docker pull xxxsha256:...用的就是它最稳定共同点是内容寻址内容一样摘要就一样。这个特性是层可以跨镜像、跨仓库复用的根本原因——registry 只认内容不认名字。tag 只是一个指向摘要的可变标签随时可以被覆盖而摘要一旦确定指向的内容永远不会变。理解这一层之后一个很实用的运维习惯就顺理成章了生产环境的基础镜像别只写nginx:1.25那是个会漂移的 tag写成nginxsha256:...今天构建和三个月后构建拿到的完全是同一份内容。这能省掉很多我本地是好的线上跑不起来的沟通成本。3. 从 Dockerfile 指令看层的生成规则3.1 会产生文件系统层的指令Dockerfile 里能产出文件系统层的指令只有三条RUN、COPY、ADD。注意是每条指令一层不是每条命令一层。RUN mkdir /app RUN echo hello /app/a.txt RUN chmod 755 /app三行三层。每层都完整保存了那一刻的文件系统状态差异还带着一份元数据。合并成一行就是一层RUN mkdir -p /app \ echo hello /app/a.txt \ chmod 755 /app那是不是层数越少越好不一定。合并之后修改任意一点都要整条重建缓存的粒度变粗了。我的经验是**把逻辑上不可分离的操作合进一层安装加清理、下载加解压加删除压缩包把希望独立缓存的步骤拆成不同的层。**层数控制在 20 层以内是我在大多数项目里觉得比较舒服的区间。层数堆到上百一方面会让联合挂载的挂载参数变得很长overlay2 需要把所有下层目录拼进挂载选项历史上内核和 Docker 对下层数量都有过限制另一方面每一层都要一次元数据操作构建和启动都会变慢。3.2 只改元数据的指令ENV、LABEL、CMD、ENTRYPOINT、EXPOSE、USER、VOLUME、ARG、STOPSIGNAL、HEALTHCHECK这些指令不产出文件系统内容它们只写进 config 和构建历史。这带来两个实用推论。一是改ENV是廉价的。调整一个环境变量不会让磁盘上多出几百 MB只是换了份 config。所以把LABEL、ENV放在 Dockerfile 靠前的位置不会拖慢构建。二是改这些元数据会让缓存链在这一点断开。经典构建器里ENV变化会让它之后的RUN层全部重建。所以别把一个天天变的构建号塞进靠前的ENV里那样等于每天全量重建一次。还有一个容易踩的点USER只改变后续指令和容器启动时的默认用户身份它不会修改任何已有文件的属主。很多人写了RUN adduser app加USER app结果容器一启动就报权限错误就是因为文件还是 root 所有。3.3 RUN 里的 rm 为什么必须和安装写在同一行前面在 1.3 讲过原理这里补充几个高频场景。凡是包管理器基本都会在下载目录留下缓存apt 是/var/lib/apt/listspip 是~/.cache/pipnpm 是~/.npmMaven 是~/.m2/repositoryyum 是/var/cache/yum。这些目录动辄几百 MB而且它们全都在镜像的可写层以外的位置——也就是它们真的会被打包进镜像。一个真实的排查例子某个 Python 服务的镜像 2.1Gdocker history看一眼最大的层是一条RUN pip install -r requirements.txt占了 1.6G。原因就是 pip 的下载缓存和构建中间产物都留在了这一层里而且因为这条指令前面还有一层COPY requirements.txt后面的清理指令放在新层里也没用。修法是两条路一是把清理写进同一条RUN二是用 BuildKit 的缓存挂载让缓存根本不落到层里# 方案一合并清理 RUN pip install --no-cache-dir -r requirements.txt # 方案二BuildKit 缓存挂载缓存不进层且跨构建复用 RUN --mounttypecache,target/root/.cache/pip \ pip install -r requirements.txt方案二在大项目里优势明显缓存目录被挂载到构建缓存区既不算进镜像体积又能在下次构建时命中装依赖的时间常常从几分钟压到几十秒。这也是 BuildKit 相比经典构建器最值得切换的理由之一。4. 构建缓存失效的真实判断逻辑4.1 缓存键是父层 指令字符串经典构建器的缓存判定规则很朴素对于RUN缓存键是父层 ID 加上这条命令的字符串本身。注意是字符串本身不是执行结果。这解释了一个经典坑。下面这种写法在某次构建里跑得好好的过两周重新构建就报 404RUN apt-get update RUN apt-get install -y curlapt-get update在第一次构建后会被缓存下来缓存的是命令字符串 当时的父层所以之后重新构建时它根本不会真的去拉最新的索引而是直接复用那个可能已经过期的索引层。等真正的install执行时索引里指向的包路径早就失效了——于是 404。修法是把 update 和 install 写在同一层里这样它们要么一起命中缓存要么一起失效并在需要强制刷新时用--no-cache或者--pull拉一次最新的基础镜像。另外只要某一层失效它后面所有层全部失效无论它们本身有没有变。这是把稳定的东西放上面、易变的东西放下面这条老规矩的数学依据。4.2 COPY 的校验和与 mtime 的关系COPY和ADD的缓存判定和RUN不同它会把源文件的内容校验和参与计算同时把文件权限等元数据也算进去。而文件修改时间mtime按官方说明并不参与校验。这条规则有两个方向的应用。一个方向是好的从 Git 仓库 checkout 出来的文件mtime 全都是 checkout 那一刻但内容没变的话COPY那一层依然能命中缓存。所以在 CI 里重新 clone 代码不会因为时间戳问题导致全量重建。另一个方向是坑touch一个文件、改一下权限、换个软链接指向都可能在你没意识到的情况下改变COPY的缓存键。反过来如果某个文件的内容以任何方式变了哪怕只多了一个空格这一层也必须重建并且连累后面所有层。还有一点很容易被忽略COPY . .会把构建上下文里所有文件都纳入校验范围。如果你的.dockerignore没配好node_modules、.git、target、dist这些目录也在里面随便跑一次构建就会因为某个编译产物变化而让整个COPY层失效。更糟的是非 BuildKit 模式下整个上下文都会被完整打包发给守护进程上下文里有几个 G 的垃圾构建光是传上下文就要等半天。# .dockerignore .git node_modules dist target *.log .env*4.3 三条让缓存命中率翻倍的写法第一条先拷依赖清单装完依赖再拷源码。COPY package.json package-lock.json ./ RUN npm ci COPY . .这样改业务代码只会让最后那个COPY层失效依赖安装那层稳如泰山。如果反着写COPY . .在前、npm ci在后那每次改一行代码都要重新装一遍依赖。第二条.dockerignore认真配。上面已经说了原因这条是投入产出比最高的改动之一。第三条用缓存挂载处理包管理器。RUN --mounttypecache,target...让下载缓存脱离镜像层既不影响体积也不影响缓存命中。在 CI 环境下还有一步把构建缓存显式带进带出。BuildKit 支持把缓存元数据内联到镜像里--build-arg BUILDKIT_INLINE_CACHE1配合--cache-from在另一台机器上复用多节点 CI 的效果差异非常明显——从每次十几分钟全量构建变成两分钟出镜像。5. 压层数、压体积多阶段构建与基础镜像选型5.1 多阶段构建把编译环境留在第一阶段镜像变大最粗暴的原因是编译器、依赖包、头文件全都留在了最终镜像里。一个 Go 服务编译完只需要一个二进制文件但镜像里可能躺着一整个 Go 工具链。多阶段构建解决的正是这件事FROM golang:1.22 AS builder WORKDIR /src COPY go.mod go.sum ./ RUN go mod download COPY . . RUN CGO_ENABLED0 GOOSlinux go build -ldflags-s -w -o /out/server ./cmd/server FROM gcr.io/distroless/static-debian12 COPY --frombuilder /out/server /server USER nonroot:nonroot ENTRYPOINT [/server]最终镜像里只有那个二进制和 distroless 的基础文件编译阶段产生的几千层、好几个 G 的内容全都被丢掉了——注意是没有被引用所以不会出现在最终镜像里也不会被推送。这里的CGO_ENABLED0是必须要写的。默认开启 CGO 的 Go 二进制会动态链接系统里的 glibc而 distroless 或者 scratch 里并没有 glibc容器一启动就报no such file or directory。这个报错信息迷惑性极强——它说的不是你要的文件不存在而是动态链接器找不到我见过不止一个人在这上面耗掉半天。5.2 --chown 与写时复制为什么 RUN chown 会让体积翻倍这个坑非常隐蔽。假设你的应用目录有 500MB# 反面写法 COPY ./app /app RUN chown -R app:app /app第一层放进 500MB 的文件第二层执行chown。看起来只是改了属主不占空间对吧实际上chown会修改文件的原数据而在联合文件系统里修改下层文件会触发整个文件被复制到上层。这 500MB 被完整复制了一份镜像变成了 1G。正确做法是在拷贝的时候就指定属主COPY --chownapp:app ./app /app一次性到位不产生额外的复制层。同理RUN chmod -R、RUN chown -R这类递归操作能避免就避免实在需要的话尽量放在文件还很小的阶段做。顺带说一句写时复制的粒度问题它是文件级的不是块级的。容器里改一个 1GB 文件里的一个字节overlayfs 会把整个 1GB 复制到可写层同时磁盘上多出 1GB。这就是为什么数据库、日志目录、上传目录必须挂载数据卷——挂上去以后就不走联合文件系统了没有复制开销数据也不会随着容器删除而消失。5.3 基础镜像选型的分层账基础镜像的选择决定了整个镜像的地板价也决定了后面会遇到什么类型的麻烦。基础镜像体积量级包管理适合场景主要代价scratch0无静态编译的 Go/Rust 单二进制没 shell、没证书、没时区distroless几十 MB无生产环境的安全基线排查问题只能靠外部工具alpine十几 MBapk需要 shell 和小工具的场景musl libc、busybox 差异debian-slim几十 MBapt通用服务兼容性最好比 alpine 大一圈ubuntu上百 MBapt与开发环境保持一致层数多、体积大alpine 的坑值得单独说。它用的是 musl libc 而不是 glibc很多预编译好的二进制、部分 Python 依赖没有 musllinux wheel 的那些在它上面会编译失败或者运行时报奇怪的错DNS 解析的行为也和 glibc 不一样某些环境下会出现解析慢或者解析不到的情况。省下来的几十 MB有时候要用好几天排查时间来换。我的态度是只有在明确知道依赖全都能在 musl 上跑的时候才用 alpine否则 debian-slim 是更省心的选择。6. overlay2 在宿主机上的落盘结构6.1 lowerdir、upperdir、merged 与 l 目录把抽象的东西落到磁盘上一切都清楚了。在 Linux 上默认的存储驱动是 overlay2目录结构大致是这样ls /var/lib/docker/overlay2/ # 每个层一个哈希目录另有 l/ 和 backingFsBlockDev 等 ls /var/lib/docker/overlay2/hash/ # diff —— 这一层真正的内容就是相对上一层的差异 # link —— 这个层的短标识内容形如 ABCD1234 # work —— overlayfs 内部使用的临时目录 # merged —— 联合挂载点只有运行中的容器才有 ls /var/lib/docker/overlay2/l/ # 一堆短符号链接指向各个层的 diff 目录l目录的作用值得说一句overlayfs 挂载时要把所有下层目录的完整路径写进挂载选项路径一长挂载参数就可能超出内核限制。Docker 于是给每个层建一个短名字的符号链接挂载时用短路径问题就绕过去了。所以你在mount输出里看到的 lowerdir 是一串/var/lib/docker/overlay2/l/XXXX:...那不是真实存储路径是链接。一个层的diff目录里直接就能看到这一层带来的文件。想验证删文件只是加白化标记可以在容器里删掉一个文件然后到它的可写层里看一眼docker exec demo rm /usr/share/nginx/html/index.html docker container inspect demo --format {{.GraphDriver.Data.UpperDir}} ls -la 上面输出的路径 # 会看到一个 c 开头的字符设备文件名为 .wh.index.html那个0/0的字符设备就是白化文件。看到它的一刻前面讲的所有理论都会变得非常具体。6.2 写时复制都发生在什么时候写时复制copy-up不只是改文件时才发生。以下动作都会触发修改只读层里某个文件的内容哪怕只改一个字节修改只读层里文件的权限或属主对只读层里的目录做重命名以可写方式打开一个只读层里的文件比如某些程序会以读写模式打开配置。最后一条最容易出意外。有些应用启动时会尝试以读写模式打开配置文件即使它并不真的写在容器里这就会触发一次复制。文件小的时候无所谓但如果是个几百 MB 的数据文件容器刚起来磁盘就多占几百 MB。所以判断规则很简单凡是需要写的数据都挂到数据卷或者绑定挂载上别让它待在联合文件系统里。6.3 卷和绑定挂载为什么不走分层数据卷和绑定挂载在容器里的挂载点是直接叠在联合视图之上的读写直接落到宿主机目录或者 Docker 管理的卷目录不经过 overlayfs也就没有复制、没有白化、没有层增长。这也带来一个需要留心的行为差异挂载会遮住镜像里同路径的内容。镜像里/app/config有一堆默认配置你在/app/config上挂了卷那么卷里是空的容器看到的就是空的默认配置全被盖住。这个现象在新人手里经常表现为配置文件明明拷进去了为啥读不到。另外提一个实践细节绑定挂载的源路径必须是目录不能是单个文件。想挂单个文件得挂它所在的目录或者用卷配合初始化逻辑。7. 推拉镜像时到底在传什么以及几个真实踩坑的排查链路7.1 blob 复用与 none 层的来源推送日志里那句 Layer already exists 是 registry 的内容寻址在起作用它按层摘要先去查有就直接跳过。所以一个正常的增量构建推送量应该远小于镜像体积。但如果你发现每次推送量都很大通常只有三种原因一是改动命中的层很靠下上面全部重建二是基础镜像的 tag 漂移了基础层的摘要变了三是构建过程本身引入了不确定性比如把构建时间写进文件、RUN里执行了apt-get upgrade。前两种可以靠调整 Dockerfile 顺序和固定 digest 解决第三种得从构建脚本里删掉不确定的东西。none:none那些幽灵镜像的来源也是同一套逻辑同一个 tag 被新构建的镜像占用了旧镜像就失去了名字变成无 tag 的悬空镜像。它们占的空间一点没少而且因为层可能被新镜像共享不能简单粗暴地全删。用docker system df -v先看清楚可回收的量再动手。7.2 用 digest 固定基础镜像# 拿到准确的 digest docker image inspect nginx:1.25 --format {{index .RepoDigests 0}} # 在 Dockerfile 里固定 FROM nginxsha256:abcdef...固定 digest 有三个好处构建的可复现性、缓存键的稳定性、以及避免某天上游悄悄换了一个小版本导致线上行为变化。代价是安全更新不会自动进来需要定期手动升一次。这个取舍在 CI 里通常用一个自动化的依赖更新流程来平衡。7.3 私有 registry 做本地缓存构建机集群规模一上来出网带宽就是瓶颈。标准做法是在内网搭一个 registry 或者带缓存功能的制品库节点先从这里拉层缓存里没有的才回源一次。同一份基础镜像集群里只从外部取一次之后全是内网流量拉取时间从分钟级降到秒级。配套要注意的是 blob 的跨仓库复用。很多 registry 支持挂载已存在的 blob同一个层在不同仓库之间不会重复存储和传输——这点在你有几十个仓库共享同一个基础镜像时节省的空间相当可观。8. 我踩过的几个分层坑与体检流程8.1 镜像莫名变成 2.5G一条完整的排查链路年初一个 Java 服务的镜像从 800MB 涨到 2.5G构建时间也翻了三倍。我把排查过程完整记一下这套路子基本能覆盖九成的体积异常。第一步看谁最大。docker history --no-trunc myapp:latest--no-trunc一定要加不然命令会被截断你根本看不出那一层到底干了什么。输出里每行都有 SIZE 列最大的那层就是嫌疑对象。那次的结果是一条RUN apt-get update apt-get install -y ...占了 1.4G但里面装的只是几个几十 MB 的诊断工具。第二步确认是不是删了但没删掉。把那条命令完整读一遍看清理有没有写在同一层里。那次的情况是团队为了保持可读性把安装和清理拆成了两条RUN于是索引和 deb 包完整地留在了安装那一层里。第三步看层的详细内容和效率。这一步用 dive 最直观它会列出每一层新增了哪些文件、哪些是重复的、镜像效率评分是多少。那次 dive 直接指出该层有大量重复文件和一个 600MB 的临时下载目录。第四步修完验证。docker build --no-cache -t myapp:fixed . docker images myapp dive myapp:fixed --ci --lowestEfficiency0.9重建后体积回到 780MB。之后我们把 dive 的检查加进了 PR 流水线效率低于阈值就直接失败杜绝同类问题再次进主干。8.2 权限丢了、跑不起来COPY 与 USER 的顺序另一个反复出现的问题是权限。一个服务以非 root 用户运行启动时报permission denied。排查下来基本都是同一类USER app写在前面COPY写在后面。这样拷进来的文件属主是 root而运行用户是 app自然读不了。正确顺序是先拷文件并指定属主再切用户COPY --chownapp:app ./config /app/config USER app还有一类更隐蔽的宿主机上文件权限是 600拷进镜像后运行用户不是属主也读不到。这类问题在本地构建时不会暴露本地可能用 root 跑一到生产就现形。我的习惯是在本地就用--user跑一遍容器验证别把这类问题留到上线。8.3 docker commit 与就地改容器的后遗症用docker exec进容器里改配置、装工具然后docker commit存成镜像看起来效率很高实际上是把一个无法复现的过程固化了下来。几个月后你想重新构建一个同样的镜像会发现怎么都拼不出来——因为改动全都散落在那个可写层里没有任何记录。更麻烦的是体积改的过程中下载的包、写的日志、临时文件全在可写层里commit 之后它们就变成了一个巨大的新层。我见过一个用 commit 迭代了几十次的镜像层数上百体积三个 G跑起来还慢。结论很直接commit 只用来应急备份现场正式交付一律走 Dockerfile。容器里改好了什么就回到 Dockerfile 里把对应的指令补上。8.4 体检与清理的常规动作日常维护我会固定做几件事# 看整体占用和可回收空间 docker system df -v # 清理无 tag 的悬空镜像 docker image prune # 清理构建缓存但保留一定额度避免下次全量重建 docker builder prune --keep-storage 20G # 查看所有层的摘要用于比对镜像差异 docker image inspect myapp:fixed --format {{json .RootFS.Layers}}有几条经验值得写下来。docker system prune -a会连构建缓存一起清掉在构建机上一执行下一次构建就是全量通常得不偿失别随手用。悬空镜像不一定能删——如果它的层被别的镜像共享着删掉只是去掉引用空间不会立刻释放这属于正常现象。还有 inode 这个坑如果df -h显示还有空间但写入报错去看df -ioverlay2 在小文件极多或者层数极多的情况下会把 inode 吃光这种时候只能清层或者给存储目录换一块 inode 更多的盘。说到底镜像分层是个减法做不出来的东西。想瘦唯一的办法是一开始就少往里加能在同一层里清掉的就在同一层清掉能不进的依赖就别进最终镜像能挂卷的数据就别留在可写层。我现在的习惯是每次写完 Dockerfile 先用docker history扫一眼最大的三层再决定要不要动它——这个动作花十秒能省下后面几个小时的排查时间。

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

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

免费获取报价