Cloud StorageにアップロードされたらGoogle Cloud Run FunctionsでSlackに通知
社内勉強会でやったことのメモです。
今回のお題はこちら
Cloud Storage へのファイル配置をトリガーに Cloud Function を発火させ、Slack へ添付ファイルとしてメッセージを送る
Cloud Storage
Google Cloudのオブジェクトストレージ。AWSでいうS3に相当。
ファイルを保管しておける。
Cloud Run Function
Google CloudのFunction-as-a-service (FaaS)。AWSでいうLambdaに相当。
関数を呼び出すことができる。
昔は Cloud Function と呼ばれていたが、今は名前が変わってCloud Run Functionと呼ぶようになったらしい。
Cloud StorageのBucketの作成
まずはBucketを作る必要がある。
最初にAPIを有効にする必要があるが、有効化ボタンを押すだけですぐに使えるようになる。
- 名前:適当な名前を入れる。名前の付け方についてはここにドキュメントがある。
- リージョン:Multi-region
- Dual-region、Regionも選べるが、デフォルトのMulti-regionにしておいた
- レイテンシや高可用性を考慮して選ぶみたいだが、基本的にはMulti-regionを選ぶことになりそう
- ストレージクラス:Standard
- 単に学習目的で特に凝ったことはしないので、Standardクラスを選択
- クラスの説明はここにある
- 公開アクセス:防止
- 削除可能ポリシー:削除(復元可能)ポリシー
(脱線)ストレージクラスについて
- ArchiveストレージはAWS S3 Glacierみたいなものかな?
- AutoclassはAWS S3 Intelligent-Tieringに相当しそう?
- Google Cloudは命名がわかりやすくて良い
- AWSは多種多様なオプションを用意しているようにみえる
(脱線)バケット名にはドメイン名を指定できる
試しに atware.co.jp
のようなドメインを指定してみると、以下のようなメッセージが表示される。
バケット名にドット(.)を使用できるのは、有効なドメイン名(example.com など)を構成する場合のみです。このドメインの使用が承認されていることを確認していない場合は、このバケットを作成する前に確認する必要があります。
- ドメイン名の所有確認を行うことで、そのドメイン名をバケット名としても使うことができるようになる
- 所有確認を行った人はドメイン所有者(ドメインオーナー)となり、組織内のドメイン名の利用を管理できるようになるっぽい
- 委任を行うことで他のユーザーにそのドメインの利用を許可できる。
- 委任されていないユーザは、そのドメイン名を含むバケットを作成できなくなる。
大きな組織では使いたいシーンがあるかもしれない。
Cloud Run FunctionsのFunctionの作成
上部の検索欄に「Cloud Function」と入力すると「Cloud Run関数」が表示されるので、それをクリック。
どうやら「Cloud Run 関数」と「Cloud Run Functions」と表記揺れがある模様。
初めて使う場合はAPIを有効にする必要があった。指示どおりに有効にすれば問題なかった。
関数の作成はこんな感じ。
- トリガーのタイプ:Cloud Storage
- イベントタイプ:
google.cloud.storage.object.v1.finalized
- finalizedはオブジェクトが作成されるか、上書きされた場合に発火するらしい
- https://cloud.google.com/functions/docs/calling/storage?hl=ja
ランタイムの設定はデフォルトのままにした(256MB / 0.167 vCPU)
コードの記述
「次へ」をクリックすると、コードエディタが開く。
言語としては今回Ruby 3.3を使ってみることにした。
言語を選択すると、Rubyのサンプルコードがエディタで開く。
require "functions_framework"
FunctionsFramework.cloud_event "hello_storage" do |event|
logger.info "Received storage event from #{event.source}!"
end
一旦このコードを動かしてみたいので、このまま「デプロイ」を押す。
関数を作成したはずなのに、関数一覧に何も表示されない問題に遭遇。
右上の通知を見ると、エラーが出ていた。
Validation failed for trigger projects/##########/locations/us/triggers/#########: Invalid resource state for "": Permission denied while using the Eventarc Service Agent. If you recently started to use Eventarc, it may take a few minutes before all necessary permissions are propagated to the Service Agent. Otherwise, verify that it has Eventarc Service Agent role.
(和訳)Eventarc Service Agentの利用中にパーミッションが拒否されました。Eventarcを最近使い始めたならService Agentにすべての必要なパーミッションが伝播するまで数分がかかります。そうでない場合、Eventarc Service Agentのロールを確認してください。
先ほどAPIを有効にしたばかりなので、エラーが出た模様。
しばらく待って「再試行」ボタンを押すと問題なく、作成が完了した。
動かしてみる
gcloud CLIをインストールしておいたので、以下のコマンドで動作を検証できる。
gcloud storage cp <ファイル> gs://<作成したバケットの名前>/
うまくいくと、こんな感じでログが出てくる
Cloud Storageにファイルをアップロードしたら処理が動くところまではできた。
このCloud Run Functionを書き換えて、以下の機能を実装したい。
- アップロードされたファイル名を取得する
- ファイルをダウンロードしてくる
- ファイルをSlackに投稿する
1. アップロードされたファイル名を取得する
サンプルを探すと、公式のチュートリアルを見つけることができた。
ファイル名は以下のようにして取得できそう。
payload = event.data
filename = payload['name']
2. ファイルのダウンロード
別途ライブラリが必要になる。https://cloud.google.com/functions/docs/tutorials/imagemagick?hl=ja のサンプルを見ると、Google Cloud Storageのライブラリを使っている。
Rubyだとgoogle-cloud-storageが利用できる。Gemfileに以下のように書く。
gem "google-cloud-storage", "~> 1.54"
Gemfile.lock
を更新しないといけないが、オンラインエディタ上ではできなさそう。
手元からCloud Run Functionsをデプロイできるようにする
チュートリアルを読むと、gcloud CLIを使ってローカルから関数を簡単にデプロイできるようだ。
gcloud functions deploy 関数名 \
--gen2 \
--runtime=ruby33 \
--region=リージョン \
--source=. \
--entry-point=エントリポイント \
--trigger-event-filters="type=google.cloud.storage.object.v1.finalized" \
--trigger-event-filters="bucket=バケット名"
Rakefileで簡単にデプロイできるようにした(以下のコードはCC0 1.0です。自由にお使いください)。
PROJECT_ID = "<プロジェクトのID>"
FUNCTION_NAME = "function-1"
REGION = "us-central1"
TRIGGER_LOCATION = "us"
RUNTIME = "ruby33"
SOURCE_LOCATION = "."
ENTRYPOINT = "hello_storage"
EVENT_TYPE = "google.cloud.storage.object.v1.finalized"
BUCKET_NAME = "<作成したバケット名>"
task :default => [:deploy]
task :deploy do
puts "Start"
system(
"gcloud",
"--verbosity=debug",
"--project=#{PROJECT_ID}"
"functions", "deploy", FUNCTION_NAME,
"--gen2",
"--region=#{REGION}",
"--runtime=#{RUNTIME}",
"--source=#{SOURCE_LOCATION}",
"--entry-point=#{ENTRYPOINT}",
"--trigger-event-filters=type=#{EVENT_TYPE}",
"--trigger-event-filters=bucket=#{BUCKET_NAME}",
"--trigger-location=#{TRIGGER_LOCATION}"
)
if $? == 0 then
puts "Successfully deployed CloudRun function '#{FUNCTION_NAME}'"
else
puts "Failed to deployed CloudRun function '#{FUNCTION_NAME}'!"
end
end
これでライブラリを追加するのが楽になったし、デプロイも簡単に行えるようになった。
実装時にちょっとハマったこと:ダブルクォートの処理
チュートリアルのシェルの実行例に倣って、以下のように書いていた:
"--trigger-event-filters=\"type=#{EVENT_TYPE}\"",
"--trigger-event-filters=\"bucket=#{BUCKET_NAME}\"",
しかし、これでは以下のようなエラーが出て、うまく動作しなかった。
ERROR: (gcloud.functions.deploy) INVALID_ARGUMENT: Trigger event type must be specified.
先に答えを言うと、不要なダブルクォートが含まれてしまっていた。シェルが実際にプロセスを起動するときにはこのダブルクォートを削除してくれるため、このダブルクォートは不要だった。
以下は調査方法のメモ。
シェルから実行する場合ではうまく動いていたので、Rakeから実行する場合とシェルから実行する場合で実行内容を比較してみることにした。
gcloud --verbosity=debug functions deploy ...
のように実行すると、デバッグログが見られることに気づいたので、その内容を比較した。
- DEBUG: Running [gcloud.functions.deploy] with arguments: [--entry-point: "hello_storage", --gen2: "true", --region: "us-central1", --runtime: "ruby33", --source: ".", --trigger-event-filters: "OrderedDict({'"type': 'google.cloud.storage.object.v1.finalized"', '"bucket': 'bucket"'})", --trigger-location: "us", --verbosity: "debug", NAME: "function-1"]
+ DEBUG: Running [gcloud.functions.deploy] with arguments: [--entry-point: "hello_storage", --gen2: "true", --region: "us-central1", --runtime: "ruby33", --source: ".", --trigger-event-filters: "OrderedDict({'type': 'google.cloud.storage.object.v1.finalized', 'bucket': 'bucket'})", --trigger-location: "us", --verbosity: "debug", NAME: "function-1"]
これで気付くことができた。
bundle install中にnio4rのインストールでコケる
functions_framework
が間接的に依存する nio4r
というライブラリのビルドが失敗した。
compiling selector.c
selector.c:301:26: error: incompatible function pointer types passing 'VALUE (*)(VALUE *)' (aka 'unsigned long (*)(unsigned long *)') to parameter of type 'VALUE (*)(VALUE)' (aka
'unsigned long (*)(unsigned long)') [-Wincompatible-function-pointer-types]
Googleで検索すると、下記のGitHub Issueを見つけた。
新しいバージョンでは問題が修正されているらしいので、それを使うようにする。
荒業ではあるが、Gemfile.lockのnio4rのバージョンを修正済みバージョンに書き換えてしまう。
これによりnio4rのビルドが通り、 bundle install が正常に終了するようになった。
2. ファイルのダウンロード(続き)
これでようやく google-cloud-storage が使えるようになる。
- GitHub:https://github.com/googleapis/google-cloud-ruby/tree/main/google-cloud-storage
- ドキュメント:https://googleapis.dev/ruby/google-cloud-storage/latest/
ドキュメントを読むと、まさに目当てのものが見つかった。
file.download("/tmp/file")
を呼ぶと、指定したパス(/tmp/file
)にファイルをダウンロードできる。
3. Slackへの投稿
Slackに添付ファイル付きでメッセージを投稿する方法を調べる。
(余談。昔よく使われていたカスタムインテグレーションは非推奨となったらしい)
メッセージを投稿する
Slackにメッセージをまず投稿してみたい。
Webhookを使う方法が簡単そう(しかし、後にこの方法ではできないと判明する)なので、これを試してみる。
Appの作成とWebhook URLの発行は、上記URLのチュートリアルに従って済ませておく。
発行したWebhook URL に対して curl でリクエストを送ると、期待通りメッセージが届くことを確認できた。
curl -X POST -H 'Content-type: application/json' --data '{"text":"Hello, World!"}' https://hooks.slack.com/services/xxxxxxxxxxx/xxxxxxxxxxx/xxxxxxxxxxxxxxxxxxxxxxxx
ただし、このWebhookではファイルの添付は行えない。
画像を投稿する
files.getUploadURLExternal と files.completeUploadExternal を使うことでファイルをアップロードできる(なお、files.upload は2025年3月に廃止されることが決まっている)。
流れとしては以下のようになる。
- files.getUploadURLExternal を呼び出す。
- 1のレスポンスに含まれるファイルアップロード用URLに対して、HTTP POSTでファイルをアップロードする。
- files.completeUploadExternal を呼び出して、アップロードを完了する。このとき一緒にメッセージを送信できる。
slack-ruby-clientというGemがあるので、これを使うことにする。
ドキュメントを読むと、files_getUploadURLExternalとfiles_completeUploadExternalが用意されているので、これを使えそう。
ファイルのアップロード
2のファイルのアップロードに関しては自力で行う必要がある。slack-ruby-clientは内部でFaradayを使っているようなので、Faradayとfaraday-multipartを使ってファイルアップロードをすることにする。
公式ドキュメントにもあるとおり、Faradayのmultipartを使うときには faraday.request :multipart
を呼ぶ必要がある。
この呼び出しによって、ミドルウェアがデフォルトのurl_encoded
からmultipart
に変わる。
require "faraday/multipart"
conn = Faraday.new(url) do |faraday|
faraday.request :multipart
end
res = conn.post(url, { filename: Faraday::Multipart::File.new(file) });
completeUploadExternal の filesパラメータでinvalid_array_argエラーが発生
client.files_completeUploadExternal
がうまく動かない。
client.files_completeUploadExternal(
files: [{ id: file_id, title: filename }],
channel_id: channel_id,
initial_comment: message
)
上記のコードを実行すると、以下のようにinvalid_array_arg
というエラーで落ちる。
slack-ruby-client-2.4.0/lib/slack/web/faraday/response/raise_error.rb:19:in `on_complete': invalid_array_arg (Slack::Web::Api::Errors::InvalidArrayArg)
files.completeUploadExternal
の仕様を読むと、配列を渡す部分がある。
{
"files": [
{ "id": "ABC1234", "title": "title" }
],
// other parameters
}
この部分が怪しいようだ。
だめだった方法: FaradayのFlatParamesEncoder
Faradayのデフォルトのurl_encoded
ミドルウェアでは対応できないのではないかと考えた。
Faradayは公式にFlatParamsEncoder等の複数パラメータを指定したときのためのエンコーダーを用意している。これを使って files.completeUploadExternal
を呼び出してみたが、私が試した限りではうまく動作しなかった。
複数のパラメータを application/x-www-form-urlencoded
で渡す方法は非標準のものがあるが、複数のオブジェクトの配列を渡すような書き方は自分の知る限りはない・・・。
※?a=1&a=2
や?a[]=1&a[]=2
うまくいった方法:Content-TypeをJSONにする
SlackのほとんどのAPIはapplication/x-www-form-urlencoded
にのみ対応しているが、このAPIはapplication/json
にも対応している。
試しに Faraday を使って files.completeUploadExternal
のペイロードとしてJSONを渡してみると、うまく動作した。
しかし、この方法は slack-ruby-client
を使わずに自分でクエリを書く必要があるので好ましくない。
conn = Faraday.new(SLACK_ENDPOINT) do |faraday|
faraday.request :json # ここでリクエストのシリアライズにJSONを使うように設定
faraday.response :json
end
payload = { files: files }
payload[:channel_id] = channel_id unless channel_id.nil?
payload[:initial_comment] = initial_comment unless initial_comment.nil?
res = conn.post(
"/api/files.completeUploadExternal",
payload,
"Content-Type" => "application/json; charset=utf-8",
"Authorization" => "Bearer #{@token}",
)
うまくいった方法:配列をJSONでエンコードする
files
にJSONでエンコードした配列を渡すといいようだ。
client.files_completeUploadExternal(
files: JSON.dump([{ id: file_id, title: filename }]),
channel_id: channel_id,
initial_comment: message
)
他の言語のSlackライブラリだとどのような実装になっているか調べたところ、配列をJSONでエンコードしている処理を見つけた。試しにJSONでエンコードしてみたところ、うまく動作した。
「Slack invalid_array_arg」 で調べると、記事が見つかる。別のAPIだが、同様にJSONでエンコードして対処する必要があったことを述べている。
実はattachmentsをAPIのオプションとして渡す際には、単純に配列として渡すのではなく、配列をさらにJSON化して文字列として渡してあげる必要があります。
slack-ruby-clientを修正できないか
slack-ruby-client
側でfiles
パラメータがオブジェクトの場合にはJSON.dump
を実行してくれても良さそう。
以前にそのような変更を行ったプルリクエストを見つけることができた。
slack-ruby-client
のコードはslack-api-ref
から自動生成される仕組み。
slack-api-ref
側の定義に"format": "json"
と定義されていれば、自動でパラメータをJSON化してくれるらしい。例えば、chat.postEphemeral
のattachments
はそのように定義されている。
単純に考えるならslack-api-ref
の定義を書き換えれば良さそうだが、実はslack-api-ref
自体もSlackの公式ドキュメントから自動生成されている。
説明にJSON
という文字列が含まれていれば、"format": "json"
が追加される仕組みになっている。
そのため、Slack本体のドキュメントを変更する必要がある。Slackに問い合わせて、ドキュメントを更新してもらわないといけない・・・。
Node.jsのnode-slack-sdkには、fileUploadV2
というメソッドがあり、ファイルアップロードを行う際に複数のメソッドを呼ぶ必要がない。このようなメソッドが slack-ruby-client にあっても良さそうだが、今回は疲れたので、そこまではしないことにする。
最終的なコード
以下のようになった。
動いているところ
gcloud storage cp grocket.png gs://<バケット名>/
とすると関数が動いて、Slackに通知される
感想
久々にRubyを触った
- 意外と覚えていた
- 変数名のミスに気づきづらかった
- 今どきはRuby LSPがあるのでそれを使うべきだと思う
- コードチェッカー、エディタのプラグインで変数の参照ミスなどに気づけるようにしたい
- デバッグ
-
pp
(pretty print) がデフォルトで入ってくれたので動作確認などに便利だった - Slackへの投稿など、ローカルで動く部分のデバッグは別ファイルに切り出せば楽だった
- もう少し早めにやっておけばデバッグ等が楽に進められたかな
-
Google Cloud の Cloud Storage と Cloud Run Functions
- 公式ドキュメントが良かった
- 読みやすいのが高評価
- 原文がいい感じなんだと思う
- 公式の例やチュートリアルが豊富で、詰まることが少なかった
- 読みやすいのが高評価
- 用語や製品名に直感的にイメージしやすい言葉を使ってくれているのでありがたい
- コンソール
- Cloud Runと統合を進めている最中なのか、Cloud Run側とCloud Run Function側と2つの画面があり、少し混乱した
- 様々な製品とのインテグレーションが楽に行えるのが良かった
- 例えば、Cloud Storage と Cloud Run Functions の連携は、Eventarcという製品が間にあるが、あまり意識しなくても使うことができた(初期化時にエラーが一回出たが)
- AWSだと適切なIAMロールを事前に作成して準備する必要がある。試してみる、権限がなくて失敗する、デバッグするというサイクルが必要。
- Google Cloudでは、権限がない場合は付与すべきロールや権限を付けるかどうかをダイアログで確認される。許可をすると、すぐに使えるようになる。
- いざIaCをしようとしてTerraformで同じことをしようとしたときに知らない製品や概念が出てきてハマりそうな気もする。
- けれど、コンソールでハマらずにすぐに使えるというのは、体験として非常に良かった。
- 一つのアカウントの中で気軽にプロジェクトをいくつも作ることができるので、試したり壊したりが簡単なのが良さそう(試していないが)。
Slack
- 配列をJSONでエンコードするという仕様はハマったので、ドキュメントに書いてあると嬉しい