🙆

NestJS + Prisma + Cloud Run + Cloud SQLを試す

2021/12/27に公開1

経緯

ここ6,7年くらいはバックエンドに関してはRails + EC2/ECSあたりのAWS環境を中心に過ごしてきたが、昨今はフロントエンドでReact/Vue + TypeScriptを書く機会も増えている。なのでこの際NestJS等でバックエンドを書けるようになれば言語のコンテキストスイッチの切り替えが容易になりそうと思った(ちなみにモバイルアプリはFlutterで書くのでDartだが、ではDartでバックエンドを書くかと言われると一人でそんな勇気はないわ...となるのでひとまず置いておく)

最近はinputとoutputを型注釈によって守れたりすることの主に開発体験方面への恩恵が個人的に大きくて、Rails以外で安住の地を見つけたいとは予々思っていた。なので先に挙げたNestJSに全ベットするわけではないにしろ何かしらフレームワークは試していきたい。

AppEngineは大昔に少し触ったことがある程度なのでGCP製品はほぼ素人だが、gcloud CLIや権限周り、その他GUI含めてGCPの体験の良さは理解している。中でもCloud Runは多方面ですでにプロダクション採用があり気になっているところなのでコンテナをCloud Runで動かしてDB含めて連携して継続的デプロイなんかをサクッと回せるようにはしておきたい。

そういうわけで冬休みの課題の一つとしてNestJS + Prisma + Cloud Run + Cloud SQLの簡単なCRUDアプリを動かすのをやってみようとなった。

Cloud Run

まずはCloud Runに入門する。そもそもCloud Runについてほぼよくわかってないのでまずは軽く下調べ。

Cloud Run はマネージド コンピューティング プラットフォームで、リクエストまたはイベント経由で呼び出し可能なコンテナを実行できます。Cloud Run はサーバーレスです。インフラストラクチャ管理が一切不要なため、最も重要な作業であるアプリケーションの構築に集中できます。

https://cloud.google.com/run

ソースコード・コンテナの自動ビルドとデプロイ、トラフィック管理、オートスケーリングなど一括で管理してくれるやつ。コンテナレジストリはContainer/Artifact Registry(Artifact = コンテナ・OSやソースコード・言語パッケージなど)

ソースからのデプロイの対応言語状況。

  • Go
  • Node.js
  • Python
  • Java
  • .NET Core

VPCネットワークへの接続について。Cloud Run サービスを VPC ネットワークに直接接続することができる。

Compute Engine VM インスタンス、Memorystore インスタンスなどのリソースに内部 IP アドレスでアクセスできます。

カスタムドメイン設定も複数方法有り。Cloud SQLでDBとの連携も問題ない。Cloud Schedulerを使ってcronみたいな使い方も可能。

個人的にはソースからデプロイだと言語の縛りもあるしDockerfileを用意したimage buildとデプロイを分けてやるパターンの方が汎用性高い感じがするのでその方向になりそう。

Cloud Runへのデプロイを試す

その前にGCPとCloud SDKの準備

まずはお試し用のプロジェクト作成。何かを試す場合はプロジェクトを作ってその配下で作業し、終わったらプロジェクトを削除することで配下のリソースが全部消えてくれる。ゴミが残らず便利。デフォルトで作成される「My Project」がprefixにあるものはいつでも削除して良いというルールにでもしておくとよさそう。

Cloud SDKのセットアップ。インストールはこれを見てやる。すでに入ってる場合はgcloud components updateをして最新にする。

次にgcloudアカウントを使い分けられるように設定。自分は仕事用のものと分けたいので。gcloud config configurations listでdefaultを確認。

 $ gcloud auth login # アカウント追加
 $ gcloud config set account `ACCOUNT` # このコマンドでアカウントを切り替えできる

以後は--projectを指定するとそのprojectのコンテキストで実行されるようになる。こんな感じで指定する->gcloud run deploy --project seraphic-spider-336202

Node.jsのサーバーを書いてデプロイ

  • ソースコードを用意するだけパターン
  • ソースコードとDockerfileを用意するパターン

の二つが有力そうなのでどちらも試してみる。どちらも公式ドキュメントが整備されているので楽そう。

ソースコードを用意するだけパターン

