Rust Прод на Rust: структура проекта, модули, конфигурация, ошибки в проде
0%

Прод на Rust: структура проекта, модули, конфигурация, ошибки в проде

Прод на 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 в домене, параллельная сборка, переиспользование в другом бинаре.

Правило зависимостей то же, что в гексагональной архитектуре: стрелки смотрят внутрь. 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:

  1. Забытый [lints] workspace = true — линты молча не применяются к крейту.
  2. Унификация фич. При cargo build --workspace фичи общей зависимости объединяются по всем членам: orders-domain может неожиданно собраться с sqlx/postgres, потому что её включил orders-infra. Проверяйте cargo build -p orders-domain и cargo tree -e features -p orders-domain.
  3. Один 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") россыпью по коду. Он ломается тремя способами: опечатка находится в рантайме, набор настроек нигде не документирован, тест не может подменить значение. Рабочая модель — слои со слиянием и однократной валидацией на старте.

Слои конфигурации: defaults, файлы, окружение, CLI и итоговый объект

#[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 и newtype PgConnectString с 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 и радиус пересборки в workspace

Отрицать бессмысленно: 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 МБ и не подверженный классу ошибок, съедающему бо́льшую часть отладочного времени в других языках. Цена известна и измерима — минуты сборки и месяцы обучения. Стоит она того или нет, зависит от того, что у вас в узком месте.

Источники

Что дальше

Экосистема и ресурсы: веб, встраиваемое, WASM, куда двигаться дальше — карта крейтов и областей применения: какие фреймворки живы, где Rust уже стандарт индустрии, где всё ещё эксперимент, и как выстроить дальнейшее обучение.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

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

Доска запросов