🚀

Next.js × GoのWEBサービスをCI/CDフローでCloud Runへデプロイ

2024/12/31に公開

個人開発しているサービス構成が定まってきたため自分用のメモとしてまとめます。ハンズオン形式で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ブランチからコードをクローンします。ここから作業開始になります。

環境変数設定

まずはプロジェクト内の環境変数を設定します。

  1. .env_sampleファイルを複製して.envにリネーム
  2. 各変数を設定。※ローカル環境で利用する情報です。
    変数名 説明
    BACKEND_PORT バックエンドポート(任意) 8081
    FRONTEND_PORT フロントエンドポート(任意) 3000

Goプロジェクト作成

Goプロジェクトを新規作成する方法を示します。Hello from Go backend!というメッセージを返すプログラムを作成します。

  1. 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"
  1. goのコードを作成する。main.goを作成して下記コードを貼り付け。
backend/src/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)
}

  1. go mod tidyを実行してgo.modを更新。(モジュールの追加や削除を行ったときに実行)
make go-mod-tidy

Next.jsプロジェクト作成

Next.jsのプロジェクトを作成する手順です。バックエンドから受信したメッセージを表示するコードになります。

  1. 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
  1. standaloneモードを有効にする
    frontend/src/next.config.tsファイルにoutput: standaloneを追記
frontend/src/next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  /* config options here */
+ output: 'standalone',
};

export default nextConfig;

  1. Next.jsのプロジェクトを編集する。page.tsxを下記に更新。
frontend/src/app/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>
      );
    }
  }
}
  1. npm installでモジュールをインストール(make コマンド)
make npm-install

サービス起動

上記でプロジェクトが作成できたら、ローカルPC上でサービスを起動してみましょう。
1.make upコマンドで起動

make up
  1. make startコマンドで起動する
make start
  1. http://localhost:<ポート番号>にアクセス ※デフォルトはhttp://localhost:3000
  2. 下図のように"Hello from Go backend!"と表示されたらOK

サービス停止

サービスの停止方法です。

  1. make stopコマンドで停止
make stop

ローカル環境(runner stage)

次に、本番環境と同様にDockerfileをビルドしてローカルPC上でrunner stageで動作確認する方法を示します。

実行方法

  1. make prod-initコマンドでDockerイメージのビルド&ネットワーク作成
make prod-init
  1. make prod-startコマンドで起動する。
make prod-start
  1. http://localhost:<ポート番号>にアクセス

Dockerfileについて

余談ですが、イメージサイズは図の通りで、フロントエンドが214MB、バックエンドが12MB程度です。比較的イメージサイズが小さいですが、マルチステージビルドでrunner stageにはdistrolessイメージを採用しています。フロントエンドとバックエンド共にrunner stageへ実行ファイルを渡して走らせています。

backend/Dockerfile
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 --from=builder --chown=nonroot:nonroot /app/backend /backend
EXPOSE ${API_PORT}
ENV GIN_MODE="release"
USER nonroot
CMD ["./backend"]
frondend/Dockerfile
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 --from=builder --chown=nonroot:nonroot /app/.next/standalone ./
COPY --from=builder --chown=nonroot:nonroot /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で行っています。こちらはモジュール化しているため、実際のコードを示します。

.github/actions/docker-image-handler/action.yml
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のコンソールで操作

  1. Google Cloudのコンソールにアクセス
  2. 「プロジェクトを選択」をクリック
  3. 「新しいプロジェクト」を押す
  4. 任意のプロジェクトIDを入力して「作成」を押す ※下図ではcloud-run-cicd-sampleというプロジェクトIDにしています。

手順2. Google Cloudのリソース作成

ローカルPCのコマンドラインからterraformでリソースを作成してきます。

  1. terraform/init/terraform.tfvars_sampleを複製してterraform/init/terraform.tfvarsにリネーム
  2. 下表を参考に各種変数を設定する。
変数名 説明
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
  1. cd コマンドでterraform/initに移動
  2. 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としていますが他のでも構いません。

  1. terrafom/bucket/terraform.tfvars_sampleを複製してterraform/bucket/terraform.tfvarsにリネーム

  2. terraform.tfvarsを設定する。
    | 変数名 | 説明 |
    | ---- | ---- |
    | project_id | Google CloudのプロジェクトID|
    | bucket_location | バケットのロケーション|

  3. cd コマンドでterraform/bucketに移動

  4. デプロイする

terraform fmt
terraform init
terraform validate
terraform plan
terraform apply

手順4. Githubにシークレットを設定

Github Actionsで利用する変数をリポジトリに設定します。

  1. ローカルPCでterraform/initにcdコマンドで移動し、terraform outputコマンドを実行
  2. 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にデプロイします。

  1. Githubへプッシュ
  2. Githubでmainブランチへのプルリクエストを出す
  3. Actionsが実行されてArtifact RegistoryにDockerイメージがデプロイされ、terraform planが終わるまで待機
  4. terraform planの結果を見てデプロイして問題ないか確認
  5. プルリクエストをマージ
  6. Actionsが実行されてCloud Runにデプロイされる
  7. Google CloudのコンソールからCloud Runへ移動してURLへアクセスする

    1.下図のように"Hello from Go backend!"と表示されたらデプロイ完了🚀🚀🚀

Google Cloudリソース削除

手順1. Cloud Run ※Github Actionsから操作

  1. GithubのActionsタブにあるterraform-destroyworkflowを押してRun workflowを実行
  2. Actionsが実行されてCloud Runリソースが削除されたことを確認
Destroy complete! Resources: 2 destroyed.

手順2. initで作成したリソース ※ローカルPCで操作

  1. cdコマンドでterraform/initに移動
  2. terraform plan --lock=false -destroyを実行
  3. terraform destroyを実行して削除されたことを確認
Apply complete! Resources: 0 added, 0 changed, 16 destroyed.

手順3. バケット

Cloud Run用のtfstateを保存しているCloud Storageはforce_destroyフラグをfalseにしているため、terraformから削除できません。そのため、Google Cloudのコンソールから削除します。

  1. Google Cloudのコンソールにアクセス
  2. Cloud Storageのバケットタブに移動
  3. 対象のバケットを選択して削除

手順4. プロジェクト

  1. Google Cloudのコンソールにアクセス
  2. 右上の︙を押して「プロジェクトの設定」に移動
  3. シャットダウンを押す
  4. ダイアログにプロジェクトIDを入力して「このままシャットダウン」を押す

参考

実装にあたっては下記の記事を参考にさせて頂きました。

最後に

ここまで読んで頂きありがとうございます。Next.js×GoアプリをCloud Runへデプロイする方法を紹介しました。今回は最小限の構成でしたが、少しでも参考になればといいなと思っています。手探りで構築しているため改善などあればぜひご指摘ください!

Discussion