SpringBoot3-和-SpringCLoud-微服务-全-

SpringBoot3 和 SpringCLoud 微服务(全)

原文:zh.annas-archive.org/md5/1722062224f06acf668c638d8a7630bb

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

本书是关于使用 Spring Boot 3 和 Spring Cloud 构建生产就绪微服务的。十年前,当我开始探索微服务时,我正在寻找这样的书籍。

本书是在我学习并掌握了用于开发、测试、部署和管理协作微服务景观的开源软件之后开发的。

本书主要涵盖 Spring Boot、Spring Cloud、Docker、Kubernetes、Istio、EFK 堆栈、Prometheus 和 Grafana。这些开源工具各自都非常出色,但理解如何以有利的方式将它们结合使用可能具有挑战性。在某些领域,它们相互补充,但在其他领域它们存在重叠,对于特定情况选择哪一个并不明显。

这是一本实践性书籍,逐步描述了如何将这些开源工具结合使用。这是我十年前开始学习微服务时一直在寻找的书籍,但现在它涵盖了开源工具的更新版本。

本书面向的对象

本书面向的是希望学习如何从头开始构建微服务景观并在本地或云中部署它们的 Java 和 Spring 开发人员以及架构师,使用 Kubernetes 作为容器编排器,Istio 作为服务网格。开始阅读本书不需要对微服务架构有任何了解。

本书涵盖的内容

第一章微服务简介,将帮助您理解本书的基本前提——微服务——以及与之相关的必要概念和设计模式。

第二章Spring Boot 简介,将向您介绍 Spring Boot 3 以及本书第一部分将使用的其他开源项目:用于开发 RESTful API 的 Spring WebFlux、用于生成基于 OpenAPI 的 API 文档的 springdoc-openapi、用于在 SQL 和 NoSQL 数据库中存储数据的 Spring Data、用于基于消息的微服务的 Spring Cloud Stream 以及用于将微服务作为容器运行的 Docker。

第三章创建一组协作微服务,将教会您从头开始创建一组协作微服务。您将使用 Spring Initializr 根据 Spring Framework 6.0 和 Spring Boot 3.0 创建基于 Spring 框架的骨架项目。

目标是创建三个核心服务(将处理自己的资源)和一个复合服务,该服务使用三个核心服务来聚合复合结果。在章节的末尾,您将学习如何添加基于 Spring WebFlux 的非常基本的 RESTful API。在接下来的章节中,将向这些微服务添加更多功能。

第四章, 使用 Docker 部署我们的微服务,将教会您如何使用 Docker 部署微服务。您将学习如何添加 Dockerfile 和 docker-compose 文件,以便使用单个命令启动整个微服务景观。然后,您将学习如何使用多个 Spring 配置文件来处理有和无 Docker 的配置。

第五章, 使用 OpenAPI 添加 API 描述,将使您熟悉使用 OpenAPI 记录微服务暴露的 API。您将使用 springdoc-openapi 工具注解服务,以动态创建基于 OpenAPI 的 API 文档。关键亮点将是如何使用 Swagger UI 在网页浏览器中测试 API。

第六章, 添加持久性,将向您展示如何向微服务的数据中添加持久性。您将使用 Spring Data 为两个核心微服务设置和访问 MongoDB 文档数据库中的数据,并为剩余的微服务访问 MySQL 关系数据库中的数据。在运行集成测试时,将使用 Testcontainers 启动数据库。

第七章, 开发响应式微服务,将向您讲解为什么以及何时响应式方法很重要,以及如何开发端到端响应式服务。您将学习如何开发和测试非阻塞同步 RESTful API 和异步事件驱动服务。您还将学习如何使用 MongoDB 的响应式非阻塞驱动程序,以及如何使用传统的阻塞代码来处理 MySQL。

第八章, Spring Cloud 简介,将向您介绍 Spring Cloud 以及本书中将使用的 Spring Cloud 组件。

第九章, 使用 Netflix Eureka 添加服务发现,将向您展示如何在 Spring Cloud 中使用 Netflix Eureka 来添加服务发现功能。这将通过向系统景观中添加一个基于 Netflix Eureka 的服务发现服务器来实现。然后,您将配置微服务使用 Spring Cloud LoadBalancer 来查找其他微服务。您将了解微服务是如何自动注册的,以及当新实例可用时,通过 Spring Cloud LoadBalancer 自动负载均衡到新实例的流量。

第十章, 使用 Spring Cloud Gateway 在边缘服务器后面隐藏微服务,将指导您如何使用 Spring Cloud Gateway 在边缘服务器后面隐藏微服务,并且只向外部消费者公开选择 API。您还将学习如何从外部消费者隐藏微服务的内部复杂性。这将通过向系统景观中添加一个基于 Spring Cloud Gateway 的边缘服务器并配置它只公开公共 API 来实现。

第十一章保护 API 访问安全,将解释如何使用 OAuth 2.0 和 OpenID Connect 保护暴露的 API。你将学习如何将基于 Spring Authorization Server 的 OAuth 2.0 授权服务器添加到系统架构中,以及如何配置边缘服务器和组合服务以要求由该授权服务器签发的有效访问令牌。

你将学习如何通过边缘服务器暴露授权服务器,并使用 HTTPS 确保其与外部消费者的通信安全。最后,你将学习如何用 Auth0 的外部 OpenID Connect 提供者替换内部的 OAuth 2.0 授权服务器。

第十二章集中式配置,将处理如何从所有微服务中收集配置文件到一个中央存储库,并使用配置服务器在运行时将配置分发到微服务。你还将学习如何将 Spring Cloud Config Server 添加到系统架构中,并配置所有微服务使用 Spring Config Server 来获取其配置。

第十三章使用 Resilience4j 提高弹性,将解释如何使用 Resilience4j 的能力来防止例如“失败链”反模式。你将学习如何向组合服务添加重试机制和断路器,如何配置断路器在电路打开时快速失败,以及如何利用回退方法创建尽力而为的响应。

第十四章理解分布式跟踪,将展示如何使用 Zipkin 收集和可视化跟踪信息。你还将使用 Micrometer Tracing 将跟踪 ID 添加到请求中,以便可视化协作微服务之间的请求链。

第十五章Kubernetes 简介,将解释 Kubernetes 的核心概念以及如何执行一个示例部署。你还将学习如何使用 Minikube 在本地设置 Kubernetes 以用于开发和测试目的。

第十六章将我们的微服务部署到 Kubernetes,将展示如何在 Kubernetes 上部署微服务。你还将学习如何使用 Helm 打包和配置微服务以便在 Kubernetes 上部署。Helm 将被用于部署针对不同运行环境(如测试和生产环境)的微服务。最后,你将学习如何用 Kubernetes 内置的服务发现支持替换 Netflix Eureka,这基于 Kubernetes 服务对象和 kube-proxy 运行时组件。

第十七章通过实现 Kubernetes 功能简化系统架构,将解释如何使用 Kubernetes 功能作为替代上一章中介绍的 Spring Cloud 服务的方案。你将了解为什么以及如何用 Kubernetes Secrets 和 ConfigMaps 替换 Spring Cloud Config Server,以及为什么以及如何用 Kubernetes Ingress 对象替换 Spring Cloud Gateway,并学习如何添加 cert-manager 以自动为外部 HTTPS 端点提供和轮换证书。

第十八章使用服务网格提高可观察性和管理,将介绍服务网格的概念,并解释如何使用 Istio 在 Kubernetes 中运行时实现服务网格。你将学习如何使用服务网格进一步改进微服务景观的弹性、安全性、流量管理和可观察性。

第十九章使用 EFK Stack 进行集中日志记录,将解释如何使用 Elasticsearch、Fluentd 和 Kibana(EFK Stack)来收集、存储和可视化来自微服务的日志流。你将学习如何在 Minikube 中部署 EFK Stack,以及如何使用它来分析收集到的日志记录并找到涉及跨多个微服务的请求处理的微服务的日志输出。你还将学习如何使用 EFK Stack 进行根本原因分析。

第二十章监控微服务,将展示如何使用 Prometheus 和 Grafana 监控在 Kubernetes 中部署的微服务。你将学习如何使用 Grafana 中的现有仪表板来监控不同类型的指标,并学习如何创建自己的仪表板。最后,你将学习如何在 Grafana 中创建警报,当配置的阈值被选定的指标超过时,这些警报将被用来发送带有警报的电子邮件。

第二十一章macOS 安装说明,将展示如何在 Mac 上安装本书中使用的工具。涵盖了基于 Intel 和 Apple 硅(ARM64)的 Mac。

第二十二章使用 WSL 2 和 Ubuntu 的 Microsoft Windows 安装说明,将展示如何使用 Windows Subsystem for Linux (WSL) v2 在 Windows PC 上安装本书中使用的工具。

第二十三章原生编译的 Java 微服务,将展示如何创建编译为原生代码的基于 Spring 的微服务。你将学习如何使用 Spring Framework 6 和 Spring Boot 3 中的新原生映像支持以及底层的 GraalVM Native Image 编译器。与使用常规的 Java 虚拟机相比,这将导致可以几乎立即启动的微服务。

每章结束时,你将找到一些简单的问题,这些问题将帮助你回顾章节中涵盖的一些内容。"评估" 是一个可以在 GitHub 仓库中找到的文件,其中包含这些问题的答案。

为了充分利用本书

推荐具备 Java 和 Spring 的基本理解。

要运行本书中的所有内容,您需要拥有一台基于 Mac Intel 或 Apple 硅的电脑或至少 16GB 内存的 PC,尽管建议您至少有 24GB,因为随着本书末尾微服务领域变得更加复杂和资源密集,所需的资源也更多。

要获取软件要求的完整列表以及设置环境以便能够跟随本书的详细说明,请参阅第二十一章(针对 macOS)和第二十二章(针对 Windows)。

下载示例代码文件

本书代码包托管在 GitHub 上,网址为github.com/PacktPublishing/Microservices-with-Spring-Boot-and-Spring-Cloud-Third-Edition。我们还有其他丰富的图书和视频代码包,可在github.com/PacktPublishing/找到。请查看它们!

下载彩色图像

我们还提供了一份包含本书中使用的截图/图表彩色图像的 PDF 文件。您可以从这里下载:packt.link/XHJmq

使用的约定

本书使用了多种文本约定。

CodeInText: 表示文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 昵称。例如:“java插件将 Java 编译器添加到项目中。”

代码块设置如下:

package se.magnus.microservices.core.product;
@SpringBootApplication
public class ProductServiceApplication { 

当我们希望您注意代码块中的特定部分时,相关的行或项目将以粗体显示:

package se.magnus.microservices.core.product;
**@SpringBootTest**
class ProductServiceApplicationTests { 

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

mkdir some-temp-folder
cd some-temp-folder 

粗体: 表示新术语、重要单词或屏幕上看到的单词。例如,菜单或对话框中的单词在文本中显示如下:“使用Spring Initializr为每个微服务生成骨架项目。”

警告或重要注意事项看起来像这样。

技巧和窍门看起来像这样。

联系我们

我们始终欢迎读者的反馈。

一般反馈: 请通过feedback@packtpub.com发送电子邮件,并在邮件主题中提及本书的标题。如果您对本书的任何方面有疑问,请通过questions@packtpub.com与我们联系。

勘误: 尽管我们已经尽最大努力确保内容的准确性,但错误仍然可能发生。如果您在这本书中发现了错误,如果您能向我们报告,我们将不胜感激。请访问www.packtpub.com/submit-errata,点击提交勘误,并填写表格。

盗版: 如果您在互联网上以任何形式遇到我们作品的非法副本,如果您能向我们提供位置地址或网站名称,我们将不胜感激。请通过copyright@packtpub.com与我们联系,并提供材料的链接。

如果您想成为一名作者:如果您在某个领域有专业知识,并且您有兴趣撰写或为书籍做出贡献,请访问authors.packtpub.com

分享您的想法

一旦您阅读了《使用 Spring Boot 3 和 Spring Cloud 的微服务,第三版》,我们很乐意听听您的想法!请点击此处直接进入此书的亚马逊评论页面并分享您的反馈。

您的评论对我们和科技社区非常重要,并将帮助我们确保我们提供高质量的内容。

下载此书的免费 PDF 副本

感谢您购买此书!

您喜欢在路上阅读,但无法携带您的印刷书籍到处走?您的电子书购买是否与您选择的设备不兼容?

别担心,现在每购买一本 Packt 图书,您都可以免费获得该书的 DRM 免费 PDF 版本。

在任何地方、任何设备上阅读。从您最喜欢的技术书籍中直接搜索、复制和粘贴代码到您的应用程序中。

优惠远不止这些,您还可以获得独家折扣、时事通讯和丰富的免费内容,每天直接发送到您的邮箱。

按照以下简单步骤获取好处:

  1. 扫描下面的二维码或访问以下链接

packt.link/free-ebook/9781805128694

  1. 提交您的购买证明

  2. 那就结束了!我们将直接将您的免费 PDF 和其他好处发送到您的电子邮件。

目录

  1. 前言

    1. 本书面向的对象

    2. 本书涵盖的内容

    3. 联系我们

    4. 分享您的想法

  2. 微服务简介

    1. 技术要求

    2. 我的微服务之路

      1. 自主软件组件的优势

      2. 自主软件组件的挑战

      3. 进入微服务

      4. 一个示例微服务景观

    3. 定义微服务

    4. 微服务面临的挑战

    5. 微服务的设计模式

      1. 服务发现

        1. 问题

        2. 解决方案

        3. 解决方案需求

      2. 边缘服务器

        1. 问题

        2. 解决方案

        3. 解决方案需求

      3. 响应式微服务

        1. 问题

        2. 解决方案

        3. 解决方案需求

      4. 集中配置

        1. 问题

        2. 解决方案

        3. 解决方案需求

      5. 集中日志分析

        1. 问题

        2. 解决方案

        3. 解决方案需求

      6. 分布式追踪

        1. 问题

        2. 解决方案

        3. 解决方案需求

      7. 断路器

        1. 问题

        2. 解决方案

        3. 解决方案需求

      8. 控制循环

        1. 问题

        2. 解决方案

        3. 解决方案需求

      9. 集中监控和警报

        1. 问题

        2. 解决方案

        3. 解决方案需求

    6. 软件使能器

    7. 其他重要注意事项

    8. 总结

  3. Spring Boot 简介

    1. 技术要求

    2. Spring Boot

      1. 约定优于配置和胖 JAR 文件

      2. 设置 Spring Boot 应用程序的代码示例

        1. 神奇的 @SpringBootApplication 注解

        2. 组件扫描

        3. 基于 Java 的配置

      3. Spring Boot 3.0 的新特性

      4. 迁移 Spring Boot 2 应用程序

    3. Spring WebFlux

      1. 设置 REST 服务的代码示例

        1. 启动依赖项

        2. 属性文件

        3. 示例 RestController

    4. springdoc-openapi

    5. Spring 数据

      1. 实体

      2. 存储库

    6. Spring Cloud Stream

      1. 发送和接收消息的代码示例
    7. Docker

    8. 总结

    9. 问题

  4. 创建一组协作的微服务

    1. 技术要求

    2. 介绍微服务景观

      1. 由微服务处理的信息

        1. 产品服务

        2. 审查服务

        3. 推荐服务

        4. 产品组合服务

        5. 与基础设施相关的信息

      2. 临时替换服务发现

    3. 生成骨架微服务

      1. 使用 Spring Initializr 生成骨架代码

      2. 在 Gradle 中设置多项目构建

    4. 添加 RESTful API

      1. 添加 API 和 util 项目

        1. api 项目

        2. util 项目

      2. 实现我们的 API

    5. 添加复合微服务

      1. API 类

      2. 属性

      3. 集成组件

      4. 复合 API 实现

    6. 添加错误处理

      1. 全局 REST 控制器异常处理器

      2. API 实现中的错误处理

      3. API 客户端中的错误处理

    7. 手动测试 API

    8. 在隔离中添加自动微服务测试

    9. 添加微服务景观的半自动化测试

      1. 尝试测试脚本
    10. 总结

    11. 问题

  5. 使用 Docker 部署我们的微服务

    1. 技术要求

    2. Docker 简介

      1. 运行我们的第一个 Docker 命令
    3. 在 Docker 中运行 Java

      1. 限制可用的 CPU

      2. 限制可用的内存

    4. 使用 Docker 与单个微服务

      1. 源代码中的更改

      2. 构建 Docker 镜像

      3. 启动服务

      4. 以分离模式运行容器

    5. 使用 Docker Compose 管理微服务景观

      1. 源代码中的更改

      2. 启动微服务架构

    6. 自动化测试协作微服务

      1. 调试测试运行
    7. 总结

    8. 问题

  6. 使用 OpenAPI 添加 API 描述

    1. 技术要求

    2. Springdoc-openapi 使用介绍

    3. 将 springdoc-openapi 添加到源代码中

      1. 向 Gradle 构建文件添加依赖项

      2. 向 ProductCompositeService 添加 OpenAPI 配置和通用 API 文档

      3. 向 ProductCompositeService 接口添加 API 特定文档

    4. 构建和启动微服务架构

    5. 尝试 OpenAPI 文档

    6. 总结

    7. 问题

  7. 添加持久性

    1. 技术要求

    2. 章节目标

    3. 向核心微服务添加持久层

      1. 添加依赖项

      2. 使用实体类存储数据

      3. 在 Spring Data 中定义仓库

    4. 编写关注持久性的自动化测试

      1. 使用 Testcontainers

      2. 编写持久性测试

    5. 在服务层中使用持久层

      1. 记录数据库连接 URL

      2. 添加新的 API

      3. 从服务层调用持久层

      4. 声明 Java Bean 映射器

      5. 更新服务测试

    6. 扩展组合服务 API

      1. 向组合服务 API 添加新操作

      2. 向集成层添加方法

      3. 实现新的组合 API 操作

      4. 更新组合服务测试

    7. 向 Docker Compose 架构添加数据库

      1. Docker Compose 配置

      2. 数据库连接配置

      3. MongoDB 和 MySQL CLI 工具

    8. 对新 API 和持久层进行手动测试

    9. 更新微服务景观的自动化测试

    10. 总结

    11. 问题

  8. 开发反应式微服务

    1. 技术要求

    2. 在非阻塞同步 API 和事件驱动异步服务之间选择

    3. 开发非阻塞同步 REST API

      1. Project Reactor 简介

      2. 使用 Spring Data for MongoDB 进行非阻塞持久化

        1. 测试代码的变更
      3. 核心服务中的非阻塞 REST API

        1. API 变更

        2. 服务实现变更

        3. 测试代码的变更

        4. 处理阻塞代码

      4. 组合服务中的非阻塞 REST API

        1. API 变更

        2. 服务实现变更

        3. 集成层的变更

        4. 测试代码的变更

    4. 开发事件驱动异步服务

      1. 处理消息挑战

        1. 消费者组

        2. 重试和死信队列

        3. 保证顺序和分区

      2. 定义主题和事件

      3. Gradle 构建文件的变更

      4. 在核心服务中消费事件

        1. 声明消息处理器

        2. 服务实现变更

        3. 为消费事件添加配置

        4. 测试代码的变更

      5. 在组合服务中发布事件

        1. 在集成层发布事件

        2. 为发布事件添加配置

        3. 测试代码的变更

    5. 运行反应式微服务景观的手动测试

      1. 保存事件

      2. 添加健康 API

      3. 不使用分区使用 RabbitMQ

      4. 使用分区与 RabbitMQ

      5. 使用每个主题两个分区的 Kafka

    6. 运行反应式微服务架构的自动化测试

    7. 总结

    8. 问题

  9. Spring Cloud 简介

    1. 技术要求

    2. Spring Cloud 的演变

    3. 使用 Netflix Eureka 进行服务发现

    4. 使用 Spring Cloud Gateway 作为边缘服务器

    5. 使用 Spring Cloud Config 进行集中配置

    6. 使用 Resilience4j 提高弹性

      1. Resilience4j 中熔断器的示例用法
    7. 使用 Micrometer Tracing 和 Zipkin 进行分布式跟踪

    8. 总结

    9. 问题

  10. 使用 Netflix Eureka 添加服务发现

    1. 技术要求

    2. 介绍服务发现

      1. 基于 DNS 的服务发现的问题

      2. 服务发现挑战

      3. 在 Spring Cloud 中使用 Netflix Eureka 进行服务发现

    3. 设置 Netflix Eureka 服务器

    4. 将微服务连接到 Netflix Eureka 服务器

    5. 设置开发使用的配置

      1. Eureka 配置参数

      2. 配置 Eureka 服务器

      3. 配置 Eureka 服务器的客户端

    6. 尝试发现服务

      1. 扩大规模

      2. 缩小规模

      3. 使用 Eureka 服务器进行破坏性测试

        1. 停止 Eureka 服务器

        2. 启动产品服务的额外实例

    7. 再次启动 Eureka 服务器

    8. 总结

    9. 问题

  11. 使用 Spring Cloud Gateway 在边缘服务器后面隐藏微服务

    1. 技术要求

    2. 将边缘服务器添加到我们的系统架构中

    3. 设置 Spring Cloud Gateway

      1. 添加组合健康检查

      2. 配置 Spring Cloud Gateway

        1. 路由规则
    4. 尝试使用边缘服务器

      1. 检查 Docker 引擎外部暴露的内容

      2. 尝试路由规则

        1. 通过边缘服务器调用产品组合 API

        2. 通过边缘服务器调用 Swagger UI

        3. 通过边缘服务器调用 Eureka

        4. 基于主机头进行路由

    5. 总结

    6. 问题

  12. 保护 API 访问

    1. 技术要求

    2. OAuth 2.0 和 OpenID Connect 简介

      1. 介绍 OAuth 2.0

      2. 介绍 OpenID Connect

    3. 保护系统景观

    4. 使用 HTTPS 保护外部通信

      1. 在运行时替换自签名证书
    5. 保护发现服务器的访问

      1. Eureka 服务器的变化

      2. Eureka 客户端的变化

    6. 添加本地授权服务器

    7. 使用 OAuth 2.0 和 OpenID Connect 保护 API

      1. 边缘服务器和产品组合服务的变化

      2. 仅针对产品组合服务的变化

        1. 更改以允许 Swagger UI 获取访问令牌
      3. 测试脚本的变化

    8. 使用本地授权服务器进行测试

      1. 构建和运行自动化测试

      2. 测试受保护发现服务器

      3. 获取访问令牌

        1. 使用客户端凭据授权流程获取访问令牌

        2. 使用授权码授权流程获取访问令牌

      4. 使用访问令牌调用受保护 API

      5. 使用 OAuth 2.0 测试 Swagger UI

    9. 使用外部 OpenID Connect 提供者进行测试

      1. 在 Auth0 中设置和配置账户

      2. 应用必要的更改以使用 Auth0 作为 OpenID 提供者

        1. 更改 OAuth 资源服务器中的配置

        2. 更改测试脚本以从 Auth0 获取访问令牌

      3. 使用 Auth0 作为 OpenID Connect 提供者运行测试脚本

      4. 使用客户端凭据授权流程获取访问令牌

      5. 使用授权代码授权流程获取访问令牌

      6. 使用 Auth0 访问令牌调用受保护的 API

      7. 获取有关用户的额外信息

    10. 摘要

    11. 问题

  13. 集中式配置

    1. 技术要求

    2. Spring Cloud Config Server 简介

      1. 选择配置存储库的存储类型

      2. 决定初始客户端连接

      3. 保护配置

        1. 保护传输中的配置

        2. 保护静态配置

      4. 介绍配置服务器 API

    3. 设置配置服务器

      1. 在边缘服务器上设置路由规则

      2. 为与 Docker 一起使用配置配置服务器

    4. 配置配置服务器的客户端

      1. 配置连接信息
    5. 结构化配置存储库

    6. 尝试使用 Spring Cloud Config Server

      1. 构建和运行自动化测试

      2. 使用配置服务器 API 获取配置

      3. 加密和解密敏感信息

    7. 摘要

    8. 问题

  14. 使用 Resilience4j 改进弹性

    1. 技术要求

    2. 介绍 Resilience4j 弹性机制

      1. 介绍断路器

      2. 介绍时间限制器

      3. 介绍重试机制

    3. 将弹性机制添加到源代码中

      1. 添加可编程延迟和随机错误

        1. API 定义中的更改

        2. 产品组合微服务中的更改

        3. 产品微服务中的更改

      2. 添加断路器和时间限制器

        1. 将依赖项添加到构建文件中

        2. 在源代码中添加注解

        3. 添加快速失败回退逻辑

        4. 添加配置

      3. 添加重试机制

        1. 添加重试注解

        2. 添加配置

      4. 添加自动化测试

    4. 尝试断路器和重试机制

      1. 构建和运行自动化测试

      2. 验证在正常操作下电路是否闭合

      3. 在出错时强制打开断路器

      4. 再次关闭断路器

      5. 尝试由随机错误引起的重试

    5. 总结

    6. 问题

  15. 理解分布式跟踪

    1. 技术要求

    2. 使用 Micrometer Tracing 和 Zipkin 介绍分布式跟踪

    3. 将分布式跟踪添加到源代码中

      1. 将依赖项添加到构建文件中

      2. 添加 Micrometer Tracing 和 Zipkin 的配置

      3. 将 Zipkin 添加到 Docker Compose 文件中

      4. 添加对缺乏支持反应式客户端的解决方案

      5. 向现有跨度添加自定义跨度自定义标签

        1. 添加自定义跨度

        2. 向现有跨度添加自定义标签

    4. 尝试分布式跟踪

      1. 启动系统景观

      2. 发送成功的 API 请求

      3. 发送失败的 API 请求

      4. 发送触发异步处理的 API 请求

    5. 总结

    6. 问题

  16. Kubernetes 简介

    1. 技术要求

    2. 介绍 Kubernetes 概念

    3. 介绍 Kubernetes API 对象

    4. 介绍 Kubernetes 运行时组件

    5. 使用 Minikube 创建 Kubernetes 集群

      1. 使用 Minikube 配置文件

      2. 使用 Kubernetes CLI,kubectl

      3. 使用 kubectl 管理上下文

      4. 创建 Kubernetes 集群

    6. 尝试一个示例部署

    7. 管理本地 Kubernetes 集群

      1. 休眠和恢复 Kubernetes 集群

      2. 终止 Kubernetes 集群

    8. 总结

    9. 问题

  17. 将我们的微服务部署到 Kubernetes

    1. 技术要求

    2. 用 Kubernetes 服务替换 Netflix Eureka

    3. 介绍 Kubernetes 的使用方法

    4. 使用 Spring Boot 对优雅关闭和存活性/就绪性探测的支持

    5. 介绍 Helm

      1. 运行 Helm 命令

      2. 查看 Helm 图表

      3. Helm 模板和值

      4. 通用库图表

        1. ConfigMap 模板

        2. 秘密模板

        3. 服务模板

        4. 部署模板

      5. 组件图表

      6. 环境图表

    6. 将应用部署到 Kubernetes 进行开发和测试

      1. 构建 Docker 镜像

      2. 解决 Helm 图表依赖关系

      3. 将应用部署到 Kubernetes

      4. 用于 Kubernetes 的测试脚本更改

      5. 测试部署

        1. 测试 Spring Boot 对优雅关闭和存活性/就绪性探测的支持
    7. 将应用部署到 Kubernetes 进行预发布和生产

      1. 源代码中的更改

      2. 将应用部署到 Kubernetes

      3. 清理

    8. 总结

    9. 问题

  18. 实现 Kubernetes 功能以简化系统架构

    1. 技术要求

    2. 替换 Spring Cloud Config Server

      1. 替换 Spring Cloud Config Server 所需的更改
    3. 替换 Spring Cloud Gateway

      1. 替换 Spring Cloud Gateway 所需的更改
    4. 自动化证书提供

    5. 使用 Kubernetes ConfigMaps、Secrets、Ingress 和 cert-manager 进行测试

      1. 轮换证书

      2. 部署到 Kubernetes 进行预演和生产

    6. 验证微服务在没有 Kubernetes 的情况下工作

      1. Docker Compose 文件中的更改

      2. 使用 Docker Compose 进行测试

    7. 摘要

    8. 问题

  19. 使用服务网格提高可观察性和管理

    1. 技术要求

    2. 使用 Istio 介绍服务网格

      1. 介绍 Istio

      2. 将 Istio 代理注入到微服务中

      3. 介绍 Istio API 对象

    3. 简化微服务景观

      1. 用 Istio 入口网关替换 Kubernetes Ingress 控制器

      2. 用 Istio 的 Jaeger 组件替换 Zipkin 服务器

    4. 在 Kubernetes 集群中部署 Istio

      1. 设置访问 Istio 服务的权限
    5. 创建服务网格

      1. 源代码更改

        1. 在 _istio_base.yaml 模板中的内容

        2. 在 _istio_dr_mutual_tls.yaml 模板中的内容

      2. 运行命令创建服务网格

      3. 记录跟踪和跨度 ID 的传播

    6. 观察服务网格

    7. 保护服务网格

      1. 使用 HTTPS 和证书保护外部端点

      2. 使用 OAuth 2.0/OIDC 访问令牌验证外部请求

      3. 使用双向身份验证(mTLS)保护内部通信

    8. 确保服务网格具有弹性

      1. 通过注入故障测试弹性

      2. 通过注入延迟测试弹性

    9. 执行零停机更新

      1. 源代码更改

        1. 虚拟服务和目标规则

        2. 部署和服务

        3. 在 prod-env Helm 图表中整合所有内容

      2. 部署微服务的 v1 和 v2 版本,并通过路由到 v1 版本

      3. 验证所有流量最初都流向微服务的 v1 版本

      4. 运行金丝雀测试

      5. 运行蓝绿部署

        1. kubectl patch 命令的简要介绍

        2. 执行蓝绿部署

    10. 使用 Docker Compose 运行测试

    11. 总结

    12. 问题

  20. 使用 EFK 堆栈进行集中式日志记录

    1. 技术要求

    2. 介绍 Fluentd

      1. Fluentd 概述

      2. 配置 Fluentd

    3. 在 Kubernetes 上部署 EFK 堆栈

      1. 构建和部署我们的微服务

      2. 部署 Elasticsearch 和 Kibana

        1. manifest 文件的概述

        2. 运行部署命令

      3. 部署 Fluentd

        1. manifest 文件的概述

        2. 运行部署命令

    4. 尝试 EFK 堆栈

      1. 初始化 Kibana

      2. 分析日志记录

      3. 从微服务中发现日志记录

      4. 执行根本原因分析

    5. 总结

    6. 问题

  21. 监控微服务

    1. 技术要求

    2. 使用 Prometheus 和 Grafana 进行性能监控简介

    3. 修改源代码以收集应用程序度量

    4. 构建和部署微服务

    5. 使用 Grafana 仪表板监控微服务

      1. 为测试安装本地邮件服务器

      2. 配置 Grafana

      3. 启动负载测试

      4. 使用 Kiali 内置仪表板

      5. 导入现有的 Grafana 仪表板

      6. 开发您自己的 Grafana 仪表板

        1. 检查 Prometheus 指标

        2. 创建仪表板

        3. 尝试新的仪表板

      7. 导出和导入 Grafana 仪表板

    6. 在 Grafana 中设置警报

      1. 设置基于邮件的通知通道

      2. 在断路器上设置警报

      3. 尝试断路器警报

    7. 总结

    8. 问题

  22. macOS 安装说明

    1. 技术要求

    2. 安装工具

      1. 安装 Homebrew

      2. 使用 Homebrew 安装工具

      3. 不使用 Homebrew 安装工具

        1. 在基于 Intel 的 Mac 上安装工具

        2. 在基于 Apple 硅的 Mac 上安装工具

      4. 安装后的操作

      5. 验证安装

    3. 访问源代码

      1. 使用 IDE

      2. 代码结构

    4. 总结

  23. 带有 WSL 2 和 Ubuntu 的 Microsoft Windows 安装说明

    1. 技术要求

    2. 安装工具

      1. 在 Windows 上安装工具

        1. 与默认 Ubuntu 服务器一起安装 WSL 2

        2. 在 WSL 2 上安装新的 Ubuntu 22.04 服务器

        3. 安装 Windows Terminal

        4. 安装 Docker Desktop for Windows

        5. 安装 Visual Studio Code 及其远程 WSL 扩展

      2. 在 WSL 2 的 Linux 服务器上安装工具

        1. 使用 apt install 安装工具

        2. 使用 SDKman 安装 Java 和 Spring Boot CLI

        3. 使用 curl 和 install 安装剩余的工具

        4. 验证安装

    3. 访问源代码

      1. 代码结构
    4. 总结

  24. 原生编译的 Java 微服务

    1. 技术要求

    2. 何时原生编译 Java 源代码

    3. 介绍 GraalVM 项目

    4. 介绍 Spring 的 AOT 引擎

    5. 处理原生编译问题

    6. 源代码中的更改

      1. Gradle 构建文件的更新

      2. 提供可达性元数据和自定义提示

      3. 在 application.yml 文件中构建时启用 Spring Bean

      4. 更新运行时属性

      5. 配置 GraalVM 原生镜像跟踪代理

      6. test-em-all.bash 验证脚本的更新

    7. 测试和编译原生镜像

      1. 安装 GraalVM 及其原生镜像编译器

      2. 运行跟踪代理

      3. 运行原生测试

      4. 为当前操作系统创建原生镜像

      5. 将原生镜像作为 Docker 镜像创建

    8. 使用 Docker Compose 进行测试

      1. 禁用 AOT 模式测试基于 Java VM 的微服务

      2. 启用 AOT 模式测试基于 Java VM 的微服务

      3. 测试原生编译的微服务

    9. 使用 Kubernetes 进行测试

    10. 摘要

    11. 问题

  25. 你可能喜欢的其他书籍

  26. 索引

标记

  1. 封面

  2. 索引

第一章:微服务简介

本书并不盲目赞扬微服务。相反,它讲述了我们如何利用其优势,同时能够处理构建可扩展、弹性好且可管理的微服务的挑战。

作为本书的介绍,本章将涵盖以下主题:

  • 我进入微服务之路

  • 什么是基于微服务的架构?

  • 微服务的挑战

  • 应对挑战的设计模式

  • 可以帮助我们处理这些挑战的软件赋能者

  • 本书未涵盖的其他重要考虑因素

技术要求

本章不需要安装。然而,你可能对查看 C4 模型规范感兴趣,c4model.com,因为本章的插图灵感来源于 C4 模型。

本章不包含任何源代码。

我进入微服务之路

当我在 2014 年首次了解到微服务的概念时,我意识到我多年来一直在不知情的情况下开发微服务(好吧,有点像)。我参与了一个 2009 年开始的项目,我们基于一组分离的特性开发了一个平台。该平台被交付给多个客户,他们在本地部署了它。为了使客户能够轻松选择他们想要从平台中使用的功能,每个特性都被开发为一个自主软件组件;也就是说,它有自己的持久化数据,并且仅通过定义良好的 API 与其他组件进行通信。

由于我无法讨论项目中具体的功能,我已经将组件的名称进行了泛化,这些组件被标记为从组件 A组件 F。平台的组成作为一组组件的图示如下:

图描述自动生成

图 1.1:平台组成

从图中我们还可以看到,每个组件都有自己的持久化数据存储,并且不与其他组件共享数据库。

每个组件都是使用 Java 和 Spring 框架开发的,打包为 WAR 文件,并在 Java EE Web 容器中作为 Web 应用部署,例如 Apache Tomcat。根据客户的具体要求,平台可以部署在单个或多个服务器上。一个双节点部署可能如下所示:

图描述自动生成

图 1.2:双节点部署场景

自主软件组件的好处

从这个项目中,我了解到将平台的职能分解为一系列自主软件组件提供了许多好处:

  • 客户可以在自己的系统环境中部署平台的部分,通过其定义良好的 API 将其与其现有系统集成。

    以下是一个示例,其中一位客户决定从平台部署组件 A组件 B组件 D组件 E,并将它们与客户系统环境中的两个现有系统系统 A系统 B集成:

图示  自动生成的描述

图 1.3:平台的局部部署

  • 另一位客户可以选择用客户系统环境中已有的实现来替换平台功能的一部分,这可能会要求在平台 API 中采用一些现有功能。

    以下是一个示例,其中客户已将平台中的组件 C组件 F替换为自己的实现:

图示  自动生成的描述

图 1.4:替换平台的部分

  • 平台中的每个组件都可以单独交付和升级。得益于使用定义良好的 API,一个组件可以升级到新版本,而无需依赖于其他组件的生命周期。

    以下是一个示例,其中组件 A已从版本v1.1升级到v1.2。调用组件 A组件 B不需要升级,因为它使用的是定义良好的 API;也就是说,升级后它仍然是相同的(或者至少是向后兼容的):

图形用户界面  自动生成的描述

图 1.5:升级特定组件

  • 由于使用了定义良好的 API,平台中的每个组件也可以独立于其他组件扩展到多个服务器。扩展可以是满足高可用性要求或处理更高请求量的需要。在这个特定项目中,这是通过在运行 Java EE Web 容器的多个服务器前手动设置负载均衡器来实现的。组件 A已扩展到三个实例的示例如下:

图 1.6:扩展平台

自主软件组件的挑战

我的团队还了解到,将平台分解引入了许多新的挑战,这些挑战在我们开发更传统的、单体式应用时并未遇到(至少没有达到同样的程度):

  • 向组件添加新实例需要手动配置负载均衡器并手动设置新节点。这项工作既耗时又容易出错。

  • 平台最初容易受到它所通信的其他系统引起的错误的影响。如果一个系统未能及时响应平台发送的请求,平台很快就会耗尽关键资源,例如,操作系统线程,尤其是在面对大量并发请求时。这导致平台中的组件挂起甚至崩溃。由于平台中的大多数通信都是基于同步通信,一个组件崩溃可能导致级联故障;也就是说,崩溃组件的客户端也可能在一段时间后崩溃。这被称为故障链

  • 保持所有组件实例的配置一致并更新迅速成为一个问题,导致大量手动和重复性的工作。这有时会导致质量问题。

  • 监控平台在延迟问题和硬件使用(例如,CPU、内存、磁盘和网络的使用)方面的状态,与监控单体应用的单一实例相比,要复杂得多。

  • 从多个分布式组件收集日志文件并关联相关日志事件也是困难的,但由于组件数量固定且事先已知,因此是可行的。

随着时间的推移,我们通过内部开发的工具和针对手动处理这些挑战的详细说明,解决了前面提到的大多数挑战。操作的规模通常在可以接受的水平上,即使它们不是理想的,手动发布组件新版本和处理运行时问题也是可行的。

进入微服务时代

2014 年了解基于微服务的架构让我意识到,其他项目也一直在努力应对类似的挑战(部分原因不同于我之前描述的原因,例如,大型云服务提供商满足 Web 规模需求)。许多微服务先驱已经发布了他们所学到经验教训的细节。从这些经验教训中学习非常有趣。

许多先驱最初开发了单体应用,从商业角度来看,它们非常成功。但随着时间的推移,这些单体应用变得越来越难以维护和演进。它们也变得难以扩展到最大机器的能力之外(也称为垂直扩展)。最终,先驱们开始寻找将单体应用拆分成更小组件的方法,这些组件可以独立发布和扩展。可以通过水平扩展来实现小组件的扩展,即在多个较小的服务器上部署组件,并在其前面放置一个负载均衡器。如果在云中执行,扩展能力可能是无限的——这仅仅是一个关于你将多少虚拟服务器引入的问题(假设你的组件可以在大量实例上扩展,但关于这一点稍后还会详细说明)。

在 2014 年,我还了解到了一些新的开源项目,它们提供了工具和框架,简化了微服务的开发,并可用于处理基于微服务架构带来的挑战。

其中一些如下:

  • Pivotal 发布了Spring Cloud,它封装了Netflix OSS的部分功能,以提供动态服务发现、配置管理、分布式跟踪、断路器等功能。

  • 我还了解到了Docker和容器革命,这对于缩小开发和生产之间的差距非常有帮助。能够将组件打包不仅作为可部署的运行时工件(例如,Java warjar文件),而且作为一个完整的镜像,准备好在运行 Docker 的服务器上启动为容器,这对于开发和测试来说是一个巨大的进步。

现在,将容器视为一个隔离的过程。我们将在第四章使用 Docker 部署我们的微服务中了解更多关于容器的内容。

  • 容器引擎,例如 Docker,仅靠它本身还不足以在生产环境中使用容器。需要某种东西来确保所有容器都处于运行状态,并且能够在多台服务器上扩展容器,从而提供高可用性和增加计算资源。

  • 这些类型的产品被称为容器编排器。在过去几年中,许多产品已经发展起来,例如 Apache Mesos、Swarm 模式下的 Docker、Amazon ECS、HashiCorp Nomad 和Kubernetes。Kubernetes 最初是由 Google 开发的。当 Google 在 2015 年发布 v1.0 版本时,它还将 Kubernetes 捐赠给了CNCF (www.cncf.io/)。在 2018 年,Kubernetes 成为了一种事实上的标准,既可以作为本地部署的预包装版本使用,也可以作为大多数主要云服务提供商的服务。

kubernetes.io/blog/2015/04/borg-predecessor-to-kubernetes/中所述,Kubernetes 实际上是一个基于开源的重写,名为Borg的内部容器编排器,在 Kubernetes 项目成立之前,Google 已经使用了十多年。

  • 2018 年,我开始了解服务网格的概念以及服务网格如何补充容器编排器,进一步减轻微服务的责任,使其可管理和有弹性。

一个示例微服务景观

由于本书无法涵盖我刚才提到的所有技术方面,我将专注于自 2014 年以来我在客户项目中证明有用的部分。我将描述它们如何一起使用来创建可管理、可扩展和有弹性的协作微服务。

本书中的每一章都将解决一个特定的问题。为了展示事物是如何相互关联的,我将使用一组协作的微服务,我们将在整本书中逐步发展这些微服务。微服务景观将在第三章创建一组协作微服务中描述;目前,只需知道它看起来是这样的:

图描述自动生成

图 1.7:本书中使用的基于微服务的系统景观

注意,这是一个非常小的协作微服务系统景观。我们将在接下来的章节中添加的周围支持服务对于这些微服务来说可能看起来过于复杂。但请记住,本书中提出的解决方案旨在支持一个更大的系统景观。

现在我们已经介绍了微服务的潜在利益和挑战,让我们开始探讨如何定义微服务。

定义微服务

微服务架构是将单体应用程序拆分为更小的组件,这实现了两个主要目标:

  • 更快的开发,实现持续部署

  • 更容易扩展,手动或自动

微服务本质上是一个独立的软件组件,它可以独立升级、替换和扩展。为了能够作为一个自主组件,它必须满足以下某些标准:

  • 它必须符合无共享架构;也就是说,微服务之间不共享数据库中的数据!

  • 它必须仅通过定义良好的接口进行通信,无论是使用 API 和同步服务,还是最好通过异步发送消息。使用的 API 和消息格式必须是稳定的、有良好文档的,并遵循定义的版本策略。

  • 它必须作为独立的运行时进程部署。每个微服务的实例都在一个独立的运行时进程中运行,例如,一个 Docker 容器。

  • 微服务实例是无状态的,因此进入微服务的请求可以由其任何实例处理。

使用一组协作的微服务,我们可以部署到多个较小的服务器上,而不是被迫部署到单个大型服务器上,就像部署单体应用程序时必须做的那样。

由于已经满足了上述标准,将单个微服务扩展到更多实例(例如,使用更多虚拟服务器)比扩展大型单体应用程序要容易得多。

利用云中可用的自动扩展功能也是一种可能性,但对于大型单体应用程序来说通常不可行。与升级大型单体应用程序相比,升级或甚至替换单个微服务也更容易。

以下图表说明了这一点,其中单体应用程序已被分割成六个微服务,所有这些微服务都已部署到单独的服务器上。其中一些微服务也独立于其他微服务进行了扩展:

图表描述自动生成,置信度中等

图 1.8:将单体分解为微服务

我经常从客户那里收到的一个非常频繁的问题是:

微服务应该有多大?

我尝试使用以下经验法则:

  • 足够小,可以放入开发者的脑海中

  • 足够大,不会危及性能(即延迟)和/或数据一致性(存储在不同微服务中的数据之间的 SQL 外键不再是你可以理所当然的事情)

因此,总结来说,微服务架构本质上是一种架构风格,我们将单体应用程序分解成一组协作的自主软件组件。其动机是使开发更快,并使扩展应用程序更容易。

在更好地理解如何定义微服务之后,我们可以继续详细说明与微服务系统景观相关的挑战。

微服务的挑战

自主软件组件的挑战部分,我们已经看到了自主软件组件可能带来的某些挑战(它们同样适用于微服务),如下所示:

  • 许多使用同步通信的小型组件可能会引起连锁故障问题,尤其是在高负载下

  • 保持许多小型组件的配置更新可能具有挑战性

  • 跟踪一个涉及许多组件且正在处理中的请求是很困难的,例如,在进行根本原因分析时,每个组件都本地存储日志记录

  • 在组件级别上分析硬件资源的使用也可能具有挑战性

  • 手动配置和管理许多小型组件可能会变得成本高昂且容易出错

将应用程序分解成一组自治组件的另一个缺点(但最初可能并不明显)是它们形成了一个分布式系统。众所周知,分布式系统由于其本质而非常难以处理。这一点已经为人所知多年(但在许多情况下直到被证明不同才被忽视)。我最喜欢的关于这一事实的引言来自彼得·德克斯特,他在 1994 年提出了以下观点:

分布式计算的 8 大谬误:基本上,每个人在第一次构建分布式应用程序时,都会做出以下八个假设。所有这些假设在长期内都被证明是错误的,并且都导致了巨大的麻烦和痛苦的学习经历:

  1. 网络是可靠的

  2. 延迟为零

  3. 带宽是无限的

  4. 网络是安全的

  5. 拓扑结构不会改变

  6. 只有一个管理员

  7. 传输成本为零

  8. 网络是同质的

——彼得·德克斯特,1994 年

通常,基于这些错误假设构建的微服务会导致容易受到暂时性网络故障和其他微服务实例中发生的问题的影响。当系统景观中微服务的数量增加时,问题的可能性也会增加。一个很好的经验法则是根据系统景观中始终存在某些问题的假设来设计您的微服务架构。微服务架构需要设计来处理这些问题,包括检测问题和重启失败的组件。此外,在客户端,确保不要向失败的微服务实例发送请求。当问题得到纠正时,应恢复对之前失败的微服务的请求;也就是说,微服务客户端需要具有弹性。当然,所有这些都需要完全自动化。对于大量微服务,操作员手动处理这是不可行的!

本主题的范围很广,但我们将暂时限制自己,并继续学习微服务的设计模式。

微服务设计模式

本主题将涵盖使用设计模式来缓解前述部分中描述的微服务挑战。在本书的后续部分,我们将看到如何使用 Spring Boot、Spring Cloud、Kubernetes 和 Istio 来实现这些设计模式。

设计模式的概念实际上相当古老;它是由克里斯托弗·亚历山大在 1977 年发明的。本质上,设计模式是关于在特定上下文中描述一个可重用解决方案来解决问题的。使用设计模式中的经过验证和测试的解决方案可以节省大量时间,并且与自行发明解决方案相比,可以提高实现的品质。

我们将要介绍的设计模式如下:

  • 服务发现

  • 边缘服务器

  • 响应式微服务

  • 中央配置

  • 中央日志分析

  • 分布式跟踪

  • 电路断路器

  • 控制回路

  • 中央监控和警报

此列表并非旨在全面;相反,它是一个最小列表,列出了处理我们之前描述的挑战所需的设计模式。

我们将采用轻量级的方法来描述设计模式,并重点关注以下内容:

  • 问题

  • 解决方案

  • 解决方案的要求

在整本书中,我们将更深入地探讨如何应用这些设计模式。这些设计模式的应用背景是一个由协作微服务组成的系统景观,其中微服务通过同步请求(例如,使用 HTTP)或发送异步消息(例如,使用消息代理)相互通信。

服务发现

服务发现模式存在以下问题、解决方案和解决方案要求。

问题

客户端如何找到微服务和它们的实例?

微服务实例在启动时通常会分配动态分配的 IP 地址,例如,在容器中运行时。这使得客户端难以向一个例如通过 HTTP 暴露 REST API 的微服务发起请求。考虑以下图表:

图形用户界面,图表  自动生成的描述

图 1.9:服务发现问题

解决方案

向系统景观添加一个新的组件——一个服务发现服务——以跟踪当前可用的微服务和其实例的 IP 地址。

解决方案要求

一些解决方案的要求如下:

  • 自动注册/注销微服务和它们的实例,随着它们的到来和离去。

  • 客户端必须能够向微服务的一个逻辑端点发起请求。请求将被路由到可用的微服务实例之一。

  • 必须在可用的实例之间对微服务的请求进行负载均衡。

  • 我们必须能够检测当前不健康的实例,以便请求不会路由到它们。

实施说明:正如我们将在第九章“使用 Netflix Eureka 添加服务发现”、第十五章“Kubernetes 简介”和第十六章“将我们的微服务部署到 Kubernetes”中看到的那样,此设计模式可以使用两种不同的策略实现:

  • 客户端路由:客户端使用一个库与服务发现服务通信,以找出应向其发送请求的正确实例。

  • 服务器端路由:服务发现服务的基础设施还公开了一个反向代理,所有请求都发送到该代理。反向代理代表客户端将请求转发到适当的微服务实例。

边缘服务器

边缘服务器模式具有以下问题、解决方案和解决方案要求。

问题

在微服务系统景观中,在许多情况下,希望将一些微服务暴露给系统景观之外,并将剩余的微服务隐藏对外访问。暴露的微服务必须保护免受恶意客户端的请求。

解决方案

向系统景观中添加一个新的组件,一个边缘服务器,所有进入的请求都将通过它:

图描述自动生成

图 1.10:边缘服务器设计模式

实施说明:边缘服务器通常表现得像一个反向代理,可以与发现服务集成以提供动态负载均衡功能。

解决方案要求

一些解决方案要求如下:

  • 隐藏那些不应暴露在其上下文之外的内部服务;也就是说,只将请求路由到配置为允许外部请求的微服务

  • 暴露外部服务并保护它们免受恶意请求;也就是说,使用标准协议和最佳实践,如 OAuth、OIDC、JWT 令牌和 API 密钥,以确保客户端是可信的。

反应式微服务

反应式微服务模式存在以下问题、解决方案和解决方案要求。

问题

传统上,作为 Java 开发者,我们习惯于使用阻塞 I/O 实现同步通信,例如,通过 HTTP 的 RESTful JSON API。使用阻塞 I/O 意味着操作系统为请求的长度分配了一个线程。

如果并发请求数量增加,服务器可能会在操作系统中耗尽可用的线程,导致从响应时间变长到服务器崩溃的问题。使用微服务架构通常会使这个问题更加严重,通常使用一系列协作的微服务来处理请求。参与服务请求的微服务越多,可用的线程就会越快耗尽。

解决方案

使用非阻塞 I/O 来确保在等待另一个服务(例如数据库或另一个微服务)的处理过程中不会分配线程。

解决方案要求

一些解决方案要求如下:

  • 在可行的情况下,使用异步编程模型,发送消息而不等待接收者处理它们。

  • 如果更喜欢同步编程模型,请使用可以使用非阻塞 I/O 执行同步请求的响应式框架,在等待响应时不分配线程。这将使微服务更容易扩展以处理增加的工作负载。

  • 微服务还必须设计成具有弹性和自我修复能力。弹性意味着即使在它依赖的服务中有一个失败的情况下也能产生响应;自我修复意味着一旦失败的服务再次运行,微服务必须能够重新使用它。

2013 年,在《反应式宣言》中确立了设计反应式系统的关键原则(www.reactivemanifesto.org/)。

根据宣言,反应式系统的基础是它们是消息驱动的;它们使用异步通信。这使得它们具有弹性,即可伸缩性,以及弹性,即对失败的容忍性。弹性和弹性共同使反应式系统能够始终及时响应。

中央配置

中央配置模式存在以下问题、解决方案和解决方案需求。

问题

传统上,应用程序与其配置一起部署,例如,一组环境变量和/或包含配置信息的文件。给定一个基于微服务架构的系统景观,即有大量部署的微服务实例,会出现一些查询:

  • 我如何获得所有运行中的微服务实例中配置的完整视图?

  • 我如何更新配置并确保所有受影响的微服务实例都正确更新?

解决方案

向系统架构中添加一个新的组件,即配置服务器,以存储所有微服务的配置,如下所示:

图表描述自动生成

图 1.11:中央配置设计模式

解决方案需求

使一组微服务的配置信息能够存储在一个地方,并为不同的环境(例如,devtestqaprod)设置不同的配置。

集中日志分析

集中日志分析存在以下问题、解决方案和解决方案需求。

问题

传统上,应用程序将日志事件写入存储在应用程序运行的服务器本地文件系统中的日志文件。给定一个基于微服务架构的系统景观,即在大量较小的服务器上部署了大量的微服务实例,我们可以提出以下问题:

  • 当每个微服务实例将其自己的本地日志文件写入时,我如何获得系统景观中正在发生的事情的概览?

  • 我如何知道是否有任何微服务实例遇到麻烦并开始向它们的日志文件中写入错误消息?

  • 如果最终用户开始报告问题,我如何找到相关的日志消息;也就是说,我如何确定哪个微服务实例是问题的根本原因?以下图表说明了问题:

图表描述自动生成

图 1.12:微服务将日志文件写入其本地文件系统

解决方案

添加一个新组件,它可以管理集中式日志记录,并能够执行以下操作:

  • 检测新的微服务实例并从它们收集日志事件

  • 在中央数据库中以结构化和可搜索的方式解释和存储日志事件

  • 提供查询和分析日志事件的 API 和图形工具

解决方案要求

一些解决方案要求如下:

  • 微服务将日志事件流式传输到标准系统输出,stdout。与将日志事件写入特定微服务的日志文件相比,这使得日志收集器更容易找到日志事件。

  • 微服务使用下一节中关于分布式跟踪设计模式描述的相关 ID 标记日志事件。

  • 定义了一个规范化的日志格式,以便日志收集器可以在将日志事件存储到中央数据库之前,将来自微服务的日志事件转换为规范化日志格式。在规范化日志格式中存储日志事件是能够查询和分析收集到的日志事件所必需的。

分布式跟踪

分布式跟踪存在以下问题、解决方案和解决方案要求。

问题

必须能够跟踪在处理系统景观外部请求时在微服务之间流动的请求和消息。

一些故障场景的例子如下:

  • 如果最终用户开始提交关于特定故障的支持案例,我们如何识别导致问题的微服务,即根本原因?

  • 如果某个支持案例提到了与特定实体相关的问题,例如,特定的订单号,我们如何找到与处理此特定订单相关的日志消息——例如,所有参与处理该订单的微服务的日志消息?

  • 如果最终用户开始提交关于响应时间过长的问题支持案例,我们如何识别调用链中哪个微服务导致了延迟?

以下图示展示了这一点:

图示描述自动生成

图 1.13:分布式跟踪问题

解决方案

为了跟踪协作微服务之间的处理过程,我们需要确保所有相关请求和消息都带有共同的关联 ID,并且关联 ID 是所有日志事件的一部分。基于关联 ID,我们可以使用集中式日志服务来查找所有相关的日志事件。如果其中一个日志事件还包含有关业务标识符的信息,例如,客户的 ID、产品或订单,我们可以使用关联 ID 找到该业务标识符的所有相关日志事件。

为了能够分析协作微服务调用链中的延迟,我们必须能够收集请求、响应和消息进入和退出每个微服务的时间戳。

解决方案要求

解决方案要求如下:

  • 在一个已知位置为所有传入或新的请求和事件分配唯一的关联 ID,例如,带有标准化名称的报头

  • 当微服务发出出站请求或发送消息时,它必须将关联 ID 添加到请求和消息中。

  • 所有日志事件都必须包含预定义格式的关联 ID,以便集中日志服务可以从日志事件中提取关联 ID 并使其可搜索。

  • 当请求、响应和消息进入和退出微服务实例时,必须创建跟踪记录。

断路器

断路器模式存在以下问题、解决方案和解决方案需求。

问题

使用同步交互的微服务系统景观可能会暴露于连锁故障之中。如果一个微服务停止响应,其客户端可能会遇到问题,并停止对客户端的请求做出响应。问题可能会递归地传播到整个系统景观,并使其主要部分失效。

这在同步请求使用阻塞 I/O 执行的情况下尤其常见,即阻塞底层操作系统的线程,在请求处理期间。结合大量并发请求和开始以意外缓慢的速度响应的服务,线程池可能会迅速耗尽,导致调用者挂起和/或崩溃。这种故障会迅速传播到调用者的调用者,依此类推。

解决方案

如果检测到调用服务的问题,添加一个断路器以阻止调用者发出新的出站请求。

解决方案需求

解决方案需求如下:

  • 打开电路并快速失败(不等待超时),如果检测到服务存在问题。

  • 用于故障纠正的探测(也称为半开电路);也就是说,定期允许单个请求通过,以查看服务是否已恢复正常运行。

  • 关闭电路,如果探测到服务再次正常运行。这种能力非常重要,因为它使系统景观对这些类型的问题具有弹性;换句话说,它能够自我修复。

下图展示了在微服务系统景观中所有同步通信都通过断路器进行的场景。所有断路器都是关闭的;它们允许流量通过,除了检测到请求服务存在问题的一个断路器(针对微服务 E)。因此,这个断路器是打开的,并使用快速失败逻辑;也就是说,它不调用失败的服务,并等待超时发生。相反,微服务 E可以立即返回响应,在响应前可选地应用一些回退逻辑:

图描述自动生成

图 1.14:断路器设计模式

控制循环

控制循环模式有以下问题、解决方案和解决方案需求。

问题

在一个拥有大量微服务实例的系统景观中,这些实例分散在多个服务器上,手动检测和纠正崩溃或挂起的微服务实例等问题非常困难。

解决方案

在系统景观中添加一个新的组件,一个控制循环。这个过程如下所示:

图描述自动生成

图 1.15:控制循环设计模式

解决方案要求

控制循环将不断观察系统景观的实际状态,将其与操作员指定的期望状态进行比较。如果两个状态不同,它将采取行动使实际状态等于期望状态。

实施说明:在容器世界中,通常使用容器编排器(如 Kubernetes)来实现此模式。我们将在第十五章“Kubernetes 简介”中了解更多关于 Kubernetes 的内容。

集中式监控和警报

对于这个模式,我们遇到了以下问题、解决方案和解决方案要求。

问题

如果观察到的响应时间或/和硬件资源的使用变得无法接受地高,很难发现问题的根本原因。例如,我们需要能够分析每个微服务的硬件资源消耗。

解决方案

为了遏制这种情况,我们在系统景观中添加了一个新的组件,一个监控服务,它能够收集每个微服务实例级别的硬件资源使用指标。

解决方案要求

解决方案要求如下:

  • 它必须能够从系统景观中使用的所有服务器收集指标,这包括自动扩展服务器。

  • 它必须能够检测在可用服务器上启动的新微服务实例,并从它们那里开始收集指标。

  • 它必须能够提供 API 和图形工具,用于查询和分析收集的指标。

  • 必须能够定义当指定的指标超过指定的阈值时触发的警报。

下面的截图显示了 Grafana,它可视化 Prometheus 的指标,我们将在第二十章“监控微服务”中了解这个监控工具:

计算机截图,描述自动生成

图 1.16:使用 Grafana 进行监控

这是一个详尽的列表!我相信这些设计模式帮助你更好地理解了微服务面临的挑战。接下来,我们将继续学习关于软件使能器的知识。

软件使能器

如我们之前提到的,我们有一些非常好的开源工具可以帮助我们满足对微服务的期望,更重要的是,处理随之而来的新挑战:

  • Spring Boot,一个应用程序框架

  • Spring Cloud/Netflix OSS,一个应用程序框架和现成服务的混合体

  • Docker,一个在单个服务器上运行容器的工具

  • Kubernetes,一个容器编排器,它管理着一群运行容器的服务器

  • Istio,一个服务网格实现

以下表格映射了我们将需要处理这些挑战的设计模式,以及本书中将用于实现这些设计模式的相应开源工具:

设计模式 Spring Boot Spring Cloud Kubernetes Istio
服务发现 Netflix Eureka 和 Spring Cloud LoadBalancer Kubernetes kube-proxy 和服务资源
边缘服务器 Spring Cloud Gateway 和 Spring Security OAuth Kubernetes 入口控制器 Istio 入口网关
响应式微服务 Project Reactor 和 Spring WebFlux
中心配置 Spring Config Server Kubernetes ConfigMaps 和 Secrets
集中日志分析 Elasticsearch、Fluentd 和 Kibana。注意:实际上不是 Kubernetes 的组成部分,但可以轻松地与 Kubernetes 一起部署和配置
分布式追踪 Micrometer 追踪和 Zipkin Jaeger
断路器 Resilience4j 异常检测
控制循环 Kubernetes 控制管理器
集中监控和警报 Kiali、Grafana 和 Prometheus

图 1.17:将设计模式映射到开源工具

请注意,Spring Cloud、Kubernetes 或 Istio 中的任何一个都可以用来实现某些设计模式,例如服务发现、边缘服务器和中心配置。我们将在本书的后面讨论使用这些替代方案的优缺点。

在介绍了本书中将使用的模式和工具之后,我们将通过一些相关领域来结束本章,这些领域也很重要,但本书没有涉及。

其他重要考虑因素

在实施微服务架构时要想取得成功,还需要考虑许多相关领域。本书不会涵盖这些领域;相反,我将在以下内容中简要提及它们:

  • DevOps 的重要性:微服务架构的一个好处是它能够缩短交付时间,在极端情况下,甚至允许持续交付新版本。为了能够快速交付,你需要建立一个遵循“你构建它,你就运行它”这一格言的组织。这意味着开发者不再被允许简单地传递软件的新版本给运维团队。相反,开发和运维组织需要更加紧密地合作,组建起对单个微服务(或一组相关微服务)端到端生命周期负责的团队。除了 Dev/ops 的组织部分,团队还需要自动化交付链,即构建、测试、打包和将微服务部署到各种部署环境中的步骤。这被称为建立交付管道

  • 组织方面和康威定律:微服务架构可能对组织产生影响的另一个有趣方面是康威定律,该定律表述如下:

    “任何设计系统(广义上)的组织都会产生一个结构,其结构与组织的沟通结构相匹配。”

    —— Melvyn Conway,1967

    这意味着,基于技术专长(例如,用户体验、业务逻辑和数据库团队)的传统方法来组织大型 IT 团队,将导致一个庞大的三层应用——通常是一个庞大的单体应用,具有一个独立的 UI 部署单元、一个处理业务逻辑的单元和一个大型数据库的部署单元。

    要成功交付基于微服务架构的应用程序,组织需要转变为与一个或一组相关微服务合作的团队。该团队必须具备执行这些微服务所需的技能,例如,业务逻辑的语言和框架以及用于持久化数据的数据库技术。

  • 将单体应用分解为微服务:最困难的决策之一(如果执行不当则代价高昂)是如何将单体应用分解为一系列协作的微服务。如果这样做的方式不正确,你最终会遇到以下问题:

    • 缓慢的交付:业务需求的变化将影响太多的微服务,从而导致额外的工作。

    • 不良性能:为了执行特定的业务功能,需要在各种微服务之间传递大量请求,从而导致响应时间过长。

    • 数据不一致:由于相关数据被分离到不同的微服务中,随着时间的推移,由不同微服务管理的数据可能会出现不一致性。

    为微服务找到合适的边界的一个好方法是应用领域驱动设计及其边界上下文的概念。根据埃里克·埃文斯(Eric Evans)的说法,一个边界上下文是:

    “一个边界(通常是子系统或特定团队的作业)的描述,其中定义并适用特定的模型。”

    这意味着由边界上下文定义的微服务将拥有其自身数据的明确模型。

  • API 设计的重要性:如果一个微服务组公开了一个通用的、外部可用的 API,那么这个 API 需要易于理解,并遵循以下指南:

    • 如果在多个 API 中使用相同的概念,那么在命名和数据类型方面应该有相同的描述。

    • 允许 API 以独立但受控的方式演变非常重要。这通常需要为 API 应用适当的版本控制方案,例如,semver.org/。这意味着在特定时期内支持 API 的多个主要版本,允许 API 客户端以自己的节奏迁移到新的主要版本。

  • 从本地到云的迁移路径:许多公司今天在本地运行他们的工作负载,但正在寻找将工作负载的部分迁移到云的方法。由于大多数云提供商今天都提供Kubernetes as a Service,一个吸引人的迁移方法可以是将工作负载首先迁移到本地的 Kubernetes(无论是否作为微服务)上,然后重新部署到由首选云提供商提供的 Kubernetes as a Service 服务上。

  • 微服务的良好设计原则,12 因素应用:12 因素应用(12factor.net)是一套设计原则,用于构建可以在云中部署的软件。这些设计原则中的大多数都可以独立于部署位置和方式(即在云中或本地)构建微服务。本书将涵盖其中的一些原则,例如配置、进程和日志,但并非全部。

第一章节的内容就到这里!我希望这能给你一个关于微服务及其带来的挑战的良好基本概念,以及我们将在本书中涵盖的内容概述。

摘要

在本章的介绍中,我描述了自己进入微服务的方式,并简要探讨了它们的历史。我们定义了微服务是什么——一种具有特定要求的自主分布式组件。我们还探讨了基于微服务的架构的优缺点。

为了应对这些挑战,我们定义了一套设计模式,并简要地将开源产品(如 Spring Boot、Spring Cloud、Kubernetes 和 Istio)的功能映射到设计模式上。

你现在渴望开发你的第一个微服务,对吧?在下一章中,你将介绍 Spring Boot 和我们将使用的互补开源工具,以开发我们的第一个微服务。

加入我们的 Discord 社区

加入我们的 Discord 空间,与作者和其他读者进行讨论:

packt.link/SpringBoot3e

第二章:Spring Boot 简介

在本章中,我们将介绍如何使用 Spring Boot 构建一组协作的微服务,重点关注如何开发提供业务价值的功能。我们将对上一章中指出的微服务挑战进行一定程度上的考虑,但在后续章节中将对它们进行全面解决。

我们将开发包含业务逻辑的微服务,基于普通的Spring Beans,并使用Spring WebFlux公开 REST API。API 将根据 OpenAPI 规范使用springdoc-openapi进行文档编制。为了使微服务处理的数据持久化,我们将使用Spring Data在 SQL 和 NoSQL 数据库中存储数据。

自 2018 年 3 月 Spring Boot v2.0 版本发布以来,开发反应式微服务(包括非阻塞同步 REST API)变得更加容易。为了开发基于消息的异步服务,我们将使用Spring Cloud Stream。有关更多信息,请参阅第一章微服务简介中的反应式微服务部分。

2022 年 11 月,Spring Boot 3.0 版本发布。它基于 Spring Framework 6.0 和 Jakarta EE 9,同时也兼容 Jakarta EE 10。作为最低 Java 版本,需要 Java 17,这是当前的长期支持LTS)版本。

最后,我们将使用Docker将我们的微服务作为容器运行。这将使我们能够通过单个命令启动和停止包括数据库服务器和消息代理在内的微服务景观。

这有很多技术和框架,所以让我们简要地了解一下它们各自的内容!

在本章中,我们将介绍以下开源项目:

  • Spring Boot

(本节还包括 v3.0 的新功能和如何迁移 v2 应用程序的概述。)

  • Spring WebFlux

  • springdoc-openapi

  • Spring Data

  • Spring Cloud Stream

  • Docker

每个产品的更多详细信息将在后续章节中提供。

技术要求

本章不包含任何可以下载的源代码,也不需要安装任何工具。

Spring Boot

Spring Boot,以及它所基于的 Spring 框架,是一个用于在 Java 中开发微服务的优秀框架。

当 Spring Framework v1.0 版本在 2004 年发布时,其主要目标之一是解决过于复杂的J2EE标准(即Java 2 Platform, Enterprise Edition)及其臭名昭著且重量级的部署描述符。Spring 框架提供了一个基于依赖注入概念的更轻量级的开发模型。与 J2EE 中的部署描述符相比,Spring 框架还使用了更轻量级的 XML 配置文件。

要使 J2EE 标准的问题更加严重,重量级的部署描述符实际上有两种类型:

  • 标准部署描述符,以标准化的方式描述配置

  • 供应商特定的部署描述符,将配置映射到供应商应用程序服务器中的特定功能

在 2006 年,J2EE 被更名为Java EE,即Java 平台,企业版。在 2017 年,Oracle 将 Java EE 提交给 Eclipse 基金会。2018 年 2 月,Java EE 被更名为 Jakarta EE。新的名称,Jakarta EE,也影响了由标准定义的 Java 包的名称,要求开发者在升级到 Jakarta EE 时进行包重命名,如迁移 Spring Boot 2 应用程序部分所述。多年来,随着 Spring 框架越来越受欢迎,Spring 框架的功能也显著增长。慢慢地,使用不再轻量级的 XML 配置文件设置 Spring 应用程序所带来的负担成为一个问题。

在 2014 年,Spring Boot v1.0 发布,解决了这些问题!

约定优于配置和胖 JAR 文件

Spring Boot 通过对如何设置 Spring 框架的核心模块和第三方产品(如用于日志记录或连接到数据库的库)有明确的意见,旨在快速开发生产就绪的 Spring 应用程序。Spring Boot 通过默认应用一系列约定来实现这一点,最大限度地减少了对配置的需求。在需要时,可以通过编写一些配置来覆盖每个约定,具体情况具体分析。这种设计模式被称为约定优于配置,并最大限度地减少了初始配置的需求。

在需要时,我认为使用 Java 和注解编写配置最佳。虽然它们比 Spring Boot 之前要小得多,但基于 XML 的老式配置文件仍然可以使用。

除了使用约定优于配置之外,Spring Boot 还倾向于基于独立 JAR 文件(也称为胖 JAR 文件)的运行时模型。在 Spring Boot 之前,运行 Spring 应用程序最常见的方式是将它作为 WAR 文件部署在 Java EE 服务器上,例如 Apache Tomcat。Spring Boot 仍然支持 WAR 文件部署。

一个胖 JAR 文件不仅包含应用程序本身的类和资源文件,还包含应用程序所依赖的所有 JAR 文件。这意味着胖 JAR 文件是运行应用程序所需的唯一 JAR 文件;也就是说,我们只需要将一个 JAR 文件传输到我们想要运行应用程序的环境中,而不是将应用程序的 JAR 文件以及所有依赖的 JAR 文件一起传输。

启动胖 JAR 文件不需要安装单独的 Java EE 服务器,例如 Apache Tomcat。相反,可以使用简单的命令(如 java -jar app.jar)启动,使其成为在 Docker 容器中运行的理想选择!例如,如果 Spring Boot 应用程序使用 HTTP 来公开 REST API,它也将包含一个嵌入的 Web 服务器。

设置 Spring Boot 应用程序的代码示例

为了更好地理解这意味着什么,让我们看看一些源代码示例。

我们在这里只将查看一些小的代码片段来指出主要特性。对于完整的工作示例,你将不得不等到下一章才能看到!

魔法的@SpringBootApplication注解

基于约定的自动配置机制可以通过在应用程序类(即包含静态main方法的类)上使用@SpringBootApplication注解来启动。以下代码展示了这一点:

**@SpringBootApplication**
public class MyApplication {
  public static void main(String[] args) {
    SpringApplication.run(MyApplication.class, args);
  }
} 

此注解将提供以下功能:

  • 它启用了组件扫描,即在应用程序类的包及其所有子包中查找 Spring 组件和配置类。

  • 应用程序类本身变成了一个配置类。

  • 它启用了自动配置,Spring Boot 会在类路径中查找它可以自动配置的 JAR 文件。例如,如果你有 Tomcat 在类路径中,Spring Boot 将自动配置 Tomcat 作为嵌入式 Web 服务器。

组件扫描

假设我们有一个以下 Spring 组件在应用程序类(或其子包)的包中:

@Component
public class MyComponentImpl implements MyComponent { ... 

应用程序中的另一个组件可以使用@Autowired注解自动获取这个组件,也称为自动装配

public class AnotherComponent {
  private final MyComponent myComponent;
  **@Autowired**
  public AnotherComponent(MyComponent myComponent) {
    this.myComponent = myComponent;
  } 

我更喜欢使用构造函数注入(而不是字段和 setter 注入)来保持组件的状态不可变。在多线程运行时环境中运行组件时,不可变状态非常重要。

如果我们想要使用在应用程序包外部声明的组件,例如,多个 Spring Boot 应用程序共享的实用组件,我们可以在应用程序类中的@SpringBootApplication注解上补充一个@ComponentScan注解:

package se.magnus.myapp;
@SpringBootApplication
**@ComponentScan**({"se.magnus.myapp",**"se.magnus.util"** })
public class MyApplication { 

我们现在可以从应用程序代码中的se.magnus.util包自动装配组件,例如,一个名为MyUtility的实用组件,如下所示:

package se.magnus.util;
@Component
public class **MyUtility** { ... 

这个实用组件可以像这样在一个应用程序组件中自动装配:

package se.magnus.myapp.services;
public class AnotherComponent {
 private final MyUtility myUtility;
 @Autowired
 public AnotherComponent(**MyUtility** myUtility) {
   this.myUtility = myUtility;
 } 

基于 Java 的配置

如果我们想要覆盖 Spring Boot 的默认配置,或者我们想要添加自己的配置,我们可以简单地使用@Configuration注解一个类,它将被我们之前描述的组件扫描机制所拾取。

例如,如果我们想在处理 HTTP 请求(由 Spring WebFlux 处理,将在下一节中描述)的过程中设置一个过滤器,在处理开始和结束时写入日志消息,我们可以配置一个日志过滤器,如下所示:

**@Configuration**
public class SubscriberApplication {
  @Bean
  public **Filter** **logFilter****()** {
    CommonsRequestLoggingFilter filter = new 
        CommonsRequestLoggingFilter();
    filter.setIncludeQueryString(true);
    filter.setIncludePayload(true);
    filter.setMaxPayloadLength(5120);
    return filter;
  } 

我们也可以直接在应用程序类中放置配置,因为@SpringBootApplication注解隐含了@Configuration注解。

关于 Spring Boot 的内容就到这里,但在我们转向下一个组件之前,让我们看看 Spring Boot 3.0 的新特性以及如何迁移 Spring Boot 2 应用程序。

Spring Boot 3.0 的新特性

对于本书的范围,Spring Boot 3.0 中最重要的新功能如下:

  • 可观测性

    Spring Boot 3.0 增强了对可观测性的支持,在之前 Spring Boot 版本中已有的对指标和日志的支持基础上,增加了内置的分布式跟踪支持。新的分布式跟踪支持基于 Spring 框架 v6.0 中的新可观测性 API 和名为 Micrometer Tracing 的新模块。Micrometer Tracing 基于 Spring Cloud Sleuth,现在已被弃用。第十四章理解分布式跟踪,介绍了如何使用新的可观测性和分布式跟踪支持。

  • 原生编译

    Spring Boot 3.0 还支持将 Spring Boot 应用程序编译成原生镜像,这些镜像是可以独立执行的文件。原生编译的 Spring Boot 应用程序启动速度更快,并且消耗更少的内存。第二十三章原生编译的 Java 微服务,描述了如何基于 Spring Boot 原生编译微服务。

  • 虚拟线程

    最后,Spring Boot 3.0 支持轻量级线程,称为来自 OpenJDK Project Loom 的虚拟线程。虚拟线程预计将简化开发反应式非阻塞微服务的编程模型,例如,与 Project Reactor 和各种 Spring 组件使用的编程模型相比。虚拟线程目前在 Java 19 中仅作为预览版提供。它们目前也缺乏对可组合性功能的支持,例如,构建同时从其他微服务聚合信息的微服务所需的。因此,虚拟线程将不会在本书中介绍。第七章开发反应式微服务,介绍了如何使用 Project Reactor 和 Spring WebFlux 实现虚拟线程。

将 Spring Boot 2 应用程序迁移

如果您已经基于 Spring Boot 2 开发了应用程序,您可能想了解迁移到 Spring Boot 3.0 需要做什么。以下是需要采取的操作列表:

  1. Pivotal 建议首先将 Spring Boot 2 应用程序升级到最新的 v2.7.x 版本,因为他们的迁移指南假设您正在使用 v2.7。

  2. 确保您已安装 Java 17 或更高版本,无论是在您的开发环境还是运行时环境中。如果您的 Spring Boot 应用程序作为 Docker 容器部署,您需要确保贵公司批准使用基于 Java 17 或更高版本发布的 Docker 镜像。

  3. 移除对 Spring Boot 2.x 中已弃用方法的调用。所有弃用方法都在 Spring Boot 3.0 中被移除,因此您必须确保您的应用程序没有调用这些方法中的任何一个。要查看应用程序中调用这些方法的确切位置,您可以在 Java 编译器中启用 lint:deprecation 标志(假设使用 Gradle):

    tasks.withType(JavaCompile) {
        options.compilerArgs += ['-Xlint:deprecation']
    } 
    
  4. javax 包的所有导入重命名为 jakarta

  5. 对于不由 Spring 管理的库,您需要确保您使用的是符合 Jakarta 规范的版本,即使用 jakarta 包。

  6. 对于破坏性变更和其他重要迁移信息,请阅读以下内容:

    github.com/spring-projects/spring-boot/wiki/Spring-Boot-3.0-Migration-Guide

    docs.spring.io/spring-security/reference/migration/index.html

  7. 确保您有端到端的全黑盒测试来验证您应用程序的功能。在迁移前后运行这些测试,以确保应用程序的功能没有受到迁移的影响。

在将本书前版源代码迁移到 Spring Boot 3.0 时,最耗时的工作是弄清楚如何处理 Spring Security 配置中的破坏性变更;有关详细信息,请参阅第十一章,保护 API 访问。例如,前版中授权服务器的以下配置需要更新:

@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
  http
    .authorizeRequests(authorizeRequests -> authorizeRequests
      .antMatchers("/actuator/**").permitAll() 

此配置在 Spring Boot 3.0 中看起来如下:

@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
  http
    .authorizeHttpRequests(authorizeRequests -> authorizeRequests
      .requestMatchers("/actuator/**").permitAll() 

随着每个章节一起提供的端到端测试脚本 test-em-all.bash 在验证每个章节迁移后功能未受影响方面变得不可或缺。

既然我们已经了解了 Spring Boot,那么让我们来谈谈 Spring WebFlux。

Spring WebFlux

Spring Boot 3.0 基于 Spring Framework 6.0,它内置了对开发反应式应用程序的支持。Spring Framework 使用 Project Reactor 作为其反应式支持的基础实现,并附带了一个新的网络框架 Spring WebFlux,该框架支持开发反应式(即非阻塞)的 HTTP 客户端和服务。

Spring WebFlux 支持两种不同的编程模型:

  • 一种基于注解的命令式风格,类似于已经存在的网络框架 Spring Web MVC,但支持反应式服务

  • 基于路由器和处理器的新功能导向模型

在本书中,我们将使用基于注解的命令式风格来演示如何轻松地将 REST 服务从 Spring Web MVC 移动到 Spring WebFlux,然后开始重构服务,使它们成为完全反应式的。

Spring WebFlux 还提供了一个完全反应式的 HTTP 客户端 WebClient,作为现有 RestTemplate 客户端的补充。

Spring WebFlux 支持在基于 Jakarta Servlet 规范 v5.0 或更高版本的 servlet 容器上运行,例如 Apache Tomcat,但也支持基于反应式且非 servlet 的嵌入式网络服务器,如 Netty (netty.io/)。

Servlet 规范是 Java EE 平台中的一个规范,它标准化了如何开发使用 HTTP 等网络协议进行通信的 Java 应用程序。

设置 REST 服务的代码示例

在我们能够创建基于 Spring WebFlux 的 REST 服务之前,我们需要将 Spring WebFlux(以及 Spring WebFlux 所需的依赖项)添加到类路径中,以便 Spring Boot 在启动时检测和配置。Spring Boot 提供了大量方便的 启动依赖项,它们带来了特定的功能,以及每个功能通常所需的依赖项。因此,让我们使用 Spring WebFlux 的启动依赖项,然后看看一个简单的 REST 服务是什么样的!

启动依赖项

在这本书中,我们将使用 Gradle 作为我们的构建工具,因此 Spring WebFlux 的启动依赖项将被添加到 build.gradle 文件中。它看起来像这样:

implementation('org.springframework.boot:spring-boot-starter-webflux') 

你可能想知道为什么我们不指定版本号。当我们查看 第三章创建一组协作微服务 的完整示例时,我们将讨论这个问题!

当微服务启动时,Spring Boot 将检测类路径上的 Spring WebFlux 并进行配置,以及启动嵌入式 Web 服务器等其他事情。Spring WebFlux 默认使用 Netty,这可以从日志输出中看到:

2023-03-09 15:23:43.592 INFO 17429 --- [ main] o.s.b.web.embedded.netty.NettyWebServer : Netty started on port(s): 8080 

如果我们想将 Netty 作为嵌入式 Web 服务器切换到 Tomcat,我们可以通过排除 Netty 的启动依赖项并添加 Tomcat 的启动依赖项来覆盖默认配置:

implementation('org.springframework.boot:spring-boot-starter-webflux') 
{
 **exclude** group: 'org.springframework.boot', module: **'spring-boot-**
 **starter-reactor-netty'**
}
**implementation**('org.springframework.boot:**spring-boot-starter-tomcat'**) 

微服务重启后,我们可以看到 Spring Boot 选择了 Tomcat:

2023-03-09 18:23:44.182 INFO 17648 --- [ main] o.s.b.w.embedded.tomcat.TomcatWebServer : Tomcat initialized with port(s): 8080 (http) 

属性文件

如前所述的示例所示,Web 服务器使用端口 8080 启动。如果您想更改端口,您可以使用属性文件覆盖默认值。Spring Boot 应用程序属性文件可以是 .properties 文件或 YAML 文件。默认情况下,它们分别命名为 application.propertiesapplication.yml

在这本书中,我们将使用 YAML 文件,以便嵌入式 Web 服务器使用的 HTTP 端口可以更改为,例如,7001。通过这样做,我们可以避免与其他在同一服务器上运行的微服务发生端口冲突。为此,我们可以在 application.yml 文件中添加以下行:

server.port: 7001 

当我们在 第四章使用 Docker 部署我们的微服务 中开始以容器形式开发我们的微服务时,端口冲突将不再成为问题。每个容器都有自己的主机名和端口范围,因此所有微服务都可以使用,例如端口 8080,而不会相互冲突。

示例 RestController

现在,有了 Spring WebFlux 和我们选择的嵌入式 Web 服务器,我们可以像使用 Spring MVC 一样编写 REST 服务,即作为一个 RestController

**@RestController**
public class MyRestService {
  **@GetMapping**(value = "/my-resource", produces = "application/json")
  List<Resource> listResources() {
    …
  } 

listResources() 方法上的 @GetMapping 注解将 Java 方法映射到 host:8080/myResource URL 上的 HTTP GET API。List<Resource> 类型的返回值将被转换为 JSON。

现在我们已经讨论了 Spring WebFlux,让我们看看我们如何使用 Spring WebFlux 记录我们开发的 API。

springdoc-openapi

开发 API 的一个重要方面,例如 RESTful 服务,是如何对其进行文档化,以便它们易于使用。SmartBear Software 的 Swagger 规范是记录 RESTful 服务最广泛使用的方法之一。许多领先的 API 网关都原生支持使用 Swagger 规范公开 RESTful 服务的文档。

2015 年,SmartBear Software 在OpenAPI 倡议下将 Swagger 规范捐赠给了 Linux Foundation,并创建了OpenAPI 规范。Swagger 这个名字仍然被用于 SmartBear Software 提供的工具。

springdoc-openapi是一个开源项目,独立于 Spring 框架,可以在运行时创建基于 OpenAPI 的 API 文档。它是通过检查应用程序来实现的,例如,检查 WebFlux 和基于 Swagger 的注解。

我们将在接下来的章节中查看完整的源代码示例,但到目前为止,以下简化的截图(被移除的部分用“”标记)将展示一个示例 API 文档:

图形用户界面,应用程序,团队描述自动生成

图 2.1:使用 Swagger UI 可视化的示例 API 文档

注意到大的执行按钮,它可以用来实际尝试 API,而不仅仅是阅读其文档!

springdoc-openapi帮助我们记录微服务暴露的 API。现在,让我们继续了解 Spring Data。

Spring Data

Spring Data 提供了一种通用的编程模型,用于在多种类型的数据库引擎中持久化数据,从传统的关系型数据库(SQL 数据库)到各种类型的 NoSQL 数据库引擎,例如文档数据库(例如,MongoDB)、键值数据库(例如,Redis)和图数据库(例如,Neo4j)。

Spring Data 项目被分为几个子项目,在这本书中,我们将使用映射到 MySQL 数据库的 Spring Data 子项目,即 MongoDB 和 JPA。

JPA代表Jakarta 持久化 API,是关于如何处理关系型数据的一个 Java 规范。请访问jakarta.ee/specifications/persistence/获取最新的规范。Jakarta EE 9 基于 Jakarta Persistence 3.0。

Spring Data 编程模型的两个核心概念是实体仓库。实体和仓库概括了如何从各种类型的数据库中存储和访问数据。它们提供了一个共同的抽象,但仍然支持向实体和仓库添加特定于数据库的行为。随着我们继续本章,我们将简要解释这两个核心概念,并伴随一些示例代码。请记住,更多细节将在接下来的章节中提供!

尽管 Spring Data 为不同类型的数据库提供了一个共同的编程模型,但这并不意味着您将能够编写可移植的源代码。例如,将数据库技术从 SQL 数据库切换到 NoSQL 数据库通常需要在源代码中进行一些更改才能实现!

实体

实体描述了 Spring Data 将要存储的数据。实体类通常使用通用的 Spring Data 注解和针对每种数据库技术特定的注解进行注解。

例如,一个将要存储在关系型数据库中的实体可以注解以下 JPA 注解:

import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.IdClass;
import jakarta.persistence.Table;
@Entity
@IdClass(ReviewEntityPK.class)
@Table(name = "review")
public class ReviewEntity {
 @Id private int productId;
 @Id private int reviewId;
 private String author;
 private String subject;
 private String content; 

如果一个实体要存储在 MongoDB 数据库中,可以使用 Spring Data MongoDB 子项目中的注解,与通用的 Spring Data 注解一起使用。例如,考虑以下代码:

import org.springframework.data.annotation.Id;
import org.springframework.data.annotation.Version;
import org.springframework.data.mongodb.core.mapping.Document;
@Document
public class RecommendationEntity {
    @Id
    private String id;
    @Version
    private int version;
    private int productId;
    private int recommendationId;
    private String author;
    private int rate;
    private String content; 

@Id@Version注解是通用注解,而@Document注解是 Spring Data MongoDB 子项目特有的。

这可以通过研究import语句来揭示;包含mongodbimport语句来自 Spring Data MongoDB 子项目。

仓库

仓库用于存储和访问不同类型数据库的数据。在其最基本的形式中,一个仓库可以被声明为一个 Java 接口,Spring Data 将根据既定的约定动态生成其实现。这些约定可以通过额外的配置来覆盖和/或补充,如果需要,还可以通过一些 Java 代码。

Spring Data 还提供了一些基本的 Java 接口,例如CrudRepository,以使仓库的定义更加简单。基本接口CrudRepository为我们提供了创建读取更新删除操作的标准方法。

要指定处理 JPA 实体ReviewEntity的仓库,我们只需要声明以下内容:

import org.springframework.data.repository.CrudRepository;
public interface ReviewRepository extends 
  CrudRepository<**ReviewEntity**, **ReviewEntityPK**> {

  Collection<ReviewEntity> **findByProductId**(int productId);
} 

在本例中,我们使用一个类,ReviewEntityPK,来描述一个复合主键。它看起来如下所示:

public class **ReviewEntityPK** implements Serializable {
    public int productId;
    public int reviewId;
} 

我们还添加了一个额外的方法findByProductId,允许我们根据productid(它是主键的一部分)查找Review实体。方法的命名遵循 Spring Data 定义的命名约定,允许 Spring Data 动态生成此方法的实现。

如果我们要使用仓库,我们可以简单地注入它,然后开始使用它,例如:

private final ReviewRepository repository;
@Autowired
public ReviewService(ReviewRepository repository) {
 this.repository = repository;
}
public void someMethod() {
  repository.save(entity);
  repository.delete(entity);
  repository.findByProductId(productId); 

除了CrudRepository接口外,Spring Data 还提供了一个反应式的基本接口ReactiveCrudRepository,它使得反应式仓库成为可能。该接口中的方法不返回对象或对象集合;相反,它们返回MonoFlux对象。正如我们将在第七章开发反应式微服务中看到的,MonoFlux对象是能够返回 0 到 1 个或 0 到 m 个实体作为它们在流中可用时的反应式流。

基于响应式的接口只能由支持响应式数据库驱动程序的 Spring Data 子项目使用;也就是说,它们基于非阻塞 I/O。Spring Data MongoDB 子项目支持响应式存储库,而 Spring Data JPA 不支持。

指定一个响应式存储库来处理之前描述的 MongoDB 实体RecommendationEntity可能看起来像以下这样:

import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import reactor.core.publisher.Flux;
public interface RecommendationRepository extends ReactiveCrudRepository<**RecommendationEntity**, String> {
    Flux<RecommendationEntity> findByProductId(int productId);
} 

这就结束了 Spring Data 部分的内容。现在让我们看看如何使用 Spring Cloud Stream 来开发基于消息的异步服务。

Spring Cloud Stream

在这部分中,我们不会关注 Spring Cloud;我们将在书的第二部分第二部分中这样做,从第八章Spring Cloud 简介第十四章理解分布式跟踪。然而,我们将引入 Spring Cloud 的一部分:Spring Cloud Stream。Spring Cloud Stream 在基于发布和订阅集成模式的信息传递之上提供了一个流抽象。Spring Cloud Stream 目前内置了对 Apache Kafka 和 RabbitMQ 的支持。存在许多独立的项目,它们提供了与其他流行信息传递系统的集成。有关更多详细信息,请参阅github.com/spring-cloud?q=binder

Spring Cloud Stream 的核心概念如下:

  • 消息:一种用于描述发送到和从信息传递系统接收到的数据的数据结构。

  • 发布者:向信息传递系统发送消息,也称为供应商

  • 订阅者:从信息传递系统接收消息,也称为消费者

  • 目的地:用于与信息传递系统通信。发布者使用输出目的地,订阅者使用输入目的地。目的地由特定的绑定器映射到底层信息传递系统中的队列和主题。

  • 绑定器:绑定器提供了与特定信息传递系统的实际集成,类似于 JDBC 驱动程序对特定类型数据库的集成。

实际要使用的信息传递系统是在运行时确定的,取决于类路径上找到的内容。Spring Cloud Stream 提供了一套关于如何处理信息传递的约定。这些约定可以通过指定消息功能(如消费者组、分区、持久性、持久性和错误处理)的配置来覆盖;例如,重试和死信队列处理。

发送和接收消息的代码示例

为了更好地理解所有这些是如何结合在一起的,让我们看看一些源代码示例。

Spring Cloud Stream 提供了两种编程模型:一种是基于注解使用(例如,@EnableBinding@Output@StreamListener)的较老且现在已弃用的模型,另一种是基于编写函数的新模型。在这本书中,我们将使用函数式实现。

要实现发布者,我们只需要将java.util.function.Supplier功能接口实现为一个 Spring Bean。例如,以下是一个发布消息为字符串的发布者:

@Bean
public **Supplier**<String> myPublisher() {
   return () -> new Date().toString();
} 

订阅者作为实现java.util.function.Consumer功能接口的 Spring Bean 实现。例如,以下是一个消费字符串消息的订阅者:

@Bean
public **Consumer**<String> mySubscriber() {
   return s -> System.out.println("ML RECEIVED: " + s);
} 

也可以定义一个处理消息的 Spring Bean,这意味着它既消费又发布消息。这可以通过实现java.util.function.Function功能接口来完成。例如,一个 Spring Bean 消费传入的消息,并在一些处理后发布一条新消息(在这个例子中,两条消息都是字符串):

@Bean
public **Function**<String, String> myProcessor() {
   return s -> "ML PROCESSED: " + s;
} 

要使 Spring Cloud Stream 了解这些函数,我们需要使用spring.cloud.function.definition配置属性来声明它们。例如,对于之前定义的三个函数,这会看起来如下:

**spring.cloud.function:**
 **definition**: myPublisher;myProcessor;mySubscriber 

最后,我们需要告诉 Spring Cloud Stream 每个函数使用哪个目标。为了连接我们的三个函数,以便我们的处理器从发布者那里消费消息,而我们的订阅者从处理器那里消费消息,我们可以提供以下配置:

spring.cloud.stream.bindings:
  myPublisher-out-0:
    destination: myProcessor-in
  myProcessor-in-0:
    destination: myProcessor-in
  myProcessor-out-0:
    destination: myProcessor-out
  mySubscriber-in-0:
    destination: myProcessor-out 

这将导致以下消息流:

myPublisher → myProcessor → mySubscriber 

默认情况下,Spring Cloud Stream 每秒触发一次供应商,因此如果我们启动一个包含之前描述的函数和配置的 Spring Boot 应用程序,我们可以期望以下输出:

ML RECEIVED: ML PROCESSED: Wed Mar 09 16:28:30 CET 2021
ML RECEIVED: ML PROCESSED: Wed Mar 09 16:28:31 CET 2021
ML RECEIVED: ML PROCESSED: Wed Mar 09 16:28:32 CET 2021
ML RECEIVED: ML PROCESSED: Wed Mar 09 16:28:33 CET 2021 

在供应商应该由外部事件触发而不是使用定时器的情况下,可以使用StreamBridge辅助类。例如,如果当调用 REST API sampleCreateAPI时应该向处理器发布消息,代码可能如下所示:

@Autowired
private StreamBridge streamBridge;
@PostMapping
void **sampleCreateAPI**(@RequestBody String body) {
  **streamBridge**.send("myProcessor-in-0", body);
} 

现在我们已经了解了各种 Spring API,让我们在下一节中学习一些关于 Docker 和容器的内容。

Docker

我假设 Docker 和容器概念不需要深入介绍。Docker 在 2013 年使容器概念作为轻量级虚拟机替代品变得非常流行。实际上,容器是 Linux 主机上的一个进程,它使用 Linux 命名空间来在不同容器之间提供隔离,这些容器在全局系统资源(如用户、进程、文件系统和网络)的使用方面是隔离的。Linux 控制组(也称为cgroups)用于限制容器可以消耗的 CPU 和内存量。

与使用虚拟机管理程序在每个虚拟机上运行操作系统完整副本的虚拟机相比,容器中的开销是传统虚拟机开销的一小部分。

这导致启动时间更快,并且在 CPU 和内存使用方面显著降低开销。

然而,提供的容器隔离性并不被认为像虚拟机提供的隔离性那样安全。随着 Windows Server 2016 的发布,Microsoft 支持在 Windows 服务器中使用 Docker。

在过去几年里,一种轻量级的虚拟机形式已经发展起来。它结合了传统虚拟机和容器的优点,为虚拟机提供了与容器相似的足迹和启动时间,并且提供了与传统虚拟机相同级别的安全隔离。一些例子包括 Amazon FirecrackerMicrosoft Windows Subsystem for Linux v2WSL2)。更多信息,请参阅 firecracker-microvm.github.iodocs.microsoft.com/en-us/windows/wsl/.

容器在开发和测试阶段都非常有用。能够用一个命令启动一个完整的协作微服务和资源管理器(例如,数据库服务器、消息代理等)的系统景观进行测试,真是太神奇了。

例如,我们可以编写脚本来自动化微服务景观的端到端测试。测试脚本可以启动微服务景观,使用公开的 API 运行测试,并拆除景观。这种类型的自动化测试脚本非常有用,既可以在将代码推送到源代码库之前在开发者的 PC 上本地运行,也可以作为交付管道中的一个步骤执行。构建服务器可以在开发者将代码推送到源代码库时,在其持续集成和部署过程中运行这些类型的测试。

对于生产使用,我们需要一个容器编排器,如 Kubernetes。我们将在本书的后面部分回到容器编排器和 Kubernetes。

在这本书中我们将要查看的大部分微服务,只需要以下这样的 Dockerfile 就可以运行作为 Docker 容器的微服务:

FROM openjdk:17
MAINTAINER Magnus Larsson <magnus.larsson.ml@gmail.com>
EXPOSE 8080
ADD ./build/libs/*.jar app.jar
ENTRYPOINT ["java","-jar","/app.jar"] 

如果我们想要用一个命令启动和停止许多容器,Docker Compose 是完美的工具。Docker Compose 使用 YAML 文件来描述要管理的容器。

对于我们的微服务,可能看起来像以下这样:

product:
 **build**: microservices/product-service
recommendation:
 **build**: microservices/recommendation-service
review:
  **build**: microservices/review-service
composite:
  **build**: microservices/product-composite-service
  **ports**:
    - "**8080:8080**" 

让我稍微解释一下前面的源代码:

  • 使用 build 指令来指定每个微服务要使用的 Dockerfile。Docker Compose 将使用它来构建 Docker 镜像,然后基于该镜像启动 Docker 容器。

  • 复合服务的 ports 指令用于在 Docker 运行的服务器上公开端口 8080。在开发者的机器上,这意味着可以通过 localhost:8080 直接访问复合服务的端口!

YAML 文件中的所有容器都可以使用以下简单的命令进行管理:

  • docker-compose up -d:启动所有容器。-d 表示容器在后台运行,不会锁定执行命令的终端。

  • docker-compose down:停止并删除所有容器。

  • docker-compose logs -f --tail=0:打印出所有容器的日志消息。-f 表示命令不会完成,而是等待新的日志消息。--tail=0 表示我们不想看到任何以前的日志消息,只想看到新的。

有关 Docker Compose 命令的完整列表,请参阅 docs.docker.com/compose/reference/

这是对 Docker 的简要介绍。我们将从 第四章使用 Docker 部署我们的微服务 开始更详细地介绍 Docker。

摘要

在本章中,我们介绍了 Spring Boot 和可以用来构建协作微服务的开源工具。

Spring Boot 用于简化基于 Spring 的、适用于生产的应用程序的开发,例如微服务。它在如何设置 Spring 框架的核心模块和第三方工具方面有很强的意见。使用 Spring WebFlux,我们可以开发暴露反应式(即非阻塞)REST 服务的微服务。为了记录这些 REST 服务,我们可以使用 springdoc-openapi 来为 API 创建基于 OpenAPI 的文档。如果我们需要持久化微服务使用的数据,我们可以使用 Spring Data,它为使用实体和仓库访问和操作持久数据提供了一个优雅的抽象。Spring Data 的编程模型类似,但不是完全可移植到不同类型的数据库之间,例如关系型、文档型、键值型和图数据库。

如果我们更喜欢在微服务之间异步发送消息,我们可以使用 Spring Cloud Stream,它提供了基于消息的流抽象。Spring Cloud Stream 自带对 Apache Kafka 和 RabbitMQ 的支持,但可以通过自定义绑定器扩展以支持其他消息代理。最后,Docker 使得容器作为轻量级虚拟机的替代方案的概念变得易于使用。基于 Linux 命名空间和控制组,容器提供了类似于传统虚拟机的隔离,但在 CPU 和内存使用方面具有显著更低的开销。

在下一章中,我们将迈出第一步,使用 Spring Boot 和 Spring WebFlux 创建具有最小功能性的微服务。

问题

  1. @SpringBootApplication 注解的目的是什么?

  2. 旧的用于开发 REST 服务的 Spring 组件 Spring Web MVC 和新的 Spring WebFlux 之间主要区别是什么?

  3. springdoc-openapi 如何帮助开发者记录 REST API?

  4. Spring Data 中的仓库有什么功能?以及仓库的最简单实现是什么?

  5. Spring Cloud Stream 中的绑定器有什么作用?

  6. Docker Compose 的目的是什么?

第三章:创建一组协作微服务

在本章中,我们将构建我们的第一个几个微服务。我们将学习如何创建具有最小功能的协作微服务。在未来的章节中,我们将向这些微服务添加更多和更多的功能。到本章结束时,我们将通过组合微服务公开一个 RESTful API。组合微服务将调用其他三个微服务,使用它们的 RESTful API 来创建聚合响应。

本章将涵盖以下主题:

  • 介绍微服务景观

  • 生成骨架微服务

  • 添加 RESTful API

  • 添加组合微服务

  • 添加错误处理

  • 手动测试 API

  • 为微服务添加隔离的自动化测试

  • 向微服务景观添加半自动化测试

技术要求

有关如何安装本书中使用的工具以及如何访问本书源代码的说明,请参阅以下内容:

  • 第二十一章macOS 的安装说明

  • 第二十二章使用 WSL 2 和 Ubuntu 的 Microsoft Windows 安装说明

本章中的所有代码示例都来自$BOOK_HOME/Chapter03中的源代码。有了工具和源代码,我们可以开始学习我们将在本章中创建的微服务系统景观。

介绍微服务景观

第一章微服务简介中,我们简要介绍了本书将使用的基于微服务的系统景观:

图描述自动生成

图 3.1:微服务景观

它由三个核心微服务组成,即产品评论推荐服务,它们都处理一种类型的资源,以及一个名为产品组合的组合微服务,该服务从三个核心服务中聚合信息。

微服务处理的信息

为了使本书中的源代码示例易于理解,它们包含的业务逻辑量最小。出于同样的原因,它们处理的企业对象的信息模型也保持最小。在本节中,我们将了解每个微服务处理的信息,包括与基础设施相关的信息。

产品服务

产品服务管理产品信息,并使用以下属性描述每个产品:

  • 产品 ID

  • 名称

  • 重量

评论服务

评论服务管理产品评论,并存储以下关于每个评论的以下信息:

  • 产品 ID

  • 评论 ID

  • 作者

  • 主题

  • 内容

推荐服务

推荐服务管理产品推荐,并存储以下关于每个推荐的以下信息:

  • 产品 ID

  • 推荐 ID

  • 作者

  • 评分

  • 内容

产品组合服务

产品组合服务从三个核心服务中聚合信息,并按以下方式展示产品信息:

  • 如产品服务所述的产品信息

  • 如审查服务所述的指定产品的产品评论列表

  • 如推荐服务所述的指定产品的产品推荐列表

基础设施相关信息

一旦我们开始以由基础设施管理的容器形式运行我们的微服务(首先是 Docker,然后是 Kubernetes),跟踪哪些容器实际上响应了我们的请求将变得很有兴趣。作为一个简单的解决方案,已向所有响应添加了一个serviceAddress属性,格式为hostname/ip-address:port

在第十八章“使用服务网格提高可观察性和管理”和第十九章“使用 EFK 堆栈进行集中式日志记录”中,我们将了解跟踪微服务处理请求的更强大解决方案。

临时替换服务发现

由于在此阶段我们没有设置任何服务发现机制,因此我们将所有微服务运行在localhost上,并为每个微服务使用硬编码的端口号。我们将使用以下端口号:

  • 产品组合服务:7000

  • 产品服务:7001

  • 审查服务:7002

  • 推荐服务:7003

我们将在开始使用 Docker 和 Kubernetes 时删除硬编码的端口号!

在本节中,我们介绍了将要创建的微服务和它们将处理的信息。在下节中,我们将使用 Spring Initializr 为微服务创建骨架代码。

生成骨架微服务

现在是时候看看我们如何为我们的微服务创建项目了。本主题的最终结果可以在$BOOK_HOME/Chapter03/1-spring-init文件夹中找到。为了简化项目设置,我们将使用Spring Initializr为每个微服务生成一个骨架项目。骨架项目包含构建项目所需的所有文件,以及微服务的空main类和测试类。之后,我们将了解如何使用我们将使用的构建工具 Gradle 中的多项目构建命令来构建所有微服务。

使用 Spring Initializr 生成骨架代码

要开始开发我们的微服务,我们将使用一个名为 Spring Initializr 的工具为我们生成骨架代码。Spring Initializr 由 Spring 团队提供,可用于配置和生成新的 Spring Boot 应用程序。该工具帮助开发者选择应用程序将使用的附加 Spring 模块,并确保依赖项配置为使用所选模块的兼容版本。该工具支持使用 Maven 或 Gradle 作为构建系统,并可以为 Java、Kotlin 或 Groovy 生成源代码。

它可以通过 URL start.spring.io/ 或使用命令行工具 spring init 从网络浏览器中调用。为了更容易地重现微服务的创建,我们将使用命令行工具。

对于每个微服务,我们将创建一个执行以下操作的 Spring Boot 项目:

  • 使用 Gradle 作为构建工具

  • 为 Java 8 生成代码

  • 将项目打包成一个胖 JAR 文件

  • 引入 ActuatorWebFlux Spring 模块的依赖

  • 基于 Spring Boot v3.0.4(它依赖于 Spring Framework v6.0.6)

Spring Boot Actuator 为管理和监控提供了一些有价值的端点。我们将在稍后看到它们的作用。Spring WebFlux 将用于创建我们的 RESTful API。

为了为我们的微服务创建骨架代码,我们需要为 product-service 运行以下命令:

spring init \
--boot-version=3.0.4 \
--type=gradle-project \
--java-version=17 \
--packaging=jar \
--name=product-service \
--package-name=se.magnus.microservices.core.product \
--groupId=se.magnus.microservices.core.product \
--dependencies=actuator,webflux \
--version=1.0.0-SNAPSHOT \
product-service 

如果你想了解更多关于 spring init CLI 的信息,可以运行 spring help init 命令。要查看可以添加的依赖项,请运行 spring init --list 命令。

如果你想要自己创建四个项目而不是使用本书 GitHub 仓库中的源代码,请尝试 $BOOK_HOME/Chapter03/1-spring-init/create-projects.bash,如下所示:

mkdir some-temp-folder
cd some-temp-folder
$BOOK_HOME/Chapter03/1-spring-init/create-projects.bash 

使用 create-projects.bash 创建我们的四个项目后,我们将有以下文件结构:

microservices/
├── product-composite-service 
├── product-service
├── recommendation-service
└── review-service 

对于每个项目,我们可以列出创建的文件。让我们为 product-service 项目做这件事:

find microservices/product-service -type f 

我们将收到以下输出:

文本描述自动生成

图 3.2:列出我们为 product-service 创建的文件

Spring Initializr 为 Gradle 创建了多个文件,一个 .gitignore 文件和三个 Spring Boot 文件:

  • ProductServiceApplication.java,我们的主应用程序类

  • application.properties,一个空属性文件

  • ProductServiceApplicationTests.java,一个配置为使用 JUnit 在我们的 Spring Boot 应用程序上运行测试的测试类

根据前一章中 The magic @SpringBootApplication annotation 部分的描述,main 应用程序类 ProductServiceApplication.java 看起来应该是这样的:

package se.magnus.microservices.core.product;
@SpringBootApplication
public class ProductServiceApplication {
   public static void main(String[] args) {
      SpringApplication.run(ProductServiceApplication.class, args);
   }
} 

测试类看起来如下:

package se.magnus.microservices.core.product;
**@SpringBootTest**
class ProductServiceApplicationTests {
   @Test
   void contextLoads() {
   }
} 

@SpringBootTest 注解将以与 @SpringBootApplication 相同的方式初始化我们的应用程序;也就是说,在执行测试之前,将使用组件扫描和自动配置设置 Spring 应用程序上下文,正如前一章所述。

让我们再看看最重要的 Gradle 文件,build.gradle。此文件的内容描述了如何构建项目——例如,如何解决依赖关系和编译、测试和打包源代码。Gradle 文件首先列出要应用的插件:

plugins {
    id 'java'
    id 'org.springframework.boot' version '3.0.4'
    id 'io.spring.dependency-management' version '1.1.0'
} 

声明的插件如下使用:

  • java 插件将 Java 编译器添加到项目中。

  • 声明了org.springframework.bootio.spring.dependency-management插件,这两个插件一起确保 Gradle 将构建一个胖 JAR 文件,并且我们不需要在 Spring Boot 启动器依赖项上指定任何显式的版本号。相反,它们由org.springframework.boot插件的版本隐含,即 3.0.4。

在构建文件的其余部分,我们基本上声明了项目的组名和版本、Java 版本及其依赖项:

**group** = 'se.magnus.microservices.composite.product'
**version** = '1.0.0-SNAPSHOT'
**sourceCompatibility** = '17'
repositories {
    mavenCentral()
}
**dependencies** {
    implementation 'org.springframework.boot:spring-boot-starter-actuator'
    implementation 'org.springframework.boot:spring-boot-starter-webflux'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
    testImplementation 'io.projectreactor:reactor-test'
}
**tasks.named('test') {**
 **useJUnitPlatform()**
**}** 

关于使用的依赖项和最终测试声明的几点说明:

  • 依赖项,就像前面的插件一样,从中央 Maven 仓库获取。

  • 依赖项的设置与 Actuator 和 WebFlux 模块中指定的设置相同,以及一些有用的测试依赖项。

  • 最后,JUnit 被配置为在 Gradle 构建中运行我们的测试。

我们可以使用以下命令分别构建每个微服务:

cd microservices/product-composite-service; ./gradlew build; cd -; \
cd microservices/product-service;           ./gradlew build; cd -; \
cd microservices/recommendation-service;    ./gradlew build; cd -; \
cd microservices/review-service;            ./gradlew build; cd -; 

注意我们如何使用由 Spring Initializr 创建的gradlew可执行文件;也就是说,我们不需要安装 Gradle!

第一次运行带有gradlew的命令时,它会自动下载 Gradle。使用的 Gradle 版本由gradle/wrapper/gradle-wrapper.properties文件中的distributionUrl属性确定。

在 Gradle 中设置多项目构建

为了使使用一个命令构建所有微服务变得简单一些,我们可以在 Gradle 中设置一个多项目构建。步骤如下:

  1. 首先,我们创建settings.gradle文件,该文件描述了 Gradle 应该构建哪些项目:

    cat <<EOF > settings.gradle
    include ':microservices:product-service'
    include ':microservices:review-service'
    include ':microservices:recommendation-service'
    include ':microservices:product-composite-service'
    EOF 
    
  2. 接下来,我们复制从其中一个项目中生成的 Gradle 可执行文件,以便我们可以为多项目构建重用它们:

    cp -r microservices/product-service/gradle .
    cp microservices/product-service/gradlew .
    cp microservices/product-service/gradlew.bat .
    cp microservices/product-service/.gitignore . 
    
  3. 我们不再需要每个项目中生成的 Gradle 可执行文件,因此我们可以使用以下命令删除它们:

    find microservices -depth -name "gradle" -exec rm -rfv "{}" \; 
    find microservices -depth -name "gradlew*" -exec rm -fv "{}" \; 
    

    结果应该与你在文件夹$BOOK_HOME/Chapter03/1-spring-init中找到的代码类似。

  4. 现在,我们可以使用一个命令构建所有微服务:

    ./gradlew build 
    

如果你还没有运行前面的命令,你可以直接进入书籍的源代码,并从那里构建微服务:

cd $BOOK_HOME/Chapter03/1-spring-init
./gradlew build 

这应该会产生以下输出:

文本描述自动生成

图 3.3:成功构建后的输出

使用 Spring Initializr 创建的微服务骨架项目和成功使用 Gradle 构建后,我们就可以在下一节中为微服务添加一些代码了。

从 DevOps 的角度来看,多项目设置可能不是首选。相反,为了使每个微服务都有其自己的构建和发布周期,为每个微服务项目设置单独的构建管道可能更受欢迎。然而,为了本书的目的,我们将使用多项目设置,以便可以使用单个命令轻松构建和部署整个系统景观。

添加 RESTful API

现在我们已经为我们的微服务设置了项目,让我们为我们的三个核心微服务添加一些 RESTful API!

本章的最终结果以及剩余主题可以在$BOOK_HOME/Chapter03/2-basic-rest-services文件夹中找到。

首先,我们将添加两个项目(apiutil),这两个项目将包含由微服务项目共享的代码,然后我们将实现 RESTful API。

添加 API 和 util 项目

要添加一个api项目,我们需要做以下几步:

  1. 首先,我们将设置一个单独的 Gradle 项目,我们可以在这个项目中放置我们的 API 定义。我们将使用 Java 接口来描述我们的 RESTful API 和模型类来描述 API 在其请求和响应中使用的数据。为了描述 API 可以返回的错误类型,还定义了一系列异常类。在 Java 接口中而不是直接在 Java 类中描述 RESTful API,对我来说,是一种将 API 定义与其实现分离的好方法。我们将在本书的后面进一步扩展这种模式,当我们向 Java 接口添加更多 API 信息以在 OpenAPI 规范中公开时。有关更多信息,请参阅第五章使用 OpenAPI 添加 API 描述

是否将一组微服务的 API 定义存储在公共 API 模块中,这是一个有争议的问题。这可能会在微服务之间产生不希望的依赖关系,从而导致单体特性,例如,导致更复杂和缓慢的开发过程。对我来说,对于属于同一交付组织的微服务来说,这是一个好的选择,也就是说,它们的发布由同一组织管理(将此与领域驱动设计中的边界上下文进行比较,我们的微服务被放置在单个边界上下文中)。如第一章微服务简介中已讨论的,同一边界上下文内的微服务需要基于公共信息模型的 API 定义,因此将这些 API 定义存储在同一个 API 模块中不会添加任何不希望的依赖项。

  1. 接下来,我们将创建一个util项目,该项目可以存放一些由我们的微服务共享的辅助类,例如,用于以统一方式处理错误。

再次从 DevOps 的角度来看,最好是为每个项目构建自己的构建管道,并在微服务项目中为apiutil项目设置版本控制的依赖项,也就是说,这样每个微服务都可以选择使用apiutil项目的哪个版本。但为了保持本书中构建和部署步骤的简单性,我们将apiutil项目作为多项目构建的一部分。

api 项目

api项目将被打包成一个库;也就是说,它不会有自己的main应用程序类。不幸的是,Spring Initializr 不支持创建库项目。相反,必须从头手动创建库项目。API 项目的源代码位于$BOOK_HOME/Chapter03/2-basic-rest-services/api

库项目的结构与应用程序项目相同,只是我们没有main应用程序类,以及build.gradle文件中的一些细微差异。org.springframework.boot Gradle 插件被替换为implementation platform部分:

ext {
    springBootVersion = '3.0.4'
}
dependencies {
    implementation platform("org.springframework.boot:spring-boot-dependencies:${springBootVersion}") 

这允许我们在用创建一个只包含项目自身类和属性文件的正常 JAR 文件替换构建步骤中构建胖 JAR 文件的同时,保留 Spring Boot 依赖管理。

我们三个核心微服务的api项目中的 Java 文件如下:

$BOOK_HOME/Chapter03/2-basic-rest-services/api/src/main/java/se/magnus/api/core
├── product
│   ├── Product.java
│   └── ProductService.java
├── recommendation
│   ├── Recommendation.java
│   └── RecommendationService.java
└── review
    ├── Review.java
    └── ReviewService.java 

这三个核心微服务的 Java 类结构看起来非常相似,所以我们只将通过product服务的源代码进行说明。

首先,我们将查看ProductService.java Java 接口,如下所示:

package se.magnus.api.core.product;
public interface ProductService {
    **@GetMapping**(
        value    = "/product/{productId}",
        produces = "application/json")
     Product **getProduct****(****@PathVariable** int productId);
} 

Java 接口声明如下:

  • product服务仅公开一个 API 方法,getProduct()(我们将在本书的第六章添加持久性中扩展 API)。

  • 要将方法映射到 HTTP GET请求,我们使用@GetMapping Spring 注解,其中我们指定方法将被映射到的 URL 路径(/product/{productId})以及响应的格式——在这种情况下,是 JSON。

  • 路径中的{productId}部分映射到一个名为productId的路径变量。

  • productId方法参数被@PathVariable注解,它将传入 HTTP 请求中的值映射到参数。例如,对/product/123的 HTTP GET请求将导致调用getProduct()方法,并将productId参数设置为123

该方法返回一个Product对象,一个基于 POJO 的模型类,其成员变量对应于Product的属性,如本章开头所述。Product.java看起来如下(省略了构造函数和 getter 方法):

public class **Product** {
  private final int productId;
  private final String name;
  private final int weight;
  private final String serviceAddress;
} 

这种类型的 POJO 类也被称为数据传输对象DTO),因为它用于在 API 实现和 API 调用者之间传输数据。当我们到达第六章添加持久性时,我们将查看另一种类型的 POJO,它可以用来描述数据在数据库中的存储方式,也称为实体对象。

API 项目还包含异常类InvalidInputExceptionNotFoundException

util 项目

util项目将以与api项目相同的方式打包为库。util项目的源代码位于$BOOK_HOME/Chapter03/2-basic-rest-services/util。该项目包含以下工具类:GlobalControllerExceptionHandlerHttpErrorInfoServiceUtil

除了ServiceUtil.java中的代码外,这些类是可重用的工具类,我们可以使用它们将 Java 异常映射到适当的 HTTP 状态码,如后续章节添加错误处理中所述。ServiceUtil.java的主要目的是找出微服务使用的 hostname、IP 地址和端口号。该类公开了一个getServiceAddress()方法,微服务可以使用它来查找它们的 hostname、IP 地址和端口号,如前述章节基础设施相关信息中所述。

实现我们的 API

现在,我们可以在核心微服务中开始实现我们的 API 了!

对于三个核心微服务的实现看起来非常相似,所以我们只通过product服务的源代码来演示。其他文件可以在$BOOK_HOME/Chapter03/2-basic-rest-services/microservices中找到。让我们看看我们是如何进行这一过程的:

  1. 我们需要将apiutil项目作为依赖项添加到我们的build.gradle文件中,在product-service项目中:

    dependencies {
       implementation project(':api')
       implementation project(':util') 
    
  2. 为了启用 Spring Boot 的自动配置功能以检测apiutil项目中的 Spring Beans,我们还需要在主应用程序类中添加一个@ComponentScan注解,该注解包括apiutil项目的包:

    @SpringBootApplication
    @ComponentScan("se.magnus")
    public class ProductServiceApplication { 
    
  3. 接下来,我们创建服务实现文件,ProductServiceImpl.java,以便实现来自api项目的 Java 接口ProductService,并使用@RestController注解该类,这样 Spring 就会根据Interface类中指定的映射调用该类中的方法:

    package se.magnus.microservices.core.product.services;
    **@RestController**
    public class ProductServiceImpl implements ProductService {
    } 
    
  4. 为了能够使用来自util项目的ServiceUtil类,我们将将其注入到构造函数中,如下所示:

    private final ServiceUtil serviceUtil;
    @Autowired
    public ProductServiceImpl(ServiceUtil serviceUtil) {
        this.serviceUtil = serviceUtil;
    } 
    
  5. 现在,我们可以通过覆盖api项目中的接口的getProduct()方法来实现 API:

    @Override
    public Product getProduct(int productId) {
     return new Product(productId, "name-" + productId, 123, 
     serviceUtil.getServiceAddress());
    } 
    

    由于我们目前没有使用数据库,我们只是根据productId的输入返回一个硬编码的响应,以及由ServiceUtil类提供的服务地址。

    对于最终结果,包括日志记录和错误处理,请参阅ProductServiceImpl.java

  6. 最后,我们还需要设置一些运行时属性——使用哪个端口以及所需的日志级别。这被添加到属性文件application.yml中:

    server.port: 7001
    logging:
      level:
        root: INFO
        se.magnus.microservices: DEBUG 
    

注意,由 Spring Initializr 生成的空application.properties文件已被 YAML 文件application.yml替换。与.properties文件相比,YAML 文件提供了更好的相关属性分组支持。如上例中的日志级别设置所示。

  1. 我们可以单独尝试product服务。使用以下命令构建并启动微服务:

    cd $BOOK_HOME/Chapter03/2-basic-rest-services
    ./gradlew build
    java -jar microservices/product-service/build/libs/*.jar & 
    

    等待以下内容在终端中打印出来:

文本描述自动生成图 3.4:启动 ProductServiceApplication

  1. product 服务进行测试调用:

    curl http://localhost:7001/product/123 
    

    它应该响应如下所示的内容:

图 3.5:测试调用预期响应

  1. 最后,停止 product 服务:

    kill $(jobs -p) 
    

我们现在已经构建、运行并测试了我们的第一个单一微服务。在下一节中,我们将实现将使用我们迄今为止创建的三个核心微服务的复合微服务。

从 Spring Boot v2.5.0 开始,在运行 ./gradlew build 命令时将创建两个 JAR 文件:普通 JAR 文件,以及仅包含编译 Spring Boot 应用程序中的 Java 文件生成的类文件的普通 JAR 文件。由于我们不需要新的普通 JAR 文件,因此已禁用其创建,以便在运行 Spring Boot 应用程序时可以使用通配符引用普通 JAR 文件,例如:

java -jar microservices/product-service/build/libs/*.jar 

通过将以下行添加到每个微服务的 build.gradle 文件中,已禁用创建新的普通 JAR 文件:

jar {
    enabled = false
} 

对于更多详细信息,请参阅 docs.spring.io/spring-boot/docs/3.0.4/gradle-plugin/reference/htmlsingle/#packaging-executable.and-plain-archives

添加复合微服务

现在,是时候通过添加将调用三个核心服务的复合服务来整合这些内容了!

复合服务的实现分为两部分:一个处理对核心服务的出站 HTTP 请求的集成组件,以及复合服务实现本身。这种责任划分的主要原因是为了简化自动化的单元和集成测试;我们可以通过用模拟替换集成组件来独立测试服务实现。

正如我们将在本书后面看到的那样,这种责任划分也将使引入断路器变得更加容易!

在我们查看两个组件的源代码之前,我们需要查看复合微服务将使用的 API 类,并了解如何使用运行时属性来保存核心微服务的地址信息。

集成组件和复合服务实现的完整实现可以在 Java 包 se.magnus.microservices.composite.product.services 中找到。

API 类

在本节中,我们将查看描述复合组件 API 的类。它们可以在 $BOOK_HOME/Chapter03/2-basic-rest-services/api 中找到。以下是一些 API 类:

$BOOK_HOME/Chapter03/2-basic-rest-services/api
└── src/main/java/se/magnus/api/composite
    └── product
        ├── ProductAggregate.java
        ├── ProductCompositeService.java
        ├── RecommendationSummary.java
        ├── ReviewSummary.java
        └── ServiceAddresses.java 

Java 接口类 ProductCompositeService.java 遵循核心服务使用的相同模式,如下所示:

package se.magnus.api.composite.product;
public interface ProductCompositeService {
    @GetMapping(
        value    = "/product-composite/{productId}",
        produces = "application/json")
    ProductAggregate getProduct(@PathVariable int productId);
} 

模型类ProductAggregate.java比核心模型复杂一些,因为它包含推荐和评论列表的字段:

package se.magnus.api.composite.product;
public class ProductAggregate {
    private final int productId;
    private final String name;
    private final int weight;
    private final **List<RecommendationSummary> recommendations**;
    private final **List<ReviewSummary> reviews**;
    private final ServiceAddresses serviceAddresses; 

剩余的 API 类是普通的 POJO 模型对象,结构与核心 API 的模型对象相同。

属性

为了避免将核心服务的地址信息硬编码到复合微服务的源代码中,后者使用一个属性文件来存储如何找到核心服务的信息。这个属性文件,application.yml,看起来如下所示:

server.port: 7000
app:
  product-service:
    host: localhost
    port: 7001
  recommendation-service:
    host: localhost
    port: 7002
  review-service:
    host: localhost
    port: 7003 

如前所述,此配置将在本书后面的部分被服务发现机制所取代。

集成组件

让我们看看复合微服务实现的第一部分,集成组件,ProductCompositeIntegration.java。它使用@Component注解声明为 Spring Bean,并实现了三个核心服务的 API 接口:

package se.magnus.microservices.composite.product.services;
@Component
public class ProductCompositeIntegration implements ProductService, RecommendationService, ReviewService { 

集成组件使用 Spring 框架中的一个辅助类RestTemplate来执行对核心微服务的实际 HTTP 请求。在我们将其注入到集成组件之前,我们需要对其进行配置。我们在main应用程序类ProductCompositeServiceApplication.java中这样做,如下所示:

@Bean
RestTemplate restTemplate() {
   return new RestTemplate();
} 

RestTemplate对象高度可配置,但我们现在先保留其默认值。

第二章Spring Boot 简介部分,我们介绍了反应式 HTTP 客户端WebClient。在本章中使用WebClient代替RestTemplate将需要所有使用WebClient的源代码也必须是反应式的,包括 API 项目中 RESTful API 的声明和复合微服务的源代码。在第七章开发反应式微服务中,我们将学习如何更改微服务的实现以遵循反应式编程模型。在这个更新步骤之一,我们将用WebClient类替换RestTemplate辅助类。但在我们了解 Spring 中的反应式开发之前,我们将使用RestTemplate类。

现在我们可以注入RestTemplate,以及一个 JSONmapper,后者用于在出错时访问错误消息,以及我们在属性文件中设置的配置值。让我们看看这是如何完成的:

  1. 对象和配置值如下注入到构造函数中:

    private final RestTemplate restTemplate;
    private final ObjectMapper mapper;
    private final String productServiceUrl;
    private final String recommendationServiceUrl;
    private final String reviewServiceUrl;
    @Autowired
    public ProductCompositeIntegration(
      RestTemplate restTemplate,
      ObjectMapper mapper,
      @Value("${app.product-service.host}") 
      String productServiceHost,
    
      @Value("${app.product-service.port}")
      int productServicePort,
      @Value("${app.recommendation-service.host}")
      String recommendationServiceHost,
      @Value("${app.recommendation-service.port}")
      int recommendationServicePort,
      @Value("${app.review-service.host}")
      String reviewServiceHost,
      @Value("${app.review-service.port}")
      int reviewServicePort
    ) 
    
  2. 构造函数的主体存储注入的对象并基于注入的值构建 URL,如下所示:

    {
      this.restTemplate = restTemplate;
      this.mapper = mapper;
      productServiceUrl = "http://" + productServiceHost + ":" + 
      productServicePort + "/product/";
      recommendationServiceUrl = "http://" + recommendationServiceHost
      + ":" + recommendationServicePort + "/recommendation?
      productId="; reviewServiceUrl = "http://" + reviewServiceHost + 
      ":" + reviewServicePort + "/review?productId=";
    } 
    
  3. 最后,集成组件通过使用RestTemplate进行实际出站调用来实现三个核心服务的 API 方法:

    public Product getProduct(int productId) {
     String url = productServiceUrl + productId;
     Product product = **restTemplate**.getForObject(url, Product.class);
     return product;
    }
    public List<Recommendation> getRecommendations(int productId) {
        String url = recommendationServiceUrl + productId;
        List<Recommendation> recommendations = 
        **restTemplate**.exchange(url, GET, null, new 
        **ParameterizedTypeReference**<List<Recommendation>>() 
        {}).getBody();
        return recommendations;
    }
    public List<Review> getReviews(int productId) {
        String url = reviewServiceUrl + productId;
        List<Review> reviews = **restTemplate**.exchange(url, GET, null,
        new **ParameterizedTypeReference**<List<Review>>() {}).getBody();
        return reviews;
    } 
    

关于方法实现的一些有趣注释:

  1. 对于getProduct()实现,可以在RestTemplate中使用getForObject()方法。期望的响应是一个Product对象。可以通过在getForObject()调用中指定RestTemplate将映射 JSON 响应到的Product.class类来表示。

  2. 对于getRecommendations()getReviews()的调用,必须使用更高级的方法exchange()。这是因为RestTemplate会自动将 JSON 响应映射到模型类。getRecommendations()getReviews()方法期望响应中包含泛型列表,即List<Recommendation>List<Review>。由于泛型在运行时不会保留任何类型信息,因此我们无法指定这些方法期望在响应中包含泛型列表。相反,我们可以使用 Spring 框架中的一个辅助类ParameterizedTypeReference,它通过在运行时保留类型信息来设计解决此问题。这意味着RestTemplate可以确定将 JSON 响应映射到哪个类。为了使用这个辅助类,我们必须使用更复杂的exchange()方法,而不是RestTemplate上的简单getForObject()方法。

复合 API 实现

最后,我们将查看复合微服务实现的最后一部分:API 实现类ProductCompositeServiceImpl.java。让我们一步一步地来看它:

  1. 与核心服务类似,复合服务实现了其 API 接口ProductCompositeService,并使用@RestController注解标记为 REST 服务:

    package se.magnus.microservices.composite.product.services;
    @RestController
    public class ProductCompositeServiceImpl implements ProductCompositeService { 
    
  2. 实现类需要ServiceUtil豆和它自己的集成组件,因此它们被注入到其构造函数中:

    private final ServiceUtil serviceUtil;
    private ProductCompositeIntegration integration;
    @Autowired
    public ProductCompositeServiceImpl(ServiceUtil serviceUtil, ProductCompositeIntegration integration) {
        this.serviceUtil = serviceUtil;
        this.integration = integration;
    } 
    
  3. 最后,API 方法实现如下:

    @Override
    public ProductAggregate getProduct(int productId) {
    
      Product product = integration.getProduct(productId);
      List<Recommendation> recommendations = 
      integration.getRecommendations(productId);
      List<Review> reviews = integration.getReviews(productId);
    
      return **createProductAggregate**(product, recommendations,
      reviews, serviceUtil.getServiceAddress());
    } 
    

集成组件用于调用三个核心服务,并使用辅助方法createProductAggregate()根据集成组件的调用响应创建ProductAggregate类型的响应对象。

辅助方法createProductAggregate()的实现相当冗长且不是非常重要,因此已从本章中省略;然而,它可以在本书的源代码中找到。

集成组件和复合服务的完整实现可以在 Java 包se.magnus.microservices.composite.product.services中找到。

从功能角度来看,这完成了复合微服务的实现。在下一节中,我们将看到我们如何处理错误。

添加错误处理

在微服务领域中,以结构化和深思熟虑的方式处理错误至关重要,因为在微服务之间使用同步 API(例如 HTTP 和 JSON)进行大量通信。同时,将特定协议的错误处理(如 HTTP 状态码)与业务逻辑分离也很重要。

可以争论说,在实现微服务时应该添加一个单独的业务逻辑层。这应该确保业务逻辑与特定协议的代码分离,使得测试和重用都更容易。为了避免本书中提供的示例中出现不必要的复杂性,我们省略了单独的业务逻辑层,因此微服务直接在@RestController组件中实现其业务逻辑。

我在util项目中创建了一系列 Java 异常,这些异常既被 API 实现使用,也被 API 客户端使用,最初是InvalidInputExceptionNotFoundException。有关详细信息,请查看 Java 包se.magnus.util.exceptions

全局 REST 控制器异常处理器

为了将特定协议的错误处理与 REST 控制器中的业务逻辑分开,也就是说,与 API 实现分开,我在util项目中创建了一个实用类,名为GlobalControllerExceptionHandler.java,并使用@RestControllerAdvice注解。

对于 API 实现抛出的每个 Java 异常,实用类都有一个异常处理方法,将 Java 异常映射到适当的 HTTP 响应,即具有适当的 HTTP 状态和 HTTP 响应体。

例如,如果一个 API 实现类抛出InvalidInputException,实用类会将其映射到一个状态码设置为422UNPROCESSABLE_ENTITY)的 HTTP 响应。以下代码展示了这一点:

@ResponseStatus(**UNPROCESSABLE_ENTITY**)
@ExceptionHandler(**InvalidInputException.class**)
public @ResponseBody HttpErrorInfo handleInvalidInputException(
    ServerHttpRequest request, InvalidInputException ex) {
    return createHttpErrorInfo(UNPROCESSABLE_ENTITY, request, ex);
} 

同样,NotFoundException被映射到404NOT_FOUND)HTTP 状态码。

每当 REST 控制器抛出这些异常之一时,Spring 都会使用实用类来创建一个 HTTP 响应。

注意,当 Spring 检测到无效请求时,例如请求包含非数字的产品 ID(在 API 声明中,productId被指定为整数),它本身会返回 HTTP 状态码400BAD_REQUEST)。

关于实用类的完整源代码,请参阅GlobalControllerExceptionHandler.java

API 实现中的错误处理

API 实现使用util项目中的异常来表示错误。它们将以 HTTPS 状态码的形式返回给 REST 客户端,以指示发生了什么错误。例如,Product微服务实现类ProductServiceImpl.java使用InvalidInputException异常来返回一个表示输入无效的错误,以及使用NotFoundException异常来告诉我们请求的产品不存在。代码如下:

if (productId < 1) throw new **InvalidInputException**("Invalid productId: 
    " + productId);
if (productId == 13) throw new **NotFoundException**("No product found for 
    productId: " + productId); 

由于我们目前没有使用数据库,我们必须模拟何时抛出NotFoundException

API 客户端中的错误处理

API 客户端,即 Composite 微服务的集成组件,执行相反的操作;它将 422 (UNPROCESSABLE_ENTITY) HTTP 状态码映射到 InvalidInputException,将 404 (NOT_FOUND) HTTP 状态码映射到 NotFoundException。请参阅 ProductCompositeIntegration.java 中的 getProduct() 方法以了解此错误处理逻辑的实现。源代码如下:

catch (HttpClientErrorException ex) {
    switch (HttpStatus.resolve(ex.getStatusCode().value())) {
    case **NOT_FOUND**:
        throw new NotFoundException(getErrorMessage(ex));
    case **UNPROCESSABLE_ENTITY**:
        throw new InvalidInputException(getErrorMessage(ex));
    default:
        LOG.warn("Got an unexpected HTTP error: {}, will rethrow it", 
        ex.getStatusCode());
        LOG.warn("Error body: {}", ex.getResponseBodyAsString());
        throw ex;
    }
} 

在集成组件中对 getRecommendations()getReviews() 的错误处理稍微宽松一些——被视为尽力而为,这意味着如果它成功获取产品信息,但未能获取推荐或评论,仍然被认为是可接受的。然而,会写入日志警告。

详细信息,请参阅 ProductCompositeIntegration.java

这样就完成了代码和组合微服务的实现。在下一节中,我们将测试微服务和它们公开的 API。

手动测试 API

这样就完成了我们微服务的实现。让我们通过以下步骤来尝试它们:

  1. 将微服务作为后台进程构建并启动。

  2. 使用 curl 调用组合 API。

  3. 停止微服务。

首先,按照以下方式将每个微服务作为后台进程构建和启动:

cd $BOOK_HOME/Chapter03/2-basic-rest-services/
./gradlew build 

构建完成后,我们可以使用以下代码将微服务作为后台进程启动到终端进程:

java -jar microservices/product-composite-service/build/libs/*.jar &
java -jar microservices/product-service/build/libs/*.jar &
java -jar microservices/recommendation-service/build/libs/*.jar &
java -jar microservices/review-service/build/libs/*.jar & 

将会有大量的日志消息写入终端,但几秒钟后,事情会平静下来,我们将在日志中找到以下消息:

文本描述自动生成

图 3.6:应用程序启动后的日志消息

这意味着它们都准备好接收请求。以下代码尝试一下:

curl http://localhost:7000/product-composite/1 

在一些日志输出后,我们将得到一个看起来像以下这样的 JSON 响应:

文本描述自动生成

图 3.7:请求后的 JSON 响应

要获取格式化的 JSON 响应,你可以使用 jq 工具:

curl http://localhost:7000/product-composite/1 -s | jq . 

这将产生以下输出(一些细节已被 ... 替换以提高可读性):

文本描述自动生成

图 3.8:格式化的 JSON 响应

如果你想的话,也可以尝试以下命令来验证错误处理是否按预期工作:

# Verify that a 404 (Not Found) error is returned for a non-existing productId (13)
curl http://localhost:7000/product-composite/13 -i
# Verify that no recommendations are returned for productId 113
curl http://localhost:7000/product-composite/113 -s | jq .
# Verify that no reviews are returned for productId 213
curl http://localhost:7000/product-composite/213 -s | jq .
# Verify that a 422 (Unprocessable Entity) error is returned for a productId that is out of range (-1)
curl http://localhost:7000/product-composite/-1 -i
# Verify that a 400 (Bad Request) error is returned for a productId that is not a number, i.e. invalid format
curl http://localhost:7000/product-composite/invalidProductId -i 

最后,你可以使用以下命令关闭微服务:

kill $(jobs -p) 

如果你使用的是 Visual Studio Code 和 Spring Tool Suite 这样的 IDE,你可以使用它们对 Spring Boot Dashboard 的支持,通过一键启动和停止你的微服务。有关如何安装 Spring Tool Suite 的说明,请参阅 github.com/spring-projects/sts4/wiki/Installation

以下截图显示了在 Visual Studio Code 中使用 Spring Boot Dashboard:

图形用户界面,文本,应用程序  自动生成的描述

图 3.9:Visual Studio Code 中的 Spring Boot 仪表板

在本节中,我们学习了如何手动启动、测试和停止协作微服务的系统景观。这类测试耗时较长,因此显然需要自动化。

在接下来的两个部分中,我们将迈出第一步学习如何自动化测试,测试单个独立微服务以及整个协作微服务系统景观。在这本书的整个过程中,我们将改进我们测试微服务的方式。

独立添加自动化微服务测试

在我们完成实现之前,我们还需要编写一些自动化测试。

目前我们没有太多业务逻辑要测试,因此不需要编写任何单元测试。相反,我们将专注于测试我们的微服务公开的 API;也就是说,我们将使用嵌入的 Web 服务器在集成测试中启动它们,然后使用测试客户端执行 HTTP 请求并验证响应。随着 Spring WebFlux 的出现,提供了一个测试客户端WebTestClient,它提供了一个流畅的 API 来发出请求并在其结果上应用断言。

以下是一个测试复合产品 API 的示例,我们进行以下测试:

  • 发送现有产品的productId并断言我们收到200作为 HTTP 响应代码,以及包含请求的productId以及一个推荐和一个评论的 JSON 响应

  • 发送缺少productId并断言我们收到404作为 HTTP 响应代码,以及包含相关错误信息的 JSON 响应

这两个测试的实现如下所示。第一个测试看起来如下:

@Autowired
private **WebTestClient client**;
@Test
void getProductById() {
  **client**.get()
    .uri("/product-composite/" + PRODUCT_ID_OK)
    .accept(APPLICATION_JSON_UTF8)
    .exchange()
    .expectStatus().isOk()
    .expectHeader().contentType(APPLICATION_JSON_UTF8)
    .expectBody()
    .jsonPath("$.productId").isEqualTo(PRODUCT_ID_OK)
    .jsonPath("$.recommendations.length()").isEqualTo(1)
    .jsonPath("$.reviews.length()").isEqualTo(1);
} 

测试代码的工作方式如下:

  • 测试使用流畅的WebTestClient API 设置要调用的 URL 为"/product-composite/" + PRODUCT_ID_OK,并指定接受的响应格式,JSON。

  • 在使用exchange()方法执行请求之后,测试验证响应状态是OK200),并且响应格式实际上是 JSON(如请求所示)。

  • 最后,测试检查响应体,并验证它是否包含预期的信息,即productId以及推荐和评论的数量。

第二个测试看起来如下:

@Test
public void getProductNotFound() {
  client.get()
    .uri("/product-composite/" + PRODUCT_ID_NOT_FOUND)
    .accept(APPLICATION_JSON_UTF8)
    .exchange()
    **.expectStatus().isNotFound()**
    .expectHeader().contentType(APPLICATION_JSON_UTF8)
    .expectBody()
    .jsonPath("$.path").isEqualTo("/product-composite/" + 
     PRODUCT_ID_NOT_FOUND)
    **.jsonPath(****"$.message"****).isEqualTo(****"NOT FOUND: "** **+** 
 **PRODUCT_ID_NOT_FOUND);**
} 

关于这个测试代码的一个重要注意事项是:

  • 这个负面测试在结构上与前面的测试非常相似;主要区别在于它验证了它收到了一个错误状态码回执,Not Found404),并且响应体包含预期的错误信息。

为了独立测试复合产品 API,我们需要模拟其依赖项,即由集成组件ProductCompositeIntegration执行的到其他三个微服务的请求。我们使用Mockito来完成此操作,如下所示:

private static final int **PRODUCT_ID_OK** = 1;
private static final int **PRODUCT_ID_NOT_FOUND** = 2;
private static final int **PRODUCT_ID_INVALID** = 3;
**@MockBean**
private **ProductCompositeIntegration compositeIntegration**;
@BeforeEach
void setUp() {
  when(compositeIntegration.getProduct(PRODUCT_ID_OK)).
    thenReturn(new Product(PRODUCT_ID_OK, "name", 1, "mock-address"));
  when(compositeIntegration.getRecommendations(PRODUCT_ID_OK)).
    thenReturn(singletonList(new Recommendation(PRODUCT_ID_OK, 1, 
    "author", 1, "content", "mock address")));
     when(compositeIntegration.getReviews(PRODUCT_ID_OK)).
    thenReturn(singletonList(new Review(PRODUCT_ID_OK, 1, "author", 
    "subject", "content", "mock address")));
  when(compositeIntegration.getProduct(PRODUCT_ID_NOT_FOUND)).
    thenThrow(new NotFoundException("NOT FOUND: " + 
    PRODUCT_ID_NOT_FOUND));
  when(compositeIntegration.getProduct(PRODUCT_ID_INVALID)).
    thenThrow(new InvalidInputException("INVALID: " + 
    PRODUCT_ID_INVALID));
} 

模拟实现的工作方式如下:

  • 首先,我们声明三个在测试类中使用的常量:PRODUCT_ID_OKPRODUCT_ID_NOT_FOUNDPRODUCT_ID_INVALID

  • 接下来,使用注解@MockBean来配置 Mockito 为ProductCompositeIntegration接口设置模拟。

  • 如果在集成组件上调用getProduct()getRecommendations()getReviews()方法,且productId设置为PRODUCT_ID_OK,模拟将返回一个正常响应。

  • 如果getProduct()方法被调用,且productId设置为PRODUCT_ID_NOT_FOUND,模拟将抛出NotFoundException异常。

  • 如果getProduct()方法被调用,且productId设置为PRODUCT_ID_INVALID,模拟将抛出InvalidInputException异常。

组合产品 API 的自动化集成测试的完整源代码可以在测试类ProductCompositeServiceApplicationTests.java中找到。

在三个核心微服务暴露的 API 上执行的自动化集成测试是相似的,但更简单,因为它们不需要模拟任何东西!测试的源代码可以在每个微服务的test文件夹中找到。

测试在执行构建时由 Gradle 自动运行:

./gradlew build 

然而,您可以指定只想运行测试(而不是构建的其他部分):

./gradlew test 

这是对如何为微服务编写独立自动化测试的介绍。在下一节中,我们将学习如何编写自动测试微服务景观的测试。在本章中,这些测试将仅部分自动化。在未来的章节中,测试将完全自动化,这是一个重大的改进。

添加微服务景观的半自动化测试

能够使用纯 Java、JUnit 和 Gradle 自动为每个微服务单独运行单元和集成测试,在开发期间非常有用,但当我们转向运维方面时,这就不够了。在运维中,我们还需要一种方法来自动验证协作的微服务系统是否能够满足我们的期望。能够随时运行一个脚本来验证多个协作的微服务在运维中是否按预期工作是非常有价值的——微服务越多,这种验证脚本的值就越高。

因此,我编写了一个简单的bash脚本,可以通过对微服务暴露的 RESTful API 进行调用来验证已部署的系统景观的功能。该脚本基于我们上面学习并使用的curl命令。脚本使用jq验证返回码和 JSON 响应的部分,脚本包含两个辅助函数assertCurl()assertEqual(),以使测试代码紧凑且易于阅读。

例如,发送一个普通请求并期望状态码为200,以及断言返回一个包含请求的productId、三个推荐和三个评论的 JSON 响应,看起来如下所示:

# Verify that a normal request works, expect three recommendations and three reviews
assertCurl 200 "curl http://$HOST:${PORT}/product-composite/1 -s"
assertEqual 1 $(echo $RESPONSE | jq .productId)
assertEqual 3 $(echo $RESPONSE | jq ".recommendations | length")
assertEqual 3 $(echo $RESPONSE | jq ".reviews | length") 

验证我们是否收到404 (Not Found)作为 HTTP 响应代码(当我们尝试查找不存在的产品时)如下所示:

# Verify that a 404 (Not Found) error is returned for a non-existing productId (13)
assertCurl 404 "curl http://$HOST:${PORT}/product-composite/13 -s" 

测试脚本test-em-all.bash实现了在手动测试 API部分描述的手动测试,该部分可以在顶级文件夹$BOOK_HOME/Chapter03/2-basic-rest-services中找到。随着我们在后续章节中向系统景观添加更多功能,我们将扩展测试脚本的功能。

第二十章监控微服务中,我们将学习关于自动监控运行中的系统景观的补充技术。在这里,我们将了解一个持续监控已部署微服务状态的监控工具,以及如果收集的指标超过配置的阈值(例如 CPU 或内存过度使用),如何发出警报。

尝试运行测试脚本

要尝试测试脚本,请执行以下步骤:

  1. 首先,启动微服务,就像我们之前做的那样:

    cd $BOOK_HOME/Chapter03/2-basic-rest-services
    java -jar microservices/product-composite-service/build/libs/*.jar & 
    java -jar microservices/product-service/build/libs/*.jar &
    java -jar microservices/recommendation-service/build/libs/*.jar &
    java -jar microservices/review-service/build/libs/*.jar & 
    
  2. 一旦它们全部启动,运行测试脚本:

    ./test-em-all.bash 
    

    预期输出将类似于以下内容:

文本描述自动生成

图 3.10:运行测试脚本后的输出

  1. 通过以下命令关闭微服务来完成这一部分:

    kill $(jobs -p) 
    

在本节中,我们迈出了自动化测试协作微服务系统景观的第一步,所有这些将在后续章节中得到改进。

摘要

我们现在已经使用 Spring Boot 构建了我们的一些微服务。在介绍了我们将在这本书中使用的微服务景观之后,我们学习了如何使用 Spring Initializr 为每个微服务创建骨架项目。

接下来,我们学习了如何使用 Spring WebFlux 为三个核心服务添加 API,并实现了一个复合服务,该服务使用三个核心服务的 API 来创建它们中信息的聚合视图。复合服务使用 Spring 框架中的RestTemplate类对核心服务公开的 API 执行 HTTP 请求。在服务中添加错误处理逻辑后,我们在微服务景观上运行了一些手动测试。

我们通过学习如何为独立运行的微服务和作为系统景观一起工作的微服务添加测试来结束本章。为了为复合服务提供受控的隔离,我们使用 Mockito 模拟了其与核心服务的依赖关系。整个系统景观的测试是通过一个 Bash 脚本执行的,该脚本使用curl对复合服务的 API 进行调用。

在具备这些技能的基础上,我们准备迈出下一步,在下一章中进入 Docker 和容器世界!其中之一,我们将学习如何使用 Docker 完全自动化协作微服务系统景观的测试。

问题

  1. 使用spring init Spring Initializr CLI 工具创建新的 Spring Boot 项目时,列出可用依赖项的命令是什么?

  2. 如何设置 Gradle 以使用一条命令构建多个相关项目?

  3. @PathVariable@RequestParam注解用于什么?

  4. 如何在 API 实现类中将特定协议的错误处理与业务逻辑分离?

  5. Mockito 用于什么?

第四章:使用 Docker 部署我们的微服务

在本章中,我们将开始使用 Docker 并将我们的微服务放入容器中!

到本章结束时,我们将运行完全自动化的微服务测试,这些测试将启动所有微服务作为 Docker 容器,除了 Docker 引擎外不需要任何基础设施。我们还将运行一系列测试以验证微服务按预期协同工作,最后关闭所有微服务,不留任何我们执行的测试痕迹。

能够以这种方式测试多个协作的微服务非常有用。作为开发者,我们可以验证微服务在我们本地的开发者机器上是否正常工作。我们还可以在构建服务器上运行完全相同的测试,以自动验证源代码的更改不会在系统级别破坏测试。此外,我们不需要为运行这些类型的测试分配专门的基础设施。在接下来的章节中,我们将看到如何将数据库和队列管理器添加到我们的测试环境中,所有这些都将作为 Docker 容器运行。

然而,这并不能取代自动单元和集成测试的需求,这些测试在隔离的情况下测试单个微服务。它们和以前一样重要。

对于生产使用,正如我们在本书中之前提到的,我们需要一个容器编排器,如 Kubernetes。我们将在本书的后面部分回到容器编排器和 Kubernetes。

本章将涵盖以下主题:

  • Docker 简介

  • Docker 和 Java – 历史上,Java 对容器的友好度并不高,但这种情况在 Java 10 中发生了变化,因此让我们看看 Docker 和 Java 如何结合在一起

  • 使用 Docker 与单个微服务

  • 使用 Docker Compose 管理微服务景观

  • 自动化协作微服务的测试

技术要求

关于如何安装本书中使用的工具以及如何访问本书源代码的说明,请参阅:

  • 第二十一章macOS 安装说明

  • 第二十二章使用 WSL 2 和 Ubuntu 的 Microsoft Windows 安装说明

本章中的代码示例全部来自 $BOOK_HOME/Chapter04 的源代码。

如果你想查看本章源代码中应用的变化,即查看添加 Docker 支持所需的工作,你可以将其与 第三章创建一组协作微服务 的源代码进行比较。你可以使用你喜欢的 diff 工具比较两个文件夹,$BOOK_HOME/Chapter03/2-basic-rest-services$BOOK_HOME/Chapter04

Docker 简介

正如我们在 第二章Spring Boot 简介 中已经提到的,Docker 在 2013 年使容器作为轻量级虚拟机的替代方案的概念变得非常流行。为了快速回顾:容器实际上是在使用 Linux 命名空间提供容器之间隔离的 Linux 主机上处理的,并且使用 Linux 控制组cgroups)来限制容器可以消耗的 CPU 和内存量。

与使用虚拟机管理程序在每个虚拟机中运行操作系统的完整副本的虚拟机相比,容器中的开销只是虚拟机开销的一小部分。这导致启动时间更快,占用空间显著降低。然而,容器并不被认为像虚拟机那样安全。看看下面的图示:

图 4.1:虚拟机与容器对比

该图说明了虚拟机和容器之间的资源使用差异,表明同一类型的服务器可以运行比虚拟机多得多的容器。主要的好处是容器不需要像虚拟机那样运行自己的操作系统实例。

运行我们的第一个 Docker 命令

让我们尝试通过使用 Docker 的 run 命令启动 Ubuntu 服务器来启动一个容器:

docker run -it --rm ubuntu 

使用前面的命令,我们要求 Docker 创建一个运行 Ubuntu 的容器,基于官方 Docker 镜像中可用的最新版本。使用 -it 选项是为了我们可以使用终端与容器进行交互,而 --rm 选项告诉 Docker 在我们退出终端会话后删除容器;否则,容器将保持 Docker 引擎中的 Exited 状态。

第一次使用我们未自行构建的 Docker 镜像时,Docker 将从默认的 Docker 仓库(Docker Hub)下载它(hub.docker.com)。这需要一些时间,但后续使用该 Docker 镜像时,容器只需几秒钟就能启动!

一旦下载了 Docker 镜像并启动了容器,Ubuntu 服务器应该会响应如下提示:

图形用户界面,文本,应用程序  自动生成的描述

图 4.2:Ubuntu 服务器响应

我们可以通过例如询问它运行的是哪个版本的 Ubuntu 来尝试容器:

cat /etc/os-release | grep 'VERSION=' 

应该会响应如下:

图形用户界面,文本,应用程序  自动生成的描述

图 4.3:Ubuntu 版本响应

我们可以使用 exit 命令退出容器,并使用 docker ps -a 命令验证 Ubuntu 容器不再退出。我们需要使用 -a 选项来查看已停止的容器;否则,只会显示正在运行的容器。

如果你更喜欢 CentOS 而不是 Ubuntu,你可以尝试使用 docker run --rm -it centos 命令。

一旦 CentOS 服务器在其容器中开始运行,例如,你可以使用 cat /etc/redhat-release 命令来询问正在运行哪个版本的 CentOS。它应该会响应如下:

文本描述自动生成

图 4.4:CentOS 版本响应

使用 exit 命令离开容器以删除它。

如果在某个时候,你发现 Docker Engine 中有很多不想要的容器,并且你想从零开始,也就是说,删除它们所有,你可以运行以下命令:

docker rm -f $(docker ps -aq) 

docker rm -f 命令停止并删除由命令指定的容器 ID 的容器。docker ps -aq 命令列出 Docker Engine 中所有运行和停止的容器的容器 ID。-q 选项减少了 docker ps 命令的输出,使其只列出容器 ID。

现在我们已经了解了 Docker 是什么,我们可以继续学习如何在 Docker 中运行 Java。

在 Docker 中运行 Java

在过去的几年里,已经尝试了多种方法来在 Docker 中以良好的方式运行 Java。最重要的是,Java 在历史上并不擅长尊重 Docker 容器中为内存和 CPU 设置的限制。

目前,官方的 Java Docker 镜像来自 OpenJDK 项目:hub.docker.com/_/openjdk/。我们将使用来自 Eclipse Temurin 项目的替代 Docker 镜像。它包含来自 OpenJDK 项目的相同二进制文件,但提供了比 OpenJDK 项目的 Docker 镜像更好地满足我们需求的变体。

在本节中,我们将使用包含完整 JDKJava 开发工具包)及其所有工具的 Docker 镜像。当我们开始在“使用 Docker 与单个微服务”部分中打包我们的微服务到 Docker 镜像时,我们将使用一个更紧凑的基于 JREJava 运行时环境)的 Docker 镜像,它只包含运行时所需的 Java 工具。

如前所述,Java 的早期版本并不擅长遵守使用 Linux cgroups 为 Docker 容器指定的配额;它们只是简单地忽略了这些设置。

因此,Java 不是根据容器中可用的内存来在 JVM 内部分配内存,而是像它有权访问 Docker 主机上的所有内存一样分配内存。当尝试分配比允许的更多的内存时,Java 容器会被主机以“内存不足”的错误消息杀死。同样,Java 根据 Docker 主机上的总可用 CPU 核心数来分配与 CPU 相关的资源,如线程池,而不是根据为容器 JVM 运行提供的 CPU 核心数。

在 Java SE 9 中,提供了基于容器的 CPU 和内存限制的初始支持,在 Java SE 10 中得到了很大改进。

让我们看看 Java SE 17 对其运行的容器中设置的限制如何响应!

在接下来的测试中,我们将在 MacBook Pro 上的虚拟机内部运行 Docker 引擎,充当 Docker 主机。Docker 主机被配置为使用8 个 CPU核心和16 GB 的内存

我们将首先看看如何将可用的 CPU 数量限制在运行 Java 的容器中。之后,我们将对内存进行同样的限制。

限制可用 CPU

让我们先找出 Java 在没有应用任何限制的情况下看到多少可用处理器(即 CPU 核心)。我们可以通过将 Java 语句Runtime.getRuntime().availableprocessors()发送到 Java CLI 工具jshell来实现。我们将使用包含完整 Java 17 JDK 的 Docker 镜像在容器中运行jshell。此镜像的 Docker 标签是eclipse-temurin:17。命令如下所示:

echo 'Runtime.getRuntime().availableProcessors()' | docker run --rm -i eclipse-temurin:17 jshell -q 

此命令将字符串Runtime.getRuntime().availableProcessors()发送到 Docker 容器,该容器将使用jshell处理该字符串。我们将得到以下响应:

文本描述自动生成

图 4.5:显示可用 CPU 核心数量的响应

8核心的响应符合预期,因为 Docker 主机被配置为使用8个 CPU 核心。让我们继续,并使用--cpus 3 Docker 选项将 Docker 容器限制为只能使用三个 CPU 核心,然后询问 JVM 它看到了多少可用处理器:

echo 'Runtime.getRuntime().availableProcessors()' | docker run --rm -i --cpus=3 eclipse-temurin:17 jshell -q 

JVM 现在响应为Runtime.getRuntime().availableProcessors()$1 ==> 3;这意味着 Java SE 17 尊重容器中的设置,因此能够正确配置如线程池等 CPU 相关资源!

限制可用内存

在可用内存的数量方面,让我们询问 JVM 它认为可以为堆分配的最大大小。我们可以通过使用-XX:+PrintFlagsFinal Java 选项请求 JVM 额外的运行时信息,然后使用grep命令过滤出MaxHeapSize参数,如下所示:

docker run -it --rm eclipse-temurin:17 java -XX:+PrintFlagsFinal | grep "size_t MaxHeapSize" 

在为 Docker 主机分配了 16 GB 的内存后,我们将得到以下响应:

图形用户界面,文本描述自动生成

图 4.6:显示 MaxHeapSize 的响应

在没有 JVM 内存限制(即不使用 JVM 参数-Xmx)的情况下,Java 将为它的堆分配容器可用内存的四分之一。因此,我们预计它将为堆分配多达 4 GB。从前面的屏幕截图可以看出,响应为 4,188,012,544 字节。这等于4,188,012,544 / 1024 / 1024 = 3,994 MB,接近预期的 4 GB。

如果我们使用 Docker 选项-m=1024M将 Docker 容器限制为只能使用最多 1 GB 的内存,我们预计会看到更低的内存最大分配。运行以下命令:

docker run -it --rm -m=1024M eclipse-temurin:17 java -XX:+PrintFlagsFinal | grep "size_t MaxHeapSize" 

将导致响应 268,435,456 字节,这等于 268,435,456 / 1024 / 1024= 256 MB。256 MB 是 1 GB 的四分之一,所以,这正如预期的那样。

我们可以像往常一样,自己设置 JVM 的最大堆大小。例如,如果我们想允许 JVM 使用我们为其堆预留的 1 GB 中的 600 MB,我们可以使用 JVM 选项-Xmx600m来指定,如下所示:

docker run -it --rm -m=1024M eclipse-temurin:17 java -Xmx600m -XX:+PrintFlagsFinal -version | grep "size_t MaxHeapSize" 

JVM 将响应 629,145,600 字节 = 629,145,600 / 1024 / 1024= 600 MB,再次符合预期。

让我们用一个“内存不足”的测试来结束,以确保这真的有效!

我们将在一个已经分配了 1 GB 内存的容器中运行的 JVM 中使用jshell分配一些内存;也就是说,它有一个 256 MB 的最大堆大小。

首先,尝试分配一个 100 MB 的字节数组:

echo 'new byte[100_000_000]' | docker run -i --rm -m=1024M eclipse-temurin:17 jshell -q 

命令将响应 $1 ==>,这意味着它工作得很好!

通常,jshell会打印出命令的结果值,但 100 MB 的字节全部设置为 0 有点太多,所以什么也没有打印出来。

现在,让我们尝试分配一个大于最大堆大小的字节数组,例如,500 MB:

echo 'new byte[500_000_000]' | docker run -i --rm -m=1024M eclipse-temurin:17 jshell -q 

JVM 看到它无法执行该操作,因为它尊重容器的最大内存设置,并立即响应Exception java.lang.OutOfMemoryError: Java heap space。太好了!

因此,总结一下,我们现在已经看到了 Java 如何尊重其容器中可用的 CPU 和内存设置。让我们继续前进,为我们的一个微服务构建第一个 Docker 镜像!

使用 Docker 与一个微服务

现在我们已经了解了 Java 在容器中的工作方式,我们可以开始使用 Docker 与我们的一个微服务一起使用。在我们能够将我们的微服务作为 Docker 容器运行之前,我们需要将其打包到一个 Docker 镜像中。要构建一个 Docker 镜像,我们需要一个 Dockerfile,所以我们将从那里开始。接下来,我们需要为我们的微服务配置一个 Docker 特定的配置。由于在容器中运行的微服务与其他微服务是隔离的——它有自己的 IP 地址、主机名和端口——它需要与在相同主机上与其他微服务一起运行时不同的配置。

例如,由于其他微服务不再运行在相同的主机上,所以不会发生端口冲突。在 Docker 中运行时,我们可以为所有微服务使用默认端口8080,而没有任何端口冲突的风险。另一方面,如果我们需要与其他微服务通信,我们就不能再像在相同主机上运行它们时那样使用localhost了。

微服务中的源代码不会因为将微服务在容器中运行而受到影响,只有它们的配置!

为了处理在本地运行时(没有 Docker)和作为 Docker 容器运行微服务时所需的不同的配置,我们将使用 Spring 配置文件。自 第三章创建一组协作微服务 以来,我们一直在使用默认的 Spring 配置文件在本地运行时(没有 Docker)。现在,我们将创建一个新的名为 docker 的 Spring 配置文件,用于我们在 Docker 中以容器形式运行微服务时使用。

源代码中的更改

我们将从 product 微服务开始,该微服务可以在源代码的 $BOOK_HOME/Chapter04/microservices/product-service/ 中找到。在下一节中,我们将将其应用于其他微服务。

首先,我们在属性文件 application.yml 的末尾添加 Spring 配置文件:

---
spring.config.activate.on-profile: docker
server.port: 8080 

Spring 配置文件可以用来指定特定环境的配置,在这种情况下,这是一个仅在运行微服务时在 Docker 容器中使用的配置。其他例子包括特定于 devtestproduction 环境的配置。配置文件中的值会覆盖默认配置文件中的值。通过使用 YAML 文件,可以在同一个文件中放置多个 Spring 配置文件,它们之间用 --- 分隔。

我们现在唯一更改的参数是正在使用的端口;当在容器中运行微服务时,我们将使用默认端口 8080

接下来,我们将创建用于构建 Docker 镜像的 Dockerfile。如 第二章Spring Boot 简介 中所述,Dockerfile 可以非常简单:

FROM **openjdk:****17**
EXPOSE **8080**
ADD **./build/libs/*.jar app.jar**
ENTRYPOINT **[****"java"****,****"-jar"****,****"/app.jar"****]** 

需要注意的一些事项包括:

  • Docker 镜像将基于官方的 OpenJDK Docker 镜像,并使用版本 17。

  • 端口 8080 将暴露给其他 Docker 容器。

  • 重量级 JAR 文件将被添加到 Docker 镜像中,来自 Gradle 构建库的 build/libs

  • Docker 用于启动基于此 Docker 镜像的容器的命令是 java -jar /app.jar

这种简单的方法有几个缺点:

  • 我们正在使用 Java SE 17 的完整 JDK,包括编译器和其他开发工具。这使得 Docker 镜像变得不必要地大,并且从安全角度来看,我们不想将不必要的工具带入镜像中。

    因此,我们更愿意使用一个仅包含运行 Java 程序所需的程序和库的基础镜像来构建 Java SE 17 JRE。不幸的是,OpenJDK 项目没有为 Java SE 17 JRE 提供一个 Docker 镜像。

  • 重量级 JAR 文件在 Docker 容器启动时需要时间来解包。更好的方法是,在构建 Docker 镜像时解包重量级 JAR 文件。

  • fat JAR 文件非常大,如下所示,大约有 20 MB。如果我们想在开发过程中对 Docker 镜像中的应用程序代码进行可重复更改,这将导致 Docker build 命令的使用次优。由于 Docker 镜像是按层构建的,我们将得到一个非常大的层,每次都需要替换,即使在应用程序代码中只更改了一个 Java 类的情况下也是如此。

  • 一个更好的方法是按不同的层划分内容,其中不经常更改的文件添加到第一层,而更改最频繁的文件放置在最后一层。这将导致 Docker 层缓存机制的良好使用。对于在更改某些应用程序代码时不会更改的第一稳定层,Docker 将直接使用缓存而不是重建它们。这将导致微服务 Docker 镜像构建更快。

关于 OpenJDK 项目缺少 Java SE 17 JRE Docker 镜像的问题,还有其他开源项目将 OpenJDK 二进制文件打包到 Docker 镜像中。其中最广泛使用的一个项目是 Eclipse Temurin (adoptium.net/temurin/)。Temurin 项目提供了他们 Docker 镜像的全 JDK 版本和最小化 JRE 版本。

当涉及到在 Docker 镜像中处理 fat JAR 文件的次优打包时,Spring Boot 在 v2.3.0 版本中解决了这个问题,使得将 fat JAR 文件的内容提取到多个文件夹中成为可能。默认情况下,Spring Boot 在提取 fat JAR 文件后会创建以下文件夹:

  • dependencies,包含所有依赖项作为 JAR 文件

  • spring-boot-loader,包含知道如何启动 Spring Boot 应用程序的 Spring Boot 类

  • snapshot-dependencies,包含任何快照依赖项

  • application,包含应用程序类文件和资源

Spring Boot 文档建议为上述列出的每个文件夹创建一个 Docker 层。在用基于 JRE 的镜像替换基于 JDK 的 Docker 镜像并添加将 fat JAR 文件展开到 Docker 镜像中适当层的指令后,Dockerfile 看起来是这样的:

**FROM** **eclipse-temurin:17.0.5_8-jre-focal as builder**
WORKDIR extracted
ADD ./build/libs/*.jar app.jar
RUN java -Djarmode=layertools -jar app.jar extract
**FROM** **eclipse-temurin:17.0.5_8-jre-focal**
WORKDIR application
COPY --from=builder extracted/dependencies/ ./
COPY --from=builder extracted/spring-boot-loader/ ./
COPY --from=builder extracted/snapshot-dependencies/ ./
COPY --from=builder extracted/application/ ./
EXPOSE 8080
ENTRYPOINT ["java", "org.springframework.boot.loader.JarLauncher"] 

为了在 Dockerfile 中处理 fat JAR 文件的提取,我们使用 多阶段构建,这意味着有一个名为 builder 的第一步,用于处理提取。第二个阶段构建实际用于运行时的 Docker 镜像,从第一阶段按需选择文件。使用这种技术,我们可以在 Dockerfile 中处理所有打包逻辑,同时将最终 Docker 镜像的大小保持在最小:

  1. 第一个阶段从以下行开始:

    FROM eclipse-temurin:17.0.5_8-jre-focal as builder 
    

    从这一行开始,我们可以看到使用了来自 Temurin 项目的 Docker 镜像,并且它包含 Java SE JRE for v17.0.5_8。我们还可以看到该阶段被命名为 builder

  2. builder 阶段将工作目录设置为 extracted 并将来自 Gradle 构建库 build/libs 的胖 JAR 文件添加到该文件夹中。

  3. builder 阶段接着运行命令 java -Djarmode=layertools -jar app.jar extract,这将执行将胖 JAR 文件提取到其工作目录,即 extracted 文件夹中的操作。

  4. 下一个和最后一个阶段从以下行开始:

    FROM eclipse-temurin:17.0.5_8-jre-focal 
    

    它使用与第一阶段相同的基 Docker 镜像,并将 application 文件夹作为其工作目录。它将 builder 阶段的展开文件按文件夹逐个复制到 application 文件夹中。这样,每个文件夹创建一个层,如上所述。参数 --from=builder 用于指示 Docker 从 builder 阶段的文件系统中选择文件。

  5. 在公开适当的端口后,在这个例子中是 8080,Dockerfile 通过告诉 Docker 运行哪个 Java 类来启动微服务,即 org.springframework.boot.loader.JarLauncher,来完成封装。

在了解源代码中所需更改后,我们准备好构建我们的第一个 Docker 镜像。

构建 Docker 镜像

要构建 Docker 镜像,我们首先需要为 product-service 构建我们的部署工件(即胖 JAR 文件):

cd $BOOK_HOME/Chapter04
./gradlew :microservices:product-service:build 

由于我们只想构建 product-service 以及它所依赖的项目(即 apiutil 项目),我们不使用正常的 build 命令,该命令构建所有微服务。相反,我们使用一个变体,告诉 Gradle 只构建 product-service 项目::microservices:product-service:build

我们可以在 Gradle 构建库中找到胖 JAR 文件,位于 build/libsls -l microservices/product-service/build/libs 命令将报告如下:

图形用户界面、文本描述自动生成

图 4.7:查看胖 JAR 文件详情

如您所见,JAR 文件的大小接近 20 MB – 没有 wonder 他们被称为胖 JAR 文件!

如果你对其实际内容感到好奇,你可以使用 unzip -l microservices/product-service/build/libs/product-service-1.0.0-SNAPSHOT.jar 命令来查看。

接下来,我们将构建 Docker 镜像并将其命名为 product-service,如下所示:

cd microservices/product-service
docker build -t product-service . 

Docker 将使用当前目录中的 Dockerfile 来构建 Docker 引擎。镜像将被标记为 product-service 并存储在 Docker 引擎的本地。

使用以下命令验证我们得到了预期的 Docker 镜像:

docker images | grep product-service 

预期输出如下:

图形用户界面、文本、应用程序描述自动生成

图 4.8:验证我们构建了 Docker 镜像

因此,现在我们已经构建了镜像,让我们看看我们如何启动服务。

启动服务

让我们使用以下命令启动 product 微服务作为容器:

docker run --rm -p8080:8080 -e "SPRING_PROFILES_ACTIVE=docker" product-service 

这是我们可以从命令中推断出的内容:

  1. docker rundocker run 命令将启动容器并在终端显示日志输出。只要容器运行,终端就会锁定。

  2. 我们已经看到了 --rm 选项;它将告诉 Docker 在我们从终端使用 Ctrl + C 停止执行后清理容器。

  3. -p8080:8080 选项将容器中的端口 8080 映射到 Docker 主机的端口 8080,这使得从外部调用它成为可能。在 Docker Desktop for Mac 的情况下,它在一个本地的 Linux 虚拟机中运行 Docker,端口也将被转发到 macOS,并在 localhost 上提供。记住,我们只能有一个容器映射到 Docker 主机上的特定端口!

  4. 使用 -e 选项,我们可以为容器指定环境变量,在这个例子中,是 SPRING_PROFILES_ACTIVE=dockerSPRING_PROFILES_ACTIVE 环境变量用于告诉 Spring 使用哪个配置文件。在我们的例子中,我们希望 Spring 使用 docker 配置文件。

  5. 最后,我们有 product-service,这是我们上面构建的 Docker 镜像的名称,Docker 将使用它来启动容器。

预期的输出如下:

文本  自动生成的描述

图 4.9:启动产品微服务后的输出

从前面的屏幕截图我们可以看到:

  • Spring 使用的配置文件是 docker。在输出中查找 The following profiles are active: docker 以验证这一点。

  • 容器分配的端口是 8080。在输出中查找 Netty started on port8080 以验证这一点。

  • 一旦写入日志消息 Started ProductServiceApplication,微服务就准备好接受请求了!

我们可以使用 localhost 上的 8080 端口与微服务进行通信,如前所述。在另一个终端窗口中尝试以下命令:

curl localhost:8080/product/3 

以下是我们预期的输出:

图形用户界面,文本  自动生成的描述

图 4.10:请求产品 3 的信息

这与我们在上一章中收到的输出类似,但有一个主要区别:我们现在有了 "service Address":"9dc086e4a88b/172.17.0.2:8080" 的内容,端口是 8080,正如预期的那样,IP 地址 172.17.0.2 是 Docker 内部网络分配给容器的 IP 地址——但主机名 9dc086e4a88b 是从哪里来的?

向 Docker 请求所有正在运行的容器:

docker ps 

我们将看到以下内容:

图形用户界面,文本  自动生成的描述

图 4.11:所有正在运行的容器

如前所述,我们可以看到主机名等同于容器的 ID,如果你想知道哪个容器实际响应了你的请求,这是很好的信息!

使用终端中的 Ctrl + C 命令停止容器,完成这个步骤后,我们现在可以继续在终端中分离运行容器。

以分离模式运行容器

好的,那很棒,但如果我们不想从启动容器的终端锁定它怎么办?在大多数情况下,对于每个正在运行的容器,有一个锁定的终端会感到不方便。是时候学习如何以 分离 的方式启动容器——在不锁定终端的情况下运行容器了!

我们可以通过添加 -d 选项,同时使用 --name 选项给它起一个名字来实现这一点。给容器起名字是可选的,如果我们不指定,Docker 会自动生成一个名字,但使用我们指定的名字可以更方便地向分离的容器发送命令。由于我们将在完成容器操作后显式地停止和删除容器,所以不再需要 --rm 选项:

docker run -d -p8080:8080 -e "SPRING_PROFILES_ACTIVE=docker" --name my-prd-srv product-service 

如果我们再次运行 docker ps 命令,我们将看到我们的新容器,名为 my-prd-srv

图形用户界面,自动生成文本描述

图 4.12:以分离模式启动容器

但我们如何从我们的容器中获取日志输出?

认识 docker logs 命令:

docker logs my-prd-srv -f 

-f 选项指示命令跟随日志输出,也就是说,当所有当前日志输出都写入终端时,命令不会结束,而是等待更多输出。如果你期望看到很多你不想看到的旧日志消息,你也可以添加 --tail 0 选项,这样你只能看到新的日志消息。或者,你可以使用 --since 选项并指定一个绝对时间戳或相对时间,例如,--since 5m,以查看最多五分钟前的日志消息。

用一个新的 curl 请求来尝试一下。你应该会看到一个新的日志消息已经写入终端的日志输出中。

通过停止和删除容器来完成这个步骤:

docker rm -f my-prd-srv 

-f 选项强制 Docker 删除容器,即使它正在运行。在删除容器之前,Docker 会自动停止容器。

现在我们知道了如何使用 Docker 与微服务一起使用,我们可以看看如何借助 Docker Compose 来管理微服务景观。

使用 Docker Compose 管理微服务景观

我们已经看到如何将单个微服务作为 Docker 容器运行,但关于如何管理整个微服务系统景观呢?

正如我们之前提到的,这就是 docker-compose 的目的。通过使用单个命令,我们可以构建、启动、记录和停止作为 Docker 容器运行的多个协作微服务。

源代码的更改

要使用 Docker Compose,我们需要创建一个配置文件,docker-compose.yml,该文件描述了 Docker Compose 将为我们管理的微服务。我们还需要为剩余的微服务设置 Dockerfile,并为每个微服务添加一个特定的 Docker Spring 配置文件。所有四个微服务都有自己的 Dockerfile,但它们看起来都与前面的相同。

当涉及到 Spring 配置文件时,三个核心服务product-recommendation-review-service具有相同的docker配置文件,它只指定在作为容器运行时应该使用默认端口8080

对于product-composite-service,事情要复杂一些,因为它需要知道核心服务在哪里。当我们在本地上运行所有服务时,它被配置为使用 localhost 和每个核心服务的单独端口号,7001-7003。当在 Docker 中运行时,每个服务将有自己的主机名,但可以在相同的端口号8080上访问。在这里,product-composite-servicedocker配置文件如下所示:

---
**spring.config.activate.on-profile:****docker**
server.port: 8080
app:
  product-service:
    host: product
    port: 8080
  recommendation-service:
    host: recommendation
    port: 8080
  review-service:
    host: review
    port: 8080 

此配置存储在属性文件application.yml中。

productrecommendationreview主机名是从哪里来的?

这些配置在docker-compose.yml文件中指定,该文件位于$BOOK_HOME/Chapter04文件夹中。它看起来是这样的:

version: '2.1'
services:
  product:
    build: microservices/product-service
    mem_limit: 512m
    environment:
      - SPRING_PROFILES_ACTIVE=docker
  recommendation:
    build: microservices/recommendation-service
    mem_limit: 512m
    environment:
      - SPRING_PROFILES_ACTIVE=docker
  review:
    build: microservices/review-service
    mem_limit: 512m
    environment:
      - SPRING_PROFILES_ACTIVE=docker
  product-composite:
    build: microservices/product-composite-service
    mem_limit: 512m
    ports:
      - "8080:8080"
    environment:
      - SPRING_PROFILES_ACTIVE=docker 

对于每个微服务,我们指定以下内容:

  • 微服务的名称。这也将是内部 Docker 网络中容器的主机名。

  • 一个build指令,指定用于构建 Docker 镜像的 Dockerfile 的位置。

  • 512 MB 的内存限制。对于本书的范围,512 MB 应该足够所有我们的微服务使用。对于本章,它可以设置为更低的值,但随着我们在接下来的章节中添加更多功能到微服务中,它们的内存需求将会增加。

  • 将为容器设置的环境变量。在我们的情况下,我们使用这些来指定要使用哪个 Spring 配置文件。

对于product-composite服务,我们还将指定端口映射 - 我们将公开其端口,以便可以从 Docker 外部访问。其他微服务将无法从外部访问。接下来,我们将看到如何启动微服务景观。

在第10 章使用 Spring Cloud Gateway 隐藏微服务背后的边缘服务器,以及第11 章保护 API 访问,我们将学习更多关于如何锁定和确保外部访问微服务系统的方法。

启动微服务景观

在所有必要的代码更改到位后,我们可以构建我们的 Docker 镜像,启动微服务景观,并运行一些测试以验证其按预期工作。为此,我们需要执行以下操作:

  1. 首先,我们使用 Gradle 构建我们的部署工件,然后使用 Docker Compose 构建 Docker 镜像:

    cd $BOOK_HOME/Chapter04
    ./gradlew build
    docker-compose build 
    
  2. 然后,我们需要验证我们是否可以看到我们的 Docker 镜像,如下所示:

    docker images | grep chapter04 
    

    我们应该看到以下输出:

文本描述由低置信度自动生成图 4.13:验证我们的 Docker 镜像

  1. 使用以下命令启动微服务环境:

    docker-compose up -d 
    

-d选项将使 Docker Compose 以分离模式运行容器,与 Docker 相同。

我们可以通过以下命令监控每个容器日志的输出,以跟踪启动过程:

docker-compose logs -f 

docker-compose logs命令支持与前面描述的docker logs相同的-f--tail选项。

docker-compose logs命令也支持将日志输出限制为容器组。只需在logs命令后添加您想查看日志输出的容器的名称。例如,要仅查看productreview服务的日志输出,请使用docker-compose logs -f product review

当所有四个微服务都报告它们已启动时,我们就准备好尝试微服务环境了。寻找以下内容:

文本描述由中等置信度自动生成

图 4.14:启动所有四个微服务

注意,每个日志消息都带有产生输出的容器的名称作为前缀!

现在,我们已经准备好运行一些测试来验证这是否按预期工作。与我们在上一章中直接在本地主机上运行相比,当我们调用 Docker 中的组合服务时,需要更改的唯一端口是端口号。我们现在使用端口8080

curl localhost:8080/product-composite/123 -s | jq . 

我们将得到相同类型的响应:

文本描述由中等置信度自动生成

图 4.15:调用组合服务

然而,有一个很大的不同之处——响应中serviceAddresses报告的主机名和端口:

计算机屏幕截图,描述由中等置信度自动生成

图 4.16:查看服务地址

在这里,我们可以看到分配给每个 Docker 容器的主机名和 IP 地址。

我们完成了;现在只剩下一步了:

docker-compose down 

前面的命令将关闭微服务环境。到目前为止,我们已经看到如何通过手动执行 Bash 命令来测试协作微服务。在下一节中,我们将看到如何增强我们的测试脚本来自动化这些手动步骤。

自动化协作微服务的测试

当手动管理一组微服务时,Docker Compose 非常有帮助。在本节中,我们将更进一步,将 Docker Compose 集成到我们的测试脚本test-em-all.bash中。测试脚本将自动启动微服务环境,运行所有必要的测试以验证微服务环境按预期工作,最后将其关闭,不留任何痕迹。

测试脚本位于$BOOK_HOME/Chapter04/test-em-all.bash

在测试脚本运行测试套件之前,它将检查测试脚本调用中是否存在 start 参数。如果找到,它将使用以下代码重新启动容器:

if [[ $@ == *"start"* ]]
then
    echo "Restarting the test environment..."
    echo "$ docker-compose down --remove-orphans"
    docker-compose down --remove-orphans
    echo "$ docker-compose up -d"
    docker-compose up -d
fi 

之后,测试脚本将等待 product-composite 服务响应 OK

waitForService http://$HOST:${PORT}/product-composite/1 

waitForService Bash 函数的实现如下:

function testUrl() {
    url=$@
    if curl $url -ks -f -o /dev/null
    then
          return 0
    else
          return 1
    fi;
}
**function****waitForService****() {**
    url=$@
    echo -n "Wait for: $url... "
    n=0
    until testUrl $url
    do
        n=$((n + 1))
        if [[ $n == 100 ]]
        then
            echo " Give up"
            exit 1
        else
            sleep 3
            echo -n ", retry #$n "
        fi
    done
    echo "DONE, continues..."
} 

waitForService 函数使用 curl 向提供的 URL 发送 HTTP 请求。请求会重复发送,直到 curl 响应表示它从请求中收到了成功的响应。函数在每次尝试之间等待 3 秒,并在 100 次尝试后放弃,停止脚本并失败。

接下来,所有测试都像之前一样执行。之后,如果脚本在调用参数中找到 stop 参数,它将拆除场景:

if [[ $@ == *"stop"* ]]
then
    echo "We are done, stopping the test environment..."
    echo "$ docker-compose down"
    docker-compose down
fi 

注意,如果某些测试失败,测试脚本不会拆除场景;它将简单地停止,留下场景以供错误分析!

测试脚本还更改了默认端口,从我们在不使用 Docker 运行微服务时使用的 7000,更改为 Docker 容器使用的 8080

让我们试试看!为了启动场景,运行测试,然后运行以下命令来拆除:

./test-em-all.bash start stop 

以下是一些关注启动和关闭阶段的测试运行示例输出。实际测试的输出已被移除(它们与上一章相同):

文本描述自动生成

图 4.17:测试运行示例输出

运行这些测试后,我们可以继续了解如何排除失败的测试。

故障排除测试运行

如果运行 ./test-em-all.bash start stop 的测试失败,以下步骤可以帮助您识别问题,并在问题解决后重新启动测试:

  1. 首先,使用以下命令检查运行中的微服务状态:

    docker-compose ps 
    

    如果所有微服务都正常运行且状态良好,您将收到以下输出:

图形用户界面,文本描述自动生成

图 4.18:检查运行中的微服务状态

  1. 如果任何微服务没有 Up 的状态,请使用 docker-compose logs 命令检查它们的日志输出中的任何错误。例如,如果您想检查 product 服务的日志输出,您将使用以下命令:

    docker-compose logs product 
    

    在这个阶段,由于微服务非常简单,因此不容易记录错误。相反,这里提供了一个来自 第六章添加持久性product 微服务的示例错误日志。假设以下内容出现在其日志输出中:

    图形用户界面,文本描述自动生成图 4.19:日志输出中的示例错误信息

    从上面的日志输出中可以看出,product微服务无法连接到其 MongoDB 数据库。鉴于数据库也作为由同一 Docker Compose 文件管理的 Docker 容器运行,可以使用docker-compose logs命令查看数据库的问题。

    如果需要,可以使用docker-compose restart命令重新启动失败的容器。例如,如果你想重新启动product微服务,可以使用以下命令:

    docker-compose restart product 
    

    如果容器丢失,例如由于崩溃,可以使用docker-compose up -d --scale命令启动它。例如,对于product微服务,你会使用以下命令:

    docker-compose up -d --scale product=1 
    

    如果日志输出中的错误表明 Docker 正在耗尽磁盘空间,可以使用以下命令回收部分空间:

    docker system prune -f --volumes 
    
  2. 一旦所有微服务都已启动、运行且状态良好,再次运行测试脚本,但不要启动微服务:

    ./test-em-all.bash 
    

    现在测试应该可以顺利运行了!

  3. 测试完成后,记得拆除系统环境:

    docker-compose down 
    

最后,关于一个组合命令的提示,该命令从源代码构建运行时工件和 Docker 镜像,然后执行 Docker 中的所有测试:

./gradlew clean build && docker-compose build && ./test-em-all.bash start stop 

这非常适合在将新代码推送到 Git 仓库或作为构建服务器构建管道的一部分之前检查一切是否正常工作!

摘要

在本章中,我们看到了如何使用 Docker 简化一组协作微服务的测试。

我们了解到,自 Java SE v10 以来,Java SE 会尊重我们对容器施加的限制,即它们可以使用多少 CPU 和内存。我们还看到了将基于 Java 的微服务作为 Docker 容器运行所需的最小步骤。多亏了 Spring 配置文件,我们可以在 Docker 中运行微服务而无需对代码进行任何修改。

最后,我们看到了 Docker Compose 如何通过单条命令帮助我们管理一组协作的微服务,无论是手动操作,还是与测试脚本(如test-em-all.bash)集成时的自动操作。

在下一章中,我们将研究如何使用 OpenAPI/Swagger 描述添加 API 的一些文档。

问题

  1. 虚拟机和 Docker 容器之间有哪些主要区别?

  2. Docker 中的命名空间和 cgroups 的目的是什么?

  3. 如果一个 Java 应用程序不遵守容器中的最大内存设置并分配了超出允许范围的内存,会发生什么?

  4. 我们如何使基于 Spring 的应用程序作为 Docker 容器运行,而无需修改其源代码?

  5. 为什么以下 Docker Compose 代码片段无法工作?

     review:
        build: microservices/review-service
        ports:
          - "8080:8080"
        environment:
          - SPRING_PROFILES_ACTIVE=docker
      product-composite:
        build: microservices/product-composite-service
        ports:
          - "8080:8080"
        environment:
          - SPRING_PROFILES_ACTIVE=docker 
    

第五章:使用 OpenAPI 添加 API 描述

一个 API(如 RESTful 服务)的价值在很大程度上取决于其消费的难易程度。良好的、易于访问的文档是 API 是否有用的一个重要部分。在本章中,我们将学习如何使用 OpenAPI 规范来记录我们可以从微服务景观外部访问的 API。

正如我们在 第二章Spring Boot 简介 中提到的,OpenAPI 规范(以前称为 Swagger 规范)是记录 RESTful 服务时最常用的规范之一。许多领先的 API 网关都原生支持 OpenAPI 规范。我们将学习如何使用开源项目 springdoc-openapi 来生成此类文档。我们还将学习如何嵌入 API 文档查看器,Swagger UI 查看器,它可以用来检查 API 文档并发出 API 请求。

到本章结束时,我们将拥有由 product-composite-service 微服务暴露的基于 OpenAPI 的 API 文档。该微服务还将暴露一个 Swagger UI 查看器,我们可以用它来可视化并测试 API。

本章将涵盖以下主题:

  • springdoc-openapi 的使用介绍

  • 将 springdoc-openapi 添加到源代码中

  • 构建和启动微服务景观

  • 尝试使用 OpenAPI 文档

技术要求

关于如何安装本书中使用的工具以及如何访问本书源代码的说明,请参阅:

  • 第二十一章 为 mac OS 安装说明

  • 第二十二章 使用 WSL 2 和 Ubuntu 为 Microsoft Windows 安装说明

本章中的代码示例全部来自 $BOOK_HOME/Chapter05 中的源代码。

如果您想查看对本章源代码所做的更改,即查看使用 springdoc-openapi 创建基于 OpenAPI 的 API 文档所需要的内容,您可以将其与 第四章使用 Docker 部署我们的微服务 的源代码进行比较。您可以使用您喜欢的 diff 工具比较两个文件夹,即 $BOOK_HOME/Chapter04$BOOK_HOME/Chapter05

springdoc-openapi 的使用介绍

使用 springdoc-openapi 使得将 API 的文档与实现 API 的源代码保持在一起成为可能。通过 springdoc-openapi,您可以在运行时通过检查代码中的 Java 注解来动态创建 API 文档。对我来说,这是一个重要的功能。如果 API 文档与 Java 源代码分开维护,它们最终会相互分离。根据我的经验,这种情况往往比预期的要早发生。

在 springdoc-openapi 创建之前,另一个开源项目SpringFox(springfox.github.io/springfox/)提供了类似的功能。近年来,SpringFox 项目没有积极维护,作为对此的反应,创建了 springdoc-openapi 项目。SpringFox 用户的迁移指南可以在springdoc.org/#migrating-from-springfox找到。

总的来说,将组件的接口与其实现分离是很重要的。在记录 RESTful API 方面,我们应该将 API 文档添加到描述 API 的 Java 接口中,而不是添加到实现 API 的 Java 类中。为了简化更新 API 文档的文本部分(例如,较长的描述),我们可以将描述放在属性文件中,而不是直接放在 Java 代码中。

除了动态创建 API 规范之外,springdoc-openapi 还附带了一个名为 Swagger UI 的嵌入式 API 查看器。我们将配置product-composite-service服务以暴露 Swagger UI 来查看其 API。

尽管 Swagger UI 在开发和测试阶段非常有用,但由于安全原因,通常不会在生产环境中的 API 上公开。在许多情况下,API 通过 API 网关公开。今天,大多数 API 网关产品都支持基于 OpenAPI 文档公开 API 文档。因此,而不是公开 Swagger UI,API 的 OpenAPI 文档(由 springdoc-openapi 生成)被导出到一个可以安全发布 API 文档的 API 网关。

如果预期 API 将由第三方开发者使用,可以设置一个包含文档和工具的开发者门户,例如用于自我注册。Swagger UI 可以在开发者门户中使用,允许开发者通过阅读文档并使用测试实例尝试 API 来了解 API。

在第十一章“保护 API 访问”中,我们将学习如何使用 OAuth 2.1 锁定对 API 的访问。我们还将学习如何配置 Swagger UI 组件以获取 OAuth 2.1 访问令牌,并在用户通过 Swagger UI 尝试 API 时使用这些令牌。

以下截图是 Swagger UI 的一个示例:

图形用户界面,应用程序描述自动生成

图 5.1:Swagger UI 示例

在前面的图中,一些目前不重要的截图部分已被替换为“”。我们将在本章的后面部分回到这些细节。

要启用 springdoc-openapi 创建 API 文档,我们需要在我们的构建文件中添加一些依赖项,并在定义 RESTful 服务的 Java 接口中添加一些注解。如上所述,我们还将 API 文档的描述部分放在属性文件中。

如果文档的部分内容已经放置在属性文件中以简化 API 文档的更新,那么重要的是属性文件必须与源代码在相同的生命周期和版本控制下处理。否则,存在它们开始与实现脱节的风险,即变得过时。

介绍了 springdoc-openapi 之后,让我们看看如何通过在源代码中进行必要的更改来开始使用它。

将 springdoc-openapi 添加到源代码中

要添加关于由product-composite-service微服务公开的外部 API 的基于 OpenAPI 的文档,我们需要更改两个项目的源代码:

  • product-composite-service:在这里,我们将设置 Java 应用程序类ProductCompositeServiceApplication中的 springdoc-openapi 配置,并添加一些与 API 相关的一般信息。

  • api:在这里,我们将向 Java 接口ProductCompositeService添加注解,描述每个 RESTful 服务和其操作。在这个阶段,我们只有一个 RESTful 服务和一个操作,接受对/product-composite/{productId}的 HTTP GET 请求,用于请求有关特定产品的组合信息。

用于描述 API 操作的实际文本将被放置在product-composite-service项目的默认属性文件application.yml中。

在我们开始使用 springdoc-openapi 之前,我们需要将其添加到 Gradle 构建文件中的依赖项。所以,让我们从这里开始吧!

将依赖项添加到 Gradle 构建文件中

springdoc-openapi 项目被划分为多个模块。对于api项目,我们只需要包含我们将用于文档化的 API 注解的模块。我们可以将其添加到api项目的构建文件build.gradle中,如下所示:

implementation 'org.springdoc:springdoc-openapi-starter-common:2.0.2' 

product-composite-service项目需要一个功能更全面的模块,该模块包含 Swagger UI 查看器和 Spring WebFlux 的支持。我们可以在构建文件build.gradle中添加依赖项,如下所示:

implementation 'org.springdoc:springdoc-openapi-starter-webflux-ui:2.0.2' 

需要添加的所有依赖项都已添加;现在让我们进行配置。

将 OpenAPI 配置和一般 API 文档添加到 ProductCompositeService

要在product-composite-service微服务中启用 springdoc-openapi,我们必须添加一些配置。为了保持源代码紧凑,我们将直接将其添加到应用程序类ProductCompositeServiceApplication.java中。

如果您愿意,可以将 springdoc-openapi 的配置放置在单独的 Spring 配置类中。

首先,我们需要定义一个 Spring Bean,它返回一个OpenAPI Bean。源代码看起来是这样的:

@Bean
public OpenAPI getOpenApiDocumentation() {
  return **new****OpenAPI****()**
    .info(new Info().title(apiTitle)
      .description(apiDescription)
      .version(apiVersion)
      .contact(new Contact()
        .name(apiContactName)
        .url(apiContactUrl)
        .email(apiContactEmail))
      .termsOfService(apiTermsOfService)
      .license(new License()
        .name(apiLicense)
        .url(apiLicenseUrl)))
    .externalDocs(new ExternalDocumentation()
      .description(apiExternalDocDesc)
      .url(apiExternalDocUrl));
} 

从前面的代码中,我们可以看到配置包含有关 API 的一般描述性信息,例如:

  • API 的名称、描述、版本和联系方式

  • 使用条款和许可信息

  • 如果有的话,有关 API 的外部信息链接

用于配置OpenAPI bean 的api变量是通过 Spring @Value注解从属性文件初始化的。具体如下:

 @Value("${api.common.version}")         String apiVersion;
  @Value("${api.common.title}")           String apiTitle;
  @Value("${api.common.description}")     String apiDescription;
  @Value("${api.common.termsOfService}")  String apiTermsOfService;
  @Value("${api.common.license}")         String apiLicense;
  @Value("${api.common.licenseUrl}")      String apiLicenseUrl;
  @Value("${api.common.externalDocDesc}") String apiExternalDocDesc;
  @Value("${api.common.externalDocUrl}")  String apiExternalDocUrl;
  @Value("${api.common.contact.name}")    String apiContactName;
  @Value("${api.common.contact.url}")     String apiContactUrl;
  @Value("${api.common.contact.email}")   String apiContactEmail; 

实际值在属性文件application.yml中设置,如下所示:

api:
  common:
    version: 1.0.0
    title: Sample API
    description: Description of the API...
    termsOfService: MY TERMS OF SERVICE
    license: MY LICENSE
    licenseUrl: MY LICENSE URL
    externalDocDesc: MY WIKI PAGE
    externalDocUrl: MY WIKI URL
    contact:
      name: NAME OF CONTACT
      url: URL TO CONTACT
      email: contact@mail.com 

属性文件还包含一些针对 springdoc-openapi 的配置:

springdoc:
  swagger-ui.path: /openapi/swagger-ui.html
  api-docs.path: /openapi/v3/api-docs
  packagesToScan: se.magnus.microservices.composite.product
  pathsToMatch: /** 

配置参数有以下目的:

  • springdoc.swagger-ui.pathspringdoc.api-docs.path 用于指定嵌入的 Swagger UI 查看器使用的 URL 在路径/openapi下可用。在本书的后续内容中,当我们添加不同类型的边缘服务器并解决安全挑战时,这将简化边缘服务器的配置。有关更多信息,请参阅以下章节:

    • 第十章使用 Spring Cloud Gateway 在边缘服务器后面隐藏微服务

    • 第十一章保护 API 访问

    • 第十七章通过实现 Kubernetes 功能简化系统景观替换 Spring Cloud Gateway部分

    • 第十八章使用服务网格提高可观察性和管理用 Istio Ingress Gateway 替换 Kubernetes Ingress 控制器部分

  • springdoc.packagesToScanspringdoc.pathsToMatch 控制 springdoc-openapi 在代码库中搜索注解的位置。我们能够给 springdoc-openapi 提供的范围越窄,扫描的速度就越快。

详细信息请参阅product-composite-service项目中的应用程序类ProductCompositeServiceApplication.javaapplication.yml属性文件。现在我们可以继续了解如何在api项目中的 Java 接口ProductCompositeService.java中添加 API 特定文档。

向 ProductCompositeService 接口添加 API 特定文档

为了记录实际的 API 及其 RESTful 操作,我们将在api项目中的ProductCompositeService.java接口声明上添加一个@Tag注解。对于 API 中的每个 RESTful 操作,我们将在相应的 Java 方法上添加一个@Operation注解,以及@ApiResponse注解,以描述操作及其预期的响应。我们将描述成功和错误响应。

除了在运行时读取这些注解外,springdoc-openapi 还会检查 Spring 注解,例如@GetMapping注解,以了解操作所接受的输入参数以及如果产生成功响应,响应将呈现什么样子。为了了解潜在错误响应的结构,springdoc-openapi 将寻找@RestControllerAdvice@ExceptionHandler注解。在第三章创建一组协作微服务中,我们在util项目中添加了一个实用类,GlobalControllerExceptionHandler.java

这个类被注解为 @RestControllerAdvice。有关详细信息,请参阅 全局 REST 控制器异常处理器 部分。异常处理器负责处理 404 (NOT_FOUND) 和 422 (UNPROCESSABLE_ENTITY) 错误。为了允许 springdoc-openapi 正确记录 Spring WebFlux 在发现请求中的不正确输入参数时生成的 400 (BAD_REQUEST) 错误,我们还在 GlobalControllerExceptionHandler.java 中添加了一个 @ExceptionHandler 用于 400 (BAD_REQUEST) 错误。

资源级别的 API 文档,对应于 Java 接口声明,如下所示:

@Tag(name = "ProductComposite", description = 
  "REST API for composite product information.")
public interface ProductCompositeService { 

对于 API 操作,我们将 @Operation@ApiResponse 注解中使用的实际文本提取到了属性文件中。注解包含属性占位符,如 ${name-of-the-property},springdoc-openapi 将在运行时使用这些占位符从属性文件中查找实际文本。API 操作的文档如下所示:

@Operation( 
  summary = 
    "${api.product-composite.get-composite-product.description}",
  description = 
    "${api.product-composite.get-composite-product.notes}")
@ApiResponses(value = {
  @ApiResponse(responseCode = "200", description = 
    "${api.responseCodes.ok.description}"),
  @ApiResponse(responseCode = "**400**", description = 
    "${api.responseCodes.badRequest.description}"),
  @ApiResponse(responseCode = "**404**", description =   
    "${api.responseCodes.notFound.description}"),
  @ApiResponse(responseCode = "**422**", description =   
    "${api.responseCodes.unprocessableEntity.description}")
})
**@GetMapping**(
  value = **"/product-composite/{productId}"**,
  produces = "application/json")
ProductAggregate getProduct**(****@PathVariable****int** **productId)**; 

从前面的源代码中,springdoc-openapi 将能够提取以下关于操作的信息:

  • 该操作接受对 URL /product-composite/{productid} 的 HTTP GET 请求,其中 URL 的最后一部分 {productid} 被用作请求的输入参数。

  • 成功的响应将生成与 Java 类 ProductAggregate 对应的 JSON 结构。

  • 在发生错误的情况下,将返回 HTTP 错误代码 400404422,并在响应体中包含错误信息,如 Java 类 GlobalControllerExceptionHandler.java 中的 @ExceptionHandler 所描述的,如上所述。

对于 @Operation@ApiResponse 注解中指定的值,我们可以直接使用属性占位符,而无需使用 Spring @Value 注解。实际值在属性文件 application.yml 中设置,如下所示:

**api:**
**responseCodes:**
**ok.description:****OK**
    badRequest.description: Bad Request, invalid format of the request. See response message for more information
    notFound.description: Not found, the specified id does not exist
    unprocessableEntity.description: Unprocessable entity, input parameters caused the processing to fail. See response message for more information
  product-composite:
    get-composite-product:
      description: Returns a composite view of the specified product id
      notes: **|**
        # Normal response
        If the requested product id is found the method will return information regarding:
        1\. Base product information
        1\. Reviews
        1\. Recommendations
        1\. Service Addresses\n(technical information regarding the addresses of the microservices that created the response)
        # Expected partial and error responses
        In the following cases, only a partial response be created (used to simplify testing of error conditions)
        ## Product id 113
        200 - Ok, but no recommendations will be returned
        ## Product id 213
        200 - Ok, but no reviews will be returned
        ## Non-numerical product id
        400 - A **Bad Request** error will be returned
        ## Product id 13
        404 - A **Not Found** error will be returned
        ## Negative product ids
        422 - An **Unprocessable Entity** error will be returned 

从前面的配置中,我们可以了解以下内容:

  • 例如,属性占位符 ${api.responseCodes.ok.description} 将被翻译为 OK。注意基于 YAML 的属性文件的层次结构:

    api:
      responseCodes:
        ok.description: OK 
    
  • 多行值以 | 开头,例如属性 api.get-composite-product.description.notes 的值。此外,请注意 springdoc-openapi 支持使用 Markdown 语法提供多行描述。

有关详细信息,请参阅 api 项目中的服务接口类 ProductCompositeService.javaproduct-composite-service 项目中的属性文件 application.yml

如果你想了解更多关于 YAML 文件结构的信息,请查看规范:yaml.org/spec/1.2/spec.html

构建 和 启动 微服务环境

在我们尝试 OpenAPI 文档之前,我们需要构建并启动微服务环境!

这可以通过以下命令完成:

cd $BOOK_HOME/Chapter05
./gradlew build && docker-compose build && docker-compose up -d 

你可能会遇到一个关于端口 8080 已经被分配的错误消息。这看起来如下:

ERROR: for product-composite Cannot start service product-composite: driver failed programming external connectivity on endpoint chapter05_product-composite_1 (0138d46f2a3055ed1b90b3b3daca92330919a1e7fec20351728633222db5e737): Bind for 0.0.0.0:8080 failed: port is already allocated 

如果是这样,你可能忘记从上一章拉取微服务景观。要找出正在运行的容器的名称,请运行以下命令:

 docker ps --format {{.Names}} 

当上一章的微服务景观仍在运行时的一个示例响应如下:

chapter05_review_1
chapter05_product_1
chapter05_recommendation_1
chapter04_review_1
chapter04_product-composite_1
chapter04_product_1
chapter04_recommendation_1 

如果你在命令输出中发现了来自其他章节的容器,例如,来自 第四章使用 Docker 部署我们的微服务,就像前面的例子中那样,你需要跳转到该章节的源代码文件夹并拉取其容器:

cd ../Chapter04
docker-compose down 

现在,你可以启动本章缺失的容器:

cd ../Chapter05
docker-compose up -d 

注意,由于其他容器已经成功启动,命令仅启动了缺失的容器 product-composite

Starting chapter05_product-composite_1 ... done 

要等待微服务景观启动并验证其是否正常工作,你可以运行以下命令:

./test-em-all.bash 

注意,测试脚本 test-em-all.bash 已扩展,包含一组测试,以验证 Swagger UI 端点按预期工作:

# Verify access to Swagger and OpenAPI URLs
echo "Swagger/OpenAPI tests"
assertCurl 302 "curl -s  http://$HOST:$PORT/openapi/swagger-ui.html"
assertCurl 200 "curl -sL http://$HOST:$PORT/openapi/swagger-ui.html"
assertCurl 200 "curl -s  http://$HOST:$PORT/openapi/webjars/swagger-ui/index.html?configUrl=/v3/api-docs/swagger-config"
assertCurl 200 "curl -s  http://$HOST:$PORT/openapi/v3/api-docs"
assertEqual "3.0.1" "$(echo $RESPONSE | jq -r .openapi)"
assertEqual "http://$HOST:$PORT" "$(echo $RESPONSE | jq -r '.servers[0].url')"
assertCurl 200 "curl -s  http://$HOST:$PORT/openapi/v3/api-docs.yaml" 

在微服务成功启动后,我们可以继续尝试使用其嵌入的 Swagger UI 查看器测试 product-composite 微服务暴露的 OpenAPI 文档。

尝试 OpenAPI 文档

要浏览 OpenAPI 文档,我们将使用嵌入的 Swagger UI 查看器。如果我们在一个网络浏览器中打开 localhost:8080/openapi/swagger-ui.html URL,我们将看到一个类似于以下截图的网页:

图形用户界面,文本,应用程序  自动生成的描述

图 5.2:带有 Swagger UI 查看器的 OpenAPI 文档

在这里,我们可以确认以下内容:

  1. 我们在 springdoc-openapi OpenAPI bean 中指定的通用信息以及指向实际 OpenAPI 文档的链接,/openapi/v3/api-docs,指向 http://localhost:8080/openapi/v3/api-docs

    注意,这是可以导出到 API 网关的 OpenAPI 文档链接,如上节 springdoc-openapi 使用介绍 中所述。

  2. API 资源列表;在我们的例子中,是 ProductComposite API。

  3. 在页面底部,有一个我们可以检查 API 中使用的模式的区域。

    按照以下步骤进行 API 文档的检查。

  4. 点击 ProductComposite API 资源以展开它。您将获得该资源上可用的操作列表。您将只看到一个操作,/product-composite/{productId}

  5. 点击它以展开。您将看到我们指定在 ProductCompositeService Java 接口中的操作文档:

图形用户界面,应用程序  自动生成的描述

图 5.3:ProductComposite API 文档

在这里,我们可以看到以下内容:

  • 操作的一行描述。

  • 一个包含操作细节的章节,包括它支持的输入参数。注意 @ApiOperation 注解中 notes 字段中的 Markdown 语法已经被很好地渲染了!

如果你向下滚动网页,你也会找到有关预期响应及其结构的文档,包括正常 200(OK)响应的文档…

图形用户界面,文本,应用程序,自动生成的描述

图 5.4:200 响应的文档

…以及我们之前定义的各种 4xx 错误响应,如下面的截图所示:

图形用户界面,应用程序,自动生成的描述

图 5.5:4xx 响应的文档

对于每个文档化的潜在错误响应,我们可以了解其含义和响应体的结构。

如果我们向上滚动到参数描述,我们会找到 尝试一下 按钮。如果我们点击该按钮,我们可以填写实际的参数值,并通过点击 执行 按钮向 API 发送请求。例如,如果我们将在 productId 字段中输入 123,我们将得到以下响应:

图形用户界面,应用程序,自动生成的描述

图 5.6:发送现有产品请求后的响应

我们将得到预期的 200(OK)作为响应代码,以及我们已熟悉的 JSON 结构在响应体中!

如果我们输入一个错误的输入,例如 -1,我们将得到一个适当的错误代码作为响应代码,422,以及响应体中的基于 JSON 的错误描述:

图形用户界面,应用程序,自动生成的描述

图 5.7:发送无效输入后的响应

注意响应体中的 message 字段清楚地指出了问题:“Invalid productId: -1”

如果你想在不使用 Swagger UI 查看器的情况下尝试调用 API,你可以从 响应 部分复制相应的 curl 命令,并在终端窗口中运行它,如前一个截图所示:

curl -X GET "http://localhost:8080/product-composite/123" -H "accept: application/json" 

太棒了,不是吗?

摘要

良好的 API 文档对于其接受至关重要,而 OpenAPI 是在文档化 RESTful 服务时最常用的规范之一。springdoc-openapi 是一个开源项目,它通过检查 Spring WebFlux 和 Swagger 注解,使得在运行时动态创建基于 OpenAPI 的 API 文档成为可能。API 的文本描述可以从 Java 源代码中的注解中提取出来,并放置在属性文件中以方便编辑。springdoc-openapi 可以配置为将内嵌的 Swagger UI 查看器引入微服务中,这使得阅读微服务公开的 API 以及从查看器中尝试它们变得非常容易。

现在,关于通过添加持久性来给我们的微服务带来活力,也就是说,将那些微服务的数据保存到数据库中的能力,我们该怎么办?为此,我们需要添加一些更多的 API,以便我们可以创建和删除由微服务处理的信息。前往下一章了解更多!

问题

  1. Springdoc-openapi 是如何帮助我们为 RESTful 服务创建 API 文档的?

  2. Springdoc-openapi 支持哪种 API 文档规范?

  3. springdoc-openapi 的OpenAPI bean 的用途是什么?

  4. 列举一些 springdoc-openapi 在运行时读取的注解,以动态创建 API 文档。

  5. 在 YAML 文件中,代码“: |"代表什么意思?

  6. 如何在不再次使用查看器的情况下重复调用使用嵌入式 Swagger UI 查看器执行的 API 调用?

加入我们的 Discord 社区

加入我们的 Discord 空间,与作者和其他读者进行讨论:

packt.link/SpringBoot3e

二维码

第六章:添加持久化

在本章中,我们将学习如何持久化微服务使用的数据。正如在第二章Spring Boot 简介中提到的,我们将使用 Spring Data 项目将数据持久化到 MongoDB 和 MySQL 数据库。

productrecommendation微服务将使用 Spring Data for MongoDB,而review微服务将使用 Spring Data for Java Persistence APIJPA)来访问 MySQL 数据库。我们将向 RESTful API 添加操作以能够在数据库中创建和删除数据。现有的读取数据 API 将更新以访问数据库。我们将以 Docker 容器的方式运行数据库,由 Docker Compose 管理,即以与我们运行微服务相同的方式。

本章将涵盖以下主题:

  • 将持久化层添加到核心微服务

  • 编写关注持久化的自动化测试

  • 在服务层中使用持久化层

  • 扩展组合服务 API

  • 将数据库添加到 Docker Compose 环境中

  • 新 API 和持久化层的手动测试

  • 更新微服务景观的自动化测试

技术要求

关于如何安装本书中使用的工具以及如何访问本书源代码的说明,请参阅:

  • 第二十一章macOS 安装说明

  • 第二十二章使用 WSL 2 和 Ubuntu 的 Microsoft Windows 安装说明

要手动访问数据库,我们将使用运行数据库时使用的 Docker 镜像中提供的 CLI 工具。我们还将公开 Docker Compose 中每个数据库的标准端口,3306用于 MySQL 和27017用于 MongoDB。这将使我们能够像它们在本地计算机上运行一样使用我们喜欢的数据库工具访问数据库。

本章中的代码示例均来自$BOOK_HOME/Chapter06的源代码。

如果你想查看本章源代码中应用的变化,即查看使用 Spring Data 将持久化添加到微服务中所需的内容,你可以将其与第五章使用 OpenAPI 添加 API 描述的源代码进行比较。你可以使用你喜欢的diff工具比较两个文件夹,$BOOK_HOME/Chapter05$BOOK_HOME/Chapter06

在深入细节之前,让我们看看我们将走向何方。

本章目标

到本章结束时,我们微服务中的层级结构将如下所示:

图片

图 6.1:我们追求的微服务景观

协议层处理特定协议的逻辑。它非常薄,仅由api项目中的RestController注解和util项目中的通用GlobalControllerExceptionHandler组成。每个微服务的主要功能都位于各自的服务层

product-composite服务包含一个集成层,用于处理与三个核心微服务的通信。核心微服务都将有一个持久层,用于与它们的数据库通信。

我们将能够使用如下命令访问存储在 MongoDB 中的数据:

docker-compose exec mongodb mongosh product-db --quiet --eval "db.products.find()" 

命令的结果应该如下所示:

图 6.2:访问存储在 MongoDB 中的数据

关于存储在 MySQL 中的数据,我们将能够使用如下命令访问它:

docker-compose exec mysql mysql -uuser -p review-db -e "select * from reviews" 

命令的结果应该如下所示:

图 6.3:访问存储在 MySQL 中的数据

mongomysql命令的输出已被缩短以提高可读性。

让我们看看如何实现这一点。我们将首先为我们的核心微服务添加持久性功能!

向核心微服务添加持久层

让我们从向核心微服务添加持久层开始。除了使用 Spring Data,我们还将使用一个 Java Bean 映射工具,MapStruct,它使得在 Spring Data 实体对象和 API 模型类之间转换变得容易。有关更多详细信息,请参阅mapstruct.org/

首先,我们需要向 MapStruct、Spring Data 以及我们打算使用的数据库的 JDBC 驱动程序添加依赖项。之后,我们可以定义我们的 Spring Data 实体类和仓库。Spring Data 实体类和仓库将放置在其自己的 Java 包persistence中。例如,对于产品微服务,它们将被放置在 Java 包se.magnus.microservices.core.product.persistence中。

添加依赖项

我们将使用 MapStruct v1.5.3,因此我们将首先在各个核心微服务的构建文件build.gradle中定义一个变量来持有版本信息:

ext {
  mapstructVersion = "1.5.3.Final"
} 

接下来,我们声明对 MapStruct 的依赖项:

implementation "org.mapstruct:mapstruct:${mapstructVersion}" 

由于 MapStruct 在编译时通过处理 MapStruct 注解来生成 bean 映射的实现,我们需要添加一个annotationProcessor和一个testAnnotationProcessor依赖项:

annotationProcessor "org.mapstruct:mapstruct-processor:${mapstructVersion}"
testAnnotationProcessor "org.mapstruct:mapstruct-processor:${mapstructVersion}" 

为了使编译时生成在流行的 IDE(如 IntelliJ IDEA)中工作,我们还需要添加以下依赖项:

compileOnly "org.mapstruct:mapstruct-processor:${mapstructVersion}" 

如果你使用的是 IntelliJ IDEA,还需要确保启用了注解处理支持。打开首选项,导航到构建执行部署 | 编译器 | 注解处理器。请确保名为启用注解处理的复选框被选中!

对于productrecommendation微服务,我们声明以下对 Spring Data MongoDB 的依赖项:

implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' 

对于review微服务,我们声明了对 Spring Data JPA 和 MySQL JDBC 驱动的依赖项如下:

implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'com.mysql:mysql-connector-j' 

为了在运行自动化集成测试时启用 MongoDB 和 MySQL 的使用,我们将使用Testcontainers及其对 JUnit 5、MongoDB 和 MySQL 的支持。对于productrecommendation微服务,我们声明以下测试依赖项:

implementation platform('org.testcontainers:testcontainers-bom:1.15.2')
testImplementation 'org.testcontainers:testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:mongodb' 

对于review微服务,我们声明以下测试依赖项:

implementation platform('org.testcontainers:testcontainers-bom:1.15.2')
testImplementation 'org.testcontainers:testcontainers'
testImplementation 'org.testcontainers:junit-jupiter'
testImplementation 'org.testcontainers:mysql' 

关于如何在集成测试中使用 Testcontainers 的更多信息,请参阅后面的编写关注持久性的自动化测试部分。

使用实体类存储数据

实体类在包含的字段方面与相应的 API 模型类相似;请参阅api项目中的 Java 包se.magnus.api.core。与 API 模型类相比,实体类中我们将添加两个字段,idversion

id字段用于存储每个存储实体的数据库标识符,对应于使用关系型数据库时的主键。我们将委托 Spring Data 生成id字段的唯一值的责任。根据所使用的数据库,Spring Data 可以将此责任委托给数据库引擎或自行处理。在任何情况下,应用程序代码都不需要考虑如何设置唯一的数据库id值。出于安全角度的最佳实践,id字段不在 API 中公开。在模型类中标识实体的字段将在相应的实体类中被分配一个唯一索引,以确保从业务角度在数据库中的一致性。

version字段用于实现乐观锁,允许 Spring Data 验证数据库中实体的更新不会覆盖并发更新。如果数据库中存储的version字段的值高于更新请求中version字段的值,这表明更新是在过时数据上进行的——要更新的信息自从从数据库读取以来已被其他人更新。基于过时数据进行的更新尝试将被 Spring Data 阻止。在编写持久性测试的部分,我们将看到验证 Spring Data 中的乐观锁机制防止对过时数据进行更新的测试。由于我们只实现了创建、读取和删除操作的 API,因此我们不会在 API 中公开version字段。

产品实体类中最有趣的部分,用于在 MongoDB 中存储实体,如下所示:

**@Document(collection="products")**
public class ProductEntity {
 **@Id**
 private String id;
 **@Version**
 private Integer version;
 **@Indexed(unique = true)**
 private int productId;
 private String name;
 private int weight; 

下面是从前面的代码中得出的几点观察:

  • 使用@Document(collection = "products")注解来标记该类为用于 MongoDB 的实体类,即映射到 MongoDB 中名为products的集合。

  • 使用@Id@Version注解来标记idversion字段,以便 Spring Data 使用,如前所述。

  • 使用@Indexed(unique = true)注解来创建一个用于业务键productId的唯一索引。

Recommendation 实体类中最有趣的部分,也用于在 MongoDB 中存储实体,看起来如下:

@Document(collection="recommendations")
**@CompoundIndex**(name = "prod-rec-id", unique = true, def = "{'productId': 1, 'recommendationId' : 1}")
public class RecommendationEntity {
    @Id
    private String id;
    @Version
    private Integer version;
    private int productId;
    private int recommendationId;
    private String author;
    private int rating;
    private String content; 

在前面产品实体说明的基础上,我们可以看到如何使用 @CompoundIndex 注解为基于 productIdrecommendationId 字段的复合业务键创建一个唯一的复合索引。

最后,用于在 SQL 数据库(如 MySQL)中存储实体的 Review 实体类中最有趣的部分如下:

**@Entity**
**@Table**(name = "reviews", indexes = { @Index(name = "reviews_unique_idx", unique = true, columnList = **"productId,reviewId"**) })
public class ReviewEntity {
    **@Id** **@GeneratedValue**
    private int id;
    **@Version**
    private int version;
    private int productId;
    private int reviewId;
    private String author;
    private String subject;
    private String content; 

有关前面代码的说明:

  • @Entity@Table 注解用于标记类为用于 JPA 的实体类——映射到名为 reviews 的 SQL 数据库表。

  • @Table 注解也用于指定将基于 productIdreviewId 字段创建一个唯一的复合索引,用于复合业务键。

  • @Id@Version 注解用于标记 idversion 字段,以便 Spring Data 使用,如前所述。为了指导 Spring Data 使用 JPA 自动为 id 字段生成唯一的 id 值,我们使用了 @GeneratedValue 注解。

实体类的完整源代码,请参阅每个核心微服务项目中的 persistence 包。

在 Spring Data 中定义仓库

Spring Data 提供了一套用于定义仓库的接口。我们将使用 CrudRepositoryPagingAndSortingRepository 接口:

  • CrudRepository 接口提供了在数据库中存储的数据上执行基本创建、读取、更新和删除操作的标准方法。

  • PagingAndSortingRepository 接口为 CrudRepository 接口添加了对分页和排序的支持。

我们将使用 CrudRepository 接口作为 RecommendationReview 仓库的基础,也将 PagingAndSortingRepository 接口作为 Product 仓库的基础。

我们还将为我们的仓库添加一些额外的查询方法,用于使用业务键 productId 查找实体。

Spring Data 支持根据方法的签名命名约定来定义额外的查询方法。例如,findByProductId(int productId) 方法签名可以用来指导 Spring Data 自动创建一个查询,从底层集合或表中返回实体。在这种情况下,它将返回 productId 字段设置为 productId 参数中指定值的实体。有关如何声明额外查询的更多详细信息,请参阅 docs.spring.io/spring-data/data-commons/docs/current/reference/html/#repositories.query-methods.query-creation

Product 仓库类看起来是这样的:

public interface ProductRepository extends
    PagingAndSortingRepository <ProductEntity, String>,
    CrudRepository<ProductEntity, String> {
    **Optional**<ProductEntity> findByProductId(int productId);
} 

由于 findByProductId 方法可能返回零个或一个产品实体,因此返回值被标记为可选的,通过将其包装在 Optional 对象中来实现。

Recommendation仓库类的样子如下:

public interface RecommendationRepository extends CrudRepository <RecommendationEntity, String> {
    **List**<RecommendationEntity> findByProductId(int productId);
} 

在这种情况下,findByProductId方法将返回零到多个推荐实体,因此返回值被定义为列表。

最后,Review仓库类的样子如下:

public interface ReviewRepository extends CrudRepository<ReviewEntity, Integer> {
    **@Transactional(readOnly = true)**
    List<ReviewEntity> findByProductId(int productId);
} 

由于 SQL 数据库是事务性的,我们必须为查询方法findByProductId()指定默认的事务类型——在我们的案例中是只读。

就这些了——这就是为我们的核心微服务建立持久化层所需的所有内容。

要查看仓库类的完整源代码,请参阅每个核心微服务项目中的persistence包。

让我们通过编写一些测试来验证持久化类是否按预期工作,开始使用持久化类。

编写关注持久化的自动化测试

在编写持久化测试时,我们希望在测试开始时启动数据库,在测试完成后将其关闭。然而,我们不想让测试等待其他资源启动,例如,一个运行时所需的 Web 服务器(如 Netty)。

Spring Boot 提供了两个针对此特定要求的类级别注解:

  • @DataMongoTest:这个注解在测试开始时启动一个 MongoDB 数据库。

  • @DataJpaTest:这个注解在测试开始时启动一个 SQL 数据库:

    • 默认情况下,Spring Boot 会将测试配置为回滚 SQL 数据库的更新,以最小化对其他测试的负面影响。在我们的案例中,这种行为将导致一些测试失败。因此,使用类级别的注解@Transactional(propagation = NOT_SUPPORTED)禁用了自动回滚。

为了在执行集成测试期间处理数据库的启动和关闭,我们将使用 Testcontainers。在探讨如何编写持久化测试之前,让我们了解一下如何使用 Testcontainers。

使用 Testcontainers

Testcontainers (www.testcontainers.org)是一个库,通过运行资源管理器(如数据库或消息代理)作为 Docker 容器来简化自动集成测试。Testcontainers 可以配置为在 JUnit 测试启动时自动启动 Docker 容器,并在测试完成后销毁容器。

为了在现有的 Spring Boot 应用程序(如本书中的微服务)的测试类中启用 Testcontainers,我们可以在测试类中添加@Testcontainers注解。使用@Container注解,例如,我们可以声明Review微服务的集成测试将使用运行 MySQL 的 Docker 容器。

代码看起来是这样的:

@SpringBootTest
@Testcontainers
class SampleTests {
  @Container
  private static MySQLContainer database = 
    new MySQLContainer("mysql:8.0.32"); 

为 MySQL 指定的版本 8.0.32 是从 Docker Compose 文件中复制的,以确保使用相同的版本。

这种方法的缺点是每个测试类都会使用自己的 Docker 容器。在 Docker 容器中启动 MySQL 需要几秒钟,通常在我的 Mac 上需要 10 秒钟。运行多个使用相同类型测试容器的测试类将为每个测试类增加这种延迟。为了避免这种额外的延迟,我们可以使用单容器模式(见www.testcontainers.org/test_framework_integration/manual_lifecycle_control/#singleton-containers)。遵循此模式,使用一个基类来启动 MySQL 的单个 Docker 容器。在Review微服务中使用的基类MySqlTestBase如下所示:

public abstract class MySqlTestBase {
  private static MySQLContainer **database** =
    new MySQLContainer("mysql:8.0.32").withStartupTimeoutSeconds(300);

  **static** {
    database.start();
  }
  **@DynamicPropertySource**
  static void **databaseProperties**(DynamicPropertyRegistry registry) {
    registry.add("spring.datasource.url", database::getJdbcUrl);
    registry.add("spring.datasource.username", database::getUsername);
    registry.add("spring.datasource.password", database::getPassword);
  }
} 

对前面源代码的解释:

  • database容器声明的方式与前面的示例相同,只是在容器启动时增加了五分钟的扩展等待期。

  • 使用一个static块在调用任何 JUnit 代码之前启动数据库容器。

  • 数据库容器在启动时会获取一些属性定义,例如使用哪个端口。为了将这些动态创建的属性注册到应用程序上下文中,定义了一个静态方法databaseProperties()。该方法使用@DynamicPropertySource注解来覆盖应用程序上下文中的数据库配置,例如来自application.yml文件的配置。

测试类如下使用基类:

class PersistenceTests extends MySqlTestBase {
class ReviewServiceApplicationTests extends MySqlTestBase { 

对于使用 MongoDB 的productreview微服务,已添加相应的基类MongoDbTestBase

默认情况下,Testcontainers 的日志输出相当详细。可以在src/test/resource文件夹中放置一个Logback配置文件来限制日志输出的数量。Logback 是一个日志框架(logback.qos.ch),它通过使用spring-boot-starter-webflux依赖项包含在微服务中。有关详细信息,请参阅www.testcontainers.org/supported_docker_environment/logging_config/。本章使用的配置文件名为src/test/resources/logback-test.xml,其内容如下:

<?xml version="1.0" encoding="UTF-8" ?>
<configuration>
    <include resource="org/springframework/boot/logging/logback/**defaults.xml**"/>
    <include resource="org/springframework/boot/logging/logback/**console-appender.xml**"/>
    <**root** **level**=**"INFO"**>
        <appender-ref ref="CONSOLE" />
    </root>
</configuration> 

关于上面XML文件的一些说明:

  • 配置文件包括 Spring Boot 提供的两个配置文件来定义默认值,并配置了一个可以将日志事件写入控制台的日志追加器。

  • 该配置文件将日志输出限制在INFO日志级别,丢弃 Testcontainers 库发出的DEBUGTRACE日志记录。

有关 Spring Boot 对日志的支持和 Logback 的使用详情,请参阅docs.spring.io/spring-boot/docs/current/reference/html/howto.html#howto-configure-logback-for-logging

最后,当使用@DataMongoTest@DataJpaTest注解而不是@SpringBootTest注解,仅在集成测试期间启动 MongoDB 和 SQL 数据库时,还有一件事需要考虑。@DataJpaTest注解默认设计为启动嵌入式数据库。由于我们想使用容器化数据库,我们必须禁用此功能。

对于@DataJpaTest注解,可以通过使用类似这样的@AutoConfigureTestDatabase注解来完成:

@DataJpaTest
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
class PersistenceTests extends MySqlTestBase { 

随着 Testcontainers 的引入,我们准备好了解如何编写持久化测试。

编写持久化测试

对于三个核心微服务的持久化测试彼此相似,因此我们只需通过product微服务的持久化测试来了解。

测试类PersistenceTests声明了一个带有@BeforeEach注解的方法setupDb(),该方法在每个测试方法执行前执行。设置方法会从数据库中移除之前测试中创建的任何实体,并插入一个测试方法可以用来作为测试基础的实体:

@DataMongoTest
class PersistenceTests {
    @Autowired
    private ProductRepository repository;
    private ProductEntity savedEntity;
    @BeforeEach
    void setupDb() {
        repository.deleteAll();
        ProductEntity entity = new ProductEntity(1, "n", 1);
        savedEntity = repository.save(entity);
        assertEqualsProduct(entity, savedEntity);
    } 

接下来是各种测试方法。首先是create测试:

@Test
void create() {
    ProductEntity newEntity = new ProductEntity(2, "n", 2);
    **repository.save**(newEntity);
    ProductEntity foundEntity = 
    repository.**findById**(newEntity.getId()).get();
    assertEqualsProduct(newEntity, foundEntity);
    **assertEquals(****2**, repository.count());
} 

此测试创建一个新的实体,验证它可以使用findById方法找到,并通过断言数据库中存储了两个实体来结束测试,一个是setup方法创建的,另一个是测试本身创建的。

update测试看起来是这样的:

@Test
void update() {
    savedEntity.setName("n2");
    repository.**save**(savedEntity);
    ProductEntity foundEntity = 
    repository.**findById**(savedEntity.getId()).get();
    **assertEquals(****1****,** (long)foundEntity.getVersion());
    **assertEquals(****"n2"****,** foundEntity.getName());
} 

此测试更新由setup方法创建的实体,再次使用标准的findById()方法从数据库中读取它,并断言它包含一些字段的预期值。请注意,当创建实体时,其version字段被 Spring Data 设置为0,因此我们期望更新后它为1

delete测试看起来是这样的:

@Test
void delete() {
    repository.**delete**(savedEntity);
    **assertFalse(repository.existsById**(savedEntity.getId()));
} 

此测试删除由setup方法创建的实体,并验证它不再存在于数据库中。

read测试看起来是这样的:

@Test
void getByProductId() {
    Optional<ProductEntity> entity = 
    repository.**findByProductId**(savedEntity.getProductId());
    assertTrue(entity.isPresent());
    **assertEqualsProduct**(savedEntity, entity.get());
} 

此测试使用findByProductId()方法获取由setup方法创建的实体,验证其被找到,然后使用本地辅助方法assertEqualsProduct()验证findByProductId()返回的实体与setup方法存储的实体看起来相同。

接下来是两个测试方法,用于验证替代流程——处理错误条件。首先是验证重复项被正确处理的测试:

@Test
void duplicateError() {
  assertThrows(**DuplicateKeyException**.class, () -> {
    ProductEntity entity = new ProductEntity(savedEntity.getProductId(), "n", 1);
    repository.save(entity);
  });
} 

测试尝试存储一个与由setup方法创建的实体使用的业务键相同的实体。如果保存操作成功,或者保存操作失败并抛出除预期的DuplicateKeyException之外的异常,则测试将失败。

在我的看法中,另一个负面测试是测试类中最有趣的测试。这是一个验证在更新过时数据的情况下正确处理错误的测试——它验证了乐观锁机制是否工作。它看起来是这样的:

@Test
void optimisticLockError() {
    // Store the saved entity in two separate entity objects
    ProductEntity **entity1** = 
    repository.findById(savedEntity.getId()).get();
    ProductEntity **entity2** = 
    repository.findById(savedEntity.getId()).get();
    // Update the entity using the first entity object
    entity1.setName("n1");
    repository.**save(entity1)**;
    //  Update the entity using the second entity object.
    // This should fail since the second entity now holds an old version 
    // number, that is, an Optimistic Lock Error
    assertThrows(**OptimisticLockingFailureException.class**, () -> {
      entity2.setName("n2");
      repository.**save(entity2)**;
    });
    // Get the updated entity from the database and verify its new state
    ProductEntity updatedEntity = 
    repository.findById(savedEntity.getId()).get();
    **assertEquals(****1****,** (int)updatedEntity.getVersion());
    **assertEquals(****"n1"****,** updatedEntity.getName());
} 

从代码中可以观察到以下内容:

  • 首先,测试读取相同的实体两次,并将其存储在两个不同的变量entity1entity2中。

  • 接下来,它使用其中一个变量entity1来更新实体。数据库中实体的更新将导致 Spring Data 自动增加实体的version字段。另一个变量entity2现在包含过时数据,这体现在其version字段上,该字段持有比数据库中相应值更低的值。

  • 当测试尝试使用包含过时数据的变量entity2更新实体时,它预期会失败,并抛出OptimisticLockingFailureException异常。

  • 测试通过断言数据库中的实体反映了第一次更新,即包含名称"n1",并且version字段具有值1;数据库中的实体只执行了一个更新。

最后,product服务包含一个测试,演示了在 Spring Data 中使用内置的排序和分页支持:

@Test
void paging() {
    repository.deleteAll();
    List<ProductEntity> newProducts = rangeClosed(1001, 1010)
        .mapToObj(i -> new ProductEntity(i, "name " + i, i))
        .collect(Collectors.toList());
    repository.**saveAll**(newProducts);
    Pageable nextPage = **PageRequest.of(****0****,** **4****, ASC,** **"productId"****);**
    nextPage = **testNextPage**(nextPage, "[1001, 1002, 1003, 1004]", 
    true);
    nextPage = testNextPage(nextPage, "[1005, 1006, 1007, 1008]", 
    true);
    nextPage = testNextPage(nextPage, "[1009, 1010]", false);
} 

上述代码的解释:

  • 测试首先删除任何现有数据,然后插入具有productId字段从10011010的 10 个实体。

  • 接下来,它创建PageRequest,请求每页4个实体,并按ProductId升序排序。

  • 最后,它使用辅助方法testNextPage读取预期的三页,验证每页上的预期产品 ID,并验证 Spring Data 是否正确报告是否存在更多页面。

辅助方法testNextPage看起来如下:

private Pageable testNextPage(Pageable nextPage, String expectedProductIds, boolean expectsNextPage) {
    Page<ProductEntity> productPage = repository.**findAll(nextPage);**
    assertEquals(expectedProductIds, productPage.getContent()
    .stream().map(p -> p.getProductId()).collect(Collectors.
    toList()).toString());
    assertEquals(expectsNextPage, productPage.hasNext());
    return productPage.nextPageable();
} 

辅助方法使用页面请求对象nextPage从存储库方法findAll()获取下一页。根据结果,它将返回的实体中的产品 ID 提取到字符串中,并将其与预期的产品 ID 列表进行比较。最后,它返回下一页。

要查看持久化测试的完整源代码,请参阅每个核心微服务项目中的测试类PersistenceTests

可以使用类似以下命令的 Gradle 执行product微服务中的持久化测试:

cd $BOOK_HOME/Chapter06
./gradlew microservices:product-service:test --tests PersistenceTests 

运行测试后,它应该响应如下:

图 6.4:BUILD SUCCESSFUL 响应

在设置持久化层之后,我们可以更新核心微服务中的服务层以使用持久化层。

在服务层中使用持久化层

在本节中,我们将学习如何在服务层中使用持久化层来存储和检索数据库中的数据。我们将按照以下步骤进行:

  1. 记录数据库连接 URL

  2. 添加新 API

  3. 从服务层调用持久化层

  4. 声明 Java Bean 映射器

  5. 更新服务测试

记录数据库连接 URL

当扩展微服务数量,每个微服务连接到自己的数据库时,可能很难跟踪每个微服务实际使用的是哪个数据库。为了避免这种混淆,一个好的做法是在微服务启动后直接添加一个LOG语句,记录用于连接到数据库的连接信息。

例如,product服务的启动代码看起来是这样的:

public class ProductServiceApplication {
  private static final Logger LOG = 
  LoggerFactory.getLogger(ProductServiceApplication.class);
  public static void main(String[] args) {
    ConfigurableApplicationContext ctx = 
    SpringApplication.run(ProductServiceApplication.class, args);
    String mongodDbHost = 
    ctx.getEnvironment().getProperty("spring.data.mongodb.host");
    String mongodDbPort = 
    ctx.getEnvironment().getProperty("spring.data.mongodb.port");
 **LOG.info(****"Connected to MongoDb: "** **+ mongodDbHost +** **":"** **+** 
 **mongodDbPort);**
  }
} 

LOG.info方法的调用将像以下内容一样写入日志:

图片

图 6.5:预期的日志输出

对于完整的源代码,请查看每个核心微服务项目中的主应用程序类,例如product-service项目中的ProductServiceApplication

添加新的 API

在我们能够使用持久层在数据库中创建和删除信息之前,我们需要在我们的核心服务 API 中创建相应的 API 操作。

创建和删除产品实体的 API 操作看起来如下:

@PostMapping(
    value    = "/product",
    consumes = "application/json",
    produces = "application/json")
Product createProduct(@RequestBody Product body);
@DeleteMapping(value = "/product/{productId}")
void deleteProduct(@PathVariable int productId); 

删除操作的实现将是幂等的;也就是说,如果多次调用,它将返回相同的结果。这在故障场景中是一个有价值的特性。例如,如果客户端在调用删除操作时遇到网络超时,它只需再次调用删除操作,无需担心不同的响应,例如,第一次响应是OK200),而连续调用时响应是Not Found404),或者任何意外的副作用。这表明即使实体在数据库中不再存在,操作也应返回状态码OK200)。

recommendationreview实体的 API 操作看起来类似;然而,请注意,当涉及到recommendationreview实体的删除操作时,它将删除指定productId的所有recommendationsreviews

对于完整的源代码,请查看api项目中核心微服务的接口声明(ProductServiceRecommendationServiceReviewService)。

从服务层调用持久层

在服务层使用持久层的源代码对所有核心微服务都是结构化的。因此,我们只需查看product微服务的源代码。

首先,我们需要将持久层的仓库类和一个 Java Bean 映射类注入到构造函数中:

private final ServiceUtil serviceUtil;
private final ProductRepository repository;
private final ProductMapper mapper;
@Autowired
public ProductServiceImpl(**ProductRepository repository, ProductMapper mapper, ServiceUtil serviceUtil**) {
    this.repository = repository;
    this.mapper = mapper;
    this.serviceUtil = serviceUtil;
} 

在下一节中,我们将看到如何定义 Java 映射类。

接下来,createProduct方法实现如下:

public Product createProduct(Product body) {
    try {
        ProductEntity entity = mapper.apiToEntity(body);
        ProductEntity newEntity = repository.**save**(entity);
        return mapper.**entityToApi**(newEntity);
    } catch (**DuplicateKeyException** dke) {
        **throw****new****InvalidInputException**("Duplicate key, Product Id: " + 
        body.getProductId());
    }
} 

createProduct方法使用了存储库中的save方法来存储新的实体。需要注意的是,映射类用于在 API 模型类和实体类之间使用两个映射方法apiToEntity()entityToApi()进行 Java bean 的转换。我们为create方法处理的唯一错误是DuplicateKeyException异常,我们将其转换为InvalidInputException异常。

getProduct方法如下所示:

public Product getProduct(int productId) {
    if (**productId <** **1**) throw new InvalidInputException("Invalid 
    productId: " + productId);
    ProductEntity entity = repository.**findByProductId**(productId)
        .**orElseThrow**(() -> new NotFoundException("No product found for 
         productId: " + productId));
    Product response = mapper.entityToApi(entity);
    response.setServiceAddress(**serviceUtil**.getServiceAddress());
    return response;
} 

在进行一些基本输入验证(即确保productId不是负数)之后,使用存储库中的findByProductId()方法来查找产品实体。由于存储库方法返回一个Optional产品,我们可以使用Optional类中的orElseThrow()方法方便地抛出NotFoundException异常,如果未找到产品实体。在返回产品信息之前,使用serviceUtil对象来填写当前使用的微服务地址。

最后,让我们看看deleteProduct方法:

public void deleteProduct(int productId) {
    repository.**findByProductId**(productId).**ifPresent**(e -> 
    repository.delete(e));
} 

delete方法也使用了存储库中的findByProductId()方法,并使用Optional类中的ifPresent()方法来方便地仅在实体存在时删除实体。请注意,实现是幂等的;如果未找到实体,则不会报告任何失败。

要查看完整的源代码,请参阅每个核心微服务项目中的服务实现类,例如,在product-service项目中查看ProductServiceImpl

声明 Java bean 映射器

那么,关于神奇的 Java bean 映射器,又是怎样的呢?

如前所述,MapStruct 用于声明我们的映射类。在所有三个核心微服务中,MapStruct 的使用方式相似,因此我们只需查看product微服务中映射对象的源代码。

product服务的mapper类如下所示:

@Mapper(componentModel = "spring")
public interface ProductMapper {
    @Mappings({
        @Mapping(target = "**serviceAddress**", ignore = true)
    })
    Product **entityToApi**(ProductEntity entity);
    @Mappings({
        @Mapping(target = "**id**", ignore = true),
        @Mapping(target = "**version**", ignore = true)
    })
    ProductEntity **apiToEntity**(Product api);
} 

从代码中可以注意以下几点:

  • entityToApi()方法将实体对象映射到 API 模型对象。由于实体类没有serviceAddress字段,entityToApi()方法被注解为忽略 API 模型对象中的serviceAddress字段。

  • apiToEntity()方法将 API 模型对象映射到实体对象。同样,apiToEntity()方法被注解为忽略 API 模型类中缺失的idversion字段。

MapStruct 不仅支持按名称映射字段,还可以指定映射具有不同名称的字段。在recommendation服务的映射类中,使用以下注解将rating实体字段映射到 API 模型字段rate

 @Mapping(target = "**rate**", source="entity.**rating**"),
    Recommendation entityToApi(RecommendationEntity entity);
    @Mapping(target = "**rating**", source="api.**rate**"),
    RecommendationEntity apiToEntity(Recommendation api); 

在成功构建 Gradle 之后,可以在每个项目的build/classes文件夹中找到生成的映射实现。例如,在product-service项目中,ProductMapperImpl.java

要查看完整的源代码,请参阅每个核心微服务项目中的映射类,例如,在product-service项目中查看ProductMapper

更新服务测试

自上一章以来,核心微服务公开的 API 测试已经更新,包括创建和删除 API 操作的测试。

添加的测试在所有三个核心微服务中都是相似的,所以我们只通过product微服务中的服务测试源代码进行说明。

为了确保每个测试都有一个已知的状态,声明了一个setupDb()设置方法,并使用@BeforeEach注解,因此它在每个测试之前执行。设置方法删除任何之前创建的实体:

@Autowired
private ProductRepository repository;
@**BeforeEach**
void **setupDb**() {
   repository.deleteAll();
} 

创建 API 的测试方法验证在创建产品实体后可以检索到该实体,并且使用相同的productId创建另一个产品实体会导致 API 请求的响应中出现预期的错误,UNPROCESSABLE_ENTITY

@Test
void duplicateError() {
   int productId = 1;
   **postAndVerifyProduct(productId, OK);**
   assertTrue(repository.findByProductId(productId).isPresent());
   **postAndVerifyProduct(productId, UNPROCESSABLE_ENTITY)**
      .jsonPath("$.path").isEqualTo("/product")
      .jsonPath("$.message").isEqualTo("Duplicate key, Product Id: " + 
       productId);
} 

删除 API 的测试方法验证可以删除产品实体,并且第二个删除请求是幂等的——它也返回状态码 OK,即使实体在数据库中不再存在:

@Test
void deleteProduct() {
   int productId = 1;
   postAndVerifyProduct(productId, OK);
   assertTrue(repository.findByProductId(productId).isPresent());
   deleteAndVerifyProduct(productId, **OK**);
   assertFalse(repository.findByProductId(productId).isPresent());
   deleteAndVerifyProduct(productId, **OK**);
} 

为了简化向 API 发送创建、读取和删除请求并验证响应状态,创建了三个辅助方法:

  • postAndVerifyProduct()

  • getAndVerifyProduct()

  • deleteAndVerifyProduct()

postAndVerifyProduct()方法如下所示:

private WebTestClient.BodyContentSpec postAndVerifyProduct(int productId, HttpStatus expectedStatus) {
   Product product = new Product(productId, "Name " + productId, 
   productId, "SA");
   return client.post()
      .uri("/product")
      .body(just(product), Product.class)
      .accept(APPLICATION_JSON)
      .**exchange()**
      .expectStatus().**isEqualTo(expectedStatus)**
      .expectHeader().**contentType(APPLICATION_JSON)**
 **.expectBody();**
} 

辅助方法执行实际的 HTTP 请求并验证响应代码和响应体的内容类型。除此之外,辅助方法还会根据调用者的需要返回响应体以进行进一步调查。其他两个辅助方法用于读取和删除请求,它们类似。

三个服务测试类的源代码可以在每个核心微服务项目中找到,例如,在product-service项目中的ProductServiceApplicationTests

现在,让我们继续了解如何扩展复合服务 API。

扩展复合服务 API

在本节中,我们将了解如何通过创建和删除复合实体的操作来扩展复合 API。我们将按照以下步骤进行:

  1. 向复合服务 API 添加新操作

  2. 将方法添加到集成层

  3. 实现新的复合 API 操作

  4. 更新复合服务测试

向复合服务 API 添加新操作

创建和删除实体以及处理聚合实体的复合版本与核心服务 API 中的创建和删除操作类似。主要区别是它们添加了用于基于 OpenAPI 的文档的注解。有关 OpenAPI 注解@Operation@ApiResponse的用法说明,请参阅第五章使用 OpenAPI 添加 API 描述,特别是将 API 特定文档添加到 ProductCompositeService 接口部分。

创建复合产品实体的 API 操作声明如下:

@Operation(
  summary = "${api.product-composite.create-composite-product.description}",
  description = "${api.product-composite.create-composite-product.notes}")
@ApiResponses(value = {
  @ApiResponse(responseCode = "400", description = "${api.responseCodes.badRequest.description}"),
  @ApiResponse(responseCode = "422", description = "${api.responseCodes.unprocessableEntity.description}")
  })
@PostMapping(
  value    = "/product-composite",
  consumes = "application/json")
void createProduct(@RequestBody ProductAggregate body); 

删除复合产品实体的 API 操作声明如下:

@Operation(
  summary = "${api.product-composite.delete-composite-product.description}",
  description = "${api.product-composite.delete-composite-product.notes}")
@ApiResponses(value = {
  @ApiResponse(responseCode = "400", description = "${api.responseCodes.badRequest.description}"),
  @ApiResponse(responseCode = "422", description = "${api.responseCodes.unprocessableEntity.description}")
})
@DeleteMapping(value = "/product-composite/{productId}")
void deleteProduct(@PathVariable int productId); 

对于完整的源代码,请参阅api项目中的 Java 接口ProductCompositeService

我们还需要像以前一样,将 API 文档的描述性文本添加到product-composite项目中的属性文件application.yml中:

create-composite-product:
  description: Creates a composite product
  notes: |
    # Normal response
    The composite product information posted to the API will be 
    split up and stored as separate product-info, recommendation 
    and review entities.
    # Expected error responses
    1\. If a product with the same productId as specified in the 
    posted information already exists, an **422 - Unprocessable 
    Entity** error with a "duplicate key" error message will be 
    Returned
delete-composite-product:
  description: Deletes a product composite
  notes: |
    # Normal response
    Entities for product information, recommendations and reviews 
    related to the specified productId will be deleted.
    The implementation of the delete method is idempotent, that is, 
    it can be called several times with the same response.
    This means that a delete request of a non-existing product will 
    return **200 Ok**. 

使用 Swagger UI 查看器,更新的 OpenAPI 文档将看起来像这样:

图形用户界面,文本,应用程序,电子邮件  自动生成的描述

图 6.6:更新后的 OpenAPI 文档

在本章的后面部分,我们将使用 Swagger UI 查看器来尝试新的组合 API 操作。

向集成层添加方法

在我们能够在组合服务中实现新的创建和删除 API 之前,我们需要扩展集成层,使其能够调用核心微服务的 API 中的底层创建和删除操作。

集成层中调用三个核心微服务的创建和删除操作的方法简单直接,彼此相似,所以我们只将通过调用product微服务的方法的源代码进行说明。

createProduct()方法看起来是这样的:

@Override
public Product createProduct(Product body) {
    try {
        return **restTemplate.postForObject**(
                   productServiceUrl, body, Product.class);
    } catch (HttpClientErrorException ex) {
        throw **handleHttpClientException**(ex);
    }
} 

它简单地委托发送 HTTP 请求的责任给RestTemplate对象,并将错误处理委托给辅助方法handleHttpClientException

deleteProduct()方法看起来是这样的:

@Override
public void deleteProduct(int productId) {
    try {
        **restTemplate.delete**(productServiceUrl + "/" + productId);
    } catch (HttpClientErrorException ex) {
        throw handleHttpClientException(ex);
    }
} 

它的实现方式与创建方法相同,但执行的是一个 HTTP delete请求。

集成层的完整源代码可以在product-composite项目中的ProductCompositeIntegration类中找到。

实现新的组合 API 操作

现在,我们可以实现组合的创建和删除方法了!

组合创建方法将聚合产品对象拆分为离散的productrecommendationreview对象,并在集成层中调用相应的创建方法:

@Override
public void createProduct(ProductAggregate body) {
    try {
        Product product = new Product(body.getProductId(), 
        body.getName(), body.getWeight(), null);
        **integration.createProduct**(product);
        if (body.getRecommendations() != null) {
            body.getRecommendations().forEach(r -> {
                Recommendation recommendation = new 
                Recommendation(body.getProductId(), 
                r.getRecommendationId(), r.getAuthor(), r.getRate(), 
                r.getContent(), null);
                **integration.createRecommendation**(recommendation);
            });
        }
        if (body.getReviews() != null) {
            body.getReviews().forEach(r -> {
                Review review = new Review(body.getProductId(), 
                r.getReviewId(), r.getAuthor(), r.getSubject(), 
                r.getContent(), null);
                **integration.createReview**(review);
            });
        }
    } catch (RuntimeException re) {
        LOG.warn("createCompositeProduct failed", re);
        throw re;
    }
} 

组合删除方法简单地调用集成层中的三个删除方法,以删除底层数据库中的相应实体:

@Override
public void deleteProduct(int productId) {
    integration.deleteProduct(productId);
    integration.deleteRecommendations(productId);
    integration.deleteReviews(productId);
} 

服务实现的完整源代码可以在product-composite项目中的ProductCompositeServiceImpl类中找到。

对于愉快的场景,这种实现将工作得很好,但如果考虑各种错误场景,我们会看到这种实现将引起麻烦!

例如,如果底层核心微服务因内部、网络或数据库问题暂时不可用,会怎样呢?

这可能会导致部分创建或删除的组合产品。对于删除操作,如果请求者简单地调用组合的删除方法直到成功,则可以修复。然而,如果底层问题持续一段时间,请求者可能会放弃,导致组合产品状态不一致——这在大多数情况下是不可接受的!

在下一章,第七章开发响应式微服务中,我们将看到如何使用同步 API(如 RESTful API)来解决这个问题。

现在,让我们带着这个脆弱的设计继续前进。

更新组合服务测试

如同在 第三章 中提到的,创建一组协作的微服务(参考 在隔离中添加自动微服务测试 部分),测试组合服务仅限于使用简单的模拟组件,而不是实际的核心服务。这限制了我们对更复杂场景的测试,例如,在底层数据库中尝试创建重复项时的错误处理。

因此,组合创建和删除 API 操作的测试相对简单:

@Test
void createCompositeProduct1() {
   ProductAggregate compositeProduct = new ProductAggregate(1, "name", 
   1, null, null, null);
   postAndVerifyProduct(compositeProduct, OK);
}
@Test
void createCompositeProduct2() {
    ProductAggregate compositeProduct = new ProductAggregate(1, "name", 
        1, singletonList(new RecommendationSummary(1, "a", 1, "c")),
        singletonList(new ReviewSummary(1, "a", "s", "c")), null);
    postAndVerifyProduct(compositeProduct, OK);
}
@Test
void deleteCompositeProduct() {
    ProductAggregate compositeProduct = new ProductAggregate(1, "name", 
        1,singletonList(new RecommendationSummary(1, "a", 1, "c")),
        singletonList(new ReviewSummary(1, "a", "s", "c")), null);
    postAndVerifyProduct(compositeProduct, OK);
    deleteAndVerifyProduct(compositeProduct.getProductId(), OK);
    deleteAndVerifyProduct(compositeProduct.getProductId(), OK);
} 

服务测试的完整源代码可以在 product-composite 项目的 ProductCompositeServiceApplicationTests 类中找到。

这些都是在源代码中需要进行的所有更改。在我们能够一起测试微服务之前,我们必须学习如何将数据库添加到由 Docker Compose 管理的系统景观中。

将数据库添加到 Docker Compose 景观中

现在,我们已经准备好了所有源代码。在我们能够启动微服务景观并尝试使用新的持久层一起测试新的 API 之前,我们必须启动一些数据库。

我们将把 MongoDB 和 MySQL 带入由 Docker Compose 控制的系统景观中,并添加配置到我们的微服务中,以便它们在运行时可以找到它们的数据库。

Docker Compose 配置

在 Docker Compose 配置文件 docker-compose.yml 中,MongoDB 和 MySQL 的声明如下:

 mongodb:
    image: **mongo:6.0.4**
    mem_limit: 512m
    ports:
      - **"27017:27017"**
    command: mongod
    **healthcheck**:
      test: "mongo --eval 'db.stats().ok'"
      interval: 5s
      timeout: 2s
      retries: 60
  mysql:
    image: **mysql:8.0.32**
    mem_limit: 512m
    ports:
      - "**3306:3306**"
    environment:
      - **MYSQL_ROOT_PASSWORD**=rootpwd
      - **MYSQL_DATABASE**=review-db
      - **MYSQL_USER**=user
      - **MYSQL_PASSWORD**=pwd
    **healthcheck**:
      test: "/usr/bin/mysql --user=user --password=pwd --execute \"SHOW DATABASES;\""
      interval: 5s
      timeout: 2s
      retries: 60 

有关前面代码的说明:

  • 我们将使用 MongoDB v6.0.4 和 MySQL 8.0.32 的官方 Docker 镜像,并将它们的默认端口 270173306 转发到 Docker 主机,当使用 Docker Desktop for Mac 时,这些端口也将在 localhost 上可用。

  • 对于 MySQL,我们还声明了一些环境变量,定义以下内容:

    • 根密码

    • 容器启动时将创建的数据库名称

    • 在容器启动时为数据库设置的用户的用户名和密码

  • 我们还声明了一个健康检查,Docker 将运行以确定 MongoDB 和 MySQL 数据库的状态。

为了避免微服务在它们启动和运行之前尝试连接到数据库,productrecommendation 服务被声明为依赖于 MongoDB 数据库,如下所示:

depends_on:
  mongodb:
    condition: service_healthy 

由于同样的原因,review 服务也被声明为依赖于 MySQL 数据库:

depends_on:
  mysql:
    condition: service_healthy 

这意味着 Docker Compose 不会启动微服务容器,直到数据库容器启动并由其健康检查报告为健康状态。

数据库连接配置

数据库已就绪后,我们现在需要设置核心微服务的配置,以便它们知道如何连接到它们的数据库。这通过每个核心微服务的配置文件application.ymlproduct-servicerecommendation-servicereview-service项目中设置。

productrecommendation服务的配置相似,因此我们只需查看product服务的配置。以下配置部分值得关注:

spring.data.mongodb:
  host: **localhost**
  port: **27017**
  database: product-db
logging:
 level:
 org.springframework.data.mongodb.core.**MongoTemplate**: **DEBUG**
---
spring.config.activate.on-profile: **docker**
spring.data.mongodb.host: **mongodb** 

代码的前重要部分:

  • 在不使用 Docker 并使用默认 Spring 配置文件的情况下运行时,数据库预期可以通过localhost:27017进行访问。

  • MongoTemplate的日志级别设置为DEBUG将允许我们在日志中看到哪些 MongoDB 语句被执行。

  • 在使用 Spring 配置文件docker在 Docker 内部运行时,数据库预期可以通过mongodb:27017进行访问。

影响其如何连接 SQL 数据库的review服务的配置如下:

**spring.jpa.hibernate.ddl-auto**: update
spring.datasource:
  url: jdbc:mysql://**localhost**/review-db
  username: user
  password: pwd
spring.datasource.hikari.**initializationFailTimeout**: 60000
**logging**:
 level:
 org.hibernate.SQL: DEBUG
 org.hibernate.type.descriptor.sql.BasicBinder: TRACE
---
spring.config.activate.on-profile: docker
spring.datasource:
 url: jdbc:mysql://**mysql**/review-db 

代码的前解释:

  • 默认情况下,Hibernate 将由 Spring Data JPA 用作 JPA 的 EntityManager。

  • spring.jpa.hibernate.ddl-auto属性用于告诉 Spring Data JPA 在启动时创建新表或更新现有 SQL 表。

注意:强烈建议在生产环境中将spring.jpa.hibernate.ddl-auto属性设置为nonevalidate——这可以防止 Spring Data JPA 操作 SQL 表的结构。更多信息,请参阅docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#howto-database-initialization

  • 在不使用 Docker 的情况下,使用默认的 Spring 配置文件,数据库预期可以通过localhost使用默认端口3306进行访问。

  • 默认情况下,Spring Data JPA 使用 HikariCP 作为 JDBC 连接池。为了最小化在硬件资源有限的计算机上的启动问题,initializationFailTimeout参数设置为 60 秒。这意味着 Spring Boot 应用程序将在启动时最多等待 60 秒以建立数据库连接。

  • Hibernate 的日志级别设置将导致 Hibernate 打印使用的 SQL 语句和实际使用的值。请注意,当在生产环境中使用时,出于隐私原因,应避免将实际值写入日志。

  • 在使用 Spring 配置文件docker在 Docker 内部运行时,数据库预期可以通过mysql主机名使用默认端口3306进行访问。

在此配置就绪后,我们就可以启动系统景观了。但在我们这样做之前,让我们学习如何运行数据库 CLI 工具。

MongoDB 和 MySQL CLI 工具

一旦我们开始运行一些微服务的测试,将很有趣地看到实际存储在微服务数据库中的数据。每个数据库 Docker 容器都附带基于 CLI 的工具,可以用来查询数据库表和集合。要运行数据库 CLI 工具,可以使用 Docker Compose 的exec命令。

本节中描述的命令将在下一节进行手动测试时使用。现在不要尝试运行它们;它们将失败,因为我们还没有启动和运行数据库!

要在mongodb容器内启动 MongoDB CLI 工具mongo,运行以下命令:

docker-compose exec mongodb mongosh ––quiet
> 

输入exit以离开mongo CLI。

要在mysql容器内启动 MySQL CLI 工具mysql并使用启动时创建的用户登录到review-db,请运行以下命令:

docker-compose exec mysql mysql -uuser -p review-db
mysql> 

mysql CLI 工具将提示您输入密码;您可以在docker-compose.yml文件中找到它。查找环境变量MYSQL_PASSWORD的值。

输入exit以离开mysql CLI。

我们将在下一节中看到这些工具的用法。

如果您更喜欢图形数据库工具,您也可以在本地运行它们,因为 MongoDB 和 MySQL 容器都在 localhost 上公开了它们的标准端口。

新 API 和持久层的手动测试

现在,我们已经准备好一起测试微服务。我们将基于新的 Docker 镜像使用 Docker Compose 启动系统景观。接下来,我们将使用 Swagger UI 查看器进行一些手动测试。最后,我们将使用数据库 CLI 工具查看插入到数据库中的数据。

使用以下命令构建并启动系统景观:

cd $BOOK_HOME/Chapter06
./gradlew build && docker-compose build && docker-compose up 

在网页浏览器中打开 Swagger UI,http://localhost:8080/openapi/swagger-ui.html,并在网页上执行以下步骤:

  1. 点击ProductComposite服务和POST方法以展开它们。

  2. 点击尝试操作按钮,然后下滑到 body 字段。

  3. productId字段的默认值0替换为123456

  4. 滚动到执行按钮并点击它。

  5. 验证返回的响应代码是200

以下是在点击执行按钮后的示例截图:

图形用户界面,应用程序描述自动生成

图 6.7:测试服务器响应

docker-compose up命令的日志输出中,我们应该能够看到以下输出(为了提高可读性进行了缩写):

图 6.8:docker-compose up 命令的日志输出

我们还可以使用数据库 CLI 工具查看不同数据库的实际内容。

使用以下命令查找product服务中的内容,即 MongoDB 中的products集合:

docker-compose exec mongodb mongosh product-db --quiet --eval "db.products.find()" 

预期得到如下响应:

文本描述自动生成

图 6.9:查找产品

使用以下命令在recommendation服务中查找内容,即 MongoDB 中的recommendations集合:

docker-compose exec mongodb mongosh recommendation-db --quiet --eval "db.recommendations.find()" 

预期会收到如下响应:

文本描述自动生成

图 6.10:查找推荐内容

使用以下命令在review服务中查找内容,即 MySQL 中的reviews表:

docker-compose exec mysql mysql -uuser -p review-db -e "select * from reviews" 

mysql CLI 工具将提示您输入密码;您可以在docker-compose.yml文件中找到它。查找环境变量MYSQL_PASSWORD的值。预期会收到如下响应:

图 6.11:查找评论

通过中断docker-compose up命令并按Ctrl + C,然后执行docker-compose down命令来关闭系统景观。之后,让我们看看如何更新微服务景观中的自动化测试。

更新微服务景观的自动化测试

微服务景观的自动化测试test-em-all.bash需要更新,以确保在运行测试之前每个微服务的数据库都处于已知状态。

脚本通过一个设置函数setupTestdata()进行扩展,该函数使用复合创建和删除 API 来设置测试数据,这些数据由测试使用。

setupTestdata函数看起来是这样的:

function setupTestdata() {
    body=\
    '{"productId":1,"name":"product 1","weight":1, "recommendations":[
        {"recommendationId":1,"author":"author 
         1","rate":1,"content":"content 1"},
        {"recommendationId":2,"author":"author 
         2","rate":2,"content":"content 2"},
        {"recommendationId":3,"author":"author 
         3","rate":3,"content":"content 3"}
    ], "reviews":[
        {"reviewId":1,"author":"author 1","subject":"subject 
         1","content":"content 1"},
        {"reviewId":2,"author":"author 2","subject":"subject 
         2","content":"content 2"},
        {"reviewId":3,"author":"author 3","subject":"subject 
         3","content":"content 3"}
    ]}'
    recreateComposite 1 "$body"
    body=\
    '{"productId":113,"name":"product 113","weight":113, "reviews":[
    {"reviewId":1,"author":"author 1","subject":"subject 
     1","content":"content 1"},
    {"reviewId":2,"author":"author 2","subject":"subject 
     2","content":"content 2"},
    {"reviewId":3,"author":"author 3","subject":"subject 
     3","content":"content 3"}
]}'
    recreateComposite 113 "$body"
    body=\
    '{"productId":213,"name":"product 213","weight":213, 
    "recommendations":[
       {"recommendationId":1,"author":"author 
         1","rate":1,"content":"content 1"},
       {"recommendationId":2,"author":"author 
        2","rate":2,"content":"content 2"},
       {"recommendationId":3,"author":"author 
        3","rate":3,"content":"content 3"}
]}'
    recreateComposite 213 "$body"
} 

它使用一个辅助函数recreateComposite()来执行对删除和创建 API 的实际请求:

function **recreateComposite**() {
    local productId=$1
    local composite=$2
    assertCurl 200 "curl -X **DELETE** http://$HOST:$PORT/product-
    composite/${productId} -s"
    curl -X **POST** http://$HOST:$PORT/product-composite -H "Content-Type: 
    application/json" --data "$composite"
} 

setupTestdata函数在waitForService函数之后直接调用:

waitForService curl -X DELETE http://$HOST:$PORT/product-composite/13
setupTestdata 

waitForService函数的主要目的是验证所有微服务都已启动并运行。在前一章中,使用了复合产品服务的 get API。在本章中,使用的是 delete API。当使用 get API 时,如果实体未找到,则只会调用product核心微服务;不会调用recommendationreview服务来验证它们是否已启动并运行。对 delete API 的调用也将确保productId 13上的未找到测试成功。在下一章中,我们将看到如何为检查微服务景观的健康状态定义特定的 API。

使用以下命令执行更新的测试脚本:

cd $BOOK_HOME/Chapter06
./test-em-all.bash start stop 

执行应通过写入如下日志消息结束:

图形用户界面,文本描述自动生成

图 6.12:测试执行结束时的日志消息

这标志着微服务景观自动化测试更新的结束。

摘要

在本章中,我们看到了如何使用 Spring Data 为核心微服务添加持久化层。我们使用了 Spring Data 的核心概念,即仓库和实体,在 MongoDB 和 MySQL 中存储数据。对于 NoSQL 数据库(如 MongoDB)和 SQL 数据库(如 MySQL)而言,编程模型是相似的,尽管它不是完全可移植的。我们还看到了 Spring Boot 的注解 @DataMongoTest@DataJpaTest 如何被用来方便地设置针对持久化的测试;这是在测试运行之前自动启动数据库,但不会启动微服务在运行时需要的其他基础设施,例如,一个像 Netty 这样的网络服务器。为了处理数据库的启动和关闭,我们使用了 Testcontainers,它在 Docker 容器中运行数据库。这导致持久化测试易于设置,并且具有最小的开销。

我们也看到了持久化层如何被服务层使用,以及我们如何添加创建和删除实体(无论是核心的还是组合的)的 API。

最后,我们了解到在运行时使用 Docker Compose 启动数据库(如 MongoDB 和 MySQL)是多么方便,以及如何使用新的创建和删除 API 在运行基于微服务的系统的自动化测试之前设置测试数据。

然而,在本章中确定了一个主要问题。使用同步 API 更新(创建或删除)一个组合实体——一个其部分存储在多个微服务中的实体——如果所有涉及的微服务都没有成功更新,可能会导致不一致性。这通常是不被接受的。这引出了下一章,我们将探讨为什么以及如何构建反应式微服务,即可扩展且健壮的微服务。

问题

  1. 基于实体和仓库的通用编程模型 Spring Data 可以用于不同类型的数据库引擎。从本章的源代码示例中,MySQL 和 MongoDB 的持久化代码最重要的区别是什么?

  2. 使用 Spring Data 实现乐观锁需要哪些要求?

  3. MapStruct 用于什么?

  4. 如果一个操作是幂等的,这意味着什么?为什么这很有用?

  5. 我们如何在不使用 API 的情况下访问存储在 MySQL 和 MongoDB 数据库中的数据?

第七章:开发反应式微服务

在本章中,我们将学习如何开发反应式微服务,即如何开发非阻塞同步 REST API 和异步事件驱动服务。我们还将学习如何在这两种选择之间进行选择。最后,我们将看到如何创建和运行反应式微服务景观的手动和自动化测试。

如在第一章微服务简介中已描述,反应式系统的基石是它们是消息驱动的——它们使用异步通信。这使得它们具有弹性,换句话说,可扩展性和弹性,意味着它们对失败具有容忍度。弹性和弹性共同使反应式系统能够做出响应。

本章将涵盖以下主题:

  • 在非阻塞同步 API 和事件驱动的异步服务之间进行选择

  • 开发非阻塞同步 REST API

  • 开发事件驱动的异步服务

  • 运行反应式微服务景观的手动测试

  • 运行反应式微服务景观的自动化测试

技术要求

关于如何安装本书中使用的工具以及如何访问本书源代码的说明,请参阅:

  • 第二十一章macOS 的安装说明

  • 第二十二章使用 WSL 2 和 Ubuntu 的 Microsoft Windows 安装说明

本章中的代码示例均来自 $BOOK_HOME/Chapter07 的源代码。

如果你想查看本章源代码中应用的变化,即查看使微服务反应式所需的内容,你可以将其与第六章添加持久性的源代码进行比较。你可以使用你喜欢的diff工具比较这两个文件夹,即 $BOOK_HOME/Chapter06$BOOK_HOME/Chapter07

在非阻塞同步 API 和事件驱动的异步服务之间进行选择

在开发反应式微服务时,并不总是明显何时使用非阻塞同步 API,何时使用事件驱动的异步服务。一般来说,为了使微服务健壮和可扩展,重要的是尽可能使其具有自主性,例如,通过最小化其运行时依赖。这也被称为松耦合。因此,事件的消息异步传递比同步 API 更可取。这是因为微服务将只依赖于运行时对消息系统的访问,而不是依赖于对多个其他微服务的同步访问。

然而,有一些情况下同步 API 可能是首选的。例如:

  • 对于需要等待响应的读取操作

  • 当客户端平台更适合消费同步 API 时,例如,移动应用或 SPA 网页应用

  • 当客户端将从其他组织连接到服务时——在这些组织中可能难以就跨组织使用的一个通用消息系统达成一致

对于本书中的系统景观,我们将使用以下内容:

  • 产品复合微服务公开的创建、读取和删除服务将基于非阻塞同步 API。复合微服务假定在 Web 和移动平台以及来自其他组织的客户端上都有客户端,而不是操作系统景观的客户端。因此,同步 API 看起来是一个自然的选择。

  • 核心微服务提供的读取服务也将被开发为非阻塞同步 API,因为最终用户正在等待它们的响应。

  • 核心微服务提供的创建和删除服务将被开发为事件驱动的异步服务,这意味着它们将监听每个微服务专属主题上的创建和删除事件。

  • 复合微服务提供的同步 API 用于创建和删除聚合产品信息,将在这些主题上发布创建和删除事件。如果发布操作成功,它将返回 202(已接受)响应;否则,将返回错误响应。202 响应与正常的 200(OK)响应不同——它表示请求已被接受,但尚未完全处理。相反,处理将在异步和独立于 202 响应的情况下完成。

如下图所示:

图形用户界面,图描述自动生成

图 7.1:微服务景观

首先,让我们学习如何开发非阻塞同步 REST API,然后我们将探讨如何开发事件驱动的异步服务。

开发非阻塞同步 REST API

在本节中,我们将学习如何开发读取 API 的非阻塞版本。复合服务将并行地对三个核心服务进行反应性(即非阻塞)调用。当复合服务从所有核心服务收到响应后,它将创建一个组合响应并将其发送回调用者。如下图所示:

图描述自动生成

图 7.2:景观中的 getCompositeProduct 部分

在本节中,我们将涵盖以下内容:

  • Project Reactor 简介

  • 使用 Spring Data for MongoDB 的非阻塞持久性

  • 核心服务中的非阻塞 REST API,包括如何处理基于 JPA 的持久层中的阻塞代码

  • 复合服务中的非阻塞 REST API

Project Reactor 简介

如我们在 第二章 中提到的 Spring WebFlux 部分,Spring Boot 简介,Spring 5 的响应式支持基于 Project Reactor (projectreactor.io)。Project Reactor 基于 Reactive Streams 规范 (www.reactive-streams.org),这是构建响应式应用程序的标准。Project Reactor 是基础的——它是 Spring WebFlux、Spring WebClient 和 Spring Data 依赖以提供其响应式和非阻塞功能的基础。

编程模型基于处理数据流,Project Reactor 的核心数据类型是 FluxMono。一个 Flux 对象用于处理 0...n 元素的流,一个 Mono 对象用于处理要么为空要么最多返回一个元素的流。我们将在本章中看到它们使用的许多示例。作为一个简短的介绍,让我们看看以下测试:

@Test
void testFlux() {
  List<Integer> list = Flux.just(1, 2, 3, 4)
    .filter(n -> n % 2 == 0)
    .map(n -> n * 2)
    .log()
    .collectList().block();
  assertThat(list).containsExactly(4, 8);
} 

下面是对前面源代码的解释:

  1. 我们使用静态辅助方法 Flux.just() 以整数 1234 初始化流。

  2. 接下来,我们 filter 出奇数——我们只允许偶数通过流。在这个测试中,这些是 24

  3. 接下来,我们将流中的值通过乘以 2 进行转换(或 map),因此它们变为 48

  4. 然后,我们在 map 操作之后 log 流中流动的数据。

  5. 我们使用 collectList 方法将流中的所有项目收集到一个 List 中,一旦流完成,就会发出一次。

  6. 到目前为止,我们只声明了流的处理。要实际获取处理的流,我们需要有人订阅它。对 block 方法的最终调用将注册一个等待处理完成的订阅者。

  7. 结果列表被保存在名为 list 的成员变量中。

  8. 我们现在可以使用 assertThat 方法来总结测试,断言流处理后的 list 包含预期的结果——整数 48

日志输出将如下所示:

文本描述自动生成

图 7.3:上述代码的日志输出

从前面的日志输出中,我们可以看到:

  1. 流的处理由一个订阅流并请求其内容的订阅者启动。

  2. 接下来,整数 48 通过 log 操作。

  3. 处理通过在订阅者上调用 onComplete 方法结束,通知它流已结束。

要查看完整的源代码,请参阅 util 项目中的 ReactorTests 测试类。

通常,我们不启动流处理。相反,我们只定义它将如何被处理,并且将由基础设施组件负责启动处理。例如,Spring WebFlux 将作为对传入 HTTP 请求的响应来执行此操作。这个规则的一个例外是,当阻塞代码需要从反应式流中获取响应时。在这些情况下,阻塞代码可以在 FluxMono 对象上调用 block() 方法以阻塞方式获取响应。

使用 Spring Data 为 MongoDB 实现非阻塞持久化

使 productrecommendation 服务的 MongoDB 基础仓库反应式化非常简单:

  • 将仓库的基类更改为 ReactiveCrudRepository

  • 将自定义查找方法更改为返回 MonoFlux 对象

修改后的 ProductRepositoryRecommendationRepository 如下所示:

public interface ProductRepository extends ReactiveCrudRepository <ProductEntity, String> {
    Mono<ProductEntity> findByProductId(int productId);
}
public interface RecommendationRepository extends ReactiveCrudRepository<RecommendationEntity, String> {
    Flux<RecommendationEntity> findByProductId(int productId);
} 

对于 review 服务的持久化代码没有应用任何更改;它将继续使用 JPA 仓库进行阻塞。有关如何在 review 服务的持久化层中处理阻塞代码的详细信息,请参阅以下部分,处理阻塞代码

对于完整源代码,请查看以下类:

  • ProductRepositoryproduct 项目中

  • RecommendationRepositoryrecommendation 项目中

测试代码的更改

当涉及到测试持久化层时,我们必须进行一些更改。由于我们的持久化方法现在返回一个 MonoFlux 对象,测试方法必须等待返回的反应式对象中响应可用。测试方法可以使用对 Mono/Flux 对象上的 block() 方法的显式调用等待响应可用,或者它们可以使用来自 Project Reactor 的 StepVerifier 辅助类声明一个可验证的异步事件序列。

让我们看看如何将以下测试代码更改为适用于仓库的反应式版本:

ProductEntity foundEntity = repository.findById(newEntity.getId()).get();
assertEqualsProduct(newEntity, foundEntity); 

我们可以在 repository.findById() 方法返回的 Mono 对象上使用 block() 方法,并保持命令式编程风格,如下所示:

ProductEntity foundEntity = repository.findById(newEntity.getId()).**block();**
assertEqualsProduct(newEntity, foundEntity); 

或者,我们可以使用 StepVerifier 类来设置一系列处理步骤,这些步骤既执行仓库查找操作,也验证结果。序列通过调用 verifyComplete() 方法初始化,如下所示:

**StepVerifier**.create(repository.findById(newEntity.getId()))
  .expectNextMatches(foundEntity -> areProductEqual(newEntity, foundEntity))
  .**verifyComplete**(); 

对于使用 StepVerifier 类的测试示例,请参阅 product 项目的 PersistenceTests 测试类。

对于使用 block() 方法的测试的相应示例,请参阅 recommendation 项目的 PersistenceTests 测试类。

核心服务中的非阻塞 REST API

在非阻塞持久化层就绪后,是时候使核心服务的 API 也非阻塞了。我们需要进行以下更改:

  • 修改 API 以使其仅返回反应式数据类型

  • 修改服务实现,使其不包含任何阻塞代码

  • 修改我们的测试,以便它们可以测试反应式服务

  • 处理阻塞代码 - 将仍然需要阻塞的代码与非阻塞代码隔离

API 的更改

为了使核心服务的 API 反应式,我们需要更新它们的方法,使它们返回MonoFlux对象。

例如,product服务中的getProduct()现在返回Mono<Product>而不是Product对象:

Mono<Product> getProduct(@PathVariable int productId); 

对于完整的源代码,请查看api项目中的以下core接口:

  • ProductService

  • RecommendationService

  • ReviewService

服务实现中的更改

对于productrecommendation项目中使用反应式持久化层的服务的实现,我们可以使用 Project Reactor 的流畅 API。例如,getProduct()方法的实现如下所示:

public **Mono**<Product> getProduct(int productId) {
    if (productId < 1) {
      throw new InvalidInputException("Invalid productId: " + productId);
    }
    return repository.**findByProductId(productId)**
        .switchIfEmpty(Mono.error(new **NotFoundException**("No product found
         for productId: " + productId)))
        .log(LOG.getName(), FINE)
        .map(e -> **mapper.entityToApi**(e))
        .map(e -> **setServiceAddress**(e));
} 

让我们看看代码做了什么:

  1. 此方法将返回一个Mono对象;处理在这里仅声明。处理由接收此服务请求的 Web 框架 Spring WebFlux 在订阅Mono对象时触发!

  2. 将使用持久化存储库中的findByProductId()方法从底层数据库中通过productId检索产品。

  3. 如果对于给定的productId没有找到产品,将抛出NotFoundException

  4. log方法将生成日志输出。

  5. 将调用mapper.entityToApi()方法将持久化层返回的实体转换为 API 模型对象。

  6. 最终的map方法将使用辅助方法setServiceAddress()来设置处理请求的微服务的 DNS 名称和 IP 地址,并将其存储在模型对象的serviceAddress字段中。

成功处理的一些示例日志输出如下:

文本描述自动生成

图 7.4:处理成功时的日志输出

以下是一个失败处理(抛出NotFoundException)的示例日志输出:

文本描述自动生成

图 7.5:处理失败时的日志输出

对于完整的源代码,请查看以下类:

  • product项目中的ProductServiceImpl

  • recommendation项目中的RecommendationServiceImpl

测试代码的更改

服务实现的测试代码已经按照我们之前描述的持久化层测试的方式进行了更改。为了处理反应式返回类型MonoFlux的异步行为,测试使用了调用block()方法和使用StepVerifier辅助类的方法组合。

对于完整的源代码,请查看以下测试类:

  • product项目中的ProductServiceApplicationTests

  • recommendation项目中的RecommendationServiceApplicationTests

处理阻塞代码

review服务的情况下,该服务使用 JPA 从关系型数据库中访问其数据,我们并没有支持非阻塞编程模型。相反,我们可以使用Scheduler来运行阻塞代码,这个Scheduler能够在一个具有有限线程数的专用线程池上运行阻塞代码。使用线程池来运行阻塞代码可以避免耗尽微服务中的可用线程,并且如果有的话,还可以避免影响微服务中的并发非阻塞处理。

让我们看看以下步骤如何设置:

  1. 首先,我们在主类ReviewServiceApplication中配置一个调度器 bean 及其线程池,如下所示:

    @Autowired
    public ReviewServiceApplication(
      @Value("${**app.threadPoolSize:10**}") Integer threadPoolSize,
      @Value("${**app.taskQueueSize:100**}") Integer taskQueueSize
    ) {
      this.threadPoolSize = threadPoolSize;
      this.taskQueueSize = taskQueueSize;
    }
    @Bean
    public Scheduler **jdbcScheduler**() {
      return Schedulers.newBoundedElastic(threadPoolSize,
        taskQueueSize, "jdbc-pool");
    } 
    

    从前面的代码中,我们可以看到调度器 bean 的名称是jdbcScheduler,并且我们可以使用以下属性来配置其线程池:

  • app.threadPoolSize,指定池中线程的最大数量;默认为10

  • app.taskQueueSize,指定允许放置在队列中等待可用线程的最大任务数;默认为100

  1. 接下来,我们将名为jdbcScheduler的调度器注入到review服务实现类中,如下所示:

    @RestController
    public class ReviewServiceImpl implements ReviewService {
      private final Scheduler jdbcScheduler;
      @Autowired
      public ReviewServiceImpl(
        **@Qualifier("jdbcScheduler")**
        **Scheduler jdbcScheduler**, ...) {
        this.jdbcScheduler = jdbcScheduler;
      } 
    
  2. 最后,我们在getReviews()方法的响应式实现中使用调度器的线程池,如下所示:

    @Override
    public Flux<Review> getReviews(int productId) {
      if (productId < 1) {
        throw new InvalidInputException("Invalid productId: " + 
          productId);
      }
      LOG.info("Will get reviews for product with id={}", 
        productId);
      return Mono.fromCallable(() -> internalGetReviews(productId))
        .flatMapMany(Flux::fromIterable)
        .log(LOG.getName(), FINE)
        .subscribeOn(jdbcScheduler);
    }
    private List<Review> internalGetReviews(int productId) {
      List<ReviewEntity> entityList = repository.
        findByProductId(productId);
      List<Review> list = mapper.entityListToApiList(entityList);
      list.forEach(e -> e.setServiceAddress(serviceUtil.
        getServiceAddress()));
      LOG.debug("Response size: {}", list.size());
      return list;
    } 
    

在这里,阻塞代码被放置在internalGetReviews()方法中,并使用Mono.fromCallable()方法包装在一个Mono对象中。getReviews()方法使用subscribeOn()方法在jdbcScheduler的线程池中的一个线程上运行阻塞代码。

当我们在本章后面运行测试时,我们可以查看review服务的日志输出,并看到 SQL 语句是在调度器的专用池中的线程上运行的证据。我们将能够看到如下日志输出:

文本描述自动生成

图 7.6:review服务的日志输出

从前面的日志输出中,我们可以看到以下内容:

  • 第一条日志输出来自getReviews()方法中的LOG.info()调用,它在名为ctor-http-nio-4的 HTTP 线程上执行,这是 WebFlux 使用的线程。

  • 在第二条日志输出中,我们可以看到由 Spring Data JPA 生成的 SQL 语句,底层使用 Hibernate。该 SQL 语句对应于repository.findByProductId()方法调用。它在名为jdbc-pool-1的线程上执行,这意味着它是在预期中的阻塞代码专用线程池中的线程上执行的!

对于完整的源代码,请参阅review项目中的ReviewServiceApplicationReviewServiceImpl类。

在处理阻塞代码的逻辑就绪后,我们就完成了核心服务中非阻塞 REST API 的实现。让我们继续看看如何也将组合服务中的 REST API 变为非阻塞。

组合服务中的非阻塞 REST API

要使组合服务中的 REST API 非阻塞,我们需要做以下操作:

  • 修改 API,使其操作仅返回反应式数据类型

  • 修改服务实现,使其以并行和非阻塞的方式调用核心服务的 API

  • 修改集成层,使其使用非阻塞 HTTP 客户端

  • 修改我们的测试,以便它们可以测试反应式服务

API 的变更

要使组合服务的 API 反应式,我们需要应用与之前应用于核心服务 API 相同的类型变更。这意味着getProduct()方法的返回类型ProductAggregate需要替换为Mono<ProductAggregate>

createProduct()deleteProduct()方法需要更新为返回Mono<Void>而不是void;否则,我们无法将任何错误响应传播回 API 的调用者。

对于完整的源代码,请参阅api项目中的ProductCompositeService接口。

服务实现的变更

为了能够并行调用三个 API,服务实现使用了Mono类的静态zip()方法。zip方法能够处理多个并发的反应式请求,并在所有请求都完成后将它们压缩在一起。代码如下:

@Override
public Mono<ProductAggregate> getProduct(int productId) {
  return **Mono.zip**(

    **values** -> **createProductAggregate**(
      (Product) values[0], 
      (List<Recommendation>) values[1], 
      (List<Review>) values[2], 
      serviceUtil.getServiceAddress()),

    integration.getProduct(productId),
    integration.getRecommendations(productId).collectList(),
    integration.getReviews(productId).collectList())

    .doOnError(ex -> 
      LOG.warn("getCompositeProduct failed: {}", 
      ex.toString()))
    .log(LOG.getName(), FINE);
} 

让我们更详细地看看:

  • zip方法的第一个参数是一个 lambda 函数,它将接收一个名为values的数组中的响应。该数组将包含一个产品、一个推荐列表和一个评论列表。从三个 API 调用中收集响应的实际聚合操作由与之前相同的辅助方法createProductAggregate()处理,没有任何变化。

  • lambda 函数后面的参数是zip方法将并行调用的请求列表,每个请求一个Mono对象。在我们的情况下,我们发送了三个由集成类中的方法创建的Mono对象,每个对象对应于发送到每个核心微服务的每个请求。

对于完整的源代码,请参阅product-composite项目中的ProductCompositeServiceImpl类。

关于如何在product-composite服务中实现createProductdeleteProduct API 操作的信息,请参阅后面的在组合服务中发布事件部分。

集成层的变更

ProductCompositeIntegration集成类中,我们将阻塞的 HTTP 客户端RestTemplate替换为 Spring 5 提供的非阻塞 HTTP 客户端WebClient

要创建WebClient实例,使用的是建造者模式。如果需要定制,例如设置公共头或过滤器,可以使用建造者来完成。有关可用的配置选项,请参阅docs.spring.io/spring/docs/current/spring-framework-reference/web-reactive.html#webflux-client-builder

WebClient的使用方式如下:

  1. 在构造函数中,WebClient是自动注入的。我们构建WebClient实例而不进行任何配置:

    public class ProductCompositeIntegration implements ProductService, RecommendationService, ReviewService {
        private final WebClient webClient;
        @Autowired
        public ProductCompositeIntegration(
            **WebClient.Builder webClient**, ...
        ) {
            this.webClient = webClient.build();
        } 
    
  2. 接下来,我们使用webClient实例来对我们的product服务进行非阻塞请求:

    @Override
    public Mono<Product> getProduct(int productId) {
      String url = productServiceUrl + "/product/" + productId;
      return **webClient**.get().uri(url).retrieve()
        .bodyToMono(Product.class)
        .log(LOG.getName(), FINE)
        .**onErrorMap**(WebClientResponseException.class, 
          ex -> handleException(ex)
        );
    } 
    

如果对product服务的 API 调用失败并返回 HTTP 错误响应,整个 API 请求将失败。WebClient中的onErrorMap()方法将调用我们的handleException(ex)方法,该方法将 HTTP 层抛出的 HTTP 异常映射到我们自己的异常,例如NotFoundExceptionInvalidInputException

然而,如果对product服务的调用成功,但对recommendationreview API 的调用失败,我们不想让整个请求失败。相反,我们希望将尽可能多的信息返回给调用者。因此,在这些情况下,我们不会传播异常,而是返回一个空的推荐或评论列表。为了抑制错误,我们将调用onErrorResume(error -> empty())。为此,代码如下所示:

@Override
public Flux<Recommendation> getRecommendations(int productId) {
  String url = recommendationServiceUrl + "/recommendation?
  productId=" + productId;
  // Return an empty result if something goes wrong to make it 
  // possible for the composite service to return partial responses
  return webClient.get().uri(url).retrieve()
    .bodyToFlux(Recommendation.class)
    .log(LOG.getName(), FINE)
    .**onErrorResume(error -> empty());**
} 

来自util项目的GlobalControllerExceptionHandler类,将像之前一样捕获异常并将它们转换为适当的 HTTP 错误响应,这些响应将发送回复合 API 的调用者。这样我们就可以决定来自底层 API 调用的特定 HTTP 错误响应是否会引发 HTTP 错误响应或只是一个部分为空的响应。

要查看完整的源代码,请参阅product-composite项目中的ProductCompositeIntegration类。

测试代码中的更改

在测试类中需要做的唯一更改是更新 Mockito 及其对集成类的模拟的设置。模拟需要返回MonoFlux对象。setup()方法使用辅助方法Mono.just()Flux.fromIterable(),如下面的代码所示:

class ProductCompositeServiceApplicationTests {
    @BeforeEach
    void setUp() {
        when(compositeIntegration.getProduct(PRODUCT_ID_OK)).
            thenReturn(**Mono.just**(new Product(PRODUCT_ID_OK, "name", 1,
             "mock-address")));
        when(compositeIntegration.getRecommendations(PRODUCT_ID_OK)).
            thenReturn(**Flux.fromIterable**(singletonList(new 
             Recommendation(PRODUCT_ID_OK, 1, "author", 1, "content",
             "mock address"))));
        when(compositeIntegration.getReviews(PRODUCT_ID_OK)).
            thenReturn(**Flux.fromIterable**(singletonList(new
             Review(PRODUCT_ID_OK, 1, "author", "subject", "content",
             "mock address")))); 

要查看完整的源代码,请参阅product-composite项目中的ProductCompositeServiceApplicationTests测试类。

这完成了我们非阻塞同步 REST API 的实现。现在,是时候开发我们的事件驱动异步服务了。

开发事件驱动的异步服务

在本节中,我们将学习如何开发创建和删除服务的事件驱动和异步版本。组合服务将在每个核心服务主题上发布创建和删除事件,然后向调用者返回一个 OK 响应,而不必等待核心服务中的处理完成。这如下面的图示所示:

图示 描述自动生成

图 7.7:创建组合产品和删除组合产品景观的 createCompositeProduct 和 deleteCompositeProduct 部分

我们将涵盖以下主题:

  • 处理消息挑战

  • 定义主题和事件

  • Gradle 构建文件中的更改

  • 在核心服务中消费事件

  • 在组合服务中发布事件

处理消息挑战

为了实现事件驱动的创建和删除服务,我们将使用 Spring Cloud Stream。在 第二章Spring Boot 简介 中,我们已经看到使用 Spring Cloud Stream 在主题上发布和消费消息是多么容易。

编程模型基于函数式范式,其中实现 java.util.function 包中 SupplierFunctionConsumer 之一的功能接口的函数可以链接在一起以执行解耦的事件驱动处理。要从非功能代码外部触发基于功能的处理,可以使用辅助类 StreamBridge

例如,要将 HTTP 请求的正文发布到主题,我们只需编写以下代码:

@Autowired
private StreamBridge streamBridge;
@PostMapping
void sampleCreateAPI(@RequestBody String body) {
  **streamBridge.send**("topic", body);
} 

辅助类 StreamBridge 用于触发处理。它将在主题上发布一条消息。可以通过实现 java.util.function.Consumer 功能接口来定义一个从主题消费事件(不创建新事件)的函数,如下所示:

@Bean
public Consumer<String> mySubscriber() {
   return s -> System.out.println("ML RECEIVED: " + s);
} 

为了将各种功能结合起来,我们使用配置。我们将在下面的部分 添加发布事件的配置添加消费事件的配置 中看到此类配置的示例。

此编程模型可以独立于所使用的消息系统使用,例如 RabbitMQ 或 Apache Kafka!

尽管发送异步消息比同步 API 调用更受欢迎,但它也带来了自己的挑战。我们将看到如何使用 Spring Cloud Stream 来处理其中的一些。Spring Cloud Stream 将涵盖以下功能:

  • 消费者组

  • 重试和死信队列

  • 保证订单和分区

我们将在以下章节中研究这些内容。

消费者组

这里的问题是,如果我们增加消息消费者实例的数量,例如,如果我们启动两个 product 微服务的实例,这两个 product 微服务的实例都将消费相同的消息,如下面的图示所示:

图示 描述自动生成

图 7.8:产品 #1 和 #2 消费相同的消息

这可能导致一条消息被处理两次,从而在数据库中可能导致重复或其他不希望的不一致性。因此,我们只想让每个消费者实例处理每条消息。这可以通过引入一个消费者组来解决,如下面的图所示:

图描述自动生成

图 7.9:消费者组

在 Spring Cloud Stream 中,消费者组可以在消费者端进行配置。例如,对于product微服务,它将看起来像这样:

spring.cloud.stream:
  bindings.messageProcessor-in-0:
    destination: products
    group: productsGroup 

从这个配置中,我们可以了解到以下内容:

  • Spring Cloud Stream 默认应用了一个命名约定,将配置绑定到函数。对于发送到函数的消息,绑定名称是<functionName>-in-<index>

    • functionName是函数的名称,在前面的示例中为messageProcessor

    • index被设置为0,除非函数需要多个输入或输出参数。我们不会使用多参数函数,所以在我们的示例中index将始终设置为0

    • 对于出站消息,绑定名称约定是<functionName>-out-<index>

  • destination属性指定了消息将被消费的主题名称,在本例中为products

  • group属性指定了要将product微服务的实例添加到哪个消费者组,在本例中为productsGroup。这意味着发送到products主题的消息将只由 Spring Cloud Stream 交付给product微服务的某个实例。

重试和死信队列

如果消费者无法处理消息,它可能会被重新排队给失败消费者,直到成功处理。如果消息的内容无效,也称为毒消息,该消息将阻止消费者处理其他消息,直到手动删除。如果失败是由于临时问题,例如,由于临时网络错误数据库无法访问,经过几次重试后处理可能会成功。

必须能够指定将消息移动到另一个存储进行故障分析和纠正的重试次数。一个失败的消息通常会被移动到一个称为死信队列的专用队列。为了避免在临时故障期间(例如,网络错误)过载基础设施,必须能够配置重试的频率,最好是在每次重试之间增加时间间隔。

在 Spring Cloud Stream 中,这可以在消费者端进行配置,例如,对于product微服务,如下所示:

spring.cloud.stream.bindings.messageProcessor-in-0.consumer:
  **maxAttempts:**3
  **backOffInitialInterval:**500
  **backOffMaxInterval:**1000
  backOffMultiplier: 2.0
spring.cloud.stream.rabbit.bindings.messageProcessor-in-0.consumer:
  autoBindDlq: true
  republishToDlq: true
spring.cloud.stream.kafka.bindings.messageProcessor-in-0.consumer:
  enableDlq: true 

在前面的示例中,我们指定 Spring Cloud Stream 在将消息放入死信队列之前应该进行3次重试。第一次重试将在500毫秒后尝试,其他两次尝试将在1000毫秒后。

启用死信队列的使用是绑定特定的;因此,我们为 RabbitMQ 和 Kafka 分别有一个配置。

保证顺序和分区

如果业务逻辑要求消息的消费和处理顺序与发送顺序相同,我们不能为每个消费者使用多个实例来提高处理性能;例如,我们不能使用消费者组。这可能在某些情况下导致处理传入消息的延迟不可接受。

我们可以使用分区来确保消息按照发送顺序交付,同时不损失性能和可伸缩性。

在大多数情况下,对消息处理的严格顺序仅适用于影响相同业务实体的消息。例如,影响产品 ID 为1的产品消息,在许多情况下可以独立于影响产品 ID 为2的产品消息进行处理。这意味着只需要保证具有相同产品 ID 的消息的顺序。

解决这个问题的方法是使每个消息都可以指定一个,消息系统可以使用它来保证具有相同键的消息之间的顺序。这可以通过在主题中引入子主题,也称为分区来实现。消息系统根据其键将消息放置在特定的分区中。

具有相同键的消息总是放置在同一个分区中。消息系统只需要保证同一分区中消息的交付顺序。为了确保消息的顺序,我们在消费者组内为每个分区配置一个消费者实例。通过增加分区的数量,我们可以允许消费者增加其实例数量。这增加了其消息处理性能,同时不丢失交付顺序。这在下图中得到说明:

图描述自动生成

图 7.10:指定消息的键

如前图所示,所有将Key设置为123的消息总是发送到Products-1分区,而将Key设置为456的消息则发送到Products-2分区。

在 Spring Cloud Stream 中,这需要在发布者和消费者两端进行配置。在发布者端,必须指定键和分区数。例如,对于product-composite服务,我们有以下配置:

spring.cloud.stream.bindings.products-out-0.producer:
  partition-key-expression: headers[**'partitionKey'**]
  partition-count: 2 

此配置意味着键将从名为partitionKey的消息头中提取,并且将使用两个分区。

每个消费者可以指定它想要从哪个分区消费消息。例如,对于product微服务,我们有以下配置:

spring.cloud.stream.bindings.messageProcessor-in-0:
  destination: products
  group:productsGroup
  consumer:
    partitioned: true
    **instance-index:****0** 

此配置告诉 Spring Cloud Stream,此消费者将只从分区号0(即第一个分区)消费消息。

定义主题和事件

正如我们在第二章“Spring Boot 简介”中的Spring Cloud Stream部分所提到的,Spring Cloud Stream 基于发布和订阅模式,其中发布者向主题发布消息,而订阅者订阅他们感兴趣接收消息的主题。

我们将为每种实体类型使用一个主题productsrecommendationsreviews

消息系统处理消息,这些消息通常由头和正文组成。一个事件是描述已发生某事的邮件。对于事件,消息正文可以用来描述事件类型、事件数据和事件发生的时间戳。

在本书的范围内,事件被定义为以下内容:

  • 事件的类型,例如,创建或删除事件

  • 一个用于标识数据,例如,产品 ID

  • 一个数据元素,即事件中的实际数据

  • 一个时间戳,描述事件发生的时间

我们将要使用的事件类如下所示:

public class **Event****<K, T>** {
    public enum Type {**CREATE, DELETE**}
    private Event.Type eventType;
    private K key;
    private T data;
    private ZonedDateTime eventCreatedAt;
    public Event() {
        this.eventType = null;
        this.key = null;
        this.data = null;
        this.eventCreatedAt = null;
    }
    public Event(Type eventType, K key, T data) {
        this.eventType = eventType;
        this.key = key;
        this.data = data;
        this.eventCreatedAt = now();
    }
    public Type getEventType() {
        return eventType;
    }
    public K getKey() {
        return key;
    }
    public T getData() {
        return data;
    }
    public ZonedDateTime getEventCreatedAt() {
        return eventCreatedAt;
    }
} 

让我们详细解释前面的源代码:

  • Event类是一个泛型类,其keydata字段由KT类型参数化

  • 事件类型被声明为一个枚举,具有允许的值,即CREATEDELETE

  • 该类定义了两个构造函数,一个是空的,另一个可以用来初始化类型、键和值成员

  • 最后,该类为其成员变量定义了 getter 方法

对于完整的源代码,请参阅api项目中的Event类。

Gradle 构建文件中的更改

要引入 Spring Cloud Stream 及其 RabbitMQ 和 Kafka 的绑定器,我们需要添加两个名为spring-cloud-starter-stream-rabbitspring-cloud-starter-stream-kafka的启动依赖项。我们还需要在product-composite项目中添加一个测试依赖项spring-cloud-stream::test-binder,以引入测试支持。以下代码显示了这一点:

dependencies {
  implementation 'org.springframework.cloud:spring-cloud-starter-stream-rabbit'
  implementation 'org.springframework.cloud:spring-cloud-starter-stream-kafka'
  testImplementation 'org.springframework.cloud:spring-cloud-stream::test-binder'
} 

要指定我们想要使用的 Spring Cloud 版本,我们首先声明一个用于版本的变量:

ext {
    springCloudVersion = "2022.0.1"
} 

接下来,我们使用变量来设置指定 Spring Cloud 版本的依赖管理,如下所示:

dependencyManagement {
    imports {
        mavenBom "org.springframework.cloud:spring-cloud-
        dependencies:${springCloudVersion}"
    }
} 

对于完整的源代码,请参阅每个微服务项目的build.gradle构建文件。

在 Gradle 构建文件中添加所需的依赖项后,我们可以开始学习如何在核心服务中消费事件。

在核心服务中消费事件

为了能够在核心服务中消费事件,我们需要做以下事情:

  • 声明消费核心服务主题上发布的事件的消息处理器

  • 将我们的服务实现更改为使用反应式持久化层

  • 添加消费事件所需的配置

  • 修改我们的测试,以便它们可以测试事件的异步处理

消费事件的源代码在所有三个核心服务中结构相同,因此我们只需查看product服务的源代码。

声明消息处理器

创建和删除实体的 REST API 已被每个核心微服务中的消息处理器所取代,这些微服务消费每个实体主题上的创建和删除事件。为了能够消费已发布到主题的消息,我们需要声明一个实现功能接口java.util.function.Consumer的 Spring Bean。

product服务的消息处理器声明如下:

**@Configuration**
public class MessageProcessorConfig {
  private final ProductService productService;
  @Autowired
  public MessageProcessorConfig(**ProductService productService**) 
  {
    this.productService = productService;
  }
  @Bean
  public Consumer<Event<Integer,Product>> **messageProcessor**() {
    ... 

从前面的代码中,我们可以看到:

  • 该类使用@Configuration注解,告诉 Spring 在该类中查找 Spring Bean。

  • 我们在构造函数中注入了ProductService接口的实现。productService豆包含执行实际创建和删除产品实体的业务逻辑。

  • 我们将消息处理器声明为一个实现功能接口Consumer的 Spring Bean,接受一个类型为Event<Integer,Product>的事件作为输入参数。

Consumer函数的实现如下所示:

return **event** -> {
  switch (event.getEventType()) {
    case CREATE:
      Product product = event.getData();
      **productService.createProduct**(product).block();
      break;
    case DELETE:
      int productId = event.getKey();
      **productService.deleteProduct**(productId).block();
      break;
    default:
      String errorMessage = "Incorrect event type: " + 
        event.getEventType() + 
        ", expected a CREATE or DELETE event";
      **throw****new****EventProcessingException**(errorMessage);
  }
}; 

前面的实现执行以下操作:

  • 它接受一个类型为Event<Integer,Product>的事件作为输入参数

  • 使用switch语句,根据事件类型,它将创建或删除产品实体

  • 它使用注入的productService豆来执行实际的创建和删除操作

  • 如果事件类型既不是创建也不是删除,将会抛出异常

为了确保我们可以将productService豆抛出的异常传播回消息系统,我们在从productService豆获取的响应上调用block()方法。这确保了消息处理器等待productService豆在底层数据库中完成创建或删除操作。如果不调用block()方法,我们就无法传播异常,消息系统也无法重新排队失败尝试或将消息移动到死信队列;相反,消息将被静默丢弃。

从性能和可扩展性的角度来看,调用block()方法通常被认为是一种不良实践。但在此情况下,我们只会并行处理少量接收到的消息,每个分区一个,如上所述。这意味着我们只会同时阻塞少量线程,这不会对性能或可扩展性产生负面影响。

对于完整的源代码,请参阅productrecommendationreview项目中的MessageProcessorConfig类。

服务实现中的更改

productrecommendation服务的创建和删除方法的服务实现已被重写,以使用非阻塞的 MongoDB 反应持久层。例如,创建产品实体如下所示:

@Override
public Mono<Product> createProduct(Product body) {
  if (body.getProductId() < 1) {
    throw new InvalidInputException("Invalid productId: " + 
      body.getProductId());
  }
  ProductEntity entity = mapper.apiToEntity(body);
  Mono<Product> newEntity = repository.save(entity)
    .log(LOG.getName(), FINE)
    .**onErrorMap**(
      DuplicateKeyException.class,
      ex -> new InvalidInputException
        ("Duplicate key, Product Id: " + body.getProductId()))
    .map(e -> mapper.entityToApi(e));
  return newEntity;
} 

注意,前面的代码中使用了onErrorMap()方法将DuplicateKeyException持久化异常映射到我们自己的InvalidInputException异常。

对于使用 JPA 的阻塞持久化层的review服务,创建和删除方法已经按照处理阻塞代码部分中描述的方式进行了更新。

对于完整的源代码,请参阅以下类:

  • ProductServiceImpl位于product项目中

  • RecommendationServiceImpl位于recommendation项目中

  • ReviewServiceImpl位于review项目中

添加消费事件的配置

我们还需要为消息系统设置一个配置,以便能够消费事件。为此,我们需要完成以下步骤:

  1. 我们声明 RabbitMQ 是默认的消息系统,默认的内容类型是 JSON:

    spring.cloud.stream:
      defaultBinder: rabbit
      default.contentType: application/json 
    
  2. 接下来,我们将输入绑定到具有特定主题名称的消息处理器,如下所示:

    spring.cloud.stream:
      bindings.messageProcessor-in-0:
        destination: products 
    
  3. 最后,我们声明了 Kafka 和 RabbitMQ 的连接信息:

    spring.cloud.stream.kafka.binder:
      brokers: 127.0.0.1
      defaultBrokerPort: 9092
    spring.rabbitmq:
      host: 127.0.0.1
      port: 5672
      username: guest
      password: guest
    ---
    spring.config.activate.on-profile: docker
    spring.rabbitmq.host: rabbitmq
    spring.cloud.stream.kafka.binder.brokers: kafka 
    

在默认的 Spring 配置文件中,当我们不使用 Docker 在localhost上运行我们的系统架构,并使用 IP 地址127.0.0.1时,我们指定了要使用的主机名。在docker Spring 配置文件中,我们指定了在 Docker 和 Docker Compose 中运行时将使用的主机名,即rabbitmqkafka

在此配置中添加了消费者配置,它还指定了消费者组、重试处理、死信队列和分区,正如在处理消息挑战部分中之前所描述的。

对于完整的源代码,请参阅productrecommendationreview项目中的application.yml配置文件。

测试代码中的更改

由于核心服务现在接收创建和删除其实体的事件,因此测试需要更新,以便它们发送事件而不是调用 REST API,就像在上一章中做的那样。为了能够从测试类中调用消息处理器,我们需要将消息处理器 bean 注入到一个成员变量中:

@SpringBootTest
class ProductServiceApplicationTests {
  @Autowired
  **@Qualifier("messageProcessor")**
  private Consumer<Event<Integer, Product>> messageProcessor; 

从前面的代码中,我们可以看到我们不仅注入了任何Consumer函数,还使用了@Qualifier注解来指定我们想要注入名为messageProcessorConsumer函数。

要将创建和删除事件发送到消息处理器,我们在测试类中添加了两个辅助方法,sendCreateProductEventsendDeleteProductEvent

 private void **sendCreateProductEvent**(int productId) {
    Product product = new Product(productId, "Name " + productId, productId, "SA");
    Event<Integer, Product> event = new Event(CREATE, productId, product);
    messageProcessor.**accept**(event);
  }
  private void **sendDeleteProductEvent**(int productId) {
    Event<Integer, Product> event = new Event(DELETE, productId, null);
    messageProcessor.**accept**(event);
  } 

注意,我们在Consumer函数接口声明中使用accept()方法来调用消息处理器。这意味着我们在测试中跳过了消息系统,直接调用消息处理器。

创建和删除实体的测试已更新,以使用这些辅助方法。

对于完整的源代码,请参阅以下测试类:

  • ProductServiceApplicationTests位于product项目中

  • RecommendationServiceApplicationTests位于recommendation项目中

  • ReviewServiceApplicationTests位于review项目中

我们已经看到了在核心微服务中消费事件所需的内容。现在让我们看看我们如何在组合微服务中发布事件。

在组合服务中发布事件

当组合服务接收到创建和删除组合产品的 HTTP 请求时,它将向核心服务在其主题上发布相应的事件。为了能够在组合服务中发布事件,我们需要执行以下步骤:

  1. 在集成层发布事件

  2. 添加发布事件的配置

  3. 修改测试用例,以便它们可以测试事件的发布

注意,在组合服务实现类中不需要进行任何更改——这是由集成层处理的!

在集成层发布事件

要在集成层发布事件,我们需要:

  1. 根据 HTTP 请求正文创建一个Event对象

  2. 创建一个Message对象,其中使用Event对象作为有效负载,并将Event对象中的键字段用作头部的分区键

  3. 使用辅助类StreamBridge在所需主题上发布事件

发送创建产品事件的代码如下:

 @Override
  public Mono<Product> createProduct(Product body) {
    return Mono.fromCallable(() -> {
      sendMessage("products-out-0", 
        new Event(CREATE, body.getProductId(), body));
      return body;
    }).subscribeOn(publishEventScheduler);
  }
  private void sendMessage(String bindingName, Event event) {
    Message message = MessageBuilder.withPayload(event)
      .setHeader("partitionKey", event.getKey())
      .build();
    **streamBridge**.send(bindingName, message);
  } 

在前面的代码中,我们可以看到:

  • 集成层通过使用辅助方法sendMessage()ProductService接口中实现createProduct()方法。辅助方法接受输出绑定的名称和事件对象。以下配置中,绑定名称products-out-0将被绑定到product服务的主题。

  • 由于sendMessage()使用阻塞代码,当调用streamBridge时,它将在由专用调度器publishEventScheduler提供的线程上执行。这与在review微服务中处理阻塞 JPA 代码的方法相同。有关详细信息,请参阅处理阻塞代码部分。

  • 辅助方法sendMessage()创建一个Message对象,并设置如上所述的payloadpartitionKey头。最后,它使用streamBridge对象将事件发送到消息系统,该系统将在配置中定义的主题上发布它。

要查看完整源代码,请参阅product-composite项目中的ProductCompositeIntegration类。

添加发布事件的配置

我们还需要设置消息系统的配置,以便能够发布事件;这与我们为消费者所做的类似。将 RabbitMQ 声明为默认消息系统,JSON 作为默认内容类型,以及 Kafka 和 RabbitMQ 作为连接信息与消费者相同。

为了声明应该用于输出绑定名称的主题,我们有以下配置:

spring.cloud.stream:
  bindings:
    products-out-0:
      destination: products
    recommendations-out-0:
      destination: recommendations
    reviews-out-0:
      destination: reviews 

当使用分区时,我们还需要指定分区键和将要使用的分区数量:

spring.cloud.stream.bindings.**products-out-**0.producer:
  partition-key-expression: headers[**'partitionKey'**]
  partition-count: 2 

在前面的配置中,我们可以看到:

  • 该配置适用于绑定名称products-out-0

  • 将使用的分区键将从消息头partitionKey中获取

  • 将使用两个分区

要查看完整源代码,请参阅product-composite项目中的application.yml配置文件。

测试代码的更改

由于其本质,测试异步事件驱动的微服务是困难的。测试通常需要以某种方式同步于异步的后台处理,以便能够验证结果。Spring Cloud Stream 提供了一个测试绑定器,可以在测试期间不使用任何消息系统来验证已发送的消息!

关于如何在product-composite项目中包含测试支持,请参阅前面提到的Gradle 构建文件中的更改部分。

测试支持包括一个OutputDestination辅助类,它可以用来获取测试期间发送的消息。已添加一个新的测试类MessagingTests,用于运行验证预期消息是否被发送的测试。让我们来看看测试类中最重要的一部分:

  1. 为了能够在测试类中注入OutputDestination豆,我们还需要从TestChannelBinderConfiguration类中引入其配置。这是通过以下代码完成的:

    @SpringBootTest
    **@Import({TestChannelBinderConfiguration.class})**
    class MessagingTests {
      @Autowired
      private **OutputDestination target**; 
    
  2. 接下来,我们声明了一些用于读取消息和清除主题的辅助方法。代码看起来是这样的:

    private void **purgeMessages**(String bindingName) {
      getMessages(bindingName);
    }
    private List<String> **getMessages**(String bindingName){
      List<String> messages = new ArrayList<>();
      boolean anyMoreMessages = true;
      while (anyMoreMessages) {
        Message<byte[]> message = 
          getMessage(bindingName);
        if (message == null) {
          anyMoreMessages = false;
        } else {
          messages.add(new String(message.getPayload()));
        }
      }
      return messages;
    }
    private Message<byte[]> **getMessage**(String bindingName){
      try {
        return target.receive(0, bindingName);
      } catch (NullPointerException npe) {
        LOG.error("getMessage() received a NPE with binding = {}", bindingName);
        return null;
      }
    } 
    

    从前面的代码中,我们可以看到:

  • getMessage()方法使用名为targetOutputDestination豆返回指定主题的消息。

  • getMessages()方法使用getMessage()方法返回主题中的所有消息。

  • purgeMessages()方法使用getMessages()方法从所有当前消息中清除一个主题。

  1. 每个测试都以使用带有@BeforeEach注解的setup()方法清除所有参与测试的主题开始:

     @BeforeEach
      void setUp() {
        purgeMessages("products");
        purgeMessages("recommendations");
        purgeMessages("reviews");
      } 
    
  2. 实际测试可以使用getMessages()方法验证主题中的消息。例如,查看以下创建组合产品的测试:

    @Test
    void createCompositeProduct1() {
      ProductAggregate composite = new ProductAggregate(1, "name", 1, null, null, null);
      **postAndVerifyProduct**(composite, ACCEPTED);
      final List<String> productMessages = **getMessages**("products");
      final List<String> recommendationMessages = **getMessages**("recommendations");
      final List<String> reviewMessages = **getMessages**("reviews");
      // Assert one expected new product event queued up
      **assertEquals(**1, productMessages.size());
      Event<Integer, Product> expectedEvent =
        new Event(CREATE, composite.getProductId(), new Product(composite.getProductId(), composite.getName(), composite.getWeight(), null));
      assertThat(productMessages.get(0), is(**sameEventExceptCreatedAt**(expectedEvent)));
      // Assert no recommendation and review events
      **assertEquals(****0**, recommendationMessages.size());
      **assertEquals(****0**, reviewMessages.size());
    } 
    

从前面的代码中,我们可以看到一个测试的例子:

  1. 首先发起一个 HTTP POST 请求,请求创建一个组合产品。

  2. 接下来,从三个主题中获取所有消息,每个主题对应一个底层核心服务。

  3. 对于这些测试,事件创建的具体时间戳无关紧要。为了能够比较实际事件与预期事件,忽略eventCreatedAt字段中的差异,可以使用一个名为IsSameEvent的辅助类。sameEventExceptCreatedAt()方法是IsSameEvent类中的一个静态方法,它比较Event对象,如果所有字段都相等(除了eventCreatedAt字段),则将它们视为相等。

  4. 最后,它验证了预期的事件可以被找到,且没有其他事件。

要查看完整的源代码,请参阅product-composite项目中的测试类MessagingTestsIsSameEvent

运行反应式微服务景观的手动测试

现在,我们拥有了完全反应式的微服务,无论是非阻塞同步 REST API 还是事件驱动的异步服务。让我们试试它们吧!

我们将学习如何使用 RabbitMQ 和 Kafka 作为消息代理来运行测试。由于 RabbitMQ 可以与或不与分区一起使用,我们将测试这两种情况。将使用三种不同的配置,每个配置定义在一个单独的 Docker Compose 文件中:

  • 不使用分区使用 RabbitMQ

  • 使用 RabbitMQ 时,每个主题有两个分区

  • 使用 Kafka 时,每个主题有两个分区

然而,在测试这三个配置之前,我们需要添加两个功能以便能够测试异步处理:

  • 在使用 RabbitMQ 时保存事件以供稍后检查

  • 一个可以用来监控微服务景观状态的健康 API

保存事件

在对事件驱动的异步服务进行了一些测试之后,查看实际发送了哪些事件可能很有趣。当使用 Spring Cloud Stream 与 Kafka 结合时,事件在主题中保留,即使消费者已经处理了它们。然而,当使用 Spring Cloud Stream 与 RabbitMQ 结合时,事件在成功处理后会被移除。

为了能够看到每个主题上已发布的哪些事件,Spring Cloud Stream 被配置为将发布的事件保存到每个主题的单独消费者组auditGroup中。对于products主题,配置如下所示:

spring.cloud.stream:
  bindings:
    products-out-0:
      destination: products
      producer:
        **required-groups:****auditGroup** 

当使用 RabbitMQ 时,这将导致创建额外的队列来存储事件,以便稍后检查。

对于完整的源代码,请参阅product-composite项目中的application.yml配置文件。

添加健康 API

测试一个使用同步 API 和异步消息组合的微服务系统景观具有挑战性。

例如,我们如何知道一个新启动的微服务景观,包括其数据库和消息系统,何时准备好处理请求和消息?

为了更容易知道所有微服务何时就绪,我们已向微服务中添加了健康 API。这些健康 API 基于 Spring Boot 模块Actuator提供的健康端点支持。默认情况下,基于 Actuator 的健康端点如果微服务本身以及 Spring Boot 所知的所有依赖都可用,则响应UP(并返回 HTTP 状态码 200)。Spring Boot 所知的依赖包括数据库和消息系统。如果微服务本身或其任何依赖不可用,健康端点将响应DOWN(并返回 HTTP 状态码 500)。

我们还可以扩展健康端点以覆盖 Spring Boot 未知的依赖项。我们将使用此功能扩展到产品组合的health端点,使其也包含三个核心服务的健康状态。这意味着产品组合health端点只有在自身和三个核心微服务都健康时才会响应UP。这可以通过test-em-all.bash脚本的自动或手动方式来使用,以找出所有微服务和它们的依赖项何时都处于运行状态。

ProductCompositeIntegration类中,我们添加了检查三个核心微服务健康的辅助方法,如下所示:

public Mono<Health> getProductHealth() {
    return getHealth(productServiceUrl);
}
public Mono<Health> getRecommendationHealth() {
    return getHealth(recommendationServiceUrl);
}
public Mono<Health> getReviewHealth() {
    return getHealth(reviewServiceUrl);
}
private Mono<Health> getHealth(String url) {
    url += "/actuator/health";
    LOG.debug("Will call the Health API on URL: {}", url);
    return webClient.get().uri(url).retrieve().bodyToMono(String.class)
        .map(s -> new Health.Builder().up().build())
        .onErrorResume(ex -> Mono.just(new 
         Health.Builder().down(ex).build()))
        .log(LOG.getName(), FINE);
} 

这段代码与我们之前用来调用核心服务读取 API 的代码类似。请注意,健康端点默认设置为/actuator/health

对于完整源代码,请参阅product-composite项目中的ProductCompositeIntegration类。

在配置类HealthCheckConfiguration中,我们使用这些辅助方法通过 Spring Actuator 类CompositeReactiveHealthContributor注册一个组合健康检查:

@Configuration
public class HealthCheckConfiguration {
  @Autowired
  ProductCompositeIntegration integration; 
  @Bean
  ReactiveHealthContributor coreServices() {
    final Map<String, ReactiveHealthIndicator> registry = new LinkedHashMap<>();
    registry.put("product", () -> integration.getProductHealth());
    registry.put("recommendation", () -> integration.getRecommendationHealth());
    registry.put("review", () -> integration.getReviewHealth());
    return CompositeReactiveHealthContributor.fromMap(registry);
  }
} 

对于完整源代码,请参阅product-composite项目中的HealthCheckConfiguration类。

最后,在所有四个微服务的application.yml配置文件中,我们配置 Spring Boot Actuator 以执行以下操作:

  • 显示有关健康状态的所有详细信息,这不仅包括UPDOWN,还包括其依赖项的信息

  • 通过 HTTP 暴露所有其端点

这两个设置的配置如下:

management.endpoint.health.show-details: "ALWAYS"
management.endpoints.web.exposure.include: "*" 

对于完整源代码的示例,请参阅product-composite项目中的application.yml配置文件。

警告:这些配置设置在开发期间很有帮助,但在生产系统中在 Actuator 端点中透露过多信息可能是一个安全问题。因此,计划在生产系统中最小化 Actuator 端点暴露的信息!

这可以通过在上面的management.endpoints.web.exposure.include属性设置中将"*"替换为例如health,info来实现。

有关 Spring Boot Actuator 暴露的端点的详细信息,请参阅docs.spring.io/spring-boot/docs/current/reference/html/production-ready-endpoints.html

可以使用以下命令手动使用健康端点(现在不要尝试,等待我们启动下面的微服务景观!):

curl localhost:8080/actuator/health -s | jq . 

这将导致包含以下内容的响应:

文本描述自动生成

图 7.11:健康端点响应

在前面的输出中,我们可以看到组合服务报告它处于健康状态;也就是说,其状态是UP。在响应的末尾,我们可以看到所有三个核心微服务也被报告为健康。

在设置了健康 API 之后,我们就准备好测试我们的响应式微服务了。

使用不使用分区的 RabbitMQ

在本节中,我们将与 RabbitMQ 一起测试响应式微服务,但不使用分区。

默认的docker-compose.yml Docker Compose 文件用于此配置。以下更改已添加到文件中:

  • 如此所示,已添加 RabbitMQ:

     rabbitmq:
        image: **rabbitmq:3.11.8-management**
        mem_limit: 512m
        ports:
          **-****5672****:5672**
          **-****15672****:15672**
        **healthcheck**:
          test: ["CMD", "rabbitmqctl", "status"]
          interval: 5s
          timeout: 2s
          retries: 60 
    

    从上述 RabbitMQ 的声明中,我们可以看到:

  • 我们使用包含管理插件和 Admin Web UI 的 RabbitMQ v3.11.8 Docker 镜像

  • 我们公开了连接到 RabbitMQ 和管理 Web UI 的标准端口,分别是567215672

  • 我们添加一个健康检查,以便 Docker 可以找出 RabbitMQ 何时准备好接受连接

  • 微服务现在在 RabbitMQ 服务上声明了依赖关系。这意味着 Docker 不会启动微服务容器,直到 RabbitMQ 服务报告为健康状态:

    depends_on:
      rabbitmq:
        condition: service_healthy 
    

要运行手动测试,请执行以下步骤:

  1. 使用以下命令构建和启动系统景观:

    cd $BOOK_HOME/Chapter07
    ./gradlew build && docker-compose build && docker-compose up -d 
    
  2. 现在,我们必须等待微服务景观启动并运行。尝试运行以下命令几次:

    curl -s localhost:8080/actuator/health | jq -r .status 
    

    当它返回UP时,我们就准备好运行我们的测试了!

  3. 首先,使用以下命令创建一个复合产品:

    body='{"productId":1,"name":"product name C","weight":300, "recommendations":[
    {"recommendationId":1,"author":"author 1","rate":1,"content":"content 1"},
     {"recommendationId":2,"author":"author 2","rate":2,"content":"content 2"},
     {"recommendationId":3,"author":"author 3","rate":3,"content":"content 3"}
    ], "reviews":[
     {"reviewId":1,"author":"author 1","subject":"subject 1","content":"content 1"},
     {"reviewId":2,"author":"author 2","subject":"subject 2","content":"content 2"},
     {"reviewId":3,"author":"author 3","subject":"subject 3","content":"content 3"}
    ]}'
    curl -X POST localhost:8080/product-composite -H "Content-Type: application/json" --data "$body" 
    

    当使用 Spring Cloud Stream 与 RabbitMQ 一起使用时,它将为每个主题创建一个 RabbitMQ 交换机以及一组队列,具体取决于我们的配置。让我们看看 Spring Cloud Stream 为我们创建了哪些队列!

  4. 在网页浏览器中打开以下 URL:http://localhost:15672/#/queues。使用默认用户名/密码guest/guest登录。你应该能看到以下队列:图形用户界面,应用程序,表格  自动生成的描述图 7.12:队列列表

    对于每个主题,我们可以看到一个用于auditGroup的队列,一个用于对应核心微服务的消费者组的队列,以及一个死信队列。我们还可以看到,auditGroup队列包含消息,正如预期的那样!

  5. 点击products.auditGroup队列,并滚动到获取消息部分,展开它,然后点击名为获取消息(s)的按钮以查看队列中的消息:图形用户界面,文本,应用程序,电子邮件  自动生成的描述图 7.13:查看队列中的消息

    从前面的屏幕截图,注意Payload,但也注意头部partitionKey,我们将在下一节中尝试使用分区来测试 RabbitMQ。

  6. 接下来,尝试使用以下代码获取产品组合:

    curl -s localhost:8080/product-composite/1 | jq 
    
  7. 最后,使用以下命令删除它:

    curl -X DELETE localhost:8080/product-composite/1 
    
  8. 尝试再次获取已删除的产品。这应该导致一个404 - "NotFound"响应!

  9. 如果您再次查看 RabbitMQ 审计队列,应该能够找到包含删除事件的新的消息。

  10. 使用以下命令关闭微服务景观来结束测试:

    docker-compose down 
    

这完成了我们使用不带分区的 RabbitMQ 的测试。现在,让我们继续测试带有分区的 RabbitMQ。

使用带有分区的 RabbitMQ

现在,让我们尝试 Spring Cloud Stream 中的分区支持!

我们为使用每个主题两个分区的 RabbitMQ 准备了一个单独的 Docker Compose 文件:docker-compose-partitions.yml。它还将为每个核心微服务启动两个实例,每个分区一个。例如,第二个 product 实例配置如下:

 product-p1:
    build: microservices/product-service
    mem_limit: 512m
    environment:
      - SPRING_PROFILES_ACTIVE=docker,**streaming_partitioned**,**streaming_instance_1**
    depends_on:
      mongodb:
        condition: service_healthy
      rabbitmq:
        condition: service_healthy 

以下是对先前配置的解释:

  • 我们使用与第一个 product 实例相同的源代码和 Dockerfile,但进行不同的配置。

  • 为了让所有微服务实例都知道它们将使用分区,我们在它们的 SPRING_PROFILES_ACTIVE 环境变量中添加了 Spring 配置文件 streaming_partitioned

  • 我们使用不同的 Spring 配置文件将两个 product 实例分配到不同的分区。第一个产品实例使用 Spring 配置文件 streaming_instance_0,第二个实例 product-p1 使用 streaming_instance_1

  • 第二个 product 实例将仅处理异步事件;它不会响应 API 调用。由于它有一个不同的名称,product-p1(也用作其 DNS 名称),它不会响应以 http://product:8080 开头的 URL 调用。

使用以下命令启动微服务景观:

export COMPOSE_FILE=docker-compose-partitions.yml
docker-compose build && docker-compose up -d 

以与上一节测试相同的方式创建一个复合产品,但还要创建一个产品 ID 设置为 2 的复合产品。如果您查看 Spring Cloud Stream 设置的队列,您将看到每个分区一个队列,并且产品审计队列现在每个都包含一条消息;产品 ID 1 的事件被放置在一个分区中,而产品 ID 2 的事件被放置在另一个分区中。

如果您在网页浏览器中返回到 http://localhost:15672/#/queues,您应该看到以下内容:

图形用户界面,应用描述自动生成

图 7.14:队列列表

要使用分区结束 RabbitMQ 的测试,请使用以下命令关闭微服务景观:

docker-compose down
unset COMPOSE_FILE 

我们现在完成了使用 RabbitMQ 的测试,无论是带分区还是不带分区。我们将尝试的最终测试配置是测试与 Kafka 一起的微服务。

使用每个主题两个分区的 Kafka

现在,我们将尝试 Spring Cloud Stream 的一个非常酷的功能:将消息系统从 RabbitMQ 更改为 Apache Kafka!

这可以通过简单地更改 spring.cloud.stream.defaultBinder 属性的值从 rabbitkafka 来完成。这由 docker-compose-kafka.yml Docker Compose 文件处理,该文件还用 Kafka 和 ZooKeeper 替换了 RabbitMQ。Kafka 和 ZooKeeper 的配置如下所示:

kafka:
  image: confluentinc/cp-kafka:7.3.1
  restart: always
  mem_limit: 1024m
  ports:
    - "9092:9092"
  environment:
    - KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181
    - KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://kafka:9092
    - KAFKA_BROKER_ID=1
    - KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1
  depends_on:
    - zookeeper
zookeeper:
  image: confluentinc/cp-zookeeper:7.3.1
  restart: always
  mem_limit: 512m
  ports:
    - "2181:2181"
  environment:
    - ZOOKEEPER_CLIENT_PORT=2181 

Kafka 也被配置为每个主题使用两个分区,并且像之前一样,我们为每个核心微服务启动两个实例,每个分区一个。有关详细信息,请参阅 Docker Compose 文件docker-compose-kafka.yml

使用以下命令启动微服务景观:

export COMPOSE_FILE=docker-compose-kafka.yml
docker-compose build && docker-compose up -d 

重复上一节的测试:创建两个产品,一个产品 ID 设置为1,另一个产品 ID 设置为2

很遗憾,Kafka 没有附带任何可以用来检查主题、分区以及其中消息的图形工具。相反,我们可以在 Kafka Docker 容器中运行 CLI 命令。

要查看主题列表,请运行以下命令:

docker-compose exec kafka kafka-topics --bootstrap-server localhost:9092 --list 

预期输出将类似于以下所示:

图形用户界面,文本,应用程序  自动生成的描述

图 7.15:查看主题列表

在前面的输出中我们看到的是:

  • 前缀为error的主题是对应死信队列的主题。

  • 你将找不到像 RabbitMQ 那样的auditGroup组。由于事件在 Kafka 中被保留在主题中,即使消费者已经处理了它们,因此不需要额外的auditGroup组。

要查看特定主题的分区,例如products主题,请运行以下命令:

docker-compose exec kafka kafka-topics --bootstrap-server localhost:9092 --describe --topic products 

预期输出将类似于以下所示:

图形用户界面,文本  自动生成的描述

图 7.16:查看产品主题中的分区

要查看特定分区的所有消息,例如products主题中的分区1,请运行以下命令:

docker-compose exec kafka kafka-console-consumer --bootstrap-server localhost:9092 --topic products --from-beginning --timeout-ms 1000 --partition 1 

预期输出将类似于以下所示:

图形用户界面,文本  自动生成的描述

图 7.17:查看产品主题中分区 1 的所有消息

输出将以超时异常结束,因为我们通过指定命令的1000毫秒超时来停止命令。

使用以下命令关闭微服务景观:

docker-compose down
unset COMPOSE_FILE 

现在,我们已经学会了如何使用 Spring Cloud Stream 将消息代理从 RabbitMQ 切换到 Kafka,而无需在源代码中进行任何更改。只需在 Docker Compose 文件中进行一些更改即可。

让我们继续本章的最后部分,学习如何自动运行这些测试!

运行反应式微服务景观的自动化测试

为了能够自动运行反应式微服务景观的测试而不是手动运行,已经增强了自动的test-em-all.bash测试脚本。最重要的更改如下:

  • 脚本使用新的health端点来了解微服务景观何时处于运行状态,如下所示:

    waitForService curl http://$HOST:$PORT/actuator/health 
    
  • 脚本新增了一个waitForMessageProcessing()函数,它在测试数据设置完成后被调用。其目的是简单地等待异步创建服务完成测试数据的创建。

要使用测试脚本自动运行带有 RabbitMQ 和 Kafka 的测试,请执行以下步骤:

  1. 使用默认的 Docker Compose 文件运行测试,即使用不带分区的 RabbitMQ,以下命令:

    unset COMPOSE_FILE
    ./test-em-all.bash start stop 
    
  2. 使用 Docker Compose docker-compose-partitions.yml 文件,通过以下命令为 RabbitMQ 运行测试,每个主题两个分区:

    export COMPOSE_FILE=docker-compose-partitions.yml 
    ./test-em-all.bash start stop
    unset COMPOSE_FILE 
    
  3. 最后,使用 Kafka 和每个主题两个分区,通过以下命令使用 Docker Compose docker-compose-kafka.yml 文件运行测试:

    export COMPOSE_FILE=docker-compose-kafka.yml 
    ./test-em-all.bash start stop
    unset COMPOSE_FILE 
    

在本节中,我们学习了如何使用 test-em-all.bash 测试脚本自动运行配置为使用 RabbitMQ 或 Kafka 作为其消息代理的反应式微服务景观的测试。

摘要

在本章中,我们看到了我们如何开发反应式微服务!

使用 Spring WebFlux 和 Spring WebClient,我们可以开发非阻塞同步 API,这些 API 可以处理传入的 HTTP 请求并发送出去的 HTTP 请求,而不会阻塞任何线程。使用 Spring Data 对 MongoDB 的反应式支持,我们也可以以非阻塞的方式访问 MongoDB 数据库,即在等待数据库响应时不阻塞任何线程。Spring WebFlux、Spring WebClient 和 Spring Data 依赖于 Project Reactor 来提供它们的反应式和非阻塞特性。当我们必须使用阻塞代码时,例如使用 Spring Data for JPA,我们可以通过在专用线程池中安排阻塞代码的处理来封装阻塞代码的处理。

我们还看到了如何使用 Spring Data Stream 来开发在 RabbitMQ 和 Kafka 作为消息系统上工作的事件驱动异步服务,而无需对代码进行任何更改。通过进行一些配置,我们可以使用 Spring Cloud Stream 中的功能,如消费者组、重试、死信队列和分区来处理异步消息的各个挑战。

我们还学习了如何手动和自动测试由反应式微服务组成的系统景观。

这是关于如何使用 Spring Boot 和 Spring Framework 的基本特性的最后一章。

接下来是 Spring Cloud 的介绍以及如何使用它使我们的服务达到生产就绪、可扩展、健壮、可配置、安全且具有弹性的状态!

问题

  1. 为什么了解如何开发反应式微服务很重要?

  2. 你是如何在非阻塞同步 API 和事件/消息驱动异步服务之间进行选择的?

  3. 事件与消息有什么不同?

  4. 列举一些与消息驱动异步服务相关的挑战。我们如何处理它们?

  5. 为什么以下测试没有失败?

    @Test
    void testStepVerifier() {
      StepVerifier.create(Flux.just(1, 2, 3, 4)
        .filter(n -> n % 2 == 0)
        .map(n -> n * 2)
        .log())
        .expectNext(4, 8, 12);
    } 
    

    首先,确保测试失败。接下来,修正测试使其成功。

  6. 使用 JUnit 编写反应式代码的测试有哪些挑战,我们如何处理它们?

第八章:Spring Cloud 简介

到目前为止,我们已经看到了如何使用 Spring Boot 来构建具有良好文档的 API 的微服务,以及 Spring WebFlux 和 springdoc-openapi;使用 Spring Data for MongoDB 和 JPA 在 MongoDB 和 SQL 数据库中持久化数据;构建响应式微服务,要么作为非阻塞 API 使用 Project Reactor,要么作为使用 RabbitMQ 或 Kafka 的 Spring Cloud Stream 的事件驱动异步服务,同时结合 Docker;以及管理和测试由微服务、数据库和消息系统组成的系统景观。

现在,是时候看看我们如何使用 Spring Cloud 来使我们的服务达到生产就绪状态,即可扩展、健壮、可配置、安全且具有弹性。

在本章中,我们将向您介绍如何使用 Spring Cloud 来实现 第一章微服务简介,在 微服务设计模式 部分中提到的以下设计模式:

  • 服务发现

  • 边缘服务器

  • 集中式配置

  • 电路断路器

  • 分布式跟踪

技术要求

本章不包含任何源代码,因此不需要安装任何工具。

Spring Cloud 的演变

在 2015 年 3 月的初始 1.0 版本中,Spring Cloud 主要是对 Netflix OSS 工具的包装,具体如下:

  • Netflix Eureka,一个发现服务器

  • Netflix Ribbon,一个客户端负载均衡器

  • Netflix Zuul,一个边缘服务器

  • Netflix Hystrix,一个电路断路器

Spring Cloud 的最初版本也包含了一个配置服务器以及与 Spring Security 的集成,提供了受 OAuth 2.0 保护的 API。2016 年 5 月,Spring Cloud 的 Brixton 版本(v1.1)被公开发布。随着 Brixton 版本的发布,Spring Cloud 获得了基于 Spring Cloud Sleuth 和 Zipkin 的分布式跟踪支持,这两者最初源于 Twitter。这些最初的 Spring Cloud 组件可以用来实现前面的设计模式。更多详情请参阅 spring.io/blog/2015/03/04/spring-cloud-1-0-0-available-nowspring.io/blog/2016/05/11/spring-cloud-brixton-release-is-available

自从 Spring Cloud 诞生以来,经过多年的发展,它已经增加了对以下内容的支持,以及其他内容:

  • 基于 HashiCorp Consul 和 Apache Zookeeper 的服务发现和集中式配置

  • 使用 Spring Cloud Stream 的事件驱动微服务

  • 云服务提供商,如 Microsoft Azure、Amazon Web Services 和 Google Cloud Platform

查看以下链接以获取完整工具列表:spring.io/projects/spring-cloud

自从 2019 年 1 月 Spring Cloud Greenwich(v2.1)版本发布以来,之前提到的某些 Netflix 工具已被置于 Spring Cloud 的维护模式。

原因是 Netflix 不再向某些工具添加新功能,而 Spring Cloud 添加了更好的替代方案。以下替代方案由 Spring Cloud 项目推荐:

当前组件 替代组件
Netflix Hystrix Resilience4j
Netflix Hystrix Dashboard/Netflix Turbine Micrometer 和监控系统
Netflix Ribbon Spring Cloud LoadBalancer
Netflix Zuul Spring Cloud Gateway

表 8.1:Spring Cloud 工具替代

想要了解更多细节,请参阅:

随着 2020 年 12 月 Spring Cloud Ilford(v2020.0.0)的发布,Spring Cloud 中剩下的唯一 Netflix 组件是 Netflix Eureka。

最后,与 Spring Boot 3 一起,Spring Cloud Kilburn(v2022.0.0)于 2022 年 12 月发布。正如在第二章Spring Boot 介绍中的Spring Boot 3.0 新闻部分所提到的,Spring Cloud Sleuth 已被 Micrometer Tracing 取代以支持分布式跟踪。

在这本书中,我们将使用以下表格中的软件组件来实现之前提到的设计模式:

设计模式 软件组件
服务发现 Netflix Eureka 和 Spring Cloud LoadBalancer
边缘服务器 Spring Cloud Gateway 和 Spring Security OAuth
集中配置 Spring Cloud Configuration Server
电路断路器 Resilience4j
分布式跟踪 Micrometer Tracing 和 Zipkin

表 8.2:按设计模式划分的软件组件

现在,让我们来探讨设计模式,并介绍将用于实现它们的软件组件!

使用 Netflix Eureka 进行服务发现

服务发现可能是使协作微服务景观生产就绪所需的最重要支持功能。正如我们在第一章微服务介绍中的服务发现部分所描述的,一个服务发现服务(或简称发现服务)可以用来跟踪现有的微服务和它们的实例。

Spring Cloud 最初支持的第一个发现服务是Netflix Eureka

我们将在第九章使用 Netflix Eureka 添加服务发现中用到它,以及一个基于 Spring Cloud LoadBalancer 的负载均衡器。

我们将看到使用 Spring Cloud 时注册微服务是多么简单。我们还将学习客户端如何发送 HTTP 请求,例如调用 RESTful API,到 Netflix Eureka 中注册的实例之一。此外,本章还将涵盖如何增加微服务实例的数量,以及如何将请求负载均衡到微服务的可用实例上(默认情况下基于轮询调度)。

以下截图展示了 Eureka 的网页界面,我们可以看到我们注册了哪些微服务:

图形用户界面,网站,自动生成的描述

图 8.1:查看当前与 Eureka 注册的微服务

从前面的截图,我们可以看到审查服务有三个可用实例,而其他三个服务每个只有一个实例。

随着 Netflix Eureka 的引入,让我们来看看 Spring Cloud 如何通过使用边缘服务器来帮助保护微服务系统架构。

使用 Spring Cloud Gateway 作为边缘服务器

另一个非常重要的支持功能是边缘服务器。正如我们在 第一章微服务简介边缘服务器 部分所描述的,它可以用来保护微服务景观,这涉及到隐藏私有服务以防止外部使用,并在外部客户端使用时保护公共服务。

最初,Spring Cloud 使用 Netflix Zuul v1 作为其边缘服务器。自从 Spring Cloud Greenwich 版本发布以来,建议使用 Spring Cloud Gateway 代替。Spring Cloud Gateway 提供了对关键功能类似的支撑,例如基于 URL 路径的路由以及通过使用 OAuth 2.0OpenID ConnectOIDC)来保护端点。

Netflix Zuul v1 和 Spring Cloud Gateway 之间的重要区别在于,Spring Cloud Gateway 基于非阻塞 API,使用 Spring 6、Project Reactor 和 Spring Boot 3,而 Netflix Zuul v1 基于阻塞 API。这意味着 Spring Cloud Gateway 应该能够处理比 Netflix Zuul v1 更多的并发请求,这对于所有外部流量都通过边缘服务器来说非常重要。

以下图展示了所有来自外部客户端的请求都通过 Spring Cloud Gateway 作为边缘服务器。基于 URL 路径,它将请求路由到目标微服务:

图形用户界面,图表,自动生成的文本描述

图 8.2:请求通过边缘服务器进行路由

在前面的图中,我们可以看到边缘服务器将具有以 /product-composite/ 开头的 URL 路径的外部请求发送到 产品组合 微服务。核心服务 产品推荐审查 对外部客户端不可达。

第十章使用 Spring Cloud Gateway 在边缘服务器后隐藏微服务 中,我们将探讨如何设置与我们的微服务一起使用的 Spring Cloud Gateway。

第十一章保护 API 访问安全 中,我们将看到如何使用 Spring Cloud Gateway 与 Spring Security OAuth2 一起使用,以 OAuth 2.0 和 OIDC 保护对边缘服务器的访问。我们还将看到 Spring Cloud Gateway 如何将调用者的身份信息传播到我们的微服务中,例如调用者的用户名或电子邮件地址。

在介绍了 Spring Cloud Gateway 之后,让我们看看 Spring Cloud 如何帮助管理微服务系统景观的配置。

使用 Spring Cloud Config 进行集中配置

为了管理微服务系统景观的配置,Spring Cloud 包含 Spring Cloud Config,它根据 第一章微服务简介集中配置部分中描述的要求提供集中式配置文件管理。

Spring Cloud Config 支持将配置文件存储在多种不同的后端,例如以下内容:

  • 一个 Git 仓库,例如 GitHub 或 Bitbucket

  • 本地文件系统

  • HashiCorp Vault

  • 一个 JDBC 数据库

Spring Cloud Config 允许我们以分层结构处理配置;例如,我们可以将配置的公共部分放在一个公共文件中,而将特定于微服务的设置放在单独的配置文件中。

Spring Cloud Config 还支持检测配置中的更改并将通知推送到受影响的微服务。它使用 Spring Cloud Bus 来传输通知。Spring Cloud Bus 是在 Spring Cloud Stream 之上的一个抽象,我们已经很熟悉了;也就是说,它支持使用 RabbitMQ 或 Kafka 作为传输通知的消息系统。

以下图示说明了 Spring Cloud Config、其客户端、Git 仓库和 Spring Cloud Bus 之间的协作:

图示,自动生成文本描述

图 8.3:Spring Cloud Config 如何融入微服务架构

图中展示了以下内容:

  1. 当微服务启动时,它们会向配置服务器请求其配置。

  2. 配置服务器从,在这种情况下,Git 仓库中获取配置。

  3. 可选地,Git 仓库可以被配置为在 Git 提交推送到 Git 仓库时向配置服务器发送通知。

  4. 配置服务器将使用 Spring Cloud Bus 发布更改事件。受更改影响的微服务将做出反应,并从配置服务器检索其更新的配置。

最后,Spring Cloud Config 还支持对配置中的敏感信息进行加密,例如凭证。

我们将在 第十二章集中配置 中了解 Spring Cloud Config。

随着 Spring Cloud Config 的引入,让我们看看 Spring Cloud 如何帮助使微服务在面对系统环境中不时发生的故障时更加具有弹性。

使用 Resilience4j 提高弹性

在一个相当大规模的系统景观中,合作的微服务,我们必须假设总有什么事情出错。故障必须被视为正常状态,系统景观必须设计来处理它!

初始时,Spring Cloud 随 Netflix Hystrix 一起提供,这是一个经过充分验证的断路器。但如上所述,自 Spring Cloud Greenwich 版本发布以来,建议用 Resilience4j 替换 Netflix Hystrix。Resilience4j 是一个基于开源的容错库。它提供的容错机制范围比 Netflix Hystrix 更广:

  • 断路器用于防止远程服务停止响应时发生连锁故障反应。

  • 限流器用于在指定时间段内限制对服务的请求数量。

  • 隔离舱用于限制对服务的并发请求数量。

  • 重试用于处理可能不时发生的随机错误。

  • 时间限制器用于避免等待慢或无响应的服务响应时间过长。

你可以在 github.com/resilience4j/resilience4j 上了解更多关于 Resilience4j 的信息。

在第十三章,使用 Resilience4j 提高弹性中,我们将重点关注 Resilience4j 中的断路器。它遵循经典的断路器设计,如下面的状态图所示:

图描述自动生成

图 8.4:断路器状态图

让我们更详细地看看状态图:

  1. 断路器以关闭状态开始,允许请求被处理。

  2. 只要请求处理成功,它就会保持在关闭状态。

  3. 如果开始出现故障,计数器开始增加。

  4. 如果在指定时间段内达到失败阈值,断路器将跳闸,即进入打开状态,不允许进一步处理请求。失败阈值和时间段都是可配置的。

  5. 相反,一个请求会快速失败,意味着它会立即返回一个异常。

  6. 在可配置的时间段后,断路器将进入半开状态,允许一个请求通过,作为探测,以查看故障是否已解决。

  7. 如果探测请求失败,断路器将回到打开状态。

  8. 如果探测请求成功,断路器将回到初始的关闭状态,允许新的请求被处理。

Resilience4j 中断路器的示例用法

假设我们有一个名为 myService 的 REST 服务,该服务使用 Resilience4j 的断路器进行保护。

如果服务开始产生内部错误,例如,因为它无法访问它所依赖的服务,我们可能会从服务中得到如下响应:500 内部服务器错误。经过一系列可配置的尝试后,电路将打开,我们将得到一个快速失败,返回错误消息,例如CircuitBreaker 'myService' is open。当错误解决后,我们再次尝试(在可配置的等待时间之后),断路器将允许新的尝试作为探测。如果调用成功,断路器将再次关闭;即,正常操作。

当与 Spring Boot 一起使用 Resilience4j 时,我们将能够通过其 Spring Boot Actuator health 端点监控微服务中断路器的状态。例如,我们可以使用 curl 来查看断路器 myService 的状态:

curl $HOST:$PORT/actuator/health -s | jq .components.circuitBreakers 

如果它正常工作,即电路是关闭的,它将响应如下:

文本描述自动生成

图 8.5:闭路响应

如果出现问题并且电路是打开的,它将响应如下:

计算机屏幕截图,描述自动生成,中等置信度

图 8.6:开路响应

随着 Resilience4j 的引入,我们已经看到了如何使用断路器来处理 REST 客户端的错误示例。让我们以介绍 Spring Cloud 如何用于分布式跟踪来结束这一章。

使用 Micrometer 跟踪和 Zipkin 进行分布式跟踪

要了解分布式系统(如协作微服务的系统景观)中正在发生的事情,能够跟踪和可视化在处理对系统景观的外部调用时,请求和消息如何在微服务之间流动至关重要。

有关此主题的更多信息,请参阅分布式跟踪部分的第一章微服务介绍

从 Spring Boot 3 开始,分布式跟踪由 Micrometer 跟踪 处理,取代了 Spring Cloud Sleuth。Micrometer 跟踪可以为属于同一处理流程的请求和消息/事件标记一个共同的 关联 ID

Micrometer 跟踪还可以用来装饰日志记录,添加关联 ID,以便更容易跟踪来自同一处理流程的不同微服务的日志记录。Zipkin 是一个分布式跟踪系统 (zipkin.io),Micrometer 跟踪可以将跟踪数据发送到它进行存储和可视化。在后面的第十九章使用 EFK 栈进行集中式日志记录中,我们将学习如何使用关联 ID 来查找和可视化同一处理流程中的日志记录。

Micrometer Tracing 和 Zipkin 中处理分布式跟踪信息的基础设施最初基于 Google Dapper(ai.google/research/pubs/pub36356)。在 Dapper 中,完整工作流程的跟踪信息被称为跟踪树,树的部分,如工作基本单元,被称为跨度。跨度可以进一步由子跨度组成,这些子跨度形成了跟踪树。关联 ID 被称为TraceId,一个跨度由其唯一的SpanId以及它所属的跟踪树的TraceId来识别。

关于实现分布式跟踪标准(或至少是建立开放事实标准的共同努力)的简短历史课:

Google 在 2005 年开始内部使用后,于 2010 年发布了关于 Dapper 的论文。

在 2016 年,OpenTracing项目加入了CNCF。OpenTracing 深受 Dapper 的影响,并为分布式跟踪提供了供应商中立的 API 和特定语言的库。

在 2019 年,OpenTracing 项目与OpenCensus项目合并,形成了一个新的 CNCF 项目,OpenTelemetry。OpenCensus 项目提供了一套用于收集指标和分布式跟踪的库。

建议进一步阅读的 URL:

在第十四章理解分布式跟踪中,我们将看到如何使用 Micrometer Tracing 和 Zipkin 来跟踪我们的微服务景观中的处理过程。以下是从 Zipkin UI 中截取的屏幕截图,它可视化了一个创建聚合产品的处理过程中创建的跟踪树:

图形用户界面 自动生成的描述

图 8.7:Zipkin 中的跟踪树

从前面的截图,我们可以看到通过网关(我们的边缘服务器)向product-composite服务发送了一个 HTTP POST请求,并且它通过向产品、推荐和评论的主题发布创建事件来响应。这些事件由三个核心微服务并行和异步地消费,这意味着product-composite服务不会等待核心微服务完成其工作。创建事件中的数据存储在每个微服务的数据库中。

在介绍了 Micrometer Tracing 和 Zipkin 进行分布式跟踪之后,我们看到了一个外部同步 HTTP 请求处理的分布式跟踪示例,该请求包括涉及微服务之间的异步事件传递。

摘要

在本章中,我们看到了 Spring Cloud 是如何从相对 Netflix OSS 中心化的状态发展到今天拥有更广泛的应用范围,包括与 Resilience4j 和 Micrometer Tracing 等工具一起使用。我们还介绍了 Spring Cloud 2022 最新版本中的组件如何用于实现我们在第一章,“微服务简介”,微服务设计模式部分中描述的一些设计模式。这些设计模式是使协作微服务环境生产就绪所必需的。

查看下一章,了解我们如何使用 Netflix Eureka 和 Spring Cloud LoadBalancer 实现服务发现!

问题

  1. Netflix Eureka 的目的是什么?

  2. Spring Cloud Gateway 的主要特性有哪些?

  3. Spring Cloud Config 支持哪些后端?

  4. Resilience4j 提供了哪些功能?

  5. 分布式追踪中使用的跟踪树和跨度概念是什么,以及最初定义它们的论文叫什么名字?

第九章:使用 Netflix Eureka 添加服务发现

在本章中,我们将学习如何基于 Spring Boot 使用 Netflix Eureka 作为微服务的发现服务。为了使我们的微服务能够与 Netflix Eureka 通信,我们将使用 Spring Cloud 模块中的 Netflix Eureka 客户端。在我们深入细节之前,我们将详细阐述为什么需要一个发现服务以及为什么 DNS 服务器不足以满足需求。

本章将涵盖以下主题:

  • 服务发现简介

  • 设置 Netflix Eureka 服务器

  • 将微服务连接到 Netflix Eureka 服务器

  • 设置开发使用的配置

  • 尝试使用 Netflix Eureka 作为发现服务

技术要求

关于如何安装本书中使用的工具以及如何访问本书源代码的说明,请参阅:

  • 第二十一章使用 macOS 的安装说明

  • 第二十二章使用 WSL 2 和 Ubuntu 的 Microsoft Windows 安装说明

本章中的代码示例均来自$BOOK_HOME/Chapter09的源代码。

如果你想查看本章源代码中应用的变化,即查看将 Netflix Eureka 作为发现服务添加到微服务领域的具体步骤,你可以将其与第七章开发响应式微服务的源代码进行比较。你可以使用你喜欢的diff工具比较两个文件夹,即$BOOK_HOME/Chapter07$BOOK_HOME/Chapter09

介绍服务发现

服务发现可能是使协作微服务领域生产就绪所需的最重要支持功能。Netflix Eureka 是 Spring Cloud 支持的第一个发现服务器。

我们严格来说是在讨论服务发现服务,但不会将其称为服务发现服务,而会简单地称为发现服务。当提到像 Netflix Eureka 这样的实际服务发现实现时,将使用发现服务器这个术语。

我们将看到在使用 Spring Cloud 时,将微服务注册到 Netflix Eureka 是多么容易。我们还将学习客户端如何使用 Spring Cloud LoadBalancer 向 Netflix Eureka 中注册的实例之一发送 HTTP 请求。最后,我们将尝试对微服务进行扩缩容,并运行一些破坏性测试以查看 Netflix Eureka 如何处理不同类型的故障场景。

在我们深入实现细节之前,我们将探讨以下主题:

  • 基于 DNS 的服务发现的问题

  • 服务发现面临的挑战

  • 在 Spring Cloud 中使用 Netflix Eureka 进行服务发现

基于 DNS 的服务发现的问题

为什么我们不能简单地启动新的微服务实例并依赖轮询 DNS?

轮询 DNS 背后的想法是,微服务的每个实例都在 DNS 服务器下以相同的名称注册其 IP 地址。当客户端请求 DNS 名称的 IP 地址时,DNS 服务器将返回注册实例的 IP 地址列表。客户端可以使用这个 IP 地址列表以轮询的方式向微服务实例发送请求,依次使用 IP 地址。

让我们试试看会发生什么!按照以下步骤操作:

  1. 假设你已经遵循了第七章开发反应式微服务中的说明,启动系统景观并使用以下命令插入一些测试数据:

    cd $BOOK_HOME/Chapter07
    ./test-em-all.bash start 
    
  2. review微服务扩展到两个实例:

    docker-compose up -d --scale review=2 
    
  3. 向复合产品服务请求它找到的review微服务的 IP 地址:

    docker-compose exec product-composite getent hosts review 
    

    预期得到如下回答:

    包含文本的图片,描述自动生成

    图 9.1:检查微服务 IP 地址

    太好了,复合产品服务看到了两个 IP 地址——在我的情况下,是192.168.96.9192.168.96.8——每个review微服务实例一个!

  4. 如果你想的话,可以使用以下命令验证这些确实是正确的 IP 地址。这些命令会请求review微服务的每个实例的 IP 地址:

    docker-compose exec --index=1 review cat /etc/hosts
    docker-compose exec --index=2 review cat /etc/hosts 
    

    每个命令输出的最后一行应包含一个 IP 地址,如前述代码所示。例如:

图形用户界面,描述自动生成,中等置信度

图 9.2:IP 地址输出

  1. 现在,让我们尝试调用product-composite服务,看看它是否使用了review微服务的两个实例:

    curl localhost:8080/product-composite/1 -s | jq -r .serviceAddresses.rev 
    

不幸的是,我们只会从微服务实例中的一个得到响应,就像这个例子一样:

图形用户界面,应用程序,描述自动生成

图 9.3:仅一个审查实例的响应

这真令人失望!

好吧,这里发生了什么?

DNS 客户端会请求 DNS 服务器解析 DNS 名称,并接收一个 IP 地址列表。接下来,DNS 客户端会逐个尝试接收到的 IP 地址,直到找到一个可以工作的地址,在大多数情况下是列表中的第一个。DNS 客户端通常会保留一个可以工作的 IP 地址;它不会为每个请求应用轮询方法。此外,典型的 DNS 服务器实现以及 DNS 协议本身都不太适合处理经常来来去去的易变微服务实例。正因为如此,尽管基于 DNS 的轮询在理论上很有吸引力,但在实际中并不实用,用于微服务实例的服务发现。

在我们继续学习如何更好地处理服务发现之前,让我们关闭系统景观:

docker-compose down 

服务发现挑战

因此,我们需要比普通的 DNS 更强大的东西来跟踪可用的微服务实例!

当我们跟踪许多小部件时,我们必须考虑以下因素,即微服务实例:

  • 新实例可以在任何时间点启动。

  • 现有的实例可能在任何时间点停止响应并最终崩溃。

  • 一些失败的实例可能过一段时间后可以恢复正常,并开始再次接收流量,而另一些则不会,应该从服务注册表中移除。

  • 一些微服务实例可能需要一些时间才能启动;也就是说,仅仅因为它们可以接收 HTTP 请求,并不意味着流量应该被路由到它们。

  • 不预期的网络分区和其他与网络相关的错误可能随时发生。

至少可以说,构建一个强大且弹性的发现服务器不是一件容易的任务。让我们看看我们如何使用 Netflix Eureka 来处理这些挑战!

Spring Cloud 中使用 Netflix Eureka 进行服务发现

Netflix Eureka 实现了客户端服务发现,这意味着客户端运行与发现服务器 Netflix Eureka 通信的软件,以获取有关可用微服务实例的信息。

这在下图中得到说明:

图描述自动生成

图 9.4:发现服务器图

流程如下:

  1. 每当微服务实例启动时——例如,Review服务——它会将自己注册到 Eureka 服务器之一。

  2. 定期地,每个微服务实例都会向 Eureka 服务器发送心跳消息,告诉它微服务实例一切正常,并准备好接收请求。

  3. 客户端——例如,产品组合服务——使用一个客户端库,该库定期向 Eureka 服务请求有关可用服务的信息。

  4. 当客户端需要向另一个微服务发送请求时,它已经在客户端库中有一个可用实例的列表,并且可以在不询问发现服务器的情况下选择其中一个。通常,可用实例是按轮询方式选择的;也就是说,在第一个实例再次被调用之前,它们一个接一个地被调用。

在第十七章中,我们将探讨使用 Kubernetes 中的服务器端服务概念提供发现服务的替代方法。

Spring Cloud 提供了一种与发现服务(如 Netflix Eureka)通信的抽象,并提供了一个名为DiscoveryClient的接口。这可以用来与发现服务交互,获取有关可用服务和实例的信息。DiscoveryClient接口的实现也能够自动将 Spring Boot 应用程序注册到发现服务器。

Spring Boot 在启动期间可以自动找到 DiscoveryClient 接口的实现,因此我们只需要引入相应实现的依赖来连接到发现服务器。在 Netflix Eureka 的情况下,我们微服务使用的依赖项是 spring-cloud-starter-netflix-eureka-client

Spring Cloud 还提供了 DiscoveryClient 实现的支持,这些实现支持使用 Apache ZooKeeper 或 HashiCorp Consul 作为发现服务器。

Spring Cloud 还为想要通过负载均衡器向发现服务中注册的实例发送请求的客户端提供了一个抽象——LoadBalancerClient 接口。标准的反应式 HTTP 客户端 WebClient 可以配置为使用 LoadBalancerClient 实现。通过将 @LoadBalanced 注解添加到返回 WebClient.Builder 对象的 @Bean 声明中,将 LoadBalancerClient 实现注入到 Builder 实例作为 ExchangeFilterFunction。在本章的 将微服务连接到 Netflix Eureka 服务器 部分,我们将会看到一些如何使用它的源代码示例。

总结来说,Spring Cloud 使得使用 Netflix Eureka 作为发现服务变得非常简单。通过本节对服务发现及其挑战的介绍,以及如何将 Netflix Eureka 与 Spring Cloud 结合使用,我们准备好学习如何设置 Netflix Eureka 服务器。

设置 Netflix Eureka 服务器

在本节中,我们将学习如何设置 Netflix Eureka 服务器以进行服务发现。使用 Spring Cloud 设置 Netflix Eureka 服务器非常简单——只需遵循以下步骤:

  1. 使用 Spring Initializr 创建一个 Spring Boot 项目,如 第三章 中所述,创建一组协作微服务,在 使用 Spring Initializr 生成骨架代码 部分中描述。

  2. 添加对 spring-cloud-starter-netflix-eureka-server 的依赖。

  3. @EnableEurekaServer 注解添加到应用程序类中。

  4. 添加一个 Dockerfile,类似于我们用于微服务的 Dockerfile,只是我们导出默认的 Eureka 端口 8761,而不是微服务的默认端口 8080

  5. 将 Eureka 服务器添加到我们的三个 Docker Compose 文件中,即 docker-compose.ymldocker-compose-partitions.ymldocker-compose-kafka.yml,如下所示:

    eureka:
      build: spring-cloud/eureka-server
      mem_limit: 512m
      ports:
        - "8761:8761" 
    
  6. 最后,添加一些配置。请参阅本章中 设置开发使用的配置 部分,其中我们将介绍 Eureka 服务器和我们的微服务的配置。

就这么简单!

应用程序类包含一个针对在 github.com/spring-cloud/spring-cloud-netflix/issues/4145 中描述的错误的解决方案。它影响了本书中使用的 Spring Cloud 版本,2022.0.1。请参阅 EurekaServerApplication.java 中的 CustomErrorController 类。

您可以在$BOOK_HOME/Chapter09/spring-cloud/eureka-server文件夹中找到 Eureka 服务器的源代码。

现在我们已经为服务发现设置了一个 Netflix Eureka 服务器,我们准备好学习如何将微服务连接到它了。

将微服务连接到 Netflix Eureka 服务器

在本节中,我们将学习如何将微服务实例连接到 Netflix Eureka 服务器。我们将学习微服务实例在启动期间如何将自己注册到 Eureka 服务器,以及客户端如何使用 Eureka 服务器来查找他们想要调用的微服务实例。

要能够在 Eureka 服务器中注册微服务实例,我们需要执行以下操作:

  1. 在构建文件build.gradle中添加对spring-cloud-starter-netflix-eureka-client的依赖:

    Implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' 
    
  2. 当对单个微服务进行测试时,我们不希望依赖于 Eureka 服务器正在运行。因此,我们将禁用所有 Spring Boot 测试中 Netflix Eureka 的使用,即带有@SpringBootTest注解的 JUnit 测试。这可以通过在注解中添加eureka.client.enabled属性并将其设置为false来实现,如下所示:

    @SpringBootTest(webEnvironment=RANDOM_PORT, properties = {"eureka.client.enabled=false"}) 
    
  3. 最后,添加一些配置。请转到设置开发使用的配置部分,我们将介绍 Eureka 服务器和我们的微服务的配置。

配置中有一个属性非常重要:spring.application.name。它用于为每个微服务提供一个虚拟主机名,这是 Eureka 服务用来识别每个微服务的名称。Eureka 客户端将使用这个虚拟主机名在用于向微服务发起 HTTP 调用的 URL 中,正如我们接下来将要看到的。

要能够在product-composite微服务中通过 Eureka 服务器查找可用的微服务实例,我们还需要执行以下操作:

  1. 在主应用程序类ProductCompositeServiceApplication中添加一个 Spring bean,该 bean 创建一个负载均衡器感知的WebClient builder:

    @Bean
    @LoadBalanced
    public WebClient.Builder loadBalancedWebClientBuilder() {
        return WebClient.builder();
    } 
    

有关如何将WebClient实例用作负载均衡器客户端的更多信息,请参阅docs.spring.io/spring-cloud-commons/docs/current/reference/html/#webclinet-loadbalancer-client

  1. WebClient-builder bean 可以通过集成类ProductCompositeIntegration使用,通过将其注入构造函数中:

    private WebClient webClient;
    @Autowired
    public ProductCompositeIntegration(
      WebClient.Builder webClientBuilder, 
      ...
    ) {
      this.webClient = webClientBuilder.build();
      ...
    } 
    

    构造函数使用注入的 builder 来创建webClient

    一旦构建了WebClient,它就是不可变的。这意味着它可以被并发请求重用,而不会相互干扰。

  2. 我们现在可以丢弃application.yml中硬编码的可用微服务配置。它看起来像这样:

    app:
      product-service:
        host: localhost
        port: 7001
      recommendation-service:
        host: localhost
        port: 7002
      review-service:
        host: localhost
        port: 7003 
    
  3. 处理硬编码配置的集成类ProductCompositeIntegration中的相应代码被简化,并替换为对核心微服务 API 的基础 URL 的声明。这将在以下代码中展示:

    private static final String PRODUCT_SERVICE_URL = "http://product";
    private static final String RECOMMENDATION_SERVICE_URL = "http://recommendation";
    private static final String REVIEW_SERVICE_URL = "http://review"; 
    

上述 URL 中的主机名不是实际的 DNS 名称。相反,它们是微服务在将自己注册到 Eureka 服务器时使用的虚拟主机名,换句话说,是spring.application.name属性的值。

现在我们已经看到了如何将微服务实例连接到 Netflix Eureka 服务器,我们可以继续学习如何配置 Eureka 服务器及其连接到的微服务实例。

设置开发使用的配置

现在,是设置 Netflix Eureka 作为发现服务中最棘手的部分:为 Eureka 服务器及其客户端(我们的微服务实例)设置一个有效的配置。

Netflix Eureka 是一个高度可配置的发现服务器,可以根据多种不同的用例进行设置,并提供强大、健壮和容错运行时特性。这种灵活性和健壮性的一个缺点是它几乎有压倒性的配置选项数量。

幸运的是,Netflix Eureka 为大多数可配置参数提供了良好的默认值——至少在使用它们的生产环境时是这样的。

当谈到在开发中使用 Netflix Eureka 时,默认值会导致启动时间过长。例如,客户端向注册在 Eureka 服务器中的微服务实例发起初始成功的调用可能需要很长时间。

使用默认配置值时,可能会遇到长达两分钟的等待时间。这个等待时间加在了 Eureka 服务和微服务启动所需的时间上。这种等待时间的原因是涉及到的进程需要相互同步注册信息。微服务实例需要向 Eureka 服务器注册,客户端需要从 Eureka 服务器收集信息。这种通信主要基于心跳,默认情况下每 30 秒发生一次。还有一些缓存也参与其中,这会减慢更新传播的速度。

我们将使用一种配置来最小化这种等待时间,这在开发期间非常有用。在生产环境中,应该将默认值作为起点!

我们将只使用一个 Netflix Eureka 服务器实例,这在开发环境中是可以的。在生产环境中,你应该始终使用两个或更多实例以确保 Netflix Eureka 服务器的高可用性。

让我们开始学习我们需要了解哪些类型的配置参数。

Eureka 配置参数

Eureka 的配置参数分为三个组:

  • eureka.server为前缀的 Eureka 服务器参数。

  • eureka.client为前缀的 Eureka 客户端参数。这是供想要与 Eureka 服务器通信的客户端使用的。

  • eureka.instance为前缀的 Eureka 实例参数。这是供想要在 Eureka 服务器中注册自己的微服务实例使用的。

一些可用的参数在 Spring Cloud Netflix 文档中有描述:docs.spring.io/spring-cloud-netflix/docs/current/reference/html/

对于可用的参数的详尽列表,我建议阅读源代码:

  • 对于 Eureka 服务器参数,查看org.springframework.cloud.netflix.eureka.server.EurekaServerConfigBean类以获取默认值,以及查看com.netflix.eureka.EurekaServerConfig接口以获取相关文档。

  • 对于 Eureka 客户端参数,查看org.springframework.cloud.netflix.eureka.EurekaClientConfigBean类以获取默认值和文档。

  • 对于 Eureka 实例参数,查看org.springframework.cloud.netflix.eureka.EurekaInstanceConfigBean类以获取默认值和文档。

让我们开始了解 Eureka 服务器的配置参数。

配置 Eureka 服务器

要在开发环境中配置 Eureka 服务器,可以使用以下配置:

server:
  port: 8761
eureka:
  instance:
    hostname: localhost
  client:
    registerWithEureka: false
    fetchRegistry: false
    serviceUrl:
      defaultZone: http://${eureka.instance.hostname}:${server.port}/eureka/

  server:
    waitTimeInMsWhenSyncEmpty: 0
    response-cache-update-interval-ms: 5000 

配置的第一部分,对于 Eureka instanceclient,是独立 Eureka 服务器的标准配置。有关详细信息,请参阅我们之前提到的 Spring Cloud 文档。用于 Eureka 服务器的最后两个参数waitTimeInMsWhenSyncEmptyresponse-cache-update-interval-ms用于最小化启动时间。

在配置了 Eureka 服务器后,我们就可以看到 Eureka 服务器的客户端,即微服务实例,如何进行配置。

配置 Eureka 服务器的客户端

为了能够连接到 Eureka 服务器,微服务有以下配置:

eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8761/eureka/
    initialInstanceInfoReplicationIntervalSeconds: 5
    registryFetchIntervalSeconds: 5
  instance:
    leaseRenewalIntervalInSeconds: 5
    leaseExpirationDurationInSeconds: 5
---
spring.config.activate.on-profile: docker
eureka.client.serviceUrl.defaultZone: http://eureka:8761/eureka/ 

eureka.client.serviceUrl.defaultZone参数用于查找 Eureka 服务器,在没有 Docker 运行时使用主机名localhost,在 Docker 容器中运行时使用主机名eureka。其他参数用于最小化启动时间和停止的微服务实例注销所需的时间。

现在,我们已经准备好使用 Netflix Eureka 服务器和我们的微服务来实际尝试使用发现服务了。

尝试使用发现服务

所有细节都准备就绪后,我们就可以尝试使用 Netflix Eureka 了:

  1. 首先,使用以下命令构建 Docker 镜像:

    cd $BOOK_HOME/Chapter09
    ./gradlew build && docker-compose build 
    
  2. 接下来,使用以下命令启动系统景观并运行常规测试:

    ./test-em-all.bash start 
    

预期输出将与我们在前几章中看到的结果类似:

图形用户界面,自动生成文本描述

图 9.5:成功的测试输出

系统景观启动并运行后,我们可以开始测试如何增加一个微服务的实例数量。

扩展

运行以下命令来尝试扩展服务:

  1. 启动两个额外的 review 微服务实例:

    docker-compose up -d --scale review=3 
    

使用前面的命令,我们要求 Docker Compose 运行三个 review 服务实例。由于一个实例已经在运行,因此将启动两个新实例。

  1. 一旦新实例启动并运行,请浏览到 http://localhost:8761/ 并预期如下:图形用户界面,网站描述自动生成

    图 9.6:查看已注册到 Eureka 的实例

    验证你能否在 Netflix Eureka 网页 UI 中看到三个 review 实例,如前面的截图所示。

  2. 知道新实例何时启动并运行的一种方法就是运行以下命令:

    docker-compose logs review | grep Started 
    

    预期输出如下:

图形用户界面,文本,应用程序描述自动生成

图 9.7:新的 review 实例

  1. 我们还可以使用 Eureka 服务公开的 REST API。要获取实例 ID 列表,我们可以发出一个 curl 命令,如下所示:

    curl -H "accept:application/json" localhost:8761/eureka/apps -s | jq -r .applications.application[].instance[].instanceId 
    

    预期将收到类似以下响应:

文本描述自动生成

图 9.8:微服务实例 ID 列表

  1. 如果你查看测试脚本 test-em-all.bash,你会找到新的测试,以验证我们能否到达 Eureka 的 REST API,并且它报告了四个实例:

    # Verify access to Eureka and that all four microservices are # registered in Eureka
    assertCurl 200 "curl -H "accept:application/json" $HOST:8761/eureka/apps -s"
    assertEqual 4 $(echo $RESPONSE | jq ".applications.application | length") 
    
  2. 现在我们已经启动并运行了所有实例,让我们通过发送一些请求并关注响应中 review 服务的地址来尝试客户端负载均衡器,如下所示:

    curl localhost:8080/product-composite/1 -s | jq -r .serviceAddresses.rev 
    

    预期将收到类似以下响应:

    文本描述自动生成

    图 9.9:Review 服务地址

    注意,每次响应中 review 服务的地址都会改变;负载均衡器使用轮询逻辑逐个调用可用的 review 实例!

  3. 我们还可以使用以下命令查看 review 实例的日志记录:

    docker-compose logs review | grep "Response size" 
    

你将看到类似以下输出的内容:

文本描述自动生成

图 9.10:Review 实例日志记录

在前面的输出中,我们可以看到三个 review 微服务实例,review_2review_3review_1 如何依次响应请求。

我们还可以尝试缩小实例,我们将在下一步进行。

缩放

让我们看看如果我们丢失一个 review 微服务实例会发生什么。运行以下命令:

  1. 我们可以通过运行以下命令来模拟一个实例意外停止:

    docker-compose up -d --scale review=2 
    
  2. review 实例关闭后,会有一个短暂的时间段,在此期间调用 API 可能会失败。这是由于关于丢失实例的信息传播到客户端、product-composite 服务所需的时间造成的。在此时间段内,客户端负载均衡器可能会选择不再存在的实例。为了防止这种情况发生,可以使用诸如超时和重试等弹性机制。在 第十三章使用 Resilience4j 提高弹性 中,我们将看到如何应用这些机制。现在,让我们在我们的 curl 命令上指定一个超时,使用 -m 2 选项指定我们最多等待两秒钟的响应:

    curl localhost:8080/product-composite/1 -m 2 
    

    如果发生超时,即客户端负载均衡器尝试调用一个不再存在的实例,那么从 curl 预期得到的响应如下:

图形用户界面,文本  自动生成的描述

图 9.11:发生超时时 curl 的响应

  1. 此外,我们还应期望从剩余的两个实例得到正常响应;也就是说,serviceAddresses.rev 字段应包含两个实例的地址,如下所示:

图形用户界面,文本,应用程序  自动生成的描述

图 9.12:剩余实例的正常响应

在前面的示例输出中,我们可以看到报告了两个不同的容器名称和 IP 地址。这意味着请求是由剩余的两个微服务实例处理的。

在尝试了微服务实例的缩放之后,我们可以尝试一些更具破坏性的操作:停止 Eureka 服务器并查看当发现服务器暂时不可用时会发生什么。

使用 Eureka 服务器进行破坏性测试

让我们在 Eureka 服务器上制造一些混乱,看看系统景观是如何处理它的!

首先,如果我们崩溃 Eureka 服务器会发生什么?

只要客户端在停止之前已从 Eureka 服务器读取有关可用微服务实例的信息,客户端就会没事,因为它们已经将信息本地缓存。然而,新实例不会提供给客户端,并且如果任何运行中的实例被终止,它们也不会得到通知。因此,调用不再运行的实例将导致失败。

让我们试试这个!

停止 Eureka 服务器

要模拟 Eureka 服务器崩溃,请按照以下步骤操作:

  1. 首先,停止 Eureka 服务器并保持两个 review 实例运行:

    docker-compose up -d --scale review=2 --scale eureka=0 
    
  2. 尝试几次调用 API 并提取 review 服务的服务地址:

    curl localhost:8080/product-composite/1 -s | jq -r .serviceAddresses.rev 
    
  3. 响应将——就像我们在停止 Eureka 服务器之前一样——包含两个 review 实例的地址,如下所示:

图形用户界面,文本,应用程序  自动生成的描述

图 9.13:包含两个 review 实例地址的响应

这表明客户端即使 Eureka 服务器不再运行,也可以调用现有实例。

启动产品服务的额外实例

作为对崩溃的 Eureka 服务器影响的最终测试,让我们看看如果我们启动 product 微服务的新实例会发生什么。执行以下步骤:

  1. 让我们尝试启动 product 服务的新的实例:

    docker-compose up -d --scale review=2 --scale eureka=0 --scale product=2 
    
  2. 调用 API 几次,并使用以下命令提取 product 服务的地址:

    curl localhost:8080/product-composite/1 -s | jq -r .serviceAddresses.pro 
    

由于没有 Eureka 服务器运行,客户端将不会通知新的 product 实例,因此所有调用都将转到第一个实例,如下例所示:

图形用户界面,文本,应用程序  自动生成的描述

图 9.14:仅第一个产品实例的地址

我们已经看到了没有 Netflix Eureka 服务器运行的一些最重要的方面。让我们通过再次启动 Netflix Eureka 服务器来总结破坏性测试部分,并看看系统景观如何处理自我修复,即弹性。

再次启动 Eureka 服务器

在本节中,我们将通过再次启动 Eureka 服务器来总结破坏性测试。我们还将验证系统景观是否能够自我修复,这意味着 product 微服务的新实例将注册到 Netflix Eureka 服务器,并且客户端将通过 Eureka 服务器进行更新。执行以下步骤:

  1. 使用以下命令启动 Eureka 服务器:

    docker-compose up -d --scale review=1 --scale eureka=1 --scale product=2 
    
  2. 使用以下命令调用几次以提取产品和 review 服务的地址:

    curl localhost:8080/product-composite/1 -s | jq -r .serviceAddresses 
    

    验证以下情况发生:

    • 所有调用都转到剩余的 review 实例,这表明客户端已经检测到第二个 review 实例已消失。

    • product 服务的调用在两个 product 实例之间进行负载均衡,这表明客户端已经检测到有两个 product 实例可用。

    响应应包含 review 实例的相同地址和两个不同的 product 实例地址,如下两个示例所示:

    图形用户界面,文本,应用程序  自动生成的描述

    图 9.15:产品和评论地址

    这是第二个响应:

    图形用户界面,文本,应用程序  自动生成的描述

    图 9.16:产品和评论地址

    IP 地址 192.168.128.4192.168.128.10 属于两个 product 实例。192.168.128.8 是唯一剩余的 review 实例的 IP 地址。

    总结来说,Eureka 服务器提供了一个非常健壮和弹性的服务发现实现。如果需要更高的可用性,可以启动多个 Eureka 服务器,并配置它们相互通信。有关如何设置多个 Eureka 服务器的详细信息,请参阅 Spring Cloud 文档:docs.spring.io/spring-cloud-netflix/docs/current/reference/html/#spring-cloud-eureka-server-peer-awareness

  3. 最后,使用以下命令关闭系统环境:

    docker-compose down 
    

这完成了对服务发现服务器 Netflix Eureka 的测试,我们学习了如何扩展和缩减微服务实例,以及如果 Netflix Eureka 服务器崩溃后再次上线会发生什么。

总结

在本章中,我们学习了如何使用 Netflix Eureka 进行服务发现。首先,我们探讨了简单基于 DNS 的服务发现解决方案的不足,以及一个健壮和弹性的服务发现解决方案必须能够处理挑战。

Netflix Eureka 是一个非常强大的服务发现解决方案,它提供了健壮、弹性和容错运行时特性。然而,正确配置可能具有挑战性,特别是为了提供流畅的开发者体验。使用 Spring Cloud,设置 Netflix Eureka 服务器和适配基于 Spring Boot 的微服务变得容易,这样它们就可以在启动时将自己注册到 Eureka,并且当作为其他微服务的客户端时,可以跟踪可用的微服务实例。

在设置了服务发现服务器之后,是时候看看我们如何使用 Spring Cloud Gateway 作为边缘服务器来处理外部流量了。翻到下一章,看看如何操作!

问题

  1. 要将使用 Spring Initializr 创建的 Spring Boot 应用程序转换为完整的 Netflix Eureka 服务器,需要什么?

  2. 要使基于 Spring Boot 的微服务自动注册为 Netflix Eureka 的启动服务,需要什么?

  3. 要使基于 Spring Boot 的微服务能够调用在 Netflix Eureka 服务器中注册的另一个微服务,需要什么?

  4. 假设你已经有一个 Netflix Eureka 服务器正在运行,以及一个微服务 A 的实例和两个微服务 B 的实例。所有微服务实例都向 Netflix Eureka 服务器注册自己。微服务 A 根据从 Eureka 服务器获得的信息向微服务 B 发送 HTTP 请求。如果以下情况发生,会发生什么?

    1. Netflix Eureka 服务器崩溃

    2. 微服务 B 的一个实例崩溃

    3. 一个新的微服务 A 实例启动

    4. 一个新的微服务 B 实例启动

    5. Netflix Eureka 服务器再次启动

第十章:使用 Spring Cloud Gateway 隐藏微服务背后的边缘服务器

在本章中,我们将学习如何使用 Spring Cloud Gateway 作为边缘服务器,以控制从我们的基于微服务系统景观中公开的 API。我们将看到具有公共 API 的微服务如何通过边缘服务器从外部访问,而具有私有 API 的微服务仅从微服务景观的内部访问。在我们的系统景观中,这意味着产品组合服务和发现服务器 Netflix Eureka 将通过边缘服务器公开。三个核心服务productrecommendationreview将对外隐藏。

本章将涵盖以下主题:

  • 将边缘服务器添加到我们的系统景观中

  • 设置 Spring Cloud Gateway,包括配置路由规则

  • 尝试使用边缘服务器

技术要求

关于如何安装本书中使用的工具以及如何访问本书源代码的说明,请参阅:

  • 第二十一章macOS 安装说明

  • 第二十二章使用 WSL 2 和 Ubuntu 的 Microsoft Windows 安装说明

本章中的所有代码示例都来自$BOOK_HOME/Chapter10中的源代码。

如果您想查看对本章源代码所做的更改——即查看将 Spring Cloud Gateway 作为边缘服务器添加到微服务景观中所需的工作——您可以将其与第九章使用 Netflix Eureka 添加服务发现的源代码进行比较。

您可以使用您喜欢的diff工具比较两个文件夹,$BOOK_HOME/Chapter09$BOOK_HOME/Chapter10

将边缘服务器添加到我们的系统景观中

在本节中,我们将看到边缘服务器是如何添加到系统景观中,以及它如何影响外部客户端访问微服务公开的公共 API 的方式。现在,所有传入的请求都将通过边缘服务器路由,如下图所示:

图形用户界面,图表,文本 描述自动生成

图 10.1:添加边缘服务器

如前图所示,外部客户端将所有请求发送到边缘服务器。边缘服务器可以根据 URL 路径路由传入的请求。例如,以/product-composite/开头的请求被路由到产品组合微服务,而以/eureka/开头的请求根据 Netflix Eureka 被路由到发现服务器。

要使发现服务与 Netflix Eureka 一起工作,我们不需要通过边缘服务器公开它。内部服务将直接与 Netflix Eureka 通信。公开它的原因是为了使操作员能够检查 Netflix Eureka 的状态,并查看当前在发现服务中注册的实例。

第九章使用 Netflix Eureka 添加服务发现 中,我们暴露了 product-composite 服务和发现服务器 Netflix Eureka 的外部。当我们在本章介绍边缘服务器时,这种情况将不再存在。这是通过从 Docker Compose 文件中删除以下两个服务的端口声明来实现的:

 product-composite:
    build: microservices/product-composite-service
    ports:
      - "8080:8080"
  eureka:
    build: spring-cloud/eureka-server
    ports:
      - "8761:8761" 

在引入边缘服务器后,我们将在下一节学习如何基于 Spring Cloud Gateway 设置边缘服务器。

设置 Spring Cloud Gateway

将 Spring Cloud Gateway 设置为边缘服务器非常简单,可以按照以下步骤进行:

  1. 使用 Spring Initializr 创建一个 Spring Boot 项目,如 第三章创建一组协作微服务 中所述 – 请参阅 使用 Spring Initializr 生成骨架代码 部分。

  2. 添加对 spring-cloud-starter-gateway 的依赖。

  3. 为了能够通过 Netflix Eureka 定位微服务实例,还需要添加 spring-cloud-starter-netflix-eureka-client 依赖。

  4. 将边缘服务器项目添加到通用构建文件 settings.gradle 中:

    include ':spring-cloud:gateway' 
    
  5. 添加一个与微服务相同的 Dockerfile;请参阅 $BOOK_HOME/Chapter10/microservices 文件夹中的 Dockerfile 内容。

  6. 将边缘服务器添加到我们的三个 Docker Compose 文件中:

    gateway:
      environment:
        - SPRING_PROFILES_ACTIVE=docker
      build: spring-cloud/gateway
      mem_limit: 512m
      ports:
        - "8080:8080" 
    

    从前面的代码中,我们可以看到边缘服务器将端口 8080 暴露给 Docker 引擎的外部。为了控制所需的内存量,对边缘服务器应用了一个 512 MB 的内存限制,这与我们对其他微服务所做的方式相同。

  7. 由于边缘服务器将处理所有传入流量,我们将组合健康检查从产品组合服务移动到边缘服务器。这将在下一节 添加组合健康检查 中描述。

  8. 添加路由规则和更多配置。由于有很多配置项,因此它们在下面的单独部分 配置 Spring Cloud Gateway 中处理。

您可以在 $BOOK_HOME/Chapter10/spring-cloud/gateway 中找到 Spring Cloud Gateway 的源代码。

添加组合健康检查

由于已经设置了边缘服务器,外部健康检查请求也必须通过边缘服务器进行。因此,检查所有微服务状态的组合健康检查已从 product-composite 服务移动到边缘服务器。请参阅 第七章开发反应式微服务 – 请参阅 添加健康 API 部分以获取组合健康检查的实现细节。

以下内容已添加到边缘服务器中:

  1. 已添加 HealthCheckConfiguration 类,它声明了反应式健康贡献者:

     @Bean
      ReactiveHealthContributor healthcheckMicroservices() {
        final Map<String, ReactiveHealthIndicator> registry = 
          new LinkedHashMap<>();
        registry.put("product",           () -> 
          getHealth("http://product"));
        registry.put("recommendation",    () -> 
          getHealth("http://recommendation"));
        registry.put("review",            () -> 
          getHealth("http://review"));
        registry.put("product-composite", () -> 
          getHealth("http://product-composite"));
        return CompositeReactiveHealthContributor.fromMap(registry);
      }
      private Mono<Health> getHealth(String baseUrl) {
        String url = baseUrl + "/actuator/health";
        LOG.debug("Setting up a call to the Health API on URL: {}", 
          url);
        return webClient.get().uri(url).retrieve()
          .bodyToMono(String.class)
          .map(s -> new Health.Builder().up().build())
          .onErrorResume(ex -> 
          Mono.just(new Health.Builder().down(ex).build()))
          .log(LOG.getName(), FINE);
      } 
    

    从前面的代码中,我们可以看到已为 product-composite 服务添加了健康检查,而不是在 第七章开发反应式微服务 中使用的健康检查!

  2. 主应用程序类 GatewayApplication 声明了一个 WebClient.Builder 实例,该实例将被用于健康指标的实现,如下所示:

     @Bean
      @LoadBalanced
      public WebClient.Builder loadBalancedWebClientBuilder() {
        return WebClient.builder();
      } 
    

从前面的源代码中,我们可以看到 WebClient.builder 被注解为 @LoadBalanced,这使得它知道在发现服务器 Netflix Eureka 中注册的微服务实例。有关更多详细信息,请参阅第九章 使用 Netflix Eureka 添加服务发现 中的 Spring Cloud 中 Netflix Eureka 的服务发现 部分。

对于边缘服务器,我们已设置复合健康检查,现在可以查看需要为 Spring Cloud Gateway 设置的配置。

配置 Spring Cloud Gateway

当涉及到配置 Spring Cloud Gateway 时,最重要的是设置路由规则。我们还需要在配置中设置一些其他事项:

  1. 由于 Spring Cloud Gateway 将使用 Netflix Eureka 来查找它将路由流量到的微服务,因此它必须像第九章 使用 Netflix Eureka 添加服务发现 中所述的那样配置为 Eureka 客户端 – 请参阅 配置 Eureka 服务器客户端 部分。

  2. 按照第七章 开发响应式微服务 中所述配置 Spring Boot Actuator 以用于开发,请参阅 添加健康 API 部分:

    management.endpoint.health.show-details: "ALWAYS"
    management.endpoints.web.exposure.include: "*" 
    
  3. 配置日志级别,以便我们可以看到 Spring Cloud Gateway 内部处理中有趣部分的日志消息,例如,它如何决定将传入的请求路由到何处:

    logging:
      level:
        root: INFO
        org.springframework.cloud.gateway.route.
            RouteDefinitionRouteLocator: INFO
        org.springframework.cloud.gateway: TRACE 
    

对于完整的源代码,请参阅配置文件 src/main/resources/application.yml

路由规则

设置路由规则可以通过两种方式完成:程序化地使用 Java DSL,或通过配置。在规则存储在外部存储(如数据库)或运行时提供(例如,通过 RESTful API 或发送到网关的消息)的情况下,使用 Java DSL 程序化设置路由规则可能很有用。在更静态的使用场景中,我发现将路由声明在配置文件 src/main/resources/application.yml 中更为方便。将路由规则与 Java 代码分离,使得无需部署微服务的新版本即可更新路由规则。

路由由以下定义:

  • 断言,根据传入的 HTTP 请求中的信息选择路由

  • 过滤器,可以修改请求和/或响应

  • 一个 目标 URI,它描述了请求应发送到何处

  • 一个 ID,即路由的名称

对于可用的断言和过滤器的完整列表,请参阅参考文档:cloud.spring.io/spring-cloud-gateway/single/spring-cloud-gateway.html

在以下小节中,我们将首先学习如何将请求路由到 product-composite 服 务和 Eureka 服 务器。之后,我们将看到如何使用谓词和过滤器,尽管它们在此书中其他地方没有使用。

路由请求到 product-composite API

例如,如果我们想将 URL 路径以 /product-composite/ 开头的传入请求路由到我们的 product-composite 服 务,我们可以指定一个如下的路由规则:

spring.cloud.gateway.routes:
- id: product-composite
  uri: lb://product-composite
  predicates:
  - Path=/product-composite/** 

从前面的代码中需要注意的一些点:

  • id: product-composite:路由的名称是 product-composite

  • uri: lb://product-composite:如果路由被其谓词选中,请求将被路由到在发现服务 Netflix Eureka 中命名为 product-composite 的服务。协议 lb:// 用于指示 Spring Cloud Gateway 使用客户端负载均衡器在发现服务中查找目标。

  • predicates: - Path=/product-composite/** 用于指定此路由应匹配哪些请求。** 匹配路径中的零个或多个元素。

为了能够将请求路由到第五章“使用 OpenAPI 添加 API 描述”中设置的 Swagger UI,向 product-composite 服 务添加了额外的路由:

- id: product-composite-swagger-ui
  uri: lb://product-composite
  predicates:
  - Path=/openapi/** 

/openapi/ 开头的 URI 开始发送到边缘服务器的请求将被路由到 product-composite 服 务。

当 Swagger UI 在边缘服务器后面呈现时,它必须能够呈现包含正确服务器 URL 的 API 的 OpenAPI 规范 – 即边缘服务器的 URL 而不是 product-composite 服务的 URL。为了使 product-composite 服 务能够在 OpenAPI 规范中生成正确的服务器 URL,已经向 product-composite 服 务添加了以下配置:

server.forward-headers-strategy: framework 

更多详情,请参阅 springdoc.org/index.html#how-can-i-deploy-springdoc-openapi-ui-behind-a-reverse-proxy

为了验证 OpenAPI 规范中已设置正确的服务器 URL,已在测试脚本 test-em-all.bash 中添加了以下测试:

 assertCurl 200 "curl -s  http://$HOST:$PORT/
    openapi/v3/api-docs"
  assertEqual "http://$HOST:$PORT" "$(echo $RESPONSE 
    | jq -r .servers[].url)" 

路由请求到 Eureka 服务器 API 和网页

Eureka 向其客户端公开了 API 和网页。为了在 Eureka 中提供 API 和网页之间的清晰分离,我们将设置如下路由:

  • /eureka/api/ 开头的路径发送到边缘服务器的请求应被视为对 Eureka API 的调用。

  • /eureka/web/ 开头的路径发送到边缘服务器的请求应被视为对 Eureka 网页的调用。

API 请求将被路由到 http://${app.eureka-server}:8761/eureka。Eureka API 的路由规则看起来像这样:

- id: eureka-api
  uri: http://${app.eureka-server}:8761
  predicates:
  - Path=/eureka/api/{segment}
  filters:
  - SetPath=/eureka/{segment} 

Path 值中的 {segment} 部分与路径中的零个或多个元素匹配,并将用于替换 SetPath 值中的 {segment} 部分。

网页请求将被路由到 http://${app.eureka-server}:8761。网页将加载多个网络资源,例如 .js.css.png 文件。这些请求将被路由到 http://${app.eureka-server}:8761/eureka。Eureka 网页的路由规则如下:

- id: eureka-web-start
  uri: http://${app.eureka-server}:8761
  predicates:
  - Path=/eureka/web
  filters:
  - SetPath=/
- id: eureka-web-other
  uri: http://${app.eureka-server}:8761
  predicates:
  - Path=/eureka/** 

从前面的配置中,我们可以得出以下结论。${app.eureka-server} 属性通过 Spring 的属性机制解析,取决于激活了哪个 Spring 配置文件:

  1. 当在不使用 Docker 的情况下在同一主机上运行服务,例如,出于调试目的时,该属性将使用 default 配置文件转换为 localhost

  2. 当以 Docker 容器运行服务时,Netflix Eureka 服务器将在一个名为 eureka 的容器中运行。因此,该属性将使用 docker 配置文件转换为 eureka

定义此翻译的 application.yml 文件中的相关部分如下:

app.eureka-server: localhost
---
spring.config.activate.on-profile: docker
app.eureka-server: eureka 

通过这种方式,我们已经看到了如何将请求路由到 product-composite 和 Eureka 服务器。作为最后一步,让我们看看如何在 Spring Cloud Gateway 中使用谓词和过滤器。

使用谓词和过滤器路由请求

要了解 Spring Cloud Gateway 中路由功能的一些更多信息,我们将尝试 基于主机的路由,其中 Spring Cloud Gateway 使用传入请求的主机名来确定请求的路由位置。我们将使用我最喜欢的网站来测试 HTTP 状态码:httpstat.us/

http://httpstat.us/${CODE} 的调用简单地返回一个包含 ${CODE} HTTP 状态码和响应体的响应,响应体中也包含 HTTP 状态码和相应的描述性文本。例如,请参阅以下 curl 命令:

curl http://httpstat.us/200 -i 

这将返回 HTTP 状态码 200,以及包含文本 200 OK 的响应体。

假设我们想要将 http://${hostname}:8080/headerrouting 的调用路由如下:

  • i.feel.lucky 主机的调用应返回 200 OK

  • im.a.teapot 主机的调用应返回 418 I'm a teapot

  • 对所有其他主机名的调用应返回 501 Not Implemented

要在 Spring Cloud Gateway 中实现这些路由规则,我们可以使用 Host 路由谓词来选择具有特定主机名的请求,并使用 SetPath 过滤器在请求路径中设置所需的 HTTP 状态码。可以按照以下方式完成:

  1. 要使对 http://i.feel.lucky:8080/headerrouting 的调用返回 200 OK,我们可以设置以下路由:

    - id: host_route_200
      uri: http://httpstat.us
      predicates:
      - Host=i.feel.lucky:8080
      - Path=/headerrouting/**
      filters:
      - SetPath=/200 
    
  2. 要使对 http://im.a.teapot:8080/headerrouting 的调用返回 418 I'm a teapot,我们可以设置以下路由:

    - id: host_route_418
      uri: http://httpstat.us
      predicates:
      - Host=im.a.teapot:8080
      - Path=/headerrouting/**
      filters:
      - SetPath=/418 
    
  3. 最后,为了使对所有其他主机名的调用返回 501 Not Implemented,我们可以设置以下路由:

    - id: host_route_501
      uri: http://httpstat.us
      predicates:
      - Path=/headerrouting/**
      filters:
      - SetPath=/501 
    

好的,这需要相当多的配置,所以现在让我们来试一试!

尝试边缘服务器

要尝试边缘服务器,我们执行以下步骤:

  1. 首先,使用以下命令构建 Docker 镜像:

    cd $BOOK_HOME/Chapter10
    ./gradlew clean build && docker-compose build 
    
  2. 接下来,在 Docker 中启动系统景观并使用以下命令运行常规测试:

    ./test-em-all.bash start 
    
  3. 预期输出类似于我们在前面的章节中看到的:

文本描述自动生成

图 10.2:test-em-all.bash 的输出

  1. 从日志输出中,注意倒数第二个测试结果,http://localhost:8080。这是验证 Swagger UI 的 OpenAPI 规范中服务器 URL 是否正确重写为边缘服务器 URL 的测试输出。

当系统景观,包括边缘服务器,启动并运行时,让我们探索以下主题:

  • 检查边缘服务器在 Docker 引擎中运行的系统景观之外暴露的内容。

  • 尝试以下最常用的路由规则:

    • 使用基于 URL 的路由来通过边缘服务器调用我们的 API

    • 使用基于 URL 的路由来通过边缘服务器调用 Swagger UI

    • 使用基于 URL 的路由来通过边缘服务器调用 Netflix Eureka,同时使用其 API 和基于 Web 的 UI

    • 使用基于头的路由来查看我们如何根据请求中的主机名来路由请求

检查 Docker 引擎外部暴露的内容

要了解边缘服务器向系统景观外部暴露了什么,请执行以下步骤:

  1. 使用docker-compose ps命令查看我们的服务暴露了哪些端口:

    docker-compose ps gateway eureka product-composite product recommendation review 
    
  2. 如以下输出所示,只有边缘服务器(命名为gateway)在其 Docker 引擎外部暴露了其端口(8080):

时间线描述自动生成,中等置信度

图 10.3:docker-compose ps 的输出

  1. 如果我们想查看边缘服务器设置了哪些路由,可以使用/actuator/gateway/routes API。这个 API 的响应相当冗长。为了限制响应只包含我们感兴趣的信息,我们可以应用一个jq过滤器。在以下示例中,选择了路由的id和请求将被路由到的uri

    curl localhost:8080/actuator/gateway/routes -s | jq '.[] | {"\(.route_id)": "\(.uri)"}' | grep -v '{\|}' 
    
  2. 此命令将响应如下:

文本描述自动生成

图 10.4:Spring Cloud Gateway 路由规则

这为我们提供了边缘服务器中实际配置的路由的良好概述。现在,让我们尝试这些路由!

尝试路由规则

在本节中,我们将尝试边缘服务器及其向系统景观外部暴露的路由。让我们首先调用product-composite API 及其 Swagger UI。然后,我们将调用 Eureka API 并访问其网页。最后,我们将通过测试基于主机名的路由来结束。

通过边缘服务器调用产品组合 API

让我们执行以下步骤来通过边缘服务器调用产品组合 API:

  1. 为了能够看到边缘服务器中正在发生的事情,我们可以跟随其日志输出:

    docker-compose logs -f --tail=0 gateway 
    
  2. 现在,在另一个终端窗口中,通过边缘服务器调用product-composite API:

    curl http://localhost:8080/product-composite/1 
    
  3. 预期从product-composite API 获得正常类型的响应:

图形用户界面  自动生成的描述

图 10.5:检索产品 ID 为 1 的复合产品的输出

  1. 我们应该在日志输出中找到以下信息:

文本  自动生成的描述

图 10.6:边缘服务器的日志输出

  1. 从日志输出中,我们可以看到基于我们在配置中指定的断言的模式匹配,并且我们可以看到边缘服务器从发现服务器中的可用实例中选择了哪个微服务实例——在这种情况下,它将请求转发到http://b8013440aea0:8080/product-composite/1

通过边缘服务器调用 Swagger UI

要验证我们是否可以通过边缘服务器访问到第五章中介绍的 Swagger UI,即使用 OpenAPI 添加 API 描述,请在网络浏览器中打开 URL http://localhost:8080/openapi/swagger-ui.html。生成的 Swagger UI 页面应如下所示:

图形用户界面,文本,应用程序  自动生成的描述

图 10.7:通过边缘服务器,网关的 Swagger UI

注意服务器 URL:http://localhost:8080;这意味着 Swagger UI 返回的 OpenAPI 规范中已经替换了product-composite API 自己的 URL,即http://product-service:8080/

如果你想的话,你可以在 Swagger UI 中实际尝试product-composite API,就像我们在第五章中做的那样,即使用 OpenAPI 添加 API 描述!

通过边缘服务器调用 Eureka

要通过边缘服务器调用 Eureka,请执行以下步骤:

  1. 首先,通过边缘服务器调用 Eureka API,以查看当前在发现服务器中注册的实例:

    curl -H "accept:application/json" \
    localhost:8080/eureka/api/apps -s | \
    jq -r .applications.application[].instance[].instanceId 
    
  2. 预期响应将与以下类似:

文本  自动生成的描述

图 10.8:Eureka 在 REST 调用中列出边缘服务器,网关

注意,边缘服务器(命名为gateway)也存在于响应中。

  1. 接下来,使用 URL http://localhost:8080/eureka/web在网页浏览器中打开 Eureka 网页:

图形用户界面,网站  自动生成的描述

图 10.9:Eureka 在 Web UI 中列出边缘服务器,网关

  1. 从前面的屏幕截图,我们可以看到 Eureka 网页报告了与上一步 API 响应中相同的可用实例。

基于主机头的路由

让我们通过测试基于请求中使用的主机名配置的路由来结束,以查看正在使用的断言和过滤器!

通常,请求中的主机名会由 HTTP 客户端自动设置在 Host 头部。当在本地测试边缘服务器时,主机名将是 localhost – 这在测试基于主机名的路由时并不那么有用。然而,我们可以在调用 API 时通过指定 Host 头部中的另一个主机名来作弊。让我们看看如何做到这一点:

  1. 要调用 i.feel.lucky 主机名,请使用以下代码:

    curl http://localhost:8080/headerrouting -H "Host: i.feel.lucky:8080" 
    
  2. 预期响应 200 OK

  3. 对于主机名 im.a.teapot,请使用以下命令:

    curl http://localhost:8080/headerrouting -H "Host: im.a.teapot:8080" 
    
  4. 预期响应 418 I'm a teapot

  5. 最后,如果不指定任何 Host 头部,请使用 localhost 作为 Host 头部:

    curl http://localhost:8080/headerrouting 
    
  6. 预期响应 501 Not Implemented

如果我们将 i.feel.luckyim.a.teapot 添加到 /etc/hosts 文件中,并指定它们应转换为与 localhost 相同的 IP 地址,即 127.0.0.1,我们也可以将它们用作请求中的真实主机名。为此,请执行以下步骤:

  1. 运行以下命令向 /etc/hosts 文件添加一行包含所需信息:

    sudo bash -c "echo '127.0.0.1 i.feel.lucky im.a.teapot' >> /etc/hosts" 
    
  2. 现在,我们可以根据主机名执行相同的路由,但无需指定 Host 头部。通过运行以下命令来尝试:

    curl http://i.feel.lucky:8080/headerrouting
    curl http://im.a.teapot:8080/headerrouting 
    
  3. 预期与之前相同的响应,200 OK418 I'm a teapot

  4. 通过以下命令关闭系统景观来结束测试:

    docker-compose down 
    
  5. 清理 /etc/hosts 文件中为主机名 i.feel.luckyim.a.teapot 添加的 DNS 名称转换。编辑 /etc/hosts 文件并删除我们添加的行:

    127.0.0.1 i.feel.lucky im.a.teapot 
    

这些对边缘服务器路由能力的测试结束了本章。

摘要

在本章中,我们看到了如何使用 Spring Cloud Gateway 作为边缘服务器来控制哪些服务可以从系统景观外部调用。基于断言、过滤器以及目标 URI,我们可以非常灵活地定义路由规则。如果我们想的话,我们可以配置 Spring Cloud Gateway 使用发现服务,如 Netflix Eureka 来查找目标微服务实例。

我们仍需要解决的一个重要问题是,我们如何防止对边缘服务器暴露的 API 的未授权访问,以及我们如何防止第三方拦截流量。

在下一章中,我们将看到如何使用标准安全机制,如 HTTPS、OAuth 和 OpenID Connect 来保护边缘服务器的访问。

问题

  1. 用于构建 Spring Cloud Gateway 路由规则中的元素被称为什么?

  2. 这些上述元素用于什么?

  3. 我们如何指导 Spring Cloud Gateway 通过发现服务,如 Netflix Eureka 来定位微服务实例?

  4. 在 Docker 环境中,我们如何确保外部 HTTP 请求只能到达边缘服务器?

  5. 我们如何更改路由规则,以便边缘服务器接受对 http://$HOST:$PORT/api/product URL 上的 product-composite 服务的调用,而不是当前使用的 http://$HOST:$PORT/product-composite

加入我们的 Discord 社区

加入我们的 Discord 空间,与作者和其他读者进行讨论:

packt.link/SpringBoot3e

第十一章:保护 API 访问

在本章中,我们将看到如何保护上一章中介绍的边缘服务器暴露的 API 和网页的访问。我们将学习如何使用 HTTPS 保护对外部访问我们 API 的监听,以及如何使用 OAuth 2.0 和 OpenID Connect 认证和授权用户和客户端应用程序访问我们的 API。最后,我们将使用 HTTP Basic 认证来保护对发现服务器 Netflix Eureka 的访问。

本章将涵盖以下主题:

  • OAuth 2.0 和 OpenID Connect 标准简介

  • 关于如何保护系统架构的一般讨论

  • 使用 HTTPS 保护外部通信

  • 保护对发现服务器,Netflix Eureka 的访问

  • 将本地授权服务器添加到我们的系统架构中

  • 使用 OAuth 2.0 和 OpenID Connect 认证和授权 API 访问

  • 使用本地授权服务器进行测试

  • 使用外部 OpenID Connect 提供商 Auth0 进行测试

技术要求

关于如何安装本书中使用的工具以及如何访问本书源代码的说明,请参阅:

  • 第二十一章macOS 安装说明

  • 第二十二章使用 WSL 2 和 Ubuntu 的 Microsoft Windows 安装说明

本章中的代码示例全部来自 $BOOK_HOME/Chapter11 中的源代码。

如果你想查看对本章源代码所做的更改,即查看在微服务领域中保护 API 访问所需要的内容,你可以将其与 第十章使用 Spring Cloud Gateway 在边缘服务器后面隐藏微服务 的源代码进行比较。你可以使用你喜欢的 diff 工具比较两个文件夹,$BOOK_HOME/Chapter10$BOOK_HOME/Chapter11

OAuth 2.0 和 OpenID Connect 简介

在介绍 OAuth 2.0 和 OpenID Connect 之前,让我们先明确一下我们所说的认证和授权的含义。认证意味着通过验证用户提供的凭据(如用户名和密码)来识别用户。授权是指允许经过认证的用户访问各种部分,在我们的例子中,是访问 API。

OAuth 2.0 是一个用于授权委托的开放标准,OpenID Connect 是 OAuth 2.0 的附加组件,它允许客户端应用程序根据授权服务器执行的认证来验证用户的身份。让我们简要地看看 OAuth 2.0 和 OpenID Connect,以获得它们目的的初步了解!

介绍 OAuth 2.0

OAuth 2.0 是一个广泛接受的开放标准,用于授权,它允许用户同意第三方客户端应用程序以用户的名义访问受保护资源。将代表用户执行操作的权利(例如,调用 API)授予第三方客户端应用程序,称为授权委托

那么,这又意味着什么呢?

让我们先理清所使用的概念:

  • 资源所有者:最终用户。

  • 客户端:第三方客户端应用程序,例如,一个网络应用程序或原生移动应用程序,它希望代表最终用户调用受保护的 API。

  • 资源服务器:暴露我们想要保护的 API 的服务器。

  • 授权服务器:在资源所有者,即最终用户经过身份验证后,授权服务器向客户端发放令牌。用户信息和用户身份验证的管理通常在幕后委托给一个身份提供者IdP)。

客户端在授权服务器中注册,并分配一个客户端 ID和一个客户端密钥。客户端密钥必须由客户端保护,就像密码一样。客户端还注册了一组允许的重定向 URI,授权服务器在用户经过身份验证后使用这些 URI 发送授权码令牌,并将它们发送回客户端应用程序。

以下是一个示例,以供说明。假设用户访问第三方客户端应用程序,并且客户端应用程序希望调用受保护的 API 来服务用户。为了允许访问这些 API,客户端应用程序需要一种方法来告诉 API 它是在代表用户行事。为了避免用户必须与客户端应用程序共享其凭据以进行身份验证的解决方案,授权服务器会发放一个访问令牌,该令牌允许客户端应用程序代表用户有限地访问一组选定的 API。

这意味着用户永远不需要向客户端应用程序透露其凭据。用户还可以同意客户端应用程序代表用户访问特定的 API。访问令牌代表一组时间限制的访问权限,在 OAuth 2.0 术语中表达为作用域。授权服务器还可以向客户端应用程序发放一个刷新令牌。刷新令牌可以被客户端应用程序用来获取新的访问令牌,而无需涉及用户。

OAuth 2.0 规范定义了四种用于发放访问令牌的授权流程,如下所述:

  • 授权码授权流程:这是最安全的,但也是最复杂的授权流程。此授权流程要求用户通过网页浏览器与授权服务器进行交互以进行身份验证并同意客户端应用程序,如下面的图示所示:

图示描述自动生成

图 11.1:OAuth 2.0 – 授权码授权流程

下面是这个图示中发生的事情:

  1. 客户端应用程序通过将用户发送到授权服务器(在网页浏览器中)来启动授权流程。

  2. 授权服务器将验证用户并请求用户的同意。

  3. 授权服务器将用户重定向回客户端应用程序,并附带一个授权代码。授权服务器将使用客户端在 步骤 1 中指定的重定向 URI来确定发送授权代码的位置。由于授权代码是通过网页浏览器返回给客户端应用程序的,即在一个可能存在恶意 JavaScript 代码潜在获取授权代码的不安全环境中,因此它只能使用一次,并且只能在短时间内使用。

  4. 为了将授权代码交换为访问令牌,客户端应用程序需要再次调用授权服务器。客户端应用程序必须向授权服务器出示其客户端 ID、客户端密钥以及授权代码。由于客户端密钥是敏感信息且必须受到保护,此调用必须从服务器端代码执行。

  5. 授权服务器发放访问令牌并将其发送回客户端应用程序。授权服务器还可以选择性地发放并返回一个刷新令牌。

  6. 使用访问令牌,客户端可以向资源服务器公开的保护 API 发送请求。

  7. 资源服务器验证访问令牌,在验证成功的情况下提供服务。步骤 6步骤 7 可以在访问令牌有效的情况下重复进行。当访问令牌的生命周期已过期时,客户端可以使用其刷新令牌来获取一个新的访问令牌。

  • 隐式授权流程:这个流程也是基于网页浏览器的,但旨在用于无法保护客户端密钥的客户端应用程序,例如单页网页应用程序。网页浏览器从授权服务器获取访问令牌,而不是授权代码。由于隐式授权流程比授权代码授权流程安全性较低,客户端无法请求刷新令牌。

  • 资源所有者密码凭证授权流程:如果客户端应用程序无法与网页浏览器交互,它可以回退到这个授权流程。在这个授权流程中,用户必须与客户端应用程序共享其凭证,客户端应用程序将使用这些凭证来获取访问令牌。

  • 客户端凭证授权流程:在客户端应用程序需要调用与特定用户无关的 API 的情况下,它可以使用此授权流程,通过其自己的客户端 ID 和客户端密钥来获取访问令牌。

完整规范可在此处找到:tools.ietf.org/html/rfc6749。还有许多其他规范详细说明了 OAuth 2.0 的各个方面;欲了解概述,请参阅www.oauth.com/oauth2-servers/map-oauth-2-0-specs/。一个值得额外关注的规定是RFC 7636 – OAuth 公共客户端的代码交换证明密钥(简称PKCE),tools.ietf.org/html/rfc7636

本规范描述了如何通过添加额外的安全层,以安全的方式利用授权代码授权流程,使原本不安全的公共客户端,如移动原生应用或桌面应用程序,能够安全地使用该授权流程。

OAuth 2.0 规范于 2012 年发布,多年来,人们从 OAuth 2.0 的使用中吸取了许多经验教训。2019 年,开始建立 OAuth 2.1,整合了 OAuth 2.0 使用中的所有最佳实践和经验。草稿版本可在此处找到:tools.ietf.org/html/draft-ietf-oauth-v2-1-08

在我看来,OAuth 2.1 最重要的改进是:

  • PKCE 已集成到授权代码授权流程中。如上所述,公共客户端将需要使用 PKCE 来提高其安全性。对于授权服务器可以验证其凭证的机密客户端,PKCE 的使用不是必需的,但建议使用。

  • 由于其安全性较低,隐式授权流程已被弃用,并从规范中省略。

  • 资源所有者密码凭证授权流程也被弃用,并从规范中省略,原因相同。

鉴于即将发布的 OAuth 2.1 规范的方向,我们将在本书中仅使用授权代码授权流程和客户端凭证授权流程。

当涉及到对由 OAuth 2.0 保护的 API 进行自动化测试时,客户端凭证授权流程非常方便,因为它不需要使用网络浏览器进行手动交互。我们将在本章后面的测试脚本中使用此授权流程;请参阅测试脚本中的更改部分。

介绍 OpenID Connect

OpenID Connect(缩写为OIDC),正如之前提到的,是 OAuth 2.0 的一个附加组件,它使客户端应用程序能够验证用户的身份。OIDC 增加了一个额外的令牌,即 ID 令牌,客户端应用程序在完成授权流程后从授权服务器获取该令牌。

ID 令牌被编码为JSON Web TokenJWT),并包含多个声明,例如用户的 ID 和电子邮件地址。ID 令牌使用 JSON Web 签名进行数字签名。这使得客户端应用程序可以通过使用授权服务器的公钥验证其数字签名来信任 ID 令牌中的信息。

可选地,访问令牌也可以像 ID 令牌一样进行编码和签名,但根据规范,这不是强制的。同样重要的是,OIDC 定义了一个发现端点,这是一种标准化的方式来建立到重要端点的 URL,例如请求授权代码和令牌或获取公钥以验证数字签名的 JWT。最后,它还定义了一个用户信息端点,可以使用该端点获取有关给定用户的访问令牌的额外信息。

有关可用规范的概述,请参阅openid.net/developers/specs/

在本书中,我们只使用符合 OpenID Connect 规范的授权服务器。这将通过使用它们的发现端点简化资源服务器的配置。我们还将使用对数字签名 JWT 访问令牌的可选支持来简化资源服务器验证访问令牌真实性的方式。请参阅下文中的边缘服务器和产品组合服务的变化部分。

这就结束了我们对 OAuth 2.0 和 OpenID Connect 标准的介绍。在本章的稍后部分,我们将了解如何使用这些标准。在下一节中,我们将从高层次了解系统景观将如何得到保护。

保护系统景观

为了确保本章引言中描述的系统景观安全,我们将执行以下步骤:

  1. 使用 HTTPS 加密对我们外部 API 的外部请求和响应,以防止窃听。

  2. 使用 OAuth 2.0 和 OpenID Connect 对访问我们的 API 的用户和客户端应用程序进行身份验证和授权。

  3. 使用 HTTP 基本身份验证保护对发现服务器 Netflix Eureka 的访问。

我们将只为与边缘服务器的外部通信应用 HTTPS,而在系统景观内部通信时使用纯 HTTP。

在本书稍后出现的关于服务网格的章节(第十八章,使用服务网格提高可观察性和管理)中,我们将看到如何从服务网格产品中获得帮助,以自动配置 HTTPS 来保护系统景观内的通信。

为了测试目的,我们将在系统景观中添加一个本地的 OAuth 2.0 授权服务器。所有与授权服务器的外部通信将通过边缘服务器路由。边缘服务器和product-composite服务将作为 OAuth 2.0 资源服务器;也就是说,它们将需要有效的 OAuth 2.0 访问令牌才能允许访问。

为了最小化验证访问令牌的开销,我们假设它们被编码为签名 JWT,并且授权服务器公开了一个端点,资源服务器可以使用该端点来访问公钥,也称为JSON Web Key Set,或简称jwk-set,这是验证签名所必需的。

系统景观将如下所示:

图描述自动生成

图 11.2:将授权服务器添加到系统景观

从前面的图中,我们可以注意到:

  • HTTPS 用于外部通信,而纯文本 HTTP 用于系统景观内部。

  • 本地 OAuth 2.0 授权服务器将通过边缘服务器从外部访问。

  • 边缘服务器和product-composite微服务都将验证访问令牌作为已签名的 JWT。

  • 边缘服务器和product-composite微服务将从其jwk-set端点获取授权服务器的公钥,并使用它们来验证基于 JWT 的访问令牌的签名。

注意,我们将专注于通过 HTTP 保护对 API 的访问,而不是涵盖一般性的最佳实践,例如,管理由OWASP Top Ten 项目指出的 Web 应用程序安全风险。有关更多信息,请参阅owasp.org/www-project-top-ten/

在了解了系统景观如何被保护之后,让我们看看我们如何可以使用 HTTPS 保护外部通信免受窃听。

使用 HTTPS 保护外部通信

在本节中,我们将学习如何防止外部通信(例如,从互联网,通过边缘服务器公开的公共 API)被窃听。我们将使用 HTTPS 来加密通信。要使用 HTTPS,我们需要执行以下操作:

  • 创建证书:我们将创建自己的自签名证书,这对于开发目的来说是足够的。

  • 配置边缘服务器:它必须配置为仅接受使用证书的基于 HTTPS 的外部流量。

使用以下命令创建自签名证书:

keytool -genkeypair -alias localhost -keyalg RSA -keysize 2048 -storetype PKCS12 -keystore edge.p12 -validity 3650 

源代码附带一个示例证书文件,因此您不需要运行此命令来运行以下示例。

命令将要求输入多个参数。当要求输入密码时,我输入了password。对于其余的参数,我简单地输入了一个空值以接受默认值。创建的证书文件edge.p12被放置在gateway项目的src/main/resources/keystore文件夹中。这意味着证书文件将在构建时放置在.jar文件中,并在运行时在keystore/edge.p12的类路径上可用。

在开发期间,使用类路径提供证书是足够的,但不适用于其他环境,例如,生产环境。下面将说明如何在运行时使用外部证书替换此证书!

要配置边缘服务器使用证书和 HTTPS,以下内容被添加到gateway项目的application.yml中:

server.port: 8443
server.ssl:
 key-store-type: PKCS12
 key-store: classpath:keystore/edge.p12
 key-store-password: password
 key-alias: localhost 

以下是从前面的源代码中的一些注意事项:

  • 证书的路径由server.ssl.key-store参数指定,并设置为classpath:keystore/edge.p12。这意味着证书将从keystore/edge.p12的位置在类路径中检索。

  • 证书的密码指定在server.ssl.key-store-password参数中。

  • 为了表明边缘服务器使用 HTTPS 而不是 HTTP,我们还将在server.port参数中将端口从8080更改为8443

除了在边缘服务器中的这些更改外,还需要在以下文件中进行更改,以反映端口和 HTTP 协议的更改,将HTTP替换为HTTPS,将8080替换为8443

  • 三个 Docker Compose 文件,docker-compose*.yml

  • 测试脚本,test-em-all.bash

如前所述,使用类路径提供证书仅适用于开发阶段。让我们看看我们如何在运行时用外部证书替换此证书。

在运行时替换自签名证书

.jar文件中放置自签名证书仅对开发有用。对于运行时环境中的工作解决方案,例如测试或生产,必须能够使用由授权CA(简称证书颁发机构)签名的证书。

必须也能够在运行时指定要使用的证书,而无需重新构建.jar文件,当使用 Docker 时,包含.jar文件的 Docker 镜像。当使用 Docker Compose 管理 Docker 容器时,我们可以将 Docker 容器中的卷映射到 Docker 主机上的证书。我们还可以为 Docker 容器设置环境变量,指向 Docker 卷中的外部证书。

第十五章Kubernetes 简介中,我们将学习 Kubernetes,我们将看到如何处理秘密,如证书,这些秘密适合在集群中运行 Docker 容器;也就是说,容器是在一组 Docker 主机上而不是在单个 Docker 主机上调度。

本主题中描述的更改尚未应用于书中 GitHub 仓库的源代码;您需要自行进行更改才能看到其效果!

要替换打包在.jar文件中的证书,请执行以下步骤:

  1. 创建第二个证书,并在被要求时将其密码设置为testtest

    cd $BOOK_HOME/Chapter11
    mkdir keystore
    keytool -genkeypair -alias localhost -keyalg RSA -keysize 2048 -storetype PKCS12 -keystore keystore/edge-test.p12 -validity 3650 
    
  2. 更新 Docker Compose 文件,docker-compose.yml,包含位置环境变量、新证书的密码以及映射到放置新证书的文件夹的卷。更改后边缘服务器的配置如下:

    gateway:
      environment:
        - SPRING_PROFILES_ACTIVE=docker
        - SERVER_SSL_KEY_STORE=file:/keystore/edge-test.p12
        - SERVER_SSL_KEY_STORE_PASSWORD=testtest
      volumes:
        - $PWD/keystore:/keystore
      build: spring-cloud/gateway
      mem_limit: 512m
      ports:
        - "8443:8443" 
    
  3. 如果边缘服务器正在运行,则需要使用以下命令重新启动:

    docker-compose up -d --scale gateway=0
    docker-compose up -d --scale gateway=1 
    

docker-compose restart gateway命令可能看起来是重启网关服务的良好候选,但实际上它没有考虑docker-compose.yml中的更改。因此,在这种情况下,它不是一个有用的命令。

  1. 新的证书现在正在使用中!

这部分关于如何使用 HTTPS 保护外部通信的内容到此结束。在下一部分,我们将学习如何使用 HTTP 基本认证来保护发现服务器,Netflix Eureka 的访问。

保护发现服务器访问

之前,我们学习了如何使用 HTTPS 保护外部通信。现在我们将使用 HTTP 基本认证来限制对发现服务器 Netflix Eureka 上的 API 和网页的访问。这意味着我们需要用户提供一个用户名和密码来获取访问权限。需要在 Eureka 服务器和 Eureka 客户端上进行更改,具体如下。

Eureka 服务器的更改

为了保护 Eureka 服务器,以下更改已应用于源代码:

  1. build.gradle中添加了对 Spring Security 的依赖项:

    implementation 'org.springframework.boot:spring-boot-starter-security' 
    
  2. 已将安全配置添加到SecurityConfig类中:

    1. 用户定义如下:

      @Bean
      public InMemoryUserDetailsManager userDetailsService() {
        UserDetails user = User.withDefaultPasswordEncoder()
            .username(username)
            .password(password)
            .roles("USER")
            .build();
        return new InMemoryUserDetailsManager(user);
      } 
      
    2. usernamepassword从配置文件注入到构造函数中:

      @Autowired
      public SecurityConfig(
        @Value("${app.eureka-username}") String username,
        @Value("${app.eureka-password}") String password
      ) {
        this.username = username;
        this.password = password;
      } 
      
    3. 所有 API 和网页都通过以下定义使用 HTTP 基本认证进行保护:

      @Bean
      public SecurityFilterChain configure(HttpSecurity http) throws Exception {
        http
          // Disable CRCF to allow services to register themselves with Eureka
          .csrf()
            .disable()
          .authorizeRequests()
            .anyRequest().authenticated()
            .and()
            .httpBasic();
        return http.build();
      } 
      
  3. 用户凭证在配置文件application.yml中设置:

    app:
     eureka-username: u
     eureka-password: p 
    
  4. 最后,测试类EurekaServerApplicationTests在测试 Eureka 服务器的 API 时使用配置文件中的凭证:

    @Value("${app.eureka-username}")
    private String username;
    
    @Value("${app.eureka-password}")
    private String password;
    
    @Autowired
    public void setTestRestTemplate(TestRestTemplate testRestTemplate) {
       this.testRestTemplate = testRestTemplate.withBasicAuth(username, password);
    } 
    

上述是限制对发现服务器 Netflix Eureka 的 API 和网页访问所需的步骤。现在它将使用 HTTP 基本认证,并要求用户提供用户名和密码来获取访问权限。最后一步是配置 Netflix Eureka 客户端,以便在访问 Netflix Eureka 服务器时传递凭证。

Eureka 客户端的更改

对于 Eureka 客户端,凭证可以指定在 Eureka 服务器的连接 URL 中。这在每个客户端的配置文件application.yml中指定,如下所示:

app:
  eureka-username: u
  eureka-password: p

eureka:
  client:
     serviceUrl:
       defaultZone: "http://${app.eureka-username}:${app.eureka-
                     password}@${app.eureka-server}:8761/eureka/" 

这部分关于如何限制对 Netflix Eureka 服务器访问的内容到此结束。在测试受保护的发现服务器部分,我们将运行测试以验证访问是否受到保护。在下一部分,我们将学习如何将本地授权服务器添加到系统架构中。

添加本地授权服务器

为了能够使用 OAuth 2.0 和 OpenID Connect 安全使用的 API 在本地和完全自动化地运行测试,我们将添加一个符合这些规范的授权服务器到我们的系统景观中。历史上,Spring Security 没有提供开箱即用的授权服务器。但在 2020 年 4 月,由 Spring Security 团队领导的一个社区驱动项目,Spring Authorization Server,宣布旨在提供授权服务器。到 2021 年 8 月,Spring Authorization Server 项目从实验状态移出,成为 Spring 项目组合的一部分。

更多信息,请参阅spring.io/blog/2020/04/15/announcing-the-spring-authorization-serverspring.io/blog/2021/08/17/spring-authorization-server-officially-moves-to-spring-projects

Spring Authorization Server 支持使用 OpenID Connect 发现端点和访问令牌的数字签名。它还提供了一个端点,可以使用发现信息访问以获取验证令牌数字签名的密钥。有了这些功能的支持,它可以作为本地和自动测试中的授权服务器使用,以验证系统景观按预期工作。

本书中的授权服务器基于 Spring Authorization Server 项目提供的示例授权服务器;请参阅github.com/spring-projects/spring-authorization-server/tree/main/samples/default-authorizationserver

对示例项目已应用以下更改:

  • 构建文件已更新,以遵循本书中其他项目的构建文件结构。

  • 端口已设置为9999

  • 已添加与本书中其他项目相同的结构的 Dockerfile。

  • 授权服务器已与 Eureka 集成,以实现与本书中其他项目相同的服务发现方式。

  • 已添加对 actuator 端点的公共访问。

警告:正如在第七章开发响应式微服务中已警告的那样,允许公共访问 actuator 的端点在开发期间非常有帮助,但在生产系统中透露过多信息可能会成为安全问题。因此,计划最小化生产中 actuator 端点暴露的信息!

  • 已添加单元测试,以验证根据 OpenID Connect 规范对最关键端点的访问。

  • 单个注册用户的用户名和密码分别设置为up

  • 已注册两个 OAuth 客户端,readerwriterreader 客户端被授予 product:read 范围,而 writer 客户端被授予 product:readproduct:write 范围。客户端被配置为将它们的客户端密钥设置为 secret-readersecret-writer

  • 客户端的允许重定向 URI 设置为 https://my.redirect.urihttps://localhost:8443/webjars/swagger-ui/oauth2-redirect.html。下面的测试中将使用第一个 URI,Swagger UI 组件将使用第二个 URI。

  • 默认情况下,出于安全原因,授权服务器不允许以 https://localhost 开头的重定向 URI。

授权服务器已被定制以接受 https://localhost 用于开发和测试目的。所应用的定制在此处描述:docs.spring.io/spring-authorization-server/docs/1.0.0/reference/html/protocol-endpoints.html#oauth2-authorization-endpoint-customizing-authorization-request-validation

授权服务器的源代码可在 $BOOK_HOME/Chapter11/spring-cloud/authorization-server 中找到。

要将授权服务器纳入系统架构,以下文件已应用了更改:

  • 服务器已被添加到通用构建文件中,settings.gradle

  • 服务器已被添加到三个 Docker Compose 文件中,docker-compose*.yml

  • 边缘服务器,spring-cloud/gateway

  • HealthCheckConfiguration 中为授权服务器添加了健康检查。

  • 在配置文件 application.yml 中已添加指向以 /oauth/login/error 开头的 URI 的授权服务器路由。这些 URI 用于向客户端颁发令牌、验证用户和显示错误消息。

  • 由于这三个 URI 需要由边缘服务器进行保护,因此它们被配置在新类 SecurityConfig 中以允许所有请求。

在理解了如何将本地授权服务器添加到系统架构之后,让我们继续看看如何使用 OAuth 2.0 和 OpenID Connect 来验证和授权对 API 的访问。

使用 OAuth 2.0 和 OpenID Connect 保护 API

在设置好授权服务器之后,我们可以增强边缘服务器和 product-composite 服务以成为 OAuth 2.0 资源服务器,这样它们将需要有效的访问令牌才能允许访问。边缘服务器将被配置为接受授权服务器提供的数字签名验证的任何访问令牌。product-composite 服务也将需要访问令牌包含有效的 OAuth 2.0 范围:

  • product:read 范围将用于访问只读 API。

  • product:write 范围将用于访问创建和删除 API。

product-composite 服务也将通过一个配置进行增强,允许其 Swagger UI 组件与授权服务器交互以颁发访问令牌。这将允许 Swagger UI 网页的用户测试受保护的 API。

我们还需要增强测试脚本 test-em-all.bash,以便在执行测试时获取访问令牌并使用它们。

边缘服务器和产品组合服务的更改

在源代码中,对边缘服务器和 product-composite 服务都应用了以下更改:

  • Spring Security 依赖项已添加到 build.gradle 文件中,以支持 OAuth 2.0 资源服务器:

    implementation 'org.springframework.boot:spring-boot-starter-security'
    implementation 'org.springframework.security:spring-security-oauth2-resource-server'
    implementation 'org.springframework.security:spring-security-oauth2-jose' 
    
  • 在两个项目中都添加了新的 SecurityConfig 类的安全配置:

    @Configuration
    @EnableWebFluxSecurity
    public class SecurityConfig {
    
      @Bean
      SecurityWebFilterChain springSecurityFilterChain(
          ServerHttpSecurity http) {
        http
          .authorizeExchange()
            .pathMatchers("/actuator/**").permitAll()
            .anyExchange().authenticated()
            .and()
          .oauth2ResourceServer()
            .jwt();
        return http.build();
      }
    } 
    

以下是对前面源代码的解释:

  • @EnableWebFluxSecurity 注解启用了基于 Spring WebFlux 的 API 的 Spring Security 支持。

  • .pathMatchers("/actuator/**").permitAll() 用于允许对应该不受保护的 URL 的无限制访问,例如,在本例中的 actuator 端点。请参阅源代码以了解被视为不受保护的 URL。小心选择哪些 URL 不受保护。例如,在投入生产之前,应保护 actuator 端点。

  • .anyExchange().authenticated() 确保用户在允许访问所有其他 URL 之前已进行身份验证。

  • .oauth2ResourceServer().jwt() 指定授权将基于编码为 JWT 的 OAuth 2.0 访问令牌。

  • 授权服务器的 OIDC 发现端点已在配置文件 application.yml 中注册:

    app.auth-server: localhost
    spring.security.oauth2.resourceserver.jwt.issuer-uri: http://${app.auth-server}:9999
    ---
    spring.config.activate.on-profile: docker
    app.auth-server: auth-server 
    

L

在本章的后面部分,当系统景观启动时,您可以测试发现端点。例如,您可以使用以下命令找到返回用于验证令牌数字签名的密钥的端点:

docker-compose exec auth-server curl localhost:9999/.well-known/openid-configuration -s | jq -r .jwks_uri 

我们还需要对仅适用于 product-composite 服务的某些更改。

仅在产品组合服务中进行的更改

除了上一节中应用的常见更改外,以下更改也已应用于 product-composite 服务:

  • SecurityConfig 类中的安全配置已通过要求访问令牌中的 OAuth 2.0 范围来细化,以便允许访问:

    .pathMatchers(POST, "/product-composite/**")
      .hasAuthority("SCOPE_product:write")
    .pathMatchers(DELETE, "/product-composite/**")
      .hasAuthority("SCOPE_product:write")
    .pathMatchers(GET, "/product-composite/**")
      .hasAuthority("SCOPE_product:read") 
    

按照惯例,当使用 Spring Security 检查权限时,OAuth 2.0 范围需要以 SCOPE_ 前缀开头。

  • 已添加了一个方法 logAuthorizationInfo(),用于在每次调用 API 时记录 JWT 编码的访问令牌的相关部分。可以使用标准的 Spring Security SecurityContext 获取访问令牌,在反应式环境中,可以使用静态辅助方法 ReactiveSecurityContextHolder.getContext() 获取。有关详细信息,请参阅 ProductCompositeServiceImpl 类。

  • 在运行基于 Spring 的集成测试时,已禁用 OAuth 的使用。为了防止在运行集成测试时 OAuth 机制启动,我们按以下方式禁用它:

    • 添加了一个安全配置TestSecurityConfig,用于测试期间使用。它允许访问所有资源:

      http.csrf().disable().authorizeExchange().anyExchange().permitAll(); 
      
    • 在每个 Spring 集成测试类中,我们配置TestSecurityConfig以覆盖现有的安全配置,如下所示:

      @SpringBootTest( 
        classes = {TestSecurityConfig.class},
        properties = {"spring.main.allow-bean-definition-
          overriding=true"}) 
      

允许 Swagger UI 获取访问令牌的更改

为了允许从 Swagger UI 组件访问受保护的 API,已在product-composite服务中应用以下更改:

  • 由 Swagger UI 组件暴露的网页已被配置为公开可用。以下行已添加到SecurityConfig类中:

    .pathMatchers("/openapi/**").permitAll()
    .pathMatchers("/webjars/**").permitAll() 
    
  • API 的 OpenAPI 规范已被增强,要求应用安全模式security_auth

以下行已添加到API项目中接口ProductCompositeService的定义中:

@SecurityRequirement(name = "security_auth") 
  • 为了定义安全模式security_auth的语义,已在product-composite项目中添加了OpenApiConfig类。它看起来像这样:

    @SecurityScheme(
      name = "security_auth", type = SecuritySchemeType.OAUTH2,
      flows = @OAuthFlows(
        authorizationCode = @OAuthFlow(
          authorizationUrl = "${springdoc.oAuthFlow.
            authorizationUrl}",
          tokenUrl = "${springdoc.oAuthFlow.tokenUrl}", 
          scopes = {
            @OAuthScope(name = "product:read", description =
              "read scope"),
            @OAuthScope(name = "product:write", description = 
              "write scope")
          }
    )))
    public class OpenApiConfig {} 
    

从前面的类定义中,我们可以看到:

  1. 安全模式将基于 OAuth 2.0。

  2. 将使用授权代码授予流程。

  3. 获取授权代码和访问令牌所需的 URL 将由配置通过参数springdoc.oAuthFlow.authorizationUrlspringdoc.oAuthFlow.tokenUrl提供。

  4. Swagger UI 将需要的一组作用域(product:readproduct:write),以便能够调用 API。

  • 最后,一些配置被添加到application.yml中:

     swagger-ui:
        oauth2-redirect-url: /swagger-ui/oauth2-redirect.html
        oauth:
          clientId: writer
          clientSecret: secret-writer
          useBasicAuthenticationWithAccessCodeGrant: true
      oAuthFlow:
        authorizationUrl: https://localhost:8443/oauth2/authorize
        tokenUrl: https://localhost:8443/oauth2/token 
    

从前面的配置中,我们可以看到:

  1. Swagger UI 将使用该 URL 来获取授权代码的重定向 URL。

  2. 其客户端 ID 和客户端密钥。

  3. 它将使用 HTTP 基本认证来识别授权服务器。

  4. 上文所述的OpenApiConfig类使用的authorizationUrltokenUrl参数的值。请注意,这些 URL 是由网络浏览器使用的,而不是由product-composite服务本身使用的。因此,它们必须可以从网络浏览器解析。

为了允许对 Swagger UI 网页的无保护访问,边缘服务器也已配置为允许对路由到 Swagger UI 组件的 URL 进行无限制访问。以下行被添加到边缘服务器的SecurityConfig类中:

.pathMatchers("/openapi/**").permitAll()
.pathMatchers("/webjars/**").permitAll() 

在这些更改到位后,边缘服务器和product-composite服务都可以作为 OAuth 2.0 资源服务器,Swagger UI 组件可以作为 OAuth 客户端。引入 OAuth 2.0 和 OpenID Connect 使用的最后一步是更新测试脚本,以便在运行测试时获取访问令牌并使用它们。

测试脚本的更改

首先,我们需要在调用任何 API(除了健康 API)之前获取一个访问令牌。这就像之前提到的那样,使用 OAuth 2.0 客户端凭据流来完成。为了能够调用创建和删除 API,我们以writer客户端的身份获取一个访问令牌,如下所示:

ACCESS_TOKEN=$(curl -k https://writer:secret-writer@$HOST:$PORT/oauth2/token -d grant_type=client_credentials -d scope="product:read product:write" -s | jq .access_token -r) 

从前面的命令中,我们可以看到它使用了 HTTP 基本认证,在主机名之前传递其客户端 ID 和客户端密钥作为writer:secret-writer@

为了验证基于范围的授权是否工作,测试脚本中增加了两个测试:

# Verify that a request without access token fails on 401, Unauthorized
assertCurl 401 "curl -k https://$HOST:$PORT/product-composite/$PROD_ID_REVS_RECS -s"
# Verify that the reader client with only read scope can call the read API but not delete API
READER_ACCESS_TOKEN=$(curl -k https://reader:secret-reader@$HOST:$PORT/oauth2/token -d grant_type=client_credentials -d scope="product:read" -s | jq .access_token -r)
READER_AUTH="-H \"Authorization: Bearer $READER_ACCESS_TOKEN\""
assertCurl 200 "curl -k https://$HOST:$PORT/product-composite/$PROD_ID_REVS_RECS $READER_AUTH -s"
assertCurl 403 "curl -k https://$HOST:$PORT/product-composite/$PROD_ID_REVS_RECS $READER_AUTH -X DELETE -s" 

测试脚本使用读取器客户端的凭据来获取访问令牌:

  • 第一个测试在未提供访问令牌的情况下调用 API。API 预期将返回401 Unauthorized HTTP 状态。

  • 第二个测试验证读取器客户端可以调用只读 API。

  • 最后一个测试调用了一个使用reader客户端的更新 API,该客户端仅被授予read权限。向删除 API 发送的请求预期将返回403 Forbidden HTTP 状态。

对于完整的源代码,请参阅test-em-all.bash

在测试脚本更新为获取和使用 OAuth 2.0 访问令牌后,我们就可以在下一节尝试它了!

使用本地授权服务器进行测试

在本节中,我们将尝试测试受保护的系统环境;也就是说,我们将一起测试所有安全组件。我们将使用本地授权服务器来颁发访问令牌。以下将执行以下测试:

  1. 首先,我们从源代码构建并运行测试脚本,以确保一切都能正常工作。

  2. 接下来,我们将测试受保护的发现服务器的 API 和网页。

  3. 之后,我们将学习如何使用 OAuth 2.0 客户端凭据流和授权码授权流获取访问令牌。

  4. 使用颁发的访问令牌,我们将测试受保护的 API。我们还将验证为读取器客户端颁发的访问令牌不能用于调用更新 API。

  5. 最后,我们还将验证 Swagger UI 是否可以颁发访问令牌并调用 API。

构建和运行自动化测试

要构建和运行自动化测试,我们执行以下步骤:

  1. 使用以下命令从源代码构建 Docker 镜像:

    cd $BOOK_HOME/Chapter11
    ./gradlew build && docker-compose build 
    
  2. 使用以下命令在 Docker 中启动系统环境并运行常规测试:

    ./test-em-all.bash start 
    

注意最后的新的负面测试,验证在未认证时返回401 Unauthorized代码,在未授权时返回403 Forbidden

测试受保护的发现服务器

在受保护的发现服务器 Eureka 启动并运行后,我们必须提供有效的凭据才能访问其 API 和网页。

例如,可以通过以下curl命令请求 Eureka 服务器注册的实例,其中我们直接在 URL 中提供用户名和密码:

curl -H "accept:application/json" https://u:p@localhost:8443/eureka/api/apps -ks | jq -r .applications.application[].instance[].instanceId 

一个示例响应如下:

文本描述自动生成

图 11.3:使用 API 调用在 Eureka 中注册的服务

访问https://localhost:8443/eureka/web上的网页时,我们首先必须接受一个不安全的连接,因为我们的证书是自签名的,然后我们必须提供有效的凭据,如配置文件中指定的(u作为用户名,p作为密码):

图形用户界面  自动生成的描述

图 11.4:Eureka 需要身份验证

登录成功后,我们将看到来自 Eureka 服务器的熟悉网页:

表格  自动生成的中等置信度描述

图 11.5:使用网页注册到 Eureka 的服务

在确保对 Eureka 服务器的访问受保护后,我们将学习如何颁发 OAuth 访问令牌。

获取访问令牌

现在我们准备使用 OAuth 2.0 定义的授权流程获取访问令牌。我们首先将尝试客户端凭证授权流程,然后是授权码授权流程。

使用客户端凭证授权流程获取访问令牌

要为writer客户端获取访问令牌,即具有product:readproduct:write作用域,执行以下命令:

curl -k https://writer:secret-writer@localhost:8443/oauth2/token -d grant_type=client_credentials -d scope="product:read product:write" -s | jq . 

客户端使用 HTTP 基本身份验证来识别自己,传递其客户端 ID,writer,以及其客户端密钥,secret

以下是一个示例响应:

文本  自动生成的描述

图 11.6:示例令牌响应

从截图我们可以看到,在响应中我们得到了以下信息:

  • 访问令牌本身。

  • 授予令牌的作用域。writer客户端被授予product:writeproduct:read作用域。它还被授予openid作用域,允许访问有关用户 ID 的信息,例如电子邮件地址。

  • 我们获得的令牌类型;Bearer表示持有此令牌的人应根据授予令牌的作用域获得访问权限。

  • 访问令牌的有效秒数,在本例中为3599秒。

要为reader客户端获取访问令牌,即只有product:read作用域,只需在先前的命令中将writer替换为reader,结果如下:

curl -k https://reader:secret-reader@localhost:8443/oauth2/token -d grant_type=client_credentials -d scope="product:read" -s | jq . 

使用授权码授权流程获取访问令牌

要使用授权码授权流程获取访问令牌,我们需要涉及一个网络浏览器。这个授权流程在部分不安全的环境(网络浏览器)中要复杂一些,以便使其更安全。

在第一个不安全的步骤中,我们将使用网络浏览器获取一个只能使用一次的授权码,以交换访问令牌。授权码将从网络浏览器传递到一个安全层,例如服务器端代码,它可以向授权服务器发出新的请求以交换授权码为访问令牌。在这个安全交换中,服务器必须提供一个客户端密钥来验证其身份。

执行以下步骤以执行授权码授权流程:

  1. 要为 reader 客户端获取授权码,请在接受自签名证书使用的网页浏览器中输入以下 URL,例如 Chrome:https://localhost:8443/oauth2/authorize?response_type=code&client_id=reader&redirect_uri=https://my.redirect.uri&scope=product:read&state=35725

  2. 当网页浏览器要求登录时,使用授权服务器配置中指定的凭据,即 up

图形用户界面  自动生成的描述

图 11.7:尝试授权码授权流程

  1. 我们将被要求为 reader 客户端同意以我们的名义调用 API:

图形用户界面,应用程序  自动生成的描述

图 11.8:授权码授权流程同意页面

  1. 点击 提交同意 按钮后,我们将得到以下响应:

图形用户界面,文本,应用程序  自动生成的描述

图 11.9:授权码授权流程重定向页面

  1. 初看可能有点失望。授权服务器发送回网页浏览器的 URL 是基于客户端在初始请求中指定的重定向 URI。将 URL 复制到文本编辑器中,你会找到类似以下的内容:

    https://my.redirect.uri/?code=7XBs...0mmyk&state=35725

    太好了!我们可以在重定向 URL 的 code 请求参数中找到授权码。从 code 参数中提取授权码,并定义一个环境变量 CODE,其值为:

    CODE=7XBs...0mmyk 
    
  2. 假设你是后端服务器,使用以下 curl 命令交换授权码和访问令牌:

    curl -k https://reader:secret-reader@localhost:8443/oauth2/token \
     -d grant_type=authorization_code \
     -d client_id=reader \
     -d redirect_uri=https://my.redirect.uri \
     -d code=$CODE -s | jq . 
    

    以下是一个示例响应:

    文本  自动生成的描述

    图 11.10:授权码授权流程访问令牌

    从截图可以看出,我们得到的响应信息与客户端凭据流程中得到的类似,但有以下例外:

    • 由于我们使用了更安全的授权流程,我们还获得了一个 刷新令牌

    • 由于我们请求了 reader 客户端的访问令牌,所以我们只得到了 product:read 范围,没有 product:write 范围

  3. 要为 writer 客户端获取授权码,请使用以下 URL:https://localhost:8443/oauth2/authorize?response_type=code&client_id=writer&redirect_uri=https://my.redirect.uri&scope=product:read+product:write&state=72489

  4. 要为 writer 客户端交换代码以获取访问令牌,请运行以下命令:

    curl -k https://writer:secret-writer@localhost:8443/oauth2/token \
      -d grant_type=authorization_code \
      -d client_id=writer \
      -d redirect_uri=https://my.redirect.uri \
      -d code=$CODE -s | jq . 
    

验证响应现在也包含 product:write 范围!

使用访问令牌调用受保护 API

现在,让我们使用我们获得的访问令牌来调用受保护的 API。

预期 OAuth 2.0 访问令牌将以标准 HTTP authorization 标头发送,其中访问令牌以 Bearer 为前缀。

运行以下命令以调用受保护的 API:

  1. 在没有有效访问令牌的情况下调用 API 以检索复合产品:

    ACCESS_TOKEN=an-invalid-token
    curl https://localhost:8443/product-composite/1 -k -H "Authorization: Bearer $ACCESS_TOKEN" -i 
    

    它应该返回以下响应:

    图形用户界面,文本,应用程序  自动生成的描述

    图 11.11:无效令牌导致 401 未授权响应

    错误信息清楚地表明访问令牌无效!

  2. 尝试使用之前章节中为 reader 客户端获取的其中一个访问令牌来检索复合产品:

    ACCESS_TOKEN={a-reader-access-token}
    curl https://localhost:8443/product-composite/1 -k -H "Authorization: Bearer $ACCESS_TOKEN" -i 
    

    现在我们将获得 200 OK 状态码,预期的响应体将被返回:

图形用户界面,文本  自动生成的描述

图 11.12:有效的访问令牌导致 200 OK 响应

  1. 如果我们尝试使用为 reader 客户端获取的访问令牌访问一个正在更新的 API,例如删除 API,调用将失败:

    ACCESS_TOKEN={a-reader-access-token}
    curl https://localhost:8443/product-composite/999 -k -H "Authorization: Bearer $ACCESS_TOKEN" -X DELETE -i 
    

    它将以类似以下响应失败:

    图形用户界面,文本  自动生成的描述

    图 11.13:权限不足导致 403 禁止结果

    从错误响应中可以看出,我们被禁止调用 API,因为请求需要比我们的访问令牌所授予的更高权限。

  2. 如果我们重复调用删除 API,但使用为 writer 客户端获取的访问令牌,调用将成功,响应为 202 已接受

即使在底层数据库中不存在指定产品 ID 的产品,delete 操作也应返回 202,因为 delete 操作是幂等的,如第六章 添加持久性 中所述。请参阅 添加新 API 部分。

如果您使用 docker-compose logs -f product-composite 命令查看日志输出,应该能够找到如下授权信息:

文本  自动生成的描述

图 11.14:日志输出中的授权信息

此信息是从 JWT 编码的访问令牌中提取的 product-composite 服务;product-composite 服务无需与授权服务器通信即可获取此信息!

通过这些测试,我们已经看到了如何使用客户端凭据和授权代码授权流程获取访问令牌。我们还看到了如何使用作用域来限制客户端可以使用特定访问令牌执行的操作,例如,仅用于读取操作。

使用 OAuth 2.0 测试 Swagger UI

在本节中,我们将学习如何使用 Swagger UI 组件访问受保护的 API。上面 仅针对 product-composite 服务中的更改 部分中描述的配置允许我们为 Swagger UI 发出访问令牌,并在从 Swagger UI 调用 API 时使用它。

要尝试它,请执行以下步骤:

  1. 通过在网页浏览器中访问以下 URL 来打开 Swagger UI 起始页面:https://localhost:8443/openapi/swagger-ui.html

  2. 在起始页面上,我们现在可以看到一个新按钮,位于服务器下拉列表旁边,按钮上显示文本授权

    点击授权按钮以启动授权码授予流程。

  3. Swagger UI 将显示一个作用域列表,它将请求授权服务器获取访问权限。通过点击带有文本全选的链接并然后点击授权按钮来选择所有作用域:图形用户界面,应用程序描述自动生成

    图 11.15:Swagger UI 请求 OAuth 作用域

    您将被重定向到授权服务器。如果您还没有从所使用的网页浏览器登录,授权服务器将要求您提供凭证,就像在使用授权码授予流程获取访问令牌部分中描述的那样。

  4. 使用用户名u和密码p登录。

  5. 授权服务器将要求您的同意。选择两个作用域并点击提交同意按钮。

  6. Swagger UI 将通过显示完成的授权流程信息来完成授权过程。点击关闭按钮返回起始页面:

图形用户界面,应用程序描述自动生成

图 11.16:Swagger UI 总结 OAuth 授权流程

  1. 现在您可以像在第五章使用 OpenAPI 添加 API 描述中描述的那样尝试 API。Swagger UI 将把访问令牌添加到请求中。如果您仔细查看响应标题下报告的curl命令,您可以找到访问令牌。

这完成了我们将使用本地授权服务器进行的测试。在下一节中,我们将用外部 OpenID Connect 兼容提供者替换它。

使用外部 OpenID Connect 提供者进行测试

因此,OAuth 舞蹈与我们所控制的授权服务器配合得很好。但如果我们用认证的 OpenID Connect 提供者替换它,会发生什么?从理论上讲,它应该能够直接工作。让我们来看看,好吗?

要获取 OpenID Connect 的认证实现列表,请参阅openid.net/developers/certified/.

我们将使用 Auth0,https://auth0.com/,作为我们的外部 OpenID 提供者测试。为了能够使用 Auth0 而不是我们自己的授权服务器,我们将讨论以下主题:

  • 在 Auth0 中设置一个具有readerwriter客户端和用户的账户

  • 应用使用 Auth0 作为 OpenID 提供者所需的更改

  • 运行测试脚本以验证其是否正常工作

  • 使用以下授权流程获取访问令牌:

    • 客户端凭证授予流程

    • 授权码授予流程

  • 使用从授权流程中获得的访问令牌调用受保护的 API

  • 使用用户信息端点获取有关用户的更多信息

让我们在接下来的部分中逐一介绍它们。

设置和配置 Auth0 账户

在 Auth0 中所需的大部分配置将由使用 Auth0 管理 API 的脚本处理。但我们必须执行一些手动步骤,直到 Auth0 创建了一个我们可以用来访问管理 API 的客户端 ID 和客户端密钥。Auth0 的服务是多租户的,允许我们创建自己的 OAuth 对象域,包括客户端、资源所有者和资源服务器。

执行以下手动步骤,在 Auth0 注册免费账户并创建一个我们可以用来访问管理 API 的客户端:

  1. 在您的浏览器中打开 URL auth0.com

  2. 点击右上角的汉堡菜单(☰)。

  3. 点击注册按钮:

    1. 使用您选择的电子邮件地址进行注册。

    2. 成功注册后,您将被要求创建一个租户域名。输入您选择的租户名称,以我的情况为例:dev-ml-3rd.eu.auth0.com

    3. 按照要求填写您的账户信息。

    4. 同时,查看您的邮箱,寻找主题为请验证您的 Auth0 账户的电子邮件,并使用电子邮件中的说明来验证您的账户。

  4. 注册后,您将被引导到一个入门页面。

  5. 在左侧菜单中,点击应用程序以展开它,然后点击APIs以找到管理 API,Auth0 管理 API。此 API 是在创建租户时为您创建的。我们将使用此 API 在租户中创建所需的定义。

  6. 点击Auth0 管理 API并选择测试标签。

  7. 将出现一个带有文本创建并授权测试的大按钮。点击它以创建一个可以用来访问管理 API 的客户端。

  8. 创建后,将显示一个带有标题从我的应用程序请求 Auth0 令牌的页面。作为最后一步,我们需要授予创建的客户端使用管理 API 的权限。

  9. 点击测试标签旁边的机器到机器应用程序标签。

  10. 在这里我们可以找到测试客户端,Auth0 管理 API(测试应用程序),我们可以看到它被授权使用管理 API。如果我们点击授权切换按钮旁边的向下箭头,将揭示大量可用的权限。

  11. 点击全部选项,然后点击更新按钮。屏幕应类似于以下截图:

图形用户界面,应用程序描述自动生成

图 11.17:Auth0 管理 API 客户端权限

  1. 在理解您现在拥有一个可以访问您租户内所有管理 API 的非常强大的客户端后,点击继续按钮。

  2. 现在,我们只需要收集创建的客户端的客户端 ID 和客户端密钥。最简单的方法是在左侧菜单中选择应用程序(在主菜单选择应用程序下)然后选择名为Auth0 管理 API(测试应用程序)的应用程序。

    应该显示一个类似于以下屏幕的屏幕:

图形用户界面,应用程序,团队  自动生成的描述

图 11.18:Auth0 管理 API 客户端应用程序信息

  1. 打开文件$BOOK_HOME/Chapter11/auth0/env.bash并从上面的屏幕复制以下值:

    1. 域名放入变量TENANT的值中

    2. 客户端 ID放入变量MGM_CLIENT_ID的值中

    3. 客户端密钥放入变量MGM_CLIENT_SECRET的值中

  2. 通过指定电子邮件地址和密码,在变量USER_EMAILUSER_PASSWORD中完成env.bash文件中所需值的设置,该脚本将为我们创建一个测试用户。

从安全角度考虑,为像这样的用户指定密码不被认为是最佳实践。Auth0 支持注册用户,他们可以自己设置密码,但这需要更复杂的设置。有关更多信息,请参阅auth0.com/docs/connections/database/password-change。由于这仅用于测试目的,指定这样的密码是可以的。

我们现在可以运行脚本,该脚本将为我们创建以下定义:

  • 两个应用程序,readerwriter,或者 OAuth 术语中的客户端

  • product-composite API,OAuth 术语中的资源服务器,具有 OAuth 作用域product:readproduct:write

  • 一个用户,OAuth 术语中的资源所有者,我们将使用它来测试授权代码流

  • 最后,我们将授予reader应用程序product:read作用域,并将writer应用程序的作用域设置为product:readproduct:write

  1. 运行以下命令:

    cd $BOOK_HOME/Chapter11/auth0
    ./setup-tenant.bash 
    

预期以下输出(以下输出中已删除详细信息):

文本  自动生成的描述

图 11.19:第一次执行setup-tenant.bash时的输出

  1. 保存输出末尾打印的export命令的副本;我们将在本章的后面多次使用它们。

  2. 在您的邮箱中查找为测试用户指定的测试邮件。您将收到一封主题为验证您的电子邮件的邮件。*使用邮件中的说明来验证测试用户的电子邮件地址。

注意,该脚本是无状态的,这意味着它可以多次运行而不会破坏配置。如果再次运行脚本,它应该响应:

图 11.20:第二次执行setup-tenant.bash时的输出

能够再次运行脚本非常有用,例如,以获取对readerwriter的客户端 ID 和客户端密钥的访问。

如果您需要删除由 setup-tenant.bash 创建的对象,您可以运行脚本 reset-tenant.bash

在创建并配置了 Auth0 账户后,我们可以继续前进并应用系统景观中必要的配置更改。

应用必要的更改以使用 Auth0 作为 OpenID 提供者

在本节中,我们将学习需要哪些配置更改才能用 Auth0 替换本地授权服务器。我们只需更改充当 OAuth 资源服务器的两个服务的配置,即 product-compositegateway 服务。我们还需要稍微修改我们的测试脚本,以便从 Auth0 而不是从本地授权服务器获取访问令牌。让我们从 OAuth 资源服务器,即 product-compositegateway 服务开始。

本主题中描述的更改尚未应用于书中 Git 仓库中的源代码;您需要自己进行这些更改才能看到它们的效果!

在 OAuth 资源服务器中更改配置

如前所述,当使用 OpenID Connect 提供者时,我们只需配置 OAuth 资源服务器中标准发现端点的基 URI。

product-compositegateway 项目中,更新 OIDC 发现端点以指向 Auth0 而不是指向我们的本地授权服务器。在两个项目的 application.yml 文件中做出以下更改:

  1. 定位属性 spring.security.oauth2.resourceserver.jwt.issuer-uri

  2. 将其值替换为 https://${TENANT}/,其中 ${TENANT} 应替换为您的租户域名;在我的情况下,它是 dev-ml.eu.auth0.com不要忘记尾随的 /!

在我的情况下,OIDC 发现端点的配置将如下所示:

spring.security.oauth2.resourceserver.jwt.issuer-uri: https://dev-ml.eu.auth0.com/ 

如果您好奇,可以通过运行以下命令查看发现文档的内容:

curl https://${TENANT}/.well-known/openid-configuration -s | jq 

按以下方式重新构建 product-compositegateway 服务:

cd $BOOK_HOME/Chapter11
./gradlew build && docker-compose up -d --build product-composite gateway 

随着 product-compositegateway 服务的更新,我们可以继续前进并更新测试脚本。

修改测试脚本以从 Auth0 获取访问令牌

我们还需要更新测试脚本,以便从 Auth0 OIDC 提供者获取访问令牌。这通过在 test-em-all.bash 中执行以下更改来完成:

  1. 找到以下命令:

    ACCESS_TOKEN=$(curl -k https://writer:secret-writer@$HOST:$PORT/oauth2/token -d grant_type=client_credentials -d scope="product:read product:write" -s | jq .access_token -r) 
    
  2. 用以下命令替换:

    export TENANT=...
    export WRITER_CLIENT_ID=...
    export WRITER_CLIENT_SECRET=...
    ACCESS_TOKEN=$(curl -X POST https://$TENANT/oauth/token \
      -d grant_type=client_credentials \
      -d audience=https://localhost:8443/product-composite \
      -d scope=product:read+product:write \
      -d client_id=$WRITER_CLIENT_ID \
      -d client_secret=$WRITER_CLIENT_SECRET -s | jq -r .access_token) 
    

注意到前一个命令,Auth0 要求我们指定请求访问令牌的预期受众,作为额外的安全层。受众是我们计划使用访问令牌调用的 API。鉴于 API 实现验证 audience 字段,这将防止有人试图使用为其他目的签发的访问令牌来访问 API。

  1. 在前面的命令中设置环境变量 TENANTWRITER_CLIENT_IDWRITER_CLIENT_SECRET 的值,这些值由 setup-tenant.bash 脚本返回。

如上所述,您可以再次运行脚本以获取这些值,而不会产生任何负面影响!

  1. 找到以下命令:

    READER_ACCESS_TOKEN=$(curl -k https://reader:secret-reader@$HOST:$PORT/oauth2/token -d grant_type=client_credentials -d scope="product:read" -s | jq .access_token -r) 
    
  2. 用以下命令替换:

    export READER_CLIENT_ID=...
    export READER_CLIENT_SECRET=...
    READER_ACCESS_TOKEN=$(curl -X POST https://$TENANT/oauth/token \
      -d grant_type=client_credentials \
      -d audience=https://localhost:8443/product-composite \
      -d scope=product:read \
      -d client_id=$READER_CLIENT_ID \
      -d client_secret=$READER_CLIENT_SECRET -s | jq -r .access_token) 
    

注意,我们在这里只请求 product:read 范围,而不是 product:write 范围。

  1. 在前面的命令中,将环境变量 READER_CLIENT_IDREADER_CLIENT_SECRET 的值设置为 setup-tenant.bash 脚本返回的值。

现在访问令牌由 Auth0 而不是我们的本地授权服务器颁发,我们的 API 实现可以使用在 application.yml 文件中配置的 Auth0 的发现服务的信息来验证访问令牌。API 实现可以像以前一样,使用访问令牌中的范围来授权客户端执行对 API 的调用,或者不授权。

这样,我们就完成了所有必要的更改。让我们运行一些测试来验证我们是否可以从 Auth0 获取访问令牌。

使用 Auth0 作为 OpenID Connect 提供者运行测试脚本

现在,我们已经准备好尝试使用 Auth0 了!

运行常规测试,但这次使用 Auth0 作为 OpenID Connect 提供者,以下命令:

./test-em-all.bash 

在日志中,您将能够找到来自 Auth0 颁发的访问令牌的授权信息。运行以下命令:

docker-compose logs product-composite | grep "Authorization info" 

预期以下命令的输出:

  1. 从使用具有 product:readproduct:write 范围的访问令牌的调用中,我们将看到以下范围列出:

文本描述自动生成

图 11.21:日志输出中来自 Auth0 的写客户端的授权信息

  1. 从仅使用 product:read 范围的访问令牌的调用中,我们将看到只列出以下范围:

文本描述自动生成

图 11.22:日志输出中来自 Auth0 的读客户端的授权信息

如我们从日志输出中看到的那样,我们现在还获得了有关此访问令牌的预期受众的信息。为了加强安全性,我们可以在我们的服务中添加一个测试来验证其 URL(在这种情况下为 https://localhost:8443/product-composite)是否是受众列表的一部分。正如之前提到的,这将防止有人试图使用为获取对我们的 API 访问权限以外的目的而颁发的访问令牌。

当与 Auth0 的自动化测试一起工作时,我们可以继续学习如何使用不同类型的授权流程获取访问令牌。让我们从客户端凭证授权流程开始。

使用客户端凭证授权流程获取访问令牌

如果您想自己从 Auth0 获取访问令牌,可以通过运行以下命令,使用客户端凭证授权流程来实现:

export TENANT=...
export WRITER_CLIENT_ID=...
export WRITER_CLIENT_SECRET=...
curl -X POST https://$TENANT/oauth/token \
  -d grant_type=client_credentials \
  -d audience=https://localhost:8443/product-composite \
  -d scope=product:read+product:write \
  -d client_id=$WRITER_CLIENT_ID \
  -d client_secret=$WRITER_CLIENT_SECRET 

在前面的命令中,将环境变量 TENANTWRITER_CLIENT_IDWRITER_CLIENT_SECRET 的值设置为 setup-tenant.bash 脚本返回的值。

按照第使用访问令牌调用受保护的 API部分的说明,你应该能够使用获取的访问令牌调用 API。

使用授权码授权流程获取访问令牌

在本节中,我们将学习如何使用授权码授权流程从 Auth0 获取访问令牌。如上所述,我们首先需要使用网络浏览器获取授权码。接下来,我们可以使用服务器端代码将授权码交换为访问令牌。

执行以下步骤以执行使用 Auth0 的授权码授权流程:

  1. 要为默认应用程序客户端获取授权码,请在网络浏览器中使用以下 URL:

    https://${TENANT}/authorize?audience=https://localhost:8443/product-composite&scope=openid email product:read product:write&response_type=code&client_id=${WRITER_CLIENT_ID}&redirect_uri=https://my.redirect.uri&state=845361

  2. 将前面的 URL 中的${TENANT}${WRITER_CLIENT_ID}替换为setup-tenant.bash脚本返回的租户域名和 writer 客户端 ID。

  3. Auth0 应显示以下登录屏幕:

图形用户界面,应用程序描述自动生成

图 11.23:使用 Auth0 的授权码授权流程,登录屏幕

  1. 登录成功后,Auth0 会要求你同意客户端应用程序:

图形用户界面,应用程序描述自动生成

图 11.24:使用 Auth0 的授权码授权流程,同意屏幕

  1. 授权码现在位于浏览器中的 URL 中,就像我们尝试使用本地授权服务器进行授权码授权流程时一样:

图形用户界面,应用程序描述自动生成

图 11.25:使用 Auth0 的授权码授权流程,访问令牌

  1. 提取代码并运行以下命令以获取访问令牌:

    CODE=...
    export TENANT=...
    export WRITER_CLIENT_ID=...
    export WRITER_CLIENT_SECRET=...
    curl -X POST https://$TENANT/oauth/token \
     -d grant_type=authorization_code \
     -d client_id=$WRITER_CLIENT_ID \
     -d client_secret=$WRITER_CLIENT_SECRET  \
     -d code=$CODE \
     -d redirect_uri=https://my.redirect.uri -s | jq . 
    

在前面的命令中设置环境变量TENANTWRITER_CLIENT_IDWRITER_CLIENT_SECRET的值为setup-tenant.bash脚本返回的值。

现在我们已经学会了如何使用两种授权流程获取访问令牌,我们准备在下一节尝试使用从 Auth0 获取的访问令牌调用外部 API。

使用 Auth0 访问令牌调用受保护的 API

我们可以使用 Auth0 签发的访问令牌来调用我们的 API,就像我们使用本地授权服务器签发的访问令牌一样。

对于只读 API,执行以下命令:

ACCESS_TOKEN=...
curl https://localhost:8443/product-composite/1 -k -H "Authorization: Bearer $ACCESS_TOKEN" -i 

对于更新中的 API,执行以下命令:

ACCESS_TOKEN=...
curl https://localhost:8443/product-composite/999 -k -H "Authorization: Bearer $ACCESS_TOKEN" -X DELETE -i 

由于我们请求了两个作用域,product:readproduct:write,因此预期的前一个 API 调用都将返回200 OK

获取有关用户的额外信息

从“使用 Auth0 作为 OpenID Connect 提供者的运行测试脚本”部分中的图 11.21图 11.22的日志输出中,我们看不到任何关于发起 API 请求的用户的信息。如果您希望您的 API 实现能够了解更多关于用户的信息,它可以调用 Auth0 的userinfo_endpointuserinfo端点的 URL 可以在对 OIDC 发现端点的请求的响应中找到,如在 OAuth 资源服务器中的配置更改部分所述。要获取与访问令牌相关的用户信息,请执行以下请求:

Export TENANT=...
curl -H "Authorization: Bearer $ACCESS_TOKEN" https://$TENANT/userinfo -s | jq 

在前面的命令中设置TENANT环境变量的值,使其等于setup-tenant.bash脚本返回的值。

注意,此命令仅适用于使用授权码授权流程颁发的访问令牌。使用客户端凭据授权流程颁发的访问令牌不包含任何用户信息,如果尝试使用,将导致错误响应。

一个示例响应如下:

文本描述自动生成

图 11.26:从 Auth0 请求额外的用户信息

此端点也可以用来验证用户是否没有在 Auth0 中撤销访问令牌。

通过以下命令关闭系统景观以结束测试:

docker-compose down 

这部分内容到此结束,我们学习了如何用外部替代方案替换本地 OAuth 2.0 授权服务器。我们还看到了如何重新配置微服务景观,以使用外部 OIDC 提供者验证访问令牌。

摘要

在本章中,我们学习了如何使用 Spring Security 来保护我们的 API。

我们看到,使用 Spring Security 启用 HTTPS 以防止第三方窃听是多么容易。使用 Spring Security,我们还了解到,使用 HTTP 基本身份验证限制对发现服务器 Netflix Eureka 的访问是直接了当的。最后,我们看到了如何使用 Spring Security 简化 OAuth 2.0 和 OpenID Connect 的使用,允许第三方客户端应用程序以用户的名义访问我们的 API,但不需要用户与客户端应用程序共享凭据。我们学习了如何设置基于 Spring Security 的本地 OAuth 2.0 授权服务器,以及如何更改配置,以便可以使用外部 OpenID Connect 提供者 Auth0。

然而,一个担忧是,如何管理所需的配置。每个微服务实例都必须提供其自己的配置,这使得很难获得当前配置的良好概览。更新涉及多个微服务的配置也将具有挑战性。加之分散的配置,我们之前看到的一些配置包含敏感信息,如凭证或证书。这似乎需要一种更好的方式来处理多个协作微服务的配置,以及如何处理配置敏感部分的方法。

在下一章中,我们将探讨 Spring Cloud Config Server,并看看它是如何被用来处理这些类型的问题的。

问题

  1. 使用自签名证书的好处和缺点是什么?

  2. OAuth 2.0 授权码的目的是什么?

  3. OAuth 2.0 范围的目的是什么?

  4. 当一个令牌是 JWT 时,这意味着什么?

  5. 我们如何信任存储在 JWT 中的信息?

  6. 使用 OAuth 2.0 授权码授权流程与原生移动应用是否合适?

  7. OpenID Connect 为 OAuth 2.0 增加了什么?

第十二章:集中配置

在本章中,我们将学习如何使用Spring Cloud Config Server来集中管理我们的微服务的配置。正如已在第一章微服务简介中所述,微服务数量的增加通常伴随着需要管理和更新的配置文件数量的增加。

使用 Spring Cloud Config Server,我们可以将所有微服务的配置文件放置在一个中央配置仓库中,这将使处理它们变得更加容易。我们的微服务将在启动时从配置服务器检索其配置。

本章将涵盖以下主题:

  • Spring Cloud Config Server 简介

  • 设置配置服务器

  • 配置配置服务器的客户端

  • 配置仓库的结构化

  • 尝试使用 Spring Cloud Config Server

技术要求

关于如何安装本书中使用的工具以及如何访问本书源代码的说明,请参阅:

  • 第二十一章macOS 安装说明

  • 第二十二章使用 WSL 2 和 Ubuntu 的 Microsoft Windows 安装说明

本章中的代码示例均来自$BOOK_HOME/Chapter12中的源代码。

如果您想查看对本章源代码所做的更改,即查看将配置服务器添加到微服务景观中所需要的内容,您可以将其与第十一章API 访问保护的源代码进行比较。您可以使用您喜欢的diff工具,并比较两个文件夹$BOOK_HOME/Chapter11$BOOK_HOME/Chapter12

Spring Cloud Config Server 简介

Spring Cloud Config Server(简称Config Server)将被添加到边缘服务器后面的现有微服务景观中,与其他微服务一样:

图 12.1:将配置服务器添加到系统架构中

在设置配置服务器时,有许多选项需要考虑:

  • 选择配置仓库的存储类型

  • 决定初始客户端连接,无论是到配置服务器还是到发现服务器

  • 保护配置,既防止对 API 的无权访问,也避免在配置仓库中存储敏感信息为纯文本

让我们逐一介绍每个选项,并介绍配置服务器公开的 API。

选择配置仓库的存储类型

如已在第八章Spring Cloud 简介中所述,配置服务器支持在多种不同的后端存储配置文件,例如:

  • Git 仓库

  • 本地文件系统

  • HashiCorp Vault

  • JDBC 数据库

在本章中,我们将使用本地文件系统。要使用本地文件系统,配置服务器需要以启用原生 Spring 配置文件的方式启动。配置存储库的位置使用spring.cloud.config.server.native.searchLocations属性指定。

决定初始客户端连接

默认情况下,客户端首先连接到配置服务器以检索其配置。根据配置,它连接到发现服务器(在我们的例子中是 Netflix Eureka)以注册自己。也可以反过来这样做,即客户端首先连接到发现服务器以找到配置服务器实例,然后连接到配置服务器以获取其配置。这两种方法都有其优缺点。

在本章中,客户端将首先连接到配置服务器。采用这种方法,可以将发现服务器的配置存储在配置服务器中。

要了解更多关于其他替代方案的信息,请参阅docs.spring.io/spring-cloud-config/docs/4.0.1/reference/html/#discovery-first-bootstrap

首先连接到配置服务器的一个担忧是配置服务器可能成为单点故障。如果客户端首先连接到一个发现服务器,例如 Netflix Eureka,则可以有多个配置服务器实例注册,从而避免单点故障。在本书的后面部分,当我们学习 Kubernetes 中的服务概念时,从第十五章Kubernetes 简介开始,我们将看到如何通过在每个 Kubernetes 服务后面运行多个容器(例如,配置服务器)来避免单点故障。

配置安全

通常,配置信息将被视为敏感信息。这意味着我们需要在传输和静止状态下保护配置信息。从运行时角度来看,配置服务器不需要通过边缘服务器暴露给外部。然而,在开发期间,能够访问配置服务器的 API 以检查配置是有用的。在生产环境中,建议限制对配置服务器的外部访问。

传输中的配置安全

当微服务或使用配置服务器 API 的任何人请求配置信息时,由于它已经使用 HTTPS,因此它将由边缘服务器保护,防止窃听。

为了确保 API 用户是已知的客户端,我们将使用 HTTP 基本身份验证。我们可以在配置服务器中使用 Spring Security 来设置 HTTP 基本身份验证,并指定环境变量SPRING_SECURITY_USER_NAMESPRING_SECURITY_USER_PASSWORD,以使用允许的凭据。

静止状态下的配置安全

为了避免有人可以访问配置存储库并窃取敏感信息(如密码)的情况,配置服务器支持在磁盘上存储配置信息的加密。配置服务器支持使用对称密钥和非对称密钥。非对称密钥更安全,但更难管理。

在本章中,我们将使用对称密钥。对称密钥在启动时通过指定环境变量ENCRYPT_KEY传递给配置服务器。加密密钥只是一个需要像任何敏感信息一样进行保护的纯文本字符串。

要了解更多关于非对称密钥的使用,请参阅docs.spring.io/spring-cloud-config/docs/4.0.1/reference/html/#_key_management

介绍配置服务器 API

配置服务器公开了一个 REST API,其客户端可以使用它来检索其配置。在本章中,我们将使用 API 中的以下端点:

  • /actuator:所有微服务公开的标准 actuator 端点。像往常一样,应该小心使用。它们在开发期间非常有用,但在用于生产之前必须被锁定。

  • /encrypt/decrypt:加密和解密敏感信息的端点。在使用生产环境之前,这些也必须被锁定。

  • /{microservice}/{profile}:返回指定微服务和指定 Spring 配置的配置。

当我们尝试配置服务器时,我们将看到 API 的一些示例用法。

设置配置服务器

在讨论的基础上设置配置服务器非常简单:

  1. 使用 Spring Initializr 创建 Spring Boot 项目,如第三章中所述,创建一组协作微服务。请参阅使用 Spring Initializr 生成骨架代码部分。

  2. 将依赖项spring-cloud-config-serverspring-boot-starter-security添加到 Gradle 构建文件build.gradle中。

  3. 在应用程序类ConfigServerApplication中添加注解@EnableConfigServer

    @EnableConfigServer
    @SpringBootApplication
    public class ConfigServerApplication { 
    
  4. 将配置服务器的配置添加到默认属性文件application.yml中:

    server.port: 8888
    spring.cloud.config.server.native.searchLocations: file:${PWD}/config-repo
    management.endpoint.health.show-details: "ALWAYS"
    management.endpoints.web.exposure.include: "*"
    logging:
      level:
        root: info
    ---
    spring.config.activate.on-profile: docker
    spring.cloud.config.server.native.searchLocations: file:/config-repo 
    

    最重要的配置是指定配置存储库的位置,使用spring.cloud.config.server.native.searchLocations属性表示。

  5. 向边缘服务器添加路由规则,以便从微服务景观外部访问配置服务器的 API。

  6. 将 Dockerfile 和配置服务器的定义添加到三个 Docker Compose 文件中。

  7. 将敏感配置参数外部化到标准的 Docker Compose 环境文件.env中。这些参数在配置服务器以 Docker 使用部分中描述。

  8. 将配置服务器添加到通用构建文件settings.gradle中:

    include ':spring-cloud:config-server' 
    

Spring Cloud Config 服务器源代码可在 $BOOK_HOME/Chapter12/spring-cloud/config-server 中找到。

现在,让我们看看如何设置第 5 步中提到的路由规则,以及如何配置在 Docker Compose 中添加的配置服务器,如第 6 步和 7 步所述。

在边缘服务器上设置路由规则

为了能够从微服务景观外部访问配置服务器的 API,我们在边缘服务器中添加了一个路由规则。所有以 /config 开头的对边缘服务器的请求都将通过以下路由规则路由到配置服务器:

 - id: config-server
   uri: http://${app.config-server}:8888
  predicates:
  - Path=/config/**
  filters:
  - RewritePath=/config/(?<segment>.*), /$\{segment} 

路由规则中的 RewritePath 过滤器将在将其发送到配置服务器之前,从传入的 URL 中删除前面的部分 /config

边缘服务器也被配置为允许所有对配置服务器的请求,将安全检查委托给配置服务器。以下行被添加到边缘服务器的 SecurityConfig 类中:

 .pathMatchers("/config/**").permitAll() 

在此路由规则到位后,我们可以使用配置服务器的 API;例如,运行以下命令以请求使用 docker Spring 配置文件的 product 服务的配置:

curl https://dev-usr:dev-pwd@localhost:8443/config/product/docker -ks | jq 

当我们稍后尝试配置服务器时,将运行此命令。

配置配置服务器以与 Docker 一起使用

配置服务器的 Dockerfile 与其他微服务的 Dockerfile 相同,只是它暴露的是端口 8888 而不是端口 8080

当涉及到将配置服务器添加到 Docker Compose 文件中时,它与我们所看到的其他微服务略有不同:

config-server:
  build: spring-cloud/config-server
  mem_limit: 512m
  environment:
    - SPRING_PROFILES_ACTIVE=docker,native
    - ENCRYPT_KEY=${CONFIG_SERVER_ENCRYPT_KEY}
    - SPRING_SECURITY_USER_NAME=${CONFIG_SERVER_USR}
    - SPRING_SECURITY_USER_PASSWORD=${CONFIG_SERVER_PWD}
  volumes:
    - $PWD/config-repo:/config-repo 

下面是对前面源代码的解释:

  1. Spring 配置文件 native 被添加,以向配置服务器发出信号,表明配置存储库基于本地文件。

  2. 环境变量 ENCRYPT_KEY 用于指定配置服务器将用于加密和解密敏感配置信息的对称加密密钥。

  3. 环境变量 SPRING_SECURITY_USER_NAMESPRING_SECURITY_USER_PASSWORD 用于指定用于保护使用基本 HTTP 认证的 API 的凭据。

  4. volumes 声明将使 config-repo 文件夹在 Docker 容器中可通过 /config-repo 访问。

三个前面环境变量的值,在 Docker Compose 文件中以 ${...} 标记,由 Docker Compose 从 .env 文件中获取:

CONFIG_SERVER_ENCRYPT_KEY=my-very-secure-encrypt-key
CONFIG_SERVER_USR=dev-usr
CONFIG_SERVER_PWD=dev-pwd 

存储在 .env 文件中的信息,即用户名、密码和加密密钥,是敏感的,如果用于除开发和测试之外的其他用途,则必须受到保护。此外,请注意,丢失加密密钥将导致配置存储库中的加密信息无法解密的情况发生!

配置配置服务器的客户端

为了能够从配置服务器获取它们的配置,我们的微服务需要更新。这可以通过以下步骤完成:

  1. spring-cloud-starter-configspring-retry 依赖项添加到 Gradle 构建文件 build.gradle 中。

  2. 将配置文件 application.yml 移动到配置存储库,并使用 spring.application.name 属性指定的客户端名称重命名。

  3. 将新的 application.yml 文件添加到 src/main/resources 文件夹中。此文件将用于存储连接到配置服务器所需的配置。有关其内容的说明,请参阅以下 配置连接信息 部分。

  4. 将访问配置服务器的凭据添加到 Docker Compose 文件中,例如 product 服务:

    product:
      environment:
     - CONFIG_SERVER_USR=${CONFIG_SERVER_USR}
     - CONFIG_SERVER_PWD=${CONFIG_SERVER_PWD} 
    
  5. 禁用运行基于 Spring Boot 的自动化测试时使用配置服务器。这是通过将 spring.cloud.config.enabled=false 添加到 @DataMongoTest@DataJpaTest@SpringBootTest 注解来完成的。它们看起来像这样:

    @DataMongoTest(properties = {"spring.cloud.config.enabled=false"})
    @DataJpaTest(properties = {"spring.cloud.config.enabled=false"})
    @SpringBootTest(webEnvironment=RANDOM_PORT, properties = {"eureka.client.enabled=false", "spring.cloud.config.enabled=false"}) 
    

配置连接信息

如前所述,src/main/resources/application.yml 文件现在包含连接到配置服务器所需的客户端配置。此文件对所有配置服务器的客户端具有相同的内容,除了由 spring.application.name 属性指定的应用程序名称(在以下示例中设置为 product):

spring.config.import: "configserver:"
spring:
  application.name: product
  cloud.config:
    failFast: true
    retry:
      initialInterval: 3000
      multiplier: 1.3
      maxInterval: 10000
      maxAttempts: 20
    uri: http://localhost:8888
    username: ${CONFIG_SERVER_USR}
    password: ${CONFIG_SERVER_PWD}
---
spring.config.activate.on-profile: docker
spring.cloud.config.uri: http://config-server:8888 

此配置将使客户端执行以下操作:

  1. 当它运行在 Docker 外部时,使用 http://localhost:8888 URL 连接到配置服务器,当在 Docker 容器中运行时,使用 http://config-server:8888 URL

  2. 使用基于 CONFIG_SERVER_USRCONFIG_SERVER_PWD 属性值的 HTTP 基本身份验证作为客户端的用户名和密码

  3. 如果需要,在启动期间尝试最多 20 次重新连接到配置服务器

  4. 如果连接尝试失败,客户端将最初等待 3 秒钟后尝试重新连接

  5. 后续重试的等待时间将增加 1.3 倍

  6. 连接尝试之间的最大等待时间为 10 秒

  7. 如果客户端在 20 次尝试后仍然无法连接到配置服务器,其启动将失败

此配置通常适用于对配置服务器的临时连接问题具有弹性。当整个微服务及其配置服务器同时启动时,例如使用 docker-compose up 命令时,特别有用。在这种情况下,许多客户端将在配置服务器准备好之前尝试连接到它,而 retry 逻辑将确保客户端在配置服务器启动并运行后成功连接。

结构化配置存储库

在将配置文件从每个客户端的源代码移动到配置存储库后,许多配置文件中都将有一些共同配置,例如,对于 actuator 端点的配置以及如何连接到 Eureka、RabbitMQ 和 Kafka。

常用部分已放置在名为application.yml的通用配置文件中。此文件由所有客户端共享。配置存储库包含以下文件:

config-repo/
├── application.yml
├── auth-server.yml
├── eureka-server.yml
├── gateway.yml
├── product-composite.yml
├── product.yml
├── recommendation.yml
└── review.yml 

配置存储库位于$BOOK_HOME/Chapter12/config-repo

尝试 Spring Cloud Config Server

现在是尝试配置服务器的时候了:

  • 首先,我们将从源代码构建并运行测试脚本,以确保一切正常。

  • 接下来,我们将尝试使用配置服务器 API 检索我们的微服务的配置。

  • 最后,我们将了解如何加密和解密敏感信息,例如密码。

构建和运行自动化测试

因此,现在我们构建并运行系统景观的验证测试,如下所示:

  1. 使用以下命令构建 Docker 镜像:

    cd $BOOK_HOME/Chapter12
    ./gradlew build && docker-compose build 
    
  2. 在 Docker 中启动系统景观并使用以下命令运行常规测试:

    ./test-em-all.bash start 
    

使用配置服务器 API 获取配置

如前所述,我们可以通过使用 URL 前缀/config通过边缘服务器访问配置服务器的 API。我们还需要提供.env文件中指定的凭据,以进行 HTTP 基本身份验证。例如,要检索以 Docker 容器形式运行的product服务所使用的配置,即激活了 Spring 配置文件docker,请运行以下命令:

curl https://dev-usr:dev-pwd@localhost:8443/config/product/docker -ks | jq . 

预期以下结构的响应(响应中的许多属性已被...替换以提高可读性):

{
  "name": "product",
  "profiles": [
    "docker"
  ],
  ...
  "propertySources": [
    {
      "name": "...file [/config-repo/product.yml]...",
      "source": {
        "spring.config.activate.on-profile": "docker",
        "server.port": 8080,
        ...
      }
    },
    {
      "name": "...file [/config-repo/product.yml]...",
      "source": {
        "server.port": 7001,
        ...
      }
    },
    {
      "name": "...file [/config-repo/application.yml]...",
      "source": {
        "spring.config.activate.on-profile": "docker",
        ...
      }
    },
    {
      "name": "...file [/config-repo/application.yml]...",
      "source": {
        ...
        "app.eureka-password": "p",
        "spring.rabbitmq.password": "guest"
      }
    }
  ]
} 

对于此响应的解释如下:

  • 响应包含来自多个属性源的属性,每个属性文件和与 API 请求匹配的 Spring 配置文件一个。属性源按优先级返回;如果多个属性源中指定了相同的属性,则响应中的第一个属性具有优先权。前面的示例响应包含以下属性源,按以下优先级顺序:

    • /config-repo/product.yml,对于docker Spring 配置文件

    • /config-repo/product.yml,对于default Spring 配置文件

    • /config-repo/application.yml,对于docker Spring 配置文件

    • /config-repo/application.yml,对于default Spring 配置文件

    例如,使用的端口将是8080而不是7001,因为在先前的响应中"server.port": 8080"server.port": 7001之前指定。

  • 敏感信息,例如 Eureka 和 RabbitMQ 的密码,以纯文本形式返回,例如"p""guest",但它们在磁盘上被加密。在配置文件application.yml中,它们被指定如下:

    app:
      eureka-password:
    '{cipher}bf298f6d5f878b342f9e44bec08cb9ac00b4ce57e98316f030194a225fac89fb'
    spring.rabbitmq:
      password: '{cipher}17fcf0ae5b8c5cf87de6875b699be4a1746dd493a99d926c7a26a68c422117ef' 
    

加密和解密敏感信息

可以使用配置服务器公开的/encrypt/decrypt端点来加密和解密信息。/encrypt端点可以用来创建要放置在配置存储库中的property文件中的加密值。参考前一个示例,其中 Eureka 和 RabbitMQ 的密码以加密形式存储在磁盘上。/decrypt端点可以用来验证存储在配置存储库磁盘上的加密信息。

  1. 要加密hello world字符串,请运行以下命令:

    curl -k https://dev-usr:dev-pwd@localhost:8443/config/encrypt --data-urlencode "hello world" 
    

    当使用curl调用/encrypt端点时,使用--data-urlencode标志是很重要的,以确保正确处理特殊字符,如+

    预期响应如下:

图形用户界面,文本,应用程序  自动生成的描述

图 12.2:配置参数的加密值

  1. 要解密加密值,请运行以下命令:

    curl -k https://dev-usr:dev-pwd@localhost:8443/config/decrypt -d d91001603dcdf3eb1392ccbd40ff201cdcf7b9af2fcaab3da39e37919033b206 
    

    预期响应为hello world字符串:

图片

图 12.3:配置参数的解密值

如果你想在配置文件中使用加密值,你需要在其前加上{cipher}并在''中包裹它。例如,要存储hello world的加密版本,在基于 YAML 的配置文件中添加以下行:

my-secret: '{cipher}d91001603dcdf3eb1392ccbd40ff201cdcf7b9af2 fcaab3da39e37919033b206' 

当配置服务器检测到格式为'{cipher}...'的值时,它在将它们发送到客户端之前会尝试使用其加密密钥来解密它们。

  1. 这些测试完成了关于集中配置的章节。通过关闭系统景观来结束:

    docker-compose down 
    

摘要

在本章中,我们看到了如何使用 Spring Cloud Config Server 来集中管理我们的微服务的配置。我们可以将配置文件放置在公共配置存储库中,并在单个配置文件中共享公共配置,同时将特定于微服务的配置保留在特定于微服务的配置文件中。微服务已更新,以便在启动时从配置服务器检索其配置,并且配置为在从配置服务器检索配置时处理暂时中断。

配置服务器可以通过要求使用 HTTP 基本身份验证来认证其 API 的使用,从而保护配置信息,并且可以通过通过使用 HTTPS 的边缘服务器公开其 API 来防止窃听。为了防止入侵者从磁盘上的配置文件中获取访问权限,我们可以使用配置服务器的/encrypt端点来加密信息,并将其加密存储在磁盘上。

在开发期间公开配置服务器的 API 是有用的,但在生产使用之前应该将其锁定。

在下一章中,我们将学习如何使用Resilience4j来减轻过度使用微服务之间同步通信的潜在缺点。

问题

  1. 在启动期间,我们可以期望审查服务向配置服务器发出哪些 API 调用以检索其配置?

  2. 使用以下命令启动了审查服务:docker compose up -d

    使用以下命令调用配置服务器时,我们应该期望返回哪些配置信息?

    curl https://dev-usr:dev-pwd@localhost:8443/config/application/default -ks | jq 
    
  3. Spring Cloud Config 支持哪些类型的存储后端?

  4. 我们如何使用 Spring Cloud Config Server 在磁盘上加密敏感信息?

  5. 我们如何保护配置服务器 API 免受滥用?

  6. 提及与首先连接到配置服务器相比,那些首先连接到发现服务器的客户端的一些优缺点。

第十三章:使用 Resilience4j 提高弹性

在本章中,我们将学习如何使用 Resilience4j 使我们的微服务更具弹性,也就是说,如何减轻和从错误中恢复。正如我们在第一章微服务简介电路断路器部分,以及第八章Spring Cloud 简介使用 Resilience4j 提高弹性部分中已经讨论过的,电路断路器可以用来最小化一个慢速或无响应的下游微服务在一个大规模同步通信微服务系统景观中可能造成的损害。我们将看到 Resilience4j 中的电路断路器如何与时间限制器和重试机制一起使用,以防止两种最常见的错误情况:

  • 开始响应缓慢或根本不响应的微服务

  • 随时随机失败的请求,例如,由于临时网络问题

本章将涵盖以下主题:

  • 介绍三个 Resilience4j 机制:电路断路器、时间限制器和重试

  • 将机制添加到源代码中

  • 在系统景观中部署时尝试这些机制

技术要求

关于如何安装本书中使用的工具以及如何访问本书源代码的说明,请参阅:

  • 第二十一章macOS 安装说明

  • 第二十二章使用 WSL 2 和 Ubuntu 的 Microsoft Windows 安装说明

本章中的代码示例均来自$BOOK_HOME/Chapter13的源代码。

如果你想查看对本章源代码所做的更改,即查看使用 Resilience4j 添加弹性的过程,你可以将其与第十二章集中配置的源代码进行比较。你可以使用你喜欢的diff工具比较两个文件夹,$BOOK_HOME/Chapter12$BOOK_HOME/Chapter13

介绍 Resilience4j 弹性机制

电路断路器、时间限制器和重试机制在两个软件组件之间的任何同步通信中都可能很有用,例如,微服务。在本章中,我们将将这些机制应用于一个地方,即在product-composite服务对product服务的调用中。以下图示说明了这一点:

图描述自动生成

图 13.1:向系统景观添加弹性功能

注意,其他微服务对发现和配置服务器的同步调用在先前的图中没有显示(为了便于阅读)。

在 2019 年 11 月的 Spring Cloud Hoxton 版本中,添加了Spring Cloud 断路器项目。它为断路器提供了一个抽象层。Resilience4j 可以配置为在底层使用。该项目不像 Resilience4j 项目那样以集成的方式提供其他弹性机制,如重试、时间限制器、舱壁或速率限制器。有关该项目的更多信息,请参阅spring.io/projects/spring-cloud-circuitbreaker

许多其他替代方案也存在。例如,Reactor 项目自带对重试和超时的内置支持;请参阅Mono.retryWhen()Mono.timeout()。Spring 也有重试机制(请参阅github.com/spring-projects/spring-retry),但它不支持响应式编程模型。

然而,没有任何替代方案像 Resilience4j 那样提供如此一致和高度集成的弹性机制集合,特别是在 Spring Boot 环境中,其中依赖项、注解和配置以优雅和一致的方式使用。最后,值得注意的是,Resilience4j 注解独立于使用的编程风格工作,无论是响应式还是命令式。

介绍断路器

让我们快速回顾一下第八章Spring Cloud 简介使用 Resilience4j 提高弹性部分提到的断路器状态图:

图描述自动生成

图 13.2:断路器状态图

断路器的关键特性如下:

  • 如果断路器检测到过多的故障,它将打开其电路,即不允许新的调用。

  • 当电路断开时,断路器将执行快速失败逻辑。这意味着它不会等待后续调用发生新的故障,例如超时。相反,它直接将调用重定向到回退方法。回退方法可以应用各种业务逻辑以产生最佳努力响应。例如,回退方法可以从本地缓存中返回数据或简单地返回一个立即的错误消息。这将防止微服务在它依赖的服务停止正常响应时变得无响应。这在高负载下特别有用。

  • 经过一段时间后,断路器将处于半开状态,允许新的调用检查导致失败的問題是否已解决。如果断路器检测到新的故障,它将再次打开电路并回到快速失败逻辑。否则,它将关闭电路并恢复正常操作。这使得微服务能够对故障具有弹性,或自我修复,这是在微服务系统中不可或缺的能力,这些微服务以同步方式相互通信。

Resilience4j 以多种方式在运行时公开有关断路器的信息:

  • 可以使用微服务的 actuator health 端点 /actuator/health 监控断路器的当前状态。

  • 断路器还会在 actuator 端点发布事件,例如状态转换和 /actuator/circuitbreakerevents

  • 最后,断路器与 Spring Boot 的指标系统集成,并可以使用它将指标发布到监控工具,例如 Prometheus。

我们将在本章尝试 healthevent 端点。在 第二十章监控微服务 中,我们将看到 Prometheus 的实际应用以及它如何收集由 Spring Boot 公开的指标,例如来自我们的断路器的指标。

为了控制断路器中的逻辑,Resilience4j 可以通过标准的 Spring Boot 配置文件进行配置。我们将使用以下配置参数:

  • slidingWindowType: 为了确定是否需要打开断路器,Resilience4j 使用滑动窗口,通过计算最近的事件来做出决策。滑动窗口可以是基于固定数量的调用或固定的时间间隔。此参数用于配置使用哪种类型的滑动窗口。

    我们将使用基于计数的滑动窗口,将此参数设置为 COUNT_BASED

  • slidingWindowSize: 在关闭状态下,用于确定电路是否应该打开的调用次数。

    我们将此参数设置为 5

  • failureRateThreshold: 导致电路打开的失败调用百分比阈值。

    我们将此参数设置为 50%。此设置,连同 slidingWindowSize 设置为 5,意味着如果最后五个调用中有三个或更多是故障,则电路将打开。

  • automaticTransitionFromOpenToHalfOpenEnabled: 确定断路器在等待期结束后是否会自动过渡到半开状态。否则,它将在等待期结束后等待第一个调用,然后过渡到半开状态。

    我们将此参数设置为 true

  • waitDurationInOpenState: 指定电路保持打开状态的时间长度,即过渡到半开状态之前。

    我们将此参数设置为 10000 ms。此设置,连同启用由前一个参数设置的自动过渡到半开状态,意味着断路器将保持电路打开 10 秒,然后过渡到半开状态。

  • permittedNumberOfCallsInHalfOpenState: 在半开状态下,用于确定电路是否会再次打开或返回到正常、关闭状态的调用次数。

    我们将此参数设置为 3,这意味着电路断路器将根据电路过渡到半打开状态后的前三次调用来决定是否打开或关闭电路。由于 failureRateThreshold 参数设置为 50%,如果两次或三次调用失败,电路将再次打开。否则,电路将关闭。

  • ignoreExceptions:此参数可以用来指定不应计为故障的异常。预期的业务异常,如“未找到”或“无效输入”,是电路断路器应该忽略的典型异常;搜索不存在数据或输入无效的用户不应导致电路打开。

    我们将此参数设置为包含异常 NotFoundExceptionInvalidInputException 的列表。

    最后,为了正确配置 Resilience4j 在 actuator health 端点中报告电路断路器状态,以下参数被设置:

  • registerHealthIndicator = true 启用 Resilience4j 填充 health 端点,其中包含其电路断路器状态的信息。

  • allowHealthIndicatorToFail = false 告诉 Resilience4j 不要影响 health 端点的状态。这意味着即使组件的一个电路断路器处于打开或半打开状态,health 端点仍然会报告 "UP"。非常重要的一点是,组件的健康状态不应仅因为其一个电路断路器未处于关闭状态而被报告为 "DOWN"。这意味着即使它依赖的某个组件不正常,该组件仍然被认为是正常的。

这实际上是电路断路器的核心价值,因此将此值设置为 true 大概会削弱引入电路断路器的价值。在 Resilience4j 的早期版本中,这实际上就是行为。在更近期的版本中,这一行为已被纠正,并且 false 实际上是此参数的默认值。但鉴于我认为理解组件的健康状态与其电路断路器状态之间的关系非常重要,我已经将其添加到配置中。

  • 最后,我们还必须配置 Spring Boot Actuator,以便在对其 health 端点的请求响应中添加 Resilience4j 生成的电路断路器健康信息:

    management.health.circuitbreakers.enabled: true 
    

对于可用的配置参数的完整列表,请参阅 resilience4j.readme.io/docs/circuitbreaker#create-and-configure-a-circuitbreaker

介绍时间限制器

为了帮助电路断路器处理缓慢或无响应的服务,超时机制可能很有帮助。Resilience4j 的超时机制,称为 TimeLimiter,可以通过标准的 Spring Boot 配置文件进行配置。我们将使用以下配置参数:

  • timeoutDuration:指定 TimeLimiter 实例在抛出超时异常之前等待调用完成的时长。我们将它设置为 2s

介绍重试机制

重试机制对于随机和不频繁的故障非常有用,例如临时的网络故障。重试机制可以在尝试之间配置一个延迟,简单地多次重试失败的请求。对重试机制使用的一个重要限制是,它重试的服务必须是幂等的,也就是说,使用相同的请求参数调用服务一次或多次会产生相同的结果。例如,读取信息是幂等的,但创建信息通常不是。你不希望重试机制意外地创建两个订单,仅仅因为第一个订单创建的响应在网络中丢失。

当涉及到事件和指标时,Resilience4j 以与断路器相同的方式公开重试信息,但并不提供任何健康信息。重试事件可以在 actuator 端点 /actuator/retryevents 上访问。为了控制重试逻辑,可以使用标准的 Spring Boot 配置文件来配置 Resilience4j。我们将使用以下配置参数:

  • maxAttempts:在放弃之前尝试的次数,包括第一次调用。我们将此参数设置为 3,允许在初始失败调用后进行最多两次的重试尝试。

  • waitDuration:下一次重试尝试之前的等待时间。我们将此值设置为 1000 毫秒,这意味着我们将等待 1 秒钟进行重试。

  • retryExceptions:将触发重试的异常列表。我们只会在 InternalServerError 异常上触发重试,即当 HTTP 请求以 500 状态码响应时。

在配置重试和断路器设置时要小心,例如,确保断路器在完成预期数量的重试之前不要打开电路!

要查看可用的配置参数的完整列表,请参阅resilience4j.readme.io/docs/retry#create-and-configure-retry

通过这次介绍,我们准备好查看如何将这些弹性机制添加到 product-composite 服务的源代码中。

将弹性机制添加到源代码中

在我们将弹性机制添加到源代码之前,我们将添加代码以使其能够强制发生错误,作为一个延迟和/或随机故障。接下来,我们将添加一个断路器以及一个时间限制器来处理缓慢或无响应的 API,以及一个可以处理随机发生的故障的重试机制。从 Resilience4j 添加这些功能遵循 Spring Boot 的方式,这是我们之前章节中使用的方式:

  • 在构建文件中添加 Resilience4j 的启动依赖

  • 在将应用弹性机制的源代码位置添加注释

  • 添加一个控制弹性机制行为的配置

处理弹性挑战是集成层的责任;因此,弹性机制将被放置在ProductCompositeIntegration类中。在业务逻辑中实现的源代码,在ProductCompositeServiceImpl类中,将不会意识到弹性机制的存在。

一旦我们建立了机制,我们最终将扩展我们的测试脚本test-em-all.bash,添加自动验证电路断路器在系统环境中部署时按预期工作的测试。

添加可编程延迟和随机错误

为了能够测试我们的弹性机制,我们需要一种控制错误发生时间的方法。实现这一目标的一种简单方法是在用于检索产品和复合产品的 API 中添加可选查询参数。

本节中添加的代码和 API 参数,用于强制延迟和错误发生,应仅在开发测试期间使用,不应在生产环境中使用。当我们学习到第十八章中关于服务网格的概念时,即使用服务网格提高可观察性和管理,我们将了解在生产环境中可以使用的更佳方法,以受控的方式引入延迟和错误。使用服务网格,我们可以引入延迟和错误,通常用于验证弹性能力,而不会影响微服务的源代码。

复合产品 API 将直接将参数传递给产品 API。以下查询参数已被添加到这两个 API 中:

  • delay: 导致product微服务上的getProduct API 延迟其响应。该参数以秒为单位指定。例如,如果参数设置为3,则会在响应返回之前造成三秒的延迟。

  • faultPercentage: 导致product微服务上的getProduct API 随机抛出异常,其概率由查询参数指定,范围从 0 到 100%。例如,如果参数设置为25,则平均每四次调用 API 时,会有一次调用失败并抛出异常。在这些情况下,它将返回HTTP 错误 500(内部服务器错误)

API 定义的变化

我们上面引入的两个查询参数delayfaultPercentage已在api项目的以下两个 Java 接口中定义:

  • ProductCompositeService:

    Mono<ProductAggregate> getProduct(
        @PathVariable int productId,
        @RequestParam(value = "delay", required = false, defaultValue =
        "0") int delay,
        @RequestParam(value = "faultPercent", required = false, 
        defaultValue = "0") int faultPercent
    ); 
    
  • ProductService:

    Mono<Product> getProduct(
         @PathVariable int productId,
         @RequestParam(value = "delay", required = false, defaultValue
         = "0") int delay,
         @RequestParam(value = "faultPercent", required = false, 
         defaultValue = "0") int faultPercent
    ); 
    

查询参数被声明为可选的,并具有默认值,这些默认值禁用了错误机制的使用。这意味着如果请求中没有使用任何查询参数,则不会应用延迟也不会抛出错误。

产品-复合微服务的变化

product-composite 微服务只是将参数传递给产品 API。服务实现接收 API 请求并将参数传递给进行产品 API 调用的集成组件:

  • ProductCompositeServiceImpl 类对集成组件的调用看起来如下:

    public Mono<ProductAggregate> getProduct(int productId,
      int delay, int faultPercent) {
        return Mono.zip(
            ...
            integration.getProduct(productId, delay, faultPercent),
            .... 
    
  • ProductCompositeIntegration 类对产品 API 的调用如下:

    public Mono<Product> getProduct(int productId, int delay, 
      int faultPercent) {
    
        URI url = UriComponentsBuilder.fromUriString(
          PRODUCT_SERVICE_URL + "/product/{productId}?delay={delay}" 
          + "&faultPercent={faultPercent}")
          .build(productId, delay, faultPercent);
      return webClient.get().uri(url).retrieve()... 
    

产品微服务的变更

product 微服务通过扩展用于从 MongoDB 数据库读取产品信息的现有流,在 ProductServiceImpl 类中实现了实际的延迟和随机错误生成器。它看起来是这样的:

public Mono<Product> getProduct(int productId, int delay, 
  int faultPercent) {
  ...
  return repository.findByProductId(productId)
    .map(e -> throwErrorIfBadLuck(e, faultPercent))
    .delayElement(Duration.ofSeconds(delay))
    ...
} 

当流返回 Spring Data 存储库的响应时,它首先应用 throwErrorIfBadLuck 方法来查看是否需要抛出异常。接下来,它使用 Mono 类中的 delayElement 函数应用延迟。

随机错误生成器 throwErrorIfBadLuck() 生成一个介于 1100 之间的随机数,如果它高于或等于指定的故障百分比,则抛出异常。如果没有抛出异常,则将产品实体传递到流中。源代码如下:

private ProductEntity throwErrorIfBadLuck(
  ProductEntity entity, int faultPercent) {
  if (faultPercent == 0) {
    return entity;
  }
  int randomThreshold = getRandomNumber(1, 100);
  if (faultPercent < randomThreshold) {
    LOG.debug("We got lucky, no error occurred, {} < {}", 
      faultPercent, randomThreshold);

  } else {
    LOG.info("Bad luck, an error occurred, {} >= {}",
      faultPercent, randomThreshold);

    throw new RuntimeException("Something went wrong...");
  }
  return entity;
}
private final Random randomNumberGenerator = new Random();
private int getRandomNumber(int min, int max) {
  if (max < min) {
    throw new IllegalArgumentException("Max must be greater than min");
  }
  return randomNumberGenerator.nextInt((max - min) + 1) + min;
} 

在程序化延迟和随机错误函数就绪后,我们准备开始向代码中添加弹性机制。我们将从断路器和时间限制器开始。

添加断路器和时间限制器

如我们之前提到的,我们需要添加依赖项、注解和配置。我们还需要添加一些代码来实现快速失败场景的回退逻辑。我们将在接下来的章节中看到如何做到这一点。

在构建文件中添加依赖项

要添加断路器和时间限制器,我们必须在构建文件 build.gradle 中添加适当的 Resilience4j 库的依赖项。

从产品文档(resilience4j.readme.io/docs/getting-started-3#setup)中,我们可以了解到需要添加以下三个依赖项。当编写这一章节时,我们将使用最新可用的版本(v2.0.2):

ext {
   resilience4jVersion = "2.0.2"
}
dependencies {
    implementation "io.github.resilience4j:resilience4j-spring-
boot2:${resilience4jVersion}"
    implementation "io.github.resilience4j:resilience4j-reactor:${resilience4jVersion}"
    implementation 'org.springframework.boot:spring-boot-starter-aop'
    ... 

为了避免 Spring Cloud 覆盖它捆绑的较旧版本的 Resilience4j 所使用的版本,我们还导入了一个 resilience4j-bom(物料清单)文件,如 Spring Boot 3 示例项目 github.com/resilience4j/resilience4j-spring-boot3-demo 中所述。我们将此 bom 文件添加到现有的 bom 文件中,该文件位于 dependencyManagement 部分:

dependencyManagement {
    imports {
        mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}"
        mavenBom "io.github.resilience4j:resilience4j-bom:${resilience4jVersion}"
    }
} 

在源代码中添加注解

可以通过注解要保护的方法来应用熔断器,在本例中是ProductCompositeIntegration类中的getProduct()方法。熔断器是由异常触发的,而不是由超时本身触发的。为了能够在超时后触发熔断器,我们将添加一个可以注解为@TimeLimiter(...)的时间限制器。源代码如下:

@TimeLimiter(name = "product")
@CircuitBreaker(
     name = "product", fallbackMethod = "getProductFallbackValue")
public Mono<Product> getProduct(
  int productId, int delay, int faultPercent) {
  ...
} 

熔断器和时间限制器注解的name "product"用于标识将要应用配置。熔断器注解中的fallbackMethod参数用于指定当熔断器开启时调用哪个回退方法(在本例中为getProductFallbackValue);下面将介绍其用法。

要激活熔断器,必须以 Spring bean 的形式调用注解的方法。在我们的案例中,是 Spring 注入到服务实现类ProductCompositeServiceImpl中的集成类,因此用作 Spring bean:

private final ProductCompositeIntegration integration;
@Autowired
public ProductCompositeServiceImpl(... ProductCompositeIntegration integration) {
  this.integration = integration;
}
public Mono<ProductAggregate> getProduct(int productId, int delay, int faultPercent) {
  return Mono.zip(
    ..., 
    integration.getProduct(productId, delay, faultPercent), 
    ... 

添加快速失败回退逻辑

要能够在熔断器开启时应用回退逻辑,即当请求快速失败时,我们可以在CircuitBreaker注解上指定一个回退方法,如前一个源代码所示。该方法必须遵循熔断器应用的方法的签名,并且还有一个额外的最后一个参数,用于传递触发熔断器的异常。在我们的案例中,回退方法的签名如下:

private Mono<Product> getProductFallbackValue(int productId, 
  int delay, int faultPercent, CallNotPermittedException ex) { 

最后一个参数指定了我们想要能够处理类型为CallNotPermittedException的异常。我们只对当熔断器处于开启状态时抛出的异常感兴趣,以便我们可以应用快速失败逻辑。当熔断器开启时,它将不允许调用底层方法;相反,它将立即抛出一个CallNotPermittedException异常。因此,我们只对捕获CallNotPermittedException异常感兴趣。

回退逻辑可以根据productId从替代源中查找信息,例如,内部缓存。在我们的案例中,我们将根据productId返回硬编码的值,以模拟缓存中的命中。为了模拟缓存中的未命中,当productId13时,我们将抛出一个not found异常。

回退方法的实现看起来是这样的:

private Mono<Product> getProductFallbackValue(int productId, 
  int delay, int faultPercent, CallNotPermittedException ex) {
  if (productId == 13) {
    String errMsg = "Product Id: " + productId 
      + " not found in fallback cache!";
    throw new NotFoundException(errMsg);
  }
  return Mono.just(new Product(productId, "Fallback product" 
    + productId, productId, serviceUtil.getServiceAddress()));
} 

添加配置

最后,将熔断器和时间限制器的配置添加到配置存储库中的product-composite.yml文件中,如下所示:

resilience4j.timelimiter:
  instances:
    product:
      timeoutDuration: 2s
management.health.circuitbreakers.enabled: true
resilience4j.circuitbreaker:
  instances:
    product:
      allowHealthIndicatorToFail: false
      registerHealthIndicator: true
      slidingWindowType: COUNT_BASED
      slidingWindowSize: 5
      failureRateThreshold: 50
      waitDurationInOpenState: 10000
      permittedNumberOfCallsInHalfOpenState: 3
      automaticTransitionFromOpenToHalfOpenEnabled: true
      ignoreExceptions:
        - se.magnus.api.exceptions.InvalidInputException
        - se.magnus.api.exceptions.NotFoundException 

配置中的值已在之前的章节中描述,介绍熔断器介绍时间限制器

添加重试机制

与断路器一样,通过添加依赖项、注解和配置来设置重试机制。依赖项已在向构建文件添加依赖项部分中添加,因此我们只需添加注解并设置配置。

添加重试注解

可以通过使用@Retry(name="nnn")注解将重试机制应用于一个方法,其中nnn是要用于此方法的配置条目名称。有关配置的详细信息,请参阅以下添加配置部分。在我们的案例中,方法与断路器和时间限制器相同,即ProductCompositeIntegration类中的getProduct()方法:

 @Retry(name = "product")
  @TimeLimiter(name = "product")
  @CircuitBreaker(name = "product", fallbackMethod =
    "getProductFallbackValue")
  public Mono<Product> getProduct(int productId, int delay, 
    int faultPercent) { 

添加配置

在配置仓库中的product-composite.yml文件中,与断路器和时间限制器一样,以相同的方式添加了重试机制的配置,如下所示:

resilience4j.retry:
  instances:
    product:
      maxAttempts: 3
      waitDuration: 1000
      retryExceptions:
      - org.springframework.web.reactive.function.client.WebClientResponseException$InternalServerError 

实际值在上面的介绍重试机制部分中已讨论。

当使用多个 Resilience4j 机制(在我们的案例中,是断路器、时间限制器和重试机制)时,了解这些方面应用的顺序是很重要的。有关信息,请参阅resilience4j.readme.io/docs/getting-started-3#aspect-order

这就是所有所需的依赖项、注解、源代码和配置。让我们通过扩展测试脚本并添加验证已部署系统景观中断路器按预期工作的测试来结束。

添加自动化测试

已将断路器的自动化测试添加到test-em-all.bash测试脚本中的单独函数testCircuitBreaker()

...
function testCircuitBreaker() {
    echo "Start Circuit Breaker tests!"
    ...
}
...
testCircuitBreaker
...
echo "End, all tests OK:" `date` 

为了能够执行一些必需的验证,我们需要访问product-composite微服务的actuator端点,这些端点不是通过边缘服务器公开的。因此,我们将通过在product-composite微服务中运行命令并使用 Docker Compose 的exec命令来访问actuator端点。微服务使用的基镜像Eclipse Temurin捆绑了curl,因此我们可以在product-composite容器中简单地运行一个curl命令来获取所需的信息。命令看起来是这样的:

docker-compose exec -T product-composite curl -s http://product-composite:8080/actuator/health 

-T参数用于禁用exec命令的终端使用。这对于在不存在终端的环境中运行test-em-all.bash测试脚本很重要,例如,在用于 CI/CD 的自动化构建管道中。

为了能够提取我们测试所需的信息,我们可以将输出管道到jq工具。例如,要提取断路器的实际状态,我们可以运行以下命令:

docker-compose exec -T product-composite curl -s http://product-composite:8080/actuator/health | jq -r .components.circuitBreakers.details.product.details.state 

根据实际状态,它将返回CLOSEDOPENHALF_OPEN

测试开始时,正是这样做,即在执行测试之前验证断路器是否关闭:

assertEqual "CLOSED" "$(docker-compose exec -T product-composite curl -s http://product-composite:8080/actuator/health | jq -r .components.circuitBreakers.details.product.details.state)" 

接下来,测试将通过连续运行三个命令来强制断路器开启,所有这些命令都会因为product服务(delay参数设置为3秒)的缓慢响应而超时:

for ((n=0; n<3; n++))
do
    assertCurl 500 "curl -k https://$HOST:$PORT/product-composite/$PROD_ID_REVS_RECS?delay=3 $AUTH -s"
    message=$(echo $RESPONSE | jq -r .message)
    assertEqual "Did not observe any item or terminal signal within 2000ms" "${message:0:57}"
done 

快速提醒配置product服务的超时设置为两秒,以便三秒的延迟会导致超时。断路器在关闭时配置为评估最后五个调用。脚本中先于断路器特定测试的测试已经执行了一些成功的调用。失败阈值设置为 50%;三个带有三秒延迟的调用足以打开电路。

在电路开启的情况下,我们期望出现快速失败的行为,即我们不需要等待超时就能得到响应。我们还期望调用fallback方法以返回最佳尝试响应。这也应该适用于正常调用,即不请求延迟。这通过以下代码得到验证:

assertEqual "OPEN" "$(docker-compose exec -T product-composite curl -s http://product-composite:8080/actuator/health | jq -r .components.circuitBreakers.details.product.details.state)"
assertCurl 200 "curl -k https://$HOST:$PORT/product-composite/$PROD_ID_REVS_RECS?delay=3 $AUTH -s"
assertEqual "Fallback product$PROD_ID_REVS_RECS" "$(echo "$RESPONSE" | jq -r .name)"
assertCurl 200 "curl -k https://$HOST:$PORT/product-composite/$PROD_ID_REVS_RECS $AUTH -s"
assertEqual "Fallback product$PROD_ID_REVS_RECS" "$(echo "$RESPONSE" | jq -r .name)" 

产品 ID 1存储在一个变量中,$PROD_ID_REVS_RECS,以便在需要时更容易修改脚本。

我们还可以验证模拟的未找到错误逻辑在回退方法中按预期工作,即回退方法对于产品 ID 13返回404, NOT_FOUND

assertCurl 404 "curl -k https://$HOST:$PORT/product-composite/$PROD_ID_NOT_FOUND $AUTH -s"
assertEqual "Product Id: $PROD_ID_NOT_FOUND not found in fallback cache!" "$(echo $RESPONSE | jq -r .message)" 

产品 ID 13存储在一个变量中,$PROD_ID_NOT_FOUND

如配置所示,断路器将在10秒后将状态更改为半开。为了验证这一点,测试等待10秒:

echo "Will sleep for 10 sec waiting for the CB to go Half Open..."
sleep 10 

在验证了预期的状态(半开)后,测试运行三个正常请求,使断路器回到其正常状态,这也得到了验证:

assertEqual "HALF_OPEN" "$(docker-compose exec -T product-composite curl -s http://product-composite:8080/actuator/health | jq -r .components.circuitBreakers.details.product.details.state)"
for ((n=0; n<3; n++))
do
    assertCurl 200 "curl -k https://$HOST:$PORT/product-composite/$PROD_ID_REVS_RECS $AUTH -s"
    assertEqual "product name C" "$(echo "$RESPONSE" | jq -r .name)"
done
assertEqual "CLOSED" "$(docker-compose exec -T product-composite curl -s http://product-composite:8080/actuator/health | jq -r .components.circuitBreakers.details.product.details.state)" 

测试代码还验证了它收到了来自底层数据库的数据响应。它是通过比较返回的产品名称与数据库中存储的值来做到这一点的。对于产品 ID 为1的产品,名称是"product name C"

快速提醒配置:断路器在半开状态下会评估前三个调用。因此,我们需要运行三个请求,其中超过 50%的请求成功,才能关闭断路器。

测试通过使用由断路器公开的/actuator/circuitbreakerevents操作器 API 来完成,以揭示内部事件。它用于找出断路器执行了哪些状态转换。我们预计最后三个状态转换如下:

  • 第一个状态转换:关闭到开启

  • 下一个状态转换:开启到半开

  • 最后的状态转换:半开到关闭

这可以通过以下代码进行验证:

assertEqual "CLOSED_TO_OPEN"      "$(docker-compose exec -T product-composite curl -s http://product-composite:8080/actuator/circuitbreakerevents/product/STATE_TRANSITION | jq -r
.circuitBreakerEvents[-3].stateTransition)"
assertEqual "OPEN_TO_HALF_OPEN"   "$(docker-compose exec -T product-composite curl -s http://product-composite:8080/actuator/circuitbreakerevents/product/STATE_TRANSITION | jq -r .circuitBreakerEvents[-2].stateTransition)"
assertEqual "HALF_OPEN_TO_CLOSED" "$(docker-compose exec -T product-composite curl -s http://product-composite:8080/actuator/circuitbreakerevents/product/STATE_TRANSITION | jq -r .circuitBreakerEvents[-1].stateTransition)" 

jq表达式circuitBreakerEvents[-1]表示断路器事件数组中的最后一个条目,[-2]是倒数第二个事件,而[-3]是倒数第三个事件。它们一起构成了最新的三个事件,即我们感兴趣的事件。

我们在测试脚本中添加了很多步骤,但有了这个,我们可以自动验证我们的断路器预期的基本行为已经到位。在下文中,我们将尝试它。我们将通过运行测试脚本和手动运行测试脚本中的命令来运行测试。

尝试断路器和重试机制

现在,是时候尝试断路器和重试机制了。我们将像往常一样,首先构建 Docker 镜像并运行测试脚本test-em-all.bash。之后,我们将手动运行我们之前描述的测试,以确保我们理解正在发生的事情!我们将执行以下手动测试:

  • 快乐的日子测试断路器,以验证在正常操作下断路器是关闭的

  • 断路器的负面测试,以验证当事情开始出错时,断路器是否会打开

  • 回到正常操作,以验证一旦问题解决,断路器会回到关闭状态

  • 尝试使用随机错误的重试机制

构建和运行自动化测试

要构建和运行自动化测试,我们需要做以下事情:

  1. 首先,使用以下命令构建 Docker 镜像:

    cd $BOOK_HOME/Chapter13
    ./gradlew build && docker-compose build 
    
  2. 接下来,在 Docker 中启动系统景观并使用以下命令运行常规测试:

    ./test-em-all.bash start 
    

当测试脚本打印出开始断路器测试时,我们之前描述的测试已经执行完毕!

验证在正常操作下断路器是否关闭

在我们能够调用 API 之前,我们需要一个访问令牌。运行以下命令以获取访问令牌:

unset ACCESS_TOKEN
ACCESS_TOKEN=$(curl -k https://writer:secret-writer@localhost:8443/oauth2/token -d grant_type=client_credentials -d scope="product:read product:write" -s | jq -r .access_token)
echo $ACCESS_TOKEN 

由授权服务器签发的访问令牌有效期为 1 小时。因此,如果您一段时间后开始收到401 – 未授权错误,可能需要获取新的访问令牌。

尝试一个正常请求并验证它返回 HTTP 响应代码200

curl -H "Authorization: Bearer $ACCESS_TOKEN" -k https://localhost:8443/product-composite/1 -w "%{http_code}\n" -o /dev/null -s 

-w "%{http_code}\n"开关用于打印 HTTP 返回状态。只要命令返回200,我们就对响应体不感兴趣,因此我们使用开关-o /dev/null来抑制它。

使用health API 验证断路器是否关闭:

docker-compose exec product-composite curl -s http://product-composite:8080/actuator/health | jq -r .components.circuitBreakers.details.product.details.state 

我们期望它响应为CLOSED

在事情出错时强制断路器打开

现在,是时候让事情出错!我的意思是,现在是时候尝试一些负面测试,以验证当事情开始出错时,断路器是否会打开。调用 API 三次,并将product服务指向每次调用时超时,即延迟响应3秒。这应该足以触发断路器:

curl -H "Authorization: Bearer $ACCESS_TOKEN" -k https://localhost:8443/product-composite/1?delay=3 -s | jq . 

我们期望每次都得到以下响应:

文本描述自动生成

图 13.3:超时后的响应

电路断路器现在是开启的,所以如果你在waitInterval(即 10 秒)内进行第四次尝试,你会看到快速失败行为和fallback方法在行动。一旦在 2 秒后时间限制器启动,你将立即得到响应而不是错误消息:

图形用户界面,文本描述自动生成

图 13.4:电路断路器开启时的响应

响应将来自回退方法。这可以通过查看名称字段中的值来识别,Fallback product1

快速失败和回退方法是电路断路器的关键功能。一个在开启状态下设置等待时间仅为 10 秒的配置要求你相当快才能看到快速失败逻辑和回退方法在行动!一旦处于半开启状态,你总是可以提交三个新的请求,这些请求会导致超时,迫使电路断路器回到开启状态,然后快速尝试第四个请求。然后,你应该从回退方法得到快速失败的响应。你还可以将等待时间增加到一分钟或两分钟,但等待那么长时间直到电路切换到半开启状态可能会相当无聊。

等待 10 秒,让电路断路器过渡到半开启状态,然后运行以下命令以验证电路现在处于半开启状态:

docker-compose exec product-composite curl -s http://product-composite:8080/actuator/health | jq -r .components.circuitBreakers.details.product.details.state 

期望它响应HALF_OPEN

再次关闭电路断路器

一旦电路断路器处于半开启状态,它将等待三个调用以确定是否应该再次开启电路,或者通过关闭它回到正常状态。

让我们提交三个正常请求来关闭电路断路器:

curl -H "Authorization: Bearer $ACCESS_TOKEN" -k https://localhost:8443/product-composite/1 -w "%{http_code}\n" -o /dev/null -s 

它们都应该响应200。通过使用health API 验证电路是否再次关闭:

docker-compose exec product-composite curl -s http://product-composite:8080/actuator/health | jq -r .components.circuitBreakers.details.product.details.state 

我们期望它响应为CLOSED

使用以下命令列出最后三个状态转换来完成这个总结:

docker-compose exec product-composite curl -s http://product-composite:8080/actuator/circuitbreakerevents/product/STATE_TRANSITION | jq -r '.circuitBreakerEvents[-3].stateTransition, .circuitBreakerEvents[-2].stateTransition, .circuitBreakerEvents[-1].stateTransition' 

期望它响应以下内容:

文本描述自动生成

图 13.5:电路断路器状态变化

这个响应告诉我们,我们已经将电路断路器在其状态图上完成了一个完整的循环:

  • 当超时错误开始阻止请求成功时,从关闭到开启

  • 从开启到半开启,查看错误是否已消失

  • 当错误消失时,即我们回到正常操作时,从半开启到关闭

到此为止,我们已经完成了电路断路器的测试;让我们继续并看看重试机制在起作用。

尝试由随机错误引起的重试

让我们模拟我们的product服务或与其通信存在一个——希望是暂时性的——随机问题。

我们可以通过使用faultPercent参数来做这件事。如果我们将其设置为25,我们期望平均每四次请求中有一个会失败。我们希望重试机制会启动,通过自动重试失败的请求来帮助我们。注意到重试机制已启动的一种方法是通过测量curl命令的响应时间。正常的响应应该大约需要 100 毫秒。由于我们已经配置了重试机制等待 1 秒(参见重试机制配置部分的waitDuration参数),我们期望每次重试尝试的响应时间增加 1 秒。为了强制发生随机错误,运行以下命令几次:

time curl -H "Authorization: Bearer $ACCESS_TOKEN" -k https://localhost:8443/product-composite/1?faultPercent=25 -w "%{http_code}\n" -o /dev/null -s 

命令应该以200响应,表示请求成功。以real开头的前缀响应时间,例如,real 0m0.078s,表示响应时间为 0.078 秒,或 78 毫秒。正常响应,即没有任何重试,应该报告大约 100 毫秒的响应时间,如下所示:

图形用户界面、文本、应用程序、聊天或文本消息  自动生成的描述

图 13.6:无重试请求的耗时

一次重试后的响应应该略超过 1 秒,如下所示:

图形用户界面、应用程序  自动生成的描述

图 13.7:有重试请求的耗时

HTTP 状态码200表示请求已成功,即使它需要重试一次才能成功!

在你注意到 1 秒的响应时间后,表示请求需要重试一次才能成功,运行以下命令以查看最后两个重试事件:

docker-compose exec product-composite curl -s http://product-composite:8080/actuator/retryevents | jq '.retryEvents[-2], .retryEvents[-1]' 

你应该能够看到失败的请求和下一次成功的尝试。creationTime时间戳预计会相差 1 秒。期望得到如下响应:

文本、聊天或文本消息  自动生成的描述

图 13.8:请求一次重试后捕获的重试事件

如果你真的很不幸,你可能会连续两次出现故障,然后你会得到 2 秒的响应时间而不是 1 秒。如果你重复前面的命令,你将能够看到numberOfAttempts字段为每次重试尝试计数,在这种情况下设置为1:"numberOfAttempts": 1。如果调用继续失败,断路器将启动并打开其电路,即后续调用将应用快速失败逻辑,并应用回退方法!

这就结束了本章。请随意在配置参数中实验,以了解更多关于弹性机制的信息。

不要忘记关闭系统景观:

docker-compose down 

摘要

在本章中,我们看到了 Resilience4j 及其断路器、时间限制器和重试机制的实际应用。

一个对其他服务有同步依赖的微服务,如果这些服务停止按预期响应,尤其是在高负载下,可能会变得无响应或甚至崩溃。通过使用断路器,可以避免这些类型的错误场景,断路器应用快速失败逻辑,并在打开时调用回退方法。断路器还可以通过允许在半开状态下发出请求来检查失败的服务是否再次正常运行,如果是,则关闭电路。为了支持断路器处理无响应的服务,可以使用时间限制器来最大化断路器在启动之前等待的时间。

重试机制可以重试偶尔随机失败请求,例如,由于临时网络问题。仅对幂等服务应用重试请求非常重要,即可以处理同一请求被发送两次或更多次的那些服务。

断路器和重试机制是通过遵循 Spring Boot 习惯来实现的:声明依赖项、添加注解和配置。Resilience4j 在运行时通过 actuator 端点公开其断路器和重试机制的信息。对于断路器,有关健康、事件和指标的信息可用。对于重试,有关事件和指标的信息可用。

我们在本章中已经看到了端点在健康和事件方面的使用,但我们必须等到 第二十章监控微服务,我们才能使用任何指标。

在下一章中,我们将介绍 Spring Cloud 的最后一部分使用,我们将学习如何使用 Spring Cloud Sleuth 和 Zipkin 通过一组协作的微服务来跟踪调用链。前往 第十四章理解分布式跟踪,开始学习!

问题

  1. 断路器的状态有哪些以及它们是如何被使用的?

  2. 我们如何处理断路器中的超时错误?

  3. 当断路器快速失败时,我们如何应用回退逻辑?

  4. 重试机制和断路器如何相互干扰?

  5. 提供一个无法应用重试机制的服务的示例。

第十四章:理解分布式追踪

在本章中,我们将学习如何使用分布式追踪更好地理解我们的微服务如何协作,例如,在完成发送到外部 API 的请求时。能够利用分布式追踪对于管理协作微服务的系统景观至关重要。正如已在第八章Spring Cloud 简介中描述的那样,Micrometer Tracing 将被用于收集追踪信息,而 Zipkin 将被用于存储和可视化这些追踪信息。

在本章中,我们将学习以下主题:

  • 使用 Micrometer Tracing 和 Zipkin 引入分布式追踪。

  • 如何将分布式追踪添加到源代码中。

  • 如何以编程方式向追踪添加信息。

  • 如何执行分布式追踪,可视化成功和失败的 API 请求。我们将看到同步和异步处理如何被可视化。

技术要求

关于如何安装本书中使用的工具以及如何访问本书源代码的说明,请参阅:

  • 第二十一章macOS 的安装说明

  • 第二十二章使用 WSL 2 和 Ubuntu 的 Microsoft Windows 安装说明

本章中的代码示例均来自$BOOK_HOME/Chapter14的源代码。

如果你想查看本章源代码中应用的变化,即查看如何使用 Micrometer Tracing 和 Zipkin 添加分布式追踪,你可以将其与第十三章使用 Resilience4j 提高弹性的源代码进行比较。你可以使用你喜欢的diff工具比较两个文件夹,$BOOK_HOME/Chapter13$BOOK_HOME/Chapter14

使用 Micrometer Tracing 和 Zipkin 引入分布式追踪

回顾第八章Spring Cloud 简介中关于使用 Micrometer Tracing 和 Zipkin 进行分布式追踪的部分,一个完整工作流程的追踪信息被称为追踪追踪树,而树中的子部分,例如工作的基本单元,被称为跨度。跨度可以由形成追踪树的子跨度组成。可以在追踪及其跨度中添加元数据,作为键值对称为标签。Zipkin UI 可以如下可视化追踪树及其跨度:

图形用户界面,应用程序描述自动生成

图 14.1:带有其跨度的追踪示例

Micrometer 跟踪用于收集跟踪信息,在调用其他微服务时传播跟踪上下文(例如,跟踪和跨度 ID),并将跟踪信息导出到像 Zipkin 这样的跟踪分析工具。跟踪信息的处理是在底层由跟踪器完成的。Micrometer 支持基于OpenTelemetry(opentelemetry.io/)或OpenZipkin Brave(github.com/openzipkin/brave)的自动配置跟踪器。将跟踪信息导出到跟踪分析工具是由报告器完成的。

默认情况下,跟踪头通过使用W3C 跟踪上下文头(www.w3.org/TR/trace-context/)在微服务之间传播,最重要的是traceparent头,但也可以配置为使用 OpenZipkin 的B3头。在本章中,我们将使用 W3C 跟踪上下文头。在第十八章中,我们将使用B3头。

一个示例 W3C 跟踪上下文traceparent头看起来像这样:

traceparent:"00-2425f26083814f66c985c717a761e810-fbec8704028cfb20-01" 

traceparent头的值包含四个部分,由-分隔:

  • 00,表示使用的版本。根据当前规范,始终是“00"。

  • 124…810是跟踪 ID。

  • fbe…b20是跨度 ID。

  • 01,最后一部分包含各种标志。当前规范支持的唯一标志是名为sampled的标志,其值为01。这意味着调用者正在记录此请求的跟踪数据。我们将配置我们的微服务以记录所有请求的跟踪数据,因此此标志始终具有01的值。

使用 OpenZipkin Brave B3头的样子如下:

X-B3-TraceId:"64436ea679e8eb6e6fa028bb3459e703"
X-B3-SpanId:"120678270898ddd5"
X-B3-ParentSpanId:"3c431d3d01987c22"
X-B3-Sampled:"1" 

头名称是自解释的,我们可以看到头不仅提供了跟踪和跨度 ID,还提供了父跨度 ID。

跟踪和跨度由 Spring Boot 自动为传入流量创建,无论是传入的 HTTP 请求还是 Spring Cloud Stream 接收到的消息。如果传入请求包含跟踪 ID,它将在创建跨度时使用;如果没有,将创建一个新的跟踪 ID。跟踪和跨度 ID 将自动传播到传出流量,无论是作为 HTTP 请求还是通过使用 Spring Cloud Stream 发送消息。

如果需要,可以通过编程方式添加额外的跟踪信息,无论是通过添加自定义跨度,还是通过向由微服务创建的所有跨度添加自定义标签。这是通过使用Micrometer 可观察性(micrometer.io/docs/observation)及其Observation API 来完成的。

Micrometer 跟踪的初始版本与 Spring Boot 3 一起发布时,在支持反应式客户端的分布式跟踪方面存在一些限制。这影响了本书中使用的底层使用 Project Reactor 的微服务。在添加对反应式客户端缺乏支持的解决方案部分,我们将学习如何缓解这些不足。

Zipkin 内置了对存储跟踪信息原生的支持,无论是存储在内存中,还是存储在 Apache Cassandra、Elasticsearch 或 MySQL 等数据库中。除此之外,还有许多扩展可用。有关详细信息,请参阅 zipkin.io/pages/extensions_choices.html。在本章中,我们将存储跟踪信息在内存中。

在引入 Micrometer 跟踪和 Zipkin 之后,让我们看看在源代码中需要做出哪些更改才能启用分布式跟踪。

将分布式跟踪添加到源代码

在本节中,我们将学习如何更新源代码以启用分布式跟踪。这可以通过以下步骤完成:

  1. 将依赖项添加到构建文件中,以引入带有跟踪器实现和报告器的 Micrometer 跟踪。

  2. 将 Zipkin 服务器添加到 Docker Compose 文件中。

  3. 配置微服务以将跟踪信息发送到 Zipkin。

  4. 为缺乏对反应式客户端的支持添加解决方案。

  5. 在现有跨度中添加创建自定义跨度自定义标签的代码。

我们将依次介绍每个步骤。

将依赖项添加到构建文件中。

为了能够利用 Micrometer 跟踪和将跟踪信息导出到 Zipkin 的能力,我们需要将所选跟踪器和报告器的依赖项添加到 Gradle 项目构建文件 build.gradle 中。

这通过添加以下两行来完成:

implementation 'io.micrometer:micrometer-tracing-bridge-otel'
implementation 'io.opentelemetry:opentelemetry-exporter-zipkin' 

对于审查服务,还添加了一个依赖项以启用有关 SQL 数据库操作的跟踪信息。它看起来像:

implementation 'net.ttddyy.observation:datasource-micrometer-spring-boot:1.0.0' 

这个库可以为审查服务执行的 SQL 操作创建跨度。这些跨度将包含有关已执行的 SQL 查询及其执行时间的信息。

添加 Micrometer 跟踪和 Zipkin 的配置

使用 Micrometer 跟踪和 Zipkin 的配置已添加到通用配置文件 config-repo/application.yml 中。在默认配置文件中,指定了跟踪信息将通过以下 URL 发送到 Zipkin:

management.zipkin.tracing.endpoint: http://zipkin:9411/api/v2/spans 

默认情况下,Micrometer 跟踪只发送 10% 的跟踪到 Zipkin。为了确保所有跟踪都发送到 Zipkin,以下属性被添加到默认配置文件中:

management.tracing.sampling.probability: 1.0 

我们还希望将跟踪和跨度 ID 写入日志;这将使我们能够关联来自协作微服务的日志输出,例如,满足发送到外部 API 的请求。

我们将在 第十九章 中研究如何使用它,即使用 EFK 栈进行集中式日志记录。

我们可以通过指定以下日志格式来在日志输出中包含跟踪和跨度 ID:

logging.pattern.level: "%5p [${spring.application.name:},%X{traceId:-},%X{spanId:-}]" 

使用上述日志格式,日志输出将如下所示:

2023-04-22T14:02:07.417Z  INFO [product-composite,01234,56789] 

product-composite 是微服务的名称,01234 是跟踪 ID,56789 是跨度 ID。

为了减少日志输出,我们还更改了 config-repo 中每个微服务的配置文件的日志级别,从 DEBUG 更改为 INFO。这使得验证跟踪和跨度 ID 是否按预期添加变得更加容易。此更改通过以下行应用:

 se.magnus: INFO 

对于 product-composite 微服务,由于相同的原因,HttpWebHandlerAdapter 类的日志级别已从 TRACE 更改为 INFO

org.springframework.web.server.adapter.HttpWebHandlerAdapter: INFO 

将 Zipkin 添加到 Docker Compose 文件中

要将 Zipkin 服务器作为 Docker 容器运行,我们将使用 Zipkin 项目发布的 Docker 镜像 openzipkin/zipkin。有关详细信息,请参阅 hub.docker.com/r/openzipkin/zipkin。Zipkin 服务器的定义如下:

 zipkin:
    image: openzipkin/zipkin:2.24.0
    restart: always
    mem_limit: 1024m
    environment:
      - STORAGE_TYPE=mem
    ports:
      - 9411:9411 

让我们解释一下前面的源代码:

  • Docker 镜像 openzipkin/zipkin 的版本指定为 2.24.0。

  • 使用 STORAGE_TYPE=mem 环境变量来指定 Zipkin 将在内存中保留所有跟踪信息。

  • Zipkin 的内存限制增加到 1,024 MB,而所有其他容器的内存限制为 512 MB。原因是 Zipkin 被配置为在内存中保留所有跟踪信息,因此一段时间后它将比其他容器消耗更多的内存。

  • 如果 Zipkin 碰巧耗尽内存并停止,我们已经应用了一个重启策略,要求 Docker 引擎始终重启容器。这既适用于容器本身崩溃的情况,也适用于 Docker 引擎重启的情况。

  • Zipkin 将 HTTP 端口 9411 暴露给网络浏览器,以便访问其网络用户界面。

虽然在开发和测试活动中将跟踪信息存储在 Zipkin 的内存中是可以的,但 Zipkin 应该配置为在生产环境中将跟踪信息存储在数据库中。

添加对缺乏反应式客户端支持的解决方案

如上所述,当前版本的 Spring Boot、Project Reactor 和 Micrometer Tracing 还未完全协同工作。因此,已经对反应式客户端的源代码应用了一些解决方案。也就是说,四个微服务和网关。问题主要与在反应式异步处理中涉及的不同线程之间传播跟踪上下文(例如跟踪和跨度 ID)的复杂性有关,特别是如果处理的部分涉及命令式同步处理。

如果一个请求的所有处理都使用同步实现,即使用同一个线程进行所有处理,那么传播跟踪上下文不是问题。可以使用 ThreadLocal 变量来存储跟踪上下文。由于所有代码都在同一个线程中运行,因此可以在实现中的任何地方从 ThreadLocal 变量中检索跟踪上下文。

这种反应式和命令式处理混合的例子是 review 微服务的实现,其中对底层 SQL 数据库的调用是同步进行的。

如果你想更详细地了解挑战,请参阅 Spring 团队发布的“统一连接反应式和非反应式”三篇博客系列。它可以在以下链接找到:spring.io/blog/2023/03/30/context-propagation-with-project-reactor-3-unified-bridging-between-reactive

值得注意的是,Micrometer Tracing 的前身 Spring Cloud Sleuth 比当前版本的 Spring Boot、Project Reactor 和 Micrometer Tracing 对反应式客户端的支持更好。希望即将推出的版本将很快解决这些不足。

如果你有一个已经使用 Spring Cloud Sleuth 的代码库,你可以在以下链接找到迁移指南:github.com/micrometer-metrics/tracing/wiki/Spring-Cloud-Sleuth-3.1-Migration-Guide

为了解决上下文传播的许多挑战,我们可以在反应式客户端的main()方法中调用Hooks.enableAutomaticContextPropagation()方法来开启自动上下文传播。有关详细信息,请参阅上述提到的“统一连接反应式和非反应式”博客系列。对于product-composite服务,它看起来是这样的:

public static void main(String[] args) {
  Hooks.enableAutomaticContextPropagation();
  SpringApplication.run(ProductCompositeServiceApplication.class, args);
} 

然而,对于product-composite服务,仍然存在一个问题。为了确保WebClient实例被正确地用于观察,例如,能够将当前的跟踪和跨度 ID 作为出站请求的头部传播,期望使用自动装配将WebClient.Builder实例注入。不幸的是,当使用 Eureka 进行服务发现时,建议将WebClient.Builder实例创建为一个带有@LoadBalanced注解的 bean,如下所示:

 @Bean
  @LoadBalanced
  public WebClient.Builder loadBalancedWebClientBuilder() {
    return WebClient.builder();
  } 

因此,在使用 Eureka 和 Micrometer Tracing 时创建WebClient实例的方式存在冲突。为了解决这个冲突,可以将@LoadBalanced bean 替换为一个负载均衡器感知的交换过滤器函数ReactorLoadBalancerExchangeFilterFunction。可以在自动装配的WebClient.Builder实例上设置交换过滤器函数,如下所示:

 @Autowired
  private ReactorLoadBalancerExchangeFilterFunction lbFunction;
  @Bean
  public WebClient webClient(WebClient.Builder builder) {
    return builder.filter(lbFunction).build();
  } 

这意味着应用程序类ProductCompositeServiceApplication注册了一个WebClient bean 而不是WebClient.Builder bean。这影响了ProductCompositeIntegration类;现在它需要自动装配一个WebClient bean 而不是WebClient.Builder bean。

要访问ReactorLoadBalancerExchangeFilterFunction函数,需要在构建文件build.gradle中添加对org.springframework.cloud:spring-cloud-starter-loadbalancer的依赖。

向现有跨度添加自定义跨度以及自定义标签

除了依赖于内置的创建跟踪和跨度的支持,我们还可以使用 Micrometer Observability 提供的 Observation API,例如,添加我们自己的跨度或向由微服务创建的现有跨度添加自定义标签。Observation API 是在具有相同名称的 Java 接口后面实现的。

让我们先看看如何添加自定义跨度,然后看看我们如何可以向由微服务创建的所有跨度添加自定义标签。

要使用 Observation API 进行任何观察,观察对象需要在一个 ObservationRegistry bean 中注册。可以使用自动装配,如以下示例所示:

@Component
public class MyComponent {
  private final ObservationRegistry registry;
  public MyComponent(ObservationRegistry registry) {
    this.registry = registry;
  } 

添加自定义跨度

要添加自定义的跨度,Observation 接口提供了一个静态方法 createNotStarted(),可以用来创建跨度。要执行跨度,可以使用 observe() 方法提供跨度应覆盖的代码。如果代码不返回任何值,它可以指定为一个 Runnable 函数;否则,需要指定为一个 Supplier 函数。

创建用于执行最小化 Supplier 函数的自定义跨度的示例如下:

 int y = Observation.createNotStarted("my observation", registry)
      .observe(() -> {
        int x = 1;
        LOG.info("Will return {}", x);
        return 1;
      });
    LOG.info("Got {}", y); 

registry 参数是一个类型为 ObservationRegistry 的 bean,并且如上所述自动装配。

执行此代码将产生如下日志输出:

2023-04-22T14:02:07.417Z  INFO [product-composite,9761b2b2b2da59c5096e78252c48ab3d,d8bcbd9cde9fe2d7] 1 --- [     parallel-6] s.m.m.c.p.s.ProductCompositeServiceImpl  : Will return 1
2023-04-22T14:02:07.417Z  INFO [product-composite,9761b2b2b2da59c5096e78252c48ab3d,4c8ea2820fb74ec9] 1 --- [     parallel-6] s.m.m.c.p.s.ProductCompositeServiceImpl  : Got 1 

从日志输出中,我们可以看到这两个日志语句都引用了相同的跟踪 ID,9761b2b2b2da59c5096e78252c48ab3d,但指定了不同的跨度 ID,d8bcbd9cde9fe2d7 是自定义跨度的跨度 ID!

如果我们想添加有关跨度的元数据,我们可以通过添加上下文名称和一组键值对形式的标签来指定。上下文名称将是跨度的名称,并且可以在 Zipkin 中可视化跟踪树时用来识别跨度。如果信息的可能值是有限的,限制在有限数量的替代方案中,则应使用 lowCardinalityKeyValue() 方法指定标签。对于无界值,应使用 highCardinalityKeyValue() 方法。调用这些方法将在当前跨度中添加标签,而低基数值也将被添加到由观察创建的指标中。

我们将研究如何在第二十章,监控微服务中,使用指标。

将信息指定为标签的示例如下:

 int y = Observation.createNotStarted("my observation", registry)
      .contextualName("product info")
      .lowCardinalityKeyValue("template-url", "/product-composite/{productId}")
      .highCardinalityKeyValue("actual-url", "/product-composite/12345678")
      .observe(() -> {
        int x = 1;
        LOG.info("Will return {}", x);
        return x;
      });
    LOG.info("Got {}", y); 

从上面的示例中,我们可以看到:

  • 上下文名称设置为 "product info"

  • 使用 lowCardinalityKeyValue() 方法指定了一个只有几个可能值的键,"template-url"。在我们的案例中,它为创建、获取和删除方法提供了三个可能的值。

  • 使用 highCardinalityKeyValue() 方法指定了一个键 "actual-url",其值数量无限,取决于指定的 productId

让我们将此应用于 product-composite 服务,创建一个提供 productId 作为标签的自定义跨度。由于产品 ID 的数量是无限的,我们将使用 highCardinalityKeyValue() 方法指定它。我们将创建一个指定每个三个 API 方法(创建、检索和删除复合产品)的当前 productId 的跨度。包含一个高基数标签的自定义跨度的创建由实用类 ObservationUtil 处理。实用类位于包 se.magnus.microservices.composite.product.services.tracing 中。该类中的实用方法 observe() 看起来是这样的:

public <T> T observe(String observationName, String contextualName, String highCardinalityKey, String highCardinalityValue, Supplier<T> supplier) {
  return Observation.createNotStarted(observationName, registry)
    .contextualName(contextualName)
    .highCardinalityKeyValue(highCardinalityKey, highCardinalityValue)
    .observe(supplier);
} 

observe() 方法包装了对 Observation.createNotStarted() 方法的调用。该方法的用法在上面的示例中已解释,因此无需进一步解释。

这个实用方法由 ProductCompositeServiceImpl 类中的辅助方法 observationWithProductInfo() 使用,该方法将常用值应用于 ProductCompositeServiceImpl 类:

private <T> T observationWithProductInfo(int productInfo, Supplier<T> supplier) {
  return observationUtil.observe(
    "composite observation",
    "product info",
    "productId",
    String.valueOf(productInfo),
    supplier);
} 

最后,辅助方法被三个 API 方法 createProduct()getProduct()deleteProduct() 使用。自定义跨度是通过在每个方法中包装现有代码创建的。现有代码已被移动到相应的“内部”方法以简化解决方案的结构。“内部”方法由 observationWithProductInfo() 方法调用。对于 getProduct(),实现现在看起来是这样的:

 public Mono<ProductAggregate> getProduct(int productId, ...) {
    return observationWithProductInfo(productId,
      () -> getProductInternal(productId, ...));
  }
  private Mono<ProductAggregate> getProductInternal(int productId, ...) {
    return observationWithProductInfo(productId, () -> {
      LOG.info("Will get composite product info for product.id={}", productId);
      return Mono.zip(
          values -> createProductAggregate(...
          integration.getProduct(productId, ...),
          integration.getRecommendations(productId).collectList(),
          integration.getReviews(productId).collectList())
          ...);
  } 

如果与第十三章中的相应实现进行比较,我们可以看到创建自定义跨度所需的变化仅限于添加一个新的“内部”方法,并从 observationWithProductInfo() 方法调用它:

 public Mono<ProductAggregate> getProduct(int productId, ...) {
    LOG.info("Will get composite product info for product.id={}", productId);
    return Mono.zip(
      values -> createProductAggregate(...
      integration.getProduct(productId, ...),
      integration.getRecommendations(productId).collectList(),
      integration.getReviews(productId).collectList())
      ...);
  } 

因此,通过提供一个适当的实用方法来处理设置自定义跨度的细节,我们可以通过非常小的更改将自定义跨度添加到现有代码中。当我们在本章后面尝试分布式跟踪时,我们将看到这个自定义跨度的实际应用。有了自定义跨度,让我们看看我们如何向微服务中创建的任何跨度添加自定义标签!

向现有跨度添加自定义标签

如果我们想向由微服务创建的所有跨度添加一些自定义信息,我们可以使用 ObservationFilter。它需要使用 ObservationRegistryCustomizer Bean 在 ObservationRegistry Bean 中注册。

让我们应用一个过滤器,将 product-composite 微服务的当前版本注册为标签,应用于它创建的每个跨度。我们需要做以下事情:

  • 更新构建文件,以便 Gradle 创建构建信息,包括在 build.gradle 文件中由 version 属性指定的当前版本。

  • 创建一个过滤器,将当前版本作为低基数标签添加到所有跨度中。

  • 创建一个注册配置 Bean,用于注册该过滤器。

要使 Gradle 创建构建信息,以下内容被添加到构建文件 build.gradle 中:

springBoot {
    buildInfo()
} 

当执行./gradlew build命令时,这将导致创建文件build/resources/main/META-INF/build-info.properties。此文件将指定当前版本为:

build.version=1.0.0-SNAPSHOT 

构建信息文件将被捆绑到微服务的 JAR 文件中,其信息可以通过BuildProperties Bean 访问。

过滤器看起来像这样:

public class BuildInfoObservationFilter implements ObservationFilter {
  private final BuildProperties buildProperties;
  public BuildInfoObservationFilter(BuildProperties buildProperties) {
    this.buildProperties = buildProperties;
  }
  @Override
  public Observation.Context map(final Observation.Context context) {
    KeyValue buildVersion = KeyValue.of("build.version", buildProperties.getVersion());
    return context.addLowCardinalityKeyValue(buildVersion);
  }
} 

从上面的源代码中,我们可以看到:

  • 一个BuildProperties Bean 被注入到过滤器的构造函数中。

  • 过滤器的map()方法从BuildProperties Bean 中检索微服务版本,并将其设置为提供的观察上下文上的低基数标签。

注册配置 Bean 看起来像这样:

@Configuration(proxyBeanMethods = false)
public class ObservationRegistryConfig implements ObservationRegistryCustomizer<ObservationRegistry> {
  private final BuildProperties buildProperties;
  public ObservationRegistryConfig(BuildProperties buildProperties) {
    this.buildProperties = buildProperties;
  }
  @Override
  public void customize(final ObservationRegistry registry) {
    registry.observationConfig().observationFilter(new BuildInfoObservationFilter(buildProperties));
  }
} 

从上面的源代码中,我们可以了解到:

  • BuildProperties Bean 也被注入到配置类的构造函数中。

  • customize()方法中,创建并注册了过滤器。过滤器还在这里注入了BuildProperties Bean。

过滤器和注册配置 Bean 可以在se.magnus.microservices.composite.product.services.tracing包中找到。当我们在本章后面尝试分布式跟踪时,我们将看到这个观察过滤器的作用。

关于处理自定义跨度更多的方式,例如,设置何时应用观察过滤器的谓词或使用注解来描述观察,请参阅micrometer.io/docs/observation

这就是使用 Micrometer Tracing 和 Zipkin 添加分布式跟踪所需的所有步骤,所以让我们在下一节中尝试一下!

尝试分布式跟踪

在源代码中进行必要的更改后,我们可以尝试分布式跟踪。我们将通过以下步骤来完成:

  1. 构建、启动和验证系统景观。

  2. 发送一个成功的 API 请求并查看在 Zipkin 中与该 API 请求相关的跟踪信息。

  3. 发送一个失败的 API 请求并查看我们可以找到的错误信息。

  4. 发送一个触发异步处理的成功 API 请求并查看其跟踪信息是如何表示的。

我们将在接下来的章节中详细讨论这些步骤。

启动系统景观

让我们启动系统景观。使用以下命令构建 Docker 镜像:

cd $BOOK_HOME/Chapter14
./gradlew build && docker-compose build 

在 Docker 中启动系统景观并使用以下命令运行常规测试:

./test-em-all.bash start 

在我们能够调用 API 之前,我们需要一个访问令牌。运行以下命令以获取访问令牌:

unset ACCESS_TOKEN
ACCESS_TOKEN=$(curl -k https://writer:secret-writer@localhost:8443/oauth2/token -d grant_type=client_credentials -d scope="product:read product:write" -s | jq -r .access_token)
echo $ACCESS_TOKEN 

如前几章所述,授权服务器颁发的访问令牌有效期为一个小时。因此,如果你过一会儿开始收到401 未授权错误,可能就是时候获取一个新的访问令牌了。

发送一个成功的 API 请求

现在,我们已经准备好向 API 发送正常请求。运行以下命令:

curl -H "Authorization: Bearer $ACCESS_TOKEN" -k https://localhost:8443/product-composite/1 -w "%{http_code}\n" -o /dev/null -s 

期望命令返回成功的 HTTP 状态码200

我们现在可以启动 Zipkin UI 来查看已发送到 Zipkin 的跟踪信息:

  1. 在您的网络浏览器中打开以下 URL:http://localhost:9411/zipkin/

  2. 要找到我们请求的跟踪信息,我们可以搜索通过gateway服务经过的跟踪。执行以下步骤:

    1. 点击大加号(红色背景上的白色+号)并选择serviceName然后gateway

    2. 点击RUN****QUERY按钮。

    3. 点击开始时间标题以按最新顺序查看结果(开始时间标题左侧应可见向下箭头)。

查找跟踪的响应应类似于以下截图:

计算机截图描述自动生成

图 14.2:使用 Zipkin 搜索分布式跟踪

  1. 我们先前 API 请求的跟踪信息是列表中的第一个。点击其SHOW按钮以查看与跟踪相关的详细信息:

图形用户界面,应用程序描述自动生成

图 14.3:在 Zipkin 中可视化的示例分布式跟踪

在详细的跟踪信息视图中,我们可以观察到以下内容:

  1. 请求被gateway服务接收。

  2. gateway服务将请求的处理委托给了product-composite服务。

  3. product-composite服务反过来向核心服务发送了三个并行请求:productrecommendationreview。查看名为product-composite: http get的跨度。

  4. 一旦product-composite服务从所有三个核心服务收到响应,它就创建了一个组合响应并通过gateway服务将其发送回调用者。

  5. 在上一节中创建的自定义跨度名为product-composite: product info。点击它以查看其标签。在右侧的详细信息视图中,我们可以看到由自定义跨度创建的标签productId = 1和由观察过滤器创建的标签build.version = 1.0.0-SNAPSHOT

  6. 为了验证由观察过滤器创建的标签是否按预期工作,点击由product-composite服务创建的其他跨度,并验证build.version是否存在。

  7. 选择名为review: query的跨度以查看由review微服务的数据库层报告的跨度:

图形用户界面描述自动生成

图 14.4:描述 SQL 查询执行的跨度

  1. 在跨度的标签列表中,我们可以看到其实际执行的 SQL 查询。我们还可以看到其执行时间为 0.8 毫秒。非常有价值的信息!

为了更好地理解跟踪和跨度 ID 在微服务之间是如何传播的,我们可以更改product-composite服务的日志配置,以便将出站请求的 HTTP 头写入其日志。这可以通过以下步骤实现:

  1. 将以下两行添加到配置文件config-repo/product-composite.yml中:

    spring.codec.log-request-details: true
    logging.level.org.springframework.web.reactive.function.client.ExchangeFunctions: TRACE 
    
  2. 这两行已经存在于配置文件中,但已被注释掉。它们前面有以下注释:

    # To see tracing headers, uncomment the following two lines and restart the product-composite service 
    
  3. 在配置文件中找到前面的注释,并取消注释其下方的两行。

  4. 之后,重新启动product-composite服务:

    docker-compose restart product-composite 
    
  5. 显示product-composite服务的日志输出:

    docker-compose log -f --tail 0 product-composite 
    
  6. 重新运行上述curl请求,你将看到包含上述提到的traceparent HTTP 头部的日志输出。例如,发送到recommendation服务的请求:

    chapter14-product-composite-1  | 2023-04-23T09:24:50.849Z TRACE [product-composite,e1420dcc38901378e888b8ce7022510e,06867b65cf84b552] 1 --- [     parallel-2] o.s.w.r.f.client.ExchangeFunctions       : [14606b71] HTTP GET http://d40874197b77:8080/recommendation?productId=1, headers=[traceparent:"00-e1420dcc38901378e888b8ce7022510e-06867b65cf84b552-01"] 
    
  7. 在示例日志输出中,我们可以看到traceparent HTTP 头部的值,其中跟踪 ID 设置为e1420dcc38901378e888b8ce7022510e,而跨度 ID 设置为06867b65cf84b552

  8. 如果你不想保留traceparent HTTP 头部的日志记录,请在config-repo/product-composite.yml中注释掉两行,并重新启动product-composite服务。

发送一个不成功的 API 请求

让我们看看如果我们发起一个失败的 API 请求,跟踪信息会是什么样子;例如,搜索一个导致超时的产品:

  1. 发送一个针对产品 ID 1的 API 请求,并强制延迟三秒,这将触发时间限制器,并验证它返回的 HTTP 状态码为500

    curl -H "Authorization: Bearer $ACCESS_TOKEN" -k https://localhost:8443/product-composite/1?delay=3 -w "%{http_code}\n" -o /dev/null -s 
    
  2. 在 Zipkin UI 中,返回到搜索页面(使用网络浏览器的后退按钮),然后再次点击RUN QUERY按钮。要按最新顺序查看结果,请点击Start Time标题。

预期结果将与以下截图类似:

计算机截图  自动生成的描述

图 14.5:使用 Zipkin 查找失败的请求

  1. 你应该会在返回列表的顶部看到失败的请求。注意,其持续时间条是红色的,表示发生了错误。点击其SHOW按钮查看详细信息:

计算机错误截图  自动生成的描述

图 14.6:使用 Zipkin 查看失败的请求的跟踪

在这里,我们可以看到一个带有错误符号的跨度,product-composite: secured request(一个带有感叹号的红色圆圈)。

  1. 点击跨度以查看其标签。你将找到一个名为error的标签,它清楚地表明错误是由两秒后发生的超时引起的。

发送一个触发异步处理的 API 请求

在 Zipkin UI 中,有趣的是看到第三种请求类型,这种请求的部分处理是异步完成的。让我们尝试一个删除请求,其中核心服务的删除过程是异步完成的。

product-composite服务通过消息代理向三个核心服务的每个服务发送删除事件,每个核心服务都会接收到删除事件并异步处理它。多亏了 Micrometer Tracing,跟踪信息被添加到发送到消息代理的事件中,从而形成了对删除请求总处理的连贯视图。

执行以下步骤:

  1. 运行以下命令以删除产品 ID 为12345的产品,并验证它返回请求被接受的 HTTP 状态码202

    curl -X DELETE -H "Authorization: Bearer $ACCESS_TOKEN" -k https://localhost:8443/product-composite/12345 -w "%{http_code}\n" -o /dev/null -s 
    

记住,删除操作是幂等的,也就是说,即使产品不存在,它也会成功!

  1. 在 Zipkin UI 中,返回搜索页面(使用网络浏览器的后退按钮)并再次点击运行查询按钮。要按最新顺序查看结果,请点击开始时间标题。预期结果如下截图所示:

计算机屏幕截图  自动生成描述

图 14.7:使用 Zipkin 查找删除请求

  1. 您应该在返回列表的顶部看到删除请求。请注意,根服务名称gateway由使用的 HTTP 方法delete后缀。点击其显示按钮以查看详细信息:

计算机屏幕截图  自动生成描述

图 14.8:使用 Zipkin 查看删除请求

在这里,我们可以看到处理删除请求的跟踪信息:

  1. 请求被gateway服务接收。

  2. gateway服务将请求的处理委托给了product-composite服务。

  3. 如预期,product-composite服务创建了一个名为product-composite: product info的自定义 span。

  4. 然后,product-composite服务在消息代理(在本例中为 RabbitMQ)上发布了三个事件。查看以send结尾的 span。

  5. product-composite服务现在已完成,并通过gateway服务返回给调用者一个 HTTP 成功状态码,200。请注意,这是在核心服务完成所有处理之前完成的!

  6. 核心服务(productrecommendationreview)接收delete事件并开始异步处理它们,即相互独立。查看以receive结尾的 span。

  7. 要确认消息代理的参与,请点击第一个产品 span:计算机屏幕截图  自动生成描述

    图 14.9:使用 Zipkin 查看事件异步处理的信息

    选定的 span 包含一个名为peer.service的标签,揭示了 RabbitMQ 的使用,而标签spring.rabbit.listener.id指出消息由消费者组productsGroup接收。

  8. 最后,为了在通过 RabbitMQ 发送的消息中查看traceparent头,我们可以检查存储在产品审计队列中的消息。在您的网页浏览器中打开localhost:15672/#/queues/%2F/products.auditGroup

  9. 点击名为Get Messages(s)的按钮以查看队列中最旧的消息。网页应如下所示:

图形用户界面,文本,应用程序  自动生成的描述

图 14.10:RabbitMQ 中的带有 traceparent 头的消息

  1. 属性部分,您将找到此消息的traceparent头。在这种情况下,追踪 ID 是99a9f2501e4d454643184c6b1cb0a232,跨度 ID 是7458430fe56d6df1

这完成了本章的分布式追踪测试!

Zipkin UI 包含更多用于查找感兴趣追踪的功能!

为了更熟悉 Zipkin UI,通过点击加号并选择tagQuery来尝试查询功能。例如,为了找到在403 - Forbidden错误上失败的请求,将其值设置为tagQuery=http.status_code=403,搜索在禁止(403)错误上失败的追踪。还可以尝试通过点击RUN QUERY按钮右侧的齿轮图标来设置回溯范围(开始和结束时间)和最大命中数限制。

通过关闭系统环境来总结测试。运行以下命令:

docker-compose down 

摘要

在本章中,我们学习了如何使用分布式追踪来理解我们的微服务是如何协作的。我们学习了如何使用 Micrometer Tracing 来收集追踪信息,以及使用 Zipkin 来存储和可视化追踪信息。

我们看到了如何通过在构建文件中添加几个依赖项并设置一些配置参数来将 Micrometer Tracing 添加到微服务中。目前,反应式微服务尚未完全支持,但我们学习了如何解决最重要的问题。如果需要,我们可以使用Observation API 来创建自定义跨度或为微服务创建的所有跨度添加标签。我们还看到了 Zipkin UI 如何使识别复杂工作流中导致意外长响应时间或错误的特定部分变得非常容易。Zipkin UI 可以可视化同步和异步工作流。

在下一章中,我们将学习容器编排器,特别是 Kubernetes。我们将学习如何使用 Kubernetes 来部署和管理微服务,同时提高重要的运行时特性,如可伸缩性、高可用性和弹性。

问题

  1. management.tracing.sampling.probability配置参数的目的是什么?

  2. 如何在执行test-em-all.bash测试脚本后识别运行时间最长的请求?

  3. 我们如何找到被第十三章使用 Resilience4j 提高弹性中引入的超时中断的请求?

  4. 第十三章使用 Resilience4j 提高弹性中引入的断路器打开时,API 请求的跟踪看起来是什么样子?

  5. 我们如何定位那些在调用者未被授权执行请求时失败的 API?

  6. 我们如何以编程方式添加跟踪信息?

第十五章:Kubernetes 简介

在本章中,我们将开始学习 Kubernetes,这是在撰写本书时最受欢迎和最广泛使用的容器编排器。由于容器编排器的一般主题和 Kubernetes 本身的内容太多,无法在一章中涵盖,因此我将专注于介绍我在过去几年使用 Kubernetes 时认为最重要的领域。

本章将涵盖以下主题:

  • Kubernetes 概念介绍

  • Kubernetes API 对象介绍

  • Kubernetes 运行时组件介绍

  • 创建本地 Kubernetes 集群

  • 尝试一个示例部署并熟悉 kubectl Kubernetes 命令行界面工具

  • 管理本地 Kubernetes 集群

技术要求

关于如何安装本书中使用的工具以及如何访问本书源代码的说明,请参阅:

  • 第二十一章macOS 安装说明

  • 第二十二章使用 WSL 2 和 Ubuntu 在 Microsoft Windows 上的安装说明

本章中的代码示例均来自 $BOOK_HOME/Chapter15 目录下的源代码。本章将要执行的示例部署在 Kubernetes 上的源代码可以在 $BOOK_HOME/Chapter15/kubernetes/first-attempts 文件夹中找到。

Kubernetes 概念介绍

从高层次来看,作为容器编排器,Kubernetes 使得一组(物理或虚拟)运行容器的服务器看起来像是一个运行容器的单个大逻辑服务器。

作为操作员,我们通过使用 Kubernetes API 创建对象来向 Kubernetes 集群声明一个期望状态。Kubernetes 会持续比较期望状态和当前状态。如果检测到差异,它会采取措施确保当前状态与期望状态相同。

Kubernetes 集群的主要目的之一是部署和运行容器,但同时也支持使用绿色/蓝色和金丝雀部署等技术进行零停机滚动升级。Kubernetes 可以调度容器,即包含一个或多个相邻容器的Pod,到集群中的可用节点。为了能够监控运行容器的健康状态,Kubernetes 假设容器实现了存活探针。如果存活探针报告容器不健康,Kubernetes 将重启该容器。可以在集群中手动或自动使用水平自动扩展来扩展容器。为了优化集群中可用硬件资源(例如内存和 CPU)的使用,可以将具有指定容器所需资源数量的配额配置到容器中。另一方面,可以在命名空间级别为容器或一组 Pod 指定允许消耗的最大量。随着本章的进行,我们将介绍命名空间。如果多个团队共享一个共同的 Kubernetes 集群,这一点尤为重要。

Kubernetes 的另一个主要目的是提供正在运行的 Pod 和它们的容器的服务发现。可以为服务发现定义 Kubernetes 服务对象,并且它还会在可用的 Pod 上进行请求的负载均衡。服务对象可以被暴露在 Kubernetes 集群外部。然而,正如我们将看到的,入口对象在很多情况下更适合处理指向一组服务的外部传入流量。为了帮助 Kubernetes 确定容器是否准备好接受传入请求,容器可以实现 就绪探测

在内部,Kubernetes 集群提供一个大型的扁平 IP 网络,其中每个 Pod 都有自己的 IP 地址,并且可以到达所有其他 Pod,无论它们运行在哪个节点上。为了支持多个网络供应商,Kubernetes 允许使用符合 容器网络接口CNI)规范(github.com/containernetworking/cni)的网络插件。默认情况下,Pod 不会被隔离;它们接受所有传入的请求。支持使用网络策略定义的 CNI 插件可以用来锁定对 Pod 的访问,例如,仅允许来自同一命名空间内 Pod 的流量。

为了允许多个团队以安全的方式在同一个 Kubernetes 集群上工作,可以应用 基于角色的访问控制RBACkubernetes.io/docs/reference/access-authn-authz/rbac/)。例如,管理员可以授权访问集群级别的资源,而团队成员的访问可以限制到由团队拥有的命名空间中创建的资源。

总的来说,这些概念提供了一个可扩展、安全、高可用性和弹性的容器运行平台。

让我们进一步了解 Kubernetes 中可用的 API 对象,以及构成 Kubernetes 集群的运行时组件。

介绍 Kubernetes API 对象

Kubernetes 定义了一个 API,用于管理不同类型的 对象资源,正如它们所知。API 中提到的最常用的类型,或称为 种类,如下所示:

  • 节点:节点代表集群中的一个服务器,无论是虚拟的还是物理的。

  • Pod:Pod 是 Kubernetes 中最小的可部署组件,由一个或多个并置的容器组成。这些容器共享相同的 IP 地址和端口范围。这意味着同一 Pod 实例中的容器可以通过 localhost 相互通信,但需要注意潜在的端口冲突。通常,Pod 由一个容器组成,但也有一些用例,可以通过在 Pod 中运行第二个容器来扩展主容器的功能。在第十八章使用服务网格来提高可观察性和管理中,Pod 中将使用第二个容器,运行一个边车容器,使主容器加入服务网格。

  • Deployment:Deployment 用于部署和升级 Pod。Deployment 对象将创建和监控 Pod 的责任交给 ReplicaSet。当首次创建 Deployment 时,Deployment 对象执行的工作并不比创建 ReplicaSet 对象的工作多多少。当执行 Deployment 的滚动升级时,Deployment 对象的角色更为复杂。

  • ReplicaSet:ReplicaSet 用于确保始终运行指定数量的 Pod。如果一个 Pod 被删除,ReplicaSet 将用一个新的 Pod 来替换它。

  • Service:Service 是一个稳定的网络端点,您可以使用它来连接到一个或多个 Pod。Service 在 Kubernetes 集群的内部网络中被分配一个 IP 地址和 DNS 名称。Service 的 IP 地址在其生命周期内保持不变。发送到 Service 的请求将通过基于轮询的负载均衡转发到可用的 Pod 之一。默认情况下,Service 仅通过集群 IP 地址在集群内部暴露。也有可能将 Service 暴露在集群外部,要么在集群中每个节点的专用端口上,要么——更好的是——通过一个了解 Kubernetes 的外部负载均衡器;也就是说,它可以自动为 Service 分配一个公共 IP 地址和/或 DNS 名称。通常,提供 Kubernetes 作为服务的云提供商支持这种类型的负载均衡器。

  • Ingress:Ingress 可以管理 Kubernetes 集群中服务的对外访问,通常使用 HTTP 或 HTTPS。例如,它可以根据 URL 路径或 HTTP 头(如主机名)将流量路由到底层的服务。通常,不是通过使用节点端口或通过负载均衡器公开多个服务,而是在服务前面设置一个 Ingress 更为方便。为了处理 Ingress 对象定义的实际通信,集群中必须运行一个 Ingress 控制器。随着我们的进展,我们将看到一个 Ingress 控制器的示例。

  • Namespace:Namespace 用于在 Kubernetes 集群中对资源进行分组和在某种程度上进行隔离。资源名称必须在它们的命名空间中是唯一的,但不同命名空间之间不必唯一。

  • ConfigMap:ConfigMap 用于存储容器使用的配置。ConfigMaps 可以映射到正在运行的容器中作为环境变量或文件。

  • Secret:用于存储容器使用的敏感数据,例如凭证。Secrets 可以通过与 ConfigMaps 相同的方式提供给容器。任何对 API 服务器具有完全读取访问权限的人都可以访问创建的 Secrets 的值,因此它们并不像名字所暗示的那样安全。

  • DaemonSet:确保在集群中的一组节点上运行一个 Pod。在第十九章使用 EFK 堆栈进行集中式日志记录中,我们将看到一个日志收集器 Fluentd 的示例,它将以 DaemonSet 的形式在每个工作节点上运行。

要查看 Kubernetes API 在 v1.26 中涵盖的资源对象的完整列表,请参阅kubernetes.io/docs/reference/generated/kubernetes-api/v1.26/

下面的图总结了处理传入请求所涉及的 Kubernetes 资源:

图描述自动生成

图 15.1:Kubernetes 资源概述

在前面的图中,我们可以看到以下内容:

  • 两个部署,部署 A部署 B,已部署到具有两个节点节点 1节点 2的集群中

  • 部署 A包含两个 Pod,Pod A1Pod A2

  • 部署 B包含一个 Pod,Pod B1

  • Pod A1被调度到节点 1

  • Pod A2Pod B1被调度到节点 2

  • 每个部署都有一个相应的服务部署,服务 A服务 B,并且它们在所有节点上都是可用的

  • 定义了一个入口来路由传入请求到两个服务

  • 客户端通常通过外部负载均衡器向集群发送请求

这些对象本身不是运行组件;相反,它们是不同类型期望状态的定义。为了在集群的当前状态下反映期望状态,Kubernetes 提供了一套由多个运行时组件组成的架构,如下一节所述。

介绍 Kubernetes 运行时组件

Kubernetes 集群包含两种类型的节点:主节点工作节点。主节点管理集群,而工作节点的主要目的是运行实际的工作负载,例如,我们在集群中部署的容器。Kubernetes 由多个运行时组件组成。最重要的组件如下:

  • 有一些组件运行在主节点上,构成了控制平面

    • API 服务器,控制平面的入口点。它公开了一个 RESTful API,例如,Kubernetes CLI 工具kubectl就是使用这个 API。

    • etcd,一个高度可用和分布式的键值存储,用作所有集群数据的数据库。

    • 一个 控制器管理器,其中包含多个控制器,它们会持续评估 etcd 数据库中定义的对象的期望状态与当前状态。每当期望状态或当前状态发生变化时,负责该类型状态的控制器会采取行动,将当前状态移动到期望状态。例如,负责管理 Pods 的复制控制器会在通过 API 服务器添加新 Pod 或正在运行的 Pod 被删除时做出反应,并确保启动新的 Pods。另一个控制器的例子是节点控制器。它负责在节点不可用时采取行动,确保在集群中的其他节点上重新调度失败节点上运行的 Pods。

    • 一个 调度器,负责将新创建的 Pods 分配到具有可用容量的节点上,例如,从内存和 CPU 的角度来看。可以使用 亲和性规则 来控制 Pods 分配到节点的方式。例如,执行大量磁盘 I/O 操作的 Pods 可以分配到具有快速 SSD 硬盘的一组工作节点。可以定义 反亲和性规则 来分离 Pods,例如,避免将来自同一 Deployment 的 Pods 分配到同一工作节点。

  • 在所有节点上运行的组件,构成 数据平面

    • kubelet,一个节点代理,它作为节点操作系统中直接运行的过程,而不是作为容器。kubelet 确保分配给其节点的 Pods 的容器处于运行状态,并且它们是健康的。它在其节点上充当 API 服务器和容器运行时之间的通道。

    • kube-proxy,一个网络代理,它使 Kubernetes 中的 Service 概念成为可能,并且能够将请求转发到适当的 Pods,如果有多个 Pods 可用于特定的 Service,通常以轮询的方式。kube-proxy 作为 DaemonSet 部署。

    • 容器运行时,这是在节点上运行容器的软件。历史上,Kubernetes 使用 Docker Engine,但今天可以使用任何 Kubernetes 容器运行时接口CRI)的实现,例如 cri-o (cri-o.io) 和 containerd (containerd.io/)。在 Kubernetes v1.24 中移除了对 Docker Engine 的支持。

    containerd 实际上是 Docker 的容器引擎。它在 2017 年从 Docker 中分离出来,如今已成为一个毕业的 CNCF 项目。

    • Kubernetes DNS,这是一个在集群内部网络中使用的 DNS 服务器。服务和 Pods 被分配一个 DNS 名称,并且 Pods 被配置为使用此 DNS 服务器解析内部 DNS 名称。DNS 服务器作为 Deployment 对象和 Service 对象部署。

下图总结了上述描述的 Kubernetes 运行时组件:

图描述自动生成

图 15.2:Kubernetes 运行时组件概述

根据图示,我们可以想象以下事件序列:

  1. 操作员使用kubectl向 Kubernetes 发送一个新的期望状态,其中包含声明新DeploymentServiceIngress对象的清单。Ingress 定义了到 Service 对象的路由,而 Service 对象被定义为选择由 Deployment 对象配置的 Pods。

  2. kubectlAPI 服务器通信,并将新的期望状态作为对象存储在etcd数据库中。

  3. 各种控制器将对新对象的创建做出反应并采取以下行动:

    1. 对于 Deployment 对象:

      1. 新的ReplicaSetPod对象将在 API 服务器中注册。

      2. 调度器将看到新的 Pod(s)并将它们调度到适当的工作节点。

      3. 在每个工作节点上,kubelet代理将根据 Pods 描述启动容器。kubelet 将使用工作节点上的容器运行时来管理容器。

    2. 对于 Service 对象:

      1. 将在 Service 对象的内部 DNS 服务器中注册一个 DNS 名称,kube-proxy将能够将使用 DNS 名称的请求路由到可用的 Pod 之一。

注意,Pods 可以从集群中的任何节点访问,因此 kube-proxy 不需要在 Pod 所在的同一节点上运行,才能将请求转发到它。

  1. 对于 Ingress 对象:

    1. Ingress 控制器将根据 Ingress 对象设置路由,并准备好接受来自 Kubernetes 集群外部的请求。与 Ingress 对象定义的路由匹配的外部请求将由 Ingress 控制器转发到 Service 对象。这些请求将如上所述由 kube-proxy 转发到 Pod。

现在我们已经了解了 Kubernetes 运行时组件及其支持的内容和运行环境,让我们继续使用 Minikube 创建一个 Kubernetes 集群。

使用 Minikube 创建 Kubernetes 集群

现在,我们已准备好创建一个 Kubernetes 集群!我们将使用 Minikube 创建一个本地单节点集群。Minikube 可以使用不同的驱动程序部署在 VM、容器或裸金属上。我们将使用首选驱动程序之一,即 Docker 驱动程序,其中 Minikube 实例在 macOS 和 Windows 上的 Docker Desktop 管理的容器中运行,并使用Windows Subsystem for Linux, v2 (WSL 2)。

关于 Minikube 中可用驱动程序的更多信息,请参阅minikube.sigs.k8s.io/docs/drivers/

Docker 及其容器已经在单独的 WSL 2 实例中运行;请参阅第二十二章,安装 Microsoft Windows 的 WSL 2 和 Ubuntu 的安装说明中的安装 Docker Desktop for Windows*部分。

将 Minikube 作为容器在 Docker 上运行的一个缺点是,Minikube 暴露的端口只能在运行 Docker 的主机上访问。为了使端口对 Docker 客户端可用,例如,在 WSL 2 上我们将使用的 macOS 或 Linux 服务器,我们可以在创建 Minikube 集群时指定端口映射。

在创建 Kubernetes 集群之前,我们需要了解一下 Minikube 配置文件,这是 Kubernetes 的 CLI 工具 kubectl 的相关知识,以及它对上下文的使用。

使用 Minikube 配置文件

为了在本地运行多个 Kubernetes 集群,Minikube 提供了配置文件的概念。例如,如果您想使用多个 Kubernetes 版本,您可以使用 Minikube 创建多个 Kubernetes 集群。每个集群都将分配一个单独的 Minikube 配置文件。大多数 Minikube 命令都接受 --profile 标志(或 -p 的简称),可以用来指定命令将应用于哪个 Kubernetes 集群。如果您计划在一段时间内使用一个特定的配置文件,存在一个更方便的替代方案,其中您可以使用以下命令指定当前配置文件:

minikube profile my-profile 

此命令将 my-profile 配置文件设置为当前配置文件。

要获取当前配置文件,请运行以下命令:

minikube config get profile 

如果没有指定配置文件,无论是使用 minikube profile 命令还是 --profile 开关,都将使用默认配置文件 minikube

可以使用 minikube profile list 命令找到有关现有配置文件的信息。

使用 Kubernetes CLI,kubectl

kubectl 是 Kubernetes 的 CLI 工具。一旦集群已设置,这通常是您唯一需要的用于管理集群的工具!

对于管理 API 对象,正如我们在本章前面所描述的,kubectl apply 命令是您唯一需要了解的命令。它是一个声明性命令;也就是说,作为操作员,我们要求 Kubernetes 应用我们通过命令给出的对象定义。然后由 Kubernetes 决定实际上需要做什么。

另一个可能是许多本书读者的熟悉声明性命令的例子是 SQL SELECT 语句,它可以连接来自多个数据库表的信息。我们在 SQL 查询中只声明预期的结果,而数据库查询优化器则需要确定以何种顺序访问表以及使用哪些索引以最有效的方式检索数据。

在某些情况下,命令式语句更受欢迎,这些语句明确告诉 Kubernetes 要做什么。一个例子是 kubectl delete 命令,其中我们明确告诉 Kubernetes 删除一些 API 对象。也可以使用明确的 kubectl create namespace 命令方便地创建命名空间对象。

重复使用祈使语句会导致它们失败,例如,使用 kubectl delete 删除相同的 API 对象两次或使用 kubectl create 创建相同的命名空间两次。声明式命令,即使用 kubectl apply,不会因重复使用而失败——它将简单地声明没有变化并退出而不采取任何行动。

以下是一些用于检索 Kubernetes 集群信息的常用命令:

  • kubectl get 显示指定 API 对象的信息

  • kubectl describe 提供有关指定 API 对象的更多详细信息

  • kubectl logs 显示容器的日志输出

我们将在本章节和接下来的章节中看到许多这些以及其他 kubectl 命令的示例!

如果对如何使用 kubectl 工具有疑问,kubectl helpkubectl <command> --help 命令始终可用,并提供非常有用的信息。另一个有用的命令是 kubectl explain,它可以用来显示在声明 Kubernetes 对象时有哪些字段可用。例如,如果你需要查找描述 Deployment 对象模板中容器可用的字段,请运行以下命令:

kubectl explain deployment.spec.template.spec.containers 

使用 kubectl 上下文

要能够与多个 Kubernetes 集群一起工作,无论是使用本地的 Minikube 还是设置在本地服务器或云中的 Kubernetes 集群,kubectl 都包含 上下文 的概念。上下文是以下内容的组合:

  • 一个 Kubernetes 集群

  • 用户认证信息

  • 默认命名空间

默认情况下,上下文保存在 ~/.kube/config 文件中,但可以使用 KUBECONFIG 环境变量更改该文件。在这本书中,我们将使用默认位置,因此我们将使用 unset KUBECONFIG 命令取消设置 KUBECONFIG

当在 Minikube 中创建 Kubernetes 集群时,会创建一个与 Minikube 配置文件同名上下文,并将其设置为当前上下文。因此,在 Minikube 中创建集群后发出的 kubectl 命令将发送到该集群。

要列出可用的上下文,请运行以下命令:

kubectl config get-contexts 

以下是一个示例响应:

图形用户界面,应用程序描述自动生成

图 15.3:kubectl 上下文列表

第一列中的通配符 ***** 标记了当前上下文。

只有在集群创建完成后,你才会在前面的响应中看到 handson-spring-boot-cloud 上下文,我们将在稍后描述其创建过程。

如果你想将当前上下文切换到另一个上下文,即与另一个 Kubernetes 集群一起工作,请运行以下命令:

kubectl config use-context my-cluster 

在此示例中,当前上下文将更改为 my-cluster

要更新上下文,例如,切换 kubectl 使用的默认命名空间,请使用 kubectl config set-context 命令。

例如,要将当前上下文的默认命名空间更改为 my-namespace,请使用以下命令:

kubectl config set-context $(kubectl config current-context) --namespace my-namespace 

在此命令中,kubectl config current-context 用于获取当前上下文名称。

创建 Kubernetes 集群

要使用 Minikube 创建 Kubernetes 集群,我们需要运行几个命令:

  • 取消设置 KUBECONFIG 环境变量,以确保 kubectl 上下文在默认配置文件 ~/.kube/config 中创建。

  • 使用 minikube start 命令创建集群,我们也可以指定要使用的 Kubernetes 版本以及要分配给集群的硬件资源量:

    • 为了能够完成本书剩余章节中的示例,请为集群分配 10 GB 的内存,即 10,240 MB。如果只分配 6 GB(6,144 MB)给 Minikube 集群,尽管速度较慢,这些示例也应该能正常工作。

    • 分配您认为合适的 CPU 核心和磁盘空间;以下示例中使用了 4 个 CPU 核心和 30 GB 的磁盘空间。

    • 指定将使用哪个版本的 Kubernetes。在本书中,我们将使用 v1.26.1。

    • 指定我们将使用上面描述的 Docker 驱动程序。

    • 指定所需的端口映射。端口 80808443 将由 Ingress 控制器使用,而端口 3008030443 将由类型为 NodePort 的服务使用。

    有关网关服务器如何部署类型为 NodePort 的服务的详细信息,请参阅第十六章,将我们的微服务部署到 Kubernetes。

  • 指定用于即将到来的 minikube 命令的 Minikube 配置文件。我们将使用 handson-spring-boot-cloud 作为配置文件名称。

  • 在集群创建后,我们将使用 Minikube 中的插件管理器来启用 Minikube 自带的 Ingress 控制器和度量服务器。Ingress 控制器和度量服务器将在下一章中使用。

运行以下命令以创建 Kubernetes 集群:

unset KUBECONFIG
minikube start \
 --profile=handson-spring-boot-cloud \
 --memory=10240 \
 --cpus=4 \
 --disk-size=30g \
 --kubernetes-version=v1.26.1 \
 --driver=docker \
 --ports=8080:80 --ports=8443:443 \
 --ports=30080:30080 --ports=30443:30443
minikube profile handson-spring-boot-cloud
minikube addons enable ingress
minikube addons enable metrics-server 

在前面的命令完成后,您应该能够与集群通信。尝试运行 kubectl get nodes 命令。它应该响应类似于以下内容:

图形用户界面,描述自动生成

图 15.4:Kubernetes 集群中的节点列表

一旦创建,集群将在后台初始化自身,启动 kube-systemingress-nginx 命名空间中的多个系统 Pods。我们可以通过以下命令来监控其进度:

kubectl get pods --all-namespaces 

一旦启动完成,前面的命令应该报告所有 Pods 的状态为 运行中,并且 READY 计数应为 1/1,这意味着每个 Pod 中的单个容器都已启动并运行:

图形用户界面,文本,描述自动生成

图 15.5:运行中的系统 Pods 列表

注意,有两个 Pod 报告为Completed,而不是Running。它们是由Job对象创建的 Pod,用于执行容器固定次数,就像批处理作业一样。运行命令kubectl get jobs --namespace=ingress-nginx以揭示这两个 Job 对象。

我们现在准备好采取一些行动了!

尝试一个示例 Deployment

让我们看看我们如何做到以下:

  • 在我们的 Kubernetes 集群中部署基于 NGINX 的简单 Web 服务器

  • 对 Deployment 应用一些更改:

    • 通过删除 Pod 来更改当前状态,并验证ReplicaSet创建了一个新的 Pod

    • 通过将 Web 服务器扩展到三个 Pod 来更改所需状态,并验证ReplicaSet通过启动两个新的 Pod 来填补空缺

  • 使用具有节点端口的 Service 将外部流量路由到 Web 服务器

首先,创建一个命名空间first-attempts,并更新kubectl上下文以默认使用此命名空间:

kubectl create namespace first-attempts
kubectl config set-context $(kubectl config current-context) --namespace=first-attempts 

我们现在可以使用kubernetes/first-attempts/nginx-deployment.yaml文件在命名空间中创建一个 NGINX Deployment。此文件如下所示:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deploy
spec:
  replicas: 1
  selector:
    matchLabels:
      app: nginx-app
  template:
    metadata:
      labels:
        app: nginx-app
    spec:
      containers:
      - name: nginx-container
        image: nginx:latest
        ports:
        - containerPort: 80 

让我们更详细地解释这段源代码:

  • kindapiVersion属性用于指定我们正在声明一个 Deployment 对象。

  • metadata部分用于描述 Deployment 对象。例如,我们给它命名为nginx-deploy

Kubernetes 对象常用的其他元数据包括它所属的namespace名称、labels 和annotations。我们将在本章和下一章中看到它们的使用。

  • 接下来是一个定义 Deployment 对象的期望状态的spec部分:

    • replicas: 1指定我们希望有一个 Pod 运行。

    • 一个指定 Deployment 如何找到它管理的 Pods 的selector部分。在这种情况下,Deployment 将寻找将app标签设置为nginx-app的 Pods。

    • template部分用于指定 Pod 的创建方式。

      • metadata部分指定了用于识别 Pods 的label,即app: nginx-app,从而匹配选择器。

      • spec部分指定了 Pod 中单个容器创建的详细信息,即nameimage以及它使用的ports

使用以下命令创建 Deployment:

cd $BOOK_HOME/Chapter15
kubectl apply -f kubernetes/first-attempts/nginx-deployment.yaml 

让我们看看使用kubectl get all命令我们得到了什么:

文本描述自动生成

图 15.6:由示例 Deployment 创建的 Kubernetes 对象

如预期,我们得到了一个 Deployment、ReplicaSet 和 Pod 对象。经过一段时间后,这主要取决于下载 NGINX Docker 镜像所需的时间,Pod 将启动并运行,在READY列中报告为1/1,这意味着所需状态等于当前状态!

现在,我们将通过删除 Pod 来更改当前状态。在删除 Pod 之前,在另一个终端中运行命令kubectl get pod --watch。使用--watch选项使命令挂起,等待当前命名空间中 Pod 的状态变化。使用以下命令删除 Pod:

kubectl delete pod --selector app=nginx-app 

由于 Pod 有一个随机名称(在前面的例子中是nginx-deploy-59b8c5f7cd-mt6pg),Pod 是根据app标签选择的,该标签在 Pod 中设置为nginx-app

注意kubectl get pod --watch如何报告当前 Pod 的终止,同时启动一个新的 Pod。

是 ReplicaSet 检测到期望状态和当前状态之间的差异,并且几乎立即启动一个新的 Pod 来补偿偏差。报告的事件应类似于以下截图:

计算机截图,描述由低置信度自动生成

图 15.7:kubectl get pod --watch 报告 Pod 的变化

在截图上,我们可以看到以d69ln结尾的 Pod 被delete命令停止了,并且 ReplicaSet 立即启动了一个以ptbkf结尾的新 Pod。

通过在kubernetes/first-attempts/nginx-deployment.yaml Deployment 文件中将期望的 Pod 数量设置为三个副本来更改期望状态。通过简单地重复我们之前提到的kubectl apply命令来应用期望状态的变化。

再次注意,kubectl get pod --watch命令报告 ReplicaSet 启动的新 Pod 以获取当前状态等效于新的期望状态,即三个 Pod。几秒钟后,将报告两个新的 NGINX Pod 已启动并运行。使用Ctrl + C停止命令。

执行kubectl get all命令,并期待得到以下类似的响应:

文本描述由低置信度自动生成

图 15.8:Kubernetes 启动的新 Pod 以满足期望状态

注意有三个 Pod,并且 Deployment 对象报告3/3。这表示有 3 个就绪的和 3 个期望的 Pod,意味着所有期望的 Pod 都准备好使用。

要启用与 Web 服务的外部通信,使用kubernetes/first-attempts/nginx-service.yaml文件创建一个服务。它看起来如下:

apiVersion: v1
kind: Service
metadata:
  name: nginx-service
spec:
  type: NodePort
  selector:
    app: nginx-app
  ports:
    - targetPort: 80
      port: 80
      nodePort: 30080 

kindapiVersion属性用于指定我们正在声明一个Service对象。

metadata部分用于描述Service对象,例如,给它一个名称:nginx-service

接下来是一个spec部分,它定义了Service对象的期望状态:

  • 使用type字段,我们指定我们想要NodePort,即一个在集群中每个节点上具有专用端口的可外部访问的服务。这意味着外部调用者可以通过集群中任何节点的此端口访问此服务背后的 Pod,而不论 Pod 实际运行在哪些节点上。

  • 选择器由 Service 用于查找可用的 Pods,在我们的案例中,是标记为app: nginx-app的 Pods。

  • 最后,ports声明如下:

    • port: 80指定了 Service 将在哪个端口上可访问,即在集群内部。

    • targetPort: 80指定了请求将被转发到的 Pod 中的端口。

    • nodePort: 30080指定了 Service 将通过集群中的任何节点在哪个端口上对外部可访问。默认情况下,节点端口必须在3000032767的范围内。

此端口范围用于最小化与其他正在使用的端口的冲突风险。在生产系统中,通常在 Kubernetes 集群前面放置一个负载均衡器,以保护外部用户免受这些端口的了解以及 Kubernetes 集群中节点的 IP 地址。有关使用LoadBalanced Kubernetes Service 的更多信息,请参阅第十八章使用服务网格来提高可观察性和管理,特别是设置对 Istio 服务的访问部分。

使用以下命令创建 Service:

kubectl apply -f kubernetes/first-attempts/nginx-service.yaml 

要查看我们得到的结果,请运行kubectl get svc命令。预期会收到以下类似响应:

图形用户界面,自动生成描述

图 15.9:我们的部署的 NodePort 服务

kubectl支持许多 API 对象的短名称,作为其全名称的替代。例如,在前面命令中使用的是svc,而不是全称service。运行kubectl api-resources命令以查看所有可用的短名称。

要通过 Service 的节点端口访问 Web 服务器,我们需要知道我们集群中单个节点的 IP 地址或主机名。当使用 Docker 驱动时,主机名始终是localhost

节点端口30080通过minikube start命令中的–ports选项由 Docker Engine 转发。有关详细信息,请参阅上面的创建 Kubernetes 集群部分。这意味着Service可以通过地址localhost:30080访问。

在 WSL 2 实例中打开的端口在 Windows 上可通过localhost访问。

使用这些信息,我们可以将 macOS 和 Windows 上的网络浏览器指向部署的 Web 服务器,使用地址http://localhost:30080。预期会收到以下类似响应:

图形用户界面,文本,应用程序,电子邮件,自动生成描述

图 15.10:NGINX 默认网页

太好了!但关于内部集群 IP 地址和端口怎么办?

验证 Web 服务器是否也可以在集群内部访问的一种方法是通过启动一个小 Pod,我们可以使用它从内部运行curl命令。curl命令将使用内部集群 IP 地址和端口。我们不需要使用内部 IP 地址;相反,我们可以使用为 Service 在内部 DNS 服务器中创建的 DNS 名称。DNS 名称的简称与 Service 的名称相同,即nginx-service

服务的完整 DNS 名称是 <service-name>.<namespace>.svc.cluster.local。此服务的完整名称是 nginx-service.first-attempts.svc.cluster.local。由于我们将在相同的命名空间中运行以下命令,我们可以使用简短名称。

运行以下命令:

kubectl run -i --rm --restart=Never curl-client --image=curlimages/curl --command -- curl -s 'http://nginx-service:80' 

命令看起来有点复杂,但它将执行以下操作:

  1. 创建一个基于 Docker 镜像 curlimages/curl 的 Pod,该镜像包含 curl 命令。

  2. 在容器内运行 curl -s 'http://nginx-service:80' 命令,并使用 -i 选项将输出重定向到终端。

  3. 使用 --rm 选项删除 Pod。

预期上一条命令的输出将包含以下信息(我们在这里只显示响应的部分):

文本描述自动生成

图 15.11:访问 Kubernetes 集群内的 NGINX

这意味着 Web 服务器也可以在集群内部访问!

这基本上是我们需要了解的所有内容,以便能够部署我们的系统景观。

通过删除包含 nginx 部署的命名空间来结束这一切:

kubectl delete namespace first-attempts 

在结束关于 Kubernetes 的这一章介绍之前,我们需要学习如何管理我们的 Kubernetes 集群。

管理本地 Kubernetes 集群

运行的 Kubernetes 集群消耗大量资源,主要是内存。因此,当我们完成在 Minikube 中与 Kubernetes 集群的工作后,我们必须能够将其休眠以释放分配给它的资源。我们还需要知道如何在想要继续工作时恢复集群。最终,我们还需要能够永久删除集群,当我们不再希望将其保留在磁盘上时。

Minikube 随带一个 stop 命令,可以用来休眠 Kubernetes 集群。我们用来最初创建 Kubernetes 集群的 start 命令也可以用来从休眠状态恢复集群。要永久删除集群,我们可以使用 Minikube 的 delete 命令。

休眠和恢复 Kubernetes 集群

运行以下命令来休眠(即 stop)Kubernetes 集群:

minikube stop 

运行以下命令再次恢复(即 start)Kubernetes 集群:

minikube start 

在重启集群后直接运行 kubectl 命令可能会导致错误信息,例如:

E0428 09:44:16.333361   79175 memcache.go:106] couldn't get resource list for metrics.k8s.io/v1beta1: the server is currently unable to handle the request 

这是因为 metrics-server 在启动时有点慢;错误信息会在一段时间后消失。

当恢复一个已经存在的集群时,start 命令会忽略你在创建集群时使用的开关。

在恢复 Kubernetes 集群后,kubectl 上下文将更新为使用此集群,当前使用的命名空间设置为 default。如果你正在使用另一个命名空间,例如,我们将在下一章“第十六章,将我们的微服务部署到 Kubernetes”中使用的 hands-on 命名空间,你可以使用以下命令更新 kubectl 上下文:

kubectl config set-context $(kubectl config current-context) --namespace=hands-on 

后续的 kubectl 命令将在适用的情况下应用于 hands-on 命名空间。

Minikube 还提供了一种比 stopstart 命令更轻量级和更快的替代方案:pauseunpause 命令。在这种情况下,控制平面的组件被暂停,而不是停止,将集群的 CPU 消耗降至最低。然而,我在最近章节中使用这些命令时遇到了问题,因此我建议使用 startstop 命令。

终止 Kubernetes 集群

如果您稍后想终止 Kubernetes 集群,您可以运行以下命令:

minikube delete --profile handson-spring-boot-cloud 

实际上,您可以在不指定配置文件的情况下运行 delete 命令,但我发现明确指定配置文件更安全。否则,您可能会意外删除错误的 Kubernetes 集群!

我们已经成功学习了如何管理在 Minikube 中运行的 Kubernetes 集群。现在我们知道如何挂起和恢复集群,以及当不再需要时,如何永久删除它。

摘要

在本章中,我们介绍了 Kubernetes 作为容器编排器。

使用 Kubernetes,我们可以将服务器集群作为一个大型的逻辑服务器来处理,该服务器运行我们的容器。我们为 Kubernetes 集群声明一个所需状态,并且它确保只要集群中有足够的硬件资源,实际状态始终与所需状态相同。

所需状态是通过使用 Kubernetes API 服务器创建资源来声明的。Kubernetes 中的控制器管理器和其控制器会对 API 服务器创建的各种资源做出反应,并采取行动确保当前状态符合新的所需状态。调度器将节点分配给新创建的容器,即包含一个或多个容器的 Pods。在每个节点上,一个代理,一个 kubelet 运行,并确保已调度到其节点的 Pods 正在运行。kube-proxy 作为网络代理,通过将发送到服务的请求转发到集群中可用的 Pods 来实现服务抽象。外部请求可以通过 Kubernetes 感知的负载均衡器来处理,该负载均衡器可以为服务提供公共 IP 地址和/或 DNS 名称,或者通过集群中所有节点上都可用的节点端口,或者通过一个专门的 Ingress 资源。

我们还通过使用 Minikube 创建本地单节点集群来尝试了 Kubernetes。Minikube 集群作为使用 Docker 驱动的 Docker 容器运行。为了使端口在 Docker 引擎外部可访问,我们可以在 minikube start 命令上使用 --ports 选项。使用名为 kubectl 的 Kubernetes CLI 工具,我们部署了一个基于 NGINX 的简单 Web 服务器。我们通过删除 Web 服务器来测试其弹性能力,并观察到它被自动重新创建。我们学习了如何通过请求在 Web 服务器上运行三个 Pod 来手动扩展它。我们创建了一个具有节点端口的 Service,并验证了我们可以从集群外部和内部访问它。

最后,我们学习了如何从休眠、恢复和终止集群的角度来管理在 Minikube 中运行的 Kubernetes 集群。

我们现在已经准备好从前面章节中部署我们的系统架构到 Kubernetes 中。前往下一章了解如何进行此操作!

问题

  1. 如果你两次运行相同的 kubectl create 命令会发生什么?

  2. 如果你两次运行相同的 kubectl apply 命令会发生什么?

  3. 在问题 1 和 2 的方面,为什么它们在第二次运行时表现不同?

  4. ReplicaSet 的作用是什么,还有哪些资源可以创建 ReplicaSet?

  5. 在 Kubernetes 集群中,etcd 的作用是什么?

  6. 如何让一个容器找出在同一 Pod 中运行的另一个容器的 IP 地址?

  7. 如果你创建两个具有相同名称但位于不同命名空间中的 Deployment 会发生什么?

  8. 两个具有相同名称的 Service 的哪种配置会导致它们失败,即使它们是在两个不同的命名空间中创建的?

加入我们的 Discord 社区

加入我们的 Discord 空间,与作者和其他读者进行讨论:

packt.link/SpringBoot3e

第十六章:将我们的微服务部署到 Kubernetes

在本章中,我们将部署本书中的微服务到 Kubernetes。为了在不同运行环境中打包和配置微服务以进行部署,我们将使用 Kubernetes 的包管理器 Helm。在这样做之前,我们需要回顾一下服务发现是如何使用的。由于 Kubernetes 内置了对服务发现的支持,因此似乎没有必要部署 Netflix Eureka 来实现这一目的。最后,我们还将尝试一些有助于在 Kubernetes 中部署微服务的 Spring Boot 功能。

本章将涵盖以下主题:

  • 用 Kubernetes 服务对象和 kube-proxy 替换 Netflix Eureka 进行服务发现

  • 介绍 Kubernetes 的使用方法

  • 使用 Spring Boot 对优雅关闭和存活性/就绪性探测的支持

  • 使用 Helm 打包、配置和在不同环境中部署微服务

  • 使用测试脚本 test-em-all.bash 验证部署

技术要求

关于如何安装本书中使用的工具以及如何访问本书的源代码的说明,请参阅:

  • 第二十一章macOS 安装说明

  • 第二十二章使用 WSL 2 和 Ubuntu 的 Microsoft Windows 安装说明

本章中的代码示例均来自 $BOOK_HOME/Chapter16 的源代码。

如果你想要查看本章源代码中应用的变化,即查看在 Kubernetes 上部署微服务所需的内容,你可以将此源代码与 第十五章Kubernetes 简介 中的源代码进行比较。你可以使用你喜欢的 diff 工具比较两个文件夹,$BOOK_HOME/Chapter15$BOOK_HOME/Chapter16

用 Kubernetes 服务替换 Netflix Eureka

如前一章,第十五章Kubernetes 简介 所示,Kubernetes 内置了一个基于 Kubernetes 服务对象和 kube-proxy 运行时组件的发现 服务。这使得部署像 Netflix Eureka 这样的单独发现服务变得不必要,我们在前几章中使用了它。

使用 Kubernetes 发现服务的一个优点是它不需要像我们与 Netflix Eureka 一起使用的 Spring Cloud LoadBalancer 这样的客户端库。这使得 Kubernetes 发现服务易于使用,与微服务基于哪种语言或框架无关。

使用 Kubernetes 发现服务的缺点是它只能在 Kubernetes 环境中工作。然而,由于发现服务基于接受服务对象 DNS 名称或 IP 地址请求的 kube-proxy,因此用类似的发现服务替换它应该相当简单,例如,另一个容器编排器捆绑的服务。

总结这一点,我们将从我们的微服务架构中移除基于 Netflix Eureka 的发现服务器,如下面的图所示:

图描述自动生成

图 16.1:用 Kubernetes 内置的发现服务替换 Netflix Eureka

要将基于 Netflix Eureka 的发现服务器替换为 Kubernetes 内置的发现服务,我们需要在我们的构建和配置文件中做一些修改。我们不需要对 Java 源代码进行任何修改,除了某些测试类,其中不再需要属性,因此将被删除。以下更改已应用于源代码:

  • Netflix Eureka 和 Spring Cloud LoadBalancer 特定配置(客户端和服务器)已从配置存储库config-repo中移除。

  • 已从config-repo/gateway.yml文件中移除了网关服务到 Eureka 服务器的路由规则。

  • spring-cloud/eureka-server文件夹中的 Eureka 服务器项目已被移除。

  • 已从 Docker Compose 文件和settings.gradle Gradle 文件中移除了 Eureka 服务器。

  • 已从所有 Eureka 客户端构建文件build.gradle中移除了对spring-cloud-starter-netflix-eureka-client的依赖。

  • 已从以前 Eureka 客户端的所有集成测试中移除了属性设置eureka.client.enabled=false

  • 网关服务不再使用 Spring Cloud LoadBalancer 中的客户端负载均衡器的路由。例如,lb://product-composite路由目标在config-repo/gateway.yml文件中已被替换为http://product-composite

  • 微服务和授权服务器使用的 HTTP 端口已从端口8080(在授权服务器的情况下为9999)更改为默认 HTTP 端口80。这已在config-repo中为每个受影响的 Service 进行配置,如下所示:

    spring.config.activate.on-profile: docker
    server.port: 80 
    

我们将使用的所有 HTTP 地址都不会受到用 Kubernetes 服务替换 Netflix Eureka 的影响。例如,复合服务使用的地址不受影响:

private final String productServiceUrl = "http://product";
private final String recommendationServiceUrl = "http://recommendation";
private final String reviewServiceUrl = "http://review"; 

这是因为我们已将微服务和授权服务器使用的 HTTP 端口更改为默认 HTTP 端口80,如前所述。

即使移除了 Netflix Eureka,使用 Docker Compose 仍然有效。这之所以可行,是因为 Docker Compose 文件中的容器名称与 Kubernetes 中使用的相应服务名称相同,这意味着微服务的 DNS 名称在两个环境中都是相同的。这可以用来在不部署到 Kubernetes 的情况下运行微服务的功能测试,例如,与 Docker Desktop 一起运行test-em-all.bash,就像我们在前面的章节中所做的那样。然而,移除 Netflix Eureka 意味着当我们使用纯 Docker 和 Docker Compose 时,不再有发现服务。因此,只有在部署到 Kubernetes 时,微服务的扩展才能工作。

第十七章通过实现 Kubernetes 功能简化系统景观,在 验证微服务无需 Kubernetes 也能工作 的部分,我们将讨论避免微服务源代码依赖于 Kubernetes 平台的重要性,从而避免供应商锁定。我们还将使用测试脚本 test-em-all.bash 以及 Docker Compose 来验证微服务在功能上不需要 Kubernetes。

既然我们已经熟悉了 Netflix Eureka 将如何被 Kubernetes 服务所取代,那么让我们介绍其他我们将使用的 Kubernetes 对象。

介绍 Kubernetes 的使用方法

在本章的后面部分,我们将详细看到如何使用各种 Kubernetes 对象来部署微服务以及它们所依赖的资源管理器,如数据库和队列管理器。在深入所有细节之前,让我们先了解一下将要使用的 Kubernetes 对象:

  • 对于将在 Kubernetes 中部署的每个微服务、数据库和队列管理器,将创建一个 Deployment 对象和一个 Service 对象。对于所有组件,除了名为 gateway 的边缘服务器外,Service 对象的类型将是 ClusterIP。对于网关,Service 对象的类型将是 NodePort,在端口 30433 接受外部 HTTPS 请求。

  • 配置服务器将使用 ConfigMap,其中包含 config-repo 中的配置文件。

  • 为了存储配置服务器及其客户端的凭证,将创建两个 Secrets:一个用于配置服务器,一个用于其客户端。

现在我们已经看到了将要创建的 Kubernetes 对象,让我们学习一下 Spring Boot 的功能,这些功能有助于简化 Kubernetes 的部署。

使用 Spring Boot 对优雅关闭和存活性/就绪性探测的支持

在 Spring Boot v2.3 中,添加了一些有用的功能来支持将部署到 Kubernetes:

  • 优雅关闭:

当微服务实例需要停止时,例如在滚动升级场景中,当实例停止时可能会影响活动请求。为了最小化这种风险,Spring Boot 添加了对优雅关闭的支持。在应用优雅关闭时,微服务停止接受新的请求,并在关闭应用程序之前等待一个可配置的时间,以便完成活动请求。完成时间超过关闭等待期的请求将被终止。这些请求将被视为异常情况,关闭程序在停止应用程序之前不能等待。

通过在 config-repo 文件夹中的公共文件 application.yml 中添加以下内容,为所有微服务启用了优雅关闭,等待期为 10 秒:

server.shutdown: graceful
spring.lifecycle.timeout-per-shutdown-phase: 10s 

更多信息,请参阅 docs.spring.io/spring-boot/docs/3.0.4/reference/htmlsingle/#features.graceful-shutdown

  • 存活性和就绪性探针:

如在第十五章《Kubernetes 简介》中所述,正确实现存活性和就绪性探针对于 Kubernetes 能够管理我们的 Pods 至关重要。

简要回顾一下,存活性探针告诉 Kubernetes 是否需要替换 Pod,而就绪性探针告诉 Kubernetes 其 Pod 是否准备好接受请求。为了简化这项工作,Spring Boot 添加了对实现存活性和就绪性探针的支持。这些探针分别暴露在 /actuator/health/liveness/actuator/health/readiness URL 上。如果需要比配置提供的更多控制,它们可以通过配置或源代码中的实现来声明。当通过配置声明探针时,可以为每个探针声明一个 健康组,指定它应包含哪些现有的健康指标。例如,如果微服务无法访问其 MongoDB 数据库,就绪性探针应报告 DOWN。在这种情况下,就绪性探针的健康组应包括 mongo 健康指标。有关可用的健康指标,请参阅 docs.spring.io/spring-boot/docs/3.0.4/reference/htmlsingle/#actuator.endpoints.health.auto-configured-health-indicators

在本章中,我们将使用以下配置在 config-repo 文件夹中的公共文件 application.yml 中声明探针:

management.endpoint.health.probes.enabled: true
management.endpoint.health.group.readiness.include: readinessState, 
rabbit, db, mongo 

配置文件的第一行启用了存活性和就绪性探针。第二行声明,如果可用,就绪性探针将包括 RabbitMQ、MongoDB 和 SQL 数据库的健康指标。对于存活性探针,我们不需要添加任何额外的健康指标。在本章的范围内,只要 Spring Boot 应用程序正在运行,存活性探针报告 UP 就足够了。

更多信息,请参阅 docs.spring.io/spring-boot/docs/3.0.4/reference/htmlsingle/#actuator.endpoints.kubernetes-probes

我们将在将微服务部署到 Kubernetes 之后尝试这些功能。在我们这样做之前,我们需要了解 Helm 并看看它是如何帮助我们打包、配置和部署微服务到 Kubernetes 的。

介绍 Helm

如上所述,将微服务部署到 Kubernetes 需要编写声明部署对象和服务对象所需状态的清单文件。如果我们还需要为微服务添加一些配置,就必须添加 ConfigMaps 和 Secrets 的清单。声明所需状态并将责任交给 Kubernetes 以确保实际状态始终尽可能接近所需状态的方法非常有用。

然而,编写和维护这些清单文件可能会变成一项重大的维护负担。这些文件将包含大量的样板代码,意味着所有微服务的清单看起来都相同。即使只需要更新内容的一小部分,处理特定环境的设置而不重复整个清单文件集也是一件麻烦事。

在只有少数微服务将被部署到少数环境(如测试、QA 和生产环境)的情况下,这可能不是处理的主要问题。

当微服务的数量增长到数十甚至数百,并且必须能够将不同的微服务组部署到不同的测试、QA 和生产环境中时,这很快就会变成一个难以管理的维护问题。

为了解决这些不足,我们将使用基于开源的包管理器 Helm (helm.sh)。Helm 附带了一种模板语言,可以用来从各种 Kubernetes 对象的通用定义中提取特定于微服务或环境的设置。

对于只有少数部署对象的较小系统景观,简单的模板工具可能就足够了。例如,如果你已经熟悉Ansible及其Jinja2模板,它们可以替代使用。此外,kubectl本身也内置了对Kustomize的支持,提供了一种无需模板即可自定义 Kubernetes 清单文件的替代方案。

在 Helm 中,一个包被称为chart。一个 chart 包含模板、模板的默认值以及可选的依赖项,这些依赖项来自其他 chart 中的定义。每个需要部署的组件,即微服务和它们所依赖的资源管理器(如数据库和队列管理器),都将有自己的 chart,描述如何部署它。

为了从组件的 chart 中提取样板定义,将使用一种特殊的 chart,即库 chart。库 chart 不包含任何可部署的定义,而只包含其他 chart 用于 Kubernetes 清单的模板——在我们的案例中,用于 Deployment、Service、ConfigMap 和 Secret 对象。

最后,为了能够描述如何将所有组件部署到不同类型的环境中,例如,用于开发和测试或预生产和生产,我们将使用父图表子图表的概念。我们将定义两种环境类型,dev-envprod-env。每个环境都将实现为一个依赖于不同子图表集的父图表,例如,微服务图表。环境图表还将提供特定于环境的默认值,例如请求的 Pod 数量、Docker 镜像版本、凭证以及资源请求和限制。

总结来说,我们将有一个可重用的库图表,命名为common;一组针对微服务和资源管理器的特定图表,放置在components文件夹中;以及两个环境特定的父图表,放置在environments文件夹中。文件结构如下:

|-- common
|   |-- Chart.yaml
|   |-- templates
|   |-- templates_org
|   `-- values.yaml
|-- components
|   |-- auth-server
|   |-- config-server
|   |-- gateway
|   |-- mongodb
|   |-- mysql
|   |-- product
|   |-- product-composite
|   |-- rabbitmq
|   |-- recommendation
|   |-- review
|   `-- zipkin-server
`-- environments
    |-- dev-env
    `-- prod-env 

这些文件可以在文件夹$BOOK_HOME/Chapter16/kubernetes/helm中找到。

要与他人共享 Helm 图表,可以将它们发布到 Helm 图表仓库。在这本书中,我们不会发布任何图表,但在第十七章实现 Kubernetes 功能以简化系统景观中,我们将使用来自图表仓库的 Helm 图表安装名为cert-manager的组件。

在我们了解图表是如何构建之前,让我们了解最常用的 Helm 命令以及如何运行它们。

运行 Helm 命令

要让 Helm 为我们执行某些操作,我们将使用其 CLI 工具,helm

最常用的 Helm 命令包括:

  • create:用于创建新的图表。

  • dependency update(简称dep up):解决对其他图表的依赖。图表放置在charts文件夹中,并更新文件Chart.lock

  • dependency build:根据文件Chart.lock中的内容重建依赖。

  • template:渲染由模板创建的定义文件。

  • install:安装一个图表。此命令可以覆盖图表提供的值,可以使用--set标志覆盖单个值,或使用--values标志提供自己的yaml文件,其中包含值。

  • install --Dry-run:模拟一个部署而不执行它;在执行之前验证部署很有用。

  • list:列出当前命名空间中的安装。

  • upgrade:更新现有安装。

  • uninstall:删除安装。

要查看 Helm 提供的命令的完整文档,请参阅helm.sh/docs/helm/

让我们把这些 Helm 命令放在上下文中,看看一个图表由哪些文件组成。

查看 Helm 图表

Helm 图表有一个预定义的文件结构。我们将使用以下文件:

  • Chart.yaml,其中包含有关图表的一般信息和可能依赖的其他图表列表。

  • templates,一个包含用于部署图表的模板的文件夹。

  • values.yaml,它包含模板使用的变量的默认值。

  • Chart.lock,Helm 在解决Chart.yaml文件中描述的依赖关系时创建的文件。该信息更详细地描述了实际使用的依赖关系。Helm 使用它来跟踪整个依赖关系树,使得能够精确地重新创建上一次图表工作时的依赖关系树。

  • charts,一个文件夹,在 Helm 解决依赖关系后,将包含此图表所依赖的图表。

  • .helmignore,一个类似于.gitignore的忽略文件。它可以用来列出在构建图表时应排除的文件。

现在我们已经了解了 Helm 图表内部的架构,让我们来学习 Helm 的核心功能之一:其模板机制以及如何向其传递值。

Helm 模板和值

Helm 模板用于参数化 Kubernetes 清单文件。使用模板,我们不再需要为每个微服务维护冗长的 Deployment 清单。相反,我们可以定义一个包含在模板中占位符的通用模板,当为特定微服务渲染清单时,这些占位符将放置微服务特定的值。让我们看一个例子,它来自kubernetes/helm/common/templates/_deployment.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "common.fullname" . }}
spec:
  replicas: {{ .Values.replicaCount }}
  template:
    spec:
      containers:
        - name: {{ .Chart.Name }} 

它看起来与我们在第十五章“Kubernetes 简介”中看到的 Deployment 清单非常相似,唯一的区别是使用了{{ ... }}构造,用于将微服务特定的值插入到模板中。构造{{ include "common.fullname" . }}用于调用其他模板,如下所述。其他两个构造用于使用 Helm 中的内置对象之一插入值。

常用内置对象的以下部分:

  • Values: 用于引用图表的values.yaml文件中的值或运行 Helm 命令(如install)时提供的值。

  • Release: 用于提供有关当前已安装发布版本的元数据。它包含如下字段:

    • Name: 发布版本的名称

    • Namespace: 执行安装的命名空间名称

    • Service: 安装服务的名称,总是返回Helm

  • Chart: 用于从Chart.yaml文件中访问信息。以下是一些可用于为部署提供元数据的字段示例:

    • Name: 图表的名称

    • Version: 图表的版本号

  • Files: 包含访问特定图表文件的函数。在本章中,我们将使用Files对象中的以下两个函数:

    • Glob: 根据 glob 模式返回图表中的文件。例如,模式"config-repo/*"将返回在config-repo文件夹中找到的所有文件

    • AsConfig: 返回适合在ConfigMap中声明值的 YAML 映射文件内容

  • Capabilities:可用于查找有关在安装上执行的操作的 Kubernetes 集群的功能信息。例如,模板可以使用此对象中的信息根据实际 Kubernetes 集群支持的 API 版本采用清单。我们本章不会使用此对象,但我认为对于更高级的使用案例,了解它对我们是有益的。

有关内置对象的更多详细信息,请参阅helm.sh/docs/chart_template_guide/builtin_objects

所有对象都可以在一个树结构中访问,其中root上下文,在大多数情况下,可以使用当前作用域来表示,用点.表示,也称为。从上面的例子中我们可以看到点的使用,例如在.Values.replicaCount.Chart.Name中,我们可以看到内置对象ValuesChart可以直接在当前作用域下访问。在上面的include指令中,我们也可以看到点被用作参数发送给名为common.fullname的模板,这意味着整个树被发送到模板。而不是将整个树发送到模板,可以传递一个子树。

当使用一些 Helm 函数时,当前作用域将发生变化,不再指向root上下文。例如,我们将在稍后遇到range函数,它可以用来遍历值集合。如果我们需要在range函数的作用域内访问root上下文,我们可以使用预定义变量$

Helm 模板还支持声明变量以引用其他对象。例如:

$name := .Release.Name 

在本例中,已声明一个变量name,用于存储当前正在处理的 Helm 发布版本值。我们将在稍后了解变量如何在更高级的结构中使用。

如果你认识从使用kubectl中使用的{{ ... }}构造的格式,你是正确的。在两种情况下,它们都是基于 Go 模板。有关更多信息,请参阅golang.org/pkg/text/template/

在引入模板机制后,让我们了解三种图表类型是如何构建的。我们将从最重要的图表common图表开始,之后解释componentsenvironments图表。

常用库图表

此图表包含可重用的模板,也称为命名模板,用于本章中我们将使用的四种 Kubernetes 清单类型:Deployment、Service、ConfigMapSecret。常用图表的结构和内容基于helm create命令的输出。具体来说,模板文件_helpers.tpl已被保留,以便重用命名约定的最佳实践。它声明以下模板,这些模板封装了命名约定:

  • common.name:基于图表名称。

  • common.fullname:基于发布名称和图表名称的组合。在本书中,我们将覆盖这个命名约定,并简单地使用图表名称。

  • common.chart:基于图表名称和版本。

有关详细信息,请参阅 _helpers.tpl 文件中的实现。

命名模板,这些模板将仅被其他模板使用,而不会用于创建自己的清单,必须以下划线 _ 开头。这是为了防止 Helm 尝试仅使用它们来创建清单。

由于之前提到的 Kubernetes 清单的命名模板包含 Helm 图表中的主要逻辑部分,因此包含大部分复杂性,我们将逐一介绍它们。

ConfigMap 模板

这个模板旨在从 config-repo 文件夹中的文件创建 ConfigMap。每个 ConfigMap 将包含特定 Deployment 所需的所有非敏感配置。Deployment 清单将把 ConfigMap 的内容映射到其 Pod 模板中的卷。这将导致由 Deployment 创建的 Pod 能够将其配置作为本地文件系统中的文件访问。有关详细信息,请参阅下面的 Deployment 模板 部分。config-repo 文件夹需要放置在使用通用图表的图表中。

在本章中,这个模板将仅在 components 文件夹中的配置服务器图表中使用。在下一章中,所有其他微服务也将使用这个模板来定义它们自己的 ConfigMaps,因为配置服务器将被移除。

模板文件命名为 _configmap_from_file.yaml,其内容如下:

{{- define "common.configmap_from_file" -}}
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ include "common.fullname" . }}
  labels:
    app.kubernetes.io/name: {{ include "common.name" . }}
    helm.sh/chart: {{ include "common.chart" . }}
    app.kubernetes.io/managed-by: {{ .Release.Service }}
data:
{{ (.Files.Glob "config-repo/*").AsConfig | indent 2 }}
{{- end -}} 

模板的解释如下:

  • 第一行,{{- define "common.configmap_from_file " -}},用于声明可重用模板的名称。模板的作用域以匹配的 {{- end -}} 结束,本例中的最后一行。

  • 为了设置 ConfigMap 的名称,使用了来自 _helpers.tpl 文件的模板 common.fullname

  • 接下来,定义了一系列标签以便于稍后更容易地识别 ConfigMap。同样,这里使用了 _helpers.tpl 文件中的模板来设置 name 并指定所使用的 chart。为了标记这个服务是使用 Helm 创建的,将标签 app.kubernetes.io/managed-by 设置为字段 .Release.Service 的值。根据对 Release 对象的早期描述,我们知道它总是返回值 Helm

  • 接下来是 ConfigMap 的核心部分,其 data 部分。为了在 ConfigMap 中指定实际的配置,使用了 Files 对象中的 Glob 函数来获取 config-repo 文件夹中的所有文件。然后,将 AsConfig 函数应用于文件中的内容,以形成一个正确的 YAML 映射。结果通过管道传递到 indent 函数,该函数确保渲染出适当的缩进,在这种情况下,使用两个字符。

{{--}} 中的连字符用于删除大括号内指令处理后剩余的前导和尾随空白。

使用 ConfigMap 模板的示例

在本章中,只有配置服务器将使用 ConfigMap。请参阅关于 组件图表 的部分,了解此模板的使用方法。

要查看 Helm 使用此模板创建的 ConfigMap,请运行以下命令:

cd $BOOK_HOME/Chapter16/kubernetes/helm/components/config-server
helm dependency update .
helm template . -s templates/configmap_from_file.yaml 

期望从 helm template 命令得到如下输出:

---
# Source: config-server/templates/configmap_from_file.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: config-server
  labels:
    app.kubernetes.io/name: config-server
    helm.sh/chart: config-server-1.0.0
    app.kubernetes.io/managed-by: Helm
data:
  application.yml: |-
    app:
      auth-server: localhost
  ...
  auth-server.yml: |-
    server.port: 9999
  ... 

data 字段包含 config-repo 文件夹中所有文件的内容。

Secrets 模板

此模板旨在创建由环境 dev-envprod-env 提供的凭证等值定义的 Secrets。Secrets 将作为 Pod 中的环境变量映射。请参阅下面的 部署模板 部分以获取详细信息。由于环境必须能够定义多个 Secrets,因此此模板设计为使用 Helm 中的 range 函数创建多个 Secret 清单。模板文件命名为 _secrets.yaml,其外观如下:

{{- define "common.secrets" -}}
{{- range $secretName, $secretMap := .Values.secrets }}
apiVersion: v1
kind: Secret
metadata:
  name: {{ $secretName }}
  labels:
    app.kubernetes.io/name: {{ $secretName }}
    helm.sh/chart: {{ include "common.chart" $ }}
    app.kubernetes.io/managed-by: {{ $.Release.Service }}
type: Opaque
data:
{{- range $key, $val := $secretMap }}
  {{ $key }}: {{ $val | b64enc }}
{{- end }}
---
{{- end -}}
{{- end -}} 

如下解释模板:

  • 在第 1 行声明模板之后,第 2 行使用了 range 函数。该函数假定 .Values.secrets 字段包含一个 Secret 名称映射和一个 Secret 的键/值对映射。在某个环境的 values.yaml 文件中声明 Secrets 字段的示例如下:

    secrets:
      a-secret:
        key-1: secret-value-1
        key-2: secret-value-2
      another-secret:
        key-3: secret-value-3 
    

此定义将渲染两个 Secrets,分别命名为 a-secretanother-secretrange 函数将当前 Secret 名称及其映射分配给变量 $secretName$secretMap

  • 由于 range 函数改变了当前作用域,我们不能再使用点符号将 root 上下文传递给 common.chart 模板。相反,必须使用变量 $

  • 在清单的 data 部分中,再次应用第二个 range 函数来遍历当前 Secret 的键/值对。每个键/值对由 range 函数分配给变量 $key$val

  • 最后,Secret 的键/值对在 data 部分定义为映射条目。$val 变量的值通过管道传递到 b64enc 函数,以获得符合 Secret 清单要求的正确 Base64 编码。

使用 --- 来分隔渲染的 Secret 清单,以便它们作为单独的 YAML 文档进行处理。

使用 Secrets 模板的示例

秘密仅由环境图表 dev-envprod-env 定义。它们用于创建特定环境的凭证。请参阅关于 环境图表 的部分,了解此模板的使用方法。

要查看 Helm 使用此模板为 dev-env 创建的 Secrets,请运行以下命令:

cd $BOOK_HOME/Chapter16/kubernetes/helm
for f in components/*; do helm dependency update $f; done
helm dependency update environments/dev-env
helm template environments/dev-env -s templates/secrets.yaml 

期望从 helm template 命令得到如下输出:

---
# Source: dev-env/templates/secrets.yaml
apiVersion: v1
kind: Secret
metadata:
  name: config-client-credentials
  labels:
    app.kubernetes.io/name: config-client-credentials
    helm.sh/chart: dev-env-1.0.0
    app.kubernetes.io/managed-by: Helm
type: Opaque
data:
  CONFIG_SERVER_PWD: ZGV2LXB3ZA==
  CONFIG_SERVER_USR: ZGV2LXVzcg==
---
# Source: dev-env/templates/secrets.yaml
apiVersion: v1
kind: Secret
metadata:
  name: config-server-secrets
  labels:
    app.kubernetes.io/name: config-server-secrets
    helm.sh/chart: dev-env-1.0.0
    app.kubernetes.io/managed-by: Helm
type: Opaque
data:
  ENCRYPT_KEY: bXktdmVyeS1zZWN1cmUtZW5jcnlwdC1rZXk=
  SPRING_SECURITY_USER_NAME: ZGV2LXVzcg==
  SPRING_SECURITY_USER_PASSWORD: ZGV2LXB3ZA== 

服务模板

服务模板引入了对从通用图表覆盖默认值的支持,使用通用图表的特定图表的值。例如,通用图表将为服务的 type 和服务将暴露的 ports 提供默认值。这对于大多数微服务来说将很有用,但其中一些需要在它们自己的 values.yaml 文件中覆盖这些默认值。

模板文件命名为 _service.yaml,其结构与其他命名模板类似,首先声明其名称,然后是实现覆盖机制。它看起来是这样的:

{{- define "common.service" -}}
{{- $common := dict "Values" .Values.common -}} 
{{- $noCommon := omit .Values "common" -}} 
{{- $overrides := dict "Values" $noCommon -}} 
{{- $noValues := omit . "Values" -}} 
{{- with merge $noValues $overrides $common -}} 

这个结构可以这样解释:

  • 当微服务使用 _service.yaml 模板来渲染其服务清单时,微服务的 values.yaml 文件中的值将在 .Values 对象中可用,而通用图表的值将在 .Values.common 字段下可用。

  • 因此,变量 $common 将引用一个由 dict 函数创建的字典,其中有一个键 Values,其值将是通用图表的默认值。这些值来自 .Values 对象中的 common 键。

  • $noCommon 变量将保留微服务中除 common 键下的值之外的所有值,该键使用 omit 函数指定。

  • $overrides 变量将引用一个字典,同样只有一个键 Values,但其值将是微服务的值,除了 common 值。它从上一行声明的 $noCommon 变量中获取值。

  • $noValues 变量将保留所有其他内置对象,除了 Values 对象。

  • 现在,覆盖将在这里发生;merge 函数将基于变量 $noValues$overrides$common 所引用的字典创建一个字典。在这种情况下,$overrides 字典中找到的值将优先于 $common 字典中的值,从而覆盖其值。

  • 最后,with 函数将改变后续模板代码的作用域,直到其 {{- end -}} 定义为止。因此,当前的作用域 . 现在将指向合并后的字典。

让我们通过一个例子来看看这将如何工作。common 图表的 values.yaml 文件包含以下服务类型和暴露端口的默认设置:

 Service:
  type: ClusterIP
  ports:
  - port: 80
    targetPort: http
    protocol: TCP
    name: http 

此设置将渲染类型为 ClusterIP 的服务对象。服务对象将暴露端口 80 并将请求转发到其端口上名为 http 的 Pod。

网关服务需要暴露 NodePort 并使用其他端口设置。为了覆盖上述默认值,它在图表的 values.yaml 文件中声明以下内容:

service:
  type: NodePort
  ports:
  - port: 443
    targetPort: 8443
    nodePort: 30443 

网关的 values.yaml 文件位于文件夹 $BOOK_HOME/Chapter16/kubernetes/helm/components/gateway/values.yaml

服务模板文件的其余部分如下所示:

apiVersion: v1
kind: Service
metadata:
  name: {{ include "common.fullname" . }}
  labels: 
    app.kubernetes.io/name: {{ include "common.name" . }}
    helm.sh/chart: {{ include "common.chart" . }}
    app.kubernetes.io/managed-by: {{ .Release.Service }}
spec:
  type: {{ .Values.service.type }}
  ports:
{{ toYaml .Values.service.ports | indent 4 }}
  selector:
    app.kubernetes.io/name: {{ include "common.name" . }}
{{- end -}}
{{- end -}} 

模板解释如下:

  • namelabels的元数据字段与之前看到的模板定义方式相同。

  • Service 的type由字段.Values.service.type设置。

  • 使用字段.Values.service.ports指定 Service 暴露的端口。内置函数toYaml用于将值格式化为yaml,然后将结果传递给indent函数,以确保正确缩进,在这种情况下为4个字符。

  • 最后,定义了 Pod 的选择器。它基于标签app.kubernetes.io/name,并使用模板common.name来指定名称。

使用 Service 模板的示例

每个组件使用 Service 模板来创建其 Service 清单。如上所述,核心微服务重用通用图表的values.yaml文件中的配置,而其他组件在其自己的values.yaml文件中覆盖这些值。

要查看为核心组件生成的 Service 清单,对于product微服务,运行以下命令:

cd $BOOK_HOME/Chapter16/kubernetes/helm
helm dependency update components/product
helm template components/product -s templates/service.yaml 

预期helm template命令的输出如下:

# Source: product/templates/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: product
  labels:
    app.kubernetes.io/name: product
    helm.sh/chart: product-1.0.0
    app.kubernetes.io/managed-by: Helm
spec:
  type: ClusterIP
  ports:
    - name: http
      port: 80
      protocol: TCP
      targetPort: http
  selector:
    app.kubernetes.io/name: product 

要查看为覆盖通用图表设置的组件生成的 Service 清单,对于gateway组件,运行以下命令:

cd $BOOK_HOME/Chapter16/kubernetes/helm
helm dependency update components/gateway
helm template components/gateway -s templates/service.yaml 

预期helm template命令的输出如下:

---
# Source: gateway/templates/service.yaml
apiVersion: v1
kind: Service
metadata:
  name: gateway
  labels:
    app.kubernetes.io/name: gateway
    helm.sh/chart: gateway-1.0.0
    app.kubernetes.io/managed-by: Helm
spec:
  type: NodePort
  ports:
    - nodePort: 30443
      port: 443
      targetPort: 8443
  selector:
    app.kubernetes.io/name: gateway 

部署模板

最后,我们有用于渲染 Deployment 清单的模板。这是最复杂的模板,因为它必须处理清单中可选的许多部分。不同的组件将使用 Deployment 清单的不同部分。通用图表的values.yaml文件包含适用于大多数组件的默认设置值,最小化在每个组件自己的图表的values.yaml文件中覆盖这些设置的需求。以下 Deployment 清单的部分对于组件的使用是可选的:

  • 容器启动时传递给容器的参数

  • 环境变量

  • 来自 Secrets 的环境变量

  • 活跃探针

  • 就绪探针

  • 一个 ConfigMap 和相应的卷

模板文件命名为_deployment.yaml,其开头几行看起来与 Service 模板非常相似,使用相同的覆盖机制:

{{- define "common.deployment" -}}
{{- $common := dict "Values" .Values.common -}} 
{{- $noCommon := omit .Values "common" -}} 
{{- $overrides := dict "Values" $noCommon -}} 
{{- $noValues := omit . "Values" -}} 
{{- with merge $noValues $overrides $common -}}
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "common.fullname" . }}
  labels:
    app.kubernetes.io/name: {{ include "common.name" . }}
    helm.sh/chart: {{ include "common.chart" . }}
    app.kubernetes.io/managed-by: {{ .Release.Service }} 

对于模板的这一部分的解释,请参阅上面 Service 模板的描述。

当涉及到清单的spec部分时,它从以下内容开始:

spec:
  replicas: {{ .Values.replicaCount }}
  selector:
    matchLabels:
      app.kubernetes.io/name: {{ include "common.name" . }}
  template:
    metadata:
      labels:
        app.kubernetes.io/name: {{ include "common.name" . }}
    spec:
      containers:
        - name: {{ .Chart.Name }}
          image: "{{ .Values.image.repository }}/{{ .Values.image.name }}:{{ .Values.image.tag }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }} 

在这里我们可以看到如何定义 spec 的核心部分:请求的replicas数量、Pod 的selector以及用于创建新 Pod 的template。模板定义了与选择器匹配的labelsname、Dockerimage和启动容器时使用的imagePullPolicy

接下来是清单中描述的各个可选部分:

 args:
            {{- toYaml . | nindent 12 }}
          {{- end }}
          {{- if .Values.env }}
          env:
          {{- range $key, $val := .Values.env }}
          - name: {{ $key }}
            value: {{ $val }}
          {{- end }}
          {{- end }}
          {{- if .Values.envFromSecretRefs }}
          envFrom:
          {{- range .Values.envFromSecretRefs }}
          - secretRef:
              name: {{ . }}
          {{- end }}
          {{- end }}
          {{- if .Values.livenessProbe_enabled }}
          livenessProbe:
{{ toYaml .Values.livenessProbe | indent 12 }}
          {{- end }}
          {{- if .Values.readinessProbe_enabled }}
          readinessProbe:
{{ toYaml .Values.readinessProbe | indent 12 }}
          {{- end }} 

对于映射到环境变量的环境变量和机密信息,使用 range 函数的方式与 secrets 模板使用的方式相同。环境变量可以在组件或环境级别指定,具体取决于其用例。机密信息始终通过环境图表指定。有关组件和环境图表的更多信息,请参阅以下章节。

清单以声明容器暴露的 portsresource 请求和限制以及最后可选地声明一个 ConfigMap 和相应的卷来映射 ConfigMap 中的文件结束:

 ports:
{{ toYaml .Values.ports | indent 12 }}
          resources:
{{ toYaml .Values.resources | indent 12 }}
      {{- if .Values.configmap.enabled }}
          volumeMounts:
          - name: {{ include "common.fullname" . }}
            mountPath: {{ .Values.configmap.volumeMounts.mountPath }}
      volumes:
        - name: {{ include "common.fullname" . }}
          configMap:
            name: {{ include "common.fullname" . }}
      {{- end }}
{{- end -}}
{{- end -}} 

从公共图表的 values.yaml 文件中,我们可以找到一些有趣的默认值,例如,如何定义存活和就绪探针的默认值:

livenessProbe_enabled: false
livenessProbe:
  httpGet:
    scheme: HTTP
    path: /actuator/health/liveness
    port: 80
  initialDelaySeconds: 10
  periodSeconds: 10
  timeoutSeconds: 2
  failureThreshold: 20
  successThreshold: 1
readinessProbe_enabled: false
readinessProbe:
  httpGet:
    scheme: HTTP
    path: /actuator/health/readiness
    port: 80
  initialDelaySeconds: 10
  periodSeconds: 10
  timeoutSeconds: 2
  failureThreshold: 3
  successThreshold: 1 

从这些声明中,我们可以看到:

  • 探针默认是禁用的,因为并非所有部署都使用探针。

  • 探针基于发送到 Spring Boot 暴露的端点的 HTTP GET 请求,如上节所述的 使用 Spring Boot 对优雅关闭和存活就绪探针的支持

    • 只要端点以 2xx3xx 响应代码响应,探针就被认为是成功的。
  • 探针可以使用以下参数进行配置:

    • initialDelaySeconds 指定 Kubernetes 在容器启动后等待多长时间才对其进行探针。

    • periodSeconds 指定 Kubernetes 发送探针请求之间的时间间隔。

    • timeoutSeconds 指定 Kubernetes 在将探针视为失败之前等待响应的时间。

    • failureThreshold 指定 Kubernetes 在放弃之前尝试失败的次数。在存活探针的情况下,这意味着重新启动 Pod。在就绪探针的情况下,这意味着 Kubernetes 将不会向容器发送更多请求,直到就绪探针再次成功。

    • successThreshold 指定探针在失败后再次被视为成功所需的成功尝试次数。这仅适用于就绪探针,因为如果为存活探针指定,则必须设置为 1

寻找探针的最佳设置可能具有挑战性,也就是说,在当 Pod 的可用性发生变化时,从 Kubernetes 获得快速反应和不过度加载 Pod 以探针请求之间找到一个适当的平衡。

具体来说,配置一个存活探针,其值过低可能导致 Kubernetes 重新启动不需要重新启动的 Pod;它们只需要一些额外的时间来启动。同时启动大量 Pod,这也可能导致启动时间过长,同样可能导致大量不必要的重启。

在探针(除 successThreshold 值外)上设置配置值过高会使 Kubernetes 响应更慢,这在开发环境中可能会很烦人。适当的值还取决于可用的硬件,这会影响 Pod 的启动时间。在本书的范围内,为了防止在硬件资源有限的计算机上不必要的重启,将 liveness 探针的 failureThreshold 设置为高值,20

使用 Deployment 模板的示例

每个组件使用 Deployment 模板来创建其 Deployment 清单。核心微服务在通用图表的 values.yaml 文件中重用了大部分配置,最小化了组件特定配置的需求,而其他组件在其自己的 values.yaml 文件中覆盖了这些值中的更多。

要查看为核心组件生成的 Deployment 清单,请为 product 微服务运行以下命令:

cd $BOOK_HOME/Chapter16/kubernetes/helm
helm dependency update components/product
helm template components/product -s templates/deployment.yaml 

要查看为覆盖通用图表设置的组件生成的 Deployment 清单,请为 MongoDB 组件运行以下命令:

cd $BOOK_HOME/Chapter16/kubernetes/helm
helm dependency update components/mongodb
helm template components/mongodb -s templates/deployment.yaml 

期望 helm template 命令的输出如下:

---
# Source: mongodb/templates/deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mongodb
  labels:
    app.kubernetes.io/name: mongodb
    helm.sh/chart: mongodb-1.0.0
    app.kubernetes.io/managed-by: Helm
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: mongodb
  template:
    metadata:
      labels:
        app.kubernetes.io/name: mongodb
    spec:
      containers:
        - name: mongodb
          image: "registry.hub.docker.com/library/mongo:6.0.4"
          imagePullPolicy: IfNotPresent
          ports:
            - containerPort: 27017
          resources:
            limits:
              memory: 350Mi 

这完成了对通用图表中可重用命名模板的浏览。文件可以在文件夹 $BOOK_HOME/Chapter16/kubernetes/helm/common 中找到。

接下来,让我们看看如何定义特定组件的图表。

组件图表

微服务图表和资源管理器图表存储在 components 文件夹中,它们都共享相同的文件结构:

  • Chart.yaml 表达了对 common 库图表的依赖。

  • template 文件夹包含两个模板,deployment.yamlService.yaml。这两个模板都应用来自通用图表的相应命名模板。例如,Service.yaml 模板看起来像这样:

    {{- template "common.service" . -}} 
    
  • values.yaml 文件包含特定于微服务的设置。例如,auth-server 图表的 values 文件看起来像这样:

    fullnameOverride: auth-server
    image:
      name: auth-server
    env:
      SPRING_PROFILES_ACTIVE: "docker"
    livenessProbe_enabled: true
    readinessProbe_enabled: true 
    

auth-server 只需要声明其名称、Docker 镜像、Spring 配置文件,以及它希望使用默认的 liveness 和 readiness 探针配置。

配置服务器与其他图表不同,因为它使用 ConfigMap 来存储包含所有其他微服务配置文件的 config-repo。在其 template 文件夹中,它定义了一个基于我们之前已介绍过的通用图表中 ConfigMaps 命名模板的模板:

{{- template "common.configmap_from_file" . -}} 

模板期望在图表文件夹 config-repo 中找到属性文件。为了避免从 $BOOK_HOME/Chapter16/config-repo 复制 config-repo,已使用命令创建了一个软链接,也称为符号链接

cd $BOOK_HOME/Chapter16/kubernetes/helm/components/config-server
ln -s ../../../../config-repo config-repo 

由于 Git 保留软链接,您不需要重新创建软链接 - git clone 命令会为您创建它!

如在通用图表的说明中已提到的,网关服务与其他微服务不同,因为它需要暴露类型为 NodePort 的服务。

除了微服务的图表之外,components 文件夹还包含我们使用的数据库、消息代理和 Zipkin 服务器的图表。它们的结构方式与微服务相同。由于通用模板已被设计用于简化微服务的图表,因此与其他图表相比,其他图表需要在 values.yaml 文件中覆盖更多的默认值。有关更多详细信息,请查看以下文件夹中的 values.yaml 文件:mongodbmysqlrabbitmqzipkin-server

环境图表

最后,environments 文件夹中的 dev-envprod-env 图表将所有内容结合起来,以完成典型开发/测试或预发布/生产环境的安装包。它们的 Charts.yaml 文件包含对 common 图表和 components 文件夹中图表的依赖,而 template 文件夹包含一个 secrets.yaml 模板,用于创建特定环境的凭据作为密钥。它基于通用图表中密钥的命名模板,看起来如下:

{{- template "common.secrets" . -}} 

查看 dev-env 图表的 values.yaml 文件,我们可以找到为 config-server-secrets 密钥定义的以下密钥值:

secrets:
  config-server-secrets:
    ENCRYPT_KEY: my-very-secure-encrypt-key
    SPRING_SECURITY_USER_NAME: dev-usr
    SPRING_SECURITY_USER_PASSWORD: dev-pwd 

这将导致 config-server-secrets 密钥包含三个密钥值,全部为 Base64 编码。其清单看起来如下:

apiVersion: v1
kind: Secret
metadata:
  name: config-server-secrets
  labels:
    ...
type: Opaque
data:
  ENCRYPT_KEY: bXktdmVyeS1zZWN1cmUtZW5jcnlwdC1rZXk=
  SPRING_SECURITY_USER_NAME: ZGV2LXVzcg==
  SPRING_SECURITY_USER_PASSWORD: ZGV2LXB3ZA== 

注意,此 values.yaml 文件包含敏感信息,例如配置服务器使用的加密密钥和访问配置服务器的密码。此文件必须安全存储。如果不适于安全存储此文件,则另一种选择是在执行 helm install 命令时从该文件中删除敏感信息并提供。

要在配置服务器的部署清单中使用密钥,dev-env 图表的 values.yaml 文件中定义了以下内容:

config-server:
  envFromSecretRefs:
    - config-server-secrets 

这将由上面描述的部署模板使用,以在配置服务器的部署清单中将密钥作为环境变量添加。

prod-env 图表覆盖的值比 dev-env 图表更多。例如,prod-env 图表中的 values.yaml 文件指定应使用额外的 Spring 配置文件 prod,以及 Docker 镜像的版本。对于 product 微服务,这看起来如下:

product:
  image:
    tag: v1
  env:
    SPRING_PROFILES_ACTIVE: "docker,prod" 

通过介绍各种类型图表包含的内容,让我们继续前进,并使用我们学到的 Helm 命令将微服务部署到 Kubernetes 中!

将应用程序部署到 Kubernetes 进行开发和测试

在本节中,我们将部署用于开发和技术活动的微服务环境,例如,系统集成测试。此类环境主要用于功能测试,因此配置为使用最少的系统资源和微服务 Docker 镜像的最新版本。

为了能够运行功能测试,我们将部署微服务及其所需的资源管理器在同一命名空间中,我们将称之为 hands-on。这使得设置测试环境变得容易,一旦我们完成测试,也可以轻松删除。我们可以简单地删除命名空间来移除测试环境使用的所有资源。

以下图表说明了此部署场景:

图描述自动生成

图 16.2:在开发环境中的微服务相同 Kubernetes 命名空间中部署的资源管理器

在我们可以部署系统景观之前,我们需要构建我们的 Docker 镜像并解决 Helm 图表的依赖项。

构建 Docker 镜像

通常,我们必须将镜像推送到 Docker 仓库并配置 Kubernetes 从仓库拉取镜像。在我们的案例中,由于我们有一个本地单节点集群,我们可以通过将 Docker 客户端指向 Minikube 中的 Docker 引擎,然后运行 docker-compose build 命令来简化此过程。这将导致 Docker 镜像立即对 Kubernetes 可用。对于开发,我们将使用 latest 作为微服务的 Docker 镜像版本。

您可以从源构建 Docker 镜像,如下所示:

cd $BOOK_HOME/Chapter16
./gradlew build
eval $(minikube docker-env)
docker-compose build 

eval $(minikube docker-env) 命令将本地 Docker 客户端指向 Minikube 中的 Docker 引擎。

docker-compose.yml 文件已更新,以指定它构建的 Docker 镜像的名称。例如,对于 product 服务,我们有以下内容:

 product:
    build: microservices/product-service
    image: hands-on/product-service 

latest 是 Docker 镜像名称的默认标签,因此无需指定。

Docker 镜像构建完成后,是时候构建 Helm 图表了。

解决 Helm 图表依赖项

首先,我们更新 components 文件夹中的依赖项:

for f in kubernetes/helm/components/*; do helm dep up $f; done 

接下来,我们更新 environments 文件夹中的依赖项:

for f in kubernetes/helm/environments/*; do helm dep up $f; done 

最后,我们验证 dev-env 文件夹的依赖项看起来良好:

helm dep ls kubernetes/helm/environments/dev-env/ 

预期命令会响应如下:

图 16.3:已解决的 Helm 图表依赖项

在构建了 Docker 镜像并解决了 Helm 依赖项后,我们可以开始部署到 Kubernetes!

部署到 Kubernetes

将系统部署到 Kubernetes 意味着创建或更新 Kubernetes 对象。我们将按照以下步骤使用 Helm 进行部署:

  1. 为了避免由于 Kubernetes 下载 Docker 镜像(可能引起我们之前描述的存活探针重启我们的 Pod)而导致的缓慢部署过程,请运行以下 docker pull 命令预先下载镜像:

    eval $(minikube docker-env)
    docker pull mysql:8.0.32 
    docker pull mongo:6.0.4
    docker pull rabbitmq:3.11.8-management
    docker pull openzipkin/zipkin:2.24.0 
    
  2. 在使用 Helm 图表之前,使用helm template命令渲染模板,以查看清单将是什么样子:

    helm template kubernetes/helm/environments/dev-env 
    

    注意,这里没有与 Kubernetes 集群进行交互,因此集群信息将被伪造,并且不会运行测试来验证渲染的清单是否会被集群接受。

  3. 要验证 Kubernetes 集群实际上会接受渲染的清单,可以通过将–-dry-run传递给helm install命令来执行安装的dry run。传递--debug标志也会显示 Helm 在渲染清单时将使用哪些用户提供的和计算出的值。运行以下命令以执行 dry run:

    helm install --dry-run --debug hands-on-dev-env \
     kubernetes/helm/environments/dev-env 
    
  4. 要启动整个系统景观的部署,包括创建命名空间、hands-on,请运行以下命令:

    helm install hands-on-dev-env \
      kubernetes/helm/environments/dev-env \
      -n hands-on \
      --create-namespace 
    

    注意,这里是 Helm 机制开始发挥作用的地方。它将使用我们在上面介绍 Helm部分中介绍的图表来渲染和应用 Kubernetes 清单,从而创建部署所需的 Kubernetes 对象。

  5. 将新创建的命名空间设置为kubectl的默认命名空间:

    kubectl config set-context $(kubectl config current-context) --namespace=hands-on 
    
  6. 要查看 Pod 启动,请运行以下命令:

    kubectl get pods --watch 
    

    此命令将连续报告新 Pod 的状态为运行中,如果出现问题,它将报告状态,例如错误CrashLoopBackOff。过了一会儿,你可能会看到网关产品组合zipkin-server Pod 报告了错误。这是因为它们都需要在启动时访问外部资源。如果没有,它们将会崩溃。网关和产品组合服务依赖于认证服务器,而 Zipkin 服务器依赖于对 RabbitMQ 的访问。通常,它们启动速度比它们依赖的资源快,导致这种情况。然而,Kubernetes 会检测到崩溃的 Pod,并将它们重启。一旦资源启动并运行,所有 Pod 都将启动并报告为就绪,READY列显示为1/1。命令的示例输出如下:

    包含文本的图片  自动生成的描述

    图 16.4:Pod 在外部依赖就绪之前重启

    在看到一些如上所示的输出后,使用Ctrl+C中断命令。

    1. 使用以下命令等待命名空间中的所有 Pod 就绪:

      kubectl wait --timeout=600s --for=condition=ready pod --all 
      

    预期命令将响应 11 行日志,如pod/... condition met,其中三个点(...)将被报告为就绪的实际 Pod 名称所替换。

  7. 要查看使用的 Docker 镜像,请运行以下命令:

    kubectl get pods -o json | jq .items[].spec.containers[].image 
    

响应应如下所示:

文本、聊天或文本消息  自动生成的描述

图 16.5:测试环境中使用的 Docker 镜像

注意,微服务的 Docker 镜像的版本标签设置为latest

我们现在准备测试我们的部署!然而,在我们能够这样做之前,我们需要通过测试脚本中所需的更改,以便与 Kubernetes 一起使用。

用于 Kubernetes 的测试脚本更改

为了验证部署,我们将像往常一样运行测试脚本,test-em-all.bash。为了与 Kubernetes 一起工作,断路器测试已经略有修改。断路器测试在 product-composite 服务上调用 actuator 端点来检查其健康状态并获取访问断路器事件的权限。由于此端点未对外暴露,前几章使用了 docker-compose exec 命令在 product-composite 服务内部运行 curl 命令以执行测试。

从本章开始,测试脚本可以使用 docker-compose exec 命令或相应的 kubectl 命令,kubectl exec,这取决于我们是否使用 Docker Compose 或 Kubernetes 运行微服务。

要知道使用哪个命令,脚本中已添加了一个新参数,USE_K8S。它默认为 false。有关详细信息,请参阅测试脚本中的 testCircuitBreaker() 函数。

测试部署

在启动测试脚本时,我们必须提供运行 Kubernetes 的主机地址,即我们的 Minikube 实例,以及我们的网关服务监听外部请求的 NodePort。网关可以通过端口 30443 访问。如 第十五章 中所述,由于我们使用 Minikube 的 docker 驱动程序,主机名始终是 localhost。由于主机名与使用 Docker Compose 运行测试时相同,我们不需要指定它;只需指定端口,以及 USE_K8S 参数即可。

使用以下命令开始测试:

PORT=30443 USE_K8S=true ./test-em-all.bash 

在脚本的输出中,我们可以看到 NodePort 的使用情况,但除此之外,一切看起来都和我们在前几章中使用 Docker Compose 时的样子一样:

文本描述自动生成

图 16.6:系统景观自动化测试的输出

在系统景观验证完成后,让我们看看我们如何测试 Spring Boot 的新功能,优雅关闭以及存活性和就绪性探测。

测试 Spring Boot 对优雅关闭和存活性及就绪性探测的支持

在本节中,我们将测试新的 Spring Boot 功能,并查看它们如何与其他 Kubernetes 组件交互。

让我们先来测试 Spring Boot 对优雅关闭的支持,在这个阶段,应用程序将等待一个可配置的时间长度,以完成活跃请求。记住,在关闭阶段不允许新的请求。

要测试优雅关闭机制,我们将运行一个持续向复合服务发送请求的客户端。首先,我们将使用它发送需要 5 秒的请求,这比关闭等待期短。等待期配置为 10 秒。然后,我们将使用它发送需要更长时间的请求,15 秒,以查看它们是如何处理的。作为测试客户端,我们将使用 Siege,这是一个基于命令行的负载测试工具。

为了能够测试运行这种长时间完成的请求,我们需要暂时增加 product-composite 服务的超时。否则,其断路器将启动并阻止我们运行长时间请求。

要在复合服务中增加超时,请执行以下步骤:

  1. values 文件中 dev-envproduct-composite 部分添加以下内容,对于 dev-envkubernetes/helm/environments/dev-env/values.yaml

     env:
        RESILIENCE4J_TIMELIMITER_INSTANCES_PRODUCT_TIMEOUTDURATION: 20s 
    

    更改后,配置文件应如下所示:

    product-composite:
      env:
        RESILIENCE4J_TIMELIMITER_INSTANCES_PRODUCT_TIMEOUTDURATION: 20s
      envFromSecretRefs:
        - config-client-credentials 
    

只要此设置处于活动状态,test-em-all.bash 中的断路器测试将不再工作,因为它们假设超时为 2 秒。

  1. 使用 Helm 的 upgrade 命令更新 Helm 安装,使用 --wait 标志以确保在命令终止时更新完成:

    helm upgrade hands-on-dev-env -n hands-on \
      kubernetes/helm/environments/dev-env --wait 
    

现在,我们可以运行测试,按照以下步骤使用短于关闭等待期的请求进行测试:

  1. 获取访问令牌:

    ACCESS_TOKEN=$(curl -d grant_type=client_credentials \
     -ks https://writer:secret-writer@localhost:30443/oauth2/token \
     -d scope="product:read product:write" \
     | jq .access_token -r) 
    

    通过运行命令 echo $ACCESS_TOKEN 确保你获得了访问令牌。如果它是空的,你必须检查上面的 curl 命令和网关以及身份验证服务器的日志。

  2. 发送测试请求并使用 delay 查询参数请求 5 秒的延迟:

    time curl -kH "Authorization: Bearer $ACCESS_TOKEN" \
      https://localhost:30443/product-composite/1?delay=5 
    

    如果你得到正常响应,并且 time 命令报告了 5 秒的响应时间,增加超时的配置更改已生效!

  3. 使用 Siege 启动需要 5 秒才能完成的请求,有五个并发用户发送请求,请求之间有 0 到 2 秒的随机延迟,以稍微分散请求:

    siege -c5 -d2 -v -H "Authorization: Bearer $ACCESS_TOKEN" \
      https://localhost:30443/product-composite/1?delay=5 
    

    对于每个完成的请求,工具的输出应如下所示:

    HTTP/1.1 200 5.04 secs: 771 bytes ==> GET /product-composite/1?delay=5 
    
  4. 使用以下命令在单独的终端窗口中监视 product 服务的日志输出:

    kubectl logs -f --tail=0 -l app.kubernetes.io/name=product 
    
  5. 现在,我们将要求 Kubernetes 重新启动 product 部署。重启将首先启动一个新的 Pod,然后关闭旧的 Pod,这意味着 Siege 发送的任何请求都不应受到重启的影响。特别关注的是,当旧 Pod 开始关闭时,它处理的少量请求。如果优雅关闭按预期工作,则不应有任何活跃请求失败。通过在单独的窗口中运行以下命令来执行重启:

    kubectl rollout restart deploy/product 
    
  6. 确保从负载测试工具 Siege 的输出中只报告成功的请求,显示 200 (OK)

  7. 在已停止的product Pod 的日志输出中,你应该看到在应用程序停止之前,所有请求都被允许优雅地终止。期望看到如下日志输出,在日志输出的末尾:

文本描述自动生成

图 16.7:所有请求都允许完成的优雅关闭

具体来说,注意两个日志消息之间的时间(在这种情况下为 4 秒),这表明关闭程序实际上等待最后一个请求完成。

现在让我们运行第二个测试,请求完成所需时间比关闭等待期更长:

  1. 重新启动 Siege,请求更长的响应时间,超过 10 秒的等待限制。启动五个并发用户,请求 15 秒的响应时间和请求之间的 0-5 秒随机延迟。使用Ctrl+C停止 Siege 并运行以下命令:

    siege -c5 -d5 -v -H "Authorization: Bearer $ACCESS_TOKEN" \
      https://localhost:30443/product-composite/1?delay=15 
    
  2. 使用以下命令监视product Pod 的日志输出:

    kubectl logs -f --tail=0 -l app.kubernetes.io/name=product 
    
  3. 重新启动product部署:

    kubectl rollout restart deploy/product 
    
  4. 跟踪product Pod 的日志输出。一旦它关闭,你应该能够看到在应用程序停止之前,并非所有请求都被允许优雅地终止。期望看到如下日志输出,在日志输出的末尾:文本描述自动生成

    图 16.8:某些长时间运行的请求被终止的优雅关闭

    日志消息优雅关闭因一个或多个请求仍然活跃而被终止表明在应用程序停止之前至少有一个请求没有被允许完成。

  5. 在负载测试工具 Siege 的输出中,现在应该出现一个或几个失败的请求报告500(内部服务器错误),如下所示:

计算机屏幕截图  描述自动生成,中等置信度

图 16.9:在关闭期间长时间运行的请求失败

这证明了关闭程序在配置的等待时间之后如何进行,以及剩余的长时间运行请求如预期的那样被终止。

这完成了 Spring Boot 优雅关闭机制的测试,这显然有助于避免正常客户端请求受到 Pod 停止的影响,例如,由于缩放或滚动升级执行的结果。

清理测试后的环境:

  1. 使用Ctrl+C停止 Siege 负载测试工具。

  2. 回滚最新的 Helm 发布以去除增加的超时:

    helm rollback hands-on-dev-env -n hands-on --wait 
    

helm rollback命令也很有用,可以回滚失败的升级。

  1. 还需要在文件kubernetes/helm/environments/dev-env/values.yaml中移除增加的超时设置。

  2. 运行test-em-all.bash以验证配置已回滚:

    PORT=30443 USE_K8S=true ./test-em-all.bash 
    

最后,让我们看看 Spring Boot 的存活和就绪探针报告了什么信息。我们将使用product服务,但也可以尝试其他服务的探针:

  1. 运行以下命令以获取product服务的存活探针输出:

    kubectl exec -it deploy/product -- \
      curl localhost/actuator/health/liveness -s | jq . 
    

    预期它会这样响应:

图形用户界面,文本,应用程序,自动生成描述

图 16.10:存活性探测的响应

  1. 运行以下命令以获取product服务就绪性探测的输出:

    kubectl exec -it deploy/product -- \
      curl localhost/actuator/health/readiness -s | jq . 
    

预期其响应会更加详细:

计算机屏幕截图,自动生成描述,中等置信度

图 16.11:就绪性探测的响应

从上面的输出中,我们可以确认现在product的可用性取决于其访问 MongoDB 和 RabbitMQ。这是预期的,因为我们配置了就绪健康组以包括 RabbitMQ、MongoDB 和 SQL 数据库的健康指标,如果可用的话。如果需要,请参阅使用 Spring Boot 对优雅关闭和存活性及就绪性探测的支持部分进行回顾。

在我们继续之前,让我们清理一下在开发环境中安装的内容。我们可以通过简单地删除命名空间来完成这项工作。删除命名空间将递归地删除命名空间中存在的资源,包括关于 Helm 安装的信息。

使用以下命令删除命名空间:

kubectl delete namespace hands-on 

如果你只想卸载helm install命令安装的内容,你可以运行命令helm uninstall hands-on-dev-env

在移除开发环境后,我们可以继续前进,并设置一个针对预发布和生产的开发环境。

将应用程序部署到 Kubernetes 进行预发布和发布

在本节中,我们将部署用于预发布和生产使用的微服务环境。预发布环境用于在将新版本投入生产之前执行质量保证QA)和用户验收测试UATs)。为了能够验证新版本不仅满足功能需求,还满足非功能需求,例如性能、健壮性、可扩展性和弹性,预发布环境被配置得尽可能接近生产环境。

将应用程序部署到预发布或生产环境时,与开发或测试部署相比,需要做出一些更改:

  • 资源管理器应在 Kubernetes 集群外部运行:从技术上讲,可以在 Kubernetes 上作为有状态容器运行数据库和队列管理器以供生产使用,使用StatefulSetsPersistentVolumes。在撰写本文时,我建议不要这样做,主要是因为 Kubernetes 中对有状态容器的支持相对较新且未经证实。相反,我建议使用现有的数据库和队列管理器本地或作为云中的托管服务,让 Kubernetes 做它最擅长的事情:运行无状态容器。就本书的范围而言,为了模拟生产环境,我们将在 Kubernetes 外部作为普通 Docker 容器运行 MySQL、MongoDB 和 RabbitMQ,使用已存在的 Docker Compose 文件。

  • 锁定

    • 由于安全原因,像actuator端点和日志级别这样的东西在生产环境中需要受到限制。

    • 从安全角度也应该审查外部暴露的端点。例如,在生产环境中,可能需要锁定对配置服务器的访问,但为了方便,我们将在本书中保持其暴露。

    • 必须指定 Docker 镜像标签,以便能够跟踪已部署的微服务的版本。

  • 扩大可用资源:为了满足高可用性和更高负载的需求,我们需要每个 Deployment 运行至少两个 Pod。我们可能还需要增加每个 Pod 允许使用的内存和 CPU 的数量。为了避免在 Minikube 实例中内存不足,我们将每个 Deployment 运行一个 Pod,但在生产环境中增加允许的最大内存。

  • 设置一个生产就绪的 Kubernetes 集群:这超出了本书的范围,但如果可行,我建议使用主要云提供商提供的托管 Kubernetes 服务之一。就本书的范围而言,我们将部署到我们的本地 Minikube 实例。

这并不是在设置生产环境时必须考虑的所有事情的详尽列表,但这是一个良好的开始。

我们的模拟生产环境将如下所示:

图描述自动生成

图 16.12:部署在 Kubernetes 外部的资源管理器

源代码中的更改

对源代码进行了以下更改,以准备在用于预演和生产的环境中进行部署:

  • config-repo配置存储库中的配置文件中添加了一个名为prod的 Spring 配置文件:

    spring.config.activate.on-profile: prod 
    
  • prod配置文件中,已添加以下内容:

    • 运行作为普通 Docker 容器的资源管理器 URL:

      spring.rabbitmq.host: 172.17.0.1
      spring.data.mongodb.host: 172.17.0.1
      spring.datasource.url: jdbc:mysql://172.17.0.1:3306/review-db 
      

我们使用172.17.0.1 IP 地址来在 Minikube 实例中定位 Docker 引擎。这是使用 Minikube 创建 Docker 引擎时的默认 IP 地址,至少对于版本 1.18 以下的 Minikube 来说是这样。

正在进行工作,以建立一个标准 DNS 名称,供容器在需要访问其运行的 Docker 主机时使用,但在撰写本文时,这项工作尚未完成。

  • 日志级别已设置为警告或更高,即错误或致命。例如:

    logging.level.root: WARN 
    
  • 在 HTTP 上公开的唯一 actuator 端点是 infohealth 端点,这些端点用于 Kubernetes 中的存活和就绪探测,以及由测试脚本 test-em-all.bash 使用的 circuitbreakerevents 端点:

    management.endpoints.web.exposure.include: health,info,circuitbreakerevents 
    

在现实世界的生产环境中,我们还应该将 imagePullPolicy: Never 设置更改为 IfNotPresent,以便从 Docker 仓库下载 Docker 镜像。然而,由于我们将部署生产设置到我们手动构建和标记 Docker 镜像的 Minikube 实例中,我们将不会更新此设置。

部署到 Kubernetes

为了模拟使用生产级资源管理器,MySQL、MongoDB 和 RabbitMQ 将使用 Docker Compose 在 Kubernetes 外部运行。我们将像前几章那样启动它们:

eval $(minikube docker-env)
docker-compose up -d mongodb mysql rabbitmq 

我们还需要使用以下命令对现有的 Docker 镜像进行 v1 标记:

docker tag hands-on/auth-server hands-on/auth-server:v1
docker tag hands-on/config-server hands-on/config-server:v1
docker tag hands-on/gateway hands-on/gateway:v1 
docker tag hands-on/product-composite-service hands-on/product-composite-service:v1 
docker tag hands-on/product-service hands-on/product-service:v1
docker tag hands-on/recommendation-service hands-on/recommendation-service:v1
docker tag hands-on/review-service hands-on/review-service:v1 

从这里开始,命令与我们在开发环境中部署的方式非常相似:

  1. 使用 Helm 部署:

    helm install hands-on-prod-env \ kubernetes/helm/environments/prod-env \
    -n hands-on --create-namespace 
    
  2. 等待部署正常运行:

    kubectl wait --timeout=600s --for=condition=ready pod --all 
    
  3. 要查看当前生产环境中使用的 Docker 镜像,请运行以下命令:

    kubectl get pods -o json | jq .items[].spec.containers[].image 
    

    响应应该看起来像以下这样:

    文本描述自动生成

    图 16.13:生产环境中使用的 Docker 镜像

    注意 Docker 镜像的 v1 版本!

    还要注意,MySQL、MongoDB 和 RabbitMQ 的资源管理器 Pod 已消失;这些可以使用 docker-compose ps 命令找到。

  4. 运行测试脚本 test-em-all.bash 以验证模拟的生产环境:

    CONFIG_SERVER_USR=prod-usr \
    CONFIG_SERVER_PWD=prod-pwd \
    PORT=30443 USE_K8S=true ./test-em-all.bash 
    

预期与在开发环境中运行测试脚本时相同的输出类型。

这完成了测试;让我们清理一下,以便 Kubernetes 环境为下一章做好准备。

清理

要删除我们使用的资源,请运行以下命令:

  1. 删除命名空间:

    kubectl delete namespace hands-on 
    
  2. 关闭运行在 Kubernetes 外部的资源管理器:

    eval $(minikube docker-env)
    docker-compose down
    eval $(minikube docker-env -u) 
    

eval $(minikube docker-env -u) 命令指示本地 Docker 客户端与本地 Docker 引擎通信,而不再与 Minikube 中的 Docker 引擎通信。

如本章前面所述,kubectl delete namespace 命令将递归删除命名空间中存在的所有 Kubernetes 资源,而 docker-compose down 命令将停止 MySQL、MongoDB 和 RabbitMQ。随着生产环境的移除,我们已到达本章的结尾。

摘要

在本章中,我们学习了如何使用 Helm 在 Kubernetes 上部署本书中的微服务。我们看到了如何使用 Helm 创建可重用的模板,从而最小化创建 Kubernetes 清单所需的样板代码。可重用模板存储在公共图表中,而特定于微服务的图表提供每个微服务特有的值。在顶层,我们有父图表,描述了如何使用微服务图表部署开发/测试和阶段/生产环境,可选地还可以与数据库和队列管理器等资源管理器的图表一起部署。

我们还看到了如何利用 Spring Boot 功能来简化部署到 Kubernetes 的过程。Spring Boot 对优雅关闭的支持可以在停止基于 Spring Boot 的微服务之前允许活动请求完成,例如,在滚动升级期间。对存活性和就绪性探测的支持使得声明对特定微服务所依赖的外部资源可用性的探测变得容易。

最后,为了能够在 Kubernetes 中部署我们的微服务,我们不得不将 Netflix Eureka 替换为 Kubernetes 内置的发现服务。更改发现服务没有对 Java 源代码进行任何更改——我们只需要对构建依赖项和一些配置进行更改。

在下一章中,我们将看到如何进一步利用 Kubernetes 来减少在 Kubernetes 中需要部署的支持服务的数量。前往下一章,了解我们如何消除对配置服务器的需求,以及我们的边缘服务器如何可以被 Kubernetes Ingress 控制器所替代。

问题

  1. 为什么我们在 Kubernetes 上部署时从微服务领域中移除了 Eureka 服务器?

  2. 我们用什么替换了 Eureka 服务器,以及这个变化如何影响了微服务的源代码?

  3. 存活性和就绪性探测的目的是什么?

  4. Spring Boot 的优雅关闭机制有什么用?

  5. 以下 Helm 模板指令的目的是什么?

    {{- $common := dict "Values" .Values.common -}} 
    {{- $noCommon := omit .Values "common" -}} 
    {{- $overrides := dict "Values" $noCommon -}} 
    {{- $noValues := omit . "Values" -}} 
    {{- with merge $noValues $overrides $common -}} 
    
  6. 为什么以下命名的 Helm 模板会失败?

    {{- define "common.secrets" -}}
    {{- range $secretName, $secretMap := .Values.secrets }}
    apiVersion: v1
    kind: Secret
    metadata:
      name: {{ $secretName }}
      labels:
        app.kubernetes.io/name: {{ $secretName }}
    type: Opaque
    data:
    {{- range $key, $val := $secretMap }}
      {{ $key }}: {{ $val | b64enc }}
    {{- end }}
    {{- end -}}
    {{- end -}} 
    
  7. 为什么以下清单不能一起工作?

    apiVersion: v1
    kind: Service
    metadata:
      name: review
      labels:
        app.kubernetes.io/name: review
    spec:
      type: ClusterIP
      ports:
        - name: http
          port: 80
          protocol: TCP
          targetPort: http
      selector:
        app.kubernetes.io/pod-name: review
    ---
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      name: review
      labels:
        app.kubernetes.io/name: review
    spec:
      replicas: 1
      selector:
        matchLabels:
          app.kubernetes.io/name: review
      template:
        metadata:
          labels:
            app.kubernetes.io/name: review
        spec:
          containers:
            - name: review
              image: "hands-on/review-service:latest"
              ports:
                - containerPort: 80
                  name: http-port
                  protocol: TCP 
    

第十七章:实施 Kubernetes 功能以简化系统景观

当前的微服务景观包含几个支持服务,这些服务实现了在大规模微服务景观中所需的重要设计模式,例如,边缘服务器、配置服务器、授权服务器以及分布式跟踪服务。为了回顾,请参阅 第一章微服务简介。在前一章中,我们将基于 Netflix Eureka 的服务发现模式实现替换为 Kubernetes 内置的发现服务。在本章中,我们将通过减少需要部署的支持服务数量来进一步简化微服务景观。相反,相应的设计模式将由 Kubernetes 的内置功能处理。Spring Cloud Config Server 将被 Kubernetes ConfigMaps 和 Secrets 替换。Spring Cloud Gateway 将被 Kubernetes Ingress 对象替换,它可以像 Spring Cloud Gateway 一样充当边缘服务器。

第十一章API 访问安全 中,我们介绍了使用证书来保护外部 API。证书是手动配置的,这既耗时又容易出错,尤其是在记得在证书过期前旋转证书时。在本章中,我们将了解 cert-manager 以及如何使用它来自动化创建、配置和旋转证书的过程。

当一个平台如 Kubernetes 上的功能越来越多时,确保微服务的源代码不依赖于该平台是很重要的。为了确保我们可以在不部署到 Kubernetes 的情况下仍然使用微服务,我们将通过使用 Docker Compose 部署微服务景观,并执行 test-em-all.bash 测试脚本以验证微服务在功能层面上仍然可以正常工作,而不使用 Kubernetes。

本章将涵盖以下主题:

  • 用 Kubernetes ConfigMaps 和 Secrets 替换 Spring Cloud Config Server

  • 用 Kubernetes Ingress 对象替换 Spring Cloud Gateway

  • 使用 cert-manager 自动配置证书

  • 在 Kubernetes 上部署和测试微服务景观

  • 使用 Docker Compose 部署和测试微服务景观,以确保微服务的源代码不会被锁定在 Kubernetes 中

技术要求

关于如何安装本书中使用的工具以及如何访问本书的源代码的说明,请参阅:

  • 第二十一章macOS 的安装说明

  • 第二十二章使用 WSL 2 和 Ubuntu 的 Microsoft Windows 安装说明

本章中的代码示例均来自 $BOOK_HOME/Chapter17 的源代码。

如果你想查看本章源代码中应用的变化,即查看用 Kubernetes 中相应的功能替换 Spring Cloud Config Server 和 Spring Cloud Gateway 以及使用 cert-manager 部署证书的过程,你可以将其与 第十六章将我们的微服务部署到 Kubernetes 中的源代码进行比较。你可以使用你喜欢的 diff 工具并比较两个文件夹,$BOOK_HOME/Chapter16$BOOK_HOME/Chapter17

替换 Spring Cloud Config Server

正如我们在上一章所看到的,ConfigMaps 和 Secrets 可以用来保存我们微服务的配置信息。Spring Cloud Config Server 添加了诸如将所有配置保存在一个地方、可选的 Git 版本控制以及能够在磁盘上加密敏感信息等功能。但它也消耗了相当数量的内存(就像任何基于 Java 和 Spring 的应用程序一样)并在启动时增加了显著的开销。

例如,当运行自动化集成测试,例如本书中使用的测试脚本 test-em-all.bash 时,所有微服务都会同时启动,包括配置服务器。由于其他微服务必须从配置服务器获取配置,因此它们都必须等待配置服务器启动并运行后才能启动自己。这导致在运行集成测试时出现显著延迟。如果我们使用 Kubernetes ConfigMaps 和 Secrets 代替,这种延迟就会消除,使自动化集成测试运行得更快。因此,当底层平台不提供类似功能时,使用 Spring Cloud Config Server 是有意义的,但在部署到 Kubernetes 时,最好使用 ConfigMaps 和 Secrets。

使用 Kubernetes ConfigMaps 和 Secrets 而不是 Spring Cloud Config Server 将使微服务景观启动更快,这意味着它将需要更少的内存。它还将通过消除一个支持服务,即配置服务器,来简化微服务景观。当我们进行替换时,重要的是要以这种方式进行,即不影响微服务中的源代码,从而避免不必要的 Kubernetes 锁定。

这一更改在下图中得到了说明:

图描述自动生成

图 17.1:用 Kubernetes 内置的 ConfigMaps 和 Secrets 替换 Spring Cloud Config Server

让我们看看用 Kubernetes ConfigMaps 和 Secrets 替换 Spring Cloud Config Server 需要什么!

特别注意,我们只更改配置;Java 源代码中不需要任何更改!

替换 Spring Cloud Config Server 所需的更改

在源代码的配置中已应用以下更改以替换 Spring Cloud Config Server 为 Kubernetes ConfigMaps 和 Secrets:

  1. 我们已经移除了 spring-cloud/config-server 项目,并也从 settings.gradle 构建文件中移除了项目。

  2. 我们已经移除了配置服务器的 Helm 图表。

  3. 我们已经从 test-em-all.bash 测试脚本中移除了特定于配置服务器的测试。

  4. 我们已经从所有微服务中移除了以下配置:

    • build.gradle 构建文件中的 spring-cloud-starter-config 依赖项

    • 每个项目 src/main/resource 文件夹中的 application.yml 文件,用于连接到配置服务器

    • 由于不再需要,集成测试中的 spring.cloud.config.enabled=false 属性设置

  5. config-repo 文件夹中的配置文件更改:

    • 我们已经移除了包含敏感信息的属性,例如 MongoDB、MySQL、RabbitMQ 的凭证以及边缘服务器使用的 TLS 证书的密码。将使用 Kubernetes Secrets 来处理敏感信息。

    • 在边缘服务器的配置中已移除配置服务器 API 的路由。

  6. kubernetes/helm/components 中微服务 Helm 图表的更改:

    • 每个图表中已添加 config-repo 文件夹。在 Helm 图表的 config-repo 文件夹中创建了从公共 config-repo 文件夹所需的配置文件的软链接。对于每个微服务,已创建一个指向公共配置文件 application.yaml 和特定于微服务的配置文件软链接。

    关于如何创建软链接的回顾,请参阅第十六章 将我们的微服务部署到 Kubernetes 中的 组件图表 部分。

    • values.yaml 文件已更新如下:

      • 用于指出要使用哪些配置文件的 Spring 属性的环境变量。例如,对于 product 微服务,该属性看起来如下:

        SPRING_CONFIG_LOCATION: file:/config-repo/application.yml,file:/config-repo/product.yml 
        
      • 微服务将使用它来查找配置文件的 ConfigMap。ConfigMap 将在容器的 /config-repo 路径内提供。声明如下:

        configmap:
          enabled: true
          volumeMounts:
            mountPath: /config-repo 
        
      • 为了创建 ConfigMap,已添加一个基于 common 图表中的命名模板 common.configmap_from_file 的模板。

  7. kubernetes/helm/environments 中环境 Helm 图表的更改:

    • 我们已经移除了对配置服务器图表的依赖。

    • values.yaml 文件已更新。

    • 配置服务器及其客户端的 Secrets 已替换为资源管理器、MongoDB、MySQL 和 RabbitMQ 及其客户端的 Secrets。例如:

       rabbitmq-zipkin-credentials:
          RABBIT_USER: rabbit-user-dev
          RABBIT_PASSWORD: rabbit-pwd-dev
        mongodb-credentials:
          SPRING_DATA_MONGODB_AUTHENTICATION_DATABASE: admin
          SPRING_DATA_MONGODB_USERNAME: mongodb-user-dev
          SPRING_DATA_MONGODB_PASSWORD: mongodb-pwd-dev 
      

    上一章的回顾:请注意,此 values.yaml 文件包含敏感信息,如上述示例中的密码。因此,必须安全地存储此文件。如果不适于安全存储此文件,则另一种选择是在执行 helm install 命令时提供敏感信息,并从该文件中移除敏感信息。

    • 每个组件都被分配了它所需的 Secrets。

    回顾上一章:Secrets 将被映射到每个 Pod 作为环境变量。

    例如,产品服务需要访问 MongoDB 和 RabbitMQ,因此分配了以下两个 Secrets:

    product:
      envFromSecretRefs:
        - rabbitmq-credentials
        - mongodb-credentials 
    

Helm 图表的values.yaml文件中的大多数更改最终都会出现在 Kubernetes 的Deployment对象清单中。例如,product微服务的Deployment对象将如下所示:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: product
spec:
  template:
    spec:
      containers:
        - name: product
          env:
          - name: SPRING_CONFIG_LOCATION
            value: file:/config-repo/application.yml,file:/config-repo/product.yml
          - name: SPRING_PROFILES_ACTIVE
            value: docker
          envFrom:
          - secretRef:
              name: rabbitmq-credentials
          - secretRef:
              name: mongodb-credentials
          volumeMounts:
          - name: product
            mountPath: /config-repo
      volumes:
        - name: product
          configMap:
            name: product 

注意,上述更改未影响的部分已被省略以提高可读性。

如果你想自己渲染组件的 Kubernetes 清单,可以通过在感兴趣的图表上应用 Helm 的template命令来实现。你还必须添加适用于template命令组件的环境values.yaml文件中的值。以product服务为例。dev-env图表的values.yaml文件包含以下适用于product服务的设置:

product:
  envFromSecretRefs:
    - rabbitmq-credentials
    - mongodb-credentials 

要将这些设置添加到template命令中,我们可以使用--set标志。

还有一个--values标志可以在执行命令时添加values.yaml文件。

当从环境图表添加值时,我们必须记住它们是组件图表的父图表。这意味着在直接应用于组件图表时,必须从设置中移除组件图表的名称。在这种情况下,这意味着我们应该将以下值添加到template命令中,以正确渲染product图表:

 envFromSecretRefs:
    - rabbitmq-credentials
    - mongodb-credentials 

可以使用--set标志定义如上所示的 YAML 数组,通过在大括号内列出元素,例如,"{a,b,c}"。可以使用以下命令渲染product图表:

helm template kubernetes/helm/components/product \
  --set envFromSecretRefs= \
    "{rabbitmq-credentials, mongodb-credentials}" 

结果将包含product微服务的清单、一个 ConfigMap、一个 Service 以及最后的Deployment对象。

这就是用 Kubernetes ConfigMaps 和 Secrets 替换配置服务器的需求。在下一节中,我们将了解如何用 Kubernetes Ingress 对象替换 Spring Cloud Gateway。

替换 Spring Cloud Gateway

在本节中,我们将通过使用 Kubernetes 内置的 Ingress 对象替换 Spring Cloud Gateway 来进一步简化微服务景观,减少需要部署的支持服务的数量。

如在第十五章Kubernetes 简介中所述,Ingress 对象可以在 Kubernetes 中用作边缘服务器,就像 Spring Cloud Gateway 一样。与 Ingress 对象相比,Spring Cloud Gateway 提供了更丰富的路由功能。然而,Ingress 是 Kubernetes 平台的一部分,无需额外部署,并且还可以使用 cert-manager 扩展来自动配置证书,正如我们将在本章后面看到的那样。

我们还使用 Spring Cloud Gateway 来保护我们的微服务免受未经身份验证的请求,通过要求从受信任的 OAuth 授权服务器或 OIDC 提供者获取有效的 OAuth 2.0/OIDC 访问令牌。如果需要回顾,请参阅第十一章“确保 API 访问安全”。通常,Kubernetes Ingress 对象不支持此功能。然而,Ingress 控制器的特定实现可能支持它。

最后,我们在第十章“使用 Spring Cloud Gateway 在边缘服务器后面隐藏微服务”中添加的复合健康检查可以被每个微服务部署清单中定义的 Kubernetes 存活和就绪探针所替代。

因此,与 Spring Cloud Config Server 一样,当底层平台不提供类似功能时,使用 Spring Cloud Gateway 是有意义的。当部署到 Kubernetes 时,最好使用 Ingress 对象。

在本章中,我们将验证请求是否包含有效访问令牌的责任委托给product-composite微服务。这是通过 Ingress 将包含访问令牌的 HTTP 头从请求转发到product-composite微服务来完成的,它将像前几章一样执行其 OAuth 访问令牌的验证。下一章将介绍服务网格的概念,我们将看到一种完全支持验证 JWT 编码 OAuth 访问令牌的 Ingress 的替代实现。

在“验证微服务在没有 Kubernetes 的情况下工作”部分,我们仍然会使用 Spring Cloud Gateway 与 Docker Compose 一起使用,因此我们不会删除项目。

以下图表显示,当部署到 Kubernetes 时,Spring Cloud Gateway 将从微服务景观中移除:

图形用户界面,图表,文本,应用程序  描述自动生成

图 17.2:用 Kubernetes 内置 Ingress 控制器替换 Spring Cloud Gateway

让我们看看用 Kubernetes Ingress 对象替换 Spring Cloud Gateway 需要什么!

特别注意,我们只更改了配置;也就是说,Java 源代码中不需要任何更改!

替换 Spring Cloud Gateway 所需的变化

以下更改已应用于源代码配置,以用 Kubernetes Ingress 对象替换 Spring Cloud Gateway:

  1. 我们已经删除了 Spring Cloud Gateway 的 Helm 图表。

  2. 我们在common图表中为 Ingress 清单添加了一个命名模板和一些默认值。

    命名模板kubernetes/helm/common/templates/_ingress.yaml以我们在前一章中熟悉的声明开始:

    {{- define "common.ingress" -}}
    {{- $common := dict "Values" .Values.common -}}
    {{- $noCommon := omit .Values "common" -}} 
    {{- $overrides := dict "Values" $noCommon -}} 
    {{- $noValues := omit . "Values" -}} 
    {{- with merge $noValues $overrides $common -}}
    apiVersion: networking.k8s.io/v1
    kind: Ingress
    metadata:
      name: {{ include "common.fullname" . }}
      labels:
        app.kubernetes.io/name: {{ include "common.name" . }}
        helm.sh/chart: {{ include "common.chart" . }}
        app.kubernetes.io/managed-by: {{ .Release.Service }}
    {{- with .Values.ingress.annotations }}
      annotations:
    {{ toYaml . | indent 4 }}
    {{- end }} 
    

    模板的名称是 common.ingress,并且 apiVersionkind 分别设置为 networking.k8s.io/v1Ingress,以标识这是一个 Ingress 清单。模板上方的其余部分与在其他需要覆盖参数的清单中看到的类似,例如 DeploymentService 模板。唯一的新部分是模板允许通过 values.yaml 文件中的 ingress.annotations 字段添加注释(如果需要的话)。

    Ingress 模板的其余部分包含清单的主要部分,即 spec 部分。它看起来像这样:

    spec:
      tls:
        - hosts:
            - {{ .Values.ingress.host | quote }}
          secretName: {{ .Values.ingress.tls.secretName }}
      rules:
        - host: {{ .Values.ingress.host | quote }}
          http:
            paths:
            {{- range .Values.ingress.paths }}
              - path: {{ .path }}
                pathType: Prefix
                backend:
                  service:
                    name: {{ .service }}
                    port:
                      name: http
            {{- end }}
    {{- end }}
    {{- end -}} 
    

    首先是 tls 部分,其中清单声明 Ingress 只接受 HTTPS 流量,并且接受的 hostname 将使用 values.yaml 文件中的 ingress.host 键指定。用于服务 HTTPS 请求的证书将存储在名为 values.yaml 文件中指定的 Secret 中,使用 ingress.tls.secretName 键。

    接下来是 rules 部分中声明的路由规则。首先是用于路由的主机名。这将与上面 tls 部分中的相同主机名相同。接下来是一系列路由。它们将使用 values.yaml 文件中的 ingress.paths 部分进行填充。每个条目包含一个 path 和请求该路径的 service 名称,该请求将被路由到该服务。每个服务都期望其端口的名称设置为 http

    common 图表的 values.yaml 文件为 Ingress 清单提供了以下默认值:

    ingress:
      annotations:
        cert-manager.io/issuer: selfsigned
      tls:
        secretName: tls-certificate 
    

    首先是针对 Ingress 对象声明的注释,cert-manager.io/issuer,表示 cert-manager 应该使用名为 selfsigned 的颁发者来管理此 Ingress 对象所需的证书。更多关于此内容将在下面的 自动化证书配置 部分中介绍。接下来是存储证书的 Secret,默认名称为 tls-certificate

  3. 我们已向环境图表 dev-envprod-env 添加了模板和额外的设置以用于 Ingress 清单。模板命名为 ingress.yml,基于上面描述的 common 图表的命名模板:

    {{- template "common.ingress" . -}} 
    
  4. 渲染 Ingress 清单所需的其余值,包括用于路由的实际 hostname 和路径,都在每个环境图表的 values.yaml 文件中指定。声明看起来像这样:

    ingress:
      host: minikube.me
      paths:
        - path: /oauth2
          service: auth-server
        - path: /login
          service: auth-server
        - path: /error
          service: auth-server
        - path: /product-composite
          service: product-composite
        - path: /actuator/health
          service: product-composite
        - path: /openapi
          service: product-composite
        - path: /webjars
          service: product-composite 
    

从配置中我们可以看到,我们将使用主机名 minikube.me,并且为 auth-server 定义了三个路由,而其余声明的路径将被路由到 product-composite 服务。

我们将在 使用 Kubernetes ConfigMaps、Secrets、Ingress 和 cert-manager 进行测试 部分中稍后注册主机名 minikube.me 到本地的 /etc/hosts 文件中。

上述更改将导致 Helm 渲染 Ingress 清单。由于 Ingress 模板仅由环境图表使用,我们需要渲染一个环境图表来查看 Ingress 清单。

运行以下命令以使用dev-env图表渲染清单:

helm template kubernetes/helm/environments/dev-env 

在输出中查找kind: Ingress,你将找到 Ingress 清单。它看起来像这样:

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: RELEASE-NAME-dev-env
  labels:
    app.kubernetes.io/name: dev-env
    helm.sh/chart: dev-env-1.0.0
    app.kubernetes.io/managed-by: Helm
  annotations:
    cert-manager.io/issuer: selfsigned
spec:
  tls:
    - hosts:
        - "minikube.me"
      secretName: tls-certificate
  rules:
    - host: "minikube.me"
      http:
        paths:
          - path: /oauth2
            pathType: Prefix
            backend:
              service:
                name: auth-server
                port:
                  name: http
          - path: /product-composite
            pathType: Prefix
            backend:
              service:
                name: product-composite
                port:
                  name: http
          - path: /actuator/health
            pathType: Prefix
            backend:
              service:
                name: product-composite
                port:
                  name: http 

注意,为了提高可读性,一些路由规则已被删除。

最后缺少的部分是包含证书的 Secret 是如何创建的;让我们接下来看看这个问题。

自动化证书配置

cert-manager 工具(cert-manager.io/docs/)是 Kubernetes 的证书管理控制器。

它可以简化证书的自动化创建、配置和轮换。它支持多个证书来源;例如:

要查看可用的发行者完整列表,请参阅cert-manager.io/docs/configuration/

由于自签名证书不需要与任何外部资源进行通信,因此在开发期间使用它们是一个很好的选择。我们将在本书的范围内使用它们。

在生产环境中使用 cert-manager 通常需要使用一个发行者,例如 Let’s Encrypt,它可以颁发客户端(例如,网络浏览器和外部系统)将信任的外部 API 的证书。

在 Kubernetes 集群中安装 cert-manager 之后,至少必须注册一个发行者。发行者可以是特定命名空间内的本地发行者,或者是在集群范围内可访问的。我们将使用在现有命名空间hands-on中注册的本地发行者。

将由环境图表dev-envprod-env负责注册适当的发行者。这两个环境都将使用自签名发行者。已添加一个名为_issuer.yaml的命名模板到common图表中。它看起来像这样:

{{- define "common.issuer" -}}
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
  name: selfsigned
spec:
  selfSigned: {}
{{- end -}} 

apiVersionkind字段指定这是一个由 cert-manager 定义的发行者。其名称设置为selfsigned。在上面的替换 Spring Cloud Gateway 所需更改部分中,我们看到了如何使用此名称来注释 Ingress 清单:

ingress:
  annotations:
    cert-manager.io/issuer: selfsigned
  tls:
    secretName: tls-certificate 

这就是让 cert-manager 启动并提供 Ingress 对象证书所需的所有内容。cert-manager 监听带有 cert-manager.io/issuer 注解的 Ingress 对象的注册,并开始使用注解值中引用的发行者颁发证书,在本例中为 selfsigned。cert-manager 工具将使用发行者创建证书并将其存储在以 Ingress 对象命名的 Secret 中。在我们的例子中,名称设置为 tls-certificate。还将创建一个具有相同名称的 Certificate 对象,其中包含管理信息,例如 cert-manager 何时需要更新证书。

由于名为 common.issuer 的模板不接受任何配置,要在 dev-envprod-env 图表中应用它,只需在每个图表中添加一个使用该命名模板的模板。该模板命名为 issuer.yaml,其外观如下:

{{- template "common.issuer" . -}} 

这样,我们就拥有了替换 Spring Cloud Config Server 和 Gateway 为本地 Kubernetes 组件和 cert-manager 所需的一切。让我们部署并运行一些测试!

使用 Kubernetes ConfigMaps、Secrets、Ingress 和 cert-manager 进行测试

在描述了前面的更改之后,我们已准备好使用 Spring Cloud Config Server 和 Spring Cloud Gateway 被 Kubernetes ConfigMaps、Secrets、Ingress 对象和 cert-manager 替换的系统景观进行测试。和之前一样,当我们使用 Spring Cloud Gateway 作为边缘服务器时,外部 API 将由 HTTPS 保护。在这个部署中,将使用由 cert-manager 提供的证书来保护外部 API 的 Ingress 控制器。这将在以下图中说明:

图 描述自动生成

图 17.3:使用 HTTPS 保护外部访问

Ingress 控制器在 Minikube 实例的默认 HTTPS 端口 443 上公开。在主机上,我们作为 Docker 容器运行 Minikube 实例时,我们通过 localhost 与 Minikube 实例通信。当创建 Minikube 实例时,已配置从 localhost8443 端口到 Minikube 实例的 443 端口的端口转发。我们在执行 minikube addons enable ingress 命令时安装了 Ingress 控制器。

关于 Minikube 实例设置的回顾,请参阅 第十五章Kubernetes 简介 中的 创建 Kubernetes 集群 部分。

这里有一个有趣的问题,Ingress 控制器是如何在 Minikube 实例上使用端口 443 的?我们看到了使用类型为 NodePort 的服务,它可以分配从 30000 开始的端口,那么 Ingress 控制器是如何使用标准的 HTTPS 端口 443 的呢?

Ingress 控制器由 ingress-nginx 命名空间中的 Deployment 对象 ingress-nginx-controller 组成。问题的答案是,Deployment 对象使用 hostPort 将 Kubernetes 主机中的端口 443 映射到 Pod 中运行的容器中的端口 443,即 Minikube 实例。Deployment 对象定义中的关键部分如下所示:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: ingress-nginx-controller
spec:
  template:
    spec:
      containers:
        image: registry.k8s.io/ingress-nginx/controller:v1.5.1
        ports:
        - containerPort: 443
          hostPort: 443 

此设置适用于用于开发和测试的单节点 Kubernetes 集群。在多节点 Kubernetes 集群中,使用外部负载均衡器来公开 Ingress 控制器,以实现高可用性和可伸缩性。

Deployment 对象使用的命令类型与我们用于 第十六章将我们的微服务部署到 Kubernetes 中使用的命令类型相同;请参阅 将应用程序部署到 Kubernetes 进行开发和测试 部分。在本节中,我们还将安装 cert-manager 并为主机名 minikube.me 添加 /etc/hosts 文件中的条目。

执行以下步骤以部署系统景观并验证其按预期工作:

  1. cert-manager 命名空间中安装 cert-manager 并等待部署完成。在安装 cert-manager 之前,我们需要添加其 Helm 仓库。运行以下命令:

    helm repo add jetstack https://charts.jetstack.io
    helm repo update
    helm install cert-manager jetstack/cert-manager \
      --create-namespace \
      --namespace cert-manager \
      --version v1.11.0 \
      --set installCRDs=true \
      --wait 
    

    cert-manager 工具还附带一组 Kubernetes 自定义资源定义CRDs),如上面介绍的 Issuer 对象。CRDs 用于 Kubernetes 以扩展其 API,即向其 API 添加新对象。上述命令中的 --set installCRDs=true 标志确保在安装 cert-manager 时安装这些对象定义。

    使用以下命令验证 cert-manager 命名空间中有三个 Pods 已就绪:

    kubectl get pods --namespace cert-manager 
    

    预期会有如下响应:

    文本描述自动生成

    图 17.4:cert-manager 命名空间中的 Pods

  2. 通过在 /etc/hosts 文件中添加一行,将 minikube.me 映射到我们可以用来访问 Minikube 实例的 IP 地址。运行以下命令:

    sudo bash -c "echo 127.0.0.1 minikube.me | tee -a /etc/hosts" 
    

    注意,sudo 命令可能会要求你输入密码。

    使用 cat /etc/hosts 命令验证结果。预期会有一行包含 127.0.0.1 minikube.me

    如果你的 /etc/hosts 文件中包含多个针对 minikube.me 的行(例如,来自之前的尝试),你需要手动删除旧的行。

  3. 你可以从源代码构建 Docker 镜像,如下所示:

    cd $BOOK_HOME/Chapter17
    eval $(minikube docker-env -u)
    ./gradlew build
    eval $(minikube docker-env)
    docker-compose build 
    

eval $(minikube docker-env -u) 命令用于确保 ./gradlew build 命令使用主机的 Docker 引擎,而不是 Minikube 实例中的 Docker 引擎。build 命令使用 Docker 运行测试容器。

  1. 解决 Helm 图表的依赖项:

    1. 首先,我们更新 components 文件夹中的依赖项:

      for f in kubernetes/helm/components/*; do helm dep up $f; done 
      
    2. 接下来,我们更新 environments 文件夹中的依赖项:

      for f in kubernetes/helm/environments/*; do helm dep up $f; done 
      
  2. hands-on 命名空间设置为 kubectl 的默认命名空间:

    kubectl config set-context $(kubectl config current-context) --namespace=hands-on 
    
  3. 在另一个终端窗口中,运行以下命令以监控 cert-manager 创建证书对象的方式:

    kubectl get certificates -w --output-watch-events 
    
  4. 使用 Helm 部署系统景观,并等待所有部署完成:

    helm install hands-on-dev-env \
      kubernetes/helm/environments/dev-env \
      -n hands-on \
      --create-namespace \
      --wait 
    
  5. 注意 cert-manager 在部署期间创建证书的方式。预期 kubectl get certificates 命令将输出以下内容:

图形用户界面,描述自动生成,中等置信度

图 17.5:cert-manager 配置证书的事件

  1. 使用 Ctrl+ C 停止 kubectl get certificates 命令。

  2. 运行测试以验证系统景观按预期工作:

    HOST=minikube.me PORT=8443 USE_K8S=true ./test-em-all.bash 
    

预期测试输出与上一章中获得的输出类似(以压缩格式):

文本描述自动生成

图 17.6:验证由 dev-env Helm 图表创建的系统景观

在完成 dev-env 之前,让我们尝试使用 cert-manager 创建的证书对象,看看它如何影响证书的保留时间。

证书轮换

让我们从以下命令开始熟悉证书对象:

kubectl describe cert tls-certificate 

在命令输出的末尾,我们将找到有关证书有效期的以下信息:

文本描述自动生成

图 17.7:证书验证期间和续签时间

我们可以看到证书有效期为 90 天(过期时间起始时间),并且 cert-manager 将在 60 天后尝试续签它(续签时间起始时间)。由于我们使用的自签名发行者不允许任何配置,这些是 cert-manager 使用的默认值:90 天,有效期和生命周期结束后的 2/3 时刻启动的续签过程。

但我们不想等待 60 天才能观察到证书的续签。如果我们研究证书对象的 API 规范 cert-manager.io/docs/reference/api-docs/#cert-manager.io/v1.Certificate,我们会在 spec 部分找到一个感兴趣的字段。

它被命名为 renewBefore,可以用来指定 cert-manager 应该何时开始续签过程。如果我们希望证书每分钟续签一次,我们可以将 renewBefore 设置为 90 天 - 1 分钟 = 90 * 24 小时 - 1 分钟 = 2160 小时 - 1 分钟 = 2159 小时和 59 分钟。

在另一个终端窗口中启动 kubectl get events -w 命令,并运行以下 patch 命令以向证书添加 renewBefore 字段:

kubectl patch certificate tls-certificate --type=json \
-p='[{"op": "add", "path": "/spec/renewBefore", "value": "2159h59m"}]' 

在 1 分钟内,get events 命令应该开始报告证书续签。对于每次续签,get events 命令应该打印以下内容:

图形用户界面,文本描述自动生成

图 17.8:cert-manager 旋转证书的事件

等待几分钟以验证证书每分钟更新一次。如果你对下一次更新何时发生感兴趣,可以发出以下命令:

kubectl get cert tls-certificate -o json | jq .status.renewalTime 

它应该响应一个类似 2023-05-07T05:58:40Z 的日期。

如果你不再想有自定义保留时间,可以使用以下命令删除 renewBefore 字段:

kubectl patch certificate tls-certificate --type=json \
  -p='[{"op": "remove", "path": "/spec/renewBefore"}]' 

这标志着我们将在使用 dev-env 图表部署的系统景观中进行的测试的结束。我们可以使用以下命令删除系统景观:

kubectl delete namespace hands-on 

让我们回顾一下如何使用 prod-env 图表部署系统景观!

将应用部署到 Kubernetes 的预发布和生产环境

使用 prod-env 图表部署到预发布和生产环境遵循与我们在第十六章“将我们的微服务部署到 Kubernetes”中“部署到 Kubernetes 的预发布和生产环境”部分所使用的相同步骤。以下以紧凑的形式重述这些步骤:

  1. 在 Kubernetes 外启动 MySQL、MongoDB 和 RabbitMQ:

    eval $(minikube docker-env)
    docker-compose up -d mongodb mysql rabbitmq 
    
  2. 使用 v1 版本标记 Docker 镜像:

    docker tag hands-on/auth-server hands-on/auth-server:v1
    docker tag hands-on/product-composite-service hands-on/product-composite-service:v1 
    docker tag hands-on/product-service hands-on/product-service:v1
    docker tag hands-on/recommendation-service hands-on/recommendation-service:v1
    docker tag hands-on/review-service hands-on/review-service:v1 
    
  3. 使用 prod-env Helm 图表部署微服务:

    helm install hands-on-prod-env \
      kubernetes/helm/environments/prod-env \
      -n hands-on --create-namespace \
      --wait 
    
  4. 运行测试以验证系统景观按预期工作:

    HOST=minikube.me PORT=8443 USE_K8S=true ./test-em-all.bash 
    

完成后,使用以下命令清理在 Kubernetes 和 Docker 中创建的资源:

  1. 如果 kubectl get cert -wkubectl get events -w 命令仍在运行,请使用 Ctrl + C 停止它们。

  2. 使用以下命令在 Kubernetes 中删除命名空间:

    kubectl delete namespace hands-on 
    
  3. 使用以下命令停止 MySQL、MongoDB 和 RabbitMQ:

    eval $(minikube docker-env)
    docker-compose down 
    

这样,我们就完成了在 Kubernetes 上运行的所有测试。让我们看看如何验证微服务在没有 Kubernetes 的情况下仍然工作。

验证微服务在没有 Kubernetes 的情况下工作

在本章和上一章中,我们看到了 Kubernetes 平台中的功能,如 ConfigMaps、Secrets、Services 和 Ingress 对象,如何简化开发协作微服务景观的努力。但重要的是要确保微服务的源代码在功能上不依赖于平台。避免这种锁定使得在需要时可以以最小的努力切换到另一个平台。切换平台不应需要修改源代码,而只需修改微服务的配置。

使用 Docker Compose 测试微服务并运行 test-em-all.bash 验证脚本将确保它们在没有 Kubernetes 的情况下从功能上工作。在没有 Kubernetes 运行微服务时,我们将缺少 Kubernetes 提供的非功能性特性,例如监控、扩展和重启容器。

当使用 Docker Compose 时,我们将替换以下 Kubernetes 功能:

  • 我们将使用映射主机文件系统的配置文件的卷来代替 ConfigMaps

  • 我们将不再使用 Secrets,而是将敏感信息,如凭证,保存在 Docker Compose 的.env文件中

  • 我们将使用 Spring Cloud Gateway 而不是 Ingress

  • 我们将直接将客户端使用的域名映射到容器的域名,这意味着我们将不会设置任何服务发现,并且无法扩展容器

使用 Docker Compose 这种方式与使用 Kubernetes 相比,在非功能性方面将产生显著的缺点。但考虑到 Docker Compose 仅用于运行功能测试,这是可以接受的。

在我们使用 Docker Compose 运行测试之前,让我们看一下docker-compose*.yml文件中的更改。

Docker Compose 文件中的更改

要在 Kubernetes 之外运行微服务,使用 Docker Compose,以下更改已应用于docker-compose*.yml文件:

  • 我们已经移除了配置服务器定义

  • 我们已经移除了以下配置服务器环境变量的使用:CONFIG_SERVER_USRCONFIG_SERVER_PWD

  • 我们已经将config-repo文件夹映射为每个需要从配置仓库读取配置文件的容器中的卷

  • 我们已经定义了SPRING_CONFIG_LOCATION环境变量,将其指向配置仓库中的配置文件,在这种情况下是/config-repo/application.yml/config-repo/product.yml文件

  • 我们已经将敏感信息,如凭证和密码,存储在 Docker Compose 的.env文件中的 TLS 证书中

  • 我们已经定义了环境变量,其中包含对资源管理器的访问凭证,使用.env文件中定义的变量

例如,product微服务的配置在docker-compose.yml文件中看起来如下:

product:
  build: microservices/product-service
  image: hands-on/product-service
  environment:
    - SPRING_PROFILES_ACTIVE=docker
    - SPRING_CONFIG_LOCATION=file:/config-repo/application.yml,file:/config-repo/product.yml
    - SPRING_RABBITMQ_USERNAME=${RABBITMQ_USR}
    - SPRING_RABBITMQ_PASSWORD=${RABBITMQ_PWD}
    - SPRING_DATA_MONGODB_AUTHENTICATION_DATABASE=admin
    - SPRING_DATA_MONGODB_USERNAME=${MONGODB_USR}
    - SPRING_DATA_MONGODB_PASSWORD=${MONGODB_PWD}
  volumes:
    - $PWD/config-repo:/config-repo 

这里是对源代码的解释:

  • config-repo文件夹被映射为卷到容器的/config-repo

  • SPRING_CONFIG_LOCATION环境变量告诉 Spring 在哪里找到属性文件,在这种情况下,是/config-repo/application.yml/config-repo/product.yml文件

  • 访问 RabbitMQ 和 MongoDB 的凭证已根据.env文件中的内容设置为环境变量

在前面的源代码中提到的凭证在.env文件中定义为:

RABBITMQ_USR=rabbit-user-prod
RABBITMQ_PWD=rabbit-pwd-prod
MONGODB_USR=mongodb-user-prod
MONGODB_PWD=mongodb-pwd-prod 

测试使用 Docker Compose

要使用 Docker Compose 进行测试,我们将使用 Docker Desktop 而不是 Minikube。执行以下步骤:

  1. 要将 Docker 客户端指向 Docker Desktop 而不是 Minikube,请运行以下命令:

    eval $(minikube docker-env --unset) 
    
  2. 为了避免在端口8443上的端口冲突,您需要停止 Minikube 实例:

    minikube stop 
    
  3. 使用以下命令在 Docker Desktop 中构建 Docker 镜像:

    docker-compose build 
    
  4. 使用 RabbitMQ(每个主题一个分区)运行测试

    COMPOSE_FILE=docker-compose.yml ./test-em-all.bash start stop 
    
  5. 测试应该从启动所有容器、运行测试以及最后停止所有容器开始。期望输出类似于我们在前面的章节中看到的输出(输出已缩减以提高可读性):

计算机屏幕截图  描述由中等置信度自动生成

图 17.9:在不使用 Kubernetes 的情况下验证系统景观的功能

  1. 可选地,使用 RabbitMQ 并为每个主题设置多个分区来运行测试:

    COMPOSE_FILE=docker-compose-partitions.yml ./test-em-all.bash start stop 
    

    预期输出与前面的测试类似。

  2. 或者,使用 Kafka 并为每个主题设置多个分区来运行测试:

    COMPOSE_FILE=docker-compose-kafka.yml ./test-em-all.bash start stop 
    

    预期输出与前面的测试类似。

由于 Kafka 代理可能需要几秒钟的时间来决定将哪个分区分配给消费者组中的实例,因此测试可能会失败,因为当测试开始时重平衡操作仍在进行中。如果测试失败,请重新运行命令,但不要使用 start 标志:

COMPOSE_FILE=docker-compose-kafka.yml ./test-em-all.bash stop 
  1. 启动 Minikube 实例,并将默认命名空间设置为 hands-on

    minikube start
    kubectl config set-context $(kubectl config current-context) --namespace=hands-on 
    

通过这些测试的成功执行,我们已经验证了微服务在没有 Kubernetes 的情况下也能工作。

摘要

在本章中,我们看到了 Kubernetes 的功能如何被用来简化微服务景观,这意味着我们减少了需要与微服务一起开发和部署的支持服务的数量。我们看到了 Kubernetes ConfigMaps 和 Secrets 如何用来取代 Spring Cloud Config Server,以及 Kubernetes Ingress 对象如何取代 Spring Cloud Gateway。

使用 cert-manager 允许我们自动为 Ingress 控制器暴露的 HTTPS 端点提供证书,消除了手动和繁琐工作的需求。

为了验证微服务的源代码可以在其他平台上运行,也就是说,没有锁定在 Kubernetes 上,我们使用 Docker Compose 部署了微服务并运行了 test-em-all.bash 测试脚本。

在下一章中,我们将介绍服务网格的概念,并学习如何使用服务网格产品 Istio 来提高在 Kubernetes 上部署的协作微服务景观的可观察性、安全性、弹性和路由。

前往下一章!

问题

  1. Spring Cloud Config Server 是如何被 Kubernetes 资源所取代的?

  2. Spring Cloud Gateway 是如何被 Kubernetes 资源所取代的?

  3. 要使 cert-manager 能够自动为 Ingress 对象提供证书,需要什么?

  4. 如何检查和更新证书的保留时间?

  5. 实际证书存储在哪里?

  6. 为什么我们使用 Docker Compose 来运行测试?

第十八章:使用服务网格提高可观察性和管理

在本章中,您将了解服务网格的概念,并了解其功能如何用于处理微服务系统景观中的挑战,包括安全性、策略执行、弹性和流量管理。服务网格还可以用于提供可观察性,即可视化微服务之间流量流向的能力。

服务网格部分重叠了我们在这本书中之前学到的 Spring Cloud 和 Kubernetes 的功能。但正如本章所示,服务网格中的大多数功能都是与 Spring Cloud 和 Kubernetes 相补充的。

本章将涵盖以下主题:

  • 服务网格概念和 Istio 介绍,Istio 是一个流行的开源实现

  • 在 Kubernetes 中部署 Istio

  • 创建、观察和保障服务网格

  • 确保服务网格具有弹性

  • 执行零停机更新

  • 使用 Docker Compose 测试微服务景观,以确保微服务中的源代码不会被锁定在 Kubernetes 或 Istio 中

技术要求

关于如何安装本书中使用的工具以及如何访问本书源代码的说明,请参阅:

  • 第二十一章macOS 安装说明

  • 第二十二章使用 WSL 2 和 Ubuntu 的 Microsoft Windows 安装说明

本章中的代码示例全部来自 $BOOK_HOME/Chapter18 的源代码。

如果您想查看对本章源代码所做的更改,即查看使用 Istio 创建服务网格所需的步骤,您可以将其与 第十七章实现 Kubernetes 功能以简化系统景观 的源代码进行比较。您可以使用您喜欢的 diff 工具比较两个文件夹,$BOOK_HOME/Chapter17$BOOK_HOME/Chapter18

使用 Istio 介绍服务网格

服务网格是一个基础设施层,它控制和观察服务之间的通信,例如微服务。服务网格中的功能,例如可观察性、安全性、策略执行、弹性和流量管理,是通过控制和监控服务网格内部的所有内部通信来实现的,即服务网格中的微服务之间的通信。

服务网格中的一个核心组件是一个轻量级的 代理 组件,它被注入到将成为服务网格一部分的每个微服务中。所有进出微服务的流量都被配置为通过其代理组件。代理组件由服务网格中的 控制平面 在运行时通过代理公开的 API 进行配置。控制平面还通过这些 API 从代理收集遥测数据,以可视化服务网格中的流量流向。

服务网格还包含一个数据平面,由代理组件以及处理服务网格外部流量(分别称为入口网关出口网关)的独立组件组成。网关组件也通过代理组件与控制平面通信。这将在以下图中说明:

包含文本、截图、矩形、图表的图片,自动生成描述

图 18.1:具有控制平面和数据平面的服务网格

服务网格的第一个公开实施是开源项目Linkerd,由 Buoyant 管理(linkerd.io),其起源可以追溯到 Twitter 的 Finagle 项目(twitter.github.io/finagle)。它于 2016 年推出,一年后,即 2017 年,IBM、Google 和 Lyft 推出了开源项目Istio(istio.io)。从那时起,已经启动了几个服务网格项目。

想要了解可用的实现概述,请参阅 CNCF 云原生景观图中的服务网格类别:landscape.cncf.io/card-mode?category=service-mesh&grouping=category。在本书中,我们将使用 Istio。

介绍 Istio

可以使用各种安装工具在多个 Kubernetes 发行版和平台上部署 Istio,具体请参阅istio.io/docs/setup。我们将使用 Istio 的 CLI 工具istioctl在我们的基于 minikube 的单节点 Kubernetes 集群中安装 Istio。

如前所述,Istio 分为控制平面和数据平面。作为操作员,我们将通过在 Kubernetes API 服务器中创建 Istio 对象来定义所需状态,例如,声明路由规则。控制平面将读取这些对象,并向数据平面中的代理发送命令以根据所需状态采取行动,例如,配置路由规则。代理处理微服务之间的实际通信,并将遥测数据报告给控制平面。遥测数据用于控制平面以可视化服务网格中的情况。

当在 Kubernetes 上部署 Istio 时,其大多数运行时组件都部署在单独的 Kubernetes 命名空间istio-system中。对于本书中将要使用的配置,我们将在该命名空间中找到以下部署:

  • istiod,运行整个控制平面的 Istio 守护进程。

有趣的事实:直到 Istio v1.4,控制平面被分成一组协作的微服务。从 v1.5 开始,它们被合并为一个由 istiod 运行的单一二进制文件,简化了运行时控制平面的安装和配置。此外,启动时间、资源使用和响应性等运行时特性也得到了改善。我认为,Istio 控制平面的这种演变是关于使用细粒度微服务时学到的有趣经验教训。

  • istio-ingressgatewayistio-egressgateway,Istio 的入口和出口网关组件,是数据平面的一部分。

  • Istio 支持与其他流行的开源项目进行多种集成,以将额外的功能引入控制平面。在这本书中,我们将集成以下组件:

    • Kiali:为服务网格提供可观察性,可视化网格中的情况。更多信息,请参阅www.kiali.io

    • 跟踪:基于 Jaeger 或 Zipkin 处理和可视化分布式跟踪信息。我们将使用 Jaeger。更多信息,请参阅www.jaegertracing.io

    • Prometheus:执行基于时间序列数据(例如性能指标)的数据摄取和存储。更多信息,请参阅prometheus.io

    • Grafana:可视化 Prometheus 收集的性能指标和其他时间序列相关数据。更多信息,请参阅grafana.com

    在第二十章“监控微服务”中,我们将探讨使用 Prometheus 和 Grafana 的性能监控能力。

  • 关于 Istio 中可用的集成更多信息,请参阅istio.io/latest/docs/ops/integrations/

除了 istio-system 命名空间之外,唯一部署的 Istio 组件是代理组件,这些组件被注入到服务网格中的微服务中。代理组件基于 Lyft 的 Envoy 代理(www.envoyproxy.io)。

Istio 控制平面和数据平面中的运行时组件总结如下图所示:

包含文本、截图、矩形 描述自动生成

图 18.2:Istio 运行时组件

现在我们已经介绍了这些内容,我们将探讨如何将这些代理对象注入到微服务中。

将 Istio 代理注入到微服务中

在前几章中,我们在 Kubernetes 中部署的微服务作为一个单独的容器在 Kubernetes Pod 中运行(回顾一下,请参考第十五章“Kubernetes 简介”中的“介绍 Kubernetes API 对象”部分)。

要使微服务加入基于 Istio 的服务网格,需要在每个微服务中注入 Istio 代理。这是通过向运行 Istio 代理的 Pod 中添加一个额外的容器来实现的。

添加到 Pod 中,旨在支持主容器(如 Istio 代理)的容器被称为 sidecar

以下图显示了 Istio 代理如何作为侧边容器注入到示例 Pod,Pod A

包含文本、截图、数字、字体描述的图片,自动生成

图 18.3:注入到 Pod A 的 Istio 代理

Pod 中的主容器,Container A,被配置为将所有流量路由通过 Istio 代理。

Istio 代理可以在创建 Pod 对象时自动注入,或者使用 istioctl 工具手动注入。要告诉 Istio 自动将 Istio 代理注入到命名空间中的新 Pod,可以将命名空间标记为 istio-injection: enabled。如果命名空间中的某些 Pod 要排除在自动注入之外,它们可以注解为 sidecar.istio.io/inject: "false"

要将 Istio 代理手动注入到现有 Deployment 对象的 Pod 中,可以使用以下命令:

kubectl get deployment sample-deployment -o yaml | istioctl kube-inject -f - | kubectl apply -f - 

这个命令乍一看可能有些令人畏惧,但实际上它只是三个独立的命令。前面的命令通过管道将输出发送到下一个命令,即 | 字符。让我们逐一分析每个命令:

  • kubectl get deployment 命令从 Kubernetes API 服务器获取名为 sample-deployment 的 Deployment 的当前定义,并以 YAML 格式返回其定义。

  • istioctl kube-inject 命令从 kubectl get deployment 命令读取定义,并在 Deployment 对象中添加一个用于 Istio 代理的额外容器。Deployment 对象中现有容器的配置被更新,以便进出流量通过 Istio 代理。

  • istioctl 命令返回包含 Istio 代理容器的 Deployment 对象的新定义。

  • kubectl apply 命令从 istioctl kube-inject 命令读取更新的配置,并应用更新后的配置。属于 Deployment 的 Pod 的升级将以我们之前看到的方式启动(参考第十五章,Kubernetes 简介中的尝试一个示例部署部分)。

在本书中,我们将通过应用以下 hands-on 命名空间的定义来自动注入 Istio 代理。

apiVersion: v1
kind: Namespace
metadata:
  name: hands-on
  labels:
    istio-injection: enabled 

从前面的定义中,我们可以看到命名空间被赋予 istio-injection 标签,其值为 enabled

在撰写本文时,Istio 还不能完全胜任作为 MySQL、MongoDB 和 RabbitMQ 的代理,因此它们将通过在它们的 Helm 图表的 values.yaml 文件中添加以下注解而被排除在服务网格之外:

annotations:
  sidecar.istio.io/inject: "false" 

在介绍如何将 Istio 代理注入到 Pod 之后,我们现在可以学习本书中使用的 Istio API 对象。

介绍 Istio API 对象

Istio 还附带了一套 Kubernetes 自定义资源定义CRDs)。CRDs 用于 Kubernetes 以扩展其 API,即向其 API 添加新对象。请参阅 Chapter 15Introduction to Kubernetes 中的 Introducing Kubernetes API objects 部分,以回顾 Kubernetes API。

在本书中,我们将使用以下 Istio 对象:

  • Gateway 用于配置如何处理进入和离开服务网格的流量。网关依赖于将入站流量路由到 Kubernetes 服务的虚拟服务。我们将使用 gateway 对象来接受以 minikube.me 结尾的 DNS 名称的入站流量,并使用 HTTPS。Istio 网关对象将替换前一章中使用的 Ingress 对象。有关详细信息,请参阅 Replacing Kubernetes Ingress Controller with Istio ingress gateway 部分。

  • VirtualService 用于在服务网格中定义路由规则。我们将使用虚拟服务来描述如何将来自 Istio 网关的入站流量路由到 Kubernetes 服务,以及服务之间的路由。我们还将使用虚拟服务来注入故障和延迟,以测试服务网格的可靠性和弹性能力。

  • DestinationRule 用于定义路由到特定服务(即目的地)的流量策略和规则。我们将使用目的地规则来设置加密策略以加密内部 HTTP 流量,并定义描述服务可用版本的子集。在从现有版本的微服务到新版本的零停机(蓝绿)部署时,我们将使用服务子集。

VirtualServiceDestinationRule 之间的责任划分在开始时可能显得有些不清楚。VirtualService 对象用于配置路由服务,而 DestinationRule 用于配置如何处理选定服务的流量。因此,首先是 VirtualService 对象,用于确定请求发送到哪里。一旦确定,接收服务的 DestinationRule 就会被应用。

  • PeerAuthentication 用于控制服务网格内部的服务间认证。Istio 可以通过自动配置传输认证的双向 TLSmTLS)来保护服务网格中服务之间的通信,其中客户端服务通过 Istio 提供的客户端证书进行认证。为了允许 Kubernetes 使用纯 HTTP 调用存活性和就绪性探针,我们将配置 Istio 允许 mTLS 和纯 HTTP 的混合模式,称为 PERMISSIVE 模式。

  • RequestAuthentication 用于根据请求中提供的凭据对最终用户进行身份验证。Istio 支持在一般情况下使用 JSON Web TokensJWTs),特别是当按照 OpenID ConnectOIDC)规范使用时。Istio 支持使用 OIDC 的标准发现端点来指定 Istio 可以从中获取用于验证 JWT 签名的公钥集 JSON Web Key Set(JWKS)。我们将配置 Istio 使用 auth 服务器通过指定其 JWKS 发现端点来对外部请求进行身份验证。为了回顾,请参阅第十一章 保护 API 访问

  • AuthorizationPolicy 用于在 Istio 中提供访问控制。在本书中,我们不会使用 Istio 的访问控制。相反,我们将重用 product-composite 微服务中实现的现有访问控制。因此,我们将配置一个允许任何经过身份验证的用户(即包含有效 JWT 的 OIDC 访问令牌的请求)访问 product-composite 微服务的 AuthorizationPolicy 对象。

更多关于这些 API 对象的信息,请参阅 istio.io/v1.17/docs/reference/config/networking/istio.io/v1.17/docs/reference/config/security/

现在我们已经介绍了我们将使用的 API 对象,我们将通过 Istio 的引入来查看微服务景观产生的变更。

简化微服务景观

如前所述,Istio 包含与微服务景观中当前使用的组件在功能上重叠的组件:

  • Istio 网关可以作为边缘服务器,作为 Kubernetes Ingress 控制器的替代方案

  • 随 Istio 一起捆绑的 Jaeger 组件可以用作分布式跟踪,而不是我们与微服务一起部署的 Zipkin 服务器

在接下来的两个小节中,我们将概述为什么以及如何用 Istio 网关替换 Kubernetes Ingress 控制器,以及我们的 Zipkin 服务器被 Istio 集成的 Jaeger 组件所替换的原因。

用 Istio 网关替换 Kubernetes Ingress 控制器

在上一章中,我们介绍了 Kubernetes Ingress 控制器作为一个边缘服务器(参考第十七章 实现 Kubernetes 功能以简化系统景观 中的 替换 Spring Cloud Gateway 部分)。与 Kubernetes Ingress 控制器相比,Istio ingress 网关具有许多优势:

  • 它可以向控制平面报告通过它的流量遥测数据

  • 它可以用于更细粒度的路由

  • 它可以在将请求路由到服务网格之前对请求进行身份验证和授权

为了利用这些优势,我们将用 Istio 入口网关替换 Kubernetes Ingress 控制器。Istio 入口网关是通过创建 GatewayVisualService 对象来使用的,如之前在 介绍 Istio API 对象 部分中所述。

kubernetes/helm/environments 中的 dev-envprod-env Helm 图表中删除了之前使用的 Ingress 对象的定义。Istio GatewayVirtualService 对象的定义文件将在 创建服务网格 部分中解释。

使用与访问 Kubernetes Ingress 控制器所用的不同 IP 地址来访问 Istio 入口网关,因此我们还需要更新映射到主机名 minikube.me 的 IP 地址,这是我们运行测试时使用的。这已在 设置访问 Istio 服务 部分中处理。

用 Istio 的 Jaeger 组件替换 Zipkin 服务器

介绍 Istio 部分所述,Istio 内置了对使用 Jaeger 的分布式跟踪的支持。使用 Jaeger,我们可以通过移除我们在 第十四章理解分布式跟踪 中引入的 Zipkin 服务器,来减轻并简化 Kubernetes 中的微服务景观。我们还将更改微服务之间传播跟踪和跨度 ID 的方式,从使用默认的 W3C 跟踪上下文头信息更改为使用 OpenZipkin 的 B3 头信息。有关更多信息,请参阅 第十四章理解分布式跟踪 中的 使用 Micrometer 跟踪和 Zipkin 介绍分布式跟踪 部分。

以下更改已应用于源代码:

  • 在所有微服务构建文件 build.gradle 中已替换以下依赖项:

    implementation 'io.micrometer:micrometer-tracing-bridge-otel'
    implementation 'io.opentelemetry:opentelemetry-exporter-zipkin' 
    

    依赖项已替换为以下内容:

    implementation 'io.micrometer:micrometer-tracing-bridge-brave'
    implementation 'io.zipkin.reporter2:zipkin-reporter-brave' 
    
  • 公共配置文件 config-repo/application.yml 中的 management.zipkin.tracing.endpoint 属性指向 Istio 中的 Jaeger 组件。它具有主机名 jaeger-collector.istio-system

  • 在三个 Docker Compose 文件 docker-compose.ymldocker-compose-partitions.ymldocker-compose-kafka.yml 中保留了对 Zipkin 服务器的定义,以便能够在 Kubernetes 和 Istio 之外使用分布式跟踪,但 Zipkin 服务器已被赋予与 Istio 中 Jaeger 组件相同的名称,即 jaeger-collector.istio-system

  • 已删除 Zipkin 服务器的 Helm 图表。

Jaeger 将在接下来的 在 Kubernetes 集群中部署 Istio 部分中安装。

在解释了微服务景观的简化之后,我们已准备好在 Kubernetes 集群中部署 Istio。

在 Kubernetes 集群中部署 Istio

在本节中,我们将学习如何在 Kubernetes 集群中部署 Istio 以及如何访问其中的 Istio 服务。

我们将使用 Istio 的 CLI 工具 istioctl,使用适合在开发环境中测试 Istio 的 demo 配置安装 Istio,即,启用大多数功能但配置为最小化资源使用。

此配置不适合生产使用和性能测试。

对于其他安装选项,请参阅 istio.io/latest/docs/setup/install/

要部署 Istio,请执行以下步骤:

  1. 确保上一章中的 Minikube 实例已通过以下命令启动并运行:

    minikube status 
    

    预期以下类似的响应,前提是它已启动并运行:

计算机屏幕截图  描述自动生成,置信度中等

图 18.4:Minikube 状态正常

  1. 运行预检查以验证 Kubernetes 集群是否已准备好在其中安装 Istio:

    istioctl experimental precheck 
    

    预期以下类似的响应:

计算机屏幕截图  描述自动生成,置信度中等

图 18.5:Istio 预检查正常

  1. 使用以下命令使用 demo 配置文件安装 Istio:

    cd $BOOK_HOME/Chapter18
    istioctl install --skip-confirmation \
      --set profile=demo \
      --set meshConfig.accessLogFile=/dev/stdout \
      --set meshConfig.accessLogEncoding=JSON \
      --set values.pilot.env.PILOT_JWT_PUB_KEY_REFRESH_INTERVAL=15s \
      -f kubernetes/istio-tracing.yml 
    

    命令参数执行以下操作:

  • accessLog 参数用于启用 Istio 代理记录处理请求。一旦 Pods 配置了 Istio 代理并启动运行,可以使用命令 kubectl logs <MY-POD> -c istio-proxy 检查访问日志。

  • PILOT_JWT_PUB_KEY_REFRESH_INTERVAL 参数配置 Istio 的守护进程 istiod 每 15 秒刷新获取的 JWKS 公钥。此参数的用法将在 部署 v1 和 v2 版本的微服务并路由到 v1 版本 部分中解释。

  • 配置文件 kubernetes/istio-tracing.yml 启用创建用于分布式跟踪的跟踪跨度。它还配置 Istio 为所有请求创建跟踪跨度。它看起来像这样:

    apiVersion: install.istio.io/v1alpha1
    kind: IstioOperator
    spec:
      meshConfig:
        enableTracing: true
        defaultConfig:
          tracing:
            sampling: 100 
    
  1. 使用以下命令等待 Deployment 对象及其 Pods 可用:

    kubectl -n istio-system wait --timeout=600s --for=condition=available deployment --all 
    
  2. 接下来,使用以下命令安装 介绍 Istio 部分中描述的额外组件——Kiali、Jaeger、Prometheus 和 Grafana:

    istio_version=$(istioctl version --short --remote=false)
    echo "Installing integrations for Istio v$istio_version"
    kubectl apply -n istio-system -f https://raw.githubusercontent.com/istio/istio/${istio_version}/samples/addons/kiali.yaml
    kubectl apply -n istio-system -f https://raw.githubusercontent.com/istio/istio/${istio_version}/samples/addons/jaeger.yaml
    kubectl apply -n istio-system -f https://raw.githubusercontent.com/istio/istio/${istio_version}/samples/addons/prometheus.yaml
    kubectl apply -n istio-system -f https://raw.githubusercontent.com/istio/istio/${istio_version}/samples/addons/grafana.yaml 
    

如果这些命令中的任何一个失败,请尝试重新运行失败的命令。错误可能由于时间问题引起,可以通过再次运行命令来解决。特别是,Kiali 的安装可能会导致以 unable to recognize 开头的错误消息。重新运行命令会使这些错误消息消失。

  1. 再次等待以下命令以使额外组件可用:

    kubectl -n istio-system wait --timeout=600s --for=condition=available deployment --all 
    
  2. 最后,运行以下命令以查看我们安装了什么:

    kubectl -n istio-system get deploy 
    

    预期以下类似的输出:

黑色屏幕的屏幕截图  描述自动生成,置信度低

图 18.6:Istio 命名空间中的部署

Istio 现在已在 Kubernetes 中部署,但在我们继续创建服务网格之前,我们需要了解如何在 Minikube 环境中访问 Istio 服务。

设置访问 Istio 服务的权限

在前一个部分中用于安装 Istio 的demo配置包含一些与连接相关的问题,我们需要解决。Istio 入口网关被配置为一个负载均衡的 Kubernetes 服务;也就是说,其类型是LoadBalancer。为了能够访问网关,我们需要在 Kubernetes 集群前面运行一个负载均衡器。

Minikube 包含一个可以用来模拟本地负载均衡器的命令,minikube tunnel。此命令为每个负载均衡的 Kubernetes 服务分配一个外部 IP 地址,包括 Istio 入口网关。我们在测试中使用的minikube.me主机名需要被转换成 Istio 入口网关的外部 IP 地址。为了简化对 Kiali 和 Jaeger 等组件的 Web UI 的访问,我们还将添加专门用于这些服务的域名,例如,kiali.minikube.me

我们还将注册一个主机名到外部健康端点,如观察服务网格部分所述。最后,还会注册后续章节中安装和使用的服务的几个主机名,这样我们就不需要在以下章节中添加新的主机名。下一章我们将安装的服务包括 Kibana、Elasticsearch 和一个邮件服务器。

要使用这些主机名通过外部访问 Istio 服务,已创建了一个 Helm 图表;请参阅kubernetes/helm/environments/istio-system。该图表包含每个 Istio 组件的GatewayVirtualServiceDestinationRule对象。为了保护对这些主机名的请求免受窃听,只允许 HTTPS 请求。在前一章中引入的cert-manager由图表用于自动为这些主机名提供 TLS 证书并将其存储在名为hands-on-certificate的 Secret 中。所有网关对象都配置为在 HTTPS 协议的配置中使用此 Secret。所有定义文件都可以在 Helm 图表的templates文件夹中找到。

这些 API 对象的使用将在下面的创建服务网格使用 HTTPS 和证书保护外部端点部分中更详细地描述。

运行以下命令以应用 Helm 图表:

helm upgrade --install istio-hands-on-addons kubernetes/helm/environments/istio-system -n istio-system --wait 

这将使网关能够将以下主机名的请求路由到相应的 Kubernetes 服务:

  • kiali.minikube.me请求被路由到kiali:20001

  • tracing.minikube.me请求被路由到tracing:80

  • prometheus.minikube.me请求被路由到prometheus:9000

  • grafana.minikube.me请求被路由到grafana:3000

为了验证证书密钥对象是否已创建,请运行以下命令:

kubectl -n istio-system get secret hands-on-certificate
kubectl -n istio-system get certificate  hands-on-certificate 

预期输出如下:

计算机的截图,中等置信度自动生成描述

图 18.7:cert-manager 已交付 TLS Secret 和证书

以下图表总结了如何访问组件:

包含文本、截图、字体、行描述的图片,自动生成

图 18.8:通过 Minikube 隧道访问组件时要使用的主机名

执行以下步骤以设置 Minikube 隧道并注册主机名:

  1. 在另一个终端窗口中运行以下命令(当隧道启动并运行时,该命令会锁定终端窗口):

    minikube tunnel 
    

    注意,此命令要求你的用户具有 sudo 权限,并且在启动时输入你的密码。在命令请求密码之前可能需要几秒钟,所以很容易错过!

    一旦隧道启动并运行,它将列出 istio-ingressgateway 作为其公开的服务之一(在我们的案例中是唯一的)。

  2. 配置主机名,使其解析到 Istio 入口网关的 IP 地址。首先,获取 minikube tunnel 命令为 Istio 入口网关暴露的 IP 地址,并将其保存到名为 INGRESS_IP 的环境变量中:

    INGRESS_IP=$(kubectl -n istio-system get service istio-ingressgateway -o jsonpath='{.status.loadBalancer.ingress[0].ip}')
    echo $INGRESS_IP 
    

    echo 命令将打印一个 IP 地址。由于我们使用 Minikube 的 Docker 驱动程序,它始终是 127.0.0.1

  3. 更新 /etc/hosts,以便所有 minikube.me 主机名都将使用 Istio 入口网关的 IP 地址:

    MINIKUBE_HOSTS="minikube.me grafana.minikube.me kiali.minikube.me prometheus.minikube.me tracing.minikube.me kibana.minikube.me elasticsearch.minikube.me mail.minikube.me health.minikube.me"
    echo `127.0.0.1` $MINIKUBE_HOSTS" | sudo tee -a /etc/hosts 
    
  4. 在 Windows 上,我们还需要更新 Windows 的 hosts 文件:

    1. 在 Windows 中,打开一个 PowerShell 终端。

    2. 使用以下命令在 Visual Code Studio 中打开 Windows 的 hosts 文件:

      code C:\Windows\System32\drivers\etc\hosts 
      
    3. 将类似的行添加到 Windows 的 hosts 文件中:

      127.0.0.1 minikube.me grafana.minikube.me kiali.minikube.me prometheus.minikube.me tracing.minikube.me kibana.minikube.me elasticsearch.minikube.me mail.minikube.me health.minikube.me 
      
    4. 当你尝试保存时,你会得到一个关于 Insufficient permissions 的错误。点击 Retry as Admin... 按钮以管理员身份更新 hosts 文件。

    5. 验证更新:

      cat C:\Windows\System32\drivers\etc\hosts 
      

默认情况下,当 WSL 重新启动时,/etc/hosts 文件会被 Windows 的 hosts 文件内容覆盖。重启 WSL 需要很长时间,因为它也会重启 Docker。重启 Docker 又会导致 Minikube 实例停止,因此需要手动重启。为了避免这个缓慢且繁琐的重启过程,我们简单地更新了这两个文件。

  1. 删除 /etc/hosts 中指向 Minikube 实例 IP 地址(127.0.0.1)的 minikube.me 行。验证 /etc/hosts 只包含一行,将 minikube.me 转换为指向 Istio 入口网关的 IP 地址,即 127.0.0.1

计算机的截图,中等置信度自动生成描述

图 18.9:已更新的 /etc/hosts 文件

  1. 使用以下命令验证 Kiali、Jaeger、Grafana 和 Prometheus 是否可以通过隧道访问:

    curl -o /dev/null -sk -L -w "%{http_code}\n" https://kiali.minikube.me/kiali/
    curl -o /dev/null -sk -L -w "%{http_code}\n" https://tracing.minikube.me
    curl -o /dev/null -sk -L -w "%{http_code}\n" https://grafana.minikube.me
    curl -o /dev/null -sk -L -w "%{http_code}\n" https://prometheus.minikube.me/graph#/ 
    

每个命令都应该返回 200OK)。如果发送给 Kiali 的请求没有返回 200,通常意味着其内部初始化尚未完成。在这种情况下,请等待一分钟,然后重试。

如果例如您的计算机或 Minikube 实例被暂停或重启,minikube tunnel 命令将停止运行。在这些情况下,需要手动重启。因此,如果您在 minikube.me 任何主机名上无法调用 API,请始终检查 Minikube 隧道是否正在运行,并在需要时重启它。

在 Minikube 隧道就绪后,我们现在可以创建服务网格了。

创建服务网格

部署了 Istio 后,我们就可以创建服务网格了。创建服务网格所需的步骤基本上与我们用于 第十七章通过实现 Kubernetes 功能简化系统景观(参考 使用 Kubernetes ConfigMaps、Secrets、Ingress 和 cert-manager 进行测试 部分)中使用的步骤相同。在运行创建服务网格的命令之前,让我们先看看 Helm 模板中为设置服务网格所做的添加。

源代码更改

为了能够在由 Istio 管理的服务网格中运行微服务,dev-env Helm 图表从 common 图表引入了两个新的命名模板 _istio_base.yaml_istio_dr_mutual_tls.yaml。让我们逐一查看它们。

_istio_base.yaml 模板中的内容

_istio_base.yaml 定义了将被 dev-envprod-env 环境图表共同使用的多个 Kubernetes 清单。首先,它定义了三个与 Istio 相关的安全相关清单:

  • 一个名为 product-composite-require-jwtAuthorizationPolicy 清单

  • 一个名为 defaultPeerAuthentication 清单

  • 一个名为 product-composite-request-authenticationRequestAuthentication 清单

下面的 Securing a service mesh 部分将解释这三个清单。

剩下的四个清单将在这里讨论。它们是两对用于配置访问和从主机名 minikube.mehealth.minikube.me 路由的 GatewayVirtualService 清单。将使用 Gateway 对象来定义如何接收外部流量,而 VirtualService 对象用于描述如何在服务网格内部路由传入的流量。

控制访问 minikube.meGateway 清单如下所示:

apiVersion: networking.istio.io/v1beta1
kind: Gateway
metadata:
  name: hands-on-gw
spec:
  selector:
    istio: ingressgateway
  servers:
  - hosts:
    - minikube.me
    port:
      name: https
      number: 443
      protocol: HTTPS
    tls:
      credentialName: hands-on-certificate
      mode: SIMPLE 

这里有一些关于源代码的解释:

  • 网关命名为 hands-on-gw;这个名称被下方的虚拟服务所使用。

  • selector 字段指定网关对象将由默认的 Istio 入口网关,名为 ingressgateway,处理。

  • hostsport 字段指定网关将使用 HTTPS 通过端口 443 处理对 minikube.me 主机名的传入请求。

  • tls字段指定 Istio 入口网关可以在名为hands-on-certificate的 TLS Secret 中找到用于 HTTPS 通信的证书和私钥。有关如何创建这些证书文件的详细信息,请参阅下面的Protecting external endpoints with HTTPS and certificates部分。SIMPLE模式表示将应用正常的 TLS 语义。

路由发送到minikube.me的请求的VirtualService清单如下所示:

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: hands-on-vs
spec:
  gateways:
  - hands-on-gw
  hosts:
  - minikube.me
  http:
  - match:
    - uri:
        prefix: /oauth2
    route:
    - destination:
        host: auth-server
  – match:
    ... 

前面清单的解释如下:

  • gatewayshosts字段指定虚拟服务将通过hands-on-gw网关路由发送到minikube.me主机名的请求。

  • http元素之后是一个matchroute块的数组,指定了 URL 路径将如何转发到相关的 Kubernetes 服务。在上面的清单中,只显示了第一对matchroute元素。它们将使用路径/oauth2发送到auth-server服务的请求映射。这种映射应该与我们在前几章中指定路由规则的方式相似。其余的matchroute元素配置了与我们在 Spring Cloud Gateway 和 Ingress 对象中看到的相同的路由规则:

    • /login → auth-server

    • /error → auth-server

    • /product-composite → product-composite

    • /openapi → product-composite

    • /webjars → product-composite

有关详细信息,请参阅kubernetes/helm/common/templates/_istio_base.yaml

在前面的源代码中,使用其短名称指定了目标主机,换句话说,是product-composite。这有效,因为示例基于同一命名空间hands-on的 Kubernetes 定义。如果不是这种情况,建议在 Istio 文档中使用主机的完全限定域名FQDN)代替。在这种情况下,它是product-composite.hands-on.svc.cluster.local

_istio_dr_mutual_tls.yaml模板中的内容

_istio_dr_mutual_tls.yaml定义了一个模板,用于指定多个DestinationRule对象。它用于指定在路由请求到其对应服务时应使用 mTLS。它还可以选择性地用于指定subsets,我们将在下面的Performing zero-downtime updates部分中的prod-env图表中使用它。模板看起来如下所示:

{{- define "common.istio_dr_mutual_tls" -}}
{{- range $idx, $dr := .Values.destinationRules }}
apiVersion: networking.istio.io/v1beta1
kind: DestinationRule
metadata:
  name: {{ $dr.name }}
spec:
  host: {{ $dr.name }}
{{- if $dr.subsets }}
{{- with $dr.subsets }}
  subsets:
{{ toYaml . | indent 2 }}
{{- end }}
{{- end }}
  trafficPolicy:
    tls:
      mode: ISTIO_MUTUAL
---
{{- end -}}
{{- end -}} 

下面是一些关于前面模板的注释:

  • range指令遍历在destinationRules变量中定义的元素

  • 清单的spec部分的host字段用于指定此DestinationRule应用到的 Kubernetes Service 的名称

  • 如果在destinationRules列表中的当前元素$dr中找到相应的元素,则仅定义subsets部分

  • 总是使用trafficPolicy来强制要求 mTLS

模板在 dev-end Helm 图表中使用,通过在 values.yaml 文件中指定 destinationRules 变量如下:

destinationRules:
  - name: product-composite
  - name: auth-server
  - name: product
  - name: recommendation
  - name: review 

文件位于 kubernetes/helm/common/templates/_istio_dr_mutual_tls.yamlkubernetes/helm/environments/dev-env/values.yaml

在源代码中进行这些更改后,我们现在可以创建服务网格。

运行命令以创建服务网格

通过运行以下命令创建服务网格:

  1. 使用以下命令从源代码构建 Docker 镜像:

    cd $BOOK_HOME/Chapter18
    eval $(minikube docker-env -u)
    ./gradlew build
    eval $(minikube docker-env)
    docker-compose build 
    

eval $(minikube docker-env -u) 命令确保 ./gradlew build 命令使用主机的 Docker 引擎,而不是 Minikube 实例中的 Docker 引擎。build 命令使用 Docker 运行测试容器。

  1. 重新创建 hands-on 命名空间,并将其设置为默认命名空间:

    kubectl delete namespace hands-on
    kubectl apply -f kubernetes/hands-on-namespace.yml
    kubectl config set-context $(kubectl config current-context) --namespace=hands-on 
    

    注意,hands-on-namespace.yml 文件创建了一个带有 istio-injection: enabled 标签的 hands-on 命名空间。这意味着在此命名空间中创建的 Pod 将自动注入 istio-proxy 容器作为边车。

  2. 使用以下命令解决 Helm 图表依赖项:

    1. 首先,我们更新 components 文件夹中的依赖项:

      for f in kubernetes/helm/components/*; do helm dep up $f; done 
      
    2. 接下来,我们更新 environments 文件夹中的依赖项:

      for f in kubernetes/helm/environments/*; do helm dep up $f; done 
      
  3. 使用 Helm 部署系统架构,并等待所有 Deployment 完成:

    helm install hands-on-dev-env \
      kubernetes/helm/environments/dev-env \
      -n hands-on --wait 
    
  4. 一旦 Deployment 完成,验证每个微服务 Pod 中都有两个容器:

    kubectl get pods 
    

    预期响应将与以下内容类似:

    计算机程序的截图  描述由中等置信度自动生成

    图 18.10:Pod 启动并运行

    注意,运行我们的微服务的 Pod 报告每个 Pod 有两个容器;也就是说,它们注入了 Istio 代理作为边车!

  5. 使用以下命令运行常规测试:

    ./test-em-all.bash 
    

    test-em-all.bash 脚本的默认值已从前面的章节更新,以适应在 Minikube 中运行的 Kubernetes。

    预期输出将与我们在前面的章节中看到的内容相似:

    计算机的截图  描述由中等置信度自动生成

    图 18.11:测试运行成功

  6. 在我们开始尝试 Istio 及其各种组件之前,让我们看看如何使用上面 用 Istio 的 Jaeger 组件替换 Zipkin 服务器 部分中提到的 B3 标头来记录跟踪和跨度 ID 的传播。

跟踪和跨度 ID 的日志传播

我们可以在 product-composite 微服务的出站请求中看到跟踪和跨度 ID,就像我们在 第十四章,理解分布式跟踪 中的 发送成功的 API 请求 部分所做的那样。由于我们现在在 Kubernetes 中运行微服务,我们需要更改 ConfigMap 中的日志配置,然后删除正在运行的 Pod 以使其影响微服务:

  1. 使用以下命令编辑 ConfigMap:

    kubectl edit cm product-composite 
    

    查找以下行:

    # To see tracing headers, uncomment the following two lines and restart the product-composite service
    # spring.codec.log-request-details: true
    # logging.level.org.springframework.web.reactive.function.client.ExchangeFunctions: TRACE 
    
  2. 取消注释这两行最后的注释并退出编辑器。

  3. 使用以下命令重启 product-composite 微服务,通过删除其 Pod:

    kubectl delete pod -l app=product-composite 
    
  4. 使用以下命令将日志输出打印到终端窗口:

    kubectl logs -f -l app=product-composite 
    
  5. 获取一个访问令牌并使用访问令牌进行请求:

    unset ACCESS_TOKEN
    ACCESS_TOKEN=$(curl -k https://writer:secret-writer@minikube.me/oauth2/token -d grant_type=client_credentials -d scope="product:read product:write" -s | jq -r .access_token)
    echo $ACCESS_TOKEN
    curl -H "Authorization: Bearer $ACCESS_TOKEN" -k https://minikube.me/product-composite/1 -w "%{http_code}\n" -o /dev/null -s 
    

    验证命令返回的 HTTP 状态码是否为成功,即 200

  6. 在日志输出中,应该可以看到如下类似的行:

    2023-05-15T15:39:25.919Z TRACE [product-composite,01bd9fb5815a7889dea69ec33afee5c5,94d9157ae179554c] 1 --- [     parallel-1] o.s.w.r.f.client.ExchangeFunctions       : [14b00bcd] HTTP GET http://product/
    product/1?delay=0&faultPercent=0, headers=[X-B3-TraceId:"01bd9fb5815a7889dea69ec33afee5c5", X-B3-SpanId:"94d9157ae179554c", X-B3-ParentSpanId:"aa3e97771ef9155e", X-B3-Sampled:"1"] 
    

    在上面的示例日志输出中,我们可以看到标准的 B3 头部,如 X-B3-TraceIdX-B3-SpanId

  7. 通过在 ConfigMap 中重新添加注释并删除其 Pod 来重启微服务,以停止记录跟踪和跨度 ID。

在服务网格运行起来之后,让我们看看如何使用 Kiali 来观察其中的情况!

观察服务网格

在本节中,我们将使用 Kiali 与 Jaeger 一起观察服务网格中的情况。

在我们这样做之前,我们需要了解如何消除 Kubernetes 的存活和就绪探针执行的健康检查产生的噪音。在之前的章节中,它们使用了与 API 请求相同的端口。这意味着 Istio 将收集健康检查和发送到 API 的请求的使用情况指标。这将导致 Kiali 显示的图表变得不必要地杂乱。Kiali 可以过滤掉我们不感兴趣的流量,但一个更简单的解决方案是为健康检查使用不同的端口。

微服务可以被配置为使用单独的端口来处理发送到 actuator 端点的请求,例如发送到 /actuator/health 端点的健康检查。以下行已被添加到所有微服务的通用配置文件 config-repo/application.yml 中:

management.server.port: 4004 

这将使所有微服务使用端口 4004 来暴露 health 端点。common Helm 图表的 values.yaml 文件已被更新,以在默认的存活和就绪探针中使用端口 4004。请参阅 kubernetes/helm/common/values.yaml

product-composite 微服务不仅将其管理端口暴露给 Kubernetes 探针,还对外部健康检查进行暴露,例如由 test-em-all.bash 执行的健康检查。这是通过 Istio 的入口网关完成的,因此端口 4004 被添加到 product-composite 微服务的 Deployment 和 Service 清单中。请参阅 kubernetes/helm/components/product-composite/values.yaml 中的 portsservice.ports 定义。

Spring Cloud Gateway(保留以在 Docker Compose 中运行测试)将继续使用相同的端口来处理 API 请求和 health 端点的请求。在 config-repo/gateway.yml 配置文件中,management 端口被恢复为 API 所使用的端口:

management.server.port: 8443 

为了简化对由 product-composite 微服务暴露的健康检查的外部访问,为 health.minikube.me 主机名配置了一个路由到 product-composite 微服务的 management 端口。请参阅上面 _istio_base.yaml 模板的说明。

在发送到 health 端点的请求处理完毕后,我们可以开始通过服务网格发送一些请求。

我们将使用 siege 启动低流量负载测试,我们在第十六章 将我们的微服务部署到 Kubernetes 中了解到它。之后,我们将查看 Kiali 的几个重要部分,以了解它如何用于在 Web 浏览器中观察服务网格。我们还将了解 Jaeger 如何用于分布式跟踪。

由于我们使用的证书是自签名的,因此网络浏览器不会自动依赖它。大多数网络浏览器允许您访问网页,如果您向它们保证您理解安全风险。如果网络浏览器拒绝,打开私密窗口在某些情况下可能会有帮助。

特别是关于 Chrome,如果它不允许您访问网页,并说 您的连接不安全,您可以点击 高级 按钮,然后点击链接 继续访问 …(不安全)

使用以下命令启动测试客户端:

ACCESS_TOKEN=$(curl https://writer:secret-writer@minikube.me/oauth2/token -d grant_type=client_credentials -d scope="product:read product:write" -ks | jq .access_token -r)
echo ACCESS_TOKEN=$ACCESS_TOKEN
siege https://minikube.me/product-composite/1 -H "Authorization: Bearer $ACCESS_TOKEN" -c1 -d1 -v 

第一个命令将获取一个 OAuth 2.0/OIDC 访问令牌,该令牌将在下一个命令中使用,其中 siege 用于每秒提交一个 HTTP 请求到 product-composite API。

预期 siege 命令的输出如下:

计算机屏幕截图  描述由中等置信度自动生成

图 18.12:系统在攻击下

使用您选择的接受自签名证书的 Web 浏览器,并按照以下步骤操作:

  1. 使用kiali.minikube.me URL 打开 Kiali 的 Web UI。默认情况下,您将以匿名用户身份登录。预期网页将与以下类似:

计算机屏幕截图  描述自动生成

图 18.13:Kiali Web UI

  1. 如果 概览 选项卡尚未激活,请点击它。

  2. 点击名为 动手实践(右上角三个垂直点)的菜单,并选择 图形。预期会显示一个图形,表示当前通过服务网格流动的流量。

  3. 点击 显示 按钮,取消选择除 响应时间中位数流量动画 之外的所有选项。

  4. 隐藏… 字段中,指定 name = jaeger 以避免将发送到 Jaeger 的跟踪视图搞乱。

  5. Kiali 现在显示一个图形,表示当前通过服务网格发送的请求,其中活动请求由箭头旁的小移动圆圈表示,如下所示:

计算机屏幕截图  描述由中等置信度自动生成

图 18.14:显示动手实践命名空间的 Kiali 图表

  1. 未知auth-server的流量代表了对授权服务器进行调用以获取 JWKS 公钥的请求。

    这为服务网格中正在发生的事情提供了一个相当好的初步概述!

  2. 现在我们来看看使用 Jaeger 的一些分布式追踪。使用tracing.minikube.me URL 打开 Web UI。在左侧菜单中点击服务下拉菜单并选择istio-ingressgateway.istio-system服务。点击查找追踪按钮,你应该会看到一个如下所示的结果:

计算机屏幕截图  描述由自动生成

图 18.15:Jaeger 可视化的分布式追踪

  1. 点击报告包含23 个跨度的其中一个追踪来检查它。预期会看到一个如下所示的网页:

计算机屏幕截图  描述由中等置信度自动生成

图 18.16:Jaeger 中完整追踪调用树的视图

这基本上与 Zipkin 在第十四章理解分布式追踪中提供的追踪信息相同。请注意,我们可以看到来自 Istio 代理和微服务本身的追踪信息。由 Istio 代理报告的跨度后面跟随着 Kubernetes 命名空间,即,.istio-system.hands-on

还有更多内容可以探索,但作为介绍已经足够了。请随意探索 Kiali 和 Jaeger 上的 Web UI。

注意,为测试客户端siege获取的访问令牌仅在一小时内有效。如果流量意外下降,检查siege的输出;如果它报告4XX而不是200,那么是时候更新访问令牌了!

让我们继续学习如何使用 Istio 在服务网格中提高安全性!

保护服务网格

在本节中,我们将学习如何使用 Istio 来提高服务网格的安全性。我们将涵盖以下主题:

  • 如何使用 HTTPS 和证书保护外部端点

  • 如何要求外部请求使用 OAuth 2.0/OIDC 访问令牌进行认证

  • 如何使用相互认证mTLS)保护内部通信

现在我们将在以下章节中了解这些内容的每一个。

使用 HTTPS 和证书保护外部端点

设置对 Istio 服务的访问_istio_base.yaml模板中的内容部分,我们了解到网关对象使用存储在名为hands-on-certificate的 Secret 中的 TLS 证书为其 HTTPS 端点。

该 Secret 是由 cert-manager 根据istio-system Helm 图中的配置创建的。图表的模板selfsigned-issuer.yaml用于定义一个内部自签名 CA,并具有以下内容:

apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
  name: selfsigned-issuer
spec:
  selfSigned: {}
---
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: ca-cert
spec:
  isCA: true
  commonName: hands-on-ca
  secretName: ca-secret
  issuerRef:
    name: selfsigned-issuer
---
apiVersion: cert-manager.io/v1
kind: Issuer
metadata:
  name: ca-issuer
spec:
  ca:
    secretName: ca-secret 

从前面的清单中,我们可以看到以下内容:

  • 一个名为selfsigned-issuer的自签名发行者。

  • 此发行者用于创建一个名为ca-cert的自签名证书。

  • 证书被赋予通配名hands-on-ca

  • 最后,使用证书 ca-cert 作为其根证书定义了一个自签名 CA,ca-issuer。此 CA 将用于签发网关对象使用的证书。

图表的模板 hands-on-certificate.yaml 将此证书定义为:

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: hands-on-certificate
spec:
  commonName: minikube.me
  subject:
    ...
  dnsNames:
  - minikube.me
  - health.minikube.m
  - dashboard.minikube.me
  - kiali.minikube.me
  - tracing.minikube.me
  - prometheus.minikube.me
  - grafana.minikube.me
  - kibana.minikube.me
  - elasticsearch.minikube.me
  - mail.minikube.me
  issuerRef:
    name: ca-issuer
  secretName: hands-on-certificate 

从此清单中,我们可以了解到:

  • 证书命名为 hands-on-certificate

  • 其通用名称设置为 minikube.me

  • 它指定了一些关于其 subject 的可选额外详细信息(为了清晰起见省略)

  • 所有其他主机名都在证书中声明为 Subject Alternative Names

  • 它将使用上面声明的名为 ca-issuer 的发行者

  • cert-manager 将 TLS 证书存储在名为 hands-on-certificate 的 Secret 中

istio-system Helm 图表安装时,这些模板被用来在 Kubernetes 中创建相应的 API 对象。这触发了 cert-manager 创建证书和 Secrets。

模板文件可以在 kubernetes/helm/environments/istio-system/templates 文件夹中找到。

要验证 Istio 入口网关使用的是这些证书,我们可以运行以下命令:

keytool -printcert -sslserver minikube.me | grep -E "Owner:|Issuer:" 

预期以下输出:

计算机屏幕截图  描述由中等置信度自动生成

图 18.17:检查 minikube.me 的证书

输出显示,该证书是为通用名称 minikube.se 签发的,并且是由我们自己的 CA 签发的,使用其通用名称为 hands-on-ca 的根证书。

第十七章实现 Kubernetes 功能以简化系统景观(参见 自动化证书供应 部分)所述,此自签名 CA 需要替换为生产用例中的 Let’s Encrypt 或其他 cert-manager 可以用来提供信任证书的 CA。

在验证证书配置后,让我们继续看看 Istio 入口网关如何保护微服务免受未认证请求。

使用 OAuth 2.0/OIDC 访问令牌验证外部请求

Istio 入口网关可以要求并验证基于 JWT 的 OAuth 2.0/OIDC 访问令牌,换句话说,保护服务网格中的微服务免受外部未认证请求。关于 JWT、OAuth 2.0 和 OIDC 的概述,请参阅 第十一章保护 API 访问(见 使用 OAuth 2.0 和 OpenID Connect 保护 API 部分)。Istio 还可以配置为执行授权,但如 介绍 Istio API 对象 部分所述,我们不会使用它。

这是在 common Helm 图表的模板 _istio_base.yaml 中配置的。两个清单看起来如下:

apiVersion: security.istio.io/v1beta1
kind: RequestAuthentication
metadata:
  name: product-composite-request-authentication
spec:
  jwtRules:
  - forwardOriginalToken: true
    issuer: http://auth-server
    jwksUri: http://auth-server.hands-on.svc.cluster.local/oauth2/jwks
  selector:
    matchLabels:
      app.kubernetes.io/name: product-composite
---
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: product-composite-require-jwt
spec:
  action: ALLOW
  rules:
  - {}
  selector:
    matchLabels:
      app.kubernetes.io/name: product-composite 

从清单中,我们可以看到以下内容:

  • 命名为 product-composite-request-authenticationRequestAuthentication 要求对发送到 product-composite 服务的请求使用有效的 JWT 编码访问令牌。

  • 它根据标签选择器选择它执行请求认证的服务,app.kubernetes.io/name: product-composite

  • 它允许来自提供者http://auth-server的令牌。

  • 它将使用http://auth-server.hands-on.svc.cluster.local/oauth2/jwks URL 来获取 JWKS。密钥集用于验证访问令牌的数字签名。

  • 它将访问令牌转发到底层服务,在我们的例子中,是product-composite微服务。

  • 命名为product-composite-require-jwtAuthorizationPolicy配置为允许对product-composite服务的所有请求;它不会应用任何授权规则。

要理解 Istio 的RequestAuthentication是否正在验证访问令牌,或者只是product-composite服务正在执行验证可能有点困难。确保 Istio 正在执行其工作的一种方法是将RequestAuthentication的配置更改为始终拒绝访问令牌。

为了验证RequestAuthentication正在生效,应用以下命令:

  1. 发送一个正常请求:

    ACCESS_TOKEN=$(curl https://writer:secret-writer@minikube.me/oauth2/token  -d grant_type=client_credentials -d scope="product:read product:write" -ks | jq .access_token -r)
    echo ACCESS_TOKEN=$ACCESS_TOKEN
    curl -k https://minikube.me/product-composite/1 -H "Authorization: Bearer $ACCESS_TOKEN" -i 
    

    验证它返回的 HTTP 响应状态码为200OK)。

  2. 编辑RequestAuthentication对象,并暂时更改issuer,例如,更改为http://auth-server-x

    kubectl edit RequestAuthentication product-composite-request-authentication 
    
  3. 验证更改:

    kubectl get RequestAuthentication product-composite-request-authentication -o yaml 
    

    验证issuer是否已更新,在我的情况下,更新为http://auth-server-x

  4. 再次发送请求。它应该以 HTTP 响应状态码401(未授权)和错误消息Jwt issuer is not configured失败:

    curl -k https://minikube.me/product-composite/1 -H "Authorization: Bearer $ACCESS_TOKEN" -i 
    

    由于 Istio 传播更改需要几秒钟,您可能需要重复命令几次,直到它失败。

    这证明了 Istio 正在验证访问令牌!

  5. issuer的更改名称恢复为http://auth-server

    kubectl edit RequestAuthentication product-composite-request-authentication 
    
  6. 验证请求是否再次工作。首先,等待几秒钟以使更改传播。然后,运行以下命令:

    curl -k https://minikube.me/product-composite/1 -H "Authorization: Bearer $ACCESS_TOKEN" 
    

建议的附加练习:尝试 Auth0 OIDC 提供者,如第十一章中所述的保护 API 访问(参考使用外部 OpenID Connect 提供者进行测试部分)。将您的 Auth0 提供者添加到jwt-authentication-policy.yml中。在我的情况下,它如下所示:

 - jwtRules:
      issuer: "https://dev-magnus.eu.auth0.com/" 
      jwksUri: "https://dev-magnus.eu.auth0.com/.well-known/jwks.json" 

现在,让我们继续讨论在 Istio 中将要覆盖的最后一个安全机制:使用双向认证,mTLS 自动保护服务网格中的内部通信。

使用双向认证(mTLS)保护内部通信

在本节中,我们将学习如何配置 Istio 以自动使用mTLS保护服务网格内部的通信。在使用双向认证时,不仅服务通过暴露证书来证明其身份,客户端也通过暴露客户端证书来向服务证明其身份。这比仅证明服务身份的正常 TLS/HTTPS 使用提供了更高的安全性。设置和维护双向认证,即为客户分配新证书和轮换过期的证书,被认为是复杂的,因此很少使用。Istio 完全自动化了服务网格内部通信所使用的双向认证证书的分配和轮换。

与手动设置相比,这使得使用双向认证变得容易得多。

那么,为什么我们应该使用双向认证呢?使用 HTTPS 和 OAuth 2.0/OIDC 访问令牌保护外部 API 不是足够了吗?

只要攻击是通过外部 API 发起的,这可能就足够了。但如果 Kubernetes 集群内的 Pod 被攻陷呢?例如,如果攻击者控制了一个 Pod,他们可以开始监听 Kubernetes 集群中其他 Pod 之间的流量。如果内部通信以明文形式发送,攻击者将很容易获取集群中 Pod 之间发送的敏感信息。为了最小化此类入侵造成的损害,可以使用双向认证来防止攻击者窃听内部网络流量。

要启用由 Istio 管理的双向认证的使用,Istio 需要在服务器端配置,使用名为PeerAuthentication的策略,并在客户端使用DestinationRule

该策略配置在common Helm 图表的模板_istio_base.yaml中。其配置文件看起来像这样:

apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
  name: default
spec:
  mtls:
    mode: PERMISSIVE 

如在介绍 Istio API 对象部分中提到的,PeerAuthentication策略配置为使用PERMISSIVE模式允许同时进行 mTLS 和平文 HTTP 请求。这使 Kubernetes 能够使用平文 HTTP 调用存活和就绪探测。

我们已经在_istio_dr_mutual_tls.yaml模板中的内容部分遇到了DestinationRule配置文件。要求 mTLS 的DestinationRule配置文件的核心部分如下所示:

 trafficPolicy:
    tls:
      mode: ISTIO_MUTUAL 

要验证内部通信是否由 mTLS 保护,请执行以下步骤:

  1. 确保在前面的观察服务网格部分启动的负载测试仍在运行并报告200OK)。

  2. 在网页浏览器中访问 Kiali 图(kiali.minikube.me)。

  3. 点击显示按钮并启用安全标签。图表将显示所有由 Istio 的自动双向认证保护的数据通信链接上的锁状图标,如下所示:

计算机截图,描述自动生成,置信度中等

图 18.18:在 Kiali 中检查 mTLS 设置

预期所有链接都将显示一个锁形图标。

对 RabbitMQ、MySQL 和 MongoDB 的调用不由 Istio 代理处理,因此如果需要,需要手动配置以使用 TLS 进行保护。

通过这种方式,我们已经看到了 Istio 中所有三种安全机制的实际应用,现在是时候看看 Istio 如何帮助我们验证服务网格的弹性了。

确保服务网格具有弹性

在本节中,我们将学习如何使用 Istio 确保服务网格具有弹性,也就是说,它能够处理服务网格中的临时故障。Istio 提供了类似于 Spring 框架提供的超时、重试以及一种称为异常检测的断路器类型来处理临时故障。

当涉及到决定是否应该使用语言原生机制来处理临时故障,或者是否应该将其委托给像 Istio 这样的服务网格时,我倾向于更喜欢使用语言原生机制,就像 第十三章使用 Resilience4j 提高弹性 中的例子一样。在许多情况下,保持错误处理的逻辑,例如,处理断路器的回退选项,与其他微服务的业务逻辑一起是很重要的。将处理临时故障的逻辑保留在源代码中也使得使用 JUnit 和测试容器等工具进行测试变得更加容易,如果将处理临时故障的任务委托给像 Istio 这样的服务网格,这个过程会变得更加复杂。

有时候,Istio 中的相应机制可能非常有帮助。例如,如果一个微服务已经部署,并且确定它无法处理生产中偶尔发生的临时故障,那么使用 Istio 添加超时或重试机制可能非常方便,而不是等待带有相应错误处理功能的微服务新版本发布。

Istio 在弹性领域提供的另一个功能是向现有的服务网格中注入故障和延迟的能力。我们为什么想要这样做呢?

以受控的方式注入故障和延迟对于验证微服务的弹性功能是否按预期工作非常有用!我们将在本节中尝试这些功能,验证 product-composite 微服务中的重试、超时和断路器是否按预期工作。

第十三章使用 Resilience4j 提高弹性(参考 添加可编程延迟和随机错误 部分)中,我们添加了对将故障和延迟注入微服务源代码的支持。最好用 Istio 的功能来替换这部分源代码,以便在运行时注入故障和延迟,如下面的子节所示。

我们将首先注入故障以查看 product-composite 微服务中的重试机制是否按预期工作。之后,我们将延迟 product 服务的响应并验证断路器是否按预期处理延迟。

通过注入故障测试弹性

让我们让 product 服务抛出随机错误,并验证微服务景观是否正确处理这种情况。我们预计 product-composite 微服务中的重试机制将启动并重试请求,直到成功或达到最大重试次数限制。这将确保短暂的故障不会比重试尝试引入的延迟更影响最终用户。请参阅 第十三章使用 Resilience4j 提高弹性 中的 添加重试机制 部分,以回顾 product-composite 微服务中的重试机制。

可以使用类似 kubernetes/resilience-tests/product-virtual-service-with-faults.yml 的虚拟服务注入故障。如下所示:

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: product
spec:
  hosts:
    - product
  http:
  - route:
    - destination:
        host: product
    fault:
      abort:
        httpStatus: 500
        percentage:
          value: 20 

定义说明,发送到 product 服务的 20% 请求将使用 HTTP 状态码 500 (Internal Server Error) 被终止。

执行以下步骤以测试此功能:

  1. 确保使用 siege 进行的负载测试,如 观察服务网格 部分中启动的,正在运行。

  2. 使用以下命令应用故障注入:

    kubectl apply -f kubernetes/resilience-tests/product-virtual-service-with-faults.yml 
    
  3. 监控 siege 负载测试工具的输出。期望输出类似于以下内容:计算机屏幕截图  自动生成的描述

    图 18.19:观察重试机制在行动

    从样本输出中,我们可以看到所有请求仍然成功,换句话说,返回了状态 200 (OK);然而,其中一些(20%)需要额外一秒钟来完成。这表明 product-composite 微服务中的重试机制已经启动,并重试了对 product 服务的失败请求。

  4. 使用以下命令移除故障注入以结束测试:

    kubectl delete -f kubernetes/resilience-tests/product-virtual-service-with-faults.yml 
    

现在让我们进入下一节,我们将注入延迟以触发断路器。

通过注入延迟测试弹性

第十三章使用 Resilience4j 提高弹性 我们知道,断路器可以用来防止在接收请求后,由于服务响应缓慢或完全无响应而产生的问题。

让我们通过使用 Istio 向 product 服务注入延迟来验证 product-composite 服务中的断路器是否按预期工作。可以使用虚拟服务注入延迟。

请参阅 kubernetes/resilience-tests/product-virtual-service-with-delay.yml。其代码如下:

apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: product
spec:
  hosts:
    - product
  http:
  - route:
    - destination:
        host: product
    fault:
      delay:
        fixedDelay: 3s
        percent: 100 

此定义说明,发送到 product 服务的所有请求都将延迟 3 秒。

product-composite服务发送到product服务的请求被配置为在 2 秒后超时。断路器被配置为在连续三次请求失败时打开电路。当电路打开时,它将快速失败;换句话说,它将立即抛出异常,不会尝试调用底层服务。product-composite微服务中的业务逻辑将捕获此异常并应用回退逻辑。为了回顾,请参阅第十三章使用 Resilience4j 提高弹性(参考添加断路器和时间限制器部分)。

按照以下步骤通过注入延迟来测试断路器:

  1. 在运行siege的终端窗口中按Ctrl + C停止负载测试。

  2. 使用以下命令在product服务中创建临时延迟:

    kubectl apply -f kubernetes/resilience-tests/product-virtual-service-with-delay.yml 
    
  3. 按照以下方式获取访问令牌:

    ACCESS_TOKEN=$(curl https://writer:secret-writer@minikube.me/oauth2/token  -d grant_type=client_credentials -d scope="product:read product:write" -ks | jq .access_token -r)
    echo ACCESS_TOKEN=$ACCESS_TOKEN 
    
  4. 连续发送六个请求:

    for i in {1..6}; do time curl -k https://minikube.me/product-composite/1 -H "Authorization: Bearer $ACCESS_TOKEN"; done 
    

    预期以下情况:

    1. 断路器在第一次三次失败调用后打开

    2. 断路器对最后三次调用应用快速失败逻辑

    3. 最后三次调用返回回退响应

    预期前三次调用的响应将是一个与超时相关的错误消息,响应时间为 2 秒(换句话说,超时时间)。预期前三次调用的响应如下:

    包含文本、屏幕截图、字体的图片,描述自动生成

    图 18.20:观察超时

    预期最后三次调用的响应将来自回退逻辑,响应时间较短。预期最后三次调用的响应如下:

    一张包含计算机屏幕截图的图片,描述自动生成,置信度中等

    图 18.21:回退方法在行动

  5. 使用以下命令通过移除临时延迟来模拟延迟问题已被解决:

    kubectl delete -f kubernetes/resilience-tests/product-virtual-service-with-delay.yml 
    
  6. 通过使用步骤 4中的for循环命令发送新的请求,验证是否能够再次返回正确答案,并且没有任何延迟。

如果你想检查断路器的状态,可以使用以下命令:

curl -ks https://health.minikube.me/actuator/health | jq -r .components.circuitBreakers.details.product.details.state 

它应该报告CLOSEDOPENHALF_OPEN,具体取决于其状态。

这证明了当使用 Istio 注入延迟时,断路器反应如预期。这标志着测试了可用于验证微服务景观弹性的 Istio 功能。我们将探索 Istio 的下一个功能是它对流量管理的支持;我们将看到它如何被用来实现零停机部署。

执行零停机更新

第十六章中所述,将我们的微服务部署到 Kubernetes,随着越来越多的独立更新的自主微服务数量的增长,能够在不停机的情况下部署更新变得至关重要。

在本节中,我们将了解 Istio 的流量管理和路由能力,以及如何使用它们来部署微服务的新版本,而无需停机。在第十五章Kubernetes 简介中,我们了解到 Kubernetes 可以用于进行滚动升级,而无需停机。使用 Kubernetes 的滚动升级机制可以自动化整个过程,但遗憾的是,它没有提供在所有用户都被路由到新版本之前测试新版本的选择。

使用 Istio,我们可以部署新版本,但最初将所有用户路由到现有版本(在本章中称为版本)。之后,我们可以使用 Istio 的细粒度路由机制来控制用户如何被路由到新版本和旧版本。我们将看到如何使用 Istio 实现两种流行的升级策略:

  • 金丝雀部署:在金丝雀部署中,所有用户都被路由到旧版本,除了被选中的测试用户组,他们被路由到新版本。当测试用户批准新版本后,可以使用蓝绿部署将常规用户路由到新版本。

  • 蓝绿部署:传统上,蓝绿部署意味着所有用户都会切换到蓝色或绿色版本,一个版本是新的,另一个版本是旧的。如果在切换到新版本时出现问题,切换回旧版本非常简单。使用 Istio,可以通过逐渐将用户转移到新版本来细化这种策略,例如,从 20%的用户开始,然后逐渐增加百分比。在任何时候,如果在新版本中发现了致命错误,都可以非常容易地将所有用户路由回旧版本。

如同在第十六章中已经提到的,我们需要记住,这些升级策略的一个先决条件是升级必须是向后兼容的。这种升级在 API 和消息格式方面都是兼容的,这些 API 和消息格式用于与其他服务和数据库结构进行通信。如果新版本的微服务需要对旧版本无法处理的外部 API、消息格式或数据库结构进行更改,那么这些升级策略就不能应用。

我们将讨论以下部署场景:

  1. 我们将首先部署微服务的v1v2版本,并将路由配置为将所有请求发送到微服务的v1版本。

  2. 接下来,我们将允许一个测试组运行金丝雀测试;也就是说,我们将验证微服务的v2新版本。为了简化测试,我们只部署核心微服务的新版本,即productrecommendationreview微服务。

  3. 最后,我们将开始使用蓝绿部署将常规用户迁移到新版本;最初是少量用户,然后随着时间的推移,越来越多的用户,直到最终所有用户都被路由到新版本。如果在新 v2 版本中检测到致命错误,我们还将看到如何快速切换回 v1 版本。

让我们先看看需要应用到源代码中的哪些更改,以便能够部署并将流量路由到核心微服务的两个并发版本,v1v2

源代码更改

为了能够同时运行微服务的多个版本,部署对象及其对应的 Pod 必须有不同的名称,例如,product-v1product-v2。然而,每个微服务必须只有一个 Kubernetes 服务对象。所有流量都通过同一个服务对象到达特定的微服务,无论请求最终将被路由到哪个版本的 Pod。为了配置金丝雀测试和蓝绿部署的实际路由规则,使用 Istio 的 VirtualServiceDestinationRule 对象。最后,prod-env Helm 图表中的 values.yaml 文件用于指定生产环境中将使用的每个微服务的版本。

让我们以下一节中的每个定义的详细信息进行说明:

  • 虚拟服务和目标规则

  • 部署和服务

  • prod-env Helm 图表中整合事物

虚拟服务和目标规则

为了在微服务的两个版本之间分割流量,我们需要在虚拟服务中指定两个版本之间的权重分布,在发送方。虚拟服务将在两个子集之间分配流量,称为 oldnewnewold 子集的确切含义在接收方的相应 DestinationRule 中定义。它使用 labels 来确定哪些 Pod 运行微服务的旧版本和新版本。

为了支持金丝雀测试,虚拟服务中需要一个始终将金丝雀测试者路由到 new 子集的路由规则。为了识别金丝雀测试者,我们将假设来自金丝雀测试者的请求包含一个名为 X-group 的 HTTP 头部,其值为 test

已将一个模板添加到 common Helm 图表,用于创建一组虚拟服务,这些服务可以在微服务的两个版本之间分割流量。该模板名为 _istio_vs_green_blue_deploy.yaml,其内容如下:

{{- define "common.istio_vs_green_blue_deploy" -}}
{{- range $name := .Values.virtualServices }}
apiVersion: networking.istio.io/v1beta1
kind: VirtualService
metadata:
  name: {{ $name }}
spec:
  hosts:
  - {{ $name }}
  http:
  - match:
    - headers:
        X-group:
          exact: test
    route:
    - destination:
        host: {{ $name }}
        subset: new
  - route:
    - destination:
        host: {{ $name }}
        subset: old
      weight: 100
    - destination:
        host: {{ $name }}
        subset: new
      weight: 0
---
{{- end -}}
{{- end -}} 

从模板中,我们可以看到以下内容:

  • range 指令遍历 virtualServices 变量中定义的元素

  • 清单 spec 部分的 hosts 字段用于指定此 VirtualService 将应用到的 Kubernetes 服务的名称

  • http 部分中,声明了三个路由目标:

    • 一个与金丝雀测试者的 HTTP 头部 X-group 匹配的路由,设置为 test。此路由始终将请求发送到 new 子集。

    • old子集和一个new子集的一个路由目标。

    • weight被指定为百分比,权重的总和始终为 100。

  • 所有流量最初都路由到old子集

为了能够根据基于头部的路由将金丝雀测试者路由到新版本,product-composite微服务已被更新为转发 HTTP 头X-group。有关详细信息,请参阅se.magnus.microservices.composite.product.services.ProductCompositeServiceImpl类中的getCompositeProduct()方法。

对于目标规则,我们将重用上面在 _istio_dr_mutual_tls.yaml 模板中的内容部分中引入的模板。此模板将由prod-env Helm 图表用于指定要使用的微服务版本。这将在下面的在 prod-env Helm 图表中整合各项部分中描述。

部署和服务

为了使目标规则能够根据 Pod 的标签识别其版本,common Helm 图表中的部署模板_deployment.yaml已添加了一个version标签。其值设置为 Pod 的 Docker 镜像的tag。我们将使用v1v2的 Docker 镜像标签,因此这也将是version标签的值。添加的行看起来像这样:

 version: {{ .Values.image.tag }} 

为了给 Pod 及其 Deployment 对象命名,包含它们的版本信息,prod-env图表中已覆盖了它们的默认名称。在它们的values.yaml文件中,使用fullnameOverride字段指定包含版本信息的名称。这是为三个核心微服务所做的,看起来像这样:

product:
  fullnameOverride: product-v1
recommendation:
  fullnameOverride: recommendation-v1
review:
  fullnameOverride: review-v1 

这的不希望产生的副作用是,相应的 Service 对象也将获得一个包含版本信息的名称。如上所述,我们需要有一个服务可以将请求路由到 Pod 的不同版本。为了避免这种命名问题,common Helm 图表中的服务模板_service.yaml已更新为使用common.name模板,而不是之前在第十七章中使用的common.fullname模板。

最后,为了能够部署三个核心微服务的多个版本,它们的 Helm 图表已在kubernetes/helm/components文件夹中进行了复制。新图表的名称后缀为-green。与现有图表相比,唯一的区别是它们不包括来自common图表的服务模板,从而避免了每个核心微服务创建两个 Service 对象。新图表的名称为product-greenrecommendation-greenreview-green

在 prod-env Helm 图表中整合各项

prod-env Helm 图表包括来自common Helm 图表的_istio_vs_green_blue_deploy.yaml模板,以及dev-env图表中包含的模板;请参阅创建服务网格部分。

将三个新的*-green Helm 图表作为依赖项添加到Chart.yaml文件中。

在其values.yaml文件中,所有内容都紧密相连。从前一节中,我们看到了如何使用包含版本信息的名称定义核心微服务的v1版本。

对于v2版本,使用三个新的*-green Helm 图表。值与v1版本相同,除了名称和 Docker 镜像标签。例如,product微服务的v2版本配置如下:

product-green:
  fullnameOverride: product-v2
  image:
    tag: v2 

为了声明三个核心微服务的虚拟服务,使用以下声明:

virtualServices:
  - product
  - recommendation
  - review 

最后,以与dev-env Helm 图表类似的方式声明目标规则。主要区别在于我们现在使用子集来声明当虚拟服务将流量路由到oldnew子集时应使用的实际版本。例如,product微服务的目标规则声明如下:

destinationRules:
  - ...
  - name: product
    subsets:
    - labels:
        version: v1
      name: old
    - labels:
        version: v2
      name: new
... 

从上面的声明中,我们可以看到发送到old子集的流量被导向product微服务的v1 Pods,而对于new子集,则导向v2 Pods。

有关详细信息,请参阅kubernetes/helm/environments/prod-env文件夹中prod-env图表中的文件。

注意,这是我们声明生产环境中的现有(旧版)和即将到来(新版)版本的地方,在这个场景中是v1v2。在未来的场景中,当升级v2v3时,old子集应更新为使用v2,而new子集应使用v3

现在,我们已经看到了源代码的所有更改,我们准备部署微服务的v1v2版本。

部署将路由到v1版本的微服务的v1v2版本

要能够测试微服务的v1v2版本,我们需要移除本章前面使用的开发环境,并创建一个可以部署微服务的v1v2版本的生产环境。

要实现这一点,运行以下命令:

  1. 卸载开发环境:

    helm uninstall hands-on-dev-env 
    
  2. 要监控开发环境中 Pod 的终止,运行以下命令直到报告“在 hands-on 命名空间中未找到资源”:

    kubectl get pods 
    
  3. 在 Kubernetes 外部启动 MySQL、MongoDB 和 RabbitMQ:

    eval $(minikube docker-env)
    docker-compose up -d mongodb mysql rabbitmq 
    
  4. 使用v1v2版本标记 Docker 镜像:

    docker tag hands-on/auth-server hands-on/auth-server:v1
    docker tag hands-on/product-composite-service hands-on/product-composite-service:v1
    docker tag hands-on/product-service hands-on/product-service:v1
    docker tag hands-on/recommendation-service hands-on/recommendation-service:v1
    docker tag hands-on/review-service hands-on/review-service:v1
    docker tag hands-on/product-service hands-on/product-service:v2
    docker tag hands-on/recommendation-service hands-on/recommendation-service:v2
    docker tag hands-on/review-service hands-on/review-service:v2 
    

微服务的v1v2版本将是这个测试中微服务的相同版本。但对于 Istio 来说,这并不重要,因此我们可以使用这种简化的方法来测试 Istio 的路由功能。

  1. 使用 Helm 部署系统架构并等待所有部署完成:

    helm install hands-on-prod-env \
      kubernetes/helm/environments/prod-env \
      -n hands-on --wait 
    
  2. 一旦部署完成,使用以下命令验证我们为三个核心微服务创建了v1v2 Pods 并正在运行:

    kubectl get pods 
    

    预期收到如下响应:

计算机程序截图  描述由中等置信度自动生成

图 18.22:同时部署的 v1 和 v2 Pods

  1. 运行常规测试以验证一切是否正常工作:

    ./test-em-all.bash 
    

    不幸的是,测试最初会失败,并显示如下错误信息:

    - Response Body: Jwks doesn't have key to match kid or alg from Jwt 
    

此错误是由 Istio 守护程序 istiod 在开发环境中缓存来自身份验证服务器的 JWKS 公钥引起的。生产环境中的身份验证服务器将具有新的 JWKS 密钥,但与 istiod 具有相同的身份,因此它试图重用旧的 JWKS 公钥,导致此失败。Istio 默认情况下将 JWKS 公钥缓存 20 分钟,但在安装 Istio 时,我们将刷新间隔降低到 15 秒;请参阅在 Kubernetes 集群中部署 Istio部分。因此,等待一段时间后,最长可达一分钟,具体取决于刷新密钥传播的速度,你应该能够成功运行测试。一旦缓存的 JWKS 问题消失,测试可能会失败,出现如下错误:

Test FAILED, EXPECTED VALUE: 3, ACTUAL VALUE: 0, WILL ABORT 

然后,只需重新运行命令,它应该运行良好!这些错误是由 JWKS 缓存引起的原始错误导致的次级故障。

预期输出与之前章节中看到的内容类似:

计算机截图  描述自动生成,置信度中等

图 18.23:测试运行成功

我们现在可以运行一些零停机部署测试了。让我们首先验证所有流量都路由到微服务的 v1 版本!

验证所有流量最初都路由到微服务的 v1 版本

为了验证所有请求都路由到微服务的 v1 版本,我们将启动负载测试工具 siege,然后使用 Kiali 观察通过服务网格的流量。

执行以下步骤:

  1. 获取新的访问令牌并启动 siege 负载测试工具,使用以下命令:

    ACCESS_TOKEN=$(curl https://writer:secret-writer@minikube.me/oauth2/token -d grant_type=client_credentials -d scope="product:read product:write" -ks | jq .access_token -r)
    echo ACCESS_TOKEN=$ACCESS_TOKEN
    siege https://minikube.me/product-composite/1 -H "Authorization: Bearer $ACCESS_TOKEN" -c1 -d1 -v 
    
  2. 前往 Kiali 的 Web UI 中的视图(kiali.minikube.me):

    1. 点击显示菜单按钮并选择命名空间框

    2. 点击应用图菜单按钮并选择版本化应用图

    3. 预期只有流量路由到微服务的v1版本,如下所示:

图表截图  描述自动生成,置信度低

图 18.24:所有请求都发送到 v1 Pods

这意味着,尽管微服务的 v2 版本已部署,但它们没有收到任何流量路由到它们。现在让我们尝试金丝雀测试,允许选定的测试用户尝试微服务的 v2 版本!

运行金丝雀测试

为了运行金丝雀测试,使一些用户被路由到新版本,而所有其他用户仍然被路由到已部署微服务的旧版本,我们需要在我们的发送到外部 API 的请求中添加 X-group HTTP 标头,并将其设置为 test 值。

要查看哪个版本的微服务处理了请求,可以在响应中的serviceAddresses字段进行检查。serviceAddresses字段包含参与创建响应的每个服务的主机名。主机名等于 Pod 的名称,因此我们可以在主机名中找到版本;例如,对于版本为v1product服务,是product-v1-...,对于版本为v2product服务,是product-v2-...

让我们从发送一个正常请求并验证它是否是响应我们请求的微服务的v1版本开始。接下来,我们将发送一个带有X-group HTTP 头设置为值test的请求,并验证新的v2版本是否在响应。

要做到这一点,请执行以下步骤:

  1. 通过使用jq过滤响应中的serviceAddresses字段来执行正常请求以验证请求是否被路由到微服务的v1版本:

    ACCESS_TOKEN=$(curl https://writer:secret-writer@minikube.me/oauth2/token  -d grant_type=client_credentials -d scope="product:read product:write" -ks | jq .access_token -r)
    echo ACCESS_TOKEN=$ACCESS_TOKEN
    curl -ks https://minikube.me/product-composite/1 -H "Authorization: Bearer $ACCESS_TOKEN" | jq .serviceAddresses 
    

    预期响应将与以下类似:

    计算机屏幕截图  自动生成描述

    图 18.25:所有请求都进入 v1 Pods

    如预期,所有三个核心服务都是微服务的v1版本。

  2. 如果我们添加X-group=test头,我们期望请求由核心微服务的v2版本提供服务。运行以下命令:

    curl -ks https://minikube.me/product-composite/1 -H "Authorization: Bearer $ACCESS_TOKEN" -H "X-group: test" | jq .serviceAddresses 
    

    预期响应将与以下类似:

计算机屏幕截图  自动生成描述

图 18.26:将 HTTP 头设置为 X-group=test 使请求进入 v2 Pods

如预期,所有响应的三个核心微服务现在都是v2版本;作为金丝雀测试员,我们被路由到新的v2版本!

由于金丝雀测试返回了预期的结果,我们现在准备允许正常用户通过蓝绿部署路由到新的v2版本。

运行蓝绿部署

要将部分正常用户路由到微服务的新的v2版本,我们需要修改虚拟服务中的权重分布。它们目前是 100/0;换句话说,所有流量都被路由到旧的v1版本。我们可以通过编辑虚拟服务的清单文件并执行一个kubectl apply命令来实现这一点,以使更改生效。作为替代方案,我们可以在 Kubernetes API 服务器中的virtual Service对象上直接使用kubectl patch命令来更改权重分布。

当需要对同一对象进行多项更改以尝试新事物时,我发现patch命令很有用,例如,更改路由规则中的权重分配。在本节中,我们将使用kubectl patch命令快速更改微服务v1v2版本之间路由规则中的权重分配。要获取执行几个kubectl patch命令后虚拟服务的状态,可以发出类似kubectl get vs NNN -o yaml的命令。例如,要获取product微服务虚拟服务的状态,可以发出以下命令:kubectl get vs product -o yaml

由于我们之前没有使用过kubectl patch命令,并且一开始可能会有些复杂,所以在进行蓝绿部署之前,让我们简要介绍一下它是如何工作的。

kubectl patch 命令简介

kubectl patch命令可用于更新 Kubernetes API 服务器中现有对象的特定字段。我们将尝试在名为review的虚拟服务上使用补丁命令,该虚拟服务是用于review微服务的。虚拟服务review定义的相关部分如下:

spec:
  http:
  - match:
    ...
  - route:
    - destination:
        host: review
        subset: old
      weight: 100
    - destination:
        host: review
        subset: new
      weight: 0 

一个示例patch命令,该命令更改了review微服务中v1v2 Pods 的路由权重分配,如下所示:

kubectl patch virtualservice review --type=json -p='[
  {"op": "add", "path": "/spec/http/1/route/0/weight", "value": 80},
  {"op": "add", "path": "/spec/http/1/route/1/weight", "value": 20}
]' 

命令将配置review微服务的路由规则,将 80%的请求路由到旧版本,20%的请求路由到新版本。

要指定在review虚拟服务中更改weight值,为旧版本提供/spec/http/1/route/0/weight路径,为新版本提供/spec/http/1/route/1/weight路径。

路径中的01用于指定虚拟服务定义中数组元素的索引。例如,http/1表示http元素下的数组中的第二个元素。参见前面的review虚拟服务定义。

从定义中我们可以看到,索引为0的第一个元素是match元素,我们不会更改它。第二个元素是route元素,我们想要更改它。

既然我们对kubectl patch命令有了更多了解,我们就准备好测试蓝绿部署了。

执行蓝绿部署

是时候逐渐使用蓝绿部署将越来越多的用户迁移到新版本了。要执行部署,请按照以下步骤操作:

  1. 确保负载测试工具siege仍在运行。注意,它是在前面的验证所有初始流量都流向微服务的 v1 版本部分启动的。

  2. 要允许 20%的用户被路由到新的v2版本的review微服务,我们可以使用以下命令修补虚拟服务并更改权重:

    kubectl patch virtualservice review --type=json -p='[
      {"op": "add", "path": "/spec/http/1/route/0/weight", "value":  
      80},
      {"op": "add", "path": "/spec/http/1/route/1/weight", "value":  
      20}
    ]' 
    
  3. 要观察路由规则的变化,请访问 Kiali Web UI(kiali.minikube.me)并选择图形视图。

  4. 点击显示菜单,将边标签更改为流量分布

  5. 在 Kiali 更新指标之前等待一分钟,以便我们可以观察变化。预期 Kiali 中的图表将显示如下:

计算机屏幕截图  描述自动生成,置信度中等

图 18.27:80%流向 v1 服务,20%流向 v2 服务

根据您等待的时间长短,图表可能看起来略有不同!在屏幕截图中,我们可以看到 Istio 现在将流量路由到review微服务的v1v2版本。

product-composite微服务发送到review微服务的流量中,6.4%被路由到新的v2 Pod,22.3%被路由到旧的v1 Pod。这意味着 6.4/(6.4 + 22.3) = 22%的请求被路由到v2 Pod,78%被路由到v1 Pod。这与我们请求的 20/80 分布相一致。

请随意尝试前面的kubectl patch命令,以影响其他核心微服务(productrecommendation)的路由规则。

为了简化对所有三个核心微服务的权重分布的更改,可以使用./kubernetes/routing-tests/split-traffic-between-old-and-new-services.bash脚本。例如,要将所有流量路由到所有微服务的v2版本,运行以下脚本,并传入权重分布0 100

./kubernetes/routing-tests/split-traffic-between-old-and-new-services.bash 0 100 

在 Kiali 能够可视化路由变化之前,您需要给它一分钟左右的时间来收集指标,但请记住,实际路由的变化是立即发生的!

预期一段时间后,图表中将只显示将请求路由到微服务v2版本的请求:

计算机屏幕截图  描述自动生成,置信度中等

图 18.28:所有流量都流向 v2 服务

根据您等待的时间长短,图表可能看起来略有不同!

如果升级到v2版本后出现严重错误,可以使用以下命令将所有微服务的流量回滚到v1版本:

./kubernetes/routing-tests/split-traffic-between-old-and-new-services.bash 100 0 

稍后,Kiali 中的图表应该看起来像之前验证所有流量最初都流向微服务的 v1 版本部分中的屏幕截图,再次显示所有请求都流向所有微服务的v1版本。

这就完成了对服务网格概念及其实现者 Istio 的介绍。

在结束本章之前,让我们回顾一下如何使用 Docker Compose 运行测试,以确保我们的微服务源代码不依赖于 Kubernetes 的部署或 Istio 的存在。

使用 Docker Compose 运行测试

正如之前多次提到的,确保微服务的源代码从功能角度来看不依赖于像 Kubernetes 或 Istio 这样的平台是很重要的。

为了验证微服务在没有 Kubernetes 和 Istio 的情况下按预期工作,请运行第十七章中描述的测试(参考使用 Docker Compose 进行测试部分)。由于测试脚本test-em-all.bash的默认值已经改变,如运行创建服务网格的命令部分之前所述,使用 Docker Compose 时必须设置以下参数:USE_K8S=false HOST=localhost PORT=8443 HEALTH_URL=https://localhost:8443。例如,要使用默认的 Docker Compose 文件docker-compose.yml运行测试,请执行以下命令:

USE_K8S=false HOST=localhost PORT=8443 HEALTH_URL=https://localhost:8443 ./test-em-all.bash start stop 

测试脚本应该像以前一样,首先启动所有容器;然后运行测试,最后停止所有容器。有关预期输出的详细信息,请参阅第十七章(参考验证微服务在没有 Kubernetes 的情况下工作部分)。

在使用 Docker Compose 成功执行测试后,我们已经验证了从功能角度来看,微服务既不依赖于 Kubernetes 也不依赖于 Istio。这些测试完成了关于使用 Istio 作为服务网格的章节。

摘要

在本章中,我们学习了服务网格的概念和 Istio,这是该概念的开放源代码实现。服务网格为处理微服务系统景观中的挑战提供了能力,例如安全性、策略执行、弹性和流量管理。服务网格还可以用来使微服务系统景观可观察,通过可视化通过微服务的流量。

对于可观察性,Istio 可以与 Kiali、Jaeger 和 Grafana(更多关于 Grafana 和 Prometheus 的内容请参阅第二十章监控微服务)集成。当涉及到安全性时,Istio 可以被配置为使用证书来保护外部 API 使用 HTTPS,并要求外部请求包含有效的基于 JWT 的 OAuth 2.0/OIDC 访问令牌。最后,Istio 可以被配置为使用相互认证(mTLS)自动保护内部通信。

为了弹性和健壮性,Istio 提供了处理重试、超时和类似于熔断器的异常检测机制的机制。在许多情况下,如果可能的话,最好在微服务的源代码中实现这些弹性能力。在 Istio 中注入故障和延迟的能力对于验证服务网格中的微服务作为一个弹性和健壮的系统景观协同工作非常有用。Istio 还可以用来处理零停机时间部署。使用其细粒度的路由规则,既可以进行金丝雀部署,也可以进行蓝绿部署。

我们还没有涉及的一个重要领域是如何收集和分析所有微服务实例创建的日志文件。在下一章中,我们将看到如何使用一个流行的工具栈,称为 EFK 工具栈,基于 Elasticsearch、Fluentd 和 Kibana 来实现这一点。

问题

  1. 服务网格中代理组件的目的是什么?

  2. 服务网格中的控制平面和数据平面有什么区别?

  3. istioctl kube-inject 命令用于什么?

  4. minikube tunnel 命令用于什么?

  5. Istio 集成了哪些工具来实现可观察性?

  6. 要使 Istio 使用相互认证保护服务网格内的通信,需要哪些配置?

  7. 虚拟服务中的 abortdelay 元素可以用作什么?

  8. 设置蓝绿部署场景需要哪些配置?

第十九章:使用 EFK 堆栈进行集中日志记录

在本章中,我们将学习如何从微服务实例收集和存储日志记录,以及如何搜索和分析日志记录。正如我们在第一章微服务介绍中提到的,当每个微服务实例将其日志记录写入其本地文件系统时,在微服务系统景观中很难获得整体概览。我们需要一个组件可以从微服务的本地文件系统中收集日志记录,并将它们存储在中央数据库中以供分析、搜索和可视化。针对此问题的流行开源解决方案基于以下工具:

  • Elasticsearch,一个具有强大搜索和分析大数据集能力的分布式数据库

  • Fluentd,一种可以用于从各种来源收集日志记录、过滤和转换收集到的信息,并将其最终发送到各种消费者(例如,Elasticsearch)的数据收集器

  • Kibana,Elasticsearch 的图形前端,可用于可视化搜索结果和运行收集到的日志记录的分析

这些工具一起被称为EFK 堆栈,以每个工具的首字母命名。

本章将涵盖以下主题:

  • 配置 Fluentd

  • 在 Kubernetes 上部署 EFK 堆栈以供开发和测试使用

  • 分析收集到的日志记录

  • 从微服务中发现日志记录并找到相关的日志记录

  • 执行根本原因分析

技术要求

关于如何安装本书中使用的工具以及如何访问本书源代码的说明,请参阅:

  • 第二十一章macOS 安装说明

  • 第二十二章使用 WSL 2 和 Ubuntu 的 Microsoft Windows 安装说明

本章中的代码示例均来自$BOOK_HOME/Chapter19的源代码。

如果你想查看本章源代码中应用的变化,即查看我们做出的更改,以便我们可以使用 EFK 堆栈进行集中日志分析,你可以将其与第十八章使用服务网格提高可观察性和管理的源代码进行比较。你可以使用你喜欢的diff工具比较两个文件夹,$BOOK_HOME/Chapter18$BOOK_HOME/Chapter19

介绍 Fluentd

在本节中,我们将学习如何配置 Fluentd 的基础知识。在我们这样做之前,让我们了解一下 Fluentd 的背景以及它在高层次上是如何工作的。

Fluentd 概述

从历史上看,处理日志记录最受欢迎的开源堆栈之一是 Elastic 的 ELK 堆栈(www.elastic.co),它基于 Elasticsearch、Logstash(用于日志收集和转换)和 Kibana。由于 Logstash 运行在 Java 虚拟机上,它需要相对较大的内存量。多年来,已经开发出许多开源替代方案,这些方案比 Logstash 需要的内存量少得多,其中之一就是 Fluentd(www.fluentd.org)。

Fluentd 由 云原生计算基金会CNCF)(www.cncf.io)管理,该基金会还管理着 Kubernetes 项目。因此,Fluentd 成为了一个基于开源的日志收集器,它自然地成为在 Kubernetes 中运行的日志收集器的首选。与 Elastic 和 Kibana 一起,它形成了 EFK 堆栈。

CNCF 维护了多个类别的替代产品列表,例如,对于日志记录。有关 CNCF 列出的 Fluentd 替代方案,请参阅 landscape.cncf.io/card-mode?category=logging&grouping=category

Fluentd 是用 C 和 Ruby 混合编写的,使用 C 语言处理性能关键的部分,而在需要更多灵活性的地方使用 Ruby,例如,允许使用 Ruby 的 gem install 命令简单地安装第三方插件。

日志记录在 Fluentd 中作为事件进行处理,并包含以下信息:

  • 一个描述日志记录创建时间的 time 字段

  • 一个标识日志记录类型的 tag 字段——该标签由 Fluentd 的路由引擎用于确定日志记录的处理方式

  • 一个包含实际日志信息的 record,该信息存储为 JSON 对象

Fluentd 配置文件用于告诉 Fluentd 如何收集、处理,并将日志记录最终发送到各种目标,例如 Elasticsearch。配置文件由以下类型的核心元素组成:

  • <source>:源元素描述 Fluentd 将收集日志记录的位置,例如,收集由 Docker 容器写入的日志文件。

    跟踪日志文件意味着监控写入日志文件的内容。一个常用的 Unix/Linux 工具,用于监控文件附加的内容,名为 tile

    源元素通常会对日志记录进行标记,描述日志记录的类型。例如,它们可以用来标记日志记录,表明它们来自在 Kubernetes 中运行的容器。

  • <filter>:过滤器元素用于处理日志记录。例如,一个过滤器元素可以解析来自基于 Spring Boot 的微服务的日志记录,并将日志消息的有趣部分提取到日志记录中的单独字段。将信息提取到日志记录中的单独字段使得信息可以通过 Elasticsearch 进行搜索。过滤器元素根据日志记录的标签选择要处理的日志记录。

  • <match>:匹配元素决定将日志记录发送到何处,充当输出元素。它们用于执行两个主要任务:

    • 将处理后的日志记录发送到目标,例如 Elasticsearch。

    • 路由决定如何处理日志记录。路由规则可以重写标记并将日志记录重新发射到 Fluentd 路由引擎以进行进一步处理。路由规则以 <match> 元素内部的嵌入式 <rule> 元素表示。输出元素决定要处理哪些日志记录,与过滤器类似:基于日志记录的标记。

Fluentd 随带一些内置和外部第三方插件,这些插件被源、过滤器和输出元素使用。在下一节中,我们将通过配置文件了解其中的一些插件。有关可用插件的信息,请参阅 Fluentd 的文档,可在 docs.fluentd.org 找到。

在完成对 Fluentd 的概述之后,我们准备了解 Fluentd 如何配置以处理来自我们的微服务的日志记录。

配置 Fluentd

Fluentd 的配置基于 GitHub 上 Fluentd 项目的配置文件,即 fluentd-kubernetes-daemonset。该项目包含 Fluentd 配置文件,说明如何从在 Kubernetes 中运行的容器收集日志记录,以及一旦处理完毕如何将它们发送到 Elasticsearch。

我们将不修改地重用此配置,这将极大地简化我们的配置。Fluentd 配置文件可以在 github.com/fluent/fluentd-kubernetes-daemonset/tree/master/archived-image/v1.4/debian-elasticsearch/conf 找到。

提供此功能的配置文件是 kubernetes.conffluent.confkubernetes.conf 配置文件包含以下信息:

  • 源元素跟踪容器日志文件以及运行在 Kubernetes 外部的进程(例如,kubelet 和 Docker 守护进程)的日志文件。源元素还会将 Kubernetes 的日志记录标记为具有完整文件名的全名,其中 / 被替换为 . 并以 kubernetes 为前缀。由于标记基于完整文件名,因此名称包含命名空间、Pod 和容器等名称。因此,标记对于通过匹配标记查找感兴趣的日志记录非常有用。

    例如,来自 product-composite 微服务的标记可能类似于 kubernetes.var.log.containers.product-composite-7...s_hands-on_comp-e...b.log,而同一 Pod 中相应的 istio-proxy 的标记可能类似于 kubernetes.var.log.containers.product-composite-7...s_hands-on_istio-proxy-1...3.log

  • 一个过滤器元素,丰富了来自运行在 Kubernetes 内部容器中的日志记录,以及包含容器名称和运行命名空间等信息的特定于 Kubernetes 的字段。

主配置文件fluent.conf包含以下信息:

  • @include语句用于其他配置文件,例如我们之前描述的kubernetes.conf文件。它还包括放置在特定文件夹中的自定义配置文件,这使得我们能够非常容易地重用这些配置文件而无需任何更改,并提供我们自己的配置文件,该文件仅处理与我们的日志记录相关的处理。我们只需将我们的配置文件放置在fluent.conf文件指定的文件夹中即可。

  • 一个输出元素,将日志记录发送到 Elasticsearch。

如后续的部署 Fluentd部分所述,这两个配置文件将被打包到我们将为 Fluentd 构建的 Docker 镜像中。

在我们自己的配置文件中需要覆盖的内容如下:

  • 识别和解析来自我们的微服务的 Spring Boot 格式日志记录。

  • 处理多行堆栈跟踪。堆栈跟踪使用多行写入日志文件,这使得 Fluentd 难以将堆栈跟踪作为一个单独的日志记录处理。

  • 将来自istio-proxy边车和同一 Pod 中运行的微服务创建的日志记录分开。由istio-proxy创建的日志记录不遵循我们基于 Spring Boot 的微服务创建的日志模式。因此,它们必须单独处理,以免 Fluentd 尝试将它们解析为 Spring Boot 格式的日志记录。

为了实现这一点,配置在很大程度上是基于使用rewrite_tag_filter插件。此插件可用于根据更改标签名称的概念路由日志记录,然后将日志记录重新发射到 Fluentd 路由引擎。

该处理由以下 UML 活动图总结:

包含文本、截图、图表的图片,自动生成描述

图 19.1:Fluentd 处理日志记录

从高层次来看,配置文件的设计如下:

  • Istio 的所有日志记录的标签,包括istio-proxy,都以前缀istio开头,以便它们可以与基于 Spring Boot 的日志记录分开。

  • 来自hands-on命名空间的所有日志记录的标签(istio-proxy的日志记录除外)都以前缀spring-boot开头。

  • Spring Boot 的日志记录会检查是否存在多行堆栈跟踪。如果日志记录是多行堆栈跟踪的一部分,它将由第三方 detect-exceptions 插件处理以重新创建堆栈跟踪。否则,它将使用正则表达式解析以提取感兴趣的信息。有关此第三方插件的详细信息,请参阅 Deploying Fluentd 部分。

fluentd-hands-on.conf 配置文件实现了此活动图。配置文件放置在 Kubernetes ConfigMap 内(参见 kubernetes/efk/fluentd-hands-on-configmap.yml)。让我们一步一步地来了解这个过程,如下所示:

  1. 首先是 ConfigMap 的定义和配置文件名,fluentd-hands-on.conf。它看起来如下:

    apiVersion: v1
    kind: ConfigMap
    metadata:
      name: fluentd-hands-on-config
      namespace: kube-system
    data:
      fluentd-hands-on.conf: | 
    

    我们可以看到,data 元素将包含 Fluentd 的配置。它以文件名开始,并使用垂直线 | 标记 Fluentd 嵌入式配置文件的开始。

  2. 第一个 <match> 元素匹配来自 Istio 的日志记录,即以 Kubernetes 开头并包含 istio 作为其命名空间或容器名称一部分的标签。它看起来像这样:

     <match kubernetes.**istio**>
          @type rewrite_tag_filter
          <rule>
            key log
            pattern ^(.*)$
            tag istio.${tag}
          </rule>
        </match> 
    

    让我们解释前面的源代码:

  • <match> 元素匹配任何符合 kubernetes.**istio** 模式的标签,即以 Kubernetes 开头并在标签名称中包含 istio 的标签。istio 可以来自命名空间或容器的名称;两者都是标签的一部分。

  • <match> 元素只包含一个 <rule> 元素,该元素将标签前缀设置为 istio${tag} 变量持有当前标签的值。

  • 由于这是 <match> 元素中的唯一 <rule> 元素,因此它被配置为匹配所有日志记录。

  • 由于所有来自 Kubernetes 的日志记录都有一个 log 字段,因此 key 字段被设置为 log,即规则在日志记录中查找 log 字段。

  • 为了匹配 log 字段中的任何字符串,pattern 字段被设置为 ^(.*)$ 正则表达式。^ 标记字符串的开始,而 $ 标记字符串的结束。(.*) 匹配任意数量的字符,除了换行符。

  • 日志记录被重新发送到 Fluentd 路由引擎。由于配置文件中没有其他元素匹配以 istio 开头的标签,因此日志记录将直接发送到之前描述的 fluent.conf 文件中定义的 Elasticsearch 输出元素。

  1. 第二个 <match> 元素匹配来自 hands-on 命名空间的所有日志记录,即由我们的微服务发出的日志记录。它看起来像这样:

     <match kubernetes.**hands-on**>
          @type rewrite_tag_filter
          <rule>
            key log
            pattern ^(.*)$
            tag spring-boot.${tag}
          </rule>
        </match> 
    

    从源代码中,我们可以看到:

  • 我们微服务发出的日志记录使用由 Spring Boot 定义的日志消息格式规则,因此它们的标签以 spring-boot 开头。然后,它们被重新发送以进行进一步处理。

  • <match> 元素配置方式与之前查看的 <match kubernetes.**istio**> 元素相同,以匹配所有记录。

  1. 第三个 <match> 元素匹配 spring-boot 日志记录,并确定它们是普通 Spring Boot 日志记录还是多行堆栈跟踪的一部分。自 Spring Boot 3 以来,Project Reactor 已向堆栈跟踪添加了额外信息,以澄清导致异常的原因。(有关详细信息,请参阅 projectreactor.io/docs/core/release/reference/#_reading_a_stack_trace_in_debug_mode。)

    为了能够解析实际的堆栈跟踪,我们将过滤掉此信息。<match> 元素看起来像这样:

     <match spring-boot.**>
          @type rewrite_tag_filter
          <rule>
            key log
            pattern /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}([-+]\d{2}:\d{2}|Z).*/
            tag parse.${tag}
          </rule>
          # Get rid of Reactor debug info:
          #
          #   Suppressed: reactor.core.publisher.FluxOnAssembly$OnAssemblyException:
          # Error has been observed at the following site(s):
          #   *__checkpoint ,ᢠHandler se.magnus.microservices.core.product.services.ProductServiceImpl#getProduct(HttpHeaders, int, int, int) [DispatcherHandler]
          #   *__checkpoint ,ᢠorg.springframework.web.filter.reactive.ServerHttpObservationFilter [DefaultWebFilterChain]
          #   *__checkpoint ,ᢠHTTP GET "/product/1?faultPercent=100" [ExceptionHandlingWebHandler]
          # Original Stack Trace:
          <rule>
            key log
            pattern /^\s+Suppressed:.*$/
            tag skip.${tag}
          </rule>
          <rule>
            key log
            pattern /^Error has been observed at the following site.*/
            tag skip.${tag}
          </rule>
          <rule>
            key log
            pattern /^\s+\*__checkpoint.*/
            tag skip.${tag}
          </rule>
          <rule>
            key log
            pattern /^Original Stack Trace:.*/
            tag skip.${tag}
          </rule>
          <rule>
            key log
            pattern /^.*/
            tag check.exception.${tag}
          </rule>
        </match> 
    

    如源代码所示,这是通过使用六个 <rule> 元素来确定的:

  • 第一个使用正则表达式检查日志元素中的 log 字段是否以时间戳开头。

  • 如果 log 字段以时间戳开头,则该日志记录被视为普通的 Spring Boot 日志记录,并且其标签以 parse 为前缀。

  • 接下来是四个规则元素,用于过滤掉 Project Reactor 添加的额外信息;它们都将标签前缀设置为 skip

  • 否则,最后一个 <rule> 元素将匹配,并将日志记录作为多行日志记录处理。其标签以 check.exception 为前缀。

  • 在此处理之后,日志记录将重新发射,并且其标签将开始于 check.exception.spring-bootskip.spring-bootparse.spring-boot

  1. 第四个 <match> 元素用于消除 Project Reactor 的日志输出,即以 skip.spring-boot 开头的匹配标签。该 <match> 元素应用了 null 输出插件,丢弃事件。它看起来像这样:

     <match skip.spring-boot.**>
          @type null
        </match> 
    
  2. 在第五个 <match> 元素中,选定的日志记录具有以 check.exception.spring-boot 开头的标签,即作为多行堆栈跟踪一部分的日志记录。它看起来像这样:

     <match check.exception.spring-boot.**>
          @type detect_exceptions
          languages java
          remove_tag_prefix check
          message log
          multiline_flush_interval 5
        </match> 
    

    detect_exceptions 插件的工作方式如下:

  • detect_exceptions 插件用于将多个单行日志记录合并成一个包含完整堆栈跟踪的单个日志记录。

  • 在多行日志记录重新发射到路由引擎之前,将 check 前缀从标签中移除,以防止日志记录的无限循环处理。

  1. 最后,配置文件由一个 <filter> 元素组成,该元素使用正则表达式解析 Spring Boot 日志消息,提取感兴趣的信息。它看起来像这样:

     <filter parse.spring-boot.**>
          @type parser
          key_name log
          time_key time
          time_format %Y-%m-%dT%H:%M:%S.%N
          reserve_data true
          format /^(?<time>\d{4}-\d{2}-
          \d{2}T\d{2}:\d{2}:\d{2}\.\d{3}([-+]\d{2}:\d{2}|Z))\s+
          (?<spring.level>[^\s]+)\s+
          (\[(?<spring.service>[^,]*),(?<spring.trace>[^,]*),(?
          <spring.span>[^\]]*)]*\])\s+
          (?<spring.pid>\d+)\s+---\s+\[\s*(?<spring.thread>[^\]]+)\]\s+
          (?<spring.class>[^\s]+)\s*:\s+
          (?<log>.*)$/
        </filter> 
    

注意,过滤器元素不会重新发射日志记录;相反,它们只是将它们传递到配置文件中匹配日志记录标签的下一个元素。

从存储在日志记录中 log 字段的 Spring Boot 日志消息中提取以下字段:

  • <time>:日志记录创建时的日期和时间戳

  • <spring.level>:日志记录的日志级别:FATALERRORWARNINFODEBUGTRACE

  • <spring.service>:微服务的名称

  • <spring.trace>:用于执行分布式跟踪的跟踪 ID

  • <spring.span>:span ID,表示分布式处理中该微服务执行的部分的 ID

  • <spring.pid>:进程 ID

  • <spring.thread>:线程 ID

  • <spring.class>:Java 类的名称

  • <log>:实际的日志消息

使用 spring.application.name 属性指定基于 Spring Boot 的微服务的名称。此属性已添加到配置仓库中每个微服务特定的属性文件,位于 config-repo 文件夹中。

正确使用正则表达式可能具有挑战性,至少可以说。幸运的是,有几个网站可以帮助。当涉及到与 Fluentd 一起使用正则表达式时,我建议使用以下网站:fluentular.herokuapp.com/

现在我们已经了解了 Fluentd 的工作原理以及配置文件的构建方式,我们准备部署 EFK 栈。

在 Kubernetes 上部署 EFK 栈

在 Kubernetes 上部署 EFK 栈将与我们的微服务部署方式相同:使用 Kubernetes 清单文件来部署对象,如 Deployments、Services 和 ConfigMaps。

EFK 栈的部署分为三个部分:

  • 部署 Elasticsearch 和 Kibana

  • 部署 Fluentd

  • 设置访问 Elasticsearch 和 Kibana

但首先,我们需要构建和部署我们自己的微服务。

构建和部署我们的微服务

使用 test-em-all.bash 测试脚本构建、部署和验证部署的方式与第十八章 使用服务网格提高可观察性和管理运行命令创建服务网格 部分的方式相同。这些说明假定 cert-manager 和 Istio 已按第十七章和第十八章中的说明安装。

运行以下命令开始:

  1. 首先,使用以下命令从源代码构建 Docker 镜像:

    cd $BOOK_HOME/Chapter19
    eval $(minikube docker-env -u)
    ./gradlew build
    eval $(minikube docker-env)
    docker-compose build 
    

eval $(minikube docker-env -u) 命令确保 ./gradlew build 命令使用主机的 Docker 引擎,而不是 Minikube 实例中的 Docker 引擎。build 命令使用 Docker 运行测试容器。

  1. 重新创建 hands-on 命名空间并将其设置为默认命名空间:

    kubectl delete namespace hands-on
    kubectl apply -f kubernetes/hands-on-namespace.yml
    kubectl config set-context $(kubectl config current-context) --namespace=hands-on 
    
  2. 使用以下命令解决 Helm 图表的依赖关系。

    首先,我们更新 components 文件夹中的依赖项:

    for f in kubernetes/helm/components/*; do helm dep up $f; done 
    

    接下来,我们更新 environments 文件夹中的依赖项:

    for f in kubernetes/helm/environments/*; do helm dep up $f; done 
    
  3. 使用 Helm 部署系统景观并等待所有部署完成:

    helm install hands-on-dev-env \
      kubernetes/helm/environments/dev-env \
      -n hands-on --wait 
    
  4. 如果 Minikube 隧道尚未运行,请在单独的终端窗口中启动它(如需回顾,请参阅第十八章,设置访问 Istio 服务部分):

    minikube tunnel 
    

    记住,这个命令要求你的用户具有sudo权限,并且在启动时输入你的密码。在命令请求密码之前需要几秒钟的时间,所以很容易错过!

    1. 使用以下命令运行正常测试以验证部署:

      ./test-em-all.bash 
      

    预期输出将与我们在前几章中看到的结果相似:

    计算机屏幕截图  自动生成的描述

    图 19.2:测试运行良好

  5. 你也可以通过运行以下命令手动尝试 API:

    ACCESS_TOKEN=$(curl -k https://writer:secret-writer@minikube.me/oauth2/token -d grant_type=client_credentials -d scope="product:read product:write" -s | jq .access_token -r)
    echo ACCESS_TOKEN=$ACCESS_TOKEN
    curl -ks https://minikube.me/product-composite/1 -H "Authorization: Bearer $ACCESS_TOKEN" | jq .productId 
    

预期在响应中收到请求的产品 ID,1

部署了微服务后,我们可以继续部署 Elasticsearch 和 Kibana!

部署 Elasticsearch 和 Kibana

我们将部署 Elasticsearch 和 Kibana 到它们自己的命名空间logging。Elasticsearch 和 Kibana 将使用 Kubernetes Deployment 和 Service 对象进行开发和测试部署。服务将在 Kubernetes 集群内部公开 Elasticsearch 和 Kibana 的标准端口,即 Elasticsearch 的端口9200和 Kibana 的端口5601

要为 Elasticsearch 和 Kibana 提供外部 HTTP 访问,我们将创建与第十八章中相同的 Istio 对象,即使用服务网格来提高可观察性和管理性,用于 Kiali 和 Jaeger – 如果需要,请参阅“设置对 Istio 服务的访问”部分进行回顾。这将使 Elasticsearch 和 Kibana 在elasticsearch.minikube.mekibana.minikube.me可用。

清单文件已打包在kubernetes/helm/environments/logging文件夹中的 Helm 图表中。

有关在 Kubernetes 生产环境中部署 Elasticsearch 和 Kibana 的推荐选项,请参阅www.elastic.co/elastic-cloud-kubernetes

我们将使用在撰写本章时 7.0 版本可用的最新版本:

  • Elasticsearch 版本 7.17.10

  • Kibana 版本 7.17.10

由于 Fluentd 插件对 Elasticsearch 的支持有限,因此没有使用 Elasticsearch 版本 8;请参阅github.com/uken/fluent-plugin-elasticsearch/issues/1005。我们将在下一节中使用此插件来安装 Fluentd 的基 Docker 镜像,即fluentd-kubernetes-daemonset

在我们部署之前,让我们看看 Helm 图表的template文件夹中清单文件中最有趣的部分。

清单文件的概述

Elasticsearch 的清单文件elasticsearch.yml包含了一个标准的 Kubernetes Deployment 和 Service 对象,我们之前已经多次见过,例如在第十五章Kubernetes 简介中的尝试示例部署部分。清单文件中最有趣的部分如下:

apiVersion: apps/v1
kind: Deployment
...
      containers:
      - name: elasticsearch
        image: docker.elastic.co/elasticsearch/elasticsearch:7.17.10
        resources:
          limits:
            cpu: 500m
            memory: 2Gi
          requests:
            cpu: 500m
            memory: 2Gi 

让我们解释一下这些清单文件中的某些内容:

  • 我们使用来自 Elastic 的官方 Docker 镜像,可在 docker.elastic.co 获取。版本设置为 7.17.10

  • Elasticsearch 容器允许分配相对较大的内存量——2 GB——以便能够以良好的性能运行查询。内存越多,性能越好。

Kibana 的配置文件 kibana.yml 也包含标准的 Kubernetes 部署和服务对象。配置文件中最有趣的部分如下:

apiVersion: apps/v1
kind: Deployment
...
      containers:
      - name: kibana
        image: docker.elastic.co/kibana/kibana:7.17.10
        env:
        - name: ELASTICSEARCH_URL
          value: http://elasticsearch:9200 

让我们解释一下一些配置文件:

  • 对于 Kibana,我们也使用来自 Elastic 的官方 Docker 镜像,可在 docker.elastic.co 获取。版本设置为 7.17.10

  • 为了将 Kibana 连接到 Elasticsearch Pod,定义了一个环境变量 ELASTICSEARCH_URL,用于指定 Elasticsearch 服务的地址,http://elasticsearch:9200

最后,设置外部访问的 Istio 配置文件位于 expose-elasticsearch.ymlexpose-kibana.yml 文件中。关于如何使用 GatewayVirtualServiceDestinationRule 对象的复习,请参阅第十八章的 创建服务网格 部分。它们将提供以下外部请求转发:

基于这些见解,我们准备部署 Elasticsearch 和 Kibana。

执行部署命令

通过以下步骤部署 Elasticsearch 和 Kibana:

  1. 为了使部署步骤运行得更快,使用以下命令预取 Elasticsearch 和 Kibana 的 Docker 镜像:

    eval $(minikube docker-env)
    docker pull docker.elastic.co/elasticsearch/elasticsearch:7.17.10
    docker pull docker.elastic.co/kibana/kibana:7.17.10 
    
  2. 使用 Helm 图表创建 logging 命名空间,在其中部署 Elasticsearch 和 Kibana,并等待 Pod 准备就绪:

    helm install logging-hands-on-add-on kubernetes/helm/environments/logging \
        -n logging --create-namespace --wait 
    
  3. 使用以下命令验证 Elasticsearch 是否正在运行:

    curl https://elasticsearch.minikube.me -sk | jq -r .tagline 
    

    预期响应为 You Know, for Search

根据您的硬件配置,您可能需要等待一分钟或两分钟,Elasticsearch 才会响应此消息。

  1. 使用以下命令验证 Kibana 是否正在运行:

    curl https://kibana.minikube.me \
      -kLs -o /dev/null -w "%{http_code}\n" 
    

预期响应为 200

同样,您可能需要等待一分钟或两分钟,Kibana 才会初始化并响应 200

部署了 Elasticsearch 和 Kibana 后,我们可以开始部署 Fluentd。

部署 Fluentd

相比于部署 Elasticsearch 和 Kibana,部署 Fluentd 要复杂一些。为了部署 Fluentd,我们将使用 Fluentd 项目在 Docker Hub 上发布的 Docker 镜像 fluent/fluentd-kubernetes-daemonset 以及来自 GitHub 上 Fluentd 项目的示例 Kubernetes 清单文件 fluentd-kubernetes-daemonset。它位于 github.com/fluent/fluentd-kubernetes-daemonset。正如项目名称所暗示的,Fluentd 将作为 DaemonSet 部署,在每个 Kubernetes 集群的节点上运行一个 Pod。每个 Fluentd Pod 负责收集与 Pod 在同一节点上运行的进程和容器的日志输出。由于我们使用的是具有单个节点集群的 Minikube,因此我们只有一个 Fluentd Pod。

为了处理包含异常堆栈跟踪的多行日志记录,我们将使用由 Google 提供的第三方 Fluentd 插件 fluent-plugin-detect-exceptions,该插件可在 github.com/GoogleCloudPlatform/fluent-plugin-detect-exceptions 找到。为了能够使用此插件,我们将构建自己的 Docker 镜像,其中将安装 fluent-plugin-detect-exceptions 插件。

Fluentd 的 Docker 镜像 fluentd-kubernetes-daemonset 将用作基础镜像。

我们将使用以下版本:

  • Fluentd 版本 1.4.2

  • fluent-plugin-detect-exceptions 版本 0.0.12

在我们部署之前,让我们看看清单文件中最有趣的部分。

对清单文件的概述

用于构建 Docker 镜像的 Dockerfile,kubernetes/efk/Dockerfile,如下所示:

FROM fluent/fluentd-kubernetes-daemonset:v1.4.2-debian-elasticsearch-1.1
RUN gem install fluent-plugin-detect-exceptions -v 0.0.12 \
 && gem sources --clear-all \
 && rm -rf /var/lib/apt/lists/* \
           /home/fluent/.gem/ruby/2.3.0/cache/*.gem 

让我们详细解释一下:

  • 基础镜像是 Fluentd 的 Docker 镜像 fluentd-kubernetes-daemonsetv1.4.2-debian-elasticsearch-1.1 标签指定将使用包含内置支持将日志记录发送到 Elasticsearch 的软件包的 1.4.2 版本。基础 Docker 镜像包含在 配置 Fluentd 部分中提到的 Fluentd 配置文件。

  • Google 插件 fluent-plugin-detect-exceptions 使用 Ruby 的包管理器 gem 进行安装。

DaemonSet 的清单文件 kubernetes/efk/fluentd-ds.yml 基于在 fluentd-kubernetes-daemonset 项目中的一个示例清单文件,该文件可以在 github.com/fluent/fluentd-kubernetes-daemonset/blob/master/fluentd-daemonset-elasticsearch.yaml 找到。

这个文件有点复杂,所以让我们分别查看最有趣的部分:

  1. 首先,这是 DaemonSet 的声明:

    apiVersion: apps/v1
    kind: DaemonSet
    metadata:
      name: fluentd
      namespace: kube-system 
    

    kind 键指定这是一个 DaemonSet。namespace 键指定 DaemonSet 将在 kube-system 命名空间中创建,而不是在 Elasticsearch 和 Kibana 部署的 logging 命名空间中。

  2. 下一个部分指定由 DaemonSet 创建的 Pod 的模板。最有趣的部分如下:

    spec:
      template:
        spec:
          containers:
          - name: fluentd
            image: hands-on/fluentd:v1
            env:
              - name: FLUENT_ELASTICSEARCH_HOST
                value: "elasticsearch.logging"
              - name: FLUENT_ELASTICSEARCH_PORT
                value: "9200" 
    

    用于 Pod 的 Docker 镜像为hands-on/fluentd:v1。我们将使用之前描述的 Dockerfile 构建此 Docker 镜像。

    Docker 镜像支持多个环境变量,并用于自定义它。其中两个最重要的如下:

  • FLUENT_ELASTICSEARCH_HOST,指定 Elasticsearch 服务的域名,elasticsearch.logging

  • FLUENT_ELASTICSEARCH_PORT,指定用于与 Elasticsearch 通信的端口,9200

由于 Fluentd Pod 运行在不同的命名空间中,因此不能使用其短名称elasticsearch来指定主机名。相反,DNS 名称的命名空间部分也必须指定,即elasticsearch.logging。作为替代,也可以使用完全限定域名FQDNelasticsearch.logging.svc.cluster.local。但由于 DNS 名称的最后部分svc.cluster.local在 Kubernetes 集群内的所有 DNS 名称中都是共享的,因此不需要指定。

  1. 最后,将多个卷(即文件系统)映射到 Pod,如下所示:

     volumeMounts:
            - name: varlog
              mountPath: /var/log
            - name: varlibdockercontainers
              mountPath: /var/lib/docker/containers
              readOnly: true
            - name: journal
              mountPath: /var/log/journal
              readOnly: true
            - name: fluentd-extra-config
              mountPath: /fluentd/etc/conf.d
          volumes:
          - name: varlog
            hostPath:
              path: /var/log
          - name: varlibdockercontainers
            hostPath:
              path: /var/lib/docker/containers
          - name: journal
            hostPath:
              path: /run/log/journal
          - name: fluentd-extra-config
            configMap:
              name: "fluentd-hands-on-config" 
    

让我们详细查看源代码:

  • 主机(即节点)上的三个文件夹映射到 Fluentd Pod。这些文件夹包含 Fluentd 将跟踪和收集日志记录的日志文件。这些文件夹是/var/log/var/lib/docker/containers/run/log/journal

  • 我们自己的配置文件,该文件指定了 Fluentd 如何处理来自我们的微服务的日志记录,通过名为fluentd-hands-on-config的 ConfigMap 映射到/fluentd/etc/conf.d文件夹。基础 Docker 镜像配置 Fluentd 以包含在/fluentd/etc/conf.d文件夹中找到的任何配置文件。有关详细信息,请参阅配置 Fluentd部分。

对于 DaemonSet 的完整源代码清单文件,请参阅kubernetes/efk/fluentd-ds.yml文件。

现在我们已经了解了所有内容,我们准备执行 Fluentd 的部署。

运行部署命令

要部署 Fluentd,我们必须构建 Docker 镜像,创建 ConfigMap,最后部署 DaemonSet。运行以下命令执行这些步骤:

  1. 使用以下命令构建 Docker 镜像并使用hands-on/fluentd:v1标记它:

    eval $(minikube docker-env)
    docker build -f kubernetes/efk/Dockerfile -t hands-on/fluentd:v1 kubernetes/efk/ 
    
  2. 使用以下命令创建 ConfigMap,部署 Fluentd 的 DaemonSet,并等待 Pod 就绪:

    kubectl apply -f kubernetes/efk/fluentd-hands-on-configmap.yml 
    kubectl apply -f kubernetes/efk/fluentd-ds.yml
    kubectl wait --timeout=120s --for=condition=Ready pod -l app=fluentd -n kube-system 
    
  3. 使用以下命令验证 Fluentd Pod 是否健康:

    kubectl logs -n kube-system -l app=fluentd --tail=-1 | grep "fluentd worker is now running worker" 
    

    预期响应为2023-05-22 14:59:46 +0000 [info]: #0 fluentd worker is now running worker=0

对于 Elasticsearch 和 Kibana,在 Fluentd 响应此消息之前,可能需要等待一分钟或两分钟。

  1. Fluentd 将从 Minikube 实例中的各种容器开始收集相当数量的日志记录。大约一分钟后,你可以使用以下命令询问 Elasticsearch 收集了多少日志记录:

    curl https://elasticsearch.minikube.me/_all/_count -sk | jq .count 
    
  2. 第一次执行此命令时可能会有些慢,但应该返回数千条日志记录的总数。在我的情况下,它返回了55607

这完成了 EFK 堆栈的部署。现在,是时候尝试它并找出所有收集到的日志记录的内容了!

尝试 EFK 堆栈

在我们尝试 EFK 堆栈之前,我们需要先初始化 Kibana,以便它知道在 Elasticsearch 中使用哪些索引。

在 Elasticsearch 中,索引对应于 SQL 概念中的数据库。SQL 概念中的对应于 Elasticsearch 中的类型文档属性

完成上述操作后,我们将尝试以下常见任务:

  1. 我们将首先分析 Fluentd 收集并存储在 Elasticsearch 中的日志记录类型。Kibana 具有非常实用的可视化功能,可用于此目的。

  2. 接下来,我们将学习如何在处理外部请求时查找由微服务创建的所有相关日志记录。我们将使用日志记录中的跟踪 ID作为关联 ID 来查找相关日志记录。

  3. 最后,我们将学习如何使用 Kibana 进行根本原因分析,找到错误的真正原因。

初始化 Kibana

在我们开始使用 Kibana 之前,我们必须指定在 Elasticsearch 中使用哪些搜索索引以及索引中哪个字段持有日志记录的时间戳。

提醒一下,我们使用的是由我们自己的 CA 创建的证书,这意味着它不被网络浏览器信任!有关如何使网络浏览器接受我们的证书的复习,请参阅第十八章的观察服务网格部分。

执行以下步骤以初始化 Kibana:

  1. 使用网络浏览器中的kibana.minikube.me URL 打开 Kibana 的 Web UI。

  2. 欢迎主页上,点击左上角的汉堡菜单(三条横线)并在菜单底部点击左侧的堆栈管理

  3. 管理菜单中,滚动到最底部并选择索引模式

  4. 点击名为创建索引模式的按钮。

  5. 将索引模式名称输入为logstash-*并点击下一步按钮。

由于历史原因,索引默认命名为logstash,尽管使用了 Fluentd 进行日志收集。

  1. 点击时间戳字段的下拉列表并选择唯一可用的字段,@timestamp

  2. 点击创建索引模式按钮。

Kibana 将显示一个页面,总结所选索引中可用的字段。

在 Kibana 初始化后,我们准备好检查收集到的日志记录。

分析日志记录

从 Fluentd 的部署开始,我们就知道它立即开始收集大量的日志记录。因此,我们首先需要做的是了解 Fluentd 收集并存储在 Elasticsearch 中的日志记录类型。

我们将使用 Kibana 的可视化功能按 Kubernetes 命名空间划分日志记录,然后要求 Kibana 显示每个命名空间内日志记录按容器类型划分的情况。饼图是此类分析合适的图表类型。按照以下步骤创建饼图:

  1. 在 Kibana 的 Web UI 中,再次点击汉堡菜单,并在菜单中选择分析下的可视化库

  2. 点击创建新可视化按钮,并在下一页选择透镜类型。将显示如下网页:

计算机截图  描述自动生成,置信度中等

图 19.3:在 Kibana 中开始分析日志记录

  1. 确认logstash-*是左上角下拉菜单中选定的索引模式。

  2. 在索引模式旁边的垂直堆叠条形图下拉菜单中,选择饼图作为可视化类型。

  3. 在饼图上方的日期选择器(一个日期区间选择器)中,设置一个足够大的日期区间以覆盖感兴趣的日志记录(在下述截图中设置为最后 15 分钟)。点击其日历图标以调整时间区间。

  4. 在索引模式下方名为搜索字段名称的域中,输入kubernetes.namespace_name.keyword

  5. 可用字段列表下,现在出现了字段kubernetes.namespace_name.keyword。将此字段拖放到页面中间名为在此处放下一些字段以开始的大框中。Kibana 将立即开始分析日志记录并按 Kubernetes 命名空间绘制饼图。

    在我的情况下,看起来是这样的:

    计算机截图  描述自动生成,置信度中等

    图 19.4:Kibana 按 Kubernetes 命名空间分析日志记录

    我们可以看到日志记录被分为我们在前几章中一直在使用的命名空间:kube-systemistio-systemlogging以及我们自己的hands-on命名空间。要查看每个命名空间中创建了哪些容器日志记录,我们需要添加第二个字段。

  6. 搜索字段名称字段中,输入kubernetes.container_name.keyword

  7. 可用字段列表中,现在出现了字段kubernetes.container_name.keyword。将此字段拖放到页面中间显示饼图的较大框中。Kibana 将立即开始分析日志记录并按 Kubernetes 命名空间和容器名称绘制饼图。

  8. 步骤 9的结果中,我们可以看到来自coredns的大量日志记录,在我的情况下占 67%。由于我们对此类日志记录不特别感兴趣,我们可以通过以下步骤添加过滤器来删除它们:

    1. 点击+ 添加过滤器(在左上角)。

    2. 选择字段kubernetes.container_name.keyword不是-****Operator。最后,输入coredns并点击保存按钮。

  9. 在我的情况下,渲染的饼图现在看起来是这样的:计算机屏幕截图  描述由中等置信度自动生成

    图 19.5:Kibana 按命名空间和容器分析日志记录

    在这里,我们可以找到我们的微服务的日志记录。大多数日志记录来自reviewrecommendation微服务。productproduct-composite微服务可以在饼图的其他部分找到。

  10. 通过在仪表板中保存此饼图来总结如何分析我们收集的日志记录类型。点击右上角的保存按钮。

  11. 在名为保存透镜可视化的页面上,执行以下操作:

    1. 给它一个标题,例如,hands-on-visualization

    2. 输入一个描述,例如,这是我在 Kibana 中的第一个可视化

    3. 添加到仪表板框中,选择新建。页面应该看起来像这样:

计算机屏幕截图  描述由中等置信度自动生成

图 19.6:在 Kibana 中创建仪表板

  1. 点击名为保存并转到仪表板的按钮。应该会呈现以下仪表板:

计算机屏幕截图  描述由中等置信度自动生成

图 19.7:Kibana 中的新仪表板

  1. 点击右上角的保存按钮,给仪表板起一个名字,例如,hands-on-dashboard,然后点击保存按钮。

您现在可以通过从汉堡菜单中选择仪表板始终返回此仪表板。

Kibana 包含大量用于分析日志记录的功能——请随意尝试。为了获得灵感,请参阅www.elastic.co/guide/en/kibana/7.17/dashboard.html。现在,我们将继续前进,开始从我们的微服务中定位实际的日志记录。

发现微服务的日志记录

在本节中,我们将学习如何利用集中式日志记录的主要功能之一,即从我们的微服务中查找日志记录。我们还将学习如何使用日志记录中的跟踪 ID 来查找属于同一进程的其他微服务的日志记录,例如,处理发送到公共 API 的外部请求。

让我们先创建一些日志记录,我们可以借助 Kibana 来查找它们。我们将使用 API 创建一个具有唯一产品 ID 的产品,然后检索有关该产品的信息。之后,我们可以尝试查找在检索产品信息时创建的日志记录。

微服务中的日志记录创建与上一章略有不同,以便 product-composite 和三个核心微服务 productrecommendationreview 在开始处理 get 请求时都记录一个日志级别设置为 INFO 的日志记录。让我们回顾每个微服务中添加的源代码:

  • 产品组合微服务日志创建:

    LOG.info("Will get composite product info for product.id={}", productId); 
    
  • 产品微服务日志创建:

    LOG.info("Will get product info for id={}", productId); 
    
  • 推荐微服务日志创建:

    LOG.info("Will get recommendations for product with id={}", productId); 
    
  • 审查微服务日志创建:

    LOG.info("Will get reviews for product with id={}", productId); 
    

更多详细信息,请参阅 microservices 文件夹中的源代码。

执行以下步骤以使用 API 创建日志记录,然后使用 Kibana 查找日志记录:

  1. 使用以下命令获取访问令牌:

    ACCESS_TOKEN=$(curl -k https://writer:secret-writer@minikube.me/oauth2/token -d grant_type=client_credentials -d scope="product:read product:write" -s | jq .access_token -r)
    echo ACCESS_TOKEN=$ACCESS_TOKEN 
    
  2. 如本节介绍中所述,我们将首先创建一个具有唯一产品 ID 的产品。通过执行以下命令创建一个最小化产品(没有推荐和评论)为 "productId" :1234

    curl -X POST -k https://minikube.me/product-composite \
      -H "Content-Type: application/json" \
      -H "Authorization: Bearer $ACCESS_TOKEN" \
      --data '{"productId":1234,"name":"product name 1234","weight":1234}' 
    

    使用以下命令读取产品:

    curl -H "Authorization: Bearer $ACCESS_TOKEN" -k 'https://minikube.me/product-composite/1234' -s | jq . 
    

    预期响应类似于以下内容:

    计算机程序屏幕截图  描述自动生成,置信度中等

    图 19.8:查找 productId = 1234 的产品

    希望我们能通过这些 API 调用创建一些日志记录。让我们跳转到 Kibana 并查看一下!

  3. 在 Kibana 网页上,从汉堡菜单点击 Discover。你会看到如下内容:计算机屏幕截图  描述自动生成,置信度中等

    图 19.9:Kibana 网页 UI 及其主要部分

    在左上角,我们可以看到 Kibana 找到了 5,350 条日志记录。时间选择器显示它们来自 过去 15 分钟。在直方图中,我们可以看到日志记录随时间分布的情况。直方图下方是一个表格,显示了查询找到的最新的日志事件。

  4. 如果你想更改时间间隔,可以使用时间选择器。点击其日历图标以调整时间间隔。

  5. 为了更好地查看日志记录中的内容,将一些日志记录字段添加到直方图下方的表格中的列。

  6. 要查看所有可用字段,点击 按类型筛选 标签右侧的向下箭头,并取消选择 隐藏空字段

  7. 从左侧的 可用字段 列表中选择字段。向下滚动直到找到该字段。为了更容易地找到字段,使用名为 搜索字段名称 的字段来过滤可用字段列表。

    将光标悬停在字段上,将出现一个 + 按钮(蓝色圆圈中的白色十字);点击它将字段添加为表格中的列。按以下顺序选择以下字段:

    1. spring.level,日志级别

    2. kubernetes.namespace_name,Kubernetes 命名空间

    3. kubernetes.container_name,容器的名称

    4. spring.trace,用于分布式跟踪的跟踪 ID

    5. log,实际的日志消息

    为了节省空间,你可以通过点击索引模式字段(包含文本logstash-*)旁边的折叠图标来隐藏字段列表。

    网页应该看起来像以下这样:

    计算机屏幕截图  描述自动生成,置信度中等

    图 19.10:Kibana 网页 UI 显示日志记录

    现在表格中包含了一些关于日志记录的有用信息!

  8. 要找到来自GET API 调用的日志记录,我们可以要求 Kibana 找到日志字段包含文本product.id=1234的日志记录。这与之前显示的product-composite微服务的日志输出相匹配。

    这可以通过在左上角的搜索字段中输入log:"product.id=1234"并点击更新按钮(此按钮也可以命名为刷新)来完成。预期会找到一条日志记录:

    计算机屏幕截图  描述自动生成,置信度中等

    图 19.11:Kibana 网页 UI 显示 productId = 1234 的日志记录

  9. 确认时间戳是你调用GET API 时的时间,并确认创建日志记录的容器名称是product-composite,也就是说,确认日志记录是由产品组合微服务发送的。

  10. 现在,我们想看到参与返回产品 ID 为1234的产品信息过程的其它微服务的相关日志记录。换句话说,我们想找到与我们所找到的日志记录具有相同跟踪 ID的日志记录。

    要做到这一点,将光标移至日志记录的spring.trace字段。字段右侧将显示两个小放大镜,一个带有+符号,一个带有-符号。点击带有+符号的放大镜以过滤跟踪 ID。

  11. 清除搜索字段,以便唯一的搜索条件是跟踪字段的过滤器。然后,点击更新按钮查看结果。预期会得到以下类似的响应:计算机屏幕截图  描述自动生成,置信度中等

    图 19.12:Kibana 网页 UI 显示跟踪 ID 的日志记录

    我们可以看到一些详细的调试消息,它们使视图变得杂乱;让我们去掉它们!

  12. 将光标移至DEBUG值上,并点击带有符号的放大镜以过滤出日志级别设置为DEBUG的日志记录。

  13. 现在我们应该能够看到四个预期的日志记录,每个对应于查找产品 ID 为1234的产品信息时涉及的每个微服务:

计算机屏幕截图  描述自动生成,置信度中等

图 19.13:Kibana 网页 UI 显示日志级别为 INFO 的跟踪 ID 的日志记录

此外,请注意,应用的过滤器包括跟踪 ID,但排除了日志级别设置为DEBUG的日志记录。

现在我们知道了如何找到预期的日志记录,我们就可以进行下一步了。这将是学习如何找到意外的日志记录,即错误信息,以及如何进行根本原因分析以找到这些错误信息的原因。

进行根本原因分析

集中日志最重要的特性之一是它使得使用来自许多来源的日志记录来分析错误成为可能,并且基于这些分析进行根本原因分析,找出错误信息的真正原因。

在本节中,我们将模拟一个错误,并查看我们如何能够找到关于它的所有信息,直到找到在系统景观中某个微服务中引起错误的源代码行。为了模拟错误,我们将重用我们在第十三章的使用 Resilience4j 提高弹性部分中引入的故障参数,在添加可编程延迟和随机错误部分中使用它来强制product微服务抛出异常。执行以下步骤:

  1. 运行以下命令在搜索产品 ID 为1234的产品信息时在product微服务中生成故障:

    curl -H "Authorization: Bearer $ACCESS_TOKEN" -k https://minikube.me/product-composite/1234?faultPercent=100 -s | jq . 
    

    预期以下错误响应:

    包含文本、截图、软件、多媒体的图片,自动生成描述

    图 19.14:一个在处理中引起错误的请求

    现在,我们必须假装我们对这个错误的原因一无所知!否则,根本原因分析就不会那么令人兴奋,对吧?

    假设我们在一个支持组织中工作,并被要求调查一个问题,即当最终用户尝试查找产品 ID 为1234的产品信息时,却收到了显示“500 Internal Server Error”错误信息的响应。

  2. 在我们开始分析问题之前,让我们在 Kibana web UI 中删除之前的搜索过滤器,以便我们可以从头开始。对于我们在上一节中定义的每个过滤器,点击其关闭图标(一个x)来移除它。

  3. 首先,使用时间选择器选择一个包含问题发生时间点的时段。在我的情况下,15 分钟就足够了。

  4. 选择属于我们的命名空间hands-on的日志记录。可以通过以下步骤完成:

    1. 通过点击左上角的汉堡图标()来展开左侧的字段列表。

    2. 点击列表中的kubernetes.namespace_name字段。显示的是前五个命名空间列表。

    3. 点击hands-on命名空间后面的+号。

  5. 接下来,搜索在此时间范围内设置日志级别为WARN的日志记录,其中日志消息提到了产品 ID 1234。这可以通过点击所选字段列表中的spring.level字段来完成。当你点击这个字段时,其最常用的值将显示在其下方。通过点击其+号来过滤WARN值。Kibana 现在将显示在所选时间范围内设置日志级别为WARN的日志记录,来自hands-on命名空间,如下所示:计算机的截图  描述自动生成,置信度中等

    图 19.15:Kiali 网页 UI,显示报告 ERRORs 的日志记录

    我们可以看到与产品 ID 1234相关的多个错误消息。顶部日志条目具有相同的跟踪 ID,因此这似乎是一个值得进一步调查的跟踪 ID。第一条日志条目还包含最终用户报告的文本500Internal Server Error,以及错误消息Something went wrong…,这可能与错误的根本原因有关。

  6. 在上一节中,我们按照同样的方式过滤了第一条日志记录的跟踪 ID。

  7. 移除WARN日志级别的过滤器,以便能够看到属于此跟踪 ID 的所有记录。预期 Kibana 将响应大量类似以下的日志记录:计算机的截图  描述自动生成,置信度中等

    图 19.16:Kiali 网页 UI,寻找根本原因

    很遗憾,我们无法通过使用跟踪 ID 来找到识别根本原因的堆栈跟踪。这是由于我们用于收集多行异常的 Fluentd 插件fluent-plugin-detect-exceptions的限制。它无法将堆栈跟踪与所使用的跟踪 ID 相关联。相反,我们可以使用 Kibana 中的功能来查找在特定日志记录附近发生的时间相近的日志记录。

  8. 使用日志记录左侧的箭头展开显示Error body: {… status”:500,”error”:”Internal Server Error”,”message”:”Something went wrong...”…}的日志记录。关于此特定日志记录的详细信息将被揭示:

计算机的截图  描述自动生成,置信度中等

图 19.17:Kiali 网页 UI,展开带有根本原因日志消息的日志记录

  1. 还有一个名为查看周围文档的链接;点击它以查看附近的日志记录。滚动到页面底部以找到可以指定记录数量的加载字段。将默认值从 5 增加到 10。预期网页如下所示:

计算机的截图  描述自动生成,置信度中等

图 19.18:Kiali 网页 UI,找到了根本原因

  1. 在展开的日志记录下方第三条日志记录包含了错误消息出了点问题...的堆栈跟踪。这个错误消息看起来很有趣。它是在展开的日志记录前五毫秒由product微服务记录的。它们似乎有关联!该日志记录中的堆栈跟踪指向ProductServiceImpl.java中的第 104 行。查看源代码(见microservices/product-service/src/main/java/se/magnus/microservices/core/product/services/ProductServiceImpl.java),第 104 行如下:

    throw new RuntimeException("Something went wrong..."); 
    

    这是错误的根本原因。我们事先确实知道这一点,但现在我们也看到了如何导航到它。

在这种情况下,问题非常简单就能解决;只需在 API 请求中省略faultPercent参数即可。在其他情况下,确定根本原因可能要困难得多!

  1. 这就结束了根本原因分析。点击网页浏览器中的后退按钮返回主页。

  2. 为了能够重用搜索条件和表格布局的配置,可以通过 Kibana 保存其定义。例如,选择对hands-on命名空间中的日志记录进行过滤,然后在右上角的菜单中点击保存链接。为搜索定义命名并点击保存按钮。当需要时,可以使用菜单中的打开链接恢复搜索定义。

这就结束了关于使用 EFK 栈进行集中日志记录的章节。

摘要

在本章中,我们学习了在系统视图中从微服务收集日志记录并将其放入一个公共集中数据库的重要性,在那里可以对存储的日志记录进行分析和搜索。我们使用了 EFK 栈,包括 Elasticsearch、Fluentd 和 Kibana,来收集、处理、存储、分析和搜索日志记录。

Fluentd 不仅用于从我们的微服务中收集日志记录,还用于 Kubernetes 集群中的各种支持容器。Elasticsearch 被用作文本搜索引擎。与 Kibana 一起,我们看到了理解我们收集了哪些类型日志记录是多么容易。

我们还学习了如何使用 Kibana 执行重要任务,例如从协作微服务中查找相关日志记录以及如何进行根本原因分析,找到错误消息的真实问题。

能够以这种方式收集和分析日志记录在生产环境中是一个重要的能力,但这些类型的活动总是在收集日志记录之后才进行。另一个重要的能力是能够监控微服务的当前健康状况,从硬件资源的使用、响应时间等方面收集和可视化运行时指标。我们在上一章中提到了这个主题,在下一章中,我们将学习更多关于监控微服务的内容。

问题

  1. 用户使用以下截图所示的搜索条件在hands-on命名空间中搜索了最后 30 天的ERROR日志消息,但没有找到任何结果。为什么?

计算机屏幕截图  描述自动生成,置信度中等

图 19.19:Kiali 网页界面,未显示预期的日志记录

  1. 用户发现了一条有趣的日志记录(如下所示)。用户如何从这条和其他微服务中找到相关的日志记录,例如,来自处理外部 API 请求的微服务?

计算机屏幕截图  描述自动生成,置信度中等

图 19.20:Kiali 网页界面;我们如何找到相关的日志记录?

  1. 用户发现了一条日志记录,似乎表明了由最终用户报告的问题的根本原因。用户如何找到显示错误发生位置的源代码中的堆栈跟踪?

计算机屏幕截图  描述自动生成,置信度低

图 19.21:Kiali 网页界面;我们如何找到根本原因?

  1. 为什么以下 Fluentd 配置元素不起作用?

    <match kubernetes.**hands-on**>
      @type rewrite_tag_filter
      <rule>
        key log
        pattern ^(.*)$
        tag spring-boot.${tag}
      </rule>
    </match> 
    
  2. 你如何确定 Elasticsearch 是否正在运行?

  3. 你突然从网络浏览器中失去了与 Kibana 的连接。可能是什么原因导致了这个问题?

第二十章:微服务监控

在本章中,我们将学习如何使用 Prometheus 和 Grafana 收集、监控和警报性能指标。正如我们在第一章微服务简介中提到的,在生产环境中,能够收集应用程序性能和硬件资源使用的指标至关重要。监控这些指标是避免 API 请求和其他过程出现长时间响应或中断的必要条件。

为了以成本效益和主动的方式监控微服务系统景观,我们还必须能够定义当指标超过配置的限制时自动触发的警报。

在本章中,我们将涵盖以下主题:

  • 使用 Prometheus 和 Grafana 的性能监控简介

  • 收集应用程序指标的源代码更改

  • 构建和部署微服务

  • 使用 Grafana 仪表板监控微服务

  • 在 Grafana 中设置警报

技术要求

关于如何安装本书中使用的工具以及如何访问本书源代码的说明,请参阅:

  • 第二十一章macOS 的安装说明

  • 第二十二章使用 WSL 2 和 Ubuntu 的 Microsoft Windows 安装说明

本章中的代码示例均来自$BOOK_HOME/Chapter19的源代码。

如果您想查看本章源代码中应用的变化,以便使用 Prometheus 和 Grafana 监控和警报性能指标,您可以将其与第十九章使用 EFK 堆栈进行集中日志记录的源代码进行比较。您可以使用您喜欢的 diff 工具比较两个文件夹,$BOOK_HOME/Chapter19$BOOK_HOME/Chapter20

使用 Prometheus 和 Grafana 的性能监控简介

在本章中,我们将重用我们在第十八章在 Kubernetes 集群中部署 Istio部分中创建的 Prometheus 和 Grafana 的部署。在那章中,我们还简要介绍了 Prometheus,这是一个流行的开源数据库,用于收集和存储时间序列数据,如性能指标。我们还了解了 Grafana,这是一个开源工具,用于可视化性能指标。随着 Grafana 的部署,还附带了一套 Istio 特定的仪表板。Kiali 也可以在不使用 Grafana 的情况下渲染一些与性能相关的图表。在本章中,我们将获得一些使用这些工具的实践经验。

我们在第十八章中部署的 Istio 配置包括 Prometheus 的配置,该配置会自动从 Kubernetes 中的 Pod 收集指标。我们所需做的只是在我们微服务中设置一个端点,该端点以 Prometheus 可以消费的格式产生指标。我们还需要向 Kubernetes Pods 添加注释,以便 Prometheus 可以找到这些端点的地址。有关如何设置此配置的详细信息,请参阅本章的源代码更改以收集应用程序指标部分。为了展示 Grafana 提升警报的能力,我们还将部署一个本地邮件服务器。

以下图表说明了我们刚才讨论的运行时组件之间的关系:

计算机屏幕截图  描述由低置信度自动生成

图 20.1:将 Prometheus 和 Grafana 添加到系统架构中

在这里,我们可以看到 Prometheus 如何使用 Kubernetes Pods 定义中的注释来收集我们的微服务指标。然后,它将这些指标存储在其数据库中。用户可以通过网络浏览器访问 Kiali 和 Grafana 的 Web UI 来监控这些指标。网络浏览器使用在第十八章的设置访问 Istio 服务部分中介绍的minikube 隧道来访问 Kiali、Grafana,以及来自邮件服务器的网页,以查看 Grafana 发送的警报。

请记住,第十八章中用于部署 Istio 的配置仅适用于开发和测试,而不是生产。例如,存储在 Prometheus 数据库中的性能指标将不会在 Prometheus Pod 重启后幸存!

本书使用的 Istio 版本为 v1.17.0,Grafana 版本为 v9.0.1,Prometheus 版本为 v2.34.0。在 Grafana v8 中引入了新的警报系统。为了支持使用 Grafana v8 以下旧版本的本书读者,我们将使用旧的警报配置方式。在下面的配置 Grafana部分中,Grafana 将被配置为使用旧的警报系统。

在下一节中,我们将查看对源代码进行了哪些更改,以便微服务产生 Prometheus 可以收集的性能指标。

源代码更改以收集应用程序指标

Spring Boot 2 引入了对使用Micrometer库(micrometer.io)以 Prometheus 格式产生性能指标的支持。我们只需要对微服务的源代码进行一个更改:我们需要在 Gradle 构建文件build.gradle中添加对 Micrometer 库micrometer-registry-prometheus的依赖项。依赖项看起来是这样的:

implementation 'io.micrometer:micrometer-registry-prometheus' 

这将使微服务在端口4004上使用/actuator/prometheus路径产生 Prometheus 指标。

在第十八章,我们将管理端口(由 actuator 使用)与微服务暴露的 API 请求服务的端口分开。如需回顾,请参阅“观察服务网格”部分。

为了让 Prometheus 了解这些端点,每个微服务的 Pod 都被注解了以下代码:

annotations:
  prometheus.io/scrape: "true"
  prometheus.io/port: "4004"
  prometheus.io/scheme: http
  prometheus.io/path: "/actuator/prometheus" 

这被添加到每个组件的 Helm 图表的 values.yaml 文件中。请参阅 kubernetes/helm/components

为了在 Prometheus 收集指标后更容易识别其来源,它们被标记为生成该指标的微服务的名称。这是通过向通用配置文件 config-repo/application.yml 添加以下配置来实现的:

management.metrics.tags.application: ${spring.application.name} 

这将导致每个生成的指标都有一个额外的标签名为 application。它将包含微服务的标准 Spring 属性名称的值,即 spring.application.name

最后,为了确保我们从配置的 Prometheus 端点获取指标,已在 test-em-all.bash 中添加了一个测试。它看起来像:

if [[ $USE_K8S == "true" ]]
then
  # Verify access to Prometheus formatted metrics
  echo "Prometheus metrics tests"
  assertCurl 200 "curl -ks https://health.minikube.me/actuator/prometheus"
fi 

注意,只有当针对 Kubernetes 运行测试脚本时,此测试才会运行。

这些都是准备微服务以生成性能指标并让 Prometheus 了解要使用哪些端点开始收集它们的必要更改。在下一节中,我们将构建和部署微服务。

构建和部署微服务

使用 test-em-all.bash 测试脚本构建、部署和验证部署的方式与第十九章“使用 EFK 栈进行集中日志记录”中“构建和部署微服务”部分的方式相同。运行以下命令:

  1. 使用以下命令从源构建 Docker 镜像:

    cd $BOOK_HOME/Chapter20
    eval $(minikube docker-env -u)
    ./gradlew build
    eval $(minikube docker-env)
    docker-compose build 
    

eval $(minikube docker-env -u) 命令确保 ./gradlew build 命令使用主机的 Docker 引擎,而不是 Minikube 实例中的 Docker 引擎。build 命令使用 Docker 来运行测试容器。

  1. 重新创建 hands-on 命名空间并将其设置为默认命名空间:

    kubectl delete namespace hands-on
    kubectl apply -f kubernetes/hands-on-namespace.yml
    kubectl config set-context $(kubectl config current-context) --namespace=hands-on 
    
  2. 使用以下命令解决 Helm 图表的依赖关系。

    首先,我们更新 components 文件夹中的依赖项:

    for f in kubernetes/helm/components/*; do helm dep up $f; done 
    

    接下来,我们更新 environments 文件夹中的依赖项:

    for f in kubernetes/helm/environments/*; do helm dep up $f; done 
    
  3. 使用 Helm 部署系统景观并等待所有部署完成:

    helm install hands-on-dev-env \
      kubernetes/helm/environments/dev-env \
      -n hands-on --wait 
    
  4. 如果 Minikube 隧道尚未运行,请按照以下步骤启动(如需回顾,请参阅第十八章“设置访问 Istio 服务”部分):

    minikube tunnel 
    

请记住,此命令需要您的用户具有 sudo 权限,并且在启动时输入您的密码。在命令请求密码之前需要几秒钟,所以很容易错过!

  1. 使用以下命令运行正常测试以验证部署:

    ./test-em-all.bash 
    

预期输出将与我们在前几章中看到的结果相似:

计算机截图  描述由中等置信度自动生成

图 20.2:所有测试正常

微服务部署后,我们可以继续前进,并开始使用 Grafana 监控我们的微服务!

使用 Grafana 仪表板监控微服务

正如我们在引言中提到的,Kiali 提供了一些非常实用的仪表板,它们通常是针对应用级性能指标,例如每秒请求数、响应时间和处理请求的故障百分比。不久我们将看到,它们在应用级非常有用。但如果我们想了解底层硬件资源的利用率,我们需要更详细的指标,例如 Java VM 相关的指标。

Grafana 有一个活跃的社区,其中之一就是分享可重用的仪表板。我们将尝试使用社区中的一个仪表板,该仪表板专门用于从 Spring Boot 应用程序(如我们的微服务)中获取大量有价值的 Java VM 相关指标。最后,我们将看到如何在 Grafana 中构建我们自己的仪表板。但让我们先探索 Kiali 和 Grafana 中随盒提供的仪表板。

在我们这样做之前,我们需要做两项准备工作:

  1. 安装本地邮件服务器进行测试并配置 Grafana,使其能够向其发送警报邮件。我们将在在 Grafana 中设置警报部分使用该邮件服务器。

  2. 为了能够监控一些指标,我们将启动我们在前几章中使用的负载测试工具。

安装本地邮件服务器进行测试

在本节中,我们将设置一个本地测试邮件服务器,并配置 Grafana 向邮件服务器发送警报邮件。

Grafana 可以向任何 SMTP 邮件服务器发送邮件,但为了保持测试的本地化,我们将部署一个名为maildev的测试邮件服务器。按照以下步骤进行:

  1. 使用以下命令在 Istio 的命名空间中安装测试邮件服务器:

    kubectl -n istio-system create deployment mail-server --image maildev/maildev:2.0.5
    kubectl -n istio-system expose deployment mail-server --port=1080,1025 --type=ClusterIP
    kubectl -n istio-system wait --timeout=60s --for=condition=ready pod -l app=mail-server 
    
  2. 为了使邮件服务器的 Web UI 可以从 Minikube 外部访问,我们在 Istio 的 Helm 图表中为邮件服务器添加了一组GatewayVirtualServiceDestinationRule清单文件。请参阅模板kubernetes/helm/environments/istio-system/templates/expose-mail.yml。运行helm upgrade命令以应用新的清单文件:

    helm upgrade istio-hands-on-addons kubernetes/helm/environments/istio-system -n istio-system 
    
  3. 通过访问其网页mail.minikube.me来验证测试邮件服务器是否正在运行。预期将渲染如下网页:

计算机的截图  描述由中等置信度自动生成

图 20.3:邮件服务器网页

有关邮件服务器的更多信息,请参阅hub.docker.com/r/maildev/maildev

邮件服务器安装完成后,我们可以在下一节中配置 Grafana,使其能够向服务器发送警报邮件。

配置 Grafana

配置 Grafana 可以通过在其 Kubernetes Deployment 对象中设置环境变量来完成。要启用旧警报系统并配置 Grafana 向测试邮件服务器发送邮件,请运行以下命令:

kubectl -n istio-system set env deployment/grafana \
    GF_ALERTING_ENABLED=true \
    GF_UNIFIED_ALERTING_ENABLED=false \
    GF_SMTP_ENABLED=true \
    GF_SMTP_SKIP_VERIFY=true \
    GF_SMTP_HOST=mail-server:1025 \
    GF_SMTP_FROM_ADDRESS=grafana@minikube.me
kubectl -n istio-system wait --timeout=60s --for=condition=ready pod -l app=Grafana 

变量 GF_ALERTING_ENABLEDGF_UNIFIED_ALERTING_ENABLED 用于启用上面提到的 使用 Prometheus 和 Grafana 介绍性能监控 部分中提到的旧版警报系统。变量 GF_SMTP_ENABLED 用于允许 Grafana 发送电子邮件。变量 GF_SMTP_SKIP_VERIFY 用于告诉 Grafana 跳过与测试邮件服务器的 SSL 检查。

GF_SMTP_HOST 变量指向我们的邮件服务器,最后,GF_SMTP_FROM_ADDRESS 变量指定在邮件中使用的“发件人”地址。

现在已经配置了 Grafana,在下一节中,我们将开始启动负载测试工具。

启动负载测试

为了有东西可以监控,让我们使用在前面章节中使用的 Siege 启动负载测试。运行以下命令以获取访问令牌,然后使用访问令牌进行授权启动负载测试:

ACCESS_TOKEN=$(curl https://writer:secret-writer@minikube.me/oauth2/token -d grant_type=client_credentials -d scope="product:read product:write" -ks | jq .access_token -r)
echo ACCESS_TOKEN=$ACCESS_TOKEN
siege https://minikube.me/product-composite/1 -H "Authorization: Bearer $ACCESS_TOKEN" -c1 -d1 -v 

请记住,访问令牌仅有效 1 小时——之后,您需要获取一个新的。

现在,我们准备好学习 Kiali 和 Grafana 中的仪表板,并探索 Istio 一起提供的 Grafana 仪表板。

使用 Kiali 内置的仪表板

第十八章 中,我们学习了 Kiali,但跳过了 Kiali 显示性能指标的部分。现在,是时候回到这个主题了!

执行以下步骤来了解 Kiali 的内置仪表板:

  1. 使用 kiali.minikube.me URL 在网页浏览器中打开 Kiali 网页 UI。如果需要,请使用 admin/admin 登录。

  2. 要查看我们的部署,请通过点击左侧菜单上的 工作负载 选项卡进入工作负载页面。

  3. 通过点击它来选择 product-composite 部署。

  4. product-composite 页面上,选择 出站指标 选项卡。您将看到一个类似于以下截图的页面:计算机的截图  自动生成的描述,中等置信度

    图 20.4:Kiali 出站指标

    Kiali 将可视化一些非常有价值的一般性能图表,并且还有更多图表可以探索。请随意尝试它们!

  5. 然而,在 Grafana 中还有更多详细性能指标可供使用。使用 grafana.minikube.me URL 在网页浏览器中打开 Grafana 网页 UI。

  6. 您将看到一个带有文本 欢迎使用 Grafana 的欢迎页面。欢迎文本上方有一个 主页 链接;点击它,您将看到一个可用仪表板的概览。您将看到一个名为 Istio 的文件夹,其中包含在 第十八章 中与 Istio 一起部署的仪表板。点击文件夹以展开并选择名为 Istio 网格仪表板 的仪表板。

    预期一个类似于以下网页:

    计算机的截图  自动生成的描述

    图 20.5:Grafana 显示 Istio 网格仪表板

    此仪表板为服务网格中涉及的微服务的指标提供了非常好的概览,如请求率、响应时间和成功率。

  7. 有很多详细的性能指标可供查看。返回Istio文件夹(在顶部菜单中点击Istio),并选择名为Istio Workload Dashboard的仪表板。选择hands-on命名空间和product-composite工作负载。最后,展开出站服务选项卡。网页应类似于以下屏幕截图:

计算机屏幕截图 自动生成描述

图 20.6:微服务的大量指标展示的 Grafana

页面显示响应代码、持续时间以及每个目的地的发送字节数等指标。请随意浏览 Istio 提供的其他仪表板!

正如我们之前提到的,Istio 仪表板在应用层面上提供了非常好的概览。但还需要监控每个微服务的硬件使用指标。在下一节中,我们将了解如何导入现有的仪表板——特别是显示基于 Spring Boot 应用程序的 Java VM 指标的仪表板。

导入现有的 Grafana 仪表板

正如我们之前提到的,Grafana 有一个活跃的社区,分享可重用的仪表板。它们可以在grafana.com/grafana/dashboards中探索。我们将尝试一个名为JVM (Micrometer) - Kubernetes - Prometheus by Istio的仪表板,该仪表板专门用于从 Kubernetes 环境中的 Spring Boot 应用程序中获取大量有价值的 JVM 相关指标。仪表板的链接是grafana.com/grafana/dashboards/11955。按照以下步骤导入此仪表板:

  1. 按照以下步骤导入名为JVM (Micrometer)的仪表板:

    1. 在 Grafana 网页上,将鼠标悬停在左侧菜单中的仪表板图标(四个方块)上。从弹出的菜单中选择+ 导入

    2. 导入页面,将仪表板 ID 11955 输入到通过 grafana.com 导入字段中,并点击其旁边的加载按钮。

    3. 在将显示的导入页面上,点击Prometheus下拉菜单并选择Prometheus数据源。

    4. 现在,通过点击导入按钮,JVM (Micrometer)仪表板将被导入并渲染。

  2. 按照以下步骤检查JVM (Micrometer)仪表板:

    1. 为了获得良好的指标视图,使用时间选择器(位于右上角)选择过去 5 分钟,并在右侧下拉菜单中选择5 秒的刷新率。

    2. 在页面左上角的应用下拉菜单中,选择product-composite微服务。

    3. 由于我们在后台使用 Siege 进行负载测试,我们将看到很多指标。以下是一个示例屏幕截图:

计算机屏幕截图  描述由中等置信度自动生成

图 20.7:显示 Java VM 指标的 Grafana

在这个仪表板中,我们可以找到所有类型的 Java VM 相关指标,包括 CPU、内存、堆和 I/O 使用情况,以及与 HTTP 相关的指标,如每秒请求数、平均持续时间以及错误率。请随意探索这些指标!

当我们想要快速开始时,能够导入现有的仪表板非常有价值。然而,更重要的是要知道如何创建我们自己的仪表板。我们将在下一节中学习这一点。

开发自己的 Grafana 仪表板

开始开发 Grafana 仪表板的过程非常简单。我们需要理解的重要一点是 Prometheus 为我们提供了哪些指标。

在本节中,我们将学习如何检查可用的指标。基于这些指标,我们将创建一个仪表板,用于监控一些更有趣的指标。

检查 Prometheus 指标

在之前的修改源代码以收集应用指标部分,我们配置了 Prometheus 从我们的微服务中收集指标。我们可以调用相同的端点并查看 Prometheus 收集了哪些指标。运行以下命令:

curl https://health.minikube.me/actuator/prometheus -ks 

命令将产生大量输出,如下例所示:

计算机屏幕截图  描述由中等置信度自动生成

图 20.8:Prometheus 指标

在所有报告的指标中,有两个非常有趣的指标:

  • resilience4j_retry_calls:Resilience4j 报告了重试机制的工作方式。它报告了成功和失败请求的四个不同值,包括和不含重试的情况。

  • resilience4j_circuitbreaker_state:Resilience4j 报告了断路器的状态。

注意到这些指标有一个名为application的标签,其中包含微服务的名称。这个字段来自我们在修改源代码以收集应用指标部分中配置的management.metrics.tags.application属性。

这些指标值得监控。我们之前使用的所有仪表板都没有使用 Resilience4j 的指标。在下一节中,我们将为这些指标创建一个仪表板。

创建仪表板

在本节中,我们将学习如何创建一个仪表板,用于可视化我们在上一节中描述的 Resilience4j 指标。

我们将分以下阶段设置仪表板:

  • 创建一个空白的仪表板

  • 为断路器指标创建一个新的面板

  • 为重试指标创建一个新的面板

  • 安排面板

创建一个空白的仪表板

执行以下步骤以创建一个空白的仪表板:

  1. 在 Grafana 网页上,将鼠标悬停在左侧菜单中的仪表板图标(四个方块)上。从弹出的菜单中选择+ 新仪表板。将显示一个名为新仪表板的网页:

手机截图  描述自动生成,置信度低

图 20.9:在 Grafana 中创建新的仪表板

  1. 点击仪表板设置按钮(其图标为齿轮),在前面截图显示的菜单中。然后,按照以下步骤操作:

    1. 名称字段中指定仪表板的名称,并将其值设置为Hands-on Dashboard

    2. 点击网页左上角的返回按钮(不要与网页浏览器的返回按钮混淆)。

  2. 点击时间选择器并选择最后 5 分钟作为范围。

  3. 点击右侧的刷新率图标并指定5s作为刷新率。

为断路器指标创建新的面板

执行以下步骤以创建一个新的断路器指标面板:

  1. 添加面板部分,点击添加新面板按钮。

将显示一个页面,其中可以配置新的面板。

  1. 在右侧标签页中,将面板标题设置为Circuit Breaker

  2. 同样在右侧标签页中,将工具提示模式设置为全部

  3. 在左下角的查询面板中,在字母A下,指定查询为关闭状态的断路器指标名称,如下所示:

    1. 指标设置为resilience4j_circuitbreaker_state

    2. 标签设置为state并指定它应等于closed

    3. 确认原始查询设置为resilience4j_circuitbreaker_state{state="closed"}

  4. 展开选项卡,并在图例下拉框中选择自定义。在图例字段中指定值{{state}}。这将在面板中创建一个图例,显示不同状态的名字。

    填充的值应如下所示:

计算机截图  描述自动生成,置信度中等

图 20.10:在 Grafana 中指定断路器指标

  1. 点击页面底部的+ 查询按钮,在B下为open状态输入一个新的查询。重复查询A的步骤,但将状态值设置为open。确认原始查询字段设置为resilience4j_circuitbreaker_state{state="open"},并将图例字段设置为{{state}}

  2. 再次点击+ 查询按钮,在C下为half_open状态输入一个新的查询。将状态值设置为half_open,并确认原始查询字段设置为resilience4j_circuitbreaker_state{state="half_open"},并将图例字段设置为{{state}}

  3. 点击页面左上角的返回按钮以返回仪表板。

为重试指标创建新的面板

在这里,我们将重复之前为前一个断路器指标添加面板的相同步骤,但我们将指定重试指标的值:

  1. 通过点击顶级菜单中的添加面板图标(一个带有加号的图表)创建一个新的面板,并在新面板中点击添加新面板

  2. 面板标题指定为Retry,并且与上一个面板一样,将工具提示模式设置为全部

  3. 度量设置为resilience4j_retry_calls_total

  4. 由于重试指标是一个计数器,其值只会上升。一个不断上升的指标在监控上并不那么有趣。因此,使用速率函数将重试指标转换为每秒速率指标。指定的时窗,即30s,由速率函数用来计算速率的平均值。要应用速率函数:

    1. 点击+ 操作按钮。

    2. 点击范围函数并选择速率函数。

    3. 范围设置为30s

  5. 原始查询字段中,确认它设置为rate(resilience4j_retry_calls_total[30s])

  6. 展开选项卡选项,然后在图例下拉框中选择自定义。在图例字段中,指定值{{kind}}。这将创建一个面板图例,其中显示不同类型重试的名称。

  7. 注意,Grafana 会立即根据指定的值在面板编辑器中开始渲染图表。

  8. 点击后退按钮返回仪表板。

安排面板

执行以下步骤以在仪表板上安排面板:

  1. 您可以通过拖动其右下角到所需大小来调整面板的大小。

  2. 您也可以通过拖动其标题到所需位置来移动一个面板。

下面是两个面板的示例布局:

计算机屏幕截图  自动生成描述

图 20.11:在 Grafana 中移动和调整面板大小

由于这个屏幕截图是在 Siege 在后台运行时拍摄的,重试面板报告successful_without_retry指标,而断路器报告关闭等于1打开半开等于0,这意味着它是关闭的并且正常运行(这在下一节中将要改变)。

  1. 最后,点击页面顶部的保存按钮。将显示保存仪表板为...对话框;确保名称是动手仪表板,然后点击保存按钮。

如果你在配置仪表板时遇到困难,请查看尝试使用断路器警报部分的末尾。那里描述了一个简单的解决方案。

创建仪表板后,我们就可以尝试使用了。在下一节中,我们将尝试这两个指标。

尝试使用新的仪表板

在我们开始测试新的仪表板之前,我们必须停止负载测试工具 Siege。为此,转到 Siege 运行的命令窗口,并按Ctrl + C停止它。

让我们先测试如何监控断路器。之后,我们将尝试重试指标。

测试断路器指标

如果我们强制断路器打开,其状态将从关闭变为打开,然后最终变为半开状态。这应该在断路器面板中报告。

打开断路器,就像我们在第十三章“使用 Resilience4j 提高弹性”部分中的“尝试断路器和重试机制”中所做的那样——也就是说,连续向 API 发送一些请求,所有这些请求都将失败。运行以下命令:

ACCESS_TOKEN=$(curl https://writer:secret-writer@minikube.me/oauth2/token -d grant_type=client_credentials -d scope="product:read product:write" -ks | jq .access_token -r)
echo ACCESS_TOKEN=$ACCESS_TOKEN
for ((n=0; n<4; n++)); do curl -o /dev/null -skL -w "%{http_code}\n" https://minikube.me/product-composite/1?delay=3 -H "Authorization: Bearer $ACCESS_TOKEN" -s; done 

我们可以预期收到三个500响应和一个最终的200响应,表示连续三个错误,这是打开断路器所需的条件。最后的200表示当product-composite微服务检测到断路器开启时,它发出的快速失败响应。

在一些罕见的情况下,我注意到在创建仪表板后,断路器指标并没有直接在 Grafana 中报告。如果一分钟内没有显示,只需重新运行前面的命令再次打开断路器。

预期关闭状态值将降至0,而开启状态值将取值为1,这意味着断路器现在已开启。10 秒后,断路器将转为半开启状态,由半开启指标值为1开启设置为0表示。这意味着断路器已准备好测试一些请求,以查看导致断路器开启的问题是否已消失。

再次关闭断路器,通过以下命令向 API 发出三个成功的请求:

for ((n=0; n<4; n++)); do curl -o /dev/null -skL -w "%{http_code}\n" https://minikube.me/product-composite/1?delay=0 -H "Authorization: Bearer $ACCESS_TOKEN" -s; done 

我们将只收到200响应。请注意,断路器指标又恢复正常,这意味着关闭指标值恢复到1

在此测试之后,Grafana 仪表板应如下所示:

计算机屏幕截图  描述由低置信度自动生成

图 20.12:在 Grafana 中查看的重试和断路器操作

从前面的屏幕截图可以看出,重试机制也报告了成功和失败的指标。当断路器开启时,所有请求都未进行重试而失败。当断路器关闭时,所有请求都未进行重试而成功。这是预期的结果。

现在我们已经看到了断路器指标在操作中的表现,让我们看看重试指标在操作中的表现!

如果你想检查断路器的状态,可以使用以下命令:

curl -ks https://health.minikube.me/actuator/health | jq -r .components.circuitBreakers.details.product.details.state 

它应报告其状态为CLOSEDOPENHALF_OPEN,具体取决于其状态。

测试重试指标

要触发重试机制,我们将使用我们在前几章中使用的faultPercentage参数。为了避免触发断路器,我们需要为该参数使用相对较低的值。运行以下命令:

while true; do curl -o /dev/null -s -L -w "%{http_code}\n" -H "Authorization: Bearer $ACCESS_TOKEN" -k https://minikube.me/product-composite/1?faultPercent=10; sleep 3; done 

此命令将每三秒调用 API 一次。它指定 10%的请求应该失败,以便重试机制能够启动并重试失败的请求。

几分钟后,仪表板应报告以下指标:

计算机屏幕截图  描述由中等置信度自动生成

图 20.13:在 Grafana 中查看的重试测试结果

在前面的屏幕截图中,我们可以看到大多数请求已成功执行,没有重试。大约 10%的请求通过重试机制重试,并在重试后成功执行。

在我们离开创建仪表板的章节之前,我们将学习如何导出和导入仪表板。

导出和导入 Grafana 仪表板

一旦创建了一个仪表板,我们通常想要执行以下两个操作:

  • 将仪表板的定义作为源代码保存在 Git 仓库中

  • 将仪表板移动到其他 Grafana 实例,例如,用于 QA 和生产环境的那些实例

要执行这些操作,我们可以使用 Grafana 的导出和导入仪表板的 API。由于我们只有一个 Grafana 实例,我们将执行以下步骤:

  1. 将仪表板导出为 JSON 文件。

  2. 删除仪表板。

  3. 从 JSON 文件导入仪表板。

在执行这些步骤之前,我们需要了解仪表板具有两种不同类型的 ID:

  • id,一个在 Grafana 实例内唯一的自增标识符。

  • uid,一个可以在多个 Grafana 实例中使用的唯一标识符。它是访问仪表板时 URL 的一部分,这意味着只要仪表板的uid保持不变,仪表板的链接就会保持相同。当创建仪表板时,Grafana 会创建一个随机的uid

当我们导入仪表板时,如果设置了id字段,Grafana 将尝试更新它。为了能够在没有安装仪表板的 Grafana 实例中测试导入仪表板,我们需要将id字段设置为null

执行以下操作以导出并导入您的仪表板:

  1. 识别您的仪表板的uid

uid值可以在显示仪表板的网页浏览器中的 URL 中找到。它看起来像这样:

[`grafana.minikube.me/d/YMcDoBg7k/hands-on-dashboard`](https://grafana.minikube.me/d/YMcDoBg7k/hands-on-dashboard) 
  1. 上面的 URL 中的uidYMcDoBg7k。在终端窗口中,创建一个包含其值的变量。在我的情况下,它将是:
`ID=YMcDoBg7k` 
  1. 使用以下命令将仪表板导出为 JSON 文件:

    curl -sk https://grafana.minikube.me/api/dashboards/uid/$ID | jq '.dashboard.id=null' > "Hands-on-Dashboard.json" 
    

curl命令将仪表板导出为 JSON 格式。jq语句将id字段设置为nulljq命令的输出被写入名为Hands-on-Dashboard.json的文件。

  1. 删除仪表板。

在网页浏览器中,选择左侧菜单中的仪表板浏览。在仪表板列表中识别Hands-on Dashboard,通过点击其前面的复选框来选择它。将显示一个红色的删除按钮;点击它,然后点击弹出的确认对话框中显示的新删除按钮。

  1. 使用以下命令通过导入 JSON 文件重新创建仪表板:

    curl -i -XPOST -H 'Accept: application/json' -H 'Content-Type: application/json' -k \
        'https://grafana.minikube.me/api/dashboards/db' \
        -d @Hands-on-Dashboard.json 
    

注意,用于访问仪表板的 URL 仍然有效,在我的情况下,是grafana.minikube.me/d/YMcDoBg7k/hands-on-dashboard

  1. 确认导入的仪表板报告的指标与删除和重新导入之前的方式相同。由于在测试重试指标部分启动的请求循环仍在运行,因此应该报告该部分的相同指标。

有关 Grafana API 的更多信息,请参阅grafana.com/docs/grafana/v9.0/developers/http_api/dashboard/#get-dashboard-by-uid

在继续下一节之前,请记住通过在执行请求循环的终端窗口中按Ctrl + C来停止我们为重试测试开始的请求循环!

在下一节中,我们将学习如何根据这些指标在 Grafana 中设置警报。

在 Grafana 中设置警报

能够监控断路器和重试指标非常有价值,但更重要的是能够在这些指标上定义自动警报。自动警报可以让我们免于手动监控指标。

Grafana 自带内置支持,可以定义警报并将通知发送到多个渠道。在本节中,我们将定义断路器的警报,并配置 Grafana 在警报触发时向测试邮件服务器发送电子邮件。本地测试邮件服务器是在为测试安装本地邮件服务器部分中安装的。

有关本章节中使用的 Grafana 版本支持的其它类型渠道,请参阅grafana.com/docs/grafana/v7.2/alerting/notifications/#list-of-supported-notifiers

在下一节中,我们将定义一个基于邮件的通知渠道,该渠道将在本节之后的警报中使用。

设置基于邮件的通知渠道

要在 Grafana 中配置基于邮件的通知渠道,请执行以下步骤:

  1. 在 Grafana 网页上,点击左侧菜单中的警报菜单选项(图标为警钟)并选择通知渠道

  2. 点击添加渠道按钮。

  3. 将名称设置为mail

  4. 选择类型为电子邮件

  5. 输入您选择的电子邮件地址。电子邮件将仅发送到本地测试邮件服务器,与指定的电子邮件地址无关。

  6. 展开通知设置并选择默认(为所有警报使用此通知)

通知渠道的配置应如下所示:

计算机的截图  描述由中等置信度自动生成

图 20.14:设置基于电子邮件的通知渠道

  1. 点击测试按钮发送测试邮件。

  2. 点击保存按钮。

  3. 点击左侧菜单中的仪表板按钮,然后点击浏览菜单项。

  4. 从列表中选择动手仪表板以返回仪表板。

  5. 检查测试邮件服务器的网页,以确保我们已收到测试邮件。你应该会收到以下内容:

计算机的截图  描述自动生成,置信度中等

图 20.15:在邮件服务器的网页上验证测试邮件

在已设置通知通道的情况下,我们准备好在断路器上定义警报。

设置断路器警报

要在断路器上创建警报,我们需要创建警报,然后将警报列表添加到仪表板中,我们可以看到随着时间的推移发生了哪些警报事件。

执行以下步骤以创建断路器警报:

  1. Hands-on Dashboard中,点击Circuit Breaker面板的标题。将出现一个下拉菜单。

  2. 选择Edit菜单选项。

  3. 在标签列表中选择Alert标签(显示为警钟图标)。

  4. 点击Create Alert按钮。

  5. Evaluate every字段中,将值设置为10s

  6. For字段中,将值设置为0m

  7. Conditions部分,指定以下值:

    • 对于WHEN字段,选择max()

    • OF字段设置为query(A, 10s, now)

    • IS ABOVE更改为IS BELOW,并将其值设置为0.5

这些设置会导致如果closed状态(与A变量相关)在最后 10 秒内低于 0.5 时触发警报。当断路器关闭时,此变量的值为 1,否则为 0。因此,这意味着当断路器不再关闭时,会触发警报。

  1. 滚动到Notifications部分以确认通知将发送到默认的通知通道,即我们之前定义的邮件通道。警报定义应如下所示:

计算机的截图  描述自动生成,置信度中等

图 20.16:在 Grafana 中设置警报,第一部分

  1. 在右上角点击Save按钮,输入类似Added an alarm的备注,然后点击Save按钮。

  2. 点击返回按钮(左箭头)返回到仪表板。

然后,我们需要执行以下步骤来创建警报列表:

  1. 在顶级菜单中点击Add panel按钮。

  2. 在新面板中点击Add new panel按钮。

  3. 在右上角,点击Time series下拉按钮,并选择Alert list选项。

  4. 在右侧的标签页中,执行以下操作:

    1. Panel title输入为Circuit Breaker Alerts

    2. Option部分,将Show字段设置为值Recent state changes

    3. 最后,启用名为Alerts from this dashboard的切换开关。

  5. Settings行下方展开Visualization行,并选择Alert list

  6. Panel options行下方,将Show字段设置为Recent state changes,将Max items设置为10,并启用Alerts from this dashboard选项。

设置应如下所示:

计算机截图,描述自动生成,置信度中等

图 20.17:在 Grafana 中设置警报,第二部分

  1. 点击后退按钮返回仪表盘。

  2. 调整面板以满足你的需求。

  3. 使用类似添加了警报列表这样的备注保存仪表盘的更改。

这里是一个添加了警报列表的示例布局:

设备截图,描述自动生成,置信度中等

图 20.18:在 Grafana 中设置带有重试、电路断路器和警报面板的布局

我们可以看到电路断路器报告的指标为健康状态(带有绿色心形图标),并且警报列表目前为空。

现在,是时候尝试警报了!

尝试电路断路器警报

在这里,我们将重复测试电路断路器指标部分中的测试,但这次,我们预期会发出警报,并发送电子邮件!让我们开始吧:

  1. 如果需要,获取新的访问令牌(有效期为 1 小时):

    ACCESS_TOKEN=$(curl https://writer:secret-writer@minikube.me/oauth2/token -d grant_type=client_credentials -d scope="product:read product:write" -ks | jq .access_token -r)
    echo ACCESS_TOKEN=$ACCESS_TOKEN 
    
  2. 如我们之前所做的那样打开电路断路器:

    for ((n=0; n<4; n++)); do curl -o /dev/null -skL -w "%{http_code}\n" https://minikube.me/product-composite/1?delay=3 -H "Authorization: Bearer $ACCESS_TOKEN" -s; done 
    

仪表盘应报告电路状态为之前的状态。几秒钟后,应发出警报,并发送电子邮件。预期仪表盘看起来如下截图所示(你可能需要刷新网页以使警报显示出来):

设备截图,描述自动生成,置信度低

图 20.19:Grafana 中发出的警报

注意到电路断路器面板标题中的警报图标(一个红色的破碎心形)。红色线条标记了警报事件的时间,并且已将警报添加到警报列表中。

  1. 在测试邮件服务器中,你应该会看到如下所示的电子邮件截图:

计算机截图,描述自动生成

图 20.20:警报电子邮件

  1. 太好了!我们得到了警报,正如我们预期的那样!现在,使用以下命令关闭电路,模拟问题已解决:

    for ((n=0; n<4; n++)); do curl -o /dev/null -skL -w "%{http_code}\n" https://minikube.me/product-composite/1?delay=0 -H "Authorization: Bearer $ACCESS_TOKEN" -s; done 
    

关闭指标应恢复正常,即1,警报应再次变为绿色。

预期仪表盘看起来如下截图所示:

设备截图,描述自动生成,置信度低

图 20.21:如 Grafana 中报告的错误已解决

注意到电路断路器面板标题中的警报图标再次变为绿色;绿色线条标记了OK事件的时间,并且它已被添加到警报列表中。

  1. 在测试邮件服务器中,你应该会看到如下所示的电子邮件截图:

计算机截图,描述自动生成,置信度中等

图 20.22:如电子邮件中报告的错误已解决

这样就完成了如何使用 Prometheus 和 Grafana 监控微服务的步骤。

如果你想要导出邮件通知的配置,可以使用以下命令:

curl https://grafana.minikube.me/api/alert-notifications/1 -ks | jq '.id=null' > mail-notification.jso 

要导入它,可以使用以下命令:

curl -ik -XPOST -H 'Accept: application/json' \
  -H 'Content-Type: application/json' \
  'https://grafana.minikube.me/api/alert-notifications' \
  -d @mail-notification.json 

为了您的方便,文件夹 $BOOK_HOME/Chapter20/kubernetes/grafana/api-export-import 包含了邮件通知器和仪表板的导出文件,我们在 导出和导入 Grafana 仪表板 部分学习了如何导出和导入。

摘要

在本章中,我们学习了如何使用 Prometheus 和 Grafana 收集和监控性能指标的警报。

我们看到,为了收集性能指标,我们可以在 Kubernetes 环境中使用 Prometheus。然后我们学习了当在 Pod 的定义中添加一些 Prometheus 注释时,Prometheus 如何自动从 Pod 收集指标。为了在我们的微服务中产生指标,我们使用了 Micrometer。

然后,我们看到了如何使用 Kiali 和 Grafana 中的仪表板来监控收集的指标,这些仪表板是 Istio 安装的一部分。我们还体验了如何消费 Grafana 社区共享的仪表板,并学习了如何开发我们自己的仪表板,其中我们使用了 Resilience4j 的指标来监控其断路器和重试机制的使用。使用 Grafana API,我们可以导出创建的仪表板并将它们导入到其他 Grafana 实例。

最后,我们学习了如何在 Grafana 中定义指标警报以及如何使用 Grafana 发送警报通知。我们使用本地测试邮件服务器接收来自 Grafana 的电子邮件警报通知。

接下来的两个章节应该已经熟悉了,涵盖了在 Mac 或 Windows PC 上安装工具。相反,你可以翻到这本书的最后一章,它将介绍如何使用全新的 Spring Native 项目(当时仍处于测试阶段)将基于 Java 的微服务编译成二进制可执行文件。这将使微服务能够在几秒钟内启动,但在构建它们时涉及了增加的复杂性和时间。

问题

  1. 我们需要对微服务的源代码进行哪些更改,以便它们产生 Prometheus 可以消费的指标?

  2. management.metrics.tags.application 配置参数用于什么?

  3. 如果你想分析一个关于高 CPU 消耗的支持案例,你将从这个章节的哪个仪表板开始?

  4. 如果你想分析一个关于慢速 API 响应的支持案例,你将从这个章节的哪个仪表板开始?

  5. 基于计数器的指标,如 Resilience4j 的重试指标,有什么问题,我们该如何以有用的方式进行监控?

  6. 这里发生了什么?

图 20.23:这里发生了什么?

如果你阅读的是以灰度渲染的截图,可能很难弄清楚指标的含义。所以,这里有一些帮助:

  1. 断路器报告的状态转换按顺序是:

    1. half_openopen

    2. openhalf_open

    3. half_openclosed

  2. 重试机制报告:

    1. 一波初始请求,其中大部分被报告为failed_without_retry,而少数被报告为successful_without_retry

    2. 第二波请求,所有请求都被报告为successful_without_retry

加入我们的 Discord 社区

加入我们的社区 Discord 空间,与作者和其他读者进行讨论:

packt.link/SpringBoot3e

第二十一章:macOS 安装说明

在本章中,我们将学习如何设置在 macOS 上运行本书中描述的命令所需的工具。我们还将学习如何获取本书源代码的访问权限。

本章将涵盖以下主题:

  • 技术要求

  • 安装工具

  • 访问源代码

如果您使用的是 Windows PC,应遵循第二十二章中关于使用 WSL 2 和 Ubuntu 的 Microsoft Windows 安装说明。

技术要求

本书中的所有命令都在配备 macOS Ventura 的 MacBook Pro 上运行,并使用bash,一个命令行 shell。所有命令都在基于 Intel 和 Apple 硅的 MacBook Pro 上进行了验证。

如果您使用的是其他 shell,例如zsh,我建议在运行本书中的命令之前使用以下命令切换到 bash:

/bin/bash 

安装工具

在本节中,我们将学习如何安装和配置工具。以下是我们将安装的工具列表,如有需要,附有下载和安装的更多信息链接:

在编写本书时使用了以下版本:

  • Git: v2.37.1

  • Docker Desktop for Mac: v4.12.0

  • Java: v17.0.6

  • curl: v7.86.0

  • jq: v1.6

  • Spring Boot CLI: v3.0.4

  • Siege: v4.1.6

  • Helm: v3.11.1

  • kubectl: v1.26.1

  • Minikube: v1.29.0

  • Istioctl: v1.17.0

大多数工具将使用Homebrew包管理器([brew.sh/](https://brew.sh/))安装,因此我们将首先安装 Homebrew。之后,我们将使用 Homebrew 安装大多数工具,并以安装剩余工具结束。

对于需要控制安装版本的工具——而不仅仅是安装最新版本——我发现 Homebrew 不足。当涉及到minikubekubectlistioctl时,安装相互兼容的版本非常重要,特别是当涉及到它们支持的 Kubernetes 版本时。简单地安装和升级到最新版本可能会导致minikube、Kubernetes 和 Istio 使用不兼容的版本。

关于 Istio 支持的 Kubernetes 版本,请参阅https://istio.io/latest/about/supported-releases/#support-status-of-istio-releases。对于minikube,请参阅minikube.sigs.k8s.io/docs/handbook/config/#selecting-a-kubernetes-version

安装 Homebrew

如果你还没有安装 Homebrew,可以使用以下命令安装:

/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" 

安装 Homebrew 也会安装Xcode的命令行工具,如果它们尚未安装,可能需要一些时间。

使用以下命令验证 Homebrew 的安装:

brew --version 

预期以下响应:

Homebrew 4.0.1 

使用 Homebrew 安装工具

在 macOS 上,curl已经预安装,git是作为 Homebrew 所需的 Xcode 命令行工具的一部分安装的。可以使用以下命令使用 Homebrew 安装 Docker、Java、jq、Spring Boot CLI、Helm 和 Siege:

brew tap spring-io/tap && \
brew tap homebrew/cask-versions && \brew install --cask temurin17 && \
brew install jq && \
brew install spring-boot && \
brew install helm && \
brew install siege && \
brew install --cask docker 

Brew 在基于 Intel 和 Apple 硅的 Mac 上安装工具到不同的文件夹中,分别在/usr/local/opt/homebrew

Java 使用名为Eclipse Temurin的发行版安装;更多信息请参阅adoptium.net/temurin/

无需 Homebrew 安装工具

当涉及到安装minikubekubectlistioctl时,我们将避免使用brew以更好地控制我们安装的版本。在基于 Intel 和 Apple 硅的 Mac 上,命令看起来略有不同,所以我们将分别介绍它们。

在基于 Intel 的 Mac 上安装工具

要安装本书中使用的kubectl版本,请运行以下命令:

curl -LO "https://dl.k8s.io/release/v1.26.1/bin/darwin/amd64/kubectl"
sudo install kubectl /usr/local/bin/kubectl
rm kubectl 

要安装本书中使用的minikube版本,请运行以下命令:

curl -LO https://storage.googleapis.com/minikube/releases/v1.29.0/minikube-darwin-amd64
sudo install minikube-darwin-amd64 /usr/local/bin/minikube
rm minikube-darwin-amd64 

要安装本书中使用的istioctl版本,请运行以下命令:

curl -L https://istio.io/downloadIstio | ISTIO_VERSION=1.17.0 TARGET_ARCH=x86_64 sh -
sudo install istio-1.17.0/bin/istioctl /usr/local/bin/istioctl
rm -r istio-1.17.0 

在基于 Apple 硅的 Mac 上安装工具

要安装本书中使用的kubectl版本,请运行以下命令:

curl -LO "https://dl.k8s.io/release/v1.26.1/bin/darwin/arm64/kubectl"
sudo install kubectl /usr/local/bin/kubectl
rm kubectl 

要安装本书中使用的minikube版本,请运行以下命令:

curl -LO https://storage.googleapis.com/minikube/releases/v1.29.0/minikube-darwin-arm64
sudo install minikube-darwin-arm64 /usr/local/bin/minikube
rm minikube-darwin-arm64 

要安装本书中使用的istioctl版本,请运行以下命令:

curl -L https://istio.io/downloadIstio | ISTIO_VERSION=1.17.0 TARGET_ARCH=arm64 sh -
sudo install istio-1.17.0/bin/istioctl /usr/local/bin/istioctl
rm -r istio-1.17.0 

如果你想使用最新版本,存在上述不兼容版本的风险,你应该可以使用以下命令使用 Homebrew 安装minikubekubectlistioctl

brew install kubernetes-cli && \<
brew install istioctl && \<
brew install minikube 

在安装了工具之后,我们需要在验证安装之前采取一些安装后操作。

安装后操作

安装 Java 和 Docker 后,我们需要采取一些操作来确保它们能正常工作:

  1. Java

在您的登录脚本中添加一个命令来设置JAVA_HOME环境变量:

echo 'export JAVA_HOME=$(/usr/libexec/java_home -v17)' >> ~/.bash_profile 

如果您不是使用~/.bash_profile作为您的登录脚本,您需要将其替换为您使用的登录脚本,例如,~/.zshrc

在您的当前终端会话中应用设置:

source ~/.bash_profile 
  1. Docker

为了能够运行这本书中的示例,建议您配置 Docker,使其能够使用大部分 CPU(除了少数几个,将所有 CPU 分配给 Docker 在测试运行时可能会使计算机无响应)和 10 GB 的内存,如果可用。初始章节使用较少的内存也能正常工作,例如,6 GB。但随着我们在书中添加更多功能,Docker 主机需要的内存将更多,以便能够平稳地运行所有微服务。

在我们配置 Docker 之前,我们必须确保 Docker 守护进程正在运行。您可以在 Mac 上像启动任何应用程序一样启动 Docker,例如,使用Spotlight或打开Finder中的应用程序文件夹并从那里启动它。

要配置 Docker,点击状态栏中的 Docker 图标并选择首选项…。转到 Docker 首选项设置中的资源选项卡,并设置CPU内存,如下面的截图所示:

计算机的截图  描述自动生成

图 21.1:Docker Desktop 资源配置

如果您不想在系统启动后手动启动 Docker,您可以转到常规选项卡并选择您登录时启动 Docker Desktop选项,如下面的截图所示:

计算机的截图  描述自动生成,置信度中等

图 21.2:Docker Desktop 常规配置

通过点击应用并重启按钮来最终化配置。

在执行了安装后操作后,我们可以验证工具是否按预期安装。

验证安装

为了验证工具安装,运行以下命令以打印每个工具的版本:

git version && \
docker version -f json | jq -r .Client.Version && \
java -version 2>&1 | grep "openjdk version" && \
curl --version | grep "curl" | sed 's/(.*//' && \
jq --version && \
spring --version && \
siege --version 2>&1 | grep SIEGE && \
helm version --short && \
kubectl version --client -o json | jq -r .clientVersion.gitVersion && \
minikube version | grep "minikube" && \
istioctl version --remote=false 

这些命令将返回如下输出:

图 21.3:使用的版本

在安装和验证了工具之后,让我们看看我们如何访问这本书的源代码。

访问源代码

这本书的源代码可以在 GitHub 仓库中找到:github.com/PacktPublishing/Microservices-with-Spring-Boot-and-Spring-Cloud-Third-Edition

为了能够运行本书中描述的命令,将源代码下载到文件夹中,并设置环境变量$BOOK_HOME,使其指向该文件夹。

示例命令如下:

export BOOK_HOME=~/Documents/Microservices-with-Spring-Boot-and-Spring-Cloud-Third-Edition 
git clone https://github.com/PacktPublishing/Microservices-with-Spring-Boot-and-Spring-Cloud-Third-Edition.git $BOOK_HOME 

Java 源代码是为 Java SE 8 编写的,并在 Docker 容器中执行时使用 Java SE 17 JRE。以下版本的 Spring 被使用:

  • Spring 框架:6.0.6

  • Spring Boot:3.0.4

  • Spring Cloud:2022.0.1

每一章中的代码示例都来自 $BOOK_HOME/ChapterNN 中的源代码,其中 NN 是章节的编号。本书中的代码示例在许多情况下都经过编辑,以删除源代码中不相关的部分,例如注释、导入和日志语句。

使用 IDE

我建议您使用支持 Spring Boot 应用程序开发的 IDE 来编写 Java 代码,例如 Visual Studio Code、Spring Tool Suite 或 IntelliJ IDEA Ultimate 版本。然而,您不需要 IDE 就能遵循本书中的说明。

代码结构

每一章都包含多个 Java 项目,每个微服务和 Spring Cloud 服务一个,再加上几个其他项目使用的库项目。第十四章 包含的项目数量最多;其项目结构如下:

├── api
├── microservices
│   ├── product-composite-service
│   ├── product-service
│   ├── recommendation-service
│   └── review-service
├── spring-cloud
│   ├── authorization-server
│   ├── config-server
│   ├── eureka-server
│   └── gateway
└── util 

所有项目都是使用 Gradle 构建的,并且文件结构遵循 Gradle 的标准约定:

├── build.gradle
├── settings.gradle
└── src
    ├── main
    │   ├── java
    │   └── resources
    └── test
        ├── java
        └── resources 

有关如何组织 Gradle 项目的更多信息,请参阅 docs.gradle.org/current/userguide/organizing_gradle_projects.html

使用这些工具,我们已经为 macOS 安装了所需的工具,并下载了本书的源代码。在下一章中,我们将学习如何在 Windows 环境中设置这些工具。

摘要

在本章中,我们学习了如何在 macOS 上安装、配置和验证运行本书中描述的命令所需的工具。对于开发,我们将使用 gitdockerjavaspring。为了在运行时创建 Kubernetes 环境,我们将使用 minikubehelmkubectlistioctl。最后,为了运行测试以验证微服务在运行时按预期工作,我们将使用 curljqsiege

我们还学习了如何从 GitHub 访问源代码以及源代码的结构。

在下一章中,我们将学习如何在基于 Microsoft Windows 的环境中设置相同的工具,该环境使用 Windows Subsystem for Linux v2WSL 2),我们将使用基于 Ubuntu 的 Linux 服务器。

第二十二章:使用 WSL 2 和 Ubuntu 的 Microsoft Windows 安装说明

在本章中,我们将学习如何设置在 Microsoft Windows 上运行本书中描述的命令所需的工具。我们还将学习如何获取本书源代码的访问权限。

本章将涵盖以下主题:

  • 技术要求

  • 安装工具

  • 访问源代码

如果你使用的是 Mac,应遵循 第二十一章macOS 安装说明 的指示。

技术要求

本书描述的所有命令都是在使用 bash 作为命令外壳的 MacBook Pro 上运行的。在本章中,我们将学习如何在 Microsoft Windows 中设置一个开发环境,在该环境中可以运行本书中的命令而无需进行任何更改。在少数情况下,必须修改命令才能在 Windows 环境中运行。这一点在每个章节中都有明确指出,并且还指定了在 Windows 环境中应使用的替代命令。

开发环境基于 Windows 子系统 for Linux v2,或简称 WSL 2,它需要 Windows 10,版本 2004(构建 19041)或更高版本。我们将使用 WSL 2 运行基于 Ubuntu 22.04 的 Linux 服务器,在那里我们将使用 bash 作为命令外壳运行所有命令。

微软提供了 Windows 和在 WSL 2 中运行的 Linux 服务器的集成。Linux 文件可以从 Windows 访问,反之亦然。我们将学习如何在 Windows 中运行的 Visual Studio Code 中访问 Linux 服务器上的文件。Linux 服务器中可从 localhost 访问的端口也在 Windows 的 localhost 上可用。我们将使用此集成从 Windows 中运行的网页浏览器访问在 Linux 服务器上运行的 Web 应用程序暴露的网页。

更多关于 WSL 2 的信息,请参阅 docs.microsoft.com/en-us/windows/wsl/

安装工具

在本节中,我们将学习如何安装和配置工具。以下是我们将安装的工具列表,如有需要,附有下载和安装的更多信息链接。

在 Windows 上,我们将安装以下工具:

在 Linux 服务器上,我们将安装以下工具:

在编写本书时使用了以下版本:

  • Windows Terminal: v1.16.10261.0

  • Visual Studio Code: v1.75.1

  • Docker Desktop for Windows: v4.14.0

  • Git: v2.34.1

  • Java: v17.0.6

  • curl: v7.81.0

  • jq: v1.6

  • Spring Boot CLI: v3.0.4

  • Siege: v4.0.7

  • Helm: v3.11.2

  • kubectl: v1.26.1

  • minikube: v 1.29.1

  • istioctl: v1.17.0

我们将首先安装 Windows 上所需的工具,然后安装运行在 WSL 2 中的 Linux 服务器上所需的工具。

在 Windows 上安装工具

在 Windows 环境中,我们将一起安装 WSL 2、Linux 服务器、Windows 终端、Docker 桌面,最后是带有远程访问 WSL 中文件扩展的 Visual Studio Code。

与默认 Ubuntu 服务器一起安装 WSL 2

运行以下命令以使用 Ubuntu 服务器安装 WSL 2:

  1. 以管理员身份打开 PowerShell 并运行以下命令:

    wsl –install 
    
  2. 重启 PC 以完成安装。

  3. 在 PC 重启后,Ubuntu 服务器将自动在新终端窗口中安装。过一会儿,你会被要求输入用户名和密码。

  4. 安装完成后,你可以使用以下命令验证安装的 Ubuntu 版本:

    lsb_release -d 
    

应该会返回一个类似以下输出的结果:

Description:    Ubuntu 22.04.1 LTS 

在 WSL 2 上安装新的 Ubuntu 22.04 服务器

如果你已经安装了 WSL 2 但没有 Ubuntu 22.04 服务器,你可以按照以下步骤安装:

  1. 从 Microsoft Store 下载安装文件:

  2. 下载安装文件后,执行它以安装 Ubuntu 20.04。

  3. 将打开一个控制台窗口,一分钟后或两分钟内,您将被要求输入用于 Linux 服务器的用户名和密码。

安装 Windows Terminal

为了简化对 Linux 服务器的访问,我强烈建议安装 Windows Terminal。它支持:

  • 使用多个标签页

  • 在标签页内使用多个窗格

  • 使用多种类型的 shell:例如,Windows 命令提示符、PowerShell、WSL 2 的 bash 和 Azure CLI

  • …以及更多;有关更多信息,请参阅 docs.microsoft.com/en-us/windows/terminal/

Windows Terminal 可以从 Microsoft Store 安装;请参阅 aka.ms/terminal

当您启动 Windows Terminal 并在菜单中单击 向下箭头 时,您会发现它已经配置为在 Linux 服务器中启动终端:

计算机的截图  自动生成的描述

图 22.1:配置 WSL 2 中的 Linux 服务器使用的 Windows Terminal

选择 Ubuntu-22.04,并将启动一个 bash shell。根据您的设置,您的当前工作目录可能被设置为 Windows 的家目录,例如,/mnt/c/Users/magnus。要访问 Linux 服务器的家目录,只需使用 cdpwd 命令来验证您是否在 Linux 服务器的文件系统中:

图形用户界面,文本,应用程序  自动生成的描述

图 22.2:使用 bash 访问 Linux 服务器文件的 Windows Terminal

安装 Windows Docker Desktop

要安装和配置 Windows Docker Desktop,请执行以下步骤:

  1. hub.docker.com/editions/community/docker-ce-desktop-windows/ 下载并安装 Docker Desktop for Windows。

  2. 如果在安装过程中被要求启用 WSL 2,请回答 **YES****。

  3. 安装完成后,从 开始 菜单启动 Docker Desktop

  4. Docker 菜单中选择 设置,然后在 设置 窗口中选择 常规 选项卡:

    • 确保选中 使用基于 WSL 2 的引擎 复选框。

    • 为了避免每次重启 PC 时手动启动 Docker Desktop,我还建议选中 登录时启动 Docker Desktop 复选框。

    常规设置应如下所示:

图形用户界面,文本,应用程序  自动生成的描述

图 22.3:Docker Desktop 配置

  1. 通过点击应用 & 重新启动按钮完成配置。

安装 Visual Studio Code 及其 Remote WSL 扩展

为了简化在 Linux 服务器内部编辑源代码,我建议使用 Visual Studio Code。通过其名为Remote WSL的 WSL 2 扩展,您可以使用在 Windows 中运行的 Visual Studio Code 轻松地在 Linux 服务器内部处理源代码。

要安装和配置 Visual Studio Code 及其 Remote WSL 扩展,请执行以下步骤:

  1. Visual Studio Code 可以从code.visualstudio.com下载和安装:

    • 当被要求选择附加任务时,选择添加到 PATH选项。这将使您能够从 Linux 服务器内部使用code命令打开 Visual Studio Code 中的文件夹。
  2. 安装完成后,从开始菜单启动Visual Studio Code

  3. 使用此链接安装 Remote WSL 扩展:marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-wsl.

如果您想了解更多关于 Visual Studio Code 如何与 WSL 2 集成的信息,请参阅这篇文章:code.visualstudio.com/docs/remote/wsl

在 WSL 2 的 Linux 服务器上安装工具

现在,是时候在 WSL 2 的 Linux 服务器上安装所需的工具了。

开始菜单启动Windows Terminal,并按照安装 Windows Terminal部分所述打开 Linux 服务器中的终端。

gitcurl工具已在 Ubuntu 中安装。剩余的工具将使用apt installsdk installcurlinstall的组合进行安装。

使用 apt install 安装工具

在本节中,我们将安装jqsiege、Helm 以及其他工具所需的几个依赖项。

使用以下命令安装jqzipunzipsiege

sudo apt update
sudo apt install -y jq
sudo apt install -y zip
sudo apt install -y unzip
sudo apt install -y siege 

要安装 Helm,请运行以下命令:

curl -s https://baltocdn.com/helm/signing.asc | \
  gpg --dearmor | sudo tee /usr/share/keyrings/helm.gpg > /dev/null sudo apt-get install apt-transport-https --yes echo "deb [arch=$(dpkg --print-architecture) \
  signed-by=/usr/share/keyrings/helm.gpg] \
  https://baltocdn.com/helm/stable/debian/ all main" | \
  sudo tee /etc/apt/sources.list.d/helm-stable-debian.list
sudo apt-get update
sudo apt install -y helm 

使用 SDKman 安装 Java 和 Spring Boot CLI

要安装 Java 和 Spring Boot CLI,我们将使用SDKman (sdkman.io). 使用以下命令安装 SDKman:

curl -s "https://get.sdkman.io" | bash
source "$HOME/.sdkman/bin/sdkman-init.sh" 

使用以下命令验证 SDKman 是否正确安装:

sdk version 

预期它会返回类似以下内容:

SDKMAN5.9.2+613 

要安装 Java,我们将使用名为Eclipse Temurin的发行版 (adoptium.net/temurin/)。

使用此命令安装 Java:

sdk install java 17.0.6-tem 

最后,安装 Spring Boot CLI:

sdk install springboot 3.0.4 

使用 curl 和 install 安装剩余的工具

最后,我们将使用curl下载可执行文件来安装kubectlminikubeistioctl。下载完成后,我们将使用install命令将文件复制到文件系统的正确位置,并确保所有者访问权限配置正确。在涉及这些工具时,安装相互兼容的版本很重要,特别是当涉及到它们支持的 Kubernetes 版本时。简单地安装和升级到最新版本可能会导致使用不兼容的minikube、Kubernetes 和 Istio 版本的情况。

关于 Istio 支持的 Kubernetes 版本,请参阅istio.io/latest/about/supported-releases/#support-status-of-istio-releases。对于minikube,请参阅minikube.sigs.k8s.io/docs/handbook/config/#selecting-a-kubernetes-version

要安装本书中使用的kubectl版本,请运行以下命令:

curl -LO "https://dl.k8s.io/release/v1.26.1/bin/linux/amd64/kubectl"
sudo install -o root -g root -m 0755 kubectl /usr/local/bin/kubectl
rm kubectl 

要安装本书中使用的minikube版本,请运行以下命令:

curl -LO https://storage.googleapis.com/minikube/releases/v1.29.0/minikube-linux-amd64
sudo install -o root -g root -m 0755 minikube-linux-amd64 \
  /usr/local/bin/minikube
rm minikube-linux-amd64 

要安装本书中使用的istioctl版本,请运行以下命令:

curl -L https://istio.io/downloadIstio | ISTIO_VERSION=1.17.0 TARGET_ARCH=x86_64 sh -
sudo install -o root -g root -m 0755 istio-1.17.0/bin/istioctl /usr/local/bin/istioctl
rm -r istio-1.17.0 

现在工具已安装,我们可以验证它们是否已按预期安装。

验证安装

要验证工具安装,运行以下命令以打印每个工具的版本:

git version && \
docker version -f json | jq -r .Client.Version && \
java -version 2>&1 | grep "openjdk version" && \
curl --version | grep "curl" | sed 's/(.*//' && \
jq --version && \
spring --version && \
siege --version 2>&1 | grep SIEGE && \
helm version --short && \
kubectl version --client -o json | \
  jq -r .clientVersion.gitVersion && \
minikube version | grep "minikube" && \
istioctl version --remote=false 

预期版本信息如下:

文本  自动生成的描述

图 22.4:WSL 2 中 Linux 服务器使用的版本

工具安装并验证后,让我们看看如何访问本书的源代码。

访问源代码

本书源代码可在 GitHub 仓库github.com/PacktPublishing/Microservices-with-Spring-Boot-and-Spring-Cloud-Third-Edition中找到。

要能够在 WSL 2 中的 Linux 服务器上运行本书中描述的命令,请将源代码下载到文件夹中,并设置环境变量$BOOK_HOME,使其指向该文件夹。以下是一些示例命令:

export BOOK_HOME=~/Microservices-with-Spring-Boot-and-Spring-Cloud-Third-Edition
git clone https://github.com/PacktPublishing/Microservices-with-Spring-Boot-and-Spring-Cloud-Third-Edition.git $BOOK_HOME 

要验证从 Visual Studio Code 下载到 WSL 2 中 Linux 服务器的源代码访问权限,请运行以下命令:

cd $BOOK_HOME
code . 

Visual Studio Code 将打开一个窗口,您可以从该窗口开始检查源代码。您还可以从菜单选择终端新建终端来启动一个终端窗口,在 Linux 服务器上运行 bash 命令。Visual Studio Code 窗口应类似于以下内容:

图形用户界面,文本,应用程序,电子邮件  自动生成的描述

图 22.5:从 Visual Studio Code 访问 Linux 服务器中的文件

Java 源代码是为 Java SE 8 编写的,并在 Docker 容器中执行时使用 Java SE 17 JRE。以下版本的 Spring 被使用:

  • Spring 框架:6.0.6

  • Spring Boot:3.0.4

  • Spring Cloud:2022.0.1

每章中的代码示例都来自 $BOOK_HOME/ChapterNN 中的源代码,其中 NN 是章节的编号。在许多情况下,书中的代码示例都被编辑过,以删除源代码中不相关的部分,例如注释、导入和日志语句。

代码结构

每章都包含多个 Java 项目,每个微服务和 Spring Cloud 服务一个,以及一些由其他项目使用的库项目。第十四章 包含的项目数量最多;其项目结构如下:

├── api
├── microservices
│   ├── product-composite-service
│   ├── product-service
│   ├── recommendation-service
│   └── review-service
├── spring-cloud
│   ├── authorization-server
│   ├── config-server
│   ├── eureka-server
│   └── gateway
└── util 

所有项目都是使用 Gradle 构建的,并且具有根据 Gradle 标准约定构建的文件结构:

├── build.gradle
├── settings.gradle
└── src
    ├── main
    │   ├── java
    │   └── resources
    └── test
        ├── java
        └── resources 

关于如何组织 Gradle 项目的更多信息,请参阅 docs.gradle.org/current/userguide/organizing_gradle_projects.html

摘要

在本章中,我们学习了如何在 WSL 2 和 Windows 上安装、配置和验证运行本书中描述的命令所需的工具。对于开发,我们将使用 gitdockerjavaspring。为了在运行时创建部署我们的微服务所需的 Kubernetes 环境,我们将使用 minikube、Helm、kubectlistioctl。最后,为了运行测试以验证微服务在运行时按预期工作,我们将使用 curljqsiege

我们还学习了如何从 GitHub 访问源代码以及源代码的结构。

在下一章和最后一章中,我们将学习如何原生编译微服务,将它们的启动时间缩短到亚秒级。

第二十三章:原生编译的 Java 微服务

在本章中,我们将学习如何将我们的微服务中的 Java 源代码编译成二进制可执行文件,称为原生镜像。与使用 Java VM 相比,原生镜像启动速度显著更快,并且预计还会消耗更少的内存。我们将介绍 Spring Framework 6 中引入的Spring AOT引擎以及GraalVM项目和它的原生镜像编译器,学习如何使用它们。

我们将涵盖以下主题:

  • 何时原生编译 Java 源代码

  • 介绍 GraalVM 项目和 Spring 的 AOT 引擎

  • 处理原生编译中的问题

  • 测试和编译原生镜像

  • 使用 Docker Compose 进行测试

  • 使用 Kubernetes 进行测试

尽管 Spring Framework 6 和 Spring Boot 3 提供了对构建 Spring Boot 应用程序原生可执行文件的支持,即通用可用性GA),但这仍处于早期阶段。在撰写本章时,在原生编译本书中的微服务时发现了许多陷阱。由于原生编译微服务对于本书其余部分的内容不是必需的,因此本章被放置在书的末尾,作为额外的一章,描述了一个令人兴奋但尚未完全成熟的技术。

技术要求

关于如何安装本书中使用的工具以及如何访问本书源代码的说明,请参阅:

  • 第二十一章macOS 安装说明

  • 第二十二章使用 WSL 2 和 Ubuntu 的 Microsoft Windows 安装说明

本章中的所有代码示例均来自$BOOK_HOME/Chapter23的源代码。

如果你想查看本章源代码中应用的变化,以便你可以原生编译微服务,你可以将其与第二十章监控微服务的源代码进行比较。你可以使用你喜欢的diff工具,比较两个文件夹$BOOK_HOME/Chapter20$BOOK_HOME/Chapter23

何时原生编译 Java 源代码

Java 一直以其一次构建,到处运行的能力而闻名,提供了出色的跨平台支持。Java 源代码编译成一次的字节码。在运行时,Java VM 使用即时编译器(也称为 JIT 编译)将字节码转换为针对目标平台的可执行代码。这需要一些时间,会减慢 Java 程序的启动速度。在微服务时代之前,Java 组件通常运行在应用程序服务器上,如 Java EE 服务器。部署后,Java 组件会长时间运行,使得较长的启动时间不再是问题。

随着微服务的引入,这种观点发生了变化。使用微服务,人们期望能够更频繁、更快速地升级它们,并根据其使用情况对微服务的实例进行上下调整。另一个期望是能够扩展到零,这意味着当微服务不被使用时,它不应运行任何实例。未使用的微服务不应分配任何硬件资源,更重要的是,不应产生任何运行时成本,例如在云部署中。为了能够满足这些期望,微服务实例能够迅速启动是非常重要的。

此外,随着容器技术的应用,应用程序本身内置的跨平台支持的重要性已经降低。相反,可以使用 Docker 构建包含对多个平台支持的 Docker 镜像,例如,同时支持arm64amd64(也称为x86_64)的 Linux,或者可以在 Windows 和 Linux 上运行的 Docker 镜像。更多信息,请参阅docs.docker.com/build/building/multi-platform/。关于包含多平台支持的 Docker 镜像的示例,请参阅本书中使用的 OpenJDK Docker 镜像,hub.docker.com/_/eclipse-temurin/tags

由于 Java 程序的启动时间可以显著减少,其他用例也浮现在脑海中;例如,使用 AWS Lambda、Azure Functions 或 Google Cloud Functions 等主要平台开发基于 Java 的函数即服务FaaS)解决方案,仅举一些例子。此外,在 Java 中开发 CLI 工具也成为了一种可行的选择。

这些共同导致了一种情况,即更快的启动速度比跨平台支持成为一个更关键的要求。这个要求可以通过在构建时将 Java 源代码编译成目标平台的二进制格式来实现,就像 C 或 Go 程序被编译一样。这被称为即时编译AOT编译。GraalVM Native Image 编译器将用于执行 AOT 编译。

正如我们将在下一节中看到的,GraalVM Native Image 编译器带来了一些限制,例如与反射和动态代理的使用相关。将 Java 代码编译成二进制原生镜像也需要相当长的时间。这项技术有其优点和缺点。

在更好地理解何时可能需要原生编译 Java 源代码之后,让我们了解相关的工具:首先,GraalVM 项目,然后是 Spring AOT 引擎。

介绍 GraalVM 项目

Oracle 多年来一直在开发高性能 Java VM 和相关工具,这些工具统称为GraalVM项目(www.graalvm.org)。该项目于 2018 年 4 月启动(medium.com/graalvm/graalvm-in-2018-b5fa7ff3b917),但其工作可以追溯到例如 Oracle Labs 在 2013 年关于该主题的研究论文:Maxine:一个易于接近的 Java 虚拟机,用于 Java 中;请参阅dl.acm.org/doi/10.1145/2400682.2400689

有趣的事实:Maxine VM 被称为元循环Java VM 实现,这意味着它本身是用 Java 编写的。

GraalVM 的 VM 是多语言的,不仅支持传统的 Java VM 语言,如 Java、Kotlin 和 Scala,还支持 JavaScript、C、C++、Ruby、Python,甚至编译成 WebAssembly 的程序。我们将关注的 GraalVM 部分是其本地图像编译器,它可以用来将 Java 字节码编译成包含特定操作系统OS)和 HW 平台二进制可执行代码的本地图像,例如,在苹果硅上的 macOS(arm64)或在 Intel 上的 Linux(amd64)。

本地图像可以在没有 Java VM 的情况下运行,包括二进制编译的应用程序类以及其他来自应用程序依赖项所需的类。它还包括一个名为Substrate VM的运行时系统,该系统处理垃圾回收、线程调度等。

为了能够构建本地图像,本地编译器基于封闭世界假设执行静态代码分析,这意味着在运行时可以调用的所有字节码必须在构建时可达。因此,在 AOT 编译期间不可用的运行时动态加载或创建类是不可能的。

为了克服这些限制,GraalVM 项目为本地编译器提供了配置选项,我们可以提供可达性元数据。使用此配置,我们可以描述动态特性(如反射)的使用和运行时生成代理类。有关更多信息,请参阅www.graalvm.org/22.3/reference-manual/Native%20Image/metadata/。在本章的后面,我们将学习创建所需可达性元数据的各种方法。

可以使用 CLI 命令Native Image或作为 Maven 或 Gradle 构建的一部分来启动 GraalVM 本地图像编译器。GraalVM 为 Maven 和 Gradle 都提供了插件。在本章中,我们将使用 Gradle 插件。

GraalVM 引入后,是时候学习 Spring 的 AOT 引擎了。

介绍 Spring 的 AOT 引擎

Spring 团队也一直在努力支持 Spring 应用的原生编译。2021 年 3 月,经过 18 个月的工作,实验性的 Spring Native 项目发布了 beta 版本;请参阅 spring.io/blog/2021/03/11/announcing-spring-native-beta。基于 Spring Native 项目的经验,Spring 框架 6 和 Spring Boot 3 中增加了构建 Native Image 的官方支持;请参阅 spring.io/blog/2023/02/23/from-spring-native-to-spring-boot-3。实际上进行原生编译时,Spring 在底层使用 GraalVM 的 Native Image 编译器。

最重要的特性是 Spring 的新 AOT 引擎,该引擎在构建时分析 Spring Boot 应用程序,并生成 GraalVM Native Image 编译器所需的初始化源代码和可达性元数据。生成的初始化源代码,也称为 AOT 生成的代码,替换了使用 Java VM 时执行的基于反射的初始化,消除了大多数动态特性(如反射和运行时生成代理类)的需求。

当执行此 AOT 处理时,会像 GraalVM Native Image 编译器一样做出封闭世界假设。这意味着只有构建时可达的 Spring bean 和类将表示在 AOT 生成的代码和可达性元数据中。必须特别注意那些仅在设置某些配置文件或满足某些条件时才创建的 Spring bean,使用 @Profile@ConditionalOnProperty 注解。这些配置文件和条件必须在构建时设置;否则,这些 Spring bean 将不会在 Native Image 中表示。

为了执行分析,AOT 引擎通过扫描应用程序的源代码为所有可找到的 Spring bean 创建 bean 定义。但不是实例化 Spring bean,即启动应用程序,而是生成在执行时将实例化 Spring bean 的相应初始化代码。对于所有动态特性的使用,它还将生成所需的可达性元数据。

从 Spring Boot 应用程序的源代码创建 Native Image 的过程由以下数据流图总结:

包含文本、截图、图表的图片,自动生成行描述

图 23.1:解释创建 Native Image 的数据流图

创建 Native Image 的步骤如下:

  1. 应用程序的源代码由 Java 编译器编译成字节码。

  2. Spring 的 AOT 引擎在封闭世界假设下分析代码,并生成 AOT 源代码和可达性元数据。

  3. AOT 生成的代码使用 Java 编译器编译成字节码。

  4. 应用程序的字节码,连同可达性元数据和 AOT 引擎创建的字节码,被发送到 GraalVM 的原生镜像编译器,该编译器创建原生镜像。

有关如何在 Spring Boot 3 中支持创建 GraalVM 原生镜像的更多信息,请参阅docs.spring.io/spring-boot/docs/current/reference/html/Native Image.html

创建原生镜像可以通过两种方式完成:

  • 为当前操作系统创建原生镜像:

    第一种选择使用 Gradle 的nativeImage任务。它将使用已安装的 GraalVM 原生镜像编译器为当前操作系统和硬件架构创建一个可执行文件。当在构建文件中声明 GraalVM 的 Gradle-plugin 时,nativeImage 任务可用。

  • 将原生镜像作为 Docker 镜像创建

    第二种选择是使用现有的 Gradle 任务bootBuildImage来创建 Docker 镜像。鉴于 GraalVM 的 Gradle 插件已在构建文件中声明,bootBuildImage任务将创建一个包含原生镜像的 Docker 镜像,而不是包含应用程序 JAR 文件的 Java VM。原生镜像将在 Docker 容器中构建,因此它将为 Linux 构建。这也意味着当使用bootBuildImage任务时,不需要安装 GraalVM 的原生镜像编译器。在底层,此任务使用buildpacks而不是 Dockerfile 来创建 Docker 镜像。

buildpacks 的概念是由 Heroku 在 2011 年引入的。2018 年,由 Pivotal 和 Heroku 创建的Cloud Native Buildpacks项目(buildpacks.io),并在同年加入了 CNCF。

为了更加正式一点,buildpack 根据 OCI Image Format 规范创建一个OCI 镜像github.com/opencontainers/image-spec/blob/master/spec.md。由于 OCI 规范基于 Docker 的镜像格式,格式非常相似,并且都由容器引擎支持。

要创建 OCI 镜像,Spring Boot 使用来自Paketo项目的 buildpack;有关更多信息,请参阅docs.spring.io/spring-boot/docs/3.0.5/reference/html/container-images.html#container-images.buildpackspaketo.io/docs/builders。遗憾的是,在撰写本章时,Paketo 的 buildpacks 不支持arm64,包括苹果硅。基于amd64(英特尔)的 buildpack 在技术上可以在苹果硅的 MacBook 上运行,但运行速度非常慢。正如这里所建议的www.cloudfoundry.org/blog/arm64-paketo-buildpacks/,可以使用非官方的arm64Docker 镜像作为临时的解决方案。它们在此处可用:hub.docker.com/r/dashaun/builder-arm

使用本地 OS 的nativeImage任务创建本地镜像比创建 Docker 镜像更快。因此,nativeImage任务可以在最初尝试成功构建本地镜像时用于快速反馈循环。但是,一旦解决这个问题,创建包含本地镜像的 Docker 镜像是测试本地编译的微服务最有用的替代方案,无论是使用 Docker Compose 还是 Kubernetes。在本章中,我们将使用前面提到的非官方 Docker 镜像,名为dashaun/builder:tiny。提供了arm64amd64的 Docker 镜像。

有几个工具和项目可以帮助消除本地编译的挑战。下一节将概述它们,在测试和编译本地镜像部分,我们将学习如何使用它们。

处理本地编译问题

正如之前提到的,使用本地编译 Spring Boot 应用程序还不是主流。因此,当你在自己的应用程序上尝试时,可能会遇到问题。本节将介绍一些可以用来处理这些问题的项目和工具。以下章节将提供如何使用这些工具的示例。

以下项目和工具可用于处理 Spring Boot 应用程序的本地编译问题:

  • Spring AOT 烟雾测试:

    此项目包含一系列测试,用于验证各种 Spring 项目在本地编译时是否正常工作。每当您遇到与本地编译 Spring 功能相关的问题时,您应该开始查找此项目以找到可行的解决方案。此外,如果您想报告关于本地编译 Spring 项目的任何问题,您可以使用此项目的测试作为模板,以可重复的方式展示问题。该项目可在github.com/spring-projects/spring-aot-smoke-tests找到。测试结果可在 Spring 的 CI 环境中找到。例如,各种 Spring Cloud 项目的测试可以在这里找到:ci.spring.io/teams/spring-aot-smoke-tests/pipelines/spring-aot-smoke-tests-3.0.x?group=cloud-app-tests

  • GraalVM 可达性元数据仓库:

    此项目包含各种尚未支持本地编译的开放源代码项目的可达性元数据。GraalVM 社区可以提交可达性元数据,这些数据在项目团队审查后获得批准。GraalVM 的 Gradle 插件会自动从该项目查找可达性元数据,并在本地编译时添加它。有关更多信息,请参阅graalvm.github.io/native-build-tools/0.9.18/gradle-plugin.html#metadata-support

  • 使用 Java VM 测试 AOT 生成的代码:

    由于本地编译 Spring Boot 应用程序需要几分钟,一个有趣的替代方案是尝试在 Java VM 上运行 Spring AOT 引擎生成的初始化代码。通常,在使用 Java VM 时,会忽略 AOT 生成的代码,但可以通过将系统属性spring.aot.enabled设置为true来改变这一点。这意味着应用程序的正常基于反射的初始化被替换为执行生成的初始化代码。这可以用作快速验证生成的初始化代码是否按预期工作。另一个积极的影响是应用程序启动速度略有提高。

  • 提供自定义提示:

    如果应用程序需要为 GraalVM Native Image 编译器创建原生图像而自定义可达性元数据,它们可以按照介绍 GraalVM 项目部分中所述的 JSON 文件提供。Spring 通过使用名为@RegisterReflectionForBinding的注解或在类中实现RuntimeHintsRegistrar接口来提供 JSON 文件的替代方案,该接口可以通过使用@ImportRuntimeHints注解激活。RegisterReflectionForBinding注解更容易使用,但实现RuntimeHintsRegistrar接口提供了对指定的提示的完全控制。

    使用 Spring 的自定义提示而非 GraalVM JSON 文件的一个重要好处是,自定义提示是类型安全的,并且由编译器进行检查。如果 GraalVM JSON 文件中引用的实体被重命名,但 JSON 文件未更新,那么该元数据将丢失。这将导致 GraalVM Native Image 编译器无法创建原生镜像。

    当使用自定义提示时,源代码甚至无法编译;通常,当实体被重命名时,IDE 会抱怨自定义提示不再有效。

  • 运行原生测试:

    尽管使用 Java VM 测试 AOT 生成的代码可以快速指示原生编译是否可行,但我们仍然需要创建应用程序的原生镜像以进行全面测试。基于创建原生镜像、启动应用程序以及最后手动运行一些测试的反馈循环非常慢且容易出错。与此过程相比的一个有吸引力的替代方案是运行原生测试,其中 Spring 的 Gradle 插件将自动创建原生镜像,然后使用原生镜像运行应用程序项目中定义的 JUnit 测试。这仍然会因为原生编译而花费时间,但整个过程是全自动且可重复的。在确保原生测试按预期运行后,它们可以被放入 CI 构建管道以进行自动化执行。可以使用以下命令使用 Gradle 启动原生测试:

    gradle nativeTest 
    
  • 使用 GraalVM 的跟踪代理:

    如果确定需要创建一个可工作的原生镜像所需的可达性元数据和/或自定义提示很困难,GraalVM 的跟踪代理可以帮助。如果在 Java VM 中运行应用程序时启用了跟踪代理,它可以根据应用程序如何使用反射、资源和代理来收集所需的可达性元数据。如果与 JUnit 测试一起运行,这特别有用,因为收集所需的可达性元数据将自动化且可重复。

    在介绍了如何处理主要预期挑战的工具和解释之后,让我们看看为了能够原生编译微服务,源代码需要做出哪些更改。

源代码的更改

在将微服务的 Java 源代码编译成原生可执行镜像之前,需要更新源代码。为了能够原生编译微服务,以下更改已应用于源代码:

  • 通过添加 GraalVM 插件、调整一些依赖项和配置bootBuildImage命令,已更新 Gradle 构建文件build.gradle

  • 已添加所需的可达性元数据和自定义提示。

  • 已添加构建时间属性文件,以确保在构建时 AOT 处理期间所需的 Spring bean 是可达的。

  • 已将一些在运行时使用的属性添加到config-repo中,以便原生编译的微服务能够成功运行。

  • 已添加配置以能够运行 GraalVM 原生镜像跟踪代理。

  • 由于 Docker 镜像不再包含 curl 命令,验证脚本 test-em-all.bash 已被更新。

  • 运行原生测试 部分所述,使用 @DisabledInNativeImage 注解在类级别禁用了原生测试。

  • 添加了两个新的 Docker Compose 文件,用于使用包含原生镜像的 Docker 镜像。

  • 已在微服务的 Dockerfile 中添加一个系统属性,以简化切换 AOT 模式。ENVIRONMENT 命令已更新,当与 Java VM 一起运行时将禁用 AOT 模式。它看起来像这样:

    ENTRYPOINT ["java", "-Dspring.aot.enabled=false", "org.springframework.boot.loader.JarLauncher"] 
    

注意,将 spring.aot.enabled 指定为一个环境变量或在属性文件中是不起作用的;它必须作为 java 命令上的系统属性来设置。

让我们逐个查看这些更改,并从应用于构建文件的更改开始。

Gradle 构建文件的更新

本节中描述的更改已应用于每个微服务项目的 build.gradle 文件,除非另有说明。

以下是一些已应用的更新:

  • 为了启用 Spring AOT 任务,已添加 GraalVM 插件:

    plugins {
        ...
        id 'org.graalvm.buildtools.native' version '0.9.18'
    } 
    
  • bootBuildImage 任务被配置为指定创建的 Docker 镜像的名称。与早期章节中使用的命名约定相同,但镜像名称前缀为 native- 以区分现有的 Docker 镜像。此外,还指定了一个支持 arm64dashaun/builder:tiny 的构建器 Docker 镜像。对于 product 微服务,配置如下:

    tasks.named('bootBuildImage') {
        imageName = "hands-on/native-product-service"
        builder = "dashaun/builder:tiny"
    } 
    
  • 为了解决原生编译中的一些问题,Spring Boot 已从其他章节中使用的 v3.0.4 升级到 v3.0.5。出于相同的原因,springdoc-openapi 已从 v2.0.2 升级到 v2.1.0。

  • 由于 github.com/spring-projects/spring-boot/issues/33238 中描述的问题,jar 任务不再被禁用。

要回顾为什么禁用了 jar 任务,请参阅 第三章 中的 实现我们的 API 部分。

这些就是构建文件所需的所有更改。在下一节中,我们将了解在某些情况下我们需要如何帮助原生编译器编译我们的源代码。

提供可达性元数据和自定义提示

在源代码中,有几个地方 GraalVM 原生编译器需要帮助才能正确编译源代码。第一个情况是微服务使用的基于 JSON 的 API 和消息。JSON 解析器 Jackson 必须能够根据微服务接收的 JSON 文档创建 Java 对象。Jackson 使用反射来完成这项工作,我们需要告诉原生编译器 Jackson 将应用反射的类。

例如,Product 类的原生提示如下所示:

@RegisterReflectionForBinding({ Event.class, ZonedDateTimeSerializer.class, Product.class})public class ProductServiceApplication { 

所有必要的自定义提示注解都已添加到每个微服务的主类中。

当编写本章时,Resilience4J 注解在原生编译时无法正常工作。在问题#1882中,通过提供一个RuntimeHintsRegistrar接口的实现来提出了解决这个问题的方案。有关详细信息,请参阅github.com/resilience4j/resilience4j/issues/1882。此方案已应用于product-service项目中的NativeHintsConfiguration类。该类最核心的部分如下:

@Configuration
@ImportRuntimeHints(NativeHintsConfiguration.class)
public class NativeHintsConfiguration implements RuntimeHintsRegistrar {
  @Override
  public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
    hints.reflection().registerType(CircuitBreakerAspect.class,
      builder -> builder.withMembers(INVOKE_DECLARED_METHODS));
    hints.reflection().registerType(RetryAspect.class,
      builder -> builder.withMembers(INVOKE_DECLARED_METHODS));
    hints.reflection().registerType(TimeLimiterAspect.class,
      builder -> builder.withMembers(INVOKE_DECLARED_METHODS));
  }
} 

从上面的源代码中,我们可以看到为product-composite微服务中使用的三个注解注册了提示,即断路器、重试和时间限制器。该类还通过使用ImportRuntimeHints注解导入自身来提供必要的配置。

一个最终的角落案例是我们必须为 Resilience4J 在retry机制声明中使用反射提供可达性元数据。配置看起来是这样的:

resilience4j.retry:
  instances:
    product:
      maxAttempts: 3
      waitDuration: 1000
      retryExceptions:
      - org.springframework.web.reactive.function.client.WebClientResponseException$InternalServerError 

此配置将启用retry机制重试类型为InternalServerError的错误。为了让 GraalVM Native Image 编译器知道必须为此类启用反射,已经使用了提供可达性元数据的第三种方式:通过提供 GraalVM 配置文件。请参阅product-composite项目中的src/main/resources/META-INF/Native Image/reflect-config.json

我们现在知道如何为我们自己的源代码提供元数据和自定义提示。接下来,我们将学习如何确保在构建时也存在的运行时所需的 Spring beans,以便 AOT 处理可以内省它们并生成适当的 AOT 代码。

application.yml文件中启用构建时的 Spring beans

如上所述,由于静态分析在构建时使用的封闭世界假设,所有运行时所需的 Spring beans 必须在构建时可达。否则,它们不能在运行时激活。鉴于它们在构建时可达,它们可以在运行时进行配置。总结来说,这意味着如果你正在使用仅在设置某些配置文件或满足某些条件(使用@Profile@ConditionalOnProperty注解)时创建的 Spring beans,你必须确保这些配置文件和条件在构建时得到满足。

例如,当使用原生编译的微服务时,在运行时指定单独的管理端口的可能性仅当在构建时将管理端口设置为随机端口(不同于标准端口)时才可行。因此,每个微服务在其src/main/resources文件夹中都有一个application.yml文件,该文件指定:

# Required to make the Spring AOT engine generate the appropriate infrastructure for a separate management port at build time
management.server.port: 9009 

在构建时指定此配置后,当创建 Native Image 时,可以在运行时使用config-repo文件夹中的属性文件将管理端口设置为任何值。

下面是所有在 application.yml 文件中设置的构建时属性列表,以避免各种 Spring 微服务使用时出现这些问题:

# Required to make Springdoc handling forward headers correctly when natively compiled
server.forward-headers-strategy: framework
# Required to make the Spring AOT engine generate the appropriate infrastructure for a separate management port, Prometheus, and K8S probes at build time
management.server.port: 9009
management.endpoint.health.probes.enabled: true
management.endpoints.web.exposure.include: health,info,circuitbreakerevents,prometheus
# Required to make the Spring AOT engine generate a ReactiveJwtDecoder for the OIDC issuer
spring.security.oauth2.resourceserver.jwt.issuer-uri: http://someissuer
# See https://github.com/springdoc/springdoc-openapi/issues/1284#issuecomment-1279854219
springdoc.enable-native-support: true
# Native Compile: Point out that RabbitMQ is to be used when performing the native compilation
spring.cloud.stream.defaultBinder: rabbit
# Native Compile: Required to disable the health check of RabbitMQ when using Kafka
# management.health.rabbit.enabled: false
# Native Compile: Required to disable the health check of Kafka when using RabbitMQ
management.health.kafka.enabled: false
# Native Compile: Required to get the circuit breaker's health check to work properly
management.health.circuitbreakers.enabled: true 

如第 #2255 号问题所述,当原生编译时,Swagger UI 不显示 授权 按钮。请参阅 github.com/springdoc/springdoc-openapi/issues/2255

也可能存在原生编译的微服务在运行时也需要略微不同的配置的情况;这将在下一节中介绍。

更新的运行时属性

在一种情况下,当使用原生编译的图像时,还需要更新运行时属性。这是 review 微服务使用的 MySQL 数据库的连接字符串。由于默认情况下 Native Image 并不表示所有字符集,我们必须指定一个在 Native Image 中可用的字符集。我们将使用 UTF-8 字符集。这是在 review 配置文件 config-repo/review.yml 中所有 MySQL 连接属性中完成的。它看起来是这样的:

spring.datasource.url: jdbc:mysql://localhost/review-db?useUnicode=true&connectionCollation=utf8_general_ci&characterSetResults=utf8&characterEncoding=utf-8 

在构建时间和运行时对所需的属性更改都覆盖后,让我们学习如何配置 GraalVM 原生图像跟踪代理。

GraalVM 原生图像跟踪代理的配置

在难以确定所需的可达性元数据和/或自定义提示的情况下,我们可以使用 GraalVM 原生图像跟踪代理。如前所述,它可以在运行时检测反射、资源和代理的使用,并根据这些信息创建所需的可达性元数据。

要启用跟踪代理以观察 JUnit 测试的执行,可以将以下 jvmArgs 添加到 test 部分的 build.gradle 文件中:

tasks.named('test') {
    useJUnitPlatform()
    jvmArgs "-agentlib:Native Image-agent=access-filter-file=src/test/resources/access-filter.json,config-output-dir=src/main/resources/META-INF/Native Image"
} 

由于跟踪代理对于本书中的微服务来说不是必需的,因此该配置在构建文件中被注释掉了。

参数 Native Image-agent=access-filter-file 指定了一个文件,列出了跟踪代理应排除的 Java 包和类,通常是我们在运行时没有用到的与测试相关的类。

例如,对于 product 微服务,文件 src/test/resources/access-filter.json 看起来是这样的:

{ "rules":
  [
    {"excludeClasses": "org.apache.maven.surefire.**"},
    {"excludeClasses": "net.bytebuddy.**"},
    {"excludeClasses": "org.apiguardian.**"},
    {"excludeClasses": "org.junit.**"},
    {"excludeClasses": "org.gradle.**"},
    {"excludeClasses": "org.mockito.**"},
    {"excludeClasses": "org.springframework.test.**"},
    {"excludeClasses": "org.springframework.boot.test.**"},
    {"excludeClasses": "org.testcontainers.**"},
    {"excludeClasses": "se.magnus.microservices.core.product.MapperTests"},
    {"excludeClasses": "se.magnus.microservices.core.product.MongoDbTestBase"},
    {"excludeClasses": "se.magnus.microservices.core.product.PersistenceTests"},
    {"excludeClasses": "se.magnus.microservices.core.product.ProductServiceApplicationTests"}
  ]
} 

config-output-dir 参数指定的文件夹将包含生成的配置文件。指定的文件夹,src/main/resources/META-INF/Native Image,是 GraalVM 原生编译器查找可达性元数据的地方。

最后,让我们学习验证脚本是如何被采用以便能够测试原生图像的。

test-em-all.bash 验证脚本的更新

在前几章中,eclipse-temurin 被用作 Docker 镜像的基础镜像。验证脚本 test-em-all.bash 使用该基础镜像中包含的 curl 命令在 product-composite 容器内运行断路器测试。

验证脚本在product-composite容器内运行curl命令,因为用于验证断路器功能的应用程序端点在 Docker 内部网络之外没有暴露。

对于原生编译的微服务,Docker 图像将不再包含像curl命令这样的实用工具。为了克服这个问题,curl命令是从auth-server的容器中执行的,其 Docker 图像仍然基于eclipse-temurin,因此包含所需的curl命令。

由于断路器的测试是从auth-server执行的,因此主机名localhost被替换为product-composite

有关详细信息,请参阅验证脚本test-em-all.bash

在解释了源代码中所需更改后,让我们学习如何使用上一节中提到的各种工具来测试和为微服务创建原生图像。

测试和编译原生图像

现在,是时候尝试测试和构建原生图像的工具了!

本节将涵盖以下工具:

  • 运行跟踪代理

  • 执行原生测试

  • 为当前操作系统创建原生图像

  • 将原生图像作为 Docker 图像创建

由于前三个工具要求本地安装 GraalVM 及其Native Image编译器,我们必须首先安装它们。接下来,我们将逐一介绍这些工具。

安装 GraalVM 及其原生图像编译器

如果你想在不使用 Docker 的情况下尝试原生测试、跟踪代理或原生编译器,你必须首先安装 GraalVM JDK 及其原生图像编译器。

可以通过以下步骤完成:

  1. 要安装 GraalVM,将使用SDKman (sdkman.io)。如果尚未安装,可以使用以下命令安装:

    curl -s "https://get.sdkman.io" | bash
    source "$HOME/.sdkman/bin/sdkman-init.sh" 
    
  2. 使用以下命令验证 SDKman 是否正确安装:

    sdk version 
    

    预期它会返回类似以下内容:

    SDKMAN 5.18.1 
    
  3. 在 Linux 上,GraalVM 的原生图像编译器需要安装 GCC。如果你在 Windows 的 WSL 2 上运行 Ubuntu 实例,你可以使用以下命令安装 GCC 及其所需依赖项:

    sudo apt install -y build-essential libz-dev zlib1g-dev 
    
  4. 现在可以安装 GraalVM 了。本书使用 Java 17 的 22.3.1 版本。可以使用以下命令安装并设置为默认 Java 版本:

    sdk install java 22.3.1.r17-grl 
    sdk default java 22.3.1.r17-grl 
    
  5. 最后,可以使用以下命令安装原生图像编译器:

    gu install Native Image 
    
  6. 要验证安装,请运行以下命令:

    java -version
    gu list 
    

    预期java -version命令的响应如下:

    openjdk version "17.0.6" 2023-01-17
    OpenJDK Runtime Environment GraalVM CE 22.3.1 (build 17.0.6+10-jvmci-22.3-b13)
    OpenJDK 64-Bit Server VM GraalVM CE 22.3.1 (build 17.0.6+10-jvmci-22.3-b13, mixed mode, sharing) 
    

    对于gu list命令,预期:

    ComponentId              Version             Component name
    -----------------------------------------------------------
    graalvm                  22.3.1              GraalVM Core  
    Native Image             22.3.1              Native Image 
    

现在我们已经准备好尝试这些工具了;让我们从跟踪代理开始!

运行跟踪代理

对于本书中的微服务,跟踪代理不是必需的。但了解如何在其他情况下使用它可能很有趣:例如,如果你的某个微服务需要跟踪代理的帮助来生成所需的可达性元数据。

如果你想尝试跟踪代理,你可以按照以下步骤进行:

  1. 在所选微服务的build.gradle文件部分激活jvmArgs参数,通过移除前面的注释字符//

  2. 运行gradle test命令,在这种情况下为product服务:

    cd $BOOK_HOME/Chapter23
    ./gradlew :microservices:product-service:test --no-daemon 
    
    • 这是一个正常的gradle test命令,但为了避免内存不足,我们禁用了 Gradle 守护进程的使用。默认情况下,守护进程的堆大小限制为 512 MB,这对于大多数情况中的跟踪代理来说是不够的。
  3. 测试完成后,你应该在microservices/product-service/src/main/resources/META-INF/Native Image文件夹中找到以下文件:

    jni-config.json
    predefined-classes-config.json
    proxy-config.json
    reflect-config.json
    resource-config.json
    serialization-config.json 
    

在浏览完生成的文件后,通过在构建文件中jvmArgs参数之前添加回注释来禁用跟踪代理并删除创建的文件。

接下来,我们将继续介绍如何使用原生测试!

运行原生测试

如前所述,运行原生测试对于自动化查找创建原生图像的问题过程非常有用。不幸的是,目前有几个问题阻止我们在这本书的微服务中使用原生测试:

因此,所有测试都已被禁用,使用原生测试在类级别使用@DisabledInNativeImage注解。这意味着我们仍然可以运行原生测试命令;所有原生图像都将被创建,但在原生图像中目前不会执行任何测试。随着这些问题的解决,@DisabledInNativeImage注解可以逐步移除,并且越来越多的测试将由原生测试命令运行。

要在所有四个微服务上运行原生测试,请运行以下命令:

./gradlew nativeTest 

要测试特定的微服务,请运行类似以下命令的命令:

./gradlew :microservices:product-service:nativeTest 

在每个微服务的测试之后,原生测试工具会创建一个看起来像以下报告的测试报告:

JUnit Platform on Native Image - report
...
[        13 tests found           ]
[        13 tests skipped         ]
[         0 tests started         ]
[         0 tests aborted         ]
[         0 tests successful      ]
[         0 tests failed          ] 

如上报告所示,目前所有测试都被跳过了。

在介绍了测试代理和原生测试之后,让我们看看我们如何创建原生图像。

为当前操作系统创建原生图像

创建原生图像的第一个选项是使用 Gradle 的nativeImage命令。它将使用已安装的 GraalVM Native Image编译器为当前操作系统和硬件架构创建可执行文件。

由于我们只使用 Docker 和 Kubernetes 来测试我们的微服务,因此我们不会使用此命令创建的原生镜像。但为了在product-composite微服务上尝试它,请运行以下命令:

  1. 使用以下命令创建原生镜像:

    ./gradlew microservices:product-composite-service:nativeCompile 
    

    可执行文件将创建在build/native/nativeCompile文件夹中,文件名为product-composite-service

  2. 可执行文件可以使用file命令进行检查:

    file microservices/product-composite-service/build/native/nativeCompile/product-composite-service 
    

    它将响应如下:

    …product-composite-service: Mach-O 64-bit executable arm64 
    

    在这里,Mach-O表示文件是为 macOS 编译的,而arm64表示它是为 Apple 硅编译的。

  3. 为了尝试它,我们需要手动启动它所需的资源。在这种情况下,只需要 RabbitMQ 才能成功启动。使用以下命令启动它:

    docker-compose up -d rabbitmq 
    
  4. 现在可以在终端中通过指定与docker-compose文件中提供的相同环境变量来启动原生镜像:

    SPRING_RABBITMQ_USERNAME=rabbit-user-prod \
    SPRING_RABBITMQ_PASSWORD=rabbit-pwd-prod \
    SPRING_CONFIG_LOCATION=file:config-repo/application.yml,file:config-repo/product-composite.yml \
    microservices/product-composite-service/build/native/nativeCompile/product-composite-service 
    

    它应该启动得很快,并在日志输出中打印如下内容:

    Started ProductCompositeServiceApplication in 0.543 seconds 
    
  5. 通过调用其存活探测来尝试它:

    curl localhost:4004/actuator/health/liveness 
    

    预期它会这样回答:

    {"status":"UP"} 
    
  6. 通过按Ctrl + C停止执行,并使用以下命令停止 RabbitMQ 容器:

    docker-compose down 
    

尽管这是创建原生镜像最快的方法,但它对本书的范围来说并不很有用。相反,它需要为 Linux 构建并放置在 Docker 容器中。让我们跳到下一节,学习如何做到这一点。

将原生镜像作为 Docker 镜像创建

现在,是时候构建包含我们微服务原生镜像的 Docker 镜像了。按照以下步骤进行:

  1. 这是一个非常资源密集的过程。因此,首先,请确保 Docker Desktop 被允许至少消耗 10 GB 的内存,以避免内存不足错误。

如果构建失败,并显示类似<container-name> exited with code 137的错误消息,那么你在 Docker 中已经耗尽了内存。

  1. 如果你的计算机内存少于 32 GB,此时停止minikube实例可能是个好主意,以避免在计算机中耗尽内存。使用以下命令:

    minikube stop 
    
  2. 确保 Docker 客户端与 Docker Desktop 通信,而不是与minikube实例通信:

    eval $(minikube docker-env -u) 
    
  3. 运行以下命令来编译product服务:

    ./gradlew :microservices:product-service:bootBuildImage --no-daemon 
    

    预期这个过程会花费一些时间。该命令将启动一个 Docker 容器以执行原生编译。第一次运行时,它还会下载用于 Docker 的 GraalVM 原生编译器,这使得编译时间更长。在我的 MacBook 上,第一次编译需要几分钟,主要取决于我的网络容量;之后,只需要大约一分钟左右。

    在编译过程中,预期会有大量的输出,包括各种警告和错误消息。成功的编译将以如下日志输出结束:

    Successfully built image 'docker.io/hands-on/native-product-service:latest' 
    
  4. 使用以下命令使用原生编译剩余的三个微服务:

    ./gradlew :microservices:product-composite-service:bootBuildImage --no-daemon
    ./gradlew :microservices:recommendation-service:bootBuildImage --no-daemon
    ./gradlew :microservices:review-service:bootBuildImage --no-daemon 
    
  5. 为了验证 Docker 镜像是否成功构建,请运行以下命令:

    docker images | grep "hands-on/native" 
    

    预期输出如下:

图形用户界面,自动生成文本描述

图 23.2:包含本机编译可执行文件的 Docker 镜像

现在我们已创建了包含本机编译可执行文件的 Docker 镜像,我们准备尝试它们!我们将从 Docker Compose 开始,然后尝试使用 Kubernetes。

使用 Docker Compose 进行测试

我们准备尝试使用本机编译的微服务。为了使用包含本机编译微服务的 Docker 镜像,已创建了两个新的 Docker Compose 文件,docker-compose-native.ymldocker-compose-partitions-native.yml。它们是docker-compose.ymldocker-compose-partitions.yml的副本,其中已从微服务的定义中移除了build选项。此外,已更改要使用的 Docker 镜像的名称,因此我们之前创建的镜像使用,名称以native-开头。

在本章中,我们仅使用docker-compose-native.yml;请随意尝试使用docker-compose-partitions-native.yml

我们首先将使用基于 Java VM 的微服务获取基准,以比较启动时间和初始内存消耗。我们将运行以下测试:

  • 使用已禁用 AOT 模式的基于 Java VM 的微服务。

  • 使用已启用 AOT 模式的基于 Java VM 的微服务。

  • 使用包含本机编译微服务的 Docker 镜像。

为了避免端口冲突,我们首先必须使用以下命令停止minikube实例:

minikube stop 

禁用 AOT 模式测试 Java VM-based 微服务

我们将通过忽略 AOT 生成的代码,以与之前章节中相同的方式启动基于 Java VM 的微服务来开始测试。运行以下命令以测试基于 Java VM 的微服务:

  1. 首先在 Docker Desktop 中编译源代码并构建基于 Java VM 的 Docker 镜像:

    cd $BOOK_HOME/Chapter23
    eval $(minikube docker-env -u)
    ./gradlew build
    docker-compose build 
    
  2. 使用基于 Java VM 的微服务的默认 Docker Compose 文件:

    unset COMPOSE_FILE 
    
  3. 启动所有容器,除了微服务的容器:

    docker-compose up -d mysql mongodb rabbitmq auth-server gateway 
    

    等待容器启动,直到 CPU 负载下降。

  4. 使用 Java VM 启动微服务:

    docker-compose up -d 
    

    等待微服务启动,再次监控 CPU 负载。

  5. 要找出启动微服务所需的时间,我们可以查找包含: Started的日志输出。运行以下命令:

    docker-compose logs product-composite product review recommendation | grep ": Started" 
    

    预期输出如下:

    计算机屏幕截图,中等置信度自动生成描述

    图 23.3:基于 Java VM 的微服务的启动时间

    在输出中,我们可以看到启动时间从 5.5 秒到 7 秒不等。请记住,所有四个微服务实例是同时启动的,因此与逐个启动相比,启动时间更长。

  6. 运行测试以验证系统景观按预期工作:

    USE_K8S=false HOST=localhost PORT=8443 HEALTH_URL=https://localhost:8443 ./test-em-all.bash 
    
  7. 预期测试结果与之前章节中看到的结果相同:

计算机屏幕截图  描述自动生成,置信度中等

图 23.4:测试脚本的输出

  1. 最后,为了找出启动和运行测试后使用的内存量,运行以下命令:

    docker stats --no-stream 
    

    预期响应如下:

    计算机程序屏幕截图  描述自动生成,置信度中等

    图 23.5:基于 Java 虚拟机的微服务的内存使用情况

    从前面的输出中,我们可以看到微服务消耗了大约 240-310 MB。

  2. 关闭系统景观:

    docker compose down 
    

现在我们知道了在不使用 AOT 生成的代码的情况下微服务启动所需的时间;让我们在 AOT 模式下测试它们。

在启用 AOT 模式下测试基于 Java 虚拟机的微服务

现在,我们将启用 AOT 模式,使用 AOT 生成的代码启动基于 Java 虚拟机的微服务。我们预计它们在 AOT 模式下启动会更快。运行以下命令:

  1. 启动所有容器,除了微服务的容器:

    docker-compose up -d mysql mongodb rabbitmq auth-server gateway 
    
  2. 通过编辑每个微服务的 Dockerfile 启用 AOT 模式,并在 ENVIRONMENT 命令中设置“-Dspring.aot.enabled=true",使其看起来像这样:

    ENTRYPOINT ["java", "-Dspring.aot.enabled=true", "org.springframework.boot.loader.JarLauncher"] 
    
  3. 重新构建微服务:

    docker-compose build 
    
  4. 启动微服务:

    docker-compose up -d 
    
  5. 检查 AOT 模式:

    docker-compose logs product-composite product review recommendation | grep "Starting AOT-processed" 
    

    预期包含“Starting AOT-processed"的四行输出。

  6. 检查启动时间:

    docker-compose logs product-composite product review recommendation | grep ": Started" 
    

    预期输出与上面未启用 AOT 模式时的输出相同,但启动时间略短。在我的情况下,启动时间从 4.5 秒到 5.5 秒不等。与正常 Java 虚拟机启动时间相比,这快了 1 到 1.5 秒。

  7. 运行 test-em-all.bash:

    USE_K8S=false HOST=localhost PORT=8443 HEALTH_URL=https://localhost:8443 ./test-em-all.bash 
    

    预期输出与上面未启用 AOT 模式时的输出相同。

  8. 在 Dockerfile 中撤销更改并重新构建 Docker 镜像以禁用 AOT 模式。

  9. 关闭系统景观:

    docker compose down 
    

在这个测试中,我们验证了使用 AOT 生成的代码,基于 Java 虚拟机的微服务启动速度略有提升。现在,是时候尝试原生编译的微服务了。

测试原生编译的微服务

现在,我们准备重复相同的程序,但这次使用带有原生编译微服务的 Docker 镜像:

  1. 切换到新的 Docker Compose 文件:

    export COMPOSE_FILE=docker-compose-native.yml 
    
  2. 启动所有容器,除了微服务的容器:

    docker-compose up -d mysql mongodb rabbitmq auth-server gateway 
    

    等待容器启动,直到 CPU 负载下降。

  3. 使用 Java 虚拟机启动微服务:

    docker-compose up -d 
    

    等待微服务启动,再次监控 CPU 负载。

  4. 为了找出原生编译的微服务启动所需的时间,运行之前运行的相同命令:

    docker-compose logs product-composite product review recommendation | grep ": Started" 
    

    预期输出如下:

    计算机屏幕截图  描述自动生成,置信度中等

    图 23.6:原生编译微服务的启动时间

    在上述输出中,我们可以看到启动时间从 0.2-0.5 秒不等。考虑到所有微服务实例都是同时启动的,与基于 Java VM 的测试需要 5.5 到 7 秒相比,这些数字相当令人印象深刻!

  5. 运行测试以验证系统景观按预期工作:

    USE_K8S=false HOST=localhost PORT=8443 HEALTH_URL=https://localhost:8443 ./test-em-all.bash 
    

    预期与上述测试相同的输出,使用基于 Java VM 的 Docker 镜像。

  6. 最后,为了了解启动和运行测试后使用的内存量,请运行以下命令:

    docker stats --no-stream 
    

    预期如下响应:

    计算机程序截图,描述自动生成,置信度中等

    图 23.7:原生编译微服务的内存使用情况

    从前面的输出中,我们可以看到微服务消耗了大约 80-130 MB。再次强调,这与 Java VM 容器使用的 240-310 MB 相比,这是一个明显的减少!

  7. 关闭系统景观:

    docker compose down 
    

为了更好地了解原生编译微服务的内存和 CPU 消耗,需要进行更真实的负载测试,但这超出了本书的范围。

在看到原生编译的微服务在启动时比基于 Java VM 的替代方案更快、内存消耗更少之后,让我们看看如何使用 Kubernetes 运行它们。

使用 Kubernetes 进行测试

要能够在 Kubernetes 上部署原生编译的微服务,已添加了一个新的环境 Helm 图表,该图表已配置为使用包含原生编译微服务的 Docker 镜像。Helm 图表可以在以下文件夹中找到:

kubernetes/helm/
└── environments
    └── dev-env-native 

在将原生编译的微服务部署到 Kubernetes 之前,我们需要考虑的另一件事是如何提供 Docker 镜像。我们不希望再次运行耗时的原生编译命令来在 minikube 实例中创建新的 Docker 镜像。如果我们在这本书中使用 Docker 仓库,我们可以将镜像推送到仓库,但我们没有。相反,我们将从 Docker Desktop 中提取 Docker 镜像并将它们导入到 minikube 实例中,作为不使用 Docker 仓库的替代方案。

使用以下命令将 Docker 镜像从 Docker Desktop 移动到 minikube 实例:

  1. 从 Docker Desktop 导出 Docker 镜像:

    eval $(minikube docker-env -u)
    docker save hands-on/native-product-composite-service:latest -o native-product-composite.tar
    docker save hands-on/native-product-service:latest -o native-product.tar
    docker save hands-on/native-recommendation-service:latest -o native-recommendation.tar
    docker save hands-on/native-review-service:latest -o native-review.tar 
    
  2. 再次启动 minikube 实例:

    minikube start 
    
  3. 在另一个终端中,启动 minikube tunnel 命令:

    minikube tunnel 
    

注意,此命令需要您的用户具有 sudo 权限,并且在启动时需要输入您的密码。在命令请求密码之前可能需要几秒钟,所以很容易错过!

  1. 将 Docker 镜像导入到 minikube 实例:

    eval $(minikube docker-env)
    docker load -i native-product-composite.tar
    docker load -i native-product.tar
    docker load -i native-recommendation.tar
    docker load -i native-review.tar 
    
  2. 最后,删除导出的 .tar 文件:

    rm native-product-composite.tar native-product.tar native-recommendation.tar native-review.tar 
    

在 Kubernetes 上构建、部署和验证部署的方式与前面的章节相同。运行以下命令:

  1. 使用以下命令构建 auth-server 的 Docker 镜像:

    docker-compose build auth-server 
    
  2. 重新创建命名空间hands-on并将其设置为默认命名空间:

    kubectl delete namespace hands-on
    kubectl apply -f kubernetes/hands-on-namespace.yml
    kubectl config set-context $(kubectl config current-context) --namespace=hands-on 
    
  3. 使用以下命令解决 Helm 图表的依赖项。

    首先,我们更新components文件夹中的依赖项:

    for f in kubernetes/helm/components/*; do helm dep up $f; done 
    

    接下来,我们更新environments文件夹中的依赖项:

    for f in kubernetes/helm/environments/*; do helm dep up $f; done 
    
  4. 现在,我们已准备好使用 Helm 部署系统景观。运行以下命令并等待所有部署完成:

    helm upgrade -install hands-on-dev-env-native \
      kubernetes/helm/environments/dev-env-native \
      -n hands-on --wait 
    

在前面的章节中,我们使用了helm install命令。这里使用的helm upgrade -install命令是一个更好的脚本选择,因为它在图表未安装时执行insert操作,但如果图表已经安装,则执行upgrade操作。这在关系数据库世界中有点类似于upsert命令。

  1. 使用以下命令运行正常测试以验证部署:

    ./test-em-all.bash 
    

    预期输出将与我们在之前的测试中看到的内容相似。

  2. 检查一个 Pod 的启动时间。为了测量特定微服务的实际启动时间,让我们删除它,然后测量 Kubernetes 重新创建它所需的启动时间:

    Kubectl delete pod -l app=product-composite
    kubectl logs -l app=product-composite --tail=-1 | grep ": Started" 
    

    预期会收到如下响应:

    图片 B19825_23_08.png

    图 23.8:在 Kubernetes 中以 Pod 运行时的启动时间

    预计启动时间将与我们在使用 Docker Compose 时观察到的时间相近,例如上面的示例中的 0.4 秒。由于我们还在启动一个作为 sidecar 的 Istio 代理,可能会有一些额外的延迟。

  3. 使用以下命令检查使用的 Docker 镜像:

    kubectl get pods -o jsonpath="{.items[*].spec.containers[*].image}" | xargs -n1 | grep hands-on 
    

    预期会收到以下响应:

    计算机程序截图  描述由低置信度自动生成

    图 23.9:具有本地编译代码的 Docker 镜像

    从输出中,我们可以看到除了auth-server之外的所有容器都使用了具有相同前缀native的 Docker 镜像,这意味着我们在 Docker 容器内运行的是本地编译的可执行文件。

    这完成了本章关于使用 Spring 的 AOT 引擎和 GraalVM 项目为我们的微服务创建本地编译的可执行文件的内容。

摘要

在本章中,我们介绍了新的Spring AOT 引擎及其底层的GraalVM项目,以及其 Native Image 编译器。在构建文件中声明 GraalVM 的插件,并为 Native Image 编译器提供一些可达性元数据和自定义提示后,它可以用来创建 Native Image。Spring Boot 的 Gradle 任务buildBootImage将这些独立的可执行文件打包成可用的 Docker 镜像。

将基于 Java 的源代码编译成原生图像的主要好处是显著更快的启动时间和更少的内存使用。在一个同时启动微服务实例的测试中,我们观察到原生编译的微服务启动时间为 0.2-0.5 秒,而基于 Java VM 的微服务需要 5.5 到 7 秒才能完成相同的测试。此外,在通过脚本test-em-all.bash进行验证后,原生编译的微服务所需的内存比基于 Java VM 的微服务少一半。

本书中的大多数库和框架已经支持 GraalVM 的Native Image编译器。对于那些不支持的情况,GraalVM Reachability Metadata Repository可以通过提供社区中的可达性元数据来提供帮助。GraalVM 的构建插件可以自动检测并从该存储库下载可达性元数据。作为最后的手段,可以使用 GraalVM Native Image 的跟踪代理来创建可达性元数据,以帮助原生编译器。跟踪代理被配置为与现有的 JUnit 测试一起运行,根据测试的执行创建可达性元数据。

如果我们发现对于特定的 Spring 功能来说,使原生图像创建正常工作存在问题,我们可以联系Spring AOT Smoke Tests项目,以获取每个 Spring 功能的示例。为了验证一旦原生编译,微服务将能够正常工作,我们可以使用 Spring Boot 的 Gradle 任务nativeTest运行单元测试。

目前,由于在编写本章时存在的一些问题,nativeTest任务与本书中的源代码结合并不十分有用。

我们还看到了如何轻松地用包含原生编译图像的 Docker 镜像替换运行 Java VM 的 Docker 镜像。通过以AOT 模式运行 Java VM Docker 镜像,可以稍微减少启动时间,同时确保生成的 AOT 代码按预期工作。最后,我们使用 Docker Compose 和 Kubernetes 测试了原生编译的镜像。

通过这种方式,我们已经到达了本书的结尾。我希望它已经帮助您学习如何使用 Spring Boot、Spring Cloud、Kubernetes 和 Istio 的所有惊人功能来开发微服务,并且您感到鼓励去尝试它们!

问题

  1. Spring 的 AOT 引擎和 GraalVM 项目之间有什么关系?

  2. 如何使用跟踪代理?

  3. JIT 编译和 AOT 编译之间的区别是什么?

  4. AOT 模式是什么,它如何有益于使用?

  5. 什么是原生自定义提示?

  6. 原生测试是什么,为什么它有用?

  7. 本地编译 Java 代码如何影响初始内存使用和启动时间?

加入我们的 Discord 社区

加入我们社区的 Discord 空间,与作者和其他读者进行讨论:

packt.link/SpringBoot3e

posted @ 2025-09-12 13:55  绝不原创的飞龙  阅读(0)  评论(0)    收藏  举报