8.6. Диагностика
Цели
После этого материала вы сможете:
- вести диагностику по фиксированной последовательности проверок, а не перебором догадок;
- определять причину по типу отказа:
refused, таймаут,gaierror, сброс соединения; - получать все нужные данные внутри container'а без установки пакетов;
- находить, на каком шаге путь пакета обрывается;
- распознавать пять типичных ошибок по их подписи.
Предварительные знания
Ключевые термины
| Термин | Объяснение |
|---|---|
ECONNREFUSED | Хост ответил отказом: порт закрыт или процесс не слушает |
ETIMEDOUT | Ответа нет: фильтрация, отсутствие маршрута, недоступный хост |
ENETUNREACH | Маршрута нет вовсе; ошибка немедленная |
gaierror | Имя не разрешилось |
ECONNRESET | Соединение установилось и было сброшено |
Теория
Лестница проверок
Диагностика сводится к одному вопросу: на каком шаге обрывается путь пакета? Проверки идут снизу вверх, от процесса к внешней сети. Первый неуспешный шаг и есть место проблемы.
7. Внешний клиент ─┐
6. Правило DNAT │ доступ извне
5. Host → адрес container'а ─┘
4. Разрешение имени ─┐
3. Сосед по сети → сервис │ доступ внутри сети
2. Container → сам себя ─┘
1. Процесс слушает ─── основа
0. Container работает
| Шаг | Вопрос | Команда |
|---|---|---|
| 0 | Container работает? | 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:
docker run --rm --network container:app nicolaka/netshoot ss -tlnp
sudo nsenter -t "$(docker inspect -f '{{.State.Pid}}' app)" -n ss -tlnp
Пять типичных ошибок и их подписи
1. localhost для обращения к соседнему сервису.
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 в шестнадцатеричном виде:
sl local_address rem_address st ...
0: 0100007F:1F90 00000000:0000 0A ...
| Поле | Расшифровка |
|---|---|
local_address | Адрес и порт через двоеточие |
0100007F | 127.0.0.1 — байты в обратном порядке |
1F90 | Порт 8000 |
st = 0A | Состояние LISTEN |
00000000:1F90 означает 0.0.0.0:8000 — то, что нужно. 0100007F:1F90 — 127.0.0.1:8000, то есть ошибка привязки.
Это самый доступный способ узнать привязку: файл есть в любом образе.
Почему счётчик правила важнее его наличия
Правило DNAT может присутствовать, но не срабатывать: выше по цепочке есть другое правило, перехватывающее трафик. Наличие строки в выводе iptables -S этого не покажет.
Счётчик пакетов (iptables -L -n -v) показывает, проходил ли трафик именно через это правило. Сравнение до и после запроса даёт однозначный ответ (урок 8.3).
Команды и примеры
Универсальный диагностический скрипт
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 " скрипт готов"
Ожидаемый вывод:
скрипт готов
Ошибка 1: localhost вместо имени сервиса
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
Ожидаемый вывод:
═══ приложение обращается к 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
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/)"
Ожидаемый вывод:
═══ шаг 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 изучать бессмысленно: оно есть и корректно, проблема не в нём.
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
Ожидаемый вывод:
═══ после BIND=0.0.0.0 ═══
из соседа: ok
с host: 200
Ошибка 3: разные сети
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
Ожидаемый вывод:
═══ обращение к 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
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
Ожидаемый вывод:
═══ шаг 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: обращение до готовности
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
Ожидаемый вывод:
═══ результат ═══
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
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
Ожидаемый вывод:
═══ в образе нет сетевых утилит ═══
ничего нет
═══ подключаем инструменты в его 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 у него свой.
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 определяет привязку и отдельно предупреждает о
127.0.0.1. - Различаются
refused,timeout,gaierror,ENETUNREACH. - Ничего не устанавливается в проверяемый container.
- Скрипт проверен на четырёх искусственно созданных неисправностях и на исправной конфигурации.
- Код возврата:
0при полной исправности, номер провалившегося шага иначе.
Подсказки
Подсказка 1
Шаг 1 можно выполнить через /proc/net/tcp внутри container'а или через nsenter с host.
Подсказка 2
Для шага 3 нужен вспомогательный container в той же сети; создавайте его на время проверки.
Подсказка 3
Шаг 6 требует не только наличия правила, но и роста счётчика.
Решение
Показать решение
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
Ожидаемый вывод:
══════ исправная конфигурация ══════
через адрес 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 неприменимы в этом виде.
Проверка результата
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: running | Container может перезапускаться в цикле |
Контрольные вопросы
На понимание:
- Перечислите семь шагов лестницы диагностики по порядку.
- Что означает
ECONNREFUSEDи чем он отличается от таймаута? - Почему
ENETUNREACHприходит мгновенно, а таймаут — через секунды? - Почему наличие правила DNAT не доказывает, что оно работает?
- Какая подпись у ошибки «приложение слушает
127.0.0.1»?
На применение:
- Как узнать привязку процесса в образе без
ssиnetstat? - Как выполнить
tcpdumpдля container'а, не меняя его образ? - Как отличить «разные сети» от «приложение не слушает»?
На диагностику:
- Из соседнего container сервис доступен, с host — нет. Какой шаг проверять?
- Проблема воспроизводится не всегда, перезапуск помогает. Версия?
Краткое резюме
- Диагностика — фиксированная лестница из семи шагов, а не перебор гипотез.
- Проверять шаг N имеет смысл только после подтверждения шага N−1.
- Первый провалившийся шаг указывает место проблемы; дальше проверять не нужно.
- Тип отказа сужает круг причин сильнее любого другого наблюдения.
refused— хост ответил; таймаут — ответа нет;ENETUNREACH— маршрута нет.gaierrorозначает проблему имени, а не связности./proc/net/tcpдаёт привязку сокетов в любом образе без утилит.--network container:<имя>иnsenterприносят инструменты, не меняя образ.- «Изнутри работает, снаружи отказ» — подпись привязки к
127.0.0.1. - «Из сети работает, с host нет» — подпись отсутствующей публикации.
- Счётчик пакетов правила — единственное доказательство, что трафик идёт через него.
- Зависимость проблемы от времени указывает на гонку при старте, а не на конфигурацию сети.
Официальные источники
| Источник | Ссылка | Что подтверждает |
|---|---|---|
| Docker: networking overview | https://docs.docker.com/engine/network/ | Модель сети, поведение режимов |
| Docker: packet filtering and firewalls | https://docs.docker.com/engine/network/packet-filtering-firewalls/ | Цепочки, порядок обработки |
Docker: docker inspect | https://docs.docker.com/reference/cli/docker/inspect/ | Состояние, сети, адреса |
Docker: docker port | https://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: socket | https://docs.python.org/3/library/socket.html | Коды ошибок соединений |
Linux: errno(3) | https://man7.org/linux/man-pages/man3/errno.3.html | ECONNREFUSED, ETIMEDOUT, ENETUNREACH |
Навигация
← Предыдущий материал
Вернуться к разделу
Следующий материал → Практические задания
Главное оглавление