📘

9つのGoogleCloudプロジェクトをTerraform化する過程で学んだことを公開します part①

2024/10/14に公開

はじめに

【追記】part2 の記事は こちら

今回タイトルにある通り 9 つの GoogleCloud のプロジェクトを Terraform で管理できるように移行作業を行う機会がありました。
自分自身、この移行作業までは Terraform を触ったことがなかったのですが、今回の移行作業を通して多くの学びを得ることができました。
学んだ事をただ単に公開するのではなく、これから初めて Terraform を触るよという方にも実装する上で参考になるような記事にしていきたいと思います!

この記事でわかること

  • terraform とは
  • 既に作成済みのリソースを Terraform で管理するための移行方法
  • terraform の繰り返し処理のベストプラクティス
  • ステートファイルの管理方法

事前情報

terraform v1.9.3
google-provider v6.2.0
Firebase CLI v13.20.2
Google Cloud SDK 462.0.1

学び ① Terraform とは

まずは今回の移行作業にあたり Terraform とは何かを理解できるようにするところからスタートしました。
基本的には 詳解 Terraform 第 3 版 ―Infrastructure as Code を実現する を読んで整理しました。

IaC(Infrastructure as Code)について

まず Terraform について考える前にもっと大きい括りである IaC について整理します。
IaC(Infrastructure as Code)とは、その名の通りインフラの構築をコードで記述し管理(デプロイ/更新/削除)できるようにすることを指します。Terraform はこの IaC を実現するためのツールの中の一つです。

もう少し IaC について整理します。例として手動でプロジェクトの設定していると、他のプロジェクトとは設定や構成が微妙に異なる状態のスノーフレークサーバができる可能性が出てきます。
このスノーフレークサーバの問題点は、次のような点にあります。

  • 再現性の欠如 他の環境で同じプロジェクトを再構築することが難しくなる
  • 手作業による変更 手動での設定変更が原因で、プロジェクト間で一貫性がなくなる
  • 管理の複雑さ 一つ一つのプロジェクトの設定が異なるため、保守や管理が非常に複雑になる

Terraform などの IaC(Infrastructure as Code)ツールは、これらの問題を回避する役割を持っています。
具体的にはインフラの設定をコードで記述しバージョン管理ができるようになるため、同じ設定を何度でも再現でき、手動作業によるミスやスノーフレークサーバ化を防ぐことができます。

次に IaC ツールを導入することのメリットを整理します。

  • 誰が実行しても毎回同じ環境を安全に再現することができる
  • 同じ環境を作る時の手間を省くことができる
  • インフラの変更に対してコードレビューができる
  • ドキュメント化することができる
    • コードを見ればどのようなインフラが構成されているのかを把握することができる
    • 「どのような構成になっているか」を属人化することなく管理することができる
  • バージョン管理ができる
    • すべての履歴がコミットログとして残るため、どのような変更が加えられたかを確認でき、問題が発生したときは以前のコードに戻すことができる

Terraform とは

Terraform とは上記の IaC を実現するためのプロビジョニングツールになります。
プロビジョニング(Provisioning)とは、インフラの構築やセットアップのプロセスであり必要なものを準備することを指しているようです。

IaC ツールから Terraform を選択するメリット

IaC ツールを導入するメリットがわかったところで Terraform を選択する理由について整理してみます。

  • 特定のプロバイダに依存していない
    • GoogleCloud / AWS / Azure
  • 成熟した大規模なコミュニティ
    • ナレッジがたくさんある
  • 周辺の関連ツールの充実度
  • 豊富なチュートリアル/ドキュメント/記事
    • 今後新しく Join するエンジニアの方が Terraform を触ったことがなかったとしてもそこまで困ることはないはず(キャッチアップの観点で)

学び ② 既存のリソースを Terraform で管理できるようにする方法

このセクションでは既存のリソースを Terraform 化する方法(インポート)についてみていきたいと思います。

