Featured image of post Terraform IaC 初体验

Terraform IaC 初体验

Terraform 可以让我们以基础设施即代码(IaC)的方式集中管理不同的基础设施,比如 Cloudflare 上的 DNS 记录和 Argo Tunnel,以及 AWS 和 GCP 实例,可是导入过程相当让人难受。本文记录了我将现有基础设施迁移到 Terraform 的过程,涵盖安装、Provider 与状态管理、Cloudflare 资源导入、远程 State 与 CI 工作流实践,以及日常的运维操作和避坑经验。

Terraform 是一款 IaC 工具,所谓 IaC 就是「基础设施即代码」,将我们的基础设施以声明式的代码写出来,随后使用terraform apply即可完成基础设施的部署,同一份配置,部署出来的一定是一模一样的基础设施(Nix OS 用户狂喜)。

为什么要用 Terraform

传统的基础设施管理绝大部分基于人力和各种云服务商的 Dashboard,这就带来了下面这些痛点:

痛点说明
难以重现通过 Dashboard 点点点进行配置,容易改错或者改漏,而且难以复现
环境漂移手动操作导致生产和测试环境配置逐渐偏离,导致极端情况下测试没问题生产直接崩
难以扩展新增一套环境需要重复大量手工操作,耗时且容易出错
难以审计没有变更记录,出了事不好甩锅
难以协作基础设施由少数「懂的人」掌控,谁想要改都得去找这些人,效率低

为了解决这些问题,「基础设施即代码」1的概念被提了出来,Terraform 就是其中一个著名的解决方案。

假设一个现实的使用场景,例如你每年都会购买新的 GCP 的 $300 试用金账号,虽然便宜,但是每年都要重新跑到 GCP 的面板里开机器。而有了 Terraform,想要部署同样配置的机器,只要在每次更换账号之后,将 API Token 替换成新的,随后terraform apply,只需要几分钟就能开出和你上个账号一模一样的机器、VPC、S3、防火墙配置等等。

再比如 Cloudflare DNS 和 Tunnel 的管理和迁移,也只需要将原 Tunnel 的 Ingress Rule 复制到新 Tunnel 的 Ingress Rule。即使将来要迁移到 AliDNS、Route 53 等其他服务商,我们也可以原封不动的将数据复制过去2

尤其是到了多人协作的时候,配合 Git,每次更改都有迹可循,更不需要担心合并冲突,PR 可以自动生成更改预览,出现问题可以立刻回滚到上一个版本,这些都是传统依赖人力去直接操作服务提供商 Dashboard 的做法完全无法做到的。

Terraform 的安装

Terraform 是用 Go 写的,编译出来的产物自然也是单个可执行文件,所以安装起来也非常容易,Windows 下可以直接使用 Winget 安装:

1
winget install Hashicorp.Terraform

如果不想使用 Winget,也可以使用 Scoop 等其他包管理器,或者将预编译的二进制文件放入$PATH,即可完成安装。

如果你在 Linux 环境下,则使用对应的包管理器安装即可,如果使用将二进制文件放入$PATH的安装方法,则要记得sudo chmod +x terraform赋予文件的执行权限。

Terraform 的两个基本概念

Provider(s)

我们前面已经提到,Terraform 是一个款础设施即代码工具,作为一个工具本身,他并不绑定某个平台,而是通过 Provider(s) 与各个平台对接,要查看有哪些 Provider(s),可以浏览 Terraform 的 Registry

Terraform 拥有丰富的 Provider(s) 生态

状态管理

Terraform 将每次执行基础设施变更操作时的状态信息保存在一个状态文件中,默认情况下会保存在当前工作目录下的terraform.tfstate文件里3,我们也可以修改配置文件自行指定其他的存储后端,例如 S3、Postgres。每次我们执行terraform apply时,Terraform 都会将目前配置文件声明的状态同现有的状态文件进行比对,从而计算变动的部分,自动确定调整顺序之后操作 Provider 落实状态变动。

