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