Skip to content
Published on

Dockerのネットワークモードと接続の問題 — bridge、host、overlay、そして127.0.0.1の罠

シェア
Authors

はじめに — コンテナは起動したのに接続できない

docker psはUpだと言います。ログにもServer listening on port 3000が出ています。ところがブラウザで開くと接続が拒否されます。隣のコンテナからcurl http://api:3000をすると名前が見つかりません。コンテナの中でcurl http://127.0.0.1:5432としてホストのデータベースに繋ごうとしたら、何もないと言われます。

3つの症状はいずれもアプリケーションのバグではありません。コンテナが自分だけのネットワーク名前空間を持つという事実ひとつから、すべて導かれる結果です。ネットワーク名前空間はインターフェースとルーティングテーブルだけでなく、ループバックアドレスとポート番号の空間まで丸ごと複製します。コンテナの127.0.0.1は、ホストの127.0.0.1とはまったく別の住所です。

この記事はまずモードごとの動作を整理し、その上で上の3つの症状をそれぞれ解剖します。

5つのモードが実際にしていること

docker run --networkが受け取る値は5種類です。名前だけ覚えると混乱するので、それぞれがネットワーク名前空間をどう扱うかで理解するほうがよいです。

bridgeが既定値です。コンテナごとに新しい名前空間を作り、vethペアの片方をコンテナに入れ、もう片方をホストのブリッジに繋ぎます。

docker run -d --name web nginx:1.27
ip -br link show type bridge
ip -br link | grep veth
docker0          UP             02:42:8f:1c:03:aa <BROADCAST,MULTICAST,UP,LOWER_UP>
veth3a91c7e@if12 UP             ba:19:7c:2e:44:81 <BROADCAST,MULTICAST,UP,LOWER_UP>
docker exec web ip -br addr
lo               UNKNOWN        127.0.0.1/8
eth0@if13        UP             172.17.0.2/16

hostは名前空間をそもそも作らず、ホストのスタックをそのまま使います。NATがないのでオーバーヘッドは消えますが、ポートの衝突がそのまま起き、-pオプションは無視されます。Linuxでのみ意味があり、Docker Desktopでは動作が異なります。

docker run --rm --network host alpine:3.20 ip -br addr | head -3
lo               UNKNOWN        127.0.0.1/8
enp5s0           UP             10.0.4.17/24
docker0          UP             172.17.0.1/16

noneは名前空間を作りますがループバックだけを置きます。ネットワークが不要なバッチ処理や、隔離された実行環境に使います。

container:NAMEは他のコンテナの名前空間に合流します。2つのコンテナが同じインターフェースと同じループバックを共有するので、互いを127.0.0.1として呼べます。

docker run -d --name app myapp:v1
docker run -d --name sidecar --network container:app envoyproxy/envoy:v1.31-latest
docker exec sidecar ip -br addr
lo               UNKNOWN        127.0.0.1/8
eth0@if17        UP             172.17.0.4/16

KubernetesのPodがまさにこの構造です。pauseコンテナが名前空間を所有し、残りが合流するので、同じPodのコンテナ同士がループバックで通信できます。

overlayは複数のホストにまたがる仮想L2ネットワークです。VXLANでパケットをカプセル化してノード間を行き来し、Swarmか外部のキーバリューストアが必要です。

モード名前空間コンテナIP名前解決ポート公開主な用途
既定のbridge新規作成172.17.0.0/16不可必要単独のコンテナ
ユーザー定義bridge新規作成ネットワークごとのサブネット内蔵DNS必要単一ホストで複数サービス
hostホストと共有ホストのIPホストの設定無視される高性能、ポートスキャナ
none新規作成ループバックのみなし不可完全な隔離
container:NAME対象と共有対象と同じ対象と同じ対象が所有サイドカー
overlay新規作成オーバーレイのサブネット内蔵DNS + VIPルーティングメッシュマルチホスト

既定のbridgeでコンテナが互いを名前で見つけられない理由

最もよくある最初の挫折です。

docker run -d --name db postgres:16
docker run --rm alpine:3.20 ping -c1 db
ping: bad address 'db'

2つのコンテナは同じdocker0ブリッジにあり、IPでは通信できます。できないのは名前解決だけです。既定のbridgeネットワークにはDockerの内蔵DNSが付かないからです。昔は--link/etc/hostsに項目を入れて解決しましたが、この機能はずいぶん前にレガシー指定されました。

