301 lines
12 KiB
Markdown
301 lines
12 KiB
Markdown
# ⛔ НЕ ИСПОЛЬЗОВАТЬ !!! СОЗДАНО ОШИБОЧНО !!! ⛔
|
||
|
||
# ПЛАН: загрузка больших файлов через ВМ (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) — два блока.
|
||
|
||
```nginx
|
||
# приём файла (без лимита 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`:
|
||
|
||
```nginx
|
||
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 -t` → `systemctl reload nginx`.
|
||
|
||
---
|
||
|
||
## 2. ВМ: приём файла (Flask на ВМ, порт :8766)
|
||
|
||
Добавить в `convert_server.py` (или отдельный `upload_server.py`):
|
||
|
||
```python
|
||
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`
|
||
|
||
```python
|
||
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)
|
||
|
||
```python
|
||
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 вместо одного.
|
||
|
||
```js
|
||
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` при загрузке страницы:
|
||
|
||
```js
|
||
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.py` → `nginx -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. Проверка (по шагам)
|
||
|
||
```bash
|
||
# 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` ВМ.
|