超越IaC:解决云计算关注点分离问题

从代码构建基础设施的解决方案可以实现应用程序开发和部署之间的清晰分离。

译自 Beyond IaC: Fixing Cloud’s Separation of Concerns Problem,作者 Jye Cusch。

托管服务和基础设施即代码 (IaC) 已成为现代应用程序不可或缺的一部分。它们简化了云资源的部署和管理,为构建可扩展、可靠的系统提供了简化的路径。然而,这种便利性隐藏着成本:应用程序越来越脆弱,并且严重缺乏真正的关注点分离。让我们探讨这些问题,并使用一个实际示例来突出显示使用托管服务和 IaC 的挑战。

需要问的问题

以下是一些问题,可以帮助您确定您的环境是否受到缺乏分离的影响:

  1. 如果您不再需要某个资源,例如 S3 存储桶,是否可能出现错误导致它继续存在于项目的 IaC(例如 Terraform 项目)中,而不再被应用程序代码引用?(代码和 IaC 是否需要手动保持同步?)
  2. 如果您的应用程序需要一个新的资源,应用程序开发人员是否需要与自动化工程师沟通才能将其添加到 IaC 代码(Terraform 项目)或平台中?(应用程序更改是否也是自动化更改?)
  3. 更改 IaC 代码中部署的服务(例如将 AWS SNS 更改为 EventBridge)是否会导致应用程序代码同时更改(从 SNS 客户端库切换到 EventBridge 库)?
  4. 当您构建一个新的应用程序或使用新的资源时,本地测试是否不够?您是否需要在云中测试您的应用程序以确保它正常工作?
  5. 环境变量名称等值的拼写错误是否会导致您的应用程序崩溃?
  6. 您是否将项目限制在受限的脚手架或模板中(例如通过开发者门户),以确保团队使用符合您组织策略的基础设施?
  7. 如果云提供商发布了比现有托管服务更快、更便宜或更好的替代方案,迁移到该服务是否需要相当长的时间或精力?您之前是否避免或推迟了此类更改?

如果以上问题的答案是肯定的,那么您就遇到了我们正在讨论的问题。如果您对所有问题都回答“否”,那么要么您正在避免使用托管服务,要么您已经在使用基础设施即代码 (IfC),或者您已经找到了另一种解决方案——我很想了解这种解决方案。

分离的幻觉:一个实际示例

考虑一个常见场景:您构建了一个依赖于 SNS 进行异步消息传递的应用程序。一段时间后,您决定从 SNS 切换到 EventBridge——可能是由于成本、性能、与其他应用程序的标准化,或者因为您需要使用其他事件源。这种类型的更改可能会发生在任何其他托管服务中,例如文件存储、队列、HTTP 网关等。

表面上,这些似乎是简单的更改:这两个服务具有类似的接口,并为您的需求提供了类似的功能。让我们分解一下实际发生的事情:

1. 代码更改

您的应用程序代码与 SNS 服务紧密耦合。您在代码中直接使用 SNS 库,处理 SNS 特定的错误,并依赖于 SNS 实现方式的功能。将 SNS 替换为其他服务意味着重写代码的很大一部分。您需要替换库、修改 API 调用,并可能重新考虑您的错误处理和重试逻辑。

2. IaC 更改

您的基础设施即代码 (IaC) 脚本同样与 SNS 绑定。您使用的 Terraform、CloudFormation 或任何其他 IaC 工具都将具有明确定义 SNS 主题、策略/角色和环境变量的脚本,用于向主题发送消息的服务以及响应发送到主题的事件的任何订阅者。将 SNS 替换为其他服务意味着深入研究这些脚本,修改资源,更新权限,并确保新服务配置正确。

3. 测试更改

您的测试也需要更新。单元测试和集成测试必须重写以适应新服务。在测试中模拟 SNS?这些模拟需要替换为新服务的模拟。在订阅者测试中模拟 SNS 事件?这些也需要更改。

4. 部署风险

在您部署更改之前,无法知道您的更改是否正确。即使进行了全面的本地测试,也始终存在部署后出现问题风险。这可能是环境变量中的拼写错误,也可能是阻止订阅触发订阅者的不正确的 IAM 策略。这些问题非常常见,尤其令人沮丧。如果它们深入到您的应用程序中,它们可能只有在您的用户开始遇到问题时才会显现出来。

5. 配置陷阱

即使您正确地获得了代码和 IaC 更改,配置问题仍然可能出现。托管服务通常依赖于特定的配置值,例如资源 ID 或端点 URL。这些配置中的简单拼写错误会导致数小时的调试。与传统代码不同,这些错误不会在编译时被捕获——您只能在运行时发现它们。

