Closed18

Cloud StorageにアップロードされたらGoogle Cloud Run FunctionsでSlackに通知

mno007mno007

社内勉強会でやったことのメモです。

今回のお題はこちら

Cloud Storage へのファイル配置をトリガーに Cloud Function を発火させ、Slack へ添付ファイルとしてメッセージを送る

mno007mno007

Cloud Storage

Google Cloudのオブジェクトストレージ。AWSでいうS3に相当。
ファイルを保管しておける。

Cloud Run Function

Google CloudのFunction-as-a-service (FaaS)。AWSでいうLambdaに相当。
関数を呼び出すことができる。

昔は Cloud Function と呼ばれていたが、今は名前が変わってCloud Run Functionと呼ぶようになったらしい。
https://blog.g-gen.co.jp/entry/cloud-run-functions-rebranding

mno007mno007

Cloud StorageのBucketの作成

まずはBucketを作る必要がある。

最初にAPIを有効にする必要があるが、有効化ボタンを押すだけですぐに使えるようになる。

  • 名前:適当な名前を入れる。名前の付け方についてはここにドキュメントがある。
  • リージョン:Multi-region
    • Dual-region、Regionも選べるが、デフォルトのMulti-regionにしておいた
    • レイテンシや高可用性を考慮して選ぶみたいだが、基本的にはMulti-regionを選ぶことになりそう
  • ストレージクラス:Standard
  • 公開アクセス:防止
  • 削除可能ポリシー:削除(復元可能)ポリシー

(脱線)ストレージクラスについて

  • ArchiveストレージはAWS S3 Glacierみたいなものかな?
  • AutoclassAWS S3 Intelligent-Tieringに相当しそう?
  • Google Cloudは命名がわかりやすくて良い
  • AWSは多種多様なオプションを用意しているようにみえる

(脱線)バケット名にはドメイン名を指定できる

試しに atware.co.jp のようなドメインを指定してみると、以下のようなメッセージが表示される。

バケット名にドット(.)を使用できるのは、有効なドメイン名(example.com など)を構成する場合のみです。このドメインの使用が承認されていることを確認していない場合は、このバケットを作成する前に確認する必要があります。

  • ドメイン名の所有確認を行うことで、そのドメイン名をバケット名としても使うことができるようになる
  • 所有確認を行った人はドメイン所有者(ドメインオーナー)となり、組織内のドメイン名の利用を管理できるようになるっぽい
    • 委任を行うことで他のユーザーにそのドメインの利用を許可できる。
    • 委任されていないユーザは、そのドメイン名を含むバケットを作成できなくなる。

https://cloud.google.com/storage/docs/domain-name-verification?hl=ja

大きな組織では使いたいシーンがあるかもしれない。

mno007mno007

Cloud Run FunctionsのFunctionの作成

上部の検索欄に「Cloud Function」と入力すると「Cloud Run関数」が表示されるので、それをクリック。
どうやら「Cloud Run 関数」と「Cloud Run Functions」と表記揺れがある模様。

初めて使う場合はAPIを有効にする必要があった。指示どおりに有効にすれば問題なかった。

関数の作成はこんな感じ。