ユーザー定義ネットワークを作ると状況が変わります。

docker network create app-net
docker run -d --name db --network app-net postgres:16
docker run --rm --network app-net alpine:3.20 ping -c1 db
PING db (172.19.0.2): 56 data bytes
64 bytes from 172.19.0.2: seq=0 ttl=64 time=0.089 ms

違いはコンテナの中のリゾルバ設定にそのまま現れます。

docker run --rm --network app-net alpine:3.20 cat /etc/resolv.conf
nameserver 127.0.0.11
options ndots:0

127.0.0.11はコンテナの名前空間の中だけに存在する住所であり、その名前空間のNATルールがこの要求をDockerデーモンのリゾルバへ渡します。デーモンは同じネットワークに属するコンテナ名と別名を知っているので答えを返し、知らない名前はホストの上流DNSへ回します。

別名を複数付けることもできます。サービス名を変更する期間に旧名を維持したいときに便利です。

docker run -d --name api-v2 \
  --network app-net \
  --network-alias api \
  --network-alias backend \
  api:v312

Composeを使えばこれらはすべて自動です。Composeはプロジェクトごとにユーザー定義ネットワークを作り、サービス名を別名として登録します。だからComposeではうまくいっていたことが、docker runに移すとうまくいかなくなることがあります。

services:
  api:
    image: api:v312
    environment:
      DATABASE_URL: postgres://app@db:5432/app   # 'db' がサービス名
  db:
    image: postgres:16

結論は単純です。既定のbridgeは後方互換のために残っているものであり、実務では常にユーザー定義ネットワークを作って使います。

ポート公開の正体 — iptablesのDNATとバインドアドレス

-p 8080:80がしていることは魔法ではなく、NATルール1行です。

docker run -d --name web -p 8080:80 nginx:1.27
sudo iptables -t nat -S DOCKER
-N DOCKER
-A DOCKER -i docker0 -j RETURN
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:80

ホストの8080に入ってきたTCPパケットの宛先が172.17.0.2:80に変わります。これに加えてDockerはdocker-proxyというユーザー空間のプロセスも一緒に起動します。

ps -eo pid,args | grep docker-proxy | head -1
  52104 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 8080 -container-ip 172.17.0.2 -container-port 80

このプロセスは、ホスト自身から来るループバックのトラフィックのようにNATルールが適用されない経路を処理します。ほとんどのトラフィックはiptablesが処理し、docker-proxyは補助的な役割です。

-host-ip 0.0.0.0が目に留まるべきです。既定の公開はホストのすべてのインターフェースにバインドします。社内ネットワークであれパブリックIPであれ、全部開きます。ローカル開発用のデータベースをこうして開けたままインターネットに露出した事例は今も出続けています。

# 危険: すべてのインターフェースに露出
docker run -d -p 5432:5432 postgres:16

# 安全: ホストのループバックからのみアクセス可能
docker run -d -p 127.0.0.1:5432:5432 postgres:16
ss -tlnp | grep -E '5432'
LISTEN 0  4096  127.0.0.1:5432  0.0.0.0:*  users:(("docker-proxy",pid=52310,fd=4))

リモートの開発サーバーなら、ここにSSHフォワーディングを重ねるのが定石です。

ssh -N -L 5432:127.0.0.1:5432 dev@build-01.internal

コンテナ同士でだけ通信すればよいサービスは、そもそも公開しないのが正しいです。同じユーザー定義ネットワークにいれば名前で繋げるので-pは不要です。Composeのexposeはドキュメント用途にすぎず、ホストにポートを開きません。

コンテナの中の127.0.0.1はホストではない

初心者が最も多く踏む地点です。ローカルで開発するときにデータベースはホストで動かし、アプリケーションだけをコンテナで起動すると、慣れた接続文字列がそのまま失敗します。

docker run --rm --network app-net api:v312 \
  sh -c 'nc -zv 127.0.0.1 5432'
nc: 127.0.0.1 (127.0.0.1:5432): Connection refused

コンテナのループバックはコンテナ自身です。ホストに繋ぐには、ホストを指す別の住所が必要です。

Docker Desktopなら特別な名前がすでに用意されています。

docker run --rm alpine:3.20 \
  sh -c 'apk add -q bind-tools && dig +short host.docker.internal'
192.168.65.254

Linuxでは自動で提供されないので、明示的に追加します。

docker run --rm --add-host=host.docker.internal:host-gateway \
  alpine:3.20 getent hosts host.docker.internal
