Link and Motivation Developers' Blog

リンクアンドモチベーションの開発者ブログです

Google Apps Script で書かれたアドオンのバックエンドを Cloud Run に移行した話

リンクアンドモチベーション・データユニットの みく / Ryo Koizumi (@slpwalks) です。

データユニットではLMグループ全体のデータドリブンな意思決定を推進するため、データ分析基盤の提供やデータ分析以外にも、帳票作成やDXツールの提供など、データの流れをなめらかにするための様々な活動を行っています。

よろしければ下記のエントリ (Qiita) もご覧ください。

qiita.com

さて、以前 Google Workspace Addon の作成方法 をテックブログでご紹介させて頂きましたが、提供しているアプリにおいて Google Apps Script (以下GAS) の限界により、パフォーマンスが悪化し、新機能の追加が出来ない状態に陥ってしまいました。

検討の結果、バックエンドを Cloud Run に移行、同時にスプレッドシートを用いていたデータストアを Firestore に切り替える方法を選択しました。 今回はその経緯と、GASから他システムに乗り換えるための肝をお伝えできればと思います。

Google Apps Script (GAS) の問題

GAS は無料で使え、Google Workspace との連携もスムーズに出来るため、社内DXの選択肢として非常に便利です。基本は JavaScript ですが、 clasp を使えば TypeScript でも記述できるため、そこそこの規模のアプリを作成することも実際には可能です。

しかし便利な裏には落とし穴が存在します。とりわけ今回問題になったのは性能面ですが、まず最初の問題として GAS にはスケールアップ・スケールアウトの選択肢が存在せず、純粋な性能不足に陥った場合に対処のしようがないことが挙げられます。

また、データストアとしてスプレッドシートを用いていたことも問題になりました。スプレッドシートはレスポンスタイムが不安定であり、同時編集に弱く、クエリーを投げられないため全てのセルを走査する必要があるなど、かなりのボトルネックになっていました。

様々なハックを試しましたがうまくいかず、アドオンが一定のペースで使われ続けていて、インフラとして確実に機能していたこともあり、コストを掛けてアーキテクチャを移行する決断を行いました。

ちなみにGASに関する他の問題は Yuuki Kondo 氏の以下のスライドが詳しいです。今回のPJTにおいても大きく参考にさせて頂きました。

speakerdeck.com

新しいアーキテクチャについて

移行における選択肢

移行においては、以下の選択肢を検討しました。

  1. DB(データストア)だけ Firestore に移行する方法
  2. フロントエンドまで含めて Cloud Run に移行する方法
  3. フロントエンド部分は GAS のまま、バックエンドを Cloud Run に移行する方法

結果的に 3. を選択しました。理由は以下の通りです。

  1. の DB だけ Firestore 移行する方法では、GAS の公式で Firestore に接続するライブラリが存在しないため、第三者もしくは自前のライブラリに頼らざるを得なくなり、また GAS 自体の性能は克服できない。
  2. については、任意の言語でアドオンをビルドする(Google Workspace Add-ons API)方法を利用するが、GAS で利用できていた Card Service が利用できなくなり、自前でJSONをハンドリングする必要があるため、レンダリング部分の移行コストが重い。

結果としてコード量は多くなるものの、バックエンドでは豊富なライブラリを利用しながら、フロントエンドでは GAS のレンダリングコードをそのまま使うことが出来るため、移行コストを含め 3. を選択しました。

Cloud Run で利用する言語について

データユニットのプライマリ言語は Python なんですが、GAS は TypeScript で書いていたため、そのまま node.js を利用することにしました。

ただ、GAS と node.js は同じ言語であると言うだけで、書き味は全く異なります。基本的に同期で動くGASに比べ、async/await を駆使する node.js は GAS に慣れた人から見たら奇妙に映るかもしれません。

ただ、バックエンド部分を node.js に移行することによって npm 経由で豊富なライブラリを使えるようになります。Salesforce との接続は jsforce が利用できますし、AWS との接続も AWS SDK が利用できます。十分にテストされたライブラリを利用できることは学習コスト以上にメリットが大きいです。