ランタイムの設定はデフォルトのままにした(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

一旦このコードを動かしてみたいので、このまま「デプロイ」を押す。

mno007mno007

関数を作成したはずなのに、関数一覧に何も表示されない問題に遭遇。

右上の通知を見ると、エラーが出ていた。

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を有効にしたばかりなので、エラーが出た模様。

しばらく待って「再試行」ボタンを押すと問題なく、作成が完了した。

mno007mno007

動かしてみる

gcloud CLIをインストールしておいたので、以下のコマンドで動作を検証できる。

gcloud storage cp <ファイル> gs://<作成したバケットの名前>/

うまくいくと、こんな感じでログが出てくる

mno007mno007

Cloud Storageにファイルをアップロードしたら処理が動くところまではできた。

このCloud Run Functionを書き換えて、以下の機能を実装したい。

  1. アップロードされたファイル名を取得する
  2. ファイルをダウンロードしてくる
  3. ファイルをSlackに投稿する
mno007mno007

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 を更新しないといけないが、オンラインエディタ上ではできなさそう。

mno007mno007

手元から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"]

これで気付くことができた。

mno007mno007

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を見つけた。

https://github.com/socketry/nio4r/issues/290

https://github.com/socketry/nio4r/issues/298#issuecomment-1820101890

新しいバージョンでは問題が修正されているらしいので、それを使うようにする。

荒業ではあるが、Gemfile.lockのnio4rのバージョンを修正済みバージョンに書き換えてしまう。

これによりnio4rのビルドが通り、 bundle install が正常に終了するようになった。

mno007mno007

2. ファイルのダウンロード(続き)

これでようやく google-cloud-storage が使えるようになる。

ドキュメントを読むと、まさに目当てのものが見つかった。

https://googleapis.dev/ruby/google-cloud-storage/latest/Google/Cloud/Storage/File.html

file.download("/tmp/file")を呼ぶと、指定したパス(/tmp/file)にファイルをダウンロードできる。

mno007mno007

3. Slackへの投稿

Slackに添付ファイル付きでメッセージを投稿する方法を調べる。

(余談。昔よく使われていたカスタムインテグレーションは非推奨となったらしい)

メッセージを投稿する

Slackにメッセージをまず投稿してみたい。

Webhookを使う方法が簡単そう(しかし、後にこの方法ではできないと判明する)なので、これを試してみる。
https://api.slack.com/messaging/webhooks

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.getUploadURLExternalfiles.completeUploadExternal を使うことでファイルをアップロードできる(なお、files.upload は2025年3月に廃止されることが決まっている)。

流れとしては以下のようになる。

  1. files.getUploadURLExternal を呼び出す。
  2. 1のレスポンスに含まれるファイルアップロード用URLに対して、HTTP POSTでファイルをアップロードする。
  3. files.completeUploadExternal を呼び出して、アップロードを完了する。このとき一緒にメッセージを送信できる。

slack-ruby-clientというGemがあるので、これを使うことにする。

ドキュメントを読むと、files_getUploadURLExternalfiles_completeUploadExternalが用意されているので、これを使えそう。

mno007mno007

ファイルのアップロード

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) });
mno007mno007

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でエンコードして対処する必要があったことを述べている。
https://techblog.raccoon.ne.jp/archives/48972607.html

実はattachmentsをAPIのオプションとして渡す際には、単純に配列として渡すのではなく、配列をさらにJSON化して文字列として渡してあげる必要があります。

mno007mno007

slack-ruby-clientを修正できないか

slack-ruby-client側でfilesパラメータがオブジェクトの場合にはJSON.dumpを実行してくれても良さそう。

以前にそのような変更を行ったプルリクエストを見つけることができた。
https://github.com/slack-ruby/slack-ruby-client/pull/448/files

slack-ruby-clientのコードはslack-api-ref から自動生成される仕組み。

slack-api-ref側の定義に"format": "json"と定義されていれば、自動でパラメータをJSON化してくれるらしい。例えば、chat.postEphemeralattachmentsそのように定義されている

単純に考えるならslack-api-refの定義を書き換えれば良さそうだが、実はslack-api-ref自体もSlackの公式ドキュメントから自動生成されている。

説明にJSONという文字列が含まれていれば、"format": "json"が追加される仕組みになっている。

https://github.com/slack-ruby/slack-api-ref/pull/64/files#diff-c45b16889185d00e09fc2c606a28d87d1fbc3969bd90590f7828505c7c66bb96R96

そのため、Slack本体のドキュメントを変更する必要がある。Slackに問い合わせて、ドキュメントを更新してもらわないといけない・・・。

Node.jsのnode-slack-sdkには、fileUploadV2というメソッドがあり、ファイルアップロードを行う際に複数のメソッドを呼ぶ必要がない。このようなメソッドが slack-ruby-client にあっても良さそうだが、今回は疲れたので、そこまではしないことにする。

mno007mno007

感想

久々に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でエンコードするという仕様はハマったので、ドキュメントに書いてあると嬉しい
このスクラップは2025/01/21にクローズされました