C++ 软件架构(五)

原文:zh.annas-archive.org/md5/FF4E2693BC25818CA0990A2CB63D13B8

译者:飞龙

协议:CC BY-NC-SA 4.0

第十三章:设计微服务

随着微服务的日益流行,我们希望在本书的一个整章中专门讨论它们。在讨论架构时,你可能会听到,“我们应该使用微服务吗?”本章将向您展示如何将现有应用程序迁移到微服务架构,以及如何构建利用微服务的新应用程序。

本章将涵盖以下主题:

  • 深入微服务

  • 构建微服务

  • 观察微服务

  • 连接微服务

  • 扩展微服务

技术要求

本章中介绍的大多数示例不需要任何特定的软件。对于redis-cpp库,请查看github.com/tdv/redis-cpp

本章中的代码已放置在 GitHub 上github.com/PacktPublishing/Software-Architecture-with-Cpp/tree/master/Chapter13

深入微服务

虽然微服务不受任何特定的编程语言或技术的限制,但在实现微服务时常见的选择是 Go 语言。这并不意味着其他语言不适合微服务开发-恰恰相反。C++的低计算和内存开销使其成为微服务的理想选择。

但首先,我们将从微服务的一些优缺点的详细视图开始。之后,我们将专注于通常与微服务相关的设计模式(而不是第四章中涵盖的一般设计模式,架构和系统设计)。

微服务的好处

你可能经常听到有关微服务的赞美之词。它们确实带来了一些好处,以下是其中一些。

模块化

由于整个应用程序被分割成许多相对较小的模块,更容易理解每个微服务的功能。这种理解的自然结果是,测试单个微服务也更容易。测试也受到每个微服务通常具有有限范围的事实的帮助。毕竟,测试日历应用程序比测试整个个人信息管理PIM)套件更容易。

然而,这种模块化也是有代价的。你的团队可能对单个微服务有更好的理解,但同时可能会发现更难理解整个应用程序是如何组成的。虽然不应该需要了解构成应用程序的微服务的所有内部细节,但组件之间的关系数量之多构成了认知挑战。在使用这种架构方法时,使用微服务契约是一个良好的实践。

可扩展性

更容易扩展范围有限的应用程序。其中一个原因是潜在的瓶颈较少。

缩放工作流程的较小部分也更具成本效益。想象一下,一个负责管理贸易展会的单片应用程序。一旦系统开始出现性能问题,唯一的扩展方式就是为单体引入更大的机器来运行。这被称为垂直扩展。

使用微服务,第一个优势是你可以水平扩展,也就是说,引入更多的机器而不是更大的机器(通常更便宜)。第二个优势来自于你只需要扩展那些出现性能问题的应用程序部分。这也有助于节省基础设施成本。

灵活性

当正确设计时,微服务不太容易受到供应商锁定的影响。当您决定要更换第三方组件中的一个时,您不必一次性进行整个痛苦的迁移。微服务设计考虑到您需要使用接口,因此唯一需要修改的部分是您的微服务与第三方组件之间的接口。

组件也可以逐个迁移,有些仍在使用旧提供商的软件。这意味着您可以将在多个地方引入破坏性变化的风险分开。而且,您可以将这与金丝雀部署模式结合起来,以更精细地管理风险。

这种灵活性不仅仅与单个服务有关。它也可能意味着不同的数据库、不同的排队和消息传递解决方案,甚至完全不同的云平台。虽然不同的云平台通常提供不同的服务和 API 来使用它们,但是在微服务架构中,您可以逐步开始迁移工作负载,并在新平台上独立测试它。

当由于性能问题、可扩展性或可用依赖性而需要重写时,重写微服务要比重写单体应用程序快得多。

与传统系统集成

微服务不一定是一刀切的方法。如果您的应用程序经过了充分测试,并且迁移到微服务可能会带来很多风险,那么就没有必要完全拆除正在运行的解决方案。最好只拆分需要进一步开发的部分,并将它们作为原始单体应用程序将使用的微服务引入。

通过遵循这种方法,您将获得与微服务相关的敏捷发布周期的好处,同时避免从头开始创建新架构并基本上重建整个应用程序。如果某些东西已经运行良好,最好专注于如何在不破坏良好部分的情况下添加新功能,而不是从头开始。在这里要小心,因为从头开始通常被用作自我提升!

分布式开发

开发团队规模小且共同办公的时代已经一去不复返。远程工作和分布式开发即使在传统的办公公司中也是事实。像 IBM、微软和英特尔这样的巨头公司有来自不同地点的人们一起在一个项目上工作。

微服务允许更小更灵活的团队,这使得分布式开发变得更加容易。当不再需要促进 20 人或更多人之间的沟通时,也更容易构建需要较少外部管理的自组织团队。

微服务的缺点

即使您认为由于其好处,您可能需要微服务,也要记住它们也有一些严重的缺点。简而言之,它们绝对不适合每个人。大公司通常可以抵消这些缺点,但较小的公司通常没有这种奢侈。

依赖成熟的 DevOps 方法

构建和测试微服务应该比在大型单片应用上执行类似操作要快得多。但为了实现敏捷开发,这种构建和测试需要更频繁地进行。

虽然在处理单体应用程序时手动部署应用程序可能是明智的,但是如果应用于微服务,同样的方法将导致许多问题。

为了在开发中采用微服务,您必须确保您的团队具有 DevOps 思维,并了解构建和运行微服务的要求。仅仅将代码交给其他人然后忘记它是不够的。

DevOps 思维将帮助您的团队尽可能自动化。在软件架构中,开发微服务而没有持续集成/持续交付流水线可能是最糟糕的想法之一。这种方法将带来微服务的所有其他缺点,而又无法实现大部分的好处。

调试更困难

微服务需要引入可观察性。没有它,当出现问题时,你永远不确定从哪里开始寻找潜在的根本原因。可观察性是一种推断应用程序状态的方式,而无需运行调试器或记录工作负载所在的机器。

日志聚合、应用程序指标、监控和分布式跟踪的组合是管理基于微服务的架构的先决条件。一旦考虑到自动扩展和自愈,甚至可能阻止您访问个别服务(如果它们开始崩溃),这一点尤其重要。

额外开销

微服务应该是精益和敏捷的。通常情况下是这样的。然而,基于微服务的架构通常需要额外的开销。首层开销与微服务通信使用的额外接口有关。RPC 库和 API 提供者和消费者不仅要按微服务的数量增加,还要按其副本的数量增加。然后还有辅助服务,如数据库、消息队列等。这些服务还包括通常由存储设施和收集数据的个体收集器组成的可观察性设施。

通过更好的扩展优化的成本可能会被运行整个服务群所需的成本所抵消,而这些服务并没有带来即时的业务价值。而且,你可能很难向利益相关者证明这些成本(无论是基础设施还是开发开销)。

微服务的设计模式

许多通用设计模式也适用于微服务。还有一些设计模式通常与微服务相关联。这里介绍的模式对于绿地项目和从单块应用程序迁移都很有用。

分解模式

这些模式涉及微服务的分解方式。我们希望确保架构稳定,服务之间松耦合。我们还希望确保服务具有内聚性和可测试性。最后,我们希望自治团队完全拥有一个或多个服务。

按业务能力分解

其中一种分解模式要求按业务能力进行分解。业务能力涉及业务为产生价值而做的事情。业务能力的例子包括商家管理和客户管理。业务能力通常以层次结构组织。

应用这种模式的主要挑战是正确识别业务能力。这需要对业务本身有一定的了解,并可能受益于与业务分析师的合作。

按子域分解

另一种分解模式与领域驱动设计DDD)方法有关。要定义服务,需要识别 DDD 子域。就像业务能力一样,识别子域需要了解业务背景。

这两种方法的主要区别在于,按业务能力分解更多关注业务的组织(其结构),而按子域分解更关注业务试图解决的问题。

每个服务一个数据库模式

存储和处理数据在每种软件架构中都是一个复杂的问题。错误的选择可能会影响可伸缩性、性能或维护成本。对于微服务来说,由于我们希望微服务之间松耦合,这增加了额外的复杂性。

这导致了一种设计模式,每个微服务连接到自己的数据库,因此独立于其他服务引入的任何更改。虽然这种模式增加了一些开销,但其额外的好处是你可以为每个微服务单独优化架构和索引。

由于数据库往往是相当庞大的基础设施,这种方法可能不可行,因此在微服务之间共享数据库是可以理解的权衡。

部署策略

当多个主机上运行微服务时,您可能会想知道分配资源的更好方式是哪种。让我们比较两种可能的方法。

每个主机单个服务

使用这种模式,我们允许每个主机只为特定类型的微服务提供服务。主要好处是你可以调整机器以更好地适应所需的工作负载,并且服务是良好隔离的。当你提供额外大的内存或快速存储时,你可以确保它只用于需要它的微服务。服务也无法消耗比所分配的资源更多的资源。

这种方法的缺点是一些主机可能被低效利用。一个可能的解决方法是在必要时使用尽可能小的机器来满足微服务的要求,并在必要时对其进行扩展。然而,这种解决方法并不能解决主机本身的额外开销问题。

每个主机多个服务

相反的方法是在一个主机上托管多个服务。这有助于优化机器的利用率,但也带来一些缺点。首先,不同的微服务可能需要不同的优化,因此在单个主机上托管它们仍然是不可能的。此外,使用这种方法,您失去了对主机分配的控制,因此一个微服务中的问题可能会导致共存的另一个微服务中断,即使后者在其他情况下不受影响。

另一个问题是微服务之间的依赖冲突。当微服务彼此不隔离时,部署必须考虑不同的可能依赖关系。这种模型也不太安全。

可观察性模式

在前面的部分中,我们提到微服务是有代价的。这个代价包括引入可观察性的要求,否则就会失去调试应用程序的能力。以下是一些与可观察性相关的模式。

日志聚合

微服务像单片应用程序一样使用日志记录。日志不是存储在本地,而是被聚合并转发到一个中央设施。这样,即使服务本身宕机,日志也是可用的。以集中的方式存储日志还有助于关联来自不同微服务的数据。

应用程序指标

要基于数据做出决策,首先需要一些数据来采取行动。收集应用程序指标有助于了解实际用户使用的应用程序行为,而不是合成测试中的行为。收集这些指标的方法有推送(应用程序主动调用性能监控服务)和拉取(性能监控服务定期检查配置的端点)。

分布式跟踪

分布式跟踪不仅有助于调查性能问题,还有助于更好地了解应用程序在真实流量下的行为。与日志记录不同,日志跟踪关注的是单个事务的整个生命周期,从它起源于用户操作的地方开始。

健康检查 API

由于微服务经常是自动化的目标,它们需要能够传达其内部状态。即使进程存在于系统中,也不意味着应用程序正在运行。对于开放的网络端口也是如此;应用程序可能正在监听,但还不能响应。健康检查 API 提供了一种外部服务确定应用程序是否准备处理工作负载的方法。自愈和自动扩展使用健康检查来确定何时需要干预。基本前提是给定的端点(例如/health)在应用程序表现如预期时返回 HTTP 代码200,如果发现任何问题,则返回不同的代码(或根本不返回)。

现在你已经了解了所有的优缺点和模式,我们将向你展示如何将单片应用程序分割并逐步转换为微服务。所提出的方法不仅限于微服务;它们在其他情况下也可能有用,包括单片应用程序。

构建微服务

关于单片应用程序有很多不同的观点。一些架构师认为单片应用程序本质上是邪恶的,因为它们不易扩展,耦合度高,难以维护。还有一些人声称,单片应用程序带来的性能优势可以抵消它们的缺点。紧密耦合的组件在网络、处理能力和内存方面需要的开销要少得多。

由于每个应用程序都有独特的业务需求,并在利益相关者的独特环境中运行,因此没有关于哪种方法更适合的通用规则。更令人困惑的是,在从单片应用程序迁移到微服务后,一些公司开始将微服务合并成宏服务。这是因为维护成千上万个单独的软件实例的负担太大,无法处理。

选择一种架构而不是另一种架构应该始终来自业务需求和对不同替代方案的仔细分析。将意识形态置于实用主义之前通常会导致组织内的大量浪费。当一个团队试图不顾一切地坚持某种方法,而不考虑不同的解决方案或不同的外部意见时,该团队就不再履行为工作提供正确工具的义务。

如果你正在开发或维护一个单片应用程序,你可能会考虑提高其可扩展性。本节介绍的技术旨在解决这个问题,同时使您的应用程序更容易迁移到微服务,如果您决定这样做的话。

瓶颈的三个主要原因如下:

  • 内存

  • 存储

  • 计算

我们将向您展示如何处理每个问题,以开发基于微服务的可扩展解决方案。

外包内存管理

帮助微服务扩展的一种方法是外包它们的一些任务。可能会妨碍扩展努力的一个任务是内存管理和缓存数据。

对于单个单片应用程序,直接将缓存数据存储在进程内存中并不是问题,因为进程将是唯一访问缓存的进程。但是,对于一个进程的多个副本,这种方法开始显示一些问题。

如果一个副本已经计算了一部分工作负载并将其存储在本地缓存中,另一个副本并不知道这一事实,必须重新计算。这样,您的应用程序既浪费了计算时间(因为同样的任务必须执行多次),又浪费了内存(因为结果也分别存储在每个副本中)。

为了缓解这些挑战,考虑切换到外部内存存储,而不是在应用程序内部管理缓存。使用外部解决方案的另一个好处是,缓存的生命周期不再与应用程序的生命周期绑定。您可以重新启动和部署应用程序的新版本,缓存中已经存储的值将被保留。

这可能还会导致启动时间更短,因为您的应用程序在启动时不再需要执行计算。内存缓存的两种流行解决方案是 Memcached 和 Redis。

Memcached

Memcached 于 2003 年发布,是这两者中较老的产品。它是一个通用的、分布式的键值存储。该项目的最初目标是通过将缓存值存储在内存中来卸载 Web 应用程序中使用的数据库。Memcached 是通过设计进行分布的。自版本 1.5.18 以来,可以在不丢失缓存内容的情况下重新启动 Memcached 服务器。这是通过使用 RAM 磁盘作为临时存储空间实现的。

它使用一个简单的 API,可以通过 telnet 或 netcat 操作,也可以使用许多流行的编程语言的绑定。虽然没有专门针对 C++的绑定,但可以使用 C/C++的libmemcached库。

Redis

Redis 是比 Memcached 更新的项目,最初版本于 2009 年发布。自那时起,Redis 已经在许多情况下取代了 Memcached 的使用。与 Memcached 一样,它是一个分布式的、通用的、内存中的键值存储。

与 Memcached 不同,Redis 还具有可选的数据持久性。虽然 Memcached 操作的是简单字符串的键和值,但 Redis 还支持其他数据类型,例如以下内容:

  • 字符串列表

  • 字符串集

  • 字符串的排序集

  • 键和值都是字符串的哈希表

  • 地理空间数据(自 Redis 3.2 起)

  • HyperLogLogs

Redis 的设计使其成为缓存会话数据、缓存网页和实现排行榜的绝佳选择。除此之外,它还可以用于消息队列。Python 的流行分布式任务队列库 Celery 使用 Redis 作为可能的代理之一,还有 RabbitMQ 和 Apache SQS。

微软、亚马逊、谷歌和阿里巴巴都在其云平台中提供基于 Redis 的托管服务。

C++中有许多 Redis 客户端的实现。两个有趣的实现是使用 C++17 编写的redis-cpp库(github.com/tdv/redis-cpp)和使用 Qt 工具包的 QRedisClient(github.com/uglide/qredisclient)。

以下是从官方文档中摘取的redis-cpp用法示例,说明了如何在存储中设置和获取一些数据:

