Terraformのモノレポ化とTerragruntでIaCをいい感じにしたーーーい！


[PTAアドベントカレンダー2021](https://adventar.org/calendars/6450)の12/23の記事です。

# 12/23/21
株式会社AbemaTVでABEMAの広告プロダクト開発チームでSREをしているKosakaと言います。
元々はデザイナー・サーバサイドエンジニアをしていましたが、最近はクラウドネイティブな技術やパブリッククラウドと戯れています。
さて、ABEMAやアメーバをはじめ、サイバーエージェントが運営する各メディアをプラットフォームとした広告プロダクト・広告配信システムを開発しているチームが社内にはいくつかあります。
それらを横軸で束ねる、PTA（Publisher adTech Associations）というアライアンスが立ち上がり、所属エンジニアやビジネス職のメンバーを中心に、広告配信を支える技術や広告業界のドメイン知識に関する情報共有、勉強会、[公開イベント](https://cyberagent.connpass.com/event/227271/)などを定期的に開催しています。
今回はその[PTAのアドベントカレンダー2021](https://adventar.org/calendars/6450)のエントリとして記事を書きたいと思います。

# あらすじ
現状のTerraform運用で見えてきた課題の解決までのストーリー。
コード再利用性をモノレポ化で改善。
冗長な手作業を生んでいるディレクトリ構成をTerragruntで解決。
できるのでしょうか、できたらいいな。いや、します。

# Terraform現状と課題感
現在ABEMAの広告部門の開発局ではGCP上にシステムを構築しており、現時点でGCPプロジェクトの数は7つになります。そのうち5つのGCPプロジェクトでTerraformを使っています。これら5つのGCPプロジェクトは、プロダクトとしては**A**と**B**の2つのプロダクトになり、TerraformのコードはGithub上の3つのリポジトリで管理されています。
なかなかわかりにくいですね…。
各GCPプロジェクトとTerraformリポジトリの構成は以下の表のようになっています。

| プロダクト | GCPプロジェクト | GCPプロジェクトの用途 | Terraformリポジトリ |
| - | - | - | - |
| A | project-A | プロダクトAの本番・ステージング・開発環境 | A-terraform |
| A | project-A-load | プロダクトAの負荷試験環境 | A-terraform-load |
| B | project-B | プロダクトBの本番環境 | プロダクトBのモノレポ内 |
| B | project-B-dev | プロダクトBのステージング・開発環境 | プロダクトBのモノレポ内 |
| B | project-B-load | プロダクトBの負荷試験環境 | プロダクトBのモノレポ内 |

表にしたところで、やはりわかりにくいですね…。

いずれのGCPプロジェクトにおいても、基本的にはGKEを中心にBigqueryやCloud SQL、Memorystore、IAM、Secret Managerといったリソースの記述がほとんどとなっています。そのため、各Terraformリポジトリで同じようなコードが現れることになります。しかしながら、Terraformモジュールは共通化されておらず、各Terraformリポジトリでそれぞれにモジュールが作成されており、再利用性が良くありません。
また、**A-terraform-load**リポジトリは更新頻度が低いこともあり、Terraformのバージョンも古いまま更新がされていないなど負債化しつつあります。

Terraformのディレクトリ構成もプロダクト**A**と**B**では異なっています。

**A-terraform**と**A-terraform-load**では以下のように開発環境、ステージング、本番、共通の各ディレクトリ配下にリソース種別単位のディレクトリが切られ、そこにtfファイルが格納されています。

```bash
## 一部分を抽出
.
├── development
│   ├── bigquery
│   ├── cloud-scheduler
│   ├── container
│   └── storage
├── modules
├── production
│   ├── bigquery
│   ├── cloud-scheduler
│   ├── container
│   └── storage
├── shared
│   ├── pubsub
│   └── security
└── staging
    ├── bigquery
    ├── cloud-scheduler
    ├── container
    └── storage
```

1つのtfファイルに全マイクロサービスのリソースが記述されてしまっている場合もあり、あるマイクロサービスのための変更をミスると他マイクロサービスにも影響が出るリスクを常にはらんでいます。
また、同種のリソース（cloud-sqlなど）を複数環境用に作成する再に、`development`コードを先に作成し、それを元に`staging` `production`のコードを書くパターンがほとんどかと思います。その場合には、エディタ上で環境ごとのディレクトリを行き来しながら作業することになります。実際には数十のサブディレクトリ、さらにそのサブディレクトリがいくつもある構成になっていて、全て展開するとエクスプローラ上の見通しも悪く、ツリー構造上距離が離れる分スクロール・検索・ジャンプの量も増えるため、効率の悪さを生んでいることに気づきました。

対して、**B**は比較的最近のプロダクトとなっていて、この課題についてはある程度解消した以下のようにマイクロサービス分離型のディレクトリ構成となっています。マイクロサービスを横断して利用されるリソースについては`shared`に格納しています。

```bash
## 一部分を抽出
.
├── modules
│   ├── bigquery
│   └── secret-manager
└── resources
    ├── service
    │   ├── micro-service-a
    │   └── micro-service-b
    │       ├── dev
    │       ├── load
    │       ├── prd
    │       └── stg
    └── shared
        ├── bigquery
        ├── cloud-spanners
        ├── dataflow
        ├── gke-clusters
        └── vpc-network
            ├── dev
            ├── load
            ├── prd
            └── stg
```

この構成により、他マイクロサービスへの影響をなくしたオペレーションは可能になりました。コンフリクトも以前と比較するとほぼなくなりました。
また、同種リソースの環境別コードが近い距離にあるため、作業効率も向上しました。

`dev` `load` `prd` `stg`の各環境用ディレクトリ配下の一般的なファイル構成は次のとおりです。

```bash
## 一部分を抽出
.
├── dev
├── load
├── prd
└── stg
    ├── main.tf
    ├── output.tf
    ├── providers.tf
    ├── settings.tf
    ├── terraform.tfvars
    ├── variables.tf
    └── versions.tf
```

`dev`環境用にファイルとコードを用意したあと、それを別の環境用に複製し必要部分に修正を入れるというパターンがよくあるのですが、その際に、backendや環境変数的に利用している共通変数、環境ごとに切り替える必要があるモジュールの参照先など、一部書き換えが必要となります。現時点でテンプレート化やコードの自動生成はされておらず、コードレビューを通しても、どうしてもミスが出てしまう部分でもあります。

コードの再利用性、冗長な手作業の解消をモチベーションとし、チーム内のTerraform再構築を行い、いい感じにしていきたいと思います。

# 再利用性の改善→モノレポ化
TerraformはGithubリポジトリをモジュールのソースとして参照することもできるのですが、別リポジトリでモジュールのコーディングを行いながら、さらに別のリポジトリでそれを参照するコーディングを行う、というフローを試した際に、単純にモジュールとそれを利用するコード、すべてを1つのリポジトリに統合し（モノレポ化）管理する方がシンプルで作業がしやすいと感じました。
ローカルマシン上の開発コンテナ内で`terraform plan`を実行しながらコーディングを行うことが多いため、開発用に同時起動するコンテナはできるだけ少なくしたいという理由もあります。

現状はプロダクトとしては**A**と**B**の2つ（マイクロサービスは100程度ありますが…）に限られるため、超巨大モノレポと化す可能性も低いと判断しました。将来的にマルチレポ＋モジュール、というリポジトリ構成にも戻せるようディレクトリ構成を設計し、モノレポ運用を試してみることにしました。
Terraformのアップグレード＋マイグレーション、開発コンテナなどメンテナンスコストが削減され、コーディングスタイルの統一などが以前と比べて容易となりつつあり、一定の効果が見えてきています。

最終的なディレクトリ構造は後ほど示します。

# 冗長な手作業の解消→Terragrunt
TerragruntはGruntwork社によるTerraformのラッパーで、Terraformコードの冗長性を減らし、再利用性を高めてくれます。その他、Terraformでは`provider`リソースにランタイム時に変数を渡すことができませんが、Terragruntの`generate`を使うことで`provider`リソースを各環境用に生成することができるなど、これがやりたかった！的な部分をカバーできます。ディレクトリ構成を設計するにあたり、学習コストがやや必要に感じましたが、設計者以外のチーム全体がすべての機能を学習する必要はなく、トータルしてはコストの削減とDX（= Developer Experience）の向上に繋がるのではないでしょうか。

現段階でディレクトリ構造は以下のようになりました。プロダクト**B**のディレクトリ構造をベースにしています。

```bash
## 一部分を抽出
.
├── modules
│   ├── cloud-iam
│   ├── cloud-storage
│   └── kubernetes-engine
│   　   └── vpc-native-cluster
│   　　      └── default
│   　           ├── README.md
│   　           ├── main.tf
│   　           └── versions.tf
└── resources
    ├── A ## プロダクトAのリソース
    └── B ## プロダクトBのリソース
        ├── dev-vars.yaml ## 開発環境用の環境変数的に使用する変数など dev.hclからロード
        ├── dev.hcl ## 開発環境用のrootとなるTerragruntファイル ---- ルートTerragruntファイル
        ├── stg-vars.yaml
        ├── stg.hcl
        ├── prd-vars.yaml
        ├── prd.hcl
        ├── service ## マイクロサービスのリソース
        │   ├── micro-service-a
        │   └── micro-service-b
        │       └── default
        │           ├── dev
        │           ├── stg
        │           ├── prd
        │           └── module
        └── shared  ## 共通リソース
            ├── gke-cluster
            └── vpc-network
                └── default ## 大きな追加や環境によるモジュール差異が大きい場合はこのレベルのディレクトリを切る `sub` or `secondary` etc
                    ├── dev
                    │   └── terragrunt.hcl ## ../module/main.tfをWETに実行する開発環境用Terragruntファイル ---- 終端Terragruntファイル
                    ├── stg
                    │   └── terragrunt.hcl
                    ├── prd
                    │   └── terragrunt.hcl
                    └── module
                        └── main.tf ## DRYなTerraformコード
```

各環境名で切られた終端ディレクトリ配下の`terragrunt.hcl`（終端Terragruntファイル）に対して`terragrunt init` `terragrunt plan` `terragrunt apply`コマンドを実行します。終端Terragruntファイルは常にルートTerragruntファイルを`include`することで環境毎のボイラープレーティングを自動化しています。

```hcl
## 末端Terragruntファイル

# ----------------------------------------------------------------------------------------------------
# COMMON SETTINGS
# 
# ----------------------------------------------------------------------------------------------------

locals {
  terraform = {
    ## コーディング時../moduleをローカルに参照する場合はtrue、通常はgithub上のタグを参照
    locally    = true

    ## ../moduleのgithubタグ（バージョン）
    source_ref = "v0.0.1"
  }
}

include "root" {
  ## 親ディレクトリを再帰的に検索し`ディレクトリ名.hcl`（この場合resources/B/dev.hcl）というファイルをルートとしてincludeする
  path   = find_in_parent_folders("${basename(get_terragrunt_dir())}.hcl") 

  ## ルートTerragruntファイルのinputsに終端Terragruntファイルからアクセス可能とする
  expose = true
}

terraform {
  ## ../moduleをDRYソースとする
  source = local.terraform.locally ? "${replace(get_terragrunt_dir(), get_env("WORKING_DIR"), format("%s//", get_env("WORKING_DIR")))}/../module" : "git::git@github.com:${REPO}//${replace(get_terragrunt_dir(), get_env("WORKING_DIR"), "")}/../module?ref=${local.terraform.source_ref}"
}


# ----------------------------------------------------------------------------------------------------
# INPUTS
# 
# inputs:
# - gcp_env
# ----------------------------------------------------------------------------------------------------

inputs = {
  gcp_env = include.root.inputs.gcp_env

  ## 以下terraform.sourceで指定されているDRYソースをWETにする設定値
  ## ソースモジュール（../module）のvariableに対応
}

```

ソースとして`../module`を参照します。Terragruntではひとつのソースしか参照することができないため、環境間の差異をモジュールで吸収できない場合はひとつ上のレイヤーでディレクトリを別途切ることを考えています。
listまたはmapで変数を受け取るモジュールにすることである程度吸収できますが、TerraformリソースIDの見通しが悪くなるのと、HCLでのコレクションの展開はコードの可読性が悪くなるためできるだけ避けたい…。

```HCL
## HCLでのコレクションの展開例

for e in flatten([
  for crypto_key_name, kms_crypto_key in var.kms_crypto_keys : [
    for role, members in kms_crypto_key.iam_role_members : [
      for member in members : {
        "key" : "${crypto_key_name}_${role}_${member}",
        "role" : role,
        "member" : member,
        "crypto_key_name" : crypto_key_name,
      }
    ]
  ]
  ]) : e.key => {
  "role" : e.role,
  "member" : e.member,
  "crypto_key_name" : e.crypto_key_name,
}
```

inputsではソースモジュールのvariableに対して変数値を設定します。現在は直接HCLで記述していますが、yamlファイル（JSONも可）からの読み込みが可能なので、yaml化することを検討しています。

また、ルートTerragruntファイルでは

- yamlからの共通変数の読み込み
- バックエンドの自動設定
- プロバイダ、バージョン指定の自動生成
- 環境毎のGCPプロジェクトに対応したcredentialsへの切り替え

を行うことで手動で行っていた作業を抹殺しました。

```hcl
## ルートTerragruntファイル

# ----------------------------------------------------------------------------------------------------
# LOCAL VARIABLES
# Only work within this file.
# ----------------------------------------------------------------------------------------------------

locals {
  ## 環境共通で利用したい変数をyamlからロード
  ## localsの宣言変数は末端Terragruntファイルからは直接アクセスできない
  ## inputsを介してexposeすることでアクセスする
  common_vars = yamldecode(file(find_in_parent_folders("./dev-vars.yaml")))
}


# ----------------------------------------------------------------------------------------------------
# GLOBAL VARIABLES
# Accessible from inherited children through 'include' block.
# ----------------------------------------------------------------------------------------------------

inputs = {
  ## GCPのリージョンやプロジェクトIDなどを環境で共通利用
  gcp_env = local.common_vars.gcp_env
}


# ----------------------------------------------------------------------------------------------------
# GLOBAL VARIABLES
# Accessible from inherited children through 'include' block.
# ----------------------------------------------------------------------------------------------------

## GCSをバックエンドとします
remote_state {
  backend = "gcs"
  config = {
    project  = local.common_vars.terraform.backend_gcp_project_id
    location = "${LOCATION}"
    bucket   = "${BUCKET_NAME}"

    ## これも以前は手動で書き換えていた部分
    prefix   = "${local.common_vars.codename}/${path_relative_to_include("root")}/terraform.tfstate"

    gcs_bucket_labels = {
      stack             = "shared"
    }
  }
}

## やや冗長であるが、自動生成なのであまり気にしない
generate "backend" {
  path      = "backend.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<EOF
terraform {
  backend "gcs" {
    project = "${local.common_vars.terraform.backend_gcp_project_id}"
    location = "${LOCATION}"
    bucket   = "${BUCKET_NAME}"
    prefix = "${local.common_vars.codename}/${path_relative_to_include("root")}/terraform.tfstate"
    gcs_bucket_labels = {
      stack = "shared"
    }
  }
}
EOF
}

generate "providers" {
  path      = "providers.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<EOF
provider "google" {
  credentials = file("${get_env("GOOGLE_APPLICATION_CREDENTIALS")}")
  project     = "${local.common_vars.gcp_env.project_id}"
  region      = "${local.common_vars.gcp_env.default_region}"
  request_timeout = "60s"
}

provider "google-beta" {
  credentials = file("${get_env("GOOGLE_APPLICATION_CREDENTIALS")}")
  project     = "${local.common_vars.gcp_env.project_id}"
  region      = "${local.common_vars.gcp_env.default_region}"
  request_timeout = "60s"
}
EOF
}

# can be overriden by versions_override.tf in the indivisual dir
## 終端側で変更する必要がある場合は｀生成されるファイル名_override.tf`で上書きができる
generate "versions" {
  path      = "versions.tf"
  if_exists = "overwrite_terragrunt"
  contents  = <<EOF
terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 4" # 4.x's latest
    }
    google-beta = {
      source  = "hashicorp/google-beta"
      version = "~> 4" # 4.x's latest
    }
  }
  required_version = ">= 1.0"
}
EOF
}

