🌀

Cloud Functions から Cloud Run functions に移行してみたら学びが深かった話

2024/08/23に公開2

8/22 に Cloud Functions の Cloud Run functions へのリブランディングが発表されましたね!都合よく、Cloud Functinos の第一世代を使った簡単な定期実行ジョブがあったので Cloud Run functions に移行してみようと思います。

https://cloud.google.com/blog/products/serverless/google-cloud-functions-is-now-cloud-run-functions?hl=en

また、最速でこちらの記事が詳細を説明してくださっています。非常にありがたいです。

https://blog.g-gen.co.jp/entry/cloud-run-functions-rebranding

移行自体簡単にできるかと思っていましたが、「ただリブランディングされて Cloud Run の特徴を追加で利用できるようになった」 と認識していた私は恥ずかしながらドツボにハマりました。前半はコンソールでの Cloud Run functions のデプロイ、後半はデプロイ後にハマりまくったポイントについてまとめています。

結論を先にお伝えしておくと、「Cloud Run functions は Cloud Run Service の一員(デプロイタイプが関数)であると言う認識を持った上で公式ドキュメントをちゃんと読もう」 というオチでした。興味がある方はぜひ読み進めていただければと思います!

この記事のターゲット

  • Google Cloud でコンテナを用意することなく手軽にコードを実行したい方
  • Cloud Run fuctions のデプロイ方法を知りたい方
  • デプロイ後にどんなドツボにハマったかが気になっている方

どんなジョブ?

Cloud Scheduler + Cloud Pub/Sub + Cloud Functions の構成で、定時になったら特定のラベルが付与されている Compute Engine を開始・停止させるものです。
かなり前に作成されたコードなので、モダンではないかもですが一旦中身の改修は置いておきます。ここでは停止用のコードを記述します。(後半はこのコードでしくじりまくります。)

stopInstancePubSub/index.js
stopInstance/index.js
const Compute = require('@google-cloud/compute');
const compute = new Compute();

/**
 * Stops Compute Engine instances.
 *
 * Expects a PubSub message with JSON-formatted event data containing the
 * following attributes:
 *  zone - the GCP zone the instances are located in.
 *  label - the label of instances to stop.
 *
 * @param {!object} event Cloud Function PubSub message event.
 * @param {!object} callback Cloud Function PubSub callback indicating completion.
 */
exports.stopInstancePubSub = async (event, context, callback) => {
  try {
    const payload = _validatePayload(
      JSON.parse(Buffer.from(event.data, 'base64').toString())
    );
    const options = {filter: `labels.${payload.label}`};
    const [vms] = await compute.getVMs(options);
    await Promise.all(
      vms.map(async instance => {
        if (payload.zone === instance.zone.id) {
          const [operation] = await compute
            .zone(payload.zone)
            .vm(instance.name)
            .stop();

          // Operation pending
          return operation.promise();
        } else {
          return Promise.resolve();
        }
      })
    );

    // Operation complete. Instance successfully stopped.
    const message = 'Successfully stopped instance(s)';
    console.log(message);
    callback(null, message);
  } catch (err) {
    console.log(err);
    callback(err);
  }
};

/**
 * Validates that a request payload contains the expected fields.
 *
 * @param {!object} payload the request payload to validate.
 * @return {!object} the payload object.
 */
const _validatePayload = payload => {
  if (!payload.zone) {
    throw new Error("Attribute 'zone' missing from payload");
  } else if (!payload.label) {
    throw new Error("Attribute 'label' missing from payload");
  }
  return payload;
};

どうやって移行する?①

まずは、コンソールから Cloud Run のサービスタブを開きます。「関数を作成」ボタンがあるのでこちらをクリック。

「サービス名」「リージョン」「ランタイム」を適宜入力・選択します。オプションで「Trigger」を選択できるので、ここでは既存のもの通り定期実行させるために「Pub/Sub トリガー」を選択します。

選択すると「Eventarc トリガー」というパネルが開いて、「トリガーの名前」「トリガーのタイプ」「イベントプロパイダ」「イベントタイプ」などの入力事項が必要なようです。「Pub/Sub トリガー」を選択すると「Google のソース」「Cloud Pub/Sub」「google.cloud.pubsub.topic.messagePublished」が自動で選択されていました。

Cloud Pub/Sub での連携の際は、「Cloud Pub/Sub トピック」「リージョン」「サービスアカウント」など適宜入力・選択をしてします。警告で下記の表示があったので、確認した上で付与します。

後半の設定は Cloud Run で見慣れたものとなっていました。Cloud Functions の感覚でさくっといけると思っていると設定値が思ったより多いという印象を受けました。