まず「既存のリソースを Terraform で管理するために import する」とはどういうことかを理解していきたいと思います。
「既存のリソース」は既に GoogleCloud のプロジェクトで作成されている実リソースを指します。
「Terraform で管理する」は「既存のリソース」をコードで記述しそのコードを使って GoogleCloud プロジェクトの実リソースを作成/変更/削除できる状態を指します。
「import する」は「Terraform で管理するため」に「既存のリソース」の情報を手元に取り込むことを指します。

今回は Terraform の標準機能である Terraform import を使ってインポートを実施したので、その方法についてみていきます。

それにしても最初に import について色々調査したのになんで見落としてたのか。。
めちゃくちゃ勿体無いことをしたなーと思いますが、これもある意味の学びなので受け止めようと思います。。

Terraform で管理できるようにインポートする

まずはシンプルに、既存のリソースをどのような流れでインポートしていくかを次の必要最低限のステップでみていきたいと思います。

  1. 現在のリソースを把握する
  2. import ブロックを使って Terraform を実行する

今回このインポートを実施するにあたり Google Cloud のリソースを Terraform 化するための 7 ステップ の記事を参考にさせていただきました。詳細なステップなど、すごく参考になると思うのでぜひ読んでいただきたいです。

また Terraform ではインポートする手段として import コマンドimport ブロック が存在しますが、今回はコードに記述していく import ブロックの方法で説明していきます。

【ステップ 1】 現在のリソースを把握する

今回のケースは既にサービスのインフラとして GoogleCloud プロジェクトで様々なリソースが作成されている状態です。
インポートするにあたり、まずはどのようなリソースが使われているのかを把握する必要があります。

方法 ① gcloud export コマンドを使う

GoogleCloud では現在のインフラ構成を Terraform のコード形式に一括でエクスポートしてくれる便利なコマンド があります。
$ gcloud beta resource-config bulk-export --resource-format=terraform > main.tf のようにコマンドを打つだけで簡単に Terraform のコードが出力される便利なコマンドです。

ただし制限事項に書かれているように一部のリソースタイプはサポートされていません。仮に一部のリソースのみを管理するのであればこれで事足りる可能性がありますが、プロジェクト全体を管理したい場合はこれで解決することは難しいです。
$ gcloud beta resource-config list-resource-types コマンドでサポートされているリソース一覧を表示できるのでこちらを参考にしつつカバーできないリソースに関して別の方法で調べる方法があります。

方法 ② プロジェクトで有効になっている API 一覧から判断する

$ gcloud services list でプロジェクトで有効になっている API 一覧を取得できるのでこの情報を参考にして使用しているリソースを把握する方法です。
有効になっている API 一覧から把握したいリソースをコマンドなどを用いて存在するか確認していきます。
今回は Firebase のリソースも管理していたので Firebase リソースを例にした確認方法を紹介します。

$ gcloud services list で API 一覧取得

// 省略
firebasedynamiclinks.googleapis.com     Firebase Dynamic Links API
firebaseextensions.googleapis.com       Firebase Extensions API
firebasehosting.googleapis.com          Firebase Hosting API
firebaseinstallations.googleapis.com    Firebase Installations API
// 省略

firebaseextensions.googleapis.com から Firebase プロジェクトでどんな拡張機能が使われているかを確認してみます。

公式ドキュメント から拡張機能のリストを取得できるコマンドがあるので $ firebase ext:list --project=xxxxxxxx を実行する

Extension Publisher Instance ID State Version Your last update
firebase/storage-resize-images firebase storage-resize-images ACTIVE 0.1.27 2024-06-22 11:23:31

コマンドの結果から storage-resize-images が使われていることを確認できました。
あとは 公式ドキュメント を参考に Firebase の拡張機能を管理するために import ブロックを書いていく流れになります。(コードの記述については後述します)

【ステップ 2】 import ブロックを使って Terraform を実行する

