Go 语言 DevOps(五)

原文:annas-archive.org/md5/3bb23876803d0893c1924ba12cfd8f56

译者:飞龙

协议:CC BY-NC-SA 4.0

第十三章:使用 Terraform 实现基础设施即代码

基础设施即代码IaC)是使用机器可读的声明性规范或命令式代码来配置计算基础设施的实践,而不是使用交互式配置工具。随着云计算的兴起,IaC 越来越流行。以前负责维护长期存在的基础设施的基础设施管理员,发现自己在公司采用云基础设施后,既需要在敏捷性上提高,又需要在容量上扩展。

请记住,在这时,软件团队和基础设施团队通常不会紧密合作,直到需要部署软件项目时。IaC 通过建立一套共享的文档,描述了软件项目所需的基础设施,从而为基础设施管理员和软件开发人员架起了桥梁。IaC 规范或代码通常存在于项目内部或与项目并行存放。通过在软件开发人员和基础设施管理员之间建立这种共享上下文,这两个团队能够在软件开发生命周期的早期就开始合作,并为基础设施建立共同的愿景。

在本章中,我们将首先学习 Terraform 如何处理 IaC 及其基本用法。在我们掌握 Terraform 的工作原理之后,我们将讨论 Terraform 提供者,并看看如何通过丰富的提供者生态系统来描述和配置各种资源,而不仅仅是计算基础设施,如虚拟机。最后,我们将学习如何通过构建我们自己的宠物商店 Terraform 提供者来扩展 Terraform。

本章将涵盖以下主题:

  • IaC 简介

  • 了解 Terraform 的基础知识

  • 了解 Terraform 提供者的基础知识

  • 构建宠物商店 Terraform 提供者

技术要求

在本章中,你需要具备以下内容:

让我们从学习一些 Terraform 基础知识开始。

本章的代码文件可以从 github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/13/petstore-provider 下载

IaC 简介

IaC 不仅促进了基础设施与软件开发团队的合作,还使得项目基础设施的部署变得更加简便和安全。通过定义基础设施并将规范存储在软件项目中,基础设施代码可以像测试软件项目一样进行测试。与测试代码一样,持续测试基础设施代码能够减少缺陷、发现低效之处,并提高对基础设施部署过程的信心。

今天我们已经习惯了这一点,但在许多组织中,与基础设施管理员合作为一个复杂的应用程序构建集群可能需要几周时间。将这一经验压缩成少数几个文件,然后能够在几分钟内部署集群,这一变化具有革命性意义。

市面上有许多 IaC 工具,每个工具在描述和配置基础设施时都有自己独特的方式。虽然它们各有不同,但每个工具都可以通过两个方面来分类:一是作者如何指定代码,二是工具如何处理代码的变更。最重要的分类是基础设施代码如何被指定。具体而言,代码是一种声明式的规范,描述所需的状态(要配置什么),或者代码是用编程语言描述的一系列命令性步骤(如何配置)。第二个分类是工具如何应用基础设施,推送(Push)或拉取(Pull)。拉取式 IaC 工具会监视中央仓库中代码的变化,推送式 IaC 工具则将其更改应用到目标系统中。

IaC 是在编写、交付和运维软件之间架起桥梁的关键实践之一。它是开发与运维交集的关键领域之一。掌握这一实践将使您的团队能够更快、更灵活、更可靠地交付软件。

理解 Terraform 的基础知识

Terraform (www.terraform.io/) 是一个由 HashiCorp 创建、用 Go 编写的开源 IaC 工具,提供一致的命令行体验来管理各种资源。通过 Terraform,基础设施工程师可以使用声明式的 Terraform 配置文件或命令式代码(www.terraform.io/cdktf)定义一组层次化资源的期望状态,从而生成 Terraform 配置文件。这些配置文件就是 IaC 中的代码。它们可以用于管理资源的整个生命周期,包括创建、修改和销毁资源,计划并预测资源变更,提供复杂资源拓扑中的依赖关系图,并存储系统的最后观察状态。

Terraform 非常容易上手,并且有一个相对平缓的学习曲线。在本章中,我们不会涵盖 Terraform 的许多功能,但这些功能在你深入使用工具时会非常有用。本章的目标不是让你成为 Terraform 的专家,而是帮助你快速上手并高效使用。

在这一部分,你将学习 Terraform 如何运作的基础知识,以及如何使用 Terraform CLI。我们将从一个简单的示例开始,并讨论执行时发生的事情。到本节结束时,你应该能够熟练地使用 Terraform CLI 定义资源、初始化并应用。

使用 Terraform 初始化和应用基础设施规范

在本节的第一部分,我们将讨论资源而不是基础设施组件。讨论资源和组件较为抽象。让我们用一个具体的例子来解释使用 Terraform 的正常操作流程。

对于我们的第一个示例,我们将使用如下所示的目录结构:

.
├── main.tf

在上面的代码块中,我们有一个目录,里面有一个名为 main.tf 的文件。在该文件中,我们将添加以下内容:

resource "local_file" "foo" {
    content  = "foo!"
    filename = "${path.module}/foo.txt"
}

在上述 Terraform main.tf 配置文件中,我们定义了一个名为 foolocal_file 资源,并将其内容设置为 foo!,该文件位于 ${path.module}/foo.txt${path.module} 是模块的文件系统路径,在此例中为 ./foo.txt

我们可以简单地运行以下命令来初始化 Terraform 并应用所需的状态:

$ terraform init && terraform apply

上面的terraform init命令将检查main.tf的有效性,拉取所需的提供程序,并初始化项目的本地状态。在执行init命令后,将执行apply命令。我们将这两个命令分为两部分来讨论,首先是init,然后是applyinit命令应输出以下内容:

$ terraform init && terraform apply
Initializing the backend...
Initializing provider plugins...
- Finding latest version of hashicorp/local...
- Installing hashicorp/local v2.2.2...
- Installed hashicorp/local v2.2.2 (signed by HashiCorp)
Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made preceding. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.
Terraform has been successfully initialized!
You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.
If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

如上面的输出所示,Terraform 安装了特定版本的hashicorp/local提供程序。然后,Terraform 将该版本保存到本地锁定文件.terraform.lock.hcl中,以确保未来使用相同的版本,从而确保可重现的构建。最后,Terraform 提供了使用terraform plan查看 Terraform 将如何执行以达到main.tf中描述的所需状态的指令。

初始化后,运行terraform apply将触发 Terraform 确定当前所需的状态,并与main.tf中资源的已知状态进行比较。terraform apply会向操作员呈现即将执行的操作计划。经操作员批准计划后,Terraform 执行该计划并保存资源的更新状态。我们来看一下terraform apply的输出:

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create
Terraform will perform the following actions:
  # local_file.foo will be created
  + resource "local_file" "foo" {
      + content              = "foo!"
      + directory_permission = "0777"
      + file_permission      = "0777"
      + filename             = "./foo.txt"
      + id                   = (known after apply)
    }
Plan: 1 to add, 0 to change, 0 to destroy.
Do you want to perform these actions?
  Terraform will perform the actions described preceding.
  Only 'yes' will be accepted to approve.
  Enter a value: yes
local_file.foo: Creating...
local_file.foo: Creation complete after 0s [id=4bf3e335199107182c6f7638efaad377acc7f452]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.

在确认计划并输入yes后,Terraform 已应用所需的状态并创建了一个本地文件资源。目录应如下所示:

.
├── .terraform
│   └── providers
│       └── registry.terraform.io
│           └── hashicorp
│               └── local
│                   └── 2.2.2
│                       └── darwin_arm64
│                           └── terraform-provider-local_v2.2.2_x5
├── .terraform.lock.hcl
├── foo.txt
├── main.tf
└── terraform.tfstate

在上面的目录结构中,我们可以看到 Terraform 用于配置文件的本地提供程序、Terraform 锁文件、foo.txt文件和terraform.tfstate文件。让我们探索一下foo.txtterraform.tfstate文件:

$ cat foo.txt
foo!

正如我们在main.tf中描述的那样,Terraform 已经创建了包含foo!内容的foo.txt。接下来,让我们看看terraform.tfstate

$ cat terraform.tfstate
{
  "version": 4,
  "terraform_version": "1.1.7",
  "serial": 1,
  "lineage": "384e96a1-5878-ed22-5368-9795a3231a00",
  "outputs": {},
  "resources": [
    {
      "mode": "managed",
      "type": "local_file",
      "name": "foo",
      "provider": "provider[\"registry.terraform.io/hashicorp/local\"]",
      "instances": [
        {
          "schema_version": 0,
          "attributes": {
            "content": "foo!",
            "content_base64": null,
            "directory_permission": "0777",
            "file_permission": "0777",
            "filename": "./foo.txt",
            "id": "4bf3e335199107182c6f7638efaad377acc7f452",
            "sensitive_content": null,
            "source": null
          },
          "sensitive_attributes": [],
          "private": "bnVsbA=="
        }
      ]
    }
  ]
}

terraform.tfstate文件比foo.txt更为有趣。tfstate文件是 Terraform 存储计划中已应用资源的最后已知状态的地方。这使得 Terraform 能够检查与最后已知状态的差异,并在未来所需状态发生变化时,生成更新资源的计划。

接下来,让我们在main.tf中更改所需的状态,并查看再次应用配置时会发生什么。我们将main.tf更新为如下:

resource "local_file" "foo" {
    content  = "foo changed!"
    filename = "${path.module}/foo.txt"
    file_permissions = "0644"
}

请注意,我们已更改了foo.txt的内容,并为该资源添加了文件权限。现在,让我们应用所需状态,看看会发生什么:

$ terraform apply -auto-approve
local_file.foo: Refreshing state... [id=4bf3e335199107182c6f7638efaad377acc7f452]
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
-/+ destroy and then create replacement
Terraform will perform the following actions:
  # local_file.foo must be replaced
-/+ resource "local_file" "foo" {
      ~ content              = "foo!" -> "foo changed!" # forces replacement
      ~ file_permission      = "0777" -> "0644" # forces replacement
      ~ id                   = "4bf3e335199107182c6f7638efaad377acc7f452" -> (known after apply)
        # (2 unchanged attributes hidden)
    }
Plan: 1 to add, 0 to change, 1 to destroy.
local_file.foo: Destroying... [id=4bf3e335199107182c6f7638efaad377acc7f452]
local_file.foo: Destruction complete after 0s
local_file.foo: Creating...
local_file.foo: Creation complete after 0s [id=5d6b2d23a15b5391d798c9c6a6b69f9a57c41aa5]
Apply complete! Resources: 1 added, 0 changed, 1 destroyed.

Terraform 能够确定资源已更改的属性,并为达到所需状态创建计划。正如计划输出中所示,1个添加,0个更改,1个销毁,表示本地的foo.txt文件将被删除并重新创建,因为文件权限的更改迫使该文件被替换。这个例子说明了,单一属性的更改可能会(但不总是)导致资源的删除和重建。请注意,我们为apply命令添加了-auto-approve标志。顾名思义,这将不会在应用计划之前提示审批。在使用该标志时,你可能需要小心,因为检查计划确保你期望的操作与计划中描述的操作一致是一个好习惯。

让我们看看foo.txt的新内容:

$ cat foo.txt
foo changed!

如你所见,foo.txt的内容已经更新,以反映所需的状态。现在,让我们检查一下目录:

.
├── foo.txt
├── main.tf
├── terraform.tfstate
└── terraform.tfstate.backup

请注意,创建了一个新文件,terraform.tfstate.backup。这是之前tfstate文件的副本,以防新的tfstate文件损坏或丢失。

默认情况下,tfstate文件是存储在本地的。在个人工作时,这完全没问题;然而,在团队合作时,就会变得难以与其他人共享最新的状态。此时,远程状态(www.terraform.io/language/state/remote)变得非常有用。我们在这里不讨论这一功能,但你应该了解它。

最后,我们将销毁我们已创建的资源:

$ terraform destroy
local_file.foo: Refreshing state... [id=5d6b2d23a15b5391d798c9c6a6b69f9a57c41aa5]
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  - destroy
Terraform will perform the following actions:
  # local_file.foo will be destroyed
  - resource "local_file" "foo" {
      - content              = "foo changed!" -> null
      - directory_permission = "0777" -> null
      - file_permission      = "0644" -> null
      - filename             = "./foo.txt" -> null
      - id                   = "5d6b2d23a15b5391d798c9c6a6b69f9a57c41aa5" -> null
    }
Plan: 0 to add, 0 to change, 1 to destroy.
Do you really want to destroy all resources?
  Terraform will destroy all your managed infrastructure, as shown above.
  There is no undo. Only 'yes' will be accepted to confirm.
  Enter a value: yes
local_file.foo: Destroying... [id=5d6b2d23a15b5391d798c9c6a6b69f9a57c41aa5]
local_file.foo: Destruction complete after 0s
Destroy complete! Resources: 1 destroyed.

运行terraform destroy将清理所有在所需状态中描述的资源。如果你检查你的目录,你会发现foo.txt文件已被删除。

恭喜!你已经掌握了 Terraform 的基础知识。我们从高层次了解了 Terraform 是如何操作的以及如何使用 Terraform CLI。我们创建了一个简单的本地文件资源,修改了它,并销毁了它。在下一节中,我们将讨论 Terraform 提供商,并探索利用这些提供商所打开的广阔世界。

理解 Terraform 提供商的基础知识

从本质上讲,Terraform 是一个平台,用于将表达的期望状态与外部系统进行对比。Terraform 与外部 API 交互的方式是通过名为 提供商 的插件。提供商负责描述其公开资源的架构,并实现与外部 API 的 创建、读取、更新和删除CRUD)交互。提供商使 Terraform 能够将几乎所有外部 API 的资源表示为 Terraform 资源。

通过其成千上万的社区和验证过的提供商,Terraform 能够管理包括 Redis、Cassandra 和 MongoDB 等数据库,所有主要云服务提供商的云基础设施,Discord 和 SendGrid 等通信和消息服务,以及大量其他提供商。如果你有兴趣,可以在 Terraform 注册表中查看它们的列表 (registry.terraform.io/)。你只需编写、规划并应用,即可实现你所期望的基础设施。

在本节中,我们将基于使用本地提供商的经验,并将我们学到的知识扩展到使用与外部 API 交互的提供商。我们将为一组云资源定义期望的状态并进行配置。

定义和配置云资源

假设我们想要将基础设施部署到我们的云服务提供商。此时,我们将通过 hashicorp/azurerm 提供商使用 Microsoft Azure。在一个空目录中,让我们从编写一个简单的 main.tf 文件开始,如下所示:

# Configure the Azure provider
terraform {
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 3.0"
    }
  }
}
provider "azurerm" {
  features {}
}
resource "azurerm_resource_group" "mygroup" {
  name     = "mygroup"
  location = "southcentralus"
}

上述 Terraform 配置文件需要 hashicorp/azurerm 提供商,并在 southcentralus 区域定义了一个名为 mygroup 的资源组(资源组是 Azure 的一个概念,用于将基础设施资源组合在一起)。

要运行本节中的其他示例,你需要一个 Azure 账户。如果你没有 Azure 账户,可以注册一个免费账户,获得 $200 的 Azure 信用: azure.microsoft.com/en-us/free/

一旦你拥有账户,请使用 Azure CLI 登录:

$ az login

上述命令将使你登录到 Azure 账户,并将默认上下文设置为你的主 Azure 订阅。要查看当前活跃的订阅,可以运行以下命令:

$ az account show
{
  "environmentName": "AzureCloud",
  "isDefault": true,
  "managedByTenants": [],
  "name": "mysubscription",
  "state": "Enabled",
  "tenantId": "888bf....db93",
  "user": {
      ...
  }
}

上述命令的输出显示了订阅名称和 Azure CLI 当前上下文的其他详细信息。azurerm 提供商将使用 Azure CLI 的认证上下文与 Azure API 进行交互。

现在我们已经在 Azure CLI 上完成了身份验证的 Azure 会话,接下来让我们使用initapply来创建我们期望的状态。在包含main.tf文件的目录下,运行以下命令:

$ terraform init && terraform apply

terraform init将初始化目录,并下载最新的azurerm提供程序。通过指定~> 3.0版本约束,Terraform 会安装3.0.x系列中的最新版本。您应该会看到类似于以下的init输出:

Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/azurerm versions matching "~> 3.0"...
- Installing hashicorp/azurerm v3.0.2...
- Installed hashicorp/azurerm v3.0.2 (signed by HashiCorp)
Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.
Terraform has been successfully initialized!
You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.
If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.

这段输出应该在使用 Terraform 初始化和应用基础设施规范部分中看过。初始化完成后,您将再次看到创建所需资源的计划。计划获得批准后,所需资源会被创建。输出应该像下面这样:

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create
Terraform will perform the following actions:
  # azurerm_resource_group.rg will be created
  + resource "azurerm_resource_group" "mygroup" {
      + id       = (known after apply)
      + location = "southcentralus"
      + name     = "mygroup"
    }
Plan: 1 to add, 0 to change, 0 to destroy.
Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.
  Enter a value: yes
azurerm_resource_group.mygroup: Creating...
azurerm_resource_group.mygroup: Creation complete after 2s [id=/subscriptions/8ec-...-24a/resourceGroups/mygroup]

从上面的输出中可以看到,资源组已经创建。

注意

如果您使用的是免费的 Azure 账户,可能在 southcentralus区域没有配额。您可能需要使用其他区域,如centralusnortheurope。要了解更多关于适合您的区域的信息,可以查看 Azure 地理位置指南:azure.microsoft.com/en-us/global-infrastructure/geographies/#geographies

打开 Azure 门户并导航到资源组视图,您应该能看到以下内容:

https://github.com/OpenDocCN/freelearn-devops-pt3-zh/raw/master/docs/go-dop/img/B17626_13_001.jpg

图 13.1 – 在 Azure 中创建的资源组

在上面的截图中,我们可以看到我们新创建的 Azure 资源组,mygroup

让我们看看在运行initapply后,哪些新文件被添加到了本地目录中:

.
├── .terraform
│   └── providers
│       └── registry.terraform.io
│           └── hashicorp
│               └── azurerm
│                   └── 3.0.2
│                       └── darwin_arm64
│                           └── terraform-provider-azurerm_v3.0.2_x5
├── .terraform.lock.hcl
├── main.tf
└── terraform.tfstate

与前面的部分类似,我们可以看到 Terraform 的锁定文件和状态文件。然而,在providers目录中,我们现在可以看到安装了azurerm提供程序。

让我们添加一些资源并应用它们。您可以在 Azure 提供程序文档中找到所有受支持资源的列表(registry.terraform.io/providers/hashicorp/azurerm/latest/docs)。我们将更新main.tf文件,包含以下资源:

