Сеть: ip, curl, DNS и диагностика

Сервер не отвечает. Что делать? Звонить девопсу? Нет - сначала проверить сеть. 80% проблем на проде - это сеть, DNS или файрвол.

Проверка сетевых интерфейсов

ip addr                    # все интерфейсы и IP-адреса
ip addr show eth0          # конкретный интерфейс
hostname -I                # только IP-адреса (коротко)
`ifconfig` всё ещё работает, но `ip` - современная замена. На минимальных образах (Alpine, Docker) `ifconfig` может отсутствовать.

ss -tlnp: читаем вывод по колонкам

ss - первая команда при любом «не подключается». Флаги расшифровываются так: -t только TCP, -l только слушающие сокеты, -n не превращать номера портов в имена, -p показать процесс.

sudo ss -tlnp
# State  Recv-Q Send-Q Local Address:Port Peer Address:Port Process
# LISTEN 0      4096       127.0.0.1:8080      0.0.0.0:*     users:(("backend",pid=1234,fd=3))
# LISTEN 0      4096         0.0.0.0:80        0.0.0.0:*     users:(("nginx",pid=567,fd=6))
# LISTEN 0      244            [::1]:5432         [::]:*     users:(("postgres",pid=890,fd=5))

Ключевая колонка - Local Address, и именно её читают неправильно:

АдресКто может подключиться
0.0.0.0:80кто угодно, со всех интерфейсов
127.0.0.1:8080только процессы на этой же машине
[::]:80все интерфейсы IPv6 (обычно плюс IPv4)
10.0.0.5:5432только через конкретный интерфейс, в этой подсети

Recv-Q у слушающего сокета - это число соединений, принятых ядром, но ещё не забранных приложением. Ненулевое значение, которое не уменьшается, означает: приложение не успевает делать accept, очередь копится, клиенты ждут. Send-Q у слушающего сокета показывает размер backlog (лимит очереди). Без sudo колонка Process будет пустой - это не баг, а права.

ss -tan state established              # активные соединения, а не только слушающие
ss -tanp | grep :5432                  # кто держит коннекты к базе
ss -s                                  # сводка: сколько сокетов какого типа

Слушает 127.0.0.1 - снаружи не доступно

Самая частая причина «локально работает, с моей машины нет». Приложение привязалось к loopback-интерфейсу, и никакой файрвол тут не виноват:

sudo ss -tlnp | grep 8080
# LISTEN 0 4096 127.0.0.1:8080 ...     <- вот причина
curl http://localhost:8080/health      # с сервера отвечает
curl http://SERVER_IP:8080/health      # снаружи: Connection refused

В коде это буквально одна строка. localhost:8080 слушает только loopback, пустой хост - все интерфейсы:

http.ListenAndServe("localhost:8080", mux)   // только с этой машины
http.ListenAndServe(":8080", mux)            // со всех интерфейсов
http.ListenAndServe("0.0.0.0:8080", mux)     // то же самое, явно
<?php
declare(strict_types=1);

// Встроенный сервер: адрес привязки задаётся аргументом -S
//   php -S 127.0.0.1:8080   - только с этой машины
//   php -S 0.0.0.0:8080     - со всех интерфейсов
// В PHP-FPM то же решение принимает директива listen в пуле:
//   listen = 127.0.0.1:9000  или  listen = 0.0.0.0:9000

$onlyLocal = stream_socket_server('tcp://127.0.0.1:8080', $errno, $errstr);
$everyone = stream_socket_server('tcp://0.0.0.0:8080', $errno, $errstr);
`docker run -p 8080:8080` пробрасывает порт на **сетевой интерфейс контейнера**. Если приложение внутри контейнера слушает `127.0.0.1`, оно слушает loopback контейнера, и проброс никуда не ведёт: `curl` с хоста получит `Connection reset` или пустой ответ. Внутри контейнера всегда бинди на `0.0.0.0`, а ограничение доступа делай пробросом (`-p 127.0.0.1:8080:8080`) или сетью compose, см. [урок про сети и volumes](../docker/07-env-volumes-networks.md).

