# Диагностика 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: ```yaml 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)