Files
lang/doc/ARCHITECTURE.md

12 KiB
Raw Permalink Blame History

Архитектура 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.

// БЫЛО (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):
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

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 после завершения или ошибки. Повторные вызовы игнорируются:

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

cd ~/lang && bash deploy_lang.sh

Деплой прокси на немецкий сервер

# 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'

Проверка статуса

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 с локальной машины

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