构建 Java 镜像的 10 个最佳实践

本文涉及的产品
容器镜像服务 ACR,镜像仓库100个 不限时长
简介: 构建 Java 镜像的 10 个最佳实践

你想构建一个 Java 应用程序并在 Docker 中运行它吗?你知道在使用 Docker 构建 Java 容器有哪些最佳实践?

在下面的速查表中,我将为你提供构建生产级 Java 容器的最佳实践,旨在优化和保护要投入生产环境中的 Docker 镜像。

构建一个简单的 Java 容器镜像

让我们从简单的 Dockerfile 开始,在构建 Java 容器时,我们经常会有如下类似的内容:

FROM maven
RUN mkdir /app
WORKDIR /app
COPY . /app
RUN mvn clean install
CMD "mvn" "exec:java"
$ docker build . -t java-application
$ docker run -p 8080:8080 java-application

这很简单,而且有效。但是,此镜像充满错误。

我们不仅应该了解如何正确使用 Maven,而且还应避免像上述示例那样构建 Java 容器。

下面,让我们开始逐步改进这个Dockerfile,使你的Java应用程序生成高效,安全的Docker镜像。

基于 Spring Boot + MyBatis Plus + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能

1. Docker 镜像使用确定性的标签

当使用 Maven 构建 Java 容器镜像时,我们首先需要基于 Maven 镜像。但是,你知道使用 Maven 基本镜像时实际上引入了哪些内容吗?

当你使用下面的代码行构建镜像时,你将获得该 Maven 镜像的最新版本:

FROM maven

这似乎是一个有趣的功能,但是这种采用 Maven 默认镜像的策略可能存在一些潜在问题:

  • 你的 Docker 构建不是幂等的。这意味着每次构建的结果可能会完全不同,今天的最新镜像可能不同于明天或下周的最新镜像,导致你的应用程序的字节码也是不同的,并且可能发生意外。因此,构建镜像时,我们希望具有可复制的确定性行为;
  • Maven Docker 镜像是基于完整的操作系统镜像。这样会导致许多其他二进制文件出现在最终的生产镜像中,但是运行你的 Java 应用程序不需要很多这些二进制文件。因此,将它们作为 Java 容器镜像的一部分存在一些缺点:1) 镜像体积变大,导致更长的下载和构建时间。2) 额外的二进制文件可能会引入安全漏洞。

如何解决?

  • 使用适合你需求的最小基础镜像。考虑一下——你是否需要一个完整的操作系统(包括所有额外的二进制文件)来运行你的程序?如果没有,也许基于 alpine 镜像或 Debian 的镜像会更好;
  • 使用特定的镜像 如果使用特定的镜像,则已经可以控制和预测某些行为。如果我使用 maven:3.6.3-jdk-11-slim 镜像,则已经确定我正在使用 JDK 11 和 Maven 3.6.3。JDK 和 Maven 的更新,将不再影响 Java 容器的行为。为了更加精确,你也可以使用镜像的 SHA256 哈希值。使用哈希将确保你每次构建镜像时都使用完全相同的基础镜像。

让我们用这些知识更新我们的 Dockerfile:

FROM maven:3.6.3-jdk-11-slim@sha256:68ce1cd457891f48d1e137c7d6a4493f60843e84c9e2634e3df1d3d5b381d36c
RUN mkdir /app
WORKDIR /app
COPY . /app
RUN mvn clean package -DskipTests

基于 Spring Cloud Alibaba + Gateway + Nacos + RocketMQ + Vue & Element 实现的后台管理系统 + 用户小程序,支持 RBAC 动态权限、多租户、数据权限、工作流、三方登录、支付、短信、商城等功能

2. 在 Java 镜像中仅安装需要的内容

以下命令会在容器中构建 Java 程序,包括其所有依赖项。这意味着源代码和构建系统都将会是 Java 容器的一部分。

RUN mvn clean package -DskipTests

我们都知道 Java 是一种编译语言。这意味着我们只需要由你的构建环境创建的工件,而不需要代码本身。这也意味着构建环境不应成为 Java 镜像的一部分。

要运行 Java 镜像,我们也不需要完整的 JDK。一个 Java 运行时环境(JRE)就足够了。因此,从本质上讲,如果它是可运行的 JAR,那么只需要使用 JRE 和已编译的 Java 构件来构建镜像。

使用 Maven 在 CI 流水线中都构建编译程序,然后将JAR复制到镜像中,如下面的更新的 Dockerfile 中所示:

