🔰

Terraform で GCE インスタンスを作成する際に initialize_params を避けるべき理由

2024/07/29に公開

こんにちは、クラウドエース SRE 部の阿部です。
この記事では、Terraform で Google Compute Engine インスタンス(以降、GCE インスタンス)を作成する際のベストプラクティスについて紹介します。特に、initialize_params の使用を避ける理由とその代替方法について説明します。

先日、弊社の海外メンバーと Terraform の書き方について議論になった際に、 GCE インスタンスのパラメータを変更すると強制再作成となってしまい、メンテナンスのときに困っていると相談を受けて、この記事を書くことを思いつきました。

結論

GCE インスタンスのブートディスクを定義する際には、以下の点に注意してください。

  • google_compute_instance リソースの boot_disk ブロックで initialize_params を使ってブートディスクを定義してはいけない
  • 代わりにブートディスクに相当する google_compute_disk リソースを定義し、 google_compute_instance リソースの boot_disk.source で参照する
  • boot_disk.auto_deletefalse に設定する

検証環境

当記事で使用するサンプルコードは以下のバージョンで動作確認しました。

  • OS: Windows 11 (Ubuntu 24.04 LTS on WSL)
  • Terraform CLI: v1.9.3
  • Google Cloud Provider: v5.38.0

なお、当記事の内容は特定バージョンに依存する動作ではないため、参考程度としてください。

よくあるサンプルコード

以下は Terraform Registry の Google Cloud Provider ドキュメントに基づいた google_compute_instance のサンプルコードです。
このコードは、initialize_params を使用してブートディスクを定義しています。

サンプルコード
resource "google_compute_instance" "default" {
  name         = "my-instance"
  machine_type = "n2-standard-2"
  zone         = "us-central1-a"

  boot_disk {
    initialize_params {
      image = "debian-cloud/debian-11"
      size  = 10
    }
  }

  network_interface {
    network = "default"

    access_config {
      // Ephemeral public IP
    }
  }
}

このコードを terraform apply すると、 my-instance という名前の GCE インスタンスが us-central1-a ゾーンに作成されます。

問題点

initialize_params を使用してブートディスクを定義すると、ディスクサイズの変更などの操作が forces replacement となり、インスタンスの再作成が必要になります。これにより、インスタンスのデータが消失するリスクがあります。

例えば、インスタンスのブートディスクの使用率が高くなったため、ディスクサイズを 20 GB に拡張しようと考えたときにどうするのがよいでしょうか?
率直に考えると boot_disk.initialize_params.size の数値を 10 から 20 に変更するのではないかと思います。
公式ドキュメントの「ファイル システムとパーティションのサイズを変更する」に記載されている通り、GCE インスタンスはオンラインでディスクサイズを増加可能です。
しかし、サンプルコードの size を変更して terraform apply を実行すると、以下のように forces replacement になってしまいます。

initialize_paramsの差分メッセージ抜粋
~ initialize_params {
    - enable_confidential_compute = false -> null
    ~ image                       = "https://www.googleapis.com/compute/v1/projects/debian-cloud/global/images/debian-11-bullseye-v20240709" -> "debian-cloud/debian-11"
    ~ labels                      = {} -> (known after apply)
    ~ provisioned_iops            = 0 -> (known after apply)
    ~ provisioned_throughput      = 0 -> (known after apply)
    - resource_manager_tags       = {} -> null
    ~ size                        = 10 -> 20 # forces replacement
    ~ type                        = "pd-standard" -> (known after apply)
  }
差分メッセージ全体
差分メッセージ全体
google_compute_instance.default: Refreshing state... [id=projects/example-project/zones/us-central1-a/instances/my-instance]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
-/+ destroy and then create replacement

Terraform will perform the following actions:

  # google_compute_instance.default must be replaced
-/+ resource "google_compute_instance" "default" {
      ~ cpu_platform         = "Intel Cascade Lake" -> (known after apply)
      ~ current_status       = "RUNNING" -> (known after apply)
      ~ effective_labels     = {} -> (known after apply)
      - enable_display       = false -> null
      ~ guest_accelerator    = [] -> (known after apply)
      ~ id                   = "projects/example-project/zones/us-central1-a/instances/my-instance" -> (known after apply)
      ~ instance_id          = "1186162411377179530" -> (known after apply)
      ~ label_fingerprint    = "42WmSpB8rSM=" -> (known after apply)
      - labels               = {} -> null
      - metadata             = {} -> null
      ~ metadata_fingerprint = "dtIz4d76InM=" -> (known after apply)
      + min_cpu_platform     = (known after apply)
        name                 = "my-instance"
      - resource_policies    = [] -> null
      ~ self_link            = "https://www.googleapis.com/compute/v1/projects/example-project/zones/us-central1-a/instances/my-instance" -> (known after apply)
      - tags                 = [] -> null
      ~ tags_fingerprint     = "42WmSpB8rSM=" -> (known after apply)
      ~ terraform_labels     = {} -> (known after apply)
        # (7 unchanged attributes hidden)

      ~ boot_disk {
          ~ device_name                = "persistent-disk-0" -> (known after apply)
          + disk_encryption_key_sha256 = (known after apply)
          + kms_key_self_link          = (known after apply)
          ~ source                     = "https://www.googleapis.com/compute/v1/projects/example-project/zones/us-central1-a/disks/my-instance" -> (known after apply)
            # (3 unchanged attributes hidden)

          ~ initialize_params {
              - enable_confidential_compute = false -> null
              ~ image                       = "https://www.googleapis.com/compute/v1/projects/debian-cloud/global/images/debian-11-bullseye-v20240709" -> "debian-cloud/debian-11"
              ~ labels                      = {} -> (known after apply)
              ~ provisioned_iops            = 0 -> (known after apply)
              ~ provisioned_throughput      = 0 -> (known after apply)
              - resource_manager_tags       = {} -> null
              ~ size                        = 10 -> 20 # forces replacement
              ~ type                        = "pd-standard" -> (known after apply)
            }
        }

      ~ confidential_instance_config {
          + allow_stopping_for_update = (known after apply)
          + can_ip_forward            = (known after apply)
          + cpu_platform              = (known after apply)
          + current_status            = (known after apply)
          + deletion_protection       = (known after apply)
          + description               = (known after apply)
          + desired_status            = (known after apply)
          + effective_labels          = (known after apply)
          + enable_display            = (known after apply)
          + guest_accelerator         = (known after apply)
          + hostname                  = (known after apply)
          + id                        = (known after apply)
          + instance_id               = (known after apply)
          + label_fingerprint         = (known after apply)
          + labels                    = (known after apply)
          + machine_type              = (known after apply)
          + metadata                  = (known after apply)
          + metadata_fingerprint      = (known after apply)
          + metadata_startup_script   = (known after apply)
          + min_cpu_platform          = (known after apply)
          + name                      = (known after apply)
          + project                   = (known after apply)
          + resource_policies         = (known after apply)
          + self_link                 = (known after apply)
          + tags                      = (known after apply)
          + tags_fingerprint          = (known after apply)
          + terraform_labels          = (known after apply)
          + zone                      = (known after apply)
        } -> (known after apply)

      ~ network_interface {
          ~ internal_ipv6_prefix_length = 0 -> (known after apply)
          + ipv6_access_type            = (known after apply)
          + ipv6_address                = (known after apply)
          ~ name                        = "nic0" -> (known after apply)
          ~ network                     = "https://www.googleapis.com/compute/v1/projects/example-project/global/networks/default" -> "default"
          ~ network_ip                  = "10.128.0.54" -> (known after apply)
          - queue_count                 = 0 -> null
          ~ stack_type                  = "IPV4_ONLY" -> (known after apply)
          ~ subnetwork                  = "https://www.googleapis.com/compute/v1/projects/example-project/regions/us-central1/subnetworks/default" -> (known after apply)
          ~ subnetwork_project          = "example-project" -> (known after apply)
            # (1 unchanged attribute hidden)

          ~ access_config {
              ~ nat_ip                 = "xxx.xxx.xxx.xxx" -> (known after apply)
              ~ network_tier           = "PREMIUM" -> (known after apply)
                # (1 unchanged attribute hidden)
            }
        }

      ~ reservation_affinity {
          + allow_stopping_for_update = (known after apply)
          + can_ip_forward            = (known after apply)
          + cpu_platform              = (known after apply)
          + current_status            = (known after apply)
          + deletion_protection       = (known after apply)
          + description               = (known after apply)
          + desired_status            = (known after apply)
          + effective_labels          = (known after apply)
          + enable_display            = (known after apply)
          + guest_accelerator         = (known after apply)
          + hostname                  = (known after apply)
          + id                        = (known after apply)
          + instance_id               = (known after apply)
          + label_fingerprint         = (known after apply)
          + labels                    = (known after apply)
          + machine_type              = (known after apply)
          + metadata                  = (known after apply)
          + metadata_fingerprint      = (known after apply)
          + metadata_startup_script   = (known after apply)
          + min_cpu_platform          = (known after apply)
          + name                      = (known after apply)
          + project                   = (known after apply)
          + resource_policies         = (known after apply)
          + self_link                 = (known after apply)
          + tags                      = (known after apply)
          + tags_fingerprint          = (known after apply)
          + terraform_labels          = (known after apply)
          + zone                      = (known after apply)
        } -> (known after apply)

      ~ scheduling {
          + allow_stopping_for_update = (known after apply)
          + can_ip_forward            = (known after apply)
          + cpu_platform              = (known after apply)
          + current_status            = (known after apply)
          + deletion_protection       = (known after apply)
          + description               = (known after apply)
          + desired_status            = (known after apply)
          + effective_labels          = (known after apply)
          + enable_display            = (known after apply)
          + guest_accelerator         = (known after apply)
          + hostname                  = (known after apply)
          + id                        = (known after apply)
          + instance_id               = (known after apply)
          + label_fingerprint         = (known after apply)
          + labels                    = (known after apply)
          + machine_type              = (known after apply)
          + metadata                  = (known after apply)
          + metadata_fingerprint      = (known after apply)
          + metadata_startup_script   = (known after apply)
          + min_cpu_platform          = (known after apply)
          + name                      = (known after apply)
          + project                   = (known after apply)
          + resource_policies         = (known after apply)
          + self_link                 = (known after apply)
          + tags                      = (known after apply)
          + tags_fingerprint          = (known after apply)
          + terraform_labels          = (known after apply)
          + zone                      = (known after apply)
        } -> (known after apply)

      - shielded_instance_config {
          - enable_integrity_monitoring = true -> null
          - enable_secure_boot          = false -> null
          - enable_vtpm                 = true -> null
        }
    }

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

