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 途徑修改了基礎設施,導致其實際狀態與代碼中宣告的狀態不一致的情況。 ↩︎