Next.jsとFirebaseで構築したアプリをCloud Runにデプロイするまでの苦悩と解決策

# はじめに

最近、Next.js 14とFirebase Authenticationを使用した認証機能付きWebアプリケーションを構築し、Google Cloud Runにデプロイするという挑戦をしてみました。
結論から言うと、想像以上に苦戦することになりましたが、様々な試行錯誤の末に無事デプロイに成功しました。

この記事では、その過程で出会った問題点と解決策を詳細に共有したいと思います。
特に、Next.jsのApp Routerと本番環境デプロイにおける落とし穴について、同じ問題で悩む方の参考になれば嬉しいです。

## 構築したアプリケーションの概要

今回構築したアプリケーションは以下の構成で作られています：

- **フロントエンド**: Next.js 14（App Router使用）
- **認証**: Firebase Authentication
- **デプロイ先**: Google Cloud Run
- **コンテナ化**: Docker

主な機能としては：
- メールアドレス・パスワードでのユーザー登録・ログイン
- Googleアカウントでのログイン
- パスワードリセット
- プロフィール情報の表示・編集

## デプロイへの挑戦と最初の問題

アプリケーションのローカル開発が完了した後、次のステップとしてGoogle Cloud Runへのデプロイを試みました。
Cloud Runを選んだ理由は、サーバーレスでスケーラブル、かつコンテナ化されたアプリケーションを簡単にデプロイできるためです。
まぁ、AzureでもAWSでも似たようなサービスはあるのですが、使ったことがないGoogle Cloudを使ってみたいという意図と、新規登録で40,000円超のクレジットが付与されることが決め手でした（笑）。

まず、Next.jsアプリケーションを最適化するために`next.config.js`を以下のように設定しました：

```js
const nextConfig = {
  output: 'standalone',
  // ...他の設定
};
```

次に、Dockerfileを作成しました：

```dockerfile
FROM node:20-alpine AS builder
WORKDIR /app

# 依存関係をインストール
COPY package.json package-lock.json ./
RUN npm ci

# ソースコードをコピー
COPY . .

# ビルド実行
RUN npm run build

# 実行ステージ
FROM node:20-alpine
WORKDIR /app

# 環境変数を設定
ENV NODE_ENV=production
ENV PORT=8080
ENV HOSTNAME=0.0.0.0

# 必要なファイルだけをコピー
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static

# ポートを公開
EXPOSE 8080

# アプリを起動
CMD ["node", "server.js"]
```

Cloud Build用の設定ファイル（`cloudbuild.yaml`）も作成しました：

```yaml
steps:
  # Dockerイメージをビルド
  - name: 'gcr.io/cloud-builders/docker'
    args: ['build', '-t', 'gcr.io/${PROJECT_ID}/nextjs-firebase-auth:latest', '.']
  
  # イメージをContainer Registryにプッシュ
  - name: 'gcr.io/cloud-builders/docker'
    args: ['push', 'gcr.io/${PROJECT_ID}/nextjs-firebase-auth:latest']
  
  # Cloud Runにデプロイ
  - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
    entrypoint: gcloud
    args:
      - 'run'
      - 'deploy'
      - 'nextjs-firebase-auth'
      - '--image=gcr.io/${PROJECT_ID}/nextjs-firebase-auth:latest'
      - '--platform=managed'
      - '--region=asia-northeast1'
      - '--allow-unauthenticated'
      - '--memory=512Mi'
```

しかし、デプロイを試みると最初の問題に直面しました：

### 問題1: publicディレクトリが見つからないエラー

Next.jsのビルド時に「public」ディレクトリが見つからないというエラーが発生しました。
これは、Dockerコンテナビルド時にディレクトリ構造の問題が生じたためでした。

**解決策**:

Dockerfileに`public`ディレクトリを明示的に作成する行を追加しました：

```dockerfile
# publicディレクトリが存在することを確認
RUN mkdir -p public
```

## 苦戦したService Unavailableエラー