Обратная сторона: слушать 0.0.0.0 базой данных на публичном VPS - плохая идея. Правильная схема для PostgreSQL и Redis - 127.0.0.1 плюс подключение через приватную сеть или SSH-туннель:

ssh -L 5432:127.0.0.1:5432 user@server    # порт сервера появится локально

curl - главный инструмент для HTTP

curl http://localhost:8080/health          # GET запрос
curl -v http://localhost:8080/health       # подробно (заголовки, TLS)
curl -X POST -H "Content-Type: application/json" \
 -d '{"email":"test@test.com"}' \
     http://localhost:8080/api/v1/users    # POST с JSON
curl -o file.tar.gz https://example.com/file.tar.gz  # скачать файл
curl -w "%{http_code}" -s -o /dev/null http://localhost:8080/health  # только код ответа

Флаги, которые нужны постоянно:

ФлагЗначение
-vverbose (показать заголовки)
-ssilent (без прогресс-бара)
-ooutput в файл
-Hдобавить заголовок
-dтело запроса
-Xметод (POST, PUT, DELETE)
-kигнорировать ошибки SSL

Читаем curl -v по фазам

curl -v показывает четыре разные фазы, и падать может любая. Понимать, на какой именно оборвалось, важнее, чем видеть итоговую ошибку:

* Host api.example.com:443 was resolved.                  <- 1. DNS
* IPv4: 203.0.113.10
*   Trying 203.0.113.10:443...
* Connected to api.example.com (203.0.113.10) port 443     <- 2. TCP
* ALPN: server accepted h2
* SSL certificate verify ok.                               <- 3. TLS
> GET /health HTTP/2                                       <- 4. HTTP-запрос (мы пишем)
> host: api.example.com
>
< HTTP/2 200                                               <- ответ (сервер пишет)
< content-type: application/json

Правило чтения: * - это комментарии самого curl о транспорте, > - что мы отправили, < - что пришло. Значит:

  • оборвалось до Trying - виноват DNS;
  • дошло до Trying и повисло - файрвол дропает пакеты (не отвергает, а молчит);
  • Connection refused сразу - порт закрыт, приложение не слушает;
  • упало на SSL certificate problem - сертификат просрочен, самоподписан или не совпало имя;
  • дошло до > и висит - принимающее приложение зависло на обработке, транспорт в порядке.

Чтобы получить числа вместо ощущений, у curl есть тайминги по фазам:

curl -s -o /dev/null -w "dns=%{time_namelookup} tcp=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n" \
  https://api.example.com/health
# dns=0.004 tcp=0.041 tls=0.128 ttfb=0.512 total=0.513

Разница ttfb - tls - это время, которое сервер думал над ответом. Если она большая, а остальное быстрое, проблема в приложении или базе, а не в сети. Обратная картина (большой dns) - симптом медленного или недоступного резолвера.

Полезные флаги для диагностики, которых нет в базовой таблице:

curl -sf http://localhost:8080/health          # -f: ненулевой exit code на 4xx/5xx, нужен в скриптах
curl -I https://example.com                    # только заголовки (HEAD)
curl -L http://example.com                     # следовать редиректам
curl --resolve api.example.com:443:127.0.0.1 https://api.example.com/health   # проверить свой сервер до смены DNS
curl --max-time 5 --connect-timeout 2 ...      # не висеть вечно в скриптах

Флаг --resolve стоит запомнить отдельно: он подменяет DNS только для этого запроса, но оставляет правильный заголовок Host и SNI. Так проверяют новый сервер, ещё не переключив домен.

DNS - проверка доменов

dig example.com                # DNS запрос (подробно)
dig +short example.com         # только IP
nslookup example.com           # альтернатива
host example.com               # ещё альтернатива
cat /etc/resolv.conf           # какие DNS-серверы используются
После смены DNS-записи изменения могут не появиться сразу. TTL (Time To Live) определяет, сколько кеш живёт. Для проверки свежих записей: `dig @8.8.8.8 example.com` - запрос напрямую к Google DNS.

dig показывает одно, приложение видит другое