Terraform 的资源导入难题

现有资源的导入一直是 Terraform 为人诟病的难题,Hashi Corp. 似乎一直在坚持着一个固执且愚蠢的观点:你所有的基础设施从一开始就应当是用我们的 Terraform 创建的,所以也不存在什么资源导入的问题。

时间版本进展问题
2014–2022v0.x–v1.4只有 terraform import,一次一条,而且完全不生成配置导入完还得自己手写 HCL4
2023.06v1.5引入 import 块和 -generate-config-out参数,可以生成配置了然而还是得一条一条写,而且还得自己提供现有资源的 ID
2024.01v1.7import 块支持 for_each批量了,但 ID 还得自己搞
2024 下半年v1.12引入了 terraform querylist 块,终于实现了自动发现资源的功能然而这个功能要 Provider 要自己实现,大量 Provider 根本没跟上

这么多年的时间,社区一直在骂,然而,「我有一堆现有资源,怎么导入 Terraform」这个问题一直没有被认真对待。Terraform 作为「基础设施即代码」工具,设计哲学就是声明式和幂等性5,Hashi Corp. 他们笃信「一切以代码声明的状态为准,就应该用 Terraform 从零开始创建资源」。但现实是绝大多数公司都有大量历史存量资源,先有资源、后有代码才是常态。这个矛盾 Hashi Corp. 承认得很晚,v1.12terraform query 才算是官方第一次认真面对这个问题——但 Provider 生态的跟进嘛……真的是一言难尽。

Terraform 的文件结构

Terraform 的文件结构很简单,其主程序运行时会无脑读取工作目录下所有的.tf文件,只要信息齐全,想给文件取什么名字都可以,比如我的文件结构就长这样:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
 tree -a -I .git
.
├── .editorconfig
├── .github
   ├── dependabot.yml
   └── workflows
       ├── terraform-apply.yml
       ├── terraform-plan.yml
       └── your-fork.yml
├── .gitignore
├── .terraform.lock.hcl
├── cf_dns_zones.tf
├── cf_tunnel.tf
├── dns_example_com.tf
├── dns_example_net.tf
├── dns_example_top.tf
├── dns_example_cn.tf
├── main.tf                 # 基础配置(terraform 块)
├── moved.tf                # 移动过的资源
├── provider.tf             # 每个 Provider 的配置
├── README.md
├── rename_resources.ps1
└── variables.tf            # 自定义的变量

main.tf用于存放基础配置:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
terraform {
  required_providers {
    cloudflare = {
      source  = "cloudflare/cloudflare"
      version = "~> 5"
    }

    tencentcloud = {
      source  = "tencentcloudstack/tencentcloud"
      version = ">= 1.81.43"
    }
  }
}

provider.tf用于存放每个 Provider 的配置:

1
2
provider "cloudflare" {}
provider "tencentcloud" {}

这里留空是因为我们可以通过环境变量来传递 Credentials,而不是直接将 Credentials 写在配置文件中,例如 Cloudflare 的 Provider 就接受CLOUDFLARE_API_TOKEN作为api_token这个变量的替代。

variables.tf用于声明自定义的变量:

1
2
3
4
5
6
7
8
9
variable "cloudflare_zone_example_com" {
  description = "Cloudflare zone ID for example.com"
  type        = string
}

variable "cloudflare_zone_example_top" {
  description = "Cloudflare zone ID for example.top"
  type        = string
}

这些变量可以通过前缀为TF_VAR_的环境变量传入,Terraform 也会自动读取terraform.tfvars文件中的变量,变量的主要用途是在别的配置文件中调用,例如:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
resource "cloudflare_dns_record" "example_cname" {
  content = "${cloudflare_zero_trust_tunnel_cloudflared.Production_Tunnel.id}.cfargotunnel.com"
  name    = "example.example.com"
  proxied = true
  tags    = []
  ttl     = 1
  type    = "CNAME"
  zone_id = var.cloudflare_zone_id_example_com  #这里调用了cloudflare_zone_example_com这个变量
  settings = {
    flatten_cname = false
  }
}