172.17.0.1        host.docker.internal

host-gatewayはDockerがブリッジのゲートウェイアドレスに置き換えてくれる予約語です。Composeでも同じように使います。

services:
  api:
    image: api:v312
    extra_hosts:
      - "host.docker.internal:host-gateway"
    environment:
      DATABASE_URL: postgres://app@host.docker.internal:5432/app

もう1つ確認すべきことがあります。ホストのサービスが127.0.0.1だけにバインドされていると、ブリッジのゲートウェイアドレスからはアクセスできません。PostgreSQLならlisten_addressesを調整するか、コンテナのサブネットだけを許可するようにpg_hba.confを手直しする必要があります。ホストのサービスを0.0.0.0で開いた瞬間、ファイアウォールの確認も併せて必要になります。

アプリケーションがループバックだけにバインドしているとき

2番目によくある症状です。コンテナは起動していてポートも公開したのに、接続が拒否されます。

docker run -d --name api -p 3000:3000 api:v312
curl -sS -m 3 http://localhost:3000/healthz
curl: (52) Empty reply from server

コンテナ内部で実際のリスニングアドレスを確認します。

docker exec api sh -c 'ss -tlnp'
State   Recv-Q  Send-Q   Local Address:Port   Peer Address:Port  Process
LISTEN  0       511          127.0.0.1:3000        0.0.0.0:*      users:(("node",pid=1,fd=20))

アプリケーションがコンテナのループバックだけにバインドしました。ホストから入ってきたパケットはeth0、すなわち172.17.0.2に到着するので、このソケットには届きません。正しい値は0.0.0.0です。

LISTEN  0  511  0.0.0.0:3000  0.0.0.0:*  users:(("node",pid=1,fd=20))

フレームワークごとに既定値が違います。開発の利便のためにループバックを既定にしているツールが多いので、コンテナへ移すときに必ず引っかかります。

# Node / Express
node -e "require('express')().listen(3000, '0.0.0.0')"

# Flask
flask run --host=0.0.0.0 --port=5000

# Django
python manage.py runserver 0.0.0.0:8000

# Rails
bin/rails server -b 0.0.0.0 -p 3000

# Vite
vite --host 0.0.0.0

# Next.js
next start -H 0.0.0.0 -p 3000

# Go: net/http
# http.ListenAndServe(":8080", nil)  <- すでにすべてのインターフェース

ここでよく出る心配が「0.0.0.0で開いたら危険ではないか」です。コンテナの中での0.0.0.0はそのコンテナのネットワーク名前空間全体を意味するだけであり、外部への露出は前節の公開時のバインドアドレスが決めます。アプリケーションはコンテナの中で0.0.0.0にバインドし、露出範囲はホスト側のバインドアドレスで制御します。2つの層を混ぜると両方とも間違えます。

ファイアウォール、MTU、オーバーレイ — 運用で出会うもの

ローカルでは出ず、サーバーでだけ出る問題が3種類あります。

第一に、ホストのファイアウォールがDockerのポートを塞げません。UFWで8080を塞いでおいたのに外から繋がる、という状況です。

sudo ufw status | head -4
Status: active
To                         Action      From
--                         ------      ----
8080/tcp                   DENY        Anywhere
curl -sS -o /dev/null -w '%{http_code}\n' http://203.0.113.42:8080/
200

理由はチェーン上の位置です。UFWのルールはほとんどがINPUTチェーンに入ります。ところがコンテナへ向かうパケットはホストが最終目的地ではないのでINPUTではなくFORWARDチェーンを通り、それより先にnatテーブルのPREROUTINGですでにDNATによって宛先が変わっています。UFWが検査する機会がありません。

解決策は3つです。最も安全で単純なのは、先に見たバインドアドレスの制限です。外部への露出が必要な場合は、Dockerがユーザールールのために空けてあるDOCKER-USERチェーンに直接入れます。

sudo iptables -I DOCKER-USER -i enp5s0 ! -s 10.0.0.0/8 -p tcp --dport 8080 -j DROP
sudo iptables -t filter -S DOCKER-USER
-N DOCKER-USER
-A DOCKER-USER -i enp5s0 ! -s 10.0.0.0/8 -p tcp -m tcp --dport 8080 -j DROP
-A DOCKER-USER -j RETURN

