Dockerfile 里删掉的文件,还在镜像里
构建时需要一个访问私有仓库的令牌,Dockerfile 里先把它拷进去,用完马上
rm。启动容器检查,文件确实没有了。可是把镜像导出来解开,令牌原封不动地躺在其中一层里。另一个镜像的 Dockerfile 最后一步清理了 20 MB 的临时文件,镜像的大小一个字节都没少。
镜像不是一个文件系统的快照,而是一摞只增不改的层。后面的指令没有办法修改前面的层,它能做的只是在上面再盖一层。「删除」在这里的含义是「遮住」。
本文用五个只差几行的 Dockerfile 把这件事量出来:哪些写法会把文件留在镜像里,哪些不会,以及怎么检查一个镜像。实验见文末。
一、删除之后,文件在哪里
第一个 Dockerfile 是最直观的写法:
FROM busybox
COPY token.txt /tmp/token.txt
RUN wc -c /tmp/token.txt > /build.log # 代表「用令牌做了点什么」
RUN rm /tmp/token.txt
RUN dd if=/dev/zero of=/big.bin bs=1M count=20
RUN rm /big.bin构建之后:
| 观察方式 | 结果 |
|---|---|
启动容器,查看 /tmp/token.txt 与 /big.bin | 都不存在 |
| 镜像大小 | 约 24 MB(基础镜像约 4 MB) |
docker save 导出后逐层查看 | 第 1 层里有 tmp/token.txt,内容就是令牌;第 4 层里有 20 MB 的 big.bin |
Dockerfile 里会改动文件系统的指令(RUN、COPY、ADD)各自产生一层,层里只记录相对于下面一层的变化。RUN rm 产生的那一层里没有文件,只有一条「这个路径已删除」的标记。容器启动时,运行时把各层叠成一个视图,被标记的路径在视图里消失;每一层本身的内容没有变。
这带来两个后果:
- 大小只增不减。 在后面的层里清理缓存、删除安装包、压缩文件,镜像都不会变小。
- 放进去的东西取不出来。 任何能拉取这个镜像的人,都能拿到每一层的完整内容。镜像推送到仓库之后,令牌就等于公开给了所有有拉取权限的人;公开仓库的话,是所有人。
二、怎么检查一个镜像
docker history <镜像> 列出每一层对应的指令和大小。哪一层异常地大、哪条 COPY 带进了不该带的东西,从这里能看出来。
要看层里的文件,把镜像导出:
docker save app:1.0 -o app.tar
tar -xf app.tar # 解开后有 manifest.json 与各层的归档
tar -tf blobs/sha256/<某一层> # 列出这一层的文件;以 .wh. 开头的条目是删除标记manifest.json 里按从下到上的顺序列出了各层。实验里对每一层做了两件事:列出文件,并在内容里搜索令牌。对第一个 Dockerfile,搜索在第 1 层命中。
镜像扫描工具(查漏洞的、查密钥的)也是逐层读取的,所以它们能发现「已经删除」的密钥。但不要反过来把「扫描没报」当成没有。
三、几种写法的结果
五个 Dockerfile 的差别只在令牌和临时文件的处理方式上:
| 写法 | 镜像大小 | 令牌在哪里 |
|---|---|---|
分开的 COPY、RUN、RUN rm | 约 24 MB | COPY 那一层 |
使用与删除写在同一条 RUN 里 | 约 4 MB | 仍在 COPY 那一层 |
| 多阶段构建 | 约 4 MB | 不在镜像里 |
RUN --mount=type=secret | 约 4 MB | 不在镜像里 |
ARG 加 --build-arg | 约 4 MB | 镜像的构建历史里 |
同一条 RUN 里创建并删除。 一层记录的是这条指令执行完之后的结果,中途创建又删除的文件不会留下。所以安装依赖和清理缓存要写在同一条 RUN 里:
RUN dd if=/dev/zero of=/big.bin bs=1M count=20 && rm /big.bin实验里这样写之后镜像回到 4 MB。但这只对「这条 RUN 自己创建的文件」有效。令牌是 COPY 进来的,COPY 是另一条指令、另一层;在后面的 RUN 里用完就删,得到的只是一个删除标记,令牌仍在 COPY 那一层里。这是最容易误以为安全的一种写法。
多阶段构建。 在前面的阶段里随便拷贝、下载、编译,最后一个阶段从干净的基础镜像开始,只把需要的产物拷过来:
FROM busybox AS build
COPY token.txt /tmp/token.txt
RUN wc -c /tmp/token.txt > /build.log
FROM busybox
COPY --from=build /build.log /build.log最终镜像只包含最后一个阶段的层。实验里它只有 1 层自己的内容(build.log),任何一层都搜不到令牌。它同时解决了大小问题:编译器、构建缓存、源代码都留在构建阶段。要注意构建阶段的层仍然在构建机的缓存里,也不要把中间阶段的镜像推送出去。
构建时的 secret 挂载。 BuildKit 可以在某一条 RUN 执行期间把一个文件挂进来,执行完就卸掉,文件不进入任何一层:
RUN --mount=type=secret,id=token wc -c /run/secrets/token > /build.logdocker build --secret id=token,src=token.txt .实验里这样构建的镜像,层和构建历史里都没有令牌。这是传递构建期密钥的首选方式。当然,如果这条 RUN 自己把密钥写进了某个文件(比如包管理器的配置),那个文件还是会进层。
构建参数。 用 ARG TOKEN 接收、--build-arg TOKEN=... 传入,令牌不会作为文件出现在任何一层里。但构建参数的值会被记进镜像配置的构建历史:
$ docker history --no-trunc app
RUN |1 TOKEN=example_token_0123456789 /bin/sh -c echo "$TOKEN" | wc -c > /build.log实验里在镜像配置和 docker history 的输出里都搜到了令牌。Dockerfile 的文档明确写着不要用构建参数传递密钥。ENV 更不行,它会成为容器运行时的环境变量。
四、已经推送出去了
发现镜像里有密钥时,处理的重点不是镜像,而是密钥:
- 立即作废并更换这个密钥。 只要镜像被拉取过,就当它已经泄露。
- 重新构建不含密钥的镜像,替换部署。
- 从仓库里删除旧的 tag 与镜像清单。这一步的作用有限:别人已经拉到本地的副本、仓库尚未回收的层、其他基于它构建的镜像都不受影响。
在旧镜像上面再加一层「删除」然后重新推送,没有任何意义。
五、构建镜像时的检查项
- 密钥用
--mount=type=secret(或type=ssh),不用COPY、ARG、ENV。 - 最终镜像用多阶段构建产出,只包含运行需要的文件。
- 安装与清理写在同一条
RUN里。 - 写好
.dockerignore。COPY . .会把构建目录里的一切带进去,包括.git、.env、本地的密钥文件和 IDE 配置。 - 镜像推送前看一眼
docker history,并在流水线里对镜像做密钥扫描。 - 运行时需要的密钥不放进镜像,由运行环境注入(Kubernetes 的 Secret、挂载的卷)。
容器看到的文件系统与镜像之间的关系,另见 容器不是轻量虚拟机;镜像的身份(tag 与摘要)见 同一个镜像 tag,两个节点上跑的不是同一个镜像。
配套实验
- codesphere-labs/kubernetes/image-layers-and-deleted-files:Docker 29.8.0 上用同一个基础镜像构建五种写法的镜像,记录大小、容器里可见的文件、
docker save导出后每一层的文件与删除标记、哪些层含有令牌,以及镜像配置与docker history里是否含有令牌(验证记录)
参考资料