195 lines
14 KiB
Markdown
195 lines
14 KiB
Markdown
# ПАТТЕРН «ВМ-буфер + pull» — загрузка больших файлов на managed-сервисы Nubes
|
||
|
||
_Дата: 2026-08-21. Проверено на LoadTest (`http-12.containerk8s.dev.nubes.ru`), образ `naeel/loadtest:v2.0.2`._
|
||
_Назначение: руководство для переделки логики загрузки в ДРУГИХ сервисах, где большой файл
|
||
не проходит через managed-кластер._
|
||
|
||
---
|
||
## 0. ПОЧЕМУ через ВМ — хронология попыток через кластер (все провалились)
|
||
|
||
Что пробовали, чтобы большой файл прошёл ВХОД на managed-кластер, — и итог:
|
||
|
||
| Попытка | Результат | Итог |
|
||
|---|---|---|
|
||
| Прямой `POST /upload` (multipart) | тела >~64КБ → HTTP 000 ~10с | блокер входа |
|
||
| Аннотации ingress (`proxy-body-size`, таймауты, `request-buffering`, `client-body-*`) | не влияют | бесполезно (обрыв на внешнем шлюзе) |
|
||
| `Transfer-Encoding: chunked` (чанки 32КБ) | обрыв ~10-15с при любом размере | бесполезно |
|
||
| Чанки на клиенте (POST по 50КБ, в т.ч. в БД) | только первый чанк доходит | бесполезно |
|
||
| ConfigMap ingress (`client-body-timeout 60`, `proxy-body-size 1024m`) + replicas=число нод | РАБОТАЕТ, но ТОЛЬКО на своём кластере (kubectl) | костыль; на чужом кластере нельзя |
|
||
| **Egress (Flask сам тянет с ВМ)** | 1-50МБ стабильно 200 | ✅ единственный универсальный путь |
|
||
|
||
**Вывод:** вход в managed-кластер физически ограничен (~64КБ / ~10с) и не настраивается через
|
||
UI/аннотации. Выход (egress) — НЕ ограничен. Поэтому: файлы входят через ВМ, а Flask тянет их
|
||
сам (pull). Детали неудач: `History/2026-08-21-annotation-tests.md`,
|
||
`History/2026-08-21-root-cause-found.md`.
|
||
|
||
---
|
||
## 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.0 Приём файла на ВМ (первая половина схемы)
|
||
|
||
1. Браузер шлёт файл на ВМ напрямую (минуя managed). На ВМ нужен приёмный endpoint.
|
||
На ВМ уже работает `convert_server.py` (:8766) — добавить туда endpoint, либо отдельный
|
||
маленький сервис. Пример (Flask на ВМ):
|
||
|
||
```python
|
||
# ВМ: приём и временное хранение
|
||
@app.route("/upload-lt", methods=["POST"])
|
||
def upload_lt():
|
||
f = request.files["file"]
|
||
fid = uuid4().hex
|
||
path = f"/var/www/lt-serve/{fid}"
|
||
f.save(path)
|
||
return jsonify({
|
||
"ok": True, "id": fid, "size": os.path.getsize(path),
|
||
"url": f"https://contracts.kube5s.ru/lt-serve/{fid}",
|
||
})
|
||
```
|
||
|
||
2. nginx на ВМ: для этого пути `client_max_body_size 100m` (или больше) — БЕЗ лимита 64КБ.
|
||
|
||
3. После сохранения ВМ сообщает Flask: **маленький POST (<64КБ — проходит шлюз)** с метаданными:
|
||
```json
|
||
{ "id": "...", "url": "https://contracts.kube5s.ru/lt-serve/<id>", "name": "файл.pdf", "size": 47729215 }
|
||
```
|
||
|
||
4. Flask получает метаданные и САМ тянет файл по `url` (egress GET, см. `/fetch` в 3.2).
|
||
|
||
### 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`).
|
||
|
||
### 3.4 Полный сквозной сценарий (5 шагов)
|
||
|
||
1. **Браузер** → `POST https://contracts.kube5s.ru/upload-lt` (nginx ВМ), файл 45МБ.
|
||
2. **ВМ** сохраняет в `/var/www/lt-serve/<id>`, отвечает `{id, size, url}`. Без лимита 64КБ.
|
||
3. **ВМ** (или клиент) → маленький `POST` на managed `.../upload-meta` `{url, name, size}` — проходит шлюз.
|
||
4. **Flask** по метаданным делает исходящий `GET url` (egress) и читает файл.
|
||
5. Flask обрабатывает (парсинг/БД/статусы). ВМ удаляет файл после загрузки или по TTL.
|
||
|
||
---
|
||
|
||
## 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`.
|