Dockerがファイアウォールに穴を空けるとはどういうことか?

1週間ほど前の下記blog記事が気になったため、少し調べてみた。似たような事例に引っかかった人はそれなりにいるようだ。

jun-networks.hatenablog.com

調べてみての感想は:

  • 「なんでデフォルトがinsecureやねん!」という利用者の心の叫びは真っ当だけど、でもこれ、Dockerの開発者の立場からすれば「せやかて工藤、しゃーないやんけ」案件だろうなあ。
  • 根本的には「Dockerのコンテナ自体はプロセスではない(Linux namespacesの機能を駆使した代物である)」という点に起因するような気がする。
  • 自作Linuxルーター万歳!

――といった具合だ。特に自作Linuxルーター万歳。*1

周知の事実だが今一度繰り返すと、コンテナ自体はプロセスではない。Linux namespacesの機能を駆使して、既定の名前空間とは別の名前空間に諸々のリソースを詰め込んで隔離した代物がコンテナである。

プロセスではないので、Dockerのbridge networkにおいて今回の話題の中心であるポート・マッピングのような機能を実現するにあたり、ユーザーランドでのソフトウェア処理による通信制御――つまりソケット・プログラミングを行っていない。

もしもソケット・プログラミングによるソフトウェア処理で実現していたならば、この件は「Linux上でネットワーク・サーバを直接動かす」のと同じレベルになるので、実運用において問題を引き起こすことはなかっただろう。

でも残念、現実は非情である。コンテナから「ホストの外部ネットワーク」への通信や、ポート・マッピングの機能を実現するために、ホスト側では古の自作Linuxルーターみたいなことをやっているのである。

bind(2)によるIPアドレスの割り当て

Dockerのネットワーク機能について語るにあたり、一見して無関係そうな、迂遠なところから話を始めようと思う。BSDソケットとネットワーク・インタフェースの関係である。

TCPUDPにはポート番号という概念があり*2、クライアント・サーバ・モデルにおけるサーバ側ソフトウェアでは、bind(2)を使用してソケットを「通信を待ち受けるポート番号」に割り当てる――というのがソケット・プログラミングにおける古典的なアプローチである。*3

ポート番号に目が向きがちだが、bind(2)ではソケットにIPアドレスを割り当てることもできる。どういうことだろうか?

例えばコンピュータにLANポートが2つ付属していて、そのうちeth0には192.0.2.1が、eth1には203.0.113.10が割り当てられていたとする。

ここで、TCPポート2195番を使用するネットワーク・サーバを動かしたいのだが、eth0(192.0.2.1:2195)宛ての通信は受信したいけどeth1(203.0.113.10:2195)宛ての通信は無視したい――という場合に、bind(2)にて「192.0.2.1:2195」というIPアドレスとポート番号のペアを割り当てることにより、eth0宛ての通信だけ受信するようになる。

eth0とeth1の両方に届いた通信を受信したいなら、「0.0.0.0」というワイルドカードとして機能するIPアドレス*4を指定すればよい。ワイルドカードアドレスを指定することにより、ローカルループバックを含む「そのコンピュータに割り当てられている全てのIPアドレス」のポート2195番宛ての通信を受信するようになる。

「bind(2)によるIPアドレスの割り当て」の理想と現実

bind(2)によるIPアドレスの割り当ては便利といえば便利なので、大抵のネットワーク・サーバでは「通信を待ち受けるIPアドレス」を設定できるようになっている。

例えば、下記は手元のsshd_configの一部を抜粋したものになるが、「ListenAddress」という項目にてbind(2)するIPアドレスを指定できるようになっている。

# sshd_config より抜粋

#Port 22
#AddressFamily any
#ListenAddress 0.0.0.0
#ListenAddress ::

既定値は0.0.0.0であり、コンピュータの全てのIPアドレス宛ての通信に反応するようになっている。

