突撃! 我が家のTerraform

こんにちわ、Core Infrastructure チームの前多です。膝が痛い。

こちらはキャディ株式会社のアドベントカレンダーの3日目の記事です。

先日、弊社の同僚からCADDiのアーキテクチャと開発組織に変遷に関する発表が行われました。

私たちのプロダクトのインフラは Terraform で構成しています。 プロダクトがロンチされてから3年以上経っていて、その発展に従ってTerraformの構成も大きく変化してきました。

この記事ではプロダクトのTerraformがどのように変化してきたかを紹介していきます。

というのは建前で、どこかでTerraformネタで発表しようと思って溜めていてたネタだったんですが、機会がなかったのでここで記事にしました。

CADDi Drawer 初期(2021-2022)

図面管理SaaSとしての基本的な機能を作ってロンチした頃。

この頃は、SaaSの機能は少なくTerraformの構成も次のようにシンプルなものでした。

terraform
├ environments
│ ├ dev
│ │ ├ main.tf
│ │ └ variables.tf
│ ├ stg
│ └ prod
└ modules
  ├ cloudsql
  │ └ main.tf
  ├ iam
  ├ gke
  ├ gcs
  ├ network
  ├ pubsub
  └ secret

environments は terraform のapply対象、state管理の対象となるルートモジュールで、ここでGoogle Cloudのプロジェクトや環境ごとのパラメータを持っています。 moduels配下は、ルートモジュールから参照されるもので、Google Cloud に作成する実際のリソースを管理しています。 当時はモジュールは、Google Cloudの機能相当で分割していたようです。

networkモジュールでVPCやサブネットを、gkeモジュールでGKEクラスタやノードプールを、のようにインフラの共通リソースの定義から始まって、 それを踏襲して Cloud Pub/Subが欲しくなったので pubsubモジュールを、のようにモジュールをクラウドの機能単位で作っていました。 (後から振り返りますが、これはモジュールの作りとしてはあまり良くはないことがわかってきます)

また当時から、このリポジトリには Terraformと Terraform Providerの更新を自動化する仕組みや、PullRequestのステータスに合わせて Plan/Applyを自動化する仕組みを導入していました。 これがあったからこそ、後続のリファクタリングがうまくいったと言っても過言ではありません。

では次にどうなったのかを見てみましょう。

CADDi Drawer 成長期(2023-2024)

機能強化やCADDi Quoteなどのプロダクトの追加といった様々な追加開発を行なっていた頃です。

開発に関わるメンバーも増え、チーム体制を取ったりと組織面でも大きな変化があった時期です。

この頃の Terraform の構成はおおよそ次のようになっていました。

terraform
├ environments
│ ├ dev
│ │ ├ main.tf
│ │ └ variables.tf
│ ├ stg
│ └ prod
└ modules
  ├ cloudsql
  │ ├ main.tf
  │ └ service_a.tf
  ├ bigquery
  ├ iam
  │ ├ main.tf
  │ └ service_a.tf
  ├ gke
  ├ network
  ├ pubsub
  ├ gcs
  │ ├ main.tf
  │ └ service_a.tf
  ├ secret
  │ ├ main.tf
  │ └ service_a.tf
  └ some_saas

ディレクトリ構成的にはあまり変わっていません。 Google Cloud で利用するAPIの追加に従ってモジュールが増える他、 この頃には Google Cloud以外の外部SaaSも使い始めてその管理用のモジュール(some_saasとしておきます)も追加されました。

そして、Google Cloud の機能単位で作成された pubsub,secret,iamなどのモジュールは 複数の開発チームが相乗りして、それぞれ必要とするリソースを追加していました。

この状態のまま、Terraform のコードが増えていったため、次のような困りごとが出てくるようになりました。

1つの修正で複数モジュールを修正する必要がある

Google Cloudのリソース同士は依存性を持つことがあります。最も良くあるのが、リソースに対してサービスアカウントのIAM Roleを付与するパターンです。 この場合、リソース単位でまとめたモジュールだとモジュール間の依存性が生まれます。