有了这些基础配置,我们就可以运行terraform init初始化 Terraform 环境并锁定依赖版本,剩下的就都是我们资源的声明了。

将现有资源导入 Terraform

前文提到过 Terraform 的状态管理这个概念,状态管理虽好,但在我们初始化时也带来了一个问题——在默认状态下,Terraform 的状态文件显然是空的。

此时我们需要做的是将云服务提供商上现有资源的状态导入 Terraform,这样 Terraform 才能无缝接手并管理我们的基础设施。相信大家也都看到了前文对 Terraform 资源导入问题的吐槽。以导入 Cloudflare 上托管的 DNS 记录为例,前文提到过,如果 Provider 支持,可以使用 Terraform 在 1.12 版本之后引入的terraform query,然而大多数情况下,Providers 都是没有跟进这个新功能的,Cloudflare 就属于不支持的那一类。

好在虽然 Hashi Corp. 不认真解决问题,各路高强度使用 Terraform 管理基础设施的公司就各显神通。Cloudflare 就维护了名为 cf-terraforming 的导入工具,免去了很多手动导入的麻烦。首先安装cf-terraforming,这个工具是用 Go 语言编写的,需要在有 Go 语言环境的情况下安装,或者将官方编译好的二进制文件其放入$PATH并赋予其执行权限。

1
go install github.com/cloudflare/cf-terraforming/cmd/cf-terraforming@latest

这个工具的用法还是比较简单的,首先你需要以下环境变量:

1
2
3
4
5
6
7
8
9
# 如果你使用 API Token
export CLOUDFLARE_API_TOKEN='Hzsq3Vub-7Y-hSTlAaLH3Jq_YfTUOCcgf22_Fs-j'

# 如果你使用 API Key
export CLOUDFLARE_EMAIL='user@example.com'
export CLOUDFLARE_API_KEY='1150bed3f45247b99f7db9696fffa17cbx9'

# 指定需要导入的域名的区域 ID,如果导入的是账户资源(例如 Cloudflare Tunnel)则不需要
export CLOUDFLARE_ZONE_ID='81b06ss3228f488fh84e5e993c2dc17'
💡 提示

此处的命令假设你正在使用 Bash,如果使用的是与 Bash 语法不兼容的 Shell,则需要做出调整,例如对于 Windows 上的 PowerShell,导入环境变量的语法如下:

1
$env:CLOUDFLARE_API_TOKEN='Hzsq3Vub-7Y-hSTlAaLH3Jq_YfTUOCcgf22_Fs-j'

通常我们只需要设置CLOUDFLARE_API_TOKENCLOUDFLARE_ZONE_ID就可以了,在控制台创建 API Token 时,记得赋予这个 Token 必要的权限,本次我们只是导入 DNS 记录,所以只赋予编辑区域 DNS 的权限即可。

赋予操作资源所需要的权限

好了,准备工作全部完成,现在可以开始生成配置文件:

首先导入账户中域名的配置,也就是cloudflare_zone

1
2
3
cf-terraforming generate \
  --key $CLOUDFLARE_API_KEY \
  --resource-type "cloudflare_zone" > zone.tf

这一步会在当前目录下生成一个名为zone.tf的文件,里面会有如下格式的内容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
resource "cloudflare_zone" "REDACTED" {
  name                = "REDACTED"
  paused              = false
  type                = "full"
  vanity_name_servers = []
  account = {
    id   = "REDACTED"
    name = "REDACTED"
  }
}

resource "cloudflare_zone" "REDACTED" {
  name                = "REDACTED"
  paused              = false
  type                = "full"
  vanity_name_servers = []
  account = {
    id   = "REDACTED"
    name = "REDACTED"
  }
}

现在域名资源已经导入了,但是里面的配置并没有。接下来,导入域名下的 DNS 记录:

1
2
3
4
cf-terraforming generate \
  --zone $CLOUDFLARE_ZONE_ID \
  --key $CLOUDFLARE_API_KEY \
  --resource-type "cloudflare_dns_record" >> dns.tf

这一步会在当前目录下生成一个名为dns.tf的配置文件,里面会有如下格式的内容:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
resource "cloudflare_dns_record" "terraform_managed_resource_5deb14xxxxxb629bf123xxxxxxxc8f_0" {
  content  = "67.24.33.108"
  name     = "example.example.com"
  proxied  = true
  tags     = []
  ttl      = 1
  type     = "A"
  zone_id  = "81c7f2de8dfxxxxxx52629xxxxxxfc"
  settings = {}
}

resource "cloudflare_dns_record" "terraform_managed_resource_89xxxxx0bf9cxxxxxx9a_1" {
  content  = "35.27.108.33"
  name     = "terraform.example.com"
  proxied  = true
  tags     = []
  ttl      = 1
  type     = "A"
  zone_id  = "8xxxxxx7644e428526xxxxxx"
  settings = {}
}

如果有多个域名需要导入,则多次分别设置环境变量CLOUDFLARE_ZONE_ID再重复运行命令即可。

这里生成的配置文件可以直接使用,也就是我们之后需要的 Terraform 配置文件。然而,此时我们仅仅只是生成了配置文件,但是目前 Terraform 的状态依然是空的,这时要是直接terraform apply,Terraform 会不管三七二十一将我们刚刚导入的声明一律视为新增资源,然后甩出一大堆「Alredy Exists」报错。所以接下来,我们需要将生成的配置文件导入 Terraform 的terraform.tfstate状态。

Terraform 在 1.5 版本引入了import块,相比以往一行一行输入命令的方式更加现代。其导入流程是先生成一个包含import块的.tf文件,下次进行terraform apply时,Terraform 就会自动为我们执行导入操作。

生成cloudflare_zoneimport块:

1
2
3
4
5
cf-terraforming import \
  --resource-type "cloudflare_zone" \
  --modern-import-block \
  --key $CLOUDFLARE_API_KEY \
  --zone $CLOUDFLARE_ZONE_ID >> import.tf

生成cloudflare_dns_recordimport块:

1
2
3
4
5
cf-terraforming import \
  --resource-type "cloudflare_dns_record" \
  --modern-import-block \
  --key $CLOUDFLARE_API_KEY \
  --zone $CLOUDFLARE_ZONE_ID >> import.tf

这一步会在当前目录下生成import.tf,它包含了所需要的导入信息,作用就是告诉 Terraform 上一步生成的每个resource块到底对应的是哪一个云服务提供商的资源 ID。这个 ID 是云服务提供商内部标记资源的代码,平常是不会在控制面板上显示的,只有在用 API 特别请求时才会知道。Terraform 在导入过程中需要用到这个 ID 以确认本地的定义对应的云端资源,以实现严格的幂等性。

好了,现在我们运行terraform plan

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
$ terraform plan
cloudflare_dns_record.minio_a: Refreshing state... [id=xxxxxxxxxxx53]
cloudflare_zero_trust_tunnel_cloudflared_config.raspberrypi: Refreshing state...
......

Terraform will perform the following actions:

  # cloudflare_dns_record.terraform_managed_resource_0 will be imported
    resource "cloudflare_dns_record" "terraform_managed_resource_REDACTED_0" {
        content     = "67.24.33.108"
        created_on  = "2026-04-08T10:18:12Z"
        id          = "5deb14c21xxxxxxx20f1c8f"
        meta        = jsonencode({})
        modified_on = "2026-04-08T10:18:12Z"
        name        = "example.example.com"
        proxiable   = true
        proxied     = true
        settings    = {}
        tags        = []
        ttl         = 1
        type        = "A"
        zone_id     = "REDACTED"
    }

  # cloudflare_dns_record.terraform_managed_resource_1 will be imported
    resource "cloudflare_dns_record" "terraform_managed_resource_89c149exxxxxxxxxxxba13xxxxxa_1" {
        content     = "35.27.108.33"
        created_on  = "2026-04-08T10:17:54Z"
        id          = "89cxxxxxxxxxxxxxxxxxx09a"
        meta        = jsonencode({})
        modified_on = "2026-04-08T10:17:54Z"
        name        = "terraform.example.com"
        proxiable   = true
        proxied     = true
        settings    = {}
        tags        = []
        ttl         = 1
        type        = "A"
        zone_id     = "81xxxxxxxxxxxxxxxxxxxxxfc"
    }

