MAATRIX / Блог / Docker переписал правила iptables и открыл базу в интернет

Docker переписал правила iptables и открыл базу в интернет

MAATRIX

Есть настроенный UFW с политикой deny incoming по умолчанию, есть открытый только 22 и 443 порт — и есть база данных, которая почему-то всё равно отвечает на запросы из интернета. Если вы когда-нибудь ловили этот парадокс, вы уже знаете, что дело не в firewall, а в том, кто на самом деле управляет iptables на сервере с Docker. Разберём инцидент по шагам: что заметили, какие гипотезы отбросили и в чём была настоящая причина.

Что мы увидели: подключения к базе, которых быть не должно

История стандартная для конца августа: команда подняла PostgreSQL в контейнере на VPS, пробросила порт наружу через docker-compose.yml для локальной отладки с ноутбука разработчика — планировали закрыть доступ через день-два, когда допилят VPN. Через несколько дней в логах PostgreSQL обнаружились подключения с IP-адресов, которых команда не узнавала:

2026-08-19 03:14:02 UTC LOG:  connection received: host=185.xxx.xxx.xxx port=51422
2026-08-19 03:14:02 UTC LOG:  password authentication failed for user "postgres"
2026-08-19 03:14:03 UTC LOG:  connection received: host=185.xxx.xxx.xxx port=51498

Пароль стоял не тривиальный, попытки перебора успехом не увенчались — но сам факт, что порт 5432 отвечает незнакомым адресам, уже был поводом для тревоги. На сервере стоял UFW, и первая реакция была: «У нас же default deny, кто это пропустил?»

Проверка показала, что тревога обоснованная:

$ sudo ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), disabled (routed)
New profiles: skip

To                         Action      From
--                         ------      ----
22/tcp                     ALLOW IN    Anywhere
443/tcp                    ALLOW IN    Anywhere
80/tcp                     ALLOW IN    Anywhere

Правила ровно те, что должны быть. Порта 5432 в списке разрешённых нет вообще. При этом:

$ nmap -p 5432 <server-ip>
PORT     STATE SERVICE
5432/tcp open  postgresql

Порт открыт снаружи, хотя UFW клянётся, что входящий трафик по умолчанию блокируется, а правила для 5432 не заведено. Это и есть завязка инцидента — дальше пошёл перебор гипотез.

Гипотезы, которые отбросили

Первым делом проверили самое банальное: не забыли ли включить сам UFW и не перезаписал ли его кто-то. Оба варианта отпали быстро.

Гипотеза 1: UFW не активен на самом деле. Проверили systemd-юнит и статус ядра:

$ systemctl status ufw
● ufw.service - Uncomplicated firewall
     Active: active (exited)
$ sudo ufw status
Status: active

Всё включено, всё активно. Не подтвердилось.

Гипотеза 2: правило добавили руками и забыли. Посмотрели историю команд и сами правила UFW построчно — ufw status numbered и /etc/ufw/user.rules. Никакого явного ALLOW на 5432 не было ни в UFW, ни в файлах правил.

Гипотеза 3: дело в security group у облачного провайдера. На части площадок firewall облака работает отдельно от iptables на самой машине и может перекрывать локальные ограничения. Проверили панель провайдера — там был разрешён только 22 и 443, 5432 нигде не фигурировал. Значит, проблема не снаружи сервера, а внутри него.

Гипотеза 4: кто-то вручную вставил правило в iptables мимо UFW. Это уже было ближе к правде, но не в том смысле, в котором ожидали. Посмотрели полную таблицу filter:

$ sudo iptables -L -n --line-numbers

И в выводе, помимо привычных цепочек ufw-user-input и ufw-before-input, обнаружились чужеродные цепочки DOCKER, DOCKER-ISOLATION-STAGE-1, DOCKER-ISOLATION-STAGE-2 и, главное, DOCKER-USER. Вот здесь и начало проясняться, кто на самом деле писал правила.

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

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

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

Настоящая причина: Docker живёт в другой цепочке, а не в той, что фильтрует UFW

UFW управляет цепочкой INPUT — то есть трафиком, который предназначен самому хосту напрямую. Но когда контейнер публикует порт через ports: - "5432:5432", Docker не отправляет этот трафик в INPUT. Он поднимает правило DNAT в таблице nat (цепочка DOCKER), которое перенаправляет входящий пакет на IP контейнера в docker-сети, а решение «пропускать или нет» после DNAT принимается уже в цепочке FORWARD, а не в INPUT.

