Terraform Cloudを使ったマルチプロジェクト環境でのTerraform運用フロー

こんにちは、Insight Edgeでエンジニアをしている島田です。
今回はTerraform Cloud(HCP Terraform)を導入したため、普段Terraformの管理をしているインフラエンジニアの方やTerraform Cloudの導入を検討している方へ向けて、Insight EdgeでのTerraform Cloudの活用方法を紹介したいと思います。

本記事は、Terraformの基本的な操作や、Google Cloud/AWS/AzureのいずれかのクラウドサービスにおけるIAMの基本的な概念、OIDC認証の概念、およびCI/CDの知識を前提としています。

目次

導入背景

Insight Edgeでは大小様々な開発・PoC案件を抱えており、短いものでは3ヶ月程度です。案件にアサインされる開発者は大体1〜3名程度で、新しい案件が始まるとGitHubリポジトリとパブリッククラウドのアカウント(AWS, Google Cloud, Azureのどれか)を作成します。各案件ではインフラの構成管理にTerraformを採用することが多いのですが、下記のような課題がありました。

  • applyのフローやシークレット管理方法などが開発者によってバラバラ
  • 各案件ではなるべくシークレットキーやサービスアカウントキーを発行せずにOIDC認証を採用したい
  • 案件が次々と立ち上がり、メンバーも毎回変わるため、applyのフローを決めたりCI管理の工数がばかにならない

Terraform Cloudを使えば組織内である程度統一した方法を確立しつつ上記のような工数も削減できそうであったため、今回の採用にいたりました。

Terraform Cloudの概要

Terraform Cloud はHashiCorpが提供するマネージドのTerraform実行環境です。
この記事では詳しくは触れませんが、下記のような機能があります。

  • Terraform実行環境のホスティング
  • Terraform Stateのリモート管理
  • Terraformの変数とシークレット管理
  • チームの作成およびアクセス制御
  • 組織ルールとしてのポリシーの適用 (Sentinel/OPA)
  • Plan/Apply結果の通知
  • ワークスペース間の依存関係を考慮した自動実行フロー
  • TerraformモジュールのPrivate Registry
  • ドリフト検知

機能や使用感については公式のチュートリアルを流し読みするとイメージがわきやすいかなと思います。
https://developer.hashicorp.com/terraform/tutorials/cloud-get-started/cloud-sign-up

GitHub ActionsなどでPlan/Applyのワークフローを組んでいる場合はそれの代替だと思っていただいて大丈夫ですが、マネージドなだけあり管理コストは低いです。GitHub Actionsでやる場合は、リポジトリごとにワークフロー・State用のバケットの作成は最低限必要になり、ドリフト検知などもやりたい場合はまた別のワークフローを作成する必要があったりと結構大変だと思います。

また、全ての案件のリポジトリのTerraform関連の設定 (CIやシークレット管理など) がTerraform Cloudに集約され、さらにTerraform Cloudの設定・構成管理自体もTerraformでできるため、組織に統一したルールを適用しやすいです。

今回は、Terraform Cloudの構成をTerraformおよびTerraform Cloudで管理・簡易化し、各案件に導入しやすくした取り組みを紹介していきたいと思います。

Terraform CloudによるTerraform Cloudの構成管理

Terraform Cloudでは terraform apply を実行するディレクトリごとに「ワークスペース」を作成し、「プロジェクト」を作ってワークスペースをグルーピングします。

弊社では、下記のように対応させることにしました。

  • プロジェクト: 案件に対応
  • ワークスペース: 案件ごとの環境に対応 (dev, prodなど)

Terraform Cloud導入後のフローは下記のようになります (Google Cloudを使う場合)。

  1. 案件発足
  2. 案件のGitHubリポジトリ、Google Cloudプロジェクトの作成
  3. Terraform Cloudのプロジェクト・ワークスペース作成
  4. Terraform Cloud用のサービスアカウントを作成 (AWSの場合はIAMロール、Azureの場合はサービスプリンシパル)
  5. 案件のリポジトリにTerraformリソースを作成

今回は、特に手間がかかりやすい、かつTerraform化が可能な3と4について自動化を進めました。

Terraform Cloudでの自動化を進めるにあたって、まず案件に紐付かないTerraformリソースを管理するための共通リポジトリ (terraform-config リポジトリ) を作成しました。

Terraform Cloudのプロジェクト作成の自動化

リポジトリを作成後、プロジェクト作成を簡単にするための project モジュールを作成しました。
このモジュールでは下記のリソースを作成しています。

  • tfe_project: Terraform Cloud上のプロジェクト
  • tfe_team: Terraform Cloud上のチーム (GitHubのチームと同じ立ち位置)
  • tfe_team_organization_members: チームにアサインするTerraform Cloudユーザー
  • tfe_team_project_access: チームがプロジェクト対して持つ権限

また、このリポジトリにはTerraform Cloud以外の案件に紐付かないリソース (Google Cloudの組織レベルのIAMとか) も置く予定があったため、terraform-cloud/ ディレクトリを切り、その下にモジュールを作成しました。

terraform-cloud/modules/project/main.tf

# プロジェクトの作成
resource "tfe_project" "this" {
  name = var.project_name
}

# チームの作成
resource "tfe_team" "this" {
  name = var.project_name  # チーム名はプロジェクト名と同じにする
}

# メンバーを上記のチームにアサイン
resource "tfe_team_organization_members" "this" {
  team_id                     = tfe_team.this.id
  organization_membership_ids = var.team_members  # メンバーのIDは変数で受け取る
}

# 作成したチームにプロジェクトへのアクセス権を付与
resource "tfe_team_project_access" "this" {
  project_id = tfe_project.this.id
  team_id    = tfe_team.this.id
  access     = "custom"

  project_access {
    settings      = "update"
    teams         = "read"
    variable_sets = "write"
  }

  workspace_access {
    create         = true
    delete         = true
    move           = true
    runs           = "apply"
    variables      = "write"
    state_versions = "write"
    sentinel_mocks = "read"
    run_tasks      = true
    locking        = true
  }
}

プロジェクト外のメンバーが間違えて何かしてしまうといったことがないように、プロジェクトと同じ名前のチームを作成し、そのプロジェクトのみに最小限のアクセス権限を与えています。

また、variable_sets (プロジェクトに紐付く共通シークレット/変数) と variables (ワークスペースごとのシークレット/変数) へのアクセス権限は write に設定し、案件のメンバーがTerraform CloudのUIから直接登録する運用にしています。

モジュール使用側

CODEOWNERSファイルで各案件のメンバーが自動でレビュアーとして追加されるようにしたいため、モジュール使用側では案件ごとにファイルを分けています。

「Example」という案件名の場合は、下記のように記述します。

terraform-cloud/project-example.tf

module "project_example" {
  source = "./modules/project"

  # プロジェクト/チーム名
  project_name = "Example"

  # 案件のメンバーのIDのリスト
  team_members = [
    # Terraform CloudのユーザーもTerraformで管理(後述)
    resource.tfe_organization_membership.shimada.id,
  ]
}

Terraform Cloudのワークスペース作成の自動化

ここでは割愛しますが、ワークスペースの作成もモジュール化しています。
とはいえ、実インフラのTerraformリソースを置いておく案件のリポジトリ側のディレクトリ構成などはチームで自由に決定できた方がいいため、project モジュールほどは簡易化していません。

workspace モジュールは、project-example.tf と同じファイルに記述しています。

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

# dev環境のGoogle Cloudリソースのワークスペース作成
module "project_example_workspace_gcloud_dev" {
  source = "./modules/workspace"

  project_id        = module.project_example.project_id
  workspace_name    = "example-gcloud-dev"
  terraform_version = "1.12.1"

  # 案件のTerraformリソースが置いてあるリポジトリ
  repo_name = "InsightEdgeJP/xxx"

  # 上記リポジトリ内でapply/planを実行したいディレクトリ
  working_directory = "terraform/gcloud/envs/dev"

