Главная/Docker Networking/Урок

8.3. Port publishing

Цели

После этого материала вы сможете:

  • прочитать и написать любую форму -p, включая привязку к адресу и диапазоны;
  • объяснить, почему приложение обязано слушать 0.0.0.0, и диагностировать обратное;
  • найти правило DNAT, созданное публикацией порта;
  • объяснить разницу между EXPOSE, --expose и -p;
  • понять, почему ss -tlnp иногда не показывает опубликованный порт;
  • ограничить доступ к порту адресом 127.0.0.1 и объяснить, почему это важнее правил firewall.

Предварительные знания

Ключевые термины

ТерминОбъяснение
published portПорт host, транслируемый в порт container'а
exposed portОбъявление в метаданных образа; доступа не даёт
DNATПодмена адреса назначения
docker-proxyВспомогательный процесс, слушающий опубликованный порт
ephemeral portПорт из динамического диапазона, выбранный ядром
DOCKER-USERЦепочка netfilter для ваших правил

Теория

Полный синтаксис

text
-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, включая внешний. Это умолчание — источник большинства случайных публикаций сервисов в сеть.

Практическое правило: для всего, что не должно быть доступно извне, указывайте адрес явно.

bash
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 run127.0.0.1--host 0.0.0.0
uvicorn127.0.0.1--host 0.0.0.0
fastapi run0.0.0.0Уже верно
gunicorn127.0.0.1:8000-b 0.0.0.0:8000
python -m http.serverВсе адресаУже верно
django runserver127.0.0.1:80000.0.0.0:8000

Возражение «слушать 0.0.0.0 небезопасно» справедливо для обычного сервера, но не для container'а: внутри namespace нет других интерфейсов, кроме eth0 и lo. Доступ извне определяется публикацией порта, а не привязкой процесса.

Что происходит при -p

  1. Docker проверяет, свободен ли порт host; при конфликте — ошибка.
  2. Создаётся правило DNAT в цепочке DOCKER таблицы nat.
  3. Добавляется разрешающее правило в цепочку DOCKER таблицы filter.
  4. Запускается вспомогательный процесс 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:

bash
sudo iptables -I DOCKER-USER -i eth0 -p tcp --dport 5432 -j DROP

Docker Engine 28 ужесточил часть умолчаний в этой области. Проверяйте поведение на своей версии командой из раздела «Команды и примеры» — не полагайтесь на предположения, включая изложенные здесь.

Порт занят

text
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. Диагностика:

bash
docker ps --format '{{.Names}}\t{{.Ports}}' | grep 8080
sudo ss -tlnp | grep ':8080 '

Случайный порт

bash
docker run -d -p 8000 myimage
docker port <container> 8000

Ядро выбирает свободный порт из динамического диапазона. Применяется в тестах, когда нужно запустить несколько экземпляров одновременно, не согласовывая номера.


Внутренний механизм

Правило DNAT

bash
sudo iptables -t nat -S DOCKER
text
-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:

json
{
  "userland-proxy": false
}

Команды и примеры

Ошибка, которая встречается чаще всех

bash
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/)"

Ожидаемый вывод:

text
═══ привязка к 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).

bash
docker rm -f bad good > /dev/null

Где именно слушает процесс

bash
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

Ожидаемый вывод:

text
═══ 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

bash
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}"

Ожидаемый вывод:

text
═══ правило, созданное публикацией ═══
  -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

bash
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

Ожидаемый вывод:

text
═══ кто слушает 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 без записи о порте — не доказательство неисправности.

Ограничение адресом

bash
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

Ожидаемый вывод:

text
═══ публикация на всех интерфейсах ═══
  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 ничего не открывает

bash
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

Ожидаемый вывод:

text
═══ образ объявляет 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 знает, какой порт публиковать.

Занятый порт

bash
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

Ожидаемый вывод:

text
═══ вторая публикация того же порта ═══
  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.

Проверьте поведение брандмауэра на своей системе

bash
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

Ожидаемый вывод:

text
═══ версия 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 — состояние по умолчанию: своих правил нет.