И вот ключевой момент: Docker при старте демона и при каждом docker run/docker compose up с проброшенным портом сам добавляет в FORWARD разрешающие правила ACCEPT для трафика на контейнер — причём вставляет их перед правилами UFW, а не после. UFW действительно управляет FORWARD через отдельные хуки (DEFAULT_FORWARD_POLICY в /etc/default/ufw), но по умолчанию политика для форвардинга у UFW — DROP, и она вообще не мешает Docker, потому что правила Docker в DOCKER/DOCKER-USER обрабатываются раньше, чем пакет доходит до цепочки, которую контролирует политика UFW.

Проверить это можно прямо руками:

$ sudo iptables -t nat -L DOCKER -n
Chain DOCKER (2 references)
target     prot opt source       destination
DNAT       tcp  --  0.0.0.0/0    0.0.0.0/0   tcp dpt:5432 to:172.19.0.3:5432

$ sudo iptables -L FORWARD -n --line-numbers
Chain FORWARD (policy DROP)
num  target              prot opt source          destination
1    DOCKER-USER         all  --  0.0.0.0/0        0.0.0.0/0
2    DOCKER-ISOLATION-STAGE-1  all --  0.0.0.0/0   0.0.0.0/0
3    ACCEPT              all  --  0.0.0.0/0        0.0.0.0/0    ctstate RELATED,ESTABLISHED
4    DOCKER              all  --  0.0.0.0/0        0.0.0.0/0
5    ACCEPT              all  --  0.0.0.0/0        0.0.0.0/0
...

Цепочка DOCKER-USER стоит первой в FORWARD и по умолчанию у неё нет ничего, кроме RETURN — то есть она ничего не блокирует, а просто отдаёт управление дальше по цепочке, где уже стоит ACCEPT для трафика на опубликованные порты контейнеров. UFW про это ничего «не знает»: его правила ufw-user-forward в этой цепочке физически не успевают отработать раньше, чем Docker уже сказал «ACCEPT».

Отсюда и парадокс: ufw status честно показывает deny по умолчанию и отсутствие правила на 5432 — и это правда для INPUT. Но опубликованный Docker-порт идёт через nat+FORWARD, где правит бал Docker, а не UFW. Это документированное поведение (Docker прямо предупреждает об этом в своей документации по iptables), но на практике про него вспоминают обычно уже после инцидента, а не до.

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

Как подтвердили гипотезу до конца

Чтобы не полагаться на догадки, сделали контрольную проверку: временно остановили только сам контейнер с базой (не демон Docker), оставив все правила iptables как есть.

$ docker compose stop db
$ nmap -p 5432 <server-ip>
PORT     STATE  SERVICE
5432/tcp closed postgresql

Порт немедленно закрылся — при том, что ни одно правило UFW не менялось. Это подтвердило: доступность порта целиком определяется тем, поднят ли контейнер с проброшенным портом, а не состоянием UFW. Обратный тест — подняли контейнер снова, порт немедленно открылся заново, опять без единого изменения в самом UFW.

Второй контрольный шаг — посмотрели docker-compose.yml, из-за которого всё началось:

services:
  db:
    image: postgres:16
    ports:
      - "5432:5432"
    environment:
      POSTGRES_PASSWORD: ${DB_PASSWORD}
    volumes:
      - pgdata:/var/lib/postgresql/data

Запись "5432:5432" без указания адреса — это сокращение от 0.0.0.0:5432:5432. Именно уточнение адреса и стало частью финального исправления.

Что изменили: три независимых способа закрыть дыру

Разбор показал, что полагаться только на UFW при работе с Docker нельзя в принципе — нужно либо не публиковать порт наружу вовсе, либо явно управлять цепочкой DOCKER-USER, которую Docker для этого и оставляет пустой намеренно. Сделали сразу три вещи, каждая закрывает проблему с разного угла.

1. Порт для базы больше не публикуется на все интерфейсы. Если доступ снаружи вообще не нужен — а в норме для базы, к которой ходит только приложение в той же docker-сети, он не нужен — правильное решение просто убрать секцию ports и оставить базу доступной только внутри пользовательской сети Docker:

services:
  db:
    image: postgres:16
    # ports больше нет вообще
    networks:
      - backend
  app:
    image: myapp:latest
    networks:
      - backend
networks:
  backend:

Приложение обращается к базе по имени сервиса (db:5432) внутри той же сети, а наружу порт не торчит физически — DNAT-правило Docker просто не создаётся, потому что нечего публиковать.

2. Там, где доступ снаружи временно всё же нужен (например, для отладки с конкретной машины), порт биндится не на все интерфейсы, а на localhost или конкретный внутренний IP.

    ports:
      - "127.0.0.1:5432:5432"

Такой порт остаётся доступен только через SSH-туннель или VPN, а не напрямую из интернета — и это не зависит от UFW вообще, потому что DNAT-правило создаётся уже с адресом назначения 127.0.0.1.

3. Для случаев, когда порт всё-таки должен быть опубликован на 0.0.0.0 (например, у входного reverse-прокси), ограничение вынесли в ту самую цепочку DOCKER-USER, которую Docker специально не трогает и оставляет для пользовательских правил. Именно так рекомендует делать сама документация Docker — не бороться с FORWARD, а работать через штатный крючок:

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

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

Дополнительно завели небольшой systemd-юнит, который применяет эти правила DOCKER-USER при загрузке — вручную вбитые правила iptables не переживают перезагрузку сервера, если их не сохранить отдельно (через iptables-persistent/netfilter-persistent или явный юнит, накатывающий правила при старте).

Отдельно завели мониторинг: раз в сутки крон-джоба сверяет список реально слушающих портов (ss -tlnp) со списком портов, которые должны быть открыты по документации проекта, и шлёт алерт при расхождении — это дешевле, чем полагаться на память команды о том, что «мы же закрыли это временно».

Что стоило сделать иначе с самого начала

Если коротко резюмировать разбор: ошибка была не в конфигурации UFW — она с самого начала была правильной, это классический антипаттерн «firewall разрешить всё» тут был ни при чём. Ошибка была в ментальной модели: команда считала, что раз на хосте настроен firewall с default deny, то любой новый сервис по умолчанию защищён, пока его явно не разрешат. Для процессов, слушающих напрямую на хосте, это верно. Для контейнеров с проброшенными портами — нет, потому что Docker встраивается в iptables на уровень ниже, чем UFW успевает подействовать, и в этом смысле результат неотличим от антипаттерна «база открыта наружу», только причина скрыта на уровень глубже, чем обычно проверяют.

Стоит также заранее понимать разницу между типами docker-сетей — bridge, host и overlay ведут себя по-разному именно в части того, как трафик доходит до контейнера и какие цепочки iptables при этом задействуются.

Практический вывод, который стоит унести из этого разбора: при работе с Docker на сервере считать правилом по умолчанию не «UFW blocks everything, пока не разрешено», а «любой проброшенный Docker-портом порт доступен снаружи, пока явно не ограничен через DOCKER-USER или не привязан к localhost». Разница на первый взгляд небольшая, но именно она определяет, окажется ли база в интернете или нет.

Если вы разворачиваете сервисы на VPS и хотите с самого начала строить сеть и firewall с учётом этой особенности Docker, а не находить её постфактум через nmap с чужого IP — можно сразу арендовать сервер с чистой Ubuntu и настроить связку UFW + DOCKER-USER по шаблону, не тратя время на повторное открытие этих граблей.

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

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

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

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

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

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

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

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

Можно ли просто запретить Docker трогать iptables?

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

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

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

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

Сравните вывод ufw status со списком реально слушающих портов через ss -tlnp и docker ps --format '{{.Names}} {{.Ports}}'. Любой порт, опубликованный на 0.0.0.0, который не фигурирует в правилах UFW как разрешённый явно, стоит проверить через nmap с внешнего хоста — если он открыт, вы нашли ровно то же самое несоответствие.

Что делать, если порт нужен снаружи именно от Docker и никак иначе?

Ограничивайте не через UFW, а через DOCKER-USER с конкретными IP или подсетями, либо ставьте перед контейнером reverse-прокси на хосте (например, Nginx), который слушает порт напрямую на хосте — тогда его действительно контролирует UFW, а порт самой базы или бэкенда вообще не публикуется наружу.

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

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

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