在本序章节中,我们将通过解释一些用于设计和实现云原生(Cloud Native)应用程序的 Kubernetes 核心概念,为阅读本书的其余部分奠定基础。 理解这些新的抽象概念,以及本书中的相关原理和模式,是构建基于云原生平台的分布式应用的关键。

本章不是理解后面章节中描述的设计模式的先决条件。熟悉 Kubernetes 核心概念的读者可以跳过本章,直接阅读您所感兴趣的模式类别章节。

云原生之路

Kubernetes 等云原生平台上最流行的应用架构是微服务架构。这种软件开发方式通过将业务能力模块化,以开发的复杂性换取运维的复杂性,来解决软件的复杂性。

作为微服务运动的一部分,有大量的理论和相关技术用于从头开始创建微服务,或者将单体拆分成微服务。这些实践大多基于 Eric Evans (Addison-Wesley) 所著的《领域驱动设计》一书以及有界上下文(Bounded Contexts)和聚合体(Aggregates)的概念。有界上下文通过将服务划分为不同的组件来处理大型服务模型,而聚合体则有助于进一步将有界上下文划分为具有定义事务边界的模块。然而,除了这些业务领域的考虑之外,对于每一个分布式系统 — 无论它是否基于微服务 — 还有许多围绕其组织结构和运行时行为的技术问题。

容器和容器编排平台(如 Kubernetes)为解决分布式应用所关注的问题提供了许多新的基本元素和抽象概念,在这里,我们会讨论将分布式系统放入 Kubernetes 时需要考虑的各种选项。在本书中,我们将容器和平台的交互看作是黑盒的。同时,我们创建了这一章节来强调容器中的内容的重要性。容器和云原生平台可以为分布式应用带来了巨大的好处,但如果您在容器中投入的都是垃圾,那么您将得到大规模的分布式垃圾。图 1-1 显示了创建良好的云原生应用所需的技能的组合。
image.png
图 1-1 创建良好的云原生应用所需的技能的组合

通常,一个云原生应用中会存在多个抽象的层次,因此需要不同的设计考量:

  • 在最底层的代码层面,你定义的每一个变量、创建的每一个方法以及决定实例化的每一个类都在应用的长期维护中起着作用。无论你使用什么容器技术和编排平台,开发团队和他们创建的工件都会产生最大的影响。重要的是,需要培养开发人员致力于努力得写出干净的代码(Clean Code),有适量的自动化测试,不断重构以提高代码质量,并且内心保有工匠精神。
  • 领域驱动设计(Domain-Driven Design)是指从业务角度出发进行软件设计,目的是使架构尽可能地接近真实世界。这种方法对于面向对象的编程语言效果最好,但也有其他好的方法来为现实世界的问题建模和设计软件。一个具有正确的业务和事务绑定的模型、易于使用的接口和丰富的 API 是以后成功实现容器化和自动化的基础。
  • 微服务架构风格(Microservices Architectural Style)非常迅速地发展成为规范,它为设计不断变化的分布式应用程序提供了宝贵的原则和实践。应用这些原则可以让您创建针对规模、弹性和变化速度进行优化的实现,这些都是当今任何现代软件的共同要求。
  • 容器(Container)很快就被采用为打包和运行分布式应用的标准方式。创建模块化、可重用的容器,使其成为良好的云原生公民是另一个基本前提。随着每个组织中容器数量的增加,就需要使用更有效的方法和工具来管理它们。云原生是一个相对较新的术语,用于描述大规模自动化容器化微服务的原则、模式和工具。Kubernetes 是目前最流行的开源云原生平台,因此通常我们也会将云原生与 Kubernetes 两个词交替使用。

在本书中,我们并不涉及清洁代码、领域驱动设计或微服务。我们只关注解决容器编排关注的模式和实践。但是为了让这些模式有效,你的应用需要从内部应用干净代码实践、领域驱动设计、微服务模式以及其他相关的设计技术来做好设计。

