Cloud RunがGCSトリガーで複数回実行されてしまう?!【トラブルシューティング】

Cloud Run

はじめに

Google Cloud Run を使って、特定の Cloud Storage バケットにファイルがアップロードされたときに自動的に処理を走らせるアーキテクチャはよく使われています。

私もこれを使用し、以下の簡単なシステム構築を行いました。

  1. 音声ファイルをGCSの特定バケットにアップロード
  2. トリガーが起動し、Cloud Runが起動(Whisper APIで文字起こし)
  3. 結果をテキストファイルで別のGCSバケット保存

しかしこの仕組み、特に対策を講じずに構築すると、Cloud Runが同じファイルに対して複数回呼び出されるという問題に直面します。私はこれを解決するために結構な時間を要しました。

今回はその原因と対処法、さらに最終的な解決策として Firestoreのトランザクションを用いた方法について、説明していきます。

なぜ Cloud Runは複数回実行されるのか?

Eventarcトリガーの裏側には Pub/Subがある

Cloud RunにCloud Storageのアップロードイベントを紐づける方法として、Eventarcトリガーを使うのが一般的だと思います。
例:

  • イベントプロバイダ:Cloud Storage

  • イベントの種類google.cloud.storage.object.v1.finalized

  • イベントの受信元:特定のバケット(例:upload-new-file

この Eventarc、実は内部的には Pub/Subを経由してイベントを Cloud Runに送信しています。そのため、Pub/Subの動作仕様がそのまま Eventarcの挙動に影響してきます。

※補足:この Pub/Subは Eventarcが自動的に作成・管理してくれる内部Pub/Subです。自分で設定・管理する必要は基本的にありません。

原因1:Pub/Sub の “at-least-one”


Google Cloudの公式ドキュメント(Pub/Subへの通知の配信保証)によると、Pub/Subの仕様として「at-least-once」という保障があります。この保証は、名前の通り最低 1 回の配信は保証されています。逆にいうと、同じイベントが複数回配信される可能性があると明記されています。

これはバグではなく設計上の仕様であり、Pub/Subを使う以上は必ず対処すべき性質です。

原因2:Cloud Run の応答遅延による再送


こちらもGoogle Cloudの公式ドキュメント(再試行イベント)によると、「Pub/Subがメッセージの配信を試み、宛先がメッセージを確認応答できない場合、Pub/Subは最小指数バックオフ(10 秒)でメッセージを再送します」という記載があります。

Cloud RunはPub/Sub(Eventarc)からのイベントを受け取ったあと、HTTPで 200 OK を返すまで「処理中」となります。EventarcのPub/Sub側では一定時間待機をし、Cloud Runからの 200 OK が返ってきたら、配信作業完了と判断します。
従って、今回のように外部APIを叩いて処理に時間がかかった際、具体的にはPub/Sub側で設定している待機時間内に200 OK が返されなかった場合、「配信ミス」と判断され、再度イベントが配信されてしまいます

今回で言うと、Whisper APIにリクエストしている間に200 OK を返せず、Pub/Sub 側が「配信ミス」と判断し、同じイベントをもう一度 Cloud Run に送ってくるという流れで何度も関数が実行されていました。

今回起きた「時間差で再実行が起きる」主な要因はこれだと考えています。

はじめに行った対処方法

対策α:Firestoreを使用した「実行」チェック

Firestoreを使用して、トリガー元になっている特定のGCSバケットにアップロードされた「ファイル名」とその「バージョン(generation)」を記録するようにします。このようにすることで以下の流れが確立されます。

  • Cloud Run実行
  • Firestoreを確認
    - 記録なしの場合:上記のルール通りに記録。文字起こしスタート
    - 記録ありの場合:「実行済み」として判断し200 OKを返す。

外部APIを叩く前にFirestoreに記録をしておくと、処理に時間がかかり2回目が実行されたとしても、即座に200 OKの返答ができ3回目の実行を防ぐことができます。

ただし、この方法にはいくつかの問題点があります。エラーが発生した場合、再びCloud Runを実行させるためには、Firestoreに保存したデータを削除する必要があります。

対策β:Eventarcの再試行待機時間を調整

前述したように、Eventarcの再試行待機時間はデフォルトで10秒になっています。今回Whisper APIを叩いて処理を行っているCloud Runはほぼ確実に10秒以上はかかってしまいます。この場合、デフォルトのままだと、少なくとも1回は「配信ミス」と捉えられ、配信が再発行されてしまいます。

そこで、Eventarcのデフォルトの再試行待機時間を伸ばしました。
癖があるのが、この設定はGUIにはないという点です。従ってgcloud CLI を使って、Pub/Sub が Cloud Run からの 200 応答をより長く待つように設定します。

またEventarcのトリガー自体ではなく、そのトリガーが使用している基盤となるPubSubのサブスクリプションを更新する必要があるということもポイントになります。

そのために、まずはサブスクリプションの確認を行い、その後更新を行います。

# 1. まずは、トリガーに関連するPub/Subサブスクリプションを確認
gcloud pubsub subscriptions list | grep your-trigger-name

# 出力結果例:
# name: projects/your-project-name/subscriptions/eventarc-your-region-your-trigger-name-sub-123
# topic: projects/your-project-name/topics/eventarc-your-region-your-trigger-name-456

# 2. 「サブスクリプション」の最小リトライ遅延を600秒に設定
gcloud pubsub subscriptions update eventarc-your-region-your-trigger-name-sub-123 \
  --min-retry-delay=600s

これにより、長い処理時間がかかっても 200 OK を適切な時間を待つようになり、「配信ミス」と判断せず配信イベントの再送がされなくなります。

それでも同時実行の問題は解決できない


上記のαとβ方法では、原因2:Cloud Runの応答遅延による再送 は防げます。

しかし、原因1:Pub/Subの「at-least-one」の仕様により、ほぼ同時に配信され、Cloud Runが複数立ち上がってしまう状況は、Firestoreに記録が完了する前に起動してしまい、これらは異なる配信イベントとして扱われるため、再試行待機時間の調整による重複防止の仕組みも機能しません。

Firestoreのトランザクションを使った対策

Firestore にはトランザクションという仕組みがあります。これは 複数プロセスが同時に同じドキュメントを触っても、一方だけが成功する という排他制御を提供してくれます。
トランザクションについてとてもわかりやすく紹介しているブログがあったので、以下に記載しておきます。
参考ブログ:Firestoreのtransactionの使いどころと使い方

トランザクションのコード例

@firestore.transactional
def update_in_transaction(transaction, doc_ref):
    snapshot = doc_ref.get(transaction=transaction)
    if snapshot.exists:
        return False
    transaction.set(doc_ref, {"processed": True})
    return True

def mark_and_check_if_new(bucket_name, file_name, generation):
    db = firestore.Client()
    doc_id = f"{bucket_name}/{file_name}/{generation}"
    doc_ref = db.collection("processed_events").document(doc_id)

    transaction = db.transaction()
    try:
        return update_in_transaction(transaction, doc_ref)
    except Exception as e:
        print(f"Firestore transaction failed: {e}")
        return False

これを実装することにより、同じファイルに対する処理は最初の1回だけしか通らないようになります。2つ以上のインスタンスが同時にこのコードを走らせようとしても、最初に処理したインスタンスだけが transaction.set() を通過でき、Cloud Runの複数回実行を制御することができました。

まとめ

Cloud RunのGCSトリガーにおける複数回実行問題は、以下の要因で起きていました。

  • Pub/Sub の最低1回保証によりイベントが再送されることがある
  • Cloud Runの200 OKの応答の遅れによるタイムアウト

これに対し以下の対策を併用することで、重複実行を防ぐ仕組みを作成しました。

  • Eventarcの再試行待機時間を調整
  • Firestoreのトランザクションを活用

今回の経験から、Cloud Runを使って時間のかかる処理や外部API連携を設計する際は、「複数回呼ばれる前提」で防衛策を講じておくことが重要であると気づきました。

今回の私が作成したシステムでは、Cloud Runの複数回の実行に関する課金は大した問題ではありませんでした。しかし、Whisper APIが複数回叩かれることに関しては、読み込ませるファイルによっては結構な課金額になってきます。今回はテスト段階でこの仕様に気づくことができたので、ファイル容量も大きくなく、無視できる程度の課金額で済みました。

危なかった、、、

おまけ:Firestore以外の選択肢

この問題に対し、他にも以下の対応策が考えられます。

  • Cloud Tasks を使ってキュー制御
  • Pub/Sub → Cloud Run functions → Cloud Runの非同期化

Cloud Run単体で完結させたい場合は今回のFirestoreトランザクション戦略が手軽かと思います。

Previous Post