🔬

TerraformとCloud BuildでGCP向けのIaC環境を作成してみた

2021/12/08に公開

この記事はterraform Advent Calendar 2021の8日目です

TL;DR

  • エンタープライズ企業のベスト プラクティス」等にマッチしたTerraform環境の作成
  • CloudBuildとGitHubを使いGitOps/継続的デプロイ(CI/CD)を実現
  • Stateを分けるためにプロジェクトは環境単位に
  • グループや権限のアサインなどプロジェクト管理作業がTerraformにより大幅に効率化

はじめに

GCPでIaCと言うと、GCPネイティブのCloud Deployment Managerがあります。ただ、少し触った限りでは情報量が少ないのとYAML, Python, Jinjaが混在していて記述の揺れが多そうな印象を個人的に受けました。と言う分けで、こういう時はマスに乗るべきとTerraformを利用することにしたのですが、AWS等に比べると情報がやはり少ないです。そこで、Terraform入門をGCPですることになったので、その時の引っかかったところ等を踏まえながら現在作成している構成の解説をしたいと思います。

また、この記事は具体的にどのようなスクリプトでリソースを作るか、という点では無く「エンタープライズ企業のベスト プラクティス」や「共有VPC」にあるような実務的な構造を 「どう管理するか?」 という点にフォーカスをしています。その際にGoogle公式の「Terraform、Cloud Build、GitOps を使用してインフラストラクチャをコードとして管理する」は非常に役に立ちました。

今回作成しているコードは以下のGitHubにあります。
https://github.com/koduki/example-gcp-terraform
この環境の運用は始めたばかりなので、誤ってる点やアドバイスなどあれば非常に助かります。

アーキテクチャ

Overview

全体のアーキテクチャとしては以下の図のようにしています。コードはGithubに格納し、各ブランチへのPushをトリガーにCloud Buildが実行されます。

TerraformはCloud Buildの中で実行されることになるので、トリガーで利用するサービスアカウントにはプロビジョニングに必要な権限をあらかじめ付与しておく必要があります。

開発者がまずreviewブランチにデプロイすると、Cloud Build上でterraform planが実行され各環境のブランチへのPRも作られます。レビューアがPlanとコード差分を確認して問題なければ承認/Mergeを行いマージが完了すると実際の各環境へのデプロイが開始します。

最初の課題: どこで実行し、どこに保存するべきか?

まず最初に考えたのは 「どこで実行するのか?」 と言う事です。Terraformの入門記事の多くはローカルにTerraformをインストールして実行しています。入門記事なのでこれで良いのですが実運用では困ります。またTerraformの実行の状態はStateとしてファイルに保存されれます。これはTerraformの冪等性を維持するための超重要なファイルです。仮に実行専用のVMを作ってもそのVMのストレージを適切にバックアップないしはアーカイビングして壊れたときや以前の状態に戻したいときにリカバリできる必要がある。とてもめんどくさいですね?

もちろんTerraformではそんなことは考慮済みでStateの保存先に各クラウドベンダーのオブジェクトストレージを指定できます。。今回はGCPを使っているのでGCSですね。、Stateが実行者のローカルに保存されてしまう事はありませんし、バックアップやリカバリも容易です。

続いて実行環境ですがGCPにVMを置いて外部のCIからキックするとか、GitHub ActionやCircleCIなどにサービスアカウントの権限を渡して直接外部からの実行も出来たのですが、個人的に強い権限を外部には可能な限り出したく無いので、GCPのCloud Buildを使う事にしました。公式のドキュメントもそうしてますしね。下記の図で言うPattern 3です。

Cloud Buildを使う制約としてNativeでサポートしているコードリポジトリがGitHubとGCP Source Code Repositoryだけになってしまうという事です。BitBucketやGitLabなど別のコードリポジトリは直接参照できません。そのためそれらのリポジトリはCode Reposとミラーリングする事で対応する必要があります。オリジナルのリポジトリにPushしてから少しタイムラグが生まれてしまいますが、汎用的なアプローチだと思います。

