🐾

Cloud Functions 2nd gen.でNestJSを使うためのテンプレート(Hot Reload付き)

2024/08/15に公開

はじめに

バックエンドにNesJSを個人的に使うようになってしばらく経ちますが、NestJSの精緻な公式ドキュメント・豊富なコミュニティ記事が心強いです。

とはいえ、試行錯誤が必要になったケースもあって、筆者の場合、サーバーレス環境へのデプロイ、具体的にはCloud Functionsへのデプロイがその一つでした。

公式ドキュメントにはなるほどServerlessの一項は設けられていますが、AWS Lambdaこそあれ、Cloud Functionsに関する具体的な記述はありません。

https://docs.nestjs.com/faq/serverless#serverless

本記事は上記ドキュメントの追補となるように、Cloud Functions向けの開発テンプレートを紹介するものです。

テンプレート

https://github.com/HosakaKeigo/nestjs-cloudfunctions-starter

  • GitHub Actions
    • 以下のセットアップが必要
      • <>で囲まれた変数の設定
      • workload_identity_providerの設定
  • Functions Frameworkでのローカル開発
  • watchコマンドでのホットリロード
  • Swagger
    • 認証保護はなし。

ポイント

nest newから変更が必要な箇所を摘要します。

package.json

"main": "dist/main.js"を追加

build後にCloud Functionsのエントリーポイントとなるファイルパス。

"start": "functions-framework --target=nestjs-cloud-functions-starter",

targetにはmain.tsで定義する関数名を入れます。

"watch": "concurrently "nest build -w" "wait-on dist && nodemon --watch ./dist --exec 'pnpm run start'""

Hot Reloadの設定。後述します。

main.tsの調整

コード
src/main.ts
import { NestFactory } from '@nestjs/core';
import { ExpressAdapter } from '@nestjs/platform-express';
import { AppModule } from './app.module';
import * as express from 'express';
import helmet from 'helmet';
import { ValidationPipe } from '@nestjs/common/pipes/validation.pipe';
import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger';
import * as functions from '@google-cloud/functions-framework';

const server = express();
let isApplicationReady = false;

const createNestServer = async (expressInstance) => {
  const app = await NestFactory.create(
    AppModule,
    new ExpressAdapter(expressInstance),
  );

  app.use(helmet());
  app.enableCors({
    origin: ['*'],
  });
  app.useGlobalPipes(new ValidationPipe());

  // Documentation
  const config = new DocumentBuilder()
    .setTitle('Nest.js Cloud Functions Starter')
    .setDescription('A Minimal Nest.js Cloud Functions Starter')
    .setVersion('1.0')
    .build();
  const document = SwaggerModule.createDocument(app, config);
  SwaggerModule.setup('api', app, document);

  console.log('the server is starting @ Cloud Functions');
  return app.init();
};

const applicationReady = async () => {
  try {
    await createNestServer(server);
    isApplicationReady = true;
  } catch {
    (err) => console.error('Nest broken', err);
  }
};

functions.http('nestjs-cloud-functions-starter', async (...args) => {
  if (!isApplicationReady) {
    await applicationReady();
  }
  server(...args);
});

isApplicationReady

Cloud Functionsを含むServerless環境では、インスタンスの最初のリクエスト処理の前にだけ、serverを初期化する必要があります。リクエストごとにserverを初期化するのを避けるためのフラグとして、isApplicationReadyを使い、初期化が一度だけ行われるようにしています。

この初期化分岐を書いていないサンプルコードが散見されますが[2]、これを考慮しないと、たとえば、Prismaをグローバルで使っている場合にPrismaインスタンスがリクエストごとに作られてしまい、

warn(prisma-client) This is the 10th instance of Prisma Client being started.

というエラーが出ます。NestJS公式ドキュメントのAWS Lambdaのケースでは、

  server = server ?? (await bootstrap());

として初期化を一度だけに制御していました。

Hot Reload

Nest.js単体の場合、nest start --watchとすればHot Reloadされます。
Functions Frameworkと併用する場合、工夫が必要でした。

使うパッケージ

スクリプト

"concurrently \"nest build -w\" \"wait-on dist && nodemon --watch ./dist --exec 'pnpm run start'\""

流れ

concurrentlynest build -wと同時にfunctions-frameworkを実行すると、最初にエラーが出て、その後、serveされる挙動になります。これはnest buildの時に一回dist/が消え、エントリーポイントを見失うため。


一回エラーになってから起動する

対策として、wait-onを使ってdist/の生成を待って、functions-frameworkを実行するようにしました。もっとスマートなやり方がありそうですが、これで動作しています。

GitHub Actions

設定が必要な値は<>で囲っています。

コード
name: Deploy to Cloud Functions

on:
  push:
    branches:
      - <SPECIFY BRANCH NAME>

jobs:
  deploy:
    name: Deploy
    runs-on: ubuntu-latest
    permissions:
      contents: read
      id-token: write
    env:
      function_name: <SPECIFY FUNCTION NAME>
      entry_point: <SPECIFY ENTRY POINT>
      workload_identity_provider: "projects/<YOUR PROJRCT ID>/locations/global/workloadIdentityPools/<YOUR POOLNAME>/providers/<YOUR PROVIDER NAME>"
      service_account: <SPECIFY SERVICE ACCOUNT WITH DUE PRIVILEGES FOR DEPLOYMENT>
      cloud_run_service_account: <SPECIFY SERVICE ACCOUNT WITH DUE PRIVILEGES FOR EXECUTION>
    strategy:
      matrix:
        node-version: [20]
    steps:
      - uses: actions/checkout@v4
      - name: 'Authenticate to Google Cloud'
        id: auth
        uses: 'google-github-actions/auth@v2'
        with:
          workload_identity_provider: ${{ env.workload_identity_provider }}
          service_account: ${{ env.service_account }}
      - name: 'Set up Cloud SDK'
        uses: 'google-github-actions/setup-gcloud@v2'
        with:
          version: '>= 363.0.0'

      - uses: pnpm/action-setup@v3
        with:
          version: 8
      - name: Use Node.js ${{ matrix.node-version }}
        uses: actions/setup-node@v4
        with:
          node-version: ${{ matrix.node-version }}
          cache: 'pnpm'
          cache-dependency-path: './functions/pnpm-lock.yaml'

      - name: Install dependencies
        run: |
          cd functions
          pnpm install

      - name: Deploy to Cloud Functions
        run: >-
          gcloud functions deploy ${{ env.function_name }}
          --gen2
          --runtime=nodejs${{ matrix.node-version }}
          --region=asia-northeast1
          --source=./functions
          --entry-point=${{ env.entry_point }}
          --trigger-http
          --allow-unauthenticated
          --run-service-account=${{ env.cloud_run_service_account }}

pnpmを使っている関係でやや長くなっています。gcloud functions deployで事前実行されるので、buildは不要でした。

認証にはgoogle-github-actions/auth@v2でWorkload Identity Federation方式で行なっています。

Workload Identity Federationの設定については、詳細な記事がありますので下記に譲ります。[3]

https://zenn.dev/cloud_ace/articles/7fe428ac4f25c8

おわりに

以上、Cloud Functionsで使えるNestJSお手製テンプレートの紹介でした。

まだまだ改善の余地がありそうですが、このテンプレートがNestJSで開発するスタートポイントになれば幸いです。

参考記事

Nest.js

https://docs.nestjs.com/faq/serverless#example-integration

https://qiita.com/0622okakyo/items/d69209b8b01c474c36be#firebase-の設定

https://qiita.com/chelproc/items/37ed6ed27ee599b586bf

https://manuel-heidrich.dev/blog/how-to-secure-your-openapi-specification-and-swagger-ui-in-a-nestjs-application/

Cloud Functions

How to develop and test your Cloud Functions locally

Google Cloudのblog記事。TypeScriptでのhot reloadについて書かれています。

紹介されている例だとtscを使っていますが、今回はdist/srcにbuildしてしまうのでうまくいきません。(tsconfigをいじれば良いのですが、なるべくnest newの原型を保ちたい。)

https://cloud.google.com/blog/ja/topics/developers-practitioners/how-to-develop-and-test-your-cloud-functions-locally

GitHub Actions

https://dev.classmethod.jp/articles/deploy-the-cloud-functions-gen2-using-github-actions/

https://zenn.dev/cloud_ace/articles/7fe428ac4f25c8

脚注
  1. 参考: https://manuel-heidrich.dev/blog/how-to-secure-your-openapi-specification-and-swagger-ui-in-a-nestjs-application/ ↩︎

  2. https://github.com/fireship-io/nest-cloud-functions/blob/master/B-functions/functions/src/index.ts など ↩︎

  3. サービスアカウントには「Cloud Functions 開発者」、「Artifact Registry 管理者」、「サービス アカウント トークン作成者」、「サービス アカウント ユーザー」のロールを付与しています。Cloud Functions 2nd.genはCloud Runで動くので、Artifact Registryを付与しています。これらが最小権限かは検証できていません。 ↩︎

Discussion