12 KiB
Диагностика 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).
Вопросы
-
Почему рестарт ingress помогает на 5-10 минут, а потом браузерные запросы снова получают RST, при том что curl с ВМ продолжает работать?
-
Может ли
externalTrafficPolicy: Cluster+ kube-vip создавать conntrack-записи, которые со временем ломают соединения из интернета (с другими TCP options/MSS) но не из локальной сети? -
Стоит ли попробовать
externalTrafficPolicy: Local? Какие риски? -
Какие ещё эксперименты можно провести без 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.
Рекомендации
keep-alive: "75"— исключить механизм #2 (самое простое, безопасное)externalTrafficPolicy: Local— исключить механизм #1 (риск: потеря source IP для других сервисов)- JS: Connection warmer — GET на Flask при загрузке страницы (уже доказано тестом #2)
- JS: пересоздавать соединение — использовать
fetch()без keep-alive (credentials: 'omit' или отдельный subdomain)