Раздел 3 · Kubernetes — глава 3.5

Путь запроса: от браузера до пода и обратно

Любимый вопрос всех собеседований на позицию выше джуна: «что происходит, когда пользователь вбивает адрес в браузере». Ценность его в том, что за пятнадцать минут можно проверить сразу всё — DNS, TCP, TLS, балансировку, Kubernetes, — и увидеть, где у человека дыры. Эта глава — детальный проход по всей цепочке, и заодно сборка остальных глав в одну картину.

Карта целиком

Сначала общий вид, чтобы было куда возвращаться. Дальше каждый шаг разбираем отдельно.

браузер │ 1. разбор URL, свои кеши, HSTS │ 2. DNS: → 203.0.113.10 │ ├─ 3. TCP-хендшейк ─────────────→ ┐ ├─ 4. TLS-хендшейк (SNI, ALPN) ───→ │ внешний балансировщик └─ 5. HTTP-запрос ────────────────→ ┘ (облачный LB / HAProxy / MetalLB) │ ═══════ граница кластера ═══════ │ 6. NodePort на одном из узлов kube-proxy: DNAT → под роутера │ 7. Ingress-контроллер (nginx / Envoy) разбирает Host и путь, выбирает сервис, балансирует ОТДЕЛЬНЫЕ ЗАПРОСЫ │ 8. IP пода приложения через CNI (роутинг или туннель, свой MTU) │ ┌────┴────┐ │ под │ ядро кладёт данные │ :8080 │ в сокет, приложение └─────────┘ делает read()

§1Браузер разбирает URL

Шаг, который обычно проскакивают, а зря — там есть пара вещей, которые реально ломают продакшен.

Браузер разбирает https://shop.example.com/cart?id=42 на схему, хост, порт (по умолчанию 443), путь и параметры. Дальше — проверки до всякой сети:

  • Кеш HSTS. Если домен когда-то отдал заголовок Strict-Transport-Security, браузер сам перепишет http:// в https://, ещё не сделав ни одного запроса. Отсюда классическая паника «я убрал редирект на HTTPS, а браузер всё равно идёт на 443» — надо чистить chrome://net-internals/#hsts.
  • Кеш ответов. Свежий ответ с подходящим Cache-Control отдастся сразу, без сети вообще.
  • Пул соединений. Если к этому хосту уже открыто живое соединение — новое устанавливать не будут, всё поедет по нему. Это тот самый keep-alive, который ломает L4-балансировку (см. главу 3.1, §7).

§2DNS: как имя превращается в адрес

Браузеру нужен IP. Резолв идёт по цепочке кешей, и на каждом уровне может закончиться досрочно.

кеш браузера └→ кеш ОС / systemd-resolved / nscd └→ /etc/hosts (проверяется раньше сети — сюда лезут при отладке) └→ рекурсивный резолвер (роутер, провайдер, 8.8.8.8, 1.1.1.1) │ если у него нет в кеше — идёт вверх по иерархии: ├→ корневые серверы: «кто отвечает за .com?» ├→ серверы .com: «кто отвечает за example.com?» └→ авторитативный сервер example.com: «shop.example.com = 203.0.113.10, TTL 300»

Что здесь важно знать сверх схемы:

TTL решает, как быстро поедет переключение. Записи кешируются на всех уровнях ровно столько, сколько сказано в TTL. Поэтому перед плановой сменой адреса TTL заранее опускают до минуты-двух, а после переключения возвращают обратно. И поэтому же DNS — плохой инструмент аварийного переключения: часть клиентов будет ходить на старый адрес ещё долго, а некоторые резолверы и приложения игнорируют TTL и кешируют дольше положенного (JVM исторически этим славилась).

Отрицательные ответы тоже кешируются. Если запросить имя до того, как запись создана, NXDOMAIN осядет в кешах на время из SOA-записи. Отсюда «я же создал запись, почему не резолвится» — потому что кто-то по дороге запомнил, что её нет.

