Eventarcを使用した非同期でジョブを実行するSlackAppづくり


はじめに

本記事はGoogle Cloud + Gaming Advent Calendar 2020 7日目の記事です。
Cloud Runを使ったSlackAppにおいて普通のVMにおける開発とは違う注意点があったのでそのあたりを書いていきたいと思います。
なぜCloud Runを使うかについては「開発環境をGKE運用からサーバーレス運用に変更してみた」にて書かせていただきました。こちらも合わせて読んでいただけると嬉しいです。とはいえSlackAppづくりだけであれば同じサーバーレスサービスのCloud Functionsの方が向いているとは思います(笑)。
また本記事でのEventarcについては一部まだpreview版の機能を使用しているため今後の変更により動かなくなることもあるかもしれませんのでご了承ください。

Eventarcについて

先月発表されましたGCPの新しい機能です。下手な説明するよりもこちらの記事が分かりやすいです。

簡潔に説明するのなら、、上記タイトルの通りです(笑)。
以前からCloud BuildCloud Storage等のイベントは特定のCloud Pub/Subトピックにpushされる仕組みがあって(cloudbuild-notifications, storage-notifications)それを使ってCloud Functions等を動かしていたので、特別使用感が変わることはなさそうですが、Eventarcによってそれらイベントが一元管理されるのかな、ぐらいの認識です。
今後のビジョンとしてはCloud Run意外のたくさんのトリガー(Event Sinkというみたいです)を作れるようになるみたいです。
Long Term Vision

まだ発表されたばかりなので今後の動きに注目です!

とはいえ現在はCloud RunのみSinkを作れるのでCloud Functionsではまだ使えません。
したがって(?)、本記事のタイトルはEventarcですが基本的にはCloud Runの話です。 Eventarcのユースケースを考えていたらSlackAppづくりで使えそうと思ったので、、手段が目的になっています。

Cloud RunにおけるSlackAppづくりの注意点

環境変数に機密情報を入れてはいけない

多くの場合SlackApp開発のサンプルコードでは、SlackのAccessTokenやSigningSecretは環境変数から取得するものが多いと思います。普通ならそれでも特に問題ないのですがGCPのサーバーレスサービスにおいては大問題です。これはCloud RunだけでなくCloud Functionsも同様です。
GCPのマネージドサーバーレスサービスはログをCloud Loggingに出力してくれます。とても便利です。しかし、使用しているライブラリによっては環境変数をすべてログに出力するようなものもあるみたいなので、環境変数に機密情報を入れていると思わぬところで流出しかねません(外部に、というよりログ閲覧権限しかIAMで振ってない人やサービスに対しても、という意味。)。したがって環境変数に機密情報を格納すべきではないです。

Cloud Run

注意: Secret の保存と使用に環境変数を使用しないでください。

Cloud Functions

環境変数は関数の構成に使用できますが、データベースの認証情報や API キーなどの機密情報の格納には適しません。 このような機密性の高い値は、ソースコードや外部の環境変数以外の場所に保存する必要があります。一部の実行環境やフレームワークでは、環境変数の内容がログに送信されることがあります。

ただ、そもそもCloud RunやCloud Functionsに限らず環境変数に機密情報は置かないほうが良いと思います。これは個人の意見です。

機密情報管理のベストプラクティス

ずばりSecret Managerを使うことです。とても簡単に使えて便利です。
あまりに簡単なのでドキュメント通りに作るだけです。普段はgcloudコマンドで作成・更新しています。Makefileにまとめているのでgcloudコマンドを意識してはいませんが(笑)。

Secret Managerへアクセスするための設定(Terraform)

普段GCPの設定はTerraformを使っているのでTerraformにて説明していきます。ただし、Secret自体の作成・更新は性質上terraformのコードとはリポジトリを切り分けたかったのでterraformではなくgcloudコマンドを使っています。

▼ サービスアカウントの作成

