Open17

Hono.js / Cloud Run / Cloud SQL / Prisma構成でのDocker環境構築メモ

hosaka313hosaka313

What

Hono.js / Cloud Run / Cloud SQL / PrismaでデータベースをクエリしJSONを作成するAPIを作る際の環境構築メモ。

Motivation

Ruby on Railsで動いているバックエンドの移行先としての検証。

既存のRailsアプリは責務が多岐に渡り、環境構築、Rspec作成、バージョンアップが困難なため、軽いAPIに分化させるための選択肢としてHono.jsを検討。

hosaka313hosaka313

Hono.jsのセットアップ〜デプロイ先の検討〜

https://hono.dev/docs/

  • Cloudflare Workers
  • Cloudflare Pages
  • Deno
  • Bun
  • Vercel
  • AWS Lambda
  • Lambda@Edge

など。

データベースにCloudSQL(MySQL)を使う、という条件で絞り込まれる。

できればCloudflare Workersを採用したいが、Edge RuntimeでPrismaを使うためにはPrisma Accelerateが必要。

https://www.prisma.io/accelerate

まだ成熟していないのと、費用が気になってくるため見送り。

https://www.prisma.io/pricing

HyperdriveはMySQLがcoming soonなので使えない。
https://developers.cloudflare.com/hyperdrive/reference/supported-databases/

次善としてCloudSQLを使うので、デプロイ先はGoogle Cloud系のサービスにしたい。
Cloud Functions for Firebase / Cloud Run Functionsは、Hono.jsのドキュメント、コード例が少ないので、回避し、コンテナに入れてCloud Runで使うことにした。

hosaka313hosaka313

Node.js用セットアップ

下記のドキュメントにしたがってNode.jsテンプレートでpnpm create honoする。

https://hono.dev/docs/getting-started/nodejs

下の方に書いてある、下記を忘れないようにする。

  • Add "outDir": "./dist" to the compilerOptions section tsconfig.json.
  • Add "exclude": ["node_modules"] to tsconfig.json.
  • Add "build": "tsc" to script section of package.json.
  • Run npm install typescript --save-dev.
  • Add "type": "module" to package.json.

Dockerfile

Cloud Run用のDockerfileと開発用のDockerfile.devの2つ作った。

ドキュメント掲載のものからの差分は以下。

  • pnpmを使う
    • マルチステージで2回pnpmを入れているのは直したい。
  • prsima関連の処理
  • portを8080に。(Cloud Run用)

Dockerfile

Dockerfile
FROM node:20-alpine AS base

FROM base AS builder

WORKDIR /app

RUN npm install -g pnpm
COPY package*json tsconfig.json src prisma ./

RUN pnpm install
RUN pnpm build

RUN pnpm prisma generate

FROM base AS runner
WORKDIR /app

RUN npm install -g pnpm

# Userの設定
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 hono

COPY --from=builder --chown=hono:nodejs /app/node_modules /app/node_modules
COPY --from=builder --chown=hono:nodejs /app/dist /app/dist
COPY --from=builder --chown=hono:nodejs /app/package.json /app/package.json

USER hono
EXPOSE 8080

CMD ["pnpm", "start"]

Dockerfile.dev

Dockerfile.dev
FROM node:20-alpine

WORKDIR /app

RUN npm install -g pnpm
COPY package*.json ./

RUN pnpm install

# ソースコードはvolume mount

CMD ["pnpm", "dev"]
hosaka313hosaka313

compose.yml

こちらもテスト用DBとCloudSQL(開発用)に接続する2つを作る。

CloudSQLに繋ぐバージョン

Cloud SQL Auth ProxyのDockerイメージがあるので、それを使う。

注意点として、V1とV2が記法の差異があるので確認しておく。(ネットの記事ではV1が多い)
https://github.com/GoogleCloudPlatform/cloud-sql-proxy/blob/main/migration-guide.md

事前準備

Cloud SQLにアクセス権のあるサービスアカウントのキーをコンテナからアクセスできるようにしておく。

今回は**Application Default Credentials (ADC)**を使った。

$gcloud auth login

これで~/.config/gcloud/application_default_credentials.jsonが生成される。
これをプロジェクトのcredentials/application_default_credentials.jsonとして保管し、mountする。[1]

--credentials-file=/credentials/application_default_credentials.jsonで認証キーのありかを指定する。また、別コンテナからアクセスできるように--address 0.0.0.0を指定。

