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

8.6. Диагностика

Цели

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

  • вести диагностику по фиксированной последовательности проверок, а не перебором догадок;
  • определять причину по типу отказа: refused, таймаут, gaierror, сброс соединения;
  • получать все нужные данные внутри container'а без установки пакетов;
  • находить, на каком шаге путь пакета обрывается;
  • распознавать пять типичных ошибок по их подписи.

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

  • 8.18.5: весь раздел.

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

ТерминОбъяснение
ECONNREFUSEDХост ответил отказом: порт закрыт или процесс не слушает
ETIMEDOUTОтвета нет: фильтрация, отсутствие маршрута, недоступный хост
ENETUNREACHМаршрута нет вовсе; ошибка немедленная
gaierrorИмя не разрешилось
ECONNRESETСоединение установилось и было сброшено

Теория

Лестница проверок

Диагностика сводится к одному вопросу: на каком шаге обрывается путь пакета? Проверки идут снизу вверх, от процесса к внешней сети. Первый неуспешный шаг и есть место проблемы.

text
  7. Внешний клиент          ─┐
  6. Правило DNAT             │  доступ извне
  5. Host → адрес container'а ─┘
  4. Разрешение имени         ─┐
  3. Сосед по сети → сервис    │  доступ внутри сети
  2. Container → сам себя     ─┘
  1. Процесс слушает          ─── основа
  0. Container работает
ШагВопросКоманда
0Container работает?docker ps -a, docker inspect .State
1Процесс слушает? На каком адресе?/proc/net/tcp или nsenter ... ss -tln
2Отвечает сам себе?Запрос к 127.0.0.1:порт изнутри
3Отвечает соседу по сети?Запрос по IP из другого container'а
4Имя разрешается?getaddrinfo из соседа
5Доступен с host по адресу container'а?Запрос к 172.x.x.x:порт
6Есть правило DNAT и растёт ли счётчик?iptables -t nat -L DOCKER -n -v
7Доступен снаружи?Запрос с другой машины

Смысл порядка: не проверяйте шаг N, пока не подтверждён шаг N−1. Большинство потерянного времени уходит на изучение правил netfilter при том, что процесс слушает не тот адрес.

Что означает тип отказа

Тип ошибки сужает круг причин сильнее, чем любое другое наблюдение.

ОтказЧто произошлоВероятные причины
ECONNREFUSEDХост есть, ответил отказомПроцесс не слушает; слушает другой порт; слушает 127.0.0.1
ETIMEDOUTОтвета не былоФильтрация; разные сети; неверный адрес
ENETUNREACHМаршрута нет--network none; сеть --internal; сломанная маршрутизация
gaierror (-2)Имя не разрешилосьРазные сети; опечатка; сосед не запущен; default bridge
ECONNRESETСоединение сброшено после установкиПроцесс упал; прокси не дождался; протокол не тот
Пустой ответСоединение есть, данных нетHTTPS-запрос к HTTP-порту или наоборот

Практический приём: сначала получите тип ошибки, потом стройте гипотезы. «Не работает» гипотез не сужает.

Инструменты без установки пакетов

Минимальные образы не содержат ни ping, ни curl, ни ss, ни dig. Устанавливать их в container с проблемой — плохая идея: вы меняете объект исследования и часто не можете это сделать в production.

Три источника данных:

ИсточникЧто даётДоступен в
/proc/net/tcp, /proc/net/route, /sys/class/net/Сокеты, маршруты, интерфейсыЛюбой образ с Linux
Python socket, urllibРазрешение имён, соединения, HTTPЛюбой Python-образ
nsenter с host, --network container:Все инструменты host в namespaceТребует доступа к host

Третий способ — основной. Он не меняет исследуемый container:

bash
docker run --rm --network container:app nicolaka/netshoot ss -tlnp
sudo nsenter -t "$(docker inspect -f '{{.State.Pid}}' app)" -n ss -tlnp

Пять типичных ошибок и их подписи

1. localhost для обращения к соседнему сервису.

python
DATABASE_URL = "postgresql://user:pass@localhost:5432/db"   # в container'е
ПризнакЗначение
ОтказECONNREFUSED немедленно
Шаг лестницы3
Причинаlocalhost — сам container (урок 8.1)
ИсправлениеИмя сервиса: @db:5432

2. Приложение слушает 127.0.0.1.

ПризнакЗначение
ОтказПустой ответ или сброс с host, изнутри работает
Шаг лестницы2 проходит, 3 и 5 нет
ПричинаПакет приходит на eth0 (урок 8.3)
Исправление--host 0.0.0.0

3. Container'ы в разных сетях.

ПризнакЗначение
Отказgaierror при обращении по имени
Шаг лестницы4
ПричинаЗона DNS собирается из сетей спрашивающего (урок 8.4)
ИсправлениеОбщая сеть или docker network connect

4. EXPOSE вместо -p.

ПризнакЗначение
ОтказТаймаут или отказ с host; изнутри сети работает
Шаг лестницы6
ПричинаEXPOSE — метаданные
ИсправлениеДобавить -p

5. Обращение до готовности сервиса.

ПризнакЗначение
ОтказECONNREFUSED в первые секунды, потом работает
Шаг лестницы3, но только в начале
Причинаdepends_on без condition: service_healthy
ИсправлениеHealthcheck плюс условие; повторы в приложении

Пятая отличается от остальных тем, что воспроизводится не всегда. Признак — зависимость от времени: перезапуск «чинит» проблему.


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