DOCKER-USERはDockerが再起動しても保存され、Dockerの自動生成ルールより先に評価されます。3つ目の選択肢であるiptables: falseは、DockerにNATルールをそもそも作らせませんが、コンテナのアウトバウンドまで自分で設定する必要があるので推奨しません。最近のDockerバージョンは既定のフィルタリングを強化しているので、実際の動作は必ず使用中のバージョンで直接確認すべきです。

第二に、MTUの問題です。症状がとても特徴的です。TCP接続は確立し、小さなリクエストは成功するのに、大きな応答やTLSハンドシェイクで止まります。

docker exec api curl -sS -o /dev/null -w '%{http_code}\n' https://api.partner.example.com/v1/ping
200
docker exec api curl -sS -o /dev/null -w '%{http_code}\n' https://api.partner.example.com/v1/bulk
curl: (28) Operation timed out after 30001 milliseconds with 0 bytes received

原因はカプセル化です。オーバーレイネットワークはVXLANヘッダで50バイトを余分に使うので、実際に使えるMTUが1450に減ります。ここにIPsecやWireGuardが重なるとさらに減ります。ところがコンテナのインターフェースが依然として1500だと大きなパケットは断片化が必要になり、経路上でICMPが遮断されていると送信側がそれを知ることができず、そのまま止まります。これがブラックホール型のMTU問題です。

診断は断片化禁止フラグを付けたpingで行います。

docker exec api ping -M do -s 1472 -c 2 api.partner.example.com
PING api.partner.example.com (203.0.113.90) 1472(1500) bytes of data.
ping: local error: message too long, mtu=1450
docker exec api ping -M do -s 1422 -c 2 api.partner.example.com
1430 bytes from 203.0.113.90: icmp_seq=1 ttl=52 time=11.8 ms

1422バイトは通り1472は通らないので、実際のMTUは1450です。ネットワーク作成時にMTUを明示すれば解決します。

docker network create \
  --driver bridge \
  --opt com.docker.network.driver.mtu=1450 \
  app-net
// /etc/docker/daemon.json — 既定のブリッジに適用
{
  "mtu": 1450
}

第三に、オーバーレイネットワーク自体の要件です。Swarmのオーバーレイはノード間で3種類の通信が開いている必要があります。クラスタ管理に2377/tcp、ノード間のゴシップ通信に7946/tcpと7946/udp、VXLANのデータプレーンに4789/udpです。セキュリティグループで4789/udpひとつが塞がっていると、サービスディスカバリはできるのに実際のトラフィックだけが流れない、という紛らわしい状態になります。

docker network create --driver overlay --attachable --opt encrypted app-mesh
docker service create --name api --network app-mesh --replicas 3 api:v312
docker network inspect app-mesh --format '{{json .Containers}}' | jq 'keys | length'
3

--opt encryptedを有効にするとIPsecでノード間のトラフィックが暗号化されますが、ヘッダが加わって有効MTUがまた減ります。前のMTU計算をやり直す必要があります。

問題の位置を絞り込む順序はいつも同じです。コンテナの中でリスニングアドレスを見て、同じネットワークの別のコンテナから名前とIPでそれぞれ繋いでみて、ホストから公開されたポートに繋いでみて、最後に外部から試します。どの段階で切れるかが、そのまま原因です。

docker exec api ss -tlnp                                  # 1. リスニングアドレス
docker run --rm --network app-net alpine:3.20 \
  sh -c 'nc -zv api 3000; nc -zv 172.19.0.3 3000'         # 2. 名前/IPの解決
curl -sS -m 3 http://127.0.0.1:3000/healthz               # 3. ホストから
curl -sS -m 3 http://203.0.113.42:3000/healthz            # 4. 外部から

おわりに — 接続の問題は4段階のどこで切れたかの問題である

Dockerのネットワーキングで覚えておく文はひとつです。コンテナは自分だけのネットワークスタックを持つので、アドレスは常に「誰の視点からか」を添えて読まなければなりません。コンテナの127.0.0.1、ホストの127.0.0.1、ブリッジのゲートウェイ、そして外部から見たホストのIPは、すべて別物です。

実務上の既定値も短くまとまります。ユーザー定義ネットワークを作って名前解決を得て、アプリケーションはコンテナの中で0.0.0.0にバインドし、外部への露出はホスト側のバインドアドレスだけで制御し、コンテナ同士でしか使わないポートは公開しません。そしてホストのファイアウォールがDockerのポートを塞いでくれるとは絶対に仮定しません。