分离的错觉

许多人认为将具有不同职责的代码分离到不同的文件或模块中意味着他们已经实现了关注点分离(例如,像 Terraform 这样的 IaC 代码与应用程序代码分离)。关注点分离不仅仅是关于接近程度:它还意味着一个模块中的更改不会强制对无关区域进行更改。在我们的示例中,从一个托管服务简单地切换到另一个等效服务需要对整个堆栈进行更改——代码、IaC、测试和配置。它们在表面上看起来是分开的,但耦合非常显著,以至于系统最终变得脆弱,并且更改会波及整个项目。

分离的真正含义

对关注点分离的一种描述是:

“模块化,因此关注点分离,是通过将信息封装在具有明确定义的接口的代码部分中来实现的。” — 维基百科

在典型的云开发中,基础设施代码的这种明确定义的接口在哪里?传统模型无法提供它,导致开发人员和基础设施团队在每次进行更改时都需要不断协调、重新配置和重新测试。

更好的方法:来自代码的基础设施

这就是来自代码的基础设施 (IfC) 的用武之地。Nitric 提供的 IfC 风格通过为基础设施需求和使用提供明确定义的接口来解决问题。它通过将底层基础设施细节从应用程序层抽象出来,将应用程序架构的关注点与部署架构的关注点分离。与传统的 IaC 不同,它不仅仅将部署脚本分离到其他文件中——它完全解耦了应用程序,分离了客户端 SDK、测试、资源标识符和其他导致部署自动化与应用程序代码之间关系脆弱的组件。

使用 IfC,当您更改提供商或单个云服务时,更改将隔离到新的基础设施层。应用程序开发人员不必了解详细信息。他们可以构建和测试他们的应用程序,确信基础设施将无缝工作,无论底层提供商是什么,因为它将符合严格的接口。类似地,部署自动化工程师可以专注于确保基础设施的健壮性,知道他们的更改不会无意中破坏应用程序。

一个现实世界的例子

让我们逐步了解一个具体的例子。我们将从一个使用 Terraform 作为基础设施即代码的项目开始。(我们在这里使用 Terraform 是因为它很熟悉。这个例子对于 Pulumi、AWS Cloud Development Kit 或其他 IaC 工具来说同样有效。)该项目部署了一个与 SNS 主题交互的基本 Go 应用程序。然后,我们将用 EventBridge 事件总线替换 SNS 主题,展示必要的应用程序代码、部署代码和测试更改。我们还将演示如何使用 Nitric 和来自代码的基础设施来实现同一个项目,突出显示复杂性的降低和关注点分离的改进,而不会限制可配置性或对底层服务的访问。

您可以在 GitHub 上查看完整的项目,其中每个步骤都由一个提交表示,说明了对应该是一个简单交换所需的更改范围。

更改 IaC 步骤的示例

为了简洁起见,我们只包含更改的示例。完整的差异很大,可以在 GitHub 上的最新提交 上查看。

1. 更新应用程序代码

由于代码使用 AWS SNS 和 Lambda 库,我们需要更新引用和实现以使用 EventBridge 来代替发送和接收消息。

例如,这段发布消息到 SNS 的代码…

publishInput := &sns.PublishInput{
  TopicArn: aws.String(topicArn),
  Message:  aws.String(string(messageBytes)),
}

_, err := a.snsClient.Publish(ctx, publishInput)

…改为将消息发送到 EventBridge 的代码:

eventInput := &eventbridge.PutEventsInput{
  Entries: []types.PutEventsRequestEntry{
    {
      Source:       aws.String(eventSourceId),
      Detail:       aws.String(string(messageBytes)),
      DetailType:   aws.String("customDetailType"),
      EventBusName: aws.String(eventBusName),
    },
  },
}

_, err := a.eventbridgeClient.PutEvents(ctx, eventInput)

2. 接下来,更新测试

由于代码依赖于 SNS 和 Lambda 库,因此这些服务被模拟用于单元测试。随着更改,我们的测试需要更新以模拟新的服务和事件类型。

例如,而不是创建一个模拟 SNS 客户端…

type MockSnsClient struct {
	mock.Mock
}

func (m *MockSnsClient) Publish(ctx context.Context, params *sns.PublishInput, optFns ...func(*sns.Options)) (*sns.PublishOutput, error) {
	args := m.Called(ctx, params, optFns)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).(*sns.PublishOutput), args.Error(1)
}