Node.js サービスをビルドしてデプロイする  |  Cloud Run のドキュメント  |  Google Cloudを上から準備やっていくだけ。

アプリの実コードはこれ。

package.json
 {
   "name": "helloworld",
   "description": "Simple hello world sample in Node",
   "version": "1.0.0",
   "private": true,
   "main": "index.js",
   "scripts": {
     "start": "node index.js"
   },
   "engines": {
     "node": ">= 12.0.0"
   },
   "author": "Google LLC",
   "license": "Apache-2.0",
   "dependencies": {
     "express": "^4.17.1"
   }
 }
index.js
 const express = require("express");
 const app = express();

 app.get("/", (req, res) => {
   const name = process.env.NAME || "World";
   res.send(`Hello ${name}!!!`);
 });

 const port = process.env.PORT || 8080;
 app.listen(port, () => {
   console.log(`helloworld: listening on port ${port}`);
 });

gcloud run deployとdeployコマンドを叩くと色々と聞かれるが全部Yesマンになって進む。GCPのAPIを有効化する必要があるのでいくつか聞かれた。run.googleapis.comartifactregistry.googleapi.comcloudbuild.googleapis.comが有効化された。一つ目はCloud Run APIの有効化、二つ目はコンテナイメージを保存するArtifact Registryに関するAPIの有効化、三つ目はCloud BuildのAPIの有効化かな。

実際のビルド〜デプロイのコマンドは内部的にはgcloud builds submit --pack image=[IMAGE] /Users/your-dir/dev/helloworldgcloud run deploy helloworld --image [IMAGE]を叩いているらしい。

3分くらいで実行が完了した。

