wheatandcatの開発ブログ

React Nativeで開発しているペペロミア & memoirの技術系記事を投稿してます

Nestjs + Fastify + Mercurius + PrismaでAPIサーバーを立てて、Cloud Runでデプロイする

新しいアプリ開発でbackendをNode.jsでAPIをやってみようと思いタイトルの構成でAPIサーバーを作成してみたら、結構ハマりどころがあったので記事した

リポジトリ

github.com

モチベーション

  • 現在Flutterでアプリを作成中
  • FlutterでAPIのやり取りで型安全にするならgraphql_codegenが使える
    • もし、React NativeだったらGraphQLでは無くtRPCを採用していたと思うが、Flutterはサポートしていないので除外
  • 他の言語も検討したが、Prismaを使用したかったので Node.jsを採用

使用技術

実装

NestJSだと以下で新規プロジェクトを作ってくれるので、そこから作成

$ npm i -g @nestjs/cli
$ nest new project-name

まず、Fastifyを導入

src/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({ logger: true })
  )

  const port = Number(process.env.PORT) || 8080
  console.log(`Listening on port ${port}`)
  await app.listen(port, '0.0.0.0')
}

bootstrap()

次にGraphQLはSchema Firstで実装したかったので、GraphQLの定義を作成

src/schema.graphql

type Query {
  hello: String
  categories: [Category]
}

type Category {
  "カテゴリーID"
  id: ID!
  "カテゴリー名"
  name: String!
  "順番"
  order: Int!
}

type Item {
  "アイテムID"
  id: ID!
  "カテゴリーID"
  categoryId: ID!
  "アイテム名"
  name: String!
  "在庫数"
  stock: Int!
  "消費期限"
  expirationDate: Time
}

input NewCategory {
  "カテゴリー名"
  name: String!
  "順番"
  order: Int!
}

type Mutation {
  createCategory(input: NewCategory!): Category!
}

scalar Time

コードの方は以下のように実装

src/app.module.ts

import { Module } from '@nestjs/common'
import { GraphQLModule } from '@nestjs/graphql'
import { MercuriusDriver, MercuriusDriverConfig } from '@nestjs/mercurius'
import { AppController } from '@src/app.controller'
import { AppService } from '@src/app.service'
import { HelloResolver } from './hello.resolver'
import { CategoryResolver } from '@src/resolver/category'
import { PrismaService } from '@src/modules/prisma/prisma.service'

@Module({
  controllers: [AppController],
  providers: [AppService, HelloResolver, CategoryResolver, PrismaService],
  imports: [
    GraphQLModule.forRoot<MercuriusDriverConfig>({
      driver: MercuriusDriver,
      graphiql: true,
      typePaths: ['./**/schema.graphql'],
    }),
  ],
})
export class AppModule {}

resolverでGraphQLのtypeを使用したいのでGraphQL Code Generatorで型を自動生成

codegen.ts

import type { CodegenConfig } from '@graphql-codegen/cli'

const config: CodegenConfig = {
  overwrite: true,
  schema: './src/schema.graphql',
  generates: {
    'src/generated/graphql.ts': {
      plugins: ['typescript'],
    },
  },
}

export default config

package.jsonに以下のコマンドを追加

package.json

  "scripts": {
     ...
    "codegen": "graphql-codegen --config codegen.ts"
  }

以下のコマンド実行でtypeファイルを作成

$ pnpm run codegen

作成されたファイルが以下

...

export type Category = {
  __typename?: 'Category';
  /** カテゴリーID */
  id: Scalars['ID']['output'];
  /** カテゴリー名 */
  name: Scalars['String']['output'];
  /** 順番 */
  order: Scalars['Int']['output'];
};

export type Item = {
  __typename?: 'Item';
  /** カテゴリーID */
  categoryId: Scalars['ID']['output'];
  /** 消費期限 */
  expirationDate?: Maybe<Scalars['Time']['output']>;
  /** アイテムID */
  id: Scalars['ID']['output'];
  /** アイテム名 */
  name: Scalars['String']['output'];
  /** 在庫数 */
  stock: Scalars['Int']['output'];
};

上記のtypeファイルを使用してresolverを作成 ※Guardとかの概念の説明は長くなるので次回の記事で紹介する

src/resolver/category.ts

import { Resolver, Query, Mutation, Args, Context } from '@nestjs/graphql'
import { UseGuards } from '@nestjs/common'
import {
  Query as QueryType,
  NewCategory,
  Category,
} from '@src/generated/graphql'
import { PrismaService } from '@src/modules/prisma/prisma.service'
import { format } from '@src/lib/graphql'
import { AuthGuard } from '@src/common/guards/auth/auth.guard'

@Resolver('')
export class CategoryResolver {
  constructor(private prisma: PrismaService) {}

  @Query(() => String)
  @UseGuards(AuthGuard)
  async categories(@Context() context): Promise<QueryType['categories']> {
    const user = context.req.auth

    const r = await this.prisma.category.findMany({
      where: {
        userId: user.userId,
      },
      orderBy: {
        order: 'asc',
      },
    })
    return r.map((c) => format(c))
  }

  @Mutation('createCategory')
  @UseGuards(AuthGuard)
  async createCategory(
    @Args('input') input: NewCategory,
    @Context() context
  ): Promise<Category> {
    const user = context.req.auth

    const r = await this.prisma.category.create({
      data: {
        userId: user.userId,
        name: input.name,
        order: input.order,
      },
    })

    return format(r)
  }
}

ローカルで起動して実行してレスポンスを確認

問題なく動作しているのを確認したので、Cloud Runでデプロイの準備
最初はCloud Native Buildpacksを使用してDockerfile無しでの実装を検討したが以下の記事の通り、うまくいかなった

zenn.dev

なので、以下のDockerfileを用意

Dockerfile

# ステージ1: 依存関係のインストールとビルド
FROM node:18 AS builder
WORKDIR /usr/src/app

# pnpmのインストール
RUN npm install -g pnpm

# package.jsonとpnpm-lock.yamlをコピー
COPY package.json pnpm-lock.yaml ./

# 依存関係のインストール
RUN pnpm install

# Prismaクライアントの生成
COPY prisma ./prisma/
RUN npx prisma generate

# アプリケーションのソースコードをコピー
COPY . .

# アプリケーションのビルド
RUN pnpm run build

# ステージ2: 本番環境用イメージの作成
FROM node:18-alpine AS production
WORKDIR /usr/src/app

# 環境変数 NODE_ENV を production に設定
ENV NODE_ENV=production

# pnpmのインストール
RUN npm install -g pnpm

# package.jsonとpnpm-lock.yamlをコピー
COPY package.json pnpm-lock.yaml ./

# 本番依存関係のみをインストール
RUN pnpm install

# ビルドしたファイルをコピー
COPY --from=builder /usr/src/app/dist ./dist
COPY --from=builder /usr/src/app/node_modules ./node_modules
COPY --from=builder /usr/src/app/prisma ./prisma
COPY --from=builder /usr/src/app/src ./src

# アプリケーションがリッスンするポート番号
EXPOSE 8080

CMD ["node", "dist/src/main"]

上記でdocker buildしたら以下のエラーが発生

PrismaClientInitializationError: Prisma Client could not locate the Query Engine for runtime "linux-musl-arm64-openssl-3.0.x".

This happened because Prisma Client was generated for "darwin-arm64", but the actual deployment required "linux-musl-arm64-openssl-3.0.x".
Add "linux-musl-arm64-openssl-3.0.x" to `binaryTargets` in the "schema.prisma" file and run `prisma generate` after saving it:

generator client {
  provider      = "prisma-client-js"
  binaryTargets = ["native", "linux-musl-arm64-openssl-3.0.x"]
}

PrismaはLinuxとMacで実行時のbinaryTargetsを指定しないとエラーになるので、エラーに記載の通りに以下を追加 ■prisma/schema.prisma

generator client {
  provider      = "prisma-client-js"
  binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
}

これで以下のコマンドでビルド成功

$ docker build --platform linux/amd64  -t gcr.io/your-project-id/stock-keeper-backend .
$ docker push gcr.io/your-project-id/stock-keeper-backend

最後に以下のコマンドでデプロイ完了

$ gcloud run deploy stock-keeper \
  --image gcr.io/your-project-id/stock-keeper-backend2 \
  --platform managed \
  --region asia-northeast1 \
  --allow-unauthenticated

中なハマりどころの多かったが、なんとかデプロイまで出来るようになった