TerraformをテストするためにConftestを導入しました

こんにちは、SREグループの浜地です。 先日、TerraformにConftestを導入したので、背景などを含めて紹介しようと思います。 「最近話題のOPA的なのでTerraformをテストしたいんだけどなー」とお考えの方にぜひ読んでいただけると幸いです。

Conftest導入背景

SREグループのInfrastructure as Code用のリポジトリは大きく分けて3つあります。Terraformに限定した話をすると、TerraformのStateを管理しているのは1つ目のリポジトリのみです。

これまでサービスインフラリポジトリのTerraformコードを反映するために、SREの開発用のPCで terraform apply する運用を続けていましたが、SREの作業効率向上のためにTerraformの自動適用を導入することになりました。

ただし、自動適用を導入するにあたってやはり「システムに与えるクラウド権限強すぎ問題」にどう対処するかが鍵となります。SRE以外の一般開発者もこのリポジトリへのWrite権限を持っているため、権限を借用して任意のコマンドを実行されるセキュリティリスクは事前に排除する必要があります。

セキュリティリスクとしてPoisoned Pipeline Executionなどのワークフローを利用したものもありますが、本記事ではTerraformのコード上で任意のコマンドを実行できるリスクを制御することを観点に置き、その対応策としてConftestを導入したことを記載します。

セキュリティリスクの対策以外にも、「マイクロサービス環境におけるTerraformディレクトリ構成(当ブログ過去記事)」でも紹介されている通り、GO株式会社では多くのマイクロサービスを運用しているため、統率を取るために設定値のルールやお作法などがあるのですが、Pull Request上ではいわゆるレビューアーによってNitsコメントとして指摘されており、Pull Requestの変更行数が多くなると見落としてしまう潜在的な問題もありました。この問題への機械的な対処としてもConftestを活用することを検討しています。

Conftestとは

Conftestとは、Open Policy Agentをエンジンとしたポリシー検証ツールで、Regoファイルを用いることでインフラで扱う様々なツールの設定を検証をすることができます。 Conftestを利用しなくてもOPAとRego(便宜上 pure OPA/Rego と呼びます)を使えば同等のことを実現することは可能ではありますが、Conftestを使うことでより簡単に実現することができます。

また、Conftestでは単なるJSONなどの構造化データだけではなく、DockerfileやTerraform(HCL)といったインフラの構築・運用でよく利用するファイルを対象にすることができるのもpure OPA/Regoを使う場合に比べて良い点です。

pure OPA/RegoやConftestの機能等については説明すると長くなってしまうので、こちらをご参照ください。

Terraformコードの検証方法とPlan結果の検証方法の紹介

ConftestでTerraformを検証する手法として大きく2つあります。Terraformコード自体の検証と、TerraformのPlan結果の検証です。

まず、先ほども述べた通りConftestはTerraformコードをそのまま利用して、コードで宣言しているリソースや値を検証することができます。本記事ではこれをTerraformコードの検証と呼びます。

次に、Terraformの機能としてPlan結果を構造化データとして出力することが可能で、JSONとして出力することが可能です。このJSONを使ってApplyするとどういった値になると予想されるかを検証することができます。本記事ではこれをPlan結果の検証と呼びます。

なお、これら2つの手法はあつかう構造化データのスキーマが異なるため、それぞれに応じた検証用ポリシーを実装する必要があります。(つまり、Terraformコードの検証用として書いたポリシーを、Plan結果の検証として使うことはできない)。

これらの手法はあらゆるTerraformの検証として適用できるかというとそうではありません。Plan結果を検証する手法は当然Planできる状態にある必要があるため、Terraformモジュール単体でPlan結果の検証をすることはできません。 上述のSREグループで扱っているリポジトリを例に上げると、適用可能な手法とリポジトリの関係は以下のようになります。

手法 サービスインフラリポジトリの検査可否 Terraformモジュールリポジトリの検査可否
Terraformコードの検証
Plan結果の検証 ○※1 ✕※2

※1:Conftest実行時にTerraform Stateを管理している場所へのアクセス権限と、各クラウド等の広域のRead権限が必要になるため注意。
※2:そのモジュールを呼び出すモックを実装して検証することも可能だが、手間や具体性・正当性を考えると「Terraformコードの検証」で十分ではないかと考えている。

この表ではTerraformコードの検証だけで十分の様に見えるため、Plan結果の検証は不要かというとそういうわけではありません。 現在のStateと比較してreplaceとなるchangeが発生してしまわないかどうかを検証したり、文字列の内部展開の結果を検証したりすることができるため、必要な要件に応じてこれらの手法を使い分けをする必要があります。