GCS バケットとサービスアカウントを作成してIAM Roleを割り当てるには現状のモジュール構成だと以下のようになります。

iamモジュール内でサービスアカウントを設定して、memberをoutputで返す。

resource "google_service_account" "some_sa" {
  account_id   = "some_sa"
}

output "some_sa_member" {
  value = resource.google_service_account.some_sa.member
}

gcsモジュールでGCSバケットを作成し、variableでSAメンバー名を受け取り、IAMロールを付与する

# gcs module
resource "google_storage_bucket" "some_bucket" {
  name          = "some_bucket"
  project       = var.project_id
  location      = "ASIA-NORTHEAST1"
  force_destroy = false
}

resource "google_storage_bucket_iam_member" "iam_member_example" {
  bucket = google_storage_bucket.some_bucket.name
  role   = "roles/storage.user"
  member = var.some_sa_member
}

ルートモジュールでmodule間のoutputとvariableを渡します。

module "iam" {
    source = "../../modules/iam"
}

module "gcs" {
    source = "../../modules/iam"
    # iam moduleの outputのSA memberを渡す
    some_sa_mamber = module.iam.some_sa_member
    # リソースが追加されるたびに variableが増えていく
    hoge_sa_member  = module.iam.....
}

とある開発チームが、GCSバケットと権限を設定するためには、二つのモジュールを修正し、モジュール間のパラメータの受け渡し(outputとvariable)を追加する必要があります。

こういったことがGCSやPub/Subなど様々なリソースで起きるので、何かしらの変更が起きるたびに複数のモジュールにまたがる修正が必要でした。

複数のリリース対象が混じっている

Google Cloudと 他のSaaS のTerraform 構成が一つのstateに混在した結果、SaaSのみをアップデートしたくてもGoogle Cloud側のリソースの修正も混じっていてリリースタイミングの調整が必要になることがありました。

このようなことから、今後更なる機能追加の障害になると考え、モジュール構成の見直しとstateの分割を検討しました。

モジュール構成の見直し

一般的なプログラミングにおける良いモジュールとは、モジュール間の依存が少なく、モジュールの中には関連が強いものが集まる、つまり疎結合・高凝集であることです。

なんからの修正に対して単一のモジュールのみの修正で済んだり、他のモジュールに影響を与えずにモジュールの追加・削除ができることが望ましい姿であると言えます。

複数の開発チームが同時に開発している状況では、チームが開発している各サービスでリソースをまとめるのが適切だろうと判断しました。 次のようなモジュール構成にすることにしました。

terraform
├ environments
│ ├ dev
│ │ ├ infra
│ │ │ ├ main.tf
│ │ │ ├ infra.tf
│ │ │ ├ app_a.tf
│ │ │ └ app_b.tf
│ │ └ some_saas
│ │   └ main.tf
│ ├ stg
│ │ ├ infra
│ │ └ some_saas
│ └ prod
│   ├ infra
│   └ some_saas
└ modules
  ├ cloudsql
  ├ network
  ├ gke
  ├ service_a
  │ ├ iam.tf
  │ ├ gcs.tf
  │ ├ pubsub.tf
  │ └ secret.tf
  ├ service_b
  └ service_c

modulesについては、開発チームが作成しているサービス単位でモジュールを作成しそこにそのサービスで使うリソースをまとめます。 ただし、VPCや GKEなどの共通基盤として利用するリソースはそのままです。

ルートモジュールについては、Google Cloud とその他のSaaSについてはこの時点でstateを分けることにしたので、階層を下げました。 Google Cloudのルートモジュールについては単一のtfファイルでモジュールの呼び出しをしていたものを、アプリケーション単位でファイル分割します。 これは将来的にstateを分割することも考慮しています。

こうすることで、前述の GCSバケットとIAMの設定については同一モジュール内で定義が済むことになります。

