ᕕ( ᐛ )ᕗ Jimyag's Blog

Docker 镜像存储与 Dockerfile 转镜像

· 502 字 · 约 3 分钟

本文是一篇介绍性文档,基于 Docker 存储驱动总览OverlayFS 存储驱动 以及构建相关官方文档整理,面向想了解“镜像在磁盘上如何实现、Dockerfile 如何变成镜像、哪些关键字会多占存储”的读者。内容涵盖:镜像存储实现、Dockerfile 转镜像与指令对照、容器可写层、单层过大(如 ADD 500G)的风险、构建过程是否计算分层信息、以及构建与存储相关官方文档索引。

说明:Docker Engine 29.0 及以后新装默认使用 containerd 的镜像存储(overlayfs snapshotter),不再使用本文描述的经典存储驱动;若你已迁移到 containerd 存储,镜像分层与 CoW 的概念仍然适用,仅底层路径与实现细节不同。

镜像在宿主机上如何存储

Docker 镜像由多层只读“层”组成,每层是相对下一层的文件系统差异。容器在运行时在这些只读层之上增加一个可写层;存储驱动负责把这些层在宿主机上具体落盘并组装成容器看到的根文件系统。

存储驱动与卷的区别

  • 存储驱动:管理的是镜像层和容器的可写层。可写层在容器删除后会消失,适合临时、运行时产生的数据。驱动会做写时复制(CoW)等优化,但写性能通常不如直接写宿主机文件系统。
  • 卷(Volume):独立于容器生命周期的宿主机目录或卷,不经过存储驱动的 CoW,适合写多、需持久化或跨容器共享的数据。写密集型应用(如数据库)应尽量用卷而不是往容器层写。

overlay2 的原理

overlay2 是 Docker 使用的存储驱动,底层依赖 Linux 内核的 OverlayFS。OverlayFS 是一种联合文件系统(union filesystem):把多个目录“叠”在一起,对外呈现为一个统一目录,而不需要把下层数据完整复制一份。

  • 两层角色:OverlayFS 把目录分为只读的“下层”(lower)和可写的“上层”(upper)。对 Docker 而言,镜像的若干只读层共同组成 lower(可有多层,overlay2 最多约 128 层),容器运行时新增的可写层是 upper。合并后的视图挂在一个单独目录上,即 merged;容器进程看到的根文件系统就是这个 merged 目录。
  • 读文件:先在上层找,有则直接读;没有则到下层找。读操作不复制数据,因此多容器共享同一镜像时,只读层在磁盘上只存一份。
  • 写已有文件:下层是只读的,不能直接改。第一次对某个已存在于下层的文件进行写时,内核会先把该文件从下层复制到上层(即 copy_up),再在上层完成写入。复制是按整个文件进行的,所以大文件第一次写会有明显延迟;之后对该文件的修改都在上层,不再触发 copy_up。
  • 新建文件:直接写在上层。
  • 删除文件:下层不能改,无法真正“删掉”下层的文件。做法是在上层放一个“白 out”(whiteout)标记,表示该路径在合并视图中被删除,容器里就看不到这个文件了;下层里的数据仍在,只是被屏蔽。
  • 目录重命名:OverlayFS 对 rename(2) 有限制,例如仅当源和目标都在上层时才允许目录重命名,否则会返回 EXDEV,应用需要自己用“复制再删除”等方式处理。

因此 overlay2 的原理可以概括为:用“下层只读 + 上层可写”的联合挂载,实现多镜像层共享与容器可写层的隔离;通过 copy_up 和 whiteout 在不动下层的前提下,对容器呈现可读写视图。

层在磁盘上的位置(overlay2)

以常用的 overlay2 驱动为例,数据在 Linux 上通常位于 /var/lib/docker/overlay2/(Docker Engine 29+ 新装可能默认用 containerd 的 snapshotter,路径与实现会不同)。

该目录下每个子目录对应一层,目录名为该层的长 ID。此外有一个名为 l(小写 L)的目录,里面是短 ID 的符号链接,指向各层的 diff 目录;mount 时用短 ID 可避免 lowerdir 参数超过内核页大小限制。最底层目录内有 link(存短 ID 名)和 diff(该层内容,如根文件系统的 bin、etc 等);往上各层除 difflink 外还有 lower(指向下层的短 ID 路径)、merged(该层与所有下层的合并视图)、work(OverlayFS 内部使用)。

overlay2 使用内核的 OverlayFS:只读的镜像层对应 lowerdir,容器的可写层对应 upperdir,合并后的视图在 merged。overlay2 最多支持约 128 个下层,便于多阶段构建和复杂镜像。注意:/var/lib/docker/ 下的文件由 Docker 管理,不要直接修改。

写时复制(CoW)与容器写操作

