面向-Java-开发者的-Docker-和-Kubernetes-教程(全)

面向 Java 开发者的 Docker 和 Kubernetes 教程(全)

原文:zh.annas-archive.org/md5/232C7A0FCE93C7B650611F281F88F33B

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

想象一下,在几分钟内在 Apache Tomcat 或 Wildfly 上创建和测试 Java EE 应用程序,以及迅速部署和管理 Java 应用程序。听起来太好了吧?您有理由欢呼,因为通过利用 Docker 和 Kubernetes,这样的场景是可能的。

本书将首先介绍 Docker,并深入探讨其网络和持久存储概念。然后,您将了解微服务的概念,以及如何将 Java 微服务部署和运行为 Docker 容器。接下来,本书将专注于 Kubernetes 及其特性。您将首先使用 Minikube 运行本地集群。下一步将是在亚马逊 AWS 上运行的 Kubernetes 上部署您的 Java 服务。在本书的最后,您将亲身体验一些更高级的主题,以进一步扩展您对 Docker 和 Kubernetes 的知识。

本书涵盖的内容

第一章,Docker 简介,介绍了 Docker 背后的原因,并介绍了 Docker 与传统虚拟化之间的区别。该章还解释了基本的 Docker 概念,如镜像、容器和 Dockerfile。

第二章,网络和持久存储,解释了 Docker 容器中网络和持久存储的工作原理。

第三章,使用微服务,概述了微服务的概念,并解释了它们与单片架构相比的优势。

第四章,创建 Java 微服务,探讨了通过使用 Java EE7 或 Spring Boot 快速构建 Java 微服务的方法。

第五章,使用 Java 应用程序创建镜像,教授如何将 Java 微服务打包成 Docker 镜像,无论是手动还是从 Maven 构建文件中。

第六章,运行带有 Java 应用程序的容器,展示了如何使用 Docker 运行容器化的 Java 应用程序。

第七章,Kubernetes 简介,介绍了 Kubernetes 的核心概念,如 Pod、节点、服务和部署。

第八章,使用 Java 与 Kubernetes,展示了如何在本地 Kubernetes 集群上部署打包为 Docker 镜像的 Java 微服务。

第九章,使用 Kubernetes API,展示了如何使用 Kubernetes API 来自动创建 Kubernetes 对象,如服务或部署。本章提供了如何使用 API 获取有关集群状态的信息的示例。

第十章,在云中部署 Java 到 Kubernetes,向读者展示了如何配置 Amazon AWS EC2 实例,使其适合运行 Kubernetes 集群。本章还详细说明了如何在 Amazon AWS 云上创建 Kubernetes 集群的方法。

第十一章,更多资源,探讨了 Java 和 Kubernetes 如何指向互联网上其他高质量的可用资源,以进一步扩展有关 Docker 和 Kubernetes 的知识。

本书所需内容

对于本书,您将需要任何一台能够运行现代版本的 Linux、Windows 10 64 位或 macOS 的体面 PC 或 Mac。

本书适合对象

本书适用于希望进入容器化世界的 Java 开发人员。读者将学习 Docker 和 Kubernetes 如何帮助在集群上部署和管理 Java 应用程序,无论是在自己的基础设施上还是在云中。

约定

在本书中,您将找到许多文本样式,用于区分不同类型的信息。以下是一些这些样式的示例及其含义的解释。文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:“当您运行docker build命令时,Dockerfile 用于创建图像。”代码块设置如下:

{

"apiVersion": "v1",

"kind": "Pod",

"metadata":{

"name": ”rest_service”,

"labels": {

"name": "rest_service"

}

},

"spec": {

"containers": [{

"name": "rest_service",

"image": "rest_service",

"ports": [{"containerPort": 8080}],

}]

}

}

任何命令行输入或输出都以以下方式编写:

docker rm $(docker ps -a -q -f status=exited)

新术语重要单词以粗体显示。屏幕上看到的单词,例如菜单或对话框中的单词,会在文本中出现,如:“点击“暂时跳过”将使您在不登录 Docker Hub 的情况下转到图像列表。”

警告或重要说明会出现在这样的框中。提示和技巧会出现在这样。

第一章:Docker 简介

本章我们将首先解释 Docker 及其架构背后的推理。我们将涵盖 Docker 概念,如镜像、层和容器。接下来,我们将安装 Docker,并学习如何从“远程”注册表中拉取一个示例基本的 Java 应用程序镜像,并在本地机器上运行它。

Docker 是作为平台即服务公司 dotCloud 的内部工具创建的。 2013 年 3 月,它作为开源软件向公众发布。 它的源代码可以在 GitHub 上免费获得:h t t p s 😕/g i t h u b . c o m /d o c k e r /d o c k e r 。不仅 Docker Inc.的核心团队致力于 Docker 的开发,还有许多大公司赞助他们的时间和精力来增强和贡献 Docker,如谷歌、微软、IBM、红帽、思科系统等。 Kubernetes 是谷歌开发的一个工具,用于根据他们在 Borg(谷歌自制的容器系统)上学到的最佳实践在计算机集群上部署容器。 在编排、自动化部署、管理和扩展容器方面,它与 Docker 相辅相成;它通过在集群中保持容器部署的平衡来管理 Docker 节点的工作负载。 Kubernetes 还提供了容器之间通信的方式,无需打开网络端口。 Kubernetes 也是一个开源项目,存放在 GitHub 上h t t p s 😕/g i t h u b . c o m /k u b e r n e t e s /k u b e r n e t e s 。每个人都可以贡献。 让我们首先从 Docker 开始我们的旅程。 以下内容将被覆盖:

  • 我们将从这个神奇工具背后的基本理念开始,并展示使用它所获得的好处,与传统虚拟化相比。

  • 我们将在三个主要平台上安装 Docker:macOS、Linux 和 Windows

Docker 的理念

Docker 的理念是将应用程序及其所有依赖项打包成一个单一的标准化部署单元。这些依赖项可以是二进制文件、库文件、JAR 文件、配置文件、脚本等。Docker 将所有这些内容打包成一个完整的文件系统,其中包含了 Java 应用程序运行所需的一切,包括虚拟机本身、诸如 Wildfly 或 Tomcat 之类的应用服务器、应用程序代码和运行时库,以及服务器上安装和部署的一切内容,以使应用程序运行。将所有这些内容打包成一个完整的镜像可以保证其可移植性;无论部署在何种环境中,它都将始终以相同的方式运行。使用 Docker,您可以在主机上运行 Java 应用程序,而无需安装 Java 运行时。与不兼容的 JDK 或 JRE、应用服务器的错误版本等相关的所有问题都将消失。升级也变得简单而轻松;您只需在主机上运行容器的新版本。

如果需要进行一些清理,您只需销毁 Docker 镜像,就好像什么都没有发生过一样。不要将 Docker 视为一种编程语言或框架,而应将其视为一种有助于解决安装、分发和管理软件等常见问题的工具。它允许开发人员和 DevOps 在任何地方构建、发布和运行其代码。任何地方也包括在多台机器上,这就是 Kubernetes 派上用场的地方;我们很快将回到这一点。

将所有应用程序代码和运行时依赖项打包为单个完整的软件单元可能看起来与虚拟化引擎相同,但实际上远非如此,我们将在下面解释。要完全了解 Docker 的真正含义,首先我们需要了解传统虚拟化和容器化之间的区别。现在让我们比较这两种技术。

虚拟化和容器化的比较

传统虚拟机代表硬件级虚拟化。实质上,它是一个完整的、虚拟化的物理机器,具有 BIOS 和安装了操作系统。它运行在主机操作系统之上。您的 Java 应用程序在虚拟化环境中运行,就像在您自己的机器上一样。使用虚拟机为您的应用程序带来了许多优势。每个虚拟机可以拥有完全不同的操作系统;例如,这些可以是不同的 Linux 版本、Solaris 或 Windows。虚拟机也是非常安全的;它们是完全隔离的、完整的操作系统。

然而,没有什么是不需要付出代价的。虚拟机包含操作系统运行所需的所有功能:核心系统库、设备驱动程序等。有时它们可能会占用资源并且很重。虚拟机需要完整安装,有时可能会很繁琐,设置起来也不那么容易。最后但并非最不重要的是,您需要更多的计算能力和资源来在虚拟机中执行您的应用程序,虚拟机监视程序需要首先导入虚拟机,然后启动它,这需要时间。然而,我相信,当涉及到运行 Java 应用程序时,拥有完整的虚拟化环境并不是我们经常想要的。Docker 通过容器化的概念来拯救。Java 应用程序(当然,不仅限于 Java)在 Docker 上运行在一个被称为容器的隔离环境中。容器在流行意义上不是虚拟机。它表现为一种操作系统虚拟化,但根本没有仿真。主要区别在于,每个传统虚拟机镜像都在独立的客户操作系统上运行,而 Docker 容器在主机上运行的相同内核内部运行。容器是自给自足的,不仅与底层操作系统隔离,而且与其他容器隔离。它有自己独立的文件系统和环境变量。当然,容器可以相互通信(例如应用程序和数据库容器),也可以共享磁盘上的文件。与传统虚拟化相比的主要区别在于,由于容器在相同的内核内部运行,它们利用更少的系统资源。所有操作系统核心软件都从 Docker 镜像中删除。基础容器通常非常轻量级。与经典虚拟化监视程序和客户操作系统相关的开销都没有了。这样,您可以为 Java 应用程序实现几乎裸金属的核心性能。此外,由于容器的最小开销,容器化 Java 应用程序的启动时间通常非常短。您还可以在几秒钟内部署数百个应用程序容器,以减少软件配置所需的时间。我们将在接下来的章节中使用 Kubernetes 来实现这一点。尽管 Docker 与传统虚拟化引擎有很大不同。请注意,容器不能替代所有用例的虚拟机;仍然需要深思熟虑的评估来确定对您的应用程序最好的是什么。两种解决方案都有其优势。一方面,我们有性能一般的完全隔离安全的虚拟机。另一方面,我们有一些关键功能缺失的容器,但配备了可以非常快速配置的高性能。让我们看看在使用 Docker 容器化时您将获得的其他好处。

使用 Docker 的好处

正如我们之前所说,使用 Docker 的主要可见好处将是非常快的性能和短的配置时间。您可以快速轻松地创建或销毁容器。容器与其他 Docker 容器有效地共享操作系统的内核和所需的库等资源。因此,在容器中运行的应用程序的多个版本将非常轻量级。结果是更快的部署、更容易的迁移和启动时间。

在部署 Java 微服务时,Docker 尤其有用。我们将在接下来的章节中详细讨论微服务。微服务应用由一系列离散的服务组成,通过 API 与其他服务通信。微服务将应用程序分解为大量的小进程。它们与单体应用相反,单体应用将所有操作作为单个进程或一组大进程运行。

使用 Docker 容器可以让您部署即插即用的软件,具有可移植性和极易分发的特点。您的容器化应用程序只需在其容器中运行;无需安装。无需安装过程具有巨大的优势;它消除了诸如软件和库冲突甚至驱动兼容性问题等问题。Docker 容器是可移植的;它们可以从任何地方运行:您的本地机器、远程服务器以及私有或公共云。所有主要的云计算提供商,如亚马逊网络服务(AWS)和谷歌的计算平台现在都支持 Docker。在亚马逊 EC2 实例上运行的容器可以轻松转移到其他环境,实现完全相同的一致性和功能。Docker 在基础架构层之上提供的额外抽象层是一个不可或缺的特性。开发人员可以创建软件而不必担心它将在哪个平台上运行。Docker 与 Java 有着相同的承诺;一次编写,到处运行;只是不是代码,而是配置您想要的服务器的方式(选择操作系统,调整配置文件,安装依赖项),您可以确信您的服务器模板将在运行 Docker 的任何主机上完全相同。

由于 Docker 的可重复构建环境,它特别适用于测试,特别是在持续集成或持续交付流程中。您可以快速启动相同的环境来运行测试。而且由于容器镜像每次都是相同的,您可以分发工作负载并并行运行测试而不会出现问题。开发人员可以在他们的机器上运行与后来在生产中运行的相同的镜像,这在测试中又有巨大的优势。

使用 Docker 容器可以加快持续集成的速度。不再有无休止的构建-测试-部署循环;Docker 容器确保应用程序在开发、测试和生产环境中运行完全相同。随着时间的推移,代码变得越来越麻烦。这就是为什么不可变基础设施的概念如今变得越来越受欢迎,容器化的概念也变得如此流行。通过将 Java 应用程序放入容器中,您可以简化部署和扩展的过程。通过拥有一个几乎不需要配置管理的轻量级 Docker 主机,您可以通过部署和重新部署容器来简单地管理应用程序。而且,由于容器非常轻量级,所以只需要几秒钟。

我们一直在谈论镜像和容器,但没有深入了解细节。现在让我们来看看 Docker 镜像和容器是什么。

Docker 概念-镜像和容器

在处理 Kubernetes 时,我们将使用 Docker 容器;它是一个开源的容器集群管理器。要运行我们自己的 Java 应用程序,我们首先需要创建一个镜像。让我们从 Docker 镜像的概念开始。

镜像

将图像视为只读模板,它是创建容器的基础。这就像一个包含应用程序运行所需的所有定义的食谱。它可以是带有应用服务器(例如 Tomcat 或 Wildfly)和 Java 应用程序本身的 Linux。每个图像都是从基本图像开始的;例如 Ubuntu;一个 Linux 图像。虽然您可以从简单的图像开始,并在其上构建应用程序堆栈,但您也可以从互联网上提供的数百个图像中选择一个已经准备好的图像。有许多图像对于 Java 开发人员特别有用:openjdktomcatwildfly等等。我们稍后将使用它们作为我们自己图像的基础。拥有,比如说,已经安装和配置正确的 Wildfly 作为您自己图像的起点要容易得多。然后您只需专注于您的 Java 应用程序。如果您是构建图像的新手,下载一个专门的基础图像是与自己开发相比获得严重速度提升的好方法。

图像是使用一系列命令创建的,称为指令。指令被放置在 Dockerfile 中。Dockerfile 只是一个普通的文本文件,包含一个有序的root文件系统更改的集合(与运行启动应用程序服务器的命令相同,添加文件或目录,创建环境变量等),以及稍后在容器运行时使用的相应执行参数。当您开始构建图像的过程时,Docker 将读取 Dockerfile 并逐个执行指令。结果将是最终图像。每个指令在图像中创建一个新的层。然后该图像层成为下一个指令创建的层的父层。Docker 图像在主机和操作系统之间具有高度的可移植性;可以在运行 Docker 的任何主机上的 Docker 容器中运行图像。Docker 在 Linux 中具有本地支持,但在 Windows 和 macOS 上必须在虚拟机中运行。重要的是要知道,Docker 使用图像来运行您的代码,而不是 Dockerfile。Dockerfile 用于在运行docker build命令时创建图像。此外,如果您将图像发布到 Docker Hub,您将发布一个带有其层的结果图像,而不是源 Dockerfile 本身。

我们之前说过,Dockerfile 中的每个指令都会创建一个新的层。层是图像的内在特性;Docker 图像是由它们组成的。现在让我们解释一下它们是什么,以及它们的特点是什么。

每个图像由一系列堆叠在一起的层组成。实际上,每一层都是一个中间图像。通过使用联合文件系统,Docker 将所有这些层组合成单个图像实体。联合文件系统允许透明地覆盖单独文件系统的文件和目录,从而产生一个统一的文件系统,如下图所示:

具有相同路径的目录的内容和结构在这些单独的文件系统中将在一个合并的目录中一起显示,在新的虚拟文件系统中。换句话说,顶层的文件系统结构将与下面的层的结构合并。具有与上一层相同路径的文件和目录将覆盖下面的文件和目录。删除上层将再次显示和暴露出先前的目录内容。正如我们之前提到的,层被堆叠放置,一层叠在另一层之上。为了保持层的顺序,Docker 利用了层 ID 和指针的概念。每个层包含 ID 和指向其父层的指针。没有指向父层的指针的层是堆栈中的第一层,即基础层。您可以在下图中看到这种关系:

图层具有一些有趣的特性。首先,它们是可重用和可缓存的。你可以在前面的图表中看到指向父图层的指针是很重要的。当 Docker 处理 Dockerfile 时,它会查看两件事:正在执行的 Dockerfile 指令和父映像。Docker 将扫描父图层的所有子图层,并寻找其命令与当前指令匹配的图层。如果找到匹配的图层,Docker 将跳过下一个 Dockerfile 指令并重复该过程。如果在缓存中找不到匹配的图层,则会创建一个新的图层。对于向图像添加文件的指令(我们稍后将详细了解它们),Docker 为每个文件内容创建一个校验和。在构建过程中,将此校验和与现有图像的校验和进行比较,以检查是否可以从缓存中重用该图层。如果两个不同的图像有一个共同的部分,比如 Linux shell 或 Java 运行时,Docker 将在这两个图像中重用 shell 图层,Docker 跟踪所有已拉取的图层,这是一个安全的操作;正如你已经知道的,图层是只读的。当下载另一个图像时,将重用该图层,只有差异将从 Docker Hub 中拉取。这当然节省了时间、带宽和磁盘空间,但它还有另一个巨大的优势。如果修改了 Docker 图像,例如通过修改容器化的 Java 应用程序,只有应用程序图层会被修改。当你成功从 Dockerfile 构建了一个图像后,你会注意到同一 Dockerfile 的后续构建会快得多。一旦 Docker 为一条指令缓存了一个图像图层,它就不需要重新构建。后来,你只需推送更新的部分,而不是整个图像。这使得流程更简单、更快速。如果你在持续部署流程中使用 Docker,这将特别有用:推送一个 Git 分支将触发构建一个图像,然后发布应用程序给用户。由于图层重用的特性,整个流程会快得多。

可重用层的概念也是 Docker 比完整虚拟机轻量的原因之一,虚拟机不共享任何内容。多亏了层,当你拉取一个图像时,最终你不必下载其整个文件系统。如果你已经有另一个图像包含了你拉取的图像的一些层,那么只有缺失的层会被实际下载。不过,需要注意的是,层的另一个特性:除了可重用,层也是可加的。如果在容器中创建了一个大文件,然后进行提交(我们稍后会讲到),然后删除该文件,再进行另一个提交;这个文件仍然会存在于层历史中。想象一下这种情况:你拉取了基础的 Ubuntu 图像,并安装了 Wildfly 应用服务器。然后你改变主意,卸载了 Wildfly 并安装了 Tomcat。所有从 Wildfly 安装中删除的文件仍然会存在于图像中,尽管它们已经被删除。图像大小会迅速增长。理解 Docker 的分层文件系统可以在图像大小上产生很大的差异。当你将图像发布到注册表时,大小可能会成为一个问题;它需要更多的请求和更长的传输时间。

当需要在集群中部署数千个容器时,大型图像就会成为一个问题。例如,你应该始终意识到层的可加性,并尝试在 Dockerfile 的每一步优化图像,就像使用命令链接一样。在创建 Java 应用程序图像时,我们将使用命令链接技术。

因为层是可加的,它们提供了特定图像是如何构建的完整历史记录。这给了你另一个很棒的功能:可以回滚到图像历史中的某个特定点。由于每个图像包含了所有构建步骤,我们可以很容易地回到以前的步骤。这可以通过给某个层打标签来实现。我们将在本书的后面介绍图像标记。

层和镜像是密切相关的。正如我们之前所说,Docker 镜像被存储为一系列只读层。这意味着一旦容器镜像被创建,它就不会改变。但是,如果整个文件系统都是只读的,这就没有太多意义了。那么如何修改一个镜像?或者将您的软件添加到基本 Web 服务器镜像中?嗯,当我们启动一个容器时,Docker 实际上会取出只读镜像(以及所有只读层),并在层堆栈顶部添加一个可写层。现在让我们专注于容器。

容器

镜像的运行实例称为容器。Docker 使用 Docker 镜像作为只读模板来启动它们。如果您启动一个镜像,您将得到这个镜像的一个运行中的容器。当然,您可以有许多相同镜像的运行容器。实际上,我们将经常使用 Kubernetes 稍后做这件事。

要运行一个容器,我们使用docker run命令:

docker run [OPTIONS] IMAGE [COMMAND] [ARG...]

有很多可以使用的run命令选项和开关;我们稍后会了解它们。一些选项包括网络配置,例如(我们将在第二章 Networking and Persistent Storage中解释 Docker 的网络概念)。其他选项,比如-it(来自交互式),告诉 Docker 引擎以不同的方式运行;在这种情况下,使容器变得交互,并附加一个终端到其输出和输入。让我们专注于容器的概念,以更好地理解整个情况。我们将很快使用docker run命令来测试我们的设置。

那么,当我们运行docker run命令时,在幕后会发生什么呢?Docker 将检查您想要运行的镜像是否在本地计算机上可用。如果没有,它将从“远程”存储库中拉取下来。Docker 引擎会获取镜像并在镜像的层堆栈顶部添加一个可写层。接下来,它会初始化镜像的名称、ID 和资源限制,如 CPU 和内存。在这个阶段,Docker 还将通过从池中找到并附加一个可用的 IP 地址来设置容器的 IP 地址。执行的最后一步将是实际的命令,作为docker run命令的最后一个参数传递。如果使用了it选项,Docker 将捕获并提供容器输出,它将显示在控制台上。现在,您可以做一些通常在准备操作系统运行应用程序时会做的事情。这可以是安装软件包(例如通过apt-get),使用 Git 拉取源代码,使用 Maven 构建您的 Java 应用程序等。所有这些操作都将修改顶部可写层中的文件系统。然后,如果执行commit命令,将创建一个包含所有更改的新镜像,类似于冻结,并准备随后运行。要停止容器,请使用docker stop命令:

docker stop

停止容器时,将保留所有设置和文件系统更改(在可写的顶层)。在容器中运行的所有进程都将停止,并且内存中的所有内容都将丢失。这就是停止容器与 Docker 镜像的区别。

要列出系统上所有容器,无论是运行还是停止的,执行docker ps命令:

docker ps -a

结果,Docker 客户端将列出一个包含容器 ID(您可以用来在其他命令中引用容器的唯一标识符)、创建日期、用于启动容器的命令、状态、暴露端口和名称的表格,可以是您分配的名称,也可以是 Docker 为您选择的有趣的名称。要删除容器,只需使用docker rm命令。如果要一次删除多个容器,可以使用容器列表(由docker ps命令给出)和一个过滤器:

docker rm $(docker ps -a -q -f status=exited)

我们已经说过,Docker 图像始终是只读且不可变的。如果它没有改变图像的可能性,那么它就不会很有用。那么除了通过修改 Dockerfile 并进行重建之外,图像修改如何可能呢?当容器启动时,层堆栈顶部的可写层就可以使用了。我们实际上可以对运行中的容器进行更改;这可以是添加或修改文件,就像安装软件包、配置操作系统等一样。如果在运行的容器中修改文件,则该文件将从底层(父级)只读层中取出,并放置在顶部的可写层中。我们的更改只可能存在于顶层。联合文件系统将覆盖底层文件。原始的底层文件不会被修改;它仍然安全地存在于底层的只读层中。通过发出docker commit命令,您可以从运行中的容器(以及可写层中的所有更改)创建一个新的只读图像。

docker commit <container-id> <image-name>

docker commit命令会将您对容器所做的更改保存在可写层中。为了避免数据损坏或不一致,Docker 将暂停您要提交更改的容器。docker commit命令的结果是一个全新的只读图像,您可以从中创建新的容器:

作为对成功提交的回应,Docker 将输出新生成图像的完整 ID。如果您在没有首先发出commit的情况下删除容器,然后再次启动相同的图像,Docker 将启动一个全新的容器,而不会保留先前运行容器中所做的任何更改。无论哪种情况,无论是否有commit,对文件系统的更改都不会影响基本图像。通过更改容器中的顶部可写层来创建图像在调试和实验时很有用,但通常最好使用 Dockerfile 以文档化和可维护的方式管理图像。

我们现在已经了解了容器化世界中构建(Dockerfile 和图像)和运行时(容器)部分。我们还缺少最后一个元素,即分发组件。Docker 的分发组件包括 Docker 注册表、索引和存储库。现在让我们专注于它们,以便有一个完整的图片。

Docker 注册表、存储库和索引

Docker 分发系统中的第一个组件是注册表。Docker 利用分层系统存储图像,如下面的屏幕截图所示:

您构建的图像可以存储在远程注册表中供他人使用。Docker注册表是一个存储 Docker 图像的服务(实际上是一个应用程序)。Docker Hub 是公开可用注册表的一个例子;它是免费的,并提供不断增长的现有图像的庞大集合。而存储库则是相关图像的集合(命名空间),通常提供相同应用程序或服务的不同版本。它是具有相同名称和不同标记的不同 Docker 图像的集合。

如果您的应用程序命名为hello-world-java,并且您的注册表的用户名(或命名空间)为dockerJavaDeveloper,那么您的图像将放在dockerJavaDeveloper/hello-world-java存储库中。您可以给图像打标签,并在单个命名存储库中存储具有不同 ID 的多个版本的图像,并使用特殊语法访问图像的不同标记版本,例如username/image_name:tagDocker存储库与 Git 存储库非常相似。例如,GitDocker存储库由 URI 标识,并且可以是公共的或私有的。URI 看起来与以下内容相同:

{registryAddress}/{namespace}/{repositoryName}:{tag}

Docker Hub 是默认注册表,如果不指定注册表地址,Docker 将从 Docker Hub 拉取图像。要在注册表中搜索图像,请执行docker search命令;例如:

$ docker search hello-java-world

如果不指定远程注册表,Docker 将在 Docker Hub 上进行搜索,并输出与您的搜索条件匹配的图像列表:

注册表和存储库之间的区别可能在开始时令人困惑,因此让我们描述一下如果执行以下命令会发生什么:

$ docker pull ubuntu:16.04

该命令从 Docker Hub 注册表中的ubuntu存储库中下载标记为16.04的镜像。官方的ubuntu存储库不使用用户名,因此在这个例子中省略了命名空间部分。

尽管 Docker Hub 是公开的,但您可以通过 Docker Hub 用户帐户免费获得一个私有仓库。最后,但并非最不重要的是,您应该了解的组件是索引。索引管理搜索和标记,还管理用户帐户和权限。实际上,注册表将身份验证委托给索引。在执行远程命令,如“推送”或“拉取”时,索引首先会查看图像的名称,然后检查是否有相应的仓库。如果有,索引会验证您是否被允许访问或修改图像。如果允许,操作将获得批准,注册表将获取或发送图像。

让我们总结一下我们到目前为止学到的东西:

  • Dockerfile 是构建图像的配方。它是一个包含有序指令的文本文件。每个 Dockerfile 都有一个基本图像,您可以在其上构建

  • 图像是文件系统的特定状态:一个只读的、冻结的不可变的快照

  • 图像由代表文件系统在不同时间点的更改的层组成;层与 Git 仓库的提交历史有些相似。Docker 使用层缓存

  • 容器是图像的运行时实例。它们可以运行或停止。您可以运行多个相同图像的容器

  • 您可以对容器上的文件系统进行更改并提交以使其持久化。提交总是会创建一个新的图像

  • 只有文件系统更改可以提交,内存更改将丢失

  • 注册表保存了一系列命名的仓库,这些仓库本身是由它们的 ID 跟踪的图像的集合。注册表与 Git 仓库相同:您可以“推送”和“拉取”图像

现在您应该对具有层和容器的图像的性质有所了解。但 Docker 不仅仅是一个 Dockerfile 处理器和运行时引擎。让我们看看还有什么其他可用的东西。

附加工具

这是一个完整的软件包,其中包含了许多有用的工具和 API,可以帮助开发人员和 DevOp 在日常工作中使用。例如,有一个 Kinematic,它是一个用于在 Windows 和 macOS X 上使用 Docker 的桌面开发环境。

从 Java 开发者的角度来看,有一些工具特别适用于程序员日常工作,比如 IntelliJ IDEA 的 Docker 集成插件(我们将在接下来的章节中大量使用这个插件)。Eclipse 的粉丝可以使用 Eclipse 的 Docker 工具,该工具从 Eclipse Mars 版本开始可用。NetBeans 也支持 Docker 命令。无论您选择哪种开发环境,这些插件都可以让您从您喜爱的 IDE 直接下载和构建 Docker 镜像,创建和启动容器,以及执行其他相关任务。

Docker 如今非常流行,难怪会有数百种第三方工具被开发出来,以使 Docker 变得更加有用。其中最突出的是 Kubernetes,这是我们在本书中将要重点关注的。但除了 Kubernetes,还有许多其他工具。它们将支持您进行与 Docker 相关的操作,如持续集成/持续交付、部署和基础设施,或者优化镜像。数十个托管服务现在支持运行和管理 Docker 容器。

随着 Docker 越来越受到关注,几乎每个月都会涌现出更多与 Docker 相关的工具。您可以在 GitHub 的 awesome Docker 列表上找到一个非常精心制作的 Docker 相关工具和服务列表,网址为 https://github.com/veggiemonk/awesome-docker。

但不仅有可用的工具。此外,Docker 提供了一组非常方便的 API。其中之一是用于管理图像和容器的远程 API。使用此 API,您将能够将图像分发到运行时 Docker 引擎。还有统计 API,它将公开容器的实时资源使用信息(如 CPU、内存、网络 I/O 和块 I/O)。此 API 端点可用于创建显示容器行为的工具;例如,在生产系统上。

现在我们知道了 Docker 背后的理念,虚拟化和容器化之间的区别,以及使用 Docker 的好处,让我们开始行动吧。我们将首先安装 Docker。

安装 Docker

在本节中,我们将了解如何在 Windows、macOS 和 Linux 操作系统上安装 Docker。接下来,我们将运行一个示例hello-world图像来验证设置,并在安装过程后检查一切是否正常运行。

Docker 的安装非常简单,但有一些事情需要注意,以使其顺利运行。我们将指出这些问题,以使安装过程变得轻松。您应该知道,Linux 是 Docker 的自然环境。如果您运行容器,它将在 Linux 内核上运行。如果您在运行 Linux 上的 Docker 上运行容器,它将使用您自己机器的内核。这在 macOS 和 Windows 上并非如此;这就是为什么如果您想在这些操作系统上运行 Docker 容器,就需要虚拟化 Linux 内核的原因。当 Docker 引擎在 macOS 或 MS Windows 上运行时,它将使用轻量级的 Linux 发行版,专门用于运行 Docker 容器。它完全运行于 RAM 中,仅使用几兆字节,并在几秒钟内启动。在 macOS 和 Windows 上安装了主要的 Docker 软件包后,默认情况下将使用操作系统内置的虚拟化引擎。因此,您的机器有一些特殊要求。对于最新的本地 Docker 设置,它深度集成到操作系统中的本地虚拟化引擎中,您需要拥有 64 位的 Windows 10 专业版或企业版。对于 macOS,最新的 Docker for Mac 是一个全新开发的本地 Mac 应用程序,具有本地用户界面,集成了 OS X 本地虚拟化、hypervisor 框架、网络和文件系统。强制要求是 Yosemite 10.10.3 或更新版本。让我们从在 macOS 上安装开始。

在 macOS 上安装

要获取 Mac 的本地 Docker 版本,请转到h t t p 😕/w w w . d o c k e r . c o m,然后转到获取 Docker macOS 部分。Docker for Mac 是一个标准的本地dmg软件包,您可以挂载。您将在软件包中找到一个单一的应用程序:

现在只需将Docker.app移动到您的Applications文件夹中,就可以了。再也没有更简单的了。如果您运行 Docker,它将作为 macOS 菜单中的一个小鲸鱼图标。该图标将在 Docker 启动过程中进行动画显示,并在完成后稳定下来:

  • 如果您现在点击图标,它将为您提供一个方便的菜单,其中包含 Docker 状态和一些附加选项:

  • Docker for Mac 具有自动更新功能,这对于保持安装程序最新非常有用。首选项...窗格为您提供了自动检查更新的可能性;它默认标记为:

  • 如果您是一个勇敢的人,您还可以切换到 beta 频道以获取更新。这样,您就可以始终拥有最新和最棒的 Docker 功能,但也会面临稳定性降低的风险,就像使用 beta 软件一样。还要注意,切换到 beta 频道将卸载当前稳定版本的 Docker 并销毁所有设置和容器。Docker 会警告您,以确保您真的想这样做:

  • 首选项...的文件共享窗格将为您提供一个选项,可以将您的 macOS 目录标记为将来要运行的 Docker 容器中的绑定挂载。我们将在本书的后面详细解释挂载目录。目前,让我们只使用默认的一组选定目录:

  • 高级窗格有一些选项,可以调整您的计算机为 Docker 提供的资源,包括处理器数量和内存量。如果您在 macOS 上开始使用 Docker,通常默认设置是一个很好的开始:

  • 代理窗格为您提供了在您的计算机上设置代理的可能性。您可以选择使用系统或手动设置,如下面的屏幕截图所示:

  • 在下一页,您可以编辑一些 Docker 守护程序设置。这将包括添加注册表和注册表镜像。Docker 在拉取镜像时将使用它们。高级选项卡包含一个文本字段,您可以在其中输入包含守护程序配置的 JSON 文本:

  • 在守护程序窗格中,您还可以关闭 Docker 实验功能。有段时间以来,默认情况下已启用实验功能。不时,新版本的 Docker 会带来新的实验功能。在撰写本书时,它们将包括例如 Checkpoint & Restore(允许您通过对其进行检查点来冻结运行中的容器的功能),Docker 图形驱动程序插件(用于使用外部/独立进程图形驱动程序与 Docker 引擎一起使用的功能,作为使用内置存储驱动程序的替代方案),以及其他一些功能。了解新版本 Docker 中包含了哪些新功能总是很有趣。单击守护程序页面中的链接将带您转到 GitHub 页面,该页面列出并解释了所有新的实验功能。

  • 最后一个“首选项...”窗格是“重置”。如果您发现您的 Docker 无法启动或表现不佳,您可以尝试将 Docker 安装重置为出厂默认设置:

但是,您应该注意,将 Docker 重置为出厂状态也将删除您可能在计算机上拥有的所有已下载的镜像和容器。如果您有尚未推送到任何地方的镜像,首先备份总是一个好主意。

在 Docker 菜单中打开 Kitematic 是打开我们之前提到的 Kitematic 应用程序的便捷快捷方式。这是一个用于在 Windows 和 Mac OS X 上使用 Docker 的桌面实用程序。如果您尚未安装 Kitematic,Docker 将为您提供安装包的链接:

  • 如果您运行 Kitematic,它将首先呈现 Docker Hub 登录屏幕。您现在可以注册 Docker Hub,然后提供用户名和密码登录:

单击“暂时跳过”将带您到图像列表,而无需登录到 Docker Hub。让我们通过拉取和运行图像来测试我们的安装。让我们搜索hello-java-world,如下面的屏幕截图所示:

从注册表中拉取图像后,启动它。Kitematic 将呈现正在运行的容器日志,其中将是来自容器化的 Java 应用程序的著名hello world消息:

这就是在 Kitematic 中运行容器的全部内容。让我们尝试从 shell 中执行相同的操作。在终端中执行以下操作:

$ docker run milkyway/java-hello-world

因此,您将看到来自容器化的 Java 应用程序的相同问候,这次是在 macOS 终端中:

就是这样,我们在 macOS 上有一个本地的 Docker 正在运行。让我们在 Linux 上安装它。

在 Linux 上安装

有很多不同的 Linux 发行版,每个 Linux 发行版的安装过程可能会有所不同。我将在最新的 16.04 Ubuntu 桌面上安装 Docker:

  1. 首先,我们需要允许apt软件包管理器使用 HTTPS 协议的存储库。从 shell 中执行:
$ sudo apt-get install -y --no-install-recommends apt-transport-https ca-certificates curl software-properties-common

  1. 接下来要做的事情是将 Docker 的apt存储库gpg密钥添加到我们的apt源列表中:
$ curl -fsSL https://download.docker.com/linux/ubuntu/gpg | sudo apt-key add –

  1. 成功后,简单的OK将是响应。使用以下命令设置稳定的存储库:
$ sudo add-apt-repository "deb [arch=amd64] https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable"

  1. 接下来,我们需要更新apt软件包索引:
$ sudo apt-get update

  1. 现在我们需要确保apt安装程序将使用官方的 Docker 存储库,而不是默认的 Ubuntu 存储库(其中可能包含较旧版本的 Docker):
$ apt-cache policy docker-ce

  1. 使用此命令安装最新版本的 Docker:
$ sudo apt-get install -y docker-ce

  1. apt软件包管理器将下载许多软件包;这些将是所需的依赖项和docker-engine本身:

  1. 就是这样,您应该已经准备好了。让我们验证一下 Docker 是否在我们的 Linux 系统上运行:
$sudo docker run milkyway/java-hello-world

  1. 正如您所看到的,Docker 引擎将从 Docker Hub 拉取milkyway/java-hello-world镜像及其所有层,并以问候语作出响应:

但是我们需要用sudo运行 Docker 命令吗?原因是 Docker 守护程序始终以root用户身份运行,自 Docker 版本 0.5.2 以来,Docker 守护程序绑定到 Unix 套接字而不是 TCP 端口。默认情况下,该 Unix 套接字由用户root拥有,因此,默认情况下,您可以使用 sudo 访问它。让我们修复它,以便能够以普通用户身份运行Docker命令:

  1. 首先,如果还不存在Docker组,请添加它:
$ sudo groupadd docker

  1. 然后,将您自己的用户添加到 Docker 组。将用户名更改为与您首选的用户匹配:
$ sudo gpasswd -a jarek docker

  1. 重新启动 Docker 守护程序:
$ sudo service docker restart

  1. 现在让我们注销并再次登录,并且再次执行docker run命令,这次不需要sudo。正如您所看到的,您现在可以像普通的非root用户一样使用 Docker 了:

  1. 就是这样。我们的 Linux Docker 安装已准备就绪。现在让我们在 Windows 上进行安装。

在 Windows 上安装

本机 Docker 软件包可在 64 位 Windows 10 专业版或企业版上运行。它使用 Windows 10 虚拟化引擎来虚拟化 Linux 内核。这就是安装包不再包含 VirtualBox 设置的原因,就像以前的 Docker for Windows 版本一样。本机应用程序以典型的.msi安装包提供。如果你运行它,它会向你打招呼,并说它将从现在开始生活在你的任务栏托盘下,小鲸鱼图标下:

托盘中的 Docker 图标会告诉你 Docker 引擎的状态。它还包含一个小但有用的上下文菜单:

让我们探索偏好设置,看看有什么可用的。第一个选项卡,常规,允许你设置 Docker 在你登录时自动运行。如果你每天使用 Docker,这可能是推荐的设置。你也可以标记自动检查更新并发送使用统计信息。发送使用统计信息将帮助 Docker 团队改进未来版本的工具;除非你有一些关键任务、安全工作要完成,我建议打开这个选项。这是为未来版本贡献的好方法:

第二个选项卡,共享驱动器,允许你选择本地 Windows 驱动器,这些驱动器将可用于你将要运行的 Docker 容器:

我们将在第二章中介绍 Docker 卷,网络和持久存储。在这里选择一个驱动器意味着你可以映射本地系统的一个目录,并将其作为 Windows 主机机器读取到你的 Docker 容器中。下一个偏好设置页面,高级,允许我们对在我们的 Windows PC 上运行的 Docker 引擎进行一些限制,并选择 Linux 内核的虚拟机镜像的位置:

默认值通常是开箱即用的,除非在开发过程中遇到问题,我建议保持它们不变。网络让你配置 Docker 与网络的工作方式,与子网地址和掩码或 DNS 服务器一样。我们将在第二章中介绍 Docker 网络,网络和持久存储

如果你在网络中使用代理,并希望 Docker 访问互联网,你可以在代理选项卡中设置代理设置:

对话框类似于您在其他应用程序中找到的,您可以在其中定义代理设置。它可以接受无代理、系统代理设置或手动设置(使用不同的代理进行 HTPP 和 HTTPS 通信)。下一个窗格可以用来配置 Docker 守护程序:

基本开关意味着 Docker 使用基本配置。您可以将其切换到高级,并以 JSON 结构的形式提供自定义设置。实验性功能与我们在 macOS 上进行 Docker 设置时已经提到的相同,这将是 Checkpoint & Restore 或启用 Docker 图形驱动程序插件,例如。您还可以指定远程注册表的列表。Docker 将从不安全的注册表中拉取图像,而不是使用纯粹的 HTTP 而不是 HTTPS。

在最后一个窗格上使用重置选项可以让您重新启动或将 Docker 重置为出厂设置:

请注意,将 Docker 重置为其初始设置也将删除当前在您的计算机上存在的所有镜像和容器。

“打开 Kitematic...”选项也出现在 Docker 托盘图标上下文菜单中,这是启动 Kitematic 的快捷方式。如果您是第一次这样做,并且没有安装 Kitematic,Docker 会询问您是否想要先下载它:

安装 Docker for Windows 就是这样。这是一个相当轻松的过程。在安装过程的最后一步,让我们检查一下 Docker 是否可以从命令提示符中运行,因为这可能是您将来启动它的方式。在命令提示符或 PowerShell 中执行以下命令:

docker run milkyway/java-hello-world

正如您在上一个屏幕截图中所看到的,我们有一个来自作为 Docker 容器启动的 Java 应用程序的 Hello World 消息。

摘要

就是这样。我们的 Docker for Windows 安装已经完全可用。在本章中,我们已经了解了 Docker 背后的理念以及传统虚拟化和容器化之间的主要区别。我们对 Docker 的核心概念,如镜像、层、容器和注册表,了解很多。我们应该已经在本地计算机上安装了 Docker;现在是时候继续学习更高级的 Docker 功能,比如网络和持久存储了。

第二章:网络和持久存储

在上一章中,我们学到了很多关于 Docker 概念的知识。我们知道容器是镜像的运行时。它将包含您的 Java 应用程序以及所有所需的依赖项,如 JRE 或应用程序服务器。但是,很少有情况下 Java 应用程序是自给自足的。它总是需要与其他服务器通信(如数据库),或者向其他人公开自己(如在应用程序服务器上运行的 Web 应用程序,需要接受来自用户或其他应用程序的请求)。现在是描述如何将 Docker 容器开放给外部世界、网络和持久存储的时候了。在本章中,您将学习如何配置网络,并公开和映射网络端口。通过这样做,您将使您的 Java 应用程序能够与其他容器通信。想象一下以下情景:您可以有一个容器运行 Tomcat 应用程序服务器与您的 Java 应用程序通信,与另一个运行数据库的容器通信,例如PostgreSQL。虽然 Kubernetes 对网络的处理方式与 Docker 默认提供的有些不同,但让我们先简要地关注 Docker 本身。稍后我们将介绍 Kubernetes 的特定网络。容器与外部世界的通信不仅仅是关于网络;在本章中,我们还将关注数据卷作为在容器运行和停止周期之间持久保存数据的一种方式。

本章涵盖以下主题:

  • Docker 网络类型

  • 网络命令

  • 创建网络

  • 映射和暴露端口

  • 与卷相关的命令

  • 创建和删除卷

让我们从 Docker 网络开始。

网络

为了使您的容器能够与外部世界通信,无论是另一个服务器还是另一个 Docker 容器,Docker 提供了不同的配置网络的方式。让我们从可用于我们的容器的网络类型开始。

Docker 网络类型

Docker 提供了三种不同的网络类型。要列出它们,请执行docker network ls命令:

$ docker network ls

Docker 将输出包含唯一网络标识符、名称和在幕后支持它的驱动程序的可用网络列表:

为了了解各种网络类型之间的区别,让我们现在逐一描述它们。

桥接

这是 Docker 中的默认网络类型。当 Docker 服务守护程序启动时,它会配置一个名为docker0的虚拟桥。如果您没有使用docker run -net=<NETWORK>选项指定网络,Docker 守护程序将默认将容器连接到桥接网络。此外,如果您创建一个新的容器,它将连接到桥接网络。对于 Docker 创建的每个容器,它都会分配一个虚拟以太网设备,该设备将连接到桥上。虚拟以太网设备被映射为在容器中显示为eth0,使用 Linux 命名空间,如您可以在以下图表中看到的那样:

in-container eth0接口从桥的地址范围中获得一个 IP 地址。换句话说,Docker 将从桥可用的范围中找到一个空闲的 IP 地址,并配置容器的eth0接口为该 IP 地址。从现在开始,如果新容器想要连接到互联网,它将使用桥;主机自己的 IP 地址。桥将自动转发连接到它的任何其他网络接口之间的数据包,并允许容器与主机机器以及同一主机上的容器进行通信。桥接网络可能是最常用的网络类型。

主机

这种类型的网络只是将容器放在主机的网络堆栈中。也就是说,主机上定义的所有网络接口都可以被容器访问,如您可以在以下图表中看到的那样:

如果您使用-net=host选项启动容器,那么容器将使用主机网络。它将与普通网络一样快:没有桥接,没有转换,什么都没有。这就是为什么当您需要获得最佳网络性能时,它可能会有用。在主机网络堆栈中运行的容器将比在桥接网络上运行的容器实现更快的网络性能,无需穿越docker0 bridgeiptables端口映射。在主机模式下,容器共享主机的网络命名空间(例如您的本地计算机),直接暴露给外部世界。通过使用-net=host命令开关,您的容器将通过主机的 IP 地址访问。但是,您需要意识到这可能是危险的。如果您有一个以 root 身份运行的应用程序,并且它有一些漏洞,那么存在主机网络被 Docker 容器远程控制的风险。使用主机网络类型还意味着您需要使用端口映射来访问容器内的服务。我们将在本章后面介绍端口映射。

长话短说,none 网络根本不配置网络。这种网络类型不使用任何驱动程序。当您不需要容器访问网络时,-net=none开关将完全禁用docker run命令的网络。

Docker 提供了一组简短的命令来处理网络。您可以从 shell(Linux 或 macOS)或 Windows 的命令提示符和 PowerShell 中运行它们。现在让我们来了解它们。

网络命令

在 Docker 中管理网络的父命令是docker network。您可以使用docker network help命令列出整个命令集,如下面的屏幕截图所示:

要获得特定命令的每个选项的详细语法和描述,请对每个命令使用-help开关。例如,要获取docker network create可用参数的描述,执行docker network create -help

让我们简要描述每个可用的命令:

  • **$ docker network ls**:这是我们之前使用的命令,它简单地列出了容器可用的网络。它将输出网络标识符、名称、使用的驱动程序和网络的范围。

  • **$ docker network create**:创建新网络。命令的完整语法是,docker network create [OPTIONS] NETWORK。我们将在短时间内使用该命令

  • **$ docker network rm**dockercnetworkcrm命令简单地删除网络

  • **$ docker network connect**:将容器连接到特定网络

  • **$ docker network disconnect**:正如其名称所示,它将断开容器与网络的连接

  • **$ docker network inspect**:docker network inspect 命令显示有关网络的详细信息。如果您遇到网络问题,这非常有用。我们现在要创建和检查我们的网络

docker network inspect 命令显示有关网络的详细信息。如果您遇到网络问题,这非常有用。我们现在要创建和检查我们的网络。

创建和检查网络

让我们创建一个网络。我们将称我们的网络为myNetwork。从 shell 或命令行执行以下命令:

$ docker network create myNetwork

这是命令的最简单形式,但可能会经常使用。它采用默认驱动程序(我们没有使用任何选项来指定驱动程序,我们将只使用默认的桥接驱动程序)。作为输出,Docker 将打印出新创建的网络的标识符:

稍后您将使用此标识符来连接容器或检查网络属性。命令的最后一个参数是网络的名称,这比 ID 更方便和更容易记住。在我们的情况下,网络名称是myNetworkdocker network create 命令接受更多参数,如下表所示:

选项 描述
-d, -driver="bridge" 管理网络的驱动程序
-aux-address=map[] 网络驱动程序使用的辅助 IPv4 或 IPv6 地址
-gateway=[] 主子网的 IPv4 或 IPv6 网关
-ip-range=[] 从子范围分配容器 IP
-ipam-driver=default IP 地址管理驱动程序
-o-opt=map[] 设置驱动程序的特定选项
-subnet=[] 以 CIDR 格式表示网络段的子网

最重要的参数之一是-d--driver)选项,默认值为 bridge。驱动程序允许您指定网络类型。您记得,Docker 默认提供了几个驱动程序:hostbridgenone

创建网络后,我们可以使用docker network inspect命令检查其属性。从 shell 或命令行执行以下操作:

$ docker network inspect myNetwork

作为回应,你将获得关于你的网络的大量详细信息。正如你在截图中看到的,我们新创建的网络使用桥接驱动程序,即使我们没有明确要求使用它:

正如你所看到的,容器列表是空的,原因是我们还没有将任何容器连接到这个网络。让我们现在来做。

将容器连接到网络

现在我们的myNetwork准备就绪,我们可以运行 Docker 容器并将其附加到网络。要启动容器,我们将使用docker run --net=<NETWORK>选项,其中<NETWORK>是默认网络之一的名称,或者是你自己创建的网络的名称。例如,让我们运行 Apache Tomcat,这是 Java Servlet 和 JavaServer 页面技术的开源实现:

docker run -it --net=myNetwork tomcat

这将需要一些时间。Docker 引擎将从 Docker Hub 拉取所有 Tomcat 镜像层,然后运行 Tomcat 容器。还有另一种选项可以将网络附加到容器上,你可以告诉 Docker 你希望容器连接到其他容器使用的相同网络。这样,你不需要显式指定网络,只需告诉 Docker 你希望两个容器在同一网络上运行。要做到这一点,使用container:前缀,就像下面的例子一样:

docker run -it --net=bridge myTomcat

docker run -it --net=container:myTomcat myPostgreSQL

在前面的例子中,我们使用桥接网络运行了myTomcat镜像。下一个命令将使用与myTomcat相同的网络运行myPostgreSQL镜像。这是一个非常常见的情况;你的应用程序将在与数据库相同的网络上运行,这将允许它们进行通信。当然,你在同一网络中启动的容器必须在同一 Docker 主机上运行。网络中的每个容器都可以直接与网络中的其他容器通信。尽管如此,网络本身会将容器与外部网络隔离开来,如下图所示:

如果在桥接、隔离网络中运行容器,我们需要指示 Docker 如何将容器的端口映射到主机的端口。我们现在要做的就是这个。

暴露端口和映射端口

通常情况下,当您希望容器化应用程序接受传入连接时,无论是来自其他容器还是来自 Docker 之外,都会出现这种情况。它可以是一个在端口 80 上监听的应用服务器,也可以是一个接受传入请求的数据库。

镜像可以暴露端口。暴露端口意味着您的容器化应用程序将在暴露的端口上监听。例如,Tomcat 应用服务器默认将在端口8080上监听。在同一主机和同一网络上运行的所有容器都可以与该端口上的 Tomcat 通信。暴露端口可以通过两种方式完成。它可以在 Dockerfile 中使用EXPOSE指令(我们将在稍后关于创建镜像的章节中进行)或者在docker run命令中使用--expose选项。接下来是这个官方 Tomcat 镜像的 Dockerfile 片段(请注意,为了示例的清晰度,它已经被缩短):

FROM openjdk:8-jre-alpine

ENV CATALINA_HOME /usr/local/tomcat

ENV PATH $CATALINA_HOME/bin:$PATH

RUN mkdir -p "$CATALINA_HOME"

WORKDIR $CATALINA_HOME

EXPOSE 8080

CMD ["catalina.sh", "run"]

正如您所看到的,在 Dockerfile 的末尾附近有一个EXPOSE 8080指令。这意味着我们可以期望该容器在运行时将监听端口号8080。让我们再次运行最新的 Tomcat 镜像。这次,我们还将为我们的容器命名为myTomcat。使用以下命令启动应用服务器:

docker run -it --name myTomcat --net=myNetwork tomcat

为了检查同一网络上的容器是否可以通信,我们将使用另一个镜像busybox。BusyBox 是一种软件,它在一个可执行文件中提供了几个精简的 Unix 工具。让我们在单独的 shell 或命令提示符窗口中运行以下命令:

docker run -it --net container:myTomcat busybox

正如您所看到的,我们已经告诉 Docker,我们希望我们的busybox容器使用与 Tomcat 相同的网络。作为另一种选择,当然也可以使用--net myNetwork选项显式指定网络名称。

让我们检查它们是否确实可以通信。在运行busybox的 shell 窗口中执行以下操作:

$ wget localhost:8080

上一个指令将在另一个容器上监听的端口8080上执行HTTP GET请求。在成功下载 Tomcat 的index.html之后,我们证明了两个容器可以通信:

到目前为止,运行在同一主机和同一网络上的容器可以相互通信。但是如何与外部通信呢?端口映射派上了用场。我们可以将 Docker 容器暴露的端口映射到主机的端口上,这将是我们的本地主机。总体思路是我们希望主机上的端口映射到运行容器中的特定端口,就像 Tomcat 容器的端口号8080一样。

绑定主机到容器的端口(或一组端口),我们使用docker run命令的-p标志,如下例所示:

$ docker run -it --name myTomcat2 --net=myNetwork -p 8080:8080 tomcat

上一个命令运行了另一个 Tomcat 实例,也连接到myNetwork网络。然而,这一次,我们将容器的端口8080映射到相同编号的主机端口。-p开关的语法非常简单:只需输入主机端口号,冒号,然后是您想要映射的容器中的端口号:

$ docker run -p <hostPort>:<containerPort> <image ID or name>

Docker 镜像可以使用 Dockerfile 中的EXPOSE指令(例如EXPOSE 7000-8000)或docker run命令向其他容器暴露一系列端口,例如:

$ docker run --expose=7000-8000 <container ID or name>

然后,您可以使用docker run命令将一系列端口从主机映射到容器:

$ docker run -p 7000-8000:7000-8000 <container ID or name>

让我们验证一下是否可以从 Docker 外部访问 Tomcat 容器。为此,让我们运行带有映射端口的 Tomcat:

$ docker run -it --name myTomcat2 --net=myNetwork -p 8080:8080 tomcat 

然后,我们可以在我们喜爱的网络浏览器中输入以下地址:http://localhost:8080

结果,我们可以看到 Tomcat 的默认欢迎页面,直接从运行的 Docker 容器中提供,如下截图所示:

很好,我们可以从 Docker 外部与我们的容器通信。顺便说一句,我们现在在主机上有两个隔离的 Tomcat 运行,没有任何端口冲突、资源冲突等。这就是容器化的力量。

您可能会问,暴露和映射端口之间有什么区别,也就是--expose开关和-p开关之间有什么区别?嗯,--expose将在运行时暴露一个端口,但不会创建任何映射到主机。暴露的端口只对在同一网络上运行的另一个容器和在同一 Docker 主机上运行的容器可用。另一方面,-p选项与publish相同:它将创建一个端口映射规则,将容器上的端口映射到主机系统上的端口。映射的端口将从 Docker 外部可用。请注意,如果您使用-p,但 Dockerfile 中没有EXPOSE,Docker 将执行隐式的EXPOSE。这是因为,如果一个端口对公众开放,它也会自动对其他 Docker 容器开放。

无法在 Dockerfile 中创建端口映射。映射一个或多个端口只是一个运行时选项。原因是端口映射配置取决于主机。Dockerfile 需要是与主机无关且可移植的。

您只能在运行时使用-p绑定端口。

还有一种选项,允许您一次性自动映射镜像中暴露的所有端口(即 Dockerfile 中的端口)在容器启动时。-P开关(这次是大写P)将动态分配一个随机的主机端口映射到 Dockerfile 中已经暴露的所有容器端口。

-p选项在映射端口时比-P提供更多控制。Docker 不会自动选择任何随机端口;由您决定主机上应该映射到容器端口的端口。

如果您运行以下命令,Docker 将在主机上将一个随机端口映射到 Tomcat 的暴露端口号8080

$ docker run -it --name myTomcat3 --net=myNetwork -P tomcat

要确切查看已映射的主机端口,可以使用docker ps命令。这可能是确定当前端口映射的最快方法。docker ps命令用于查看正在运行的容器列表。从单独的 shell 控制台执行以下操作:

$ docker ps

在输出中,Docker 将列出所有正在运行的容器,显示在PORTS列中已经映射了哪些端口:

正如您在上一张截图中所看到的,我们的myTomcat3容器将把8080端口映射到主机上的32772端口。再次在http://localhost:32772地址上执行HTTP GET方法将会显示myTomcat3的欢迎页面。docker ps命令的替代方法是 docker port 命令,与容器 ID 或名称一起使用(这将为您提供已映射的端口信息)。在我们的情况下,这将是:

$ docker port myTomcat3

因此,Docker 将输出映射,表示容器中的端口号 80 已映射到主机上的端口号8080

关于所有端口映射的信息也可以在 docker inspect 命令的结果中找到。例如,执行以下命令:

$ docker inspect myTomcat2

docker inspect命令的输出中,您将找到包含映射信息的Ports部分:

让我们简要总结一下与暴露和映射端口相关的选项:

指令 含义
EXPOSE 表示指定端口上有服务可用。在 Dockerfile 中使用,使暴露的端口对其他容器开放。
--expose EXPOSE相同,但在运行时,在容器启动期间使用。
-p hostPort:containerPort 指定端口映射规则,将容器上的端口与主机上的端口进行映射。使得 Docker 外部的端口开放。
-P 将主机的动态分配的随机端口(或端口)映射到使用EXPOSE--expose暴露的所有端口。

映射端口是一个很棒的功能。它为您提供了灵活的配置可能性,可以将您的容器开放给外部世界。事实上,如果您希望容器化的 Web 服务器、数据库或消息服务器能够与其他服务器通信,这是必不可少的。如果默认的网络驱动程序集不够用,您可以尝试在互联网上找到特定的驱动程序,或者自己开发一个。Docker 引擎网络插件扩展了 Docker 以支持各种网络技术,如 IPVLAN、MACVLAN,或者完全不同和奇特的技术。在 Docker 中,网络的可能性几乎是无限的。现在让我们专注于 Docker 容器可扩展性卷的另一个非常重要的方面。

持久存储

正如您在第一章中所记得的,Docker 简介,Docker 容器文件系统默认是临时的。如果您启动一个 Docker 镜像(即运行容器),您将得到一个读写层,位于层栈的顶部。您可以随意创建,修改和删除文件;如果您将更改提交回镜像,它们将变得持久。如果您想在镜像中创建应用程序的完整设置,包括所有环境,这是一个很好的功能。但是,当涉及存储和检索数据时,这并不是很方便。最好的选择是将容器的生命周期和您的应用程序与数据分开。理想情况下,您可能希望将这些分开,以便由您的应用程序生成(或使用)的数据不会被销毁或绑定到容器的生命周期,并且可以被重复使用。

一个完美的例子是一个 Web 应用程序服务器:Docker 镜像包含 Web 服务器软件,例如 Tomcat,部署了您的 Java 应用程序,配置好并且可以立即使用。但是,服务器将使用的数据应该与镜像分离。这是通过卷来实现的,在本章的这部分我们将重点关注卷。卷不是联合文件系统的一部分,因此写操作是即时的并且尽可能快,不需要提交任何更改。

卷存在于联合文件系统之外,并且作为主机文件系统上的普通目录和文件存在。

Docker 数据卷有三个主要用途:

  • 在主机文件系统和 Docker 容器之间共享数据

  • 在容器被移除时保留数据

  • 与其他 Docker 容器共享数据

让我们从我们可以使用的卷相关命令列表开始。

与卷相关的命令

与卷相关的命令的基础是 docker volume。命令如下:

  • **$docker volume create**:创建一个卷

  • **$ docker volume inspect**:显示一个或多个卷的详细信息

  • **$docker volume ls**:列出卷

  • **$ docker volume rm**:删除一个或多个卷

  • **$ docker volume prune**:删除所有未使用的卷,即不再映射到任何容器的所有卷

与与网络相关的命令类似,如果您使用-help开关执行每个命令,您可以获得详细的描述和所有可能的选项,例如:docker volume create -help。让我们开始创建一个卷。

创建卷

正如您从第一章 Docker 简介中记得的那样,Docker for Windows 或 Docker for Mac 中有一个设置屏幕,允许我们指定 Docker 可以访问哪些驱动器。首先,让我们在 Docker for Windows 中标记驱动器 D,以便让它可用于 Docker 容器:

为了我们的卷示例,我在我的 D 驱动器上创建了一个docker_volumes/volume1目录,并在其中创建了一个空的data.txt文件:

有两种创建卷的方法。第一种是在运行镜像时指定-v选项。让我们运行我们已经知道的busybox镜像,并同时为我们的数据创建一个卷:

$ docker run -v d:/docker_volumes/volume1:/volume -it busybox

在上一个命令中,我们使用-v开关创建了一个卷,并指示 Docker 将host目录d:/docker_volumes/volume1映射到正在运行的容器中的/volume目录。如果我们现在列出正在运行的busybox容器中/volume目录的内容,我们可以看到我们的空data1.txt文件,如下面的截图所示:

-v选项中的参数是主机上的目录(在这种情况下是您自己的操作系统,在我们的示例中是d:/docker_volumes/volume1),一个冒号,以及容器中可用的路径,在我们的示例中是/volume1。创建的卷是一种映射的目录。它将对容器可用,并且也可以从主机操作系统中访问。映射目录(主机的d:/docker_volumes/volume1)中已经存在的任何文件将在映射期间在容器内可用;它们不会在映射期间被删除。

-v选项不仅可以用于目录,还可以用于单个文件。如果您想在容器中使用配置文件,这将非常有用。最好的例子是官方 Docker 文档中的例子:

$ docker run -it -v ~/.bash_history:/root/.bash_history ubuntu

执行上一个命令将在本地机器和正在运行的 Ubuntu 容器之间给您相同的 bash 历史记录。最重要的是,如果您退出容器,您本地机器上的 bash 历史记录将包含您在容器内执行的 bash 命令。映射文件对您作为开发人员在调试或尝试应用程序配置时也很有用。

从主机映射单个文件允许暴露应用程序的配置。

除了在启动容器时创建卷外,还有一个命令可以在启动容器之前创建卷。我们现在将使用它。

创建无名称卷的最简单形式将是:

$ docker volume create

作为输出,Docker 将为您提供卷标识符,您以后可以使用它来引用此卷。最好给卷一个有意义的名称。要创建一个独立的命名卷,请执行以下命令:

$ docker volume create --name myVolume

要列出我们现在可用的卷,执行docker volume ls命令:

$ docker volume ls

输出将简单地列出到目前为止我们创建的卷的列表:

以这种方式创建的卷不会显式地映射到主机上的路径。如果容器的基本映像包含指定挂载点处的数据(作为 Dockerfile 处理的结果),则此数据将在卷初始化时复制到新卷中。这与显式指定host目录不同。其背后的想法是,在创建图像时,您不应该关心卷在主机系统上的位置,使图像在不同主机之间可移植。让我们运行另一个容器并将命名卷映射到其中:

$ docker run -it -v myVolume:/volume --name myBusybox3 busybox

请注意,这一次,我们没有在主机上指定路径。相反,我们指示 Docker 使用我们在上一步创建的命名卷。命名卷将在容器中的/volume路径处可用。让我们在卷上创建一个文本文件:

如果我们现在运行另一个容器,指定相同的命名卷,我们将能够访问我们在之前创建的myBusybox3容器中可用的相同数据:

$ docker run -it -v myVolume:/volume --name myBusybox4 busybox

我们的两个容器现在共享单个卷,如下截图所示:

Docker 命名卷是在容器之间共享卷的一种简单方法。它们也是数据专用容器的一个很好的替代方案,这在 Docker 的旧时代曾经是一种常见做法。现在已经不再是这样了——命名卷要好得多。值得注意的是,您不仅限于每个容器只有一个卷,因为那将是一个严重的限制。

您可以多次使用-v来挂载多个数据卷。

在容器之间共享卷的另一个选项是-volumes-from开关。如果您的一个容器已经挂载了卷,通过使用此选项,我们可以指示 Docker 使用另一个容器中映射的卷,而不是提供卷的名称。考虑以下示例:

$ docker run -it -volumes-from myBusybox4 --name myBusybox5 busybox

以这种方式运行myBusybox5容器后,如果再次进入运行的myBusybox5容器中的/volume目录,您将看到相同的data.txt文件。

docker volume ls命令可以接受一些过滤参数,这可能非常有用。例如,您可以列出未被任何容器使用的卷:

docker volume ls -f dangling=true

不再被任何容器使用的卷可以通过使用 docker volumes prune 命令轻松删除:

docker volume prune

要列出使用特定驱动程序创建的卷(我们将在短时间内介绍驱动程序),您可以使用驱动程序过滤器来过滤列表,如下例所示:

docker volume ls -f driver=local

最后但同样重要的是,创建卷的另一种方法是在 Dockerfile 中使用VOLUME CREATE指令。在本书的后面,当从 Dockerfile 创建镜像时,我们将使用它。使用VOLUME CREATE指令创建卷与在容器启动期间使用-v选项相比有一个非常重要的区别:当使用VOLUME CREATE时,您无法指定host目录。这类似于暴露和映射端口。您无法在 Dockerfile 中映射端口。Dockerfile 应该是可移植的、可共享的和与主机无关的。host目录是 100%依赖于主机的,会在任何其他机器上出现问题,这与 Docker 的理念有点不符。因此,在 Dockerfile 中只能使用可移植指令。

如果需要在创建卷时指定host目录,则需要在运行时指定它。

删除卷

与创建卷一样,Docker 中有两种删除卷的方法。首先,您可以通过引用容器的名称并执行 docker rm -v命令来删除卷:

$ docker rm -v <containerName or ID>

当删除容器时,如果没有提供-v选项,Docker 不会警告您删除其卷。结果,您将拥有悬空卷——不再被容器引用的卷。正如您记得的那样,使用docker volume prune命令很容易摆脱它们。

另一种删除卷的选项是使用docker volume rm命令:

$ docker volume rm <volumeName or ID>

如果卷恰好被容器使用,Docker 引擎将不允许您删除它,并会给出警告消息:

正如您所看到的,在 Docker 中创建、共享和删除卷并不那么棘手。它非常灵活,允许创建您的应用程序所需的设置。但这种灵活性还有更多。在创建卷时,您可以指定--driver选项(或简写为-d),如果您需要映射一些外部、不太标准的存储,这可能会很有用。到目前为止,我们创建的卷都是使用本地文件系统驱动程序(文件存储在主机系统的本地驱动器上);您可以在使用volume inspect命令检查卷时看到驱动程序名称。不过还有其他选项——现在让我们来看看它们。

卷驱动程序

与网络驱动程序插件一样,卷插件扩展了 Docker 引擎的功能,并实现了与其他类型的存储的集成。在互联网上有大量可用的免费插件;您可以在 Docker 的 GitHub 页面上找到一个列表。其中一些包括:

  • Azure 文件存储的 Docker 卷驱动程序:这是一个 Docker 卷驱动程序,它使用 Azure 文件存储将文件共享挂载到 Docker 容器作为卷。它使用 Azure 文件存储的网络文件共享(SMB/CIFS 协议)功能。您可以创建可以在不同主机之间无缝迁移或在不同主机上运行的多个容器之间共享卷的 Docker 容器。

  • IPFS:开源卷插件,允许将 IPFS 文件系统用作卷。IPFS 是一个非常有趣和有前途的存储系统;它可以以高效的方式分发大量数据。它提供了去重、高性能和集群持久性,提供安全的 P2P 内容传递、快速性能和去中心化的归档。IPFS 提供了对数据的弹性访问,独立于低延迟或对骨干网的连接。

  • Keywhiz:您可以使用此驱动程序使您的容器与远程 Keywhiz 服务器通信。Keywhiz 是一个用于管理和分发秘密数据的系统,例如 TLS 证书/密钥、GPG 密钥、API 令牌和数据库凭据。Keywhiz 使管理变得更容易和更安全:Keywhiz 服务器在集群中将加密的秘密数据集中存储在数据库中。客户端使用相互认证的 TLSmTLS)来检索他们有权限访问的秘密。

从前面的例子中可以看出,它们非常有趣,有时甚至是异国情调的。由于 Docker 及其插件架构的可扩展性,您可以创建非常灵活的设置。但是,第三方驱动程序并不总是引入全新的存储类型;有时它们只是扩展现有的驱动程序。一个例子就是 Local Persist Plugin,它通过允许您在主机的任何位置指定挂载点来扩展默认的本地驱动程序功能,从而使文件始终持久存在,即使通过docker volume rm命令删除了卷。

如果您需要一个尚未提供的卷插件,您可以自己编写。该过程在 Docker 的 GitHub 页面上有非常详细的文档,还有可扩展的示例。

我们现在已经了解了如何将我们的容器开放给外部世界。我们可以使用网络和挂载卷来在容器和其他主机之间共享数据。让我们总结一下我们在本章中学到的内容:

  • 我们可以使用网络插件来进一步扩展网络数据交换

  • 卷会持久保存数据,即使容器重新启动

  • 对卷上的文件的更改是直接进行的,但在更新镜像时不会包括这些更改

  • 数据卷即使容器本身被删除也会持久存在

  • 卷允许在主机文件系统和 Docker 容器之间共享数据,或者在其他 Docker 容器之间共享数据

  • 我们可以使用卷驱动程序来进一步扩展文件交换的可能性

同一台 Docker 主机上的容器在默认的桥接网络上会自动看到彼此。

总结

在本章中,我们学习了 Docker 网络和存储卷功能。我们知道如何区分各种网络类型,如何创建网络,以及如何公开和映射网络端口。

我们已经学习了与卷相关的命令,现在可以创建或删除卷。在第三章 使用微服务中,我们将专注于使用 Docker 和 Kubernetes 部署的软件,以及后来的 Java 微服务。

第三章:使用微服务

在阅读前两章之后,您现在应该对 Docker 架构及其概念有所了解。在我们继续 Java、Docker 和 Kubernetes 之旅之前,让我们先了解一下微服务的概念。

通过阅读本章,您将了解为什么转向微服务和云开发是必要的,以及为什么单片架构不再是一个选择。微服务架构也是 Docker 和 Kubernetes 特别有用的地方。

本章将涵盖以下主题:

  • 微服务简介和与单片架构的比较

  • Docker 和 Kubernetes 如何适应微服务世界

  • 何时使用微服务架构

在我们实际创建 Java 微服务并使用 Docker 和 Kubernetes 部署之前,让我们先解释一下微服务的概念,并将其与单片架构进行比较。

微服务简介

根据定义,微服务,也称为微服务架构MSA),是一种架构风格和设计模式,它认为一个应用程序应该由一组松散耦合的服务组成。这种架构将业务领域模型分解为由服务实现的较小、一致的部分。换句话说,每个服务都将有自己的责任,独立于其他服务,每个服务都将提供特定的功能。

这些服务应该是孤立的和自治的。然而,它们当然需要通信以提供一些业务功能。它们通常使用REST暴露或通过发布和订阅事件的方式进行通信。

解释微服务背后理念的最好方式是将其与构建大型应用程序的旧传统方法——单片设计进行比较。

看一下下面的图表,展示了单片应用程序和由微服务组成的分布式应用程序。

正如您在上一个图表中所看到的,单片应用程序与使用微服务架构创建的应用程序完全不同。让我们比较这两种方法,并指出它们的优点和缺点。

单片与微服务

我们从描述单片架构开始比较,以展示其特点。

单片架构

过去,我们习惯于创建完整、庞大和统一的代码片段作为应用程序。以 Web MVC 应用程序为例。这种应用程序的简化架构如下图所示:

正如你所看到的,该图表展示了典型的网络应用程序,这里是银行系统的一个片段。这是一个模型 视图 控制器MVC)应用程序,由模型、视图和控制器组成,用于向客户端浏览器提供 HTML 内容。它可能还可以通过 REST 端点接受和发送 JSON 内容。这种应用程序是作为一个单一单元构建的。正如你所看到的,我们在这里有几个层。企业应用程序通常分为三个部分:客户端用户界面(包括在浏览器中运行的 HTML 页面和 JavaScript)、处理HTTP请求的服务器端部分(可能使用类似 spring 的控制器构建),然后我们有一个服务层,可能使用 EJB 或 Spring 服务来实现。服务层执行特定领域的业务逻辑,并最终检索/更新数据库中的数据。这是一个非常典型的网络应用程序,我们每个人可能都曾经创建过。整个应用程序是一个单体,一个单一的逻辑可执行文件。要对系统进行任何更改,我们必须构建和部署整个服务器端应用程序的更新版本;这种应用程序通常打包成单个 WAR 或 EAR 存档,连同所有静态内容,如 HTML 和 JavaScript 文件一起。一旦部署,所有应用程序代码都在同一台机器上运行。通常情况下,要扩展这种应用程序,需要在集群中的多台机器上部署多个相同的应用程序代码副本,可能在某个负载均衡器后面。

这个设计并不算太糟糕,毕竟我们的应用程序已经上线运行了。但是,世界变化很快,特别是在使用敏捷方法论的时候。企业已经开始要求比以往更快地发布软件。尽快成为 IT 开发语言词典中非常常见的词语。规格经常波动,所以代码经常变化并随着时间增长。如果团队规模庞大(在复杂的大型应用程序的情况下可能会是这样),每个人都必须非常小心,不要破坏彼此的工作。随着每个新增的功能,我们的应用程序变得越来越复杂。编译和构建时间变得更长,迟早会变得棘手,使用单元测试或集成测试来测试整个系统。此外,新成员加入团队的入口点可能令人望而生畏,他们需要从源代码存储库中检出整个项目。然后他们需要在他们的集成开发环境中构建它(在大型应用程序的情况下并不总是那么容易),并分析和理解组件结构以完成他们的工作。此外,负责用户界面部分的人需要与负责中间层的开发人员、数据库建模人员、数据库管理员等进行沟通。随着时间的推移,团队结构往往会开始模仿应用程序架构。有风险,即特定层上的开发人员倾向于尽可能多地将逻辑放入他所控制的层中。结果,随着时间的推移,代码可能变得难以维护。我们都曾经历过这种情况,对吧?

此外,单片系统的扩展并不像将 WAR 或 EAR 放入另一个应用服务器然后启动那么容易。因为所有应用代码都在服务器上的同一个进程中运行,通常几乎不可能扩展应用程序的各个部分。举个例子:我们有一个集成了 VOIP 外部服务的应用程序。我们的应用程序用户不多,但是来自 VOIP 服务的事件却很多,我们需要处理。为了处理不断增加的负载,我们需要扩展我们的应用程序,在单片系统的情况下,我们需要扩展整个系统。这是因为应用程序是一个单一的、庞大的工作单元。如果应用程序的一个服务是 CPU 或资源密集型的,整个服务器必须配备足够的内存和 CPU 来处理负载。这可能很昂贵。每个服务器都需要一个快速的 CPU 和足够的 RAM 来运行我们应用程序中最苛刻的组件。

所有单片应用程序都具有以下特点:

  • 它们通常很大,经常涉及许多人参与其中。这可能是一个问题,当将项目加载到 IDE 中时,尽管拥有强大的机器和出色的开发环境,比如 IntelliJ IDEA。但问题不仅仅在于数百、数千或数百万行代码。它还涉及解决方案的复杂性,比如团队成员之间的沟通问题。沟通问题可能导致在应用程序的不同部分针对同一个问题出现多种解决方案。这将使问题变得更加复杂,很容易演变成一个无人能够理解整个系统的大团团乱。此外,人们可能害怕对系统进行重大更改,因为在相反的一端可能会突然停止工作。如果这是由用户在生产系统上报告的,那就太糟糕了。

  • 它们有一个长的发布周期,我们都知道发布管理、权限、回归测试等流程。几乎不可能在一个庞大的单片应用程序中创建持续交付流程。

  • 它们很难扩展;通常需要运维团队投入大量工作来在集群中增加一个新的应用实例。扩展特定功能是不可能的,你唯一的选择就是在集群中增加整个系统的实例。这使得扩展变得非常具有挑战性。

  • 在部署失败的情况下,整个系统将不可用。

  • 你被锁定在特定的编程语言或技术栈中。当然,使用 Java,系统的部分可以用在 JVM 上运行的一个或多个语言开发,比如 Scala、Kotlin 或 Groovy,但如果你需要与.net库集成,问题就开始了。这也意味着你不总是能够使用合适的工具来完成工作。想象一下,你想在数据库中存储大量复杂的文档。它们通常有不同的结构。作为文档数据库的 MongoDB 应该是合适的,对吧?是的,但我们的系统正在运行 Oracle。

  • 它不太适合敏捷开发过程,在这种过程中,我们需要不断实施变更,几乎立即发布到生产环境,并准备好进行下一次迭代。

正如你所看到的,单体应用只适用于小规模团队和小型项目。如果你需要一个更大规模并涉及多个团队的系统,最好看看其他选择。但是对于现有的单体系统,你可能喜欢处理它,该怎么办呢?你可能会意识到,将系统的一些部分外包到小服务中可能会很方便。这将加快开发过程并增加可测试性。它还将使你的应用程序更容易扩展。虽然单体应用仍保留核心功能,但许多部分可以外包到支持核心模块的小边缘服务中。这种方法在下图中呈现:

在这种中间解决方案中,主要业务逻辑将保留在你的应用程序单体中。诸如集成、后台作业或其他可以通过消息触发的小子系统等事物可以移动到它们自己的服务中。你甚至可以将这些服务放入云中,以进一步减少管理基础设施的必要性。这种方法允许你逐渐将现有的单体应用程序转变为完全面向服务的架构。让我们来看看微服务的方法。

微服务架构

微服务架构旨在解决我们提到的单片应用程序的问题。主要区别在于单片应用程序中定义的服务被分解为单独的服务。最重要的是,它们是分别部署在不同的主机上的。看一下下面的图表:

使用微服务架构创建应用程序时,每个微服务负责单一的、特定的业务功能,并且只包含执行该特定业务逻辑所需的实现。这与创建系统的“分而治之”的方式相同。这似乎与面向 SOA 的架构相似。事实上,传统的 SOA 和微服务架构有一些共同的特点。两者都将应用程序的片段组织成服务,并且都定义了清晰的边界,服务可以在其中与其他服务解耦。然而,SOA 起源于需要将单片应用程序与另一个应用程序集成起来。通常情况下,这是通过通常基于 SOAP 的 API 完成的,使用繁重的 XML 消息传递。在 SOA 中,这种集成在中间通常严重依赖某种中间件,通常是企业服务总线(ESB)。微服务架构也可以利用消息总线,但有显著的区别。在微服务架构中,消息层中根本没有逻辑,它纯粹用作从一个服务到另一个服务的消息传输。这与 ESB 形成了鲜明对比,ESB 需要大量的逻辑来进行消息路由、模式验证、消息转换等。因此,微服务架构比传统的 SOA 更不繁琐。

在扩展方面,将微服务与单片应用程序进行比较时存在巨大的差异。微服务的关键优势在于单个服务可以根据资源需求进行独立扩展。这是因为它们是自给自足的和独立的。由于微服务通常部署在资源较小的主机上,主机只需要包含服务正常运行所需的资源。随着资源需求的增长,横向和纵向扩展都很容易。要进行横向扩展,只需部署所需数量的实例来处理特定组件的负载。

在接下来的章节中,当我们开始了解 Kubernetes 时,我们将回到这个概念。与单片系统相比,垂直扩展也更容易和更便宜,您只需升级部署微服务的主机。此外,引入服务的新版本也很容易,您不需要停止整个系统只是为了升级某个功能。事实上,您可以在运行时进行。部署后,微服务提高了整个应用程序的容错能力。例如,如果一个服务出现内存泄漏或其他问题,只有这个服务会受到影响,然后可以修复和升级,而不会干扰其他部分系统。这在单片架构中并非如此,那里一个故障组件可能会导致整个应用程序崩溃。

从开发者的角度来看,将应用程序拆分为单独部署的独立组件具有巨大优势。精通服务器端 JavaScript 的开发者可以开发其node.js部分,而系统的其余部分将使用 Java 开发。这一切都与每个微服务暴露的 API 有关;除了这个 API,每个微服务都不需要了解其他服务的任何信息。这使得开发过程变得更加容易。单独的微服务可以独立开发和测试。基本上,微服务的方法规定,不是所有开发者都在一个庞大的代码库上工作,而是由小而敏捷的团队管理的几个较小的代码库。服务之间唯一的依赖是它们暴露的 API。存储数据也有所不同。正如我们之前所说,每个微服务应该负责存储自己的数据,因为它应该是独立的。这导致了微服务架构的另一个特性,即具有多语言持久性的可能性。微服务应该拥有自己的数据。

微服务之间使用 REST 端点或事件进行通信和数据交换,它们可以以最适合工作的形式存储自己的数据。如果数据是关系型的,服务将使用传统的关系型数据库,如 MySQL 或 PostgreSQL。如果文档数据库更适合工作,微服务可以使用例如 MongoDB,或者如果是图形数据,可以使用 Neo4j。这导致另一个结论,通过实施微服务架构,我们现在只能选择最适合工作的编程语言或框架,这也适用于数据存储。当然,拥有自己的数据可能会导致微服务架构中的一个挑战,即数据一致性。我们将在本章稍后讨论这个主题。

让我们从开发过程的角度总结使用微服务架构的好处:

  • 服务可以使用各种语言、框架及其版本进行编写

  • 每个微服务相对较小,更容易被开发人员理解(从而减少错误),易于开发和可测试

  • 部署和启动时间快,这使开发人员更加高效

  • 每项服务可以由多个服务实例组成,以增加吞吐量和可用性

  • 每个服务可以独立部署,更容易频繁部署新版本的服务

  • 更容易组织开发过程;每个团队拥有并负责一个或多个服务,可以独立开发、发布或扩展他们的服务,而不受其他团队的影响

  • 您可以选择您认为最适合工作的编程语言或框架。对技术栈没有长期承诺。如果需要,服务可以在新的技术栈中重写,如果没有 API 更改,这对系统的其他部分是透明的

  • 对于持续交付来说更好,因为小单元更容易管理、测试和部署。只要每个团队保持向后和向前的 API 兼容性,就可以在与其他团队解耦的发布周期中工作。有一些情况下这些发布周期是耦合的,但这并不是常见情况

保持数据一致性

服务必须松散耦合,以便它们可以独立开发、部署和扩展。它们当然需要进行通信,但它们是彼此独立的。它们有明确定义的接口并封装实现细节。但是数据呢?在现实世界和非平凡的应用程序中(微服务应用程序可能是非平凡的),业务交易经常必须跨多个服务。例如,如果你创建一个银行应用程序,在执行客户的转账订单之前,你需要确保它不会超过他的账户余额。单体应用程序附带的单个数据库给了我们很多便利:原子事务,一个查找数据的地方等等。

另一方面,在微服务世界中,不同的服务需要是独立的。这也意味着它们可以有不同的数据存储需求。对于一些服务,它可能是关系型数据库,而其他服务可能需要像 MongoDB 这样擅长存储复杂的非结构化数据的文档数据库。

因此,在构建微服务并将我们的数据库拆分成多个较小的数据库时,我们如何管理这些挑战呢?我们还说过服务应该拥有自己的数据。也就是说,每个微服务应该只依赖于自己的数据库。服务的数据库实际上是该服务实现的一部分。这在设计微服务架构时会带来相当有趣的挑战。正如马丁·福勒在他的“微服务权衡”专栏中所说的:在分布式系统中保持强一致性非常困难,这意味着每个人都必须管理最终一致性。我们如何处理这个问题?嗯,这一切都与边界有关。

微服务应该有明确定义的责任和边界。

微服务需要根据其业务领域进行分组。此外,在实践中,您需要以这样的方式设计您的微服务,使它们不能直接连接到另一个服务拥有的数据库。松散耦合意味着微服务应该公开清晰的 API 接口,模拟与数据相关的数据和访问模式。它们必须遵守这些接口,当需要进行更改时,您可能会引入版本控制机制,并创建另一个版本的微服务。您可以使用发布/订阅模式将一个微服务的事件分派给其他微服务进行处理,就像您在下面的图表中看到的那样。

您希望使用的发布/订阅机制应该为事件处理提供重试和回滚功能。在发布/订阅场景中,修改或生成数据的服务允许其他服务订阅事件。订阅的服务接收到事件,表明数据已被修改。通常情况下,事件包含已经被修改的数据。当然,事件发布/订阅模式不仅可以用于数据更改,还可以作为服务之间的通用通信机制。这是一种简单而有效的方法,但它也有一个缺点,就是可能会丢失事件。

在创建分布式应用程序时,您可能需要考虑一段时间会出现数据不一致的情况。当应用程序在一台机器上更改数据项时,该更改需要传播到其他副本。由于更改传播不是即时的,因此在某个时间间隔内,一些副本将具有最新的更改,而其他副本则没有。然而,更改最终将传播到所有副本。这就是为什么这被称为最终一致性。您的服务需要假设数据在一段时间内处于不一致状态,并需要通过使用数据本身,推迟操作,甚至忽略某些数据来处理这种情况。

正如你所看到的,微服务架构背后有很多挑战,但也有很多优势。不过,你应该注意,我们需要解决更多的挑战。由于服务彼此独立,它们可以用不同的编程语言实现。这意味着每个服务的部署过程可能会有所不同:对于 Java Web 应用程序和 node.js 应用程序来说,部署过程完全不同。这可能会使部署到服务器变得复杂。这正是 Docker 发挥作用的关键点。

Docker 角色

正如你在前几章中所记得的,Docker 利用了容器化的概念。无论应用程序使用什么语言和技术,你只需将其放入一个可部署和可运行的软件中,称为镜像(在这种情况下,应用程序将是一个微服务)。我们将在第四章《创建 Java 微服务》中详细介绍将 Java 应用程序打包到镜像的过程。Docker 镜像将包含我们的服务所需的一切,可以是一个带有所有必需库和应用服务器的 Java 虚拟机,也可以是一个将 node.js 应用程序与所有所需的 node.js 模块(如 express.js 等)打包在一起的 node.js 运行时。一个微服务可能由两个容器组成,一个运行服务代码,另一个运行数据库以保存服务自己的数据。

Docker 将容器隔离到一个进程或服务。实际上,我们应用程序的所有部分只是一堆打包好的黑匣子,可以直接使用 Docker 镜像。容器作为完全隔离的沙盒运行,每个容器只有操作系统的最小内核。Docker 使用 Linux 内核,并利用诸如 cnames 和命名空间之类的内核接口,允许多个容器共享同一个内核,同时完全隔离运行。

由于底层系统的系统资源是共享的,您可以以最佳性能运行您的服务,与传统虚拟机相比,占用空间大大减小。因为容器是可移植的,正如我们在第二章中所说的,网络和持久存储,它们可以在 Docker 引擎可以运行的任何地方运行。这使得微服务的部署过程变得简单。要在给定主机上部署服务的新版本,只需停止运行的容器,并启动一个基于使用服务代码最新版本的 Docker 镜像的新容器。我们将在本书的后面介绍创建镜像新版本的过程。当然,主机上运行的所有其他容器都不会受到此更改的影响。

微服务需要使用REST协议进行通信,我们的 Docker 容器(或者更准确地说,您的 Java 微服务打包并在 Docker 容器内运行)也需要使用网络进行通信。正如您在第二章中记得的,关于网络的网络和持久存储,很容易暴露和映射 Docker 容器的网络端口。Docker 容器化似乎非常适合微服务架构的目的。您可以将微服务打包到一个便携式盒子中,并暴露所需的网络端口,使其能够与外部世界通信。在需要时,您可以运行任意数量的这些盒子。

让我们总结一下在处理微服务时有用的 Docker 功能:

  • 很容易扩展和缩减服务,只需更改运行的容器实例数量

  • 容器隐藏了每个服务背后技术的细节。我们的所有服务容器都以完全相同的方式启动和停止,无论它们使用什么技术栈

  • 每个服务实例都是隔离的

  • 您可以限制容器消耗的 CPU 和内存的运行时约束

  • 容器构建和启动速度快。正如您在第一章中记得的,Docker 简介,与传统虚拟化相比,开销很小

  • Docker 镜像层被缓存,这在创建服务的新版本时可以提供另一个速度提升

微服务架构的定义完全符合吗?当然符合,但是有一个问题。因为我们的微服务分布在多个主机上,很难跟踪哪些主机正在运行某些服务,也很难监视哪些服务需要更多资源,或者在最坏的情况下,已经死掉并且无法正常运行。此外,我们需要对属于特定应用程序或功能的服务进行分组。这是我们拼图中缺少的元素:容器管理和编排。许多框架出现了,目的是处理更复杂的场景:在集群中管理单个服务或在多个主机上管理多个实例,或者如何在部署和管理级别协调多个服务之间。其中一个工具就是 Kubernetes。

Kubernetes 的作用

虽然 Docker 提供了容器的生命周期管理,但 Kubernetes 将其提升到了下一个级别,提供了容器集群的编排和管理。正如你所知,使用微服务架构创建的应用程序将包含一些分离的、独立的服务。我们如何对它们进行编排和管理?Kubernetes 是一个开源工具,非常适合这种情况。它定义了一组构建块,提供了部署、维护和扩展应用程序的机制。Kubernetes 中的基本调度单元称为 pod。Pod 中的容器在同一主机上运行,共享相同的 IP 地址,并通过 localhost 找到彼此。它们还可以使用标准的进程间通信方式进行通信,比如共享内存或信号量。Pod 为容器化组件增加了另一个抽象级别。一个 pod 由一个或多个容器组成,这些容器保证在主机上共同定位,并且可以共享资源。它与一个应用程序相关的容器的逻辑集合是相同的。

对于传统服务,例如与相应数据库一起的 REST 端点(实际上是我们完整的微服务),Kubernetes 提供了服务的概念。服务定义了一组逻辑 pod,并强制执行从外部世界访问这些逻辑组的规则。Kubernetes 使用标签的概念来为 pod 和其他资源(服务、部署等)添加标签。这些标签是可以在创建时附加到资源上,然后随时添加和修改的简单键值对。我们稍后将使用标签来组织和选择资源的子集(例如 pod)以将它们作为一个实体进行管理。

Kubernetes 可以自动将您的容器或一组容器放置在特定的主机上。为了找到合适的主机(具有最小工作负载的主机),它将分析主机的当前工作负载以及不同的共存和可用性约束。当然,您可以手动指定主机,但拥有这种自动功能可以充分利用可用的处理能力和资源。Kubernetes 可以监视容器、pod 和集群级别的资源使用情况(CPU 和 RAM)。资源使用和性能分析代理在每个节点上运行,自动发现节点上的容器,并收集 CPU、内存、文件系统和网络使用统计信息。

Kubernetes 还管理您的容器实例的生命周期。如果实例过多,其中一些将被停止。如果工作负载增加,新的容器将自动启动。这个功能称为容器自动扩展。它将根据内存、CPU 利用率或您为服务定义的其他指标(例如每秒查询次数)自动更改运行容器的数量。

正如您从第二章中记得的那样,网络和持久存储,Docker 使用卷来持久保存您的应用数据。Kubernetes 也支持两种卷:常规卷与 pod 具有相同的生命周期,持久卷则独立于任何 pod 的生命周期。卷类型以插件的形式与 Docker 实现方式相同。这种可扩展的设计使您可以拥有几乎任何类型的卷。它目前包含存储插件,如 Google Cloud Platform 卷、AWS 弹性块存储卷等。

Kubernetes 可以监视您的服务的健康状况,它可以通过执行指定的HTTP方法(例如与GET相同)来执行指定的 URL 并分析响应中给出的HTTP状态代码来实现。此外,TCP 探测可以检查指定端口是否打开,也可以用于监视服务的健康状况。最后,但同样重要的是,您可以指定可以在容器中执行的命令,以及可以根据命令的响应采取的一些操作。如果指定的探测方法发出信号表明容器出现问题,它可以自动重新启动。当您需要更新软件时,Kubernetes 支持滚动更新。此功能允许您以最小的停机时间更新部署的容器化应用程序。滚动更新功能允许您指定在更新时可能关闭的旧副本的数量。使用 Docker 升级容器化软件特别容易,因为您已经知道,它只是容器的新图像版本。我想现在您已经完全了解了。部署可以更新、部署或回滚。负载平衡、服务发现,所有您在编排和管理运行在 Docker 容器中的微服务群时可能需要的功能都可以在 Kubernetes 中使用。最初由谷歌为大规模而制作,Kubernetes 现在被各种规模的组织广泛使用来在生产环境中运行容器。

何时使用微服务架构

微服务架构是一种新的思考应用程序结构的方式。在开始时,当您开始创建一个相对较小的系统时,可能不需要使用微服务方法。当然,基本的 Web 应用程序没有问题。在为办公室的人们制作基本的 Web 应用程序时,采用微服务架构可能有些过度。另一方面,如果您计划开发一个新的、超级互联网服务,将被数百万移动客户端使用,我会考虑从一开始就采用微服务。开玩笑的时候,您明白了,始终要选择最适合工作的工具。最终目标是提供业务价值。

然而,你应该在一段时间后牢记你系统的整体情况。如果你的应用程序在功能和功能上比你预期的要大,或者你从一开始就知道这一点,你可能想要开始将功能拆分成微服务。你应该尝试进行功能分解,并指出系统的片段具有明确的边界,并且在将来需要扩展和单独部署。如果有很多人在一个项目上工作,让他们开发应用程序的独立部分将极大地推动开发过程。每个服务可以使用不同的技术栈,可以用不同的编程语言或框架实现,并且可以在最合适的数据存储中存储自己的数据。这一切都与 API 和服务之间的通信方式有关。拥有这样的架构将导致更快的上市时间,与单体架构相比,构建、测试和部署时间大大缩短。如果只需要扩展需要处理更高工作负载的服务。有了 Docker 和 Kubernetes,没有理由不去使用微服务架构;这将在未来得到回报,毫无疑问。

微服务架构不仅仅是一个新潮的时髦词汇,它通常被认为是今天构建应用程序的更好方式。微服务理念的诞生是由于需要更好地利用计算资源以及需要维护越来越复杂的基于 Web 的应用程序。

在构建微服务时,Java 是一个很好的选择。你可以将微服务创建为一个单独的可执行 JAR,自包含的 Spring Boot 应用程序,或者部署在诸如 Wildfly 或 Tomcat 之类的应用服务器上的功能齐全的 Web 应用程序。根据你的用例和微服务的职责和功能,任何一种方式都可以。Docker 仓库包含许多有用的镜像,你可以自由地将其作为微服务的基础。Docker Hub 中的许多镜像是由私人个人创建的,有些是扩展官方镜像并根据自己的需求进行定制,但其他一些是从基础镜像定制的整个平台配置。基础镜像可以简单到纯 JDK,也可以是一个完全配置好的 Wildfly 准备运行。这将极大地提高开发性能。

总结

在这一章中,我们已经比较了单体架构和微服务架构。我希望你能看到使用后者的优势。我们还学习了 Docker 和 Kubernetes 在部署容器化应用程序时如何融入整个画面,使这个过程变得更加简单和愉快。Java 是一个实践证明的生态系统,用于实现微服务。您将要创建的软件将由小型、高度可测试和高效的模块组成。实际上,在第四章 创建 Java 微服务中,我们将亲自动手创建这样一个微服务。

第四章:创建 Java 微服务

在第三章中,我们已经看到了微服务架构背后的许多理论,使用微服务。现在是实践的时候;我们将要实现我们自己的微服务。这将是一个简单的 REST 服务,接受GETPOSTHTTP方法来检索和更新实体。在 Java 中开发微服务时有几种选择。在本章中,我们将概述两种主要方法,可能最流行的将是 JEE7 和 Spring Boot。我们将简要介绍如何使用 JEE JAX-RS 编写微服务。我们还将创建一个在 Spring Boot 上运行的微服务。实际上,在第五章中,使用 Java 应用程序创建图像,我们将从 Docker 容器中运行我们的 Spring Boot 微服务。正如我们在第三章中所说,使用微服务,微服务通常使用 REST 与外部世界通信。我们的 REST 微服务将尽可能简单;我们只需要有一些东西可以使用 Docker 和 Kubernetes 部署。我们不会专注于高级微服务功能,比如身份验证、安全、过滤器等等,因为这超出了本书的范围。我们的示例的目的是让您了解如何开发 REST 服务,然后使用 Docker 和 Kubernetes 部署它们。本章将涵盖以下主题:

  • REST 简介

  • 使用 Java EE7 注解在 Java 中创建 REST 服务

  • 使用 Spring Boot 创建 REST 服务

  • 运行服务,然后使用不同的 HTTP 客户端调用它

在本章末尾,我们将熟悉一些有用的工具-我们将使用一些代码生成工具,比如 Spring Initialzr,快速启动一个 Spring Boot 服务项目。在我们开始编写自己的微服务之前,让我们简要解释一下 REST 是什么。

REST 简介

REST 首字母缩略词代表表述性状态转移。这是一种基于网络的软件的架构风格和设计。它描述了一个系统如何与另一个系统通信状态。这非常适合微服务世界。正如您从第三章中所记得的,使用微服务,基于微服务架构的软件应用程序是一堆分离的、独立的服务相互通信。

在我们继续之前,有一些 REST 中的概念我们需要了解:

  • resource:这是 REST 架构中的主要概念。任何信息都可以是一个资源。银行账户、人员、图像、书籍。资源的表示必须是无状态的。

  • representation:资源可以被表示的特定方式。例如,银行账户资源可以使用 JSON、XML 或 HTML 来表示。不同的客户端可能请求资源的不同表示,一个可以接受 JSON,而其他人可能期望 XML。

  • server:服务提供者。它公开可以被客户端消费的服务。

  • client:服务消费者。这可以是另一个微服务、应用程序,或者只是运行 Angular 应用程序的用户的网络浏览器

正如定义所说,REST 被用来在网络上传输这些资源表示。表示本身是通过某种媒体类型创建的。媒体类型可以不同。一些媒体类型的例子包括 JSON、XML 或 RDF。JSON 媒体类型被广泛接受,可能是最常用的。在我们的例子中,我们也将使用 JSON 来与我们的服务进行通信。当然,REST 不是微服务通信的唯一选择;还有其他选择,比如谷歌的非常好的 gRPC,它带来了很多优势,比如 HTTP/2 和 protobuff。在 REST 架构中,资源由组件来操作。事实上,这些组件就是我们的微服务。组件通过标准统一接口请求和操作资源。REST 不绑定到任何特定的协议;然而,REST 调用最常使用最流行的 HTTPHTTPS 协议。在 HTTP 的情况下,这个统一接口由标准的 HTTP 方法组成,比如 GETPUTPOSTDELETE

REST 不绑定到任何特定的协议。

在我们开始实现响应 HTTP 调用的服务之前,了解一下我们将要使用的 HTTP 方法是值得的。我们现在将更加关注它们。

HTTP 方法

基于 REST 的架构使用标准的 HTTP 方法:PUTGETPOSTDELETE。以下列表解释了这些操作:

  • GET 提供对资源的读取访问。调用 GET 不应该产生任何副作用。这意味着 GET 操作是幂等的。资源永远不会通过 GET 请求而被改变;例如,请求没有副作用。这意味着它是幂等的。

  • PUT创建一个新资源。与GET类似,它也应该是幂等的。

  • DELETE移除资源。当重复调用时,DELETE操作不应产生不同的结果。

  • POST将更新现有资源或创建新资源。

RESTful web 服务就是基于REST资源概念和使用 HTTP 方法的 web 服务。它应该定义暴露方法的基本 URI,支持的 MIME 类型,比如 XML、文本或 JSON,以及服务处理的一组操作(POSTGETPUTDELETE)。根据 RESTful 原则,HTTP 对 REST 来说是简单且非常自然的。这些原则是一组约束,确保客户端(比如服务消费者、其他服务或浏览器)可以以灵活的方式与服务器通信。现在让我们来看看它们。

在 REST 原则的客户端-服务器通信中,所有以 RESTful 风格构建的应用程序原则上也必须是客户端-服务器的。应该有一个服务器(服务提供者)和一个客户端(服务消费者)。这样可以实现松散耦合和服务器和客户端的独立演进。这非常符合微服务的概念。正如你在第三章中所记得的,使用微服务,它们必须是独立的:

  • 无状态:每个客户端对服务器的请求都要求其状态完全表示。服务器必须能够完全理解客户端的请求,而不使用任何服务器上下文或服务器会话状态。换句话说,所有状态必须在客户端上管理。每个 REST 服务都应该是无状态的。后续请求不应该依赖于临时存储在先前请求中的某些数据。消息应该是自描述的。

  • 可缓存:响应数据可以标记为可缓存或不可缓存。任何标记为可缓存的数据都可以在同一后续请求的响应中被重用。每个响应都应该指示它是否可缓存。

  • 统一接口:所有组件必须通过单一统一的接口进行交互。因为所有组件的交互都通过这个接口进行,与不同服务的交互非常简单。

  • 分层系统:服务的消费者不应假定与服务提供者直接连接。换句话说,客户端在任何时候都无法确定自己是连接到最终服务器还是中间服务器。中间层有助于强制执行安全策略,并通过启用负载平衡来提高系统的可伸缩性。由于请求可以被缓存,客户端可能会从中间层获取缓存的响应。

  • 资源通过表示的操作:一个资源可以有多个表示。应该可以通过任何这些表示的消息来修改资源。

  • 超媒体作为应用状态的引擎(HATEOAS):RESTful 应用的消费者应该只知道一个固定的服务 URL。所有后续资源应该可以从资源表示中包含的链接中发现。

前述概念代表了 REST 的定义特征,并将 REST 架构与其他架构(如 Web 服务)区分开来。值得注意的是,REST 服务是 Web 服务,但 Web 服务不一定是 REST 服务。REST 微服务应该代表实体的状态。例如,让我们的实体是一本书(连同其属性,如 ID、标题和作者),表示为 XML、JSON 或纯文本。关于 REST 最基本的思考方式是将服务的 URL 格式化。例如,有了我们的book资源,我们可以想象在服务中定义以下操作:

  • /books将允许访问所有书籍

  • /books/:id将是查看单个书籍的操作,根据其唯一 ID 检索

  • /books发送POST请求将是您实际上创建新书并将其存储在数据库中的方式

  • /books/:id发送PUT请求将是您如何更新给定书籍的属性,再次根据其唯一 ID 进行标识

  • /books/:id发送DELETE请求将是您如何删除特定书籍,再次根据其唯一 ID 进行标识

值得一试的是,REST 不是 HTTP。它通常使用 HTTP,因为在其最一般的形式中,REST 是关于将动词的概念映射到任意的名词集合,并且与 HTTP 方法很好地契合。HTTP 包含一组有用的通用动词(GETPOSTPUTPATCH等)。在 REST 中,我们不传输实际对象,而是以特定形式的表示形式传输,例如 XML、文本或 JSON。作为一种架构风格,REST 只是一个概念。它的实现方式取决于你。Java 非常适合开发 REST 服务。让我们看看我们该如何做。

Java 中的 REST

在 Java 中开发 REST 服务时,我们至少有几种框架可以选择。最流行的将是纯 JEE7 与 JAX-RS 或 Spring 框架与其 Spring Boot。您可以选择其中任何一个,或者将它们混合在一起。现在让我们更详细地看看这两个,从 JAX-RS 开始。

Java EE7 - 使用 Jersey 的 JAX-RS

JAX-RS 诞生于Java 规范请求JSR)311。正如官方定义所说,JAX-RS 是用于 RESTful web 服务的 Java API。它是一个规范,提供支持,根据 REST 架构模式创建 web 服务。JAX-RS 使用 Java 注解,引入自 Java SE 5,以简化 web 服务客户端和端点的开发和部署。从 1.1 版本开始,JAX-RS 是 Java EE 的官方一部分。作为 Java EE 的官方一部分的一个显著特点是,无需配置即可开始使用 JAX-RS。

Java EE 7 与 JAX-RS 2.0 带来了几个有用的功能,进一步简化了微服务的开发。JAX-RS 2.0 最重要的新功能之一是支持遵循 REST 的 HATEOAS 原则的超媒体。Jersey,来自 Oracle 的库,可能是最广为人知的实现了这一规范的库。

Jersey 是 JSR 311 规范的参考实现。

Jersey 实现提供了一个库,用于在 Java servlet 容器中实现 RESTful web 服务。在服务器端,Jersey 提供了一个 servlet 实现,它扫描预定义的类来识别 RESTful 资源。Jersey 使编写 RESTful 服务变得更加容易。它抽象了许多低级别的编码,否则你将需要自己完成。使用 Jersey,你可以以声明性的方式来完成。在web.xml文件中注册的 servlet 会分析传入的HTTP请求,并选择正确的类和方法来响应此请求。它通过查看类和方法级别的注解来找到要执行的正确方法。注解类可以存在于不同的包中,但是你可以通过web.xml指示 Jersey servlet 扫描特定的包以查找注解类。

JAX-RS 支持通过Java XML 绑定架构JAXB)创建 XML 和 JSON。Jersey 实现还提供了一个client库,用于与 RESTful web 服务进行通信。

正如我们之前所说,我们使用 Java 注解开发 JAX-RS 应用程序。这很容易且愉快。现在让我们来描述这些注解。

JAX-RS 注解

JAX-RS 中最重要的注解列在下表中:

- 注解 含义
- @PATH 设置基本 URL + /your_path 的路径。基本 URL 基于你的应用程序名称、servlet 和web.xml配置文件中的 URL 模式。
- @POST 表示以下方法将响应HTTP POST请求。
- @GET 表示以下方法将响应HTTP GET请求。
- @PUT 表示以下方法将响应HTTP PUT请求。
- @DELETE 表示以下方法将响应HTTP DELETE请求。
- @Produces 定义了一个带有@GET注解的方法要传递的 MIME 类型。例如可以是"text/plain""application/xml""application/json"
- @Consumes 定义了这个方法要消耗的 MIME 类型。
- @PathParam 用于从 URL 中提取(注入)值到方法参数中。这样,你可以将资源的 ID 注入到方法中,以获取正确的对象。
- @QueryParam 用于提取(注入)请求中携带的 URI 查询参数。统一资源标识符URI)是用于在互联网上标识名称或资源的一串字符。
@DefaultValue 指定默认值。对于可选参数很有用。
@CookieParam 允许您将客户端请求发送的 cookie 注入到 JAX-RS 资源方法中的注释。
@Provider @Provider注释用于 JAX-RS 运行时感兴趣的任何内容,例如MessageBodyReaderMessageBodyWriter。对于HTTP请求,MessageBodyReader用于将HTTP请求实体主体映射到方法参数。在响应端,返回值通过使用MessageBodyWriter映射到HTTP响应实体主体。如果应用程序需要提供额外的元数据,例如HTTP标头或不同的状态代码,方法可以返回一个包装实体的响应,并且可以使用Response.ResponseBuilder构建。
@ApplicationPath @ApplicationPath注释用于定义应用程序的 URL 映射。@ApplicationPath指定的路径是resource类中@Path注释指定的所有资源 URI 的基本 URI。您只能将@ApplicationPath应用于javax.ws.rs.core.Application的子类。

注释名称一开始可能不够清晰或不够自解释。让我们看一下示例 REST 端点实现,它将变得更加清晰。应用程序本身带有@ApplicationPath注释。默认情况下,在启动符合 JEE 的服务器时,JAX-RS 将扫描 Java 应用程序存档中的所有资源,以查找公开的端点。我们可以重写getClasses()方法,手动向 JAX-RS 运行时注册应用程序中的resource类。您可以在以下示例中看到它:

package pl.finsys.jaxrs_example 
@ApplicationPath("/myApp") 
public class MyApplication extends Application { 
   @Override 
   public Set<Class<?>> getClasses() { 
      final Set<Class<?>> classes = new HashSet<>(); 
      classes.add(MyBeansExposure.class); 
      return classes; 
   } 
} 

在前面的示例中,我们只是注册了一个 REST 应用程序,给它了/myApp基本 URI 路径。只有一个REST方法处理程序(端点),即MyBeansExposure类,我们在 REST 应用程序中注册它。在单独的 Java 类中实现的简化 REST 端点可以看起来与此相同:

package pl.finsys.jaxrs_example 
import javax.annotation.PostConstruct; 
import javax.enterprise.context.ApplicationScoped; 
import javax.ws.rs.DELETE; 
import javax.ws.rs.GET; 
import javax.ws.rs.POST; 
import javax.ws.rs.Path; 
import javax.ws.rs.PathParam; 
import javax.ws.rs.container.ResourceContext; 
import javax.ws.rs.core.Context; 
import javax.ws.rs.core.Response; 

@ApplicationScoped 
@Path("beans") 
public class MyBeansExposure { 
    @Context ResourceContext rc; 
    private Map<String, Bean> myBeans; 

    @GET 
    @Produces("application/json") 
    public Collection<Bean> allBeans() { 
        return Response.status(200).entity(myBeans.values()).build(); 
    } 

    @GET 
    @Produces("application/json") 
    @Path("{id}") 
    public Bean singleBean(@PathParam("id") String id) { 
        return Response.status(200).entity(myBeans.get(id)).build(); 
    } 

    @POST 
    @Consumes("application/json") 
    public Response add(Bean bean) { 
        if (bean != null) { 
            myBeans.put(bean.getName(), bean); 
        } 
        final URI id = URI.create(bean.getName()); 
        return Response.created(id).build(); 
    } 

    @DELETE 
    @Path("{id}") 
    public void remove(@PathParam("id") String id) { 
        myBeans.remove(id); 
    } 

} 

正如你在上一个例子中所看到的,我们有类级别的@Path注解。每个标记有@GET@PUT@DELETE@POST注解的方法都将响应于以基本@Path开头的 URI 的调用。此外,我们可以在方法级别上使用@Path注解;它将扩展特定方法响应的 URI 路径。在我们的例子中,使用 URI 路径myApp/beans执行的HTTP GET将调用allBeans()方法,以 JSON 格式返回豆子集合。使用myApp/beans/12 URI 路径执行的GET方法将调用singleBean()方法,并且由于@PathParam注解,{id}参数将被传递给方法。在myApp|beans|12 URI 上调用HTTP DELETE方法将执行remove()方法,参数值为12。为了给你几乎无限的灵活性,@Path注解支持正则表达式。考虑以下例子:

package pl.finsys.jaxrs_example 
import javax.ws.rs.GET; 
import javax.ws.rs.Path; 
import javax.ws.rs.PathParam; 
import javax.ws.rs.core.Response; 

@Stateless 
@Path("/books") 
public class BookResource { 

   @GET 
   @Path("{title : [a-zA-Z][a-zA-Z_0-9]}") 
    public Response getBookByTitle(@PathParam("title") String title) { 
      return Response.status(200).entity("getBookByTitle is called, title : " + title).build(); 
   } 

   @GET 
   @Path("{isbn : \\d+}") 
   public Response getBookByISBN(@PathParam("isbn") String isbn) { 
      return Response.status(200).entity("getBookByISBN is called, isbn : " + isbn).build(); 
   } 
} 

在上一个例子中,我们有两个@GET映射,每个映射都有相同的/books/路径映射。第一个映射,带有/{title : [a-zA-Z][a-zA-Z_0-9]}参数,只会对字母和数字做出反应。第二个映射,带有/{isbn : \\d+}参数,只有在调用 URI 时提供数字时才会执行。正如你所看到的,我们映射了两个相同的路径,但每个路径都会对不同类型的传入路径参数做出反应。

除了使用@PathParam,我们还可以使用@QueryParams来使用请求参数提供参数。看看下面的例子:

package pl.finsys.jaxrs_example 
import java.util.List; 
import javax.ws.rs.GET; 
import javax.ws.rs.Path; 
import javax.ws.rs.core.Context; 
import javax.ws.rs.core.Response; 
import javax.ws.rs.core.UriInfo; 

@Stateless 
@Path("/users") 
public class UserResource { 
   @EJB private UserService userService; 
   @GET 
   @Path("/query") 
   @Produces("application/json") 
   public Response getUsers( 
      @QueryParam("from") int from, 
      @QueryParam("to") int to, 
      @QueryParam("orderBy") List<String> orderBy)) { 
      List<User> users = userService.getUsers(from, to, orderBy); 
      return Response.status(200).entity(users).build(); 
   } 
} 

在上一个例子中,当在/users/query?from=1&to=100&orderBy=name上调用HTTP GET时,JAX-RS 将把 URI 参数传递给getUsers()方法参数,并调用注入的userService来获取数据(例如,从数据库中)。

要打包 JAX-RS 应用程序,我们当然需要一个 Maven pom.xml文件。在其最简单的形式中,它可以看起来与以下内容相同:

<?xml version="1.0" encoding="UTF-8"?> 
<project  

         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 
    <modelVersion>4.0.0</modelVersion> 

    <groupId>pl.finsys</groupId> 
    <artifactId>jee7-rest</artifactId> 
    <packaging>war</packaging> 
    <version>1.0-SNAPSHOT</version> 

    <dependencies> 
        <dependency> 
            <groupId>javax</groupId> 
            <artifactId>javaee-api</artifactId> 
            <version>7.0</version> 
            <scope>provided</scope> 
        </dependency> 
    </dependencies> 
    <build> 
        <finalName>jee7-rest</finalName> 
    </build> 

    <properties> 
        <maven.compiler.source>1.8</maven.compiler.source> 
        <maven.compiler.target>1.8</maven.compiler.target> 
        <failOnMissingWebXml>false</failOnMissingWebXml> 
    </properties> 
</project> 

创建 JEE7 REST 服务非常简单,不是吗?通过构建项目并将其部署到符合 JEE 标准的应用服务器,我们有一些端点准备好等待通过HTTP调用。但还有一种更简单和更快的方法。在微服务时代,我们希望以最小的开销更快地创建单独的组件。这就是 Spring Boot 的用武之地。现在让我们来看看它。

Spring Boot

Spring 本身是一个非常受欢迎的基于 Java 的框架,用于构建 Web 和企业应用程序。它不仅仅是关注依赖注入的 Spring Core。Spring 框架提供了许多功能,可以让开发人员的生活更轻松,并允许您更快地交付所需的功能。列表很长;这里只是一些例子:

  • Spring data:简化了与关系型和 NoSQL 数据存储的数据访问

  • Spring batch:提供了一个强大的批处理框架

  • Spring security:提供了许多保护应用程序的方式

  • Spring social:支持与 Twitter、Facebook、GitHub 等社交网络站点集成

  • Spring integration:实现了企业集成模式,以便使用轻量级消息传递和声明性适配器与其他企业应用程序集成

但是为什么 Spring 变得如此受欢迎?有几个原因:

  • 它采用依赖注入方法,鼓励编写可测试、松耦合的代码

  • 很容易包含数据库事务管理功能

  • 与其他流行的 Java 框架集成,如 JPA/Hibernate 等

  • 它包括一个用于更快地构建 Web 应用程序的最先进的 MVC 框架,将视图与业务逻辑分离。

在 Spring 框架中配置 bean 可以通过多种方式进行,如 XML 定义文件、Java 注解和代码配置。这可能是一个繁琐的过程。此外,我们经常为不同的应用程序做大量样板配置。Spring Boot 应运而生,以解决配置的复杂性。我们可以将 Spring Boot 用于自己的目的,并开发可以直接运行的小型独立服务。它可以是一个单独的可运行的 fat JAR 文件,其中包含运行应用程序所需的所有 Java 依赖项。无需应用服务器或复杂的部署描述符配置。实际上,在幕后,Spring Boot 将为您启动嵌入式服务器。当然,您并不一定要使用嵌入式应用服务器。您始终可以构建一个 WAR 文件,将其部署到自己的 Tomcat 或 Wildfly 上,例如。值得知道的是,即使在运行 Spring Boot 应用程序时大多数事情都会自动发生,它也不是一个代码生成框架。

所有这些是否让你想起了 Docker 容器的简单性和可移植性?当然,但是在应用程序级别。正如我们在第三章 使用微服务中讨论的那样,我们正在向着具有更小、独立部署的微服务的架构迈进。这意味着我们需要能够快速上手并运行新组件。使用 Spring Boot 时,我们可以获得很多开箱即用的功能。这些功能以 Maven 构件的形式提供,你只需在 Maven 的pom.xml文件中包含它们。

下表显示了 Spring Boot 提供的一些重要起始项目,我们将使用:

项目 描述
spring-boot-starter Spring Boot 应用程序的基本起始项目。提供自动配置和日志记录的支持。
spring-boot-starter-web 用于构建基于 Spring MVC 的 Web 应用程序或 RESTful 应用程序的起始项目。这使用 Tomcat 作为默认的嵌入式 Servlet 容器。
spring-boot-starter-data-jpa 提供对 Spring Data JPA 的支持。默认实现是 Hibernate。
spring-boot-starter-validation 提供对 Java Bean 验证 API 的支持。默认实现是 Hibernate Validator。
spring-boot-starter-test 提供对各种单元测试框架的支持,如 JUnit、Mockito 和 Hamcrest matchers

还有很多其他项目,可能对你有用。我们不打算使用它们,但让我们看看还有什么其他选择:

spring-boot-starter-web-services 用于开发基于 XML 的 Web 服务的起始项目
spring-boot-starter-activemq 支持使用 ActiveMQ 上的 JMS 进行基于消息的通信
spring-boot-starter-integration 支持 Spring Integration,这是一个提供企业集成模式实现的框架
spring-boot-starter-jdbc 提供对 Spring JDBC 的支持。默认情况下配置了 Tomcat JDBC 连接池。
spring-boot-starter-hateoas HATEOAS 代表超媒体作为应用状态的引擎。使用HATEOAS的 RESTful 服务返回与当前上下文相关的附加资源的链接,以及数据。
spring-boot-starter-jersey JAX-RS 是开发 REST API 的 Java EE 标准。Jersey 是默认实现。这个起始项目提供了构建基于 JAX-RS 的 REST API 的支持。
spring-boot-starter-websocket HTTP是无状态的。Web 套接字允许在服务器和浏览器之间保持连接。这个启动器项目提供了对 Spring WebSockets 的支持。
spring-boot-starter-aop 提供面向切面编程的支持。还提供了对高级面向切面编程的 AspectJ 的支持。
spring-boot-starter-amqp 默认为RabbitMQ,这个启动器项目提供了使用 AMQP 进行消息传递的支持。
spring-boot-starter-security 这个启动器项目启用了 Spring Security 的自动配置。
spring-boot-starter-batch 提供使用 Spring Batch 开发批处理应用程序的支持。
spring-boot-starter-cache 使用 Spring Framework 基本支持缓存。
spring-boot-starter-data-rest 支持使用 Spring Data REST 公开 REST 服务。

让我们使用一些这些好东西来编写我们自己的 Spring Boot 微服务。

编写 Spring Boot 微服务

我们知道我们有一些启动器可用,所以让我们利用它们来节省一些时间。我们要创建的服务将是用于从数据库中存储和检索实体的简单 REST 微服务:在我们的案例中是书籍。我们不打算实现身份验证和安全功能,只是尽可能地使它简洁和简单。书籍将存储在内存关系型 H2 数据库中。我们将使用 Maven 构建和运行我们的书店,所以让我们从pom.xml构建文件开始。

Maven 构建文件

正如你所看到的,我们自己服务的父项目是 spring-boot-starter-parent。Spring 这是为基于 Spring Boot 的应用程序提供依赖和插件管理的父项目。这为我们提供了很多功能。我们还包括两个启动器:

  • spring-boot-starter-web:这是因为我们将创建我们的请求映射(类似于使用 JEE7 JAX-RS 之前使用@Path注释的@GET@POST映射)

  • spring-boot-starter-data-jpa:因为我们将把我们的书保存在内存中的 H2 数据库中

启动器是为不同目的定制的简化的依赖描述符。例如,spring-boot-starter-web是用于使用 Spring MVC 构建 Web 和 RESTful 应用程序的启动器。它使用 Tomcat 作为默认的嵌入式容器。我们还包括了 Spring Boot Maven 插件,它允许我们在原地运行应用程序,而无需构建 JAR 或 WAR,或准备 JAR 或 WAR 文件以供将来部署。我们完整的pom.xml应该与这个一样:

<?xml version="1.0" encoding="UTF-8"?> 
<project   
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd"> 
    <modelVersion>4.0.0</modelVersion> 

    <groupId>pl.finsys</groupId> 
    <artifactId>rest-example</artifactId> 
    <version>0.1.0</version> 

    <parent> 
        <groupId>org.springframework.boot</groupId> 
        <artifactId>spring-boot-starter-

 parent</artifactId> 
        <version>1.5.2.RELEASE</version> 
    </parent> 

    <dependencies> 
        <dependency> 
            <groupId>org.springframework.boot</groupId> 
            <artifactId>spring-boot-starter-

 web</artifactId> 
        </dependency> 
        <dependency> 
            <groupId>org.springframework.boot</groupId> 
            <artifactId>spring-boot-starter-data-

 jpa</artifactId> 
        </dependency> 
        <dependency> 
            <groupId>org.hibernate</groupId> 
            <artifactId>hibernate-validator</artifactId> 
        </dependency> 
        <dependency> 
            <groupId>org.hsqldb</groupId> 
            <artifactId>hsqldb</artifactId> 
            <scope>runtime</scope> 
        </dependency> 

        <!--test dependencies--> 
        <dependency> 
            <groupId>org.springframework.boot</groupId> 
            <artifactId>spring-boot-starter-test</artifactId> 
            <scope>test</scope> 
        </dependency> 
        <dependency> 
            <groupId>com.jayway.jsonpath</groupId> 
            <artifactId>json-path</artifactId> 
            <scope>test</scope> 
        </dependency> 
    </dependencies> 

    <properties> 
        <java.version>1.8</java.version> 
    </properties> 

    <build> 
        <plugins> 
            <plugin> 
                <groupId>org.springframework.boot</groupId> 
                <artifactId>spring-boot-maven-plugin</artifactId> 
            </plugin> 
        </plugins> 
    </build> 

    <repositories> 
        <repository> 
            <id>spring-releases</id> 
            <url>https://repo.spring.io/libs-release</url> 
        </repository> 
    </repositories> 
    <pluginRepositories> 
        <pluginRepository> 
            <id>spring-releases</id> 
            <url>https://repo.spring.io/libs-release</url> 
        </pluginRepository> 
    </pluginRepositories> 
</project> 

首先,在pom.xml文件中,我们定义了父 Maven artifact。由于我们的应用是 Spring Boot 应用程序,我们从spring-boot-starter-parent artifact 继承我们的pom.xml。这为我们提供了所有 Spring Boot 的好处,例如启动机制,依赖注入等。通过将spring-boot-starter-data-jpa作为依赖项添加,我们将能够使用所有与数据库相关的功能,例如 JDBC 事务管理,用于实体类的 JPA 注解等。有了准备好的pom.xml,让我们继续定义微服务的入口点。

应用程序入口点

我们的应用程序入口点将被命名为BookStoreApplication,并且将是BookstoreApplication.java

package pl.finsys.example; 

import org.springframework.boot.SpringApplication; 
import org.springframework.boot.autoconfigure.SpringBootApplication; 

@SpringBootApplication 
public class BookstoreApplication { 

    public static void main(final String[] args) { 
        SpringApplication.run(BookstoreApplication.class, args); 
    } 
} 

就是这样。整个代码只有九行,不包括空行。它不能再简洁了。@SpringBootApplication是一种快捷注解,非常方便。它替代了以下所有注解:

  • @Configuration:标有此注解的类成为应用程序上下文的 bean 定义源

  • @EnableAutoConfiguration:此注解使 Spring Boot 根据类路径设置、其他 bean 和各种属性设置添加 bean

  • @EnableWebMvc:通常你会为 Spring MVC 应用程序添加这个,但是当 Spring Boot 在类路径上看到spring-webmvc时,它会自动添加它。这标志着应用程序是一个 Web 应用程序,从而激活关键行为,如设置DispatcherServlet

  • @ComponentScan:告诉 Spring 查找其他组件、配置和服务,使其能够找到控制器

到目前为止一切顺利。我们需要一些模型来为我们的服务。我们将在数据库中保存一些实体;这就是spring-boot-starter-data-jpa启动器派上用场的地方。我们将能够使用 JPA(使用 Hibernate 实现)和javax.transaction-api,甚至无需明确声明它。我们需要一个书店的实体模型。

领域模型和仓库

我们服务中的领域模型将是一个Book类,在Book.java文件中定义:

package pl.finsys.example.domain; 

import javax.persistence.Column; 
import javax.persistence.Entity; 
import javax.persistence.Id; 
import javax.validation.constraints.NotNull; 
import javax.validation.constraints.Size; 

@Entity 
public class Book { 

    @Id 
    @NotNull 
    @Column(name = "id", nullable = false, updatable = false) 
    private Long id; 

    @NotNull 
    @Size(max = 64) 
    @Column(name = "author", nullable = false) 
    private String author; 

    @NotNull 
    @Size(max = 64) 
    @Column(name = "title", nullable = false) 
    private String title; 

    public Book() { 
    } 

    public Book(final Long id, final String author, final String title) { 
        this.id = id; 
        this.title = title; 
        this.author = author; 
    } 

    public Long getId() { 
        return id; 
    } 

    public String getAuthor() { 
        return author; 
    } 

    public String getTitle() { 
        return title; 
    } 

    public void setTitle(String title) { 
        this.title = title; 
    } 

    @Override 
    public String toString() { 
        return "Book{" + 
                "id=" + id + 
                ", author='" + author + '\'' + 
                ", title='" + title + '\'' + 
                '}'; 
    } 
} 

正如您在前面的清单中所看到的,Book类是一个简单的 POJO,带有一些注解、属性和 getter 和 setter。@Entity注解来自javax.persistence包,并将 POJO 标记为数据库实体,以便 JPA 可以从 H2 数据库中存储或检索它。@Column注解指定了数据库列的名称,对应的书籍属性将被存储在其中。@NotNull@Size注解将确保我们的实体在进入数据库之前填入了适当的值。

我们已经定义了我们的实体;现在是时候有一个机制来读取和存储它在数据库中。我们将使用 Spring 的JpaRepository来实现这个目的。我们的仓库的名称将在BookRepository.java文件中为BookRepository

package pl.finsys.example.repository; 

import pl.finsys.example.domain.Book; 
import org.springframework.data.jpa.repository.JpaRepository; 

public interface BookRepository extends JpaRepository<Book, Long> { 
} 

Spring Data JPA 提供了一个仓库编程模型,它从每个受管领域对象的接口开始。定义这个接口有两个目的。首先,通过扩展JPARepository接口,我们可以在我们的类型中获得一堆通用的 CRUD 方法,允许保存我们的实体,删除它们等等。例如,以下方法是可用的(声明在我们正在扩展的JPARepository接口中):

  • List<T> findAll();

  • List<T> findAll(Sort sort);

  • List<T> findAll(Iterable<ID> ids);

  • <S extends T> List<S> save(Iterable<S> entities);

  • T getOne(ID id);

  • <S extends T> S save(S entity);

  • <S extends T> Iterable<S> save(Iterable<S> entities);

  • T findOne(ID id);

  • boolean exists(ID id);

  • Iterable<T> findAll();

  • Iterable<T> findAll(Iterable<ID> ids);

  • long count();

  • void delete(ID id);

  • void delete(T entity);

  • void delete(Iterable<? extends T> entities);

  • void deleteAll();

没有 SQL 编码,没有 JPA-QL 查询,什么都没有。只需扩展 Spring 的JPARepository接口,所有这些方法都可以随时使用。当然,我们不局限于这些。我们可以在我们的接口中声明自己的方法,比如findByTitle(String title)。它将在运行时被 Spring 捕获,并通过标题找到一本书。我强烈建议阅读 Spring Data 项目文档并进一步实验;它非常方便使用。直接从控制器使用entity存储库通常不是很好的做法,所以现在是时候有一个书籍服务了。它将是一个BookService接口,在BookService.java中定义:

package pl.finsys.example.service; 

import pl.finsys.example.domain.Book; 
import javax.validation.Valid; 
import javax.validation.constraints.NotNull; 
import java.util.List; 

public interface BookService { 
    Book saveBook(@NotNull @Valid final Book book); 
    List<Book> getList(); 
    Book getBook(Long bookId); 
    void deleteBook(final Long bookId); 
} 

实现,在BookServiceImpl.java中可以看起来与以下内容相同:

package pl.finsys.example.service; 

import org.springframework.beans.factory.annotation.Autowired; 
import pl.finsys.example.domain.Book; 
import pl.finsys.example.repository.BookRepository; 
import pl.finsys.example.service.exception.BookAlreadyExistsException; 
import org.slf4j.Logger; 
import org.slf4j.LoggerFactory; 
import org.springframework.stereotype.Service; 
import org.springframework.transaction.annotation.Transactional; 
import org.springframework.validation.annotation.Validated; 

import javax.validation.Valid; 
import javax.validation.constraints.NotNull; 
import java.util.List; 

@Service 
@Validated 
public class BookServiceImpl implements BookService { 

    private static final Logger LOGGER = LoggerFactory.getLogger(BookServiceImpl.class); 
    private final BookRepository repository; 

    @Autowired 
    public BookServiceImpl(final BookRepository repository) { 
        this.repository = repository; 
    } 

    @Override 
    @Transactional 
    public Book saveBook(@NotNull @Valid final Book book) { 
        LOGGER.debug("Creating {}", book); 
        Book existing = repository.findOne(book.getId()); 
        if (existing != null) { 
            throw new BookAlreadyExistsException( 
                    String.format("There already exists a book with id=%s", book.getId())); 
        } 
        return repository.save(book); 
    } 

    @Override 
    @Transactional(readOnly = true) 
    public List<Book> getList() { 
        LOGGER.debug("Retrieving the list of all users"); 
        return repository.findAll(); 
    } 

    @Override 
    public Book getBook(Long bookId) { 
        return repository.findOne(bookId); 
    } 

    @Override 
    @Transactional 
    public void deleteBook(final Long bookId) { 
        LOGGER.debug("deleting {}", bookId); 
        repository.delete(bookId); 
    } 

} 

前面的清单介绍了BookService的实现。请注意,我们已经在构造函数中注入了BookRepository。所有实现方法,如saveBook()getBook()deleteBook()getList()都将使用注入的BookRepository来操作数据库中的书籍实体。现在是最后一个类的时候,实际的控制器将把所有前面的类连接在一起。

REST 控制器

REST 控制器定义了服务将要响应的 URI 路径。它声明了路径和相应的HTTP方法,每个控制器方法都应该对其做出反应。我们使用注解来定义所有这些。这种方法与 Jersey 的 JAX-RS 非常相似。我们的服务只有一个book资源,所以我们首先只会有一个控制器。它将是BookController类,在BookController.java中定义:

package pl.finsys.example.controller; 

import org.springframework.beans.factory.annotation.Autowired; 
import pl.finsys.example.domain.Book; 
import pl.finsys.example.service.BookService; 
import pl.finsys.example.service.exception.BookAlreadyExistsException; 
import org.slf4j.Logger; 
import org.slf4j.LoggerFactory; 
import org.springframework.http.HttpStatus; 
import org.springframework.web.bind.annotation.*; 

import javax.validation.Valid; 
import java.util.List; 

@RestController 
public class BookController { 

   private static final Logger LOGGER =     LoggerFactory.getLogger(BookController.class); 
private final BookService bookService; 

    @Autowired 
    public BookController(final BookService bookService) { 
        this.bookService = bookService; 
    } 

@RequestMapping(value = "/books", method = RequestMethod.POST, consumes={"application/json"}) 
    public Book saveBook(@RequestBody @Valid final Book book) { 
        LOGGER.debug("Received request to create the {}", book); 
        return bookService.saveBook(book); 
    } 

@RequestMapping(value = "/books", method = RequestMethod.GET, produces={"application/json"}) 
    public List<Book> listBooks() {             
        LOGGER.debug("Received request to list all books"); 
        return bookService.getList(); 
    } 

@RequestMapping(value = "/books/{id}", method = RequestMethod.GET, produces={"application/json"}) 
    public Book singleBook(@PathVariable Long id) { 
        LOGGER.debug("Received request to list a specific book"); 
        return bookService.getBook(id); 
    } 

@RequestMapping(value = "/books/{id}", method = RequestMethod.DELETE) 
    public void deleteBook(@PathVariable Long id) { 
        LOGGER.debug("Received request to delete a specific book"); 
        bookService.deleteBook(id); 
    } 
    @ExceptionHandler 
    @ResponseStatus(HttpStatus.CONFLICT) 
   public String handleUserAlreadyExistsException(BookAlreadyExistsException e) { 
        return e.getMessage(); 
    } 
} 

正如您在前面的示例中所看到的,该类使用@RestController注解进行了标注。这实际上是使其成为控制器的原因。事实上,这是一个方便的注解,它本身带有@Controller@ResponseBody注解。@Controller表示一个被注解的类是一个控制器(Web 控制器),还允许通过 Spring 的类路径扫描自动检测实现类。控制器中应该对特定 URI 的调用做出响应的每个方法都使用@RequestMapping注解进行映射。@RequestMapping接受参数,其中最重要的是:

  • value:它将指定 URI 路径

  • method:指定要处理的HTTP方法

  • headers:映射请求的标头,格式为myHeader=myValue。只有当传入请求标头被发现具有给定值时,请求才会使用标头参数来处理该方法

  • consumes:指定映射请求可以消耗的媒体类型,例如"text/plain""application/json"。这也可以是媒体类型的列表,例如:{"text/plain", "application/json"}

  • produces:指定映射请求可以生成的媒体类型,例如"text/plain""application/json"。这也可以是媒体类型的列表,例如:{"text/plain", "application/json"}

类似于 JAX-RS @PathParam@QueryParam用于指定控制器方法的输入参数,现在在 Spring 中我们有@PathVariable@RequestParam。如果您需要使方法参数出现在请求体中(作为您想要保存的整个 JSON 对象,与我们的saveBook()方法中一样),则需要使用@RequestBody注释来映射参数。至于输出,@ResponseBody注释可以告诉我们的控制器,方法返回值应绑定到 Web 响应主体。

在现实世界的服务中,您可能会有很多具有许多映射路径的控制器。将这样的服务暴露给世界时,通常最好记录服务的 API。这个 API 文档就是服务合同。手动执行此操作可能会很繁琐。而且,如果您进行更改,最好将 API 文档同步。有一个工具可以使这变得更容易,Swagger。

记录 API

在客户端可以使用服务之前,它需要一个服务合同。服务合同定义了有关服务的所有细节;例如,服务如何被调用,服务的 URI 是什么,请求和响应格式是什么。您的客户端需要知道如何与您的 API 进行交互。在过去几年中,Swagger 得到了许多主要供应商的支持。Swagger 的规范以 JSON 格式呈现了服务资源和操作的所有细节。规范的格式被称为 OpenAPI 规范(Swagger RESTful API 文档规范)。它既可以被人类阅读,也可以被机器阅读,易于解析、传输和在集成中使用。SpringFox库可用于从 RESTful 服务代码生成 Swagger 文档。而且,还有一个名为 Swagger UI 的精彩工具,当集成到应用程序中时,提供人类可读的文档。在本节中,我们将为我们的服务生成 Swagger 文档。SpringFox库可在 GitHub 上找到springfox.github.io/springfox/,并且在 Maven 中央库中也可以找到,它是一个用于自动构建 Spring 构建的 API 的 JSON API 文档的工具。更好的是,该库提供了 Swagger UI 工具。该工具将与您的服务一起部署,并且可以以非常便捷的方式浏览生成的 API 文档。让我们向我们的服务介绍 Swagger。我们首先要向我们的服务的pom.xml文件添加所需的依赖项:

<dependency> 
   <groupId>io.springfox</groupId> 
   <artifactId>springfox-swagger2</artifactId> 
   <version>2.6.1</version> 
</dependency> 

<dependency> 
   <groupId>io.springfox</groupId> 
   <artifactId>springfox-swagger-ui</artifactId> 
   <version>2.5.0</version> 
</dependency> 

在我们的应用程序的类路径中有了该库后,我们需要将其打开。接下来的步骤将是添加配置类以启用和生成 Swagger 文档。我们可以通过创建一个使用 Spring @Configuration注解的类来实现,就像下面的例子一样:

package pl.finsys.example.configuration; 

import org.springframework.context.annotation.Bean; 
import org.springframework.context.annotation.Configuration; 
import springfox.documentation.builders.PathSelectors; 
import springfox.documentation.builders.RequestHandlerSelectors; 
import springfox.documentation.spi.DocumentationType; 
import springfox.documentation.spring.web.plugins.Docket; 
import springfox.documentation.swagger2.annotations.EnableSwagger2; 

@Configuration 
@EnableSwagger2 
public class SwaggerConfig { 
    @Bean 
    public Docket api() { 
        return new Docket(DocumentationType.SWAGGER_2) 
                .select() 
                .apis(RequestHandlerSelectors.any()) 
                .paths(PathSelectors.any()).build(); 
    } 
} 

在这里解释一下。@Configuration表示被注释的类定义了一个 Spring 配置,@EnableSwagger2关闭了 Swagger 支持。Docket是一个构建器类,用于配置生成 Swagger 文档,配置为DocumentationType.SWAGGER_2以生成兼容 Swagger 2 的 API 文档。在Docket实例上调用的select()方法返回一个ApiSelectorBuilder,它提供了apis()paths()方法,用于使用字符串谓词过滤要记录的控制器和方法。在我们的例子中,我们希望记录所有控制器和所有映射的路径;这就是为什么我们使用.apis(RequestHandlerSelectors.any()).paths(PathSelectors.any())

您还可以使用传递给paths()regex参数来提供一个额外的过滤器,仅为与正则表达式匹配的路径生成文档。

就是这样;这是为您的 API 生成文档的最简单形式。如果您现在运行服务(我们将在不久的将来这样做),将会有两个端点可用:

  • http://localhost:8080/v2/api-docs

  • http://localhost:8080/swagger-ui.html

第一个包含了 Swagger 2 兼容的文档,以 JSON 格式呈现,如下截图所示:

要以更加有用的形式浏览 API 文档,请将浏览器指向第二个 URL。您将看到 Swagger UI 工具界面:

Swagger UI 是一组 HTML、JavaScript 和 CSS 资源,可以根据符合 Swagger 的 API 动态生成美观的文档。它列出了您的服务操作以及其请求和响应格式。最重要的是,您可以使用此工具测试您的服务,执行特定的请求。实际上,这是一个快速测试您的服务的好工具。我们的文档并不是非常描述性的。当然,我们列出了我们的暴露端点及其输入和输出描述。如果我们能用一些更具体的细节增强文档就更好了。我们可以做到这一点,我们可以在服务的代码中使用 Java 注解来增强生成的文档。这些注解来自 Swagger-annotation 包,如果您在项目中使用springfox-swagger2库,它将可用。例如,考虑以下代码片段:

@ApiOperation(value = "Retrieve a list of books.",

responseContainer = "List")

@RequestMapping(value = "/books", method = RequestMethod.GET, produces = {"application/json"})

public List<Book> listBooks() {

LOGGER.debug("Received request to list all books");

return bookService.getList();

}

在前面的代码中,我们使用@ApiOperation注解提供了对操作的更详细描述。还有更多:@ApiImplicitParam用于描述参数,@Authorization提供要在此资源/操作上使用的授权方案的名称,@License提供有关许可证的信息,等等。所有这些注解都将被springfox-swagger2捕获并用于增强生成的文档。我强烈建议查看 swagger-annotations 的 JavaDoc;你将能够以详细、专业的方式记录你的 API。

我想我们的小服务已经准备好了;是时候让它活起来了。

运行应用程序

因为我们已经在pom.xml构建文件中定义了 Spring Boot 插件,所以现在可以使用 Maven 启动应用程序。你只需要在系统路径上有 Maven,但作为 Java 开发人员,你可能已经有了。要运行应用程序,请在命令行(MacOS 上的终端或 Windows 上的cmd.exe)中执行以下操作:

$ mvn spring-boot:run

过一会儿,Spring 的启动日志将出现在控制台上,你的微服务将准备好接受HTTP请求。很快,在第五章,使用 Java 应用程序创建图像,我们的目标将是从 Docker 容器中看到相同的情况:

如果你愿意,你也可以直接从 IDE(IntelliJ IDEA、Eclipse 或 Netbeans)运行应用程序。我们的BookstoreApplication类有一个main()方法;你只需要在你的 IDE 中创建一个运行时配置并运行它。这与 JEE7 JAX-RS 服务不同。在那种情况下,你需要将服务部署在一个符合 JEE 标准的应用服务器上才能运行它。当调试服务时,定义main()方法非常方便。只需以BookstoreApplication为入口点开始调试会话。无需创建远程调试会话。服务运行后,是时候对其公开的端点进行一些调用了。

发出调用

调用从服务中公开的操作可以使用任何可以执行HTTP请求的工具或库。第一个明显的选择可能只是一个网络浏览器。但是网络浏览器只方便执行GET请求(比如从我们的书店服务获取书籍列表)。如果你需要执行其他方法,比如POSTPUT,或者提供额外的请求参数、头部值等,你将需要使用一些替代方案。第一个选择可能是 cURL,一个用于使用各种协议传输数据的命令行工具。让我们看看我们还有哪些其他选择。

Spring RestTemplate

如果你需要从另一个服务调用服务,你将需要一个HTTP客户端。Spring 提供了非常有用的RestTemplate类。它为你提供了同步的客户端端HTTP访问,简化了与 HTTP 服务器的通信,并强制执行 RESTful 原则。它处理 HTTP 连接,让应用程序代码提供 URL(可能带有模板变量)并提取结果。默认情况下,RestTemplate依赖于标准的 JDK 设施来建立 HTTP 连接。你可以通过其setRequestFactory()方法切换到你选择的不同的 HTTP 库,比如 Apache HttpComponentsNettyOkHttp。调用REST资源以获取ID = 1的书可以简单地如下所示:

package pl.finsys.example.client; 

import org.springframework.http.ResponseEntity; 
import org.springframework.web.client.RestTemplate; 
import pl.finsys.example.domain.Book; 

public class ExampleClient { 
    public static void main(String[] args) { 
        try { 
            RestTemplate restTemplate = new RestTemplate(); 
            ResponseEntity<Book> response = restTemplate.getForEntity("http://localhost:8080/books/1", Book.class); 
            System.out.println(response.getBody()); 
        } catch (Exception e) { 
            e.printStackTrace(); 
        } 
    } 
} 

当然,这只是一个简化的客户端示例,来向你展示这个想法。你可以使用RestTemplate来创建更复杂的客户端调用 REST 资源。

HTTPie

HTTPie 是 cURL 的一个很好的命令行替代品,可在httpie.org找到。它是一个命令行HTTP客户端。幸运的是,名字中的“ie”并不是来自于 Internet Explorer。如果你喜欢从 shell 或命令行工作,HTTPie只是一个单一的命令,它为 cUrl 添加了以下功能:合理的默认设置,表达和直观的命令语法,带颜色和格式的终端输出,内置的 JSON 支持,持久会话,表单和文件上传,代理和认证支持,以及对任意请求数据和头部的支持。它是用 Python 编写的,在 Linux、macOSX 和 Windows 上都可以运行。

Postman

Postman 是许多开发人员的首选工具。它可以作为 Chrome 插件或独立实用程序在www.getpostman.com上使用。Postman 非常方便使用。它是一个强大的 GUI 平台,可以使您的 API 开发更快速、更容易,从构建 API 请求到测试、文档编制和共享。您可以保存 HTTP 请求以供以后使用,并将它们组织成集合。如果您在多个环境中工作,例如在开发服务时使用本地主机和以后在生产环境中使用,Postman 引入了环境的概念。环境使您能够使用变量自定义您的请求。这样,您可以轻松地在不同的设置之间切换,而不必更改您的请求。每个环境都表示为一组键值对。这使得在多个环境中工作变得容易。它还具有非常方便的 UI 来编辑您的 HTTP 请求:

您可以定义请求头、cookie 和正文。如果您的服务支持身份验证,Postman 包含许多身份验证助手:它可以是基本身份验证、摘要身份验证和 OAuth。响应正文可以在三个视图中的一个中查看:漂亮、原始和预览。漂亮模式会格式化 JSON 或 XML 响应,使其更容易查看,并且标题会显示为标题选项卡中的键/值对。这是一个非常强大和愉快的工具。如果您在 macOS 上工作,甚至有更好的东西。

Paw for Mac

Paw 是一个功能齐全的 HTTP 客户端,可以让您测试构建或使用的 API。它具有美丽的原生 OS X 界面,可以组合请求,检查服务器响应,并直接生成客户端代码。正如您在以下截图中所看到的,它还包含一个强大的编辑器来组合您的请求:

它还支持许多身份验证模式,包括 OAuth 1 和 2、基本身份验证、摘要身份验证、Hawk、AWS 签名版本 4 和 Amazon S3。与 Postman 类似,Paw 还允许您将请求组织到文件夹中。您还可以快速定义和切换不同的环境。有趣的功能是 Paw 可以生成客户端代码来执行您的请求。它可以为 cURL、HTTPie、Objective-C、Python、JavaScript、Ruby、PHP、Java、Go 等生成代码。猜猜?Paw 还可以导入我们一直在谈论的 Swagger 文档。您可以使用此功能来测试您获得文档的服务。

如果您需要快速启动新服务,有一些工具可能会派上用场。其中之一是Initializr

Spring Initializr

Spring Initializr 是一个基于 Web 的工具,可在start.spring.io上使用。这是 Spring 项目的快速启动生成器。Spring Initializr 的使用方法如下:

  • 从网页浏览器访问start.spring.io

  • 在您的 IDE(IntelliJ IDEA Ultimate 或 NetBeans,使用插件)

  • 从命令行使用 Spring Boot CLI,或者简单地使用 cURL 或 HTTPie

使用 Web 应用程序非常方便;您只需要提供有关您的应用程序 Maven 原型的详细信息,例如组、工件名称、描述等:

在“依赖项”部分,您可以输入您想要包括的功能的关键字,例如 JPA、web 等。您还可以切换 UI 以查看高级视图,以列出所有功能并准备选择:

作为输出,Spring Initializr 将创建一个 ZIP 存档,其中包含您想要开始的基本 Maven 项目。Spring Initializr 创建的项目是一个 Maven 项目,并遵循标准的Maven目录布局。这在创建新的 Spring 项目时真的节省了很多时间。您不再需要搜索特定的 Maven 原型并寻找它们的版本。Initializr 将自动为您生成pom.xmlpom.xml中的依赖项的存在很重要,因为当在类路径上发现某些内容时,Spring Boot 将自动决定要自动创建什么。例如,如果 H2 数据库的依赖项存在并且在应用程序运行时存在于类路径上,Spring Boot 将自动创建数据连接和嵌入式 H2 数据库。

摘要

正如您所看到的,开发 Java 微服务并不像听起来那么棘手。您可以选择使用 JEE7 JAX-RS 或 Spring Boot,连接一些类,一个基本的服务就准备好了。您并不局限于使用 Spring MVC 来创建您的 REST 端点。如果您更熟悉 Java EE JAX-RS 规范,您可以很容易地将 JAX-RS 集成到 Spring 应用程序中,特别是 Spring Boot 应用程序。然后您可以从两者中选择最适合您的部分。

当然,在现实世界中,您可能希望包括一些更高级的功能,如身份验证和安全性。有了 Spring Initializr,您在开发自己的服务时可以获得严重的速度提升。在第五章中,使用 Java 应用程序创建图像,我们将把我们的书店服务打包成一个 Docker 镜像,并使用 Docker Engine 运行它。

第五章:使用 Java 应用程序创建镜像

现在我们有一个简单但功能齐全的基于 Spring Bootstrap 的 Java 微服务,我们可以进一步进行。在使用 Kubernetes 部署之前,让我们将其打包为 Docker 镜像。在本章中,我们将创建一个包含我们应用程序的 Docker 镜像,并将 Spring Boot 应用程序 docker 化以在隔离环境中运行,即容器中。

本章涵盖的主题将是:

  • 创建 Dockerfile

  • Dockerfile 指令

  • 构建镜像

  • 创建和删除镜像

让我们从定义一个Dockerfile开始,这将是我们容器的定义。

Dockerfile

正如您在第一章中所记得的,Docker 简介Dockerfile是一种构建镜像的配方。它是一个纯文本文件,包含按顺序由 Docker 执行的指令。每个Dockerfile都有一个基础镜像,Docker 引擎将用它来构建。生成的镜像将是文件系统的特定状态:一个只读的、冻结的不可变的快照,由代表文件系统在不同时间点上的更改的层组成。

Docker 中的镜像创建流程非常简单,基本上包括两个步骤:

  1. 首先,您准备一个名为Dockerfile的文本文件,其中包含一系列关于如何构建镜像的指令。您可以在Dockerfile中使用的指令集并不是很广泛,但足以充分指导 Docker 如何创建镜像。

  2. 接下来,您执行docker build命令,基于您刚刚创建的Dockerfile创建一个 Docker 镜像。docker build命令在上下文中运行。构建的上下文是指定位置的文件,可以是PATH或 URL。PATH是本地文件系统上的目录,URL 是 Git 存储库位置。上下文会递归处理。PATH将包括任何子目录。URL 将包括存储库及其子模块。

如果您创建一个包含 Java 应用程序的镜像,您也可以跳过第二步,并利用其中一个可用的 Docker Maven 插件。在学习如何使用docker build命令构建镜像之后,我们还将使用 Maven 创建我们的镜像。在使用 Maven 构建时,上下文将由 Maven 自动提供给docker build命令(或者在这种情况下是一个构建过程)。实际上,根本不需要Dockerfile,它将在构建过程中自动创建。我们将在短时间内了解这一点。

Dockerfile的标准名称就是Dockerfile。它只是一个纯文本文件。根据您使用的 IDE,有插件可以提供 Dockerfile 语法高亮和自动补全,这使得编辑它们变得轻而易举。Dockerfile 指令使用简单明了的语法,使它们非常容易理解、创建和使用。它们被设计为自解释的,特别是因为它们允许像正确编写的应用程序源代码一样进行注释。现在让我们来了解一下Dockerfile指令。

Dockerfile 指令

我们将从每个 Dockerfile 顶部必须具有的指令FROM开始。

FROM

这是 Dockerfile 中的第一条指令。它为文件中接下来的每个后续指令设置基础镜像。FROM指令的语法很简单。就是:

FROM <image>,或FROM <image>:<tag>,或FROM <image>@<digest>

FROM指令以tagdigest作为参数。如果您决定跳过它们,Docker 将假定您想要从latest标签构建您的镜像。请注意,latest并不总是您想要构建的镜像的最新版本。latest标签有点特殊。而且它可能不会像您期望的那样工作。总之,除非镜像创建者(例如openjdkfabric8)有特定的buildtagpush模式,否则latest标签并不意味着任何特殊含义。分配给镜像的latest标签只是意味着它是最后构建并执行的镜像,没有提供特定标签。很容易理解,这可能会令人困惑,拉取标记为latest的镜像将不会获取软件的最新版本。

当拉取标记为latest的镜像时,Docker 不会检查您是否获取了软件的最新版本。

如果 Docker 在构建过程中找不到你提供的标签或摘要,将会抛出错误。你应该明智地选择基础镜像。我的建议是始终优先选择在 Docker Hub 上找到的官方仓库。通过选择官方镜像,你可以相当确信它的质量高,经过测试,得到支持和维护。

对于容器化 Java 应用程序,我们有两个选项。第一个是使用基础 Linux 镜像,并使用RUN指令安装 Java(我们将在稍后介绍RUN)。第二个选项是拉取已经安装了 Java 运行时的镜像。在这里,你有更多选择。例如:

  • openjdk:一个官方仓库,包含了 Java 平台标准版的开源实现。标签latest指向了8u121-alpine OpenJDK 版本,这是在撰写本书时的最新版本。

  • fabric8/java-alpine-openjdk8-jdk:这个基础镜像实际上也被 fabric8 Maven 插件使用。

  • frolvlad/alpine-oraclejdk8:有三个标签可供选择:full(只删除源代码 tarballs),cleaned(清理桌面部分),slim(删除除编译器和 JVM 之外的所有内容)。标签 latest 指向了 cleaned 版本。

  • jeanblanchard/java:一个包含基于 Alpine Linux 的镜像的仓库,以保持尺寸最小(大约是基于 Ubuntu 的镜像的 25%)。标签latest指向了 Oracle Java 8(Server JRE)。

通过在 Docker Hub 上注册并创建账户,你将获得访问 Docker Store 的权限。它可以在store.docker.com找到。尝试在 Docker Store 中搜索与 Java 相关的镜像。你会找到许多有用的镜像可供选择,其中之一就是官方的 Oracle Java 8 SE(Server JRE)镜像。这个 Docker 镜像提供了 Server JRE,这是专门针对在服务器环境中部署 Java 的运行时环境。Server JRE 包括用于 JVM 监控和服务器应用程序常用的工具。你可以通过在 Docker Store 购买官方 Java Docker 镜像来获取这个官方 Java Docker 镜像。点击获取内容,价格为$0.00,因此可以免费用于开发目的。

请注意,来自 Docker Store 的镜像与您的 Docker Hub 帐户绑定。在拉取它们或构建以它们为基础镜像的自己的镜像之前,您需要使用 docker login 命令和您的 Docker Hub 凭据对 Docker Store 进行身份验证。

为了我们的目的,让我们选择 jeanblanchard/java。这是官方的 Oracle Java 运行在 Alpine Linux 发行版之上。基础镜像小巧且下载速度快。我们的 FROM 指令将与此相同:

FROM jeanblanchard/java:8

如果在您的 Docker 主机上(例如在您的本地计算机上)找不到 FROM 镜像,Docker 将尝试从 Docker Hub(或者如果您已经设置了私有仓库,则从私有仓库)中找到并拉取它。Dockerfile 中的所有后续指令将使用 FROM 中指定的镜像作为基础起点。这就是为什么它是强制性的;一个有效的 Dockerfile 必须在顶部有它。

MAINTAINER

通过使用 MAINTAINER 指令,您可以设置生成的镜像的 Author 字段。这可以是您的姓名、用户名,或者您希望作为您正在编写的 Dockerfile 创建的镜像的作者。这个命令可以放在 Dockerfile 的任何位置,但最好的做法是将其放在文件顶部,在 FROM 指令之后。这是一个所谓的非执行命令,意味着它不会对生成的镜像进行任何更改。语法非常简单:

MAINTAINER authors_name

WORKDIR

WORKDIR 指令为 Dockerfile 中在它之后出现的任何 CMDRUNENTRYPOINTCOPYADD 指令添加一个工作目录。该指令的语法是 WORKDIR /PATH。如果提供了相对路径,可以在一个 Dockerfile 中有多个 WORKDIR 指令;它将相对于前一个 WORKDIR 指令的路径。

ADD

ADD 的基本作用是将文件从源复制到容器自己的文件系统中的所需目的地。它接受两个参数:源(<source path or URL>)和目的地(<destination path>):

ADD <source path or URL> <destination path >

源可以有两种形式:它可以是文件、目录或 URL 的路径。路径是相对于构建过程将要启动的目录(我们之前提到的构建上下文)的。这意味着您不能将例如 "../../config.json" 放置为 ADD 指令的源路径参数。

源路径和目标路径可以包含通配符。这些与常规文件系统中的通配符相同:*表示任何文本字符串,?表示任何单个字符。

例如,ADD target/*.jar /将所有以.jar结尾的文件添加到镜像文件系统的根目录中。

如果需要,可以指定多个源路径,并用逗号分隔。它们都必须相对于构建上下文,就像只有一个源路径一样。如果您的源路径或目标路径包含空格,您需要使用特殊的语法,添加方括号:

ADD ["<source path or URL>" "<destination path>"]

如果源路径不以斜杠结尾,它将被视为单个文件,并且只会被复制到目标路径中。如果源路径以斜杠结尾,它将被视为目录:然后将其整个内容复制到目标路径中,但目录本身不会在目标路径中创建。因此,可以看到,当向镜像添加文件或目录时,斜杠/非常重要。如果源路径指向常见格式(如 ZIP、TAR 等)的压缩存档,它将被解压缩到目标路径中。Docker 不是通过文件名来识别存档,而是检查文件的内容。

如果存档损坏或者以其他方式无法被 Docker 读取,它将不会被解压缩,也不会给出错误消息。文件将被复制到目标路径中。

相同的尾部斜杠规则适用于目标路径;如果以斜杠结尾,表示它是一个目录。否则,它将被视为单个文件。这在构建镜像的文件系统内容时为您提供了很大的灵活性;您可以将文件添加到目录中,将文件添加为单个文件(使用相同或不同的名称),或者只添加整个目录。

ADD 命令不仅仅是从本地文件系统复制文件,您还可以使用它从网络获取文件。如果源是一个 URL,那么 URL 的内容将自动下载并放置在目标位置。请注意,从网络下载的文件存档将不会被解压缩。再次强调,当下载文件时,尾部的斜杠很重要;如果目标路径以斜杠结尾,文件将被下载到该目录中。否则,下载的文件将只是保存在您提供的目标路径下的名称。

<destination directory> 可以是绝对路径,也可以是相对于 WORKDIR 指令指定的目录的路径(我们将在稍后介绍)。源(或多个源)将被复制到指定的目标位置。例如:

  • ADD config.json projectRoot/ 将把 config.json 文件添加到 <WORKDIR>/projectRoot/

  • ADD config.json /absoluteDirectory/ 将把 config.json 文件添加到 /absoluteDirectory/

关于镜像中创建的文件的所有权,它们将始终以用户 ID(UID0 和组 ID(GID0 创建。权限将与源文件相同,除非它是从远程 URL 下载的文件:在这种情况下,它将获得权限值 600(只有所有者可以读写该文件)。如果您需要更改这些值(所有权或权限),您需要在 ADD 指令之后在您的 Dockerfile 中提供更多的指令。

如果您需要添加到镜像的文件位于需要身份验证的 URL 上,ADD 指令将无法工作。您需要使用 shell 命令来下载文件,比如 wgetcurl

请注意,如果您不需要其特殊功能,比如解压缩存档,就不应该使用 ADD,而应该使用 COPY

COPY

COPY 指令将从 <source path> 复制新文件或目录,并将它们添加到容器的文件系统中的路径 <destination path>

它与 ADD 指令非常相似,甚至语法也没有区别:

COPY <source path or URL> <destination path >

COPY 也适用于 ADD 的所有规则:所有源路径必须相对于构建的上下文。再次强调,源路径和目标路径末尾的斜杠的存在很重要:如果存在,路径将被视为文件;否则,它将被视为目录。

当然,就像ADD一样,你可以有多个源路径。如果源路径或目标路径包含空格,你需要用方括号括起来:

COPY ["<source path or URL>" "<destination path>"]

<destination path>是一个绝对路径(如果以斜杠开头),或者是相对于WORKDIR指令指定的路径的路径。

正如你所看到的,COPY的功能与ADD指令几乎相同,只有一个区别。COPY仅支持将本地文件基本复制到容器中。另一方面,ADD提供了一些更多的功能,比如归档解压、通过 URL 下载文件等。Docker 的最佳实践建议,如果你不需要ADD的这些附加功能,应该优先使用COPY。由于COPY命令的透明性,Dockerfile将更清洁、更易于理解。

ADDCOPY指令有一个共同的重要方面,即缓存。基本上,Docker 在构建过程中缓存进入镜像的文件。镜像中文件或文件的内容被检查,并为每个文件计算校验和。在缓存查找期间,校验和与现有镜像中的校验和进行比较。如果文件的内容和元数据发生了变化,缓存就会失效。否则,如果源文件没有发生变化,现有的镜像层就会被重用。

如果你有多个 Dockerfile 步骤使用来自你的上下文的不同文件,单独COPY它们,而不是一次性全部复制。这将确保每个步骤的构建缓存只有在特定所需文件发生变化时才会失效(强制步骤重新运行)。

正如你所看到的,COPY指令的语法和行为几乎与ADD指令相同,但它们的功能集有些不同。对于不需要ADD功能的归档解压或从 URL 获取文件的文件和目录,你应该始终使用COPY

运行

RUN指令是Dockerfile的中心执行指令。实质上,RUN指令将在当前镜像的新层上执行一个命令(或多个命令),然后提交结果。生成的提交镜像将作为Dockerfile中下一条指令的基础。正如你从第一章中记得的,Docker 简介,分层是 Docker 的核心概念。RUN以命令作为其参数,并运行它以创建新的层。

这也意味着COPYENTRYPOINT设置的参数可以在运行时被覆盖,所以如果你在启动容器后没有改变任何东西,结果将始终相同。然而,RUN将在构建时执行,无论你在运行时做什么,其效果都会存在。

为了使你的 Dockerfile 更易读和更易维护,你可以将长或复杂的RUN语句拆分成多行,用反斜杠分隔它们。

Dockerfile中的RUN命令将按照它们在其中出现的顺序执行。

每个RUN指令在镜像中创建一个新的层。

正如你已经从第一章中了解的那样,Docker 简介,层被 Docker 缓存和重用。在下一次构建期间,RUN指令的缓存不会自动失效。例如,RUN apt-get upgrade -y的指令的缓存将在下一次构建中被重用。缓存为什么重要?在大多数情况下,缓存非常有用,可以节省大量构建镜像的时间。它使构建新容器变得非常快速。然而,需要警惕。有时缓存可能会带来意外的结果。在构建过程中,缓存被大量使用,当你希望RUN命令的更新输出进入新容器时,可能会出现问题。如果RUN命令在两次构建之间没有改变,Docker 的缓存将不会失效。实际上,Docker 将重用缓存中的先前结果。这显然是有害的。想象一种情况,当你使用RUN命令从 Git 仓库中拉取源代码时,通过使用git clone作为构建镜像的第一步。

当 Docker 缓存需要失效时要注意,否则你将在镜像构建中得到意外的结果。

这就是为什么知道如何选择性地使缓存失效很重要。在 Docker 世界中,这被称为缓存破坏。

考虑以下示例。RUN最常见的用例可能是apt-get的应用,它是 Ubuntu 上用于下载软件包的包管理器命令。假设我们有以下 Dockerfile,安装 Java 运行时:

FROM ubuntu 
RUN apt-get update 
RUN apt-get install -y openjdk-8-jre 

如果我们从这个Dockerfile构建一个镜像,两个RUN指令的所有层将被放入层缓存中。但是,过了一会儿,您决定在镜像中加入node.js包,所以现在Dockerfile看起来和这样一样:

FROM ubuntu 
RUN apt-get update 
RUN apt-get install -y openjdk-8-jre 
RUN apt-get install -y nodejs 

如果您第二次运行docker build,Docker 将通过从缓存中获取它们来重用层。因此,apt-get update将不会被执行,因为将使用缓存的版本。实际上,您新创建的镜像可能会有javanode.js包的过时版本。在创建RUN指令时,您应该始终牢记缓存的概念。在我们的例子中,我们应该始终将RUN apt-get updateapt-get install结合在同一个RUN语句中,这将创建一个单独的层;例如:

RUN apt-get update \

&& apt-get install -y openjdk-8-jre \

&& apt-get install -y nodejs \

&& apt-get clean

比这更好的是,您还可以使用一种称为“版本固定”的技术来避免缓存问题。这只是为要安装的包提供一个具体的版本。

CMD

CMD指令的目的是为执行容器提供默认值。您可以将CMD指令视为镜像的起点,当容器稍后运行时。这可以是一个可执行文件,或者,如果您指定了ENTRYPOINT指令(我们将在下面解释),您可以省略可执行文件,只提供默认参数。CMD指令的语法可以有两种形式:

  • CMD ["executable","parameter1","parameter2"]:这是所谓的exec形式。这也是首选和推荐的形式。参数是 JSON 数组,它们需要用方括号括起来。重要的一点是,当容器运行时,exec形式不会调用命令 shell。它只是运行提供的可执行文件作为第一个参数。如果Dockerfile中存在ENTRYPOINT指令,CMDENTRYPOINT指令提供了一组默认参数。

  • CMD command parameter1 parameter2:这是指令的 shell 形式。这次,shell(如果存在于镜像中)将处理提供的命令。指定的二进制文件将使用/bin/sh -c调用 shell 来执行。这意味着,如果您使用CMD echo $HOSTNAME来显示容器的主机名,您应该使用指令的 shell 形式。

我们之前说过,CMD指令的推荐形式是exec形式。原因在于:通过 shell 启动的所有内容都将作为/bin/sh -c的子命令启动,这不会传递信号。这意味着可执行文件不会成为容器的 PID 1,并且不会接收 Unix 信号,因此您的可执行文件将无法接收来自docker stop <container>SIGTERM。还有另一个缺点:您将需要在容器中使用 shell。如果您正在构建一个最小的镜像,它不需要包含 shell 二进制文件。使用 shell 形式的CMD指令将会简单失败。

当 Docker 执行命令时,它不会检查容器内是否有 shell 可用。如果镜像中没有/bin/sh,容器将无法启动。

另一方面,如果我们将CMD更改为exec形式,Docker 将寻找一个名为echo的可执行文件,这当然会失败,因为echo是一个 shell 命令。

因为CMD在运行容器时与 Docker 引擎的起点相同,Dockerfile 中只能有一个单独的CMD指令。

如果在 Dockerfile 中有多个CMD指令,只有最后一个会生效。

您可能会注意到CMD指令与RUN非常相似。它们都可以运行任何命令(或应用程序)。但有一个重要的区别:执行时间。通过RUN指令提供的命令在构建时执行,而通过CMD指令指定的命令在通过docker run在新创建的镜像上启动容器时执行。与CMD不同,RUN指令实际上用于构建镜像,通过在之前的层上创建一个新的层来提交。

RUN是一个构建时指令,CMD是一个运行时指令。

信不信由你,我们现在可以将我们的 REST 示例微服务容器化。让我们通过在第四章中创建的pom.xml文件上执行mvn clean install来检查它是否构建成功,创建 Java 微服务。构建成功后,我们应该有一个包含rest-example-0.1.0.jar文件的target目录。target目录中的 Spring Boot 应用程序 JAR 是一个可执行的、厚重的 JAR。我们将从 Docker 容器内运行它。让我们编写基本的Dockerfile,使用我们已经知道的命令,并将其放在我们项目的根目录(这将是我们docker build命令的上下文):

FROM jeanblanchard/java:8

COPY target/rest-example-0.1.0.jar rest-example-0.1.0.jar

CMD java -jar rest-example-0.1.0.jar

现在我们可以运行docker build命令,使用rest-example作为镜像名称,省略标签(你会记得,在构建镜像时省略标签会导致创建latest标签):

$ docker build . -t rest-example

作为第一个参数的点指定了docker build命令的上下文。在我们的情况下,它将只是我们小微服务的根目录。在构建过程中,Docker 将输出所有的步骤和层 ID。请注意,几乎每个Dockerfile指令都会创建一个新的层。如果你还记得第一章,Docker 简介,Docker 利用了层缓存。如果特定的层可以被重用,它将从缓存中取出。这极大地提高了构建过程的性能。最后,Docker 将输出新创建的镜像的 ID,如下截图所示:

镜像已经创建,所以应该可以运行。要列出镜像,执行以下 Docker 命令:

$ docker image ls

如下截图所示,我们的rest-example镜像已经准备好可以运行了:

到目前为止,一切都很顺利。我们已经构建了我们的镜像的基本形式。虽然运行镜像的过程是第六章的主题,使用 Java 应用程序运行容器,让我们现在快速运行它来证明它正在工作。要运行镜像,执行以下命令:

$ docker run -it rest-example

过一会儿,你应该会看到熟悉的 Spring Boot 横幅,这表明我们的服务是从 Docker 容器内部运行的:

这并不是很复杂,对吧?基本的Dockerfile只包含三行,使用FROM定义基础镜像,使用COPY将可执行的 jar 传输到镜像的文件系统中,以及使用CMD指令来运行服务。

使用 Maven 构建应用程序 jar 存档,然后使用 Dockerfile 的COPY指令进行复制就可以了。那么,将构建过程委托给 Docker 守护进程本身呢?嗯,我们可以使用我们已经知道的Dockerfile指令来做到这一点。使用 Docker 守护进程构建 Java 应用程序的缺点是镜像将包含所有的 JDK(包括 Java 编译器)、Maven 二进制文件和我们的应用程序源代码。我建议构建一个单一的构件(JAR 或 WAR 文件),进行彻底的测试(使用面向发布的 QA 周期),并将唯一的构件(当然还有它的依赖项)部署到目标机器上。然而,为了了解Dockerfile可能实现的功能,让我们看看以下示例,假设我们的应用程序代码在本地磁盘上的/app文件夹中:

FROM java:8 

RUN apt-get update

RUN apt-get install -y maven

WORKDIR /app

COPY pom.xml /app/pom.xml

COPY src /app/src

RUN ["mvn", "package"]

CMD ["/usr/lib/jvm/java-8-openjdk-amd64/bin/java", 

"-jar", "target/ rest-example-0.1.0.jar"]

在前面的例子中,Maven 构建过程将由 Docker 执行。我们只需运行apt-get命令来安装 Maven,将我们的应用程序源代码添加到镜像中,执行 Maven 的package命令,然后运行我们的服务。它的行为将与我们将已构建的构件复制到镜像文件系统中完全相同。

有一个 Dockerfile 指令与CMD指令有点相关:ENTRYPOINT。现在让我们来看看它。

ENTRYPOINT

官方的 Docker 文档说ENTRYPOINT指令允许您配置一个将作为可执行文件运行的容器。至少在第一次使用时,这并不是很清楚。ENTRYPOINT指令与CMD指令有关。实际上,起初可能会有些混淆。其原因很简单:CMD首先开发,然后为了更多的定制开发了ENTRYPOINT,这两个指令之间的一些功能重叠。让我们解释一下。ENTRYPOINT指定容器启动时将始终执行的命令。另一方面,CMD指定将传递给ENTRYPOINT的参数。Docker 有一个默认的ENTRYPOINT,即/bin/sh -c,但没有默认的CMD。例如,考虑这个 Docker 命令:

docker run ubuntu "echo" "hello world"

在这种情况下,镜像将是最新的ubuntuENTRYPOINT将是默认的/bin/sh -c,传递给ENTRYPOINT的命令将是echo "hello world"

ENTRYPOINT指令的语法可以有两种形式,类似于CMD

ENTRYPOINT ["executable", "parameter1", "parameter2"]exec形式,首选和推荐。与CMD指令的exec形式一样,这不会调用命令 shell。这意味着不会发生正常的 shell 处理。例如,ENTRYPOINT [ "echo", "$HOSTNAME" ]将不会对$HOSTNAME变量进行变量替换。如果您需要 shell 处理,那么您需要使用 shell 形式或直接执行 shell。例如:

ENTRYPOINT [ "sh", "-c", "echo $HOSTNAME" ]

在 Dockerfile 中使用ENV定义的变量(我们稍后会介绍),将被 Dockerfile 解析器替换。

ENTRYPOINT command parameter1 parameter2是一个 shell 形式。将发生正常的 shell 处理。这种形式还将忽略任何CMDdocker run命令行参数。此外,您的命令将不会成为 PID 1,因为它将由 shell 执行。因此,如果您然后运行docker stop <container>,容器将无法干净地退出,并且在超时后停止命令将被迫发送SIGKILL

CMD指令一样,Dockerfile 中的最后一个ENTRYPOINT指令才会生效。在 Dockerfile 中覆盖ENTRYPOINT允许您在运行容器时有不同的命令处理您的参数。如果您需要更改图像中的默认 shell,可以通过更改ENTRYPOINT来实现:

FROM ubuntu 

ENTRYPOINT ["/bin/bash"]

从现在开始,所有来自CMD的参数,或者在使用docker run启动容器时提供的参数,将由 Bash shell 处理,而不是默认的/bin/sh -c

考虑这个基于 BusyBox 的简单Dockerfile。BusyBox 是一个软件,它在一个可执行文件中提供了几个精简的 Unix 工具。为了演示ENTRYPOINT,我们将使用 BusyBox 中的ping命令:

FROM busybox 

ENTRYPOINT ["/bin/ping"] 

CMD ["localhost"]

让我们使用先前的 Dockerfile 构建镜像,执行以下命令:

$ docker build -t ping-example .

如果现在使用ping镜像运行容器,ENTRYPOINT指令将处理提供的CMD参数:在我们的情况下,默认情况下将是localhost。让我们运行它,使用以下命令:

$ docker run ping-example

因此,您将得到一个/bin/ping localhost的命令行响应,如您在以下截图中所见:

CMD指令,正如你从描述中记得的那样,设置了默认命令和/或参数,当你运行容器时,可以从命令行覆盖它们。ENTRYPOINT不同,它的命令和参数不能被命令行覆盖。相反,所有命令行参数将被附加到ENTRYPOINT参数之后。这样你可以锁定在容器启动时始终执行的命令。

CMD参数不同,当 Docker 容器使用命令行参数运行时,ENTRYPOINT命令和参数不会被忽略。

因为命令行参数将被附加到ENTRYPOINT参数,我们可以通过传递给ENTRYPOINT的不同参数来运行我们的ping镜像。让我们尝试一下,通过使用不同的输入来运行我们的 ping 示例:

$ docker run ping-example www.google.com

这次它的行为会有所不同。提供的参数值www.google.com将被附加到ENTRYPOINT,而不是 Dockerfile 中提供的默认CMD值。将执行的总命令行将是/bin/ping www.google.com,如你在下面的截图中所见:

您可以使用exec形式的ENTRYPOINT来设置相当稳定的默认命令和参数,然后使用CMD的任一形式来设置更有可能被更改的附加默认值。

有了ENTRYPOINT指令,我们就有了很多的灵活性。最后但并非最不重要的是,当使用docker run命令的--entrypoint参数启动容器时,ENTRYPOINT也可以被覆盖。请注意,你可以使用--entrypoint来覆盖ENTRYPOINT设置,但这只能设置要执行的二进制文件(不会使用sh -c)。正如你所见,CMDENTRYPOINT指令都定义了在运行容器时执行的命令。让我们总结一下我们对它们之间的区别和合作所学到的内容:

  • 一个 Dockerfile 应该指定至少一个CMDENTRYPOINT指令

  • Dockerfile 中只有最后一个CMDENTRYPOINT将被使用

  • 在使用容器作为可执行文件时,应该定义ENTRYPOINT

  • 你应该使用CMD指令来定义作为ENTRYPOINT定义的命令的默认参数,或者在容器中执行ad-hoc命令的方式

  • 当使用替代参数运行容器时,CMD将被覆盖

  • ENTRYPOINT设置了每次使用该镜像创建容器时使用的具体默认应用程序。

  • 如果你将ENTRYPOINTCMD配对,你可以从CMD中删除一个可执行文件,只留下它的参数,这些参数将传递给ENTRYPOINT

  • ENTRYPOINT的最佳用法是设置镜像的主要命令,允许该镜像像执行该命令一样运行(然后使用CMD作为默认标志)。

我们的服务运行正常,但并不是很有用。首先,启动它涉及许多手动步骤,这就是为什么我们将在本章后面使用 Maven 自动化它。其次,正如你会记得的,我们的服务监听着端口号为 8080 的 HTTP 请求。我们的基本镜像运行了,但没有暴露任何网络端口,因此没有人和没有东西可以访问该服务。让我们继续学习有关剩余的 Dockerfile 指令来修复它。

EXPOSE

EXPOSE指令通知 Docker 容器在运行时监听指定的网络端口。我们已经在第二章中提到了EXPOSE指令,网络和持久存储。正如你会记得的,Dockerfile 中的EXPOSE相当于--expose命令行选项。Docker 使用EXPOSE命令后跟端口号来允许流入的流量到达容器。我们已经知道EXPOSE不会自动使容器的端口在主机上可访问。要做到这一点,你必须使用-p标志来发布一系列端口,或者使用-P标志一次发布所有暴露的端口。

让我们回到我们的Dockerfile并暴露一个端口:

FROM jeanblanchard/java:8

COPY target/rest-example-0.1.0.jar rest-example-0.1.0.jar

CMD java -jar rest-example-0.1.0.jar

EXPOSE 8080

如果你现在使用相同的命令重新构建镜像,docker build . -t rest-example,你会注意到 Docker 输出了第四层,表示端口 8080 已经被暴露。暴露的端口将对此 Docker 主机上的其他容器可用,并且如果在运行时映射它们,也对外部世界可用。好吧,让我们尝试一下,使用以下docker run命令:

$ docker run -p 8080:8080 -it rest-example

如果您现在使用HTTP请求调用本地主机,比如POST(用于保存我们的图书实体)或GET(用于获取图书列表或单本图书),就像我们在第四章中所做的那样,创建 Java 微服务,使用任何 HTTP 工具,比如 HTTPie 或 Postman,它将像以前一样做出响应。但是,这一次是来自 Docker 容器。现在,这是一件了不起的事情。让我们了解剩下的重要的 Dockerfile 指令。

VOLUME

正如您在第一章中所记得的,Docker 简介,容器文件系统默认是临时的。如果您启动 Docker 镜像(即运行容器),您将得到一个读写层,该层位于堆栈的顶部。您可以随意创建,修改和删除文件,然后提交该层以保留更改。在第二章中,网络和持久存储,我们已经学会了如何创建卷,这是一种很好的存储和检索数据的方法。我们可以在Dockerfile中使用VOLUME指令做同样的事情。

语法再简单不过了:就是VOLUME ["/volumeName"]

VOLUME的参数可以是 JSON 数组,也可以是一个带有一个或多个参数的普通字符串。例如:

VOLUME ["/var/lib/tomcat8/webapps/"]

VOLUME /var/log/mongodb /var/log/tomcat

VOLUME指令创建一个具有指定名称的挂载点,并将其标记为包含来自本机主机或其他容器的外部挂载卷。

VOLUME命令将在容器内部挂载一个目录,并将在该目录内创建或编辑的任何文件存储在容器文件结构之外的主机磁盘上。在Dockerfile中使用VOLUME让 Docker 知道某个目录包含永久数据。Docker 将为该数据创建一个卷,并且即使删除使用它的所有容器,也不会删除它。它还绕过了联合文件系统,因此该卷实际上是一个实际的目录,它会在所有共享它的容器中(例如,如果它们使用--volumes-from选项启动)以正确的方式挂载,无论是读写还是只读。要理解VOLUME,让我们看一个简单的 Dockerfile:

FROM ubuntu 

VOLUME /var/myVolume

如果您现在运行容器并在/var/myVolume中保存一些文件,它们将可供其他容器共享。

基本上,VOLUME-v几乎是相等的。VOLUME-v之间的区别在于,您可以在执行docker run启动容器时动态使用-v并将您的host目录挂载到容器上。这样做的原因是 Dockerfile 旨在具有可移植性和共享性。主机目录卷是 100%依赖于主机的,并且在任何其他机器上都会出现问题,这与 Docker 的理念有些不符。因此,在 Dockerfile 中只能使用可移植指令。

VOLUME-v之间的根本区别在于:-v会将操作系统中现有的文件挂载到 Docker 容器内,而VOLUME会在主机上创建一个新的空卷,并将其挂载到容器内。

LABEL

为了向我们的镜像添加元数据,我们使用LABEL指令。单个标签是一个键值对。如果标签值中需要有空格,您需要用引号将其包裹起来。标签是可累加的,它们包括从作为您自己镜像基础的镜像(FROM指令中的镜像)中获取的所有标签。如果 Docker 遇到已经存在的标签,它将用新值覆盖具有相同键的标签。在定义标签时,有一些规则必须遵守:键只能由小写字母数字字符、点和破折号组成,并且必须以字母数字字符开头和结尾。为了防止命名冲突,Docker 建议使用反向域表示法为标签键使用命名空间。另一方面,没有命名空间(点)的键保留供命令行使用。

LABEL指令的语法很简单:

LABEL "key"="value"

要使用多行值,请使用反斜杠将行分隔开;例如:

LABEL description="This is my \

multiline description of the software."

您可以在单个镜像中拥有多个标签。用空格或反斜杠分隔它们;例如:

LABEL key1="value1" key2="value2" key3="value3"

LABEL key1="value1" \

key2="value2" \

key3="value3"

实际上,如果您的镜像中需要有多个标签,建议使用LABEL指令的多标签形式,因为这样会在镜像中只产生一个额外的层。

每个LABEL指令都会创建一个新的层。如果您的镜像有很多标签,请使用单个LABEL指令的多重形式。

如果您想要查看镜像具有哪些标签,可以使用您已经在之前章节中了解过的docker inspect命令。

ENV

ENV是一个Dockerfile指令,它将环境变量<key>设置为值<value>。您可以有两种选项来使用ENV

  • 第一个,ENV <key> <value> ,将一个单一变量设置为一个值。第一个空格后的整个字符串将被视为 <value> 。这将包括任何字符,还有空格和引号。例如:
ENV JAVA_HOME /var/lib/java8

  • 第二个,带有等号的是 ENV <key>=<value> 。这种形式允许一次设置多个环境变量。如果需要在值中提供空格,您需要使用引号。如果需要在值中使用引号,使用反斜杠:
ENV CONFIG_TYPE=file CONFIG_LOCATION="home/Jarek/my \app/config.json"

请注意,您可以使用 ENV 更新 PATH 环境变量,然后 CMD 参数将意识到该设置。这将导致 DockerfileCMD 参数的更清晰形式。例如,设置如下:

ENV PATH /var/lib/tomcat8/bin:$PATH

这将确保 CMD ["startup.sh"] 起作用,因为它将在系统 PATH 中找到 startup.sh 文件。您还可以使用 ENV 设置经常修改的版本号,以便更容易处理升级,如下例所示:

ENV TOMCAT_VERSION_MAJOR 8

ENV TOMCAT_VERSION 8.5.4

RUN curl -SL http://apache.uib.no/tomcat/tomcat-$TOMCAT_VERSION_MAJOR/v$TOMCAT_VERSION/bin/apache-tomcat-$TOMCAT_VERSION.tar.gz | tar zxvf apache-tomcat-$TOMCAT_VERSION.tar.gz -c /usr/Jarek/apache-tomcat-$TOMCAT_VERSION

ENV PATH /usr/Jarek/apache-tomcat-$TOMCAT_VERSION/bin:$PATH

在上一个示例中,Docker 将下载 ENV 变量中指定的 Tomcat 版本,将其提取到具有该版本名称的新目录中,并设置系统 PATH 以使其可用于运行。

使用 ENV 设置的环境变量将在从生成的镜像运行容器时持续存在。与使用 LABEL 创建的标签一样,您可以使用 docker inspect 命令查看 ENV 值。ENV 值也可以在容器启动之前使用 docker run --env <key>=<value> 覆盖。

USER

USER 指令设置运行镜像时要使用的用户名或 UID。它将影响 Dockerfile 中接下来的任何 RUNCMDENTRYPOINT 指令的用户。

指令的语法只是 USER <用户名或 UID> ;例如:

USER tomcat

如果可执行文件可以在没有特权的情况下运行,可以使用 USER 命令。Dockerfile 可以包含与此相同的用户和组创建指令:

RUN groupadd -r tomcat && useradd -r -g tomcat tomcat

频繁切换用户将增加生成镜像中的层数,并使 Dockerfile 更加复杂。

ARG

ARG 指令用于在 docker build 命令期间向 Docker 守护程序传递参数。ARG 变量定义从 Dockerfile 中定义的行开始生效。通过使用 --build-arg 开关,您可以为已定义的变量分配一个值:

$ docker build --build-arg <variable name>=<value> .

--build-arg中的值将传递给构建图像的守护程序。您可以使用多个ARG指令指定多个参数。如果您指定了未使用ARG定义的构建时间参数,构建将失败并显示错误,但可以在Dockerfile中指定默认值。您可以通过以下方式指定默认参数值:

FROM ubuntu 

ARG user=jarek

如果在开始构建之前未指定任何参数,则将使用默认值:

不建议使用ARG传递秘密,如 GitHub 密钥、用户凭据、密码等,因为所有这些都将通过使用docker history命令对图像的任何用户可见!

ONBUILD

ONBUILD指令指定了另一个指令,当使用此图像作为其基础图像构建其他图像时将触发该指令。换句话说,ONBUILD指令是父Dockerfile给子Dockerfile(下游构建)的指令。任何构建指令都可以注册为触发器,并且这些指令将在Dockerfile中的FROM指令之后立即触发。

ONBUILD指令的语法如下:

ONBUILD <INSTRUCTION>

在其中,<INSTRUCTION>是另一个 Dockerfile 构建指令,稍后将在构建子图像时触发。有一些限制:ONBUILD指令不允许链接另一个ONBUILD指令,也不允许FROMMAINTAINER指令作为ONBUILD触发器。

这在构建将用作基础构建其他图像的图像时非常有用。例如,应用程序构建环境或可能使用用户特定配置进行定制的守护程序。ONBUILD指令非常有用(docs.docker.com/engine/reference/builder/#onbuilddocs.docker.com/engine/reference/builder/#maintainer-deprecated),用于自动构建所选软件堆栈。考虑以下使用 Maven 构建 Java 应用程序的示例(是的,Maven 也可以作为 Docker 容器使用)。基本上,您项目的 Dockerfile 只需要引用包含ONBUILD指令的基础容器即可:

 FROM maven:3.3-jdk-8-onbuild 

 CMD ["java","-jar","/usr/src/app/target/app-1.0-SNAPSHOT-jar-with-dependencies.jar"] 

没有魔法,如果您查看父级的 Dockerfile,一切都会变得清晰。在我们的情况下,它将是 GitHub 上可用的docker-maven Dockerfile:

 FROM maven:3-jdk-8

RUN mkdir -p /usr/src/app

WORKDIR /usr/src/app

ONBUILD ADD . /usr/src/app

ONBUILD RUN mvn install 

有一个基础镜像,其中安装了 Java 和 Maven,并有一系列指令来复制文件和运行 Maven。

ONBUILD指令会向镜像添加一个触发指令,以便在将来作为另一个构建的基础时执行。触发器将在子构建的上下文中执行,就好像它被立即插入到子Dockerfile中的FROM指令之后一样。

当 Docker 在构建过程中遇到ONBUILD指令时,构建器会向正在构建的镜像的元数据中添加一种触发器。但这是影响到该镜像的唯一方式。在构建结束时,所有触发器的列表将存储在镜像清单中,键为OnBuild。您可以使用我们已经知道的docker inspect命令来查看它们。

稍后,该镜像可以作为新构建的基础,使用FROM指令。在处理FROM指令时,Docker 构建器会寻找ONBUILD触发器,并按照它们注册的顺序执行它们。如果任何触发器失败,FROM指令将被中止,这将导致构建失败。如果所有触发器成功,FROM指令完成,构建继续进行。

STOPSIGNAL

要指定应发送哪个系统调用信号以退出容器,请使用STOPSIGNAL指令。该信号可以是与内核的syscall表中的位置匹配的有效无符号数字,例如9,或者是格式为SIGNAME的信号名称,例如SIGKILL

HEALTHCHECK

HEALTHCHECK指令可用于通知 Docker 如何测试容器以检查其是否仍在工作。这可以是检查我们的 REST 服务是否响应HTTP调用,或者只是监听指定的端口。

容器可以有几种状态,可以使用docker ps命令列出。这些可以是createdrestartingrunningpausedexiteddead。但有时这还不够;从 Docker 的角度来看,容器可能仍然存活,但应用程序可能会挂起或以其他方式失败。对应用程序状态的额外检查可能很有用,HEALTHCHECK非常方便。

HEALTHCHECK状态最初为 starting。每当健康检查通过时,它就变为healthy(无论之前处于什么状态)。在连续失败一定次数后,它就会变为unhealthy

HEALTHCHECK指令的语法如下:

HEALTHCHECK --interval=<interval> --timeout=<timeout> CMD <command>

<interval>(默认值为 30 秒)和<timeout>(同样,默认值为 30 秒)是时间值,分别指定检查间隔和超时时间。<command>是实际用于检查应用程序是否仍在运行的命令。<command>的退出代码被 Docker 用来确定健康检查是失败还是成功。值可以是0,表示容器健康并且可以使用,也可以是1,表示出现了问题,容器无法正常工作。Java 微服务的healthcheck实现可以是一个简单的/ping REST 端点,返回任何内容(如时间戳),甚至可以返回一个空响应和HTTP 200状态码,证明它还活着。我们的HEALTHCHECK可以执行对这个端点的GET方法,检查服务是否响应。

HEALTHCHECK --interval=5m --timeout=2s --retries=3 CMD curl -f http://localhost/ping || exit 1

在上一个示例中,命令curl -f http://localhost/ping将每 5 分钟执行一次,最长超时时间为 2 秒。如果检查的单次运行时间超过 2 秒,则认为检查失败。如果连续三次重试失败,容器将获得unhealthy状态。

Dockerfile 中只能有一个HEALTHCHECK指令。如果列出多个,则只有最后一个HEALTHCHECK会生效。

HEALTHCHECK指令使您有可能微调容器监控,从而确保容器正常工作。这比仅有runningexiteddead标准 Docker 状态要好。

现在我们已经了解了Dockerfile指令,我们准备好准备我们的图像。让我们自动化一些事情。我们将使用 Maven 创建和运行我们的图像。

使用 Maven 创建图像

当然,我们可以使用 Docker 本身来构建我们的 Docker 镜像。但这不是 Spring 开发人员的典型用例。对我们来说,典型的用例是使用 Maven。如果你已经设置了持续集成流程,例如使用 Jenkins,这将特别有用。将镜像构建过程委托给 Maven 可以给你很大的灵活性,也可以节省大量时间。目前在 GitHub 上至少有几个免费的 Docker Maven 插件可用,例如:

我们的用例将使用 Maven 打包 Spring Boot 可执行 JAR 文件,然后将构建产物复制到 Docker 镜像中。使用 Maven 插件来构建 Docker 主要关注两个方面:

  • 构建和推送包含构建产物的 Docker 镜像

  • 启动和停止 Docker 容器进行集成测试和开发。这是我们将在第六章中专注的内容,使用 Java 应用程序运行容器

让我们现在专注于创建一个镜像,从插件目标和可能的配置选项开始。

fabric8 Docker 插件提供了一些 Maven 目标:

  • docker:build:这使用 maven-assembly-plugin 的装配描述符格式来指定将从子目录(默认为/maven)添加到镜像中的内容

  • docker:push:使用此插件构建的镜像可以推送到公共或私有的 Docker 注册表

  • docker:startdocker:stop:用于启动和停止容器

  • docker:watch:这将依次执行docker:builddocker:run。它可以在后台永远运行(单独的控制台),除非您使用 CTRL+C 停止它。它可以监视装配文件的更改并重新运行构建。这样可以节省很多时间。

  • docker:remove:用于清理镜像和容器

  • docker:logs:这会打印出正在运行的容器的输出

  • docker:volume-createdocker:volume-remove:分别用于创建和删除卷。我们将在本章后面再回到这些内容

在我们运行这些目标之前,我们需要告诉插件它应该如何行为。我们在项目的pom.xml文件中的插件配置中进行配置:

  • Maven Docker 插件配置

插件定义中的重要部分是<configuration>元素。这是您设置插件行为的地方。<configuration>中有两个主要元素:

  • 指定如何构建镜像的<build>配置

  • 描述如何创建和启动容器的<run>配置

这是fabric8 Maven 插件的 Docker 的配置的最简单的示例:

<plugin>

 <groupId>io.fabric8</groupId>

 <artifactId>docker-maven-plugin</artifactId>

 <version>0.20.1</version>

 <configuration>

 <dockerHost>http://127.0.0.1:2375</dockerHost>

 <verbose>true</verbose>

 <images>

 <image>

 <name>rest-example:${project.version}</name>

 <build>

 <dockerFile>Dockerfile</dockerFile>

 <assembly>

 <descriptorRef>artifact</descriptorRef>

 </assembly>

 </build>

 </image>

 </images>

 </configuration>

</plugin>

<dockerHost>指定正在运行的 Docker 引擎的 IP 地址和端口,因此,当然,要使其构建,您首先需要运行 Docker。在前面的情况下,如果您从 shell 运行mvn clean package docker:build命令,Fabric8 Docker 插件将使用您提供的Dockerfile构建镜像。但是还有另一种构建图像的方法,根本不使用Dockerfile,至少不是显式定义的。要做到这一点,我们需要稍微更改插件配置。看一下修改后的配置:

<configuration>

 <images>

 <image>

 <name>rest-example:${project.version}</name>

 <alias>rest-example</alias>

 <build>

 <from>jeanblanchard/java:8</from>

 <assembly>

 <descriptorRef>artifact</descriptorRef>

 </assembly>

 <cmd>java -jar 

 maven/${project.name}-${project.version}.jar</cmd>

 </build>

 </image>

 </images>

</configuration>

正如您所看到的,我们不再提供Dockerfile。相反,我们只提供Dockerfile指令作为插件配置元素。这非常方便,因为我们不再需要硬编码可执行 jar 名称、版本等。它将从 Maven 构建范围中获取。例如,jar 的名称将被提供给<cmd>元素。这将自动导致在Dockerfile中生成有效的CMD指令。如果我们现在使用mvn clean package docker:build命令构建项目,Docker 将使用我们的应用程序构建一个镜像。让我们按字母顺序列出我们可用的配置元素:

元素 描述

| assembly | <assembly> 元素定义了如何构建进入 Docker 镜像的构件和其他文件。您可以使用 targetDir 元素提供一个目录,其中包含装配中包含的文件和构件将被复制到镜像中。这个元素的默认值是 /maven。在我们的示例中,我们将使用 <descriptorRef> 提供预定义装配描述符之一。<descriptorRef> 是一种方便的快捷方式,可以取以下值:

  • artifact-with-dependencies : 附加项目的构件和所有依赖项。此外,当类路径文件存在于目标目录中时,它将被添加进去。

  • artifact : 仅附加项目的构件,而不包括依赖项。

  • project : 附加整个 Maven 项目,但不包括 target/ 目录。

  • rootWar : 将构件复制为 ROOT.warexposed 目录。例如,Tomcat 可以在 root 上下文中部署 war 文件。

|

buildArgs 允许提供一个映射,指定 Docker buildArgs 的值,在使用构建参数的外部 Dockerfile 构建镜像时使用。键值语法与定义 Maven 属性(或 labelsenv)时相同。
buildOptions 一个映射,用于指定构建选项,提供给 Docker 守护程序在构建镜像时使用。
cleanup 这对于在每次构建后清理未标记的镜像很有用(包括从中创建的任何容器)。默认值是 try,它尝试删除旧镜像,但如果不可能,例如,镜像仍然被运行中的容器使用,则不会使构建失败。
cmd 这相当于我们已经了解的 CMD 指令,用于提供默认执行的命令。
compression 可以取 none(默认值)、gzipbzip2 值。它允许我们指定压缩模式以及构建存档如何传输到 Docker 守护程序(docker:build)。
entryPoint 等同于 Dockerfile 中的 ENTRYPOINT
env 等同于 Dockerfile 中的 ENV
from 等同于 Dockerfile 中的 FROM,用于指定基础镜像。
healthCheck 等同于 Dockerfile 中的 HEALTHCHECK
labels 用于定义标签,与 Dockerfile 中的 LABEL 相同。
maintainer 等同于 Dockerfile 中的 MAINTAINER
nocache 用于禁用 Docker 的构建层缓存。可以通过设置系统属性 docker.nocache 来覆盖,当运行 Maven 命令时。
optimize 如果设置为 true,则会将所有 runCmds 压缩成单个 RUN 指令。强烈建议最小化创建的镜像层的数量。
ports 在 Dockerfile 中的 EXPOSE 的等效。这是一个 <port> 元素的列表,每个元素表示要暴露的一个端口。格式可以是纯数字,如 "8080",也可以附加协议,如 "8080/tcp"
runCmds 等效于 RUN,在构建过程中要运行的命令。它包含要传递给 shell 的 <run> 元素。
tags 可以包含一系列 <tag> 元素,提供构建后要标记的额外标签。
user 等效于 Dockerfile 中的 USER,指定 Dockerfile 应切换到的用户。
volumes 包含一系列 VOLUME 等效,一个 <volume> 元素的列表,用于创建容器卷。
workdir 与 Dockerfile 中的 WORKDIR 等效,表示启动容器时要切换到的目录。

如您所见,插件配置非常灵活,包含了 Dockerfile 指令的完整等效集。让我们看看我们的 pom.xml 在正确配置下是什么样子。

完整的 pom.xml

如果您从头开始关注我们的项目,完整的 Maven POM 与以下内容相同:

 <?xml version="1.0" encoding="UTF-8"?>

    <project   xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">

      <modelVersion>4.0.0</modelVersion>

      <groupId>pl.finsys</groupId>

      <artifactId>rest-example</artifactId>

      <version>0.1.0</version>

      <parent>

        <groupId>org.springframework.boot</groupId>

        <artifactId>spring-boot-starter-

         parent</artifactId>

        <version>1.5.2.RELEASE</version>

      </parent>

      <dependencies>

        <dependency>

          <groupId>org.springframework.boot</groupId>

          <artifactId>spring-boot-starter-web</artifactId>

        </dependency>

        <dependency>

          <groupId>org.springframework.boot</groupId>

          <artifactId>spring-boot-starter-data-

           jpa</artifactId>

        </dependency>

        <dependency>

          <groupId>org.hibernate</groupId>

          <artifactId>hibernate-validator</artifactId>

        </dependency>

        <dependency>

          <groupId>org.hsqldb</groupId>

          <artifactId>hsqldb</artifactId>

          <scope>runtime</scope>

        </dependency>

        <dependency>

          <groupId>io.springfox</groupId>

          <artifactId>springfox-swagger2</artifactId>

          <version>2.6.1</version>

        </dependency>

        <dependency>

          <groupId>io.springfox</groupId>

          <artifactId>springfox-swagger-ui</artifactId>

          <version>2.5.0</version>

        </dependency>

        <!--test dependencies-->

        <dependency>

          <groupId>org.springframework.boot</groupId>

          <artifactId>spring-boot-starter-

           test</artifactId>

          <scope>test</scope>

        </dependency>

        <dependency>

          <groupId>org.springframework.boot</groupId>

          <artifactId>spring-boot-starter-

           test</artifactId>

          <scope>test</scope>

        </dependency>

        <dependency>

          <groupId>com.jayway.jsonpath</groupId>

          <artifactId>json-path</artifactId>

          <scope>test</scope>

        </dependency>

      </dependencies>

      <properties>

        <java.version>1.8</java.version>

      </properties>

      <build>

        <plugins>

          <plugin>

            <groupId>org.springframework.boot</groupId>

            <artifactId>spring-boot-maven-

             plugin</artifactId>

          </plugin>

          <plugin>

            <groupId>org.springframework.boot</groupId>

            <artifactId>spring-boot-maven-

            plugin</artifactId>

          </plugin>

          <plugin>

            <groupId>io.fabric8</groupId>

            <artifactId>docker-maven-plugin</artifactId>

            <version>0.20.1</version>

            <configuration>

              <images>

                <image>

                  <name>rest-example:${project.version}

                  </name>

                  <alias>rest-example</alias>

                  <build>

                    <from>openjdk:latest</from>

                    <assembly>

                      <descriptorRef>artifact</descriptorRef>

                    </assembly>

                    <cmd>java -jar maven/${project.name}-${project.version}.jar</cmd>

                  </build>

                  <run>

                    <wait>

                      <log>Hello World!</log>

                    </wait>

                  </run>

                </image>

              </images>

            </configuration>

          </plugin>

        </plugins>

      </build>

      <repositories>

        <repository>

          <id>spring-releases</id>

          <url>https://repo.spring.io/libs-release</url>

        </repository>

      </repositories>

      <pluginRepositories>

        <pluginRepository>

          <id>spring-releases</id>

          <url>https://repo.spring.io/libs-release</url>

        </pluginRepository>

      </pluginRepositories>

    </project> 

构建镜像

要使用我们的 Spring Boot 构件构建 Docker 镜像,请运行以下命令:

$ mvn clean package docker:build

clean 告诉 Maven 删除 target 目录。Maven 将始终使用 package 命令编译您的类。使用 docker:build 命令运行 package 命令非常重要。如果尝试在两个单独的步骤中运行这些命令,将会遇到错误。在构建 Docker 镜像时,您将在控制台中看到以下输出:

新镜像的 ID 将显示在控制台输出中。如果您想知道自动生成的 Dockerfile 看起来和什么一样,您可以在项目的 target/docker/rest-example/0.1.0/build 目录中找到它。第一次构建此 Docker 镜像时,由于正在下载所有层,所以会花费更长时间。但由于层缓存的原因,每次构建都会快得多。

创建和删除卷

Fabric8 Maven Docker 插件如果没有管理卷的可能性,就不可能成为一个完整的解决方案。实际上,它提供了两种处理卷的方式:docker:volume-createdocker:volume-remove。正如你可能还记得的那样,来自第二章的网络和持久存储,Docker 在处理卷和它们的驱动程序时使用了类似插件的架构。fabric8插件可以配置为将特定的卷驱动程序及其参数传递给 Docker 守护程序。考虑一下插件配置的以下片段:

 <plugin> 

 <configuration> 

    [...] 

    <volumes> 

    <volume> 

    <name>myVolume</name> 

    <driver>local</driver> 

    <opts> 

    <type>tmpfs</type> 

    <device>tmpfs</device> 

    <o>size=100m,uid=1000</o> 

    </opts> 

    <labels> 

    <volatileData>true</volatileData> 

    </labels> 

    </volume> 

    </volumes> 

    </configuration> 

  </plugin> 

在上一个例子中,我们使用本地文件系统驱动程序创建了一个命名卷。它可以在容器启动期间挂载,如pom.xml文件的<run>部分中指定的那样。

总结

在本章中,我们看了如何开始使用 Docker 容器和打包 Java 应用程序。我们可以通过手动使用docker build命令和Dockerfile来手动完成,也可以使用 Maven 来自动化。对于 Java 开发人员,Docker 有助于将我们的应用程序隔离在一个干净的环境中。隔离很重要,因为它减少了我们使用的软件环境的复杂性。Fabric8 Maven Docker 插件是一个很好的工具,我们可以使用它来使用 Maven 自动构建我们的镜像,特别是在处理 Java 应用程序时。不再需要手动编写 Dockerfile,我们只需使用广泛的选项配置插件,就可以完成。此外,使用 Maven 使我们可以轻松地将 Docker 构建纳入我们现有的开发流程中,例如使用 Jenkins 进行持续交付。在第六章中,使用 Java 应用程序运行容器,我们将更详细地讨论如何从容器内部运行我们的 Java 应用程序。当然,我们也会使用 Maven 来完成这个过程。

第六章:使用 Java 应用程序运行容器

在第五章 使用 Java 应用程序创建镜像中,我们学习了 Dockerfile 的结构以及如何构建我们的镜像。在这一点上,您应该能够创建自己的 Docker 镜像并开始使用它。实际上,我们已经多次运行了容器,但没有深入细节。我们手动构建了镜像,使用 Dockerfile,然后发出了docker build命令。我们还使用 Maven 来自动化构建过程。我们创建的镜像包含了我们简单的 REST Java 服务。我们已经运行它来检查它是否真的有效。然而,这一次,我们将更详细地讨论从我们的镜像运行容器的一些细节。本章将包括以下概念:

  • 启动和停止容器

  • 容器运行模式

  • 监控容器

  • 容器重启策略

  • 资源的运行时约束

  • 使用 Maven 运行容器

启动和停止容器

让我们回到一点,从基础知识开始:如何手动从 shell 或命令行运行和停止 Docker 容器。

开始

正如您在前几章中看到的那样,要从镜像中启动容器,我们使用docker run命令。运行的容器将有自己的文件系统、网络堆栈和与主机分开的隔离进程树。正如您在第五章 使用 Java 应用程序创建镜像中所记得的,每个docker run命令都会创建一个新的容器,并执行 Dockerfile、CMDENTRYPOINT中指定的命令。

docker run命令的语法如下:

$ docker run [OPTIONS] IMAGE[:TAG|@DIGEST] [COMMAND] [ARG...]

该命令使用镜像名称,可选的TAGDIGEST。如果跳过TAGDIGEST命令参数,Docker 将基于标记为latest的镜像运行容器。docker run命令还接受一组可能有用的选项,例如运行时模式、分离或前台、网络设置或 CPU 和内存的运行时限制。我们将在本章后面介绍这些内容。当然,您可以执行docker run命令,几乎没有任何参数,除了镜像名称。它将运行并采用镜像中定义的默认选项。指定选项可以让您覆盖图像作者指定的选项以及 Docker 引擎的运行时默认值。

COMMAND参数不是必需的,镜像的作者可能已经在Dockerfile中使用CMD指令提供了默认的COMMANDCMD在 Dockerfile 中只出现一次,通常是最后一条指令。从镜像启动容器时,我们可以通过提供自己的命令或参数作为docker runCOMMAND参数来覆盖CMD指令。在docker run命令中出现在镜像名称之后的任何内容都将传递给容器,并被视为CMD参数。如果镜像还指定了ENTRYPOINT,那么CMDCOMMAND将作为参数附加到ENTRYPOINT。但是猜猜,我们也可以使用docker run命令的--entrypoint选项来覆盖ENTRYPOINT

停止

要停止一个或多个正在运行的 Docker 容器,我们使用docker stop命令。语法很简单:

$ docker stop [OPTIONS] CONTAINER [CONTAINER...]

您可以指定一个或多个要停止的容器。docker stop的唯一选项是-t--time),它允许我们指定在停止容器之前等待的时间。默认值为 10 秒,应该足够容器优雅地停止。要以更加残酷的方式停止容器,可以执行以下命令:

$ docker kill  CONTAINER [CONTAINER...]

docker stopdocker kill之间有什么区别?它们都会停止正在运行的容器。但有一个重要的区别:

  • docker stop:容器内的主进程首先会收到SIGTERM,然后经过一个宽限期,会收到SIGKILL

  • docker kill:容器内的主进程将被发送SIGKILL(默认)或使用--signal选项指定的任何信号

换句话说,docker stop尝试通过发送标准的 POSIX 信号SIGTERM来触发优雅的关闭,而docker kill只是残酷地杀死进程,因此关闭容器。

列出正在运行的容器

要列出正在运行的容器,只需执行docker ps命令:

$ docker ps

要包括 Docker 主机上存在的所有容器,请包括-a选项:

$ docker ps -a

您还可以使用-f选项过滤列表以指定过滤器。过滤器需要以key=value格式提供。当前可用的过滤器包括:

  • id:按容器的 id 筛选

  • 标签:按标签筛选

  • 名称:按容器的名称筛选

  • 退出:按容器的退出代码筛选

  • 状态:按状态筛选,可以是 created、restarting、running、removing、paused、exited 或 dead

  • volume:当指定卷名称或挂载点时,将包括挂载指定卷的容器

  • network:当指定网络 ID 或名称时,将包括连接到指定网络的容器

考虑以下示例,它将获取 Docker 主机上的所有容器,并通过运行状态进行筛选:

$ docker ps -a -f status=running

删除容器

要从主机中删除容器,我们使用docker rm命令。语法如下:

$ docker rm [OPTIONS] CONTAINER [CONTAINER...]

您可以一次指定一个或多个容器。如果您一遍又一遍地运行短期前台进程,这些文件系统的大小可能会迅速增长。有一个解决方案:不要手动清理,告诉 Docker 在容器退出时自动清理容器并删除文件系统。您可以通过添加--rm标志来实现这一点,这样在进程完成后容器数据会被自动删除。

--rm标志将使 Docker 在容器关闭后删除容器。

例如,使用以下示例中的run命令:

$ docker run --rm -it Ubuntu /bin/bash

上述命令告诉 Docker 在关闭容器时将其删除。

在启动 Docker 容器时,您可以决定是以默认模式、前台模式还是后台模式(即分离模式)运行容器。让我们解释一下它们之间的区别。

容器运行模式

Docker 有两种容器运行模式,前台和分离。让我们从默认模式,即前台模式开始。

前台

在前台模式下,您用来执行docker run的控制台将附加到标准输入、输出和错误流。这是默认行为;Docker 将STDINSTDOUTSTDERR流附加到您的 shell 控制台。如果需要,您可以更改此行为,并为docker run命令使用-a开关。作为-a开关的参数,您使用要附加到控制台的流的名称。例如:

$ docker run -a stdin -a stdout -i -t centos /bin/bash

上述命令将把stdinstdout流附加到您的控制台。

有用的docker run选项是-i--interactive(用于保持STDIN流开放,即使未附加)和-t-tty(用于附加伪 tty)开关,通常一起使用为-it,您需要使用它为在容器中运行的进程分配伪 tty控制台。实际上,我们在第五章中使用了这个选项,使用 Java 应用程序创建镜像,当我们运行我们的 REST 服务时。

$ docker run -it rest-example

简单地说,-it用于在容器启动后将命令行附加到容器。这样,您可以在 shell 控制台中查看正在运行的容器的情况,并在需要时与容器交互。

分离

您可以使用-d选项以分离模式启动 Docker 容器。这是前台模式的相反。容器启动并在后台运行,就像守护进程或服务一样。让我们尝试在后台运行我们的 rest-example,执行以下命令:

$ docker run -d -p 8080:8080 rest-example

容器启动后,您将获得控制权,并可以使用 shell 或命令行执行其他命令。Docker 将只输出容器 ID,如下面的屏幕截图所示:

您可以使用容器 ID 在其他 docker 命令中引用容器,例如,如果您需要停止容器或附加到容器。我们的服务虽然在后台运行,但仍在工作:Spring Boot 应用程序在端口8080上监听HTTP GETPOST请求。请注意,以分离模式启动的容器会在用于运行容器的根进程退出时停止。了解这一点很重要,即使您有一些在后台运行的进程(从 Dockerfile 中的指令启动),Docker 也会在启动容器的命令完成时停止容器。在我们的情况下,Spring Boot 应用程序正在运行和监听,并且同时防止 Docker 关闭容器。要将容器从后台带回到控制台的前台,您需要附加到它。

附加到运行的容器

要保持对分离容器的控制,请使用docker attach命令。docker attach的语法非常简单:

$ docker attach [OPTIONS] <container ID or name>

在我们的情况下,这将是在启动容器时给我们的 ID:

$ docker attach 5687bd611f84b53716424fd826984f551251bc95f3db49715fc7211a6bb23840

此时,如果有什么东西被打印出来,比如我们运行的 REST 服务的另一条日志行,您将在控制台上看到它。正如您所看到的,如果您需要实时查看写入stdout流的内容,docker attach命令会很有用。它基本上会重新附加您的控制台到容器中运行的进程。换句话说,它将stdout流传输到您的屏幕,并将stdin映射到您的键盘,允许您输入命令并查看它们的输出。请注意,当附加到容器时按下CTRL + C键序列会终止容器的运行进程,而不是从控制台中分离。要从进程中分离,请使用默认的CTRL+PCTRL+Q键序列。如果CTRL + PCTRL + Q序列与您现有的键盘快捷键冲突,您可以通过为docker attach命令设置--detach-keys选项来提供自己的分离序列。如果您希望能够使用CTRL + C分离,您可以通过将sig-proxy参数设置为false来告诉 Docker 不要向容器中运行的进程发送sig-term

$ docker attach --sig-proxy=false [container-name or ID]

如果容器在后台运行,监视其行为将是很好的。Docker 提供了一套功能来实现这一点。让我们看看如何监视运行中的容器。

监视容器

监视运行中的 Docker 容器有一些方法。可以查看日志文件,查看容器事件和统计信息,还可以检查容器属性。让我们从 Docker 具有的强大日志记录功能开始。访问日志条目至关重要,特别是如果您的容器在分离的运行时模式下运行。让我们看看在日志记录机制方面 Docker 能提供什么。

查看日志

大多数应用程序将它们的日志条目输出到标准的stdout流。如果容器在前台模式下运行,您将在控制台上看到它。但是,当以分离模式运行容器时,您在控制台上将什么也看不到,只会看到容器 ID。但是,Docker 引擎会在主机上的历史文件中收集运行容器的所有stdout输出。您可以使用docker logs命令来显示它。命令的语法如下:

$ docker logs -f <container name or ID>

docker logs命令将仅将日志的最后几行输出到控制台。由于容器仍在后台运行(以分离模式),您将立即收到提示,如下面的屏幕截图所示,显示了我们的 REST 服务日志文件的片段:

-f标志在 Linux tail命令中起着相同的作用,它会在控制台上持续显示新的日志条目。当你完成后,按下CTRL + C停止在控制台上显示日志文件。请注意,这与在容器中按下CTRL + C不同,那里CTRL + C会终止容器内运行的进程。这次,它只会停止显示日志文件,很安全。

日志文件是永久的,即使容器停止,只要其文件系统仍然存在于磁盘上(直到使用docker rm命令删除为止)。默认情况下,日志条目存储在位于/var/lib/docker目录中的 JSON 文件中。您可以使用docker inspect命令查看日志文件的完整路径,并使用模板提取LogPath(我们将在稍后介绍inspect和模板)。

我们已经说过,默认情况下,日志条目将进入 JSON 文件。但这可以很容易地改变,因为 Docker 利用了日志驱动程序的概念。通过使用不同的驱动程序,您可以选择其他存储容器日志的方式。默认驱动程序是json-file驱动程序,它只是将条目写入 JSON 文件。每个驱动程序都可以接受附加参数。例如,JSON 驱动程序接受:

--log-opt max-size=[0-9+][k|m|g]

--log-opt max-file=[0-9+]

您可能已经猜到,这类似于我们 Java 应用程序中的滚动文件。max-size指定可以创建的最大文件大小;达到指定大小后,Docker 将创建一个新文件。您可以使用大小后缀kmg,其中 k 代表千字节,m代表兆字节,g代表千兆字节。将日志拆分为单独的文件使得传输、存档等变得更加容易。此外,如果文件更小,搜索日志文件会更加方便。

docker log命令只显示最新日志文件中的日志条目。

还有一些其他可用的日志驱动程序。列表包括:

  • none:它将完全关闭日志记录

  • syslog:这是 Docker 的syslog日志驱动程序。它将日志消息写入系统syslog

  • journald:将日志消息记录到journaldsystemd-journald是负责事件记录的守护程序,其追加日志文件作为其日志文件

  • splunk:提供使用Event Http Collector 将日志消息写入 Splunk。Splunk 可用作企业级日志分析工具。您可以在www.splunk.com了解更多信息

  • gelf:将日志条目写入 GELF 端点,如 Graylog 或 Logstash。 Graylog 可在www.graylog.org找到,是一个开源日志管理工具,支持对所有日志文件进行搜索、分析和警报。您可以在www.elastic.co/products/logstash找到 Logstash,它是用于处理任何数据(包括日志数据)的管道。

  • fluentd:将日志消息写入fluentd。Fluentd 是一个用于统一日志层的开源数据收集器。Fluentd 的主要特点是通过提供统一的日志层来将数据源与后端系统分离。它体积小,速度快,并且具有数百个插件,使其成为非常灵活的解决方案。您可以在其网站www.fluentd.org上了解更多关于fluentd的信息

  • gcplogs:将日志条目发送到 Google Cloud 日志记录

  • awslogs:此驱动程序将日志消息写入 Amazon CloudWatch 日志。

正如您所看到的,Docker 的可插拔架构在运行容器时提供了几乎无限的灵活性。要切换到其他日志驱动程序,请使用docker run命令的--log-driver选项。例如,要将日志条目存储在syslog中,请执行以下操作:

$ docker run --log-driver=syslog rest-example

请注意,docker logs命令仅适用于json-filejournald驱动程序。要访问写入其他日志引擎的日志,您将需要使用与您选择的驱动程序匹配的工具。使用专门的工具浏览日志条目通常更方便;实际上,这通常是您选择另一个日志驱动程序的原因。例如,在 Logstash 或 Splunk 中搜索和浏览日志比在充满 JSON 条目的文本文件中查找要快得多。

查看日志条目是监视我们的应用程序在主机上的行为的便捷方式。有时,看到运行容器的属性也是很好的,比如映射的网络端口或映射的卷等等。为了显示容器的属性,我们使用docker inspect命令,这非常有用。

检查容器

我们一直在使用的docker ps命令用于列出运行的容器,它给我们提供了很多关于容器的信息,比如它们的 ID、运行时间、映射端口等等。为了显示关于运行容器的更多细节,我们可以使用docker inspect。命令的语法如下:

$ docker inspect [OPTIONS] CONTAINER|IMAGE|TASK [CONTAINER|IMAGE|TASK...]

默认情况下,docker inspect命令将以 JSON 数组格式输出有关容器或镜像的信息。由于有许多属性,这可能不太可读。如果我们知道我们要找的是什么,我们可以提供一个模板来处理输出,使用-f(或--format)选项。模板使用来自 Go 语言的模板格式(顺便说一句,Docker 本身是用 Go 语言编写的)。docker inspect命令最简单和最常用的模板只是一个简短的模板,用于提取你需要的信息,例如:

$ docker inspect -f '{{.State.ExitCode}}' jboss/wildfly

由于inspect命令接受 Go 模板来形成容器或镜像元数据的输出,这个特性为处理和转换结果提供了几乎无限的可能性。Go 模板引擎非常强大,所以,我们可以使用模板引擎来进一步处理结果,而不是通过 grep 来处理输出,这样虽然快速但混乱。

--format的参数只是我们要应用于容器元数据的模板。在这个模板中,我们可以使用条件语句、循环和其他 Go 语言特性。例如,以下内容将找到所有具有非零退出代码的容器的名称:

$ docker inspect -f '{{if ne 0.0 .State.ExitCode }}{{.Name}} {{.State.ExitCode}}{{ end }}' $(docker ps -aq)

请注意,我们提供了$(docker ps -aq),而不是容器 ID 或名称。因此,所有正在运行的容器的 ID 将被传递给docker inspect命令,这可能是一个很方便的快捷方式。花括号{{}}表示 Go 模板指令,它们之外的任何内容都将被直接打印出来。在 Go 模板中,.表示上下文。大多数情况下,当前上下文将是元数据的整个数据结构,但在需要时可以重新绑定,包括使用with操作。例如,这两个inspect命令将打印出完全相同的结果:

$ docker inspect -f '{{.State.ExitCode}}' wildfly

$ docker inspect -f '{{with .State}} {{.ExitCode}} {{end}}' wildfly

如果您在绑定的上下文中,美元符号($)将始终让您进入root上下文。我们可以执行这个命令:

$ docker inspect -f '{{with .State}} {{$.Name}} exited with {{.ExitCode}} exit code \ {{end}}' wildfly

然后将输出:

/wildfly exited with 0 exit code.

模板引擎支持逻辑函数,如andornot;它们将返回布尔结果。还支持比较函数,如eq(相等)、ne(不相等)、lt(小于)、le(小于或等于)、gt(大于)和ge(大于或等于)。比较函数可以比较字符串、浮点数或整数。与条件函数一起使用,如if,所有这些在从inspect命令创建更复杂的输出时都非常有用:

$ docker inspect -f '{{if eq .State.ExitCode 0.0}} \

Normal Exit \

{{else if eq .State.ExitCode 1.0}} \

Not a Normal Exit \

{{else}} \

Still Not a Normal Exit \

{{end}}' wildfly

有时,docker inspect命令的大量输出可能会令人困惑。由于输出以 JSON 格式呈现,可以使用jq工具来获取输出的概述并挑选出有趣的部分。

jq工具可以免费获取,网址为stedolan.github.io/jq/。它是一个轻量灵活的命令行 JSON 处理器,类似于 JSON 数据的sed命令。例如,让我们从元数据中提取容器的 IP 地址:

$ docker inspect <containerID> | jq -r '.[0].NetworkSettings.IPAddress'

正如您所看到的,docker inspect命令提供了有关 Docker 容器的有用信息。结合 Go 模板功能,以及可选的jq工具,它为您提供了一个强大的工具,可以获取有关您的容器的信息,并可以在脚本中进一步使用。但除了元数据之外,还有另一个有价值的信息来源。这就是运行时统计信息,现在我们将重点关注这一点。

统计信息

要查看容器的 CPU、内存、磁盘 I/O 和网络 I/O 统计信息,请使用docker stats命令。该命令的语法如下:

docker stats [OPTIONS] [CONTAINER...]

您可以通过指定由空格分隔的容器 ID 或名称列表来将统计量限制为一个或多个特定容器。默认情况下,如果未指定容器,则该命令将显示所有运行中容器的统计信息,如下面的屏幕截图所示:

docker stats命令接受选项,其中可以包括:

  • --no-stream:这将禁用流式统计信息,并且只拉取第一个结果

  • -a--all):这将显示所有(不仅仅是运行中的)容器的统计信息

统计信息可用于查看我们的容器在运行时的行为是否良好。这些信息可以用来检查是否需要对容器应用一些资源约束,我们将在本章稍后讨论运行时约束。

查看日志、容器元数据和运行时统计信息,可以在监视运行中的容器时给您几乎无限的可能性。除此之外,我们还可以全局查看 docker 主机上发生的情况。当主机上的 docker 引擎接收到命令时,它将发出我们可以观察到的事件。现在让我们来看看这个机制。

容器事件

为了实时观察到 docker 引擎接收的事件,我们使用docker events命令。如果容器已启动、停止、暂停等,事件将被发布。如果您想知道容器运行时发生了什么,这将非常有用。这是一个强大的监控功能。Docker 容器报告了大量的事件,您可以使用docker events命令列出。列表包括:

attach, commit, copy, create, destroy, detach, die, exec_create, exec_detach, exec_start, export, health_status, kill, oom, pause, rename, resize, restart, start, stop, top, unpause, update

docker events命令可以使用-f开关,如果您正在寻找特定内容,它将过滤输出。如果未提供过滤器,则将报告所有事件。目前可能的过滤器列表包括:

  • 容器(container=<名称或 ID>

  • 事件(event=<事件操作>

  • 镜像(image=<标签或 ID>

  • 插件(实验性)(plugin=<名称或 ID>

  • 标签(label=<键>label=<键>=<值>

  • 类型(type=<容器或镜像或卷或网络或守护程序>

  • 卷(volume=<名称或 ID>

  • 网络(network=<名称或 ID>

  • 守护程序(daemon=<名称或 ID>

看看以下示例。在一个控制台窗口中运行了docker events命令,而在另一个控制台中发出了docker run rest-example命令。如您在以下截图中所见,docker events将报告我们的 rest-example 容器的创建、附加、连接和启动事件:

因此,您将获得一个时间戳和事件的名称,以及导致事件的容器的 ID。docker events命令可以接受其他选项,例如--since--until,用于指定要获取事件的时间范围。监视容器事件是一个很好的工具,可以看到 Docker 主机上发生了什么。但这还不是全部。您还可以影响容器在崩溃时的行为,例如。我们使用容器重启策略来实现这一点。

重启策略

通过在docker run命令中使用--restart选项,您可以指定重启策略。这告诉 Docker 在容器关闭时如何反应。然后可以重新启动容器以最小化停机时间,例如在生产服务器上运行时。然而,在我们解释 Docker 重启策略之前,让我们先专注一会儿退出代码。退出代码是关键信息,它告诉我们容器无法运行或退出的原因。有时它与您将作为参数提供给docker run的命令有关。当docker run命令以非零代码结束时,退出代码遵循chroot标准,如您在这里所见:

  • 退出代码125docker run命令本身失败

  • 退出代码126:提供的命令无法调用

  • 退出代码127:提供的命令找不到

  • 其他非零的、应用程序相关的退出代码

您可能还记得,在之前的章节中,我们一直在使用docker ps命令列出运行中的容器。要列出非运行中的容器,我们可以为docker ps命令添加-a开关。当容器完成时,退出代码可以在docker ps -a命令的输出中的状态列中找到。可以通过在启动容器时指定重启策略来自动重新启动崩溃的容器。通过docker run命令的-restart 开关来指定所需的重启策略,就像这个例子中一样:

$ docker run --restart=always rest-example

目前 Docker 有四种重启策略。让我们逐一了解它们,从最简单的开始:no

没有

no策略是默认的重启策略,简单地不会在任何情况下重新启动容器。实际上,您不必指定此策略,因为这是默认行为。除非您有一些可配置的设置来运行 Docker 容器,否则no策略可以用作关闭开关。

始终

如果我们希望无论命令的退出代码是什么,容器都会重新启动,我们可以使用always重启策略。基本上,它就是字面意思;Docker 将在任何情况下重新启动容器。重启策略将始终重新启动容器。即使容器在重新启动之前已停止,也是如此。每当 Docker 服务重新启动时,使用 always 策略的容器也将被重新启动,无论它们是否正在执行。

使用always重启策略,Docker 守护程序将尝试无限次重新启动容器。

在失败时

这是一种特殊的重启策略,可能是最常用的。通过使用on-failure重启策略,您指示 Docker 在容器以非零退出状态退出时重新启动容器,否则不重新启动。这就是我们从退出代码开始解释重启策略的原因。您还可以选择为 Docker 尝试重新启动容器的次数提供一个数字。此重启策略的语法也略有不同,因为使用此策略,您还可以指定 Docker 将尝试自动重新启动容器的最大次数。

考虑这个例子:

$ docker run --restart=on-failure:5 rest-example

在失败的情况下,上述命令将运行具有我们的 REST 服务的容器,并在放弃之前尝试重新启动五次。 on-failures重启策略的主要好处是,当应用程序以成功的退出代码退出时(这意味着应用程序没有错误,只是执行完毕),容器将不会重新启动。可以通过我们已经知道的docker inspect命令获取容器的重新启动尝试次数。例如,要获取具有特定 ID 或名称的容器的重新启动次数:

$ docker inspect -f "{{ .RestartCount }}" <ContainerID>

您还可以发现容器上次启动的时间:

$ docker inspect -f "{{ .State.StartedAt }}" <ContainerID>

您应该知道,Docker 在重新启动容器之间使用延迟,以防止洪水般的保护。这是一个递增的延迟;它从 100 毫秒的值开始,然后 Docker 将加倍上一个延迟。实际上,守护程序将等待 100 毫秒,然后是 200 毫秒,400,800 等,直到达到on-failure限制,或者当您使用docker stop停止容器,或者通过执行docker rm -f命令强制删除容器。

如果容器成功重新启动,则延迟将重置为默认值 100 毫秒。

unless-stopped

always类似,如果我们希望容器无论退出代码如何都重新启动,我们可以使用unless-stoppedunless-stopped重启策略与always相同,唯一的例外是,它将重新启动容器,而不管退出状态如何,但如果容器在停止状态之前已被停止,则不会在守护程序启动时启动它。这意味着使用unless-stopped重启策略,如果容器在重新启动前正在运行,则系统重新启动后容器将被重新启动。当 Docker 容器中的应用程序退出时,该容器也将被停止。如果容器中运行的应用程序崩溃,容器将停止,并且该容器将保持停止状态,直到有人或某物重新启动它。

在将重启策略应用于容器之前,最好先考虑容器将用于做什么样的工作。这也取决于将在容器上运行的软件的类型。例如,数据库可能应该应用alwaysunless-stopped策略。如果您的容器应用了某种重启策略,当您使用docker ps命令列出容器时,它将显示为RestartingUp状态。

更新正在运行的容器的重启策略

有时,在容器已经启动后,有必要即时更新 Docker 运行时参数。一个例子是,如果您想要防止容器在 Docker 主机上消耗过多资源。为了在运行时设置策略,我们可以使用docker update命令。除了其他运行时参数(例如内存或 CPU 约束,我们将在本章后面讨论),docker update命令还提供了更新运行中容器的重启策略的选项。语法非常简单,您只需要提供您希望容器具有的新重启策略以及容器的 ID 或名称:

$ docker update --restart=always <CONTAINER_ID or NAME>

运行docker update命令后,新的重启策略将立即生效。另一方面,如果您在已停止的容器上执行update命令,该策略将在以后启动容器时使用。可能的选项与您启动容器时可以指定的选项完全相同:

  • no(默认值)

  • always

  • 失败时

  • unless-stopped

如果在 Docker 主机上运行多个容器,并且想要一次性为它们指定新的重启策略,只需提供它们所有的 ID 或名称,用空格分隔。

您还可以使用docker events命令查看应用了哪种重启策略,这是您已经在上一节中了解过的。docker events可以用来观察容器报告的运行时事件的历史记录,还会报告docker update事件,提供有关已更改的详细信息。如果容器已应用重启策略,事件将被发布。如果要检查运行中容器的重启策略,请使用docker inspect与容器 ID 或名称以及设置--format参数的路径:

$ docker inspect --format '{{ .HostConfig.RestartPolicy.Name }}' <ContainerID>

根据容器设置重启策略的能力非常适用于那些图像是自包含的,不需要进行更复杂的编排任务的情况。重启策略不是您可以在运行中容器上更改的唯一参数。

资源的运行时约束

在运行时限制 Docker 容器使用资源可能是有用的。Docker 为您提供了许多设置内存、CPU 使用或磁盘访问使用的约束的可能性。让我们从设置内存约束开始。

内存

值得知道,默认情况下,即如果您在没有任何约束的情况下使用默认设置,则运行的容器可以使用所有主机内存。要更改此行为,我们可以使用docker run命令的--memory(或-m简称)开关。它分别采用kmg后缀,表示千字节,兆字节和千兆字节。

具有设置内存约束的docker run命令的语法将如下所示:

$ docker run -it -m 512m ubuntu

上述命令将执行 Ubuntu 镜像,容器可以使用的最大内存为半个千兆字节。

如果您没有设置容器可以分配的内存限制,这可能会导致随机问题,其中单个容器可以轻松使整个主机系统变得不稳定和/或无法使用。因此,始终在容器上使用内存约束是明智的决定。

除了用户内存限制外,还有内存预留和内核内存约束。让我们解释一下内存预留限制是什么。在正常工作条件下,运行的容器可以并且可能会使用所需的内存,直到您使用docker run命令的--memory-m)开关设置的限制。当应用内存预留时,Docker 将检测到低内存情况,并尝试强制容器将其消耗限制到预留限制。如果您没有设置内存预留限制,它将与使用-m开关设置的硬内存限制完全相同。

内存预留不是硬限制功能。不能保证不会超出限制。内存预留功能将尝试确保根据预留设置分配内存。

考虑以下示例:

$ docker run -it -m 1G --memory-reservation 500M ubuntu /bin/bash

上述命令将将硬内存限制设置为1g,然后将内存预留设置为半个千兆字节。设置这些约束后,当容器消耗的内存超过500M但少于1G时,Docker 将尝试将容器内存缩小到少于500M

在下一个示例中,我们将设置内存预留而不设置硬内存限制:

$ docker run -it --memory-reservation 1G ubuntu /bin/bash

在前面的示例中,当容器启动时,它可以使用其进程所需的内存。--memory-reservation开关设置将阻止容器长时间消耗过多的内存,因为每次内存回收都会将容器的内存使用量缩小到预留中指定的大小。

内核内存与用户内存完全不同,主要区别在于内核内存无法交换到磁盘。它包括堆栈页面、slab 页面、套接字内存压力和 TCP 内存压力。您可以使用--kernel-memory开关来设置内核内存限制以约束这些类型的内存。与设置用户内存限制一样,只需提供一个带有后缀的数字,例如kbg,分别表示千字节、兆字节或千兆字节,尽管以千字节设置它可能是一个非常罕见的情况。

例如,每个进程都会占用一些堆栈页面。通过限制内核内存,您可以防止在内核内存使用过高时启动新进程。此外,由于主机无法将内核内存交换到磁盘,容器可能会通过消耗过多的内核内存来阻塞整个主机服务。

设置内核内存限制很简单。我们可以单独设置--kernel-memory,而不限制总内存使用量,就像下面的例子一样:

$ docker run -it --kernel-memory 100M ubuntu  /bin/bash

在上面的例子中,容器中的进程可以根据需要使用内存,但只能消耗100M的内核内存。我们还可以设置硬内存限制,如下面的命令所示:

$ docker run -it -m 1G --kernel-memory 100M ubuntu /bin/bash

在上述命令中,我们同时设置了内存和内核内存,因此容器中的进程可以总共使用1G内存,其中包括100M的内核内存。

与内存相关的另一个约束条件在运行容器时可能会有用,这是 swappines 约束。我们可以使用--memory-swappiness开关来应用约束到docker run命令。当你想要避免与内存交换相关的性能下降时,这可能会有所帮助。--memory-swappiness开关的参数是可以交换的匿名内存页面的百分比,因此它的值范围是从0100。将值设置为零,将根据您的内核版本禁用交换或使用最小交换。相反,值为100会将所有匿名页面设置为可以交换出去的候选项。例如:

$ docker run -it --memory-swappiness=0 ubuntu /bin/bash

在上述命令中,我们完全关闭了ubuntu容器的交换。

除了设置内存使用约束外,您还可以指示 Docker 如何分配处理器能力给它将要运行的容器。

处理器

使用-c(或--cpu-shares作为等效项)来为docker run命令开关指定 CPU 份额的值是可能的。默认情况下,每个新容器都有 1024 份 CPU 份额,并且所有容器获得相同的 CPU 周期。这个百分比可以通过改变容器的 CPU 份额权重相对于所有其他正在运行的容器的权重来改变。但请注意,您不能设置容器可以使用的精确处理器速度。这是一个相对权重,与实际处理器速度无关。事实上,没有办法准确地说一个容器应该有权利只使用主机处理器的 2 GHz。

CPU 份额只是一个数字,与 CPU 速度没有任何关系。

如果我们启动两个容器,两者都将使用 100%的 CPU,处理器时间将在两个容器之间平均分配。原因是两个容器将拥有相同数量的处理器份额。但是如果您将一个容器的处理器份额限制为 512,它将只获得 CPU 时间的一半。这并不意味着它只能使用 CPU 的一半;这个比例只在运行 CPU 密集型进程时适用。如果另一个容器(具有1024份份额)处于空闲状态,我们的容器将被允许使用 100%的处理器时间。实际的 CPU 时间将取决于系统上运行的容器数量。这在一个具体的例子中更容易理解。

考虑三个容器,一个(我们称之为Container1)设置了--cpu-shares1024,另外两个(Container2Container3)设置了--cpu-shares512。当所有三个容器中的进程尝试使用所有的 CPU 功率时,Container1将获得总 CPU 时间的 50%,因为它相对于其他正在运行的容器(Container2Container3的总和)有一半的 CPU 使用量。如果我们添加一个--cpu-share为 1024 的第四个容器(Container4),我们的第一个Container1只会获得 CPU 的 33%,因为它现在相对于总 CPU 功率的三分之一。Container2将获得 16.5%,Container3也是 16.5%,最后一个Container4再次被允许使用 CPU 的 33%。

虽然docker run命令的-c--cpu_shares标志修改了容器相对于所有其他运行容器的 CPU 份额权重,但它不限制容器对主机机器 CPU 的使用。但是还有另一个标志可以限制容器的 CPU 使用:--cpu-quota。其默认值为100000,表示允许使用 100%的 CPU 使用率。我们可以使用--cpu-quota来限制 CPU 使用,例如:

$ docker run -it  --cpu-quota=50000 ubuntu /bin/bash

在前面的命令中,容器的限制将是 CPU 资源的 50%。--cpu-quota通常与docker run--cpu-period标志一起使用。这是 CPU CFS(Completely Fair Scheduler)周期的设置。默认周期值为 100000,即 100 毫秒。看一个例子:

$ docker run -it --cpu-quota=25000 --cpu-period=50000  ubuntu /bin/bash

这意味着容器可以每 50 毫秒获得 50%的 CPU 使用率。

限制 CPU 份额和使用率并不是我们可以在容器上设置的唯一与处理器相关的约束。当我们想要执行此操作时,docker run命令的--cpuset开关非常方便。考虑以下例子:

$ docker run -it --cpuset 4 ubuntu

上述命令将运行ubuntu镜像,并允许容器使用所有四个处理器核心。要启动容器并只允许使用一个处理器核心,可以将--cpuset值更改为1

$ docker run -it --cpuset 1 ubuntu

当然,您可以将--cpuset选项与--cpu_shares混合在一起,以调整容器的 CPU 约束。

更新正在运行的容器的约束

与重启策略一样,当容器已经在运行时也可以更新约束。如果您发现您的容器占用了太多的 Docker 主机系统资源,并希望限制此使用,这可能会有所帮助。同样,我们使用docker update命令来执行此操作。

与重启策略一样,docker update命令的语法与启动容器时相同,您将所需的约束作为 docker update 命令的参数指定,然后提供容器 ID(例如从docker ps命令输出中获取)或其名称。同样,如果您想一次更改多个容器的约束,只需提供它们的 ID 或名称,用空格分隔。让我们看一些在运行时如何更新约束的示例:

$ docker update --cpu-shares 512 abbdef1231677

上述命令将限制 CPU 份额的值为 512。当然,您也可以同时对多个容器应用 CPU 和内存约束:

docker update --cpu-shares 512 -m 500M abbdef1231677 dabdff1231678

上述命令将更新 CPU 份额和内存限制到两个容器,标识为abbdef1231677dabdff1231678

当更新运行时约束时,当然也可以在一个命令中应用所需的重启策略,就像下面的例子一样:

$ docker update --restart=always -m 300M aabef1234716

正如您所看到的,设置约束的能力在运行 Docker 容器时给了您很大的灵活性。但值得注意的是,应用约束并不总是可能的。原因是约束设置功能严重依赖于 Docker 主机的内部情况,特别是其内核。例如,设置内核内存限制或内存 swappiness并不总是可能的,有时您会收到您的内核不支持内核内存限制或内核不支持内存 swappiness 功能的消息。有时这些限制是可配置的,有时不是。例如,如果您收到警告:您的内核不支持 Ubuntu 上的 cgroup 交换限制,您可以在 Grub 配置文件中使用cgroup_enable=memory swapaccount=1设置来调整 Grub 引导加载程序,例如在 Ubuntu 中,这将是/etc/default/grub。重要的是要阅读 Docker 打印出的日志,以确保您的约束已经生效。

在容器启动或在动态更新约束后,始终注意 Docker 输出的警告,可能会导致您的约束不起作用!

我们已经知道如何使用命令行中可用的命令来运行和观察容器。然而,如果您需要在开发流程中启动容器,例如进行集成测试,这并不是很方便。我们在第五章中使用的 Fabric8 Docker Maven 插件,用于构建镜像,如果我们需要运行容器,也会很方便。现在让我们来做吧。

使用 Maven 运行

该插件提供了两个与启动和停止容器相关的 Maven 目标。这将是 docker:startdocker:stop 。使用 docker:start 创建和启动容器,使用 docker:stop 停止和销毁容器。如果需要在集成测试期间运行容器,典型用例将是在 Maven 构建阶段中包含这些目标:docker:start 将绑定到 pre-integration-testdocker:stop 绑定到 post-integration-test 阶段。

插件配置

该插件使用 pom.xml 文件中 <configuration><run> 子元素中的配置。最重要的配置元素列表如下:

cmd 应在容器启动结束时执行的命令。如果未给出,则使用图像的默认命令。
entrypoint 容器的入口点。
log 日志配置,用于控制是否以及如何打印运行容器的日志消息。这也可以配置要使用的日志驱动程序。
memory 内存限制(以字节为单位)

| namingStrategy | 容器名称创建的命名策略:

  • none:使用来自 Docker 的随机分配的名称(默认)

  • alias:使用图像配置中指定的别名。如果已经存在具有此名称的容器,则会抛出错误。

|

| network | <network> 元素可用于配置容器的网络模式。它知道以下子元素:

  • <mode>:网络模式,可以是以下值之一:

  • bridge:使用默认的 Docker 桥接模式(默认)

  • host:共享 Docker 主机网络接口

  • container:连接到指定容器的网络

容器的名称取自 <name> 元素:

  • custom:使用自定义网络,必须在使用 Docker 网络创建之前创建

  • none:不会设置网络

|

| ports | <ports> 配置包含端口映射的列表。每个映射有多个部分,每个部分由冒号分隔。这相当于使用 docker run 命令和 -p 选项时的端口映射。一个示例条目可以看起来像这样:

<ports>   
<port>8080:8080</port>   
</ports>   

|

| restartPolicy | 提供了我们在本章前面讨论过的重启策略。一个示例条目可以看起来像下面这样:

<restartPolicy>   
<name> on-failure</name>   
<retry>5</retry>   
</restartPolicy>   

|

| volumes | 用于绑定到主机目录和其他容器的卷配置。示例配置可以看起来像下面这样:

<volumes>   
<bind>   
<volume>/logs</volume><volume>/opt/host_export:/opt/container_import</volume> </bind>   
</volumes>   

|

我们的 Java REST 服务的完整<configuration>元素可以看起来和以下一样。这是一个非常基本的例子,我们只在这里配置了运行时端口映射:

<configuration> 
<images> 
<image> 
<name>rest-example:${project.version}</name> 
<alias>rest-example</alias> 
<build> 
<from>openjdk:latest</from> 
<assembly> 
<descriptorRef>artifact</descriptorRef> 
</assembly> 
<cmd>java -jar maven/${project.name}-${project.version}.jar</cmd> 
</build> 
<run> 
<ports> 
<port>8080:8080</port> 
</ports> 
</run> 
</image> 
</images> 
</configuration>

配置了我们的容器后,让我们尝试运行它,使用 Maven。

启动和停止容器

要启动容器,请执行以下操作:

$ mvn clean package docker:start

Maven 将从源代码构建我们的 REST 服务,构建镜像,并在后台启动容器。作为输出,我们将得到容器的 ID,如你可以在以下截图中看到的那样:

容器现在在后台运行。要测试它是否在运行,我们可以发出docker ps命令来列出所有正在运行的容器,或者通过在映射的8080端口上执行一些HTTP方法,如GETPOST来调用服务。端口已在<build>配置元素中公开,并在<run>配置元素中公开。这很方便,不是吗?但是,如果我们想要看到容器的输出而不是在后台运行它怎么办?这也很容易;让我们首先通过发出以下命令来停止它:

$ mvn docker:stop

10 秒后(你会记得,这是在停止容器之前的默认超时时间),Maven 将输出一个声明,表示容器已经停止:

[INFO] DOCKER> [rest-example:0.1.0] "rest-example": Stop and removed container 51660084f0d8 after 0 ms

让我们再次运行容器,这次使用 Maven 的docker:run目标,而不是docker:start。执行以下操作:

$ mvn clean package docker:run

这次,Maven Docker 插件将运行容器,我们将在控制台上看到 Spring Boot 横幅,如你可以在以下截图中看到的那样:

我猜你现在可以辨别docker:startdocker:run之间的区别了。正确,docker:run相当于docker run命令的-i选项。docker:run还会自动打开showLogs选项,这样你就可以看到容器内发生了什么。作为替代,你可以提供docker.follow作为系统属性,这样docker:start将永远不会返回,而是阻塞,直到按下CTRL + C,就像当你执行docker:run Maven 目标时一样。

正如你所看到的,Fabric8 Docker Maven 插件给了你与从 shell 或命令行运行和停止容器时一样的控制。但这里是 Maven 构建过程本身的优势:你可以自动化事情。Docker 容器现在可以在构建过程中使用,集成测试和持续交付流程中使用;你说了算。

摘要

在本章中,我们已经学会了如何管理容器的生命周期,使用不同的运行模式(前台和后台)启动它,停止或删除它。我们还知道如何创建约束,使我们的容器按照我们想要的方式运行,通过使用运行时约束来限制 CPU 和 RAM 的使用。当我们的容器运行时,我们现在能够以多种方式检查容器的行为,比如读取日志输出,查看事件或浏览统计数据。如果你正在使用 Maven,作为 Java 开发人员,你可能会配置 Docker Maven 插件,以便自动启动或停止容器。

我们已经对 Docker 有了很多了解,我们可以构建和运行镜像。现在是时候更进一步了。我们将使用 Kubernetes 自动化部署、扩展和管理容器化应用程序。这是真正有趣的时刻。

第七章:Kubernetes 简介

阅读完第六章,使用 Java 应用程序运行容器,现在您对使用 Docker 打包 Java 应用程序有了很多知识。现在是时候更进一步,专注于我们所缺少的内容--容器管理和编排。市场上有一些合适的工具,例如 Nomad、Docker Swarm、Apache Mesos 或 AZK 等。在本章中,我们将重点介绍可能是最受欢迎的工具之一,Kubernetes。Kubernetes(有时简称为 k8s)是由 Google 于 2015 年创建的用于 Docker 容器的开源编排系统。Google 开发的第一个统一容器管理系统是内部称为 Borg 的系统;Kubernetes 是它的后代。本章涵盖的主题列表将是:

  • 为什么以及何时需要容器管理

  • Kubernetes 简介

  • 基本的 Kubernetes 概念

让我们从回答为什么我们需要 Kubernetes 这个问题开始。我们将探讨容器管理和编排背后的原因。

我们为什么需要 Kubernetes?

正如您已经知道的那样,Docker 容器为运行打包成小型独立软件的 Java 服务提供了极大的灵活性。Docker 容器使应用程序的组件可移植--您可以在不需要担心依赖项或底层操作系统的情况下,在不同的环境中移动单个服务。只要操作系统能够运行 Docker 引擎,您的 Java 容器就可以在该系统上运行。

另外,正如你在第一章中所记得的,Docker 简介,Docker 隔离容器的概念远非传统虚拟化。区别在于 Docker 容器利用主机操作系统的资源--它们轻便、快速且易于启动。这一切都很好,但也存在一些风险。你的应用由多个独立的微服务组成。服务的数量可能会随着时间增长。此外,如果你的应用开始承受更大的负载,增加相同服务的容器数量以分担负载会很好。这并不意味着你只需要使用自己的服务器基础设施--你的容器可以部署到云端。今天我们有很多云服务提供商,比如谷歌或亚马逊。在云端运行你的容器,会给你带来很多优势。首先,你不需要管理自己的服务器。其次,在大多数云端,你只需为实际使用付费。如果负载增加,云服务的成本当然会增加,因为你将使用更多的计算能力。但如果没有负载,你将付出零成本。这说起来容易,但监控服务器使用情况,尤其是在应用或应用程序运行的组件数量庞大时,可能会有些棘手。你需要仔细查看云公司的账单,并确保你没有一个容器在云端空转。如果特定服务对你的应用不那么重要,也不需要快速响应,你可以将其迁移到最便宜的机器上。另一方面,如果另一个服务承受更大的负载并且至关重要,你可能会希望将其迁移到更强大的机器上或增加更多实例。最重要的是,通过使用 Kubernetes,这可以自动化。通过拥有管理 Docker 容器的正确工具,这可以实时完成。你的应用可以以非常灵活的方式自适应--最终用户可能甚至不会意识到他们使用的应用程序位于何处。容器管理和监控软件可以通过更好地利用你支付的硬件大大降低硬件成本。Kubernetes 处理在计算集群中的节点上进行调度,并积极管理工作负载,以确保它们的状态与用户声明的意图相匹配。使用标签和 Pods 的概念(我们将在本章后面介绍),Kubernetes 将组成应用程序的容器分组为逻辑单元,以便进行简单的管理和发现。

将应用程序以一组容器的形式运行在受管理的环境中,也改变了对软件开发的视角。你可以在服务的新版本上进行工作,当准备好时,可以实现动态滚动更新。这也意味着专注于应用程序而不是运行在其上的机器,这结果允许开发团队以更加灵活、更小、更模块化的方式运作。它使得软件开发真正地变得敏捷,这正是我们一直想要的。微服务是小型且独立的,构建和部署时间大大缩短。此外,发布的风险也更小,因此你可以更频繁地发布较小的更改,最大程度地减少一次性发布所有内容可能导致的巨大失败的可能性。

在我们开始介绍基本的 Kubernetes 概念之前,让我们总结一下 Kubernetes 给我们带来了什么:

  • 快速、可预测地部署应用程序

  • 动态扩展

  • 无缝发布新功能

  • 防故障

  • 将硬件使用限制在所需的资源上

  • 敏捷的应用程序开发

  • 操作系统、主机和云提供商之间的可移植性

这是一系列无法轻易超越的功能。要理解如何实现这一点,我们需要了解核心的 Kubernetes 概念。到目前为止,我们只知道来自 Docker 的一个概念--容器--它是一个可移植的、独立的软件单元。容器可以包含任何我们想要的东西,无论是数据库还是 Java REST 微服务。让我们来了解剩下的部分。

基本的 Kubernetes 概念

集群是一组节点;它们可以是安装了 Kubernetes 平台的物理服务器或虚拟机。基本的 Kubernetes 架构如下图所示:

正如你所看到的,Kubernetes 集群由一个主节点和若干个工作节点以及一些组件组成。虽然乍一看可能会让人感到害怕和复杂,但如果我们逐个描述这些概念,从 Pod 开始,就会更容易理解。

Pods

Pod 由一个或多个 Docker 容器组成。这是 Kubernetes 平台的基本单元,也是 Kubernetes 处理的基本执行单元。Pod 的图示如下:

在同一 Pod 中运行的容器共享相同的网络命名空间、磁盘和安全上下文。事实上,建议在同一 Pod 中运行的容器之间使用 localhost 进行通信。每个容器还可以与集群中的任何其他 Pod 或服务进行通信。

正如您从第二章中记得的,网络和持久存储,您可以在 Docker 容器中挂载卷。Kubernetes 还支持卷的概念。附加到 Pod 的卷可以在此 Pod 上运行的一个或多个容器内挂载。Kubernetes 支持许多不同类型的卷,作为原生支持挂载 GitHub 存储库、网络磁盘、本地硬盘等。

如果您的应用程序需要分布式存储并且需要处理大量数据,您不仅仅局限于本地硬盘。Kubernetes 还支持卷提供程序。目前,可用的持久卷提供程序列表包括:

  • GCE:谷歌云平台

  • AWS:亚马逊网络服务

  • GlusterFS:可扩展的网络文件系统。使用免费的开源软件 GlusterFS,您可以利用现有的存储硬件创建大型分布式存储解决方案

  • OpenStack Cinder:用于 OpenStack Nova 计算平台用户的块存储服务

  • CephRBD:可靠的自主分布式对象存储(RADOS),为您的应用程序提供单一统一存储集群中的对象、块和文件系统存储

  • QuoByte

  • Kube-Aliyun

网络命名空间和卷不是 Pod 的唯一属性。正如您在 Pod 的图表中所看到的,Pod 可以附加标签和注释。标签在 Kubernetes 中非常重要。它们是附加到对象(在本例中是 Pod)的键/值对。标签的理念是它们可以用于标识对象--标签对用户来说是有意义和相关的。标签的一个示例可能是:

app=my-rest-service 

layer=backend

稍后,我们将使用标签选择器来选择具有指定标签的对象(如 Pods)。通过标签选择器,在 Kubernetes 中是核心分组原语,客户端或用户可以识别对象或一组对象。选择器类似于标签,也是用于使用匹配标签识别资源的键值表达式。例如,选择器表达式app = my-rest-service将选择所有具有标签app = my-rest-service的 Pods。另一方面,注释是一种可以附加到 Pods 的元数据。它们不是用于识别属性;它们是可以被工具或库读取的属性。关于注释应包含什么的规则没有规定--这取决于您。注释可以包含诸如构建或发布版本、时间戳、Git 分支名称、Gitpull请求编号或任何其他内容,如手机号码。

标签用于识别有关 Kubernetes 对象(如 Pods)的信息。注释只是附加到对象的元数据。

我们之前说过,Pod 是 Kubernetes 中的执行的基本单位。它可以包含多个容器。具有多个 Docker 容器的 Pod 的现实生活示例可能是我们的 Java REST 微服务 Pod。例如,在之前的章节中,我们的微服务一直将其数据库数据存储在内存中。在现实生活中,数据可能应该存储在真正的数据库中。我们的 Pod 可能会有一个包含 Java JRE 和 Spring Boot 应用程序本身的容器,以及第二个包含 PostgreSQL 数据库的容器,微服务使用它来存储数据。这两个容器组成一个 Pod--一个单一的、解耦的执行单元,包含我们的 REST 服务运行所需的一切。

Pod 的定义是一个名为Pod清单的 JSON 或 YAML 文件。看一个包含一个容器的简单示例:

apiVersion: v1

kind: Pod

metadata:

 name: rest_service

spec:

 containers:

 name: rest_service

 image: rest_service

 ports:

 - containerPort: 8080

在 JSON 文件中相同的pod清单看起来与以下内容相同:

{

 "apiVersion": "v1", 

 "kind": "Pod",

 "metadata":{

 "name": ”rest_service”,

 "labels": {

 "name": "rest_service"

 }

 },

 "spec": {

 "containers": [{

 "name": "rest_service",

 "image": "rest_service",

 "ports": [{"containerPort": 8080}],

 }]

 }

}

容器的image是 Docker 镜像名称。containerPort公开来自 REST 服务容器的端口,因此我们可以连接到 Pod 的 IP 上的服务。默认情况下,正如您从第一章中记得的那样,Docker 简介中定义的image中的入口点将运行。

非常重要的是要意识到 Pod 的生命周期是脆弱的。因为 Pod 被视为无状态、独立的单元,如果其中一个不健康或者只是被新版本替换,Kubernetes Master 不会对其手下留情--它只会将其杀死并处理掉。

事实上,Pod 有一个严格定义的生命周期。以下列表描述了 Pod 生命周期的各个阶段:

  • 挂起:这个阶段意味着 Pod 已经被 Kubernetes 系统接受,但一个或多个 Docker 容器镜像尚未被创建。Pod 可能会在这个阶段停留一段时间--例如,如果需要从互联网下载镜像。

  • 运行中:Pod 已经放置到一个节点上,并且 Pod 的所有 Docker 容器都已经被创建。

  • 成功:Pod 中的所有 Docker 容器都已成功终止。

  • 失败:Pod 中的所有 Docker 容器都已终止,但至少一个容器以失败状态终止或被系统终止。

  • 未知:这通常表示与 Pod 主机的通信出现问题;由于某种原因,无法检索 Pod 的状态。

当一个 Pod 被关闭时,不仅仅是因为它失败了。更常见的情况是,如果我们的应用程序需要处理增加的负载,我们需要运行更多的 Pod。另一方面,如果负载减少或根本没有负载,那么运行大量 Pod 就没有意义--我们可以处理掉它们。当然,我们可以手动启动和停止 Pod,但自动化总是更好。这就引出了 ReplicaSets 的概念。

ReplicaSets

ReplicaSets 是使用复制来扩展应用程序的概念。Kubernetes 复制有什么用处?通常情况下,您会希望复制您的容器(实际上就是您的应用程序)出于几个原因,包括:

  • 扩展:当负载增加并对现有实例的数量造成过重负荷时,Kubernetes 使您能够轻松地扩展应用程序,根据需要创建额外的实例。

  • 负载均衡:我们可以轻松地将流量分发到不同的实例,以防止单个实例或节点过载。负载均衡是因为 Kubernetes 的架构而自带的,非常方便。

  • 可靠性和容错性:通过拥有应用程序的多个版本,可以防止一个或多个失败时出现问题。如果系统替换任何失败的容器,这一点尤为重要。

复制适用于许多用例,包括基于微服务的应用程序,其中多个独立的小型服务提供非常具体的功能,或者基于云原生应用程序,该应用程序基于任何组件随时可能失败的理论。 复制是实现它们的完美解决方案,因为多个实例自然适合于架构。

ReplicaSet 确保在任何给定时间运行指定数量的 Pod 克隆,称为副本。 如果有太多,它们将被关闭。 如果需要更多,例如由于错误或崩溃而死亡了一些,或者可能有更高的负载,将会启动一些更多的 Pod。 ReplicaSets 由部署使用。 让我们看看部署是什么。

部署

部署负责创建和更新应用程序的实例。 一旦部署已创建,Kubernetes Master 将应用程序实例调度到集群中的各个节点。 部署是一个更高级别的抽象层; 在进行 Pod 编排、创建、删除和更新时,它管理 ReplicaSets。 部署为 Pod 和 ReplicaSets 提供声明性更新。 部署允许轻松更新 Replica Set,以及能够回滚到先前的部署。

您只需指定所需的副本数量和每个 Pod 中要运行的容器,部署控制器将启动它们。 YAML 文件中的示例部署清单定义看起来与以下内容相同:

apiVersion: 1.0

kind: Deployment

metadata:

 name: rest_service-deployment

spec:

 replicas: 3

 template:

 metadata:

 labels:

 app: rest_service

 spec:

 containers:

 - name: rest_service

 image: rest_service

 ports:

 - containerPort: 8080

在前面的示例中,部署控制器将创建一个包含三个运行我们的 Java REST 服务的 Pod 的 ReplicaSet。

部署是一种控制结构,负责启动或关闭 Pod。 部署通过创建或关闭副本来管理 Pod 或一组 Pod 的状态。 部署还管理对 Pod 的更新。 部署是一个更高的抽象层,它创建 ReplicaSets 资源。 ReplicaSets 监视 Pod,并确保始终运行正确数量的副本。 当您想要更新 Pod 时,您需要修改部署清单。 此修改将创建一个新的 ReplicaSet,该 ReplicaSet 将扩展,而先前的 ReplicaSet 将缩减,从而实现应用程序的无停机部署。

部署的主要目的是进行滚动更新和回滚。滚动更新是以串行、逐个更新应用程序到新版本的过程。通过逐个更新实例,您可以保持应用程序的运行。如果您一次性更新所有实例,您的应用程序很可能会出现停机时间。此外,执行滚动更新允许您在过程中捕获错误,以便在影响所有用户之前进行回滚。

部署还允许我们轻松回滚。要执行回滚,我们只需设置要回滚到的修订版本。Kubernetes 将扩展相应的副本集并缩减当前的副本集,这将导致服务回滚到指定的修订版本。实际上,在《第八章》使用 Java 与 Kubernetes中,我们将大量使用部署来向集群推出服务的更新。

复制是 Kubernetes 功能的重要部分。正如您所看到的,Pod 的生命周期是脆弱且短暂的。因为 Pod 及其克隆品一直在出现和消失,我们需要一些永久和有形的东西,一些将永远存在,这样我们的应用程序用户(或其他 Pod)可以发现并调用。这就引出了 Kubernetes 服务的概念。让我们现在专注于它们。

服务

Kubernetes 服务将一个或多个 Pod 组合成一个内部或外部进程,需要长时间运行并且可以外部访问,例如我们的 Java REST API 端点或数据库主机。这就是我们为 Pods 分配标签非常重要的地方;服务通过寻找特定标签来查找要分组的 Pods。我们使用标签选择器来选择具有特定标签的 Pods,并将服务或副本集应用于它们。其他应用程序可以通过 Kubernetes 服务发现找到我们的服务。

服务是 Kubernetes 提供网络连接到一个或多个 Pod 的抽象。正如你从关于 Docker 网络的章节中记得的那样,默认情况下,Docker 使用主机私有网络,容器只能在它们位于同一主机上时才能相互通信。在 Kubernetes 中,集群 Pod 可以与其他 Pod 通信,无论它们降落在哪个主机上。这是可能的,因为有了服务。每个服务都有自己的 IP 地址和端口,其在服务的生命周期内保持不变。服务具有集成的负载均衡器,将网络流量分发到所有 Pod。虽然 Pod 的生命周期可能很脆弱,因为它们根据应用程序的需要被启动或关闭,但服务是一个更为持续的概念。每个 Pod 都有自己的 IP 地址,但当它死亡并且另一个被带到生活时,IP 地址可能会不同。这可能会成为一个问题--如果一组 Pod 在 Kubernetes 集群内为其他 Pod 提供功能,一个可能会丢失另一个的 IP 地址。通过分配寿命的 IP 地址,服务解决了这个问题。服务抽象实现了解耦。假设我们的 Java REST 服务运行在 Spring Boot 应用程序之上。我们需要一种方式将来自互联网的 HTTP 请求,比如GETPOST,路由到我们的 Docker 容器。我们将通过设置一个使用负载均衡器将来自公共 IP 地址的请求路由到其中一个容器的 Kubernetes 服务来实现。我们将把包含 REST 服务的容器分组到一个 Pod 中,并命名为,比如,我们的小 REST 服务。然后我们将定义一个 Kubernetes 服务,它将为我们的小 REST 服务 Pod 中的任何容器提供端口8080。Kubernetes 将使用负载均衡器在指定的容器之间分配流量。让我们总结一下 Kubernetes 服务的特点:

  • 服务是持久和永久的

  • 它们提供发现

  • 它们提供负载均衡

  • 它们暴露了一个稳定的网络 IP 地址

  • 它们通过标签的使用来查找要分组的 Pod

我们已经说过有一个内置的服务发现机制。Kubernetes 支持两种主要的查找服务的模式:环境变量和 DNS。服务发现是找出如何连接到服务的过程。Kubernetes 包含一个专门用于此目的的内置 DNS 服务器:kube-dns。

kube-dns

Kubernetes 提供了一个 DNS 集群附加组件,每次集群启动时都会自动启动。DNS 服务本身作为一个集群服务运行--它的 SkyDNS--一个建立在etcd之上的服务的公告和发现的分布式服务(您将在本章后面了解到 etcd 是什么)。它利用 DNS 查询来发现可用的服务。它支持前向查找(A 记录)、服务查找(SRV 记录)和反向 IP 地址查找(PTR 记录)。实际上,服务是 Kubernetes 分配 DNS 名称的唯一类型对象;Kubernetes 生成一个解析为服务 IP 地址的内部 DNS 条目。服务被分配一个 DNS A 记录,格式为service-name.namespace-name.svc.cluster.local。这将解析为服务的集群 IP。例如,对于一个名为my-rest-service的服务,DNS 附加组件将确保该服务通过my-rest-service.default.svc.cluster.local主机名对集群中的其他 Pod(和其他服务)可用。基于 DNS 的服务发现提供了一种灵活和通用的方式来连接整个集群中的服务。

请注意,当使用hostNetwork=true选项时,Kubernetes 将使用主机的 DNS 服务器,而不使用集群的 DNS 服务器。

在我们的 Kubernetes 之旅中,还有一个概念会不时出现--命名空间。让我们找出它的用途。

命名空间

命名空间在 Kubernetes 内部作为一个分组机制。Pods、卷、ReplicaSets 和服务可以在命名空间内轻松合作,但命名空间提供了与集群其他部分的隔离。这种隔离的可能用例是什么?好吧,命名空间让您在同一组机器的集群中管理不同的环境。例如,您可以在同一组机器的集群中拥有不同的测试和暂存环境。

这可能会节省一些资源在您的基础设施中,但它可能是危险的;没有命名空间,将在同一集群上运行预发布版本的软件的新版本可能会有风险。有了可用的命名空间,您可以在同一集群中对不同的环境进行操作,而不必担心影响其他环境。

因为 Kubernetes 使用default命名空间,所以使用命名空间是可选的,但建议使用。

我们已经解释了所有 Kubernetes 的抽象概念--我们知道有 Pods、ReplicaSets、部署和服务。现在是时候转向 Kubernetes 架构的物理执行层了。所有这些小而脆弱的 Pod 都需要存在的地方。它们存在于我们现在要了解的节点中。

节点

节点是 Kubernetes 架构中的工作马。它可以是虚拟机器或物理机器,这取决于您的基础设施。工作节点按照主节点的指示运行任务,我们很快会解释主节点是什么。节点(在早期的 Kubernetes 生命周期中,它们被称为 Minions)可以运行一个或多个 Pod。它们在容器化环境中提供特定于应用程序的虚拟主机。

当工作节点死机时,运行在该节点上的 Pod 也会死机。

以下图表显示了节点的内容:

正如您在前面的图表中所看到的,Kubernetes 中的节点内部运行着一些非常重要的进程。让我们逐一解释它们的目的。

Kubelet

Kubelet 可能是 Kubernetes 中最重要的控制器。它是一个进程,响应来自主节点的命令(我们将在一秒钟内解释主节点是什么)。每个节点都有这个进程在监听。主节点调用它来管理 Pod 及其容器。Kubelet 运行 Pod(正如您已经知道的,它们是共享 IP 和卷的容器集合)。Kubelet(kubernetes.io/v1.0/docs/admin/kubelet/)负责在单个机器上运行的内容,它有一个任务:确保所有容器都在运行。换句话说,Kubelet 是代理的名称,节点是代理运行的机器的名称。值得知道的是,每个 Kubelet 还有一个内部的HTTP服务器,它监听 HTTP 请求并响应简单的 API 调用以提交新的清单。

代理

代理是一个创建虚拟 IP 地址的网络代理,客户端可以访问该地址。网络调用将被透明地代理到 Kubernetes 服务中的 Pod。正如您已经知道的那样,服务提供了一种将 Pod 分组成单一业务流程的方式,可以在共同的访问策略下访问。通过在节点上运行代理,我们可以调用服务 IP 地址。从技术上讲,节点的代理是一个kube-proxy (kubernetes.io/docs/admin/kube-proxy/) 进程,它编程iptables规则来捕获对服务 IP 地址的访问。Kubernetes 网络代理在每个节点上运行。没有它,我们将无法访问服务。

kube-proxy只知道 UDP 和 TCP,不理解 HTTP,提供负载平衡,只用于访问服务。

Docker

最后,每个节点都需要运行一些东西。这将是一个 Docker 容器运行时,负责拉取镜像并运行容器。

所有这些节点,就像现实世界中的任何其他工作人员组一样,都需要一个管理者。在 Kubernetes 中,节点管理器的角色由一个特殊的节点执行:主节点。

主节点

主节点不运行任何容器--它只处理和管理集群。主节点是提供集群统一视图的中央控制点。有一个单独的主节点控制多个工作节点,实际上运行我们的容器。主节点自动处理跨集群工作节点的 Pod 调度-考虑到每个节点上的可用资源。主节点的结构如下图所示:

让我们逐个解析主节点,从etcd开始。

etcd

Kubernetes 将其所有集群状态存储在etcd,这是一个具有强一致性模型的分布式数据存储。etcd是一个分布式、可靠的关键值存储,用于分布式系统的最关键数据,重点是:

  • 简单:定义明确的面向用户的 API

  • 安全:自动 TLS,可选客户端证书认证

  • 快速:经过基准测试,每秒 10,000 次写入

  • 可靠:使用 Raft 正确分布

这个状态包括集群中存在哪些节点,应该运行哪些 Pod,它们运行在哪些节点上,以及更多其他信息。整个集群状态存储在一个etcd实例中。这提供了一种可靠地存储配置数据的方式。另一个在主节点上运行的关键组件是 API 服务器。

API 服务器

主节点上驻留的主要组件之一是 API 服务器。它非常重要,以至于有时候,您可能会发现主节点通常被称为 API 服务器。从技术上讲,它是一个名为kube-apiserver的进程,它接受并响应使用 JSON 的HTTP REST请求。它的主要目的是验证和配置 API 对象的数据,这些对象包括 Pod、服务、ReplicaSets 等。API 服务器通过提供集群的共享状态的前端,使所有其他组件进行交互。API 服务器是中央管理实体,是唯一连接到 etcd 的 Kubernetes 组件。所有其他组件必须通过 API 服务器来处理集群状态。我们将在第九章中详细介绍 Kubernetes API,使用 Kubernetes API

主节点不运行任何容器--它只处理和管理整个集群。实际运行容器的节点是工作节点。

调度器

正如我们之前所说,如果您创建一个部署,主节点将安排将应用实例分布到集群中的各个节点上。一旦应用实例启动并运行,部署控制器将持续监视这些实例。这是一种自我修复机制--如果一个节点宕机或被删除,部署控制器将替换它。

现在我们知道了构成 Kubernetes 架构的特定组件是什么,让我们看看有哪些工具可供我们使用。

可用工具

在本书的其余部分,我们将使用一些工具。让我们从最重要的工具kubectl开始。

kubectl

kubectl是针对 Kubernetes 集群运行命令的命令行界面。事实上,这是在使用 Kubernetes 时最常用的命令。在第八章,使用 Java 与 Kubernetes中,我们将介绍命令的语法和可能的用法。使用kubectl,您将与您的集群进行交互。当然,通过主节点和 API 服务器公开的 API,我们可以使用我们选择的HTTP客户端来执行,但使用kubectl更快速和更方便。kubectl提供了许多功能,例如列出资源、显示有关资源的详细信息、打印日志、管理集群以及在 Pod 中执行容器上的命令。

仪表板

Kubernetes 仪表板是一个漂亮、干净的基于 Web 的 UI,用于 Kubernetes 集群。使用仪表板,您可以管理和排除集群本身以及其中运行的应用程序。你可以说它是 Kubernetes 的用户界面。对于那些喜欢使用图形界面的人来说,仪表板可以是一个方便的工具,用于部署容器化应用程序并概览集群中运行的应用程序,以及创建或修改诸如部署、Pod 和服务等个别资源。例如,您可以扩展部署,启动滚动更新,重新启动 Pod,或使用部署向导部署新应用程序。我们还将在第八章,使用 Java 与 Kubernetes中使用仪表板。

Minikube

运行集群似乎是一个需要大量设置的复杂过程。这并不一定是事实。实际上,在本地机器上轻松运行 Kubernetes 集群以进行学习、测试和开发是相当容易的。在 GitHub 上提供的minikube工具github.com/kubernetes/minikube允许您在自己的机器上设置本地集群。它适用于所有主要平台,包括 Linux、macOS 和 Windows。启动的集群当然将是单节点集群,但这已经足够开始进行实际的 Kubernetes 示例。实际上,在第八章,使用 Java 与 Kubernetes中,在我们开始将我们的REST服务部署到集群之前,我们将在本地运行 Kubernetes。

除了前面提到的之外,您可能会在互联网上找到许多其他与 Kubernetes 非常配合的工具和实用程序。

摘要

本章介绍了许多新概念。让我们简要总结一下我们对 Kubernetes 架构的了解。

Kubernetes(k8s)是一个用于自动化容器操作的开源平台,如部署、调度和在节点集群中扩展。使用 Kubernetes,您可以:

  • 自动化部署和复制容器

  • 在飞行中扩展和缩小容器

  • 将容器组织成组,并在它们之间提供负载平衡

  • 轻松推出应用程序容器的新版本

  • 为您的应用程序提供容错机制——如果一个容器死了,它会被替换

  • Kubernetes 包括:

  • 集群:一组节点。

  • 节点:作为工作者的物理或虚拟机。每个节点运行 kubelet、代理和 Docker 引擎进程。

  • 主节点:提供对集群的统一视图。它提供了 Kubernetes API 服务器。API 服务器提供了一个REST端点,可用于与集群交互。主节点还包括用于创建和复制 Pods 的控制器。

  • Pods:被调度到节点。每个 Pod 运行一个单独的容器或一组容器和卷。同一 Pod 中的容器共享相同的网络命名空间和卷,并可以使用本地主机相互通信。它们的生命是脆弱的;它们会不断诞生和死亡。

  • 标签:Pods 具有附加的键/值对标签。标签用于精确选择 Pods。

  • 服务:定义一组 Pods 和访问它们的策略的抽象。服务通过使用标签选择器来找到它们的 Pod 组。因为单个 Pod 的 IP 可能会改变,所以服务为其客户端提供了一个永久的 IP 地址。

这可能有点令人不知所措的理论。别担心,在第八章,使用 Java 与 Kubernetes中,我们将运行本地 Kubernetes 集群。我们的计划将包括使用minikube创建本地 Kubernetes 集群。然后,我们将使用我们的 Java REST 微服务部署和管理 Docker 容器。通过一些实际的、动手操作,Kubernetes 架构将会更加清晰。运行本地 Kubernetes 并不是我们要做的唯一的事情。稍后,在第十章,在云中部署 Java 到 Kubernetes中,我们将把我们的应用程序放在真正的云端——那是 Kubernetes 真正发光的地方。

第八章:使用 Java 与 Kubernetes

在第七章中,Kubernetes 简介,我们了解了 Kubernetes 的架构和概念。我们知道节点、Pod 和服务。在本章中,我们将进行一些实际的实践,并将我们的 Java REST 服务部署到本地 Kubernetes 集群。为了学习目的,我们将使用 Minikube 工具在本地机器上创建一个集群。在第一次学习时,最好在本地机器上学习 Kubernetes,而不是直接去云端。因为 Minikube 在本地运行,而不是通过云提供商,某些特定于提供商的功能,如负载均衡器和持久卷,将无法直接使用。但是,您可以使用NodePortHostPath、持久卷和一些插件,如 DNS 或仪表板,在将应用程序推送到真正的、生产级别的集群之前,在本地测试您的应用程序。在第十章中,在云中部署 Java 到 Kubernetes,我们将在Amazon Web ServicesAWS)和 Google 容器引擎中运行 Kubernetes。

为了跟上,我们需要准备好以下工具:

  • Docker:构建我们想要部署的 Docker 镜像

  • minikube:本地 Kubernetes 环境

  • kubectl:Kubernetes 命令行界面

本章将涵盖以下主题:

  • 在 macOS、Windows 和 Linux 上安装 Minikube

  • 使用 Minikube 启动本地 Kubernetes 集群

  • 在本地集群上部署 Java 应用程序

  • 与容器交互:扩展、自动扩展和查看集群事件

  • 使用 Kubernetes 仪表板

我假设你到目前为止已经安装并运行了 Docker,所以让我们专注于minikube实用程序。我们已经在第七章中提到了minikubeKubernetes 简介;现在,我们将详细介绍一些内容,从安装过程开始。

安装 Minikube

Minikube 工具源代码和所有文档都可以在 GitHub 上找到github.com/kubernetes/minikube

在 Mac 上安装

以下命令序列将下载minikube二进制文件,设置可执行标志并将其复制到/usr/local/bin文件夹,这将使其在 macOS shell 中可用:

$ curl -Lo minikube https://storage.googleapis.com/minikube/releases/v0.12.2/minikube-darwin-amd64

$ chmod +x minikube

$ sudo mv minikube /usr/local/bin/

或者,如果您使用 Homebrew 软件包管理器(可以在brew.sh免费获得),这是非常方便和推荐的,您可以通过输入以下命令来安装minikube

$ brew cask install minikube

在 Windows 上安装

Windows 上的 Minikube 也只是一个可执行文件。您可以在 Minikube 的网站github.com/kubernetes/minikube上找到最新版本。您只需要下载最新的可执行文件,将其重命名为minikube.exe,并将其放在系统路径中,以便从命令行中使用。

在 Linux 上安装

在 Linux 上的安装过程与 macOS 相同。唯一的区别是可执行文件的名称。以下命令将下载最新的 Minikube 版本,设置可执行位,并将其移动到/usr/local/bin目录中:

$ curl -Lo minikube https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64 && chmod +x minikube && sudo mv minikube /usr/local/bin/

就是这样,一个 Minikube 和 Docker 就足以启动本地集群。是时候让它活起来了:

启动本地 Kubernetes 集群

我们正在使用minikube提供的本地 Kubernetes 集群。使用以下命令启动您的集群:

$ minikube start

Minikube 在自己的虚拟机上运行。根据您的主机操作系统,您可以在几个虚拟化驱动程序之间进行选择。目前支持的有virtualboxvmwarefusionxhyvehypervkvm(基于内核的虚拟机)。默认的 VM 驱动程序是 virtual box。您可以覆盖此选项。这是使用xhyve的 macOS 启动命令行的示例:

$ minikube start --vm-driver=xhyve

首次启动 Minikube 时,您会看到它正在下载 Minikube ISO,因此该过程将需要更长一点时间。不过,这是一次性操作。Minikube 配置将保存在您的home目录中的.minikube文件夹中,例如在 Linux 或 macOS 上为~/.minikube。在第一次运行时,Minikube 还将配置kubectl命令行工具(我们将在短时间内回到它)以使用本地的minikube集群。此设置称为kubectl上下文。它确定kubectl正在与哪个集群交互。所有可用的上下文都存在于~/.kube/config文件中。

由于集群现在正在运行,并且我们默认启用了dashboard插件,您可以使用以下命令查看(仍然为空的)Kubernetes 仪表板:

$ minikube dashboard

它将用集群仪表板的 URL 打开您的默认浏览器:

如您所见,仪表板现在是空的。如果您浏览到命名空间菜单,您会注意到 Minikube 创建了一些命名空间,其中一个可用于我们的目的,简单地命名为默认。Minikube 安装的部分,例如 DNS 或仪表板,也在集群本身上运行,具有单独的命名空间,如 kube-public 和 kube-system。

随意浏览菜单和部分;目前还没有造成任何伤害,这是一个本地开发集群,什么都没有运行。我们将在本章的最后一节回到仪表板,看看我们如何可以使用它来从漂亮的 UI 部署我们的服务,如果你更喜欢这样做,而不是使用命令行的 shell。

当然,让集群空转是相当无用的,所以我们需要一个工具来管理它。虽然我们几乎可以使用仪表板来完成所有事情,但使用命令行工具会更方便。kubectl控制 Kubernetes 集群。我们将大量使用kubectl命令行工具来部署、调度和扩展我们的应用程序和微服务。该工具作为 Mac、Linux 和 Windows 的独立二进制文件提供。在下一节中,您将找到不同平台的安装说明。

安装 kubectl

kubectl适用于所有主要平台。让我们从 macOS 安装开始。

在 Mac 上安装

以下命令序列将下载kubectl二进制文件,设置可执行标志并将其复制到/usr/local/bin文件夹中,这将使其在 macOS shell 中可用:

$ curl -O https://storage.googleapis.com/kubernetes-release/release/v1.5.2

/bin/darwin/amd64/kubectl

$ chmod +x kubectl

$ sudo cp kubectl /usr/local/bin

Homebrew 提供了安装kubectl并保持其最新的最便捷方式。要安装,请使用此命令:

$ brew install kubectl

要更新,请使用以下命令:

$ brew upgrade kubectl

在 Windows 上安装

您可以在 GitHub 上找到 Windows kubectl的发布列表github.com/eirslett/kubectl-windows/releases 。与 Minikube 类似,kubectl 只是一个单独的.exe文件。在撰写本书时,它是github.com/eirslett/kubectl-windows/releases/download/v1.6.3/kubectl.exe 。您需要下载exe文件并将其放在系统路径上,以便在命令行中使用。

在 Linux 上安装

安装过程与 macOS 非常相似。以下命令将获取kubectl二进制文件,给予可执行标志,然后将其移动到/usr/local/bin中,以便在 shell 中使用:

$ curl -O https://storage.googleapis.com/kubernetes-release/release/v1.5.2

/bin/linux/amd64/kubectl

$ chmod +x kubectl

$ sudo cp kubectl /usr/local/bin/kubectl

要验证您的本地集群是否已启动并且kubectl已正确配置,请执行以下命令:

$ kubectl cluster-info 

在输出中,您将获得有关集群的基本信息,包括其 IP 地址和运行的 Minikube 插件(我们将在本章后面再回到插件):

要列出我们集群中正在运行的节点,执行get nodes命令:

$ kubectl get nodes

当然,这只是一个单节点集群,所以在上一个命令的输出中没有什么意外:

我们的集群已经启动运行了;现在是时候在上面部署我们的服务了。

在 Kubernetes 集群上部署

我们通过定义一个服务来开始在 Kubernetes 集群上部署我们的软件。正如您从第七章 Kubernetes 简介中记得的那样,服务将一组 Pods 抽象为单个 IP 和端口,允许简单的 TCP/UDP 负载,并允许 Pods 列表动态更改。让我们从创建服务开始。

创建服务

默认情况下,每个 Pod 只能在 Kubernetes 集群内部通过其内部 IP 地址访问。为了使容器可以从 Kubernetes 虚拟网络外部访问,我们需要将 Pod 公开为 Kubernetes 服务。要创建一个服务,我们将使用简单的.yaml文件,其中包含服务清单。YAML 是一种人类可读的数据序列化语言,通常用于配置文件。我们的 Java rest-example的示例服务清单可能看起来与以下内容相同:

apiVersion: v1

kind: Service

metadata:

 name: rest-example

 labels:

 app: rest-example

 tier: backend

spec:

 type: NodePort

 ports:

 - port: 8080

 selector:

 app: rest-example

 tier: backend

请注意,服务的清单不涉及 Docker 镜像。这是因为,正如您从第七章 Kubernetes 简介中记得的那样,Kubernetes 中的服务只是一个提供网络连接给一个或多个 Pod 的抽象。每个服务都有自己的 IP 地址和端口,其在服务的生命周期内保持不变。每个 Pod 都需要具有特定的标签,以便服务发现,服务使用和标签selectors来分组查找 Pods。在我们之前的示例中,selector将挑选出所有具有标签app值为rest-example和标签名为tier值为backend的 Pods:

selector:

 app: rest-example

 tier: backend

正如您在第七章中所记得的,Kubernetes 简介,Kubernetes 集群中的每个节点都运行一个 kube-proxy 进程。kube-proxy 在 Kubernetes 服务中扮演着至关重要的角色。它的目的是为它们公开虚拟 IP。自 Kubernetes 1.2 以来,iptables 代理是默认设置。您可以使用两种选项来设置代理:用户空间和 iptables。这些设置指的是实际处理连接转发的内容。在两种情况下,都会安装本地 iptables 规则来拦截具有与服务关联的目标 IP 地址的出站 TCP 连接。这两种模式之间有一个重要的区别:

  • 代理模式:用户空间:在用户空间模式下,iptables 规则转发到一个本地端口,kube-proxy 正在监听连接。运行在用户空间的 kube-proxy 终止连接,与服务的后端建立新连接,然后将请求转发到后端,并将响应返回给本地进程。用户空间模式的优势在于,因为连接是从应用程序创建的,如果连接被拒绝,应用程序可以重试到不同的后端。

  • 代理模式:iptables:在这种模式下,iptables 规则被安装直接将目的地为服务的数据包转发到服务的后端。这比将数据包从内核移动到 kube-proxy 然后再返回内核更有效,因此会产生更高的吞吐量和更好的尾延迟。然而,与用户空间模式不同,使用 iptables 模式会使得如果最初选择的 Pod 不响应,则无法自动重试另一个 Pod,因此它依赖于工作的就绪探针。

正如您所看到的,在这两种情况下,节点上都会运行 kube-proxy 二进制文件。在用户空间模式下,它会将自己插入为代理;在 iptables 模式下,它将配置 iptables 而不是自己代理连接。

服务类型可以具有以下值:

  • NodePort:通过指定NodePort服务类型,我们声明将服务暴露到集群外部。Kubernetes 主节点将从配置的标志范围(默认值:30000-32767)分配一个端口,集群的每个节点将代理该端口(每个节点上的相同端口号)到您的服务。

  • 负载均衡器:这将在支持外部负载均衡器的云提供商(例如在 Amazon AWS 云上)上创建负载均衡器。在使用 Minikube 时,此功能不可用。

  • Cluster IP:这将仅在集群内部公开服务。这是默认值,如果您不提供其他值,将使用此值。

准备好我们的service.yml文件后,我们可以通过执行以下kubectl命令来创建我们的第一个 Kubernetes 服务:

$ kubectl create -f service.yml

要查看我们的服务是否正确创建,我们可以执行kubectl get services命令:

我们还可以通过添加--all-namespaces开关来列出其他服务(包括minikube集群本身提供的服务,如果您感兴趣)。

$ kubectl get services --all-namespaces

查看特定服务的详细信息,我们使用describe命令。执行以下命令以查看我们的rest-example Java 服务的详细信息:

$ kubectl describe service rest-example

在输出中,我们呈现了最有用的服务属性,特别是端点(我们的内部容器 IP 和端口,在这种情况下只有一个,因为我们有一个 Pod 在服务中运行),服务内部端口和代理的 NodePort:

将所有设置放在.yaml文件中非常方便。但有时,需要以更动态的方式创建服务;例如在一些自动化流程中。在这种情况下,我们可以通过向kubectl命令本身提供所有参数和选项,手动创建服务,而不是首先创建.yaml文件。但在执行此操作之前,您需要先创建部署,因为手动创建服务只是使用kubectl命令公开部署。毕竟,服务是一个公开的部署,实际上只是一组 Pod。这样公开的示例,将导致服务创建,看起来与这个相同:

$ kubectl expose deployment rest-example--type="NodePort"

创建部署

在创建部署之前,我们需要准备好并发布到注册表的 Docker 镜像,例如 Docker Hub。当然,它也可以是您组织中托管的私有存储库。正如您从第七章中记得的,Kubernetes 简介,Pod 中的每个 Docker 容器都有自己的镜像。默认情况下,Pod 中的 kubectl 进程将尝试从指定的注册表中拉取每个镜像。您可以通过在部署描述符中为imagePullPolicy属性指定值来更改此行为。它可以具有以下值:

  • IfNotPresent:使用此设置,仅当本地主机上不存在图像时,才会从注册表中提取图像

  • Never:使用此选项,kubelet 将仅使用本地图像。

在创建部署时,使用值IfNotPresent设置imagePullPolicy很有用;否则,Minikube 将在查找本地主机上的图像之前尝试下载图像。

Kubernetes 使用与 Docker 本身相同的图像语法,包括私有注册表和标记。

重要的是您在图像名称中提供标记。否则,Kubernetes 将在存储库中查找图像时使用最新标记,与 Docker 一样。

在本地构建图像时,与本地 Kubernetes 集群一起工作会有点棘手。Minikube 在单独的 VM 中运行,因此它不会看到您在本地使用 Docker 在计算机上构建的图像。有一个解决方法。您可以执行以下命令:

$ eval $(minikube docker-env)

先前的命令实际上将利用在minikube上运行的 Docker 守护程序,并在 Minikube 的 Docker 上构建您的图像。这样,本地构建的图像将可供 Minikube 使用,而无需从外部注册表中提取。这并不是很方便,将 Docker 图像推送到远程注册表肯定更容易。让我们将我们的 rest-example 图像推送到DockerHub注册表。

  1. 首先,我们需要登录:
$ docker login

  1. 然后,我们将使用docker tag命令标记我们的图像(请注意,您需要提供自己的 DockerHub 用户名,而不是$DOCKER_HUB_USER):
$ docker tag 54529c0ebed7 $DOCKER_HUB_USER/rest-example

  1. 最后一步将是使用docker push命令将我们的图像推送到 Docker Hub:
$ docker push $DOCKER_HUB_USER/rest-example

  1. 现在我们在注册表中有一个可用的图像,我们需要一个部署清单。这又是一个.yaml文件,看起来可能与此相同:
 apiVersion: extensions/v1beta1

kind: Deployment

metadata:

  name: rest-example

spec:

  replicas: 1

  template:

    metadata:

      labels:

        app: rest-example

        tier: backend

    spec:

      containers:

      - name: rest-example

        image: jotka/rest-example

        imagePullPolicy: IfNotPresent

        resources:

          requests:

            cpu: 100m

            memory: 100Mi

        env:

        - name: GET_HOSTS_FROM

          value: dns

        ports:

        - containerPort: 8080

在集群上使用kubectl创建此部署,您需要执行以下命令,与创建服务时完全相同,只是文件名不同:

$ kubectl create -f deployment.yml

您可以查看部署属性:

$ kubectl describe deployment rest-service

如您所见,已创建一个 Pod 以及一个 ReplicaSet 和默认的滚动更新策略。您还可以查看 Pods:

$ kubectl get pods

get pods命令的输出将给出部署中运行的 Pod 的名称。稍后这将很重要,因为如果要与特定的 Pod 交互,您需要知道其名称:

作为.yaml文件中部署描述符的替代方案,您可以使用kubectl run命令和选项从命令行创建部署,如下例所示:

$ kubectl run rest-example --image=jotka/rest-example --replicas=1 --port=8080 --labels="app:rest-example;tier:backend" --expose 

让我们总结一下与创建资源和获取有关它们的信息相关的kubectl命令,以及一些示例,放在表中:

示例命令 意义
kubectl create -f ./service.yaml 创建资源
kubectl create -f ./service.yaml -f ./deployment.yaml 从多个文件创建
kubectl create -f ./dir 在指定目录中的所有清单文件中创建资源
kubectl create -f https://sampleUrl 从 URL 创建资源
kubectl run nginx --image=nginx 启动 nginx 的单个实例
Kubectl get pods 获取pod的文档
kubectl get pods --selector=app=rest-example 列出与指定标签selector匹配的所有 Pod
kubectl explain pods 显示所有 Pod 的详细信息
kubectl get services 列出所有已创建的服务
kubectl explain service 显示指定服务的详细信息
kubectl explain services 显示所有已创建服务的详细信息
kubectl get deployments 列出所有已创建的部署
kubectl get deployment 显示指定服务的详细信息
kubectl explain deployment 显示指定部署的详细信息
kubectl explain deployments 显示所有已创建部署的详细信息
kubectl get nodes 列出所有集群节点
kubectl explain node 显示指定节点的详细信息
 Calling the service

正如我们在kubectl describe service rest-example命令输出中所看到的,我们的rest-example service可以通过端口8080和域名rest-example在集群内部访问。在我们的情况下,端点的完整 URL 将是http://rest-example:8080。然而,为了能够从外部世界执行服务,我们使用了NodePort映射,并且我们知道它被赋予了端口31141。我们所需要的只是集群的 IP 来调用服务。我们可以使用以下命令获取它:

$ minikube ip

有一个快捷方式可以了解外部可访问的服务 URL 和端口号。我们可以使用minikube service命令来告诉我们确切的服务地址:

$ minikube service rest-example --url

上一个命令的输出将是带有映射端口号的服务 URL。如果您跳过--url开关,minikube将只是使用您的默认 Web 浏览器打开服务的 URL。这有时很方便。

拥有端点的完整 URL 后,我们可以使用任何HTTP客户端(例如curl)访问服务:

当服务运行时,应用程序日志通常可以帮助您了解集群内部发生了什么。日志对于调试问题和监视集群活动特别有用。让我们看看如何访问我们的容器日志。

与容器交互和查看日志

大多数现代应用程序都有某种日志记录机制。例如,我们的 Java REST 服务使用 slf4j 从 REST 控制器输出日志。容器化应用程序最简单和最简单的日志记录方法就是写入标准输出和标准错误流。Kubernetes 支持这一点。

假设我们已经使用浏览器或 curl 向我们的新 Web 服务发送了请求,现在应该能够看到一些日志。在此之前,我们需要有一个 Pod 的名称,在部署过程中会自动创建。要获取 Pod 的名称,请使用kubectl get pods命令。之后,您可以显示指定 Pod 的日志:

$ kubectl logs rest-example-3660361385-gkzb8

如您在以下截图中所见,我们将访问来自 Pod 中运行的服务的著名 Spring Boot 横幅:

查看日志并不是我们可以对特定 Pod 进行的唯一操作。与 Docker 类似(实际上,Pod 正在运行 Docker),我们可以使用kubectl exec命令与容器进行交互。例如,要获取正在运行的容器的 shell:

$ kubectl exec -it rest-example-3660361385-gkzb8 -- /bin/bash

上一个命令将把您的 shell 控制台附加到正在运行的容器中的 shell,您可以与之交互,例如列出进程,就像您在以下截图中所见的那样:

kubectl exec命令的语法与 Docker 中的exec命令非常相似,只有一个小差别,正如您从第七章中所记得的,Kubernetes 简介,一个 Pod 可以运行多个容器。在这种情况下,我们可以使用--container-c命令开关来指定kubectl exec命令中的容器。例如,假设我们有一个名为rest-example-3660361385-gkzb8的 Pod。这个 Pod 有两个名为 service 和 database 的容器。以下命令将打开一个 shell 到 service 容器:

$ kubectl exec -it rest-example-3660361385-gkzb8 --container service -- /bin/bash

拥有查看日志和与容器交互的可能性为您提供了很大的灵活性,可以准确定位您在运行 Pod 时可能遇到的问题。让我们总结与查看日志和与 Pod 交互相关的 kubectl 命令表:

示例命令 意义
kubectl logs myPod 转储 pod 日志(stdout)
kubectl logs myPod -c myContainer 转储 pod 容器日志(stdout,多容器情况)
kubectl logs -f myPod 流式传输 pod 日志(stdout)
kubectl logs -f myPod -c myContainer 流式传输 pod 容器日志(stdout,多容器情况)
kubectl run -i --tty busybox --image=busybox -- sh 以交互式 shell 运行 pod
kubectl attach myPod -i 连接到正在运行的容器
kubectl port-forward myPod 8080:8090 将 Pod 的端口 8080 转发到本地机器上的 8090
kubectl exec myPod -- ls / 在现有 pod 中运行命令(单容器情况)
kubectl exec myPod -c myContainer -- ls / 在现有 pod 中运行命令(多容器情况)
kubectl top pod POD_NAME --containers 显示给定 pod 及其容器的指标

正如您已经知道的那样,Pod 和容器是脆弱的。它们可能会崩溃或被杀死。您可以使用 kubectl logs 命令检索具有 --previous 标志的容器的先前实例化的日志,以防容器崩溃。假设我们的服务运行良好,但由于第七章 Kubernetes 简介 中描述的原因,例如更高的负载,您决定增加运行的容器数量。Kubernetes 允许您增加每个服务运行的 Pod 实例的数量。这可以手动或自动完成。让我们首先关注手动扩展。

手动扩展

部署创建后,新的 ReplicaSet 也会自动创建。正如您在第七章 Kubernetes 简介中所记得的那样,ReplicaSet 确保在任何给定时间运行指定数量的 Pod 克隆,称为副本。如果太多,其中一些将被关闭。如果需要更多,例如如果其中一些因错误或崩溃而死亡,将创建新的 Pod。请注意,如果尝试直接扩展 ReplicaSet,那么它将(在很短的时间内)具有所需的 Pod 数量,例如三个。但是,如果部署控制器看到您已将副本集修改为三个,因为它知道应该是一个(在部署清单中定义),它将将其重置为一个。通过手动修改为您创建的副本集,您有点违背了系统控制器。

需要扩展部署而不是直接扩展副本集。

当然,我们的 Java rest-example服务将数据保存在内存中,因此它不是无状态的,因此它可能不是扩展的最佳示例;如果另一个实例被启动,它将拥有自己的数据。但是,它是一个 Kubernetes 服务,因此我们可以使用它来演示扩展。要将我们的rest-example部署从一个扩展到三个 Pod,请执行以下kubectl scale命令:

$ kubectl scale deployment rest-example --replicas=3

过一段时间,为了检查,执行以下命令,您将看到部署中现在运行着三个 Pod:

$ kubectl get deployments

$ kubectl get pods

在下表中,您可以看到与手动扩展相关的一些kubectl命令的更多示例:

示例命令 意义
kubectl scale deployment rest-example --replicas=3 将名为rest-example的部署扩展到3个 Pod
kubectl scale --replicas=3 -f deployment.yaml deployment.yaml文件中指定的资源扩展到3
kubectl scale deployment rest-example --current-replicas=2 --replicas=3 如果名为rest-example的部署当前大小为2,则将其扩展到3个 Pod
kubectl scale --replicas=5 deployment/foo deployment/bar 一次扩展多个部署

如果服务负载增加,Kubernetes 可以自动进行扩展。

自动缩放

通过水平 Pod 自动缩放,Kubernetes 根据观察到的 CPU 利用率自动调整部署或 ReplicaSet 中 Pod 的数量。Kubernetes 控制器定期调整部署中 Pod“副本”的数量,以匹配观察到的平均 CPU 利用率与您指定的目标。

水平自动缩放器只是 Kubernetes 中的另一种资源类型,因此我们可以像创建其他资源一样使用kubectl命令创建它:

  • kubectl get hpa:列出自动缩放器

  • kubectl describe hpa:获取详细描述

  • kubectl delete hpa:删除自动缩放器

此外,还有一个特殊的kubectl autoscale命令,用于轻松创建水平 Pod 自动缩放器。一个示例可能是:

$ kubectl autoscale deployment rest-example --cpu-percent=50 --min=1 --max=10

上一个命令将为我们的rest-example部署创建一个自动缩放器,目标 CPU 利用率设置为50%副本数量在110之间。

所有集群事件都被注册,包括手动或自动缩放产生的事件。查看集群事件在监视我们的集群上执行的确切操作时可能会有所帮助。

查看集群事件

查看集群事件,请输入以下命令:

$ kubectl get events

它将呈现一个巨大的表格,其中包含集群上注册的所有事件:

表格将包括节点状态的更改,拉取 Docker 镜像,启动和停止容器等事件。查看整个集群的情况非常方便。

使用 Kubernetes 仪表板

Kubernetes 仪表板是 Kubernetes 集群的通用、基于 Web 的 UI。它允许用户管理运行在集群中的应用程序并对其进行故障排除,以及管理集群本身。我们还可以编辑部署、服务或 Pod 的清单文件。更改将立即被 Kubernetes 接管,因此它使我们能够扩展或缩减部署,例如。

如果您使用minikube dashboard命令打开仪表板,它将在默认浏览器中打开一个仪表板 URL。从这里,您可以列出集群上的所有资源,例如部署、服务、Pod 等。正如您在下面的屏幕截图中所看到的,我们的仪表板不再是空的;我们有一个名为rest-example的部署:

如果您点击它的名称,您将进入部署详细信息页面,它将显示您可以使用kubectl describe deployment命令获取的相同信息,但具有良好的 UI:

仪表板不仅是只读实用程序。每个资源都有一个方便的菜单,您可以使用它来删除或编辑其清单:

如果您选择查看/编辑 YAML 菜单选项,您将能够使用方便的编辑器编辑清单:

请注意,如果您更改一个值,例如replicas的数量,并单击“更新”,更改将被发送到 Kubernetes 并执行。这样,您也可以例如扩展您的部署。

由于部署已自动创建了一个 ReplicaSet,因此 ReplicaSet 也将显示在仪表板上:

服务也是一样的。如果您浏览到服务菜单,它将显示在集群上创建的所有服务的列表:

单击服务名称将带您转到详细信息页面:

在详细信息屏幕上,列出了所有重要信息。这包括标签选择器,用于查找 Pod 的端口类型,集群 IP,内部端点,当然还有运行在服务内部的 Pod 的列表。通过单击 Pod 的名称,您可以查看正在运行的 Pod 的详细信息,包括其日志输出,如下面的屏幕截图所示:

仪表板是一个非常方便的工具,可以与现有的部署、服务和 Pod 进行交互。但还有更多。如果您单击仪表板工具栏右上角的“创建”按钮,将显示一个“部署容器化应用程序”屏幕。从这里,您实际上可以创建一个新的部署:

您有机会使用.yaml文件,就像我们之前使用命令行一样,但是您还可以手动指定部署的详细信息,提供应用程序名称,并且可以选择创建一个服务用于部署。相当方便,不是吗?仪表板只是 Minikube 可用的插件之一。让我们看看我们还有什么可以使用。

Minikube 插件

Minikube 带有几个插件,例如 Kubernetes 仪表板,Kubernetes DNS 等。我们可以通过执行以下命令列出可用的插件:

$ minikube addons list

上一个命令的输出将列出可用的插件及其当前状态,例如:

要启用或禁用插件,我们使用minikube addons disableminikube addons enable,例如:

$ minikube addons disable dashboard

$ minikube addons enable heapster

如果插件已启用,我们可以通过执行addon open命令打开相应的 Web 用户界面,例如:

$ minikube addons open heapster

清理

如果您完成了部署和服务的操作,或者想要从头开始,您可以通过删除部署或服务来清理集群:

$ kubectl delete deployment rest-example

$ kubectl delete service rest-example

这段代码也可以合并成一个命令,例如:

$ kubectl delete service,deployment rest-example

kubectl delete支持标签selectors和命名空间。让我们在表中看一些命令的其他示例:

示例命令 含义
kubectl delete pod,service baz foo 删除具有相同名称bazfoo的 pod 和服务
kubectl delete pods,services -l name=myLabel 删除具有标签name=myLabel的 pod 和服务
kubectl -n my-ns delete po,svc --all 删除命名空间my-ns中的所有 pod 和服务

要停止minikube集群,只需发出:

$ minikube stop

如果您想要删除当前的minikube集群,可以发出以下命令来执行:

$ minikube delete

总结

正如您所看到的,Minikube 是尝试 Kubernetes 并在本地开发中使用它的简单方法。运行本地集群并不像开始时看起来那么可怕。最重要的是,本地的minikube集群是一个有效的 Kubernetes 集群。如果您通过在本地玩耍来了解 Kubernetes,您将能够在真实的云中部署您的应用程序而不会遇到任何问题。让我们总结一下我们需要执行的步骤,以使我们的 Java 应用程序在 Kubernetes 集群上运行。

首先,我们需要为我们的微服务编写一些代码。这可以基于您想要的任何内容,可以是在 Tomcat、JBoss 或 Spring Bootstrap 上运行的微服务。没关系,您只需选择您希望软件运行的技术:

  • 接下来,将代码放入 Docker 镜像中。您可以通过手动创建 Dockerfile 来完成,也可以使用 Docker Maven 插件来自动化此过程

  • 创建 Kubernetes 元数据,如部署清单和服务清单

  • 通过部署和创建服务来应用元数据

  • 根据您的需求扩展您的应用程序

  • 从命令行或仪表板管理您的集群

在第九章中,使用 Kubernetes API,我们将深入了解 Kubernetes API。这是与 Kubernetes 集群进行交互的绝佳方式。由于 API 的存在,可能性几乎是无限的,您可以创建自己的开发流程,例如使用 Jenkins 进行持续交付。拥有 API,您不仅仅局限于现有工具来部署软件到 Kubernetes。事情可能会变得更有趣。

第九章:使用 Kubernetes API。

在第七章中,Kubernetes 简介,和第八章,使用 Java 与 Kubernetes,我们学习了 Kubernetes 的概念,并通过安装本地 Kubernetes 集群minikube来实践使用它们。我们了解了 Kubernetes 架构的所有组件,比如 pod、节点、部署和服务等。我们还提到了驻留在 Master 节点上的主要组件之一,即 API 服务器。正如你在第七章中所记得的,API 服务器在技术上是一个名为kube-apiserver的进程,它接受并响应使用 JSON 的HTTP REST请求。API 服务器的主要目的是验证和处理集群资源的数据,比如 Pod、服务或部署等。API 服务器是中央管理实体。它也是唯一直接连接到etcd的 Kubernetes 组件,etcd是 Kubernetes 存储其所有集群状态的分布式键值数据存储。

在之前的章节中,我们一直在使用kubectl命令行工具来管理我们的集群。kubectl是一个有用的实用工具,每当我们想要针对我们的集群执行命令时,无论是创建、编辑还是删除资源。事实上,kubectl也与 API 服务器通信;你可能已经注意到,在 Kubernetes 中几乎每个改变某些东西的操作基本上都是在编辑资源。如果你想要扩展或缩减你的应用程序,这将通过修改部署资源来完成。Kubernetes 将即时捕捉到变化并将其应用到资源上。此外,诸如列出 Pod 或部署的只读操作,将执行相应的GET请求。

实际上,如果您以更高级别的详细程度运行 kubectl 命令,例如使用--v=6--v=9选项,您可以看到正在进行的 REST 调用。我们稍后将回到这个问题。我们可以使用 kubectl、客户端库或进行 REST 请求来访问 API。REST API 何时有用?嗯,您可以在任何编程或脚本语言中创建 REST 调用。这创造了一个全新的灵活性水平,您可以从自己的 Java 应用程序中管理 Kubernetes,从 Jenkins 中的持续交付流程中管理,或者从您正在使用的构建工具中管理,例如 Maven。可能性几乎是无限的。在本章中,我们将通过使用命令行 curl 实用程序进行 REST 调用来了解 API 概述、其结构和示例请求。本章将涵盖以下主题:

  • 关于 API 版本控制的解释

  • 认证(确定谁是谁)

  • 授权(确定谁能做什么)

  • 通过进行一些示例调用来使用 API

  • OpenAPI Swagger 文档

让我们开始 API 概述。

API 版本控制

Kubernetes 不断发展。其功能发生变化,这也导致 API 发生变化。为了处理这些变化,并且在较长时间内不破坏与现有客户端的兼容性,Kubernetes 支持多个 API 版本,每个版本都有不同的 API 路径,例如/api/v1/apis/extensions/v1beta1。Kubernetes API 规范中有三个 API 级别:alpha,beta 和 stable。让我们了解一下它们的区别。

Alpha

Alpha 版本级别默认禁用,与其他软件一样,Alpha 版本应被视为有错误并且不适合生产。此外,您应该注意,Alpha 版本中引入的任何功能可能在稳定版本中并不总是可用。此外,API 的更改可能在下一个版本中不兼容。除非您非常渴望测试新功能或进行一些实验,否则不应使用alpha版本。

Beta

Beta 级别与 API 的alpha级别完全不同,代码经过测试(仍然可能存在一些错误,因为它仍然不是稳定版本)。此外,与alpha级别相比,beta中的功能将不会在将来的版本中被删除。如果 API 中有破坏性的、不向后兼容的更改,Kubernetes 团队将提供迁移指南。在生产环境中使用beta并不是最好的主意,但您可以在非业务关键的集群上安全地使用beta。您也被鼓励从使用beta中提供反馈,这将使我们所有人使用的 Kubernetes 变得更好。beta级别中的版本名称将包含单词beta,例如v1beta1

稳定

API 的稳定级别是经过测试的,已经准备好投入生产的软件。稳定 API 中的版本名称将是vX,其中X是一个整数,例如v1

Kubernetes API 利用了 API 组的概念。引入 API 组是为了将来更容易地扩展 Kubernetes API。API 组在REST路径和调用的 JSON 负载的apiVersion字段中指定。目前,有几个 API 组正在使用:core、batch 和 extensions。组名是 API 调用的REST路径的一部分:/apis/$GROUP_NAME/$VERSION。核心组是一个例外,它不显示在REST路径中,例如:/api/v1您可以在 Kubernetes API 参考中找到支持的 API 组的完整列表。

通过使用 API,您几乎可以像使用kubectl命令一样对集群进行任何操作。这可能是危险的;这就是为什么 Kubernetes 支持认证(确定您是谁)和授权(您可以做什么)。调用 API 服务的基本流程如下图所示:

让我们从认证开始。

认证

默认情况下,Kubernetes API 服务器在两个端口上提供HTTP请求:

  • 本地主机不安全端口:默认情况下,IP 地址为localhost,端口号为8080。没有 TLS 通信,此端口上的所有请求都将绕过身份验证和授权插件。这是为了测试和引导,以及主节点的其他组件。这也用于其他 Kubernetes 组件,如调度程序或控制器管理器来执行 API 调用。您可以使用--insecure-port开关更改端口号,并使用--insecure-bind-address命令行开关更改默认 IP。

  • 安全端口:默认端口号是6443(可以使用--secure-port开关进行更改),通常在云提供商上是443。它使用 TLS 通信。可以使用--tls-cert-file开关设置证书。可以使用--tls-private-key-file开关提供私有 SSL 密钥。通过此端口传入的所有请求将由身份验证和授权模块以及准入控制模块处理。尽可能使用安全端口。通过让 API 客户端验证api-server呈现的 TLS 证书,它们可以验证连接既加密又不易受中间人攻击。您还应该在仅本地主机可以访问不安全端口的情况下运行api-server,以便通过网络传入的连接使用HTTP

  • 使用 minikube 直接访问 API 服务器时,您需要使用 minikube 生成的自定义 SSL 证书。客户端证书和密钥通常存储在~/.minikube/apiserver.crt~/.minikube/apiserver.key中。在进行HTTP请求时,您需要将它们加载到您的HTTP客户端中。如果您使用curl,请使用--cert--key选项来使用certkey文件。

API 服务器的访问可以通过代理简化,在本章后面我们将开始介绍。

如果您想从不同的域发送请求到 Kubernetes API,您需要在api-server上启用cors。您可以通过在kube-apiserver配置中添加--cors-allowed-origins=["http://*"]参数来实现。通常在/etc/default/kube-apiserver文件中进行配置,并重新启动kube-apiserver

请注意,Kubernetes 集群不会自行管理用户。相反,用户被假定由外部独立服务管理。Kubernetes 集群中没有代表普通用户帐户的资源。这就是为什么用户不能通过 API 调用添加到集群中。

Kubernetes 不会自行管理用户帐户。

Kubernetes API 支持多种身份验证形式:HTTP基本身份验证、持有者令牌和客户端证书。它们被称为身份验证策略。在启动api-server时,您可以使用命令行标志来启用或禁用这些身份验证策略。让我们看看可能的情况,从最简单的基本身份验证策略开始。

HTTP 基本身份验证

要使用此身份验证策略,您需要使用--basic-auth-file=<path_to_auth_file>开关启动api-server。它应该是一个包含每个用户以下条目的csv文件:

password, user name, userid

您还可以指定一个可选的第四列,其中包含用逗号分隔的组名。如果用户有多个组,整个列的内容必须用双引号括起来,例如:

password, user, userid,"group1,group2,group3"

如果api-server使用基本身份验证策略,它将期望所有的REST调用都包含在Authorization头中,其中包含用BASE64编码的用户名和密码(类似于普通的基本身份验证保护的 web 调用),例如:

BASE64ENCODED(USER:PASSWORD)

要生成授权头值,您可以在 shell 中使用以下命令,它将为具有密码 secret 的用户生成值:

echo -n "user:secret" | base64

请注意,对基本auth文件的任何更改都需要重新启动api-server才能生效。

在云中运行 Kubernetes 时,通常会使用HTTP基本身份验证作为默认。例如,一旦在 Google 容器引擎上启动容器集群,您将在 GCP 项目中的 VM 上运行api-server。如果运行gcloud preview container clusters列表,您将看到api-server监听请求的端点以及访问它所需的凭据。您将在第十章中找到更多关于在云中运行 Kubernetes 的内容,在云上部署 Java 到 Kubernetes

静态令牌文件

要使api-server使用此方案,需要使用--token-auth-file=<PATH_TO_TOKEN_FILE>开关启动。与HTTP基本身份验证策略类似,提供的文件是一个包含每个用户记录的csv文件。记录需要采用以下格式:

token, user, userid, group

再次强调,组名是可选的,如果用户有多个组,您需要用逗号分隔它们并用双引号括起来。令牌只是一个base64编码的字符串。在 Linux 上生成令牌的示例命令可以如下:

$ echo `dd if=/dev/urandom bs=128 count=1 2>/dev/null | base64 | tr -d "=+/" | dd bs=32 count=1 2>/dev/null`

输出将是一个令牌,然后您将其输入到token文件中,例如:

3XQ8W6IAourkXOLH2yfpbGFXftbH0vn,default,default

当使用这种策略时,api-server将期望一个值为Bearer < TOKEN>Authorization头。在我们的示例中,这将看起来与以下内容相同:

Authorization: Bearer 3XQ8W6IAourkXOLH2yfpbGFXftbH0vn

令牌永久有效,并且令牌列表在不重新启动 API 服务器的情况下无法更改。

客户端证书

为了使用这个方案,api-server需要使用以下开关启动:

--client-ca-file=<PATH_TO_CA_CERTIFICATE_FILE>

CA_CERTIFICATE_FILE必须包含一个或多个证书颁发机构,用于验证提交给api-server的客户端证书。客户端证书的/CN(通用名称)用作用户名。客户端证书还可以使用组织字段指示用户的组成员资格。要为用户包括多个组成员资格,您需要在证书中包括多个组织字段。例如,使用openssl命令行工具生成证书签名请求:

$ openssl req -new -key user.pem -out user-csr.pem \

-subj "/CN=user/O=group1/O=group2"

这将为用户名user创建一个证书签名请求,属于两个组group1group2

OpenID

OpenID connect 1.0 是 OAuth 2.0 协议之上的一个简单身份验证层。您可以在互联网上阅读有关 OpenID connect 的更多信息,网址为https://openid.net/connect。它允许客户端根据授权服务器执行的身份验证来验证最终用户的身份,并以一种可互操作和类似于REST的方式获取有关最终用户的基本配置信息。所有云提供商,包括 Azure、Amazon 和 Google 都支持 OpenID。与OAuth2的主要区别在于访问令牌中返回的附加字段称为id_token。这个令牌是一个带有众所周知字段(例如用户的电子邮件)的JSON Web TokenJWT),由服务器签名。为了识别用户,认证器使用OAuth2token响应中的id_token作为持有者令牌。要使用 OpenID 身份验证,您需要登录到您的身份提供者,该提供者将为您提供一个id_token(以及标准的 OAuth 2.0 access_tokenrefresh_token

由于进行身份验证所需的所有数据都包含在id_token中,Kubernetes 不需要向身份提供者发出额外的调用。这对于可扩展性非常重要,每个请求都是无状态的。

要为kubectl命令提供一个令牌值,您需要使用--token标志。或者,您可以直接将其添加到您的kubeconfig文件中。

这是当您执行对您的api-serverHTTP调用时会发生的事情的简化流程:

  • kubectl将在authorization标头中发送您的id_token到 API 服务器。

  • API 服务器将通过检查配置中命名的证书来验证 JWT 签名

  • API 服务器将检查id_token是否已过期

  • API 服务器将确保用户经过授权,并且如果是这样的话会向kubectl返回一个响应。

默认情况下,任何具有对api-server的访问凭据的人都可以完全访问集群。您还可以配置更精细的授权策略,现在让我们来看看授权。

授权

成功验证后的下一步是检查经过授权的用户允许进行哪些操作。截至今天,Kubernetes 支持四种类型的授权策略方案。要使用特定的授权模式,启动api-server时使用--authorization-mode开关。语法是:

$ kube-apiserver --authorization-mode <mode>

<mode>参数包含了 Kubernetes 应该用来对用户进行身份验证的授权插件的有序列表。当启用了多个身份验证插件时,第一个成功验证请求的插件将使 Kubernetes 跳过执行所有剩余的插件。

默认授权模式是AlwaysAllow,允许所有请求。

支持以下授权方案:

  • 基于属性的控制

  • 基于角色的控制

  • Webhook

  • AlwaysDeny

  • AlwaysAllow

让我们简要地逐一描述它们。

基于属性的访问控制

基于属性的访问控制ABAC)策略将在使用--authorization-mode=ABAC选项启动api-server时使用。该策略使用本地文件,您可以以灵活的方式在其中定义每个用户应具有的权限。还有一个提供策略文件的额外选项:--authorization-policy-file,因此使用此策略的完整语法将是:

$ kube-apiserver --authorization-mode=ABAC \

--authorization-policy-file=<PATH_TO_ POLICY_FILE>

请注意,对策略文件的任何更改都将需要重新启动api-server

正如你从第七章中记得的,Kubernetes 简介,Kubernetes 集群使用命名空间的概念来对相关资源进行分组,如 Pod、部署或服务。api-server中的授权模式利用了这些命名空间。ABAC策略文件语法相当清晰和可读。每个条目都是描述授权规则的 JSON 对象。考虑策略文件中的以下条目,它为用户john提供对命名空间myApp的完全访问权限:

{

 "apiVersion": "abac.authorization.kubernetes.io/v1beta1", 

 "kind": "Policy", 

 "spec": {

 "user":"john", 

 "namespace": "myApp", 

 "resource": "*", 

 "apiGroup": "*", 

 "nonResourcePath": "*" 

 }

}

下一个示例将为用户admin提供对所有命名空间的完全访问权限:

{

 "apiVersion": "abac.authorization.kubernetes.io/v1beta1", 

 "kind": "Policy", 

 "spec":{

 "user":"admin", 

 "namespace": "*", 

 "resource": "*", 

 "apiGroup": "*", 

 "nonResourcePath": "*" 

 }

}

最后,一个示例,为所有用户提供对整个集群的只读访问权限:

{

 "apiVersion": "abac.authorization.kubernetes.io/v1beta1", 

 "kind": "Policy", 

 "spec": {

 "user":"*", 

 "namespace": "*", 

 "resource": "*", 

 "apiGroup": "*", 

 "nonResourcePath": "*", 

 "readonly":true 

 }

} 

基于角色的访问控制(RBAC)

基于角色的访问控制RBAC),策略实施深度集成到了 Kubernetes 中。事实上,Kubernetes 在内部使用它来为系统组件授予必要的权限以使其正常运行。RBAC是 100%的 API 驱动,角色和绑定是管理员可以在集群上编写和创建的 API 资源,就像其他资源(如 Pod、部署或服务)一样。启用RBAC模式就像向kube-apiserver传递一个标志一样简单:

--authorization-mode=RBAC

这种模式允许您使用 Kubernetes API 创建和存储策略。在RBAC API 中,一组权限由角色的概念表示。命名空间角色和整个集群角色之间有区别,由Role资源表示,整个集群角色由ClusterRole资源表示。ClusterRole可以定义与Role相同的所有权限,但也可以定义一些与集群相关的权限,例如管理集群节点或修改所有可用命名空间中的资源。请注意,一旦启用了RBAC,API 的每个方面都将被禁止访问。

权限是可累加的;没有拒绝规则。

这是一个角色的示例,它为所有资源的所有操作提供了整套可用权限:

apiVersion: rbac.authorization.k8s.io/v1beta1

metadata:

 name: cluster-writer

rules:

 - apiGroups: ["*"]

 resources: ["*"]

 verbs: ["*"]

 nonResourceURLs: ["*"]

角色是一个资源,正如你从第八章中记得的,使用 Java 与 Kubernetes,要使用文件创建资源,你执行kubectl create命令,例如:

$ kubectl create -f cluster-writer.yml

RoleClusterRole定义了一组权限,但不直接将它们分配给用户或组。在 Kubernetes API 中有另一个资源,即RoleBindingClusterRoleBinding。它们将RoleClusterRole绑定到特定的主体,可以是用户、组或服务用户。要绑定RoleClusterRole,您需要执行kubectl create rolebinding命令。看一下以下示例。要在命名空间myApp中向名为john的用户授予adminClusterRole

$ kubectl create rolebinding john-admin-binding \

--clusterrole=admin --user=john --namespace=myApp

下一个将在整个集群中向名为admin的用户授予cluster-admin ClusterRole

$ kubectl create clusterrolebinding admin-cluster-admin-binding \

--clusterrole=cluster-admin --user=admin

使用kubectl create -f的等效 YAML 文件如下:

apiVersion: rbac.authorization.k8s.io/v1beta1

kind: ClusterRoleBinding

metadata:

 name: admin-cluster-admin-binding

roleRef:

 apiGroup: rbac.authorization.k8s.io

 kind: ClusterRole

 name cluster-admin

subjects:

- kind: User

 name: admin

WebHook

api-server--authorization-mode=Webhook选项启动时,它将调用外部的HTTP服务器来对用户进行授权。这使您能够创建自己的授权服务器。换句话说,WebHook 是一种HTTP回调模式,允许您使用远程REST服务器来管理授权,无论是您自己开发的,还是第三方授权服务器。

在进行授权检查时,api-server将执行HTTP POST请求,其中包含一个序列化的api.authorization.v1beta1.SubjectAccessReview对象的 JSON 有效负载。此对象描述了向api-server发出请求的用户,此用户想要执行的操作,以及作为此操作主题的资源的详细信息。示例请求有效负载可能如下例所示:

{

 "apiVersion": "authorization.k8s.io/v1beta1",

 "kind": "SubjectAccessReview",

 "spec": {

 "resourceAttributes": {

 "namespace": "rest-example",

 "verb": "get",

 "resource": "pods"

 },

 "user": "john",

 "group": [

 "group1",

 "group2"

 ]

 }

} 

远程授权服务器应提供响应,指示此用户是否被授权在指定资源上执行指定操作。响应应包含SubjectAccessReviewStatus字段,指定api-server是否应允许或拒绝访问。宽松的 JSON 响应看起来与此相同:

{

 "apiVersion": "authorization.k8s.io/v1beta1",

 "kind": "SubjectAccessReview",

 "status": {

 "allowed": true

 }

} 

负面响应将如下例所示出现:

{

 "apiVersion": "authorization.k8s.io/v1beta1",

 "kind": "SubjectAccessReview",

 "status": {

 "allowed": false,

 "reason": "user does not have read access to the namespace"

 }

}

将授权委托给另一个服务的可能性使授权过程非常灵活,想象一下,根据用户在企业LDAP目录中的角色,您自己的软件授权用户在集群中执行某些操作。

AlwaysDeny

此策略拒绝所有请求。如果您使用--authorization-mode=AlwaysDeny开关启动api-server,则将使用它。如果您正在进行一些测试或希望阻止传入请求而不实际停止api-server,这可能很有用。

AlwaysAllow

如果您使用--authorization-mode=AlwaysAllow开启api-server,则所有请求将被接受,而不使用任何授权模式。只有在不需要对 API 请求进行授权时才使用此标志。

正如您所看到的,Kubernetes 中的身份验证和授权可能性非常灵活。在本章开头的图表中,我们已经看到了 API 调用流程的第三阶段:准入控制。准入控制扮演着什么角色?让我们找出来。

准入控制

准入控制插件在请求经过身份验证和授权后,但在对 API 资源进行任何更改之前拦截对 Kubernetes API 服务器的请求。这些插件按顺序运行,在请求被接受到集群之前运行。Kubernetes API 服务器支持一个标志admission-control,它接受一个逗号分隔的有序准入控制插件列表。

现在我们已经了解了 API 调用的外观,让我们实际利用一些。

使用 API

API 参考是一份详细的文档,可以在互联网上找到kubernetes.io/docs/api-reference/v1.6/当然,API 版本将来会更改,v1.6是写作时的当前版本。

在我们对api-server进行一些实际调用之前,值得知道kubectl也使用 API 与 Kubernetes 集群进行通信。正如我们之前提到的,您可以通过kubectl命令查看正在进行的REST调用。查看在使用kubectl时发送到服务器的内容是熟悉 Kubernetes API 的好方法。

要查看kubectl执行的REST请求,可以以更高的详细级别运行它,例如使用--v=6--v=9选项。

在我们开始实际进行REST调用之前,让我们简要地看一下 API 操作有哪些可能。

API 操作

Kubernetes API 定义了 CRUD(创建、更新、读取和删除)一组操作:

  • Create:创建操作将在集群中创建资源。您需要在您的REST调用中提供的 JSON 有效负载是资源清单。这相当于我们在第八章中构建的 YAML 文件,使用 Java 与 Kubernetes。这次,它将以 JSON 格式呈现。

  • Update:更新操作可以是ReplacePatchReplace将简单地用提供的规范替换整个资源对象(例如 Pod)。另一方面,Patch将仅对特定字段应用更改。

  • Read:读取操作可以是GetListWatch。通过执行Get,您将得到一个特定资源对象的名称。执行List将检索命名空间内特定类型的所有资源对象。您可以使用选择器查询。List操作的一种特殊形式是List All Namespaces,正如其名称所示,这将检索所有命名空间中的资源。Watch操作将流式传输对象或列表对象的结果,因为它们被更新。

  • 删除:将简单地删除资源。

Kubernetes api-server还公开了一些其他特定于资源的操作。这包括Rollback,它将 Pod 模板回滚到先前的版本,或者读取/写入规模,它读取或更新给定资源的副本数量。

示例调用

在以下示例中,我们将使用命令行HTTP客户端curl。您不限于curl,可以自由使用您觉得方便的HTTP客户端。使用带有用户界面的HTTP客户端通常非常方便,它们通常以结构化形式呈现HTTP响应,并有时还会进行一些请求验证,如果它是格式良好的。我推荐的 GUI 客户端将是 Postman(适用于 Windows、Linux 或 Mac),或者 Mac 的 PAW。

在进行任何调用之前,让我们首先启动一个代理到 Kubernetes API 服务器。首先需要配置kubectl,以便能够与您的集群通信。在我们的本地 Kubernetes 安装中,使用minikubekubectl命令将自动配置。要启动到api-server的代理,请执行以下命令:

$ kubectl proxy --port=8080

在代理会话运行时,发送到localhost:8000的任何请求将被转发到 Kubernetes API 服务器。要检查我们的api-server是否正在运行,让我们询问它支持的 API 版本:

$ curl http://localhost:8080/api/

如果api-server正在运行并等待传入请求,它应该给您一个类似于这样的输出:

看起来一切都很顺利;让我们继续利用暴露的 API,开始创建服务,与之前一样。

使用 API 创建服务

首先,让我们创建一个服务清单文件。请注意,如果您在第八章中使用kubectl创建了您的服务、部署和 Pod,使用 Java 与 Kubernetes,您将需要使用kubectl或 Kubernetes 仪表板将它们删除。我们将使用相同的名称来创建服务和部署。在使用curl发送较大有效负载时,最好将有效负载放在外部文件中,而不是在命令行中输入。我们将使用的 JSON 文件作为有效负载与我们使用kubectl创建 Pod 时使用的文件非常相似,但这次是以 JSON 格式。让我们创建一个名为service.json的文件:

{

 "apiVersion": "v1",

 "kind": "Service",

 "metadata": {

 "name": "rest-example",

 "labels": {

 "app": "rest-example",

 "tier": "backend"

 }

 },

 "spec": {

 "type": "NodePort",

 "ports": [

 {

 "port": 8080

 }

 ],

 "selector": {

 "app": "rest-example",

 "tier": "backend"

 }

 }

} 

请注意,JSON 文件的内容基本上与我们在使用 YAML 文件创建资源时使用的内容相同。是的,您可以清楚地看到kubectl命令是如何实现的,它只是从文件输入创建一个 JSON 有效负载,一点魔术都没有。

您可以在网上使用其中一个 YAML/JSON 转换器在 YAML 和 JSON 之间进行转换。Kubernetes api-server将接受这样的 JSON,就像Kubectl接受 YAML 文件一样。

准备好我们的 JSON 文件,下一步是通过调用以下命令在我们的集群中创建服务资源:

$ curl -s http://localhost:8080/api/v1/namespaces/default/services \

-XPOST -H 'Content-Type: application/json' -d@service.json

定义了我们的服务,让我们创建一个部署。

使用 API 创建部署

创建部署与创建服务非常相似,毕竟它是创建另一种类型的 Kubernetes 资源。我们所需要的只是一个适当的 JSON 有效负载文件,我们将使用POST HTTP方法将其发送到api-server。我们的 JSON 格式的rest-example部署清单可以如下所示:

{

 "apiVersion": "extensions/v1beta1",

 "kind": "Deployment",

 "metadata": {

 "name": "rest-example"

 },

 "spec": {

 "replicas": 1,

 "template": {

 "metadata": {

 "labels": {

 "app": "rest-example",

 "tier": "backend"

 }

 },

 "spec": {

 "containers": [

 {

 "name": "rest-example",

 "image": "jotka/rest-example",

 "imagePullPolicy": "IfNotPresent",

 "resources": {

 "requests": {

 "cpu": "100m",

 "memory": "100Mi"

 }

 },

 "env": [

 {

 "name": "GET_HOSTS_FROM",

 "value": "dns"

 }

 ],

 "ports": [

 {

 "containerPort": 8080

 }

 ]

 }

 ]

 }

 }

 }

}

让我们使用deployment.json文件名保存文件。再次,我们现在需要做的就是将这个文件发布到api-server。这个过程与创建服务非常相似,只是向不同的端点发送不同的有效负载进行POST。要使用curl从 shell 创建部署,请执行以下命令:

$ curl -s \ http://localhost:8080/apis/extensions/v1beta1/namespaces/default/deployments -XPOST -H 'Content-Type: application/json' \

-d@deployment.json

在前面的示例中,您应该注意到与部署相关的 API 命令位于另一个 API 组:extensions。这就是为什么端点将具有不同的REST路径。

执行这两个REST HTTP请求后,我们应该在集群中创建了我们的服务和部署。当然,因为部署清单包含副本数为1,一个 Pod 也将被创建。让我们通过执行以下命令来检查是否属实:

$ kubectl get services

$ kubectl get deployments

$ kubectl get pods

正如您在以下截图中所看到的,所有资源都存在于我们的集群中。然而,这一次,它们是通过两个简单的HTTP POST请求到 Kubernetes api-servers创建的,而不是使用kubectl

我们之前说过,我们可以观察kubectl工具执行的HTTP请求。让我们验证一下。我们将执行最后一个命令以获取 Pod 的列表,但使用相同的额外详细级别,就像这样:

$ kubectl get pods -v6

输出应该类似于以下内容:

有一堆关于从集群缓存获取信息的日志行,但最后一行特别有趣,它包含了kubectl正在进行的实际HTTP请求:

GET https://192.168.99.100:8443/api/v1/namespaces/default/pods

如果您现在使用此 URL 运行curl GET命令,所有身份验证和授权机制都会生效。但是通过运行api-server代理,我们可以通过在代理端口上执行调用来跳过授权和身份验证(请注意,curl默认执行GET方法):

$ curl http://localhost:8080/api/v1/namespaces/default/pods

作为输出,您将获得包含有关集群中 Pod 的详细信息的 JSON 响应。API 正在工作,正如您在以下截图中所看到的:

删除服务和部署

如果您决定进行一些清理工作,您可以通过执行HTTP DELETE请求来删除服务和部署,例如:

$ curl http://localhost:8000/ \ apis/extensions/v1beta1/namespaces/default/deployments/rest-example \ 

-XDELETE

$ curl http://localhost:8080/ \ api/v1/namespaces/default/services/rest-example -XDELETE

通过查看 Web 文档或窥探kubectl调用的 URL 来找到正确的 API 操作REST路径(端点)可能非常不方便。有一种更好的方法;Kubernetes api-server的 OpenAPI 规范。让我们看看如何获取这个规范。

Swagger 文档

Kubernetes 的api-server利用 OpenAPI 规范提供了可用 API 命令的列表。OpenAPI 规范定义了一种标准的、与语言无关的 REST API 接口,允许人类和计算机在没有访问源代码、文档或通过网络流量检查的情况下发现和理解服务的能力。使用随 Kubernetesapi-server一起提供的 SwaggerUI 工具浏览 API 命令目录非常方便。您也可以使用 SwaggerUI 执行 HTTP 命令。

请注意,如果您正在使用 Minikube 运行本地集群,默认情况下未启用 SwaggerUI。您需要在集群启动期间使用以下命令启用它:

$ minikube start --extra-config=apiserver.Features.EnableSwaggerUI=true

在端口8080上仍在运行api-server代理的情况下,访问以下主机在您的 Web 浏览器中查看 SwaggerUI 屏幕:

http://localhost:8080/swagger-ui/

您将看到一个可用 API 命令的列表,分组为 API 组:

展开每个 API 部分将为您提供所有可用的端点以及每个操作的描述。SwaggerUI 是一个探索 API 的清晰可读形式的绝佳工具。

摘要

正如您所看到的,Kubernetes 公开的 API 是您工具库中非常强大的工具。可以通过仪表板或kubectl客户端执行的任何任务都作为 API 公开。您可以通过使用HTTP调用简单地执行集群中的几乎任何操作。Kubernetes 采用 API 优先的方法,使其可编程和可扩展。正如我们所看到的,使用 API 很容易入门。我们的服务和部署创建示例可能很简单,但应该让您了解如何使用api-server进行实验。使用 API,您不仅可以从命令行使用kubectl,还可以从您自己的应用程序、构建脚本或持续交付流水线中创建和检索集群资源。只有您的想象力和天空是极限,说到天空,现在是时候移动到那里,看看 Kubernetes 如何在云中使用了。

第十章:在云中部署 Java 在 Kubernetes 上

在之前的章节中,我们已经成功在本地运行了 Kubernetes 集群。使用minikube是学习 Kubernetes 和在自己的机器上进行实验的好方法。由minikube支持的集群的行为与在服务器上运行的普通集群完全相同。然而,如果您决定在生产环境中运行集群软件,云是最佳解决方案之一。在本章中,我们将简要介绍在 Docker 上运行微服务的情况下使用云环境的优势。接下来,我们将在 Amazon AWS 上部署我们的 Kubernetes 集群。配置 AWS 并在其上运行 Kubernetes 并不是从一开始就最简单和直接的过程,但是,遵循本章将为您提供一个过程概述,您将能够快速运行自己的云集群,并在其上部署自己或第三方的 Docker 镜像。

涵盖的主题列表包括:

  • 使用云、Docker 和 Kubernetes 的好处

  • 安装所需工具

  • 配置 AWS

  • 部署集群

让我们从使用云部署的 Kubernetes 集群的优势开始。

使用云、Docker 和 Kubernetes 的好处

在 Kubernetes 集群上部署应用程序有其优势。它具有故障弹性、可扩展性和高效的架构。拥有自己的基础设施和使用云有什么区别?嗯,这归结为几个因素。首先,它可以显著降低成本。对于小型服务或应用程序,当不使用时可以关闭,因此在云中部署应用程序的价格可能更低,由于硬件成本更低,将更有效地利用物理资源。您将不必为不使用计算能力或网络带宽的节点付费。

拥有自己的服务器需要支付硬件、能源和操作系统软件的费用。Docker 和 Kubernetes 是免费的,即使用于商业目的;因此,如果在云中运行,云服务提供商的费用将是唯一的成本。云服务提供商经常更新其软件堆栈;通过拥有最新和最好的操作系统软件版本,您可以从中受益。

在计算能力或网络带宽方面,像亚马逊或谷歌这样的大型云提供商很难被击败。他们的云基础设施非常庞大。由于他们为许多不同的客户提供服务,他们购买大型高性能系统,其性能水平远高于小公司内部运行的水平。此外,正如您将在本章的后续部分中看到的,云提供商可以在几分钟甚至几秒钟内启动新的服务器或服务。因此,如果有需要,新的实例将以几乎对软件用户透明的方式被带到生活中。如果您的应用程序需要处理大量请求,有时在云中部署它可能是唯一的选择。

至于容错性,因为云提供商将其基础设施分布在全球各地(例如 AWS 区域,您将在本章后面看到),您的软件可以是无故障的。没有任何单一事故,如停电、火灾或地震,可以阻止您的应用程序运行。将 Kubernetes 加入到方程式中可以扩展部署的规模,增加应用程序的容错性,甚至将完全失败的机会降低到零。

让我们将软件移到云端。为此,我们需要先创建一个工具集,安装所需的软件。

安装工具

要能够在 Amazon EC2 上管理 Kubernetes 集群,我们需要先安装一些命令行工具。当然,也可以使用 Amazon EC2 的 Web 界面。启动集群是一个相当复杂的过程;您需要一个具有适当访问权限和权限的用户,用于集群状态的存储,运行 Kubernetes 主节点和工作节点的 EC2 实例等。手动完成所有操作是可能的,但可能会耗时且容易出错。幸运的是,我们有工具可以自动化大部分工作,这将是 AWS 命令行客户端(awscli)和kops,Kubernetes 操作,生产级 K8s 安装,升级和管理。不过有一些要求。Kops在 Linux 和 macOS 上运行,它是用 Go 编写的,就像 Docker 一样。awscli是用 Python 编写的,所以让我们先专注于 Python 安装。

Python 和 PIP

运行 AWS 命令行工具(awscli),我们需要在我们的机器上安装python3

它可能已经存在,您可以使用以下命令进行验证:

$ python3 --version

如果输出是command not found,最快的安装方法将是使用系统上的软件包管理器,例如 Debian/Ubuntu 上的apt,Fedora 上的yum,或 macOS 上的 Homebrew。如果您在 macOS 上工作并且尚未安装 Homebrew,我强烈建议您这样做;它是一个很棒的工具,可以让您轻松安装成千上万的软件包以及所有所需的依赖项。Homebrew 可以免费获取brew.sh/。要安装它,请执行以下命令:

$ ruby -e "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/master/install)"

从现在开始,您应该在 macOS 终端中可以使用brew命令。

要在 Linux 上使用apt软件包管理器(在 Debian 或 Ubuntu 上)安装 Python,请执行以下命令:

$ sudo apt-get update

$ sudo apt-get install python3.6

在 macOS 上,这将是以下命令:

$ brew install python3

安装 Python 的过程取决于您的计算机速度和互联网连接速度,但不应该花费太长时间。一旦安装了 Python,我们将需要另一个工具,即pippip是安装 Python 软件包的推荐工具。它本身是用 Python 编写的。您可以使用您选择的软件包管理器来安装它,例如在 Ubuntu 或 Debian 上执行以下命令:

$ sudo apt-get install python3-pip

安装pip的另一种方法是使用安装脚本。在这种情况下,Linux 和 macOS 的过程完全相同。首先,我们需要使用以下命令下载安装脚本:

$ curl -O https://bootstrap.pypa.io/get-pip.py

过一段时间,我们需要通过执行以下命令运行安装脚本:

$ python3 get-pip.py -user

过一段时间,pip应该可以在终端 shell 中使用。要验证它是否正常工作,请执行以下命令:

$ pip -V

or 

$ pip --version

现在我们已经安装并正常运行 Python 和 pip,是时候转向更有趣的事情了,安装 Amazon AWS 命令行工具。

AWS 命令行工具

Amazon AWS 命令行工具awscli)界面是管理 AWS 服务的统一工具。awscli是建立在 AWS SDK for Python 之上的,它提供了与 AWS 服务交互的命令。只需进行最小配置(实际上,提供登录 ID 和密码就足够了,我们马上就会做),您就可以开始使用 AWS 管理控制台 Web 界面提供的所有功能。此外,awscli不仅仅是关于 EC2,我们将用它来部署我们的集群,还涉及其他服务,例如 S3(存储服务)。

要安装awscli,执行以下pip命令:

$ pip3 install --user --upgrade awscli

过一会儿,pip将在驱动器的python3文件夹结构中下载并安装必要的文件。在 macOS 和 Python 3.6 的情况下,它将是~/Library/Python/3.6/bin。将此文件夹添加到您的PATH环境变量中非常方便,以便在 shell 中的任何位置都可以使用。这很简单;您需要编辑其中一个文件中的PATH变量,具体取决于您使用的 shell:

  • Bash.bash_profile.profile.bash_login

  • Zsh.zshrc

  • Tcsh.tcshrc.cshrc.login

在 macOS 上,PATH条目可能看起来与此相同:

export PATH=~/Library/Python/3.6/bin/:$PATH

重新登录或启动新的终端后,您可以通过执行以下命令来验证aws命令是否可用:

$ aws -version

正如您在输出中所看到的,这将为您提供详细的aws命令行工具版本,还有它运行的 Python 版本:

awscli已准备就绪,但我们还有一个工具要添加到我们的工具设置中。这将是 Kubernetes kops

Kops

Kubernetes 操作或简称kops是生产级 Kubernetes 安装、升级和管理工具。它是一个命令行实用程序,可帮助您在 AWS 上创建、销毁、升级和维护高可用的 Kubernetes 集群。该工具官方支持 AWS。您可以在 GitHub 上找到kops的发布版本:github.com/kubernetes/kops/releases

要在 macOS 或 Linux 上安装,您只需要下载二进制文件,更改权限为可执行,然后就完成了。例如,要下载,请执行:

$ wget \ https://github.com/kubernetes/kops/releases/download/1.6.1/kops-darwin-amd64 

$ chmod +x kops-darwin-amd64

$ mv kops-darwin-amd64 /usr/local/bin/kops

或者,如果您使用 Linux,请执行以下命令:

$ wget \ https://github.com/kubernetes/kops/releases/download/1.6.2/kops-linux-amd64

$ chmod +x kops-linux-amd64

$ mv kops-linux-amd64 /usr/local/bin/kops

另外,再次使用软件包管理器将是获取最新的kops二进制文件的最简单方法,例如在 macOS 上使用brew

$ brew update && brew install kops

请注意,您必须安装kubectlkubernetes.io/docs/tasks/tools/install-kubectl/)才能使kops正常工作。如果您使用软件包管理器,kubectl的依赖关系可能已经在kops软件包中定义,因此将首先安装kubernetes-cli

最后一个工具是jq。虽然不是强制性的,但在处理 JSON 数据时非常有用。所有 AWS、Kubernetes 和kops命令都将发布和接收 JSON 对象,因此安装jq工具非常方便,我强烈建议安装jq

jq

jq是一个命令行 JSON 处理器。它的工作原理类似于 JSON 数据的sed;您可以使用它来过滤、解析和转换结构化数据,就像sedawkgrep让您处理原始文本一样容易。Jq可在 GitHub 上找到stedolan.github.io/jq/。安装非常简单;它只是一个单一的二进制文件,适用于 Windows、macOS 和 Linux。只需下载它并将其复制到系统PATH上可用的文件夹中,以便能够从 shell 或命令行中运行它。

假设在开始使用 kops 之前我们已经安装了所有工具,我们需要首先配置我们的 AWS 账户。这将创建一个管理员用户,然后使用aws命令行工具创建用于运行kops的用户。

配置 Amazon AWS

在设置 Kubernetes 集群之前,AWS 的配置基本上是创建一个用户。所有其他工作将由kops命令更多或更少地自动完成。在我们可以从命令行使用kops之前,最好有一个专门用于kops的用户。但首先,我们需要创建一个管理员用户。我们将从 Web 管理控制台进行操作。

创建一个管理员用户

根据您选择的 AWS 区域,AWS 管理控制台可在console.aws.amazon.com的子域上使用,例如eu-central-1.console.aws.amazon.com。登录后,转到安全、身份和合规性部分的 IAM 页面,然后切换到用户页面,然后单击“添加用户”按钮。

您将看到用户创建屏幕:

我们将需要这个用户来使用awscli,所以我们需要标记的唯一选项是程序化访问。单击“下一步:权限”,让我们通过将其添加到admin组来为我们的admin用户提供完整的管理权限。

在用户创建向导的最后一页,您将能够看到访问密钥 ID 和秘密访问密钥 ID。不要关闭页面,我们将在短时间内需要两者来使用awscli进行身份验证:

就是这样。我们已经创建了一个具有所有管理权限的管理员用户,并获得了访问密钥。这就是我们使用awscli管理 AWS 实例所需要的一切。使用admin用户运行kops可能不是最好的主意,所以让我们为此创建一个单独的用户。然而,这次我们将从命令行进行操作。与在 Web 控制台上点击 UI 相比,这将更加容易。首先,让我们使用管理员用户的 Access Key ID 和Secret access key ID进行身份验证,这些信息显示在用户创建向导的最后一页上。

为 kops 创建用户

kops用户需要在 AWS 中具有以下权限才能正常运行:

  • AmazonEC2FullAccess

  • AmazonS3FullAccess

  • AmazonRoute53FullAccess

  • IAMFullAccess

  • AmazonVPCFullAccess

首先,我们将创建一个名为kops的组,并为该组分配所需的权限。执行以下命令列表来创建一个组并分配权限:

$ aws iam create-group --group-name kops

$ aws iam attach-group-policy --policy-arn $ arn:aws:iam::aws:policy/AmazonEC2FullAccess --group-name kops

$ aws iam attach-group-policy --policy-arn arn:aws:iam::aws:policy/AmazonS3FullAccess --group-name kops

$ aws iam attach-group-policy --policy-arn arn:aws:iam::aws:policy/AmazonRoute53FullAccess --group-name kops

$ aws iam attach-group-policy --policy-arn arn:aws:iam::aws:policy/IAMFullAccess --group-name kops

$ aws iam attach-group-policy --policy-arn arn:aws:iam::aws:policy/AmazonVPCFullAccess --group-name kops

create-group命令将给您一些 JSON 响应,但是如果一切顺利,当将权限(组策略)附加到组时将不会有响应:

接下来,让我们创建kops IAM 用户并将用户添加到kops组,使用以下命令:

$ aws iam create-user --user-name kops

$ aws iam add-user-to-group --user-name kops --group-name kops

如果您感兴趣,现在可以登录到 Web AWS 控制台。您会看到我们的kops用户拥有我们需要的所有权限:

要列出所有注册用户,请执行以下命令:

$ aws iam list-users 

正如您在以下截图中所看到的,我们现在应该有两个用户:adminkops

关于我们的新kops用户,我们需要做的最后一件事就是生成访问密钥。我们将需要它们来使用aws configure命令进行身份验证。执行以下操作为kops用户生成访问密钥:

$ aws iam create-access-key --user-name kops

正如您在以下截图中所看到的,AWS 将以包含AccessKeyIdSecretAccessKey的 JSON 响应进行回答;在使用aws configure命令进行身份验证时,我们将需要这两者:

现在我们需要做的就是使用aws configure命令进行身份验证,提供我们在响应中获得的AccessKeyIdSecretAccessKey。执行以下操作:

$ aws configure 

因为aws configure命令不会为kops导出这些变量以供使用,所以我们现在需要导出它们:

$ export AWS_ACCESS_KEY_ID=<access key>

$ export AWS_SECRET_ACCESS_KEY=<secret key>

就是这样,我们已经使用名为kops的新用户进行了身份验证,该用户具有启动 Kubernetes 集群所需的所有权限。从现在开始,我们执行的每个kops命令都将使用 AWS kops用户。现在是时候回到重点并最终创建我们的集群了。

创建集群

我们将创建一个包含一个主节点和两个工作节点的简单集群。要使用kops进行操作,我们需要:

  • 用户配置文件在~/.aws/credentials中声明(如果您使用aws configure进行身份验证,则会自动完成)。

  • 用于存储kops集群状态的 S3 存储桶。为了存储我们集群及其状态的表示,我们需要创建一个专用的 S3 存储桶供kops使用。这个存储桶将成为我们集群配置的真相来源。

  • 已配置 DNS。这意味着我们需要在同一 AWS 账户中拥有一个 Route 53 托管区域。Amazon Route 53 是一个高可用性和可扩展的云域名系统DNS)网络服务。Kops 将使用它来创建集群所需的记录。如果您使用更新的 kops(1.6.2 或更高版本),则 DNS 配置是可选的。相反,可以轻松地创建一个基于 gossip 的集群。为了简单起见,我们将使用基于 gossip 的集群。为了使其工作,集群名称必须以k8s.local结尾。让我们看看关于 DNS 设置的其他选项。

DNS 设置

基本上,我们的集群域名有四种可能的情况:托管在 AWS 上的根域,托管在 AWS 上的域的子域,在其他地方托管的域使用亚马逊 Route 53,最后,在 Route 53 中设置集群的子域,同时在其他地方设置根域。现在让我们简要地看一下这些设置。

托管在 AWS 上的根域

如果您在 AWS 上购买并托管了您的域名,那么您可能已经自动配置了 Route 53。如果您想要使用此根级域名用于您的集群,您无需做任何操作即可使用该域名与您的集群。

托管在 AWS 上的域的子域

如果您在 AWS 上购买并托管了您的域名,但想要将子域用于集群,您需要在 Route 53 中创建一个新的托管区域,然后将新路由委派给这个新区域。基本上就是将您的子域的 NS 服务器复制到 Route 53 中的父域。假设我们的域是mydomain.com;我们首先需要获取一些信息。请注意,当执行aws命令时,现在jq命令行工具非常方便。首先,我们需要我们主要父区域的 ID:

$ aws route53 list-hosted-zones | jq '.HostedZones[] \ 

| select(.Name=="mydomain.com.") | .Id'

要创建新的子域,请执行以下操作:

$ aws route53 create-hosted-zone --name myservice.mydomain.com \ 

--caller-reference $ID | jq .DelegationSet.NameServers

请注意,上一个命令将列出新域的名称服务器。如果您之前创建了子域,并且想要列出名称服务器(以便首先将 NS 服务器列表复制到父区域,我们需要知道它们),请执行以下命令以获取子域区域 ID:

$ aws route53 list-hosted-zones | jq '.HostedZones[] | \ select(.Name==" myservice.mydomain.com.") | .Id'

有了子域区域的 ID,我们可以通过执行以下命令列出其名称服务器:

$ aws route53 get-hosted-zone --id <your-subdomain-zoneID> \

| jq .DelegationSet.NameServers

到目前为止,我们有父区域的区域 ID,子域区域的 ID 和子域名称服务器列表。我们准备好将它们复制到父区域中了。最方便的方法是准备 JSON 文件,因为输入内容相当长。文件将如下所示:

{

 "Changes": [

 {

 "Action": "CREATE",

 "ResourceRecordSet": {

 "Name": "myservice.mydomain.com",

 "Type": "NS",

 "TTL": 300,

 "ResourceRecords": [

 {

 "Value": "ns-1.awsdns-1.com"

 },

 {

 "Value": "ns-2.awsdns-2.org"

 },

 {

 "Value": "ns-3.awsdns-3.com"

 },

 {

 "Value": "ns-4.awsdns-4.net"

 }

 ]

 }

 }

 ]

}

您需要将此保存为文件,比如my-service-subdomain.json,并执行最后一个命令。它将把名称服务器列表复制到父区域中。

$ aws route53 change-resource-record-sets 

--change-batch file://my-service-subdomain.json \

--hosted-zone-id <your-parent-zone-id>

一段时间后,所有发送到*.myservice.mydomain.com的网络流量将被路由到 AWS Route 53 中正确的子域托管区域。

使用另一个注册商购买的域名的 Route 53

如果您在其他地方购买了域名,并且想要将整个域专用于您的 AWS 托管集群,情况可能会有些复杂,因为此设置要求您在另一个域名注册商处进行重要更改。

如果您的域名注册商也是域名的 DNS 服务提供商(实际上,这种情况非常常见),建议在继续域名注册转移过程之前将您的 DNS 服务转移到 Amazon Route 53。

这样做的原因是,当您转移注册时,之前的注册商可能会在他们收到来自 Route 53 的转移请求后禁用该域的 DNS 服务。因此,您在该域上拥有的任何服务,如 Web 应用程序或电子邮件,可能会变得不可用。要将域注册转移到 Route 53,您需要使用 Route 53 控制台,该控制台位于console.aws.amazon.com/route53/。在导航窗格中,选择 Registered Domains,然后选择 Transfer Domain,并输入您想要转移的域的名称,然后单击 Check。如果该域不可转移,控制台将列出可能的原因以及处理它们的推荐方法。如果一切正常并且该域可以转移,您将有选项将其添加到购物车中。然后,您需要输入一些详细信息,例如您的联系信息,用于转移的授权代码(您应该从之前的注册商那里获取),以及名称服务器设置。我强烈建议选择 Route 63 托管的 DNS 服务器,因为它非常容易配置且可靠。Route 63 将负责与您之前的注册商进行通信,但您可能会收到一些需要确认的电子邮件。转移过程可能需要更长的时间,但完成后,您可以继续以与前两种情况相同的方式配置基于 AWS 的集群的域。

AWS Route 53 中集群的子域,域名在其他地方

如果您在亚马逊以外的注册商那里注册了您的域,并且想要使用该域的子域指向您的集群,您需要修改您注册商中的名称服务器条目。这将需要在 Route 53 中创建一个新的托管区子域,然后将该子域的名称服务器记录迁移到您的注册商。

与托管在 AWS 上的域上的子域类似,让我们首先创建一个子域,通过执行以下命令:

$ aws route53 create-hosted-zone \

--name myservice.mydomain.com \

--caller-reference $ID | jq .DelegationSet.NameServers

上一个命令的输出将列出子域的名称服务器。您需要登录到您的注册商设置页面,并创建一个新的子域,提供从上一个命令中收到的四个名称服务器记录。您可以在您特定的注册商帮助指南中找到有关如何编辑您域的名称服务器的详细说明。

之前的指南应该使您的集群在特定域或子域下可用。然而,在本章的其余部分,我们将运行基于流言的集群。

在 AWS 上创建任何内容之前,我们必须查看可用的区域。您应该知道,Amazon EC2 托管在全球多个位置。这些位置由区域和可用区组成。每个区域是一个单独的地理区域。每个区域都有多个隔离的位置,称为可用区。您可以选择您想要的位置,但首先,您需要检查可用的区域。现在让我们这样做。

检查区域的可用性

要列出特定区域可用的区域,请执行以下命令:

$ aws ec2 describe-availability-zones --region eu-central-1

如您在以下截图中所见,AWS 将在响应中列出可用的区域:

创建存储

我们的集群需要在某个地方存储其状态。Kops 使用 Amazon S3 存储桶来实现这一目的。S3 存储桶是Amazon Web ServicesAWS)对象存储服务Simple Storage SolutionS3)中的逻辑存储单元。存储桶用于存储对象,对象由描述数据的数据和元数据组成。要创建一个存储桶,请执行以下aws命令:

$ aws s3api create-bucket \

--bucket my-cluster-store \

--region eu-central-1 \

--create-bucket-configuration LocationConstraint=eu-central-1

如您在以下截图中所见,AWS 将向您提供有关存储位置的简明信息:

创建存储后,我们需要在创建集群时使其对kops可用。为此,我们需要将存储桶的名称导出到KOPS_STATE_STORE环境变量中:

$ export KOPS_STATE_STORE=s3://my-cluster-store

我们现在准备创建一个集群。

在你记得的时候,我们将使用基于流言的集群,而不是配置的 DNS,因此名称必须以k8s.local结尾。

创建一个集群

首先将我们的集群名称导出到环境变量中。这将很有用,因为我们经常会引用集群的名称。执行以下命令导出集群名称:

$ export NAME=my-rest-cluster.k8s.local

kops create cluster是我们将用来创建集群的命令。请注意,这不会影响我们的 Amazon EC2 实例。该命令的结果只是一个本地集群模板,我们可以在在 AWS 上进行真正的物理更改之前进行审查和编辑。

命令的语法非常简单:

$ kops create cluster [options]

该命令有很多选项;您可以在 GitHub 上始终找到最新的描述,网址为github.com/kubernetes/kops/blob/master/docs/cli/kops_create_cluster.md 。让我们专注于最重要的几个:

选项 描述
--master-count [number] 设置主节点的数量。默认值是每个主区域一个主节点。
--master-size [string] 设置主节点的实例大小,例如:--master-size=t2.medium
--master-volume-size [number] 设置主节点实例卷大小(以 GB 为单位)。
--master-zones [zone1,zone2] 指定要运行主节点的 AWS 区域(这必须是奇数)。
--zones [zone1,zone2 ] 用于运行集群的区域,例如:--zones eu-central-1a,eu-central-1b
--node-count [number] 设置节点的数量。
--node-size [string] 设置节点的实例大小,例如:--node-size=t2.medium
--node-volume-size int32 设置节点的实例卷大小(以 GB 为单位)。

如果您想将您的集群设置为私有的(默认情况下是公共的),您还需要考虑使用以下选项:

选项 描述
--associate-public-ip [true&#124;false] 指定是否要为您的集群分配公共 IP。
--topology [public&#124;private] 指定集群的内部网络拓扑,可以是publicprivate
--bastion --bastion标志启用了一个堡垒实例组。该选项仅适用于私有拓扑。它将为集群实例的 SSH 访问生成一个专用的 SSH 跳转主机。跳转主机提供了进入集群私有网络的入口点。它可以启动和停止,以启用或禁用来自互联网的入站 SSH 通信。

现在让我们使用以下命令创建我们的集群:

$ kops create cluster --v=0 \

--cloud=aws --node-count 2 \

--master-size=t2.medium \

--master-zones=eu-central-1a \

--zones eu-central-1a,eu-central-1b  \

--name=${NAME} \

--node-size=t2.medium

在响应中,kops将列出已创建的配置的所有细节,并建议您可以采取的新集群配置的一些下一步操作:

运行命令后,kops将配置您的kubectl Kubernetes 客户端指向您的新集群;在我们的示例中,这将是my-rest-cluster.k8s.local

正如我们之前所说,在这个阶段,只创建了集群的模板,而不是集群本身。您仍然可以通过编辑您的集群来更改任何选项:

$ kops edit cluster my-rest-cluster.k8s.local

这将启动你在 shell 中定义的默认编辑器,在那里你可以看到已生成的集群模板。它将包含更多的设置,不仅仅是你在运行cluster create命令时指定的那些:

如果你对你的集群模板满意,现在是时候启动它,创建真正的基于云的资源,比如网络和 EC2 实例。一旦基础设施准备好,kops将在 EC2 实例上安装 Kubernetes。让我们开始吧。

启动集群

要启动集群并启动所有必要的 EC2 实例,你需要执行update命令。kops手册建议你首先在预览模式下执行,不要使用--yes开关。这不会启动任何 EC2 实例:

$ kops update cluster ${NAME} 

如果一切看起来正确,使用--yes开关执行更新命令:

$ kops update cluster ${NAME} --yes

你的集群正在启动,应该在几分钟内准备就绪。如果你现在登录到 WAS 管理控制台,你会看到你的 EC2 实例正在启动,就像你在下面的截图中看到的那样:

你也可以通过发出以下命令来检查整个集群状态:

$ kops validate cluster

输出将包含有关集群节点数量和状态的信息,包括主节点:

当然,由于kubectl现在配置为在我们的 AWS 集群上操作,我们可以使用kubectl get nodes命令列出节点,就像我们在第九章中使用minikube基础集群一样。执行以下命令:

$ list nodes: kubectl get nodes --show-labels

将会给你提供有关你的集群节点名称和状态的信息:

更新集群

Kops的行为类似于kubectl;你可以在编辑器中编辑配置文件,然后再实际对集群进行任何更改。kops update命令将应用配置更改,但不会修改正在运行的基础设施。要更新运行中的集群,你需要执行rolling-update命令。以下将启动集群基础设施的更新或重建过程:

 $ kops 

rolling

-

update 

cluster

 –

yes 

我们的新集群正在运行,但是它是空的。让我们部署一些东西。

安装仪表板

当集群运行时,部署一个仪表板会很好,以查看您的服务、部署、Pod 等的状态。仪表板默认包含在 minikube 集群中,但是在我们全新的亚马逊集群上,我们需要手动安装它。这是一个简单的过程。由于我们已经配置了 kubectl 来操作远程集群,我们可以使用 kubernetes-dashboard.yaml 模板作为输入执行以下 kubectl create 命令:

$ kubectl create -f \

https://rawgit.com/kubernetes/dashboard/master/src/deploy

kubernetes-dashboard.yaml

接下来要做的事情是代理网络流量,使用我们已经知道的以下 kubectl proxy 命令:

$ kubectl proxy

就是这样!过一会儿,仪表板将被部署,我们将能够使用本地主机地址访问它:

http://localhost:8001/,如下截图所示,是我们在第九章中已经看到的相同的仪表板,使用 Kubernetes API

从现在开始,您可以使用 kubectl 和仪表板来管理您的集群,就像我们在第九章中所做的那样,使用 Kubernetes API。所有 kubectl create 命令将与本地集群一样工作。但是,这一次,您的软件将部署到云端。

如果您决定删除集群,请执行以下命令:

$ kops delete cluster -name=${NAME} --yes

请注意,如果您只是创建了集群模板,而没有首先执行 kops update cluster ${NAME} --yes,您也可以删除集群,如下截图所示:

如果集群已经在亚马逊上创建,删除过程将需要更长时间,因为首先需要关闭所有主节点和工作节点的 EC2 实例。

摘要

在本章中,我们在真正的云端,亚马逊 AWS 上设置了一个集群。Kops是我们目前可用的最好的工具之一,用于在 AWS 上管理 Kubernetes。使用它,您可以轻松地在 AWS 上创建和管理集群。它可以是一个测试或生产级别的集群;kops将使其创建和管理变得轻而易举。

第十一章:更多资源

我们已经结束了我们的 Docker 和 Kubernetes 之旅。阅读完这本书后,您应该已经知道 Kubernetes 如何补充 Docker。您可以将它们视为软件堆栈的不同层;Docker 位于下方,为单个容器提供服务,而 Kubernetes 在集群中编排和管理它们。Docker 变得越来越受欢迎,很多人在开发或生产部署中使用它。举几个例子,PayPal、通用电气、Groupon、Spotify 和 Uber 都在使用它。它已经足够成熟,可以在生产环境中运行,我希望您也能成功地使用它来部署和运行您的 Java 应用程序。

要进一步扩展您对 Docker 和 Kubernetes 的知识,有大量的信息。关键是找到有价值的信息。在本章中,我将介绍最有用的信息,如果您想进一步扩展您的 Docker 和 Kubernetes 知识。

Docker

我们列表上的第一个将是令人敬畏的 Docker 列表。

令人敬畏的 Docker

在 GitHub 上可以找到令人敬畏的 Docker,网址为veggiemonk.github.io/awesome-docker/。作者经常更新列表,因此您可以在本地克隆 Git 存储库,并定期更新以查看新内容。令人敬畏的 Docker 包含诸如 Docker 简介、工具(包括开发工具、测试或实用工具)等部分。视频部分在学习 Docker 时尤其有用,您可以在这里找到教程和培训。除了这个列表,很难找到更多有用的信息。

博客

我建议继续学习有关 Docker 的第一个博客将是 Arun Gupta 的博客,网址为blog.arungupta.me。Arun 于 2014 年 7 月开始首次撰写有关 Docker 的博客,他是 Couchbase 的开发者倡导副总裁,也是 Java 冠军、JUG 领导者和 Docker 船长。他在博客上写了很多东西;您可以使用#docker标签过滤与 Docker 相关的内容,链接为:blog.arungupta.me/tag/docker/

您将在这里找到许多与 Java 开发和 Docker 相关的有用信息。他还编写了一篇很棒的 Docker 教程,可在 GitHub 上找到:github.com/arun-gupta/docker-tutorial

接下来是官方的 Docker 博客,位于blog.docker.com。您在这里找不到有关如何使用 Docker 的许多教程,但会有关于新版本及其功能的公告,更高级的 Docker 使用技巧,以及 Docker 活动等社区新闻。

红帽开发者计划,位于容器类别下,可在developers.redhat.com/blog/category/containers/找到许多关于 Docker 和容器技术的有用文章。

互动教程

网上有许多 Docker 教程,但我发现其中一个特别有趣。这是 Katakoda 的互动式 Docker 学习课程,可在www.katacoda.com/courses/docker找到。您将在这里找到 Docker 的完整功能集,从部署单个容器开始,然后涉及添加标签、检查容器和优化图像构建等主题。它是互动式的;您只需要一个现代浏览器,甚至不需要在本地机器上安装 Docker。它非常完整且学起来很有趣。另一个是training.play-with-docker.com。它包括三个部分:初学者,涵盖运行单个容器等基础知识,中级,涵盖网络等内容,以及高级,涵盖 Docker 安全性。其中一些课程任务是互动式的,您可以直接在浏览器中执行它们。

Kubernetes

当 Docker 开始变得更受欢迎时,对容器管理平台的需求开始引起关注。因此,关于 Kubernetes 的更多资源开始在互联网上出现。

令人敬畏的 Kubernetes

类似于其 Docker 对应物,位于 GitHub github.com/ramitsurana/awesome-kubernetes的令人敬畏的 Kubernetes 列表包含了许多关于 Kubernetes 的有用资源。您将在这里找到很多内容;从 Kubernetes 的介绍开始,通过有用工具和开发平台的列表,直到企业 Kubernetes 产品。甚至还有一个链接指向如何使用树莓派设备安装 Kubernetes 集群的教程!

教程

Kubernetes 官方网站包含许多有趣的教程,从基础知识开始,逐步介绍整个 Kubernetes 功能列表。教程列表可在kubernetes.io/docs/tutorials/上找到。如果您还没有按照我们的 Minikube 安装指南进行操作,我强烈建议您这样做,使用 Kubernetes 官方的 Bootcamp,这是一个互动式的基于 Web 的教程,其目标是使用 Minikube 部署本地开发 Kubernetes 集群。它可在kubernetes.io/docs/tutorials/kubernetes-basics/cluster-interactive/上找到。

博客

Kubernetes 官方博客可在blog.kubernetes.io/找到。您将在这里找到有关新发布的公告、有用的技术文章和有趣的案例研究。

红帽企业 Linux 博客还包含许多有关 Kubernetes 的有趣文章。它们都标有 Kubernetes 标签,因此您可以通过使用链接rhelblog.redhat.com/tag/kubernetes/轻松过滤出它们。

扩展

正如您所知,Kubernetes 支持扩展。有一个很好的资源跟踪许多 Kubernetes,可在github.com/coreos/awesome-kubernetes-extensions上找到。例如,如果您需要将某些证书管理器集成到您的架构中,您可能会在那里找到合适的扩展。

工具

除了有用的文章和教程之外,还有一些有用的工具或平台,使使用 Kubernetes 更加愉快。现在让我们简要介绍它们。

Rancher

Rancher,可在rancher.com找到,是一个在我们的书中值得单独介绍的平台。它是一个开源软件,可以轻松在任何基础设施上部署和管理 Docker 容器和 Kubernetes。您可以使用最完整的容器管理平台在任何基础设施上轻松部署和运行容器。

Helm 和图表

Kubernetes Helm(在 GitHub 上可用:github.com/kubernetes/helm)引入了图表的概念,这些图表是预配置的 Kubernetes 资源包,是为 Kubernetes 精心策划的应用程序定义。Helm 是一个管理图表的工具;它简化了安装和管理 Kubernetes 应用程序。可以将其视为 Kubernetes 的apt/yum/homebrew软件包管理器。您可以使用它来查找和使用打包为 Kubernetes 图表的热门软件,共享您自己的应用程序作为 Kubernetes 图表,并创建可重现的 Kubernetes 应用程序构建。当然,GitHub 上有一个专门的图表存储库:github.com/kubernetes/charts。目前,图表二进制存储库可在 Google Cloud 上使用:console.cloud.google.com/storage/browser/kubernetes-charts/,其中包含许多有用的预打包工具,如 Ghost(node.js博客平台)、Jenkins、Joomla、MongoDb、MySQL、Redis、Minecraft 等等。

Kompose

Kompose(github.com/kubernetes/kompose)是一个帮助将 Compose 配置文件移入 Kubernetes 的工具。Kompose 是一个用于定义和运行多容器 Docker 应用程序的工具。如果您是 Kompose 用户,可以使用它将多容器配置直接转换为 Kubernetes 设置,方法是将 Docker Compose 文件转换为 Kubernetes 对象。请注意,将 Docker Compose 格式转换为 Kubernetes 资源清单可能不会完全准确,但在首次在 Kubernetes 上部署应用程序时,它会有很大帮助。

Kubetop

Kubetop,同样可在 GitHub 上找到:github.com/LeastAuthority/kubetop,与 Kubernetes 集群的top命令相同。它非常有用;它列出了集群上所有正在运行的节点、它们上的所有 pod 以及这些 pod 中的所有容器。该工具为您提供有关每个节点的 CPU 和内存利用率的信息,类似于 Unix/Linux 的top命令。如果您需要快速了解集群上消耗最多资源的内容,这个快速的命令行工具是一个非常方便的选择。

Kube-applier

在 GitHub 上可以找到kube-appliergithub.com/box/kube-applier,它可以为你的 Kubernetes 集群提供自动化部署和声明性配置。它作为一个 Kubernetes 服务运行,获取托管在 Git 存储库中的一组声明性配置文件,并将它们应用于 Kubernetes 集群。

kube-applier作为一个 Pod 在你的集群中运行,并持续监视 Git 存储库,以确保集群对象与存储库中的相关spec文件(JSON 或 YAML)保持最新。该工具还包含一个状态页面,并提供用于监控的指标。我发现它在日常开发中非常有用,特别是在你的部署、服务或 Pod 定义经常发生变化的情况下。

正如你所看到的,网络上有很多关于 Docker 和 Kubernetes 的有用资源。阅读完这本书后,你可能会想跳过大部分基础知识,直接进入更高级的话题。所有这些资源最好的一点是它们都是免费的,所以基本上没有什么能阻止你去探索受管容器的美妙世界。尝试学习,如果时机成熟,那就继续使用 Docker 和 Kubernetes 来部署你的生产就绪的 Java 软件,无论是在你自己的基础设施上还是在云上。看到你的 Java 应用如何自我扩展并变得无故障将会是令人惊叹的。Docker 和 Kubernetes 使其成为可能,而你现在已经具备了使用它们的知识。Docker 和 Kubernetes 彻底改变了技术领域的面貌,我希望它也能改变你的开发和发布流程,使之变得更好。

posted @ 2024-05-06 18:37  绝不原创的飞龙  阅读(25)  评论(0编辑  收藏  举报