Последняя строка существенна: проверить доступность порта извне с той же машины нельзя. Только запрос с другого хоста даёт достоверный ответ.


Практическое упражнение

Задание. Соберите приложение с двумя портами и настройте публикацию по-разному, доказав каждое утверждение.

Требования:

  1. Публичный порт приложения доступен с host и с других машин локальной сети.
  2. Административный порт доступен только с самого host.
  3. Порт базы данных не опубликован вовсе, но доступен приложению.
  4. Найдено и показано правило DNAT для каждого опубликованного порта.
  5. Показано, что счётчик пакетов правила растёт при обращении.
  6. Приложение, привязанное к 127.0.0.1, воспроизведено и исправлено с объяснением.

Подсказки

Подсказка 1

Требование 1 нельзя достоверно проверить с той же машины. Приблизьтесь: обратитесь по адресу host, а не по localhost.

Подсказка 2

Требование 3 проверяется из соседнего container'а и с host — результаты должны различаться.

Подсказка 3

Счётчики правил показывает iptables -t nat -L DOCKER -n -v.

Решение

Показать решение
bash
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"

Ожидаемый вывод:

text
═══ Требование 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.

Проверка результата

bash
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

Контрольные вопросы

На понимание:

  1. Что означает -p 8080:80 без указания адреса host?
  2. Почему процесс обязан слушать 0.0.0.0, а не 127.0.0.1?
  3. В чём разница между EXPOSE, --expose и -p?
  4. Почему ufw deny 5432 не закрывает опубликованный порт?
  5. Почему опубликованный порт может отсутствовать в выводе ss?

На применение:

  1. Как сделать порт доступным только с самого host?
  2. Как найти правило DNAT конкретной публикации и убедиться, что оно работает?
  3. Как узнать, на каком случайном порту опубликован сервис?

На диагностику:

  1. Container running, docker port показывает трансляцию, curl не отвечает. Первый шаг?
  2. port is already allocated. Порядок действий?

Краткое резюме

  1. Синтаксис: -p [адрес_host:][порт_host:]порт_container[/протокол].
  2. Без адреса публикация идёт на 0.0.0.0 — все интерфейсы host, включая внешний.
  3. Для внутренних сервисов указывают 127.0.0.1: явно.
  4. EXPOSE — метаданные; доступа не даёт, но определяет поведение -P.
  5. Приложение обязано слушать 0.0.0.0: пакет приходит на eth0, а не на loopback.
  6. Симптом ошибки: изнутри работает, снаружи нет, при корректном docker port.
  7. Публикация создаёт правило DNAT в цепочке DOCKER таблицы nat.
  8. Рост счётчика пакетов правила — единственное прямое доказательство, что оно работает.
  9. docker-proxy слушает опубликованный порт; в ss виден он, а не приложение.
  10. При userland-proxy: false слушающего сокета нет, а публикация работает.
  11. DNAT срабатывает до INPUT, поэтому правила ufw deny порт не закрывают.
  12. Свои правила пишут в цепочку DOCKER-USER; надёжнее — привязка к 127.0.0.1.

Официальные источники

ИсточникСсылкаЧто подтверждает
Docker: published portshttps://docs.docker.com/engine/network/#published-portsСинтаксис -p, привязка к адресу
Docker: packet filtering and firewallshttps://docs.docker.com/engine/network/packet-filtering-firewalls/DNAT, DOCKER-USER, взаимодействие с брандмауэром host
Docker: docker run referencehttps://docs.docker.com/reference/cli/docker/container/run/#publishПолный синтаксис публикации, -P
Dockerfile: EXPOSEhttps://docs.docker.com/reference/dockerfile/#exposeЧто делает и чего не делает
Docker: docker porthttps://docs.docker.com/reference/cli/docker/container/port/Просмотр трансляций
Docker: daemon optionshttps://docs.docker.com/reference/cli/dockerd/userland-proxy
netfilter: packet flowhttps://www.netfilter.org/documentation/Порядок цепочек: PREROUTING, FORWARD, INPUT

Навигация

← Предыдущий материал
Вернуться к разделу
Следующий материал → Docker DNS
Главное оглавление

Markdown на GitHub ↗