我是如何将 Java 基础 docker 镜像大小从 674Mb 优化到 58Mb的
如果您是 Java 开发人员,并且正在使用 Docker 打包应用程序,您可能已经注意到,即使是"hello world"类型的项目,最终镜像的大小也可能非常大。在本文中,我们将介绍一些优化 Java 应用程序的 Docker 镜像大小的技巧。
我们将使用上一篇文章《使用 RFC-9457 规范在 Spring web 中处理错误》中构建的相同 Spring web 应用程序来演示这些技巧。我们的应用程序仅包含 2 个端点:
- GET /users/:id :通过 id 获取用户
- POST /users:创建新用户
Controller.java
java
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
@GetMapping("{id}")
public User getUser ( @PathVariable Long id) {
return userService.getUserById(id)
.orElseThrow(() -> new UserNotFoundException (id, "/api/users" ));
}
@PostMapping
public User createUser ( @Valid @RequestBody User user) {
return userService.createUser(user);
}
不是太多吧?但正如你所看到的,最简单的docker镜像(没有应用一些优化)的大小可能相当大。
不是太多吧?但正如你所看到的,最简单的docker镜像(没有应用一些优化)的大小可能相当大。
本文的源代码可以在github上找到
为什么我们应该关心镜像大小?
镜像大小会对您的绩效产生重大影响,无论是作为开发人员还是组织。特别是当您在处理包含多项服务的大型项目时,镜像的大小可能会非常大,这可能会花费您大量的金钱和时间。
应避免使用大镜像的原因如下:
- 磁盘空间:您正在浪费 Docker 注册表和生产服务器中的磁盘空间。
- 构建速度较慢:镜像越大,构建和推送镜像所需的时间越长。
- 安全性:镜像越大,依赖关系越大,攻击面就越大。
- 带宽:镜像越大,从注册表拉取和推送镜像时消耗的带宽就越大。
使用简单的 Dockerfile
基础镜像很重要 ✌🏽 :选择正确的基础镜像
在开始考虑优化之前,你应该始终小心用于打包应用程序的基础镜像。你选择的基础镜像会对最终镜像的大小产生重大影响(如下所示)。
您可以使用多个基础镜像来打包 Java 应用程序,其中一些是:
- JDK Alpine 基础镜像 :这些镜像体积相当小,但并不适合所有应用程序,因此您可能会遇到与某些库的兼容性问题。
- JDK Slim 基础镜像 :这些镜像基于 Debian 或 Ubuntu,与完整的 JDK 镜像相比,它们的尺寸非常小,但仍然相当大。
- JDK 完整基础镜像 :这些镜像尺寸相当大,它们包含运行应用程序所需的所有模块和依赖项。
openjdk:17-jdk-slim
为了让您对基础镜像的大小有一个概念,这里是(slim) 和 eclipse-temurin:17-jdk-alpine
(alpine) 镜像大小的比较:
知道应用程序工件(jar)的大小为:~20MB
openjdk:17-jdk-slim
为了让您对基础镜像的大小有一个概念,这里是(slim) 和 eclipse-temurin:17-jdk-alpine
(alpine) 镜像大小的比较:
知道应用程序工件(jar)的大小为:~20MB
将我们的工件打包到docker镜像中,我们需要 Dockefile
在应用程序根目录中定义如下:
使用 openjdk:17-jdk-slim 作为基础镜像。
Dockerfile.base-openjdk
bash
FROM openjdk: 17 -jdk-slim
# 设置容器中的工作目录
WORKDIR /app
# 创建用户
RUN addgroup -- system spring && adduser -- system spring --ingroup spring
# 更改为用户
USER spring:spring
COPY target/*.jar app.jar
EXPOSE 8080
CMD [ "java" , "-jar" , "app.jar" ]
定义Dockerfile后,我们可以使用以下命令构建镜像:
docker build-t 用户服务。
此后,你应该有一个名为的 docker 镜像 user-service
,你可以看到,与应用程序工件的大小相比,镜像的大小相当大,大约为 674MB
等什么🤯!!然而,这只是一个具有 2 个端点且没有依赖关系的小项目,那么具有十几个依赖项和文件的应用程序怎么办呢?
使用 eclipse-temurin:17-jdk-alpine 作为基础镜像。
Dockerfile.base-temurin
bash
FROM eclipse-temurin:17-jdk-alpine
ARG APPLICATION_USER=spring
# 创建用户来运行应用程序,不要以 root 身份运行
RUN addgroup --system $APPLICATION_USER && adduser --system $APPLICATION_USER --ingroup $APPLICATION_USER
# 创建应用程序目录
RUN mkdir /app && chown -R $APPLICATION_USER /app
# 设置用户来运行应用程序
USER $APPLICATION_USER
# 将 jar 文件复制到容器
COPY -- chown = $APPLICATION_USER : $APPLICATION_USER target/*.jar /app/app.jar
# 设置工作目录
WORKDIR /app
# 公开端口
EXPOSE 8080
# 运行应用程序
ENTRYPOINT [ "java" , "-jar" , "/app/app.jar" ]
使用以下命令构建镜像后:
bash
docker build -t 用户服务:alpine -f Dockerfile.base-alpine . --platform=linux/amd64
🚨 附注
重要提示:如果您在 Apple Silicon 上使用 MAC,则在构建映像时可能会遇到以下问题:
[internal] 加载 docker.io/library/eclipse-temurin:17-jdk-alpine 的元数据:
Dockerfile:2
1 | # 第一阶段,构建自定义 JRE
2 | >>> FROM eclipse-temurin:17-jdk-alpine AS jre-builder
3 |
4 | # 安装 jlink 所需的 binutils
错误:无法解决:eclipse-temurin:17-jdk-alpine:清单中的平台不匹配:未找到
要解决此问题,您可以将其添加到
docker build
命令中:--平台=linux/amd64
linux/amd64
或者您可以通过运行以下命令将默认平台设置为:导出 DOCKER_DEFAULT_PLATFORM=linux/amd64
linux/amd64
或者您可以通过运行以下命令将默认平台设置为:
export DOCKER_DEFAULT_PLATFORM=linux/amd64
eclipse-temurin:17-jdk-alpine
使用该镜像作为基础镜像构建镜像后,我们得到了以下结果:
查看两个镜像的大小,即使不进行任何调整,用作基础镜像的镜像大小也为 180MB,比用作基础镜像的 eclipse-temurin:17-jdk-alpine
镜像(674MB)小 73%openjdk:17-jdk-slim
动手优化
等一下,为什么我们不能使用 JRE
image 来代替 JDK
image ?
好问题!这是因为从 Java 11 开始,JRE
不再可用
其中需要考虑的最重要的一点是这部分"用户可以使用 jlink 创建更小的自定义运行时。"
JRE
使用构建您自己的镜像 jlink
jlink
是一种可用于创建自定义运行时映像的工具,该映像仅包含运行应用程序所需的模块;
👉 如果您的应用程序不与数据库交互,则无需 java.sql
在映像中包含该模块。 如果您不与桌面 GUI 交互,则无需 java.desktop
在映像中包含该模块。 等等。
它有点像
JRE
镜像的替代品,但对您想要在镜像中使用的模块有更多的控制权。
因此 jlink
我们的 Dockerfile 应该是这样的:
bash
# 第一阶段,构建自定义 JRE
FROM eclipse-temurin:17-jdk-alpine AS jre-builder
# 安装 jlink 所需的 binutils
RUN apk update && \
apk add binutils
# 构建小型 JRE 映像
RUN $JAVA_HOME /bin/jlink \
--verbose \
--add-modules ALL-MODULE-PATH \
--strip-debug \
--no-man-pages \
--no-header-files \
--compress=2 \
--output /optimized-jdk-17
# 第二阶段,使用自定义 JRE 并构建应用程序映像
FROM alpine:latest
ENV JAVA_HOME=/opt/jdk/jdk-17
ENV PATH= " ${JAVA_HOME} /bin: ${PATH} "
# 从基础映像复制 JRE
COPY --from=jre-builder /optimized-jdk-17 $JAVA_HOME
# 添加应用程序用户
ARG APPLICATION_USER=spring
# 创建用户来运行应用程序,不要以 root 身份运行
RUN addgroup --system $APPLICATION_USER && adduser --system $APPLICATION_USER --ingroup $APPLICATION_USER
# 创建应用程序目录
RUN mkdir /app && chown -R $APPLICATION_USER /app
COPY -- chown = $APPLICATION_USER : $APPLICATION_USER target/*.jar /app/app.jar
WORKDIR /app
USER $APPLICATION_USER
EXPOSE 8080
ENTRYPOINT [ "java" , "-jar" , "/app/app.jar" ]
那么让我们解释一下我们在这里所做的事情:
- 我们有两个阶段,第一阶段用于构建自定义 JRE 映像
jlink
,第二阶段用于将应用程序打包到 slim alpine 映像中。 - 在第一阶段,我们使用
eclipse-temurin:17-jdk-alpine
来构建自定义 JRE 映像jlink
。然后我们安装binutils
所需的jlink
,然后我们运行jlink
构建一个小型 JRE 映像,该映像包含--add-modules ALL-MODULE-PATH
运行应用程序所需的所有模块(目前)。 - 在第二阶段,我们使用
alpine
镜像(相当小的 3Mb)来打包我们的应用程序)作为基础镜像,然后我们JRE
从第一阶段获取自定义并将其用作我们的JAVA_HOME
。 - Dockerfile 的其余部分与前一个相同,只是复制工件并使用自定义用户(非 root)设置入口点。
然后我们可以使用以下命令构建镜像:
docker build -t 用户服务:jlink-all-modules-temurin -f Dockerfile.jlink-all-modules.temurin 。
如果运行以下命令:
docker 镜像用户服务
您将看到该镜像的新 Docker 镜像大小现在为 85.3MB,比使用 eclipse-temurin 基础镜像的基础镜像小约 95MB 🎉🥳
为了确保镜像按预期工作,您可以运行以下命令:
docker run -p 8080:8080 用户服务:jlink-all-modules-temurin
您应该会看到应用程序按预期运行。
这还不够
作为一名优秀的开发人员,我们总是希望改进我们的工作,所以让我们看看如何进一步改善镜像尺寸。
--add-modules ALL-MODULE-PATH
镜像大小仍然很大,这是因为在命令中使用时 jlink
,我们包含了运行应用程序所需的所有模块,但我们肯定不需要所有模块。因此,让我们看看如何通过仅包含运行应用程序所需的模块来获得较小的镜像大小。
如何知道运行应用程序需要哪些模块?
我们可以使用 jdeps
JDK自带的工具。jdeps
该工具可以用来分析jar文件的依赖关系并生成运行应用程序所需的模块列表。
为此,我们可以在项目根目录运行以下命令:
jdeps --ignore-missing-deps -q \
--recursive \
--multi-release 17 \
--print-module-deps \
--class-path BOOT-INF/lib/* \
target/spring-error-handling-rfc-9457-0.0.1-SNAPSHOT.jar
这将打印出运行应用程序所需的模块列表,在我们的例子中是:
bash
java.base、java.compiler、java.desktop、java.instrument、java.management、java.naming、java.net.http、java.prefs、java.rmi、java.scripting、java.security.jgss、java.sql、jdk.jfr、jdk.unsupported
我们可以简单地将其放入命令 ALL-MODULE-PATH
中 jlink
:
Dockerfile.jlink-已知模块.temurin
bash
# 第一阶段,构建自定义 JRE
FROM openjdk:17-jdk-slim AS jre-builder
# 安装 jlink 所需的 binutils
RUN apt-get update -y && \
apt-get install -y binutils
# 构建小型 JRE 映像
RUN $JAVA_HOME/bin/jlink \
--verbose \
--add-modules java.base,java.compiler,java.desktop,java.instrument,java.management,java.naming,java.net.http,java.prefs,java.rmi,java.scripting,java.security.jgss,java.sql,jdk.jfr,jdk.unsupported \
--strip-debug \
--no-man-pages \
--no-header-files \
--compress = 2 \
--output /optimized-jdk-17
# 第二阶段,使用自定义 JRE 并构建应用程序映像
FROM alpine:latest
ENV JAVA_HOME =/opt/jdk/jdk- 17
ENV PATH = "${JAVA_HOME}/bin:${PATH}"
# 从基础镜像复制 JRE
COPY --from =jre-builder /optimized-jdk- 17 $JAVA_HOME
# 添加应用程序用户
ARG APPLICATION_USER =spring
# 创建用户来运行应用程序,不要以 root 身份运行
RUN addgroup --system $APPLICATION_USER && adduser --system $APPLICATION_USER --ingroup $APPLICATION_USER
# 创建应用程序目录
RUN mkdir /app && chown -R $APPLICATION_USER /app
COPY --chown = $APPLICATION_USER : $APPLICATION_USER target/*.jar /app/app.jar
WORKDIR /app
USER $APPLICATION_USER
EXPOSE 8080
ENTRYPOINT [ "java", "-jar", "/app/app.jar" ]
然后我们可以使用以下命令构建镜像:
docker build - t用户-服务:jlink -已知-模块- temurin - f Dockerfile.jlink -已知-模块.temurin 。
构建后镜像的尺寸如下:
我们得到的镜像尺寸较小,为 57.8MB,而不是 85.3MB。
这很好,但是我们不能自动化这个过程,而是 jdeps
手动运行命令,然后将模块复制到 jlink
命令中?
dockerfile 内部流程自动化
Dockerfile.jlink-带有-jdeps.temurin
bash
# 第一阶段,构建自定义 JRE
FROM eclipse-temurin:17-jdk-alpine AS jre-builder
RUN mkdir /opt/app
COPY 。 /opt/app
WORKDIR /opt/app
环境 MAVEN_VERSION 3.5.4
环境 MAVEN_HOME /usr/lib/mvn
环境 PATH $MAVEN_HOME/bin:$PATH
运行 apk 更新 && \
apk 添加 --no-cache tar binutils
运行 wget http://archive.apache.org/dist/maven/maven-3/$MAVEN_VERSION/binaries/apache-maven-$MAVEN_VERSION-bin.tar.gz && \
tar -zxvf apache-maven-$MAVEN_VERSION-bin.tar.gz && \
rm apache-maven-$MAVEN_VERSION-bin.tar.gz && \
mv apache-maven-$MAVEN_VERSION /usr/lib/mvn
运行 mvn package -DskipTests
运行 jar xvf target/spring-error-handling-rfc-9457-0.0.1-SNAPSHOT.jar
RUN jdeps --ignore-missing-deps -q \
--recursive \
--multi-release 17 \
--print-module-deps \
--class-path 'BOOT-INF/lib/*' \
target/spring-error-handling-rfc-9457-0.0.1-SNAPSHOT.jar > modules.txt
# 构建小型 JRE 映像
RUN $JAVA_HOME/bin/jlink \
--verbose \
--add-modules $(cat modules.txt) \
--strip-debug \
--no-man-pages \
--no-header-files \
--compress = 2 \
--output /optimized-jdk-17
# 第二阶段,使用自定义 JRE 并构建应用程序映像
FROM alpine:latest
ENV JAVA_HOME =/opt/jdk/jdk- 17
ENV PATH = "${JAVA_HOME}/bin:${PATH}"
# 从基础镜像复制 JRE
COPY --from =jre-builder /optimized-jdk- 17 $JAVA_HOME
# 添加应用程序用户
ARG APPLICATION_USER =spring
# 创建用户来运行应用程序,不要以 root 身份运行
RUN addgroup --system $APPLICATION_USER && adduser --system $APPLICATION_USER --ingroup $APPLICATION_USER
# 创建应用程序目录
RUN mkdir /app && chown -R $APPLICATION_USER /app
COPY --chown = $APPLICATION_USER : $APPLICATION_USER target/*.jar /app/app.jar
WORKDIR /app
USER $APPLICATION_USER
EXPOSE 8080
ENTRYPOINT [ "java", "-jar", "/app/app.jar" ]
然后我们可以使用以下命令构建镜像:
docker build -t 用户服务:jlink-with-jdeps.temurin -f Dockerfile.jlink-with-jdeps.temurin . --platform=linux/amd64
奖金
在我们完成之前,请注意,您可以使用一个 .dockerignore
文件来排除一些文件和目录被复制到镜像中,这有助于减少中间阶段镜像的大小。
您还应该知道,选择小型的基础镜像是好的,但要确保它具有良好的安全策略并且与您的应用程序兼容。
结论
希望这篇文章对您有所帮助。如果您有任何问题或意见,请随时通过Twitter或LinkedIn 与我联系。请务必访问我的网站https://www.abdelrani.com查看新文章。
参考
- https://docs.docker.com/develop/develop-images/multistage-build/
- https://docs.oracle.com/en/java/javase/11/tools/jlink.html
- https://docs.oracle.com/en/java/javase/11/tools/jdeps.html
- https://www.oracle.com/java/technologies/javase/11-relnote-issues.html
以上文章翻译自medium,原文链接