关于 IaC

IaC 的历史

2000 年之后,随着 IT 技术发展,用户的需求越来越复杂,软件系统以及基础设施也变的越来越复杂。最早的运维都是手工式的,面临着几个问题:

  1. 交互式变更所引入的人的因素太大,导致了变更的不可控性
  2. 基础设施变化越来越快,手工操作成本高且效率低
  3. 交互式变更难以管控,且无法实现版本控制

IaC 就是在这个时期出现的想要解决这些问题的概念,维基百科定义的 IaC 指通过 machine-readable 的定义文件,而不是物理硬件配置或交互式配置工具来管理和配置计算机数据中心的过程。该过程管理的 IT 基础设施包括物理设备(如 Bare-metal 或 VM),以及相关的配置资源。定义文件可能在版本控制系统 VCS 中,文件中代码可能使用脚本或者声明式定义,但IaC 通常使用声明式方法管理基础设施。

IaC 有几个核心特征:

  1. 最终产物为 machine-readable 的文件,可以是脚本、声明式代码或者配置文件。
  2. 基于最终产物,可以利用 VCS(Git、SVN) 实现版本管理。
  3. 利用 CI/CD 系统(如 Jenkins、GitLab CI)实现持续集成/持续交付。
  4. 基于同样的定义文件,最终表现出来的行为是一致且幂等的。

这个时期出现了一些 IaC 工具,典型的如 Puppet、Chef、Ansible,实际上这些工具,可能设计上各有所取舍(比如 Pull/Push 模型的取舍),但是其核心的特征不会变化:

  1. 框架内部提供了常见的比如 SSH 链接管理,多机并行执行,auto retry 等功能。
  2. 基于上面描述的这一套基础功能,提供了一套 DSL 封装。让开发者更专注于 IaC 的逻辑,而非基础层面的细节。
  3. 其开源开放,并形成了一套完善的插件机制。社区可以基于这一套提供更丰富的生态。比如 SDN 社区基于 Ansible 提供了各种交换机的 playbook;Ansible 官方提供的 AWS playbook 等。

云上资源编排

2006 年 8 月 Amazon 正式发布了 EC2 服务,从这时整个基础设施开始快步向 Cloud 时代迈进。截止目前,各家云厂商提供了各种各样的服务,经过十多年的演进,诞生出了诸如 IaaS,PaaS,DaaS,FaaS 等等各种各样的服务模式。这些服务模式,让我们的基础设施的构建,变得更加的简单,更加的快速。但是这些服务模式,也带来了一些问题:

  1. 需要人工在云厂商控制台开通服务、创建资源,资源数量过多后难以管理:人工变更会引入不可控因素,且效率低,也无法实现版本控制,和最早基础设施管理面临的问题是类似的。
  2. 云服务逐渐标准化,大部分企业出于成本考虑可能会使用多个云服务,多云场景下的资源管理更加复杂,仅依靠人工操作基本是不可行的。

云时代的,面向云资源管理的新型 IaC 工具的需求也愈发的迫切,这个时候,Terraform 这样的新型工具应运而生。Terraform 通过声明式定义描述 ECS、RDS、Redis、MQ 等多种基础设施,使资源编排变得十分简单,使用 Terraform 很容易就能定义一台 ECS 实例:

resource "volcengine_vpc" "foo" {
  vpc_name = "tf-test-2"
  cidr_block = "172.16.0.0/16"
}

resource "volcengine_subnet" "foo1" {
  subnet_name = "subnet-test-1"
  cidr_block = "172.16.1.0/24"
  zone_id = "cn-beijing-a"
  vpc_id = volcengine_vpc.foo.id
}

resource "volcengine_security_group" "foo1" {
  depends_on = [volcengine_subnet.foo1]
  vpc_id = volcengine_vpc.foo.id
}

resource "volcengine_ecs_instance" "default" {
  image_id = "image-xxx"
  instance_type = "ecs.g1.large"
  instance_name = "tf-test-2"
  description = "xym-tf-test-desc-1"
  password = "xxx"
  instance_charge_type = "PostPaid"
  system_volume_type = "PTSSD"
  system_volume_size = 60
  subnet_id = volcengine_subnet.foo1.id
  security_group_ids = [volcengine_security_group.foo1.id]
  data_volumes {
    volume_type = "PTSSD"
    size = 100
    delete_with_instance = true
  }
  deployment_set_id = ""
  ipv6_address_count = 1
#  secondary_network_interfaces {
#    subnet_id = volcengine_subnet.foo1.id
#    security_group_ids = [volcengine_security_group.foo1.id]
#  }
}

