Заключительная статья серии про Rust в backend. Сервис написан (Axum) и упакован в минимальный образ — осталось довести его до production: конфигурация, корректное завершение, логи и трейсы, health checks, обработка ошибок, работа с БД и тесты. Это не про сам Rust, а про дисциплину эксплуатации; язык лишь помогает поймать часть ошибок на компиляции.
В статье
- Конфигурация: env и fail-fast
- Graceful shutdown
- Логи и трейсы: tracing + OpenTelemetry
- Health checks
- Обработка ошибок
- База данных: sqlx
- Тестирование
- Чеклист перед production
Конфигурация: 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 не делает эксплуатацию бесплатной, но честно переносит часть проверок на компилятор — а остальное всё равно про инженерную дисциплину.
Комментарии