また、こちらの設定画面では Cloud Functions のようなソースコードを設定する項目はなかったのでコードはどこで作成するのだろうと思いつつ Cloud Run functions を作成しました。

Eventarc とは?

Eventarc は名前知っている程度だったので、公式ドキュメントから概要を引用します。イベント駆動で処理フローを構築する際のトリガーなどを管理してくれるサービスのようです。

Eventarc を使用すると、基盤となるインフラストラクチャを実装、カスタマイズ、またはメンテナンスすることなく、イベント ドリブン アーキテクチャを構築できます。Eventarc は、分離されたマイクロサービス間の状態変更(イベント)フローを管理するための、標準化されたソリューションを提供します。トリガーされると、Eventarc は配信、セキュリティ、認可、オブザーバビリティ、エラー処理の管理を行いながら、これらのイベントをさまざまな宛先にルーティングします(このドキュメントのイベントの宛先を参照)。
公式ドキュメント - Eventarc の概要

Cloud Functions では関数に直接 Cloud Pub/Sub からメッセージを受け取るような形になっていましたが、Cloud Run functions のトリガーは Eventarc 管理のリソースを用いる形になっていました。

どうやって移行する?②

作成が完了すると、Cloud Run サービスのソースの項目に自動で遷移しました。

一応、Cloud Run サービスの一覧画面を見ると、作成したサービス自体は存在していてデプロイタイプが「関数」となっていました。

上記のコードと package.json を雑にコピペして保存して再デプロイしてみます。

最後にちゃんと発火するかを確認するために Cloud Scheduler から発火させてみます。

・・・あれ、うまくいかない。エラー吐いて起動しっぱなしだ。。
ということで、ここからはドツボにハマったポイントを 3 つまとめていきます。

なんでうまくいかなかった?①

エラーとしては、想定している引数と異なっているというものでした。

Cloud Functions の時に作成したスクリプトでは、下記のように event.data で base64 でエンコードされた文字列を取得することができました。しかし、このまま Cloud Run functions の新たな関数にコードを移行すると event.data が undefined となっていて怒られていました。

