React/Vue/AngularフロントエンドをNginxでDockerize:レイヤーキャッシュの最適化とクライアントサイドルーティング

Docker tutorial - IT technology blog
Docker tutorial - IT technology blog

フロントエンドのDockerize時によくある問題

初めて実際のプロジェクト(かなり大きなReactアプリ)にDockerを使ったとき、書いたDockerfileは…ソース全体をイメージにコピーして、その中でnpm run buildを実行するという書き方でした。ビルドしたイメージは1GB超え、CSSを1行修正するたびに最初からビルドし直しで、毎回10分以上かかっていました。今思えば笑える話です。

さらに、サーバーにデプロイした後、ユーザーがサブページをリロードすると即座にNginxから404 Not Foundエラーが返ってきました。Nginxは/dashboard/settingsというルートの処理方法を知らず、そのパスで静的ファイルを探してしまうのです。

イメージが重い・ビルドが遅い問題と、ルーティングの404エラーは、フロントエンドのDockerizeを始めたばかりの頃に誰もが直面する2大問題です。この記事では、両方を根本から解決する方法を紹介します。

押さえておくべきコア概念

マルチステージビルドとは?

Dockerでは1つのDockerfile内で複数のFROMを使うことができます。最初のステージではNodeイメージを使ってビルドし、次のステージではコンパクトなNginxイメージで静的ファイルを配信します。最終的なイメージにはビルド出力だけが含まれ、Nodeもnode_modulesも存在しません。その結果?以前の数百MBから、わずか20〜30MB程度になることがほとんどです。

レイヤーキャッシュの仕組み

Dockerはレイヤーごとにビルドします。あるレイヤーが前回のビルドから変わっていなければ、Dockerはキャッシュを再利用してビルドをスキップします。ポイントはDockerfileのコマンド順序です:変更頻度の低いもの(依存関係のインストール)を前に、変更頻度の高いもの(ソースコード)を後に置きます。これにより、次回以降のビルドでは変更されたレイヤー以降のみを再実行するため、2〜3分かかるnpm ciステップが完全にキャッシュされます。

クライアントサイドルーティングとNginx

現代のフロントエンドフレームワーク(React Router、Vue Router、Angular Router)はクライアント側でルーティングを管理します。アプリ全体で唯一のエントリポイントはindex.htmlファイル1つだけです。ユーザーが/aboutにアクセスするとJavaScriptがそのルートを処理しますが、Nginxはそれを知りません。Nginxはディスク上の/about/index.htmlを探しますが、見つからないので404を返します。実際のファイルに一致しないすべてのルートに対してindex.htmlにフォールバックするようNginxを設定する必要があります。

実践的な詳細手順

1. マルチステージビルドを使った最適化Dockerfile

React/Vue/Angularプロジェクトで実際に使っているテンプレートです:

# ---- ステージ 1: ビルド ----
FROM node:20-alpine AS builder

WORKDIR /app

# パッケージファイルを先にコピー — このレイヤーをキャッシュするため
# package.jsonまたはロックファイルが変更された場合のみ再実行
COPY package.json package-lock.json ./
RUN npm ci --frozen-lockfile

# ソースコードは後でコピー — このレイヤーはより頻繁に変更される
COPY . .
RUN npm run build

# ---- ステージ 2: 配信 ----
FROM nginx:1.27-alpine

# ステージ1のビルド出力をコピー
COPY --from=builder /app/dist /usr/share/nginx/html

# カスタムNginx設定をコピー
COPY nginx.conf /etc/nginx/conf.d/default.conf

EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

出力ディレクトリについての注意:

  • React (Vite):dist/
  • React (CRA):build/
  • Vue (Vite):dist/
  • Angular:dist/<project-name>/browser/

使用しているフレームワークに合わせてCOPY --from=builder /app/distのパスを修正してください。

2. .dockerignoreファイル — 見落とさないで

このファイルを忘れる人が多いです。これがないと、Dockerはビルドのたびにnode_modules(数百MBになりがち)をビルドコンテキストにコピーしてしまいます。このステップだけでビルドコンテキストが500MB超に膨れ上がり、ビルド開始前から大幅に遅くなります。

node_modules
dist
build
.git
.env
.env.local
.env.*.local
*.log
.DS_Store

3. クライアントサイドルーティングを処理するNginx設定