Ответом может быть не один адрес. Несколько A-записей — примитивная балансировка; CNAME на домен провайдера — типично для CDN и облачных балансировщиков; anycast-адрес — один и тот же IP анонсируется из десятков точек мира, и маршрутизация сама приводит тебя к ближайшей.

В корпоративной сети почти всегда split-horizon: внутренний DNS отдаёт на то же имя внутренний адрес, а внешний — публичный. Это то, чем правильно лечится проблема hairpin NAT из главы 1.2.

$ dig +short shop.example.com
203.0.113.10

$ dig shop.example.com +trace          # пройти всю цепочку от корня
$ dig @8.8.8.8 shop.example.com        # спросить конкретный резолвер
$ dig shop.example.com +noall +answer  # посмотреть TTL в ответе
shop.example.com.  287  IN  A  203.0.113.10
                    ↑ столько секунд осталось жить в кеше резолвера

§3TCP-соединение до этого адреса

Дальше — то, что подробно разобрано в главе 1.1: SYN, SYN-ACK, ACK, согласование MSS и масштабирования окна. Здесь важно добавить контекст пути.

Пакет от домашнего компьютера почти наверняка пройдёт через NAT на домашнем роутере, а часто и через второй NAT у провайдера. Сервер увидит не адрес пользователя, а адрес последнего NAT — что уже на этом этапе означает, что «настоящий IP клиента» становится вопросом договорённостей, а не фактом.

Полезный ориентир по времени: хендшейк стоит один круговой путь. Внутри города — единицы миллисекунд, между континентами — 100–200 мс. Дальше пойдёт TLS, и это ещё круги. Отсюда вся индустриальная борьба за keep-alive, TLS 1.3 и переиспользование сессий: сами данные передаются быстро, дорого именно установление соединения.

§4TLS: как договариваются о шифровании

После установки TCP браузер начинает TLS-хендшейк. Полностью его знать не обязательно, но три вещи спрашивают постоянно.

браузер сервер / балансировщик │ │── ClientHello ─────────────────────────────────→ │ • версии TLS, список шифров │ • SNI: "shop.example.com" ← ИМЯ ХОСТА ОТКРЫТЫМ ТЕКСТОМ │ • ALPN: "h2", "http/1.1" ← какой протокол хотим │ │←──────────────── ServerHello + сертификат ────── │ • выбранный шифр, выбранный ALPN │ • цепочка сертификатов │ │ проверка: подпись доверенным CA? имя совпадает? не истёк? │ │── обмен ключами, оба считают общий сеансовый ключ ──→ │══════════ дальше всё шифруется ════════════════════

SNI — поле в самом первом пакете, где браузер открытым текстом называет запрашиваемый домен. Без него сервер не смог бы понять, какой сертификат предъявлять, если на одном IP висят сотни сайтов. Практическое следствие: маршрутизация по домену возможна до расшифровки — на этом работает режим passthrough у роутеров и балансировка TLS-трафика без владения ключами.

ALPN — здесь же решается, будет это HTTP/2 или HTTP/1.1. Важно, потому что HTTP/2 — это одно мультиплексированное соединение, и все выводы про балансировку из главы 3.1 начинают действовать именно с этого момента.

Проверка сертификата — три независимых условия: подпись выдана доверенным центром (цепочка до корня в хранилище браузера), имя в SAN совпадает с запрошенным доменом, срок действия не истёк. Отсюда самые частые продовые ошибки: отдали сертификат без промежуточного звена цепочки (в браузере работает, потому что он его дотягивает сам, а curl и Java — нет), не обновили вовремя, поставили сертификат не на то имя.

В TLS 1.3 хендшейк укладывается в один круговой путь вместо двух, а при переиспользовании сессии данные можно отправить вообще сразу. Плюс к TLS 1.3 нельзя применить старые атаки на слабые шифры — их из протокола просто выкинули.

$ openssl s_client -connect shop.example.com:443 -servername shop.example.com
                                                  ↑ без -servername получишь
                                                    дефолтный сертификат
subject=CN = shop.example.com
issuer=C = US, O = Let's Encrypt, CN = R3
Verify return code: 0 (ok)
ALPN protocol: h2