Чтение /proc/net/tcp

Файл содержит все TCP-сокеты namespace в шестнадцатеричном виде:

text
  sl  local_address rem_address   st ...
   0: 0100007F:1F90 00000000:0000 0A ...
ПолеРасшифровка
local_addressАдрес и порт через двоеточие
0100007F127.0.0.1 — байты в обратном порядке
1F90Порт 8000
st = 0AСостояние LISTEN

00000000:1F90 означает 0.0.0.0:8000 — то, что нужно. 0100007F:1F90127.0.0.1:8000, то есть ошибка привязки.

Это самый доступный способ узнать привязку: файл есть в любом образе.

Почему счётчик правила важнее его наличия

Правило DNAT может присутствовать, но не срабатывать: выше по цепочке есть другое правило, перехватывающее трафик. Наличие строки в выводе iptables -S этого не покажет.

Счётчик пакетов (iptables -L -n -v) показывает, проходил ли трафик именно через это правило. Сравнение до и после запроса даёт однозначный ответ (урок 8.3).


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

Универсальный диагностический скрипт

bash
mkdir -p /tmp/netdiag && cd /tmp/netdiag

cat > diag.py <<'PY'
"""Диагностика сетевой связности средствами стандартной библиотеки.

Работает в любом образе с Python, ничего не требует устанавливать.
"""
from __future__ import annotations

import socket
import sys
import time
from pathlib import Path

STATE_LISTEN = "0A"


def hex_to_ip(h: str) -> str:
    return ".".join(str(int(h[i:i + 2], 16)) for i in (6, 4, 2, 0))


def listening_sockets() -> list[tuple[str, int]]:
    """Слушающие TCP-сокеты из /proc/net/tcp — без ss и netstat."""
    out: list[tuple[str, int]] = []
    for path in ("/proc/net/tcp", "/proc/net/tcp6"):
        p = Path(path)
        if not p.exists():
            continue
        for line in p.read_text().splitlines()[1:]:
            parts = line.split()
            if len(parts) < 4 or parts[3] != STATE_LISTEN:
                continue
            addr_hex, port_hex = parts[1].rsplit(":", 1)
            addr = hex_to_ip(addr_hex) if len(addr_hex) == 8 else f"[{addr_hex}]"
            out.append((addr, int(port_hex, 16)))
    return sorted(set(out))


def own_addresses() -> list[str]:
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        s.connect(("10.255.255.255", 1))
        return [s.getsockname()[0]]
    except OSError:
        return []
    finally:
        s.close()


def default_gateway() -> str | None:
    p = Path("/proc/net/route")
    if not p.exists():
        return None
    for line in p.read_text().splitlines()[1:]:
        f = line.split()
        if len(f) > 2 and f[1] == "00000000":
            return hex_to_ip(f[2])
    return None


def probe(host: str, port: int, timeout: float = 4.0) -> str:
    start = time.monotonic()
    try:
        addr = socket.gethostbyname(host)
    except socket.gaierror as exc:
        ms = (time.monotonic() - start) * 1000
        return f"gaierror   {ms:7.1f} мс  {exc.strerror}"

    s = socket.socket()
    s.settimeout(timeout)
    try:
        s.connect((addr, port))
        ms = (time.monotonic() - start) * 1000
        return f"ok         {ms:7.1f} мс  {addr}:{port}"
    except ConnectionRefusedError:
        ms = (time.monotonic() - start) * 1000
        return f"refused    {ms:7.1f} мс  {addr}:{port} — хост есть, порт закрыт"
    except (socket.timeout, TimeoutError):
        ms = (time.monotonic() - start) * 1000
        return f"timeout    {ms:7.1f} мс  {addr}:{port} — фильтрация или чужая сеть"
    except OSError as exc:
        ms = (time.monotonic() - start) * 1000
        errno = getattr(exc, "errno", "?")
        return f"errno {errno:<4} {ms:7.1f} мс  {exc.strerror or exc}"
    finally:
        s.close()


def main(argv: list[str]) -> int:
    print("── шаг 1: что слушает этот container ──")
    socks = listening_sockets()
    if not socks:
        print("   ✗ ни одного слушающего сокета")
    for addr, port in socks:
        flag = "⚠ только loopback!" if addr.startswith("127.") else "✓"
        print(f"   {flag} {addr}:{port}")

    print("\n── сеть container'а ──")
    for a in own_addresses():
        print(f"   адрес:  {a}")
    print(f"   шлюз:   {default_gateway() or 'нет маршрута по умолчанию'}")
    resolv = Path("/etc/resolv.conf")
    if resolv.exists():
        for line in resolv.read_text().splitlines():
            if line.startswith("nameserver"):
                print(f"   DNS:    {line.split()[1]}")

    if not argv:
        return 0

    print("\n── шаги 2–4: проверка целей ──")
    for target in argv:
        host, _, port = target.rpartition(":")
        print(f"   {target:<28} {probe(host, int(port))}")
    return 0


if __name__ == "__main__":
    sys.exit(main(sys.argv[1:]))
PY
echo "  скрипт готов"

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

text
  скрипт готов

Ошибка 1: localhost вместо имени сервиса

bash
cd /tmp/netdiag
docker network create dnet > /dev/null
docker run -d --network dnet --name db -e POSTGRES_PASSWORD=x postgres:17-alpine > /dev/null
docker run -d --network dnet --name app -v "$PWD/diag.py:/diag.py:ro" \
    python:3.13-slim sleep 600 > /dev/null