分布式基本元素

为了解释我们所说的新的抽象和基本元素(Primitives)的含义,这里我们将它们与众所周知的面向对象编程(OOP),特别是 Java 进行比较。在 OOP 的世界里,我们有类、对象、包、继承、封装和多态等概念。然后,Java 运行时提供了具体的功能和保障,以此来管理对象和整个应用程序的生命周期。

Java 语言和 Java 虚拟机(JVM)为创建应用程序提供了本地的、进程中的构建块。Kubernetes 为这一众所周知的思路增加了一个全新的维度,它提供了一套新的分布式基本元素(Distributed Primitives)和运行时,用于构建分布式系统,这些系统分布在多个节点和过程中。有了 Kubernetes 在手,我们就不会只依靠本地的构建元素来实现整个应用行为。

当然,我们仍然需要使用面向对象的构件来创建分布式应用的组件,但我们也可以使用 Kubernetes 基本元素来实现一些应用行为。表 1-1 显示了各种开发概念在本地(Java 语言环境)和分布式基本元素下的不同实现方式。
image.png
表 1-1 各种开发概念在本地(Java 语言环境)和分布式基本元素下的不同实现方式

可以看到本地和分布式构建的基本元素是很相似的,但它们不能直接比较和替换。它们在不同的抽象层次上运行,并具有不同的前提条件和运行要求;同时,有一些基本元素是可以一起使用的,例如我们仍然必须使用类来创建对象并将它们放入容器镜像中。然而,其他有一些基本元素,如 Kubernetes 中的 CronJob,可以完全取代 Java 中的 ExecutorService 类元素的功能。

接下来,就让我们一起更为深入地了解几个 Kubernetes 中面向分布式应用开发的、最为吸引人的基本元素。

容器(Containers)

容器是基于 Kubernetes 构建的云原生应用的核心构件。如果我们用 OOP 和 Java 做一个类比,容器镜像就像类,而容器就像对象。类似的,我们可以扩展类来重用和改变行为,我们同样可以通过容器镜像扩展其他容器镜像来重用和改变行为。和我们可以进行对象组合和引用功能一样,我们可以通过把容器放到 Pod 中,通过容器直接的协作来进行容器组合。

如果我们继续比较,Kubernetes 会像 JVM 一样,但它可以分布在多个主机上,并负责运行和管理容器。Init 容器类似于对象构造器;DaemonSets 类似于在后台运行的守护进程线程(例如,像 Java Garbage Collector)。Pod 则是类似于反转控制(IoC)上下文的东西(例如 Spring Framework),在这里,多个运行中的对象共享一个受管理的生命周期,并且可以直接访问对方。

这样的类比可能也是有限的,但重点是容器在 Kubernetes 中扮演着基础性的角色,而创建模块化、可重用、单一用途的容器镜像是任何项目乃至整个容器生态系统长期成功的基础。除了容器镜像代表了打包和隔离的技术特性外,容器代表什么,在分布式应用中它的作用是什么?这里有一些关于如何看待容器的思考:

  • 容器镜像是解决单一问题的功能单位。
  • 容器镜像由一个团队负责管理,并有一个发布周期。
  • 容器镜像是自给自足的,它定义并携带它的运行时依赖性。
  • 容器镜像是不可改变的,一旦建立,它就不会改变;它是可配置的。
  • 容器镜像有定义良好的 API 来暴露其功能。
  • 容器通常以单个 Unix 进程的形式运行。
  • 容器是无状态的,可以在任何时候安全地扩大或缩小规模。

除了所有这些特点,一个合适的容器镜像是模块化的。它可以通过参数化的配置被创建用于在不同的环境和场景中重复使用。通过小型的、模块化的、可重用的容器镜像,可以进而创建更专业的、长期稳定的容器镜像,这类似于编程语言世界中一个伟大的可重用库。