何故このような動作になるのか

何故 initialize_params のパラメータを変更すると forces replacement と判断されるかというと、 Google Cloud Provider の実装としてパラメータが変更が発生したときにそうなるよう設定されているためです。
(下記ソースコードの ForceNew: true が該当ロジックです。)

https://github.com/hashicorp/terraform-provider-google/blob/v5.38.0/google/services/compute/resource_compute_instance.go#L211-L218

ただ、先にも述べたとおり、 GCE インスタンスのディスクサイズはオンライン変更可能なため、Google Cloud Provider で forces replacement とするのは少しおかしいのではないかと思うかもしれません。

実はインスタンスが更新できるプロパティでは、ブートディスクは更新対象外になっています。
実際に、Compute Engine API を直接実行してブートディスクの initializeParams.diskSizeGb を更新すると以下のようなエラーになってしまいます。

エラーメッセージ

  "error": {
    "code": 400,
    "message": "Invalid value for field 'resource.disks[0]': '{  \"initializeParams\": {    \"diskSizeGb\": \"20\"  }}'. Boot disk must be the first disk attached to the instance.",
    "errors": [
      {
        "message": "Invalid value for field 'resource.disks[0]': '{  \"initializeParams\": {    \"diskSizeGb\": \"20\"  }}'. Boot disk must be the first disk attached to the instance.",
        "domain": "global",
        "reason": "invalid"
      }
    ]
  }
}

そのため、Google Cloud Provider では initialize_params の変更は forces replacement になるように実装されているのだと思います。

改善したサンプルコード

では、どのように実装すればブートディスクサイズを Terraform 上で変更できるようになるかというと、 google_compute_instance とは別にブートディスクとして google_compute_disk リソースを定義します。
google_compute_instance では、 boot_disk.sourcegoogle_compute_disk を参照するように設定します。
例えば、前述のサンプルを以下のように修正します。