最初のステップで移行に必要なリソースを把握できたら、実際に import するためのコードを書いていきます。コードの書き方については 2 つのパターンが存在します。

パターン ① import ブロックから resource ブロックを生成する

これは最初に import ブロックだけを記述した状態でインポートを実行して resource ブロックを生成する方法です。
例として GoogleCloud の CloudStorage のバケットを import してみたいと思います。

import ブロックは id(実リソースの識別子), to(tf ファイル内で識別するためのリソース識別子) を書くのはどのリソースでも同じですが ID はリソースごとに違ったりします。Terraform のドキュメントで import ブロックのサンプルが載っているのでそちらを参考にして書いていきましょう。CloudStorage の場合

まずは tf ファイルを作成し Terraform を動かすための最小限のコードを書いていきます。

terraform {
  required_version = "1.9.3"
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "6.2.0"
    }
    google-beta = {
      source  = "hashicorp/google-beta"
      version = "6.2.0"
    }
  }
}

provider "google-beta" {
  user_project_override = true
  region                = "asia-northeast1"
}

import ブロックを定義します。

import {
  id = "testdayo-xxxxx/testdesuyo55" //リソース識別するためのID
  to = google_storage_bucket.test // tfファイル内でリソースを識別するためのアドレス
}

$ terraform plan -generate-config-out=generated.tf を実行します(generated.tf の部分は任意のファイル名で良い)。すると次の結果が出力され generated.tf ファイルに resource ブロックが追加されると思います。

実行結果

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

generated.tf ファイル

# __generated__ by Terraform
# Please review these resources and move them into your main configuration files.

# __generated__ by Terraform from "testdayo-xxxxx/testdesuyo55"
resource "google_storage_bucket" "test" {
  default_event_based_hold    = false
  enable_object_retention     = false
  force_destroy               = false
  labels                      = {}
  location                    = "US-EAST1"
  name                        = "testdesuyo55"
  project                     = "testdayo-xxxxx"
  public_access_prevention    = "enforced"
  requester_pays              = false
  rpo                         = null
  storage_class               = "STANDARD"
  uniform_bucket_level_access = true
  soft_delete_policy {
    retention_duration_seconds = 604800
  }
}

$ terraform apply を実行すると、、

google_storage_bucket.testdesuyo55: Importing... [id=testdayo-xxxxx/testdesuyo55]
google_storage_bucket.testdesuyo55: Import complete [id=testdayo-xxxxx/testdesuyo55]

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

無事にインポートが成功しました!
terraform.tfstate ファイル(後述します)にもバケットのリソースが追加されていることが確認できるはずです。これで CloudStorage のバケットを Terraform で管理できるようになりました。

以降は $ terraform plan を実行しても差分が発生しないようになります。
この時点で resource ブロックは generated.tf で定義されるので、あとは適宜ディレクトリ構成に合わせて別ファイルに書き換えるなどの対応を行ってください。import ブロックは削除してもらって構いません。

パターン ② import ブロックに加えて resource ブロックも事前に定義してインポートする

こちらのパターンは事前に resource ブロックも定義するパターンになります。
import ブロックの「to」で定義したアドレスが tf ファイル上に既に存在する場合、terraform は tf ファイルの自動生成を行わず、既存の resource ブロックと実際のリソースが紐づけられます。

こちらのパターンでは gcloud export コマンドでコードを出力できるリソースに関しては出力しておくと楽だと思います。

gcloud コマンド出力結果

resource "google_storage_bucket" "testdesuyo55" {
  force_destroy               = false
  location                    = "US-EAST1"
  name                        = "testdesuyo55"
  project                     = "testdayo-xxxxx"
  public_access_prevention    = "enforced"
  storage_class               = "STANDARD"
  uniform_bucket_level_access = true
}
import {
  id = "testdayo-xxxxx/testdesuyo55"
  to = google_storage_bucket.testdesuyo55
}

