『Terraform と gcloud CLI を使用した完璧な Google Cloud インフラストラクチャの構築』は本当に完璧なのかやってみた

はじめに

SREチームの川岡です。
もうそろそろコミューンのインフラをコード化しなきゃと考えていたときに、Google Cloudのブログで『Terraform と gcloud CLI を使用した完璧な Google Cloud インフラストラクチャの構築』という記事を見ました。
「完璧」という言葉に惹かれ、上の記事を参考にして自社プロダクトで検証してみたので紹介します。
なお、Terraformの概要や用語は説明しませんのでご了承ください。

コミューンのインフラにおける課題

わたしたちのプロダクト、commmune(コミューン)|コミュニティサクセスプラットフォームは、ベータ版としてサービスをリリースしてから3年ほど経っています。
リリース当初はシンプルなアーキテクチャーでしたが、利用者の増加に伴う増強、その時々の要求に応じてインフラの増築を重ねたことで以前に比べて複雑になっています。
アーキテクチャーの変遷については以前の記事、『コミューンのアーキテクチャ選定』をご覧ください。

手で増改築してきたことで以下の課題が顕著になってきました。

  • 再現性が低い
    • たとえば開発環境とほぼ同じの構成のフィーチャー環境を新たに構築しようとすると職人技を再現するのに苦労する。
    • ゴールデンイメージだと思っていたものが実は違って手で設定変更しなければならなかった。
  • 統一性がない
    • たとえばCloud Load BalancingのURLマップが環境ごとに微妙に異なっていることがわかり手動で差分を埋めなければならなかった。
  • 運用管理コスト
    • 入社後しばらく経った新メンバーから「GCPの権限ほしいです」と言われて、手でIAMユーザやグループの設定を行っている
    • 1つの環境でWAFのポリシーを追加すると全環境へ手作業で反映しなければならない。

IaCでこれらの課題を軽減できるはずです。それではやっていきます。

使ってみた

記事ではGoogle Cloud CLIによるTerraform向けの宣言型エクスポートのプレビュー版を使って既存のGCPリソースをTerraform化する流れが紹介されています。

ポイントはここです。

Google Cloud における IaC ワークフローで生じるずれの多くは、クラウド導入の開始時にそうしていない場合に、すべての Google Cloud リソースとその状態を記述した HCL Terraform ファイルを実際に作成する方法から生じるものです。たとえるなら、家が建ってしまってから、工程や順序に関する文書がないまま現場関係者が段階的に家の青写真を作成しようとするようなものです。宣言型エクスポートを使用すると、既存の Google Cloud リソースを Terraform に簡単に移行できます。また、誤った構成やずれからも保護できます。

どのような流れで進めるかは記事中の図がわかりやすいです。

今回は、gcloudコマンドで現在のGCPリソースのスナップショットを作成し、各リソースのTerraform用のHCLファイルを作成してterraform planを実行するまでを以下の流れで進めます。

  1. 既存のGCPのリソースをTerraform形式でエクスポートする
  2. main.tfを作成する
  3. 既存のGCPリソースをインポートする
  4. terraform planで実行計画を見る

既存のGCPのリソースをTerraform形式でエクスポートする

以下のコマンドを実行します。(Cloud SDKのインストールが必要です)

$ gcloud beta resource-config bulk-export --project=<エクスポートする対象のGCPプロジェクト名> --resource-format=terraform --path=./

Cloud Asset APIのエラーが出たので有効にしました。

別のGCPプロジェクトからコマンドを実行すると失敗したので、以下のコマンドでプロジェクトをセットしてから再度実行しました。

$ gcloud config set project <GCPのプロジェクト名>
Updated property [core/project].

5分ほどでエクスポートが完了しました。
ディレクトリが複数作成されるのでどこに何が定義されているかぱっと見わかりづらいです。

リソース単位で分割されるようですがコミューンでは使っていないリソースも含まれていました。
なお、利用しているGCPのリソースによって完了時間は変動します。ためしにコミューンの組織全体を指定してエクスポートしたところ1時間ほどかかりました。

.tfファイルの中身を見ると数行の定義とコメントアウトされたterraform import文が書かれているだけでした。

