prodnote.dev本番ノート

Index/02

Docker Compose の ports は override ファイルに書いても上書きされない — 本番を止める直前に気づいた話

docker-compose.override.yml に ports を書けばベースを置き換える、と思っていたら連結されていた。次に再作成した瞬間に port is already allocated でサイトが起動しなくなる状態だった。最小再現で挙動を確認し、!override / !reset で直すまで。

Published
Tags
DockerDocker Compose個人開発

ローカル開発用にポート番号を変えただけのつもりでした。

その変更を本番に持っていくと、次に 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:80443: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 は打つので、次のデプロイでサイトが上がらなくなる状態でした。

キーによってマージ規則が違う

ここが引っかかりやすいところです。同じ出力の中で、commandvolumes を見てください。

    command:
      - nginx
      - -g
      - daemon off;
    volumes:
      - type: bind
        source: /tmp/compose-merge-demo/html
        target: /usr/share/nginx/html

docker-compose.prod.yml には commandvolumes も書いていないのに、ベースの値がそのまま残っています

つまり Compose のマージには、少なくとも2つの異なる挙動があります。

キーoverride ファイルでの扱い結果
command / volumes など書かない(省略する)ベースの値を継承する。消えない
ports / expose など配列書くベースと連結される。置き換わらない

「書かなければ継承される」と「書いても連結される」は別の話ですが、実害はどちらも同じです。ベースに書いた開発用の設定が、本番の解決結果に混ざる。

前者は以前にも踏んでいて、本番の Next.js が next dev で動き続けていた原因もこれでした。docker-compose.prod.ymlcommand を書いていなかったので、ベースの開発用コマンドを継承していたのです。

なぜ今まで表面化しなかったのか

この構成は以前から同じでした。それでも問題が出なかった理由も、再現できます。

ポートを変える前、ベースと本番用の両方にまったく同じ 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つになり、commandvolumes出力から消えました。意図どおりです。

  • !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 の値では再現しないことがあるので、本番と同じ環境変数を与えて解決させるのが確実です。