📝

React Router on BunをCloudRunで動かす(CloudBuild + CloudDeploy)

2025/03/12に公開

前提

Bun で動く Web アプリ(React Router + Hono)をコンテナ化して CloudRun で動かします。
CI/CD は、GitHub と連携して CloudBuild と CloudDeploy を使います。

Web アプリ

React Router + Hono

詳細は以下の記事で説明しています。
ベースは React Router で、サーバサイドで動く API 部分を Hono で実装しています。

https://zenn.dev/k4nd4/articles/1599422fca8d7f

Dockerfile

※このファイルは Cline に書いてもらいました

公式の Bun イメージがあるので、それを使います。
動けばいいやの精神であまり修正していません。development-dependencies-envステージでディレクトリを丸ごとコピーしていたり、最終的にnode_modulesもコピーしていたりするところは改善の余地があるかもしれません。

このあたりは Node.js でも Bun でもあまり変わらないので、今までのノウハウを使えます。

FROM oven/bun:1 AS development-dependencies-env
COPY . /app
WORKDIR /app
RUN bun install

FROM oven/bun:1 AS production-dependencies-env
COPY ./package.json bun.lockb /app/
WORKDIR /app
RUN bun install --production

FROM oven/bun:1 AS build-env
COPY . /app/
COPY --from=development-dependencies-env /app/node_modules /app/node_modules
WORKDIR /app
RUN bun --bun run build

FROM oven/bun:1
COPY ./package.json bun.lockb /app/
COPY --from=production-dependencies-env /app/node_modules /app/node_modules
COPY --from=build-env /app/build /app/build
WORKDIR /app
CMD ["bun", "--bun", "run", "start"]

[CI]CloudBuild

cloudbuild.ymlを書く

GCP の CloudBuild は、ルートディレクトリにあるcloudbuild.ymlという YAML ファイルに基づいて動きます。

詳細は公式ドキュメントに預けますが、steps内に CI ジョブをシーケンシャルに記述します。

steps:
  # Build the container image
  - name: "gcr.io/cloud-builders/docker"
    args:
      ["build", "-t", "gcr.io/<プロジェクト名>/<タグ名>:$SHORT_SHA", "."]
  # Push the container image to Container Registry
  - name: "gcr.io/cloud-builders/docker"
    args: ["push", "gcr.io/<プロジェクト名>/<タグ名>:$SHORT_SHA"]
  # Deploy container image to Cloud Run
  - name: "gcr.io/google.com/cloudsdktool/cloud-sdk"
    entrypoint: gcloud
    args:
      [
        "deploy",
        "releases",
        "create",
        "<リリース名>-$SHORT_SHA",
        "--region",
        "us-central1",
        "--images",
        "<イメージ名>=gcr.io/<プロジェクト名>/<タグ名>:$SHORT_SHA",
        "--delivery-pipeline",
        "<パイプライン名>",
      ]

今回は 3 ステップで構成しました。

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

docker buildでコンテナイメージをビルドします。

Git の push で発火するので、タグ名に$SHORT_SHAで省略されたコミットのハッシュ値を付与しています。省略していないハッシュ値も取ろうと思えば取れるのですが、長すぎてイメージ名の規則に違反してしまいます(これでちょっとハマりました)。

コンテナイメージのアップロード

GCP の Artifact Registry にアップロードします。

デプロイパイプラインの作成

ここが CloudDeploy 固有の処理になります。CloudBuild のジョブから CloudDeploy のパイプラインを作成します。

デプロイパイプラインは大きなデプロイの単位です。CloudDeploy は

  1. 検証環境にリリース
  2. 問題なければプロモート実行
  3. 本番環境にリリース

という流れになっています。この一連の流れがデプロイパイプラインになります。基本的には一度作成したものを使い回すことになります。

1 つのデプロイパイプラインに対して、複数回リリースを行うことができます(コンテナイメージを更新するたびなど)。そのため、リリース名にも$SHORT_SHAを付与しています。

--imagesのフラグは、後述の CloudDeploy の設定ファイルのために指定します。コンテナイメージのエイリアスのようなもので、ここで設定したイメージ名を使って CloudDeploy が動きます。今までの step で使っていたコンテナイメージ名を指定します。

GitHub 連携をする

GCP のコンソールから GitHub 連携をしておくと、ブランチへの push などに応じて CloudBuild を動かすことができるようになります。

詳細は公式ドキュメントに書かれているので参考にしてください。

https://cloud.google.com/build/docs/automating-builds/github/connect-repo-github?hl=ja

[CD]CloudDeploy

設定ファイルを書く

初めて使う時に手間取ったところですが、CloudDeploy を使うためにはいくつかの設定ファイルを書かなければなりません。パイプラインの設計次第ではありますが、公式ガイドに書いてあって実際に筆者も採用したのは以下のような構成です。

  • clouddeploy.yaml
  • skaffold.yaml
  • run-service-dev.yaml
  • run-service-prod.yaml

clouddeploy.yaml

デプロイパイプラインの全体を定義するものです。metadata.nameには先述の CloudBuild で使った<パイプライン名>を指定します。