改善サンプルコード
resource "google_compute_instance" "default" {
  name         = "my-instance"
  machine_type = "n2-standard-2"
  zone         = "us-central1-a"

  boot_disk {
    auto_delete = false
    source      = google_compute_disk.default.self_link
  }

  network_interface {
    network = "default"

    access_config {
      // Ephemeral public IP
    }
  }
}

resource "google_compute_disk" "default" {
  name  = "my-instance"
  zone  = "us-central1-a"
  image = "debian-cloud/debian-11"
  size  = 10
}
変更箇所
--- instances_before.tf	2024-07-29 14:16:20.266982950 +0900
+++ instances.tf	2024-07-29 14:09:25.606229671 +0900
@@ -4,10 +4,8 @@
   zone         = "us-central1-a"

   boot_disk {
-    initialize_params {
-      image = "debian-cloud/debian-11"
-      size  = 20
-    }
+    auto_delete = false
+    source      = google_compute_disk.default.self_link
   }

   network_interface {
@@ -18,3 +16,15 @@
     }
   }
 }
+
+resource "google_compute_disk" "default" {
+  name  = "my-instance"
+  zone  = "us-central1-a"
+  image = "debian-cloud/debian-11"
+  size  = 20
+}
+
+import {
+  id = "ca-abe-test/us-central1-a/my-instance"
+  to = google_compute_disk.default
+}

このように設定しておけば、 initialize_params の動作仕様に悩まされることなくブートディスクのサイズ変更が可能です。
試しに google_compute_disksize を変更すると以下のようになります。想定通り、インプレース更新になっておりディスクのデータは保持されます。

差分メッセージ
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  ~ update in-place

Terraform will perform the following actions:

  # google_compute_disk.default will be updated in-place
  ~ resource "google_compute_disk" "default" {
        id                          = "projects/example-project/zones/us-central1-a/disks/my-instance"
        name                        = "my-instance"
      ~ size                        = 10 -> 20
        # (26 unchanged attributes hidden)

        # (4 unchanged blocks hidden)
    }

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

注意事項

必ず google_compute_instanceboot_disk.auto_deletefalse を設定しましょう。

boot_disk.auto_delete はデフォルト true になっていますが、これを設定し忘れると google_compute_instance が Destroy されるタイミングで依存関係になっているブートディスクも削除されてしまうため、 google_compute_disk が意図しないタイミングで削除されてしまいます。
boot_disk.sourceboot_disk.auto_delete はセットで設定しましょう。

既に initialize_params で GCE インスタンスを構築している場合

既に initialize_params を使って構築している場合はどうするかというと、ブートディスクをインポートして google_compute_disk を設定すればよいです。
ただし、現状の Google Cloud Provider では、 auto_delete を変更しようとすると forces replacement が発生してしまうため、事前に以下のコマンドで auto_deletefalse にする必要があります。

Auto Delete解除コマンド
gcloud compute instances set-disk-auto-delete インスタンス名 --no-auto-delete --disk ブートディスク名 --zone ゾーン名

今回のサンプルコードであれば以下のコマンドを実行します。

サンプルコードにおけるAuto Delete解除コマンド
gcloud compute instances set-disk-auto-delete my-instance --no-auto-delete --disk my-instance --zone us-central1-a

上記コマンドを実行後に、 import ブロックを使ってブートディスクリソースをインポートしながらコード修正すれば OK です。

インポートありサンプルコード
resource "google_compute_instance" "default" {
  name         = "my-instance"
  machine_type = "n2-standard-2"
  zone         = "us-central1-a"

  boot_disk {
    auto_delete = false
    source      = google_compute_disk.default.self_link
  }

  network_interface {
    network = "default"

    access_config {
      // Ephemeral public IP
    }
  }
}

resource "google_compute_disk" "default" {
  name  = "my-instance"
  zone  = "us-central1-a"
  image = "debian-cloud/debian-11"
  size  = 10
}

import {
  id = "example-project/us-central1-a/my-instance"
  to = google_compute_disk.default
}

まとめ

あらかじめ initialze_params の動作を理解していればコーディング規約や Linter で防ぐことが可能です。
しかし、世間で公開されている GCE インスタンスのサンプルコードの中には、initialize_params を使用しているケースが散見されます。
そのため、初めて Terraform で GCE インスタンスを構築する方は、この落とし穴にはまりやすいと感じました。

この記事が、Terraform で GCE インスタンスを構築する方のお役に立ちましたら幸いです。

Discussion