sleep 8

echo "═══ приложение обращается к localhost:5432 ═══"
docker exec app python /diag.py localhost:5432 db:5432

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

text
═══ приложение обращается к localhost:5432 ═══
── шаг 1: что слушает этот container ──
   ✗ ни одного слушающего сокета

── сеть container'а ──
   адрес:  172.26.0.3
   шлюз:   172.26.0.1
   DNS:    127.0.0.11

── шаги 2–4: проверка целей ──
   localhost:5432               refused        0.4 мс  127.0.0.1:5432 — хост есть, порт закрыт
   db:5432                      ok             1.8 мс  172.26.0.2:5432

Подпись ошибки видна сразу: refused за доли миллисекунды по адресу 127.0.0.1. Мгновенный отказ означает, что до сети дело не дошло — ядро само отклонило соединение, потому что на этом порту loopback никто не слушает.

Строка ниже показывает, что нужное имя работает. Исправление — заменить localhost на db в конфигурации.

Ошибка 2: привязка к 127.0.0.1

bash
cd /tmp/netdiag
cat > srv.py <<'PY'
import os
from http.server import BaseHTTPRequestHandler, HTTPServer


class H(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200); self.end_headers(); self.wfile.write(b"ok\n")

    def log_message(self, *a):
        pass


bind = os.environ.get("BIND", "127.0.0.1")
print(f"слушаю {bind}:8000", flush=True)
HTTPServer((bind, 8000), H).serve_forever()
PY

docker run -d --network dnet --name bad -p 18400:8000 \
    -v "$PWD/srv.py:/srv.py:ro" -v "$PWD/diag.py:/diag.py:ro" \
    -e BIND=127.0.0.1 python:3.13-slim python /srv.py > /dev/null
sleep 4

echo "═══ шаг 0: container работает ═══"
printf '  статус: %s, код: %s\n' \
    "$(docker inspect bad --format '{{.State.Status}}')" \
    "$(docker inspect bad --format '{{.State.ExitCode}}')"

echo "═══ шаг 1: что слушает ═══"
docker exec bad python /diag.py | head -5

echo "═══ шаг 2: сам себе отвечает ═══"
docker exec bad python /diag.py 127.0.0.1:8000 2>/dev/null | tail -1

echo "═══ шаг 3: сосед по сети ═══"
docker exec app python /diag.py bad:8000 2>/dev/null | tail -1

echo "═══ шаг 5: с host по адресу container'а ═══"
ip_bad="$(docker inspect -f '{{.NetworkSettings.Networks.dnet.IPAddress}}' bad)"
timeout 5 python3 -c "
import socket
s = socket.socket(); s.settimeout(3)
try:
    s.connect(('$ip_bad', 8000)); print('  ok')
except Exception as e:
    print(f'  {type(e).__name__}')
"

echo "═══ шаг 6: правило DNAT есть ═══"
sudo iptables -t nat -S DOCKER 2>/dev/null | grep -c 18400 | xargs printf '  правил: %s\n'
printf '  curl с host: %s\n' "$(curl -s -m 3 -o /dev/null -w '%{http_code}' http://localhost:18400/)"

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

text
═══ шаг 0: container работает ═══
  статус: running, код: 0
═══ шаг 1: что слушает ═══
── шаг 1: что слушает этот container ──
   ⚠ только loopback! 127.0.0.1:8000

── сеть container'а ──
   адрес:  172.26.0.4
═══ шаг 2: сам себе отвечает ═══
   127.0.0.1:8000               ok             0.5 мс  127.0.0.1:8000
═══ шаг 3: сосед по сети ═══
   bad:8000                     refused        1.2 мс  172.26.0.4:8000 — хост есть, порт закрыт
═══ шаг 5: с host по адресу container'а ═══
  ConnectionRefusedError
═══ шаг 6: правило DNAT есть ═══
  правил: 1
  curl с host: 000

Лестница отработала как задумано.

Шаг 1 сразу пометил проблему: ⚠ только loopback!. Дальше можно было не проверять — но остальные шаги показывают, как выглядит эта ошибка на каждом уровне.

Шаг 2 проходит, шаг 3 даёт refused. Именно эта пара — «изнутри работает, снаружи отказ» — однозначно указывает на привязку к loopback.

Шаг 6 показывает, почему правило DNAT изучать бессмысленно: оно есть и корректно, проблема не в нём.

bash
docker rm -f bad > /dev/null
docker run -d --network dnet --name good -p 18401:8000 \
    -v "$PWD/srv.py:/srv.py:ro" -e BIND=0.0.0.0 python:3.13-slim python /srv.py > /dev/null
sleep 4
printf '═══ после BIND=0.0.0.0 ═══\n'
printf '  из соседа: %s\n' "$(docker exec app python /diag.py good:8000 2>/dev/null | tail -1 | awk '{print $2}')"
printf '  с host:    %s\n' "$(curl -s -m 3 -o /dev/null -w '%{http_code}' http://localhost:18401/)"
docker rm -f good > /dev/null

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

text
═══ после BIND=0.0.0.0 ═══
  из соседа: ok
  с host:    200

Ошибка 3: разные сети

bash
cd /tmp/netdiag
docker network create othernet > /dev/null
docker run -d --network othernet --name far \
    -v "$PWD/diag.py:/diag.py:ro" python:3.13-slim sleep 600 > /dev/null
