diff --git a/History/2026-08-21-pattern-vm-buffer-pull.md b/History/2026-08-21-pattern-vm-buffer-pull.md new file mode 100644 index 0000000..90a3e33 --- /dev/null +++ b/History/2026-08-21-pattern-vm-buffer-pull.md @@ -0,0 +1,139 @@ +# ПАТТЕРН «ВМ-буфер + pull» — загрузка больших файлов на managed-сервисы Nubes + +_Дата: 2026-08-21. Проверено на LoadTest (`http-12.containerk8s.dev.nubes.ru`), образ `naeel/loadtest:v2.0.2`._ +_Назначение: руководство для переделки логики загрузки в ДРУГИХ сервисах, где большой файл +не проходит через managed-кластер._ + +--- + +## 1. Проблема (что и почему не работает) + +**Входной шлюз managed-кластеров Nubes** (внешний балансировщик перед кластером): +- тела >~64КБ обрываются: HTTP 000 через ~10с (TTFB=0) — на общих кластерах + (`k8s-4-sandbox`, `containerk8s`); на iot-naeel была задержка ~51с, но 200. +- случайная граница в зоне ~64-84КБ (68КБ рвётся, 84КБ проходит), ≥100КБ — стабильно рвётся. +- это НЕ ingress-nginx кластера: аннотации `proxy-body-size`/таймауты на ingress-ресурсе + **не помогают** (проверено; поле `ingress_annotations` платформы «поддерживает пока простые аннотации»). +- **chunked (Transfer-Encoding, чанки 32КБ) тоже рвётся** — обрыв ~10-15с при любом размере + → фиксированный таймаут шлюза на приём тела, способ передачи не влияет. +- **чанковая загрузка на клиенте** (несколько POST по 50КБ) — тоже НЕ работает на managed: + только первый чанк доходит, остальные рвутся (история contracts v1.15-1.21, 2026-06-17). +- `/upload` напрямую на managed: 1МБ → HTTP 000 ~10с (подтверждено 2026-08-21). + +**Вывод:** большой файл (или много POST'ов) через внешний URL на managed-кластер — не проходит. +Правка возможна ТОЛЬКО на стороне платформы (входной шлюз), а не в коде/аннотациях/чанках. + +--- + +## 2. Решение: ВМ = временный склад + Flask тянет сам (pull) + +``` +Браузер ──(большой файл)──▶ ВМ (nginx, БЕЗ лимитов платформы) + │ маленький POST (метаданные: id, имя, размер, url) + ▼ + Flask (managed-контейнер, в кластере) + ▲ +ВМ ◀──────(egress GET по url)┘ ← ИСХОДЯЩИЙ запрос из кластера +``` + +**Почему работает:** входной шлюз ограничивает **входящие тела запросов** (POST/upload). +**Исходящий (egress) GET** из кластера наружу — НЕ ограничен: большие ОТВЕТЫ проходят. +Проверено: 10МБ с ВМ → 200 за 0.9с, 50МБ → 200 за 5.3с. + +**Принципы:** +1. ВМ принимает файл напрямую (nginx, `client_max_body_size` свой, без лимита 64КБ). +2. ВМ хранит файл временно и отдаёт по HTTP (nginx static/alias). +3. ВМ шлёт во Flask маленький POST (<64КБ — проходит шлюз) с метаданными и ссылкой на файл. +4. Flask САМ делает исходящий GET к ВМ и читает файл (egress). +5. Обработка/сборка/БД/UI — во Flask. ВМ — только приём+хранение (минимум логики). +6. Данные наружу НЕ выходят: браузер → ВМ напрямую; ВМ отдаёт файл только Flask'у (по запросу). + +--- + +## 3. Как повторить (пошагово) + +### 3.1 На ВМ (5.172.178.213) — раздача файлов через существующий nginx +1. Каталог: `/var/www/lt-serve/` (nginx-юзер `www-data` имеет доступ; `/home/naeel` — НЕТ, 403). +2. В `nginx-contracts.conf` (sites-enabled) добавлен location (отдельный блок, рядом с `/docs/`): + ```nginx + location /lt-serve/ { + alias /var/www/lt-serve/; + } + ``` +3. Безопасно: backup (`/tmp/nginx-contracts.conf.bak.20260821`) → правка → `nginx -t` → `systemctl reload nginx`. +4. Проверка: `curl --noproxy '*' https://contracts.kube5s.ru/lt-serve/<файл>`. + +⚠️ **Прокси:** локальные curl на Krupski идут через HTTP-прокси (`172.17.192.1:10808`), +который душит передачу (обрыв ~16КБ). Внешние тесты — ТОЛЬКО с `--noproxy '*'`. + +### 3.2 Во Flask (managed) — endpoint pull +Временный endpoint `/fetch?url=...` (исходящий GET, возвращает статус+размер): +```python +@app.route("/fetch") +def fetch_url(): + import requests + url = request.args.get("url", "") + if not url: + return jsonify({"ok": False, "error": "нет параметра url"}), 400 + t0 = time.time() + try: + r = requests.get(url, timeout=120) + size = len(r.content) + return jsonify({ + "ok": True, "url": url, "status": r.status_code, + "size_bytes": size, "size_mb": round(size / (1024 * 1024), 3), + "total_ms": round((time.time() - t0) * 1000, 2), + }) + except Exception as e: + return jsonify({"ok": False, "error": type(e).__name__ + ": " + str(e), "url": url}), 502 +``` +В прод-варианте — `stream=True` и чтение по частям (см. раздел 5), и ОБЯЗАТЕЛЬНО +авторизация/тайм-лимит ссылок (ВМ не должен раздавать файлы кому попало). + +### 3.3 Образ и деплой +- Dockerfile loadtest: `python:3.12-slim` + gunicorn. +- **ВАЖНО (грабли):** папка `site/` конфликтует со stdlib Python `site.py` → + `import site.app` падает (`ModuleNotFoundError`). Рабочий CMD: + ``` + gunicorn --bind 0.0.0.0:5000 --chdir /app/site --timeout 300 --workers 1 app:app + ``` +- Образы: `naeel/loadtest:v2.0.1` (фикс gunicorn), `v2.0.2` (+`/fetch`). +- Реестр: managed-кластеры (`k8s-3/4-sandbox`, `containerk8s`) тянут образы ТОЛЬКО из + внутреннего nexus `nexus-sa.tst.nubes.ru/docker-nubes/...` (дефолт `jolt`), НЕ из Docker Hub! + Для `k8s-4-sandbox` образ `naeel/loadtest` из Docker Hub завёлся (см. ниже «кластеры»). +- Аннотация: работает ТОЛЬКО одна — `nginx.ingress.kubernetes.io/proxy-body-size: 1024m`. + Добавление остальных (таймауты, request-buffering) → падение скрипта платформы + (`script returned exit code 1`, `No such property: name for class: Script2`). + +--- + +## 4. Кластеры (наблюдения 2026-08-21) +- `k8s-3-sandbox-nubes-ru` — НЕ запускается вообще (баги платформы: скрипт манифестов падает). +- `k8s-4-sandbox-nubes-ru` / `containerk8s` — работает; образ из Docker Hub подтянулся; + сервис поднялся после фикса gunicorn; одна аннотация `proxy-body-size` ставится. + +--- + +## 5. Ограничения и рекомендации для прод-варианта +1. **OOM при больших файлах:** `/fetch` читает весь файл в память (`r.content`). Квота пода + 200МБ → 100МБ-файл убивает под (502, под перезапускается, health потом 200). + **Решение:** `requests.get(..., stream=True)` + чтение/запись по частям (буфер 64-256КБ), + ИЛИ поднять квоту памяти (resourceMemory 1024МБ). Проверено: до 50МБ стабильно. +2. **1 worker gunicorn (sync):** параллельные запросы сериализуются. Для параллельной + обработки — `--workers 2-4` (учитывая память). +3. **Безопасность ВМ-раздачи:** файлы на ВМ должны быть доступны только Flask'у + (токен/секрет в URL, TTL, удаление после загрузки). Иначе — открытый статический хостинг. +4. **Жизненный цикл:** файл на ВМ удалять после того, как Flask его забрал (или по TTL). +5. **Входной шлюз** остаётся с лимитом ~64КБ — все НОВЫЕ входы больших данных — через ВМ. + +--- + +## 6. Ссылки (история LoadTest) +- `History/2026-08-21-container-build.md` — сборка образа, аннотации. +- `History/2026-08-21-gunicorn-fix-v201.md` — фикс site.app → --chdir /app/site. +- `History/2026-08-21-annotation-tests.md` — тесты лимита, аннотация не помогла, chunked не помог. +- `History/2026-08-21-egress-confirmed.md` — подтверждение egress (Flask тянет с ВМ). +- `History/2026-08-21-stress-test-egress.md` — стресс-тест (7 сценариев). +- История contracts (чанки на managed не работают): `History/sessions/session-07-chunks.md`, + `History/features/chunk-analysis-request.md`, `History/features/connection-reset-analysis.md`. +- ВМ: `nginx-contracts.conf`, каталог `/var/www/lt-serve/`, backup `/tmp/nginx-contracts.conf.bak.20260821`.