Terraform管理されたステージング環境・本番環境の差異を検出したくて頑張っている話

SREチームの橋本です。今回はステージング環境の運用でありがちな本番との差分に対処する試みを紹介します。

背景

ステージング環境について、例えばIT用語辞典では

ステージング環境とは、情報システムやソフトウェアの開発の最終段階で検証用に用意される、実際の運用環境と変わらない環境のこと。

と説明しています。検証用ですから、インフラ面で言っても本番環境となるべく一致した構成であってほしいということになります。

しかし実際にはさまざまな経緯(ステージング環境を後から立てたり!)から、たとえTerraform管理していたとしても差異が発生してしまうことがあります。

こうしたとき、その差異を検出する一つの方法としてはTerraformの.tfファイルを比較することですが、これにもいろいろな書き方がありえます。

例えばaws_db_proxy_endpointはterraform-provider-awsのv3.38.0で追加されましたが、それまではRoute 53でRDS proxyのエンドポイントを指定する際など自前で書いてあげる必要がありました。こうしたバージョンアップの対応が環境によってずれてしまうことは往々にして起こりえます。

そうしたTerraform側の変化がない場合でも、データソース(data)を使わずにARNを組み立てていたり、デフォルト値を明示したりしていなかったり、リソースを書く順番が違ったり……などチームで運用しているとどうしても細かい違いが生まれてしまうものです。

.tfファイルの文法はHCL(HashiCorp Configuration Language)と呼ばれる言語になっていて、リソースを書く順番だとかファイルの分け方だとかについてはHCLとして解釈して比較することで吸収できるかもしれません。 しかしHCLはかなりシンプルな言語で、.tfで当たり前のように使っているaws_route53_zone.foobar.nameといったリソースの持つ値の参照は実はHCLの機能ではないのです。それゆえHCLとして解釈するだけでは多くの表記揺れを吸収できず、Terraformが行っているリソース参照などを自力で再現する羽目になってしまいます。

そうした手間を省こうと思うと、.tfではなく実際の値が記録されているtfstateを比べることになります。

tfstateならそのまま比較できるわけではなくARNをTerraform上の名前に戻すなどの処理は必要ですが、.tfの高度な分析を行うよりは手間が少ないと考えられます。やはりterraform planと似たようなものを実装することになり、気が進まないという欠点はありますが。

ツールのイメージ

tfstateはファイルに出力できるのでこれを入力とします。また、後述しますがスキーマ情報も必要となるのでこれも追加して

> command_name schema.json left_tfstate.json right_tfstate.json

といった形で実行するCLIツールを考えます。出力としては

common resources: 100
with diff: 30
left only resources: 20
right only resources: 10

といった形で比較できたリソース、そのうち差分があったリソース、片方にしかなかったリソースの数を示すサマリー、そして

compare data.aws_ecs_service.foo
  /service_name : "foo" -> "foo-v2"

compare data.aws_ecs_service.bar
  /desired_count : 1 -> 2

……

といった個々の差分の表示があれば、とりあえず人間が読む分には十分と言えるでしょう。

実装

まずterraformコマンドにはshowというサブコマンドがあり、terraform show -jsonとすればJSON形式でtfstateが取得できます。

{
  "format_version": "1.0",
  "terraform_version": "1.2.9",
  "values": {
    "root_module": {
      "resources": [
        {
          "address": "aws_cloudwatch_log_group.app",
          "mode": "managed",
          "type": "aws_cloudwatch_log_group",
          "name": "app",
          "provider_name": "registry.terraform.io/hashicorp/aws",
          "schema_version": 0,
          "values": {
            "arn": "arn:aws:logs:ap-northeast-1:************:log-group:docker/app",
            "id": "docker/app",
            "kms_key_id": "",
            "name": "docker/app",
            "name_prefix": null,
            "retention_in_days": 365,
            "tags": {},
            "tags_all": {
              "ManagedBy": "terraform"
            }
          },
          "sensitive_values": {
            "tags": {},
            "tags_all": {}
          }
        },
        ……
      ]
    },
    "child_modules": {
      "resources": [
        ……
      ]
    }
  }
}

フォーマットもちゃんと公開されているのでこの出力をjsondiffで比較するのが基本的な方針となりますが、問題となるのがtfstate上はarguments(.tfで指定するもの)とattributes(自動的に決まり、他から参照される値)に区別がないことです。

例えば上に示したaws_cloudwatch_log_groupについて、arnはユーザーには決められないattributeですが、他のargumentsと一緒にvaluesに含まれています。

そこでもう一つ、追加の情報としてプロバイダー固有のスキーマ情報が必要になります。通常はあまり使わないサブコマンドなのですがterraform providers schemaというものがあり、これによってtfstateの要素ごとの性質が分かります。

