フロントエンドにサーバーが必要なとき:SSRアプリケーションのCI/CDパイプライン構築
Next.jsアプリで機能を実装し終えたとします。ローカルではビルドが通り、本番にプッシュしました。しかし、ユーザーに表示されるのはスピンローダーだけの空白画面。サーバープロセスは起動したものの、実際にはリクエストを処理できる状態ではありませんでした。
この瞬間、サーバーレンダリングされるフロントエンドのデプロイが、静的サイトのデプロイとは根本的に異なることを実感します。React SPAや静的Gatsbyサイトで使っていたパイプラインは、もはや通用しません。
根本的な違い:ファイルではなくサーバーをデプロイする
静的フロントエンドの場合、ビルドはHTML、CSS、JavaScriptファイルを生成します。それらをCDNやストレージバケットにアップロードすれば完了です。デプロイは本質的にファイルコピー操作です。
サーバーサイドレンダリング(SSR)では、ビルド出力にサーバーサイドコードが含まれ、それをプロセスとして実行する必要があります。Next.js、Nuxt、Remixなどのフレームワークは、以下のものを含むフォルダを生成します。
- サーバー上で実行されるJavaScriptコード
- クライアント向けJavaScriptバンドル
- 画像やフォントなどの静的アセット
- アプリケーションを起動するエントリポイントファイル(多くの場合
server.js)
パイプラインは、この出力を継続的に実行されるアプリケーションとして扱う必要があります。単なるファイルセットではありません。これにより、ビルド、テスト、デプロイの方法がすべて変わります。
ステップ1:適切なターゲットでビルドする
ビルドステップは一見、静的フロントエンドと似ています。npm run buildまたはフレームワークの同等のコマンドを実行します。しかし、出力は異なり、その後の処理も異なります。
SSRの場合、ビルド出力はサーバー上で実行可能な形にパッケージ化する必要があります。コンテナを使用する場合、これは以下のものを含むDockerイメージを作成することを意味します。
- ビルド済みサーバーコード
- ランタイム依存関係(Node.jsバージョン、システムライブラリ)
- ランタイムに必要な設定ファイル
- エントリポイントスクリプト
Dockerfileは次のようになります。
FROM node:20-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --only=production
COPY .next ./.next
COPY public ./public
EXPOSE 3000
CMD ["node", ".next/standalone/server.js"]
重要なのは、ソースコード全体をコピーするのではなく、アプリケーションの実行に必要なものだけをコピーすることです。これによりイメージが小さく保たれ、攻撃対象領域が減少します。
ステップ2:ヘルスチェックは必須
多くのSSRパイプラインがここで失敗します。コンテナが起動し、プロセスが実行されると、誰もがアプリケーションが動作していると想定します。しかし、「プロセスが実行中」であることと「アプリケーションがリクエストを処理できる」ことは同じではありません。
アプリケーションは正常に起動しても、以下の理由でページをレンダリングできない場合があります。
- データベース接続がタイムアウトする
- 外部APIに到達できない
- 環境変数が不足している
- 必要なサービスがまだ準備できていない
アプリケーションにヘルスチェックエンドポイントを追加します。通常は/healthまたは/api/healthに配置し、アプリが実際にリクエストを処理できる場合に200ステータスを返します。パイプラインはデプロイ後、新しいバージョンにトラフィックをルーティングする前に、このエンドポイントを呼び出す必要があります。
ヘルスチェックが失敗した場合は、パイプラインを停止します。ユーザーにエラーページや無限ローディング状態を見せてはいけません。チームが調査し、問題を修正し、パイプラインを再実行します。
ステップ3:デプロイ戦略を選択する
SSRアプリケーションをデプロイするには、サーバーに直接デプロイする方法とコンテナにデプロイする方法の2つが一般的です。それぞれパイプラインに異なる影響を与えます。
次のフローチャートは、ビルドからデプロイまでの完全なSSRパイプラインと、重要なヘルスチェックの分岐点を示しています。
サーバー直接デプロイ
ビルド出力をサーバーにコピーし、古いプロセスを停止して新しいプロセスを起動します。ここでの重要な懸念は、移行の処理方法です。
古いプロセスを即座に強制終了すると、現在処理中のリクエストはすべて失敗します。ユーザーは操作の途中でエラーを目にします。解決策はグレースフルシャットダウンです。古いサーバーは新しいリクエストの受け入れを停止しますが、既に処理中のリクエストは完了させます。それらが完了したら、プロセスはクリーンに終了します。その後、新しいサーバーが起動し、トラフィックの受け入れを開始します。
パイプラインスクリプトはこの引き継ぎを調整する必要があります。実現可能ですが、注意深いスクリプト作成と監視が必要です。
コンテナデプロイ
コンテナを使用すると、より細かい制御が可能です。パイプラインは新しいDockerイメージをビルドし、レジストリにプッシュし、コンテナオーケストレーションプラットフォームにデプロイします。
以下は、ビルド済みSSRアプリケーションをコンテナデプロイ用にパッケージ化する最小限のDockerfileです。
FROM node:20-alpine
WORKDIR /app
# 本番依存関係をコピー
COPY package.json package-lock.json ./
RUN npm ci --only=production
# ビルド済みサーバーとクライアントアセットをコピー
COPY .next ./.next
COPY public ./public
# アプリケーションがリッスンするポートを公開
EXPOSE 3000
# サーバーを起動
CMD ["node", ".next/standalone/server.js"]
Kubernetesを使用する場合、これはローリングアップデートになります。
- 新しいイメージで新しいPodが起動する
- Podがヘルスチェックを実行する
- 正常と判断されると、徐々に新しいPodにトラフィックが移行する
- 古いPodは現在のリクエストを完了した後に終了する
Kubernetesはグレースフルシャットダウンとトラフィックの移行を自動的に処理します。パイプラインは新しいイメージタグでデプロイマニフェストを更新し、適用するだけで済みます。
ステップ4:実行中のバージョンを追跡する
デプロイ後、パイプラインはどのバージョンが実行されているかを記録する必要があります。コミットハッシュ、イメージタグ、またはデプロイタイムスタンプをアクセス可能な場所に保存します。この情報は以下の場合に非常に役立ちます。
- 以前のバージョンにロールバックする
- どのデプロイでバグが混入したかを調査する
- パフォーマンス問題を特定のリリースと関連付ける
簡単な方法:Dockerイメージにコミットハッシュでタグを付け、そのマッピングをデータベースまたは単純なテキストファイルに保存します。監視ツールはアラート発報時にこのデータを参照できます。
SSRパイプラインの実践的チェックリスト
パイプラインを本番準備完了と呼ぶ前に、以下の項目を確認してください。
- ビルド出力がデプロイ可能なアーティファクト(Dockerイメージまたはサーバーパッケージ)にパッケージ化されている
- ヘルスチェックエンドポイントが存在し、意味のあるステータスを返す
- パイプラインがトラフィックをルーティングする前にヘルスチェックの合格を待つ
- ヘルスチェックが失敗した場合、パイプラインが停止しアラートを発する
- グレースフルシャットダウンが設定されている(古いプロセスが処理中のリクエストを完了する)
- ローリングアップデート戦略がテストされている(デプロイ中のダウンタイムなし)
- バージョン情報がデプロイ後に保存され、アクセス可能である
- ロールバック手順が文書化され、テストされている
まとめ
SSRフロントエンドは静的サイトではありません。HTMLをレンダリングするサーバーアプリケーションです。パイプラインもそれに合わせて扱いましょう。ランタイム向けにビルドし、ヘルスチェックで検証し、ゼロダウンタイム戦略でデプロイし、どのバージョンがユーザーにサービスを提供しているかを常に把握します。これらの基本を正しく実践すれば、ユーザーが空白画面を見ることはありません。ただ高速で動作するページが表示されるだけです。