Cloud Functions 2nd gen.でNestJSを使うためのテンプレート(Hot Reload付き)
はじめに
バックエンドにNesJSを個人的に使うようになってしばらく経ちますが、NestJSの精緻な公式ドキュメント・豊富なコミュニティ記事が心強いです。
とはいえ、試行錯誤が必要になったケースもあって、筆者の場合、サーバーレス環境へのデプロイ、具体的にはCloud Functionsへのデプロイがその一つでした。
公式ドキュメントにはなるほどServerlessの一項は設けられていますが、AWS Lambdaこそあれ、Cloud Functionsに関する具体的な記述はありません。
本記事は上記ドキュメントの追補となるように、Cloud Functions向けの開発テンプレートを紹介するものです。
テンプレート
- 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の調整
コード
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と併用する場合、工夫が必要でした。
使うパッケージ
- 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'\""
流れ
concurrently
でnest 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]
おわりに
以上、Cloud Functionsで使えるNestJSお手製テンプレートの紹介でした。
まだまだ改善の余地がありそうですが、このテンプレートがNestJSで開発するスタートポイントになれば幸いです。
参考記事
Nest.js
Cloud Functions
How to develop and test your Cloud Functions locally
Google Cloudのblog記事。TypeScriptでのhot reloadについて書かれています。
紹介されている例だとtsc
を使っていますが、今回はdist/src
にbuildしてしまうのでうまくいきません。(tsconfigをいじれば良いのですが、なるべくnest new
の原型を保ちたい。)
GitHub Actions
-
参考: https://manuel-heidrich.dev/blog/how-to-secure-your-openapi-specification-and-swagger-ui-in-a-nestjs-application/ ↩︎
-
https://github.com/fireship-io/nest-cloud-functions/blob/master/B-functions/functions/src/index.ts など ↩︎
-
サービスアカウントには「Cloud Functions 開発者」、「Artifact Registry 管理者」、「サービス アカウント トークン作成者」、「サービス アカウント ユーザー」のロールを付与しています。Cloud Functions 2nd.genはCloud Runで動くので、Artifact Registryを付与しています。これらが最小権限かは検証できていません。 ↩︎
Discussion