🦁

TerraformでCloudBuildとGitHub連携を自動化して更にSlack通知の構成も自動化してみた

2024/10/27に公開

はじめ

Web サービスのインフラで GoogleCloud を使っている場合 GitHubActions ではなく CloudBuild で CI/CD のパイプラインを構築しているケースがあるかと思います。
コンソール画面でぽちぽちしながら CloudBuild と GitHub を連携するのって意外と面倒ですよね。
CloudBuild では 第二世代 から Terraform でこの連携を自動化できるようになりました。
今回はこれに加えて CloudBuild の状況を Slack に通知できる構成も Terraform を使って自動化できるようにしていきたいと思います。

実は CloudBuild の Slack 通知の構成の自動化は GoogleCloud の公式ドキュメント にもあるようにCloud Build Notifierというイメージを使うことで Terraform を使わなくても簡単に構築ができます。
もし Terraform で管理する必要がない場合はそちらの方法を採用することも検討してみてください。
ただ Terraform だと細かい設定が柔軟にできかつそれが資産として残るので Terraform の可能性もぜひ検討してみてください!

事前情報

Terraform v1.9.8
Google Provider v6.8.0

本題

次の準備ができている前提で進めていきます。

  • GoogleCloud プロジェクトを作成している
  • GitHub に CloudBuild のアプリをインストールしている
  • GitHub のトークンを発行している
    • 今回は PAT を発行しています
  • Slack アプリを作成していて WebhookURL を取得している

CloudBuild と GitHub の連携を Terraform で自動化する

既に述べた通り CloudBuild は第二世代から Terraform がサポートされるようになりました。
まずここでは Terraform を使って CloudBuild と GitHub の接続を自動化する方法を見ていきたいと思います。
今回のコードは ドキュメントのサンプル を参考にしているのでそちらも参考にすると良いと思います。

まずは次のようにコードを記述します。

# データソースを使ってシークレットを取得する
data "google_secret_manager_secret_version" "app_id" {
  provider = google
  project  = ${var.project_id}
  secret   = "app_id"
}
data "google_secret_manager_secret_version" "github_token" {
  provider = google
  project  = ${var.project_id}
  secret   = "github_token"
}

# CloudBuildのサービスエージェントに権限付与
resource "google_project_iam_member" "cloudbuild" {
  role    = "roles/secretmanager.secretAccessor"
  member  = "serviceAccount:service-${var.project_number}@gcp-sa-cloudbuild.iam.gserviceaccount.com"
  project = ${var.project_id}
}

# cloudbuildとgithub接続
resource "google_cloudbuildv2_connection" "test" {
  project  = ${var.project_id}
  location = "asia-east1"
  name     = ${var.repository} //リポジトリ名

  github_config {
    app_installation_id = data.google_secret_manager_secret_version.app_id.secret_data
    authorizer_credential {
      oauth_token_secret_version = data.google_secret_manager_secret_version.github_token.id
    }
  }
}

一つずつ見ていきます。

データソースを使ってシークレットを取得する

# データソースを使ってシークレットを取得する
data "google_secret_manager_secret_version" "app_id" {
  provider = google
  project  = ${var.project_id}
  secret   = "app_id"
}
data "google_secret_manager_secret_version" "github_token" {
  provider = google
  project  = ${var.project_id}
  secret   = "github_token"
}

まず CloudBuild と GitHub を接続するには GitHub トークンなどのシークレットな値を扱うことになります。もちろんそのまま扱うことはしないので今回は DataSource を使って SecretManager からシークレットの値を取得しています。(DataSource についてはまた別の記事で紹介します)
この場合は事前にプロジェクトの SecretManager に値を登録しておくことが必要になります。

CloudBuild のサービスエージェントに権限付与

# CloudBuildのサービスエージェントに権限付与
resource "google_project_iam_member" "cloudbuild" {
  role    = "roles/secretmanager.secretAccessor"
  member  = "serviceAccount:service-${var.project_number}@gcp-sa-cloudbuild.iam.gserviceaccount.com"
  project = ${var.project_id}
}

次に CloudBuild のサービスエージェントに SecretManager の読み取りロールを付与します。
このサービスエージェントは CloudBuild API を有効化すると自動で作成されます。Google が管理するサービスアカウントになるため権限が付与されたかどうかをコンソールから確認したい場合は少し工夫が必要です。
サービスエージェントの検索方法

そしてなぜこの付与が必要かというと、付与していない場合に次のようなエラーが出るからです。

Error waiting to create Connection:
Error code 9, message: could not access secret
"projects/xxxxxxxxx/secrets/github_token/versions/1"
with service account "service-xxxxxxxxx@gcp-sa-cloudbuild.iam.gserviceaccount.com":
generic::permission_denied: Permission 'secretmanager.versions.access' denied
for resource 'projects/xxxxxxxxx/secrets/token/versions/1' (or it may not exist)

エラーが出る理由については後述します。

