Rust в production: паттерны для надёжного backend

Production-паттерны для Rust backend: конфигурация через env, graceful shutdown, structured logging, OpenTelemetry трейсы, health checks, error handling стратегии

Заключительная статья серии про Rust в backend. Сервис написан (Axum) и упакован в минимальный образ — осталось довести его до production: конфигурация, корректное завершение, логи и трейсы, health checks, обработка ошибок, работа с БД и тесты. Это не про сам Rust, а про дисциплину эксплуатации; язык лишь помогает поймать часть ошибок на компиляции.

Production-сервис под присмотром: приборная панель — health и телеметрия — над отлаженным механизмом с клапаном плавного останова

В статье

Конфигурация: env и fail-fast

Конфигурация — только через переменные окружения, разобранные в типизированную структуру и провалидированные на старте. Сервис, которому не хватает обязательной переменной, должен падать сразу, а не на первом запросе.

use serde::Deserialize;

#[derive(Debug, Deserialize)]
struct Config {
    #[serde(default = "default_port")]
    port: u16,
    database_url: String,          // обязательная — нет → паника на старте
    #[serde(default = "default_log")]
    rust_log: String,
}
fn default_port() -> u16 { 8080 }
fn default_log() -> String { "info".into() }

let cfg: Config = envy::from_env().expect("invalid configuration");

Это тот же принцип, что и в Go-сервисах: один источник истины (env), без конфиг-файлов, с явной валидацией. Секреты — тоже через env, из секрет-менеджера, не в репозитории.

Graceful shutdown

Под оркестратором сервис получает SIGTERM и обязан корректно дослужить текущие запросы и закрыть соединения, а не оборваться. Ловим оба сигнала — SIGTERM и Ctrl-C:

use tokio::signal;

async fn shutdown_signal() {
    let ctrl_c = async { signal::ctrl_c().await.ok(); };
    #[cfg(unix)]
    let term = async {
        signal::unix::signal(signal::unix::SignalKind::terminate())
            .expect("install SIGTERM handler").recv().await;
    };
    #[cfg(not(unix))]
    let term = std::future::pending::<()>();

    tokio::select! { _ = ctrl_c => {}, _ = term => {} }
}

axum::serve(listener, app).with_graceful_shutdown(shutdown_signal()) перестанет принимать новые соединения и дождётся завершения активных. Долгоживущие операции стоит ограничить таймаутом, чтобы shutdown не завис навсегда.

Логи и трейсы: tracing + OpenTelemetry

Стандарт логирования в async-Rust — крейт tracing: он понимает спаны (контекст запроса), а не только отдельные строки. В production — структурированный JSON, уровень — из env:

use tracing_subscriber::{fmt, EnvFilter};

fmt()
    .json()
    .with_env_filter(EnvFilter::from_default_env()) // RUST_LOG=info,sqlx=warn
    .init();

Трейсы экспортируются через мост tracing-opentelemetry: спаны tracing превращаются в OTEL-спаны и уходят по OTLP в OTEL Collector, а оттуда — в бэкенд (метрики, трейсы). Механически это ещё один слой поверх того же tracing: Registry собирается из fmt-слоя (JSON-логи) и tracing_opentelemetry::layer() с OTLP-экспортёром из крейтов opentelemetry / opentelemetry_sdk / opentelemetry-otlp. Метрики — через тот же SDK opentelemetry-rust. Важная деталь: при завершении нужно вызвать shutdown провайдера, иначе последний батч телеметрии потеряется.

Оговорка по версиям: API opentelemetry-rust заметно меняется между релизами, поэтому в companion-демо намеренно оставлены только структурные JSON-логи, без привязки OTLP-обвязки к конкретным версиям крейтов — иначе пример быстро устаревает. Сами слои добавляются, как описано выше; точные сигнатуры сверяйте с актуальной докой opentelemetry-rust.

Health checks

Два разных эндпоинта, которые часто путают:

  • liveness (/healthz) — «процесс жив». Возвращает 200 без проверки зависимостей. Если он падает — оркестратор перезапускает под.
  • readiness (/readyz) — «готов принимать трафик». Проверяет критичные зависимости (например, SELECT 1 к БД). Если не готов — под убирается из балансировки, но не перезапускается.

Путаница приводит к каскадам: если в liveness засунуть проверку БД, кратковременная недоступность базы перезапустит все поды разом. Liveness — про процесс, readiness — про зависимости.

Обработка ошибок

Идиома Rust: thiserror для типизированных доменных ошибок (в библиотечном/доменном коде), anyhow для прикладного кода и main, где конкретный тип не важен. Доменная ошибка отображается в HTTP через IntoResponse:

use thiserror::Error;
use axum::{response::{IntoResponse, Response}, http::StatusCode, Json};

#[derive(Error, Debug)]
enum AppError {
    #[error("not found")]
    NotFound,
    #[error("invalid input: {0}")]
    Validation(String),
    #[error(transparent)]
    Internal(#[from] anyhow::Error),
}

impl IntoResponse for AppError {
    fn into_response(self) -> Response {
        let (status, title, detail) = match &self {
            AppError::NotFound => (StatusCode::NOT_FOUND, "Not Found", self.to_string()),
            AppError::Validation(m) => (StatusCode::BAD_REQUEST, "Validation Failed", m.clone()),
            AppError::Internal(e) => {
                tracing::error!(error = ?e, "internal error"); // логируем, наружу — generic
                (StatusCode::INTERNAL_SERVER_ERROR, "Internal Server Error", "internal error".into())
            }
        };
        // RFC 7807: type/title/status/detail + Content-Type: application/problem+json
        let body = serde_json::json!({
            "type": "about:blank", "title": title,
            "status": status.as_u16(), "detail": detail,
        });
        let mut resp = (status, Json(body)).into_response();
        resp.headers_mut().insert(
            axum::http::header::CONTENT_TYPE,
            axum::http::HeaderValue::from_static("application/problem+json"),
        );
        resp
    }
}

Два правила: внутренние ошибки логируются, но не утекают клиенту (только generic-сообщение), а формат тела ответа лучше держать единым — например, по RFC 7807 (application/problem+json), чтобы клиенты разбирали ошибки одинаково.

База данных: sqlx

sqlx даёт асинхронный доступ к БД с проверкой SQL на этапе компиляции: макрос query!/query_as! сверяет запрос со схемой реальной базы при сборке. Опечатка в имени столбца или несоответствие типов — ошибка компиляции, а не сюрприз в runtime.

let pool = sqlx::postgres::PgPoolOptions::new()
    .max_connections(10)
    .connect(&cfg.database_url).await?;

sqlx::migrate!("./migrations").run(&pool).await?;   // миграции из репозитория

let user = sqlx::query_as!(User, "SELECT id, name FROM users WHERE id = $1", id)
    .fetch_optional(&pool).await?;

Для CI без живой БД на сборке используйте offline-режим (cargo sqlx prepare фиксирует метаданные запросов в .sqlx/). Пул соединений кладётся в состояние сервиса и переиспользуется.

Тестирование

  • Unit — чистая логика; зависимости за трейтами, моки через mockall. Не злоупотребляйте: моки проверяют взаимодействие, а не поведение.
  • Integration — поднимайте реальную БД через testcontainers (контейнер PostgreSQL на время теста) и гоняйте запросы по-настоящему. Это ловит то, что моки пропускают: SQL, миграции, типы.
  • API — поднимите приложение на случайном порту и бейте по нему reqwest-клиентом, проверяя реальные HTTP-ответы и коды.

Связка «тонкие unit + честные integration на testcontainers» даёт уверенность без хрупких полностью замоканных тестов. В companion-демо такой integration-тест есть: testcontainers поднимает Postgres на время теста, накатывает миграции и проверяет POST/GET/404 через роутер (cargo test).

Чеклист перед production

  • Конфиг валидируется на старте, секреты — из env, не в образе.
  • SIGTERM → graceful shutdown с таймаутом.
  • Структурированные логи (JSON) + трейсы в OTEL Collector; провайдер телеметрии корректно завершается.
  • Раздельные liveness/readiness; readiness проверяет зависимости.
  • Внутренние ошибки логируются, наружу — generic; единый формат тела ошибки.
  • БД: пул с лимитом, миграции применяются при старте/деплое, запросы проверены (sqlx offline в CI).
  • Образ минимальный, non-root, с CA-сертификатами (см. предыдущую статью).
  • Ресурсные лимиты и таймауты на внешние вызовы.

Собранный воедино каркас — конфиг из env с fail-fast, graceful shutdown по SIGTERM, структурные JSON-логи, liveness/readiness, error handling в problem+json и sqlx с проверкой запросов на компиляции — лежит в digital-cookbook, rust/production/: cargo build проходит без БД (offline-кеш .sqlx/), а docker compose up -d db поднимает Postgres для реального запуска и cargo sqlx prepare.

На этом серия про Rust в backend завершена: от вопроса «а нужен ли он вообще» до production-сервиса. Rust не делает эксплуатацию бесплатной, но честно переносит часть проверок на компилятор — а остальное всё равно про инженерную дисциплину.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии