Files
drhider/History/2026-07-14-browser-rst-debug.md
T
2026-07-14 20:40:26 +04:00

12 KiB
Raw Blame History

Диагностика ERR_CONNECTION_RESET в браузере

Дата: 2026-07-14


Хронология

Фаза 1: MTU (решено)

Проблема: 63KB POST из браузера → ERR_CONNECTION_RESET. Причина: MTU 1500 + Geneve 50 = 1550 > underlay 1450. Решение: Cilium MTU = 1400. Проверено curl с ВМ (30/30).

Фаза 2: HTTP/2

HTTP/2 → ERR_HTTP2_PROTOCOL_ERROR. HTTP/1.1 → ERR_CONNECTION_RESET. Решение: use-http2: "false".

Фаза 3: Поиск корневой причины RST

Баг: браузер → свежая страница → POST 63KB → ERR_CONNECTION_RESET.

Полная матрица тестов

# Сценарий Результат
1 Браузер: свежая страница → POST 63KB RST / ⏳ 100% hang
2 Браузер: свежая страница → GET (до Flask) → POST 63KB 200 OK
3 Браузер: свежая страница → POST 10B → POST 63KB 200 OK
4 Браузер: свежая страница → GET (nginx сам, 405) → POST 63KB RST
5 curl с ВМ (5.172.178.213) → POST 63KB всегда <100ms
6 Сразу после kubectl rollout restart ingress → браузер работает
7 Через 5-10 мин после рестарта → браузер снова RST

Что исключено

Гипотеза Статус Почему
MTU исключено 1400, curl с ВМ всегда работает
HTTP/2 исключено use-http2: false, та же проблема
XHR vs fetch исключено оба метода падают одинаково
proxy_request_buffering: off исключено on для location /
client-body-timeout: 10 исключено 63KB < 1 сек
FD/worker-лимиты исключено 1M FD, 16K connections, 62MB mem
OOM/Restart исключено 0 рестартов, нет OOMKilled
Stale upstream keepalive исключено keepalive=0 не помогло
nginx→Flask (upstream) исключено curl с ВМ доказывает что работает

Эксперименты

Перезапуск ingress временно чинит

После kubectl rollout restart — браузер работает. Через 5-10 мин — снова RST. Накопление состояния.

Ресурсы в норме

worker_rlimit_nofile: 1047552 (1M FD)
worker_connections:    16384
worker_processes:      4
Memory:                62 MiB, CPU: 3-7m
Restarts: 0, OOMKilled: нет

upstream-keepalive-connections: 0 — НЕ фикс

Баг вернулся через 5-10 минут. Keepalive pool не при чём.

Критическое противоречие

  • curl с ВМ (тот же датацентр) → POST 63KB → всегда 200 OK <100ms
  • Браузер через 5-10 мин после рестарта ingress → ERR_CONNECTION_RESET

curl и браузер идут через один VIP (185.247.187.151), на одни ingress-поды. Разница: клиент (curl vs Chrome) и сетевой путь (локальная сеть ДЦ vs интернет/VPN).

Текущая конфигурация (2026-07-14)

Параметр Значение
use-http2 "false"
upstream-keepalive-connections "0"
error-log-level debug
proxy-body-size 1024m
client-body-timeout "10"
keep-alive "10"
Cilium MTU 1400
externalTrafficPolicy Cluster
Ingress controller 1.12.6 (Штурвал)

Вопрос к Claude (Sonnet/Opus)

Симптом

Браузер (Chrome/Electron, Windows) → POST 63KB multipart/form-data → ERR_CONNECTION_RESET или зависание на ⏳ 100%. Тело уходит полностью, ответ не приходит — TCP RST.

Окружение

Кластер:       bare-metal K8s 1.34.1, 4 ноды (3 worker + 1 control-plane)
CNI:           Cilium Geneve, MTU подов = 1400
VIP:           kube-vip 185.247.187.151 (ARP на все 4 ноды)
Ingress:       nginx-ingress (Штурвал Helm), контроллер 1.12.6
               2 реплики, externalTrafficPolicy: Cluster
Приложение:    Flask/Waitress, 1 реплика (Штурвал managed)

Конфиг ingress:

use-http2: "false"
upstream-keepalive-connections: "0"
client-body-timeout: "10"
keep-alive: "10"
worker-processes: 4
worker-rlimit-nofile: 1047552
worker-connections: 16384
proxy-body-size: 1024m
proxy-request-buffering: on   # для location /
error-log-level: debug

Критическое противоречие

  • curl с ВМ (5.172.178.213, тот же датацентр) → POST 63KB → всегда 200 OK <100ms
  • Браузер через 5-10 мин после рестарта ingress → ERR_CONNECTION_RESET
  • Сразу после рестарта ingress → браузер работает