// 出力したコードでresourceブロックを定義
+ resource "google_storage_bucket" "testdesuyo55" {
+  force_destroy               = false
+  location                    = "US-EAST1"
+  name                        = "testdesuyo55"
+  project                     = "testdayo-xxxxx"
+  public_access_prevention    = "enforced"
+  storage_class               = "STANDARD"
+  uniform_bucket_level_access = true
+ }

// もしコードがわからない場合は必須の項目のみ定義しておく
// 注)ローケーションやバケット名は一致させる必要あり
+ resource "google_storage_bucket" "testdesuyo55" {
+  location                    = "US-EAST1"
+  name                        = "testdesuyo55"
+  project                     = "testdayo-xxxxx"
+ }

to の命名については export コマンドで出力された時にデフォルトで指定されていますが、あくまでも tf ファイル内で識別するための命名なので上書きすることが可能です。基本的には問題ないですが、意図しない差分が発生していないかの確認はちゃんとするようにしましょう。

$ terraform plan を実行します。
既に resource ブロックを定義しているので別ファイルを指定する必要はありません。

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

$ terraform plan を実行すると、、

google_storage_bucket.testdesuyo55: Importing... [id=testdayo-xxxxx/testdesuyo55]
google_storage_bucket.testdesuyo55: Import complete [id=testdayo-xxxxx/testdesuyo55]

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

インポートが成功しました!
terraform.tfstate ファイルにもリソースが追加されていると思います。

モジュールへのインポート

インポートの流れがある程度把握できたところで、次にモジュールへのインポートについてみていきます。
Terraform ではモジュールへのインポートが可能です。

モジュールへインポートする際の注意点は 2 つです。

  1. module に resource ブロックを定義しておくこと
  2. import ブロックはモジュールではなくルートの tf ファイルに定義しておくこと

import ブロックはモジュール内で使うことができません。そのため全ての import ブロックをルートのファイルに定義していく必要があります。

今回は次のようなディレクトリ構成で確認してみます。

root
├── main.tf
├── module
│ └── cloudstorage.tf
├── terraform.tfstate
└── terraform.tfstate.backup
// main.tf
terraform {
  required_version = "1.9.3"
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "6.2.0"
    }
    google-beta = {
      source  = "hashicorp/google-beta"
      version = "6.2.0"
    }
  }
}

provider "google-beta" {
  user_project_override = true
  region                = "asia-northeast1"
}

provider "google-beta" {
  alias                 = "no_user_project_override"
  user_project_override = false
  region                = "asia-northeast1"
}

import {
  id = "testdayo-xxxxx/testdesuyo55"
  to = module.modules.google_storage_bucket.test // モジュールで定義されているリソースのアドレスを指定
}

module "modules" {
  source = "./module"
}

module では事前に resource ブロックを定義しておきます。

// cloudstorage.tf
resource "google_storage_bucket" "test" {
  force_destroy               = false
  location                    = "US-EAST1"
  name                        = "testdesuyo55"
  project                     = "testdayo-xxxxx"
  public_access_prevention    = "enforced"
  storage_class               = "STANDARD"
  uniform_bucket_level_access = true
}

仮にモジュールに import ブロックを定義した場合次のようなエラがーが出るはずです。

An import block was detected in "module.modules". Import blocks are only allowed in the root module

あとはこれまで通り $ terraform plan$ terraform apply を実行するとインポートが成功します。

複数リソースをインポートする

GoogleCloud の場合だと例えば複数のロールを持ったサービスアカウントをインポートする場合などがあると思います。

複数リソースのインポートは繰り返し処理である countfor_each を使った方法がありますが、今回は for_each を使ってインポートします。
これは複数リソースを扱いたい場合に共通するのですが、基本的には for_each を使うようにしています。(理由については次のセクションで説明します)

