Введение: почему именно Rust для SaaS
Каждый стартап рано или поздно сталкивается с выбором стека для бэкенда. JavaScript? Python? Go? Rust? На первый взгляд, Rust — странное решение для SaaS: сложный синтаксис, крутая кривая обучения, маленькое сообщество по сравнению с тем же Python. Но за этой сложностью стоит конкретная выгода: скорость, типобезопасность и предсказуемость поведения в production. В TEOM мы обрабатываем аудио и видео для транскрибации — задачу, где каждая миллисекунда на счету. 80-минутная запись психотерапевтической сессии обрабатывается за 193 секунды — это в 25 раз быстрее реального времени. За этим стоит не только оптимизация моделей (GigaAM с WER 23.9%), но и правильный выбор инфраструктуры. В этой статье расскажу наш путь от выбора стека до работы в production: что получилось, что оказалось сложнее, и почему Axum стал нашим основным фреймворком.Определения: термины, которые понадобятся
Прежде чем начать, определю несколько терминов, которые будут встречаться в статье:- Axum — веб-фреймворк для Rust от команды Tokio. Строится поверх hyper (низкоуровневая HTTP-библиотека) и tower (абстракция для middleware). Сочетает производительность с удобным API для маршрутизации и обработки запросов.
- SQLx — асинхронная библиотека для работы с PostgreSQL из Rust. В отличие от других драйверов, проверяет SQL-запросы на этапе компиляции — ошибки в базе данных ловятся не в runtime, а при сборке.
- Tokio — асинхронный runtime для Rust. Предоставляет event loop, таймеры, каналы связи и таски (лёгкие потоки выполнения). Всё асинхронное в Rust работает поверх Tokio.
- SERDE — фреймворк для сериализации и десериализации данных. Превращает Rust-структуры в JSON, YAML или другие форматы и обратно. Используется повсеместно.
- Tower — абстракция для построения сетевых сервисов. Предоставляет слои (layers), которые оборачивают обработчики: логирование, аутентификация, rate limiting и другие middleware.
- WER (Word Error Rate) — метрика точности распознавания речи. Показывает процент слов, которые модель распознала неправильно. Чем ниже, тем лучше.
- Middleware — промежуточное ПО, которое обрабатывает запрос до или после основного обработчика. Используется для логирования, аутентификации, кэширования и других поперечных задач.
- Сериализация — процесс превращения данных из внутреннего представления программы в формат для передачи (например, JSON).
- Таск — лёгкая единица выполнения в асинхронном runtime. В отличие от потока, таск не привязан к ядру процессора и переключается операционной системой при необходимости.
- Connection pooling — техника переиспользования соединений с базой данных вместо создания нового для каждого запроса. Снижает накладные расходы на установку соединения.
Акт I: Зов приключения — зачем менять Python на Rust
TEOM начался с Python-бэкенда. Flask, SQLAlchemy, простая архитектура. И пока мы обрабатывали 10-минутные записи — всё было нормально. Скорость обработки — 15-20 минут на запись, несколько параллельных задач, пользователи ждут. Но когда мы перешли на 60-80-минутные записи, Python начал задыхаться. Вот конкретные проблемы:Проблема 1: блокирующий ввод-вывод
Python-сервер блокируется при ожидании ответа от ML-модели. Каждый запрос на транскрибацию занимал рабочий поток на 5-15 минут. Пул потоков исчерпывался, новые запросы стояли в очереди. ``python
Проблемный код на Python
@app.route('/transcribe', methods=['POST'])
def transcribe():
audio = request.files['audio']
# Блокирующий вызов — поток простаивает
result = gigaam.transcribe(audio) # 5-15 минут!
return jsonify(result)
`
Проблема 2: потребление памяти
Python-процесс с загруженной моделью GigaAM потреблял 4-6 ГБ RAM. При масштабировании на несколько экземпляров — сервер просто не влезал в память.
Проблема 3: ошибки в runtime
Python не ловит ошибки типов на этапе разработки. Опечатка в имени поля структуры — это runtime-ошибка, которая проявляется только при обработке конкретного запроса. В production, когда пользователи ждут результат, это критично.
Мы попробовали Go — получилось быстрее, но не настолько, как хотелось бы. А когда столкнулись с проблемами параллелизма в ML-пайплайне, поняли: нужен язык с настоящими асинхронными примитивами и нулевой стоимостью абстракций.
Акт II: Встреча с наставником — выбор Axum
Rust не был очевидным выбором. Мы рассматривали Actix Web, Rocket и Tide. Но Axum выделялся нескольким вещами:
Поддержка сообщества Tokio
Axum создан и поддерживается командой Tokio — того самого runtime, который используется в большинстве асинхронных Rust-проектов. Это значит, что документация, примеры и решения проблем всегда актуальны. Если вы гуглите «как сделать X в Axum», ответ почти наверняка есть.
Отсутствие макросов-волшебства
В отличие от Rocket, который использует attribute-макросы для маршрутизации ( #[get("/")] ), Axum строится на обычных функциях и замыканиях. Это упрощает отладку: вы видите точный стек вызовов, без «волшебных» преобразований, которые сложно отслеживать.
Экстракторы вместо валидации
Axum использует систему экстракторов: вы описываете, какие данные ожидает обработчик, и фреймворк сам парсит и валидирует их. Ошибки — это не исключения, а типизированные значения.
`rust
use axum::{extract::Json, http::StatusCode, response::IntoResponse};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct TranscribeRequest {
audio_url: String,
language: Option, // "ru" по умолчанию
}
#[derive(Serialize)]
struct TranscribeResponse {
text: String,
duration_seconds: f64,
word_count: usize,
}
async fn transcribe(
Json(request): Json,
) -> Result, StatusCode> {
// request уже распарсен и валидирован
// Ошибка типов невозможна — компилятор проверил
let result = ml_service.transcribe(&request.audio_url).await;
match result {
Ok(text) => Ok(Json(TranscribeResponse {
text,
duration_seconds: 0.0,
word_count: 0,
})),
Err(_) => Err(StatusCode::INTERNAL_SERVER_ERROR),
}
}
`
Tower: middleware как слои
Tower предоставляет простой способ добавлять middleware. Логирование, аутентификация, rate limiting — всё строится как слои, которые оборачивают обработчик.
`rust
use tower_http::trace::TraceLayer;
use tower_http::cors::CorsLayer;
let app = Router::new()
.route("/v1/transcribe", post(transcribe_handler))
.layer(TraceLayer::new_for_http())
.layer(CorsLayer::permissive());
`
Конвейерная композиция
Axum позволяет компоновать маршруты и слои как конструктор. Каждый роут — это функция, которая принимает состояние и возвращает роутер. Это делает архитектуру модульной и тестируемой.
Акт III: Испытания и порог — production-проблемы
Испытание 0: обработка ошибок
В Rust нет исключений (exceptions). Вместо этого используется тип Result — либо значение, либо ошибка. Это заставляет явно обрабатывать каждый возможный сбой.
В Axum это выглядит так: обработчик возвращает Result, AppError>, где AppError — наш собственный тип ошибки. Мы реализовали для него IntoResponse, чтобы ошибки автоматически превращались в HTTP-ответы с правильными кодами и сообщениями.
`rust
#[derive(Debug)]
enum AppError {
NotFound(String),
ValidationError(String),
InternalError(String),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match self {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
AppError::ValidationError(msg) => (StatusCode::BAD_REQUEST, msg),
AppError::InternalError(msg) => (StatusCode::INTERNAL_SERVER_ERROR, msg),
};
let body = Json(serde_json::json!({ "error": message }));
(status, body).into_response()
}
}
`
Такой подход гарантирует, что мы не забыли обработать ни одной ошибки. Если функция может упасть — компилятор заставит вас это учесть.
Испытание 1: долгие ML-запросы
Первая серьёзная проблема: ML-модели (GigaAM, PaddleOCR) выполняются долго — от 2 до 15 минут. Axum по умолчанию обрабатывает запросы в тасках Tokio. Но если ML-операция блокирует таск, весь runtime замедляется.
Решение: выносим ML-операции в отдельные таски с ограничением параллелизма.
`rust
use tokio::sync::Semaphore;
use std::sync::Arc;
// Ограничиваем количество параллельных ML-операций
let semaphore = Arc::new(Semaphore::new(4)); // максимум 4 одновременно
async fn transcribe_with_limit(
request: TranscribeRequest,
semaphore: Arc,
) -> Result {
let _permit = semaphore.acquire().await
.map_err(|_| "Сервис перегружен".to_string())?;
// ML-операция выполняется в отдельном таске
let result = tokio::task::spawn_blocking(move || {
gigaam.transcribe(&request.audio_url)
}).await.map_err(|e| e.to_string())?;
Ok(result)
}
`
Испытание 2: работа с PostgreSQL
SQLx проверяет SQL-запросы на этапе компиляции. Это огромное преимущество: ошибки в запросах ловятся не в production, а при cargo build. Но есть нюанс: запросы должны быть известны на этапе компиляции.
Для динамических запросов (фильтрация, сортировка, пагинация) мы используем sqlx::query_as с ручной сборкой запроса:
`rust
use sqlx::PgPool;
async fn get_user_transcriptions(
pool: &PgPool,
user_id: i64,
limit: i64,
offset: i64,
) -> Result, sqlx::Error> {
sqlx::query_as!(
Transcription,
r#"
SELECT id, user_id, audio_url, text, created_at
FROM transcriptions
WHERE user_id = $1
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
"#,
user_id,
limit,
offset
)
.fetch_all(pool)
.await
}
`
Испытание 3: стриминг ответов
При транскрибации длинных записей пользователь хочет видеть прогресс. Мы реализовали стриминг через Server-Sent Events (SSE): каждые несколько секунд клиент получает обновление о текущем статусе.
`rust
use axum::response::sse::{Event, Sse};
use futures::stream::Stream;
use std::convert::Infallible;
async fn transcribe_stream(
request: Json,
) -> Sse>> {
let stream = async_stream::stream! {
yield Ok(Event::default().data("Начинаю обработку..."));
// Промежуточные обновления
for progress in 0..100 {
tokio::time::sleep(Duration::from_secs(5)).await;
yield Ok(Event::default()
.data(format!("Прогресс: {}%", progress)));
}
yield Ok(Event::default().data("Готово!"));
};
Sse::new(stream)
}
`
Акт IV: Награда — результаты в production
После шести месяцев разработки и двух месяцев в production, вот что мы имеем:
Тестирование
Rust-экосистема предоставляет мощные инструменты для тестирования. Мы написали интеграционные тесты для каждого API-эндпоинта, которые проверяют не только happy path, но и граничные случаи: пустые запросы, невалидные данные, превышение лимитов.
Команда cargo test запускает все тесты за несколько секунд. В сравнении с Python, где pytest может работать минуты — это огромная разница. Более того,Потому что мы используем sqlx::query_as, изменения схемы базы данных отлавливаются на этапе компиляции, а не во время запуска тестов.
`rust
#[tokio::test]
async fn test_transcribe_endpoint() {
let app = create_test_app().await;
let response = app
.oneshot(
Request::builder()
.uri("/v1/transcribe")
.method("POST")
.header("content-type", "application/json")
.body(Body::from(serde_json::json!({
"audio_url": "https://example.com/audio.wav"
}).to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
}
`
Деплой
Мы развернули TEOM на Docker Compose с следующими сервисами (подробнее о выборе между ):
- PostgreSQL (pgvector:pg16) — основная база данных, порт 5432
- Redis — кэш и очередь задач
- MinIO — объектное хранилище для аудио- и видеофайлов
- Axum API — наш основной бэкенд
- ML-воркер — отдельный процесс для инференса моделей
Docker-образ нашего API весит всего 45 МБ (против 800 МБ для Python-версии). Это значит, что деплой занимает секунды, а не минуты. Автоматическое масштабирование работает проще: контейнер запускается быстро, потребляет мало ресурсов.
Производительность
- Обработка 80-минутной записи: 193 секунды (было 15-20 минут на Python)
- Задержка ответа (p95): 120 мс (было 800 мс на Python)
- Потребление памяти: 1.2 ГБ на экземпляр (было 4-6 ГБ)
- Максимальная нагрузка: 50 параллельных транскрибаций на одном сервере
Надёжность
- Точность транскрибации: GigaAM показал WER 23.9% на нашем бенчмарке — значительно лучше Whisper (35.7%)
- Отказоустойчивость: типовая система Rust ловит ошибки на этапе компиляции, которые в Python привели бы к крашу в production
- Логирование: полная трассировка каждого запроса через Tower middleware
Скорость разработки
Парадоксально, но после изучения Rust мы стали разрабатывать быстрее. Компилятор ловит ошибки до запуска, а типизированный код самодокументируется. PR-ревью занимают меньше времени, потому что компилятор уже проверил большую часть логики.
Акт V: Возвращение — что бы мы сделали иначе
Не начинать с монолита
В первые месяцы мы пытались уместить всё в один бинарник: API, ML-инференс, фоновые задачи, . Это было ошибкой. В итоге разделили на микросервисы: API-сервис (Axum), ML-воркер (отдельный процесс), фоновые задачи (Redis Queue).
Раньше использовать Connection Pooling
SQLx предоставляет пул соединений к PostgreSQL, но мы подключили его только на третий месяц разработки. Ранее каждый запрос создавал новое соединение — это добавляло 50-100 мс к каждому запросу. Для API с десятками запросов в секунду это было критично.
Не бояться unsafe
Rust запрещает небезопасный код по умолчанию. Но для интеграции с C-библиотеками (ffmpeg, модели) иногда необходим unsafe`. Мы слишком долго пытались обойтись без него, в итоге написали лишние тысячи строк кода.
Инвестировать в документацию раньше
Rust-код самодокументируется лучше, чем Python или JavaScript, но это не значит, что документация не нужна. Мы слишком долго откладывали написаниеREADME, что потом затрудняло онboarding новых разработчиков.Заключение: Rust как инвестиция
Rust + Axum — это инвестиция в будущее. Кривая обучения крутая: на освоение уходит 2-3 месяца. Но потом вы получаете язык, который:- Не даёт писать небезопасный код (без явного разрешения)
- Ловит ошибки на этапе компиляции
- Обеспечивает производительность на уровне C/C++
- Имеет отличную экосистему для асинхронного программирования
Попробуйте TEOM
Хотите увидеть, как Rust + Axum работают в деле? TEOM — это платформа для транскрибации аудио и видео с использованием модели GigaAM. Обрабатывайте записи любой длины, получайте точные расшифровки с метками спикеров и анализируйте содержание. 80 минут аудио — 193 секунды обработки. Без компромиссов в качестве. Попробуйте TEOM бесплатно на teom.io — начните с одной записи и убедитесь сами.rustaxumsaasbackendтранскрибацияproduction