どこから分ける? 環境とプロジェクト分割の話

続いて考えたのが 「どの単位でTerraformのプロジェクトを分割するか?」 という事です。Terraformを一度に実行する単位、と言い換えても良いでしょう。

何故これが重要なのかと言うと Stateの単位 とこれが直結するからです。先ほども言いましたが、TerraformのStateは超重要ファイル。これが壊れると 「プロビジョニングできなくなるか、意図しないリソースを消す」 という恐ろしい自体が発生します。開発環境の作業で本番環境を壊したりするとシャレになりませんよね? もちろん、きちんとコード差分やplanを見て修正する範囲を注意深くすれば大丈夫だとは思いますが、取扱いの難しいモノリスは将来負債になると決まっています。なのでいくつかの観点で分割しました。正解とか無い分野だと思うのですが、当面はこれで運用できそうかな、と。

前置きが長くなりましたが、以下のような構成にしています。

projects
├── 01_org
│   ├── 01_resources
│   │   ├── environments
│   │   │   └── all
│   │   └── modules
│   └── 02_groups
│       ├── environments
│       │   └── all
│       └── modules
├── 02_cmn
│   ├── sharedvpc-cmn
│   │   ├── environments
│   │   │   ├── dev
│   │   │   └── prd
│   │   └── modules
│   └── terraform-cmn
│       ├── environments
│       │   └── all
│       └── modules
└── 03_svc
    └── simpleweb-svc
        ├── environments
        │   ├── dev
        │   └── prd
        └── modules

まず大別してorg, cmn, svcにフォルダが分かれています。これは以下のような使いわけです。

Folder Name Full Name Description
01_org Organization プロジェクトの作成やグループの作成、権限の変更など組織全体に影響のある作業
02_cmn Common SharedVPCやCloud Buildなど複数のプロジェクトで共有して利用するプロジェクトに関する作業
03_svc Service 具体的なプロダクトがデプロイされるサービスプロジェクトに関する作業

これらのトップレベルフォルダーの下に、01_resources, 02_groups, sharedvpc-cmn, simpleweb-svcなどの具体的な処理の単位でフォルダを作成しています。そして、この単位で モジュールを共通化しています。この下がdev/prdなどの環境なのですが、どの環境に対しても同じモジュールを使い環境差異をパラメータとして埋めるという構成になっています。

environments配下は先述してる通り環境になり以下のような意味となります。

Environment Description
dev 開発環境プロジェクトに影響がある作業
prd 本番環境プロジェクトに影響がある作業
all 全ての環境に対して影響がある作業。原則、orgとcmnの一部にしか存在しない

またこのenvironmentsが本構成でのTerraformのプロジェクトの単位です。つまり環境単位でStateが分割されています。モジュールを共通化してるので多少ホイラーテンプレートは増えてしまいますが、devとprdの構成を意図的に変える事も出来ますし、GCP側のプロジェクトの単位とも一致しやすいので、このような構成にしています。Workspacesを使うのが王道かもですが現時点では分かりやすさ重視の構成

Build Pipline

ブランチとBuild Piplineの関係

Terraformのプロジェクト構成の次はブランチとBuild Pipelineに関して解説します。
一般的なアプリケーションのコード管理における開発環境 -> 本番環境の関係とは異なり、devprdはそれぞれ独立してmainの子になります。これは環境毎のコードベースが独立しているためです。もちろんモジュールは共通化しているのでdevで追加では無く振舞いの改修をしたときにはいきなりmainにマージすると事故るのでprdもケアする必要があります。運用で回避は出来ますが、この辺はもう少し仕組みを練りこむ必要がありそうです。