今回は導入したい検証内容の要件が次項の通りまとまっていたため、「Terraformコードの検証の手法」のみを利用してConftestを導入することにしました。

Conftest導入

今回Conftestで検証したい内容を「Terraformを利用して外部コードの実行を制限したい」と定めます。

これを突破できてしまう方法は以下の2つです。これらはTerraform Plan時に外部コマンドを実行するため、Planコマンド実行前に検出できることが理想です。

今回これらをそれぞれdeny_using_local_exec_provisioner, deny_using_external_providerポリシーとして実装することで、Terraformコードを検証することで違反していないか機械的にチェックできるようにします。

ディレクトリ構成

Conftestを導入するにあたり、サービスインフラリポジトリのディレクトリ構成をこのようにしました。といっても、新たにpoliciesというディレクトリを加えただけです。

これは後項「今後の展望」にも記載しますが、Plan結果の検証やKubernetesの設定ファイルの検証をする将来を見据えた構成にしています。

.
├── kubernetes                                    # 稼働中のKubernetesの設定ファイル格納ディレクトリ
├── terraform                                     # 稼働中のTerraformコードの格納ディレクトリ
│   ├── service-a
│   │   ├── development
│   │   └── production
│   └── service-b
│        ├── development
│        └── production
└── policies
     ├── kubernetes                               # Kubernetes用のConftest設定ファイル&ポリシー格納ディレクトリ予定
     └── terraform                                # Terraform用のConftest設定ファイル&ポリシー格納ディレクトリ
          ├── definitions                         # Terraformコードの検証用設定ファイルとポリシー格納ディレクトリ
          │   ├── conftest.toml
          │   ├── deny_using_external_provider.rego
          │   └── deny_using_local_exec_provisioner.rego
          ├── plan_results                        # Plan結果の検証用設定ファイルとポリシー格納ディレクトリ予定
          │   └── conftest.toml
          └── test_data                           # 各手法のテストデータ格納ディレクトリ
              ├── main.tf
              └── tfplan.json

このディレクトリ形式を採用したことで、Conftest実行コマンドは以下のようになります。

$ conftest test -c ./policies/terraform/conftest.toml [検証対象のディレクトリorファイル]

# 例

$ conftest test -c ./policies/terraform/conftest.toml terraform/service-a

この構成になったことで、毎回すべてのTerraformファイルを検証する必要はなく、変更のあったサービスのみConftestの対象にすることができます。

ちなみに、conftest.tomlはConftest実行時のオプションをまとめたもので、中身は以下のようになっています。

# conftest.toml
policy = "policies/terraform/definitions"
namespace = "main"
ignore = ".terraform|.json"   # ディレクトリ配下の不要なファイルは検査対象外とする

ポリシー実装

上述の設定ファイルの通りになりますが、policies/terraform/definitions ディレクトリにRegoファイルを配置することで、検証時に利用されます。 今回は、deny_using_local_exec_provisioner.regoとdeny_using_external_provider.regoというファイルを作成し、それぞれポリシーを実装します。

注意:

  • ファイル名とポリシー名を一致させていますが、仕様上その必要はありません。
  • ファイルやネームスペース(パッケージ)の粒度は将来的に見直される可能性がありますのでご了承ください。
  • 今回はあくまで紹介のための簡単な実装であるため、多重にネストしたmoduleでの定義などにもケアする必要があります。
  • someなどのRegoの記法についても説明を省略します。

なお、ポリシー名の接頭辞が deny_ なのはConftestの仕様を利用しているためです。

参考:Conftest - Evaluating Policies https://www.conftest.dev/#evaluating-policies

# deny_using_local_exec_provisioner.rego
package main

deny_using_local_exec_provisioner[msg] {
    resources = input.resource

    some x, y
    has_field(resources[x][y].provisioner, "local-exec")
    msg = sprintf("Not allowed using local-exec provisioner `%v.%v`", [x, y])
}

has_field(object, field) {
    object[field]
}

has_field(object, field) {
    object[field] == false
}

has_field(object, field) := false {
    not object[field]
    not object[field] == false
}
# deny_using_external_provider.rego
package main

deny_external_provider[msg] {
    has_field(input.data, "external")
    msg = "Not allowed using external provider"
}

# 同じpackageであるため、has_fieldは省略可能ですが一応記載しておきます
has_field(object, field) {
    object[field]
}

has_field(object, field) {
    object[field] == false
}

has_field(object, field) := false {
    not object[field]
    not object[field] == false
}

ポリシーはこれで実装できましたので、テストデータを使って検証します。 以下のように上述のポリシーに違反しているテストデータを作成します。