stopInstancePubSub.js
exports.stopInstancePubSub = async (event, context, callback) => {
  try {
    const payload = _validatePayload(
      JSON.parse(Buffer.from(event.data, 'base64').toString())
    );
    ...

この event.data には Cloud Scheduler で設定したメッセージが入ってくる想定でした。

Cloud Run functions で event から想定のデータを取得するのに四苦八苦しましたが、最終的には下記のコードで想定通りの挙動となりました。(ちなみに既存のランタイムが Node.js 10 を使用していたため、今回 Node.js 20 にあげたことで callback は不要となりました。)

stopInstancePubSub.js
exports.stopInstancePubSub = async (event, context) => {
  try {
    const eventData = event.body.message.data;
    if (eventData === undefined) {
        console.log('eventData is undefined')
        return;
    }
    const payload = _validatePayload(
      JSON.parse(Buffer.from(eventData, 'base64').toString())
    );

既存の Cloud Functions の時に作成したコードを Cloud Run functions の新たな関数としてデプロイするには、メッセージの取得の仕方が異なる可能性があるようです。

Cloud Run functions のテスト

Clud Run のコンソールをよく見たら「テスト」というボタンがあったのでクリックしてみると、「サービスのテスト」というパネルが表示されます。メッセージを簡単に送れるようになっているので、送信したいメッセージを入力して、「Cloud Shell を実行」をクリックすると「CLI テストコマンド」が自動で貼り付けられてすぐに実行可能となります。

こちらで見て思ったのが、この json がリクエストボディになっていると解釈すると、request.body.message.data で取得できるのも納得できるなと思いました。

なんでうまくいかなかった?②

上記のエラーを解消してほっとすると、ログがこのような表示になっていました。

1 分前後の間隔でリクエストが飛んできていて、都度処理が走っていました。最初は理解できずに焦りましたが、まずはリクエスト元であるサブスクリプションを確認しました。見てみると 「リージョン別の未確認メッセージ」が 8 つほど溜まっていました

これは上述した想定メッセージの取得に対応できない中で Cloud Schduler を強制で発火させたことで、正常に完了できないメッセージが溜まっている状況でした。

関連しそうな記述があったので引用しておきます。

公開処理中に、一時的または永続的な公開エラーが発生することがあります。一時的なエラーの場合、通常は Pub/Sub がメッセージを自動的に再試行するため、特別な操作を行う必要はありません。
パブリッシュ オペレーションが成功したものの、パブリッシャー クライアントでパブリッシュ レスポンスが時間内に受信されなかった場合にも、エラーが発生することがあります。この場合も、パブリッシュ オペレーションが再試行されます。その結果、メッセージ ID が異なる 2 つの同一のメッセージを作成できます。
永続的なエラーが発生した場合は、Pub/Sub の過負荷を避けるために、パブリッシュ プロセスの外部で適切なアクションを実装することを検討してください。
公式ドキュメント - Pub/Sub:リクエストの再試行

これにより再試行が繰り返されていたことが考えられます。ひとまず、こちらの溜まっているメッセージを削除したいのでサブスクリプションのコンソール画面の「メッセージをパージ」をクリックしました。これで未確認メッセージの削除が完了できました。

しかし、想定メッセージの取得対応後は処理自体は完了しているのにも関わらず Ack が返却されずメッセージ完了していない状態が続いているのには違和感がありました。

なんでうまくいかなかった?③

公式ドキュメントの Cloud Run サービスの Pub/Sub 経由の HTTP リクエストの処理を読んでいたら、下記の記述が。

正確な HTTP レスポンス コードを返すようにサービスをコーディングする必要があります。HTTP 200 や 204 などの成功コードは、Pub/Sub メッセージの処理の完了を意味します。HTTP 400 や 500 などのエラーコードは、push を使用したメッセージの受信で説明されているように、メッセージが再試行されることを示します。
公式ドキュメント - Cloud Run で Pub/Sub を使用するチュートリアル:コードを確認する

うすうす気づいていましたが、明示的にレスポンスを返す必要がありました。最終的には下記のようなコードとすることで Cloud Functions の時に作成された関数を Cloud Run functions の新たな関数としてデプロイすることができました。

stopInstancePubSub/index.js
stopInstancePubSub/index.js
const Compute = require('@google-cloud/compute');
const compute = new Compute();

/**
 * Stops Compute Engine instances.
 *
 * Expects a PubSub message with JSON-formatted event data containing the
 * following attributes:
 *  zone - the GCP zone the instances are located in.
 *  label - the label of instances to stop.
 *
 * @param {!object} event Cloud Function PubSub message event.
 * @param {!object} callback Cloud Function PubSub callback indicating completion.
 */
exports.stopInstancePubSub = async (req, res) => {
  try {
    const eventData = req.body.message.data;
    if (eventData === undefined) {
        console.log('eventData is undifined');
        return;
    }
    const payload = _validatePayload(
      JSON.parse(Buffer.from(eventData, 'base64').toString())
    );
    const options = {filter: `labels.${payload.label}`};
    const [vms] = await compute.getVMs(options);
    await Promise.all(
      vms.map(async instance => {
        if (payload.zone === instance.zone.id) {
          const [operation] = await compute
            .zone(payload.zone)
            .vm(instance.name)
            .stop();

          // Operation pending
          return operation.promise();
        } else {
          return Promise.resolve();
        }
      })
    );

    // Operation complete. Instance successfully stopped.
    const message = 'Successfully stopped instance(s)';
    console.log(message);
    res.status(204).send();
  } catch (err) {
    console.log(err);
    res.status(204).send();
  }
};

/**
 * Validates that a request payload contains the expected fields.
 *
 * @param {!object} payload the request payload to validate.
 * @return {!object} the payload object.
 */
const _validatePayload = payload => {
  if (!payload.zone) {
    throw new Error("Attribute 'zone' missing from payload");
  } else if (!payload.label) {
    throw new Error("Attribute 'label' missing from payload");
  }
  return payload;
};

さいごに

Cloud Functions の時に作成された関数を Cloud Run functions への移行を試してみました。最後までやってみてやっとわかったのが、Cloud Run functions として関数をデプロイするには公式ドキュメントでいう Cloud Run のサービスで呼び出してトリガーするに沿って実装するということでした。

言われてみれば当たり前ですが、冒頭でも言った 「ただリブランディングされて Cloud Run の特徴を追加で利用できるになった」 と認識していると Cloud Functions 時に作成された関数の移行ではハマるポイントなのかもと思いました。

結構大変でしたが普段はアプリケーションを乗せてサービス公開する使い方が多いので HTTP 呼び出しについて勉強になりました!

https://twitter.com/pHaya72

Discussion

waddy_uwaddy_u

完全に同じ認識だったので助かります!

既存の Cloud Functions の時に作成したコードを Cloud Run functions の新たな関数としてデプロイするには、メッセージの取得の仕方が異なる可能性があるようです。

既存の Cloud Functions とは別物と考えたほうが良さそうですね…

Tomonori Hayashi / @pHaya72Tomonori Hayashi / @pHaya72

わざわざコメントありがとうございます!
はい、Cloud Functions + α くらいの軽いノリで手をつけていました。。笑
実態は Cloud Run サービスにスクリプトベースでデプロイできるようになったくらいの方が勘違いは少なくなりそうです!!