#include <cstdlib>
#include <iostream>

#include <redis-cpp/execute.h>
#include <redis-cpp/stream.h>

int main() {
  try {
    auto stream = rediscpp::make_stream("localhost", "6379");

    auto const key = "my_key";

    auto response = rediscpp::execute(*stream, "set", key,
                                      "Some value for 'my_key'", "ex", 
                                      "60");

    std::cout << "Set key '" << key << "': " 
              << response.as<std::string>()
              << std::endl;

    response = rediscpp::execute(*stream, "get", key);
    std::cout << "Get key '" << key << "': " 
              << response.as<std::string>()
              << std::endl;
  } catch (std::exception const &e) {
    std::cerr << "Error: " << e.what() << std::endl;
    return EXIT_FAILURE;
  }
  return EXIT_SUCCESS;
}

正如您所看到的,该库处理不同数据类型的处理。该示例将值设置为字符串列表。

哪种内存缓存更好?

对于大多数应用程序,Redis 现在可能是一个更好的选择。它拥有更好的用户社区、许多不同的实现,并得到了良好的支持。除此之外,它还具有快照、复制、事务和发布/订阅模型。可以在 Redis 中嵌入 Lua 脚本,并且对地理空间数据的支持使其成为地理启用的 Web 和移动应用程序的绝佳选择。

然而,如果您的主要目标是在 Web 应用程序中缓存数据库查询的结果,那么 Memcached 是一个更简单的解决方案,开销更小。这意味着它应该更好地利用资源,因为它不必存储类型元数据或在不同类型之间执行转换。

外包存储

引入和扩展微服务时的另一个可能的限制是存储。传统上,本地块设备用于存储不属于数据库的对象(如静态 PDF 文件、文档或图像)。即使在今天,块存储仍然非常受欢迎,包括本地块设备和网络文件系统,如 NFS 或 CIFS。

虽然 NFS 和 CIFS 属于网络附加存储(NAS)的领域,但也有与在不同级别上运行的概念相关的协议:存储区域网络(SAN)。一些流行的协议包括 iSCSI、网络块设备(NBD)、以太网上的 ATA、光纤通道协议和以太网上的光纤通道。

另一种方法是针对分布式计算设计的集群文件系统:GlusterFS、CephFS 或 Lustre。然而,所有这些都作为块设备运行,向用户公开相同的 POSIX 文件 API。

作为亚马逊网络服务的一部分,提出了存储的新观点。亚马逊简单存储服务(S3)是对象存储。API 提供对存储在存储桶中的对象的访问。这与传统文件系统不同,因为文件、目录或索引节点之间没有区别。有存储桶和指向对象的键,对象是由服务存储的二进制数据。

外包计算

微服务的原则之一是一个进程只负责执行工作流的一部分。从单体架构迁移到微服务的一个自然步骤将是定义可能的长时间运行的任务,并将它们拆分为单独的进程。

这是任务队列背后的概念。任务队列处理管理任务的整个生命周期。与自己实现线程或多进程不同,使用任务队列,您将任务委托给异步处理任务的任务队列。任务可能在与发起进程相同的机器上执行,但也可能在具有专门要求的机器上运行。

任务及其结果是异步的,因此在主进程中没有阻塞。Web 开发中流行的任务队列示例包括 Python 的 Celery、Ruby 的 Sidekiq、Node.js 的 Kue 和 Go 的 Machinery。所有这些都可以与 Redis 一起使用作为代理。不幸的是,对于 C++,目前没有类似成熟的解决方案。

如果您认真考虑采用这种方法,一个可能的方法是直接在 Redis 中实现任务队列。Redis 及其 API 提供了支持这种行为所需的基本操作。另一种可能的方法是使用现有的任务队列之一,例如 Celery,并通过直接调用 Redis 来调用它们。然而,这并不被建议,因为它依赖于任务队列的实现细节,而不是文档化的公共 API。另一种方法是使用 SWIG 或类似方法提供的绑定来接口任务队列。

观察微服务

您构建的每个微服务都需要遵循一般的架构设计模式。微服务和传统应用程序之间的主要区别在于前者需要实现可观察性。

本节重点介绍了一些可观察性的方法。我们在这里描述了几种开源解决方案,当您设计系统时可能会发现有用。

记录

记录是一个即使您从未设计过微服务也应该熟悉的主题。日志(或日志文件)存储有关系统中发生事件的信息。系统可能指的是您的应用程序、您的应用程序运行的操作系统,或者您用于部署的云平台。这些组件中的每一个都可能提供日志。

日志被存储为单独的文件,因为它们提供了所有事件的永久记录。当系统变得无响应时,我们希望查询日志,并找出停机的可能根本原因。

这意味着日志也提供审计跟踪。因为事件是按时间顺序记录的,我们能够通过检查记录的历史状态来了解系统的状态。

为了帮助调试,日志通常是人类可读的。虽然日志也有二进制格式,但在使用文件存储日志时,这样的格式相当罕见。

使用微服务记录

这种日志记录方法本身与传统方法并没有太大区别。微服务通常不使用文本文件来存储日志,而是通常将日志打印到stdout。然后使用统一的日志层来检索和处理日志。要实现日志记录,您需要一个日志库,可以根据您的需求进行配置。

使用 spdlog 在 C++中记录日志

C++中一种流行且快速的日志库是spdlog。它使用 C++11 构建,可以作为仅头文件库或静态库使用(可减少编译时间)。

spdlog的一些有趣特性包括以下内容:

  • 格式化

  • 多个输出端:

  • 轮换文件

  • 控制台

  • Syslog

  • 自定义(实现为单个函数)

  • 多线程和单线程版本

  • 可选的异步模式

spdlog可能缺少的一个功能是直接支持 Logstash 或 Fluentd。如果要使用这些聚合器之一,仍然可以配置spdlog以使用文件输出,并使用 Filebeat 或 Fluent Bit 将文件内容转发到适当的聚合器。

统一日志层

大多数情况下,我们无法控制所有使用的微服务。其中一些将使用一个日志库,而其他人将使用不同的日志库。更糟糕的是,格式将完全不同,它们的轮换策略也将不同。更糟糕的是,我们仍然希望将操作系统事件与应用程序事件相关联。这就是统一日志层发挥作用的地方。

统一日志层的目的之一是从不同来源收集日志。这种统一日志层工具提供了许多集成,并理解不同的日志格式和传输方式(如文件、HTTP 和 TCP)。

统一日志层还能够过滤日志。我们可能需要过滤以满足合规性,匿名化客户的个人信息,或保护我们服务的实现细节。

为了更容易在以后查询日志,统一日志层还可以在不同格式之间进行转换。即使您使用的不同服务将日志存储为 JSON、CSV 和 Apache 格式,统一的日志层解决方案也能够将它们全部转换为 JSON 以赋予它们结构。

统一日志层的最终任务是将日志转发到它们的下一个目的地。根据系统的复杂性,下一个目的地可能是存储设施或另一个过滤、转换和转发设施。

以下是一些有趣的组件,可以帮助您构建统一的日志层。

Logstash

Logstash 是最受欢迎的统一日志层解决方案之一。目前,它由 Elastic 公司拥有,该公司是 Elasticsearch 背后的公司。如果您听说过 ELK 堆栈(现在称为 Elastic Stack),Logstash 是该首字母缩写中的“L”。

Logstash 最初是用 Ruby 编写的,然后被移植到了 JRuby。不幸的是,这意味着它需要相当多的资源。因此,不建议在每台机器上运行 Logstash。相反,它主要用作轻量级的日志转发器,每台机器部署轻量级的 Filebeat 来执行收集。

Filebeat

Filebeat 是 Beats 系列产品的一部分。它的目标是提供一个轻量级的 Logstash 替代方案,可以直接与应用程序一起使用。

这样,Beats 提供了低开销,而集中式 Logstash 安装执行所有繁重的工作,包括转换、过滤和转发。

除了 Filebeat 之外,Beats 系列的其他产品如下:

  • 用于性能的 Metricbeat

  • 用于网络数据的 Packetbeat

  • 用于审计数据的 Auditbeat

  • 用于运行时间监控的心跳

Fluentd

Fluentd 是 Logstash 的主要竞争对手。它也是一些云提供商的首选工具。

由于其使用插件的模块化方法,您可以找到用于数据源(如 Ruby 应用程序、Docker 容器、SNMP 或 MQTT 协议)、数据输出(如 Elastic Stack、SQL 数据库、Sentry、Datadog 或 Slack)以及其他各种过滤器和中间件的插件。

Fluentd 应该比 Logstash 占用更少的资源,但仍然不是一个适合大规模运行的完美解决方案。与与 Fluentd 配合使用的 Filebeat 的对应物称为 Fluent Bit。

Fluent Bit

Fluent Bit 是用 C 编写的,提供了一个更快、更轻的解决方案,可以插入到 Fluentd 中。作为日志处理器和转发器,它还具有许多输入和输出的集成。

除了日志收集,Fluent Bit 还可以监视 Linux 系统上的 CPU 和内存指标。它可以与 Fluentd 一起使用,也可以直接转发到 Elasticsearch 或 InfluxDB。

Vector

虽然 Logstash 和 Fluentd 是稳定、成熟和经过验证的解决方案,但在统一日志层空间中也有一些更新的提议。

其中之一是 Vector,旨在通过单一工具处理所有可观察性数据。为了与竞争对手区分,它专注于性能和正确性。这也体现在技术选择上。Vector 使用 Rust 作为引擎,Lua 作为脚本语言(而不是 Logstash 和 Fluentd 使用的自定义领域特定语言)。

在撰写本文时,它尚未达到稳定的 1.0 版本,因此在这一点上,不应将其视为生产就绪。

日志聚合

日志聚合解决了由于过多数据而产生的另一个问题:如何存储和访问日志。统一的日志层使日志即使在机器故障时也可用,而日志聚合的任务是帮助我们快速找到我们正在寻找的信息。

允许存储、索引和查询大量数据的两种可能产品是 Elasticsearch 和 Loki。

Elasticsearch

Elasticsearch 是自托管日志聚合的最流行解决方案。这是(以前的)ELK Stack 中的“E”。它具有基于 Apache Lucene 的出色搜索引擎。

作为其领域的事实标准,Elasticsearch 具有许多集成,并且在社区和商业服务方面得到了很好的支持。一些云提供商提供 Elasticsearch 作为托管服务,这使得在应用程序中引入 Elasticsearch 变得更容易。除此之外,制造 Elasticsearch 的 Elastic 公司还提供了一个不与任何特定云提供商绑定的托管解决方案。

Loki

Loki 旨在解决 Elasticsearch 中发现的一些缺点。Loki 的重点领域是水平扩展性和高可用性。它是从头开始构建的云原生解决方案。

Loki 的设计选择受到 Prometheus 和 Grafana 的启发。这并不奇怪,因为它是由负责 Grafana 的团队开发的。

虽然 Loki 应该是一个稳定的解决方案,但它并不像 Elasticsearch 那样受欢迎,这意味着可能会缺少一些集成,文档和社区支持也不会像 Elasticsearch 那样。Fluentd 和 Vector 都有支持 Loki 进行日志聚合的插件。

日志可视化

我们想考虑的日志堆栈的最后一部分是日志可视化。这有助于我们查询和分析日志。它以一种易于访问的方式呈现数据,因此所有感兴趣的方都可以检查,如运营商、开发人员、QA 或业务。

日志可视化工具使我们能够创建仪表板,使我们更容易阅读我们感兴趣的数据。有了这个,我们能够探索事件,寻找相关性,并从简单的用户界面中找到异常数据。

有两个专门用于日志可视化的主要产品。

Kibana

Kibana 是 ELK Stack 的最后一个元素。它在 Elasticsearch 之上提供了一个更简单的查询语言。尽管您可以使用 Kibana 查询和可视化不同类型的数据,但它主要专注于日志。

与 ELK Stack 的其他部分一样,它目前是可视化日志的事实标准。

Grafana

Grafana 是另一个数据可视化工具。直到最近,它主要专注于性能指标的时间序列数据。然而,随着 Loki 的引入,它现在也可以用于日志。

它的一个优点是它是以可插拔后端为目标构建的,因此很容易切换存储以适应您的需求。

监控

监控是从系统中收集与性能相关的指标的过程。与警报配对时,监控帮助我们了解系统何时表现如预期,以及何时发生故障。

我们最感兴趣的三种类型的指标如下:

  • 可用性,让我们知道我们的资源中哪些是正常运行的,哪些已经崩溃或变得无响应。

  • 资源利用率让我们了解工作负载如何适应系统。

  • 性能,它向我们展示了在哪里以及���何改进服务质量。

监控的两种模型是推送和拉取。在前者中,每个受监视的对象(机器、应用程序和网络设备)定期将数据推送到中心点。在后者中,对象在配置的端点呈现数据,监控代理定期抓取数据。

拉取模型使得扩展更容易。这样,多个对象不会阻塞监控代理连接。相反,多个代理可以在准备好时收集数据,从而更好地利用可用资源。

两个具有 C++客户端库的监控解决方案是 Prometheus 和 InfluxDB。Prometheus 是一个拉取模型的例子,它专注于收集和存储时间序列数据。InfluxDB 默认使用推送模型。除了监控,它还在物联网、传感器网络和家庭自动化方面很受欢迎。

Prometheus 和 InfluxDB 通常与 Grafana 一起用于可视化数据和管理仪表板。两者都内置了警报功能,但也可以通过 Grafana 与外部警报系统集成。

跟踪

跟踪提供的信息通常比事件日志更低级。另一个重要的区别是,跟踪存储每个事务的 ID,因此很容易可视化整个工作流程。这个 ID 通常被称为跟踪 ID、事务 ID 或相关 ID。

与事件日志不同,跟踪不是为了人类可读。它们由跟踪器处理。在实施跟踪时,有必要使用一个能够与系统的所有可能元素集成的跟踪解决方案:前端应用程序、后端应用程序和数据库。这样,跟踪有助于准确定位性能滞后的确切原因。

OpenTracing

分布式跟踪中的一个标准是 OpenTracing。这个标准是由 Jaeger 的作者提出的,Jaeger 是一个开源的跟踪器。

OpenTracing 支持许多不同的跟踪器,除了 Jaeger,它还支持许多不同的编程语言。最重要的包括以下内容:

  • Go

  • C++

  • C#

  • Java

  • JavaScript

  • Objective-C

  • PHP

  • Python

  • Ruby

OpenTracing 最重要的特性是它是供应商中立的。这意味着一旦我们对应用程序进行了仪器化,我们就不需要修改整个代码库来切换到不同的跟踪器。这样,它可以防止供应商锁定。

Jaeger

Jaeger 是一个跟踪器,可以与包括 Elasticsearch、Cassandra 和 Kafka 在内的各种后端一起使用。

它与 OpenTracing 兼容,这并不奇怪。由于它是一个 Cloud Native Computing Foundation 毕业的项目,它有很好的社区支持,这也意味着它与其他服务和框架的集成很好。

OpenZipkin

OpenZipkin 是 Jaeger 的主要竞争对手。它已经在市场上存在了更长的时间。尽���这应该意味着它是一个更成熟的解决方案,但与 Jaeger 相比,它的受欢迎程度正在下降。特别是,OpenZipkin 中的 C++并没有得到积极的维护,这可能会导致未来的维护问题。

集成的可观察性解决方案

如果您不想自己构建可观察性层,那么有一些受欢迎的商业解决方案可能会考虑。它们都以软件即服务模式运行。我们不会在这里进行详细的比较,因为它们的提供可能在本书编写后发生重大变化。

这些服务如下:

  • Datadog

  • Splunk

  • Honeycomb