# когда до срока обновления
$ echo | openssl s_client -connect shop.example.com:443 2>/dev/null \
  | openssl x509 -noout -dates

§5Внешний балансировщик

Адрес, полученный из DNS, почти никогда не принадлежит поду или даже узлу кластера. Это адрес входной точки, и вариантов немного.

Что стоит на входеКак работает
Облачный балансировщик (ELB, ALB, и аналоги)Создаётся автоматически при Service типа LoadBalancer. Бывает L4 (проксирует TCP) или L7 (разбирает HTTP сам)
MetalLB в bare-metalРаздаёт адреса из пула и анонсирует их — либо gratuitous ARP в L2-режиме, либо BGP-анонсами. Тот самый ARP из главы 1.2
HAProxy / nginx на отдельных машинахКлассика для on-prem: пара машин с плавающим VIP через keepalived, за ними — узлы кластера. Твой вариант по опыту
CDN перед всем этимБлижайшая точка присутствия отдаёт статику из кеша, динамику проксирует к origin

Два вопроса, которые тут задают:

Где обрывается TLS? Три варианта: на CDN (тогда до origin идёт отдельное соединение), на внешнем балансировщике (внутрь кластера — открытый HTTP), или он проходит балансировщик насквозь и обрывается уже на ingress-контроллере. От этого зависит, где лежат сертификаты и кто занимается их обновлением.

Что происходит с IP клиента? L7-балансировщик добавляет заголовок X-Forwarded-For. L4 никаких заголовков добавить не может — он не понимает HTTP, — поэтому для сохранения адреса используют proxy protocol: перед данными соединения передаётся маленький блок с настоящими адресами. Обе стороны должны быть настроены согласованно, иначе получишь либо потерянный IP, либо мусор в начале запроса.

Грабли

Здоровье проверяется здесь же. Балансировщик опрашивает бэкенды и выбрасывает мёртвые — но только если проверка настроена осмысленно. Проверка TCP-соединением на порт узла почти бесполезна: NodePort открыт на всех узлах и отвечает, даже когда подов приложения нет ни одного. Проверять надо HTTP-эндпоинт, который отвечает 200 только при реально работающем приложении. Это же — обязательное условие для externalTrafficPolicy: Local (см. главу 1.2, §9): без корректной проверки трафик поедет на узлы без подов и умрёт там.

§6Вход в кластер

Балансировщик выбрал узел и отправил соединение на его NodePort — скажем, 30443. Дальше начинается то, что разобрано в главе 3.1.

Пакет попадает в сетевой стек узла, где kube-proxy заранее разложил правила: трафик на этот порт надо направить в один из подов ingress-контроллера. Делается DNAT, трансляция запоминается в conntrack, и все последующие пакеты соединения идут туда же без повторного прохода по правилам.

Если выбранный под роутера живёт на другом узле, пакет пойдёт туда через сеть подов — и вот тут добавится SNAT, чтобы ответ вернулся тем же путём. Именно поэтому по умолчанию (externalTrafficPolicy: Cluster) приложение видит адрес узла, а не клиента.

запрос на NodePort 30443 узла worker-2 │ externalTrafficPolicy: Cluster externalTrafficPolicy: Local ────────────────────────────── ──────────────────────────── под роутера может быть где угодно только под на ЭТОМ узле +SNAT, чтобы ответ вернулся сюда SNAT не нужен IP клиента потерян IP клиента сохранён лишний хоп по сети подов прямая доставка узел без подов всё равно примет узел без подов отбросит трафик

§7Ingress-контроллер: первое место, где читают HTTP

До этого момента всё было четвёртым уровнем — адреса и порты, содержимое никого не интересовало. Ingress-контроллер (nginx, Envoy, Traefik, HAProxy) — первое звено, которое разбирает HTTP.

