複数の Next.js アプリケーションをローカルで同時起動する際にポート番号を覚えきれない問題を nginx で解決する

こんにちは、株式会社アルファドライブ @takano-hi です。AlphaDrive で Web フロントエンドを中心に設計・実装などを担当しています。 今回は、弊社のローカル開発環境で localhost:xxxx といったポート番号が増え続ける問題を解決した話をまとめてみました。

背景

弊社では Next.js を利用したフロントエンドアプリケーションを複数開発しており、今後も増える見込みがあります。 通常 Next.js アプリケーションは、ローカル開発環境の起動時に localhost:3000 にホストされます。 これが複数になると、2つ目以降はそのままではポート 3000 番が占有されていることによるエラーが発生するため、別のポート番号を指定する等の方法で回避することが多いと思います。 さらに、それぞれのプロジェクトごとに Storybook を用意したりすると、必要なポートはさらに増えていきます。 弊社では下記のような状況になっていました。

サービス名 URL
サービスA http://localhost:3000
サービスB http://localhost:3001
サービスC http://localhost:3002
サービスAのStorybook http://localhost:6006
サービスBのStorybook http://localhost:6007
サービスCのStorybook http://localhost:6008
認証サービス http://localhost:18080
開発用メールサーバー http://localhost:1080

こうなってくると、どのサービスのポート番号が何番だったか覚えていられません。 そこで、前職で採用されていた仕組みを参考に、各サービスの前段に nginx を挟むことでポート番号ではなくサービス名で名前解決できるようにする仕組みを導入しました。 下記にてその手順を説明していきます。

1. docker-compose.yml にプロキシ用サービスを追加

弊社ではレポジトリが monorepo 構成で、 docker-compose を利用して開発環境を管理しているので、まずは docker-compose.yml にプロキシ用のサービスを追加していきます。

version: "3.8"
services:
  service-a:
    build: ./service-a
    command: npm run dev
  service-b:
    build: ./service-b
    command: npm run dev
  service-c:
    build: ./service-c
    command: npm run dev
  # ここから下を追加
  dev-proxy:
    build: ./dev-proxy
    ports:
      - 80:80
    depends_on:
      - service-a
      - service-b
      - service-c

※ 上記の docker-compose.yml は分かりやすさのため実際より簡略化してあります。

※ service-a, service-b, service-c それぞれのコンテナの中では、 npm run dev の中で turborepo を利用して、アプリケーション本体と Storybook が同時に起動するような構成になっています。

2. プロキシ用 nginx を設定する

次にプロキシ用 nginx の設定ファイルを書いていきます。

# dev-proxy/nginx.conf
user  nginx;
worker_processes  auto;

error_log  /var/log/nginx/error.log debug;
pid        /var/run/nginx.pid;

events {
    worker_connections  1024;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '[$time_local] $host "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    server {
        listen 80;
        server_name service-a.localhost;

        # ホスト名が http://service-a.localhost であれば service-a コンテナの3000番にプロキシする
        location / {
            proxy_pass http://service-a:3000; # docker-compose で管理するコンテナ群はサービス名をホスト名として名前解決できる
            proxy_set_header Host $host;
        }

        # Next.js 12 以降で HMR のために WebSocket 通信を利用するので、WebSocket をプロキシするための設定を加える
        location = /_next/webpack-hmr {
            proxy_pass http://service-a:3000/_next/webpack-hmr;
            include /etc/nginx/conf.d/webpack-hmr.conf;
        }
    }

    server {
        listen 80;
        server_name service-a-storybook.localhost;

        # ホスト名が http://service-a-storybook.localhost であれば service-a コンテナの 6006 番にプロキシする
        location / {
            proxy_pass http://service-a:6006;
            proxy_set_header Host $host;
        }

        # Storybook の HMR 向け WebSocket 通信のプロキシ設定
        location = /storybook-server-channel {
            proxy_pass http://frontends:6006/storybook-server-channel;
            include /etc/nginx/conf.d/webpack-hmr.conf;
        }
    }

    # 以上の server ブロックを各サービス分記述する
}

HMR のための WebSocket 通信向け設定は全て同じなので、別ファイルに切り出して使い回しています。

# dev-proxy/conf.d/webpack-hmr.conf
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "Upgrade";

ここまで書けたら、これらが nginx の挙動に反映されるように Dockerfile を書いていきます。

FROM nginx:1.25.2-bookworm

COPY ./nginx.conf /etc/nginx/nginx.conf
COPY ./conf.d/ /etc/nginx/conf.d/

