OpenTofu による複数環境運用: OpenTofu はバックエンド設定に変数が使える

2025/02/23に公開

この記事の概要

  • Terraform の fork である OpenTofu ではバックエンド設定に変数を使うことができます。
  • Terraform で dev, stg, prd といった複数の環境を運用するにはいくつかの方法が考えられますが、どの方法もワークアラウンドに過ぎず、問題が残ります。この詳細は Terraform の複数環境運用の比較 をごらんください。
  • OpenTofu では バックエンド設定に変数が使える ため、簡単に、かつ Terraform にあった問題を解決する形で複数環境の運用を行うことができます。
  • さらに、 OpenTofu の変数によるバックエンド切り替えは、 initplan / apply で環境の指定が一致していない場合はエラーとして扱ってくれるので、環境指定の誤りによる環境破壊の問題が起きず、 Terraform のパラメーターファイルによる切り替えよりも安全な運用が行えます。

OpenTofu について

OpenTofu は MPL2.0 ライセンスで開発される、 Terraform のフォークです。

本記事では OpenTofu の歴史的な経緯や、Terraform との全般的な相違や導入方法については説明をしません。OpenTofu について興味をお持ちの方は Zenn の opentofu トピックの記事 を見ていただくとよいでしょう。

本記事は、 Terraform での複数環境運用で起きる問題を背景に OpenTofu での複数環境運用について説明します。
その都合で、 Terraform と OpenTofu の両方について言及しますが、共通の機能・動作については、Terraform の機能として説明を行いますのでご了承ください。要するに、Terraform と OpenTofu 共通の話題については「Terraform で」といったような書き方をします。

バックエンド設定での変数の利用

Terraform で構築したインフラの情報を保持するファイルを ステートファイル と言い、これを保存する場所を バックエンド と呼びます。
AWS の S3 バケットや GCP の GCS バケットなどをこのバックエンドとして使うことで、複数のメンバー間でステートファイルを共有することができ、チーム内でインフラを共有して管理することができます。
プロジェクトで dev, stg, prd といったように複数の環境を持ち、それぞれに独立のインフラを構築する場合、このバックエンドを環境ごとに切り替えて運用することが多いでしょう。

ところが Terraform が抱える不便のひとつとして、このバックエンドの設定に変数を使用することができません。

dev, stg, prd で構築するインフラはしばしば差異があります。例えば prd で構築する DBMS の性能は、dev で構築する DBMS よりも高く設定するなどの差異があるのが一般的です。
Terraform では変数を使用することで、インフラ設定のパラメーターを環境ごとに別にする運用をすることができます。
ところがこの変数をバックエンド設定には使用することができないため、「インフラのパラメーター設定」と「バックエンドの設定」を共通にできません。

「バックエンド設定で変数を使いたい」という要望は長らく Terraform の Issue として登録されていますが、現在のところサポートされていません: Using variables in terraform backend config block #13022

このため Terraform で複数環境を運用する場合には、ルートモジュールを切り替えたり、バックエンド設定のパラメーターファイルと変数ファイルを併用するといったワークアラウンドに近い方法を取る必要があります。以前、それらの方法の紹介と比較を Terraform の複数環境運用の比較 にまとめました。

それに対して OpenTofu では 2024 年 7 月にリリースされたバージョン 1.8.0から、バックエンド設定で変数が利用可能になりました:

OpenTofu での複数環境運用

OpenTofu での複数環境運用の実装方法

以下のようなディレクトリー構成にします (カッコ内はリポジトリーにはコミットされないファイル/ディレクトリー):

|
+- env/
|   +- _common.tfvars
|   +- _dev.tfvars
|   +- _prd.tfvars
|   +- _stg.tfvars
|
+- (.terraform/)
+- .terraform.lock.hcl
+- main.tf
+- outputs.tf
+- variables.tf
+- Makefile

/env/ENV.tfvars の内容は、 /variables.tfvars の変数定義と対応づきます。
また、その変数を /main.tf のバックエンド設定でも利用します:

  • /variables.tfvars:

    variable "basename" {
      type        = string
      description = "構築するリソースの共通prefix"
    }
    
    variable "env" {
      type        = string
      description = "構築する環境名"
    }
    
    variable "account_id" {
      type        = number
      description = "使用するAWSアカウントID"
    }
    
  • /env/ENV.tfvars:

    env        = "dev"
    account_id = ACCOUNTID
    
  • /main.tf:

    terraform {
      ...
      backend "s3" {
        bucket         = "${var.account_id}-tfstate-${var.env}"
        key            = "opentofu-env-demo.tfstate"
        dynamodb_table = "tfstate-lock"
      }
    }
    

ちなみに basename については、環境によらず共通の値を使用するため env/_common.tfvars というファイルに別にしています:

basename = "opentofu-env-demo"

