Files
lang/doc/ARCHITECTURE.md

264 lines
12 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Архитектура Lyngvo — проксирование Whisper через немецкий сервер
## Общая схема
```
Браузер (Россия) Немецкий сервер (Vultr) Groq API
┌─────────────────┐ HTTPS/WSS ┌──────────────────────────┐ HTTPS ┌──────────┐
│ capire.kube5s.ru │ ───────────────→│ proxy.kube5s.ru │ ──────────→│ api.groq │
│ (K8s, index.html)│ │ │ │ .com │
└─────────────────┘ │ nginx:443 │ └──────────┘
│ ├─ /ws → ws_server:8766 │
│ └─ / → gunicorn:8765 │
│ └─ Flask proxy.py │
└──────────────────────────┘
```
## Компоненты на немецком сервере (95.179.252.111)
| Порт | Протокол | Сервис | Назначение |
|------|----------|--------|------------|
| 443 TCP | HTTPS | nginx | Терминирует TLS, проксирует в gunicorn/ws_server |
| 443 UDP | Hysteria2 | VPN | НЕ ТРОГАТЬ — пользовательский VPN |
| 8443 TCP | xray | VPN | НЕ ТРОГАТЬ — пользовательский VPN |
| 8765 TCP | HTTP | gunicorn (Flask) | Прямые POST-запросы (маленькие файлы ≤14KB) |
| 8766 TCP | WS | python3 ws_server.py | WebSocket (большие файлы >14KB) |
## Ошибка 1: Whisper заблокирован в России
**Симптом:** nginx в K8s не мог проксировать multipart POST на api.groq.com — Groq/Cloudflare возвращали 408 или connection hang.
**Причина:** Groq блокирует прямой доступ из российских IP.
**Решение:** Прокси-сервер на немецком Vultr (95.179.252.111). Сначала пробовали nginx `proxy_pass` напрямую — не работало для больших файлов (>~10KB). Затем Python-прокси.
---
## Ошибка 2: Python http.server не держит большие multipart
**Симптом:** `BaseHTTPRequestHandler` молча падал (HTTP:000) для тел >~3KB multipart.
**Причина:** http.server плохо обрабатывает большие multipart-тела (баг/ограничение модуля).
**Решение:** Flask + gunicorn (4 воркера). Файл: `/opt/groq-proxy/proxy.py`, systemd unit: `groq-proxy.service`.
---
## Ошибка 3: blobToWav — файлы стали 3-5x больше
**Симптом:** Добавили функцию `blobToWav()` (v33) — конвертация WebM→WAV перед отправкой. Файлы выросли с ~20KB до ~64KB, грузились 30+ секунд и отваливались по таймауту.
**Причина:** WAV — несжатый PCM. WebM/Opus — сжатый кодек. Запись 2-3 секунды: WebM ~15-20KB, WAV ~60-70KB.
**Решение (v34):** Полностью удалили `blobToWav()`. Отправляем нативный `audio/webm` blob, полученный от `MediaRecorder`.
```javascript
// БЫЛО (v33, сломано):
let wavBlob = await blobToWav(blob);
formData.append('file', new File([wavBlob], 'audio.wav', { type: 'audio/wav' }));
// СТАЛО (v34+, правильно):
formData.append('file', new File([blob], 'audio.webm', { type: 'audio/webm' }));
```
---
## Ошибка 4: Двойной CORS-заголовок «*, *»
**Симптом:** Браузер блокировал ответ: «The 'Access-Control-Allow-Origin' header contains multiple values '*, *', but only one is allowed». `transcribe()` висел 19.87с.
**Причина:** И nginx (`add_header Access-Control-Allow-Origin "*"`), и Flask (в `CORS` dict) добавляли одинаковый заголовок. nginx добавлял СВОЙ поверх ответа Flask → в ответе оказывалось два заголовка `Access-Control-Allow-Origin: *`.
**Решение:** Убрали CORS-заголовки из nginx. Flask сам отдаёт правильный CORS.
```nginx
# БЫЛО (в nginx):
add_header Access-Control-Allow-Origin "*" always;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS" always;
add_header Access-Control-Allow-Headers "Authorization, Content-Type" always;
# СТАЛО: ничего, Flask сам управляет CORS
```
---
## Ошибка 5: POST-запросы >16KB не доходят из России в Германию
**Симптом:**
- OPTIONS (0 байт): 0.3с
- POST 3-16KB: 0.3-0.4с
- POST 24KB+: 80% таймаутов (HTTP:000, 10-30с)
- POST 64KB: 100% таймаутов
**Причина:** Комбинация факторов:
1. DPI/шeйпинг — российский провайдер может резать HTTPS POST с телом > некоторого порога
2. VPN hairpin — если VPN включён глобально, трафик на proxy.kube5s.ru идёт через туннель обратно на тот же сервер, TCP-буферы не справляются
3. nginx буферизация — nginx пишет тело в `/var/cache/nginx/client_temp/` (лог: «client request body is buffered to a temporary file»)
**Исследование:**
- SCP 64KB работает (2.1с, 338KB/s) — чистый TCP через VPN
- SSH-туннель в обход nginx → gunicorn: 0.6с
- HTTP/2, HTTP без TLS, без Expect — не помогли
- `client_body_buffer_size 256k`, `proxy_request_buffering off` — не помогли
**Решение (v43): WebSocket для файлов >14KB.**
---
## Ошибка 6: Чанки через HTTP POST не работают
**Симптом:** `/upload/start` возвращал HTTP:000 (тот же сетевой таймаут).
**Причина:** Каждый POST-запрос страдает от той же проблемы (DPI/hairpin/буферизация), независимо от размера тела.
**Вывод:** HTTP POST — тупиковый путь. Нужен другой протокол.
---
## Решение: WebSocket (v43)
**Идея:** WebSocket после HTTP Upgrade становится чистым TCP-туннелем с минимальным фреймингом. nginx не буферизует WebSocket-фреймы — только проксирует байты туда-обратно.
**Реализация:**
### Сервер: `/opt/groq-proxy/ws_server.py`
- Порт 8766 (только localhost)
- Принимает JSON-сообщение: `{"token": "...", "chunks": N}`
- Принимает N бинарных сообщений (чанки по 4KB)
- Собирает, отправляет multipart в Groq
- Возвращает JSON с результатом
### Клиент: `transcribe()` в index.html
- Если blob ≤ 14KB: прямой POST (как раньше)
- Если blob > 14KB: WebSocket `wss://proxy.kube5s.ru/ws`
- Шлёт контрольное JSON-сообщение
- Шлёт бинарные чанки по 4KB (`blob.slice()` + `chunk.arrayBuffer()`)
- Получает JSON-ответ
### Nginx: `/ws` location
```nginx
location /ws {
proxy_pass http://127.0.0.1:8766;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
```
### Systemd: `groq-ws.service`
- `KillSignal=SIGKILL` — обязательно! Старый процесс asyncio не умирает от SIGTERM
- `KillMode=process`
- `Restart=always`
**Результат:**
- 64KB с немецкого сервера: 0.34с
- 64KB с России (локальная машина): 0.67с
---
## Ошибка 7: WebSocket-сервер падал каждую секунду
**Симптом:** Systemd бесконечно перезапускал `groq-ws.service` (restart counter = 35+). Браузер иногда подключался (HTTP 101), иногда получал 502.
**Причина:** При `systemctl restart` старый asyncio-процесс не убивался SIGTERM. Порт 8766 оставался занят. Новый процесс падал с `OSError: [Errno 98] address already in use`.
**Решение:** `KillSignal=SIGKILL` в systemd unit.
---
## Ошибка 8: Параллельные анализы (isAnalyzing flag, v39)
**Симптом:** Повторные клики на Сравнить/Стерео запускали параллельные `doCompare()`, множили сетевые запросы.
**Решение:** Флаг `isAnalyzing = true` в начале `doCompare()`, сброс в `false` после завершения или ошибки. Повторные вызовы игнорируются:
```javascript
async function doCompare(blob, originalText, duration) {
if (isAnalyzing) return '';
isAnalyzing = true;
// ...
isAnalyzing = false;
return whisperResult.text || '';
}
```
---
## Файлы и их расположение
### На немецком сервере (95.179.252.111)
| Файл | Назначение |
|------|------------|
| `/opt/groq-proxy/proxy.py` | Flask-прокси (прямые POST ≤14KB) |
| `/opt/groq-proxy/ws_server.py` | WebSocket-сервер (файлы >14KB) |
| `/etc/nginx/conf.d/groq-proxy.conf` | nginx: TLS, /ws, / |
| `/etc/systemd/system/groq-proxy.service` | gunicorn (4 воркера, порт 8765, timeout 120s) |
| `/etc/systemd/system/groq-ws.service` | WebSocket server (порт 8766, SIGKILL) |
| `/tmp/groq_key.txt` | Groq API ключ (для тестов) |
### Локально
| Файл | Назначение |
|------|------------|
| `~/lang/index.html` | SPA приложение (v43, WebSocket + прямой POST) |
| `~/lang/deploy_lang.sh` | Скрипт деплоя в K8s (configmap + rollout) |
| `~/lang/token.txt` | Groq API ключ |
---
## Команды
### Деплой index.html в K8s
```bash
cd ~/lang && bash deploy_lang.sh
```
### Деплой прокси на немецкий сервер
```bash
# Flask proxy
scp -i ~/.ssh/vultr_openssh /tmp/groq_proxy.py root@95.179.252.111:/opt/groq-proxy/proxy.py
ssh -i ~/.ssh/vultr_openssh root@95.179.252.111 'systemctl restart groq-proxy'
# WebSocket server
scp -i ~/.ssh/vultr_openssh /tmp/ws_server.py root@95.179.252.111:/opt/groq-proxy/ws_server.py
ssh -i ~/.ssh/vultr_openssh root@95.179.252.111 'systemctl restart groq-ws'
```
### Проверка статуса
```bash
ssh -i ~/.ssh/vultr_openssh root@95.179.252.111 \
'systemctl is-active groq-proxy groq-ws nginx && ss -tlnp | grep -E "8765|8766"'
```
### Тест WebSocket с локальной машины
```bash
cd ~/lang && .venv/bin/python3 -c "
import asyncio, websockets, json
async def t():
ws = await websockets.connect('wss://proxy.kube5s.ru/ws')
await ws.send(json.dumps({'token': '$(cat token.txt)', 'chunks': 1}))
with open('/tmp/test.wav','rb') as f: await ws.send(f.read())
print(await ws.recv())
asyncio.run(t())
"
```
---
## Версии index.html
| Версия | Изменения |
|--------|-----------|
| v33 | ❌ Добавлен blobToWav — сломано |
| v34 | ✅ Удалён blobToWav, прямой WebM |
| v35 | Таймаут увеличен до 60с |
| v36 | Слоги после 📊, 🗑 в конце строки |
| v37 | Дебаг-логи в консоль |
| v38 | Лог размера блоба |
| v39 | isAnalyzing флаг, try/catch в doCompare |
| v40 | Чистка дебаг-логов |
| v41 | Лог [WHISPER] в консоль |
| v42 | ❌ Чанки через HTTP POST (не сработало) |
| v43 | ✅ WebSocket для файлов >14KB |