ここでは 3 つのロールを持ったサービスアカウントユーザーをインポートできるように確認してきます。
ロールを一つずつインポートする場合との差分を確認しながらコードを記述してみます。

+ locals {
+  roles = toset([
+    "roles/cloudfunctions.developer",
+    "roles/secretmanager.secretAccessor",
+    "roles/editor",
+  ])
+ }

import {
+ for_each = local.roles
- id       = "testdayo-xxxxx roles/editor user:${email}"
+ id       = "testdayo-xxxxx ${each.value} user:${email}"
- to       = google_project_iam_member.test
+ to       = google_project_iam_member.test[each.key]
}

resource "google_project_iam_member" "test" {
+ for_each = local.roles
  project  = "testdayo-xxxxx"

- role   = "roles/editor"
+ role   = each.value
  member = "user:${email}"
}

for_each は set 型か map 型しか扱えない ので toset を使って set 型の配列で変数を定義します。set 型は配列ではあるのですが、配列内で同じ値を許容しないという特徴があります。
また for_each は key-value で値を取得できますが、今回は配列でどちらも同じ値になるので、key と value のどちらで指定しても構いません。
また見ての通り繰り返し処理を使った方が効率が良いことがわかります。

コードを書いたら $ terraform plan$ terraform apply を実行しましょう。
次のような差分が出力されインポートが成功するはずです。

# google_project_iam_member.test["roles/cloudfunctions.developer"] will be imported
  resource "google_project_iam_member" "test" {
      etag    = "BwYkUgE8kH0="
      id      = "testdayo-xxxxx/roles/cloudfunctions.developer/user:xxxxxxxx"
      member  = "user:xxxxxxxx"
      project = "testdayo-xxxxx"
      role    = "roles/cloudfunctions.developer"
  }
...
...

インポートが成功してステートファイルにもリソースが追加されます。
これで google_project_iam_member.test["roles/cloudfunctions.developer"] のような形でリソースの各ロールにアクセスできるようになりました。

// terraform.tstate
[
  {
    "index_key": "roles/cloudfunctions.developer"
    // 省略
  },
  {
    "index_key": "roles/editor"
    // 省略
  },
  {
    "index_key": "roles/secretmanager.secretAccessor"
    // 省略
  }
]

学び ③ Terraform の繰り返し処理について

こちらのセクションでは、なぜ繰り返し処理で count を使わずに for_each を使うのかを説明していきます。

違いを理解するために count を使ってリソースを作成してみます。
例として複数ロールを持つサービスアカウントを count で作成します。

locals {
  roles = [
    "roles/cloudfunctions.developer",
    "roles/secretmanager.secretAccessor",
    "roles/editor",
  ]
}

resource "google_project_iam_member" "test" {
  count   = length(local.roles)
  project = "testdayo-xxxxx"
  role    = local.roles[count.index]
  member  = "user:xxxxxxxxx"
}

リソースを作成後、ステートファイルをみるとインデックス番号が振られたインスタンスが作成されていることがわかります。

差分結果

# google_project_iam_member.test[0] will be created
+ resource "google_project_iam_member" "test" {
    + etag    = (known after apply)
    + id      = (known after apply)
    + member  = "user:xxxxxxxxxx"
    + project = "testdayo-xxxxx"
    + role    = "roles/cloudfunctions.developer"
  }

# google_project_iam_member.test[1] will be created
+ resource "google_project_iam_member" "test" {
    + etag    = (known after apply)
    + id      = (known after apply)
    + member  = "user:xxxxxxxxxx"
    + project = "testdayo-xxxxx"
    + role    = "roles/secretmanager.secretAccessor"
  }
...

ステートファイル

  "instances": [
    {
      "index_key": 0,
      // 省略
    },
    {
      "index_key": 1,
      // 省略
    },
    {
      "index_key": 2,
      // 省略
    }
  ]

count はリソースを配列として作成するため、リソース作成後は google_project_iam_member.test[0] のようにインデックスを指定してアクセスできます。