FROM openjdk:11-jre-slim@sha256:31a5d3fa2942eea891cf954f7d07359e09cf1b1f3d35fb32fedebb1e3399fc9e
RUN mkdir /app
COPY ./target/java-application.jar /app/java-application.jar
WORKDIR /app
CMD "java" "-jar" "java-application.jar"

3. 使用多阶段构建 Java 镜像

在本文的前面,我们谈到了我们不需要在容器中构建 Java 应用程序。但是,在某些情况下,将我们的应用程序构建为 Docker 镜像的一部分很方便。

我们可以将 Docker 镜像的构建分为多个阶段。我们可以使用构建应用程序所需的所有工具来构建镜像,并在最后阶段创建实际的生产镜像。

FROM maven:3.6.3-jdk-11-slim@sha256:68ce1cd457891f48d1e137c7d6a4493f60843e84c9e2634e3df1d3d5b381d36c AS build
RUN mkdir /project
COPY . /project
WORKDIR /project
RUN mvn clean package -DskipTests
FROM adoptopenjdk/openjdk11:jre-11.0.9.1_1-alpine@sha256:b6ab039066382d39cfc843914ef1fc624aa60e2a16ede433509ccadd6d995b1f
RUN mkdir /app
COPY --from=build /project/target/java-application.jar /app/java-application.jar
WORKDIR /app
CMD "java" "-jar" "java-application.jar"

4. 防止敏感信息泄漏

在创建 Java 应用程序和 Docker 镜像时,很有可能需要连接到私有仓库,类似 settings.xml 的配置文件经常会泄露敏感信息。但在使用多阶段构建时,你可以安全地将 settings.xml 复制到你的构建容器中。带有凭据的设置将不会出现在你的最终镜像中。此外,如果将凭据用作命令行参数,则可以在构建镜像中安全地执行此操作。

使用多阶段构建,你可以创建多个阶段,仅将结果复制到最终的生产镜像中。这种分离是确保在生产环境中不泄漏数据的一种方法。

哦,顺便说一句,使用 docker history 命令查看 Java 镜像的输出:

$ docker history java-application

输出仅显示来自容器镜像的信息,而不显示构建镜像的过程。

5.不要以 root 用户运行容器

创建 Docker 容器时,你需要应用最小特权原则,防止由于某种原因攻击者能够入侵你的应用程序,则你不希望他们能够访问所有内容。

拥有多层安全性,可以帮助你减少系统威胁。因此,必须确保你不以 root 用户身份运行应用程序。

但默认情况下,创建 Docker 容器时,你将以 root 身份运行它。尽管这对于开发很方便,但是你不希望在生产镜像中使用它。假设由于某种原因,攻击者可以访问终端或可以执行代码。在那种情况下,它对正在运行的容器具有显著的特权,并且访问主机文件系统。

解决方案非常简单。创建一个有限特权的特定用户来运行你的应用程序,并确保该用户可以运行该应用程序。最后,在运行应用程序之前,不要忘记使用新创建的用户。

让我们相应地更新我们的 Dockerfile。

FROM maven:3.6.3-jdk-11-slim@sha256:68ce1cd457891f48d1e137c7d6a4493f60843e84c9e2634e3df1d3d5b381d36c AS build
RUN mkdir /project
COPY . /project
WORKDIR /project
RUN mvn clean package -DskipTests
FROM adoptopenjdk/openjdk11:jre-11.0.9.1_1-alpine@sha256:b6ab039066382d39cfc843914ef1fc624aa60e2a16ede433509ccadd6d995b1f
RUN mkdir /app
RUN addgroup --system javauser && adduser -S -s /bin/false -G javauser javauser
COPY --from=build /project/target/java-application.jar /app/java-application.jar
WORKDIR /app
RUN chown -R javauser:javauser /app
USER javauser
CMD "java" "-jar" "java-application.jar"

6. Java 应用程序不要使用 PID 为 1 的进程

在许多示例中,我看到了使用构建环境来启动容器化 Java 应用程序的常见错误。

上面,我们了解了要在  Java 容器中使用 Maven 或 Gradle 的重要性,但是使用如下命令,会有不同的效果:

CMD “mvn” “exec:java”
CMD [“mvn”, “spring-boot run”]
CMD “gradle” “bootRun”
CMD “run-app.sh”

在 Docker 中运行应用程序时,第一个应用程序将以进程 ID 为 1(PID=1)运行。Linux内核会以特殊方式处理 PID 为 1 的进程。通常,进程号为 1 的 PID 上的过程是初始化过程。如果我们使用 Maven 运行 Java 应用程序,那么如何确定 Maven 将类似 SIGTERM 信号转发给 Java 进程呢?