以下のnginx.confはほとんどのプロジェクトに対応しており、ルーティングとキャッシュの両方を処理します:

server {
    listen 80;
    server_name _;

    root /usr/share/nginx/html;
    index index.html;

    # 帯域幅を削減するためgzipを有効化
    gzip on;
    gzip_types text/plain text/css application/json application/javascript
               text/xml application/xml application/xml+rss text/javascript;
    gzip_min_length 1024;

    # ファイル名にハッシュを含む静的アセットに積極的なキャッシュを適用
    # 例: main.a3f92b1c.js — コードが変更されるとハッシュも変わる
    location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
        try_files $uri =404;
    }

    # 重要: クライアントサイドルーティングのためindex.htmlにフォールバック
    location / {
        try_files $uri $uri/ /index.html;
    }

    # index.htmlはキャッシュしない — ブラウザが常に最新バージョンを取得するため
    location = /index.html {
        add_header Cache-Control "no-cache, no-store, must-revalidate";
        add_header Pragma "no-cache";
        add_header Expires "0";
    }
}

try_files $uri $uri/ /index.html;という行が鍵です:Nginxはまず実際のファイルを探し、見つからなければindex.htmlを返します — React/Vue/Angular Routerがその後の処理を担当します。

4. Docker Composeで実行する

フロントエンドがより大きなスタック(バックエンド、データベースなど)の一部である場合、Docker Composeと組み合わせると次のようになります:

services:
  frontend:
    build:
      context: ./frontend
      dockerfile: Dockerfile
    ports:
      - "3000:80"
    environment:
      - NODE_ENV=production
    restart: unless-stopped

  backend:
    build: ./backend
    ports:
      - "8000:8000"
    depends_on:
      - db
    restart: unless-stopped

  db:
    image: postgres:16-alpine
    volumes:
      - pgdata:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: myapp
      POSTGRES_USER: user
      POSTGRES_PASSWORD: secret

volumes:
  pgdata:

5. ビルド時にフロントエンドへ環境変数を渡す

この点はバックエンドとは根本的に異なります:React/Vue/Angularの環境変数はランタイムではなく、ビルド時にコードに埋め込まれます。そのため、NginxステージではなくbuilderステージにARGとENVを渡す必要があります:

# DockerfileにARGとENVを追加
FROM node:20-alpine AS builder
WORKDIR /app

ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL

COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

ビルド時に--build-argで値を渡します:

docker build \
  --build-arg VITE_API_URL=https://api.yourdomain.com \
  -t frontend:latest .

またはdocker-compose.ymlで:

services:
  frontend:
    build:
      context: ./frontend
      args:
        VITE_API_URL: https://api.yourdomain.com

6. レイヤーキャッシュの実際の動作を確認する

正しい順序でDockerfileを書いたら、一度ビルドしてから小さなコンポーネントファイルを1つだけ修正してビルドし直してみてください。出力は次のようになります:

$ docker build -t frontend:v2 .

[1/6] FROM node:20-alpine          # キャッシュ済み
[2/6] WORKDIR /app                 # キャッシュ済み
[3/6] COPY package.json ...        # キャッシュ済み  ← package.jsonは変更なし
[4/6] RUN npm ci                   # キャッシュ済み  ← 最も時間のかかるステップ、キャッシュされた!
[5/6] COPY . .                     # 再ビルド  ← ソースコードが変更された
[6/6] RUN npm run build            # 再ビルド

npm ciステップ — 通常2〜3分かかる — はパッケージを追加・削除したときだけ再実行されます。通常のビルドは以前の10分超から30〜60秒程度に短縮されます。

まとめ

フロントエンドのDockerizeで最も重要な2つを挙げるとすれば:DockerfileのレイヤーのOrderNginxのtry_files設定です。この2点を押さえるだけで、よくある問題のほとんどを回避できます。

マルチステージビルドはイメージをコンパクトにするだけではありません — 本番コンテナにはNode.jsも元のソースコードも存在しないため、攻撃対象領域を大幅に削減できます。軽量化とセキュリティ強化が同時に実現できるのです。

同じサーバーでフロントエンドとバックエンドの両方のリバースプロキシとしてNginxを使っている場合、location /apiの設定を追加してバックエンドへのリクエストをプロキシすることもできます — ただし、それは別の記事のテーマです。

Share: