构建Docker几个小技巧

每当您构建 Docker 映像时,例如,您想要将 Java/Node/Python 应用程序整合为一个,您都会遇到以下两个问题:

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

Docker 镜像层 101
看看下面的内容Dockerfile。

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

通过运行docker build -t myapp .此 Dockerfile,您将获得(一个)Docker镜像,该镜像将基于 Java 17 (Eclipse-Temurin) 镜像,并包含并运行我们的 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

哇哈! Runningapt-update为我们生成的 Docker 镜像添加了一个大小高达 45.7MB 的新层。

现在,每次推送或拉取镜像时,您都需要传输这些额外的兆​​字节。

图层是相加的
让我们继续上面的示例并添加更多运行命令,以安装最新的 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)。让我们看看我们的镜像历史现在是什么样子:

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然后安装存储库中的任何最新软件包,您实际上会破坏这种可重复性,因为软件包版本可能会在构建之间发生变化。

要旨:

  1. 仅安装您要安装的特定版本
  2. 首先避免在 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 都会:

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

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

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

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

以 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 或其他软件包管理器也是如此。只需确保指定正确的目标文件夹即可。