最近ハマっているterraformプロジェクト構成

こんにちは。Engineering Managerの小林です。

今日は最近一番使い勝手がいいと思っているterraformのプロジェクト構成を公開します。

dev(development)、stg(staging)、prd(production)の3環境がある想定で進めていきます。

今回の構成で実現したいこと

各環境で共通のリソースをDRYに書きたい

とても一般的な要求ですね。dev, stg, prdの各環境があるときに、google_cloud_run_v2_serviceを3個もコピペしたくありません。

1サーバーならまだ良いですが、10個もサーバーがあったりすると大変なことになります。

これを実現するためにterraform workspaceがアンチパターンなのは有名ですね。

ドキュメント にもこの用途には向かないと書かれています。

各環境で一部構成が違う

terraform workspaceが環境の切り替えに向かない理由がこれです。

  • prod環境はWAFを設定したいけど、dev環境は設定したくない。費用がかかるので、設定での制御ではなくリソースをそもそも作りたくない
  • コスト節約のためにdevとstageでリソースを使いまわすので、全く同じtfファイルでは困る
  • dev環境は特殊な制御が必要でLBの構造が若干違う
  • 権限管理が複雑で、変数の記述だけでは制御しきれない

などなど、コスト節約や特殊な事情で一部環境だけ若干違うことはあるあるかと思います。

環境ごとの差分が分かるようにしたい

例えば、典型的なAPIサーバーをCloud Runで動かすとき、大まかな構成は環境ごとに差分がないと思いますがCPUの割当量だけ変えたいということはよくあると思います。

このとき、

const dev = new HogeServices({apiCPU: "1"});
const stg = new HogeServices({apiCPU: "1"});
const prd = new HogeServices({apiCPU: "2"});

のようになっているとAPIサーバーのCPU割当量以外の差分が無いことが一目でわかり便利です。

ある程度の粒度でstate分割をしてplanの時間を削減したい

リソースの数が増えてくるとplanの時間が延びてDXが顕著に悪くなります。

これの対応策としてstateを分割するのが一案です。

ネットワーク構成なんてそうそう変わらないのに毎回planするのは無駄ですね。

提案手法

(root)
  ├ modules/           ... 汎用的に使えるモジュール
  │   ├ some_module/
  │   └ .../
  │
  ├ templates/            ... 全環境で使用するリソースを **moduleとして** 定義する。オブジェクト指向に例えると、ここでclassを定義して
  │   ├ some_template/    ./projects 配下でインスタンスを生成する。variableには環境依存な値のみが存在することが理想。
  │   └ .../
  │
  └ projects/             ... ここの子ディレクトリが1環境に対応
      │
      ├ dev/            ... planにかかる時間を減らすためにproject配下にディレクトリを生やしても良い。 *1
      │  ├ common/            ... ここでterraformコマンドを実行する
      │  │  ├ main.tf            ... terraformコマンドを実行するためのprovider定義など
      │  │  ├ hoge_template.tf            ... templates/ で定義したmoduleをここで呼ぶ
      │  │  └ fuga.tf            ... 特定の環境にしか無いリソースはここで書く
      │  │
      │  ├ component_1/
      │  │  ├ main.tf
      │  │  └ fuga_template.tf
      │  │
      │  └ .../
      │
      ├ stg/
      │  ├ common/
      │  │  ├ main.tf
      │  │  └ hoge_template.tf
      │  │
      │  ├ component_1/
      │  │  ├ main.tf
      │  │  └ fuga_template.tf
      │  │
      │  └ .../
      └ .../

modules

ここには汎用的に使えるものを置きます。

modulesはアンチパターンのような言説をたまに見かけますが、ユースケースにオーバーフィットしすぎなければ便利に使えます。

例えば、Google CloudでCloud Runをロードバランサーの下に置こうとすると、毎回以下のような記述を書かされます。

resource "google_compute_region_network_endpoint_group" "this" {
  name                  = var.name
  network_endpoint_type = "SERVERLESS"
  region                = var.region
  cloud_run {
    service = var.cloud_run_service_name
  }
}

