🎉

Next.jsをCloudRunでstandaloneモードで動作させようとしたら無駄にハマってしまった件

に公開

概要

タイトルの通り、Next.jsをCloudRunなどのコンテナ環境で動作させるためにGitHubActionsを用いて、CI/CDパイプラインを構築してデプロイしようとした際にハマってしまった際のお話です。

対象読者

  • Next.jsをある程度開発して大体のことは理解している方
  • CloudRunの使い方がわかる方
  • GitHubActionsの使い方がわかる方
  • Next.jsをstandaloneモードでGitHubActionsを使用してデプロイしようとしている方

事象

処理の流れは以下です。

  1. GitHubActions上でビルド
  2. ビルド成果物をコンテナにコピーしてコンテナをビルド
  3. gcloudコマンドからOIDC経由でCloudRunにデプロイ ← ❌
  4. CloudRun起動 ← ❌

ただし、静的ファイルをCloudflare R2から配信したいのでNext.jsビルド①とR2アップロード②、コンテナビルド③のジョブを分けるようにする(②、③は needs: ① とする構成)にしていました。
また、ジョブ間でNext.jsビルドの成果物を共有するためにGitHubActionsのアーティファクトにアップロード/ダウンロードして使用していた

デプロイパイプライン

デプロイパイプライン
デプロイパイプライン

原因

デプロイパイプラインの以下のstepで、include-hidden-files: trueにしないと隠しファイルである.nextが除外されていたため。
また、v3までは含まれていたがv4から挙動が変わったっぽいです。

- name: Upload standalone files as artifact
        uses: actions/upload-artifact@v4
        with:
          name: next-standalone
          path: frontend/.next/standalone
          retention-days: 1
          include-hidden-files: true # 指定しないと隠しファイルが除外されるようになっていた

解決法

actions/upload-artifact@v4を使用するstepで、include-hidden-files: trueを指定するようにする。

actions/upload-artifactのv3とv4の違い

actions/upload-artifact v3 → v4 の主な違い

以下、claudeによるまとめ

1. アーティファクト名がワークフロー内で一意である必要がある(破壊的変更)

v4ではアーティファクトの作成がimmutable(不変)になったため、同じ名前のアーティファクトに対して複数回アップロードすることができなくなりました。アップロードを分割するか、名前を変える必要があります。

v3では同じ名前で複数jobからアップロードできていたのに対し、v4では同名のアーティファクトが既に存在する場合 (409) Conflict エラーが発生します。特にmatrix buildで注意が必要です。

なお、v4では overwrite: true を指定することで上書きは可能です。


2. アップロード速度の大幅改善

アップロード速度が大幅に向上しており、最悪ケースのシナリオで90%以上の改善が見られます。


3. アーティファクトIDがすぐに取得できる

アップロード完了後すぐにアーティファクトIDが返され、UIおよびREST APIで即座に利用可能になります。v3ではワークフローの実行が完了するまでIDが利用できませんでした。


4. 隠しファイル(dotfiles)がデフォルトで除外される(v4.4以降)

v4.4以降では隠しファイルがデフォルトで除外されます。隠しファイルをアップロードしたい場合は include-hidden-files: true を指定する必要があります。


5. 1ジョブあたりのアーティファクト数に上限

ワークフロー実行内の個々のジョブごとに、アーティファクト数の上限が500に設定されました。


6. actions/upload-artifact/merge サブアクションの追加

新しいサブアクション actions/upload-artifact/merge が追加されました。これにより、複数のアーティファクトを1つにまとめることができます(v3で同名アップロードで実現していたパターンの代替手段)。


7. GitHub Enterprise Server(GHES)では非対応

upload-artifact@v4以降はGitHub Enterprise Server(GHES)では現時点でサポートされていません。GHESを使用している場合はv3(Node 16)またはv3-node20(Node 20)を使用する必要があります。


8. v3のサポート終了

2025年1月30日以降、GitHub ActionsユーザーはActions v3の upload-artifact および download-artifact を使用できなくなりました。できるだけ早くv4に移行する必要があります。


参考URL

まとめ

.nextは、.(ドット)から始まる隠しファイルなのをずっと意識してなかったので気付くのに時間がかかってしまいました。この記事を執筆した理由もおそらく次やるときに忘れて同じ失敗をしそうだなと感じたからです。

また、pnpmを使用しているときにstandaloneモードでビルドするとError: Cannot find module 'styled-jsx/style'で起動できないこともあるらしく、それを疑ったりしていましたが本当の原因は違いました。ただ、Error: Cannot find module 'styled-jsx/style'のエラーも以下のようにすれば解決できるらしく勉強にはなりました。

.npmrc
node-linker=hoisted

参考

CDパイプライン
name: Deploy to Cloud Run for Frontend

on:
  workflow_dispatch:
  push:
    tags:
      - 'v*'