resource "google_service_account" "some_sa" {
  account_id   = "some_sa"
}

resource "google_storage_bucket" "some_bucket" {
  name          = "some_bucket"
  project       = var.project_id
  location      = "ASIA-NORTHEAST1"
  force_destroy = false
}

resource "google_storage_bucket_iam_member" "iam_member_example" {
  bucket = google_storage_bucket.some_bucket.name
  role   = "roles/storage.user"
  member = google_service_account.some_sa.member
}

outputもvariableも不要になり、すっきりします。

ですが、モジュールの構成を変えるというは単にソースコードを直せば良いというわけではありません。 どうやってこれを達成したかを次に解説します。

同一state内のリソースの移動

Terraformのリソース定義は、名称を変更するだけでも stateとの差分が発生するので リソースの削除と新規作成という結果になります。 これは、Terraformの仕様上しょうがない部分で、stateをtarraform state mv のようなコマンドで直接修正するという方法があります。

developer.hashicorp.com

ただコマンドによるstate の変更は、stateを直接更新してしまうので、試行錯誤しながら作業を進めていくのは難しいです。

Terraform 1.1 から moved block, import block, removed block という機能が提供されました。

developer.hashicorp.com

これは、stateの変更をしたい内容をルートモジュールに記載しておくことで、変更の結果を加味してplan/apply を行なってくれる機能です。 これを使えば、変更の結果を試行錯誤しつつ作業を進められます。

例えば、前述のgcs, iam モジュールの内容を service_a というモジュールに移動する場合、移動先のモジュールのソースコードを書いて、 次のような moved block を書きます。

moved {
  from = module.gcs.google_storage_bucket.some_bucket
  to   = module.service_a.google_storage_bucket.some_bucket
}

moved {
  from = module.gcs.google_storage_bucket_iam_member.iam_member_example
  to   = module.service_a.google_storage_bucket_iam_member.iam_member_example
}

moved {
  from = module.iam.google_service_account.some_sa
  to   = module.service_a.google_service_account.some_sa
}

movedブロックがある状態で planをすると、モジュールを変更した状態で比較が行われるので基本的に差分は無しになります。 名称のミスなどがあって差分が出た場合でも、安心して修正ができます。

余談ですが、この作業はモジュール内のリソースの一覧を出力するなどしてある程度は機械化できるのですが、結構大変でした。 当時はcopilotが登場したくらいの頃だったので、今ならAIツールでもっと賢くできるかもしれません。

以下の画像が一気にモジュール構成を変えた時のPRのサマリです。この量でもplan結果はほぼ差分なしでした。

補足 import, removed block

基本的には moved ブロックだけで事足りるのですが、作業を進めていく上でいくつか個別対処したことがあります。

1つめは、複数のモジュールで同一のリソースが定義されているというものでした。 IAM ロールを割り振る iam_member リソースは、色々なモジュールで定義されていて、モジュールの変更を見直していたら全く同じ内容が出てきて一つにマージする必要がありました。

単純にまとめてしまうと、片方のiam_memberリソースが削除扱いになるので場合によってはiam_memberが消えてしまう可能性もあります。 この場合、removed blockによってTerraformのstateでだけそのリソースを無かったことにします。

removed {
  from = modue.iam.google_storage_bucket_iam_member.some_member
  lifecycle {
    destroy = false
  }
}

destroy = false でGoogle Cloudからはリソースを削除しないとを明示することに注意します。

2つめは、Terraformで管理されていないリソースがある環境でだけあったというもので、これは import block でterraform stateに取り込みます。

import {
  to = module.service_c.google_service_account.some_sa
  id = "projects/${var.project_id}/serviceAccounts/some_sa@${var.project_id}.iam.gserviceaccount.com"
}

import は import したいリソースごとに id を指定します。idに何を書くかはリソースによって異なるので、ドキュメントを読んで正しいIDを指定することに注意します。

state分割でのリソースの移動