Что он делает по порядку:

  1. Обрывает TLS, если это его роль: по SNI находит нужный секрет с сертификатом и расшифровывает.
  2. Читает заголовок Host и путь, ищет подходящее правило среди всех объектов Ingress в кластере. Правила подбираются по точности: конкретный хост важнее wildcard, точный путь важнее префикса.
  3. Применяет всё, что навешано аннотациями: редиректы, переписывание пути, ограничение скорости, авторизацию, таймауты, CORS.
  4. Выбирает бэкенд и отправляет запрос дальше.

И вот здесь — ключевое отличие от всего, что было раньше, которое стоит подчеркнуть на собеседовании:

Главное про этот шаг

Ingress-контроллер балансирует отдельные HTTP-запросы, а не соединения. Он держит собственный пул соединений к бэкендам и раскидывает по ним запросы честным round robin (или least connections — настраивается). Это ровно то, чего не умеет Service на уровне ядра. Поэтому единственный правильный ответ на вопрос «как размазать нагрузку от gRPC или от клиента с keep-alive» — поставить L7-прокси: он разрывает одно длинное клиентское соединение на отдельные запросы к разным подам.

Ещё одна деталь, которая отличает знающего человека: ingress-nginx по умолчанию не ходит через ClusterIP. Он читает EndpointSlice сервиса и обращается напрямую к IP подов, полностью минуя kube-proxy и iptables. Это даёт ему собственную балансировку, свои таймауты и плавное выведение подов из ротации. Так что «трафик идёт через Service» на этом участке — неточность: Service тут используется только как справочник адресов.

§8Последний участок: до пода приложения

Роутер отправляет запрос на IP пода — например, 10.244.3.17:8080. Пакет уходит в сеть подов, и дальше всё зависит от CNI:

  • Роутинг (Calico с BGP, Cilium нативно): узлы знают маршруты до подсетей друг друга, пакет идёт как обычный маршрутизируемый трафик. Быстро, но требует, чтобы сеть между узлами умела эти маршруты.
  • Туннель (VXLAN, Geneve, IPIP): пакет упаковывается в ещё один пакет, летит между узлами по обычной сети, распаковывается на той стороне. Работает где угодно, но добавляет заголовки — и вычитает их из MTU. Все ужасы из главы 1.1 живут именно здесь.

На целевом узле пакет попадает в veth-интерфейс пода, оттуда — в его сетевой namespace, ядро по номеру порта находит слушающий сокет, кладёт данные в буфер. Приложение делает read() и наконец получает HTTP-запрос — тот самый, который пользователь отправил секунду назад.

Стоит проговорить, что внутри пода никакого дополнительного проксирования нет — если только не используется service mesh. С sidecar-прокси (Istio, linkerd) добавляется ещё один шаг: iptables внутри пода заворачивают весь трафик в локальный Envoy, и уже он передаёт запрос приложению на localhost. Отсюда и накладные расходы mesh, и его возможности — mTLS, ретраи, детальные метрики.

§9Ответ идёт обратно

Обратный путь короче, потому что все решения уже приняты и записаны:

  • Приложение пишет ответ в тот же сокет — соединение уже установлено, ничего выбирать не надо.
  • На узле conntrack разворачивает трансляции обратно: адрес источника снова становится тем, к которому клиент подключался. Без conntrack ответ пришёл бы с адресом пода, и клиент бы его отбросил как чужой.
  • Ingress-контроллер добавляет свои заголовки, при необходимости сжимает тело, шифрует и отдаёт клиенту.
  • Соединение остаётся открытым (keep-alive), и следующий запрос браузера поедет по нему — уже без DNS, TCP и TLS. Первый запрос стоит дорого, остальные почти бесплатны.

Тут же живёт красивый дополнительный вопрос: «а почему после выкатки часть пользователей получает 502?» Ответ — из главы 3.1: под уже начал завершаться, а правила и пулы соединений о нём ещё не знают. Разница только в том, что теперь ты можешь показать, в каком именно месте цепочки это происходит.

§10Отладка по шагам

Главная практическая ценность всей цепочки — умение делить её пополам. Не «сайт не работает», а последовательная проверка: до какого места доходит.