古典的な「管理者がサーバの隅から隅まで把握している」というスタイルのサーバ構築・管理においては、サーバ上で動かす「TCP/IPを喋るサーバ・デーモン」について、各々の設定を見直して、必要最小限の「IPアドレスのbind(2)」を実現するだろう。つまり、軽々しくワイルドカードアドレスを使用したままにはしておかないのが理想的である。

現実は異なる。ネットワーク・サーバの設定を変更して特定のIPアドレスをbind(2)するようなことは、今となってはあまり多くない。設定ファイルの既定値はワイルドカードアドレスであることが多いし、大半のケースでは既定値であるワイルドカードアドレスのまま運用している。

先に抜粋したsshd_configから分かるように、私自身もワイルドカードアドレスのままネットワーク・サーバの類を動かしていることが多い。

実際のところ、現在のサーバ管理においては、多種多様な「TCP/IPを喋るサーバ・デーモン」を動かしている上に、各種コンポーネント類の入れ替わりも早い。なのでいちいち全てのネットワーク・サーバの設定を精査なんてしていられない。ちょっと間に合わない。

では、bind(2)によるIPアドレスの割り当ての代わりに何をしているかといえば、ファイアウォールで宛先アドレス/ポート番号をチェックしてパケットフィルタリングしているのである。

ネットワーク・サーバの設定ファイルは分散している上に内容も千差万別だが、ファイアウォールなら1ヶ所で集中管理できる。その点は便利だよね。

ローカル・プロセス宛てのパケットをフィルタリングするタイミング

Linux上では、ネットワーク・サーバはプロセスとして動作している。LinuxのNetfilterにおいては、ローカルプロセス宛てのパケットはINPUTチェインでフィルタリングすればよい。

次の図は、NetfilterにおけるL3でのパケット処理の流れを示したものである。

 +-----------------------------------------------------------------+
 |                       Network Interfaces                        |
 |                      (lo/eth/wlan/etc...)                       |
 +-----------------------------------------------------------------+
        |                                                   |
 Incoming packet                                     Outgoing packet
        |                                                   |
 +-------------+                                            |
 | PREROUTING  |                                            |
 | +---------+ |                                     +-------------+
 | |   raw   | |                                     | POSTROUTING |
 | +---------+ |                                     | +---------+ |
 |      |      |                                     | |   nat   | |
 | +---------+ |                                     | +---------+ |
 | | mangle  | |                                     |      |      |
 | +---------+ |                                     | +---------+ |
 |      |      |                                     | | mangle  | |
 | +---------+ |                                     | +---------+ |
 | |   nat   | |                                     +-------------+
 | +---------+ |                                            |
 +-------------+                                            |
        |           +---------------------------+           |
        |           |          FORWARD          |           |
 +-------------+    | +--------+     +--------+ |    +-------------+
 |   Routing   |--->| | mangle |---->| filter | |--->|   Routing   |
 +-------------+    | +--------+     +--------+ |    +-------------+
        |           +---------------------------+           |
        |                                            +-------------+
        |                                            |   OUTPUT    |
        |                                            | +---------+ |
        |                                            | | filter  | |
 +-------------+                                     | +---------+ |
 |    INPUT    |                                     |      |      |
 | +---------+ |                                     | +---------+ |
 | | mangle  | |                                     | |   nat   | |
 | +---------+ |                                     | +---------+ |
 |      |      |                                     |      |      |
 | +---------+ |                                     | +---------+ |
 | | filter  | |                                     | | mangle  | |
 | +---------+ |                                     | +---------+ |
 +-------------+                                     |      |      |
        |                                            | +---------+ |
        |                                            | |   raw   | |
 +-----------------------------------------------+   | +---------+ |
 |      |          Local Process                 |   +-------------+
 |      |                                        |          |
 | +---------------------+ +-------------------+ |   +-------------+
 | | recv(2)/recvfrom(2) | | send(2)/sendto(2) |-|-->|   Routing   |
 | +---------------------+ +-------------------+ |   +-------------+
 |           |                       |           |
 |      Received data            Send data       |
 +-----------------------------------------------+