resource "google_compute_backend_service" "this" {
  name                  = var.name
  protocol              = "HTTPS"
  load_balancing_scheme = "EXTERNAL_MANAGED"

  backend {
    group = google_compute_region_network_endpoint_group.this.id
  }
}

以下のように書けると便利です。

module "api" {
  source                 = "../modules/lb_cloud_run"
  name                   = var.name
  cloud_run_service_name = var.name
}

templates

ここでは、各環境に必要なリソースをmodule形式で定義します。 先述した

const dev = new HogeServices({apiCPU: "1"});
const stg = new  HogeServices({apiCPU: "1"});
const prd = new  HogeServices({apiCPU: "2"});

のHogeServicesを定義するイメージです。

moduleのvariableで環境ごとに異なる値を渡しましょう。

ここで大事なのは各環境の差分を過剰に表現しないことです。

moduleの渡されるvariableを見て内部でどんな制御が起きるか分からないものはbadです。

moduleのvariableは各環境のattributeを定義しているのであって、内部のresource作成を制御しているわけではないというイメージが良いと思います。

projects/XXX

ここでいよいよリソースを定義していきます。

templates/ で作成したmoduleを実体化しましょう。また、環境依存のリソースもここで記述します。

ケーススタディ

急にAWSの話になってしまいますが、インフラコスト削減のためにALBをdevとstageで使いまわすケースを考えてみましょう。

このとき、ALBの定義をtemplatesに入れると当然2つのALBが作られてしまうので、templatesにはサーバーの定義だけ入れておいて、projects/ でALBを定義します。

templates/hoge_service/main.tf

resource "aws_lb_target_group" "this" {
  name        = var.name
  ...
}

resource "aws_ecs_service" "this" {
  name            = "api"
  ...
  load_balancer {
    target_group_arn = aws_lb_target_group.this.arn
    ...
  }
}

resource "aws_lb_listener" "https" {
  load_balancer_arn = var.alb_arn
  ...
}

resource "aws_lb_listener_rule" "this" {
  listener_arn = aws_lb_listener.https.arn
  priority     = 1000

  action {
    type             = "forward"
    target_group_arn = aws_lb_target_group.this.arn
  }
}

projects/dev/main.tf

module "hoge_service" {
  source  = "../../templates/hoge_service.tf"
  env     = "dev"
  alb_arn = aws_lb.this.arn
}

resource "aws_lb" "this" {
  name  = var.name
  ...
}

projects/stage/main.tf

module "hoge_service" {
  source  = "../../templates/hoge_service.tf"
  env     = "stage"
  alb_arn = data.aws_lb.this.arn
}

data "aws_lb" "this" {
  name  = var.name
}

紐づけるALBのarnを外から渡すことで、サーバーの定義が最大限使いまわせています。

ステート分割

projects/ 配下が実際にplan/applyを行うディレクトリになるので、必要であればここでディレクトリを分けてstateを分割します。

あまり大きなサービスでなければ、commonというディレクトリにネットワーク定義やNAT定義などの低レイヤーであまり変更がないものを入れて、別のディレクトリにサーバーなどの変更が多いコンポーネントを置くのがお勧めです。

余談ですが最近、今更ながら terraform_remote_state の存在を知ったので、既存のterraformを書き換えたいなと思っています。

まとめ

terraformは各社さまざまな運用があると思いますので、俺の考えた最強のterraform構成の公開お待ちしております(昔そんなブームあった気もする)

告知

2025年12月9日(火)に、弊社のオフィスにて交流イベントLegalscape Nightを開催します。

当日は、私たちが普段どんなツールを使い、どのように開発を進めているのかを気軽にお話しできる場にしたいと思っています。 ご応募は以下のページからお待ちしております!

legalscape.notion.site

※Legalscapeテックブログ経由の旨を参加フォームからご登録いただきますようお願いいたします。 参加お申し込みフォームからお申し込みいただいた後、担当より参加確定メールをお送りいたします。応募者多数の場合、ご参加いただけない場合ございますこと大変恐縮ですがご了承ください。その際もご連絡いたします。