resource "google_project_service" "analytics_googleapis_com" {
  project = "*"
  service = "analytics.googleapis.com"
}
# terraform import google_project_service.analytics_googleapis_com */analytics.googleapis.com

コミューンが使っているCloud Runはエクスポートされませんでした。(バックエンドの定義はなぜかエクスポートされました)注1
その他、ComputeNetworkEndpointGroupは対応しているのですがエクスポートされずでした。

なお、今回はプロジェクトを指定してエクスポートしましたが、他にも組織やフォルダを指定することができます。

main.tfを作成する

以下のコマンドを実行します。

$ gcloud alpha resource-config terraform init-provider

providerの設定だけ書かれているシンプルなmain.tfが作成されました。

provider "google" {
  project = "*"
  region  = "asia-northeast1"
  zone    = "asia-northeast1-b"
}

既存のGCPリソースをインポートする

terraform importでGCPのリソースを.tfstateへインポートするためのスクリプト作成->スクリプト実行という流れです。
まずスクリプトを作成するため以下のコマンドを実行します。

$ gcloud beta resource-config terraform generate-import ./ --output-script-file=import.sh --output-module-file=modules.tf

これによりmodules.tfimport.shが作成されます。 module.tfには各モジュールのソースの場所が定義されていました。ここにもproviderが定義されているのでterraform initするとmain.tfとバッティングしてエラーがでるので片方はコメントアウトしました。

provider "google" {
  project = "*"
}

module "projects-*-ComputeAddress-asia-northeast1" {
  source = "./projects/*/ComputeAddress/asia-northeast1"
}

module "projects-*-ComputeFirewall" {
  source = "./projects/*/ComputeFirewall"
}

module "*-ComputeDisk-asia-northeast1-c" {
  source = "./*/ComputeDisk/asia-northeast1-c"
}

module "gcp-*-*-Project-LoggingLogSink" {
  source = "./*/*/Project/LoggingLogSink"
}

module "projects-*-ComputeSSLCertificate-global" {
  source = "./projects/*/ComputeSSLCertificate/global"
}
.
.
.
.
.

import.shにはGCPリソースをterraform importするコマンドがずらっと書かれていました。

terraform import module.gcp-*-Service.google_project_service.appengine_googleapis_com */appengine.googleapis.com
terraform import module.gcp-*-Service.google_project_service.bigquery_googleapis_com */bigquery.googleapis.com
terraform import module.gcp-*-Service.google_project_service.bigquerymigration_googleapis_com */bigquerymigration.googleapis.com
terraform import module.gcp-*-Service.google_project_service.bigquerystorage_googleapis_com */bigquerystorage.googleapis.com
terraform import module.gcp-*-Service.google_project_service.cloudapis_googleapis_com */cloudapis.googleapis.com
terraform import module.gcp-*-Service.google_project_service.cloudasset_googleapis_com */cloudasset.googleapis.com
terraform import module.gcp-*-Service.google_project_service.cloudbuild_googleapis_com */cloudbuild.googleapis.com
terraform import module.gcp-*-Service.google_project_service.clouddebugger_googleapis_com */clouddebugger.googleapis.com
terraform import module.gcp-*-Service.google_project_service.cloudfunctions_googleapis_com */cloudfunctions.googleapis.com
terraform import module.gcp-*-Service.google_project_service.cloudresourcemanager_googleapis_com */cloudresourcemanager.googleapis.com
terraform import module.gcp-*-Service.google_project_service.cloudscheduler_googleapis_com */cloudscheduler.googleapis.com
terraform import module.gcp-*-Service.google_project_service.cloudtasks_googleapis_com */cloudtasks.googleapis.com
terraform import module.gcp-*-Service.google_project_service.cloudtrace_googleapis_com */cloudtrace.googleapis.com
terraform import module.gcp-*-Service.google_project_service.compute_googleapis_com */compute.googleapis.com

次にterraform initを実行します。

$ terraform init

最後にスクリプトを実行します。注2

$ ./import.sh 

GCPのリソースがterraform.tfstateへ追記されていきます。

module.gcp-*-Service.google_project_service.appengine_googleapis_com: Importing from ID "*/appengine.googleapis.com"...
module.gcp-*-Service.google_project_service.appengine_googleapis_com: Import prepared!
  Prepared google_project_service for import
