Google Cloud Run

GCP Hono Validation Container

Hono を中核に据えたコンテナファーストの ReScript サービスで、Google Cloud Run へのデプロイをすぐに行える。Hono (Node.js) スターターと異なり、このテンプレートは設計済みのマルチステージ Dockerfile.dockerignore.env.example、そして gcloud run deploy と Cloud SQL のレシピを順を追って説明する README セクションを同梱している。

ランタイムイメージは意図的に小さく構成されている。ビルダーステージで全依存関係をインストールし ReScript をコンパイルした後、ランタイムステージで 本番用のみ の依存関係を再インストールし、生成された .res.mjs ファイルをコピーし、非 root ユーザーにドロップしてポート 8080 を公開する。結果として、ローカル・CI レジストリ・Cloud Run のいずれの環境でも同じ動作をする決定的なイメージが得られる。

生成内容

my-service/
├── rescript.json
├── package.json                    # ESM, "type":"module", engines.node = >=24
├── Dockerfile                      # multi-stage: builder → runtime
├── .dockerignore                   # excludes node_modules, lib/, coverage, .env
├── .env.example                    # PORT=8080 (Cloud Run sets it for you)
├── src/
│   ├── ServerMain.res              # entry — calls Server.start()
│   ├── Server.res                  # Hono app: GET /, POST /echo, reads PORT
│   ├── Hono.res                    # bindings shared with other Hono templates
│   ├── HonoNodeServer.res          # @hono/node-server bindings
│   ├── Validation.res              # zod or sury — selected in the wizard
│   └── __tests__/Server.test.mjs   # vitest smoke test that imports Server.res.mjs
├── README.md                       # script docs + API + Environment + Deploy + Cloud SQL
├── LICENSE                         # MIT, holder = project name
├── .nvmrc                          # Node 24
├── .gitignore                      # node_modules + dist/ + .env + ReScript artifacts
├── .editorconfig                   # 2-space indent, LF line endings
└── .github/
    ├── dependabot.yml              # weekly npm updates
    └── workflows/ci.yml            # install + rescript build + vitest

ウィザードオプション

オプション

効果

Project name

npm の name、LICENSE の保持者となり、README の gcloud run deploy <projectName> 例にも差し込まれる

Package manager

npm / pnpm / yarn / bun のいずれか。packageManager Corepack フィールド、README のインストール/実行コマンド、CI キャッシュキー、そして Dockerfile のインストールコマンドとベースイメージ を切り替える

Validation library

zodsury。どの src/Validation.res バリアントを同梱するかを選択し、対応するランタイム依存を追加する

Dockerfile の分岐は、他の Hono テンプレートとの意味のある違いとなっている。Bun を選ぶとベースイメージは oven/bun:1-slim に切り替わり、すべての npm install / pnpm install 行は bun install に置き換わり、ランタイムユーザーは node から bun に変わる(どちらも uid 1000)。Node のメジャーバージョンを nodeMajor (デフォルトは TemplateVersions.NODE_MAJOR)に固定することで、gcloud run deploy とローカルの node --watch が常に同じランタイムを見るようになる。package.json の engines とイメージのバージョンずれは発生しない。

主要な依存

パッケージ

用途

バージョン

rescript

ReScript コンパイラ

TemplateVersions.RESCRIPT

@rescript/core

標準ライブラリ

TemplateVersions.RESCRIPT_CORE

@rescript/runtime

コンパイル済み .res.mjs の import に必要なランタイムスタブ

TemplateVersions.RESCRIPT_RUNTIME

hono

HTTP フレームワーク。小型・高速で、Web 標準の Request/Response に対応

TemplateVersions.HONO

@hono/node-server

Hono アプリを Node の HTTP サーバーにマウントするアダプタ

TemplateVersions.HONO_NODE_SERVER

zod または sury

バリデーションバックエンド(ウィザードで選択)

TemplateVersions.ZOD / TemplateVersions.SURY

vitest (dev)

スモークテストランナー

TemplateVersions.VITEST

@vitest/coverage-v8 (dev)

test:coverage 用のカバレッジプロバイダ