Plan: 2 to import, 0 to add, 0 to change, 0 to destroy.

────────────────────────────────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly
these actions if you run "terraform apply" now.

如果需要 Add、Change 和 Destroy 的资源数量都是 0,说明我们的导入操作没有问题,直接terraform apply --auto-approve,资源就导入完成了。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
$ terraform apply --auto-approve
cloudflare_dns_record.push_a: Refreshing state... [id=REDACTED]

Terraform will perform the following actions:

  # cloudflare_dns_record.terraform_managed_resource_REDACTED_0 will be imported
    resource "cloudflare_dns_record" "terraform_managed_resource_REDACTED_0" {
        content     = "67.24.33.108"
        created_on  = "2026-04-08T10:18:12Z"
        id          = "REDACTED"
        meta        = jsonencode({})
        modified_on = "2026-04-08T10:18:12Z"
        name        = "example.example.com"
        proxiable   = true
        proxied     = true
        settings    = {}
        tags        = []
        ttl         = 1
        type        = "A"
        zone_id     = "REDACTED"
    }

  # cloudflare_dns_record.terraform_managed_resource_REDACTED_1 will be imported
    resource "cloudflare_dns_record" "terraform_managed_resource_REDACTED_1" {
        content     = "35.27.108.33"
        created_on  = "2026-04-08T10:17:54Z"
        id          = "REDACTED"
        meta        = jsonencode({})
        modified_on = "2026-04-08T10:17:54Z"
        name        = "terraform.example.com"
        proxiable   = true
        proxied     = true
        settings    = {}
        tags        = []
        ttl         = 1
        type        = "A"
        zone_id     = "REDACTED"
    }

Plan: 2 to import, 0 to add, 0 to change, 0 to destroy.
cloudflare_dns_record.terraform_managed_resource_REDACTED_1: Importing... [id=REDACTED/REDACTED]
cloudflare_dns_record.terraform_managed_resource_REDACTED_1: Import complete [id=REDACTED/REDACTED]
cloudflare_dns_record.terraform_managed_resource_REDACTED_0: Importing... [id=REDACTED/REDACTED]
cloudflare_dns_record.terraform_managed_resource_REDACTED_0: Import complete [id=REDACTED/REDACTED]

Apply complete! Resources: 2 imported, 0 added, 0 changed, 0 destroyed.

状态存储和持续集成

IaC 的核心价值之一就在于可以轻易实现基于 Git 的多人协作,以及 CI 的持续集成,但是在此之前,又有一个新的问题需要解决——terraform.tfstate到底放哪里:没人想要换一次环境辛辛苦苦导入的状态就丢一次。

Terraform 目前支持以下几种保存状态的后端:

  • local
  • remote
  • azurerm
  • consul
  • cos
  • gcs
  • http
  • Kubernetes
  • oci
  • oss
  • pg
  • s3

没有特殊需求可以像我一样选择s3,毕竟 Cloudflare R2 有免费额度,不用白不用。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
terraform {
  backend "s3" {
    bucket = "terraform"
    key    = "terraform.tfstate"
    region = "auto"
    endpoints = {
      s3 = "https://REDACTED.r2.cloudflarestorage.com"
    }

    # R2 不需要这些 AWS 的验证
    skip_credentials_validation = true
    skip_metadata_api_check     = true
    skip_region_validation      = true
    skip_requesting_account_id  = true
    use_path_style              = true
  }
}