以下コマンドアウトプット。作成されたService URLにアクセスするとHello, World!という文字列が返ってくることがわかる(もう↓プロジェクトは消してるので確認はできない)。

 ⠼ Building and deploying new service... Uploading sources.
 ⠏ Building and deploying new service... Uploading sources.
   ✓ Uploading sources...
   . Building Container...
 ⠶ Building and deploying new service... Uploading sources.
 ✓ Building and deploying new service... Done.
   . Setting IAM Policy...
   ✓ Building Container... Logs are available at [https://console.cloud.google.com/cloud-build/builds/1f6d82b0-a5b1-4b1a-a4e3-eba58db2c357?project=931888767753].
   ✓ Creating Revision...
   ✓ Routing traffic...
   ✓ Setting IAM Policy...
 Done.
 Service [helloworld] revision [helloworld-00001-caf] has been deployed and is serving 100 percent of traffic.
 Service URL: https://helloworld-njutwnbw2a-de.a.run.app

ソースコードとDockerfileを用意するパターン

クイックスタート: ビルドとデプロイ  |  Cloud Run のドキュメント  |  Google Cloudに沿ってやっていくだけ。

ソースコードの用意

ソースコードはさっきと同じ。加えてDockerfileと.dockerignoreを用意する。

Dockerfile
 # Use the official lightweight Node.js 12 image.
 # https://hub.docker.com/_/node
 FROM node:12-slim

 # Create and change to the app directory.
 WORKDIR /usr/src/app

 # Copy application dependency manifests to the container image.
 # A wildcard is used to ensure copying both package.json AND package-lock.json (when available).
 # Copying this first prevents re-running npm install on every code change.
 COPY package*.json ./

 # Install production dependencies.
 # If you add a package-lock.json, speed your build by switching to 'npm ci'.
 # RUN npm ci --only=production
 RUN npm install --only=production

 # Copy local code to the container image.
 COPY . ./

 # Run the web service on container startup.
 CMD [ "node", "index.js" ]
.dockerignore
 Dockerfile
 .dockerignore
 node_modules
 npm-debug.log

コンテナイメージのビルド

Container Buildでコンテナイメージのビルドを行う。生成物はContainer Registryに置かれる。

$ gcloud builds submit --tag gcr.io/PROJECT-ID/helloworld --project PROJECT-ID

Cloud Runへデプロイ

下記コマンドで一発。

$ gcloud run deploy --image gcr.io/PROJECT-ID/helloworld --platform managed --project PROJECT-ID

アウトプット

 Deploying container to Cloud Run service [helloworld] in project [PROJECT-ID] region [asia-east1]
 ✓ Deploying new service... Done.
   ✓ Creating Revision... Initializing project for the current region.
   ✓ Routing traffic...
   ✓ Setting IAM Policy...
 Done.
 Service [helloworld] revision [helloworld-00001-zek] has been deployed and is serving 100 percent of traffic.
 Service URL: https://PROJECTNAME-unique-de.a.run.app

再デプロイ

ソースコードの変更やImageの変更を行った場合の再デプロイについて。

ビルドは同じコマンドを指定する。

$ gcloud builds submit --tag gcr.io/PROJECT-ID/helloworld --project PROJECT-ID

デプロイも基本一緒。

$ gcloud run deploy --image gcr.io/PROJECT-ID/helloworld --platform managed --project PROJECT-ID

もしtrafficを新しいlatest imageに切り替えるのを手動でできるようにするには--no-trafficをつける。

$ gcloud run deploy --no-traffic --image gcr.io/PROJECT-ID/helloworld --platform managed --project PROJECT-ID

imageだけデプロイされてトラフィックはまだ流れていない状態の図↓
https://scrapbox.io/files/61c6d63f287b73002043d21c.png

GUIでトラフィックの割合を変更できる。めっちゃ便利だ...
https://scrapbox.io/files/61c6d70a7eeae2001dca76ab.png

NestJSとPrisma

公式Docsは後で詳しく読むとしてnest.js + prisma + fastify + postgresqlでREST APIのCRUDを作成するを見ながらまずは雰囲気を掴んでみる。

REST APIを作る

nestjsを使う準備

$ yarn global add @nestjs/cli
$ nest new nestjs-prisma-crud
$ cd nestjs-prisma-crud

依存関係インストール

$ yarn add prisma --dev
$ yarn add @prisma/client @nestjs/platform-fastify @nestjs/config @nestjs/mapped-types

prisma準備

$ npx prisma init
.env
DATABASE_URL="postgresql://admin:randompassword@localhost:5432/testdb?schema=public"

DBをDockerでローカルに立ち上げる

docker-compose.yaml
 version: "3.9"
 services:
   db:
     image: postgres:13-alpine
     container_name: nestjs-prisma-crud-postgres
     ports:
       - 5432:5432
     environment:
       POSTGRES_USER: postgres
       POSTGRES_PASSWORD: password
       PGDATA: /var/lib/postgresql/data/pgdata
     volumes:
       - ./db:/var/lib/postgresql/data
$ docker-compose up -d

fastifyの設定

必須ではないけどみんなやっとるので何も考えずに波に乗っておく。

main.ts
 import { NestFactory } from '@nestjs/core';
 import {
   FastifyAdapter,
   NestFastifyApplication,
 } from '@nestjs/platform-fastify';
 import { AppModule } from './app.module';

 async function bootstrap() {
   const app = await NestFactory.create<NestFastifyApplication>(
     AppModule,
     new FastifyAdapter(),
   );
   await app.listen(3000);
 }
 bootstrap();

prismaのmigration

schema.prisma
model task {
  id      Int     @id @default(autoincrement())
  title   String
  content String?
}
$ npx prisma migrate dev --name init

※もしbrew install postgressで過去にインストールしたpostgressがローカルでデーモンで立ち上がってたりすると~was denied access on the database postgres.publicみたいなエラーが出るので注意(なんかここでハマった...)

npx prisma studioを叩くとGUIのクライアントが開くのでちゃんとテーブルが作成されているか確認できる。

prismaのクライアント生成

$ npx prisma generate

Rails GenerateのNestJS版みたいなやつでREST APIを生やす

どういうリソースを作るかREST APIやGraphQLから雛形を選んでいく。

$ nest g resource
? What name would you like to use for this resource (plural, e.g., "users")? task
? What transport layer do you use? REST API
? Would you like to generate CRUD entry points? Yes
CREATE src/task/task.controller.spec.ts (556 bytes)
CREATE src/task/task.controller.ts (883 bytes)
CREATE src/task/task.module.ts (240 bytes)
CREATE src/task/task.service.spec.ts (446 bytes)
CREATE src/task/task.service.ts (607 bytes)
CREATE src/task/dto/create-task.dto.ts (30 bytes)
CREATE src/task/dto/update-task.dto.ts (169 bytes)
CREATE src/task/entities/task.entity.ts (21 bytes)
UPDATE src/app.module.ts (308 bytes)

生成されたtask.controller.tsを見るとCRUDのエンドポイントが生成されてる。実際に叩いてみると成功してることがわかる。

$ yarn start:dev
$ curl localhost:3000/task

APIでprismaのDB操作

クライアントを使う準備。

src/prisma.service.ts
import { Injectable, OnModuleInit, OnModuleDestroy } from '@nestjs/common';
import { PrismaClient } from '@prisma/client';

@Injectable()
export class PrismaService extends PrismaClient
  implements OnModuleInit, OnModuleDestroy {
  async onModuleInit() {
    await this.$connect();
  }

  async onModuleDestroy() {
    await this.$disconnect();
  }
}

src/task/task.controller.tssrc/task/task.service.tsでprismaを使う準備。

src/task/task.controller.ts
 import {
   Controller,
   Get,
   Post,
   Body,
   Put,
   Param,
   Delete,
 } from '@nestjs/common';
 import { TaskService } from './task.service';
 import { task, Prisma } from '@prisma/client';

 @Controller('task')
 export class TaskController {
   constructor(private readonly taskService: TaskService) {}

   @Post()
   async create(@Body() data: Prisma.taskCreateInput): Promise<task> {
     return this.taskService.create(data);
   }

   @Get()
   async findAll(): Promise<task[]> {
     return this.taskService.findAll();
   }

   @Get(':id')
   async findOne(@Param('id') id: string): Promise<task> {
     return this.taskService.findOne(+id);
   }

   @Put(':id')
   async update(
     @Param('id') id: string,
     @Body() data: Prisma.taskUpdateInput,
   ): Promise<task> {
     return this.taskService.update(+id, data);
   }

   @Delete(':id')
   async remove(@Param('id') id: string): Promise<task> {
     return this.taskService.remove(+id);
   }
 }
src/task/task.service.ts
 import { Injectable } from '@nestjs/common';
 import { task, Prisma } from '@prisma/client';
 import { PrismaService } from 'src/prisma.service';

 @Injectable()
 export class TaskService {
   constructor(private prisma: PrismaService) {}

   create(data: Prisma.taskCreateInput): Promise<task> {
     return this.prisma.task.create({ data });
   }

   findAll(): Promise<task[]> {
     return this.prisma.task.findMany();
   }

   findOne(id: number) {
     return this.prisma.task.findUnique({ where: { id } });
   }

   update(id: number, data: Prisma.taskUpdateInput): Promise<task> {
     return this.prisma.task.update({
       where: { id: id },
       data,
     });
   }

   remove(id: number): Promise<task> {
     return this.prisma.task.delete({ where: { id: id } });
   }
 }

確認

$ curl -X POST localhost:3000/task -H 'Content-Type:application/json' -d "{\"title\":\"あいうえお\", \"content\": \"アイウエオ\"}"
$ curl -X GET localhost:3000/task
$ curl -X GET localhost:3000/task/1
$ curl -X PATCH localhost:3000/task/1 -H 'Content-Type:application/json' -d "{\"title\":\"あああああ\", \"content\": \"いいいいい\"}"
$ curl -X DELETE localhost:3000/task/1

めちゃくちゃ簡単に出来た。controllerとserviceが綺麗に責務が分かれているのでRailsで初心者がやりがちなfat controllerが発生しにくそう。何よりprismaがよく出来ていてmigrationもORMとしての機能も型が各フィールドで守られていて体験が良い。複数のmodelを組み合わせたビジネスロジックの記述はどこにするのか?とか細かいあれこれはまだわからんので公式ドキュメントを読んでみる。ひとまずNestJS + Prismaの雰囲気だけ掴めた。

Cloud Runにデプロイしてみる

先のREST APIを持つNestJS + PrismaのアプリをCloud Runにデプロイするには、

  • NestJS+Prisma用のDockerfileを作成すること
  • Cloud Run + Cloud SQLを使えるようにすること

の二つの準備が必要。以下、アプリは上記のNestJSのものをそのまま使う。

NestJS用のDockerfileを作成する

prismaのclient生成をビルド時に行い、migrateをアプリのCMDで行ってる。

Dockerfile
 # syntax=docker/dockerfile:1
 FROM node:16 AS builder
 ENV NODE_ENV=development
 WORKDIR /app
 COPY package.json ./
 COPY yarn.lock ./
 COPY prisma ./prisma
 RUN yarn install
 RUN yarn run prisma generate
 COPY . .
 RUN yarn build

 FROM node:16 AS runner
 ARG NODE_ENV=production
 ENV NODE_ENV=${NODE_ENV}
 WORKDIR /app
 COPY --from=builder /app/node_modules ./node_modules
 COPY package.json ./
 COPY yarn.lock ./
 COPY prisma ./prisma
 COPY start.sh ./
 RUN yarn install
 COPY --from=builder /app/dist ./dist
 RUN chmod +x ./start.sh
 CMD ["./start.sh"]
.dockerignore
Dockerfile
.dockerignore
node_modules
npm-debug.log
start.sh
 yarn run prisma migrate deploy
 yarn start:prod
main.ts
 import { NestFactory } from '@nestjs/core';
 import {
   FastifyAdapter,
   NestFastifyApplication,
 } from '@nestjs/platform-fastify';
 import { AppModule } from './app.module';

 async function bootstrap() {
   const app = await NestFactory.create<NestFastifyApplication>(
     AppModule,
     new FastifyAdapter(),
   );
   const port = Number(process.env.PORT) || 3000;
   await app.listen(port, '0.0.0.0');
 }
 bootstrap();

Cloud Run + Cloud SQLを使えるようにする

データベースを作成する

GCPのコンソールもしくはからCloudSQLでインスタンスを作成する。テスト用なので一番小さいスペックで良い。インスタンスが出来たらユーザーとデータベースを確認しておく。

コマンドで作成する場合の例。公式のここを参考に。

$ gcloud sql instances create INSTANCE_NAME \
--database-version=POSTGRES_12 \
--cpu=2 \
--memory=7680MB \
--storage-size=10 \
--storage-type=HDD \
--tier=db-f1-micro \
--region=us-central \

$ gcloud sql users set-password postgres \
--instance=INSTANCE_NAME \
--password=PASSWORD

Cloud SQL Admin APIを有効化する

Cloud Run から Cloud SQL への接続 | Cloud SQL for PostgreSQL | Google Cloudを見てAPIを有効化しておく。これを忘れるとCloud RunからCloud SQLへ接続が通らない。

デプロイ

gcloud run deploy PROJECT_NAME \
 --source . \
 --project PROJECT_ID \
 --platform=managed \
 --region REGION \
 --allow-unauthenticated \
 --add-cloudsql-instances PROJECT_ID:REGION:NAME \
 --set-env-vars="DATABASE_URL=postgresql://postgres:password@localhost:5432/postgres?host=/cloudsql/PROJECT_ID:REGION:NAME"

確認

生成されたURLでREST APIを叩けるか確認してみる。

$ curl -X POST https://nestjs-prisma-crud-XXXXXXXXX-XX.X.run.app/task -H 'Content-Type:application/json' -d "{\"title\":\"あいうえお\", \"content\": \"アイウエオ\"}"
$ curl -X GET https://nestjs-prisma-crud-XXXXXXXXX-XX.X.run.app/task
$ curl -X GET https://nestjs-prisma-crud-XXXXXXXXX-XX.X.run.app/task/1
$ curl -X PATCH https://nestjs-prisma-crud-XXXXXXXXX-XX.X.run.app/task/1 -H 'Content-Type:application/json' -d "{\"title\":\"あああああ\", \"content\": \"いいいいい\"}"
$ curl -X DELETE https://nestjs-prisma-crud-XXXXXXXXX-XX.X.run.app/task/1

Cloud Run -> Cloud SQL への疎通を行うための設定とprismaを適切なタイミングで実行したりするDockerfileを作成する部分が出来ればあとはそんなに大変じゃなかった。

一通りやってみて思ったけどprisma migrateはDockerfileに含めない方が良いかも。継続的デプロイの設定をするときにimage buildとcontainer deployを分割し、ワークフローの中でmigrateの処理を挟む方が柔軟性高そうだし。ここをみた感じだとcloudbuild.yamlを書いてるが、このstepの中にmigrateの処理を挟めば良さそうである。

掃除

このままにしておくとCloud SQLの課金やArtifact Registryの課金が発生するのでもうしばらく使わなそうな場合はプロジェクトごと消してしまうと良い。ただしプロジェクト作成数には制限があるのである程度まとまった作業が終わってから消すようにしたほうが無難かも。プロジェクトを削除しても完全には消えず30日間は残り続ける仕様で、それらもプロジェクト作成数制限(デフォだと20くらい)に引っかかるらしい。Tutorialをやったらプロジェクト消してまた新しいTutorialをやるときに新しいプロジェクトを作って...みたいなことをしてると上限にぶつかる。

まとめ

新たなバックエンドのWeb開発の環境を模索すべくNestJS + Prisma + Cloud Run + Cloud SQLを試してみた。Cloud Runはイメージを用意するだけでサクッとスケーラブルなサーバー環境が立ち上がるのでめちゃくちゃ便利そうということがわかった。Cloud Runのデプロイ後初回アクセスが遅いっぽいのとかCloud SQLをDBとして据えてどれくらいパフォーマンスが出るのかとかその辺りは検証してないのでわからないが、使ってる人たちの声を眺めると普通に~中規模くらいまでなら問題なく使えるのでは?という雰囲気だった。GUIでぽちぽち出来るしサーバーレスゆえサーバー管理に煩わされにくそうなので、小さなチームでサービスを作る時に選択肢に入りそう。

NestJSに関してはアーキテクチャが割と好みだし、prismaはmigrationやらclient生成までそつなくこなしてくれそうで割と難なく使っていけそうな雰囲気があった。REST APIだけでなくGraphQLも考慮された作りになっていて雛形生成もできるのでそういうバックエンド作りにも便利そう(というかNestJSでGraphQLサーバーを作るみたいな方が情報としてむしろ多かった)。あとは公式Docsを読み込んでいくか〜という感じ。

価格に関してはCloud RunはそうでもないがCloud SQLは東京リージョンの最安でdb-f1-microの$12くらいなのでちゃんと使うにはそれなりにお金はかかると思う。なので個人開発でできるだけ安くしたいという場合だとちょっと手が出しにくいかも。Herokuを使ったりFirebase/Supebaseなんかに頼った方が個人開発には向いてそうと感じた。

最後に今回使ったコードはここに置いておく。参考までに。
https://github.com/YuheiNakasaka/nestjs-prisma-crud

リンク

Cloud Run へのデプロイ  |  Cloud Build のドキュメント  |  Google Cloud
Node.js サービスをビルドしてデプロイする  |  Cloud Run のドキュメント  |  Google Cloud
クイックスタート: ビルドとデプロイ  |  Cloud Run のドキュメント  |  Google Cloud
GCPの秩序を取り戻すための試み 〜新米GCP管理者の奮闘記〜 - ZOZO TECH BLOG
Google Cloudなんもわからないマンが、Cloud Runの凄さをあれこれ調べてみた | DevelopersIO
NestJS を Cloud Run へデプロイする方法いろいろ / 継続的デプロイの構築
gcloud でアカウントやプロジェクトを切り替える | 1Q77
Google Cloud Platformのプロジェクト数の割り当てを増やしてもらった - みーのぺーじ
Documentation | NestJS - A progressive Node.js framework
Prisma - Next-generation Node.js and TypeScript ORM for Databases
Prisma Migrationの動作を確認してみた | DevelopersIO
nest.js + prisma + fastify + postgresqlでREST APIのCRUDを作成する
Cloud RunからCloud SQLやMemorystoreへの繋ぎ方 - オールアバウトTech Blog
Cloud Run+Cloud SQLでPrisma in NestJSを動かすお話 - Qiita
Cloud Run から Cloud SQL への接続  |  Cloud SQL for MySQL  |  Google Cloud

Discussion

yuuyuu

質問なのですが、こちらの手順ではNestJSでAPIを作成した後にdckerによりコンテナ化しているようですが、NestJsプロジェクトを初めからdockerで構築する場合とどのような違いがあるのでしょうか?
是非教えていただけると嬉しいです。