…我们将创建一个模拟 EventBridge 客户端:

type MockEventBridgeClient struct {
	mock.Mock
}

func (m *MockEventBridgeClient) PutEvents(ctx context.Context, params *eventbridge.PutEventsInput, optFns ...func(*eventbridge.Options)) (*eventbridge.PutEventsOutput, error) {
	args := m.Called(ctx, params, optFns)
	if args.Get(0) == nil {
		return nil, args.Error(1)
	}
	return args.Get(0).(*eventbridge.PutEventsOutput), args.Error(1)
}

您可能会自动生成模拟客户端,但使用这些模拟的测试无论如何都需要更改。

3. 最后,更新部署自动化

在我们的示例中,我们从一开始就包含了一个来自 EventBridge 的 Terraform 模块,以更好地模拟已建立的环境。这使得 Terraform 更改最小化——正如它们应该的那样。

我们从一个 SNS 模块和传递给发布者的变量开始:

module "topic" {
  source     = "./modules/sns-pubsub"
  topic_name = "iac-topic"
  lambda_subscribers = {
    subfunc = module.subscriber.function_arn
  }
}

module "publisher" {
  source       = "./modules/service"
  service_name = "iac-publisher"
  image        = "publisher:latest"
  environment = {
    SNS_TOPIC_ARN = module.topic.topic_arn
  }
}

resource "aws_iam_role_policy" "policy" {
  role = module.publisher.role_name
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = {
      Effect   = "Allow"
      Action   = "sns:Publish"
      Resource = module.topic.topic_arn
    }
  })
}

我们将它更改为一个 EventBridge 模块,以及发布所需的新的变量:

locals {
  bus_source_id = "custom.source"
}

module "event_bus" {
  source    = "./modules/eventbridge-pubsub"
  bus_name  = "iac-bus"
  source_id = local.bus_source_id
  lambda_subscribers = {
    subfunc = module.subscriber.function_arn
  }
}

module "publisher" {
  source       = "./modules/service"
  service_name = "iac-publisher"
  image        = "publisher:latest"
  environment = {
    EVENT_BUS_NAME  = module.event_bus.bus_name
    EVENT_SOURCE_ID = local.bus_source_id
  }
}

resource "aws_iam_role_policy" "policy" {
  role = module.publisher.role_name
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = {
      Effect   = "Allow"
      Action   = "events:PutEvents"
      Resource = module.event_bus.bus_arn
    }
  })
}

不幸的是,剩下的一个问题是,我们需要确保 Terraform HCL(HashiCorp 配置语言)中的环境变量,例如 SNS_TOPIC_ARNEVENT_BUS_NAME,与应用程序代码中使用的名称完全匹配。如果没有部署应用程序并对其进行测试,很难发现此处的拼写错误或其他错误。

更改 IfC 的步骤

与 IaC 不同,IfC 的更改非常小,以至于我们可以在这里显示所需的全部更改,而不是仅仅一个示例。

我们从一个 nitric.aws.yaml 堆栈文件开始,该文件配置为使用默认的 Nitric AWS 提供程序,该提供程序使用 SNS 作为主题:

provider: nitric/aws@1.11.1
region: us-east-1

然后,我们交换任何我们想要的其他提供程序。在这种情况下,它是一个扩展的提供程序,它使用 EventBridge 而不是 SNS:

provider: nitric/awseventbridge@0.0.1
region: us-east-1

所有其他代码和测试保持不变,因为它们正在使用 Nitric Topics API,该 API 将代码与 SNS 或 EventBridge 的直接集成分离。

就像构建 Terraform 模块一样,Nitric 提供程序中的 EventBridge 更改是隔离的。但是,与单独使用 Terraform 不同,Nitric 还可以封装新服务的运行时代码,使其能够独立构建和测试。

由于 Nitric 提供程序可以使用任何 IaC 工具(如 Terraform、Pulumi 或 AWS CDK)构建或自定义,因此仍然可以保持细粒度控制,并且添加 IfC 不会丢失任何东西。

下一步

托管服务和 IaC 的承诺是不可否认的,但如果没有适当的关注点分离,你将得到一个脆弱的、紧密耦合的系统。基础设施即代码解决方案可以引入一个新的分离层,在应用程序开发和部署之间提供清晰的分离。

我们希望你能通过遵循 Nitric 文档 中的指南或查看 GitHub 上的项目来自己尝试一下。

如果你对本文、Nitric 或示例项目有任何反馈,我们很乐意听到你的意见。在 Discord 上与我们聊天

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注