state を分割する場合、 前述の moved ブロックは使用できません。 state mv コマンドでモジュール単位で別のstate に移動していきます。

state の移動元と移動先それぞれで、removedブロック,importブロックを使えばひょっとすると代替できるかもしれません。 しかし import ブロックは上で述べた通りID指定が必須なので移動したいリソースのIDを列挙するのは困難なのでお勧めしません。

stateをまたいだリソースの移動は次の手順で行います。

  1. 移動元、移動先それぞれのルートモジュールでstateをローカルにダウンロードして、ローカルのstateを参照する
  2. stateまたぎでmoduleを移動する
  3. 両方のルートモジュールで plan を実行し、差分がなければローカルstateをリモートにpushする

次のスクリプトで1,2を自動化します。

#!/bin/bash

# 移動元ルートモジュールのパス
SRC=$1 
# 移動先ルートモジュールのパス
TARGET=$2
# スペース区切りでルートモジュール内の移動するmodule 名のリスト。 
MODUELS=$3

base_dir=$(pwd)

echo "SRC ローカルにstateをダウンロード" 
cd $SRC
terraform init
terraform state pull > ${base_dir}/${TARGET}/src.tfstate


echo "TARGET ローカルにstateをダウンロード" 
cd $base_dir
cd $TARGET
terraform init

terraform state pull > target.tfstate

# localのstateを使うように一時ファイルで上書き
cat << EOF > override.tf
terraform {
  backend "local" {
    path = "target.tfstate"
  }
}
EOF

# ローカルstateを使うようにinit をやり直す
terraform init -reconfigure


# モジュールリストごとにstateをmove
for module in $(tr ' ' '\n' <<< ${MODUELS})
do
    echo "move module.${module}"
    # state-out で移動先のstate ファイルを指定する
    terraform state mv -state=src.tfstate -state-out=target.tfstate module.${module} module.${module}
done

この状態で plan を実施して、差分がないようなら 次のスクリプトで更新後のstateを反映します。

#!/bin/bash

SRC=$1
TARGET=$2

base_dir=$(pwd)

echo "TARGET: リモートのstateを使うように設定し直して、state をpushする"
cd ${TARGET}
# push target state
rm override.tf
terraform init -reconfigure
terraform state push target.tfstate

echo "SRC: state をpushする"
cd ${base_dir}
mv $TARGET/src.tfstate $SRC/src.tfstate
cd $SRC

terraform state push src.tfstate

planのチェックもスクリプトで自動化してしまえば全作業が自動化できそうです。

現在そして将来

これまでの作業で、ある程度モジュールの独立性が確保されたため、Terraformのコード修正は比較的楽になりました。 その結果、Terraform コードが増えたので今は次のような問題を抱えています。

  • plan/apply にかかる時間が増えている, 3-5分かかっている
  • リソースが増えすぎていて、モジュールやリソースのオーナーがわかりづらくなっている

これらの問題についてどのように解決するかは現在進行形ですが、次のように考えています。

  • stateをアプリケーション単位で分割し、plan/applyを並列化する
  • ルートモジュールごとに default labelを付与して、生成されるリソースのオーナーがわかるようにする

stateを分割すると、state間の情報共有をどうするかやapplyの順序といった問題が出てきます。 多分完璧なやり方はないだろうと思っているので、ある程度の妥協をしつつ進めていくのかなと思っています。

何か良いアイデアをお持ちの方はぜひ教えてください。

まとめ

  • モジュールは関連の強いリソースでまとめましょう。そして関連は技術的な軸ではなく開発組織の軸で考えましょう
    • そこが思い浮かばないなら、無理にモジュールにしなくても良いです
  • モジュールの構成をしくじっても、どうにかなります。気合と根性で解決したことも今ならAIで楽になるはず
    • moved blockが使えなければ詰んでいたので、Terraformのアップデートは運用に組み込みましょう
  • これを見ているあなたもぜひ、我が家のTerraformの歴史を公開してみてください