在本节中,您已经看到了在微服务中实现可观察性。接下来,我们将继续学习如何连接微服务。

连接微服务

微服务非常有用,因为它们可以以许多不同的方式与其他服务连接,从而创造新的价值。然而,由于微服务没有标准,因此连接它们的方法也没有统一的方式。

这意味着大多数情况下,当我们想要使用特定的微服务时,我们必须学会如何与其交互。好消息是,尽管在微服务中可以实现任何通信方法,但有一些流行的方法是大多数微服务遵循的。

在设计围绕微服务的架构时,如何连接微服务只是一个相关问题之一。另一个问题是连接到什么以及在哪里连接。这就是服务发现发挥作用的地方。通过服务发现,我们让微服务使用自动化手段发现和连接应用程序中的其他服务。

这三个问题,如何、什么和在哪里,将是我们接下来的话题。我们将介绍一些现代微服务使用的最流行的通信和发现方法。

应用程序编程接口(API)

就像软件库一样,微服务通常会暴露 API。这些 API 使得与微服务进行通信成为可能。由于典型的通信方式利用计算机网络,API 的最流行形式是 Web API。

在上一章中,我们已经涵盖了一些可能的网络服务方法。如今,微服务通常使用基于表述状态转移REST)的网络服务。

远程过程调用

虽然诸如 REST 之类的 Web API 允许轻松调试和良好的互操作性,但与数据转换和使用 HTTP 进行传输相关的开销很大。

这种开销对一些微服务来说可能太大了,这就是轻量级远程过程调用RPCs)的原因。

Apache Thrift

Apache Thrift 是一种接口描述语言和二进制通信协议。它用作一种 RPC 方法,允许创建用多种语言构建的分布式和可扩展服务。

它支持多种二进制协议和传输方法。每种编程语言都使用本机数据类型,因此即使在现有代码库中也很容易引入。

gRPC

如果您真的关心性能,通常会发现基于文本的解决方案不适合您。然而,REST 虽然优雅且易于理解,但可能对您的需求来说太慢了。如果是这种情况,您应该尝试围绕二进制协议构建您的 API。其中一种日益流行的协议是 gRPC。

gRPC,顾名思义,是最初由 Google 开发的 RPC 系统。它使用 HTTP/2 进行传输,并使用协议缓冲区作为多种编程语言之间的接口描述语言IDL)以及数据序列化的可互操作性。也可以使用替代技术,例如 FlatBuffers。gRPC 可以同步和异步使用,并允许创建简单服务和流式服务。

假设您已决定使用protobufs,我们的 Greeter 服务定义可以如下所示:

service Greeter {
 rpc Greet(GreetRequest) returns (GreetResponse);
}

message GreetRequest {
 string name = 1;
}

message GreetResponse {
 string reply = 1;
}

使用protoc编译器,您可以从此定义创建数据访问代码。假设您想为我们的 Greeter 创建一个同步服务器,可以按以下方式创建服务:

class Greeter : public Greeter::Service {
  Status sendRequest(ServerContext *context, const GreetRequest 
*request,
                     GreetReply *reply) override {
    auto name = request->name();
    if (name.empty()) return Status::INVALID_ARGUMENT;
    reply->set_result("Hello " + name);
    return Status::OK;
  }
};

然后,您必须构建并运行服务器:

int main() {
  Greeter service;
  ServerBuilder builder;
  builder.AddListeningPort("localhost", grpc::InsecureServerCredentials());
  builder.RegisterService(&service);

  auto server(builder.BuildAndStart());
  server->Wait();
}

就是这么简单。现在让我们来看一个用于消费此服务的客户端:

  #include <grpcpp/grpcpp.h>

  #include <string>

  #include "grpc/service.grpc.pb.h"

  using grpc::ClientContext;
  using grpc::Status;

  int main() {
    std::string address("localhost:50000");
    auto channel =
        grpc::CreateChannel(address, grpc::InsecureChannelCredentials());
    auto stub = Greeter::NewStub(channel);

    GreetRequest request;
    request.set_name("World");

    GreetResponse reply;
    ClientContext context;
    Status status = stub->Greet(&context, request, &reply);

    if (status.ok()) {
      std::cout << reply.reply() << '\n';
    } else {
      std::cerr << "Error: " << status.error_code() << '\n';
    }
  }

这是一个简单的同步示例。要使其异步工作,您需要添加标签和CompletionQueue,如 gRPC 网站上所述。

gRPC 的一个有趣特性是它适用于 Android 和 iOS 上的移动应用程序。这意味着如果您在内部使用 gRPC,则无需提供额外的服务器来转换来自移动应用程序的流量。

在本节中,您了解了微服务使用的最流行的通信和发现方法。接下来,我们将看到如何扩展微服务。

扩展微服务

微服务的一个重要好处是它们比单体应用程序更有效地扩展。在相同的硬件基础设施下,您理论上可以从微服务中获得比单体应用程序更高的性能。

在实践中,好处并不那么直接。微服务及其相关辅助工具也会提供开销,对于规模较小的应用程序,可能不如最佳单体应用程序高效。

请记住,即使某些东西在“纸上”看起来不错,也不意味着它会成功。如果您想基于可扩展性或性能做出架构决策,最好准备计算和实验。这样,您将根据数据而不仅仅是情感行事。

每个主机部署单个服务的扩展

对于每个主机部署的单个服务,扩展微服务需要添加或删除承载微服务的额外机器。如果您的应用程序在云架构(公共或私有)上运行,许多提供商提供称为自动缩放组的概念。

自动缩放组定义了将在所有分组实例上运行的基本虚拟机映像。每当达到临界阈值(例如 80%的 CPU 使用)时,将创建一个新实例并将其添加到组中。由于自动缩放组在负载均衡器后运行,因此增加的流量将在现有实例和新实例之间分配,从而降低每个实例的平均负载。当流量激增后,扩展控制器会关闭多余的机器,以保持成本低廉。

不同的指标可以作为扩展事件的触发器。CPU 负载是最容易使用的指标之一,但可能不是最准确的指标。其他指标,例如队列中的消息数量,可能更适合您的应用程序。

以下是一个用于缩放策略的 Terraform 配置摘录:

autoscaling_policy {
    max_replicas = 5
    min_replicas = 3

    cooldown_period = 60

    cpu_utilization {
      target = 0.8
    }
}

这意味着在任何给定时间,至少会有三个实例运行,最多为五个实例。一旦 CPU 负载达到所有组实例的平均 80%,扩展器将触发。发生这种情况时,将会启动一个新实例。新机器的指标只有在其运行至少 60 秒后才会被收集(冷却期)。

每个主机部署多个服务的扩展

这种扩展模式也适用于每个主机部署多个服务。您可能可以想象,这并不是最有效的方法。仅基于单个服务的减少吞吐量来扩展整套服务类似于扩展单体应用程序。

如果您使用此模式,扩展微服务的更好方法是使用编排器。如果您不想使用容器,Nomad 是一个与许多不同执行驱动程序兼容的绝佳选择。对于容器化工作负载,Docker Swarm 或 Kubernetes 都会帮助您。编排器是我们将在接下来的两章中回顾的一个主题。

总结

微服务是软件架构中的一个伟大新趋势。只要确保您了解危险并为其做好准备,它们可能会很合适。本章解释了帮助引入微服务的常见设计和迁移模式。我们还涵盖了诸如可观察性和连接性之类的高级主题,在建立基于微服务的架构时至关重要。

到目前为止,您应该能够将应用程序设计和分解为单独的微服务。然后,每个微服务都能够处理一部分工作负载。

虽然微服务本身是有效的,但它们在与容器结合使用时尤其受欢迎。容器是下一章的主题。

问题

  1. 为什么微服务能帮助您更好地利用系统资源?

  2. 微服务和单体架构如何共存(在不断发展的系统中)?

  3. 哪种类型的团队最能从微服务中受益?

  4. 引入微服务时为什么需要成熟的 DevOps 方法?

  5. 统一的日志记录层是什么?

  6. 日志记录和跟踪有何不同?

  7. 为什么 REST 可能不是连接微服务的最佳选择?

  8. 微服务的部署策略是什么?每种策略的好处是什么?

进一步阅读

第十四章:容器

从开发到生产的过渡一直是一个痛苦的过程。它涉及大量文档、交接、安装和配置。由于每种编程语言产生的软件行为略有不同,异构应用程序的部署总是困难的。

其中一些问题已经通过容器得到缓解。使用容器,安装和配置大多是标准化的。处理分发的方式有几种,但这个问题也有一些标准可遵循。这使得容器成为那些希望增加开发和运维之间合作的组织的绝佳选择。

本章将涵盖以下主题:

  • 构建容器

  • 测试和集成容器

  • 理解容器编排

技术要求

本章列出的示例需要以下内容:

本章中的代码已放在 GitHub 上,网址为github.com/PacktPublishing/Software-Architecture-with-Cpp/tree/master/Chapter14

重新介绍容器

最近容器引起了很多关注。有人可能认为它们是一种以前不可用的全新技术。然而,事实并非如此。在 Docker 和 Kubernetes 崛起之前,这两者目前在行业中占主导地位,已经有了诸如 LXC 之类的解决方案,它们提供了许多类似的功能。

我们可以追溯到自 1979 年 UNIX 系统中可用的 chroot 机制,将一个执行环境与另一个分离的起源。类似的概念也在 FreeBSD jails 和 Solaris Zones 中使用过。

容器的主要任务是将一个执行环境与另一个隔离开。这个隔离的环境可以有自己的配置、不同的应用程序,甚至不同的用户帐户,与主机环境不同。

尽管容器与主机隔离,它们通常共享相同的操作系统内核。这是与虚拟化环境的主要区别。虚拟机有专用的虚拟资源,这意味着它们在硬件级别上是分离的。容器在进程级别上是分离的,这意味着运行它们的开销更小。

容器的一个强大优势是能够打包和运行另一个已经针对运行您的应用程序进行了优化和配置的操作系统。没有容器,构建和部署过程通常包括几个步骤:

  1. 应用已构建。

  2. 提供示例配置文件。

  3. 准备安装脚本和相关文档。

  4. 应用程序已打包为目标操作系统(如 Debian 或 Red Hat)。

  5. 软件包部署到目标平台。

  6. 安装脚本为应用程序运行准备了基础。

  7. 配置必须进行调整以适应现有系统。

当您切换到容器时,就不再需要强大的安装脚本。应用程序只会针对一个众所周知的操作系统进行目标设置——容器中存在的操作系统。配置也是一样:应用程序预先配置为目标操作系统并与其一起分发,而不是准备许多可配置选项。部署过程只包括解压容器镜像并在其中运行应用程序进程。

虽然容器和微服务经常被认为是同一件事,但它们并不是。此外,容器可能意味着应用容器或操作系统容器,只有应用容器与微服务配合得很好。接下来的章节将告诉您原因。我们将描述您可能遇到的不同容器类型,向您展示它们与微服务的关系,并解释何时最好使用它们(以及何时避免使用它们)。

探索容器类型

到目前为止描述的容器中,操作系统容器与由 Docker、Kubernetes 和 LXD 领导的当前容器趋势有根本的不同。应用容器专注于在容器内运行单个进程-即应用程序,而不是专注于重新创建具有诸如 syslog 和 cron 等服务的整个操作系统。

专有解决方案替换了所有通常的操作系统级服务。这些解决方案提供了一种统一的方式来管理容器内的应用程序。例如,不使用 syslog 来处理日志,而是将 PID 1 的进程的标准输出视为应用程序日志。不使用init.d或 systemd 等机制,而是由运行时应用程序处理应用容器的生命周期。

由于 Docker 目前是应用容器的主要解决方案,我们将在本书中大多数情况下使用它作为示例。为了使画面完整,我们将提出可行的替代方案,因为它们可能更适合您的需求。由于项目和规范是开源的,这些替代方案与 Docker 兼容,并且可以用作替代品。

在本章的后面,我们将解释如何使用 Docker 来构建、部署、运行和管理应用容器。

微服务的兴起

Docker 的成功与微服务的采用增长同时出现并不奇怪,因为微服务和应用容器自然地结合在一起。

没有应用容器,没有一种简单而统一的方式来打包、部署和维护微服务。尽管一些公司开发了一些解决这些问题的解决方案,但没有一种解决方案足够流行,可以成为行业标准。

没有微服务,应用容器的功能相当有限。软件架构专注于构建专门为给定的服务集合明确配置的整个系统。用另一个服务替换一个服务需要改变架构。

应用容器提供了一种标准的分发微服务的方式。每个微服务都带有其自己的嵌入式配置,因此诸如自动扩展或自愈等操作不再需要了解底层应用程序。

您仍然可以在没有应用容器的情况下使用微服务,也可以在应用容器中托管微服务。例如,尽管 PostgreSQL 数据库和 Nginx Web 服务器都不是设计为微服务,但它们通常在应用容器中使用。

选择何时使用容器

容器方法有几个好处。操作系统容器和应用容器在其优势所在的一些不同用例中也有所不同。

容器的好处

与隔离环境的另一种流行方式虚拟机相比,容器在运行时需要更少的开销。与虚拟机不同,不需要运行一个单独的操作系统内核版本,并使用硬件或软件虚拟化技术。应用容器也不运行通常在虚拟机中找到的其他操作系统服务,如 syslog、cron 或 init。此外,应用容器提供更小的镜像,因为它们通常不必携带整个操作系统副本。在极端情况下,应用容器可以由单个静态链接的二进制文件组成。

此时,你可能会想,如果里面只有一个单一的二进制文件,为什么还要费心使用容器呢?拥有统一和标准化的构建和运行容器的方式有一个特定的好处。由于容器必须遵循特定的约定,因此比起常规的二进制文件,对它们进行编排更容易,后者可能对日志记录、配置、打开端口等有不同的期望。

另一件事是,容器提供了内置的隔离手段。每个容器都有自己的进程命名空间和用户帐户命名空间,等等。这意味着一个容器中的进程(或进程)对主机上的进程或其他容器中的进程没有概念。沙盒化甚至可以进一步进行,因为你可以为你的容器分配内存和 CPU 配额,使用相同的标准用户界面(无论是 Docker、Kubernetes 还是其他什么)。

标准化的运行时也意味着更高的可移植性。一旦容器构建完成,通常可以在不同的操作系统上运行,而无需修改。这也意味着在运行的东西与开发中运行的东西非常接近或相同。问题的再现更加轻松,调试也更加轻松。

容器的缺点

由于现在有很大的压力要将工作负载迁移到容器中,作为架构师,你需要了解与这种迁移相关的所有风险。利益无处不在,你可能已经理解了它们。

容器采用的主要障碍是,并非所有应用程序都能轻松迁移到容器中。特别是那些以微服务为设计目标的应用程序容器。如果你的应用程序不是基于微服务架构的,将其放入容器中可能会带来更多问题。

如果你的应用程序已经很好地扩展,使用基于 TCP/IP 的 IPC,并且大部分是无状态的,那么转移到容器应该不会有挑战。否则,这些方面中的每一个都将带来挑战,并促使重新思考现有的设计。

与容器相关的另一个问题是持久存储。理想情况下,容器不应该有自己的持久存储。这样可以利用快速启动、轻松扩展和灵活的调度。问题在于提供业务价值的应用程序不能没有持久存储���

这个缺点通常可以通过使大多数容器无状态,并依赖于一个外部的非容器化组件来存储数据和状态来减轻。这样的外部组件可以是传统的自托管数据库,也可以是来自云提供商的托管数据库。无论选择哪个方向,都需要重新考虑架构并相应地进行修改。

