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

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

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

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

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

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

:::note warn
本記事のCloud Functionsはgcloud CLIで操作する通常のCloud Functionsであって、Firebase CLIで扱うCloud Functions for Firebaseではありません。

Cloud Functions for Firebaseの場合、以下が課題になっています。

- Firebase CLIでは実行サービスアカウントをdeployオプションに指定できない([参考](https://stackoverflow.com/questions/76972282/using-custom-serviceaccount-with-firebase-functions))
  - [runtimeOptions](https://firebase.google.com/docs/reference/functions/firebase-functions.runtimeoptions?hl=ja)でソース内指定は可能。 
- Workload Identity連携でのGitHub Actionsがうまくいかない。
  - [google-github-actions/auth@v2](https://github.com/google-github-actions/auth)には`This option [Inputs: Workload Identity Federation] is not supported by Firebase Admin SDK. Use Service Account Key JSON authentication instead.`の記述があります。

:::

# テンプレート
https://github.com/HosakaKeigo/nestjs-cloudfunctions-starter

- GitHub Actions
  - 以下のセットアップが必要 
    - `<>`で囲まれた変数の設定
    - workload_identity_providerの設定
- [Functions Framework](https://github.com/GoogleCloudPlatform/functions-framework-nodejs)でのローカル開発
- `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の調整
```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);
});
```

:::note warn
Swaggerの保護は行なっていません。必要に応じてBasic Authや環境に応じた保護を追加してください。[^1]
:::

[^1]: 参考： https://manuel-heidrich.dev/blog/how-to-secure-your-openapi-specification-and-swagger-ui-in-a-nestjs-application/

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

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

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

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

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

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

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

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

### 使うパッケージ
- nodemon
- concurrently
  - コマンドを同時に実行するライブラリ 
  - https://github.com/open-cli-tools/concurrently
- wait-on
  - dist/以下の生成を待ってスクリプトを実行させる
  - https://github.com/jeffbski/wait-on

### スクリプト
`"concurrently \"nest build -w\" \"wait-on dist && nodemon --watch ./dist --exec 'pnpm run start'\""`

### 流れ
```mermaid
graph TD
    A[watch] --> B[nest build -w]
    B --> C[dist/にbuild]
    C -->|src/の変更を検知| B
    C -->|wait-onでdistの変更を待つ| D[functions-frameworkをスタート]
    D --> E[localhostにserve]
```

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

![](https://storage.googleapis.com/zenn-user-upload/346b1afff5e7-20240815.png)
*一回エラーになってから起動する*

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

## GitHub Actions
設定が必要な値は`<>`で囲っています。
```yml:.github/workflows/deploy_to_cloud_functions.yml
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](https://github.com/google-github-actions/auth)でWorkload Identity Federation方式で行なっています。

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

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

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

# おわりに
以上、Cloud Functionsで使えるNest.jsお手製テンプレートの紹介でした。

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


# 参考記事
## Nest.js
https://docs.nestjs.com/faq/serverless#example-integration

https://qiita.com/0622okakyo/items/d69209b8b01c474c36be#firebase-%E3%81%AE%E8%A8%AD%E5%AE%9A

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