ではこの状態で google_project_iam_member.test[1] のリソースを削除してみるとどうなるでしょうか。

locals {
  roles = [
    "roles/cloudfunctions.developer",
-   "roles/secretmanager.secretAccessor", // google_project_iam_member.test[1]
    "roles/editor",
  ]
}

$ terraform plan を実行してみると、、

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

ロールを一つだけ削除したいのに余計な差分が発生してしまいます。
これは配列の真ん中のインデックスを指定したために、配列の詰め直しする必要が発生したのが理由です。そのためリソースの再生成と削除が行われてしまい、他のロールにも影響が出てしまいます。

次に count を使った場合でロールを追加する場合を確認してみます。
あえて配列の末尾ではないところに追加してみます。

locals {
  roles= [
    "roles/cloudfunctions.developer",
    "roles/secretmanager.secretAccessor",
+   "roles/cloudfunctions.viewer",
    "roles/editor",
  ]
}

$ terraform plan を実行してみると、、

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

ロールを一つ追加したいだけなのでに余計な差分が発生してしまいます。
このように配列を末尾ではないところで追加した場合も配列のインデックスが変わるので配列の詰め直しが発生しこのような差分を発生させてしまいます。

ここまでで count を使うことの危険性について理解できたかと思います。
これらの問題は for_each を使うことで回避できます。
for_each はリソースをマップとして作成するため google_project_iam_member.test["roles/cloudfunctions.developer"] のような形でアクセスできます。
マップで作成されることでリソースの操作ではキーを指定することになるため、ロールを削除しようとしても他のキーに影響を与えることがありません。

locals {
  roles = toset([
    "roles/cloudfunctions.developer",
-   "roles/secretmanager.secretAccessor",
    "roles/editor",
  ])
}

resource "google_project_iam_member" "test" {
  for_each = local.roles
  project  = "testdayo-xxxxx"

  role     = each.value
  member   = "user:xxxxxxxx"
}

差分出力

# google_project_iam_member.test["roles/secretmanager.secretAccessor"] will be destroyed
# (because key ["roles/secretmanager.secretAccessor"] is not in for_each map)
- resource "google_project_iam_member" "test" {
    - etag    = "BwYkUgE8kH0=" -> null
    - id      = "testdayo-xxxxx/roles/secretmanager.secretAccessor/user:xxxxxxxxxxx" -> null
    - member  = "user:xxxxxxxxxxx" -> null
    - project = "testdayo-xxxxx" -> null
    - role    = "roles/secretmanager.secretAccessor" -> null
  }

Plan: 0 to add, 0 to change, 1 to destroy.

ここまでで繰り返し処理で for_each を使う理由が理解できたかと思います!

最後に for_each ではなく count を使った方が良いケースについて考えてみます。
これは条件分岐でリソースを作る or 作らないを判断するようなケースで使えます(要は count が 0 か 1 のどちらかになるケースだと count を使った方が良い)。
コードで確認してみます。

例えば「プロジェクトの環境が dev 環境の時だけリソースを作成したい」のようなことを実現するときに、条件に一致したらリソースを作成し、一致しなければリソースを作成しないといったことを count を使って実現できます。

resource "google_storage_bucket" "example" {
  count           = var.env = "development" ? 1 : 0 // environmentがdevelopmentの時のみリソース作成
  location        = "US-EAST1"
  name            = "testdesuyo55"
  project         = "testdayo-xxxxx"
}

学び ④ ステートファイルについて理解する

Terraform では「どんなインフラが構築されているか」の情報を Terraform ステートファイル に記録するようになっています。デフォルトだと Terraform を実行したディレクト配下に terraform.tfstate というファイルが作成されます。

例えば Terraform で GoogleCloud プロジェクトを作成したときに記録されるステートファイルは次のようになります。

リソースブロック

resource "google_project" "testdesu" {
  provider = google-beta.no_user_project_override

  auto_create_network = true
  name                = "testdayo-xxxxx"
  project_id          = "testdayo-xxxxx"
  folder_id           = null
  org_id              = null
  billing_account     = "xxxxxxxxxxxxxxxxxxxxx"
}

ステートファイル

{
  "version": 4,
  "terraform_version": "1.9.3",
  "serial": 2,
  "lineage": "d8740741-c6cc-e3de-3e15-b956dc1f2be3",
  "outputs": {},
  "resources": [
    {
      "mode": "managed",
      "type": "google_project",
      "name": "testdesu",
      "provider": "provider[\"registry.terraform.io/hashicorp/google-beta\"].no_user_project_override",
      "instances": [
        {
          "schema_version": 1,
          "attributes": {
            "auto_create_network": true,
            "billing_account": "xxxxxxxxxxxxx",
            "effective_labels": {
              "test": "test"
            },
            "folder_id": null,
            "id": "projects/testdayo-xxxxx",
            "labels": {},
            "name": "testdayo-xxxxx",
            "number": "xxxxxxxx",
            "org_id": null,
            "project_id": "testdayo-xxxxx",
            "skip_delete": null,
            "terraform_labels": {},
            "timeouts": null
          },
          "sensitive_attributes": [],
          "private": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx"
        }
      ]
    }
  ],
  "check_results": null
}

「実際のリソース」と「tf ファイルで記述したリソース」がマッピングしていることがわかるかと思います。
そして Terraform はこのステートファイルを通して「testdayo-xxxxx という ID の GoogleCloud プロジェクトが作成されている」ということを知ることができます。

Terraform を実行すると、プロバイダ(今回であれば google provider)が GoogleCloud などのクラウドインフラに API コールをして最新のリソース情報を取得し、まずはステートファイルとの差分を確認します。これは例えば GUI や CLI のような Terraform 以外の方法でリソースが更新されていた場合の差分を検知するためです(基本的にはステートファイルと実際のリソースで差分は発生しない想定)。

次に上記の差分を考慮して、ステートファイルと tf ファイルで記述したコードを比較して差分を確認します。この差分が $ terraform plan の出力結果になり $ terraform apply を実行するとステートファイルに追加されます。

このように $ terraform plan$ terraform apply で差分を検出できたり、新しく変更を更新するためにもこのファイルはとても重要なファイルだということがわかりました。

ステートファイルについては次の 3 つのポイントを整理したいと思います。

ステートファイルの管理方法について

ステートファイルは特に指定しない場合はローカルのディレクトリに作成されます。実際の運用を考えたときにどこで管理するのが良いでしょうか??
そのままローカルで管理??それとも Git などのバージョン管理システムがいいのでしょうか??

結論 Terraform の リモートバックエンド の機能を使うのが良さそうです。
バックエンドとはステート情報をどのように読み込んだり保存したりするかを決めるものになります。
デフォルトでローカルに保存するバックエンドは ローカルバックエンド と呼ばれています。

ローカルバックエンドを採用しない方が良い理由

例えば個人のプロジェクトで Terraform を使用するならローカルに保存する方法でも良いと思います。
しかしチーム開発のような複数人で Terraform を使用する場合だと次のような問題が発生します。

  • 共有ができない
    • 各個人がローカルでステートファイルを使っているとそれぞれで差分が発生してしまう
  • ロック機能を持っていない
    • ロックがかかってないと A さんと B さんが同時にあるリソースに対して Terraform を実行すると同時にステートファイルを更新しようとするため競合が発生してしまう
  • ローカル PC の故障による紛失
    • ローカル PC でデータが消えたり、故障したり、誤操作したりした場合にファイルを誤って削除してしまう可能性

ファイルの共有を Git でしない方が良い理由