resource "azurerm_resource_group" "mygroup" {
  name     = "mygroup"
  location = "southcentralus"
}
resource "azurerm_service_plan" "myplan" {
  name                = "myplan"
  resource_group_name = azurerm_resource_group.mygroup.name
  location            = azurerm_resource_group.mygroup.location
  os_type             = "Linux"
  sku_name            = "S1"
}
resource "random_integer" "ri" {
  min = 10000
  max = 99999
}
resource "azurerm_linux_web_app" "myapp" {
  name                = "myapp-${random_integer.ri.result}"
  resource_group_name = azurerm_resource_group.mygroup.name
  location            = azurerm_service_plan.myplan.location
  service_plan_id     = azurerm_service_plan.myplan.id
  site_config {
      application_stack {
          docker_image = "nginxdemos/hello"
          docker_image_tag = "latest"
      }
  }
}
output "host_name" {
    value = azurerm_linux_web_app.myapp.default_hostname
}

添加到前面的main.tf文件中的资源包括两个 Azure 资源,一个应用服务计划,一个 Linux Web 应用,以及一个random_integer资源。Azure 应用服务计划定义了一个区域性的计算基础设施部署,用于运行基于 Linux 的 Web 应用。Azure Linux Web 应用与 Azure 应用服务计划相关联,并配置为运行一个 hello world NGINX 演示容器镜像。random_integer资源需要提供一些随机输入,用于完全限定域名FQDN)的配置,供 Linux Web 应用使用。

请注意变量的使用。例如,我们使用azurerm_resource_group.mygroup.nameazure_service_plan资源中的resource_group_name提供值。使用变量有助于最小化配置文件中的字符串字面值数量。这在进行修改时很有帮助,因为你可以在一个地方进行修改,而不是在每个字符串出现的地方修改。

此外,请注意使用输出变量host_name。这指示 Terraform 在terraform apply完成后输出host_name键,值为azurerm_linux_web_app.myapp.default_hostname。我们将使用此输出,以便在网站部署后更方便地打开它。

让我们再次运行terraform apply,看看会发生什么:

$ terraform apply
│
│ Error: Inconsistent dependency lock file
│
│ The following dependency selections recorded in the lock file are inconsistent with the current configuration:
│   - provider registry.terraform.io/hashicorp/random: required by this configuration but no version is selected
│
│ To update the locked dependency selections to match a changed configuration, run:
│   terraform init -upgrade
│

哎呀!terraform apply返回了一个错误,提示我们在配置中添加了一个新提供者,而上次没有这个。运行terraform init -upgraderandom模块将被添加:

$ terraform init -upgrade
Initializing the backend...
Initializing provider plugins...
- Finding latest version of hashicorp/random...
- Finding hashicorp/azurerm versions matching "~> 3.0"...
- Installing hashicorp/random v3.1.2...
- Installed hashicorp/random v3.1.2 (signed by HashiCorp)
- Using previously-installed hashicorp/azurerm v3.0.2

你应该会看到类似上面的输出,显示 Terraform 正在安装最新版本的hashicorp/random提供者。让我们看看在添加了提供者后,我们的目录现在是什么样子的:

.
├── .terraform
│   └── providers
│       └── registry.terraform.io
│           └── hashicorp
│               ├── azurerm
│               │   └── 3.0.2
│               │       └── darwin_arm64
│               │           └── terraform-provider-azurerm_v3.0.2_x5
│               └── random
│                   └── 3.1.2
│                       └── darwin_arm64
│                           └── terraform-provider-random_v3.1.2_x5

如你所见,random提供者现在已安装。我们应该可以再次使用apply了:

$ terraform apply -auto-approve
azurerm_resource_group.mygroup: Refreshing state...
...
Plan: 3 to add, 0 to change, 0 to destroy.
Changes to Outputs:
  + host_name = (known after apply)
random_integer.ri: Creating...
random_integer.ri: Creation complete after 0s [id=18515]
azurerm_service_plan.myplan: Creating...
azurerm_service_plan.myplan: Still creating... [10s elapsed]
azurerm_service_plan.myplan: Creation complete after 12s [id=/subscriptions/8ec-...-24a/resourceGroups/mygroup/providers/Microsoft.Web/serverfarms/myplan]
azurerm_linux_web_app.myapp: Creating...
azurerm_linux_web_app.myapp: Still creating... [10s elapsed]
azurerm_linux_web_app.myapp: Still creating... [20s elapsed]
azurerm_linux_web_app.myapp: Creation complete after 28s [id=/subscriptions/8ec-...-24a/resourceGroups/mygroup/providers/Microsoft.Web/sites/myapp-18515]
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
Outputs:
host_name = "myapp-18515.azurewebsites.net"

我们省略了terraform apply的部分输出。需要注意的是,我们正在创建main.tf中描述的每个资源,它们已经成功配置,并且host_name包含了一个通用资源标识符URI),用于访问新部署的 Web 应用。

获取host_name的 URI 并在浏览器中打开。你应该会看到如下内容:

https://github.com/OpenDocCN/freelearn-devops-pt3-zh/raw/master/docs/go-dop/img/B17626_13_002.jpg

图 13.2 – NGINX 运行在 Azure 应用服务中

如果你返回到 Azure 门户,你也会看到在你的资源组内创建的资源。

我希望你能花点时间通过定义和应用其他资源进行实验。一旦你掌握了使用提供者和一些基本语法,Terraform 将变得非常愉快。当你完成资源配置后,只需运行terraform destroy,它们将被删除。

在本节中,我们学习了使用提供者来操作云资源的一些基础知识。我们只需要使用几个提供者,但正如本节开头所讨论的那样,世上有成千上万的提供者。你很可能能够找到一个提供者来解决你的问题。然而,也可能有一些你希望用 Terraform 管理的 API 和资源,而没有现成的提供者。在下一节中,我们将为一个虚构的宠物商店构建一个 Terraform 提供者。

构建宠物商店 Terraform 提供者

即使 Terraform 提供商注册表 (registry.terraform.io/) 几乎涵盖了您能想到的每个提供商,但您可能需要的提供商尚未存在。也许您希望使用 Terraform 与公司内部的专有 API 资源进行交互。如果您想管理尚不存在于 Terraform 提供商生态系统中的资源,您将需要为该 API 编写一个提供商。好消息是,编写 Terraform 提供商相对简单。HashiCorp 的负责人提供了出色的文档、SDK 和工具,使构建提供商变得轻而易举。

在之前的章节中,我们学习了 Terraform 的基础知识以及如何使用提供商与本地和外部系统中的资源进行交互。我们能够构建云资源以部署运行在容器中的 Linux Web 应用程序。

在本节中,我们将在前几节的基础上构建,并学习如何构建我们自己的提供商。我们在本节中构建的 Terraform 提供商将暴露宠物资源,并与本地 docker-compose-hosted 宠物店服务交互,以模拟外部 API。

您将学习如何定义具有强大模式和验证的自定义资源,创建数据源,并为我们的宠物资源实施 CRUD 交互。最后,我们将讨论通过 Terraform 提供商注册表发布供全球使用的模块。

构建自定义提供商的资源

HashiCorp 提供了一套广泛的教程,用于构建自定义提供商 (learn.hashicorp.com/collections/terraform/providers)。如果您打算构建自己的自定义提供商,我强烈推荐查阅这些内容。

本节的代码位于 github.com/PacktPublishing/Go-for-DevOps/tree/main/chapter/13/petstore-provider。我们不会覆盖所有代码,但我们将深入探讨最有趣的部分。我尽力保持只保留最简单的实现;然而,简单并不总是优雅。

此外,我们的宠物店自定义提供商使用的是 Terraform 插件 SDK v2 (www.terraform.io/plugin/sdkv2/sdkv2-intro),而不是新的(在撰写本文时)Terraform 插件框架。我选择这条路线是因为大多数现有的提供商都使用 SDK v2,而 Terraform 插件框架 (www.terraform.io/plugin/framework) 尚未达到稳定性。如果您对权衡利弊感兴趣,请阅读 HashiCorp 的 Which SDK Should I Use? 文章 (www.terraform.io/plugin/which-sdk)。

现在我们已经建立了内容和学习的基础,让我们继续进行代码编写。

宠物店提供商

我们的宠物商店 Terraform 提供者只是另一个 Go 应用程序。Terraform 和提供者之间的大部分交互都是在 Terraform SDK 层面处理的,很少有东西会干扰到提供者开发者。让我们首先来看一下提供者的目录结构:

.
├── Makefile
├── docker-compose.yml
├── examples
│   └── main.tf
├── go.mod
├── go.sum
├── internal
│   ├── client # contains the grpc pet store API client
│   │   └── ...
│   ├── data_source_pet.go
│   ├── provider.go
│   ├── resource_pets.go
│   └── schema.go
└── main.go

就像我之前说的,这是一个标准的 Go 应用程序,入口点在main.go中。让我们从顶部开始,逐步查看文件。列表中的第一个是 Makefile:

HOSTNAME=example.com
NAMESPACE=gofordevops
NAME=petstore
BINARY=terraform-provider-${NAME}
VERSION=0.1.0
GOARCH  := $(shell go env GOARCH)
GOOS := $(shell go env GOOS)
default: install
build:
     go build -o ${BINARY}
install: build
     mkdir -p ~/.terraform.d/plugins/${HOSTNAME}/${NAMESPACE}/${NAME}/${VERSION}/${GOOS}_${GOARCH}
     mv ${BINARY} ~/.terraform.d/plugins/${HOSTNAME}/${NAMESPACE}/${NAME}/${VERSION}/${GOOS}_${GOARCH}
test:
     go test ./... -v
testacc:
     TF_ACC=1 go test ./... -v $(TESTARGS) -timeout 120m

上面的 Makefile 提供了一些有用的构建任务和环境配置。例如,makemake install会根据当前架构构建提供者,并将其放置在~/.terraform.d/plugins目录树中,这样我们就可以在本地使用该提供者,而无需将其发布到注册表中。

接下来,我们有docker-compose.yml文件。让我们看一下:

version: '3.7'
services:
  petstore:
    build:
      context: ../../10/petstore/.
    command:
      - /go/bin/petstore
      - --localDebug
    ports:
      - "6742:6742"

docker-compose.yml文件运行来自第十章的宠物商店服务,使用 GitHub Actions 自动化工作流,并在端口6742上暴露 gRPC 服务。宠物商店服务将宠物存储在内存中,因此要清除当前存储的宠物,只需重新启动该服务即可。稍后我们将在本节中讨论如何启动和停止服务。

接下来,我们来看examples/main.tf。让我们看看定义我们宠物资源的示例:

terraform {
  required_providers {
    petstore = {
      version = "0.1.0"
      source  = "example.com/gofordevops/petstore"
    }
  }
}
...
resource "petstore_pet" "thor" {
  name     = "Thor"
  type     = "dog"
  birthday = "2021-04-01T00:00:00Z"
}
resource "petstore_pet" "tron" {
  name     = "Tron"
  type     = "cat"
  birthday = "2020-06-25T00:00:00Z"
}
data "petstore_pet" "all" {
  depends_on = [petstore_pet.thor, petstore_pet.tron]
}

在前面的main.tf文件中,我们可以看到提供者已注册并配置为使用本地宠物商店服务。我们还可以看到定义了两个petstore_pet资源,分别是ThorTron。在这些资源之后,我们定义了一个petstore_pet数据源。稍后我们将更详细地介绍文件的各个部分。

我希望你在进入代码之前先看到main.tf,因为它会给你一个关于我们希望在提供者实现中实现的接口的概念。我相信看到提供者的使用将帮助你更好地理解提供者的实现。

剩下的源代码全部是 Go 语言编写的,所以与其从上到下查看,我打算跳到main.go中的入口点,深入了解实际的实现:

package main
import (
     "github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
     "github.com/hashicorp/terraform-plugin-sdk/v2/plugin"
     petstore "github.com/PacktPublishing/Go-for-DevOps/chapter/13/petstore-provider/internal"
)
func main() {
     plugin.Serve(&plugin.ServeOpts{
          ProviderFunc: func() *schema.Provider {
               return petstore.Provider()
          },
     })
}

好的,main.go是足够简单的。在main中,我们所做的就是通过 Terraform 插件 SDK v2 启动一个插件服务器,并为其提供我们的宠物商店提供者的实现。接下来,让我们看看internal/provider.go中的petstore.Provider实现:

// Provider is the entry point for defining the Terraform provider, and will create a new Pet Store provider.
func Provider() *schema.Provider {
     return &schema.Provider{
          Schema: map[string]*schema.Schema{
               "host": {
                    Type:        schema.TypeString,
                    Optional:    true,
                    DefaultFunc: schema.EnvDefaultFunc("PETSTORE_HOST", nil),
               },
          },
          ResourcesMap: map[string]*schema.Resource{
               "petstore_pet": resourcePet(),
          },
          DataSourcesMap: map[string]*schema.Resource{
               "petstore_pet": dataSourcePet(),
          },
          ConfigureContextFunc: configure,
     }
}

provider.go 中只有两个函数。Provider 函数创建一个 *schema.Provider,该提供者描述了配置提供者的架构、提供者的资源、提供者的数据源以及用于初始化提供者的配置函数。提供者的资源映射通过字符串名称包含资源及其架构。每个结构的架构描述了与 Terraform 交互的领域特定语言,以操作其字段和资源层次结构。我们将在稍后详细查看这些结构的架构。

接下来,让我们来看看 provider.go 中的 configure 函数:

// configure builds a new Pet Store client the provider will use to interact with the Pet Store service
func configure(_ context.Context, data *schema.ResourceData) (interface{}, diag.Diagnostics) {
     // Warning or errors can be collected in a slice type
     var diags diag.Diagnostics
     host, ok := data.Get("host").(string)
     if !ok {
          return nil, diag.Errorf("the host (127.0.0.1:443) must be provided explicitly or via env var PETSTORE_HOST")
     }
     c, err := client.New(host)
     if err != nil {
          return nil, append(diags, diag.Diagnostic{
               Severity: diag.Error,
               Summary:  "Unable to create Pet Store client",
               Detail:   "Unable to connect to the Pet Store service",
          })
     }
     return c, diags
}

configure 函数负责处理提供者配置。请注意,前面 Provider 架构中描述的 host 数据通过 data 参数提供。这是你将在整个提供者中看到的常见模式。我们使用 host 配置数据来构建宠物店服务的客户端。如果无法构建宠物店客户端,我们会将一个 diag.Diagnostic 结构附加到 diag.Diagnostics 切片中。这些诊断结构会通知 Terraform 提供者中发生的不同严重性的事件。在这种情况下,如果我们无法构建客户端,则会发生错误,并应将此信息反馈给用户。如果一切顺利,我们将返回 client 实例和一个空的 diag.Diagnostics 切片。

接下来,让我们来查看宠物店数据源。

实现宠物店数据源

宠物店数据源比资源实现简单一些,因为数据源是 Terraform 用来从外部 API 拉取数据的方式,并且在这种情况下是只读的。宠物店数据源定义在 internal/data_source_pet.go 中。

宠物店数据源有三个主要函数。我们将逐个查看它们。首先从 dataSourcePet 函数开始:

func dataSourcePet() *schema.Resource {
     return &schema.Resource{
          ReadContext: dataSourcePetRead,
          Schema:      getPetDataSchema(),
     }
}

上述函数通过提供一个 getPetDataSchema 的数据架构来创建 *schema.Resource 数据源。ReadContext 期望一个函数,该函数负责翻译输入架构,查询外部 API,并返回与架构中定义的结构匹配的数据给 Terraform。

getPetDataSchema 的定义位于 internal/schema.go 中,查看它会对理解 dataSourcePetRead 中的代码有帮助。我们将把该函数分为两部分,输入部分和计算出的输出部分:

func getPetDataSchema() map[string]*schema.Schema {
     return map[string]*schema.Schema{
          "pet_id": {
               Type:     schema.TypeString,
               Optional: true,
          },
          "name": {
               Type:             schema.TypeString,
               Optional:         true,
               ValidateDiagFunc: validateName(),
          },
          "type": {
               Type:             schema.TypeString,
               Optional:         true,
               ValidateDiagFunc: validateType(),
          },
          "birthday": {
               Type:             schema.TypeString,
               Optional:         true,
               ValidateDiagFunc: validateBirthday(),
          },

上述架构描述了宠物店宠物数据源的数据结构。每个顶级键都标记为可选,并将用于过滤数据源。例如,name 键指定它是可选的,类型为 string,并且应通过 validateName 函数进行验证。我们将在后续部分详细讨论验证。

以下是数据源输出的架构:

          "pets": {
               Type:     schema.TypeList,
               Computed: true,
               Elem: &schema.Resource{
                    Schema: map[string]*schema.Schema{
                         "id": {
                              Type:     schema.TypeString,
                              Computed: true,
                         },
                         "name": {
                              Type:     schema.TypeString,
                              Computed: true,
                         },
                         "type": {
                              Type:     schema.TypeString,
                              Computed: true,
                         },
                         "birthday": {
                              Type:     schema.TypeString,
                              Computed: true,
                         },
                    },
               },
          },
     }
}

pets 键包含所有 Computed 值,这意味着每个值都是只读的。它们表示查询的列表结果。

现在我们对使用的数据模式有了更好的理解,让我们继续实现 dataSourcePetRead