ШагЧем проверитьСимптом провала
DNSdig shop.example.comПусто, NXDOMAIN или старый адрес
Доступность адресаcurl -v --resolve shop.example.com:443:203.0.113.10 https://shop.example.comПроверяет сервер в обход DNS — разделяет «сломан DNS» и «сломан сервер»
TCPnc -zv 203.0.113.10 443Refused — никто не слушает; таймаут — дропает файрвол
TLSopenssl s_client -connect ... -servername ...Истёк, не то имя, нет промежуточного сертификата
БалансировщикЕго логи и статус бэкендовВсе бэкенды помечены нездоровыми
Вход в кластерcurl -H "Host: shop.example.com" http://<node-ip>:30080Работает мимо балансировщика — значит, дело в нём
Ingresskubectl logs -n ingress-nginx ..., kubectl describe ing404 — не подошло правило; 503 — нет живых бэкендов
Servicekubectl get endpointsliceПусто — самая частая причина 503. Не совпал селектор или поды не Ready
Подkubectl exec -it другой-под -- curl 10.244.3.17:8080Проверка в обход всего: живо ли само приложение
Приложениеkubectl logs, kubectl describe podПадает, не слушает нужный порт, не проходит пробы

Спросят

«Пользователь получает 502, с чего начнёшь?» — Не перечисляй причины наугад, назови метод: «Делил бы пополам. 502 отдаёт прокси, значит, до прокси дошли — проблема правее. Смотрю логи ingress: если пишет "no live upstreams" — иду в endpoints сервиса, там чаще всего пусто из-за селектора или readiness. Если endpoints на месте — курлю под напрямую из соседнего пода: отвечает или нет. Если отвечает, а через ingress нет — дело в самом ingress или в том, что поды в этот момент завершаются и правила не успевают обновиться.» Метод ценится выше, чем список причин.

§11Как это рассказывать

Рассказ на пять минут, без воды. Ориентир — вот такой.

«Браузер разбирает URL и сначала смотрит свои кеши — HSTS может сразу переписать схему на https, а живое соединение к этому хосту вообще избавит от установки нового. Дальше нужен адрес: кеш браузера, кеш ОС, /etc/hosts, потом рекурсивный резолвер, который при промахе идёт от корневых серверов к зоне .com и к авторитативному серверу домена. Ответ кешируется по TTL на всех уровнях — поэтому DNS плохо подходит для аварийного переключения.

С полученным адресом устанавливается TCP — три пакета, попутно согласуются MSS и масштабирование окна. Потом TLS: в ClientHello открытым текстом едет SNI с именем хоста, по нему сервер понимает, какой сертификат предъявить, и там же в ALPN выбирается HTTP/2 или HTTP/1.1. Браузер проверяет цепочку до доверенного корня, совпадение имени и срок.

Адрес из DNS — это обычно внешний балансировщик: облачный, MetalLB или пара HAProxy с плавающим VIP. Он выбирает живой узел кластера по health check и передаёт соединение на NodePort. На узле kube-proxy делает DNAT на под ingress-контроллера и запоминает трансляцию в conntrack; если под роутера на другом узле, добавится ещё и SNAT — и вот из-за этого по умолчанию теряется IP клиента, что чинится либо externalTrafficPolicy: Local, либо proxy protocol.

Ingress-контроллер — первое место, где вообще читают HTTP. Он смотрит Host и путь, находит правило, применяет аннотации и отправляет запрос в бэкенд. Важно, что он балансирует именно запросы, а не соединения, и обращается напрямую к IP подов из endpoints, минуя ClusterIP. Дальше пакет идёт по сети подов — либо маршрутизацией, либо туннелем с уменьшенным MTU — попадает в veth пода, ядро находит сокет по порту, приложение читает запрос.

Обратно проще: conntrack разворачивает все трансляции, ответ уходит клиенту, соединение остаётся открытым, и следующий запрос идёт по нему без DNS, TCP и TLS. А когда что-то ломается, вся ценность этой цепочки в том, что её можно делить пополам: dig, curl --resolve, openssl s_client, логи ingress, endpoints сервиса, curl до пода напрямую.»