4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

コンテナのポートが競合する不思議を解明する【docker】

4
Posted at

はじめに

こんにちは!学生エンジニアのmoritomoです!

みなさん、複数プロジェクトを並行開発していて、「ポート競合エラー」に泣かされたことはありませんか?これまで僕は「別のコンテナを止めればいいや」と脳死で対応してきましたが、ある時ふと疑問が湧きました。

「コンテナごとにIPがあるなら、ポートが同じでも競合しないはずじゃね?」

今回は、その 「IPがあるのにポートが競合する謎」 を解き明かすべく、Dockerネットワークの仕組みを深掘りしてみました!

前提として、dockerコンテナ一つにつき、一つのプライベートIPを持っていることを頭に入れておいてください

犯人は「ホストOS」のポート占有

結論、ポート競合の原因はホストOSのポートが競合していることにより生じていました。

Docker Composeなどでよく書くこのyamlを思い出してください。

services:
  web:
    ports:
      - "8080:80"

ポートが二つ書いてあると思いますが、右側と左側で住んでいる世界が全く違うんです。

  • 右側 80 : dockerの世界での入り口
  • 左側 8080 : ホストOSの世界での入り口

ここで、以下のように二つのコンテナを立てた場合イメージしてください。

image.png

Dockerは Network Namespace という技術を使って、コンテナごとにネットワーク環境を独立させています。なので、コンテナA(172.18.0.2)とコンテナB(172.18.0.3)は 「別のIPアドレス」を持つ別個の環境 として振る舞うため、docker内で両方が80番ポートを LISTEN していても、カーネル内では完全に区別され、コンフリクトしません。
しかし、私たちが 8080:80 と書いた瞬間、Docker(厳密には docker-proxy)はホストOSに対して、 「ホストの8080番ポートを占有(bind)させてくれ」 というシステムコールを発行します。従って、TCP/IPのルール上、「同一のIPアドレス × 同一のポート番号」を同時に占有することはできないためコンフリクトが起こってしまいます。

簡潔なポート競合するまでのフロー

1. プロジェクトAを起動 : ホストの 0.0.0.0:8080 を確保。
2. プロジェクトBを起動 : 同じく 0.0.0.0:8080 を確保しようとする。
3. ホストOS : 「いや、そこはもうプロジェクトAが使ってるから無理だよ!」port is already allocated

つまり、私たちが「Dockerのポートが被った」と呼んでいる現象の正体は、コンテナ内部の問題ではなく、 「ホストPCというたった一つの共有リソースを、複数のコンテナが奪い合おうとしてOSに拒否された結果」 なのです。

ポート競合時の解決法

1. 競合しているコンテナを止める

競合しているコンテナIDを取得しstopしましょう。

$ docker stop <コンテナID>

もしくは、止めたいコンテナのcomposeが書いてあるyaml配下まで移動してコマンドを打ちましょう。

$ docker compose stop

2. ホスト側のポート番号をずらす

今回学んだ通り、競合しているのは 左側(ホスト側)のポート です。ここを重複しないように書き換えます。

ports:
      - "18080:80"   # 8080が使われていたら、18080などにずらす

3. ホスト側に仮想IPを追加する

通常、ループバックアドレスは 127.0.0.1 ですが、実は 127.0.0.2 〜 127.255.255.254 まで自由に使うことができます。ターミナルで以下のコマンドを叩くと、一時的に 127.0.0.2 を使えるようになります。

macOSの場合

$ sudo ifconfig lo0 alias 127.0.0.2 up

Windowsの場合

$ sudo ip addr add 127.0.0.2/8 dev lo

ホスト側に新しいIPが用意できたら、yamlファイルで「ホスト側のどのIPのどのポートを使うか」を指定します。

ports:
      - "127.0.0.2:8080:80"   # 追加したIPも記述する

‼️注意‼️
今までは localhost:8080 ないしは 127.0.0.1:8080 をブラウザで打ち込んで見ていましたが、今回はIPを追加し指定したため、アクセス先のURLもそのIPに合わせる必要があります。(例. 127.0.0.2:8080)

4. リバースプロキシを導入する

ホストのポートを1つのリバースプロキシコンテナのみが bindし、届いたHTTPリクエストヘッダーの「ドメイン名」を見て各コンテナへ転送する仕組みです。