EXPOSE 80

これで設定ファイルが nginx に反映されます。 試しに dev-proxy をビルドして動作を確認してみましょう。

$ docker compose build dev-proxy
$ docker compose up -d
$ open http://service-a.localhost

するとポート番号なしで、サブドメインにサービス名を足すことでアプリケーションにアクセスすることができるようになりました。

余談

今回の記事では <service-name>.localhost というホスト名でご説明しましたが、実際には <repository-name>-<service-name>.localhost のように、レポジトリ名をホスト名に含めるのがオススメです。 職場の環境・体制によっては、複数レポジトリを同時に開発することもあると思いますが、この時、レポジトリの間でサービス名が重複してしまった場合にも衝突を回避することができます。

3. ポータルページを用意する

ここまでで、ポート番号を覚えていなくてもサービス名さえ覚えていれば即座に開発環境にアクセスできる状態になったので当初の問題は解決しました。ですが、さらに踏み込んでサービス名すら覚えていなかった場合を考慮して、ポータルページを用意してみましょう。 今回は「http://localhost にアクセスすれば、各サービスの開発環境へのリンク一覧が表示される」といった仕様で考えてみます。

まずはポータルページとなる静的 HTML ファイルを用意します。

<!-- dev-proxy/services.html -->
<html lang="ja">
  <head>
    <title>local development portal</title>
    <script src="https://cdn.tailwindcss.com"></script>
  </head>
  <body>
    <div class="p-5">
      <h1 class="text-3xl font-bold mb-5">local development portal</h1>
      <ul id="service-list" class="list-disc list-inside"></ul>
    </div>
    <script>
      var menuItems = [
        { url: "http://service-a.localhost", label: "service-a" },
        { url: "http://service-a-storybook.localhost", label: "service-a-storybook" },
        { url: "http://service-b.localhost", label: "service-b" },
        { url: "http://service-b-storybook.localhost", label: "service-b-storybook" },
        { url: "http://service-c.localhost", label: "service-c" },
        { url: "http://service-c-storybook.localhost", label: "service-c-storybook" },
        { url: "http://auth.localhost", label: "auth" },
        { url: "http://maildev.localhost", label: "maildev" },
      ];

      var serviceList = document.getElementById("service-list");

      menuItems.forEach((menuItem) => {
        var listItem = document.createElement("li");
        listItem.classList.add("list-disc", "list-inside");

        var link = document.createElement("a");
        link.classList.add("text-blue-600", "hover:underline");
        link.setAttribute("href", menuItem.url);
        link.textContent = menuItem.label;

        listItem.appendChild(link);
        serviceList.appendChild(listItem);
      });
    </script>
  </body>
</html>

作成したファイルをブラウザで開くと、次のようなシンプルなリストが表示されます。

services.html

これを http://localhost で表示されるように nginx.conf と Dockerfile に追記していきます。

# dev-proxy/Dockerfile
FROM nginx:1.25.2-bookworm

WORKDIR /app

COPY ./nginx.conf /etc/nginx/nginx.conf
COPY ./conf.d/ /etc/nginx/conf.d/
# 下記を追記: service.html をコンテナ内の /etc/nginx/html/services.html に配置する
COPY ./services.html /etc/nginx/html/services.html

EXPOSE 80
# dev-proxy/nginx.conf
user  nginx;
worker_processes  auto;

error_log  /var/log/nginx/error.log debug;
pid        /var/run/nginx.pid;

events {
    worker_connections  1024;
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    log_format  main  '[$time_local] $host "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';

    access_log  /var/log/nginx/access.log  main;

    ...

    # http ブロックの末尾に下記を追記
    server {
        listen 80 default_server;
        server_name _;

        location = / {
            index services.html;
        }
    }
}

ここまで完了したら、dev-proxy をビルドし直すと挙動に反映されます。

$ docker compose down
$ docker compose build dev-proxy
$ docker compose up -d

※ ただし、このポータルページは localhost:80 を占有してしまうため、複数レポジトリで開発する際にはポート番号を変更するなどの工夫が必要です。

まとめ

プロダクトの規模が大きくなるに従ってアプリケーションの数が増え、ポート番号が増え続けてしまって覚えられないという問題の対処法について解説しました。 より発展的には、ポータルページで各サービスの health check の結果を元に起動中のサービスのみ表示するようにするなど、工夫の方向性は色々ありそうです。 この調子で開発環境を快適にして、日々の開発を爆速にしていきましょう!

Page top