Docker Image 终极理解

以最快的时间、最小的大小构建 Docker Images 镜像的技巧和窍门。

我们要了解什么?

每当你构建 Docker Images 镜像时,比如说,你想把你的 Java/Node/Python 应用程序放入其中,你就会遇到以下两个问题:

  • 如何让 docker build 命令运行得越快越好?
  • 如何确保生成的 Docker 镜像尽可能小?

请继续阅读以获得这些问题的答案。

Docker Image 镜像层

请看下面的 Dockerfile:

FROM eclipse-temurin:17-jdk
ARG JAR_FILE=build/libs/*.jar
COPY  ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]

通过在此 Dockerfile 上运行 docker build -t myapp .,您将获得(一个)基于 Java 17 (Eclipse-Temurin) 镜像的 Docker 镜像,并包含和运行我们的 Java 应用程序(app.jar 文件)。

可能不太明显的是,Docker 文件中的每一行都会创建一个 Docker 镜像层–每个镜像都由多个这样的层组成。

您可以通过运行例如:

docker image history myapp

这将在新行上返回图像层:

IMAGE          CREATED              CREATED BY                                      SIZE      COMMENT
3ca5a60826f0   8 minutes ago   ENTRYPOINT ["java" "-jar" "/app.jar"]           0B        buildkit.dockerfile.v0
<missing>      8 minutes ago   COPY build/libs/*.jar app.jar # buildkit        19.7MB    buildkit.dockerfile.v0
<missing>      8 minutes ago   ARG JAR_FILE=build/libs/*.jar                   0B        buildkit.dockerfile.v0
... (other layers from the base image left out)

有一层用于 ENTRYPOINT 行,一层用于 COPY,一层用于 ARG

包含 app.jar 文件(COPY)的层大约有 20MB,ENTRYPOINTARG 行的元数据图层为 0B。

现在,我们该如何处理这些信息呢?

你的层很容易膨胀

想象一下,你想通过软件包管理器安装一个软件包,为此,你需要运行 apt update 来更新软件包管理器的索引。

FROM eclipse-temurin:17-jdk
RUN apt update -y
ARG JAR_FILE=build/libs/*.jar
COPY  ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]

让我们来看看生成的层(docker image history myapp),重点是最后一行(RUN /bin/sh -c...):

IMAGE          CREATED         CREATED BY                                      SIZE      COMMENT
c14a18a04751   8 seconds ago   ENTRYPOINT ["java" "-jar" "/app.jar"]           0B        buildkit.dockerfile.v0
<missing>      8 seconds ago   COPY build/libs/*.jar app.jar # buildkit        19.7MB    buildkit.dockerfile.v0
<missing>      8 seconds ago   ARG JAR_FILE=build/libs/*.jar                   0B        buildkit.dockerfile.v0
<missing>      8 seconds ago   RUN /bin/sh -c apt update -y # buildkit         45.7MB    buildkit.dockerfile.v0

哇哈!运行 apt-update 为我们生成的 Docker 镜像添加了一个新的层,高达 45.7MB。现在,每次 push 或 pull 镜像时,你都需要传输这些额外的 MB。

层是累加的

让我们继续上面的示例,再添加几条运行命令,安装最新的 mysql 软件包。

FROM eclipse-temurin:17-jdk
RUN apt update -y
RUN apt install mysql -y
RUN rm -rf /var/lib/apt/lists/*
ARG JAR_FILE=build/libs/*.jar
COPY  ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]

此外,我们还使用 rm -rf /var/lib/apt/lists/* 命令删除了 apt 索引缓存(上文提到的 45.7MB)。让我们看看image 历史记录现在是什么样子:

59f82a5b4c5a   6 seconds ago   ENTRYPOINT ["java" "-jar" "/app.jar"]           0B        buildkit.dockerfile.v0
<missing>      6 seconds ago   COPY build/libs/*.jar app.jar # buildkit        19.7MB    buildkit.dockerfile.v0
<missing>      6 seconds ago   ARG JAR_FILE=build/libs/*.jar                   0B        buildkit.dockerfile.v0
<missing>      6 seconds ago   RUN /bin/sh -c rm -rf /var/lib/apt/lists/* #…   0B        buildkit.dockerfile.v0
<missing>      7 seconds ago   RUN /bin/sh -c apt install -y mysql-server #…   605MB     buildkit.dockerfile.v0
<missing>      8 minutes ago   RUN /bin/sh -c apt update -y # buildkit         45.7MB    buildkit.dockerfile.v0

哇,那是什么?尽管我们删除了 apt 缓存文件,但 45.7MB 的层仍然存在(此外还有 605MB 的 MySQL 层)。

这是因为层是严格可加/不可变的。你当然可以从当前层中删除这些文件,但较早/以前的层仍会包含它们。

如何解决这个问题?一个简单的解决方法是在一行中运行所有三个 RUN 命令(==生成一个镜像层):

FROM eclipse-temurin:17-jdk
RUN apt update -y &&  \
    apt install -y mysql-server &&  \
    rm -rf /var/lib/apt/lists/*
ARG JAR_FILE=build/libs/*.jar
COPY  ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]

现在让我们来看看这 镜像 的历史:

IMAGE          CREATED          CREATED BY                                      SIZE      COMMENT
4b8c0f7f895a   14 seconds ago   ENTRYPOINT ["java" "-jar" "/app.jar"]           0B        buildkit.dockerfile.v0
<missing>      14 seconds ago   COPY build/libs/*.jar app.jar # buildkit        19.7MB    buildkit.dockerfile.v0
<missing>      14 seconds ago   ARG JAR_FILE=build/libs/*.jar                   0B        buildkit.dockerfile.v0
<missing>      14 seconds ago   RUN /bin/sh -c apt update -y &&      apt ins…   605MB     buildkit.dockerfile.v0

哈!我们至少暂时保住了 45.7MB 的内存。还有什么问题吗?

使其具有可重复性

理想情况下,你希望你的构建是可重现的(谁能想到呢)。如果运行 apt update,然后安装软件仓库中的任何最新软件包,就会有效地破坏可重复性,因为软件包的版本可能会在两次构建之间发生变化。

要点:

  • 只安装特定版本的软件包
  • 避免在 Dockerfile 中让软件包管理器管理–相反,构建一个新的基础镜像,并在 Dockerfile 的 FROM 中使用它。这样速度也会快很多!

层的顺序很重要

您需要确保将变化较大的层放在 Dockerfile 的底部,而较稳定的层应排在顶部。

为什么呢?因为在构建镜像时,你需要从在两次构建之间发生变化的层开始重建每一层。

举个实际例子:想象一下,您要将 index.html 文件打包到镜像中,而这个文件变化很大,也就是说比其他任何文件都要频繁。

FROM eclipse-temurin:17-jdk
COPY index.html index.html
RUN apt update -y &&  \
    apt install -y mysql-server &&  \
    rm -rf /var/lib/apt/lists/*
ARG JAR_FILE=build/libs/*.jar
COPY  ${JAR_FILE} app.jar
ENTRYPOINT ["java","-jar","/app.jar"]

你可以看到 COPY index.html index.html 行几乎添加在了 Dockerfile 的顶部。现在,每当 index.html 文件发生变化,你就需要重建所有后续层,即 _RUN apt-updateARGCOPY app.jar 层,这将耗费大量时间。在我的机器上,上述所有操作大约需要 17 秒才能完成。

不过,如果你将语句重新排序到底部,Docker 就可以重新使用之前的所有层,因为它们并没有改变。

FROM eclipse-temurin:17-jdk
RUN apt update -y &&  \
    apt install -y mysql-server &&  \
    rm -rf /var/lib/apt/lists/*
ARG JAR_FILE=build/libs/*.jar
COPY  ${JAR_FILE} app.jar
COPY index.html index.html
ENTRYPOINT ["java","-jar","/app.jar"]

现在,一个新的 docker build 只需要 0.5 秒(在我的机器上),好多了!

以下是分层的黄金法则:

  • 很少更改或需要大量时间/网络的文件(如安装新软件) → 顶部
  • 经常更改的文件(如源代码)→较低
  • ENV、CMD 等 → 底部

Docker 什么时候会重新构建图层?

每次运行 docker build 时,Docker 并不总是会重建所有的镜像层。关于 Docker 何时以及如何缓存你的图层,有一套特定的规则,你可以在官方文档中阅读。

要点是,每当你运行 Docker build 时,Docker 都会:

  • 检查 Dockerfile 中的命令是否有更改(例如,你是否将 RUN blah 改为 RUN doh)。
  • ADDCOPY 的情况下,是否有任何涉及的文件(或者说它们的校验和)发生了变化?

.dockerignore

当你运行 docker build -t <tag> .时,你的当前目录 .实际上就是所谓的build context。这意味着当前目录下的所有文件都将被压缩,并发送给本地或远程的 Docker 守护进程来执行构建。

如果你想确保某些目录永远不会被发送到你的构建守护进程,从而保持快速和小巧,你可以创建一个 .dockerignore 文件,其语法与 .gitignore 类似。

一般来说,你应该把与编译无关的文件/目录放在这里(比如你的 .git 文件夹),这在使用 COPY ./somewhere 等命令时尤为重要,因为这样一来,整个项目都会出现在生成的 image 中。

以 npm 为例:例如,你可能想在构建时运行 npm install,让它下载其依赖项,而不是(慢慢地)将你的 node_modules 文件夹复制进去,因此这也是 dockerignore 文件的一个很好的候选项。不过,如果你这么做了,还有一个技巧你一定要知道:目录缓存。

目录缓存

假设您运行 npm installpip install gradlew build 等来构建镜像。这将导致下载依赖项并创建新的镜像层。现在,如果要重建该镜像层,所有依赖项都将在下一次构建时重新下载,因为已经下载的依赖项中没有 .npm.cache.gradle 文件夹可用。

但你可以改变这种情况!让我们以 pip 为例,修改以下一行:

FROM ...
RUN pip install -r requirements.txt
CMD ...

改成:

RUN --mount=type=cache,target=/root/.cache pip install -r requirements.txt

这将告诉 Docker 在构建过程中将缓存层/文件夹(/root/.cache)挂载到容器中,在本例中,就是 pip 为根用户缓存其依赖项的文件夹。诀窍在于:这个文件夹最终不会出现在生成的映像中,但/root/.cache 会在所有后续构建中提供给 pip,这样你就能获得不错的速度!

NPM、Gradle 或其他软件包管理器也是如此。只需确保指定正确的目标文件夹即可。

什么是多阶段构建?

即将推出。

结束

这篇文章应该已经让你很好地掌握了 Docker 镜像的基础知识。如果您有任何疑问或其他意见,请在下面的评论区发表。

致谢和参考文献

感谢 Maarten Balliauw、Andreas Eisele 的评论/更正/讨论。

本文文字及图片出自 Docker Images - Finally Understandable

你也许感兴趣的:

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注