Docker Image 终极理解

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

以最快的时间、最小的大小构建 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,ENTRYPOINT 和 ARG 行的元数据图层为 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-update、ARG 和 COPY 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)。
  • 在 ADD 或 COPY 的情况下,是否有任何涉及的文件(或者说它们的校验和)发生了变化?

.dockerignore

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

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

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

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

目录缓存

假设您运行 npm install、pip 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,由 WebHek 分享。

发表回复

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


京ICP备12002735号