🏃‍♂️

Next.js アプリを Cloud Run へデプロイする(Secret Manager利用)

2022/02/10に公開

Next.jsアプリケーションのデプロイ先といえばVercelが筆頭ですが、他のプラットフォームでも起動できます[1]。この記事は Next.js のDocker Imageをつくり、Google Cloud の Cloud Run へデプロイしたときの記録です。なお、Next.jsはGraphQLを呼ぶServer Side Rendering(SSR)アプリを想定しています。

Terraform で Artifact Registry をつくる

Cloud Run へデプロイするにはコンテナイメージが必要です。そしてコンテナイメージを保存する場所は、Google Cloud の Artifact Registry がオススメです。以下を参考に Artifact Registry のリポジトリを作成してください

https://zenn.dev/waddy/articles/terraform-google-cloud

参考までに、こちらのリポジトリで今回用に改変したものを用意しています。

https://github.com/cm-wada-yusuke/gql-nest-prisma-training/tree/main/blog-deploy-cloud-run/infra/modules/artifact-registry

blog-deploy-cloud-run/infra/modules/artifact-registry/artifact-registry.tf
variable "gcp_project_id" {}
variable "artifact_registry_location" {
  type = string
  # https://cloud.google.com/storage/docs/locations
  description = "Artifact Registry のロケーションをどこにするか"
}

# backendアプリケーション用の Artifact Registry リポジトリ
resource "google_artifact_registry_repository" "blog-backend-training-app" {
  provider = google-beta

  project       = var.gcp_project_id
  location      = var.artifact_registry_location
  repository_id = "blog-backend-training-app"
  description   = "バックエンドアプリケーション"
  format        = "DOCKER"
}


+# frontendアプリケーション用の Artifact Registry リポジトリ
+resource "google_artifact_registry_repository" "blog-frontend-training-app" {
+  provider = google-beta
+
+  project       = var.gcp_project_id
+  location      = var.artifact_registry_location
+  repository_id = "blog-frontend-training-app"
+  description   = "フロントエンドアプリケーション"
+  format        = "DOCKER"
+}

Dockerfile をつくる

イメージビルド用のDockerfileを作ります。Next.jsをビルドするためのDockerfileは、公式がコレ!というものを公開しているわけではなさそうです(あったら教えてください)。GitHub Discussions で見かけた記述を参考にさせていただきました。

https://github.com/vercel/next.js/discussions/16995

blog-deploy-cloud-run/frontend/Dockerfile
FROM node:16 AS builder

ARG graphql_endpoint

# ビルドには devDependencies もインストールする必要があるため
ENV NODE_ENV=development

# アプリに埋め込む環境変数
ENV NEXT_PUBLIC_GRAPHQL_ENDPOINT=$graphql_endpoint

WORKDIR /app
COPY package.json ./
COPY yarn.lock ./
RUN yarn install
COPY . .
RUN yarn build


FROM node:16-stretch-slim AS runner
ENV NODE_ENV=production

WORKDIR /app
COPY package.json ./
COPY yarn.lock ./
# NODE_ENV=productionにしてyarn install(npm install)するとdevDependenciesがインストールされません
RUN yarn install
COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
CMD ["yarn", "start"]

いくつかポイントを抜粋します。

マルチステージビルド

Docker Image のビルドでは、ビルド途中はさまざまなライブラリが必要なものの、実行ファイルは少量、途中インストールしたツールがムダになってしまうというシーンがあります。今回の例でいくと package.jsondevDependencies がそれです。マルチステージビルドを使うと、ビルド時と成果物のイメージを分けることができます。FROM node:16 AS builder はビルドで、FROM node:16-stretch-slim AS runnerは実行用です。後者ではENV NODE_ENV=productionとしていることがわかります。

builder の成果物は、

COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public

このようにしてrunnerへ渡します。

next.config.js を本番ファイルへ含めることを忘れない

これを私は忘れまして、原因がわからぬエラーに悩まされました。next.config.jsNextImageのための画像ドメイン設定を入れていたのですが、このファイルを本番デプロイへ含めなかったために画像が表示されないという事象につながりました。原因がわからず、画像URLも合っているのに表示されなかったためかなり時間がかかってしまいました。本番イメージを軽くしようとしたのがアダに。チクショウ...

RUN yarn install
+ COPY --from=builder /app/next.config.js ./
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public

この部分ですね。みなさんは忘れないようにしてください。

NEXT_PUBLIC_XXX として公開する環境変数