resource "google_service_account" "this" {
  account_id   = "サービスアカウントのID(なんか適当に)"
  display_name = "CloudRun用のサービスアカウント"
}

▼ 作成したサービスアカウントにSecretManagerへのアクセス権を付与

resource "google_secret_manager_secret_iam_member" "this" {
  secret_id = "作成したシークレット名"
  role      = "roles/secretmanager.secretAccessor"
  member    = "serviceAccount:${google_service_account.this.email}"
}

▼ そのサービスアカウントをCloudRunサービスに使わせる
Cloud Run自体のTerraformはいろいろ省略してますがサービスアカウントの部分だけを見ていただければと思います。
これをしないとデフォルトではCompute Engine のデフォルトのサービス アカウントが使用されます。それはEditor権限を持ったサービスアカウントなので強すぎますドキュメントでは専用のサービスアカウントにすることをおすすめする程度ですがCloud Runのサービスごとサービスアカウントは分けたほうが良いと思います。これは個人の意見です。
あとEditor権限だけだとSecretManagerへのアクセス権はないので共同開発者にはEditor権限とは別にSecret Manager Admin権限等も必要かもしれません。

resource "google_cloud_run_service" "this" {
  name     = "CloudRunサービス名"
  location = "asia-northeast1"

  template {
    spec {
      containers {
        // 省略...
      }
      service_account_name = google_service_account.this.email
    }
  }
}

Secret Managerへアクセスする(Golang)

普段Golangで開発することが多いのでGolangでの説明になります。また、実際に動いているコードではなく抜粋して編集しているため、もしかするとそのままだと動かないかもしれません。
また、SecretManagerで保存したテキストは下記のようなyaml形式を想定したものです。

SlackAccessToken: xoxb-xxxxxxxxxxx-xxxxxxxxxx
SlackSigningSecret: xxxxxxxxxxxxxxxxxxxxxx
package main

import (
    "context"
    "fmt"
    "os"

    "cloud.google.com/go/secretmanager/apiv1"
    secretmanagerpb "google.golang.org/genproto/googleapis/cloud/secretmanager/v1"
    "gopkg.in/yaml.v2"
)

func main() {
    // Cloud RunではPROJECT_IDとPORTが自動でついてきます。
    // httpサーバーをリッスンするときにこのPORTを使います。
    port := os.Getenv("PORT")
    projectID := os.Getenv("PROJECT_ID")
    // SecretManagerのSecretNameはコード埋め込みでも環境変数でもどちらでもいいと思います。
    secretName := "demo-secret"
    // SecretManagerのSecretVersionは環境変数で渡してます。
    // これに関してはコード埋め込みだとCloudRun上では使い勝手悪いと思います。
    secretVersion := os.Getenv("SECRET_VERSION")

    ctx := context.Background()

    // Secret Manager Client作成
    client, err := secretmanager.NewClient(ctx)
    if err != nil {
        panic(err)
    }
    defer client.Close()

    // Secret Managerへアクセスする
    req := &secretmanagerpb.AccessSecretVersionRequest{
        Name: fmt.Sprintf("projects/%s/secrets/%s/versions/%s", projectID, secretName, secretVersion),
    }
    result, err := client.AccessSecretVersion(ctx, req)
    if err != nil {
        panic(err)
    }

    // yamlのunmarshal
    cfg := &config{}
    if err := yaml.Unmarshal(result.Payload.Data, cfg); err != nil {
        panic(err)
    }
}

type config struct {
    SlackAccessToken   string   `yaml:"SlackAccessToken"`
    SlackSigningSecret string   `yaml:"SlackSigningSecret"`
}

slackの制約上3秒以内にレスポンスしたいが非同期でジョブを動かせない