sleep 2

echo "═══ обращение к db из другой сети ═══"
docker exec far python /diag.py db:5432 2>/dev/null | tail -1

echo "═══ по IP тоже ═══"
ip_db="$(docker inspect -f '{{.NetworkSettings.Networks.dnet.IPAddress}}' db)"
docker exec far python /diag.py "$ip_db:5432" 2>/dev/null | tail -1

echo "═══ в каких сетях кто ═══"
for c in db far; do
    printf '  %-5s %s\n' "$c" \
        "$(docker inspect "$c" --format '{{range $n, $v := .NetworkSettings.Networks}}{{$n}} {{end}}')"
done

echo "═══ исправление ═══"
docker network connect dnet far
sleep 2
docker exec far python /diag.py db:5432 2>/dev/null | tail -1

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

text
═══ обращение к db из другой сети ═══
   db:5432                      gaierror       0.3 мс  Name or service not known
═══ по IP тоже ═══
   172.26.0.2:5432              timeout     4001.2 мс  172.26.0.2:5432 — фильтрация или чужая сеть
═══ в каких сетях кто ═══
  db    dnet 
  far   othernet 
═══ исправление ═══
   db:5432                      ok             1.9 мс  172.26.0.2:5432

Две разные подписи одной причины. По имени — gaierror мгновенно: зона DNS не содержит имени. По адресу — таймаут четыре секунды: пакет ушёл и был отброшен правилом изоляции.

Сравнение сетей в третьем блоке подтверждает диагноз за одну команду.

Ошибка 4: EXPOSE вместо -p

bash
cd /tmp/netdiag
docker run -d --network dnet --name noport \
    -v "$PWD/srv.py:/srv.py:ro" -e BIND=0.0.0.0 python:3.13-slim python /srv.py > /dev/null
sleep 4

echo "═══ шаг 3: из сети работает ═══"
docker exec app python /diag.py noport:8000 2>/dev/null | tail -1

echo "═══ шаг 6: правило DNAT ═══"
ip_np="$(docker inspect -f '{{.NetworkSettings.Networks.dnet.IPAddress}}' noport)"
sudo iptables -t nat -S DOCKER 2>/dev/null | grep -c "$ip_np" | xargs printf '  правил для %s: %%s\n' "$ip_np"
printf '  docker port: %s\n' "$(docker port noport 2>/dev/null || echo 'пусто')"
printf '  curl с host: %s\n' "$(curl -s -m 3 -o /dev/null -w '%{http_code}' http://localhost:8000/)"

docker rm -f noport > /dev/null

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

text
═══ шаг 3: из сети работает ═══
   noport:8000                  ok             1.4 мс  172.26.0.5:8000
═══ шаг 6: правило DNAT ═══
  правил для 172.26.0.5: 0
  docker port: пусто
  curl с host: 000

Подпись: шаг 3 проходит, шаг 6 показывает ноль правил. Сервис работает, просто не опубликован.

Это отличается от ошибки 2, где шаг 3 давал refused. Различие в одном шаге лестницы указывает на совершенно разные исправления.

Ошибка 5: обращение до готовности

