Files
loadtest/History/plan-2026-08-23-vm-upload-pull.md
T

12 KiB
Raw Blame History

НЕ ИСПОЛЬЗОВАТЬ !!! СОЗДАНО ОШИБОЧНО !!!

ПЛАН: загрузка больших файлов через ВМ (pull) — реализация во Flask

Дата: 2026-08-23. Цель: переделать /upload так, чтобы большой файл входил через ВМ, а Flask сам тянул его (egress). Язык: Python/Flask. Основа: site/app.py (текущий), паттерн History/2026-08-21-pattern-vm-buffer-pull.md.


0. Что меняем (кратко)

Сейчас /upload принимает multipart напрямую на managed → тело >64КБ рвётся на входном шлюзе. Меняем на 3 хопа:

Браузер ──POST файл──▶ ВМ /upload-lt (nginx, client_max_body_size 100m)  → {id, size, url}
Браузер ──POST метаданные (<64КБ)──▶ Flask /upload-vm (managed)
Flask ──GET url (egress, stream)──▶ ВМ /lt-serve/<id>   → читает файл по частям

Список правок:

# Где Что
1 ВМ: nginx location /upload-lt/ + client_max_body_size 100m; location /lt-serve/ уже есть
2 ВМ: Flask-приём (convert_server.py или отдельный) endpoint /upload-lt (сохранить, вернуть url) + /lt-serve/<id> DELETE
3 managed Flask app.py новый /upload-vm (метаданные + egress pull со stream) + /config (отдать браузеру URL ВМ)
4 templates/index.html загрузка в 2 хопа вместо /upload
5 requirements.txt requests уже есть — ничего нового

1. ВМ: nginx

В nginx-contracts.conf (sites-enabled) — два блока.

# приём файла (без лимита 64КБ), проброс на локальный Flask ВМ (:8766)
location /upload-lt {
    proxy_pass http://127.0.0.1:8766;
    client_max_body_size 100m;
    proxy_request_buffering off;   # не буферизовать тело на nginx
    proxy_read_timeout 300s;
    proxy_send_timeout 300s;
}

# раздача временного файла (уже добавлен 2026-08-21)
location /lt-serve/ {
    alias /var/www/lt-serve/;
}

CORS для браузера (кросс-домен: браузер на managed, POST на ВМ) — в блоке /upload-lt:

add_header Access-Control-Allow-Origin  "https://http-12.containerk8s.dev.nubes.ru" always;
add_header Access-Control-Allow-Methods "POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Content-Type" always;
if ($request_method = OPTIONS) { return 204; }

Примечание: Access-Control-Allow-Origin — подставить реальный origin managed-сервиса; для диагностики можно *.

Порядок: cp nginx-contracts.conf /tmp/nginx-contracts.conf.bak.$(date +%Y%m%d) → правка → nginx -tsystemctl reload nginx.


2. ВМ: приём файла (Flask на ВМ, порт :8766)

Добавить в convert_server.py (или отдельный upload_server.py):

import os, uuid, hmac, hashlib, time
from flask import Flask, request, jsonify

VM_TOKEN = os.environ.get("VM_TOKEN", "CHANGE_ME")   # общий секрет с managed-Flask
SERVE_DIR = "/var/www/lt-serve"                       # nginx-юзер www-data имеет доступ

def _sign(fid: str) -> str:
    return hmac.new(VM_TOKEN.encode(), fid.encode(), hashlib.sha256).hexdigest()

@app.route("/upload-lt", methods=["POST"])
def upload_lt():
    if "file" not in request.files:
        return jsonify({"ok": False, "error": "нет поля file"}), 400
    f = request.files["file"]
    if not f or f.filename == "":
        return jsonify({"ok": False, "error": "пустое имя"}), 400

    fid = uuid.uuid4().hex
    path = os.path.join(SERVE_DIR, fid)
    f.save(path)                                     # потоковая запись на диск
    size = os.path.getsize(path)
    url = f"https://contracts.kube5s.ru/lt-serve/{fid}?t={_sign(fid)}"
    return jsonify({"ok": True, "id": fid, "size": size, "url": url})

