diff --git a/History/2026-07-10-fix-multipart-file.md b/History/2026-07-10-fix-multipart-file.md new file mode 100644 index 0000000..6e39013 --- /dev/null +++ b/History/2026-07-10-fix-multipart-file.md @@ -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 diff --git a/History/2026-07-10-root-cause-analysis.md b/History/2026-07-10-root-cause-analysis.md new file mode 100644 index 0000000..07df3b9 --- /dev/null +++ b/History/2026-07-10-root-cause-analysis.md @@ -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 не рвёт. diff --git a/History/2026-07-10-sonnet-plan.md b/History/2026-07-10-sonnet-plan.md new file mode 100644 index 0000000..7c0c9db --- /dev/null +++ b/History/2026-07-10-sonnet-plan.md @@ -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) diff --git a/History/2026-07-10-upload-investigation.md b/History/2026-07-10-upload-investigation.md new file mode 100644 index 0000000..8f01d1c --- /dev/null +++ b/History/2026-07-10-upload-investigation.md @@ -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` diff --git a/History/sonnet-question-upload.md b/History/sonnet-question-upload.md new file mode 100644 index 0000000..834a84d --- /dev/null +++ b/History/sonnet-question-upload.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? diff --git a/site/app.py b/site/app.py index dfff25d..9d4150d 100644 --- a/site/app.py +++ b/site/app.py @@ -4,7 +4,7 @@ from flask import Flask, render_template, request app = Flask(__name__) -VERSION = '1.0.5' +VERSION = '1.0.6' @app.route('/') diff --git a/site/static/app.js b/site/static/app.js index af5fa0c..d5e97c9 100644 --- a/site/static/app.js +++ b/site/static/app.js @@ -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() — последовательная загрузка всех файлов */ async function uploadAll() { if (uploading || files.length === 0) return; @@ -137,6 +150,9 @@ async function uploadAll() { uploadBtn.disabled = true; clearBtn.disabled = true; + // Прогрев соединения + await warmup(); + var totalBytes = 0; var totalElapsed = 0; var okCount = 0; @@ -151,7 +167,7 @@ async function uploadAll() { renderTable(); try { - var result = await uploadOne(f, i); + var result = await uploadOneWithRetry(f, i); f.status = 'ok'; f.elapsed = result.elapsed_ms; 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; } +/** 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, отправляет как поле формы */ function uploadOne(f, idx) { return new Promise(function (resolve, reject) {