Featured image of post Terraform IaC 初体験

Terraform IaC 初体験

Terraform を使うと、インフラストラクチャ・アズ・コード(IaC)方式で、Cloudflare 上の DNS レコードや Argo Tunnel、AWS および GCP のインスタンスなど、さまざまなインフラを一元管理できます。ただし、インポートプロセスにはかなり苦労しました。本記事では、既存のインフラを Terraform へ移行した過程を記録しています。インストール、Provider と State 管理、Cloudflare リソースのインポート、リモート State と CI ワークフローの実践、日常の運用作業およびトラブル回避の経験についてまとめます。

Terraform は IaC ツールであり、IaC とは「インフラストラクチャ・アズ・コード」を意味します。私たちが持つインフラストラクチャを宣言的なコードとして記述し、その後 terraform apply を実行することでインフラストラクチャのデプロイが完了します。同一の設定であれば、必ず同じインフラストラクチャがデプロイされます(Nix OS ユーザーにとっては大喜びの内容です)。

Terraform を使用する理由

従来のインフラストラクチャ管理の大部分は人的作業と各種クラウドプロバイダーのダッシュボードに依存しており、以下のような課題が生じています:

課題説明
再現性の欠如ダッシュボードでのクリック操作による設定変更は、誤りや見落としが発生しやすく、復元も困難
環境ドリフト手動操作により本番環境とテスト環境の設定が徐々に乖離し、極端なケースではテストでは問題ないのに本番でダウンすることがある
拡張の難しさ新しい環境を追加するには多数の手動作業を繰り返す必要があり、時間がかかりミスも発生しやすい
監査の難しさ変更履歴がないため、トラブル発生時に責任の所在を特定するのが難しい
協力の難しさインフラストラクチャは少数の「詳しい人」によって管理されており、誰かが変更したい場合でもその人を探さなければならず効率が悪い

これらの問題を解決するために、「インフラストラクチャ・アズ・コード」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 には変更プレビューが自動生成され、問題が発生すればすぐに前バージョンにロールバックできます。これらは従来の人手によるプロバイダーダッシュボードへの直接操作では到底不可能なことです。

Terraform のインストール

Terraform は Go で書かれており、コンパイルされた出力は単一の実行可能ファイルであるため、インストールも非常に簡単です。Windows では Winget を直接使用できます:

1
winget install Hashicorp.Terraform

Winget を使いたくない場合は、Scoop などの別のパッケージマネージャーを使用するか、事前コンパイルされたバイナリファイルを $PATH に配置するだけでインストールが完了します。

Linux 環境の場合は、対応するパッケージマネージャーを使用してインストールしてください。バイナリファイルを $PATH に配置する方法を使う場合は、sudo chmod +x terraform でファイルの実行権限を設定することを忘れないでください。

Terraform の 2 つの基本概念

Provider(s)

前述の通り、Terraform はインフラストラクチャ・アズ・コードツールですが、ツール自体は特定のプラットフォームにバインドされておらず、Provider(s) を通じて各プラットフォームと連携します。利用可能な Provider(s) を確認するには、Terraform のレジストリを参照してください。

Terraform は豊富な Provider(s) エコシステムを持つ

状態管理

Terraform はインフラストラクチャ変更操作を実行する際の状態情報を状態ファイルに保存します。デフォルトでは現在の作業ディレクトリ内の terraform.tfstate ファイルに保存されます3。構成ファイルを変更して他のストレージバックエンド(S3、Postgres など)を指定することも可能です。terraform apply を実行するたびに、Terraform は現在の構成ファイルで宣言された状態と既存の状態ファイルを比較し、変更部分を計算して調整順序を自動的に決定した後、Provider を操作して状態変動を実現します。

Terraform のリソースインポートの難題