パイプラインは複数のステージで構成されます。先の例で言えば、検証環境にデプロイするステージと本番環境にデプロイするステージの二つがそれにあたります。

CloudDeploy では、リリース先のリソースをターゲットと呼びます。今回でいえばアプリケーションを CloudRun で動かすので、CloudRun がターゲットといえます。検証環境用と本番環境用の二つを用意するので、ターゲットも二つ必要です。これらのターゲットの定義もこの YAML に記載します。

まとめると、

  • ステージ
  • ターゲット

の二つを書くためのものになります。これらを対応させることで、「このターゲットにこのアプリをデプロイする」という定義ができます。ステージごとの詳細は別のファイルに外出ししており、profilesという形で参照します。

XXXの部分はプロジェクト名を入れています

apiVersion: deploy.cloud.google.com/v1
kind: DeliveryPipeline
metadata:
  name: <パイプライン名>
description: main application pipeline
serialPipeline:
  stages:
    - targetId: target-cloudrun-XXX-dev
      profiles: [dev]
    - targetId: target-cloudrun-XXX-prod
      profiles: [prod]
---
apiVersion: deploy.cloud.google.com/v1
kind: Target
metadata:
  name: target-cloudrun-XXX-dev
description: Cloud Run development service
run:
  location: projects/XXX/locations/us-central1
---
apiVersion: deploy.cloud.google.com/v1
kind: Target
metadata:
  name: target-cloudrun-XXX-prod
description: Cloud Run production service
run:
  location: projects/XXX/locations/us-central1

skaffold.yaml

clouddeploy.yamlで参照するprofilesを定義します。とは言っても、profileの名前と実体を書いている YAML とのマッピング程度の役割です。

apiVersion: skaffold/v4beta7
kind: Config
metadata:
  name: deploy-cloudrun-XXX
profiles:
  - name: dev
    manifests:
      rawYaml:
        - run-service-dev.yaml
  - name: prod
    manifests:
      rawYaml:
        - run-service-prod.yaml
deploy:
  cloudrun: {}

run-service-dev.yaml / run-service-prod.yaml

CloudRun の定義ファイルです。skaffold.yamlから参照されます。

中身の書き方はサービス(CloudRun や GKS など)によります。今回は CloudRun を採用しているので、CloudRun 特有の YAML ファイルになります(環境変数などはここで設定できます)。実際に利用するサービスに応じて適宜書き換えます。

特徴として、imageには CloudBuild で--imagesフラグで指定したイメージ名を用います。

apiVersion: serving.knative.dev/v1
kind: Service
metadata:
  name: deploy-run-service-dev
spec:
  template:
    spec:
      containers:
        - image: cloudrun-XXX-image
          env:
            - name: FIRESTORE_DATABASE_ID
              value: db-dev
            - name: API_BASE_URL
              value: https://YYY

プロモートする

CloudBuild と GitHub の連携を済ませた上で、トリガーを発火すると(例:mainブランチに push する)CloudBuild の CI ジョブによって CloudDeploy が動きます。このときはdev環境へのデプロイまで自動で行われますが、prod環境へは自動では行われません。実際の開発でも検証環境でテストを実施して、問題がなければ手動で上位の環境に昇格(プロモート)することが多いと思います。

CloudDeploy でプロモートするにはコンソールにアクセスして該当のデプロイパイプラインを開き、「プロモート」をクリックします。

ステージは基本的に単方向で構成され、プロモートするたびに後続の環境にデプロイされていきます。

自動でプロモートする設定もあるようですが、今回は手動(デフォルト)にしてみました。

何が嬉しいのか?

環境差分をどこで吸収するのか?という観点に立つと、同じイメージをプロモートしていく方式が便利だということに気づきます。

筆者も経験がありますが、

  • 環境ごとに.envファイルを用意する
  • 環境ごとにビルドスクリプトを組む(npm run build:dev, npm run build:prod...)
  • 環境ごとにコンテナイメージが出来上がる

といった、「ビルド時に環境差分を吸収する」構成は珍しくないと思います。

この方式のデメリットとして以下のものが挙げられます。

  • 環境ごとにビルドからやり直さなければならない
  • 検証済みのイメージとは(厳密な意味で)差分があるイメージを本番環境にデプロイすることになる
  • CI/CD パイプラインが煩雑になりがち

一方で、「ランタイムで環境差分を吸収する」構成を取ることにより、

  • イメージのビルドは一度でよい
  • 真に同じ断面を昇格できる

といったメリットが享受できます。

ピュアな SPA だとランタイムという概念が無いので難しいですが、サーバが存在するアプリケーションであれば、「ランタイムで環境差分を吸収する」メリットが大きいので、ぜひ採用すべきでしょう。

まとめ

Bun や Deno のようなまだ普及しきっていないランタイムでアプリを動かしたい場合は、やはり CloudRun のようなサーバレスでコンテナを動かせるサービスと相性がいいですね。

環境差分の吸収の観点からも CloudDeploy のような CD サービスを活用できると運用も楽になるので、どんどん使っていきたいです。

AWS や Azure にも類似サービスがあると思うので、今度は違うパブリッククラウドでやってみようかしら。

Discussion