如果像下面的示例,那样运行 Docker 容器,则Java应用程序将具有 PID 为 1 的进程。

CMD "java" "-jar" "application.jar"

请注意,docker killdocker stop 命令仅向 PID 为 1 的容器进程发送信号。例如,如果你正在运行 Java 应用的 shell 脚本,/bin/sh 不会将信号转发给子进程。

更为重要的是,在 Linux 中,PID 为 1 的容器进程还有一些其他职责。因此,在某些情况下,你不希望应用程序成为 PID 为 1 的进程,因为你不知道如何处理这些问题。一个很好的解决方案是使用 dumb-init

RUN apk add dumb-init
CMD "dumb-init" "java" "-jar" "java-application.jar"

当你像这样运行 Docker 容器时,dumb-init 会占用 PID 为1的容器进程并承担所有责任。你的 Java 流程不再需要考虑这一点。

我们更新后的 Dockerfile 现在看起来像这样:

FROM maven:3.6.3-jdk-11-slim@sha256:68ce1cd457891f48d1e137c7d6a4493f60843e84c9e2634e3df1d3d5b381d36c AS build
RUN mkdir /project
COPY . /project
WORKDIR /project
RUN mvn clean package -DskipTests
FROM adoptopenjdk/openjdk11:jre-11.0.9.1_1-alpine@sha256:b6ab039066382d39cfc843914ef1fc624aa60e2a16ede433509ccadd6d995b1f
RUN apk add dumb-init
RUN mkdir /app
RUN addgroup --system javauser && adduser -S -s /bin/false -G javauser javauser
COPY --from=build /project/target/java-code-workshop-0.0.1-SNAPSHOT.jar /app/java-application.jar
WORKDIR /app
RUN chown -R javauser:javauser /app
USER javauser
CMD "dumb-init" "java" "-jar" "java-application.jar"

7. 优雅下线 Java 应用程序

当你的应用程序收到关闭信号时,理想情况下,我们希望所有内容都能正常关闭。根据你开发应用程序的方式,中断信号(SIGINT)或 CTRL + C 可能导致立即终止进程。

这可能不是你想要的东西,因为诸如此类的事情可能会导致意外行为,甚至导致数据丢失。

当你将应用程序作为 Payara 或 Apache Tomcat 之类的 Web 服务器的一部分运行时,该 Web 服务器很可能会正常关闭。对于某些支持可运行应用程序的框架也是如此。例如,Spring Boot 具有嵌入式 Tomcat 版本,可以有效地处理关机问题。

当你创建一个独立的 Java 应用程序或手动创建一个可运行的 JAR 时,你必须自己处理这些中断信号。

解决方案非常简单。添加一个退出钩子(hook),如下面的示例所示。收到类似 SIGINT 信号后,优雅下线应用程序的进程将会被启动。

Runtime.getRuntime().addShutdownHook(new Thread() {
   @Override
   public void run() {
       System.out.println("Inside Add Shutdown Hook");
   }
});

诚然,与 Dockerfile 相关的问题相比,这是一个通用的 Web 应用程序问题,但在容器环境中更重要。

8. 使用 .dockerignore 文件

为了防止不必要的文件污染 git 存储库,你可以使用 .gitignore 文件。

对于 Docker 镜像,我们有类似的东西—— .dockerignore 文件。类似于 git 的忽略文件,它是为了防止 Docker 镜像中出现不需要的文件或目录。同时,我们也不希望敏感信息泄漏到我们的 Docker 镜像中。

请参阅以下示例的 .dockerignore

