8.3. Port publishing
Цели
После этого материала вы сможете:
- прочитать и написать любую форму
-p, включая привязку к адресу и диапазоны; - объяснить, почему приложение обязано слушать
0.0.0.0, и диагностировать обратное; - найти правило DNAT, созданное публикацией порта;
- объяснить разницу между
EXPOSE,--exposeи-p; - понять, почему
ss -tlnpиногда не показывает опубликованный порт; - ограничить доступ к порту адресом
127.0.0.1и объяснить, почему это важнее правил firewall.
Предварительные знания
- 8.1. Основы Docker networking — путь пакета и DNAT;
- 8.2. Bridge networks.
Ключевые термины
| Термин | Объяснение |
|---|---|
published port | Порт host, транслируемый в порт container'а |
exposed port | Объявление в метаданных образа; доступа не даёт |
DNAT | Подмена адреса назначения |
docker-proxy | Вспомогательный процесс, слушающий опубликованный порт |
ephemeral port | Порт из динамического диапазона, выбранный ядром |
DOCKER-USER | Цепочка netfilter для ваших правил |
Теория
Полный синтаксис
-p [адрес_host:][порт_host:]порт_container[/протокол]
| Форма | Значение |
|---|---|
-p 8080:80 | Порт 80 container'а доступен на всех адресах host, порт 8080 |
-p 127.0.0.1:8080:80 | Только с самого host |
-p 192.168.1.10:8080:80 | Только на этом адресе host |
-p 80 | Порт 80 доступен на случайном свободном порту host |
-p 8080:80/udp | Протокол UDP; для обоих нужны две записи |
-p 8000-8010:8000-8010 | Диапазон, порт в порт |
-P | Все порты из EXPOSE — на случайные порты host |
Форма без адреса означает 0.0.0.0, то есть все интерфейсы host, включая внешний. Это умолчание — источник большинства случайных публикаций сервисов в сеть.
Практическое правило: для всего, что не должно быть доступно извне, указывайте адрес явно.
docker run -p 127.0.0.1:5432:5432 postgres:17-alpine
EXPOSE, --expose и -p
Три похожих понятия с разными последствиями.
| Что делает | Даёт доступ с host | |
|---|---|---|
EXPOSE 8000 в Dockerfile | Запись в метаданных образа | Нет |
--expose 8000 при запуске | Запись в метаданных container'а | Нет |
-p 8000:8000 | Правило DNAT | Да |
EXPOSE — документация: он сообщает, какой порт слушает приложение. Единственный функциональный эффект — флаг -P публикует именно объявленные порты.
Внутри общей сети порт доступен независимо от EXPOSE (урок 8.2): объявление ничего не открывает и ничего не закрывает.
Оставлять EXPOSE в Dockerfile стоит — как подсказку читателю и для -P.
Приложение обязано слушать 0.0.0.0
Пакет приходит в container через eth0 (урок 8.1). Процесс, привязанный к 127.0.0.1, слушает только loopback своего namespace и такой пакет не получит.
| Слушает | Доступен изнутри container'а | Доступен из соседнего container'а | Доступен с host при -p |
|---|---|---|---|
127.0.0.1 | Да | Нет | Нет |
0.0.0.0 | Да | Да | Да |
| Конкретный адрес container'а | Да | Да | Да |
Симптом ошибки узнаваем: container в состоянии running, в логах чисто, docker port показывает публикацию, а curl возвращает пустой ответ или сброс соединения.
Умолчания фреймворков различаются, и это важно знать:
| Инструмент | Умолчание | Как задать 0.0.0.0 |
|---|---|---|
flask run | 127.0.0.1 | --host 0.0.0.0 |
uvicorn | 127.0.0.1 | --host 0.0.0.0 |
fastapi run | 0.0.0.0 | Уже верно |
gunicorn | 127.0.0.1:8000 | -b 0.0.0.0:8000 |
python -m http.server | Все адреса | Уже верно |
django runserver | 127.0.0.1:8000 | 0.0.0.0:8000 |
Возражение «слушать 0.0.0.0 небезопасно» справедливо для обычного сервера, но не для container'а: внутри namespace нет других интерфейсов, кроме eth0 и lo. Доступ извне определяется публикацией порта, а не привязкой процесса.
Что происходит при -p
- Docker проверяет, свободен ли порт host; при конфликте — ошибка.
- Создаётся правило DNAT в цепочке
DOCKERтаблицыnat. - Добавляется разрешающее правило в цепочку
DOCKERтаблицыfilter. - Запускается вспомогательный процесс
docker-proxy, слушающий этот порт (если не отключён).
Четвёртый пункт объясняет наблюдение, которое многих сбивает: ss -tlnp на host показывает docker-proxy, а не приложение. И наоборот — при userland-proxy: false порт не виден в ss, хотя работает: трансляция происходит целиком в netfilter, без слушающего сокета.
Отсюда правило диагностики: отсутствие порта в ss на host не означает, что публикация не работает. Достоверный источник — docker port и правила nat.
Публикация и firewall host
Важное для безопасности следствие устройства DNAT.
Трансляция происходит в цепочке PREROUTING, то есть до фильтрации в INPUT. Правила брандмауэра host, написанные для INPUT (типичные ufw deny 5432), опубликованный порт не закрывают: пакет к нему обрабатывается как транзитный и идёт через FORWARD.
Практические последствия:
| Что вы сделали | Что получилось |
|---|---|
ufw deny 5432 плюс -p 5432:5432 | Порт доступен извне |
-p 127.0.0.1:5432:5432 | Доступен только с host — работает |
Правило в цепочке DOCKER-USER | Работает: цепочка обрабатывается до правил Docker |
Надёжный способ — не публиковать лишнего и привязывать к 127.0.0.1. Правила брандмауэра — второй рубеж, и писать их нужно в DOCKER-USER:
sudo iptables -I DOCKER-USER -i eth0 -p tcp --dport 5432 -j DROP
Docker Engine 28 ужесточил часть умолчаний в этой области. Проверяйте поведение на своей версии командой из раздела «Команды и примеры» — не полагайтесь на предположения, включая изложенные здесь.
Порт занят
Error response from daemon: driver failed programming external connectivity on
endpoint web: Bind for 0.0.0.0:8080 failed: port is already allocated
Причина всегда одна: порт host занят — другим container'ом или процессом host. Диагностика:
docker ps --format '{{.Names}}\t{{.Ports}}' | grep 8080
sudo ss -tlnp | grep ':8080 '
Случайный порт
docker run -d -p 8000 myimage
docker port <container> 8000
Ядро выбирает свободный порт из динамического диапазона. Применяется в тестах, когда нужно запустить несколько экземпляров одновременно, не согласовывая номера.
Внутренний механизм
Правило DNAT
sudo iptables -t nat -S DOCKER
-A DOCKER -i docker0 -j RETURN
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 8080 -j DNAT --to-destination 172.17.0.2:8000
Второе правило и есть публикация: пакет, пришедший не через docker0 на порт 8080, получает новый адрес назначения.
Первое правило важно не меньше: трафик, уже пришедший с docker0, трансляции не подвергается — иначе обращения container'ов друг к другу ломались бы.
Зачем нужен docker-proxy
Правило DNAT в PREROUTING не срабатывает для пакетов, порождённых самим host и адресованных 127.0.0.1: они не проходят PREROUTING. Для таких случаев есть правило в OUTPUT, но остаются пограничные ситуации — например, обращение container'а к опубликованному порту через адрес host.
docker-proxy закрывает их, принимая соединение в пространстве пользователя и открывая встречное к container'у. Цена — процесс на каждый опубликованный порт и лишнее копирование данных.
Отключается в /etc/docker/daemon.json:
{
"userland-proxy": false
}
Команды и примеры
Ошибка, которая встречается чаще всех
mkdir -p /tmp/bindtest && cd /tmp/bindtest
cat > app.py <<'PY'
"""Один и тот же сервер, привязка задаётся переменной окружения."""
import os
from http.server import HTTPServer, BaseHTTPRequestHandler
class H(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.end_headers()
self.wfile.write(b"ok\n")
def log_message(self, *args):
pass
host = os.environ.get("BIND", "127.0.0.1")
print(f"слушаю {host}:8000", flush=True)
HTTPServer((host, 8000), H).serve_forever()
PY
cat > Dockerfile <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY app.py .
EXPOSE 8000
CMD ["python", "app.py"]
EOF
docker build -q -t bindtest . > /dev/null
echo "═══ привязка к 127.0.0.1 ═══"
docker run -d --name bad -p 18001:8000 -e BIND=127.0.0.1 bindtest > /dev/null
sleep 3
printf ' статус container: %s\n' "$(docker inspect bad --format '{{.State.Status}}')"
printf ' логи: %s\n' "$(docker logs bad 2>&1 | head -1)"
printf ' docker port: %s\n' "$(docker port bad)"
printf ' curl с host: '
curl -s -m 3 -o /dev/null -w '%{http_code}\n' http://localhost:18001/ || echo "нет ответа"
printf ' curl внутри: '
docker exec bad python -c "
import urllib.request
print(urllib.request.urlopen('http://127.0.0.1:8000/', timeout=2).status)
"
echo
echo "═══ привязка к 0.0.0.0 ═══"
docker run -d --name good -p 18002:8000 -e BIND=0.0.0.0 bindtest > /dev/null
sleep 3
printf ' логи: %s\n' "$(docker logs good 2>&1 | head -1)"
printf ' curl с host: %s\n' \
"$(curl -s -m 3 -o /dev/null -w '%{http_code}' http://localhost:18002/)"
Ожидаемый вывод:
═══ привязка к 127.0.0.1 ═══
статус container: running
логи: слушаю 127.0.0.1:8000
docker port: 8000/tcp -> 0.0.0.0:18001
curl с host: 000
нет ответа
curl внутри: 200
═══ привязка к 0.0.0.0 ═══
логи: слушаю 0.0.0.0:8000
curl с host: 200
Разберём первый блок — он воспроизводит самую частую сетевую проблему.
Container работает, логи чистые, публикация настроена — docker port подтверждает трансляцию. Изнутри сервер отвечает 200. А снаружи ответа нет.
Причина в единственной строке логов: слушаю 127.0.0.1:8000. Пакет с host приходит на eth0 container'а, а процесс слушает только loopback.
Ключ к диагностике — сравнение двух curl: изнутри работает, снаружи нет. Это сразу отсекает половину гипотез (урок 8.6).
docker rm -f bad good > /dev/null
Где именно слушает процесс
docker run -d --name probe -p 18003:8000 -e BIND=127.0.0.1 bindtest > /dev/null
sleep 3
echo "═══ ss внутри namespace (через nsenter, без установки утилит) ═══"
pid="$(docker inspect -f '{{.State.Pid}}' probe)"
sudo nsenter -t "$pid" -n ss -tlnp 2>/dev/null | sed 's/^/ /'
echo "═══ то же без nsenter — через /proc/net/tcp ═══"
docker exec probe python -c "
with open('/proc/net/tcp') as f:
next(f)
for line in f:
p = line.split()
if p[3] != '0A': # 0A = LISTEN
continue
addr_hex, port_hex = p[1].split(':')
addr = '.'.join(str(int(addr_hex[i:i+2], 16)) for i in (6, 4, 2, 0))
print(f' слушает {addr}:{int(port_hex, 16)}')
"
docker rm -f probe > /dev/null
Ожидаемый вывод:
═══ ss внутри namespace (через nsenter, без установки утилит) ═══
State Recv-Q Send-Q Local Address:Port Peer Address:Port
LISTEN 0 5 127.0.0.1:8000 0.0.0.0:*
═══ то же без nsenter — через /proc/net/tcp ═══
слушает 127.0.0.1:8000
127.0.0.1:8000 вместо 0.0.0.0:8000 — прямая улика. Второй способ полезен, когда нет прав sudo на host: /proc/net/tcp доступен в любом образе.
Правило DNAT
docker run -d --name dnat -p 18004:8000 -e BIND=0.0.0.0 bindtest > /dev/null
sleep 2
ip_c="$(docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' dnat)"
echo "═══ правило, созданное публикацией ═══"
sudo iptables -t nat -S DOCKER 2>/dev/null | grep 18004 | sed 's/^/ /'
echo "═══ адрес container'а ═══"
echo " $ip_c"
echo "═══ разрешающее правило в filter ═══"
sudo iptables -S DOCKER 2>/dev/null | grep -F "$ip_c" | sed 's/^/ /'
echo "═══ счётчик пакетов до и после запроса ═══"
before="$(sudo iptables -t nat -L DOCKER -n -v 2>/dev/null | awk '/18004/ {print $1; exit}')"
curl -s -o /dev/null http://localhost:18004/
after="$(sudo iptables -t nat -L DOCKER -n -v 2>/dev/null | awk '/18004/ {print $1; exit}')"
printf ' пакетов через правило: %s → %s\n' "${before:-0}" "${after:-0}"
Ожидаемый вывод:
═══ правило, созданное публикацией ═══
-A DOCKER ! -i docker0 -p tcp -m tcp --dport 18004 -j DNAT --to-destination 172.17.0.2:8000
═══ адрес container'а ═══
172.17.0.2
═══ разрешающее правило в filter ═══
-A DOCKER -d 172.17.0.2/32 ! -i docker0 -o docker0 -p tcp -m tcp --dport 8000 -j ACCEPT
═══ счётчик пакетов до и после запроса ═══
пакетов через правило: 0 → 1
Счётчик — самое убедительное доказательство: правило не просто существует, через него прошёл пакет. Это отличает «правило есть, но не срабатывает» от «правило работает».
Такая проверка — стандартный шаг диагностики, когда порт опубликован, а ответа нет.
docker-proxy и ss на host
echo "═══ кто слушает 18004 на host ═══"
sudo ss -tlnp 2>/dev/null | grep ':18004 ' | sed 's/^/ /'
echo "═══ процессы docker-proxy ═══"
ps -eo pid,args 2>/dev/null | grep '[d]ocker-proxy' | head -3 | sed 's/^/ /'
echo "═══ режим userland-proxy ═══"
docker info --format ' userland-proxy отключён: {{index .SecurityOptions}}' > /dev/null 2>&1
grep -q '"userland-proxy": *false' /etc/docker/daemon.json 2>/dev/null \
&& echo " userland-proxy отключён в daemon.json" \
|| echo " userland-proxy включён (умолчание)"
docker rm -f dnat > /dev/null
Ожидаемый вывод:
═══ кто слушает 18004 на host ═══
LISTEN 0 4096 0.0.0.0:18004 0.0.0.0:* users:(("docker-proxy",pid=48213,fd=7))
═══ процессы docker-proxy ═══
48213 /usr/bin/docker-proxy -proto tcp -host-ip 0.0.0.0 -host-port 18004 -container-ip 172.17.0.2 -container-port 8000
═══ режим userland-proxy ═══
userland-proxy включён (умолчание)
В ss виден docker-proxy, а не ваше приложение — это нормально. Аргументы процесса читаются как полное описание трансляции.
При userland-proxy: false этих строк не будет вовсе, а публикация продолжит работать через DNAT. Поэтому вывод ss без записи о порте — не доказательство неисправности.
Ограничение адресом
echo "═══ публикация на всех интерфейсах ═══"
docker run -d --name open -p 18005:8000 -e BIND=0.0.0.0 bindtest > /dev/null
sleep 3
docker port open | sed 's/^/ /'
host_ip="$(ip route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}')"
printf ' через localhost: %s\n' "$(curl -s -m 3 -o /dev/null -w '%{http_code}' http://localhost:18005/)"
printf ' через %s: %s\n' "$host_ip" "$(curl -s -m 3 -o /dev/null -w '%{http_code}' "http://$host_ip:18005/")"
echo
echo "═══ публикация только на localhost ═══"
docker run -d --name closed -p 127.0.0.1:18006:8000 -e BIND=0.0.0.0 bindtest > /dev/null
sleep 3
docker port closed | sed 's/^/ /'
printf ' через localhost: %s\n' "$(curl -s -m 3 -o /dev/null -w '%{http_code}' http://localhost:18006/)"
printf ' через %s: %s\n' "$host_ip" \
"$(curl -s -m 3 -o /dev/null -w '%{http_code}' "http://$host_ip:18006/" 2>/dev/null || echo 'недоступен')"
docker rm -f open closed > /dev/null
Ожидаемый вывод:
═══ публикация на всех интерфейсах ═══
8000/tcp -> 0.0.0.0:18005
через localhost: 200
через 192.168.1.42: 200
═══ публикация только на localhost ═══
8000/tcp -> 127.0.0.1:18006
через localhost: 200
через 192.168.1.42: 000
Второй случай — то, что нужно для базы данных, административных панелей и всего, к чему обращается только сам host.
Разница в одном префиксе 127.0.0.1:, а последствия принципиальные: в первом случае сервис доступен всей локальной сети, во втором — только машине.
EXPOSE ничего не открывает
echo "═══ образ объявляет EXPOSE 8000 ═══"
docker image inspect bindtest --format ' ExposedPorts: {{json .Config.ExposedPorts}}'
echo "═══ запуск БЕЗ -p ═══"
docker run -d --name noexp -e BIND=0.0.0.0 bindtest > /dev/null
sleep 3
printf ' docker port: %s\n' "$(docker port noexp || echo 'пусто')"
printf ' curl с host: %s\n' "$(curl -s -m 3 -o /dev/null -w '%{http_code}' http://localhost:8000/ 2>/dev/null || echo '000')"
echo "═══ запуск с -P (публикует объявленные порты) ═══"
docker run -d --name withP -P -e BIND=0.0.0.0 bindtest > /dev/null
sleep 3
mapping="$(docker port withP 8000)"
printf ' docker port: %s\n' "$mapping"
port="${mapping##*:}"
printf ' curl: %s\n' "$(curl -s -m 3 -o /dev/null -w '%{http_code}' "http://localhost:$port/")"
docker rm -f noexp withP > /dev/null
Ожидаемый вывод:
═══ образ объявляет EXPOSE 8000 ═══
ExposedPorts: {"8000/tcp":{}}
═══ запуск БЕЗ -p ═══
docker port: пусто
curl с host: 000
═══ запуск с -P (публикует объявленные порты) ═══
docker port: 0.0.0.0:32768
curl: 200
EXPOSE присутствует в образе, но без -p или -P порт с host недоступен. Единственный функциональный эффект объявления — то, что -P знает, какой порт публиковать.
Занятый порт
docker run -d --name first -p 18007:8000 -e BIND=0.0.0.0 bindtest > /dev/null
sleep 2
echo "═══ вторая публикация того же порта ═══"
docker run -d --name second -p 18007:8000 -e BIND=0.0.0.0 bindtest 2>&1 | tail -2 | sed 's/^/ /'
echo "═══ кто занял ═══"
docker ps --format ' {{.Names}}\t{{.Ports}}' | grep 18007
docker rm -f first second > /dev/null 2>&1
docker rmi -f bindtest > /dev/null
cd /tmp && rm -rf /tmp/bindtest
Ожидаемый вывод:
═══ вторая публикация того же порта ═══
docker: Error response from daemon: driver failed programming external
connectivity on endpoint second: Bind for 0.0.0.0:18007 failed: port is already allocated
═══ кто занял ═══
first 0.0.0.0:18007->8000/tcp
Сообщение точное, и docker ps сразу называет виновника. Если бы порт занял процесс host, его показал бы ss -tlnp.
Проверьте поведение брандмауэра на своей системе
cat > /tmp/fwcheck.sh <<'SH'
#!/usr/bin/env bash
# Проверяет, закрывает ли ваш брандмауэр опубликованный порт.
# Выполняется локально; для полной проверки нужен второй хост.
set -uo pipefail
echo "═══ версия Docker и backend iptables ═══"
docker version --format ' Docker Engine: {{.Server.Version}}'
iptables -V 2>/dev/null | sed 's/^/ /' || sudo iptables -V | sed 's/^/ /'
echo
echo "═══ состояние брандмауэра ═══"
if command -v ufw > /dev/null 2>&1; then
sudo ufw status | head -5 | sed 's/^/ /'
elif command -v firewall-cmd > /dev/null 2>&1; then
sudo firewall-cmd --state 2>/dev/null | sed 's/^/ /'
else
echo " ufw и firewalld не установлены"
fi
echo
echo "═══ цепочка DOCKER-USER (место для ваших правил) ═══"
sudo iptables -S DOCKER-USER 2>/dev/null | sed 's/^/ /' || echo " цепочка не найдена"
echo
echo "═══ порядок обработки ═══"
echo " PREROUTING (nat, DNAT) → FORWARD (filter: DOCKER-USER, DOCKER) → container"
echo " Правила для INPUT опубликованных портов НЕ касаются."
echo
echo " Достоверная проверка доступности извне выполняется с другой машины:"
echo " curl -m 5 http://<адрес-этого-host>:<порт>/"
SH
chmod +x /tmp/fwcheck.sh
/tmp/fwcheck.sh
rm -f /tmp/fwcheck.sh
Ожидаемый вывод:
═══ версия Docker и backend iptables ═══
Docker Engine: 29.0.1
iptables v1.8.10 (nf_tables)
═══ состояние брандмауэра ═══
Status: active
To Action From
-- ------ ----
22/tcp ALLOW Anywhere
═══ цепочка DOCKER-USER (место для ваших правил) ═══
-N DOCKER-USER
-A DOCKER-USER -j RETURN
═══ порядок обработки ═══
PREROUTING (nat, DNAT) → FORWARD (filter: DOCKER-USER, DOCKER) → container
Правила для INPUT опубликованных портов НЕ касаются.
Достоверная проверка доступности извне выполняется с другой машины:
curl -m 5 http://<адрес-этого-host>:<порт>/
Строка iptables v1.8.10 (nf_tables) показывает, что команда iptables здесь — интерфейс к nftables; правила хранятся в новой подсистеме, а синтаксис остался прежним.
Пустая DOCKER-USER с единственным RETURN — состояние по умолчанию: своих правил нет.
Последняя строка существенна: проверить доступность порта извне с той же машины нельзя. Только запрос с другого хоста даёт достоверный ответ.
Практическое упражнение
Задание. Соберите приложение с двумя портами и настройте публикацию по-разному, доказав каждое утверждение.
Требования:
- Публичный порт приложения доступен с host и с других машин локальной сети.
- Административный порт доступен только с самого host.
- Порт базы данных не опубликован вовсе, но доступен приложению.
- Найдено и показано правило DNAT для каждого опубликованного порта.
- Показано, что счётчик пакетов правила растёт при обращении.
- Приложение, привязанное к
127.0.0.1, воспроизведено и исправлено с объяснением.
Подсказки
Подсказка 1
Требование 1 нельзя достоверно проверить с той же машины. Приблизьтесь: обратитесь по адресу host, а не по localhost.
Подсказка 2
Требование 3 проверяется из соседнего container'а и с host — результаты должны различаться.
Подсказка 3
Счётчики правил показывает iptables -t nat -L DOCKER -n -v.
Решение
Показать решение
mkdir -p /tmp/pubports && cd /tmp/pubports
cat > app.py <<'PY'
"""Приложение с публичным и административным портами."""
from __future__ import annotations
import os
import threading
from http.server import BaseHTTPRequestHandler, HTTPServer
def make_handler(name: str):
class H(BaseHTTPRequestHandler):
def do_GET(self):
self.send_response(200)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.end_headers()
self.wfile.write(f"{name}\n".encode())
def log_message(self, *args):
pass
return H
def serve(port: int, name: str, bind: str) -> None:
print(f"{name}: слушаю {bind}:{port}", flush=True)
HTTPServer((bind, port), make_handler(name)).serve_forever()
if __name__ == "__main__":
bind = os.environ.get("BIND", "0.0.0.0")
threading.Thread(target=serve, args=(8000, "public", bind), daemon=True).start()
serve(9000, "admin", bind)
PY
cat > Dockerfile <<'EOF'
FROM python:3.13-slim
ENV PYTHONUNBUFFERED=1
WORKDIR /app
COPY app.py .
EXPOSE 8000 9000
CMD ["python", "app.py"]
EOF
docker build -q -t pubports . > /dev/null
docker network create pubnet > /dev/null
# Требование 3: база без публикации
docker run -d --network pubnet --name db \
-e POSTGRES_PASSWORD=secret postgres:17-alpine > /dev/null
# Требования 1 и 2: разная публикация двух портов
docker run -d --network pubnet --name app \
-p 18100:8000 \
-p 127.0.0.1:19100:9000 \
pubports > /dev/null
docker run -d --network pubnet --name neighbour python:3.13-slim sleep 600 > /dev/null
sleep 10
host_ip="$(ip route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}')"
fail=0
ok() { printf ' ✓ %s\n' "$1"; }
bad() { printf ' ✗ %s\n' "$1"; fail=1; }
code() { curl -s -m 4 -o /dev/null -w '%{http_code}' "$1" 2>/dev/null || echo 000; }
printf '\n═══ Требование 1: публичный порт ═══\n'
printf ' localhost:18100 → %s\n' "$(code http://localhost:18100/)"
printf ' %s:18100 → %s\n' "$host_ip" "$(code "http://$host_ip:18100/")"
[ "$(code "http://$host_ip:18100/")" = "200" ] \
&& ok "доступен по адресу host (значит, и из локальной сети)" \
|| bad "недоступен по адресу host"
printf ' привязка: %s\n' "$(docker port app 8000)"
printf '\n═══ Требование 2: административный порт ═══\n'
printf ' localhost:19100 → %s\n' "$(code http://localhost:19100/)"
printf ' %s:19100 → %s\n' "$host_ip" "$(code "http://$host_ip:19100/")"
[ "$(code http://localhost:19100/)" = "200" ] && [ "$(code "http://$host_ip:19100/")" = "000" ] \
&& ok "доступен только с host" || bad "правило не сработало"
printf ' привязка: %s\n' "$(docker port app 9000)"
printf '\n═══ Требование 3: база без публикации ═══\n'
[ -z "$(docker port db)" ] && ok "портов не опубликовано" || bad "база публикует порты"
docker exec neighbour python -c "
import socket
s = socket.socket(); s.settimeout(3)
try:
s.connect(('db', 5432)); print(' ✓ из соседнего container доступна')
except Exception as e:
print(f' ✗ из соседнего container: {type(e).__name__}')
"
if curl -s -m 3 -o /dev/null telnet://localhost:5432 2>/dev/null; then
bad "база отвечает на host:5432"
else
ok "с host по localhost:5432 недоступна"
fi
printf '\n═══ Требование 4: правила DNAT ═══\n'
for p in 18100 19100; do
rule="$(sudo iptables -t nat -S DOCKER 2>/dev/null | grep -- "--dport $p ")"
if [ -n "$rule" ]; then
printf ' %s\n' "$rule"
else
bad "правило для порта $p не найдено"
fi
done
sudo iptables -t nat -S DOCKER 2>/dev/null | grep -q -- '--dport 5432 ' \
&& bad "для 5432 есть правило DNAT" || ok "для 5432 правила DNAT нет"
printf '\n═══ Требование 5: счётчик пакетов ═══\n'
cnt() { sudo iptables -t nat -L DOCKER -n -v 2>/dev/null | awk -v p="dpt:$1" '$0 ~ p {print $1; exit}'; }
before="$(cnt 18100)"
for _ in 1 2 3; do curl -s -o /dev/null "http://$host_ip:18100/"; done
after="$(cnt 18100)"
printf ' пакетов через правило 18100: %s → %s\n' "${before:-0}" "${after:-0}"
[ "${after:-0}" -gt "${before:-0}" ] && ok "правило реально обрабатывает трафик" \
|| bad "счётчик не изменился"
printf '\n═══ Требование 6: привязка к 127.0.0.1 ═══\n'
docker run -d --name broken -p 18101:8000 -e BIND=127.0.0.1 pubports > /dev/null
sleep 4
printf ' логи: %s\n' "$(docker logs broken 2>&1 | head -1)"
printf ' docker port: %s\n' "$(docker port broken 8000)"
printf ' снаружи: %s\n' "$(code http://localhost:18101/)"
printf ' изнутри: %s\n' \
"$(docker exec broken python -c "
import urllib.request
print(urllib.request.urlopen('http://127.0.0.1:8000/', timeout=2).status)" 2>/dev/null)"
[ "$(code http://localhost:18101/)" = "000" ] \
&& ok "проблема воспроизведена: публикация есть, доступа нет" \
|| bad "не воспроизвелось"
docker rm -f broken > /dev/null
docker run -d --name fixed -p 18102:8000 -e BIND=0.0.0.0 pubports > /dev/null
sleep 4
printf ' после BIND=0.0.0.0: %s\n' "$(code http://localhost:18102/)"
[ "$(code http://localhost:18102/)" = "200" ] && ok "исправлено" || bad "не исправилось"
docker rm -f fixed > /dev/null
printf '\n═══ ИТОГ ═══\n'
[ "$fail" -eq 0 ] && echo " все требования выполнены" || echo " ЕСТЬ ПРОВАЛЫ"
docker rm -f app db neighbour > /dev/null 2>&1
docker network rm pubnet > /dev/null 2>&1
docker rmi -f pubports > /dev/null 2>&1
cd /tmp && rm -rf /tmp/pubports
exit "$fail"
Ожидаемый вывод:
═══ Требование 1: публичный порт ═══
localhost:18100 → 200
192.168.1.42:18100 → 200
✓ доступен по адресу host (значит, и из локальной сети)
привязка: 0.0.0.0:18100
═══ Требование 2: административный порт ═══
localhost:19100 → 200
192.168.1.42:19100 → 000
✓ доступен только с host
привязка: 127.0.0.1:19100
═══ Требование 3: база без публикации ═══
✓ портов не опубликовано
✓ из соседнего container доступна
✓ с host по localhost:5432 недоступна
═══ Требование 4: правила DNAT ═══
-A DOCKER ! -i br-9f3a2c1 -p tcp -m tcp --dport 18100 -j DNAT --to-destination 172.22.0.3:8000
-A DOCKER ! -i br-9f3a2c1 -p tcp -m tcp -d 127.0.0.1 --dport 19100 -j DNAT --to-destination 172.22.0.3:9000
✓ для 5432 правила DNAT нет
═══ Требование 5: счётчик пакетов ═══
пакетов через правило 18100: 4 → 7
✓ правило реально обрабатывает трафик
═══ Требование 6: привязка к 127.0.0.1 ═══
логи: public: слушаю 127.0.0.1:8000
docker port: 0.0.0.0:18101
снаружи: 000
изнутри: 200
✓ проблема воспроизведена: публикация есть, доступа нет
после BIND=0.0.0.0: 200
✓ исправлено
═══ ИТОГ ═══
все требования выполнены
Все шесть требований выполнены.
Обратите внимание на разницу двух правил в требовании 4: у административного порта присутствует -d 127.0.0.1. Именно это условие и делает порт локальным — не брандмауэр, а сама трансляция.
Три решения, определяющие качество.
Требование 1 проверяется по адресу host, а не по localhost. Запрос к localhost прошёл бы даже при публикации на 127.0.0.1 и ничего бы не доказал. Обращение по внешнему адресу интерфейса — ближайшее, что можно сделать с одной машины: оно проходит тот же путь, что и запрос из локальной сети. Полная проверка требует второй машины, и об этом сказано прямо, а не замаскировано зелёной галочкой.
Требование 3 проверяется тремя способами: docker port, доступ из сети, доступ с host. Первый показывает конфигурацию, второй — что сервис вообще работает, третий — что он закрыт. Проверка только третьего дала бы «зелёный» результат и для остановленной базы.
Требование 5 сравнивает счётчик до и после. Наличие правила ничего не говорит о том, что трафик идёт именно через него: правило может быть перекрыто более ранним. Рост счётчика — единственное прямое доказательство. Это же главный приём при диагностике «правило есть, а не работает».
Чего решение не делает. Оно не проверяет доступность извне по-настоящему: все запросы уходят с той же машины, и путь пакета отличается от пути из локальной сети. Не проверяется и поведение брандмауэра: ufw deny 18100 не закрыл бы этот порт, но убедиться в этом можно только запросом с другого хоста. Для рабочей конфигурации оба пункта проверяют с отдельной машины, а ограничение доступа задают привязкой к адресу, а не правилами INPUT.
Проверка результата
docker run -d --name pcheck -p 127.0.0.1:18888:8000 python:3.13-slim \
python -m http.server 8000 --bind 0.0.0.0 > /dev/null
sleep 3
docker port pcheck
curl -s -o /dev/null -w 'localhost: %{http_code}\n' http://localhost:18888/
sudo iptables -t nat -S DOCKER | grep 18888
docker rm -f pcheck > /dev/null
Ожидается HTTP 200 и правило DNAT с условием -d 127.0.0.1.
Типичные ошибки
| Ошибка | Причина | Исправление |
|---|---|---|
Приложение слушает 127.0.0.1 | Умолчание фреймворка | Пакет приходит на eth0; слушать 0.0.0.0 |
Считают, что EXPOSE открывает порт | Название вводит в заблуждение | Это метаданные; нужен -p |
-p 5432:5432 для базы | «Чтобы приложение достучалось» | Внутри сети порт доступен и так |
| Публикация без адреса для внутренних сервисов | Не знали про умолчание | 0.0.0.0 — все интерфейсы; указать 127.0.0.1: |
ufw deny для закрытия порта container'а | Привычка | DNAT срабатывает до INPUT; писать в DOCKER-USER |
Считают отсутствие порта в ss неисправностью | Ожидают слушающий сокет | При userland-proxy: false его нет |
Ищут приложение в выводе ss на host | Логично предположить | Виден docker-proxy |
| Проверяют доступность извне с той же машины | Нет второй | Путь пакета другой; нужен внешний хост |
| Порт занят — перезапускают демон | Не посмотрели, кто занял | docker ps и ss -tlnp называют виновника |
Диапазон -p 8000-9000:8000-9000 | Кажется удобным | Тысяча правил и тысяча docker-proxy |
Контрольные вопросы
На понимание:
- Что означает
-p 8080:80без указания адреса host? - Почему процесс обязан слушать
0.0.0.0, а не127.0.0.1? - В чём разница между
EXPOSE,--exposeи-p? - Почему
ufw deny 5432не закрывает опубликованный порт? - Почему опубликованный порт может отсутствовать в выводе
ss?
На применение:
- Как сделать порт доступным только с самого host?
- Как найти правило DNAT конкретной публикации и убедиться, что оно работает?
- Как узнать, на каком случайном порту опубликован сервис?
На диагностику:
- Container
running,docker portпоказывает трансляцию,curlне отвечает. Первый шаг? port is already allocated. Порядок действий?
Краткое резюме
- Синтаксис:
-p [адрес_host:][порт_host:]порт_container[/протокол]. - Без адреса публикация идёт на
0.0.0.0— все интерфейсы host, включая внешний. - Для внутренних сервисов указывают
127.0.0.1:явно. EXPOSE— метаданные; доступа не даёт, но определяет поведение-P.- Приложение обязано слушать
0.0.0.0: пакет приходит наeth0, а не на loopback. - Симптом ошибки: изнутри работает, снаружи нет, при корректном
docker port. - Публикация создаёт правило DNAT в цепочке
DOCKERтаблицыnat. - Рост счётчика пакетов правила — единственное прямое доказательство, что оно работает.
docker-proxyслушает опубликованный порт; вssвиден он, а не приложение.- При
userland-proxy: falseслушающего сокета нет, а публикация работает. - DNAT срабатывает до
INPUT, поэтому правилаufw denyпорт не закрывают. - Свои правила пишут в цепочку
DOCKER-USER; надёжнее — привязка к127.0.0.1.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Docker: published ports | https://docs.docker.com/engine/network/#published-ports | Синтаксис -p, привязка к адресу |
| Docker: packet filtering and firewalls | https://docs.docker.com/engine/network/packet-filtering-firewalls/ | DNAT, DOCKER-USER, взаимодействие с брандмауэром host |
Docker: docker run reference | https://docs.docker.com/reference/cli/docker/container/run/#publish | Полный синтаксис публикации, -P |
Dockerfile: EXPOSE | https://docs.docker.com/reference/dockerfile/#expose | Что делает и чего не делает |
Docker: docker port | https://docs.docker.com/reference/cli/docker/container/port/ | Просмотр трансляций |
| Docker: daemon options | https://docs.docker.com/reference/cli/dockerd/ | userland-proxy |
| netfilter: packet flow | https://www.netfilter.org/documentation/ | Порядок цепочек: PREROUTING, FORWARD, INPUT |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Docker DNS
Главное оглавление