PTAアドベントカレンダー2021の12/23の記事です。
12/23/21
株式会社AbemaTVでABEMAの広告プロダクト開発チームでSREをしているKosakaと言います。
元々はデザイナー・サーバサイドエンジニアをしていましたが、最近はクラウドネイティブな技術やパブリッククラウドと戯れています。
さて、ABEMAやアメーバをはじめ、サイバーエージェントが運営する各メディアをプラットフォームとした広告プロダクト・広告配信システムを開発しているチームが社内にはいくつかあります。
それらを横軸で束ねる、PTA(Publisher adTech Associations)というアライアンスが立ち上がり、所属エンジニアやビジネス職のメンバーを中心に、広告配信を支える技術や広告業界のドメイン知識に関する情報共有、勉強会、公開イベントなどを定期的に開催しています。
今回はそのPTAのアドベントカレンダー2021のエントリとして記事を書きたいと思います。
あらすじ
現状の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ファイルが格納されています。
## 一部分を抽出
.
├── 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
に格納しています。
## 一部分を抽出
.
├── 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
の各環境用ディレクトリ配下の一般的なファイル構成は次のとおりです。
## 一部分を抽出
.
├── 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のディレクトリ構造をベースにしています。
## 一部分を抽出
.
├── 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
することで環境毎のボイラープレーティングを自動化しています。
## 末端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でのコレクションの展開例
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への切り替え
を行うことで手動で行っていた作業を抹殺しました。
## ルート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リソース自体に変更の必要ないので安心感があります。
モノレポ化によっていくつもエディタを開いて往来しながら作業する必要もなくなりリードタイムの削減ができそうです。