また ドキュメントのサンプルのコード では権限の付与が次のように google_iam_policy が使われていますが個人的にはなるべく使わない方が良いかなと思っています。

data "google_iam_policy" "p4sa-secretAccessor" {
  binding {
    role = "roles/secretmanager.secretAccessor"
    # Here, 123456789 is the Google Cloud project number for the project that contains the connection.
    members = ["serviceAccount:service-123456789@gcp-sa-cloudbuild.iam.gserviceaccount.com"]
  }
}

理由については Terraform で Google Cloud の IAM を管理する際の注意点 がすごくわかりやすく解説されているのでこちらを読むと分かると思います。あくまでもこのケースでは問題ないのですが、意識付けの面から(うっかり使わないように)避けた方が良いという個人的な意見もあります。

CloudBuild と GitHub 接続

# CloudBuildとGitHub接続
resource "google_cloudbuildv2_connection" "test" {
  project  = ${var.project_id}
  location = "asia-east1"
  name     = ${var.repository} //リポジトリ名

  github_config {
    app_installation_id = data.google_secret_manager_secret_version.app_id.secret_data
    authorizer_credential {
      oauth_token_secret_version = data.google_secret_manager_secret_version.github_token.id
    }
  }
}

次に CloudBuild と GitHub の接続するリソースを定義します。
location が asia-northeast1 ではなく asia-east1 になっているのは、このあと実際にビルドしたときに次のようなエラーが出るためです。

ビルドを実行できませんでした: failed precondition:
due to quota restrictions, cannot run builds in this region,
see https://cloud.google.com/build/docs/locations#restricted_regions_for_some_projects

公式ドキュメント を見ると、使用状況に応じて特定のリージョンでしか CloudBuild を使用できないとのこと。今回はこれに該当したためエラーが発生したようです。
そのため回避策として asia-east1 を指定しました。
ちなみに第一世代ではリージョンの指定が必須ではなかったためデフォルトで グローバル(非リージョン) でしたが第二世代では指定が必須になっています。

app_installation_id には GitHub へインストールした CloudBuild アプリの ID、oauth_token_secret_version には GitHub トークンを登録している SecretManager の ID を指定します。

ここで先ほどのエラーが出る理由を解説します(CloudBuild のサービスエージェントに SecretManager の読み取りロールを付与する理由)。
CloudBuild と GitHub の接続をするには CloudBuild がリポジトリにアクセスできたりするために GitHub の認証情報を取得しておく必要があります。oauth_token_secret_version では実際のトークンではなくトークンが登録されている ID を指定しているため CloudBuild に SecretManager の値を読み取れる権限が必要です。
ここではユーザーの代わりに CloudBuild のサービスエージェントが SecretManager からトークンを取得するため、権限の付与が必要というわけです。

サービスエージェントについての詳しい解説はしないですが サービスエージェントとは何か などの記事が参考になるかと思います。

ここまでで CloudBuild と GitHub の接続ができるコードを書くことができました!
ただこれだけではブランチに push したところでビルドは実行されません。
次は GitHub のリポジトリの設定やトリガーの設定などを行っていきます。

こちらのコードを記述します。
一つずつ見ていきましょう。

# githubリポジトリをcloudbuildに登録
resource "google_cloudbuildv2_repository" "github" {
  project           = ${var.project_id}
  name              = ${var.project_id}
  parent_connection = google_cloudbuildv2_connection.github.id
  remote_uri        = "https://github.com/xxxxxx/xxxx.git"
}

# CloudBuild で使用するサービスアカウントの作成
locals {
  # ビルドプロセスによって権限を変えてください
  cloudbuild_trigger_roles = [
    "roles/iam.serviceAccountUser",
    "roles/logging.logWriter",
    "roles/cloudfunctions.developer",
    "roles/firebase.admin",
    "roles/secretmanager.secretAccessor",
  ]
}

resource "google_service_account" "cloudbuild_trigger" {
  project      = ${var.project_id}
  account_id   = "cloudbuild-trigger"
  display_name = "cloudbuild-trigger"
  description  = "used by cloudbuild-trigger"
}

resource "google_project_iam_member" "cloudbuild_trigger" {
  for_each = toset(local.cloudbuild_trigger_roles)
  role     = each.value
  member   = "serviceAccount:${google_service_account.cloudbuild_trigger.email}"
  project  = ${var.project_id}
}

# cloudbuildのトリガーの詳細設定
resource "google_cloudbuild_trigger" "github" {
  location        = "asia-east1"
  project         = ${var.project_id}
  service_account = "projects/${var.project_id}/serviceAccounts/${google_service_account.cloudbuild_trigger.unique_id}"
  filename        = "cloudbuild.yaml"

  substitutions = {
    _FOO = "development"
  }

  repository_event_config {
    repository = google_cloudbuildv2_repository.github.id
    push {
      branch = "^main$"
    }
  }
}

リポジトリを CloudBuild に登録