module.gcp-*-Service.google_project_service.appengine_googleapis_com: Refreshing state... [id=*/appengine.googleapis.com]

Import successful!

The resources that were imported are shown above. These resources are now in
your Terraform state and will henceforth be managed by Terraform.

module.gcp-*-Service.google_project_service.bigquery_googleapis_com: Importing from ID "*/bigquery.googleapis.com"...
module.gcp-*-Service.google_project_service.bigquery_googleapis_com: Import prepared!
  Prepared google_project_service for import
module.gcp-*-Service.google_project_service.bigquery_googleapis_com: Refreshing state... [id=*/bigquery.googleapis.com]

Import successful!
.
.
.
.

利用しているGCPリソースの種類や量によってスクリプトの実行が完了するまで時間が変わります。
ちなみにコミューンの本番環境で試したところ約7時間かかりました(笑)
コミューンは利用しているBig Queryのデータセットやテーブル、Cloud Storageのバケット数が多いからだと思います。(Big Queryのterraform import文がimport.shに2000行以上ありました…)
最終的にterraform.tfstateは13万行を超えました…!

なお、Cloud Logging ログルーターのインポートは失敗します。

module.gcp-*-Project-LoggingLogSink.google_logging_log_sink.a_required: Importing from ID "*###_Required"...

Error: unknown resource type: google_logging_log_sink

リソースタイプが不明というエラーですが変更されたのでしょうか。
Terraformのドキュメントを見るとgoogle_logging_project_sink が一番近いように思いました。
registry.terraform.io

terraform planで実行計画を見る

terraform planを実行します。
「完璧」なコードが作成されることを期待しましたが、エラーが出ました...
主なエラーを4つ見ていきます。