最初の問題を解決した後、ビルドは成功したものの、デプロイされたアプリケーションにアクセスすると「Service Unavailable」エラーが表示されるという新たな問題が発生しました。

ログを確認すると：

```
Memory limit of 512 MiB exceeded with 542 MiB used. Consider increasing the memory limit, see https://cloud.google.com/run/docs/configuring/memory-limits
```

Next.jsアプリケーションが割り当てられたメモリ制限（512MiB）を超えてしまっていることがわかりました。

### 試行錯誤その1: マルチステージビルドの最適化

最初に試したのは、Dockerfileでのマルチステージビルドをより最適化することでした。
不要なファイルを削除し、`standalone`出力を活用して最小限のファイルだけをコンテナに含めるようにしました。

しかし、この方法でもメモリ使用量の問題は解決しませんでした。

### 試行錯誤その2: 本番ビルドの問題

次に、開発モード（`npm run dev`）ではなく本番モード（`npm run build`と`npm start`）での実行を試みました。
本番ビルドは通常、開発モードよりも効率的にリソースを使用するためです。

しかし、本番ビルド時に新たな問題が発生しました：

```
Error: Element type is invalid: expected a string (for built-in components) or a class/function (for composite components) but got: undefined.
```

これは、App Routerでの「サーバーコンポーネント」と「クライアントコンポーネント」の混在に関する問題でした。
特に：

1. `use client`ディレクティブを持つコンポーネントからmetadataをエクスポートしていた
2. サーバーサイドレンダリング時にFirebaseの初期化処理に問題があった

### 解決策: コンポーネント構造の見直しとメモリ増強

最終的に、以下の二つのアプローチを組み合わせて問題を解決しました：

1. **メモリ割り当ての増加**:
   Cloud Runの設定で、メモリを512MiBから1024MiB（1GiB）に増やしました。

   ```yaml
   - '--memory=1024Mi'
   ```

2. **アーキテクチャの修正**:
   サーバーコンポーネントとクライアントコンポーネントを明確に分離しました。
   `app/layout.tsx`から'use client'ディレクティブを削除し、代わりに新しい`ClientLayout`コンポーネントを作成してFirebaseの認証ロジックをカプセル化しました。

   ```tsx
   // app/layout.tsx
   import type { Metadata } from 'next';
   import './globals.css';
   import ClientLayout from '@/components/layout/ClientLayout';

   export const metadata: Metadata = {
     title: 'Next.js 認証アプリ',
     description: 'Next.jsとFirebaseで作成された認証アプリケーション',
   };

   export default function RootLayout({
     children,
   }: {
     children: React.ReactNode;
   }) {
     return (
       <html lang="ja">
         <body>
           <ClientLayout>
             {children}
           </ClientLayout>
         </body>
       </html>
     );
   }
   ```

   ```tsx
   // components/layout/ClientLayout.tsx
   'use client';

   import { ReactNode } from 'react';
   import { AuthProvider } from '@/contexts/AuthContext';
   import Navbar from '@/components/layout/Navbar';

   interface ClientLayoutProps {
     children: ReactNode;
   }

   const ClientLayout = ({ children }: ClientLayoutProps) => {
     return (
       <AuthProvider>
         <Navbar />
         <main className="main-content">
           {children}
         </main>
       </AuthProvider>
     );
   };

   export default ClientLayout;
   ```

3. **Firebase初期化の改善**:
   サーバーサイドとクライアントサイドの処理をより明確に分離し、エラーハンドリングを強化しました。

   ```typescript
   // lib/firebase.ts
   'use client';

   import { initializeApp, getApps, FirebaseApp } from 'firebase/app';
   import { getAuth, Auth } from 'firebase/auth';
   import { getFirestore, Firestore } from 'firebase/firestore';

   // サーバーサイドレンダリングのチェック
   const isServer = typeof window === 'undefined';

   // Firebase初期化関数
   function initializeFirebase() {
     if (isServer) {
       console.log('サーバーサイドレンダリング: ダミーFirebaseオブジェクトを使用');
       // ダミーのFirebaseオブジェクト（サーバーサイドレンダリング用）
       const dummyApp = {} as FirebaseApp;
       const dummyAuth = {
         currentUser: null,
         onAuthStateChanged: () => () => {},
       } as unknown as Auth;
       const dummyDb = {} as Firestore;

       return { app: dummyApp, auth: dummyAuth, db: dummyDb };
     }

     // クライアントサイドの処理
     // ...
   }

   const { app, auth, db } = initializeFirebase();
   export { app, auth, db };
   ```

