Next.jsをCloud Runにデプロイする完全ガイド — 料金高騰対策(Cloud Armor+ロードバランサ)、CDN、GitHub Actions、Secret運用など —

本記事は、Next.js Google Cloud Run へデプロイし、Artifact Registry / Cloud Build / Secret Manager を活用した運用、さらに外部 HTTP(S) ロードバランサ+Cloud Armor によるボット遮断・レート制限、Cloud CDN によるコスト最適化、GitHub Actions による自動デプロイまでを網羅します。
※実環境の値(プロジェクトID・サービス名・ドメインなど)はすべてプレースホルダーに置き換えています。必要に応じて読み替えてください。


想定アーキテクチャ(概要)

  • Next.js(App Router)→ Cloud Run(コンテナ)
  • コンテナイメージ → Artifact Registry(Docker)
  • 手動 or CI/CD(Cloud Build / GitHub Actions)でビルド&デプロイ
  • 本番公開は External HTTP(S) Load Balancer(サーバレスNEGで Cloud Run を背後に)
    • Cloud Armor(WAF+レート制限)でボットや不正プローブを前段で遮断
    • Cloud CDN(任意)で静的アセットをキャッシュ
  • Secret(API Key / ハッシュ等)は Secret Manager で管理し Cloud Run に注入