compose.yml
services:
  sql_proxy:
    image: gcr.io/cloud-sql-connectors/cloud-sql-proxy:latest
    volumes:
      - ./credentials/:/credentials
    ports:
      - "127.0.0.1:3306:3306"  # localhost限定でバインド
    restart: always
    command: --address 0.0.0.0 <project_name>:<region>:<instance_name> --credentials-file=/credentials/application_default_credentials.json
  api:
    build:
      context: .
      dockerfile: Dockerfile.dev 
    ports:
      - "8080:8080"
    environment:
      - DATABASE_URL=${DATABASE_URL}
    depends_on:
      - sql_proxy
    volumes:
      - .:/app
      - /app/node_modules

ローカルのMySQLを立てる

こちらはより簡単。パスワード等、ベタ打ち。

DATABASE_URLにrootを使っているのがよろしくないが、参考記事を見てもどうしてもprisma migrate devが通らなくて諦めた。

https://zenn.dev/tatsuyasusukida/articles/why-prisma-migrate-dev-fails-in-myql

services:
  db:
    image: mysql:8.0
    command: --default-authentication-plugin=mysql_native_password
    restart: always
    environment:
      MYSQL_ROOT_PASSWORD: rootpassword
      MYSQL_DATABASE: testdb
      MYSQL_USER: testuser
      MYSQL_PASSWORD: testpassword
    ports:
      - "3306:3306"
    volumes:
      - mysql_data:/var/lib/mysql
      - ./mysql/init:/docker-entrypoint-initdb.d
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      timeout: 10s
      retries: 10

  seminar-list-backend-test:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "8080:8080"
    environment:
      - DATABASE_URL=mysql://root:rootpassword@db:3306/testdb
      - ALLOW_INTEGRATION_TEST=true # vitest環境で誤ってDBテストを回さないようにするため。
    depends_on:
      db:
        condition: service_healthy
    volumes:
      - .:/app
      - /app/node_modules

volumes:
  mysql_data:
脚注
  1. volume mountしている記事がほとんどだったので従ったが、--mount=type=secretなどよりセキュアなやり方はありそう。 ↩︎

hosaka313hosaka313

Prisma Schema作成

docker compose upでCloud SQL用のコンテナ群を立てたら、下記に従ってSchemaを落としてくる。

https://zenn.dev/hosaka313/scraps/e43fde2fb5f0df

具体的には.envDATABASE_URLは以下になる。

$mysql://<user_name>:<password>@sql_proxy:3306/<db_name>

@sql_proxycompose.ymlで指定したサービス名になる。

hosaka313hosaka313

package.json

先に掲示すると、package.jsonは下記のような感じ。(工事中)
テスト環境は隔離したいので、container:~に分けているが、少し見苦しい感じになっている。

VSCodeでDev Containerを使うのも選択肢。
https://code.visualstudio.com/docs/devcontainers/containers

{
  "name": "seminar-list-backend",
  "type": "module",
  "scripts": {
    "dev": "npx prisma generate & tsx watch src/index.ts",
    "build": "tsc",
    "test": "pnpm vitest",
    "start": "node dist/index.js",
    "migrate": "prisma migrate dev",
    "generate": "prisma generate",
    "container:up": "docker-compose -f compose.test.yml up -d",
    "container:down": "docker-compose -f compose.test.yml down",
    "container:remove": "docker-compose -f compose.test.yml down -v",
    "container:install": "docker-compose -f compose.test.yml exec seminar-list-backend-test pnpm install",
    "container:migrate": "docker-compose -f compose.test.yml exec seminar-list-backend-test pnpm migrate",
    "container:generate": "docker-compose -f compose.test.yml exec seminar-list-backend-test pnpm generate",
    "container:setup": "pnpm container:install && pnpm container:migrate && pnpm container:generate",
    "container:test": "docker-compose -f compose.test.yml exec seminar-list-backend-test pnpm test"
  },
  "dependencies": {
    "@hono/node-server": "^1.13.3",
    "@hono/swagger-ui": "^0.4.1",
    "@hono/zod-openapi": "^0.16.4",
    "@prisma/client": "^5.21.1",
    "hono": "^4.6.8",
    "zod": "^3.23.8"
  },
  "devDependencies": {
    "@biomejs/biome": "1.9.4",
    "@quramy/prisma-fabbrica": "^2.2.1",
    "@types/node": "^20.11.17",
    "prisma": "^5.21.1",
    "tsx": "^4.7.1",
    "typescript": "^5.6.3",
    "vitest": "^2.1.4",
    "vitest-environment-vprisma": "^1.3.0"
  }
}

@hono/zod-openapi

ValidationとOpenAPI Specが一緒に書ける。

https://hono.dev/examples/zod-openapi

@quramy/prisma-fabbrica

テストデータ作成用。

