在这一引言章节中,我们为本书的其余部分奠定了基础,通过解释一些设计和实施云原生应用程序所需的核心 Kubernetes 概念来设定场景。理解这些新抽象概念,以及本书中的相关原则和模式,是构建可以由 Kubernetes 自动化的分布式应用程序的关键。
本章不是理解后续描述的模式的先决条件。熟悉 Kubernetes 概念的读者可以跳过本章,直接进入感兴趣的模式类别。
通往云原生的路径
微服务是创建云原生应用程序的最流行的架构风格之一。它们通过对业务能力进行模块化来应对软件复杂性,将开发复杂性转化为运营复杂性。这就是为什么成功使用微服务的关键先决条件是创建可以通过 Kubernetes 进行大规模操作的应用程序。
作为微服务运动的一部分,关于从零开始创建微服务或将单体应用拆分为微服务的理论、技术和补充工具非常丰富。其中大多数实践基于 Eric Evans(Addison-Wesley)的《领域驱动设计》和边界上下文及聚合概念。边界上下文通过将大型模型划分为不同的组件来处理,而聚合则帮助将边界上下文进一步分组为具有定义的事务边界的模块。然而,除了这些业务领域的考虑之外,对于每个分布式系统——无论是否基于微服务——还有一些技术问题涉及其外部结构和运行时耦合。容器和像 Kubernetes 这样的容器编排工具引入了新的原语和抽象来解决分布式应用程序的关注点,在这里,我们讨论了将分布式系统放入 Kubernetes 时需要考虑的各种选项。
在本书中,我们通过将容器视为黑箱来查看容器和平台的交互。然而,我们创建了这一部分以强调容器内放入内容的重要性。容器和云原生平台为您的分布式应用程序带来了巨大的好处,但如果容器里填充的只是垃圾,您将得到大规模的分布式垃圾。图 1-1 展示了创建良好的云原生应用程序所需技能的混合,以及 Kubernetes 模式如何适应其中。
从高层次来看,创建优秀的云原生应用程序需要对多种设计技术有所了解:
在最低的代码层面上,您定义的每一个变量、创建的每一个方法以及决定实例化的每一个类都对应用程序的长期维护产生影响。无论您使用什么容器技术和编排平台,开发团队及其创建的工件将产生最大的影响。培养那些努力编写干净代码、拥有适当数量自动化测试、不断重构以提高代码质量并以软件工艺原则为核心指导的开发者是非常重要的。
领域驱动设计是从业务角度出发,意图将架构尽可能地接近现实世界的一种软件设计方法。这种方法最适用于面向对象的编程语言,但也有其他良好的方式来对现实世界问题进行建模和设计。具有正确业务和事务边界、易于使用的接口和丰富 API 的模型是成功容器化和自动化的基础。
六边形架构及其变体,如洋葱架构和清洁架构,通过解耦应用程序组件并提供标准化的接口来改进应用程序的灵活性和可维护性。通过将系统的核心业务逻辑与周围的基础设施解耦,六边形架构使得系统更容易迁移到不同的环境或平台。这些架构与领域驱动设计相辅相成,并帮助将应用程序代码安排得具有明确边界和外部化基础设施依赖性。
微服务架构风格和十二因子应用方法论迅速演变为创建分布式应用程序的标准,它们为设计变化中的分布式应用程序提供了宝贵的原则和实践。应用这些原则可以创建优化规模、弹性和变更速度的实现,这些都是现代软件的常见需求。
容器迅速被采纳为打包和运行分布式应用程序的标准方式,无论这些是微服务还是函数。创建模块化、可重用的容器,使其成为良好的云原生公民,是另一个基本的前提。云原生是一个用于描述自动化容器化应用程序的原则、模式和工具的术语。我们将云原生与 Kubernetes 互换使用,Kubernetes 是目前最流行的开源云原生平台。
在本书中,我们不会覆盖干净代码、领域驱动设计、六边形架构或微服务。我们只专注于解决容器编排问题的模式和实践。但为了使这些模式有效,您的应用程序需要从内部设计良好,包括使用干净代码实践、领域驱动设计、类似六边形架构的外部依赖隔离、微服务原则和其他相关设计技术。
分布式原语
为了说明我们所说的新抽象和原语的含义,我们将其与广为人知的面向对象编程(OOP)进行比较,特别是与 Java。面向对象编程中,我们有类、对象、包、继承、封装和多态等概念。然后,Java 运行时提供了特定的功能和保证,管理我们对象及应用程序的生命周期。
Java 语言和 Java 虚拟机(JVM)提供了用于创建应用程序的本地、进程内构建块。Kubernetes 在这一熟悉的思维模式上增加了一个全新的维度,提供了一组新的分布式原语和运行时,用于构建跨多个节点和进程的分布式系统。使用 Kubernetes,我们不再仅仅依赖本地原语来实现整个应用程序的行为。
我们仍然需要使用面向对象的构建块来创建分布式应用程序的组件,但我们也可以使用 Kubernetes 的原语来实现一些应用程序行为。表 1-1 展示了在 JVM 和 Kubernetes 中,本地和分布式原语如何实现各种开发概念的不同。
| 概念 | 本地原语 | 分布式原语 |
|---|---|---|
| 行为封装 | 类 | 容器镜像 |
| 行为实例 | 对象 | 容器 |
| 重用单元 | .jar | 容器镜像 |
| 组成 | 类 A 包含 类 B | Sidecar 模式 |
| 继承 | 类 A 扩展 类 B | 容器的 FROM 父镜像 |
| 部署单元 | .jar/.war/.ear | Pod |
| 构建/运行时隔离 | 模块、包、类 | 命名空间、Pod、容器 |
| 初始化前提 | 构造函数 | Init 容器 |
| 初始化后触发器 | Init-method | postStart |
| 预销毁触发器 | Destroy-method | preStop |
| 清理过程 | finalize()、shutdown hook | - |
| 异步和并行执行 | ThreadPoolExecutor、ForkJoinPool | Job |
| 定期任务 | Timer、ScheduledExecutorService | CronJob |
| 背景任务 | 守护线程 | DaemonSet |
| 配置管理 | System.getenv()、Properties | ConfigMap、Secret |
进程内原语和分布式原语有一些共性,但它们并不直接可比且不可替换。它们在不同的抽象层次上操作,并且具有不同的前提条件和保证。有些原语应该一起使用。例如,我们仍需使用类来创建对象并将其放入容器镜像中。然而,像 Kubernetes 中的 CronJob 这样的其他原语可以完全替代 Java 中的 ExecutorService 行为。
接下来,让我们看看 Kubernetes 中一些特别有趣的分布式抽象和原语,这些对应用程序开发者尤为重要。
容器
容器是基于 Kubernetes 的云原生应用程序的构建块。如果我们将其与 OOP 和 Java 进行比较,容器镜像就像类,而容器就像对象。正如我们可以扩展类以重用和改变行为,我们也可以有扩展其他容器镜像的容器镜像,以重用和改变行为。同样地,我们可以进行对象组成和功能使用,也可以通过将容器放入 Pod 并使用协作容器来进行容器组成。
如果继续比较,Kubernetes 就像 JVM,但分布在多个主机上,并负责运行和管理容器。Init 容器类似于对象构造函数;DaemonSets 类似于在后台运行的守护线程(例如 Java 垃圾回收器)。Pod 类似于一个控制反转(IoC)上下文(例如 Spring Framework),其中多个运行的对象共享一个受管理的生命周期,并可以直接访问彼此。
这个类比不会走得太远,但要点是容器在 Kubernetes 中扮演了基础角色,创建模块化、可重用、单一目的的容器镜像是任何项目长期成功和整个容器生态系统成功的基础。除了提供打包和隔离的技术特性外,容器在分布式应用程序中的代表性和目的是什么?以下是对容器的一些建议:
- 容器镜像是解决单一关注点的功能单元。
- 容器镜像由一个团队拥有,并具有自己的发布周期。
- 容器镜像是自包含的,定义并携带其运行时依赖项。
- 容器镜像是不可变的,一旦构建就不会改变;它是配置的。
- 容器镜像定义其资源需求和外部依赖项。
- 容器镜像有明确的 API 以暴露其功能。
- 容器通常作为单个 Unix 进程运行。
- 容器是一次性可处置的,随时可以安全地扩展或缩减。
除了这些特性外,一个适当的容器镜像是模块化的。它是参数化的,并为将在不同环境中运行的重用而创建。拥有小型、模块化和可重用的容器镜像可以在长期内创建出更专业和稳定的容器镜像,类似于编程语言世界中的优秀可重用库。
Pods
从容器的特性来看,它们与实施微服务原则完全契合。容器镜像提供了单一的功能单元,属于单一团队,具有独立的发布周期,并提供部署和运行时隔离。大多数时候,一个微服务对应一个容器镜像。
然而,大多数云原生平台提供了另一种原语,用于管理一组容器的生命周期——在 Kubernetes 中称为 Pod。Pod 是一组容器的原子调度、部署和运行时隔离单元。Pod 中的所有容器总是调度到同一主机,部署和缩放在一起,还可以共享文件系统、网络和进程命名空间。这种联合生命周期允许 Pod 中的容器通过文件系统或通过 localhost 或主机间通信机制进行交互(例如为了性能原因)。Pod 还代表了应用程序的安全边界。尽管在同一 Pod 中可以有具有不同安全参数的容器,但通常所有容器将具有相同的访问级别、网络分段和身份。
正如图 1-2 所示,在开发和构建时,一个微服务对应于一个由一个团队开发和发布的容器镜像。但在运行时,一个微服务由 Pod 表示,Pod 是部署、定位和扩展的单元。运行容器的唯一方法——无论是为了扩展还是迁移——都是通过 Pod 抽象。有时一个 Pod 包含多个容器。在其中一个例子中,容器化的微服务在运行时使用了一个辅助容器,正如第 16 章“Sidecar”所示。
容器、Pods及其独特的特性为基于微服务的应用程序设计提供了一组新的模式和原则。我们已经了解了良好设计的容器的一些特性,现在让我们看看 Pod 的一些特性:
- Pod 是调度的原子单元。这意味着调度器会尝试找到一个能够满足 Pod 中所有容器需求的主机(有关 init 容器的一些细节在第 15 章“Init Container”中讨论)。如果你创建了一个包含多个容器的 Pod,调度器需要找到一个资源足够的主机来满足所有容器的需求。这个调度过程在第 6 章“自动化部署”中有详细描述。
- Pod 确保容器的共同定位。由于共同定位,同一 Pod 中的容器有额外的方式相互交互。最常见的通信方式包括使用共享的本地文件系统交换数据、使用本地主机网络接口,或使用主机间进程通信(IPC)机制进行高性能交互。
- Pod 具有共享的 IP 地址、名称和端口范围。这意味着同一 Pod 中的容器必须仔细配置,以避免端口冲突,就像并行运行的 Unix 进程在主机上共享网络空间时需要小心一样。
- Pod 是 Kubernetes 中应用程序的基本单位,但你不能直接访问 Pods——这就是 Services 的作用所在。
Services
Pods 是短暂的。由于各种原因(例如,扩展、缩减、容器健康检查失败、节点迁移),它们会随时出现和消失。Pod 的 IP 地址仅在它被调度并在节点上启动后才会被知晓。如果 Pod 所在的节点不再健康,Pod 可以被重新调度到其他节点。这意味着 Pod 的网络地址可能会在应用程序的生命周期中发生变化,因此需要另一种原语来进行发现和负载均衡。
这就是 Kubernetes Services 发挥作用的地方。Service 是 Kubernetes 的另一种简单而强大的抽象,它将 Service 名称绑定到一个 IP 地址和端口号上。因此,Service 代表了访问应用程序的命名入口。在最常见的场景中,Service 作为一组 Pods 的入口点,但这并不总是如此。Service 是一种通用原语,它也可以指向 Kubernetes 集群外部提供的功能。因此,Service 原语可以用于服务发现和负载均衡,并允许在不影响服务消费者的情况下改变实现和扩展。我们在第 13 章“服务发现”中详细解释了 Services。
标签
我们已经了解到,微服务在构建时是一个容器镜像,但在运行时由 Pod 表示。那么,包含多个微服务的应用程序是什么样的呢?在这里,Kubernetes 提供了两个额外的原语来帮助你定义应用程序的概念:标签和命名空间。
在微服务出现之前,一个应用程序对应于一个具有单一版本控制方案和发布周期的部署单元。应用程序在 .war、.ear 或其他打包格式中是一个单一文件。然而,随着应用程序被拆分为独立开发、发布、运行、重启或扩展的微服务,应用程序的概念变得不那么重要,我们不再需要在应用程序级别执行关键的工件或活动。但如果你仍需要一种方式来指示一些独立服务属于一个应用程序,标签可以派上用场。假设我们已经将一个单体应用程序拆分为三个微服务,另一个拆分为两个微服务。
我们现在有五个 Pod 定义(可能还有更多的 Pod 实例),这些 Pod 在开发和运行时是独立的。然而,我们可能仍需要指示前三个 Pods 代表一个应用程序,而另外两个 Pods 代表另一个应用程序。即使这些 Pods 是独立的,为了提供业务价值,它们也可能相互依赖。例如,一个 Pod 可能包含负责前端的容器,而其他两个 Pods 负责提供后端功能。如果这些 Pods 中的任何一个出现故障,从业务角度来看,应用程序就会失效。使用标签选择器可以让我们查询和识别一组 Pods,并将其作为一个逻辑单元进行管理。图 1-3 展示了如何使用标签将分布式应用程序的部分分组到特定的子系统中。
标签在许多方面都非常有用,以下是一些示例:
- ReplicaSets 使用标签 来保持某些特定 Pod 实例的运行。这意味着每个 Pod 定义需要具有唯一的标签组合,用于调度。
- 调度器也大量使用标签。调度器利用标签来共同定位或分布 Pods 到满足 Pods 要求的节点上。
- 标签可以指示一组 Pods 的逻辑分组,并为它们提供应用程序的身份。
- 除了上述典型用例外,标签还可以用于存储元数据。尽管很难预测标签的所有用途,但最好拥有足够的标签来描述 Pods 的所有重要方面。例如,标签可以用来指示应用程序的逻辑组、业务特征和重要性、特定的运行时平台依赖性(如硬件架构)或位置偏好等,这些都是有用的。
这些标签可以在后续阶段由调度器用于更细粒度的调度,或者从命令行用于大规模管理匹配的 Pods。然而,不应过度添加标签,可以在需要时再添加。删除标签风险更大,因为没有简单的方法来了解标签的用途以及这种操作可能引起的意外效果。
注释
与标签非常相似的另一个原语是注释。像标签一样,注释也组织为一个映射,但注释用于指定不可搜索的元数据,主要供机器使用而非人类。
- 注释中的信息不用于查询和匹配对象,而是用于附加来自各种工具和库的额外元数据。例如,注释可以用于记录构建 ID、发布 ID、镜像信息、时间戳、Git 分支名称、拉取请求编号、镜像哈希、注册表地址、作者姓名、工具信息等。标签主要用于查询匹配和对匹配资源执行操作,而注释则用于附加机器可消费的元数据。
命名空间
另一个可以帮助管理资源组的原语是 Kubernetes 命名空间。尽管命名空间看起来可能与标签相似,但实际上它是一种具有不同特性和用途的原语。
- Kubernetes 命名空间 允许你将一个 Kubernetes 集群(通常跨多个主机)划分为逻辑资源池。命名空间为 Kubernetes 资源提供作用域,并提供了将授权和其他策略应用于集群子部分的机制。命名空间最常见的用例是表示不同的软件环境,如开发、测试、集成测试或生产。命名空间也可以用于实现多租户,并为团队工作空间、项目甚至特定应用程序提供隔离。但最终,为了更大的环境隔离,命名空间是不够的,通常需要拥有独立的集群。通常,有一个非生产 Kubernetes 集群用于一些环境(开发、测试和集成测试),另一个生产 Kubernetes 集群用于性能测试和生产环境。
以下是命名空间的一些特性,以及它们在不同场景中的作用:
- 命名空间作为 Kubernetes 资源进行管理。
- 命名空间为资源提供作用域,如容器、Pods、Services 或 ReplicaSets。资源的名称在一个命名空间内需要唯一,但跨命名空间则不需要。
- 默认情况下,命名空间提供资源的作用域,但并不隔离资源,防止一个资源访问另一个资源。例如,来自开发命名空间的 Pod 可以访问来自生产命名空间的 Pod,只要 Pod IP 地址已知。有关跨命名空间的网络隔离以创建轻量级多租户解决方案的详细信息,请参见第 24 章“网络分段”。
- 某些其他资源,如命名空间、节点和 PersistentVolumes,不属于任何命名空间,应具有唯一的集群级别名称。
- 每个 Kubernetes Service 属于一个命名空间,并获取一个对应的域名服务(DNS)记录,形式为
<service-name>.<namespace-name>.svc.cluster.local。因此,命名空间名称在每个属于给定命名空间的 Service 的 URL 中都存在。这也是为什么为命名空间命名时需要谨慎的一个原因。 - ResourceQuotas 提供限制,限制每个命名空间的资源总消耗。使用 ResourceQuotas,集群管理员可以控制每种类型的对象在命名空间中的数量。例如,一个开发命名空间可能仅允许五个 ConfigMaps、五个 Secrets、五个 Services、五个 ReplicaSets、五个 PersistentVolumeClaims 和十个 Pods。
- ResourceQuotas 还可以限制在给定命名空间中请求的计算资源总和。例如,在一个容量为 32 GB RAM 和 16 核心的集群中,可以为生产命名空间分配 16 GB RAM 和 8 核心,为预生产环境分配 8 GB RAM 和 4 核心,为开发环境分配 4 GB RAM 和 2 核心,为测试命名空间分配相同的资源量。能够对资源进行约束,而不依赖于底层基础设施的形状和限制,这是非常宝贵的。
讨论
我们仅简要介绍了本书中使用的一些主要 Kubernetes 概念。然而,开发人员日常工作中还会使用更多的原语。例如,如果你创建一个容器化服务,有很多 Kubernetes 抽象可以用来充分利用 Kubernetes 的优势。请记住,这些只是应用程序开发人员用于将容器化服务集成到 Kubernetes 中的一部分对象。还有许多其他概念主要由集群管理员用于管理 Kubernetes。图 1-4 概述了对开发人员有用的主要 Kubernetes 资源。
随着时间的推移,这些新原语催生了新的问题解决方式,其中一些重复的解决方案演变为模式。在本书中,我们将重点关注经过验证的模式,而不是详细描述每一个 Kubernetes 资源。
更多信息
- 《十二因素应用》
- CNCF 云原生定义 v1.0
- 六边形架构
- 《领域驱动设计:软件复杂性的核心》
- 编写 Dockerfile 的最佳实践
- 基于容器的应用设计原则
- 通用容器镜像指南