# githubリポジトリをcloudbuildに登録
resource "google_cloudbuildv2_repository" "github" {
  project           = ${var.project_id}
  name              = ${var.project_id}
  parent_connection = google_cloudbuildv2_connection.github.id
  remote_uri        = "https://github.com/xxxxx/xxxx.git"
}

ここでは接続するリポジトリを登録します。
特にないのでスキップします。

CloudBuild で使用するサービスアカウントの作成

# CloudBuild で使用するサービスアカウントの作成
locals {
  cloudbuild_trigger_roles = [
    "roles/iam.serviceAccountUser",
    "roles/logging.logWriter",
    "roles/cloudfunctions.developer",
    "roles/firebase.admin",
    "roles/secretmanager.secretAccessor",
  ]
}

resource "google_service_account" "cloudbuild_trigger" {
  project      = ${var.project_id}
  account_id   = "cloudbuild-trigger"
  display_name = "cloudbuild-trigger"
  description  = "used by cloudbuild-trigger"
}

resource "google_project_iam_member" "cloudbuild_trigger" {
  for_each = toset(local.cloudbuild_trigger_roles)
  role     = each.value
  member   = "serviceAccount:${google_service_account.cloudbuild_trigger.email}"
  project  = ${var.project_id}
}

ここでは CloudBuild で使用するサービスアカウントを作成します。
このサービスアカウントはユーザーに変わってビルドを実行してくれるサービスアカウントになります。
ビルドプロセスの中で SecretManager にアクセスしたり、Firebase や CloudRun のデプロイをしたりなど様々な処理を行います。
このサービスアカウントを指定しない場合はデフォルトの CloudBuild サービスアカウントが使用されますが必要以上の権限が付与されている場合があります。
ドキュメント でもベストプラクティスとして挙げられているようになるべくデフォルトのサービスアカウントは使わずに独自のサービスアカウントを作成することをお勧めします。

MUST で必要な権限が roles/iam.serviceAccountUserroles/logging.logWriter になります。
あとは例えばビルドプロセスの中で SecretManager にアクセスする必要があるなら roles/secretmanager.secretAccessor、CloudFunctions をデプロイするなら roles/cloudfunctions.developer のように各プロジェクトに合わせて権限の付与をしてください。

CloudBuild トリガーの詳細設定

resource "google_cloudbuild_trigger" "github" {
  location        = "asia-east1"
  project         = ${var.project_id}
  service_account = "projects/${var.project_id}/serviceAccounts/${google_service_account.cloudbuild_trigger.unique_id}"
  filename        = "cloudbuild.yaml"

  substitutions = {
    _FOO = "development"
  }

  repository_event_config {
    repository = google_cloudbuildv2_repository.github.id
    push {
      branch = "^main$"
    }
  }
}

ここではトリガーの設定します。
今回は GitHub のブランチへの push をトリガーにしています。
service_account には一つ前で作成したサービスアカウントを指定します。(別に unique_id じゃなくても良い)
filename には cloudbuild.yaml ファイルへのパスを定義します。ここは各リポジトリの構成に合わせてください。
substitutions は環境変数を定義できます。例えば _FOO と定義した場合は cloudbuild.yaml では次のように呼び出すことができます。

steps:
  - id: "echo hello"
    name: "gcr.io/cloud-builders/gcloud"
    entrypoint: "bash"
    args: ["-c", "echo hello!! ${_FOO}"] # hello!! development

repository_event_config ではリポジトリの指定やどのブランチへの push をトリガーとするかなどを設定します。
substitutionsrepository_event_config では環境毎にブランチを変えたり変数の値を変更したいケースがあると思います。その場合は次のようにすると「dev ブランチに push した場合は dev プロジェクトへのデプロイをする」や「環境毎に env の値を変える」などといったことを実現できます。

resource "google_cloudbuild_trigger" "github" {
  location        = "asia-east1"
  project         = var.project_id
  service_account = "projects/${var.project_id}/serviceAccounts/${google_service_account.cloudbuild_trigger.unique_id}"
  filename        = "cloudbuild.yaml"

  substitutions = {
    _ENV = var.env # 環境毎の変数を設定できるためvar.envがdevなら_ENVもdevを持つ
  }

  repository_event_config {
    repository = google_cloudbuildv2_repository.github.id
    push {
      branch = var.env == "prod" ? "^main$" : "^${var.env}$"
      # prodへのデプロイはmainブランチのpushがトリガー
      # stagingへのデプロイはstagingブランチのpushがトリガー、のようにできる
    }
  }
}

これで CloudBuild と GitHub の接続を自動化する準備が整いました。

一旦ここまでの流れをまとめたサンプルのソースコードを貼っておきます(プロバイダなどは省略します)。

CloudBuild + GitHub 連携ソースコード
# データソースを使ってシークレットを取得する
data "google_secret_manager_secret_version" "app_id" {
  provider = google
  project  = ${var.project_id}
  secret   = "app_id"
}
data "google_secret_manager_secret_version" "github_token" {
  provider = google
  project  = ${var.project_id}
  secret   = "github_token"
}