容器组(Pods)

从容器的特性来看,我们可以看到,容器是实现微服务原则的完美搭配。一个容器镜像提供了一个单一的功能单元,属于一个团队,具有独立的发布周期,并提供部署和运行时隔离。大多数情况下,一个微服务对应一个容器镜像。

然而,大多数云原生平台都提供了另一种用于管理一组容器生命周期的基本元素 — 在 Kubernetes 中,它被称为 Pod。Pod 是一组容器的调度、部署和运行时隔离的原子单位。一个 Pod 中的所有容器总是被调度到同一个主机上,不管是出于扩展还是主机迁移的目的,都会被部署在一起,并且还可以共享文件系统、网络和进程名称空间。这种联合生命周期允许 Pod 中的容器通过文件系统或通过网络通过本地主机或主机进程间通信机制进行交互,如果需要的话(例如出于性能原因)。

从图 1-2 中可以看出,在开发和构建时,一个微服务是由一个团队开发并发布的容器镜像来响应的。但在运行时,微服务由 Pod 表示,它是部署、放置和扩展的单位。运行容器的唯一方法 — 无论是扩展还是迁移,都要通过 Pod 抽象。有时,一个 Pod 包含多个容器。一个这样的例子是,当一个容器化的微服务在运行时使用一个帮助容器时,就像第 15 章 “Sidecar” 后面演示的那样。
image.png
图 1-2 一个 Pod 是部署、放置和扩展的原子单位

容器和容器组(Pod)及其独特的特性为设计基于微服务的应用程序提供了一套新的模式和原则。我们看了一些设计良好的容器的特征,现在让我们看看 Pod 的一些特征:

  • Pod 是调度的原子单位。这意味着调度器试图找到一个满足属于 Pod 的所有容器需求的主机(围绕 Init 容器有一些特殊性,我们在第 14 章 “Init 容器” 中介绍)。如果你创建了一个有很多容器的 Pod,调度器需要找到一个有足够资源的主机,以满足所有容器需求的组合。这个调度过程在第 6 章 “自动放置”(Auomated Placement)中描述。
  • Pod 确保了容器的同城化(Colocation)。基于此特性,同一 Pod 中的容器有了额外的方式来相互交互。最常见的通信方式包括使用共享的本地文件系统交换数据,或使用本地主机网络接口,或使用主机进程间通信(IPC)机制进行高性能交互。
  • 一个 Pod 有一个 IP 地址、名称和端口范围,所有属于它的容器都可以共享。这就意味着同一 Pod 中的容器必须小心翼翼地避免端口冲突,就像并行运行的 Unix 程序在共享主机上的网络空间时必须注意一样。

Pod 是你的应用在 Kubernetes 中的原子形态,但通常我们不会直接访问 Pods — 那是服务(Services)大显身手的地方。

服务(Services)

Pod 是有生命周期的、不是永久存在的 — 它们可以在任何时候因为各种原因而来来去去,比如扩大和缩小规模,容器健康检查失败,以及节点迁移。Pod 的 IP 地址只有在它被调度并在节点上启动后才会被知道。如果一个 Pod 运行的现有节点不再健康,它可以被重新安排到不同的节点上。所有这一切都意味着 Pod 的网络地址可能会在应用的生命周期中发生变化,这就需要另一个基元来进行发现和负载均衡。

这就是 Kubernetes 的服务的作用。服务是另一个简单而强大的 Kubernetes 抽象,它将服务名与 IP 地址和端口号永久绑定。所以一个 Service 代表了一个访问应用的命名入口点。在最常见的情况下,Service 作为一组 Pod 的入口点,但情况可能并不总是如此。服务是一个通用的基本元素,它也可能指向 Kubernetes 集群之外提供的功能。因此,服务可用于服务发现和负载均衡,并允许在不影响服务消费者的情况下改变实现和扩展。我们讲在第 12 章 “服务发现” 中详细解释服务。