前提

  • GCPプロジェクトがある
  • gcloud CLI がローカルに導入済み(なければ後述コマンドで導入)
  • 決めておくもの
    • PROJECT_ID(例:your-gcp-project-id
    • REGION(例:asia-northeast1
    • REPO(Artifact Registry のリポジトリ名、例:frontend
    • SERVICE(Cloud Run サービス名、例:frontend-app
    • 公開ドメイン(例:example.com / www.example.com

前準備として、GCPプロジェクト上で次のAPIを有効化しておく

  • Cloud Run
  • Artifact Registry
  • Cloud Build
  • Secret Manager

1. ローカルから手動ビルド&デプロイ

1-1. macOS で gcloud 未導入の場合

brew install --cask google-cloud-sdk
exec -l $SHELL
gcloud init

1-2. 変数定義と初期設定

PROJECT_ID="your-gcp-project-id"
REGION="asia-northeast1"
REPO="frontend"
SERVICE="frontend-app"
IMAGE="${REGION}-docker.pkg.dev/${PROJECT_ID}/${REPO}/${SERVICE}:$(git rev-parse --short HEAD)"

# 認証と設定
gcloud auth login
gcloud config set project "${PROJECT_ID}"
gcloud services enable run.googleapis.com artifactregistry.googleapis.com cloudbuild.googleapis.com secretmanager.googleapis.com

1-3. Artifact Registry 作成(初回のみ)

gcloud artifacts repositories create "${REPO}" \
  --repository-format=docker \
  --location="${REGION}" \
  --description="Frontend images" || true

gcloud auth configure-docker "${REGION}-docker.pkg.dev"

1-4. コンテナをビルド&プッシュ

Apple Silicon でも linux/amd64 を指定してビルドします(Cloud Run 推奨)。

# buildx セットアップ(初回のみ)
docker buildx create --name builder --use >/dev/null 2>&1 || docker buildx use builder

docker buildx build \
  --platform linux/amd64 \
  -f docker/frontend/Dockerfile.prod \
  -t "${IMAGE}" \
  --push \
  .

1-5. Cloud Run へデプロイ

gcloud run deploy "${SERVICE}" \
  --image "${IMAGE}" \
  --region "${REGION}" \
  --platform managed \
  --allow-unauthenticated \
  --port 8080 \
  --memory 512Mi \
  --cpu 1 \
  --concurrency 50 \
  --min-instances 0 \
  --max-instances 10 \
  --set-env-vars "NODE_ENV=production,NEXT_TELEMETRY_DISABLED=1,HOSTNAME=0.0.0.0"

メモ:上記の例はめちゃくちゃミニマムな構成です。コールドスタート回避には --min-instances 1 以上を検討。

また、それなりの規模のサービスを運用するならmax-instances、cpu、concurrencyなど適宜調整してください。


2. Secret の注入(環境変数などはこれで設定)

# 例: API_KEY を Secret Manager の latest から注入
gcloud run services update "${SERVICE}" \
  --region "${REGION}" \
  --set-secrets "API_KEY=api-key:latest"


3, カスタムドメインを Cloud Run に直接マッピング(簡易構成)

(※後述の LB+Cloud Armor 推奨構成 を使わない場合。この簡易構成は防御無しなのでMVPとかPoCとかのすぐに閉じるような物に限定した方がいい。恒久的に公開しておくサービスは、4の推奨構成を採用した方がいい)

  1. Cloud Run サービスの URL を確認
  2. Cloud Run → 対象サービス → 「カスタムドメイン」 から example.com / www.example.com を追加
  3. 指示に従って DNS(A/AAAA or CNAME)を設定
  4. 証明書(Google 管理)が自動発行→有効化後に https://example.com で公開

4. 料金高騰対策:Cloud Armor+外部 HTTP(S) LB(推奨構成)

目的:ボットや .php などの不正プローブを前段で遮断し、無駄なリクエストから Cloud Run コンテナを保護してスケール&課金増を抑える。

4-1. 構成ポイント

  • 外部 HTTP(S) LB(グローバル)
  • バックエンドは Serverless NEG(Cloud Run サービスを紐付け)
  • フロントエンドに Cloud Armor セキュリティポリシー
  • Cloud Run の Ingress を 「internal-and-cloud-load-balancing」 に制限(ロードバランサ経由のみ許可)
  • すでにCloud Run直叩きのドメインマッピングがあるなら、ロードバランサ側へ移管

4-2. Cloud Armor:推奨ルール例

  1. 事前定義 WAF ルール(OWASP / Common Threats)
  2. レート制限(IP 単位で 100 req / 60s 超過→429/一時BAN)
  3. User-Agent ベースの遮断(空UA、ツール系UA等)
  4. 地理的制限(必要に応じて)

正規表現の注意:Cloud Armor の matches()RE2キャプチャ()不可大小無視は文字クラス(例:[Pp][Hh][Pp])で表現。
シェルのクオート:zsh では --expression '…'式全体をシングルクオート)を推奨。内部の文字列・正規表現は CEL の仕様に従ってダブルクオート

ルール定義の前準備(既存 priority 確認から)

POLICY="frontend-armor"

gcloud compute security-policies create "$POLICY" --description "WAF+RateLimit for frontend" || true

# 既存の priority を確認する
gcloud compute security-policies rules list \
  --security-policy "$POLICY" \
  --format="table(priority,action,preview,match.expr.expression)"

# 以降の例では、空いている priority を使ってください
PR_UA1=1110
PR_UA2=1111
PR_EXT1=1210
PR_EXT2=1211
PR_EXT3=1212
PR_PATH1=1220
PR_PATH2=1221
PR_RATE_SUS1=2010
PR_RATE_SUS2=2011
PR_RATE_GLOBAL=3010

UA ブロック(例)

gcloud compute security-policies rules create $PR_UA1 \
  --security-policy "$POLICY" \
  --description "Block suspicious UA" \
  --expression 'request.headers["user-agent"] == "" || request.headers["user-agent"].matches(".*[Gg][Oo]-[Hh][Tt][Tt][Pp]-[Cc][Ll][Ii][Ee][Nn][Tt].*") || request.headers["user-agent"].matches(".*[Cc][Uu][Rr][Ll].*") || request.headers["user-agent"].matches(".*[Ww][Gg][Ee][Tt].*") || request.headers["user-agent"].matches(".*[Ll][Ii][Bb][Ww][Ww][Ww]-[Pp][Ee][Rr][Ll].*")' \
  --action deny-403 \
  --preview

gcloud compute security-policies rules create $PR_UA2 \
  --security-policy "$POLICY" \
  --description "Block suspicious UA (part 2)" \
  --expression 'request.headers["user-agent"].matches(".*[Pp][Yy][Tt][Hh][Oo][Nn]-[Rr][Ee][Qq][Uu][Ee][Ss][Tt][Ss].*") || request.headers["user-agent"].matches(".*[Aa][Ii][Oo][Hh][Tt][Tt][Pp].*") || request.headers["user-agent"].matches(".*[Ss][Ee][Ll][Ee][Nn][Ii][Uu][Mm].*") || request.headers["user-agent"].matches(".*[Pp][Uu][Pp][Pp][Ee][Tt][Tt][Ee][Rr].*") || request.headers["user-agent"].matches(".*[Ss][Cc][Rr][Aa][Pp][Yy].*")' \
  --action deny-403 \
  --preview

危険拡張子の遮断(例)

# .php / .phar / .phtml / .env / .ini
gcloud compute security-policies rules create $PR_EXT1 \
  --security-policy "$POLICY" \
  --description "Block probe by extensions" \
  --expression 'request.path.matches(".*\\.[Pp][Hh][Pp]$") || request.path.matches(".*\\.[Pp][Hh][Aa][Rr]$") || request.path.matches(".*\\.[Pp][Hh][Tt][Mm][Ll]$") || request.path.matches(".*\\.[Ee][Nn][Vv]$") || request.path.matches(".*\\.[Ii][Nn][Ii]$")' \
  --action deny-403 \
  --preview

# .bak / .old / .sql / .zip / .tar / .gz
gcloud compute security-policies rules create $PR_EXT2 \
  --security-policy "$POLICY" \
  --description "Block probe by extensions (part 2)" \
  --expression 'request.path.matches(".*\\.[Bb][Aa][Kk]$") || request.path.matches(".*\\.[Oo][Ll][Dd]$") || request.path.matches(".*\\.[Ss][Qq][Ll]$") || request.path.matches(".*\\.[Zz][Ii][Pp]$") || request.path.matches(".*\\.[Tt][Aa][Rr]$")' \
  --action deny-403 \
  --preview

gcloud compute security-policies rules create $PR_EXT3 \
  --security-policy "$POLICY" \
  --description "Block probe by extensions (part 3)" \
  --expression 'request.path.matches(".*\\.[Gg][Zz]$")' \
  --action deny-403 \
  --preview

# 典型的な WordPress / .git / vendor など
gcloud compute security-policies rules create $PR_PATH1 \
  --security-policy "$POLICY" \
  --description "Block common probe paths" \
  --expression 'request.path.matches("^/[Ww][Pp]-[Aa][Dd][Mm][Ii][Nn].*") || request.path.matches("^/[Ww][Pp]-[Ll][Oo][Gg][Ii][Nn].*") || request.path.matches(".*/[Xx][Mm][Ll][Rr][Pp][Cc]\\.[Pp][Hh][Pp]$") || request.path.matches(".*/\\.[Gg][Ii][Tt]/.*") || request.path.matches("^/[Vv][Ee][Nn][Dd][Oo][Rr]/.*")' \
  --action deny-403 \
  --preview

gcloud compute security-policies rules create $PR_PATH2 \
  --security-policy "$POLICY" \
  --description "Block common probe paths (part 2)" \
  --expression 'request.path.matches(".*/[Cc][Oo][Mm][Pp][Oo][Ss][Ee][Rr]\\.[Jj][Ss][Oo][Nn]$") || request.path.matches(".*/[Cc][Oo][Mm][Pp][Oo][Ss][Ee][Rr]\\.[Ll][Oo][Cc][Kk]$")' \
  --action deny-403 \
  --preview

あなたのサイトで公開しているコンテンツの拡張子は制限しないよう注意してください。
例えば、zipを公開しているサイトで、$PR_EXT2でやってるような.zipを制限するポリシーを入れちゃうと、コンテンツにアクセスした時に403エラーになっちゃいます。

レートベース BAN(例)

# 疑わしいパスを叩くIPを長めにBAN
gcloud compute security-policies rules create $PR_RATE_SUS1 \
  --security-policy "$POLICY" \
  --description "Rate ban for suspicious paths" \
  --expression 'request.path.matches(".*\\.[Pp][Hh][Pp]$") || request.path.matches(".*\\.[Pp][Hh][Aa][Rr]$") || request.path.matches(".*\\.[Pp][Hh][Tt][Mm][Ll]$") || request.path.matches(".*/\\.[Gg][Ii][Tt]/.*") || request.path.matches("^/[Ww][Pp]-[Aa][Dd][Mm][Ii][Nn].*")' \
  --action rate-based-ban \
  --rate-limit-threshold-count 10 \
  --rate-limit-threshold-interval-sec 60 \
  --ban-threshold-count 10 \
  --ban-threshold-interval-sec 60 \
  --ban-duration-sec 3600 \
  --enforce-on-key IP \
  --conform-action allow \
  --exceed-action deny-429 \
  --preview

gcloud compute security-policies rules create $PR_RATE_SUS2 \
  --security-policy "$POLICY" \
  --description "Rate ban for suspicious paths (part 2)" \
  --expression 'request.path.matches("^/[Ww][Pp]-[Ll][Oo][Gg][Ii][Nn].*") || request.path.matches(".*/[Xx][Mm][Ll][Rr][Pp][Cc]\\.[Pp][Hh][Pp]$") || request.path.matches("^/[Vv][Ee][Nn][Dd][Oo][Rr]/.*") || request.path.matches(".*/[Cc][Oo][Mm][Pp][Oo][Ss][Ee][Rr]\\.[Jj][Ss][Oo][Nn]$") || request.path.matches(".*/[Cc][Oo][Mm][Pp][Oo][Ss][Ee][Rr]\\.[Ll][Oo][Cc][Kk]$")' \
  --action rate-based-ban \
  --rate-limit-threshold-count 10 \
  --rate-limit-threshold-interval-sec 60 \
  --ban-threshold-count 10 \
  --ban-threshold-interval-sec 60 \
  --ban-duration-sec 3600 \
  --enforce-on-key IP \
  --conform-action allow \
  --exceed-action deny-429 \
  --preview

全体レート制限(保険)

gcloud compute security-policies rules create $PR_RATE_GLOBAL \
  --security-policy "$POLICY" \
  --description "Global rate limit per IP" \
  --expression "true" \
  --action rate-based-ban \
  --rate-limit-threshold-count 100 \
  --rate-limit-threshold-interval-sec 60 \
  --ban-threshold-count 100 \
  --ban-threshold-interval-sec 60 \
  --ban-duration-sec 600 \
  --enforce-on-key IP \
  --conform-action allow \
  --exceed-action deny-429 \
  --preview

適用手順:まずは --preview で 24〜72 時間観測 → 誤検知がなければ gcloud compute security-policies rules update <priority> --preview=false で Enforcement 化。

4-3. ロードバランサ(外部 HTTP(S))の構築手順

# 1) Serverless NEG(Cloud Run を後段に)
gcloud compute network-endpoint-groups create frontend-neg \
  --region="${REGION}" \
  --network-endpoint-type=serverless \
  --cloud-run-service="${SERVICE}"

# 2) バックエンドサービス作成+NEG 追加+Cloud Armor 適用
gcloud compute backend-services create frontend-backend \
  --global --load-balancing-scheme=EXTERNAL_MANAGED

gcloud compute backend-services add-backend frontend-backend \
  --global --network-endpoint-group=frontend-neg \
  --network-endpoint-group-region="${REGION}"

gcloud compute backend-services update frontend-backend \
  --global --security-policy="${POLICY}"

# 3) URL マップ / HTTPS プロキシ / 証明書(Google 管理証明書推奨)
gcloud compute url-maps create frontend-map --default-service frontend-backend

gcloud compute ssl-certificates create frontend-cert \
  --domains=example.com,www.example.com \
  --global

gcloud compute target-https-proxies create frontend-https \
  --url-map frontend-map \
  --ssl-certificates frontend-cert

gcloud compute forwarding-rules create frontend-fr-https \
  --global \
  --target-https-proxy=frontend-https \
  --ports=443 \
  --load-balancing-scheme=EXTERNAL_MANAGED

証明書が FAILED_NOT_VISIBLE の場合:DNS の A/AAAA が LB のグローバルIPを向いているか確認。CAA を厳格運用している場合は pki.goog を許可。

4-4. DNS 切替

  • example.com(apex)を LB の IPv4/IPv6 に向ける(A/AAAA)
  • www.example.com は LB のホスト名へ CNAME

4-5. Cloud Run Ingress をロードバランサ限定に

この設定によってロードバランサ経由以外からの直接アクセスは 403 に。

gcloud run services update "${SERVICE}" \
  --region "${REGION}" \
  --ingress internal-and-cloud-load-balancing

5. Cloud CDN(任意・コスト最適化)

Next.js の静的アセット(.next/staticpublic/)は通常長い Cache-Control を返します。まずはオリジンヘッダ尊重で有効化。

gcloud compute backend-services update frontend-backend \
  --global \
  --enable-cdn

差し替えを即時反映したい場合はキャッシュ無効化(パージ):

gcloud compute url-maps invalidate-cdn-cache frontend-map \
  --path "/*" \
  --async

6. ログ取り込み除外(ノイズ・コスト対策)

.php などの 404 ノイズを _Default シンクの除外 に追加。ログは保存しておくだけで料金がかかるので不要なログを取り込まない、保存期間を適切にするなどの対策が重要。

gcloud logging sinks describe _Default \
  --format="yaml(name,destination,exclusions)"

gcloud logging sinks update _Default \
  --add-exclusion=name=exclude-php-probes,description="Exclude LB 404 .php probes",filter='resource.type="http_load_balancer" AND httpRequest.status=404 AND httpRequest.requestUrl =~ ".*\.php($|\\?)"'

gcloud logging sinks describe _Default --format="yaml(exclusions)"

トラブルシュート(要点)

  • 証明書が有効化されない
    • DNS の A/AAAA が LB の IP を向いているか、伝播が完了しているか、CAA で pki.goog が許可されているか確認
  • Cloud Armor 誤検知
    • まず --preview 運用でヒットログを観測 → 誤検知パターンの除外 or ルール分割
  • Apple Silicon のビルド失敗
    • --platform linux/amd64 を付けて buildx でビルド
  • Cloud Run に直アクセスできてしまう
    • --ingress internal-and-cloud-load-balancing が適用されているか再確認

まとめ

  • 簡易構成:Cloud Run にドメインを直マッピングで公開
  • 推奨構成LB+Cloud Armor(WAF/RateLimit) で前段防御し、Cloud Run Ingress を LB 限定
  • コスト最適化Cloud CDN とログの除外・保持期間調整
  • 開発効率:Cloud Build / GitHub Actions で 自動デプロイ、Secret は Secret Manager で安全に

この手順をベースに、あなたの PROJECT_ID / REGION / SERVICE / example.com に置き換えれば、そのまま実運用レベルの構成に乗せられます。必要に応じてルールやしきい値を調整して、安全・高速・安価な運用を実現しましょう。