# CloudBuildのサービスエージェントに権限付与
resource "google_project_iam_member" "cloudbuild" {
  role    = "roles/secretmanager.secretAccessor"
  member  = "serviceAccount:service-${var.project_number}@gcp-sa-cloudbuild.iam.gserviceaccount.com"
  project = ${var.project_id}
}

# cloudbuildとgithub接続
resource "google_cloudbuildv2_connection" "test" {
  project  = ${var.project_id}
  location = "asia-east1"
  name     = ${var.repository} //リポジトリ名

  github_config {
    app_installation_id = data.google_secret_manager_secret_version.app_id.secret_data
    authorizer_credential {
      oauth_token_secret_version = data.google_secret_manager_secret_version.github_token.id
    }
  }
}

# githubリポジトリをcloudbuildに登録
resource "google_cloudbuildv2_repository" "github" {
  project           = ${var.project_id}
  name              = ${var.project_id}
  parent_connection = google_cloudbuildv2_connection.github.id
  remote_uri        = "https://github.com/xxxxxx/xxxx.git"
}

# CloudBuild で使用するサービスアカウントの作成
locals {
  # ビルドプロセスによって権限を変えてください
  cloudbuild_trigger_roles = [
    "roles/iam.serviceAccountUser",
    "roles/logging.logWriter",
    "roles/cloudfunctions.developer",
    "roles/firebase.admin",
    "roles/secretmanager.secretAccessor",
  ]
}

resource "google_service_account" "cloudbuild_trigger" {
  project      = ${var.project_id}
  account_id   = "cloudbuild-trigger"
  display_name = "cloudbuild-trigger"
  description  = "used by cloudbuild-trigger"
}

resource "google_project_iam_member" "cloudbuild_trigger" {
  for_each = toset(local.cloudbuild_trigger_roles)
  role     = each.value
  member   = "serviceAccount:${google_service_account.cloudbuild_trigger.email}"
  project  = ${var.project_id}
}

# cloudbuildのトリガーの詳細設定
resource "google_cloudbuild_trigger" "github" {
  location        = "asia-east1"
  project         = ${var.project_id}
  service_account = "projects/${var.project_id}/serviceAccounts/${google_service_account.cloudbuild_trigger.unique_id}"
  filename        = "cloudbuild.yaml"

  substitutions = {
    _FOO = "development"
  }

  repository_event_config {
    repository = google_cloudbuildv2_repository.github.id
    push {
      branch = "^main$"
    }
  }
}

terraform plan と apply を実行してみましょう。
CloudBuild のコンソール画面を確認すると成功していることが分かると思います。

apply 後のコンソールを確認してみます。
cloudbuildコンソール1
cloudbuildコンソール2
サービスアカウントは独自で設定したサービスアカウトの unique_id になっています。
cloudbuildコンソール3

ちなみに cloudbuild.yaml は適当な感じにしています。

steps:
  - id: "echo hello"
    name: "gcr.io/cloud-builders/gcloud"
    entrypoint: "bash"
    args: ["-c", "echo hello!! ${_FOO}"]
options:
  logging: CLOUD_LOGGING_ONLY

指定したリポジトリの main ブランチに push するとビルドが実行され意図した通りに出力されました 🎉
cloudbuild完了コンソール

Slack 通知の構成を Terraform で作成する

リリースが自動できるようになりましたが、このままだとビルドの状況をコンソールで確認しなければいけないので Slack 通知するようにしてみましょう。

まずは全体像の把握をしましょう。
公式ドキュメント には全体の構成図があるためそちらを貼り付けておきます。

出典 Cloud Build Notifier
cloudbuildのSlack通知の構成

また Cloud Build のビルド結果を Slack 通知するためのポイント紹介 – Block kit のカスタマイズ例も の記事でも見やすい構成図を確認できます。
言語化すると次のような流れで CloudBuild から Slack への通知を実現しています。

  1. CloudBuild はすべてのビルドイベントの更新とビルドメタデータを PubSub に送信する
  2. push 型のサブスクリプションで定義された PubSub はトピックをリッスンし受信したメッセージをフィルタして CloudRun を実行する
  3. CloudRun で SecretManager に登録されている SlackWebhookURL を取得し、CloudStorage から Slack 通知するテンプレートファイルを取得する
  4. CloudRun から Slack へ通知する

この流れができるように Terraform で構築していきましょう。
まずは最初の構築から見ていきます。

# CloudRunのデフォルトであるcomputeサービスアカウントに権限を付与する
locals {
  roles = [
    "roles/secretmanager.secretAccessor",
    "roles/storage.objectViewer",
  ]
}

resource "google_project_iam_member" "default_compute" {
  project = var.project_id

  for_each = toset(local.roles)
  role     = each.value
  member   = "serviceAccount:${var.project_number}-compute@developer.gserviceaccount.com"
}

