MAATRIX / Блог / Docker пробил ваш firewall: почему порт все-таки открыт, хотя вы его закрыли

Docker пробил ваш firewall: почему порт все-таки открыт, хотя вы его закрыли

MAATRIX

Вы настроили UFW, поставили default deny incoming, открыли только 22 и 443 — и вроде бы всё закрыто. Но nmap с чужого IP показывает, что порт вашего контейнера всё равно отвечает. Это не баг в UFW и не взлом — это штатное поведение Docker, которое почти никто не читает в документации, пока не наступит на него сам. Разберём, почему так происходит на уровне iptables, как проверить реальное состояние правил на своём сервере и как закрыть порт способом, который действительно работает.

Что происходит на самом деле: Docker и UFW живут в разных цепочках iptables

UFW — это удобная обёртка над iptables, но управляет она в первую очередь цепочкой INPUT: трафиком, который адресован самому хосту напрямую. Когда вы пишете default deny incoming и открываете 22/443, вы настраиваете именно INPUT, и для процессов, слушающих порт прямо на хосте (например, Nginx, запущенный не в контейнере), это работает ровно так, как ожидается.

Проблема в том, что трафик к порту, опубликованному контейнером через -p или ports: в Compose, в INPUT вообще не попадает. Docker при публикации порта добавляет в таблицу nat правило DNAT в цепочке DOCKER, которое переписывает адрес назначения пакета на внутренний IP контейнера в docker-сети — это происходит ещё в PREROUTING, до того как ядро вообще решает, кому адресован пакет. После DNAT пакет для ядра выглядит как транзитный, предназначенный не хосту, а другой машине (пусть и виртуальной), поэтому решение «пропускать или нет» принимается в цепочке FORWARD, а не в INPUT.

И здесь вторая часть проблемы: при старте демона и при каждом docker run -p / docker compose up с проброшенным портом Docker сам добавляет в FORWARD разрешающие правила для трафика на контейнер, вставляя их через iptables -I — то есть в начало цепочки. UFW тоже умеет управлять форвардингом (через DEFAULT_FORWARD_POLICY в /etc/default/ufw и цепочку ufw-user-forward), но правила Docker в DOCKER/DOCKER-USER встают раньше по порядку обработки, чем пакет вообще доходит до той части FORWARD, которую контролирует UFW. Итог: ufw status честно показывает deny по умолчанию и отсутствие разрешающего правила на нужный порт — и это правда для INPUT. Но опубликованный Docker-портом сервис живёт в nat + FORWARD, где решения принимает Docker, а не UFW.

Как проверить реальное состояние правил на своём сервере

Не верьте на слово ни UFW, ни своей памяти о том, что вы там пробрасывали три месяца назад — проверяйте цепочки напрямую.

Сначала посмотрите, что Docker реально добавил в NAT:

sudo iptables -t nat -L DOCKER -n

Если видите строку вида DNAT tcp -- 0.0.0.0/0 0.0.0.0/0 tcp dpt:5432 to:172.19.0.3:5432 — этот порт публикуется на все интерфейсы, независимо от того, что говорит UFW.

Дальше проверьте порядок цепочек в FORWARD:

sudo iptables -L FORWARD -n --line-numbers

Если DOCKER-USER и DOCKER стоят раньше, чем правила UFW (ufw-user-forward и подобные), — значит, для проброшенных портов контейнеров решение принимается ещё до того, как UFW успевает его увидеть.

Сверьте это со списком реально слушающих сокетов на хосте и с тем, что говорит сам Docker:

ss -tlnp
docker ps --format '{{.Names}}\t{{.Ports}}'

docker ps покажет, какие контейнеры публикуют порты и на какой адрес — обратите внимание на разницу между 0.0.0.0:5432->5432/tcp и 127.0.0.1:5432->5432/tcp. Первое видно снаружи, второе — нет.

И финальная проверка — снаружи, с другого хоста или сервера, а не с самой машины (локальная проверка через curl localhost ничего не скажет о видимости порта из интернета):

nmap -p 5432,6379,27017 <ip-вашего-сервера>

Если порт помечен open, а в UFW для него нет разрешающего правила — вы нашли ровно ту ситуацию, о которой эта статья. Полезно держать этот набор команд под рукой в чек-листе после любого docker compose up с новой публикацией портов, а не вспоминать о нём только после инцидента. Разбор конкретного инцидента с такой же механикой — база, случайно оставшаяся доступной снаружи из-за проброшенного порта, — есть в статье «Docker переписал правила iptables и открыл базу в интернет», там пошагово показано, как гипотезу подтверждали на реальном сервере.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Почему это не баг, а осознанное поведение Docker