同时随着各家 SaaS 的发展,研发人员也尝试着将这些 SaaS 服务也进行代码化/描述式配置化。以 Terraform 为例,我们可以通过 Terraform 的 Provider 来进行对接,比如 GitLab ProviderGitHub Provider 等等。

在 IaC 工具帮助我们完成基础设施描述的标准化之后,在此基础上能做更多有趣的事情:比如我们可以基于 Infracost 来计算每次资源变更所带来的资源花费变更;利用 atlantis 来基于 PR 实现 Terraform 流程自动化。

Terraform 已经能够解决多云资源编排场景下的绝大多数问题了,不过并不意味着 Terraform 在各个地方都是完美的,Terraform 也存在一些问题,使得其他资源编排工具有了存在的必要。

Terraform 的问题

HCL 语法的缺陷

locals {
  dns_records = {
    "demo1" : 1,
    "demo2" : 2
    "demo3" : 3,
  }
  lb_listener_port  = 80
  instance_rpc_port = 9545

  default_target_group_attr = {
    backend_protocol     = "HTTP"
    backend_port         = 9545
    target_type          = "instance"
    deregistration_delay = 10
    protocol_version     = "HTTP1"
    health_check = {
      enabled             = true
      interval            = 15
      path                = "/status"
      port                = 9545
      healthy_threshold   = 3
      unhealthy_threshold = 3
      timeout             = 5
      protocol            = "HTTP"
      matcher             = "200-499"
    }
  }
}

module "alb" {
  source  = "terraform-aws-modules/alb/aws"
  version = "~> 6.0"

  name                       = "alb-demo-internal-rpc"
  load_balancer_type         = "application"
  internal                   = true
  enable_deletion_protection = true


  http_tcp_listeners = [
    {
      protocol           = "HTTP"
      port               = local.lb_listener_port
      target_group_index = 0
      action_type        = "forward"
    }
  ]

  http_tcp_listener_rules = concat([
    for rec, pos in local.dns_records : {
      http_tcp_listener_index = 0
      priority                = 105 + tonumber(pos)
      actions = [
        {
          type               = "forward"
          target_group_index = tonumber(pos)
        }
      ]
      conditions = [
        {
          host_headers = ["${rec}.example.com"]
        }
      ]

    }
    ], [{
      http_tcp_listener_index = 0
      priority                = 120
      actions = [
        {
          type = "weighted-forward"
          target_groups = [
            {
              target_group_index = 0
              weight             = 95
            },
            {
              target_group_index = 5
              weight             = 4
            },
          ]
        }
      ]
      conditions = [
        {
          host_headers = ["demo0.example.com"]
        }
      ]
  }])

  target_groups = [
    merge(
      {
        name_prefix = "demo0"
        targets = {
          "demo0-${module.ec2_instance_demo[0].tags_all["Name"]}" = {
            target_id = module.ec2_instance_demo[0].id
            port      = local.instance_rpc_port
          }
        }
      },
      local.default_target_group_attr,
    ),
    merge(
      {
        name_prefix = "demo1"
        targets = {
          "demo1-${module.ec2_instance_demo[0].tags_all["Name"]}" = {
            target_id = module.ec2_instance_demo[0].id
            port      = local.instance_rpc_port
          }
        }
      },
      local.default_target_group_attr,
    ),
    merge(
      {
        name_prefix = "demo2"
        targets = {
          "demo2-${module.ec2_family_c[0].tags_all["Name"]}" = {
            target_id = module.ec2_family_c[0].id
            port      = local.instance_rpc_port
          },
        }
      },
      local.default_target_group_attr,
    ),
    merge(
      {
        name_prefix = "demo3"
        targets = {
          "demo3-${module.ec2_family_d[0].tags_all["Name"]}" = {
            target_id = module.ec2_family_d[0].id
            port      = local.instance_rpc_port
          },
        }
      },
      local.default_target_group_attr,
    ), # target_group_index_3
    merge(
      {
        name_prefix = "demonew"
        targets = {
          "demo0-${module.ec2_instance_reader[0].tags_all["Name"]}" = {
            target_id = module.ec2_instance_reader[0].id
            port      = local.instance_rpc_port
          }
        }
      },
      local.default_target_group_attr,
    ),
  ]
}

上述示例配置使用了 terraform-aws-modules/alb/aws module,根据不同的域名*.example.com将流量转发到不同的 instance 上。然后对于 demo0.example.com 这个域名,进行单独的流量灰度处理。

Terrafrom 这种 DSL 的解决方案所需要面临的问题就是在对于这种动态灵活的场景下,其表达能力将会有很大的局限性,对于开发者来说,HCL 的语法和通用编程语言相差甚远,在面对复杂场景时,使用 HCL 编排资源可能面临无从下手的困难。

所以社区像 Pulumi 这样支持多种编程语言的 IaC 产品应运而生了。Pulumi 通过多语言 Language Host 支持了使用如 Golang/Java/Python/NodeJS/DotNET 等 SDK 实现资源编排操作,内部实现了 Terraform CLI 的部分能力,并且直接对接 Terraform SDK,可以复用已有的 Terraform Provider,使得云提供商接入 Pulumi 的成本大大降低,实现原理可以参考Pulumi 工作运行原理

使用 Pulumi + Python 改写上述示例:

from pulumi_aws import alb

dns_records = {
    "demo1": 1,
    "demo2": 2,
    "demo3": 3,
}
lb_listener_port = 80
instance_rpc_port = 9545

default_target_group_attr = {
    "backend_protocol": "HTTP",
    "backend_port": 9545,
    "target_type": "instance",
    "deregistration_delay": 10,
    "protocol_version": "HTTP1",
    "health_check": {
        "enabled": True,
        "interval": 15,
        "path": "/status",
        "port": 9545,
        "healthy_threshold": 3,
        "unhealthy_threshold": 3,
        "timeout": 5,
        "protocol": "HTTP",
        "matcher": "200-499",
    },
}

rules = [
    {
        "http_tcp_listener_index": 0,
        "priority": 120,
        "actions": [
            {
                "type": "weighted-forward",
                "target_groups": [
                    {"target_group_index": 0, "weight": 95},
                    {"target_group_index": 5, "weight": 4},
                ],
            }
        ],
        "conditions": [{"host_headers": ["demo0.example.com"]}],
    }
]

for rec, pos in dns_records.items():
    rules.append({
        "http_tcp_listener_index": 0,
        "priority": 105 + pos,
        "actions": [
            {
                "type": "forward",
                "target_group_index": pos,
            }
        ],
        "conditions": [
            {
                "host_headers": [f"{rec}.example.com"],
            }
        ],
    })

targetGroups = [
    alb.TargetGroup(
        f"demo0-{module.ec2_instance_demo[0].tags_all['Name'].apply(lambda x: x)}",
        alb.TargetGroupArgs(
            name_prefix="demo0",
            targets=[
                {
                    "target_id": module.ec2_instance_demo[0].id,
                    "port": instance_rpc_port,
                }
            ],
            **default_target_group_attr,
        )
    ),
    alb.TargetGroup(
        f"demo1-{module.ec2_instance_demo[0].tags_all['Name'].apply(lambda x: x)}",
        alb.TargetGroupArgs(
            name_prefix="demo1",
            targets=[
                {
                    "target_id": module.ec2_instance_demo[0].id,
                    "port": instance_rpc_port,
                }
            ],
            **default_target_group_attr,
        )
    ),
    alb.TargetGroup(
        f"demo2-{module.ec2_family_c[0].tags_all['Name'].apply(lambda x: x)}",
        alb.TargetGroupArgs(
            name_prefix="demo2",
            targets=[
                {
                    "target_id": module.ec2_family_c[0].id,
                    "port": instance_rpc_port,
                }
            ],
            **default_target_group_attr,
        )
    ),
    alb.TargetGroup(
        f"demo3-{module.ec2_family_d[0].tags_all['Name'].apply(lambda x: x)}",
        alb.TargetGroupArgs(
            name_prefix="demo3",
            targets=[
                {
                    "target_id": module.ec2_family_d[0].id,
                    "port": instance_rpc_port,
                }
            ],
            **default_target_group_attr,
        )
    ),
    alb.TargetGroup(
        f"demo0-{module.ec2_instance_reader[0].tags_all['Name'].apply(lambda x: x)}",
        alb.TargetGroupArgs(
            name_prefix="demo0",
            targets=[
                {
                    "target_id": module.ec2_instance_reader[0].id,
                    "port": instance_rpc_port,
                }
            ],
            **default_target_group_attr,
        )
    ),
]

alb_module = alb.ApplicationLoadBalancer(
    "alb",
    name="alb-demo-internal-rpc",
    load_balancer_type="application",
    internal=True,
    enable_deletion_protection=True,
    http_tcp_listeners=[
        {
            "protocol": "HTTP",
            "port": lb_listener_port,
            "target_group_index": 0,
            "action_type": "forward",
        }
    ],
    http_tcp_listener_rules=rules,
    target_groups=targetGroups
)

使用通用编程语言,不仅更容易上手,配置可读性更高,还能利用语言生态做更多 HCL 无法实现的功能,也更方便编写测试用例,保证编排代码的鲁棒性。

云原生体验不足

Terraform 本质还是一个 CLI 工具,资源状态信息存储在本地,如今基础设施逐步云化,对团队来说不可能在本地机器运行 Terraform 管理云资源,虽然 Terraform 也提供了状态远端存储的能力,比如可以使用 Terraform Cloud,将状态信息存储在 Terraform 云端;或者各个云服务的 OSS 服务,但总体体验相对云原生还有距离:前者需要将数据存储在 Terraform Cloud,并且还需要付费;后者需要手动配置、维护 OSS,总体没有开箱即用的使用体验。

Crossplane 就是解决上述问题的工具,通过扩展 Kubernetes API 定义云资源,在 K8s 实现资源编排。在 K8s 已经成为事实标准的今天,如果团队已有 K8s 集群,可以很轻松的通过 Crossplane 在 K8s 集群上管理基础设施,无需除 K8s 之外的任何外部依赖,只需要编写好资源 yaml 定义,通过 kubectl 创建资源,Crossplane 能够自动管理该资源的全生命周期流程。

使用 yaml 定义一台 ECS 实例:

apiVersion: ecs.volcengine.upbound.io/v1alpha1
kind: Instance
metadata:
  annotations:
    meta.upbound.io/example-id: ecs/v1alpha1/instance
  labels:
    testing.upbound.io/example-name: default
  name: default
spec:
  forProvider:
    dataVolumes:
    - deleteWithInstance: true
      size: 100
      volumeType: PTSSD
    deploymentSetId: ""
    description: tf-test-desc-1
    imageId: image-xxx
    instanceChargeType: PostPaid
    instanceName: xym-tf-test-2
    instanceType: ecs.g1.large
    ipv6AddressCount: 1
    passwordSecretRef:
      key: example-key
      name: example-secret
      namespace: upbound-system
    securityGroupIdsRefs:
    - name: foo1
    subnetIdSelector:
      matchLabels:
        testing.upbound.io/example-name: foo1
    systemVolumeSize: 60
    systemVolumeType: PTSSD

---

apiVersion: vpc.volcengine.upbound.io/v1alpha1
kind: SecurityGroup
metadata:
  annotations:
    meta.upbound.io/example-id: ecs/v1alpha1/instance
  labels:
    testing.upbound.io/example-name: foo1
  name: foo1
spec:
  forProvider:
    vpcIdSelector:
      matchLabels:
        testing.upbound.io/example-name: foo

---

apiVersion: vpc.volcengine.upbound.io/v1alpha1
kind: Subnet
metadata:
  annotations:
    meta.upbound.io/example-id: ecs/v1alpha1/instance
  labels:
    testing.upbound.io/example-name: foo1
  name: foo1
spec:
  forProvider:
    cidrBlock: 172.16.1.0/24
    subnetName: subnet-test-1
    vpcIdSelector:
      matchLabels:
        testing.upbound.io/example-name: foo
    zoneId: cn-beijing-a

---

apiVersion: vpc.volcengine.upbound.io/v1alpha1
kind: VPC
metadata:
  annotations:
    meta.upbound.io/example-id: ecs/v1alpha1/instance
  labels:
    testing.upbound.io/example-name: foo
  name: foo
spec:
  forProvider:
    cidrBlock: 172.16.0.0/16
    vpcName: tf-test-2

---

Terraform & Pulumi & Crossplane 对比

Resources

Terraform 有最丰富的 Provider 支持,火山引擎上的云服务优先接入 Terraform,不过由于 Pulumi、Crossplane Provider 接入均支持基于 Terraform Provider,因此从资源丰富度上对比三者是一致的。

Requirements

三者都需要 CLI 工具,Terraform 和 Pulumi 本身分发的二进制包,安装好即可使用。Crossplane 依赖 Kubernetes 集群,相对成本较高。

如果需要将状态存储在远端,Terraform 和 Pulumi 是类似的,都提供了自家的云服务,并且也支持配置 OSS;Crossplane 则会将状态作为 K8s 资源信息,存储在 K8s 的 etcd 集群中。

Kubernetes 已经成为标准,安装成本相对不高,考虑到实际使用会接入 CI/CD 或 GitOps,并且状态不会存储在本地,如果团队内部已经有一个 K8s 集群,Crossplane 在运行时的成本是最低的。

