GCPのリソースをTerraformで管理できるようにした話

## はじめに

私の趣味の個人開発では、一部プロダクトにGCPのリソースが使われています。今回はこれらのリソース管理をTerraformで行えるようにした話を書きます。

## 動機

今までは、GCPのリソースをすべてWebコンソール上で手動で作成していました。しかし、この方法では人的ミスが発生しやすく、また、リソースの状態を管理することが難しいと感じました。そこで、Terraformを使ってリソース管理を行うことで、リソースの状態をコードで管理できるようにしました。

## ディレクトリ構成

本番環境だけで運用していると、本番環境でまずいことを起こしてしまう可能性があるため、動作確認用のステージング環境を作成することにしました。そこで問題になったのが、ディレクトリ構成をどうするか？ということです。

企業のプロジェクトだとステージング環境と本番環境は共通化されていないことが多いと思います。

- <https://engineering.mercari.com/blog/entry/20220121-securing-terraform-monorepo-ci/>
- <https://inside.pixiv.blog/2020/07/30/172828>
- <https://techlife.cookpad.com/entry/2020/02/28/120000>

ですが、今回は「個人開発」故の以下の特性を考慮して、ステージング環境と本番環境を共通化することにしました。

- 全コードをセルフレビューしているため、ステージング環境のリソースを本番環境にも記述するときに生じるミスを可能な限り減らしておきたい
  - せっかくステージング環境を作っているのに、本番環境に反映し忘れるということが起こりうる
  - 複数人で管理していたらレビューで防げるミスも、個人開発だと気づけずに本番環境に反映してしまう可能性がある
- ステージング環境と本番環境で構成が異なることがない
  - 企業のプロジェクトだと負荷対策やDevOpsなどで本番環境とステージング環境で構成が異なることがあるが、個人開発だとステージング環境と本番環境で構成が異なることはない
    → 本番環境とステージング環境を共通化してしまっても問題ない

