SSD Nodes Learn Hosting plans →
ガイド Matt Connor著者 Matt Connor

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-alpine

ufw 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 は受信パケットを決まった順序で処理します。この問題は、その順序によって発生します。

  1. PREROUTING が最初に実行されます。ここから到達するルールでは、パケットの宛先を書き換えられます。公開ポートに対する Docker のルールは、PREROUTING がジャンプする DOCKER chain 内で、実際にこの処理を行います。
  2. 次に routing decision が行われます。ホスト自身宛てのパケットは INPUT chain に進みます。別の machine 宛てのパケットは FORWARD chain に進みます。
  3. UFW のルールは INPUT にあります。Docker のルールは FORWARD にあります。

起動したばかりの container に対する Docker のルールを確認します。

sudo iptables -t nat -L DOCKER -n
Chain 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:80

DNAT の行がすべてを説明しています。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 は本来の役割に戻ります。つまり、ホスト自身が提供するポートを保護します。ここでそのルールセットを作成し、次にコマンドを順番に実行します。

ToolUFW rule generator

実際のフィルタリング: 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 8080

Docker 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 での待ち受け自体が発生しないため、いずれのケースも回避できます。