Next.js × GoのWEBサービスをCI/CDフローでCloud Runへデプロイ
個人開発しているサービス構成が定まってきたため自分用のメモとしてまとめます。ハンズオン形式でNext.js×GoのWEBサービスをCloud RunにCI/CDフローでデプロイする方法を紹介します。
概要
- フロントエンドはNext.js、バックエンドはGo
- GitHub Actionsを用いたCI/CDフローの構築
- Terraform(IaC)
- フロントエンドはNext.js,バックエンドはGo
- Cloud Run
- マルチコンテナ構成(サイドカー)
完成形
プロジェクト作成からデプロイの手順を分かりやすくするため、アプリ自体は非常に簡単なものになります。フロントエンドから来たリクエストに対してバックエンドがメッセージを返して、それを表示するものになります。
前置き
私はITエンジニアではなく、独学で個人開発をしています。そういった方の悩みとして、ローカル環境で動作するものができたとしても、デプロイするところまで持っていくのが難しいということが少なからずあるのではないかと思っています。デプロイまで一気に進めることで、システム全体の流れを把握しやすくなると考えています。そのため、記事の構成はコードの説明などを最小限にして、手順通り進めればデプロイまでできるようにしています。
リポジトリ
リポジトリには完成版(mainブランチ)とハンズオン開始用(startブランチ)があります。ハンズオン形式で進めたい方はstartブランチをクローンしてください。
制約
この構成にするにあたって、以下の点を重視しました。
お財布に優しい(大事)
個人開発ではコストは重要です。今回はリクエスト処理時間に応じた課金体系で、アクセスが少ないうちは低コストで運用できる見込みのCloud Runを採用しました。言語はコンパイル型を選択し、コンテナのイメージサイズをできるだけ小さく抑えています。
保守・運用に手間をかけない
限られたリソースで開発するため、保守・運用コストは最小限に抑えたいです。そこで、CI/CDフローによるデプロイ自動化と、Terraformによるインフラ管理で再現性を確保しました。
利用者が多く情報が入手しやすい
独学で開発を進める必要があるので、インターネット上で情報収集しやすい言語・フレームワークを選定しました。フロントエンドはNext.js、バックエンドはGoです。
アーキテクチャ
全体像です。モノレポ構成で、Cloud Runにフロントエンドとバックエンドの2つのコンテナをマルチコンテナ(サイドカー)としてデプロイします。フロントエンドがIngress Containerとして外部に公開されます。GitHubへのプルリクエストでテストとTerraform Planを実行し、問題なければmainブランチへのマージを行い、Cloud Runが更新されます。DockerイメージはArtifact Registry、Terraformの.tfstateファイルはCloud Storageに保存します。
動作環境
ローカル環境
- PC: MacBook Pro 14inch 2021 / M1 Pro / 32GB
- OS: macOS Sequoia 15.1.1
- Docker: 4.36.0
- Terraform: v1.9.4
- GNU Make: 3.81
- VS Code: 1.96
プロジェクト
- Go: 1.23
- Node.js: 22
- Next.js: 15
Google Cloud
- Aritfact Registry
- Cloud Run
- Cloud Storage
ローカル環境
プロジェクトの作成・編集はローカルPCで行います。ビルドと実行はDocker環境を使用し、Makefileで大部分の操作が可能です。Dockerfileには開発用(devステージ)、本番用(runnerステージ)、プロジェクト新規作成用(initステージ)があります。dev/initステージはdocker compose、runnerステージはdockerコマンドで行います。
プロジェクト新規作成
startブランチからコードをクローンします。ここから作業開始になります。
環境変数設定
まずはプロジェクト内の環境変数を設定します。
-
.env_sample
ファイルを複製して.env
にリネーム - 各変数を設定。※ローカル環境で利用する情報です。
変数名 説明 例 BACKEND_PORT バックエンドポート(任意) 8081 FRONTEND_PORT フロントエンドポート(任意) 3000
Goプロジェクト作成
Goプロジェクトを新規作成する方法を示します。Hello from Go backend!というメッセージを返すプログラムを作成します。
-
go mod init
を実行
docker compose -f compose-init.yaml run --rm backend sh -c "go mod init <モジュール名>"
例
docker compose -f compose-init.yaml run --rm backend sh -c "go mod init github.com/nicky-tree55/cloud-run-nextjs-go-cicd-sample/backend"
- goのコードを作成する。main.goを作成して下記コードを貼り付け。
package main
import (
"os"
"github.com/gin-gonic/gin"
)
func main() {
r := gin.Default()
r.GET("/", func(c *gin.Context) {
c.String(200, "Hello from Go backend!")
})
port := "8081"
if envPort := os.Getenv("API_PORT"); envPort != "" {
port = envPort
}
r.Run(":" + port)
}
- go mod tidyを実行してgo.modを更新。(モジュールの追加や削除を行ったときに実行)
make go-mod-tidy
Next.jsプロジェクト作成
Next.jsのプロジェクトを作成する手順です。バックエンドから受信したメッセージを表示するコードになります。
- Next.jsプロジェクトを新規作成
docker compose -f compose-init.yaml run --rm frontend sh -c "npx create-next-app@latest . --use-npm --typescript"
途中のNext.jsプロジェクト作成で聞かれる質問はデフォルトのままにしています。
Need to install the following packages:
create-next-app@15.1.3
Ok to proceed? (y) y
✔ Would you like to use ESLint? … No / Yes
✔ Would you like to use Tailwind CSS? … No / Yes
✔ Would you like your code inside a `src/` directory? … No / Yes
✔ Would you like to use App Router? (recommended) … No / Yes
✔ Would you like to use Turbopack for `next dev`? … No / Yes
? Would you like to customize the import alias (`@/*` by default)? › No / Yes
- standaloneモードを有効にする
frontend/src/next.config.ts
ファイルにoutput: standalone
を追記
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
+ output: 'standalone',
};
export default nextConfig;
- Next.jsのプロジェクトを編集する。page.tsxを下記に更新。
export default async function Home() {
const backendUrl:string = process.env.NEXT_PUBLIC_API_URL || '';
if (backendUrl === '') {
return (
<h1>BACKEND_URL is not set. Current value: {backendUrl}</h1>
);
} else {
try {
const dynamicData = await fetch(backendUrl, { cache: 'no-store' });
if (!dynamicData.ok) {
throw new Error(`HTTP error! status: ${dynamicData.status}`);
}
const data = await dynamicData.text();
return (
<h1>{data}</h1>
);
} catch (error) {
return (
<h1>Error fetching data: {(error as Error).message}. Current value: {backendUrl}</h1>
);
}
}
}
- npm installでモジュールをインストール(make コマンド)
make npm-install
サービス起動
上記でプロジェクトが作成できたら、ローカルPC上でサービスを起動してみましょう。
1.make up
コマンドで起動
make up
-
make start
コマンドで起動する
make start
- http://localhost:<ポート番号>にアクセス ※デフォルトはhttp://localhost:3000
- 下図のように"Hello from Go backend!"と表示されたらOK
サービス停止
サービスの停止方法です。
-
make stop
コマンドで停止
make stop
ローカル環境(runner stage)
次に、本番環境と同様にDockerfileをビルドしてローカルPC上でrunner stageで動作確認する方法を示します。
実行方法
-
make prod-init
コマンドでDockerイメージのビルド&ネットワーク作成
make prod-init
-
make prod-start
コマンドで起動する。
make prod-start
Dockerfileについて
余談ですが、イメージサイズは図の通りで、フロントエンドが214MB、バックエンドが12MB程度です。比較的イメージサイズが小さいですが、マルチステージビルドでrunner stageにはdistrolessイメージを採用しています。フロントエンドとバックエンド共にrunner stageへ実行ファイルを渡して走らせています。
ARG GO_VERSION=1.23
FROM golang:${GO_VERSION} AS base
RUN apt-get update && \
apt-get install -y \
git g++ musl-dev sqlite3 libsqlite3-dev && \
rm -rf /var/lib/apt/lists/*
FROM base AS init
WORKDIR /app
FROM base AS dev
WORKDIR /app
COPY . /app
ENV CGO_ENABLED=1
ENV GIN_MODE="debug"
FROM base AS builder
ARG API_PORT
WORKDIR /app
COPY src/ ./
RUN go mod download
RUN go build -o backend .
ENV GIN_MODE="release"
RUN CGO_ENABLED=0 GOOS=linux go build -o backend .
FROM gcr.io/distroless/static-debian12 AS runner
COPY /app/backend /backend
EXPOSE ${API_PORT}
ENV GIN_MODE="release"
USER nonroot
CMD ["./backend"]
ARG IMG_VER="22.12.0-slim"
### base ####
FROM node:${IMG_VER} AS base
RUN apt-get update -qq && \
apt-get install -y --no-install-recommends \
libvips42 \
&& rm -rf /var/lib/apt/lists/*
RUN npm install -g npm@latest
RUN corepack disable
FROM base AS init
WORKDIR /app
FROM base AS dev
WORKDIR /app
FROM base AS builder
WORKDIR /app
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
COPY ./src/package.json ./src/package-lock.json ./
RUN npm ci
ENV NODE_ENV=production
COPY ./src .
RUN npm run build
FROM gcr.io/distroless/nodejs22-debian12 AS runner
WORKDIR /app
COPY /app/.next/standalone ./
COPY /app/.next/static ./.next/static
USER nonroot
CMD ["server.js"]
🚀本番環境(Cloud Run)🚀
Github Actionsを介してGoogle CloudのCloud Runへデプロイする方法を示します。
説明
ハンズオンの前に本番環境について説明をしておきます。
CI/CDフロー
CI/CDのフローチャートを示します。各イベントに応じて下記3種類の条件分岐があります。各ブランチにpushされたときにテストが実行、プルリクエスト送信時にイメージのビルドとTerraform Planの実行、mainブランチにマージした時にデプロイというフローにしています。プルリクエスト送信後にテストとビルドを実行しその結果をコメントに反映します。これにより、mainブランチにマージする(デプロイする)前に異常があれば気づくことができます。
- push:テストを実行
- pull request: Dockerイメージのビルドを実行、Artifact Registryにプッシュ、terraform planの結果をコメントに追加
- main merge: terraform applyを実行してCloud Runを更新
Google Cloudへのアクセス
Github ActionsからGoogle Cloudのサービスを操作するために、Workload Identity連携を利用しています。以下がGithub Actionsの該当コードです。
- name: 'Authenticate to Google Cloud'
id: 'auth'
uses: 'google-github-actions/auth@v2'
with:
token_format: 'access_token'
service_account: ${{ env.BUILD_ACCOUNT_EMAIL }}
workload_identity_provider: '${{ env.WORKLOAD_IDENTITY_PROVIDER_NAME }}'
Dockerイメージ管理
Google CloudのArtifact RegistryでDockerイメージを管理しています。ただし、プルリクエストを送信するたびにイメージを保管していると容量が増大します。プルリクエスト時に元からあるlatestタグが付いたイメージにbackupタグを付与し、backupタグが付いていたイメージを削除するようにしています。これにより、管理するイメージ数を2個に抑えています。これらの操作はDocker Image Handlerというstepで行っています。こちらはモジュール化しているため、実際のコードを示します。
name: "Docker Image Handler"
description: "Build, Push, and Manage Docker images at Artifact Registry"
inputs:
context:
description: "Build context for Docker image"
required: true
image_suffix:
description: "Image suffix for tagging"
required: true
build_args:
description: "Optional build arguments for Docker build"
required: false
outputs: {}
runs:
using: "composite"
steps:
# DockerイメージをビルドしてArtifact Registryにpushする
- name: Build Docker Image
uses: docker/build-push-action@v4
with:
context: ${{ inputs.context }}
platforms: linux/amd64
push: true
tags: |
${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.ARTIFACT_REPO }}/${{ inputs.image_suffix }}:${{ github.sha }}
build-args: ${{ inputs.build_args }}
# Artifact Registryにあるイメージのタグのリストを取得する
- name: Get Image Tags
shell: bash
run: |
TAGS_JSON=$(gcloud artifacts docker tags list ${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.ARTIFACT_REPO }}/${{ inputs.image_suffix }} --format="json(TAG)")
echo "${TAGS_JSON}" > /tmp/image_tags.json
TAGS_LIST=($(echo "${TAGS_JSON}" | jq -r '.[].tag | split("/") | last'))
echo "Tags: ${TAGS_LIST[@]}"
# Artifact Registryにあるbackupイメージを削除する
- name: Remove Previous Image
shell: bash
run: |
TAGS_JSON=$(cat /tmp/image_tags.json)
TAGS_LIST=($(echo "${TAGS_JSON}" | jq -r '.[].tag | split("/") | last'))
if [[ " ${TAGS_LIST[@]} " =~ "backup" ]]; then
echo "Backup tag found. Deleting..."
gcloud artifacts docker images delete ${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.ARTIFACT_REPO }}/${{ inputs.image_suffix }}:backup --delete-tags --quiet
else
echo "No backup tag found."
fi
# Artifact Registryにあるlatestイメージにbackupタグをつける
- name: Tag Docker image backup
shell: bash
run: |
TAGS_JSON=$(cat /tmp/image_tags.json)
TAGS_LIST=($(echo "${TAGS_JSON}" | jq -r '.[].tag | split("/") | last'))
if [[ " ${TAGS_LIST[@]} " =~ "latest" ]]; then
echo "Latest tag found"
echo "Tagging latest image as backup..."
gcloud artifacts docker tags add \
${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.ARTIFACT_REPO }}/${{ inputs.image_suffix }}:latest \
${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.ARTIFACT_REPO }}/${{ inputs.image_suffix }}:backup \
--quiet
else
echo "No latest tag found"
fi
# Artifact Registryにpushしたイメージにlatestタグをつける
- name: Tag Docker image latest
shell: bash
run: |
echo "Tagging pushed image as latest..."
gcloud artifacts docker tags add \
${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.ARTIFACT_REPO }}/${{ inputs.image_suffix }}:${{ github.sha }} \
${{ env.REGION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.ARTIFACT_REPO }}/${{ inputs.image_suffix }}:latest \
--quiet
サービスアカウント
Cloud Runへデプロイするにあたり2つのサービスアカウントを利用します。Cloud Runを外部公開し運用するアカウントは権限を最小にしたいため、ビルドやリソースの作成をするアカウントを別途作成し利用します。これらのアカウントはterraformで作成し管理します。
tfstate管理
terraformディレクトリ以下に3つのterraformファイルがあります。これらのtfstateの管理についてです。Cloud Run用のtfstateはリリースのたびに更新されるため、Google Cloudのバケットで管理します。プロジェクト新規作成時に1度のみ利用するGoogle Cloudのリソース作成用tfstateはローカルPC上での管理となります。
terraform | 管理先 |
---|---|
application | Google Cloud |
init | ローカルPC |
bucket | ローカルPC |
🚀デプロイ手順🚀
それでは、実際にCloud Runへデプロイしていきます。
手順1. Google Cloudのロジェクトを新規作成する。※Google Cloudのコンソールで操作
- Google Cloudのコンソールにアクセス
- 「プロジェクトを選択」をクリック
- 「新しいプロジェクト」を押す
- 任意のプロジェクトIDを入力して「作成」を押す ※下図では
cloud-run-cicd-sample
というプロジェクトIDにしています。
手順2. Google Cloudのリソース作成
ローカルPCのコマンドラインからterraformでリソースを作成してきます。
-
terraform/init/terraform.tfvars_sample
を複製してterraform/init/terraform.tfvars
にリネーム - 下表を参考に各種変数を設定する。
変数名 | 説明 |
---|---|
project_id | Google CloudのプロジェクトID |
location | サービスをデプロイするlocation |
operation_sa_id | サービス運用アカウントID |
operation_sa_display_name | サービス運用アカウント表示名 |
build_sa_id | ビルドアカウントID |
build_sa_display_name | ビルドアカウント表示名 |
artifact_registry_repository_id | Artifact RegistryのリポジトリID |
github_repo_owner | githubのリポジトリオーナ名 |
github_repo_name | githubのリポジトリ名 |
workload_identity_pool_id | Worklaod Identity Pool ID |
workload_identity_provider_id | Worklaod Identity Provider ID |
- cd コマンドで
terraform/init
に移動 - terraformコマンドを順次実行してデプロイする
terraform fmt
terraform init
terraform validate
terraform plan
terraform apply
手順3. Cloud Run用terraform.tfstate保存用バケットを作成
Cloud RunのtfstateファイルはGoogle Cloudで管理します。そのため、tfstateファイル保存用のバケットを作成します。こちらもローカルPCのコマンドラインからterraformで作成してきます。簡単に削除できないようにバケットのみファイルを分けています。サンプルでは、リージョンを料金が安いus-west1としていますが他のでも構いません。
-
terrafom/bucket/terraform.tfvars_sample
を複製してterraform/bucket/terraform.tfvars
にリネーム -
terraform.tfvars
を設定する。
| 変数名 | 説明 |
| ---- | ---- |
| project_id | Google CloudのプロジェクトID|
| bucket_location | バケットのロケーション| -
cd コマンドで
terraform/bucket
に移動 -
デプロイする
terraform fmt
terraform init
terraform validate
terraform plan
terraform apply
手順4. Githubにシークレットを設定
Github Actionsで利用する変数をリポジトリに設定します。
- ローカルPCでterraform/initにcdコマンドで移動し、
terraform output
コマンドを実行 - Githubのリポジトリにアクセスし、Setting>Secrets and Variables>Actions>New repositry secretで下表の変数を設定する。変数に格納する値は
terraform output
コマンドで確認した値を転記する。
変数名 | 説明 |
---|---|
GCP_PROJECT_ID | プロジェクトID |
GCP_REGION | リージョン(loaction) |
ARTIFACT_REPO | Artifact RegistoryのリポジトリID |
BUILD_ACCOUNT | ビルドアカウントのemail |
OPERATION_ACCOUNT | 運用アカウントのemail |
WORKLOAD_IDENTITY_PROVIDER | WORKLOAD_IDENTITY_PROVIDERのID |
手順5. Github Actionsを走らせてCloud Runにデプロイ🚀🚀🚀
Cloud Runにデプロイします。
- Githubへプッシュ
- Githubでmainブランチへのプルリクエストを出す
- Actionsが実行されてArtifact RegistoryにDockerイメージがデプロイされ、terraform planが終わるまで待機
- terraform planの結果を見てデプロイして問題ないか確認
- プルリクエストをマージ
- Actionsが実行されてCloud Runにデプロイされる
- Google CloudのコンソールからCloud Runへ移動してURLへアクセスする
1.下図のように"Hello from Go backend!"と表示されたらデプロイ完了🚀🚀🚀
Google Cloudリソース削除
手順1. Cloud Run ※Github Actionsから操作
- GithubのActionsタブにある
terraform-destroy
workflowを押してRun workflowを実行 - Actionsが実行されてCloud Runリソースが削除されたことを確認
Destroy complete! Resources: 2 destroyed.
手順2. initで作成したリソース ※ローカルPCで操作
- cdコマンドで
terraform/init
に移動 -
terraform plan --lock=false -destroy
を実行 -
terraform destroy
を実行して削除されたことを確認
Apply complete! Resources: 0 added, 0 changed, 16 destroyed.
手順3. バケット
Cloud Run用のtfstateを保存しているCloud Storageはforce_destroyフラグをfalseにしているため、terraformから削除できません。そのため、Google Cloudのコンソールから削除します。
- Google Cloudのコンソールにアクセス
- Cloud Storageのバケットタブに移動
- 対象のバケットを選択して削除
手順4. プロジェクト
- Google Cloudのコンソールにアクセス
- 右上の︙を押して「プロジェクトの設定」に移動
- シャットダウンを押す
- ダイアログにプロジェクトIDを入力して「このままシャットダウン」を押す
参考
実装にあたっては下記の記事を参考にさせて頂きました。
- TerraformとGitHub Actionsで複数のCloud RunをまとめてDevOpsした結果, 開発者体験がいい感じになった話.
- Webアプリの自動ビルドとArtifact Registryにイメージ保管
- Node.js Docker baseイメージには alpine < distroless < ubuntu+slim 構成がよさそう
- Next.js v14用マルチステージビルドDockerfile
- マルチステージビルド・standaloneモードでDockerイメージサイズを削減する方法
最後に
ここまで読んで頂きありがとうございます。Next.js×GoアプリをCloud Runへデプロイする方法を紹介しました。今回は最小限の構成でしたが、少しでも参考になればといいなと思っています。手探りで構築しているため改善などあればぜひご指摘ください!
Discussion