TemplateVersions.VITEST_COVERAGE_V8

主要なファイル

src/Server.res

Hono アプリ本体。process.env から PORT を読み取り(Cloud Run が自動的に設定する)、ローカル開発用には 8080 にフォールバックする:

@val external processEnv: Dict.t<string> = "process.env"

let port =
  processEnv
  ->Dict.get("PORT")
  ->Option.flatMap(Int.fromString(_))
  ->Option.getOr(8080)

let app = Hono.createApp()

app->Hono.get("/", ctx => ctx->Hono.text("Cloud Run + Hono + ReScript"))

app->Hono.post("/echo", async ctx => {
  let raw = await ctx->Hono.req->Hono.jsonBody
  switch Validation.parseEchoPayload(raw) {
  | Error(msg) => ctx->Hono.status(400)->Hono.json({"error": msg})
  | Ok(payload) =>
    ctx->Hono.json({"echo": payload.message, "receivedAt": Date.now()->Float.toString})
  }
})

let start = () => {
  HonoNodeServer.serve({fetch: app->HonoNodeServer.honoFetch, port})
  Console.log(`Server running on http://localhost:${port->Int.toString}`)
}

CORS フックはファイル冒頭付近にコメントアウトされた状態で残されている。別オリジンのブラウザからサービスを呼び出す場合はコメントを解除し、デプロイ前に許可リストを調整すること。

src/ServerMain.res

中身は Server.start() の 1 行のみ。この分割があることで、vitest が import("../Server.res.mjs") を実行してポートにバインドせずにアプリのコンパイルを検証できる。

src/Validation.res

parseEchoPayload: JSON.t => result<echoPayload, string> —— /echo ルートのボディに対するランタイムコントラクト。シグネチャは zod / sury のどちらのバリアントでも同一なので、呼び出し側がどちらのライブラリが採用されたかで分岐する必要はない。

Dockerfile

ウィザード入力から生成される 2 ステージのイメージ。具体的な内容は選択したパッケージマネージャと nodeMajor に依存するが、形は次のとおり:

# syntax=docker/dockerfile:1.7

# --- Builder stage: install all deps + compile ReScript ---
FROM node:22-slim AS builder
WORKDIR /app
COPY package*.json ./
RUN <pm-install>          # e.g. corepack enable && pnpm install --frozen-lockfile=false
COPY . .
RUN <pm-exec> rescript    # e.g. pnpm exec rescript / npx rescript / bunx rescript

# --- Runtime stage: prod deps + compiled output only ---
FROM node:22-slim AS runtime
ENV NODE_ENV=production
WORKDIR /app
COPY package*.json ./
RUN <pm-install-prod>     # e.g. pnpm install --prod --frozen-lockfile=false
COPY --from=builder /app/src ./src
USER node
EXPOSE 8080
CMD ["node", "src/ServerMain.res.mjs"]

次の 3 つの性質は意図的であり、設計上重要である:

  • node_modules はステージ間でコピーされない —— --prod フラグで再インストールされるため、ランタイムレイヤーはロックファイルから再現可能となる。

  • ランタイムステージは非 root ユーザーで動作する(Node イメージでは nodeoven/bun では bun)。Cloud Run の堅牢化ポリシーがこれを前提としている。

  • ベースイメージの Node メジャーバージョンは package.jsonengines.node および .nvmrc と一致する。 nodeMajor を更新すると、3 つすべてが同時に更新される。

.dockerignore

VCS メタデータ、IDE の状態、パッケージマネージャのデバッグログ、lib/ の ReScript ビルド成果物、coverage/、すべての .env* ファイルを除外することでビルドコンテキストを小さく保つ。!.env.example 行はサンプル env ファイルを保持し、README のデプロイ手順が引き続きそれを参照できるようにする。

npm スクリプト

スクリプト

説明

start

node src/ServerMain.res.mjs —— コンパイル済みサーバーを実行する

dev

node --watch src/ServerMain.res.mjs —— 再ビルド出力に応じて再起動する

test

vitest run —— スモークスイートを実行する

test:coverage

vitest run --coverage —— 上記に v8 カバレッジを加えたもの