Branch Parent Commit Description
env/dev main OK 開発環境用のブランチ。ここにPushすると開発環境のデプロイが即座に走る
env/prd_review env/prd OK 本番環境レビュー用のブランチ。ここにPushすとprdに対してterraform planが実行されprdへのPRも作成される
env/prd main 禁止 本番デプロイ用のブランチ。PRとPlanをレビューしreviewブランチがマージされると本番環境へのデプロイが走る
env/all_review env/all OK all環境レビュー用のブランチ。ここにPushすとallに対してterraform planが実行されallへのPRも作成される
env/all main 禁止 allデプロイ用のブランチ。PRとPlanをレビューしreviewブランチがマージされると環境全体へのデプロイが走る

開発者は基本的にreviewブランチにのみPushします。各環境branchへの直接PushはNGです。reviewブランチにPushされるとCloud Buildでterraform planが実行され、PRを下記のようにGitHubに作成します。この際記載されているURLはCloudBuildのログとなっておりPlanの結果が表示されているのでコード差分と共にPlanを確認する事が出来ます。

トリガー一覧はこんな感じです。

なお、devだけはクイックにサイクルを回したいのでreviewブランチを作らずに直接コミットを今はしていますが、このあたりは人が増えてきたりGCPで扱うプロダクトが増えたら見直すべきだと思います。

branchによる処理の振り分け方法

さてreviewブランチかどうか、あるいは環境がどれであるか、こういった要素でビルドされる内容が変わっていますがどのように制御しているでしょうか? 答えは簡単でcloudbuild.yamlの中でbranch名を見て判断していります。以下がcloudbuild.yamlの抜粋となります。

- id: 'tf init'
  name: 'hashicorp/terraform:1.0.1'
  entrypoint: 'sh'
  args: 
  - '-c'
  - |
      if [ "$BRANCH_NAME" == "env/all_review" -o "$BRANCH_NAME" == "env/all" ]; then
...
        cd /workspace/projects/01_org/01_resources/environments/all
        terraform init || exit -1
        cd /workspace/projects/01_org/02_groups/environments/all
        terraform init || exit -1
...
      elif  [ "$BRANCH_NAME" == "env/prd_review" -o "$BRANCH_NAME" == "env/prd" ]; then
...
        cd /workspace/projects/02_cmn/sharedvpc-cmn/environments/prd
        terraform init || exit -1
...
      elif  [ "$BRANCH_NAME" == "env/dev_review" -o "$BRANCH_NAME" == "env/dev" ]; then
... 
        cd /workspace/projects/02_cmn/sharedvpc-cmn/environments/dev
        terraform init || exit -1
...
      else
        echo "skip"
      fi

このような形でブランチを判定し、単純にBashで振舞いを制御しているわけです。Trigger側でブランチ毎に異なるcloudbuild.yamlを実行する、とかも出来るのですが現時点では見通しが良いのでcloudbuild.yamlにちょっとしたハックを入れてる状態です。このあたりは運用がこなれてきたりすれば変えるかもしれません。

全体的な構成としては以下のようにこちらの構成を踏襲していますがトリガーにサービスアカウントを指定するためにloggingオプションをCLOUD_LOGGING_ONLYにしています。またmake a pull requestというPRを作成するステップを追加しています。以下は抜粋した全体のフローです。

steps:
- id: 'branch name'
- id: 'tf init'
- id: 'tf plan'
- id: 'tf apply'
- id: 'make a pull request'
options:
  logging: CLOUD_LOGGING_ONLY  
availableSecrets:
  secretManager:
  - versionName: projects/$PROJECT_ID/secrets/github-token-for-cd/versions/1
    env: 'GITHUB_TOKEN'

GitHubへの Pull Requestの作成

GitHubへのPRの作成はcli/make-pr.shを使ってやっています。こちらは非常にシンプルなコードでcurlでGitHubのREST APIを叩いてるだけです。クレデンシャルに関してはCloud Seacret Managerに保存しavailableSecretsパラメータを使って渡しています。

一応、中で環境ブランチにマージする時とmainブランチにマージする時でメッセージを変えたかったので少し分岐していますがさほど重要ではありません。

OWNER=koduki
REPO=gcp-terraform
COMPARE=${OWNER}:${BRANCH_FROM}
BASE=${BRANCH_TO}