内部でソケット通信しているネットワーク・サーバは、この図におけるローカル・プロセスに該当する。INPUTチェインは、ネットワーク・インタフェースからローカル・プロセスまでの経路上に位置する。なので、INPUTチェインにてパケットフィルタリングを行うことになる。

VPSなどでサーバを構築する場合、iptablesやnftablesのINPUTチェインにフィルタリングルールを設定するだろう。大概の入門文書にてそう案内されているし、実務的にもその通りだ。

ということで、Linuxサーバを触っている人にとって、INPUTチェインは身近なものである。普通にネットワーク・サーバを動かす分には、割とそれだけで十分に間に合うものだ。

Bridge networkにおけるホストとコンテナのネットワーク構成

残念ながら、Dockerのコンテナそのものはローカル・プロセスではない。ローカル・プロセスではないのだから、コンテナ宛ての通信について「ホスト側のNetfilterのINPUTチェイン」にて制御を試みるのは的外れである。

次の図は、Dockerでネットワーク・ドライバとしてbridge networkを選択した状態で、3つのコンテナを動かした際の、大まかなネットワーク構成を示したものである。

 +----------------------------------------------+
 | Linux box                                    |
 |                                              |
 | +------------------------------------------+ |
 | | Default network namespace                | |
 | |                                          | |
 | | +-------+ +-------+ +-------+ +-------+  | |
 | | |  lo   | | eth0  | | eth1  | | wlan0 |  | |
 | | +-------+ +-------+ +-------+ +-------+  | |
 | |                                          | |
 | |               +---------+                | |
 | |               | docker0 |                | |
 | |               +----+----+                | |
 | |                    |                     | |
 | |     +--------------+--------------+      | |
 | |     |              |              |      | |
 | | +---+---+      +---+---+      +---+---+  | |
 | | | veth0 |      | veth1 |      | veth2 |  | |
 | | +---+---+      +---+---+      +---+---+  | |
 | +-----|--------------|--------------|------+ |
 |       |              |              |        |
 | +-----|------+ +-----|------+ +-----|------+ |
 | | +---+---+  | | +---+---+  | | +---+---+  | |
 | | | veth0 |  | | | veth0 |  | | | veth0 |  | |
 | | +-------+  | | +-------+  | | +-------+  | |
 | | +-------+  | | +-------+  | | +-------+  | |
 | | |  lo   |  | | |  lo   |  | | |  lo   |  | |
 | | +-------+  | | +-------+  | | +-------+  | |
 | |            | |            | |            | |
 | | Container1 | | Container2 | | Container3 | |
 | +------------+ +------------+ +------------+ |
 +----------------------------------------------+

Dockerのホスト側から見ると、実行中の各コンテナはdocker0という仮想ネットワーク・デバイスの先にぶら下がっている。

この構造は、下記のネットワーク構成と非常によく似ている。

 +------------------------------------------+
 | Linux_box0                               |
 |                                          |
 | +-------+ +-------+ +-------+ +-------+  |
 | |  lo   | | eth0  | | eth1  | | wlan0 |  |
 | +-------+ +-------+ +-------+ +-------+  |
 |                                          |
 |                +-------+                 |
 |                | eth2  |                 |
 |                +---+---+                 |
 +--------------------|---------------------+
                      |
       +--------------+--------------+
       |              |              |
 +-----|------+ +-----|------+ +-----|------+
 | +---+---+  | | +---+---+  | | +---+---+  |
 | | eth0  |  | | | eth0  |  | | | eth0  |  |
 | +-------+  | | +-------+  | | +-------+  |
 | +-------+  | | +-------+  | | +-------+  |
 | |  lo   |  | | |  lo   |  | | |  lo   |  |
 | +-------+  | | +-------+  | | +-------+  |
 |            | |            | |            |
 | Linux_box1 | | Linux_box2 | | Linux_box3 |
 +------------+ +------------+ +------------+

