上周帮同事定位一个构建问题:业务镜像 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 的索引实体还完整地待在第一条指令生成的那层里,删除只是加了一个白化标记。最终镜像体积 = 第一层的全部内容 + 第二层的一堆白化标记。
正确写法是把它们放进同一条RUN:
RUN apt-get update \ && apt-get install -y --no-install-recommends curl \ && rm -rf /var/lib/apt/lists/*同一条RUN里的所有操作在同一个层里完成,文件被写进去又被删掉,这个过程不会被记录进层内容——最终这一层里就没有那堆索引。这就是"分层"给我们上的第一课:清理必须和写入在同一层里完成。
2. 镜像、层、容器可写层:组成结构与三个容易被搞混的 ID
2.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 ID | config JSON 的内容摘要 | docker images里显示的短 ID,同一份构建产物 ID 相同 |
| layer digest | 单个层 tar 的摘要(压缩/未压缩两种) | registry 按它去重,也是推送日志里那串sha256:... |
| manifest digest | 整个 manifest 的摘要 | docker pull xxx@sha256:...用的就是它,最稳定 |
共同点是内容寻址:内容一样,摘要就一样。这个特性是"层可以跨镜像、跨仓库复用"的根本原因——registry 只认内容,不认名字。tag 只是一个指向摘要的可变标签,随时可以被覆盖,而摘要一旦确定,指向的内容永远不会变。
理解这一层之后,一个很实用的运维习惯就顺理成章了:生产环境的基础镜像别只写nginx:1.25,那是个会漂移的 tag;写成nginx@sha256:...,今天构建和三个月后构建拿到的完全是同一份内容。这能省掉很多"我本地是好的,线上跑不起来"的沟通成本。
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/lists,pip 是~/.cache/pip,npm 是~/.npm,Maven 是~/.m2/repository,yum 是/var/cache/yum。这些目录动辄几百 MB,而且它们全都在镜像的可写层以外的位置——也就是它们真的会被打包进镜像。
一个真实的排查例子:某个 Python 服务的镜像 2.1G,docker 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 --mount=type=cache,target=/root/.cache/pip \ pip install -r requirements.txt方案二在大项目里优势明显:缓存目录被挂载到构建缓存区,既不算进镜像体积,又能在下次构建时命中,装依赖的时间常常从几分钟压到几十秒。这也是 BuildKit 相比经典构建器最值得切换的理由之一。
4. 构建缓存失效的真实判断逻辑
4.1 缓存键是"父层 + 指令字符串"
经典构建器的缓存判定规则很朴素:对于RUN,缓存键是父层 ID 加上这条命令的字符串本身。注意,是字符串本身,不是执行结果。
这解释了一个经典坑。下面这种写法在某次构建里跑得好好的,过两周重新构建就报 404:
RUN 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 --mount=type=cache,target=...让下载缓存脱离镜像层,既不影响体积也不影响缓存命中。
在 CI 环境下还有一步:把构建缓存显式带进带出。BuildKit 支持把缓存元数据内联到镜像里(--build-arg BUILDKIT_INLINE_CACHE=1),配合--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_ENABLED=0 GOOS=linux go build -ldflags="-s -w" -o /out/server ./cmd/server FROM gcr.io/distroless/static-debian12 COPY --from=builder /out/server /server USER nonroot:nonroot ENTRYPOINT ["/server"]最终镜像里只有那个二进制和 distroless 的基础文件,编译阶段产生的几千层、好几个 G 的内容全都被丢掉了——注意,是"没有被引用",所以不会出现在最终镜像里,也不会被推送。
这里的CGO_ENABLED=0是必须要写的。默认开启 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 --chown=app:app ./app /app一次性到位,不产生额外的复制层。同理,RUN chmod -R、RUN chown -R这类递归操作能避免就避免,实在需要的话尽量放在文件还很小的阶段做。
顺带说一句写时复制的粒度问题:它是文件级的,不是块级的。容器里改一个 1GB 文件里的一个字节,overlayfs 会把整个 1GB 复制到可写层,同时磁盘上多出 1GB。这就是为什么数据库、日志目录、上传目录必须挂载数据卷——挂上去以后就不走联合文件系统了,没有复制开销,数据也不会随着容器删除而消失。
5.3 基础镜像选型的分层账
基础镜像的选择决定了整个镜像的"地板价",也决定了后面会遇到什么类型的麻烦。
| 基础镜像 | 体积量级 | 包管理 | 适合场景 | 主要代价 |
|---|---|---|---|---|
| scratch | 0 | 无 | 静态编译的 Go/Rust 单二进制 | 没 shell、没证书、没时区 |
| distroless | 几十 MB | 无 | 生产环境的安全基线 | 排查问题只能靠外部工具 |
| alpine | 十几 MB | apk | 需要 shell 和小工具的场景 | musl libc、busybox 差异 |
| debian-slim | 几十 MB | apt | 通用服务,兼容性最好 | 比 alpine 大一圈 |
| ubuntu | 上百 MB | apt | 与开发环境保持一致 | 层数多、体积大 |
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 nginx@sha256: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 --lowestEfficiency=0.9重建后体积回到 780MB。之后我们把 dive 的检查加进了 PR 流水线,效率低于阈值就直接失败,杜绝同类问题再次进主干。
8.2 权限丢了、跑不起来:COPY 与 USER 的顺序
另一个反复出现的问题是权限。一个服务以非 root 用户运行,启动时报permission denied。排查下来基本都是同一类:USER app写在前面,COPY写在后面。这样拷进来的文件属主是 root,而运行用户是 app,自然读不了。
正确顺序是先拷文件并指定属主,再切用户:
COPY --chown=app: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 -i,overlay2 在小文件极多或者层数极多的情况下会把 inode 吃光,这种时候只能清层或者给存储目录换一块 inode 更多的盘。
说到底,镜像分层是个"减法做不出来"的东西。想瘦,唯一的办法是一开始就少往里加:能在同一层里清掉的就在同一层清掉,能不进的依赖就别进最终镜像,能挂卷的数据就别留在可写层。我现在的习惯是,每次写完 Dockerfile 先用docker history扫一眼最大的三层,再决定要不要动它——这个动作花十秒,能省下后面几个小时的排查时间。