对于 S3 的后端,建议用AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY两个环境变量来存储 Credentials:

1
2
export AWS_ACCESS_KEY_ID='REDACTED'
export AWS_SECRET_ACCESS_KEY='REDACTED'

配置完成,运行terraform init -migrate-state,配置就成功存储到云上了,以后不论在何处修改配置、运行terraform apply都无须担心 Terraform 的 State 不同步的问题。

接下来就是 Github CI 的配置,其实很简单,无非就是每次git push时触发一次terraform initterraform fmtterraform apply,以下是我的.github/workflows/apply.yml

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
name: "Terraform Apply"

on:
  push:
    branches:
      - main

env:
  TF_IN_AUTOMATION: "true"
  CLOUDFLARE_API_TOKEN: "${{ secrets.CLOUDFLARE_API_TOKEN }}"
  AWS_ACCESS_KEY_ID: "${{ secrets.AWS_ACCESS_KEY_ID }}"
  AWS_SECRET_ACCESS_KEY: "${{ secrets.AWS_SECRET_ACCESS_KEY }}"
  TF_VAR_cloudflare_zone_id_example_com: "${{ vars.TF_VAR_CLOUDFLARE_ZONE_ID_EXAMPLE_COM }}"
  TF_VAR_cloudflare_zone_id_example_top: ${{ vars.TF_VAR_CLOUDFLARE_ZONE_ID_EXAMPLE_TOP }}

jobs:
  terraform:
    name: "Terraform Apply"
    runs-on: ubuntu-latest
    permissions:
      contents: read
    concurrency:
      group: terraform-apply
      cancel-in-progress: false
    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v4

      - name: Terraform Init
        run: terraform init -input=false

      - name: Terraform Apply
        run: terraform apply -input=false -auto-approve

对于 PR 则应当让 CI 自动为每次 PR 附上terraform plan的输出:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
name: Terraform Plan

on:
  pull_request:
    paths:
      - "**/*.tf"
      - ".github/workflows/terraform-plan.yml"

permissions:
  contents: read
  pull-requests: write

env:
  TF_IN_AUTOMATION: "true"
  CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
  AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
  AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
  TF_VAR_cloudflare_zone_id_example_com: "${{ vars.TF_VAR_CLOUDFLARE_ZONE_ID_EXAMPLE_COM }}"
  TF_VAR_cloudflare_zone_id_example_top: ${{ vars.TF_VAR_CLOUDFLARE_ZONE_ID_EXAMPLE_TOP }}

jobs:
  plan:
    name: Terraform Plan
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v6

      - uses: hashicorp/setup-terraform@v4

      - name: Terraform fmt
        id: fmt
        run: terraform fmt -check -recursive
        continue-on-error: true

      - name: Terraform Init
        id: init
        run: terraform init -input=false

      - name: Terraform Validate
        id: validate
        run: terraform validate -no-color

      - name: Terraform Plan
        id: plan
        run: terraform plan -input=false -no-color
        continue-on-error: true

      - name: Post Plan to PR
        uses: actions/github-script@v8
        with:
          github-token: ${{ secrets.GITHUB_TOKEN }}
          script: |
            const { data: comments } = await github.rest.issues.listComments({
              owner: context.repo.owner,
              repo: context.repo.repo,
              issue_number: context.issue.number,
            });
            const botComment = comments.find(c =>
              c.user.type === 'Bot' && c.body.includes('<!-- terraform-plan -->')
            );

            const planOutput = `${{ steps.plan.outputs.stdout }}`.substring(0, 65000);

            const body = `<!-- terraform-plan -->
            #### Terraform Plan

            | Step     | Result                            |
            | -------- | --------------------------------- |
            | fmt      | \`${{ steps.fmt.outcome }}\`      |
            | init     | \`${{ steps.init.outcome }}\`     |
            | validate | \`${{ steps.validate.outcome }}\` |
            | plan     | \`${{ steps.plan.outcome }}\`     |

            <details><summary>展开 Plan 详情</summary>

            \`\`\`terraform
            ${planOutput}
            \`\`\`
            </details>`;

            if (botComment) {
              await github.rest.issues.updateComment({
                owner: context.repo.owner,
                repo: context.repo.repo,
                comment_id: botComment.id,
                body
              });
            } else {
              await github.rest.issues.createComment({
                issue_number: context.issue.number,
                owner: context.repo.owner,
                repo: context.repo.repo,
                body
              });
            }

      - name: Fail if plan failed
        if: steps.plan.outcome == 'failure'
        run: exit 1