この図は自作Linuxルータの実行環境の一例である。Linux_box0がルータである。eth2はプライベート・ネットワークに繋がっている。プライベート・ネットワーク上には3台のコンピュータ(Linux_box1~Linux_box3)が動作している。Linux_box1~Linux_box3は、ルータであるLinux_box0を経由して外部ネットワークと通信する。

ここで、外部ネットワークと繋がっているネットワーク・インタフェースがeth0だと仮定すると、Linux_box0では、何かしらの方法でeth0~eth2間でパケットを橋渡しする必要がある。

Dockerの場合も似たようなもので、やはり外部ネットワークと繋がっているのがeth0だと仮定すると、ホスト側にて何かしらの方法でeth0~docker0間でパケットを橋渡しする必要がある。

どう橋渡しするか? IPフォワードを使用してネットワーク・インタフェース間でパケットを転送するのである。この時、NetfilterのPREROUTINGチェインとPOSTROUTINGチェインにて、パケットの宛先IPアドレスの書き換えを行っている(俗に言うNATとかIPマスカレードとかいうアレである)。

先に示したNetfilterのパケット処理の図で言うと、例えばLinux_box1/Container1が外部ネットワークに向けて送信したパケットは、Linux_box0/Dockerホスト側では次のように処理されるだろう。

  1. Network Interfaces(eth2/docker0) → PREROUTINGチェイン。
  2. PREROUTINGチェイン → FORWARDチェイン。
  3. FORWARDチェインのフィルタリング・ルールが適用される。
  4. FORWARDチェイン → POSTROUTINGチェイン。
  5. POSTROUTINGチェインで送信元IPアドレスが書き換えられる。
  6. POSTROUTINGチェイン → Network Interfaces(eth0)。

反対方向の、外部ネットワークからLinux_box1/Container1に向けてのパケットは、次のように処理されるだろう。

  1. Network Interfaces(eth0) → PREROUTINGチェイン。
  2. PREROUTINGチェインで宛先IPアドレスが書き換えられる。
  3. PREROUTINGチェイン → FORWARDチェイン。
  4. FORWARDチェインのフィルタリング・ルールが適用される。
  5. FORWARDチェイン → POSTROUTINGチェイン。
  6. POSTROUTINGチェイン → Network Interfaces(eth2/docker0)。

どちらのケースも(特に受信方向のパケットについて)INPUTチェインを通過しない。だからINPUTチェインに記述したフィルタリング・ルールは適用されない。

このことは、Dockerのネットワークまわりについて理解していれば、自明のことである。

残念ながら、私を含めて「コンテナはプロセスではない」ということの本当の意味についての理解が浅い人は多い。そのような人が今まで触ってきたネットワーク・サーバと同じような塩梅でコンテナを扱おうとしてしまい、ファイアウォールについてもネットワーク・サーバの時と同じ流儀で適用すれば十分だと判断してしまった時に、INPUTチェインのフィルタリング・ルールが適用されないという事実が牙をむくことになる。

……自作Linuxルータなら、物理的にLANの構成が目に見えて分かる。でもDockerの場合は全てがホスト側のコンピュータの中におさまっていて目に見えない。目に見えないからこそ、混乱を引き起こしやすいのかもしれない。

実際にNetfilterに設定されるルールを見てみる

Dockerは外部ネットワークとコンテナの間をつなぐためにNetfilterのルールを変更する。具体的にはどのような内容なのだろうか?

以下に示す環境(Ubuntu 22.04上のDocker 24.0.3)にてルールを確認してみた。実験用に用意した環境であるため、そもそもパケットフィルタリングのルールは未設定の環境である。

$ lsb_release -a
Distributor ID: Ubuntu
Description:    Ubuntu 22.04.2 LTS
Release:        22.04
Codename:       jammy

$ uname -a
Linux fabrico 5.19.0-46-generic #47~22.04.1-Ubuntu SMP PREEMPT_DYNAMIC Wed Jun 21 15:35:31 UTC 2 x86_64 x86_64 x86_64 GNU/Linux

$ docker --version
Docker version 24.0.3, build 3713ee1

$ docker version
Client: Docker Engine - Community
 Version:           24.0.3
 API version:       1.43
 Go version:        go1.20.5
 Git commit:        3713ee1
 Built:             Wed Jul  5 20:44:55 2023
 OS/Arch:           linux/amd64
 Context:           default

Server: Docker Engine - Community
 Engine:
  Version:          24.0.3
  API version:      1.43 (minimum version 1.12)
  Go version:       go1.20.5
  Git commit:       1d9c861
  Built:            Wed Jul  5 20:44:55 2023
  OS/Arch:          linux/amd64
  Experimental:     false
 containerd:
  Version:          1.6.21
  GitCommit:        3dce8eb055cbb6872793272b4f20ed16117344f8
 runc:
  Version:          1.1.7
  GitCommit:        v1.1.7-0-g860f061
 docker-init:
  Version:          0.19.0
  GitCommit:        de40ad0

まず、Docker本体が動作している時にnftablesでルールを確認すると、次のようになっている。

# sudo nft -s list ruleset
table ip nat {
    chain POSTROUTING {
        type nat hook postrouting priority srcnat; policy accept;
        oifname != "docker0" ip saddr 172.17.0.0/16 counter masquerade
    }

    chain PREROUTING {
        type nat hook prerouting priority dstnat; policy accept;
        fib daddr type local counter jump DOCKER
    }

    chain OUTPUT {
        type nat hook output priority -100; policy accept;
        ip daddr != 127.0.0.0/8 fib daddr type local counter jump DOCKER
    }

    chain DOCKER {
        iifname "docker0" counter return
    }
}
table ip filter {
    chain DOCKER {
    }

    chain DOCKER-ISOLATION-STAGE-1 {
        iifname "docker0" oifname != "docker0" counter jump DOCKER-ISOLATION-STAGE-2
        counter return
    }

    chain FORWARD {
        type filter hook forward priority filter; policy drop;
        counter jump DOCKER-USER
        counter jump DOCKER-ISOLATION-STAGE-1
        oifname "docker0" ct state related,established counter accept
        oifname "docker0" counter jump DOCKER
        iifname "docker0" oifname != "docker0" counter accept
        iifname "docker0" oifname "docker0" counter accept
    }

    chain DOCKER-USER {
        counter return
    }

    chain DOCKER-ISOLATION-STAGE-2 {
        oifname "docker0" counter drop
        counter return
    }
}

この状態では、コンテナがクライアント側として振る舞う通信が許可されている(なのでapt(8)やyum(8)を使用してネットワーク経由でもコンポーネントをアップデートすることが可能である)。

具体的には、POSTROUTINGチェインの下記ルールにより、コンテナから外部ネットワークに向けて送信したパケットの送信元IPアドレスが「ホスト側のIPアドレス」に書き換えられてから外部ネットワークに送出される。

oifname != "docker0" ip saddr 172.17.0.0/16 counter masquerade

nftablesのルールでは明示されていないが、外部ネットワークからコンテナ宛てに届いたパケットについても、PREROUTINGチェインあたりのタイミングで送信先IPアドレスが「ホスト側のIPアドレス」から「コンテナのIPアドレス」に書き換えられているはずである*5

外部ネットワークからコンテナ宛てに届いたパケットに関しては、FORWARDチェインの下記ルールにより「コンテナが送信したパケットに対する応答パケット」のみを受容するように動作している。

oifname "docker0" ct state related,established counter accept

総じて、送信側も受信側も実に「家庭用のブロードバンドルータ」っぽいルールであるな、という感想である。

次にdocker run -p 8080:8080でコンテナを起動した時のルールはどうなっているだろうか?

# docker run -p 8080:8080 test_image
# sudo nft -s list ruleset
table ip nat {
    chain POSTROUTING {
        type nat hook postrouting priority srcnat; policy accept;
        oifname != "docker0" ip saddr 172.17.0.0/16 counter masquerade
        meta l4proto tcp ip saddr 172.17.0.2 ip daddr 172.17.0.2 tcp dport 8080 counter masquerade
    }

    chain PREROUTING {
        type nat hook prerouting priority dstnat; policy accept;
        fib daddr type local counter jump DOCKER
    }

    chain OUTPUT {
        type nat hook output priority -100; policy accept;
        ip daddr != 127.0.0.0/8 fib daddr type local counter jump DOCKER
    }

    chain DOCKER {
        iifname "docker0" counter return
        iifname != "docker0" meta l4proto tcp tcp dport 8080 counter dnat to 172.17.0.2:8080
    }
}
table ip filter {
    chain DOCKER {
        iifname != "docker0" oifname "docker0" meta l4proto tcp ip daddr 172.17.0.2 tcp dport 8080 counter accept
    }

    chain DOCKER-ISOLATION-STAGE-1 {
        iifname "docker0" oifname != "docker0" counter jump DOCKER-ISOLATION-STAGE-2
        counter return
    }

    chain FORWARD {
        type filter hook forward priority filter; policy drop;
        counter jump DOCKER-USER
        counter jump DOCKER-ISOLATION-STAGE-1
        oifname "docker0" ct state related,established counter accept
        oifname "docker0" counter jump DOCKER
        iifname "docker0" oifname != "docker0" counter accept
        iifname "docker0" oifname "docker0" counter accept
    }

    chain DOCKER-USER {
        counter return
    }

    chain DOCKER-ISOLATION-STAGE-2 {
        oifname "docker0" counter drop
        counter return
    }
}

コンテナから外部ネットワークに向けて送信されたパケットの扱いについては、先ほどと同じだ。違いは外部ネットワークからコンテナ宛てに届いたパケットの扱いに見られる。

外部ネットワークからホスト側のポート8080番に届いたパケットは、PREROUTINGチェインからジャンプした先のDOCKERチェインにおいて、下記ルールによって宛先IPアドレス/ポート番号が書き換えられる。

iifname != "docker0" meta l4proto tcp tcp dport 8080 counter dnat to 172.17.0.2:8080

その後、FORWARDチェインからジャンプした先のDOCKERチェインの下記フィルタリング・ルールでパケットが受容される。

iifname != "docker0" oifname "docker0" meta l4proto tcp ip daddr 172.17.0.2 tcp dport 8080 counter accept

この一連の流れには、家庭用ルータのポート開放機能を彷彿させるものがある。

さて、PREROUTINGチェインで実行される下記ルールを再度見てみる。

iifname != "docker0" meta l4proto tcp tcp dport 8080 counter dnat to 172.17.0.2:8080

このルールでは、ホスト側に届いたパケットの宛先IPアドレスをチェックしていない。そのため、ホスト側の「docker0を除く、全ての『IPアドレスが付与された』ネットワーク・インタフェース」のポート8080番に届いたパケットが、DNATで宛先がコンテナのIPアドレスに書き換えられて、以降のチェインに流れて行ってしまうことになる。

それでは、例えばdocker run -p 192.0.2.100:8080:8080のように、IPアドレスを明示してポート・マッピングした場合には、どのようなルールになるだろうか?