## 最終的なデプロイ戦略

最終的に、開発モードでアプリケーションを実行し、メモリ制限を増やす戦略を採用しました。
これは理想的な解決策ではないものの、アプリケーションを確実に動作させるための実用的な解決策でした。

最終的なDockerfileはこのようになりました：

```dockerfile
# ベースイメージ
FROM node:20-alpine
WORKDIR /app

# 依存関係をインストール
COPY package.json package-lock.json ./
RUN npm ci

# ソースコードをコピー
COPY . .

# publicディレクトリが存在することを確認
RUN mkdir -p public

# ポートを公開
EXPOSE 8080

# 環境変数設定
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=8080
ENV HOSTNAME=0.0.0.0

# 開発モードで実行（メモリ使用量増加に対応）
CMD ["npm", "run", "dev", "--", "-p", "8080", "-H", "0.0.0.0"]
```

そして、Cloud Runのデプロイ設定でメモリを1GiBに増やしました：

```yaml
  # Cloud Runにデプロイ
  - name: 'gcr.io/google.com/cloudsdktool/cloud-sdk'
    entrypoint: gcloud
    args:
      # ...
      - '--memory=1024Mi'
      # ...
```

## 得られた教訓

今回のデプロイ作業から得られた主な教訓は以下の通りです：

1. **Next.jsのApp Routerには注意が必要**:
   サーバーコンポーネントとクライアントコンポーネントの区別は、特に本番環境でビルドする際に重要です。
   開発環境では見過ごされがちな問題が本番環境で表面化することがあります。

2. **Firebase初期化はクライアントサイドで**:
   Next.jsのApp Routerを使用する場合、Firebaseの初期化はサーバーコンポーネントで行わず、明示的に'use client'ディレクティブを使ったクライアントコンポーネントで行う必要があります。

3. **メモリ使用量には要注意**:
   Next.jsアプリケーション、特に開発モードで実行する場合はメモリ使用量が多くなる傾向があります。
   Cloud Runでは適切なメモリ割り当てが重要です。

4. **エラーログの重要性**:
   「Service Unavailable」のような一般的なエラーメッセージだけでなく、Cloud RunのログでRuntimeエラーやメモリ使用量などの詳細を確認することが問題解決の鍵となります。

## まとめ

Next.jsとFirebaseを使ったアプリケーションをCloud Runにデプロイする過程は、予想以上に複雑で多くの落とし穴がありました。
特に、Next.js 14のApp Routerを使用した場合、サーバーコンポーネントとクライアントコンポーネントの区別が本番環境でのビルドに大きく影響します。

今回は開発モードでの実行とメモリ増強という実用的な解決策を採用しましたが、将来的には本番ビルドでも問題なく動作するようにコードを最適化していきたいと考えています。

皆さんも同様の課題に取り組む際は、今回の経験が少しでも参考になれば幸いです。
最終的に、Next.jsアプリケーションはCloud Run上で問題なく動作するようになり、Googleログイン機能も含めて全ての機能が正常に動作しています。

## 参考リンク

- [Next.js公式ドキュメント](https://nextjs.org/docs)
- [App Routerでのサーバーコンポーネントとクライアントコンポーネント](https://nextjs.org/docs/app/building-your-application/rendering/client-components)
- [Cloud Runのデプロイガイド](https://cloud.google.com/run/docs/deploying)
- [Firebase Authentication](https://firebase.google.com/docs/auth)
- [Cloud RunでのメモリとCPUの割り当て](https://cloud.google.com/run/docs/configuring/memory-limits)