前往小程序,Get更优阅读体验!
立即前往
首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >成功优化!Java 基础 Docker 镜像从 674MB 缩减到 58MB 的经验分享

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

原创
作者头像
程序员皮皮林
发布2024-11-05 20:51:26
521
发布2024-11-05 20:51:26

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

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

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

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

GET /users/

: 根据ID获取用户 POST /users : 创建新用户

代码语言:javascript
复制
@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。

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

代码语言:javascript
复制
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后,可以使用以下命令构建镜像:

代码语言:javascript
复制
docker build -t user-service .

完成后,你应该会有一个名为user-service的Docker镜像,正如你所看到的,与应用程序工件的大小相比,镜像的大小相当大,约为674MB。

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

使用 eclipse-temurin:17-jdk-alpine 作为基础镜像。

Dockerfile.base-temurin

代码语言:javascript
复制
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"]

在使用以下命令构建镜像后:

代码语言:javascript
复制
docker build -t user-service:alpine -f Dockerfile.base-alpine . --platform=linux/amd64

🚨 附注

重要提示:如果你在Apple Silicon的MAC上构建镜像,可能会遇到以下问题:

代码语言:javascript
复制
> [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构建命令中添加:

代码语言:javascript
复制
--platform=linux/amd64

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

代码语言:javascript
复制
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应该如下所示:

代码语言:javascript
复制
# 第一阶段,构建自定义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)设置入口点。

然后我们可以使用以下命令构建镜像:

代码语言:javascript
复制
docker build -t user-service:jlink-all-modules-temurin -f Dockerfile.jlink-all-modules.temurin .

如果你运行命令:

代码语言:javascript
复制
docker images user-service

你会看到新Docker镜像的大小现在为85.3MB,比基础镜像小约95MB 🎉🥳

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

代码语言:javascript
复制
docker run -p 8080:8080 user-service:jlink-all-modules-temurin

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

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

目前镜像的大小依然较大,这是因为在 jlink 命令中使用 --add-modules ALL-MODULE-PATH 时,我们包含了运行应用程序所需的所有模块,但我们并不需要所有模块。让我们看看如何仅包含运行应用程序所需的模块,从而获得更小的镜像大小。

如何确定运行应用程序所需的模块? 我们可以使用 JDK 附带的 jdeps 工具。jdeps 是一个可以分析 jar 文件依赖关系并生成所需模块列表的工具。

为此,我们可以在项目根目录下运行以下命令:

代码语言:javascript
复制
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

这将打印出运行应用程序所需的模块列表,在我们的案例中为:

代码语言:javascript
复制
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

代码语言:javascript
复制
# 第一阶段,构建自定义 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" ]

然后我们可以使用以下命令构建镜像:

代码语言:javascript
复制
docker build -t user-service:jlink-known-modules-temurin -f Dockerfile.jlink-known-modules.temurin .

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

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

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

在 Dockerfile 中自动化该过程

Dockerfile.jlink-with-jdeps.temurin

代码语言:javascript
复制
# 第一阶段,构建自定义 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" ]

然后我们可以使用以下命令构建镜像:

代码语言:javascript
复制
docker build -t user-service:jlink-with-jdeps.temurin -f Dockerfile.jlink-with-jdeps.temurin . --platform=linux/amd64

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

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

结论

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

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

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

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。

如有侵权,请联系 cloudcommunity@tencent.com 删除。

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
相关产品与服务
容器服务
腾讯云容器服务(Tencent Kubernetes Engine, TKE)基于原生 kubernetes 提供以容器为核心的、高度可扩展的高性能容器管理服务,覆盖 Serverless、边缘计算、分布式云等多种业务部署场景,业内首创单个集群兼容多种计算节点的容器资源管理模式。同时产品作为云原生 Finops 领先布道者,主导开源项目Crane,全面助力客户实现资源优化、成本控制。
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档