fix: JS warmup GET /health на загрузке + keep-alive 75 + bump v0.0.26
Deploy drhider / validate (push) Waiting to run
Deploy drhider / validate (push) Waiting to run
This commit is contained in:
@@ -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)
|
||||
Reference in New Issue
Block a user