TITLE="Review Request of Terraform for ${BRANCH_TO}"
if [ $IS_DEPLOY == "TRUE" ]; then
  DEPLOY_MSG="Once you approve & merge this request, deplyment is executed."
else
  DEPLOY_MSG=""
fi
BODY="Please review below Terraform Plan. ${DEPLOY_MSG} \
\n \
\n \
url:\n \
https://console.cloud.google.com/cloud-build/builds;region=global/${BUILD_ID}?project=${PROJECT_ID}"

DATA='{ "title": "'$TITLE'","body": "'$BODY'","head": "'$COMPARE'","base": "'$BASE'"}'
curl -v -H "Authorization: token $GITHUB_TOKEN" https://api.github.com/repos/$OWNER/$REPO/pulls --data "$DATA"

実はGitHubに限れば 「ビルドが成功したときだけマージする」 超便利なトリガーもたくさん用意されているのでPRするスクリプトを自作する必要はあまり無いのですが、他のリポジトリでの利用も想定してこのような汎用的に動きそうな構造にしています。

グループと権限の管理

今回のTerraformによるIaCを作る上でもう一つ考えたのがグループとロール/権限管理(アサインポリシー) の簡略化です。
エンタープライズ企業のベスト プラクティス」に従ってプロジェクトを作るとプロジェクト毎に管理者や開発者をアサインできるように以下のように大量のグループの作成が必要になります。Read Onlyのグループとか作るともっと増えますよね...

  • gcp-cmn-nw-admins@nklab.dev
  • gcp-cmn-developers@nklab.dev
  • gcp-svc-simpleweb-dev-admins@nklab.dev
  • gcp-svc-simpleweb-prd-admins@nklab.dev
  • gcp-svc-simpleweb-prd-developers@nklab.dev
  • ...

これは日々の運用を楽にはしますが初期作業が面倒なのも事実です。今回はTeraformを使ってそのあたりも管理出来るようにしたのでProject作成にまつわる作業がかなり軽減されました。

まずグループの管理は01_org/02_group配下です。環境はallしかありません。このプロジェクトでは以下の3つをやっています。

  • グループの作成
  • 仮想的なロールグループとなるrolesetの定義
  • グループにロールセットをアサイン

1グループ、1ロールだと厳密ではあれど運用がさすがに面倒なので、複数のロールをまとめて与えたい事は多いと思います。そのためrolesetという概念を導入して一括して当てています。具体的には以下のような連想配列として宣言しています。

variable "prj_rolesets" {
  default = {
    "nwadmin" : [
      "roles/compute.xpnAdmin",
      "roles/compute.securityAdmin",
      "roles/vpcaccess.admin",
      "roles/accesscontextmanager.policyAdmin",
    ],
    "prjadmin" : [
      "roles/compute.admin",
      "roles/storage.admin"
    ],
    "user" : [
      "roles/compute.admin"
    ]
  }
}