1. google_compute_route
╷
│ Error: Invalid combination of arguments
│ 
│   with module.projects-*-ComputeRoute.google_compute_route.peering_route_*,
│   on projects/*/ComputeRoute/peering-route-*.tf line 1, in resource "google_compute_route" "peering_route_*":
│    1: resource "google_compute_route" "peering_route_*" {
│ 
│ "next_hop_ip": one of `next_hop_gateway,next_hop_ilb,next_hop_instance,next_hop_ip,next_hop_vpn_tunnel` must be specified
╵
╷
│ Error: Invalid combination of arguments
│ 
│   with module.projects-*-ComputeRoute.google_compute_route.peering_route_*,
│   on projects/*/ComputeRoute/peering-route-*.tf line 1, in resource "google_compute_route" "peering_route_*":
│    1: resource "google_compute_route" "peering_route_*" {
│ 
│ "next_hop_gateway": one of `next_hop_gateway,next_hop_ilb,next_hop_instance,next_hop_ip,next_hop_vpn_tunnel` must be specified
╵
╷
│ Error: Invalid combination of arguments
│ 
│   with module.projects-*-ComputeRoute.google_compute_route.peering_route_*,
│   on projects/*/ComputeRoute/peering-route-*.tf line 1, in resource "google_compute_route" "peering_route_*":
│    1: resource "google_compute_route" "peering_route_*" {
│ 
│ "next_hop_vpn_tunnel": one of `next_hop_gateway,next_hop_ilb,next_hop_instance,next_hop_ip,next_hop_vpn_tunnel` must be specified
╵
╷
│ Error: Invalid combination of arguments
│ 
│   with module.projects-*-ComputeRoute.google_compute_route.peering_route_*,
│   on projects/*/ComputeRoute/peering-route-*.tf line 1, in resource "google_compute_route" "peering_route_*":
│    1: resource "google_compute_route" "peering_route_*" {
│ 
│ "next_hop_ilb": one of `next_hop_gateway,next_hop_ilb,next_hop_instance,next_hop_ip,next_hop_vpn_tunnel` must be specified
╵
╷
│ Error: Invalid combination of arguments
│ 
│   with module.projects-*-ComputeRoute.google_compute_route.peering_route_*,
│   on projects/*/ComputeRoute/peering-route-*.tf line 1, in resource "google_compute_route" "peering_route_*":
│    1: resource "google_compute_route" "peering_route_*" {
│ 
│ "next_hop_instance": one of `next_hop_gateway,next_hop_ilb,next_hop_instance,next_hop_ip,next_hop_vpn_tunnel` must be specified

原因は書いてあるとおりでnext_hop_*の定義が1つは必須のようです。
GUIコンソールでVPCルートのピアリング設定を確認し、next_hop_gateway = "servicenetworking-googleapis-com”を.tfファイルへ追加するとOKでした。

2. google_compute_ssl_certificate
│ Error: Missing required argument
│ 
│   on projects/*/ComputeSSLCertificate/global/*.tf line 1, in resource "google_compute_ssl_certificate" "***":
│    1: resource "google_compute_ssl_certificate" "***" {
│ 
│ The argument "private_key" is required, but no definition was found.
╵

SSL署名書の秘密鍵の定義が必要でした。
セルフマネージド証明書、Googleマネージド証明書ともに証明書のみインポートされました。
セルフマネージドの場合は秘密鍵ファイルを用意してprivate_key = file("./ca.key")などと定義すれば良いです。
しかし、以下サイトにも記載ありますが、証明書や秘密鍵を定義するということは.tfstate自体が機密情報になるので取り扱いは十分注意です。
www.terraform.io

Googleマネージド証明書の場合、秘密鍵はGCPが生成するため入手できないはずでどうすれば良いのわからずでしたが、そもそもリソースタイプが違う気がします。
セルフ、マネージドどちらもタイプはgoogle_compute_ssl_certificateになっていますが、マネージドの場合はgoogle_compute_managed_ssl_certificateが正しいのではないかと思います。(後者にはprivate_keyという識別子は無い)
registry.terraform.io
なお、Aレコードが異なるなどの理由でプロビジョニングされなかったGoogleフルマネージド証明書がそのまま残っているとterraform plan時にエラーになるため削除します。

3. google_storage_bucket
│ Error: Unsupported argument
│ 
│   on projects/*/StorageBucket/ASIA/*.tf line 12, in resource "google_storage_bucket" "*":
│   12:   public_access_prevention    = "inherited"
│ 
│ An argument named "public_access_prevention" is not expected here.

バケットの公開アクセスの防止の設定と思われます。(コンソールだと以下の箇所)

is not expected here とあるので定義場所の問題かと思いましたが変えても結果は同じでした。
以下で報告されていましたが定義を消すのが回避策ということ以上のことはわからなかったです。
issuetracker.google.com
試しに1つのバケットで.tfファイルから定義を消してterraform applyしたところ公開アクセスの防止設定が変わることはありませんでした。しかし公開に関わる設定なのであまり手を入れたくないですね。

4. google_logging_log_sink
│ Error: Invalid resource type
│ 
│   on */Project/LoggingLogSink/*.tf line 1, in resource "google_logging_log_sink" "*":
│    1: resource "google_logging_log_sink" "*" {
│ 
│ The provider hashicorp/google does not support resource type "google_logging_log_sink".
╵

ログルーターのシンクの定義のようですが、ログを見るとそもそもサポートされいないようです。もしかすると先ほど述べたgoogle_logging_project_sinkを使って手動で定義するのが良いかもしれないです。

検証結果

今回の検証で感じた良い点やGA版へ期待することです。

感想

一言でいうと今回の検証で作成できたものは「完璧ではなかった」ですが、数回コマンドを実行するだけで既存のGCPリソースをここまでTerraform化してくれるのは素晴らしいと思いました。

記事中に

このプレビュー リリースの現在の制限:
多くの Google Cloud リソースに対応していますが、すべてが対象ではありません

とあることから、現段階で完璧にするにはエクスポート未対応のGCPリソースを手動でインポートして既存リソースとの差分を埋めることが必要です。
また、差分を埋めた気になりterraform planが成功したとしても、本番運用前には別のGCPプロジェクトでterraform applyで本当に再現できたか確認する必要があるかなと思いました。
たとえば環境が壊れたときにterraform applyを実行したが再現できないなんてこともありえそうだなと。

今回の検証ではコミューンのインフラが抱える課題すべてを一度に解決することは難しいという結果になったのですが、実はTerraform化はSREチームの別のメンバーがCDK for Terraformを使って進めています。実際の運用フェーズではHCLではなくTypeScriptを書くことになりそうです。

良い点

  • gcloudコマンドが使えれば簡単にエクスポートできる
  • gcloudコマンドもterraform importもGCPアカウントで認証できる

GA版に期待すること

  • 対応するGCPリソースの増加
  • どのリソースがエクスポートできなかったを教えてほしい
    • あと何をインポートすれば完璧にできるのか見えるとうれしいです
  • エクスポート、インポートの速度アップ

さいごに

エンジニア募集中!

コミューンではSREを含めてさまざまなエンジニアを募集中です!
commmune-careers.studio.site

注釈

注1

以下のコマンドでgcloud buld exportに対応しているリソースが表示できました。
xが対応しているリソースですね。

$ gcloud beta resource-config list-resource-types --project=****
Listing exportable resource types for project [*****]...done.                                                                                                                                             
┌─────────────────────────────┬──────────────┬─────────┬──────┐
│           KRM KIND          │ BULK EXPORT? │ EXPORT? │ IAM? │
├─────────────────────────────┼──────────────┼─────────┼──────┤
│ BigQueryDataset             │ x            │ x       │      │
│ BigQueryTable               │ x            │ x       │ x    │
│ ComputeAddress              │ x            │ x       │      │
│ ComputeBackendService       │ x            │ x       │      │
│ ComputeDisk                 │ x            │ x       │ x    │
│ ComputeFirewall             │ x            │ x       │      │
│ ComputeForwardingRule       │ x            │ x       │      │
│ ComputeHealthCheck          │ x            │ x       │      │
│ ComputeImage                │ x            │ x       │ x    │
│ ComputeInstance             │ x            │ x       │ x    │
│ ComputeInstanceGroup        │ x            │ x       │      │
│ ComputeInstanceTemplate     │ x            │ x       │      │
│ ComputeNetwork              │ x            │ x       │      │
│ ComputeNetworkEndpointGroup │ x            │ x       │      │
│ ComputeResourcePolicy       │ x            │ x       │      │
│ ComputeRoute                │ x            │ x       │      │
│ ComputeRouter               │ x            │ x       │      │
│ ComputeSecurityPolicy       │ x            │ x       │      │
│ ComputeSnapshot             │ x            │ x       │      │
│ ComputeSubnetwork           │ x            │ x       │ x    │
│ DNSManagedZone              │ x            │ x       │      │
│ IAMServiceAccount           │ x            │         │ x    │
│ IAMServiceAccountKey        │              │         │      │
│ LoggingLogSink              │ x            │         │      │
│ MonitoringAlertPolicy       │ x            │         │      │
│ Project                     │ x            │ x       │ x    │
│ PubSubSubscription          │ x            │ x       │ x    │
│ PubSubTopic                 │ x            │ x       │ x    │
│ RedisInstance               │ x            │ x       │      │
│ SQLInstance                 │ x            │ x       │      │
│ SecretManagerSecret         │ x            │ x       │ x    │
│ SecretManagerSecretVersion  │ x            │         │      │
│ Service                     │ x            │ x       │      │
│ StorageBucket               │ x            │         │ x    │
└─────────────────────────────┴──────────────┴─────────┴──────┘

注2

import.sh実行時に以下のエラーが出ることがあります。

Error: Attempted to load application default credentials since neither `credentials` nor `access_token` was set in the provider block.  No credentials loaded. To use your gcloud credentials, run 'gcloud auth application-default login'.  Original error: google: could not find default credentials. See https://developers.google.com/accounts/docs/application-default-credentials for more information.

原因はログに書いてある通りでterraform importしたいが認証情報がないためなので以下を実行するとOKです。

$ gcloud auth application-default login

Your browser has been opened to visit:

    https://accounts.google.com/o/oauth2/auth?response_type=code&********

Credentials saved to file: [/*/.config/gcloud/application_default_credentials.json]

These credentials will be used by any library that requests Application Default Credentials (ADC).

Quota project "*" was added to ADC which can be used by Google client libraries for billing and quota. Note that some services may still bill the project owning the resource.

これはGCPに対してterraform importするために個人のGCPの認証情報をterraformコマンドへ付与しているのだと思います。