读文件时,若上层没有该文件,则直接读下层,不复制。第一次对某文件进行写或删除时,存储驱动会做“写时复制”:

  • 写已有文件:先把该文件从只读层复制到可写层(overlay2 里叫 copy_up),再在可写层修改。复制是按文件为单位,大文件第一次写会有明显延迟。
  • 删文件:在可写层记录“白 out”等标记,逻辑上对容器隐藏下层文件,下层内容不变。
  • 新增文件:直接写在可写层。

因此,只有“会改变文件系统内容”的 Dockerfile 指令才会在构建时产生新的镜像层;仅改元数据的指令不会多出一层,也不会多占一层存储。

Dockerfile 如何转换为镜像

构建时,Docker 按顺序执行 Dockerfile 中的指令。每条会改变文件系统内容的指令会在当前镜像栈顶增加一个新的只读层;其余指令只更新镜像的元数据(如默认命令、环境变量、暴露端口等),不产生新层。最终镜像 = 若干只读层 + 一份元数据(镜像配置、历史等)。

会产生新存储层的指令

以下指令会修改文件系统或引入新的层,因此会增加镜像的存储(至少一层):

指令 说明
FROM 引入基础镜像的全部层;若本地没有则先拉取,这些层都会成为新镜像的一部分。
RUN 在临时容器里执行命令,把产生的文件系统变更提交为一个新层。
COPY 把构建上下文中的文件/目录复制进镜像,产生新层。
ADD 与 COPY 类似,且支持 URL 与归档解压,同样产生新层。

同一 Dockerfile 里多次 RUN、COPY、ADD 会对应多个层;层数越多,镜像越大、构建与拉取时的元数据也越多,因此实践中常合并 RUN、用 .dockerignore 减少 COPY/ADD 内容。

不产生新存储层的指令(仅元数据)

以下指令只修改镜像的配置或运行时行为,不向镜像增加新的文件系统层,因此不会多占“层”的存储:

指令 说明
LABEL 设置镜像元数据(如作者、版本),仅写入镜像配置。
ENV 设置环境变量,写入镜像配置,容器运行时可见;不新增层。
EXPOSE 声明监听端口,仅元数据。
CMD 指定容器默认执行的命令或参数,仅元数据。
ENTRYPOINT 指定容器入口程序,仅元数据。
USER 指定后续 RUN/CMD/ENTRYPOINT 使用的用户,仅元数据。
WORKDIR 设置工作目录,仅元数据。
VOLUME 声明挂载点,不往镜像里写数据,仅元数据。
STOPSIGNAL 设置停止容器时发送的信号,仅元数据。
HEALTHCHECK 配置健康检查命令与参数,仅元数据。
SHELL 指定 RUN/CMD/ENTRYPOINT 使用的 shell,仅元数据。
ONBUILD 把指令推迟到“以本镜像为 base 再构建”时执行,仅记录在元数据中。
MAINTAINER 已废弃,等价于 LABEL,仅元数据。

注意:例如在 RUN 里用 rm 删文件,会生成新层,该层里记录“删除”这一差异,但被删文件仍存在于下层,总镜像大小可能不会减少,因此要做镜像体积优化时应在同一层内“生成再删”或使用多阶段构建。

简单对照示例

下面这段 Dockerfile:

1
2
3
4
5
6
FROM ubuntu:22.04
LABEL org.opencontainers.image.authors="[email protected]"
COPY . /app
RUN make /app
RUN rm -r $HOME/.cache
CMD python /app/app.py
  • FROM:引入 ubuntu:22.04 的所有层。
  • LABEL:不产生层。
  • COPY:产生一层,包含当前上下文中的文件。
  • 第一个 RUN:产生一层(make 生成的文件)。
  • 第二个 RUN:再产生一层(删除 .cache 的差异;下层里 .cache 仍在,总大小可能几乎不变)。
  • CMD:不产生层,只设默认命令。

最终镜像 = 基础镜像层 + COPY 层 + 两个 RUN 层,外加一份包含 LABEL、CMD 等的元数据。

容器相对镜像多出来的“可写层”

运行容器时,会在镜像所有只读层之上加一个薄的可写层。容器内新建、修改、删除文件都发生在这一层;容器删除后,该层被丢弃,镜像不变。多容器共享同一镜像时,只读层在磁盘上只存一份,每个容器各自拥有一份可写层。查看容器占用时可使用 docker ps -ssize 为可写层大小,virtual size 为只读镜像 + 可写层。

单层体积过大的风险(例如 ADD 500G)

制作一个层的过程

