DockerがUFWをバイパスする原因と解決策
DockerがiptablesのDNATルールを直接書き込むため、UFWでdeny設定をしてもポートが外部からアクセスできてしまいます。この仕組みの理由と、docker-compose.ymlでの設定変更やiptablesのchainを利用した具体的な修正方法を詳しく解説します。
なぜ Docker は UFW をバイパスするのか
Docker が UFW をバイパスする理由は、公開されたコンテナポートが UFW の管理するファイアウォールルールを通過しないためです。docker run -p 8080:80 を実行すると、Docker はカーネルの nat テーブル内の PREROUTING チェインに DNAT (destination network address translation) ルールを書き込みます。このルールは、カーネルがパケットの転送先を決定する前に、各パケットの宛先をコンテナのプライベートアドレスに書き換えます。書き換えられたパケットは、Docker が制御する FORWARD チェインを通じてコンテナへ転送されます。UFW のルールは INPUT チェインに存在するため、パケットがそこに入ることはありません。その結果、ufw status はデフォルト拒否を示し、sudo ufw deny 8080 は成功を報告しますが、ポート 8080 は依然としてインターネット全体からの接続に応答します。
これは Docker のバグではなく、UFW の不具合でもありません。両方のツールは同じカーネルファイアウォールを操作しています。Docker のルールはパケットの経路におけるより早い段階で作用するため、UFW は呼び出されません。このガイドでは、このバイパス現象を実証し、その仕組みを説明した上で、有効な 2 つの修正策(127.0.0.1 でポートを公開する方法、および DOCKER-USER チェインでフィルタリングする方法)について解説します。UFW 自体が初めてである場合は、まず UFW ファイアウォールの基本ガイド に従ってセットアップを行ってください。デフォルト拒否のファイアウォールは、サーバー上の他のすべての設定の基礎として適切であるためです。
自身のサーバーでバイパスを確認する
UFWが有効で、着信トラフィックに対してデフォルトのdenyポリシーが設定されている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)を表示しますが、port 8080に対するルールは存在しません。ファイアウォールのレポートによれば、このポートは閉じられています。次に、サーバー自身ではなく、別のマシンからテストを行います。
curl -I http://your-vps-ip:8080/HTTP/1.1 200 OKコンテナが応答します。明示的なdenyルールを追加して、再度テストしてください。
sudo ufw deny 8080/tcpポートは依然として応答します。これは、denyルールがパケットが通過しないchainにあるためです。UFWの失敗ではありません。ルールが参照されなかっただけです。これが、この問題が発見されにくい理由でもあります。どこにもエラーは表示されず、デプロイは成功し、ファイアウォールのステータス出力は正常にロックされたサーバーと同じに見えるからです。
メカニズム: PREROUTINGはINPUTより先に実行されます
カーネルは受信パケットを固定された順序で処理します。問題の原因はこの順序にあります。
PREROUTINGが最初に実行されます。ここでのルールはパケットの宛先を書き換える可能性があります。Dockerの公開ポートに関するルールはまさにこれを行います。- 次にルーティングの決定が行われます。ホスト自体を宛先とするパケットは
INPUTチェーンへ送られます。他のマシンを宛先とするパケットはFORWARDチェーンへ送られます。 - 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 の行がすべてを説明しています。ポート 8080 に届くすべてのパケットは、宛先が 172.17.0.2:80(Dockerのプライベートブリッジネットワーク上のコンテナのアドレス)に書き換えられます。書き換え後、パケットの宛先はホストではなくなるため、ルーティング決定によって FORWARD パスへと送られます。Dockerはすでにそのパスに、自身のネットワークへのトラフィックを許可するルールを追加しています。あなたの deny 8080/tcp ルールは、INPUT でパケットを待ち続けていますが、パケットはそこには届きません。
Ubuntu 24.04 では、iptables コマンドは nftables のフロントエンドですが、チェーンの順序と結果は同一です。UFWとDockerはどちらも同じカーネルのパケットパイプラインに書き込みますが、Dockerの開始地点の方が早いです。
一般的な解決策: 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 宛てのパケットのみに一致するためです。インターネットからのパケットがこの宛先を持つことは正当な方法ではあり得ないため、カーネルはファイアウォールのルールが実行される前にパケットを破棄します。これにより、ポートはホストからのみアクセス可能になり、それ以外からはアクセスできなくなります。バインディングを確認してください。
sudo ss -tlnp | grep 8080出力結果が 0.0.0.0:8080 や [::]:8080 ではなく、127.0.0.1:8080 である必要があります。次に、別のマシンから curl http://your-vps-ip:8080/ が拒否されることを確認してください。
インターネットに公開する必要があるサービスについては、80 および 443 ポートを占有し、ホスト名でルーティングを行うリバースプロキシを1つ実行し、それ以外のポートは何も公開しないでください。これが Traefik reverse proxy guide で構築されているパターンであり、Nextcloud on a VPS のようなセルフホストアプリがプロキシ経由以外からアクセスされないようにするための方法です。ports: エントリの宣言方法とその他の Compose ワークフローについては、the Docker Compose basics guide で説明しています。
すべての内部コンテナを loopback で動作させることで、UFW は本来の役割、つまりホスト自体が提供するポートの保護に戻ります。ここでルールセットを作成し、以下のコマンドを順番に実行してください。
実践的なフィルタリング: DOCKER-USER chain
コンテナのポートをネットワークに公開したまま、特定のアクセスのみに制限したい場合があります。例えば、特定のオフィスのアドレスからのみアクセス可能なデータベースのレプリカポートなどが挙げられます。このために、Dockerは DOCKER-USER chainを提供しています。すべてのコンテナ宛てのパケットは、Docker自身のacceptルールを通過する前に DOCKER-USER を通過します。Dockerがこのchainにルールを書き込むことはありません。このchainはユーザーが管理するためのものであり、Docker daemonを再起動しても内容は保持されます。
コマンドを実行する前の注意点があります。パケットが 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 から送信されたもの以外をすべてドロップする」という意味です。--ctdir ORIGINAL マッチを使用することで、ルールをクライアントからコンテナへの方向のみに限定しています。これにより、応答パケットが誤ってブロックされるのを防ぎます。eth0 を公開インターフェースに、ip route | grep default をその名前に置き換えてください。以前と同様にテストを行います。許可されたアドレスからの curl は成功し、それ以外の場所からは接続がタイムアウトします。
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はリロード時および起動時にこのファイルを読み込むため、コンテナのフィルタリング設定は他のファイアウォール設定と同じ場所に保存され、再起動やDockerのアップグレード後も維持されます。
Dockerのiptables integrationを無効にすべきでない理由
この問題に対する古い回答では、/etc/docker/daemon.jsonで{ "iptables": false }を設定することが推奨されています。しかし、設定しないでください。Dockerのfirewall rulesは、単なるポートの公開以上の役割を果たしています。masquerade ruleは、コンテナがホストのIPアドレスを介して外部インターネットにアクセスするために必要です。このintegrationをオフにすると、コンテナはimageのpull、パッケージミラーへの接続、外部APIへのリクエストができなくなります。DNAT rulesは-pを動作させるために不可欠であり、無効にすると公開ポートが完全に機能しなくなります。また、Composeの各networkを分離するためのisolation rulesも失われます。この設定でバイパスを解決しようとすると、コンテナのnetworkingを破壊することになります。その場合、すべてのruleを手動で記述し、管理しなければなりません。Dockerの公式ドキュメントでは、この設定はまさにそのような作業を意図しているユーザー向けであると説明されています。DOCKER-USER chainが存在する理由は、このswitchを必要とさせないためです。
同様の問題における IPv6 の側面
まず、IPv6 で公開されているポートの状態を確認してください:
sudo ss -tlnp | grep 8080Docker Engine 27 以降、Docker はデフォルトで ip6tables を管理します。IPv6 が有効な Docker network では、公開されたポートは IPv6 tables でも同様の DNAT 処理を受けます。そのため、同様のバイパスが発生し、同じ修正方法が適用されます。DOCKER-USER chain は ip6tables にも存在するため、sudo ip6tables -I DOCKER-USER ... を使用してルールをミラーリングしてください。その後、サーバーのパブリック IPv6 アドレス(例:curl -6 http://[2001:db8:2a::1]:8080/)に対して、外部から curl でテストを行います。
IPv6 が無効な network では、IPv6 クライアントは代わりに docker-proxy によって処理されます。これは [::]:8080 で listen し、IPv4 を介してコンテナへトラフィックを転送する通常のユーザ空間プロセスです。ホストプロセスへのトラフィックは INPUT を通過するため、UFW がその経路をフィルタリングできます。ただし、これは UFW が IPv6 を管理している場合に限ります。UFW が管理しているか、および VPS で IPv6 の隙間が生じるその他の要因については、UFW と IPv6 のガイド を参照してください。
loopback での公開は、この問題自体を回避します。-p 127.0.0.1:8080:80 は IPv4 loopback のみに bind するため、IPv6 listener は存在せず、どちらのスタックからも外部から到達することはできません。
構成の基本原則
- すべての内部ポートを
127.0.0.1で公開し、最初から外部に露出しないようにします。 - 80番と443番のポートを所有する、1つの reverse proxy にパブリック側の通信を任せます。
- ホストの UFW はデフォルトで deny に設定し、SSH と proxy のポートのみを許可します。
DOCKER-USERで、本来公開すべきコンテナポートをフィルタリングします。これは元の destination port に基づき、/etc/ufw/after.rulesに保存されます。- Docker の iptables 連携は有効のままにします。
一度設定すれば、予期せぬ露出を防げます。ufw status はホストについて、DOCKER-USER はコンテナについて説明しています。誤って公開されることはなく、次に docker run -p を実行したときには、意図した通りにのみ公開されます。
FAQ
UFWがポートをブロックしているのに、なぜDockerコンテナにアクセスできるのですか?
DockerがPREROUTINGチェーンのDNATルールを使用してポートを公開しているためです。これにより、フィルタリングが行われる前に、パケットの送信先がコンテナのアドレスに書き換えられます。パケットはFORWARDパスを通過しますが、UFWのルールはINPUTチェーンにあり、パケットはこのチェーンを通過しません。ファイアウォールは参照されないため、公開されたコンテナポートに対してdenyルールは効果を発揮しません。
UFWでDockerの公開ポートをブロックするにはどうすればよいですか?
UFW単体では不可能です。UFWのルールは異なるチェーンにあるためです。解決策は2つあります。1つは、ポートを127.0.0.1:8080:80として公開し、ホストのみがアクセスできるようにして公開を停止することです。もう1つは、conntrackを使用して元の送信先ポートに一致するiptablesルールをDOCKER-USERチェーンで適用することです。このルールを/etc/ufw/after.rulesに保存すれば、再起動やufw reload後も維持されます。
Dockerのdaemon.jsonで "iptables": false を設定すべきですか?
いいえ。その設定はDockerのすべてのファイアウォールおよびNATルールを削除します。これにより、回避問題の解決以上に多くの問題が発生します。masqueradeルールが削除されるため、コンテナは外部インターネットへのアクセスができなくなり、DNATルールが削除されるため、公開ポートも機能しなくなります。代わりに、loopback公開と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で何も待機しないため、両方のケースを回避できます。