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

14 KiB
Raw Blame History

ПАТТЕРН «ВМ-буфер + 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 на ВМ):

    # ВМ: приём и временное хранение
    @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КБ — проходит шлюз) с метаданными:

    { "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/):
    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).

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.