Docker Compose の ports は override ファイルに書いても上書きされない — 本番を止める直前に気づいた話
docker-compose.override.yml に ports を書けばベースを置き換える、と思っていたら連結されていた。次に再作成した瞬間に port is already allocated でサイトが起動しなくなる状態だった。最小再現で挙動を確認し、!override / !reset で直すまで。
ローカル開発用にポート番号を変えただけのつもりでした。
その変更を本番に持っていくと、次に nginx コンテナを作り直した瞬間に port is already allocated で起動しなくなる状態になっていました。サイトは止まっていません。止まる状態で本番に入る直前に気づけた、という話です。
原因は Docker Compose のマージ規則でした。ports は、override ファイルに書いてもベースの定義を置き換えません。連結されます。
以下、最小再現で挙動を確認しながら書きます。手元でも追試できます。
何をしたか
複数のプロジェクトが同じ Docker ホストに同居していて、ポートが衝突しました。そこでローカル開発用のポートを変更しました。
docker-compose.yml(ベース)のこの1行です。
# 変更前
ports:
- "80:80"
# 変更後
ports:
- "127.0.0.1:19000:80"
ローカルでは意図どおり動きました。本番用の docker-compose.prod.yml には別途 80:80 と 443:443 が書いてあるので、本番はそちらで上書きされる——と考えていました。
そこが間違いでした。
最小再現
3つのファイルを作ります。
docker-compose.yml(ベース):
services:
web:
image: nginx:alpine
command: ["nginx", "-g", "daemon off;"]
volumes:
- ./html:/usr/share/nginx/html
ports:
- "127.0.0.1:19000:80"
docker-compose.prod.yml(本番用の上書き):
services:
web:
# command と volumes は「書かない」
ports:
- "80:80"
- "443:443"
解決後の設定を見ます。コンテナは起動しません。config は読むだけです。
docker compose -f docker-compose.yml -f docker-compose.prod.yml config
結果です。
ports:
- mode: ingress
host_ip: 127.0.0.1
target: 80
published: "19000"
protocol: tcp
- mode: ingress
target: 80
published: "80"
protocol: tcp
- mode: ingress
target: 443
published: "443"
protocol: tcp
3つあります。 ベースの 127.0.0.1:19000:80 が消えていません。
本番の .env では 19000 ではなく 80 を使っていたため、実際には 80 を2回 publish しようとする解決結果になっていました。すでに動いているコンテナはそのまま動き続けますが、次に再作成した時点で必ず起動に失敗します。
デプロイのたびに up -d は打つので、次のデプロイでサイトが上がらなくなる状態でした。
キーによってマージ規則が違う
ここが引っかかりやすいところです。同じ出力の中で、command と volumes を見てください。
command:
- nginx
- -g
- daemon off;
volumes:
- type: bind
source: /tmp/compose-merge-demo/html
target: /usr/share/nginx/html
docker-compose.prod.yml には command も volumes も書いていないのに、ベースの値がそのまま残っています。
つまり Compose のマージには、少なくとも2つの異なる挙動があります。
| キー | override ファイルでの扱い | 結果 |
|---|---|---|
command / volumes など | 書かない(省略する) | ベースの値を継承する。消えない |
ports / expose など配列 | 書く | ベースと連結される。置き換わらない |
「書かなければ継承される」と「書いても連結される」は別の話ですが、実害はどちらも同じです。ベースに書いた開発用の設定が、本番の解決結果に混ざる。
前者は以前にも踏んでいて、本番の Next.js が next dev で動き続けていた原因もこれでした。docker-compose.prod.yml に command を書いていなかったので、ベースの開発用コマンドを継承していたのです。
なぜ今まで表面化しなかったのか
この構成は以前から同じでした。それでも問題が出なかった理由も、再現できます。
ポートを変える前、ベースと本番用の両方にまったく同じ 80:80 が書かれていました。その状態で解決すると、こうなります。
ports:
- mode: ingress
target: 80
published: "80"
protocol: tcp
- mode: ingress
target: 443
published: "443"
protocol: tcp
2つです。重複が排除されています。
連結は起きていたのに、連結された結果が同じ値だったため、見た目には正しく動いていました。ポート番号を変えた瞬間に、それまで隠れていた挙動が実害として出てきたわけです。
「今まで動いていた」は、正しく設定されている根拠にはなりません。
直し方:!override と !reset
Compose には、この継承・連結を明示的に打ち消すタグがあります。
services:
web:
command: !reset null # ベースの command を消す
volumes: !reset [] # ベースの volumes を消す
ports: !override # ベースの ports を置き換える
- "80:80"
- "443:443"
同じように解決してみます。
services:
web:
image: nginx:alpine
networks:
default: null
ports:
- mode: ingress
target: 80
published: "80"
protocol: tcp
- mode: ingress
target: 443
published: "443"
protocol: tcp
ports が2つになり、command と volumes は出力から消えました。意図どおりです。
!reset… そのキーをベースごと取り除く!override… ベースの値を破棄して、こちらの値で置き換える
配列を「置き換えたい」ときは !override、「無かったことにしたい」ときは !reset です。
検証に使ったのは Docker Compose v2.39.2 です。これらのタグは比較的新しい Compose でのみ使えるので、古い環境では動きません。
副産物:意図せず外部に公開されていたポート
同じ規則で、もう1つ見つかりました。
API サーバーのコンテナにも docker-compose.prod.yml 側に ports が書かれておらず、ベースの定義をそのまま継承していました。その結果、本番ですべてのインターフェイスに向けて publish されていました。
管理画面への唯一の到達経路だったので publish 自体は止めず、!override でループバック限定に直しました。
ports: !override
- "127.0.0.1:18000:8000"
外からは届かなくなり、必要なときは SSH ポートフォワードで到達できます。
ssh -L 18000:127.0.0.1:8000 user@server
ポートの意図しない公開も、同じマージ規則から生まれます。 ports を触ったら、公開範囲も一緒に確認する価値があります。
結論
- Compose のマージは、キーによって「継承」と「連結」という別の挙動をする
portsは override ファイルに書いても置き換わらない。連結される- 打ち消すには
!override(置き換え)または!reset(削除)を明示する - 同じ値が連結されている間は、重複排除されて表面化しない
そして実務上いちばん効いた対策は、これでした。
docker compose -f docker-compose.yml -f docker-compose.prod.yml config
本番へ持っていく前に、解決後の設定を読む。 YAML を書いた時点の意図ではなく、Compose が実際にどう解釈したかを見ます。今回もこれで、止まる前に気づけました。
docker-compose.yml を触ったら毎回読む、というルールにしています。数秒で終わるうえ、ローカルの .env の値では再現しないことがあるので、本番と同じ環境変数を与えて解決させるのが確実です。