АйТи Фреш
Главная / Статьи / Безопасность
Безопасность

Почему UFW не закрывает опубликованный порт Docker

Автор: Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш» · · ~18 мин чтения
Почему UFW не закрывает опубликованный порт Docker
Иллюстрация к статье «Почему 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/FORWARDDOCKER-USER → разрешающие цепочки Docker → bridge-интерфейс → контейнер. Правило ufw deny 5432/tcp обычно оказывается в ufw-user-input. До него этот пакет не доходит. Это не уязвимость и не случайная несовместимость конкретного релиза, а следствие того, как Docker реализует публикацию портов.

Моя позиция жёсткая: внутренний сервис вообще не должен публиковаться на внешний интерфейс. Базу данных, Redis, административную панель и метрики я либо оставляю только в Docker-сети, либо привязываю к loopback. DOCKER-USER использую как точечный ACL, когда прямой внешний доступ действительно нужен. Пытаться превратить UFW в единственный источник истины для всех контейнерных маршрутов я перестал давно — слишком легко получить ложное ощущение закрытого сервера.

Запрет UFW нельзя считать проверенным, пока вы не протестировали порт с другой машины. Проверка через `localhost` или с самого Docker-хоста не моделирует внешний трафик.
Памятка: UFW не сломан: пакет идёт другим маршрутом — схема
Памятка: UFW не сломан: пакет идёт другим маршрутом. Открыть схему в полном размере

Как увидеть правила, которые скрыты за 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. Механически переносить старые инструкции туда нельзя.

Не удаляйте и не редактируйте цепочки, созданные самим Docker. Их структура менялась даже между соседними релизами. Для iptables-бэкенда точка расширения — `DOCKER-USER`.
Почему UFW не закрывает опубликованный порт 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 она не остановит.

Если указать только `5432:5432`, Docker по умолчанию публикует порт на `0.0.0.0` и `[::]`. Не рассчитывайте, что UFW исправит небезопасный Compose-файл.
Памятка: Мой основной способ: loopback, внутренняя сеть и reverse proxy — схема
Памятка: Мой основной способ: loopback, внутренняя сеть и reverse proxy. Открыть схему в полном размере

Практика: «Клёвое место», 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 сотрудников не было.

При изменении `ports` контейнер обычно пересоздаётся. До работ проверьте volume, резервную копию и допустимый простой: закрытие порта не должно закончиться потерей базы.

Когда нужен 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-публикации никто не проверяет, встречается слишком часто.

Правило `ACCEPT` в `DOCKER-USER` принимает пакет окончательно: остальные цепочки Docker, включая изоляцию сетей, он уже не проходит. Поэтому широкий `ACCEPT` может открыть больше, чем задумано. Для ACL я пишу узкие `DROP` для запрещённого, а разрешённое оставляю на штатные правила Docker.
Порядок действий: Когда нужен DOCKER-USER и как сделать ACL — схема
Порядок действий: Когда нужен DOCKER-USER и как сделать ACL. Открыть схему в полном размере

На что тратить время, а на что — нет

Не советую выставлять в конфигурации 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 и проверьте эти адреса снаружи.

Для типового сервера самое надёжное правило скучное: наружу публикуется только то, что действительно является публичным сервисом. Всё остальное живёт во внутренней сети или на loopback.

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

Почему `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 она не совместима, и отказываться от лишних публикаций ради неё не стоит.

Столкнулись с похожей задачей? Обращайтесь — решим

Если у вас происходит что-то из описанного в этой статье — или любая другая проблема с ИТ-инфраструктурой, — обращайтесь в любое время. Мои специалисты и я лично разберём ситуацию, найдём настоящую причину и доведём до решения.

Возьмёмся и за разовую задачу, и за постоянное обслуживание. Первичная консультация — бесплатно и без обязательств.

📞 +7 903 729-62-41 💬 MAX: +7 903 729-62-41 ✈ Telegram @ITfresh_Boss

С уважением, Семёнов Евгений Сергеевич, директор «АйТи Фреш» — IT-аутсорсинг для компаний до 50 рабочих мест, 15+ лет практики

Источники

© ООО «АйТи-Фреш» · Москва · Все статьи