これまでの「ポート番号をずらす」方法がトランスポート層(L4)での解決だったのに対し、リバースプロキシはアプリケーション層(L7)の情報を利用します。ホストOSのネットワークスタックにおいてポートを消費するのはプロキシ1体だけになるため、ホスト側でのポート衝突は発生しません。イメージ図を以下に示します。

image.png

このように、そもそもの1つのプロジェクトに対して1つのwebサーバーという概念から離れます。リバースプロキシを導入するやり方では、プロジェクトを横断するような一つのリバースプロキシサーバーがHTTPヘッダーに書いてあるドメイン名を参照して各webコンテナを割り当てます。

簡潔なフロー

1. ブラウザにドメイン名を入力

ブラウザにドメイン名(サービス名)を入力します。ホストOSは、このドメインが自分自身 127.0.0.1 であることを突き止め、PCの80番ポートにパケットを投げます。

2. リバースプロキシが受信

ホストの80番ポートを占有している「唯一のリバースプロキシコンテナ」が通信を受け取ります。この時点ではまだ「どのアプリ宛か」は確定していません。

3. L7(アプリケーション層)の解析

リバースプロキシコンテナが届いたHTTPヘッダーを解析し、Host: サービス名 という記述を見つけ出します。これを自分の設定ファイル server_name と照合し、転送先を決定します。

4. 内部ネットワーク経由の転送

リバースプロキシコンテナがDockerの内部ネットワークを通じて、対象のコンテナへパケットをパスします。アプリが処理した結果は、再びリバースプロキシコンテナを通ってブラウザへと返されます。

リバースプロキシ導入のステップ

1. 共通ネットワークの作成

次のコマンドでプロジェクトが違っても、コンテナ同士が名前で通信できるようにします。

$ docker network create proxy-net

2. リバースプロキシ側の準備(今回はNginx)

Nginx用の設定ファイルと docker-compose.yml を用意します。

Nginx用の設定ファイル nginx.conf

server {
    listen 80;
    server_name app-php.local;   # ブラウザで打つ名前

    location / {
        # 転送先は「コンテナ名」で指定する
        proxy_pass http://php-container:80;
        proxy_set_header Host $host;
    }
}

server {
    listen 80;
    server_name app-python.local;

    location / {
        proxy_pass http://python-container:80;
        proxy_set_header Host $host;
    }
}

Nginx用の docker-compose.yml

services:
  reverse-proxy:
    image: nginx:latest
    container_name: global-proxy
    volumes:
      - ./nginx.conf:/etc/nginx/conf.d/default.conf
    ports:
      - "80:80"    # ホストの80番を唯一占有している
    networks:
      - proxy-net

networks:
  proxy-net:
    external: true

3. アプリ側の設定(今回はPHPとPython)

PHP, Python二つのプロジェクトのdocker-compose.ymlを書きます。ここでportsは書きません!

PHPプロジェクトの docker-compose.yml

services:
  php-app:
    container_name: php-container   # Nginxの設定と合わせる
    image: my-php-app
    networks:
      - proxy-net

networks:
  proxy-net:
    external: true

Pythonプロジェクトの docker-compose.yml

services:
  python-app:
    container_name: python-container   # Nginxの設定と合わせる
    image: my-python-app
    networks:
      - proxy-net

networks:
  proxy-net:
    external: true

4. 設定したサービス名を 127.0.0.1 で名前解決する

設定したサービス名を 127.0.0.1 で名前解決するため(ブラウザでサービス名をそのまま打てるように)に ~/etc/hosts のファイルに対して以下の2行を書き込みます。

127.0.0.1 app-php.local
127.0.0.1 app-python.local

5. ブラウザで表示する
ブラウザで http://app-php.local またはhttp://app-python.localにアクセスするとポートが被ることなく、それぞれのアプリが表示されます!

まとめ

  • コンテナ内のポートは競合しない : Network Namespace によって隔離されているため、中ではみんな自由に同じポートを使える
  • 競合の正体はホスト側 : ホストPCのポートを奪い合おうとしてOSに怒られていた
  • 解決策はレイヤーごとに存在する(けどstopさせるので十分) : L4, L7それぞれでコンフリを防ぐ方法がある

最後まで読んでいただき、ありがとうございました!

4
3
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
4
3

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?