feat: retry 3x + warmup health check for upload (Phase 2), bump 1.0.5→1.0.6
Deploy loadtest / validate (push) Waiting to run
Deploy loadtest / validate (push) Waiting to run
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
# 2026-07-10 — Fix: base64 как файл (multipart file-part)
|
||||||
|
|
||||||
|
## План
|
||||||
|
Обойти `SecRequestBodyNoFilesLimit = 128KB` — отправлять base64 как файловую часть multipart (с filename), а не как поле формы.
|
||||||
|
|
||||||
|
Файловые части идут под `SecRequestBodyLimit = 12.5MB`.
|
||||||
|
|
||||||
|
## Изменения
|
||||||
|
1. `site/static/app.js` — `fd.append('data', ...)` → `fd.append('data', new Blob([b64]), name + '.b64')`
|
||||||
|
2. `site/app.py` — добавить приём base64 из `request.files`, совместить с существующим file-режимом
|
||||||
|
3. Bump версии 1.0.5 → 1.0.6
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# 2026-07-10 — ИТОГ РАССЛЕДОВАНИЯ: почему режется загрузка
|
||||||
|
|
||||||
|
## Методология
|
||||||
|
Тестирование от внутреннего к внешнему — послойная изоляция каждого компонента.
|
||||||
|
|
||||||
|
## Результаты тестов
|
||||||
|
|
||||||
|
| # | Тест | Маршрут | Размер | Результат |
|
||||||
|
|---|------|---------|--------|-----------|
|
||||||
|
| 1 | Python внутри пода → Flask | pod → localhost:5000 | 100KB base64 (136KB POST) | ✅ 102400 байт, 4ms |
|
||||||
|
| 2 | Python внутри пода → Ingress HTTPS | pod → 10.105.13.158:443 | 136KB POST | ✅ 102400 байт, 2ms |
|
||||||
|
| 3 | Python снаружи → Ingress | интернет → 185.247.187.151:443 | 136KB POST | ❌ **СЛУЧАЙНО** |
|
||||||
|
| 4 | 10×100KB снаружи | интернет → 185.247.187.151:443 | 136KB POST | ~50% OK, ~50% RemoteDisconnected |
|
||||||
|
|
||||||
|
## Бинарный поиск предела (снаружи)
|
||||||
|
|
||||||
|
| Размер POST | Результат |
|
||||||
|
|-------------|-----------|
|
||||||
|
| 1-48 KB | ✅ всегда |
|
||||||
|
| 50 KB | ❌ |
|
||||||
|
| 52 KB | ✅ |
|
||||||
|
| 55-80 KB | ❌ |
|
||||||
|
| 100 KB | ✅ (иногда!) |
|
||||||
|
|
||||||
|
**Твёрдого предела НЕТ.** Сбои случайные.
|
||||||
|
|
||||||
|
## Исключённые причины
|
||||||
|
|
||||||
|
| Компонент | Статус | Доказательство |
|
||||||
|
|-----------|--------|----------------|
|
||||||
|
| Flask dev-сервер | ✅ OK | Тест #1 |
|
||||||
|
| nginx ingress | ✅ OK | Тест #2 |
|
||||||
|
| ModSecurity (128KB NoFilesLimit) | ✅ OK | Тест #2, DetectionOnly |
|
||||||
|
| Gunicorn field_size | ❌ не используется | Shturval запускает `python app.py` |
|
||||||
|
|
||||||
|
## Где проблема
|
||||||
|
|
||||||
|
```
|
||||||
|
Клиент (интернет)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
??? Edge-инфраструктура хостера (NUBES LLC, AS212136) ???
|
||||||
|
│ ← здесь рвётся TCP-соединение для POST > ~48KB
|
||||||
|
▼
|
||||||
|
185.247.187.151:443 (hostPort на ноде)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
nginx ingress pod ─── ModSecurity ─── pythonk8s pod (Flask)
|
||||||
|
✅ всё работает
|
||||||
|
```
|
||||||
|
|
||||||
|
IP в логах ingress: `192.168.255.x` — приватный IP хостинг-провайдера.
|
||||||
|
На ноде порт 443 слушает kube-vip/hostPort напрямую (без proxy_protocol).
|
||||||
|
Внутри кластера всё идеально. Снаружи — случайные обрывы.
|
||||||
|
|
||||||
|
## Аналогичный опыт (contracts)
|
||||||
|
`/home/naeel/nubes/contracts/History/`:
|
||||||
|
- `connection-reset-analysis.md`: curl 100KB → HTTP 000 случайно (5 тестов: 2 OK, 3 обрыв)
|
||||||
|
- `sonnet-upload-from-scratch.md`: "64KB — жёсткий предел платформы" (43 версии)
|
||||||
|
- `session-08-upload-saga.md`: чанки 50KB — только первый доходит
|
||||||
|
|
||||||
|
## Ложные правки (эта сессия)
|
||||||
|
- Dockerfile `--limit-request-field_size 0` — бесполезно (Gunicorn не используется)
|
||||||
|
- Версия 1.0.4 → 1.0.5 — задеплоено, но проблемы не решает (причина вне кода)
|
||||||
|
|
||||||
|
## Компоненты инфраструктуры (найдены)
|
||||||
|
- Shturval v2.12.1
|
||||||
|
- kube-vip (DaemonSet) — VIP для LoadBalancer, IP 185.247.187.151
|
||||||
|
- nginx ingress через hostPort (порты 80/443 прямо на ноде)
|
||||||
|
- ModSecurity OWASP CRS 4.10.0, SecRuleEngine DetectionOnly
|
||||||
|
- `SecRequestBodyNoFilesLimit 131072` (128KB) — НЕ причина (внутри кластера работает)
|
||||||
|
|
||||||
|
## РЕШЕНИЕ
|
||||||
|
|
||||||
|
Contracts на этой же ВМ работает потому, что использует **родной nginx** с `proxy_request_buffering off`:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# /etc/nginx/sites-enabled/nginx-contracts.conf
|
||||||
|
location /upload {
|
||||||
|
proxy_pass http://127.0.0.1:8766;
|
||||||
|
client_max_body_size 100m;
|
||||||
|
proxy_request_buffering off; # ← вот почему работает
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Для loadtest добавлена аннотация в k8s ingress:
|
||||||
|
```
|
||||||
|
nginx.ingress.kubernetes.io/proxy-request-buffering: "off"
|
||||||
|
```
|
||||||
|
|
||||||
|
Конфиг применился (подтверждено в `/etc/nginx/nginx.conf` внутри ingress pod):
|
||||||
|
```
|
||||||
|
proxy_request_buffering off;
|
||||||
|
```
|
||||||
|
|
||||||
|
## Причина
|
||||||
|
Edge-инфраструктура хостера (NUBES LLC) рвёт TCP-соединения при буферизации больших POST-тел. `proxy_request_buffering off` стримит тело напрямую в бэкенд — edge не рвёт.
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# 2026-07-10 — План Соннета: HTTP/2 мультиплексирование
|
||||||
|
|
||||||
|
## Идея Соннета
|
||||||
|
Edge LB дропает ~50% НОВЫХ TCP-соединений. HTTP/2 открывает ОДНО TLS-соединение и мультиплексирует все запросы через него. Edge LB видит одно долгоживущее соединение → дропать нечего.
|
||||||
|
|
||||||
|
## Phase 1 — ❌ ПРОВАЛ (2026-07-10)
|
||||||
|
keep-alive 10→75 ухудшило: 5/10 → 2/10. Edge LB агрессивнее дропает долгие соединения.
|
||||||
|
Откачено.
|
||||||
|
|
||||||
|
## Phase 2 — в работе
|
||||||
|
Ретрай в app.js + warmup /health.
|
||||||
|
|
||||||
|
### Phase 3 — WebSocket (ядерный)
|
||||||
|
- flask-sock, endpoint `/upload-ws`
|
||||||
|
- Handshake GET (маленький), данные binary frames
|
||||||
|
- Edge LB никогда не видит большой POST на новом TCP
|
||||||
|
|
||||||
|
## Файлы
|
||||||
|
- nginx ingress ConfigMap
|
||||||
|
- app.js (`uploadOne`, `uploadAll`)
|
||||||
|
- app.py (версия)
|
||||||
|
- requirements.txt (только Phase 3)
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# 2026-07-10 — Полное расследование: почему режутся файлы при загрузке
|
||||||
|
|
||||||
|
## Симптом
|
||||||
|
Загрузка файлов > 64KB через веб-интерфейс loadtest обрывается. Даже чанки не проходят.
|
||||||
|
|
||||||
|
## Диагностика
|
||||||
|
|
||||||
|
### Тесты
|
||||||
|
| Тест | Результат |
|
||||||
|
|------|-----------|
|
||||||
|
| Изнутри пода (Flask, 100KB base64) | ✅ 102400 байт, 4ms |
|
||||||
|
| Снаружи (curl, 136KB URL-encoded POST) | ❌ 408 Timeout / Connection reset |
|
||||||
|
| Снаружи (Python urllib, 136KB POST) | ❌ RemoteDisconnected |
|
||||||
|
| Маленький POST (12 байт) | ✅ |
|
||||||
|
|
||||||
|
### Инфраструктура
|
||||||
|
```
|
||||||
|
Клиент → [прокси/LB] → nginx ingress (ModSecurity) → Flask dev-сервер (не Gunicorn!)
|
||||||
|
```
|
||||||
|
|
||||||
|
- Платформа: Shturval (managed Kubernetes)
|
||||||
|
- Сервис: `loadtest.pythonk8s.dev.nubes.ru`, IP `185.247.187.151` (NUBES LLC)
|
||||||
|
- Namespace: `9039a501-df2c-4b85-b049-9cd4f77e6ac0`
|
||||||
|
- Deployment: `pythonk8s`
|
||||||
|
- Запуск: `python app.py` (Flask dev-сервер, НЕ Gunicorn)
|
||||||
|
|
||||||
|
### ModSecurity в nginx ingress
|
||||||
|
```
|
||||||
|
SecRequestBodyLimit 13107200 # 12.5 MB — общий лимит
|
||||||
|
SecRequestBodyNoFilesLimit 131072 # 128 KB — лимит для НЕ-файловых полей
|
||||||
|
SecRuleEngine DetectionOnly # только логирует, не блокирует
|
||||||
|
```
|
||||||
|
Базовая строка 136KB отправляется как URL-encoded form → попадает под `NoFilesLimit`.
|
||||||
|
|
||||||
|
### Ingress-логи
|
||||||
|
- Маленькие POST (146 байт) → 200 OK
|
||||||
|
- Большие POST (49-65KB) → 408 Timeout через 10 секунд
|
||||||
|
- `ingress_request_length` показывает 49-65KB при отправленных 136KB → тело обрезается ДО ingress (прокси/LB)
|
||||||
|
|
||||||
|
## Предыдущий опыт (contracts)
|
||||||
|
Из `/home/naeel/nubes/contracts/History/`:
|
||||||
|
|
||||||
|
### `session-07-chunks.md` (v1.15-1.18)
|
||||||
|
- Чанки 50KB FormData → только первый доходит
|
||||||
|
- Чанки в PostgreSQL → то же самое
|
||||||
|
- 7 отдельных `psycopg2.connect` на сборку → медленно → таймаут Ingress
|
||||||
|
|
||||||
|
### `session-08-upload-saga.md` (v1.12-1.26)
|
||||||
|
- 26 версий до рабочей загрузки
|
||||||
|
- FormData/multipart → ❌
|
||||||
|
- base64 JSON → ❌
|
||||||
|
- v1.26: `original_bytes DROP NOT NULL` + `original_b64 TEXT` → ✓ РАБОТАЛО
|
||||||
|
- Потом регресс на v1.27+
|
||||||
|
|
||||||
|
### `upload-analysis-v40.md` (v1.40)
|
||||||
|
- `request.get_data(as_text=True)` + raw regex (без JSON, без b64decode, без БД) → всё равно HTTP 000
|
||||||
|
- Redis `rpush()` под подозрением
|
||||||
|
- curl: 100KB — 2/5 HTTP 000, 3/5 HTTP 200 (случайно)
|
||||||
|
|
||||||
|
### `connection-reset-analysis.md` (v1.21)
|
||||||
|
- HTTP 000 случайно, не зависит от размера
|
||||||
|
- Health check (GET) всегда 200
|
||||||
|
- Прямой /test (JSON → БД) всегда работает
|
||||||
|
|
||||||
|
### `sonnet-upload-from-scratch.md` — ИТОГ
|
||||||
|
> **"64KB — жёсткий предел платформы"**
|
||||||
|
> 43 версии. FormData, JSON/base64, чанки, Redis, retry — ничего не помогло.
|
||||||
|
|
||||||
|
## Корневая причина
|
||||||
|
Платформа pythonk8s (Shturval) имеет **непреодолимый предел ~64KB** на тело POST-запроса.
|
||||||
|
Это не баг приложения — это ограничение инфраструктуры:
|
||||||
|
- ModSecurity `SecRequestBodyNoFilesLimit` = 128KB
|
||||||
|
- Прокси/LB перед ingress обрезает тело ещё сильнее (до ~50-65KB)
|
||||||
|
- Ingress таймаутит через 10 секунд
|
||||||
|
|
||||||
|
Ни base64, ни JSON, ни чанки не обходят этот предел.
|
||||||
|
|
||||||
|
## Ложная правка
|
||||||
|
- `Dockerfile`: добавлены `--limit-request-field_size 0 --limit-request-body 0` — **бесполезно**, т.к. Gunicorn не используется
|
||||||
|
- `site/app.py`: версия 1.0.4 → 1.0.5 — задеплоено, но проблему не решает
|
||||||
|
|
||||||
|
## Что дальше
|
||||||
|
Для loadtest как инструмента замера скорости загрузки этот предел фатален.
|
||||||
|
Нужно решать на уровне платформы:
|
||||||
|
1. Увеличить `SecRequestBodyNoFilesLimit` в ModSecurity
|
||||||
|
2. Убрать/настроить прокси/LB перед ingress
|
||||||
|
3. Либо перенести сервис на другую платформу без этих ограничений
|
||||||
|
|
||||||
|
## Источники
|
||||||
|
- `/home/naeel/nubes/contracts/History/sessions/session-07-chunks.md`
|
||||||
|
- `/home/naeel/nubes/contracts/History/sessions/session-08-upload-saga.md`
|
||||||
|
- `/home/naeel/nubes/contracts/History/features/upload-analysis-v40.md`
|
||||||
|
- `/home/naeel/nubes/contracts/History/features/upload-analysis-v41.md`
|
||||||
|
- `/home/naeel/nubes/contracts/History/features/connection-reset-analysis.md`
|
||||||
|
- `/home/naeel/nubes/contracts/History/features/chunk-analysis-request.md`
|
||||||
|
- `/home/naeel/nubes/contracts/History/llm-analysis/sonnet-upload-from-scratch.md`
|
||||||
|
- `/home/naeel/nubes/contracts/History/llm-analysis/sonnet-appjs-analysis.md`
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Вопрос Sonnet: upload > 64KB падает на k8s-сервисе
|
||||||
|
|
||||||
|
## Суть
|
||||||
|
Сервис loadtest (Flask, замер скорости загрузки файлов) на Shturval k8s v2.12.1.
|
||||||
|
Файлы > ~48KB падают случайно при загрузке из интернета.
|
||||||
|
|
||||||
|
## Инфраструктура
|
||||||
|
```
|
||||||
|
Клиент (интернет)
|
||||||
|
→ Edge LB хостера (NUBES LLC, 185.247.187.151)
|
||||||
|
→ hostPort:443 на ноде k8s
|
||||||
|
→ nginx ingress pod (Shturval, ModSecurity OWASP CRS 4.10 DetectionOnly)
|
||||||
|
→ pythonk8s pod (Flask dev-сервер, python app.py)
|
||||||
|
```
|
||||||
|
|
||||||
|
Ingress аннотации:
|
||||||
|
- proxy-body-size: 1024m
|
||||||
|
- proxy-connect-timeout: 120
|
||||||
|
- proxy-read-timeout: 600
|
||||||
|
- proxy-send-timeout: 600
|
||||||
|
- proxy-request-buffering: off (добавили — помогло частично)
|
||||||
|
|
||||||
|
ModSecurity: SecRuleEngine DetectionOnly, SecRequestBodyNoFilesLimit 131072 (128KB)
|
||||||
|
|
||||||
|
## Тесты (100KB base64 → 136KB POST)
|
||||||
|
|
||||||
|
| Тест | Результат |
|
||||||
|
|------|-----------|
|
||||||
|
| Изнутри пода → Flask | ✅ 102400 байт, 4ms |
|
||||||
|
| Изнутри кластера → ingress HTTPS | ✅ 102400 байт, 2ms |
|
||||||
|
| Снаружи (новые TCP на каждый запрос) | ❌ ~50% Timeout |
|
||||||
|
| Снаружи (HTTP/1.1 keep-alive, одно соединение) | ✅ 8/10 (первые 2 таймаут, остальные OK) |
|
||||||
|
|
||||||
|
## Что исключено
|
||||||
|
- Flask — работает (тест изнутри)
|
||||||
|
- nginx ingress — работает (тест изнутри)
|
||||||
|
- ModSecurity — DetectionOnly, внутри работает
|
||||||
|
- Gunicorn — не используется (Shturval запускает python app.py)
|
||||||
|
- MTU — 1500, ping ок
|
||||||
|
- Размер тела — нет жёсткого предела (иногда 100KB проходит, иногда 50KB нет)
|
||||||
|
|
||||||
|
## Вывод
|
||||||
|
Edge LB хостера дропает ~50% НОВЫХ TCP-соединений. Установленное соединение (keep-alive) — стабильно.
|
||||||
|
|
||||||
|
## Что пробовали
|
||||||
|
1. Dockerfile: --limit-request-field_size 0 — бесполезно (Gunicorn не используется)
|
||||||
|
2. Ingress annotation: proxy-request-buffering off — улучшило с 3/10 до 5/10
|
||||||
|
3. HTTP/1.1 keep-alive — 8/10
|
||||||
|
|
||||||
|
## Ограничения
|
||||||
|
- Менять можно ТОЛЬКО внутри k8s (ingress, поды, деплойменты)
|
||||||
|
- Edge LB хостера — не наш, не контролируем
|
||||||
|
- Base64 должен остаться (требование)
|
||||||
|
|
||||||
|
## Вопрос
|
||||||
|
Как сделать загрузку 100% надёжной? Идеи по обходу проблемы с edge LB?
|
||||||
|
Может WebSocket? Может gRPC? Может HTTP/2 multiplexing? Может что-то на уровне ingress?
|
||||||
+1
-1
@@ -4,7 +4,7 @@ from flask import Flask, render_template, request
|
|||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
|
|
||||||
VERSION = '1.0.5'
|
VERSION = '1.0.6'
|
||||||
|
|
||||||
|
|
||||||
@app.route('/')
|
@app.route('/')
|
||||||
|
|||||||
+43
-1
@@ -130,6 +130,19 @@ function renderTable() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** warmup() — прогрев соединения перед загрузкой */
|
||||||
|
function warmup() {
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
xhr.open('GET', '/health');
|
||||||
|
xhr.timeout = 3000;
|
||||||
|
xhr.onload = function () { resolve(true); };
|
||||||
|
xhr.onerror = function () { resolve(false); };
|
||||||
|
xhr.ontimeout = function () { resolve(false); };
|
||||||
|
xhr.send();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** uploadAll() — последовательная загрузка всех файлов */
|
/** uploadAll() — последовательная загрузка всех файлов */
|
||||||
async function uploadAll() {
|
async function uploadAll() {
|
||||||
if (uploading || files.length === 0) return;
|
if (uploading || files.length === 0) return;
|
||||||
@@ -137,6 +150,9 @@ async function uploadAll() {
|
|||||||
uploadBtn.disabled = true;
|
uploadBtn.disabled = true;
|
||||||
clearBtn.disabled = true;
|
clearBtn.disabled = true;
|
||||||
|
|
||||||
|
// Прогрев соединения
|
||||||
|
await warmup();
|
||||||
|
|
||||||
var totalBytes = 0;
|
var totalBytes = 0;
|
||||||
var totalElapsed = 0;
|
var totalElapsed = 0;
|
||||||
var okCount = 0;
|
var okCount = 0;
|
||||||
@@ -151,7 +167,7 @@ async function uploadAll() {
|
|||||||
renderTable();
|
renderTable();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
var result = await uploadOne(f, i);
|
var result = await uploadOneWithRetry(f, i);
|
||||||
f.status = 'ok';
|
f.status = 'ok';
|
||||||
f.elapsed = result.elapsed_ms;
|
f.elapsed = result.elapsed_ms;
|
||||||
f.speed = result.elapsed_ms > 0 ? Math.round(result.bytes / (result.elapsed_ms / 1000)) : null;
|
f.speed = result.elapsed_ms > 0 ? Math.round(result.bytes / (result.elapsed_ms / 1000)) : null;
|
||||||
@@ -180,6 +196,32 @@ async function uploadAll() {
|
|||||||
fileSummary.textContent = summary;
|
fileSummary.textContent = summary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** uploadOneWithRetry(f, idx) — ретрай до 3 раз */
|
||||||
|
function uploadOneWithRetry(f, idx) {
|
||||||
|
var MAX_RETRIES = 3;
|
||||||
|
var RETRY_DELAY = 500; // ms
|
||||||
|
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
var attempt = 0;
|
||||||
|
|
||||||
|
function tryUpload() {
|
||||||
|
attempt++;
|
||||||
|
uploadOne(f, idx).then(resolve).catch(function (e) {
|
||||||
|
if (attempt < MAX_RETRIES) {
|
||||||
|
f.progress = 0;
|
||||||
|
f.status = 'uploading';
|
||||||
|
renderTable();
|
||||||
|
setTimeout(tryUpload, RETRY_DELAY);
|
||||||
|
} else {
|
||||||
|
reject(e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
tryUpload();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/** uploadOne(fileEntry, idx) — читает файл в base64, отправляет как поле формы */
|
/** uploadOne(fileEntry, idx) — читает файл в base64, отправляет как поле формы */
|
||||||
function uploadOne(f, idx) {
|
function uploadOne(f, idx) {
|
||||||
return new Promise(function (resolve, reject) {
|
return new Promise(function (resolve, reject) {
|
||||||
|
|||||||
Reference in New Issue
Block a user