# slack.json を保存するためのバケット作成
resource "google_storage_bucket" "test" {
  project       = var.project_id
  name          = "slack-json"
  location      = "ASIA-NORTHEAST1"
  storage_class = "STANDARD"

  uniform_bucket_level_access = true
}

# slack.json アップロード
resource "google_storage_bucket_object" "test" {
  name         = "slack.json"
  source       = "file(../slack.json)"
  content_type = "application/json"
  bucket       = google_storage_bucket.test.id
}

# slack.yaml を保存するためのバケット作成
resource "google_storage_bucket" "test2" {
  project       = var.project_id
  name          = "slack-yaml"
  location      = "ASIA-NORTHEAST1"
  storage_class = "STANDARD"

  uniform_bucket_level_access = true
}

# slack.yaml アップロード
resource "google_storage_bucket_object" "test2" {
  name   = "slack.yaml"
  bucket = google_storage_bucket.test2.id

  content = yamlencode({
    apiVersion = "cloud-build-notifiers/v1"
    kind       = "SlackNotifier"
    metadata = {
      name : "cloudbuild-slack-notifier"
    }
    spec = {
      notification = {
        filter = "build.build_trigger_id == '${google_cloudbuild_trigger.github.trigger_id}' && build.status in [Build.Status.WORKING, Build.Status.SUCCESS, Build.Status.FAILURE, Build.Status.INTERNAL_ERROR, Build.Status.TIMEOUT, Build.Status.CANCELLED]"
        params = {
          buildStatus = "$(build.status)"
        }
        delivery = {
          webhookUrl = {
            secretRef = "webhook-url"
          }
        }
        template = {
          type = "golang"
          uri  = "gs://slack-json/slack.json"
        }
      }
      secrets = [
        {
          name  = "webhook-url"
          value = "projects/${var.project_id}/secrets/webhook_url/versions/latest"
        }
      ]
    }
  })
}

一つずつ見ていきます。

CloudRun のデフォルトサービスアカウントに権限を付与する

# CloudRunのデフォルトであるcomputeサービスアカウントに権限を付与する
locals {
  roles = [
    "roles/secretmanager.secretAccessor",
    "roles/storage.objectViewer",
  ]
}

resource "google_project_iam_member" "default_compute" {
  project = var.project_id

  for_each = toset(local.roles)
  role     = each.value
  member   = "serviceAccount:${var.project_number}-compute@developer.gserviceaccount.com"
}

既に述べた通り今回は CloudRun を経由して通知をします。
CloudRun では SecretManager や CloudStorage にアクセスするため権限を付与する必要があります。本当は独自のサービスアカウントでも良いのですが今回はそのままデフォルトにしておきます。

Slack 通知のメッセージテンプレートと設定ファイル保存するバケットの作成+アップロード

# slack.json を保存するためのバケット作成
resource "google_storage_bucket" "test" {
  project       = var.project_id
  name          = "slack-json"
  location      = "ASIA-NORTHEAST1"
  storage_class = "STANDARD"

  uniform_bucket_level_access = true
}

# slack.json アップロード
resource "google_storage_bucket_object" "test" {
  name         = "slack.json"
  source       = "file(../slack.json)"
  content_type = "application/json"
  bucket       = google_storage_bucket.test.id
}

# slack.yaml を保存するためのバケット作成
resource "google_storage_bucket" "test2" {
  project       = var.project_id
  name          = "slack-yaml"
  location      = "ASIA-NORTHEAST1"
  storage_class = "STANDARD"

  uniform_bucket_level_access = true
}

# slack.yaml アップロード
resource "google_storage_bucket_object" "test2" {
  name   = "slack.yaml"
  bucket = google_storage_bucket.test2.id

  content = yamlencode({
    apiVersion = "cloud-build-notifiers/v1"
    kind       = "SlackNotifier"
    metadata = {
      name : "cloudbuild-slack-notifier"
    }
    spec = {
      notification = {
        filter = "build.build_trigger_id ==
        '${google_cloudbuild_trigger.github.trigger_id}'
        && build.status in [Build.Status.WORKING,
        Build.Status.SUCCESS, Build.Status.FAILURE,
        Build.Status.INTERNAL_ERROR,
        Build.Status.TIMEOUT,
        Build.Status.CANCELLED]"
        params = {
          buildStatus = "$(build.status)"
        }
        delivery = {
          webhookUrl = {
            secretRef = "webhook-url"
          }
        }
        template = {
          type = "golang"
          uri  = "gs://slack-json/slack.json"
        }
      }
      secrets = [
        {
          name  = "webhook-url"
          value = "projects/${var.project_id}/secrets/webhook_url/versions/latest"
        }
      ]
    }
  })
}

ここでは次のファイルを保存するためのバケットの作成とアップロードを行います。

  • 通知メッセージのテンプレートファイル
  • 通知を構成する設定ファイル