# docker run -p 192.0.2.100:8080:8080 test_image
# sudo nft -s list ruleset
table ip nat {
    chain POSTROUTING {
        type nat hook postrouting priority srcnat; policy accept;
        oifname != "docker0" ip saddr 172.17.0.0/16 counter masquerade
        meta l4proto tcp ip saddr 172.17.0.2 ip daddr 172.17.0.2 tcp dport 8080 counter masquerade
    }

    chain PREROUTING {
        type nat hook prerouting priority dstnat; policy accept;
        fib daddr type local counter jump DOCKER
    }

    chain OUTPUT {
        type nat hook output priority -100; policy accept;
        ip daddr != 127.0.0.0/8 fib daddr type local counter jump DOCKER
    }

    chain DOCKER {
        iifname "docker0" counter return
        iifname != "docker0" meta l4proto tcp ip daddr 192.0.2.100 tcp dport 8080 counter dnat to 172.17.0.2:8080
    }
}
table ip filter {
    chain DOCKER {
        iifname != "docker0" oifname "docker0" meta l4proto tcp ip daddr 172.17.0.2 tcp dport 8080 counter accept
    }

    chain DOCKER-ISOLATION-STAGE-1 {
        iifname "docker0" oifname != "docker0" counter jump DOCKER-ISOLATION-STAGE-2
        counter return
    }

    chain FORWARD {
        type filter hook forward priority filter; policy drop;
        counter jump DOCKER-USER
        counter jump DOCKER-ISOLATION-STAGE-1
        oifname "docker0" ct state related,established counter accept
        oifname "docker0" counter jump DOCKER
        iifname "docker0" oifname != "docker0" counter accept
        iifname "docker0" oifname "docker0" counter accept
    }

    chain DOCKER-USER {
        counter return
    }

    chain DOCKER-ISOLATION-STAGE-2 {
        oifname "docker0" counter drop
        counter return
    }
}

PREROUTINGチェインでDNATする部分のルールのみ変化している。

# IPアドレスを明示しなかった場合:
iifname != "docker0" meta l4proto tcp tcp dport 8080 counter dnat to 172.17.0.2:8080

# IPアドレスを明示した場合:
iifname != "docker0" meta l4proto tcp ip daddr 192.0.2.100 tcp dport 8080 counter dnat to 172.17.0.2:8080

IPアドレスを明示した場合は、DNATする際に宛先IPアドレスをチェックしている。これにより「ホスト側の特定のネットワーク・インタフェースに届いたパケット」のみがDNATされ、以降のチェインに流れていくようになる。

「せやかて工藤、しゃーないやんけ」案件

ポート・マッピングする際にIPアドレスを明示することで、ホスト側の特定のネットワーク・インタフェースに届いたパケットだけがDNATされて、コンテナに転送されるのであった。

さて、Dockerの開発者になった気分で思考実験してみよう。「ホスト側の特定のネットワーク・インタフェースを示すIPアドレス」の既定値(デフォルト値)として相応しい値は何だろうか?

Dockerを動かすホストには、ネットワーク・インタフェースは何個存在するだろうか? それぞれのネットワーク・インタフェースには、どのようなIPアドレスが付与されているだろうか?

答え:ネットワーク・インタフェースが何個あるのか分からないし、付与されているIPアドレスも分からない。

さあ、既定値として相応しいIPアドレスは何だろうか?

――と考えた時に、既存のネットワーク・サーバの待ち受けIPアドレスのように「とりあえず全てのネットワーク・インタフェース宛てのパケットを受容してしまおう」という判断をすることは、まあ、意外とありがちなパターンではないかと思う。

唯一の違いは、普通のネットワーク・サーバならNetfilterのINPUTチェインのフィルタリング・ルールが適用される(だから、その辺のルールさえ適切ならば、ワイルドカードアドレスにbind(2)しても被害はでない)のにたいして、Dockerのポート・マッピングではINPUTチェインのルールが適用されないことである。

ここで、既定値としてローカルループバックを採用することは、Dockerという汎用なソフトウェアとしては「ちょっとやりすぎ」感がある。

