Java 应用容器化与部署

如何开始打包、分发并将 Java 交付至生产环境

应用程序的容器化 提供了一种方法,可以将所有必需的应用程序资源——包括程序和配置文件、环境变量、网络设置等——组合到一个标准化、易于管理的包中。

从单个容器镜像可以启动、运行、管理和终止多个功能相同的容器,确保从镜像创建点开始的一致性。容器可以在截然不同的操作平台上运行,从本地机器到全球可扩展的云环境,以及介于两者之间的一切。可以构建流水线,轻松地在它们之间过渡。
虽然应用程序容器化有许多好处,但许多都可以归结为一个词:一致性

为何要对 JAVA 应用进行容器化?

Java 早期的一个承诺是"一次编写,到处运行",即"WORA"。尽管 Java 通过其 Java 虚拟机(JVM)实现了某种形式的目标,但在实现真正无缝的体验方面仍然存在相当多的外部障碍。
容器化解决了几乎所有这些外部障碍。虽然 100% 在任何追求中都可能是一个难以实现的目标,但将 Java 应用程序的可执行文件及其所有必需的依赖项和支持属性(配置等)打包的能力,使我们达到了有效的 100% 可移植性和一致性水平。

为 JAVA 应用程序创建 DOCKERFILE

许多开发人员通过仔细阅读官方的 Dockerfile 参考文档来开始他们的容器化工作。为了立即获得良好效果,让我们介绍关键点,创建一些镜像,并在此基础上进行构建。

为容器化选择操作系统和 JDK 构建

对此有各种不同的观点,但如果您刚开始接触容器化,从一个较小但完整的操作系统(OS)开始是一个很好的第一步。我们稍后将讨论其他选项(例如,无发行版)。
一般来说,您在操作系统层包含的内容越多,容器镜像就越大,安全漏洞的攻击面也就越大。可信来源也是一个关键的考虑因素。如果使用完整的操作系统构建,强烈推荐使用 eclipse-temurin(基于 Ubuntu)或 Alpine 基础层。
任何 OpenJDK 的构建都能运行您基于 JVM 的 Java 应用程序,而 Eclipse Temurin 是众多良好选项之一。但是,如果您希望对可能发现的任何 Java 问题获得专门的生产支持,那么选择商业支持的构建可以提供这种支持。

JAVA 应用的基本 DOCKERFILE 结构

一个基本 Java 应用程序的最低可行 Dockerfile 看起来像这样:

FROM eclipse-temurin:latest
COPY java-in-the-can-0.0.1-SNAPSHOT.jar /app.jar
EXPOSE 8080
CMD ["java", "-jar", "/app.jar"]

将上述文本(在 COPY 指令中使用您应用程序的名称)保存在一个名为 Dockerfile 的文件中,该文件与您的 Java 应用程序(.jar)文件位于同一目录。
在上面的 Dockerfile 中,我们提供了构建容器镜像的基本信息:

  • 构建应用程序容器镜像所基于的更高层基础镜像FROM
  • 将 .jar 文件复制COPY)到镜像中(在此示例中,还进行了重命名)的命令
  • 为应用程序监听连接请求而需要暴露EXPOSE)的任何特定端口(如有必要)
  • 在容器启动时运行应用程序的命令CMD
    在包含您的 Dockerfile 和 .jar 文件的目录中执行以下命令:
docker build -t <app-image-name> .

请注意,在运行镜像创建和其他容器命令之前,docker 守护进程(或 Mac/Windows 上的 Docker Desktop、Podman 等)必须正在运行。另外,不要忘记命令末尾的 .;它指的是可以找到 Dockerfile 的当前目录。
以这种方式运行生成的应用程序容器,用您上面创建的容器镜像名称替换 <app-image-name>

docker run -p 8080:8080 <app-image-name>

选择无发行版操作系统+JDK 基础镜像

对于大多数用例,在大小和攻击面方面可实现的最佳优化可能由"无发行版"基础镜像提供。虽然无发行版基础镜像中确实包含一个 Linux 发行版,但它已被剥离了任何非当前目的 specifically 不需要的文件,留下一个完全精简的操作系统,对于无发行版 Java 镜像而言,还包括 JVM。以下是一个使用无发行版 Java 基础镜像的 Dockerfile 示例:

FROM mcr.microsoft.com/openjdk/jdk:21-distroless
COPY java-in-the-can-0.0.1-SNAPSHOT.jar /app.jar
EXPOSE 8080
CMD ["-Xmx256m", "-jar", "/app.jar"]

请注意,这个针对 Java 优化的基础镜像预先配置了 java 命令的 ENTRYPOINT,因此 CMD 指令用于为 JVM 启动器进程提供命令行参数。

使用多阶段构建来减小镜像大小

