Почему UFW не закрывает опубликованный порт Docker
Знакомая картина: `ufw status` показывает `Default: deny (incoming)`, правила для порта нет или стоит явный запрет, а внешний `nmap` уверенно пишет `open`. Я встречал это много раз. Администратор начинает переставлять UFW, перезагружать сервер и подозревать провайдера. Причина обычно проще: опубликованный порт Docker проходит не через ту цепочку, которую вы проверяете. Ниже покажу путь пакета, мой рабочий способ закрыть лишнее и случай, когда без `DOCKER-USER` действительно не обойтись.
UFW не сломан: пакет идёт другим маршрутом
Короткий ответ: Docker сам программирует Netfilter, чтобы публикация вида -p 5432:5432 работала. Сначала Docker меняет адрес назначения пакета — выполняет DNAT с адреса хоста на адрес контейнера. После этого пакет становится транзитным и проходит через FORWARD, а не через обычный INPUT, где администратор ожидает увидеть действие UFW. Поэтому зелёная надпись Status: active ещё ничего не говорит о контейнерном периметре.
На стандартном iptables-бэкенде маршрут упрощённо выглядит так: Интернет → интерфейс хоста → nat/PREROUTING → DNAT → filter/FORWARD → DOCKER-USER → разрешающие цепочки Docker → bridge-интерфейс → контейнер. Правило ufw deny 5432/tcp обычно оказывается в ufw-user-input. До него этот пакет не доходит. Это не уязвимость и не случайная несовместимость конкретного релиза, а следствие того, как Docker реализует публикацию портов.
Моя позиция жёсткая: внутренний сервис вообще не должен публиковаться на внешний интерфейс. Базу данных, Redis, административную панель и метрики я либо оставляю только в Docker-сети, либо привязываю к loopback. DOCKER-USER использую как точечный ACL, когда прямой внешний доступ действительно нужен. Пытаться превратить UFW в единственный источник истины для всех контейнерных маршрутов я перестал давно — слишком легко получить ложное ощущение закрытого сервера.
- `EXPOSE 5432` в Dockerfile только документирует порт и сам по себе не открывает его наружу.
- `ports: - 5432:5432` или `docker run -p 5432:5432` публикует порт на всех адресах хоста.
- `127.0.0.1:5432:5432` оставляет публикацию доступной только с самого хоста.
- Контейнеры одной пользовательской Docker-сети могут обращаться друг к другу по имени сервиса без секции `ports`.
Как увидеть правила, которые скрыты за ufw status
Я начинаю не с редактирования firewall, а с инвентаризации публикаций. docker ps и docker port показывают намерение Docker, ss — сокеты хоста, а таблицы Netfilter — реальный путь пакета. На системах с отключённым userland-proxy опубликованный порт может не выглядеть как привычный слушающий процесс в ss, но DNAT продолжит работать. Поэтому одной команды недостаточно.
sudo ufw status verbose
docker ps --format 'table {{.Names}}\t{{.Ports}}'
docker port backoffice
docker port database
sudo ss -lntup
sudo iptables -t nat -S DOCKER
sudo iptables -S DOCKER-USER
sudo iptables -nvL FORWARD --line-numbers
sudo ip6tables -S DOCKER-USER
sudo nft list rulesetПоследнюю команду я выполняю даже при работе через iptables: на современных Ubuntu утилита iptables часто использует совместимый backend nf_tables, и полный ruleset помогает заметить смешанную конфигурацию.
Обратите внимание на вывод Docker. Запись 0.0.0.0:5432->5432/tcp означает публикацию на всех IPv4-интерфейсах. Запись [::]:5432->5432/tcp — на IPv6. Нужный безопасный вариант для локального доступа выглядит как 127.0.0.1:5432->5432/tcp. Заодно проверяю Compose-файлы, override-файлы и фактическую конфигурацию: разработчик мог убрать ports из основного файла, но оставить публикацию в compose.production.yaml.
docker compose config
docker inspect database --format '{{json .NetworkSettings.Ports}}'
iptables -V
docker info | grep -i 'Firewall Backend'Если iptables -V содержит nf_tables, это ещё не означает, что Docker запущен с новым nftables-бэкендом. Важен именно backend Docker и наличие его цепочек.
По состоянию на сентябрь 2026 года актуальная ветка Docker Engine — 29, а nftables-бэкенд, появившийся в 29.0.0, официально остаётся экспериментальным. По умолчанию Docker продолжает использовать iptables. В этом режиме для пользовательских правил предусмотрена цепочка DOCKER-USER, выполняемая раньше DOCKER-FORWARD и DOCKER. При экспериментальном nftables-бэкенде цепочки DOCKER-USER нет: нужны собственная nftables-таблица и base chain с подходящим hook и priority. Механически переносить старые инструкции туда нельзя.
- Сверьте опубликованные порты со списком согласованных внешних сервисов.
- Проверьте IPv4 и IPv6 отдельно.
- Посмотрите счётчики пакетов в `FORWARD` и `DOCKER-USER`.
- Повторите внешний тест после перезапуска Docker и после перезагрузки ОС.
Мой основной способ: loopback, внутренняя сеть и reverse proxy
Сначала я делю сервисы на три группы. Публичный HTTP-трафик идёт через один reverse proxy на 80/443. Служебные интерфейсы публикуются на 127.0.0.1 и доступны через VPN или SSH-туннель. Базы и очереди, которым не нужен доступ с хоста, вообще не получают ports. Такая схема проще для аудита: наружу смотрят два-три заранее известных сокета, а не набор публикаций, появившихся из Compose.
Например, веб-интерфейс бэк-офиса и PostgreSQL можно ограничить loopback, а связь приложения с базой оставить во внутренней сети. В Compose Specification отдельное поле version уже не требуется.
services:
backoffice:
image: nginx:1.28-alpine
restart: unless-stopped
ports:
- "127.0.0.1:8080:80"
networks:
- backend
database:
image: postgres:16.12-bookworm
restart: unless-stopped
environment:
POSTGRES_DB: shop
POSTGRES_USER: shop_app
POSTGRES_PASSWORD_FILE: /run/secrets/db_password
ports:
- "127.0.0.1:5432:5432"
secrets:
- db_password
networks:
- backend
networks:
backend:
secrets:
db_password:
file: ./secrets/db_password.txtЕсли доступ к PostgreSQL с хоста не нужен, я удаляю его секцию ports целиком. Приложение продолжает ходить на database:5432 по сети backend. Это предпочтительный вариант.
Перед контейнером я ставлю Nginx на хосте либо отдельный управляемый ingress. В варианте с хостовым Nginx соединение на 443 попадает в INPUT, поэтому обычные правила UFW снова работают предсказуемо. Сертификат, ограничения запросов и журнал доступа находятся в одном месте.
server {
listen 80;
listen [::]:80;
server_name backoffice.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name backoffice.example.com;
ssl_certificate /etc/letsencrypt/live/backoffice.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/backoffice.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}На UFW я разрешаю 80/443, а SSH — только с адреса офиса или VPN. Адрес 203.0.113.18 ниже относится к документационному диапазону; в рабочем правиле будет реальный белый адрес.
sudo ufw default deny incoming
sudo ufw allow 80/tcp
sudo ufw allow 443/tcp
sudo ufw allow proto tcp from 203.0.113.18 to any port 22
sudo ufw enableДля временной работы администратора с локально опубликованной базой достаточно туннеля. Открывать 5432 для всего Интернета не требуется.
ssh -N -L 15432:127.0.0.1:5432 admin@198.51.100.20
psql 'host=127.0.0.1 port=15432 dbname=shop user=shop_app sslmode=require'Да, reverse proxy на хосте добавляет ещё один компонент. Для небольшого сервера я считаю эту цену оправданной: схема очевидна любому дежурному администратору, а диагностика не превращается в археологию по цепочкам NAT.
Страховка от человеческого фактора — поменять адрес привязки по умолчанию. Тогда публикация без явного адреса, вроде 8080:80, для новых bridge-сетей ляжет на loopback, а не на 0.0.0.0. Опция документирована в разделе Docker о публикации портов; для сети bridge по умолчанию используется ключ ip в том же файле.
{
"ip": "127.0.0.1",
"default-network-opts": {
"bridge": {
"com.docker.network.bridge.host_binding_ipv4": "127.0.0.1"
}
}
}Файл /etc/docker/daemon.json применяется после sudo systemctl restart docker, а уже созданные Compose-сети нужно пересоздать через docker compose down и docker compose up -d. Опция касается только IPv4: IPv6-публикации проверяю отдельно. И это страховка, а не замена ревью Compose-файлов — явно указанный 0.0.0.0:5432:5432 она не остановит.
- Публичное приложение: reverse proxy и только 80/443.
- Административный интерфейс: loopback плюс VPN или SSH-туннель.
- База, Redis, очередь: только пользовательская Docker-сеть.
- Прямая внешняя публикация: исключение с отдельным ACL.
Практика: «Клёвое место», 15 рабочих мест
Название условное, сценарий собран из типовых внедрений. Интернет-магазин рыболовных товаров «Клёвое место» — 15 рабочих мест: операторы заказов, склад, закупщик и бухгалтер, пятеро из них регулярно работают удалённо. Бэк-офис магазина (обработка заказов, остатки, выгрузка в службы доставки) жил на виртуальной машине с 4 vCPU, 8 Гбайт RAM и диском NVMe 160 Гбайт. На момент работ стояли Ubuntu Server 24.04.4 LTS, UFW 0.36.2, Docker Engine 29.8.0 и Compose plugin 2.39.4. Сервер имел один публичный IPv4-адрес; IPv6 у провайдера был отключён. Витрина магазина размещалась отдельно, у хостинг-провайдера, так что простой бэк-офиса не останавливал приём заказов с сайта, но задерживал их обработку.
После обновления приложения администратор добавил две публикации: 8080:8080 для проверки бэк-офиса и 5432:5432 для подключения к PostgreSQL из DBeaver. Потом он выполнил ufw deny 5432/tcp, увидел запрет в ufw status numbered и посчитал вопрос закрытым.
services:
backoffice:
ports:
- "8080:8080"
database:
ports:
- "5432:5432"Через семь часов внешний мониторинг обнаружил оба порта. В docker ps были записи 0.0.0.0:8080->8080/tcp и 0.0.0.0:5432->5432/tcp, а в nat/DOCKER — правила DNAT. Счётчики ufw-user-input для 5432 оставались нулевыми, потому что трафик шёл через FORWARD.
В журнале PostgreSQL мы нашли 637 неудачных попыток аутентификации с 31 адреса. Это неприятно, но я не стал объявлять утечку: успешных чужих входов, выгрузок и изменений данных по журналам приложения, PostgreSQL и провайдера не обнаружили. В базе лежали заказы с телефонами и адресами покупателей, поэтому к инциденту я отнёсся всерьёз. Пароль был длинным, роль приложения не имела прав суперпользователя. Риск был реальным, однако сам факт сканирования ещё не равен взлому. Сначала закрыли доступ, затем сменили секрет и проверили роли, резервные копии и события входа.
Я изменил публикацию бэк-офиса на 127.0.0.1:8080:8080, PostgreSQL — на 127.0.0.1:5432:5432, поставил хостовый Nginx и оставил снаружи 80/443. SSH разрешили только через офисный адрес и VPN. Пересоздание двух контейнеров заняло 43 секунды в согласованное окно — ранним утром, до начала смены операторов; данные лежали в именованном volume и не переносились. После перезагрузки мы проверили сервер с двух независимых внешних узлов: 8080 и 5432 были недоступны, 443 отвечал, SSH открывался только через VPN. За следующую неделю ложных срабатываний и жалоб от 15 сотрудников не было.
- До работ: четыре внешних TCP-порта, включая бэк-офис и PostgreSQL с данными покупателей.
- После работ: 80/443 публично, SSH только с доверенного адреса, контейнерные сервисы на loopback.
- Проверка: два внешних источника, IPv4-сканирование, перезапуск Docker и полная перезагрузка.
- Результат: операторы и склад работают как раньше, прямой контакт Интернета с приложением и базой исчез.
Когда нужен DOCKER-USER и как сделать ACL
Иногда loopback не подходит. Например, внешний сервер резервного копирования должен напрямую подключаться к опубликованному порту, а VPN пока нет. Тогда на стабильном iptables-бэкенде Docker я ограничиваю источник в DOCKER-USER и дублирую запрет в firewall провайдера. Сначала пропускаю уже установленные соединения, затем отбрасываю обращения к исходному адресу и порту от всех, кроме разрешённого источника.
sudo iptables -I DOCKER-USER 1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
sudo iptables -I DOCKER-USER 2 -i ens3 -p tcp -m conntrack --ctorigdst 198.51.100.20 --ctorigdstport 5432 ! -s 203.0.113.18/32 -j DROP
sudo iptables -nvL DOCKER-USER --line-numbersЗдесь 198.51.100.20 — документационный адрес Docker-хоста, 203.0.113.18 — разрешённый источник, ens3 — внешний интерфейс примера. На рабочем сервере значения берутся из сетевой схемы, а не копируются вслепую.
Почему используется conntrack --ctorigdstport? К моменту входа в DOCKER-USER DNAT уже выполнен, и обычный --dport видит порт контейнера. Если внешний порт 15432 перенаправлен на внутренний 5432, это имеет значение. Расширение conntrack позволяет сопоставить исходное назначение, но Docker предупреждает о возможном снижении производительности. На порту базы с умеренной нагрузкой цена обычно незаметна; на высоконагруженном ingress я сначала измеряю её и стараюсь фильтровать раньше — на маршрутизаторе или firewall провайдера.
Правила должны переживать перезапуск и применяться после появления цепочки Docker. Я предпочитаю небольшой идемпотентный скрипт и oneshot-unit, зависящий от docker.service, а не ручную команду из истории shell.
#!/bin/sh
set -eu
iptables -C DOCKER-USER -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT 2>/dev/null || iptables -I DOCKER-USER 1 -m conntrack --ctstate ESTABLISHED,RELATED -j ACCEPT
iptables -C DOCKER-USER -i ens3 -p tcp -m conntrack --ctorigdst 198.51.100.20 --ctorigdstport 5432 ! -s 203.0.113.18/32 -j DROP 2>/dev/null || iptables -I DOCKER-USER 2 -i ens3 -p tcp -m conntrack --ctorigdst 198.51.100.20 --ctorigdstport 5432 ! -s 203.0.113.18/32 -j DROP[Unit]
Description=ACL for published Docker ports
Requires=docker.service
After=docker.service
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/docker-acl.sh
RemainAfterExit=yes
[Install]
WantedBy=multi-user.targetПосле размещения файлов выставляю права и проверяю холодный старт.
sudo chmod 0750 /usr/local/sbin/docker-acl.sh
sudo systemctl daemon-reload
sudo systemctl enable --now docker-acl.service
sudo systemctl status docker-acl.serviceДля IPv6 нужен отдельный аудит и, при публикации на IPv6, эквивалентные правила ip6tables с реальными IPv6-адресами. Я не считаю отключение IPv6 универсальным лечением: если протокол используется, его надо фильтровать; если не используется во всей инфраструктуре, отключение должно быть осознанным и документированным. Половинчатый вариант, когда UFW обслуживает IPv6, а Docker-публикации никто не проверяет, встречается слишком часто.
- Сначала ограничьте порт в firewall провайдера или на пограничном устройстве.
- Затем поставьте host-level ACL в `DOCKER-USER`.
- Разрешите `ESTABLISHED,RELATED` раньше запрещающих правил.
- Автоматизируйте восстановление ACL и тестируйте его после обновлений Docker.
На что тратить время, а на что — нет
Не советую выставлять в конфигурации Docker iptables=false или ip6tables=false ради примирения с UFW. Docker прямо предупреждает: без созданных им правил обычно ломаются masquerading, доступ контейнеров наружу и публикация портов; при некоторых конфигурациях локальная сеть, наоборот, получает лишний доступ к контейнерам. Строить полный replacement-ruleset можно, но для сервера малого и среднего бизнеса это редко оправдано.
На nftables-бэкенд Docker 29 я пока не перевожу обычный production-хост только ради этой задачи. Поддержка экспериментальная, DOCKER-USER там отсутствует, а собственные base chains требуют понимания hook, priority и взаимодействия с правилами Docker. Если команда уже стандартизировала nftables и тестирует обновления, решение нормальное. Но смешивать UFW, ручные iptables-команды и экспериментальные nftables-таблицы без единой модели — верный способ получить правила, которые работают до первой перезагрузки.
Про утилиту ufw-docker спрашивают почти на каждом аудите. Она дописывает в /etc/ufw/after.rules правила для DOCKER-USER и ufw-user-forward: по умолчанию закрывает опубликованные порты для внешних сетей и оставляет обмен внутри частных подсетей. Открытие выглядит так.
sudo ufw-docker install
sudo systemctl restart ufw
sudo ufw-docker allow backoffice 80
sudo ufw route allow proto tcp from any to any port 80Главная ловушка — в правиле указывается порт контейнера, а не хоста: при -p 8080:80 разрешать нужно 80. Утилита рабочая, но это сторонний проект, а не часть Docker или Ubuntu. С nftables-бэкендом Docker она не работает, потому что там нет DOCKER-USER. Если в команде привыкли к синтаксису UFW, я её допускаю, однако правило loopback и внутренних сетей от этого не отменяется.
Финальную проверку выполняю извне и сохраняю результат в эксплуатационную документацию. Минимальный тест включает TCP-подключение, сканирование разрешённых портов, просмотр счётчиков и повтор после reboot.
nmap -Pn -sT -p 22,80,443,8080,5432 198.51.100.20
nc -vz -w 3 198.51.100.20 5432
curl -I https://backoffice.example.com
sudo iptables -nvL DOCKER-USER --line-numbers
docker ps --format 'table {{.Names}}\t{{.Ports}}'nmap запускается с внешней машины, остальные диагностические команды — на Docker-хосте. Для UDP нужен отдельный тест: результат open|filtered сам по себе не доказывает блокировку.
Приоритеты у меня такие: убрать лишние ports, привязать служебные публикации к loopback, закрыть периметр у провайдера, затем добавить точечный DOCKER-USER. На косметику в ufw status можно не тратить вечер. Важно не то, насколько аккуратно выглядит список UFW, а какие пакеты реально доходят до контейнера. Если после прочтения вы сделаете только одну вещь — выполните docker ps, посмотрите колонку PORTS и проверьте эти адреса снаружи.
- Не отключать firewall-механику Docker без полного replacement-ruleset.
- Не считать `ufw status` полным аудитом Docker-хоста.
- Не забывать про IPv6 и UDP.
- Не оставлять диагностические публикации после завершения работ.
- Проверять правила после обновления Docker Engine.
Частые вопросы
Почему `ufw deny 5432/tcp` не закрывает опубликованный PostgreSQL?
Docker выполняет DNAT до обработки обычной цепочкой INPUT. Пакет становится пересылаемым и проходит через FORWARD и цепочки Docker, поэтому правило в ufw-user-input его не видит.
Достаточно ли заменить `5432:5432` на `127.0.0.1:5432:5432`?
Для запрета внешнего доступа в стандартной bridge-сети — обычно да. Затем проверьте фактическую публикацию через `docker ps` и протестируйте порт с внешней машины. Docker должен быть версии 28.0.0 или новее: в более старых релизах хосты из того же L2-сегмента могли достучаться до портов, опубликованных на localhost.
Можно ли вообще убрать `ports` у базы данных?
Да, и это мой предпочтительный вариант. Контейнеры одной пользовательской сети обращаются к базе по имени сервиса и внутреннему порту. Публикация нужна только для соединений с хоста или извне.
Где ограничивать прямой доступ к опубликованному порту?
Сначала на внешнем firewall провайдера или маршрутизатора, затем в `DOCKER-USER` при iptables-бэкенде. Для nftables-бэкенда Docker 29 используется собственная nftables-таблица с base chain, поскольку `DOCKER-USER` там нет.
Поможет ли `ufw route deny`?
Иногда такое правило можно встроить в проходящий через FORWARD трафик, но результат зависит от порядка цепочек относительно разрешающих правил Docker. Я не выбираю этот вариант как основной: явный `DOCKER-USER` предсказуемее для штатного iptables-бэкенда.
Нужно ли отключать управление iptables в Docker?
Нет, если у вас нет полностью спроектированного replacement-ruleset. Отключение правил Docker часто ломает bridge-сети, NAT и исходящий доступ контейнеров, а иногда создаёт новые проблемы с изоляцией.
Стоит ли ставить ufw-docker?
Можно, если администраторы привыкли к UFW. Утилита добавляет правила в `/etc/ufw/after.rules` и работает через `DOCKER-USER`, а открывать доступ нужно по порту контейнера, а не хоста. С экспериментальным nftables-бэкендом Docker 29 она не совместима, и отказываться от лишних публикаций ради неё не стоит.
Источники
- Docker Docs — Packet filtering and firewalls, разделы Firewall backend, Prevent Docker from manipulating firewall rules и Docker and ufw; актуально для Docker Engine 29; https://docs.docker.com/engine/network/packet-filtering-firewalls/
- Docker Docs — Docker with iptables, разделы Docker and iptables chains, Add iptables policies before Docker's rules, Match the original IP and ports и Restrict external connections; https://docs.docker.com/engine/network/firewall-iptables/
- Docker Docs — Port publishing and mapping, разделы Publishing ports и Setting the default bind address; включая предупреждение для версий до 28.0.0; https://docs.docker.com/engine/network/port-publishing/
- Docker Docs — Docker with nftables, Docker Engine 29, разделы Docker's nftables tables и Migrating DOCKER-USER; nftables-бэкенд отмечен экспериментальным; https://docs.docker.com/engine/network/firewall-nftables/
- Docker Docs — Docker Engine version 29 release notes, релиз 29.8.0 от 3 сентября 2026 года; https://docs.docker.com/engine/release-notes/29/
- Ubuntu Server documentation — Firewall, раздел ufw — Uncomplicated Firewall; описание UFW как host-based frontend и правил ufw-user-input; обновлено 26 июня 2026 года; https://ubuntu.com/server/docs/how-to/security/firewalls/
- Ubuntu Packages — Пакет ufw 0.36.2-6 для Ubuntu 24.04 LTS Noble; https://packages.ubuntu.com/noble/ufw
- chaifeng/ufw-docker (GitHub) — README проекта ufw-docker: изменения /etc/ufw/after.rules, команды ufw-docker install и ufw-docker allow, правило ufw route allow по порту контейнера; https://github.com/chaifeng/ufw-docker