# test_data/main.tf
resource "null_resource" "test" {
  provisioner "local-exec" {
    command = "echo Hello"
  }
}

# testと差別化のために、違反していないtest2も定義しておきます
resource "null_resource" "test2" {
}

data "external" "test" {
  program = ["echo", "{\"foo\":\"bar\"}"]
}

このテストデータを使ってConftestで検証しましょう。

$ conftest test -c policies/terraform/definitions/conftest.toml policies/terraform/test_data/main.tf

FAIL - policies/terraform/test_data/main.tf - main - Not allowed using external provider
FAIL - policies/terraform/test_data/main.tf - main - Not allowed using local-exec provisioner `null_resource.test`

2 tests, 0 passed, 0 warnings, 2 failures, 0 exceptions

このように違反を検知することができました。Terraformのリソースアドレスを記載することで、どのリソースが違反しているかも表示できるようになります。違反していない null_resource.test2 は正しく判定されているため、メッセージに出てきません。

あとはこれをGitHub ActionsやCircleCIといったワークフローを通じてConftestを実行することで、違反した状態でメインブランチへの取り込みを阻止したり、Plan前に実行してエラーとして扱うことでTerraform Planの実行を防いだりします。 (Conftestのオプション --output=github と使うことで、違反しているコード行なども出力され、ReviewdogのようにPull Request上での表示ができるはずなんですが、今回うまくいかなかったため省略します)

今後の展望

今回、Conftestを利用してTerraformコードを検証することで、外部コマンド実行の抑制を実現することができました。 今後はこれだけにとどまらず、以下のことを実現したいと考えています。

  • Terraformコード上のインフラ設定値の設定ミス検出
      • DBバックアップ日数の設定ミス
      • 本番環境におけるAWS t系インスタンスの利用禁止
      • モニタリングの設定値関係の矛盾検出(WarningよりCriticalの方が低いのを防ぐ)などなど
    • これらを実現するためにPlan結果の検証をできる状態にしておく
  • Kubernetesの設定ファイルの検証
  • アプリケーションリポジトリで管理しているインフラ設定値の検証

導入において特筆事項が生まれた場合、改めて記事にしようと思いますので、ぜひご期待ください。

おまけ:Conftestを使ったRegoファイルのトライアンドエラー方法

Conftestを使ってRegoでポリシーを記載しようとして躓いた人は数多くいるのではないかと思っています。自分もそのひとりでした。 特にConftestを使う以前、pure OPA/Regoを使って実践しようとしたのですが長時間ハマってしまったことがあり、非常に苦い思い出があります。

Conftestを使い始めてハマる最大のポイントは「Rego内でそもそもどういうデータ構造になっているかわからない」と考えているため、その点の対処方法について記載します。

例えば、以下のようなTerraformコードのデータ構造を知りたい、という場合を考えます。

resources "null_resource" "test" {
}

とりあえずRego内でどういうデータ構造なのかをデバッグ出力したいという場合、以下のようなポリシーを記載することでConftest実行時にメッセージとして出力されます。

deny_test[msg] {
    resources = input.resource
    msg = sprintf("debug: ```%v```", [resources])
}
$ conftest test -p [↑のファイル] main.tf

FAIL - ./main.tf - main - debug: ```{"null_resource": {"test": {}}}```

1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions

この方法は当然Conftestで検証可能なすべての対象に利用することができますので、メッセージの内容を元に構造を把握し、ポリシーを実装していくのがいいと思われます。

また、この他にtraceを使った方法もあります。

参考文献:Conftestでのtraceによるデバッグ https://volanja.github.io/p/conftest-debug-trace/

ただしいずれの場合も注意点として、「ポリシー内のすべての式を true にする」ことが重要で、ひとつでも false と評価される式があると「違反していないのでテスト通過」として扱われる可能性があります。(ポリシーに記載した式は依存関係がない場合上から順番に実行されるわけではない※3ため、明示的に最初にtraceを呼び出しても出力されるわけではない)

とくに気をつけたいポイントは、「タイポなどで代入に失敗した場合、式が false になる」という点で、例えば以下のようにタイポしているとConftestはテスト通過扱いとなり msg は出力されなくなってしまいます。(これで多くの時間を浪費しました)

 deny_test[msg] {
-  resources = input.resource
+   resources = input.resources
    msg = sprintf("debug: ```%v```", [resources])
 }

※3:↓のようなコードでも問題なく動きます

deny_test[msg] {
    r0 = resources.null_resource
    resources = input.resource
    msg = sprintf("debug: ```%v```", [r0])
}

それでは、皆様のよいConftestライフを期待しています!