Kubernetes-微服务实用指南-全-

Kubernetes 微服务实用指南(全)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

使用 Kubernetes 进行微服务实践是您一直在等待的书籍。它将引导您同时开发微服务并将其部署到 Kubernetes 上。微服务架构与 Kubernetes 之间的协同作用非常强大。本书涵盖了所有方面。它解释了微服务和 Kubernetes 背后的概念,讨论了现实世界中的问题和权衡,带您完成了完整的基于微服务的系统开发,展示了最佳实践,并提供了充分的建议。

本书深入浅出地涵盖了大量内容,并提供了工作代码来说明。您将学习如何设计基于微服务的架构,构建微服务,测试您构建的微服务,并将它们打包为 Docker 镜像。然后,您将学习如何将系统部署为一组 Docker 镜像到 Kubernetes,并在那里进行管理。

在学习的过程中,您将熟悉最重要的趋势,如自动化的持续集成/持续交付(CI/CD),基于 gRPC 的微服务,无服务器计算和服务网格。

通过本书,您将获得大量关于规划、开发和操作基于微服务架构部署在 Kubernetes 上的大规模云原生系统的知识和实践经验。

本书适合对象

本书面向希望成为大规模软件工程前沿人员的软件开发人员和 DevOps 工程师。如果您有使用容器部署在多台机器上并由多个团队开发的大规模软件系统的经验,将会有所帮助。

本书涵盖内容

第一章,面向开发人员的 Kubernetes 简介,向您介绍了 Kubernetes。您将快速了解 Kubernetes,并了解其与微服务的契合程度。

第二章,微服务入门,讨论了微服务架构中常见问题的各个方面、模式和方法,以及它们与其他常见架构(如单体架构和大型服务)的比较。

第三章,Delinkcious – 示例应用,探讨了为什么我们应该选择 Go 作为 Delinkcious 的编程语言;然后我们将看看 Go kit。

第四章《设置 CI/CD 流水线》教你了解 CI/CD 流水线解决的问题,涵盖了 Kubernetes 的 CI/CD 流水线的不同选项,最后看看如何为 Delinkcious 构建 CI/CD 流水线。

第五章《使用 Kubernetes 配置微服务》将您带入微服务配置的实际和现实世界领域。此外,我们将讨论 Kubernetes 特定的选项,特别是 ConfigMaps。

第六章《在 Kubernetes 上保护微服务》深入探讨了如何在 Kubernetes 上保护您的微服务。我们还将讨论作为 Kubernetes 上微服务安全基础的支柱。

第七章《与世界交流- API 和负载均衡器》让我们向世界开放 Delinkcious,并让用户可以在集群外与其进行交互。此外,我们将添加一个基于 gRPC 的新闻服务,用户可以使用它获取关注的其他用户的新闻。最后,我们将添加一个消息队列,让服务以松散耦合的方式进行通信。

第八章《处理有状态服务》深入研究了 Kubernetes 的存储模型。我们还将扩展 Delinkcious 新闻服务,将其数据存储在 Redis 中,而不是在内存中。

第九章《在 Kubernetes 上运行无服务器任务》深入探讨了云原生系统中最热门的趋势之一:无服务器计算(也称为函数即服务,或 FaaS)。此外,我们将介绍在 Kubernetes 中进行无服务器计算的其他方法。

第十章《测试微服务》涵盖了测试及其各种类型:单元测试、集成测试和各种端到端测试。我们还深入探讨了 Delinkcious 测试的结构。

第十一章《部署微服务》涉及两个相关但分开的主题:生产部署和开发部署。

第十二章《监控、日志和指标》关注在 Kubernetes 上运行大规模分布式系统的操作方面,以及如何设计系统以及需要考虑的因素,以确保卓越的操作姿态。

第十三章,服务网格-使用 Istio,审查了服务网格的热门话题,特别是 Istio。这很令人兴奋,因为服务网格是一个真正的游戏改变者。

第十四章,微服务和 Kubernetes 的未来,涵盖了 Kubernetes 和微服务的主题,将帮助我们学习如何决定何时是采用和投资新技术的正确时机。

充分利用本书

任何软件要求要么列在每章的技术要求部分开头,要么,如果安装特定软件是本章材料的一部分,那么您需要的任何说明将包含在章节本身中。大多数安装都是安装到 Kubernetes 集群中的软件组件。这是本书实践性的重要部分。

下载示例代码文件

您可以从www.packt.com的帐户中下载本书的示例代码文件。如果您在其他地方购买了本书,您可以访问www.packt.com/support并注册,文件将直接通过电子邮件发送给您。

您可以按照以下步骤下载代码文件:

  1. 登录或注册www.packt.com

  2. 选择 SUPPORT 选项卡。

  3. 单击“代码下载和勘误”。

  4. 在搜索框中输入书名,然后按照屏幕上的说明操作。

文件下载后,请确保使用最新版本的解压软件解压文件夹:

  • WinRAR/7-Zip for Windows

  • Zipeg/iZip/UnRarX for Mac

  • 7-Zip/PeaZip for Linux

该书的代码包也托管在 GitHub 上,网址为github.com/PacktPublishing/Hands-On-Microservices-with-Kubernetes。如果代码有更新,将在现有的 GitHub 存储库上进行更新。

我们还有其他代码包,来自我们丰富的图书和视频目录,可在github.com/PacktPublishing/上找到。去看看吧!

下载彩色图片

我们还提供了一个 PDF 文件,其中包含了本书中使用的屏幕截图/图表的彩色图片。您可以在这里下载:static.packt-cdn.com/downloads/9781789805468_ColorImages.pdf

使用的约定

本书中使用了许多文本约定。

CodeInText:表示文本中的代码词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄。这是一个例子:“请注意,我确保它通过chmod +x是可执行的。”

代码块设置如下:

version: 2
jobs:
  build:
    docker:
    - image: circleci/golang:1.11
    - image: circleci/postgres:9.6-alpine

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

$ tree -L 2
.
├── LICENSE
├── README.md
├── build.sh

粗体:表示一个新术语、一个重要词或者你在屏幕上看到的词。例如,菜单或对话框中的词会以这种方式出现在文本中。这是一个例子:“我们可以通过从 ACTIONS 下拉菜单中选择同步来同步它。”

警告或重要提示会出现在这样的地方。提示和技巧会出现在这样的地方。

第一章:开发人员的 Kubernetes 简介

在本章中,我们将向您介绍 Kubernetes。Kubernetes 是一个庞大的平台,在一个章节中很难充分展现它。幸运的是,我们有一整本书来探索它。如果您感到有些不知所措,请不要担心。我会简要提到许多概念和功能。在后面的章节中,我们将详细介绍其中的许多内容,以及这些 Kubernetes 概念之间的联系和互动。为了增加趣味并尽早动手,您还将在本地机器上创建一个 Kubernetes 集群(Minikube)。本章将涵盖以下主题:

  • Kubernetes 简介

  • Kubernetes 架构

  • Kubernetes 和微服务

  • 创建一个本地集群

技术要求

在本章中,您将需要以下工具:

  • Docker

  • Kubectl

  • Minikube

安装 Docker

要安装 Docker,请按照这里的说明操作:docs.docker.com/install/#supported-platforms。我将在 macOS 上使用 Docker。

安装 kubectl

要安装 kubectl,请按照这里的说明操作:kubernetes.io/docs/tasks/tools/install-kubectl/

Kubectl 是 Kubernetes 的 CLI,我们将在整本书中广泛使用它。

安装 Minikube

要安装 Minikube,请按照这里的说明操作:kubernetes.io/docs/tasks/tools/install-minikube/

请注意,您还需要安装一个 hypervisor。对于 macOS,我发现 VirtualBox 是最可靠的。您可能更喜欢另一个 hypervisor,比如 HyperKit。当您开始使用 Minikube 时,将会有更详细的说明。

代码

Kubernetes 简介

在这一部分,您将了解 Kubernetes 的全部内容,它的历史以及它是如何变得如此受欢迎的。

Kubernetes - 容器编排平台

Kubernetes 的主要功能是在一组机器(物理或虚拟)上部署和管理大量基于容器的工作负载。这意味着 Kubernetes 提供了将容器部署到集群的手段。它确保遵守各种调度约束,并将容器有效地打包到集群节点中。此外,Kubernetes 会自动监视您的容器,并在它们失败时重新启动它们。Kubernetes 还会将工作负载从有问题的节点重新定位到其他节点上。Kubernetes 是一个非常灵活的平台。它依赖于计算、内存、存储和网络的基础设施层,并利用这些资源发挥其魔力。

Kubernetes 的历史

Kubernetes 和整个云原生领域发展迅猛,但让我们花点时间回顾一下我们是如何到达这里的。这将是一个非常简短的旅程,因为 Kubernetes 于 2014 年 6 月从谷歌推出,仅仅几年前。当 Docker 变得流行时,它改变了人们打包、分发和部署软件的方式。但很快就显而易见,Docker 本身无法满足大型分布式系统的规模。一些编排解决方案变得可用,比如 Apache Mesos,后来是 Docker 自己的 swarm。但它们从未达到 Kubernetes 的水平。Kubernetes 在概念上基于谷歌的 Borg 系统。它汇集了谷歌工程十年的设计和技术卓越性,但它是一个新的开源项目。在 2015 年的 OSCON 上,Kubernetes 1.0 发布了,大门敞开了。Kubernetes 及其生态系统的增长以及背后的社区,与其技术卓越性一样令人印象深刻。

Kubernetes 在希腊语中意味着舵手。你会注意到许多与 Kubernetes 相关项目的航海术语。

Kubernetes 的现状

Kubernetes 现在是家喻户晓的名字。DevOps 世界几乎将容器编排与 Kubernetes 等同起来。所有主要的云服务提供商都提供托管的 Kubernetes 解决方案。它在企业和初创公司中无处不在。虽然 Kubernetes 仍然年轻,创新不断发生,但这一切都是以非常健康的方式进行的。核心非常稳固,经过了严格测试,并在许多公司的生产中使用。有一些非常大的参与者在合作并推动 Kubernetes 的发展,比如谷歌(显然)、微软、亚马逊、IBM 和 VMware。

Cloud Native Computing FoundationCNCF)开源组织提供认证。每 3 个月,都会推出一个新的 Kubernetes 版本,这是数百名志愿者和有偿工程师合作的结果。有一个庞大的生态系统围绕着商业和开源项目的主要项目。稍后您将看到,Kubernetes 灵活和可扩展的设计鼓励了这个生态系统,并有助于将 Kubernetes 集成到任何云平台中。

了解 Kubernetes 架构

Kubernetes 是软件工程的奇迹。Kubernetes 的架构和设计是其成功的重要组成部分。每个集群都有一个控制平面和数据平面。控制平面由多个组件组成,例如 API 服务器,用于保持集群状态的元数据存储,以及负责管理数据平面中的节点并为用户提供访问权限的多个控制器。生产中的控制平面将分布在多台机器上,以实现高可用性和鲁棒性。数据平面由多个节点或工作节点组成。控制平面将在这些节点上部署和运行您的 pod(容器组),然后监视更改并做出响应。

以下是一个说明整体架构的图表:

让我们详细审查控制平面和数据平面,以及 kubectl,这是您用来与 Kubernetes 集群交互的命令行工具。

控制平面

控制平面由几个组件组成:

  • API 服务器

  • etcd 元数据存储

  • 调度程序

  • 控制器管理器

  • 云控制器管理器

让我们来审查每个组件的作用。

API 服务器

kube-api-server是一个大型的 REST 服务器,向世界公开 Kubernetes API。您可以在控制平面中拥有多个 API 服务器实例,以实现高可用性。API 服务器将集群状态保存在 etcd 中。

etcd 存储

完整的集群存储在 etcd(coreos.com/etcd/)中,这是一个一致且可靠的分布式键值存储。etcd 存储是一个开源项目(最初由 CoreOS 开发)。

通常会有三个或五个 etcd 实例以实现冗余。如果您丢失了 etcd 存储中的数据,您将丢失整个集群。

调度程序

kube 调度器负责将 pod 调度到工作节点。它实现了一个复杂的调度算法,考虑了很多信息,比如每个节点上的资源可用性,用户指定的各种约束条件,可用节点的类型,资源限制和配额,以及其他因素,比如亲和性,反亲和性,容忍和污点。

控制器管理器

kube 控制器管理器是一个包含多个控制器的单个进程,以简化操作。这些控制器监视集群的事件和变化,并做出相应的响应:

  • 节点控制器:负责在节点宕机时发现并做出响应。

  • 复制控制器:确保每个复制集或复制控制器对象有正确数量的 pod。

  • 端点控制器:为每个服务分配一个列出服务 pod 的端点对象。

  • 服务账户和令牌控制器:使用默认服务账户和相应的 API 访问令牌初始化新的命名空间。

数据平面

数据平面是集群中运行容器化工作负载的节点的集合。数据平面和控制平面可以共享物理或虚拟机。当你运行单节点集群(比如 Minikube)时,当然会发生这种情况。但是,通常在一个生产就绪的部署中,数据平面会有自己的节点。Kubernetes 在每个节点上安装了几个组件,以便通信、监视和调度 pod:kubelet、kube 代理和容器运行时(例如 Docker 守护程序)。

kubelet

kubelet 是一个 Kubernetes 代理。它负责与 API 服务器通信,并在节点上运行和管理 pod。以下是 kubelet 的一些职责:

  • 从 API 服务器下载 pod 的秘密

  • 挂载卷

  • 通过容器运行时接口(CRI)运行 pod 容器

  • 报告节点和每个 pod 的状态

  • 探测容器的存活状态

kube 代理

kube 代理负责节点的网络方面。它作为服务的本地前端运行,并且可以转发 TCP 和 UDP 数据包。它通过 DNS 或环境变量发现服务的 IP 地址。

容器运行时

Kubernetes 最终运行容器,即使它们是组织在 pod 中的。Kubernetes 支持不同的容器运行时。最初,只支持 Docker。现在,Kubernetes 通过基于 gRPC 的CRI接口运行容器。

每个实现 CRI 的容器运行时都可以在由kubelet控制的节点上使用,如前图所示。

Kubectl

Kubectl是一个你应该非常熟悉的工具。它是你的 Kubernetes 集群的命令行接口CLI)。我们将在整本书中广泛使用 kubectl 来管理和操作 Kubernetes。以下是 kubectl 在您的指尖上提供的功能的简短列表:

  • 集群管理

  • 部署

  • 故障排除和调试

  • 资源管理(Kubernetes 对象)

  • 配置和元数据

只需键入kubectl即可获得所有命令的完整列表,kubectl <command> --help以获取有关特定命令的更详细信息。

Kubernetes 和微服务-完美匹配

Kubernetes 是一个具有惊人能力和美妙生态系统的平台。它如何帮助您的系统?正如您将看到的,Kubernetes 和微服务之间有非常好的对齐。Kubernetes 的构建块,如命名空间、pod、部署和服务,直接映射到重要的微服务概念和敏捷软件开发生命周期SDLC)。让我们深入研究。

打包和部署微服务

当您使用基于微服务的架构时,您将拥有大量的微服务。这些微服务通常可以独立开发和部署。打包机制只是容器。您开发的每个微服务都将有一个 Dockerfile。生成的镜像代表该微服务的部署单元。在 Kubernetes 中,您的微服务镜像将在一个 pod 中运行(可能与其他容器一起)。但是,运行在节点上的隔离 pod 并不是非常有弹性。如果 pod 的容器崩溃,节点上的 kubelet 将重新启动 pod 的容器,但是如果节点本身发生了什么事情,pod 就消失了。Kubernetes 具有构建在 pod 上的抽象和资源。

ReplicaSets 是具有一定数量副本的 pod 集。当你创建一个 ReplicaSet 时,Kubernetes 将确保你指定的正确数量的 pod 始终在集群中运行。部署资源进一步提供了一个与你考虑和思考微服务方式完全一致的抽象。当你准备好一个微服务的新版本时,你会想要部署它。这是一个 Kubernetes 部署清单:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx
  labels:
    app: nginx
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
      - name: nginx
        image: nginx:1.15.4
        ports:
        - containerPort: 80

该文件可以在 github.com/the-gigi/hands-on-microservices-with-kubernetes-code/blob/master/ch1/nginx-deployment.yaml. 找到

这是一个 YAML 文件(yaml.org/),其中包含一些对所有 Kubernetes 资源通用的字段,以及一些特定于部署的字段。让我们一一分解。你在这里学到的几乎所有内容都适用于其他资源:

  • apiVersion 字段标记了 Kubernetes 资源的版本。Kubernetes API 服务器的特定版本(例如 V1.13.0)可以与不同资源的不同版本一起工作。资源版本有两个部分:API 组(在本例中为 apps)和版本号(v1)。版本号可能包括 alphabeta 标识:
apiVersion: apps/v1
  • kind 字段指定了我们正在处理的资源或 API 对象是什么。在本章和以后,你将遇到许多种类的资源:
kind: Deployment
  • metadata 部分包含了资源的名称(nginx)和一组标签,这些标签只是键值对字符串。名称用于指代特定的资源。标签允许对共享相同标签的一组资源进行操作。标签非常有用和灵活。在这种情况下,只有一个标签(app: nginx):
metadata:
  name: nginx
  labels:
    app: nginx
  • 接下来,我们有一个 spec 字段。这是一个 ReplicaSet spec。你可以直接创建一个 ReplicaSet,但它将是静态的。部署的整个目的是管理其副本集。ReplicaSet spec 中包含什么?显然,它包含了 replicas 的数量(3)。它有一个带有一组 matchLabels(也是 app: nginx)的选择器,并且有一个 pod 模板。ReplicaSet 将管理具有与 matchLabels 匹配的标签的 pod:
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nginx
  template:
     ...
  • 让我们看一下 pod 模板。模板有两个部分:metadataspecmetadata是您指定标签的地方。spec描述了 pod 中的containers。一个 pod 中可能有一个或多个容器。在这种情况下,只有一个容器。容器的关键字段是镜像(通常是 Docker 镜像),其中打包了您的微服务。这是我们想要运行的代码。还有一个名称(nginx)和一组端口:
metadata:
  labels:
    app: nginx
spec:
  containers:
  - name: nginx
    image: nginx:1.15.4
    ports:
    - containerPort: 80

还有更多可选字段。如果您想深入了解,请查看部署资源的 API 参考kubernetes.io/docs/reference/generated/kubernetes-api/v1.13/#deployment-v1-apps

暴露和发现微服务

我们使用部署部署了我们的微服务。现在,我们需要暴露它,以便其他集群中的服务可以使用它,并且可能还可以使其在集群外可见。Kubernetes 提供了Service资源来实现这一目的。Kubernetes 服务由标签标识的 pod 支持:

apiVersion: v1
kind: Service
metadata:
  name: nginx
  labels:
    app: nginx
spec:
  ports:
  - port: 80
    protocol: TCP
  selector:
    app: nginx

服务在集群内部使用 DNS 或环境变量相互发现。这是默认行为。但是,如果您想使服务对外部可访问,通常会设置一个入口对象或负载均衡器。我们将在以后详细探讨这个主题。

保护微服务

Kubernetes 是为运行大规模关键系统而设计的,安全性是至关重要的。微服务通常比单片系统更具挑战性,因为在许多边界上存在大量内部通信。此外,微服务鼓励敏捷开发,这导致系统不断变化。没有稳定的状态可以一次性确保安全。您必须不断调整系统的安全性以适应变化。Kubernetes 预先配备了几个概念和机制,用于安全开发、部署和运行您的微服务。您仍然需要采用最佳实践,例如最小权限原则、深度安全和最小化影响范围。以下是 Kubernetes 的一些安全功能。

命名空间

命名空间可以让您将集群的不同部分相互隔离。您可以创建任意数量的命名空间,并将许多资源和操作范围限定在其命名空间内,包括限制和配额。在命名空间中运行的 pod 只能直接访问其自己的命名空间。要访问其他命名空间,它们必须通过公共 API 进行。

服务账户

服务账户为您的微服务提供身份。每个服务账户都将具有与其账户关联的特定特权和访问权限。服务账户非常简单:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: custom-service-account

您可以将服务账户与 pod 关联(例如,在部署的 pod spec中),并且在 pod 内部运行的微服务将具有该身份以及与该账户关联的所有特权和限制。如果不分配服务账户,则 pod 将获得其命名空间的默认服务账户。每个服务账户都与用于对其进行身份验证的秘密相关联。

秘密

Kubernetes 为所有微服务提供了秘密管理功能。秘密可以在 etcd 上(自 Kubernetes 1.7 起)加密存储,并且始终在传输过程中进行加密(通过 HTTPS)。秘密是按命名空间管理的。秘密在 pod 中作为文件(秘密卷)或环境变量挂载。有多种方法可以创建秘密。秘密可以包含两个映射:datastringData。数据映射中的值的类型可以是任意的,但必须是 base64 编码的。例如,请参考以下内容:

apiVersion: v1
kind: Secret
metadata:
  name: custom-secret
type: Opaque
data:
  username: YWRtaW4=
  password: MWYyZDFlMmU2N2Rm

以下是 pod 如何将秘密加载为卷:

apiVersion: v1
kind: Pod
metadata:
  name: db
spec:
  containers:
  - name: mypod
    image: postgres
    volumeMounts:
    - name: db_creds
      mountPath: "/etc/db_creds"
      readOnly: true
  volumes:
  - name: foo
    secret:
      secretName: custom-secret

最终结果是,由 Kubernetes 在 pod 外部管理的 DB 凭据秘密显示为 pod 内部的常规文件,可通过路径/etc/db_creds访问。

安全通信

Kubernetes 利用客户端证书来完全验证任何外部通信的双方身份(例如 kubectl)。所有从外部到 Kubernetes API 的通信都应该是通过 HTTP 进行的。API 服务器与节点上的 kubelet 之间的内部集群通信也是通过 HTTPS 进行的(kubelet 端点)。但是,默认情况下不使用客户端证书(您可以启用它)。

API 服务器与节点、pod 和服务之间的通信默认情况下是通过 HTTP 进行的,并且没有经过身份验证。您可以将它们升级为 HTTPS,但请注意客户端证书会被检查,因此不要在公共网络上运行工作节点。

网络策略

在分布式系统中,除了保护每个容器、pod 和节点之外,还至关重要的是控制网络上的通信。Kubernetes 支持网络策略,这使您可以完全灵活地定义和塑造整个集群中的流量和访问。

对微服务进行身份验证和授权

身份验证和授权也与安全性相关,通过限制对受信任用户和 Kubernetes 的有限方面的访问来实现。组织有多种方法来对其用户进行身份验证。Kubernetes 支持许多常见的身份验证方案,例如 X.509 证书和 HTTP 基本身份验证(不太安全),以及通过 webhook 的外部身份验证服务器,这样可以对身份验证过程进行最终控制。身份验证过程只是将请求的凭据与身份(原始用户或冒充用户)进行匹配。授权过程控制着用户被允许做什么。进入 RBAC。

基于角色的访问控制

基于角色的访问控制RBAC)并非必需!您可以使用 Kubernetes 中的其他机制执行授权。但这是最佳实践。RBAC 基于两个概念:角色和绑定。角色是对资源的权限集,定义为规则。有两种类型的角色:Role,适用于单个命名空间,以及ClusterRole,适用于集群中的所有命名空间。

这是默认命名空间中的一个角色,允许获取、监视和列出所有的 pod。每个角色都有三个组成部分:API 组、资源和动词:

kind: Role
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  namespace: default
  name: pod-reader
rules:
- apiGroups: [""] # "" indicates the core API group
  resources: ["pods"]
  verbs: ["get", "watch", "list"]

集群角色非常相似,只是没有命名空间字段,因为它们适用于所有命名空间。

绑定是将一组主体(用户、用户组或服务帐户)与角色关联起来。有两种类型的绑定,RoleBindingClusterRoleBinding,它们对应于RoleClusterRole

kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: pod-reader
  namespace: default
subjects:
- kind: User
  name: gigi # Name is case sensitive
  apiGroup: rbac.authorization.k8s.io
roleRef:
  kind: Role # must be Role or ClusterRole
  name: pod-reader # must match the name of the Role or ClusterRole you bind to
  apiGroup: rbac.authorization.k8s.io

有趣的是,您可以将ClusterRole绑定到单个命名空间中的主体。这对于定义应在多个命名空间中使用的角色非常方便,一次作为集群角色,然后将它们绑定到特定命名空间中的特定主体。

集群角色绑定类似,但必须绑定集群角色,并始终适用于整个集群。

请注意,RBAC 用于授予对 Kubernetes 资源的访问权限。它可以调节对您的服务端点的访问权限,但您可能仍然需要微服务中的细粒度授权。

升级微服务

部署和保护微服务只是开始。随着您的系统的发展和演变,您将需要升级您的微服务。关于如何进行这些操作有许多重要的考虑,我们稍后将讨论(版本控制、滚动更新、蓝绿部署和金丝雀发布)。Kubernetes 直接支持许多这些概念,并且在其之上构建的生态系统提供了许多不同的风格和有见解的解决方案。

目标通常是零停机时间和安全回滚,如果出现问题。Kubernetes 部署提供了原语,例如更新部署、暂停部署和回滚部署。具体的工作流程是建立在这些坚实的基础之上的。

升级服务的机制通常涉及将其镜像升级到新版本,有时还需要对其支持资源和访问进行更改:卷、角色、配额、限制等。

微服务的扩展

使用 Kubernetes 扩展微服务有两个方面。第一个方面是扩展支持特定微服务的 pod 数量。第二个方面是集群的总容量。您可以通过更新部署的副本数量来显式地扩展微服务,但这需要您不断保持警惕。对于长时间内处理请求量有很大变化的服务(例如,工作时间与非工作时间或工作日与周末),这可能需要大量的工作。Kubernetes 提供了基于 CPU、内存或自定义指标的水平 pod 自动扩展,可以自动地扩展您的服务。

以下是如何扩展我们当前固定为三个副本的nginx部署,使其在所有实例的平均 CPU 使用率之间在25之间变化:

apiVersion: autoscaling/v1
kind: HorizontalPodAutoscaler
metadata:
    name: nginx
    namespace: default
spec:
    maxReplicas: 5
    minReplicas: 2
    targetCPUUtilizationPercentage: 90
    scaleTargetRef:
      apiVersion: v1
      kind: Deployment
      name: nginx

结果是 Kubernetes 将监视属于nginx部署的 pod 的 CPU 利用率。当一段时间内(默认为 5 分钟)的平均 CPU 超过 90%时,它将添加更多副本,直到最多为 5 个,或者直到利用率低于 90%。HPA 也可以缩小规模,但即使 CPU 利用率为零,它也将始终保持至少两个副本。

监控微服务

你的微服务部署并在 Kubernetes 上运行。你可以在需要时更新微服务的版本。Kubernetes 会自动处理修复和扩展。然而,你仍然需要监视你的系统并跟踪错误和性能。这对于解决问题很重要,但也对于通知你潜在的改进、优化和成本削减很重要。

有几类相关信息是重要的,你应该监控:

  • 第三方日志

  • 应用程序日志

  • 应用程序错误

  • Kubernetes 事件

  • 指标

当考虑由多个微服务和多个支持组件组成的系统时,日志的数量将是可观的。解决方案是中央日志记录,所有日志都会发送到一个地方,你可以随意切割和分析。当然可以记录错误,但通常有用的是报告带有额外元数据的错误,比如堆栈跟踪,并在专用环境中审查它们(例如 sentry 或 rollbar)。指标对于检测性能和系统健康问题或随时间变化的趋势是有用的。

Kubernetes 提供了几种机制和抽象来监视你的微服务。该生态系统还提供了许多有用的项目。

日志记录

有几种实现与 Kubernetes 的中央日志记录的方法:

  • 在每个节点上运行一个日志代理

  • 向每个应用程序 pod 注入一个日志边车容器

  • 让你的应用程序直接发送日志到中央日志服务

每种方法都有其利弊。但是,主要的是 Kubernetes 支持所有方法,并使容器和 pod 日志可供使用。

参考kubernetes.io/docs/concepts/cluster-administration/logging/#cluster-level-logging-architectures进行深入讨论。

指标

Kubernetes 附带了 cAdvisor(github.com/google/cadvisor),这是一个用于收集容器指标的工具,集成到 kubelet 二进制文件中。Kubernetes 以前提供了一个名为heapster的度量服务器,需要额外的后端和 UI。但是,如今,最佳的度量服务器是开源项目 Prometheus。如果你在 Google 的 GKE 上运行 Kubernetes,那么 Google Cloud Monitoring 是一个不需要在你的集群中安装额外组件的好选择。其他云提供商也与他们的监控解决方案集成(例如,EKS 上的 CloudWatch)。

创建本地集群

Kubernetes 作为部署平台的一个优势是,你可以创建一个本地集群,并且只需相对较少的努力,就可以拥有一个非常接近生产环境的真实环境。主要好处是开发人员可以在本地测试他们的微服务,并与集群中的其他服务进行协作。当你的系统由许多微服务组成时,更重要的测试通常是集成测试,甚至是配置和基础设施测试,而不是单元测试。Kubernetes 使这种测试变得更容易,需要更少脆弱的模拟。

在这一部分,你将安装一个本地 Kubernetes 集群和一些额外的项目,然后使用宝贵的 kubectl 命令行工具来探索它。

安装 Minikube

Minikube 是一个可以在任何地方安装的单节点 Kubernetes 集群。我在这里使用的是 macOS,但过去我也成功地在 Windows 上使用过。在安装 Minikube 本身之前,你必须安装一个 hypervisor。我更喜欢 HyperKit:

$ curl -LO https://storage.googleapis.com/minikube/releases/latest/docker-machine-driver-hyperkit \
 && chmod +x docker-machine-driver-hyperkit \
 && sudo mv docker-machine-driver-hyperkit /usr/local/bin/ \
 && sudo chown root:wheel /usr/local/bin/docker-machine-driver-hyperkit \
 && sudo chmod u+s /usr/local/bin/docker-machine-driver-hyperkit

但是,我偶尔会遇到 HyperKit 的问题。如果你无法解决这些问题,我建议使用 VirtualBox 作为 hypervisor。运行以下命令通过 Homebrew 安装 VirtualBox:

$ brew cask install virtualbox

现在,你可以安装 Minikube 本身。再次使用 Homebrew 是最好的方法:

brew cask install minikube

如果你不是在 macOS 上,请按照官方说明进行操作:kubernetes.io/docs/tasks/tools/install-minikube/

在使用 HyperKit 启动 Minikube 之前,你必须关闭任何 VPN。在 Minikube 启动后,你可以重新启动 VPN。

Minikube 支持多个版本的 Kubernetes。目前,默认版本是 1.10.0,但 1.13.0 已经发布并得到支持,所以让我们使用这个版本:

$ minikube start --vm-driver=hyperkit --kubernetes-version=v1.13.0

如果您使用 VirtualBox 作为您的 hypervisor,您不需要指定--vm-driver

$ minikube start --kubernetes-version=v1.13.0

您应该看到以下内容:

$ minikube start --kubernetes-version=v1.13.0
Starting local Kubernetes v1.13.0 cluster...
Starting VM...
Downloading Minikube ISO
 178.88 MB / 178.88 MB [============================================] 100.00% 0s
Getting VM IP address...
E0111 07:47:46.013804   18969 start.go:211] Error parsing version semver:  Version string empty
Moving files into cluster...
Downloading kubeadm v1.13.0
Downloading kubelet v1.13.0
Finished Downloading kubeadm v1.13.0
Finished Downloading kubelet v1.13.0
Setting up certs...
Connecting to cluster...
Setting up kubeconfig...
Stopping extra container runtimes...
Starting cluster components...
Verifying kubelet health ...
Verifying apiserver health ...Kubectl is now configured to use the cluster.
Loading cached images from config file.

Everything looks great. Please enjoy minikube!

如果这是您第一次启动 Minikube 集群,Minikube 将自动下载 Minikube VM(178.88 MB)。

此时,您的 Minikube 集群已准备就绪。

Minikube 故障排除

如果遇到问题(例如,如果您忘记关闭 VPN),请尝试删除 Minikube 安装并使用详细日志重新启动:

$ minikube delete
$ rm -rf ~/.minikube
$ minikube start --vm-driver=hyperkit --kubernetes-version=v1.13.0 --logtostderr --v=3

如果您的 Minikube 安装卡住了(可能在等待 SSH),您可能需要重新启动以解除卡住。如果这样做没有帮助,请尝试以下操作:

sudo mv /var/db/dhcpd_leases /var/db/dhcpd_leases.old
sudo touch /var/db/dhcpd_leases

然后,再次重启。

验证您的集群

如果一切正常,您可以检查您的 Minikube 版本:

$ minikube version
minikube version: v0.31.0

Minikube 还有许多其他有用的命令。只需输入minikube即可查看命令和标志列表。

玩转您的集群

Minikube 正在运行,所以让我们玩得开心。在本节中,您的 kubectl 将为您提供良好的服务。让我们从检查我们的节点开始:

$ kubectl get nodes
NAME       STATUS    ROLES     AGE       VERSION
minikube   Ready     master    4m        v1.13.0

您的集群已经有一些正在运行的 pod 和服务。原来 Kubernetes 正在使用自己的服务和 pod。但是,这些 pod 和服务在命名空间中运行。以下是所有的命名空间:

$ kubectl get ns
NAME          STATUS    AGE
default       Active    18m
kube-public   Active    18m
kube-system   Active    18m

要查看所有命名空间中的所有服务,可以使用--all-namespaces标志:

$ kubectl get svc --all-namespaces
NAMESPACE          NAME  TYPE   CLUSTER-IP  EXTERNAL-IP   PORT(S)   AGE
default  kubernetes   ClusterIP   10.96.0.1  <none>   443/TCP       19m
kube-system kube-dns  ClusterIP   10.96.0.10 <none>   53/UDP,53/TCP 19m
kube-system kubernetes-dashboard  ClusterIP 10.111.39.46 <none>        80/TCP          18m

Kubernetes API 服务器本身作为默认命名空间中的服务运行,然后我们有kube-dnskubernetes-dashboardkube-system命名空间中运行。

要探索仪表板,您可以运行专用的 Minikube 命令minikube dashboard。您还可以使用kubectl,它更通用,可以在任何 Kubernetes 集群上运行:

$ kubectl port-forward deployment/kubernetes-dashboard 9090

然后,浏览http://localhost:9090,您将看到以下仪表板:

安装 Helm

Helm 是 Kubernetes 包管理器。它不随 Kubernetes 一起提供,因此您必须安装它。Helm 有两个组件:一个名为tiller的服务器端组件,以及一个名为helm的 CLI。

首先,让我们使用 Homebrew 在本地安装helm

$ brew install kubernetes-helm

然后,正确初始化服务器和客户端类型:

$ helm init
$HELM_HOME has been configured at /Users/gigi.sayfan/.helm.

Tiller (the Helm server-side component) has been installed into your Kubernetes Cluster.

Please note: by default, Tiller is deployed with an insecure 'allow unauthenticated users' policy.
To prevent this, run `helm init` with the --tiller-tls-verify flag.
For more information on securing your installation see: https://docs.helm.sh/using_helm/#securing-your-helm-installation
Happy Helming!

有了 Helm,您可以轻松在 Kubernetes 集群中安装各种好东西。目前在稳定的图表存储库中有275个字符(Helm 术语表示一个包):

$ helm search | wc -l
275

例如,查看所有标记为db类型的发布:

$ helm search db
NAME                               CHART VERSION  APP VERSION    DESCRIPTION
stable/cockroachdb                 2.0.6          2.1.1          CockroachDB is a scalable, survivable, strongly-consisten...
stable/hlf-couchdb                 1.0.5          0.4.9          CouchDB instance for Hyperledger Fabric (these charts are...
stable/influxdb                    1.0.0          1.7            Scalable datastore for metrics, events, and real-time ana...
stable/kubedb                      0.1.3          0.8.0-beta.2   DEPRECATED KubeDB by AppsCode - Making running production...
stable/mariadb                     5.2.3          10.1.37        Fast, reliable, scalable, and easy to use open-source rel...
stable/mongodb                     4.9.1          4.0.3          NoSQL document-oriented database that stores JSON-like do...
stable/mongodb-replicaset          3.8.0          3.6            NoSQL document-oriented database that stores JSON-like do...
stable/percona-xtradb-cluster      0.6.0          5.7.19         free, fully compatible, enhanced, open source drop-in rep...
stable/prometheus-couchdb-exporter 0.1.0          1.0            A Helm chart to export the metrics from couchdb in Promet...
stable/rethinkdb                   0.2.0          0.1.0          The open-source database for the realtime web
jenkins-x/cb-app-slack             0.0.1                         A Slack App for CloudBees Core
stable/kapacitor                   1.1.0          1.5.1          InfluxDB's native data processing engine. It can process ...
stable/lamp                        0.1.5          5.7            Modular and transparent LAMP stack chart supporting PHP-F...
stable/postgresql                  2.7.6          10.6.0         Chart for PostgreSQL, an object-relational database manag...
stable/phpmyadmin                  2.0.0          4.8.3          phpMyAdmin is an mysql administration frontend
stable/unifi                       0.2.1          5.9.29         Ubiquiti Network's Unifi Controller

我们将在整本书中大量使用 Helm。

摘要

在本章中,您对 Kubernetes 进行了一个快速的介绍,并了解了它与微服务的契合程度。Kubernetes 的可扩展架构赋予了大型企业组织、初创公司和开源组织一个强大的社区,使它们能够合作并围绕 Kubernetes 创建生态系统,从而增加其益处并确保其持久性。Kubernetes 内置的概念和抽象非常适合基于微服务的系统。它们支持软件开发生命周期的每个阶段,从开发、测试、部署,一直到监控和故障排除。Minikube 项目让每个开发人员都可以运行一个本地的 Kubernetes 集群,这对于在类似于生产环境的本地环境中进行 Kubernetes 实验和测试非常有用。Helm 项目是 Kubernetes 的一个很棒的补充,作为事实上的软件包管理解决方案提供了巨大的价值。在下一章中,我们将深入了解微服务的世界,并了解它们为何是开发复杂且快速移动的分布式系统的最佳方法。

进一步阅读

第二章:开始使用微服务

在上一章中,您了解了 Kubernetes 的全部内容,以及它如何适合作为开发、部署和管理微服务的平台,甚至还在本地 Kubernetes 集群中玩了一点。在本章中,我们将讨论微服务的一般情况,以及为什么它们是构建复杂系统的最佳方式。我们还将讨论解决基于微服务的系统中常见问题的各种方面、模式和方法,以及它们与其他常见架构(如单体和大型服务)的比较。

我们将在本章中涵盖大量材料:

  • 在小规模编程中-少即是多

  • 使您的微服务自主

  • 使用接口和契约

  • 通过 API 公开您的服务

  • 使用客户端库

  • 管理依赖关系

  • 编排微服务

  • 利用所有权

  • 理解康威定律

  • 跨多个服务进行故障排除

  • 利用共享服务库

  • 选择源代码控制策略

  • 创建数据策略

技术要求

在本章中,您将看到一些使用 Go 的代码示例。我建议您安装 Go 并尝试自己构建和运行代码示例。

在 macOS 上使用 Homebrew 安装 Go

在 macOS 上,我建议使用 Homebrew:

$ brew install go

接下来,请确保go命令可用:

$ ls -la `which go`
lrwxr-xr-x  1 gigi.sayfan  admin  26 Nov 17 09:03 /usr/local/bin/go -> ../Cellar/go/1.11.2/bin/go

要查看所有选项,只需输入go。此外,请确保在您的.bashrc文件中定义GOPATH并将$GOPATH/bin添加到您的路径中。

Go 带有 Go CLI,提供了许多功能,但您可能希望安装其他工具。查看awesome-go.com/

在其他平台上安装 Go

在其他平台上,请按照官方说明操作:golang.org/doc/install.

代码

您可以在此处找到本章的代码:github.com/PacktPublishing/Hands-On-Microservices-with-Kubernetes/tree/master/Chapter02

在小规模编程中-少即是多

想想你学习编程的时候。你写了一些接受简单输入、进行一些处理并产生一些输出的小程序。生活很美好。你可以把整个程序都记在脑子里。

您理解了每一行代码。调试和故障排除都很容易。例如,考虑一个用于在摄氏度和华氏度之间转换温度的程序:

package main

import (
        "fmt"
        "os"
        "strconv"
)

func celsius2fahrenheit(t float64) float64 {
        return 9.0/5.0*t + 32
}

func fahrenheit2celsius(t float64) float64 {
        return (t - 32) * 5.0 / 9.0
}

func usage() {
      fmt.Println("Usage: temperature_converter <mode> <temperature>")
      fmt.Println()
      fmt.Println("This program converts temperatures between Celsius and Fahrenheit")
      fmt.Println("'mode' is either 'c2f' or 'f2c'")
      fmt.Println("'temperature' is a floating point number to be converted according to mode")
     os.Exit(1)
}

func main() {
         if len(os.Args) != 3 {
                usage()
          }
          mode := os.Args[1]
          if mode != "f2c" && mode != "c2f" {
                  usage()
          }

          t, err := strconv.ParseFloat(os.Args[2], 64)
          if err != nil {
                  usage()
           }

          var converted float64
           if mode == "f2c" {
                  converted = fahrenheit2celsius(t)
           } else {
                   converted = celsius2fahrenheit(t)
           }
           fmt.Println(converted)
}

这个程序非常简单。它很好地验证了输入,并在出现问题时显示了使用信息。程序实际执行的计算只有两行代码,用于转换温度,但代码长度为 45 行。甚至没有任何注释。然而,这 45 行代码非常易读且易于测试。没有第三方依赖(只有 Go 标准库)。没有 IO(文件、数据库、网络)。不需要认证或授权。不需要限制调用速率。没有日志记录,没有指标收集。没有版本控制,健康检查或配置。没有在多个环境中部署和没有在生产中进行监控。

现在,考虑将这个简单的程序集成到一个大型企业系统中。您将不得不考虑其中许多方面。系统的其他部分将开始使用温度转换功能。突然之间,最简单的操作可能会产生连锁影响。系统的其他部分的更改可能会影响温度转换器:

这种复杂性的增加是自然的。大型企业系统有许多要求。微服务的承诺是通过遵循适当的架构指南和已建立的模式,可以将额外的复杂性整齐地打包并用于许多小型微服务,这些微服务共同完成系统目标。理想情况下,服务开发人员大部分时间都可以不受包围系统的影响。然而,提供适当程度的隔离并且仍然允许在整个系统的上下文中进行测试和调试需要付出很大的努力。

使您的微服务自主

对抗复杂性的最佳方法之一是使您的微服务自主。自主服务是一种不依赖于系统中其他服务或第三方服务的服务。自主服务管理自己的状态,并且在很大程度上可以不了解系统的其余部分。

我喜欢将自主微服务看作类似于不可变函数。自主服务永远不会改变系统中其他组件的状态。这种服务的好处是,无论系统的其余部分如何发展,以及它们如何被其他服务使用,它们的复杂性都保持不变。

使用接口和契约

接口是软件工程师可以使用的最好工具之一。一旦将某物公开为接口,就可以自由更改其背后的实现。接口是在单个进程中使用的构造。它们对于测试与其他组件的交互非常有用,在基于微服务的系统中这种交互非常丰富。以下是我们示例应用程序的一个接口:

type UserManager interface {
   Register(user User) error
   Login(username string, authToken string) (session string, err error)
   Logout(username string, session string) error
}

UserManager接口定义了一些方法,它们的输入和输出。但是,它没有指定语义。例如,如果对已经登录的用户调用Login()方法会发生什么?这是一个错误吗?先前的会话是否终止并创建一个新会话?它是否返回现有会话而不出现错误(幂等方法)?这些问题由合同回答。合同很难完全指定,Go 不提供对合同的任何支持。但是,合同很重要,它们总是存在的,即使只是隐含地存在。

一些语言不支持接口作为语言的第一类语法结构。但是,实现相同效果非常容易。动态类型的语言,如 Python,Ruby 和 JavaScript,允许您传递任何满足调用者使用的属性和方法集的对象。静态语言,如 C 和 C++,通过函数指针集(C)或仅具有纯虚函数的结构(C++)来实现。

通过 API 公开您的服务

微服务之间有时会通过网络相互交互,有时还会与外部世界进行交互。服务通过 API 公开其功能。我喜欢将 API 想象为通过网络的接口。编程语言接口使用其所编写的语言的语法(例如,Go 的接口类型)。现代网络 API 也使用一些高级表示。基础是 UDP 和 TCP。但是,微服务通常会通过 Web 传输公开其功能,例如 HTTP(REST,GraphQL,SOAP),HTTP/2(gRPC),或者在某些情况下是 WebSockets。一些服务可能模仿其他的线路协议,例如 memcached,但这在特殊情况下很有用。在 2019 年,没有理由直接在 TCP/UDP 上构建自定义协议或使用专有和特定于语言的协议。像 Java RMI,.NET remoting,DCOM 和 CORBA 这样的方法最好留在过去,除非您需要支持一些遗留代码库。

有两种微服务的类别,如下所示:

  • 内部微服务只能被通常在相同网络/集群中运行的其他微服务访问,这些服务可以暴露更专业的 API,因为你可以控制这两个服务及其客户端(其他服务)。

  • 外部服务对外开放,并且通常需要从 Web 浏览器或使用多种语言的客户端进行消费。

使用标准网络 API 而不是标准语言无关的传输的好处在于它实现了微服务的多语言承诺。每个服务可以用自己的编程语言实现(例如,一个服务用 Go,另一个用 Python),它们甚至可以在以后完全不同的语言中迁移(比如 Rust),而不会造成中断,因为所有这些服务都通过网络 API 进行交互。我们将在后面讨论多语言方法及其权衡。

使用客户端库

接口非常方便。你可以在你的编程语言环境中操作,使用本地数据类型调用方法。使用网络 API 是不同的。你需要根据传输方式使用网络库。你需要序列化你的有效负载和响应,并处理网络错误、断开连接和超时。客户端库模式封装了远程服务和所有这些决策,并为你提供一个标准接口,作为服务的客户端,你只需调用它。客户端库在幕后会处理调用网络 API 所涉及的所有仪式。泄漏抽象的法则(www.joelonsoftware.com/2002/11/11/the-law-of-leaky-abstractions/)说你实际上无法隐藏网络。然而,你可以很有效地隐藏它,使消费服务不受影响,并使用关于超时、重试和缓存的策略进行正确配置。

gRPC 最大的卖点之一是它为你生成了一个客户端库。

管理依赖关系

现代系统有很多依赖关系。有效地管理它们是软件开发生命周期SDLC)的重要组成部分。有两种依赖关系:

  • 库/包(链接到运行服务进程)

  • 远程服务(可通过网络访问)

这些依赖关系中的每一个都可以是内部的或第三方的。您通过语言的包管理系统来管理库或软件包。Go 很长一段时间没有官方的包管理系统,出现了几种解决方案,例如 Glide 和 Dep。如今(Go 1.12),Go 模块是官方解决方案。

您通过发现端点和跟踪 API 版本来管理远程服务。内部依赖和第三方依赖之间的区别在于变化的速度。内部依赖将更快地发生变化。使用微服务时,您将依赖于其他微服务。版本控制和跟踪 API 背后的合同成为开发中非常重要的方面。

协调微服务

当将单体系统与基于微服务的系统进行比较时,有一件事是清楚的。一切都更多。单个微服务更简单,更容易理解,修改和排除单个服务的问题。但是,理解整个系统,跨多个服务进行更改和调试问题更具挑战性。还会在单独的微服务之间通过网络发生更多的交互,而在单体系统中,这些交互将在同一进程中发生。这意味着要从微服务中受益,您需要一种纪律严明的方法,需要应用最佳实践,并且需要有您可以使用的良好工具。

统一性与灵活性的权衡

假设您有一百个微服务,但它们都非常小且非常相似。它们都使用相同的数据存储(例如,相同类型的关系数据库)。它们都以相同的方式配置(例如,配置文件)。它们都将错误和日志报告给集中日志服务器。它们都使用相同的编程语言实现(例如,Go)。通常,系统将处理几个用例。每个用例将涉及这一百个微服务的一些子集。还将有一些通用微服务在大多数用例中使用(例如,授权服务)。然后,理解整个系统可能并不那么困难,只要有一些良好的文档。您可以单独查看每个用例,并且当您扩展系统并添加更多用例,并且可能增长到一千个微服务时,复杂性仍然受到限制。

一个很好的类比是文件和目录。假设您按流派、艺术家和歌曲组织您的音乐。最初,您有三种流派,20 位艺术家和 200 首歌曲。然后,您扩展了一切,现在有 10 种流派,50 位艺术家和 3,000 首歌曲。组织仍然是相同的旧的流派/艺术家/歌曲的层次结构。当您扩展到一定程度时,规模本身可能会带来新的问题。例如,对于音乐,当您的音乐太多,无法放入硬盘时,您需要一种质量上不同的解决方案(例如,将其保存在云中)。对于微服务也是如此,但分而治之的方法效果很好。如果您达到互联网规模——亚马逊、谷歌、Facebook——那么,是的,您需要更为复杂的解决方案来解决每个方面的问题。

但是,使用统一的微服务,你会牺牲许多好处。例如,团队和开发人员可能被迫使用不适合任务的编程语言,或者他们将不得不遵守严格的日志记录和错误报告的操作标准,即使是针对小型非关键的内部服务。

您需要了解统一与多样化微服务的利弊。这是一个从完全统一的微服务到任何事物都可以的范围,每个微服务都是独特的雪花的光谱。您的责任是在这个光谱上找到系统的最佳位置。

利用所有权

由于微服务很小。一个开发人员可以拥有整个微服务并完全了解它。其他开发人员也可能熟悉它,但即使只有一个开发人员熟悉一个服务,新开发人员接手应该也相对简单和无痛,因为范围是如此有限且理想情况下相似。

独占所有权可以非常强大。开发人员需要通过服务 API 与其他开发人员和团队进行沟通,但可以在实现上非常快速地迭代。您可能仍希望团队中的其他开发人员审查内部设计和实现,但即使在极端情况下,所有者完全独立工作且没有监督,潜在的损害也是有限的,因为每个微服务的范围都很小,并且通过明确定义的 API 与系统的其余部分进行交互。

生产力的差异可能令人瞠目结舌。

理解康威定律

康威定律的定义如下:

设计系统的组织受限于产生与这些组织的沟通结构相同的设计。

这意味着系统的结构将反映构建它的团队的结构。埃里克·雷蒙德的一个著名变体是:

“如果有四个组建编译器的团队,你将得到一个 4 通道编译器。”

这非常有洞察力,我个人在许多不同的组织中一再见证了这一点。这与基于微服务的系统非常相关。有了许多小的微服务,你不需要为每个微服务专门的团队。会有一些更高级别的微服务组合在一起,以产生系统的某些方面。现在,问题是如何考虑高层结构。有三个主要选项:

  • 垂直

  • 水平

  • 矩阵

在这方面,微服务可能非常重要。作为小型自治组件,它们支持所有结构。但更重要的是,当组织需要从一种方法转变为另一种方法时。通常的轨迹是:水平|垂直|矩阵。

如果软件遵循微服务架构,组织可以以更少的摩擦进行这些转变。这甚至可能成为一个决定性因素。即使不遵循微服务架构的组织决定继续使用不合适的结构,因为打破单体的风险和努力太大。

垂直

垂直方法将系统的功能切片,包括多个微服务,并且一个团队完全负责该功能,从设计到实施,再到部署和维护。团队作为孤立体运作,它们之间的沟通通常是有限和正式的。这种方法有利于微服务的一些方面,比如以下内容:

  • 多语言

  • 灵活性

  • 独立移动的部分

  • 端到端的所有权

  • 垂直切片内部的合同不太正式

  • 易于扩展到更多的垂直切片(只需组建另一个团队)

  • 跨垂直切片应用变更很困难,特别是随着垂直切片数量的增加。

这种方法在非常大的组织中很常见,因为它具有可扩展性的优势。这也需要大量的创造力和努力来在全面上取得改进。筒仓之间会有工作重复。追求完全重用和协调是徒劳的。垂直方法的诀窍在于找到甜蜜点,将通用功能打包成一种可以被多个筒仓使用的方式,但不需要明确的协调。

水平

水平方法将系统视为分层架构。团队结构沿着这些层组织。可能会有一个前端组、后端组和一个 DevOps 组。每个组对他们层面的所有方面负责。垂直功能是通过所有层的不同组之间的协作来实现的。这种方法更适合产品数量较少的较小组织(有时只有一个)。

水平方法的好处在于组织可以在整个水平层面建立专业知识并分享知识。通常,组织从水平组织开始,随着它们的增长,可能扩展到更多的产品,或者可能扩展到多个地理位置,它们会分成更垂直的结构。在每个筒仓内,结构通常是水平的。

矩阵

矩阵组织是最复杂的。你有你的垂直筒仓,但组织认识到筒仓之间的重复和变化浪费资源,也使得在筒仓之间转移人员变得具有挑战性,如果它们分散得太多。在矩阵组织中,除了垂直筒仓,还有横切组,他们与所有垂直筒仓合作,并试图带来一定程度的一致性、统一性和秩序。例如,组织可能规定所有垂直筒仓必须将他们的软件部署到 AWS 云上。在这种情况下,可能会有一个云平台组,由垂直筒仓之外管理,并为所有垂直筒仓提供指导、工具和其他共享服务。安全性是另一个很好的例子。许多组织认为安全是必须集中管理的领域,不能任由每个筒仓的心情而定。

跨多个服务进行故障排除

由于系统的大多数功能将涉及多个微服务之间的交互,能够跟踪请求从所有这些微服务和各种数据存储中进入是非常重要的。实现这一点的最佳方法之一是分布式跟踪,您可以为每个请求打上标记,并可以从头到尾跟踪它。

调试分布式系统和基于微服务的系统的微妙之处需要很多专业知识。考虑单个请求通过系统的以下方面:

  • 处理请求的微服务可能使用不同的编程语言。

  • 微服务可以使用不同的传输/协议公开 API。

  • 请求可能是异步工作流的一部分,涉及在队列中等待和/或周期性处理。

  • 请求的持久状态可能分布在许多由不同微服务控制的独立数据存储中。

当您需要在系统中跨越整个微服务范围调试问题时,每个微服务的自治性变成了一种障碍。您必须构建明确的支持,以便通过聚合来自多个微服务的内部信息来获得系统级别的可见性。

利用共享服务库

如果您选择统一的微服务方法,拥有一个所有服务都使用并实现许多横切关注点的共享库(或多个库)非常有用,例如以下内容:

  • 配置

  • 秘密管理

  • 服务发现

  • API 包装

  • 日志记录

  • 分布式跟踪

这个库可以实现整个工作流程,比如与其他微服务或第三方依赖项交互的身份验证和授权,并为每个微服务进行繁重的工作。这样,微服务只负责正确使用这些库并实现自己的功能。

即使您选择多语言路径并支持多种语言,这种方法也可以工作。您可以为所有支持的语言实现这个库,服务本身可以用不同的语言实现。

然而,共享库的维护和演进以及所有微服务采用它们的速度都会带来成本。一个真正的危险是不同的微服务将使用许多版本的共享库,并且当使用不同版本的共享库的服务进行通信时会导致微妙(或不那么微妙)的问题。

我们将在书中后面探讨的服务网格方法可以为这个问题提供一些答案。

选择源代码控制策略

这是一个非常有趣的场景。有两种主要方法:monorepo 和多个 repos。让我们探讨每种方法的利弊。

Monorepo

在 monorepo 方法中,你的整个代码库都在一个单一的源代码控制存储库中。对整个代码库执行操作非常容易。每当你进行更改时,它立即反映在整个代码库中。版本控制基本上不可行。这对于保持所有代码同步非常有用。但是,如果你确实需要逐步升级系统的某些部分,你需要想出解决方法,比如创建一个带有新更改的单独副本。此外,你的源代码始终保持同步并不意味着你部署的服务都在使用最新版本。如果你总是一次性部署所有服务,你基本上就是在构建一个单体应用。请注意,即使你的更改已经合并,你仍然可能有多个 repo,如果你为第三方开源项目做出贡献(即使你只使用你的更改合并后的上游版本)。

Monorepo 的另一个大问题是,你可能需要大量定制工具来管理你的多个 repo。像谷歌和微软这样的大公司使用多 repo 方法。他们有特殊的需求,定制工具方面并不会阻碍他们。我对于多 repo 方法是否适合较小的组织持保留态度。然而,我会在 Delinkcious(演示应用)中使用 monorepo,这样我们可以一起探索并形成意见。一个主要的缺点是许多现代 CI/CD 工具链使用 GitOps,这会触发源代码控制 repo 中的更改。当只有一个 monorepo 时,你失去了源代码控制 repo 和微服务之间的一对一映射。

多个 repos

多 repo 方法恰恰相反。每个项目,通常每个库,都有一个单独的源代码控制存储库。项目之间相互消费,就像第三方库一样。这种方法有几个优点:

  • 项目和服务之间清晰的物理边界。

  • 源代码控制存储库和服务或项目之间的一对一映射。

  • 将服务的部署映射到源代码控制存储库非常容易。

  • 统一对待所有依赖项——内部和第三方。

然而,这种方法存在显著的成本,特别是随着服务和项目数量的增长以及它们之间的依赖关系图变得更加复杂时:

  • 经常需要在多个存储库中应用变更。

  • 通常需要维护存储库的多个版本,因为不同的服务依赖不同的服务。

  • 在所有存储库中应用横切变化是困难的。

混合

混合方法涉及使用少量存储库。每个存储库包含多个服务和项目。每个存储库与其他存储库隔离,但在每个存储库内,多个服务和项目可以同时开发。这种方法平衡了单存储库和多个存储库的利弊。当存在明确的组织边界和经常存在地理边界时,这可能是有用的。例如,如果一家公司有多个完全独立的产品线,将每个产品线分成自己的单存储库可能是一个好主意。

创建数据策略

软件系统最重要的责任之一是管理数据。有许多类型的数据,大多数数据应该在系统故障时幸存,或者您应该能够重建它。数据通常与其他数据有复杂的关系。这在关系数据库中非常明显,但也存在于其他类型的数据中。单体应用通常使用大型数据存储,保存所有相关数据,因此可以对整个数据集执行查询和事务。微服务是不同的。每个微服务都是自治的,负责自己的数据。然而,整个系统需要查询和操作现在存储在许多独立数据存储中并由许多不同服务管理的数据。让我们看看如何使用最佳实践来解决这一挑战。

每个微服务一个数据存储

每个微服务一个数据存储是微服务架构的关键元素。一旦两个微服务可以直接访问相同的数据存储,它们就紧密耦合,不再是独立的。有一些重要的细微差别需要理解。多个微服务使用同一个数据库实例可能没问题,但它们不能共享相同的逻辑数据库。

数据库实例是一个资源配置问题。在某些情况下,开发微服务的团队也负责为其提供数据存储。在这种情况下,明智的做法可能是为每个微服务有物理上分开的数据库实例,而不仅仅是逻辑实例。请注意,在使用云数据存储时,微服务开发人员无法控制并且不知道数据存储的物理配置。

我们同意两个微服务不应共享相同的数据存储。但是,如果一个单一的微服务管理两个或更多的数据存储呢?这通常也是不被赞同的。如果您的设计需要两个单独的数据存储,最好为每个专门指定一个微服务:

有一个常见的例外情况——您可能希望由同一个微服务管理内存数据存储(缓存)和持久数据存储。工作流程是服务将数据写入持久存储和缓存,并从缓存中提供查询。缓存可以定期刷新,或者基于更改通知,或者在缓存未命中时刷新。

但即使在这种情况下,使用一个单独的集中式缓存,比如由一个单独的微服务管理的 Redis,可能是更好的设计。请记住,在服务众多用户的大型系统中,每个微服务可能有多个实例。

另一个将数据存储的物理配置和配置从微服务本身抽象出来的原因是,这些配置在不同的环境中可能是不同的。您的生产环境可能为每个微服务有物理上分开的数据存储,但在开发环境中,最好只有一个物理数据库实例,有许多小的逻辑数据库。

运行分布式查询

我们同意每个微服务应该有自己的数据存储。这意味着系统的整体状态将分布在多个数据存储中,只能从它们自己的微服务中访问。大多数有趣的查询将涉及多个数据存储中可用的数据。每个消费者只需访问所有这些微服务并聚合所有数据以满足其查询。然而,出于几个原因,这是次优的:

  • 消费者深刻了解系统如何管理数据。

  • 消费者需要访问存储与查询相关数据的每项服务。

  • 更改架构可能需要更改许多消费者。

解决这个问题的两种常见解决方案是 CQRS 和 API 组合。它的很酷之处在于,实现这两种解决方案的服务具有相同的 API,因此可以在不影响用户的情况下从一种解决方案切换到另一种解决方案,甚至混合使用。这意味着一些查询将由 CQRS 提供服务,而另一些查询将由 API 组合提供服务,所有这些都由同一个服务实现。总的来说,我建议从 API 组合开始,只有在存在适当条件并且收益是强制性的情况下才过渡到 CQRS,因为它的复杂性要高得多。

采用命令查询职责分离

通过命令查询职责分离CQRS),来自各种微服务的数据被聚合到一个新的只读数据存储中,该存储被设计用来回答特定的查询。名称的含义是,您将更新数据(命令)的责任与读取数据(查询)的责任分开(分离)。不同的服务负责这些活动。通常通过观察所有数据存储的变化来实现,并需要一个变更通知系统。您也可以使用轮询,但这通常是不可取的。当已知查询经常使用时,这种解决方案会发挥作用。

以下是 CQRS 在实际中的示例。CQRS 服务(负责查询)从三个微服务(负责更新)接收到变更通知,并将它们聚合到自己的数据存储中。

当查询到来时,CQRS 服务通过访问自己的聚合视图来响应,而不会影响微服务:

优点如下:

  • 查询不会干扰更新主数据存储。

  • 聚合器服务公开了一个专门针对特定查询的 API。

  • 更改数据在幕后的管理方式更容易,而不会影响消费者。

  • 快速响应时间。

缺点如下:

  • 它给系统增加了复杂性。

  • 它复制了数据。

  • 部分视图需要明确处理。

采用 API 组合

API 组合方法更加轻量级。表面上看,它看起来就像 CQRS 解决方案。它公开了一个 API,可以跨多个微服务回答众所周知的查询。不同之处在于它不保留自己的数据存储。每当有请求进来时,它将访问包含数据的各个微服务,组合结果并返回。当系统不支持事件通知数据更改时,以及对主要数据存储运行查询的负载是可以接受的时,这种解决方案就会发光。

这里是 API 组合在操作中的示例,其中对 API 组合器服务的查询在幕后被转换为对三个微服务的查询:

优点如下:

  • 轻量级解决方案。

  • 聚合器服务公开了一个专门针对特定查询的 API。

  • 结果始终是最新的。

  • 没有架构要求,比如事件通知。

缺点如下:

  • 任何服务的失败都将导致查询失败。这需要关于重试和超时的策略决策。

  • 大量查询可能会影响主要数据存储。

使用 saga 来管理跨多个服务的事务

当一切正常时,API 组合器和 CQRS 模式为分布式查询提供了足够的解决方案。然而,维护分布式数据完整性是一个复杂的问题。如果您将所有数据存储在单个关系数据库中,并在架构中指定适当的约束条件,那么您可以依赖数据库引擎来处理数据完整性。但是,当多个微服务在隔离的数据存储中维护您的数据时(关系或非关系),情况就大不相同了。数据完整性是必不可少的,但必须由您的代码来维护。saga 模式解决了这个问题。在深入了解 saga 模式之前,让我们先了解一般的数据完整性。

了解 ACID

数据完整性的一个常见度量是修改数据的所有事务都具有 ACID 属性:

  • 原子性:事务中的所有操作都成功,或者全部失败。

  • 一致性:事务之前和之后,数据的状态符合所有约束。

  • 隔离性:并发事务的行为就像被串行化一样。

  • 持久性:当事务成功完成时,结果被持久化。

ACID 属性并不特定于关系数据库,但通常在这个背景下使用,主要是因为关系模式及其形式约束提供了一种方便的一致性度量。隔离性属性通常会对性能产生严重影响,并且在一些更偏向高性能和最终一致性的系统中可能会放宽。

持久性属性是非常明显的。如果你的数据不能安全持久化,那么所有的努力都没有意义。持久性有不同的级别:

  • 持久性到磁盘:可以在节点重启时存活,但不能在磁盘故障时存活

  • 多个节点上的冗余内存:可以在节点和磁盘故障时存活,但不能在所有节点暂时故障时存活

  • 冗余磁盘:可以在磁盘故障时存活

  • 地理分布式副本:可以在整个数据中心宕机时存活

  • 备份:存储大量信息更便宜,但恢复速度较慢,通常滞后于实时

原子性要求也是显而易见的。没有人喜欢部分更改,这可能会违反数据完整性并以难以排查的方式破坏系统。

理解 CAP 定理

CAP 定理指出,分布式系统不能同时具备以下三个特性:

  • 一致性

  • 可用性

  • 分区弹性

在实践中,你可以选择 CP 系统或 AP 系统。CP系统(一致性和分区弹性)始终保持一致,并且在组件之间存在网络分区时不会提供查询或进行更改。它只在系统完全连接时才能运行。这显然意味着你没有可用性。另一方面,AP系统(可用性和分区弹性)始终可用,并且可以以分裂脑的方式运行。当系统分裂时,每个部分可能会继续正常运行,但系统将不一致,因为每个部分都不知道另一部分发生的事务。

AP 系统通常被称为最终一致系统,因为当恢复连接时,某些对账过程会确保整个系统再次同步。一个有趣的变体是冻结系统,在网络分区发生时,它们会优雅地退化,并且两个部分都会继续提供查询,但拒绝对系统的所有修改。请注意,在分区的那一刻,没有保证两个部分是一致的,因为一个部分中的一些事务可能仍未复制到另一部分。通常,这已经足够好了,因为分裂部分之间的差异很小,并且不会随着时间的推移而增加,因为新的更改会被拒绝。

将 saga 模式应用于微服务

关系数据库可以通过算法(例如两阶段提交和对所有数据的控制)为分布式系统提供 ACID 合规性。两阶段提交算法分为准备和提交两个阶段。然而,参与分布式事务的服务必须共享相同的数据库。这对于管理自己的数据库的微服务来说是行不通的。

进入 saga 模式。saga 模式的基本思想是对所有微服务的操作进行集中管理,并且对于每个操作,如果由于某种原因整个事务无法完成,将执行一个补偿操作。这实现了 ACID 的原子性属性。但是,每个微服务上的更改立即可见,而不仅仅在整个分布式事务结束时才可见。这违反了一致性和隔离性属性。如果您将系统设计为 AP,也就是最终一致,这不是问题。但是,这需要您的代码意识到这一点,并且能够处理可能部分不一致或过时的数据。在许多情况下,这是一个可以接受的妥协。

saga 是如何工作的?saga 是一组在微服务上的操作和相应的补偿操作。当一个操作失败时,将按相反的顺序调用其补偿操作以及所有先前操作的补偿操作,以回滚系统的整个状态。

实现 sagas 并不是一件简单的事,因为补偿操作也可能会失败。一般来说,瞬态状态必须是持久的,并标记为这样,必须存储大量的元数据以实现可靠的回滚。一个好的做法是有一个带外进程频繁运行,并清理在实时未能完成所有补偿操作的失败的 sagas。

一个很好的理解 sagas 的方式是将其视为工作流程。工作流程很酷,因为它们可以实现长时间的过程,甚至涉及人类而不仅仅是软件。

总结

在本章中,我们涵盖了很多内容。我们讨论了微服务的基本原则——少即是多——以及将系统分解为许多小型和自包含的微服务可以帮助其扩展。我们还讨论了开发人员在利用微服务架构时面临的挑战。我们提供了大量关于构建基于微服务的系统的概念、选项、最佳实践和务实建议。在这一点上,你应该欣赏到微服务提供的灵活性,但也应该对你可以选择利用它们的许多方式有些担忧。

在本书的其余部分,我们将详细探讨这个领域,并一起使用一些最好的可用框架和工具构建一个基于微服务的系统,并将其部署在 Kubernetes 上。在下一章中,你将会遇到 Delinkcious——我们的示例应用程序——它将作为一个动手实验室。你还将一窥 Go-kit,这是一个用于构建 Go 微服务的微服务框架。

进一步阅读

如果你对微服务感兴趣,我建议从以下文章开始阅读:www.martinfowler.com/

第三章:Delinkcious - 示例应用程序

Delinkcious 是 Delicious([en.wikipedia.org/wiki/Delicious_(website)](https://en.wikipedia.org/wiki/Delicious_(website))的模仿者。Delicious 曾经是一个管理用户链接的互联网热门网站。它被雅虎收购,然后被转手多次。最终被 Pinboard 收购,后者运行类似的服务,并打算很快关闭 Delicious。

Delinkcious 允许用户将 URL 存储在网络上的酷炫位置,对其进行标记,并以各种方式查询它们。在本书中,Delinkcious 将作为一个实时实验室,演示许多微服务和 Kubernetes 概念,以及在真实应用程序环境中的功能。重点将放在后端,因此不会有时髦的前端 Web 应用程序或移动应用程序。我会把它们留给你作为可怕的练习。

在本章中,我们将了解为什么我选择 Go 作为 Delinkcious 的编程语言,然后看看Go kit - 一个我将用来构建 Delinkcious 的优秀的 Go 微服务工具包。然后,我们将使用社交图服务作为一个运行示例,剖析 Delinkcious 本身的不同方面。

我们将涵盖以下主题:

  • Delinkcious 微服务

  • Delinkcious 数据存储

  • Delinkcious API

  • Delinkcious 客户端库

技术要求

如果您迄今为止已经跟着本书走过,那么您已经安装了 Go。我建议安装一个好的 Go IDE 来跟随本章的代码,因为需要大量的学习。让我们看看几个不错的选择。

Visual Studio Code

Visual Studio Code,也称为VS Codecode.visualstudio.com/docs/languages/go),是微软的开源 IDE。它不是专门针对 Go 的,但通过专门和复杂的 Go 扩展,与 Go 有深度集成。它被认为是最好的免费 Go IDE。

GoLand

JetBrains 的 GoLand(www.jetbrains.com/go/)是我个人最喜欢的。它遵循了 IntelliJ IDEA、PyCharm 和其他优秀 IDE 的优良传统。这是一个付费版本,有 30 天的免费试用期。不幸的是,没有社区版。如果您有能力,我强烈推荐它。如果您不能或不想为 IDE 付费(完全合理),请查看其他选项。

LiteIDE

LiteIDE 或 LiteIDE X (github.com/visualfc/liteide)是一个非常有趣的开源项目。它是最早的 Go IDE 之一,早于 GoLand 和 VS Code 的 Go 扩展。我在早期使用过它,并对其质量感到惊讶。最终我放弃了它,因为使用 GNU Project Debugger(GDB)进行交互式调试时遇到了困难。它正在积极开发,有很多贡献者,并支持所有最新和最伟大的 Go 功能,包括 Go 1.1 和 Go 模块。现在您可以使用 Delve 进行调试,这是最好的 Go 调试器。

其他选项

如果您是一个死忠的命令行用户,根本不喜欢 IDE,您有可用的选项。大多数编程和文本编辑器都有某种形式的 Go 支持。Go 维基(github.com/golang/go/wiki/IDEsAndTextEditorPlugins)有一个大列表的 IDE 和文本编辑器插件,所以去看看吧。

代码

在本章中,没有代码文件,因为您只会了解 Delinkcious 应用程序:

选择 Go 用于 Delinkcious

我用许多优秀的语言编写并发布了生产后端代码,如 C/C++、Python、C#,当然还有 Go。我也使用了一些不那么好的语言,但让我们不讨论这些。我决定使用 Go 作为 Delinkcious 的编程语言,因为它是微服务的绝佳语言:

  • Go 编译为单个二进制文件,没有外部依赖(对于简单的 Dockerfile 非常棒)。

  • Go 非常易读和易学。

  • Go 对网络编程和并发有很好的支持。

  • Go 是许多云原生数据存储、队列和框架(包括 Docker 和 Kubernetes)的实现语言。

你可能会说微服务应该是语言无关的,我不应该专注于一种语言。这是真的,但我的目标是在这本书中非常实际,并深入研究在 Kubernetes 上构建微服务的所有细节。为了做到这一点,我不得不做出具体的选择并坚持下去。试图在多种语言中达到相同的深度是徒劳的。也就是说,微服务的边界非常清晰(这是微服务的一个优点),你可以看到在另一种语言中实现微服务将对系统的其余部分造成一些问题。

了解 Go kit

您可以从头开始编写您的微服务(使用 Go 或任何其他语言),它们将通过它们的 API 很好地相互交互。然而,在现实世界的系统中,将有大量的共享和/或交叉关注点,您希望它们保持一致:

  • 配置

  • 秘密管理

  • 中央日志记录

  • 指标

  • 认证

  • 授权

  • 安全

  • 分布式跟踪

  • 服务发现

实际上,在大多数大型生产系统中,微服务需要遵守特定的政策。

使用 Go kit(gokit.io/)。Go kit 对微服务空间采取了非常模块化的方法。它提供了高度的关注点分离,这是构建微服务的推荐方法,以及很大的灵活性。正如网站所说,少数意见,轻松持有

使用 Go kit 构建微服务

Go kit 关注的是最佳实践。您的业务逻辑是作为纯 Go 库实现的,它只处理接口和 Go 结构。所有涉及 API、序列化、路由和网络的复杂方面都将被分别放置在明确分离的层中,这些层利用了 Go kit 的概念和基础设施,如传输、端点和服务。这使得开发体验非常好,您可以在最简单的环境中演变和测试应用代码。这是 Delinkcious 服务之一-社交图的接口。请注意,它是纯 Go 的。没有 API、微服务,甚至没有 Go kit 的导入:

type SocialGraphManager interface {
   Follow(followed string, follower string) error
   Unfollow(followed string, follower string) error

   GetFollowing(username string) (map[string]bool, error)
   GetFollowers(username string) (map[string]bool, error)
}

这个接口的实现位于一个 Go 包中,它完全不知道 Go kit 甚至不知道它被用在微服务中:

package social_graph_manager

import (
   "errors"
   om "github.com/the-gigi/delinkcious/pkg/object_model"
)

type SocialGraphManager struct {
   store om.SocialGraphManager
}

func (m *SocialGraphManager) Follow(followed string, follower string) (err error) {
    ...
}

func (m *SocialGraphManager) Unfollow(followed string, follower string) (err error) {
    ...
}

func (m *SocialGraphManager) GetFollowing(username string) (map[string]bool, error) {
    ...
}

func (m *SocialGraphManager) GetFollowers(username string) (map[string]bool, error) {
    ...
}

将 Go kit 服务视为一个具有不同层的洋葱是一个很好的思路。核心是您的业务逻辑,上面叠加了各种关注点,如路由、速率限制、日志记录和度量标准,最终通过传输暴露给其他服务或全球:

Go kit 主要通过使用请求-响应模型支持 RPC 风格的通信。

理解传输

微服务最大的问题之一是它们通过网络相互交互和与客户端交互;换句话说,至少比在同一进程内调用方法复杂一个数量级。Go kit 通过传输概念明确支持微服务的网络方面。

Go kit 传输封装了所有复杂性,并与其他 Go kit 构造集成,如请求、响应和端点。Go kit 官方支持以下传输方式:

  • HTTP

  • gRPC

  • Thrift

  • net/rpc

但是,在其 GitHub 存储库中还有几种传输方式,包括用于消息队列和发布/订阅的 AMQP 和 NATS 传输。Go kit 传输的一个很酷的功能是,您可以在不更改代码的情况下通过多种传输方式公开相同的服务。

理解端点

Go kit 微服务实际上只是一组端点。每个端点对应于您服务接口中的一个方法。端点始终与至少一个传输和一个处理程序相关联,您实现该处理程序以处理请求。Go kit 端点支持 RPC 通信风格,并具有请求和响应结构。

这是Follow()方法端点的工厂函数:

func makeFollowEndpoint(svc om.SocialGraphManager) endpoint.Endpoint {
   return func(_ context.Context, request interface{}) (interface{}, error) {
      req := request.(followRequest)
      err := svc.Follow(req.Followed, req.Follower)
      res := followResponse{}
      if err != nil {
         res.Err = err.Error()
      }
      return res, nil
   }
}

我将很快解释这里发生了什么。现在,只需注意它接受om.SocialGraphManager类型的svc参数,这是一个接口,并调用其Follow()方法。

理解服务

这是您的代码插入系统的地方。当调用端点时,它会调用您的服务实现中的相应方法来完成所有工作。端点包装器会完成请求和响应的编码和解码工作。您可以使用最合理的抽象来专注于应用逻辑。

这是SocialGraphManager函数的Follow()方法的实现:

func (m *SocialGraphManager) Follow(followed string, follower string) (err error) {
   if followed == "" || follower == "" {
      err = errors.New("followed and follower can't be empty")
      return
   }

   return m.store.Follow(followed, follower)
}

理解中间件

正如前面的洋葱图所示,Go kit 是可组合的。除了必需的传输、端点和服务之外,Go kit 还使用装饰器模式可选择地包装服务和端点,以处理横切关注点,例如以下内容:

  • 弹性(例如,带有指数回退的重试)

  • 身份验证和授权

  • 日志记录

  • 度量收集

  • 分布式跟踪

  • 服务发现

  • 速率限制

这种以固定核心为基础的方法,使用少量的抽象,如传输、端点和服务,可以通过统一的中间件机制进行扩展,易于理解和使用。Go kit 在为中间件提供足够的内置功能和留出空间以满足您的需求之间取得了平衡。例如,在 Kubernetes 上运行时,服务发现已经为您处理了。很棒的是,在这种情况下你不必绕过 Go kit。您不绝对需要的功能和能力是可选的。

理解客户端

在第二章中,开始使用微服务,我们讨论了微服务的客户端库原则。一个微服务与另一个微服务交流时,理想情况下会利用通过接口公开的客户端库。Go kit 为编写这种客户端库提供了出色的支持和指导。使用微服务只需接收一个接口。它实际上对于它正在与另一个服务交流这一事实是完全不可知的。在(几乎)所有意图和目的上,远程服务可能正在同一个进程中运行。这对于测试或重构服务并将稍微过大的服务拆分为两个独立服务非常有用。

Go kit 具有类似于服务端点的客户端端点,但工作方向相反。服务端点解码请求,委托工作给服务,并编码响应。客户端端点编码请求,在网络上调用远程服务,并解码响应。

以下是客户端的Follow()方法的样子:

func (s EndpointSet) Follow(followed string, follower string) (err error) {
   resp, err := s.FollowEndpoint(context.Background(), FollowRequest{Followed: followed, Follower: follower})
   if err != nil {
      return err
   }
   response := resp.(SimpleResponse)

   if response.Err != "" {
      err = errors.New(response.Err)
   }
   return
}

生成样板

Go kit 的清晰关注点分离和整洁的架构分层是有代价的。代价是大量乏味、令人昏昏欲睡和容易出错的样板代码,用于在不同结构和方法签名之间转换请求和响应。了解 Go kit 如何以通用方式支持强类型接口是有用的,但对于大型项目,首选解决方案是从 Go 接口和数据类型生成所有样板。有几个项目可以完成这项任务,包括 Go kit 本身正在开发的一个名为kitgen的项目(github.com/go-kit/kit/tree/master/cmd/kitgen)。

目前它被认为是实验性的。我非常喜欢代码生成,并强烈推荐它。然而,在接下来的章节中,我们将看到大量手动样板代码,以清楚地说明发生了什么,并避免任何魔法。

介绍 Delinkcious 目录结构

在初始开发阶段,Delinkcious 系统由三个服务组成:

  • 链接服务

  • 用户服务

  • 社交图服务

高级目录结构包括以下子目录:

  • cmd

  • pkg

  • svc

root目录还包括一些常见文件,如README.md和重要的go.modgo.sum文件,以支持 Go 模块。我在这里使用 monorepo 方法,因此整个 Delinkcious 系统将驻留在这个目录结构中,并被视为单个 Go 模块,尽管有许多包:

$ tree -L 1
.
├── LICENSE
├── README.md
├── go.mod
├── go.sum
├── cmd
├── pkg
└── svc

cmd 子目录

cmd子目录包含各种工具和命令,以支持开发和运营,以及涉及多个参与者、服务或外部依赖的端到端测试;例如,通过其客户端库测试微服务。

目前,它只包含了社交图服务的单个端到端测试:

$ tree cmd
cmd
└── social_graph_service_e2e
 └── social_graph_service_e2e.go

pkg 子目录

pkg子目录是所有包的所在地。它包括微服务的实现,客户端库,抽象对象模型,其他支持包和单元测试。大部分代码以 Go 包的形式存在,这些包在实际微服务之前很容易开发和测试:

$ tree pkg
pkg
├── link_manager
│   ├── abstract_link_store.go
│   ├── db_link_store.go
│   ├── db_link_store_test.go
│   ├── in_memory_link_store.go
│   ├── link_manager.go
│   └── link_manager_suite_test.go
├── link_manager_client
│   └── client.go
├── object_model
│   ├── README.md
│   ├── interfaces.go
│   └── types.go
├── social_graph_client
│   ├── client.go
│   └── endpoints.go
├── social_graph_manager
│   ├── db_scoial_graph_store.go
│   ├── db_social_graph_manager_test.go
│   ├── in_memory_social_graph_manager_test.go
│   ├── in_memory_social_graph_store.go
│   ├── social_graph_manager.go
│   └── social_graph_manager_suite_test.go
└── user_manager
 ├── db_user_manager_test.go
 ├── db_user_store.go
 ├── in_memory_user_manager.go
 ├── in_memory_user_manager_test.go
 ├── in_memory_user_store.go
 └── user_manager_suite_test.go

svc 子目录

svc子目录是 Delinkcious 微服务的所在地。每个微服务都是一个独立的二进制文件,有自己的主包。delinkcious_service是一个遵循 API 网关模式的公共服务(microservices.io/patterns/apigateway.html):

$ tree svc
svc
├── delinkcious_service
│   └── README.md
├── link_service
│   ├── link_service.go
│   └── transport.go
├── social_graph_service
│   ├── social_graph_service.go
│   └── transport.go
└── user_service
 ├── transport.go
 └── user_service.go

介绍 Delinkcious 微服务

让我们详细检查 Delinkcious 服务,并逐步分析。我们将从内部开始,从服务层开始,一直到传输层。

有三种不同的服务:

  • 链接服务

  • 用户服务

  • 社交图服务

它们共同合作,提供 Delinkcious 的功能,即为用户管理链接并跟踪他们的社交图(关注/粉丝关系)。

对象模型

对象模型是所有接口和相关数据类型的集合,由服务实现。我选择把它们都放在一个包里:github.com/the-gigi/delinkcious/pkg/object_model。它包含两个文件:interfaces.gotypes.go

interfaces.go文件包含了三个 Delinkcious 服务的接口:

package object_model

type LinkManager interface {
   GetLinks(request GetLinksRequest) (GetLinksResult, error)
   AddLink(request AddLinkRequest) error
   UpdateLink(request UpdateLinkRequest) error
   DeleteLink(username string, url string) error
}

type UserManager interface {
   Register(user User) error
   Login(username string, authToken string) (session string, err error)
   Logout(username string, session string) error
}

type SocialGraphManager interface {
   Follow(followed string, follower string) error
   Unfollow(followed string, follower string) error

   GetFollowing(username string) (map[string]bool, error)
   GetFollowers(username string) (map[string]bool, error)
}

type LinkManagerEvents interface {
   OnLinkAdded(username string, link *Link)
   OnLinkUpdated(username string, link *Link)
   OnLinkDeleted(username string, url string)
}

types.go文件包含了在各种接口方法的签名中使用的结构体:

package object_model

import "time"

type Link struct {
   Url         string
   Title       string
   Description string
   Tags        map[string]bool
   CreatedAt   time.Time
   UpdatedAt   time.Time
}

type GetLinksRequest struct {
   UrlRegex         string
   TitleRegex       string
   DescriptionRegex string
   Username         string
   Tag              string
   StartToken       string
}

type GetLinksResult struct {
   Links         []Link
   NextPageToken string
}

type AddLinkRequest struct {
   Url         string
   Title       string
   Description string
   Username    string
   Tags        map[string]bool
}

type UpdateLinkRequest struct {
   Url         string
   Title       string
   Description string
   Username    string
   AddTags     map[string]bool
   RemoveTags  map[string]bool
}

type User struct {
   Email string
   Name  string
}

object_model包只是使用基本的 Go 类型、标准库类型(time.Time)和用户定义的类型来表示 Delinkcious 领域。这都是纯粹的 Go。在这个层次上,没有网络、API、微服务或 Go kit 的依赖或意识。

服务实现

下一层是将服务接口实现为简单的 Go 包。在这一点上,每个服务都有自己的包:

  • github.com/the-gigi/delinkcious/pkg/link_manager

  • github.com/the-gigi/delinkcious/pkg/user_manager

  • github.com/the-gigi/delinkcious/pkg/social_graph_manager

请注意,这些是 Go 包名,而不是 URL。

让我们详细检查social_graph_manager包。它将object_model包导入为om,因为它需要实现om.SocialGraphManager接口。它定义了一个名为SocialGraphManagerstruct,其中有一个名为store的字段,类型为om.SocialGraphManager。因此,在这种情况下,store字段的接口与管理器的接口是相同的:

package social_graph_manager

import (
   "errors"
   om "github.com/the-gigi/delinkcious/pkg/object_model"
)

type SocialGraphManager struct {
   store om.SocialGraphManager
}

这可能有点令人困惑。想法是store字段实现相同的接口,以便顶级管理器可以实现一些验证逻辑并将繁重的工作委托给存储。您很快就会看到这一点。

此外,store字段是一个接口的事实允许我们使用实现相同接口的不同存储。这非常有用。NewSocialGraphManager()函数接受一个store字段,该字段不能为nil,然后返回一个提供的存储的新的SocialGraphManager实例。

func NewSocialGraphManager(store om.SocialGraphManager) (om.SocialGraphManager, error) {
   if store == nil {
      return nil, errors.New("store can't be nil")
   }
   return &SocialGraphManager{store: store}, nil
}

SocialGraphManager结构本身非常简单。它执行一些有效性检查,然后将工作委托给它的store

func (m *SocialGraphManager) Follow(followed string, follower string) (err error) {
   if followed == "" || follower == "" {
      err = errors.New("followed and follower can't be empty")
      return
   }

   return m.store.Follow(followed, follower)
}

func (m *SocialGraphManager) Unfollow(followed string, follower string) (err error) {
   if followed == "" || follower == "" {
      err = errors.New("followed and follower can't be empty")
      return
   }

   return m.store.Unfollow(followed, follower)
}

func (m *SocialGraphManager) GetFollowing(username string) (map[string]bool, error) {
   return m.store.GetFollowing(username)
}

func (m *SocialGraphManager) GetFollowers(username string) (map[string]bool, error) {
   return m.store.GetFollowers(username)
}

社交图管理器是一个非常简单的库。让我们继续剥离洋葱,看看服务本身,它位于svc子目录下:github.com/the-gigi/delinkcious/tree/master/svc/social_graph_service

让我们从social_graph_service.go文件开始。我们将介绍大多数服务相似的主要部分。该文件位于service包中,这是我使用的一个约定。它导入了几个重要的包:

package service

import (
   httptransport "github.com/go-kit/kit/transport/http"
   "github.com/gorilla/mux"
   sgm "github.com/the-gigi/delinkcious/pkg/social_graph_manager"
   "log"
   "net/http"
)

Go kit http传输包对于使用 HTTP 传输的服务是必需的。gorilla/mux包提供了一流的路由功能。social_graph_manager是执行所有繁重工作的服务的实现。log包用于记录日志,net/http包用于提供 HTTP 服务,因为它是一个 HTTP 服务。

只有一个名为Run()的函数。它首先创建一个社交图管理器的数据存储,然后创建社交图管理器本身,并将store字段传递给它。因此,social_graph_manager的功能是在包中实现的,但service负责做出策略决策并传递配置好的数据存储。如果在这一点上出了任何问题,服务将通过log.Fatal()调用退出,因为在这个早期阶段没有办法恢复。

func Run() {
   store, err := sgm.NewDbSocialGraphStore("localhost", 5432, "postgres", "postgres")
   if err != nil {
      log.Fatal(err)
   }
   svc, err := sgm.NewSocialGraphManager(store)
   if err != nil {
      log.Fatal(err)
   }

接下来是为每个端点构建处理程序的部分。这是通过调用 HTTP 传输的NewServer()函数来完成的。参数是Endpoint工厂函数(我们很快将对其进行审查)、请求解码器函数和response编码器函数。对于 HTTP 服务,通常将请求和响应编码为 JSON。

followHandler := httptransport.NewServer(
   makeFollowEndpoint(svc),
   decodeFollowRequest,
   encodeResponse,
)

unfollowHandler := httptransport.NewServer(
   makeUnfollowEndpoint(svc),
   decodeUnfollowRequest,
   encodeResponse,
)

getFollowingHandler := httptransport.NewServer(
   makeGetFollowingEndpoint(svc),
   decodeGetFollowingRequest,
   encodeResponse,
)

getFollowersHandler := httptransport.NewServer(
   makeGetFollowersEndpoint(svc),
   decodeGetFollowersRequest,
   encodeResponse,
)

此时,我们已经正确初始化了SocialGraphManager并且为所有端点准备好了处理程序。现在是时候通过gorilla路由器向世界公开它们了。每个端点都与一个路由和一个方法相关联。在这种情况下,followunfollow操作使用 POST 方法,followingfollowers操作使用 GET 方法:

r := mux.NewRouter()
r.Methods("POST").Path("/follow").Handler(followHandler)
r.Methods("POST").Path("/unfollow").Handler(unfollowHandler)
r.Methods("GET").Path("/following/{username}").Handler(getFollowingHandler)
r.Methods("GET").Path("/followers/{username}").Handler(getFollowersHandler)

最后一部分只是将配置好的路由器传递给标准 HTTP 包的ListenAndServe()方法。该服务硬编码为监听端口9090。在本书的后面,我们将看到如何以灵活和更具产业实力的方式配置这些东西:

log.Println("Listening on port 9090...")
log.Fatal(http.ListenAndServe(":9090", r))

实现支持函数

你可能还记得,pkg/social_graph_manager包中的社交图实现完全与传输无关。它根据 Go 实现SocialGraphManager接口,不管负载是 JSON 还是 protobuf,以及通过 HTTP、gRPC、Thrift 或任何其他方法传输。服务负责翻译、编码和解码。这些支持函数在transport.go文件中实现。

对于每个端点,都有三个函数,它们是 Go kit 的 HTTP 传输NewServer()函数的输入:

  • Endpoint工厂函数

  • request解码器

  • response编码器

让我们从Endpoint工厂函数开始,这是最有趣的部分。让我们以GetFollowing()操作为例。makeGetFollowingEndpoint()函数以SocialGraphManager接口作为输入(如你之前看到的,在实践中,它将是pkg/social_graph_manager中的实现)。它返回一个通用的endpoint.Endpoint函数,这是一个接受Context和通用request并返回通用responseerror的函数:

type Endpoint func(ctx context.Context, request interface{}) (response interface{}, err error)

makeGetFollowingEndpoint()方法的工作是返回一个符合这个签名的函数。它返回这样一个函数,在其实现中,接受通用请求(空接口)和类型,然后将其断言为具体的请求,即getByUsernameRequest

req := request.(getByUsernameRequest)

这是一个关键概念。我们从一个通用对象跨越边界,这个对象可以是任何东西,到一个强类型的结构体。这确保了,即使 Go kit 端点是以空接口的形式操作,我们的微服务的实现也经过了类型检查。如果请求不包含正确的字段,它会引发 panic。我也可以检查是否可能进行类型断言,而不是引发 panic,这在某些情况下可能更合适:

req, ok := request.(getByUsernameRequest)
if !ok {
   ...
}

让我们来看看请求本身。它只是一个带有一个名为Username的字符串字段的结构体。它有 JSON 结构标签,在这种情况下是可选的,因为 JSON 包可以通过大小写的不同来自动处理与实际 JSON 不同的字段名(例如Usernameusername):

type getByUsernameRequest struct {
   Username string `json:"username"`
}

请注意,请求类型是getByUsernameRequest而不是getFollowingRequest,这可能与您期望的一致,以支持它正在支持的操作。原因是我实际上在多个端点上使用相同的请求。GetFollowers()操作也需要一个username,而getByUsernameRequest同时为GetFollowing()GetFollowers()提供服务。

此时,我们从请求中得到了用户名,我们可以调用底层实现的GetFollowing()方法:

followingMap, err := svc.GetFollowing(req.Username)

结果是请求用户正在关注的用户的映射和标准错误。但是,这是一个 HTTP 端点,所以下一步是将这些信息打包到getFollowingResponse结构体中:

type getFollowingResponse struct {
   Following map[string]bool `json:"following"`
   Err       string          `json:"err"`
}

以下映射可以转换为string->bool的 JSON 映射。然而,Go 错误接口没有直接的等价物。解决方案是将错误编码为字符串(通过err.Error()),其中空字符串表示没有错误:

res := getFollowingResponse{Following: followingMap}
if err != nil {
   res.Err = err.Error()
}

这是整个函数:

func makeGetFollowingEndpoint(svc om.SocialGraphManager) endpoint.Endpoint {
   return func(_ context.Context, request interface{}) (interface{}, error) {
      req := request.(getByUsernameRequest)
      followingMap, err := svc.GetFollowing(req.Username)
      res := getFollowingResponse{Following: followingMap}
      if err != nil {
         res.Err = err.Error()
      }
      return res, nil
   }
}

现在,让我们来看看decodeGetFollowingRequest()函数。它接受标准的http.Request对象。它需要从请求中提取用户名,并返回一个getByUsernameRequest结构体,以便端点稍后可以使用。在 HTTP 请求级别,用户名将成为请求路径的一部分。该函数将解析路径,提取用户名,准备请求,并返回请求或错误(例如,未提供用户名):

func decodeGetFollowingRequest(_ context.Context, r *http.Request) (interface{}, error) {
   parts := strings.Split(r.URL.Path, "/")
   username := parts[len(parts)-1]
   if username == "" || username == "following" {
      return nil, errors.New("user name must not be empty")
   }
   request := getByUsernameRequest{Username: username}
   return request, nil

最后一个支持函数是encodeResonse()函数。理论上,每个端点都可以有自己的自定义response编码函数。但在这种情况下,我使用了一个通用函数,它知道如何将所有响应编码为 JSON:

func encodeResponse(_ context.Context, w http.ResponseWriter, response interface{}) error {
   return json.NewEncoder(w).Encode(response)
}

这需要所有响应结构都可以被 JSON 序列化,这是通过将 Go 错误接口转换为端点实现的字符串来处理的。

通过客户端库调用 API。

社交图管理器现在可以通过 HTTP REST API 访问。这是一个快速的本地演示。首先,我将启动 Postgres DB(我有一个名为postgres的 Docker 镜像),它用作数据存储,然后我将在service目录中运行服务本身,即delinkcious/svc/social_graph_service

$ docker restart postgres
$ go run main.go

2018/12/31 10:41:23 Listening on port 9090...

通过调用/follow端点来添加一些关注/被关注的关系。我将使用出色的 HTTPie(httpie.org/),在我看来,这是一个更好的curl。但是,如果你喜欢,你也可以使用curl

$ http POST http://localhost:9090/follow followed=liat follower=gigi
HTTP/1.1 200 OK
Content-Length: 11
Content-Type: text/plain; charset=utf-8
Date: Mon, 31 Dec 2018 09:19:01 GMT

{
 "err": ""
}

$ http POST http://localhost:9090/follow followed=guy follower=gigi
HTTP/1.1 200 OK
Content-Length: 11
Content-Type: text/plain; charset=utf-8
Date: Mon, 31 Dec 2018 09:19:01 GMT

{
 "err": ""
}

这两个调用使gigi用户关注liatguy用户。让我们使用/following端点来验证这一点:

$ http GET http://localhost:9090/following/gigi
HTTP/1.1 200 OK
Content-Length: 37
Content-Type: text/plain; charset=utf-8
Date: Mon, 31 Dec 2018 09:37:21 GMT

{
 "err": "",
 "following": {
 "guy": true
 "liat": true
 }
}

JSON 响应中有一个空错误,following映射包含了guyliat用户,如预期的那样。

虽然 REST API 很酷,但我们可以做得更好。我们不应该强迫调用者理解我们服务的 URL 模式,并解码和编码 JSON 负载,为什么不提供一个客户端库来完成所有这些呢?这对于所有使用少量语言进行交流的内部微服务来说尤其如此,在许多情况下,甚至只有一种语言。服务和客户端可以共享相同的接口,甚至可能有一些共同的类型。此外,Go kit 提供了对客户端端点的支持,这些端点与服务端端点非常相似。这直接转化为一个非常简化的端到端开发者体验,你只需留在编程语言空间。所有端点、传输、编码和解码可以大部分时间保持隐藏,作为实现细节。

社交图服务提供了一个客户端库,位于pkg/social_graph_client包中。client.go文件类似于social_graph_service.go文件,负责在NewClient()函数中创建一组端点,并返回SocialGraphManager接口。NewClient()函数以基本 URL 作为参数,然后使用 Go kit 的 HTTP 传输的NewClient()函数构建一组客户端端点。每个端点都需要一个 URL、一个方法(在本例中为GETPOST)、一个request编码器和一个response解码器。它就像服务的镜像。然后,它将客户端端点分配给EndpointSet结构体,可以通过SocialGraphManager接口公开它们:

func NewClient(baseURL string) (om.SocialGraphManager, error) {
   // Quickly sanitize the instance string.
   if !strings.HasPrefix(baseURL, "http") {
      baseURL = "http://" + baseURL
   }
   u, err := url.Parse(baseURL)
   if err != nil {
      return nil, err
   }

   followEndpoint := httptransport.NewClient(
      "POST",
      copyURL(u, "/follow"),
      encodeHTTPGenericRequest,
      decodeSimpleResponse).Endpoint()

   unfollowEndpoint := httptransport.NewClient(
      "POST",
      copyURL(u, "/unfollow"),
      encodeHTTPGenericRequest,
      decodeSimpleResponse).Endpoint()

   getFollowingEndpoint := httptransport.NewClient(
      "GET",
      copyURL(u, "/following"),
      encodeGetByUsernameRequest,
      decodeGetFollowingResponse).Endpoint()

   getFollowersEndpoint := httptransport.NewClient(
      "GET",
      copyURL(u, "/followers"),
      encodeGetByUsernameRequest,
      decodeGetFollowersResponse).Endpoint()

   // Returning the EndpointSet as an interface relies on the
   // EndpointSet implementing the Service methods. That's just a simple bit
   // of glue code.
   return EndpointSet{
      FollowEndpoint:       followEndpoint,
      UnfollowEndpoint:     unfollowEndpoint,
      GetFollowingEndpoint: getFollowingEndpoint,
      GetFollowersEndpoint: getFollowersEndpoint,
   }, nil
}

EndpointSet结构体在endpoints.go文件中定义。它包含端点本身,这些端点是函数,并实现了SocialGraphManager方法,在其中将工作委托给端点的函数:

type EndpointSet struct {
   FollowEndpoint       endpoint.Endpoint
   UnfollowEndpoint     endpoint.Endpoint
   GetFollowingEndpoint endpoint.Endpoint
   GetFollowersEndpoint endpoint.Endpoint
}

让我们检查EndpointSet结构体的GetFollowing()方法。它接受用户名作为字符串,然后调用带有填充输入用户名的getByUserNameRequest的端点。如果调用端点函数返回错误,它就会退出。否则,它进行类型断言,将通用响应转换为getFollowingResponse结构体。如果其错误字符串不为空,它会从中创建一个 Go 错误。最终,它将响应中的关注用户作为映射返回:

func (s EndpointSet) GetFollowing(username string) (following map[string]bool, err error) {
   resp, err := s.GetFollowingEndpoint(context.Background(), getByUserNameRequest{Username: username})
   if err != nil {
      return
   }

   response := resp.(getFollowingResponse)
   if response.Err != "" {
      err = errors.New(response.Err)
   }
   following = response.Following
   return
}

存储数据

我们已经看到了 Go kit 和我们自己的代码如何接受带有 JSON 负载的 HTTP 请求,将其转换为 Go 结构,调用服务实现,并将响应编码为 JSON 返回给调用者。现在,让我们更深入地了解数据的持久存储。社交图管理器负责维护用户之间的关注/粉丝关系。有许多选项可用于存储此类数据,包括关系数据库、键值存储,当然还有图数据库,这可能是最自然的。在这个阶段,我选择使用关系数据库,因为它熟悉、可靠,并且可以很好地支持所需的操作:

  • 关注

  • 取消关注

  • 获取关注者

  • 获取以下

然而,如果我们后来发现我们更喜欢不同的数据存储或者扩展关系型数据库以添加一些缓存机制,那么很容易做到,因为社交图管理器的数据存储被隐藏在一个接口后面。它实际上使用的是同一个接口,即 SocialGraphManager。正如您可能记得的那样,社交图管理器包在其工厂函数中接受了一个 SocialGraphManager 类型的存储参数:

func NewSocialGraphManager(store om.SocialGraphManager) (om.SocialGraphManager, error) {
   if store == nil {
      return nil, errors.New("store can't be nil")
   }
   return &SocialGraphManager{store: store}, nil
}

由于社交图管理器通过这个接口与其数据存储进行交互,因此可以在不对社交图管理器本身进行任何代码更改的情况下进行更改实现。

我将利用这一点进行单元测试,其中我使用一个易于设置的内存数据存储,可以快速填充测试数据,并允许我在本地运行测试。

让我们来看看内存中的社交图数据存储,可以在github.com/the-gigi/delinkcious/blob/master/pkg/social_graph_manager/in_memory_social_graph_store.go找到。

它几乎没有依赖关系 - 只有 SocialGraphManager 接口和标准错误包。它定义了一个 SocialUser 结构,其中包含用户名以及它正在关注的用户的名称,以及正在关注它的用户的名称:

package social_graph_manager

import (
   "errors"
   om "github.com/the-gigi/delinkcious/pkg/object_model"
)

type Followers map[string]bool
type Following map[string]bool

type SocialUser struct {
   Username  string
   Followers Followers
   Following Following
}

func NewSocialUser(username string) (user *SocialUser, err error) {
   if username == "" {
      err = errors.New("user name can't be empty")
      return
   }

   user = &SocialUser{Username: username, Followers: Followers{}, Following: Following{}}
   return
}

数据存储本身是一个名为 InMemorySocialGraphStore 的结构,其中包含用户名和相应的 SocialUser 结构之间的映射:

type SocialGraph map[string]*SocialUser

type InMemorySocialGraphStore struct {
   socialGraph SocialGraph
}

func NewInMemorySocialGraphStore() om.SocialGraphManager {
   return &InMemorySocialGraphStore{
      socialGraph: SocialGraph{},
   }
}

这都是相当普通的。InMemorySocialGraphStore 结构实现了 SocialGraphManager 接口方法。例如,这是 Follow() 方法:

func (m *InMemorySocialGraphStore) Follow(followed string, follower string) (err error) {
   followedUser := m.socialGraph[followed]
   if followedUser == nil {
      followedUser, _ = NewSocialUser(followed)
      m.socialGraph[followed] = followedUser
   }

   if followedUser.Followers[follower] {
      return errors.New("already following")
   }

   followedUser.Followers[follower] = true

   followerUser := m.socialGraph[follower]
   if followerUser == nil {
      followerUser, _ = NewSocialUser(follower)
      m.socialGraph[follower] = followerUser
   }

   followerUser.Following[followed] = true

   return

此时,没有必要过多关注它的工作原理。我想要传达的主要观点是,通过使用接口作为抽象,您可以获得很大的灵活性和清晰的关注点分离,这在您想要在测试期间开发系统或服务的特定部分时非常有帮助。如果您想要进行重大更改,比如更改底层数据存储或可互换使用多个数据存储,那么拥有一个接口是一个救命稻草。

总结

在本章中,您仔细了解了 Go kit 工具包,整个 Delinkcious 系统及其微服务,并深入研究了 Delinkcious 的社交图组件。本章的主题是,Go kit 提供了清晰的抽象,如服务、端点和传输,以及用于将微服务分层的通用功能。然后,您可以为松散耦合但内聚的微服务系统添加代码。您还跟随了来自客户端的请求的路径,一直到服务,然后通过所有层返回。在这一点上,您应该对 Go kit 如何塑造 Delinkcious 架构以及它如何使任何其他系统受益有一个大致的了解。您可能会对所有这些信息感到有些不知所措,但请记住,这种复杂性被整齐地打包了起来,您大部分时间可以忽略它,专注于您的应用程序,并获得好处。

在下一章中,我们将讨论任何现代基于微服务的系统中非常关键的部分 - CI/CD 流水线。我们将创建一个 Kubernetes 集群,配置 CircleCI,部署 Argo CD 持续交付解决方案,并了解如何在 Kubernetes 上部署 Delinkcious。

进一步阅读

让我们参考以下参考资料:

第四章:设置 CI/CD 流水线

在基于微服务的系统中,有许多组成部分。Kubernetes 是一个提供了许多构建块的丰富平台。可靠和可预测地管理和部署所有这些组件需要高度的组织和自动化。这就是 CI/CD 流水线的作用。

在本章中,我们将了解 CI/CD 流水线解决的问题,介绍 Kubernetes 的 CI/CD 流水线的不同选项,并最终构建 Delinkcious 的 CI/CD 流水线。

在本章中,我们将讨论以下主题:

  • 理解 CI/CD 流水线

  • Kubernetes CI/CD 流水线的选项

  • GitOps

  • 自动化的 CI/CD

  • 使用 CircleCI 构建您的镜像

  • 为 Delinkcious 设置持续交付

技术要求

在本章中,您将使用 CircleCI 和 Argo CD。我将向您展示如何稍后在 Kubernetes 集群中安装 Argo CD。要免费设置 CircleCI,请按照它们网站上的入门说明circleci.com/docs/2.0/getting-started/

代码

本章的 Delinkcious 版本可以在github.com/the-gigi/delinkcious/releases/tag/v0.2找到。

我们将在主要的 Delinkcious 代码库上工作,因此没有代码片段或示例。

理解 CI/CD 流水线

软件系统的开发生命周期从代码开始,经过测试,生成构件,更多测试,最终部署到生产环境。基本思想是,每当开发人员向其源代码控制系统(例如 GitHub)提交更改时,这些更改都会被持续集成CI)系统检测到,并立即运行测试。

这通常会由同行进行审查,并将代码更改(或拉取请求)从特性分支或开发分支合并到主分支。在 Kubernetes 的上下文中,CI 系统还负责构建服务的 Docker 镜像并将其推送到镜像注册表。在这一点上,我们有包含新代码的 Docker 镜像。这就是 CD 系统的作用。

当新镜像可用时,持续交付CD)系统将其部署到目标环境。CD 是确保整个系统处于期望状态的过程,通过配置和部署来实现。有时,如果系统不支持动态配置,部署可能会因配置更改而发生。我们将在第五章中详细讨论配置,使用 Kubernetes 配置微服务

因此,CI/CD 流水线是一组工具,可以检测代码更改,并根据组织的流程和政策将其推送到生产环境。通常由 DevOps 工程师负责构建和维护此流水线,并且开发人员大量使用。

每个组织和公司(甚至同一公司内的不同团队)都会有一个特定的流程。在我第一份工作中,我的第一个任务是用许多人都不再理解的递归 makefile 替换基于 Perl 的构建系统(那时候 CI/CD 流水线被称为这样)。该构建系统必须在 Windows 上运行代码生成步骤,使用一些建模软件,在两种不同的 Unix 平台上(包括嵌入式平台)使用两种不同的工具链编译和运行 C++单元测试,并触发 open CVS。我选择了 Python,并不得不从头开始创建一切。

这很有趣,但非常特定于这家公司。通常认为 CI/CD 流水线是由事件驱动的一系列步骤的工作流程。

以下图表展示了一个简单的 CI/CD 流水线:

此流水线中的各个阶段的功能如下:

  1. 开发人员将他们的更改提交到 GitHub(源代码控制)

  2. CI 服务器运行测试,构建 Docker 镜像,并将镜像推送到 DockerHub(镜像注册表)

  3. Argo CD 服务器检测到有新镜像可用,并部署到 Kubernetes 集群

现在我们已经了解了 CI/CD 流水线,让我们来看一下特定的 CI/CD 流水线选择。

Delinkcious CI/CD 流水线的选项

为您的系统选择 CI/CD 流水线是一个重大决定。当我为 Delinkcious 面临这个决定时,我调查了几种替代方案。这里没有明显的选择。Kubernetes 发展迅速,工具和流程难以跟上。我评估了几种选择,并选择了 CircleCI 进行持续集成和 Argo CD 进行持续交付。我最初考虑了一个整个 CI/CD 流水线的一站式解决方案,但在审查了一些选项后,我决定更喜欢将它们视为两个单独的实体,并为 CI 和 CD 选择了不同的解决方案。让我们简要回顾一些这些选项(还有很多很多):

  • Jenkins X

  • Spinnaker

  • Travis CI 和 CircleCI

  • Tekton

  • Argo CD

  • 自己动手

Jenkins X

Jenkins X 是我的首选和最喜欢的。我读了一些文章,看了一些演示,让我想要喜欢它。它提供了您想要的所有功能,包括一些高级功能:

  • 自动化的 CI/CD

  • 通过 GitOps 进行环境推广

  • 拉取请求预览环境

  • 对您的提交和拉取请求的自动反馈

在幕后,它利用了成熟但复杂的 Jenkins 产品。Jenkins X 的前提是它将掩盖 Jenkins 的复杂性,并提供一个特定于 Kubernetes 的简化工作流程。

当我尝试实际使用 Jenkins X 时,我对一些问题感到失望:

  • 它不能直接使用,故障排除很复杂。

  • 它非常主观。

  • 它不很好地支持单一代码库方法(或根本不支持)。

我试图让它工作一段时间,但在阅读了其他人的经验并看到 Jenkins X 的 slack 社区频道缺乏响应后,我对 Jenkins X 失去了兴趣。我仍然喜欢这个想法,但在我再次尝试之前,它真的必须非常稳定。

Spinnaker

Spinnaker 是 Netflix 的开源 CI/CD 解决方案。它有很多好处,包括以下:

  • 它已被许多公司采用。

  • 它与其他产品有很多集成。

  • 它支持很多最佳实践。

Spinnaker 的缺点如下:

  • 它是一个庞大而复杂的系统。

  • 它有一个陡峭的学习曲线。

  • 它不是特定于 Kubernetes 的。

最后,我决定放弃 Spinnaker——不是因为 Spinnaker 本身有任何问题,而是因为我对它没有经验。在开发 Delinkcious 本身和写这本书的过程中,我不想从头开始学习这样一个庞大的产品。你可能会发现 Spinnaker 对你来说是正确的 CI/CD 解决方案。

Travis CI 和 CircleCI

我更喜欢将 CI 解决方案与 CD 解决方案分开。在概念上,CI 流程的作用是生成一个容器镜像并将其推送到注册表。它根本不需要了解 Kubernetes。另一方面,CD 解决方案必须对 Kubernetes 有所了解,并且理想情况下在集群内运行。

对于 CI,我考虑了 Travis CI 和 CircleCI。两者都为开源项目提供免费的 CI 服务。我选择了 CircleCI,因为它更具备功能完备,并且具有更好的用户界面,这很重要。我相信 Travis CI 也会很好用。我在其他一些开源项目中使用 Travis CI。重要的是要注意,流水线的 CI 部分完全与 Kubernetes 无关。最终结果是镜像仓库中的 Docker 镜像。这个 Docker 镜像可以用于其他目的,而不一定要部署在 Kubernetes 集群中。

Tekton

Tekton 是一个非常有趣的项目。它是 Kubernetes 原生的,具有很好的步骤、任务、运行和流水线的抽象。它相对年轻,但似乎非常有前途。它还被选为 CD 基金会的首批项目之一:cd.foundation/projects/

看它如何发展将会很有趣。

Tekton 的优点如下:

  • 现代设计和清晰的概念模型

  • 得到 CD 基金会的支持。

  • 建立在 prow 之上(Kubernetes 自身的 CI/CD 解决方案)

  • Kubernetes 原生解决方案

Tekton 的缺点如下:

  • 它仍然相当新和不稳定。

  • 它没有其他解决方案的所有功能和能力。

Argo CD

与 CI 解决方案相反,CD 解决方案非常特定于 Kubernetes。我选择 Argo CD 有几个原因:

  • 对 Kubernetes 有认识

  • 建立在通用工作流引擎(Argo)之上

  • 出色的用户界面

  • 在您的 Kubernetes 集群上运行

  • 用 Go 实现(并不是很重要,但我喜欢它)

Argo CD 也有一些缺点:

  • 它不是 CD 基金会或 CNCF 的成员(在社区中认可度较低)。

  • Intuit 是其背后的主要公司,不是一个主要的云原生强大力量。

Argo CD 是一个来自 Intuit 的年轻项目,他们收购了 Argo 项目的原始开发人员- Applatix。我真的很喜欢它的架构,当我尝试过它时,一切都像魔术一样运行。

自己动手

我曾简要考虑过创建自己的简单 CI/CD 流水线。操作并不复杂。对于本书的目的,我并不需要一个非常可靠的解决方案,而且很容易解释每个步骤发生了什么。然而,考虑到读者,我决定最好使用现有的工具,这些工具可以直接利用,并且还可以节省我开发一个糟糕的 CI/CD 解决方案的时间。

此时,您应该对 Kubernetes 上的 CI/CD 解决方案有了一个很好的了解。我们审查了大多数流行的解决方案,并选择了 CircleCI 和 Argo CD 作为 Delinkcious CI/CD 解决方案的最佳选择。接下来,我们将讨论 GitOps 的热门新趋势。

GitOps

GitOps 是一个新的时髦词汇,尽管概念并不是很新。这是基础设施即代码的另一种变体。基本思想是您的代码、配置和所需的资源都应该在一个源代码控制存储库中进行描述和存储,并进行版本控制。每当您向存储库推送更改时,您的 CI/CD 解决方案将做出响应并采取正确的操作。甚至可以通过在存储库中恢复到先前版本来启动回滚。当然,存储库不一定是 Git,但 GitOps 听起来比源代码控制运营好得多,大多数人都使用 Git,所以我们就在这里了。

CircleCI 和 Argo CD 都完全支持并倡导 GitOps 模型。当您git push代码更改时,CircleCI 将触发并开始构建正确的镜像。当您git push更改到 Kubernetes 清单时,Argo CD 将触发并将这些更改部署到您的 Kubernetes 集群。

现在我们清楚了 GitOps 是什么,我们可以开始为 Delinkcious 实施流水线的持续集成部分。我们将使用 CircleCI 从源代码构建 Docker 镜像。

使用 CircleCI 构建您的镜像

让我们深入研究 Delinkcious CI 流水线。我们将逐步介绍持续集成过程中的每个步骤,其中包括以下内容:

  • 审查源代码树

  • 配置 CI 流水线

  • 理解构建脚本

  • 使用多阶段 Dockerfile 对 Go 服务进行 Docker 化

  • 探索 CircleCI 用户界面

审查源代码树

持续集成是关于构建和测试的东西。第一步是了解 Delinkcious 中需要构建和测试的内容。让我们再看一下 Delinkcious 源代码树:

$ tree -L 2
.
├── LICENSE
├── README.md
├── build.sh
├── cmd
│   ├── link_service_e2e
│   ├── social_graph_service_e2e
│   └── user_service_e2e
├── go.mod
├── go.sum
├── pkg
│   ├── db_util
│   ├── link_manager
│   ├── link_manager_client
│   ├── object_model
│   ├── social_graph_client
│   ├── social_graph_manager
│   ├── user_client
│   └── user_manager
└── svc
 ├── api_gateway_service
 ├── link_service
 ├── social_graph_service
 └── user_service

pkg目录包含服务和命令使用的包。我们应该运行这些包的单元测试。svc目录包含我们的微服务。我们应该构建这些服务,将每个服务打包到适当版本的 Docker 镜像中,并将这些镜像推送到 DockerHub(镜像注册表)。cmd目录目前包含端到端测试。这些测试旨在在本地运行,不需要由 CI 管道构建(如果您想将端到端测试添加到我们的测试流程中,可以更改这一点)。

配置 CI 管道

CircleCI 由一个标准名称和位置的单个 YAML 文件进行配置,即<根目录>/.circleci/config.yaml

version: 2
jobs:
  build:
    docker:
    - image: circleci/golang:1.11
    - image: circleci/postgres:9.6-alpine
      environment: # environment variables for primary container
        POSTGRES_USER: postgres
    working_directory: /go/src/github.com/the-gigi/delinkcious
    steps:
    - checkout
    - run:
        name: Get all dependencies
        command: |
          go get -v ./...
          go get -u github.com/onsi/ginkgo/ginkgo
          go get -u github.com/onsi/gomega/...
    - run:
        name: Test everything
        command: ginkgo -r -race -failFast -progress
    - setup_remote_docker:
        docker_layer_caching: true
    - run:
        name: build and push Docker images
        shell: /bin/bash
        command: |
          chmod +x ./build.sh
          ./build.sh

让我们分开来理解发生了什么。第一部分指定了构建作业,下面是必要的 Docker 镜像(golangpostgres)及其环境。然后,我们有工作目录,build命令应该在其中执行:

version: 2
jobs:
 build:
 docker:
 - image: circleci/golang:1.11
 - image: circleci/postgres:9.6-alpine
      environment: # environment variables for primary container
        POSTGRES_USER: postgres
    working_directory: /go/src/github.com/the-gigi/delinkcious

下一部分是构建步骤。第一步只是检出。在 CircleCI UI 中,我将项目与 Delinkcious GitHub 存储库关联起来,以便它知道从哪里检出。如果存储库不是公共的,那么您还需要提供访问令牌。第二步是一个run命令,用于获取 Delinkcious 的所有 Go 依赖项:

steps:
- checkout
- run:
    name: Get all dependencies
    command: |
      go get -v ./...
      go get -u github.com/onsi/ginkgo/ginkgo
      go get -u github.com/onsi/gomega/...

我必须显式地go get ginkgo框架和gomega库,因为它们是使用 Golang 点符号导入的,这使它们对go get ./...不可见。

一旦我们有了所有的依赖,我们就可以运行测试。在这种情况下,我正在使用ginkgo测试框架:

- run:
    name: Test everything
    command: ginkgo -r -race -failFast -progress

下一部分是构建和推送 Docker 镜像的地方。由于它需要访问 Docker 守护程序,因此需要通过setup_remote_docker步骤进行特殊设置。docker_layer_caching选项用于通过重用先前的层使一切更高效和更快。实际的构建和推送由build.sh脚本处理,我们将在下一部分进行查看。请注意,我确保通过chmod +x是可执行的:

- setup_remote_docker:
    docker_layer_caching: true
- run:
    name: build and push Docker images
    shell: /bin/bash
    command: |
      chmod +x ./build.sh
      ./build.sh

我在这里只是浅尝辄止。CircleCI 还有更多功能,包括用于可重用配置、工作流、触发器和构件的 orbs。

理解 build.sh 脚本

build.sh脚本可在github.com/the-gigi/delinkcious/blob/master/build.sh找到。

让我们逐步检查它。我们将在这里遵循几个最佳实践。首先,最好在脚本中添加一个 shebang,其中包含将执行您的脚本的二进制文件的路径 - 也就是说,如果您知道它的位置。如果您尝试编写一个可以在许多不同平台上运行的跨平台脚本,您可能需要依赖路径或其他技术。set -eo pipefail将在任何出现问题时立即失败(即使在管道的中间)。

这在生产环境中是强烈推荐的:

#!/bin/bash

set -eo pipefail

接下来的几行只是为目录和 Docker 镜像的标记设置了一些变量。有两个标记:STABLE_TABTAGSTABLE_TAG标记具有主要版本和次要版本,并且在每次构建中不会更改。TAG包括 CircleCI 提供的CIRCLE_BUILD_NUM,并且在每次构建中递增。这意味着TAG始终是唯一的。这被认为是标记和版本化镜像的最佳实践:

IMAGE_PREFIX='g1g1'
STABLE_TAG='0.2'

TAG="${STABLE_TAG}.${CIRCLE_BUILD_NUM}"
ROOT_DIR="$(pwd)"
SVC_DIR="${ROOT_DIR}/svc"

接下来,我们进入svc目录,这是所有服务的父目录,并使用在 CircleCI 项目中设置的环境变量登录到 DockerHub。

cd $SVC_DIR
docker login -u $DOCKERHUB_USERNAME -p $DOCKERHUB_PASSWORD

现在,我们来到了主要事件。脚本遍历svc目录的所有子目录,寻找Dockerfile。如果找到Dockerfile,它会构建一个镜像,使用服务名称和TAG以及STABLE_TAG的组合对其进行标记,最后将标记的镜像推送到注册表:

cd "${SVC_DIR}/$svc"
    if [[ ! -f Dockerfile ]]; then
        continue
    fi
    UNTAGGED_IMAGE=$(echo "${IMAGE_PREFIX}/delinkcious-${svc}" | sed -e 's/_/-/g' -e 's/-service//g')
    STABLE_IMAGE="${UNTAGGED_IMAGE}:${STABLE_TAG}"
    IMAGE="${UNTAGGED_IMAGE}:${TAG}"
    docker build -t "$IMAGE" .
    docker tag "${IMAGE}" "${STABLE_IMAGE}"
    docker push "${IMAGE}"
    docker push "${STABLE_IMAGE}"
done
cd $ROOT_DIR

使用多阶段 Dockerfile 对 Go 服务进行 Docker 化

在微服务系统中构建的 Docker 镜像非常重要。您将构建许多镜像,并且每个镜像都会构建多次。这些镜像也会在网络上传输,并且它们是攻击者的目标。考虑到这一点,构建具有以下属性的镜像是有意义的:

  • 轻量级

  • 提供最小的攻击面

这可以通过使用适当的基础镜像来实现。例如,由于其小的占用空间,Alpine 非常受欢迎。然而,没有什么能比得上 scratch 基础镜像。对于基于 Go 的微服务,您可以创建一个只包含服务二进制文件的镜像。让我们继续剥离洋葱,看看其中一个服务的 Dockerfile。剧透警告:它们几乎完全相同,只是在服务名称方面有所不同。

你可以在github.com/the-gigi/delinkcious/blob/master/svc/link_service/Dockerfile找到link_serviceDockerfile

我们在这里使用了多阶段的Dockerfile。我们将使用标准的 Golang 镜像构建镜像。最后一行中的神秘魔法是构建一个真正静态和自包含的 Golang 二进制文件所需的内容,它不需要动态运行时库:

FROM golang:1.11 AS builder
ADD ./main.go main.go
ADD ./service service
# Fetch dependencies
RUN go get -d -v

# Build image as a truly static Go binary
RUN CGO_ENABLED=0 GOOS=linux go build -o /link_service -a -tags netgo -ldflags '-s -w' .

然后我们将最终的二进制文件复制到一个基于 scratch 的镜像中,并创建尽可能小和最安全的镜像。我们暴露了7070端口,这是服务监听的端口:

FROM scratch
MAINTAINER Gigi Sayfan <the.gigi@gmail.com>
COPY --from=builder /link_service /app/link_service
EXPOSE 7070
ENTRYPOINT ["/app/link_service"]

探索 CircleCI UI

CircleCI 有一个非常友好的 UI。在这里,您可以设置各种项目设置,探索您的构建,并深入到特定的构建中。请记住,我们使用了 monorepo 方法,并且在build.sh文件中,我们负责构建多个服务。从 CircleCI 的角度来看,Delinkcious 是一个单一的连贯项目。这是 Delinkcious 项目的视图,显示了最近的构建:

让我们深入研究一下成功的构建。一切都很好,一切都是绿色的:

您甚至可以展开任何步骤并检查控制台输出。这是测试阶段的输出:

这很酷,但当事情出错时,你需要弄清楚原因时,它甚至更有用。例如,有一次,我试图将build.sh脚本隐藏在.circleci目录中,紧挨着config.yaml文件,但它没有被添加到 Docker 上下文中,并产生了以下错误:

考虑未来的改进

Dockerfile 几乎是重复的,并且有一些可以参数化的假设。在 Kubernetes 生态系统中,有一些有趣的项目可以帮助解决这些问题。一些解决方案是用于本地开发的,可以自动生成必要的 Dockerfile,而其他一些则更加针对一致和统一的生产设置。我们将在后面的章节中研究其中一些。在本章中,我希望保持简单,避免用太多选项和间接层来压倒你。

另一个改进的机会是仅测试和构建已更改的服务(或其依赖项已更改)。目前,build.sh 脚本总是构建所有图像,并使用相同的标签对它们进行标记。

到目前为止,我们已经使用 CircleCI 和 Docker 构建了完整的 CI 管道。下一阶段是设置 Argo CD 作为持续交付管道。

为 Delinkcious 设置持续交付

在我们掌握了 CircleCI 中的持续集成之后,我们可以将注意力转向持续交付。首先,我们将看看将 Delinkcious 微服务部署到 Kubernetes 集群需要什么,然后我们将研究 Argo CD 本身,最后,我们将通过 Argo CD 为 Delinkcious 设置完整的持续交付。

部署 Delinkcious 微服务

每个 Delinkcious 微服务在其 k8s 子目录中定义了一组 Kubernetes 资源的 YAML 清单。这是 link 服务的 k8s 目录:

]$ tree k8s
k8s
├── db.yaml
└── link_manager.yaml

link_manager.yaml 文件包含两个资源:Kubernetes 部署和 Kubernetes 服务。Kubernetes 部署如下:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: link-manager
  labels:
    svc: link
    app: manager
spec:
  replicas: 1
  selector:
    matchLabels:
      svc: link
      app: manager
  template:
    metadata:
      labels:
        svc: link
        app: manager
    spec:
      containers:
      - name: link-manager
        image: g1g1/delinkcious-link:0.2
        ports:
        - containerPort: 8080

Kubernetes 服务如下:

apiVersion: v1
kind: Service
metadata:
  name: link-manager
spec:
  ports:
  - port:  8080
  selector:
    svc: link
    app: manager

db.yaml 文件描述了 link 服务用于持久化其状态的数据库。可以通过将 k8s 目录传递给 kubectl apply 来一次性部署两者:

$ kubectl apply -f k8s
deployment.apps "link-db" created
service "link-db" created
deployment.apps "link-manager" created
service "link-manager" created

kubectl create 和 kubectl apply 之间的主要区别是,如果资源已经存在,create 将返回错误。

使用 kubectl 从命令行部署很好,但我们的目标是自动化这个过程。让我们来了解一下。

理解 Argo CD

Argo CD 是 Kubernetes 的开源持续交付解决方案。它由 Intuit 创建,并被包括 Google、NVIDIA、Datadog 和 Adobe 在内的许多其他公司采用。它具有一系列令人印象深刻的功能,如下所示:

  • 将应用程序自动部署到特定目标环境

  • CLI 和 Web 可视化应用程序以及所需状态和实际状态之间的差异

  • 支持高级部署模式的钩子(蓝/绿和金丝雀)

  • 支持多个配置管理工具(普通 YAML、ksonnet、kustomize、Helm 等)

  • 对所有部署的应用程序进行持续监控

  • 手动或自动将应用程序同步到所需状态

  • 回滚到 Git 存储库中提交的任何应用程序状态

  • 对应用程序的所有组件进行健康评估

  • SSO 集成

  • GitOps webhook integration (GitHub, GitLab, and BitBucket)

  • 用于与 CI 流水线集成的服务帐户/访问密钥管理

  • 应用事件和 API 调用的审计跟踪

Argo CD 是建立在 Argo 上的

Argo CD 是一个专门的 CD 流水线,但它是建立在稳固的 Argo 工作流引擎之上的。我非常喜欢这种分层方法,您可以在这个坚实的通用基础上构建具有 CD 特定功能和能力的工作流程。

Argo CD 利用 GitOps

Argo CD 遵循 GitOps 方法。基本原则是您系统的状态存储在 Git 中。Argo CD 通过检查 Git 差异并使用 Git 基元来回滚和协调实时状态,来管理您的实时状态与期望状态。

开始使用 Argo CD

Argo CD 遵循最佳实践,并期望在 Kubernetes 集群上的专用命名空间中安装:

$ kubectl create namespace argocd
$ kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml

让我们看看创建了什么。 Argo CD 安装了四种类型的对象:pod、service、deployment 和 replica set。以下是 pod:

$ kubectl get all -n argocd NAME                                        READY  STATUS RESTARTS  AGE
pod/argocd-application-controller-7c5cf86b76-2cp4z 1/1   Running  1  1m
pod/argocd-repo-server-74f4b4845-hxzw7             1/1   Running  0  1m
pod/argocd-server-9fc58bc5d-cjc95                  1/1   Running  0  1m
pod/dex-server-8fdd8bb69-7dlcj                     1/1   Running  0  1m

以下是服务:

NAME                                  TYPE        CLUSTER-IP       EXTERNAL-IP  PORT(S) 
service/argocd-application-controller ClusterIP   10.106.22.145    <none>       8083/TCP 
service/argocd-metrics                ClusterIP   10.104.1.83      <none>       8082/TCP 
service/argocd-repo-server            ClusterIP   10.99.83.118     <none>       8081/TCP 
service/argocd-server                 ClusterIP   10.103.35.4      <none>       80/TCP,443/TCP 
service/dex-server                    ClusterIP   10.110.209.247   <none>       5556/TCP,5557/TCP 

以下是部署:


NAME                                            DESIRED   CURRENT   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/argocd-application-controller   1         1         1            1           1m
deployment.apps/argocd-repo-server              1         1         1            1           1m
deployment.apps/argocd-server                   1         1         1            1           1m
deployment.apps/dex-server                      1         1         1            1           1m

最后,以下是副本集:

NAME                                                       DESIRED   CURRENT   READY     AGE
replicaset.apps/argocd-application-controller-7c5cf86b76   1         1         1         1m
replicaset.apps/argocd-repo-server-74f4b4845               1         1         1         1m
replicaset.apps/argocd-server-9fc58bc5d                    1         1         1         1m
replicaset.apps/dex-server-8fdd8bb69                       1         1         1         1m

然而,Argo CD 还安装了两个自定义资源定义CRD):

$ kubectl get crd
NAME                       AGE
applications.argoproj.io   7d
appprojects.argoproj.io    7d

CRD 允许各种项目扩展 Kubernetes API 并添加自己的域对象,以及监视它们和其他 Kubernetes 资源的控制器。Argo CD 将应用程序和项目的概念添加到 Kubernetes 的世界中。很快,您将看到它们如何集成,以实现内置 Kubernetes 资源(如部署、服务和 pod)的持续交付目的。让我们开始吧:

  1. 安装 Argo CD CLI:
$ brew install argoproj/tap/argocd
  1. 端口转发以访问 Argo CD 服务器:
$ kubectl port-forward -n argocd svc/argocd-server 8080:443
  1. 管理员用户的初始密码是 Argo CD 服务器的名称:
$ kubectl get pods -n argocd -l app.kubernetes.io/name=argocd-server -o name | cut -d'/' -f 2
  1. 登录到服务器:
$ argocd login :8080
  1. 如果它抱怨登录不安全,只需按y确认:
WARNING: server certificate had error: tls: either ServerName or InsecureSkipVerify must be specified in the tls.Config. Proceed insecurely (y/n)?
  1. 或者,要跳过警告,请输入以下内容:
argocd login --insecure :8080

然后,您可以更改密码。

  1. 如果您将密码存储在环境变量中(例如ARGOCD_PASSWORD),那么您可以使用一行命令登录,而无需进一步提问:
argocd login --insecure --username admin --password $ARGOCD_PASSWORD :8080

配置 Argo CD

记得端口转发到 argocd-server:

$ kubectl port-forward -n argocd svc/argocd-server 8080:443

然后,您只需浏览到https://localhost:8080并提供admin用户的密码即可登录:

配置 Argo CD 是一种乐趣。它的用户界面非常愉快且易于使用。它支持 Delinkcious monorepo,而且没有假设每个 Git 存储库只包含一个应用程序或项目。

它会要求您选择一个 Git 存储库以监视更改,一个 Kubernetes 集群(默认为安装在其上的集群),然后它将尝试检测存储库中的清单。Argo CD 支持多种清单格式和模板,例如 Helm、ksonnet 和 kustomize。我们将在本书的后面介绍其中一些优秀的工具。为了保持简单,我们已经为每个应用程序配置了包含其原始k8s YAML 清单的目录,Argo CD 也支持这些清单。

说到做到,Argo CD 已经准备就绪!

使用同步策略

默认情况下,Argo CD 会检测应用程序的清单是否不同步,但不会自动同步。这是一个很好的默认设置。在某些情况下,需要在专用环境中运行更多测试,然后再将更改推送到生产环境。在其他情况下,必须有人参与。然而,在许多其他情况下,可以立即自动部署更改到集群中,而无需人为干预。Argo CD 遵循 GitOps 的事实也使得非常容易将同步回任何先前的版本(包括最后一个)。

对于 Delinkcious,我选择了自动同步,因为它是一个演示项目,部署错误版本的后果是可以忽略不计的。这可以在 UI 中或从 CLI 中完成:

argocd app set <APPNAME> --sync-policy automated

自动同步策略不能保证应用程序始终处于同步状态。自动同步过程受到一些限制,具体如下:

  • 处于错误状态的应用程序将不会尝试自动同步。

  • Argo CD 将仅针对特定提交 SHA 和参数尝试一次自动同步。

  • 如果由于任何原因自动同步失败,它将不会再次尝试。

  • 您无法使用自动同步回滚应用程序。

在所有这些情况下,您要么必须对清单进行更改以触发另一个自动同步,要么手动同步。要回滚(或者一般地,同步到先前的版本),您必须关闭自动同步。

Argo CD 在部署时提供了另一种修剪资源的策略。当现有资源不再存在于 Git 中时,默认情况下 Argo CD 不会将其删除。这是一种安全机制,用于避免在编辑 Kubernetes 清单时出现错误时破坏关键资源。但是,如果您知道自己在做什么(例如,对于无状态应用程序),您可以打开自动修剪:

argocd app set <APPNAME> --auto-prune

探索 Argo CD

现在我们已经登录并配置了 Argo CD,让我们稍微探索一下。我真的很喜欢 UI,但如果您想以编程方式访问它,也可以通过命令行或 REST API 完成所有操作。

我已经使用三个 Delinkcious 微服务配置了 Argo CD。在 Argo CD 中,每个服务都被视为一个应用程序。让我们来看看应用程序视图:

这里有一些有趣的东西。让我们来谈谈每一个:

  • 项目是用于分组应用程序的 Argo CD 概念。

  • 命名空间是应用程序应安装的 Kubernetes 命名空间。

  • 集群是 Kubernetes 集群,即https://kubernetes.default.svc,这是安装了 Argo CD 的集群。

  • 状态告诉您当前应用程序是否与其 Git 存储库中的 YAML 清单同步。

  • 健康状态告诉您应用程序是否正常。

  • 存储库是应用程序的 Git 存储库。

  • 路径是存储库中k8s YAML 清单所在的相对路径(Argo CD 监视此目录以进行更改)。

以下是您从argocd CLI 中获得的内容:

$ argocd app list
NAME                  CLUSTER                         NAMESPACE  PROJECT  STATUS     HEALTH   SYNCPOLICY  CONDITIONS
link-manager          https://kubernetes.default.svc  default    default  OutOfSync  Healthy  Auto-Prune  <none>
social-graph-manager  https://kubernetes.default.svc  default    default  Synced     Healthy  Auto-Prune  <none>
user-manager          https://kubernetes.default.svc  default    default  Synced     Healthy  Auto-Prune  <none>

正如您可以在 UI 和 CLI 中看到的那样,link-manager不同步。我们可以通过从“ACTIONS”下拉菜单中选择“同步”来同步它。

或者,您也可以从 CLI 中执行此操作:

$ argocd app sync link-manager

UI 最酷的地方之一是它如何呈现与应用程序相关的所有k8s资源。点击social-graph-manager应用程序,我们会得到以下视图:

我们可以看到应用程序本身、服务、部署和 Pod,包括运行的 Pod 数量。这实际上是一个经过筛选的视图,如果我们愿意,我们可以将与每个部署相关的副本集和每个服务的端点添加到显示中。但是,大多数情况下这些都不是很有趣,因此 Argo CD 默认不显示它们。

我们可以点击一个服务,查看其信息的摘要,包括清单:

对于 Pods,我们甚至可以检查日志,如下面的截图所示,所有这些都可以在 Argo CD 的 UI 中轻松完成:

Argo CD 已经可以带您走很远。然而,它还有更多的提供,我们将在本书的后面深入探讨这些内容。

总结

在本章中,我们讨论了基于微服务的分布式系统的 CI/CD 流水线的重要性。我们审查了一些针对 Kubernetes 的 CI/CD 选项,并确定了使用 CircleCI 进行 CI 部分(代码更改|Docker 镜像)和 Argo CD 进行 CD 部分(k8s清单更改|部署的应用程序)的组合。

我们还介绍了使用多阶段构建构建 Docker 镜像的最佳实践,Postgres DB 的k8s YAML 清单,以及部署和服务k8s资源。然后,我们在集群中安装了 Argo CD,配置它来构建所有我们的微服务,并探索了 UI 和 CLI。在这一点上,您应该对 CI/CD 的概念以及其重要性有清晰的理解,各种解决方案的利弊以及如何为您的系统选择最佳选项。

然而,还有更多内容。在后面的章节中,我们将通过额外的测试、安全检查和高级多环境部署选项来改进我们的 CI/CD 流水线。

在下一章中,我们将把注意力转向配置我们的服务。配置是开发复杂系统的重要部分,需要大型团队开发、测试和部署。我们将探讨各种传统配置选项,如命令行参数、环境变量和配置文件,以及更动态的配置选项和 Kubernetes 的特殊配置功能。

进一步阅读

您可以参考以下来源,了解本章涵盖的更多信息:

第五章:使用 Kubernetes 配置微服务

在本章中,我们将进入微服务配置的实际和现实世界领域。配置是构建复杂分布式系统的重要组成部分。一般来说,配置涉及代码应该意识到的系统的任何方面,但并未编码在代码本身中。以下是本章将讨论的主题:

  • 配置到底是什么?

  • 以老式方式管理配置

  • 动态管理配置

  • 使用 Kubernetes 配置微服务

在本章结束时,您将对配置的价值有扎实的了解。您还将学会静态和动态配置软件的许多方法,以及 Kubernetes 提供的特殊配置选项(其中之一是其最佳功能)。您还将获得洞察力和知识,以从 Kubernetes 作为开发人员和运营商提供的灵活性和控制中受益。

技术要求

在本章中,我们将查看许多 Kubernetes 清单,并扩展 Delinkcious 的功能。不需要安装任何新东西。

代码

像往常一样,代码分为两个 Git 存储库:

配置到底是什么?

配置是一个非常重载的术语。让我们为我们的目的清晰地定义它:配置主要是指计算所需的操作数据。配置可能在不同的环境之间有所不同。以下是一些典型的配置项:

  • 服务发现

  • 支持测试

  • 特定于环境的元数据

  • 秘密

  • 第三方配置

  • 功能标志

  • 超时

  • 速率限制

  • 各种默认值

通常,处理输入数据的代码利用配置数据来控制计算的操作方面,而不是算法方面。有一些特殊情况,通过配置,您可以在运行时在不同的算法之间切换,但这已经涉及到灰色地带。让我们为我们的目的保持简单。

在考虑配置时,重要的是要考虑谁应该创建和更新配置数据。可能是代码的开发者,也可能不是。例如,速率限制可能由 DevOps 团队成员确定,但功能标志将由开发人员设置。此外,在不同的环境中,不同的人可能修改相同的值。通常在生产中会有最严格的限制。

配置和秘密

秘密是用于访问数据库和其他服务(内部和/或外部)的凭据。从技术上讲,它们是配置数据,但实际上,由于它们的敏感性,它们经常需要在静止时加密并进行更严格的控制。通常会将秘密存储和单独管理,而不是与常规配置分开。

在本章中,我们只考虑非敏感配置。在下一章中,我们将详细讨论秘密。Kubernetes 还在 API 级别将配置与秘密分开。

以老式方式管理配置

当我说老式方式时,我的意思是在 Kubernetes 之前的静态配置。但正如您将看到的,老式方式有时是最好的方式,而且通常也得到 Kubernetes 的良好支持。让我们来看看配置程序的各种方式,考虑它们的优缺点,以及何时适用。我们将在这里介绍的配置机制如下:

  • 无配置(约定优于配置)

  • 命令行参数

  • 环境变量

  • 配置文件

Delinkcious 主要是用 Go 实现的,但我们将使用不同的编程语言来演示配置选项,只是为了好玩和多样性。

约定优于配置

有时,您实际上不需要配置;程序可以只做出一些决定,对其进行文档记录,就这样。例如,输出文件的目录名称可以是可配置的,但程序可以决定它将输出,就这样。这种方法的好处是非常可预测的:您不必考虑配置,只需通过阅读程序代码,就可以准确知道它的功能和一切应该在哪里。运营商的工作量很少。缺点是,如果需要更多的灵活性,您就没有办法(例如,可能程序运行的卷上没有足够的空间)。

请注意,约定优于配置并不意味着根本没有配置。这意味着在使用约定时可以减少配置的数量。

这是一个小的 Rust 程序,它将斐波那契序列打印到屏幕上,直到 100。按照约定,它决定不会超过 100。您无法配置它以打印更多或更少的数字而不改变代码:

fn main() {
    let mut a: u8 = 0;
    let mut b: u8 = 1;
    println!("{}", a);
    while b <= 100 {
        println!("{}", b);
        b = a + b;
        a = b - a;
    }
}

Output:

0
1
1
2
3
5
8
13
21
34
55
89

命令行标志

命令行标志或参数是编程的重要组成部分。运行程序时,您提供参数,程序使用这些参数来配置自身。使用它们有利有弊:

  • 优点

  • 非常灵活

  • 熟悉并且在每种编程语言中都可用

  • 有关短选项和长选项的最佳实践已经建立

  • 与交互式使用文档配合良好

  • 缺点

  • 参数始终是字符串

  • 需要引用包含空格的参数

  • 难以处理多行参数

  • 命令行参数的数量限制

  • 每个参数的大小限制

命令行参数通常用于输入以及配置。输入和配置之间的界限有时可能有点模糊。在大多数情况下,这并不重要,但对于只想通过命令行参数将其输入传递给程序的用户来说,这可能会让他们感到困惑,因为他们会看到一大堆令人困惑的配置选项。

这是一个小的 Ruby 程序,它将斐波那契序列写入到一个作为命令行参数提供的数字。

if __FILE__ == $0
  limit = Integer(ARGV[0])
  a = 0
  b = 1
  puts a
  while b < limit
    puts b
    b = a + b
    a = b - a
  end
end

环境变量

环境变量是另一个受欢迎的选项。当您的程序在可能由另一个程序(或 shell 脚本)设置的环境中运行时,它们非常有用。环境变量通常从父环境继承。它们还用于运行交互式程序,当用户总是希望向程序提供相同的选项(或一组选项)时。与其一遍又一遍地输入带有相同选项的长命令行,不如设置一个环境变量(甚至可能在您的配置文件中)一次,然后无需参数运行程序。一个很好的例子是 AWS CLI,它允许您将许多配置选项指定为环境变量(例如,AWS_DEFAULT_REGIONAWS_PROFILE)。

这里有一个小的 Python 程序,它会写出斐波那契数列,直到一个作为环境变量提供的数字。请注意,FIB_LIMIT环境变量被读取为字符串,程序必须将其转换为整数。

import os

limit = int(os.environ['FIB_LIMIT'])
a = 0
b = 1
print(a)
while b < limit:
    print(b)
    b = a + b
    a = b - a

配置文件

配置文件在有大量配置数据时特别有用,尤其是当这些数据具有分层结构时。在大多数情况下,通过命令行参数或环境变量配置应用程序的选项太过于繁琐。配置文件还有另一个优点,就是可以链接多个配置文件。通常,应用程序会在搜索路径中查找配置文件,例如/etc/conf,然后是home目录,然后是当前目录。这提供了很大的灵活性,因为您可以拥有通用配置,同时还能够覆盖某些用户或运行时的部分配置。

配置文件非常棒!您应该考虑哪种格式最适合您的用例。有很多选择。配置文件格式会遵循趋势,每隔几年就会有新的亮点。让我们回顾一些旧格式,以及一些新格式。

INI 格式

INI 文件曾经在 Windows 上非常流行。INI 代表初始化。在八十年代,瞎折腾windows.inisystem.ini以使某些东西工作是非常常见的。格式本身非常简单,包括带有键-值对和注释的部分。这是一个简单的 INI 文件:

[section]
a=1
b=2

; here is a comment
[another_section]
c=3
d=4
e=5

Windows API 有用于读取和写入 INI 文件的函数,因此许多 Windows 应用程序将它们用作配置文件。

XML 格式

XML (www.w3.org/XML/)是 W3C 标准,在九十年代非常流行。它代表可扩展标记语言,用于一切:数据,文档,API(SOAP),当然还有配置文件。它非常冗长,它的主要特点是自我描述并包含自己的元数据。XML 有模式和许多建立在其之上的标准。有一段时间,人们认为它会取代 HTML(还记得 XHTML 吗?)。那都是过去了。这是一个样本 XML 配置文件:

<?xml version="1.0" encoding="UTF-8"?>
    <startminimized value="False">
  <width value="1024">
  <height value = "768">
  <dummy />
  <plugin>
    <name value="Show Warning Message Box">
    <dllfile value="foo.dll">
    <method value = "warning">
  </plugin>
  <plugin>
    <name value="Show Error Message Box">
    <dllfile value="foo.dll">
    <method value = "error">
  </plugin>
  <plugin>
    <name value="Get Random Number">
    <dllfile value="bar.dll">
        <method value = "random">
  </plugin>
</xml>

JSON 格式

JSON(json.org/)代表JavaScript 对象表示法。随着动态网络应用和 REST API 的增长,它变得越来越受欢迎。与 XML 相比,它的简洁性让人耳目一新,并迅速占领了行业。它的成名之处在于它可以一对一地转换为 JavaScript 对象。这是一个简单的 JSON 文件:

{
  "firstName": "John",
  "lastName": "Smith",
  "age": 25,
  "address": {
    "streetAddress": "21 2nd Street",
    "city": "New York",
    "state": "NY",
    "postalCode": "10021"
  },
  "phoneNumber": [
    {
      "type": "home",
      "number": "212 555-1234"
    },
    {
      "type": "fax",
      "number": "646 555-4567"
    }
  ],
  "gender": {
    "type": "male"
  }
}

我个人从来不喜欢 JSON 作为配置文件格式;它不支持注释,对数组末尾的额外逗号要求过于严格,将日期和时间序列化为 JSON 总是很麻烦。它也非常冗长,需要用引号、括号,并且需要转义许多字符(尽管它不像 XML 那样糟糕)。

YAML 格式

你在本书中已经看到了很多 YAML(yaml.org/),因为 Kubernetes 清单通常是以 YAML 编写的。YAML 是 JSON 的超集,但它还提供了一个更简洁的语法,非常易于阅读,以及更多的功能,比如引用、类型的自动检测和对齐多行值的支持。

这是一个具有比通常在普通 Kubernetes 清单中看到的更多花哨功能的 YAML 文件的示例:

# sequencer protocols for Laser eye surgery
---
- step:  &id001                  # defines anchor label &id001
    instrument:      Lasik 3000
    pulseEnergy:     5.4
    pulseDuration:   12
    repetition:      1000
    spotSize:        1mm

- step: &id002
    instrument:      Lasik 3000
    pulseEnergy:     5.0
    pulseDuration:   10
    repetition:      500
    spotSize:        2mm
- step: *id001                   # refers to the first step (with anchor &id001)
- step: *id002                   # refers to the second step
- step:
    <<: *id001
    spotSize: 2mm                # redefines just this key, refers rest from &id001
- step: *id002

YAML 不像 JSON 那样受欢迎,但它慢慢地积聚了动力。像 Kubernetes 和 AWS CloudFormation 这样的大型项目使用 YAML(以及 JSON,因为它是超集)作为它们的配置格式。CloudFormation 后来添加了对 YAML 的支持;Kubernetes 从 YAML 开始。

它目前是我最喜欢的配置文件格式;然而,YAML 有它的陷阱和批评,特别是当你使用一些更高级的功能时。

TOML 格式

进入 TOML(github.com/toml-lang/toml)—Tom's Obvious Minimal Language。TOML 就像是增强版的 INI 文件。它是所有格式中最不为人知的,但自从被 Rust 的包管理器 Cargo 使用以来,它开始获得动力。TOML 在表现形式上介于 JSON 和 YAML 之间。它支持自动检测的数据类型和注释,但它不像 YAML 那样强大。尽管如此,它是最容易被人类阅读和编写的。它支持嵌套,主要是通过点符号而不是缩进。

这是一个 TOML 文件的示例;看看它有多可读:

# This is how to comment in TOML.

title = "A TOML Example"

[owner]
name = "Gigi Sayfan"
dob = 1968-09-28T07:32:00-08:00 # First class dates

# Simple section with various data types
[kubernetes]
api_server = "192.168.1.1"
ports = [ 80, 443 ]
connection_max = 5000
enabled = true

# Nested section
[servers]

  # Indentation (tabs and/or spaces) is optional
  [servers.alpha]
  ip = "10.0.0.1"
  dc = "dc-1"

  [servers.beta]
  ip = "10.0.0.2"
  dc = "dc-2"

[clients]
data = [ ["gamma", "delta"], [1, 2] ]

# Line breaks are OK when inside arrays
hosts = [
  "alpha",
  "omega"
]

专有格式

一些应用程序只是提出了自己的格式。这是一个 Nginx web 服务器的示例配置文件:

user       www www;  ## Default: nobody
worker_processes  5;  ## Default: 1
error_log  logs/error.log;
pid        logs/nginx.pid;
worker_rlimit_nofile 8192;

events {
  worker_connections  4096;  ## Default: 1024
}

http {
  include    conf/mime.types;
  include    /etc/nginx/proxy.conf;
  include    /etc/nginx/fastcgi.conf;
  index    index.html index.htm index.php;

  default_type application/octet-stream;
  log_format   main '$remote_addr - $remote_user [$time_local]  $status '
    '"$request" $body_bytes_sent "$http_referer" '
    '"$http_user_agent" "$http_x_forwarded_for"';
  access_log   logs/access.log  main;
  sendfile     on;
  tcp_nopush   on;
  server_names_hash_bucket_size 128; # this seems to be required for some vhosts

  server { # php/fastcgi
    listen       80;
    server_name  domain1.com www.domain1.com;
    access_log   logs/domain1.access.log  main;
    root         html;

    location ~ \.php$ {
      fastcgi_pass   127.0.0.1:1025;
    }
  }
}

我不建议为您的应用程序发明另一个构思不周的配置格式。在 JSON、YAML 和 TOML 之间,您应该找到表达性、人类可读性和熟悉度之间的平衡点。此外,所有语言都有库来解析和组合这些熟悉的格式。

不要发明自己的配置格式!

混合配置和默认值

到目前为止,我们已经审查了主要的配置机制:

  • 约定优于配置

  • 命令行参数

  • 环境变量

  • 配置文件

这些机制并不是互斥的。许多应用程序将支持其中一些,甚至全部。很多时候,会有一个配置解析机制,其中配置文件有一个标准的名称和位置,但您仍然可以通过环境变量指定不同的配置文件,并且甚至可以通过命令行参数为特定运行覆盖甚至那个。你不必走得太远。Kubectl 是一个程序,默认情况下在$HOME/.kube中查找其配置文件;您可以通过KUBECONFIG环境变量指定不同的文件。您可以通过传递--config命令行标志为特定命令指定特殊的配置文件。

说到这一点,kubectl 也使用 YAML 作为其配置格式。这是我的 Minikube 配置文件:

$ cat ~/.kube/config
apiVersion: v1
clusters:
- cluster:
 certificate-authority: /Users/gigi.sayfan/.minikube/ca.crt
 server: https://192.168.99.121:8443
 name: minikube
contexts:
- context:
 cluster: minikube
 user: minikube
 name: minikube
current-context: minikube
kind: Config
preferences: {}
users:
- name: minikube
 user:
 client-certificate: /Users/gigi.sayfan/.minikube/client.crt
 client-key: /Users/gigi.sayfan/.minikube/client.key

Kubectl 支持在同一配置文件中的多个集群/上下文。您可以通过kubectl use-context在它们之间切换;然而,许多经常使用多个集群的人不喜欢将它们全部放在同一个配置文件中,而更喜欢为每个集群单独创建一个文件,然后通过KUBECONFIG环境变量或通过命令行传递--config来在它们之间切换。

十二要素应用程序配置

Heroku 是云平台即服务的先驱之一。2011 年,他们发布了用于构建 Web 应用程序的 12 要素方法论。这是一个相当坚实的方法,并且在当时非常创新。它也恰好是在 Heroku 上轻松部署应用程序的最佳方式。

对于我们的目的,他们网站最有趣的部分是配置部分,可以在12factor.net/config找到。

简而言之,他们建议 Web 服务和应用程序始终将配置存储在环境变量中。这是一个安全但有些有限的指导方针。这意味着每当配置更改时,服务都必须重新启动,并且受到环境变量的一般限制。

稍后,我们将看到 Kubernetes 如何支持将配置作为环境变量和配置文件,以及一些特殊的变化。但首先,让我们讨论动态配置。

动态管理配置

到目前为止,我们讨论的配置选项都是静态的。你必须重新启动,并且在某些情况下(比如使用嵌入式配置文件),重新部署你的服务来改变它的配置。当配置改变时重新启动服务的好处是你不必担心新配置对内存状态和正在处理的请求的影响,因为你是从头开始的;然而,缺点是你会失去所有正在处理的请求(除非你使用优雅关闭)和任何已经预热的缓存或一次性初始化工作,这可能是相当大的。然而,你可以通过使用滚动更新和蓝绿部署来在一定程度上减轻这种情况。

理解动态配置

动态配置意味着服务保持运行,代码和内存状态保持不变,但它可以检测到配置已经改变,并根据新的配置动态调整其行为。从操作员的角度来看,当配置需要改变时,他们只需更新中央配置存储,而不需要强制重新启动/部署代码未改变的服务。

重要的是要理解这不是一个二元选择;一些配置可能是静态的,当它改变时,你必须重新启动服务,但其他一些配置项可能是动态的。

由于动态配置可以改变系统的行为方式,这种改变无法通过源代码控制来捕捉,因此保留更改历史和审计是一个常见的做法。让我们看看什么时候应该使用动态配置,什么时候不应该使用!

动态配置何时有用?

动态配置在以下情况下很有用:

  • 如果你只有一个服务实例,那么重新启动意味着短暂的中断

  • 如果您有要快速切换的功能标志

  • 如果您的服务需要初始化或丢弃正在进行中的请求是昂贵的

  • 如果您的服务不支持高级部署策略,例如滚动更新,蓝绿色或金丝雀部署

  • 重新部署时,新的配置文件可能会从源代码控制中拉取未准备好部署的不相关代码更改

何时应避免动态配置?

然而,动态配置并非适用于所有情况。如果您想要完全安全,那么在配置更改时重新启动服务会使事情更容易理解和分析。也就是说,微服务通常足够简单,您可以理解配置更改的所有影响。

在以下情况下,最好避免动态配置:

  • 受监管的服务,配置更改必须经过审查和批准流程

  • 关键服务,静态配置的低风险胜过动态配置的任何好处

  • 动态配置机制不存在,而且好处不足以证明开发这样的机制是合理的

  • 现有系统具有大量服务,迁移到动态配置的好处不足以证明成本

  • 高级部署策略提供了动态配置的好处,静态配置和重新启动/重新部署

  • 跟踪和审计配置更改的复杂性太高

远程配置存储

动态配置的一个选项是远程配置存储。所有服务实例可以定期查询配置存储,检查配置是否已更改,并在更改时读取新配置。可能的选项包括以下内容:

  • 关系数据库(Postgres,MySQL)

  • 键-值存储(Etcd,Redis)

  • 共享文件系统(NFS,EFS)

总的来说,如果您的所有/大多数服务已经使用特定类型的存储,通常将动态配置放在那里会更简单。反模式是将配置存储在与服务持久存储相同的存储中。问题在于配置将分布在多个数据存储中,而且一些配置更改是中心化的。跨所有服务管理、跟踪和审计配置更改将会很困难。

远程配置服务

更高级的方法是创建一个专门的配置服务。此服务的目的是为所有配置需求提供一站式服务。每个服务将仅访问其配置,并且很容易为每个配置更改实现控制机制。配置服务的缺点是您需要构建它并进行维护。如果不小心的话,它也可能成为单点故障(SPOF)。

到目前为止,我们已经非常详细地介绍了系统配置的许多选项。现在,是时候研究一下 Kubernetes 带来了什么了。

使用 Kubernetes 配置微服务

使用 Kubernetes 或任何容器编排器,您有各种有趣的配置选项。Kubernetes 为您运行容器。无法为特定运行设置不同的环境选项和命令行参数,因为 Kubernetes 决定何时何地运行容器。您可以将配置文件嵌入到 Docker 镜像中或更改其运行的命令;但是,这意味着为每个配置更改烘烤新镜像并将其部署到集群中。这并不是世界末日,但这是一个繁重的操作。您还可以使用我之前提到的动态配置选项:

  • 远程配置存储

  • 远程配置服务

但是,当涉及到动态配置时,Kubernetes 有一些非常巧妙的技巧。最创新的动态配置机制是 ConfigMaps。您还可以使用自定义资源更加复杂。让我们深入了解一下。

使用 Kubernetes ConfigMaps

ConfigMaps 是由 Kubernetes 每个命名空间管理的 Kubernetes 资源,并且可以被任何 pod 或容器引用。这是link-manager服务的 ConfigMap:

apiVersion: v1
kind: ConfigMap
metadata:
  name: link-service-config
  namespace: default
data:
  MAX_LINKS_PER_USER: "10"
  PORT: "8080"

link-manager部署资源通过使用envFrom键将其导入到 pod 中:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: link-manager
  labels:
    svc: link
    app: manager
spec:
  replicas: 1
  selector:
    matchLabels:
      svc: link
      app: manager
  template:
    metadata:
      labels:
        svc: link
        app: manager
    spec:
      containers:
      - name: link-manager
        image: g1g1/delinkcious-link:0.2
        ports:
        - containerPort: 8080
      envFrom:
      - configMapRef:
          name: link-manager-config

这样做的效果是,当link-manager服务运行时,ConfigMap 的data部分中的键值对将被投影为环境变量:

MAX_LINKS_PER_PAGE=10
PORT=9090

让我们看看 Argo CD 如何可视化link-manager服务具有 ConfigMap。请注意名为link-service-config的顶部框:

您甚至可以通过单击 ConfigMap 框从 Argo CD UI 中深入检查 ConfigMap 本身。非常漂亮。

请注意,由于 ConfigMap 作为环境变量被消耗,这是静态配置。如果你想改变其中任何内容,你需要重新启动服务。在 Kubernetes 中,可以通过几种方式来实现:

  • 杀死 pod(部署的副本集将创建新的 pod)

  • 删除并重新创建部署(这有相同的效果,但不需要显式杀死 pod)

  • 应用其他更改并重新部署

让我们看看代码如何使用它。这段代码可以在svc/link_manager/service/link_manager_service.go找到:

port := os.Getenv("PORT")
if port == "" {
   port = "8080"
}

maxLinksPerUserStr := os.Getenv("MAX_LINKS_PER_USER")
if maxLinksPerUserStr == "" {
   maxLinksPerUserStr = "10"
}

os.Getenv()标准库函数从环境中获取PORTMAX_LINKS_PER_USER。这很棒,因为它允许我们在 Kubernetes 集群之外测试服务,并正确配置它。例如,链接服务端到端测试——专为 Kubernetes 之外的本地测试设计——在启动社交图管理器和link-manager服务之前设置环境变量:

func runLinkService(ctx context.Context) {
   // Set environment
   err := os.Setenv("PORT", "8080")
   check(err)

   err = os.Setenv("MAX_LINKS_PER_USER", "10")
   check(err)

   runService(ctx, ".", "link_service")
}

func runSocialGraphService(ctx context.Context) {
   err := os.Setenv("PORT", "9090")
   check(err)

   runService(ctx, "../social_graph_service", "social_graph_service")
}

现在我们已经看过 Delinkcious 如何使用 ConfigMaps,让我们继续进行 ConfigMaps 的工作细节。

创建和管理 ConfigMaps

Kubernetes 提供了多种创建 ConfigMaps 的方法:

  • 从命令行值

  • 从一个或多个文件

  • 从整个目录

  • 通过直接创建 ConfigMap YAML 清单

最后,所有的 ConfigMaps 都是一组键值对。键和值取决于创建 ConfigMap 的方法。在玩 ConfigMaps 时,我发现使用--dry-run标志很有用,这样我就可以在实际创建之前看到将要创建的 ConfigMap。让我们看一些例子。以下是如何从命令行参数创建 ConfigMap:

$ kubectl create configmap test --dry-run --from-literal=a=1 --from-literal=b=2 -o yaml
apiVersion: v1
data:
 a: "1"
 b: "2"
kind: ConfigMap
metadata:
 creationTimestamp: null
 name: test

这种方法主要用于玩转 ConfigMaps。您必须使用繁琐的--from-literal参数逐个指定每个配置项。

从文件创建 ConfigMap 是一种更可行的方法。它与 GitOps 概念很好地配合,您可以保留用于创建 ConfigMaps 的源配置文件的历史记录。我们可以创建一个非常简单的名为comics.yaml的 YAML 文件:

superhero: Doctor Strange
villain: Thanos

接下来,让我们使用以下命令从这个文件创建一个 ConfigMap(好吧,只是一个干燥的run):

$ kubectl create configmap file-config --dry-run --from-file comics.yaml -o yaml

apiVersion: v1
data:
 comics.yaml: |+
 superhero: Doctor Strange
 villain: Thanos

kind: ConfigMap
metadata:
 creationTimestamp: null
 name: file-config

有趣的是,文件的整个内容都映射到一个键:comics.yaml。值是文件的整个内容。在 YAML 中,|+表示以下的多行块是一个值。如果我们添加额外的--from-file参数,那么每个文件将在 ConfigMap 中有自己的键。同样,如果--from-file的参数是一个目录,那么目录中的每个文件都将成为 ConfigMap 中的一个键。

最后,让我们看一个手动构建的 ConfigMap。这并不难做到:只需在data部分下添加一堆键值对即可:

apiVersion: v1
kind: ConfigMap
metadata:
  name: env-config
  namespace: default
data:
  SUPERHERO: Superman
  VILLAIN: Lex Luthor

在这里,我们创建了专门的SUPERHEROVILLAIN键。

让我们看看 pod 如何消耗这些 ConfigMap。pod 从env-config ConfigMap 中获取其环境。它执行一个命令,监视SUPERHEROVILLAIN环境变量的值,并且每两秒钟回显当前值:

apiVersion: v1
kind: Pod
metadata:
  name: some-pod
spec:
  containers:
  - name: some-container
    image: busybox
    command: [ "/bin/sh", "-c", "watch 'echo \"superhero: $SUPERHERO villain: $VILLAIN\"'" ]
    envFrom:
    - configMapRef:
        name: env-config
  restartPolicy: Never

必须在启动 pod 之前创建 ConfigMap!

$ kubectl create -f env-config.yaml
configmap "env-config" created

$ kubectl create -f some-pod.yaml
pod "some-pod" created

kubectl 命令非常有用,可以用来检查输出:

$ kubectl logs -f some-pod

Every 2s: echo "superhero: $SUPERHERO villain: $VILLAIN"      2019-02-08 20:50:39

superhero: Superman villain: Lex Luthor

如预期的那样,值与 ConfigMap 匹配。但是如果我们更改 ConfigMap 会发生什么呢?kubectl edit configmap命令允许您在编辑器中更新现有的 ConfigMap:

$ kubectl edit configmap env-config

# Please edit the object below. Lines beginning with a '#' will be ignored,
# and an empty file will abort the edit. If an error occurs while saving this file will be
# reopened with the relevant failures.
#
apiVersion: v1
data:
 SUPERHERO: Batman
 VILLAIN: Joker
kind: ConfigMap
metadata:
 creationTimestamp: 2019-02-08T20:49:37Z
 name: env-config
 namespace: default
 resourceVersion: "874765"
 selfLink: /api/v1/namespaces/default/configmaps/env-config
 uid: 0c83dee5-2be3-11e9-9999-0800275914a6

configmap "env-config" edited

我们已经将超级英雄和反派更改为 Batman 和 Joker。让我们验证一下更改:

$ kubectl get configmap env-config -o yaml

apiVersion: v1
data:
 SUPERHERO: Batman
 VILLAIN: Joker
kind: ConfigMap
metadata:
 creationTimestamp: 2019-02-08T20:49:37Z
 name: env-config
 namespace: default
 resourceVersion: "875323"
 selfLink: /api/v1/namespaces/default/configmaps/env-config
 uid: 0c83dee5-2be3-11e9-9999-0800275914a6

新的值已经存在。让我们检查一下 pod 日志。什么都不应该改变,因为 pod 将 ConfigMap 作为环境变量消耗,而在 pod 运行时无法从外部更改:

$ kubectl logs -f some-pod

Every 2s: echo "superhero: $SUPERHERO villain: $VILLAIN"    2019-02-08 20:59:22

superhero: Superman villain: Lex Luthor

然而,如果我们删除并重新创建 pod,情况就不同了:

$ kubectl delete -f some-pod.yaml
pod "some-pod" deleted

$ kubectl create -f some-pod.yaml
pod "some-pod" created

$ kubectl logs -f some-pod

Every 2s: echo "superhero: $SUPERHERO villain: $VILLAIN" 2019-02-08 21:45:47

superhero: Batman villain: Joker

我把最好的留到了最后。让我们看看一些动态配置的实际操作。名为some-other-pod的 pod 正在将名为file-config的 ConfigMap 作为文件进行消耗。首先,它创建了一个名为config-volume的卷,该卷从file-config ConfigMap 中获取数据。然后,这个卷被挂载到/etc/config中。正在运行的命令只是简单地监视/etc/config/comics文件:

apiVersion: v1
kind: Pod
metadata:
  name: some-other-pod
spec:
  containers:
  - name: some-container
    image: busybox
    command: [ "/bin/sh", "-c", "watch \"cat /etc/config/comics\"" ]
    volumeMounts:
    - name: config-volume
      mountPath: /etc/config
  volumes:
  - name: config-volume
    configMap:
      name: file-config
  restartPolicy: Never

这是file-config ConfigMap:

apiVersion: v1
kind: ConfigMap
metadata:
  name: file-config
  namespace: default
data:
  comics: |+
    superhero: Doctor Strange
    villain: Thanos

它有一个名为comics(文件名)的键,值是一个多行的 YAML 字符串,其中包含超级英雄和反派条目(Doctor StrangeThanos)。说到做到,ConfigMap data部分下的 comics 键的内容将被挂载到容器中作为/etc/config/comics文件。

让我们验证一下:

$ kubectl create -f file-config.yaml
configmap "file-config" created

$ kubectl create -f some-other-pod.yaml
pod "some-other-pod" created

$ kubectl logs -f some-other-pod

Every 2s: cat /etc/config/comics      2019-02-08 22:15:08

superhero: Doctor Strange
villain: Thanos

到目前为止,一切看起来都很好。现在是主要的吸引力。让我们将 ConfigMap 的内容更改为超级英雄神奇女侠和反派美杜莎。这次我们将使用kubectl apply命令,而不是删除和重新创建 ConfigMap。ConfigMap 被正确更新,但我们也会收到一个警告(可以忽略):

$ kubectl apply -f file-config.yaml
Warning: kubectl apply should be used on resource created by either kubectl create --save-config or kubectl apply
configmap "file-config" configured

$ kubectl get configmap file-config -o yaml
apiVersion: v1
data:
 comics: |+
 superhero: Super Woman
 villain: Medusa

kind: ConfigMap
metadata:
 annotations:
 kubectl.kubernetes.io/last-applied-configuration: |
 {"apiVersion":"v1","data":{"comics":"superhero: Super Woman\nvillain: Medusa\n\n"},"kind":"ConfigMap","metadata":{"annotations":{},"name":"file-config","namespace":"default"}}
 creationTimestamp: 2019-02-08T22:14:01Z
 name: file-config
 namespace: default
 resourceVersion: "881662"
 selfLink: /api/v1/namespaces/default/configmaps/file-config
 uid: d6e892f4-2bee-11e9-9999-0800275914a6

请注意前面的注释。有趣的是,它存储了最后一次应用的更改,这在数据中是可用的,而不是以前的值用于历史上下文。

现在,让我们再次检查日志,而不重新启动 pod!

$ kubectl logs -f some-other-pod

Every 2s: cat /etc/config/comics     2019-02-08 23:02:58

superhero: Super Woman
villain: Medusa

是的,这是一个巨大的成功!现在 pod 打印出了更新的配置信息,无需重新启动。

在本节中,我们演示了如何使用 ConfigMaps 作为文件挂载的动态配置。让我们看看当大规模系统的配置需求由多个团队在长时间内开发时,我们应该做些什么。

应用高级配置

对于有大量服务和大量配置的大规模系统,您可能希望有一些消耗多个 ConfigMaps 的服务。这与单个 ConfigMap 可能包含多个文件、目录和文字值的事实是分开的,可以任意组合。例如,每个服务可能有自己特定的配置,但也可能使用一些需要配置的共享库。在这种情况下,您可以为共享库和每个服务单独创建一个 ConfigMap。在这种情况下,服务将同时消耗它们自己的 ConfigMap 和共享库的 ConfigMap。

另一个常见的情况是针对不同环境(开发、暂存和生产)有不同的配置。由于在 Kubernetes 中,每个环境通常都有自己的命名空间,您需要在这里有创意。ConfigMaps 的作用域是它们的命名空间。这意味着即使您在各个环境中的配置是相同的,您仍然需要在每个命名空间中创建一个副本。有各种解决方案可以用来管理这种配置文件和 Kubernetes 清单的泛滥。我不会详细介绍这些内容,只是简单提一下一些更受欢迎的选项,没有特定顺序:

您还可以自己构建一些工具来执行此操作。在下一节中,我们将看另一种选择,这种选择非常酷,但更复杂——自定义资源。

Kubernetes 自定义资源

Kubernetes 是一个非常可扩展的平台。您可以将自己的资源添加到 Kubernetes API 中,并享受 API 机制的所有好处,包括 kubectl 支持来管理它们。是的,就是这么好。您需要做的第一件事是定义自定义资源,也称为 CRD。定义将指定 Kubernetes API 上的端点、版本、范围、种类以及与这种新类型的资源交互时使用的名称。

这里有一个超级英雄 CRD:

apiVersion: apiextensions.k8s.io/v1beta1
kind: CustomResourceDefinition
metadata:
  # name must match the spec fields below, and be in the form: <plural>.<group>
  name: superheros.example.org
spec:
  # group name to use for REST API: /apis/<group>/<version>
  group: example.org
  # list of versions supported by this CustomResourceDefinition
  versions:
  - name: v1
    # Each version can be enabled/disabled by Served flag.
    served: true
    # One and only one version must be marked as the storage version.
    storage: true
  # either Namespaced or Cluster
  scope: Cluster
  names:
    # plural name to be used in the URL: /apis/<group>/<version>/<plural>
    plural: superheros
    # singular name to be used as an alias on the CLI and for display
    singular: superhero
    # kind is normally the CamelCased singular type. Your resource manifests use this.
    kind: SuperHero
    # shortNames allow shorter string to match your resource on the CLI
    shortNames:
    - hr

自定义资源可以从所有命名空间中访问。当构建 URL 时,范围是相关的,并且在删除命名空间中的所有对象时(命名空间范围 CRD 将与其命名空间一起被删除)。

让我们创建一些超级英雄资源。antman超级英雄具有与超级英雄 CRD 中定义的相同的 API 版本和种类。它在metadata中有一个名字,而spec是完全开放的。你可以在那里定义任何字段。在这种情况下,字段是superpowersize

apiVersion: "example.org/v1"
kind: SuperHero
metadata:
  name: antman
spec:
  superpower: "can shrink"
  size: "tiny"

让我们来看看绿巨人。它非常相似,但在其spec中还有一个颜色字段:

apiVersion: "example.org/v1"
kind: SuperHero
metadata:
  name: hulk
spec:
  superpower: "super strong"
  size: "big"
  color: "green"

让我们从 CRD 本身开始创建整个团队:

$ kubectl create -f superheros-crd.yaml
customresourcedefinition.apiextensions.k8s.io "superheros.example.org" created

$ kubectl create -f antman.yaml
superhero.example.org "antman" created

$ kubectl create -f hulk.yaml
superhero.example.org "hulk" created

现在让我们用kubectl来检查它们。我们可以在这里使用hr的简称:

$ kubectl get hr
NAME               AGE
antman              5m
hulk                5m

我们还可以检查超级英雄的详细信息:

$ kubectl get superhero hulk -o yaml
apiVersion: example.org/v1
kind: SuperHero
metadata:
 creationTimestamp: 2019-02-09T09:58:32Z
 generation: 1
 name: hulk
 namespace: default
 resourceVersion: "932374"
 selfLink: /apis/example.org/v1/namespaces/default/superheros/hulk
 uid: 4256d27b-2c51-11e9-9999-0800275914a6
spec:
 color: green
 size: big
 superpower: super strong

这很酷,但自定义资源能做什么?很多。如果您考虑一下,您将获得一个带有 CLI 支持和可靠持久存储的免费 CRUD API。只需发明您的对象模型,并创建、获取、列出、更新和删除尽可能多的自定义资源。但它还可以更进一步:您可以拥有自己的控制器,监视您的自定义资源,并在需要时采取行动。这实际上就是 Argo CD 的工作原理,您可以从以下命令中看到:

$ kubectl get crd -n argocd
NAME                         AGE
applications.argoproj.io     20d
appprojects.argoproj.io      20d

这如何帮助配置?由于自定义资源在整个集群中可用,你可以将它们用于跨命名空间的共享配置。CRDs 可以作为集中的远程配置服务,正如我们在动态配置部分中讨论的那样,但你不需要自己实现任何东西。另一个选择是创建一个监视这些 CRDs 的控制器,然后自动将它们复制到适当的 ConfigMaps 中。在 Kubernetes 中,你只受到你的想象力的限制。最重要的是,对于管理配置是一项艰巨任务的大型复杂系统,Kubernetes 为你提供了扩展配置的工具。让我们把注意力转向配置的一个方面,这在其他系统上经常会引起很多困难——服务发现。

服务发现

Kubernetes 内置支持服务发现,无需在你的部分进行任何额外的工作。每个服务都有一个 endpoints 资源,Kubernetes 会及时更新该资源,其中包含该服务所有支持的 pod 的地址。以下是单节点 Minikube 集群的 endpoints。请注意,即使只有一个物理节点,每个 pod 都有自己的 IP 地址。这展示了 Kubernetes 的著名的扁平网络模型。只有 Kubernetes API 服务器有一个公共 IP 地址。

$ kubectl get endpoints
NAME                   ENDPOINTS             AGE
kubernetes             192.168.99.122:8443   27d
link-db                172.17.0.13:5432      16d
link-manager           172.17.0.10:8080      16d
social-graph-db        172.17.0.8:5432       26d
social-graph-manager   172.17.0.7:9090       19d
user-db                172.17.0.12:5432      18d
user-manager           172.17.0.9:7070       18d

通常,你不会直接处理 endpoints 资源。每个服务都会自动通过 DNS 和环境变量向集群中的其他服务公开。

如果你处理发现在 Kubernetes 集群之外运行的外部服务,那么你就得自己解决。一个好的方法可能是将它们添加到 ConfigMap 中,并在这些外部服务需要更改时进行更新。如果你需要管理访问这些外部服务的秘密凭据(这很可能),最好将它们放在 Kubernetes secrets 中,我们将在下一章中介绍。

总结

在本章中,我们讨论了与配置相关的一切,但不包括秘密管理。首先,我们考虑了经典配置,然后我们看了动态配置,重点是远程配置存储和远程配置服务。

接下来,我们讨论了 Kubernetes 特定的选项,特别是 ConfigMaps。我们介绍了 ConfigMap 可以被创建和管理的所有方式。我们还看到了一个 pod 可以如何使用 ConfigMap,可以作为环境变量(静态配置),也可以作为挂载卷中的配置文件,当相应的 ConfigMap 被操作员修改时,这些配置文件会自动更新。最后,我们看了更强大的选项,比如自定义资源,并讨论了服务发现这个特殊但非常重要的案例。到这一点,你应该对一般的配置有一个清晰的认识,并了解了在传统方式或 Kubernetes 特定方式下配置微服务的可用选项。

在下一章中,我们将看一下关键的安全主题。部署在 Kubernetes 集群中的基于微服务的系统通常提供基本服务并管理关键数据。在许多情况下,保护数据和系统本身是首要任务。Kubernetes 在遵循最佳实践时提供了多种机制来协助构建安全系统。

进一步阅读

以下是一些资源供您使用,以便您可以了解本章讨论的概念和机制的细节:

第六章:在 Kubernetes 上保护微服务

在本章中,我们将深入研究如何在 Kubernetes 上保护您的微服务。这是一个广泛的主题,我们将专注于对于在 Kubernetes 集群中构建和部署微服务的开发人员最相关的方面。您必须非常严格地处理安全问题,因为您的对手将积极尝试找到漏洞,渗透您的系统,访问敏感信息,运行僵尸网络,窃取您的数据,破坏您的数据,销毁您的数据,并使您的系统不可用。安全性应该设计到系统中,而不是作为事后的附加物。我们将通过涵盖一般安全原则和最佳实践来解决这个问题,然后深入探讨 Kubernetes 提供的安全机制。

在本章中,我们将涵盖以下主题:

  • 应用健全的安全原则

  • 用户帐户和服务帐户之间的区分

  • 使用 Kubernetes 管理机密

  • 使用 RBAC 管理权限

  • 通过身份验证、授权和准入控制访问

  • 通过使用安全最佳实践来加固 Kubernetes

技术要求

在本章中,我们将查看许多 Kubernetes 清单,并使 Delinkcious 更安全。没有必要安装任何新内容。

代码

代码分为两个 Git 存储库:

应用健全的安全原则

有许多通用原则。让我们回顾最重要的原则,并了解它们如何帮助防止攻击,使攻击变得更加困难,从而最小化任何攻击造成的损害,并帮助从这些攻击中恢复:

  • 深度防御:深度防御意味着多层和冗余的安全层。其目的是使攻击者难以破坏您的系统。多因素身份验证是一个很好的例子。您有用户名和密码,但您还必须输入发送到您手机的一次性代码。如果攻击者发现了您的凭据,但无法访问您的手机,他们将无法登录系统并造成破坏。深度防御有多个好处,例如:

  • 使您的系统更安全

  • 使攻破您的安全成本过高,以至于攻击者甚至不会尝试

  • 更好地保护免受非恶意错误

  • 最小权限原则:最小权限原则类似于间谍世界中著名的“需要知道基础”。您不能泄露您不知道的东西。您无法破坏您无权访问的东西。任何代理都可能被破坏。仅限制到必要的权限将在违规发生时最小化损害,并有助于审计、缓解和分析事件。

  • 最小化攻击面:这个原则非常明确。您的攻击面越小,保护起来就越容易。请记住以下几点:

  • 不要暴露您不需要的 API

  • 不要保留您不使用的数据

  • 不要提供执行相同任务的不同方式

最安全的代码是根本不写代码。这也是最高效和无 bug 的代码。非常谨慎地考虑要添加的每个新功能的业务价值。在迁移到一些新技术或系统时,请确保不要留下遗留物品。除了防止许多攻击向量外,当发生违规时,较小的攻击面将有助于集中调查并找到根本原因。

  • 最小化爆炸半径:假设您的系统将被破坏或可能已经被破坏。然而,威胁的级别是不同的。最小化爆炸半径意味着受损组件不能轻易接触其他组件并在系统中传播。这也意味着对这些受损组件可用的资源不超过应该在那里运行的合法工作负载的需求。

  • 不要相信任何人:以下是您不应该信任的实体的部分列表:

  • 您的用户

  • 您的合作伙伴

  • 供应商

  • 您的云服务提供商

  • 开源开发人员

  • 您的开发人员

  • 您的管理员

  • 你自己

  • 你的安全

当我们说不要相信时,我们并不是恶意的。每个人都是可犯错误的,诚实的错误可能会和有针对性的攻击一样有害。不要相信任何人原则的伟大之处在于你不必做出判断。最小信任的相同方法将帮助你预防和减轻错误和攻击。

  • 保守一点:林迪效应表明,对于一些不易腐烂的东西,它们存在的时间越长,你就可以期待它们存在的时间越长。例如,如果一家餐厅存在了 20 年,你可以期待它还会存在很多年,而一个刚刚开业的全新餐厅更有可能在短时间内关闭。这对软件和技术来说非常真实。最新的 JavaScript 框架可能只有短暂的寿命,但像 jQuery 这样的东西会存在很长时间。从安全的角度来看,使用更成熟和经过严峻考验的软件会有其他好处,其安全性经受了严峻考验。从别人的经验中学习往往更好。考虑以下事项:

  • 不要升级到最新和最好的(除非明确修复了安全漏洞)。

  • 更看重稳定性而不是能力。

  • 更看重简单性而不是强大性。

这与不要相信任何人原则相辅相成。不要相信新的闪亮东西,也不要相信你当前依赖的新版本。当然,微服务和 Kubernetes 是相对较新的技术,生态系统正在快速发展。在这种情况下,我假设你已经做出了决定,认为这些创新的整体好处和它们当前的状态已经足够成熟,可以进行建设。

  • 保持警惕:安全不是一劳永逸的事情。你必须积极地继续努力。以下是一些你应该执行的全球性持续活动和流程:

  • 定期打补丁。

  • 旋转你的秘密。

  • 使用短寿命的密钥、令牌和证书。

  • 跟进 CVEs。

  • 审计一切。

  • 测试你系统的安全性。

  • 做好准备:当不可避免的违规发生时,做好准备并确保你已经或正在做以下事情:

  • 建立一个事件管理协议。

  • 遵循你的协议。

  • 堵住漏洞。

  • 恢复系统安全。

  • 进行安全事件的事后分析。

  • 评估和学习。

  • 更新你的流程、工具和安全性以提高你的安全姿态。

  • 不要编写自己的加密算法:很多人对加密算法感到兴奋和/或失望,当强加密影响性能时。控制你的兴奋和/或失望。让专家来做加密。这比看起来要困难得多,风险也太高了。

既然我们清楚了良好安全性的一般原则,让我们来看看 Kubernetes 在安全方面提供了什么。

区分用户账户和服务账户

账户是 Kubernetes 中的一个核心概念。对 Kubernetes API 服务器的每个请求都必须来自一个特定的账户,API 服务器将在进行操作之前对其进行身份验证、授权和准入。有两种类型的账户:

  • 用户账户

  • 服务账户

让我们来检查两种账户类型,并了解它们之间的区别以及在何时适合使用每种类型。

用户账户

用户账户是为人类(集群管理员或开发人员)设计的,他们通常通过 kubectl 或以编程方式从外部操作 Kubernetes。最终用户不应该拥有 Kubernetes 用户账户,只能拥有应用级别的用户账户。这与 Kubernetes 无关。请记住,Kubernetes 会为您管理容器-它不知道容器内部发生了什么,也不知道您的应用实际在做什么。

您的用户凭据存储在~/.kube/config文件中。如果您正在使用多个集群,则您的~/.kube/config文件中可能有多个集群、用户和上下文。有些人喜欢为每个集群单独创建一个配置文件,并使用KUBECONFIG环境变量在它们之间切换。这取决于您。以下是我本地 Minikube 集群的配置文件:

apiVersion: v1
clusters:
- cluster:
    certificate-authority: /Users/gigi.sayfan/.minikube/ca.crt
    server: https://192.168.99.123:8443
  name: minikube
contexts:
- context:
    cluster: minikube
    user: minikube
  name: minikube
current-context: minikube
kind: Config
preferences: {}
users:
- name: minikube
  user:
    client-certificate: /Users/gigi.sayfan/.minikube/client.crt
    client-key: /Users/gigi.sayfan/.minikube/client.key

正如您在上面的代码块中所看到的,这是一个遵循典型 Kubernetes 资源约定的 YAML 文件,尽管它不是您可以在集群中创建的对象。请注意,一切都是复数形式:集群、上下文、用户。在这种情况下,只有一个集群和一个用户。但是,您可以创建多个上下文,这些上下文是集群和用户的组合,这样您就可以在同一个集群中拥有不同权限的多个用户,甚至在同一个 Minikube 配置文件中拥有多个集群。current-context确定了kubectl的每个操作的目标(使用哪个用户凭据访问哪个集群)。用户账户具有集群范围,这意味着我们可以访问任何命名空间中的资源。

服务账户

服务帐户是另一回事。每个 pod 都有一个与之关联的服务帐户,并且在该 pod 中运行的所有工作负载都使用该服务帐户作为其身份。服务帐户的范围限定为命名空间。当您创建一个 pod(直接或通过部署)时,可以指定一个服务帐户。如果创建 pod 而没有指定服务帐户,则使用命名空间的默认服务帐户。每个服务帐户都有一个与之关联的秘密,用于与 API 服务器通信。

以下代码块显示了默认命名空间中的默认服务帐户:

$ kubectl get sa default -o yaml
apiVersion: v1
kind: ServiceAccount
metadata:
 creationTimestamp: 2019-01-11T15:49:27Z
 name: default
 namespace: default
 resourceVersion: "325"
 selfLink: /api/v1/namespaces/default/serviceaccounts/default
 uid: 79e17169-15b8-11e9-8591-0800275914a6
secrets:
- name: default-token-td5tz

服务帐户可以有多个秘密。我们很快将讨论秘密。服务帐户允许在 pod 中运行的代码与 API 服务器通信。

您可以从/var/run/secrets/kubernetes.io/serviceaccount获取令牌和 CA 证书,然后通过授权标头传递这些凭据来构造REST HTTP请求。例如,以下代码块显示了在默认命名空间中列出 pod 的请求:

# TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
# CA_CERT=$(cat /var/run/secrets/kubernetes.io/serviceaccount/ca.crt)
# URL="https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT}"

# curl --cacert "$CERT" -H "Authorization: Bearer $TOKEN" "$URL/api/v1/namespaces/default/pods"
{
  "kind": "Status",
  "apiVersion": "v1",
  "metadata": {

  },
  "status": "Failure",
  "message": "pods is forbidden: User \"system:serviceaccount:default:default\" cannot list resource \"pods\" in API group \"\" in the namespace \"default\"",
  "reason": "Forbidden",
  "details": {
    "kind": "pods"
  },
  "code": 403
}

结果是 403 禁止。默认服务帐户不允许列出 pod,实际上它不允许做任何事情。在授权部分,我们将看到如何授予服务帐户权限。

如果您不喜欢手动构造 curl 请求,也可以通过客户端库以编程方式执行。我创建了一个基于 Python 的 Docker 镜像,其中包括 Kubernetes 的官方 Python 客户端库(github.com/kubernetes-client/python)以及一些其他好东西,如 vim、IPython 和 HTTPie。

这是构建镜像的 Dockerfile:

FROM python:3

RUN apt-get update -y
RUN apt-get install -y vim
RUN pip install kubernetes \
                httpie     \
                ipython

CMD bash

我将其上传到 DockerHub 作为g1g1/py-kube:0.2。现在,我们可以在集群中将其作为一个 pod 运行,并进行良好的故障排除或交互式探索会话:

$ kubectl run trouble -it --image=g1g1/py-kube:0.2 bash

执行上述命令将使您进入一个命令行提示符,您可以在其中使用 Python、IPython、HTTPie 以及可用的 Kubernetes Python 客户端包做任何您想做的事情。以下是我们如何从 Python 中列出默认命名空间中的 pod:

# ipython
Python 3.7.2 (default, Feb  6 2019, 12:04:03)
Type 'copyright', 'credits' or 'license' for more information
IPython 7.2.0 -- An enhanced Interactive Python. Type '?' for help.

In [1]: from kubernetes import client, config
In [2]: config.load_incluster_config()
In [3]: api = client.CoreV1Api()
In [4]: api.list_namespaced_pod(namespace='default')

结果将类似 - 一个 Python 异常 - 因为默认帐户被禁止列出 pod。请注意,如果您的 pod 不需要访问 API 服务器(非常常见),您可以通过设置automountServiceAccountToken: false来明确表示。

这可以在服务账户级别或 pod 规范中完成。这样,即使在以后有人或某物在您的控制之外添加了对服务账户的权限,由于没有挂载令牌,pod 将无法对 API 服务器进行身份验证,并且不会获得意外访问。Delinkcious 服务目前不需要访问 API 服务器,因此通过遵循最小权限原则,我们可以将其添加到部署中的规范中。

以下是如何为 LinkManager 创建服务账户(无法访问 API 服务器)并将其添加到部署中:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: link-manager
  automountServiceAccountToken: false
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: link-manager
  labels:
    svc: link
    app: manager
spec:
  replicas: 1
  selector:
    matchLabels:
      svc: link
      app: manager
  serviceAccountName: link-manager
...

在使用 RBAC 授予我们的服务账户超级权限之前,让我们回顾一下 Kubernetes 如何管理秘密。Kubernetes 默认情况下将秘密存储在 etcd 中。可以将 etcd 与第三方解决方案集成,但在本节中,我们将专注于原始的 Kubernetes。秘密应该在静止和传输中进行加密,etcd 自版本 3 以来就支持了这一点。

现在我们了解了 Kubernetes 中账户如何工作,让我们看看如何管理秘密。

使用 Kubernetes 管理秘密

在使用 RBAC 授予我们的服务账户超级权限之前,让我们回顾一下 Kubernetes 如何管理秘密。Kubernetes 默认情况下将秘密存储在 etcd (coreos.com/etcd/)中。Kubernetes 可以管理不同类型的秘密。让我们看看各种秘密类型,然后创建我们自己的秘密并将它们传递给容器。最后,我们将一起构建一个安全的 pod。

了解 Kubernetes 秘密的三种类型

有三种不同类型的秘密:

  • 服务账户 API 令牌(用于与 API 服务器通信的凭据)

  • 注册表秘密(用于从私有注册表中拉取图像的凭据)

  • 不透明秘密(Kubernetes 一无所知的您的秘密)

服务账户 API 令牌是每个服务账户内置的(除非您指定了automountServiceAccountToken: false)。这是link-manager的服务账户 API 令牌的秘密:

$ kubectl get secret link-manager-token-zgzff | grep link-manager-token
link-manager-token-zgzff   kubernetes.io/service-account-token  3   20h

pull secrets图像稍微复杂一些。不同的私有注册表行为不同,并且需要不同的秘密。此外,一些私有注册表要求您经常刷新令牌。让我们以 DockerHub 为例。DockerHub 默认情况下允许您拥有单个私有存储库。我将py-kube转换为私有存储库,如下截图所示:

我删除了本地 Docker 镜像。要拉取它,我需要创建一个注册表机密:

$ kubectl create secret docker-registry private-dockerhub \
 --docker-server=docker.io \
 --docker-username=g1g1 \
 --docker-password=$DOCKER_PASSWORD \
 --docker-email=$DOCKER_EMAIL
secret "private-dockerhub" created
$ kubectl get secret private-dockerhub
NAME                TYPE                             DATA      AGE
private-dockerhub   kubernetes.io/dockerconfigjson   1         16s

最后一种类型的机密是Opaque,是最有趣的机密类型。您可以将敏感信息存储在 Kubernetes 不会触及的不透明机密中。它只为您提供了一个强大且安全的机密存储库,并提供了一个用于创建、读取和更新这些机密的 API。您可以通过许多方式创建不透明机密,例如以下方式:

  • 从文字值

  • 从文件或目录

  • env文件(单独行中的键值对)

  • 使用kind创建一个 YAML 清单

这与 ConfigMaps 非常相似。现在,让我们创建一些机密。

创建您自己的机密

创建机密的最简单和最有用的方法之一是通过包含键值对的简单env文件:

a=1
b=2

我们可以使用-o yaml标志(YAML 输出格式)来创建一个机密,以查看创建了什么:

$ kubectl create secret generic generic-secrets --from-env-file=generic-secrets.txt -o yaml

apiVersion: v1
data:
 a: MQ==
 b: Mg==
kind: Secret
metadata:
 creationTimestamp: 2019-02-16T21:37:38Z
 name: generic-secrets
 namespace: default
 resourceVersion: "1207295"
 selfLink: /api/v1/namespaces/default/secrets/generic-secrets
 uid: 14e1db5c-3233-11e9-8e69-0800275914a6
type: Opaque

类型是Opaque,返回的值是 base64 编码的。要获取这些值并解码它们,您可以使用以下命令:

$ echo -n $(kubectl get secret generic-secrets -o jsonpath="{.data.a}") | base64 -D
1

jsonpath输出格式允许您深入到对象的特定部分。如果您喜欢,您也可以使用jqstedolan.github.io/jq/)。

请注意,机密不会被存储或传输;它们只是以 base-64 进行加密或编码,任何人都可以解码。当您使用您的用户帐户创建机密(或获取机密)时,您会得到解密后机密的 base-64 编码表示。但是,它在磁盘上是加密的,并且在传输过程中也是加密的,因为您是通过 HTTPS 与 Kubernetes API 服务器通信的。

现在我们已经了解了如何创建机密,我们将使它们可用于在容器中运行的工作负载。

将机密传递给容器

有许多方法可以将机密传递给容器,例如以下方法:

  • 您可以将机密嵌入到容器镜像中。

  • 您可以将它们传递到环境变量中。

  • 您可以将它们挂载为文件。

最安全的方式是将你的秘密作为文件挂载。当你将你的秘密嵌入到镜像中时,任何有权访问镜像的人都可以检索你的秘密。当你将你的秘密作为环境变量传递时,它们可以通过docker inspectkubectl describe pod以及如果你不清理环境的话,子进程也可以查看。此外,通常在报告错误时记录整个环境,这需要所有开发人员的纪律来清理和编辑秘密。挂载的文件不会受到这些弱点的影响,但请注意,任何可以kubectl exec进入你的容器的人都可以检查任何挂载的文件,包括秘密,如果你不仔细管理权限的话。

让我们从一个 YAML 清单中创建一个秘密。选择这种方法时,你有责任对值进行 base64 编码:

$ echo -n top-secret | base64
dG9wLXNlY3JldA==

$ echo -n bottom-secret | base64
Ym90dG9tLXNlY3JldA==

apiVersion: v1
kind: Secret
type: Opaque
metadata:
  name: generic-secrets2
  namespace: default
data:
  c: dG9wLXNlY3JldA==
  d: Ym90dG9tLXNlY3JldA==

让我们创建新的秘密,并通过使用kubectl get secret来验证它们是否成功创建:

$ kubectl create -f generic-secrets2.yaml
secret "generic-secrets2" created

$ echo -n $(kubectl get secret generic-secrets2 -o jsonpath="{.data.c}") | base64 -d
top-secret

$ echo -n $(kubectl get secret generic-secrets2 -o jsonpath="{.data.d}") | base64 -d
bottom-secret

现在我们知道如何创建不透明/通用的秘密并将它们传递给容器,让我们把所有的点连接起来,构建一个安全的 pod。

构建一个安全的 pod

该 pod 有一个自定义服务,不需要与 API 服务器通信(因此不需要自动挂载服务账户令牌);相反,该 pod 提供imagePullSecret来拉取我们的私有仓库,并且还挂载了一些通用秘密作为文件。

让我们开始学习如何构建一个安全的 pod:

  1. 第一步是自定义服务账户。以下是 YAML 清单:
apiVersion: v1
kind: ServiceAccount
metadata:
  name: service-account
automountServiceAccountToken: false

让我们创建它:

$ kubectl create -f service-account.yaml
serviceaccount "service-account" created
  1. 现在,我们将把它附加到我们的 pod 上,并设置之前创建的imagePullSecret。这里有很多事情要做。我附加了一个自定义服务账户,创建了一个引用generic-secrets2秘密的秘密卷,然后挂载到/etc/generic-secrets2;最后,我将imagePullSecrets设置为private-dockerhub秘密:
apiVersion: v1
kind: Pod
metadata:
  name: trouble
spec:
  serviceAccountName: service-account
  containers:
  - name: trouble
    image: g1g1/py-kube:0.2
    command: ["/bin/bash", "-c", "while true ; do sleep 10 ; done"]
    volumeMounts:
    - name: generic-secrets2
      mountPath: "/etc/generic-secrets2"
      readOnly: true
  imagePullSecrets:
  - name: private-dockerhub
  volumes:
  - name: generic-secrets2
    secret:
      secretName: generic-secrets2
  1. 接下来,我们可以创建我们的 pod 并开始玩耍:
$ kubectl create -f pod-with-secrets.yaml
pod "trouble" created

Kubernetes 能够从私有仓库中拉取镜像。我们不希望有 API 服务器令牌(/var/run/secrets/kubernetes.io/serviceaccount/不应该存在),我们的秘密应该作为文件挂载在/etc/generic-secrets2中。让我们通过使用kubectl exec -it启动一个交互式 shell 来验证这一点,并检查服务账户文件是否存在,但通用秘密cd存在:

$ kubectl exec -it trouble bash

# ls /var/run/secrets/kubernetes.io/serviceaccount/
ls: cannot access '/var/run/secrets/kubernetes.io/serviceaccount/': No such file or directory

# cat /etc/generic-secrets2/c
top-secret

# cat /etc/generic-secrets2/d
bottom-secret

太好了,它起作用了!

在这里,我们着重于管理自定义密钥并构建一个无法访问 Kubernetes API 服务器的安全 Pod,但通常您需要仔细管理不同实体对 Kubernetes API 服务器的访问权限。Kubernetes 具有明确定义的基于角色的访问控制模型(也称为RBAC)。让我们看看它的运作方式。

使用 RBAC 管理权限

RBAC 是用于管理对 Kubernetes 资源的访问的机制。从 Kubernetes 1.8 开始,RBAC 被认为是稳定的。使用--authorization-mode=RBAC启动 API 服务器以启用它。当请求发送到 API 服务器时,RBAC 的工作原理如下:

  1. 首先,它通过调用者的用户凭据或服务账户凭据对请求进行身份验证(如果失败,则返回 401 未经授权)。

  2. 接下来,它检查 RBAC 策略,以验证请求者是否被授权对目标资源执行操作(如果失败,则返回 403 禁止)。

  3. 最后,它通过一个准入控制器运行,该控制器可能因各种原因拒绝或修改请求。

RBAC 模型由身份(用户和服务账户)、资源(Kubernetes 对象)、动词(标准操作,如getlistcreate)、角色和角色绑定组成。Delinkcious 服务不需要访问 API 服务器,因此它们不需要访问权限。但是,持续交付解决方案 Argo CD 绝对需要访问权限,因为它部署我们的服务和所有相关对象。

让我们来看一下角色中的以下片段,并详细了解它。您可以在这里找到源代码:github.com/argoproj/argo-cd/blob/master/manifests/install.yaml#L116

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  labels:
    app.kubernetes.io/component: server
    app.kubernetes.io/name: argo-cd
  name: argocd-server
rules:
- apiGroups:
  - ""
  resources:
  - secrets
  - configmaps
  verbs:
  - create
  - get
  - list
  ...
- apiGroups:
  - argoproj.io
  resources:
  - applications
  - appprojects
  verbs:
  - create
  - get
  - list
  ...
- apiGroups:
  - ""
  resources:
  - events
  verbs:
  - create
  - list

角色有规则。每个规则将允许的动词列表分配给每个 API 组和该 API 组内的资源。例如,对于空的 API 组(表示核心 API 组)和configmapssecrets资源,Argo CD 服务器可以应用所有这些动词:

- apiGroups:
  - ""
  resources:
  - secrets
  - configmaps
  verbs:
  - create
  - get
  - list
  - watch
  - update
  - patch
  - delete

argoproj.io API 组和applicationsappprojects资源(都是由 Argo CD 定义的 CRD)有另一个动词列表。最后,对于核心组的events资源,它只能使用createlist动词:

- apiGroups:
  - ""
  resources:
  - events
  verbs:
  - create
- list

RBAC 角色仅适用于创建它的命名空间。这意味着 Argo CD 可以对configmapssecrets做任何事情并不太可怕,如果它是在专用命名空间中创建的。您可能还记得,我在名为argocd的命名空间中在集群上安装了 Argo CD。

然而,类似于角色,RBAC 还有一个ClusterRole,其中列出的权限在整个集群中都是允许的。Argo CD 也有集群角色。例如,argocd-application-controller具有以下集群角色:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  labels:
    app.kubernetes.io/component: application-controller
    app.kubernetes.io/name: argo-cd
  name: argocd-application-controller
rules:
- apiGroups:
  - '*'
  resources:
  - '*'
  verbs:
  - '*'
- nonResourceURLs:
  - '*'
  verbs:
- '*'

这几乎可以访问集群中的任何内容。这相当于根本没有 RBAC。我不确定为什么 Argo CD 应用程序控制器需要这样的全局访问权限。我猜想这只是为了更容易地访问任何内容,而不是明确列出所有内容,如果是一个很长的列表。然而,从安全的角度来看,这并不是最佳做法。

角色和集群角色只是一系列权限列表。为了使其正常工作,您需要将角色绑定到一组帐户。这就是角色绑定和集群角色绑定发挥作用的地方。角色绑定仅在其命名空间中起作用。您可以将角色绑定到角色和集群角色(在这种情况下,集群角色仅在目标命名空间中处于活动状态)。这是一个例子:

apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  labels:
    app.kubernetes.io/component: application-controller
    app.kubernetes.io/name: argo-cd
  name: argocd-application-controller
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: argocd-application-controller
subjects:
- kind: ServiceAccount
name: argocd-application-controller

集群角色绑定适用于整个集群,只能绑定集群角色(因为角色受限于其命名空间)。

现在我们了解了如何使用 RBAC 控制对 Kubernetes 资源的访问权限,让我们继续控制对我们自己的微服务的访问权限。

通过身份验证、授权和准入来控制访问

Kubernetes 具有一个有趣的访问控制模型,超出了标准的访问控制。对于您的微服务,它提供了身份验证、授权和准入的三重保证。您可能熟悉身份验证(谁在调用?)和授权(调用者被允许做什么?)。准入并不常见。它可以用于更动态的情况,即使调用者经过了正确的身份验证和授权,也可能被拒绝请求。

微服务认证

服务账户和 RBAC 是管理 Kubernetes 对象的身份和访问的良好解决方案。然而,在微服务架构中,微服务之间会有大量的通信。这种通信发生在集群内部,可能被认为不太容易受到攻击。但是,深度防御原则指导我们也要加密、认证和管理这种通信。这里有几种方法。最健壮的方法需要你自己的私钥基础设施PKI)和证书颁发机构CA),可以处理证书的发布、吊销和更新,因为服务实例的出现和消失。这相当复杂(如果你使用云提供商,他们可能会为你提供)。一个相对简单的方法是利用 Kubernetes secrets,并在每两个可以相互通信的服务之间创建共享的密钥。然后,当请求到来时,我们可以检查调用服务是否传递了正确的密钥,从而对其进行认证。

让我们为link-managergraph-manager创建一个共享密钥(记住它必须是 base64 编码的):

$ echo -n "social-graph-manager: 123" | base64
c29jaWFsLWdyYXBoLW1hbmFnZXI6IDEyMw==

然后,我们将为link-manager创建一个密钥,如下所示:

apiVersion: v1
kind: Secret
type: Opaque
metadata:
  name: mutual-auth
  namespace: default
data:
  mutual-auth.yaml: c29jaWFsLWdyYXBoLW1hbmFnZXI6IDEyMw==

永远不要将密钥提交到源代码控制。我在这里只是为了教育目的而这样做。

要使用kubectljsonpath格式查看密钥的值,您需要转义mutual-auth.yaml中的点:

$ kubectl get secret link-mutual-auth -o "jsonpath={.data['mutual-auth\.yaml']}" | base64 -D
social-graph-manager: 123

我们将重复这个过程为social-graph-manager

$ echo -n "link-manager: 123" | base64
bGluay1tYW5hZ2VyOiAxMjM=

然后,我们将为social-graph-manager创建一个密钥,如下所示:

apiVersion: v1
kind: Secret
type: Opaque
metadata:
  name: mutual-auth
  namespace: default
data:
  mutual-auth.yaml: bGluay1tYW5hZ2VyOiAxMjM=

此时,link-managersocial-graph-manager有一个共享的密钥,我们可以将其挂载到各自的 pod 上。这是link-manager部署中的 pod 规范,它将密钥从一个卷挂载到/etc/delinkcious。密钥将显示为mutual-auth.yaml文件:

spec:
  containers:
  - name: link-manager
    image: g1g1/delinkcious-link:0.3
    imagePullPolicy: Always
    ports:
    - containerPort: 8080
    envFrom:
    - configMapRef:
        name: link-manager-config
    volumeMounts:
    - name: mutual-auth
      mountPath: /etc/delinkcious
      readOnly: true
  volumes:
  - name: mutual-auth
    secret:
      secretName: link-mutual-auth

我们可以将相同的约定应用于所有服务。结果是每个 pod 都将有一个名为/etc/delinkcious/mutual-auth.yaml的文件,其中包含它需要通信的所有服务的令牌。基于这个约定,我们创建了一个叫做auth_util的小包,它读取文件,填充了一些映射,并暴露了一些用于映射和匹配调用方和令牌的函数。auth_util包期望文件本身是一个 YAML 文件,格式为<caller>: <token>的键值对。

以下是声明和映射:

package auth_util

import (
   _ "github.com/lib/pq"
   "gopkg.in/yaml.v2"
   "io/ioutil"
   "os"
)

const callersFilename = "/etc/delinkcious/mutual-auth.yaml"

var callersByName = map[string]string{}
var callersByToken = map[string][]string{}

init()函数读取文件(除非env变量DELINKCIOUS_MUTUAL_AUTH设置为false),将其解组为callersByName映射,然后遍历它并填充反向callersByToken映射,其中令牌是键,调用者是值(可能重复):

func init() {
   if os.Getenv("DELINKCIOUS_MUTUAL_AUTH") == "false" {
      return
   }

   data, err := ioutil.ReadFile(callersFilename)
   if err != nil {
      panic(err)
   }
   err = yaml.Unmarshal(data, callersByName)
   if err != nil {
      panic(err)
   }

   for caller, token := range callersByName {
      callersByToken[token] = append(callersByToken[token], caller)
   }
}

最后,GetToken()HasCaller()函数提供了被服务和客户端用来相互通信的包的外部接口:

func GetToken(caller string) string {
   return callersByName[caller]
}

func HasCaller(caller string, token string) bool {
   for _, c := range callersByToken[token] {
      if c == caller {
         return true
      }
   }

   return false
}

让我们看看链接服务如何调用社交图服务的GetFollowers()方法。GetFollowers()方法从环境中提取认证令牌,并将其与标头中提供的令牌进行比较(这仅为链接服务所知),以验证调用者是否真的是链接服务。与往常一样,核心逻辑不会改变。整个身份验证方案都隔离在传输和客户端层。由于社交图服务使用 HTTP 传输,客户端将令牌存储在名为Delinkcious-Caller-Service的标头中。它通过auth_util包的GetToken()函数获取令牌,而不知道秘密来自何处(在我们的情况下,Kubernetes 秘密被挂载为文件):

// encodeHTTPGenericRequest is a transport/http.EncodeRequestFunc that
// JSON-encodes any request to the request body. Primarily useful in a client.
func encodeHTTPGenericRequest(_ context.Context, r *http.Request, request interface{}) error {
   var buf bytes.Buffer
   if err := json.NewEncoder(&buf).Encode(request); err != nil {
      return err
   }
   r.Body = ioutil.NopCloser(&buf)

   if os.Getenv("DELINKCIOUS_MUTUAL_AUTH") != "false" {
      token := auth_util.GetToken(SERVICE_NAME)
      r.Header["Delinkcious-Caller-Token"] = []string{token}
   }

   return nil
}

在服务端,社交图服务传输层确保Delinkcious-Caller-Token存在,并且包含有效调用者的令牌:

func decodeGetFollowersRequest(_ context.Context, r *http.Request) (interface{}, error) {
   if os.Getenv("DELINKCIOUS_MUTUAL_AUTH") != "false" {
      token := r.Header["Delinkcious-Caller-Token"]
      if len(token) == 0 || token[0] == "" {
         return nil, errors.New("Missing caller token")
      }

      if !auth_util.HasCaller("link-manager", token[0]) {
         return nil, errors.New("Unauthorized caller")
      }
   }
   parts := strings.Split(r.URL.Path, "/")
   username := parts[len(parts)-1]
   if username == "" || username == "followers" {
      return nil, errors.New("user name must not be empty")
   }
   request := getByUsernameRequest{Username: username}
   return request, nil
}

这种机制的美妙之处在于,我们将解析文件和从 HTTP 请求中提取标头等繁琐的管道工作都保留在传输层,并保持核心逻辑原始。

在第十三章中,服务网格-使用 Istio 工作,我们将看到使用服务网格对微服务进行身份验证的另一种解决方案。现在,让我们继续授权微服务。

授权微服务

授权微服务可能非常简单,也可能非常复杂。在最简单的情况下,如果调用微服务经过身份验证,则被授权执行任何操作。然而,有时这是不够的,您需要根据其他请求参数进行非常复杂和细粒度的授权。例如,在我曾经工作过的一家公司,我为具有空间和时间维度的传感器网络开发了授权方案。用户可以查询数据,但他们可能仅限于特定的城市、建筑物、楼层或房间。

如果他们从未经授权的位置请求数据,他们的请求将被拒绝。他们还受到时间范围的限制,不能在指定的时间范围之外查询。

对于 Delinkcious,您可以想象用户可能只能查看自己的链接和他们关注的用户的链接(如果获得批准)。

承认微服务

身份验证和授权是非常著名和熟悉的访问控制机制(尽管难以强大地实施)。准入是跟随授权的另一步。即使请求经过身份验证和授权,也可能无法立即满足请求。这可能是由于服务器端的速率限制或其他间歇性问题。Kubernetes 实现了额外的功能,例如作为准入的一部分改变请求。对于您自己的微服务,可能并不需要这样做。

到目前为止,我们已经讨论了帐户、秘密和访问控制。然而,为了更接近一个安全和加固的集群,还有很多工作要做。

使用安全最佳实践加固您的 Kubernetes 集群

在本节中,我们将介绍各种最佳实践,并看看 Delinkcious 离正确的方式有多近。

保护您的镜像

最重要的之一是确保您部署到集群的镜像是安全的。这里有几个很好的指导方针要遵循。

始终拉取镜像

在容器规范中,有一个名为ImagePullPolicy的可选键。默认值是IfNotPresent。这个默认值有一些问题,如下所示:

  • 如果您使用latest等标签(您不应该这样做),那么您将无法获取更新的镜像。

  • 您可能会与同一节点上的其他租户发生冲突。

  • 同一节点上的其他租户可以运行您的镜像。

Kubernetes 有一个名为AlwaysPullImages的准入控制器,它将每个 pod 的ImagePullPolicy设置为AlwaysPullImages。这可以防止所有问题,但会拉取镜像,即使它们已经存在并且您有权使用它们。您可以通过将其添加到传递给kube-apiserver的启用准入控制器列表中的--enable-admission-controllers标志来启用此准入控制器。

扫描漏洞

代码或依赖项中的漏洞会使攻击者能够访问你的系统。国家漏洞数据库(nvd.nist.gov/)是了解新漏洞和管理漏洞的流程的好地方,比如安全内容自动化协议SCAP)。

开源解决方案,如 Claire(github.com/coreos/clair)和 Anchore(anchore.com/kubernetes/)可用,还有商业解决方案。许多镜像注册表也提供扫描服务。

更新你的依赖项

保持依赖项的最新状态,特别是如果它们修复了已知的漏洞。在这里,你需要在警惕和保守之间找到合适的平衡。

固定基础镜像的版本

基础镜像的版本固定对于确保可重复构建至关重要。如果未指定基础镜像的版本,将会获取最新版本,这可能并非你想要的。

使用最小的基础镜像

最小化攻击面的原则敦促你尽可能使用最小的基础镜像;越小越受限制,越好。除了这些安全好处,你还可以享受更快的拉取和推送(尽管层只有在升级基础镜像时才会使其相关)。Alpine 是一个非常受欢迎的基础镜像。Delinkcious 服务采用了极端的方法,使用SCRATCH镜像作为基础镜像。

几乎整个服务只是一个 Go 可执行文件,就是这样。它小巧、快速、安全,但当你需要解决问题时,你会为此付出代价,因为没有工具可以帮助你。

如果我们遵循所有这些准则,我们的镜像将是安全的,但我们仍应用最小权限和零信任的基本原则,并在网络层面最小化影响范围。如果容器、Pod 或节点某种方式被 compromise,它们不应该被允许访问网络的其他部分,除非是工作负载运行所需的。这就是命名空间和网络策略发挥作用的地方。

划分和征服你的网络

除了身份验证作为深度防御的一部分,你还可以通过使用命名空间和网络策略来确保服务只有在必要时才能相互通信。

命名空间是一个非常直观但强大的概念。然而,它们本身并不能阻止同一集群中的 pod 相互通信。在 Kubernetes 中,集群中的所有 pod 共享相同的平面网络地址空间。这是 Kubernetes 网络模块的一个很大的简化之一。你的 pod 可以在同一节点上,也可以在不同的节点上 - 这并不重要。

每个 pod 都有自己的 IP 地址(即使多个 pod 在同一物理节点或 VM 上运行,只有一个 IP 地址)。这就是网络策略的作用。网络策略基本上是一组规则,指定了 pod 之间的集群内通信(东西流量),以及集群中服务与外部世界之间的通信(南北流量)。如果没有指定网络策略,所有传入流量(入口)默认情况下都允许访问每个 pod 的所有端口。从安全的角度来看,这是不可接受的。

让我们首先阻止所有的入口流量,然后根据需要逐渐开放:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: deny-all
spec:
  podSelector: {}
  policyTypes:
  - Ingress

请注意,网络策略是在 pod 级别工作的。您可以使用有意义的标签正确地对 pod 进行分组,这是您应该这样做的主要原因之一。

在应用此策略之前,最好知道它是否可以从故障排除的 pod 中工作,如下面的代码块所示:

# http GET http://$SOCIAL_GRAPH_MANAGER_SERVICE_HOST:9090/following/gigi

HTTP/1.1 200 OK
Content-Length: 37
Content-Type: text/plain; charset=utf-8
Date: Mon, 18 Feb 2019 18:00:52 GMT

{
    "err": "",
    "following": {
        "liat": true
    }
}

然而,在应用了deny-all策略之后,我们得到了一个超时错误,如下所示:

# http GET http://$SOCIAL_GRAPH_MANAGER_SERVICE_HOST:9090/following/gigi

http: error: Request timed out (30s).

现在所有的 pod 都被隔离了,让我们允许social-graph-manager访问它的数据库。这是一个网络策略,只允许social-graph-manager访问端口5432上的social-graph-db

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-social-graph-db
  namespace: default
spec:
  podSelector:
    matchLabels:
      svc: social-graph
      app: db
  ingress:
  - from:
    - podSelector:
        matchLabels:
          svc: social-graph
          app: manger
    ports:
    - protocol: TCP
      port: 5432

以下附加策略允许从link-managersocial-graph-manager的端口9090进行入口,如下面的代码所示:

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-link-to-social-graph
  namespace: default
spec:
  podSelector:
    matchLabels:
      svc: social-graph
      app: manager
  ingress:
  - from:
    - podSelector:
        matchLabels:
          svc: link
          app: manger
    ports:
    - protocol: TCP
      port: 9090

除了安全性的好处之外,网络策略还作为系统中信息流动的实时文档。您可以准确地知道哪些服务与其他服务通信,以及外部服务。

我们已经控制了我们的网络。现在,是时候把注意力转向我们的镜像注册表了。毕竟,这是我们获取镜像的地方,我们给予了很多权限。

保护您的镜像注册表

强烈建议使用私有图像注册表。如果您拥有专有代码,那么您不得以公共访问方式发布您的容器,因为对您的图像进行逆向工程将授予攻击者访问权限。但是,这也有其他原因。您可以更好地控制(和审计)从注册表中拉取和推送图像。

这里有两个选择:

  • 使用由 AWS、Google、Microsoft 或 Quay 等第三方管理的私有注册表。

  • 使用您自己的私有注册表。

如果您在云平台上部署系统,并且该平台与其自己的图像注册表有良好的集成,或者如果您在云原生计算的精神中不管理自己的注册表,并且更喜欢让 Quay 等第三方为您完成,那么第一个选项是有意义的。

第二个选项(运行自己的容器注册表)可能是最佳选择,如果您需要对所有图像(包括基本图像和依赖项)进行额外控制。

根据需要授予对 Kubernetes 资源的访问权限

最小特权原则指导您仅向实际需要访问 Kubernetes 资源的服务授予权限(例如,Argo CD)。RBAC 在这里是一个很好的选择,因为默认情况下所有内容都被锁定,您可以明确添加权限。但是,要注意不要陷入给予通配符访问所有内容的陷阱,只是为了克服 RBAC 配置的困难。例如,让我们看一个具有以下规则的集群角色:

rules:
- apiGroups:
  - '*'
  resources:
  - '*'
  verbs:
  - '*'
- nonResourceURLs:
  - '*'
  verbs:
- '*'

这比禁用 RBAC 更糟糕,因为它会给您一种虚假的安全感。另一个更动态的选择是通过 Webhook 和外部服务器进行动态身份验证、授权和准入控制。这些给您提供了最大的灵活性。

使用配额来最小化爆炸半径

限制和配额是 Kubernetes 的机制,您可以控制分配给集群、Pod 和容器的各种有限资源,如 CPU 和内存。它们非常有用,有多种原因:

  • 性能。

  • 容量规划。

  • 成本管理。

  • 它们帮助 Kubernetes 根据资源利用率安排 Pod。

当您的工作负载在预算内运行时,一切都变得更可预测和更容易推理,尽管您必须做出努力来弄清楚实际需要多少资源,并随着时间的推移进行调整。这并不像听起来那么糟糕,因为通过水平 pod 自动缩放,您可以让 Kubernetes 动态调整服务的 pod 数量,即使每个 pod 都有非常严格的配额。

从安全的角度来看,如果攻击者获得对集群上运行的工作负载的访问权限,它将限制它可以使用的物理资源的数量。如今最常见的攻击之一就是用加密货币挖矿来饱和目标。类似的攻击类型是 fork 炸弹,它通过使一个恶意进程无法控制地复制自己来消耗所有可用资源。网络策略通过限制对网络上其他 pod 的访问来限制受损工作负载的爆炸半径。资源配额最小化了受损 pod 的主机节点上利用资源的爆炸半径。

有几种类型的配额,例如以下内容:

  • 计算配额(CPU 和内存)

  • 存储配额(磁盘和外部存储)

  • 对象(Kubernetes 对象)

  • 扩展资源(非 Kubernetes 资源,如 GPU)

资源配额非常微妙。您需要理解几个概念,例如单位和范围,以及请求和限制之间的区别。我将解释基础知识,并通过为 Delinkcious 用户服务添加资源配额来演示它们。为容器分配资源配额,因此您可以将其添加到容器规范中,如下所示:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: user-manager
  labels:
    svc: user
    app: manager
spec:
  replicas: 1
  selector:
    matchLabels:
      svc: user
      app: manager
  template:
    metadata:
      labels:
        svc: user
        app: manager
    spec:
      containers:
      - name: user-manager
        image: g1g1/delinkcious-user:0.3
        imagePullPolicy: Always
        ports:
        - containerPort: 7070
        resources:
          requests:
            memory: 64Mi
            cpu: 250m
          limits:
            memory: 64Mi
            cpu: 250m

资源下有两个部分:

  • 请求:请求是容器为了启动而请求的资源。如果 Kubernetes 无法满足特定资源的请求,它将不会启动 pod。您的工作负载可以确保在其整个生命周期中分配了这么多 CPU 和内存,并且您可以将其存入银行。

在上面的块中,我指定了64Mi内存和250m CPU 单位的请求(有关这些单位的解释,请参见下一节)。

  • 限制:限制是工作负载可能访问的资源的上限。超出其内存限制的容器可能会被杀死,并且整个 pod 可能会从节点中被驱逐。如果被杀死,Kubernetes 将重新启动容器,并在被驱逐时重新调度 pod,就像它对任何类型的故障一样。如果容器超出其 CPU 限制,它将不会被杀死,甚至可能会在一段时间内逃脱,但是由于 CPU 更容易控制,它可能只是得不到它请求的所有 CPU,并且会经常休眠以保持在其限制范围内。

通常,最好的方法是将请求指定为限制,就像我为用户管理器所做的那样。工作负载知道它已经拥有了它将来需要的所有资源,不必担心在同一节点上有其他饥饿的邻居竞争相同资源池的情况下试图接近限制。

虽然资源是针对每个容器指定的,但是当 pod 具有多个容器时,重要的是考虑整个 pod 的总资源请求(所有容器请求的总和)。这是因为 pod 总是作为一个单元进行调度。如果您有一个具有 10 个容器的 pod,每个容器都要求 2 Gib 的内存,那么这意味着您的 pod 需要一个具有 20 Gib 空闲内存的节点。

请求和限制的单位

您可以使用以下后缀来请求和限制内存:E、P、T、G、M 和 K。您还可以使用 2 的幂后缀(它们总是稍微大一些),即 Ei、Pi、Ti、Gi、Mi 和 Ki。您还可以只使用整数,包括字节的指数表示法。

以下大致相同:257,988,979, 258e6, 258M 和 246Mi。CPU 单位相对于托管环境如下:

  • 1 个 AWS vCPU

  • 1 个 GCP Core

  • 1 个 Azure vCore

  • 1 个 IBM vCPU

  • 在具有超线程的裸机英特尔处理器上的 1 个超线程

您可以请求 CPU 的分辨率为 0.001 的分数。更方便的方法是使用 milliCPU 和带有m后缀的整数;例如,100 m 是 0.1 CPU。

实施安全上下文

有时,Pod 和容器需要提升的特权或访问节点。这对于您的应用工作负载来说将是非常罕见的。但是,在必要时,Kubernetes 具有一个安全上下文的概念,它封装并允许您配置多个 Linux 安全概念和机制。从安全的角度来看,这是至关重要的,因为它打开了一个从容器世界到主机机器的隧道。

以下是一些安全上下文涵盖的一些机制的列表:

  • 允许(或禁止)特权升级

  • 通过用户 ID 和组 ID 进行访问控制(runAsUserrunAsGroup

  • 能力与无限制的根访问相对

  • 使用 AppArmor 和 seccomp 配置文件

  • SELinux 配置

有许多细节和交互超出了本书的范围。我只分享一个SecurityContext的例子:

apiVersion: v1
kind: Pod
metadata:
  name: secure-pod
spec:
  containers:
  - name: some-container
    image: g1g1/py-kube:0.2
    command: ["/bin/bash", "-c", "while true ; do sleep 10 ; done"]
    securityContext:
      runAsUser: 2000
      allowPrivilegeEscalation: false
      capabilities:
        add: ["NET_ADMIN", "SYS_TIME"]
      seLinuxOptions:
        level: "s0:c123,c456"

安全策略执行不同的操作,比如将容器内的用户 ID 设置为2000,并且不允许特权升级(获取 root),如下所示:

$ kubectl exec -it secure-pod bash

I have no name!@secure-pod:/$ whoami
whoami: cannot find name for user ID 2000

I have no name!@secure-pod:/$ sudo su
bash: sudo: command not found

安全上下文是集中化 Pod 或容器的安全方面的一个很好的方式,但在一个大型集群中,您可能安装第三方软件包(如 helm charts),很难确保每个 Pod 和容器都获得正确的安全上下文。这就是 Pod 安全策略出现的地方。

使用安全策略加固您的 Pod

Pod 安全策略允许您设置一个适用于所有新创建的 Pod 的全局策略。它作为访问控制的准入阶段的一部分执行。Pod 安全策略可以为没有安全上下文的 Pod 创建安全上下文,或者拒绝创建和更新具有不符合策略的安全上下文的 Pod。以下是一个安全策略,它将阻止 Pod 获取允许访问主机设备的特权状态:

apiVersion: policy/v1beta1
kind: PodSecurityPolicy
metadata:
  name: disallow-privileged-access
spec:
  privileged: false
  allowPrivilegeEscalation: false
  # required fields.
  seLinux:
    rule: RunAsAny
  supplementalGroups:
    rule: RunAsAny
  runAsUser:
    rule: RunAsAny
  fsGroup:
    rule: RunAsAny
  volumes:
  - '*'

以下是一些很好的策略要执行(如果您不需要这些能力):

  • 只读根文件系统

  • 控制挂载主机卷

  • 防止特权访问和升级

最后,让我们确保我们将用于与 Kubernetes 集群一起工作的工具也是安全的。

加固您的工具链

Delinkcious 相当完善。它使用的主要工具是 Argo CD。Argo CD 可能会造成很大的损害,它在集群内运行并从 GitHub 拉取。然而,它有很多权限。在我决定将 Argo CD 作为 Delinkcious 的持续交付解决方案之前,我从安全的角度认真审查了它。Argo CD 的开发人员在考虑如何使 Argo CD 安全方面做得很好。他们做出了明智的选择,实施了这些选择,并记录了如何安全地运行 Argo CD。以下是 Argo CD 提供的安全功能:

  • 通过 JWT 令牌对管理员用户进行身份验证

  • 通过 RBAC 进行授权

  • 通过 HTTPS 进行安全通信

  • 秘密和凭证管理

  • 审计

  • 集群 RBAC

让我们简要地看一下它们。

通过 JWT 令牌对管理员用户进行身份验证

Argo CD 具有内置的管理员用户。所有其他用户必须使用单点登录SSO)。对 Argo CD 服务器的身份验证始终使用JSON Web TokenJWT)。管理员用户的凭据也会转换为 JWT。

它还支持通过/api/v1/projects/{project}/roles/{role}/token端点进行自动化,生成由 Argo CD 本身签发的自动化令牌。这些令牌的范围有限,并且过期得很快。

通过 RBAC 进行授权

Argo CD 通过将用户的 JWT 组声明映射到 RBAC 角色来授权请求。这是行业标准认证与 Kubernetes 授权模型 RBAC 的非常好的结合。

通过 HTTPS 进行安全通信

Argo CD 的所有通信,以及其自身组件之间的通信,都是通过 HTTPS/TLS 完成的。

秘密和凭证管理

Argo CD 需要管理许多敏感信息,例如:

  • Kubernetes 秘密

  • Git 凭证

  • OAuth2 客户端凭证

  • 对外部集群的凭证(当未安装在集群中时)

Argo CD 确保将所有这些秘密保留给自己。它永远不会通过在响应中返回它们或记录它们来泄露它们。所有 API 响应和日志都经过清理和编辑。

审计

您可以通过查看 git 提交日志来审计大部分活动,这会触发 Argo CD 中的所有内容。但是,Argo CD 还发送各种事件以捕获集群内的活动,以提供额外的可见性。这种组合很强大。

集群 RBAC

默认情况下,Argo CD 使用集群范围的管理员角色。这并不是必要的。建议将其写权限限制在需要管理的命名空间中。

总结

在本章中,我们认真看待了一个严肃的话题:安全。基于微服务的架构和 Kubernetes 对于支持关键任务目标并经常管理敏感信息的大规模企业分布式系统是最有意义的。除了开发和演进这样复杂系统的挑战之外,我们必须意识到这样的系统对攻击者非常诱人。

我们必须使用严格的流程和最佳实践来保护系统、用户和数据。从这里开始,我们涵盖了安全原则和最佳实践,我们也看到它们如何相互支持,以及 Kubernetes 如何致力于允许它们安全地开发和操作我们的系统。

我们还讨论了作为 Kubernetes 微服务安全基础的支柱:认证/授权/准入的三重 A,集群内外的安全通信,强大的密钥管理(静态和传输加密),以及分层安全策略。

在这一点上,您应该清楚地了解了您可以使用的安全机制,并且有足够的信息来决定如何将它们整合到您的系统中。安全永远不会完成,但利用最佳实践将使您能够在每个时间点上找到安全和系统其他要求之间的正确平衡。

在下一章中,我们将最终向世界开放 Delinkcious!我们将研究公共 API、负载均衡器以及我们需要注意的性能和安全性重要考虑因素。

进一步阅读

有许多关于 Kubernetes 安全性的良好资源。我收集了一些非常好的外部资源,这些资源将帮助您在您的旅程中:

以下 Kubernetes 文档页面扩展了本章涵盖的许多主题:

第七章:与世界交流- API 和负载均衡器

在本章中,我们最终将向外部打开 Delinkcious,让用户可以从集群外部与其进行交互。这很重要,因为 Delinkcious 用户无法访问集群内部运行的内部服务。我们将通过添加基于 Python 的 API 网关服务并将其暴露给世界(包括社交登录)来显著扩展 Delinkcious 的功能。我们将添加一个基于 gRPC 的新闻服务,用户可以使用它来获取关注的其他用户的新闻。最后,我们将添加一个消息队列,让服务以松散耦合的方式进行通信。

在本章中,我们将涵盖以下主题:

  • 熟悉 Kubernetes 服务

  • 东西向与南北向通信

  • 理解入口和负载均衡

  • 提供和使用公共 REST API

  • 提供和使用内部 gRPC API

  • 通过消息队列发送和接收事件

  • 为服务网格做准备

技术要求

在本章中,我们将向 Delinkcious 添加一个 Python 服务。无需安装任何新内容。我们稍后将为 Python 服务构建一个 Docker 镜像。

代码

您可以在这里找到更新的 Delinkcious 应用程序:github.com/the-gigi/delinkcious/releases/tag/v0.5

熟悉 Kubernetes 服务

Pod(一个或多个容器捆绑在一起)是 Kubernetes 中的工作单位。部署确保有足够的 Pod 在运行。但是,单个 Pod 是短暂的。Kubernetes 服务是行动所在的地方,以及您如何将您的 Pod 公开为一个连贯的服务,供集群中的其他服务甚至外部世界使用。Kubernetes 服务提供稳定的标识,并且通常将应用程序服务(可以是微服务或传统的大型服务)进行 1:1 映射。让我们看看所有的服务:

$ kubectl get svc
NAME                TYPE      CLUSTER-IP    EXTERNAL-IP   PORT(S)   AGE
api-gateway      LoadBalancer   10.103.167.102  <pending> 80:31965/TCP  6m2s
kubernetes         ClusterIP    10.96.0.1         <none>    443/TCP      25m
link-db            ClusterIP    10.107.131.61     <none>    5432/TCP     8m53s 
link-manager       ClusterIP    10.109.32.254     <none>    8080/TCP     8m53s
news-manager       ClusterIP    10.99.206.183     <none>    6060/TCP     7m45s
news-manager-redis ClusterIP     None             <none>    6379/TCP     7m45s
social-graph-db    ClusterIP    10.106.164.24     <none>    5432/TCP     8m38s
social-graph-manager ClusterIP   10.100.107.79    <none>    9090/TCP     8m37s
user-db             ClusterIP    None             <none>    5432/TCP     8m10s
user-manager        ClusterIP    10.108.45.93     <none>    7070/TCP     8m10s

您已经看到了 Delinkcious 微服务是如何使用 Kubernetes 服务部署的,以及它们如何通过 Kubernetes 提供的环境变量进行发现和调用。Kubernetes 还提供基于 DNS 的发现。

每个服务都可以通过 DNS 名称在集群内部访问:

<service name>.<namespace>.svc.cluster.local

我更喜欢使用环境变量,因为这样可以让我在 Kubernetes 之外运行服务进行测试。

以下是如何使用环境变量和 DNS 查找social-graph-manager服务的 IP 地址:

$ dig +short social-graph-manager.default.svc.cluster.local
10.107.162.99

$ env | grep SOCIAL_GRAPH_MANAGER_SERVICE_HOST
SOCIAL_GRAPH_MANAGER_SERVICE_HOST=10.107.162.99

Kubernetes 通过指定标签选择器将服务与其支持的 pod 关联起来。例如,如下所示的代码,news-service由具有svc: linkapp: manager标签的 pod 支持:

spec:
  replicas: 1
  selector:
    matchLabels:
      svc: link
      app: manager

然后,Kubernetes 使用endpoints资源管理与标签选择器匹配的所有 pod 的 IP 地址,如下所示:

$ kubectl get endpoints
NAME                   ENDPOINTS                                            AGE
api-gateway            172.17.0.15:5000                                     1d
kubernetes             192.168.99.137:8443                                  51d
link-db                172.17.0.19:5432                                     40d
.
.
.
social-graph-db        172.17.0.16:5432                                     50d
social-graph-manager   172.17.0.18:9090                                     43d

endpoints资源始终保持支持服务的所有 pod 的 IP 地址和端口的最新列表。当添加、删除或重新创建具有另一个 IP 地址和端口的 pod 时,将更新endpoints资源。现在,让我们看看 Kubernetes 中有哪些类型的服务。

Kubernetes 中的服务类型

Kubernetes 服务始终具有类型。了解何时使用每种类型的服务非常重要。让我们来看看各种服务类型及其之间的区别:

  • ClusterIP(默认):ClusterIP 类型意味着服务只能在集群内部访问。这是默认设置,非常适合微服务之间的通信。为了测试目的,您可以使用kube-proxyport-forwarding来暴露这样的服务。这也是查看 Kubernetes 仪表板或内部服务的其他 UI(例如 Delinkcious 中的 Argo CD)的好方法。

如果不指定 ClusterIP 的类型,请将ClusterIP设置为None

  • NodePort:NodePort 类型的服务通过所有节点上的专用端口向世界公开。您可以通过<Node IP>:<NodePort>访问服务。如果您自己运行 Kubernetes API 服务器,则可以通过--service-node-port-range控制范围来选择 NodePort(默认情况下为 30000-32767)。

您还可以在服务定义中明确指定 NodePort。如果您通过指定的节点端口暴露了大量服务,则必须小心管理这些端口,以避免冲突。当请求通过专用 NodePort 进入任何节点时,kubelet 将负责将其转发到具有其中一个支持 pod 的节点(您可以通过 endpoints 找到它)。

  • LoadBalancer:当您的 Kubernetes 集群在提供负载均衡器支持的云平台上运行时,这种类型的服务最常见。尽管在本地集群中也有适用于 Kubernetes 的负载均衡器,但外部负载均衡器将负责接受外部请求并将其通过服务路由到后端 Pod。通常存在云提供商特定的复杂性,例如特殊注释或必须创建双重服务来处理内部和外部请求。我们将使用 LoadBalancer 类型来将 Delinkcious 暴露给 minikube 的世界,该世界提供了负载均衡器仿真。

  • ExternalName:这些服务只是将请求解析到外部提供的 DNS 名称。如果您的服务需要与集群外部未运行的外部服务通信,但仍希望能够像它们是 Kubernetes 服务一样找到它们,这将非常有用。如果您计划将这些外部服务迁移到集群中,这可能会很有用。

现在我们了解了服务的全部内容,让我们讨论一下集群内部的跨服务通信和将服务暴露到集群外部之间的区别。

东西通信与南北通信

东西通信是指服务/Pod/容器在集群内部相互通信。正如您可能还记得的那样,Kubernetes 通过 DNS 和环境变量公开了集群内的所有服务。这解决了集群内部的服务发现问题。您可以通过网络策略或其他机制来进一步施加限制。例如,在第五章中,使用 Kubernetes 配置微服务,我们在链接服务和社交图服务之间建立了相互认证。

南北通信是指向世界暴露服务。理论上,您可以仅通过 NodePort 暴露您的服务,但这种方法存在许多问题,包括以下问题:

  • 您必须自行处理安全/加密传输

  • 无法控制哪些 Pod 实际上会为请求提供服务

  • 您必须让 Kubernetes 为您的服务选择随机端口,或者仔细管理端口冲突。

  • 每个端口只能暴露一个服务(例如,令人垂涎的端口80不能被重用)

批准生产的暴露服务的方法是通过入口控制器和/或负载均衡器使用。

理解入口和负载均衡

Kubernetes 中的入口概念是关于控制对您的服务的访问,并可能提供其他功能,例如以下内容:

  • SSL 终止

  • 身份验证

  • 路由到多个服务

有一个入口资源,定义其他相关信息的路由规则,还有一个入口控制器,它读取集群中定义的所有入口资源(跨所有命名空间)。入口资源接收所有请求并路由到分发它们到后台 pod 的目标服务。入口控制器充当集群范围的软件负载均衡器和路由器。通常,会有一个硬件负载均衡器坐在集群前面,并将所有流量发送到入口控制器。

让我们把所有这些概念放在一起,通过添加一个公共 API 网关来向世界展示 Delinkcious。

提供和使用公共 REST API

在这一部分,我们将构建一个全新的 Python 服务(API 网关),以证明 Kubernetes 实际上是与语言无关的。然后,我们将通过 OAuth2 添加用户身份验证,并将 API 网关服务暴露给外部。

构建基于 Python 的 API 网关服务

API 网关服务旨在接收来自集群外部的所有请求,并将它们路由到适当的服务。以下是目录结构:

$ tree
 .
 ├── Dockerfile
 ├── README.md
 ├── api_gateway_service
 │   ├── __init__.py
 │   ├── api.py
 │   ├── config.py
 │   ├── news_client.py
 │   ├── news_client_test.py
 │   ├── news_pb2.py
 │   ├── news_pb2_grpc.py
 │   └── resources.py
 ├── k8s
 │   ├── api_gateway.yaml
 │   ├── configmap.yaml
 │   └── secrets.yaml
 ├── requirements.txt
 ├── run.py
 └── tests
 └── api_gateway_service_test.py

这与 Go 服务有些不同。代码位于api_gateway_service目录下,这也是一个 Python 包。Kubernetes 资源位于k8s子目录下,还有一个tests子目录。在顶级目录中,run.py文件是入口点,如Dockerfile中定义的那样。run.py中的main()函数调用了从api.py模块导入的app.run()方法:

import os
from api_gateway_service.api import app

def main():
    port = int(os.environ.get('PORT', 5000))
    login_url = 'http://localhost:{}/login'.format(port)
    print('If you run locally, browse to', login_url)
    host = '0.0.0.0'
    app.run(host=host, port=port)

if __name__ == "__main__":
    main()

api.py模块负责创建应用程序,连接路由,并实现社交登录。

实现社交登录

api-gateway服务利用了几个 Python 包来帮助通过 GitHub 实现社交登录。稍后,我们将介绍用户流程,但首先,我们将看一下实现它的代码。login()方法正在与 GitHub 联系,并请求对当前用户进行授权,该用户必须已登录 GitHub 并授权给 Delinkcious。

logout()方法只是从当前会话中删除访问令牌。authorized()方法在 GitHub 成功登录尝试后被调用,并提供一个访问令牌,该令牌将在用户的浏览器中显示。这个访问令牌必须作为标头传递给 API 网关的所有未来请求:

@app.route('/login')
def login():
    callback = url_for('authorized', _external=True)
    result = app.github.authorize(callback)
    return result

@app.route('/login/authorized')
def authorized():
    resp = app.github.authorized_response()
    if resp is None:
        # return 'Access denied: reason=%s error=%s' % (
        #     request.args['error'],
        #     request.args['error_description']
        # )
        abort(401, message='Access denied!')
    token = resp['access_token']
    # Must be in a list or tuple because github auth code extracts the first
    user = app.github.get('user', token=(token,))
    user.data['access_token'] = token
    return jsonify(user.data)

@app.route('/logout')
def logout():
    session.pop('github_token', None)
    return 'OK'

当用户传递有效的访问令牌时,Delinkcious 可以从 GitHub 检索他们的姓名和电子邮件。如果访问令牌丢失或无效,请求将被拒绝,并显示 401 访问被拒绝错误。这发生在resources.py中的_get_user()函数中:

def _get_user():
    """Get the user object or create it based on the token in the session

    If there is no access token abort with 401 message
    """
    if 'Access-Token' not in request.headers:
        abort(401, message='Access Denied!')

    token = request.headers['Access-Token']
    user_data = github.get('user', token=dict(access_token=token)).data
    if 'email' not in user_data:
        abort(401, message='Access Denied!')

    email = user_data['email']
    name = user_data['name']

    return name, email

GitHub 对象是在api.py模块的create_app()函数中创建和初始化的。首先,它导入了一些第三方库,即FlaskOAuthApi类:

import os

from flask import Flask, url_for, session, jsonify
from flask_oauthlib.client import OAuth
from flask_restful import Api, abort
from . import resources
from .resources import Link

然后,它使用 GitHub Oauth提供程序初始化Flask应用程序:

def create_app():
    app = Flask(__name__)
    app.config.from_object('api_gateway_service.config')
    oauth = OAuth(app)
    github = oauth.remote_app(
        'github',
        consumer_key=os.environ['GITHUB_CLIENT_ID'],
        consumer_secret=os.environ['GITHUB_CLIENT_SECRET'],
        request_token_params={'scope': 'user:email'},
        base_url='https://api.github.com/',
        request_token_url=None,
        access_token_method='POST',
        access_token_url='https://github.com/login/oauth/access_token',
        authorize_url='https://github.com/login/oauth/authorize')
    github._tokengetter = lambda: session.get('github_token')
    resources.github = app.github = github

最后,它设置路由映射并存储初始化的app对象:

api = Api(app)
    resource_map = (
        (Link, '/v1.0/links'),
    )

    for resource, route in resource_map:
        api.add_resource(resource, route)

    return app

app = create_app()

将流量路由到内部微服务

API 网关服务的主要工作是实现我们在第二章中讨论的 API 网关模式,开始使用微服务。例如,它是如何将获取链接请求路由到链接微服务的适当方法的。

Link类是从Resource基类派生的。它从环境中获取主机和端口,并构造基本 URL。

当 GET 请求links端点时,将调用get()方法。它从_get_user()函数中的 GitHub 令牌中提取用户名,并解析请求 URL 的查询部分以获取其他参数。然后,它会向链接管理器服务发出自己的请求:

class Link(Resource):
    host = os.environ.get('LINK_MANAGER_SERVICE_HOST', 'localhost')
    port = os.environ.get('LINK_MANAGER_SERVICE_PORT', '8080')
    base_url = 'http://{}:{}/links'.format(host, port)

    def get(self):
        """Get all links

        If user doesn't exist create it (with no goals)
        """
        username, email = _get_user()
        parser = RequestParser()
        parser.add_argument('url_regex', type=str, required=False)
        parser.add_argument('title_regex', type=str, required=False)
        parser.add_argument('description_regex', type=str, required=False)
        parser.add_argument('tag', type=str, required=False)
        parser.add_argument('start_token', type=str, required=False)
        args = parser.parse_args()
        args.update(username=username)
        r = requests.get(self.base_url, params=args)

        if not r.ok:
            abort(r.status_code, message=r.content)

        return r.json()

利用基础 Docker 镜像来减少构建时间

当我们为 Delinkcious 构建 Go 微服务时,我们使用了 scratch 镜像作为基础,只是复制了 Go 二进制文件。这些镜像非常轻量,不到 10MB。然而,即使使用python:alpine,API 网关也几乎有 500MB,这比标准的基于 Debian 的 Python 镜像要轻得多:

$ docker images | grep g1g1.*0.3
g1g1/delinkcious-user              0.3    07bcc08b1d73   38 hours ago    6.09MB
g1g1/delinkcious-social-graph      0.3    0be0e9e55689   38 hours ago    6.37MB
g1g1/delinkcious-news              0.3    0ccd600f2190   38 hours ago    8.94MB
g1g1/delinkcious-link              0.3    9fcd7aaf9a98   38 hours ago    6.95MB
g1g1/delinkcious-api-gateway       0.3    d5778d95219d   38 hours ago    493MB

此外,API 网关需要构建一些与本地库的绑定。安装 C/C++工具链,然后构建本地库需要很长时间(超过 15 分钟)。Docker 在这里表现出色,具有可重用的层和基础镜像。我们可以将所有繁重的东西放入一个单独的基础镜像中,位于svc/shared/docker/python_flask_grpc/Dockerfile

FROM python:alpine
RUN apk add build-base
COPY requirements.txt /tmp
WORKDIR /tmp
RUN pip install -r requirements.txt

requirements.txt文件包含执行社交登录并需要使用 gRPC 服务的Flask应用程序的依赖项(稍后详细介绍):

requests-oauthlib==1.1.0
Flask-OAuthlib==0.9.5
Flask-RESTful==0.3.7
grpcio==1.18.0
grpcio-tools==1.18.0

将所有这些放在一起,我们可以构建基础镜像,然后 API 网关 Dockerfile 可以基于它。以下是在svc/shared/docker/python_flask_grpc/build.sh中的超级简单构建脚本,用于构建基础镜像并将其推送到 DockerHub:

IMAGE=g1g1/delinkcious-python-flask-grpc:0.1
docker build . -t $IMAGE
docker push $IMAGE

让我们看一下svc/api_gateway_service/Dockerfile中 API 网关服务的 Dockerfile。它基于我们的基础镜像。然后,它复制api_gate_service目录,公开5000端口,并执行run.py脚本:

FROM g1g1/delinkcious-python-flask-grpc:0.1
MAINTAINER Gigi Sayfan "the.gigi@gmail.com"
COPY . /api_gateway_service
WORKDIR /api_gateway_service
EXPOSE 5000
ENTRYPOINT python run.py

好处是只要重型基础镜像不改变,对实际 API 服务网关代码进行更改将导致闪电般快速的 Docker 镜像构建。我们说的是几秒钟,而不是 15 分钟。在这一点上,我们对 API 网关服务有了一个不错而快速的构建-测试-调试-部署。现在是向集群添加入口的好时机。

添加入口

在 Minikube 上,您必须启用入口附加组件:

$ minikube addons enable ingress 
 ingress was successfully enabled

在其他 Kubernetes 集群上,您可能希望安装自己喜欢的入口控制器(例如 Contour、Traefik 或 Ambassador)。

以下代码是 API 网关服务的入口清单。通过使用这种模式,我们的整个集群将有一个单一的入口,将每个请求引导到我们的 API 网关服务,然后将其路由到适当的内部服务:

apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: api-gateway
  annotations:
    nginx.ingress.kubernetes.io/rewrite-target: /
spec:
  rules:
  - host: delinkcio.us
    http:
      paths:
      - path: /*
        backend:
          serviceName: api-gateway
          servicePort: 80

单个入口服务简单而有效。在大多数云平台上,您按入口资源付费,因为为每个入口资源创建了一个负载均衡器。您可以轻松扩展 API 网关实例的数量,因为它完全无状态。

Minikube 在网络下面做了很多魔术,模拟负载均衡器,并隧道流量。我不建议使用 Minikube 来测试对集群的入口。相反,我们将使用 LoadBalancer 类型的服务,并通过 Minikube 集群 IP 访问它。

验证 API 网关在集群外部是否可用

Delinkcious 使用 GitHub 作为社交登录提供程序。您必须拥有 GitHub 帐户才能跟随。

用户流程如下:

  1. 查找 Delinkcious URL(在 Minikube 上,这将经常更改)。

  2. 登录并获取访问令牌。

  3. 从集群外部访问 Delinkcious API 网关。

让我们深入详细讨论一下。

查找 Delinkcious URL

在生产集群中,您将配置一个众所周知的 DNS 名称,并连接一个负载均衡器到该名称。使用 Minikube,我们可以使用以下命令获取 API 网关服务的 URL:

$ minikube service api-gateway --url
http://192.168.99.138:31658

为了与命令进行交互使用,将其存储在环境变量中是方便的,如下所示:

$ export DELINKCIOUS_URL=$(minikube service api-gateway --url)

获取访问令牌

获取访问令牌的步骤如下:

  1. 现在我们有了 API 网关 URL,我们可以浏览到登录端点,即 http://192.168.99.138:31658/login。如果您已登录到您的 GitHub 帐户,您将看到以下对话框:

  1. 接下来,如果这是您第一次登录 Delinkcious,GitHub 将要求您授权 Delinkcious 获取访问您的电子邮件和姓名:

  1. 如果您同意,那么您将被重定向到一个页面,该页面将向您显示有关您的 GitHub 个人资料的大量信息,但更重要的是,向您提供一个访问令牌,如下截图所示:

让我们也将访问令牌存储在环境变量中:

$ export DELINKCIOUS_TOKEN=def7de18d9c05ce139e37140871a9d16fd37ea9d

现在我们已经获得了从外部访问 Delinkcious 所需的所有信息,让我们来试一试。

从集群外部访问 Delinkcious API 网关

我们将使用 HTTPie 命中 ${DELINKCIOUS_URL}/v1.0/links 的 API 网关端点。要进行身份验证,我们必须将访问令牌作为标头提供,即 "Access-Token: ${DELINKCIOUS_TOKEN}"

从零开始,让我们验证一下是否没有任何链接:

$ http "${DELINKCIOUS_URL}/v1.0/links" "Access-Token: ${DELINKCIOUS_TOKEN}"
HTTP/1.0 200 OK
Content-Length: 27
Content-Type: application/json
Date: Mon, 04 Mar 2019 00:52:18 GMT
Server: Werkzeug/0.14.1 Python/3.7.2

{
    "err": "",
    "links": null
}

好了,到目前为止一切都很顺利。让我们通过向 /v1.0/links 端点发送 POST 请求来添加一些链接。这是第一个链接:

$ http POST "${DELINKCIOUS_URL}/v1.0/links" "Access-Token: ${DELINKCIOUS_TOKEN}" url=http://gg.com title=example
HTTP/1.0 200 OK
Content-Length: 12
Content-Type: application/json
Date: Mon, 04 Mar 2019 00:52:49 GMT
Server: Werkzeug/0.14.1 Python/3.7.2

{
    "err": ""
}

这是第二个链接:

$ http POST "${DELINKCIOUS_URL}/v1.0/links" "Access-Token: ${DELINKCIOUS_TOKEN}" url=http://gg2.com title=example
HTTP/1.0 200 OK
Content-Length: 12
Content-Type: application/json
Date: Mon, 04 Mar 2019 00:52:49 GMT
Server: Werkzeug/0.14.1 Python/3.7.2

{
    "err": ""
}

没有错误。太好了。通过再次获取链接,我们可以看到我们刚刚添加的新链接:

$ http "${DELINKCIOUS_URL}/v1.0/links" "Access-Token: ${DELINKCIOUS_TOKEN}"
HTTP/1.0 200 OK
Content-Length: 330
Content-Type: application/json
Date: Mon, 04 Mar 2019 00:52:52 GMT
Server: Werkzeug/0.14.1 Python/3.7.2

{
    "err": "",
    "links": [
        {
            "CreatedAt": "2019-03-04T00:52:35Z",
            "Description": "",
            "Tags": null,
            "Title": "example",
            "UpdatedAt": "2019-03-04T00:52:35Z",
            "Url": "http://gg.com"
        },
        {
            "CreatedAt": "2019-03-04T00:52:48Z",
            "Description": "",
            "Tags": null,
            "Title": "example",
            "UpdatedAt": "2019-03-04T00:52:48Z",
            "Url": "http://gg2.com"
        }
    ]
}

我们已成功建立了端到端的流程,包括用户身份验证,因此通过其内部 HTTP REST API 与 Go 微服务通信的 Python API 网关服务,并将信息存储在关系型数据库中。现在,让我们提高赌注并添加另一个服务。

这次,将使用 gRPC 传输的 Go 微服务。

提供和使用内部 gRPC API

我们将在本节中实现的服务称为新闻服务。它的工作是跟踪链接事件,如添加链接或更新链接,并向用户返回新事件。

定义 NewsManager 接口

此接口公开了一个GetNews()方法。用户可以调用它并从他们关注的用户那里收到链接事件列表。以下是 Go 接口和相关结构。它并不复杂:一个带有usernametoken字段的请求结构体,以及一个结果结构体。结果结构体包含一个Event结构体列表,其中包含以下信息:EventTypeUsernameUrlTimestamp

type NewsManager interface {
        GetNews(request GetNewsRequest) (GetNewsResult, error)
}

type GetNewsRequest struct {
        Username   string
        StartToken string
}

type Event struct {
        EventType EventTypeEnum
        Username  string
        Url       string
        Timestamp time.Time
}

type GetNewsResult struct {
        Events    []*Event
        NextToken string
}

实现新闻管理器包

核心逻辑服务的实现在pkg/news_manager中。让我们看一下new_manager.go文件。NewsManager结构有一个名为eventStoreInMemoryNewsStore,它实现了NewsManager接口的GetNews()方法。它将实际获取新闻的工作委托给存储。

但是,它知道分页并负责将令牌从字符串转换为整数以匹配存储偏好:

package news_manager

import (
        "errors"
        "github.com/the-gigi/delinkcious/pkg/link_manager_events"
        om "github.com/the-gigi/delinkcious/pkg/object_model"
        "strconv"
        "time"
)

type NewsManager struct {
        eventStore *InMemoryNewsStore
}

func (m *NewsManager) GetNews(req om.GetNewsRequest) (resp om.GetNewsResult, err error) {
        if req.Username == "" {
                err = errors.New("user name can't be empty")
                return
        }

        startIndex := 0
        if req.StartToken != "" {
                startIndex, err := strconv.Atoi(req.StartToken)
                if err != nil || startIndex < 0 {
                        err = errors.New("invalid start token: " + req.StartToken)
                        return resp, err
                }
        }

        events, nextIndex, err := m.eventStore.GetNews(req.Username, startIndex)
        if err != nil {
                return
        }

        resp.Events = events
        if nextIndex != -1 {
                resp.NextToken = strconv.Itoa(nextIndex)
        }

        return
}

存储非常基础,只是在用户名和所有事件之间保持映射,如下所示:

package news_manager

import (
        "errors"
        om "github.com/the-gigi/delinkcious/pkg/object_model"
)

const maxPageSize = 10

// User events are a map of username:userEvents
type userEvents map[string][]*om.Event

// InMemoryNewsStore manages a UserEvents data structure
type InMemoryNewsStore struct {
        userEvents userEvents
}

func NewInMemoryNewsStore() *InMemoryNewsStore {
        return &InMemoryNewsStore{userEvents{}}
}

存储实现了自己的GetNews()方法(与interface方法的签名不同)。它只是根据起始索引和最大页面大小返回目标用户请求的切片:

func (m *InMemoryNewsStore) GetNews(username string, startIndex int) (events []*om.Event, nextIndex int, err error) {
        userEvents := m.userEvents[username]
        if startIndex > len(userEvents) {
                err = errors.New("Index out of bounds")
                return
        }

        pageSize := len(userEvents) - startIndex
        if pageSize > maxPageSize {
                pageSize = maxPageSize
                nextIndex = startIndex + maxPageSize
        } else {
                nextIndex = -1
        }

        events = userEvents[startIndex : startIndex+pageSize]
        return
}

它还有一种添加新事件的方法:

func (m *InMemoryNewsStore) AddEvent(username string, event *om.Event) (err error) {
        if username == "" {
                err = errors.New("user name can't be empty")
                return
        }

        if event == nil {
                err = errors.New("event can't be nil")
                return
        }

        if m.userEvents[username] == nil {
                m.userEvents[username] = []*om.Event{}
        }

        m.userEvents[username] = append(m.userEvents[username], event)
        return
}

现在我们已经实现了存储和向用户提供新闻的核心逻辑,让我们看看如何将这个功能公开为 gRPC 服务。

将 NewsManager 公开为 gRPC 服务

在深入了解新闻服务的 gRPC 实现之前,让我们看看到底是怎么回事。gRPC 是一组用于连接服务和应用程序的传输协议、有效载荷格式、概念框架和代码生成工具。它起源于 Google(因此在 gRPC 中有 g),是一个高性能且成熟的 RPC 框架。它有很多优点,比如以下:

  • 跨平台

  • 行业广泛采用

  • 所有相关编程语言的惯用客户端库

  • 极其高效的传输协议

  • Google 协议缓冲区用于强类型合同

  • HTTP/2 支持实现双向流

  • 高度可扩展(自定义您自己的身份验证、授权、负载均衡和健康检查)

  • 出色的文档

总之,对于内部微服务而言,它在几乎所有方面都优于基于 HTTP 的 REST API。

对于 Delinkcious 来说,这非常合适,因为我们选择的微服务框架 Go-kit 对 gRPC 有很好的支持。

定义 gRPC 服务契约

gRPC 要求您使用受协议缓冲区启发的特殊 DSL 为您的服务定义契约。它非常直观,并且让 gRPC 为您生成大量样板代码。我选择将契约和生成的代码放在一个名为pb(协议缓冲区的常用简称)的单独顶级目录中,因为生成的代码的不同部分将被服务和消费者使用。在这些情况下,最好将共享代码放在一个单独的位置,而不是随意地将其放入服务或客户端。

这是pb/new-service/pb/news.proto文件:

syntax = "proto3";
package pb;

import "google/protobuf/timestamp.proto";

service News {
    rpc GetNews(GetNewsRequest) returns (GetNewsResponse) {}
}

message GetNewsRequest {
    string username = 1;
    string startToken = 2;
}

enum EventType {
    LINK_ADDED = 0;
    LINK_UPDATED = 1;
    LINK_DELETED = 2;
}

message Event  {
        EventType eventType = 1;
        string username = 2;
        string url = 3;
        google.protobuf.Timestamp timestamp = 4;
}

message GetNewsResponse {
        repeated Event events = 1;
        string nextToken = 2;
    string err = 3;
}

我们不需要逐行讨论每一行的语法和含义。简而言之,请求和响应始终是消息。服务级错误需要嵌入在响应消息中。其他错误,如网络或无效的有效载荷,将被单独报告。一个有趣的细节是,除了原始数据类型和嵌入的消息之外,您还可以使用其他高级类型,例如google.protobuf.Timestamp数据类型。这显著提高了抽象级别,并为诸如日期和时间戳之类的事物带来了强类型化的好处,这些事物在使用 JSON 进行 HTTP/REST 工作时,您总是需要自己进行序列化和反序列化。

服务定义很酷,但我们需要一些实际的代码来连接这些点。让我们看看 gRPC 如何帮助完成这个任务。

使用 gRPC 生成服务存根和客户端库

gRPC 模型用于使用一个名为protoc的工具生成服务存根和客户端库。我们需要为新闻服务本身生成 Go 代码,以及为消费它的 API 网关生成 Python 代码。

您可以通过运行以下命令生成news.pb.go

protoc --go_out=plugins=grpc:. news.proto

您可以通过运行以下命令生成news_pb2.pynews_pb2_grpc.py

python -m grpc_tools.protoc -I. --python_out=. --grpc_python_out=. news.proto

此时,Go 客户端代码和 Python 客户端代码都可以用于从 Go 代码或 Python 代码调用新闻服务。

使用 Go-kit 构建 NewsManager 服务

这是在news_service.go中服务本身的实现。它看起来非常类似于 HTTP 服务。让我们分解一下重要的部分。首先,它导入一些库,包括在pb/news-service-pbpkg/news_manager和一个名为google.golang.org/grpc的一般 gRPC 库中生成的 gRPC 代码。在Run()函数的开头,它从环境中获取service端口来监听:

package service

import (
        "fmt"
        "github.com/the-gigi/delinkcious/pb/news_service/pb"
        nm "github.com/the-gigi/delinkcious/pkg/news_manager"
        "google.golang.org/grpc"
        "log"
        "net"
        "os"
)

func Run() {
        port := os.Getenv("PORT")
        if port == "" {
                port = "6060"
        }

现在,我们需要在目标端口上创建一个标准的 TCP 监听器:

listener, err := net.Listen("tcp", ":"+port)
        if err != nil {
                log.Fatal(err)
        }

此外,我们必须连接到一个 NATS 消息队列服务。我们将在下一节中详细讨论这个问题:

natsHostname := os.Getenv("NATS_CLUSTER_SERVICE_HOST")
        natsPort := os.Getenv("NATS_CLUSTER_SERVICE_PORT")

这里是主要的初始化代码。它实例化一个新的新闻管理器,创建一个新的 gRPC 服务器,创建一个新闻管理器对象,并将新闻管理器注册到 gRPC 服务器。pb.RegisterNewsManager()方法是由 gRPC 从news.proto文件生成的:

svc, err := nm.NewNewsManager(natsHostname, natsPort)
        if err != nil {
                log.Fatal(err)
        }

        gRPCServer := grpc.NewServer()
        newsServer := newNewsServer(svc)
        pb.RegisterNewsServer(gRPCServer, newsServer)

最后,gRPC 服务器开始在 TCP 监听器上监听:

fmt.Printf("News service is listening on port %s...\n", port)
        err = gRPCServer.Serve(listener)
        fmt.Println("Serve() failed", err)
}

实现 gRPC 传输

拼图的最后一部分是在transport.go文件中实现 gRPC 传输。在概念上,它类似于 HTTP 传输,但有一些不同的细节。让我们分解一下,以便清楚地了解所有部分是如何组合在一起的。

首先,导入所有相关的包,包括来自 go-kit 的 gRPC 传输。请注意,在news_service.go中,没有任何地方提到 go-kit。您肯定可以直接在 Go 中使用一般的 gRPC 库实现 gRPC 服务。然而,在这里,通过其服务和端点的概念,go-kit 将帮助使这变得更容易:

package service

import (
        "context"
        "github.com/go-kit/kit/endpoint"
        grpctransport "github.com/go-kit/kit/transport/grpc"
        "github.com/golang/protobuf/ptypes/timestamp"
        "github.com/the-gigi/delinkcious/pb/news_service/pb"
        om "github.com/the-gigi/delinkcious/pkg/object_model"
)

newEvent()函数是一个辅助函数,它从我们的抽象对象模型中采用om.Event到 gRPC 生成的事件对象。最重要的部分是翻译事件类型和时间戳:

func newEvent(e *om.Event) (event *pb.Event) {
        event = &pb.Event{
                EventType: (pb.EventType)(e.EventType),
                Username:  e.Username,
                Url:       e.Url,
        }

        seconds := e.Timestamp.Unix()
        nanos := (int32(e.Timestamp.UnixNano() - 1e9*seconds))
        event.Timestamp = &timestamp.Timestamp{Seconds: seconds, Nanos: nanos}
        return
}

解码请求和编码响应非常简单 - 没有必要序列化或反序列化任何 JSON 代码:

func decodeGetNewsRequest(_ context.Context, r interface{}) (interface{}, error) {
        request := r.(*pb.GetNewsRequest)
        return om.GetNewsRequest{
                Username:   request.Username,
                StartToken: request.StartToken,
        }, nil
}

func encodeGetNewsResponse(_ context.Context, r interface{}) (interface{}, error) {
        return r, nil
}

创建端点类似于您在其他服务中看到的 HTTP 传输。它调用实际的服务实现,然后翻译响应并处理错误(如果有的话):

func makeGetNewsEndpoint(svc om.NewsManager) endpoint.Endpoint {
        return func(_ context.Context, request interface{}) (interface{}, error) {
                req := request.(om.GetNewsRequest)
                r, err := svc.GetNews(req)
                res := &pb.GetNewsResponse{
                        Events:    []*pb.Event{},
                        NextToken: r.NextToken,
                }
                if err != nil {
                        res.Err = err.Error()
                }
                for _, e := range r.Events {
                        event := newEvent(e)
                        res.Events = append(res.Events, event)
                }
                return res, nil
        }
}

处理程序实现了从生成的代码中的 gRPC 新闻接口:

type handler struct {
        getNews grpctransport.Handler
}

func (s *handler) GetNews(ctx context.Context, r *pb.GetNewsRequest) (*pb.GetNewsResponse, error) {
        _, resp, err := s.getNews.ServeGRPC(ctx, r)
        if err != nil {
                return nil, err
        }

        return resp.(*pb.GetNewsResponse), nil
}

newNewsServer()函数将所有内容联系在一起。它返回一个包装在 Go-kit 处理程序中的 gRPC 处理程序,连接端点、请求解码器和响应编码器:

func newNewsServer(svc om.NewsManager) pb.NewsServer {
        return &handler{
                getNews: grpctransport.NewServer(
                        makeGetNewsEndpoint(svc),
                        decodeGetNewsRequest,
                        encodeGetNewsResponse,
                ),
        }
}

这可能看起来非常混乱,有着各种层和嵌套函数,但底线是你只需要编写很少的粘合代码(并且可以生成它,这是理想的),最终得到一个非常干净、安全(强类型)和高效的 gRPC 服务。

现在我们有了一个可以提供新闻的 gRPC 新闻服务,让我们看看如何为其提供新闻。

通过消息队列发送和接收事件

新闻服务需要为每个用户存储链接事件。链接服务知道不同用户何时添加、更新或删除链接。解决这个问题的一种方法是向新闻服务添加另一个 API,并让链接服务调用此 API,并通知新闻服务每个相关事件。然而,这种方法会在链接服务和新闻服务之间创建紧密耦合。链接服务并不真正关心新闻服务,因为它不需要任何来自新闻服务的东西。相反,让我们选择一种松散耦合的解决方案。链接服务只会向一个通用消息队列服务发送事件。然后,独立地,新闻服务将订阅从该消息队列接收消息。这种方法有几个好处,如下所示:

  • 不需要更复杂的服务代码

  • 与事件通知的交互模型完美契合

  • 很容易在不改变代码的情况下添加额外的监听器到相同的事件

我在这里使用的术语,即消息事件通知,是可以互换的。这个想法是,源有一些信息以一种即时即忘的方式与世界分享。

它不需要知道谁对信息感兴趣(可能是没有人或多个监听器),以及是否成功处理。Delinkcious 使用 NATS 消息系统进行服务之间的松散耦合通信。

NATS 是什么?

NATS(nats.io/)是一个开源消息队列服务。它是一个Cloud Native Computing FoundationCNCF)项目,用 Go 实现,被认为是在 Kubernetes 中需要消息队列时的顶级竞争者之一。NATS 支持多种消息传递模型,如下所示:

  • 发布-订阅

  • 请求-回复

  • 排队

NATS 非常灵活,可以用于许多用例。它也可以在高可用的集群中运行。对于 Delinkcious,我们将使用发布-订阅模型。以下图表说明了发布-订阅消息传递模型。发布者发布一条消息,所有订阅者都会收到相同的消息:

让我们在我们的集群中部署 NATS。

在集群中部署 NATS

首先,让我们安装 NATS 操作员(github.com/nats-io/nats-operator)。NATS 操作员可以帮助您在 Kubernetes 中管理 NATS 集群。以下是安装它的命令:

$ kubectl apply -f https://github.com/nats-io/nats-operator/releases/download/v0.4.5/00-prereqs.yaml
$ kubectl apply -f https://github.com/nats-io/nats-operator/releases/download/v0.4.5/10-deployment.yaml

NATS 操作员提供了一个 NatsCluster 自定义资源定义CRD),我们将使用它在我们的 Kubernetes 集群中部署 NATS。不要被 Kubernetes 集群内的 NATS 集群关系所困扰。这真的很好,因为我们可以像内置的 Kubernetes 资源一样部署 NATS 集群。以下是在svc/shared/k8s/nats_cluster.yaml中可用的 YAML 清单:

apiVersion: nats.io/v1alpha2
kind: NatsCluster
metadata:
  name: nats-cluster
spec:
  size: 1
  version: "1.3.0"

让我们使用kubectl部署它,并验证它是否被正确部署:

$ kubectl apply -f nats_cluster.yaml
natscluster.nats.io "nats-cluster" configured

$ kubectl get svc -l app=nats
NAME                TYPE      CLUSTER-IP   EXTERNAL-IP   PORT(S)    AGE
nats-cluster       ClusterIP  10.102.48.27  <none>       4222/TCP    5d
nats-cluster-mgmt  ClusterIP   None         <none>        6222/TCP,8222/TCP,7777/TCP   5d

看起来不错。监听端口4222nats-cluster服务是 NATS 服务器。另一个服务是管理服务。让我们向 NATS 服务器发送一些事件。

使用 NATS 发送链接事件

正如你可能记得的,我们在我们的对象模型中定义了一个LinkManagerEvents接口:

type LinkManagerEvents interface {
        OnLinkAdded(username string, link *Link)
        OnLinkUpdated(username string, link *Link)
        OnLinkDeleted(username string, url string)
}

LinkManager包在其NewLinkManager()方法中接收此事件链接:

func NewLinkManager(linkStore LinkStore,
        socialGraphManager om.SocialGraphManager,
        eventSink om.LinkManagerEvents,
        maxLinksPerUser int64) (om.LinkManager, error) {
        if linkStore == nil {
                return nil, errors.New("link store")
        }

        if eventSink != nil && socialGraphManager == nil {
                msg := "social graph manager can't be nil if event sink is not nil"
                return nil, errors.New(msg)
        }

        return &LinkManager{
                linkStore:          linkStore,
                socialGraphManager: socialGraphManager,
                eventSink:          eventSink,
                maxLinksPerUser:    maxLinksPerUser,
        }, nil
}

稍后,当链接被添加、更新或删除时,LinkManager将调用相应的OnLinkXXX()方法。例如,当调用AddLink()时,对于每个关注者,都会在接收器上调用OnLinkAdded()方法:

if m.eventSink != nil {
                followers, err := m.socialGraphManager.GetFollowers(request.Username)
                if err != nil {
                        return err
                }

                for follower := range followers {
                        m.eventSink.OnLinkAdded(follower, link)
                }
        }

这很棒,但这些事件将如何传送到 NATS 服务器?这就是链接服务发挥作用的地方。在实例化LinkManager对象时,它将传递一个专用的事件发送对象作为实现LinkManagerEvents的接收器。每当它接收到诸如OnLinkAdded()OnLinkUpdated()之类的事件时,它会将事件发布到link-events主题的 NATS 服务器上。它暂时忽略OnLinkDeleted()事件。这个对象位于pkg/link_manager_events package/sender.go中:

package link_manager_events

import (
        "github.com/nats-io/go-nats"
        "log"

        om "github.com/the-gigi/delinkcious/pkg/object_model"
)

type eventSender struct {
        hostname string
        nats     *nats.EncodedConn
}

这里是OnLinkAdded()OnLinkUpdated()OnLinkDeleted()方法的实现:

func (s *eventSender) OnLinkAdded(username string, link *om.Link) {
        err := s.nats.Publish(subject, Event{om.LinkAdded, username, link})
        if err != nil {
                log.Fatal(err)
        }
}

func (s *eventSender) OnLinkUpdated(username string, link *om.Link) {
        err := s.nats.Publish(subject, Event{om.LinkUpdated, username, link})
        if err != nil {
                log.Fatal(err)
        }
}

func (s *eventSender) OnLinkDeleted(username string, url string) {
        // Ignore link delete events
}

NewEventSender()工厂函数接受 NATS 服务的 URL,将事件发送到 NATS 服务,并返回一个LinkManagerEvents接口,可以作为LinkManager的接收端:

func NewEventSender(url string) (om.LinkManagerEvents, error) {
        ec, err := connect(url)
        if err != nil {
                return nil, err
        }
        return &eventSender{hostname: url, nats: ec}, nil
}

现在,链接服务所需做的就是找出 NATS 服务器的 URL。由于 NATS 服务器作为 Kubernetes 服务运行,其主机名和端口可以通过环境变量获得,就像 Delinkcious 微服务一样。以下是链接服务的Run()函数中的相关代码:

natsHostname := os.Getenv("NATS_CLUSTER_SERVICE_HOST")
        natsPort := os.Getenv("NATS_CLUSTER_SERVICE_PORT")

        var eventSink om.LinkManagerEvents
        if natsHostname != "" {
                natsUrl := natsHostname + ":" + natsPort
                eventSink, err = nats.NewEventSender(natsUrl)
                if err != nil {
                        log.Fatal(err)
                }
        } else {
                eventSink = &EventSink{}
        }

        svc, err := lm.NewLinkManager(store, socialGraphClient, eventSink, maxLinksPerUser)
        if err != nil {
                log.Fatal(err)
        }

此时,每当为用户添加或更新新链接时,LinkManager将为每个关注者调用OnLinkAdded()OnLinkUpdated()方法,这将导致该事件被发送到link-events主题的 NATS 服务器上,所有订阅者都将收到并处理它。下一步是新闻服务订阅这些事件。

使用 NATS 订阅链接事件

新闻服务使用pkg/link_manager_events/listener.go中的Listen()函数。它接受 NATS 服务器的 URL 和实现LinkManagerEvents接口的事件接收端。它连接到 NATS 服务器,然后订阅link-events主题。这与事件发送器发送这些事件的主题相同:

package link_manager_events

import (
        om "github.com/the-gigi/delinkcious/pkg/object_model"
)

func Listen(url string, sink om.LinkManagerEvents) (err error) {
        conn, err := connect(url)
        if err != nil {
                return
        }

        conn.Subscribe(subject, func(e *Event) {
                switch e.EventType {
                case om.LinkAdded:
                        {
                                sink.OnLinkAdded(e.Username, e.Link)
                        }
                case om.LinkUpdated:
                        {
                                sink.OnLinkAdded(e.Username, e.Link)
                        }
                default:
                        // Ignore other event types
                }
        })

        return
}

现在,让我们看一下定义link-events主题的nats.go文件,以及connect()函数,该函数被事件发送器和Listen()函数使用。连接函数使用go-nats客户端建立连接,然后用 JSON 编码器包装它,这使它能够自动序列化发送和接收 Go 结构。这很不错:

package link_manager_events

import "github.com/nats-io/go-nats"

const subject = "link-events"

func connect(url string) (encodedConn *nats.EncodedConn, err error) {
        conn, err := nats.Connect(url)
        if err != nil {
                return
        }

        encodedConn, err = nats.NewEncodedConn(conn, nats.JSON_ENCODER)
        return
}

新闻服务在其NewNewsManager()工厂函数中调用Listen()函数。首先,它实例化实现LinkManagerEvents的新闻管理器对象。然后,如果提供了 NATS 主机名,则组合 NATS 服务器 URL 并调用Listen()函数,从而将新闻管理器对象作为接收端传递:

func NewNewsManager(natsHostname string, natsPort string) (om.NewsManager, error) {
        nm := &NewsManager{eventStore: NewInMemoryNewsStore()}
        if natsHostname != "" {
                natsUrl := natsHostname + ":" + natsPort
                err := link_manager_events.Listen(natsUrl, nm)
                if err != nil {
                        return nil, err
                }
        }

        return nm, nil
}

下一步是对传入的事件进行处理。

处理链接事件

新闻管理器通过NewNewsManager()函数订阅链接事件,结果是这些事件将作为对OnLinkAdded()OnlinkUpdated()的调用到达(删除链接事件被忽略)。新闻管理器创建了一个在抽象对象模型中定义的Event对象,用EventTypeUsernameUrlTimestamp填充它,然后调用事件存储的AddEvent()函数。这是OnLinkAdded()方法:

func (m *NewsManager) OnLinkAdded(username string, link *om.Link) {
        event := &om.Event{
                EventType: om.LinkAdded,
                Username:  username,
                Url:       link.Url,
                Timestamp: time.Now().UTC(),
        }
        m.eventStore.AddEvent(username, event)
}

这是OnLinkUpdated()方法:

func (m *NewsManager) OnLinkUpdated(username string, link *om.Link) {
        event := &om.Event{
                EventType: om.LinkUpdated,
                Username:  username,
                Url:       link.Url,
                Timestamp: time.Now().UTC(),
        }
        m.eventStore.AddEvent(username, event)
}

让我们看看存储在其AddEvent()方法中做了什么。这很简单:订阅用户位于userEvents映射中。如果他们还不存在,那么将创建一个空条目并添加新事件。如果目标用户调用GetNews(),他们将收到为他们收集的事件:

func (m *InMemoryNewsStore) AddEvent(username string, event *om.Event) (err error) {
        if username == "" {
                err = errors.New("user name can't be empty")
                return
        }
        if event == nil {
                err = errors.New("event can't be nil")
                return
        }
        if m.userEvents[username] == nil {
                m.userEvents[username] = []*om.Event{}
        }
        m.userEvents[username] = append(m.userEvents[username], event)
        return
}

这就结束了我们对新闻服务及其通过 NATS 服务与链接管理器的交互的覆盖。这是我们在第二章中讨论的命令查询责任分离CQRS)模式的应用,使用微服务入门。现在 Delinkcious 系统看起来是这样的:

现在我们了解了 Delinkcious 中如何处理事件,让我们快速看一下服务网格。

理解服务网格

服务网格是在您的集群中运行的另一层管理。我们将在第十三章中详细了解服务网格和特别是 Istio,服务网格-使用 Istio。在这一点上,我只想提一下,服务网格经常也承担入口控制器的角色。

使用服务网格进行入口的主要原因之一是,内置的入口资源非常通用,受到多个问题的限制,例如以下问题:

  • 没有很好的方法来验证规则

  • 入口资源可能会相互冲突

  • 使用特定的入口控制器通常很复杂,并且需要自定义注释。

总结

在本章中,我们完成了许多任务并连接了所有要点。特别是,我们实现了两种微服务设计模式(API 网关和 CQRS),添加了一个用 Python 实现的全新服务(包括一个分割的 Docker 基础镜像),添加了一个 gRPC 服务,向我们的集群添加了一个开源消息队列系统(NATS)并将其与发布-订阅消息传递集成,最后,我们向世界打开了我们的集群,并通过向 Delinkcious 添加和获取链接来演示端到端的交互。

在这一点上,Delinkcious 可以被视为 Alpha 级软件。它是功能性的,但离生产就绪还差得远。在下一章中,我们将通过处理任何软件系统的最有价值的商品 - 数据,使 Delinkcious 更加健壮。Kubernetes 提供了许多管理数据和有状态服务的设施,我们将充分利用它们。

进一步阅读

您可以参考以下来源,了解本章涵盖的更多信息:

www.devx.com/architect/high-performance-services-with-grpc.html

第八章:使用有状态服务

到目前为止,一切都很有趣。我们构建了服务,将它们部署到 Kubernetes,并对这些服务运行命令和查询。我们通过在部署时调度 Pod 或在出现问题时使 Kubernetes 能够使这些服务正常运行。这对于可以在任何地方运行的无状态服务非常有效。在现实世界中,分布式系统管理重要数据。如果数据库将其数据存储在主机文件系统上,而该主机宕机,您(或 Kubernetes)不能只是在新节点上启动数据库的新实例,因为数据将丢失。

一般来说,通过冗余来防止数据丢失;您可以保留多个副本,存储备份,利用追加日志等。Kubernetes 通过提供整个存储模型以及相关资源的概念来提供帮助,例如卷、卷索赔和 StatefulSets。

在本章中,我们将深入探讨 Kubernetes 存储模型。我们还将扩展 Delinkcious 新闻服务,将其数据存储在 Redis 中,而不是内存中。我们将涵盖以下主题:

  • 抽象存储

  • 将数据存储在 Kubernetes 集群之外

  • 使用 StatefulSets 在 Kubernetes 集群内部存储数据

  • 使用本地存储实现高性能

  • 在 Kubernetes 中使用关系型数据库

  • 在 Kubernetes 中使用非关系型数据存储

技术要求

在本章中,我们将检查一些 Kubernetes 清单,使用不同的存储选项,并扩展 Delinkcious 以支持新的数据存储。无需安装任何新内容。

代码

代码分为两个 Git 存储库,如下所示:

抽象存储

Kubernetes 的核心是一个编排引擎,用于管理容器化的工作负载。请注意,这里的关键词是容器化。Kubernetes 不关心工作负载是什么,只要它们被打包在容器中;它知道如何处理它们。最初,Kubernetes 只支持 Docker 镜像,然后后来添加了对其他运行时的支持。然后,Kubernetes 1.5 引入了容器运行时接口CRI),并逐渐将对其他运行时的显式支持推出了树外。在这里,Kubernetes 不再关心节点上实际部署的容器运行时是什么,只需要与 CRI 一起工作。

类似的情况也发生在网络中,容器网络接口CNI)早已定义。Kubernetes 的生命周期很简单。不同的网络解决方案提供它们的 CNI 插件。然而,存储是不同的(直到不是)。在接下来的小节中,我们将介绍 Kubernetes 存储模型,了解树内和树外存储插件之间的区别,最后了解容器存储接口CSI),它为 Kubernetes 中的存储提供了一个巧妙的解决方案。

Kubernetes 存储模型

Kubernetes 存储模型包括几个概念:存储类、卷、持久卷和持久卷索赔。让我们看看这些概念是如何相互作用,允许容器化工作负载在执行期间访问存储的。

存储类

存储类是描述可以供应的存储类型的一种方式。通常,在没有指定特定存储类的情况下供应卷时会使用默认存储类。这是 Minikube 中的标准存储类,它在主机上存储数据(即托管节点)。

$ kubectl get storageclass
NAME PROVISIONER AGE
standard (default) k8s.io/minikube-hostpath 65d

不同的存储类具有与实际后备存储相关的不同参数。卷供应商知道如何使用其存储类的参数。存储类元数据包括供应商,如下所示:

$ kubectl get storageclass -o jsonpath='{.items[0].provisioner}'
k8s.io/minikube-hostpath

卷、持久卷和供应

Kubernetes 中的卷具有与其 pod 相一致的显式生命周期。当 pod 消失时,存储也会消失。有许多类型的卷非常有用。我们已经看到了一些例子,比如 ConfigMap 和 secret 卷。但还有其他用于读写的卷类型。

您可以在这里查看所有卷类型的完整列表:kubernetes.io/docs/concepts/storage/volumes/#types-of-volumes

Kubernetes 还支持持久卷的概念。这些卷必须由系统管理员进行配置,而不是由 Kubernetes 本身管理。当您想要持久存储数据时,就可以使用持久卷。管理员可以提前静态配置持久卷。该过程涉及管理员配置外部存储并创建用户可以使用的PersistentVolume Kubernetes 对象。

动态配置是动态创建卷的过程。用户请求存储空间,这是动态创建的。动态配置取决于存储类。用户可以指定特定的存储类,否则将使用默认存储类(如果存在)。所有 Kubernetes 云提供商都支持动态配置。Minikube 也支持它(后备存储是本地主机文件系统)。

持久卷索赔

因此,集群管理员要么提供一些持久卷,要么集群支持动态配置。现在,我们可以通过创建持久卷索赔来为我们的工作负载索取一些存储空间。但首先,重要的是要理解临时和持久存储之间的区别。我们将在一个 pod 中创建一个临时文件,重新启动 pod,并检查文件是否消失。然后,我们将再次执行相同的操作,但这次将文件写入持久存储,并在重新启动 pod 后检查文件是否仍然存在。

在我们开始之前,让我分享一些方便的 shell 函数和别名,我创建了这些函数和别名,以便快速启动特定 pod 中的交互式会话。Kubernetes 部署会生成随机的 pod 名称。例如,对于trouble部署,当前的 pod 名称是trouble-6785b4949b-84x22

$ kubectl get po | grep trouble
trouble-6785b4949b-84x22     1/1 Running   1     2h

这不是一个很容易记住的名字,而且每当 pod 被重新启动时(由部署自动完成),它也会发生变化。不幸的是,kubectl exec命令需要一个确切的 pod 名称来运行命令。我创建了一个名为get_pod_name_by_label()的小 shell 函数,它根据标签返回一个 pod 名称。由于 pod 模板中的标签不会改变,这是发现 pod 名称的好方法。然而,可能会有多个来自相同部署的带有相同标签的 pod。我们只需要任何一种类型的 pod,所以我们可以简单地选择第一个。这是函数,我将其别名为kpn,这样使用起来更容易:

get_pod_name_by_label ()
 {
 kubectl get po -l $1 -o custom-columns=NAME:.metadata.name | tail +2 | uniq
 }

alias kpn='get_pod_name_by_label'

例如,trouble部署的 pod 可以有一个名为run=trouble的标签。这是如何找到实际的 pod 名称:

$ get_pod_name_by_label run=trouble
trouble-6785b4949b-84x22

使用这个函数,我创建了一个名为trouble的别名,它在trouble pod 中启动一个交互式的 bash 会话:

$ alias trouble='kubectl exec -it $(get_pod_name_by_label run=trouble) bash'

现在,我们可以连接到trouble pod 并开始在其中工作:

$ trouble
root@trouble-6785b4949b-84x22:/#

这是一个很长的离题,但这是一个非常有用的技术。现在,让我们回到我们的计划,并创建一个临时文件,如下所示:

root@trouble-6785b4949b-84x22:/# echo "life is short" > life.txt
root@trouble-6785b4949b-84x22:/# cat life.txt
life is short

现在,让我们杀死这个 pod。trouble部署将安排一个新的trouble pod,如下所示:

$ kubectl delete pod $(get_pod_name_by_label run=trouble)
pod "trouble-6785b4949b-84x22" deleted

$ get_pod_name_by_label run=trouble
trouble-6785b4949b-n6cmj

当我们访问新的 pod 时,我们发现life.txt如预期般消失了:

$ trouble
root@trouble-6785b4949b-n6cmj:/# cat life.txt
cat: life.txt: No such file or directory

这是可以理解的,因为它存储在容器的文件系统中。下一步是让trouble pod 声明一些持久存储。这里有一个动态提供一吉比特的持久卷索赔:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: some-storage
spec:
  accessModes:
  - ReadWriteOnce
  resources:
    requests:
      storage: 1Gi
  volumeMode: Filesystem

这是整个trouble部署的 YAML 清单,它作为卷使用这个索赔,并将其挂载到容器中:

---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: trouble
  labels:
    run: trouble
spec:
  replicas: 1
  selector:
    matchLabels:
      run: trouble
  template:
    metadata:
      labels:
        run: trouble
    spec:
      containers:
      - name: trouble
        image: g1g1/py-kube:0.2
        imagePullPolicy: Always
        command: ["/bin/bash", "-c", "while true ; do sleep 10 ; done"]
        volumeMounts:
        - name: keep-me
          mountPath: "/data"
      imagePullSecrets:
      - name: private-dockerhub
      volumes:
      - name: keep-me
        persistentVolumeClaim:
          claimName: some-storage

keep-me卷是基于some-storage持久卷索赔的:

volumes:
- name: keep-me
  persistentVolumeClaim:
    claimName: some-storage

卷被挂载到容器内部的/data目录中:

volumeMounts:
- name: keep-me
  mountPath: "/data"

现在,让我们向/data写入一些内容,如下所示:

$ trouble
root@trouble-64554479d-tszlb:/# ls /data
root@trouble-64554479d-tszlb:/# cd /data/
root@trouble-64554479d-tszlb:/data# echo "to infinity and be-yond!" > infinity.txt
root@trouble-64554479d-tszlb:/data# cat infinity.txt
to infinity and beyond!

最后的状态是删除 pod,并在创建新的 pod 时验证infinity.txt文件是否仍然在/data中:

$ kubectl delete pod trouble-64554479d-tszlb
pod "trouble-64554479d-tszlb" deleted

$ trouble
root@trouble-64554479d-mpl24:/# cat /data/infinity.txt
to infinity and beyond!

太好了,它起作用了!一个新的 pod 被创建,并且带有infinity.txt文件的持久存储被挂载到了新的容器上。

持久卷也可以用来直接在同一图像的多个实例之间共享信息,因为相同的持久存储将被挂载到使用相同持久存储索赔的所有容器中。

树内和树外存储插件

有两种类型的存储插件:内部和外部。内部意味着这些存储插件是 Kubernetes 本身的一部分。在卷子句中,您可以按名称引用它们。例如,在这里,通过名称配置了Google Compute Engine(GCE)持久磁盘。Kubernetes 明确知道这样的卷有字段,如pdNamefsType

volumes:
  - name: test-volume
    gcePersistentDisk:
      pdName: my-data-disk
      fsType: ext4

您可以在以下链接找到完整的内部存储插件列表:kubernetes.io/docs/concepts/storage/persistent-volumes/#types-of-persistent-volumes

还有其他几种专门的卷类型,如emptyDirlocaldownwardAPIhostPath,您可以阅读更多相关信息。内部插件的概念有些繁琐。它使 Kubernetes 变得臃肿,并且需要在提供商想要改进其存储插件或引入新插件时改变 Kubernetes 本身。

这就是外部插件出现的地方。其想法是,Kubernetes 定义了一个标准的存储接口和一种提供插件以在运行集群中实现接口的标准方式。然后,集群管理员的工作就是确保适当的外部插件可用。

Kubernetes 支持两种类型的外部插件:FlexVolume 和 CSI。FlexVolume 已经过时。我不会详细介绍 FlexVolume,除了建议您不要使用它。

有关更多详细信息,您可以参考以下链接:kubernetes.io/docs/concepts/storage/volumes/#flexVolume

存储的重要组成部分是 CSI。让我们深入了解 CSI 的工作原理以及它是多么巨大的改进。

理解 CSI

CSI 旨在解决内部插件的所有问题以及 FlexVolume 插件的繁琐方面。CSI 对存储提供商如此诱人的原因在于,它不仅是 Kubernetes 的标准,而且是行业标准。它允许存储提供商为其存储解决方案编写单个驱动程序,并立即与 Docker、Cloud Foundry、Mesos 和当然还有 Kubernetes 等广泛的容器编排平台兼容。

您可以在github.com/container-storage-interface/spec找到官方规范。

Kubernetes 团队提供了三个组件,它们是旁路容器,并为任何 CSI 存储提供了通用的 CSI 支持。这些组件如下:

  • 驱动注册器

  • 外部供应商

  • 外部连接器

它们的工作是与 kubelet 和 API 服务器进行接口。存储供应商通常会将这些旁路容器与它们的存储驱动实现打包在一个单独的 pod 中,可以部署为 Kubernetes DaemonSet 在所有节点上。

这是一个图表,展示了所有部件之间的交互:

这相当复杂,但这种复杂性是必要的,以分离关注点,允许 Kubernetes 团队进行大量的繁重工作,并让存储供应商专注于他们的存储解决方案。就用户和开发人员而言,这一切都是完全透明的。他们继续通过相同的 Kubernetes 存储抽象(存储类、卷和持久卷索赔)与存储进行交互。

标准化 CSI

CSI 优于 in-tree 插件(和 FlexVolume 插件)。然而,目前的混合情况,您可以使用 in-tree 插件(或 FlexVolume 插件)或 CSI 插件,是次优的。Kubernetes 团队有一个详细的计划,将 in-tree 插件迁移到 CSI。

您可以在github.com/kubernetes/community/blob/master/contributors/design-proposals/storage/csi-migration.md找到关于这个详细计划的更多信息。

将数据存储在 Kubernetes 集群之外

Kubernetes 不是一个封闭的系统。在 Kubernetes 集群内运行的工作负载可以访问集群外运行的存储。当您迁移一个已经存在于存储中、并在 Kubernetes 之外配置和操作的现有应用程序时,这是最合适的。在这种情况下,逐步进行是明智的选择。首先,将工作负载移动为由 Kubernetes 管理的容器运行。这些容器将配置为具有位于集群外的数据存储的端点。稍后,您可以考虑是否值得将这些外部存储引入系统。

还有一些其他使用情况,使用集群外存储是有意义的,比如:

  • 您的存储集群使用一些奇特的硬件,或者网络没有成熟的内置或 CSI 插件(希望随着 CSI 成为黄金标准,这种情况会变得罕见)。

  • 通过云提供商运行 Kubernetes 可能会太昂贵、风险太大和/或迁移所有数据太慢。

  • 组织中的其他应用程序使用相同的存储集群,将所有应用程序和系统迁移到 Kubernetes 通常是不切实际和不经济的。

  • 由于监管要求,您必须保留对数据的控制。

在 Kubernetes 之外管理存储有几个缺点:

  • 安全性(您需要为您的工作负载提供对单独存储集群的网络访问)。

  • 您必须实现存储集群的扩展、可用性、监控和配置。

  • 当存储集群端发生变化时,您通常需要在 Kubernetes 端进行相应的配置更改。

  • 由于额外的网络跳跃和/或身份验证、授权或加密,可能会遭受性能或延迟开销。

使用 StatefulSets 在集群内存储数据

最好将数据存储在 Kubernetes 集群内。这提供了一个统一的一站式管理工作负载和它们所依赖的所有资源的方式(不包括第三方外部服务)。此外,您可以将存储与流线型监控集成,这非常重要。我们将在未来的章节中深入讨论监控。然而,磁盘空间不足是许多系统管理员的苦恼。但是,如果您将数据存储在一个节点上,而您的数据存储 pod 被重新调度到另一个节点,它期望可用的数据却不在那里,这就会出现问题。Kubernetes 的设计者意识到,短暂的 pod 理念对存储不起作用。您可以尝试使用 pod-node 亲和性和 Kubernetes 提供的其他机制来自行管理,但最好使用 StatefulSet,这是 Kubernetes 中管理存储感知服务的特定解决方案。

理解 StatefulSet

在其核心,StatefulSet 是一个控制器,管理一组具有一些额外属性的 pod,例如排序和唯一性。StatefulSet 允许其一组 pod 被部署和扩展,同时保留它们的特殊属性。StatefulSets 在 Kubernetes 1.9 中达到了一般可用性GA)状态。您可以将 StatefulSet 视为升级版的部署。让我们看一个用户服务的示例 StatefulSet,它使用关系型 PostgresDB 作为其数据存储:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: user-db
spec:
  selector:
    matchLabels:
      svc: user
      app: postgres
  serviceName: user-db
  replicas: 1
  template:
    metadata:
      labels:
        svc: user
        app: postgres
    spec:
      terminationGracePeriodSeconds: 10
      containers:
      - name: nginx
        image: postgres:11.1-alpine
        ports:
        - containerPort: 5432
        env:
        - name: POSTGRES_DB
          value: user_manager
        - name: POSTGRES_USER
          value: postgres
        - name: POSTGRES_PASSWORD
          value: postgres
        - name: PGDATA
          value: /data/user-db

        volumeMounts:
        - name: user-db
          mountPath: /data/user-db
  volumeClaimTemplates:
  - metadata:
      name: user-db
    spec:
      accessModes: [ "ReadWriteOnce" ]
      # storageClassName: <custom storage class>
      resources:
        requests:
          storage: 1Gi

这里有很多内容,但它都是由熟悉的概念组成的。让我们把它分解成组件。

StatefulSet 组件

StatefulSet 由三个主要部分组成,如下所示:

  • StatefulSet 元数据和定义:StatefulSet 元数据和定义与部署非常相似。您有标准的 API 版本,种类和元数据名称;然后,spec,其中包括对 pod 的选择器(必须与接下来的 pod 模板选择器匹配),副本的数量(在这种情况下只有一个),以及与部署相比的主要区别,即serviceName
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: user-db
spec:
  selector:
    matchLabels:
      svc: user
      app: postgres
  replicas: 1
  serviceName: user-db

StatefulSet 必须有一个与 StatefulSet 关联的无头服务来管理 pod 的网络标识。在这种情况下,服务名称是user-db;这里是为了完整性:

apiVersion: v1
kind: Service
metadata:
  name: user-db
spec:
  ports:
  - port: 5432
  clusterIP: None
  selector:
    svc: user
    app: postgres
  • 一个 pod 模板:下一部分是标准的 pod 模板。PGDATA 环境变量(/data/user-db)告诉 postgres 从哪里读取和写入数据,必须与user-db卷的挂载路径(/data/user-db)或子目录相同。这是我们将数据存储与底层存储连接起来的地方:
template:
  metadata:
    labels:
      svc: user
      app: postgres
  spec:
    terminationGracePeriodSeconds: 10
    containers:
    - name: nginx
      image: postgres:11.1-alpine
      ports:
      - containerPort: 5432
      env:
      - name: POSTGRES_DB
        value: user_manager
      - name: POSTGRES_USER
        value: postgres
      - name: POSTGRES_PASSWORD
        value: postgres
      - name: PGDATA
        value: /data/user-db
      volumeMounts:
      - name: user-db
        mountPath: /data/user-db
  • 卷索赔模板:最后一部分是卷索赔模板。请注意,这是复数形式;一些数据存储可能需要多种类型的卷(例如,用于日志记录或缓存),这些卷需要它们自己的持久索赔。在这种情况下,一个持久索赔就足够了:
volumeClaimTemplates:
- metadata:
    name: user-db
  spec:
    accessModes: [ "ReadWriteOnce" ]
    # storageClassName: <custom storage class>
    resources:
      requests:
        storage: 1Gi

现在是深入了解 StatefulSets 的特殊属性以及它们为什么重要的好时机。

Pod 标识

StatefulSet pod 具有稳定的标识,包括以下三元组:稳定的网络标识,序数索引和稳定的存储。这些总是一起的;每个 pod 的名称是<statefulset name>-<ordinal>

与 StatefulSet 关联的无头服务提供了稳定的网络标识。服务 DNS 名称将如下所示:

<service name>.<namespace>.svc.cluster.local

每个 pod,X,将具有如下稳定的 DNS 名称:

<statefulset name>-<ordinal>.<service name>.<namespace>.svc.cluster.local

例如,user-db StatefulSet 的第一个 pod 将被称为以下内容:

user-db-0.user-db.default.svc.cluster.local

此外,StatefulSet 的 pod 会自动被分配一个标签,如下所示:

statefulset.kubernetes.io/pod-name=<pod-name>

有序性

StatefulSet 中的每个 pod 都会获得一个序号索引。但是,这有什么作用呢?嗯,一些数据存储依赖于初始化的有序序列。StatefulSet 确保当 StatefulSet 的 pod 被初始化、扩展或缩减时,总是按顺序进行。

在 Kubernetes 1.7 中,有序性限制得到了放宽。对于不需要有序性的数据存储,允许在 StatefulSet 中对多个 pod 进行并行操作是有意义的。这可以在podPolicy字段中指定。允许的值有OrderedReady用于默认的有序行为,或者parallel用于放宽的并行模式,其中可以在其他 pod 仍在启动或终止时启动或终止 pod。

何时应该使用 StatefulSet?

当你在云中自己管理数据存储并且需要对数据存储使用的存储有良好的控制时,你应该使用 StatefulSet。主要用例是分布式数据存储,但即使你的数据存储只有一个实例或 pod,StatefulSet 也是有用的。稳定的 pod 标识和稳定的附加存储是非常值得的,尽管有序性当然不是必需的。如果你的数据存储由共享存储层(如 NFS)支持,那么 StatefulSet 可能就不是必要的。

此外,这可能是常识,但如果你不自己管理数据存储,那么你就不需要担心存储层,也不需要定义自己的 StatefulSets。例如,如果你在 AWS 上运行系统并使用 S3、RDS、DynamoDB 和 Redshift,那么你实际上不需要 StatefulSet。

比较部署和 StatefulSets

部署旨在管理任何一组 pod。它们也可以用于管理分布式数据存储的 pod。StatefulSets 专门设计用于支持分布式数据存储的需求。然而,有序性和唯一性的特殊属性并不总是必要的。让我们将部署与 StatefulSets 进行比较,自己看看:

  • 部署没有关联的存储,而 StatefulSets 有。

  • 部署没有关联的服务,而 StatefulSets 有。

  • 部署的 pod 没有 DNS 名称,而 StatefulSet 的 pod 有。

  • 部署以任意顺序启动和终止 pod,而 StatefulSets 遵循规定的顺序(默认情况下)。

我建议您坚持使用部署,除非您的分布式数据存储需要 StatefulSets 的特殊属性。如果您只需要一个稳定的标识,而不是有序的启动和关闭,那么请使用podPolicy=Parallel

审查一个大型 StatefulSet 示例

Cassandra (cassandra.apache.org/) 是一个我有很多经验的有趣的分布式数据存储。它非常强大,但需要大量的知识才能正确运行和开发。它也是 StatefulSets 的一个很好的用例。让我们快速回顾一下 Cassandra,并学习如何在 Kubernetes 中部署它。请注意,我们将不会在 Delinkcious 中使用 Cassandra。

Cassandra 的快速介绍

Cassandra 是一个 Apache 开源项目。它是一个列式数据存储,非常适合管理时间序列数据。我已经使用它来收集和管理来自数千个空气质量传感器网络的数据超过三年。

Cassandra 有一个有趣的建模方法,但在这里,我们关心存储。Cassandra 具有高可用性,线性可扩展性,并且非常可靠(没有 SPOF),通过冗余。Cassandra 节点共享数据的责任(通过分布式哈希表或 DHT 进行分区)。数据的多个副本分布在多个节点上(通常是三个或五个)。

这样,如果 Cassandra 节点出现故障,那么还有其他两个节点具有相同的数据并且可以响应查询。所有节点都是相同的;没有主节点和从节点。节点通过八卦协议不断地与彼此交谈,当新节点加入集群时,Cassandra 会重新分配数据到所有节点。这是一个显示数据如何分布在 Cassandra 集群中的图表:

您可以将节点视为一个环,DHT 算法对每个宽行(工作单元)进行哈希处理,并将其分配给 N 个节点(取决于集群的复制因子)。通过这种对特定节点中的单个行的精确放置,您可以看到 StatefulSet 的稳定标识和潜在的排序属性如何派上用场。

让我们探讨在 Kubernetes 中将 Cassandra 集群部署为 StatefulSet 需要做些什么。

使用 StatefulSets 在 Kubernetes 上部署 Cassandra

这是一个截断版本,包括我们应该关注的部分。

第一部分包括apiVersionkindmetadataspec,正如我们之前所见。名称是cassandra,标签是app: cassandra。在spec中,serviceName名称也是cassandra,有三个副本:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: cassandra
  labels:
    app: cassandra
 spec:
   serviceName: cassandra
   replicas: 3
   selector:
     matchLabels:
       app: cassandra 

Pod 模板具有匹配的标签app: cassandra。容器也被命名为cassandra,并使用了一个始终拉取策略的 Google 示例镜像。在这里,terminationGraceInSeconds设置为 1,800 秒(即 30 分钟)。这是 StatefulSet 允许 pod 尝试恢复的时间。Cassandra 内置了很多冗余,所以让一个节点尝试恢复 30 分钟是可以接受的。我删除了很多端口、环境变量和就绪检查(省略号)。卷挂载被称为cassandra-data,其路径为/cassandra_data。这就是 Cassandra 存储其数据文件的地方。

template:
  metadata:
    labels:
      app: cassandra
  spec:
    terminationGracePeriodSeconds: 1800
    containers:
    - name: cassandra
      image: gcr.io/google-samples/cassandra:v13
      imagePullPolicy: Always
      ...
      volumeMounts:
      - name: cassandra-data
        mountPath: /cassandra_data

最后,卷索赔模板定义了与容器中挂载的名称为cassandra-data的卷匹配的持久存储。存储类fast在这里没有显示,但通常是运行 Cassandra pod 的同一节点上的本地存储。存储大小为 1 gibibyte。

volumeClaimTemplates:
- metadata:
    name: cassandra-data
  spec:
    accessModes: [ "ReadWriteOnce" ]
    storageClassName: fast
    resources:
      requests:
        storage: 1Gi

到目前为止,这一切对你来说应该都很熟悉。然而,还有更多成功的 Cassandra 部署要发现。如果你还记得,Cassandra 没有主节点;Cassandra 节点使用 gossip 协议不断地相互交流。

但是 Cassandra 节点如何找到彼此?进入种子提供程序;每当向集群添加新节点时,它都会配置一些种子节点的 IP 地址(在这种情况下为10.0.0.110.0.0.210.0.0.3)。它开始与这些种子节点交换消息,这些种子节点通知新节点集群中的其他 Cassandra 节点,并通知所有其他现有节点新节点已加入集群。通过这种方式,集群中的每个节点都可以非常快速地了解集群中的每个其他节点。

这是典型 Kubernetes 配置文件(cassandra.yaml)中定义种子提供程序的部分。在这种情况下,它只是一个简单的 IP 地址列表。

seed_provider:
    - class_name: SEED_PROVIDER
        parameters:
        # seeds is actually a comma-delimited list of addresses.
        # Ex: "<ip1>,<ip2>,<ip3>"
        - seeds: "10.0.0.1,10.0.0.2,10.0.0.3,"

种子提供程序也可以是自定义类。这是一个非常好的可扩展设计。在 Kubernetes 中是必要的,因为原始种子节点可能会被移动并获得新的 IP 地址。

为了解决这个问题,有一个自定义的KubernetesSeedProvider类,它与 Kubernetes API 服务器通信,并且始终可以返回查询时种子节点的 IP 地址。Cassandra 是用 Java 实现的,自定义种子提供程序也是实现了SeedProvider Java 接口的 Java 类。

我们不会详细解析这段代码。需要注意的主要是它与一个名为cassandra-seed.so的本地 Go 库进行接口,然后使用它来获取 Cassandra 服务的 Kubernetes 端点:

package io.k8s.cassandra;

import java.io.IOException;
import java.net.InetAddress;
import java.util.Collections;
import java.util.List;
import java.util.Map;

...

 /**
 * Create new seed provider
 *
 * @param params
 */
 public KubernetesSeedProvider(Map<String, String> params) {
 }

...
 }
 }

private static String getEnvOrDefault(String var, String def) {
 String val = System.getenv(var);
...
 static class Endpoints {
 public List<InetAddress> ips;
 }
 }

完整的源代码可以在github.com/kubernetes/examples/blob/master/cassandra/java/src/main/java/io/k8s/cassandra/KubernetesSeedProvider.java找到。

这就是将 Cassandra 连接到 Kubernetes 并使它们能够一起工作的魔力。现在我们已经看到了一个复杂的分布式数据存储如何在 Cassandra 中部署,让我们来看看本地存储,它在 Kubernetes 1.14 中升级为 GA。

使用本地存储实现高性能

现在让我们讨论计算和存储之间的关联。速度、容量、持久性和成本之间存在有趣的关系。当您的数据存储在处理器附近时,您可以立即开始处理它,而不是通过网络获取。这就是本地存储的承诺。

有两种主要的本地数据存储方式:内存和本地驱动器。然而,有细微差别;内存是最快的,SSD 驱动器比内存慢大约 4 倍,旋转硬盘比 SSD 驱动器慢大约 20 倍(https://gist.github.com/jboner/2841832)。

现在考虑以下两个选项:

  • 将数据存储在内存中

  • 将数据存储在本地 SSD 上

将数据存储在内存中

就读写延迟和吞吐量而言,保持数据在内存中是性能最高的。有不同的内存类型和缓存,但归根结底,内存非常快。然而,内存也有显著的缺点,例如以下:

  • 与磁盘相比,节点的内存要有限得多(也就是说,需要更多的机器来存储相同数量的数据)。

  • 内存非常昂贵。

  • 内存是短暂的。

有一些用例需要将整个数据集存储在内存中。在这些情况下,数据集要么非常小,要么可以分布在多台机器上。如果数据很重要且不容易生成,那么可以通过以下两种方式解决内存的临时性:

  • 保持持久副本。

  • 冗余(即在多台机器和可能地理分布的情况下在内存中保留数据)。

将数据存储在本地 SSD 上

本地 SSD 的速度不及内存快,但非常快。当然,您也可以始终结合内存缓存(任何体面的数据存储都会利用内存缓存)。当您需要快速性能,但工作集不适合内存,或者您不想支付大内存的高额费用时,使用 SSD 是合适的,因为 SSD 便宜得多,但仍然非常快。例如,Cassandra 建议使用本地 SSD 存储作为其数据的后备存储。

在 Kubernetes 中使用关系型数据库

到目前为止,我们在所有服务中都使用了关系型数据库,但是,正如我们很快会发现的那样,我们并没有真正的持久性。首先,我们将看看数据存储在哪里,然后我们将探讨其持久性。最后,我们将迁移其中一个数据库以使用 StatefulSet 来实现适当的持久性和耐久性。

了解数据存储的位置

对于 PostgreSQL,有一个data目录;可以使用PGDATA环境变量设置此目录。默认情况下,它设置为/var/lib/postgresql/data

$ kubectl exec -it link-db-6b9b64db5-zp59g env | grep PGDATA
PGDATA=/var/lib/postgresql/data

让我们看看这个目录包含什么:

$ kubectl exec -it link-db-6b9b64db5-zp59g ls /var/lib/postgresql/data
PG_VERSION pg_multixact pg_tblspc
base pg_notify pg_twophase
global pg_replslot pg_wal
pg_commit_ts pg_serial pg_xact
pg_dynshmem pg_snapshots post-gresql.auto.conf
pg_hba.conf pg_stat postgresql.conf
pg_ident.conf pg_stat_tmp postmaster.opts
pg_logical pg_subtrans postmaster.pid

然而,data目录可以是临时的或持久的,这取决于它是如何挂载到容器中的。

使用部署和服务

通过服务面向数据库 pod,您可以轻松访问数据。当数据库 pod 被杀死时,它将被部署重新启动。但是,由于 pod 可以被调度到不同的节点上,您需要确保它可以访问实际数据所在的存储。否则,它将只是空启动,您将丢失所有数据。这是一个仅用于开发的设置,以及大多数 Delinkcious 服务保持其数据的方式 - 通过运行一个只有其 pod 持久性的 PostgresDB 容器。事实证明,数据存储在运行在 pod 内部的 Docker 容器中。

在 Minikube 中,我可以直接检查 Docker 容器,首先通过 SSH 进入节点,找到 postgres 容器的 ID,然后检查它(也就是说,只有在显示相关信息时):

$ minikube ssh
_ _
_ _ ( ) ( )
___ ___ (_) ___ (_)| |/') _ _ | |_ __
/' _ ` _ `\| |/' _ `\| || , < ( ) ( )| '_`\ /'__`\
| ( ) ( ) || || ( ) || || |\`\ | (_) || |_) )( ___/
(_) (_) (_)(_)(_) (_)(_)(_) (_)`\___/'(_,__/'`\____)

$ docker ps -f name=k8s_postgres_link-db -q
409d4a52a7f5

$ docker inspect -f "{{json .Mounts}}" 409d4a52a7f5 | jq .[1]
{
"Type": "volume",
"Name": "f9d090d6defba28f0c0bfac8ab7935d189332478d0bf03def6175f5c0a2e93d7",
 "Source": "/var/lib/docker/volumes/f9d090d6defba28f0c0bfac8ab7935d189332478d0bf03def6175f5c0a2e93d7/_data",
"Destination": "/var/lib/postgresql/data",
"Driver": "local",
"Mode": "",
"RW": true,
"Propagation": ""
}

这意味着,如果容器消失(例如,如果我们升级到新版本),并且当然如果节点消失,那么所有我们的数据都会消失。

使用 StatefulSet

使用 StatefulSet,情况就不同了。数据目录被挂载到容器中,但存储本身是由外部管理的。只要外部存储可靠且冗余,我们的数据就是安全的,不管特定容器、pod 和节点发生了什么。我们之前提到过如何使用无头服务为用户数据库定义 StatefulSet。然而,使用 StatefulSet 的存储可能有点具有挑战性。附加到 StatefulSet 的无头服务没有集群 IP。那么,用户服务如何连接到其数据库呢?好吧,我们将不得不帮助它。

帮助用户服务定位 StatefulSet pods

无头user-db服务没有集群 IP,如下所示:

$ kubectl get svc user-db
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
user-db ClusterIP None <none> 5432/TCP 4d

但是,它确实有端点,这些端点是支持服务的所有 pod 在集群中的 IP 地址:

$ kubectl get endpoints user-db
NAME ENDPOINTS AGE
user-db 172.17.0.25:5432 4d

这是一个不错的选择;端点不会通过环境变量暴露,例如具有集群 IP 的服务(<service name>_SERVICE_HOST<service name>_SERVICE_PORT)。因此,为了使服务找到无头服务的端点,它们将不得不直接查询 Kubernetes API。虽然这是可能的,但它增加了服务和 Kubernetes 之间不必要的耦合。我们将无法在 Kubernetes 之外运行服务进行测试,因为它依赖于 Kubernetes API。但是,我们可以欺骗用户服务,并使用配置映射填充USER_DB_SERVICE_HOSTUSER_DB_SERVICE_PORT

这个想法是 StatefulSet pods 有一个稳定的 DNS 名称。对于用户数据库,有一个 pod,其 DNS 名称是user-db-0.user-db.default.svc.cluster.local。在故障排除容器 shell 中,我们可以通过运行dig命令来验证 DNS 名称确实解析为用户数据库端点172.17.0.25

root@trouble-64554479d-zclxc:/# dig +short us-er-db-0.user-db.default.svc.cluster.local
172.17.0.25

现在,我们可以将这个稳定的 DNS 名称分配给user-manager服务的配置映射中的USER_DB_SERVICE_HOST

apiVersion: v1
kind: ConfigMap
metadata:
  name: user-manager-config
  namespace: default
data:
  USER_DB_SERVICE_HOST: "us-er-db-0.user-db.default.svc.cluster.local"
  USER_DB_SERVICE_PORT: "5432"

一旦应用了此配置映射,用户服务将能够通过环境变量找到 StatefulSet 的用户数据库 pod。以下是使用pkg/db_util/db_util.go中的这些环境变量的代码:

func GetDbEndpoint(dbName string) (host string, port int, err error) {
 hostEnvVar := strings.ToUpper(dbName) + "_DB_SERVICE_HOST"
 host = os.Getenv(hostEnvVar)
 if host == "" {
 host = "localhost"
 }

portEnvVar := strings.ToUpper(dbName) + "_DB_SERVICE_PORT"
 dbPort := os.Getenv(portEnvVar)
 if dbPort == "" {
 dbPort = "5432"
 }

port, err = strconv.Atoi(dbPort)
 return
 }

用户服务在其Run()函数中调用它以初始化其数据库存储:

func Run() {
 dbHost, dbPort, err := db_util.GetDbEndpoint("user")
 if err != nil {
 log.Fatal(err)
 }

store, err := sgm.NewDbUserStore(dbHost, dbPort, "postgres", "postgres")
 if err != nil {
 log.Fatal(err)
 }
 ...
 }

现在,让我们看看如何解决管理模式更改的问题。

管理模式更改

在使用关系数据库时,最具挑战性的话题之一是管理 SQL 模式。当模式发生变化时,变化可能是向后兼容的(通过添加列)或非向后兼容的(通过将一个表拆分为两个独立的表)。当模式发生变化时,我们需要迁移我们的数据库,还需要迁移受模式更改影响的代码。

如果您可以承受短暂的停机时间,那么该过程可以非常简单,如下所示:

  1. 关闭所有受影响的服务并执行 DB 迁移。

  2. 部署一个新代码,知道如何处理新模式。

  3. 一切都能正常工作。

然而,如果您需要保持系统运行,您将不得不经历一个更复杂的过程,将模式更改分解为多个向后兼容的更改,包括相应的代码更改。

例如,当将一个表拆分为两个表时,可以执行以下过程:

  1. 保留原始表格。

  2. 添加两个新表。

  3. 部署代码,既写入旧表,也写入新表,并且可以从所有表中读取。

  4. 将所有数据从旧表迁移到新表。

  5. 部署一个只从新表中读取数据的代码更改(现在所有数据都在新表中)。

  6. 删除旧表。

关系数据库非常有用;然而,有时正确的解决方案是非关系型数据存储。

在 Kubernetes 中使用非关系型数据存储

Kubernetes 和 StatefulSets 并不局限于关系型数据存储,甚至不是为其设计的。非关系型(也称为 NoSQL)数据存储对许多用例非常有用。最通用和流行的内存数据存储之一是 Redis。让我们了解 Redis,并检查如何将 Delinkcious 新闻服务迁移到使用 Redis,而不是将事件存储在临时内存中。

Redis 简介

Redis 通常被描述为数据结构服务器。由于它将整个数据存储保留在内存中,因此可以高效地对数据执行许多高级操作。当然,你要付出的代价是必须将所有数据保留在内存中。这只对小型数据集可能,并且即使如此,也是昂贵的。如果你不访问大部分数据,将其保留在内存中是一种巨大的浪费。Redis 可以用作快速的分布式缓存,用于热数据;因此,即使你不能将其用作内存中整个数据集的分布式缓存,你仍然可以将 Redis 用于热数据(经常使用的数据)。Redis 还支持集群,其中数据在多个节点之间共享,因此它也能处理非常大的数据集。Redis 具有令人印象深刻的功能列表,包括以下内容:

  • 它提供了多种数据结构,如列表、哈希、集合、有序集合、位图、流和地理空间索引。

  • 它在许多数据结构上提供原子操作。

  • 它支持事务。

  • 它支持带有 TTL 的自动驱逐。

  • 它支持 LRU 驱逐。

  • 它启用发布/订阅。

  • 它允许可选的持久化到磁盘。

  • 它允许将操作可选地附加到日志中。

  • 它提供 Lua 脚本。

现在,让我们来看看 Delinkcious 如何使用 Redis。

在新闻服务中持久化事件

新闻服务将 Redis 实例作为 StatefulSet 进行配置,如下所示:

apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: news-manager-redis
spec:
  serviceName: news-manager-redis
  replicas: 1
  selector:
    matchLabels:
      app: redis
      svc: news-manager
  template:
    metadata:
      labels:
        app: redis
        svc: news-manager
    spec:
      containers:
      - name: redis-primary
        image: redis:5.0.3-alpine
        imagePullPolicy: Always
        ports:
        - containerPort: 6379
          name: redis
        volumeMounts:
        - name: news-manager-redis
          mountPath: /data
  volumeClaimTemplates:
  - metadata:
      name: news-manager-redis
    spec:
      accessModes: [ "ReadWriteOnce" ]
      resources:
        requests:
          storage: 1Gi

它由一个无头服务支持:

apiVersion: v1
kind: Service
metadata:
  name: news-manager-redis
  labels:
    app: redis
    svc: news-manager
spec:
  selector:
    app: redis
    svc: news-manager
  type: None
  ports:
  - port: 6379
    name: redis

我们可以使用相同的技巧,通过使用配置映射将 Redis pod 的 DNS 名称注入到环境变量中:

apiVersion: v1
kind: ConfigMap
metadata:
  name: news-manager-config
  namespace: default
data:
  PORT: "6060"
  NEWS_MANAGER_REDIS_SERVICE_HOST: "news-manager-redis-0.news-manager-redis.default.svc.cluster.local"
  USER_DB_SERVICE_PORT: "6379"

完成了配置,让我们来看看代码如何访问 Redis。在新闻服务的Run()函数中,如果 Redis 的环境变量不为空,它将创建一个新的 Redis 存储:

redisHostname := os.Getenv("NEWS_MANAGER_REDIS_SERVICE_HOST")
redisPort := os.Getenv("NEWS_MANAGER_REDIS_SERVICE_PORT")

var store nm.Store
if redisHostname == "" {
store = nm.NewInMemoryNewsStore()
} else {
address := fmt.Sprintf("%s:%s", redisHostname, redisPort)
store, err = nm.NewRedisNewsStore(address)
if err != nil {
log.Fatal(err)
}
}

NewRedisNewStore()函数在pkg/new_manager/redis_news_store中定义。它创建一个新的 Redis 客户端(来自go-redis库)。它还调用客户端的Ping()方法来确保 Redis 正在运行并且是可访问的:

package news_manager

import (
 "github.com/go-redis/redis"
 "github.com/pelletier/go-toml"
 om "github.com/the-gigi/delinkcious/pkg/object_model"
 )

// RedisNewsStore manages a UserEvents data structure
 type RedisNewsStore struct {
 redis *redis.Client
 }

func NewRedisNewsStore(address string) (store Store, err error) {
 client := redis.NewClient(&redis.Options{
 Addr: address,
 Password: "", // use empty password for simplicity. should come from a secret in production
 DB: 0, // use default DB
 })

_, err = client.Ping().Result()
 if err != nil {
 return
 }

store = &RedisNewsStore{redis: client}
 return
 }

RedisNewsStore将事件存储在 Redis 列表中,并将其序列化为 TOML。这一切都在AddEvent()中实现,如下所示:

func (m *RedisNewsStore) AddEvent(username string, event *om.Event) (err error) {
 t, err := toml.Marshal(*event)
 if err != nil {
 return
 }
err = m.redis.RPush(username, t).Err()
 return
 }

RedisNewsStore 实现了 GetNews() 方法来按顺序获取事件。首先,它根据起始索引和最大页面大小计算要查询事件列表的起始和结束索引。然后,它获取结果,将它们序列化为 TOML,将它们解组为 om.Event 结构,并将它们附加到事件结果列表中。最后,它计算下一个要获取的索引(如果没有更多事件,则为 -1):

const redisMaxPageSize = 10

func (m *RedisNewsStore) GetNews(username string, startIndex int) (events []*om.Event, nextIndex int, err error) {
 stop := startIndex + redisMaxPageSize - 1
 result, err := m.redis.LRange(username, int64(startIndex), int64(stop)).Result()
 if err != nil {
 return
 }

for _, t := range result {
 var event om.Event
 err = toml.Unmarshal([]byte(t), &event)
 if err != nil {
 return
 }

events = append(events, &event)
 }

if len(result) == redisMaxPageSize {
 nextIndex = stop + 1
 } else {
 nextIndex = -1
 }

return
 }

在这一点上,您应该对非关系型数据存储有很好的掌握,包括何时使用它们以及如何将 Redis 集成为您的服务的数据存储。

摘要

在本章中,我们处理了存储和现实世界数据持久性的非常重要的主题。我们了解了 Kubernetes 存储模型、常见存储接口和 StatefulSets。然后,我们讨论了如何在 Kubernetes 中管理关系型和非关系型数据,并迁移了几个 Delinkcious 服务以使用适当的持久性存储通过 StatefulSets,包括如何为 StatefulSet pods 提供数据存储端点。最后,我们使用 Redis 为新闻服务实现了一个非短暂数据存储。在这一点上,您应该清楚地了解了 Kubernetes 如何管理存储,并能够为您的系统选择适当的数据存储,并将它们集成到您的 Kubernetes 集群和服务中。

在下一章中,我们将探索令人兴奋的无服务器计算领域。我们将考虑无服务器模型何时有用,讨论 Kubernetes 的当前解决方案,并通过一些无服务器任务扩展 Delinkcious。

进一步阅读

您可以参考以下参考资料获取更多信息:

第九章:在 Kubernetes 上运行无服务器任务

在本章中,我们将深入探讨云原生系统中最热门的趋势之一:无服务器计算(也称为函数即服务FaaS)。我们将解释无服务器意味着什么(剧透警告:它的意义不止一种),以及它与微服务的比较。我们将使用 Nuclio 无服务器框架实现并部署 Delinkcious 的一个很酷的新功能,即链接检查。最后,我们将简要介绍在 Kubernetes 中进行无服务器计算的其他方法。

本章将涵盖以下主题:

  • 云中的无服务器

  • 使用 Delinkcious 进行链接检查

  • 使用 Nuclio 进行无服务器链接检查

技术要求

在本章中,我们将安装一个名为 Nuclio 的无服务器框架。首先,让我们创建一个专用命名空间,如下所示:

$ kubectl create namespace nuclio

这是一个很好的安全实践,因为 Nuclio 不会干扰您集群的其余部分。接下来,我们将应用一些基于角色的访问控制RBAC)权限。如果您查看文件(在将其运行在您的集群之前,您应该始终检查 Kubernetes 清单),您会发现大多数权限都限于 Nuclio 命名空间,并且有一些关于 Nuclio 本身创建的自定义资源定义CRDs)的集群范围权限;这是一个很好的卫生习惯:

$ kubectl apply -f https://raw.githubusercontent.com/nuclio/nuclio/master/hack/k8s/resources/nuclio-rbac.yaml

现在让我们部署 Nuclio 本身;它会创建一些 CRD,并部署控制器和仪表板服务。这非常经济和直接,如下所示:

$ kubectl apply -f https://raw.githubusercontent.com/nuclio/nuclio/master/hack/k8s/resources/nuclio.yaml

现在,让我们通过检查控制器和仪表板 pod 是否成功运行来验证安装:

$ kubectl get pods --namespace nuclio
 NAME                               READY     STATUS    RESTARTS   AGE
 nuclio-controller-556774b65-mtvmm   1/1       Running   0          22m
 nuclio-dashboard-67ff7bb6d4-czvxp   1/1       Running   0          22m

仪表板很好,但更适合临时探索。对于更严肃的生产使用,最好使用nuctl CLI。下一步是从github.com/nuclio/nuclio/releases下载并安装nuctl

然后,将可执行文件复制到您的路径中,创建symlink nuctl,如下所示:

$ cd /usr/local/bin
$ curl -LO https://github.com/nuclio/nuclio/releases/download/1.1.2/nuctl-1.1.2-darwin-amd64
$ ln -s nuctl-1.1.2-darwin-amd64 nuctl

最后,让我们创建一个镜像拉取密钥,以便 Nuclio 可以将函数部署到我们的集群中:

$ kubectl create secret docker-registry registry-credentials -n nuclio \
 --docker-username g1g1 \
 --docker-password $DOCKERHUB_PASSWORD \
 --docker-server registry.hub.docker.com \
 --docker-email the.gigi@gmail.com

secret "registry-credentials" created

您还可以使用其他注册表和适当的凭据;在 Minikube 中,甚至可以使用本地注册表。但是,为了保持一致,我们将使用 Docker Hub 注册表。

代码

代码分为两个 Git 存储库,如下所示:

云中的无服务器

人们对云中的无服务器有两种不同的定义,特别是在 Kubernetes 的上下文中。第一种意思是您不必管理集群的节点。这个概念的一些很好的例子包括 AWS Fargate(aws.amazon.com/fargate/)和 Azure Container Instances(ACI)(azure.microsoft.com/en-us/services/container-instances/)。无服务器的第二个意思是,您的代码不是部署为长时间运行的服务,而是打包为可以按需调用或以不同方式触发的函数。这个概念的一些很好的例子包括 AWS Lambda 和 Google Cloud Functions。

让我们了解服务和无服务器函数之间的共同点和区别。

微服务和无服务器函数

相同的代码通常可以作为微服务或无服务器函数运行。区别主要在于操作。让我们比较微服务和无服务器函数的操作属性,如下所示:

微服务 无服务器函数

|

  • 始终运行(可以缩减至至少一个)。

  • 可以暴露多个端点(如 HTTP 和 gRPC)。

  • 需要自己实现请求处理和路由。

  • 可以监听事件。

  • 服务实例可以维护内存缓存、长期连接和会话。

  • 在 Kubernetes 中,微服务直接由服务对象表示。

|

  • 按需运行(理论上;它可以缩减到零)。

  • 暴露单个端点(通常为 HTTP)。

  • 可以通过事件触发或获得自动端点。

  • 通常对资源使用和最大运行时间有严格限制。

  • 有时,可能会有冷启动(即从零开始扩展)。

  • 在 Kubernetes 中,没有原生的无服务器函数概念(作业和定时作业接近)。

|

这应该为您提供一些相对良好的指导,告诉您何时使用微服务,何时使用无服务器函数。在以下情况下,微服务是正确的选择:

  • 您的工作负载需要持续运行,或几乎持续运行。

  • 每个请求运行的时间很长,无法被无服务器函数的限制所支持。

  • 工作负载在调用之间使用本地状态,无法轻松地移动到外部数据存储。

然而,如果您的工作负载很少运行,持续时间相对较短,那么您可能更喜欢使用无服务器函数。

还有一些其他工程考虑要牢记。例如,服务更为熟悉,通常具有各种支持库。开发人员可能更喜欢服务,并希望将代码部署到系统时有一个单一的范例。特别是在 Kubernetes 中,有大量的无服务器函数选项可供选择,很难选择正确的选项。另一方面,无服务器函数通常支持敏捷和轻量级的部署模型,开发人员可以将一些代码放在一起,它就会在集群上神奇地开始运行,因为无服务器函数解决方案负责处理打包和部署的所有业务。

在 Kubernetes 中建模无服务器函数

归根结底,Kubernetes 运行容器,因此您知道您的无服务器函数将被打包为容器。然而,在 Kubernetes 中有两种主要表示无服务器函数的方式。第一种是作为代码;在这里,开发人员基本上以某种形式(作为文件或通过将其推送到 Git 存储库)提供函数。第二种是将其构建为实际容器。开发人员构建一个常规容器,无服务器框架负责安排它并将其作为函数运行。

函数作为代码

这种方法的好处是,作为开发人员,您完全可以绕过构建图像、标记它们、将它们推送到注册表并将它们部署到集群的整个业务(即部署、服务、入口和 NetworkPolicy)。这对于临时探索和一次性工作也非常有用。

函数作为容器

在这里,作为开发人员,您是在熟悉的领域。您使用常规流程构建一个容器,然后稍后将其部署到集群作为无服务器函数。它仍然比常规服务更轻量级,因为您只需要在容器中实现一个函数,而不是一个完整的 HTTP 或 gRPC 服务器,或者注册以监听某些事件。您可以通过无服务器函数解决方案获得所有这些。

构建、配置和部署无服务器函数

您已经实现了您的无服务器函数,现在您想要将其部署到集群中。无论您是构建无服务器函数(如果它是一个容器)还是将其提供为函数,通常也需要以某种方式对其进行配置。配置可能包含诸如扩展限制、函数代码位置以及如何调用和触发它的信息。然后,下一步是将函数部署到集群中。这可能是通过 CLI 或 Web UI 的一次性部署,或者也可能与您的 CI/CD 流水线集成。这主要取决于您的无服务器函数是您主要应用程序的一部分,还是您以临时方式启动它以进行故障排除或手动清理任务。

调用无服务器函数

一旦无服务器函数在集群中部署,它将处于休眠状态。将有一个控制器不断运行,准备调用或触发函数。控制器应该占用非常少的资源,只需监听传入的请求或事件以触发函数。在 Kubernetes 中,如果您需要从集群外部调用函数,可能会有一些额外的入口配置。然而,最常见的用例是在内部调用函数并向世界公开一个完整的服务。

现在我们了解了无服务器函数的全部内容,让我们为 Delinkcious 添加一些无服务器函数功能。

使用 Delinkcious 进行链接检查

Delinkcious 是一个链接管理系统。链接 - 或者,正式称为统一资源标识符URIs)- 实际上只是指向特定资源的指针。链接可能存在两个问题,如下所示:

  • 它们可能是损坏的(也就是说,它们指向一个不存在的资源)。

  • 它们可能指向一个不良资源(如钓鱼或注入病毒的网站、仇恨言论或儿童色情)。

检查链接并维护每个链接的状态是链接管理的重要方面。让我们从设计 Delinkcious 执行链接检查的方式开始。

设计链接检查

让我们在 Delinkcious 的背景下考虑链接检查。我们应该将当前状态视为未来的改进。以下是一些假设:

  • 链接可能是暂时的或永久的中断。

  • 链接检查可能是一个繁重的操作(特别是在分析内容时)。

  • 链接的状态可能随时改变(也就是说,如果指向的资源被删除,有效链接可能会突然中断)。

具体来说,Delinkcious 链接会按用户冗余存储。如果两个用户添加相同的链接,它将分别为每个用户存储。这意味着,如果在添加链接时进行链接检查,如果N用户添加相同的链接,那么每次都会进行检查。这不是很有效,特别是对于许多用户可能添加并且可以从单个检查中受益的热门链接。

考虑以下情况,这甚至更糟:

  • N用户添加链接L

  • 对于所有这些N用户,链接检查L都通过了。

  • 另一个用户N+1添加相同的链接L,现在已经损坏(例如,托管公司删除了页面)。

  • 只有最后一个用户N+1将拥有链接L的正确状态,即无效。

  • 所有以前的N用户仍然会认为链接是有效的。

由于我们在本章中想要专注于无服务器函数,我们将接受 Delinkcious 为每个用户存储链接的方式中的这些限制。将来可能会有更有效和更健壮的设计,如下所示:

  • 独立于用户存储所有链接。

  • 添加链接的用户将与该链接关联。

  • 链接检查将自动反映所有用户的链接的最新状态。

在设计链接检查时,让我们考虑一些以下选项,用于在添加新链接时检查链接:

  • 在添加链接时,只需在链接服务中运行链接检查代码。

  • 在添加链接时,调用一个单独的链接检查服务。

  • 在添加链接时,调用一个无服务器函数进行链接检查。

  • 在添加链接时,保持链接处于待定状态,定期对所有最近添加的链接进行检查。

另外,由于链接随时可能会中断,定期对现有链接运行链接检查可能是有用的。

让我们考虑第一个选项,即在链接管理器内部运行链接检查。虽然这样做简单,但也存在一些问题,比如:

  • 如果链接检查时间太长(例如,如果目标不可达或内容分类需要很长时间),那么它将延迟对添加链接的用户的响应,甚至可能超时。

  • 即使实际的链接检查是异步进行的,它仍然以不可预测的方式占用了链接服务的资源。

  • 没有简单的方法可以安排定期检查或临时检查链接,而不对链接管理器进行重大更改。

  • 从概念上讲,链接检查是链接管理的一个单独责任,不应该存在于同一个微服务中。

让我们考虑第二个选项,即实施一个专门的链接检查服务。这个选项解决了大部分第一个选项的问题,但可能有些过度。也就是说,当没有必要经常检查链接时,这并不是最佳选项;例如,如果大多数添加的链接都经过了检查,或者链接检查只是定期进行。此外,为了实施一个单一操作的服务,检查链接似乎有些过度。

这让我们剩下了第三和第四个选项,两者都可以通过无服务器函数解决方案有效实施,如下图所示。

让我们从以下简单的设计开始:

  • 当添加新链接时,链接管理器将调用一个无服务器函数。

  • 新链接最初将处于待定状态。

  • 无服务器函数将仅检查链接是否可达。

  • 无服务器函数将通过 NATS 系统发送一个事件,链接管理器将订阅该事件。

  • 当链接管理器接收到事件时,将更新链接状态从“待定”到“有效”或“无效”。

以下是描述这一流程的图表:

有了一个坚实的设计,让我们继续实施并将其与 Delinkcious 集成。

实施链接检查

在这个阶段,我们将独立于无服务器函数实现链接检查功能。让我们从我们的对象模型开始,并向我们的链接对象添加Status字段,可能的值为pendingvalidinvalid。我们在这里定义了一个名为LinkStatusalias类型,并为这些值定义了常量。但是,请注意,它不像其他语言中的强类型enum,它实际上只是一个字符串:

const (
     LinkStatusPending = "pending"
     LinkStatusValid   = "valid"
     LinkStatusInvalid = "invalid"
 )

 type LinkStatus = string

 type Link struct {
     Url         string
     Title       string
     Description string
     Status      LinkStatus
     Tags        map[string]bool
     CreatedAt   time.Time
     UpdatedAt   time.Time
 }

让我们也定义一个CheckLinkRequest对象,以后会派上用场。请注意,每个请求都是针对特定用户的,并包括链接的 URL:

type CheckLinkRequest struct {
     Username string
     Url      string
 }

现在,让我们定义一个接口,LinkManager将实现该接口以在链接检查完成时得到通知。该接口非常简单,只有一个方法,用于通知接收者(在我们的例子中是LinkManager)用户、URL 和链接状态:

type LinkCheckerEvents interface {
     OnLinkChecked(username string, url string, status LinkStatus)
 }

让我们创建一个新的包pkg/link_checker,以隔离这个功能。它有一个名为CheckLink()的函数,接受一个 URL,并使用内置的 Go HTTP 客户端调用其 HEAD HTTP 方法。

如果结果小于 400,则被视为成功,否则将 HTTP 状态作为错误返回:

package link_checker

 import (
     "errors"
     "net/http"
 )

 // CheckLinks tries to get the headers of the target url and returns error if it fails
 func CheckLink(url string) (err error) {
     resp, err := http.Head(url)
     if err != nil {
         return
     }
     if resp.StatusCode >= 400 {
         err = errors.New(resp.Status)
     }
     return
 }

HEAD 方法只返回一些头部信息,是检查链接是否可达的有效方法,因为即使对于非常大的资源,头部信息也只是一小部分数据。显然,如果我们想将链接检查扩展到扫描和分析内容,这是不够的,但现在可以用。

根据我们的设计,当链接检查完成时,LinkManager应该通过 NATS 接收到一个事件,其中包含检查结果。这与新闻服务监听链接事件(如链接添加和链接更新事件)非常相似。让我们为 NATS 集成实现另一个包link_checker_events,它将允许我们发送和订阅链接检查事件。首先,我们需要一个包含用户名、URL 和链接状态的事件对象:

package link_checker_events

 import (
     om "github.com/the-gigi/delinkcious/pkg/object_model"
 )

 type Event struct {
     Username string
     Url      string
     Status   om.LinkStatus
 }

然后,我们需要能够通过 NATS 发送事件。eventSender对象实现了LinkCheckerEvents接口。每当它接收到调用时,它会创建link_checker_events.Event并将其发布到 NATS:

package link_checker_events

 import (
     "github.com/nats-io/go-nats"
     om "github.com/the-gigi/delinkcious/pkg/object_model"
     "log"
 )

 type eventSender struct {
     hostname string
     nats     *nats.EncodedConn
 }

 func (s *eventSender) OnLinkChecked(username string, url string, status om.LinkStatus) {
     err := s.nats.Publish(subject, Event{username, url, status})
     if err != nil {
         log.Fatal(err)
     }
 }

 func NewEventSender(url string) (om.LinkCheckerEvents, error) {
     ec, err := connect(url)
     if err != nil {
         return nil, err
     }
     return &eventSender{hostname: url, nats: ec}, nil
 }

事件在link_checker_events包中定义,而不是在一般的 Delinkcious 对象模型中定义的原因是,这个事件只是为了通过 NATS 与链接检查监听器进行接口交互而创建的。没有必要在包外部暴露这个事件(除了让 NATS 对其进行序列化)。在Listen()方法中,代码连接到 NATS 服务器并在队列中订阅 NATS(这意味着即使多个订阅者订阅了同一个队列,也只有一个监听器会处理每个事件)。

当订阅到队列的监听函数从 NATS 接收到事件时,它将其转发到实现om.LinkCheckerEvents的事件接收器(同时忽略链接删除事件):

package link_manager_events

 import (
     om "github.com/the-gigi/delinkcious/pkg/object_model"
 )

 func Listen(url string, sink om.LinkManagerEvents) (err error) {
     conn, err := connect(url)
     if err != nil {
         return
     }

     conn.QueueSubscribe(subject, queue, func(e *Event) {
         switch e.EventType {
         case om.LinkAdded:
             {
                 sink.OnLinkAdded(e.Username, e.Link)
             }
         case om.LinkUpdated:
             {
                 sink.OnLinkUpdated(e.Username, e.Link)
             }
         default:
             // Ignore other event types
         }
     })

     return
 }

如果您仔细跟随,您可能已经注意到有一个关键部分缺失,这是我们在设计中描述的,即调用链接检查。一切都已经连接好,准备好检查链接,但实际上没有人在调用链接检查。这就是LinkManager发挥作用的地方,用来调用无服务器函数。

使用 Nuclio 进行无服务器链接检查

在我们深入研究LinkManager并关闭 Delinkcious 中的链接检查循环之前,让我们熟悉一下 Nuclio(nuclio.io/),并探索它如何为 Delinkcious 提供非常适用的无服务器函数解决方案。

Nuclio 的简要介绍

Nuclio 是一个经过精心打磨的开源平台,用于高性能无服务器函数。它由 Iguazio 开发,并支持多个平台,如 Docker、Kubernetes、GKE 和 Iguazio 本身。我们显然关心 Kubernetes,但有趣的是 Nuclio 也可以在其他平台上使用。它具有以下功能:

  • 它可以从源代码构建函数,也可以提供您自己的容器。

  • 这是一个非常清晰的概念模型。

  • 它与 Kubernetes 集成非常好。

  • 它使用一个名为nuctl的 CLI。

  • 如果您想要交互式地使用它,它有一个 Web 仪表板。

  • 它有一系列方法来部署、管理和调用您的无服务器函数。

  • 它提供 GPU 支持。

  • 这是一个 24/7 支持的托管解决方案(需要付费)。

最后,它有一个超酷的标志!您可以在这里查看标志:

现在让我们使用 Nuclio 构建和部署我们的链接检查功能到 Delinkcious 中。

创建一个链接检查无服务器函数

第一步是创建一个无服务器函数;这里有两个组件:

  • 函数代码

  • 函数配置

让我们创建一个专门的目录,名为fun,用于存储无服务器函数。无服务器函数实际上不属于我们现有的任何类别;也就是说,它们既不是普通的包,也不是服务,也不是命令。我们可以将函数代码和其配置作为一个 YAML 文件放在link_checker子目录下。以后,如果我们决定将其他功能建模为无服务器函数,那么我们可以为每个函数创建额外的子目录,如下所示:

$ tree fun
 fun
 └── link_checker
 ├── function.yaml
 └── link_checker.go

函数本身是在link_checker.go中实现的。link_checker函数负责在触发时检查链接并向 NATS 发布结果事件。让我们逐步分解,从导入和常量开始。我们的函数将利用 Nuclio GO SDK,该 SDK 提供了一个标准签名,我们稍后会看到。它还导入了我们的 Delinkcious 包:object_modellink_checkerlink_checker_events包。

在这里,我们还根据众所周知的 Kubernetes DNS 名称定义 NATS URL。请注意,natsUrl常量包括命名空间(默认情况下)。link_checker无服务器函数将在 Nuclio 命名空间中运行,但将向运行在默认命名空间中的 NATS 服务器发送事件。

这不是一个问题;命名空间在网络层不是相互隔离的(除非你明确创建了网络策略):

package main

 import (
     "encoding/json"
     "errors"
     "fmt"
     "github.com/nuclio/nuclio-sdk-go"
     "github.com/the-gigi/delinkcious/pkg/link_checker"
     "github.com/the-gigi/delinkcious/pkg/link_checker_events"
     om "github.com/the-gigi/delinkcious/pkg/object_model"
 )

 const natsUrl = "nats-cluster.default.svc.cluster.local:4222"

实现 Nuclio 无服务器函数(使用 Go)意味着实现具有特定签名的处理函数。该函数接受 Nuclio 上下文和 Nuclio 事件对象。两者都在 Nuclio GO SDK 中定义。处理函数返回一个空接口(基本上可以返回任何东西)。但是,这里我们使用的是 HTTP 调用函数的标准 Nuclio 响应对象。Nuclio 事件有一个GetBody()消息,可以用来获取函数的输入。

在这里,我们使用 Delinkcious 对象模型中的标准 JSON 编码器对CheckLinkRequest进行解组。这是调用link_checker函数的人和函数本身之间的契约。由于 Nuclio 提供了一个通用签名,我们必须验证在请求体中提供的输入。如果没有提供,那么json.Unmarshal()调用将失败,并且函数将返回 400(即,错误的请求)错误:

func Handler(context *nuclio.Context, event nuclio.Event) (interface{}, error) { r := nuclio.Response{ StatusCode: 200, ContentType: "application/text", }

body := event.GetBody()
 var e om.CheckLinkRequest
 err := json.Unmarshal(body, &e)
 if err != nil {
     msg := fmt.Sprintf("failed to unmarshal body: %v", body)
     context.Logger.Error(msg)

     r.StatusCode = 400
     r.Body = []byte(fmt.Sprintf(msg))
     return r, errors.New(msg)

 }

此外,如果解组成功,但生成的CheckLinkRequest具有空用户名或空 URL,则仍然是无效输入,函数也将返回 400 错误:

username := e.Username
 url := e.Url
 if username == "" || url == "" {
     msg := fmt.Sprintf("missing USERNAME ('%s') and/or URL ('%s')", username, url)
     context.Logger.Error(msg)

     r.StatusCode = 400
     r.Body = []byte(msg)
     return r, errors.New(msg)
 }

在这一点上,函数验证了输入,我们得到了一个用户名和一个 URL,并且准备检查链接本身是否有效。只需调用我们之前实现的pkg/link_checker包的CheckLink()函数。状态初始化为LinkStatusValid,如果检查返回错误,则状态设置为LinkStatusInvalid如下:

status := om.LinkStatusValid
err = link_checker.CheckLink(url)
if err != nil {
status = om.LinkStatusInvalid
     }

但是,不要混淆!pkg/link_checker包是实现CheckLink()函数的包。相比之下,fun/link_checker是一个调用CheckLink()的 Nuclio 无服务器函数。

链接已经被检查,我们有了它的状态;现在是时候通过 NATS 发布结果了。同样,我们已经在pkg/link_checker_events中完成了所有的艰苦工作。函数使用natsUrl常量创建一个新的事件发送器。如果失败,函数将返回错误。如果发送器被正确创建,它将使用用户名、URL 和状态调用其OnLinkChecked()方法。最后,它返回 Nuclio 响应(初始化为 200 OK)和无错误,如下所示:

    sender, err := link_checker_events.NewEventSender(natsUrl)
     if err != nil {
         context.Logger.Error(err.Error())

         r.StatusCode = 500
         r.Body = []byte(err.Error())
         return r, err
     }

     sender.OnLinkChecked(username, url, status)
     return r, nil

然而,代码只是故事的一半。让我们在fun/link_checker/function.yaml中审查函数配置。它看起来就像一个标准的 Kubernetes 资源,这不是巧合。

您可以在nuclio.io/docs/latest/reference/function-configuration-reference/查看完整规范。

在下面的代码块中,我们指定了 API 版本、种类(NuclioFunction),然后是规范。我们填写了描述,运行时字段为 Golang,处理程序定义了实现处理程序函数的包和函数名称。我们还指定了最小和最大副本数,在这种情况下都是1。请注意,Nuclio 没有提供缩放到零的方法。每个部署的函数都至少有一个副本等待触发。配置的唯一自定义部分是build命令,用于安装ca-certificates包。这使用了Alpine Linux Package ManagerAPK)系统。这是必要的,因为链接检查器需要检查 HTTPS 链接,这需要根 CA 证书。

apiVersion: "nuclio.io/v1beta1"
 kind: "NuclioFunction"
 spec:
   description: >
     A function that connects to NATS, checks incoming links and publishes LinkValid or LinkInvalid events.
   runtime: "golang"
   handler: main:Handler
   minReplicas: 1
   maxReplicas: 1
   build:
     commands:
     - apk --update --no-cache add ca-certificates

好了!我们创建了一个链接检查器无服务器函数和一个配置;现在让我们将其部署到我们的集群中。

使用 nuctl 部署链接检查器函数

当 Nuclio 部署函数时,实际上会构建一个 Docker 镜像并将其推送到注册表中。在这里,我们将使用 Docker Hub 注册表;所以,首先让我们登录:

$ docker login
Login with your Docker ID to push and pull images from Docker Hub. If you don't have a Docker ID, head over to https://hub.docker.com to create one.
 Username: g1g1
 Password:
 Login Succeeded

函数名称必须遵循 DNS 命名规则,因此link_checker中的""标记是不可接受的。相反,我们将命名函数为link-checker并运行nuctl deploy命令,如下所示:

$ cd fun/link_checker
$ nuctl deploy link-checker -n nuclio -p . --registry g1g1

 nuctl (I) Deploying function {"name": "link-checker"}
 nuctl (I) Building {"name": "link-checker"}
 nuctl (I) Staging files and preparing base images
 nuctl (I) Pulling image {"imageName": "quay.io/nuclio/handler-builder-golang-onbuild:1.1.2-amd64-alpine"}
 nuctl (I) Building processor image {"imageName": "processor-link-checker:latest"}
 nuctl (I) Pushing image {"from": "processor-link-checker:latest", "to": "g1g1/processor-link-checker:latest"}
 nuctl (I) Build complete {"result": {"Image":"processor-link-checker:latest"...}}
 nuctl (I) Function deploy complete {"httpPort": 31475}

请注意,目前编写时使用nuctl将函数部署到 Docker Hub 注册表的文档是不正确的。我为 Nuclio 团队打开了一个 GitHub 问题(github.com/nuclio/nuclio/issues/1181)。希望在您阅读此文时能够修复。

函数已部署到 Nuclio 命名空间,如下所示:

$ kubectl get nucliofunctions -n nuclio
 NAME           AGE
 link-checker   42m

查看所有配置的最佳方法是再次使用nuctl

$ nuctl get function -n nuclio -o yaml
 metadata:
 name: link-checker
 namespace: nuclio
 spec:
 alias: latest
 build:
 path: .
 registry: g1g1
 timestamp: 1554442452
 description: |
A function with a configuration that connects to NATS, listens to LinkAdded events, check the links and send LinkValid or LinkInvalid events.
 handler: main:Handler
 image: g1g1/processor-link-checker:latest
 imageHash: "1554442427312071335"
 maxReplicas: 1
 minReplicas: 1
 platform: {}
 readinessTimeoutSeconds: 30
 replicas: 1
 resources: {}
 runRegistry: g1g1
 runtime: golang
 serviceType: NodePort
 targetCPU: 75
 version: -1

正如您所看到的,它大量借鉴了我们的function.yaml配置文件。

我们已成功使用nuctl CLI 部署了我们的函数,这对开发人员和 CI/CD 系统非常有用。现在让我们看看如何使用 Nuclio Web UI 部署函数。

使用 Nuclio 仪表板部署函数

Nuclio 有一个很酷的 Web UI 仪表板。Nuclio 仪表板做得非常好;它作为一个服务安装在我们的集群中。首先,我们需要在访问之前进行一些端口转发:

$ kubectl port-forward -n nuclio $(kubectl get pods -n nuclio -l nuclio.io/app=dashboard -o jsonpath='{.items[0].metadata.name}') 8070

接下来,我们可以浏览到localhost:8070并使用仪表板。仪表板允许您直接从单个屏幕查看、部署和测试(或调用)无服务器函数。这对于临时探索非常有用。

在这里,我稍微修改了hello示例函数(用 Python),甚至用文本Yeah, it works!进行了测试:

一旦函数在集群中部署,我们可以以不同的方式调用它。

直接调用链接检查器函数

使用nuctl调用函数非常简单。我们需要提供函数名称(link-checker),命名空间,集群 IP 地址和输入到函数的主体:

nuctl invoke link-checker -n nuclio --external-ips $(mk ip)

在 LinkManager 中触发链接检查

在开发函数并希望快速进行编辑-部署-调试周期时,使用nuctl是不错的。但是,在生产中,您将希望通过使用 HTTP 端点或其中一个触发器来调用函数。对于 Delinkcious,最简单的方法是让LinkManager直接命中 HTTP 端点。这发生在将新链接添加到LinkManagerAddLink()方法时。它只是调用triggerLinkCheck并提供用户名和 URL,如下所示:

func (m *LinkManager) AddLink(request om.AddLinkRequest) (err error) {
     ...

     // Trigger link check asynchronously (don't wait for result)
     triggerLinkCheck(request.Username, request.Url)
     return
 }

重要的是AddLink()方法不必等待链接检查完成。如果记得,链接将立即以pending状态存储。稍后,当检查完成时,状态将更改为validinvalid。为了实现这一点,triggerLinkCheck()函数运行一个 goroutine,立即返回控制。

与此同时,goroutine 准备了om.CheckLinkRequest,这是link_checker无服务器函数的处理程序所期望的。它通过json.Marshal()将其序列化为 JSON,并使用 Go 内置的 HTTP 客户端,向 Nuclio 命名空间中链接检查函数的 URL 发送 POST 请求(在另一个命名空间中命中 HTTP 端点没有问题)。在这里,我们只忽略任何错误;如果出现问题,那么链接将保持在pending状态,我们可以稍后决定如何处理它。

// Nuclio functions listen by default on port 8080 of their service IP
 const link_checker_func_url = "http://link-checker.nuclio.svc.cluster.local:8080"

func triggerLinkCheck(username string, url string) {
     go func() {
         checkLinkRequest := &om.CheckLinkRequest{Username: username, Url: url}
         data, err := json.Marshal(checkLinkRequest)
         if err != nil {
             return
         }

         req, err := http.NewRequest("POST", link_checker_func_url, bytes.NewBuffer(data))
         req.Header.Set("Content-Type", "application/json")
         client := &http.Client{}
         resp, err := client.Do(req)
         if err != nil {
             return
         }
         defer resp.Body.Close()
     }()
 }

我们在这里做了很多工作,但我们保持了一切松散耦合并准备进行扩展。很容易添加更复杂的链接检查逻辑,以便触发链接检查作为 NATS 事件,而不是直接命中 HTTP 端点,甚至用完全不同的无服务器函数解决方案替换 Nuclio 无服务器函数。让我们简要地看一下以下部分中的其他选项。

其他 Kubernetes 无服务器框架

AWS Lambda 函数使云中的无服务器函数非常受欢迎。Kubernetes 不是一个完全成熟的无服务器函数原语,但它通过作业和 CronJob 资源非常接近。除此之外,社区开发了大量无服务器函数解决方案(Nuclio 就是其中之一)。以下是一些更受欢迎和成熟的选项,我们将在以下小节中看到:

  • Kubernetes 作业和 CronJobs

  • KNative

  • Fission

  • Kubeless

  • OpenFaas

Kubernetes 作业和 CronJobs

Kubernetes 部署和服务都是关于创建一组长时间运行的 pod,这些 pod 应该无限期地运行。 Kubernetes Job 的目的是运行一个或多个 pod,直到其中一个成功完成。当您创建一个 Job 时,它看起来非常像一个部署,只是重启策略应该是Never

以下是一个从 Python 打印Yeah, it works in a Job!!!的 Kubernetes Job:

apiVersion: batch/v1
kind: Job
metadata:
  name: yeah-it-works
spec:
  template:
    spec:
      containers:
      - name: yeah-it-works
        image: python:3.6-alpine
        command: ["python",  "-c", "print('Yeah, it works in a Job!!!')"]
      restartPolicy: Never

现在我可以运行这个 Job,观察它的完成,并检查日志,如下所示:

$ kubectl create -f job.yaml
 job.batch/yeah-it-works created

 $ kubectl get po | grep yeah-it-works
 yeah-it-works-flzl5            0/1     Completed   0          116s

 $ kubectl logs yeah-it-works-flzl5
 Yeah, it works in a Job!!!

这几乎是一个无服务器函数。当然,它没有所有的花里胡哨,但核心功能是存在的:启动一个容器,运行它直到完成,并获取结果。

Kubernetes CronJob 类似于 Job,只是它会按计划触发。如果您不想在第三方无服务器函数框架上增加额外的依赖项,那么您可以在 Kubernetes Job 和 CronJob 对象之上构建一个基本解决方案。

KNative

KNative(cloud.google.com/knative/)是无服务器函数领域的相对新手,但我实际上预测它将成为主流的首选解决方案,其中有几个原因,例如:

KNative 有三个独立的组件,如下所示:

  • 构建

  • 服务

  • 事件

它被设计为非常可插拔,以便您可以自己选择构建器或事件源。构建组件负责从源代码到镜像的转换。服务组件负责扩展所需的容器数量以处理负载。它可以根据生成的负载进行扩展,或者减少,甚至可以减少到零。事件组件与在无服务器函数中生成和消耗事件有关。

Fission

Fission(fission.io/)是来自 Platform9 的开源无服务器框架,支持多种语言,如 Python、NodeJS、Go、C#和 PHP。它可以扩展以支持其他语言。它保持一组准备就绪的容器,因此新的函数调用具有非常低的延迟,但在没有负载时无法实现零缩放。Fission 特别之处在于它能够通过 Fission 工作流(fission.io/workflows/)组合和链接函数。这类似于 AWS 步函数;Fission 的其他有趣特性包括以下内容:

  • 它可以与 Istio 集成进行监控。

  • 它可以通过 Fluentd 集成将日志整合到 CLI 中(Fluentd 会自动安装为 DaemonSet)。

  • 它提供了 Prometheus 集成,用于指标收集和仪表板可见性。

Kubeless

Kubeless 是 Bitnami 推出的另一个 Kubernetes 原生框架。它使用函数、触发器和运行时的概念模型,这些模型是使用通过 ConfigMaps 配置的 Kubernetes CRD 实现的。Kubeless 使用 Kubernetes 部署来部署函数 pod,并使用Horizontal Pod AutoscalerHPA)进行自动缩放。

这意味着 Kubeless 不能实现零缩放,因为目前 HPA 不能实现零缩放。Kubeless 最主要的亮点之一是其出色的用户界面。

OpenFaas

OpenFaas(www.openfaas.com/)是最早的 FaaS 项目之一。它可以在 Kubernetes 或 Docker Swarm 上运行。由于它是跨平台的,它以通用的非 Kubernetes 方式执行许多操作。例如,它可以通过使用自己的函数容器管理来实现零缩放。它还支持许多语言,甚至支持纯二进制函数。

它还有 OpenFaaS Cloud 项目,这是一个完整的基于 GitOps 的 CI/CD 流水线,用于管理您的无服务器函数。与其他无服务器函数项目类似,OpenFaas 有自己的 CLI 和 UI 用于管理和部署。

总结

在本章中,我们以一种时尚的方式为 Delinkcious 引入了链接检查!我们讨论了无服务器场景,包括它的两个常见含义;即不处理实例、节点或服务器,以及云函数作为服务。然后,我们在 Delinkcious 中实现了一个松散耦合的解决方案,利用我们的 NATS 消息系统来在链接被检查时分发事件。然后,我们详细介绍了 Nuclio,并使用它来闭环,并让LinkManager启动无服务器函数进行链接检查,并稍后得到通知以更新链接状态。

最后,我们调查了许多其他解决方案和 Kubernetes 上的无服务器函数框架。在这一点上,您应该对无服务器计算和无服务器函数有一个扎实的了解。您应该能够就您的系统和项目是否可以从无服务器函数中受益以及哪种解决方案最佳做出明智的决定。很明显,这些好处是真实的,而且这不是一个会消失的时尚。我预计 Kubernetes 中的无服务器解决方案将 consolide(可能围绕 KNative)并成为大多数 Kubernetes 部署的基石,即使它们不是核心 Kubernetes 的一部分。

在下一章中,我们将回到基础知识,并探讨我最喜欢的一个主题,即测试。测试可以成就或毁掉大型项目,在微服务和 Kubernetes 的背景下有许多经验教训可以应用。

更多阅读

您可以参考以下参考资料以获取更多信息:

第十章:测试微服务

软件是人类创造的最复杂的东西。大多数程序员在编写 10 行代码时都无法避免出现错误。现在,考虑一下编写由大量相互作用的组件组成的分布式系统所需的工作,这些组件由大型团队使用大量第三方依赖、大量数据驱动逻辑和大量配置进行设计和实现。随着时间的推移,许多最初构建系统的架构师和工程师可能已经离开组织或转移到不同的角色。需求变化,新技术被重新引入,更好的实践被发现。系统必须发展以满足所有这些变化。

底线是,如果没有严格的测试,你几乎没有机会构建一个可行的非平凡系统。适当的测试是确保系统按预期工作并在引入破坏性变化之前立即识别问题的骨架。基于微服务的架构在测试方面引入了一些独特的挑战,因为许多工作流涉及多个微服务,可能难以控制所有相关微服务和数据存储的测试条件。Kubernetes 引入了自己的测试挑战,因为它在幕后做了很多工作,需要更多的工作来创建可预测和可重复的测试。

我们将在 Delinkcious 中演示所有这些类型的测试。特别是,我们将专注于使用 Kubernetes 进行本地测试。然后,我们将讨论隔离这个重要问题,它允许我们在不影响生产环境的情况下运行端到端测试。最后,我们将看到如何处理数据密集型测试。

本章将涵盖以下主题:

  • 单元测试

  • 集成测试

  • 使用 Kubernetes 进行本地测试

  • 隔离

  • 端到端测试

  • 管理测试数据

技术要求

代码分布在两个 Git 存储库之间:

单元测试

单元测试是最容易融入代码库的测试类型,但它带来了很多价值。当我说它是最容易的时候,我认为你可以使用最佳实践,比如适当的抽象、关注点分离、依赖注入等等。试图测试一个意大利面代码库并不容易!

让我们简要谈谈 Go 中的单元测试、Ginkgo 测试框架,然后回顾一些 Delinkcious 中的单元测试。

使用 Go 进行单元测试

Go 是一种现代语言,认识到测试的重要性。Go 鼓励对于每个foo.go文件,都有一个foo_test.go。它还提供了 testing 包,Go 工具有一个test命令。让我们看一个简单的例子。这是一个包含safeDivide()函数的foo.go文件。这个函数用于整数除法,并返回一个结果和一个错误。

如果分母非零,则不返回错误,但如果分母为零,则返回“除以零”错误:

package main

 import "errors"

 func safeDivide(a int, b int) (int, error) {
         if b == 0 {
                 return 0, errors.New("division by zero")
         }

         return a / b, nil
 }

请注意,当两个操作数都是整数时,Go 除法使用整数除法。这样做是为了确保两个整数相除的结果始终是整数部分(小数部分被舍弃)。例如,6/4 返回 1。

这是一个名为foo_test.go的 Go 单元测试文件,测试了非零和零分母,并使用了testing包。每个test函数接受一个指向testing.T对象的指针。当测试失败时,它调用T对象的Errorf()方法:

package main

 import (
         "testing"
 )

func TestExactResult(t *testing.T) {
        result, err := safeDivide(8, 4)
        if err != nil {
                t.Errorf("8 / 4 expected 2,  got error %v", err)
        }

        if result != 2 {
         t.Errorf("8 / 4 expected 2,  got %d", result)
        }
} 

func TestIntDivision(t *testing.T) {
        result, err := safeDivide(14, 5)
        if err != nil {
                t.Errorf("14 / 5 expected 2,  got error %v", err)
        }

        if result != 2 {
                   t.Errorf("14 / 5 expected 2,  got %d", result)
        }
}

func TestDivideByZero(t *testing.T) {
        result, err := safeDivide(77, 0)
        if err == nil {
                t.Errorf("77 / 0 expected 'division by zero' error,  got result %d", result)
        }

       if err.Error() != "division by zero" {
               t.Errorf("77 / 0 expected 'division by zero' error,  got this error instead %v", err)
       }
}

现在,要运行测试,我们可以使用go test -v命令。这是标准 Go 工具的一部分:

$ go test -v
=== RUN   TestExactResult
--- PASS: TestExactResult (0.00s)
=== RUN   TestIntDivision
--- PASS: TestIntDivision (0.00s)
=== RUN   TestDivideByZero
--- PASS: TestDivideByZero (0.00s)
PASS
ok      github.com/the-gigi/hands-on-microservices-with-kubernetes-code/ch10    0.010s

很好 - 所有测试都通过了。我们还可以看到测试运行花了多长时间。让我们引入一个有意的错误。现在,safeDivide减去了,而不是除以:

package main

 import "errors"

 func safeDivide(a int, b int) (int, error) {
         if b == 0 {
                 return 0, errors.New("division by zero")
         }

         return a - b, nil
}

我们只期望通过零除测试:

$ go test -v
=== RUN   TestExactResult
--- FAIL: TestExactResult (0.00s)
 foo_test.go:14: 8 / 4 expected 2,  got 4
=== RUN   TestIntDivision
--- FAIL: TestIntDivision (0.00s)
 foo_test.go:25: 14 / 5 expected 2,  got 9
=== RUN   TestDivideByZero
--- PASS: TestDivideByZero (0.00s)
FAIL
exit status 1
FAIL    github.com/the-gigi/hands-on-microservices-with-kubernetes-code/ch10    0.009s

我们得到了我们预期的结果。

testing包还有很多内容。T对象有其他方法可以使用。它提供了基准测试和常见设置的设施。然而,总的来说,由于测试包的人体工程学,最好不要在T对象上调用方法。在没有额外的工具支持的情况下,使用testing包管理复杂和分层的测试集也可能会很困难。这正是 Ginkgo 出现的地方。让我们来了解一下 Ginkgo。Delinkcious 使用 Ginkgo 进行单元测试。

使用 Ginkgo 和 Gomega 进行单元测试

Ginkgo(github.com/onsi/ginkgo)是一个行为驱动开发BDD)测试框架。它仍然在底层使用测试包,但允许您使用更好的语法编写测试。它还与 Gomega(github.com/onsi/gomega)很搭配,后者是一个出色的断言库。使用 Ginkgo 和 Gomega 可以获得以下功能:

  • 编写 BDD 风格的测试

  • 任意嵌套块(DescribeContextWhen

  • 良好的设置/拆卸支持(BeforeEachAfterEachBeforeSuiteAfterSuite

  • 仅关注一个测试或通过正则表达式匹配

  • 通过正则表达式跳过测试

  • 并行性

  • 与覆盖率和基准测试的集成

让我们看看 Delinkcious 如何在其单元测试中使用 Ginkgo 和 Gomega。

Delinkcious 单元测试

我们将使用link_manager包中的LinkManager作为示例。它具有非常复杂的交互:它允许您管理数据存储,访问另一个微服务(社交图服务),触发无服务器函数(链接检查器)并响应链接检查事件。这听起来是一组非常多样化的依赖关系,但正如您将看到的,通过设计可测试性,可以在不太复杂的情况下实现高水平的测试。

设计可测试性

适当的测试开始于编写测试之前很长时间。即使您实践测试驱动设计TDD)并在实现之前编写测试,您仍然需要在编写测试之前设计要测试的代码的接口(否则测试将调用哪些函数或方法?)。对于 Delinkcious,我们采用了非常有意识的方法,包括抽象、层和关注点分离。我们所有的辛勤工作现在将会得到回报。

让我们看看LinkManager,并只考虑它的依赖关系:

package link_manager

 import (
     "bytes"
     "encoding/json"
     "errors"
     "github.com/the-gigi/delinkcious/pkg/link_checker_events"
     om "github.com/the-gigi/delinkcious/pkg/object_model"
     "log"
     "net/http"
 )

正如您所看到的,LinkManager依赖于 Delinkcious 对象模型抽象包,link_checker_events和标准的 Go 包。LinkManager不依赖于任何其他 Delinkcious 组件的实现或任何第三方依赖。在测试期间,我们可以为所有依赖项提供替代(模拟)实现,并完全控制测试环境和结果。我们将在下一节中看到如何做到这一点。

模拟的艺术

理想情况下,对象在创建时应注入所有依赖项。让我们看看NewLinkManager()函数:

func NewLinkManager(linkStore LinkStore,
     socialGraphManager om.SocialGraphManager,
     natsUrl string,
     eventSink om.LinkManagerEvents,
     maxLinksPerUser int64) (om.LinkManager, error) {
     ...
 }

这几乎是理想的情况。我们得到了链接存储、社交图管理器和事件接收器的接口。然而,这里有两个未注入的依赖项:link_checker_events和内置的net/http包。让我们从模拟链接存储、社交图管理器和链接管理器事件接收器开始,然后考虑更困难的情况。

LinkStore是在内部定义的一个接口:

package link_manager

 import (
     om "github.com/the-gigi/delinkcious/pkg/object_model"
 )

 type LinkStore interface {
     GetLinks(request om.GetLinksRequest) (om.GetLinksResult, error)
     AddLink(request om.AddLinkRequest) (*om.Link, error)
     UpdateLink(request om.UpdateLinkRequest) (*om.Link, error)
     DeleteLink(username string, url string) error
     SetLinkStatus(username, url string, status om.LinkStatus) error
 }

pkg/link_manager/mock_social_graph_manager.go文件中,我们可以找到一个模拟社交图管理器,它实现了om.SocialGraphManager并且总是从newMockSocialGraphManager()函数中提供的关注者中返回GetFollowers()方法。这是重用相同的模拟来进行不同测试的一个很好的方法,这些测试需要GetFollowers()不同的预定义响应。其他方法只返回 nil 的原因是它们不被LinkManager调用,所以不需要提供实际的响应:

package link_manager
type mockSocialGraphManager struct { followers map[string]bool }

func (m *mockSocialGraphManager) Follow(followed string, follower string) error { return nil }

func (m *mockSocialGraphManager) Unfollow(followed string, follower string) error { return nil }

func (m *mockSocialGraphManager) GetFollowing(username string) (map[string]bool, error) { return nil, nil }

func (m *mockSocialGraphManager) GetFollowers(username string) (map[string]bool, error) { return m.followers, nil }

func newMockSocialGraphManager(followers []string) *mockSocialGraphManager { m := &mockSocialGraphManager{ map[string]bool{}, } for _, f := range followers { m.followers[f] = true }

return m

}

事件接收器有点不同。我们有兴趣验证当调用各种操作,比如AddLink()时,LinkManager是否正确通知了事件接收器。为了做到这一点,我们可以创建一个测试事件接收器,它实现了om.LinkManagerEvents接口,并跟踪接收到的事件。这是在pkg/link_manager/test_event_sink.go文件中的代码。testEventSink结构体为每种事件类型保留了一个映射,其中键是用户名,值是链接列表。它根据各种事件更新这些映射:

package link_manager

import ( om "github.com/the-gigi/delinkcious/pkg/object_model" )

type testEventsSink struct { addLinkEvents map[string][]om.Link updateLinkEvents map[string][]om.Link deletedLinkEvents map[string][]string }

func (s testEventsSink) OnLinkAdded(username string, link om.Link) { if s.addLinkEvents[username] == nil { s.addLinkEvents[username] = []*om.Link{} } s.addLinkEvents[username] = append(s.addLinkEvents[username], link) }

func (s testEventsSink) OnLinkUpdated(username string, link om.Link) { if s.updateLinkEvents[username] == nil { s.updateLinkEvents[username] = []*om.Link{} } s.updateLinkEvents[username] = append(s.updateLinkEvents[username], link) }

func (s *testEventsSink) OnLinkDeleted(username string, url string) { if s.deletedLinkEvents[username] == nil { s.deletedLinkEvents[username] = []string{} } s.deletedLinkEvents[username] = append(s.deletedLinkEvents[username], url) }

func newLinkManagerEventsSink() testEventsSink { return &testEventsSink{ map[string][]om.Link{}, map[string][]*om.Link{}, map[string][]string{}, } }

现在我们已经准备好了模拟,让我们创建 Ginkgo 测试套件。

启动测试套件

Ginkgo 是建立在 Go 的测试包之上的,这很方便,因为你可以只用go test来运行你的 Ginkgo 测试,尽管 Ginkgo 还提供了一个名为 Ginkgo 的 CLI,提供了更多的选项。要为一个包启动一个测试套件,运行ginkgo bootstrap命令。它将生成一个名为<package>_suite_test.go的文件。该文件将所有的 Ginkgo 测试连接到标准的 Go 测试,并导入ginkgogomega包。这是link_manager包的测试套件文件:

package link_manager
import ( "testing"
. "github.com/onsi/ginkgo"
. "github.com/onsi/gomega"
)
func TestLinkManager(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "LinkManager Suite") }

有了测试套件文件,我们可以开始编写一些单元测试。

实现 LinkManager 单元测试

让我们看看获取和添加链接的测试。那里有很多事情要做。这都在pkg/link_manager/in_memory_link_manager_test.go文件中。首先,让我们通过导入ginkgogomegadelinkcious对象模型来设置场景:

package link_manager
import ( . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" om "github.com/the-gigi/delinkcious/pkg/object_model" )

Ginkgo 的Describe块描述文件中的所有测试,并定义将被多个测试使用的变量:

var _ = Describe("In-memory link manager tests", func() { var err error var linkManager om.LinkManager var socialGraphManager mockSocialGraphManager var eventSink testEventsSink

BeforeEach()函数在每个测试之前调用。它使用liat作为唯一的关注者创建一个新的模拟社交图管理器,一个新的事件接收器,并使用这些依赖项初始化新的LinkManager,以及一个内存链接存储,从而利用依赖注入实践:

BeforeEach(func() {
     socialGraphManager = newMockSocialGraphManager([]string{"liat"})
     eventSink = newLinkManagerEventsSink()
     linkManager, err = NewLinkManager(NewInMemoryLinkStore(),
         socialGraphManager,
         "",
         eventSink,
         10)
     Ω(err).Should(BeNil())
 })

这是实际的测试。注意以 BDD 风格定义测试,读起来像英语,应该添加并获取链接。让我们一步一步地分解;首先,测试确保"gigi"用户没有现有链接,通过调用GetLinks()并断言结果为空,使用 Gomega 的Ω运算符:

It("should add and get links", func() {
     // No links initially
     r := om.GetLinksRequest{
         Username: "gigi",
     }
     res, err := linkManager.GetLinks(r)
     Ω(err).Should(BeNil())
     Ω(res.Links).Should(HaveLen(0))

接下来是关于添加链接并确保没有错误发生的部分:

    // Add a link
     r2 := om.AddLinkRequest{
         Username: "gigi",
         Url:      "https://golang.org/",
         Title:    "Golang",
         Tags:     map[string]bool{"programming": true},
     }
     err = linkManager.AddLink(r2)
     Ω(err).Should(BeNil())

现在,测试调用GetLinks()并期望刚刚添加的链接被返回:

    res, err = linkManager.GetLinks(r)
     Ω(err).Should(BeNil())
     Ω(res.Links).Should(HaveLen(1))
     link := res.Links[0]
     Ω(link.Url).Should(Equal(r2.Url))
     Ω(link.Title).Should(Equal(r2.Title))

最后,测试确保事件接收器记录了follower "liat"OnLinkAdded()调用:

    // Verify link manager notified the event sink about a single added event for the follower "liat"
     Ω(eventSink.addLinkEvents).Should(HaveLen(1))
     Ω(eventSink.addLinkEvents["liat"]).Should(HaveLen(1))
     Ω(*eventSink.addLinkEvents["liat"][0]).Should(Equal(link))
     Ω(eventSink.updateLinkEvents).Should(HaveLen(0))
     Ω(eventSink.deletedLinkEvents).Should(HaveLen(0))
 })

这是一个非常典型的单元测试,执行以下任务:

  • 控制测试环境

  • 模拟依赖项(社交图管理器)

  • 为外部交互提供记录占位符(测试事件接收器记录链接管理器事件)

  • 执行被测试的代码(获取链接和添加链接)

  • 验证响应(一开始没有链接;添加后返回一个链接)

  • 验证任何外部交互(事件接收器接收到OnLinkAdded()事件)

我们这里没有测试错误情况,但很容易添加。您可以添加错误输入并检查返回预期错误的测试代码。

你应该测试所有吗?

答案是否定的!测试提供了很多价值,但也有成本。添加测试的边际价值正在减少。测试所有是困难的,甚至是不可能的。考虑到测试需要时间来开发,它可能会减慢对系统的更改(您需要更新测试),并且当依赖关系发生变化时,测试可能需要更改。测试还需要时间和资源来运行,这可能会减慢编辑-测试-部署周期。此外,测试也可能存在错误。找到您需要进行多少测试的平衡点是一个判断性的决定。

单元测试非常有价值,但还不够。这对于基于微服务的架构尤其如此,因为有很多小组件可能可以独立工作,但无法一起实现系统的目标。这就是集成测试的用武之地。

集成测试

集成测试是包括多个相互交互的组件的测试。集成测试意味着在没有或者很少模拟的情况下测试完整的子系统。Delinkcious 有几个针对特定服务的集成测试。这些测试不是自动化的 Go 测试。它们不使用 Ginkgo 或标准的 Go 测试。它们是在出现错误时会 panic 的可执行程序。这些程序旨在测试跨服务的交互以及服务如何与实际数据存储等第三方组件集成。例如,link_manager_e2e测试执行以下步骤:

  1. 启动社交图服务和链接服务作为本地进程

  2. 在 Docker 容器中启动一个 Postgres 数据库

  3. 对链接服务运行测试

  4. 验证结果

让我们看看它是如何发挥作用的。导入列表包括 Postgres Golang 驱动程序(lib/pq),几个 Delinkcious 包,以及一些标准的 Go 包(contextlogos)。请注意,pq被导入为破折号。这意味着pq名称不可用。以这种未命名模式导入库的原因是它只需要运行一些初始化代码,不会被外部访问。具体来说,pq向标准的 Go database/sql库注册了一个 Go 驱动程序:

package main
import ( "context" _ "github.com/lib/pq" "github.com/the-gigi/delinkcious/pkg/db_util" "github.com/the-gigi/delinkcious/pkg/link_manager_client" om "github.com/the-gigi/delinkcious/pkg/object_model" . "github.com/the-gigi/delinkcious/pkg/test_util" "log" "os" )

让我们来看一些用于设置测试环境的函数,首先是初始化数据库。

初始化测试数据库

initDB()函数通过传递数据库名称(link_manager)调用RunLocalDB()函数。这很重要,因为如果你是从头开始的,它也需要创建数据库。然后,为了确保测试总是从头开始运行,它删除tagslinks表,如下所示:

func initDB() { db, err := db_util.RunLocalDB("link_manager") Check(err)
tables := []string{"tags", "links"}
 for _, table := range tables {
     err = db_util.DeleteFromTableIfExist(db, table)
     Check(err)
 }
}

运行服务

测试有两个单独的函数来运行服务。这些函数非常相似。它们设置环境变量并调用RunService()函数,我们很快就会深入了解。两个服务都依赖于PORT环境变量的值,并且每个服务的值都需要不同。这意味着我们必须按顺序启动服务,而不是并行启动。否则,服务可能最终会监听错误的端口:

func runLinkService(ctx context.Context) {
     // Set environment
     err := os.Setenv("PORT", "8080")
     Check(err)

     err = os.Setenv("MAX_LINKS_PER_USER", "10")
     Check(err)

     RunService(ctx, ".", "link_service")
 }

 func runSocialGraphService(ctx context.Context) {
     err := os.Setenv("PORT", "9090")
     Check(err)

     RunService(ctx, "../social_graph_service", "social_graph_service")
 }

运行实际测试

main()函数是整个测试的驱动程序。它打开了链接管理器和社交图管理器之间的相互认证,初始化数据库,并运行服务(只要RUN_XXX_SERVICE环境变量为true):

func main() {
     // Turn on authentication
     err := os.Setenv("DELINKCIOUS_MUTUAL_AUTH", "true")
     Check(err)

     initDB()

     ctx := context.Background()
     defer KillServer(ctx)

     if os.Getenv("RUN_SOCIAL_GRAPH_SERVICE") == "true" {
         runSocialGraphService(ctx)
     }

     if os.Getenv("RUN_LINK_SERVICE") == "true" {
         runLinkService(ctx)
     }

现在它已经准备好实际运行测试了。它使用链接管理器客户端连接到本地主机上的端口8080,这是链接服务正在运行的地方。然后,它调用GetLinks()方法,打印结果(应该为空),通过调用AddLink()添加一个链接,再次调用GetLinks(),并打印结果(应该是一个链接):

// Run some tests with the client
     cli, err := link_manager_client.NewClient("localhost:8080")
     Check(err)

     links, err := cli.GetLinks(om.GetLinksRequest{Username: "gigi"})
     Check(err)
     log.Print("gigi's links:", links)

     err = cli.AddLink(om.AddLinkRequest{Username: "gigi",
         Url:   "https://github.com/the-gigi",
         Title: "Gigi on Github",
         Tags:  map[string]bool{"programming": true}})
     Check(err)

     links, err = cli.GetLinks(om.GetLinksRequest{Username: "gigi"})
     Check(err)
     log.Print("gigi's links:", links)

这个集成测试不是自动化的。它是为了交互式使用而设计的,开发人员可以运行和调试单个服务。如果发生错误,它会立即退出。每个操作的结果只是简单地打印到屏幕上。

测试的其余部分检查了UpdateLink()DeleteLink()操作:

    err = cli.UpdateLink(om.UpdateLinkRequest{Username: "gigi",
         Url:         "https://github.com/the-gigi",
         Description: "Most of my open source code is here"},
     )

     Check(err)
     links, err = cli.GetLinks(om.GetLinksRequest{Username: "gigi"})
     Check(err)
     log.Print("gigi's links:", links)

     err = cli.DeleteLink("gigi", "https://github.com/the-gigi")
     Check(err)
     Check(err)
     links, err = cli.GetLinks(om.GetLinksRequest{Username: "gigi"})
     Check(err)
     log.Print("gigi's links:", links)
 }

通过链接管理器客户端库进行测试确保了从客户端到服务到依赖服务及其数据存储的整个链条都在工作。

让我们来看一些测试助手函数,当我们试图在本地测试和调试微服务之间的复杂交互时,它们非常有用。

实现数据库测试助手

在深入代码之前,让我们考虑一下我们想要实现的目标。我们希望创建一个本地空数据库。我们希望将其作为 Docker 容器启动,但只有在它尚未运行时才这样做。为了做到这一点,我们需要检查 Docker 容器是否已经在运行,如果我们应该重新启动它,或者我们应该运行一个新的容器。然后,我们将尝试连接到目标数据库,并在不存在时创建它。服务将负责根据需要创建模式,因为通用的 DB 实用程序对特定服务的数据库模式一无所知。

db_util包中的db_util.go文件包含所有辅助函数。首先,让我们回顾一下导入的内容,其中包括标准的 Go database/sql包和 squirrel - 一个流畅风格的 Go 库,用于生成 SQL(但不是 ORM)。还导入了 Postgres 驱动程序库pq

package db_util

 import (
     "database/sql"
     "fmt"
     sq "github.com/Masterminds/squirrel"
     _ "github.com/lib/pq"
     "log"
     "os"
     "os/exec"
     "strconv"
     "strings"
 )

dbParams结构包含连接到数据库所需的信息,defaultDbParams()函数方便地获取填充有默认值的结构:

type dbParams struct {
     Host     string
     Port     int
     User     string
     Password string
     DbName   string
 }

 func defaultDbParams() dbParams {
     return dbParams{
         Host:     "localhost",
         Port:     5432,
         User:     "postgres",
         Password: "postgres",
     }
 }

您可以通过传递dbParams结构中的信息来调用connectToDB()函数。如果一切顺利,您将得到一个数据库句柄(*sql.DB),然后可以使用它来以后访问数据库:

func connectToDB(host string, port int, username string, password string, dbName string) (db *sql.DB, err error) {
     mask := "host=%s port=%d user=%s password=%s dbname=%s sslmode=disable"
     dcn := fmt.Sprintf(mask, host, port, username, password, dbName)
     db, err = sql.Open("postgres", dcn)
     return
 }

完成所有准备工作后,让我们看看RunLocalDB()函数是如何工作的。首先,它运行docker ps -f name=postgres命令,列出名为postgres的正在运行的 Docker 容器(只能有一个):

func RunLocalDB(dbName string) (db *sql.DB, err error) {
     // Launch the DB if not running
     out, err := exec.Command("docker", "ps", "-f", "name=postgres", "--format", "{{.Names}}").CombinedOutput()
     if err != nil {
         return
     }

如果输出为空,这意味着没有正在运行的容器,因此它会尝试重新启动容器,以防它已经停止。如果这也失败了,它就会运行一个新的postgres:alpine镜像的容器,将标准的5432端口暴露给本地主机。注意-z标志。它告诉 Docker 以分离(非阻塞)模式运行容器,这允许函数继续。如果由于任何原因无法运行新容器,它会放弃并返回错误:

    s := string(out)
     if s == "" {
         out, err = exec.Command("docker", "restart", "postgres").CombinedOutput()
         if err != nil {
             log.Print(string(out))
             _, err = exec.Command("docker", "run", "-d", "--name", "postgres",
                 "-p", "5432:5432",
                 "-e", "POSTGRES_PASSWORD=postgres",
                 "postgres:alpine").CombinedOutput()

         }
         if err != nil {
             return
         }
     }

此时,我们正在运行一个在容器中运行的 Postgres DB。我们可以使用defaultDBParams()函数并调用EnsureDB()函数,接下来我们将对其进行检查:

p := defaultDbParams()
 db, err = EnsureDB(p.Host, p.Port, p.User, p.Password, dbName)
 return
}

为了确保数据库已准备就绪,我们需要连接到 postgres 实例的 Postgres DB。每个 postgres 实例都有几个内置数据库,包括postgres数据库。postgres 实例的 Postgres DB 可用于获取有关实例的信息和元数据。特别是,我们可以查询pg_database表以检查目标数据库是否存在。如果不存在,我们可以通过执行CREATE database <db name>命令来创建它。最后,我们连接到目标数据库并返回其句柄。通常情况下,如果出现任何问题,我们会返回错误:

// Make sure the database exists (creates it if it doesn't)

func EnsureDB(host string, port int, username string, password string, dbName string) (db *sql.DB, err error) { // Connect to the postgres DB postgresDb, err := connectToDB(host, port, username, password, "postgres") if err != nil { return }

// Check if the DB exists in the list of databases
 var count int
 sb := sq.StatementBuilder.PlaceholderFormat(sq.Dollar)
 q := sb.Select("count(*)").From("pg_database").Where(sq.Eq{"datname": dbName})
 err = q.RunWith(postgresDb).QueryRow().Scan(&count)
 if err != nil {
     return
 }

 // If it doesn't exist create it
 if count == 0 {
     _, err = postgresDb.Exec("CREATE database " + dbName)
     if err != nil {
         return
     }
 }

 db, err = connectToDB(host, port, username, password, dbName)
 return
}

这是一个深入研究自动设置本地测试数据库的过程。在许多情况下,甚至超出微服务范围,这非常方便。

实施服务测试助手

让我们看一些测试服务的辅助函数。test_util包非常基础,使用 Go 标准包作为依赖项:

package test_util

import ( "context" "os" "os/exec" )

它提供了一个错误检查函数和两个运行和停止服务的函数。

检查错误

关于 Go 的一个让人讨厌的事情是你必须一直进行显式的错误检查。以下片段非常常见;我们调用一个返回结果和错误的函数,检查错误,如果它不是 nil,我们就做一些事情(通常我们只是返回):

...
 result, err := foo()
 if err != nil {
     return err
 }
...

Check()函数通过决定它将仅仅恐慌并退出程序(或当前的 Go 例程)使得这一点更加简洁。这在测试场景中是一个可以接受的选择,因为你希望一旦遇到任何故障就退出:

func Check(err error) { if err != nil { panic(err) } }

前面的片段可以缩短为以下内容:

...
 result, err := foo()
 Check(err)
...

如果您的代码需要检查许多错误,那么这些小的节省会累积起来。

在本地运行服务

最重要的辅助函数之一是RunService()。微服务通常依赖于其他微服务。在测试服务时,测试代码通常需要运行依赖的服务。在这里,代码在其target目录中构建一个 Go 服务并执行它:

// Build and run a service in a target directory
func RunService(ctx context.Context, targetDir string, service string) {
   // Save and restore later current working dir
   wd, err := os.Getwd()
   Check(err)
   defer os.Chdir(wd)

   // Build the server if needed
   os.Chdir(targetDir)
   _, err = os.Stat("./" + service)
   if os.IsNotExist(err) {
      _, err := exec.Command("go", "build", ".").CombinedOutput()
      Check(err)
   }

   cmd := exec.CommandContext(ctx, "./"+service)
   err = cmd.Start()
   Check(err)
}

运行服务很重要,但在测试结束时清理并停止所有由测试启动的服务也很重要。

停止本地服务

停止服务就像调用上下文的Done()方法一样简单。它可以用来向使用上下文的任何代码发出完成信号:

func StopService(ctx context.Context) { ctx.Done() }

正如您所看到的,运行 Delinkcious,甚至只是在没有 Kubernetes 帮助的情况下本地运行 Delinkcious 的一些部分,都涉及大量的工作。当 Delinkcious 运行时,它非常适用于调试和故障排除,但创建和维护这个设置是乏味且容易出错的。

此外,即使所有集成测试都能正常工作,它们也无法完全复制 Kubernetes 集群,可能会有许多未被捕获的故障模式。让我们看看如何使用 Kubernetes 本身进行本地测试。

使用 Kubernetes 进行本地测试

Kubernetes 的一个特点是同一个集群可以在任何地方运行。对于真实世界的系统来说,如果您使用的服务在本地不可用,或者访问本地的速度太慢或者太昂贵,那么情况就不总是那么简单。关键是要在高保真度和便利性之间找到一个好的平衡点。

让我们编写一个烟雾测试,让 Delinkcious 通过获取链接、添加链接和检查它们的状态的主要工作流程。

编写烟雾测试

Delinkcious 烟雾测试不是自动化的。它可以是,但需要特殊的设置才能在 CI/CD 环境中运行。对于真实的生产系统,我强烈建议您进行自动化的烟雾测试(以及其他测试)。

代码位于cmd/smoke_test目录中,由一个名为smoke.go的文件组成。它通过 API 网关公开的 REST API 对 Delinkcious 进行测试。我们可以使用任何语言编写这个测试,因为没有客户端库。我选择使用 Go 是为了保持一致性,并突出如何从 Go 中消费原始的 REST API,直接使用 URL、查询字符串和 JSON 负载序列化。我还使用了 Delinkcious 对象模型链接作为方便的序列化目标。

测试期望本地 Minikube 集群中已安装并运行 Delinkcious。以下是测试的流程:

  1. 删除我们的测试链接以重新开始。

  2. 获取链接(并打印它们)。

  3. 添加一个测试链接。

  4. 再次获取链接(新链接应该具有待定状态)。

  5. 等待几秒钟。

  6. 再次获取链接(新链接现在应该具有有效状态)。

这个简单的烟雾测试涵盖了 Delinkcious 功能的重要部分,例如以下内容:

  • 命中 API 网关的多个端点(获取链接、发布新链接、删除链接)。

  • 验证调用者身份(通过访问令牌)。

  • API 网关将转发请求到链接管理器服务。

  • 链接管理器服务将触发链接检查器无服务器函数。

  • 链接检查器将通过 NATS 通知链接管理器新链接的状态。

以后,我们可以扩展测试以创建社交关系,这将涉及社交图管理器,以及检查新闻服务。这将建立一个全面的端到端测试。对于烟雾测试目的,上述工作流程就足够了。

让我们从导入列表开始,其中包括许多标准的 Go 库,以及 Delinkcious 的object_model(用于Link结构)包和test_util包(用于Check()函数)。我们可以很容易地避免这些依赖关系,但它们是熟悉和方便的:

package main

import ( "encoding/json" "errors" "fmt" om "github.com/the-gigi/delinkcious/pkg/object_model" . "github.com/the-gigi/delinkcious/pkg/test_util" "io/ioutil" "log" "net/http" net_url "net/url" "os" "os/exec" "time" )

接下来的部分定义了一些变量。delinkciousUrl稍后将被初始化。delinkciousToken应该在环境中可用,httpClient是我们将用于调用 Delinkcious REST API 的标准 Go HTTP 客户端:

var ( delinkciousUrl string delinkciousToken = os.Getenv("DELINKCIOUS_TOKEN") httpClient = http.Client{} )

完成前提工作后,我们可以专注于测试本身。它非常简单,看起来非常像冒烟测试的高级描述。它使用以下命令从 Minikube 获取 Delinkcious URL:

$ minikube service api-gateway --url http://192.168.99.161:30866

然后,它调用DeleteLink()GetLinks()AddLink()函数,如下所示:

func main() { tempUrl, err := exec.Command("minikube", "service", "api-gateway", "--url").CombinedOutput() delinkciousUrl = string(tempUrl[:len(tempUrl)-1]) + "/v1.0" Check(err)

// Delete link
 deleteLink("https://github.com/the-gigi")

 // Get links
 getLinks()

 // Add a new link
 addLink("https://github.com/the-gigi", "Gigi on Github")

 // Get links again
 getLinks()

 // Wait a little and get links again
 time.Sleep(time.Second * 3)
 getLinks()

}

GetLinks()函数构造正确的 URL,创建一个新的 HTTP 请求,将身份验证令牌作为标头添加(根据 API 网关社交登录身份验证的要求),并命中/links端点。当响应返回时,它检查状态码,并在出现错误时退出。否则,它将响应的主体反序列化为om.GetLinksResult结构,并打印链接:

func getLinks() { req, err := http.NewRequest("GET", string(delinkciousUrl)+"/links", nil) Check(err)

req.Header.Add("Access-Token", delinkciousToken)
 r, err := httpClient.Do(req)
 Check(err)

 defer r.Body.Close()

 if r.StatusCode != http.StatusOK {
     Check(errors.New(r.Status))
 }

 var glr om.GetLinksResult
 body, err := ioutil.ReadAll(r.Body)

 err = json.Unmarshal(body, &glr)
 Check(err)

 log.Println("======= Links =======")
 for _, link := range glr.Links {
     log.Println(fmt.Sprintf("title: '%s', url: '%s', status: '%s'", link.Title, link.Url, link.Status))
 }

}

addLink()函数非常相似,只是它使用 POST 方法,并且只检查响应是否具有 OK 状态。该函数接受一个 URL 和一个标题,并构造一个 URL(包括对查询字符串进行编码)以符合 API 网关规范。如果状态不是 OK,它将使用响应的内容作为错误消息:

func addLink(url string, title string) { params := net_url.Values{} params.Add("url", url) params.Add("title", title) qs := params.Encode()

log.Println("===== Add Link ======")
 log.Println(fmt.Sprintf("Adding new link - title: '%s', url: '%s'", title, url))

 url = fmt.Sprintf("%s/links?%s", delinkciousUrl, qs)
 req, err := http.NewRequest("POST", url, nil)
 Check(err)

 req.Header.Add("Access-Token", delinkciousToken)
 r, err := httpClient.Do(req)
 Check(err)
 if r.StatusCode != http.StatusOK {
     defer r.Body.Close()
     bodyBytes, err := ioutil.ReadAll(r.Body)
     Check(err)
     message := r.Status + " " + string(bodyBytes)
     Check(errors.New(message))
 }

}

太好了!现在,让我们看看测试是如何运行的。

运行测试

在运行测试之前,我们应该导出DELINKCIOUS_TOKEN并确保 Minikube 正在运行:

$ minikube status host: Running kubelet: Running apiserver: Running kubectl: Correctly Configured: pointing to minikube-vm at 192.168.99.160

要运行测试,我们只需输入以下内容:

$ go run smoke.go

结果将打印到控制台。已经有一个无效的链接,即http://gg.com。然后,测试添加了新链接,即https://github.com/the-gigi。新链接的状态最初是挂起的,然后在几秒钟后,当链接检查成功时,它变为有效:

2019/04/19 10:03:48 ======= Links ======= 2019/04/19 10:03:48 title: 'gg', url: 'http://gg.com', status: 'invalid' 2019/04/19 10:03:48 ===== Add Link ====== 2019/04/19 10:03:48 Adding new link - title: 'Gigi on Github', url: 'https://github.com/the-gigi' 2019/04/19 10:03:49 ======= Links ======= 2019/04/19 10:03:49 title: 'gg', url: 'http://gg.com', status: 'invalid' 2019/04/19 10:03:49 title: 'Gigi on Github', url: 'https://github.com/the-gigi', status: 'pending' 2019/04/19 10:03:52 ======= Links ======= 2019/04/19 10:03:52 title: 'gg', url: 'http://gg.com', status: 'invalid' 2019/04/19 10:03:52 title: 'Gigi on Github', url: 'https://github.com/the-gigi', status: 'valid'

Telepresence

Telepresence (www.telepresence.io/) 是一个特殊的工具。它允许您在本地运行一个服务,就好像它正在您的 Kubernetes 集群内运行一样。为什么这很有趣?考虑我们刚刚实施的冒烟测试。如果我们检测到失败,我们希望执行以下三件事:

  • 找到根本原因。

  • 修复它。

  • 验证修复是否有效。

由于我们只在 Kubernetes 集群上运行冒烟测试时才发现了故障,这可能是我们的本地单元测试未检测到的故障。找到根本原因的常规方法(除了离线审查代码之外)是添加一堆日志记录语句,添加实验性调试代码,注释掉无关的部分并部署修改后的代码,重新运行冒烟测试,并尝试了解出现了什么问题。

将修改后的代码部署到 Kubernetes 集群通常涉及以下步骤:

  1. 修改代码

  2. 将修改后的代码推送到 Git 存储库(污染您的 Git 历史记录,因为这些更改仅用于调试)

  3. 构建镜像(通常需要运行各种测试)

  4. 将新镜像推送到镜像注册表

  5. 将新镜像部署到集群

这个过程很繁琐,不鼓励临时探索和快速编辑-调试-修复循环。在第十一章中,我们将探索一些工具,可以跳过推送到 Git 存储库并为您自动构建镜像,但镜像仍然会构建并部署到集群。

使用 Telepresence,您只需在本地对代码进行更改,Telepresence 会确保您的本地服务成为集群的一个完整成员。它看到相同的环境和 Kubernetes 资源,可以通过内部网络与其他服务通信,实际上它是集群的一部分。

Telepresence 通过在集群内安装代理来实现这一点,代理会联系并与您的本地服务进行通信。这非常巧妙。让我们安装 Telepresence 并开始使用它。

安装 Telepresence

安装 Telepresence 需要 FUSE 文件系统:

brew cask install osxfuse

然后,我们可以安装 Telepresence 本身:

brew install datawire/blackbird/telepresence

通过 Telepresence 运行本地链接服务

让我们通过 Telepresence 在本地运行链接管理器服务。首先,为了证明真的是本地服务在运行,我们可以修改服务代码。例如,当获取链接时,我们可以打印一条消息,即"**** 本地链接服务在这里!调用 GetLinks() ****"

让我们将其添加到svc/link_service/service/transport.go中的GetLinks端点:

func makeGetLinksEndpoint(svc om.LinkManager) endpoint.Endpoint { return func(_ context.Context, request interface{}) (interface{}, error) { fmt.Println("**** Local link service here! calling GetLinks() ****") req := request.(om.GetLinksRequest) result, err := svc.GetLinks(req) res := getLinksResponse{} for _, link := range result.Links { res.Links = append(res.Links, newLink(link)) } if err != nil { res.Err = err.Error() return res, err } return res, nil } }

现在,我们可以构建本地链接服务(使用 Telepresence 推荐的标志),并将link-manager部署与本地服务进行交换:

$ cd svc/service/link_service
$ go build -gcflags "all=-N -l" .

$ telepresence --swap-deployment link-manager --run ./link_service
T: How Telepresence uses sudo: https://www.telepresence.io/reference/install#dependencies
T: Invoking sudo. Please enter your sudo password.
Password:
T: Starting proxy with method 'vpn-tcp', which has the following limitations: All processes are affected, only one telepresence can run per machine, and you can't use other VPNs. You may need to add cloud hosts and headless services with --also-proxy.
T: For a full list of method limitations see https://telepresence.io/reference/methods.html
T: Volumes are rooted at $TELEPRESENCE_ROOT. See https://telepresence.io/howto/volumes.html for details.
T: Starting network proxy to cluster by swapping out Deployment link-manager with a proxy
T: Forwarding remote port 8080 to local port 8080.

T: Guessing that Services IP range is 10.96.0.0/12\. Services started after this point will be inaccessible if are outside this range; restart telepresence if you can't access a new Service.
T: Setup complete. Launching your command.
2019/04/20 01:17:06 DB host: 10.100.193.162 DB port: 5432
2019/04/20 01:17:06 Listening on port 8080...

请注意,当您为以下任务交换部署时,Telepresence 需要sudo权限:

  • 修改本地网络(通过sshuttlepf/iptables)以用于 Go 程序的vpn-tcp方法

  • 运行docker命令(对于 Linux 上的某些配置)

  • 挂载远程文件系统以在 Docker 容器中访问

为了测试我们的新更改,让我们再次运行smoke测试:

$ go run smoke.go 
2019/04/21 00:18:50 ======= Links ======= 2019/04/21 00:18:50 ===== Add Link ====== 2019/04/21 00:18:50 Adding new link - title: 'Gigi on Github', url: 'https://github.com/the-gigi' 2019/04/21 00:18:50 ======= Links ======= 2019/04/21 00:18:50 title: 'Gigi on Github', url: 'https://github.com/the-gigi', status: 'pending' 2019/04/21 00:18:54 ======= Links ======= 2019/04/21 00:18:54 title: 'Gigi on Github', url: 'https://github.com/the-gigi', status: 'valid'

查看我们的本地服务输出,我们可以看到在运行smoke测试时确实被调用了:

**** Local link service here! calling GetLinks() ****
**** Local link service here! calling GetLinks() ****

您可能还记得,smoke 测试会在集群中调用 API 网关,因此我们的本地服务被调用表明它确实在集群中运行。有趣的是,我们本地服务的输出不会被 Kubernetes 日志捕获。如果我们搜索日志,什么也找不到。以下命令不会生成任何输出:

$ kubectl logs svc/link-manager | grep "Local link service here" 

现在,让我们看看如何将 GoLand 调试器连接到正在运行的本地服务。

使用 GoLand 附加到本地链接服务进行实时调试

这是调试的终极目标!我们将使用 GoLand 交互式调试器连接到我们的本地链接服务,同时它作为 Kubernetes 集群的一部分在运行。这再好不过了。让我们开始吧:

  1. 首先,按照这里的说明准备好使用 GoLand 附加到本地 Go 进程:blog.jetbrains.com/go/2019/02/06/debugging-with-goland-getting-started/#debugging-a-running-application-on-the-local-machine

  2. 然后,在 GoLand 中点击 Run | Attach to Process 菜单选项,将会出现以下对话框:

不幸的是,当 GoLand 成功附加到进程时,Telepresence 错误地认为本地服务已退出,并关闭了到 Kubernetes 集群及其自身控制进程的隧道。

本地链接服务仍在运行,但不再连接到集群。我为 Telepresence 团队打开了一个 GitHub 问题:github.com/telepresenceio/telepresence/issues/1003

后来我联系了 Telepresence 开发人员,深入了解了代码,并贡献了最近合并的修复。

请参阅以下 PR(为在 Telepresence 下附加调试器到进程添加支持):github.com/telepresenceio/telepresence/pull/1005

如果您正在使用 VS Code 进行 Go 编程,可以尝试按照这里的信息进行操作:github.com/Microsoft/vscode-go/wiki/Debugging-Go-code-using-VS-Code

到目前为止,我们编写了一个独立的冒烟测试,并使用 Telepresence 来能够在我们的 Kubernetes 集群中本地调试服务。这对于交互式开发来说再好不过了。下一节将处理测试隔离。

隔离测试

隔离是测试的一个关键主题。核心思想是,一般来说,您的测试应该与生产环境隔离,甚至与其他共享环境隔离。如果测试不是隔离的,那么测试所做的更改可能会影响这些环境,反之亦然(对这些环境的外部更改可能会破坏假设的测试)。另一种隔离级别是在测试之间。如果您的测试并行运行并对相同的资源进行更改,那么各种竞争条件可能会发生,测试可能会相互干扰并导致错误的负面结果。

如果测试不并行运行,但忽略清理测试 A 可能会导致破坏测试 B 的更改。隔离可以帮助的另一种情况是当多个团队或开发者想要测试不兼容的更改时。如果两个开发者对共享环境进行了不兼容的更改,其中至少一个将遇到失败。隔离有各种级别,它们通常与成本呈反比-更隔离的测试设置成本更高。

让我们考虑以下隔离方法:

  • 测试集群

  • 测试命名空间

  • 跨命名空间/集群

测试集群

集群级别的隔离是最高形式的隔离。您可以在完全独立于生产集群的集群中运行测试。这种方法的挑战在于如何保持测试集群/集群与生产集群的同步。在软件方面,通过一个良好的 CI/CD 系统可能并不太困难,但填充和迁移数据通常相当复杂。

测试集群有两种形式:

  • 每个开发者都有自己的集群。

  • 为执行系统测试而专门设置的集群。

每个开发者一个集群

为每个开发人员创建一个集群是最高级别的隔离。开发人员不必担心破坏其他人的代码或受其他人的代码影响。但是,这种方法也有一些显著的缺点,例如:

  • 为每个开发人员提供一个成熟的集群通常成本太高。

  • 提供的集群通常与生产系统的高保真度不高。

  • 通常仍然需要另一个集成环境来协调多个团队/开发人员的更改。

使用 Kubernetes,可能可以将 Minikube 作为每个开发人员的本地集群,并避免许多缺点。

系统测试的专用集群

为系统测试创建专用集群是在部署到生产环境之前,整合更改并再次测试的好方法。测试集群可以运行更严格的测试,依赖外部资源,并与第三方服务交互。这样的测试集群是昂贵的资源,您必须仔细管理它们。

测试命名空间

测试命名空间是一种轻量级的隔离形式。它们可以与生产系统并行运行,并重用生产环境的一些资源(例如控制平面)。同步数据可能更容易,在 Kubernetes 上,特别是编写自定义控制器来同步和审计测试命名空间与生产命名空间是一个不错的选择。

测试命名空间的缺点是隔离级别降低。默认情况下,不同命名空间中的服务仍然可以相互通信。如果您的系统已经使用多个命名空间,那么您必须非常小心,以保持测试与生产的隔离。

编写多租户系统

多租户系统是指完全隔离的实体共享相同的物理或虚拟资源的系统。Kubernetes 命名空间提供了几种机制来支持这一点。您可以定义网络策略,防止命名空间之间的连接(除了与 Kubernetes API 服务器的交互)。您可以定义每个命名空间的资源配额和限制,以防止恶意命名空间占用所有集群资源。如果您的系统已经设置为多租户,您可以将测试命名空间视为另一个租户。

跨命名空间/集群

有时,您的系统部署到多个协调的命名空间甚至多个集群中。在这种情况下,您需要更加注意如何设计模拟相同架构的测试,同时要小心测试不要与生产命名空间或集群发生交互。

端到端测试

端到端测试对于复杂的分布式系统非常重要。我们为 Delinkcious 编写的冒烟测试就是端到端测试的一个例子,但还有其他几个类别。端到端测试通常针对专用环境运行,比如一个暂存环境,但在某些情况下,它们会直接针对生产环境运行(需要特别注意)。由于端到端测试通常需要很长时间才能运行,并且可能设置起来很慢、费用很高,因此通常不会在每次提交时运行。相反,通常会定期运行(每晚、每个周末或每个月)或临时运行(例如,在重要发布之前)。端到端测试有几个类别。

我们将在以下部分探讨一些最重要的类别,例如以下内容:

  • 验收测试

  • 回归测试

  • 性能测试

验收测试

验收测试是一种验证系统行为是否符合预期的测试形式。决定什么是可以接受的是系统利益相关者的责任。它可以简单到一个冒烟测试,也可以复杂到测试代码中所有可能的路径、所有故障模式和所有副作用(例如,写入日志文件的消息)。良好的验收测试套件的主要好处之一是它是描述系统的一种强制性手段,这种描述对于非工程师利益相关者(如产品经理和高层管理人员)是有意义的。理想的情况(我从未在实践中见过)是业务利益相关者能够自己编写和维护验收测试。

这在精神上接近于可视化编程。我个人认为所有的自动化测试都应该由开发人员编写和维护,但你的情况可能有所不同。Delinkcious 目前只公开了一个 REST API,并没有用户界面的 Web 应用程序。大多数系统现在都有成为验收测试边界的 Web 应用程序。在浏览器中运行验收测试是很常见的。有很多好的框架。如果你喜欢使用 Go,Agouti (agouti.org/) 是一个很好的选择。它与 Ginkgo 和 Gomega 紧密集成,可以通过 PhantomJS、Selenium 或 ChromeDriver 驱动浏览器。

回归测试

回归测试是一个很好的选择,当你只想确保新系统不会偏离当前系统的行为时。如果你有全面的验收测试,那么你只需要确保新版本的系统通过所有验收测试,就像之前的版本一样。然而,如果你的验收测试覆盖不足,你可以通过向当前系统和新系统发送相同的输入并验证输出是否相同来获得某种信心。这也可以通过模糊测试来完成,其中你生成随机输入。

性能测试

性能测试是一个很大的话题。在这里,目标是衡量系统的性能,而不是其响应的正确性。也就是说,错误可能会显著影响性能。考虑以下错误处理选项:

  • 遇到错误时立即返回

  • 重试五次,并在尝试之间休眠一秒钟

现在,考虑这两种策略,考虑一个通常需要大约两秒来处理的请求。在一个简单的性能测试中,对于这个请求的大量错误将会增加性能,当使用第一种策略时(因为请求将不会被处理并立即返回),但当使用第二种策略时会降低性能(请求将在失败之前重试五秒)。

微服务架构通常利用异步处理、队列和其他机制,这可能会使系统的实际性能测试变得具有挑战性。此外,涉及大量的网络调用,这可能是不稳定的。

此外,性能不仅仅是响应时间的问题。它可能包括 CPU 和内存利用率、外部 API 调用次数、对网络存储的访问等等。性能也与可用性和成本密切相关。在复杂的云原生分布式系统中,性能测试通常可以指导架构决策。

正如您所看到的,端到端测试是一个相当复杂的问题,必须非常谨慎地考虑,因为端到端测试的价值和成本都不容忽视。管理端到端测试中最困难的资源之一就是测试数据。

让我们来看看一些管理测试数据的方法,它们的优缺点。

管理测试数据

使用 Kubernetes 相对容易部署大量软件,包括由许多组件组成的软件,如典型的微服务架构。然而,数据变化要少得多。有不同的方法来生成和维护测试数据。不同的测试数据管理策略适用于不同类型的端到端测试。让我们来看看合成数据、手动测试数据和生产快照。

合成数据

合成数据是您以编程方式生成的测试数据。其优缺点如下:

  • 优点

  • 易于控制和更新,因为它是以编程方式生成的

  • 易于创建错误数据以测试错误处理

  • 易于创建大量数据

  • 缺点

  • 您需要编写代码来生成它。

  • 可能与实际数据格式不同步。

手动测试数据

手动测试数据类似于合成数据,但是您需要手动创建它。其优缺点如下:

  • 优点

  • 拥有终极控制权,包括验证输出应该是什么

  • 可以基于示例数据,并进行轻微调整。

  • 快速启动(无需编写和维护代码)

  • 无需过滤或去匿名化

  • 缺点

  • 繁琐且容易出错

  • 难以生成大量测试数据

  • 难以在多个微服务之间生成相关数据

  • 必须在数据格式更改时手动更新

生产快照

生产快照实际上是记录真实数据并将其用于填充测试系统。其优缺点如下:

  • 优点

  • 与真实数据高度一致

  • 重新收集确保测试数据始终与生产数据同步

  • 缺点

  • 需要过滤和去匿名化敏感数据

  • 数据可能不支持所有测试场景(例如,错误处理)

  • 可能难以收集所有相关数据

总结

在本章中,我们涵盖了测试及其各种类型:单元测试,集成测试和各种端到端测试。我们还深入探讨了 Delinkcious 测试的结构。我们探索了链接管理器的单元测试,添加了一个新的冒烟测试,并介绍了 Telepresence,以加速对真实 Kubernetes 集群进行编辑-测试-调试生命周期,同时在本地修改代码。

话虽如此,测试是一个有成本的范围,盲目地添加越来越多的测试并不能使您的系统变得更好或更高质量。在测试数量和质量之间存在许多重要的权衡,例如开发和维护测试所需的时间,运行测试所需的时间和资源,以及测试早期检测到的问题的数量和复杂性。您应该有足够的上下文来为您的系统做出艰难的决策,并选择最适合您的测试策略。

同样重要的是要记住,随着系统的发展,测试也在不断演变,即使是同一组织,测试的水平在风险更高时通常也必须提高。如果您是一个业余开发人员,发布了一个 Beta 产品,有一些用户只是在家里玩玩,您可能在测试上不那么严格(除非它可以节省开发时间)。然而,随着您的公司的发展和吸引更多将您的产品用于关键任务的用户,代码中出现问题的影响可能需要更严格的测试。

在下一章中,我们将探讨 Delinkcious 的各种部署用例和情况。Kubernetes 及其生态系统提供了许多有趣的选项和工具。我们将考虑到生产环境的强大部署以及快速的面向开发人员的场景。

进一步阅读

您可以参考以下参考资料,了解本章涵盖的更多信息:

第十一章:部署微服务

在本章中,我们将处理两个相关但分开的主题:生产部署和开发部署。这两个领域使用的关注点、流程和工具都非常不同。在两种情况下,目标都是将新软件部署到集群中,但其他一切都不同。对于生产部署,保持系统稳定、能够获得可预测的构建和部署体验,最重要的是识别并能够回滚错误的部署是可取的。对于开发部署,希望为每个开发人员拥有隔离的部署,快速周转,并且能够避免在源代码控制或持续集成 / 持续部署CI/CD)系统(包括镜像注册表)中堆积临时开发版本。因此,分歧的重点有利于将生产部署与开发部署隔离开来。

在本章中,我们将涵盖以下主题:

  • Kubernetes 部署

  • 部署到多个环境

  • 了解部署策略(滚动更新、蓝绿部署、金丝雀部署)

  • 回滚部署

  • 管理版本和升级

  • 本地开发部署

技术要求

在本章中,我们将安装许多工具,包括以下内容:

  • KO

  • Ksync

  • Draft

  • Skaffold

  • Tilt

无需提前安装它们。

代码

代码分为两个 Git 存储库:

Kubernetes 部署

我们在《第一章》中简要讨论了部署,开发人员的 Kubernetes 简介,并且我们在几乎每一章中都使用了 Kubernetes 部署。然而,在深入研究更复杂的模式和策略之前,回顾基本构建块以及 Kubernetes 部署、Kubernetes 服务和扩展或自动扩展之间的关系将是有用的。

部署是通过 ReplicaSet 管理 pod 的 Kubernetes 资源。Kubernetes ReplicaSet 是一组由一组共同的标签标识并具有一定数量副本的 pod。ReplicaSet 与其 pod 之间的连接是 pod 元数据中的 ownerReferences 字段。ReplicaSet 控制器确保始终运行正确数量的副本。如果某个 pod 因任何原因死亡,ReplicaSet 控制器将安排一个新的 pod 来替代它。以下图示了这种关系:

部署和 ReplicaSet

我们还可以使用 kubectl 在元数据中观察所有权链。首先,让我们获取社交图管理器 pod 的名称,并从 ownerReferences 元数据中找到其 ReplicaSet 所有者的名称:

$ kubectl get po -l svc=social-graph,app=manager
NAME READY STATUS RESTARTS AGE
social-graph-manager-7d84ffc5f7-bst7w 1/1 Running 53 20d

 $ kubectl get po social-graph-manager-7d84ffc5f7-bst7w -o jsonpath="{.metadata.ownerReferences[0]['name']}"
 social-graph-manager-7d84ffc5f7

 $ kubectl get po social-graph-manager-7d84ffc5f7-bst7w -o jsonpath="{.metadata.ownerReferences[0]['kind']}"
 ReplicaSet

接下来,我们将获取拥有 ReplicaSet 的部署的名称:

$ kubectl get rs social-graph-manager-7d84ffc5f7 -o jsonpath="{.metadata.ownerReferences[0]['name']}"
 graph-manager

 $ kubectl get rs social-graph-manager-7d84ffc5f7 -o jsonpath="{.metadata.ownerReferences[0]['kind']}"
 Deployment

因此,如果 ReplicaSet 控制器负责管理 pod 的数量,那么 Deployment 对象添加了什么呢?Deployment 对象封装了部署的概念,包括部署策略和部署历史。它还提供了面向部署的操作,如更新部署和回滚部署,我们稍后会看到。

部署到多个环境

在本节中,我们将在新的 staging 命名空间中为 Delinkcious 创建一个 staging 环境。staging 命名空间将是默认命名空间的一个完整副本,将作为我们的生产环境。

首先,让我们创建命名空间:

$ kubectl create ns staging
namespace/staging created

然后,在 Argo CD 中,我们可以创建一个名为 staging 的新项目:

Argo CD staging 项目

现在,我们需要配置所有服务,以便 Argo CD 可以将它们同步到 staging 环境。现在在 UI 中执行这项工作可能有点繁琐,因为我们有大量的服务。相反,我们将使用 Argo CD CLI 和一个名为 bootstrap_staging.py 的 Python 3 程序来自动化这个过程。该程序需要以下内容:

  • 已创建 staging 命名空间。

  • Argo CD CLI 已安装并在路径中。

  • Argo CD 服务可以通过本地主机的端口 8080 访问。

  • Argo CD 管理员密码配置为环境变量。

要在本地主机上的端口 80 上暴露 Argo CD,我们可以运行以下命令:

kubectl port-forward -n argocd svc/argocd-server 8080:443

让我们来分解程序并了解它是如何工作的。这是一个很好的基础,您可以通过自动化 CLI 工具来开发自己的定制 CI/CD 解决方案。唯一的依赖是 Python 的标准库模块:subprocess(允许您运行命令行工具)和 os(用于访问环境变量)。在这里,我们只需要运行 Argo CD CLI。

run() 函数隐藏了所有实现细节,并提供了一个方便的接口,您只需要将参数作为字符串传递。run() 函数将准备一个适当的命令列表,可以传递给 subprocess 模块的 check_output() 函数,捕获输出,并将其从字节解码为字符串:

import os
 import subprocess

def run(cmd):
     cmd = ('argocd ' + cmd).split()
     output = subprocess.check_output(cmd)
     return output.decode('utf-8')

login() 函数利用 run(),从环境中获取管理员密码,并构造适当的命令字符串,带有所有必要的标志,以便您可以作为管理员用户登录到 Argo CD:

def login():
     host = 'localhost:8080'
     password = os.environ['ARGOCD_PASSWORD']
     cmd = f'login {host} --insecure --username admin --password {password}'
     output = run(cmd)
     print(output)

get_apps() 函数接受一个命名空间,并返回其中 Argo CD 应用程序的相关字段。这个函数将在 default 命名空间和 staging 命名空间上都被使用。该函数调用 app list 命令,解析输出,并用相关信息填充一个 Python 字典:

def get_apps(namespace):
     """ """
     output = run(f'app list -o wide')
     keys = 'name project namespace path repo'.split()
     apps = []
     lines = output.split('\n')
     headers = [h.lower() for h in lines[0].split()]
     for line in lines[1:]:
         items = line.split()
         app = {k: v for k, v in zip(headers, items) if k in keys}
         if app:
             apps.append(app)
     return apps

create_project() 函数接受创建新的 Argo CD 项目所需的所有信息。请注意,多个 Argo CD 项目可以共存于同一个 Kubernetes 命名空间中。它还允许访问所有集群资源,这对于创建应用程序是必要的。由于我们已经在 Argo CD UI 中创建了项目,所以在这个程序中不需要使用它,但是如果将来需要创建更多项目,保留它是很好的:

def create_project(project, cluster, namespace, description, repo):
     """ """
     cmd = f'proj create {project} --description {description} -d {cluster},{namespace} -s {repo}'
     output = run(cmd)
     print(output)

     # Add access to resources
     cmd = f'proj allow-cluster-resource {project} "*" "*"'
     output = run(cmd)
     print(output)

最后一个通用函数被称为 create_app(),它接受创建 Argo CD 应用程序所需的所有信息。它假设 Argo CD 正在目标集群内运行,因此 --dest-server 始终是 https://kubernetes.default.svc

def create_app(name, project, namespace, repo, path):
     """ """
     cmd = f"""app create {name}-staging --project {project} --dest-server https://kubernetes.default.svc
               --dest-namespace {namespace} --repo {repo} --path {path}"""
     output = run(cmd)
     print(output)

copy_apps_from_default_to_staging() 函数使用了我们之前声明的一些函数。它获取默认命名空间中的所有应用程序,对它们进行迭代,并在暂存项目和命名空间中创建相同的应用程序:

def copy_apps_from_default_to_staging():
     apps = get_apps('default')

     for a in apps:
         create_app(a['name'], 'staging', 'staging', a['repo'], a['path'])

最后,这是 main 函数:

def main():
     login()
     copy_apps_from_default_to_staging()

     apps = get_apps('staging')
     for a in apps:
         print(a)

 if __name__ == '__main__':
     main()

现在我们有了两个环境,让我们考虑一些工作流程和推广策略。每当有变更被推送时,GitHub CircleCI 将会检测到它。如果所有的测试都通过了,它将为每个服务烘烤一个新的镜像并将其推送到 Docker Hub。问题是,在部署方面应该发生什么?Argo CD 有同步策略,我们可以配置它们在 Docker Hub 上有新镜像时自动同步/部署。例如,一个常见的做法是自动部署到 staging,只有在 staging 上通过了各种测试(例如 smoke 测试)后才部署到 production。从 staging 到 production 的推广可能是自动的或手动的。

没有一种适合所有情况的答案。即使在同一个组织中,不同的部署策略和策略通常也会用于具有不同需求的项目或服务。

让我们来看一些更常见的部署策略以及它们能够实现的用例。

理解部署策略

在 Kubernetes 中部署一个服务的新版本意味着用运行版本 XN 个后台 pod 替换为运行版本 X+1N 个后台 pod。从运行版本 X 的 N 个 pod 到运行版本 X+1 的 N 个 pod 有多种方法。Kubernetes 部署支持两种默认策略:RecreateRollingUpdate(默认策略)。蓝绿部署和金丝雀部署是另外两种流行的策略。在深入研究各种部署策略以及它们的优缺点之前,了解在 Kubernetes 中更新部署的过程是很重要的。

只有当部署规范的 pod 模板发生变化时,才会出现部署的新一组 pod。这通常发生在您更改 pod 模板的镜像版本或容器的标签集时。请注意,扩展部署(增加或减少其副本数量)是更新,因此不会使用部署策略。任何新添加的 pod 中将使用与当前运行的 pod 相同版本的镜像。

重新创建部署

一个微不足道但天真的做法是终止所有运行版本 X 的 pod,然后创建一个新的部署,其中 pod 模板规范中的镜像版本设置为 X+1。这种方法有一些问题:

  • 在新的 pod 上线之前,服务将不可用。

  • 如果新版本有问题,服务将在过程被逆转之前不可用(忽略错误和数据损坏)。

Recreate部署策略适用于开发,或者当您希望有短暂的中断,但确保没有同时存在的版本混合。短暂的中断可能是可以接受的,例如,如果服务从队列中获取其工作,并且在升级到新版本时服务短暂下线没有不良后果。另一种情况是,如果您希望以不向后兼容的方式更改服务或其依赖项的公共 API。在这种情况下,当前的 pod 必须一次性终止,并且必须部署新的 pod。对于不兼容更改的多阶段部署有解决方案,但在某些情况下,更容易和可接受的方法是直接切断联系,并支付短暂中断的成本。

要启用这种策略,编辑部署的清单,将策略类型更改为Recreate,并删除rollingUpdate部分(只有在类型为RollingUpdate时才允许这样做):

$ kubectl edit deployment user-manager
 deployment.extensions/user-manager edited

 $ kubectl get deployment user-manager -o yaml | grep strategy -A 1
 strategy:
 type: Recreate

对于大多数服务,希望在升级时保持服务连续性和零停机时间,并在检测到问题时立即回滚。RollingUpdate策略解决了这些情况。

滚动更新

默认的部署策略是RollingUpdate

$ kubectl get deployment social-graph-manager -o yaml | grep strategy -A 4
 strategy:
 rollingUpdate:
 maxSurge: 25%
 maxUnavailable: 25%
 type: RollingUpdate

滚动更新的工作方式如下:pod 的总数(旧的和新的)将是当前副本数加上最大浪涌。部署控制器将开始用新的 pod 替换旧的 pod,确保不超过限制。最大浪涌可以是绝对数,比如 4,也可以是百分比,比如 25%。例如,如果部署的副本数是 4,最大浪涌是 25%,那么可以添加一个额外的新 pod,并终止一个旧的 pod。maxUnavailable是在部署期间低于副本数的 pod 的数量。

以下图示说明了滚动更新的工作方式:

滚动更新

滚动更新在新版本与当前版本兼容时是有意义的。准备处理请求的活动 pod 的数量保持在您使用maxSurgemaxUnavailable指定的副本数的合理范围内,并且逐渐地,所有当前的 pod 都被新的 pod 替换。整体服务不会中断。

然而,有时您必须立即替换所有的 pod,对于必须保持可用性的关键服务,Recreate策略是行不通的。这就是蓝绿部署的用武之地。

蓝绿部署

蓝绿部署是一个众所周知的模式。其思想是不更新现有部署;相反,您创建一个带有新版本的全新部署。最初,您的新版本不提供服务流量。然后,当您验证新部署已经运行良好(甚至可以对其运行一些smoke测试),您一次性将所有流量从当前版本切换到新版本。如果在切换到新版本后遇到任何问题,您可以立即将所有流量切换回先前的部署,该部署仍在运行。当您确信新部署运行良好时,您可以销毁先前的部署。

蓝绿部署的最大优势之一是它们不必在单个 Kubernetes 部署的级别上运行。在微服务架构中,您必须同时更新多个交互服务时,这一点至关重要。如果您尝试同时更新多个 Kubernetes 部署,可能会出现一些服务已经被替换,而另一些没有(即使您接受Recreate策略的成本)。如果在部署过程中出现单个服务的问题,现在您必须回滚所有其他服务。通过蓝绿部署,您可以避免这些问题,并完全控制何时要一次性切换到所有服务的新版本。

如何从蓝色(当前)切换到绿色(新)?与 Kubernetes 兼容的传统方法是在负载均衡器级别进行操作。大多数需要如此复杂部署策略的系统都会有负载均衡器。当您使用负载均衡器切换流量时,您的绿色部署包括绿色 Kubernetes 部署和绿色 Kubernetes 服务,以及任何其他资源(如果需要更改),如密码和配置映射。如果需要更新多个服务,那么您将拥有一组相互引用的绿色资源。

如果您有像 contour 这样的 Ingress 控制器,那么它通常可以用于在需要时将流量从蓝色切换到绿色,然后再切换回来。

以下图表说明了蓝绿部署的工作原理:

蓝绿部署

让我们为 link manager 服务进行单一服务的蓝绿部署。我们将起点称为blue,并且我们希望在没有中断的情况下部署green版本的 link manager。这是计划:

  1. 向当前的link-manager部署添加deployment: blue标签。

  2. 更新link-manager服务选择器以匹配deployment: blue标签。

  3. 实现LinkManager的新版本,该版本使用[green]字符串作为每个链接描述的前缀。

  4. 向部署的 pod 模板规范添加deployment: green标签。

  5. 提升版本号。

  6. 让 CircleCI 创建一个新版本。

  7. 将新版本部署为名为green-link-manager的单独部署。

  8. 更新link-manager服务选择器以匹配deployment: green标签。

  9. 验证服务返回的链接描述,并包括[green]前缀。

这可能听起来很复杂,但就像许多 CI/CD 流程一样,一旦你建立了一个模式,你就可以自动化并构建围绕它的工具。这样你就可以在没有人类参与的情况下执行复杂的工作流程,或者在重要的关键点(例如,在真正部署到生产环境之前)注入人工审查和批准。让我们详细介绍一下步骤。

添加部署-蓝色标签

我们可以编辑部署,并手动添加deployment: blue,除了现有的svc: linkapp: manager标签:

$ kubectl edit deployment link-manager 
deployment.extensions/link-manager edited

这将触发 pod 的重新部署,因为我们改变了标签。让我们验证新的 pod 是否有deployment: blue标签。这里有一个相当花哨的kubectl命令,使用自定义列来显示所有匹配svc=linkapp=manager的 pod 的名称、部署标签和 IP 地址。

如你所见,所有三个 pod 都有deployment:blue标签,正如预期的那样:

$ kubectl get po -l svc=link,app=manager
 -o custom columns="NAME:.metadata.name,DEPLOYMENT:.metadata.labels.deployment,IP:.status.podIP" 
NAME                           DEPLOYMENT IP 
link-manager-65d4998d47-chxpj  blue       172.17.0.37 
link-manager-65d4998d47-jwt7x  blue       172.17.0.36 
link-manager-65d4998d47-rlfhb  blue       172.17.0.35

我们甚至可以验证 IP 地址是否与link-manager服务的端点匹配:

$ kubectl get ep link-manager
 NAME ENDPOINTS AGE
 link-manager 172.17.0.35:8080,172.17.0.36:8080,172.17.0.37:8080 21d

现在 pod 都带有blue标签,我们需要更新服务。

更新 link-manager 服务以仅匹配蓝色 pod

服务,你可能还记得,匹配任何带有svc: linkapp: manager标签的 pod:

$ kubectl get svc link-manager -o custom-columns=SELECTOR:.spec.selector
SELECTOR 
map[app:manager svc:link]

通过添加deployment: blue标签,我们没有干扰匹配。然而,为了准备我们的绿色部署,我们应该确保服务只匹配当前蓝色部署的 pod。

让我们将deployment: blue标签添加到服务的selector中:

selector: app: manager svc: link deployment: blue

我们可以通过使用以下命令来验证它是否起作用:

$ kubectl get svc link-manager -o custom-columns=SELECTOR:.spec.selector 
SELECTOR 
map[app:manager deployment:blue svc:link]

在切换到绿色版本之前,让我们在代码中做出更改,以清楚表明这是一个不同的版本。

在每个链接的描述前加上[绿色]

让我们在链接服务的传输层中做这个。

目标文件是github.com/the-gigi/delinkcious/blob/master/svc/link_service/service/transport.go#L26

更改非常小。在newLink()函数中,我们将描述前缀为[green]字符串:

func newLink(source om.Link) link { 
return link{ 
Url: source.Url, 
Title: source.Title, 
Description: "[green] " + source.Description, 
Status: source.Status, 
Tags: source.Tags, 
CreatedAt: source.CreatedAt.Format(time.RFC3339), 
UpdatedAt: source.UpdatedAt.Format(time.RFC3339), } }

为了部署我们的新绿色版本,我们需要创建一个新的镜像。这需要提升 Delinkcious 版本号。

提升版本号

Delinkcious 版本在[build.sh]文件中维护(github.com/the-gigi/delinkcious/blob/master/build.sh#L6),CircleCI 从中调用,即[.circleci/config.yml]文件(github.com/the-gigi/delinkcious/blob/master/.circleci/config.yml#L28)。

STABLE_TAG变量控制版本号。当前版本是0.3。让我们将其提升到0.4

#!/bin/bash
set -eo pipefail
IMAGE_PREFIX='g1g1' STABLE_TAG='0.4'
TAG="{CIRCLE_BUILD_NUM}" ...

好的。我们提升了版本号,现在可以让 CircleCI 构建一个新的镜像。

让 CircleCI 构建新镜像

由于 GitOps 和我们的 CircleCI 自动化,这一步只涉及将我们的更改推送到 GitHub。CircleCI 检测到更改,构建新代码,创建新的 Docker 镜像,并将其推送到 Docker Hub 注册表。就是这样:

Docker Hub 链接服务 0.4

现在新镜像已经构建并推送到 Docker Hub 注册表,我们可以将其部署到集群作为绿色部署。

部署新的(绿色)版本

好的 - 我们在 Docker Hub 上有了我们的新的delinkcious-link:0.4镜像。让我们将其部署到集群中。请记住,我们希望将其与我们当前的(蓝色)部署一起部署,即link-manager。让我们创建一个名为green-link-manager的新部署。它与我们的蓝色部署的区别如下:

  • 名称是green-link-manager

  • Pod 模板规范具有deployment: green标签。

  • 镜像版本是0.4

apiVersion: apps/v1
kind: Deployment
metadata:
 name: green-link-manager
 labels:
 svc: link
 app: manager
 deployment: green
spec:
 replicas: 3
 selector:
 matchLabels:
 svc: link
 app: manager
 deployment: green
 template:
 metadata:
 labels:
 svc: link
 app: manager
 deployment: green
 spec:
 serviceAccount: link-manager
 containers:
 - name: link-manager
 image: g1g1/delinkcious-link:0.4
 imagePullPolicy: Always
 ports:
 - containerPort: 8080
 envFrom:
 - configMapRef:
 name: link-manager-config
 volumeMounts:
 - name: mutual-auth
 mountPath: /etc/delinkcious
 readOnly: true
 volumes:
 - name: mutual-auth
 secret:
 secretName: link-mutual-auth

现在,是时候部署了:

$ kubectl apply -f green_link_manager.yaml
deployment.apps/green-link-manager created

在我们更新服务以使用绿色部署之前,让我们先审查一下集群。正如您所看到的,我们有蓝色和绿色部署并行运行:

$ kubectl get po -l svc=link,app=manager -o custom-columns="NAME:.metadata.name,DEPLOYMENT:.metadata.labels.deployment"
NAME                                  DEPLOYMENT
green-link-manager-5874c6cd4f-2ldfn   green
green-link-manager-5874c6cd4f-mvm5v   green
green-link-manager-5874c6cd4f-vcj9s   green
link-manager-65d4998d47-chxpj         blue
link-manager-65d4998d47-jwt7x         blue
link-manager-65d4998d47-rlfhb         blue

更新链接管理器服务以使用绿色部署

首先,让我们确保服务仍在使用蓝色部署。当我们得到一个链接描述时,不应该有任何[green]前缀:

$ http "${DELINKCIOUS_URL}/v1.0/links" "Access-Token: ${DELINKCIOUS_TOKEN}"'
HTTP/1.0 200 OK
Content-Length: 214
Content-Type: application/json
Date: Tue, 30 Apr 2019 06:02:03 GMT
Server: Werkzeug/0.14.1 Python/3.7.2

{
 "err": "",
 "links": [
 {
 "CreatedAt": "2019-04-30T06:01:47Z",
 "Description": "nothing to see here...",
 "Status": "invalid",
 "Tags": null,
 "Title": "gg",
 "UpdatedAt": "2019-04-30T06:01:47Z",
 "Url": "http://gg.com"
 }
 ]
}

描述是没有什么可看的..*.*。这一次,我们将使用kubectl patch命令应用补丁,而不是交互式地使用kubectl edit编辑服务,以应用一个将部署标签从blue切换到green的补丁。这是补丁文件-green-patch.yaml

 spec:
   selector:
     deployment: green

让我们应用补丁:

$ kubectl patch service/link-manager --patch "$(cat green-patch.yaml)"
 service/link-manager patched 

最后一步是验证服务现在是否使用绿色部署。

验证服务现在是否使用绿色 pod 来处理请求。

让我们有条不紊地进行,从服务中的选择器开始:

$ kubectl get svc link-manager -o jsonpath="{.spec.selector.deployment}"
 **green** 

好的-选择器是绿色的。让我们再次获取链接,看看[green]前缀是否出现:

$ http "${DELINKCIOUS_URL}/v1.0/links" "Access-Token: ${DELINKCIOUS_TOKEN}"'

 HTTP/1.0 200 OK
 Content-Length: 221
 Content-Type: application/json
 Date: Tue, 30 Apr 2019 06:19:43 GMT
 Server: Werkzeug/0.14.1 Python/3.7.2

 {
 "err": "",
 "links": [
 {
 "CreatedAt": "2019-04-30T06:01:47Z",
 "Description": "[green] nothing to see here...",
 "Status": "invalid",
 "Tags": null,
 "Title": "gg",
 "UpdatedAt": "2019-04-30T06:01:47Z",
 "Url": "http://gg.com"
 }
 ]
 }

是的!描述现在是[green] 这里没有什么可看的...

现在,我们可以摆脱蓝色部署,我们的服务将继续针对绿色部署运行:

$ kubectl delete deployment link-manager
 deployment.extensions "link-manager" deleted

 $ kubectl get po -l svc=link,app=manager
 NAME                                  READY   STATUS    RESTARTS   AGE
 green-link-manager-5874c6cd4f-2ldfn   1/1     Running   5          1h
 green-link-manager-5874c6cd4f-mvm5v   1/1     Running   5          1h
 green-link-manager-5874c6cd4f-vcj9s   1/1     Running   5          1h

我们已成功在 Delinkcious 上执行了蓝绿部署。让我们讨论最后一个模式,即金丝雀部署。

金丝雀部署

金丝雀部署是另一种复杂的部署模式。考虑一个拥有大量用户的大规模分布式系统的情况。您想要引入服务的新版本。您已经尽力测试了这个变化,但是生产系统太复杂,无法在测试环境中完全模拟。因此,您无法确定新版本不会引起一些问题。你该怎么办?您可以使用金丝雀部署。其想法是,某些变化必须在生产环境中进行测试,然后您才能相对确定它们能够按预期工作。金丝雀部署模式允许您限制新版本可能引起的损害,如果出现问题。

Kubernetes 上的基本金丝雀部署通过在大多数 pod 上运行当前版本,只在少数 pod 上运行新版本来工作。大多数请求将由当前版本处理,只有一小部分请求将由新版本处理。

这假设了一个轮询负载均衡算法(默认),或者任何其他分配请求更或多或少均匀地跨所有 pod 的算法。

以下图表说明了金丝雀部署的外观:

金丝雀部署

请注意,金丝雀部署要求您的当前版本和新版本可以共存。例如,如果您的更改涉及模式更改,则您的当前版本和新版本是不兼容的,天真的金丝雀部署将无法工作。

基本金丝雀部署的好处在于它使用现有的 Kubernetes 对象,并且可以由外部的操作员进行配置。无需自定义代码或将其他组件安装到您的集群中。但是,基本金丝雀部署有一些限制:

  • 粒度为 K/N(最坏情况是 N = 1 的单例)。

  • 无法控制对同一服务的不同请求的不同百分比(例如,仅读请求的金丝雀部署)。

  • 无法控制同一用户对同一版本的所有请求。

在某些情况下,这些限制太严重,需要另一种解决方案。复杂的金丝雀部署通常利用应用程序级别的知识。这可以通过 Ingress 对象、服务网格或专用的应用程序级别流量整形器来实现。我们将在第十三章中看一个例子,服务网格 - 与 Istio 一起使用

是时候进行 link 服务的实际金丝雀部署了。

为 Delinkcious 使用基本金丝雀部署

创建金丝雀部署与蓝绿部署非常相似。我们的link-manager服务当前正在运行绿色部署。这意味着它具有deployment: green的选择器。金丝雀是黄色的,所以我们将创建一个新版本的代码,该代码在链接描述前加上[yellow]。让我们的目标是将 10%的请求发送到新版本。为了实现这一点,我们将将当前版本扩展到九个副本,并添加一个具有新版本的部署。这就是金丝雀的技巧 - 我们将从服务选择器中删除部署标签。这意味着它将选择两个 pod;即deployment: greendeployment: yellow。我们也可以从部署中删除标签(因为没有人是基于此标签进行选择),但最好将它们保留为元数据,以及以防我们想要进行另一个蓝绿部署。

以下是计划:

  1. 构建代码的新版本。

  2. 创建一个新版本的副本计数为 1 的部署,标记为deployment: yellow

  3. 将当前的绿色部署扩展到九个副本。

  4. 更新服务以选择svc: linkapp: manager(忽略deployment: <color>)。

  5. 对服务运行多个查询,并验证由金丝雀部署提供服务的请求比例为 10%。

代码更改是trivial: [green] -> [yellow]

func newLink(source om.Link) link {
     return link{
         Url:         source.Url,
         Title:       source.Title,
         Description: "[green] " + source.Description,
         Status:      source.Status,
         Tags:        source.Tags,
         CreatedAt:   source.CreatedAt.Format(time.RFC3339),
         UpdatedAt:   source.UpdatedAt.Format(time.RFC3339),
     }
 }

然后,我们需要将build.sh中的版本从0.4升级到0.5

#!/bin/bash

 set -eo pipefail

 IMAGE_PREFIX='g1g1'
 STABLE_TAG='0.4'

 TAG="${STABLE_TAG}.${CIRCLE_BUILD_NUM}" ...

一旦我们将这些更改推送到 GitHub,CircleCI 将构建并推送一个新的镜像到DockerHub: g1g1/delinkcious-link:0.5

在这一点上,我们可以创建一个新的0.5版本的部署,一个单独的副本,并更新标签。让我们称之为yellow_link_manager.yaml

--- apiVersion: apps/v1
 kind: Deployment
 metadata:
   name: yellow-link-manager
   labels:
     svc: link
     app: manager
     deployment: yellow
 spec:
   replicas: 1
   selector:
     matchLabels:
       svc: link
       app: manager
       deployment: yellow
   template:
     metadata:
       labels:
         svc: link
         app: manager
         deployment: yellow
     spec:
       serviceAccount: link-manager
       containers:
       - name: link-manager
         image: g1g1/delinkcious-link:0.5
         imagePullPolicy: Always
         ports:
         - containerPort: 8080
         envFrom:
         - configMapRef:
             name: link-manager-config
         volumeMounts:
         - name: mutual-auth
           mountPath: /etc/delinkcious
           readOnly: true
       volumes:
       - name: mutual-auth
         secret:
           secretName: link-mutual-auth

下一步是部署我们的金丝雀:

$ kubectl apply -f yellow_link_manager.yaml
 deployment.apps/yellow-link-manager created 

在更改服务以包含金丝雀部署之前,让我们将绿色部署扩展到 9 个副本,以便在激活金丝雀后它可以接收 90%的流量:

$ kubectl scale --replicas=9 deployment/green-link-manager
 deployment.extensions/green-link-manager scaled

 $ kubectl get po -l svc=link,app=manager
 NAME                                  READY   STATUS    RESTARTS   AGE
 green-link-manager-5874c6cd4f-2ldfn    1/1    Running   10         15h
 green-link-manager-5874c6cd4f-9csxz    1/1    Running   0          52s
 green-link-manager-5874c6cd4f-c5rqn    1/1    Running   0          52s
 green-link-manager-5874c6cd4f-mvm5v    1/1    Running   10         15h
 green-link-manager-5874c6cd4f-qn4zj    1/1    Running   0          52s
 green-link-manager-5874c6cd4f-r2jxf    1/1    Running   0          52s
 green-link-manager-5874c6cd4f-rtwsj    1/1    Running   0          52s
 green-link-manager-5874c6cd4f-sw27r    1/1    Running   0          52s
 green-link-manager-5874c6cd4f-vcj9s    1/1    Running   10         15h
 yellow-link-manager-67847d6b85-n97b5   1/1    Running   4        6m20s

好了,我们有九个绿色的 pod 和一个黄色(金丝雀)的 pod 在运行。让我们更新服务,只基于svc: linkapp: manager标签进行选择,这将包括所有十个 pod。我们需要删除deployment: green标签。

我们之前使用的 YAML 补丁文件方法在这里不起作用,因为它只能添加或更新标签。这次我们将使用 JSON 补丁,使用remove操作,并指定选择器中deployment键的路径。

请注意,在打补丁之前,选择器中有deployment: green,而在打补丁之后,只剩下svc: linkapp: manager

$ kubectl get svc link-manager -o custom-columns=NAME:.metadata.name,SELECTOR:.spec.selector
 NAME           SELECTOR
 link-manager   map[app:manager deployment:green svc:link]

 $ kubectl patch svc link-manager --type=json -p='[{"op": "remove", "path": "/spec/selector/deployment"}]'
 service/link-manager patched

 $ kubectl get svc link-manager -o custom-columns=NAME:.metadata.name,SELECTOR:.spec.selector
 NAME           SELECTOR
 link-manager   map[app:manager svc:link]

现在开始表演。我们将向 Delinkcious 发送 30 个 GET 请求并检查描述:

$ for i in {1..30}
 > do
 >   http "${DELINKCIOUS_URL}/v1.0/links" "Access-Token: ${DELINKCIOUS_TOKEN}" | jq .links[0].Description
 > done

 "[green] nothing to see here..."
 "[yellow] nothing to see here..."
 "[green] nothing to see here..."
 "[green] nothing to see here..."
 "[green] nothing to see here..."
 "[green] nothing to see here..."
 "[green] nothing to see here..."
 "[green] nothing to see here..."
 "[green] nothing to see here..."
 "[yellow] nothing to see here..."
 "[green] nothing to see here..."
 "[green] nothing to see here..."
 "[green] nothing to see here..."
 "[green] nothing to see here..."
 "[green] nothing to see here..."
 "[yellow] nothing to see here..."
 "[green] nothing to see here..."
 "[yellow] nothing to see here..."
 "[yellow] nothing to see here..."
 "[green] nothing to see here..."
 "[green] nothing to see here..."
 "[green] nothing to see here..."
 "[green] nothing to see here..."
 "[yellow] nothing to see here..."
 "[green] nothing to see here..."
 "[green] nothing to see here..."
 "[green] nothing to see here..."
 "[green] nothing to see here..."
 "[green] nothing to see here..."
 "[green] nothing to see here..."

有趣的是-我们得到了 24 个绿色的响应和 6 个黄色的响应。这比预期的要高得多(平均三个黄色的响应)。我又运行了几次,第二次运行时又得到了六个黄色的响应,第三次运行时只得到了一个黄色的响应。这都是在 Minikube 上运行的,所以负载均衡可能有点特殊。让我们宣布胜利。

使用金丝雀部署进行 A/B 测试

金丝雀部署也可以用于支持 A/B 测试。只要我们有足够的 pod 来处理负载,我们可以部署任意多个版本。每个版本都可以包含特殊代码来记录相关数据,然后您可以获得见解并将用户行为与特定版本相关联。这是可能的,但您可能需要构建大量的工具和约定使其可用。如果 A/B 测试是设计工作流程的重要部分,我建议选择一个已建立的 A/B 测试解决方案。在我看来,重复发明 A/B 测试轮子是不值得的。

让我们考虑当出现问题时该怎么办,以便尽快恢复到正常状态。

回滚部署

在部署后出现问题时,最佳做法是回滚更改,恢复到已知可用的上一个版本。您进行此操作的方式取决于您采用的部署模式。让我们逐一考虑它们。

回滚标准的 Kubernetes 部署

Kubernetes 部署保留历史记录。例如,如果我们编辑用户管理器部署并将图像版本设置为0.5,那么现在我们可以看到有两个修订版:

$ kubectl get po -l svc=user,app=manager -o jsonpath="{.items[0].spec.containers[0].image}"
 g1g1/delinkcious-user:0.5

 $ kubectl rollout history deployment user-manager
 deployment.extensions/user-manager
 REVISION  CHANGE-CAUSE
 1         <none>
 2         <none>

CHANGE-CAUSE列默认情况下不记录。让我们再次进行更改,将版本更改为 0.4,但使用--record=true标志:

$ kubectl edit deployment user-manager --record=true
 deployment.extensions/user-manager edited

 $ kubectl rollout history deployment user-manager
 deployment.extensions/user-manager
 REVISION  CHANGE-CAUSE
 1         <none>
 2         <none>
 3         kubectl edit deployment user-manager --record=true

好的。让我们回滚到原始的 0.3 版本。那将是修订版 1。我们也可以使用rollout history命令查看特定修订版的情况:

$ kubectl rollout history deployment user-manager --revision=1
 deployment.extensions/user-manager with revision #1
 Pod Template:
 Labels:    app=manager
 pod-template-hash=6fb9878576
 svc=user
 Containers:
 user-manager:
 Image:    g1g1/delinkcious-user:0.3
 Port:    7070/TCP
 Host Port:    0/TCP
 Limits:
 cpu:    250m
 memory:    64Mi
 Requests:
 cpu:    250m
 memory:    64Mi
 Environment Variables from:
 user-manager-config    ConfigMap    Optional: false
 Environment:    <none>
 Mounts:    <none>
 Volumes:    <none>

如您所见,修订版 1 具有版本 0.3。回滚的命令如下:

$ kubectl rollout undo deployment user-manager --to-revision=1
 deployment.extensions/user-manager rolled back

 $ kubectl get deployment user-manager -o jsonpath="{.spec.template.spec.containers[0].image}"
 g1g1/delinkcious-user:0.3

回滚将使用滚动更新的相同机制,逐渐替换 pod,直到所有正在运行的 pod 都具有正确的版本。

回滚蓝绿部署

Kubernetes 不直接支持蓝绿部署。从绿色切换回蓝色(假设蓝色部署的 pod 仍在运行)非常简单。您只需更改Service选择器,选择deployment: blue而不是deployment: green。从蓝色立即切换到绿色,反之亦然,是蓝绿部署模式的主要动机,因此这么简单也就不足为奇了。一旦切换回蓝色,您可以删除绿色部署并找出问题所在。

回滚金丝雀部署

金丝雀部署可能更容易回滚。大多数 pod 运行经过验证的旧版本。金丝雀部署的 pod 仅提供少量请求。如果检测到金丝雀部署出现问题,只需删除部署。您的主要部署将继续提供传入请求。如果必要(例如,您的金丝雀部署提供了少量但重要的流量),您可以扩展主要部署以弥补不再存在的金丝雀 pod。

在模式、API 或有效负载更改后处理回滚

您选择的部署策略通常取决于新版本引入的更改的性质。例如,如果您的更改涉及破坏性的数据库模式更改,比如将 A 表拆分为 B 和 C 表,那么您不能简单地部署新版本来读取/写入 B 和 C。数据库需要先进行迁移。然而,如果遇到问题并希望回滚到先前的版本,那么您将在相反的方向上遇到相同的问题。您的旧版本将尝试从 A 表读取/写入,而 A 表已经不存在了。如果更改配置文件或某些网络协议的有效负载格式,也可能出现相同的问题。如果您不协调 API 更改,可能会破坏客户端。

解决这些兼容性问题的方法是在多个部署中执行这些更改,其中每个部署与上一个部署完全兼容。这需要一些规划和工作。让我们考虑将 A 表拆分为 B 表和 C 表的情况。假设我们处于版本 1.0,并最终希望最终使用版本 2.0。

我们的第一个更改将标记为版本 1.1。它将执行以下操作:

  • 创建 B 和 C 表(但保留 A 表)。

  • 更改代码以写入 B 和 C。

  • 更改代码以从 A、B 和 C 读取并合并结果(旧数据来自 A,而新数据来自 B 和 C)。

  • 如果需要删除数据,只需标记为已删除。

我们部署版本 1.1,如果发现有问题,我们将回滚到版本 1.0。我们所有的旧数据仍然在 A 表中,而版本 1.0 与之完全兼容。我们可能已经丢失或损坏了 B 表和 C 表中的少量数据,但这是我们之前没有充分测试的代价。版本 1.1 可能是一个金丝雀部署,因此只丢失了少量请求。

然后,我们发现问题,修复它们,并部署版本 1.2,这与版本 1.1 写入 B 和 C 的方式相同,但是从 A、B 和 C 读取,并且不删除 A 中的数据。

我们观察一段时间,直到我们确信版本 1.2 按预期工作。

下一步是迁移数据。我们将 A 表中的数据写入 B 表和 C 表。活动部署版本 1.2 继续从 B 和 C 读取,并且仅合并 A 中缺失的数据。在完成所有代码更改之前,我们仍然保留 A 中的所有旧数据。

此时,所有数据都在 B 表和 C 表中。我们部署版本 1.3,它忽略 A 表,并完全针对 B 表和 C 表工作。

我们再次观察,如果遇到 1.3 的任何问题,可以回到版本 1.2,并发布版本 1.4、1.5 等。但是,在某个时候,我们的代码将按预期工作,然后我们可以将最终版本重命名/重新标记为 2.0,或者只是剪切一个除版本号外完全相同的新版本。

最后一步是删除 A 表。

这可能是一个缓慢的过程,每当部署新版本时都需要运行大量测试,但在进行可能损坏数据的危险更改时是必要的。

当然,您将在开始之前备份数据,但是对于高吞吐量系统,即使在糟糕的升级期间短暂的中断也可能非常昂贵。

底线是包含模式更改的更新是复杂的。管理的方法是执行多阶段升级,其中每个阶段与上一个阶段兼容。只有在证明当前阶段正常工作时才能前进。单个微服务拥有每个数据存储的原则的好处是,至少 DB 模式更改受限于单个服务,并且不需要跨多个服务协调。

版本和依赖管理

管理版本是一个棘手的话题。在基于微服务的架构中,您的微服务可能具有许多依赖项,以及许多内部和外部客户端。有几类版本化资源,它们都需要不同的管理策略和版本化方案。

管理公共 API

公共 API 是在集群外部使用的网络 API,通常由大量用户和/或开发人员使用,他们可能与您的组织有或没有正式关系。公共 API 可能需要身份验证,但有时可能是匿名的。公共 API 的版本控制方案通常只涉及主要版本,例如 V1、V2 等。Kubernetes API 就是这种版本控制方案的一个很好的例子,尽管它还有 API 组的概念,并使用 alpha 和 beta 修饰符,因为它面向开发人员。

Delinkcious 到目前为止使用了<major>.<minor>版本控制方案的单一公共 API:

 api = Api(app)
     resource_map = (
         (Link, '/v1.0/links'),
         (Followers, '/v1.0/followers'),
         (Following, '/v1.0/following'),
     )

这有点过度了,只有一个主要版本就足够了。让我们来改变它(当然还有所有受影响的测试):

     api = Api(app)
     resource_map = (
         (Link, '/v1/links'),
         (Followers, '/v1/followers'),
         (Following, '/v1/following'),
     )

请注意,即使在编写本书的过程中我们进行了重大更改,我们仍然保持相同的版本。这没问题,因为目前还没有外部用户,所以我们有权更改我们的公共 API。然而,一旦我们正式发布我们的应用程序,如果我们进行重大更改而不更改 API 版本,我们就有义务考虑用户的负担。这是一个相当糟糕的反模式。

管理跨服务依赖

跨服务依赖通常被定义和记录为内部 API。然而,对实现和/或合同的微小更改可能会显着影响其他服务。例如,如果我们更改object_model/types.go中的结构,可能需要修改大量代码。在经过充分测试的单体库中,这不是一个问题,因为进行更改的开发人员可以确保所有相关的消费者和测试都已更新。许多系统由多个存储库构建,可能很难识别所有的消费者。在这些情况下,重大更改可能会保留,并在部署后被发现。

Delinkcious 是一个单体库,实际上在其端点的 URL 中根本没有使用任何版本控制方案。这是社交图管理器的 API:

     r := mux.NewRouter()
     r.Methods("POST").Path("/follow").Handler(followHandler)
     r.Methods("POST").Path("/unfollow").Handler(unfollowHandler)
     r.Methods("GET").Path("/following/{username}").Handler(getFollowingHandler)
     r.Methods("GET").Path("/followers/{username}").Handler(getFollowersHandler)

如果您永远不打算运行同一服务的多个版本,这种方法是可以接受的。在大型系统中,这不是一种可扩展的方法。总会有一些消费者不愿意立即升级到最新和最好的版本。

管理第三方依赖

第三方依赖有三种不同的版本:

  • 您构建软件的库和软件包(如第二章中讨论的使用微服务入门

  • 通过 API 由您的代码访问的第三方服务

  • 您用于操作和运行系统的服务

例如,如果您在云中运行系统,那么您的云提供商就是一个巨大的依赖(Kubernetes 可以帮助减轻风险)。另一个很好的例子是将第三方服务用作 CI/CD 解决方案。

选择第三方依赖时,您会放弃一些(或很多)控制权。您应该始终考虑如果第三方依赖突然变得不可用或不可接受会发生什么。这可能有很多原因:

  • 开源项目被放弃或失去动力

  • 第三方提供商关闭

  • 库存在太多安全漏洞

  • 服务存在太多故障

假设您明智地选择了依赖,让我们考虑两种情况:

  • 升级到库的新版本

  • 升级到第三方服务的新 API 版本

每次升级都需要相应升级系统中使用这些依赖的任何组件(库或服务)。通常,这些升级不应该修改任何服务的 API,也不应该修改库的公共接口。它们可能会改变您的服务的运行配置文件(希望是更好的,比如更少的内存,更高的性能)。

升级您的服务很简单。您只需部署依赖新第三方依赖的新版本服务,然后就可以继续进行。对第三方库的更改可能会更加复杂。您需要识别所有依赖于这个第三方库的库。升级您的库,然后识别使用任何(现在升级过的)库的每个服务,并升级这些服务。

强烈建议为您的库和软件包使用语义化版本控制。

管理基础设施和工具链

您的基础架构和工具链也必须小心管理,甚至进行版本控制。在大型系统中,您的 CI/CD 流水线通常会调用各种脚本来自动化重要任务,比如迁移数据库、预处理数据和配置云资源。这些内部工具可能会发生重大变化。容器化系统中另一个重要的类别是基础镜像的版本。代码基础设施方法结合 GitOps,主张将系统的这些方面进行版本控制并存储在源代码控制系统(Git)中。

到目前为止,我们已经涵盖了很多关于真实部署的黑暗角落和困难用例,以及如何安全可靠地演进和升级大型系统。让我们回到个别开发人员。对于需要在集群中进行快速编辑-测试-调试循环的开发人员,有一套非常不同的要求和关注点。

本地开发部署

开发人员希望快速迭代。当我对某些代码进行更改时,我希望尽快运行测试,如果有问题,尽快修复。我们已经看到这在单元测试中运行得很好。然而,当系统使用微服务架构打包为容器并部署到 Kubernetes 集群时,这是不够的。为了真正评估变更的影响,我们经常需要构建一个镜像(其中可能包括更新 Kubernetes 清单,如部署、秘钥和配置映射)并将其部署到集群中。在 Minikube 上本地开发非常棒,但即使部署到本地 Minikube 集群也需要时间和精力。在第十章中,测试微服务,我们使用 Telepresence 进行了交互式调试,效果很好。然而,Telepresence 有其自己的怪癖和缺点,并不总是最适合的工具。在接下来的小节中,我们将介绍几种其他替代方案,在某些情况下可能是更好的选择。

Ko

Ko(github.com/google/ko)是一个非常有趣的 Go 特定工具。它的目标是简化和隐藏构建图像的过程。其想法是,在 Kubernetes 部署中,您将图像路径从注册表替换为 Go 导入路径。Ko 将读取此导入路径,为您构建 Docker 图像,将其发布到注册表(如果使用 Minikube,则为本地),并将其部署到您的集群中。Ko 提供了指定基本图像和在生成的图像中包含静态数据的方法。

让我们试一试,稍后讨论体验。

您可以通过标准的go get命令安装 Ko:

go get github.com/google/ko/cmd/ko

Ko 要求您在GOPATH中工作。出于各种原因,我通常不在GOPATH中工作(Delinkcious 使用不需要GOPATH的 Go 模块)。为了适应 Ko,我使用了以下代码:

 $ export GOPATH=~/go
 $ mkdir -p ~/go/src/github.com/the-gigi
 $ cd ~/go/src/github.com/the-gigi
 $ ln -s ~/git/delinkcious delinkcious
 $ cd delinkcious
 $ go get -d ./...

在这里,我复制了 Go 在GOPATH下期望的目录结构,包括在 GitHub 上复制到 Delinkcious 的路径。然后,我使用go get -d ./...递归地获取了 Delinkcious 的所有依赖项。

最后的准备步骤是为本地开发设置 Ko。当 Ko 构建图像时,我们不应将其推送到 Docker Hub 或任何远程注册表。我们希望快速本地循环。Ko 允许您以各种方式执行此操作。其中最简单的方法之一如下:

export KO_DOCKER_REPO=ko.local

其他方法包括配置文件或在运行 Ko 时传递-L标志。

现在,我们可以继续使用 Ko。这是ko-link-manager.yaml文件,其中将图像替换为链接管理器服务的 Go 导入路径(github.com/the-gigi/delinkcious/svc/link_service)。请注意,我将imagePullPolicyAlways更改为IfNotPresent

Always策略是安全且已准备就绪的策略,但在本地工作时,它将忽略本地的 Ko 镜像,而是从 Docker Hub 拉取:

---
 apiVersion: apps/v1
 kind: Deployment
 metadata:
   name: ko-link-manager
   labels:
     svc: link
     app: manager
 spec:
   replicas: 1
   selector:
     matchLabels:
       svc: link
       app: manager
   template:
     metadata:
       labels:
         svc: link
         app: manager
     spec:
       serviceAccount: link-manager
       containers:
       - name: link-manager
         image: "github.com/the-gigi/delinkcious/svc/link_service"
         imagePullPolicy: IfNotPresent
         ports:
         - containerPort: 8080
         envFrom:
         - configMapRef:
             name: link-manager-config
         volumeMounts:
         - name: mutual-auth
           mountPath: /etc/delinkcious
           readOnly: true
       volumes:
       - name: mutual-auth
         secret:
           secretName: link-mutual-auth

下一步是在修改后的部署清单上运行 Ko:

$ ko apply -f ko_link_manager.yaml
 2019/05/01 14:29:31 Building github.com/the-gigi/delinkcious/svc/link_service
 2019/05/01 14:29:34 Using base gcr.io/distroless/static:latest for github.com/the-gigi/delinkcious/svc/link_service
 2019/05/01 14:29:34 No matching credentials were found, falling back on anonymous
 2019/05/01 14:29:36 Loading ko.local/link_service-1819ff5de960487aed3f9074cd43cc03:1c862ed08cf571c6a82a3e4a1eb2d79dbe122fc4901e73f88b51f0731d4cd565
 2019/05/01 14:29:38 Loaded ko.local/link_service-1819ff5de960487aed3f9074cd43cc03:1c862ed08cf571c6a82a3e4a1eb2d79dbe122fc4901e73f88b51f0731d4cd565
 2019/05/01 14:29:38 Adding tag latest
 2019/05/01 14:29:38 Added tag latest
 deployment.apps/ko-link-manager configured

为了测试部署,让我们运行我们的smoke测试:

 $ go run smoke.go
 2019/05/01 14:35:59 ======= Links =======
 2019/05/01 14:35:59 ===== Add Link ======
 2019/05/01 14:35:59 Adding new link - title: 'Gigi on Github', url: 'https://github.com/the-gigi'
 2019/05/01 14:36:00 ======= Links =======
 2019/05/01 14:36:00 title: 'Gigi on Github', url: 'https://github.com/the-gigi', status: 'pending', description: '[yellow] '
 2019/05/01 14:36:04 ======= Links =======
 2019/05/01 14:36:04 title: 'Gigi on Github', url: 'https://github.com/the-gigi', status: 'valid', description: '[yellow] '

一切看起来都很好。链接描述包含我们金丝雀部署工作的[yellow]前缀。让我们将其更改为[ko],看看 Ko 可以重新部署有多快:

func newLink(source om.Link) link {
     return link{
         Url:         source.Url,
         Title:       source.Title,
         Description: "[ko] " + source.Description,
         Status:      source.Status,
         Tags:        source.Tags,
         CreatedAt:   source.CreatedAt.Format(time.RFC3339),
         UpdatedAt:   source.UpdatedAt.Format(time.RFC3339),
     }
 }

在修改后的代码上再次运行 Ko 只需 19 秒,一直到在集群中部署。这令人印象深刻:

$ ko apply -f ko_link_manager.yaml
 2019/05/01 14:39:37 Building github.com/the-gigi/delinkcious/svc/link_service
 2019/05/01 14:39:52 Using base gcr.io/distroless/static:latest for github.com/the-gigi/delinkcious/svc/link_service
 2019/05/01 14:39:52 No matching credentials were found, falling back on anonymous
 2019/05/01 14:39:54 Loading ko.local/link_service-1819ff5de960487aed3f9074cd43cc03:1af7800585ca70a390da7e68e6eef506513e0f5d08cabc05a51c453e366ededf
 2019/05/01 14:39:56 Loaded ko.local/link_service-1819ff5de960487aed3f9074cd43cc03:1af7800585ca70a390da7e68e6eef506513e0f5d08cabc05a51c453e366ededf
 2019/05/01 14:39:56 Adding tag latest
 2019/05/01 14:39:56 Added tag latest
 deployment.apps/ko-link-manager configured

smoke测试有效,并且描述现在包含[ko]前缀而不是[yellow],这证明 Ko 按照广告中的方式工作,并且确实快速构建了一个 Docker 容器并将其部署到了集群中:

$ go run smoke.go
 2019/05/01 22:12:10 ======= Links =======
 2019/05/01 22:12:10 ===== Add Link ======
 2019/05/01 22:12:10 Adding new link - title: 'Gigi on Github', url: 'https://github.com/the-gigi'
 2019/05/01 22:12:10 ======= Links =======
 2019/05/01 22:12:10 title: 'Gigi on Github', url: 'https://github.com/the-gigi', status: 'pending', description: '[ko] '
 2019/05/01 22:12:14 ======= Links =======
 2019/05/01 22:12:14 title: 'Gigi on Github', url: 'https://github.com/the-gigi', status: 'valid', description: '[ko] '

让我们来看看 Ko 构建的镜像。为了做到这一点,我们将ssh进入 Minikube 节点并检查 Docker 镜像:

$ mk ssh
 _             _
 _         _ ( )           ( )
 ___ ___  (_)  ___  (_)| |/')  _   _ | |_      __
 /' _ ` _ `\| |/' _ `\| || , <  ( ) ( )| '_`\  /'__`\
 | ( ) ( ) || || ( ) || || |\`\ | (_) || |_) )(  ___/
 (_) (_) (_)(_)(_) (_)(_)(_) (_)`\___/'(_,__/'`\____)

 $ docker images | grep ko
 ko.local/link_service-1819ff5de960487aed3f9074cd43cc03           1af7800585ca70a390da7e68e6eef506513e0f5d08cabc05a51c453e366ededf   9188384722a5        49 years ago        14.1MB
 ko.local/link_service-1819ff5de960487aed3f9074cd43cc03           latest                                                             9188384722a5        49 years ago        14.1MB

镜像似乎由于某种原因具有 Unix 纪元(1970 年)开始的创建日期。除此之外,一切看起来都很好。请注意,该镜像比我们正常的链接管理器要大,因为 Ko 默认使用gcr.io/distroless/base:latest作为基础镜像,而 Delinkcious 使用 SCRATCH 镜像。如果你愿意,你可以使用.ko.yaml配置文件覆盖基础镜像。

简而言之,Ko 易于安装、配置,并且运行非常好。不过,我觉得它太过受限:

  • 这是一个仅适用于 Go 的工具。

  • 你必须将你的代码放在GOPATH中,并使用标准的 Go 目录结构(在 Go 1.11+模块中已过时)。

  • 你必须修改你的清单(或者使用 Go 导入路径创建一个副本)。

在将新的 Go 服务集成到 CI/CD 系统之前,测试它可能是一个不错的选择。

Ksync

Ksync 是一个非常有趣的工具。它根本不构建镜像。它直接在集群中的运行容器内部同步本地目录和远程目录中的文件。这是非常简化的操作,特别是如果你同步到本地的 Minikube 集群。然而,这种便利是有代价的。Ksync 特别适用于使用动态语言(如 Python 和 Node)实现的服务,可以在同步更改时进行热重载应用程序。如果你的应用程序不支持热重载,Ksync 可以在每次更改后重新启动容器。让我们开始工作吧:

  1. 安装 Ksync 非常简单,但记得在将其直接传输到bash之前检查你要安装的内容!
curl https://vapor-ware.github.io/gimme-that/gimme.sh | bash

如果你愿意,你可以使用go命令来安装它:

go get github.com/vapor-ware/ksync/cmd/ksync
  1. 我们还需要启动 Ksync 的集群端组件,它将在每个节点上创建一个 DaemonSet 来监听更改并将其反映到运行的容器中:
ksync init
  1. 现在,我们可以告诉 Ksync 监听更改。这是一个阻塞操作,Ksync 将永远监听。我们可以在一个单独的终端或选项卡中运行它:
ksync watch
  1. 设置的最后一部分是在目标 pod 或多个 pod 上的本地目录和远程目录之间建立映射。通常情况下,我们通过标签选择器来识别 pod。唯一使用动态语言的 Delinkcious 服务是 API 网关,所以我们将在这里使用它:
cd svc/api_gateway_service ksync create --selector=svc=api-gateway $PWD /api_gateway_service
  1. 我们可以通过修改 API 网关来测试 Ksync 是否有效。让我们在get()方法中添加一个 Ksync 消息:
def get(self):
     """Get all links
     """
     username, email = _get_user()
     parser = RequestParser()
     parser.add_argument('url_regex', type=str, required=False)
     parser.add_argument('title_regex', type=str, required=False)
     parser.add_argument('description_regex', type=str, required=False)
     parser.add_argument('tag', type=str, required=False)
     parser.add_argument('start_token', type=str, required=False)
     args = parser.parse_args()
     args.update(username=username)
     r = requests.get(self.base_url, params=args)

     if not r.ok:
         abort(r.status_code, message=r.content)

     result = r.json()
     result.update(ksync='Yeah, it works!')
     return result
  1. 几秒钟后,我们将看到来自 Ksync 的是的,它有效了!消息。这是一个巨大的成功:
$ http "${DELINKCIOUS_URL}/v1/links" "Access-Token: ${DELINKCIOUS_TOKEN}"'
HTTP/1.0 200 OK Content-Length: 249 Content-Type: application/json Date: Thu, 02 May 2019 17:17:07 GMT Server: Werkzeug/0.14.1 Python/3.7.2
{ "err": "", "ksync": "Yeah, it works!", "links": [ { "CreatedAt": "2019-05-02T05:12:10Z", "Description": "[ko] ", "Status": "valid", "Tags": null, "Title": "Gigi on Github", "UpdatedAt": "2019-05-02T05:12:10Z", "Url": "https://github.com/the-gigi" } ] }

总之,Ksync 非常简洁和快速。我真的很喜欢它不会烘烤图像,将它们推送到注册表,然后部署到集群的事实。如果您的所有工作负载都使用动态语言,那么使用 Ksync 是一个明智的选择。

Draft

Draft 是微软的另一个工具(最初来自 Deis),它可以快速构建图像而无需 Dockerfile。它使用各种语言的标准构建包。看起来似乎您无法提供自己的基础图像。这有两个问题:

  • 您的服务可能不仅仅是代码,可能还依赖于您在 Dockerfile 中设置的东西。

  • Draft 使用的基础图像相当大。

Draft 依赖于 Helm,因此您必须在集群上安装 Helm。安装非常灵活,支持许多方法。

您可以确信 Draft 在 Windows 上运行良好,不像云原生领域中许多其他工具,其中 Windows 是一个二等公民。这种心态开始改变,因为微软、Azure 和 AKS 是 Kubernetes 生态系统的重要贡献者。好的,让我们来试用一下 Draft:

  1. 在 macOS 上安装draft(假设您已经安装了 Helm)就像做以下操作一样简单:
brew install azure/draft/draft
  1. 让我们配置 Draft,将其图像直接推送到 Minikube(与 Ko 相同):
$ draft init
$ draft init Installing default plugins... Installation of default plugins complete Installing default pack repositories... Installation of default pack repositories complete $DRAFT_HOME has been configured at /Users/gigi.sayfan/.draft. Happy Sailing!
$ eval $(minikube docker-env)

像往常一样,让我们在描述中添加一个前缀[draft]

func newLink(source om.Link) link { return link{ Url: source.Url, Title: source.Title, Description: "[draft]" + source.Description, Status: source.Status, Tags: source.Tags, CreatedAt: source.CreatedAt.Format(time.RFC3339), UpdatedAt: source.UpdatedAt.Format(time.RFC3339), } }
  1. 接下来,我们让 draft 通过调用draft create命令准备,并使用--app选择 Helm 发布名称:
$ draft create --app draft-link-manager --> Draft detected Go (67.381270%) --> Ready to sail
  1. 最后,我们可以部署到集群:
$ draft up
Draft Up Started: 'draft-link-manager': 01D9XZD650WS93T46YE4QJ3V70 draft-link-manager: Building Docker Image: SUCCESS (9.0060s) draft-link-manager: Pushing Docker Image

不幸的是,draft 在推送 Docker 镜像阶段挂起了。过去它对我有用,所以也许是最新版本的一个新问题。

总的来说,draft 相当简单,但太有限了。它创建的大图像和无法提供自己的基础图像是致命缺陷。文档也非常稀少。我建议只在您使用 Windows 且其他工具不够好时使用它。

Skaffold

Skaffold(skaffold.dev/)是一个非常完整的解决方案。它非常灵活,支持本地开发和与 CI/CD 集成,并且有出色的文档。以下是 Skaffold 的一些特点:

  • 检测代码更改,构建图像,推送和部署。

  • 可以直接将源文件同步到 pod 中(就像 Ksync 一样)。

  • 它有一个复杂的概念模型,包括构建器、测试器、部署器、标签策略和推送策略。

  • 您可以自定义每个方面。

  • 通过从头到尾运行 Skaffold 来集成您的 CI/CD 流水线,或者使用特定阶段作为构建模块。

  • 通过配置文件、用户级别配置、环境变量或命令行标志进行每个环境的配置。

  • 这是一个客户端工具-无需在集群中安装任何东西。

  • 自动将容器端口转发到本地机器。

  • 聚合部署的 pod 的日志。

这是一个说明 Skaffold 工作流程的图表:

Skaffold

让我们安装 Skaffold 并试用一下:

$ brew install skaffold

接下来,让我们在link_service目录中创建一个配置文件。Skaffold 将询问我们关于不同元素使用哪个 Dockerfile 的一些问题,例如数据库和服务本身:

$ skaffold init ? Choose the dockerfile to build image postgres:11.1-alpine None (image not built from these sources) ? Choose the dockerfile to build image g1g1/delinkcious-link:0.6 Dockerfile WARN[0014] unused dockerfiles found in repository: [Dockerfile.dev] apiVersion: skaffold/v1beta9 kind: Config build: artifacts: - image: g1g1/delinkcious-link:0.6 deploy: kubectl: manifests: - k8s/configmap.yaml - k8s/db.yaml - k8s/link_manager.yaml - k8s/secrets.yaml
Do you want to write this configuration to skaffold.yaml? [y/n]: y Configuration skaffold.yaml was written You can now run [skaffold build] to build the artifacts or [skaffold run] to build and deploy or [skaffold dev] to enter development mode, with auto-redeploy.

让我们尝试使用 Skaffold 构建一个图像:

$ skaffold build Generating tags... - g1g1/delinkcious-link:0.6 -> g1g1/delinkcious-link:0.6:v0.6-79-g6b178c6-dirty Tags generated in 2.005247255s Starting build... Found [minikube] context, using local docker daemon. Building [g1g1/delinkcious-link:0.6]... Sending build context to Docker daemon 10.75kB Complete in 4.717424985s FATA[0004] build failed: building [g1g1/delinkcious-link:0.6]: build artifact: docker build: Error response from daemon: invalid reference format

哦,不好-它失败了。我进行了一些搜索,发现了一个未解决的问题:

https://github.com/GoogleContainerTools/skaffold/issues/1749 

Skaffold 是一个大型解决方案。它不仅仅是本地开发。它也有一个相当复杂的学习曲线(例如,同步文件需要手动设置每个目录和文件类型)。如果您喜欢它的模型,并且在 CI/CD 解决方案中使用它,那么在本地开发中使用它也是有意义的。一定要试试看,并自行决定。如果您有类似 Delinkcious 的混合系统,它可以构建图像以及直接同步文件,这是一个很大的优势。

Tilt

最后,但绝对不是最不重要的,是 Tilt。Tilt 是我迄今为止最喜欢的开发工具。Tilt 也非常全面和灵活。它以一种称为 Starlark 的语言编写的 Tiltfile 为中心(github.com/bazelbuild/starlark/),这是 Python 的一个子集。我立刻就着迷了。Tilt 的特别之处在于,它不仅仅是自动构建图像并将其部署到集群或同步文件。它实际上为您提供了一个完整的实时开发环境,提供了大量信息,突出显示事件和错误,并让您深入了解集群中正在发生的事情。让我们开始吧。

让我们安装 Tilt 然后开始做生意:

brew tap windmilleng/tap brew install windmilleng/tap/tilt

我为链接服务编写了一个非常通用的 Tiltfile。

# Get all the YAML files
script = """python -c 'from glob import glob; print(",".join(glob("k8s/*.yaml")))'""" yaml_files = str(local(script))[:-1] yaml_files = yaml_files.split(',') for f in yaml_files: k8s_yaml(f)

# Get the service name
script = """import os; print('-'.join(os.getcwd().split("/")[-1].split("_")[:-1])""" name = str(local(script))[:-1]
docker_build('g1g1/delinkcious-' + name, '.', dockerfile='Dockerfile.dev')

让我们分解并分析一下。首先,我们需要 k8s 子目录下的所有 YAML 文件。我们可以直接编码它们,但这样做有什么乐趣呢?此外,不同的服务将有不同的 YAML 文件列表。Skylark 类似于 Python,但不能使用 Python 库。例如,glob 库非常适合使用通配符枚举文件。以下是列出k8s子目录中所有带有.yaml后缀的文件的 Python 代码:

Python 3.7.3 (default, Mar 27 2019, 09:23:15) [Clang 10.0.1 (clang-1001.0.46.3)] on darwin Type "help", "copyright", "credits" or "license" for more information. >>> from glob import glob >>> glob("k8s/*.yaml") ['k8s/db.yaml', 'k8s/secrets.yaml', 'k8s/link_manager.yaml', 'k8s/configmap.yaml']

我们无法直接在 Starlark 中执行这个操作,但我们可以使用local()函数,它允许我们运行任何命令并捕获输出。因此,我们可以通过 Tilt 的local()函数执行先前的 Python 代码,通过运行 Python 解释器并通过 Tilt 的local()函数执行一个小脚本来实现:

script = """python -c 'from glob import glob; print(",".join(glob("k8s/*.yaml")))'""" yaml_files = str(local(script))[:-1]

这里有一些额外的细节。首先,我们将从 glob 返回的文件列表转换为逗号分隔的字符串。但是,local()函数返回一个名为 Blob 的 Tilt 对象。我们只需要一个普通字符串,所以我们通过用str()函数包装local()调用来将 blob 转换为字符串。最后,我们移除最后一个字符(最后的[:-1]),这是一个换行符(因为我们使用了 Python 的print()函数)。

最终结果是,在yaml_files变量中,我们有一个字符串,它是所有 YAML 清单的逗号分隔列表。

接下来,我们将这个逗号分隔的字符串拆分回 Python/Starlark 文件名列表:

yaml_files = yaml_files.split(',')

对于这些文件中的每一个,我们调用 Tilt 的k8s_yaml()函数。这个函数告诉 Tilt 监视这些文件的更改:

for f in yaml_files: k8s_yaml(f)

接下来,我们重复之前的技巧,并执行一个 Python 一行代码,从当前目录名称中提取服务名称。所有 Delinkcious 服务目录都遵循相同的命名约定,即<service name>_service。这个一行代码将当前目录拆分,丢弃最后一个组件(始终为service),并通过-作为分隔符将组件连接起来。

现在,我们需要获取服务名称:

script = """import os; print('-'.join(os.getcwd().split("/")[-1].split("_")[:-1]),""" name = str(local(script))[:-1]

现在我们有了服务名称,最后一步是通过调用 Tilt 的docker_build()函数来构建镜像。请记住,Delinkcious 使用的 Docker 镜像命名约定是g1g1/delinkcious-<service name>。我这里还使用了一个特殊的Dockerfile.dev,它与生产环境的 Dockerfile 不同,更方便调试和故障排除。如果您没有指定 Docker 文件,那么默认为Dockerfile

docker_build('g1g1/delinkcious-' + name, '.', dockerfile='Dockerfile.dev')

这可能看起来非常复杂和费解,但好处是我可以将此文件放在任何服务目录中,它将按原样工作。

对于链接服务,等效的硬编码文件如下:

k8s_yam('k8s/db.yaml') k8s_yam('k8s/secrets.yaml') k8s_yam('k8s/link_manager.yaml') k8s_yam(''k8s/configmap.yaml'')
docker_build('g1g1/delinkcious-link, '.', dockerfile='Dockerfile.dev')

这并不算太糟糕,但是每次添加新的清单时,您都必须记得更新 Tiltfile,并且您需要为每个服务保留一个单独的 Tiltfile。

让我们看看 Tilt 的实际效果。当我们输入tilt up时,我们将看到以下文本 UI:

Tilt

在 Tilt 控制台中,您可以做很多事情,包括检查日志和探索错误。Tilt 不断显示更新和系统状态,并始终尝试呈现最有用的信息。

看到 Tilt 使用自己的标签构建图像很有趣:

$ kubectl get po link-manager-654959fd78-9rnnh -o jsonpath="{.spec.containers[0].image}"
docker.io/g1g1/delinkcious-link:tilt-2b1afed5db0064f2

让我们进行标准更改,看看 Tilt 的反应:

func newLink(source om.Link) link { return link{ Url: source.Url, Title: source.Title, Description: "[tilt] " + source.Description, Status: source.Status, Tags: source.Tags, CreatedAt: source.CreatedAt.Format(time.RFC3339), UpdatedAt: source.UpdatedAt.Format(time.RFC3339), } }

Tilt 检测到更改并构建了一个新的图像,然后迅速部署到集群中:

$ http "${DELINKCIOUS_URL}/v1/links" "Access-Token: ${DELINKCIOUS_TOKEN}" HTTP/1.0 200 OK Content-Length: 221 Content-Type: application/json Date: Sat, 04 May 2019 07:38:32 GMT Server: Werkzeug/0.14.1 Python/3.7.2
{ "err": "", "links": [ { "CreatedAt": "2019-05-04T07:38:28Z", "Description": "[tilt] nothing to see here...", "Status": "pending", "Tags": null, "Title": "gg", "UpdatedAt": "2019-05-04T07:38:28Z", "Url": "http://gg.com" } ] }

让我们尝试一些文件同步。我们必须在调试模式下运行 Flask 才能使热重新加载起作用。只需在 Dockerfile 的ENTRYPOINT中添加FLASK_DEBUG=1即可:

FROM g1g1/delinkcious-python-flask-grpc:0.1 MAINTAINER Gigi Sayfan "the.gigi@gmail.com" COPY . /api_gateway_service WORKDIR /api_gateway_service EXPOSE 5000 ENTRYPOINT FLASK_DEBUG=1 python run.py

您可以决定是否要使用单独的Dockerfile.dev文件与 Tilt 一起使用,就像我们用于链接服务一样。这是一个使用 Tilt 的实时更新功能的 API 网关服务的 Tiltfile:

# Get all the YAML files
yaml_files = str(local("""python -c 'from glob import glob; print(",".join(glob("k8s/*.yaml")))'"""))[:-1] yaml_files = yaml_files.split(',') for f in yaml_files: k8s_yaml(f)
# Get the service name
script = """python -c 'import os; print("-".join(os.getcwd().split("/")[-1].split("_")[:-1]))'""" name = str(local(script))[:-1]
docker_build('g1g1/delinkcious-' + name, '.', live_update=[ # when requirements.txt changes, we need to do a full build fall_back_on('requirements.txt'), # Map the local source code into the container under /api_gateway_service sync('.', '/api_gateway_service'), ])

在这一点上,我们可以运行tilt up并访问/links端点:

$ http "${DELINKCIOUS_URL}/v1/links" "Access-Token: ${DELINKCIOUS_TOKEN}"
HTTP/1.0 200 OK 
Content-Length: 221 
Content-Type: application/json 
Date: Sat, 04 May 2019 20:39:42 GMT 
Server: Werkzeug/0.14.1 Python/3.7.2
{ 
"err": "", 
"links": [ { 
"CreatedAt": "2019-05-04T07:38:28Z", 
"Description": "[tilt] nothing to see here...", 
"Status": "pending", 
"Tags": null, 
"Title": "gg", 
"UpdatedAt": "2019-05-04T07:38:28Z", 
"Url": "http://gg.com" 
} ] 
}

Tilt 将向我们显示请求和成功的200响应:

Tilt API 网关

让我们做一点小改动,看看 tilt 是否能够检测到并同步容器中的代码。在resources.py文件中,让我们在GET links的结果中添加键值对- tilt: Yeah, sync works!!

class Link(Resource): host = os.environ.get('LINK_MANAGER_SERVICE_HOST', 'localhost') port = os.environ.get('LINK_MANAGER_SERVICE_PORT', '8080') base_url = 'http://{}:{}/links'.format(host, port)
def get(self):
     """Get all links
     """
     username, email = _get_user()
     parser = RequestParser()
     parser.add_argument('url_regex', type=str, required=False)
     parser.add_argument('title_regex', type=str, required=False)
     parser.add_argument('description_regex', type=str, required=False)
     parser.add_argument('tag', type=str, required=False)
     parser.add_argument('start_token', type=str, required=False)
     args = parser.parse_args()
     args.update(username=username)
     r = requests.get(self.base_url, params=args)

     if not r.ok:
         abort(r.status_code, message=r.content)
     r['tilt'] = 'Yeah, sync works!!!'
     return r.json()

正如您在以下截图中所看到的,Tilt 检测到了resources.py中的代码更改,并将新文件复制到容器中:

Tilt API 网关 2

让我们再次调用端点并观察结果。它按预期工作。在结果中,我们得到了链接后的预期键值:

$ http "${DELINKCIOUS_URL}/v1/links" "Access-Token:
${DELINKCIOUS_TOKEN}"

HTTP/1.0 200 OK 
Content-Length: 374 
Content-Type: application/json 
Date: Sat, 04 May 2019 21:06:13 GMT 
Server: Werkzeug/0.14.1 Python/3.7.2
{
 "err": "", 
"links": 
[ { 
"CreatedAt": "2019-05-04T07:38:28Z", 
"Description": "[tilt] nothing to see here...", 
"Status": "pending", 
"Tags": null, 
"Title": "gg", "UpdatedAt": 
"2019-05-04T07:38:28Z", 
"Url": "http://gg.com" 
} ], 
"tilt": "Yeah, 
sync works!!!" 
} 

总的来说,Tilt 做得非常好。它基于一个坚实的概念模型,执行得非常好,解决了本地开发的问题,比其他任何工具都要好。Tiltfile 和 Starlark 功能强大而简洁。它支持完整的 Docker 构建和动态语言的文件同步。

总结

在本章中,我们涵盖了与部署到 Kubernetes 相关的广泛主题。我们从深入研究 Kubernetes 部署对象开始,考虑并实施了对多个环境(例如,暂存和生产)的部署。我们深入研究了滚动更新、蓝绿部署和金丝雀部署等高级部署策略,并在 Delinkcious 上对它们进行了实验。然后,我们看了一下如何回滚失败的部署以及管理依赖和版本的关键主题。之后,我们转向本地开发,并调查了多个用于快速迭代的工具,您可以对代码进行更改,它们会自动部署到您的集群。我们涵盖了 Ko、Ksync、Draft、Skaffold 和我个人最喜欢的 Tilt。

在这一点上,您应该对各种部署策略有深入的了解,知道何时在您的系统上使用它们,并且对 Kubernetes 的本地开发工具有丰富的实践经验,可以将其整合到您的工作流程中。

在下一章中,我们将把它提升到下一个级别,并严肃地监控我们的系统。我们将研究故障模式,如何设计自愈系统,自动扩展,配置和性能。然后,我们将考虑日志记录,收集指标和分布式跟踪。

进一步阅读

如果您想了解本章涵盖的更多内容,请参考以下链接:

第十二章:监控、日志记录和指标

在本章中,我们将重点关注在 Kubernetes 上运行大规模分布式系统的操作方面,以及如何设计系统以及要考虑什么以确保一流的操作姿态。也就是说,事情总会出现问题,你必须准备好尽快检测、解决问题并做出响应。Kubernetes 提供的操作最佳实践包括以下内容:

  • 自愈

  • 自动缩放

  • 资源管理

然而,集群管理员和开发人员必须了解这些功能是如何工作的,如何配置和交互,以便正确理解它们。高可用性、健壮性、性能、安全性和成本之间总是需要权衡。重要的是要意识到所有这些因素及其之间的关系会随着时间的推移而发生变化,必须定期重新审视和评估。

这就是监控的作用。监控就是要了解系统的运行情况。有几个信息来源与不同的目的相关:

  • 日志记录:您明确记录应用程序代码中的相关信息(您使用的库也可能会记录)。

  • 指标:收集有关系统的详细信息,如 CPU、内存、磁盘使用情况、磁盘 I/O、网络和自定义应用程序指标。

  • 跟踪:附加 ID 以跟踪请求跨多个微服务。

在本章中,我们将看到 Go-kit、Kubernetes 和生态系统如何实现并支持所有相关的用例。

本章涵盖以下主题:

  • 使用 Kubernetes 进行自愈

  • 自动缩放 Kubernetes 集群

  • 使用 Kubernetes 配置资源

  • 正确的性能

  • 日志记录

  • 在 Kubernetes 上收集指标

  • 警报

  • 分布式跨越

技术要求

在本章中,我们将在集群中安装几个组件:

  • Prometheus:指标和警报解决方案

  • Fluentd:中央日志代理

  • Jaeger:分布式跟踪系统

代码

代码分为两个 Git 存储库:

使用 Kubernetes 进行自愈

自愈是由无数物理和虚拟组件构成的大规模系统的一个非常重要的属性。基于大型 Kubernetes 集群运行的微服务系统就是一个典型的例子。组件可能以多种方式失败。自愈的前提是整个系统不会失败,并且能够自动修复自己,即使这可能会导致系统在暂时的降低容量下运行。

这些可靠系统的基本构建块如下:

  • 冗余

  • 可观测性

  • 自动恢复

基本前提是每个组件都可能失败 - 机器崩溃,磁盘损坏,网络连接中断,配置可能不同步,新软件发布存在错误,第三方服务中断等等。冗余意味着没有单点故障。您可以运行许多组件的多个副本,如节点和 Pod,将数据写入多个数据存储,并在多个数据中心、可用区或地区部署系统。您甚至可以在多个云平台上部署系统(特别是如果您使用 Kubernetes)。当然,冗余是有限的。完全的冗余非常昂贵。例如,在 AWS 和 GKE 上运行完全冗余的系统可能是很少有公司能够负担或甚至需要的奢侈品。

可观测性是检测出问题发生的能力。您必须监视您的系统,并了解您观察到的信号,以便检测异常情况。这是在进行补救和恢复之前的第一步。

自动化的自愈和恢复在理论上是不需要的。您可以有一个团队的操作员整天观察仪表板,并在识别出问题时采取纠正措施。实际上,这种方法是不可扩展的。人类反应、解释和行动都很慢 - 更不用说他们更容易出错。也就是说,大多数自动化解决方案都是从后来成本重复手动干预变得清晰的手动流程开始的。如果某些问题只是偶尔发生,那么可能可以通过手动干预来解决。

让我们讨论几种故障模式,并看看 Kubernetes 如何帮助所有自愈的支柱。

容器故障

Kubernetes 在 pod 内运行容器。如果容器因任何原因而死亡,Kubernetes 将默认立即检测并重新启动它。Kubernetes 的行为可以通过 pod 规范的restartPolicy文件来控制。可能的值为Always(默认值),OnFailureNever。请注意,重启策略适用于 pod 中的所有容器。无法针对每个容器指定重启策略。这似乎有点短视,因为您可能在一个 pod 中有多个需要不同重启策略的容器。

如果一个容器一直失败,它将进入CrashOff状态。让我们通过向 API 网关引入有意的错误来看看这种情况:

import os
 from api_gateway_service.api import app
 def main():
     port = int(os.environ.get('PORT', 5000))
     login_url = 'http://localhost:{}/login'.format(port)
     print('If you run locally, browse to', login_url)
     host = '0.0.0.0'
     app.run(host=host, port=port)

 if __name__ == "__main__":
     raise RuntimeError('Failing on purpose to demonstrate CrashLoopBackOff')
     main()

进行倾斜后,我们可以看到 API 网关进入CrashLoopBackOff状态。这意味着它一直失败,Kubernetes 一直重新启动它。回退部分是重新启动尝试之间的延迟。Kubernetes 使用指数回退延迟,从 10 秒开始,每次加倍,最长延迟为 5 分钟:

崩溃循环回退

这种方法非常有用,因为如果故障是暂时的,那么 Kubernetes 会通过重新启动容器来自我修复,直到暂时问题消失。但是,如果问题持续存在,那么容器状态和错误日志将保留下来,并提供可供高级恢复流程使用的可观察性,或者作为人为操作员或开发人员的最后手段。

节点故障

当节点失败时,节点上的所有 pod 将变得不可用,Kubernetes 将安排它们在集群中的其他节点上运行。假设您设计的系统具有冗余性,并且失败的节点不是单点故障,系统应该会自动恢复。如果集群只有几个节点,那么节点的丢失对集群处理流量的能力可能会有重大影响。

系统性故障

有时会发生系统性故障。其中一些如下:

  • 总网络故障(整个集群无法访问)

  • 数据中心故障

  • 可用性区域故障

  • 区域故障

  • 云服务提供商故障

在这些情况下,你可能没有通过设计实现冗余(成本效益比不经济)。系统将会宕机。用户将会经历中断。重要的是不要丢失或损坏任何数据,并且能够在根本原因得到解决后尽快恢复在线。然而,如果你的组织必须以任何代价保持在线,Kubernetes 将为你提供选项。操作词是,即将来。这项工作是在一个名为联邦 v2 的项目下进行的(v1 由于存在太多问题而被弃用)。

你将能够在不同的数据中心、不同的可用性区域、不同的地区甚至不同的云提供商中启动一个完整的 Kubernetes 集群,甚至一组集群。你将能够将这些物理分布的集群作为一个单一的逻辑集群来运行、管理和处理,并且希望在这些集群之间无缝地进行故障转移。

如果你想要实现这种集群级别的冗余,你可以考虑使用 gardener(gardener.cloud/)项目来构建它。

自动缩放 Kubernetes 集群

自动缩放就是将你的系统调整到需求。这可能意味着向部署添加更多的副本,扩展现有节点的容量,或者添加新的节点。虽然扩展你的集群不是一个失败,但它遵循与自愈相同的模式。你可以将与需求不一致的集群视为不健康。如果集群配置不足,那么请求将无法处理或等待时间太长,这可能导致超时或性能不佳。如果集群配置过多,那么你将为你不需要的资源付费。在这两种情况下,即使 pod 和服务本身正在运行,你也可以将集群视为不健康。

就像自愈一样,你首先需要检测到你需要扩展你的集群,然后你可以采取正确的行动。有几种方法可以扩展集群的容量:你可以添加更多的 pod,你可以添加新的节点,你可以增加现有节点的容量。让我们详细地回顾一下它们。

水平 pod 自动缩放

水平 Pod 自动缩放器是一个控制器,旨在根据 Pod 的负载调整部署中的 Pod 数量。是否应该扩展(增加 Pod)或缩减(删除 Pod)部署的决定基于指标。水平 Pod 自动缩放器默认支持 CPU 利用率,但也可以添加自定义指标。水平自动缩放器的好处在于它位于标准 Kubernetes 部署的顶部,只需调整其副本数量。部署本身和 Pod 都不知道它们正在被缩放:

水平 Pod 自动缩放器

前面的图表说明了水平自动缩放器的工作原理。

使用水平 Pod 自动缩放器

我们可以使用 kubectl 进行自动缩放。由于自动缩放器依赖 Heapster 和度量服务器,我们需要使用minikube addons命令启用它们。我们已经启用了 Heapster,所以这应该足够了:

$ minikube addons enable metrics-server
 metrics-server was successfully enabled

我们还必须在部署的 Pod 规范中指定 CPU 请求:

    resources:
       requests:
         cpu: 100m

资源请求是 Kubernetes 承诺可以为容器提供的资源。这样,水平 Pod 自动缩放器可以确保只有在能够为新 Pod 提供所请求的最低 CPU 时才会启动新的 Pod。

让我们介绍一些代码,这些代码将导致社交图管理器浪费大量的 CPU:

func wasteCPU() {
     fmt.Println("wasteCPU() here!")
     go func() {
         for {
             if rand.Int() % 8000 == 0 {
                 time.Sleep(50 * time.Microsecond)
             }
         }
     }()
 }

在这里,我们根据 50%的 CPU 利用率在 1 到 5 个 Pod 之间调整社交图管理器:

$ kubectl autoscale deployment social-graph-manager --cpu-percent=50 --min=1 --max=5

运行倾斜并部署浪费 CPU 的代码后,CPU 利用率增加,越来越多的 Pod 被创建,最多达到五个。这是 Kubernetes 仪表板的屏幕截图,显示了 CPU、Pod 和水平 Pod 自动缩放器:

Hp 仪表板

让我们来看看水平 Pod 自动缩放器本身:

$ kubectl get hpa
NAME   REFERENCE  TARGETS    MINPODS   MAXPODS   REPLICAS   AGE
social-graph-manager   Deployment/social-graph-manager   138%/50%   1         5         5          12h

如您所见,当前负载为 CPU 利用率的138%,这意味着需要超过一个 CPU 核心,这大于 50%。因此,社交图管理器将继续运行五个 Pod(允许的最大数量)。

水平 Pod 自动缩放器是 Kubernetes 的一个长期存在的通用机制。它仅依赖于内部组件来收集指标。我们在这里演示了默认的基于 CPU 的自动缩放,但它也可以配置为基于多个自定义指标工作。现在是时候看一些其他自动缩放方法了。

集群自动缩放

Pod 自动缩放对开发人员和运维人员来说是一份礼物 - 他们不需要手动调整服务的规模或编写自己的半成品自动缩放脚本。Kubernetes 提供了一个经过良好设计、良好实现和经过实战测试的强大解决方案。然而,这就留下了集群容量的问题。如果 Kubernetes 尝试向集群添加更多的 Pod,但集群已经运行到最大容量,那么 Pod 自动缩放器将失败。另一方面,如果您过度配置集群,以防 Pod 自动缩放器需要添加更多的 Pod,那么您就是在浪费金钱。

进入 auto-scaler 集群 (github.com/kubernetes/autoscaler/tree/master/cluster-autoscaler)。

这是一个自 Kubernetes 1.8 版以来一直可用的 Kubernetes 项目。它适用于 GCP、AWS、Azure、AliCloud 和 BaiduCloud。如果 GKE、EKS 和 AKS 为您提供了托管的控制平面(它们负责管理 Kubernetes 本身),那么集群自动缩放器为您提供了一个托管的数据平面。它将根据您的需求和配置向集群添加或删除节点。

调整集群大小的触发器是当 Kubernetes 由于资源不足而无法调度 Pod 时。这与水平 Pod 自动缩放器非常配合。结合起来,这种组合可以使您拥有一个真正弹性的 Kubernetes 集群,可以根据当前负载自动增长和收缩(在一定范围内)。

集群自动缩放器本质上非常简单。它不关心为什么无法调度 Pod。只要无法调度 Pod,它就会向集群添加节点。它会删除空节点或者它们的 Pod 可以被重新调度到其他节点上的节点。也就是说,它并不是一个完全没有头脑的机制。

它了解几个 Kubernetes 概念,并在决定增长或收缩集群时考虑它们:

  • Pod 中断预算

  • 整体资源约束

  • 亲和性和反亲和性

  • Pod 优先级和抢占

例如,如果无法调度具有最佳努力优先级的 Pod,集群自动缩放器将不会扩展集群。特别是,它不会删除具有以下一个或多个属性的节点:

  • 使用本地存储

  • 带有注释"cluster-autoscaler.kubernetes.io/scale-down-disabled": "true"

  • 主机 Pod 带有注释"cluster-autoscaler.kubernetes.io/safe-to-evict": "false"

  • 带有限制性PodDisruptionBudget的主机节点

添加节点的总时间通常不到 5 分钟。集群自动缩放器每 10 秒扫描一次未调度的 Pod,并在必要时立即提供新节点。但是,云提供商需要 3-4 分钟来提供并将节点附加到集群。

让我们转向另一种形式的自动缩放:垂直 Pod 自动缩放。

垂直 Pod 自动缩放

垂直 Pod 自动缩放器目前(Kubernetes 1.15)处于 Beta 阶段。它承担了与自动缩放相关的不同任务-微调 CPU 和内存请求。考虑一个实际上并不做太多事情并且需要 100 MiB 内存的 Pod,但它当前请求了 500 MiB。首先,这是对分配给 Pod 的 400 MiB 内存的净浪费,而且从来没有被使用。然而,影响可能更大。因为 Pod 更加臃肿,它可能会阻止其他 Pod 在其旁边被调度。

垂直自动缩放器通过监视 Pod 的实际 CPU 和内存使用情况并自动调整其请求来解决这个问题。它还要求您安装度量服务器。

这非常酷。垂直 Pod 自动缩放器以几种模式工作:

  • 初始: 在创建 Pod 时分配资源请求

  • 自动: 在创建 Pod 时分配资源请求,并在 Pod 的生命周期中更新它们

  • 重建: 类似于 Auto,当需要更新资源请求时,Pod 总是重新启动

  • updatedOff: 不修改资源请求,但可以查看建议

目前,Auto 的工作方式与重建相同,并在每次更改时重新启动 Pod。将来,它将使用原地更新。让我们来试试垂直自动缩放器。安装过程相当粗糙,需要克隆 Git 存储库并运行一个 shell 脚本(该脚本运行许多其他 shell 脚本):

$ git clone https://github.com/kubernetes/autoscaler.git
$ cd autoscaler/vertical-pod-autoscaler/hack/ 
$ ./vpa-up.sh

它安装了一个服务、两个 CRD 和三个 Pod:

$ kubectl -n kube-system get svc | grep vpa
vpa-webhook    ClusterIP   10.103.169.18    <none>        443/TCP

$ kubectl -n kube-system get po | grep vpa
vpa-admission-controller-68c748777d-92hbg 1/1  Running   0   72s
vpa-recommender-6fc8c67d85-shh8g          1/1  Running   0   77s
vpa-updater-786b96955c-8mcrc              1/1  Running   0   78s

$ kubectl get crd | grep vertical
verticalpodautoscalercheckpoints.autoscaling.k8s.io  2019-05-08T04:58:24Z
verticalpodautoscalers.autoscaling.k8s.io            2019-05-08T04:58:24Z

让我们为链接管理器部署创建一个 VPA 配置文件。我们将把模式设置为Off,这样它只会建议 CPU 和内存请求的适当值,但不会实际设置它们:

apiVersion: autoscaling.k8s.io/v1beta2
kind: VerticalPodAutoscaler
metadata:
  name: link-manager
spec:
  targetRef:
    apiVersion: "extensions/v1beta1"
    kind:       Deployment
    name:       link-manager
  updatePolicy:
    updateMode: "Off"

我们可以创建并检查建议:

$ kubectl create -f link-manager-vpa.yaml
 verticalpodautoscaler.autoscaling.k8s.io/link-manager created

$ kubectl get vpa link-manager -o jsonpath="{.status.recommendation.containerRecommendations[0].lowerBound}"
 map[cpu:25m memory:262144k]

$ kubectl get vpa link-manager -o jsonpath="{.status.recommendation.containerRecommendations[0].target}"
 map[cpu:25m memory:262144k]

我不建议在这一点上让垂直 Pod 自动缩放器在你的系统上失控。它仍然在变化中,并且有一些严重的限制。最大的限制是它不能与水平 Pod 自动缩放器并行运行。

一个有趣的方法,如果你想利用它来微调你的资源请求,是在一个模拟你的生产集群的测试集群上运行一段时间,关闭水平 Pod 自动缩放器,看看它的表现如何。

使用 Kubernetes 配置资源

传统上,资源的配置一直是操作员或系统管理员的工作。然而,采用 DevOps 方法后,开发人员经常被要求自行配置资源。如果组织有一个传统的 IT 部门,他们通常更关心开发人员应该具有什么权限来配置资源以及他们应该设置什么全局限制。在本节中,我们将从两个角度来看资源配置的问题。

你应该配置什么资源?

重要的是要区分 Kubernetes 资源和它们所依赖的基础设施资源。对于 Kubernetes 资源,Kubernetes API 是一个好方法。你如何与 API 交互取决于你,但我建议你生成 YAML 文件,并在你的 CI/CD 流水线中通过kubectl createkubectl apply来运行它们。

kubectl runkubectl scale这样的命令对于交互式地探索你的集群和运行临时任务是有用的,但它们违背了声明性基础设施即代码的原则。

你也可以直接访问 Kubernetes API 的 REST 端点,或者如果你使用一些高级编程语言如 Python 实现非常复杂的 CI/CD 工作流程,可以使用客户端库。即使在那里,你也可以考虑只调用kubectl

让我们转向您的集群正在运行的基础架构层。主要资源是计算、内存和存储。节点结合了计算、内存和本地存储。共享存储是单独配置的。在云中,您可能会使用预配置的云存储。这意味着您的主要关注点是为您的集群配置节点和外部存储。但这还不是全部。您还需要通过网络层连接所有这些节点,并考虑权限。Kubernetes 集群中的网络大部分时间由 CNI 提供者负责。每个 Pod 都有自己的 IP 地址的著名的扁平网络模型是 Kubernetes 的最佳特性之一,它为开发人员简化了许多事情。

在 Kubernetes 上,权限和访问通常由基于角色的访问控制RBAC)处理,正如我们在第六章中详细讨论的那样,《使用 Kubernetes 保护微服务》。

在我们努力实现自动配置的情况下,对资源施加合理的配额和限制非常重要。

定义容器限制

在 Kubernetes 上,我们可以为每个容器定义 CPU 和内存限制。这确保容器不会使用超过限制的资源。它有两个主要目的:

  • 防止同一节点上的容器和 Pod 相互争夺资源

  • 通过了解 Pod 将使用的最大资源量,帮助 Kubernetes 以最有效的方式调度 Pod

我们在第六章中从安全角度审视了限制,《在 Kubernetes 上保护微服务》。重点是控制爆炸半径。如果容器受到损害,它可能会利用为其配置的资源限制以上的资源。

以下是为user-manager服务设置 CPU 和内存限制的示例。它遵循了将资源限制和资源请求设置为相同值的最佳实践:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: user-manager
  labels:
    svc: user
    app: manager
spec:
  replicas: 1
  selector:
    matchLabels:
      svc: user
      app: manager
  template:
    metadata:
      labels:
        svc: user
        app: manager
    spec:
      containers:
      - name: user-manager
        image: g1g1/delinkcious-user:0.3
        imagePullPolicy: Always
        ports:
        - containerPort: 7070
        resources:
          requests:
            memory: 64Mi
            cpu: 250m
          limits:
            memory: 64Mi
            cpu: 250m

设置容器限制非常有用,但它无法解决许多 Pod 或其他资源的不受控制的分配问题。这就是资源配额发挥作用的地方。

指定资源配额

Kubernetes 允许您针对每个命名空间指定配额。您可以设置不同类型的配额,例如 CPU、内存和各种对象的计数,包括持久卷索赔。让我们为 Delinkcious 的默认命名空间设置一些配额:

apiVersion: v1
kind: List
items:
- apiVersion: v1
  kind: ResourceQuota
  metadata:
    name: awesome-quota
  spec:
    hard:
      cpu: "1000"
      memory: 200Gi
      pods: "100"

以下是应用于quota的命令:

$ kubectl create -f resource-quota.yaml 
resourcequota/awesome-quota created

现在,我们可以检查实际使用的资源配额对象,并将其与配额进行比较,以查看我们的接近程度:

$ kubectl get resourcequota awesome-quota -o yaml | grep status -A 8
status:
 hard:
 cpu: 1k
 memory: 200Gi
 pods: "100"
 used:
 cpu: 350m
 memory: 64Mi
 pods: "10"

显然,这个资源配额远远超出了集群的当前使用情况。这没关系。它不分配或保留任何资源。这只是意味着配额并不是非常限制性的。

资源配额还有许多微妙和选项。有适用于具有特定条件或状态(TerminatingNotTerminatingBestEffortNotBestEffort)的资源的范围。有些资源是特定于某些优先级类别的配额。要点是,您可以变得非常精细,并提供资源配额策略来控制集群中的资源分配,即使在配置错误或攻击的情况下。

到目前为止,我们已经覆盖了资源配额,并可以继续实际配置资源。有几种方法可以做到这一点,对于复杂的系统,我们可能希望使用其中一些,如果不是全部。

手动配置

手动配置听起来像是一种反模式,但在实践中在几种情况下是有用的;例如,如果您正在管理物理上必须配置服务器、将它们连接在一起并安装存储的本地集群。另一个常见的用例是在开发过程中,当您想要开发自己的自动配置,但又需要进行交互式实验(可能不是在生产环境)。然而,即使在生产环境中,如果您发现了一些错误配置或其他问题,您可能需要通过手动配置一些资源来应对危机。

利用自动缩放

在云上,强烈建议使用我们之前讨论过的自动缩放解决方案。水平 Pod 自动缩放器是一个不错的选择。集群自动缩放器也很棒,如果您的集群处理非常动态的工作负载,并且您不想经常过度配置。垂直自动缩放器可能是在这一点上对资源请求进行微调的最佳选择。

自动化配置自己的资源

如果您有更复杂的需求,您总是可以自己动手。Kubernetes 鼓励同时运行自己的控制器,可以监视不同的事件并通过配置一些资源或者在本地运行一些工具来响应,或者作为您的 CI/CD 流水线的一部分,检查集群的状态并做出一些配置决策。

一旦你的集群得到了适当的配置,你应该开始考虑性能问题。性能很有趣,因为你需要考虑很多权衡。

正确理解性能

性能之所以重要,有很多原因,我们很快就会深入探讨。了解何时是尝试提高性能的合适时机非常重要。我的指导原则是:让它工作,让它正确,让它快。也就是说,首先,让系统做任何需要做的事情,无论多么慢和笨拙。然后,清理架构和代码。现在,你准备好提高性能并考虑重构、变化以及其他可能影响性能的因素了。

但是,性能改进有一个初步步骤,那就是性能分析和基准测试。试图在没有衡量你尝试改进的内容的情况下提高性能,就好像试图让你的代码在没有编写任何测试的情况下正确运行一样。这不仅是徒劳的,而且,即使你真的幸运地提高了性能,没有测量,你怎么知道呢?

让我们了解一些关于性能的事情。它使一切变得复杂。然而,它通常是一个必要的恶。当性能影响用户体验或成本时,提高性能就变得重要。更糟糕的是,提高用户体验通常是有成本的。找到平衡点很困难。不幸的是,平衡点不会固定不变。你的系统会发展,用户数量会增长,技术会改变,资源成本也会改变。例如,一个小型社交媒体初创公司没有必要建立自己的数据中心,但像 Facebook 这样的社交媒体巨头现在设计他们自己的定制服务器来挤出更多性能并节省成本。规模变化很大。

底线是,为了做出这些决定,你必须了解你的系统如何运作,并能够衡量每个组件以及对系统性能产生影响的变化。

性能和用户体验

用户体验完全取决于感知性能。我点击按钮后,我在屏幕上看到漂亮的图片需要多快?显然,你可以通过改进系统的实际性能来提高性能,购买更快的硬件,以并行方式运行任务,改进算法,将依赖项升级到更新和更高性能的版本等等。但是,很多时候,更多的是关于更智能的架构,通过添加缓存、提供近似结果和将工作推送到客户端来减少工作量。然后,还有预取的方法,你可以在需要之前尝试做一些工作,以预测用户的需求。

用户体验决策可以显著影响性能。比如考虑一个聊天程序,客户端每秒钟不断地轮询服务器以获取每次按键操作,与每分钟只检查一次新消息的用户体验是不同的,后者的性能成本是前者的 60 倍。

性能和高可用性

系统中最糟糕的例行事务之一就是超时。超时意味着用户无法及时得到答案。超时意味着你做了很多工作,现在都白费了。你可能有重试逻辑,用户最终会得到答案,但性能会受到影响。当你的系统及其所有组件都具有高可用性(并且没有过载)时,你可以最大程度地减少超时的发生。如果你的系统非常冗余,甚至可以将相同的请求多次发送到不同的后端,每当其中一个响应时,你就有了答案。

另一方面,一个高可用和冗余的系统有时需要与所有分片/后端(或至少是多数派)同步,以确保你拥有最新的、最新的答案。当然,在高可用系统上插入或更新数据也更加复杂,通常需要更长的时间。如果冗余跨越多个可用区、地区或大陆,响应时间可能会增加数个数量级。

性能和成本

性能和成本之间有着非常有趣的关系。有许多方法可以提高性能。其中一些方法可以降低成本,比如优化你的代码,压缩你发送的数据,或者将计算推送到客户端。然而,其他提高性能的方法会增加成本,比如在更强大的硬件上运行,将数据复制到靠近客户端的多个位置,并预取未请求的数据。

最终,这是一个商业决策。即使是双赢的性能改进并不总是像改进算法那样优先级高。例如,您可以花费大量时间想出比以前算法快 10 倍的算法。但是计算时间可能在整个处理请求的时间中是微不足道的,因为它被数据库访问、序列化数据和发送给客户端所主导。在这种情况下,您只是浪费了本来可以用来开发更有用的东西的时间,可能使您的代码不稳定并引入了错误,并使您的代码更难理解。再次强调,良好的指标和分析将帮助您确定系统中值得改进性能和成本的热点。

性能和安全

性能和安全通常是相互对立的。安全通常倾向于在整个系统内外推动加密。强大的身份验证和授权方法可能是必要的,但会带来性能开销。然而,安全有时间接地通过倡导削减不必要的功能和减少系统的表面积来帮助性能。这种产生更紧凑系统的简约方法使您能够专注于改善性能的更小目标。通常,安全系统不会随意添加可能在没有仔细考虑的情况下损害性能的任意功能。

之后,我们将探讨如何使用 Kubernetes 收集和使用指标,但首先让我们来看看日志记录,这是监控系统的另一个支柱。

日志记录

日志记录是在系统运行过程中记录消息的能力。日志消息通常是结构化的并带有时间戳。在诊断问题和解决系统故障时,它们通常是不可或缺的。在进行事后分析并发现根本原因时,它们也至关重要。在大规模分布式系统中,将有许多组件记录日志消息。收集、整理和筛选这些消息是一项非常重要的任务。但首先,让我们考虑记录哪些信息是有用的。

您应该记录什么?

这是一个百万美元的问题。一个简单的方法是记录一切。你永远不可能有太多的数据,很难预测在尝试弄清楚系统出了什么问题时你会需要什么数据。然而,一切到底意味着什么?显然你可以走得太远。例如,你可以记录代码中每一个小函数的每一个调用,包括所有的参数,以及当前状态,或者记录每一个网络调用的有效负载。有时,有安全和监管限制会阻止你记录某些数据,比如受保护的健康信息PHI)和个人可识别信息PII)。你需要足够了解你的系统来决定哪种信息对你是相关的。一个很好的起点是记录所有的传入请求以及你的微服务之间的交互,以及你的微服务和第三方服务之间的交互。

日志记录与错误报告

错误是一种特殊的信息。有些错误是你的代码可以处理的(例如,通过重试或使用一些替代方法)。然而,也有一些错误必须尽快处理,否则系统将遭受部分或完全的停机。但即使不紧急的错误有时也需要记录大量的信息。你可以像记录其他信息一样记录错误,但通常值得将错误记录到专用的错误报告服务,比如 Rollbar 或 Sentry。错误的一个关键信息是包含堆栈中每个帧的状态(局部变量)的堆栈跟踪。对于生产系统,我建议你使用专门的错误报告服务,而不仅仅是记录日志。

追求完美的 Go 日志记录接口的探索

Delinkcious 主要是用 Go 实现的,所以让我们来谈谈 Go 中的日志记录。有一个标准库 Logger,它是一个结构体而不是一个接口。它是可配置的,你可以在创建它时传递一个io.Writer对象。然而,Logger结构的方法是固定的,不支持日志级别或结构化日志记录。而且,只有一个输出写入器的事实在某些情况下可能是一个限制。这是标准 Logger 的规范:

type Logger struct { ... } // Not an interface!

func New(out io.Writer, prefix string, flag int) *Logger

// flag controls date, time, µs, UTC, caller

// Log
func (l *Logger) Print(v ...interface{})
func (l *Logger) Printf(format string, v ...interface{})
func (l *Logger) Println(v ...interface{})

// Log and call os.Exit(1)
func (l *Logger) Fatal(v ...interface{})
func (l *Logger) Fatalf(format string, v ...interface{})
func (l *Logger) Fatalln(v ...interface{})

// Log and panic
func (l *Logger) Panic(v ...interface{})
func (l *Logger) Panicf(format string, v ...interface{})
func (l *Logger) Panicln(v ...interface{})

func (l *Logger) Output(calldepth int, s string) error

如果你需要这些功能,你需要使用另一个库,它位于标准库Logger之上。有几个包提供了各种不同的风格:

它们以不同的方式处理接口、可比性和可玩性。然而,我们正在使用 Go-kit,它对日志记录有自己的看法。

使用 Go-kit 记录日志

Go-kit 拥有有史以来最简单的接口。只有一个方法Log(),它接受一个可以是任何类型的键和值列表:

type Logger interface {
 Log(keyvals ...interface{}) error
}

这里的基本思想是,Go-kit 对于如何记录消息没有意见。您总是添加时间戳吗?您有日志级别吗?什么级别?所有这些问题的答案都取决于您。您获得一个完全通用的接口,您决定要记录哪些键值。

使用 Go-kit 设置记录器

好的。接口是通用的,但我们需要一个实际的记录器对象来使用。Go-kit 支持几种写入器和记录器对象,可以生成熟悉的日志格式,如 JSON、logfmt 或 logrus。让我们使用 JSON 格式和同步写入器设置一个记录器。同步写入器可以安全地从多个 Go 例程中使用,JSON 格式化器将键值格式化为 JSON 字符串。此外,我们可以添加一些默认字段,例如服务名称,这是日志消息在源代码中的来源,以及当前时间戳。由于我们可能希望从多个服务中使用相同的记录器规范,让我们将其放在一个所有服务都可以使用的包中。最后一件事是添加一个Fatal()函数,它将转发到标准的log.Fatal()函数。这允许当前使用Fatal()的代码继续工作而无需更改。这是 Delinkcious 日志包,其中包含记录器的工厂函数和Fatal()函数:

package log

import (
  kit_log "github.com/go-kit/kit/log"
  std_log "log"
  "os"
)

func NewLogger(service string) (logger kit_log.Logger) {
  w := kit_log.NewSyncWriter(os.Stderr)
  logger = kit_log.NewJSONLogger(w)
  logger = kit_log.With(logger, "service", service)
  logger = kit_log.With(logger, "timestamp", kit_log.DefaultTimestampUTC)
  logger = kit_log.With(logger, "called from", kit_log.DefaultCaller)

  return
}

func Fatal(v ... interface{}) {
  std_log.Fatal(v...)
}

写入器只是写入标准错误流,这将被捕获并发送到 Kubernetes 容器日志中。

让我们看看我们的记录器是如何工作的,让我们把它附加到我们的链接服务上。

使用日志中间件

让我们考虑一下我们想要实例化记录器的地方,然后在哪里使用它并记录消息。这很重要,因为我们需要确保记录器对代码中需要记录消息的所有地方都是可用的。一个微不足道的方法是只需将记录器参数添加到所有接口中,并以这种方式传播记录器。然而,这样做会非常破坏性,并违反我们清晰的对象模型。记录是一个实现和操作细节。理想情况下,它不应出现在我们的对象模型类型或接口中。此外,它是一个 Go-kit 类型,到目前为止,我们已经成功地使我们的对象模型甚至我们的领域包完全不知道它们被 Go-kit 包装。Delinkcious 服务在 SVC 下是唯一知道 Go-kit 的代码部分。

让我们尝试保持这种状态。Go-kit 提供了中间件概念,允许我们以一种松散耦合的方式链接多个中间件组件。服务的所有中间件组件都实现了服务接口,一个小的包装器允许 Go-kit 依次调用它们。让我们从包装器开始,它只是一个接受LinkManager接口并返回LinkManager接口的函数类型:

type linkManagerMiddleware func(om.LinkManager) om.LinkManager

logging_middleware.go文件有一个名为newLoggingMiddlware()的工厂函数,它接受一个记录器对象并返回一个与linkManagerMiddleware匹配的函数。这个函数又实例化了loggingMiddelware结构,将链中的下一个组件和记录器传递给它:

// implement function to return ServiceMiddleware
func newLoggingMiddleware(logger log.Logger) linkManagerMiddleware {
  return func(next om.LinkManager) om.LinkManager {
    return loggingMiddleware{next, logger}
  }
}

这可能会很令人困惑,但基本思想是能够链接任意中间件组件,执行一些工作并让其余的计算继续进行。我们之所以有这么多层间接性,是因为 Go-kit 对我们的类型和接口一无所知,所以我们必须通过编写这些样板代码来协助。正如我之前提到的,所有这些都可以自动生成,也应该自动生成。让我们来看看loggingMiddleware结构及其方法。结构本身有一个linkManager接口,它是链中的下一个组件和记录器对象:

type loggingMiddleware struct {
  next om.LinkManager
  logger log.Logger
}

作为LinkManager中间件组件,它必须实现LinkManager接口方法。这是GetLinks()的实现。它使用记录器记录一些值,特别是方法名称,即GetLinks,请求对象,结果和持续时间。然后,它调用链中下一个组件的GetLinks()方法。

func (m loggingMiddleware) GetLinks(request om.GetLinksRequest) (result om.GetLinksResult, err error) {
  defer func(begin time.Time) {
    m.logger.Log(
      "method", "GetLinks",
      "request", request,
      "result", result,
      "duration", time.Since(begin),
    )
  }(time.Now())
  result, err = m.next.GetLinks(request)
  return
}

为了简单起见,其他方法只是调用链中的下一个组件而不做任何事情:

func (m loggingMiddleware) AddLink(request om.AddLinkRequest) error {
  return m.next.AddLink(request)
}

func (m loggingMiddleware) UpdateLink(request om.UpdateLinkRequest) error {
  return m.next.UpdateLink(request)
}

func (m loggingMiddleware) DeleteLink(username string, url string) error {
  return m.next.DeleteLink(username, url)
}

中间件链概念非常强大。中间件可以在将输入传递给下一个组件之前预处理输入,它可以短路并立即返回而不调用下一个组件,或者它可以对来自下一个组件的结果进行后处理。

让我们看看在运行冒烟测试时从链接服务中的日志输出。对于人类来说,它看起来有点混乱,但所有必要的信息都在那里,清晰标记并且可以随时进行大规模分析。它很容易使用 grep,并且很容易使用jq等工具进行深入挖掘。

$ kubectl logs svc/link-manager
{"called from":"link_service.go:133","msg":"*** listening on ***","port":"8080","service":"link manager","timestamp":"2019-05-13T02:44:42.588578835Z"}
{"called from":"logging_middleware.go:25","duration":"1.526953ms","method":"GetLinks","request":{"UrlRegex":"","TitleRegex":"","DescriptionRegex":"","Username":"Gigi Sayfan","Tag":"","StartToken":""},"result":{"Links":[],"NextPageToken":""},"service":"link manager","timestamp":"2019-05-13T02:45:05.302342532Z"}
{"called from":"logging_middleware.go:25","duration":"591.148µs","method":"GetLinks","request":{"UrlRegex":"","TitleRegex":"","DescriptionRegex":"","Username":"Gigi Sayfan","Tag":"","StartToken":""},"result":{"Links":[{"Url":"https://github.com/the-gigi","Title":"Gigi on Github","Description":"","Status":"pending","Tags":null,"CreatedAt":"2019-05-13T02:45:05.845411Z","UpdatedAt":"2019-05-13T02:45:05.845411Z"}],"NextPageToken":""},"service":"link manager","timestamp":"2019-05-13T02:45:06.134842509Z"}
{"called from":"logging_middleware.go:25","duration":"911.499µs","method":"GetLinks","request":{"UrlRegex":"","TitleRegex":"","DescriptionRegex":"","Username":"Gigi Sayfan","Tag":"","StartToken":""},"result":{"Links":[{"Url":"https://github.com/the-gigi","Title":"Gigi on Github","Description":"","Status":"pending","Tags":null,"CreatedAt":"2019-05-13T02:45:05.845411Z","UpdatedAt":"2019-05-13T02:45:05.845411Z"}],"NextPageToken":""},"service":"link manager","timestamp":"2019-05-13T02:45:09.438915897Z"}

由于 Go-kit,我们已经有了一个强大而灵活的日志记录机制。然而,使用kubectl logs手动获取日志并不具备可扩展性。对于真实世界的系统,我们需要集中式日志管理。

Kubernetes 的集中式日志记录

在 Kubernetes 中,容器写入标准输出和标准错误流。Kubernetes 使这些日志可用(例如,通过kubectl logs)。甚至可以通过使用kubectl logs -p获取容器的上一次运行的日志,但是,如果 Pod 被重新调度,那么它的容器和它们的日志就会消失。如果节点本身崩溃,您也会丢失日志。即使对于具有大量服务的集群,所有日志都是可用的,筛选容器日志并尝试理解系统状态是一项非平凡的任务。进入集中式日志记录。其想法是运行一个日志代理,可以作为每个 Pod 中的边缘容器,或者作为每个节点上的守护程序集,监听所有日志,并实时将它们发送到一个集中的位置,那里可以对它们进行聚合、过滤和排序。当然,您也可以直接从您的容器显式地记录到集中式日志记录服务。

在我看来,最简单和最健壮的方法是使用守护进程集。集群管理员确保在每个节点上安装了一个日志代理,就这样。无需更改您的 pod 规范以注入侧边容器,也无需依赖特殊库与远程日志记录服务通信。您的代码写入标准输出和标准错误,就完成了。您可能使用的大多数其他服务,如 Web 服务器和数据库,也可以配置为写入标准输出和标准错误。

在 Kubernetes 上最流行的日志代理之一是 Fluentd(www.fluentd.org)。它也是一个 CNCF 毕业项目。除非您有非常充分的理由使用其他日志代理,否则应该使用 Fluentd。下面是一个图表,说明了 Fluentd 如何作为一个 DaemonSet 适配到 Kubernetes 中,部署到每个节点,拉取所有 pod 的日志,并将它们发送到一个集中的日志管理系统:

Fluentd

让我们谈谈日志管理系统。在开源世界中,ELK 堆栈——ElasticSearch、LogStash 和 Kibana——是非常流行的组合。ElasticSearch 存储日志并提供各种切片和切块的方式。LogStash 是日志摄取管道,Kibana 是一个强大的可视化解决方案。Fluentd 可以替代 LogStash 作为日志代理,你可以得到 EFK 堆栈——ElasticSearch、Fluentd 和 Kibana 在 Kubernetes 上运行得非常好。还有 Helm 图表和 GitHub 存储库,可以在您的 Kubernetes 集群上一键安装 EFK。然而,你也应该考虑集群外的日志记录服务。正如我们之前讨论过的,日志对故障排除和事后分析非常有帮助。如果您的集群出现问题,您可能无法在最需要的时候访问日志。Fluentd 可以与大量的数据输出集成。在这里查看完整列表:www.fluentd.org/dataoutputs。我们已经涵盖了日志记录,现在是时候谈谈指标了。

在 Kubernetes 上收集指标

指标是许多有趣用例的关键组件,如自愈、自动缩放和警报。作为一个分布式平台,Kubernetes 在指标方面提供了非常强大的功能,具有强大而通用且灵活的指标 API。

Kubernetes 一直支持通过 cAdvisor(集成到 kube-proxy 中)和 Heapster(github.com/kubernetes-retired/heapster)来支持度量。但是,cAdvisor 在 Kubernetes 1.12 中被移除,Heapster 在 Kubernetes 1.13 中被移除。您仍然可以安装它们(就像我们之前在 minikube 上使用 Heapster 插件一样),但它们不再是 Kubernetes 的一部分,也不再推荐使用。在 Kubernetes 上进行度量的新方法是使用度量 API 和度量服务器(github.com/kubernetes-incubator/metrics-server)。

介绍 Kubernetes 度量 API

Kubernetes 度量 API 非常通用。它支持节点和 Pod 度量,以及自定义度量。度量有一个使用字段,一个时间戳和一个窗口(度量收集的时间范围)。以下是节点度量的 API 定义:

// resource usage metrics of a node.
type NodeMetrics struct {
  metav1.TypeMeta
  metav1.ObjectMeta

  // The following fields define time interval from which metrics were
  // collected from the interval [Timestamp-Window, Timestamp].
  Timestamp metav1.Time
  Window metav1.Duration

  // The memory usage is the memory working set.
  Usage corev1.ResourceList
}

// NodeMetricsList is a list of NodeMetrics.
type NodeMetricsList struct {
  metav1.TypeMeta
  // Standard list metadata.
  // More info: https://git.k8s.io/community/contributors/devel/api-conventions.md#types-kinds
  metav1.ListMeta

  // List of node metrics.
  Items []NodeMetrics
}

使用字段类型为ResourceList,但实际上是资源名称到数量的映射:

// ResourceList is a set of (resource name, quantity) pairs.
type ResourceList map[ResourceName]resource.Quantity

还有另外两个与度量相关的 API:外部度量 API 和自定义度量 API。它们旨在通过任意自定义度量或来自 Kubernetes 外部的度量(例如云提供商监控)来扩展 Kubernetes 度量。您可以注释这些额外的度量并将其用于自动缩放。

了解 Kubernetes 度量服务器

Kubernetes 度量服务器是 Heapster 和 cAdvisor 的现代替代品。它实现了度量 API 并提供节点和 Pod 度量。这些度量由各种自动缩放器使用,并且在处理尽力而为的情况时,Kubernetes 调度器本身也会使用这些度量。根据您的 Kubernetes 发行版,度量服务器可能已安装或未安装。如果需要安装它,您可以使用 helm。例如,在 AWS EKS 上,您必须自己安装度量服务器,使用以下命令(您可以选择任何命名空间):

helm install stable/metrics-server \
 --name metrics-server \
 --version 2.0.4 \
 --namespace kube-system

通常,您不直接与度量服务器交互。您可以使用kubectl get --raw命令访问度量:

$ kubectl get --raw "/apis/metrics.k8s.io/v1beta1/nodes" | jq .
{
 "kind": "NodeMetricsList",
 "apiVersion": "metrics.k8s.io/v1beta1",
 "metadata": {
 "selfLink": "/apis/metrics.k8s.io/v1beta1/nodes"
 },
 "items": [
 {
 "metadata": {
 "name": "ip-192-168-13-100.ec2.internal",
 "selfLink": "/apis/metrics.k8s.io/v1beta1/nodes/ip-192-168-13-100.ec2.internal",
 "creationTimestamp": "2019-05-17T20:05:29Z"
 },
 "timestamp": "2019-05-17T20:04:54Z",
 "window": "30s",
 "usage": {
 "cpu": "85887417n",
 "memory": "885828Ki"
 }
 }
 ]
}

此外,您还可以使用非常有用的kubectl命令,即kubectl top,它可以快速查看节点或 Pod 的性能概况:

$ kubectl top nodes
NAME                        CPU(cores) CPU%  MEMORY(bytes)  MEMORY%
ip-192-168-13-100.ec2.internal   85m   4%     863Mi           11%

$ kubectl top pods
NAME                                    CPU(cores)   MEMORY(bytes)
api-gateway-795f7dcbdb-ml2tm            1m           23Mi
link-db-7445d6cbf7-2zs2m                1m           32Mi
link-manager-54968ff8cf-q94pj           0m           4Mi
nats-cluster-1                          1m           3Mi
nats-operator-55dfdc6868-fj5j2          2m           11Mi
news-manager-7f447f5c9f-c4pc4           0m           1Mi
news-manager-redis-0                    1m           1Mi
social-graph-db-7565b59467-dmdlw        1m           31Mi
social-graph-manager-64cdf589c7-4bjcn   0m           1Mi
user-db-0                               1m           32Mi
user-manager-699458447-6lwjq            1m           1Mi

请注意,截至撰写时的 Kubernetes 1.15(当前版本),Kubernetes 仪表板尚未与性能指标服务器集成。它仍然需要 Heapster。我相信您很快就能与 metrics-server 一起使用。

metrics-server 是用于 CPU 和内存的标准 Kubernetes 指标解决方案,但是,如果您想进一步考虑自定义指标,那么 Prometheus 是一个明显的选择。与大多数 Kubernetes 不同,在指标领域有大量选项,Prometheus 在所有其他免费和开源选项中脱颖而出。

使用 Prometheus

Prometheus(prometheus.io/)是一个开源的 CNCF 毕业项目(仅次于 Kubernetes 本身)。它是 Kubernetes 的事实标准指标收集解决方案。它具有令人印象深刻的功能集,在 Kubernetes 上有大量安装基础,并且拥有活跃的社区。一些突出的功能如下:

  • 通用的多维数据模型,其中每个指标都被建模为键值对的时间序列

  • 一个强大的查询语言,称为 PromQL,可以让您生成报告、图形和表格

  • 内置警报引擎,其中警报由 PromQL 查询定义和触发

  • 强大的可视化 - Grafana,控制台模板语言等

  • 与 Kubernetes 之外的其他基础设施组件的许多集成

让我们看看以下参考资料:

将 Prometheus 部署到集群中

Prometheus 是一个功能强大的项目,具有许多功能、选项和集成。部署和管理它并不是一项微不足道的任务。有一些项目可以提供帮助。Prometheus operator(github.com/coreos/prometheus-operator)提供了一种使用 Kubernetes 资源深度配置 Prometheus 的方法。

操作员概念(coreos.com/blog/introducing-operators.html)是由 CoreOS 在 2016 年引入的(后来被 RedHat 收购,再后来被 IBM 收购)。Kubernetes 操作员是负责使用 Kubernetes CRD 在集群内管理有状态应用程序的控制器。实际上,操作员在实践中扩展了 Kubernetes API,以在管理像 Prometheus 这样的外部组件时提供无缝体验。实际上,Prometheus 操作员是第一个操作员(连同 Etcd 操作员):

Prometheus 操作员

kube-promethus(github.com/coreos/kube-prometheus)项目是建立在 Prometheus 操作员之上的,并添加了以下内容:

  • Grafana 可视化

  • 高可用的 Prometheus 集群

  • 高可用的Alertmanager集群

  • 用于 Kubernetes 度量标准 API 的适配器

  • 通过 Prometheus 节点导出器获取内核和操作系统度量标准

  • 通过kube-state-metrics获取 Kubernetes 对象状态的各种度量标准

Prometheus 操作员带来了在 Kubernetes 命名空间中启动 Prometheus 实例、配置它并通过标签定位服务的能力。

现在,我们将使用 helm 部署一个完整的 Prometheus 安装:

$ helm install --name prometheus stable/prometheus
 This will create service accounts, RBAC roles, RBAC bindings, deployments, services and even a daemon set. In addition it will print the following information to connect to different components:

可以通过集群内以下 DNS 名称的端口80访问 Prometheus 服务器:prometheus-server.default.svc.cluster.local

通过在同一个 shell 中运行以下命令来获取 Prometheus 服务器 URL:

  export POD_NAME=$(kubectl get pods --namespace default -l "app=prometheus,component=server" -o jsonpath="{.items[0].metadata.name}")
  kubectl --namespace default port-forward $POD_NAME 9090

Prometheus alertmanager可以通过集群内以下 DNS 名称的端口80访问:

prometheus-alertmanager.default.svc.cluster.local

通过在同一个 shell 中运行以下命令来获取Alertmanager URL:

export POD_NAME=$(kubectl get pods --namespace default -l "app=prometheus,component=alertmanager" -o jsonpath="{.items[0].metadata.name}")
  kubectl --namespace default port-forward $POD_NAME 9093

Prometheus pushgateway可以通过集群内以下 DNS 名称的端口 9091 访问:

 prometheus-pushgateway.default.svc.cluster.local

通过在同一个 shell 中运行以下命令来获取PushGateway URL:

  export POD_NAME=$(kubectl get pods --namespace default -l "app=prometheus,component=pushgateway" -o jsonpath="{.items[0].metadata.name}")
  kubectl --namespace default port-forward $POD_NAME 9091

让我们看看安装了哪些服务:

$ kubectl get svc -o name | grep prom
service/prometheus-alertmanager
service/prometheus-kube-state-metrics
service/prometheus-node-exporter
service/prometheus-pushgateway
service/prometheus-server

一切似乎都很正常。让我们按照说明查看一下 Prometheus web UI:

$ export POD_NAME=$(kubectl get pods --namespace default -l "app=prometheus,component=server" -o jsonpath="{.items[0].metadata.name}")

$ kubectl port-forward $POD_NAME 9090
Forwarding from 127.0.0.1:9090 -> 9090
Forwarding from [::1]:9090 -> 9090

我们现在可以浏览到localhost:9090并进行一些检查。让我们检查一下集群中 goroutines 的数量:

Prometheus web UI

Prometheus 收集的指标数量令人震惊。有数百种不同的内置指标。打开指标选择下拉菜单时,看看右侧滚动条有多小:

Prometheus 下拉菜单

比你所需的指标要多得多,但它们每一个对于某些特定的故障排除任务都可能很重要。

从 Delinkcious 记录自定义指标

好的:Prometheus 已安装并自动收集标准指标,但我们也想记录自己的自定义指标。Prometheus 以拉模式工作。想要提供指标的服务需要公开一个/metrics端点(也可以使用其 Push Gateway 将指标推送到 Prometheus)。让我们利用 Go-kit 的中间件概念,并添加一个类似于日志中间件的指标中间件。我们将利用 Prometheus 提供的 Go 客户端库。

客户端库提供了几种原语,如计数器、摘要、直方图和仪表。为了理解如何从 Go 服务记录指标,我们将对链接服务的每个端点进行仪器化,记录请求的数量(计数器)以及所有请求的摘要(摘要)。让我们从一个名为 pkg/metrics 的单独库中提供工厂函数。该库提供了一个方便的包装器,围绕 Prometheus Go 客户端。Go-kit 在 Prometheus Go 客户端的顶部有自己的抽象层,但除非你打算切换到另一个指标提供程序(如statsd),否则它并没有提供太多价值。这对于 Delinkcious 和你的系统来说也不太可能。服务名称、指标名称和帮助字符串将用于稍后构建完全限定的指标名称:

package metrics

import (
  "github.com/prometheus/client_golang/prometheus"
  "github.com/prometheus/client_golang/prometheus/promauto"
)

func NewCounter(service string, name string, help string) prometheus.Counter {
  opts := prometheus.CounterOpts{
    Namespace: "",
    Subsystem: service,
    Name: name,
    Help: help,
  }
  counter := promauto.NewCounter(opts)
  return counter
}

func NewSummary(service string, name string, help string) prometheus.Summary {
  opts := prometheus.SummaryOpts{
    Namespace: "",
    Subsystem: service,
    Name: name,
    Help: help,
  }

  summary := promauto.NewSummary(opts)
  return summary
}

下一步是构建中间件。它应该看起来非常熟悉,因为它几乎与日志中间件完全相同。newMetricsMiddleware()函数为每个端点创建一个计数器和摘要指标,并将其作为我们之前定义的通用linkManagerMiddleware函数返回(一个接受下一个中间件并返回自身以组装所有实现om.LinkManager接口的组件链的函数):

package service

import (
  "github.com/prometheus/client_golang/prometheus"
  "github.com/the-gigi/delinkcious/pkg/metrics"
  om "github.com/the-gigi/delinkcious/pkg/object_model"
  "strings"
  "time"
)

// implement function to return ServiceMiddleware
func newMetricsMiddleware() linkManagerMiddleware {
  return func(next om.LinkManager) om.LinkManager {
    m := metricsMiddleware{next,
      map[string]prometheus.Counter{},
      map[string]prometheus.Summary{}}
    methodNames := []string{"GetLinks", "AddLink", "UpdateLink", "DeleteLink"}
    for _, name := range methodNames {
      m.requestCounter[name] = metrics.NewCounter("link",
                                                  strings.ToLower(name)+"_count",
                                                  "count # of requests")
      m.requestLatency[name] = metrics.NewSummary("link",
                                                  strings.ToLower(name)+"_summary",
                                                  "request summary in milliseconds")

    }
    return m
  }

metricsMiddleware结构存储了下一个中间件和两个映射。一个映射是方法名称到 Prometheus 计数器的映射,而另一个映射是方法名称到 Prometheus 摘要的映射。它们被LinkManager接口方法用于分别记录每个方法的指标:

type metricsMiddleware struct {
  next om.LinkManager
  requestCounter map[string]prometheus.Counter
  requestLatency map[string]prometheus.Summary
}

中间件方法使用执行操作的模式,这种情况下是记录指标,然后调用下一个组件。这是GetLinks()方法:

func (m metricsMiddleware) GetLinks(request om.GetLinksRequest) (result om.GetLinksResult, err error) {
  defer func(begin time.Time) {
    m.recordMetrics("GetLinks", begin)
  }(time.Now())
  result, err = m.next.GetLinks(request)
  return
}

实际的指标记录是由recordMetrics()方法完成的,它接受方法名称(这里是GetLinks)和开始时间。它被延迟到GetLinks()方法的结尾,这使它能够计算GetLinks()方法本身的持续时间。它使用与方法名称匹配的映射中的计数器和摘要:

func (m metricsMiddleware) recordMetrics(name string, begin time.Time) {
  m.requestCounter[name].Inc()
  durationMilliseconds := float64(time.Since(begin).Nanoseconds() * 1000000)
  m.requestLatency[name].Observe(durationMilliseconds)
}

到目前为止,我们的指标中间件已经准备就绪,但我们仍然需要将其连接到中间件链并将其公开为/metrics端点。由于我们已经完成了所有的准备工作,这只是链接服务的Run()方法中的两行代码:

// Hook up the metrics middleware
svc = newMetricsMiddleware()(svc)

...

// Expose the metrics endpoint
r.Methods("GET").Path("/metrics").Handler(promhttp.Handler())

现在,我们可以查询/metrics端点并查看返回的指标。让我们运行三次烟雾测试并检查GetLinks()AddLink()方法的指标。正如预期的那样,AddLink()方法在每次烟雾测试中被调用一次(总共三次),而GetLinks()方法在每次测试中被调用三次,总共九次。我们还可以看到帮助字符串。

总结分位数在处理大型数据集时非常有用:

$ http http://localhost:8080/metrics | grep 'link_get\|add'

# HELP link_addlink_count count # of requests
# TYPE link_addlink_count counter
link_addlink_count 3
# HELP link_addlink_summary request summary in milliseconds
# TYPE link_addlink_summary summary
link_addlink_summary{quantile="0.5"} 2.514194e+12
link_addlink_summary{quantile="0.9"} 2.565382e+12
link_addlink_summary{quantile="0.99"} 2.565382e+12
link_addlink_summary_sum 7.438251e+12
link_addlink_summary_count 3
# HELP link_getlinks_count count # of requests
# TYPE link_getlinks_count counter
link_getlinks_count 9
# HELP link_getlinks_summary request summary in milliseconds
# TYPE link_getlinks_summary summary
link_getlinks_summary{quantile="0.5"} 5.91539e+11
link_getlinks_summary{quantile="0.9"} 8.50423e+11
link_getlinks_summary{quantile="0.99"} 8.50423e+11
link_getlinks_summary_sum 5.710272e+12
link_getlinks_summary_count 9

自定义指标非常好。然而,除了查看大量的数字、图表和直方图,欣赏你的手工作品之外,指标的真正价值在于通知自动化系统或您有关系统状态的变化。这就是警报的作用。

警报

对于关键系统来说,警报非常重要。你可以尽可能地计划和构建弹性功能,但你永远无法构建一个无懈可击的系统。构建强大可靠系统的正确心态是尽量减少故障,但也要承认故障会发生。当故障发生时,你需要快速检测并警报相关人员,以便他们能够调查和解决问题。请注意,我明确说了警报人员。如果你的系统具有自愈能力,那么你可能有兴趣查看系统能够自行纠正的问题报告。我不认为这些是故障,因为系统设计用于处理它们。例如,容器可以崩溃多少次都可以;kubelet 将继续重新启动它们。从 Kubernetes 的角度来看,容器崩溃并不被视为故障。如果容器内运行的应用程序没有设计来处理这样的崩溃和重启,那么你可能需要为这种情况配置警报,但这是你的决定。

我想提出的主要观点是,失败是一个很大的词。许多可能被视为失败的事情包括内存耗尽、服务器崩溃、磁盘损坏、间歇性或长时间的网络中断,以及数据中心下线。然而,如果你为此进行设计并采取缓解措施,它们并不是系统的失败。系统将按设计继续运行,可能以降低的容量,但仍在运行。如果这些事件经常发生并且显著地降低了系统的总吞吐量或用户体验,你可能需要调查根本原因并加以解决。这都是定义服务水平目标SLOs)和服务水平协议SLAs)的一部分。只要你在 SLAs 范围内运作,系统就不会失败,即使多个组件失败,即使服务未达到其 SLO。

接受组件故障

接受失败意味着认识到在一个大型系统中,组件会经常失败。这并不是一个不寻常的情况。你希望尽量减少组件的故障,因为每次故障都会有各种成本,即使整个系统仍在运行。但这种情况会发生。大多数组件故障可以通过自动处理或通过设置冗余来处理。然而,系统不断发展,大多数系统并不处于每个组件故障都有相应应对措施的完美状态。因此,从理论上讲,本来可以预防的组件故障可能会变成系统故障。例如,如果你将日志写入本地磁盘并且不旋转日志文件,那么最终你的磁盘空间会用完(非常常见的故障),如果使用该磁盘的服务器正在运行一些关键组件而没有冗余,那么你就会面临系统故障。

勉强接受系统故障

因此,系统故障是会发生的。即使是最大的云服务提供商也会不时发生故障。系统故障有不同的级别,从临时短暂的非关键子系统故障,到整个系统长时间的完全停机,甚至到大规模数据丢失。一个极端的例子是恶意攻击者针对一家公司及其所有备份,这可能会使其破产。这更多地与安全有关,但了解系统故障的整个范围是很好的。

处理系统故障的常见方法是冗余、备份和分隔。这些是可靠的方法,但成本高,正如我们之前提到的,它们并不能预防所有故障。在最大程度地减少系统故障的可能性和影响之后,下一步是计划快速的灾难恢复。

考虑人为因素

现在,我们严格处于人们对实际事件做出反应的领域。一些关键系统可能会由人们进行 24/7 实时监控,他们会认真观察系统状态并随时准备采取行动。大多数公司会根据各种触发器设置警报。请注意,即使对于复杂系统,你有 24/7 实时监控,你仍然需要向监控系统的人员提供警报,因为对于这样的系统,通常会有大量描述当前状态的数据和信息。

让我们来看看一个合理的警报计划的几个方面,这对人们非常有效。

警告与警报

让我们再次考虑磁盘空间不足的情况。这是一个随着时间而恶化的情况。随着越来越多的数据记录到日志文件中,磁盘空间逐渐减少。如果你没有采取任何措施,你会发现当应用程序开始发出奇怪的错误时,通常是实际故障的下游,你将不得不追溯到源头。我曾经历过这种情况;这并不好玩。更好的方法是定期检查磁盘空间,并在超过一定阈值时(例如 95%)发出警报。但为什么要等到情况变得危急?在这种逐渐恶化的情况下,更好的做法是在早期(例如 75%)检测到问题并通过某种机制发出警告。这将给系统操作员充足的时间做出反应,而不会引发不必要的危机。

考虑严重级别

这就引出了警报严重级别。不同的严重级别需要不同的响应。不同的组织可以定义自己的级别。例如,PagerDuty 有一个 1-5 的等级,遵循 DEFCON 阶梯。我个人更喜欢警报的两个级别:在凌晨 3 点叫醒我可以等到早上。我喜欢以实际的方式来考虑严重级别。对于每个严重级别,你会执行什么样的响应或后续工作?如果你总是对 3-5 级别的严重性做同样的事情,那么将它们分类为 3、4 和 5 级别与将它们归为单一的低优先级严重级别有什么好处呢?

你的情况可能不同,所以确保考虑所有利益相关者。生产事故并不好玩。

确定警报通道

警报通道与严重级别紧密相关。让我们考虑以下选项:

  • 叫醒电话给值班工程师

  • 即时消息到公共频道

  • 电子邮件

通常,同一事件会被广播到多个通道。显然,叫醒电话是最具侵入性的,即时消息(例如,slack)可能会弹出通知,但必须有人在附近看到它。电子邮件通常更具信息性。结合多个通道是很常见的。例如,值班工程师接到叫醒电话,团队事件通道收到一条消息,团队经理收到一封电子邮件。

优化嘈杂的警报

嘈杂的警报是一个问题。如果警报太多 - 尤其是低优先级的警报 - 那么就会出现两个主要问题:

  • 这会分散所有收到通知的人的注意力(尤其是在半夜被叫醒的可怜工程师)。

  • 这可能导致人们忽略警报。

您不希望因为大量嘈杂的低优先级警报而错过重要的警报。调整警报是一门艺术,也是一个持续的过程。

我建议阅读并采纳 Rob Ewaschuk(前 Google 网站可靠性工程师)的《关于警报的我的哲学》(docs.google.com/document/d/199PqyG3UsyXlwieHaqbGiWVa8eMWi8zzAn0YfcApr8Q/edit)。

利用 Prometheus 警报管理器

警报自然而然地依赖于指标。除了作为一个出色的指标收集器外,Prometheus 还提供了一个警报管理器。我们已经将其作为整体 Prometheus 安装的一部分安装了:

$ kubectl get svc prometheus-alertmanager
NAME                      TYPE     CLUSTER-IP EXTERNAL-IP PORT(S) AGE
prometheus-alertmanager  ClusterIP  10.100.109.90 <none>  80/TCP   24h

我们不会配置任何警报,因为我不想为 Delinkcious 值班。

警报管理器具有以下概念模型:

  • 分组

  • 集成

  • 抑制

  • 沉默

分组处理将多个信号合并为单个通知。例如,如果您的许多服务使用 AWS S3 并且出现故障,那么许多服务可能会触发警报。但是通过分组,您可以配置警报管理器仅发送一个通知。

集成是通知目标。警报管理器支持许多目标,如电子邮件、PagerDuty、Slack、HipChat、PushOver、OpsGenie、VictoOps 和微信。对于所有其他集成,建议使用通用的 HTTP webhook 集成。

抑制是一个有趣的概念,您可以跳过发送通知,如果其他警报已经触发。这是在分组之上的另一种方式,可以避免为相同的高级问题发送多个通知。

沉默只是一种暂时静音某些警报的机制。如果您的警报规则没有与分组和抑制清晰地配置,或者即使一些有效的警报仍在触发,但您已经处理了情况,并且暂时不需要更多通知,这将非常有用。您可以在 Web UI 中配置沉默。

在 Prometheus 中配置警报

您可以通过在 Prometheus 服务器配置文件中配置规则来触发警报。这些警报由警报管理器处理,根据其配置决定如何处理这些警报。以下是一个示例:

groups:
- name: link-manager
  rules:
  - alert: SlowAddLink
    expr: link_addlink_summary{quantile="0.5"} > 5
    for: 1m
    labels:
      severity: critical
    annotations:
      description: the AddLink() method takes more than 5 seconds for more than half of the request in the last minute
      summary: the AddLink() method takes too long

规则有一个表达式,如果为真,则触发警报。有一个时间段(这里是 1 分钟),条件必须为真,这样您就可以避免触发一次性异常(如果您选择的话)。警报有与之关联的严重性和一些注释。

在处理了指标和警报之后,让我们继续看看在警报触发并通知我们有问题时该怎么做。

分布式跟踪

警报通知您有什么问题可能会很模糊,比如“网站出了问题”。这对于故障排除、检测根本原因和修复并不是很有用。特别是对于基于微服务的架构,每个用户请求可能由大量微服务处理,每个组件可能以有趣的方式失败。有几种方法可以尝试缩小范围:

  • 查看最近的部署和配置更改。

  • 检查您的第三方依赖是否遭受了中断。

  • 如果根本原因尚未解决,请考虑类似的问题。

如果幸运的话,您可以立即诊断问题。然而,在调试大规模分布式系统时,您真的不想依赖运气。最好是有一个系统的方法。进入分布式跟踪。

我们将使用 Jaeger(www.jaegertracing.io/)分布式跟踪系统。这是又一个 CNCF 项目,最初是 Uber 的开源项目。Jaeger 可以帮助解决的问题如下:

  • 分布式事务监控

  • 性能和延迟优化

  • 根本原因分析

  • 服务依赖分析

  • 分布式上下文传播

在我们可以使用 Jaeger 之前,我们需要将其安装到集群中。

安装 Jaeger

安装 Jaeger 的最佳方式是使用 Jaeger-operator,所以让我们首先安装运算符:

$ kubectl create -f https://raw.githubusercontent.com/jaegertracing/jaeger-operator/master/deploy/crds/jaegertracing_v1_jaeger_crd.yaml
customresourcedefinition.apiextensions.k8s.io/jaegers.jaegertracing.io created
$ kubectl create -f https://raw.githubusercontent.com/jaegertracing/jaeger-operator/master/deploy/service_account.yaml
serviceaccount/jaeger-operator created
$ kubectl create -f https://raw.githubusercontent.com/jaegertracing/jaeger-operator/master/deploy/role.yaml
clusterrole.rbac.authorization.k8s.io/jaeger-operator created
$ kubectl create -f https://raw.githubusercontent.com/jaegertracing/jaeger-operator/master/deploy/role_binding.yaml
clusterrolebinding.rbac.authorization.k8s.io/jaeger-operator created
$ kubectl create -f https://raw.githubusercontent.com/jaegertracing/jaeger-operator/master/deploy/operator.yaml
deployment.apps/jaeger-operator created

安装了运算符之后,我们可以使用以下清单创建一个 Jaeger 实例:

apiVersion: jaegertracing.io/v1
kind: Jaeger
metadata:
  name: jaeger-in-memory
spec:
  agent:
    strategy: DaemonSet

这是一个简单的内存实例。您还可以创建由 Elasticsearch 和 Cassandra 支持的实例:

Jaeger UI

Jaeger 有一个非常漂亮的 Web UI,可以让您深入了解和探索分布式工作流程。

将跟踪集成到您的服务中

这里有几个步骤,但其要点是,你可以将跟踪视为另一种形式的中间件。核心抽象是 span。一个请求涉及多个微服务,你记录这些 span 并将日志与它们关联起来。

这是跟踪中间件,类似于日志中间件,不同之处在于它为GetLinks()方法启动一个 span,而不是记录日志。像往常一样,有一个工厂函数返回一个linkManagerMiddleware函数,调用链中的下一个中间件。工厂函数接受一个跟踪器,它可以启动和结束一个 span:

package service

import (
  "github.com/opentracing/opentracing-go"
  om "github.com/the-gigi/delinkcious/pkg/object_model"
)

func newTracingMiddleware(tracer opentracing.Tracer) linkManagerMiddleware {
  return func(next om.LinkManager) om.LinkManager {
    return tracingMiddleware{next, tracer}
  }
}

type tracingMiddleware struct {
  next om.LinkManager
  tracer opentracing.Tracer
}

func (m tracingMiddleware) GetLinks(request om.GetLinksRequest) (result om.GetLinksResult, err error) {
  defer func(span opentracing.Span) {
    span.Finish()
  }(m.tracer.StartSpan("GetLinks"))
  result, err = m.next.GetLinks(request)
  return
}

让我们添加以下函数来创建一个 Jaeger 跟踪器:

// createTracer returns an instance of Jaeger Tracer that samples
// 100% of traces and logs all spans to stdout.
func createTracer(service string) (opentracing.Tracer, io.Closer) {
  cfg := &jaegerconfig.Configuration{
    ServiceName: service,
    Sampler: &jargerconfig.SamplerConfig{
      Type: "const",
      Param: 1,
    },
    Reporter: &jaegerconfig.ReporterConfig{
      LogSpans: true,
    },
  }
  logger := jaegerconfig.Logger(jaeger.StdLogger)
  tracer, closer, err := cfg.NewTracer(logger)
  if err != nil {
    panic(fmt.Sprintf("ERROR: cannot create tracer: %v\n", err))
  }
  return tracer, closer
}

然后,Run()函数创建一个新的跟踪器和一个跟踪中间件,将其挂接到中间件链上:

// Create a tracer
 tracer, closer := createTracer("link-manager")
 defer closer.Close()

 ...

 // Hook up the tracing middleware
 svc = newTracingMiddleware(tracer)(svc)

运行完烟雾测试后,我们可以搜索日志以查找 span 的报告。我们期望有三个 span,因为烟雾测试调用了GetLinks()三次:

$ kubectl logs svc/link-manager | grep span
2019/05/20 16:44:17 Reporting span 72bce473b1af5236:72bce473b1af5236:0:1
2019/05/20 16:44:18 Reporting span 6e9f45ce1bb0a071:6e9f45ce1bb0a071:0:1
2019/05/20 16:44:21 Reporting span 32dd9d1edc9e747a:32dd9d1edc9e747a:0:1

跟踪和 Jaeger 还有很多内容。这只是刚刚开始涉及到表面。我鼓励你多了解它,尝试它,并将其整合到你的系统中。

总结

在本章中,我们涵盖了许多主题,包括自愈、自动扩展、日志记录、度量和分布式跟踪。监控分布式系统很困难。仅仅安装和配置各种监控服务如 Fluentd、Prometheus 和 Jaeger 就是一个不小的项目。管理它们之间的交互以及你的服务如何支持日志记录、仪器化和跟踪增加了另一层复杂性。我们已经看到,Go-kit 的中间件概念使得以一种与核心业务逻辑解耦的方式更容易地添加这些运营关注点。一旦你为这些系统建立了所有的监控,就会有一系列新的挑战需要考虑 - 你如何从所有的数据中获得洞察?你如何将其整合到你的警报和事件响应流程中?你如何不断改进对系统的理解和改进你的流程?这些都是你必须自己回答的难题,但你可能会在接下来的进一步阅读部分中找到一些指导。

在下一章中,我们将探讨服务网格和 Istio 这个令人兴奋的世界。服务网格是一个真正的创新,可以真正地减轻服务的许多运营问题,让它们专注于它们的核心领域。然而,像 Istio 这样的服务网格有着广泛的应用范围,需要克服相当大的学习曲线。服务网格的好处是否能够弥补增加的复杂性?我们很快就会找出答案。

进一步阅读

请参考以下链接,了解本章涵盖的更多内容:

第十三章:服务网格 - 与 Istio 一起工作

在本章中,我们将回顾服务网格和特别是 Istio 这一热门话题。这令人兴奋,因为服务网格是一个真正的游戏改变者。它将许多复杂的任务从服务中移出到独立的代理中。这是一个巨大的胜利,特别是在多语言环境中,不同的服务是用不同的编程语言实现的,或者如果你需要将一些遗留应用迁移到你的集群中。

在本章中,我们将涵盖以下主题:

  • 服务网格是什么

  • Istio 为我们带来了什么

  • Istio 上的 Delinkcious

  • Istio 的替代方案

技术要求

在本章中,我们将使用 Istio。我选择在本章中使用Google Kubernetes EngineGKE),因为 Istio 可以作为附加组件在 GKE 上启用,无需您安装它。这有以下两个好处:

  • 它节省了安装时间

  • 它展示了 Delinkcious 可以在云中运行,而不仅仅是在本地

要安装 Istio,您只需在 GKE 控制台中启用它,并选择 mTLS 模式,这是服务之间的相互认证。我选择了宽容模式,这意味着集群内部通信默认情况下不加密,服务将接受加密和非加密连接。您可以针对每个服务进行覆盖。对于生产集群,我建议使用严格的 mTLS 模式,其中所有连接必须加密:

Istio 将安装在自己的istio-system命名空间中,如下所示:

$ kubectl -n istio-system get po

NAME READY STATUS RESTARTS AGE
istio-citadel-6995f7bd9-69qhw 1/1 Running 0 11h
istio-cleanup-secrets-6xkjx 0/1 Completed 0 11h
istio-egressgateway-57b96d87bd-8lld5 1/1 Running 0 11h
istio-galley-6d7dd498f6-pm8zz 1/1 Running 0 11h
istio-ingressgateway-ddd557db7-b4mqq 1/1 Running 0 11h
istio-pilot-5765d76b8c-l9n5n 2/2 Running 0 11h
istio-policy-5b47b88467-tfq4b 2/2 Running 0 11h
istio-sidecar-injector-6b9fbbfcf6-vv2pt 1/1 Running 0 11h
istio-telemetry-65dcd9ff85-dxrhf 2/2 Running 0 11h
promsd-7b49dcb96c-cn49l 2/2 Running 1 11h

代码

您可以在github.com/the-gigi/delinkcious/releases/tag/v0.11找到更新的 Delinkcious 应用程序。

什么是服务网格?

让我们首先回顾一下微服务面临的问题,与单体应用相比,看看服务网格是如何解决这些问题的,然后你就会明白我为什么对它们如此兴奋。在设计和编写 Delinkcious 时,应用代码相当简单。我们跟踪用户、他们的链接以及他们的关注/被关注关系。我们还进行一些链接检查,并将最近的链接存储在新闻服务中。最后,我们通过 API 公开所有这些功能。

将单体应用与微服务进行比较

在单体系统中实现所有这些功能本来很容易。在 Delinkcious 单体系统中部署、监控和调试也很简单。然而,随着 Delinkcious 在功能上的增长,以及用户和开发团队的增加,单体应用程序的缺点变得更加明显。这就是为什么我们选择了基于微服务的方法。然而,在这个过程中,我们不得不编写大量的代码,安装许多额外的工具,并配置许多与 Delinkcious 应用程序本身无关的组件。我们明智地利用了 Kubernetes 和 Go kit 来清晰地将所有这些额外的关注点与 Delinkcious 领域代码分离,但这是一项艰苦的工作。

例如,如果安全性是高优先级,您会希望在系统中对服务间调用进行身份验证和授权。我们在 Delinkcious 中通过引入链接服务和社交图服务之间的共享密钥来实现这一点。我们必须配置一个密钥,确保它只能被这两个服务访问,并添加代码来验证每个调用是否来自正确的服务。在许多服务中维护(例如,轮换密钥)和演变这一点并不是一件容易的事情。

这是另一个例子,即分布式跟踪。在单体系统中,整个调用链可以通过堆栈跟踪来捕获。在 Delinkcious 中,您必须安装分布式跟踪服务,例如 Jaeger,并修改代码以记录跨度。

在单体系统中进行集中日志记录是微不足道的,因为单体系统已经是一个集中的实体。

总之,微服务带来了许多好处,但它们要管理和控制得多困难。

使用共享库来管理微服务的横切关注点

最常见的方法之一是在一个库或一组库中实现所有这些关注点。所有微服务都包含或依赖于处理所有这些横切面问题的共享库,如配置、日志记录、秘钥管理、跟踪、速率限制和容错。这在理论上听起来很不错;让服务处理应用领域,让一个共享库或一组库处理常见的关注点。Netflix 的 Hystrix 就是一个很好的例子,它是一个处理延迟和容错的 Java 库。Twitter 的 Finagle 是另一个很好的例子,它是一个针对 JVM 的 Scala 库。许多组织使用一系列这样的库,并经常编写自己的库。

然而,在实践中,这种方法有严重的缺点。第一个问题是,作为一个编程语言库,它自然是用特定的语言实现的(例如,在 Hystrix 的情况下是 Java)。你的系统可能有多种语言的微服务(即使 Delinkcious 也有 Go 和 Python 服务)。使用不同编程语言实现的微服务是最大的好处之一。共享库(或库)显著地阻碍了这一方面。这是因为你最终会有几个不太吸引人的选项,如下所示:

  • 你将所有的微服务限制在一个编程语言中。

  • 你为你使用的每种编程语言维护跨语言共享库,使其行为相同。

  • 你接受不同的服务将以不同的方式与你的集中服务进行交互(例如,不同的日志格式或缺失的跟踪)。

所有这些选项都相当糟糕。但事情并没有就此结束;假设你选择了前面提到的一些选项的组合。这很可能会包括大量的自定义代码,因为没有现成的库能够提供你所需的一切。现在,你想要更新你的共享代码库。由于它被所有或大多数服务共享,这意味着你必须对所有服务进行全面升级。然而,很可能你不能一次性关闭系统并升级所有服务。

相反,您将不得不以滚动更新的形式进行。即使是蓝绿部署也无法立即在多个服务之间完成。问题在于,通常,共享代码与您如何管理服务之间的共享秘密或身份验证有关。例如,如果服务 A 升级到共享库的新版本,而服务 B 仍在以前的版本上,它们可能无法通信。这会导致停机,可能会影响许多服务。您可以找到一种以向后兼容的方式引入更改的方法,但这更加困难且容易出错。

好的,所以跨所有服务共享库是有用的,但很难管理。让我们看看服务网格如何帮助。

使用服务网格来管理微服务的横切关注点

服务网格是一组智能代理和额外的控制基础设施组件。代理部署在集群中的每个节点上。代理拦截所有服务之间的通信,并可以代表您执行许多工作,以前必须由服务(或服务使用的共享库)完成。服务网格的一些责任如下:

  • 通过重试和自动故障转移可靠地传递请求

  • 延迟感知负载平衡

  • 根据灵活和动态的路由规则路由请求(这也被称为流量整形)

  • 通过截止日期进行断路

  • 服务对服务的身份验证和授权

  • 报告指标和支持分布式跟踪

所有这些功能对于许多大规模云原生应用程序都很重要。从服务中卸载它们是一个巨大的胜利。诸如智能流量整形之类的功能需要构建专门的可靠服务,而无需服务网格。

以下图表说明了服务网格嵌入到 Kubernetes 集群中的方式:

服务网格听起来确实是革命性的。让我们看看它们如何适应 Kubernetes。

了解 Kubernetes 与服务网格之间的关系

乍一看,服务网格听起来与 Kubernetes 本身非常相似。Kubernetes 将 kubelet 和 kube-proxy 部署到每个节点上,而服务网格则部署自己的代理。Kubernetes 有一个控制平面,kubelet/kube-proxy 与之交互,而服务网格有自己的控制平面,网格代理与之交互。

我喜欢把服务网格看作是对 Kubernetes 的一个补充。Kubernetes 主要负责调度 pod 并为其提供扁平网络模型和服务发现,以便不同的 pod 和服务之间可以相互通信。这就是服务网格接管并以更精细的方式管理这种服务与服务之间的通信。在负载平衡和网络策略方面有一层薄薄的重叠,但总体而言,服务网格是对 Kubernetes 的一个很好的补充。

同样重要的是要意识到这两种令人惊叹的技术并不依赖于彼此。显然,您可以在没有服务网格的情况下运行 Kubernetes 集群。此外,许多服务网格可以与其他非 Kubernetes 平台一起工作,例如 Mesos、Nomad、Cloud Foundry 和基于 Consul 的部署。

现在我们了解了什么是服务网格,让我们来看一个具体的例子。

Istio 带来了什么?

Istio 是一个服务网格,最初由 Google、IBM 和 Lyft 开发。它于 2017 年中期推出并迅速获得成功。它带来了一个统一的模型,具有控制平面和数据平面,围绕 Envoy 代理构建,具有很大的动力,并已经成为其他项目的基础。当然,它是开源的,是Cloud Native Computing Foundation (CNCF)项目。在 Kubernetes 中,每个 Envoy 代理都被注入为参与网格的每个 pod 的旁路容器。

让我们探索 Istio 架构,然后深入了解它提供的服务。

了解 Istio 架构

Istio 是一个提供了许多功能的大型框架,它有多个部分相互交互,并与 Kubernetes 组件(主要是间接和不显眼地)交互。它分为控制平面和数据平面。数据平面是一组代理(每个 pod 一个)。它们的控制平面是一组负责配置代理和收集数据遥测的组件。

以下图表说明了 Istio 的不同部分,它们之间的关系以及它们之间交换的信息:

让我们深入了解每个组件,从 Envoy 代理开始。

使者

Envoy 是一个用 C++实现的高性能代理。它由 Lyft 开发,作为 Istio 的数据平面,但它也是一个独立的 CNCF 项目,可以单独使用。对于服务网格中的每个 pod,Istio 注入(自动或通过istioctl CLI)一个 Envoy 侧容器来处理繁重的工作:

  • 代理 HTTP、HTTP/2 和 gRPC 流量之间的 pod

  • 复杂的负载平衡

  • mTLS 终止

  • HTTP/2 和 gRPC 代理

  • 提供服务健康状况

  • 对不健康服务的断路

  • 基于百分比的流量整形

  • 注入故障进行测试

  • 详细的度量

Envoy 代理控制其 pod 的所有传入和传出通信。这是 Istio 最重要的组件。Envoy 的配置并不是微不足道的,这是 Istio 控制平面处理的一个很大的部分。

下一个组件是 Pilot。

飞行员

Pilot 负责平台无关的服务发现、动态负载平衡和路由。它将高级路由规则和弹性从自己的规则 API 转换为 Envoy 配置。这种抽象层允许 Istio 在多个编排平台上运行。Pilot 获取所有特定于平台的信息,将其转换为 Envoy 数据平面配置格式,并通过 Envoy 数据平面 API 传播到每个 Envoy 代理。Pilot 是无状态的;在 Kubernetes 中,所有配置都存储为 etcd 上的自定义资源定义CRD)。

混合器

Mixer 负责抽象度量收集和策略。这些方面通常通过直接访问特定后端的 API 来在服务中实现。这样做的好处是可以减轻服务开发人员的负担,并将控制权交给配置 Istio 的运营商。它还允许您在不改变代码的情况下轻松切换后端。Mixer 可以与以下类型的后端一起工作:

  • 日志记录

  • 授权

  • 配额

  • 遥测

  • 计费

Envoy 代理与 Mixer 之间的交互很简单 - 在每个请求之前,代理调用 Mixer 进行前置条件检查,这可能导致请求被拒绝;在每个请求之后,代理向 Mixer 报告度量。Mixer 具有适配器 API,以便为任意基础设施后端进行扩展。这是其设计的一个重要部分。

Citadel

Citadel 负责 Istio 中的证书和密钥管理。它与各种平台集成,并与它们的身份机制保持一致。例如,在 Kubernetes 中,它使用服务账户;在 AWS 上,它使用 AWS IAM;在 GCP/GKE 上,它可以使用 GCP IAM。Istio PKI 基于 Citadel。它使用 X.509 证书以 SPIFEE 格式作为服务身份的载体。

以下是 Kubernetes 中的工作流程:

  • Citadel 为现有的服务账户创建证书和密钥对。

  • Citadel 监视 Kubernetes API 服务器,以便为新的服务账户提供证书和密钥对。

  • Citadel 将证书和密钥存储为 Kubernetes 秘密。

  • Kubernetes 将秘密挂载到与服务账户关联的每个新 pod 中(这是标准的 Kubernetes 实践)。

  • 当证书过期时,Citadel 会自动旋转 Kubernetes 秘密。

  • Pilot 生成安全命名信息,将服务账户与 Istio 服务关联起来。然后 Pilot 将安全命名信息传递给 Envoy 代理。

我们将要介绍的最后一个主要组件是 Galley。

Galley

Galley 是一个相对简单的组件。它的工作是在不同平台上抽象用户配置。它将摄入的配置提供给 Pilot 和 Mixer。

现在我们已经将 Istio 分解为其主要组件,让我们看看它如何完成作为服务网格的职责。第一能力是流量管理。

使用 Istio 管理流量

Istio 在集群内部的网络级别运行,管理服务之间的通信,以及管理如何将服务暴露给外部世界。它提供了许多功能,如请求路由、负载平衡、自动重试和故障注入。让我们从路由请求开始,回顾所有这些功能。

路由请求

Istio 引入了自己的虚拟服务作为 CRD。Istio 服务具有一个在 Kubernetes 服务中不存在的版本概念。同一图像可以部署为虚拟服务的不同版本。例如,您可以将生产环境或分段环境表示为同一服务的不同版本。Istio 允许您配置规则,确定如何将流量路由到服务的不同版本。

这样的工作方式是 Pilot 将入口和出口规则发送到代理,以确定请求应该由哪里处理。然后在 Kubernetes 中将规则定义为 CRD。以下是一个简单的示例,定义了link-manager服务的虚拟服务:

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
 name: link-manager
spec:
  hosts:
  - link-manager # same as link-manager.default.svc.cluster.local
  http:
  - route:
    - destination:
        host: link-manager

让我们看看 Istio 如何进行负载均衡。

负载均衡

Istio 具有自己的平台无关的服务发现,适配器适用于底层平台(例如 Kubernetes)。它依赖于底层平台管理的服务注册表的存在,并删除不健康的实例以更新其负载均衡池。目前支持三种负载均衡算法:

  • 轮询

  • 随机

  • 加权最少请求

Envoy 还有一些其他算法,例如 Maglev、环形哈希和加权轮询,Istio 目前还不支持。

Istio 还执行定期健康检查,以验证池中的实例实际上是健康的,并且如果它们未通过配置的健康检查阈值,可以暂时将它们从负载均衡中移除。

您可以通过单独的DestinationRule CRD 在目标规则中配置负载均衡,如下所示:

apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: link-manager
spec:
  host: link-manager
  trafficPolicy:
    loadBalancer:
      simple: ROUND_ROBIN

您可以按端口指定不同的算法,如下所示:

apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: link-manager
spec:
  host: link-manager
  trafficPolicy:
    portLevelSettings:
    - port:
        number: 80
      loadBalancer:
        simple: LEAST_CONN
    - port:
        number: 8080
      loadBalancer:
        simple: ROUND_ROBIN

现在,让我们看看 Istio 如何帮助我们自动处理故障。

处理故障

Istio 提供了许多处理故障的机制,包括以下内容:

  • 超时

  • 重试(包括退避和抖动)

  • 速率限制

  • 健康检查

  • 断路器

所有这些都可以通过 Istio CRD 进行配置。

例如,以下代码演示了如何在 TCP 级别(HTTP 也支持)设置link-manager服务的连接限制和超时:

apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
   name: link-manager
spec:
  host: link-manager
  trafficPolicy:
     connectionPool:
       tcp:
         maxConnections: 200
         connectTimeout: 45ms
         tcpKeepalive:
           time: 3600s
           interval: 75s

断路器是通过在给定时间段内明确检查应用程序错误(例如,5XX HTTP 状态代码)来完成的。这是在outlierDetection部分完成的。以下示例每 2 分钟检查 10 个连续错误。如果服务超过此阈值,实例将被从池中驱逐 5 分钟:

apiVersion: networking.istio.io/v1alpha3
kind: DestinationRule
metadata:
  name: link-manager
spec:
  host: link-manager
  trafficPolicy:
     outlierDetection:
       consecutiveErrors: 10
       interval: 2m
       baseEjectionTime: 5m

请注意,就 Kubernetes 而言,服务可能没问题,因为容器正在运行。

Istio 提供了许多处理操作级别错误和故障的方式,这很棒。在测试分布式系统时,重要的是测试某些组件失败时的行为。Istio 通过允许您故意注入故障来支持这种用例。

注入故障进行测试

Istio 的故障处理机制并不能神奇地修复错误。自动重试可以自动处理间歇性故障,但有些故障需要应用程序甚至人工操作员来处理。事实上,Istio 故障处理的错误配置本身可能导致故障(例如,配置的超时时间太短)。可以通过人为注入故障来测试系统在故障存在的情况下的行为。Istio 可以注入两种类型的故障:中止和延迟。您可以在虚拟服务级别配置故障注入。

以下是一个示例,其中将在link-manager服务的 10%的所有请求中添加 5 秒的延迟,以模拟系统的重负载:

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
   name: link-manager
 spec:
   hosts:
   - link-manager
   http:
   - fault:
       delay:
         percent: 10
         fixedDelay: 5s

在压力和故障存在的情况下进行测试是一个巨大的好处,但所有测试都是不完整的。当部署新版本时,您可能希望将其部署给一小部分用户,或者让新版本处理一小部分所有请求。这就是金丝雀部署的用武之地。

进行金丝雀部署

我们之前发现了如何在 Kubernetes 中执行金丝雀部署。如果我们想将 10%的请求转发到我们的金丝雀版本,我们必须部署当前版本的九个 pod 和一个金丝雀 pod 以获得正确的比例。Kubernetes 的负载均衡与部署的 pod 紧密耦合。这是次优的。Istio 有更好的负载均衡方法,因为它在网络级别运行。您可以简单地配置服务的两个版本,并决定百分之多少的请求发送到每个版本,而不管每个版本运行多少个 pod。

以下是一个示例,Istio 将分割流量并将 95%发送到服务的 v1,5%发送到服务的 v2:

apiVersion: networking.istio.io/v1alpha3
kind: VirtualService
metadata:
  name: link-service
spec:
  hosts:
    - reviews
  http:
  - route:
    - destination:
        host: link-service
        subset: v1
       weight: 95
     - destination:
         host: reviews
         subset: v2
       weight: 5

子集 v1 和 v2 是根据标签在目标规则中定义的。在这种情况下,标签是version: v1version: v2

apiVersion: networking.istio.io/v1alpha3
 kind: DestinationRule
 metadata:
   name: link-manager
 spec:
   host: link-manager
   subsets:
   - name: v1
     labels:
       version: v1
   - name: v2
     labels:
       version: v2

这是对 Istio 流量管理能力的相当全面的覆盖,但还有更多可以发现的。让我们把注意力转向安全。

使用 Istio 保护您的集群

Istio 的安全模型围绕三个主题展开:身份、认证和授权。

理解 Istio 的身份

Istio 管理自己的身份模型,可以代表人类用户、服务或服务组。在 Kubernetes 中,Istio 使用 Kubernetes 的服务账户来表示身份。Istio 使用其 PKI(通过 Citadel)为其管理的每个 pod 创建强大的加密身份。它为每个服务账户创建一个 x.509 证书(以 SPIFEE 格式)和一对密钥,并将它们作为秘密注入到 pod 中。Pilot 管理了 DNS 服务名称和允许运行它们的身份之间的映射。当客户端调用服务时,它们可以验证服务确实是由允许的身份运行的,并且可以检测到恶意服务。有了强大的身份验证,让我们来看看 Istio 的身份验证是如何工作的。

使用 Istio 对用户进行身份验证

Istio 认证是基于策略的。有两种类型的策略:命名空间策略和网格策略。命名空间策略适用于单个命名空间。网格策略适用于整个集群。只能有一个网格策略,类型为MeshPolicy,并且必须命名为default。以下是一个要求所有服务使用 mTLS 的网格策略示例:

apiVersion: "authentication.istio.io/v1alpha1"
 kind: "MeshPolicy"
 metadata:
   name: "default"
 spec:
   peers:
   - mtls: {}

命名空间策略的类型为Policy。如果不指定命名空间,则它将应用于默认命名空间。每个命名空间只能有一个策略,并且也必须被称为default。以下策略使用目标选择器仅适用于api-gateway服务和链接服务的端口8080

apiVersion: "authentication.istio.io/v1alpha1"
 kind: "Policy"
 metadata:
   name: "default"
   namespace: "some-ns"
 spec:
   targets:
    - name: api-gateway
    - name: link-manager
      ports:
      - number: 8080

这个想法是为了避免歧义;策略是从服务到命名空间再到网格解析的。如果存在一个狭窄的策略,它将优先。

Istio 提供了通过 mTLS 进行对等体身份验证或通过 JWT 进行源身份验证。

您可以通过peers部分配置对等体身份验证,如下所示:

peers:
   - mtls: {}

您可以通过origins部分配置源身份,如下所示:

origins:
 - jwt:
     issuer: "https://accounts.google.com"
     jwksUri: "https://www.googleapis.com/oauth2/v3/certs"
     trigger_rules:
     - excluded_paths:
       - exact: /healthcheck

如您所见,可以为特定路径配置源身份验证(通过包括或排除路径)。在前面的示例中,/healthcheck路径被豁免于身份验证,这对于经常需要从负载均衡器或远程监控服务调用的健康检查端点是有意义的。

默认情况下,如果存在对等体部分,则使用对等体身份验证。如果没有,则不会设置身份验证。要强制进行源身份验证,可以将以下内容添加到策略中:

principalBinding: USE_ORIGIN

现在我们已经了解了 Istio 如何对请求进行身份验证,让我们来看看它是如何进行授权的。

使用 Istio 授权请求

服务通常公开多个端点。服务 A 可能只允许调用服务 B 的特定端点。服务 A 必须首先对服务 B 进行身份验证,然后还必须对特定请求进行授权。Istio 通过扩展 Kubernetes 用于授权对其 API 服务器的请求的基于角色的访问控制RBAC)来支持这一点。

重要的是要注意,授权默认处于关闭状态。要启用它,您可以创建一个ClusterRbacConfig对象。模式控制授权的启用方式,如下所示:

  • OFF表示授权已禁用(默认)。

  • ON表示授权对整个网格中的所有服务启用。

  • ON_WITH_INCLUSION表示授权对所有包含的命名空间和服务启用。

  • ON_WITH_EXCLUSION表示授权对所有命名空间和服务启用,除了被排除的。

以下是授权在除了kube-systemdevelopment之外的所有命名空间上启用的示例:

apiVersion: "rbac.istio.io/v1alpha1"
 kind: ClusterRbacConfig
 metadata:
   name: default
 spec:
   mode: 'ON_WITH_EXCLUSION'
   exclusion:
     namespaces: ["kube-system", "development"]

实际授权在服务级别操作,与 Kubernetes 的 RBAC 模型非常相似。在 Kubernetes 中有RoleClusterRoleRoleBindingClusterRoleBinding,在 Istio 中有ServiceRoleServiceRoleBinding

基本的细粒度是namespace/service/path/method。您可以使用通配符进行分组。例如,以下角色授予默认命名空间中所有 Delinkcious 管理者和 API 网关的 GET 和 HEAD 访问权限:

apiVersion: "rbac.istio.io/v1alpha1"
 kind: ServiceRole
 metadata:
   name: full-access-reader
   namespace: default
 spec:
   rules:
   - services: ["*-manager", "api-gateway"]
     paths:
     methods: ["GET", "HEAD"]

但是,Istio 还提供了通过约束和属性进行更进一步的控制。您可以通过源命名空间或 IP、标签、请求标头和其他属性来限制规则。

您可以参考istio.io/docs/reference/config/authorization/constraints-and-properties/获取更多详细信息。

一旦您有了ServiceRole,您需要将其与允许执行请求操作的主体(例如服务账户或人类用户)的列表进行关联。以下是如何定义ServiceRoleBinding

apiVersion: "rbac.istio.io/v1alpha1"
 kind: ServiceRoleBinding
 metadata:
   name: test-binding-products
   namespace: default
 spec:
   subjects:
   - user: "service-account-delinkcious"
   - user: "istio-ingress-service-account"
     properties:
       request.auth.claims[email]: "the.gigi@gmail.com"
   roleRef:
     kind: ServiceRole
     name: "full-access-reader"

通过将主体用户设置为*,可以使角色对经过身份验证或未经身份验证的用户公开可用。

Istio 授权有很多内容,我们无法在这里涵盖。您可以阅读以下主题:

  • TCP 协议的授权

  • 宽容模式(实验性)

  • 调试授权问题

  • 通过 Envoy 过滤器进行授权

一旦请求在那里得到授权,如果未能符合策略检查,仍可能被拒绝。

使用 Istio 执行策略

Istio 策略执行类似于 Kubernetes 中的准入控制器的工作方式。Mixer 有一组适配器,在请求处理之前和之后被调用。在我们进一步深入之前,重要的是要注意,默认情况下策略执行是禁用的。如果您使用 helm 安装 Istio,可以通过提供以下标志来启用它:

--set global.disablePolicyChecks=false.

在 GKE 上,它是启用的;以下是如何检查这一点:

$ kubectl -n istio-system get cm istio -o jsonpath="{@.data.mesh}" | grep disablePolicyChecks
disablePolicyChecks: false

如果结果是disablePolicyChecks: false,那么它已经启用。否则,通过编辑 Istio ConfigMap 并将其设置为 false 来启用它。

一种常见的策略类型是速率限制。您可以通过配置配额对象、将它们绑定到特定服务并定义混合器规则来强制执行速率限制。在 Istio 演示应用程序中可以找到一个很好的例子,网址为raw.githubusercontent.com/istio/istio/release-1.1/samples/bookinfo/policy/mixer-rule-productpage-ratelimit.yaml

您还可以通过创建 Mixer 适配器来添加自己的策略。有三种内置类型的适配器,如下:

  • 检查

  • 配额

  • 报告

这并不是微不足道的;您将不得不实现一个可以处理专用模板中指定的数据的 gRPC 服务。现在,让我们来看看 Istio 为我们收集的指标。

使用 Istio 收集指标

Istio 在每个请求之后收集指标。指标被发送到 Mixer。Envoy 是主要的指标生产者,但如果愿意,您也可以添加自己的指标。指标的配置模型基于多个 Istio 概念:属性、实例、模板、处理程序、规则和 Mixer 适配器。

以下是一个计算所有请求并将其报告为request-count指标的示例实例:

apiVersion: config.istio.io/v1alpha2
 kind: instance
 metadata:
   name: request-count
   namespace: istio-system
 spec:
   compiledTemplate: metric
   params:
     value: "1" # count each request
     dimensions:
       reporter: conditional((context.reporter.kind | "inbound") == "outbound", "client", "server")
       source: source.workload.name | "unknown"
       destination: destination.workload.name | "unknown"
       message: '"counting requests..."'
     monitored_resource_type: '"UNSPECIFIED"'

现在,我们可以配置一个 Prometheus 处理器来接收指标。Prometheus 是一个编译适配器(它是 Mixer 的一部分),因此我们可以在规范中直接使用它。spec | params | metrics 部分包含一种 COUNTER,一个 Prometheus 指标名称(request_count),以及最重要的,我们刚刚定义的实例名称,它是指标的来源:

apiVersion: config.istio.io/v1alpha2
 kind: handler
 metadata:
   name: request-count-handler
   namespace: istio-system
 spec:
   compiledAdapter: prometheus
   params:
     metrics:
     - name: request_count # Prometheus metric name
       instance_name: request-count.instance.istio-system # Mixer instance name (fully-qualified)
       kind: COUNTER
       label_names:
       - reporter
       - source
       - destination
       - message

最后,我们用一条规则将所有内容串联起来,如下所示:

apiVersion: config.istio.io/v1alpha2
 kind: rule
 metadata:
   name: prom-request-counter
   namespace: istio-system
 spec:
   actions:
   - handler: request-count-handler
     instances: [ request-count ]

好的,Istio 确实非常强大。但有没有一些情况下你不应该使用 Istio 呢?

何时应避免使用 Istio?

Istio 提供了大量价值。然而,这些价值并非没有代价。Istio 的侵入性和复杂性带来了一些显著的缺点。在采用 Istio 之前,您应该考虑这些缺点:

  • 在已经复杂的 Kubernetes 之上增加额外的概念和管理系统,使得学习曲线变得非常陡峭。

  • 配置问题的故障排除具有挑战性。

  • 与其他项目的整合可能缺失或部分完成(例如,NATS 和 Telepresence)。

  • 代理会增加延迟并消耗 CPU 和内存资源。

如果您刚开始接触 Kubernetes,我建议您先熟悉它,然后再考虑使用 Istio。

既然我们了解了 Istio 的核心内容,让我们探讨 Delinkcious 如何从 Istio 中获益。

Delinkcious 在 Istio 上

通过 Istio,Delinkcious 可以潜在地摆脱大量额外负担。那么,为什么将此功能从 Delinkcious 服务或 Go kit 中间件迁移到 Istio 是一个好主意呢?

嗯,原因在于这种功能通常与应用领域无关。我们投入了大量工作来仔细分离关注点并将 Delinkcious 领域与它们的部署和管理方式隔离。然而,只要所有这些关注点都由微服务本身处理,我们就需要每次进行操作更改时修改代码并重新构建它们。即使很多这些操作是数据驱动的,它也可能使故障排除和调试问题变得困难,因为当故障发生时,并不总是容易确定它是由于领域代码中的错误还是操作代码中的错误。

让我们来看一些具体的例子,其中 Istio 可以简化 Delinkcious。

移除服务间的相互认证

如您所记,在第六章,在 Kubernetes 上保护微服务中,我们创建了 link-manager 服务与 social-graph-manager 服务之间的相互秘密:

$ kubectl get secret | grep mutual
link-mutual-auth             Opaque          1      9d
 social-graph-mutual-auth    Opaque          1      5d19h

这需要大量的协调和明确的工作来编码秘密,然后将秘密挂载到容器中:

    spec:
       containers:
       - name: link-manager
         image: g1g1/delinkcious-link:0.3
         imagePullPolicy: Always
         ports:
         - containerPort: 8080
         envFrom:
         - configMapRef:
             name: link-manager-config
         volumeMounts:
         - name: mutual-auth
           mountPath: /etc/delinkcious
           readOnly: true
       volumes:
       - name: mutual-auth
         secret:
           secretName: link-mutual-auth

然后,链接管理器必须通过我们实现的 auth_util 包获取秘密,并将其作为请求头注入:

// encodeHTTPGenericRequest is a transport/http.EncodeRequestFunc that
 // JSON-encodes any request to the request body. Primarily useful in a client.
 func encodeHTTPGenericRequest(_ context.Context, r *http.Request, request interface{}) error {
     var buf bytes.Buffer
     if err := json.NewEncoder(&buf).Encode(request); err != nil {
         return err
     }
     r.Body = ioutil.NopCloser(&buf)

     if os.Getenv("DELINKCIOUS_MUTUAL_AUTH") != "false" {
         token := auth_util.GetToken(SERVICE_NAME)
         r.Header["Delinkcious-Caller-Token"] = []string{token}
     }

     return nil
 }

最后,社交图谱管理器必须意识到这一方案,并明确检查调用者是否被允许:

func decodeGetFollowersRequest(_ context.Context, r *http.Request) (interface{}, error){ 
    if os.Getenv("DELINKCIOUS_MUTUAL_AUTH") != "false" { 
        token := r.Header["Delinkcious-Caller-Token"] 
        if len(token) == 0 || token[0] == "" { 
            return nil, errors.New("Missing caller token") 
        }
        if !auth_util.HasCaller("link-manager", token[0]) {
         return nil, errors.New("Unauthorized caller")
        }
    }
 ...
}

这涉及到大量与服务本身无关的工作。想象一下,管理数百个相互作用的微服务中的数千种方法。这种方法繁琐、易错,并且每当增加或删除交互时,都需要对两个服务进行代码更改。

使用 Istio,我们可以完全将其外部化为一个角色和一个角色绑定。以下是一个允许您调用/following端点的 GET 方法的角色:

apiVersion: "rbac.istio.io/v1alpha1"
 kind: ServiceRole
 metadata:
   name: get-following
   namespace: default
 spec:
   rules:
   - services: ["social-graph.default.svc.cluster.local"]
     paths: ["/following"]
     methods: ["GET"]

为了仅允许链接服务调用该方法,我们可以将角色绑定到link-manager服务帐户作为主体用户:

apiVersion: "rbac.istio.io/v1alpha1"
 kind: ServiceRoleBinding
 metadata:
   name: get-following
   namespace: default
 spec:
   subjects:
   - user: "cluster.local/ns/default/sa/link-manager"
   roleRef:
     kind: ServiceRole
     name: "get-following"

如果稍后我们需要允许其他服务调用/following端点,我们可以向此角色绑定添加更多主体。社交服务本身不需要知道哪些服务被允许调用其方法。调用服务不需要明确提供任何凭据。服务网格会处理所有这些。

Istio 真正能帮助 Delinkcious 的另一个领域是金丝雀部署。

利用更佳的金丝雀部署

在第十一章,部署微服务中,我们使用 Kubernetes 部署和服务进行金丝雀部署。为了将 10%的流量转向金丝雀版本,我们将当前版本扩展到九个副本,并创建了一个金丝雀部署,为新版本设置一个副本。我们为两个部署使用了相同的标签(svc: linkapp: manager)。

link-manager服务在两个部署前均匀地分配了负载,实现了我们目标的 90/10 分割:

$ kubectl scale --replicas=9 deployment/green-link-manager
 deployment.extensions/green-link-manager scaled

 $ kubectl get po -l svc=link,app=manager
 NAME                                 READY  STATUS    RESTARTS   AGE
 green-link-manager-5874c6cd4f-2ldfn   1/1   Running   10         15h
 green-link-manager-5874c6cd4f-9csxz   1/1   Running   0          52s
 green-link-manager-5874c6cd4f-c5rqn   1/1   Running   0          52s
 green-link-manager-5874c6cd4f-mvm5v   1/1   Running   10         15h
 green-link-manager-5874c6cd4f-qn4zj   1/1   Running   0          52s
 green-link-manager-5874c6cd4f-r2jxf   1/1   Running   0          52s
 green-link-manager-5874c6cd4f-rtwsj   1/1   Running   0          52s
 green-link-manager-5874c6cd4f-sw27r   1/1   Running   0          52s
 green-link-manager-5874c6cd4f-vcj9s   1/1   Running   10         15h
 yellow-link-manager-67847d6b85-n97b5  1/1   Running   4          6m20s

这虽然可行,但它将金丝雀部署与扩展部署耦合在一起。这可能会很昂贵,特别是如果您需要运行金丝雀部署一段时间直到您确信它没问题。理想情况下,您不应该需要为了将一定百分比的流量转向新版本而创建更多的 pod。

Istio 的子集概念的流量整形能力完美地解决了这一用例。以下虚拟服务将流量按 90/10 的比例分配给名为v0.5的子集和另一个名为canary的子集:

apiVersion: networking.istio.io/v1alpha3
 kind: VirtualService
 metadata:
   name: social-graph-manager
 spec:
   hosts:
     - social-graph-manager
   http:
   - route:
     - destination:
         host: social-graph-manager
         subset: v0.5
       weight: 90
     - destination:
         host: social-graph-manager
         subset: canary
       weight: 10

使用 Istio 的虚拟服务和子集进行金丝雀部署对 Delinkcious 非常有利。Istio 还能帮助进行日志记录和错误报告。

自动日志记录和错误报告

当在 GKE 上运行 Delinkcious 并使用 Istio 插件时,您将获得与 Stackdriver 的自动集成,这是一个一站式监控商店,包括指标、集中式日志记录、错误报告和分布式跟踪。以下是搜索link-manager日志时的 Stackdriver 日志查看器:

或者,您可以通过下拉列表按服务名称进行筛选。以下是指定 api-gateway 时的样子:

有时,错误报告视图正是您所需要的:

然后,您可以深入任何错误并获取大量额外信息,这将帮助您理解出了什么问题以及如何修复它:

尽管 Istio 提供了大量价值,并且在 Stackdriver 的情况下,您还能享受到自动设置的好处,但它并非总是顺风顺水——它存在一些限制和粗糙之处。

适应 NATS

我在将 Istio 部署到 Delinkcious 集群时发现的一个限制是,NATS 与 Istio 不兼容,因为它需要直接连接,并且在 Envoy 代理劫持通信时会中断。解决方案是阻止 Istio 注入边车容器,并接受 NATS 将不会被管理。将NatsCluster CRD 添加到 Pod 规范的以下注释中为我们完成了这项工作:sidecar.istio.io/inject: "false":

apiVersion: nats.io/v1alpha2
 kind: NatsCluster
 metadata:
   name: nats-cluster
 spec:
   pod:
     # Disable istio on nats pods
     annotations:
       sidecar.istio.io/inject: "false"
   size: 1
   version: "1.4.0"

前面的代码是带有注释的完整NatsCluster资源定义。

审视 Istio 的影响范围

Istio 在集群中部署了大量内容,因此让我们回顾其中一些。值得庆幸的是,Istio 控制平面被隔离在其专有的istio-system命名空间中,但 CRD 始终是集群范围的,而 Istio 在这些方面并不吝啬:

$ kubectl get crd -l k8s-app=istio -o custom-columns="NAME:.metadata.name"

 NAME
 adapters.config.istio.io
 apikeys.config.istio.io
 attributemanifests.config.istio.io
 authorizations.config.istio.io
 bypasses.config.istio.io
 checknothings.config.istio.io
 circonuses.config.istio.io
 deniers.config.istio.io
 destinationrules.networking.istio.io
 edges.config.istio.io
 envoyfilters.networking.istio.io
 fluentds.config.istio.io
 gateways.networking.istio.io
 handlers.config.istio.io
 httpapispecbindings.config.istio.io
 httpapispecs.config.istio.io
 instances.config.istio.io
 kubernetesenvs.config.istio.io
 kuberneteses.config.istio.io
 listcheckers.config.istio.io
 listentries.config.istio.io
 logentries.config.istio.io
 memquotas.config.istio.io
 metrics.config.istio.io
 noops.config.istio.io
 opas.config.istio.io
 prometheuses.config.istio.io
 quotas.config.istio.io
 quotaspecbindings.config.istio.io
 quotaspecs.config.istio.io
 rbacconfigs.rbac.istio.io
 rbacs.config.istio.io
 redisquotas.config.istio.io
 reportnothings.config.istio.io
 rules.config.istio.io
 servicecontrolreports.config.istio.io
 servicecontrols.config.istio.io
 serviceentries.networking.istio.io
 servicerolebindings.rbac.istio.io
 serviceroles.rbac.istio.io
 signalfxs.config.istio.io
 solarwindses.config.istio.io
 stackdrivers.config.istio.io
 statsds.config.istio.io
 stdios.config.istio.io
 templates.config.istio.io
 tracespans.config.istio.io
 virtualservices.networking.istio.io

除了所有那些 CRD 之外,Istio 将其所有组件安装到 Istio 命名空间中:

$ kubectl -n istio-system get all -o name
 pod/istio-citadel-6995f7bd9-7c7x9
 pod/istio-egressgateway-57b96d87bd-cnc2s
 pod/istio-galley-6d7dd498f6-b29sk
 pod/istio-ingressgateway-ddd557db7-glwm2
 pod/istio-pilot-5765d76b8c-d9hq7
 pod/istio-policy-5b47b88467-x7pqf
 pod/istio-sidecar-injector-6b9fbbfcf6-fhc4k
 pod/istio-telemetry-65dcd9ff85-bkjtd
 pod/promsd-7b49dcb96c-wrfs8
 service/istio-citadel
 service/istio-egressgateway
 service/istio-galley
 service/istio-ingressgateway
 service/istio-pilot
 service/istio-policy
 service/istio-sidecar-injector
 service/istio-telemetry
 service/promsd
 deployment.apps/istio-citadel
 deployment.apps/istio-egressgateway
 deployment.apps/istio-galley
 deployment.apps/istio-ingressgateway
 deployment.apps/istio-pilot
 deployment.apps/istio-policy
 deployment.apps/istio-sidecar-injector
 deployment.apps/istio-telemetry
 deployment.apps/promsd
 replicaset.apps/istio-citadel-6995f7bd9
 replicaset.apps/istio-egressgateway-57b96d87bd
 replicaset.apps/istio-galley-6d7dd498f6
 replicaset.apps/istio-ingressgateway-ddd557db7
 replicaset.apps/istio-pilot-5765d76b8c
 replicaset.apps/istio-policy-5b47b88467
 replicaset.apps/istio-sidecar-injector-6b9fbbfcf6
 replicaset.apps/istio-telemetry-65dcd9ff85
 replicaset.apps/promsd-7b49dcb96c
 horizontalpodautoscaler.autoscaling/istio-egressgateway
 horizontalpodautoscaler.autoscaling/istio-ingressgateway
 horizontalpodautoscaler.autoscaling/istio-pilot
 horizontalpodautoscaler.autoscaling/istio-policy
 horizontalpodautoscaler.autoscaling/istio-telemetry

最后,当然,Istio 将其边车代理安装到每个 Pod 中(除了 Nats,我们在那里禁用了它)。如您所见,默认命名空间中的每个 Pod 都有两个容器(在READY列下显示 2/2)。一个容器负责工作,另一个则是 Istio 代理边车容器:

$ kubectl get po
 NAME READY STATUS RESTARTS AGE
 api-gateway-5497d95c74-zlgnm 2/2 Running 0 4d11h
 link-db-7445d6cbf7-wdfsb 2/2 Running 0 4d22h
 link-manager-54968ff8cf-vtpqr 2/2 Running 1 4d13h
 nats-cluster-1 1/1 Running 0 4d20h
 nats-operator-55dfdc6868-2b57q 2/2 Running 3 4d22h
 news-manager-7f447f5c9f-n2v2v 2/2 Running 1 4d20h
 news-manager-redis-0 2/2 Running 0 4d22h
 social-graph-db-7d8ffb877b-nrzxh 2/2 Running 0 4d11h
 social-graph-manager-59b464456f-48lrn 2/2 Running 1 4d11h
 trouble-64554479d-rjszv 2/2 Running 0 4d17h
 user-db-0 2/2 Running 0 4d22h
 user-manager-699458447-9h64n 2/2 Running 2 4d22h

如果您认为 Istio 过于庞大和复杂,您可能仍希望通过追求替代方案来享受服务网格的好处。

Istio 的替代方案

Istio 虽然势头强劲,但并不一定是最适合您的服务网格。让我们来看看其他一些服务网格并考虑它们的特性。

Linkerd 2.0

Buoyant 是在 2016 年创造了术语“服务网格”的公司,并推出了第一个服务网格——Linkerd。它基于 Twitter 的 Finagle,并用 Scala 实现。此后,Buoyant 开发了一个专注于 Kubernetes 的新服务网格,称为 Conduit(用 Rust 和 Go 实现),后来(在 2018 年 7 月)将其改名为 Linkerd 2.0。它像 Istio 一样是一个 CNCF 项目。Linkerd 2.0 还使用可以自动或手动注入的旁路容器。

由于其轻量级设计和在 Rust 中更紧密实现数据平面代理,Linkerd 2.0 应该优于 Istio,并在控制平面消耗更少的资源。您可以参考以下资源获取更多信息:

Buoyant 是一家较小的公司,似乎在功能上略逊于 Istio。

Envoy

Istio 的数据平面是 Envoy,它完成所有繁重的工作。您可能会发现 Istio 的控制平面过于复杂,并希望删除这一间接层,并构建自己的控制平面直接与 Envoy 交互。在某些特定情况下,这可能很有用;例如,如果您想使用 Istio 不支持的 Envoy 提供的负载均衡算法。

HashiCorp Consul

Consul 并不完全符合服务网格的所有要求,但它提供了服务发现、服务身份验证和 mTLS 授权。它并不特定于 Kubernetes,并且没有得到 CNCF 的认可。如果您已经在使用 Consul 或其他 HashiCorp 产品,您可能更喜欢将其用作服务网格。

AWS App Mesh

如果您在 AWS 上运行基础设施,您应该考虑 AWS App Mesh。这是一个较新的项目,专门针对 AWS,并且还使用 Envoy 作为其数据平面。可以肯定地假设它将与 AWS IAM 网络和监控技术最好地集成。目前尚不清楚 AWS App Mesh 是否将成为 Kubernetes 的更好的服务网格,或者其主要目的是为 ECS 提供服务网格的好处- AWS 的专有容器编排解决方案。

其他

还有一些其他的服务网格。我只是在这里提一下,这样你就可以进一步了解它们。其中一些与 Istio 有某种形式的集成。它们的价值并不总是很清楚,因为它们不是开放的:

  • Aspen Mesh

  • Kong Mesh

  • AVI Networks 通用服务网格

无网格选项

您可以完全避免使用服务网格,而使用诸如 Go kit、Hystrix 或 Finagle 之类的库。您可能会失去外部服务网格的好处,但如果您严格控制所有微服务,并且它们都使用相同的编程语言,那么库方法可能对您非常有效。这在概念上和操作上更简单,它将管理横切关注点的责任转移到开发人员身上。

总结

在本章中,我们看了服务网格,特别是 Istio。Istio 是一个复杂的项目;它位于 Kubernetes 之上,并创建了一种带有代理的影子集群。Istio 具有出色的功能;它可以在非常精细的级别上塑造流量,提供复杂的身份验证和授权,执行高级策略,收集大量信息,并帮助扩展您的集群。

我们介绍了 Istio 的架构,其强大的功能,并探讨了 Delinkcious 如何从这些功能中受益。

然而,Istio 远非简单。它创建了大量的自定义资源,并且以复杂的方式重叠和扩展现有的 Kubernetes 资源(VirtualService 与 Service)。

我们还回顾了 Istio 的替代方案,包括 Linkerd 2.0、纯 Envoy、AWS App Mesh 和 Consul。

到目前为止,您应该对服务网格的好处以及 Istio 对您的项目能做什么有了很好的理解。您可能需要进行一些额外的阅读和实验,以便做出明智的决定,即立即将 Istio 纳入您的系统,考虑其中一种替代方案,或者只是等待。

我相信服务网格和特别是 Istio 将非常重要,并且将成为纳入大型 Kubernetes 集群的标准最佳实践。

在下一章中,也是最后一章,我们将继续讨论微服务、Kubernetes 和其他新兴趋势的未来,比如无服务器。

进一步阅读

您可以参考以下资源,了解本章涵盖的更多信息:

第十四章:微服务和 Kubernetes 的未来

明天的软件系统将会更大、更复杂,能够处理更多的数据,并且对我们的世界产生更大的影响。想想无人驾驶汽车和无处不在的机器人。人类处理复杂性的能力不会扩展。这意味着我们将不得不采用分而治之的方法来构建这些复杂的软件系统。基于微服务的架构将继续取代单体架构。然后,挑战将转移到协调所有这些微服务成为一个连贯整体。这就是 Kubernetes 作为标准编排解决方案的作用所在。

在本章中,我们将讨论微服务和 Kubernetes 的近期发展。我们将关注近期的发展,因为创新的速度令人惊叹,试图展望更远的未来是徒劳的。长期愿景是,人工智能可能会进步到大部分软件开发可以自动化的程度。在这一点上,人类处理复杂性的限制可能不适用,而由人工智能开发的软件将无法被人类理解。

因此,让我们把遥远的未来放在一边,以亲身实践的精神讨论新兴技术、标准和趋势,这些将在未来几年内变得相关,你可能想要了解。

我们将涵盖一些微服务主题,例如以下内容:

  • 微服务与无服务器函数

  • 微服务、容器和编排

  • gRPC/gRPC-Web

  • HTTP/3

  • GraphQL

我们还将讨论一些 Kubernetes 主题:

  • Kubernetes 的可扩展性

  • 服务网格集成

  • 基于 Kubernetes 的无服务器计算

  • Kubernetes 和虚拟机

  • 集群自动缩放

  • 使用操作员

让我们从微服务开始。

微服务的未来

微服务是构建现代大规模系统的主要方法。但是,它们是否会继续是首选?让我们找出答案。

微服务与无服务器函数

关于微服务的未来最大的问题之一是,无服务器函数是否会使微服务过时。答案绝对是否定的。无服务器函数有许多很好的优点,以及一些严重的限制,比如冷启动和时间限制。当你的函数调用其他函数时,这些限制会累积。如果你想应用指数退避的重试逻辑,函数的执行时间限制就会成为一个很大的问题。一个长时间运行的服务可以保持本地状态和与数据存储的连接,并更快地响应请求。但对我来说,无服务器函数最大的问题是它们代表一个单一的函数,相当于一个服务的单一端点。我发现将一个封装完整领域的服务抽象出来是非常有价值的。如果你试图将一个有 10 个方法的服务转移到无服务器函数,那么你将遇到管理问题。

所有这些 10 个功能都需要访问相同的数据存储,并且可能需要修改多个功能。所有这些功能都需要类似的访问、配置和凭据来访问各种依赖关系。微服务将继续是大型、云原生分布式系统的支柱。然而,很多工作将被转移到无服务器函数,这是有道理的。我们可能会看到一些系统完全由无服务器函数组成,但这将是被迫的,并且必须做出妥协。

让我们看看微服务和容器之间的共生关系。

微服务、容器和编排

当你将一个单体应用程序分解成微服务,或者从头开始构建一个基于微服务的系统时,你最终会得到很多服务。你需要打包、部署、升级和配置所有这些微服务。容器解决了打包问题。没有容器,很难扩展基于微服务的系统。随着系统中微服务数量的增加,需要一个专门的解决方案来编排各种容器和进行最佳调度。这就是 Kubernetes 的优势所在。分布式系统的未来是更多的微服务,打包成更多的容器,需要 Kubernetes 来管理它们。我在这里提到 Kubernetes,是因为在 2019 年,Kubernetes 赢得了容器编排之战。

许多微服务的另一个方面是它们需要通过网络相互通信。在单体架构中,大多数交互只是函数调用,但在微服务环境中,许多交互需要命中端点或进行远程过程调用。这就是 gRPC 的作用。

gRPC 和 gRPC-Web

gRPC 是谷歌的远程过程调用协议。多年来,出现了许多 RPC 协议。我仍然记得 CORBA 和 DCOM 以及 Java RMI 的日子。快进到现代网络,REST 击败了 SOAP 成为 Web API 领域的大猩猩。但是,如今,gRPC 正在击败 REST。gRPC 提供了基于契约的模型,具有强类型、基于 protobuf 的高效负载,并自动生成客户端代码。这种组合非常强大。REST 的最后避难所是其无处不在和从浏览器中运行的 Web 应用程序调用携带 JSON 负载的 REST API 的便利性。

但是,即使这种优势正在消失。您始终可以在 gRPC 服务前面放置一个与 REST 兼容的 gRPC 网关,但我认为这是一种权宜之计。另一方面,gRPC-web 是一个完整的 JavaScript 库,可以让 Web 应用程序简单地调用 gRPC 服务。参见github.com/grpc/grpc-web/tree/master/packages/grpc-web

GraphQL

如果 gRPC 是集群内的 REST 杀手,那么 GraphQL 就是边缘的 REST 杀手。GraphQL 只是一种更优越的范例。它给前端开发人员很大的自由来发展他们的设计。它将前端的需求与后端的严格 API 解耦,并作为完美的 BFF(面向前端的后端)模式。参见samnewman.io/patterns/architectural/bff/

与 gRPC 合同类似,GraphQL 服务的结构化模式对于大规模系统非常诱人。

此外,GraphQL 解决了传统 REST API 中可怕的N+1问题,其中您首先从 REST 端点获取N个资源列表,然后您必须进行N次更多的调用(每个资源一个)以获取列表中每个N项的相关资源。

我预计随着开发人员变得更加舒适,意识增强,工具改进和学习材料变得更加可用,GraphQL 将获得越来越多的关注。

HTTP/3 即将到来

Web 是建立在 HTTP 之上的。毫无疑问。这个协议的表现非常出色。以下是一个快速回顾:1991 年,Tim-Berneres-Lee 提出了 HTTP 0.9 来支持他对万维网的构想。1996 年,HTTP 工作组发布了信息性 RFC 1945,推出了 HTTP 1.0,以促进 20 世纪 90 年代末的互联网繁荣。1997 年,发布了 HTTP 1.1 的第一个官方 RFC 2068。1999 年,RFC 2616 对 HTTP 1.1 进行了一些改进,并成为了两个十年的主要标准。2015 年,基于 Google 的 SPDY 协议发布了 HTTP/2,并且所有主要浏览器都支持它。

gRPC 建立在 HTTP/2 之上,修复了以前版本的 HTTP 中的许多问题,并提供了以下功能:

  • 二进制帧和压缩

  • 使用流进行多路复用(在同一个 TCP 连接上进行多个请求)

  • 更好的流量控制

  • 服务器推送

听起来很不错。HTTP/3 会给我们带来什么?它提供了与 HTTP/2 相同的功能集。然而,HTTP/2 基于 TCP,不支持流。这意味着流是在 HTTP/2 级别实现的。HTTP/3 基于 QUIC,这是一种基于 UDP 的可靠传输。细节超出了范围,但底线是 HTTP/3 将具有更好的性能,并且始终是安全的。

广泛采用 HTTP/3 可能仍需要一段时间,因为许多企业会在其网络上阻止或限制 UDP。然而,其优势是令人信服的,而且相比 REST API,基于 HTTP/3 的 gRPC 将具有更大的性能优势。

这些是将影响微服务的主要未来趋势。让我们看看 Kubernetes 的下一步发展。

Kubernetes 的未来

Kubernetes 是不可或缺的。我敢做一个大胆的预测,说它将在未来几十年内一直存在。它无可否认地是容器编排领域的当前领导者,但更重要的是,它的设计方式非常可扩展。任何潜在的改进都可以建立在 Kubernetes 提供的良好基础之上(例如服务网格),或者替换这些基础(如网络插件、存储插件和自定义调度器)。很难想象会有一个全新的平台能够使 Kubernetes 过时,而不是改进和整合它。

此外,Kubernetes 背后的行业动力以及它在 CNCF 的开放开发和管理方式都很鼓舞人心。尽管它起源于谷歌,但没有人认为它是谷歌的项目。它被视为一个真正使每个人受益的开源项目。

现在,考虑到 Kubernetes 满足了整个领域的需求,从业余爱好者在笔记本电脑上玩转本地 Kubernetes,到在本地或云端进行测试的开发人员,一直到需要对其自己的本地数据中心进行认证和支持的大型企业。

对 Kubernetes 唯一的批评就是它很难学。目前是这样,但它会变得越来越容易。有很多好的培训材料。开发人员和运维人员会获得经验。很容易找到信息,社区也很大且充满活力。

很多人说 Kubernetes 很快就会变得无聊,并成为一个看不见的基础设施层。我不认同这个观点。Kubernetes 体验中的一些难点,比如设置集群和在集群中安装大量额外软件,会变得无聊,但我认为在未来 5 年里我们会看到各个方面的创新。

让我们深入了解具体的技术和趋势。

Kubernetes 的可扩展性

这是一个很容易的选择。Kubernetes 一直被设计为一个可扩展的平台。但是,一些扩展机制需要合并到主 Kubernetes 存储库中。Kubernetes 开发人员早早地意识到了这些限制,并在各个方面引入了更松散耦合的机制来扩展 Kubernetes,并替换过去被视为核心组件的部分。

抽象容器运行时

Docker 曾经是 Kubernetes 支持的唯一容器运行时。然后它为现在已废弃的 RKT 运行时添加了特殊支持。然而,后来,它引入了容器运行时接口CRI)作为通过标准接口集成任何容器运行时的方法。以下是一些实现 CRI 并可在 Kubernetes 中使用的运行时:

  • Docker(当然)

  • CRI-O(支持任何 OCI 镜像)

  • Containerd(于 2019 年 2 月成为 CNCF 毕业生)

  • Frakti(Kata 容器)

  • PouchContainer(P2P 镜像分发,可选的基于 VM)

抽象网络

Kubernetes 网络始终需要 Container Networking Interface(CNI)插件。这是另一个 CNCF 项目。它允许在网络和网络安全领域进行大量创新。

您可以在这里找到支持 CNI(超出 Kubernetes 范围)的平台的长列表,以及更长的插件列表github.com/containernetworking/cni.

我期望 CNI 仍然是网络解决方案的标准接口。一个非常有趣的项目是 Cilium,它利用扩展的伯克利数据包过滤器(eBPF)在 Linux 内核级别提供非常高性能的网络和安全性,这可能抵消一些服务网格边车代理的开销。

抽象存储

Kubernetes 具有基于卷和持久卷索赔的抽象存储模型。它支持大量的内部存储解决方案。这意味着这些存储解决方案必须内置到 Kubernetes 代码库中。

早期(在 Kubernetes 1.2 中),Kubernetes 团队引入了一种特殊类型的插件,称为 FlexVolume,它提供了一个用于 out-of-tree 插件的接口。存储提供商可以提供实现 FlexVolume 接口的自己的驱动程序,并且可以作为存储层而不修改 Kubernetes 本身。但是,FlexVolume 方法仍然相当笨拙。它需要在每个节点上安装特殊的驱动程序,并且在某些情况下还需要在主节点上安装。

在 Kubernetes 1.13 中,容器存储接口(CSI)成熟到了一般可用(GA)状态,并提供了一个基于现代 gRPC 的接口,用于实现 out-of-tree 存储插件。很快,Kubernetes 甚至将通过 CSI 支持原始块存储(在 Kubernetes 1.14 中引入为 beta)。

以下图表说明了 CSI 在 Kubernetes 集群中的位置,以及它如何整洁地隔离存储提供商:

容器存储接口

趋势是用基于 CSI 的实现替换所有 in-tree 和 FlexVolume 插件,这将允许从核心 Kubernetes 代码库中删除大部分功能。

云提供商接口

Kubernetes 在云平台(如 Google 的 GKE,微软的 AKS,亚马逊的 EKS,阿里巴巴的 AliCloud,IBM 的云 Kubernetes 服务,DigitalOcean 的 Kubernetes 服务,VMware 的 Cloud PKS 和甲骨文的 Kubernetes 容器引擎)中取得了很大成功。

在早期,将 Kubernetes 集成到云平台需要大量的工作,并涉及定制多个 Kubernetes 控制平面组件,如 API 服务器、kubelet 和控制器管理器。

为了让云平台提供商更容易,Kubernetes 引入了云控制器管理器CCM)。CCM 通过一组稳定的接口将云提供商需要实现的所有部分抽象出来。现在,Kubernetes 与云提供商之间的接触点被正式化,更容易理解并确保集成成功。让我们看看以下图表:

云控制器管理器

上述图表说明了 Kubernetes 集群与主机云平台之间的交互。

服务网格集成

我在第十三章的结尾提到过,服务网格-使用 Istio,服务网格非常重要。它们补充了 Kubernetes 并增加了很多价值。虽然 Kubernetes 提供了资源的管理和调度以及可扩展的 API,但服务网格提供了管理集群中容器之间流量的下一层。

这种共生关系非常强大。在 GKE 上,Istio 已经只需点击一个按钮即可。我预计大多数 Kubernetes 发行版都会提供安装 Istio(或者在 EKS 的情况下可能是 AWS 应用程序网格)作为初始设置的选项。

在这一点上,我预计很多其他解决方案会将 Istio 视为标准组件并在其基础上构建。在这个领域值得关注的一个有趣项目是 Kyma(kyma-project.io/),它旨在轻松安装一系列最佳的云原生组件。Kyna 采用可扩展和开放的 Kubernetes,并添加了一套有见地的、良好集成的组件,例如以下内容:

  • Helm

  • Dex

  • Istio

  • Knative

  • Prometheus

  • Grafana

  • Jeager

  • Kubeless

  • Loki

  • Velero(前身为 Ark)

  • Minio

Kubernetes 上的无服务器计算

正如我们在第九章中讨论的,在 Kubernetes 上运行无服务器任务,无服务器计算非常流行。市面上有很多解决方案。让我们在这里区分两个不同的解决方案:

  • 函数即服务FaaS

  • 服务器即服务SaaS

FaaS SaaS
FaaS意味着您可以启动一个函数,无论是作为打包成镜像的源代码,还是作为预打包的镜像。然后,这个镜像被安排在您的集群上,并运行到完成。您仍然需要管理和扩展集群中的节点,并确保您有足够的容量来运行长时间运行的服务和函数。

显然,您可以混合搭配,并运行 Kubernetes 集群自动缩放器,也可以运行一些函数作为服务框架,以获得两者的好处。

到目前为止,一切都很好。但是,Kubernetes 通常部署在具有自己非 Kubernetes 解决方案的公共云平台上。例如,在 AWS 中,您有 Lambda 函数(FaaS)以及 Fargate(SaaS)。Microsoft Azure 拥有 Azure Functions 和使用虚拟 kubelet 的容器实例,您可以弹性地扩展您的 AKS 集群。谷歌有 Cloud Functions 和 Cloud Run。

看到公共云提供商如何将他们的产品与 Kubernetes 集成将会很有趣。Google Cloud Run 是建立在 Knative 之上的,并且已经可以在您的 GKE 集群或 Google 的基础设施上运行(因此它独立于 Kubernetes)。

我预测 Knative 将成为另一个标准组件,其他 FaaS 解决方案将在 Kubernetes 上使用它作为构建块,因为它非常便携,并得到谷歌和 Pivotal 等主要参与者的支持。它从一开始就被设计为一组松散耦合的可插拔组件,让您可以替换您喜欢的组件。

Kubernetes 最初是 Docker 容器的编排平台。许多特定于 Docker 的假设被构建进去。Kubernetes 1.3 添加了对 CoreOS rkt 的特殊支持,并开始了朝向解耦的运行时体验的旅程。Kubernetes 1.5 引入了 CRI,其中 kubelet 通过 gRPC 与容器运行时引擎通信。CRI 在 Kubernetes 1.6 中稳定。

SaaS意味着您不需要预配和管理集群中的节点。您的集群会根据负载自动增长和缩小。Kubernetes 集群自动缩放器在 Kubernetes 上提供了这种能力。

正如我之前在讨论容器运行时的抽象时提到的,CRI 为多个运行时实现打开了大门。一类运行时扩展是轻量级或微型虚拟机。这似乎有点适得其反,因为容器运动最大的动机之一是虚拟机对于动态云应用来说太过沉重。

事实证明,容器在隔离方面并不是百分之百可靠。对于许多用例来说,安全性问题比其他任何问题都更重要。解决方案是重新引入虚拟机,但轻量化。现在,行业已经积累了一些与容器相关的经验,可以设计下一代虚拟机,找到铁壁般的隔离和高性能/低资源之间的平衡点。

以下是一些最重要的项目:

  • gVisor

  • Firecracker

  • Kata containers

gVisor

gVisor 是谷歌的一个开源项目。它是一个位于主机内核前面的用户空间内核沙箱。它公开了一个名为 runsc 的Open Container InitiativeOCI)接口。它还有一个 CRI 插件,可以直接与 Kubernetes 接口。gVisor 提供的保护只是部分的。如果容器被入侵,用户内核和特殊的 secomp 策略提供额外的安全层,但这并不是完全隔离。gVisor 被谷歌 AppEngine 使用。

Firecracker

Firecracker 是 AWS 的一个开源项目。它是一个使用 KVM 管理微型虚拟机的虚拟机监视器。它专门设计用于运行安全的多租户容器和函数作为服务。它目前只能在英特尔 CPU 上运行,但计划支持 AMD 和 ARM。

AWS Lambda 和 AWS Fargate 已经在使用 Firecracker。目前,Firecracker 在 Kubernetes 上不能轻松使用。计划通过 containerd 提供容器集成。参考链接:github.com/firecracker-microvm/firecracker-containerd/

Kata containers

这是另一个由OpenStack 基金会OSF)管理的开源解决方案,即 Kata 容器。它结合了英特尔的 clear 容器和 Hyper.sh RunV 的技术。它支持多个虚拟化程序,如 QEMU、NEMU,甚至 Firecracker。Kata 容器的目标是构建基于硬件虚拟化的安全容器运行时,用于工作负载隔离。Kata 容器已经可以通过 containerd 在 Kubernetes 上使用。

很难说它会如何摇摆。已经有一些整合。对安全和可靠的容器运行时有很强的需求。所有项目都可以在 Kubernetes 上使用,或者计划很快将它们集成起来。这可能是云原生领域最重要但又看不见的改进之一。主要问题是那些轻量级虚拟机可能会为某些用例引入太多性能开销。

集群自动缩放

如果您处理波动负载(可以肯定地说任何非平凡系统都是如此),那么您有三个选择:

  • 过度配置您的集群。

  • 尝试找到一个理想的大小并处理停机,超时和性能慢。

  • 根据需求扩大和缩小您的集群。

让我们更详细地讨论前述选项:

  • 选项 1 很昂贵。您支付资源,大部分时间都没有充分利用。它确实给您带来了一些宁静,但最终,您可能会遇到需求暂时超过甚至超过您过度配置的容量的情况。

  • 选项 2 实际上不是一个选项。如果您选择了过度配置并低估了,您可能会发现自己在那里。

  • 选项 3 是您想要的地方。您的集群容量与您的工作负载相匹配。您始终可以满足您的 SLO 和 SLA,并且不必支付未使用的容量。但是,尝试手动弹性地管理您的集群是行不通的。

解决方案是自动执行。这就是集群自动缩放器的作用。我相信,对于大规模集群,集群自动缩放器将成为标准组件。可能会有其他自定义控制器,根据自定义指标调整集群大小,或者调整节点以外的其他资源。

我完全期待所有大型云提供商投资并解决与集群自动缩放相关的所有当前问题,并确保它在其平台上无缝运行。

Kubernetes 社区中另一个突出的趋势是通过 Kubernetes 操作员提供复杂组件,这已成为最佳实践。

使用操作员

Kubernetes 操作员是封装了某些应用程序的操作知识的控制器。它可以管理安装、配置、更新、故障转移等。操作员通常依赖于 CRD 来保持自己的状态,并可以自动响应事件。提供操作员正在迅速成为发布新的复杂软件的方式。

Helm 图表适用于将位安装到集群上(操作员可能会使用 Helm 图表进行此操作),但与复杂组件相关的持续管理很多,如数据存储、监控解决方案、CI/CD 流水线、消息代理和无服务器框架。

这里的趋势非常明显:复杂的项目将提供操作员作为标准功能。

有两个支持这一趋势的有趣项目。

OperatorHub(operatorhub.io/)是一个经过筛选的 Kubernetes 操作员索引,人们可以在那里找到精心打包的软件来安装到他们的集群上。OperatorHub 由 RedHat(现在是 IBM 的一部分)、亚马逊、微软和谷歌发起。它按类别和提供商进行了很好的组织,并且易于搜索。以下是主页的截图:

操作员中心

操作员非常有用,但他们需要对 Kubernetes 的工作原理、控制器、协调逻辑的概念、如何创建 CRD 以及如何与 Kubernetes API 服务器交互有相当好的了解。这并不是什么难事,但也不是微不足道的。如果你想开发自己的操作员,有一个名为 Operator Framework 的项目(github.com/operator-framework)。Operator Framework 提供了一个 SDK,可以让您轻松开始使用您的操作员。有关使用 Go 编写操作员、使用 Ansible 或 Helm 的指南。

操作员显著减少了复杂性,但如果您需要管理多个集群怎么办?这就是集群联邦的用武之地。

联邦

管理单个大型 Kubernetes 集群并不简单。管理多个地理分布式集群要困难得多。特别是如果您试图将多个集群视为一个大的逻辑集群。在高可用性、故障转移、负载平衡、安全性和延迟方面会出现许多挑战。

对于许多非常庞大的系统,多个集群是必需的。有时,对于较小的系统也是必需的。以下是一些用例:

  • 混合式本地/云

  • 地理分布式冗余和可用性

  • 多提供商冗余和可用性

  • 非常庞大的系统(节点比单个 Kubernetes 集群能处理的要多)

Kubernetes 尝试通过 Kubernetes 联邦 V1 提案和实施来解决这个问题。但它失败了,从未达到 GA。但是,然后出现了 V2,网址是github.com/kubernetes-sigs/federation-v2.

所有大型云提供商都有混合本地/云系统的产品。这些包括以下内容:

  • Google Anthos

  • GKE 本地 - AWS Outposts:Microsoft Azure Stack

此外,许多第三方 Kubernetes 解决方案提供跨云甚至裸金属管理多个集群。在这方面最有前途的项目之一是 Gardener(gardener.cloud/),它可以让您管理数千个集群。它通过拥有一个管理许多种子集群(作为自定义资源)的花园集群来运行,这些种子集群可以有射击集群。

我认为这是一个自然的进步。一旦行业掌握了管理单个集群的艺术,那么掌握一系列集群将成为下一个挑战。

总结

在本章中,我们看了一下微服务和 Kubernetes 接下来的发展方向。所有指标显示,无论是微服务还是 Kubernetes,在设计、构建、演进和操作云原生、大规模、分布式系统时都将继续发挥重要作用。这是个好消息。小型程序、脚本和移动应用不会消失,但后端系统将变得更大,处理更多数据,并负责管理我们生活中越来越多的方面。虚拟现实、传感器和人工智能等技术将需要处理和存储越来越多的数据。

在微服务世界的短期发展中,gRPC 将成为一种流行的服务间通信和公共接口传输方式。Web 客户端将能够通过 gRPC for web 消费 gRPC。GraphQL 是另一项创新,与 REST API 相比是一项重大改进。行业仍需要一些时间来理解如何设计和构建基于微服务的架构。构建单个微服务很简单。构建一整套协调的微服务系统则是另一回事。

容器和 Kubernetes 解决了基于微服务的架构所面临的一些难题。新技术,如服务网格,将迅速获得关注。无服务器计算(SaaS 和 FaaS)将帮助开发人员更快地部署和更新应用程序。容器和虚拟化的融合将导致更安全的系统。运维人员将使更大更有用的构建块成为现实。集群联邦将成为可扩展系统的新前沿。

到目前为止,您应该对即将到来的情况有一个很好的了解,知道可以预期什么。这些知识将使您能够提前规划,并对现在应该投资哪些技术以及哪些技术需要更多成熟进行自己的评估。

简而言之,我们正处在一个激动人心的新时代的开端,我们将学会如何在前所未有的规模上创建可靠的系统。继续学习,掌握所有可用的惊人技术,构建自己的系统,并回馈社区。

进一步阅读

阅读列表非常广泛,因为我们讨论了许多值得关注和跟进的新项目和技术:

posted @ 2024-05-20 12:01  绝不原创的飞龙  阅读(23)  评论(0编辑  收藏  举报