由于应用程序容器遵循特定的约定,应用程序必须修改以遵循这些约定。对于一些应用程序来说,这将是一个低成本的任务。对于其他一些应用程序,比如使用内存 IPC 的多进程组件,这将是复杂的。

经常被忽略的一点是,只要容器内的应用程序是本地 Linux 应用程序,应用程序容器就能很好地工作。虽然支持 Windows 容器,但它们既不方便也不像它们的 Linux 对应物那样受支持。它们还需要运行作为主机的经过许可的 Windows 机器。

如果你从头开始构建一个新的应用程序,并且可以基于这项技术设计,那么很容易享受应用程序容器的好处。将现有的应用程序移植到应用程序容器中,特别是如果它很复杂,将需要更多的工作,可能还需要对整个架构进行改造。在这种情况下,我们建议您特别仔细地考虑所有的利弊。做出错误的决定可能会损害产品的交付时间、可用性和预算。

构建容器

应用程序容器是本节的重点。虽然操作系统容器大多遵循系统编程原则,但应用程序容器带来了新的挑战和模式。它们还提供了专门的构建工具来处理这些挑战。我们将考虑的主要工具是 Docker,因为它是当前构建和运行应用程序容器的事实标准。我们还将介绍一些构建应用程序容器的替代方法。

除非另有说明,从现在开始,当我们使用“容器”这个词时,它指的是“应用程序容器”。

在这一部分,我们将专注于使用 Docker 构建和部署容器的不同方法。

解释容器镜像

在我们描述容器镜像及如何构建它们之前,了解容器和容器镜像之间的区别至关重要。这两个术语经常会引起混淆,尤其是在非正式的对话中。

容器和容器镜像之间的区别与运行中的进程和可执行文件之间的区别相同。

容器镜像是静态的:它们是特定文件系统的快照和相关的元数据。元数据描述了在运行时设置了哪些环境变量,或者在创建容器时运行哪个程序,等等。

容器是动态的:它们运行在容器镜像内的一个进程。我们可以从容器镜像创建容器,也可以通过对运行中的容器进行快照来创建容器镜像。事实上,容器镜像构建过程包括创建多个容器,执行其中的命令,并在命令完成后对它们进行快照。

为了区分容器镜像引入的数据和运行时生成的数据,Docker 使用联合挂载文件系统来创建不同的文件系统层。这些层也存在于容器镜像中。通常,容器镜像的每个构建步骤对应于结果容器镜像中的一个新层。

使用 Dockerfiles 构建应用程序

使用 Docker 构建应用程序容器镜像的最常见方法是使用 Dockerfile。Dockerfile 是一种描述生成结果镜像所需操作的命令式语言。一些操作会创建新的文件系统层,而其他操作则会操作元数据。

我们不会详细介绍和具体涉及 Dockerfiles。相反,我们将展示不同的方法来将 C++应用程序容器化。为此,我们需要介绍一些与 Dockerfiles 相关的语法和概念。

这是一个非常简单的 Dockerfile 的示例:

FROM ubuntu:bionic

RUN apt-get update && apt-get -y install build-essentials gcc

CMD /usr/bin/gcc

通常,我们可以将 Dockerfile 分为三个部分:

  • 导入基本镜像(FROM指令)

  • 在容器内执行操作,将导致容器镜像(RUN指令)

  • 运行时使用的元数据(CMD命令)

后两部分可能会交错进行,每个部分可能包含一个或多个指令。也可以省略任何后续部分,因为只有基本镜像是必需的。这并不意味着你不能从空文件系统开始。有一个名为scratch的特殊基本镜像就是为了这个目的。在否则空的文件系统中添加一个单独的静态链接二进制文件可能看起来像下面这样:

FROM scratch

COPY customer /bin/customer

CMD /bin/customer

在第一个 Dockerfile 中,我们采取的步骤如下:

  1. 导入基本的 Ubuntu Bionic 镜像。

  2. 在容器内运行命令。命令的结果将在目标镜像内创建一个新的文件系统层。这意味着使用apt-get安装的软件包将在所有基于此镜像的容器中可用。

  3. 设置运行时元数据。在基于此镜像创建容器时,我们希望将GCC作为默认进程运行。

要从 Dockerfile 构建镜像,您将使用docker build命令。它需要一个必需的参数,即包含构建上下文的目录,这意味着 Dockerfile 本身和您想要复制到容器内的其他文件。要从当前目录构建 Dockerfile,请使用docker build

这将构建一个匿名镜像,这并不是很有用��大多数情况下,您希望使用命名的镜像。在命名容器镜像时有一个惯例要遵循,我们将在下一节中介绍。

命名和分发镜像

Docker 中的每个容器镜像都有一个独特的名称,由三个元素组成:注册表的名称,镜像的名称,一个标签。容器注册表是保存容器镜像的对象仓库。Docker 的默认容器注册表是docker.io。当从这个注册表中拉取镜像时,我们可以省略注册表的名称。

我们之前的例子中,ubuntu:bionic的完整名称是docker.io/ubuntu:bionic。在这个例子中,ubuntu是镜像的名称,而bionic是代表镜像特定版本的标签。

在基于容器的应用程序构建时,您将有兴趣存储所有的注册表镜像。可以搭建自己的私有注册表并在那里保存镜像,或者使用托管解决方案。流行的托管解决方案包括以下内容:

  • Docker Hub

  • quay.io

  • GitHub

  • 云提供商(如 AWS、GCP 或 Azure)

Docker Hub 仍然是最受欢迎的,尽管一些公共镜像正在迁移到 quay.io。两者都是通用的,允许存储公共和私有镜像。如果您已经在使用特定平台并希望将镜像保持接近 CI 流水线或部署目标,GitHub 或云提供商对您来说可能更具吸引力。如果您希望减少使用的个别服务数量,这也是有帮助的。

如果以上解决方案都不适合您,那么搭建自己的本地注册表也非常简单,只需要运行一个容器。

要构建一个命名的镜像,您需要向docker build命令传递-t参数。例如,要构建一个名为dominicanfair/merchant:v2.0.3的镜像,您将使用docker build -t dominicanfair/merchant:v2.0.3 .

已编译的应用程序和容器

对于解释性语言(如 Python 或 JavaScript)的应用程序构建容器镜像,方法基本上是相同的:

  1. 安装依赖项。

  2. 将源文件复制到容器镜像中。

  3. 复制必要的配置。

  4. 设置运行时命令。

然而,对于已编译的应用程序,还有一个额外的步骤是首先编译应用程序。有几种可能的方法来实现这一步骤,每种方法都有其优缺点。

最明显的方法是首先安装所有的依赖项,复制源文件,然后编译应用程序作为容器构建步骤之一。主要的好处是我们可以准确控制工具链的内容和配置,因此有一种便携的方式来构建应用程序。然而,缺点是太大而无法忽视:生成的容器镜像包含了许多不必要的文件。毕竟,在运行时我们既不需要源代码也不需要工具链。由于叠加文件系统的工作方式,无法在引入到先前层中的文件之后删除这些文件。而且,如果攻击者设法侵入容器,容器中的源代码可能会构成安全风险。

它可以看起来像这样:

FROM ubuntu:bionic

RUN apt-get update && apt-get -y install build-essentials gcc cmake

ADD . /usr/src

WORKDIR /usr/src

RUN mkdir build && \
    cd build && \
    cmake .. -DCMAKE_BUILD_TYPE=Release && \
    cmake --build . && \
    cmake --install .

CMD /usr/local/bin/customer

另一种明显的方法,也是我们之前讨论过的方法,是在主机上构建应用程序,然后只将生成的二进制文件复制到容器映像中。当已经建立了一个构建过程时,这需要对当前构建过程进行较少的更改。主要的缺点是您必须在构建机器上与容器中使用相同的库集。例如,如果您的主机操作系统是 Ubuntu 20.04,那么您的容器也必须基于 Ubuntu 20.04。否则,您会面临不兼容性的风险。使用这种方法,还需要独立配置工具链而不是容器。

就像这样:

FROM scratch

COPY customer /bin/customer

CMD /bin/customer

一种稍微复杂的方法是采用多阶段构建。使用多阶段构建,一个阶段可能专门用于设置工具链和编译项目,而另一个阶段则将生成的二进制文件复制到目标容器映像中。这比以前的解决方案有几个好处。首先,Dockerfile 现在控制工具链和运行时环境,因此构建的每一步都有详细记录。其次,可以使用带有工具链的映像来确保开发和持续集成/持续部署(CI/CD)流水线之间的兼容性。这种方式还使得更容易分发工具链本身的升级和修复。主要的缺点是容器化的工具链可能不像本机工具链那样方便使用。此外,构建工具并不特别适合应用容器,后者要求每个容器只运行一个进程。这可能导致一些进程崩溃或被强制停止时出现意外行为。

前面示例的多阶段版本如下所示:

FROM ubuntu:bionic AS builder

RUN apt-get update && apt-get -y install build-essentials gcc cmake

ADD . /usr/src

WORKDIR /usr/src

RUN mkdir build && \
    cd build && \
    cmake .. -DCMAKE_BUILD_TYPE=Release && \
    cmake --build .

FROM ubuntu:bionic

COPY --from=builder /usr/src/build/bin/customer /bin/customer

CMD /bin/customer

从第一个 FROM 命令开始的第一个阶段设置了构建器,添加了源代码并构建了二进制文件。然后,从第二个 FROM 命令开始的第二阶段,复制了上一阶段的结果二进制文件,而没有复制工具链或源代码。

通过清单定位多个架构

使用 Docker 的应用容器通常在 x86_64(也称为 AMD64)机器上使用。如果您只针对这个平台,那就没什么好担心的。但是,如果您正在开发物联网、嵌入式或边缘应用程序,您可能对多架构映像感兴趣。

由于 Docker 可用于许多不同的 CPU 架构,有多种方法可以处理多平台上的映像管理。

处理为不同目标构建的映像的一种方法是使用映像标签来描述特定平台。例如,我们可以使用 merchant:v2.0.3-aarch64 而不是 merchant:v2.0.3。尽管这种方法可能看起来最容易实现,但实际上有点问题。

不仅需要更改构建过程以在标记过程中包含架构。在拉取映像以运行它们时,还必须手动在所有地方添加预期的后缀。如果使用编排器,将无法以直接的方式在��同平台之间共享清单,因为标签将是特定于平台的。