そこで、以下のディレクトリ構成を採用しました。基本的にGCPに記載の[ベストプラクティス](https://cloud.google.com/docs/terraform/best-practices-for-terraform?hl=ja#subdirectories)に従っています。

```txt
├── modules
│   ├── service1
│   │   ├── main.tf
│   │   └── variables.tf
│   ├── service2
│   │   ├── main.tf
│   │   └── variables.tf
│   └── common_resource1
│       ├── main.tf
│       ├── outputs.tf
│       └── variables.tf
├── production
│   ├── backend.tf
│   ├── locals.tf
│   ├── main.tf
│   └── providers.tf
└── staging
    ├── backend.tf
    ├── locals.tf
    ├── main.tf
    └── providers.tf
```

サービスごとのリソースを `modules` 以下に配置し、 `production` と `staging` にはそれらのリソースを呼び出すためのコードを配置しました。環境依存の設定は外から変数として渡すようにしています。

運用を初めて1ヶ月が経った現在、variableをたくさん使わないといけなくてやや面倒という気持ちもありますが、トータルではこの構成でよかったと思っています。

## 実装

基本的にはGCPのドキュメントに従っていきます。

https://cloud.google.com/docs/terraform/get-started-with-terraform?hl=ja

- プロバイダーの設定

```tf
provider "google" {
  project = local.project
  default_labels = {
    service     = "home-cluster"
    environment = local.env
  }
}
```

- `tf plan` で変更点を確認
  - 実行には`gcloud`のログインが必要そう
- `tf apply` で変更を適用

GitHub Actionsを使ってリソース管理をするためには、`tfstate`（terraform上のリソースの状態）をGCSに保存する必要があります。

参考：<https://cloud.google.com/docs/terraform/resource-management/store-state?hl=ja>

まずはtfstateのためのGCSバケットを作成します。

```tf
resource "google_storage_bucket" "terraform_remote_backend" {
  name     = "bucket-name"
  location = "US"

  force_destroy               = false
  public_access_prevention    = "enforced"
  uniform_bucket_level_access = true
}
```

次に、tfstateをGCSに保存するための設定を行います。

```tf
terraform {
  backend "gcs" {
    bucket = "stg-terraform-remote-backend.piny940.com"
  }
}
```

最後に `terraform init -migrate-state` を実行して、tfstateをGCSに移行します。

### Workload Identity連携

Terraform関連の処理はGitHub Actionsを使って行おうとしていたため、そのためのサービスアカウントを作成する必要があります。
Terraformのためのサービスアカウントは、その特性上、強い権限を持つことになり、サービスアカウントキーの管理方法が課題になります。そこで、GCPのWorkload Identity連携の機能を活用することで、サービスアカウントキーを発行することなく、サービスアカウントを利用できるようにしました。

https://cloud.google.com/iam/docs/workload-identity-federation?hl=ja

まずは`workload_identity_pool`を作成します。

```tf
resource "google_iam_workload_identity_pool" "default" {
  workload_identity_pool_id = "pool"
}
```

次に、サービスアカウントを作成・ロール付与を行います。

ここでは最強の権限を与えていますが、本来は最小限の権限を与えるべきです。

```tf
resource "google_service_account" "terraform_github_actions" {
  account_id                   = "terraform-github-actions"
  display_name                 = "Terraform GitHub Actions"
  create_ignore_already_exists = true
}
resource "google_project_iam_member" "terraform_github_actions_workload_identity_user" {
  project = var.project
  member  = "serviceAccount:${google_service_account.terraform_github_actions.email}"
  role    = "roles/iam.workloadIdentityUser"
}
resource "google_project_iam_member" "terraform_github_actions_owner" {
  project = var.project
  member  = "serviceAccount:${google_service_account.terraform_github_actions.email}"
  role    = "roles/owner"
}
```

githubを`workload_identity_pool_provider`として登録します。

```tf
resource "google_iam_workload_identity_pool_provider" "repo_github_actions" {
  workload_identity_pool_id          = google_iam_workload_identity_pool.default.workload_identity_pool_id
  workload_identity_pool_provider_id = "repo-github-actions"
  display_name                       = "Terrform GitHub Actions"
  description                        = "for Terraform GitHub Actions"
  attribute_condition                = "assertion.repository == \"{user/repo}\""
  attribute_mapping = {
    "google.subject" = "assertion.sub"
  }
  oidc {
    issuer_uri = "https://token.actions.githubusercontent.com"
  }
}
```

最後に、サービスアカウントの権限借用の設定を行います。
subの値はGitHubのドキュメントに記載の通りです。

https://docs.github.com/ja/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect

```tf
resource "google_service_account_iam_member" "terraform_github_actions_env_workload_identity_user" {
  service_account_id = google_service_account.terraform_github_actions.id
  role               = "roles/iam.workloadIdentityUser"
  member             = "principal://iam.googleapis.com/projects/{プロジェクト番号}/locations/global/workloadIdentityPools/${google_iam_workload_identity_pool.default.workload_identity_pool_id}/subject/{subの値}"
}
```

### GitHub ActionsでのCI/CD

基本的な設定はドキュメントに記載のとおりです。

https://cloud.google.com/iam/docs/workload-identity-federation-with-deployment-pipelines?hl=ja#impersonation

ですが、今回はステージング環境と本番環境のコードを共通化したことによって、ある問題が発生しました。

GitHub ActionsでのCI/CDは、PRをマージしたタイミングで `terraform apply` をするようにしたいです。しかし、ステージング環境と本番環境のコードが共通化されているため、ステージング環境のリソースがapplyされたときには、同時に本番環境のリソースもapplyされてしまいます。

この問題を解決するために、GitHubの `deployment` 機能を活用して、以下のデプロイフローを採用することで解決しました。

- PRを作成。`terraform plan` が実行され、変更点を確認する。
- `environment: staging` を設定したJobで `terraform apply` を実行する
  - [Deployment protection rules](https://docs.github.com/ja/actions/managing-workflow-runs-and-deployments/managing-deployments/managing-environments-for-deployment#deployment-protection-rules)でownerの許可を必要にすることで、私が確認してからapplyすることができる
- PRがマージされたら、`environment: production` を設定したJobで `terraform apply` を実行する
  - `production` 環境のDeploymentはmainブランチでしか行えないようにしています。

これにより、ステージング環境と本番環境のリソースが同時にapplyされることを防ぐことができました。

## まとめ

今回は、GCPのリソース管理をTerraformで行えるようにしました。
個人開発ゆえの特性を考慮して、独自の工夫を組み込むことができたのはかなりよかったと感じています。

これにてIaCを無事実現でき、かなり良いDXを得ることができました。今後はAWSでも同様の工夫を行って開発環境をより良くしていきたいなと思っています。