@app.route("/lt-serve/<fid>", methods=["DELETE"])
def delete_lt(fid):
    token = request.args.get("t", "")
    if not hmac.compare_digest(token, _sign(fid)):
        return jsonify({"ok": False, "error": "bad token"}), 403
    path = os.path.join(SERVE_DIR, fid)
    if os.path.exists(path):
        os.remove(path)
    return jsonify({"ok": True})

Каталог SERVE_DIR должен существовать и быть writable для пользователя Flask-ВМ (www-data).


3. Managed Flask (site/app.py)

3.1 Новый endpoint /upload-vm

import os, hmac, hashlib, time
import requests

VM_TOKEN = os.environ.get("VM_TOKEN", "CHANGE_ME")
CHUNK = 256 * 1024          # буфер чтения при pull (не грузить файл в память целиком)

def _sign(fid: str) -> str:
    return hmac.new(VM_TOKEN.encode(), fid.encode(), hashlib.sha256).hexdigest()

@app.route("/upload-vm", methods=["POST"])
def upload_vm():
    meta = request.get_json(force=True)
    fid = meta.get("id")
    url = meta.get("url")
    name = meta.get("name", "")
    expected_size = meta.get("size", 0)
    if not fid or not url:
        return jsonify({"ok": False, "error": "нужны id и url"}), 400

    t0 = time.time()
    # egress pull — STREAM (не r.content, чтобы не убить под по OOM)
    r = requests.get(url, timeout=300, stream=True)
    r.raise_for_status()

    md5 = hashlib.md5()
    size = 0
    out_path = os.path.join("/tmp", fid)
    with open(out_path, "wb") as out:
        for chunk in r.iter_content(chunk_size=CHUNK):
            if not chunk:
                continue
            md5.update(chunk)
            size += len(chunk)
            out.write(chunk)
    total_s = time.time() - t0

    # целостность
    ok = (size == expected_size)

    # сообщить ВМ удалить файл
    try:
        requests.delete(url.split("?")[0] + f"?t={_sign(fid)}", timeout=10)
    except Exception:
        pass   # удалит TTL-очистка на ВМ

    return jsonify({
        "ok": ok,
        "id": fid, "name": name,
        "size_bytes": size,
        "size_mb": round(size / (1024 * 1024), 3),
        "md5": md5.hexdigest(),
        "pull_ms": round(total_s * 1000, 2),
        "speed_mb_s": round(size / (1024 * 1024) / total_s, 3) if total_s > 0 else 0,
    })

3.2 /config — отдать браузеру URL ВМ (чтобы не хардкодить в JS)

VM_UPLOAD_URL = os.environ.get("VM_UPLOAD_URL", "https://contracts.kube5s.ru/upload-lt")

@app.route("/config")
def config():
    return jsonify({"vm_upload_url": VM_UPLOAD_URL, "version": VERSION})

3.3 Убрать временное

  • /fetch — удалить или оставить как диагностику (на прод не выносить).
  • /upload (multipart) — оставить для справки/старых тестов, но фронтенд переключить на /upload-vm.

4. Фронтенд (templates/index.html)

Заменить логику startUpload(): два XHR вместо одного.

