はじめに
1 台の開発ホストで多数の Docker Compose スタックを立ち上げっぱなしにしていて、ポート衝突に消耗している方へ。
僕は 20 前後の Docker Compose スタックを、1 台の開発ホストで同時に立ち上げっぱなしにして開発しています。それぞれが「API(PHP/Apache)+ MySQL + phpMyAdmin + Mailpit + フロントの Vite dev server」くらいの構成なので、1 スタックあたり 4〜5 個のホストポートを食う。単純計算で 80〜100 個のポートが同じホスト上でぶつからずに並ぶ必要があります。
Compose のテンプレをコピーして新しいスタックを起こすと、当然ながら全員がデフォルトポート(8080, 5173, 3306, 8025 …)を握ろうとします。2 個目を up した瞬間に Bind for 0.0.0.0:3306 failed: port is already allocated で落ちる。これを 20 スタック分、場当たりで避けていくと必ず破綻する——というのがこの記事の主題です。
やったことは単純で、「どのスタックがどのポート帯を持つか」を 1 個の正本ファイル(レジストリ)に集約し、各 Compose はそこから割り当てられた帯だけを使う、という運用に切り替えました。ここでは、その設計と、実際に起きたポート衝突・正本が 2 つに割れた失敗を振り返ります。
この記事で分かること:
- Docker Compose を多数並走させたとき、衝突するのは「ホスト側ポートだけ」で、管理対象が
ports:の左側に絞れること - スタックごとに「百の位ブロック」を1つ持たせ、割り当てを
${VAR:-デフォルト}でリポジトリに焼き込む設計 - ポート衝突・正本の二重化という2つの失敗から得た「SSOT は台帳1枚に集約する」規律
スタック数は「20 前後」。基準は、コンテナ用ディレクトリ直下に
compose.yaml/docker-compose.ymlを持つリポが 31 個、うちフィールドトライアルの複製を除くと約 27 個、恒常的に同居する主要スタックは 15 前後、という実測です(数字は執筆時点)。
何が衝突するのか
まず「ポートがぶつかる」の中身を整理します。Compose の ports: はホスト側の 0.0.0.0:PORT を掴むので、別プロジェクト・別ネットワークでも、ホストポートが同じなら衝突します。コンテナ内部のポート(80, 3306)は各スタックで同じで構わない。問題は左側(ホスト側)だけです。
services:
app:
ports:
- "8080:80" # ← このホスト側 8080 が、20 スタックで早い者勝ちになる
デフォルトのままだと衝突するのは典型的にこの辺り。
| コンテナ内 | 代表的なデフォルト | 用途 |
|---|---|---|
80 |
8080 |
API / 管理 UI |
3306 |
3306 |
MySQL(ホスト公開) |
5173 |
5173 |
Vite dev server |
8025 / 1025
|
8025 / 1025
|
Mailpit Web / SMTP |
これを 20 スタックで「空いてるポートを手で探して埋める」と、割り当ての記憶が人の頭とバラバラの .env に散る。半年後に「このポート誰が使ってたっけ」を毎回 docker ps と ss -ltnp で調べる羽目になります。
設計:スタックごとに「百の位ブロック」を 1 つ持たせる
採用したルールはこうです。
-
各スタックに一意なブロック(百の位)を 1 つ割り当てる。
81xx,82xx,83xx… のように。そのスタックの HTTP 系サービスは、自分のブロックの中でしか番号を取らない。 -
MySQL と Vite は連番の離散ポートを、ブロックと対応づけて割り当てる(例:
81xxブロックの相棒は MySQL3381、Vite5181)。末尾 2 桁を揃えておくと、番号を見た瞬間に持ち主が分かる。 -
デフォルトポート(
8080,5173,3306, …)は誰も使わない。予約表に載っていない番号も使わない。
たとえばブロック 81xx を持つスタックなら、Compose はこう書きます。実際の値はコミットしてよい(後述)。
services:
app:
ports:
- "${APP_HTTP_PORT:-8110}:80" # 81xx ブロックの先頭
phpmyadmin:
ports:
- "${APP_PHPMYADMIN_PORT:-8111}:80" # 同ブロックの次の空き
mailpit:
ports:
- "${APP_MAILPIT_WEB_PORT:-8112}:8025"
- "${APP_MAILPIT_SMTP_PORT:-1181}:1025"
db:
ports:
- "${APP_MYSQL_PORT:-3381}:3306" # 33xx の離散ポート
frontend:
ports:
- "${APP_FRONTEND_PORT:-5181}:5173" # 51xx の離散ポート
ポイントは ${APP_HTTP_PORT:-8110} のデフォルト値です。ここに正本の割り当てを書いておけば、.env を用意しなくても正しいポートで上がる。特殊な環境(CI や、たまたま帯を空けたいとき)だけ .env で上書きする。「committed default + env override」の形にすると、正しい割り当てがリポジトリに焼き込まれて、口伝にならずに済みます。
新しいサービスを 1 個足すときは、自分のブロックの中の次の空き(8113, 8114 …)を取る。ブロックの外には絶対に出ない。これだけで、あるスタックの拡張が別スタックを侵食する事故が消えます。
正本は 1 個のレジストリファイルに集約する
各 Compose の中身だけ見ても「ホスト全体で何が空いているか」は分かりません。そこで、全スタックの割り当てを 1 個の Markdown 表にして、衝突回避の唯一の参照先(single source of truth)にしました。イメージはこうです(番号は説明用の例)。
# ローカル Docker ポート — 割り当てレジストリ(binding)
各アプリは開発ホスト上で並走するので、一意なローカルポート帯を持つ。
デフォルトポート(8080/5173/3306…)や、下の他アプリの帯は使わない。
| ブロック / 離散ポート | アプリ |
| --------------------------- | -------- |
| 81xx & 3381 & 5181 | app-a |
| 82xx & 3382 & 5182 | app-b |
| 83xx & 3383 & 5183 | app-c |
| ... | ... |
| 87xx & 3387 & 5187 | app-g |
| 88xx & 3388 & 5188 | app-h |
新しい帯が要るときは、次の空きブロック(89xx …)を確保し、同じ PR でこの表に追記する。
運用の肝は次の 3 つです。
- 「使え」だけでなく「使うな」も書く。自分の帯の指定だけだと、他人の帯を踏んでいるか判断できない。**予約済み一覧(=踏むな)**を同じ表に載せることで、新規スタックが即座に空きを選べる。
- 帯の確保は必ず PR で、表への追記と同じ変更に含める。「先にポートを使い始めて、表の更新を忘れる」を構造的に防ぐ。
-
1 つのブロックに複数アプリを同居させるときは注記を残す。たとえば
87xxを主に持つアプリがいても、その中の8700–8702だけを別アプリに貸す、といった例外は必ず「なぜ衝突しないか」を添えて書く(片方はブロック上端、片方は下端、のように棲み分けを明記)。
失敗①:同じ百の位を 2 つのスタックが奪い合った
正本を作る前、実際に 1 つの百の位ブロックを、2 つのスタックが別々に掴んでいて衝突しました。片方が先にそのブロックの下端を使い始め、あとから来たもう片方が同じ近傍に MySQL の離散ポートを置いた。両方を同時に up した日に初めて表面化する——単体では緑、同居させた瞬間に赤という、いちばん気付きにくいタイプです。
教訓は「帯の境界を、口頭やコミットメッセージではなく、正本の表で引く」こと。表に載っていれば、2 人目は「この近傍は埋まっている」を最初に見ます。載っていなければ、docker ps に映るのはその日たまたま上げているスタックだけなので、空いて見えて踏む。衝突は全スタックを同時に起こさないと再現しないので、テストではなく台帳で防ぐしかありません。
(この衝突は実在の事例を一般化したものです。具体的なアプリ名・実ポートは伏せています。)
失敗②:正本が 2 箇所に割れてドリフトした
もう 1 つやったのが、正本の二重化です。運用しているうちに「もっと良い採番」を思いついてしまった。
-
旧:
local-ports.md——アプリごとにブロックを手で割り当てた、現に効いている台帳。 -
新:出生順にアプリ ID を採番し、その ID から機械的にポート帯を導出する方式(ID = n → ブロック
80xx + n、MySQL338x + n…)。ヒューマンエラーが減る、はず。
問題は、新方式のドキュメントを別ファイルとして先に書き始めたことです。移行が完了する前に「割り当ての事実」を語るファイルが 2 つになり、どちらが binding なのか分からない期間が生まれた。片方だけ更新されて、もう片方がズレる典型的な二重管理です。
落とし所は、「正本は常に 1 つ」を明文化することでした。新方式のファイル冒頭に Status: 未採用(pending adoption)。移行完了までは旧ファイルが binding と書き、旧ファイルからも相互リンクを張る。採番のアイデアと、現に効いている台帳を、同じ 1 枚に統合するまで新ファイルは「提案」に留める。ポートに限らず、SSOT はファイルを増やした瞬間に SSOT でなくなる、という当たり前を踏み抜いた失敗でした。
学び
-
ホスト側ポートだけが衝突する。コンテナ内の
80/3306は揃えてよい。管理すべきはports:の左側だけ。ここを「スタックごとに一意なブロック」に切り分ければ、20 スタックでも決定論的に並ぶ。 -
割り当てはリポジトリに焼き込む。
${VAR:-デフォルト}のデフォルトに正本の値を書けば、.envなしで正しく上がり、口伝にならない。上書きは特殊時だけ。 - 正本は 1 個の台帳に集約し、「使うな」も書く。予約済み一覧があって初めて、新規スタックが安全に空きを選べる。帯の確保は表への追記と同じ PR で。
- ポート衝突はテストで防げない——全スタック同時起動でしか再現しないから。防ぐのはコードではなく台帳の規律。
- SSOT はファイルを増やすと壊れる。より良い採番を思いついても、移行が終わるまでは旧台帳を binding に固定し、新案は「提案」に留める。事実を語るファイルは常に 1 つ。
一次資料
- 内部のポート割り当てレジストリ(ローカル開発用・非公開)と、その後継となる「アプリ ID → 派生ポート帯」採番方式のドラフト
- 各スタックの
compose.yaml(${..._PORT:-デフォルト}形式のホストポート指定)
関連記事(NeNe シリーズ)
- 運用系の連作:既存マイグレーションを「移行しない」境界宣言(.sql/.php 共存・近日公開)と同じ「境界を 1 箇所で宣言して混在を負債にしない」思想の姉妹編。
── 森 秀之(彩音インターナショナル) 🔗 ayane.co.jp