  # このファイルに変更があった場合にapply/planをトリガーする
  trigger_patterns = [
    "/terraform/gcloud/envs/dev/**/*",
    "/terraform/gcloud/modules/**/*",
  ]

  # SlackチャンネルのWebhook URL
  alert_channel        = "..."
  notification_channel = "..."
}

Terraform Cloudに乗せる

以上でローカルからapplyすればプロジェクト・ワークスペースを作成できるようになったのですが、このTerraform実行もTerraform Cloudで行いたいです。そのためには、terraform-cloud/ ディレクトリに対応したワークスペースを作成する必要がありますが、こちらはTerraform CloudのUIから tfc-config というワークスペースを手動で作成しました。手動で作成したのは、Terraform Cloudの構成管理をするワークスペースを作成するためにはTerraform Cloudの構成管理をするワークスペースが先に必要というニワトリタマゴの問題があるためです。

tfc-config ワークスペースと各案件のプロジェクト・ワークスペースの関係は下図のようになります。

main.tf に、作成した tfc-config ワークスペースを指定します。

terraform-cloud/main.tf

terraform {
  required_version = "1.12.1"

  # Terraform Cloudを使う場合は `backend` ブロックの代わりに `cloud` ブロックが必要
  cloud {
    organization = "ExampleOrg"

    workspaces {
      name = "tfc-config"
    }
  }

  required_providers {
    tfe = {
      version = "0.66.0"
    }
  }
}

provider "tfe" {
  organization = "ExampleOrg"
}

以上でTerraform Cloudの構成管理がTerraform Cloudで行えるようになり、案件開始時のステップ3の Terraform Cloudのプロジェクト・ワークスペース作成 のTerraform化が達成できました。

ステップ3を詳細に書くと下記のようになります。

  1. 案件のメンバーが terraform-config リポジトリにプロジェクト・ワークスペース追加のPR作成
  2. CODEOWNERSに記載のOwnerにレビュー依頼が飛ぶ
  3. Ownerがレビュー後マージ
  4. Terraform Cloudが tfc-config ワークスペースのapplyを自動実行

また、この時点でのディレクトリ構成は下記のようになります。

terraform-config  ←リポジトリ名
├── CODEOWNERS
├── README.md
└── terraform-cloud  ←このディレクトリがtfc-configワークスペースに対応
    ├── main.tf
    ├── modules
    │   ├── project
    │   │   ├── main.tf
    │   │   └── variables.tf
    ├── project-xxx.tf
    └── project-yyy.tf

その他のTerraform Cloud関連リソースの管理

プロジェクト・ワークスペース以外にもいくつか tfc-config ワークスペースで管理しているものがあります。

メンバー管理

tfe_organization_membership を使うとTerraform Cloudの組織への招待が行えるため、組織メンバーの管理もここで行なっています。

terraform-cloud/members.tf

resource "tfe_organization_membership" "shimada" {
  email = "shimada@example.com"
}

applyが実行され、tfe_organization_membership が作成されると、指定のメールアドレスに招待メールが飛ぶようになっています。

新規にTerraform Cloudを使いたいメンバーには、自分で自分を追加するPRを作成してもらうようにしています。

チーム管理

案件に紐付かないチームおよびチームメンバーの管理も同様に tfc-config ワークスペースで行なっています。

terraform-cloud/teams.tf

# 例:Ownerチーム (Ownerチームは初めから存在しているため `data` を使用)
data "tfe_team" "owners" {
  name = "owners"
}

resource "tfe_team_organization_members" "owners" {
  team_id = data.tfe_team.owners.id

  organization_membership_ids = [
    resource.tfe_organization_membership.xxx.id,
    ...,
  ]
}

こちらも権限が欲しいメンバーに自分でPRを作成してもらうようにしており、PRの作成が権限申請と権限追加作業を兼ねています。どこの組織も大抵何らかの形で申請が必要かと思いますが、承認側からするとPRを確認してマージするだけでよくなるので運用が楽になると思います。

Terraform Cloudに与えるサービスアカウントのモジュール化