Ловушка, на которую попадаются все: dig не учитывает /etc/hosts. Он отправляет запрос прямо DNS-серверу, минуя системный резолвер. А приложение (и curl, и Go-сервис) идёт через resolver, который сначала смотрит в файл. Отсюда классика: dig +short api.internal возвращает продовый IP, а сервис стучится на localhost, потому что кто-то полгода назад оставил строку в /etc/hosts.

cat /etc/hosts                 # локальные переопределения имён
getent hosts api.internal      # разрешить имя так, как это сделает приложение
dig +short api.internal        # разрешить имя в обход /etc/hosts
cat /etc/nsswitch.conf | grep hosts
# hosts: files dns             <- files (то есть /etc/hosts) идёт ПЕРВЫМ

Правило диагностики: расхождение между getent hosts и dig +short почти всегда означает запись в /etc/hosts. Проверять всегда обеими командами.

Тот же файл - легальный инструмент. Локальная разработка с «настоящими» доменами делается строкой 127.0.0.1 api.local, а проверка нового сервера до переключения DNS - через curl --resolve из раздела выше (это чище, чем правка /etc/hosts, потому что не нужно потом откатывать).

Кеш DNS живёт как минимум в двух местах, и оба сбрасываются вручную:

resolvectl statistics                  # статистика кеша systemd-resolved
sudo resolvectl flush-caches           # сбросить кеш systemd-resolved
resolvectl query api.example.com       # что вернёт резолвер с учётом кеша и /etc/hosts
Стандартный резолвер Go не держит собственного кеша, зато соединения живут в пуле `http.Transport` и переиспользуются: после смены IP у домена сервис может часами стучаться на старый адрес по уже открытому keep-alive соединению. Симптом - «DNS переключили, все переключились, а один сервис нет». Лечится рестартом сервиса или уменьшением `IdleConnTimeout` у транспорта, см. урок про [HTTP-клиент и сервер в Go](../go/13-http.md).

Проверка доступности

ping example.com               # ICMP ping (Ctrl+C для остановки)
ping -c 4 example.com          # 4 пакета и стоп

# Проверка TCP-порта (без ping)
nc -zv example.com 443         # можно ли подключиться к порту 443
nc -zv localhost 5432           # доступен ли PostgreSQL

# Или через curl
curl -v telnet://localhost:5432 # проверка TCP-соединения

nc -z отвечает не на тот вопрос

nc -z открывает TCP-соединение и сразу закрывает. Он отвечает ровно на один вопрос: «принял ли кто-то соединение на этом порту». И этого мало.

ПроверкаЧто подтверждаетЧего не заметит
pingхост отвечает на ICMPчто приложение вообще запущено; ICMP часто заблокирован отдельно
nc -z host 8080порт принимает соединениязависшее приложение, ошибки 500, просроченный TLS, неверный роутинг
curl -sf host/healthприложение отвечает 2xxглубину проверки: живы ли база и брокер
curl -sf host/health с проверкой теласервис проверил свои зависимостиничего важного, это верхний уровень

Практическое следствие: nc -z годится для быстрой проверки в bash-скрипте, но health-check сервиса всегда делают HTTP-запросом с -f. Причина в том, что accept выполняет ядро: приложение может висеть в дедлоке или ждать базу, а соединения всё равно принимаются - nc -z покажет «всё хорошо» на мёртвом сервисе.

Три ошибки подключения и что каждая означает

Connection refused    порт никто не слушает: процесс упал, слушает другой адрес
                      или ты стучишься не туда. Пакет дошёл, ответ RST пришёл.
Connection timed out  ответа нет вообще: файрвол дропает пакеты молча,
                      security group закрыт, маршрут ведёт в никуда.
No route to host      маршрутизации до адреса нет: неверная подсеть,
                      упал шлюз, VPN отключился.

Разница между первой и второй строкой - главная развилка диагностики. refused означает, что сеть работает и дело в приложении (или адресе привязки, см. раздел про 127.0.0.1). timed out означает, что дело в фильтрации трафика, и лезть в логи приложения бессмысленно.

