容器不再是未来的事情 - 它们就在我们身边。公司用它们来运行一切--从最简单的脚本到大型应用程序。你创建一个容器,在本地、测试环境、QA和最后在生产中运行同样的东西。一个无状态的盒子,以最小的要求构建,与虚拟机不同--不需要虚拟化整个操作系统。没有库的问题,没有互操作性的问题--至少在大多数情况下是梦想成真了。这些只是运行容器化应用程序的一些好处。
而当涉及到我们很多开发者,当我们说到容器时,我们会想到Docker。这是因为Docker的简单性、生态系统和在各种平台上的可用性。在这篇博文中,我们将学习如何用Java创建一个Web应用程序,使其能够在容器中运行,如何构建容器,最后,如何运行它并使其可观察。
Docker容器的好处
你可以把容器 镜像想象成一个包,它把你的应用程序代码和运行它所需要的一切都打包在一起,但仅此而已。它是轻量级的,因为它只包含应用程序和所需的依赖项以及运行环境。它是独立的,因为不需要外部依赖性。它们是安全的,因为默认情况下,隔离是打开的。
容器 镜像在运行时成为容器,当它在能够运行容器镜像的环境中运行时--在我们的例子中是Docker引擎。Docker引擎可用于主要的操作系统。从用户的角度来看,只要你使用的是能在你的操作系统上运行的镜像,你就不必担心任何形式的兼容性问题。
Docker本身不仅仅是Docker引擎。它是帮助你运行、构建和管理容器镜像的整个环境。它配备了各种工具,可以加速容器的工作,帮助你更快地实现你的目标。
你应该在Docker容器中运行Java吗?
但我可以在Docker上、在容器内运行我的Java应用吗?答案是--是的,你可以。你需要Docker来运行Java应用程序吗?你现在可能已经知道答案了。不,你不需要Docker来运行Java应用程序,你完全可以在一个专门的环境中运行它们,就像你之前运行它们时一样。但是,如果你想继续前进,在所有环境中使用完全相同的软件包,确保一切都以相同的方式运行--只要使用容器,很快你就会离不开它们了。
什么是Java Docker容器?
在我们讨论如何使用容器的细节之前,让我们先回答一个出现的问题--Java是如何融入这一切的?
答案其实很简单--Java Docker容器是一个标准的容器镜像,它包含了Java运行环境和运行你的应用程序所需的所有依赖项。这样的容器可以被下载到安装了Docker Engine的环境中,并直接启动它。不需要额外的安装,不需要Java运行时环境,什么都不需要。只有Docker Engine和包含你的Java应用程序的容器镜像。就是这么简单。
如何将一个Java Web应用程序Docker化。开发人员的分步教程
现在让我们来看看如何使用Docker本身,如何创建一个镜像,启动它并使用它。
安装Docker
安装Docker引擎取决于你想运行的Java容器镜像的操作系统。你只需从Docker的网站上下载适合你的操作系统的安装包,或使用其中一个软件包管理器,即适合你的目标平台的软件包,就可以安装它。如果你还没有安装Docker,而且你想了解更多关于它的信息,我鼓励你现在花点时间看看Docker的官方安装指南页面,之后继续阅读文章的其余部分。
Docker文件
要建立一个新的Java Docker镜像,我们需要一个叫做Dockerfile的文件。它是一个文本文件,包含了Docker在构建容器镜像时可以并将使用命令行执行的指令。这就是所需的全部内容。
下面是一个假设的Java应用程序的Dockerfile的例子:
FROM openjdk:17-alpine3.14
WORKDIR /application
COPY build/libs/awesome-app-1.0.jar ./
CMD ["java", "-jar", "awesome-app-1.0.jar"]
这就是全部。正如你所看到的,这是很直接的,但让我们一步一步地讨论。
Dockerfile中的第一件事是告诉Docker我们想用哪个镜像作为我们应用程序中的基本容器镜像:
FROM openjdk:17-alpine3.14
Docker镜像可以继承自其他镜像。例如,上面的Java Docker镜像是OpenJDK的官方镜像,它带有运行Java应用程序所需的所有软件包和工具。你可以在Docker Hub中找到所有的OpenJDK容器镜像。当然,我们可以列出手动安装Java开发工具包所需的所有命令,但这样会更简单。
下一步是调用WORKDIR命令。我们用它来设置工作目录为**/application**,这样就更容易运行其余的命令了。
COPY命令将包含我们的应用程序的awesome-app-1.0.jar文件复制到工作目录,在我们的例子中是**/application**。
最后,CMD命令执行一个命令。在我们的例子中,它只是运行java -jar awesome-app-1.0.jar命令来启动应用程序。这就是我们需要的全部。
当然,还有其他的命令,这里只提一些:
- RUN允许我们在容器镜像构建过程中运行任何UNIX命令
- EXPOSE从容器本身暴露端口
- ENV设置环境变量
- ADD将新文件复制到容器镜像文件系统中。
你可以从官方Docker文档中的Docker文件参考中了解更多关于它们的信息。
你可能已经注意到,我们使用了一个已经从本地文件系统构建的应用程序。这是一个可行的解决方案,但你也可能想在Java容器镜像构建期间构建应用程序。我们将在学习了如何使用镜像之后,再研究如何实现这一目的。
使用Java Docker镜像
一旦我们创建了Docker文件,我们就可以构建我们的Java容器镜像,然后运行它,把它变成一个正在运行的容器。
要建立这个镜像,你可以运行这样的命令:
docker build -t sematext/docker-awesome-app-demo:0.0.1-SNAPSHOT .
上述命令告诉Docker构建镜像,给它一个名字和一个标签(我们稍后会回到这个问题),并使用位于当前工作目录中的Dockerfile(.字符)。
一旦构建完成,我们就可以通过运行来启动该容器:
docker run sematext/docker-awesome-app-demo:0.0.1-SNAPSHOT
这些是最基本的,但我们知道,Java项目不是手工构建的,也不是已经预包装好的。相反,我们要使用其中一个构建工具。
现在让我们来看看Maven和Gradle这两个流行的Java依赖和构建管理工具,以及如何将它们与Docker一起使用。
用Maven构建Java Docker镜像
为了演示如何用Maven创建Java Docker镜像,我使用Spring Initializr作为使用Maven快速构建网络应用的最简单方法。我只是生成了一个最简单的Maven项目,其中Spring Web是唯一的依赖项。
我下载了创建的归档文件并解压,形成了以下目录结构:
-rw-r--r--@ 1 gro staff 429 12 mar 19:13 HELP.md<
-rwxr-xr-x@ 1 gro staff 10284 12 mar 19:13 mvnw<
-rw-r--r--@ 1 gro staff 6734 12 mar 19:13 mvnw.cmd<
-rw-r--r--@ 1 gro staff 1223 12 mar 19:13 pom.xml<
drwxr-xr-x@ 4 gro staff 128 12 mar 19:13 src
除此之外,我还创建了一个简单的Java类,作为Spring RestController使用:
package com.sematext.demo;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.RequestMapping;
@RestController
public class WelcomeController {
@RequestMapping("/")
public String index() {
return "Welcome to Docker!";
}
}
使用Maven并为我们构建容器的Docker文件如下:
FROM maven:3.8-jdk-11
RUN mkdir /project
COPY . /project
WORKDIR /project
RUN mvn clean package -DskipTests
CMD ["java", "-jar", "target/demo-0.0.1-SNAPSHOT.jar"]
这里的Docker文件非常简单。我们使用Maven Java容器镜像,包括Maven 3.8和JDK 11。接下来,我们创建**/project**目录,将本地目录的内容复制到其中,将工作目录设置为我们创建的目录,并运行Maven构建命令。最后一步是负责运行我们的Docker Java容器。
现在我们来试试通过运行来构建镜像:
docker build -t sematext/docker-example-demo:0.0.1-SNAPSHOT .
构建完成后,我们可以通过运行以下命令来尝试运行该容器:
docker run -d -p 8080:8080 sematext/docker-example-demo:0.0.1-SNAPSHOT
该命令告诉Docker引擎在后台运行给定的Docker容器(-d选项),并将容器内的8080端口暴露给外部世界。如果我们不这样做,这个端口将无法从外部世界到达。如果我们想让某些端口默认打开,我们也可以在Docker文件中使用EXPOSE命令。
要想知道Docker引擎内部正在运行什么,我们可以直接运行以下命令:
docker ps
结果会是:
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
3f2c861c6728 sematext/docker-example-demo:0.0.1-SNAPSHOT "/usr/local/bin/mvn-…" 2 minutes ago Up 2 minutes 0.0.0.0:8080->8080/tcp infallible_driscoll
它告诉我们,我们的Java Docker容器已经运行了2分钟,名字是infallible_driscoll--是的,Docker会给我们的容器起随机的名字,尽管我们也可以控制。随机的名字可能很有趣,但在生产环境中,你可能想给你的容器起一个有意义的名字--例如,包括在其中运行的应用程序的名字。这样一来,你就能很容易地识别容器中运行的是什么,而不需要看容器镜像的名称或检查。
并通过运行一个简单的curl命令来测试它是否真的运行:
curl -XGET 'localhost:8080'
而上述命令的结果会如下:
Welcome to Docker!
这样我们的Spring RestController就工作了。
用Gradle构建Java容器镜像
为Gradle构建Java Docker镜像与我们刚才讨论的Maven没有区别。唯一不同的是,我们必须使用Gradle作为构建工具,而且Docker文件的外观也会有些不同。
我不会在这里重复所有的步骤,因为它们都是一样的,但我想向你展示使用Gradle时的Docker文件的样子:
FROM gradle:7.4-jdk11
RUN mkdir /project
COPY . /project
WORKDIR /project
RUN gradle build
CMD ["java", "-jar", "build/libs/demo-0.0.1-SNAPSHOT.jar"]
你可以看到它是非常相似的。唯一的区别是基础Java容器镜像和我们运行的构建项目的命令。另外,我调整了最后一条命令,即负责运行应用程序的命令,只是因为jar文件的最终位置不同。其他一切都保持不变--不仅是在配置方面,而且在运行的命令方面也是如此。
给Docker容器镜像打标签
在构建Java Docker镜像时,我还想提到一件事--标签。每个容器镜像都有一个标签--你可以把它想象成与之相关的版本,这个版本可以用来获取和使用给定的版本。默认情况下,Docker会使用一个叫做最新的标签,它指向最新的版本。但使用最新的标签并不是最好的选择。你可能想坚持使用一个给定的主要版本,不要遇到兼容性问题。
你的容器图像也是如此--它们应该有标签,所以你知道你正在运行什么。在我们的例子中,我们使用0.0.1-SNAPSHOT作为标签。在构建过程中,我们使用了以下命令。
docker build -t sematext/docker-example-demo:0.0.1-SNAPSHOT .
上述命令中的sematext是组织,docker-example-demo是容器镜像的名称。它们共同创造了完整的Java容器镜像名称。0.0.1-SNAPHOT是标签,你在**:**字符后面提供它。你应该注意你的容器的正确版本,以便标签是有意义的,不会让你的用户感到困惑。
启动和停止Docker容器
第一次你想启动容器时,你要使用docker run命令,在你想运行的容器镜像上创建一个可写的容器层,比如说:
docker run -d --name my_cnt sematext/docker-example-demo:0.0.1-SNAPSHOT
这只在你第一次运行容器镜像时需要。在上面的例子中,我们运行了我们建立的容器镜像,并为该容器分配了一个名字my_cnt。现在我们可以试着通过运行来停止它:
docker stop my_cnt
这将停止该容器,同时停止在该容器内运行的应用程序本身。然后我们可以再次启动该容器,但这次不是使用运行命令,而是启动。我们是这样做的:
docker start my_cnt
最后,当容器被停止并且我们不再需要它时,我们可以通过使用rm 命令将其删除:
docker rm my_cnt
这是对容器生命周期的一个简单控制。
发布Docker容器镜像
最后,还有一件事是你应该注意的--发布Java Docker容器镜像。默认情况下,当你构建你的容器镜像时,它将被存储在你的本地磁盘上,它不会被任何其他系统使用。这就是为什么我们需要把镜像发布到一个资源库,比如Docker Hub。
用你的Docker账户登录后(你可以在hub.docker.com),只需选择标记的容器镜像并运行以下命令:
docker push sematext/docker-example-demo:0.0.1-SNAPSHOT
一段时间后,根据你的互联网连接和Java容器镜像的大小,你的镜像会被推送到Docker Hub。从现在开始,该镜像就可以从Docker Hub中提取,这意味着其他机器现在可以访问它。要做到这一点,你只需使用pull命令:
docker pull sematext/docker-example-demo:0.0.1-SNAPSHOT
Docker Hub并不是推送容器到远程仓库的唯一选择,你可以运行自己的容器注册表。在一些组织的情况下,这是必要的。我们不会谈论这样的选项,因为这已经超出了这篇文章的范围,但我想说的是,有这样的可能性。
使用Docker构建Java容器的最佳实践
在Docker容器内创建和运行Java网络应用程序时,如果你想让你的环境运行更长时间而不出现任何问题,就应该遵循一些最佳实践。
1.使用明确的版本
在你的软件中处理依赖关系时,你通常会将你的应用程序使用的某个库设置为某个版本。如果那是一个直接的依赖关系,它将不会被升级,直到你手动改变版本。对于Docker容器镜像,你也应该这样做。
默认情况下,Docker会尝试拉出一个带有最新标签的容器镜像,这意味着你可能期望镜像的版本会随着时间而改变。你不应该依赖这种机制。相反,你应该指定你想使用的容器镜像的确切版本。如果你在Docker文件中使用该镜像,或者你只是在运行该容器,这都不重要。试着坚持使用该版本,否则当版本改变时,你可能会遇到意想不到的结果。
2.使用多阶段的构建
在讨论创建Java Docker容器镜像时,我们说有时在一次构建过程中构建应用程序和容器镜像是有好处的。虽然这是真的,但我们不一定想在同一个Docker文件中构建它们。这样的Docker文件很快就会变得庞大、复杂,从而难以维护。
幸运的是,Docker为此提供了一个解决方案。我们可以将创建工作分为多个阶段,而不是单一的、大型的Dockerfile,一个是构建应用程序,一个是构建Java容器镜像。
例如,我们可以在构建过程中引入两个步骤,这个Dockerfile的例子说明了这一点:
FROM maven:3.8-jdk-11 AS build_step
RUN mkdir /project
COPY . /project
WORKDIR /project
RUN mvn clean package -DskipTests
FROM adoptopenjdk/openjdk11:jdk-11.0.11_9-alpine-slim
RUN mkdir /application
COPY --from=build_step /project/target/awesome-app-1.0.jar /application
WORKDIR /application
CMD ["java", "-jar", "awesome-app-1.0.jar"]
在第一步,也就是我们所说的build_step,我们使用Maven构建项目,并将构建结果放到名为/project的目录中。构建的第二步使用不同的镜像,创建一个名为/application的新文件夹,并将构建结果复制到该文件夹。关键的区别在于,COPY命令指定了一个额外的标志,即-from**,它告诉Docker应该从哪个步骤复制工件。
3.自动化任何需要的手动步骤
这一点非常简单,也非常重要。你应该记住,当你的Java应用程序启动时需要的每一个手动步骤都应该被自动化。这种自动化应该是Dockerfile文件的一部分,或者存在于容器启动时运行的脚本中。可能有各种必要的事情,如下载外部数据、准备一些数据,以及几乎所有应用程序启动所需的东西。如果有些事情需要手动步骤,那么在容器中运行时就应该自动化。
4.独立的责任
与传统的应用服务器不同,容器的设计是为了分割责任,每个容器只有一个责任。容器应该是一个单一的部署单位,有一个单一的责任,一个单一的关注。理想情况下,它们应该有一个单一的进程在运行。
想象一下OpenSearch的部署--对于一个较大的部署,你通常会有3个主节点、两个客户节点和多个数据节点--每个节点都在一个单独的容器中运行。
这种方法的一些好处是隔离、可扩展性和易于管理。隔离是因为每个进程都是独立运行的,不会干扰到其他进程。可扩展性,因为我们可以独立于其他容器来扩展每种类型的容器。最后,便于管理,因为Docker如何监控容器的生命周期,并能对其宕机做出反应。
5.限制权限
确保Java容器安全的良好做法之一是限制权限。经验法则--不要在容器内以root身份运行你的应用程序。你要尽量减少恶意用户可以访问的潜在攻击载体。
想象一下,你的容器或应用程序有一个错误,允许以容器内运行应用程序的用户的权限执行代码。这样的命令,以root身份运行,拥有所有的访问权限,可以读写每一个位置,还有很多很多。有了有限的访问权限和一个专门的用户,潜在攻击的可能性是有限的。
恐怕Docker默认会以root身份运行我们的命令,但我们可以轻松地改变这一点。让我们改变我们创建的初始Docker文件,并在那里创建一个新用户。修改后,我们的新Dockerfile会如下:
FROM openjdk:17-alpine3.14
WORKDIR /application
COPY build/libs/awesome-app-1.0.jar ./
RUN addgroup --system juser
RUN adduser -S -s /bin/false -G juser juser
RUN chown -R javauser:javauser /application
USER juser
CMD ["java", "-jar", "awesome-app-1.0.jar"]
我们刚才所做的是,我们创建了一个新的组,叫做juser,还有一个叫做juser的用户。接下来,我们把Java Docker镜像中的**/application**文件夹的所有权交给新创建的用户,最后切换到该用户。之后,我们就继续并启动应用程序,就这么简单。
6.确保Java是支持容器的
直截了当地说--不要使用旧的Java版本。旧的Java版本不知道容器化的环境,因此在运行时可能会产生问题,比如不受应用于容器的限制的约束。正因为如此,你应该使用的最小的Java版本是Java 1.8.0_191,但你最好使用较新的一个版本。
如果你想了解在Docker容器中运行早期版本的Java时可能发生的一些问题,请看我们的DockerCon照明讲座OOps, OOMs, Oh My!容器化的JVM应用。
7.为所有环境构建一个Java Docker镜像
拥有Docker化Java应用程序的好处是,你可以而且应该在你的CI/CD管道中构建一个单一的镜像,然后在所有应该运行的环境中重复使用该容器镜像。无论你是要部署到生产、测试、QA还是其他环境,你都应该使用相同的Java Docker镜像。区别只在于配置。这就是下一个最佳实践的意义所在。
8.配置和代码分开
运行Java容器所需的所有变量都应该在运行时提供。根据你的系统架构,配置变量应该被提供给容器或注入。不要对配置变量进行硬编码--这将使你无法在多个环境中重复使用容器图像。
你有各种方法可以实现。你可以通过环境变量来传递配置,这在容器领域是非常常见的。你可以使用基于网络的配置服务--例如,Spring Cloud Config。最后,你可以直接挂载一个卷,其中包含所有需要的配置的专用属性文件。关键是不要在Java Docker镜像中对配置进行硬编码。
9.调整你的Java虚拟机参数
在容器内运行你的Java代码并不意味着你不需要进行适当的配置。这包括正确调整Java虚拟机参数,使其能够在容器化环境中完美地工作。这包括Java虚拟机的性能调整以及垃圾收集的调整。
10.监控你的Java Docker容器
你需要知道你的环境中正在发生什么。然而,手动查看每个Java Docker容器是不可能的或非常困难的,因为Docker带来了许多新的管理挑战。这就是为什么你需要一个监控工具,它可以从你的容器化环境中收集和展示指标;允许你查看在容器内运行的应用程序的Java日志;向你展示Docker主机的利用率指标;以及至少有基本的警报功能,这样你就不必不断查看所展示的数据。
在你开始使用容器的时候,这可能并不明显。但是,一旦你进入生产阶段并希望确保环境健康,监控就成为一种必要。
用Sematext监控和调试容器化的Java应用程序

Sematext监控
当涉及到Docker容器时,可观察性并不容易。你可以有很多的容器。它们是动态的,可以自动扩展。观察Docker日志、指标和健康状况是不可能手动进行的。你需要一个好的Docker监控工具,可以帮助你完成所有这些任务,为你提供所有必要的信息。Sematext Monitoring的容器监控功能正是这样一个工具。
通过在你的环境中作为另一个容器运行的轻量级代理,你可以访问所有的主机和容器指标- 没有盲点。你可以很容易地看到你的Docker主机资源是如何被利用的,容器的资源是如何被利用的,更重要的是,通过自动发现来监控Docker容器内运行的Java应用程序。从你的应用程序中发送日志,并将其与指标一起查看,对其发出警报,所有这些都在一个单一的监控解决方案中。