Terraform CloudがGoogle Cloudへ変更を適用するためには、適切な権限を持ったサービスアカウントを与えてやる必要があります。認証はサービスアカウントのJSONキーを環境変数として設定する方法と、Workload Identityを使ったOIDC認証の2通りです。組織としてはセキュリティ観点からWorkload Identityを使った認証を推奨したいですが、Workload Identityの設定はそれなりに手間がかかります。そこで、サービスアカウントの作成やWorkload Identityの設定もTerraform Cloudのプロジェクト作成と同時にできたら楽だと思い、モジュール化してしまいました。

サービスアカウント作成モジュール

このモジュールは、指定のGoogle Cloudプロジェクト内に下記のリソースを作成します。

  • Terraform Cloudに与えるサービスアカウント
  • 上記のサービスアカウントと指定ロールの紐付け
  • Workload Identity関連の設定

作成するのはGoogle Cloudのリソースのため、terraform-cloud/ ディレクトリではなく gcloud/ ディレクトリの下に作成しました。

gcloud/modules/tfc-service-account/main.tf

# IAMのAPIを有効化
resource "google_project_service" "iam" {
  project            = var.project_id
  service            = "iam.googleapis.com"
  disable_on_destroy = false
}

# TFCに与えるサービスアカウントの作成
resource "google_service_account" "terraform_cloud" {
  project      = var.project_id
  account_id   = "terraform-cloud"  # サービスアカウント名はモジュール内にハードコード
  display_name = "Terraform Cloud Service Account"

  depends_on = [google_project_service.iam]
}

# サービスアカウントにロールをアタッチ
resource "google_project_iam_member" "terraform_cloud_role_bindings" {
  for_each = toset(var.roles)  # ロールは変数で受け取り

  project = var.project_id
  role    = each.value
  member  = "serviceAccount:${google_service_account.terraform_cloud.email}"
}

# 以下、Workload Identityの設定
resource "google_iam_workload_identity_pool" "terraform_cloud" {
  project                   = var.project_id
  workload_identity_pool_id = "terraform-cloud"  # ハードコード
  display_name              = "Terraform Cloud"

  depends_on = [google_project_service.iam]
}

resource "google_iam_workload_identity_pool_provider" "terraform_cloud" {
  project                            = var.project_id
  workload_identity_pool_id          = google_iam_workload_identity_pool.terraform_cloud.workload_identity_pool_id
  workload_identity_pool_provider_id = "terraform-cloud"  # ハードコード
  display_name                       = "Terraform Cloud"

  attribute_condition                = "attribute.tfc_organization_id == '${local.tfc_organization_id}' && attribute.tfc_project_name == '${var.tfc_project_name}'"
  attribute_mapping = {
    "attribute.tfc_organization_id" = "assertion.terraform_organization_id"
    "attribute.tfc_project_name"    = "assertion.terraform_project_name"
    "attribute.tfc_workspace_name"  = "assertion.terraform_workspace_name"
    "google.subject"                = "assertion.sub"
  }

  oidc {
    issuer_uri = "https://app.terraform.io"
  }
}

