doc: подробная архитектура и хронология ошибок
This commit is contained in:
+246
-86
@@ -1,103 +1,263 @@
|
|||||||
# Lyngvo — архитектура и инфраструктура
|
# Архитектура Lyngvo — проксирование Whisper через немецкий сервер
|
||||||
|
|
||||||
## Что это
|
## Общая схема
|
||||||
|
|
||||||
Одностраничное веб-приложение (SPA) для тренировки итальянского произношения.
|
|
||||||
URL: `https://capire.kube5s.ru`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Стек
|
|
||||||
|
|
||||||
| Компонент | Технология |
|
|
||||||
|---|---|
|
|
||||||
| Frontend | Single HTML file (`index.html`), vanilla JS, Web Audio API |
|
|
||||||
| Хостинг | Kubernetes (kube5s), namespace `default`, deploy `lyngvo` |
|
|
||||||
| Конфиг | ConfigMap `lyngvo-html` (index.html) + `lyngvo-nginx` (nginx.conf) |
|
|
||||||
| Ingress | `capire.kube5s.ru` |
|
|
||||||
| AI: распознавание речи | Groq Whisper (`whisper-large-v3`) |
|
|
||||||
| AI: перевод | Groq LLaMA (`llama-3.3-70b-versatile`) |
|
|
||||||
| TTS | Groq TTS (через тот же прокси) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Groq API прокси
|
|
||||||
|
|
||||||
Groq API заблокирован в России. Все запросы идут через немецкий сервер.
|
|
||||||
|
|
||||||
```
|
```
|
||||||
Браузер (Россия)
|
Браузер (Россия) Немецкий сервер (Vultr) Groq API
|
||||||
→ HTTPS → proxy.kube5s.ru (Германия, 95.179.252.111)
|
┌─────────────────┐ HTTPS/WSS ┌──────────────────────────┐ HTTPS ┌──────────┐
|
||||||
nginx (TLS termination, port 443)
|
│ capire.kube5s.ru │ ───────────────→│ proxy.kube5s.ru │ ──────────→│ api.groq │
|
||||||
→ localhost:8765 (Python HTTP сервер)
|
│ (K8s, index.html)│ │ │ │ .com │
|
||||||
→ HTTPS → api.groq.com
|
└─────────────────┘ │ nginx:443 │ └──────────┘
|
||||||
|
│ ├─ /ws → ws_server:8766 │
|
||||||
|
│ └─ / → gunicorn:8765 │
|
||||||
|
│ └─ Flask proxy.py │
|
||||||
|
└──────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
### Компоненты на немецком сервере
|
## Компоненты на немецком сервере (95.179.252.111)
|
||||||
|
|
||||||
| Файл/сервис | Назначение |
|
| Порт | Протокол | Сервис | Назначение |
|
||||||
|---|---|
|
|------|----------|--------|------------|
|
||||||
| `/opt/groq-proxy/proxy.py` | Python HTTP сервер, универсальный форвардер |
|
| 443 TCP | HTTPS | nginx | Терминирует TLS, проксирует в gunicorn/ws_server |
|
||||||
| `groq-proxy.service` (systemd) | Автозапуск proxy.py |
|
| 443 UDP | Hysteria2 | VPN | НЕ ТРОГАТЬ — пользовательский VPN |
|
||||||
| `/etc/nginx/conf.d/groq-proxy.conf` | TLS + проброс на localhost:8765 |
|
| 8443 TCP | xray | VPN | НЕ ТРОГАТЬ — пользовательский VPN |
|
||||||
| Let's Encrypt cert | `proxy.kube5s.ru` |
|
| 8765 TCP | HTTP | gunicorn (Flask) | Прямые POST-запросы (маленькие файлы ≤14KB) |
|
||||||
|
| 8766 TCP | WS | python3 ws_server.py | WebSocket (большие файлы >14KB) |
|
||||||
|
|
||||||
**Почему Python, а не nginx proxy_pass:**
|
## Ошибка 1: Whisper заблокирован в России
|
||||||
nginx не может надёжно проксировать большие multipart/form-data (аудио) в Groq/Cloudflare — соединение обрывается с 408/502. Python `requests` делает нормальный HTTP-запрос от имени сервера.
|
|
||||||
|
**Симптом:** 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
|
```bash
|
||||||
bash ~/lang/deploy_lang.sh
|
cd ~/lang && bash deploy_lang.sh
|
||||||
```
|
```
|
||||||
|
|
||||||
Что делает скрипт:
|
### Деплой прокси на немецкий сервер
|
||||||
1. Читает Groq API ключ из `~/lang/token.txt`
|
|
||||||
2. Вшивает ключ в `index.html` через `sed` (в HTML ключ всегда пустая строка)
|
|
||||||
3. rsync всей папки на ВМ (`5.172.178.213`)
|
|
||||||
4. `kubectl apply` configmap + deployment + ingress
|
|
||||||
5. `kubectl rollout restart deploy/lyngvo`
|
|
||||||
|
|
||||||
**Groq ключ** хранится только в `~/lang/token.txt` (в `.gitignore`), в репо не коммитится.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Файлы репо
|
|
||||||
|
|
||||||
```
|
|
||||||
index.html — вся логика приложения (один файл)
|
|
||||||
deploy_lang.sh — скрипт деплоя
|
|
||||||
nginx.conf — конфиг nginx внутри k8s пода
|
|
||||||
k8s/
|
|
||||||
deployment.yaml — Deployment + Service
|
|
||||||
ingress.yaml — Ingress capire.kube5s.ru
|
|
||||||
token.txt — Groq API ключ (в .gitignore)
|
|
||||||
doc/
|
|
||||||
ARCHITECTURE.md — этот файл
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Что живёт внутри index.html
|
|
||||||
|
|
||||||
- `VERSION` — строка версии, отображается в заголовке
|
|
||||||
- **phoneticAnalysis(blob)** — фонетический анализ аудио через Web Audio API (pitch, стабильность, артикуляция, качество окончания)
|
|
||||||
- **transcribe(blob)** — отправка аудио в Groq Whisper, возвращает распознанный текст
|
|
||||||
- **translateToRussian(text)** — перевод через Groq LLaMA
|
|
||||||
- **doCompare(blob, text, duration)** — параллельный запуск transcribe + phoneticAnalysis, формирует результат
|
|
||||||
- **История записей** — IndexedDB (`lyngvo_recs`), кнопки ▶ / 🎧 / 📊 / 🗑
|
|
||||||
- **История фраз** — localStorage, последние 5 фраз
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## SSH доступ
|
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# ВМ (Kubernetes)
|
# Flask proxy
|
||||||
ssh -i ~/.ssh/naeel_vm_id_ed25519 -o StrictHostKeyChecking=no naeel@5.172.178.213
|
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'
|
||||||
|
|
||||||
# Немецкий сервер (Groq прокси)
|
# WebSocket server
|
||||||
ssh -i ~/.ssh/vultr_openssh -o StrictHostKeyChecking=no root@95.179.252.111
|
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 |
|
||||||
|
|||||||
Reference in New Issue
Block a user