MTU: «пинг идёт, а запрос не проходит»

Отдельный класс инцидентов, который выглядит как магия: ping работает, curl подключается и печатает заголовки запроса, а дальше висит. Или SSH заходит, но обрывается, как только выводит много текста.

Причина - MTU, максимальный размер пакета на пути. Мелкие пакеты (пинг, запрос) проходят, крупные (ответ с телом, вывод команды) - нет, потому что где-то по маршруту MTU меньше, а сообщение «пакет слишком большой» блокируется файрволом. Классические места: VPN, туннели, сети внутри Docker и оверлеи, мобильные операторы.

ip link show eth0 | grep mtu           # какой MTU у интерфейса (обычно 1500)
ping -M do -s 1472 example.com         # 1472 + 28 байт заголовков = 1500
# Frag needed and DF set (mtu = 1400)  <- значит реальный предел 1400
tracepath example.com                  # покажет MTU по шагам маршрута

Проверка простая: если ping -c 3 без флагов проходит, а ping -M do -s 1472 возвращает Frag needed, у тебя проблема с MTU, а не с приложением. Лечение на стороне сервера - понизить MTU интерфейса или включить MSS clamping на шлюзе; в одиночку бэкендер это чинит редко, но правильно назвать причину сетевикам - половина решения.

Диагностика: маршрут до сервера

traceroute example.com         # через какие узлы идёт трафик
mtr example.com                # traceroute + ping в реальном времени

Файрвол

sudo ufw status                # статус файрвола (Ubuntu)
sudo ufw allow 8080/tcp        # открыть порт
sudo ufw deny 3306/tcp         # закрыть порт
sudo iptables -L -n            # правила iptables (низкоуровнево)
Если закроешь SSH-порт - потеряешь доступ к серверу. Всегда проверяй правила перед применением. `ufw allow 22/tcp` должен быть первым правилом.

Скачивание файлов

wget https://example.com/file.tar.gz         # скачать файл
wget -q -O - https://example.com/script.sh   # вывести в stdout
curl -fsSL https://example.com/install.sh     # аналог через curl

Чек-лист диагностики «сервер не отвечает»

  1. ping server - сервер вообще жив?
  2. ss -tlnp | grep 8080 - приложение слушает порт?
  3. curl -v http://localhost:8080/health - отвечает локально?
  4. sudo ufw status - файрвол не блокирует?
  5. dig +short yourdomain.com - DNS указывает на правильный IP?
  6. curl -v https://yourdomain.com/health - отвечает снаружи?

Мини-задание

  • Узнай свой IP: hostname -I или ip addr
  • Проверь DNS: dig +short google.com
  • Сделай HTTP-запрос: curl -v http://httpbin.org/get
  • Проверь порт: nc -zv localhost 22
  • Выполни sudo ss -tlnp и найди сервис, который слушает только 127.0.0.1
  • Замерь тайминги: curl -s -o /dev/null -w "dns=%{time_namelookup} ttfb=%{time_starttransfer}\n" https://example.com
  • Сравни getent hosts localhost и dig +short localhost - объясни разницу
  • Проверь MTU своего интерфейса: ip link show | grep mtu
  • Постучись на закрытый порт (curl -v http://localhost:9999) и на дропающий адрес - сравни refused и timed out

Итог

  • curl - основной инструмент HTTP-диагностики. Выучи флаги -v, -s, -H, -d.
  • ss -tlnp - порты. dig - DNS. ping / nc - доступность.
  • Чек-лист из 6 шагов покрывает 90% ситуаций «почему не работает».

Типичная ошибка

Паниковать и перезагружать сервер, когда приложение не отвечает. Перезагрузка - это не диагностика. Сначала пройди чек-лист, найди причину, потом действуй.

Мини-практика

Подними любой HTTP-сервер локально (например, встроенный сервер PHP - php -S localhost:9090). Проверь через curl -v http://localhost:9090. Посмотри заголовки ответа. Потом останови сервер и повтори curl - увидишь Connection refused. Запомни, как выглядит разница между «сервис не отвечает» и «сервис не запущен».

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.