まずは設定ファイルである slack.yaml ですが YAML ファイルを別で作ってアップロードするのではなく google_storage_bucket_object の content に直で定義しました。
これは使いたい変数がありそのままコード上で定義した方が便利なためです。
リポジトリにサンプルコードがあるのでそれを参考しながらカスタマイズしていきましょう。

それでは設定の定義についてみていきます。
基本的にカスタマイズできるのは spec.notification.filterspec.notification.params の部分かなと思います。spec.notification.filter はどのタイミングで Slack にメッセージを送るかをフィルタリングして指定するものです。
ここでは Build.Status というステータスの状態を表す変数を使い通知のタイミングを制御できます。
今回は次のタイミングで通知するようにしています。

  • 開始
  • 完了
  • 失敗
  • タイムアウト
  • キャンセル
  • 内部エラー

また注意点が "build.build_trigger_id == '${google_cloudbuild_trigger.github.trigger_id}' の部分です。
結論これがないと通知されない場合があるため念の為設定しています(GitHub への push トリガーで発生したビルドを指定するフィルタリング)。
実際に確認した通知されないケースはビルドプロセスで Firebase のデプロイをした時でした。
添付にあるように Slack 通知が別のビルドで発生したためです。
二つのビルドが実行されたことで、おそらく CloudRun がどのビルドの状況を監視するのかが分からなくなった可能性があると考えました、がまだ全て調査できていないです。。

ビルドが二つに分かれる
cloudbuildのビルドプロセス

spec.notification.params は後述するテンプレートファイルで CloudBuild のメタデータを使えるように変数の参照を設定するものです。テンプレートファイル内で Build.Statue を直接使うことができないので buildStatus と任意の変数名を設定することで Params.buildStatus のように参照できます。今回はステータスのみですが、他にもコミットやトリガー名などなど用途に合わせてパラメーターを設定できます。
secrets.value は webhookURL を登録している SecretManager のパスを指定します。
spec.notification.template.uri は後述するテンプレートファイルのデプロイ後の storage の URI を指定する点に注意してください。

次に Slack 通知のメッセージテンプレートの設定ついてみていきます。

こちらは JSON ファイルで別ファイルを作成しています。書き方については Slack の BoltKit なので、シュミレーター で確認しつつ自分用にカスタマイズした情報を載せるようにしましょう。また サンプルも用意されている のでこちらをもとにカスタマイズしていくのが良さそうです。
今回は次の情報を表示できるようにしました。

  • ビルドステータスと関連するアイコン
  • プロジェクト ID
  • ビルドのログ URL
[
  {
    "type": "section",
    "text": {
      "type": "mrkdwn",
      "text": "Cloud Build build status *{{ if eq .Params.buildStatus `WORKING` }}START{{ else }}{{.Params.buildStatus}}{{ end }}*. {{ if eq .Params.buildStatus `SUCCESS` }}✅{{ else if eq .Params.buildStatus `FAILURE` }}❌{{ else if eq .Params.buildStatus `WORKING` }}🔨{{ else if eq .Params.buildStatus `INTERNAL_ERROR` }}🚨{{ else if eq .Params.buildStatus `CANCELLED` }}🚫{{ else if eq .Params.buildStatus `TIMEOUT` }}⏰{{ else }}❓{{ end }}"
    }
  },
  {
    "type": "section",
    "text": {
      "type": "mrkdwn",
      "text": "*ProjectId:*\n{{.Build.ProjectId}}"
    }
  },
  {
    "type": "divider"
  },
  {
    "type": "section",
    "text": {
      "type": "mrkdwn",
      "text": "View Build Logs"
    },
    "accessory": {
      "type": "button",
      "text": {
        "type": "plain_text",
        "text": "Logs"
      },
      "value": "click_me_123",
      "url": "{{.Build.LogUrl}}",
      "action_id": "button-action"
    }
  }
]

では最後です!

最後のコードはこちらです。

# ID Tokenを発行するための権限を付与する
resource "google_project_iam_member" "pubsub" {
  project = var.project_id

  role   = "roles/iam.serviceAccountTokenCreator"
  member = "serviceAccount:service-${project_number}@gcp-sa-pubsub.iam.gserviceaccount.com"
}

# cloud runを起動するサービスアカウント作成
resource "google_service_account" "for_pubsub" {
  project      = var.project_id
  account_id   = "for-pubsub"
  display_name = "for-pubsub"
  description  = "Cloud Run Pub/Sub Invoker"
}

# 権限を付与する
resource "google_cloud_run_v2_service_iam_member" "member" {
  project  = google_cloud_run_v2_service.test.project
  location = google_cloud_run_v2_service.test.location
  name     = google_cloud_run_v2_service.test.name
  role     = "roles/run.invoker"
  member   = "serviceAccount:${google_service_account.for_pubsub.email}"
}

# CloudRunのデプロイ
resource "google_cloud_run_v2_service" "test" {
  name     = "cloudrun-slack"
  location = "asia-northeast1"
  project  = var.project_id

  template {
    scaling {
      max_instance_count = 1
    }

    containers {
      image = "us-east1-docker.pkg.dev/gcb-release/cloud-build-notifiers/slack:latest"
      env {
        name  = "CONFIG_PATH"
        value = "gs://slack-yaml/slack.yaml"
      }
      env {
        name  = "PROJECT_ID"
        value = var.project_id
      }
    }
  }
}

# トピックの作成
resource "google_pubsub_topic" "slack" {
  name    = "cloud-builds" # 固定
  project = var.project_id
}

# サブスクリプションの作成
resource "google_pubsub_subscription" "subscription" {
  name    = "cloud-builds-subscription"
  project = var.project_id
  topic   = google_pubsub_topic.slack.name

  push_config {
    push_endpoint = google_cloud_run_v2_service.test.uri
    oidc_token {
      service_account_email = google_service_account.for_pubsub.email
    }
  }
}

PubSub の認証周りの設定

# ID Tokenを発行するための権限を付与する
resource "google_project_iam_member" "pubsub" {
  project = var.project_id

  role   = "roles/iam.serviceAccountTokenCreator"
  member = "serviceAccount:service-${project_number}@gcp-sa-pubsub.iam.gserviceaccount.com"
}

# cloud runを起動するサービスアカウント作成
resource "google_service_account" "for_pubsub" {
  project      = var.project_id
  account_id   = "for-pubsub"
  display_name = "for-pubsub"
  description  = "Cloud Run Pub/Sub Invoker"
}

# 起動する権限を付与する
resource "google_cloud_run_v2_service_iam_member" "member" {
  project  = google_cloud_run_v2_service.test.project
  location = google_cloud_run_v2_service.test.location
  name     = google_cloud_run_v2_service.test.name
  role     = "roles/run.invoker"
  member   = "serviceAccount:${google_service_account.for_pubsub.email}"
}

ここで何をしているかですが、まず前提を押さえておきます。

  • PubSub トリガーで CloudRun を実行するため PubSub のサブスクリプションに CloudRun のエンドポイントを設定する
  • PubSub はトピックをリッスンし、そのエンドポイントに HTTP リクエストを送ることで CloudRun を実行する

でこのときに CloudRun 側で「本当に指定したプロジェクトからのリクエストかどうか」の認証をするのですが、その認証で使うサービスアカウントの作成をしています。

この辺りの認証については GCP からの HTTP リクエストをセキュアに認証する という記事を読むとすごく理解が進むと思います。大変分かりやすので参考にしていただきたいです。

Slack 通知をするイメージを CloudRun にデプロイ

# CloudRunデプロイ
resource "google_cloud_run_v2_service" "test" {
  name     = "cloudrun-slack"
  location = "asia-northeast1"
  project  = var.project_id

  template {
    scaling {
      max_instance_count = 1
    }

    containers {
      image = "us-east1-docker.pkg.dev/gcb-release/cloud-build-notifiers/slack:latest"
      env {
        name  = "CONFIG_PATH"
        # 先ほど作ったSlack通知の設定ファイルのstorageURL
        value = "gs://slack-yaml/slack.yaml"
      }
      env {
        name  = "PROJECT_ID"
        value = var.project_id
      }
    }
  }
}

ここでは Slack 通知をする Docker イメージを CloudRun にデプロイします。
これは Cloud Build Notifier というイメージですが Slack 以外にもサポートがされています。
これで CloudRun の環境から Slack 通知をできます。
CONFIG_PATH と PROJECT_ID は必須なのでそれぞれ設定しましょう。
念のためインスタンスの最大数は 1 に設定しています。

PubSub のトピックとサブスクリプションを作成する

# トピックの作成
resource "google_pubsub_topic" "slack" {
  name    = "cloud-builds" # 固定(他の値だと動かない)
  project = var.project_id
}

# サブスクリプションの作成
resource "google_pubsub_subscription" "subscription" {
  name    = "cloud-builds-subscription"
  project = var.project_id
  topic   = google_pubsub_topic.slack.name

  push_config {
    # CloudRunのエンドポイント
    push_endpoint = google_cloud_run_v2_service.test.uri
    oidc_token {
      # 先ほど作ったCloudRunの認証用サービスアカウントを指定
      service_account_email = google_service_account.for_pubsub.email
    }
  }
}

こちらでは PubSub トリガーに必要なトピックとサブスクリプションを作成します。
注意点としてはトピック名は必ず cloud-builds にすることです。
これは ドキュメント にも書いていますし、ソースコード でも確認ができます。

ようやく Slack 通知の構成を Terraform で定義できました。
terraform plan と apply を実行して再度 GitHub の main ブランチに push してみるとビルドと開始と完了時に Slack への通知ができているはずです 🎉

cloudbuildのSlack通知

