Короткий ответ
«Порт контейнера недоступен» почти никогда не лечится 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 внутри |
| Только с хоста, не с LAN | bind 127.0.0.1 на хосте или UFW |
| Нигде, контейнер Restarting | не порт, а процесс |
| Ping хоста есть, TCP нет | firewall/publish |
Соседние: нет интернета у контейнера — это egress; не видят друг друга — east-west DNS/сеть.
Возможные причины
- Забыли
ports:, оставили толькоexpose. 127.0.0.1:8080:8080скопировали из «безопасного» gist; с LAN не зайти.- Приложение слушает
127.0.0.1внутри namespace — publish бесполезен. - UFW default deny, правило для 8080 не добавлено (Docker иногда обходит UFW через FORWARD — поведение зависит от
DOCKER-USER; не считайте это документацией к отключению UFW). - Порт занят, compose поднял сервис без publish после ошибки.
- IPv6: клиент идёт на AAAA, слушаете только IPv4.
- Редко:
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 resetvs 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.