この env/_common.tfvars の分離は複数環境運用には本質的には関係ないので、 env/ENV.tfvars に含めてしまってもよいです。

OpenTofu での複数環境運用の運用方法

OpenTofu の実行の際には、各種コマンドの引数で -var-file を指定して変数ファイルを引き渡します。
今回の実装では共通のパラメーターを指定する env/_common.tfvars と環境ごとのパラメーターを指定する env/ENV.tfvars の2つを使用するため、 -var-file を2回指定します。

opentofu init -var-file="env/_common.tfvars" -var-file="env/dev.tfvars"
opentofu plan -var-file="env/_common.tfvars" -var-file="env/dev.tfvars"
opentofu apply -var-file="env/_common.tfvars" -var-file="env/dev.tfvars"

Makefile などのタスクランナーを使用して、以下のようにコマンドの呼び出しを簡略化するのが良いでしょう:

make init ENV=dev
make plan ENV=dev
make apply ENV=dev

変数によるバックエンド切り替えのメリット・デメリット

Terraform での複数環境運用で使用する以下の手法と、 OpenTofu で実現できる変数によるバックエンド切り替えについて比較を行います:

  • ルートモジュールの切り替え
  • パラメーターファイルの切り替え

上記の各手法の詳細は Terraform の複数環境運用の比較 をごらんください。

変数によるバックエンド切り替えのメリット

各手法における、以下の問題が解消されます:

  • ルートモジュールの切り替え
    • リソース名が module.main.aws_dynamodb_table.table のように先頭に module.main がつく問題。
      • リソース名はシンプルに aws_dynamodb_table.table という形式になります。
    • output が構造化されてしまう問題
    • .terraform.lock.hcl が環境ごとに作成される問題。
  • パラメーターファイルの切り替え
    • バックエンド設定とパラメーター設定が食い違った状態で apply できてしまう問題。
      • 詳細は後述。

変数によるバックエンド切り替えのデメリット

以下の問題があります:

  • 別環境の opentofu plan や opentofu apply を実行する前には必ず opentofu init を実行する必要があります。
    • ただし、実行を忘れた場合にはエラーになるので、ミスオペレーションによる環境破壊などの問題につながることはありません。詳細は後述。
  • コマンドの実行で -var-file の指定がいちいち必要。
    • 問題の軽減策として、変数にデフォルト値を設定しないようにすることで指定を忘れた場合にエラーにすることができます。これによって指定忘れによる環境破壊などの問題を回避することができます。
      • env 変数など、1つだけ指定必須のものを入れておくだけで達成できるので対処としては簡単です。
    • また、別観点の問題の軽減策として、make などのタスクランナーと併用することによって負担を減らすことができます。
      • 間違いなく共通で使用できるタスクランナーが make くらいで、単純なタスクランナーとしては make は使いづらい、といった別の問題はある。

環境切り替え忘れによる挙動の比較

「OpenTofu の変数によるバックエンド切り替え」「Terraform のパラメーターファイルによるバックエンド切り替え」の2つの方法の特徴として、「環境切り替えのたびに opentofu init / terraform init を実行する必要がある」という点があります。

この運用について以下のような事故が起きることがあります:

  1. dev 環境への適用のために、 make init ENV=dev を実行する。
  2. dev 環境への適用を実行する: make apply ENV=dev
  3. stg 環境の適用のために、 make init ENV=stg を実行するべきなのだが、 うっかりこの操作を忘れてしまう。
  4. stg 環境への適用を実行する: make apply ENV=stg

このときの動作について、実行を忘れたときの動作が OpenTofu のほうが安全なのでその点についても紹介します。
Terraform については、ミスオペレーションによってインフラを破壊する事故が起きてしまうリスクがあります。

OpenTofu での環境切り替え忘れ時の挙動

「4. stg 環境への適用を実行する: make apply ENV=stg」 のときに以下のように init との相違を検知してエラーになります。この動作により、「stg 環境のパラメーターで dev のインフラを更新してしまう」といった問題は起きません:

$ make apply ENV=stg
docker compose run --rm opentofu apply -var-file="env/_common.tfvars" -var-file="env/stg.tfvars"
╷
│ Error: Backend initialization required: please run "tofu init"
│
│ Reason: Backend configuration block has changed
│
│ The "backend" is the interface that OpenTofu uses to store state,
│ perform operations, etc. If this message is showing up, it means that the
│ OpenTofu configuration you're using is using a custom configuration for
│ the OpenTofu backend.
│
│ Changes to backend configurations require reinitialization. This allows
│ OpenTofu to set up the new configuration, copy existing state, etc. Please run
│ "tofu init" with either the "-reconfigure" or "-migrate-state" flags to
│ use the current configuration.
│
│ If the change reason above is incorrect, please verify your configuration
│ hasn't changed and try again. At this point, no changes to your existing
│ configuration or state have been made.
╵
make: *** [apply] エラー 1
aws-vault: error: exec: Failed to wait for command termination: exit status 2
$