// dataSourcePetRead finds pets in the pet store given an ID
func dataSourcePetRead(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics {
     psClient, err := clientFromMeta(meta)
     if err != nil {
          return diag.FromErr(err)
     }
     pets, err := findPetsInStore(ctx, psClient, findPetsRequest{
          Name:     data.Get("name").(string),
          Birthday: data.Get("birthday").(string),
          Type:     PetType(data.Get("type").(string)),
          ID:       data.Get("pet_id").(string),
     })
     if err != nil {
          return diag.FromErr(err)
     }
     // always run
     data.SetId(strconv.FormatInt(time.Now().Unix(), 10))
     if err := data.Set("pets", flattenPets(pets)); err != nil {
          return diag.FromErr(err)
     }
     return nil
}

dataSourcePetRead 中,我们为宠物店服务实例化了一个客户端,从提供的数据模式中填充过滤条件,然后将 pets 键在 data 参数中设置为从宠物店服务返回的宠物数据,格式为模式中指定的键值格式。flattenPets 函数负责将我们从宠物店服务接收到的 protobuf 结构转换为模式所期望的格式。如果你对实现感兴趣,它并不是特别优雅,但很简单。

我故意没有提到 data.SetId 函数。我们将它的值设置为一个每次都会从宠物店服务中获取数据的值。当该数据的 ID 改变时,Terraform 会识别数据发生了变化。这确保了每次执行该函数时,ID 都会发生变化。

configure 函数中,我们创建了宠物店客户端,那么我们是如何在数据源中访问该客户端的呢?我们可以在 clientFromMeta 函数中找到答案:

// clientFromMeta casts meta into a Pet Store client or returns an error
func clientFromMeta(meta interface{}) (*client.Client, error) {
     psClient, ok := meta.(*client.Client)
     if !ok {
          return nil, errors.New("meta does not contain a Pet Store client")
     }
     return psClient, nil
}

clientFromMeta 函数接收传入 ReadContext 函数的 meta interface{} 参数,并将其转换为宠物店客户端。meta 变量包含在 configure 函数中返回的变量。这一点可能不像我们希望的那样直观,但它是有效的。

使用之前描述的代码和来自 internal/data_source_pet.go 的一些帮助函数,我们实现了一个过滤的数据源,连接到宠物店 API,可以在 Terraform 配置文件中使用。

接下来,让我们来看一下我们是如何处理宠物资源的 CRUD 操作的。

实现宠物资源

宠物资源的实现遵循了与宠物店数据源类似的许多模式,但对于宠物资源,我们还需要实现创建、更新和删除操作,而不仅仅是读取操作。除非另有说明,我们在讲解宠物资源实现时,代码都位于 internal/resource_pet.go 中。

我们先从检查 resourcePet 函数开始,该函数是在创建提供程序模式时被调用的:

func resourcePet() *schema.Resource {
     return &schema.Resource{
          CreateContext: resourcePetCreate,
          ReadContext:   resourcePetRead,
          UpdateContext: resourcePetUpdate,
          DeleteContext: resourcePetDelete,
          Schema:        getPetResourceSchema(),
          Importer: &schema.ResourceImporter{
               StateContext: schema.ImportStatePassthroughContext,
          },
     }
}

和宠物店数据源一样,宠物资源也定义了每个 CRUD 操作的处理程序以及一个模式。在讨论 CRUD 操作之前,我们先来看一下模式,它位于 internal/schema.go 中:

func getPetResourceSchema() map[string]*schema.Schema {
     return map[string]*schema.Schema{
          "id": {
               Type:     schema.TypeString,
               Optional: true,
               Computed: true,
          },
          "name": {
               Type:             schema.TypeString,
               Required:         true,
               ValidateDiagFunc: validateName(),
          },
          "type": {
               Type:             schema.TypeString,
               Required:         true,
               ValidateDiagFunc: validateType(),
          },
          "birthday": {
               Type:             schema.TypeString,
               Required:         true,
               ValidateDiagFunc: validateBirthday(),
          },
     }
}

这里定义的模式比数据源模式更简单,因为我们没有定义查询过滤条件。请注意,id 键是计算得出的,但其他键都不是。id 值由宠物店服务生成,不需要由用户指定。

由于这些值是由用户以字符串形式指定的,因此验证变得更加重要。为了提供更好的用户体验,我们希望在值无效时向用户提供反馈。让我们来看一下如何通过validateType函数验证type字段:

func validateType() schema.SchemaValidateDiagFunc {
     return validateDiagFunc(validation.StringInSlice([]string{
          string(DogPetType),
          string(CatPetType),
          string(ReptilePetType),
          string(BirdPetType),
     }, true))
}

validateType函数返回一个通过每个有效的枚举值构造的验证。这防止用户输入宠物类型的字符串值,而该类型在宠物商店中不受支持。其余的验证采取了类似的方法来验证输入值的范围。

现在我们已经探讨了模式,准备开始探索 CRUD 操作。让我们从read操作开始:

// resourcePetRead finds a pet in the pet store by ID and populate the resource data
func resourcePetRead(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics {
     psClient, err := clientFromMeta(meta)
     if err != nil {
          return diag.FromErr(err)
     }
     pets, err := findPetsInStore(ctx, psClient, findPetsRequest{ID: data.Id()})
     if err != nil {
          return diag.FromErr(err)
     }
     if len(pets) == 0 {
          return nil
     }
     return setDataFromPet(pets[0], data)
}

resourcePetRead函数从meta参数获取宠物商店客户端,然后通过 ID 在商店中查找宠物。如果找到宠物,data参数将使用来自宠物的数据进行更新。

这足够简单。接下来,让我们看一下创建操作:

// resourcePetCreate creates a pet in the pet store
func resourcePetCreate(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics {
     psClient, err := clientFromMeta(meta)
     if err != nil {
          return diag.FromErr(err)
     }
     pet := &client.Pet{Pet: &pb.Pet{}}
     diags := fillPetFromData(pet, data)
     ids, err := psClient.AddPets(ctx, []*pb.Pet{pet.Pet})
     if err != nil {
          return append(diags, diag.FromErr(err)...)
     }
     data.SetId(ids[0])
     return diags
}

resourcePetCreate函数遵循类似的模式。不同之处在于宠物是从data参数中的字段构造的,然后调用宠物商店 API 将宠物添加到商店。最后,设置新宠物的 ID。

接下来,让我们看一下更新操作:

// resourcePetUpdate updates a pet in the pet store by ID
func resourcePetUpdate(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics {
     psClient, err := clientFromMeta(meta)
     if err != nil {
          return diag.FromErr(err)
     }
     pets, err := findPetsInStore(ctx, psClient, findPetsRequest{ID: data.Id()})
     if err != nil {
          return diag.FromErr(err)
     }
     if len(pets) == 0 {
          return diag.Diagnostics{
               {
                    Severity: diag.Error,
                    Summary:  "no pet was found",
                    Detail:   "no pet was found when trying to update the pet by ID",
               },
          }
     }
     pet := pets[0]
     diags := fillPetFromData(pet, data)
     if diags.HasError() {
          return diags
     }
     if err := psClient.UpdatePets(ctx, []*pb.Pet{pet.Pet}); err != nil {
          return append(diags, diag.FromErr(err)...)
     }
     return diags
}

resourcePetUpdate函数结合了读取和创建的部分。首先,我们需要检查宠物是否在商店中,并获取宠物数据。如果没有找到宠物,则返回错误。如果找到宠物,则更新宠物的字段,并调用宠物商店客户端上的UpdatePets

删除操作相对简单,因此我在这里不再深入讨论。如果你愿意,你可以查看resourcePetDelete自己了解。

到目前为止,我们已经实现了宠物资源,并准备好查看我们的 Terraform 提供者如何运作。

运行宠物商店提供者

现在我们已经有了一个完整实现的宠物商店提供者,接下来有趣的部分就是运行它。从宠物商店提供者的根目录,运行以下命令。请确保 Docker 已经在运行:

$ docker-compose up -d
$ make
$ cd examples
$ terraform init && terraform apply

上述命令将使用docker-compose启动宠物商店服务,构建并安装提供者,将其移动到示例目录,最后使用initapply来创建包含宠物的期望状态。

init执行时,你应该看到如下内容:

Initializing the backend...
Initializing provider plugins...
- Finding example.com/gofordevops/petstore versions matching "0.1.0"...
- Installing example.com/gofordevops/petstore v0.1.0...
- Installed example.com/gofordevops/petstore v0.1.0 (unauthenticated)

耶!提供者已安装,Terraform 已准备好应用我们的资源。

在 Terraform 应用了资源后,你应该看到如下输出:

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Outputs:
all_pets = {
  "birthday" = tostring(null)
  "id" = "1648955761"
  "name" = tostring(null)
  "pet_id" = tostring(null)
  "pets" = tolist([
    {
      "birthday" = "2020-06-25T00:00:00Z"
      "id" = "495b1c94-6f67-46f2-9d4d-e84cc182d523"
      "name" = "Tron"
      "type" = "cat"
    },
    {
      "birthday" = "2021-04-01T00:00:00Z"
      "id" = "36e65cb2-18ea-4aec-a410-7bad64d7b00d"
      "name" = "Thor"
      "type" = "dog"
    },
  ])
  "type" = tostring(null)
}
thor = {
  "36e65cb2-18ea-4aec-a410-7bad64d7b00d" = {
    "birthday" = "2021-04-01T00:00:00Z"
    "id" = "36e65cb2-18ea-4aec-a410-7bad64d7b00d"
    "name" = "Thor"
    "type" = "dog"
  }
}

从之前的输出中,我们可以看到我们的两个资源,TronThor,都已添加,并且在没有过滤器的情况下查询的数据源返回了每只宠物。最后,我们可以看到返回了thor输出,包含了Thor的数据。

我们再看看examples/main.tf,看看thor输出来自哪里:

variable "pet_name" {
  type    = string
  default = "Thor"
}
data "petstore_pet" "all" {
  depends_on = [petstore_pet.thor, petstore_pet.tron]
}
# Only returns Thor by name
output "thor" {
  value = {
    for pet in data.petstore_pet.all.pets :
    pet.id => pet
    if pet.name == var.pet_name
  }
}

在之前的 main.tf 文件中,我们定义了一个值为 Thorpet_name 变量。然后,我们查询了宠物商店数据源,没有提供过滤器,而是依赖文件中两个资源的完成。最后,我们输出了一个键为 thor 的值,这个查询仅在 pet.name 等于 var.pet_name 时才会匹配。这样,我们就过滤出了名为 Thor 的宠物数据。

现在,你可以使用到目前为止学到的任何 Terraform 技能来操作宠物商店资源。实际上,实现这一切的代码并不多。

发布自定义提供者

任何人都可以通过使用 GitHub 账户登录来将提供者发布到 Terraform 注册表。HashiCorp 提供了出色的文档,指导如何发布提供者。我们在本书中不会逐步讲解这个过程,因为《发布并将提供者发布到 Terraform 注册表》(learn.hashicorp.com/tutorials/terraform/provider-release-publish) 的文档如果你已经走到这一步,应该足够帮助你构建自己的 Terraform 提供者。

总结

在本章中,我们了解了基础设施即代码(IaC)的历史以及利用这一实践将软件开发与运维结合的优势,通过设置共享的上下文来表达并持续测试基础设施。我们了解了 Terraform 在 IaC 工具生态系统中的位置,以及如何使用它来描述期望的基础设施状态、修改现有基础设施、部署云基础设施,最后,创建我们自己的资源以自动化外部 API。你现在应该准备好了所需的工具,以改进自己的软件项目。

在下一章,我们将学习如何使用 Go 将应用部署到 Kubernetes,并基于此知识了解如何通过 Go 扩展它。我们将使 Kubernetes 用户能够将宠物作为自定义 Kubernetes 资源进行协调。

第十四章:在 Kubernetes 中部署和构建应用程序

很难夸大 Kubernetes 对 DevOps 世界的影响。自从 2014 年由 Google 开源以来,Kubernetes 在这几年中经历了迅猛的流行。在此期间,Kubernetes 已经成为编排云原生容器工作负载的主要解决方案,将其与 Apache Mesos 和 Docker Swarm 等编排工具区分开来。通过在异构环境上提供统一的 API,Kubernetes 已经成为跨云和混合环境部署应用程序的通用工具。

那么,Kubernetes 究竟是什么?根据它的文档,“Kubernetes 是一个可移植、可扩展的开源平台,用于管理容器化的工作负载和服务,既支持声明式配置也支持自动化”kubernetes.io/docs/concepts/overview/what-is-kubernetes/)。这有很多内容需要解读。我会用不同的方式总结这一声明。Kubernetes 是一组 API 和抽象层,使得运行容器化应用程序变得更加容易。它提供了诸如服务发现、负载均衡、存储抽象与编排、自动化发布与回滚、自愈功能,以及密钥、证书和配置管理等服务。此外,如果 Kubernetes 没有直接提供你所需要的某些特定功能,可能在围绕 Kubernetes 核心构建的充满活力的开源生态系统中就有解决方案可用。Kubernetes 生态系统是一个庞大的工具集,可以帮助你实现运营目标,而无需重新发明轮子。

上述所有功能都通过 Kubernetes API 暴露出来,且具有无限的可编程性。

本章不会深入探讨 Kubernetes 的各个方面。要深入全面地探索 Kubernetes 需要多本书的内容。好消息是,关于 Kubernetes 有许多很棒的书籍:www.packtpub.com/catalogsearch/result?q=kubernetes。此外,Kubernetes 的社区驱动文档(kubernetes.io/docs/home/)是一个宝贵的资源,可以帮助你更深入地了解 Kubernetes。

本章的目标是为你使用 Go 编程 Kubernetes 提供一个起点。我们将从创建一个简单的 Go 程序开始,将 Kubernetes 资源部署到本地 Kubernetes 集群中,运行一个负载均衡的 HTTP 服务。然后,我们将学习如何通过自定义资源扩展 Kubernetes API,展示如何利用 Kubernetes 协同管理任何外部资源。我们将构建自定义的宠物资源,这些资源将存储在集群中运行的宠物商店服务中,以此说明管理外部资源的概念。通过本章学习,你将掌握有效使用 Kubernetes API 的知识,并理解 Kubernetes 的一些核心设计原则。

本章将涵盖以下主题:

  • 与 Kubernetes API 交互

  • 使用 Go 部署一个负载均衡的 HTTP 应用

  • 使用自定义资源和操作员扩展 Kubernetes

  • 构建一个宠物商店操作员

技术要求

本章将需要以下工具:

本章的代码文件可以从 github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/14 下载

与 Kubernetes API 交互

在介绍中,我们将 Kubernetes API 当作一个整体进行讨论,尽管从某种意义上讲,它确实可以被这样理解。然而,我们一直在讨论的 Kubernetes API 实际上是由 Kubernetes 核心部分——控制平面 API 服务器提供的多个 API 的聚合。API 服务器暴露了一个 HTTP API,公开了聚合后的 API,并允许查询和操作如 Pods、Deployments、Services 和 Namespaces 等 API 对象。

在本节中,我们将学习如何使用 KinD 创建一个本地集群。我们将使用本地集群通过 kubectl 操作一个命名空间资源。我们将研究 Kubernetes 资源的基本结构,并查看如何通过它们的 Group、Version、Kind、Name,通常还有 Namespace,来定位单个资源。最后,我们将讨论身份验证和 kubeconfig 文件。本节将为我们通过 Go 在更低层次上与 Kubernetes API 进行交互做准备。

创建一个 KinD 集群

在开始与 Kubernetes API 交互之前,让我们使用KinD构建一个本地 Kubernetes 集群。这是一个工具,允许我们通过 Docker 在本地创建 Kubernetes 集群,而不是作为主机上的服务运行。要创建集群,请运行以下命令:

$ kind create cluster

上述命令将创建一个名为kind的集群。它将构建一个 Kubernetes 控制平面,并将kubectl的当前上下文设置为指向新创建的集群。

你可以通过运行以下命令列出kind创建的集群:

$ kind get clusters
kind

get clusters的输出中可以看到,创建了一个名为kind的新集群。

使用 kubectl 与 API 交互

Kubernetes 提供了一个命令行工具用于与 API 交互,即kubectlkubectl提供了一些很好的开发者体验功能,但其主要用途是执行kubectl操作:

$ kubectl create namespace petstore

上述命令创建了一个名为petstore的命名空间:

$ cat <<EOF | kubectl create -f -
apiVersion: v1
kind: Namespace
metadata:
  name: petstore
EOF

上述命令通过内联 YAML 文档创建了相同的命名空间。接下来,让我们使用kubectl获取该命名空间的 YAML 文件:

$ kubectl get namespace petstore -o yaml
apiVersion: v1
kind: Namespace
metadata:
  creationTimestamp: "2022-03-06T15:55:09Z"
  labels:
    kubernetes.io/metadata.name: petstore
  name: petstore
  resourceVersion: "2162"
  uid: cddb2eb8-9c46-4089-9c99-e31259dfcd1c
spec:
  finalizers:
  - kubernetes
status:
  phase: Active

上述命令获取了petstore命名空间并以.yaml格式输出了整个资源。请特别注意顶级键apiVersionkindmetadataspecstatus。这些键中的值和结构在 Kubernetes 中的所有资源中都是通用的。

分组版本种类(GVK)命名空间名称

在 Kubernetes API 中,你可以通过其分组、种类、版本、名称以及通常的命名空间的组合来标识任何资源。我说“通常的命名空间”是因为并非所有资源都属于命名空间。命名空间是存在于命名空间之外的资源的一个例子(还有其他低级资源,如节点和持久卷)。然而,大多数其他资源,如 Pods、Services 和 Deployments,存在于命名空间中。在前一部分中提到的命名空间示例中,分组被省略了,因为它位于 Kubernetes 核心 API 中,并由 API 服务器假定。实际上,petstore命名空间的标识符是apiVersion: v1kind: Namespacemetadata.name: petstore

内化组、版本、种类、命名空间和名称的概念。这对于理解如何与 Kubernetes API 交互至关重要。

specstatus部分

Kubernetes 中的每个资源都有specstatus部分。资源的spec部分是描述资源期望状态的结构。Kubernetes 的任务是将系统的状态调整为该期望状态。在某些情况下,spec会描述外部系统的期望状态。例如,spec可以描述一个负载均衡器,包括期望的外部 IP。该资源的调和器将负责创建网络接口并设置路由,以确保 IP 路由到该特定的网络接口。

status部分是资源的一个结构,描述了资源的当前状态。它应该由 Kubernetes 进行修改,而不是由用户修改。例如,Deployment 的status包含给定 Deployment 的就绪副本数量。Deployment 的spec将包含所需的副本数。Kubernetes 的任务是朝着所需状态推进,并用资源的当前状态更新status

随着本章的深入,我们将更多地了解specstatus

身份验证

到目前为止,我们只是假设能够访问 Kubernetes 集群,但实际上这一点是由kind处理的,它能为kubectl设置默认上下文。kubectl的默认上下文存储在你的主目录中。你可以通过运行以下命令查看已设置的上下文:

$ cat ~/.kube/config
apiVersion: v1
clusters:
- cluster:
    certificate-authority-data:
    server: https://127.0.0.1:55451
  name: kind-kind
contexts:
- context:
    cluster: kind-kind
    user: kind-kind
  name: kind-kind
current-context: kind-kind
kind: Config
preferences: {}
users:
- name: kind-kind
  user:
    client-certificate-data:
    client-key-data:

在上面的输出中,我省略了证书数据,以便提供更简洁的配置视图。它包含了我们建立与本地集群实例的安全连接所需的所有信息。请注意服务的地址、集群的名称以及用户的名称。

通过运行以下命令,我们可以获得kind集群的kubeconfig

$ kind get kubeconfig --name kind > .tmp-kubeconfig

如果你cat该文件的内容,你会看到~/.kube/config中有一个非常相似的结构。kubeconfig文件是一个便捷的方式,用来封装与 API 服务器进行身份验证所需的信息,并与 Kubernetes 生态系统中的许多工具一起使用。例如,你可以通过以下命令覆盖kubectl的上下文,使用不同的kubeconfig

$ KUBECONFIG=./.tmp-kubeconfig kubectl get namespaces

上述命令将列出kind集群中的所有命名空间,但它将使用我们刚刚创建的本地kubeconfig文件。

有多种工具可以用来管理你所使用的集群。其中一个很棒的例子是 Ahmet Alp Balkan 的kubectxahmet.im/blog/kubectx/),它可以帮助你流畅地管理多个集群。正如我之前提到的,充满活力的开源生态系统提供了各种各样的工具,让你使用 Kubernetes 的体验更加愉快。

最后,让我们清理petstore命名空间并删除我们的kind集群:

$ kubectl delete namespace petstore
$ kind delete cluster --name kind

在这一部分,我们学习了与 Kubernetes API 交互的基础知识以及 Kubernetes 资源的基本结构。我们能够创建本地的 Kubernetes 体验,并且已经准备好使用 Go 来构建与 Kubernetes 交互的应用程序。

在下一部分,我们将利用我们所学的 Kubernetes API 知识,构建一个 Go 应用程序,用于部署一个负载均衡的 HTTP 应用。

使用 Go 部署一个负载均衡的 HTTP 应用程序

现在我们对 Kubernetes API 及其暴露的资源有了更深入的了解,可以开始从kubectl转向使用 Go。

在本节中,我们将使用 Go 完成许多在上一节中使用kubectl做的相同操作。我们将使用默认上下文进行身份验证,并创建一个命名空间。然而,我们不会停在那里。我们将向集群部署一个负载均衡的 HTTP 应用程序,并在向服务发送请求时,查看日志如何流式输出到 STDOUT。

本节的代码可以在github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/14/workloads找到。我们接下来要讲解的示例可以通过以下命令执行:

$ kind create cluster --name workloads --config kind-config.yaml
$ kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
$ kubectl wait --namespace ingress-nginx \
  --for=condition=ready pod \
  --selector=app.kubernetes.io/component=controller \
  --timeout=90s
$ go run .

前面的命令将创建一个名为workloads的 KinD 集群,并使用一个配置文件来启用集群的主机网络入口。我们将使用入口来公开运行在集群中的服务,地址是localhost:port。然后,命令将部署 NGINX 入口控制器,并等待它准备就绪。最后,我们运行 Go 程序来部署我们的应用程序。在服务部署并运行后,打开浏览器并访问http://localhost:8080/hello。你应该会看到如下内容:

https://github.com/OpenDocCN/freelearn-devops-pt3-zh/raw/master/docs/go-dop/img/B17626_14_001.jpg

图 14.1 – 部署的 NGINX hello world

你应该能够看到请求日志流输出到 STDOUT。它们应该如下所示:

10.244.0.7 - - [07/Mar/2022:02:34:59 +0000] "GET /hello HTTP/1.1" 200 7252 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.3 Safari/605.1.15" "172.22.0.1"

如果你刷新页面,你应该看到服务器名称变化,表明请求正在跨部署中的两个 Pod 副本进行负载均衡。按Ctrl + C来终止 Go 程序。

要销毁集群,请运行以下命令:

$ kind delete cluster --name workloads

前面的命令将删除名为workloadskind集群。接下来,让我们探索这个 Go 应用程序,了解刚刚发生了什么。

一切从main开始

让我们直接进入代码,看看这个 Go 程序到底在做什么:

func main() {
     ctx, cancel := context.WithCancel(context.Background())
     defer cancel()
     clientSet := getClientSet()
     nsFoo := createNamespace(ctx, clientSet, "foo")
     defer func() {
          deleteNamespace(ctx, clientSet, nsFoo)
     }()
     deployNginx(ctx, clientSet, nsFoo, "hello-world")
     fmt.Printf("You can now see your running service: http://localhost:8080/hello\n\n")
     listenToPodLogs(ctx, clientSet, nsFoo, "hello-world")
     // wait for ctrl-c to exit the program
     waitForExitSignal()
}

在前面的代码中,我们建立了一个从背景上下文派生的上下文。在这个场景中,这基本上没有什么效果,但如果你需要取消一个正在运行时间过长的请求,它将是一个非常强大的工具。接下来,我们创建了clientSet,它是一个强类型的客户端,用来与 Kubernetes API 进行交互。然后我们在createNamespacedeployNginxlistenToPodLogs中使用了clientSet。最后,我们等待一个信号来终止程序。就这样!

接下来,让我们深入探讨每个函数,从getClientSet开始。

创建 ClientSet

让我们看看getClientSet

func getClientSet() *kubernetes.Clientset {
	var kubeconfig *string
	if home := homedir.HomeDir(); home != "" {
		kubeconfig = flag.String(
			"kubeconfig",
			filepath.Join(home, ".kube", "config"),
			"(optional) absolute path to the kubeconfig file",
		)
	} else {
		kubeconfig = flag.String(
			"kubeconfig",
			"",
			"absolute path to the kubeconfig file",
		)
	}
	flag.Parse()
	// use the current context in kubeconfig
	config, err := clientcmd.BuildConfigFromFlags(
		"",
		*kubeconfig,
	)
	panicIfError(err)

	// create the clientSet
	cs, err := kubernetes.NewForConfig(config)
	panicIfError(err)
	return cs
}

在前面的代码中,你可以看到我们构建了标志绑定,用来使用现有的~/.kube/config上下文,或者通过绝对文件路径接受kubeconfig文件。然后,我们使用这个标志或默认值构建配置。接着,这个配置被用来创建*kubernetes.ClientSet。正如我们在kubectl部分所学到的,kubeconfig包含了我们连接和认证服务器所需的所有信息。现在我们有了一个客户端,准备与 Kubernetes 集群进行交互。

接下来,让我们看看 ClientSet 的实际操作。

创建一个命名空间

现在我们有了一个 ClientSet,可以用它来创建我们需要部署的资源,以运行负载均衡的 HTTP 应用程序。我们来看看 createNamespace

func createNamespace(
	ctx context.Context,
	clientSet *kubernetes.Clientset,
	name string,
) *corev1.Namespace {
	fmt.Printf("Creating namespace %q.\n\n", name)
	ns := &corev1.Namespace{
		ObjectMeta: metav1.ObjectMeta{
			Name: name,
		},
	}
	ns, err := clientSet.CoreV1().
		Namespaces().
		Create(ctx, ns, metav1.CreateOptions{})
	panicIfError(err)
	return ns
}

在上述代码中,我们构建了一个 corev1.Namespace 结构体,在 ObjectMeta 字段中提供名称。如果你还记得我们之前使用 kubectl 创建命名空间的 YAML 示例,这个字段对应的是 metadata.name。Kubernetes 资源的 Go 结构与它们的 YAML 表现非常接近。最后,我们使用 clientSet 通过 Kubernetes API 服务器创建命名空间并返回命名空间。metav1.CreateOptions 包含一些选项,用于更改 create 操作的行为,但我们在本书中不会探讨这个结构。

我们现在已经创建了用于部署应用程序的命名空间。接下来,让我们看看如何部署应用程序。

将应用程序部署到命名空间中

现在我们已经创建了 clientSet 和命名空间,准备好部署将代表我们应用程序的资源。我们来看看 deployNginx 函数:

func deployNginx(
	ctx context.Context,
	clientSet *kubernetes.Clientset,
	ns *corev1.Namespace,
	name string,
) {
	deployment := createNginxDeployment(
		ctx,
		clientSet,
		ns,
		name,
	)
	waitForReadyReplicas(ctx, clientSet, deployment)
	createNginxService(ctx, clientSet, ns, name)
	createNginxIngress(ctx, clientSet, ns, name)
}

在上述代码中,我们创建了 NGINX 部署资源,并等待部署的副本准备就绪。部署就绪后,代码创建了服务资源,以便在部署中的 pods 之间进行负载均衡。最后,我们创建了 ingress 资源,以便在本地主机端口上公开该服务。

接下来,让我们查看这些函数,了解它们在做什么。

创建 NGINX 部署

部署应用程序的第一个函数是 createNginxDeployment

func createNginxDeployment(
	ctx context.Context,
	clientSet *kubernetes.Clientset,
	ns *corev1.Namespace,
	name string,
) *appv1.Deployment {
	var (
		matchLabel = map[string]string{"app": "nginx"}
		objMeta    = metav1.ObjectMeta{
			Name:      name,
			Namespace: ns.Name,
			Labels:    matchLabel,
		}
            [...]
	)
	deployment := &appv1.Deployment{
		ObjectMeta: objMeta,
		Spec: appv1.DeploymentSpec{
			Replicas: to.Int32Ptr(2),
			Selector: &metav1.LabelSelector{
				MatchLabels: matchLabel,
			},
			Template: template,
		},
	}
	deployment, err := clientSet.
		AppsV1().
		Deployments(ns.Name).
		Create(ctx, deployment, metav1.CreateOptions{})
	panicIfError(err)
	return deployment
}

上述代码初始化了 matchLabel,它是一个键值对,将用于将 Deployment 与 Service 连接。我们还为 Deployment 资源初始化了 ObjectMeta,使用命名空间和 matchLabel。接下来,我们构建了一个包含规范的 Deployment 结构,期望有两个副本,使用我们之前构建的 matchLabelLabelSelector,并且有一个 Pod 模板,运行一个容器,使用 nginxdemos/hello:latest 镜像,并在容器上暴露端口 80。最后,我们创建了部署,指定了命名空间和我们构建的 Deployment 结构。

现在我们已经创建了 Deployment,让我们看看如何等待 Deployment 中的 pods 变为就绪状态。

等待准备就绪的副本与期望副本匹配

当创建一个 Deployment 时,需要为每个副本创建 pod,并使其开始运行,才能处理请求。我们编写的 Kubernetes 或 API 请求并没有要求我们等待这些 pods。这只是为了提供一些用户反馈,并展示资源状态部分的用法。我们来看看如何等待 Deployment 的状态与期望的状态匹配:

func waitForReadyReplicas(
	ctx context.Context,
	clientSet *kubernetes.Clientset,
	deployment *appv1.Deployment,
) {
	fmt.Printf("Waiting for ready replicas in: %q\n", deployment.Name)
	for {
		expectedReplicas := *deployment.Spec.Replicas
		readyReplicas := getReadyReplicasForDeployment(
			ctx,
			clientSet,
			deployment,
		)
		if readyReplicas == expectedReplicas {
			fmt.Printf("replicas are ready!\n\n")
			return
		}
		fmt.Printf("replicas are not ready yet. %d/%d\n",
			readyReplicas, expectedReplicas)
		time.Sleep(1 * time.Second)
	}
}
func getReadyReplicasForDeployment(
	ctx context.Context,
	clientSet *kubernetes.Clientset,
	deployment *appv1.Deployment,
) int32 {
	dep, err := clientSet.
		AppsV1().
		Deployments(deployment.Namespace).
		Get(ctx, deployment.Name, metav1.GetOptions{})
	panicIfError(err)
	return dep.Status.ReadyReplicas
}

在前面的代码中,我们通过循环检查期望的副本数是否与就绪副本数匹配,若匹配则返回。如果不匹配,则等待一秒钟再试。这个代码并不非常健壮,但它展示了 Kubernetes 操作的目标导向性质。

现在我们已经有了一个正在运行的部署,我们可以构建一个 Service,以在部署中的 Pods 之间进行负载均衡。

创建用于负载均衡的 Service

部署中的两个 Pod 副本现在在80端口运行 NGINX 演示,但每个副本都有自己的接口。我们可以将流量定向到每个副本,但更方便的方法是定向到一个地址并进行负载均衡请求。让我们创建一个 Service 资源来实现这一点:

func createNginxService(
	ctx context.Context,
	clientSet *kubernetes.Clientset,
	ns *corev1.Namespace,
	name string,
) {
	var (
		matchLabel = map[string]string{"app": "nginx"}
		objMeta    = metav1.ObjectMeta{
			Name:      name,
			Namespace: ns.Name,
			Labels:    matchLabel,
		}
	)
	service := &corev1.Service{
		ObjectMeta: objMeta,
		Spec: corev1.ServiceSpec{
			Selector: matchLabel,
			Ports: []corev1.ServicePort{
				{
					Port:     80,
					Protocol: corev1.ProtocolTCP,
					Name:     "http",
				},
			},
		},
	}
	service, err := clientSet.
		CoreV1().
		Services(ns.Name).
		Create(ctx, service, metav1.CreateOptions{})
	panicIfError(err)
}

在前面的代码中,我们初始化了与部署中相同的matchLabelObjectMeta。然而,我们并没有创建一个 Deployment 资源,而是创建了一个 Service 资源结构,指定了要匹配的 Selector 和要暴露的传输控制协议TCP)端口。Selector 标签是确保负载均衡器的后端池中包含正确 Pods 的关键。最后,我们像其他 Kubernetes 资源一样创建了 Service。

我们只剩下一步了。我们需要通过入口来暴露我们的服务,这样我们就可以通过本地机器上的端口将流量发送到集群中。

创建入口以在本地主机端口暴露我们的应用程序

此时,我们无法通过localhost:port访问我们的服务。我们可以通过kubectl将流量转发到集群中,但这个部分留给你自己探索。接下来我们将创建一个入口并在本地主机网络上打开一个端口。让我们来看一下如何创建入口资源:

func createNginxIngress(
	ctx context.Context,
	clientSet *kubernetes.Clientset,
	ns *corev1.Namespace,
	name string,
) {
	var (
		prefix  = netv1.PathTypePrefix
		objMeta = metav1.ObjectMeta{
			Name:      name,
			Namespace: ns.Name,
		}
		ingressPath = netv1.HTTPIngressPath{
			PathType: &prefix,
			Path:     "/hello",
			Backend: netv1.IngressBackend{
				Service: &netv1.IngressServiceBackend{
					Name: name,
					Port: netv1.ServiceBackendPort{
						Name: "http",
					},
				},
			},
		}
	ingress := &netv1.Ingress{
		ObjectMeta: objMeta,
		Spec: netv1.IngressSpec{
			Rules: rules,
		},
	}
	ingress, err := clientSet.
		NetworkingV1().
		Ingresses(ns.Name).
		Create(ctx, ingress, metav1.CreateOptions{})
	panicIfError(err)
}

在前面的代码中,我们初始化了一个前缀,与之前相同的objMeta,以及ingressPath,它将路径前缀/hello映射到我们创建的服务名和端口名。是的,Kubernetes 为我们做了将网络连接起来的“魔法”!接下来,我们按照之前结构的方式构建 Ingress 结构,并使用clientSet创建入口。通过这最后一步,我们使用 Go 和 Kubernetes API 部署了整个应用程序堆栈。

接下来,让我们回到main.go,看看如何使用 Kubernetes 流式传输 Pods 的日志,展示程序运行时的 HTTP 请求。

为 NGINX 应用程序流式传输 Pod 日志

Kubernetes API 提供了许多出色的功能来运行工作负载。其中最基础和最有用的功能之一就是能够访问正在运行的 Pods 的日志。让我们来看一下如何将多个运行中的 Pods 的日志流式传输到 STDOUT:

func listenToPodLogs(
	ctx context.Context,
	clientSet *kubernetes.Clientset,
	ns *corev1.Namespace,
	containerName string,
) {
	// list all the pods in namespace foo
	podList := listPods(ctx, clientSet, ns)
	for _, pod := range podList.Items {
		podName := pod.Name
		go func() {
			opts := &corev1.PodLogOptions{
				Container: containerName,
				Follow:    true,
			}
			podLogs, err := clientSet.
				CoreV1().
				Pods(ns.Name).
				GetLogs(podName, opts).
				Stream(ctx)
			panicIfError(err)
			_, _ = os.Stdout.ReadFrom(podLogs)
		}()
	}
}
func listPods(
	ctx context.Context,
	clientSet *kubernetes.Clientset,
	ns *corev1.Namespace,
) *corev1.PodList {
	podList, err := clientSet.
		CoreV1().
		Pods(ns.Name).
		List(ctx, metav1.ListOptions{})
	panicIfError(err)
	/* omitted some logging for brevity */
	return podList
}

在前面的代码中,listenToPodLogs 列出了给定命名空间中的 pods,然后为每个 pod 启动了 go func。在 go func 中,我们使用 Kubernetes API 请求一个 podLogs 的流,它返回一个 io.ReadCloser,可以从 pod 中实时读取日志。然后我们告诉 STDOUT 从这个管道中读取,日志就会被输出到我们的 STDOUT。

如果你认为从正在运行的工作负载中获取日志会比这更困难,我想你不会是唯一一个这样想的人。Kubernetes 确实非常复杂,但由于一切都以 API 的形式暴露出来,这使得该平台极其灵活和可编程。

我们已经探索了除了 waitForExitSignal 的所有功能,这个功能相对简单,并没有为此处讲述的 Kubernetes 相关内容增添什么。如果你想了解它,可以参考源代码仓库。

通过这个使用 Kubernetes API 来以 Go 编程方式部署应用的示例,我希望你能从中获得一种力量感,去学习、构建,并且在与 Kubernetes API 交互时感到相对舒适。Kubernetes API 的内容远不止这些,而且它还在不断发展。事实上,在接下来的部分,我们将开始讨论如何通过自定义资源扩展 Kubernetes API。

扩展 Kubernetes 与自定义资源和操作符

在前面的部分,我们已经了解到 Kubernetes API 不仅仅是一个单一的 API,而是由一系列聚合的 API 组成,这些 API 由名为 操作符控制器 的协作服务支持。操作符是对 Kubernetes 的扩展,它们利用自定义资源通过控制器来管理系统和应用。控制器是操作符的组件,执行某种资源的控制循环。自定义资源的控制循环是一个迭代过程,它观察资源的期望状态,并可能通过多个循环来推动系统状态朝着期望的状态发展。

之前的句子比较抽象。我喜欢换个方式总结。Kubernetes 是一个自动化平台。自动化是一系列步骤和决策树,驱动实现最终目标。我喜欢以类似的方式看待操作符。我认为编写操作符就像是将一份操作手册——人类完成操作活动的步骤——转化为让计算机执行的自动化。操作符和控制器就像是将操作知识结晶成代码,在 Kubernetes 中运行。

自定义资源可以表示任何内容。它们可以是与 Kubernetes 资源相关的事物,也可以是完全与 Kubernetes 无关的外部事物。举个例子,关于集群工作负载的自定义资源,在第九章使用 OpenTelemetry 进行可观察性中,我们讨论了 OTel 收集器并通过其容器镜像在 docker-compose 中部署它,但我们也可以使用 Kubernetes 操作器来做同样的事情,在 Kubernetes 集群中运行它。OTel 操作器暴露了一个自定义资源,像下面这样:

apiVersion: opentelemetry.io/v1alpha1
kind: OpenTelemetryCollector
metadata:
  name: simplest
spec:
  config: |
    receivers:
      otlp:
        protocols:
          grpc:
          http:
    processors:
    exporters:
      logging:
    service:
      pipelines:
        traces:
          receivers: [otlp]
          processors: []
          exporters: [logging]

在前面的代码块中,我们看到一个自定义资源,描述了来自 github.com/open-telemetry/opentelemetry-operator 的 OTel 收集器。这个自定义资源以特定领域的语言描述了 OpenTelemetry 操作器应该如何配置和运行 OpenTelemetry 收集器。然而,一个自定义资源也可以像我们在下一部分看到的那样,轻松表示一个自定义的 Pet 资源,用于表示宠物店中的宠物。

你还记得如何识别前述资源的组、版本、类型、命名空间和名称吗?答案是 group: opentelemetry.ioversion: v1alpha1kind: OpenTelemetryCollectornamespace: defaultname: simplest

在这一部分,我想强调的是,如果有人去除掉 Pods、节点、存储、网络以及 Kubernetes 容器工作负载调度的其他部分,只剩下 Kubernetes API 服务器,它仍然会是一个极其有用的软件。在这一部分,我们将介绍一些关于操作器、自定义资源定义CRDs)、控制器以及 Kubernetes API 服务器强大功能的背景知识。我们无法深入覆盖所有内容,但这次概览将有助于我们实现第一个操作器,并希望能激励你深入学习如何扩展 Kubernetes API。

自定义资源定义

CRDs 是可以应用于 Kubernetes 集群的资源,用于为自定义资源创建新的 RESTful 资源路径。让我们来看一下 Kubernetes 文档中关于 CronJob 的示例:kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#create-a-customresourcedefinition

apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
  # name must be in the form: <plural>.<group>
  name: crontabs.stable.example.com
spec:
  # group name to use for REST API: /apis/<group>/<version>
  group: stable.example.com
  # list of versions supported by this CustomResourceDefinition
  versions:
    - name: v1
      # Each version can be enabled/disabled by Served flag.
      served: true
      # only one version must be marked as the storage version.
      storage: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                cronSpec:
                  type: string
                image:
                  type: string
                replicas:
                  type: integer
  # either Namespaced or Cluster
  scope: Namespaced
  names:
    plural: crontabs
    singular: crontab
    kind: CronTab
    shortNames:
    - ct

正如你从之前的 YAML 中看到的,CRD 被指定为 Kubernetes 中的任何资源。CRD 资源有groupversionkindname,但在spec中,你可以看到描述新资源类型的元数据,并使用 OpenAPI V3 来描述模式。同样,注意到 spec 包含了自定义资源的 group、version 和 kind。如 YAML 结构所示,自定义资源可以在任何给定时间提供多个版本,但只能有一个版本标记为存储版本。

在下一节中,我们将讨论 Kubernetes 如何能够仅存储一个版本但提供多个版本。

自定义资源版本管理和转换

如前节所述,Kubernetes 仅存储资源的一个版本。资源的新版本通常在资源的模式发生变化时引入——例如,添加了一个新字段或对模式进行了其他变更。在这种情况下,Kubernetes 需要某种方法来在资源版本之间进行转换。Kubernetes 的做法是使用转换 Webhook。这意味着你可以注册一个 Webhook,将资源的存储版本转换为请求的版本。这形成了一个中心和分支模型,其中中心是存储版本,分支是其他受支持的版本。你可以在 Kubernetes 文档中看到一个示例:kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definition-versioning/#configure-customresourcedefinition-to-use-conversion-webhooks

稍微消化一下这个概念。这是任何 API 平台都应该提供的强大功能。拥有一种标准化的方法来将一个 API 版本转换为另一个版本,使得在微服务环境中更容易平滑地采用组件。

结构化模式、验证和默认值

正如我们在前面的 CronJob CRD 规格示例中看到的,我们可以使用 OpenAPI 来描述资源的强类型模式。这对于为需要与 API 交互的编程语言生成 API 客户端非常有益。此外,我们还可以描述各种验证,以确保资源的结构和值的各个方面。例如,我们可以描述哪些字段是必需的,值的有效范围,字符串的有效模式,以及结构和值的许多其他方面。此外,我们还可以为字段提供默认值并在模式中指定它们。

除了架构之外,API 服务器还暴露了验证和变更的 Webhooks,这些 Webhooks 可以填补架构失败的空白——例如,如果你想基于某些超出架构范围的逻辑来验证或更改资源。这些 Webhooks 可以被用来改善开发者在使用自定义资源时的体验,比起接受可能无效的资源,或者默认某些难以计算的值,从而避免用户需要提供这些值。

控制器

协调的核心是控制器,它为特定资源类型执行控制循环。控制器监视 Kubernetes API 中的资源类型,并注意到资源发生了变化。控制器接收到资源的新版本,观察期望的状态,观察它控制的系统的当前状态,并试图推进系统状态朝着资源中表达的期望状态发生变化。控制器并不是根据资源版本之间的差异来操作,而是根据当前的期望状态进行操作。我注意到,对于新接触控制器开发的人来说,通常会倾向于只考虑基于两个资源版本之间的差异来行动,但这并不推荐。

通常,控制器能够并发地协调多个资源,但永远不会并发地协调相同的资源。这简化了协调模型的实现。

此外,大多数控制器一次只会有一个领导者。例如,如果有两个操作符实例在运行,只有一个会成为领导者,另一个则处于空闲状态,等待当另一个进程崩溃时成为领导者。

站在巨人的肩膀上

我相信这听起来非常复杂,实际上也确实如此。然而,我们可以幸运地依赖一些开创性的项目,这些项目使得构建操作符、控制器和 CRD 变得更加容易。Kubernetes 操作符有着一个充满活力且日益壮大的生态系统。

在下一节中,我们将依赖的项目包括 controller-runtime (github.com/kubernetes-sigs/controller-runtime),kubebuilder (github.com/kubernetes-sigs/kubebuilder) 和 operator-sdk (github.com/operator-framework/operator-sdk)。controller-runtime 提供了一组 Go 库,使得构建控制器更加简单,且在 kubebuilderoperator-sdk 中都有使用。kubebuilder 是一个用于构建 Kubernetes API 的框架,提供了一套工具,可以轻松生成 API 结构、控制器和 Kubernetes API 相关的清单。operator-sdk 是操作员框架 (github.com/operator-framework) 的一个组件,它扩展自 kubebuilder,并试图解决操作员开发者面临的生命周期、发布和其他更高层次的问题。

如果你对一个雄心勃勃的项目感兴趣,想要扩展 Kubernetes API 来创建声明式集群基础设施,并使 Kubernetes 能够构建新的 Kubernetes 集群,我鼓励你查看 Cluster API (github.com/kubernetes-sigs/cluster-api)。

我希望本节内容让你对 Kubernetes API 的强大功能感到惊叹,并激发你想要进一步学习的兴趣。我相信我们已经涵盖了扩展 Kubernetes API 的基础知识,因此我们可以毫不费力地着手构建自己的协调器。在接下来的章节中,我们将使用 operator-sdk 来构建一个 Pet 资源和操作员,以协调宠物商店服务中的宠物。

构建一个宠物商店操作员

本节中,我们将基于上一节中关于 CRD、操作员和控制器的背景信息,来实现我们自己的操作员。该操作员将只有一个 CRD,Pet,并且只有一个控制器来协调这些 Pet 资源。Pet 的期望状态将与我们在前几章中使用的宠物商店服务进行协调。

正如我们在上一节中讨论的,这将是一个使用 Kubernetes 控制循环来协调一个与 Kubernetes 中其他资源无依赖关系的资源状态的示例。记住,你可以在 CRD 中建模任何内容,并使用 Kubernetes 作为构建任何类型资源的强大 API 的工具。

在本节中,你将学习如何从头开始构建一个操作符。你将定义一个新的 CRD 和控制器。你将研究构建工具和不同的代码生成工具,用于消除大部分的模板代码。你将把控制器和宠物商店服务部署到本地的kind集群,并学习如何使用Tilt.dev实现更快的内循环开发周期。此仓库的代码位于github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/14/petstore-operator

初始化新操作符

在本节中,我们将使用operator-sdk命令行工具初始化新的操作符。这将用于为我们的操作符搭建项目结构:

$ operator-sdk init --domain example.com --repo github.com/Go-for-DevOps/chapter/14/petstore-operator
Writing kustomize manifests for you to edit...
Writing scaffold for you to edit...
Get controller runtime:
$ go get sigs.k8s.io/controller-runtime@v0.11.0
Update dependencies:
$ go mod tidy
Next: define a resource with:
$ operator-sdk create api

通过执行前面的命令,operator-sdk将使用一个示例域来搭建一个新的操作符项目,这将形成我们未来 CRD 的组名后缀。–repo标志基于书本代码的仓库,但你应该希望它反映你项目的仓库路径,或者省略它并让它使用默认值。让我们在搭建之后看看仓库中有什么:

$ ls -al
total 368
-rw-------  1 david  staff    776 Feb 27 10:15 Dockerfile
-rw-------  1 david  staff   9884 Feb 27 10:16 Makefile
-rw-------  1 david  staff    261 Feb 27 10:16 PROJECT
drwx------  8 david  staff    256 Feb 27 10:16 config/
-rw-------  1 david  staff   3258 Feb 27 10:16 go.mod
-rw-r--r--  1 david  staff  94793 Feb 27 10:16 go.sum
drwx------  3 david  staff     96 Feb 27 10:15 hack/
-rw-------  1 david  staff   2791 Feb 27 10:15 main.go

前面的列表展示了项目的顶层结构。Dockerfile 包含构建控制器镜像的命令。Makefile 包含各种有用的任务;然而,在本教程中我们不会多加使用它。PROJECT文件包含关于操作符的元数据。config目录包含描述和部署操作符及 CRD 到 Kubernetes 所需的清单。hack目录包含一个模板许可头,将被添加到生成的文件中,它是放置有用的开发或构建脚本的好地方。其余的文件只是常规的 Go 应用程序代码。

现在我们对为我们搭建的结构有了大致了解,可以继续生成我们的Pet资源和控制器:

$ operator-sdk create api --group petstore --version v1alpha1 --kind Pet --resource --controller
Writing kustomize manifests for you to edit...
Writing scaffold for you to edit...
api/v1alpha1/pet_types.go
controllers/pet_controller.go
Update dependencies:
$ go mod tidy
Running make:
$ make generate
go: creating new go.mod: module tmp
# ... lots of go mod output ...
Next: implement your new API and generate the manifests (e.g. CRDs,CRs) with:
$ make manifests

通过执行前面的命令,我已经指示operator-sdkpetstore组中创建一个新的 API,使用Pet种类的v1alpha1版本,并生成该类型的 CRD 和控制器。请注意,命令创建了api/v1alpha1/pet_types.gocontrollers/pet_controller.go,然后运行了make generatemake manifests。很快,我们将看到code注释在这两个 Go 文件中导致make generatemake manifests生成 CRD 清单以及 Kubernetes 的基于角色的授权控制RBAC)用于控制器。操作符的 RBAC 条目将赋予控制器对新生成的资源执行 CRUD 操作的权限。CRD 清单将包含我们新创建资源的架构。

