Прод на Rust: структура проекта, модули, конфигурация, ошибки в проде
Компилятор уже отобрал у вас целый класс проблем: висячие указатели, гонки данных,
use-after-free, забытый unlock. Но эта экономия не архитектурная. Компилятор ничего не
скажет о том, что домен зависит от Postgres, что конфиг читается через std::env::var в
двадцати местах, что unwrap() в обработчике роняет запрос, а panic = "abort" роняет весь
под целиком.
Прод — это решения, которые компилятор проверить не может. Разберём их на сквозном примере:
сервис заказов orders на axum + sqlx + tokio.
Что «прод» значит именно для Rust
Артефакт — один самодостаточный бинарь. Нет JVM, нет интерпретатора, нет runtime-пакетов:
образ на 15 МБ вместо 300 МБ, старт за 5–20 мс, FROM scratch реален. Холодный старт
перестаёт быть аргументом против, serverless и sidecar-и становятся дешёвыми.
Нет GC — нет и GC-пауз, но есть аллокатор. Профиль латентности плоский: p99 близок к p50,
а не в 20 раз выше из-за stop-the-world. Взамен приходят фрагментация кучи, поведение
системного malloc под многопоточной нагрузкой и рост RSS, который никто не «соберёт».
Модель памяти разбиралась во Владении и заимствовании,
измерения — в performance.
Отказ — это паника, и устроена она иначе, чем исключение. Она не ловится try/catch,
разматывает стек до границы задачи и по умолчанию не восстанавливается.
Компиляция — часть стоимости владения. Секунды в Go против минут в Rust — цена, которую платят люди, а не машины. Обсудим её честно в отдельном разделе.
Модули: приватность как инструмент, а не бюрократия
Модули — это не папки, это границы инвариантов. Крейт — единица компиляции и
версионирования; внутри крейта модули образуют дерево имён; всё приватно по умолчанию, а
pub — публичное обещание, которое придётся поддерживать.
// crates/orders-domain/src/lib.rs
pub mod order; // src/order.rs либо src/order/mod.rs
pub mod money;
mod validation; // приватный: деталь реализации домена
pub use order::{Order, OrderId, OrderStatus}; // фасад: плоский стабильный набор имён
pub use money::Money;
Главный приём — приватное поле держит инвариант. В Rust это работает жёстче, чем в языках с рефлексией: обойти конструктор нечем.
/// Деньги в минорных единицах. Инвариант: неотрицательны.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Money(i64); // поле приватно — Money(-5) снаружи не собрать
impl Money {
/// Единственная дверь внутрь типа.
pub fn from_minor(v: i64) -> Result<Self, MoneyError> {
if v < 0 { return Err(MoneyError::Negative(v)); }
Ok(Money(v))
}
pub fn minor(self) -> i64 { self.0 }
}
Попытка обойти дверь ловится компилятором:
error[E0603]: tuple struct constructor `Money` is private
--> crates/orders-app/src/pricing.rs:12:24
|
12 | let total = money::Money(-500);
| ^^^^^ private tuple struct constructor
|
note: the tuple struct constructor `Money` is defined here
--> crates/orders-domain/src/money.rs:5:18
|
5 | pub struct Money(i64);
| ^^^
help: consider making the field publicly accessible
Первый навык прод-Rust — читать не только первую строку, но и note с help. note
показывает, где объявлена сущность, help предлагает исправление — почти всегда самое
прямолинейное. Прямолинейное не значит правильное: здесь help предлагает открыть поле, то
есть выбросить инвариант. Верный ответ — вызвать Money::from_minor.
Градации видимости — это язык описания архитектуры внутри крейта:
| Форма | Кто видит | Когда |
|---|---|---|
| (по умолчанию) | модуль и его потомки | всё, пока не доказано обратное |
pub(super) |
родительский модуль | помощник для соседей по подсистеме |
pub(crate) |
весь крейт | внутренний API между слоями |
pub(in crate::a::b) |
конкретное поддерево | лечит «почти публичное» |
pub |
внешний мир | осознанное semver-обязательство |
Правило: pub(crate) — рабочая лошадка, pub — исключение. Каждое pub в библиотечном
крейте нельзя изменить без мажорной версии. Вторая частая ошибка — путаница «модуль ≠ файл»:
error[E0433]: failed to resolve: use of undeclared crate or module `validation`
--> crates/orders-domain/src/order.rs:3:5
|
3 | use validation::check_items;
| ^^^^^^^^^^ use of undeclared crate or module `validation`
|
help: consider importing this module through its path
|
3 - use validation::check_items;
3 + use crate::validation::check_items;
Пути в Rust 2018+ абсолютны от корня крейта: crate::, self::, super:: или имя внешнего
крейта. Файл сам по себе в дерево не попадает — его вносит объявление mod.
Крейты и workspace: когда резать
Модуль — граница видимости: бесплатен и гибок. Крейт — граница компиляции, версионирования,
feature-флагов и unsafe-политики: стоит дороже (отдельная единица пересборки, потеря
инлайнинга между крейтами — лечится LTO). Режьте на крейты, когда есть причина: разный
темп изменений, отдельная публикация, запрет unsafe в домене, параллельная сборка,
переиспользование в другом бинаре.
типы, инварианты, правила
зависимости: serde, thiserror"] APP["orders-app
use-cases и порты-трейты
зависит только от domain"] INF["orders-infra
адаптеры: sqlx, reqwest, rdkafka
реализует порты"] API["orders-api (bin)
axum, конфиг, composition root"] CLI["orders-cli (bin)
админ-операции, миграции"] end APP --> DOM INF --> APP INF --> DOM API --> APP API --> INF CLI --> APP CLI --> INF style DOM fill:#7fae5b22,stroke:#7fae5b style APP fill:#5b8dd622,stroke:#5b8dd6 style INF fill:#d1873f22,stroke:#d1873f style API fill:#c97fa822,stroke:#c97fa8
Правило зависимостей то же, что в гексагональной архитектуре: стрелки смотрят внутрь.
orders-domain не знает ни про HTTP, ни про SQL — и это проверяется механически, потому
что у него в Cargo.toml просто нет таких зависимостей. Код-ревью не нужно. Общая теория —
в architecture-patterns;
здесь важно именно исполнение на Rust.
# Корневой Cargo.toml: версии и линты в одном месте
[workspace]
resolver = "3" # MSRV-aware разрешение версий (edition 2024)
members = ["crates/*", "xtask"]
[workspace.package]
edition = "2024"
rust-version = "1.85" # MSRV заявлен явно и проверяется в CI
[workspace.dependencies]
tokio = { version = "1", features = ["rt-multi-thread", "macros", "signal"] }
thiserror = "2"
sqlx = { version = "0.8", default-features = false,
features = ["postgres", "runtime-tokio", "tls-rustls", "macros"] }
[workspace.lints.rust]
unsafe_code = "forbid" # в бизнес-крейтах unsafe запрещён физически
[workspace.lints.clippy]
unwrap_used = "warn" # unwrap в проде — решение, а не привычка
expect_used = "warn"
todo = "deny"
Крейт-член подключает это через edition.workspace = true, tokio.workspace = true и
обязательную секцию [lints] workspace = true. Три грабли workspace:
- Забытый
[lints] workspace = true— линты молча не применяются к крейту. - Унификация фич. При
cargo build --workspaceфичи общей зависимости объединяются по всем членам:orders-domainможет неожиданно собраться сsqlx/postgres, потому что её включилorders-infra. Проверяйтеcargo build -p orders-domainиcargo tree -e features -p orders-domain. - Один
Cargo.lockна весь workspace — это благо для воспроизводимости, но версию нельзя поднять для одного члена, только для всех.
Коммитьте Cargo.lock всегда: у библиотек он не влияет на потребителей, но делает
воспроизводимым ваш собственный CI
(Cargo FAQ).
Порты и адаптеры: трейты вместо DI-контейнера
DI-контейнер в Rust не нужен: его роль играет main. Она читает конфиг, создаёт адаптеры и
складывает их в состояние приложения — это и есть composition root, единственное место, где
встречаются абстракция и реализация.
// crates/orders-app/src/ports.rs — что нужно сценариям, но не как это сделано
pub trait OrderRepo: Send + Sync + 'static {
async fn find(&self, id: OrderId) -> Result<Option<Order>, RepoError>;
async fn save(&self, order: &Order) -> Result<(), RepoError>;
}
async fn в трейте стабилен с Rust 1.75 — но попытка положить такой трейт в Arc<dyn ...>
даёт самую характерную ошибку прод-Rust:
error[E0038]: the trait `OrderRepo` is not dyn compatible
--> crates/orders-api/src/state.rs:9:22
|
9 | pub repo: Arc<dyn OrderRepo>,
| ^^^^^^^^^^^^^ `OrderRepo` is not dyn compatible
|
note: for a trait to be dyn compatible it needs to adhere to certain rules
--> crates/orders-app/src/ports.rs:3:14
|
2 | pub trait OrderRepo: Send + Sync + 'static {
| --------- this trait is not dyn compatible...
3 | async fn find(&self, id: OrderId) -> Result<Option<Order>, RepoError>;
| ^^^^ ...because method `find` is `async`
= help: consider moving `find` to another trait
(До Rust 1.83 то же самое звучало как «the trait cannot be made into an object» — формулировку переименовали, смысл прежний.)
Причина — в модели исполнения: async fn это сахар над «функция возвращает непрозрачный
тип, свой у каждой реализации». Размер такого типа неизвестен, а vtable нужен фиксированный
боксированный результат. Выходы три: дженерик PlaceOrder<R: OrderRepo> (ноль накладных
расходов и инлайнинг, но тип протекает вверх по слоям, а мономорфизация раздувает код и
время сборки); #[async_trait] (одна аллокация Pin<Box<dyn Future>> на вызов, зато
работает везде); ручной возврат Pin<Box<dyn Future + Send + '_>> (то же самое без
макроса). Практика: на границах приложения — dyn через #[async_trait], внутри горячих
циклов — дженерики. Аллокация теряется на фоне сетевого RTT, а читаемость и время сборки
выигрывают заметно. Механику разбирали в
Типах и трейтах и
Асинхронном Rust.
// crates/orders-api/src/state.rs — состояние приложения, дешёвое для клонирования
#[derive(Clone)]
pub struct AppState {
pub repo: Arc<dyn OrderRepo>, // Clone = один атомарный инкремент
pub payments: Arc<dyn PaymentGateway>,
pub clock: Arc<dyn Clock>, // время как зависимость, иначе тесты нестабильны
pub cfg: Arc<Config>,
}
Arc здесь не «указатель на всякий случай», а точное выражение факта: адаптеры живут столько
же, сколько процесс, и разделяются между всеми задачами. Тесты подставляют in-memory
реализации портов — без базы, без сети, без мок-фреймворков
(Тестирование).
Конфигурация: один типизированный объект, много слоёв
Антипаттерн из каждого первого проекта — std::env::var("DB_URL") россыпью по коду. Он
ломается тремя способами: опечатка находится в рантайме, набор настроек нигде не
документирован, тест не может подменить значение. Рабочая модель — слои со слиянием и
однократной валидацией на старте.
#[derive(Debug, Deserialize)]
pub struct DbConfig {
pub url: String,
pub password: SecretString, // Debug печатает [REDACTED], Serialize запрещён
pub pool_size: NonZeroU32, // «больше нуля» проверяет тип, а не код
}
impl Config {
pub fn load(profile: &str) -> Result<Self, ConfigError> {
use figment::{Figment, providers::{Env, Format, Serialized, Toml}};
let cfg: Config = Figment::from(Serialized::defaults(Config::default()))
.merge(Toml::file("config/base.toml"))
.merge(Toml::file(format!("config/{profile}.toml")))
.merge(Env::prefixed("APP__").split("__")) // APP__DB__POOL_SIZE -> db.pool_size
.extract()?;
cfg.validate()?; // упасть здесь дешевле, чем под нагрузкой
Ok(cfg)
}
fn validate(&self) -> Result<(), ConfigError> {
// Классическая причина 502 при выкатке: под убивают раньше,
// чем успевает завершиться самый долгий запрос.
if self.http.request_timeout >= self.shutdown_grace {
return Err(ConfigError::Invalid("shutdown_grace должен быть больше request_timeout"));
}
Ok(())
}
}
Что тут важно и часто делается неправильно:
- Секреты в
SecretString(крейтsecrecy). Иначе первый жеtracing::info!(?config)отправит пароль в централизованные логи, откуда его не вытрешь. Самая дешёвая по трудозатратам и самая дорогая по последствиям утечка. Durationчерезhumantime_serde, чтобы в конфиге было"3s", а не голое число, про которое никто не помнит — миллисекунды это или секунды.- Валидация одна и на старте. После
loadконфиг неизменяем и живёт вArc. Нужен hot-reload — делайте его явно черезarc_swap::ArcSwap, а не «иногда перечитываем env». - Типы вместо проверок.
NonZeroU32и newtypePgConnectStringсTryFrom<String>превращают валидность в свойство типа — тот же приём, что сMoney. - Хранение и ротация секретов — трек security.
Ошибки: три разных зверя под одним словом
В проде «ошибка» — три несовместимые категории, и главная архитектурная работа в том, чтобы их не смешивать.
Различие операционное, а не стилистическое: доменная ошибка — нормальная работа сервиса (алертить нельзя, иначе дежурный оглохнет), инфраструктурная — кандидат на повтор, баг — то, что должно разбудить разработчика. Разделение повторяет разделение крейтов (детали — в Обработке ошибок):
// orders-domain: точный перечислимый тип. Никакого anyhow в библиотеке.
#[derive(Debug, thiserror::Error)]
pub enum OrderError {
#[error("заказ {0} не найден")]
NotFound(OrderId),
#[error("заказ уже оплачен, повторная оплата запрещена")]
AlreadyPaid,
#[error("сумма {actual} не совпадает с ожидаемой {expected}")]
AmountMismatch { expected: Money, actual: Money },
}
// orders-infra: своя категория, оборачивает чужие ошибки без потери причины
#[derive(Debug, thiserror::Error)]
pub enum RepoError {
#[error("база недоступна")]
Unavailable(#[source] sqlx::Error), // #[source] сохраняет цепочку причин
#[error("конфликт версий при сохранении заказа {0}")]
Conflict(OrderId),
}
На границе HTTP доменная ошибка превращается в контракт API — это единственное место, где знание о статус-кодах вообще существует:
impl axum::response::IntoResponse for ApiError {
fn into_response(self) -> Response {
let (status, code, public) = match &self.0 {
ServiceError::Domain(OrderError::NotFound(_)) =>
(StatusCode::NOT_FOUND, "order_not_found", self.0.to_string()),
ServiceError::Domain(OrderError::AlreadyPaid) =>
(StatusCode::CONFLICT, "already_paid", self.0.to_string()),
ServiceError::Domain(e) =>
(StatusCode::UNPROCESSABLE_ENTITY, "invalid_request", e.to_string()),
// Детали инфраструктуры наружу не отдаём: это и утечка,
// и бессмысленный для клиента текст.
ServiceError::Repo(e) => {
tracing::error!(error = ?e, "ошибка хранилища");
(StatusCode::SERVICE_UNAVAILABLE, "storage_unavailable",
"временная недоступность, повторите позже".into())
}
};
let trace_id = tracing::Span::current().context().span().span_context().trace_id();
(status, Json(json!({ "code": code, "message": public, "trace_id": trace_id.to_string() })))
.into_response()
}
}
trace_id в теле ошибки окупается в первый же инцидент: пользователь присылает скриншот, вы
находите весь путь запроса за секунды вместо получаса грепа.
Паника в проде: не исключение и не «просто краш»
Паника — сигнал «инвариант программы нарушен». В проде она всё равно случается, и от настроек зависит, что именно упадёт.
thread 'tokio-runtime-worker' panicked at crates/orders-api/src/handlers/orders.rs:42:38:
called `Option::unwrap()` on a `None` value
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
| Настройка | Паника в задаче tokio | catch_unwind |
Размер бинаря |
|---|---|---|---|
panic = "unwind" (по умолчанию) |
падает одна задача, JoinHandle даёт Err |
работает | больше на таблицы размотки |
panic = "abort" |
падает весь процесс | не работает | меньше на 5–15% |
Для сетевого сервиса берите unwind: один битый запрос не должен убивать под с сотней живых
соединений. Для CLI и embedded abort разумен. Минимальный прод-набор:
fn install_panic_hook() {
let default = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
// Паника должна попасть в тот же структурированный лог, что и всё остальное,
// иначе она уедет в stderr мимо агрегатора и её никто не увидит.
tracing::error!(panic = %info,
backtrace = %std::backtrace::Backtrace::force_capture(),
"паника: инвариант нарушен");
metrics::counter!("panics_total").increment(1);
default(info);
}));
}
// Слой, превращающий панику обработчика в 500. Работает ТОЛЬКО при panic = "unwind".
let app = Router::new()
.route("/orders/{id}", get(get_order)) // axum 0.8: {id}, а не :id
.layer(tower_http::catch_panic::CatchPanicLayer::new())
.layer(tower_http::timeout::TimeoutLayer::new(cfg.http.request_timeout))
.layer(tower::limit::ConcurrencyLimitLayer::new(cfg.http.max_inflight))
.with_state(state);
Отдельная классика — отравленный мьютекс. Если поток паникует, удерживая
std::sync::Mutex, все последующие lock() возвращают Err(PoisonError), и привычный
.lock().unwrap() каскадом роняет сервис из-за одной первичной паники:
thread 'tokio-runtime-worker' panicked at src/cache.rs:31:27:
called `Result::unwrap()` on an `Err` value: PoisonError { .. }
Лечится parking_lot::Mutex (отравления нет вовсе, к тому же быстрее) либо явным
.unwrap_or_else(|e| e.into_inner()), если состояние переживает частичную запись. Примитивы
разбирались в Бесстрашной конкурентности.
Самая дорогая ошибка async-Rust — блокирующий вызов внутри async-задачи. Он не паникует и не логируется: просто съедает поток рантайма, и сервис «залипает» под нагрузкой без единой ошибки в логе. Часть случаев компилятор ловит:
error: future cannot be sent between threads safely
--> crates/orders-api/src/handlers/orders.rs:24:18
|
24 | tokio::spawn(async move {
| ^^^^^^^^^^ future created by async block is not `Send`
|
= help: within this `async` block, the trait `Send` is not implemented
for `std::sync::MutexGuard<'_, State>`
note: future is not `Send` as this value is used across an await
Но std::fs::read, reqwest::blocking или тяжёлый CPU-цикл он не поймает — их выносят в
tokio::task::spawn_blocking. Признак в проде: латентность растёт нелинейно, CPU не
загружен, tokio-console показывает задачи с большим временем poll.
Наблюдаемость: tracing, а не println!
Де-факто стандарт — tracing, и его модель отличается от
логирования: базовая единица не строка, а span — интервал с контекстом. Контекст
наследуется, поэтому order_id, записанный один раз на входе, окажется во всех вложенных
записях.
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env().unwrap_or_else(|_| "info,orders=debug,sqlx=warn".into()))
.with(fmt::layer().json().with_current_span(true)) // машиночитаемо для агрегатора
.with(tracing_opentelemetry::layer().with_tracer(otel_tracer()?))
.try_init()?;
#[tracing::instrument(
name = "place_order",
skip(self, cmd), // не логируем целиком: PII и карта
fields(order_id = %cmd.order_id, amount = cmd.amount.minor())
)]
pub async fn place_order(&self, cmd: PlaceOrderCmd) -> Result<Order, ServiceError> {
let order = self.repo.find(cmd.order_id).await?
.ok_or(OrderError::NotFound(cmd.order_id))?;
let paid = self.payments.charge(&order, cmd.amount).await?;
self.repo.save(&paid).await?;
tracing::info!("заказ оплачен"); // order_id уже в контексте span, повторять не нужно
Ok(paid)
}
skip — не косметика, а требование приватности: #[instrument] по умолчанию логирует все
аргументы через Debug, включая структуру с телефоном и адресом клиента.
Метрики — metrics + metrics-exporter-prometheus либо OpenTelemetry. Обязательный минимум:
RED (rate/errors/duration) по эндпоинтам, состояние пулов соединений, panics_total, длины
очередей. Инструментальная сторона —
operating-systems
и devops.
Сборка релиза: профили, образ и то, что ломается только в контейнере
[profile.release]
opt-level = 3
lto = "thin" # межкрейтовый инлайнинг; "fat" даёт +3–7%, но сборка вдвое дольше
codegen-units = 16 # 1 = чуть быстрее код, заметно медленнее сборка
panic = "unwind" # сервису нужен CatchPanicLayer
debug = "line-tables-only" # бэктрейс с номерами строк
split-debuginfo = "packed" # символы в отдельный файл, бинарь остаётся компактным
strip = "none" # не стрипать то, что только что сгенерировали
[profile.dev.package."*"]
opt-level = 2 # быстрые зависимости при быстро собираемом своём коде
debug = "line-tables-only" вместе со split-debuginfo почти бесплатны. Без символов
первый же инцидент даст бэктрейс из голых адресов, и вы будете восстанавливать его по коммиту
вручную; экономия мегабайтов этих часов не стоит.
# cargo-chef: слой зависимостей кэшируется независимо от вашего кода
FROM rust:1.85-slim AS chef
RUN cargo install cargo-chef --locked
WORKDIR /app
FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json
FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
RUN cargo chef cook --release --recipe-path recipe.json # пересобирается только при смене Cargo.lock
COPY . .
ENV SQLX_OFFLINE=true # sqlx::query! берёт схему из .sqlx/, а не из живой БД
RUN cargo build --release --bin orders-api
FROM gcr.io/distroless/cc-debian12:nonroot AS runtime
COPY --from=builder /app/target/release/orders-api /usr/local/bin/orders-api
USER nonroot
ENTRYPOINT ["/usr/local/bin/orders-api"]
Грабли, которые проявляются только в контейнере:
- Без
cargo-chefкаждая правка одной строки пересобирает все 200 зависимостей — разница между 30-секундным и 8-минутным CI прячется в этих десяти строках Dockerfile. SQLX_OFFLINE=trueи закоммиченный каталог.sqlx, иначеsqlx::query!полезет в базу во время компиляции и сборка упадёт в CI, где базы нет.- musl против glibc. Статический бинарь удобен, но тянет медленный аллокатор (лечится
jemalloc/mimalloc), аgetaddrinfo/NSS в статике ведёт себя иначе. Дляscratchберитеrustlsвместо OpenSSL:reqwestсdefault-features = false, features = ["rustls-tls"]. distroless/cc, а неdistroless/staticпри линковке с glibc: вstaticнетlibgcc_s, и вы упадёте на первой же размотке стека.
Контейнерная часть подробно — в devops и operating-systems.
Честно про цену: компиляция и кривая обучения
Отрицать бессмысленно: Rust собирается медленно, и это ощущается ежедневно. Порядки для сервиса на 40–60 тысяч строк и ~200 зависимостей:
| Операция | Типичное время |
|---|---|
cargo check после правки листового крейта |
1–4 с |
cargo build (dev) после правки |
8–30 с |
cargo build --release с нуля |
3–10 мин |
то же с lto = "fat" |
6–25 мин |
| Полный CI: fmt + clippy + test + release | 8–20 мин |
Что помогает, по убыванию отношения эффекта к усилиям: cargo check вместо cargo build в
цикле правок (rust-analyzer делает это фоном); быстрый линковщик mold/lld (12 секунд
линковки превращаются в одну); cargo nextest вместо cargo test (в 2–3 раза быстрее);
нарезка на крейты так, чтобы часто изменяемый код был листом графа; sccache в CI;
cargo llvm-lines и cargo bloat, чтобы найти дженерик, породивший гигабайт IR (классика —
обобщённая функция, вызываемая с тридцатью типами там, где хватило бы &dyn); сокращение
зависимостей через cargo machete и default-features = false.
# .cargo/config.toml
[target.x86_64-unknown-linux-gnu]
linker = "clang"
rustflags = ["-C", "link-arg=-fuse-ld=mold"]
[build]
rustc-wrapper = "sccache"
Кривая обучения. Разработчик с опытом C++/Go выходит на продуктивность за 1–3 месяца, приходящий из Python/JS — за 3–6. Первые недели уходят на борьбу с borrow checker, дальше модель усваивается и мешать перестаёт. Планируйте это как реальную статью бюджета: наём сложнее, онбординг дольше, ревью строже.
Ориентир: если узкое место системы — сеть, диск или чужой API, Rust даст дисциплину и предсказуемость, но не скорость. Если узкое место — CPU, память, хвост латентности или безопасность работы с недоверенным вводом, выигрыш измеряется в разы. Типичный компромисс — гибрид: продуктовый код на языке с быстрым циклом, горячее ядро на Rust через FFI или PyO3 (unsafe и FFI). Системный контекст этих решений — темы трека systems-programming: модель памяти процесса, стоимость системных вызовов, статическая и динамическая линковка.
Эксплуатация: то, что происходит после docker push
Graceful shutdown обязателен, иначе каждая выкатка даёт всплеск 502:
async fn shutdown_signal(grace: Duration) {
let term = async {
tokio::signal::unix::signal(SignalKind::terminate()).expect("SIGTERM").recv().await;
};
tokio::select! { _ = tokio::signal::ctrl_c() => {}, _ = term => {} }
HEALTH.set_draining(); // 1. перестаём проходить readiness
tokio::time::sleep(Duration::from_secs(5)).await; // 2. ждём, пока нас уберут из Endpoints
tracing::info!(?grace, "начинаем graceful shutdown");
}
axum::serve(listener, app).with_graceful_shutdown(shutdown_signal(cfg.shutdown_grace)).await?;
Пауза между «перестал быть ready» и «перестал слушать» — не паранойя: в Kubernetes удаление пода из Endpoints и остановка контейнера идут параллельно, и без задержки часть трафика приедет в уже закрытый сокет.
Утечки в Rust возможны. Компилятор гарантирует отсутствие UB, но не отсутствие утечек:
mem::forget безопасен, а цикл Rc живёт вечно. Прод-источники роста памяти — циклы
Rc/Arc без Weak (кэши с обратными ссылками); неограниченные unbounded_channel, где
при отставании консьюмера очередь растёт до OOM; задачи tokio, которые никто не отменяет;
фрагментация кучи, из-за которой RSS не падает после пика. Последнее часто лечится сменой
аллокатора — самая дешёвая оптимизация прод-Rust:
#[cfg(not(target_env = "msvc"))]
#[global_allocator]
static GLOBAL: tikv_jemallocator::Jemalloc = tikv_jemallocator::Jemalloc;
jemalloc устойчивее системного malloc при высокой многопоточной нагрузке и умеет отдавать
профиль кучи (MALLOC_CONF=prof:true), превращая «где у нас утечка» из гадания в измерение.
Методология — performance и
рабочий процесс оптимизации.
Цепочка поставок. Крейты приходят с crates.io транзитивно сотнями. Минимум для прода:
cargo audit (RustSec advisory DB), cargo deny check (лицензии, дубли версий, запрещённые
источники), закреплённый Cargo.lock, а для публикуемых библиотек — cargo semver-checks,
ловящий случайные ломающие изменения публичного API до релиза. Общая картина рисков —
security.
Чек-лист прод-готовности
-
unsafe_code = "forbid"во всех крейтах, кроме явно выделенного FFI-слоя. -
clippy::unwrap_usedвключён; каждый оставшийсяexpectнесёт объяснение. - Домен не зависит ни от одного фреймворка — проверяется составом
Cargo.toml. - Конфиг — один типизированный объект, валидируется на старте, секреты в
SecretString. - Ошибки разделены на доменные / инфраструктурные / баги; наружу не течёт
sqlx::Error. - Panic hook пишет в общий лог;
CatchPanicLayerна HTTP-слое;panic = "unwind". -
tracingв JSON,trace_idв теле ошибки,skipна всех аргументах с PII. - Таймаут и лимит одновременных запросов заданы на каждом внешнем вызове.
- Graceful shutdown с паузой на дренаж; readiness и liveness — разные эндпоинты.
-
debug = "line-tables-only", символы сохранены и доступны для расшифровки бэктрейсов. - Dockerfile с
cargo-chef,SQLX_OFFLINE, non-root, distroless. -
cargo auditиcargo denyв CI;Cargo.lockв репозитории; MSRV зафиксирован.
Мини-итог
Прод-Rust — это дисциплина расстановки границ. Крейты и pub(crate) задают границу знаний,
трейты-порты — границу зависимостей, типы-обёртки — границу допустимых значений, слои
конфигурации — границу между кодом и окружением, таксономия ошибок — границу между штатной
работой и поводом разбудить дежурного. Компилятор проверит вашу расстановку границ неумолимо
и бесплатно, но расставить их за вас не сможет.
Взамен вы получаете то, ради чего всё затевалось: сервис с плоским хвостом латентности, стартующий за 10 мс, занимающий 40 МБ и не подверженный классу ошибок, съедающему бо́льшую часть отладочного времени в других языках. Цена известна и измерима — минуты сборки и месяцы обучения. Стоит она того или нет, зависит от того, что у вас в узком месте.
Источники
- The Rust Book, гл. 7 «Managing Growing Projects»
- The Cargo Book — Workspaces и профили сборки
- Rust API Guidelines — что выносить в
pub - The Rust Performance Book — Compile Times, Nicholas Nethercote
- tracing и tracing-subscriber
- tokio: Graceful Shutdown
- cargo-chef — кэширование зависимостей в Docker
- Luca Palmieri, «Zero To Production In Rust» — zero2prod.com
- Jon Gjengset, «Rust for Rustaceans» — rust-for-rustaceans.com
- RustSec Advisory Database, cargo-deny, cargo-semver-checks
Что дальше
Экосистема и ресурсы: веб, встраиваемое, WASM, куда двигаться дальше — карта крейтов и областей применения: какие фреймворки живы, где Rust уже стандарт индустрии, где всё ещё эксперимент, и как выстроить дальнейшее обучение.