如果你是一名Java开发者,并且使用Docker来打包你的应用程序,你可能会注意到即使是“hello world”这样的项目,最终生成的镜像大小也可能会相当大。在这篇文章中,我们将介绍一些优化Java应用程序Docker镜像大小的技巧。
我们将使用上一篇文章《使用RFC-9457规范处理Spring Web错误》中构建的同一个Spring Web应用程序来演示这些技巧。我们的应用程序仅包含2个端点:
- GET /users/:id:通过ID获取用户
- POST /users:创建新用户
UserController.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镜像(没有进行一些优化的情况下)的大小可能会相当大。
我们为什么应该关心镜像大小?本文的源代码可以在 github 找到
镜像的大小会对你的性能产生显著影响,无论是作为开发者还是作为组织。特别是在大型项目中,当你处理多个服务时,镜像的大小可能会变得很大,这可能会花费你大量的金钱和时间。
你应避免使用大镜像的原因包括:
- 磁盘空间:你在 Docker 注册表和生产服务器上浪费了磁盘空间。
- 构建速度变慢:镜像越大,构建和推送镜像所需的时间就越长。
- 安全性:镜像越大,依赖项就越多,攻击面也就越大。
- 带宽:镜像越大,在从注册表拉取和推送镜像时消耗的带宽就越多。
在你开始考虑优化之前,你始终应该注意你用来打包应用程序的基础镜像。你选择的基础镜像会对最终镜像的大小产生显著影响(如下所示)。
你可以使用一些基础镜像来打包你的 Java 应用程序,其中一些包括:
- JDK Alpine 基础镜像 : 这些镜像体积很小,但并不适合所有应用程序,因此可能会遇到一些库的兼容性问题。
- JDK Slim 基础镜像 : 这些镜像是基于 Debian 或 Ubuntu 的,与完整的 JDK 镜像相比,它们体积较小,但仍然相对较大。
- JDK 完整基础镜像 : 这些镜像体积较大,包含了运行应用程序所需的所有模块和依赖。
为了让你了解基础镜像的大小,这里比较了 openjdk:17-jdk-slim
(slim)和 eclipse-temurin:17-jdk-alpine
(alpine)镜像的大小:
知道应用程序构建 artifact (jar) 的大小约为:20MB
o 将我们的工件打包到 docker 镜像中,我们需要在应用程序根目录下定义一个 Dockerfile
,如下所示:
Dockerfile.base-openjdk
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 user-service .
在此之后,你应该会有一个名为 user-service
的 Docker 镜像,如你所见,该镜像的大小与应用程序工件的大小相比相当大,大约为 674MB。
等等什么 🤯 !!这只是一个小项目,只有两个端点且没有依赖,那么对于一个有几十个依赖和文件的应用程序呢!!
使用 eclipse-temurin:17-jdk-alpine 作为基础镜像。Dockerfile.base-temurin
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"]
在使用以下命令构建镜像之后:
docker build -t user-service:alpine -f Dockerfile.base-alpine . --platform=linux/amd64
🚨 重要说明
重要提示:如果您使用的是 Apple Silicon 上的 MAC,在构建镜像时可能会遇到以下问题:
— — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — —
[内部] 加载元数据 docker.io/library/eclipse-temurin:17-jdk-alpine:
— — —
Dockerfile:2
— — — — — — — — — —
1 | # 第一阶段,构建自定义 JRE
2 | >>> FROM eclipse-temurin:17-jdk-alpine AS jre-builder
3 |
4 | # 安装 binutils,jlink 所需
— — — — — — — — — —
错误:无法解决:eclipse-temurin:17-jdk-alpine: 平台在清单中未找到
— — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — — —要解决此问题,您可以在
docker build
命令中添加以下参数:-- platform=linux/amd64
或者,您可以通过运行以下命令将默认平台设置为
linux/amd64
:export DOCKER_DEFAULT_PLATFORM=linux/amd64
或者,您可以通过运行以下命令将默认平台设置为
linux/amd64
:export DOCKER_DEFAULT_PLATFORM=linux/amd64
使用 eclipse-temurin:17-jdk-alpine
作为基础镜像构建镜像后,我们得到了这个:
看看两个镜像的大小,即使没有进行任何调整,以 eclipse-temurin:17-jdk-alpine
为基础镜像构建的镜像大小为 180MB,比以 openjdk:17-jdk-slim
为基础镜像构建的 674MB 镜像小 73%。
JRE
镜像而是要用 JDK
镜像呢?
好的问题!从Java 11开始,JRE
不再可用。
最重要的是要注意这一部分:“用户可以使用 jlink 创建更小的自定义运行时。”
jlink
构建自己的 JRE
镜像
jlink
是一个工具,可以用来创建一个自定义的运行时镜像,其中只包含运行你的应用程序所需的模块。
👉 如果你的应用不需要与数据库交互,你不需要在镜像中包含 java.sql
模块。如果你的应用不需要与桌面 GUI 交互,你也不需要在镜像中包含 java.desktop
模块,以此类推。
它有点像
JRE
镜像的替代品,但你可以更灵活地控制在镜像中使用的模块。
所以在这里使用 jlink
,我们的 Dockerfile 应该是这样的:
# 第一阶段,构建自定义JRE
FROM eclipse-temurin:17-jdk-alpine AS jre-builder
# 安装binutils,jlink所需
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" ]
所以让我们来解释一下这里我们做了什么:
- 我们有两个阶段,第一个阶段用于使用
jlink
构建自定义的 JRE 镜像,第二个阶段用于在精简的 Alpine 镜像中打包应用程序。 - 在第一个阶段,我们使用
eclipse-temurin:17-jdk-alpine
镜像来构建自定义的 JRE 镜像。然后安装binutils
,这是jlink
所必需的,接着运行jlink
以构建一个包含所有所需模块的小型 JRE 镜像,这些模块通过--add-modules ALL-MODULE-PATH
(目前)来运行应用程序。 - 在第二个阶段,我们使用
alpine
镜像(这是一个非常小的 3MB 镜像)作为基础镜像来打包我们的应用程序。然后从第一个阶段获取自定义的JRE
并将其设置为我们的JAVA_HOME
。 - Dockerfile 的其余部分与之前的相同,只是复制了构建工件并使用自定义用户(而不是 root 用户)设置了入口点。
然后我们可以使用以下命令构建镜像:
docker build -t user-service:jlink-all-modules-temurin -f Dockerfile.jlink-all-modules.temurin .
如果你运行以下命令:
docker images user-service
你会看到新的 Docker 镜像大小现在是 85.3MB,比使用 eclipse-temurin 基础镜像时大约少了 95MB 🎉🥳
为了确保镜像按预期工作,你可以运行以下命令:
docker run -p 8080:8080 user-service:jlink-all-modules-temurin
你应该看到应用程序如预期的那样运行。
作为优秀的开发者,我们总是希望改进我们的工作,所以让我们看看如何进一步减小镜像的大小。
镜像大小仍然很大,这是因为我们在 jlink
命令中使用了 --add-modules ALL-MODULE-PATH
,这包含了运行应用程序所需的所有模块,但实际上我们并不需要所有这些模块。所以让我们看看如何通过只包含运行应用程序所需的模块来减小镜像大小。
我们可以使用 JDK 中自带的 jdeps
工具。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
这会打印出运行应用程序所需的所有模块,对于我们的情况是:
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
我们可以将这个内容替换掉 jlink
命令中的 ALL-MODULE-PATH
:
Dockerfile.jlink-known-modules.temurin
# 第一阶段,构建自定义JRE
FROM openjdk:17-jdk-slim AS jre-builder
# 安装binutils,jlink需要
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 user-service:jlink-known-modules-temurin -f Dockerfile.jlink-known-modules.temurin .
并且这是构建镜像后的大小:
我们得到了一个更小的镜像大小,为 57.8MB,而不是 85.3MB。
这很好,但我们能否自动化这个过程,而不是手动运行 jdeps
命令,然后再将模块复制到 jlink
命令中?
Dockerfile.jlink-with-jdeps.temurin
# 第一阶段,构建自定义JRE
FROM eclipse-temurin:17-jdk-alpine AS jre-builder
RUN mkdir /opt/app
COPY . /opt/app
WORKDIR /opt/app
ENV MAVEN_VERSION 3.5.4
ENV MAVEN_HOME /usr/lib/mvn
ENV PATH $MAVEN_HOME/bin:$PATH
RUN apk update && \
apk add --no-cache tar binutils
RUN 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
RUN mvn package -DskipTests
RUN 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 user-service:jlink-with-jdeps.temurin -f Dockerfile.jlink-with-jdeps.temurin . --platform=linux/amd64
在我们结束之前,请注意你可以使用 .dockerignore
文件来排除一些文件和目录不被复制到镜像中,这可以在中间阶段帮助减小镜像的大小。
你也应该注意,选择一个小的基础镜像是好的,但要确保它具有良好的安全策略,并且与你的应用程序兼容。
结论希望这篇文章对你有所帮助。如果你有任何问题或意见,请随时通过 twitter 或 linkedin 联系我。并且一定要访问我的网站 https://www.abdelrani.com 查看新文章。
参考资料共同學習,寫下你的評論
評論加載中...
作者其他優質文章