CloudBuild + Slack 通知構成のコードをまとめたのを貼り付けておきます。

CloudBuild + Slack 通知構成ソースコード
# CloudRunのデフォルトであるcomputeサービスアカウントに権限を付与する
locals {
  roles = [
    "roles/secretmanager.secretAccessor",
    "roles/storage.objectViewer",
  ]
}

resource "google_project_iam_member" "default_compute" {
  project = var.project_id

  for_each = toset(local.roles)
  role     = each.value
  member   = "serviceAccount:${var.project_number}-compute@developer.gserviceaccount.com"
}

# slack.json を保存するためのバケット作成
resource "google_storage_bucket" "test" {
  project       = var.project_id
  name          = "slack-json"
  location      = "ASIA-NORTHEAST1"
  storage_class = "STANDARD"

  uniform_bucket_level_access = true
}

# slack.json アップロード
resource "google_storage_bucket_object" "test" {
  name         = "slack.json"
  source       = "file(../slack.json)"
  content_type = "application/json"
  bucket       = google_storage_bucket.test.id
}

# slack.yaml を保存するためのバケット作成
resource "google_storage_bucket" "test2" {
  project       = var.project_id
  name          = "slack-yaml"
  location      = "ASIA-NORTHEAST1"
  storage_class = "STANDARD"

  uniform_bucket_level_access = true
}

# slack.yaml アップロード
resource "google_storage_bucket_object" "test2" {
  name   = "slack.yaml"
  bucket = google_storage_bucket.test2.id

  content = yamlencode({
    apiVersion = "cloud-build-notifiers/v1"
    kind       = "SlackNotifier"
    metadata = {
      name : "cloudbuild-slack-notifier"
    }
    spec = {
      notification = {
        filter = "build.build_trigger_id == '${google_cloudbuild_trigger.github.trigger_id}'
        && build.status in [Build.Status.WORKING,
        Build.Status.SUCCESS, Build.Status.FAILURE,
        Build.Status.INTERNAL_ERROR,
        Build.Status.TIMEOUT,
        Build.Status.CANCELLED]"
        params = {
          buildStatus = "$(build.status)"
        }
        delivery = {
          webhookUrl = {
            secretRef = "webhook-url"
          }
        }
        template = {
          type = "golang"
          uri  = "gs://slack-json/slack.json"
        }
      }
      secrets = [
        {
          name  = "webhook-url"
          value = "projects/${var.project_id}/secrets/webhook_url/versions/latest"
        }
      ]
    }
  })
}

# ID Tokenを発行するための権限を付与する
resource "google_project_iam_member" "pubsub" {
  project = var.project_id

  role   = "roles/iam.serviceAccountTokenCreator"
  member = "serviceAccount:service-${project_number}@gcp-sa-pubsub.iam.gserviceaccount.com"
}

# cloud runを起動するサービスアカウント作成
resource "google_service_account" "for_pubsub" {
  project      = var.project_id
  account_id   = "for-pubsub"
  display_name = "for-pubsub"
  description  = "Cloud Run Pub/Sub Invoker"
}

# 権限を付与する
resource "google_cloud_run_v2_service_iam_member" "member" {
  project  = google_cloud_run_v2_service.test.project
  location = google_cloud_run_v2_service.test.location
  name     = google_cloud_run_v2_service.test.name
  role     = "roles/run.invoker"
  member   = "serviceAccount:${google_service_account.for_pubsub.email}"
}

# CloudRunのデプロイ
resource "google_cloud_run_v2_service" "test" {
  name     = "cloudrun-slack"
  location = "asia-northeast1"
  project  = var.project_id

  template {
    scaling {
      max_instance_count = 1
    }

    containers {
      image = "us-east1-docker.pkg.dev/gcb-release/cloud-build-notifiers/slack:latest"
      env {
        name  = "CONFIG_PATH"
        value = "gs://slack-yaml/slack.yaml"
      }
      env {
        name  = "PROJECT_ID"
        value = var.project_id
      }
    }
  }
}

# トピックの作成
resource "google_pubsub_topic" "slack" {
  name    = "cloud-builds" # 固定
  project = var.project_id
}

# サブスクリプションの作成
resource "google_pubsub_subscription" "subscription" {
  name    = "cloud-builds-subscription"
  project = var.project_id
  topic   = google_pubsub_topic.slack.name

  push_config {
    push_endpoint = google_cloud_run_v2_service.test.uri
    oidc_token {
      service_account_email = google_service_account.for_pubsub.email
    }
  }
}

参考記事

Cloud Build の結果を Slack に通知する
Cloud Build のビルド結果を Slack 通知するためのポイント紹介 – Block kit のカスタマイズ例も
CloudBuild の結果を Slack へ通知する
Slack 通知を構成する
変数値の置換
Cloud Build デフォルト サービス アカウント
GCP からの HTTP リクエストをセキュアに認証する
Cloud Build Notifier

GitHubで編集を提案

Discussion