Next.jsアプリでは、NEXT_PUBLIC_というプレフィックスを環境名につけることで、成果物のJavaScriptファイルへ埋め込んでくれます。結果、ブラウザでその環境変数が使えます。注意点としてこの変数はビルド時に必要で、起動するタイミングでセットしてもブラウザは環境変数を使えません。Docker Image とする場合も同様で、ビルドのタイミングで環境変数として設定しています。

docker buildコマンド時に注入したいため、外部から変数を受け付けられるよう、ARG graphql_endpointを宣言しています。docker buildを次のように叩きます(cloudbuild.ymlは後述します)。

ターミナル
docker build \
--file=Dockerfile \
--build-arg=graphql_endpoint=$NEXT_PUBLIC_GRAPHQL_ENDPOINT \
.

ビルドする環境にNEXT_PUBLIC_GRAPHQL_ENDPOINT変数があればそれを使ってdocker buildへ値を渡せます。

Cloud Build Trigger をつくる

トリガー自体は画面から作るでもTerraformで作るでも構いません。例としてTerraformでデプロイする場合、以下のようなモジュールを追加します。

infra/modules/cloud-sql/cloud-sql.tf
resource "google_cloudbuild_trigger" "deploy-frontend-training-app" {
  name        = "deploy-frontend-training-app"
  description = "Next.jsアプリを Cloud Run へdeployする"
  github {
    owner = var.github_owner
    name  = var.github_app_repo_name
    push {
      branch = "^main$"
    }
  }
  included_files = ["blog-deploy-cloud-run/frontend/**"]
  filename       = "blog-deploy-cloud-run/frontend/cloudbuild.yml"
  substitutions = {
    _REGION                         = var.region
    _SERVICE_ACCOUNT                = var.cloud_run_service_account
    _ARTIFACT_REPOSITORY_IMAGE_NAME = "${var.region}-docker.pkg.dev/${var.gcp_project_id}/${var.frontend_app_name}/blog-frontend"
  }
}

main.ts から次のようにしてよびます。

blog-deploy-cloud-run/infra/main.tf
locals {
  backend_app_name  = "blog-training-backend-app"
  frontend_app_name = "blog-training-frontend-app"
}

# Cloud Build
# マイグレーション+バックエンドデプロイ
# フロントエンドデプロイ
module "cloud-build" {
  source                      = "./modules/cloud-build"
  gcp_project_id              = var.gcp_project_id
  region                      = var.primary_region
  cloud_run_service_account   = module.cloud-run.blog_training_app_runner_service_account
  frontend_app_name           = local.frontend_app_name
  github_owner                = "cm-wada-yusuke"
  github_app_repo_name        = "gql-nest-prisma-training"
}

terraform apply すれば Cloud Build Trigger が作成されます。

cloudbuild.yml をつくる

frontend/cloudbuild.yml
steps:
+  - id: build-frontend
+    name: "gcr.io/cloud-builders/docker"
+    entrypoint: "bash"
+    args:
+        - -c
+        - >-
+          docker build
+          --file=Dockerfile
+          --build-arg=graphql_endpoint=$$GRAPHQL_ENDPOINT
+          --tag=$_ARTIFACT_REPOSITORY_IMAGE_NAME:$SHORT_SHA
+          --tag=$_ARTIFACT_REPOSITORY_IMAGE_NAME:latest
+          --cache-from=$_ARTIFACT_REPOSITORY_IMAGE_NAME:latest
+          .
+    secretEnv: ["GRAPHQL_ENDPOINT"]
    dir: "blog-deploy-cloud-run/frontend"
  - id: push-frontend
    name: "docker"
    args:
      - push
      - --all-tags
      - $_ARTIFACT_REPOSITORY_IMAGE_NAME
    dir: "blog-deploy-cloud-run/frontend"
    waitFor: ["build-frontend"]
  - id: deploy-frontend
    name: gcr.io/cloud-builders/gcloud
    args:
      - beta
      - run
      - deploy
      - training-frontend
      - --quiet
      - --platform=managed
      - --project=$PROJECT_ID
      - --region=$_REGION
      - --image=$_ARTIFACT_REPOSITORY_IMAGE_NAME:$SHORT_SHA
      - --service-account=$_SERVICE_ACCOUNT
      - --revision-suffix=$SHORT_SHA
      - --tag=latest
      - --concurrency=40
      - --cpu=1
      - --memory=512Mi
      - --max-instances=3
      - --min-instances=0
      - --no-use-http2
      - --allow-unauthenticated
      - --no-cpu-throttling
      - --ingress=all
+     - --update-secrets=GRAPHQL_ENDPOINT=BLOG_TRAINING_GRAPHQL_ENDPOINT:latest
    dir: "blog-deploy-cloud-run/frontend"
    waitFor: ["push-frontend"]