res:build

rescript —— ワンショットコンパイル

res:dev

rescript -w —— 保存時に再コンパイル

res:clean

rescript clean —— 生成された .res.mjs を削除する

ローカル開発では res:devdev を 2 つのターミナルで動かす(あるいは、シングルウィンドウのワークフローを好むなら、自前の concurrently 呼び出しで連結する)。

Cloud Run へのデプロイ

README には、Cloud Build でイメージをプッシュし gcloud run deploy でロールアウトするコピー&ペースト可能なデプロイブロックが含まれている:

gcloud builds submit --tag gcr.io/PROJECT-ID/<projectName>
gcloud run deploy <projectName> \
  --image gcr.io/PROJECT-ID/<projectName> \
  --port 8080 \
  --allow-unauthenticated

Cloud Run はランタイムで PORT を注入する(値が常に 8080 とは限らない)。そのため Server.resprocess.env からこの値を読み取る。--port 8080 フラグはコンテナがリッスンするポートを Cloud Run に伝えるものであり、公開するポートを指定するものではない —— これらは独立した設定である。

README ではさらに 2 つの運用フォローアップを扱っている。デプロイ済みリビジョンのイメージダイジェストを固定して不変ロールアウトを実現する方法と、gcloud run services logs tail によるログのテーリングである。

Cloud SQL レシピ

同梱の README には、@google-cloud/cloud-sql-connector(またはバニラの pg ドライバと Cloud SQL Auth Proxy の組み合わせ)を使って Cloud SQL の Postgres インスタンスに接続する短いレシピが含まれている。要約すると次のパターンになる:

pnpm add pg @google-cloud/cloud-sql-connector
@module("pg") @new external makePool: 'opts => 'pool = "Pool"
@send external queryAsync: ('pool, string, array<'a>) => promise<'rows> = "query"

gcloud run deploy--set-env-vars を使い、DATABASE_URL(または Cloud SQL 接続名と認証情報)を環境変数として渡すこと。Postgres 上で Drizzle ORM を使う場合は Drizzle のセットアップ レシピを参照する。libsql クライアントを pg に置き換えれば同じ構造がそのまま当てはまる。

2日目以降のレシピ

プロジェクトを開いた後の ReScript 側のエディタワークフローについては 機能概要 を参照する。

補足

  • PORT はマニフェストで設定できない。 Cloud Run が呼び出しごとに設定し、Server.res がそれを尊重する。ローカルでは PORT=3000 pnpm dev が動作する。これは同じ env 読み取りロジックが、8080 にデフォルトする前にユーザー入力にフォールバックするためである。

  • Dockerfile はロックファイルがない場合でも bun install --production を再実行する。 Bun は初回インストール時に bun.lock を作成する。コミット済みロックファイルがない CI 環境でも動作するイメージは生成されるが、再現性を保証するため本番デプロイでは必ずロックファイルをコミットすべきである。

  • COPY --from=builder /app/src ./src はステージ間で受け渡される唯一の成果物である。追加の出力(dist/public/)を生成する場合は、それぞれに対して明示的な COPY --from=builder 行を追加する必要がある。

  • node エンジンは >=24 に固定されており.nvmrc24 を指定する。Dockerfile のベースイメージは nodeMajor(デフォルトは TemplateVersions.NODE_MAJOR)に追従する。ウィザードでいずれかを変更するとすべてが更新される。

  • vitest スイートは意図的に最小限である —— import("../Server.res.mjs") が解決することのみを検証する。リグレッションカバレッジをテストに依存させる前に、app.request("/") スタイルの統合テスト(例は Monorepo および Full-Stack テンプレートを参照)を追加すること。

  • .gitignore.env を除外する が、.env.example は保持する。Cloud Run のシークレットは --set-env-vars か Secret Manager を経由して渡すべきで、コミットした .env 経由で渡してはならない。

  • CORS はデフォルトで無効になっている。 別オリジンのブラウザサイドクライアントにサービスを公開する前に、Server.res 冒頭の Hono.cors(...) ブロックのコメントを解除し、オリジンの許可リストを固定すること。