Skip to content

Dockerfile 里删掉的文件,还在镜像里 ​

构建时需要一个访问私有仓库的令牌,Dockerfile 里先把它拷进去,用完马上 rm。启动容器检查,文件确实没有了。可是把镜像导出来解开,令牌原封不动地躺在其中一层里。另一个镜像的 Dockerfile 最后一步清理了 20 MB 的临时文件,镜像的大小一个字节都没少。

镜像不是一个文件系统的快照,而是一摞只增不改的层。后面的指令没有办法修改前面的层,它能做的只是在上面再盖一层。「删除」在这里的含义是「遮住」。

本文用五个只差几行的 Dockerfile 把这件事量出来:哪些写法会把文件留在镜像里,哪些不会,以及怎么检查一个镜像。实验见文末。

一、删除之后,文件在哪里 ​

第一个 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 rm /big.bin删除标记看不见 big.bin 了,镜像没有变小RUN dd … of=/big.bin20 MB大文件在这一层RUN rm /tmp/token.txt删除标记看不见令牌了RUN wc -c … > /build.logbuild.logCOPY token.txt /tmp/token.txt令牌文件令牌完整地留在这一层FROM busybox约 4 MB基础镜像容器里:/tmp/token.txt 与 /big.bin 都不存在;镜像大小约 24 MB,而不是 4 MBdocker save 导出后解开第 1 层,就能读到令牌的原文多阶段构建或 secret 挂载:令牌不进入最终镜像的任何一层
图 1 · 镜像的每一层只记录「相对下面一层的变化」,写好之后不再修改。后面的指令删除一个文件,做的是在新的一层里加一条删除标记,下面那一层里的文件原样还在。容器启动时把各层叠起来看,被标记的文件看不见;但镜像被拉取、导出时,每一层都完整地交到对方手里。

Dockerfile 里会改动文件系统的指令(RUN、COPY、ADD)各自产生一层,层里只记录相对于下面一层的变化。RUN rm 产生的那一层里没有文件,只有一条「这个路径已删除」的标记。容器启动时,运行时把各层叠成一个视图,被标记的路径在视图里消失;每一层本身的内容没有变。

这带来两个后果:

  • 大小只增不减。 在后面的层里清理缓存、删除安装包、压缩文件,镜像都不会变小。
  • 放进去的东西取不出来。 任何能拉取这个镜像的人,都能拿到每一层的完整内容。镜像推送到仓库之后,令牌就等于公开给了所有有拉取权限的人;公开仓库的话,是所有人。

二、怎么检查一个镜像 ​

docker history <镜像> 列出每一层对应的指令和大小。哪一层异常地大、哪条 COPY 带进了不该带的东西,从这里能看出来。

要看层里的文件,把镜像导出:

bash
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 MBCOPY 那一层
使用与删除写在同一条 RUN 里约 4 MB仍在 COPY 那一层
多阶段构建约 4 MB不在镜像里
RUN --mount=type=secret约 4 MB不在镜像里
ARG 加 --build-arg约 4 MB镜像的构建历史里

同一条 RUN 里创建并删除。 一层记录的是这条指令执行完之后的结果,中途创建又删除的文件不会留下。所以安装依赖和清理缓存要写在同一条 RUN 里:

dockerfile
RUN dd if=/dev/zero of=/big.bin bs=1M count=20 && rm /big.bin

实验里这样写之后镜像回到 4 MB。但这只对「这条 RUN 自己创建的文件」有效。令牌是 COPY 进来的,COPY 是另一条指令、另一层;在后面的 RUN 里用完就删,得到的只是一个删除标记,令牌仍在 COPY 那一层里。这是最容易误以为安全的一种写法。

多阶段构建。 在前面的阶段里随便拷贝、下载、编译,最后一个阶段从干净的基础镜像开始,只把需要的产物拷过来:

dockerfile
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 执行期间把一个文件挂进来,执行完就卸掉,文件不进入任何一层:

dockerfile
RUN --mount=type=secret,id=token wc -c /run/secrets/token > /build.log
bash
docker build --secret id=token,src=token.txt .

实验里这样构建的镜像,层和构建历史里都没有令牌。这是传递构建期密钥的首选方式。当然,如果这条 RUN 自己把密钥写进了某个文件(比如包管理器的配置),那个文件还是会进层。

构建参数。 用 ARG TOKEN 接收、--build-arg TOKEN=... 传入,令牌不会作为文件出现在任何一层里。但构建参数的值会被记进镜像配置的构建历史:

text
$ docker history --no-trunc app
RUN |1 TOKEN=example_token_0123456789 /bin/sh -c echo "$TOKEN" | wc -c > /build.log

实验里在镜像配置和 docker history 的输出里都搜到了令牌。Dockerfile 的文档明确写着不要用构建参数传递密钥。ENV 更不行,它会成为容器运行时的环境变量。

四、已经推送出去了 ​

发现镜像里有密钥时,处理的重点不是镜像,而是密钥:

  1. 立即作废并更换这个密钥。 只要镜像被拉取过,就当它已经泄露。
  2. 重新构建不含密钥的镜像,替换部署。
  3. 从仓库里删除旧的 tag 与镜像清单。这一步的作用有限:别人已经拉到本地的副本、仓库尚未回收的层、其他基于它构建的镜像都不受影响。

在旧镜像上面再加一层「删除」然后重新推送,没有任何意义。

五、构建镜像时的检查项 ​

  1. 密钥用 --mount=type=secret(或 type=ssh),不用 COPY、ARG、ENV。
  2. 最终镜像用多阶段构建产出,只包含运行需要的文件。
  3. 安装与清理写在同一条 RUN 里。
  4. 写好 .dockerignore。COPY . . 会把构建目录里的一切带进去,包括 .git、.env、本地的密钥文件和 IDE 配置。
  5. 镜像推送前看一眼 docker history,并在流水线里对镜像做密钥扫描。
  6. 运行时需要的密钥不放进镜像,由运行环境注入(Kubernetes 的 Secret、挂载的卷)。

容器看到的文件系统与镜像之间的关系,另见 容器不是轻量虚拟机;镜像的身份(tag 与摘要)见 同一个镜像 tag,两个节点上跑的不是同一个镜像。


配套实验

参考资料

文章以 CC BY-NC-SA 4.0 授权 · 代码片段以 MIT 授权