1週間ほど前の下記blog記事が気になったため、少し調べてみた。似たような事例に引っかかった人はそれなりにいるようだ。
調べてみての感想は:
- 「なんでデフォルトがinsecureやねん!」という利用者の心の叫びは真っ当だけど、でもこれ、Dockerの開発者の立場からすれば「せやかて工藤、しゃーないやんけ」案件だろうなあ。
- 根本的には「Dockerのコンテナ自体はプロセスではない(Linux namespacesの機能を駆使した代物である)」という点に起因するような気がする。
- 自作Linuxルーター万歳!
周知の事実だが今一度繰り返すと、コンテナ自体はプロセスではない。Linux namespacesの機能を駆使して、既定の名前空間とは別の名前空間に諸々のリソースを詰め込んで隔離した代物がコンテナである。
プロセスではないので、Dockerのbridge networkにおいて今回の話題の中心であるポート・マッピングのような機能を実現するにあたり、ユーザーランドでのソフトウェア処理による通信制御――つまりソケット・プログラミングを行っていない。
もしもソケット・プログラミングによるソフトウェア処理で実現していたならば、この件は「Linux上でネットワーク・サーバを直接動かす」のと同じレベルになるので、実運用において問題を引き起こすことはなかっただろう。
でも残念、現実は非情である。コンテナから「ホストの外部ネットワーク」への通信や、ポート・マッピングの機能を実現するために、ホスト側では古の自作Linuxルーターみたいなことをやっているのである。
bind(2)によるIPアドレスの割り当て
Dockerのネットワーク機能について語るにあたり、一見して無関係そうな、迂遠なところから話を始めようと思う。BSDソケットとネットワーク・インタフェースの関係である。
TCPやUDPにはポート番号という概念があり*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ホスト側では次のように処理されるだろう。
- Network Interfaces(eth2/docker0) → PREROUTINGチェイン。
- PREROUTINGチェイン → FORWARDチェイン。
- FORWARDチェインのフィルタリング・ルールが適用される。
- FORWARDチェイン → POSTROUTINGチェイン。
- POSTROUTINGチェインで送信元IPアドレスが書き換えられる。
- POSTROUTINGチェイン → Network Interfaces(eth0)。
反対方向の、外部ネットワークからLinux_box1/Container1に向けてのパケットは、次のように処理されるだろう。
- Network Interfaces(eth0) → PREROUTINGチェイン。
- PREROUTINGチェインで宛先IPアドレスが書き換えられる。
- PREROUTINGチェイン → FORWARDチェイン。
- FORWARDチェインのフィルタリング・ルールが適用される。
- FORWARDチェイン → POSTROUTINGチェイン。
- 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という汎用なソフトウェアとしては「ちょっとやりすぎ」感がある。
例えばRDBMS(MySQLとか)の管理ポートなら「既定値=ローカルループバック」でも納得できるかもしれない。なぜならRDBMSを全世界に大公開することは非常に稀だからだ。というか普通は、公開ネットワーク上のサーバで動かしているRDBMSの管理ポートに外からアクセスできてしまったら、真っ先にサーバのセキュリティ対策不足を疑うと思う。
一方でWebサーバの公開ポートについて「既定値=ローカルループバック」だったら「うーん、それってどうなのよ?」みたいなお気持ち表明が出てくるはずである。「既定値はワイルドカードアドレスでいいのでは?」とも突っ込まれるかもしれない。
Dockerは汎用な仕組みである。各コンテナでは、Webサーバ(広く公開されることが多い)が動いていることもあれば、RDBMS(公開範囲はできるだけ狭く)が動いていることもある。
ローカルループバックを既定値とするのは適切だろうか? むしろDockerユーザからのお気持ち表明が出ないだろうか?
非常に悩ましい。私個人の感想としては、仮に開発者が「しゃーないやんけ」とこぼしていたとしても、まあ、むべなるかな……。
対策案
とりあえず以下の4通りが考えられる。オススメは(1)ないし(2)じゃないかしら。
- コンテナを起動する時に、ポート・マッピングにて「パケットを受容するネットワーク・インタフェースのIPアドレス」を明示する。
- NetfilterのFORWARDチェインにて「コンテナ宛ての通信」のパケットフィルタリングを行う。
- Dockerは
DOCKER-USER
という名前のチェインを追加する。ここにユーザ定義のフィルタリング・ルールを追加すればよい。
- Dockerは
- 各々のコンテナ側にてパケットフィルタリングする。
docker run
する時に--cap-add=NET_ADMIN
を付与することで、コンテナの中でiptablesやnftablesを利用できるようになる。- ある種の「特権モード」でコンテナを動かすことになるので、何となくコンテナの長所の1つを潰してしまうことになる気がする。
- DockerがNetfilterのフィルタリング・ルールを勝手に変更しないように設定した上で、自前でフルセットのフィルタリング・ルールを用意する。
おまけ
コンテナ起動後に追加されるルールのうち、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
)である場合でもコンテナが自分自身に接続できるようにするため、というエッジケース対応用のルールであるらしい。
*2:SCTPやDCCPなどの後発のL4プロトコルにもポート番号はある。AppleTalkやIPX/SPXやNBF(NetBEUI)などの古いプロトコルについては知らない。
*3:サーバ側のポート番号が固定されているので、クライアント側は「送信先ポート番号」を決め打ちして通信開始する――というアプローチである。これとは別に、VoIPやWebRTCなどのように、仲介者(SIPサーバやシグナリングサーバ)を通じて(SDPなどで)「通信に使用するポート番号」の情報を相互に交換することにより、ポート番号を動的に決定する(≒決め打ちではない)アプローチもある。
*5:iptablesのマニュアルを確認した感じでは、IPマスカレードを使用する場合にはPOSTROUTINGチェインにだけ設定しておけばよいみたいである。