标签(Labels)

我们已经看到,微服务在构建时是一个容器,但在运行时由一个 Pod 来表示。那么,什么是一个由多个微服务组成的应用呢?在这里,Kubernetes 又提供了两个基本元素,可以帮助你定义应用的概念:标签和命名空间。首先我们来看“标签”。

在微服务体系之前,一个应用对应一个部署单元,有单一的版本方案和发布周期。一个应用有一个单一的文件,形式是 .war,或者 .ear 或者其他打包格式。但后来,应用程序被拆分成了微服务,微服务是独立开发、发布、运行、重启或扩展的。有了微服务,应用程序的概念就减少了,不再有关键的工件或活动需要我们在应用层面执行。然而,如果你仍然需要一种方法来表明一些独立的服务属于一个应用程序,可以使用标签。让我们想象一下,我们已经将一个单体应用程序分成了三个微服务,而另一个应用程序则分成了两个微服务。

我们现在有五个 Pod 声明(也许还有更多的 Pod 实例),这些 Pod 的运行环境各不相同。然而,我们可能仍然需要表明前三个 Pod 代表一个应用程序,而其他两个 Pod 代表另一个应用程序。即使 Pod 可能是独立提供业务价值的,但它们可能相互依赖。例如,一个 Pod 可能包含负责前端的容器,而另外两个 Pod 负责提供后端功能。如果这些 Pod 中的任何一个瘫痪了,从业务角度来看,这个应用程序就没有用了。使用标签选择器使我们能够查询和识别一组 Pod,并将其作为一个逻辑单元来管理。图 1-3 显示了如何使用标签将分布式应用的各个部分归为特定的子系统。
image.png
图 1-3 通过标签将分布式应用的各个 Pod 归为不同的子系统

下面是几个标签常见的使用场景:

  • 标签可被 ReplicaSets 使用,以保持特定 Pod 运行一定数量的实例。这意味着每个 Pod 的声明都需要有一个用于调度的唯一标签组合。
  • 标签也被调度器大量使用。调度器使用标签来将多个 Pod 集中或分散部署在各个节点上,使 Pod 可以被部署在满足 Pod 要求的节点上。
  • 标签可以表示一组 Pod 的逻辑分组,并给它们一个应用标识。(如图 1-3 所示)
  • 除了前面的典型用例,标签还可以用来存储元数据。可能很难预测一个标签的用途,但最好有足够的标签来描述 Pod 的所有重要方面的信息。例如,通过标签来表示应用程序的逻辑组、业务特征和关键性、特定的运行时平台依赖性(如硬件架构)或位置偏好都是不错的选择。之后,这些标签可以被调度器用来进行更精细的调度,也可以在命令行中使用相同的标签来批量管理匹配的 Pod。
    然而,你不应该过分的提前添加太多的标签。如果需要的话,你可以随时添加。移除标签的风险要大得多,因为没有直截了当的方法来找出一个标签的用途,以及这样的行为可能会引起什么意外的效果。


注释(Annotations)

另一个与标签非常相似的基本元素叫做注释。和标签一样,注释也是以 Map 数据结构组织的,但它们的目的是为了指定不可搜索的元数据,并且是为了机器使用而不是人类使用。

注释上的信息不是用来查询和匹配对象的。相反,它的目的是为了给我们想要使用的各种工具和库的对象附加额外的元数据。一些使用注释的例子包括构建 ID、发布 ID、图像信息、时间戳、Git 分支名、拉请求号、图像哈希、注册表地址、作者名、工具信息等。因此,标签主要用于查询匹配和对匹配的资源执行操作,而注释则用于附加可以被机器消耗的元数据。

命名空间(Namespaces)

另一个同样可以帮助管理一组资源的基本元素是 Kubernetes 命名空间。正如我们所描述的那样,命名空间看似与标签相似,但实际上,它是一个非常不同的基本元素,具有不同的特性和目的。