generate "main" {
  path      = "main.tf"
  if_exists = "skip"
  contents  = <<EOF
# empty
EOF
}


# ----------------------------------------------------------------------------------------------------
# TERRAFORM
# Switches credentials before executing terraform commands. Can be overwritten.
# ----------------------------------------------------------------------------------------------------

## GCPプロジェクトに対応したcredentialsに切り替えるスクリプトを`terraform init`前に自動実行する
terraform {
  before_hook "switch_credentials" {
    commands = ["init"]
    execute  = ["ctx", "${local.common_vars.gcp_env.project_name}"]
  }
}
```

GCPプロジェクトcredentialsの切り替えにはスクリプトを用意し、Terragruntのbefore_hookで`terraform init`実行時に切り替えを行っています。Terragruntから`plan` `apply` `destroy`を実行する際は`init`も呼ばれているようなので、`init`前の実行をhookするだけで十分なようです。
スクリプトでは、`gcloud auth login`したユーザの権限でSecret Managerに`gclooud secret version access`し、viewer権限のcredentialsを取得します。これでローカル上の開発コンテナ内から`terragrunt plan`が実行できます。`apply`オペレーションはCIで行いますが、緊急的に開発コンテナ内から実行することもオプションとして用意しています。

# まとめ
Terragruntを導入してみて、Terraformリソースのコーディングに集中できるようになりました。ディレクリ構造を変えたため、まだ既存のTerraformリポジトリからの移行にはまだ時間がかかりそうですが、DX向上の面でも価値はあったと思います。Terraformリソース自体はTerragruntからも完全にDRYなため、Terragruntの使用をやめる判断となった場合でもTerraformリソース自体に変更の必要ないので安心感があります。
モノレポ化によっていくつもエディタを開いて往来しながら作業する必要もなくなりリードタイムの削減ができそうです。