每次 PR 都会有 Plan 的输出

后续的工作流程

到这一步,Terraform 的「接管初始化」已经结束了,后面就进入了日常维护阶段。这个阶段其实就三件事:

  1. 创建新资源
  2. 修改现有的资源
  3. 删除不再需要的资源

常用的操作就两个:terraform planterraform apply,如果是个人使用,小的改动直接提交就算了;如果是团队协作,每次修改则应当遵循能 PR 就不直接 Commit 的原则。

创建基础设施

假设你现在要新增一条 DNS 记录,或者新建一个 Tunnel、一个对象存储桶,流程如下:

最理想的plan输出是:

  • X to add
  • 0 to change
  • 0 to destroy

如果你只是想加东西,结果出现了to destroy,那就先别冲动,通常是引用写错、变量搞错,或者不小心改了资源地址,仔细检查是什么地方出了问题。

修改与删除基础设施

修改流程和创建类似,但要多一步——评估变更是否会触发重建。

因为很多 Provider 字段是ForceNew,你以为只是改个字段,Terraform 看完说:「好的,删了重建。」这在我们修改 DNS 的这个场景并不是什么很大的问题,但是到了云实例这种资源,如果删除重建势必会造成损失。

建议按下面这个顺序来:

对生产环境来说,创建、修改得慢一点并不是什么很大的问题,最应当看重的是操作的正确性,慢一点,不要出错。IaC 不是比手速,IaC 比的是可预期性。

如果是删除资源(比如下线某个 DNS 记录、清理废弃 Tunnel),走下面这个流程6

日常协作建议

  • 把 Credentials 放环境变量或 CI Secret,别写进.tf和仓库
  • 对关键资源开启保护策略,防止误删
  • 将目录按资源类型拆分
  • 定期执行terraform plan做基础设施漂移检查7和校正,避免在面板误操作手动修改

虽然这套流程看起来很麻烦,但每一次变更都有记录、可审计、可回滚,最重要的是可复现,这才是 IaC 最有价值的地方。

参考


  1. Infrastructure as Code,是指采用机器可读的配置文件定义所需要的基础设施的部署方法。 ↩︎

  2. 每个供应商的配置文件字段命名和格式都有区别,但这可以很容易的编写脚本进行转换。 ↩︎

  3. 需要特别注意的是,状态文件中可能包含数据库密码、API 密钥等明文存储的敏感信息,因此绝对不要将 .tfstate 文件提交到公开的代码仓库中。 ↩︎

  4. HashiCorp Configuration Language,HashiCorp 自家开发的一种声明式配置语言,旨在兼顾机器可读性与人类可读性。 ↩︎

  5. 幂等性(Idempotence)指计算机系统或接口在接收到同一请求的多次操作时,产生的影响与一次执行的结果相同,不论执行多少次,系统的最终状态始终保持一致。 ↩︎

  6. 如果你仅仅是想让 Terraform 不再管理某个资源,而不是真正在云端销毁它,应该使用 terraform state rm 命令,而不是在代码中删掉资源块然后 apply,否则云环境上的真实资源也会被一并销毁。 ↩︎

  7. 基础设施漂移(Infrastructure Drift)是指现实中通过控制台点按等非 IaC 途径修改了基础设施,导致其实际状态与代码中声明的状态不一致的情况。 ↩︎