Kubernetes 命名空间允许将 Kubernetes 集群(通常分布在多个主机上)划分为一个逻辑资源池。命名空间为 Kubernetes 资源提供了使用范围,以及将授权和其他策略应用于集群的一个隔离的机制。命名空间最常见的用例是代表不同的软件环境,如开发、测试、集成测试或生产。命名空间也可以用来实现多租户,并为团队工作区、项目、甚至特定的应用程序提供隔离。但最终,对于某些环境的更大的隔离,命名空间是不够的,拥有独立的集群是常见的。通常情况下,有一个非生产 Kubernetes 集群用于某些环境(开发、测试和集成测试),另一个生产 Kubernetes 集群代表性能测试和生产环境。

让我们来看看命名空间的一些特点,以及它们如何在不同的情况下帮助我们:

  • 命名空间是作为 Kubernetes 的资源来管理的。
  • 命名空间为容器、Pod、Service 或 ReplicaSets 等资源提供了工作范围。资源的名称在一个命名空间内需要是唯一的,但不能跨命名空间。
  • 默认情况下,命名空间为资源提供了使用范围,但没有任何东西可以隔离这些资源并阻止从一个资源到另一个资源的访问。例如,一个来自开发命名空间的 Pod 可以访问另一个来自生产命名空间的 Pod,只要 Pod 的 IP 地址是已知的。然而,如果需要的话,有一些 Kubernetes 插件可以提供网络隔离,实现真正的跨命名空间的多租户。
  • 其他一些资源,如命名空间本身、节点和持久化存储(PersistentVolumes)不属于命名空间,而是有唯一的集群范围名称。
  • 每个 Kubernetes Service 都属于一个命名空间,并得到一个相应的 DNS 地址,该地址的命名空间形式为 <service-name>.<namespace-name>.svc.cluster.local。所以命名空间名称在每个属于给定命名空间的 Service 的 URI 中。这就是明智地命名命名空间至关重要的一个原因。
  • ResourceQuotas 提供了限制每个命名空间的资源消耗量的约束。通过 ResourceQuotas,集群管理员可以控制每个命名空间中允许的每种类型的对象数量。例如,一个开发者命名空间可能只允许 5 个 ConfigMaps、5 个 Secrets、5 个 Services、5 个 ReplicaSets、5 个 PersistentVolumeClaims 和 10 个 Pod。
  • ResourceQuotas 也可以限制我们在给定命名空间中可以请求的计算资源的总和。例如,在一个容量为 32 GB RAM 和 16 个内核的集群中,可以为生产命名空间分配一半的资源 — 16 GB RAM 和 8 个内核,为暂存环境分配 8 GB RAM 和 4 个内核,为开发分配 4 GB RAM 和 2 个内核,为测试命名空间分配相同数量的资源。通过使用命名空间和 ResourceQuotas 对一组对象施加资源约束的能力是非常宝贵的。

一些讨论

我们在本书中只简单介绍了我们使用的几个主要的 Kubernetes 概念。然而,还有更多的基本元素在被开发人员每天使用。例如,如果你创建了一个容器化服务,就可以使用 Kubernetes 对象的集合来获得 Kubernetes 的所有好处。请记住,这些只是应用开发人员用于将容器化服务集成到 Kubernetes 中的对象。平台管理员还使用了其他 Kubernetes 的资源,以使开发人员能够有效地管理该平台。图 1-4 给出了对开发者有用的众多 Kubernetes 资源的概览。
image.png
图 1-4 开发者可以使用的众多的 Kubernetes 核心资源和概念

随着时间的推移,这些新的基本元素造就了新的解决问题的方法,其中一些重复的解决方案成为模式。在本书中,我们不会详细描述一个 Kubernetes 资源,而是将重点放在 Kubernetes 被证明为模式的一些方案介绍上。

参考资料