Docker コンテナに対するポートフォワーディングはプロジェクト外部からの接続に対してのみ行われる

言いたいことはタイトルの通りである。 Docker にはポートフォワーディング機能があり、これを使うとコンテナのポートをホストのポートに割り当てることができる。

この目的から考えれば、ポートフォワーディングがプロジェクト内部のコンテナからの接続に対して働かないのは当たり前なことのように思えるが、本当にそうであることを確かめたかったので実験してみた。

結論だけ知りたい場合は、実験部分を飛ばして最後の「ねたばらし」の部分だけ読んでほしい。

もしかすると本稿の内容には間違いがあるかもしれないので、その場合はご指摘いただきたい。

実験の手順

適当な場所に test ディレクトリを作り、その中で以下のような docker-compose.yml を作成する。

version: "3.9"

services:
  nginx:
    image: nginx
    ports:
      - 8080:80
    networks:
      - mynetwork
      - default

  client1:
    image: curlimages/curl
    command: "sh -c 'sleep 10 && curl nginx:80 && curl nginx:8088'"
    networks:
      - mynetwork
    depends_on:
      - nginx

  client2:
    image: curlimages/curl
    command: "sh -c 'sleep 20 && curl nginx:80 && curl nginx:8088'"
    networks:
      - default
    depends_on:
      - nginx

networks:
  mynetwork:

この状態で docker-compose up すると、以下のコンテナが作成される。

  • nginx
    • 80 番ポートで接続を待ち受ける。これはホストの 8080 番ポートにバインドされている。
  • client1, 2
    • それぞれ異なるネットワークから nginx コンテナに curl して通信がつながるか確認する。

さらに、以下のネットワークが作成される。

  • test_default という名前の bridge ネットワーク(暗黙的に作られる
  • test_mynetwork という名前の bridge ネットワーク

これら全体の構成を図にすると、以下のようになる。

この構成のもとで host、client1、client2 からそれぞれ nginx コンテナの 80 番と 8080 番ポートに curl して、接続が通じるか調べる。

実験の結果

host から curl した結果を以下に示す。80 番ポートへの接続は失敗し、8080 番ポートへの接続は成功している。

$ curl localhost:80
curl: (7) Failed to connect to localhost port 80: Connection refused

$ curl localhost:8080
<!DOCTYPE html>
<html>
(省略)
</html>

次に、client1 の curl 実行ログを以下に示す。 前半部分は nginx:80 に対する接続で、成功している。後半は nginx:8080 に対する接続で、失敗している。

% Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0
<!DOCTYPE html>
<html>
(省略)
</html>
100   615  100   615    0     0   597k      0 --:--:-- --:--:-- --:--:--  600k

% Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0
curl: (7) Failed to connect to nginx port 8080 after 0 ms: Connection refused

最後に、client2 の curl 実行ログを以下に示す。 client1 のときと同じような結果となった。

% Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0
<!DOCTYPE html>
<html>
(省略)
</html>
100   615  100   615    0     0   599k      0 --:--:-- --:--:-- --:--:--  600k

% Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:-- --:--:-- --:--:--     0
curl: (7) Failed to connect to nginx port 8080 after 0 ms: Connection refused

実験の考察

まず、host からコンテナの接続について考える。

そもそも host 自体は 80 番ポートを LISTEN していないので、ここへの接続が失敗するのは自明である。 また、8080 番ポートへ接続すると、以下のようにポートフォワーディングが行われ、接続に成功する。

次に、コンテナからコンテナへの接続について考える。

各コンテナから nginx:80 には以下のようにアクセスできるが......

どのようなネットワーク構成であっても Docker プロジェクト内からの接続にはポートフォワーディングが効かないため、8080→80 の変換が行われず、結果として nginx:8080 への接続は以下のように失敗する。

ねたばらし

では、どうしてポートフォワーディングは外部からの接続のみに対して効くのだろうか?

Docker のドキュメント を読めば分かるが、Docker サーバが起動するとホストの iptables にマスカレーディングルールが追加される。今回の実験の構成を起動したときの iptables を実際に確認してみると、以下のようになっていることがわかる。

$ sudo iptables-legacy -t nat -L -n
(省略)

Chain DOCKER (2 references)
target     prot opt source               destination
(省略)
DNAT       tcp  --  0.0.0.0/0            0.0.0.0/0            tcp dpt:8080 to:172.27.0.2:80

iptables の DNAT ルールは Destination Network Address Translation、つまりネットワークパケットの宛先アドレスを書き換える機能を持つ。 上記の例では、dpt(destination port:宛先ポート)が 8080 な TCP パケットの行き先を、172.27.0.2(nginx コンテナのアドレス)の 80 番ポートに書き換えている。 このルールによってポートフォワーディングが実現され、コンテナの特定のポートをホストの特定のポートに紐付けることが可能となる。 この他にも通信をうまく取り扱うためのルールが複数追加されるので、気になる方はぜひ確かめてみてほしい。

一方、コンテナ間の通信にはこのルールが適用されないため、結果としてポートフォワーディングも行われないのである。

結論

Docker コンテナに対するポートフォワーディングはプロジェクト外部からの接続に対してのみ行われる。

この実験を通して、普段あまり意識しないような Docker の内部の処理を垣間見ることができ、とても興味深いわねえという気持ちになった。