timeout: 2000s
substitutions:
  _REGION: by-terraform
  _ARTIFACT_REPOSITORY_IMAGE_NAME: by-terraform
  _SERVICE_ACCOUNT: by-terraform
+availableSecrets:
+  secretManager:
+    - versionName: projects/$PROJECT_ID/secrets/BLOG_TRAINING_GRAPHQL_ENDPOINT/versions/latest
+      env: GRAPHQL_ENDPOINT

# ビルド結果に生成したイメージ情報を表示する
# https://cloud.google.com/build/docs/building/build-containers
images:
  - $_ARTIFACT_REPOSITORY_IMAGE_NAME:$SHORT_SHA

こちらもポイントを抜粋します。

Secret Manager からシークレット値を呼び出し

availableSecretsを使うことで、Secret Manager の値をビルド時の環境変数として展開できます。それを使っているのがさきほど docker build で使ったこの部分です。

  - id: build-frontend
    name: "gcr.io/cloud-builders/docker"
    entrypoint: "bash"
    args:
        - -c
        - >-
          docker build
          --file=Dockerfile
+          --build-arg=graphql_endpoint=$$GRAPHQL_ENDPOINT
          --tag=$_ARTIFACT_REPOSITORY_IMAGE_NAME:$SHORT_SHA
          --tag=$_ARTIFACT_REPOSITORY_IMAGE_NAME:latest
          --cache-from=$_ARTIFACT_REPOSITORY_IMAGE_NAME:latest
          .
+    secretEnv: ["GRAPHQL_ENDPOINT"]

Secret Manager から読み出すときは、ビルドステップの bash エントリーポイントから使う必要があります。よくみるとbashコマンドの中ではgraphql_endpoint=$$GRAPHQL_ENDPOINTのようにダラーふたつ$$指定していますね。docker buildの実行環境で環境変数として展開されているため、このような使い方になります。Cloud Build と Secret Managerのからみはcatnoseさんの記事がよくまとまっているのでそちらも参照ください。

https://zenn.dev/catnose99/articles/6cb0fc434a4a62

Cloud Run の環境変数とSecret Managerをマウント

Cloud Run へのデプロイコマンド、この部分です。

--update-secrets=GRAPHQL_ENDPOINT=BLOG_TRAINING_GRAPHQL_ENDPOINT:latest

こちらは、ビルド時に埋め込むタイプではなく、Cloud Runの実行環境で展開してくれるオプションです。なので、さきほどdocker buildで使った方法とは設定方法が異なるため注意してください。

Secret Manager へ GRAPHQL_ENDPOINT 値を登録

あとは、ビルドと実行で必要なGraphQLのエンドポイントを Secret Manager へ登録します。GraphQLを使う場合、SSRアプリを想定しているのでgetServerSidePropsからGRAPHQL_ENDPOINTを使うことになりそうですが、フォーム入力なども考慮するとブラウザからもよびたいところです。というわけでcloudbuild.ymlにて NEXT_PUBLIC_GRAPHQL_ENDPOINT も設定しています。"Secret"ではなくなりますが、環境変数の設定をビルド・デプロイ時まで遅延させるサンプルとして活用いただければと思います。

Secret Manager への登録方法はいろいろありますが、一般的にSecret Managerへ登録するということは秘匿情報なので手作業が安全ではなかろうかという考えです。画面から登録します。

  • 名前: BLOG_TRAINING_GRAPHQL_ENDPOINT
  • シークレットの値: GraphQL バックエンドのURL
    • 例: https://training-backend-xxxxxxxxx-uc.a.run.app/graphql

ビルド実行

Cloud Build から Trigger を起動します。

Cloud Run にデプロイされることを確認してください。

(この記事ではバックエンドに言及していませんが)バックエンドのGraphQLとつながってデータが読み出せればOKです。

おわりに

Next.jsはデプロイ先を公式でサポートしているということもあり、Vercelのノウハウが多いです。他のプラットフォームへデプロイするにあたり、Docker Image として構築できると取り回しやすく便利かと思いためしました。また、Cloud Run へ実際にデプロイして意図どおり動作することを確認しました。どなたかの参考になれば幸いです。

参考

https://github.com/vercel/next.js/discussions/16995

ソースコード

この記事で使ったソースコードはGitHubで公開しています。

https://github.com/cm-wada-yusuke/gql-nest-prisma-training/tree/main/blog-deploy-cloud-run

脚注
  1. ISRなどのVercelプラットフォームならでは機能が一部使えない点にご注意ください。参考:Next.jsアプリをVercelからGoogle Cloudに移行した話 ↩︎

Discussion