DockerでUFWをバイパスする理由と対策
Dockerの公開ポートがUFWのdenyを無視する理由を解説します。iptablesのDNATで8080番ポートが外部から応答する仕組みと、機能する2つの対策を紹介します。
Docker が UFW を迂回する理由
Docker が UFW を迂回するのは、公開されたコンテナポートが、UFW の管理するファイアウォールルールを通過しないためです。docker run -p 8080:80 を実行すると、Docker はカーネルの nat テーブルにある独自の DOCKER チェーンへ DNAT(宛先ネットワークアドレス変換)ルールを書き込みます。PREROUTING チェーンはこのチェーンへジャンプします。このルールにより、カーネルがパケットの送信先を決定する前に、各パケットの宛先がコンテナのプライベートアドレスへ書き換えられます。書き換えられたパケットは、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 より先に実行される
kernel は受信パケットを決まった順序で処理します。この問題は、その順序によって発生します。
PREROUTINGが最初に実行されます。ここから到達するルールでは、パケットの宛先を書き換えられます。公開ポートに対する Docker のルールは、PREROUTINGがジャンプするDOCKERchain 内で、実際にこの処理を行います。- 次に routing decision が行われます。ホスト自身宛てのパケットは
INPUTchain に進みます。別の machine 宛てのパケットはFORWARDchain に進みます。 - UFW のルールは
INPUTにあります。Docker のルールはFORWARDにあります。
起動したばかりの container に対する 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 の private bridge network 上にある container の address です。書き換え後、パケットはホスト宛てではなくなるため、routing decision により FORWARD の経路へ進みます。そこには Docker が自分の network への traffic を許可するルールをすでに追加しています。deny 8080/tcp のルールは、決して到着しないパケットを INPUT で待ち続けます。
Ubuntu 24.04 では、iptables command は nftables の front end ですが、chain の順序と結果は同じです。UFW と Docker は同じ kernel の packet pipeline にルールを書き込み、Docker の entry point のほうが前にあります。これは UFW 固有の問題ではありません。Rocky または AlmaLinux の VPS 上の firewalld も同じ pipeline の同じ位置で filtering を行い、同じ DNAT rule によって迂回されます。そのため、以下の修正方法はそこでも使用できます。
日常的な対策: ポートを 127.0.0.1 で公開する
そもそも、ほとんどのコンテナは外部公開する必要がありません。データベース、リバースプロキシの背後にあるアプリケーションサーバー、管理パネル、メトリクスエンドポイントなどは、インターネットから直接応答させるべきではありません。loopback アドレスで公開します。
docker run -d --name web -p 127.0.0.1:8080:80 nginx:1.29-alpineまたは、Compose ファイルで次のように指定します。
services:
web:
image: nginx:1.29-alpine
ports:
- "127.0.0.1:8080:80"これは、Docker の DNAT ルールが 127.0.0.1 宛てのパケットだけに一致するようになるためです。さらに Docker Engine 28.0.0 以降では、Docker が raw テーブルの PREROUTING chain にもルールを追加します。このルールは、loopback 以外のインターフェースから到着した、その loopback ポート宛てのパケットを破棄します。これらのリリースでは、ポートに接続できるのはホスト自身からだけです。28.0.0 より前のリリースでは、同じ L2 セグメント上のホスト、たとえば同じネットワークスイッチに接続されたマシンから、localhost に公開されたポートへ接続できる場合があると Docker は説明しています。そのため、これに依存する前に docker version を確認してアップグレードしてください。バインドを確認します。
sudo ss -tlnp | grep 8080出力には 127.0.0.1:8080 が表示され、0.0.0.0:8080 や [::]:8080 ではないことを確認します。次に、別のマシンから curl http://your-vps-ip:8080/ が応答しないことを確認します。公開アドレス宛てに送信されたパケットは、Docker によって書き換えられなくなります。そのため INPUT に到達し、UFW のデフォルトの deny ポリシーと、前述の deny 8080/tcp ルールによって、拒否応答ではなく静かに破棄されます。したがって curl は停止したようになり、"connection refused" ではなく接続タイムアウトで終了します。
インターネットに公開するサービスについては、80 番ポートと 443 番ポートを管理し、ホスト名に応じてルーティングするリバースプロキシを 1 つ実行します。それ以外のポートは公開しません。これは Traefik リバースプロキシガイドで構成するパターンです。また、VPS 上の Nextcloud のような自己ホスト型アプリケーションを、プロキシ経由以外から到達できないようにする方法でもあります。ports: エントリの宣言方法と、Compose のその他の手順については、Docker Compose 基礎ガイドで説明しています。
すべての内部コンテナを loopback 上に配置すると、UFW は本来の役割に戻ります。つまり、ホスト自身が提供するポートを保護します。ここでそのルールセットを作成し、次にコマンドを順番に実行します。
実際のフィルタリング: DOCKER-USER チェーン
コンテナのポートをネットワークに公開したまま、接続元を制限しなければならない場合があります。たとえば、データベースのレプリカ用ポートへの接続を、1 つのオフィスのアドレスだけに許可する場合です。この用途のために、Docker は DOCKER-USER チェーンを提供しています。コンテナへ向かうすべてのパケットは、Docker 独自の accept ルールに到達する前に DOCKER-USER を通過します。このチェーンはユーザーがルールを追加するためのもので、Docker がそのルールを削除または変更することはありません。daemon の再起動後も同様です。
このチェーンは、Docker のデフォルトの iptables ファイアウォールバックエンドに属します。Docker Engine 29 では実験的な nftables バックエンドが追加されました。これを使用するには、firewall-backend daemon オプションで有効化する必要があります。nftables では DOCKER-USER チェーンは存在しません。カスタムフィルタリングは、独自に用意した別の nftables テーブルに、forward hook の base chain を作成して設定します。このオプションを変更していなければ、使用しているのは iptables であり、以下の手順がそのまま適用されます。2 つのツールの違いについては、iptables と nftables の違いに関するガイドで説明しています。
コマンドを実行する前に、注意すべき点があります。パケットが DOCKER-USER に到達する時点では、DNAT による書き換えがすでに完了しています。パケットの宛先ポートは公開ポートの 8080 ではなく、コンテナのポートである 80 です。そのため、--dport 8080 に一致するルールは何にも一致しません。確実な方法は、クライアントが最初に接続したポートに一致させることです。この情報はカーネルの 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 である接続に属するパケットのうち、10.0.0.10 から送信されたもの以外をすべて drop する、という意味です。--ctdir ORIGINAL match により、ルールの対象はクライアントからコンテナへの方向に限定されます。そのため、返信パケットを誤って捕捉しません。eth0 は外部公開用インターフェースに置き換えてください。ip route | grep default でその名前を確認できます。テスト方法は前と同じです。許可したアドレスからの curl は成功し、それ以外からは接続がタイムアウトします。このハングアップは、背後に何もないポートではなく、DROP ルールが機能していることを示します。拒否される接続とタイムアウトする接続の違いを確認すれば、フィルタリングされたポートと、単に待ち受けていないサービスを最も早く区別できます。
iptables コマンドで追加したルールは、再起動すると消えます。UFW がこのファイアウォールをすでに管理しているため、ルールを永続化する場所として適切なのは /etc/ufw/after.rules です。ファイルの末尾にブロックを追加します。
*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 と起動のたびにこのファイルを再適用します。これで、コンテナのフィルタリングは他のファイアウォール設定と同じ場所で管理され、再起動後も Docker のアップグレード後も維持されます。
Docker の iptables 連携を無効にしてはいけない理由
この問題に対する古い回答では、/etc/docker/daemon.json に { "iptables": false } を設定するよう勧めています。これは行わないでください。Docker のファイアウォールルールは、ポートの公開だけを行うものではありません。masquerade ルールにより、コンテナはホストのアドレスを経由して外部インターネットへアクセスできます。そのため、連携を無効にすると、コンテナはパッケージミラーに接続したり、外部 API(application programming interface)を呼び出したりできなくなります。DNAT ルールと分離ルールもなくなり、代替ルールがない場合はコンテナのポートがローカルネットワーク上のホストからアクセス可能になると、Docker のドキュメントは警告しています。この抜け道を防ぐにはコンテナのネットワーク機能を壊すことになり、これらのルールをすべて手作業で作成して保守する必要があります。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 address に対して curl を実行し、外部からテストします。これは Docker のデフォルトの iptables backend の場合です。実験的な nftables backend では、どちらの stack にも DOCKER-USER chain はありません。その場合、IPv6 の filtering は独自の ip6 nftables table に記述します。
IPv6 のない network では、IPv6 client は代わりに docker-proxy で処理されます。これは [::]:8080 で待ち受ける通常の user-space process で、traffic を IPv4 経由で container に転送します。host process への traffic は INPUT を通るため、UFW でこの経路を filtering できます。ただし、UFW が IPv6 自体を管理している場合に限ります。UFW が IPv6 を管理しているかどうか、および VPS で IPv6 の抜け道が生じるその他の経路については、UFW と IPv6 のガイドで説明します。
loopback に公開すれば、この問題全体を回避できます。-p 127.0.0.1:8080:80 は IPv4 loopback のみで bind するため、IPv6 listener は存在しません。また、Docker Engine 28.0.0 以降では、どちらの stack でも外部から到達できるものはありません。
崩れにくい構成
- Docker Engine 28.0.0 以降では、すべての内部ポートを
127.0.0.1に公開します。最初から外部に公開されません(古いリリースでは、同じ L2 セグメント上のホストから到達できました)。 - 公開側は、80 と 443 のポートを管理する 1 つのリバースプロキシに集約します。
- ホストでは UFW のデフォルト拒否を維持し、SSH とプロキシのポートだけを許可します。
- Docker のデフォルトの iptables バックエンドでは、実際に公開するコンテナのポートを
DOCKER-USERでフィルタリングします。元の宛先ポートに基づいて照合し、/etc/ufw/after.rulesに永続化します。 - Docker の iptables 統合は有効なままにします。
1 回設定すれば、予期しない公開を防げます。ufw status にはホストの設定を記述し、DOCKER-USER にはコンテナの設定を記述します。意図せず公開されるポートはなくなり、次に入力する docker run -p では、意図したものだけが正確に公開されます。
FAQ
UFW でポートをブロックしているのに、なぜ Docker コンテナにアクセスできるのですか?
Docker は nat テーブル内の DOCKER チェーンに DNAT ルールを設定してポートを公開するためです。このルールは PREROUTING から到達し、フィルタリングが行われる前にパケットの宛先をコンテナの IP アドレスに書き換えます。その後、パケットは FORWARD パスを通過しますが、UFW のルールはパケットが到達しない INPUT チェーンに配置されています。ファイアウォールが参照されないため、拒否ルールは公開されたコンテナポートに対して何の効果も持ちません。
Docker が公開したポートを UFW でブロックするにはどうすればよいですか?
UFW のルールは適切なチェーンに存在しないため、UFW 単体では不可能です。Docker Engine 28.0.0 以降であれば、127.0.0.1:8080:80 として公開することでホストからのみアクセス可能にし、ポートの露出を避けてください。あるいは、conntrack を介して元の宛先ポートに一致する iptables ルールを DOCKER-USER チェーンに追加してフィルタリングします。そのルールを /etc/ufw/after.rules に保存し、再起動や ufw reload 後も有効な状態を維持してください。
Docker の daemon.json で "iptables": false を設定すべきですか?
いいえ。その設定を行うと Docker のファイアウォールおよび NAT ルールの大部分が削除され、バイパスの問題以上に多くの機能が損なわれます。マスカレードルールが消滅するためコンテナはインターネットへのアウトバウンド通信を失い、Docker は代替ルールがない場合にコンテナポートがローカルネットワーク上のホストからアクセス可能になる旨を警告しています。ループバック公開と DOCKER-USER チェーンを使用してください。これらはコンテナのネットワーク機能を損なうことなく露出の問題を解決します。
Docker は IPv6 でも UFW をバイパスしますか?
Docker Engine 27 以降では ip6tables の管理がデフォルトで有効なため、IPv6 対応の Docker ネットワークで公開されたポートは IPv4 と同様に UFW を迂回して書き換えられます。Docker のデフォルトの iptables バックエンドでは、ip6tables を使用して DOCKER-USER ルールをミラーリングする必要があります。Engine 29 からの実験的な nftables バックエンドには DOCKER-USER チェーンが存在しないため、独自の nftables テーブルでフィルタリングを行う必要があります。IPv6 を使用しないネットワークでは、docker-proxy プロセスが [::] で待ち受けを行い、そのトラフィックは INPUT を通過するため、UFW が IPv6 を管理していればフィルタリングが可能です。127.0.0.1 で公開すれば IPv6 での待ち受け自体が発生しないため、いずれのケースも回避できます。