«Сервис не отвечает» — это не диагноз, а симптом. За ним прячется десяток разных причин: имя не разрешается, порт закрыт, соединение установилось, но ответа нет, ответ приходит, но через десять секунд. Пока причина не названа, чинить нечего.
Хорошая новость: разложить симптом на причины можно за пару минут, не поднимая никаких инструментов сверх тех, что уже стоят на любой машине. Плохая — если идти не по порядку, легко полчаса изучать логи приложения, которое вообще не получало запроса.
Порядок такой: имя → порт → запрос целиком → соединения → трафик. Каждый следующий шаг дороже предыдущего, и до последнего доходят редко.
Как это выглядит на одном разборе: каждая команда отвечает на свой вопрос и отсекает половину причин.
Порядок работает как отсев: имя разрешилось, порт открыт — и остаётся один вопрос, где ушли 1845 мс. Сети досталось 61 мс, остальные 1784 сервис думал сам, так что чинить надо его, а не маршруты. До шагов 4 и 5 дело так и не дошло — обычно и не доходит.
Шаг 1: разрешается ли имя
Первое, что делает клиент, — превращает имя в адрес. Если это не работает, всё остальное не имеет значения.
dig +short orders.internal
# 10.2.14.7
Пусто — имени нет. Дальше смотрят, кто должен был его дать: тот ли сервер имён отвечает и не закэширован ли старый ответ.
dig orders.internal | grep -E "SERVER|ANSWER"
# ;; ANSWER SECTION:
# ;; SERVER: 10.96.0.10#53(10.96.0.10)
У dig есть особенность, о которую спотыкаются чаще всего: он идёт прямо к серверу имён и больше никуда. Приложение так не делает — оно спрашивает операционную систему, а та сначала заглядывает в /etc/hosts и только потом, по порядку из /etc/nsswitch.conf, идёт в DNS. Поэтому строчка в hosts, добавленная месяц назад и забытая, для dig невидима, а процесс уводит совсем в другое место. Чтобы увидеть ровно то, что увидит приложение, спрашивайте так:
getent hosts orders.internal
# 10.2.14.7 orders.internal
Разошлись dig и getent — ответ найден: имя переопределено локально.
Ещё две ловушки на этом шаге. Первая: приложение может держать собственный кэш адресов — Java кэширует их по умолчанию на 30 секунд, и после переезда сервиса она ещё какое-то время ходит на старый адрес. Если dig показывает новый адрес, а приложение стучится в старый, дело именно в этом. Вторая: внутри кластера короткое имя достраивается до полного по списку доменов поиска, и запрос уходит не туда, куда вы думали, — полное имя с точкой на конце (orders.internal.) снимает вопрос.
Шаг 2: открыт ли порт
Имя разрешилось — проверяем, принимает ли кто-то соединения на нужном порту.
curl -v --connect-timeout 3 telnet://10.2.14.7:8080
Одна оговорка: команда работает, только если curl собран с поддержкой telnet, а в урезанных образах её часто выкидывают — тогда вместо ответа вы получите Protocol "telnet" not supported. Проверяется это curl --version: список протоколов идёт второй строкой. Если telnet там нет, тот же вопрос задают иначе:
nc -vz 10.2.14.7 8080
# а если нет и nc — сойдёт обычный http-запрос: нас интересует не ответ, а сам факт соединения
curl -sS -o /dev/null --connect-timeout 3 http://10.2.14.7:8080/
Здесь важны не подробности вывода, а какая из трёх вещей случилась.
Одна проверка порта и три исхода: смотрите, отказали вам мгновенно или не ответили вовсе.
Соединение установилось («Connected to») — порт открыт, слушающий процесс есть. Проблема дальше, идите к третьему шагу.
Connection refused — ответ пришёл мгновенно, и он отрицательный: на этом адресе никто не слушает. Приложение упало, не поднялось или слушает другой порт. Это хороший, честный отказ — его видно сразу.
Ожидание до таймаута — ответа нет вовсе. Пакеты уходят в никуда: их роняет межсетевой экран, правило безопасности в облаке или сетевая политика кластера. Отличать эти два случая критично: «отказано» — чините приложение, «молчит» — чините сеть.
На самой машине сервиса полезно убедиться, что он слушает там, где вы ожидаете:
ss -ltnp | grep 8080
# LISTEN 0 4096 *:8080 *:* users:(("java",pid=1,fd=42))
Смотрите на адрес слева от порта. *:8080 или 0.0.0.0:8080 — принимает отовсюду. А вот 127.0.0.1:8080 — только со своей машины: снаружи такой порт закрыт, и это частая причина «локально работает, в контейнере нет».
Шаг 3: где теряется время
Соединение устанавливается, ответ приходит, но медленно. Дальше нужно понять, на каком именно этапе уходит время, — и это curl умеет показывать сам.
curl -o /dev/null -s -w \
'dns=%{time_namelookup} connect=%{time_connect} tls=%{time_appconnect} ttfb=%{time_starttransfer} total=%{time_total}\n' \
https://orders.internal/api/orders/1
# dns=0.004 connect=0.005 tls=0.061 ttfb=1.842 total=1.845
Числа читаются как накопительная шкала: каждое следующее включает предыдущие. Разница между соседними и есть длительность этапа.
В примере выше видно всё сразу: имя разрешилось за 4 миллисекунды, соединение установилось мгновенно, рукопожатие защищённого канала заняло 56 миллисекунд, а вот от конца рукопожатия до первого байта ответа прошло почти две секунды. Значит, сеть ни при чём — думает само приложение, и дальше идти надо в его метрики и логи, а не в сетевые настройки.
Обратная картина — большой connect при мгновенном ttfb — говорит о том, что время съедает установка соединения: далёкий сервер, потери пакетов или переполненная очередь на приёме. Дальше идут по этим трём: ping и traceroute до адреса покажут дальность и потери, а ss -ltn на сервере покажет очередь приёма, Recv-Q рядом с Send-Q; если она полна, сервер не успевает принимать соединения, и лечится это не сетью, а его нагрузкой.
Шаг 4: сколько соединений и в каких они состояниях
Когда сервис «иногда не отвечает» под нагрузкой, полезно посмотреть не на один запрос, а на картину целиком.
ss -tanH | awk '{print $1}' | sort | uniq -c | sort -rn
# 842 ESTAB
# 611 TIME-WAIT
# 47 CLOSE-WAIT
# 3 SYN-SENT
Флаг -H здесь не для красоты: без него в подсчёт попадёт строка заголовка, и в выводе появится загадочное состояние State с единичкой. Если ss старый и -H не знает, отрежьте заголовок сами: ss -tan | tail -n +2 | ....
Что означают перекосы:
- Много
TIME-WAIT— норма для сервера, который часто закрывает соединения сам. Тревожно, когда их десятки тысяч на клиенте: значит, соединения не переиспользуются, и скоро кончатся свободные исходящие порты. Лечится включением keep-alive, а не настройками ядра. - Растущий
CLOSE-WAIT— почти всегда ошибка в коде: собеседник закрыл свою сторону, а приложение не закрыло свою. Такие соединения не исчезнут сами и будут копиться, пока не упрётся лимит открытых файлов. - Много
SYN-SENT— запросы уходят и остаются без ответа: та же картина, что и «молчит» на втором шаге, только видно её массово.
Два пути закрытия одного соединения: смотрите, у кого остаётся TIME-WAIT и где висит CLOSE-WAIT.
А туда ли я стучусь
Половина инцидентов «у нас работает, у вас нет» — это про то, что запрос уходит не туда, куда вы думаете. Три приёма, которые это проверяют.
Принудительный адрес при том же имени. curl --resolve подставляет свой адрес для имени, не меняя ни заголовок узла, ни проверку сертификата:
curl -v --resolve api.example.com:443:10.0.1.15 https://api.example.com/health
Так проверяют конкретный экземпляр за балансировщиком: имя то же, сертификат тот же, а идём именно туда. Это же отвечает на вопрос «все экземпляры отвечают или только некоторые».
Обращение по адресу с заголовком узла. Когда сертификат не важен (внутри сети, по HTTP), можно пойти прямо по адресу и сказать серверу, за какое имя мы его спрашиваем:
curl -v -H 'Host: api.example.com' http://10.0.1.15:8080/health
Разница с предыдущим — здесь нет TLS, зато видно, как отвечает конкретный процесс. Если по адресу отвечает, а по имени нет — проблема в разрешении имени или в балансировщике, а не в сервисе.
Версия протокола адресации. У имени бывают адреса обеих версий, и клиент может выбрать не ту: curl -4 и curl -6 заставляют идти по конкретной. Классический симптом — «работает из терминала, не работает из приложения» или наоборот: разные клиенты предпочитают разные версии, а доступ настроен только для одной.
Диагностика изнутри контейнера
Важная поправка ко всем шагам: у процесса в контейнере или в поде своё сетевое пространство. Это значит, что проверка с хоста ничего не доказывает о том, что видит приложение, — и наоборот.
Поэтому диагностику ведут оттуда, откуда идёт запрос: kubectl exec в под, docker exec в контейнер. Если в образе нет инструментов (а в минимальных образах их нет), есть два пути: временный отладочный контейнер, подключённый к тому же сетевому пространству (kubectl debug -it --image=nicolaka/netshoot --target=…), или запуск отдельного пода с теми же настройками сети и правами.
И вторая поправка: ping часто ничего не доказывает. Он использует служебный протокол ICMP, а его блокируют и в облаках, и в корпоративных сетях, и на самих хостах. Непрошедший ping при работающем сервисе — норма; прошедший ping при неработающем сервисе — тоже норма. Проверять доступность надо тем же протоколом, которым ходит приложение: nc -vz host port для TCP, curl для HTTP.
Проверка сертификата и времени
Просроченный сертификат — один из самых частых инцидентов, и проверяется он двумя командами.
curl -vI https://api.example.com 2>&1 | grep -E 'expire|subject|issuer'
echo | openssl s_client -connect api.example.com:443 -servername api.example.com 2>/dev/null \
| openssl x509 -noout -subject -issuer -dates
Второй вариант показывает всю цепочку и точные даты. Смотреть надо на три вещи: не истёк ли срок, то ли имя в сертификате (при нескольких именах на одном адресе важен параметр с именем — без него сервер отдаст сертификат по умолчанию), и полна ли цепочка — не хватает промежуточного сертификата, и часть клиентов будет отвергать соединение, а другая работать.
И отдельно — время на машине. Сертификат «истёк» или «ещё не действует» на машине с уехавшими часами; то же ломает подписи запросов к облакам (там окно обычно пятнадцать минут) и проверку токенов. timedatectl status или date -u в сравнении с настоящим временем — проверка на пять секунд, которая экономит час разбирательств.
Пороги и где лежат лимиты
Шаг с подсчётом состояний соединений полезен только с числами, а числа зависят от машины — поэтому важнее знать, где посмотреть предел.
Соединения в ожидании закрытия (TIME_WAIT). Сами по себе они безвредны: это нормальное состояние после закрытия. Опасен их рост на клиенте к одному адресу: предел — размер диапазона эфемерных портов, и он виден в sysctl net.ipv4.ip_local_port_range (обычно около 28 тысяч). Считают так: ss -tan state time-wait | wc -l; число, сравнимое с диапазоном, означает, что новые соединения скоро начнут падать.
Очередь на приём (Send-Q у слушающего сокета в ss -lt) — сколько соединений ждёт, пока приложение их примет. Ненулевое значение постоянно означает, что приложение не успевает принимать: либо мало потоков, либо они заняты.
Открытые файлы. Каждое соединение — это дескриптор. Предел процесса виден в cat /proc/<pid>/limits (строка про открытые файлы), а текущее число — ls /proc/<pid>/fd | wc -l. Достигнутый предел выглядит в журнале как ошибка «слишком много открытых файлов» — и это одна из двух главных причин, по которой сервис с долгими соединениями перестаёт принимать новые.
Отслеживание соединений на межсетевом экране. Там своя таблица и свой предел; исчерпание выглядит как случайные потери соединений при исправной сети. Смотрят в nf_conntrack_count против nf_conntrack_max.
Правило для оповещений: ставить их не на абсолютные числа, а на долю от предела (скажем, 80 %), потому что пределы на разных машинах разные.
Шаг 5: посмотреть на сам трафик
Если предыдущие шаги не объяснили происходящее, остаётся посмотреть, что реально уходит с машины и приходит на неё.
sudo tcpdump -n -i any host 10.2.14.7 and port 8080
# 12:04:11.201 IP 10.2.9.3.51422 > 10.2.14.7.8080: Flags [S], seq 812...
# 12:04:12.203 IP 10.2.9.3.51422 > 10.2.14.7.8080: Flags [S], seq 812...
Даже без разбора протокола эта картинка отвечает на главный вопрос: одинаковые попытки установить соединение, повторяющиеся раз в секунду, и ни одного ответа. Пакеты уходят, обратно ничего — значит, теряются они где-то по пути, и разбираться нужно с сетью, а не с приложением.
Дамп — тяжёлый инструмент: он требует прав, шумит на нагруженной машине и почти бесполезен для защищённых соединений, где содержимое зашифровано. Поэтому его берут последним и всегда с фильтром по адресу и порту, чтобы не утонуть в чужом трафике.
Если непонятно, где именно теряются пакеты, помогает mtr: он показывает путь до адреса и потери на каждом переходе.
mtr -rw -c 20 10.2.14.7
Потери на промежуточном узле сами по себе ничего не значат — маршрутизаторы часто не отвечают на служебные пакеты, экономя силы. Значимы потери, которые начинаются на каком-то узле и держатся до самого конца.
Глубже: постоянное наблюдение на границе: задержка по этапам, повторы, предохранитель, пулрасширенное
Пять шагов выше это разбор с хоста, когда уже сломалось. Чтобы узнать раньше и не с хоста, на границе сервиса снимают постоянные метрики, и набор их небольшой.
Задержка по этапам, а не одна цифра. «Вызов оплаты занял 800 мс» ничего не говорит; полезно знать, из чего они сложились: ожидание соединения из пула, установка соединения (DNS, TCP, TLS), время до первого байта ответа, чтение тела. Клиенты умеют это отдавать: curl -w с переменными time_connect, time_appconnect, time_starttransfer для разовой проверки, а в приложении перехватчики HTTP-клиента или OpenTelemetry, где у span есть события на каждый этап. По раскладке сразу видно, что лечить: растёт ожидание пула, значит пул мал или соединения не возвращают; растёт установка, значит keep-alive не работает или DNS медленный; растёт время до первого байта, значит проблема у соседа.
Доля повторов и таймаутов. Счётчики на каждый исходящий вызов по имени соседа: сколько попыток, сколько закончились таймаутом, сколько ушли на повтор, сколько получили 429 и 5xx. Не средние, а доли: доля повторов выше нескольких процентов означает, что сосед деградирует раньше, чем упадёт, а всплеск таймаутов при нормальной задержке означает, что таймаут выставлен впритык.
Состояние предохранителя. Каждый переход между замкнутым, разомкнутым и полуоткрытым состоянием это событие в метриках и в логе, и разомкнутый предохранитель это сигнал тревоги, а не просто цифра на панели. Resilience4j и Polly отдают это готовыми метриками, gobreaker, opossum и pybreaker — событием или слушателем смены состояния, на которые вешают метрику, и график «сколько времени за час предохранитель к оплате был разомкнут» лучше любого описания инцидента.
Пул соединений. Активные, простаивающие, ожидающие и время ожидания, для каждого пула отдельно: к базе, к каждому соседу, к очереди. Пул, где активные упираются в максимум, а ожидающие растут, это следующий инцидент; пул, где активные всегда около нуля, размером в сотню, это зря занятая память соседа.
Гистограммы и процентили. Средняя задержка скрывает хвост, а страдает именно хвост: p99 в пять раз выше медианы это норма для сети, и сигнал тревоги ставят на p99, а не на среднее. Гистограммы позволяют считать процентили на стороне мониторинга и складывать по экземплярам, средние складывать нельзя.
Всё это снимает не только сервис, но и балансировщик перед ним и sidecar рядом, и часто их метрики честнее: они видят запросы, которые до сервиса не дошли. Как это укладывается в общую систему наблюдаемости, разбирает соответствующий раздел; здесь важен минимум, без которого сетевой инцидент разбирают вслепую.
Коротко
- Идите по порядку: имя, порт, время запроса, соединения, трафик. Каждый следующий шаг дороже, и до последнего доходят редко.
Connection refusedи таймаут — разные диагнозы: первое означает «никто не слушает», второе «пакеты не доходят».curl -wраскладывает запрос на этапы и за секунду отвечает, кто виноват: сеть или приложение.- Приложение может держать свой кэш адресов, поэтому
digи поведение сервиса расходятся; куда именно идёт запрос, проверяютcurl --resolve, обращением по адресу с заголовком узла и принудительным-4/-6. - Растущий
CLOSE-WAIT— не сетевая проблема, а незакрытые соединения в коде. - Слушающий адрес важен не меньше порта:
127.0.0.1снаружи недоступен. - Постоянно на границе снимают задержку по этапам (пул, соединение, первый байт, тело), доли таймаутов и повторов, переходы предохранителя, состояние каждого пула; тревога по p99, а не по среднему.
- У контейнера своё сетевое пространство: диагностику ведут изнутри пода, а
pingничего не доказывает — ICMP часто закрыт, проверяют тем же протоколом. - Сертификат смотрят
openssl s_client(срок, имя, полнота цепочки) и обязательно сверяют время на машине — уехавшие часы ломают и сертификаты, и подписи. - Пороги привязывают к пределам машины: диапазон эфемерных портов против числа
TIME_WAIT, очередь на приём, предел открытых файлов, таблица отслеживания соединений.
Что почитать дальше
- IP-адреса и порты — откуда берутся эфемерные порты и чем грозит их исчерпание.
- TCP и UDP — что стоит за состояниями соединений.
- DNS — кэши на всех уровнях и почему адрес обновляется не сразу.
- Соединения и keep-alive — как переиспользование соединений убирает половину проблем из этой статьи.