curl и браузер идут через один VIP (185.247.187.151), на одни ingress-поды. Разница: клиент (curl vs Chrome) и сетевой путь до VIP (локальная сеть ДЦ vs интернет/VPN).

Вопросы

  1. Почему рестарт ingress помогает на 5-10 минут, а потом браузерные запросы снова получают RST, при том что curl с ВМ продолжает работать?

  2. Может ли externalTrafficPolicy: Cluster + kube-vip создавать conntrack-записи, которые со временем ломают соединения из интернета (с другими TCP options/MSS) но не из локальной сети?

  3. Стоит ли попробовать externalTrafficPolicy: Local? Какие риски?

  4. Какие ещё эксперименты можно провести без tcpdump (нет доступа к нодам по SSH, нет tcpdump в подах)?


TODO

  • Ответ от Claude
  • externalTrafficPolicy: Local — эксперимент
  • Воспроизвести с curl из WSL на машине пользователя
  • tcpdump через kubectl debug (требует координации)
  • Обновить ingress controller 1.12.6 → актуальный
  • Проверить client-body-timeout: 60 + keep-alive: 75

Ответ Claude (Sonnet) — 2026-07-14

Два независимых механизма

Симптом объясняется наложением двух механизмов:

Механизм 1: kube-vip ARP flapping + conntrack asymmetry (основной)

Именно это объясняет 5-10-минутное окно и разницу между browser и curl.

Как работает с externalTrafficPolicy: Cluster:

Browser → VIP → Node N1 (kube-vip leader) → iptables DNAT+SNAT → nginx pod
                     │
                     └── conntrack entry создаётся на N1:
                         (browser_ip:srcport → 185.247.187.151:443)
                          ↔ (N1_ip:eph → nginx_pod:443)

Проблема возникает когда kube-vip меняет лидера:

kube-vip: N1 → N2 (истёк lease или GARP от N2)
Коммутатор: обновляет ARP-кэш, VIP теперь на N2

Browser (старое HTTP keep-alive соединение):
  → PSH/ACK 63KB → попадает на N2
  N2: нет conntrack записи → TCP RST

curl (новое соединение):
  → SYN → попадает на N2
  N2: создаёт новую conntrack → всё ОК

Почему 5-10 минут: время жизни ARP-записи на коммутаторе или интервал переизбрания kube-vip лидера. "ARP на все 4 ноды" — периодические GARP-анонсы могут уходить с разных нод.

Почему GET-to-Flask чинит последующий POST (тесты #2 vs #4):

  • GET → Flask: создаётся новое TCP-соединение (новый SYN) → правильный conntrack на текущей ноде. POST идёт по тому же соединению → conntrack жив → OK.
  • GET → 405: nginx отвечает сам и закрывает соединение с Connection: close. POST создаёт новое соединение, но VIP уже на другой ноде → RST.

Механизм 2: keepalive_timeout: 10s — слишком агрессивно

keep-alive: "10" в ConfigMap = keepalive_timeout 10s в nginx.

T=0:  Browser загружает страницу (GET /)
T=10: nginx закрывает idle соединение (отправляет FIN)
T=?:  Пользователь заполняет форму (>10 сек → почти всегда)
T=X:  Browser отправляет POST по "живому" с его точки зрения соединению
      → nginx уже закрыл socket → RST

Браузер не всегда замечает FIN немедленно (TCP буферизация ОС). curl — каждый раз новое соединение.

Почему curl всегда работает

Browser curl
Соединение HTTP keep-alive, долгоживущее Новое для каждого запроса
conntrack Создано при SYN, может устареть Новый SYN на каждый вызов
keepalive Держит idle >10s Сразу POST → не ждёт
VIP flap Пакет идёт на новую ноду без conntrack SYN на новой ноде создаёт conntrack

Ключевой инсайт

Разница не в TCP-опциях (MSS, window size). Разница в типе пакета: curl всегда начинает с SYN → conntrack создаётся на правильной ноде. Browser держит соединение между запросами → когда VIP мигрирует, браузерные пакеты (ACK/PSH) приходят на новую ноду без предшествующего SYN → нет conntrack-записи → RST.

Итоговая таблица причин

Причина Механизм Почему не curl
kube-vip ARP lease renewal Смена ноды → conntrack orphan Новый SYN всегда
keepalive_timeout: 10s nginx закрывает idle conn → browser не замечает Нет idle

Оба фактора вместе: рестарт сбрасывает состояние, 5-10 минут → kube-vip переизбирается / браузер держит соединение дольше 10s → RST.

Рекомендации

  1. keep-alive: "75" — исключить механизм #2 (самое простое, безопасное)
  2. externalTrafficPolicy: Local — исключить механизм #1 (риск: потеря source IP для других сервисов)
  3. JS: Connection warmer — GET на Flask при загрузке страницы (уже доказано тестом #2)
  4. JS: пересоздавать соединение — использовать fetch() без keep-alive (credentials: 'omit' или отдельный subdomain)