React Router on BunをCloudRunで動かす(CloudBuild + CloudDeploy)
前提
Bun で動く Web アプリ(React Router + Hono)をコンテナ化して CloudRun で動かします。
CI/CD は、GitHub と連携して CloudBuild と CloudDeploy を使います。
Web アプリ
React Router + Hono
詳細は以下の記事で説明しています。
ベースは React Router で、サーバサイドで動く API 部分を Hono で実装しています。
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 /app/node_modules /app/node_modules
WORKDIR /app
RUN bun --bun run build
FROM oven/bun:1
COPY ./package.json bun.lockb /app/
COPY /app/node_modules /app/node_modules
COPY /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 つのデプロイパイプラインに対して、複数回リリースを行うことができます(コンテナイメージを更新するたびなど)。そのため、リリース名にも$SHORT_SHA
を付与しています。
--images
のフラグは、後述の CloudDeploy の設定ファイルのために指定します。コンテナイメージのエイリアスのようなもので、ここで設定したイメージ名を使って CloudDeploy が動きます。今までの step で使っていたコンテナイメージ名を指定します。
GitHub 連携をする
GCP のコンソールから GitHub 連携をしておくと、ブランチへの push などに応じて CloudBuild を動かすことができるようになります。
詳細は公式ドキュメントに書かれているので参考にしてください。
[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