Terraform での環境切り替え忘れ時の挙動

「4. stg 環境への適用を実行する: make apply ENV=stg」 のときに以下のように「stg の設定で dev のインフラを上書きする」動作になります:

$ make apply ENV=stg
docker compose run --rm terraform apply -var-file="env/stg/terraform.tfvars"
aws_dynamodb_table.table: Refreshing state... [id=terraform-parameterfile-dev]

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:

  # aws_dynamodb_table.table must be replaced
-/+ resource "aws_dynamodb_table" "table" {
      ~ arn                         = "arn:aws:dynamodb:ap-northeast-1:xxxxxxxxxxxx:table/terraform-parameterfile-dev" -> (known after apply)
      - deletion_protection_enabled = false -> null
      ~ id                          = "terraform-parameterfile-dev" -> (known after apply)
      ~ name                        = "terraform-parameterfile-dev" -> "terraform-parameterfile-stg" # forces replacement
      ~ read_capacity               = 0 -> (known after apply)
      + stream_arn                  = (known after apply)
      - stream_enabled              = false -> null
      + stream_label                = (known after apply)
      + stream_view_type            = (known after apply)
      - table_class                 = "STANDARD" -> null
      - tags                        = {} -> null
      ~ tags_all                    = {} -> (known after apply)
      ~ write_capacity              = 0 -> (known after apply)
        # (2 unchanged attributes hidden)

      ~ point_in_time_recovery {
          + arn                         = (known after apply)
          + billing_mode                = (known after apply)
          + deletion_protection_enabled = (known after apply)
          + hash_key                    = (known after apply)
          + id                          = (known after apply)
          + name                        = (known after apply)
          + range_key                   = (known after apply)
          + read_capacity               = (known after apply)
          + restore_date_time           = (known after apply)
          + restore_source_name         = (known after apply)
          + restore_to_latest_time      = (known after apply)
          + stream_arn                  = (known after apply)
          + stream_enabled              = (known after apply)
          + stream_label                = (known after apply)
          + stream_view_type            = (known after apply)
          + table_class                 = (known after apply)
          + tags                        = (known after apply)
          + tags_all                    = (known after apply)
          + write_capacity              = (known after apply)
        } -> (known after apply)

      ~ server_side_encryption {
          + arn                         = (known after apply)
          + billing_mode                = (known after apply)
          + deletion_protection_enabled = (known after apply)
          + hash_key                    = (known after apply)
          + id                          = (known after apply)
          + name                        = (known after apply)
          + range_key                   = (known after apply)
          + read_capacity               = (known after apply)
          + restore_date_time           = (known after apply)
          + restore_source_name         = (known after apply)
          + restore_to_latest_time      = (known after apply)
          + stream_arn                  = (known after apply)
          + stream_enabled              = (known after apply)
          + stream_label                = (known after apply)
          + stream_view_type            = (known after apply)
          + table_class                 = (known after apply)
          + tags                        = (known after apply)
          + tags_all                    = (known after apply)
          + write_capacity              = (known after apply)
        } -> (known after apply)

      ~ ttl {
          + arn                         = (known after apply)
          + billing_mode                = (known after apply)
          + deletion_protection_enabled = (known after apply)
          + hash_key                    = (known after apply)
          + id                          = (known after apply)
          + name                        = (known after apply)
          + range_key                   = (known after apply)
          + read_capacity               = (known after apply)
          + restore_date_time           = (known after apply)
          + restore_source_name         = (known after apply)
          + restore_to_latest_time      = (known after apply)
          + stream_arn                  = (known after apply)
          + stream_enabled              = (known after apply)
          + stream_label                = (known after apply)
          + stream_view_type            = (known after apply)
          + table_class                 = (known after apply)
          + tags                        = (known after apply)
          + tags_all                    = (known after apply)
          + write_capacity              = (known after apply)
        } -> (known after apply)

        # (1 unchanged block hidden)
    }

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

Changes to Outputs:
  ~ dynamodb_table_arn = "arn:aws:dynamodb:ap-northeast-1:xxxxxxxxxxxx:table/terraform-parameterfile-dev" -> (known after apply)

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value:

うっかり yes で答えたら dev の環境が破壊されてしまいます。
dev を破壊しても大したことはないですが、dev の設定で prd を破壊してしまう、といったことを起こすとちょっとした事件です。
AWS の場合、環境によって使用する認証情報が異なることが多いので権限不足でエラーになってくれるケースが多いですが、 GCP の場合は認証に Google アカウントを使用していると全環境で認証情報が共通なので、エラーが起きることもなく、適用できてしまうことがあります。

Discussion