9つのGoogleCloudプロジェクトをTerraform化する過程で学んだことを公開します part②
はじめに
こちらの記事は 前回の続き になります。
この記事でわかること
- (ざっくり)Terraform がどのように動いているのか
- 複数プロジェクトを管理する場合のディレクトリ構成
- 複数プロジェクトを管理する場合のモジュールの設計
- terraform-docs を活用したドキュメントの作成方法
学び ⑥ Terraform がどのように動いているのかを理解する
ここでは Terraform がどのように動いて GoogleCloud などのリソースを操作しているのか大枠を理解できるようにしたいと思います。
まずは全体のイメージを掴んでみます。
出典: Plugin Development
似たような画像も見つけたので併せて貼り付けておきます。
出典: Terraform Architecture Overview – Structure and Workflow
Terraform の動作について 公式ドキュメント では次のように述べれられています。
Terraform は、アプリケーション プログラミング インターフェース (API) を通じて、クラウド プラットフォームやその他のサービス上のリソースを作成および管理します。プロバイダーにより、アクセス可能な API を持つほぼすべてのプラットフォームやサービスで Terraform が動作できるようになります。
どうやら Provider というものを使い API を通じてリソースを操作しているようです。
ここでそれぞれの要素が何を表しているのかを整理して見ます。
まずは Terraform 本体から。
Terraform Core
Terraform 本体 は Terraform Core と呼ばれ Terraform のコマンドラインツール(CLI) を提供しています。オープンソースなので GitHub リポジトリ にもアクセスできます。
公式ドキュメント では次のように書かれています。
この辺りは理解しやすいかと思います。
Terraform Core は、Go プログラミング言語で記述された静的にコンパイルされたバイナリです。コンパイルされたバイナリは、Terraform を使用するすべての人にとってのエントリポイントとなるコマンド ライン ツール (CLI) です 。
Terraform Core の機能としては次のように 公式ドキュメント で挙げられています。
- コードとしてのインフラストラクチャ: 構成ファイルとモジュールの読み取りと補間
- リソース状態管理
- リソースグラフの構築
- 計画の実行
- RPC 経由のプラグインとの通信
もう少し詳しく見てみます。Terraform Core のアーキテクチャに関しては Terraform Core Architecture Summary として readme ファイルに記述されています。
Terraform は実行されるたびにいくつかの初期設定が行われたあと、ユーザーが指定したコマンドを実行します。コマンドについては command パッケージ で各コマンドの処理が記述されています。またこれらの処理と指定したコマンドをマッピングしているのがルートにある commands.go になります。ソースコードを見ると次のようにマッピングされていることが分かります。
Commands = map[string]cli.CommandFactory{
"apply": func() (cli.Command, error) {
return &command.ApplyCommand{
Meta: meta,
}, nil
},
"plan": func() (cli.Command, error) {
return &command.PlanCommand{
Meta: meta,
}, nil
},
...
}
そのためユーザーが指定したコマンドで Terraform を実行すると commands.go
を通じてマッピングした処理を呼び出して処理を行うことができます。
Terraform Plugins
次にプロバイダについてみていくのですが、プロバイダは Terraform Plugins として Terraform をサポートしているので plugins について整理します。
公式ドキュメント に書かれているものを一部抜粋します。
Terraform プラグインは Go で記述されており、RPC 経由で Terraform Core によって呼び出される実行可能バイナリです。各プラグインは、AWS などの特定のサービスや bash などのプロビジョナの実装を公開します。Terraform 構成で使用されるすべてのプロバイダーとプロビジョナはプラグインです。これらは別のプロセスとして実行され、RPC インターフェース経由でメインの Terraform バイナリと通信します。Terraform にはいくつかのプロビジョナが組み込まれていますが、プロバイダーは必要に応じて動的に検出されます
Terraform Plugins は Terraform の拡張機能というイメージが近いと思います。
現在はプロバイダとプロビジョナと呼ばれる拡張機能があり、Terraform Core から RPC 経由で呼び出されます(RPC については詳しくないので省きます)。
今回はプロバイダに焦点を当てて見ていきます。
プロバイダについては 公式ドキュメント では次のように書かれています。
プロバイダーにより、Terraform はクラウド プロバイダー、SaaS プロバイダー、およびその他の API と対話できるようになります。Terraform 構成では、Terraform がインストールして使用できるように、必要なプロバイダーを宣言する必要があります。また、一部のプロバイダーは、使用する前に構成 (エンドポイント URL やクラウド リージョンなど) が必要です。
これらのプロバイダは無数にあります。公開されているプロバイダのほとんどは Terraform Registry というプロバイダの提供元で確認することできます。よく使われるものではクラウドプロバイダの Azure、AWS、GoogleCloud などがありますがそれ以外にも GitHub などの SaaS プロバイダも存在します。
Terraform はこれらのプロバイダをインストールして、それぞれのサービスやプラットフォームが提供している API とコミュニケーションをとりリソースの操作できるようになっています。
また Terraform Architecture Overview – Structure and Workflow では次のように書かれています。
各プロバイダーは、特定のサービス内で Terraform が管理できるリソースを定義し、Terraform 構成をそのサービスに固有の API 呼び出しに変換する責任を負います。
これは例えば次のように「プロジェクトの Cloud Run API を有効化する」というリソースを定義したときにこれを API を呼び出す処理に変換するということですね。プロパイダにはその責任があるということです。
resource "google_project_service" "testdayo" {
provider = google-beta
project = xxxxxxx
disable_on_destroy = true
service = "run.googleapis.com"
}
次にプロバイダのインストールについて整理します。
インストールは Terraform CLI が提供している $ terraform init で初期化した際に、プロバイダに関する情報をソースコードから見つけてインストールしています。
では Terraform はどうやって「どのプロバイダのどのバージョンをインストールするか」という情報を見つけてインストールしているのでしょうか。
それは required_providers
ブロックを見て判断しています。
source
はプロパイダの提供元である外部レジストリを指すアドレスで version
はインストールするプロバイダのバージョンを指定します。
例えば次のような場合だと version 6.2.0
の Google Provider
をインストールしていることがわかります。
terraform {
required_version = "1.9.3"
required_providers {
google = {
source = "hashicorp/google"
version = "6.2.0"
}
}
}
プロバイダをインストールすると、実行したディレクトリ配下に .terraform.lock.hcl
という ロックファイル が作成されます。またプロバイダのバイナリは .terraform
ディレクトリの配下にインストールされます。
.
├── .terraform
│ └── providers
│ └── registry.terraform.io
│ └── hashicorp
│ └── google
│ └── 6.2.0
│ └── darwin_arm64
│ ├── LICENSE.txt
│ └── terraform-provider-google_v6.2.0_x5
├── .terraform.lock.hcl
└── xxxx.tf
ロックファイルはプロバイダの依存関係や互換性といった情報が次のような形で記録されます。
provider "registry.terraform.io/hashicorp/google" {
version = "6.2.0" // プロバイダのバージョン
constraints = "6.2.0" // terraformが実際には参照しない値。人間向けにバージョン制約を示しているとのこと。
hashes = [ // プロバイダーの配布物のハッシュ値のリスト
xxxxxxxxxxxxx,
xxxxxxxxxxxxx,
xxxxxxxxxxxxx,
...
]
// hashes: インストールする各パッケージが、ロックファイルに以前に記録されたチェックサムの少なくとも 1 つと一致するかどうかを確認し一致しなければエラーを返す
}
ここまででプロバイダについて整理してきて次のような特徴を持っていることが分かると思います。
- Terraform は特定のサービスに依存していない
- Azure、AWS、GoogleCloud をはじめとした無数のプロバイダを通して各サービスを Terraform で管理できる
- プロバイダを利用することで基盤となるサービスやプロバイダに関係なく、一貫性があり再現可能な方法でインフラストラクチャを維持できる
- インストールしてリソースを定義すると Terraform ユーザー共通の処理を実現できる。プロバイダがこの部分を抽象化している
- プロバイダ間でコードの書き方が変わらない。どれも HCL で各ブロックを定義していく形。ここもプロバイダが抽象化してくれている
- Terraform Core 自体は GoogleCloud や AWS などの情報は持っていないので直接やり取りをする術を持っていない。そのためプラグインに依存する
- プロバイダを使うことで API を通してリソースを操作できるようになる
ここまでで Terraform Core(本体)と Terraform Plugins についてと両者の関係性がなんとなく理解できたのではないでしょうか!
「Terraform ってこんな感じで動いてるんだな」と少しでも理解してもらえると嬉しいです!
学び ⑦ ディレクトリ構成とモジュールの設計について
このディレクトリ構成/モジュール設計は今回の移行作業で一番悩んだところかもしれません。
「どんなディレクトリ構成にした方が良いのか」「モジュールには何を切り出すのか」「モジュール配下のファイルの単位は何で分けるのか」「そもそもモジュールを使った方が良いのか」などの悩みポイントがありました。
ディレクトリ構成
まず結論からですが次のような構成にしました。
前提として 1 サービスに dev / staging / production の 3 つのプロジェクトが存在しています。
この構成は今回のような環境毎にプロジェクトが分かれているパターンではよくあるものだと思います。
見てもらえると分かるように environments ディレクトリで環境ごとにフォルダを分けたのと、module というフォルダを作りその配下に gcp
と firebase
フォルダを作るようにしました。わざわざ module の中に作って階層を深くする必要もないと思うのですが module に切り出していることが明示的に分かった方が良いかなと思いこのようにしています。
.
├── environments
│ ├── development
│ │ ├── README.md
│ │ ├── locals.tf
│ │ ├── main.tf
│ │ ├── outputs.tf
│ │ └── variables.tf
│ ├── production
│ │ ├── README.md
│ │ ├── locals.tf
│ │ ├── main.tf
│ │ ├── outputs.tf
│ │ └── variables.tf
│ └── staging
│ ├── README.md
│ ├── locals.tf
│ ├── main.tf
│ ├── outputs.tf
│ └── variables.tf
└── modules
├── firebase
│ ├── README.md
│ ├── xxxxxxxx.tf
│ ├── xxxxxxxx.tf
│ ├── xxxxxxxx.tf
│ └── variables.tf
└── gcp
├── README.md
├── xxxxxxxxx.tf
├── xxxxxxxxx.tf
├── xxxxxxxxx.tf
└── variables.tf
モジュール設計
そもそも Terraform のモジュールとは何なのでしょうか。
次のように 公式ドキュメント で記述されています。
モジュールは、一緒に使用される複数のリソースのコンテナです。モジュールを使用すると、軽量の抽象化を作成できるため、インフラストラクチャを、物理オブジェクトの観点から直接記述するのではなく、アーキテクチャの観点から記述できます。
もう少しわかりやすく噛み砕いていきたいと思います。
例えば今回のような同一サービス内で環境毎にプロジェクトがある場合、ほとんどは同じようなリソースを使ったり、同じような設定になるはずです。
例えば pubsub のトピックを作る場合はどのプロジェクトでも次のように作っています。
これを各プロジェクト毎で書いていったらシンプルにコードの記述量が増えます。一回リソースブロックを書いたらコピペするので手間がかかります。
また仮にトピック名を topic-b
に変えることになった場合同じ修正をプロジェクト毎にする必要が出てきます。
resource "google_pubsub_topic" "testdayo" {
name = "topic-a"
project = "xxxxxxxxxxx"
}
なので、できれば各プロジェクト共通で一つのリソースブロックを使いまわせるようにして、project
だけは変数で動的に設定できるようにしたいです。
Terraform においてはこのようなコードは Terraform モジュールに入れてそのモジュールをコード内の複数の場所で使いまわすことが可能です。
次のように修正することで再利用可能なモジュールが作れます。
Terraform を実行するディレクトリにあるファイルはルートモジュールと呼ばれ、ルートモジュールから再利用する子モジュールを呼び出す形です。
ルートモジュール
// service-A/environments/development/main.tf
module "gcp" {
source = "../../modules/gcp" // 再利用可能なモジュールへのパス
project_id = "development-xxxxxx" // 動的に設定する値
}
// service-A/environments/staging/main.tf
module "gcp" {
source = "../../modules/gcp" // 再利用可能なモジュールへのパス
project_id = "staging-xxxxxx" // 動的に設定する値
}
子のモジュールで受け取る変数の定義
// service-A/modules/gcp/variables.tf
variable "project_id" {
description = "プロジェクトID"
type = string
}
子のモジュールで再利用可能なリソースブロックの定義
// service-A/modules/gcp/pubsub.tf
resource "google_pubsub_topic" "testdayo" {
name = "topic-a"
project = var.project_id // 変数で動的に設定できるようにする
}
このようにモジュールを上手く使うことですっきりとした構成にできました。
今回は GoogleCloud と Firebase のリソースを使用していたので gcp
と firebase
に分けています。そもそも直接触れる管理画面が違うので、それに合わせてディレクトリも分けるようにしたかったのと、GoogleCloud の storage と Firebase の storage のように似たようなリソース名が同じディレクトリにあるのは避けたかったのが背景としてあります。
またモジュールのファイルの単位は1 ファイルに複数リソースが紐づく形にしています。
公式ドキュメント にもあるように単一のリソースタイプを薄くラップするだけのモジュールを作成することは推奨されておらず過度に使用しないように注意が必要です。
とはいえ、なんでもかんでもリソースを詰め込むのではなく、例えば先ほどの pubsub の場合であれば同一ファイル内に topic と subscription のリソースを定義することで pubsub の機能として動作するモジュールを作るようにする、といった単位でファイルを構成するようにしました。
学び ⑧ 運用を考えたドキュメント作成とスタイルガイド
スタイルガイドとベストプラクティスについて
Terraform では 公式のスタイルガイド が出ています。コードを書く際はこちらに従う形でコーディングを行いました。
また Terraform では GoogleCloud が提供している ベストプラクティス も存在します。ディレクトリの切り方など参考になるところは基本的にこちらに則って移行を進めました。
tarraform-docs を使ったドキュメントの自動生成
part① の記事 でも書いたように IaC でインフラを管理するとコード自体がインフラ構築の手順書となります。
構築の手順書はありますが、例えばモジュールに渡している変数の値や説明、アウトプットしている値や説明などはすぐに理解できるようにしておいた方が運用が楽になると思います。
特に今回は自分一人で移行作業を行ったため、今後の運用を考えるとより丁寧に行う必要がありました。
今回はこれを実現するためのツールとして terraform-docs を使うようにしました。
このツールを使うと既存のコードからマークダウン形式である程度形の整ったドキュメントを自動生成できます。
基本的に使うメリットとしては次のようなものが挙げられるかなと思います。
- コードを基に自動生成するのでコードを同期した状態の最新のドキュメントができる
- 手作業で作成すると大変な作業を自動でやってくれる
- CI に組み込める
やることは簡単で、terraform-docs をインストールしてドキュメントを生成したいディレクトリで次のコマンドを打つだけです。基本的なドキュメント生成であればこれで十分だと思います。
$ terraform-docs markdown table --output-file README.md --output-mode inject .
markdown
の部分は出力するフォーマットを指定しますが Markdown 以外にも toml / YAML / json など様々なフォーマットを選択できます。
table
の部分は document
も選択できます。どちらにするかは個人の好みになるかと思います。table にするとリソース名や変数名、説明などがテーブル形式で出力されるので見やすいです。
--output-file
は出力先のファイルパスを指定します。この場合はカレントディレクトリに README.md ファイルを作成します。
--output-mode
はファイルの置き換える範囲を指定します。デフォルトは inject なので <!-- BEGIN_TF_DOCS -->
<!-- END_TF_DOCS -->
のブロックの中に出力され以降もドキュメントに変更があった場合はこのブロック内のみが上書きされます。replace
も指定できますが、その場合はファイル内の全てを上書きするので注意が必要です。
完成イメージは 公式ドキュメント の方で確認できます。
自動生成したドキュメントは ベストプラクティス にあるようにルートモジュールと切り出したモジュールそれぞれに README.md ファイルとして配置するようにしました。
その他のドキュメント
その他にもセットアップに関するドキュメントや運用する上でのポイントなどを載せたドキュメントも追加で必要です。
これらは ベストプラクティス に従い docs ディレクトリを作成してそこでまとめて管理するようにしました。
参考記事
terraform-docs でドキュメントの自動生成
Terraform 公式がスタイルガイドを出したので読んで要約した
How does Terraform Work
Terraform Architecture Overview – Structure and Workflow
【Terraform】.terraform.lock.hcl について理解する
Discussion