Короткий ответ

«Порт контейнера недоступен» почти никогда не лечится network_mode: host и не требует privileged. На host.example сверните три слоя: слушает ли процесс внутри (docker compose exec web ss -lntp), опубликован ли он (ports: / docker port), на какой bind-адрес (0.0.0.0 vs 127.0.0.1), пропускает ли UFW/nft. expose: в Compose не публикует порт на хост. Клиенты в LAN, которые ходят на 10.0.20.10:8080, получат refused, если publish только 127.0.0.1:8080:8080.

Симптомы и как отличить

  • с самого хоста curl 127.0.0.1:8080 работает, с рабочего места — нет;
  • или наоборот: внутри сети compose curl web:8080 ок, с хоста нет;
  • docker ps в колонке PORTS пусто при живом nginx;
  • браузер крутит, nmap показывает filtered.
Где работаетВывод
Только docker compose exec на 127.0.0.1:8080нет publish, либо приложение bind только lo внутри
Только с хоста, не с LANbind 127.0.0.1 на хосте или UFW
Нигде, контейнер Restartingне порт, а процесс
Ping хоста есть, TCP нетfirewall/publish

Соседние: нет интернета у контейнера — это egress; не видят друг друга — east-west DNS/сеть.

Возможные причины

  1. Забыли ports: , оставили только expose.
  2. 127.0.0.1:8080:8080 скопировали из «безопасного» gist; с LAN не зайти.
  3. Приложение слушает 127.0.0.1 внутри namespace — publish бесполезен.
  4. UFW default deny, правило для 8080 не добавлено (Docker иногда обходит UFW через FORWARD — поведение зависит от DOCKER-USER; не считайте это документацией к отключению UFW).
  5. Порт занят, compose поднял сервис без publish после ошибки.
  6. IPv6: клиент идёт на AAAA, слушаете только IPv4.
  7. Редко: iptables: false в daemon.json.

Диагностика

IP хоста 10.0.20.10, сервис web, контейнер app-web-1.

1. Что думает Docker о publish

cd /opt/app
docker compose ps
docker port app-web-1
docker inspect app-web-1 --format '{{json .NetworkSettings.Ports}}'
docker inspect app-web-1 --format '{{json .HostConfig.PortBindings}}'

Пустой PortBindings — нет publish.

2. Кто слушает на хосте

sudo ss -tlnp | grep -E '8080|docker-proxy'

Bind 127.0.0.1:8080 vs *:8080 vs [::]:8080 — разные миры.

3. Слушает ли приложение внутри

docker compose exec web sh -c 'ss -lntp || netstat -lntp'

127.0.0.1:8080 внутри — с хоста через -p 8080:8080 не попадёте: DNAT идёт на eth0 контейнера, не на lo. Нужен bind 0.0.0.0 в приложении.

4. Firewall

sudo ufw status verbose
sudo nft list ruleset | grep -n -i -E '8080|DOCKER-USER|ufw-user' | head

Не ufw disable. Проверка с другой машины в LAN:

# на jump-хосте, не на host.example
curl -sS -o /dev/null -w '%{http_code}\n' --max-time 5 http://10.0.20.10:8080/

curl с localhost хоста не доказывает доступность для пользователей.

Решение

Сценарий A. Нет ports в YAML

services:
  web:
    ports:
      - "10.0.20.10:8080:8080"

Явный bind на LAN-адрес лучше 0.0.0.0, если сервис не должен торчать на второй NIC/WAN. Пересоздание:

docker compose up -d web
docker port app-web-1

Сценарий B. Случайно 127.0.0.1

Замените "127.0.0.1:8080:8080" на адрес, с которого должны ходить клиенты, либо на reverse-proxy на localhost + публикация только proxy. Не публикуйте БД на 0.0.0.0.

Сценарий C. Приложение на loopback внутри

Конфиг gunicorn/uvicorn/bind 127.0.0.1. Смените на 0.0.0.0 внутри сети контейнера. Это не «открыть мир»: мир режет publish и UFW.

Сценарий D. UFW

Разрешите только нужный источник:

sudo ufw allow from 10.0.10.0/24 to any port 8080 proto tcp comment 'app web'
sudo ufw status numbered

Если пакеты всё же обходят UFW через цепочки Docker — добавьте явное правило в DOCKER-USER (DROP с чужих), не отключайте фильтр. Документируйте.

Сценарий E. Порт занят

sudo ss -tlnp | grep 8080

Остановите чужой bind или смените publish в compose и в proxy/DNS.

Как проверить, что проблема устранена

С хоста и с LAN- hop:

curl -sS -o /dev/null -w '%{http_code} local\n' --max-time 5 http://127.0.0.1:8080/
curl -sS -o /dev/null -w '%{http_code} lan\n' --max-time 5 http://10.0.20.10:8080/
docker port app-web-1

Коды 200/302/401 — «порт жив» (401 тоже успех транспорта). 000 — снова refused/timeout. Сверьте матрицу: кто имеет право ходить.

Если не помогло

  • HTTP ок на IP, имя app.example нет — DNS, не Docker.
  • Работает IPv4, клиент на AAAA — либо слушайте IPv6, либо не публикуйте AAAA.
  • Connection reset vs refused: процесс упал после accept; логи приложения.
  • Порт виден с хоста, не с WAN — так и должно быть, если нет DNAT на периметре. Не «чините» публикацией на все интерфейсы ради теста с телефона в LTE без VPN.
  • Hairpin с самого host.example на свой публичный DNAT часто ломается; тестируйте с другой машины.

На Ubuntu 22.04/24.04 ufw и цепочки Docker живут рядом: пакет до docker-proxy может пройти FORWARD, а INPUT на 8080 — нет, или наоборот. Не делайте вывод по одному curl с localhost. Если в daemon.json включён ip6tables (по умолчанию в 27.x часто да), клиент с AAAA ходит иначе, чем ваш IPv4-тест. Сверьте docker port с ss -tlnp6.

Публикация 8080:8080 без адреса на хосте с двумя NIC (LAN 10.0.20.10 и WAN) означает listen на обоих. Для внутреннего HTTP это лишняя поверхность. Явный bind на LAN плюс reverse-proxy — нормальный эталон, не «усложнение».

Профилактика

  • В YAML явный bind-адрес, не голый 8080:8080 на хостах с WAN.
  • Матрица портов: сервис / адрес / источник.
  • Reverse-proxy на 80/443, приложение на localhost или внутренней сети compose.
  • Не expose вместо ports в runbook для «внешнего» доступа.
  • Мониторинг ss + внешний probe.

FAQ

expose vs ports

expose — документация и доступ из других контейнеров той же сети. ports — публикация на хост (и дальше по firewall).

Нужен ли host network для «как на железе»?

Нет для обычного HTTP. network_mode: host ломает DNS имён сервисов и изоляцию. Только штучные агенты, и то осознанно.

Почему curl localhost с хоста работает, а IP своей NIC — нет?

Bind 127.0.0.1 на docker-proxy. Либо UFW по интерфейсам.

Docker обходит UFW — отключить UFW?

Нет. Настройте DOCKER-USER и публикацию на нужный адрес. Отключённый firewall — не диагностика.

Можно ли -p 80:80 плюс nginx на хосте?

Нет одновременно. Один listen. Либо контейнер, либо хостовый nginx, либо разные IP.