接下来,让我们快速查看已更改的文件:

$ git status
M  PROJECT
A  api/v1alpha1/groupversion_info.go
A  api/v1alpha1/pet_types.go
A  api/v1alpha1/zz_generated.deepcopy.go
A  config/crd/bases/petstore.example.com_pets.yaml
A  config/crd/kustomization.yaml
A  config/crd/kustomizeconfig.yaml
A  config/crd/patches/cainjection_in_pets.yaml
A  config/crd/patches/webhook_in_pets.yaml
A  config/rbac/pet_editor_role.yaml
A  config/rbac/pet_viewer_role.yaml
A  config/samples/kustomization.yaml
A  config/samples/petstore_v1alpha1_pet.yaml
A  controllers/pet_controller.go
A  controllers/suite_test.go
M  go.mod
M  main.go

正如我们所见,文件进行了相当多的更改。我不会深入讨论每一项更改。最值得注意的是生成了config/crd/bases/petstore.example.com_pets.yaml,它包含了我们的Pet资源的 CRD。在运维项目中,通常将 API 中的资源描述放在api/目录下,Kubernetes 清单文件放在config/目录下,控制器放在controllers/目录下。

接下来,让我们看看在api/v1alpha1/pet_types.go中生成了什么内容:

// EDIT THIS FILE!  THIS IS SCAFFOLDING FOR YOU TO OWN!
// NOTE: json tags are required.  Any new fields you add must have json tags for the fields to be serialized.
// PetSpec defines the desired state of Pet
type PetSpec struct {
     // INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
     // Important: Run "make" to regenerate code after modifying this file
     // Foo is an example field of Pet. Edit pet_types.go to remove/update
     Foo string `json:"foo,omitempty"`
}
// PetStatus defines the observed state of Pet
type PetStatus struct {
     // INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
     // Important: Run "make" to regenerate code after modifying this file
}

上面的代码展示了pet_types.go文件中的一个代码片段。create api命令生成了一个包含specstatusPet资源。PetSpec包含一个名为Foo的字段,该字段会序列化为键foo,并且在创建或更新资源时是可选的。status目前为空。

请注意文件中的注释。它们指示我们在这里添加新字段到类型中,并在完成后运行make,以确保config/目录下的 CRD 清单文件得到更新。

现在,让我们来看一下文件的其余部分:

//+kubebuilder:object:root=true
//+kubebuilder:subresource:status
// Pet is the Schema for the pets API
type Pet struct {
     metav1.TypeMeta   `json:",inline"`
     metav1.ObjectMeta `json:"metadata,omitempty"`
     Spec   PetSpec   `json:"spec,omitempty"`
     Status PetStatus `json:"status,omitempty"`
}
//+kubebuilder:object:root=true
// PetList contains a list of Pet
type PetList struct {
     metav1.TypeMeta `json:",inline"`
     metav1.ListMeta `json:"metadata,omitempty"`
     Items           []Pet `json:"items"`
}
func init() {
     SchemeBuilder.Register(&Pet{}, &PetList{})
}

在这里,我们可以看到PetPetList的定义,它们都会在接下来的架构构建器中注册。请注意//+kubebuilder build注释。这些构建注释指示kubebuilder如何生成 CRD 清单文件。

请注意,Pet已经定义了带有json标签的specstatus,这些标签我们在之前处理过的其他 Kubernetes 资源中也见过。Pet还包括TypeMeta,它提供了 Kubernetes 的组版本种类信息,以及ObjectMeta,它包含了资源的名称、命名空间和其他元数据。

使用这些结构,我们已经拥有一个功能完全的自定义资源。然而,当前的资源并没有表示我们希望用来表示宠物资源的字段,因此需要更新以更好地表示我们的宠物结构。

接下来,让我们看看在controllers/pet_controller.go中为PetReconciler生成了什么内容,PetReconciler是运行对宠物资源进行对账的控制循环的控制器:

type PetReconciler struct {
     client.Client
     Scheme *runtime.Scheme
}
//+kubebuilder:rbac:groups=petstore.example.com,resources=pets,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=petstore.example.com,resources=pets/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=petstore.example.com,resources=pets/finalizers,verbs=update
func (r *PetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
     _ = log.FromContext(ctx)
     return ctrl.Result{}, nil
}
func (r *PetReconciler) SetupWithManager(mgr ctrl.Manager) error {
     return ctrl.NewControllerManagedBy(mgr).
          For(&petstorev1alpha1.Pet{}).
          Complete(r)
}

在上面的代码中,我们可以看到一个PetReconciler类型,它嵌入了一个client.Client,这是一个通用的 Kubernetes API 客户端,还有一个*runtime.Scheme,它包含已注册的已知类型和架构。如果我们继续往下看,可以看到一系列//+kubebuilder:rbac build注释,它们指示代码生成器为控制器创建 RBAC 权限,以便它能够操作Pet资源。接下来,我们可以看到Reconcile func,它会在每次资源发生更改且需要与宠物商店对账时被调用。最后,我们可以看到SetupWithManager函数,它从main.go中被调用,以启动控制器并告知它和管理器控制器将会对哪个资源进行对账。

我们已经覆盖了脚手架过程中的重要变化。接下来我们可以继续实现我们的Pet资源,以反映宠物商店中的领域模型。我们在宠物商店中的pet实体有三个可变的必填属性,分别是NameTypeBirthday,以及一个只读属性ID。我们需要将这些属性添加到我们的Pet资源中,以便暴露给 API:

// PetType is the type of the pet. For example, a dog.
// +kubebuilder:validation:Enum=dog;cat;bird;reptile
type PetType string
const (
    DogPetType     PetType = "dog"
    CatPetType     PetType = "cat"
    BirdPetType    PetType = "bird"
    ReptilePetType PetType = "reptile"
)
// PetSpec defines the desired state of Pet
type PetSpec struct {
     // Name is the name of the pet
     Name string `json:"name"`
     // Type is the type of pet Type PetType `json:"type"`
     // Birthday is the date the pet was born
     Birthday metav1.Time `json:"birthday"`
}
// PetStatus defines the observed state of Pet
type PetStatus struct {
    // ID is the unique ID for the pet
    ID string `json:"id,omitempty"`
}

以上是我对Pet所做的代码修改,目的是反映宠物商店服务的领域模型。请注意PetType类型前面的// +kubebuilder:validation:Enum注解。这是告诉 CRD 清单生成器,模式应当添加验证,确保PetSpec中的Type字段只能提供这些字符串。此外,注意spec中的每个字段都没有omitempty JSON 标签。这将告诉 CRD 清单生成器,这些字段是必填的。

Pet的状态只有一个ID字段,这个字段可以为空。它将存储从宠物商店服务返回的唯一标识符。

现在我们已经定义了我们的Pet,让我们在控制器循环中协调pet和宠物商店:

// Reconcile moves the current state of the pet to be the desired state described in the pet.spec.
func (r *PetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, errResult error) {
     logger := log.FromContext(ctx)
     pet := &petstorev1.Pet{}
     if err := r.Get(ctx, req.NamespacedName, pet); err != nil {
          if apierrors.IsNotFound(err) {
               logger.Info("object was not found")
               return reconcile.Result{}, nil
          }
          logger.Error(err, "failed to fetch pet from API server")
          // this will cause this pet resource to be requeued
          return ctrl.Result{}, err
     }
     helper, err := patch.NewHelper(pet, r.Client)
     if err != nil {
          return ctrl.Result{}, errors.Wrap(err, "failed to create patch helper")
     }
     defer func() {
          // patch the resource
          if err := helper.Patch(ctx, pet); err != nil {
               errResult = err
          }
     }()
     if pet.DeletionTimestamp.IsZero() {
          // the pet is not marked for delete
          return r.ReconcileNormal(ctx, pet)
     }
     // pet has been marked for delete
     return r.ReconcileDelete(ctx, pet)
}

上述代码已被添加到对pet资源的协调中。当我们从 API 服务器接收到变化时,我们并没有获得很多信息。我们只会得到变化的宠物的NamespacedNameNamespacedName包含了发生变化的宠物的命名空间和名称。记住,PetReconciler嵌入了一个client.Client。它为我们提供了访问 Kubernetes API 服务器的权限。我们使用Get方法请求需要协调的宠物。如果没有找到该宠物,我们会返回一个空的协调结果和nil错误。这通知控制器等待另一次变化发生。如果请求过程中发生了错误,我们会返回空的协调结果和错误。如果错误不为nil,协调器会再次尝试并进行指数退避。

如果我们能够成功获取宠物信息,我们将创建一个补丁助手,这将允许我们在协调循环过程中追踪Pet资源的变化,并在协调循环结束时将资源变化补丁回传给 Kubernetes API 服务器。defer确保我们会在Reconcile函数的最后进行补丁操作。

如果宠物没有设置删除时间戳,那么我们知道 Kubernetes 并未标记该资源为删除状态,因此我们调用ReconcileNormal,在这个过程中我们会尝试将期望的状态持久化到宠物商店中。否则,我们会调用ReconcileDelete将宠物从宠物商店中删除。

接下来我们来看一下ReconcileNormal,并理解当我们遇到非删除的宠物资源状态变化时应该做什么:

func (r *PetReconciler) ReconcileNormal(ctx context.Context, pet *petstorev1.Pet) (ctrl.Result, error) {
     controllerutil.AddFinalizer(pet, PetFinalizer)
     psc, err := getPetstoreClient()
     if err != nil {
          return ctrl.Result{}, errors.Wrap(err, "unable to construct petstore client")
     }

     psPet, err := findPetInStore(ctx, psc, pet)
     if err != nil {
          return ctrl.Result{}, errors.Wrap(err, "failed trying to find pet in pet store")
     }
     if psPet == nil {
          // no pet was found, create a pet in the store
          err := createPetInStore(ctx, pet, psc)
          return ctrl.Result{}, err
     }
     // pet was found, update the pet in the store
     if err := updatePetInStore(ctx, psc, pet, psPet.Pet); err != nil {
          return ctrl.Result{}, err
     }
     return ctrl.Result{}, nil
}