ステートファイルを共有するストレージとしては Git に登録することも一つの選択肢ではありますが次のような問題が発生します。
Git 以外にもバージョン管理システムもありますが、どれも同じような問題があるようです。(詳しく調査していない)

  • pull するのを忘れて古いステートファイルの状態で Terraform を実行する可能性がある
    • Terraform を実行したいとなった場合に毎回最新のステートファイルを取得する必要があるが忘れてしまう可能性がある
  • Terraform 実行後にステートファイルを push しないといけないが忘れてしまう可能性がある
    • ステートファイルは常に最新にする必要があるが Terraform 実行後に push し忘れる可能性がある
  • ロック機能を持っていない
  • 機密情報の取り扱い
    • ステートファイルは全てのデータがプレーンテキストで保存されている。ステートファイル内には機密情報も含まれているため(請求アカウント ID などなど)これらの情報をプレーンテキストのまま push するべきではない

リモートバックエンドを使った方が良い理由

リモートバックエンドを使うと、ローカルバックエンドやバージョン管理システムでの問題を回避できます。
google provider の場合は Cloud Storage、AWS では S3 などが一般的に使われています。
今回は移行に伴い Cloud Storage をリモートバックエンドとして使うようにしました。

  • plan や apply で毎回最新のステートファイルをロードしてくれる
    • バックエンド(Cloud Storage)から最新のステートファイルを自動でロードしてくれる
    • apply 実行後は自動でバックエンドに保存してくれる
  • ロック機能がある
    • 競合が発生するとロックが解除されるまで待機してくれる
  • シークレットの暗号化
    • バックエンドへの送受信とステートファイル自体の暗号化してくれる
  • アクセス権の設定ができる
    • バックエンドへのアクセス権を設定することでステートファイルへのアクセスを制御することができる

学び ⑤ 初めて触って感じたこと

最後に今回初めて触ってみた感じたことを少しだけ書いていきたいと思います。

Terraform で全てが解決するわけではない

最初は Terraform などの IaC ツールを入れたらインフラの全てを管理できるようになり運用が楽になる、全てを解決してくれるような魔法のツールだと思っていました。
しかし実際に進めていくと、シークレットの管理が難しかったり、実はガチガチに管理しない方が良いようなリソースがあったりすることに気づきました。

例えばシークレットの管理は運用上最初は手動でやった方が良かったり、cloud run functions を管理してしまうと毎回 Terraform 経由でデプロイすることになり開発体験が悪くなるので管理しない方が良かったり、などが挙げられます。

plan が成功したから apply も成功する保証はない

これも実際に実行してみてわかったことです。
一番多かったのは apply すると権限エラーが発生するパターンでした。
今回はローカルから Terraform を実行するパターンなので、当然ですがローカルから Terraform 経由で GoogleCloud のリソースを操作する場合は Terraform を実行するユーザーに対して適切な権限を付与する必要がありました。

ロールバックのような機能がない

例えば apply でエラーが失敗したら場合、失敗したところまでのリソースが作成されます。
以降は修正して再度実行すると途中から再開することになる、ということがわかりました。

最後に

かなりボリュームのある記事になってしまいました。。
まだまだたくさんの学びがあり書き足りないので part3 くらいまで書いていきたいなーという気持ちです!
調べれば調べるほど色々知らなかった情報や実は理解できていなかった情報もたくさん出てくるので少しずつまとめていきたいと思います。
後はまだ CI/CD の構築ができていないので、機会があればこの辺りも挑戦していきたいなと思います!

参考記事

詳解 Terraform 第 3 版 ―Infrastructure as Code を実現する
Google Cloud のリソースを Terraform 化するための 7 ステップ
Terraform の import コマンドと import ブロックを試してみた
Terraform の count と for_each の使い分けと Splat Expressions について
Google Cloud リソースを Terraform 形式にエクスポートする
Terraform AWS S3 リモートバックエンドを用いて tfstate を管理してみよう

GitHubで編集を提案

Discussion