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