組織にアサインするものと、プロジェクトにアサインするものがあり、それぞれvar_rolesets.tfに記載しています。またTerraform-Builderに関するアカウントは連想配列とループで表現しているとロールを追加しただけでも、リストにあるロールのアサインが再作成になります。そのためロールをアサインする権限自体が外れて権限エラーでビルドが失敗 する事があります。role_assign-terraform`として別モジュールに切り出しています。こうする事でビルドするリソース対象が増えたときに カジュアルに権限を追加しても大丈夫 です。

グループはvar_group_and_policies.tfの中で管理をしています。

variable "group_list" {
  default = {
    "gcp-cmn-nw-admins@nklab.dev" : { "name" : "gcp-cmn-nw-admins", "description" : "Network Administrator" },
    "gcp-cmn-developers@nklab.dev" : { "name" : "gcp-cmn-developers", "description" : "Network Developer" },
    "gcp-svc-simpleweb-dev-admins@nklab.dev" : { "name" : "gcp-svc-simpleweb-dev-admins", "description" : "Simple Web Administrator on Dev" },
    "gcp-svc-simpleweb-dev-developers@nklab.dev" : { "name" : "gcp-svc-simpleweb-dev-developers", "description" : "Simple Web Administrator on Dev" },
    "gcp-svc-simpleweb-prd-admins@nklab.dev" : { "name" : "gcp-svc-simpleweb-prd-admins", "description" : "Simple Web Administrator on Production" },
    "gcp-svc-simpleweb-prd-developers@nklab.dev" : { "name" : "gcp-svc-simpleweb-prd-developers", "description" : "Simple Web Administrator on Production" },
  }
}

最後に作成したグループとRolesetのマッピングが以下となります。

variable "assign_policy4org" {
  default = {
    "group:gcp-cmn-nw-admins@nklab.dev"                                                   = "nwadmin",
    "serviceAccount:svc-terraform-builder@terraform-cmn-all-xxx.iam.gserviceaccount.com" = "terraform-builder-extra"
  }
}

variable "assign_policy4sharedvpc_dev" {
  default = {
    "group:gcp-cmn-nw-admins@nklab.dev"  = "prjadmin",
    "group:gcp-cmn-developers@nklab.dev" = "user",
  }
}
....
variable "assign_policy4simpleweb_prd" {
  default = {
    "group:gcp-svc-simpleweb-prd-admins@nklab.dev"     = "prjadmin",
    "group:gcp-svc-simpleweb-prd-developers@nklab.dev" = "user",
  }
}

これでグループの作成や権限管理を簡単に行う事が出来ます。これだけでもTerraformというかIaCを入れる価値がありますよね><

なお、ユーザのアサインに関しては少し特性というか利用のタイミングが違うので、このスクリプトとは別で管理をしています。

Tips

ローカルでのterraform planの実行

基本的にCIサーバに全てを任せてローカルでterraformコマンドを叩くべきではありませんが、さすがにterraform planくらいは実行したい事が良くあります。PushしてreviewのビルドでFailとか悲しいですからね。簡単なタイポくらいは見つけたい。という分けで以下のように実行すればOKです。

準備

まずは下準備としてサービスアカウントの鍵ファイルをダウンロードします。ダウンロード出来たなら$HOME/.secret/src-terraform-key.jsonの名前で保存します。

実行

下記のように実行する事でdockerまたはpodmanを使って-dオプションで指定したプロジェクトの実行計画を見る事が出来ます。

./cli/terraform.sh -d projects/01_org/01_resources/environments/all init
./cli/terraform.sh -d projects/01_org/01_resources/environments/all plan

リソース名のpostfix

ちょっとした工夫としてプロジェクト名の後ろにpostfixを付ける用にしています。この値は何でも良いのですが基本的に過去に使ったものは使えません。こうする事で 「同じプロジェクト名では作れない」 というGCPの制約を回避できます。スクリプトで作ってると結構ゼロから再構築する事もあるので機械的に付けると便利です。

module "prj_cmn_sharedvpc_dev" {
  source = "../../modules/cmn/sharedvpc"

  postfix         = "xfea9"
  env             = "dev"
  billing_account = var.billing_account

  cmn          = module.root.cmn
  default_apis = var.prj_default_apis

  depends_on = [
    module.root
  ]
}

まとめ

エンタープライズ企業のベスト プラクティス」や「共有VPC」に対応できるようなTerraform及びGitHub/Cloud Buildでのビルドパイプラインを作ってみました。

CloudのようなSDxはIaCとしてプログラマブルに構成管理を出来るのが魅力だと思っています。PRによるレビュー/承認のプロセスやコミットログも残るのでセキュリティ/監査対応的にもバッチリ! 今回ガッツリ作れたので公私の環境共になるべくこの運用に寄せて手作業はSandboxや限定的な部分にと留めたいと思っています。

私自身がTerraformを使い始めたばかりと言う事で、初心者ならでわのハマりポイントを書けてるところもあれば、逆にイケてない部分も多くあるのかと思います。その点に関しては過不足やアドバイスがあればコメント等頂ければと。

それではHappy Hacking!

Discussion