また、バックエンドとフロントエンドが同じ言語であるため、結合部分について共通の型を参照することでインタフェースの同期ができます。何かしらの変更を行う場合、ここを起点にすることで Linter を用いて変更範囲を確定出来ます。

Cloud Run / Firestore について

バックエンドとしては複数のエンドポイントを生やす必要がある以上、HTTP で待ち受けができることが条件になりました。

フロントエンドと同じGCPプロジェクトで管理するという条件だと、Cloud Run のほかに App Engine も選択肢に入ります。標準で IAPドメイン認証)が使えるなどのメリットもあるものの、Docker コンテナが利用できた方が後々の可搬性も高いと判断し、Cloud Run を選択しました。

DBはコストを考えて Firestore を選択しました。あまり難しい要件もなく、単純にキーバリューのストアとして使っています。

この時点でコストも試算し、問題ないことを確認しました。

GAS から移行する

GAS から移行するためには、既存の GAS で出来ていた機能が再現出来ることが必要になります。

以下トピック別に示します。

自動デプロイ

ビルドしたコンテナを Artifact Registry にプッシュし、Cloud Run 側でリビジョンを変更することによって行います。

Github Actions で行うには以下の手順が詳しいです。

qiita.com

認証・認可

認証 (Authentication) について

前述でも触れましたが、Cloud Run 単体では IAP を利用することが出来ず、ロードバランサを通す必要があります。ロードバランサの設置はそれなりのコストがかかるため、今回は GAS からのリクエストに限定されていることもあり、アプリケーション側で認証の仕組みを作ることにしました。

GAS は openid スコープを有効にすることで、 ScriptApp.getIdentityToken()OIDCトークン を発行することが出来ます。生成されたトークンの中身を jwt.io で覗くとクライアントIDや発行者のドメインが取れることがわかるので、このトークンをリクエスト側で Authorization ヘッダーに詰めて送ります。