如果您有构建所需但最终输出不需要的文件,多阶段构建提供了减小容器镜像大小的方法。就本文参考而言,情况并非如此,因为 JVM 以及应用程序的 .jar 文件和依赖项已为镜像创建预先配置好了。
正如您可能想象的那样,在某些非常常见的情况下,这变得有利。通常,应用程序通过配置好的构建流水线部署到生产环境,这些流水线基于源仓库上的触发器来创建构件。这是多阶段构建的最佳用例之一:构建流水线创建一个带有适当工具的构建容器,使用它来创建构件(例如 .jar 文件、配置文件),然后将这些构件复制到一个新的容器镜像中,该镜像不包含生产环境不需要的额外工具。这一系列操作大致类似于我们之前手动完成的操作,但实现了自动化,以获得一致和最优的结果。

管理环境变量

有多种方法可以向容器和应用程序提供输入值,用于启动或执行。一个应该采用的良好实践是,尽可能使用 ENVENTRYPOINTCMD 指令在 Dockerfile 本身中指定所有可能的值。所有这些值都可以在容器初始化时根据需要覆盖。
请注意,覆盖现有环境变量时应谨慎,因为这可能会以意外和不良的方式改变应用程序行为。
使用 ENV 配置 Java 特定选项的示例:

ENV JAVA_OPTS="-Xmx512m -Xms256m"

相同的概念也适用于应用程序特定变量:

ENV APP_GREETING="Greetings, Friend!"

使用 ENTRYPOINT 配置应用程序特定值的示例:

ENTRYPOINT ["java", "$JAVA_OPTS", "-jar", "your-app.jar"]

使用 CMD 的示例:

CMD ["java", "-Xmx256m", "-jar", "/app.jar"]

您可能已经注意到,ENTRYPOINTCMD 都可以用来执行 Java 应用程序。像所有其他技术(和非技术)选项一样,这两种指令各有优缺点。如果操作得当,两者都会使您的 Java 应用程序运行。
一般来说,Java 应用程序使用 CMD 指令,以便应用程序可以处理操作系统信号,用于支持的钩子机制(例如,SIGTERM 对应 java.lang.Runtime.addShutdownHook)。当然,这并非绝对必要,并且可以(也经常)使用 ENTRYPOINTCMD 来促进运行时参数传递,以提供/覆盖特定行为。这两者并不互斥。

使用 SPRING BOOT 插件进行容器化

如果您使用 Spring Boot 开发 Java 应用程序,容器化会简单得多。无论使用 Maven 还是 Gradle 作为项目构建工具,创建容器镜像都像执行一个预定义的目标一样简单。

  • 如果使用 Maven 作为构建工具,您可以通过调用 build-image 目标来创建包含应用程序的容器镜像:
    ./mvnw spring-boot:build-image
  • 如果使用 Gradle 作为构建工具,您可以通过调用 bootBuildImage 目标来创建包含应用程序的容器镜像:
    ./gradlew bootBuildImage
    在大多数情况下,自定义镜像创建(例如,镜像层定义)既不必要也不可取,但如果需要这样做,请参阅 Spring Boot Maven 或 Gradle 插件文档中的"打包 OCI 镜像"部分。

为原生应用程序构建容器镜像

开发人员可以选择使用 JVM 或作为原生的、操作系统特定的可执行文件来交付 Java 应用程序。以下部分提供了一些关于选择的考虑因素,以及如果您决定使用原生应用程序构建容器镜像,如何以最小的代价实现。

使用 GRAALVM 的 JAVA 原生可执行文件

GraalVM 支持创建原生可执行文件/二进制 Java 应用程序,在构建时执行所有编译和优化,而不是利用 JVM 在运行应用程序字节码时进行一些优化。
与所有选择一样,需要权衡利弊。编译为字节码与原生可执行文件是秒与分钟的问题,并且 JVM 执行的运行时优化在原生可执行文件中消失了,因为代码无法在运行时动态重写(这是 JVM 启用的一个特性)。
原生可执行文件优于基于 JVM 的 Java 应用程序的地方在于文件大小、内存需求和启动时间。原生应用程序要小得多,需要的资源更少,不需要 JVM 存在,并且启动速度显著更快。这在许多生产环境中是非常重要的考虑因素,因为较小的应用程序(及其容器)会降低平台资源需求,并且以毫秒而不是几秒衡量的启动时间可以增加可用性、可扩展性以及系统设计和部署的选项,从而可以显著节省成本。
根据您的框架和工具选择,有几种构建完全可执行的、操作系统原生的 Java 应用程序的选项。然而,一旦您有了原生可执行文件/二进制应用程序,您可以创建一个类似以下的 Dockerfile 作为您的原生应用程序容器镜像的模板:

FROM alpine:latest
WORKDIR /app
COPY java-in-the-can /app/
EXPOSE 8080
CMD ["/app/java-in-the-can"]

如果您使用 Spring Boot,您可以使用 GraalVM Maven 或 Gradle 插件,通过一条命令将您的应用程序编译为操作系统原生应用程序并创建容器镜像。