如果没有 K8s 集群,考虑到实际使用情况,一般会将状态存储在云端,如果使用 Terraform 或者 Pulumi 云服务,需要考虑付费成本,如果自建 OSS,也需要考虑 OSS 的费用和配置维护成本,后续自建 CI/CD 或者 GitOps 也需要额外的成本投入。

Syntax

  • Terraform 自研 HCL 用于描述资源编排信息,同时也支持使用 JSON 定义
  • Pulumi 支持多种编程语言,如 Golang、Java、C#、JS/TS、Python SDK
  • Crossplane 属于 K8s 扩展,使用 Yaml 定义资源

三者对比 HCL 相对学习成本最高,一些复杂场景的配置语法也相对难写;Pulumi 最容易上手,对于开发者来说,可以选择熟悉的编程语言实现资源编排,利用语言功能实现更加复杂的编排逻辑,而且也是三者中最方便写测试用例的,不过需要依赖 Pulumi 各个语言的 SDK;得益于 K8s 的广泛应用和 Yaml 的简单规则,使用 Yaml 配置是最简单方便的,不需要依赖额外的 SDK。

Plan/Preview

Terraform 和 Pulumi 区别不大,都支持通过 CLI 查看期望状态和当前状态的差异,以及执行计划;Crossplane 不支持查看差异和执行计划。

Create/Update/Destroy

三者都可以通过 CLI 执行资源变更操作,区别是 Terraform 和 Pulumi 默认情况会同步等待资源变更完成,Crossplane 则是异步操作,通过kubectl apply -f xxx.yaml命令执行变更后可以通过 kubectl describe命令查看资源变更详情。

CI/CD

三者都支持通过 CLI 的方式使用,对接 CI/CD 系统也能通过 CLI 方式实现,基本没有太大区别。

Drift & Sync

配置偏移发生于以下情形:由于手动、未经批准或不受监控的更改而导致基础架构随时间推移而发生变化,并且这些变化没有得到系统化地记录或跟踪。这些变化往往是由于紧急情况或过于复杂造成的,会导致资源实际状态和定义的期望状态不一致。比如定义了一个 K8s 集群中的三台节点,用户手动在云厂商控制台删除了一台,这时就发生了配置偏移。

Crossplane 利用 K8s 实现自动偏移检测和变更同步。Crossplane 在 K8s 中的自定义 Controller 定时执行 Reconcile 逻辑,过程中会 Refresh 资源状态,对比实际状态和期望状态,如果不一致则尝试 Update 或 Replace 资源为期望状态。

Terraform 和 Pulumi 也可以通过类似 CronJob 的手段实现 Crossplane 的自动偏移检测和同步,不过从工具本身来说,仅 Crossplane 原生支持自动偏移检测和同步的。

GitOps

GitOps 指将资源编排配置清单存储在 Git 仓库,当有变更提交到 Git 仓库后,能够触发一个处理流程保证基础设施实际状态和 Git 仓库中定义的状态保持一致。

Terraform 和 Pulumi 没有相关的生态,因此需要一定的开发工作;Crossplane 本身也没有相关能力,但由于属于 K8s 生态,因此可以使用 K8s 生态工具 ArgoCD、FluxCD,持续监控 Git 仓库,当有变更时触发资源 Reconcile,修改资源当前状态至定义的期望状态。这也是 Crossplane 的核心优势之一,可以借助 K8s 生态提供更加丰富的能力。

Support

  • Terraform 在三者中起源最早,有最丰富的 Providers、Modules、Examples 支持和最多的用户,社区也十分活跃
  • Pulumi Provider 相对较少,官方提供了工具支持从 Terraform Provider 到 Pulumi Provider 的转换,使得 Pulumi 接入成本较低,不过除了主流的云厂商,一些使用稍微不那么广泛的 Provider 并不支持 Pulumi。
  • Crossplane 的 Provider 支持最少,不过也包含了各大主流云厂商,并且官方提供了 upjet 工具,支持从 Terraform Provider 到 Crossplane Provider 的转换,因此主流使用场景基本资源支持都有覆盖。Crossplane 最大的优势是属于 K8s 生态,能够利用生态内的各种强大工具,Crossplane 属于 CNCF 项目,社区活跃度也是有保证的。

总结

关于如何选择合适的 IaC 工具,一般来说如果没有特殊需求,选 Terraform 是完全没问题的。如果需要使用通用编程语言,或者有相关的复杂需求 HCL 很难支持的,可以考虑 Pulumi;如果已有 K8s 集群,或者准备建设 K8s 集群的,可以考虑 Crossplane,相对成本较低,利用 K8s 生态内的多种工具,可以更高效的实现资源编排。

相关文档