Почему Docker игнорирует UFW и как это исправить
Docker добавляет правила в iptables напрямую, минуя цепочку INPUT, где работает UFW. Узнайте, как ограничить доступ к портам контейнеров через конфигурацию DOCKER-USER.
Почему Docker обходит UFW
Docker обходит UFW, так как опубликованные порты контейнеров никогда не проходят через правила брандмауэра, которыми управляет UFW. Когда вы запускаете docker run -p 8080:80, Docker записывает правило DNAT (destination network address translation) в свою собственную цепочку DOCKER в таблице nat ядра, на которую перенаправляет цепочка PREROUTING. Это правило перезаписывает адрес назначения каждого пакета на частный адрес контейнера до того, как ядро решит, куда направляется пакет. Затем переписанный пакет пересылается в контейнер через цепочку FORWARD, которую контролирует Docker. Правила UFW находятся в цепочке INPUT, и пакет никогда в неё не попадает. Поэтому ufw status показывает политику по умолчанию «запретить», sudo ufw deny 8080 сообщает об успехе, а порт 8080 всё равно отвечает всему Интернету.
Это не ошибка Docker, и UFW не сломан. Оба инструмента программируют один и тот же брандмауэр ядра. Правила Docker просто действуют на более раннем этапе пути пакета, поэтому UFW даже не получает запрос. В этом руководстве демонстрируется обход, объясняется механизм, а затем рассматриваются два работающих исправления: публикация портов на 127.0.0.1 и фильтрация в цепочке DOCKER-USER. Если UFW для вас в новинку, сначала настройте его с помощью руководства по основам брандмауэра UFW, так как брандмауэр с политикой запрета по умолчанию остаётся правильной базой для всего остального на сервере.
Проверка обхода защиты на собственном сервере
Начните с VPS, на котором активен UFW с политикой default deny для входящего трафика. Запустите веб-контейнер с опубликованным портом:
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Контейнер отвечает. Добавьте явное правило запрета и протестируйте снова:
sudo ufw deny 8080/tcpПорт по-прежнему отвечает, так как правило запрета находится в цепочке, до которой пакет не доходит. UFW не дал сбой. К нему просто не обращались. Именно поэтому проблему так трудно обнаружить: нигде не выводится ошибок, развертывание проходит успешно, а статус межсетевого экрана выглядит в точности как у исправно защищенного сервера.
Механизм: PREROUTING выполняется перед INPUT
Ядро обрабатывает входящий пакет в строго определенном порядке, и вся проблема заключается именно в этой последовательности.
PREROUTINGвыполняется первым. Правила, применяемые на этом этапе, могут изменить адрес назначения пакета. Правило Docker для опубликованного порта в цепочкеDOCKER, куда перенаправляетPREROUTING, делает именно это.- Затем следует решение о маршрутизации. Пакет, адресованный самому хосту, попадает в цепочку
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:80Строка DNAT объясняет всё. Любой пакет, приходящий на порт 8080, меняет адрес назначения на 172.17.0.2:80 — это адрес контейнера во внутренней мостовой сети Docker. После подмены адреса пакет больше не считается адресованным хосту, поэтому решение о маршрутизации отправляет его по пути FORWARD, где Docker уже добавил правила, разрешающие трафик в свои сети. Ваше правило deny 8080/tcp ожидает в INPUT пакет, который никогда не дойдет до этой цепочки.
В Ubuntu 24.04 команда iptables является интерфейсом для nftables, но порядок цепочек и результат остаются прежними. UFW и Docker записывают правила в один и тот же конвейер обработки пакетов ядра, и точка входа Docker находится раньше. Это не является особенностью только UFW: firewalld в Rocky или AlmaLinux VPS фильтрует трафик в той же точке конвейера и обходится тем же правилом DNAT, поэтому приведенные ниже решения применимы и там.
Повседневное решение: публикация портов на 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"Это работает, потому что правило DNAT в Docker теперь соответствует только пакетам, адресованным 127.0.0.1. Начиная с Docker Engine 28.0.0, Docker также добавляет правило в цепочку PREROUTING таблицы raw, которое отбрасывает любой пакет для этого порта loopback, пришедший не через интерфейс loopback. В этих версиях порт доступен только с хоста и больше ниоткуда. В версиях до 28.0.0 Docker указывает, что хосты в том же сегменте L2 (например, машины на одном сетевом коммутаторе) всё ещё могут получить доступ к портам, опубликованным на localhost. Поэтому проверьте 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 и выполняет маршрутизацию по имени хоста, и не публикуйте больше ничего. Это шаблон, на котором строится руководство по обратному прокси Traefik, и именно так self-hosted приложение, например Nextcloud на VPS, остаётся недоступным иначе как через прокси. О том, как объявляются записи ports: и как устроен остальной рабочий процесс Compose, рассказано в базовом руководстве по Docker Compose.
Когда все внутренние контейнеры работают через loopback, UFW возвращается к своей обычной задаче: защите портов, которые обслуживает сам хост. Сформируйте этот набор правил, а затем выполните команды по порядку:
Реальная фильтрация: цепочка DOCKER-USER
Иногда порт контейнера должен оставаться опубликованным в сети, но доступ к нему нужно ограничить — например, для порта реплики базы данных, к которому может обращаться только один офисный IP-адрес. Для этого Docker предоставляет цепочку DOCKER-USER. Каждый пакет, направляющийся к любому контейнеру, проходит через DOCKER-USER до того, как сработают собственные правила accept в Docker. Эта цепочка предназначена для ваших правил, и Docker не удаляет и не изменяет их, даже после перезапуска демона.
Данная цепочка относится к стандартному бэкенду межсетевого экрана iptables в Docker. В Docker Engine 29 был добавлен экспериментальный бэкенд nftables, который необходимо включать с помощью опции демона firewall-backend. В нем нет цепочки DOCKER-USER: пользовательская фильтрация выполняется в отдельной таблице nftables с базовой цепочкой на хуке forward. Если вы не меняли эту опцию, вы используете iptables, и всё нижеизложенное применимо к вам. Разница между этими инструментами описана в руководстве по сравнению iptables и nftables.
Важный нюанс перед выполнением команды: к моменту, когда пакет достигает DOCKER-USER, преобразование DNAT уже выполнено. Порт назначения пакета — это порт контейнера (например, 80), а не опубликованный порт (8080). Поэтому правило, соответствующее --dport 8080, ничего не найдет. Надежный способ — фильтровать по порту, к которому клиент обратился изначально; эта информация сохраняется в трекере соединений ядра:
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 с разрешенного адреса проходит успешно, а с любого другого — соединение зависает по таймауту. Это зависание — признак того, что правило 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 применяет этот файл при каждой перезагрузке и каждом обновлении конфигурации, поэтому ваша фильтрация контейнеров теперь находится в том же месте, что и остальные правила межсетевого экрана, и сохраняется как после перезагрузки системы, так и после обновления Docker.
Почему не следует отключать интеграцию Docker с iptables
В устаревших руководствах по решению этой проблемы предлагается установить { "iptables": false } в /etc/docker/daemon.json. Не делайте этого. Правила межсетевого экрана Docker выполняют гораздо больше функций, чем просто публикация портов. Правило маскарадинга (masquerade) обеспечивает контейнерам исходящий доступ в Интернет через адрес хоста, поэтому при отключенной интеграции контейнеры не смогут обращаться к зеркалам репозиториев или вызывать внешние API (application programming interface). Правила DNAT и изоляции также перестанут работать, и документация Docker предупреждает: если не создать правила на замену, порты контейнеров станут доступны всем хостам в локальной сети. Вы устраните проблему обхода, сломав сетевое взаимодействие контейнеров, и каждое из этих правил вам придется писать и поддерживать вручную. В официальной документации Docker эта настройка названа неподходящей для большинства пользователей, а создание правил вручную описано как крайне сложная задача. Цепочка DOCKER-USER существует именно для того, чтобы никому не требовалось использовать этот переключатель.
Проблема IPv6 в том же контексте
Сначала проверьте, как выглядит опубликованный порт в IPv6:
sudo ss -tlnp | grep 8080Начиная с Docker Engine 27, Docker по умолчанию управляет ip6tables. В сети Docker с включенным IPv6 опубликованный порт подвергается такой же обработке DNAT в таблицах IPv6. Следовательно, здесь существует такой же обходной путь, и применяется то же исправление: цепочка DOCKER-USER также присутствует в ip6tables. Продублируйте свое правило с помощью sudo ip6tables -I DOCKER-USER ... и протестируйте доступ снаружи, используя curl для публичного IPv6-адреса вашего сервера, например curl -6 http://[2001:db8:2a::1]:8080/. Это справедливо для стандартного бэкенда iptables в Docker; в экспериментальном бэкенде nftables цепочки DOCKER-USER нет ни в одном стеке, а фильтрация IPv6 выполняется в вашей собственной таблице ip6 в nftables.
В сети без IPv6 запросы от IPv6-клиентов обрабатываются процессом docker-proxy — это обычный процесс в пространстве пользователя, который слушает [::]:8080 и перенаправляет трафик в контейнер по протоколу IPv4. Трафик к процессу на хосте проходит через INPUT, поэтому UFW может фильтровать этот путь, но только если UFW вообще настроен на работу с IPv6. О том, включен ли он, и о других способах возникновения уязвимостей IPv6 на VPS, читайте в руководстве по UFW и IPv6.
Публикация на loopback позволяет полностью избежать этого вопроса: -p 127.0.0.1:8080:80 привязывается только к IPv4 loopback, поэтому слушателя IPv6 не существует, а в Docker Engine 28.0.0 и более поздних версиях доступ извне невозможен ни в одном из стеков.
Надежный шаблон конфигурации
- Публикуйте все внутренние порты на
127.0.0.1, чтобы они изначально не были доступны извне (актуально для Docker Engine 28.0.0 и новее; в старых версиях хосты в том же L2-сегменте могли получить к ним доступ). - Назначьте публичную сторону одному reverse proxy, который занимает порты 80 и 443.
- Используйте политику default deny в UFW для хоста, разрешая только SSH и порты прокси.
- Фильтруйте действительно публичные порты контейнеров в
DOCKER-USERна уровне стандартного iptables-бэкенда Docker, выполняя сопоставление по исходному порту назначения и сохраняя правила в/etc/ufw/after.rules. - Оставьте интеграцию Docker с iptables включенной.
Настроенная один раз, эта схема исключает неожиданности: ufw status описывает хост, а DOCKER-USER описывает контейнеры. Ни один порт не будет опубликован случайно, и следующая команда docker run -p откроет именно то, что вы планировали.
FAQ
Почему я могу подключиться к Docker-контейнеру, если UFW блокирует порт?
Потому что Docker публикует порт с помощью правила DNAT в цепочке DOCKER таблицы nat, которая вызывается из PREROUTING. Это правило перезаписывает адрес назначения пакета на адрес контейнера до того, как сработает фильтрация. Затем пакет следует по пути FORWARD, а правила UFW находятся в цепочке INPUT, в которую пакет никогда не попадает. Межсетевой экран не опрашивается, поэтому его запрещающие правила не влияют на опубликованные порты контейнеров.
Как заставить UFW блокировать опубликованные порты Docker?
Сам UFW не может этого сделать, так как его правила находятся в другой цепочке. Либо перестаньте открывать порт наружу, публикуя его как 127.0.0.1:8080:80, чтобы только хост мог обращаться к нему (в Docker Engine 28.0.0 и новее), либо фильтруйте трафик в цепочке DOCKER-USER с помощью правила iptables, которое сопоставляет исходный порт назначения через conntrack. Сохраните это правило в /etc/ufw/after.rules, чтобы оно применялось после перезагрузок и ufw reload.
Стоит ли устанавливать "iptables": false в файле daemon.json для Docker?
Нет. Эта настройка удаляет большинство правил межсетевого экрана и NAT в Docker, что приводит к гораздо большим проблемам, чем просто обход фильтрации. Контейнеры теряют доступ к Интернету, так как правило маскарадинга исчезает, а Docker предупреждает, что без замещающих правил порты контейнеров становятся доступны хостам в локальной сети. Используйте публикацию через loopback и цепочку DOCKER-USER; это устраняет проблему доступа без нарушения работы сети контейнеров.
Обходит ли Docker UFW также и в IPv6?
В Docker Engine 27 и новее управление ip6tables включено по умолчанию, поэтому порт, опубликованный в сети Docker с поддержкой IPv6, обходит UFW точно так же, как и в IPv4. На стандартном бэкенде iptables для Docker требуется такое же правило DOCKER-USER, продублированное с помощью ip6tables. В экспериментальном бэкенде nftables, начиная с версии Engine 29, цепочки DOCKER-USER нет, поэтому фильтрация настраивается в вашей собственной таблице nftables. В сетях без IPv6 процесс docker-proxy слушает [::], и этот трафик проходит через INPUT, где UFW может его отфильтровать, если в UFW включена поддержка IPv6. Публикация на 127.0.0.1 позволяет избежать обоих случаев, так как в этом режиме прослушивание IPv6 не выполняется вовсе.