MAVEN
首先,将此依赖项添加到您的 pom.xml<build><plugins> 部分并保存文件:

<plugin>
    <groupId>org.graalvm.buildtools</groupId>
    <artifactId>native-maven-plugin</artifactId>
</plugin>

要构建原生应用程序和容器镜像,从您的项目根目录运行此命令:
./mvnw -Pnative spring-boot:build-image

GRADLE
类似地,将此依赖项添加到您的 build.gradle 文件的 plugins {} 部分并保存:
id 'org.graalvm.buildtools.native'

要构建原生应用程序和容器镜像,从您的项目根目录运行此命令:
./gradlew bootBuildImage

关于减小镜像大小和加快启动时间的考虑因素

您可能已经注意到,上述各节的顺序总体上趋向于生产更精简、启动更快的容器镜像。许多决策可能涉及组织标准或选择(例如,部署标准),这些标准或选择会使天平倾向于或反对某些选择,但一般来说,容器镜像优化的路径遵循以下顺序:

  1. 选择更小的基础镜像(操作系统发行版和 JVM)
  2. 选择带有 JVM 的无发行版镜像
  3. 如果您的工具链(例如 Spring Boot)允许,利用专门构建的工具
  4. 利用带有原生可执行应用程序的精简发行版或无发行版镜像

容器中 JAVA 应用程序的部署策略

您的应用程序的重要考虑因素超出了将其打包成应用程序容器镜像的范围。接下来是部署和维护决策,这些决策对于您的应用程序进入并保持在生产环境至关重要。

单容器部署

对于基本上是自包含的应用程序,部署到生产环境可以像单个命令一样简单,前提是部署目标已准备好接受容器化应用程序。即使在应用程序部署之前必须创建支持资源的情况下,这通常也意味着通过命令行或 Web 门户发出少量指令。当应用程序包含多个容器部署时,进程间依赖关系可能要求按特定顺序部署容器,以确保可用性或最小化波动或 chatter。为了实现这些目标,需要进行编排部署。

编排部署

编排部署可能比单容器部署复杂得多,并相应地提供更多功能。由于这两个特点,与单容器部署相比,编排部署可能有更多平台层级值得考虑。这些层级范围从提供广泛灵活性并相应需要开发人员付出更高水平努力的较低层级 Kubernetes 平台,到完成大量繁重工作以安全配置和集成多个容器和/或服务的完整平台。
您选择的目标平台将决定您对部署工具(例如,脚本、门户、基础设施配置工具)的选择。非常笼统地说,您选择的平台目标应该是能够部署和维护您的应用程序及其相关服务的最简单的平台。其他重要的考虑因素包括应用程序所有必需容器/服务的部署目标之间的比较成本、您组织已建立的实践/流水线等。

构建支持持续打补丁的 JAVA 应用程序

生产部署在应用程序上线后并未完成;开发人员必须确保应用程序保持安全、最新和可用。关键的补丁管理考虑因素包括:

  • 定期补丁 – 为常规补丁(例如,每月或每季度)建立一个无中断、可预测的频率,以更新库和依赖项
  • 紧急补丁 – 提供关于何时需要紧急补丁的指导,通常是为了应对关键漏洞或紧急安全更新
    容器镜像中需要打补丁的组件包括:
  • 基础操作系统容器镜像
  • 附加的操作系统包(如果适用)
  • 应用程序运行时(例如,JVM 版本,如果未包含在基础镜像中)
  • 应用程序依赖项/库
  • 应用程序性能监控(APM)代理二进制文件
    由开发人员及其组织来决定并严格维护一个保护应用程序、系统基础设施和数据的补丁策略。请参考此指南来帮助制定您的具体策略。

结论

容器化使开发人员能够将所有必需的应用程序资源和支持服务组合到一个或多个容器镜像中,并更轻松地部署、运行和管理它们。如果操作得当,容器化可以从镜像创建点开始实现安全性和一致性。容器可以在截然不同的操作平台上运行,从本地机器到全球可扩展的云环境。可以构建流水线,轻松地在它们之间过渡。因此,开发人员构建和运行支持生产工作负载的相同构件,减少了冲突并简化了调优和故障排除。
如果您是容器新手,请从小处着手,在本地构建以获得知识和稳定的基础,然后通过纳入更多容器最佳实践、构建流水线和合适的云平台来"扩展构建",逐步走向强大的应用程序部署生产模型。

其他考虑因素和资源:

作者:MARK A. HECKLER,
微软首席云技术推广专家(Java/JVM 语言)
Mark Heckler 是一名软件开发人员,微软的 Java/JVM 语言首席云技术推广专家,会议演讲者,Java Champion 和 Kotlin 开发专家,专注于为云和边缘计算平台快速开发生产软件。Mark 是开源贡献者,也是《Spring Boot: Up and Running》的作者,可以在 X @mkheck 上找到他。


【注】本文译自:Java Application Containerization and Deployment