env:
  SERVICE_NAME: tavinikkiy-agent-frontend
  GCP_PROJECT_ID: ${{ secrets.GCP_PROJECT_ID }}
  GCP_REGION: asia-northeast1
  GCP_REPOSITORY: frontend

jobs:
  build:
    runs-on: ubuntu-latest

    permissions:
      id-token: write
      contents: read

    steps:
      - uses: actions/checkout@v3
      - name: Set up Node.js
        uses: pnpm/action-setup@v2
        with:
          version: 10.12.1
          node-version: 20

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v1
        with:
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      - uses: actions/cache@v3
        id: node_modules_cache_id
        env:
          cache-name: cache-node-modules
        with:
          path: '**/node_modules'
          key: ${{ runner.os }}-setup-${{ env.cache-name }}-${{ hashFiles('**/pnpm-lock.yaml') }}-${{ hashFiles('frontend/.npmrc') }}

      - if: ${{ steps.node_modules_cache_id.outputs.cache-hit != 'true' }}
        name: Install dependencies
        run: |
          cd frontend
          pnpm install

      - if: ${{ steps.node_modules_cache_id.outputs.cache-hit != 'true' }}
        name: Save node_modules cache
        uses: actions/cache/save@v3
        with:
          path: '**/node_modules'
          key: ${{ steps.node_modules_cache_id.outputs.cache-primary-key }}

      - uses: actions/cache@v3
        name: Cache Next.js build
        with:
          path: |
            ${{ github.workspace }}/frontend/.next/cache
          key: ${{ runner.os }}-nextjs-${{ hashFiles('frontend/pnpm-lock.yaml') }}-${{ hashFiles('frontend/.npmrc') }}
          restore-keys: |
            ${{ runner.os }}-nextjs-

      - name: Next.js build
        run: |
          cd frontend
          pnpm run build
        env:
          NEXT_PUBLIC_FIREBASE_APIKEY: ${{ secrets.NEXT_PUBLIC_FIREBASE_APIKEY }}
          NEXT_PUBLIC_FIREBASE_DOMAIN: ${{ secrets.NEXT_PUBLIC_FIREBASE_AUTHDOMAIN }}
          NEXT_PUBLIC_FIREBASE_PROJECTID: ${{ secrets.NEXT_PUBLIC_FIREBASE_PROJECTID }}
          NEXT_PUBLIC_FIREBASE_APPID: ${{ secrets.NEXT_PUBLIC_FIREBASE_APPID }}
          NEXT_PUBLIC_FIREBASE_MEASUREMENTID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MEASUREMENTID }}
          NEXT_PUBLIC_FIREBASE_STORAGEBUCKET: ${{ secrets.NEXT_PUBLIC_FIREBASE_STORAGEBUCKET }}
          NEXT_PUBLIC_FIREBASE_MESSAGINGSENDERID: ${{ secrets.NEXT_PUBLIC_FIREBASE_MESSAGINGSENDERID }}
          NEXT_PUBLIC_API_BASE_URL: https://api-agent.tavinikkiy.com
          R2_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESSKEY }}
          R2_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRETKEY }}
          R2_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}
          R2_BUCKET_NAME: ${{ secrets.CLOUDFLARE_R2_BUCKET_NAME }}

      - name: Upload static files as artifact
        uses: actions/upload-artifact@v4
        with:
          name: next-static
          path: frontend/.next/static
          retention-days: 1
      
      - name: Upload standalone files as artifact
        uses: actions/upload-artifact@v4
        with:
          name: next-standalone
          path: frontend/.next/standalone
          retention-days: 1
          include-hidden-files: true

      - name: Upload assets file as artifact
        uses: actions/upload-artifact@v4
        with:
          name: next-assets
          path: frontend/public
          retention-days: 1

  docker-build:
    runs-on: ubuntu-latest
    needs: build

    permissions:
      id-token: write
      contents: read

    steps:
      - uses: actions/checkout@v3

      - name: Download static files artifact
        uses: actions/download-artifact@v4
        with:
          name: next-static
          path: frontend/.next/static

      - name: Download standalone files artifact
        uses: actions/download-artifact@v4
        with:
          name: next-standalone
          path: frontend/.next/standalone

      - name: Download assets file artifact
        uses: actions/download-artifact@v4
        with:
          name: next-assets
          path: frontend/public

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v1
        with:
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      - name: Configure docker for artifact registry
        run: |
          gcloud auth configure-docker asia-northeast1-docker.pkg.dev

      - name: Set up Docker Buildx
        uses: docker/setup-buildx-action@v3
        with:
          platforms: linux/amd64

      - name: Build and push Docker image
        uses: docker/build-push-action@v5
        with:
          context: ./frontend
          platforms: linux/amd64
          push: true
          target: deploy
          tags: ${{ env.IMAGE }}
          cache-from: type=gha
          cache-to: type=gha,mode=max
        env:
          IMAGE: asia-northeast1-docker.pkg.dev/${{ env.GCP_PROJECT_ID }}/${{ env.GCP_REPOSITORY }}/${{ env.SERVICE_NAME }}:${{ github.sha }}

  upload-static:
    runs-on: ubuntu-latest
    needs: build

    steps:
      - name: Download static files artifact
        uses: actions/download-artifact@v4
        with:
          name: next-static
          path: .next/static

      - name: Download assets file artifact
        uses: actions/download-artifact@v4
        with:
          name: next-assets
          path: public

      - name: Install AWS CLI
        run: |
          pip install awscli --quiet

      - name: Upload static files to R2
        run: |
          aws s3 sync .next/static s3://${{ secrets.CLOUDFLARE_R2_STATIC_ASSET_BUCKET_NAME }}/_next/static \
            --endpoint-url https://${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}.r2.cloudflarestorage.com \
            --cache-control "public, max-age=31536000, immutable" \
            --delete && \
          aws s3 sync public s3://${{ secrets.CLOUDFLARE_R2_STATIC_ASSET_BUCKET_NAME }}/ \
            --endpoint-url https://${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}.r2.cloudflarestorage.com \
            --cache-control "public, max-age=31536000, immutable" \

        env:
          AWS_ACCESS_KEY_ID: ${{ secrets.CLOUDFLARE_R2_ACCESSKEY }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.CLOUDFLARE_R2_SECRETKEY }}
          AWS_DEFAULT_REGION: auto

  deploy:
    runs-on: ubuntu-latest
    needs: docker-build

    permissions:
      id-token: write
      contents: read

    steps:
      - uses: actions/checkout@v3
      - name: Set up Node.js
        uses: pnpm/action-setup@v2
        with:
          version: 10.12.1
          node-version: 20

      - uses: google-github-actions/auth@v1
        with:
          workload_identity_provider: ${{ secrets.WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.SERVICE_ACCOUNT }}

      - name: Deploy to Cloud Run
        run: |
          gcloud run deploy ${{ env.SERVICE_NAME }} \
            --image ${{ env.IMAGE }} \
            --project ${{ env.GCP_PROJECT_ID }} \
            --region ${{ env.GCP_REGION }} \
            --platform=managed \
            --allow-unauthenticated \
            --service-account=${{ secrets.SERVICE_ACCOUNT }} \
            --quiet \
            --set-env-vars "NEXT_PUBLIC_FIREBASE_APIKEY=${{ secrets.NEXT_PUBLIC_FIREBASE_APIKEY }}" \
            --set-env-vars "NEXT_PUBLIC_FIREBASE_DOMAIN=${{ secrets.NEXT_PUBLIC_FIREBASE_AUTHDOMAIN }}" \
            --set-env-vars "NEXT_PUBLIC_FIREBASE_PROJECTID=${{ secrets.NEXT_PUBLIC_FIREBASE_PROJECTID }}" \
            --set-env-vars "NEXT_PUBLIC_FIREBASE_APPID=${{ secrets.NEXT_PUBLIC_FIREBASE_APPID }}" \
            --set-env-vars "NEXT_PUBLIC_FIREBASE_MEASUREMENTID=${{ secrets.NEXT_PUBLIC_FIREBASE_MEASUREMENTID }}" \
            --set-env-vars "NEXT_PUBLIC_FIREBASE_STORAGEBUCKET=${{ secrets.NEXT_PUBLIC_FIREBASE_STORAGEBUCKET }}" \
            --set-env-vars "NEXT_PUBLIC_FIREBASE_MESSAGINGSENDERID=${{ secrets.NEXT_PUBLIC_FIREBASE_MESSAGINGSENDERID }}" \
            --set-env-vars "NEXT_PUBLIC_API_BASE_URL=https://api-agent.tavinikkiy.com" \
            --set-env-vars "R2_ACCESS_KEY_ID=${{ secrets.CLOUDFLARE_R2_ACCESSKEY }}" \
            --set-env-vars "R2_SECRET_ACCESS_KEY=${{ secrets.CLOUDFLARE_R2_SECRETKEY }}" \
            --set-env-vars "R2_ACCOUNT_ID=${{ secrets.CLOUDFLARE_R2_ACCOUNT_ID }}" \
            --set-env-vars "R2_BUCKET_NAME=${{ secrets.CLOUDFLARE_R2_BUCKET_NAME }}"
        env:
          IMAGE: asia-northeast1-docker.pkg.dev/${{ env.GCP_PROJECT_ID }}/${{ env.GCP_REPOSITORY }}/${{ env.SERVICE_NAME }}:${{ github.sha }}
Dockerfile
# --------------------------------------------------
# Production stage
FROM node:20-alpine AS deploy
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs

COPY --chown=nextjs:nodejs .next/standalone ./
COPY --chown=nextjs:nodejs .next/static ./.next/static
COPY --chown=nextjs:nodejs public ./public

USER nextjs

EXPOSE 8080
ENV PORT=8080
ENV HOSTNAME="0.0.0.0"

CMD ["node", "server.js"]

https://github.com/vercel/next.js/issues/56900

Discussion