ReconcileNormal中,我们始终确保PetFinalizer已经被添加到资源中。Finalizer 是 Kubernetes 知道何时可以垃圾回收资源的一种方式。如果资源上仍然有 finalizer,Kubernetes 将不会删除该资源。在控制器中,当资源具有需要在删除之前清理的外部资源时,finalizer 非常有用。在这种情况下,我们需要在 Kubernetes Pet资源被删除之前从宠物商店中移除Pet。如果我们不这么做,可能会在宠物商店中留下从未被删除的宠物。

在设置 finalizer 之后,我们构建一个宠物商店客户端。我们在这里不做更多细节说明,但可以简单说,它构建了一个用于宠物商店服务的 gRPC 客户端。通过宠物商店客户端,我们查询商店中的宠物。如果找不到该宠物,我们会在商店中创建一个;否则,我们会更新商店中的宠物,以反映 Kubernetes Pet资源中指定的期望状态。

让我们快速看一下createPetInStore函数:

func createPetInStore(ctx context.Context, pet *petstorev1.Pet, psc *psclient.Client) error {
     pbPet := &pb.Pet{
          Name:     pet.Spec.Name,
          Type:     petTypeToProtoPetType(pet.Spec.Type),
          Birthday: timeToPbDate(pet.Spec.Birthday),
     }
     ids, err := psc.AddPets(ctx, []*pb.Pet{pbPet})
     if err != nil {
          return errors.Wrap(err, "failed to create new pet")
     }
     pet.Status.ID = ids[0]
     return nil
}

当我们在宠物商店创建宠物时,我们会在 gRPC 客户端上调用AddPets,传递 Kubernetes Pet资源的期望状态,并在 Kubernetes Pet资源的状态中记录ID

让我们继续看updatePetInStore函数:

func updatePetInStore(ctx context.Context, psc *psclient.Client, pet *petstorev1.Pet, pbPet *pb.Pet) error {
     pbPet.Name = pet.Spec.Name
     pbPet.Type = petTypeToProtoPetType(pet.Spec.Type)
     pbPet.Birthday = timeToPbDate(pet.Spec.Birthday)
     if err := psc.UpdatePets(ctx, []*pb.Pet{pbPet}); err != nil {
          return errors.Wrap(err, "failed to update the pet in the store")
     }
     return nil
}

当我们更新商店中的宠物时,我们会使用获取的商店宠物,并使用 Kubernetes Pet资源中的期望状态更新字段。

如果在流程的任何一点遇到错误,我们会将错误传递到Reconcile,在那里它会触发重新排队的对账循环,并且会进行指数回退。ReconcileNormal中的操作是幂等的。它们可以重复运行,以达到相同的状态,并且在面对错误时会重试。对账循环对于失败通常是非常具有弹性的。

这就是ReconcileNormal的全部内容。让我们看看在ReconcileDelete中发生了什么:

// ReconcileDelete deletes the pet from the petstore and removes the finalizer.
func (r *PetReconciler) ReconcileDelete(ctx context.Context, pet *petstorev1.Pet) (ctrl.Result, error) {
     psc, err := getPetstoreClient()
     if err != nil {
          return ctrl.Result{}, errors.Wrap(err, "unable to construct petstore client")
     }
     if pet.Status.ID != "" {
          if err := psc.DeletePets(ctx, []string{pet.Status.ID}); err != nil {
               return ctrl.Result{}, errors.Wrap(err, "failed to delete pet")
          }
     }
     // remove finalizer, so K8s can garbage collect the resource.
     controllerutil.RemoveFinalizer(pet, PetFinalizer)
     return ctrl.Result{}, nil
}

在上面的代码块中的ReconcileDelete中,我们获取一个宠物商店客户端来与宠物商店交互。如果pet.Status.ID不为空,我们尝试从宠物商店删除该宠物。如果该操作成功,我们将移除 finalizer,通知 Kubernetes 它可以删除该资源。

你已经扩展了 Kubernetes 并创建了你的第一个 CRD 和控制器!让我们试运行一下。

要启动项目并查看你的 Kubernetes 运算符在运行中,执行以下命令:

$ ctlptl create cluster kind --name kind-petstore --registry=ctlptl-registry
$ tilt up

前面的命令将创建一个kind集群和本地的Tilt.dev。一旦你完成,你应该会看到类似如下的内容:

https://github.com/OpenDocCN/freelearn-devops-pt3-zh/raw/master/docs/go-dop/img/B17626_14_002.jpg

图 14.2 – Tilt 的所有资源 Web 视图

等待左侧面板上的每个服务变为绿色。一旦它们变绿,意味着宠物商店操作员和服务已经成功部署。如果你点击左侧列出的其中一个服务,它将显示该组件的日志输出。petstore-operator-controller-manager是你的 Kubernetes 控制器。接下来,我们将一些宠物应用到 Kubernetes 集群中,看看会发生什么。

让我们首先看看我们要应用的宠物样本。样本位于config/samples/petstore_v1alpha1_pet.yaml

---
apiVersion: petstore.example.com/v1alpha1
kind: Pet
metadata:
  name: pet-sample1
spec:
  name: Thor
  type: dog
  birthday: 2021-04-01T00:00:00Z
---
apiVersion: petstore.example.com/v1alpha1
kind: Pet
metadata:
  name: pet-sample2
spec:
  name: Tron
  type: cat
  birthday: 2020-06-25T00:00:00Z

我们有两个宠物,ThorTron。我们可以通过以下命令应用它们:

$ kubectl apply -f config/samples/petstore_v1alpha1_pet.yaml

这应该已经回复了它们被创建的消息,接下来你应该可以通过运行以下命令来获取它们:

$ kubectl get pets
NAME          AGE
pet-sample1   2m17s
pet-sample2   2m17s

我们可以看到我们定义了两个宠物。让我们确保它们有 ID。运行以下命令:

$ kubectl get pets -o yaml
apiVersion: petstore.example.com/v1alpha1
kind: Pet
metadata:
  finalizers:
  - pet.petstore.example.com
  name: pet-sample2
  namespace: default
spec:
  birthday: "2020-06-25T00:00:00Z"
  name: Tron
  type: cat
status:
  id: 23743da5-34fe-46f6-bed8-1f5bdbaabbe6

我已省略了前面代码中的一些噪声内容,但这大致是你应该看到的。Tron 有一个由宠物商店服务生成的 ID;它被应用到 Kubernetes 的Pet资源状态中。

现在,让我们通过将Thor的名称更改为Thorbert来测试我们的同步循环:

$ kubectl edit pets pet-sample1

这将打开你的默认编辑器。你可以去更改Thor的值为Thorbert,以触发一个新的同步循环。

你应该能在浏览器中看到类似的输出,并且 Tilt 中会有宠物商店操作员的日志。

[manager] 1.6466368389433222e+09     INFO     controller.pet     finding pets in store     {"reconciler group": "petstore.example.com", "reconciler kind": "Pet", "name": "pet-sample1", "namespace": "default", "pet": "Thorbert", "id": "cef9499f-6214-4227-b217-265fd8f196e6"}

正如你从前面的代码中看到的,Thor现在已经更改为Thorbert

最后,让我们通过运行以下命令来删除这些宠物:

$ kubectl delete pets --all
pet.petstore.example.com "pet-sample1" deleted
pet.petstore.example.com "pet-sample2" deleted

删除资源后,你应该能够在 Tilt 中查看日志输出,反映delete操作已成功。

在本节中,你学习了如何从零开始构建操作员,扩展 Kubernetes API 并创建一个自定义资源,该资源能够将状态同步到外部服务,并在此过程中使用了一些非常有用的工具。

总结

在本章中,我们学习了如何使用 Go 部署和操作 Kubernetes 中的资源。我们在此基础上扩展了 Kubernetes,创建了自定义的Pet资源,并学会了如何持续同步宠物的期望状态与宠物商店的状态。我们学到了如何扩展 Kubernetes 以表示任何外部资源,并且它提供了一个强大的平台来描述几乎任何领域。

你应该能够将本章中学到的内容应用于自动化与 Kubernetes 资源的交互,并通过 Kubernetes API 将 Kubernetes 扩展到原生暴露你自己的资源。我敢打赌,你能想到你公司里的一些服务和资源,你希望能够通过简单地将一些 YAML 应用到 Kubernetes 集群中来管理它们。你现在已经具备了解决这些问题的知识。

在下一章,我们将学习如何使用 Go 编程来管理云端资源。我们将学习如何通过 Go 客户端库与云服务提供商的 API 进行交互,以便修改云资源,并在资源配置完成后,如何使用这些云服务和基础设施。

第十五章:编程云

你可能听过“云就是别人家的电脑”这句话。虽然有一定的道理,但也有些偏离实际。云服务提供商提供的是运行在其数据中心的虚拟机,你可以付费使用,从这个角度看,确实是在使用别人家的电脑。然而,这并没有呈现出云服务提供商的整体面貌。云服务提供商是由数百个应用托管、数据、合规性和计算基础设施服务组成的,这些服务分布在全球数百个数据中心,并通过完全可编程的 API 进行暴露。

在本章中,我们将学习如何使用 Microsoft Azure 与云 API 进行交互。我们将从了解 API 的本质开始,包括如何描述 API 以及在哪里找到有关它们的更多文档。接下来,我们将学习身份、身份验证和授权的基础知识。然后,我们将通过使用 Azure SDK for Go 的一系列示例,应用我们学到的知识,构建云基础设施并利用其他云服务。

到本章结束时,你将掌握有效使用 Microsoft Azure 的知识,并获得与其他云服务提供商合作的可转移技能。

本章将涵盖以下主题:

  • 什么是云?

  • 学习 Azure API 的基础知识

  • 使用 Azure 资源管理器构建基础设施

  • 使用已配置的 Azure 基础设施

技术要求

本章将需要以下工具:

什么是云?

亚马逊、微软和谷歌云物理计算基础设施的资本投资规模是巨大的。试想一下,建设 200 多个物理数据中心,配备多个冗余的电力和冷却系统,并且具备先进的物理安全设施,需要多少投资。这些数据中心在面临自然灾害时仍能保持韧性。即便如此,你也只是在触及冰山一角。

这些数据中心需要全球最大的互联网络之一将它们连接起来。所有这些基础设施在没有大量电力和冷却的支持下无法运作,最好是来自可持续的能源来源。例如,Azure 自 2012 年以来一直实现碳中和,并承诺到 2030 年实现碳负排放。当人们谈论超大规模云时,他们指的是这些云服务提供商的全球范围运营。

有没有想过访问这些数据中心会是怎样的体验?例如,要访问 Azure 的数据中心,你必须通过多个安全层级。你首先需要申请进入数据中心并提供有效的商业理由。如果获得批准,当你到达数据中心的外围访问点时,你会注意到周围有众多摄像头、高大的钢铁围栏以及混凝土围墙。你需要验证身份,并进入建筑物的入口。在建筑物入口,你会遇到安保人员,他们会再次通过双重身份验证(包括生物识别)来确认你的身份。通过生物识别扫描后,他们会引导你进入数据中心的特定区域。在进入数据中心的过程中,你还需要通过全身金属探测器筛查,确保你没有携带不该带出的物品。这些数据中心的安全措施非常严格。

仍然觉得这像是别人的电脑吗?

云服务提供商的物理基础设施令人敬畏。然而,我们应该将焦点从云服务提供商的运营规模转向云服务是如何暴露给开发者的。正如我们最初提到的,云服务提供商通过 API 暴露云的功能,开发者可以使用这些 API 来管理运行在云上的基础设施和应用程序。我们可以利用这些 API 构建应用程序,借助超大规模的云基础设施,使其具备全球规模。

学习 Azure API 的基础知识

现在我们知道,编程云的路径是通过 API,让我们深入了解一下这些 API。了解如何将大量的 API 组合在一起,形成一致的编程接口是非常重要的。我们还将学习在遇到挑战时,如何找到相关的代码和文档。

在这一部分,我们将讨论主要云服务如何定义 API,并为云 API 编程提供软件开发工具包SDK)。我们将了解在哪里可以找到这些 SDK,以及如何查找 API 和 SDK 的文档。

我们还将学习关于身份、基于角色的访问控制RBAC)以及资源层级的知识,特别是在微软 Azure 中。最后,我们将创建并登录一个免费的 Azure 账户,在后续章节中我们将使用它来进行云编程。

云 API 和 SDK 的背景知识

正如我们在上一节中讨论的那样,云服务提供商会暴露用于管理和访问数百个服务的 API,这些服务分布在众多区域内。这些 API 通常采用 表述性状态转移 (REST) 或 谷歌远程过程调用 (gRPC) 实现。在每个云服务提供商内部,很可能有同等数量的工程团队负责构建这些 API。为了提供一致的资源表示,至关重要的是这些 API 在整体上能为每个服务提供相似的行为。每个云服务提供商在解决这个问题时都有自己的方法。例如,在微软 Azure 中,定义 REST API 的规则由 微软 Azure REST API 指南github.com/microsoft/api-guidelines/blob/vNext/azure/Guidelines.md)明确规定。这些规则为服务团队提供了指导。

开发者通常不会直接通过 HTTP 使用云 API,而是通过使用 SDK。这些 SDK 是一组库,提供了对特定编程语言的 API 访问。

例如,Azure (github.com/Azure/azure-sdk-for-go)、AWS (https://github.com/aws/aws-sdk-go) 和 Google (github.com/googleapis/google-api-go-client) 都为它们的云服务提供了 Go SDK 以及多种其他语言的 SDK。这些 SDK 力求消除访问云 API 所需的样板代码,简化开发者编写与之交互的程序代码。除了云服务提供商发布的文档外,始终记得 GoDocs 是你的朋友。例如,Azure Blob 存储服务的 GoDocs (github.com/Azure/azure-kusto-go) 提供了使用该 SDK 的有用信息。

这些 SDK 大多数是基于机器可读的 API 规范生成的。当你拥有数百个服务和多种编程语言时,依靠大量人工编写 SDK 无法有效扩展。每个云服务提供商都有自己解决这个问题的方法。

例如,微软 Azure 几乎所有的 Azure API 参考文档(docs.microsoft.com/en-us/rest/api/azure/)和 SDK 都是使用 Azure REST API 规范库中的 OpenAPI 规范生成的(github.com/Azure/azure-rest-api-specs)。生成文档和 SDK 的整个过程托管在 GitHub 上,并由 AutoRest 代码生成器等开源工具提供支持(github.com/Azure/autorest)。

趣味提示

本书的其中一位作者 David Justice 在 Azure 建立了这一过程,并首次提交了 Azure REST API 规格仓库的代码(github.com/Azure/azure-rest-api-specs/commit/8c42e6392618a878d5286b8735b99bbde693c0a2)。

Microsoft Azure 身份、RBAC 和资源层次结构

为了准备与 Azure API 进行交互,我们需要了解一些基础知识——身份、RBAC 和资源层次结构。身份确定了与 API 交互的用户或主体。RBAC 定义了身份在 API 内可以做什么。资源层次结构描述了 Azure 云中资源之间的关系。RBAC 角色和权限描述了主体可以对给定的资源或资源层次结构做什么。例如,用户可以被分配 Azure 订阅的贡献者权限,从而能够修改该订阅中的资源。

Azure 中的身份存储在Azure Active DirectoryAAD)中。这是一个企业身份与访问管理服务,提供单点登录、多因素认证和条件访问等功能。AAD 中的身份存在于一个或多个租户中,租户包含多个身份。身份可以是用户身份,表示人类,并具有交互式身份验证流程,或者可以是服务主体,表示非人类身份,如没有交互式身份验证流程的应用程序。

Azure 中资源的根源是 Azure 订阅。订阅是一个逻辑容器,包含 Azure 资源组。每个资源,如虚拟机、存储账户或虚拟网络,都位于某个资源组内。资源组是一个逻辑实体,将多个 Azure 资源关联在一起,方便你将它们作为一个整体进行管理。

身份会被授予 RBAC 角色和权限,以便与 Azure 订阅和资源进行交互。你可以将 AAD 和 Azure 看作是两个由 RBAC 权限和角色绑定在一起的独立系统。我们不会深入探讨每个 RBAC 角色或权限,但你可以在 Azure 内置角色文档中找到更多信息(docs.microsoft.com/en-us/azure/role-based-access-control/built-in-roles)。

现在我们对将要使用的云环境有了一些基本了解,接下来我们开始吧。

创建 Azure 账户并访问 API

为了运行本章其余的示例,你需要一个 Azure 账户。如果你没有 Azure 账户,你可以注册一个免费账户,并获得 200 美元的 Azure 积分(azure.microsoft.com/en-us/free/)。

一旦你有了账户,使用 Azure CLI 登录:

$ az login

该命令将登录到您的 Azure 账户,并为您的主 Azure 订阅设置默认上下文。默认情况下,当您创建 Azure 账户时,您的身份将被授予订阅中的 owner 角色。owner 角色授予完全访问权限来管理所有资源,包括在 Azure RBAC 中分配角色的能力。要查看当前活动的订阅,请运行以下命令:

$ az account show
{
  "environmentName": "AzureCloud",
  "isDefault": true,
  "managedByTenants": [],
  "name": "mysubscription",
  "state": "Enabled",
  "tenantId": "888bf....db93",
  "user": {
      ...
  }
}

上述命令的输出显示了订阅的名称以及当前 Azure CLI 上下文的其他详细信息。在接下来的命令中,我们将使用 az CLI 直接与 Azure API 进行交互:

az rest --method get --uri "/subscriptions?api-version=2019-03-01"

上述命令将列出您的身份通过 RBAC 权限访问的订阅。请注意,作为 Azure REST API 指南的一部分,所有 Azure API 必须使用 api-version 查询参数。这是强制性的,确保 API 消费者始终可以依赖于请求和响应格式的稳定性。API 更新频繁,如果没有指定某个 API 的 api-version 查询参数,消费者可能会面临 API 的重大变化。

接下来,让我们使用 debug 标志运行相同的请求:

az rest --method get --uri "/subscriptions?api-version=2019-03-01" --debug

使用 Azure CLI 执行任何命令时,添加--debug标志将输出 HTTP 请求的详细信息,显示类似以下内容的输出:

 Request URL: 'https://management.azure.com/subscriptions?apiversion=2019-03-01'
 Request method: 'GET'
 Request headers:
     'User-Agent': 'python/3.10.2 (macOS-12.3.1-arm64-arm-64bit) AZURECLI/2.34.1 (HOMEBREW)'
    urllib3.connectionpool: Starting new HTTPS connection (1): management.azure.com:443
urllib3.connectionpool: https://management.azure.com:443 "GET /subscriptions?api-version=2019-03-01 HTTP/1.1" 200 6079
 Response status: 200
 Response headers:
     'Content-Type': 'application/json; charset=utf-8'
     'x-ms-ratelimit-remaining-tenant-reads': '11999'
     'x-ms-request-id': 'aebed1f6-75f9-48c2-ae0b-1dd18ae5ec46'
     'x-ms-correlation-request-id': 'aebed1f6-75f9-48c2-ae0b-
     'Date': 'Sat, 09 Apr 2022 22:52:32 GMT'
     'Content-Length': '6079'