Соблазн назвать это антипаттерном «firewall разрешить всё» понятен, но здесь ситуация обратная: UFW настроен правильно и по умолчанию запрещает всё, дело не в ошибке конфигурации, а в том, что Docker архитектурно работает на уровень ниже, чем видит UFW. Это сделано намеренно, и сам проект открыто это документирует. Docker должен предсказуемо публиковать порты на любой системе — с UFW, firewalld или голым iptables без всякой обёртки, — и если бы демон полагался на то, что сторонний firewall-фронтенд сам разберётся с DNAT и форвардингом, поведение docker run -p отличалось бы от сервера к серверу в зависимости от того, что там установлено и в каком порядке. Поэтому Docker управляет своими цепочками iptables напрямую и не спрашивает разрешения у UFW.

Это поведение включено по умолчанию флагом "iptables": true в /etc/docker/daemon.json (можно проверить: файла может не быть вовсе, тогда действует значение по умолчанию — true). Отключить его можно, но тогда публикация портов перестанет работать автоматически вообще — вам придётся вручную писать DNAT и FORWARD правила под каждый контейнер, что на практике более хрупко, чем работать через штатный крючок, который Docker для этого и оставляет.

Отдельно стоит понимать, что поведение отличается в зависимости от типа docker-сети: всё описанное выше — про сети типа bridge (в том числе пользовательские). В режиме network_mode: host контейнер вообще не проходит через DNAT и NAT-трансляцию — порт слушается сразу на хосте, и тогда его действительно контролирует INPUT-цепочка и, соответственно, UFW. Это тоже нужно проверять отдельно, а не считать, что раз один сервис вёл себя так, то и остальные будут вести себя так же.

Способ 1: не публиковать порт вообще

Самый надёжный вариант — вообще не пробрасывать порт наружу, если снаружи он не нужен. Для связки «база + бэкенд» в норме доступ нужен только между контейнерами внутри одной docker-сети, а не из интернета:

services:
  db:
    image: postgres:16
    # секции ports нет вообще
    networks:
      - backend
  app:
    image: myapp:latest
    ports:
      - "443:443"
    networks:
      - backend
networks:
  backend:

Приложение обращается к базе по имени сервиса (db:5432) внутри пользовательской сети backend — DNS на уровне Docker резолвит имя сервиса в текущий IP контейнера. Наружу порт физически не торчит: раз секции ports нет, DNAT-правило в цепочке DOCKER просто не создаётся, и проверять здесь через UFW нечего — угрозы физически не существует, а не «закрыта правилом».

Это же правило работает для admin-панелей вроде Portainer, Redis, RabbitMQ management UI и любых внутренних сервисов, к которым по факту ходит только другой контейнер того же стека — их стоит по умолчанию оставлять без публикации, а не открывать «на всякий случай для отладки» и забывать закрыть.

Способ 2: биндить порт на localhost, а не на все интерфейсы

Иногда доступ снаружи временно нужен — отладка с ноутбука, разовый импорт данных, доступ администратора к дашборду. Здесь неправильный шаблон — публиковать порт на 0.0.0.0 и полагаться на UFW; правильный — биндить порт сразу на 127.0.0.1:

    ports:
      - "127.0.0.1:5432:5432"

Без явного адреса запись "5432:5432" — это сокращение от 0.0.0.0:5432:5432, то есть публикация на все интерфейсы. Добавление 127.0.0.1: меняет сам адрес назначения в DNAT-правиле, которое создаёт Docker, — и это не зависит от UFW вообще, потому что порт физически недоступен снаружи сетевого стека хоста, а не «заблокирован» каким-то правилом сверху.

Доступ к такому порту получают через SSH-туннель:

ssh -L 5432:127.0.0.1:5432 user@server-ip

или через VPN (WireGuard/AmneziaWG), если доступ нужен не разово, а постоянно нескольким людям — тогда порт остаётся привязанным к 127.0.0.1 или к внутреннему VPN-интерфейсу, а не к публичному IP. Это же решение снимает и вторую проблему — сервис вообще не виден сканерам портов из интернета, а не просто «отвечает отказом на неавторизованный запрос».

Способ 3: точечно ограничить через DOCKER-USER

Бывают случаи, когда порт всё-таки должен слушать 0.0.0.0 — например, у входного reverse-прокси или у сервиса, к которому ходят внешние клиенты с разных IP без VPN. Здесь неправильно пытаться закрыть его правилами UFW — они, как разобрано выше, не увидят этот трафик. Правильно — работать через цепочку DOCKER-USER, которую Docker специально оставляет пустой и не трогает при рестарте демона или пересоздании контейнеров, именно для пользовательских правил:

sudo iptables -I DOCKER-USER -i eth0 -s 203.0.113.10 -j RETURN
sudo iptables -I DOCKER-USER -i eth0 -j DROP

Первое правило пропускает трафик с доверенного адреса дальше по цепочке (RETURN возвращает обработку в вызывающую цепочку FORWARD, где уже стоят правила Docker), второе — дропает всё остальное, что пришло на внешний интерфейс. Порядок важен: правило с RETURN для доверенных адресов должно стоять раньше общего DROP.

Такие правила не переживают перезагрузку сервера, если их не сохранить — через iptables-persistent/netfilter-persistent или отдельный systemd-юнит, применяющий их при старте:

[Unit]
Description=Custom DOCKER-USER rules
After=docker.service
Requires=docker.service

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/docker-user-rules.sh
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target

Если руками поддерживать список разрешённых IP в DOCKER-USER неудобно, в сообществе есть готовые обёртки вроде ufw-docker, которые автоматизируют синхронизацию правил UFW с DOCKER-USER — они снимают часть рутины, но добавляют дополнительный слой, за состоянием которого тоже нужно следить после обновлений Docker и самого скрипта; для одного-двух проброшенных портов писать правила DOCKER-USER руками обычно надёжнее и прозрачнее, чем тянуть стороннюю зависимость.

Отдельно стоит знать: если у вас установлен firewalld вместо UFW, логика та же самая — firewalld тоже управляет в основном INPUT/зонами, а не той частью FORWARD, куда Docker вставляет свои цепочки, так что рекомендация работать через DOCKER-USER актуальна независимо от того, какой фронтенд поверх iptables вы используете. Если вы разворачиваете Docker в rootless-режиме — там сетевой стек контейнеров устроен иначе (через slirp4netns/pasta вместо прямой работы с iptables от имени root), и часть описанного здесь поведения к rootless-режиму не относится вовсе — это стоит проверять отдельно, а не переносить выводы автоматически.

Ниже — сводка трёх способов, чтобы было проще выбрать вариант под конкретный сервис:

СпособЧто видно снаружиКогда применятьРиск при ошибке
Не публиковать портНичего, DNAT-правила нетСервис нужен только другим контейнерам в той же сетиМинимальный — порта физически нет
Bind на 127.0.0.1Ничего, доступ только через SSH-туннель/VPNРазовая или редкая отладка, админ-доступНизкий, но нужен доступ к хосту для туннеля
DOCKER-USER allowlistПорт открыт, но только для разрешённых IPВходной прокси или сервис с внешними клиентамиСредний — легко забыть обновить список IP

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

UFW вообще бесполезен вместе с Docker?

Нет. Он продолжает контролировать трафик, адресованный самому хосту напрямую — SSH, веб-сервер, запущенный не в контейнере, и любой процесс, слушающий сокет на хосте без network_mode: host со стороны Docker. Проблема именно с портами, опубликованными контейнерами через -p: они идут через nat + FORWARD, а не через INPUT, который контролирует UFW.

Можно ли просто отключить управление iptables со стороны Docker?

Да, флагом "iptables": false в /etc/docker/daemon.json с перезапуском демона. Но тогда публикация портов контейнеров вообще перестанет работать сама по себе — вам придётся руками писать DNAT и FORWARD правила под каждый проброшенный порт, а это на практике сложнее поддерживать, чем работать через DOCKER-USER.

Правила в DOCKER-USER переживут docker compose down и обновление образов?

Да, эта цепочка специально зарезервирована Docker под пользовательские правила и не перезаписывается при пересоздании контейнеров или рестарте демона. Не переживёт она только перезагрузку самой машины, если правила не сохранены через iptables-persistent или отдельный systemd-юнит, который применяет их при загрузке.

Как быстро проверить прямо сейчас, есть ли такая дыра на моём сервере?

Сравните docker ps --format '{{.Names}}\t{{.Ports}}' со списком правил UFW: любой порт, опубликованный на 0.0.0.0, для которого в UFW нет явного разрешения, стоит проверить через nmap с внешнего хоста. Если порт открыт снаружи — вы нашли ровно то расхождение, о котором эта статья.

А если у меня Kubernetes, а не голый Docker — там то же самое?

Механика похожая, но детали другие: kube-proxy и CNI-плагины тоже управляют iptables/IPVS напрямую и тоже могут расходиться с firewall-обёртками на узле, но конкретные цепочки и правила отличаются от тех, что использует Docker Engine, — переносить команды из этой статьи один в один на кластер не стоит.

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →