DockerでUFWをバイパスする理由と対策
Dockerの公開ポートがUFWのdenyを無視する理由を解説します。iptablesのDNATで8080番ポートが外部から応答する仕組みと、機能する2つの対策を紹介します。
Docker が UFW をバイパスする理由
Docker が UFW をバイパスするのは、公開されたコンテナポートを通過するパケットが、UFW の管理するファイアウォールルールを通らないためです。docker run -p 8080:80 を実行すると、Docker はカーネルの nat テーブルにある PREROUTING チェーンへ DNAT(宛先ネットワークアドレス変換)ルールを書き込みます。このルールは、カーネルがパケットの転送先を決定する前に、各パケットの宛先をコンテナのプライベートアドレスへ書き換えます。書き換えられたパケットは、Docker が管理する FORWARD チェーンを経由してコンテナへ転送されます。UFW のルールは INPUT チェーンにあり、パケットはそこへ入りません。そのため、ufw status はデフォルト拒否を表示し、sudo ufw deny 8080 は成功を報告する一方で、ポート 8080 はインターネット全体からの接続に応答し続けます。
これは Docker のバグではなく、UFW が壊れているわけでもありません。両方のツールが同じカーネルファイアウォールを設定しています。Docker のルールはパケット経路上のより早い段階で適用されるため、UFW のルールが参照されないだけです。このガイドでは、このバイパスを確認し、その仕組みを説明したうえで、機能する 2 つの対策を扱います。具体的には、127.0.0.1 でポートを公開する方法と、DOCKER-USER チェーンでフィルタリングする方法です。UFW 自体に慣れていない場合は、まず UFW ファイアウォールの基本ガイドで設定してください。デフォルト拒否のファイアウォールは、サーバー上の他のすべてのサービスに対する適切な基盤です。
自分のサーバーでバイパスを確認する
受信トラフィックのデフォルトポリシーが deny に設定され、UFW が有効な VPS から始めます。公開ポートを指定して Web コンテナを起動します。
sudo ufw status verbose
docker run -d --name web -p 8080:80 nginx:1.29-alpineufw status verbose には Default: deny (incoming), allow (outgoing) が表示され、ポート 8080 のルールは表示されません。ファイアウォール自身のレポートでは、ポートは閉じています。次に、サーバー自身ではなく別のマシンからテストします。
curl -I http://your-vps-ip:8080/HTTP/1.1 200 OKコンテナは応答します。明示的な deny ルールを追加し、もう一度テストします。
sudo ufw deny 8080/tcpポートは引き続き応答します。deny ルールが、パケットが通過しないチェーンに置かれているためです。UFW が失敗したのではありません。UFW は最初から参照されていませんでした。この問題が見つかりにくい理由もここにあります。どこにもエラーは表示されず、デプロイは成功し、ファイアウォールのステータス出力も、適切に保護されたサーバーとまったく同じに見えます。
仕組み: PREROUTING は INPUT より先に実行される
カーネルは受信パケットを固定された順序で処理します。この問題は、その順序によって発生します。
PREROUTINGが最初に実行されます。ここでのルールはパケットの宛先を書き換えられます。Docker が公開ポートに対して設定するルールも、まさにこの処理を行います。- 次にルーティング判定が行われます。ホスト自身宛てのパケットは
INPUTchain に送られます。それ以外のマシン宛てのパケットはFORWARDchain に送られます。 - UFW のルールは
INPUTにあります。Docker のルールはFORWARDにあります。
起動したコンテナに対する Docker のルールを確認します。
sudo iptables -t nat -L DOCKER -nChain DOCKER (2 references)
target prot opt source destination
RETURN 0 -- 0.0.0.0/0 0.0.0.0/0
DNAT 6 -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:8080 to:172.17.0.2:80DNAT の行がすべてを示しています。port 8080 宛てに到着したパケットは、宛先が 172.17.0.2:80 に書き換えられます。これは Docker のプライベート bridge network 上にあるコンテナのアドレスです。書き換え後、パケットはホスト宛てではなくなるため、ルーティング判定によって FORWARD の経路へ送られます。そこには、Docker が自分の network へのトラフィックを許可するルールをすでに追加しています。deny 8080/tcp のルールは、決して到着しないパケットを INPUT で待ち続けます。
Ubuntu 24.04 では、iptables command は nftables のフロントエンドですが、chain の順序と結果は同じです。UFW と Docker は同じカーネルのパケット処理経路にルールを書き込みますが、Docker の入口のほうが前にあります。これは UFW 固有の問題ではありません。Rocky または AlmaLinux の VPS における firewalld も、同じ処理経路上の同じ位置でフィルタリングするため、同じ DNAT rule によって迂回されます。したがって、以下の修正方法は firewalld にも適用できます。
日常的な対処: 127.0.0.1 でポートを公開する
そもそも、ほとんどのコンテナを外部公開する必要はありません。データベース、リバースプロキシの背後にあるアプリケーションサーバー、管理パネル、メトリクスエンドポイントは、いずれもインターネットから直接応答させるべきではありません。ループバックアドレスで公開します。
docker run -d --name web -p 127.0.0.1:8080:80 nginx:1.29-alpineCompose ファイルでは、次のように記述します。
services:
web:
image: nginx:1.29-alpine
ports:
- "127.0.0.1:8080:80"これは、Docker の DNAT ルールが 127.0.0.1 宛てのパケットだけに一致するためです。インターネットからのパケットが、この宛先を正当に指定することはありません。そのため、カーネルはファイアウォールルールが実行される前にパケットを破棄します。ポートに接続できるのはホスト自身からだけです。バインドを確認します。
sudo ss -tlnp | grep 8080出力には 127.0.0.1:8080 が表示され、0.0.0.0:8080 や [::]:8080 は表示されないことを確認します。次に、別のマシンから curl http://your-vps-ip:8080/ が拒否されることを確認します。
インターネットに公開するサービスでは、ポート 80 と 443 を管理し、ホスト名に応じて転送するリバースプロキシを 1 つ稼働させ、それ以外は公開しません。これは Traefik リバースプロキシガイドで構成する方式です。VPS 上の Nextcloud のような自己ホスト型アプリケーションも、プロキシ経由以外では到達できない状態にできます。ports: エントリの宣言方法と、Compose のその他の手順については、Docker Compose 基本ガイドで説明します。
すべての内部コンテナをループバックにバインドすると、UFW は本来の役割に戻ります。つまり、ホスト自身が提供するポートを保護します。ここでルールセットを作成し、次のコマンドを順番に実行します。
実際のフィルタリング: DOCKER-USER chain
コンテナのポートをネットワークに公開したまま、アクセス元を制限しなければならない場合があります。たとえば、データベースレプリカ用ポートへの接続を 1 つのオフィスアドレスだけに許可する場合です。この用途のために、Docker は DOCKER-USER chain を提供しています。コンテナへ向かうすべてのパケットは、Docker 独自の accept ルールより前に DOCKER-USER を通過します。Docker がこの chain にルールを書き込むことはありません。この chain はユーザーが使用するためのものであり、Docker は daemon の再起動後も内容を変更しません。
コマンドを実行する前に、1 つ注意が必要です。パケットが DOCKER-USER に到達する時点では、すでに DNAT の書き換えが行われています。パケットの宛先ポートは公開ポートの 8080 ではなく、コンテナのポート(この例では 80)です。そのため、--dport 8080 に一致するルールは何も捕捉しません。確実な方法は、クライアントが最初に接続したポートを指定することです。この情報は kernel の connection tracker が保持しています。
sudo iptables -I DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROPこれは、eth0 から入ってきたパケットのうち、元の宛先ポートが 8080 である connection に属し、10.0.0.10 から送信されていないものをすべて drop する、という意味です。--ctdir ORIGINAL match により、ルールの対象をクライアントからコンテナへの方向に限定します。そのため、reply パケットを誤って捕捉しません。eth0 は公開インターフェース名に置き換えてください。ip route | grep default でその名前を確認できます。これまでと同じ方法でテストします。許可したアドレスからは curl が成功し、それ以外の場所からは connection が timeout します。この停止状態は、背後に何もないポートではなく、DROP rule が機能していることを示します。接続拒否と timeout の違いを確認するのが、ポートがフィルタリングされているのか、単に service が listen していないのかを判別する最も早い方法です。
iptables command で追加した rules は reboot すると消えます。すでに UFW がこの firewall を管理しているため、永続化する場所として適切なのは /etc/ufw/after.rules です。ファイルの末尾に block を追加します。
*filter
:DOCKER-USER - [0:0]
-A DOCKER-USER -i eth0 -p tcp -m conntrack --ctorigdstport 8080 --ctdir ORIGINAL ! -s 10.0.0.10 -j DROP
COMMIT続いて sudo ufw reload を実行します。UFW は reload のたび、および boot のたびにこのファイルを再適用します。そのため、コンテナの filtering は他の firewall 設定と同じ場所で管理され、reboot と Docker upgrade のどちらを行っても維持されます。
Docker の iptables 統合を無効にしてはいけない理由
この問題に対する古い回答では、/etc/docker/daemon.json に { "iptables": false } を設定するよう提案しています。設定しないでください。Docker のファイアウォールルールは、ポートの公開以外にも多くの処理を担います。masquerade ルールにより、コンテナはホストのアドレスを介して外部インターネットへアクセスできます。したがって統合を無効にすると、コンテナはイメージを pull できず、パッケージミラーへ接続できず、外部 API(application programming interface)も呼び出せません。DNAT ルールにより -p が機能しているため、公開ポートも完全に機能しなくなります。分離された Compose ネットワーク間の通信を隔離するルールも失われます。この設定による回避を行うと、コンテナネットワークを壊すことになり、これらのルールをすべて自分で作成して手動で保守する必要があります。Docker の公式ドキュメントでも、この設定はまさにその運用を意図するユーザー向けと説明されています。DOCKER-USER chain は、このスイッチを誰も必要としないようにするために存在します。
同じ問題の IPv6 側
まず、IPv6 で公開ポートがどのように見えるかを確認します。
sudo ss -tlnp | grep 8080Docker Engine 27 以降、Docker はデフォルトで ip6tables を管理します。IPv6 を有効にした Docker network では、公開ポートに対して IPv6 のテーブルでも同じ DNAT 処理が行われるため、同じバイパスが存在し、同じ修正が適用されます。DOCKER-USER chain は ip6tables にも存在するため、sudo ip6tables -I DOCKER-USER ... でルールを追加し、curl -6 http://[2001:db8:2a::1]:8080/ のようにサーバーの公開 IPv6 アドレスに対して curl を実行して外部からテストします。
IPv6 を使用しない network では、IPv6 クライアントは代わりに docker-proxy で処理されます。これはユーザー空間で動作する通常のプロセスで、[::]:8080 を listen し、IPv4 経由でコンテナへトラフィックを転送します。ホストプロセス宛てのトラフィックは INPUT を通るため、UFW でその経路をフィルタリングできます。ただし、UFW 自体が IPv6 を管理している場合に限ります。IPv6 を管理しているかどうか、および VPS で IPv6 の抜け道が生じるその他の経路については、UFW と IPv6 のガイドで説明します。
loopback に公開すれば、この問題全体を回避できます。-p 127.0.0.1:8080:80 は IPv4 の loopback のみで bind するため、IPv6 の listener は存在せず、どちらのプロトコルスタックでも外部から到達できません。
問題なく維持できる構成
- すべての内部ポートを
127.0.0.1に公開し、最初から外部に露出しないようにします。 - 公開側は、80 と 443 番ポートを管理する 1 つのリバースプロキシに集約します。
- ホストでは UFW のデフォルト拒否を維持し、SSH とプロキシのポートだけを許可します。
- 実際に公開するコンテナポートは、元の宛先ポートに基づいて
DOCKER-USERでフィルタリングし、/etc/ufw/after.rulesに永続化します。 - Docker の iptables 連携は有効のままにします。
一度設定すれば、想定外の公開がなくなります。ufw status はホストを、DOCKER-USER はコンテナをそれぞれ表します。意図せず公開されるポートはなくなり、次に入力する docker run -p では、意図したポートだけが正確に公開されます。
FAQ
Docker コンテナに到達できるのは、UFW がポートをブロックしているのになぜですか?
Docker は PREROUTING チェーンの DNAT ルールでポートを公開し、フィルタリングが行われる前にパケットの宛先をコンテナのアドレスへ書き換えるためです。パケットはその後 FORWARD の経路を通ります。UFW のルールは INPUT にあるため、パケットはこのチェーンに入りません。ファイアウォールは確認されないので、UFW の拒否ルールは公開されたコンテナポートに影響しません。
Docker が公開したポートを UFW でブロックするにはどうすればよいですか?
UFW のルールは適用されるチェーンが異なるため、UFW 自体ではブロックできません。ポートを公開せず、127.0.0.1:8080:80 として公開してホストからのみ到達できるようにします。または、元の宛先ポートを conntrack で照合する iptables ルールを DOCKER-USER チェーンに設定します。そのルールを /etc/ufw/after.rules に永続化すると、再起動後も維持され、ufw reload ます。
Docker の daemon.json で "iptables": false を設定すべきですか?
いいえ。この設定を使うと Docker のファイアウォールルールと NAT ルールがすべて削除され、ポートのバイパス以外にも多くの機能が壊れます。masquerade ルールがなくなるため、コンテナから外部インターネットへ接続できなくなります。また、DNAT ルールがなくなるため、公開ポートも機能しません。代わりにループバック公開と DOCKER-USER チェーンを使用してください。コンテナネットワークを壊さずに公開範囲を制限できます。
Docker は IPv6 でも UFW をバイパスしますか?
Docker Engine 27 以降では ip6tables の管理がデフォルトで有効です。そのため、IPv6 対応の Docker ネットワークで公開したポートは IPv4 と同様に UFW を迂回する形で書き換えられ、DOCKER-USER ルールにも ip6tables を反映する必要があります。IPv6 を使用しないネットワークでは、docker-proxy プロセスが [::] で待ち受けます。このトラフィックは INPUT を通るため、UFW が IPv6 を管理していればフィルタリングできます。127.0.0.1 で公開すれば、IPv6 では何も待ち受けないため、どちらのケースも回避できます。