既存リソースのインポートはこれまで Terraform が批判されてきた問題点であり、Hashi Corp. は一貫して「すべてのインフラストラクチャは最初から Terraform で作成されるべきであり、リソースインポートの問題などは存在しない」という頑固で愚かな見解を堅持しているようです。

時期バージョン進展問題
2014–2022v0.x–v1.4terraform import のみで、一度に 1 件ずつ、かつ全く設定を生成しないインポート後でも自分で HCL4 を書く必要がある
2023.06v1.5import ブロックと -generate-config-out パラメータを導入し、設定を生成可能になったそれでも 1 行ずつ書く必要があり、既存リソースの ID も自分で提供しなければならない
2024.01v1.7import ブロックが for_each をサポートバッチ処理が可能になったが、ID はまだ自分で用意する必要がある
2024 年下半期v1.12terraform 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 を構成ファイルに直接書くのではなく、環境変数を通じて渡せるからです。例えば Cloudflare の Provider は、CLOUDFLARE_API_TOKENapi_token 変数の代替として受け入れます。

variables.tfはカスタム変数を宣言するために使用されます:

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

variable "cloudflare_zone_example_top" {
  description = "example.top の Cloudflare zone ID"
  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 の編集権限だけ付与すれば OK です。

リソース操作に必要な権限を付与する

準備完了です。今から構成ファイルを生成しましょう:

まずアカウント内のドメイン構成をインポートします。つまり 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 は文脈に関係なく新しくインポートした宣言をすべて新規リソースとして扱い、「Already Exists」というエラーメッセージを大量に表示します。そこで次に、生成された構成ファイルを Terraform の terraform.tfstate 状態にインポートする必要があります。

Terraform は 1.5 バージョンで import ブロックを導入し、以前のような 1 行ずつコマンドを入力する方式よりも現代的になりました。そのインポートフローは、まず 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 バックエンドの場合、Credentials を保存するには AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY の 2 つの環境変数を使用することをお勧めします:

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 の「接管初期化」は終了し、日常保守フェーズに入りました。この段階では実際には 3 つのことしかありません:

  1. 新しいリソースの作成
  2. 既存リソースの変更
  3. 不要になったリソースの削除

よく使う操作は 2 つ:terraform planterraform apply。個人利用の場合、小さな変更はそのままコミットしても良いでしょう。チームで協力する場合、毎回変更は PR を通じた方が原則です。

インフラストラクチャの作成

今 DNS レコードを追加したり、Tunnel やオブジェクトストレージバケットを新しく作る場合のフローは以下の通りです:

最も理想的な plan 出力は:

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

何かを追加したいだけなのに to destroy が表示された場合は、焦らずにチェックしてください。引用ミスタイプ、変数間違い、またはリソースアドレスのうっかり変更などが原因であることがほとんどです。どこに問題があるのか仔細に確認してください。

修改と删除基础设施

変更フローは作成と似ていますが、もう一つのステップが増えます——変更が再構築を引き起こすかどうかの評価です。

多くの Provider フィールドは ForceNew となっており、フィールドを変えるつもりが Terraform は「了解、削除して再構築します」と言ってくるかもしれません。DNS のようなリソースでは大きな問題ではありませんが、クラウドインスタンスのようなリソースになると、削除して再構築すれば損失が発生します。

以下の順序で進めることをお勧めします:

本番環境にとって、作成や変更が遅くても大きな問題ではありません。重要なのは操作の正確性です。少し遅くても、間違えないようにしてください。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 以外の経路でインフラストラクチャに変更を加え、実際の状態とコード内で宣言された状態が不一致になる現象のこと。 ↩︎

本ウェブページの日本語版は、生成系大規模言語モデル(LLM)によって簡体字中国語版の内容を翻訳したものです。その内容は「乱筆」による確認、校正および承認を経ておりません。ご利用の際には、いかなる場合でも本日本語版を簡体字中国語版または英語版と同等と見なさないようご注意ください。
Hugo で構築されています。
テーマ StackJimmy によって設計されています。