resource "google_service_account_iam_member" "terraform_cloud_workload_identity_user_binding" {
  service_account_id = google_service_account.terraform_cloud.name
  role               = "roles/iam.workloadIdentityUser"
  member             = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.terraform_cloud.name}/*"
}

設定簡素化のために、サービスアカウント名などを terraform-cloud という固定値でモジュール内に埋め込んでしまっています。上記は案件、つまりGoogle Cloudのプロジェクトごとに作成するため、リソース名重複の問題はありません。

モジュール使用側では下記のように使います。

gcloud/tfc/project-example.tf

module "project_example_tfc_gcloud_service_account" {
  source = "../modules/tfc-service-account"

  project_id       = "example"       # Google CloudのProject ID
  tfc_project_name = "Example"       # Terraform Cloudのプロジェクト名
  roles            = ["roles/owner"] # サービスアカウントにアタッチするロール
}

以上で適切な権限を持ったサービスアカウントを案件のGoogle Cloudプロジェクト内にTerraformで作成できるようになりました。
tfc-config と同様に gcloud/tfc/ に対応したワークスペースの作成する必要がありますが、こちらは割愛します。

OIDC認証用の環境変数登録の自動化

次に、上記のモジュールで作成したサービスアカウントの情報を、プロジェクトまたはワークスペースに環境変数として登録してやる必要があります。

Workload Identityを使った認証で必要な環境変数は下記の5つです。

キー名 説明
TFC_GCP_PROVIDER_AUTH 常に true
TFC_GCP_PROJECT_NUMBER プロジェクトIDでなくプロジェクト番号
TFC_GCP_RUN_SERVICE_ACCOUNT_EMAIL サービスアカウントのEmail
TFC_GCP_WORKLOAD_POOL_ID ワークロードプールのID
TFC_GCP_WORKLOAD_PROVIDER_ID ワークロードプロバイダのID

モジュール内でサービスアカウント名などをハードコードしていたため、このうち TFC_GCP_PROJECT_NUMBER の値以外は下記のように自動的に決まります。

キー名
TFC_GCP_RUN_SERVICE_ACCOUNT_EMAIL terraform-cloud@{プロジェクトID}.iam.gserviceaccount.com
TFC_GCP_WORKLOAD_POOL_ID terraform-cloud
TFC_GCP_WORKLOAD_PROVIDER_ID terraform-cloud

つまり、gcloud/tfc/ のワークスペースと tfc-config ワークスペースに依存関係が発生しないため、project モジュール内で予め環境変数を作成しておくことができます。

これらの環境変数は同じプロジェクト内のワークスペースで共通のため、variable setsが使えます。variable setsに登録した環境変数やシークレットは、同じプロジェクト下の全てのワークスペースで自動的に適用されます。

project モジュールに variable-sets.tf を追加しました。

terraform-cloud/modules/project/variable-sets.tf

locals {
  # Google Cloudを使わない場合もあるため、フラグ制御する
  enable_google_cloud = var.google_cloud_project_id != null
}

# variable_setの作成
resource "tfe_variable_set" "this" {
  name              = "${lower(replace(var.project_name, " ", "-"))}-tfc"
  description       = "Managed by terrform-cloud"
  organization      = "ExampleOrg"
  parent_project_id = tfe_project.this.id  # どのプロジェクトに所属させるか
}

# プロジェクト下の全ワークスペースがvariable_setを使用できるようにするためのスコープ設定
resource "tfe_project_variable_set" "this" {
  variable_set_id = tfe_variable_set.this.id
  project_id      = tfe_project.this.id
}

data "google_project" "project" {
  count      = local.enable_google_cloud ? 1 : 0
  project_id = var.google_cloud_project_id
}

# 以下、必要な5つの環境変数を作成
resource "tfe_variable" "var_tfc_gcp_provider_auth" {
  count           = local.enable_google_cloud ? 1 : 0
  variable_set_id = tfe_variable_set.this.id

  key      = "TFC_GCP_PROVIDER_AUTH"
  value    = "true"
  category = "env"
}

resource "tfe_variable" "var_tfc_gcp_run_service_account_email" {
  count           = local.enable_google_cloud ? 1 : 0
  variable_set_id = tfe_variable_set.this.id

  key      = "TFC_GCP_RUN_SERVICE_ACCOUNT_EMAIL"
  value    = "terraform-cloud@${var.google_cloud_project_id}.iam.gserviceaccount.com"
  category = "env"
}

resource "tfe_variable" "var_tfc_gcp_project_number" {
  count           = local.enable_google_cloud ? 1 : 0
  variable_set_id = tfe_variable_set.this.id

  key      = "TFC_GCP_PROJECT_NUMBER"
  value    = data.google_project.project[0].number
  category = "env"
}

resource "tfe_variable" "var_tfc_gcp_workload_pool_id" {
  count           = local.enable_google_cloud ? 1 : 0
  variable_set_id = tfe_variable_set.this.id

  key      = "TFC_GCP_WORKLOAD_POOL_ID"
  value    = "terraform-cloud"
  category = "env"
}

resource "tfe_variable" "var_tfc_gcp_workload_provider_id" {
  count           = local.enable_google_cloud ? 1 : 0
  variable_set_id = tfe_variable_set.this.id

  key      = "TFC_GCP_WORKLOAD_PROVIDER_ID"
  value    = "terraform-cloud"
  category = "env"
}

これで案件開始時に下記を追加するPRを作成するだけで、サービスアカウントの作成、Terraform Cloudプロジェクトの作成および認証用の環境変数設定まで完了できるようになりました。

  • tfc-service-accountモジュール: gcloud/tfc/project-example.tf
  • projectモジュール: terraform-cloud/project-example.tf

各案件のGoogle Cloudプロジェクトにサービスアカウントを作成するためのサービスアカウント

だいぶメタ的になって来ましたが、gcloud/tfc/ 内で使う tfc-service-account モジュールは、任意のGoogle Cloudプロジェクトにサービスアカウントを作成します。つまり、gcloud/tfc/ に対応するワークスペースには、任意のGoogle Cloudプロジェクトにサービスアカウントを作成できる権限を持ったサービスアカウントを与えてやらなくてはいけません。

図にすると下記のようなイメージです。

Google Cloudでは組織レベルにサービスアカウントを作成できないため、いずれかのプロジェクト内にサービスアカウントを作成し、そのサービスアカウントに組織レベルのIAMやWorkload Identityの編集ロールをアタッチしてやる必要があります。

下記のステップで実現可能でした。

  1. terraform-cloud という名前のGoogle Cloudプロジェクトを作成
  2. terraform-cloud プロジェクトにサービスアカウントを作成
  3. 2で作成したサービスアカウントに組織レベルのIAMやWorkload Identityの編集ロールをアタッチ
  4. terraform-cloud プロジェクト内でWorkload Identityを構成する
  5. gcloud/tfc/ に対応するワークスペースへ先述の5つのOIDC認証用の環境変数を設定

完全なミニマムではないですが、サービスアカウントには下記のロールを付与すれば十分です。

  • roles/resourcemanager.projectIamAdmin
  • roles/iam.serviceAccountAdmin
  • roles/iam.workloadIdentityPoolAdmin
  • roles/serviceusage.serviceUsageAdmin (各プロジェクト側のIAMのAPIを有効化するために必要)

最終的なディレクトリ構成

terraform-config リポジトリの最終的なディレクトリ構成は下記のようになりました (locals.tfなど一部省略)。

terraform-config  ←リポジトリ名
├── CODEOWNERS
├── README.md
├── gcloud
│   ├── modules
│   │   └── tfc-service-account
│   │       ├── project-xxx.tf
│   │       ├── project-yyy.tf
│   │       ├── main.tf
│   │       └── variables.tf
│   └── tfc  ←このディレクトリが各案件ごとのサービスアカウントを作成するワークスペースに対応
│       ├── main.tf
│       ├── project-xxx.tf
│       └── project-yyy.tf
└── terraform-cloud  ←このディレクトリがtfc-configワークスペースに対応
    ├── main.tf
    ├── members.tf
    ├── modules
    │   ├── project
    │   │   ├── main.tf
    │   │   ├── variable-sets.tf
    │   │   └── variables.tf
    │   └── workspace
    │       ├── main.tf
    │       └── variables.tf
    ├── project-xxx.tf
    ├── project-yyy.tf
    └── teams.tf

今後の展望と課題

現状、AWSやAzureについては案件ごとにOIDCの設定をしてからTerraform Cloud上に環境変数を手動で登録していますが、AWSやAzureについてもGoogle Cloudと同じように簡易化できると思います。
また、クラウドのIAM管理などはもちろんGitHubのアカウントやリポジトリなどTerraformで管理できるものは意外と多いため、Terraformを使ったコードベースでの申請の仕組みは汎用的に使えるのではないかなと思います。弊社にもまだまだ改善できそうな部分が多いため、引き続き新しいツールの導入検討や改善活動を続けていきたいと思います。

本稿がTerraformを使った運用改善の一助になれば幸いです。