例えばRDBMSMySQLとか)の管理ポートなら「既定値=ローカルループバック」でも納得できるかもしれない。なぜならRDBMSを全世界に大公開することは非常に稀だからだ。というか普通は、公開ネットワーク上のサーバで動かしているRDBMSの管理ポートに外からアクセスできてしまったら、真っ先にサーバのセキュリティ対策不足を疑うと思う。

一方でWebサーバの公開ポートについて「既定値=ローカルループバック」だったら「うーん、それってどうなのよ?」みたいなお気持ち表明が出てくるはずである。「既定値はワイルドカードアドレスでいいのでは?」とも突っ込まれるかもしれない。

Dockerは汎用な仕組みである。各コンテナでは、Webサーバ(広く公開されることが多い)が動いていることもあれば、RDBMS(公開範囲はできるだけ狭く)が動いていることもある。

ローカルループバックを既定値とするのは適切だろうか? むしろDockerユーザからのお気持ち表明が出ないだろうか?

非常に悩ましい。私個人の感想としては、仮に開発者が「しゃーないやんけ」とこぼしていたとしても、まあ、むべなるかな……。

対策案

とりあえず以下の4通りが考えられる。オススメは(1)ないし(2)じゃないかしら。

  1. コンテナを起動する時に、ポート・マッピングにて「パケットを受容するネットワーク・インタフェースのIPアドレス」を明示する。
    • 例えばdocker run -p 8080:8080ではなくdocker run -p 127.0.0.1:8080:8080のように明示する。
    • 設定ファイル(例えばdaemon.json)を書き換えて、ポート・マッピングの既定値をワイルドカードアドレス以外に変更しておくのも良いアイデアだろう。
  2. NetfilterのFORWARDチェインにて「コンテナ宛ての通信」のパケットフィルタリングを行う。
    • DockerはDOCKER-USERという名前のチェインを追加する。ここにユーザ定義のフィルタリング・ルールを追加すればよい。
  3. 各々のコンテナ側にてパケットフィルタリングする。
    • docker runする時に--cap-add=NET_ADMINを付与することで、コンテナの中でiptablesやnftablesを利用できるようになる。
    • ある種の「特権モード」でコンテナを動かすことになるので、何となくコンテナの長所の1つを潰してしまうことになる気がする。
  4. DockerがNetfilterのフィルタリング・ルールを勝手に変更しないように設定した上で、自前でフルセットのフィルタリング・ルールを用意する。
    • daemon.jsoniptablesキーをfalseにすればよいのだが、公式マニュアルでは非推奨となっている。

おまけ

コンテナ起動後に追加されるルールのうち、POSTROUTINGに追加される下記内容について:

meta l4proto tcp ip saddr 172.17.0.2 ip daddr 172.17.0.2 tcp dport 8080 counter masquerade

どのような意図のルールなのか分からなかったのだが、Stack Overflow情報によれば、POSTROUTINGのデフォルト・ルールがDENY(つまりpolicy drop)である場合でもコンテナが自分自身に接続できるようにするため、というエッジケース対応用のルールであるらしい。

stackoverflow.com

*1:なお私は自作Linuxルーターエアプ勢である。

*2:SCTPやDCCPなどの後発のL4プロトコルにもポート番号はある。AppleTalkやIPX/SPXやNBF(NetBEUI)などの古いプロトコルについては知らない。

*3:サーバ側のポート番号が固定されているので、クライアント側は「送信先ポート番号」を決め打ちして通信開始する――というアプローチである。これとは別に、VoIPやWebRTCなどのように、仲介者(SIPサーバやシグナリングサーバ)を通じて(SDPなどで)「通信に使用するポート番号」の情報を相互に交換することにより、ポート番号を動的に決定する(≒決め打ちではない)アプローチもある。

*4:IPv6では「::」である。

*5:iptablesのマニュアルを確認した感じでは、IPマスカレードを使用する場合にはPOSTROUTINGチェインにだけ設定しておけばよいみたいである。