该输出对于查看发送到 Azure API 的 HTTP 内容非常有用。另外,注意 URI https://management.azure.com/... 对应于Azure 资源管理器ARM)。ARM 是由每个 Azure 资源的资源提供服务组成的复合服务,负责在其中变更资源。

在这一部分,我们了解了主要云平台如何定义 API 并为 API 提供 SDK。我们还特别学习了 Azure 身份、RBAC 和资源层次结构。尽管这些信息可能特定于 Azure,但所有主要云平台遵循相同的模式。一旦你了解了某个云平台如何处理身份与访问管理IAM),它的大致方法也可以迁移到其他云平台。最后,我们登录到 Azure 账户,供后续章节使用,并学习了如何通过 Azure CLI 直接访问 Azure REST API。

在下一部分,我们将使用 Azure SDK for Go 来变更云基础设施。让我们开始用 Go 编程操作 Azure 云。

使用 Azure 资源管理器构建基础设施

云 API 分为两类:管理平面和数据平面。管理平面是一个控制基础设施创建、删除和变更的 API。数据平面是由配置好的基础设施暴露的 API。

例如,管理平面将用于创建 SQL 数据库。SQL 数据库资源的数据平面则是用于操作数据库内数据和结构的 SQL 协议。

管理平面由云资源 API 提供服务,数据平面由已配置服务暴露的 API 提供服务。

在本节中,我们将学习如何使用 Azure Go SDK 在 Azure 中配置基础设施。我们将学习如何创建和销毁资源组、虚拟网络、子网、公有 IP、虚拟机和数据库。本节的目标是让大家熟悉 Azure Go SDK 以及如何与 ARM 进行交互。

Azure Go SDK

正如我们在上一节中讨论的,云 SDK 简化了指定语言与云服务提供商 API 之间的交互。对于 Azure,我们将使用 Azure Go SDK(github.com/Azure/azure-sdk-for-go/)来与 Azure API 进行交互。具体来说,我们将使用该 SDK 的最新版本(github.com/Azure/azure-sdk-for-go#management-new-releases),该版本已经重新设计,以遵循 Azure 为 Go 语言制定的设计指南(azure.github.io/azure-sdk/golang_introduction.html)。有关最新的包和文档信息,请务必查看 Azure SDK 发布页面(azure.github.io/azure-sdk/releases/latest/mgmt/go.html)。

本节的代码位于 GitHub 的本章代码文件夹中:github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/15

设置本地环境

要运行本节的代码,您需要设置一个 .env 文件。在仓库的 ./chapter/15 目录下运行以下命令:

$ mkdir .ssh
$ ssh-keygen -t rsa -b 4096 -f ./.ssh/id_rsa -q -N ""
$ chmod 600 ./.ssh/id_rsa*

此命令将在 ./chapter/15 中创建一个 .ssh 目录,在该目录下生成一个 SSH 密钥对,并确保对该密钥对设置了正确的权限。

注意

上述命令会创建一个没有密码的 SSH 密钥。我们仅使用这个密钥对作为示例。在实际使用中,您应该为密钥设置强密码。

接下来,让我们设置一个本地的 .env 文件,用于存储示例中使用的环境变量:

echo -e "AZURE_SUBSCRIPTION_ID=$(az account show --query 'id' -o tsv)\nSSH_PUBLIC_KEY_PATH=./.ssh/id_rsa.pub" >> .env

现在,这个命令将创建一个 .env 文件,文件中包含两个环境变量,AZURE_SUBSCRIPTION_IDSSH_PUBLIC_KEY_PATH。我们通过 Azure CLI 的当前活动订阅来推导出 Azure 订阅 ID 的值。

现在我们已经设置好了本地环境,接下来构建一个 cloud-init 配置脚本,并通过公有 IP 提供 SSH 访问。

构建 Azure 虚拟机

我们先从运行示例开始,然后再深入研究构建基础设施的代码。要运行示例,请执行以下命令:

$ go run ./cmd/compute/main.go
Staring to build Azure resources...
Building an Azure Resource Group named "fragrant-violet"...
Building an Azure Network Security Group named "fragrant-violet-nsg"...
Building an Azure Virtual Network named "fragrant-violet-vnet"...
Building an Azure Virtual Machine named "fragrant-violet-vm"...
Fetching the first Network Interface named "fragrant-violet-nic-6d8bb6ea" connected to the VM...
Fetching the Public IP Address named "fragrant-violet-pip-6d8bb6ea" connected to the VM...
Connect with: `ssh -i ./.ssh/id_rsa devops@20.225.222.128`
Press enter to delete the infrastructure.

在运行 go run ./cmd/compute/main.go 后,你应该看到与前一个命令块中显示的类似的内容。从输出中可以看到,程序构建了多个基础设施组件,包括一个 Azure 资源组、网络安全组、虚拟网络和虚拟机。稍后我们将更详细地讨论这些基础设施的每个部分。

正如输出所示,你还可以使用 SSH 访问虚拟机,具体操作见输出内容。我们将使用此方法来检查虚拟机的预配置状态,以确认 cloud-init 配置脚本是否按预期运行。

如果你访问 Azure 门户,应该能看到以下内容:

https://github.com/OpenDocCN/freelearn-devops-pt3-zh/raw/master/docs/go-dop/img/B17626_15_001.jpg

图 15.1 – Azure 门户虚拟机基础设施

在前面的截图中,你可以看到资源组以及所有已创建的基础设施。接下来,我们来看看为这些基础设施提供服务的代码。

使用 Go 配置 Azure 基础设施

在这些示例中,你将看到如何构建 Azure API 客户端,查询用于访问 API 的凭证,并修改基础设施。这些示例中的许多使用了简化的错误处理方式,以使代码尽可能简洁,便于说明。panic 不是你的朋友。请根据需要适当地包装和传递错误。

让我们从 go run ./cmd/compute/main.go 的入口点开始,学习如何使用 Go 来配置云基础设施:

func main() {
	_ = godotenv.Load()
	ctx := context.Background()
	subscriptionID := helpers.MustGetenv(
		"AZURE_SUBSCRIPTION_ID",
	)
	sshPubKeyPath := helpers.MustGetenv("SSH_PUBLIC_KEY_PATH")
	factory := mgmt.NewVirtualMachineFactory(
		subscriptionID,
		sshPubKeyPath,
	)
	fmt.Println("Staring to build Azure resources...")
	stack := factory.CreateVirtualMachineStack(
		ctx,
		"southcentralus",
	)
	admin := stack.VirtualMachine.Properties.OSProfile.AdminUsername
	ipAddress := stack.PublicIP.Properties.IPAddress
	sshIdentityPath := strings.TrimRight(sshPubKeyPath, ".pub")
	fmt.Printf(
		"Connect with: `ssh -i %s %s@%s`\n\n",
		sshIdentityPath, *admin, *ipAddress,
	)
	fmt.Println("Press enter to delete the infrastructure.")
	reader := bufio.NewReader(os.Stdin)
	_, _ = reader.ReadString('\n')
	factory.DestroyVirtualMachineStack(context.Background(), stack)
}

在前面的代码中,我们使用 godotenv.Load() 加载本地 .env 文件中的环境变量。在 main 函数中,我们创建一个新的 VirtualMachineFactory 来管理 Azure 基础设施的创建和删除。基础设施在 factory.CreateVirtualMachineStack 中创建后,我们打印出 SSH 连接详情,并提示用户确认是否删除基础设施堆栈。

接下来,让我们深入了解虚拟机工厂,看看虚拟机堆栈中包含了哪些内容:

type VirtualMachineFactory struct {
     subscriptionID string
     sshPubKeyPath  string
     cred           azcore.TokenCredential
     groupsClient   *armresources.ResourceGroupsClient
     vmClient       *armcompute.VirtualMachinesClient
     vnetClient     *armnetwork.VirtualNetworksClient
     subnetClient   *armnetwork.SubnetsClient
     nicClient      *armnetwork.InterfacesClient
     nsgClient      *armnetwork.SecurityGroupsClient
     pipClient      *armnetwork.PublicIPAddressesClient
}

这段代码定义了 VirtualMachineFactory 的结构,它负责创建和访问 Azure SDK API 客户端。我们使用 NewVirtualMachineFactory 函数来实例化这些客户端,如下所示:

func NewVirtualMachineFactory(subscriptionID, sshPubKeyPath string) *VirtualMachineFactory {
     cred := HandleErrWithResult(azidentity.NewDefaultAzureCredential(nil))
     return &VirtualMachineFactory{
          cred:           cred,
          subscriptionID: subscriptionID,
          sshPubKeyPath:  sshPubKeyPath,
          groupsClient:   BuildClient(subscriptionID, cred, armresources.NewResourceGroupsClient),
          vmClient:       BuildClient(subscriptionID, cred, armcompute.NewVirtualMachinesClient),
          vnetClient:     BuildClient(subscriptionID, cred, armnetwork.NewVirtualNetworksClient),
          subnetClient:   BuildClient(subscriptionID, cred, armnetwork.NewSubnetsClient),
          nsgClient:      BuildClient(subscriptionID, cred, armnetwork.NewSecurityGroupsClient),
          nicClient:      BuildClient(subscriptionID, cred, armnetwork.NewInterfacesClient),
          pipClient:      BuildClient(subscriptionID, cred, armnetwork.NewPublicIPAddressesClient),
     }
}

这段代码构建了一个新的默认 Azure 身份凭证。该凭证用于验证客户端对 Azure API 的身份。默认情况下,该凭证会检查多个来源以寻找可用的身份。默认凭证首先会检查环境变量,然后尝试使用 Azure 托管身份(docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview),最后,如果没有找到身份,则会回退到使用 Azure CLI 的用户身份。对于本示例,我们依赖 Azure CLI 身份与 Azure API 交互。这对开发非常方便,但不应在已部署的应用程序或脚本中使用。非交互式身份验证需要使用 Azure 服务主体(docs.microsoft.com/en-us/azure/active-directory/develop/app-objects-and-service-principals)或 Azure 托管身份。

VM 工厂使用 subscriptionID、凭证以及每个客户端的 New* 函数构建每个 Azure API 客户端。BuildClient() 构建每个客户端。

既然我们已经了解了如何实例化凭证和 API 客户端,接下来让我们深入了解 CreateVirtualMachineStack 中的基础设施创建:

func (vmf *VirtualMachineFactory) CreateVirtualMachineStack(ctx context.Context, location string) *VirtualMachineStack {
     stack := &VirtualMachineStack{
          Location:   location,
          name:       haiku.Haikunate(),
          sshKeyPath: HandleErrWithResult(homedir.Expand(vmf.sshPubKeyPath)),
     }
     stack.ResourceGroup = vmf.createResourceGroup(ctx, stack.name, stack.Location)
     stack.SecurityGroup = vmf.createSecurityGroup(ctx, stack.name, stack.Location)
     stack.VirtualNetwork = vmf.createVirtualNetwork(ctx, stack)
     stack.VirtualMachine = vmf.createVirtualMachine(ctx, stack)
     stack.NetworkInterface = vmf.getFirstNetworkInterface(ctx, stack)
     stack.PublicIP = vmf.getPublicIPAddress(ctx, stack)
     return stack
}

在前面的代码中,我们创建了一个堆栈的概念——一组相关的基础设施。我们使用给定的位置、一个人类可读的名称和 SSH 公钥路径的内容创建了一个新的堆栈。随后,我们创建了每个 Azure 资源,以便为虚拟机提供公共 SSH 访问权限。

让我们来探讨一下 CreateVirtualMachineStack 中的 createget 函数:

func (vmf *VirtualMachineFactory) createResourceGroup(ctx context.Context, name, location string) armresources.ResourceGroup {
     param := armresources.ResourceGroup{
          Location: to.Ptr(location),
     }
     fmt.Printf("Building an Azure Resource Group named %q...\n", name)
     res, err := vmf.groupsClient.CreateOrUpdate(ctx, name, param, nil)
     HandleErr(err)
     return res.ResourceGroup
}

在前面的代码中,createResourceGroupgroupsClient 上调用 CreateOrUpdate,在指定位置创建一个 Azure 资源组。Azure 资源组是 Azure 资源的逻辑容器。我们将使用这个资源组作为其余资源的容器。

接下来,让我们深入了解网络安全组创建函数 createSecurityGroup

func (vmf *VirtualMachineFactory) createSecurityGroup(ctx context.Context, name, location string) armnetwork.SecurityGroup {
     param := armnetwork.SecurityGroup{
          Location: to.Ptr(location),
          Name:     to.Ptr(name + "-nsg"),
          Properties: &armnetwork.SecurityGroupPropertiesFormat{
               SecurityRules: []*armnetwork.SecurityRule{
                    {
                         Name: to.Ptr("ssh"),
                         Properties: &armnetwork.SecurityRulePropertiesFormat{
                              Access:                   to.Ptr(armnetwork.SecurityRuleAccessAllow),
                              Direction:                to.Ptr(armnetwork.SecurityRuleDirectionInbound),
                              Protocol:                 to.Ptr(armnetwork.SecurityRuleProtocolAsterisk),
                              Description:              to.Ptr("allow ssh on 22"),
                              DestinationAddressPrefix: to.Ptr("*"),
                              DestinationPortRange:     to.Ptr("22"),
                              Priority:                 to.Ptr(int32(101)),
                              SourcePortRange:          to.Ptr("*"),
                              SourceAddressPrefix:      to.Ptr("*"),
                         },
                    },
               },
          },
     }
     fmt.Printf("Building an Azure Network Security Group named %q...\n", *param.Name)
     poller, err := vmf.nsgClient.BeginCreateOrUpdate(ctx, name, *param.Name, param, nil)
     HandleErr(err)
     res := HandleErrPoller(ctx, poller)
     return res.SecurityGroup
}

在前面的代码中,我们构建了一个 Azure 网络安全组,该安全组包含一个单独的安全规则,允许在端口 22 上的网络流量,从而为虚拟机启用 SSH 访问。请注意,我们调用的是 BeginCreateOrUpdate,而不是 CreateOrUpdate,后者会向 Azure API 发送 PUTPATCH 请求,并启动一个长期运行的操作。

在 Azure 中,长时间运行的操作是指—在初始变更被接受后—执行直到到达终态。例如,在创建网络安全组时,API 接收初始变更,然后开始构建基础设施。基础设施构建完成后,API 会通过操作状态或配置状态表示完成。poller负责跟踪这个长时间运行的操作直到完成。在HandleErrPoller中,我们跟踪轮询直到完成,并返回资源的最终状态。

接下来,让我们通过createVirtualNetwork来探讨虚拟网络的创建:

func (vmf *VirtualMachineFactory) createVirtualNetwork(ctx context.Context, vmStack *VirtualMachineStack) armnetwork.VirtualNetwork {
     param := armnetwork.VirtualNetwork{
          Location: to.Ptr(vmStack.Location),
          Name:     to.Ptr(vmStack.name + "-vnet"),
          Properties: &armnetwork.VirtualNetworkPropertiesFormat{
               AddressSpace: &armnetwork.AddressSpace{
                    AddressPrefixes: []*string{to.Ptr("10.0.0.0/16")},
               },
               Subnets: []*armnetwork.Subnet{
                    {
                         Name: to.Ptr("subnet1"),
                         Properties: &armnetwork.SubnetPropertiesFormat{
                              AddressPrefix:        to.Ptr("10.0.0.0/24"),
                              NetworkSecurityGroup: &vmStack.SecurityGroup,
                         },
                    },
               },
          },
     }
     fmt.Printf("Building an Azure Virtual Network named %q...\n", *param.Name)
     poller, err := vmf.vnetClient.BeginCreateOrUpdate(ctx, vmStack.name, *param.Name, param, nil)
     HandleErr(err)
     res := HandleErrPoller(ctx, poller)
     return res.VirtualNetwork
}

在前一个代码块中,我们为虚拟机构建了一个 Azure 虚拟网络。该虚拟网络的 CIDR 设置为10.0.0.0/16 10.0.0.0/24。子网引用了我们在前一个代码块中构建的网络安全组,这导致网络安全组中的规则会被强制执行在子网上。

现在我们已经为虚拟机构建好了网络,接下来让我们通过createVirtualMachine来构建虚拟机:

func (vmf *VirtualMachineFactory) createVirtualMachine(ctx context.Context, vmStack *VirtualMachineStack) armcompute.VirtualMachine {
     param := linuxVM(vmStack)
     fmt.Printf("Building an Azure Virtual Machine named %q...\n", *param.Name)
     poller, err := vmf.vmClient.BeginCreateOrUpdate(ctx, vmStack.name, *param.Name, param, nil)
     HandleErr(err)
     res := HandleErrPoller(ctx, poller)
     return res.VirtualMachine
}

createVirtualMachine()并没有太多可展示的内容。如您所见,创建资源的相同模式通过长时间运行的 API 调用应用在这段代码中。值得注意的是在linuxVM()中的一些细节:

func linuxVM(vmStack *VirtualMachineStack) armcompute.VirtualMachine {
     return armcompute.VirtualMachine{
          Location: to.Ptr(vmStack.Location),
          Name:     to.Ptr(vmStack.name + "-vm"),
          Properties: &armcompute.VirtualMachineProperties{
               HardwareProfile: &armcompute.HardwareProfile{
                    VMSize: to.Ptr(armcompute.VirtualMachineSizeTypesStandardD2SV3),
               },
            StorageProfile: &armcompute.StorageProfile{
                    ImageReference: &armcompute.ImageReference{
                         Publisher: to.Ptr("Canonical"),
                         Offer:     to.Ptr("UbuntuServer"),
                         SKU:       to.Ptr("18.04-LTS"),
                         Version:   to.Ptr("latest"),
                    },
               },
               NetworkProfile: networkProfile(vmStack),
               OSProfile:      linuxOSProfile(vmStack),
          },
     }
}

linuxVM中,我们指定了虚拟机的位置、名称以及属性。在属性中,我们指定了希望配置的硬件类型。在这种情况下,我们配置的是 Standard D3v2(您可以在docs.microsoft.com/en-us/azure/virtual-machines/dv3-dsv3-series)硬件库存单位SKU)。

我们还指定了我们的StorageProfile,用于指定操作系统以及我们希望附加到虚拟机的数据磁盘。在这个例子中,我们指定了要运行最新版本的 Ubuntu 18.04。由于NetworkProfileOSProfile的复杂性太高,无法在此函数中包含,所以我们将在以下代码块中分别探讨它们:

func networkProfile(vmStack *VirtualMachineStack) *armcompute.NetworkProfile {
     firstSubnet := vmStack.VirtualNetwork.Properties.Subnets[0]
     return &armcompute.NetworkProfile{
          NetworkAPIVersion: to.Ptr(armcompute.NetworkAPIVersionTwoThousandTwenty1101),
          NetworkInterfaceConfigurations: []*armcompute.VirtualMachineNetworkInterfaceConfiguration{
               {
                    Name: to.Ptr(vmStack.name + "-nic"),
                    Properties: &armcompute.VirtualMachineNetworkInterfaceConfigurationProperties{
                         IPConfigurations: []*armcompute.VirtualMachineNetworkInterfaceIPConfiguration{
                              {
                                   Name: to.Ptr(vmStack.name + "-nic-conf"),
                                   Properties: &armcompute.VirtualMachineNetworkInterfaceIPConfigurationProperties{
                                        Primary: to.Ptr(true),
                                        Subnet: &armcompute.SubResource{
                                             ID: firstSubnet.ID,
                                        },
                                        PublicIPAddress Configuration: &armcompute.VirtualMachinePublicIPAddress Configuration{
                                             Name: to.Ptr(vmStack.name + "-pip"),
                                             Properties: &armcompute.VirtualMachinePublicIPAddressConfiguration Properties{
                                                  PublicIPAllocationMethod: to.Ptr(armcompute.PublicIPAllocation MethodStatic),
                                                  PublicIPAddressVersion:   to.Ptr(armcompute.IPVersionsIPv4),
                                             },
                                        },
                                   },
                              },
                         },
                         Primary: to.Ptr(true),
                    },
               },
          },
     }
}

networkProfile()中,我们创建了NetworkProfile,它指定虚拟机应该使用 IPv4 并通过公共 IP 进行暴露,同时该虚拟机应只有一个网络接口。网络接口应分配到我们在createVirtualNetwork()中创建的子网。

接下来,让我们通过以下代码块探讨OSProfile配置,具体通过linuxOSProfile()进行配置:

func linuxOSProfile(vmStack *VirtualMachineStack) *armcompute.OSProfile {
     sshKeyData := HandleErrWithResult(ioutil.ReadFile(vmStack.sshKeyPath))
     cloudInitContent := HandleErrWithResult(ioutil.ReadFile("./cloud-init/init.yml"))
     b64EncodedInitScript := base64.StdEncoding.EncodeToString(cloudInitContent)
     return &armcompute.OSProfile{
          AdminUsername: to.Ptr("devops"),
          ComputerName:  to.Ptr(vmStack.name),
          CustomData:    to.Ptr(b64EncodedInitScript),
          LinuxConfiguration: &armcompute.LinuxConfiguration{
               DisablePasswordAuthentication: to.Ptr(true),
               SSH: &armcompute.SSHConfiguration{
                    PublicKeys: []*armcompute.SSHPublicKey{
                         {
                              Path:    to.Ptr("/home/devops/.ssh/authorized_keys"),
                              KeyData: to.Ptr(string(sshKeyData)),
                         },
                    },
               },
          },
     }
}

linuxOSProfile中,我们创建了一个OSProfile,其中包括了管理员用户名、计算机名以及 SSH 配置等细节。请注意,CustomData字段用于指定 Base64 编码的cloud-init YAML,该 YAML 文件用于执行虚拟机的初始配置。

让我们探讨一下在cloud-init YAML 文件中我们正在做什么:

#cloud-config
package_upgrade: true
packages:
  - nginx
  - golang
runcmd:
  - echo "hello world"

一旦虚拟机(VM)创建完成,以下的cloud-init指令将被执行:

  1. 首先,升级 Ubuntu 机器上的软件包。

  2. 接下来,nginxgolang包通过高级包工具APT)安装。

  3. 最后,runcmd echos "hello world"

cloud-init对于引导虚拟机非常有用。如果你以前没有使用过它,我强烈建议你进一步探索它(cloudinit.readthedocs.io/en/latest/)。

我们可以通过 SSH 访问虚拟机并执行类似下面的命令来验证cloud-init是否执行。记住,你的 IP 地址与这里显示的不同:

$ ssh -i ./.ssh/id_rsa devops@20.225.222.128
devops@fragrant-violet:~$ which go
/usr/bin/go
devops@fragrant-violet:~$ which nginx
/usr/sbin/nginx
cat /var/log/cloud-init-output.log

如你所见,nginxgo已经安装。你还应该能在已配置的虚拟机的/var/log/cloud-init-output.log中看到 APT 变更和hello world

你已经配置并创建了一个 Azure 虚拟机及相关基础设施!现在,让我们销毁整个基础设施堆栈。你应该能够在运行go run ./cmd/compute/main.go的 shell 中按下Enter键。

让我们看看在调用factory.DestroyVirtualMachineStack时发生了什么:

func (vmf *VirtualMachineFactory) DestroyVirtualMachineStack(ctx context.Context, vmStack *VirtualMachineStack) {
     _, err := vmf.groupsClient.BeginDelete(ctx, vmStack.name, nil)
     HandleErr(err)
}

DestroyVirtualMachineStack中,我们简单地在组的客户端上调用BeginDelete(),并指定资源组名称。然而,与其他示例不同,我们并没有等待轮询器完成。我们将DELETE HTTP请求发送到 Azure。我们不等待基础设施完全删除,而是相信delete请求的接受意味着它最终会达到删除的终端状态。

我们现在已经使用 Azure SDK for Go 构建并清理了一堆基础设施。我们学会了如何创建资源组、虚拟网络、子网、公有 IP 和虚拟机,以及如何将这种模式扩展到 Azure 中的任何资源。此外,这些技能适用于每个主要云平台,不仅仅是 Azure。AWS 和 GCP 也有类似的概念和 API 访问模式。

在下一节中,我们将构建一个 Azure 存储账户,并通过上传文件然后提供受限访问来下载这些文件,了解如何使用云服务的数据平面。

使用已配置的 Azure 基础设施

在上一节中,我们构建了一堆计算和网络基础设施来说明如何操作云基础设施。在这一节中,我们将把已配置的基础设施与 Azure 控制平面配对,并通过已配置服务的数据平面使用这些基础设施。

在这一部分,我们将构建一个云存储基础设施。我们将使用 Azure 存储来存储文件,并通过共享访问签名(docs.microsoft.com/en-us/azure/storage/common/storage-sas-overview)为这些文件提供受限访问。我们将学习如何使用 ARM 获取账户密钥,并使用这些密钥为存储资源提供受限访问。

构建一个 Azure 存储账户

让我们通过运行示例开始,然后我们将深入研究如何构建基础设施和使用配置的存储账户。要执行示例,请运行以下命令:

$ go run ./cmd/storage/main.go
Staring to build Azure resources...
Building an Azure Resource Group named "falling-rain"...
Building an Azure Storage Account named "fallingrain"...
Fetching the Azure Storage Account shared key...
Creating a new container "jd-imgs" in the Storage Account...
Reading all files ./blobs...
Uploading file "img1.jpeg" to container jd-imgs...
Uploading file "img2.jpeg" to container jd-imgs...
Uploading file "img3.jpeg" to container jd-imgs...
Uploading file "img4.jpeg" to container jd-imgs...
Generating readonly links to blobs that expire in 2 hours...
https://fallingrain.blob.core.windows.net/jd-imgs/img1.jpeg?se=2022-04-20T21%3A50%3A25Z&sig=MrwCXziwLLQeepLZjrW93IeEkTLxJ%2BEX16rmGa2w548%3D&sp=r&sr=b&st=2022-04-20T19%3A50%3A25Z&sv=2019-12-12
...
Press enter to delete the infrastructure.

如你在之前的输出中看到的,示例创建了一个资源组和一个存储账户,获取了账户密钥,然后将./blobs中的所有图片上传到云端。最后,示例通过共享访问签名打印出每张图片的 URI。如果你点击其中一个 URI,你应该能够下载我们上传到存储账户中的图片。

当你尝试在没有查询字符串的情况下下载img1.jpeg时会发生什么——例如,使用https://fallingrain.blob.core.windows.net/jd-imgs/img1.jpeg链接?你应该会看到访问被拒绝的消息。

让我们看看如何使用 Azure 存储上传文件并限制访问权限。

使用 Go 配置 Azure 存储

在这个示例中,我们将配置一个 Azure 资源组和一个 Azure 存储账户。为了让代码尽可能简洁以便说明,我们使用了简化的错误处理行为。正如我在上一节所说,panic 不是你的朋友。请适当包装和抛出错误。

让我们从 Go 的入口点run ./cmd/storage/main.go开始,学习如何使用 Go 来配置存储账户:

func init() {
     _ = godotenv.Load()
}
func main() {
     subscriptionID := MustGetenv("AZURE_SUBSCRIPTION_ID")
     factory := mgmt.NewStorageFactory(subscriptionID)
     fmt.Println("Staring to build Azure resources...")
     stack := factory.CreateStorageStack(
  context.Background(),
  "southcentralus”,
)
     uploadBlobs(stack)
     printSASUris(stack)
     fmt.Println("Press enter to delete the infrastructure.")
     reader := bufio.NewReader(os.Stdin)
     _, _ = reader.ReadString('\n')
     factory.DestroyStorageStack(context.Background(), stack)
}

类似于上一节中的虚拟机基础设施示例,我们使用NewStorageFactory()创建StorageFactory,然后使用它来创建和销毁存储堆栈。在中间,我们调用uploadBlobs()上传图片文件,并调用printSASUris()为每个上传的文件生成并打印共享访问签名。

首先,我们来看看如何配置存储基础设施:

type StorageFactory struct {
     subscriptionID string
     cred           azcore.TokenCredential
     groupsClient   *armresources.ResourceGroupsClient
     storageClient  *armstorage.AccountsClient
}
func NewStorageFactory(subscriptionID string) *StorageFactory {
     cred := HandleErrWithResult(
  azidentity. NewDefaultAzureCredential(nil),
)
     return &StorageFactory{
          cred:           cred,
          subscriptionID: subscriptionID,
          groupsClient:   BuildClient(subscriptionID, cred, armresources.NewResourceGroupsClient),
          storageClient:  BuildClient(subscriptionID, cred, armstorage.NewAccountsClient),
     }
}

存储工厂看起来类似于上一节中的VirtualMachineFactory。然而,存储工厂只使用资源组和存储客户端。

接下来,让我们探索CreateStorageStack(),看看我们是如何创建 Azure 存储账户的:

func (sf *StorageFactory) CreateStorageStack(ctx context.Context, location string) *StorageStack {
     stack := &StorageStack{
          name: haiku.Haikunate(),
     }
     stack.ResourceGroup = sf.createResourceGroup(ctx, stack.name, location)
     stack.Account = sf.createStorageAccount(ctx, stack.name, location)
     stack.AccountKey = sf.getPrimaryAccountKey(ctx, stack)
     return stack
}

在前面的代码中,我们为堆栈创建了一个人类可读的名称,用它来命名资源组和存储账户。然后我们将已创建的资源填充到堆栈字段中。

我将不会介绍createResourceGroup(),因为它已在上一节中讲解过。然而,createStorageAccount()getPrimaryAccountKey()很有意思。让我们探讨一下它们的功能:

// createStorageAccount creates an Azure Storage Account
func (sf *StorageFactory) createStorageAccount(ctx context.Context, name, location string) armstorage.Account {
     param := armstorage.AccountCreateParameters{
          Location: to.Ptr(location),
          Kind:     to.Ptr(armstorage.KindBlockBlobStorage),
          SKU: &armstorage.SKU{
               Name: to.Ptr(armstorage.SKUNamePremiumLRS),
               Tier: to.Ptr(armstorage.SKUTierPremium),
          },
     }
     accountName := strings.Replace(name, "-", "", -1)
     fmt.Printf("Building an Azure Storage Account named %q...\n", accountName)
     poller, err := sf.storageClient.BeginCreate(ctx, name, accountName, param, nil)
     HandleErr(err)
     res := HandleErrPoller(ctx, poller)
     return res.Account
}

在前面的代码中,createStorageAccount()创建了一个新的块 blob,具有高级性能层,并且是本地冗余的 Azure 存储帐户。块 blob (docs.microsoft.com/en-us/rest/api/storageservices/understanding-block-blobs--append-blobs--and-page-blobs#about-block-blobs) 优化了大数据量的上传,正如其名称所示,它被分成了任意大小的块。本地冗余存储 (docs.microsoft.com/en-us/azure/storage/common/storage-redundancy#locally-redundant-storage) 意味着每个块会在同一个数据中心内复制 3 次,并且在给定的一年内保证提供 99.999999999%(11 个 9!)的耐用性。最后,Azure 存储的高级层级 (docs.microsoft.com/en-us/azure/storage/blobs/scalability-targets-premium-block-blobs) 表示存储帐户将针对那些持续需要低延迟和高交易吞吐量的块 blob 变更的应用程序进行优化。

除了存储帐户的配置外,其他资源的配置与我们到目前为止配置的资源类似。

为了生成上传 blob 的共享访问签名,我们需要获取一个存储帐户密钥,该密钥是在存储帐户创建时配置的。让我们看看如何请求存储帐户密钥:

func (sf *StorageFactory) getPrimaryAccountKey(ctx context.Context, stack *StorageStack) *armstorage.AccountKey {
     fmt.Printf("Fetching the Azure Storage Account shared key...\n")
     res, err := sf.storageClient.ListKeys(ctx, stack.name, *stack.Account.Name, nil)
     HandleErr(err)
     return res.Keys[0]
}

在这段代码中,我们通过在存储客户端上调用ListKeys来获取帐户密钥,并返回第一个返回的帐户密钥。

现在我们已经配置好了存储基础设施并获取了存储帐户密钥,我们可以使用存储服务上传文件并提供对文件的受限访问。

使用 Azure 存储

让我们使用uploadBlobs函数将./blobs中的文件上传到我们的存储帐户:

func uploadBlobs(stack *mgmt.StorageStack) {
     serviceClient := stack.ServiceClient()
     containerClient, err := serviceClient.NewContainerClient("jd-imgs")
     HandleErr(err)
     fmt.Printf("Creating a new container \"jd-imgs\" in the Storage Account...\n")
     _, err = containerClient.Create(context.Background(), nil)
     HandleErr(err)
     fmt.Printf("Reading all files ./blobs...\n")
     files, err := ioutil.ReadDir("./blobs")
     HandleErr(err)
     for _, file := range files {
          fmt.Printf("Uploading file %q to container jd-imgs...\n", file.Name())
          blobClient := HandleErrWithResult(containerClient.NewBlockBlobClient(file.Name()))
          osFile := HandleErrWithResult(os.Open(path.Join("./blobs", file.Name())))
          _ = HandleErrWithResult(blobClient.UploadFile(context.Background(), osFile, azblob.UploadOption{}))
     }
}

在前面的代码中,我们创建了一个服务客户端来与存储服务客户端进行交互。通过serviceClient,我们可以定义一个名为jd-imgs的新存储容器。你可以把存储容器看作是一个类似于目录的实体。在指定容器后,我们调用create来请求存储服务创建该容器。一旦我们有了容器,我们就可以遍历./blobs目录中的每个图像,并使用块 blob 客户端将它们上传。

到目前为止,我们一直在使用 Azure CLI 身份作为与 Azure 服务交互的凭证。然而,当我们实例化serviceClient时,我们开始使用 Azure 存储帐户密钥与我们的存储帐户进行交互。让我们看看ServiceClient()

func (ss *StorageStack) ServiceClient() *azblob.ServiceClient {
     cred := HandleErrWithResult(azblob.NewSharedKeyCredential(*ss.Account.Name, *ss.AccountKey.Value))
     blobURI := *ss.Account.Properties.PrimaryEndpoints.Blob
     client, err := azblob.NewServiceClientWithSharedKey(blobURI, cred, nil)
     HandleErr(err)
     return client
}

在前面的代码中,我们使用存储账户名称和账户密钥的值创建了一个新的凭证。我们构建了 ServiceClient,使用存储账户的 blob 终结点和新构建的共享密钥凭证。共享密钥凭证将被用于所有从服务客户端派生的客户端。

现在我们已经将文件上传为块 blob,让我们看看如何创建签名 URI 来提供受限访问:

func printSASUris(stack *mgmt.StorageStack) {
     serviceClient := stack.ServiceClient()
     containerClient, err := serviceClient.NewContainerClient("jd-imgs")
     HandleErr(err)
     fmt.Printf("\nGenerating readonly links to blobs that expire in 2 hours...\n")
     files := HandleErrWithResult(ioutil.ReadDir("./blobs"))
     for _, file := range files {
          blobClient := HandleErrWithResult(containerClient.NewBlockBlobClient(file.Name()))
          permissions := azblob.BlobSASPermissions{
               Read: true,
          }
          now := time.Now().UTC()
          sasQuery := HandleErrWithResult(blobClient.GetSASToken(permissions, now, now.Add(2*time.Hour)))
          fmt.Println(blobClient.URL() + "?" + sasQuery.Encode())
     }
}

我们在前面的代码块中构建了 ServiceClient 并建立了一个容器客户端。然后,我们遍历本地 ./blobs 目录中的每个文件,并创建一个 blob 客户端。

blob 客户端有一个有用的方法,叫做 GetSASToken,它会根据 blob 访问权限和有效期生成共享访问令牌。在我们的案例中,我们授予的访问权限是立即生效并在 2 小时后过期的读取权限。为了创建一个完整的 URI 以访问 blob,我们需要将 blob URL 和共享访问令牌生成的查询字符串组合起来。我们通过 blobClient.URL()"?"sasQuery.Encode() 来实现这一点。现在,任何拥有签名 URI 的人都可以访问该文件。

在最后一节中,我们构建并使用了云存储基础设施来存储文件,并通过使用共享访问签名(SAS)提供对这些文件的受限访问。我们学习了如何获取账户密钥,并使用它们来提供对存储资源的受限访问。通过这些技能,你可以结合权限和其他约束来定制访问方式。以这种方式提供受限访问是一个强大的工具。例如,你可以创建一个仅写的 URI,指向一个尚未创建的 blob,将 URI 传递给客户端,然后让他们上传文件,而无需访问存储账户中的任何其他文件。

总结

Azure 存储仅是你可以用来构建云端应用的数百项服务中的一种。每个云服务提供商都有类似的存储服务,这些服务的操作方式相似。本章中展示的示例特定于 Microsoft Azure,但可以轻松地模仿其他云服务。

Azure 存储示例有助于说明云管理平面和数据平面之间的区别。如果仔细观察,你会发现 创建、读取、更新和删除CRUD)资源操作在使用 ARM 时与与 Azure 存储服务、容器和 blob 客户端的交互非常相似。云中的资源管理是统一的,而数据库、存储服务和内容分发网络的数据平面则很少统一,通常通过专门构建的 API 来暴露。

在本章中,我们学到了云不仅仅是“别人的计算机”。云是一个跨越行星规模的高安全性数据中心网络,里面充满了计算、网络和存储硬件。我们还学习了身份、认证和授权的基础知识,并结合了 Microsoft Azure 的具体实例。我们简要介绍了 Azure RBAC 及其与 AAD 身份的关系。最后,我们学习了如何使用 Microsoft Azure 配置和使用云资源。

你应该能够将你在这里学到的知识应用到云服务的配置和使用中,以实现你的目标。这些技能主要集中在 Microsoft Azure,但在这里学到的技能很容易转移到 AWS 或 Google 云平台。

在下一章中,我们将探讨当软件在不完美条件下运行时会发生什么。我们将学习如何为混乱设计。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值