bash
cd /tmp/netdiag
cat > compose.yaml <<'EOF'
services:
  db:
    image: postgres:17-alpine
    environment:
      POSTGRES_PASSWORD: secret
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 2s
      timeout: 3s
      retries: 15

  # ПЛОХО: ждёт только запуска container'а
  early:
    image: python:3.13-slim
    depends_on:
      - db
    command: ["python", "-c", "import socket,sys;s=socket.socket();s.settimeout(3);
              sys.exit(0) if not s.connect_ex(('db',5432)) else print('РАНО: соединения нет')"]

  # ХОРОШО: ждёт готовности сервиса
  ready:
    image: python:3.13-slim
    depends_on:
      db:
        condition: service_healthy
    command: ["python", "-c", "import socket,sys;s=socket.socket();s.settimeout(3);
              print('готово') if not s.connect_ex(('db',5432)) else sys.exit(1)"]
EOF

docker compose up --abort-on-container-exit --exit-code-from ready > /tmp/netdiag/out.txt 2>&1
echo "═══ результат ═══"
grep -E 'РАНО|готово|exited' /tmp/netdiag/out.txt | head -6 | sed 's/^/  /'
docker compose down -v > /dev/null 2>&1

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

text
═══ результат ═══
  early-1  | РАНО: соединения нет
  early-1 exited with code 0
  ready-1  | готово
  ready-1 exited with code 0

Оба сервиса зависят от db, но early стартовал сразу после запуска container'а базы — до того, как PostgreSQL начал принимать соединения.

Подпись этой ошибки — зависимость от времени: тот же код при повторном запуске может сработать, если база успела подняться. Отсюда правило: depends_on без condition: service_healthy гарантий не даёт (раздел 09).

Инструменты через общий namespace

bash
cd /tmp/netdiag
docker run -d --network dnet --name observed \
    -v "$PWD/srv.py:/srv.py:ro" -e BIND=0.0.0.0 python:3.13-slim python /srv.py > /dev/null
sleep 3

echo "═══ в образе нет сетевых утилит ═══"
docker exec observed sh -c 'command -v ss ip ping curl tcpdump 2>/dev/null || echo "  ничего нет"'

echo "═══ подключаем инструменты в его namespace ═══"
docker run --rm --network container:observed alpine:3.21 sh -c '
    apk add --no-cache iproute2 curl > /dev/null 2>&1
    echo "  ss -tln:"
    ss -tln | tail -n +2 | sed "s/^/    /"
    echo "  ip route:"
    ip route | sed "s/^/    /"
    echo "  curl к сервису через localhost общего namespace:"
    curl -s -o /dev/null -w "    HTTP %{http_code}\n" http://127.0.0.1:8000/
'

echo "═══ то же через nsenter с host ═══"
pid="$(docker inspect -f '{{.State.Pid}}' observed)"
sudo nsenter -t "$pid" -n ss -tlnp 2>/dev/null | sed 's/^/  /'

docker rm -f observed > /dev/null

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

text
═══ в образе нет сетевых утилит ═══
  ничего нет
═══ подключаем инструменты в его namespace ═══
  ss -tln:
    LISTEN  0  5  0.0.0.0:8000  0.0.0.0:*
  ip route:
    default via 172.26.0.1 dev eth0
    172.26.0.0/16 dev eth0 scope link src 172.26.0.6
  curl к сервису через localhost общего namespace:
    HTTP 200
═══ то же через nsenter с host ═══
  LISTEN 0  5  0.0.0.0:8000  0.0.0.0:*  users:(("python",pid=54120,fd=3))

Два способа, один результат. nsenter дополнительно показывает процесс, владеющий сокетом, — ss внутри общего namespace этого не видит, потому что PID namespace у него свой.

bash
docker rm -f app db far > /dev/null 2>&1
docker network rm dnet othernet > /dev/null 2>&1
cd /tmp && rm -rf /tmp/netdiag

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

Задание. Напишите скрипт netladder.sh, проходящий лестницу проверок для указанного container'а и порта и называющий шаг, на котором обрывается путь.

Требования:

  1. Проверяются все семь шагов по порядку; при провале сообщается номер шага и вероятная причина.
  2. Шаг 1 определяет привязку и отдельно предупреждает о 127.0.0.1.
  3. Различаются refused, timeout, gaierror, ENETUNREACH.
  4. Ничего не устанавливается в проверяемый container.
  5. Скрипт проверен на четырёх искусственно созданных неисправностях и на исправной конфигурации.
  6. Код возврата: 0 при полной исправности, номер провалившегося шага иначе.

Подсказки

Подсказка 1

Шаг 1 можно выполнить через /proc/net/tcp внутри container'а или через nsenter с host.

Подсказка 2

Для шага 3 нужен вспомогательный container в той же сети; создавайте его на время проверки.

Подсказка 3

Шаг 6 требует не только наличия правила, но и роста счётчика.

Решение

Показать решение
bash
mkdir -p /tmp/ladder && cd /tmp/ladder

cat > probe.py <<'PY'
"""Проба соединения: печатает исход одним словом и подробности."""
from __future__ import annotations

import socket
import sys
import time

if __name__ == "__main__":
    host, port = sys.argv[1], int(sys.argv[2])
    start = time.monotonic()
    try:
        addr = socket.gethostbyname(host)
    except socket.gaierror as exc:
        print(f"gaierror {(time.monotonic() - start) * 1000:.1f} {exc.strerror}")
        sys.exit(0)

    s = socket.socket()
    s.settimeout(float(sys.argv[3]) if len(sys.argv) > 3 else 4.0)
    try:
        s.connect((addr, port))
        print(f"ok {(time.monotonic() - start) * 1000:.1f} {addr}")
    except ConnectionRefusedError:
        print(f"refused {(time.monotonic() - start) * 1000:.1f} {addr}")
    except (socket.timeout, TimeoutError):
        print(f"timeout {(time.monotonic() - start) * 1000:.1f} {addr}")
    except OSError as exc:
        kind = "unreachable" if getattr(exc, "errno", 0) in (101, 113) else "oserror"
        print(f"{kind} {(time.monotonic() - start) * 1000:.1f} {exc.strerror}")
    finally:
        s.close()
PY

cat > listening.py <<'PY'
"""Слушающие сокеты из /proc/net/tcp: addr:port построчно."""
from pathlib import Path


def hex_ip(h: str) -> str:
    return ".".join(str(int(h[i:i + 2], 16)) for i in (6, 4, 2, 0))


for line in Path("/proc/net/tcp").read_text().splitlines()[1:]:
    f = line.split()
    if len(f) > 3 and f[3] == "0A":
        a, p = f[1].rsplit(":", 1)
        print(f"{hex_ip(a)}:{int(p, 16)}")
PY

cat > netladder.sh <<'SH'
#!/usr/bin/env bash
# Лестница сетевой диагностики. Код возврата = номер провалившегося шага.
set -uo pipefail

C="${1:?имя container}"
PORT="${2:?порт внутри container}"
HELPER_IMG="${HELPER_IMG:-python:3.13-slim}"
SELF_DIR="$(cd "$(dirname "$0")" && pwd)"

step() { printf '\n%s\n' "── шаг $1: $2 ──"; }
ok()   { printf '   ✓ %s\n' "$1"; }
die()  { printf '   ✗ %s\n' "$1"; printf '\n   ПРИЧИНА: %s\n' "$2"; exit "$3"; }

# ── шаг 0 ──
step 0 "container существует и работает"
if ! docker inspect "$C" > /dev/null 2>&1; then
    die "container '$C' не найден" "неверное имя или container удалён" 0
fi
status="$(docker inspect "$C" --format '{{.State.Status}}')"
exitcode="$(docker inspect "$C" --format '{{.State.ExitCode}}')"
restarts="$(docker inspect "$C" --format '{{.RestartCount}}')"
printf '   статус=%s код=%s перезапусков=%s\n' "$status" "$exitcode" "$restarts"
[ "$status" = "running" ] || die "container не запущен" \
    "смотрите docker logs $C; код выхода $exitcode" 0
ok "работает"

# ── шаг 1 ──
step 1 "процесс слушает порт $PORT"
socks="$(docker cp "$SELF_DIR/listening.py" "$C:/tmp/_l.py" 2>/dev/null \
         && docker exec "$C" python /tmp/_l.py 2>/dev/null)"
if [ -z "$socks" ]; then
    # запасной путь: nsenter с host
    pid="$(docker inspect -f '{{.State.Pid}}' "$C")"
    socks="$(sudo nsenter -t "$pid" -n ss -tlnH 2>/dev/null | awk '{print $4}')"
fi
printf '   слушает:\n'
echo "$socks" | sed 's/^/     /'

match="$(echo "$socks" | grep -E ":${PORT}\$" || true)"
[ -n "$match" ] || die "на порту $PORT никто не слушает" \
    "процесс не запустился, слушает другой порт или упал" 1

loopback_only=1
while read -r s; do
    [ -n "$s" ] || continue
    case "$s" in
        127.*|"[00000000000000000000000001000000]"*) ;;
        *) loopback_only=0 ;;
    esac