一种更好的方法,不需要修改部署步骤,是使用 manifest-tool(https://github.com/estesp/manifest-tool)。首先,构建过程看起来与之前建议的类似。映像在所有支持的架构上分别构建,并带有标签中的平台后缀推送到注册表。在所有映像都推送后,manifest-tool 合并映像以提供单个多架构映像。这样,每个支持的平台都能使用完全相同的标签。

这里提供了 manifest-tool 的示例配置:

image: hosacpp/merchant:v2.0.3
manifests:
  - image: hosacpp/merchant:v2.0.3-amd64
    platform:
      architecture: amd64
      os: linux
  - image: hosacpp/merchant:v2.0.3-arm32
    platform:
      architecture: arm
      os: linux
  - image: hosacpp/merchant:v2.0.3-arm64
    platform:
      architecture: arm64
      os: linux

在这里,我们有三个支持的平台,每个平台都有其相应的后缀(hosacpp/merchant:v2.0.3-amd64hosacpp/merchant:v2.0.3-arm32hosacpp/merchant:v2.0.3-arm64)。Manifest-tool将为每个平台构建的镜像合并,并生成一个hosacpp/merchant:v2.0.3镜像,我们可以在任何地方使用。

另一种可能性是使用 Docker 内置的名为 Buildx 的功能。使用 Buildx,你可以附加多个构建器实例,每个实例针对所需的架构。有趣的是,你不需要本机机器来运行构建;你还可以在多阶段构建中使用 QEMU 模拟或交叉编译。尽管它比之前的方法更强大,但 Buildx 也相当复杂。在撰写本文时,它需要 Docker 实验模式和 Linux 内核 4.8 或更高版本。你需要设置和管理构建器,并且并非所有功能都以直观的方式运行。它可能会在不久的将来改进并变得更加稳定。

准备构建环境并构建多平台镜像的示例代码可能如下所示:

# create two build contexts running on different machines
docker context create \
    --docker host=ssh://docker-user@host1.domifair.org \
    --description="Remote engine amd64" \
    node-amd64
docker context create \
    --docker host=ssh://docker-user@host2.domifair.org \
    --description="Remote engine arm64" \
    node-arm64

# use the contexts
docker buildx create --use --name mybuild node-amd64
docker buildx create --append --name mybuild node-arm64

# build an image
docker buildx build --platform linux/amd64,linux/arm64 .

正如你所看到的,如果你习惯于常规的docker build命令,这可能会有点令人困惑。

构建应用程序容器的替代方法

使用 Docker 构建容器镜像需要 Docker 守护程序运行。Docker 守护程序需要 root 权限,在某些设置中可能会带来安全问题。即使进行构建的 Docker 客户端可能由非特权用户运行,但在构建环境中安装 Docker 守护程序并非总是可行。

Buildah

Buildah 是一个替代工具,可以配置为在没有 root 访问权限的情况下运行。Buildah 可以使用常规的 Dockerfile,我们之前讨论过。它还提供了自己的命令行界面,你可以在 shell 脚本或其他更直观的自动化中使用。将之前的 Dockerfile 重写为使用 buildah 接口的 shell 脚本之一将如下所示:

#!/bin/sh

ctr=$(buildah from ubuntu:bionic)

buildah run $ctr -- /bin/sh -c 'apt-get update && apt-get install -y build-essential gcc'

buildah config --cmd '/usr/bin/gcc' "$ctr"

buildah commit "$ctr" hosacpp-gcc

buildah rm "$ctr"

Buildah 的一个有趣特性是它允许你将容器镜像文件系统挂载到主机文件系统中。这样,你可以使用主机的命令与镜像的内容进行交互。如果你有一些不想(或者由于许可限制而无法)放入容器中的软件,使用 Buildah 时仍然可以在容器外部调用它。

Ansible-bender

Ansible-bender 使用 Ansible playbooks 和 Buildah 来构建容器镜像。所有配置,包括基本镜像和元数据,都作为 playbook 中的变量传递。以下是我们之前的示例转换为 Ansible 语法的示例:

---
- name: Container image with ansible-bender
  hosts: all
  vars:
    ansible_bender:
      base_image: python:3-buster

      target_image:
        name: hosacpp-gcc
        cmd: /usr/bin/gcc
  tasks:
  - name: Install Apt packages
    apt:
      pkg:
        - build-essential
        - gcc

正如你所看到的,ansible_bender变量负责所有与容器特定配置相关的内容。下面呈现的任务在基于base_image的容器内执行。

需要注意的一点是,Ansible 需要基本镜像中存在 Python 解释器。这就是为什么我们不得不将在之前的示例中使用的ubuntu:bionic更改为python:3-busterubuntu:bionic是一个没有预安装 Python 解释器的 Ubuntu 镜像。

其他

还有其他构建容器镜像的方法。你可以使用 Nix 创建文件系统镜像,然后使用 Dockerfile 的COPY指令将其放入镜像中,例如。更进一步,你可以通过任何其他方式准备文件系统镜像,然后使用docker import将其导入为基本容器镜像。

选择符合你特定需求的解决方案。请记住,使用docker build使用 Dockerfile 进行构建是最流行的方法,因此它是最有文档支持的。使用 Buildah 更加灵活,可以更好地将创建容器镜像融入到构建过程中。最后,如果你已经在 Ansible 中投入了大量精力,并且想要重用已有的模块,ansible-bender可能是一个不错的解决方案。

将容器与 CMake 集成

在这一部分,我们将演示如何通过使用 CMake 来创建 Docker 镜像。

使用 CMake 配置 Dockerfile

首先,我们需要一个 Dockerfile。让我们使用另一个 CMake 输入文件来实现这一点:

configure_file(${CMAKE_CURRENT_SOURCE_DIR}/Dockerfile.in
                ${PROJECT_BINARY_DIR}/Dockerfile @ONLY)

请注意,我们使用PROJECT_BINARY_DIR来避免覆盖源树中其他项目创建的 Dockerfile,如果我们的项目是更大项目的一部分。

我们的Dockerfile.in文件将如下所示:

FROM ubuntu:latest
ADD Customer-@PROJECT_VERSION@-Linux.deb .
RUN apt-get update && \
    apt-get -y --no-install-recommends install ./Customer-@PROJECT_VERSION@-Linux.deb && \
    apt-get autoremove -y && \
    apt-get clean && \
    rm -r /var/lib/apt/lists/* Customer-@PROJECT_VERSION@-Linux.deb
ENTRYPOINT ["/usr/bin/customer"]
EXPOSE 8080

首先,我们指定我们将使用最新的 Ubuntu 镜像,在其中安装我们的 DEB 包及其依赖项,然后进行整理。在安装软件包的同时更新软件包管理器缓存是很重要的,以避免由于 Docker 层的工作方式而导致的旧缓存问题。清理也作为相同的RUN命令的一部分进行(在同一层),以使层大小更小。安装软件包后,我们让我们的镜像在启动时运行customer微服务。最后,我们告诉 Docker 暴露它将监听的端口。

现在,回到我们的CMakeLists.txt文件。

将容器与 CMake 集成

对于基于 CMake 的项目,可以包含一个负责构建容器的构建步骤。为此,我们需要告诉 CMake 找到 Docker 可执行文件,并在找不到时退出。我们可以使用以下方法来实现:

find_program(Docker_EXECUTABLE docker)
 if(NOT Docker_EXECUTABLE)
   message(FATAL_ERROR "Docker not found")
 endif()

让我们重新访问第七章中的一个示例,构建和打包。在那里,我们为客户应用程序构建了一个二进制文件和一个 Conan 软件包。现在,我们希望将这个应用程序打包为一个 Debian 存档,并构建一个预安装软件包的 Debian 容器镜像,用于客户应用程序。

为了创建我们的 DEB 软件包,我们需要一个辅助目标。让我们使用 CMake 的add_custom_target功能来实现这一点:

add_custom_target(
   customer-deb
   COMMENT "Creating Customer DEB package"
   COMMAND ${CMAKE_CPACK_COMMAND} -G DEB
   WORKING_DIRECTORY ${PROJECT_BINARY_DIR}
   VERBATIM)
 add_dependencies(customer-deb libcustomer)

我们的目标调用 CPack 来创建我们感兴趣的一个软件包,并省略其余的软件包。我们希望软件包在与 Dockerfile 相同的目录中创建,以方便起见。推荐使用VERBATIM关键字,因为使用它,CMake 将转义有问题的字符。如果未指定,您的脚本的行为可能会因不同平台而异。

add_dependencies调用将确保在 CMake 构建customer-deb目标之前,libcustomer已经构建。现在我们有了辅助目标,让我们在创建容器镜像时使用它:

add_custom_target(
   docker
   COMMENT "Preparing Docker image"
   COMMAND ${Docker_EXECUTABLE} build ${PROJECT_BINARY_DIR}
           -t dominicanfair/customer:${PROJECT_VERSION} -t dominicanfair/customer:latest
   VERBATIM)
 add_dependencies(docker customer-deb)

如您所见,我们调用了我们之前在包含我们的 Dockerfile 和 DEB 软件包的目录中找到的 Docker 可执行文件,以创建一个镜像。我们还告诉 Docker 将我们的镜像标记为最新版本和我们项目的版本。最后,我们确保在调用我们的 Docker 目标时将构建 DEB 软件包。

如果您选择的生成器是make,那么构建镜像就像make docker一样简单。如果您更喜欢完整的 CMake 命令(例如,为了创建与生成器无关的脚本),那么调用是cmake --build . --target docker

测试和集成容器

容器非常适合 CI/CD 流水线。由于它们大多数情况下除了容器运行时本身不需要其他依赖项,因此它们可以很容易地进行测试。工作机器不必被配置以满足测试需求,因此添加更多节点更容易。而且,它们���是通用的,因此它们可以充当构建者、测试运行者,甚至是部署执行者,而无需任何先前的配置。

CI/CD中使用容器的另一个巨大好处是它们彼此隔离。这意味着在同一台机器上运行的多个副本不应该相互干扰。这是真的,除非测试需要一些来自主机操作系统的资源,例如端口转发或卷挂载。因此最好设计测试,使这些资源不是必需的(或者至少它们不会发生冲突)。端口随机化是一种有用的技术,可以避免冲突,例如。

容器内的运行时库

容器的选择可能会影响工具链的选择,因此也会影响应用程序可用的 C++语言特性。由于容器通常基于 Linux,可用的系统编译器通常是带有 glibc 标准库的 GNU GCC。然而,一些流行的用于容器的 Linux 发行版,如 Alpine Linux,基于不同的标准库 musl。

如果你的目标是这样的发行版,确保你将要使用的代码,无论是内部开发的还是来自第三方提供者,都与 musl 兼容。musl 和 Alpine Linux 的主要优势是它们可以生成更小的容器镜像。例如,为 Debian Buster 构建的 Python 镜像约为 330MB,精简版的 Debian 版本约为 40MB,而 Alpine 版本仅约为 16MB。更小的镜像意味着更少的带宽浪费(用于上传和下载)和更快的更新。

Alpine 可能也会引入一些不需要的特性,比如更长的构建时间、隐晦的错误或性能降低。如果你想使用它来减小大小,务必进行适当的测试,确保应用程序没有问题。

为了进一步减小镜像的大小,你可以考虑放弃底层操作系统。这里所说的操作系统是指通常存在于容器中的所有用户空间工具,如 shell、包管理器和共享库。毕竟,如果你的应用是唯一要运行的东西,其他一切都是不必要的。

Go 或 Rust 应用程序通常提供一个自包含的静态构建,可以形成一个容器镜像。虽然在 C++中可能不那么直接,但也值得考虑。

减小镜像大小也有一些缺点。首先,如果你决定使用 Alpine Linux,请记住它不像 Ubuntu、Debian 或 CentOS 那样受欢迎。尽管它经常是容器开发者的首选平台,但对于其他用途来说非常不寻常。

这意味着可能会出现新的兼容性问题,主要源自它不是基于事实上的标准 glibc 实现。如果你依赖第三方组件,提供者可能不会为这个平台提供支持。

如果你决定采用容器镜像中的单个静态链接二进制文件路线,也有一些挑战需要考虑。首先,你不建议静态链接 glibc,因为它内部使用 dlopen 来处理Name Service Switch(NSS)和 iconv。如果你的软件依赖于 DNS 解析或字符集转换,你仍然需要提供 glibc 和相关库的副本。

另一个需要考虑的问题是,通常会使用 shell 和包管理器来调试行为异常的容器。当你的某个容器表现出奇怪的行为时,你可以在容器内启动另一个进程,并通过使用诸如pslscat等标准 UNIX 工具来弄清楚容器内部发生了什么。要在容器内运行这样的应用程序,它必须首先存在于容器镜像中。一些解决方法允许操作员在运行的容器内注入调试二进制文件,但目前没有一个得到很好的支持。

替代容器运行时

Docker 是构建和运行容器的最流行方式,但由于容器标准是开放的,也有其他可供选择的运行时。用于替代 Docker 并提供类似用户体验的主要工具是 Podman。与前一节中描述的 Buildah 一起,它们是旨在完全取代 Docker 的工具。

它们的另一个好处是不需要在主机上运行额外的守护程序,就像 Docker 一样。它们两者也都支持(尽管尚不成熟)无根操作,这使它们更适合安全关键操作。Podman 接受您期望 Docker CLI 执行的所有命令,因此您可以简单地将其用作别名。

另一种旨在提供更好安全性的容器方法是Kata Containers倡议。Kata Containers 使用轻量级虚拟机来利用硬件虚拟化,以在容器和主机操作系统之间提供额外的隔离级别。

Cri-O 和 containerd 也是 Kubernetes 使用的流行运行时。

理解容器编排

一些容器的好处只有在使用容器编排器来管理它们时才会显现出来。编排器会跟踪将运行您的工作负载的所有节点,并监视这些节点上分布的容器的健康和状态。

例如,高可用性等更高级的功能需要正确设置编排器,通常意味着至少要为控制平面专门分配三台机器,另外还需要为工作节点分配三台机器。节点的自动缩放,以及容器的自动缩放,还需要编排器具有能够控制底层基础设施的驱动程序(例如,通过使用云提供商的 API)。

在这里,我们将介绍一些最受欢迎的编排器,您可以选择其中一个作为系统的基础。您将在下一章Kubernetes中找到更多关于 Kubernetes 的实用信息,云原生设计。在这里,我们给您一个可能的选择概述。

所提供的编排器操作类似的对象(服务、容器、批处理作业),尽管每个对象的行为可能不同。可用的功能和操作原则在它们之间也有所不同。它们的共同之处在于,通常您会编写一个配置文件,以声明方式描述所需的资源,然后使用专用的 CLI 工具应用此配置。为了说明工具之间的差异,我们提供了一个示例配置,指定了之前介绍的一个 Web 应用程序(商家服务)和一个流行的 Web 服务器 Nginx 作为代理。

自托管解决方案

无论您是在本地运行应用程序,还是在私有云或公共云中运行,您可能希望对所选择的编排器有严格的控制。以下是这个领域中的一些自托管解决方案。请记住,它们中的大多数也可以作为托管服务提供。但是,选择自托管可以帮助您防止供应商锁定,这可能对您的组织是可取的。

Kubernetes

Kubernetes 可能是我们在这里提到的所有编排器中最为人所知的。它很普遍,这意味着如果您决定实施它,将会有很多文档和社区支持。

尽管 Kubernetes 使用与 Docker 相同的应用程序容器格式,但基本上这就是所有相似之处的结束。不可能使用标准的 Docker 工具直接与 Kubernetes 集群和资源进行交互。在使用 Kubernetes 时,需要学习一套新的工具和概念。

与 Docker 不同,容器是您将操作的主要对象,而在 Kubernetes 中,运行时的最小单元称为 Pod。Pod 可能由一个或多个共享挂载点和网络资源的容器组成。Pod 本身很少引起兴趣,因为 Kubernetes 还具有更高级的概念,如复制控制器、部署控制器或守护进程集。它们的作用是跟踪 Pod 并确保节点上运行所需数量的副本。

Kubernetes 中的网络模型也与 Docker 非常不同。在 Docker 中,您可以将容器的端口转发,使其可以从不同的机器访问。在 Kubernetes 中,如果要访问一个 pod,通常会创建一个 Service 资源,它可以作为负载均衡器来处理指向服务后端的流量。服务可以用于 pod 之间的通信,也可以暴露给互联网。在内部,Kubernetes 资源使用 DNS 名称执行服务发现。

Kubernetes 是声明性的,最终一致的。这意味着您不必直接创建和分配资源,只需提供所需最终状态的描述,Kubernetes 将完成将集群带到所需状态所需的工作。资源通常使用 YAML 描述。

由于 Kubernetes 具有高度的可扩展性,因此在Cloud Native Computing FoundationCNCF)下开发了许多相关项目,将 Kubernetes 转变为一个与提供商无关的云开发平台。我们将在下一章第十五章中更详细地介绍 Kubernetes,云原生设计

以下是使用 YAML(merchant.yaml)在 Kubernetes 中的资源定义方式:

apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: dominican-front
  name: dominican-front
spec:
  selector:
    matchLabels:
      app: dominican-front
  template:
    metadata:
      labels:
        app: dominican-front
    spec:
      containers:
        - name: webserver
          imagePullPolicy: Always
          image: nginx
          ports:
            - name: http
              containerPort: 80
              protocol: TCP
      restartPolicy: Always
---
apiVersion: v1
kind: Service
metadata:
  labels:
    app: dominican-front
  name: dominican-front
spec:
  ports:
    - port: 80
      protocol: TCP
      targetPort: 80
  selector:
    app: dominican-front
  type: ClusterIP
---
apiVersion: apps/v1
kind: Deployment
metadata:
  labels:
    app: dominican-merchant
  name: merchant
spec:
  selector:
    matchLabels:
      app: dominican-merchant
  replicas: 3
  template:
    metadata:
      labels:
        app: dominican-merchant
    spec:
      containers:
        - name: merchant
          imagePullPolicy: Always
          image: hosacpp/merchant:v2.0.3
          ports:
            - name: http
              containerPort: 8000
              protocol: TCP
      restartPolicy: Always
---
apiVersion: v1
kind: Service
metadata:
  labels:
    app: dominican-merchant
  name: merchant
spec:
  ports:
    - port: 80
      protocol: TCP
      targetPort: 8000
  selector:
    app: dominican-merchant
    type: ClusterIP

要应用此配置并编排容器,请使用kubectl apply -f merchant.yaml

Docker Swarm

Docker 引擎,也需要构建和运行 Docker 容器,预装有自己的编排器。这个编排器是 Docker Swarm,其主要特点是通过使用 Docker API 与现有的 Docker 工具高度兼容。

Docker Swarm 使用服务的概念来管理健康检查和自动扩展。它原生支持服务的滚动升级。服务能够发布它们的端口,然后由 Swarm 的负载均衡器提供服务。它支持将配置存储为对象以进行运行时自定义,并内置了基本的秘密管理。

Docker Swarm 比 Kubernetes 简单得多,可扩展性较差。如果您不想了解 Kubernetes 的所有细节,这可能是一个优势。然而,主要的缺点是缺乏流行度,这意味着更难找到有关 Docker Swarm 的相关材料。

使用 Docker Swarm 的好处之一是您不必学习新的命令。如果您已经习惯了 Docker 和 Docker Compose,Swarm 可以使用相同的资源。它允许特定选项扩展 Docker 以处理部署。

使用 Swarm 编排的两个服务看起来像这样(docker-compose.yml):

version: "3.8"
services:
  web:
    image: nginx
    ports:
      - "80:80"
    depends_on:
      - merchant
  merchant:
    image: hosacpp/merchant:v2.0.3
    deploy:
      replicas: 3
    ports:
      - "8000"

应用配置时,您可以运行docker stack deploy --compose-file docker-compose.yml dominican

Nomad

Nomad 与前两种解决方案不同,因为它不仅专注于容器。它是一个通用的编排器,支持 Docker、Podman、Qemu 虚拟机、隔离的 fork/exec 和其他几种任务驱动程序。如果您想获得容器编排的一些优势而不将应用迁移到容器中,那么了解 Nomad 是值得的。

它相对容易设置,并且与其他 HashiCorp 产品(如 Consul 用于服务发现和 Vault 用于秘密管理)很好地集成。与 Docker 或 Kubernetes 一样,Nomad 客户端可以在本地运行,并连接到负责管理集群的服务器。

Nomad 有三种作业类型可用:

  • 服务:一个不应该在没有手动干预的情况下退出的长期任务(例如,Web 服务器或数据库)。

  • 批处理:一个较短寿命的任务,可以在几分钟内完成。如果批处理作业返回指示错误的退出代码,则根据配置重新启动或重新调度。

  • 系统:必须在集群中的每个节点上运行的任务(例如,日志代理)。

与其他编排器相比,Nomad 在安装和维护方面相对容易。在任务驱动程序或设备插件(用于访问专用硬件,如 GPU 或 FPGA)方面也是可扩展的。与 Kubernetes 相比,Nomad 在社区支持和第三方集成方面欠缺。Nomad 不需要您重新设计应用程序的架构以获得提供的好处,而这在 Kubernetes 中经常发生。

要使用 Nomad 配置这两个服务,我们需要两个配置文件。第一个是nginx.nomad

job "web" {
  datacenters = ["dc1"]
  type = "service"
  group "nginx" {
    task "nginx" {
      driver = "docker"
      config {
        image = "nginx"
        port_map {
          http = 80
        }
      }
      resources {
        network {
          port "http" {
              static = 80
          }
        }
      }
      service {
        name = "nginx"
        tags = [ "dominican-front", "web", "nginx" ]
        port = "http"
        check {
          type = "tcp"
          interval = "10s"
          timeout = "2s"
        }
      }
    }
  }
}

第二个描述了商户应用程序,因此被称为merchant.nomad

job "merchant" {
  datacenters = ["dc1"]
  type = "service"
  group "merchant" {
    count = 3
    task "merchant" {
      driver = "docker"
      config {
        image = "hosacpp/merchant:v2.0.3"
        port_map {
          http = 8000
        }
      }
      resources {
        network {
          port "http" {
              static = 8000
          }
        }
      }
      service {
        name = "merchant"
        tags = [ "dominican-front", "merchant" ]
        port = "http"
        check {
          type = "tcp"
          interval = "10s"
          timeout = "2s"
        }
      }
    }
  }
}

要应用配置,您需要运行nomad job run merchant.nomad && nomad job run nginx.nomad

OpenShift

OpenShift 是红帽的基于 Kubernetes 构建的商业容器平台。它包括许多在 Kubernetes 集群的日常运营中有用的附加组件。您将获得一个容器注册表,一个类似 Jenkins 的构建工具,用于监控的 Prometheus,用于服务网格的 Istio 和用于跟踪的 Jaeger。它与 Kubernetes 不完全兼容,因此不应将其视为可直接替换的产品。

它是建立在现有的红帽技术之上,如 CoreOS 和红帽企业 Linux。您可以在本地使用它,在红帽云中使用它,在受支持的公共云提供商之一(包括 AWS、GCP、IBM 和 Microsoft Azure)中使用它,或者作为混合云使用。

还有一个名为 OKD 的开源社区支持项目,它是红帽 OpenShift 的基础。如果您不需要商业支持和 OpenShift 的其他好处,仍然可以在 Kubernetes 工作流程中使用 OKD。

托管服务

如前所述,一些前述的编排器也可以作为托管服务提供。例如,Kubernetes 可以作为多个公共云提供商的托管解决方案。本节将向您展示一些不基于上述任何解决方案的容器编排的不同方法。

AWS ECS

在 Kubernetes 发布其 1.0 版本之前,亚马逊网络服务提出了自己的容器编排技术,称为弹性容器服务(ECS)。ECS 提供了一个编排器,可以在需要时监视、扩展和重新启动您的服务。

要在 ECS 中运行容器,您需要提供工作负载将运行的 EC2 实例。您不需要为编排器的使用付费,但您需要为通常使用的所有 AWS 服务付费(例如底层的 EC2 实例或 RDS 数据库)。

ECS 的一个重要优势是其与 AWS 生态系统的出色集成。如果您已经熟悉 AWS 服务并投资于该平台,您将更容易理解和管理 ECS。

如果您不需要许多 Kubernetes 高级功能和其扩展功能,ECS 可能是更好的选择,因为它更直接,更容易学习。

AWS Fargate

AWS 还提供了另一个托管的编排器 Fargate。与 ECS 不同,它不需要您为底层的 EC2 实例进行配置和付费。您需要关注的唯一组件是容器、与其连接的网络接口和 IAM 权限。

与其他解决方案相比,Fargate 需要的维护量最少,也是最容易学习的。由于现有的 AWS 产品在这一领域已经提供了自动扩展和负载平衡功能。

这里的主要缺点是与 ECS 相比,您为托管服务支付的高额费用。直接比较是不可能的,因为 ECS 需要支付 EC2 实例的费用,而 Fargate 需要独立支付内存和 CPU 使用费用。对集群缺乏直接控制可能会导致一旦服务开始自动扩展就会产生高昂的成本。

Azure Service Fabric

所有先前解决方案的问题在于它们大多针对首先是 Linux 中心的 Docker 容器。另一方面,Azure Service Fabric 是由微软支持的首先是 Windows 的产品。它可以在不修改的情况下运行传统的 Windows 应用程序,这可能有助于您迁移应用程序,如果它依赖于这些服务。

与 Kubernetes 一样,Azure Service Fabric 本身并不是一个容器编排器,而是一个平台,您可以在其上构建应用程序。其中一个构建块恰好是容器,因此它作为编排器运行良好。

随着 Azure Kubernetes Service 的最新推出,这是 Azure 云中的托管 Kubernetes 平台,使用 Service Fabric 的需求减少了。

总结

当您是现代软件的架构师时,您必须考虑现代技术。考虑它们并不意味着盲目地追随潮流;它意味着能够客观地评估特定建议是否在您的情况下有意义。

在前几章中介绍的微服务和本章介绍的容器都值得考虑和理解。它们是否值得实施?这在很大程度上取决于您正在设计的产品类型。如果您已经读到这里,那么您已经准备好自己做出决定了。

下一章专门讨论云原生设计。这是一个非常有趣但也复杂的主题,涉及面向服务的架构、CI/CD、微服务、容器和云服务。事实证明,C++的出色性能是一些云原生构建块的受欢迎特性。

问题

  1. 应用程序容器与操作系统容器有何不同?

  2. UNIX 系统中一些早期的沙盒环境示例是什么?

  3. 为什么容器非常适合微服务?

  4. 容器和虚拟机之间的主要区别是什么?

  5. 应用程序容器何时不是一个好选择?

  6. 有哪些构建多平台容器映像的工具?

  7. 除了 Docker,还有哪些其他容器运行时?

  8. 一些流行的编排器是什么?

进一步阅读

第十五章:云原生设计

正如其名称所示,云原生设计描述了首先建立在云中运行的应用程序架构。它不是由单一技术或语言定义的,而是充分利用现代云平台所提供的一切。

这可能意味着在必要时结合使用平台即服务PaaS),多云部署,边缘计算,函数即服务FaaS),静态文件托管,微服务和托管服务。它超越了传统操作系统的边界。云原生开发人员不再针对 POSIX API 和类 UNIX 操作系统,而是使用诸如 boto3、Pulumi 或 Kubernetes 等库和框架构建更高级别的概念。

本章将涵盖以下主题:

  • 理解云原生

  • 使用 Kubernetes 编排云原生工作负载

  • 使用服务网格连接服务

  • 分布式系统中的可观察性

  • 采用 GitOps

通过本章结束时,您将对如何在应用程序中使用软件架构的现代趋势有很好的理解。

技术要求

本章中的一些示例需要 Kubernetes 1.18。

本章中的代码已放置在 GitHub 上github.com/PacktPublishing/Software-Architecture-with-Cpp/tree/master/Chapter15

理解云原生

虽然可以将现有应用程序迁移到云中运行,但这种迁移不会使应用程序成为云原生。它可能在云中运行,但架构选择仍然基于本地模型。

简而言之,云原生应用程序通常是分布式的,松散耦合的,并且可扩展的。它们不受特定的物理基础设施约束,也不需要开发人员考虑特定的基础设施。这类应用程序通常是面向 Web 的。

在本章中,我们将介绍一些云原生构建模块的示例,并描述一些云原生模式。

云原生计算基金会

云原生设计的支持者之一是Cloud Native Computing FoundationCNCF),它托管了 Kubernetes 项目。CNCF 拥有各种技术,使得更容易构建与云供应商无关的云原生应用程序。此类技术的示例包括以下内容:

  • Fluentd,统一的日志记录层

  • Jaeger,用于分布式跟踪

  • Prometheus,用于监控

  • CoreDNS,用于服务发现

云原生应用程序通常使用应用程序容器构建,通常在 Kubernetes 平台上运行。但这不是必需的,完全可以在 Kubernetes 和容器之外使用许多 CNCF 框架。

云作为操作系统

云原生设计的主要特点是将各种云资源视为应用程序的构建模块。在云原生设计中,很少使用单独的虚拟机VMs)。与针对在某些实例上运行的特定操作系统相反,在云原生方法中,您要么直接针对云 API(例如使用 FaaS),要么针对 Kubernetes 等中间解决方案。在这种意义上,云成为您的操作系统,因为 POSIX API 不再限制您。

随着容器改变了构建和分发软件的方式,现在可以摆脱对基础硬件基础设施的思考。您的软件并非在孤立运行,因此仍然需要连接不同的服务,监视它们,控制它们的生命周期,存储数据或传递秘密。这是 Kubernetes 提供的功能之一,也是它变得如此受欢迎的原因之一。

您可能可以想象,云原生应用程序是面向 Web 和移动设备的。桌面应用程序也可以从具有一些云原生组件中受益,但这是一个不太常见的用例。

在云原生应用程序中仍然可以使用硬件和其他低级访问。如果您的工作负载需要使用 GPU,这不应该阻止您进行云原生。而且,如果您想要访问其他地方无法获得的自定义硬件,云原生应用程序也可以在本地构建。这个术语不仅限于公共云,而是一种思考不同资源的方式。

负载平衡和服务发现

负载平衡是分布式应用程序的重要组成部分。它不仅可以将传入的请求分散到一组服务中,这对于扩展至关重要,还可以帮助应用程序的响应性和可用性。智能负载均衡器可以收集指标以应对传入流量的模式,监视其集群中服务器的状态,并将请求转发到负载较轻和响应更快的节点,避免当前不健康的节点。

负载平衡带来更高的吞吐量和更少的停机时间。通过将请求转发到多个服务器,消除了单点故障,尤其是如果使用多个负载均衡器,例如在主备方案中。

负载均衡器可以在架构的任何地方使用:您可以平衡来自 Web 的请求,Web 服务器对其他服务的请求,对缓存或数据库服务器的请求,以及其他满足您需求的请求。

在引入负载平衡时有一些事项需要记住。其中之一是会话持久性——确保来自同一客户的所有请求都发送到同一服务器,这样精心选择的粉红色高跟鞋就不会从他们的购物篮中消失在您的电子商务网站上。负载均衡可能会让会话变得棘手:要特别小心,不要混淆会话,这样客户就不会突然开始被登录到彼此的个人资料中——许多公司在这方面出现过错误,尤其是在添加缓存时。将两者结合起来是一个好主意;只要确保它是正确的方式。

反向代理

即使您只想部署一个服务器实例,将另一个服务添加到负载均衡器之前,而不是负载均衡器,即反向代理,可能是一个好主意。虽然代理通常代表客户端发送一些请求,但反向代理代表处理这些请求的服务器,因此得名。

你问为什么要使用它?有几个原因和用途可以使用这样的代理:

  • 安全性:您的服务器地址现在被隐藏,服务器可以受到代理的 DDoS 防护能力的保护。

  • 灵活性和可扩展性:您可以以任何您想要的方式和时间修改代理后面隐藏的基础设施。

  • 缓���:如果您已经知道服务器将给出什么答案,为什么还要打扰服务器呢?

  • 压缩:压缩数据将减少所需的带宽,这对于连接质量差的移动用户可能特别有用。它还可以降低您的网络成本(但可能会消耗计算能力)。

  • SSL 终止:通过接管加密和解密网络流量的负担,减轻后端服务器的负载。

反向代理的一个例子是NGINX。它还提供负载平衡能力、A/B 测试等等。它的另一个能力是服务发现。让我们看看它如何有帮助。

服务发现

正如其名称所示,服务发现SD)允许自动检测计算机网络中特定服务的实例。调用者不必硬编码服务应该托管的域名或 IP,而只需指向服务注册表。使用这种方法,您的架构变得更加灵活,因为现在您使用的所有服务都可以很容易地找到。如果您设计了基于微服务的架构,引入 SD 确实可以大有作为。

有几种 SD 的方法。在客户端发现中,调用者直接联系 SD 实例。每个服务实例都有一个注册表客户端,用于注册和注销实例,处理心跳等。虽然相当直接,但在这种方法中,每个客户端都必须实现服务发现逻辑。Netflix Eureka 是在这种方法中常用的服务注册表的一个例子。

另一种方法是使用服务器端发现。在这种情况下,服务注册表也存在,并且每个服务实例中都有注册表客户端。但是,调用者不直接联系它。相反,他们连接到负载均衡器,例如 AWS 弹性负载均衡器,然后再调用服务注册表或使用其内置服务注册表,然后将客户端调用分派到特定实例。除了 AWS ELB,还可以使用 NGINX 和 Consul 来提供服务器端 SD 功能。

我们现在知道如何高效地找到和使用我们的服务,那么让我们学习如何最好地部署它们。

使用 Kubernetes 编排云原生工作负载

Kubernetes 是一个可扩展的开源平台,用于自动化和管理容器应用程序。有时被称为 k8s,因为它以’k’开头,以’s’结尾,在中间有八个字母。

其设计基于 Borg,这是 Google 内部使用的系统。Kubernetes 中的一些功能包括:

  • 应用程序的自动扩展

  • 可配置的网络

  • 批处理作业执行

  • 应用程序的统一升级

  • 在其上运行高可用性应用程序的能力

  • 声明性配置

在组织中运行 Kubernetes 有不同的方式。选择其中一种需要您分析与其相关的额外成本和收益。

Kubernetes 结构

虽然可以在单台机器上运行 Kubernetes(例如使用 minikube、k3s 或 k3d),但不建议在生产环境中这样做。单机集群功能有限,没有故障转移机制。Kubernetes 集群的典型大小是六台或更多。其中三台机器组成控制平面。另外三台是工作节点。

三台机器的最低要求来自于这是提供高可用性的最小数量。控制平面节点也可以作为工作节点可用,尽管这并不被鼓励。

控制平面

在 Kubernetes 中,您很少与单个工作节点进行交互。相反,所有 API 请求都发送到控制平面。然后,控制平面根据请求决定要采取的操作,然后与工作节点通信。

与控制平面的交互可以采取多种形式:

  • 使用 kubectl CLI

  • 使用 Web 仪表板

  • 从应用程序内部使用 Kubernetes API 而不是 kubectl

控制平面节点通常运行 API 服务器、调度器、配置存储(etcd)以及可能处理特定需求的其他一些附加进程。例如,在 Google Cloud Platform 等公共云中部署的 Kubernetes 集群上,控制平面节点上运行云控制器。云控制器与云提供商的 API 交互,以替换失败的机器、提供负载均衡器或分配外部 IP 地址。

工作节点

构成控制平面和工作节点池的节点是实际运行工作负载的机器。它们可以是你在本地托管的物理服务器,私有托管的 VM,或者来自你的云提供商的 VM。

集群中的每个节点至少运行以下三个程序:

  • 容器运行时(例如 Docker Engine 或 cri-o),允许机器处理应用程序容器

  • kubelet,负责接收来自控制平面的请求,并根据这些请求管理单个容器

  • kube-proxy,负责节点级别的网络和负载平衡

部署 Kubernetes 的可能方法

正如你从阅读前一节中所了解的,部署 Kubernetes 有不同的可能方式。

其中一种方法是将其部署到本地托管的裸金属服务器上。其中一个好处是对于大规模应用程序来说,这可能比云提供商提供的更便宜。这种方法有一个主要缺点——当需要时,你将需要操作员提供额外的节点。

为了缓解这个问题,你可以在裸金属服务器上运行一个虚拟化设备。这样就可以使用 Kubernetes 内置的云控制器自动提供必要的资源。你仍然可以控制成本,但手动工作会减少。虚拟化会增加一些开销,但在大多数情况下,这应该是一个公平的权衡。

如果你不想自己托管服务器,你可以部署 Kubernetes 在云提供商的 VM 上运行。通过选择这种方式,你可以使用一些现有的模板进行最佳设置。在流行的云平台上有 Terraform 和 Ansible 模块可用于构建集群。

最后,主要云服务提供商提供的托管服务。在其中一些服务中,你只需要为工作节点付费,而控制平面是免费的。

在公共云中运行时,为什么会选择自托管的 Kubernetes 而不是托管服务?其中一个原因可能是你需要的特定版本的 Kubernetes。当涉及到引入更新时,云提供商通常会有些慢。

理解 Kubernetes 的概念

Kubernetes 引入了一些概念,如果你第一次听到它们可能会感到陌生或困惑。当你了解它们的目的时,就会更容易理解 Kubernetes 的特殊之处。以下是一些最常见的 Kubernetes 对象:

  • 容器,特别是应用容器,是一种分发和运行单个应用程序的方法。它包含了在任何地方运行未经修改的应用程序所需的代码和配置。

  • Pod是基本的 Kubernetes 构建块。它是原子的,由一个或多个容器组成。Pod 中的所有容器共享相同的网络接口、卷(如持久存储或秘密)和资源(CPU 和内存)。

  • 部署是一个描述工作负载及其生命周期特性的高级对象。它通常管理一组 pod 副本,允许滚动升级,并在失败时管理回滚。这使得扩展和管理 Kubernetes 应用程序的生命周期变得容易。

  • DaemonSet是一个类似于部署的控制器,它管理 pod 的分布位置。部署关注保持给定数量的副本,而 DaemonSets 将 pod 分布在所有工作节点上。主要用例是在每个节点上运行系统级服务,比如监控或日志代理。

  • Jobs设计用于一次性任务。部署中的 pod 在其中的容器终止时会自动重新启动。它们适用于所有始终开启的服务,以便监听网络端口的请求。但是,部署不适用于批处理作业,例如缩略图生成,您只希望在需要时运行。作业创建一个或多个 pod,并监视它们直到完成给定的任务。当特定数量的成功 pod 终止时,作业被视为完成。

  • CronJobs,顾名思义,是定期在集群中运行的作业。

  • 服务代表集群中执行的特定功能。它们有与之关联的网络端点(通常是负载平衡)。服务可以由一个或多个 pod 执行。服务的生命周期独立于许多 pod 的生命周期。由于 pod 是瞬时的,它们可以随时创建和销毁。服务将个体 pod 抽象出来,以实现高可用性。服务具有自己的 IP 地址和 DNS 名称,以便使用。

声明性方法

我们在第九章中已经介绍了声明性和命令式方法之间的区别,持续集成/持续部署。Kubernetes 采用声明性方法。您不是提供关于需要采取的步骤的指示,而是提供描述集群所需状态的资源。由控制平面来分配内部资源,以满足您的需求。

可以直接使用命令行添加资源。这对于测试可能很快,但大多数时候您希望有您创建的资源的记录。因此,大多数人使用清单文件,这些文件提供所需资源的编码描述。清单通常是 YAML 文件,但也可以使用 JSON。

这是一个具有单个 Pod 的示例 YAML 清单:

apiVersion: v1

kind: Pod

metadata:

  name: simple-server

  labels:

    app: dominican-front

spec:

  containers:

    - name: webserver

      image: nginx

      ports:

        - name: http

          containerPort: 80

          protocol: TCP

第一行是必需的,它告诉清单中将使用哪个 API 版本。某些资源仅在扩展中可用,因此这是解析器如何行为的信息。

第二行描述了我们正在创建的资源。接下来是元数据和资源的规范。

元数据中的名称是必需的,因为这是区分一个资源和另一个资源的方式。如果我们想要创建另一个具有相同名称的 pod,我们将收到一个错误,指出已经存在这样的资源。标签是可选的,在编写选择器时非常有用。例如,如果我们想要创建一个允许连接到 pod 的服务,我们将使用一个匹配标签应用程序的选择器,其值等于dominican-front

规范也是必需的部分,因为它描述了资源的实际内容。在我们的示例中,我们列出了在 pod 内运行的所有容器。准确地说,一个名为webserver的容器,使用来自 Docker Hub 的图像nginx。由于我们希望从外部连接到 Nginx web 服务器,我们还公开了服务器正在侦听的容器端口80。端口描述中的名称是可选的。

Kubernetes 网络

Kubernetes 允许可插拔的网络架构。根据要求,可以使用几种驱动程序。无论选择哪个驱动程序,一些概念是通用的。以下是典型的网络场景。

容器与容器之间的通信

单个 pod 可以托管多个不同的容器。由于网络接口绑定到 pod 而不是容器,每个容器在相同的网络命名空间中运行。这意味着各种容器可以使用本地主机网络相互通信。

Pod 与 Pod 之间的通信

每个 pod 都有一个分配的内部集群本地 IP 地址。一旦 pod 被删除,该地址就不会持久存在。当一个 pod 知道另一个 pod 的地址时,它可以连接到另一个 pod 的暴露端口,因为它们共享相同的扁平网络。就这种通信模型而言,您可以将 pod 视为托管容器的 VM。这很少被使用,因为首选方法是 pod 到服务的通信。

pod 到服务的通信

pod 到服务的通信是集群内通信的最常见用例。每个服务都有一个分配的 IP 地址和 DNS 名称。当一个 pod 连接到一个服务时,连接被代理到服务选择的组中的一个 pod。代理是早期描述的 kube-proxy 工具的任务。

外部到内部的通信

外部流量通常通过负载均衡器进入集群。这些负载均衡器要么与特定服务绑定,要么由特定服务处理。当外部暴露的服务处理流量时,它的行为类似于 pod 到服务的通信。通过 Ingress 控制器,您可以使用其他功能,如路由、可观察性或高级负载平衡。

使用 Kubernetes 是一个好主意吗?

在组织内引入 Kubernetes 需要一些投资。Kubernetes 提供了许多好处,如自动扩展、自动化或部署方案。然而,这些好处可能无法证明必要的投资。

这项投资涉及几个领域:

  • 基础设施成本:运行控制平面和工作节点所需的成本可能相对较高。此外,如果您想使用各种 Kubernetes 扩展,如 GitOps 或服务网格(稍后描述),成本可能会上升。它们还需要额外的资源来运行,并在您的应用程序常规服务的基础上提供更多的开销。除了节点本身,您还应考虑其他成本。一些 Kubernetes 功能在部署到支持的云提供商时效果最佳。这意味着为了从这些功能中受益,您必须选���以下路线之一:

a. 将您的工作负载移至特定支持的云。

b. 为您选择的云提供商实现自己的驱动程序。

c. 将您的本地基础架构迁移到虚拟化的 API 启用环境,如 VMware vSphere 或 OpenStack。

  • 运营成本:Kubernetes 集群和相关服务需要维护。尽管您的应用程序需要较少的维护,但这一好处略微被保持集群运行的成本所抵消。

  • 教育成本:您的整个产品团队都必须学习新概念。即使您有一个专门的平台团队为开发人员提供易于使用的工具,开发人员仍需要基本了解他们的工作如何影响整个系统以及他们应该使用哪个 API。

在决定引入 Kubernetes 之前,首先考虑一下您是否能负担起它所需的初始投资。

分布式系统中的可观察性

诸如云原生架构之类的分布式系统提出了一些独特的挑战。在任何给定时间,不同服务的数量使得调查组件的性能变得非常不方便。

在单片系统中,通常日志记录和性能监控就足够了。在分布式系统中,即使日志记录也需要设计选择。不同的组件产生不同的日志格式。这些日志必须存储在某个地方。将它们与提供它们的服务放在一起,将使在停机情况下获得整体情况变得具有挑战性。此外,由于微服务可能存在时间很短,您将希望将日志的生命周期与提供它们的服务或托管服务的机器的生命周期解耦。

在第十三章中,设计微服务,我们描述了统一的日志记录层如何帮助管理日志。但日志只显示系统中特定点发生的情况。要从单个事务的角度看到整个图片,您需要采用不同的方法。

这就是追踪的作用。

追踪与日志记录的不同之处

追踪是日志记录的一种专门形式。它提供的信息比日志更低级。这可能包括所有函数调用、它��的参数、大小和执行时间。它们还包含正在处理的事务的唯一 ID。这些细节使得重新组装它们并查看给定事务在系统中的生命周期成为可能。

追踪中的性能信息可帮助您发现系统中的瓶颈和次优组件。

尽管日志通常由操作员和开发人员阅读,但它们往往是人类可读的。对于追踪没有这样的要求。要查看跟踪,您将使用专用的可视化程序。这意味着即使跟踪更详细,它们可能也比日志占用更少的空间。

以下图表是单个跟踪的概述:

外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传

图 15.1 - 单个跟踪

两个服务通过网络通信。在Service A中,我们有一个包含子跨度和单个日志的父跨度。子跨度通常对应于更深层的函数调用。日志代表最小的信息片段。它们中的每一个都被计时,并可能包含其他信息。

Service B的网络调用保留了跨度上下文。即使Service B在另一台机器上的不同进程中执行,所有信息也可以稍后重新组装,因为事务 ID 得以保留。

我们从重新组装跟踪中获得的额外信息是我们分布式系统中服务之间的���赖关系图。由于跟踪包含整个调用链,因此可以可视化此信息并检查意外的依赖关系。

选择追踪解决方案

在实施追踪时,有几种可能的解决方案可供选择。正如您可能想象的那样,您可以使用自托管工具和托管工具来为您的应用程序进行仪器化。我们将简要描述托管工具,并重点关注自托管工具。

Jaeger 和 OpenTracing

分布式跟踪的标准之一是 Jaeger 的作者提出的 OpenTracing。Jaeger 是为云原生应用程序构建的跟踪器。它解决了监视分布式事务和传播跟踪上下文的问题。它对以下目的很有用:

  • 性能或延迟优化

  • 执行根本原因分析

  • 分析服务之间的依赖关系

OpenTracing 是一个开放标准,提供了一个独立于使用的跟踪器的 API。这意味着当您的应用程序使用 OpenTracing 进行仪器化时,您避免了对特定供应商的锁定。如果在某个时候,您决定从 Jaeger 切换到 Zipkin、DataDog 或任何其他兼容的跟踪器,您不必修改整个仪器化代码。

有许多与 OpenTracing 兼容的客户端库。您还可以找到许多资源,包括教程和文章,解释如何根据您的需求实现 API。OpenTracing 官方支持以下语言:

  • Go

  • JavaScript

  • Java

  • Python

  • Ruby

  • PHP

  • Objective-C

  • C++

  • C#

还有一些非官方的库可用,特定应用程序也可以导出 OpenTracing 数据。这包括 Nginx 和 Envoy,两个流行的 Web 代理。

Jaeger 还接受 Zipkin 格式的样本。我们将在下一节中介绍 Zipkin。这意味着如果您(或您的任何依赖项)已经使用 Zipkin,您就不必将仪器从一种格式重写为另一种格式。对于所有新应用程序,OpenTracing 是推荐的方法。

Jaeger 的扩展性很好。您可以将其作为单个二进制文件或单个应用程序容器来运行以进行评估。您可以配置 Jaeger 以在生产中使用其自己的后端或支持的外部后端,如 Elasticsearch、Cassandra 或 Kafka。

Jaeger 是一个 CNCF 毕业项目。这意味着它已经达到了与 Kubernetes、Prometheus 或 Fluentd 类似的成熟水平。因此,我们期望它在其他 CNCF 应用程序中获得更多支持。

Zipkin

Jaeger 的主要竞争对手是 Zipkin。这是一个更老的项目,这意味着它更加成熟。通常,更老的项目也会得到更好的支持,但在这种情况下,CNCF 的认可对 Jaeger 更有利。

Zipkin 使用其专有协议来处理跟踪。它支持 OpenTracing,但可能不具有与本机 Jaeger 协议相同的成熟度和支持水平。正如我们之前提到的,还可以配置 Jaeger 以收集 Zipkin 格式的跟踪。这意味着这两者至少在某种程度上是可以互换的。

该项目由 Apache 基金会托管,但不被视为 CNCF 项目。在开发云原生应用程序时,Jaeger 是一个更好的选择。如果您正在寻找一个多用途的跟踪解决方案,那么考虑 Zipkin 也是值得的。

Zipkin 没有支持的 C++实现是一个缺点。有非官方的库,但似乎支持不够好。使用 C++ OpenTracing 库是仪表化 C++代码的首选方式。

使用 OpenTracing 为应用程序添加仪表

本节将说明如何向现有应用程序添加 Jaeger 和 OpenTracing 的仪表。我们将使用opentracing-cppjaeger-client-cpp库。

首先,我们要设置跟踪器:

#include <jaegertracing/Tracer.h>


void setUpTracer()

{

    // We want to read the sampling server configuration from the 
    // environment variables

    auto config = jaegertracing::Config;
    config.fromEnv();

    // Jaeger provides us with ConsoleLogger and NullLogger

    auto tracer = jaegertracing::Tracer::make(

        "customer", config, jaegertracing::logging::consoleLogger());

    opentracing::Tracer::InitGlobal(

        std::static_pointer_cast<opentracing::Tracer>(tracer));

}

配置采样服务器的两种首选方法要么使用环境变量,就像我们做的那样,要么使用 YAML 配置文件。当使用环境变量时,我们必须在运行应用程序之前设置它们。最重要的是以下几个:

  • JAEGER_AGENT_HOST:Jaeger 代理所在的主机名

  • JAEGER_AGENT_POR:Jaeger 代理正在监听的端口

  • JAEGER_SERVICE_NAME:我们应用程序的名称

接下来,我们配置跟踪器并提供日志记录实现。如果可用的ConsoleLogger不够,可以实现自定义日志记录解决方案。对于基于容器的应用程序,统一的日志记录层,ConsoleLogger应该足够了。

当我们设置好跟踪器后,我们希望在要仪表化的函数中添加 span。以下代码就是这样做的:

auto responder::respond(const http_request &request, status_code status,

                        const json::value &response) -> void {

  auto span = opentracing::Tracer::Global()->StartSpan("respond");

  // ...

}

这个 span 可以在以后用来在给定函数内创建子 span。它也可以作为参数传播到更深的函数调用中。它的使用方式如下:

auto responder::prepare_response(const std::string &name, const std::unique_ptr<opentracing::Span>& parentSpan)

    -> std::pair<status_code, json::value> {

  auto span = opentracing::Tracer::Global()->StartSpan(

        "prepare_response", { opentracing::ChildOf(&parentSpan->context()) });

  return {status_codes::OK,

          json::value::string(string_t("Hello, ") + name + "!")};

}


auto responder::respond(const http_request &request, status_code status)

    -> void {

  auto span = opentracing::Tracer::Global()->StartSpan("respond");

  // ...

  auto response = this->prepare_response("Dominic", span);

  // ...

}

当我们调用opentracing::ChildOf函数时,上下文传播就会发生。我们还可以使用inject()extract()调用通过网络调用传递上下文。

使用服务网格连接服务

微服务和云原生设计带来了一系列问题。不同服务之间的通信、可观察性、调试、速率限制、身份验证、访问控制和 A/B 测试可能会具有挑战性,即使服务数量有限。随着服务数量的增加,上述要求的复杂性也会增加。

这就是服务网格介入的地方。简而言之,服务网格通过一些资源(运行控制平面和边车所需的资源)来交换自动化和集中控制的解决方案,以解决上述挑战。

引入服务网格

我们在本章介绍中提到的所有要求以前都是在应用程序内部编码的。事实证明,许多要求可以被抽象化,因为它们在许多不同的应用程序中共享。当您的应用程序由许多服务组成时,向所有这些服务添加新功能开始变得昂贵。通过服务网格,您可以从一个单一点控制这些功能。

由于容器化工作流已经抽象化了一些运行时和一些网络,服务网格将抽象化提升到了另一个层次。这样,容器中的应用程序只知道 OSI 网络模型的应用程序级别发生了什么。服务网格处理更低级别的内容。

设置服务网格允许您以一种新的方式控制所有网络流量,并更好地了解这些流量。依赖关系变得可见,流动、形状和流量量也变得可见。

服务网格不仅处理流量的流动。其他流行的模式,如断路器、速率限制或重试,不必由每个应用程序单独实现和配置。这也是可以外包给服务网格的功能。同样,A/B 测试或金丝雀部署是服务网格能够实现的用例。

正如之前提到的,服务网格的一个好处是更大的控制权。其架构通常包括一个可管理的外部流量边缘代理和通常部署为旁路的内部代理。这样,网络策略可以被编写为代码,并存储在一个地方与所有其他配置一起。与为要连接的两个服务打开双向 TLS 加密相比,您只需在服务网格配置中启用一次该功能。

接下来,我们将介绍一些服务网格解决方案。

服务网格解决方案

这里描述的所有解决方案都是自托管的。

Istio

Istio 是一组强大的服务网格工具。它允许您通过部署 Envoy 代理作为旁路容器来连接微服务。由于 Envoy 是可编程的,Istio 控制平面的配置更改会被传达给所有代理,然后代理会相应地重新配置自己。

Envoy 代理除了其他功能外,还负责处理加密和身份验证。使用 Istio,为服务之间启用双向 TLS 通常只需要在配置中进行一次切换。如果您不希望所有服务之间都使用 mTLS,您还可以选择那些需要此额外保护的服务,同时允许其他所有服务之间的未加密流量。

Istio 还有助于可观察性。首先,Envoy 代理导出与 Prometheus 兼容的代理级别指标。 Istio 还导出服务级别指标和控制平面指标。接下来,有描述网格内流量流动的分布式跟踪。Istio 可以将跟踪提供给不同的后端:Zipkin、Jaeger、Lightstep 和 Datadog。最后,还有 Envoy 访问日志,以类似 Nginx 的格式显示每个调用。

使用 Kiali 可以可视化您的网格,这是一个交互式的 Web 界面。这样,您可以看到服务的图表,包括加密是否已启用,不同服务之间流量的大小,以及每个服务的健康检查状态。

Istio 的作者声称,这种服务网格应该与不同的技术兼容。在撰写本文时,最好的文档化、最好的集成和最好的测试是与 Kubernetes 的集成。其他支持的环境包括本地环境、通用云、Mesos 和带有 Consul 的 Nomad。

如果您在关注合规性的行业工作(如金融机构),那么 Istio 可以在这些方面提供帮助。

Envoy

虽然 Envoy 本身不是服务网格,但由于其在 Istio 中的使用,它值得在本节中提及。

Envoy 是一个类似于 Nginx 或 HAProxy 的服务代理。主要区别在于它可以动态重新配置。这是通过 API 以编程方式实现的,不需要更改配置文件然后重新加载守护程序。

关于 Envoy 的有趣事实是其性能和流行度。根据 SolarWinds 进行的测试,Envoy 在作为服务代理时击败了竞争对手。这些竞争对手包括 HAProxy、Nginx、Traefik 和 AWS 应用负载均衡器。Envoy 比 Nginx、HAProxy、Apache 和 Microsoft IIS 等这个领域的老牌领导者要年轻得多,但这并没有阻止 Envoy 进入 Netcraft 最常用的前 10 名网页服务器列表。

Linkerd

在 Istio 成为服务网格的代名词之前,这个领域由 Linkerd 代表。关于命名存在一些混淆,因为最初的 Linkerd 项目旨在是平台无关的,并且针对 Java 虚拟机。这意味着它资源密集且经常运行缓慢。更新的版本 Linkerd2 已经重写以解决这些问题。与最初的 Linkerd 相反,Linkerd2 只专注于 Kubernetes。

Linkerd 和 Linkerd2 都使用自己的代理解决方案,而不是依赖于 Envoy 等现有项目。这样做的理由是,专用代理(而不是通用的 Envoy)提供了更好的安全性和性能。Linkerd2 的一个有趣特性是,开发它的公司也提供付费支持。

Consul 服务网格

服务网格领域的最新增加是 Consul 服务网格。这是 HashiCorp 的产品,这家知名的云公司以 Terraform、Vault、Packer、Nomad 和 Consul 等工具而闻名。

就像其他解决方案一样,它具有 mTLS 和流量管理。它被宣传为一个多云、多数据中心和多地区的网格。它与不同的平台、数据平面产品和可观察性提供者集成。在撰写本文时,现实情况要逊色一些,因为主要支持的平台是 Nomad 和 Kubernetes,而支持的代理要么是内置代理,要么是 Envoy。

如果您考虑使用 Nomad 来部署应用程序,那么 Consul 服务网格可能是一个很好的选择,因为两者都是 HashiCorp 产品。

GitOps 进行中

本章我们想要讨论的最后一个话题是 GitOps。尽管这个术语听起来很新潮,但其背后的理念并不是全新的。它是持续集成/持续部署CI/CD)模式的延伸。或许延伸并不是一个很好的描述。

虽然 CI/CD 系统通常旨在非常灵活,但 GitOps 旨在最小化可能的集成数量。两个主要的常量是 Git 和 Kubernetes。Git 用于版本控制、发布管理和环境分离。Kubernetes 用作标准化和可编程的部署平台。

这样,CI/CD 流水线几乎变得透明。这与处理构建的所有阶段的命令式代码的方法相反。为了允许这种抽象级别,通常需要以下内容:

  • 基础设施即代码,以允许所有必要环境的自动化部署

  • 具有功能分支和拉取请求或合并请求的 Git 工作流

  • 声明性工作流配置,这在 Kubernetes 中已经��用

GitOps 的原则

由于 GitOps 是已建立的 CI/CD 模式的延伸,可能很难清楚地区分两者。以下是一些 GitOps 原则,它们将这种方法与通用的 CI/CD 区分开来。

声明性描述

经典 CI/CD 系统和 GitOps 之间的主要区别在于操作模式。大多数 CI/CD 系统是命令式的:它们由一系列必须按顺序执行的步骤组成,以使管道成功。

即使管道的概念是必要的,因为它意味着一个具有入口、一组连接和一个接收器的对象。一些步骤可以并行执行,但是每当存在依赖关系时,进程必须停止并等待依赖步骤完成。

在 GitOps 中,配置是声明性的。这指的是系统的整个状态 - 应用程序、它们的配置、监控和仪表板。所有这些都被视为代码,具有与常规应用程序代码相同的特性。

系统的状态在 Git 中进行版本控制

由于系统的状态是以代码编写的,您从中获得了一些好处。诸如更容易的审计、代码审查和版本控制等功能现在不仅适用于应用程序代码。其结果是,如果出现任何问题,恢复���工作状态只需要一个git revert命令。

您可以利用 Git 的签名提交、SSH 和 GPG 密钥来控制不同环境。通过添加一个门控机制,确保只有符合要求标准的提交才能推送到存储库,您还可以消除许多可能由手动运行sshkubectl命令而导致的意外错误。

可审计

您存储在版本控制系统中的所有内容都可以进行审计。在引入新代码之前,您进行代码审查。当您注意到错误时,您可以撤消引入错误的更改,或者返回到上一个工作版本。您的存储库成为关于整个系统的唯一真相。

将其应用于应用程序代码时已经很有用。然而,将审计配置、辅助服务、指标、仪表板甚至部署策略的能力扩展,使其变得更加强大。您不再需要问自己,“好吧,为什么这个配置最终进入了生产环境?”您只需要检查 Git 日志。

与已建立的组件集成

大多数 CI/CD 工具引入了专有的配置语法。Jenkins 使用 Jenkins DSL。每个流行的 SaaS 解决方案都使用 YAML,但这些 YAML 文件彼此不兼容。您无法在 Travis 和 CircleCI 之间切换,也无法在 CircleCI 和 GitLab CI 之间切换,而无需重写您的管道。

这有两个缺点。一个是明显的供应商锁定。另一个是需要学习配置语法以使用给定的工具。即使您的大部分管道已在其他地方定义(shell 脚本、Dockerfile 或 Kubernetes 清单),您仍然需要编写一些粘合代码来指示 CI/CD 工具使用它。

GitOps 与之不同。在这里,您不编写显式指令或使用专有语法。相反,您可以重用其他常见标准,例如 Helm 或 Kustomize。需要学习的内容更少,迁移过程更加轻松。此外,GitOps 工具通常与 CNCF 生态系统中的其他组件很好地集成,因此您可以将部署指标存储在 Prometheus 中,并使用 Grafana 进行审计。

配置漂移预防

配置漂移发生在给定系统的当前状态与存储库中描述的期望状态不同时。多种原因导致了配置漂移。

例如,让我们考虑一个具有基于 VM 的工作负载的配置管理工具。所有 VM 都以相同的状态启动。当 CM 第一次运行时,它将机器带到期望的状态。但是,如果默认情况下在这些机器上运行自动更新代理,该代理可能会自行更新一些软件包,而不考虑 CM 中的期望状态。此外,由于网络连接可能不稳定,一些机器可能会更新到软件包的新版本,而其他机器则不会。

在极端情况下,更新的软件包可能与您的应用程序所需的固定软件包不兼容。这种情况将破坏整个 CM 工作流程,并使您的机器处于不可用状态。

使用 GitOps,系统内始终运行着一个代理,用于跟踪系统的当前状态和期望状态。如果当前状态突然与期望状态不同,代理可以修复它或发出有关配置漂移的警报。

防止配置漂移为您的系统增加了另一层自愈。如果您正在运行 Kubernetes,您已经在 Pod 级别上具有自愈能力。每当一个 Pod 失败时,另一个将在其位置重新创建。如果您在其下使用可编程基础设施(例如云提供商或本地 OpenStack),您还具有节点的自愈能力。通过 GitOps,您可以获得工作负载及其配置的自愈。

GitOps 的好处

正如您可以想象的那样,GitOps 的描述功能提供了几个好处。以下是其中一些。

提高生产力

CI/CD 流水线已经自动化了许多常规任务。它们通过帮助进行更多部署来减少交付时间。GitOps 增加了一个反馈循环,可以防止配置漂移并允许自愈。这意味着您的团队可以更快地交付,并且不必担心引入潜在问题,因为它们很容易恢复。这反过来意味着开发吞吐量增加,您可以更快地引入新功能并更有信心。

更好的开发人员体验

使用 GitOps,开发人员不必担心构建容器���使用 kubectl 来控制集群。部署新功能只需要使用 Git,这在大多数环境中已经是一个熟悉的工具。

这也意味着入职速度更快,因为新员工不必学习很多新工具才能提高工作效率。GitOps 使用标准和一致的组件,因此对运营方面的更改不应影响开发人员。

更高的稳定性和可靠性

使用 Git 来存储系统状态意味着您可以访问审计日志。该日志包含所有引入的更改的描述。如果您的任务跟踪系统与 Git 集成(这是一个好习惯),通常可以确定与系统更改相关的业务功能。

使用 GitOps,不需要手动访问节点或整个集群的需求减少了,这减少了因运行无效命令而产生意外错误的机会。通过使用 Git 强大的还原功能,可以轻松修复系统中出现的随机错误。

从严重灾难(如失去整个控制平面)中恢复也更容易。所需的只是设置一个新的干净集群,在那里安装一个 GitOps 运算符,并将其指向具有您配置的存储库。不久之后,您将获得与以前的生产系统完全相同的副本,而无需手动干预。

提高安全性

减少对集群和节点的访问需求意味着提高了安全性。在丢失或盗窃密钥方面可以少操心。您避免了这样一种情况:即使某人不再在团队(或公司)工作,但仍然保留对生产环境的访问权限。

当涉及对系统的访问时,Git 存储库处理单一真相。即使恶意行为者决定在系统中引入后门,所需的更改也将经过代码审查。当您的存储库使用具有强验证的 GPG 签名提交时,冒充其他开发人员也更具挑战性。

到目前为止,我们主要讨论了从开发和运营角度的好处。但是 GitOps 也有利于业务。它为系统提供了业务可观察性,这是以前很难实现的。

很容易跟踪给定发布中存在的功能,因为它们都存储在 Git 中。由于 Git 提交了一个任务跟踪器的链接,业务人员可以获得预览链接,以查看应用程序在各种开发阶段的外观。

它还提供了清晰度,可以回答以下常见问题:

  • 生产环境中运行什么?

  • 上一个发布解决了哪些问题?

  • 哪个更改可能导致服务降级?

所有这些答案的问题甚至可以在友好的仪表板中呈现。当然,仪表板本身也可以存储在 Git 中。

GitOps 工具

GitOps 空间是一个新兴的领域。已经有一些可以被认为是稳定和成熟的工具。以下是一些最受欢迎的工具。

FluxCD

FluxCD 是 Kubernetes 的一个主观的 GitOps 操作者。选择的集成提供核心功能。它使用 Helm 图表和 Kustomize 来描述资源。

它与 Prometheus 的集成为部署过程增加了可观察性。为了帮助维护,FluxCD 具有 CLI。

ArgoCD

与 FluxCD 不同,它提供了更广泛的工具选择。如果您已经在配置中使用 Jsonnet 或 Ksonnet,这可能会很有用。与 FluxCD 一样,它与 Prometheus 集成,并具有 CLI。

在撰写本书时,ArgoCD 比 FluxCD 更受欢迎。

Jenkins X

与名称可能暗示的相反,Jenkins X 与著名的 Jenkins CI 系统没有太多共同之处。它由同一家公司支持,但 Jenkins 和 Jenkins X 的整个概念完全不同。

虽然其他两个工具都是特意小而自包含的,但 Jenkins X 是一个复杂的解决方案,具有许多集成和更广泛的范围。它支持触发自定义构建任务,使其看起来像是经典 CI/CD 系统和 GitOps 之间的桥梁。

总结

恭喜您完成了本章的阅读!使用现代 C++不仅仅是理解最近添加的语言特性。您的应用程序将在生产环境中运行。作为架构师,您还可以选择确保运行时环境符合要求。在前几章中,我们描述了分布式应用程序中的一些流行趋势。我们希��这些知识将帮助您决定哪种最适合您的产品。

进行云原生转型带来了许多好处,并且可以自动化大部分工作流程。将定制工具切换到行业标准可以使您的软件更具弹性并更易于更新。在本章中,我们已经涵盖了流行的云原生解决方案的优缺点和用例。

一些工具,如 Jaeger 的分布式跟踪,对大多数项目都带来了即时的好处。其他工具,如 Istio 或 Kubernetes,在大规模操作中表现最佳。阅读本章后,您应该具有足够的知识来决定是否值得为您的应用程序引入云原生设计。

问题

  1. 在云中运行应用程序和使其成为云原生之间有什么区别?

  2. 如何在本地运行云原生应用程序?

  3. Kubernetes 的最小高可用集群大小是多少?

  4. 哪个 Kubernetes 对象代表允许网络连接的微服务?

  5. 为什么在分布式系统中日志记录不足够?

  6. 服务网格如何帮助构建安全系统?

  7. GitOps 如何提高生产力?

  8. 监控的标准 CNCF 项目是什么?

进一步阅读

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值