https://github.com/Quramy/prisma-fabbrica

vitest-environment-vprisma

jest-prismaのVitest版。
テストごとにrollbackしてくれて、実際のデータを用いながらデータベースを汚さず、分離(isolation)を維持したテストができる。

https://github.com/aiji42/vitest-environment-vprisma

hosaka313hosaka313

Cloud Runへのデプロイについて

デプロイについて早めに。

Cloud Runなのでコンテナが起動できればgcloud run deploy <options>でだいたい問題なく動くが、CloudSQLへの接続で設定が必要。

https://cloud.google.com/sdk/gcloud/reference/run/deploy

  • サービスアカウント
    • 別プロジェクトのCloudSQLを使うケースなどでは、Default compute service accountでは権限が足りないため、指定が必要。(--service-account
  • Cloud Runでの接続設定
  • DATABASE_URLの変更

Cloud Runへのアクセス(gcloud run services proxy)

publicなAPIであっても開発環境のCloud Runにはallow-unauthenticatedはNoにし、認証なしアクセスは禁止する。

gcloud auth loginしてあれば、

$gcloud run services proxy my-service --port=8080

として、localhostをCloud Runのサービスにproxyできる。

https://cloud.google.com/sdk/gcloud/reference/run/services/proxy

hosaka313hosaka313

Hono.jsを書く

ここからはベストプラクティスなどを見ながらAPIを書いていく。

https://hono.dev/docs/guides/best-practices

下記の記事を参考に、Honoインスタンスをシングルトンで引き回し、routeを登録していく形式とした。

https://zenn.dev/aishift/articles/a3dc8dcaac6bfa#app%2Fcontextオブジェクトの使い方

factory

以下のようにしてみた。

HonoではなくOpenAPIHonoの方のインスタンスを使う。

src/factory/createApp.ts
import { swaggerUI } from "@hono/swagger-ui";
import { OpenAPIHono } from "@hono/zod-openapi";
import { logger } from "hono/logger";
import { secureHeaders } from "hono/secure-headers";

export const createApp = () => {
  const app = new OpenAPIHono();
  app.use(logger())
  app.use(secureHeaders())
  app.onError(handleError);

  /**
   * Swagger UI
   */
  app.get("/ui", swaggerUI({ url: "/doc" }));
  app.doc("/doc", {
    openapi: "3.0.0",
    info: {
      version: "1.0.0",
      title: "バックエンドAPI",
      description: "Hono.jsで作ってみるバックエンドAPI",
    },
  });
  return app;
}

export type App = ReturnType<typeof createApp>;

/**
 * グローバルエラーハンドリング
 */
function handleError(err: Error) {
  return new Response(JSON.stringify({
    message: 'エラーが発生しました',
    error: err.message
  }), {
    status: 500,
    headers: {
      'Content-Type': 'application/json'
    }
  });
}

prismaも後々のmockも考えてfactory関数経由にしたいので、以下のように作った。

src/factory/getPrisma.ts
import { PrismaClient } from '@prisma/client'

/**
 * PrismaClientを取得する
 * @param database_url 環境変数のDATABASE_URL
 */
export const getPrisma = (database_url: string | undefined) => {
  if (!database_url) {
    throw new Error("DATABASE_URL is not set")
  }

  const prisma = new PrismaClient({
    datasourceUrl: database_url,
  })

  return prisma
}

コネクション管理のため、これもシングルトンにしたり、contextに注入したりした方が良いのかな、と思ったが、ドキュメントでは上記のような実装が推奨だった。

https://hono.dev/examples/prisma

Prismaの方には「faasでは、handlerの外に書いてね」とあるので、ちょっと悩みどころ。

https://www.prisma.io/docs/orm/prisma-client/setup-and-configuration/databases-connections#serverless-environments-faas

contextに入れるかどうかの議論は下記があった。

https://github.com/honojs/hono/issues/414#issuecomment-1547014723

hosaka313hosaka313

index.ts

index.tsは綺麗に保ちたいので、下記のようにした。

/seminarというエンドポイントを生やすために、seminarApi(app)という関数を足す。
この形式にしておくと、整然とエンドポイントを足していくことができるので、書き味が良さそう。

これも下記で紹介されていたやり方。
https://zenn.dev/aishift/articles/a3dc8dcaac6bfa#app%2Fcontextオブジェクトの使い方

index
import { serve } from '@hono/node-server'
import { seminarApi } from './routes/seminar/index.js'
import { createApp } from './factory/createApp.js'

const app = createApp()
seminarApi(app)

const port = 8080
console.log(`Server is running on http://localhost:${port}`)

serve({
  fetch: app.fetch,
  port
})

portはベタ打ちにしているが、環境変数から取った方がベター。

hosaka313hosaka313

src/routes/seminar/index.ts

上記のseminarApiの実装は下記のようになっている。
index.tsと同様のことを行い、/seminar以下のエンドポイントを生やしていく。

src/routes/seminar/index.ts
import { teacherIndexApi } from "./teachers/index.js";
import { teacherIdApi } from "./teachers/id.js";
import { holdsApi } from "./holds/index.js";
import type { App } from "../../factory/createApp.js";

/**
 * seminar/のAPIを登録する。
 */
export const seminarApi = (app: App) => {
  teacherIndexApi(app);
  teacherIdApi(app);
  holdsApi(app);
}

こうすることで階層的にエンドポイントを生やしていけるが、too muchな感もある。
HonoXだとFile-based routingできるようだが、詳しくは調べなかった。

hosaka313hosaka313

src/routes/seminar/teachers/index.ts

実際のエンドポイントを定義する。

src/routes/seminar/teachers/index.ts
import { createRoute } from "@hono/zod-openapi";
import type { App } from "../../../factory/createApp.js";
import { env } from "hono/adapter";
import type { APP_ENV } from "../../../types.js";
import { getPrisma } from "../../../factory/getPrisma.js";
import type { PrismaClient } from "@prisma/client";
import { TeachersIndexResponseSchema } from "../../../schema/seminar/teachers.js";

const teacherIndexRoute = createRoute({
  method: "get",
  path: "/seminar/teachers",
  description: "講師一覧を取得する",
  responses: {
    200: {
      content: {
        "application/json": {
          schema: TeachersIndexResponseSchema,
        },
      },
      description: "Returns the teacher list",
    },
    404: {
      content: {
        "application/json": {
          schema: {
            message: "Not Found",
          },
        },
      },
      description: "Teacher list not found",
    },
  },
});

/**
 * 講師一覧を取得する
 */
export const teacherIndexApi = (app: App) => {
  app.openapi(teacherIndexRoute, async (c) => {
    const databaseUrl = env<APP_ENV>(c).DATABASE_URL;
    const prisma = getPrisma(databaseUrl);
    const seminarTeachers = await getSeminarTeachers(prisma);

    return c.json(seminarTeachers, 200);
  });
}

/**
 * 講師一覧を取得する
 */
async function getSeminarTeachers(prisma: PrismaClient) {
  const seminarTeachers = await prisma.seminar_teachers.findMany({
    include: {
      judge: {
        select: {
          ptna_id: true,
          user: {
            select: {
              name: true,
              name_kana: true
            }
          }
        }
      }
    }
  });

  return seminarTeachers.map((seminarTeacher) => ({
    name: seminarTeacher.judge.user?.name ?? "",
    name_kana: seminarTeacher.judge.user?.name_kana ?? "",
    ptna_id: seminarTeacher.judge.ptna_id,
    teacher_point: seminarTeacher.teacher_point || 0
  }))
}

ここでnpx prisma generateで型エラーを見たり、schema.tsのリレーションを整えたり、少し苦労。
Railsのmigrationと併用したい場合、どう管理するのが良いのだろう...

Zod OpenAPI用のSchema

ファイルが大きくなってしまうので、切り分けた。

zのimport元は@hono/zod-openapizodではない模様。

import { z } from "@hono/zod-openapi";

const TeacherSchema = z.object({
  name: z.string(),
  name_kana: z.string(),
  ptna_id: z.number().nullable(),
  teacher_point: z.number()
});

export const TeachersIndexResponseSchema = TeacherSchema.array();
hosaka313hosaka313

requestの検証

requestの検証をしたい場合の一例。path paramsの場合。

  request: {
    params: z.object({
      id: z.string().refine((val) => !Number.isNaN(Number(val)), {
        message: "Invalid Id, must be a number"
      }).transform((val) => Number(val)).describe("userId")
    })
  },
hosaka313hosaka313

Test

Vitestを使ったテスト環境を作っていく。テストにあたってはPrismaClientをスタブせず、Docker上のMySQLにテストデータを与える統合テストを行う。

Prismaでの統合テストについては、決定版はなさそうなので、調べて決めるしかない。

https://www.prisma.io/docs/orm/prisma-client/testing/integration-testing

この子達で戦うことにした。

    "vitest": "^2.1.4",
    "vitest-environment-vprisma": "^1.3.0"
    "@quramy/prisma-fabbrica": "^2.2.1",
hosaka313hosaka313

tests/vitest.setup.ts

  • vitest-environment-vprismaの差し込み
    • prisma のfactoryをmockすることで実現
  • @quramy/prisma-fabbricaのinitialize

を行うセットアップ関数を作成。

不要かもしれないが、composeファイルの指定を間違えてAuth Proxyで繋がっているCloudSQLを向いてしまうのが怖いので、ALLOW_INTEGRATION_TESTという独自環境変数を検証して、明示的に指定がない場合は、エラーを出すようにした。(rails_helperでのRAILS_ENVの検証みたいな...?)

tests/vitest.setup.ts
import { initialize } from "../src/__generated__/fabbrica/index.js"

vi.mock("../src/factory/getPrisma.js", () => {
  console.log(`ALLOW_INTEGRATION_TEST: ${process.env.ALLOW_INTEGRATION_TEST}`)
  if (!process.env.ALLOW_INTEGRATION_TEST) {
    throw new Error("Integration test is not allowed. Please set ALLOW_INTEGRATION_TEST=true")
  }
  console.log("mocking prisma...")
  return {
    getPrisma: vi.fn(() => vPrisma.client),
  }
})

const prisma = vPrisma.client;
initialize({ prisma });
hosaka313hosaka313

vitest.config.ts

しょぼいが下記。databaseUrlも怖いのであえて指定している。デフォルトはprocess.env.DATABASE_URLになる。

vitest.config.ts
import { defineConfig } from 'vitest/config'

export default defineConfig({
  test: {
    clearMocks: true,
    globals: true,
    environment: "vprisma",
    setupFiles: ["vitest-environment-vprisma/setup", "tests/vitest.setup.ts"],
    environmentOptions: {
      vprisma: {
        databaseUrl: "mysql://root:rootpassword@db:3306/testdb", // 誤削除防止のため、process.envではなく、文字列で指定
        verboseQuery: false,
        // Commit without rolling back the transaction. (default: false)
        disableRollback: false,
      },
    },
  },
})
hosaka313hosaka313

テストを書いてみる

faker.jsを使うのが良いかもしれないが、シンプルにseqで通し番号をつけてみた。

import { describe } from "node:test"
import { beforeEach, expect, test, vi } from "vitest"
import { createApp, type App } from "../../../src/factory/createApp.js"
import { seminarApi } from "../../../src/routes/seminar/index.js"
import { definejudgesFactory, defineseminar_teachersFactory, defineusersFactory, initialize } from "../../../src/__generated__/fabbrica/index.js"

describe("seminar/teachers", () => {
  let app: App;

  beforeEach(() => {
    // 各テスト前にモックをリセット
    vi.clearAllMocks();
    app = createApp();
    seminarApi(app);
  })

  test("GET /seminar/teachers", async () => {
    const UserFactory = defineusersFactory({
      defaultData: ({ seq }) => ({
        name: `user_${seq}`,
        name_kana: "ユーザー",
        ptna_id: 1000 + seq,
      })
    });
    const JudgeFactory = definejudgesFactory({
      defaultData: {
        user: UserFactory
      }
    });
    const SeminarTeachersFactory = defineseminar_teachersFactory({
      defaultData: async ({ seq }) => ({
        judge: JudgeFactory,
        teacher_point: seq + 1
      })
    });
    await SeminarTeachersFactory.createList(10);
    const response = await app.request("/seminar/teachers");

    // セミナー講師一覧が取得できること
    expect(response.status).toBe(200);
    const body = await response.json();
    expect(body).toHaveLength(10);
    body.forEach((teacher: unknown, index: number) => {
      expect(teacher).toEqual({
        name: `user_${index}`,
        name_kana: 'ユーザー',
        ptna_id: 1000 + index,
        teacher_point: index + 1
      });
    });
  });
})

これを実行するとテストがpassした。

ログを入れてみると、テストデータで想定のJSONが返ってきていた。

テスト用DBを見てみると、vitest-environment-vprismaのおかげでレコードはできていない。

hosaka313hosaka313

disableRollbackをtrueにしてみる。

試しに、vitest.config.tsの設定を変えて、データが残るようにしてみる。

    environmentOptions: {
      vprisma: {
        databaseUrl: "mysql://root:rootpassword@db:3306/testdb", // 誤削除防止のため、process.envではなく、文字列で指定
        verboseQuery: false,
        // Commit without rolling back the transaction. (default: false)
-        disableRollback: false,
+        disableRollback: true,
      },
    },

これでテストを再度実行してみると、レコードができる。

しかし、これだと次回実行時にエラーが出る。

  • ユニーク規制の違反
  • レコード数違いによるテスト失敗

これを防ぐためにvitest-environment-vprismaを使うので、基本はdisableRollbackfalse(デフォルト)にする。