既存のGCPサービスをTerraform化 〜 CloudRun/CloudSchedulerのエラーをCloudFunctionsでSlackに通知

こんにちは、飯沼です。

以前、花田さんが悩みながら構築した「CloudRun/CloudSchedulerのエラーをSlackに通知するサービス」が無事リリースされ、運用が開始されました!

花田さんの記事:

それまでは毎日の運用業務として40以上あるCloudRun/CloudSchedulerのエラーチェックを目視で行っていたのですが、
その作業から解放されました!
ありがたいことです。

今回はこのエラーを通知するサービスをIaC化したお話になります。
Web UIで作成した Sink,Pub/Sub,Cloud FunctionsでSlackにエラー通知を行うサービスをTerraformでコード化してデプロイすることについて書いていきます。

概要

まず、エラーを検知する対象のサービスと、エラーを通知するサービスについて簡単におさらいします。

エラーを検知する対象サービスについて

f:id:AdwaysEngineerBlog:20211028191320p:plain

サービスは3つあり指定時間になると外部データをBigQueryに保存します。
エラーになると後続のサービスに影響があるため、エラーの監視を行いたいです。
この組み合わせが14パターンありますのでエラーを検知する対象は、 14 × 3 で42になります。
(パターンは今後も増える。。。)

エラーを通知するサービスについて

2種類あります。

  • 各サービスでエラーが発生したらSlackに通知(通常エラー)
  • 処理時間が30分を超えたらSlackに通知(処理時間超過エラー)

エラーを通知するサービスの構成図

f:id:AdwaysEngineerBlog:20211028191335p:plain

  • Cloud Logging(Sink):
    • フィルター設定でScheduler/CloudRunの特定のログを取得できるようにします。(フィルター設定を汎用的にすることでパターンが増えてもコードの修正がないようにします)
  • Cloud Pub/Sub:
    • Sinkの宛先をCloud Pub/Subにします。
  • Cloud Functions:
    • Cloud Pub/SubをトリガーにCloud Functionsを動かしてSlackに通知します。

やりたいこと

Web UIで作成したGCPの各サービスをTerraform化します。

  • Cloud Logging(Sink)
  • Cloud Pub/Sub
  • Cloud Functions

ディレクトリ構成

通常エラー用と処理時間超過エラー用とそれぞれディレクトリを分けてCloud Functionsのコードを管理しています。

f:id:AdwaysEngineerBlog:20211028191352p:plain

index.jsがCloud Functionsのコードになります。
それぞれterraformディレクトリを作りました。

同じ構成なので、通常エラー用の方だけ説明します。

Cloud Logging(Sink)のTerraform化

Web UIで作成したSinkをコード化します。

  • Sink名: sink-A-error
  • フィルタ設定:
resource.type=("cloud_scheduler_job" OR "cloud_run_revision")
resource.labels.service_name=~"A-cloudrun" OR resource.labels.job_id=~"A-cron"
NOT textPayload=~"It is very likely that you can safely ignore this message and that this is not the cause of any error you might be troubleshooting."
"error"

cloud_logging.tf

resource "google_logging_project_sink" "sink-A-error" {
  name        = "sink-A-error"
  destination = "pubsub.googleapis.com/projects/${local.project}/topics/${google_pubsub_topic.topic.name}"
  filter      = <<EOT
resource.type=("cloud_scheduler_job" OR "cloud_run_revision")
resource.labels.service_name=~"A-cloudrun" OR resource.labels.job_id=~"A-cron"
NOT textPayload=~"It is very likely that you can safely ignore this message and that this is not the cause of any error you might be troubleshooting."
"error"
EOT

  unique_writer_identity = true
}

ポイント

  • filterに改行を含む文字列を設定したい場合は、heredocで記述します。
  • unique_writer_identity = true とすると p123456789012-123456@gcp-sa-logging.iam.gserviceaccount.com のような名前のサービスアカウントが作られます。
    • この自動で作られるサービスアカウントに「Pub/Sub パブリッシャー」のロールをつける必要があります。
    • Terraformからロールをつける方法もあるのですが、ロールの設定はシステム管理者につけてもらう運用になっているので、サービスアカウントが作成された後つけてもらいます。

参考ドキュメント

Sinkのサービスアカウントについて

シンクを作成すると、Logging によって一意の書き込み ID と呼ばれるシンクのサービスアカウントが新規に作成されます。このサービスアカウントは Cloud Logging によって所有、管理されているため、直接管理できません。シンクが削除されると、このサービスアカウントは削除されます。

Cloud Pub/SubのTerraform化

Web UIで作成したPubSubトピックをコード化します。

  • トピックID:pubsub-A-error

cloud_pubsub.tf

resource "google_pubsub_topic" "topic" {
  name = "pubsub-A-error"
}

参考ドキュメント

Cloud FunctionsのTerraform化

Web UIで作成したCloud Functionsをコード化します。
Terraformでデプロイまで行います。

  • Cloud Functionsのソースコードをzip化
  • zipをGCSにアップロード
  • Cloud Functionsがzipファイルを取得してデプロイ

entry_point は、"sendNotificationSlack"という名前で作っています。
event_trigger は上で作ったpubsub topicにします。

cloud_functions.tf

data "archive_file" "function_archive" {
  type        = "zip"
  output_path = "../index.zip"

  source {
    content  = file("../index.js")
    filename = "index.js"
  }

  source {
    content  = file("../package.json")
    filename = "package.json"
  }
}

resource "google_storage_bucket" "bucket" {
  name = "error-A-slack"
}

resource "google_storage_bucket_object" "archive" {
  name   = "index${data.archive_file.function_archive.output_md5}.zip"
  bucket = google_storage_bucket.bucket.name
  source = data.archive_file.function_archive.output_path
}

resource "google_cloudfunctions_function" "function" {
  name        = "error-A-slack"
  description = "Cloud SchedulerとCloud Runで発生したエラーをslackに通知する"
  runtime     = "nodejs14"

  available_memory_mb   = 256
  source_archive_bucket = google_storage_bucket.bucket.name
  source_archive_object = google_storage_bucket_object.archive.name
  event_trigger {
    event_type = "providers/cloud.pubsub/eventTypes/topic.publish"
    resource   = google_pubsub_topic.topic.name
  }
  entry_point = "sendNotificationSlack"

  environment_variables = {
    PROJECT              = local.project,
    SLACK_WEBHOOK_SM_KEY = local.secret_manager_key,
    SM_VERSION           = local.secret_version,
  }
}

ポイント

  • data archive_file でCloud Functionsのソースコードをzipでアーカイブしています
    • ディレクトリごとアーカイブする方法がお手軽なのですが、不要なファイルまでデプロイしたくないためfile関数を使って必要なファイルだけを指定するようにしました
  • 機密情報をシークレットマネージャで管理して、環境変数にシークレットマネージャのキーを入れるようにしています
    • Cloud Functionsのコード内で扱うSlackのWebhookのような機密情報は環境変数に直接入れないでシークレットマネージャから参照するようにしました
  • シークレットを参照できるように、Cloud Functionsを実行するサービスアカウントに「Secret Manager のシークレットアクセサー」のロールを追加します
    • Cloud Functionsを実行するサービスアカウントは、<プロジェクトID>@appspot.gserviceaccount.com という名前なのでこれにロールを追加します

参考ドキュメント 

Terraformの実行

Terraformを実行するサービスアカウントに下記ロールを追加します

ロール 用途
ストレージ管理者 Terraformの構成作成
Pub/Sub 編集者 Pub/Sub の設定
Cloud Functions 管理者 Cloud Functionsの作成
サービスアカウント ユーザー Cloud Functions作成時に必要
ログ構成書き込み Sinkの設定

terraform apply で、Sink,Pub/Sub,Cloud Functions を構築することができます。

デプロイ後に必要なロールの設定を行います。

作成されたSinkのサービスアカウントにロールを追加します。

ロール 用途
Pub/Sub パブリッシャー シンクを作成したときに、Logging によって一意の書き込み ID と呼ばれるシンクのサービスアカウントが新規に作成される。そのサービスアカウントにロールを追加する

Cloud Functionsを実行するサービスアカウント <プロジェクトID>@appspot.gserviceaccount.com にロールを追加します。

ロール 用途
Secret Manager のシークレットアクセサー シークレットを参照

これでコード化とデプロイまで行うことができました。

感想

すでに動いているサービスをTerraform化しました。
すんなりいくのかなと思っていましたが、実際にやってみるとチームのレビューでは気づかなかったことや変えたほうがいいところが見えてきました。
実装とIaC化をチーム内で別の人が担当したのですが、学びがあり、サービスとしてもブラッシュアップできたので、良い試みだと思いました。
また、チームでロールの管理や機密情報の管理をどうするか話し合えたことも良かったです。
以上になります。何かの学びがあれば幸いです。