done <<< "$match"
[ "$loopback_only" -eq 0 ] || die "порт $PORT привязан только к 127.0.0.1" \
    "пакет приходит на eth0; запускайте с --host 0.0.0.0" 1
ok "слушает на доступном адресе"

# ── шаг 2 ──
step 2 "container отвечает сам себе"
r="$(docker cp "$SELF_DIR/probe.py" "$C:/tmp/_p.py" 2>/dev/null \
     && docker exec "$C" python /tmp/_p.py 127.0.0.1 "$PORT" 2 2>/dev/null)"
printf '   %s\n' "$r"
[ "${r%% *}" = "ok" ] || die "сам себе не отвечает" \
    "процесс слушает, но соединение не принимает — проверьте логи" 2
ok "отвечает"

# ── шаг 3–4 ──
net="$(docker inspect "$C" --format '{{range $n, $v := .NetworkSettings.Networks}}{{$n}}{{"\n"}}{{end}}' | head -1)"
cip="$(docker inspect "$C" --format "{{(index .NetworkSettings.Networks \"$net\").IPAddress}}")"
printf '\n   сеть: %s, адрес container: %s\n' "$net" "$cip"

step 3 "сосед по сети достаёт по IP"
helper="ladder-helper-$$"
docker run -d --name "$helper" --network "$net" \
    -v "$SELF_DIR/probe.py:/probe.py:ro" "$HELPER_IMG" sleep 120 > /dev/null
trap 'docker rm -f "$helper" > /dev/null 2>&1' EXIT
sleep 1
r="$(docker exec "$helper" python /probe.py "$cip" "$PORT" 4)"
printf '   %s\n' "$r"
case "${r%% *}" in
    ok) ok "доступен по IP из сети" ;;
    refused) die "сосед получил отказ" "процесс слушает не тот интерфейс" 3 ;;
    timeout) die "сосед не дождался ответа" "правило фильтрации внутри сети" 3 ;;
    *) die "неожиданный исход: $r" "см. вывод выше" 3 ;;
esac

step 4 "имя container разрешается"
r="$(docker exec "$helper" python /probe.py "$C" "$PORT" 4)"
printf '   %s\n' "$r"
case "${r%% *}" in
    ok) ok "имя разрешается и порт доступен" ;;
    gaierror) die "имя '$C' не разрешается" \
        "default bridge не даёт имён; или container'ы в разных сетях" 4 ;;
    *) die "имя разрешилось, но соединение: $r" "см. шаг 3" 4 ;;
esac

# ── шаг 5 ──
step 5 "host достаёт container по его адресу"
r="$(python3 "$SELF_DIR/probe.py" "$cip" "$PORT" 4)"
printf '   %s\n' "$r"
[ "${r%% *}" = "ok" ] || die "с host по адресу container недоступен" \
    "маршрут к docker-сети или правило FORWARD" 5
ok "доступен с host напрямую"

# ── шаг 6 ──
step 6 "публикация порта"
mapping="$(docker port "$C" "$PORT" 2>/dev/null | head -1)"
if [ -z "$mapping" ]; then
    die "порт $PORT не опубликован" \
        "нужен -p; EXPOSE публикацию не выполняет" 6
fi
printf '   docker port: %s\n' "$mapping"
hport="${mapping##*:}"

cnt() { sudo iptables -t nat -L DOCKER -n -v 2>/dev/null \
        | awk -v p="dpt:$1" '$0 ~ p {print $1; exit}'; }
before="$(cnt "$hport")"
code="$(curl -s -m 4 -o /dev/null -w '%{http_code}' "http://127.0.0.1:$hport/" 2>/dev/null || echo 000)"
after="$(cnt "$hport")"
printf '   HTTP с host: %s\n' "$code"
printf '   счётчик правила DNAT: %s → %s\n' "${before:-н/д}" "${after:-н/д}"
if [ "${before:-0}" = "${after:-0}" ] && [ -n "${before:-}" ]; then
    printf '   ⚠ счётчик не изменился — трафик идёт мимо этого правила\n'
fi
ok "публикация настроена"

# ── шаг 7 ──
step 7 "доступность извне"
hostip="$(ip route get 1.1.1.1 2>/dev/null | awk '{print $7; exit}')"
code="$(curl -s -m 4 -o /dev/null -w '%{http_code}' "http://$hostip:$hport/" 2>/dev/null || echo 000)"
printf '   через адрес host (%s): %s\n' "$hostip" "$code"
printf '   ПРИМЕЧАНИЕ: достоверная проверка требует запроса с другой машины\n'

printf '\n═══ все шаги пройдены ═══\n'
exit 0
SH
chmod +x netladder.sh

cat > srv.py <<'PY'
import os
from http.server import BaseHTTPRequestHandler, HTTPServer


class H(BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200); self.end_headers(); self.wfile.write(b"ok\n")

    def log_message(self, *a):
        pass


bind = os.environ.get("BIND", "0.0.0.0")
port = int(os.environ.get("PORT", "8000"))
print(f"слушаю {bind}:{port}", flush=True)
HTTPServer((bind, port), H).serve_forever()
PY

# ── Проверка на пяти конфигурациях ──
docker network create ladnet > /dev/null

run_case() {  # run_case <имя> <описание> <ожидаемый код> <docker run args...>
    local name="$1" desc="$2" want="$3"; shift 3
    printf '\n══════ %s ══════\n' "$desc"
    docker rm -f "$name" > /dev/null 2>&1
    docker run -d --name "$name" "$@" > /dev/null 2>&1
    sleep 4
    ./netladder.sh "$name" 8000 > "/tmp/ladder/$name.log" 2>&1
    local got=$?
    tail -6 "/tmp/ladder/$name.log" | sed 's/^/  /'
    if [ "$got" = "$want" ]; then
        printf '  ✓ код %s — как и ожидалось\n' "$got"
    else
        printf '  ✗ код %s, ожидался %s\n' "$got" "$want"
    fi
    docker rm -f "$name" > /dev/null 2>&1
}

# Исправная конфигурация
run_case good "исправная конфигурация" 0 \
    --network ladnet -p 18500:8000 -v "$PWD/srv.py:/srv.py:ro" python:3.13-slim python /srv.py

# Неисправность 1: привязка к loopback (шаг 1)
run_case f-bind "неисправность: привязка к 127.0.0.1" 1 \
    --network ladnet -p 18501:8000 -e BIND=127.0.0.1 \
    -v "$PWD/srv.py:/srv.py:ro" python:3.13-slim python /srv.py

# Неисправность 2: слушает другой порт (шаг 1)
run_case f-port "неисправность: слушает не тот порт" 1 \
    --network ladnet -p 18502:8000 -e PORT=9000 \
    -v "$PWD/srv.py:/srv.py:ro" python:3.13-slim python /srv.py

# Неисправность 3: default bridge — нет имён (шаг 4)
run_case f-dns "неисправность: default bridge, имя не разрешается" 4 \
    -p 18503:8000 -v "$PWD/srv.py:/srv.py:ro" python:3.13-slim python /srv.py

# Неисправность 4: порт не опубликован (шаг 6)
run_case f-pub "неисправность: порт не опубликован" 6 \
    --network ladnet -v "$PWD/srv.py:/srv.py:ro" python:3.13-slim python /srv.py

docker network rm ladnet > /dev/null 2>&1
cd /tmp && rm -rf /tmp/ladder

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

text
══════ исправная конфигурация ══════
     через адрес host (192.168.1.42): 200
     ПРИМЕЧАНИЕ: достоверная проверка требует запроса с другой машины
  
  ═══ все шаги пройдены ═══
  ✓ код 0 — как и ожидалось

══════ неисправность: привязка к 127.0.0.1 ══════
     слушает:
       127.0.0.1:8000
     ✗ порт 8000 привязан только к 127.0.0.1
  
     ПРИЧИНА: пакет приходит на eth0; запускайте с --host 0.0.0.0
  ✓ код 1 — как и ожидалось

══════ неисправность: слушает не тот порт ══════
     слушает:
       0.0.0.0:9000
     ✗ на порту 8000 никто не слушает
  
     ПРИЧИНА: процесс не запустился, слушает другой порт или упал
  ✓ код 1 — как и ожидалось

══════ неисправность: default bridge, имя не разрешается ══════
     gaierror 0.3 Name or service not known
     ✗ имя 'f-dns' не разрешается
  
     ПРИЧИНА: default bridge не даёт имён; или container'ы в разных сетях
  ✓ код 4 — как и ожидалось

══════ неисправность: порт не опубликован ══════
     ✗ порт 8000 не опубликован
  
     ПРИЧИНА: нужен -p; EXPOSE публикацию не выполняет
  ✓ код 6 — как и ожидалось

Все пять случаев дали ожидаемый результат.

Три решения, определяющие качество.

Скрипт останавливается на первом провале и не проверяет дальше. Это не экономия времени, а суть метода. Продолжив после провала шага 1, скрипт выдал бы ещё четыре ошибки — и читателю пришлось бы гадать, какая из пяти первична. Один провал, одна причина, одно исправление.

Проверен не только «плохой» случай, но и исправная конфигурация. Диагностический инструмент, который всегда что-то находит, бесполезен: он не отличает неисправность от нормы. Случай good с кодом 0 — обязательная часть проверки самого скрипта.