function startUpload() {
  var input = document.getElementById('file');
  var file = input.files[0];
  if (!file) { /* ошибка */ return; }

  var startMs = Date.now();

  // 1) файл напрямую на ВМ
  var fd = new FormData();
  fd.append('file', file, file.name);

  var x1 = new XMLHttpRequest();
  x1.open('POST', VM_UPLOAD_URL);            // из /config, либо хардкод
  x1.timeout = 600000;
  x1.upload.onprogress = function(e) { /* прогресс как сейчас */ };

  x1.onload = function() {
    var up = JSON.parse(x1.responseText);    // {ok, id, size, url}
    if (!up.ok) { /* ошибка ВМ */ return; }

    // 2) маленький POST метаданных во Flask (same-origin)
    var meta = { id: up.id, url: up.url, name: file.name, size: up.size };
    var x2 = new XMLHttpRequest();
    x2.open('POST', '/upload-vm');
    x2.setRequestHeader('Content-Type', 'application/json');
    x2.onload = function() {
      var data = JSON.parse(x2.responseText);
      renderUploadStats(file, startMs, data);   // вывести размер/md5/pull_ms
    };
    x2.onerror = function() { /* сетевая ошибка */ };
    x2.send(JSON.stringify(meta));
  };
  x1.onerror = function() { /* сетевая ошибка на ВМ */ };
  x1.send(fd);
}

Получить VM_UPLOAD_URL при загрузке страницы:

var VM_UPLOAD_URL;
fetch('/config').then(r => r.json()).then(c => { VM_UPLOAD_URL = c.vm_upload_url; });

5. Конфиг/переменные (пода managed)

Задать в ЛК при создании пода (или манифесте):

Переменная Значение
VM_TOKEN общий секрет (одинаковый на ВМ и в поде)
VM_UPLOAD_URL https://contracts.kube5s.ru/upload-lt

На ВМ: VM_TOKEN в окружении convert_server.py/systemd-юнита.


6. Безопасность (обязательно)

  1. id = uuid4().hex (128 бит, не угадать).
  2. url содержит подписанный t=<hmac> — Flask перепроверяет при DELETE.
  3. Файл удаляется сразу после pull (DELETE) + TTL-очистка на ВМ (cron: find /var/www/lt-serve -mmin +60 -delete).
  4. /lt-serve/ отдаёт файл только по полному url с токеном; без него 404 (опционально проверка в nginx secure_link).
  5. VM_TOKEN — НЕ в коде, только env.

7. Деплой

  1. Правки ВМ: nginx + upload_server.pynginx -t → reload → перезапуск Flask ВМ.
  2. Правки managed: app.py, index.html.
  3. Bump версии (VERSION в app.py) + Dockerfile-тег (naeel/loadtest:v2.1.0).
  4. docker build -t naeel/loadtest:v2.1.0 .docker push.
  5. Redeploy пода с env VM_TOKEN, VM_UPLOAD_URL.

8. Проверка (по шагам)

# 1) приём на ВМ (локально, с --noproxy — иначе прокси Krupski душит)
curl --noproxy '*' -F "file=@/tmp/big.bin" https://contracts.kube5s.ru/upload-lt
# → {"ok":true,"id":"...","size":...,"url":".../lt-serve/...?t=..."}

# 2) egress pull из пода (managed)
curl "https://http-12.containerk8s.dev.nubes.ru/fetch?url=<url_из_шага_1>"

# 3) полный сценарий через /upload-vm (из пода)
curl -X POST https://http-12.containerk8s.dev.nubes.ru/upload-vm \
  -H 'Content-Type: application/json' \
  -d '{"id":"<id>","url":"<url>","name":"big.bin","size":<size>}'
# → {"ok":true,"md5":"...","pull_ms":...,"speed_mb_s":...}

Тест-файл: dd if=/dev/urandom of=/tmp/big.bin bs=1M count=45.


9. Ограничения (помнить при прод-варианте)

  1. OOM — решено: pull через stream=True + iter_content (буфер 256КБ), не r.content.
  2. 1 worker gunicorn — параллельные pull сериализуются. При росте нагрузки: --workers 2-4 (следить за памятью).
  3. Входной шлюз ~64КБ остаётся — все НОВЫЕ входы больших данных только через ВМ.
  4. CORS — origin managed-сервиса должен быть в Access-Control-Allow-Origin ВМ.