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,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