diff --git a/History/2026-07-14-browser-rst-debug.md b/History/2026-07-14-browser-rst-debug.md new file mode 100644 index 0000000..dc8dded --- /dev/null +++ b/History/2026-07-14-browser-rst-debug.md @@ -0,0 +1,237 @@ +# Диагностика 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) diff --git a/site/app.py b/site/app.py index a73e9dc..7fa7b44 100644 --- a/site/app.py +++ b/site/app.py @@ -20,7 +20,7 @@ if _sys_path_root not in sys.path: sys.path.insert(0, _sys_path_root) # Версия приложения (меняется при изменениях) -VERSION = "0.0.25" +VERSION = "0.0.26" def create_app(): diff --git a/site/templates/index.html b/site/templates/index.html index d15b370..544cfe1 100644 --- a/site/templates/index.html +++ b/site/templates/index.html @@ -169,7 +169,7 @@ function rr() { function rm(i) { sf.splice(i, 1); const d = new DataTransfer(); sf.forEach(f => d.items.add(f)); fi.files = d.files; rr(); } -window.addEventListener('load', () => { sf = []; fi.value = ''; rr(); }); +window.addEventListener('load', () => { sf = []; fi.value = ''; rr(); /* прогрев upstream-соединения */ fetch('/health').catch(() => {}); }); fi.addEventListener('change', () => { sf = Array.from(fi.files); rr(); }); function ss(idx, h) { const e = document.getElementById('st-' + idx); if (e) e.innerHTML = h; }