{
  "format_version": "1.0",
  "provider_schemas": {
    "registry.terraform.io/hashicorp/aws": {
      "provider": {
        "version": 0,
        "block": {
          ……
        }
      },
      "resource_schemas": {
        "aws_cloudwatch_log_group": {
          "version": 0,
          "block": {
            "attributes": {
              "arn": {
                "type": "string",
                "description_kind": "plain",
                "computed": true
              },
              "id": {
                "type": "string",
                "description_kind": "plain",
                "optional": true,
                "computed": true
              },
              "kms_key_id": {
                "type": "string",
                "description_kind": "plain",
                "optional": true
              },
              "name": {
                "type": "string",
                "description_kind": "plain",
                "optional": true,
                "computed": true
              },
              "name_prefix": {
                "type": "string",
                "description_kind": "plain",
                "optional": true
              },
              "retention_in_days": {
                "type": "number",
                "description_kind": "plain",
                "optional": true
              },
              "tags": {
                "type": [
                  "map",
                  "string"
                ],
                "description_kind": "plain",
                "optional": true
              },
              "tags_all": {
                "type": [
                  "map",
                  "string"
                ],
                "description_kind": "plain",
                "optional": true,
                "computed": true
              }
            },
            "description_kind": "plain"
          }
        },
        ……
      },
      "data_source_schemas": {
        ……
      }
    }
  }
}

中でも特にcomputedoptionalが重要で、

  • computedでない→必ず指定する必要がある
  • optionalである→人間が指定する(こともある)

のどちらかを満たせばargumentsと判定することができます。 (optionalが真だと指定がない場合に自動で値が入るのでcomputedは真になっています。)

スキーマについてもaws_cloudwatch_log_groupを見ると、例えばarncomputedでありoptionalではないことからattributeと分かります。 ただしidは人間が指定しない、というかドキュメントにも書かれていないものですが、例外的にargumentと同じ性質になっているようです。

この他にも以下のような処理を行い、なるべく人間に優しい差分を出力するように努めています。

  • ARNなどの識別子をリソース名に変換する
  • スキーマを見てset(順序がない)の場合はソートする
  • jsondiffの出力からstg-(ステージング)とprod-(プロダクション=本番)のような差分を無視する
  • ポリシー類のjsonを再帰的に比較する

実際の環境で実行してみる

実際にあるプロジェクトのtfstateでステージングと本番を比較してみたところ、サマリーとしては以下のようになりました。

common resources: 202
with diff: 98
left only resources: 234
right only resources: 317

leftがステージング、rightが本番です。left/right onlyの比較できなかったリソースがかなりありますが、中身を見るとステージング側でfor_eachにより整理した部分で、リソース名が食い違って比較対象が見つからなかったようです。(このステージング環境も後追いで構築された部分があるので、その構築の際に整理したものですね。)

比較対象を自力で探すのは大変なので、リソースの命名については一致しているのを期待したいところですが、この程度は自動で検知した方が良いかもしれません。

検知された差分の中身を見ていくと以下のようなものが見られました。

compare aws_lb.main
  /access_logs/0/bucket : "logs-********-stg" -> "logs.********"

これはバケット名に.を使わないことが推奨されているのを受けてステージングで命名規則を変更したようです。

compare aws_iam_role.lambda_sqs2worker
  compare /assume_role_policy:
    /Statement/0/Sid : "" -> null
  /managed_policy_arns/1 : "aws_iam_policy.logs_wo" -> "aws_iam_policy.lambda_function"
  /managed_policy_arns/2 : "aws_iam_policy.sqs_rw" -> "aws_iam_policy.logs_wo"
  /managed_policy_arns/3 : "aws_iam_policy.ssm_params_ro" -> "aws_iam_policy.sqs_rw"
  /managed_policy_arns/4 : "aws_iam_policy.vpc_access" -> "aws_iam_policy.ssm_param_ro"
  /managed_policy_arns/- : null -> "aws_iam_policy.vpc_access"

表示がちょっと分かりにくいですがaws_iam_policy.lambda_functionという汎用っぽいポリシーがステージングでは消えています。 これもステージング構築の際に権限を整理したものと思われます。

compare aws_elasticache_replication_group.main
  /description : "main replication group for stg" -> "prod replication group"
  /engine_version : "5.0.6" -> "3.2.10"
  /node_type : "cache.t4g.small" -> "cache.r4.large"
  /replication_group_description : "main replication group for stg" -> "prod replication group"
  /timeouts : null -> {"create":null,"delete":null,"update":null}

本質的な差はエンジンバージョンとノードタイプですね。 バージョン3系は2023年7月でEoLなので、その前にアップデートして本番側もバージョンが上がる予定となっています…。

他にもIPアドレスだとか内部的なIDだとかの違いから差分が出たり、"s3:Get*"["s3:Get*"]のような細かい差分が出たりも見られました。 こういう部分も自動で無視したり、無視する設定をデフォルトのconfigで用意したりしたいですね。

まとめ

まだPoC的な段階ではありますが、tfstateの比較によってステージング環境を本番と相同に保つ試みについて書かせて頂きました。

現状では今あるものを比較する限りですが、将来的には「PRができた段階でterraform planの結果を評価する」などの取り組みにも挑戦したいところです。具体的にはサマリー部分の数値をterraform plan前後で比べることで、そのPRによってステージングと本番が「より近づいたか」「より離れたか」を定量的に評価することが可能となります。 また例えばステージングに先に反映した内容を本番にも入れようとしたのに差分が減らなかったら、何か一方に設定ミスがあるということになります。

ミスがないか確かめる、というのは明確なゴールがないので果てがなく、心理的にもコストが高くなりがちなタスクです。そうした部分こそ自動化を図ることで運用しやすい環境、運用しやすいプロダクトを目指したいと考えています。

カヤックでは、信頼できる開発環境運用に興味があるエンジニアも募集しています。

hubspot.kayac.com