成功优化!Java 基础 Docker 镜像从 674MB 缩减到 58MB 的经验分享

简介: 本文分享了如何通过 jlink 和 jdeps 工具将 Java 基础 Docker 镜像从 674MB 优化至 58MB 的经验。首先介绍了选择合适的基础镜像的重要性,然后详细讲解了使用 jlink 构建自定义 JRE 镜像的方法,并通过 jdeps 自动化模块依赖分析,最终实现了镜像的大幅缩减。此外,文章还提供了实用的 .dockerignore 文件技巧和选择安全、兼容的基础镜像的建议,帮助开发者提升镜像优化的效果。

成功优化!Java 基础 Docker 镜像从 674MB 缩减到 58MB 的经验分享

在当今的软件开发领域,微服务架构和容器化应用已成为常态。随着应用程序的复杂性和规模不断增加,开发者们面临的一个主要挑战是如何有效管理和优化应用程序的体积。尤其是在使用 Java 进行开发时,生成的 Docker 镜像往往会相对较大,这不仅影响了部署速度,还增加了网络传输的负担和存储成本。因此,如何精简镜像大小成为了每个开发者亟待解决的问题。

本文将深入探讨如何通过 jlink 工具生成更小的 Java 运行时环境(JRE)镜像,并自动化整个过程。我们将分析不同模块的依赖关系,确保仅包括运行应用程序所需的最小模块。通过这样的方法,不仅可以提高应用程序的效率,还能优化资源的使用,让我们的微服务更加轻量、灵活。

我们将使用之前文章中构建的Spring Web应用来演示这些技巧,该文章是关于使用RFC-9457规范进行错误处理。我们的应用仅包含两个端点:

GET /users/

: 根据ID获取用户

POST /users : 创建新用户

@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注册表和生产服务器上浪费了磁盘空间。
  • 构建时间延长:镜像越大,构建和推送镜像所需的时间越长。
  • 安全性:镜像越大,依赖项越多,攻击面也越大。
  • 带宽:镜像越大,从注册表拉取和推送镜像时的带宽消耗越高。

使用简单明了的Dockerfile

基础镜像 Matter ✌🏽 : 选择合适的基础镜像

在考虑优化之前,你应该始终注意用于打包应用的基础镜像。你选择的基础镜像可能对最终镜像的大小产生显著影响。

可以用来打包Java应用的基础镜像有几种,包括:

  • JDK Alpine基础镜像:这些镜像体积较小,但不适合所有应用,因此可能会面临一些库的兼容性问题。
  • JDK Slim基础镜像:这些镜像基于Debian或Ubuntu,相较于完整的JDK镜像来说体积较小,但仍然比较大。
  • JDK完整基础镜像:这些镜像体积较大,包含运行应用所需的所有模块和依赖项。

为了给你一个基础镜像大小的概念,以下是openjdk:17-jdk-slim(瘦身版)和eclipse-temurin:17-jdk-alpine镜像大小的比较:

已知应用程序(jar)的大小约为20MB。

a73734a6dd92de77a836db737607d47.png

为了在Docker镜像中打包我们的工件,我们需要在应用根目录中定义一个Dockerfile,如下所示:

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。

77ea82aea702a64573e8edcd82d8868.png

等等,这只是一个只有两个端点的小项目,没有任何依赖项,那么对于一个有数十个依赖项和文件的应用来说,情况会如何呢?

使用 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上构建镜像,可能会遇到以下问题:

> [internal] load metadata for docker.io/library/eclipse-temurin:17-jdk-alpine:
Dockerfile:2
1 | # First stage, build the custom JRE
2 | >>> FROM eclipse-temurin:17-jdk-alpine AS jre-builder
3 |
4 | # Install binutils, required by jlink
ERROR: failed to solve: eclipse-temurin:17-jdk-alpine: no match for platform in manifest: not found

要解决此问题,你可以在Docker构建命令中添加:

--platform=linux/amd64

或者通过运行以下命令将默认平台设置为 linux/amd64

export DOCKER_DEFAULT_PLATFORM=linux/amd64

使用 eclipse-temurin:17-jdk-alpine 作为基础镜像构建完镜像后,我们得到了这个结果:

7c98abc6370e3d816275c5e3254067a.png

看看两个镜像的大小,使用 eclipse-temurin:17-jdk-alpine 作为基础镜像的镜像大小为180MB,比使用 openjdk:17-jdk-slim 作为基础镜像的674MB小73%。

实际优化

等一下,为什么我们不能使用JRE镜像而使用JDK镜像呢?

好问题!这是因为从Java 11开始,JRE不再可用。

最重要的注意事项是“用户可以使用jlink创建更小的自定义运行时”。

4d09256dc5c8c03e82691131d83b0db.png

使用 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 镜像来使用 jlink 构建自定义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,比基础镜像小约95MB 🎉🥳

a3d5aca96806101d9b2e777038c2196.png

为了确保镜像按预期工作,你可以运行以下命令:

docker run -p 8080:8080 user-service:jlink-all-modules-temurin

你应该会看到应用按预期运行。

7cb0cfd4024a3242ffe0824903a9d45.png

这还不够 🤌🏽

作为优秀的开发者,我们总是希望改进我们的工作,让我们看看如何进一步减少镜像的大小。

目前镜像的大小依然较大,这是因为在 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

我们可以简单地将这些模块替代 ALL-MODULE-PATH,修改 jlink 命令如下:

Dockerfile.jlink-known-modules.temurin

# 第一阶段,构建自定义 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 user-service:jlink-known-modules-temurin -f Dockerfile.jlink-known-modules.temurin .

这里是构建后的镜像大小:

869b83fff2dc103bfc9f3cee7797f06.png

