Docker——镜像深入理解


整体说明

  • TLDR:镜像的本质是静态的只读模板
  • 镜像(Image)是一个静态的、只读的二进制文件集合 ,包含运行应用所需的代码、 runtime、库、环境变量、配置文件等所有依赖
  • 镜像的核心作用是:
    • 作为容器的”模板”:容器是镜像的运行实例(镜像 + 可写层)
    • 保证环境一致性:无论在哪个宿主机器上,基于同一镜像创建的容器都能运行相同的应用(”一次构建,到处运行”)
  • 镜像的核心特性包括
    • 只读性 :所有层不可修改,保证安全性和可复用性
    • 分层存储 :基于 UnionFS,层可共享,减少冗余
    • 轻量高效 :仅包含应用依赖,体积远小于虚拟机镜像
    • 可移植性 :镜像内容与宿主环境无关,实现”一次构建,到处运行”

镜像的分层结构:UnionFS 与 Copy-on-Write

  • Docker 镜像最核心的设计是分层存储 ,基于 Union File System(联合文件系统) 实现
  • 这种分层结构让镜像具备了”可复用、轻量、高效”的特性

分层的本质

  • 每个镜像由多个只读层(Layer) 叠加而成,每层对应镜像构建过程中的一个操作(如 RUNCOPY 等 Dockerfile 指令)
  • 层与层之间通过哈希值唯一标识(如 sha256:a1b2c3...),相同的层会被不同镜像共享(避免重复存储)
  • 例如,一个 nginx 镜像可能包含以下层:
    • 基础层:ubuntu:20.04 的底层文件系统(如 /bin/etc 等)
    • 依赖层:安装 nginx 所需的库(如 libpcre3 等)
    • 应用层:nginx 二进制文件和配置文件(如 /usr/sbin/nginx/etc/nginx/

联合挂载(Union Mount)

  • 当镜像被用于创建容器时,Docker 会将所有只读层联合挂载为一个统一的文件系统,对容器来说,这些层看起来是一个完整的目录(透明化分层细节)

Copy-on-Write(写时复制)机制

  • Copy-on-Write(写时复制)机制是镜像分层与容器交互的核心机制
  • 镜像的所有层都是只读的,容器启动时,Docker 会在镜像顶层添加一个可写层(Writable Layer)
  • 当容器需要修改文件时:
    • 若文件在底层(镜像层),会先将文件复制到可写层 ,再修改可写层的副本(底层文件不变)
    • 若文件是新创建的,直接写入可写层
  • 这种机制保证了:
    • 镜像层不会被容器修改(只读),可安全复用
    • 容器的修改仅保存在自己的可写层,不影响其他容器或镜像

Dockerfile 构建镜像与分层

  • 镜像的构建通常通过 Dockerfile 定义(而非 docker commit,后者不推荐)

  • Dockerfile 中的每一条指令都会生成一个新的只读层 ,指令与层的对应关系是理解镜像体积和优化的关键

  • 下面是一个 Dockerfile 与分层对应的示例:

    1
    2
    3
    4
    5
    6
    FROM ubuntu:20.04       # 基础层(复用 ubuntu:20.04 的所有层)
    RUN apt-get update # 层 1:执行 update 后的文件变化
    RUN apt-get install -y nginx # 层 2:安装 nginx 后的变化
    COPY nginx.conf /etc/nginx/ # 层 3:复制配置文件的变化
    EXPOSE 80 # 元数据(不生成层,仅记录信息)
    CMD ["nginx", "-g", "daemon off;"] # 元数据(容器启动命令)
  • EXPOSECMDENV 等指令仅修改镜像的元数据(保存在镜像的配置层),不生成新的文件层

  • 多条指令会生成多个层,层越多,镜像体积可能越大(需优化)


附录:镜像 ID 与 Digest的区别

  • 镜像 ID是镜像的唯一标识符(64 位哈希,通常显示前 12 位),由镜像的所有层和元数据共同计算得出
    • f9c8f87e172b(完整 ID 为 f9c8f87e172b2a4e41a73e2d685c8f...
  • Digest(摘要) :镜像内容的哈希值(基于所有层的内容计算),用于验证镜像的完整性(避免篡改)
    • 拉去影响完成时,给出的 Digest: sha256:abc123... 就是摘要,相同摘要的镜像内容一定相同

附录:Storage Driver

  • Docker 通过存储驱动管理镜像层和容器可写层的存储与联合挂载
  • 不同的存储驱动实现方式不同,主流的是 overlay2(Linux 推荐,性能最优)
  • overlay2 驱动的核心结构如下:
    • 镜像层 :存储在 /var/lib/docker/overlay2/ 下,每层对应一个目录(以层哈希命名)
    • 可写层 :容器启动时,overlay2 会创建一个新目录作为可写层,并通过”上下层”关系关联镜像层
    • 合并视图 :通过 overlay2 的联合挂载,将所有层合并为容器内看到的统一文件系统

附录:镜像的体积优化

  • 镜像体积过大会导致存储、传输和启动效率下降,优化核心是减少层数、删除冗余文件

  • 合并指令(减少层数) :多条 RUN 指令可合并为一条(用 && 连接),并清理缓存(如 apt-get clean):

    1
    2
    3
    4
    5
    6
    7
    8
    # 优化前(2 层)
    RUN apt-get update
    RUN apt-get install -y nginx

    # 优化后(1 层,且清理缓存)
    RUN apt-get update && \
    apt-get install -y nginx && \
    rm -rf /var/lib/apt/lists/* # 删除 apt 缓存
  • 多阶段构建(丢弃无用层) :用于编译型应用(如 Go、Java),仅保留运行时所需文件(下面的代码最终镜像仅包含 alpine 基础层 + 二进制文件,体积大幅减小):

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    # 阶段 1:编译(包含编译器等冗余工具)
    FROM golang:1.20 AS builder
    WORKDIR /app
    COPY . .
    RUN go build -o myapp .

    # 阶段 2:运行(仅保留编译产物)
    FROM alpine:3.18
    COPY --from=builder /app/myapp /myapp # 仅复制编译好的二进制文件
    CMD ["/myapp"]
  • 优先选择 alpine(几 MB)、slim 版本,而非完整版(如 ubuntu 完整版约 200MB,alpine 约 5MB)

  • 删除临时文件、日志、包管理缓存(如 yum clean allnpm cache clean


附录:执行 docker pull 时在发生什么?

  • 当执行 docker pull [镜像名] 时,终端会显示的多行输出,这是 Docker 拉取镜像过程的详细日志,每一行对应镜像的一个 层(Layer) 的下载或处理状态,包含层的信息
  • 如果本地已经有的层,不会再下载,且不同镜像是可以共享同一个层的(通过 ID 唯一识别)
  • 层 ID :每个层的唯一标识符(如 a1b2c3d4...
  • 操作类型
    • Pull complete:该层已成功下载并解压
    • Already exists:本地已存在该层,无需重复下载
    • Downloading:正在下载该层,会显示进度(如 50% [=====>]
    • Verifying Checksum:验证文件完整性
    • Extracting:解压下载的层文件
  • 为什么会有这么多层?
    • Docker 镜像是由多个 只读层(Layer) 叠加而成的
    • 每个层对应镜像构建过程中的一个操作(如 RUNCOPY 等指令)
    • 层具有 可复用性 :不同镜像可能共享相同的层,避免重复存储和下载
    • 层的设计让镜像更新更高效(只需更新变化的层)

附录:镜像与容器的关系:动态 vs 静态

  • 镜像 :静态、只读、多分层,是容器的”模板”
  • 容器 :动态、可写,是镜像的”运行实例”(= 镜像所有只读层 + 容器独有的可写层)
  • 可以理解为:容器 = 镜像(只读层) + 可写层(容器私有) + 容器元数据(如网络配置、环境变量等)
  • 当容器被删除时,其可写层和元数据会被清理 ,但镜像的只读层不受影响(可继续用于创建新容器)