.dockerignore
**/*.logDockerfile
.git
.gitignore

使用 .dockerignore 文件的要点是:

  • 跳过仅用于测试目的的依赖项;
  • 使你免于泄露密钥或凭据信息进入 Java Docker 镜像的文件;
  • 另外,日志文件也可能包含你不想公开的敏感信息;
  • 保持 Docker 镜像的美观和整洁,本质上是使镜像变小。除此之外,它还有助于防止意外行为。

9. 确保 Java 版本支持容器

Java 虚拟机(JVM)是一件了不起的事情。它会根据其运行的系统进行自我调整。有基于行为的调整,可以动态优化堆的大小。但是,在 Java 8 和 Java 9 等较旧的版本中,JVM 无法识别容器设置的CPU限制或内存限制。这些较旧的 Java 版本的 JVM 看到了主机系统上的全部内存和所有 CPU 容量。Docker 设置的限制将被忽略。

随着 Java 10 的发布,JVM 现在可以感知容器,并且可以识别容器设置的约束。该功能 UseContainerSupport 是 JVM 标志,默认情况下设置为活动状态。Java 10 中发布的容器感知功能也已移植到 Java-8u191

对于 Java 8 之前的版本,你可以手动尝试使用该 -Xmx 标志来限制堆大小,但这是一个痛苦的练习。紧接着,堆大小不等于 Java 使用的内存。对于 Java-8u131 和 Java 9,容器感知功能是实验性的,你必须主动激活。

-XX:+ UnlockExperimentalVMOptions -XX:+ UseCGroupMemoryLimitForHeap

最好的选择是将 Java 更新到 10 以上的版本,以便默认情况下支持容器。不幸的是,许多公司仍然严重依赖 Java 8。这意味着你应该在 Docker 镜像中更新到 Java 的最新版本,或者确保至少使用 Java 8 update 191 或更高版本。

10. 谨慎使用容器自动化生成工具

你可能会偶然发现适用于构建系统的出色工具和插件。除了这些插件,还有一些很棒的工具可以帮助你创建 Java 容器,甚至可以根据需要自动发布应用。

从开发人员的角度来看,这看起来很棒,因为你不必在创建实际应用程序时,还要花费精力维护 Dockerfile。

这样的插件的一个例子是 JIB。如下所示,我只需要调用 mvn jib:dockerBuild 命令可以构建镜像:

<plugin>
   <groupId>com.google.cloud.tools</groupId>
   <artifactId>jib-maven-plugin</artifactId>
   <version>2.7.1</version>
   <configuration>
       <to>
           <image>myimage</image>
       </to>
   </configuration>
</plugin>

它将为我构建一个具有指定名称的 Docker 镜像,而没有任何麻烦。

使用 2.3 及更高版本时,可以通过调用 mvn 命令进行操作:

mvn spring-boot:build-image

在这种情况下,系统都会自动为我创建一个 Java 镜像。这些镜像还比较小,那是因为他们正在使用非发行版镜像或 buildpack 作为镜像的基础。但是,无论镜像大小如何,你如何知道这些容器是安全的?你需要进行更深入的调查,即使这样,你也不确定将来是否会保持这种状态。

我并不是说你在创建 Java Docker 时不应使用这些工具。但是,如果你打算发布这些镜像,则应研究 Java 镜像所有方面的安全。镜像扫描将是一个好的开始。从安全性的角度来看,我的观点是,以完全控制和正确的方式创建 Dockerfile,是创建镜像更好,更安全的方式。

相关实践学习
通过workbench远程登录ECS,快速搭建Docker环境
本教程指导用户体验通过workbench远程登录ECS,完成搭建Docker环境的快速搭建,并使用Docker部署一个Nginx服务。
深入解析Docker容器化技术
Docker是一个开源的应用容器引擎,让开发者可以打包他们的应用以及依赖包到一个可移植的容器中,然后发布到任何流行的Linux机器上,也可以实现虚拟化,容器是完全使用沙箱机制,相互之间不会有任何接口。Docker是世界领先的软件容器平台。开发人员利用Docker可以消除协作编码时“在我的机器上可正常工作”的问题。运维人员利用Docker可以在隔离容器中并行运行和管理应用,获得更好的计算密度。企业利用Docker可以构建敏捷的软件交付管道,以更快的速度、更高的安全性和可靠的信誉为Linux和Windows Server应用发布新功能。 在本套课程中,我们将全面的讲解Docker技术栈,从环境安装到容器、镜像操作以及生产环境如何部署开发的微服务应用。本课程由黑马程序员提供。 &nbsp; &nbsp; 相关的阿里云产品:容器服务 ACK 容器服务 Kubernetes 版(简称 ACK)提供高性能可伸缩的容器应用管理能力,支持企业级容器化应用的全生命周期管理。整合阿里云虚拟化、存储、网络和安全能力,打造云端最佳容器化应用运行环境。 了解产品详情: https://www.aliyun.com/product/kubernetes
相关文章
|
15天前
|
移动开发 Java Android开发
构建高效Android应用:探究Kotlin与Java的性能差异
【4月更文挑战第3天】在移动开发领域,性能优化一直是开发者关注的焦点。随着Kotlin的兴起,其在Android开发中的地位逐渐上升,但关于其与Java在性能方面的对比,尚无明确共识。本文通过深入分析并结合实际测试数据,探讨了Kotlin与Java在Android平台上的性能表现,揭示了在不同场景下两者的差异及其对应用性能的潜在影响,为开发者在选择编程语言时提供参考依据。
|
22天前
|
Java
Java中的多线程编程技巧与最佳实践
在当今软件开发领域,多线程编程已经成为不可或缺的一部分。本文将深入探讨Java中的多线程编程技巧与最佳实践,帮助开发人员更好地利用多线程提高程序性能和并发处理能力。
|
28天前
|
Java 编译器 Android开发
构建高效Android应用:探究Kotlin与Java的性能差异
【2月更文挑战第30天】 随着Kotlin成为开发Android应用的首选语言,开发者社区对于其性能表现持续关注。本文通过深入分析与基准测试,探讨Kotlin与Java在Android平台上的性能差异,揭示两种语言在编译效率、运行时性能和内存消耗方面的具体表现,并提供优化建议。我们的目标是为Android开发者提供科学依据,帮助他们在项目实践中做出明智的编程语言选择。
|
1月前
|
存储 安全 Java
深入理解 Java 多线程、Lambda 表达式及线程安全最佳实践
线程使程序能够通过同时执行多个任务而更有效地运行。 线程可用于在不中断主程序的情况下在后台执行复杂的任务。 创建线程 有两种创建线程的方式。 扩展Thread类 可以通过扩展Thread类并覆盖其run()方法来创建线程:
102 1
深入理解 Java 多线程、Lambda 表达式及线程安全最佳实践
|
1月前
|
Java 程序员 索引
Java中的异常处理:理解、实践与最佳实践
【2月更文挑战第26天】在Java编程中,异常处理是一个重要的概念。它不仅帮助我们在程序出错时提供有关错误的详细信息,而且还允许我们以一种结构化的方式来处理这些错误。本文将深入探讨Java中的异常处理,包括如何创建自定义异常,如何使用try-catch-finally语句块,以及如何在实际编程中应用最佳实践。
26 3
|
22天前
|
Java 编译器 Android开发
构建高效Android应用:探究Kotlin与Java的性能差异
在开发高性能的Android应用时,选择合适的编程语言至关重要。近年来,Kotlin因其简洁性和功能性受到开发者的青睐,但其性能是否与传统的Java相比有所不足?本文通过对比分析Kotlin与Java在Android平台上的运行效率,揭示二者在编译速度、运行时性能及资源消耗方面的具体差异,并探讨在实际项目中如何做出最佳选择。
17 4
|
1天前
|
消息中间件 存储 Java
深度探索:使用Apache Kafka构建高效Java消息队列处理系统
【4月更文挑战第17天】本文介绍了在Java环境下使用Apache Kafka进行消息队列处理的方法。Kafka是一个分布式流处理平台,采用发布/订阅模型,支持高效的消息生产和消费。文章详细讲解了Kafka的核心概念,包括主题、生产者和消费者,以及消息的存储和消费流程。此外,还展示了Java代码示例,说明如何创建生产者和消费者。最后,讨论了在高并发场景下的优化策略,如分区、消息压缩和批处理。通过理解和应用这些策略,可以构建高性能的消息系统。
|
16天前
|
前端开发 Java API
构建RESTful API:Java中的RESTful服务开发
【4月更文挑战第3天】本文介绍了在Java环境中构建RESTful API的重要性及方法。遵循REST原则,利用HTTP方法处理资源,实现CRUD操作。在Java中,常用框架如Spring MVC简化了RESTful服务开发,包括定义资源、设计表示层、实现CRUD、考虑安全性、文档和测试。通过Spring MVC示例展示了创建RESTful服务的步骤,强调了其在现代Web服务开发中的关键角色,有助于提升互操作性和用户体验。
构建RESTful API:Java中的RESTful服务开发
|
23天前
|
Java 程序员 API
Java中的异常处理:理解、实践与最佳实践
在Java编程中,异常处理是一个重要的概念。本文将深入探讨Java中的异常处理,包括其基本概念、如何在实践中应用,以及一些最佳实践。我们将通过实例和代码片段来解析这些概念,以帮助读者更好地理解和应用Java的异常处理。
10 0
|
30天前
|
Java 编译器 Android开发
构建高效Android应用:探究Kotlin与Java的性能对比
【2月更文挑战第28天】 在Android开发领域,Kotlin作为一种现代编程语言,逐渐取代了传统的Java语言。本文通过深入分析Kotlin和Java在Android平台上的性能差异,揭示两者在编译效率、运行速度以及内存消耗等方面的比较结果。我们将探讨Kotlin协程如何优化异步编程,以及Kotlin Extensions对提升开发效率的贡献。同时,文中还将介绍一些性能优化的实践技巧,帮助开发者在Kotlin环境下构建更加高效的Android应用。