我们得到了一个较小的镜像,大小为 57.8MB,而不是 85.3MB。

这很好,但我们能否自动化这个过程,而不是手动运行 jdeps 命令然后将模块复制到 jlink 命令中?

在 Dockerfile 中自动化该过程

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

d5d08ee9a7363ce9c7ac62a0ee115da.png

额外提示

在结束之前,请注意,您可以使用 .dockerignore 文件排除某些文件和目录,以减少镜像在中间阶段的大小。

您还应该注意,选择小型基础镜像是好的,但请确保它具备良好的安全策略,并与您的应用程序兼容。

结论

通过本文的探讨,我们成功展示了如何利用 jlink 工具和 jdeps 工具来生成更加精简的 Java 镜像。我们不仅减少了镜像的体积,从 85.3MB 降至 57.8MB,节省了大量的存储和传输资源,而且还引入了自动化的过程,进一步提升了开发效率。

在持续追求优化的过程中,自动化工具和最佳实践是每个开发者的得力助手。通过使用 .dockerignore 文件来排除不必要的文件和目录,我们还可以在构建镜像的中间阶段进一步减少体积。选择一个适合的基础镜像并确保其安全性和兼容性,也同样重要。

最后,优化镜像不仅能提升应用程序的性能,更能增强整体系统的可维护性和可扩展性。希望大家能够在实际项目中应用这些技术,进一步推动软件开发的高效化和现代化。

相关文章
|
21天前
|
缓存 Linux 网络安全
docker的镜像无法下载如何解决?
【10月更文挑战第31天】docker的镜像无法下载如何解决?
782 29
|
5天前
|
监控 算法 Java
Java虚拟机垃圾回收机制深度剖析与优化策略####
【10月更文挑战第21天】 本文旨在深入探讨Java虚拟机(JVM)中的垃圾回收机制,揭示其工作原理、常见算法及参数调优技巧。通过案例分析,展示如何根据应用特性调整GC策略,以提升Java应用的性能和稳定性,为开发者提供实战中的优化指南。 ####
26 5
|
18天前
|
缓存 算法 Java
本文聚焦于Java内存管理与调优,介绍Java内存模型、内存泄漏检测与预防、高效字符串拼接、数据结构优化及垃圾回收机制
在现代软件开发中,性能优化至关重要。本文聚焦于Java内存管理与调优,介绍Java内存模型、内存泄漏检测与预防、高效字符串拼接、数据结构优化及垃圾回收机制。通过调整垃圾回收器参数、优化堆大小与布局、使用对象池和缓存技术,开发者可显著提升应用性能和稳定性。
36 6
|
17天前
|
存储 关系型数据库 Linux
【赵渝强老师】什么是Docker的镜像
Docker镜像是一个只读模板,包含应用程序及其运行所需的依赖环境。镜像采用分层文件系统,每次修改都会以读写层形式添加到原只读模板上。内核bootfs用于加载Linux内核,根镜像相当于操作系统,上方为应用层。镜像在物理存储上是一系列文件的集合,默认存储路径为“/var/lib/docker”。
|
23天前
|
存储 监控 Linux
docker构建镜像详解!!!
本文回顾了Docker的基本命令和管理技巧,包括容器和镜像的增删改查操作,容器的生命周期管理,以及如何通过端口映射和数据卷实现容器与宿主机之间的网络通信和数据持久化。文章还详细介绍了如何使用Docker部署一个简单的Web应用,并通过数据卷映射实现配置文件和日志的管理。最后,文章总结了如何制作自定义镜像,包括Nginx、Python3和CentOS镜像,以及如何制作私有云盘镜像。
106 2
|
24天前
|
关系型数据库 MySQL Docker
docker环境下mysql镜像启动后权限更改问题的解决
在Docker环境下运行MySQL容器时,权限问题是一个常见的困扰。通过正确设置目录和文件的权限,可以确保MySQL容器顺利启动并正常运行。本文提供了多种解决方案,包括在主机上设置正确的权限、使用Dockerfile和Docker Compose进行配置、在容器启动后手动更改权限以及使用 `init`脚本自动更改权限。根据实际情况选择合适的方法,可以有效解决MySQL容器启动后的权限问题。希望本文对您在Docker环境下运行MySQL容器有所帮助。
51 1
|
17天前
|
缓存 JavaScript 安全
深入理解Docker镜像构建过程
深入理解Docker镜像构建过程
53 0
|
Java Linux Docker
Java Spring Boot 2.0实战Docker容器与架构原理,视频与课件,基于Linux环境
Docker是最流行的开源容器引擎,Go语言开发,在互联网大规模集群、云计算、微服务等架构中广泛使用。本次课程一起学习Docker容器架构原理、如何基于Linux系统实战实战Docker容器,打包、发布、运行 Java Spring Boot 2.0应用。
10738 0
|
10天前
|
Java 开发者
Java多线程编程中的常见误区与最佳实践####
本文深入剖析了Java多线程编程中开发者常遇到的几个典型误区,如对`start()`与`run()`方法的混淆使用、忽视线程安全问题、错误处理未同步的共享变量等,并针对这些问题提出了具体的解决方案和最佳实践。通过实例代码对比,直观展示了正确与错误的实现方式,旨在帮助读者构建更加健壮、高效的多线程应用程序。 ####
|
1天前
|
缓存 Java 开发者
Java多线程编程的陷阱与最佳实践####
本文深入探讨了Java多线程编程中常见的陷阱,如竞态条件、死锁和内存一致性错误,并提供了实用的避免策略。通过分析典型错误案例,本文旨在帮助开发者更好地理解和掌握多线程环境下的编程技巧,从而提升并发程序的稳定性和性能。 ####
下一篇
无影云桌面