Вспомогательный container создаётся в той же сети, что и проверяемый, и удаляется через trap. Сеть определяется из docker inspect, а не задаётся параметром — иначе шаг 3 проверял бы связность из произвольной сети и всегда падал бы для user-defined конфигураций. trap ... EXIT гарантирует уборку даже при досрочном выходе через die.

Чего решение не делает. Шаг 7 не является настоящей проверкой: запрос уходит с той же машины и проходит другой путь, чем запрос из внешней сети. Скрипт сообщает об этом прямо, а не выдаёт зелёный результат. Не покрыт и случай нескольких сетей у container'а: берётся первая, а проблема может быть во второй. Не проверяется UDP — вся лестница построена на TCP-соединениях, и для UDP-сервисов шаги 2–5 неприменимы в этом виде.

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

bash
docker run -d --name diagcheck -p 18600:8000 python:3.13-slim \
    python -m http.server 8000 --bind 127.0.0.1 > /dev/null
sleep 3
docker exec diagcheck python -c "
from pathlib import Path
for line in Path('/proc/net/tcp').read_text().splitlines()[1:]:
    f = line.split()
    if f[3] == '0A':
        a, p = f[1].rsplit(':', 1)
        ip = '.'.join(str(int(a[i:i+2], 16)) for i in (6, 4, 2, 0))
        print(f'{ip}:{int(p, 16)}')
"
docker rm -f diagcheck > /dev/null

Ожидается 127.0.0.1:8000 — та самая ошибка привязки, видимая без установки утилит.

Типичные ошибки

ОшибкаПричинаИсправление
Начинают с правил iptablesКажется «глубокой» проверкойСначала шаг 1: слушает ли процесс и где
Устанавливают утилиты в проблемный containerИнструментов нетМеняется объект исследования; --network container:
«Не работает» вместо типа ошибкиНе смотрят исключениеrefused, timeout, gaierror — разные причины
Перезапуск как первое действиеИногда помогаетСкрывает ошибку 5 и теряет состояние
Проверяют доступность извне с той же машиныНет второйПуть пакета другой
Считают наличие правила DNAT доказательствомПравило есть — значит работаетСмотреть счётчик пакетов
Проверяют шаги не по порядкуКажется быстрееПровал на шаге 1 делает шаги 3–7 бессмысленными
ping для проверки сервисаПривычкаICMP может быть закрыт; проверяйте TCP-порт
Игнорируют RestartCountСмотрят только Status: runningContainer может перезапускаться в цикле

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

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

  1. Перечислите семь шагов лестницы диагностики по порядку.
  2. Что означает ECONNREFUSED и чем он отличается от таймаута?
  3. Почему ENETUNREACH приходит мгновенно, а таймаут — через секунды?
  4. Почему наличие правила DNAT не доказывает, что оно работает?
  5. Какая подпись у ошибки «приложение слушает 127.0.0.1»?

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

  1. Как узнать привязку процесса в образе без ss и netstat?
  2. Как выполнить tcpdump для container'а, не меняя его образ?
  3. Как отличить «разные сети» от «приложение не слушает»?

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

  1. Из соседнего container сервис доступен, с host — нет. Какой шаг проверять?
  2. Проблема воспроизводится не всегда, перезапуск помогает. Версия?

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

  1. Диагностика — фиксированная лестница из семи шагов, а не перебор гипотез.
  2. Проверять шаг N имеет смысл только после подтверждения шага N−1.
  3. Первый провалившийся шаг указывает место проблемы; дальше проверять не нужно.
  4. Тип отказа сужает круг причин сильнее любого другого наблюдения.
  5. refused — хост ответил; таймаут — ответа нет; ENETUNREACH — маршрута нет.
  6. gaierror означает проблему имени, а не связности.
  7. /proc/net/tcp даёт привязку сокетов в любом образе без утилит.
  8. --network container:<имя> и nsenter приносят инструменты, не меняя образ.
  9. «Изнутри работает, снаружи отказ» — подпись привязки к 127.0.0.1.
  10. «Из сети работает, с host нет» — подпись отсутствующей публикации.
  11. Счётчик пакетов правила — единственное доказательство, что трафик идёт через него.
  12. Зависимость проблемы от времени указывает на гонку при старте, а не на конфигурацию сети.

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

ИсточникСсылкаЧто подтверждает
Docker: networking overviewhttps://docs.docker.com/engine/network/Модель сети, поведение режимов
Docker: packet filtering and firewallshttps://docs.docker.com/engine/network/packet-filtering-firewalls/Цепочки, порядок обработки
Docker: docker inspecthttps://docs.docker.com/reference/cli/docker/inspect/Состояние, сети, адреса
Docker: docker porthttps://docs.docker.com/reference/cli/docker/container/port/Просмотр публикаций
Linux: proc(5)https://man7.org/linux/man-pages/man5/proc.5.htmlФормат /proc/net/tcp
Linux: nsenter(1)https://man7.org/linux/man-pages/man1/nsenter.1.htmlВход в namespace
Python: sockethttps://docs.python.org/3/library/socket.htmlКоды ошибок соединений
Linux: errno(3)https://man7.org/linux/man-pages/man3/errno.3.htmlECONNREFUSED, ETIMEDOUT, ENETUNREACH

Навигация

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

Markdown на GitHub ↗