🤔

Cloud Run Functionsのディレクトリ構成から考える最適な使い方

に公開

はじめに

バックエンドでシンプルな処理を実装する場合、GCPでは何を使うのが良いでしょうか。

ざっと思いつくプロダクトは以下の通りです。皆さんはそれぞれの違いを説明できますか?

  • Cloud Run Functions
  • Cloud Functions for Firebase
  • Cloud Run Service
  • Cloud Run Jobs
  • Batch

私も恥ずかしながらまだすべては理解できていませんが、プロダクトごとに想定された使い方があるので、要件を考慮しながら最適解を見定める必要があります。

やりたいこと

今回やりたいことは、「firestoreでのドキュメント作成をトリガーに、Slackにメッセージを送信する」というシンプルなものです。

プロダクトを作っていくと、どうしても管理画面がサービスごとに散らばってしまうのですが、エラー通知などに加えて、問い合わせなどの重要なイベントやKPIとなる出来事はすぐにSlackで拾えるような仕組みを作っておくと、後々の運用が楽になります。

この要件はイベントトリガー型のタスクなため、どちらかというと定期実行を想定しており、イベントから起動するには別途カスタマイズが必要なCloud Run JobsとBatchはまず今回は選定対象から除外しました。

Cloud Functions for Firebaseではダメ?

Cloud Functions for Firebaseという名前だけ見ると今回のタスクにピッタリなプロダクトがありますが、少し検証してみたところいくつか制約がありました。具体的には、

  • 対応言語はNode.jsとPythonのみと本家のFunctionsよりも少なくて不安
  • そしてPythonではIDEのdebuggerが機能しないのでloggingやprint文でデバッグする必要がある

という状況です。Functions for Firebaseの方が通常のFunctionsよりもFirebaseのプロダクトとの連携処理がシンプルに書けるというだけで同じことはできるので、こちらも今回は除外することにしました。

ちなみに、debuggerの問題はissueが立てられて1年以上放置されているところを見るに、node.jsの利用が前提になっていて、技術的に不可能なのでは?という印象を受けます。

https://github.com/firebase/firebase-tools/issues/6838

Cloud Run Serviceと Functionsの違い

残ったのはCloud Run (Service) と Functionsですが、この2つの違いは何でしょうか。

少し昔の記事にはなりますが、以下のような比較がありました。


出典: Google Cloud Blog: Cloud Functions vs. Cloud Run: when to use one over the other

画像の通り、Functionsは利用者がメンテナンスしないといけないのはコードのみ、そのほかはマネージドという点がメリットとして挙げられています。なおFuncionsがCloud Runに統合された現在では、デプロイ後に出来上がるインフラはほとんど変わらないようです。

今回の要件ではCloud Run Serviceを使うほどの処理か疑問だったため、シンプルさを重視してまずはFunctionsで実装してみることにしました。

Functionsのディレクトリ構成

今回、Slackに通知したい対象のFirestore コレクションは2つあるので、Functionsを2つ作成することになります。

トリガーとしてEventarcやPub/Subといったプロダクトも必要になりますが、Functionsを使うと関数とデプロイ時のコマンドにそれぞれ少量のコードを追加するだけなので、確かに細かいことは気にしなくて済んで便利です。

function1.py
@functions_framework.cloud_event
def function1(cloud_event: CloudEvent) -> str:
    ...
デプロイ時のコマンド
gcloud functions deploy function1 \
    --runtime python312 \
    # ここでeventarcに必要なオプションを指定
    --trigger-location {firestore-location} \
    --trigger-event-filters type=google.cloud.firestore.document.v1.created \
    --trigger-event-filters database="(default)" \
    --trigger-event-filters-path-pattern=document='your-collection/{document}' \
    --entry-point function1 \
    --set-env-vars GOOGLE_CLOUD_PROJECT=$PROJECT_ID

また、loggingやらslack通知といった共通処理はcommonsというディレクトリにまとめて、各Functionsからimportして使う形にしてみました。

functions/
├── commons
│   ├── slack.py
│   └── custom_logger.py
├── function1
│   ├── main.py
│   └── requirements.txt
├── function2
│   ├── main.py
│   └── requirements.txt

さてこれでデプロイ、としたいところですが、このままではエラーになります。というのも、Functionsではentry pointとなるmain.pyより上の階層にあるコードは指定できない仕組みになっています。

そのため、commonsはビルド時に対象外となってしまい、コードが見つからないエラーが発生します。

ビルド時にcommons を移動させる、パッケージ化するなど他の選択肢も考えられますが、それはそれで複雑化し、Functionsのシンプルさという利点を活かせないので、あまり良い選択肢とは言えないでしょう。

公式ドキュメントを見てみると、複数の関数を管理する場合は、以下のような構成が推奨されていました。

functions/
├── src
│   ├── commons
│   │   ├── slack.py
│   │   └── custom_logger.py
│   ├── function1.py
│   └── function2.py
├── requirements.txt
└── main.py

また、main.py はentry pointとして、具体的な実装をimportするだけのファイルとして定義します。

main.py
from src.function1 import *
from src.function2 import *

この方法だと、確かにコード自体はDRYに書けるのですが、

  • function2側にエラーがあると、function1のデプロイが失敗する
  • 関数ごとにdockerイメージが作成されるので、無駄なストレージ費用が発生する

という形になり、これはこれで関数の数が増えるとデメリットが大きくなります。

どうやらFunctionsで動かすことを想定しているのは、共通コードという概念が生まれないような、私が当初考えていたよりはるかに小さなの処理のようです。

とはいえ今回はまだ2種類だけだったので、このままデプロイすることにしました。

まとめ

ということで、Cloud Run Functionsについて少しだけ詳しくなることができました。

インフラを気にせず関数をデプロイできるというのは確かに便利なので、

  • 最初はCloud Run Functionsでミニマムに実装する
  • 類似の機能が増えてきて、単一のアプリケーションとして作るメリットが生まれてきたら複数エンドポイントをサポートしているCloud Run ServiceかJobsに移行する

という考え方が良いのではないでしょうか。

最後に、Tanukiの情報も是非チェックしていってください。

「Tanuki」テックブログ

Discussion