// GAS側
private fetch<T, R extends string>(
  path: string,
  payload: any
): SuccessResponse<T> | ErrorResponse<R> {
  const response = UrlFetchApp.fetch(this.baseUrl + path, {
    method: 'post',
    contentType: 'application/json',
    headers: {
      authorization: `Bearer ${ScriptApp.getIdentityToken()}`,
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true,
  });
}

バックエンド側は Express の Middleware を使ってトークンの検証プロセスを通すようにします。

  • GAS のクライアントIDは自動で発行されるため、万一変わった時のことを考えて決めうちはせず、プロジェクトID部分を検証しています。
  • 本来はドメイン部分を検証すべきですが、後々言及する Cloud Scheduler から発行されるリクエストは発行者が異なるため、検証から外しています。
// バックエンド側
export const auth =
  async (req: Request, res: Response, next: NextFunction) => {
    const authorization = req.headers.authorization?.split(' ')[1];
    if (!authorization) {
      res.status(401).send({
        status: 'error',
        code: 'Unauthorized',
        message: 'No authorization header',
      });
      return;
    }
    try {
      const projectId = propertyAccessor.get('PROJECT_ID');

      const client = new OAuth2Client();
      const ticket = await client.verifyIdToken({
        idToken: authorization,
      });
      const payload = ticket.getPayload();
      const aud = payload?.aud;
      if (!aud || !aud.startsWith(projectId)) {
        console.error(`Invalid audience: ${aud}`);
        throw new Error('Invalid audience');
      }
      const email = payload?.email;
      if (!email) {
        console.error(`Invalid email: ${email}`);
        throw new Error('Invalid email');
      }
      console.info(`Accessed by ${email}`);
    } catch (e) {
      res.status(401).send({
        status: 'error',
        code: 'Unauthorized',
        message: 'Invalid authorization header',
      });
      return;
    }

    next();
  };

認可 (Authorization) について

GAS からは ScriptApp.getOAuthToken() でアクセストークンを発行できます。これのスコープは GAS と同じになります。

認証と同じくヘッダーに詰めて、バックエンド側の Google API クライアントの設定にそのまま渡すことで、GAS で定義したスコープをそのままバックエンドでも利用できます。つまりバックエンド側からもユーザーのスコープでリソースへの書き込み操作などが出来るようになります。

// GAS側
private fetch<T, R extends string>(
  path: string,
  payload: any
): SuccessResponse<T> | ErrorResponse<R> {
  const response = UrlFetchApp.fetch(this.baseUrl + path, {
    method: 'post',
    contentType: 'application/json',
    headers: {
      authorization: `Bearer ${ScriptApp.getIdentityToken()}`,
      'x-api-authorization': `Bearer ${ScriptApp.getOAuthToken()}`,
    },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true,
  });
// バックエンド側
export class CalendarEventClientWrapper {
  private client: google.calendar_v3.Calendar;

  constructor(accessToken: string) {
    this.client = new google.calendar_v3.Calendar({
      headers: {
        Authorization: `Bearer ${accessToken}`,
      },
    });
  }

  // 省略
}

スコープを追加した場合もアドオン側で自動的に認可フローが走るため、バックエンド側で認可フローを作る必要はありません。

スクリプトプロパティ

  • 環境変数を利用することになります。
  • 機密性が高い情報はシークレットを利用します。

定期実行トリガー

Cloud Run には Cloud Run ジョブというものがあり、コンテナを利用してジョブタスクが作れる機能がります。しかし環境変数を別途設定する必要があることと、せいぜい数分で終わるジョブだったこともあり、Cloud Scheduler から POST リクエストを発行しそれを受ける形にしました。

先述の認証の仕組みを通す必要がありますが、ここでは email がサービスアカウントのメールアドレスになることを利用します。

DB (Firestore)

既存のコードではスプレッドシートをデータストアとして利用するための簡易ORMライブラリ(自作)を利用していたため、互換レイヤーを作成して違和感ない形で移行できるようにしました。

Firestoreは(雑に利用する分には)クセがなく扱いやすいです。Converterドメインオブジェクトとドキュメントのマッピングが簡単に出来たり、学習コストが低かったです。

JOIN が出来ないため n+1 が発生しますが気にせずガンガンアクセスしても笑っちゃうほど早いです。元がスプレッドシートなので……。

開発

ts-node-dev を用いることでホットリロードでのローカル開発を行うことが出来ます。

認証・認可の仕組みを通す必要がありますが、同じプロジェクト内に GAS のスタンドアロンスクリプトを用意して都度トークンを発行できるようにしました。

移行のメリット・デメリット

メリット

  • レスポンスタイムが安定しました。ちゃんとしたDBを使っているため同時編集にも強く、しばしば起こっていたタイムアウトも一掃されました。動作がスムーズになる以上に使っていての安定感が強いです。
  • 外部ライブラリを使えるようになったため、主に外部APIとの接続周りが安定しました。
  • ローカルでの実行が出来、Jestによるテストも可能なため、堅牢性と開発スピードが増しました。今まではいちいち開発環境にpushする必要があり、単体テストも制限されていました。
  • 総じて、安心して機能を追加できるようになったため、エンドユーザーへより多くの価値を提供できるようになりました。安定しただけでなく API が分離されるようになったため、他アプリとの統合も現実的になりました。

デメリット

  • 移行における学習コストが高いです。GAS にはない node.js/esModule/Docker などの知識や Cloud Run の知識も新たに必要になるため、初学者には辛いです。
  • バックエンド・フロントエンドと分かれている以上、どうしてもコード量は増え、エラーハンドリングも辛くなります。GAS と node.js で書き味が違うのもスイッチングコストを発生させます。
  • そもそも GAS で中大規模なプロジェクトを書くというのがレアケースであり、小さなプロジェクトである限り移行メリットは薄いです。
  • 無料で使えていたものが有料になります。

まとめ

今回の移行については以下の学びがありました。

  • 安定して価値を生んでいるものを脆弱な仕組みに頼るべきではないこと
  • ハックをしようとした時点で手段を疑うこと

また、今回の話は GAS 自体の利便性を否定するものではありません。依然ちょっとした処理を効率化するには便利な仕組みですし、初学者に対しての学習インフラとしても優れています。 今回に関しては提供していたアプリケーションの規模が GAS で担保できるものを超えていたという話です。

この記事が GAS の性能に悩んでいる人の参考になれば幸いです。