本記事の本題です。
まずslackのslash commandやinteractive eventは3000ms以内にレスポンスを返さないとタイムアウトしてしまいます
したがってslash command等で重いジョブを動す場合、通常であれば一度slackにレスポンスを返して裏で非同期でジョブを動かす手法が多いと思います。シーケンス図で表すとこのようなイメージだと思います。

User -> Slack : スラッシュコマンド実行
Slack -> 自作SlackApp : webhook発行
自作SlackApp -> 自作SlackApp : Slackからの通信か検証
自作SlackApp -> Slack : 一旦200OKで返す
Slack -> User : スラッシュコマンドが完了する
自作SlackApp -> 自作SlackApp : 任意のジョブ動かす
自作SlackApp -> Slack : chat.postMessage APIを使ってジョブ結果を伝える
Slack -> User : 追加の情報が表示される

Cloud Runはリクエストがなくなればコンテナがなくなる?

Cloud Run は、リクエストがない場合はゼロにスケーリングし、リソースを使用しません。

リクエスト終了からどのくらい経つとコンテナが終了するのかはわかりませんが、さきほどのシーケンス図でいうところの「一旦200OKで返す」をして任意のジョブを動かしている最中にコンテナが強制終了してしまうかもしれません。これに関しては申し訳ありませんが未確認です。

Webhookプロバイダにタイムアウト制約がある場合の非同期処理

前述の方法でジョブが中断されるかは未確認ではありますが、ドキュメントにはこういったケースの注意事項が記載されています。

データ処理が Cloud Run または Webhook プロバイダによって割り当てられた時間を超える場合は、Pub/Sub や Cloud Tasks など、処理を非同期で完了することを許可するプロダクトを使用する必要があります。これらのプロダクトを使用すると、データを迅速に引き渡し、すぐに Webhook プロバイダに成功レスポンスを返して、タイムアウトの心配なく処理を続行できます。

まさにこれはslackのことを言っているのではないでしょうか!
ということで従来であればPub/Subへpushして別のCloud Runがそのpushをトリガーに起動する、といった方法を取るところですが、今回はEventarcを使っていきたいと思います。

Eventarc設定方法

EventarcについてはまだTerraformもありませんしGCPコンソール上にもありませんのでgcloudコマンドで設定していきます。
ドキュメントはこちらです。
...書いていこうかと思ったのですが、ほぼドキュメントと同じ記述になってしまうのでやめました。
golang側のコードに関してもこちらがそのまま使えると思います。
簡単な流れは

  • Eventarcをトリガーに起動する用(Event Sink)のCloud Runを作成する。
    • 今回でいうとこれはジョブを実行する用のCloud Runです。
  • そのCloud Run(Event Sink)に紐付いたEventarcのpub/subトリガーを作成
  • するとpub/subトピックが作成されている
  • slackからのレシーバー用Cloud Runからそのトピックへpushする
User -> Slack : スラッシュコマンド実行
Slack -> 自作SlackApp1 : webhook発行
自作SlackApp1 -> 自作SlackApp1 : Slackからの通信か検証
自作SlackApp1 -> CloudPubSub : Eventarc用のトピックへ情報をpush
自作SlackApp1 -> Slack : 200OKで返す
Slack -> User : スラッシュコマンドが完了する
CloudPubSub -> 自作SlackApp2 : Eventarcによって起動する
自作SlackApp2 -> 自作SlackApp2 : 任意のジョブ動かす
自作SlackApp2 -> Slack : chat.postMessage APIを使ってジョブ結果を伝える
Slack -> User : 追加の情報が表示される

まとめ

今回Eventarcのユースケースについて考えてみました。Eventarcを使わずに普通のPub/Subを使ってしまうと実装者によってバラバラなフォーマットがトピックに流れてしまうかもしれません。Eventarcを使うことで統一され、より整合性のあるコードになるのではないかと思います。また、今回カスタムソースについてしか触れませんでしたが、他にも60を超えるGoogleCloudソースが扱えるわけですから、別の利用方法も模索していきたいと思います。