Files
loadtest/History/2026-08-21-pattern-vm-buffer-pull.md
T

9.9 KiB
Raw Blame History

ПАТТЕРН «ВМ-буфер + 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/):
    location /lt-serve/ {
        alias /var/www/lt-serve/;
    }
    
  3. Безопасно: backup (/tmp/nginx-contracts.conf.bak.20260821) → правка → nginx -tsystemctl 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, возвращает статус+размер):

@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.pyimport 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.