以 ADD/COPY 为例,构建时“生成这一层”大致会经历以下步骤;RUN 会多一步“在临时容器里执行命令”,但最后同样要提交可写层并计算 digest。

  1. 准备构建上下文(仅影响 COPY 与从本地上下文取文件的 ADD)
    客户端执行 docker build 时,会把构建上下文(默认当前目录及子目录)打包并发送给 builder(Docker daemon 或远程 BuildKit)。上下文里的所有文件都会参与这次上传;若包含 500G 文件,则构建一开始就要传输约 500G。ADD 若源为 URL,则由 builder 在构建过程中下载,不占上下文上传,但下载量仍为 500G。

  2. 将内容写入新层
    builder 执行 ADD/COPY 时,会把要加入镜像的文件写入“新层”的存储位置。在 overlay2 上,即在新创建的层目录下写入 diff 等结构,相当于把 500G 数据写入一次磁盘。RUN 则是先在临时容器的可写层里产生变更,再把这些变更作为新层提交,同样是一次完整写入。

  3. 计算层的 content digest(分层信息)
    层写入完成后,builder 需要为该层生成唯一标识(diff ID),用于镜像 manifest、拉取与缓存。该标识基于层“内容”计算(例如对层内容做 tar 流再哈希,或按块哈希)。因此需要再次读取刚写入的层内容(对 500G 而言即读取约 500G),并做哈希运算,带来大量 I/O 和 CPU 时间。

  4. 打包、压缩与持久化(推送或保存时)
    层在推送到仓库或 docker save 导出时,通常会先打成 tar 包,再做压缩(常见为 gzip)。即:层内容 → tar 打包 → 压缩 → 得到层的 blob,再对该 blob 计算 digest 用于 registry。因此大层在推送/保存时还会经历一次 tar 与压缩的 I/O;拉取或加载时则要解压并解包。500G 的层即使压缩后体积变小,打包与解包仍要读写大量数据。

  5. 记录到镜像与缓存
    计算得到的 digest 与层元数据被写入镜像 manifest;若启用构建缓存,该层也会被缓存,供后续构建复用(前提是缓存校验命中,ADD/COPY 的缓存校验基于文件元数据,见 Build cache invalidation 文档)。

因此,单层内包含 500G 时,至少会有:一次约 500G 的传输(或下载)、一次约 500G 的写入、一次约 500G 的读取与哈希;推送/拉取或 save/load 时还有 tar 打包与(通常)gzip 压缩/解压带来的 I/O。每一步都会放大耗时与资源占用。

带来的风险小结

  • 镜像与层体积:该层和镜像总大小会包含这 500G。即使用后续 RUN 删除该文件,删除只记录在更上层,本层内容不变,镜像仍约 500G。
  • 构建阶段:构建上下文会先发给 builder;若大文件在上下文内,每次构建都会上传约 500G,耗时长、占带宽与磁盘。
  • 推送与拉取:该层需要完整传输,仓库存储与拉取时间都会显著增加。
  • 写时复制:容器首次写该文件时,overlay2 等驱动会对整个文件做 copy_up,可能再占约 500G 并产生长时间 I/O。
  • 分层信息计算:如上所述,构建时会对该层内容计算基于内容的标识,需要对层内容做哈希,500G 会参与读取与运算,构建会卡在这一层很久。

更稳妥的做法是:需要大体积数据时优先用运行时挂载(Volume 或 bind mount),而不是把大文件做进镜像层;若仅在构建阶段需要,可在一层内“下载或解压 → 使用 → 删除”,避免把 500G 永久留在某一层。

构建过程是否会计算分层信息

会。每一层在构建完成后会有一个基于内容的唯一标识(diff ID / content digest),用于镜像 manifest、拉取与缓存。该标识由该层的“内容”计算得出(例如对层内容做 tar 再哈希,或按块哈希),而不是只算元数据。因此若某一层包含 500G 文件,构建过程在写出该层后,还会读取该层内容以计算该哈希,即会“计算这 500G 数据的分层信息”,带来额外 I/O 与耗时。

与“缓存是否失效”区分开:ADD/COPY 的缓存校验用的是“文件元数据”的 checksum(见官方 Build cache invalidation 文档),用于决定是否复用已有层;而层的 content digest 是在层被创建/提交时,对整层内容做哈希得到的,用于标识和传输该层。

构建与存储相关官方文档

以下为 Docker 官方文档,可作为“构建过程、分层、缓存、上下文、存储”的参考。

  • Docker Build:构建功能总览与入口。
  • Understanding image layers:镜像由多层文件系统变更组成、层不可变、联合视图与可写层;可用 docker image history 查看每层。
  • Build cache (layers):每条指令对应一层、某层变化后该层及后续层需重建、缓存失效基本规则。
  • Build context:构建上下文是什么、本地/远程/Git/tarball、上下文会发给 builder、.dockerignore 用法;与“大文件是否在构建时被传输”直接相关。
  • Build cache invalidation:ADD/COPY 等基于文件元数据计算 cache checksum、RUN 缓存规则等。
  • Storage drivers:镜像层与可写层在宿主机上的存储、CoW。
  • OverlayFS storage driver:overlay2 的目录结构、copy_up、限制等。
  • Select a storage driver:如何选择与配置存储驱动。

关于“层身份是否由内容哈希得到”:官方“Understanding image layers”中写明层采用 content-addressable storage;镜像规范(如 OCI)中层的 digest 即对层内容的哈希。因此“构建过程会计算分层信息(内容哈希)”有文档与规范依据,具体算法在实现(如 BuildKit)中。

参考链接:

#Docker #Storage #Overlayfs #Dockerfile