RE:NODE

Приложения13 мин чтения

Веб-сервис на Rust: axum, релизные сборки и память

Выкатываем сервис на axum или actix-web в продакшен: релизные профили, время сборки и RAM на небольших тарифах, привязка, потоки tokio и graceful shutdown.

0 прочтений

Хорошая новость о деплое на Rust в том, что результат - один бинарник без среды выполнения, который спокойно обслуживает оживлённый сайт, занимая двадцать мегабайт памяти. Неприятная новость в том, что получение этого бинарника - самая затратная часть, причём затратная именно по тому ресурсу, которого на небольшом тарифе меньше всего. Релизная сборка скромного веб-сервиса требует гигабайт или два RAM и несколько минут работы процессора; то, что она производит, потом простаивает на доле того, что нужно сопоставимому сервису на Python или Node.

Так что весь вопрос деплоя для Rust сводится к тому, где происходит сборка и что нужно работающему процессу после неё. В этой статье ответ на оба, а ещё настройки, которые меняют время сборки и размер бинарника, правильная привязка внутри контейнера, сколько рабочих потоков tokio вам на самом деле нужно на доле ядра и что ломается.

Что нужно сервису на Rust во время работы#

Почти ничего. Компилятор собирает ваш код и зависимости в один исполняемый файл. Нет интерпретатора, виртуального окружения, node_modules и менеджера пакетов на сервере. Но бинарнику всё же нужно:

  • Библиотека C, с которой он был скомпонован. Целевая платформа по умолчанию x86_64-unknown-linux-gnu компонуется динамически с glibc, так что бинарнику нужна glibc не старше той, с которой он собирался. Это причина ошибки version 'GLIBC_2.34' not found, когда бинарник, собранный на новом дистрибутиве, копируют на более старый. Сборка под x86_64-unknown-linux-musl даёт статически скомпонованный бинарник, который работает где угодно, ценой небольшой потери производительности в задачах с интенсивным выделением памяти.
  • Сертификаты TLS, если он делает исходящие HTTPS-вызовы. Крейты с native-tls читают системное хранилище; rustls с webpki-roots несёт своё, и это одна зависимость меньше.
  • Память, измеряемая десятками мегабайт. Небольшой сервис на axum с пулом соединений с базой обычно простаивает на менее чем 20 МБ резидентной памяти и растёт вместе с соединениями и буферами, а не с размером кода.

Одну деталь про glibc стоит знать заранее, чтобы она вас не озадачила. При конкурентной работе аллокатор glibc создаёт арены для каждого потока и редко возвращает освобождённую память операционной системе, поэтому резидентная память может расти, а потом выходить на плато, заметно превышающее то, что программа реально удерживает. Обычно её выравнивает MALLOC_ARENA_MAX=2 в окружении, а более тяжёлое решение - переключиться на jemalloc или mimalloc через крейт. Не гонитесь за этим, пока не увидите график, который это показывает.

axum или actix-web#

Оба зрелые, оба достаточно быстры, чтобы узким местом была ваша база данных, и оба работают на tokio. Разница - в форме кода.

axum построен на hyper и tower, так что его middleware - это middleware tower, которое использует и вся остальная экосистема. Обработчики - обычные асинхронные функции, а маршрутизация - это данные:

src/main.rs
use axum::{routing::get, Router};use tokio::net::TcpListener;#[tokio::main]async fn main() {    let app = Router::new().route("/health", get(|| async { "ok" }));    let port = std::env::var("SERVER_PORT").unwrap_or_else(|_| "8000".into());    let listener = TcpListener::bind(format!("0.0.0.0:{port}")).await.unwrap();    axum::serve(listener, app).await.unwrap();}

У actix-web своя среда выполнения, выросшая из акторов и надстроенная над tokio, свои трейты middleware и сервер в стиле билдера:

src/main.rs
HttpServer::new(|| App::new().route("/health", web::get().to(health)))    .workers(2)    .bind(("0.0.0.0", port))?    .run()    .await

Выбирайте axum, если хотите переиспользовать слои tower и экосистему hyper, и actix-web, если вам больше нравится его API или нужно то, что есть только в нём. Не выбирайте ни один по цифрам бенчмарков: разрыв между ними куда меньше, чем разница между одним обращением к базе и двумя.

Обратите внимание на .workers(2) в примере с actix. По умолчанию он запускает по одному воркеру на логический процессор, а это та же ловушка, что разобрана ниже для tokio.

Сборка: debug, release и профиль, который имеет значение#

cargo build даёт отладочный бинарник: без оптимизации, с включёнными проверками переполнения и часто в пять-десять раз медленнее во время работы. Его никогда не деплоят. cargo build --release создаёт оптимизированный бинарник в target/release/<name>.

bash
$ cargo build --release --locked$ ./target/release/api

--locked заставляет сборку упасть, а не молча обновить Cargo.lock. Для приложения, в отличие от библиотеки, lock-файл принадлежит репозиторию, и именно --locked делает его коммит осмысленным.

Релизный профиль настраивается, и параметры обменивают время сборки на скорость работы и размер бинарника:

Cargo.toml
[profile.release]lto = "thin"codegen-units = 1strip = "symbols"
ПараметрЗначение по умолчанию в releaseЧто даёт изменение
opt-level3"s" или "z" оптимизируют по размеру, а не по скорости
ltofalse"thin" или true встраивают код между крейтами; сборка медленнее и требует больше памяти
codegen-units161 даёт более качественный код и более медленную, более прожорливую по памяти сборку
stripfalse"symbols" убирает отладочные символы, часто вдвое уменьшая бинарник
debugfalse1 сохраняет таблицы строк, чтобы у паник были полезные backtrace
panic"unwind""abort" меньше и быстрее, но любая паника убивает процесс

На panic = "abort" стоит остановиться. При раскрутке стека паника внутри обработчика axum убивает задачу этого запроса, а процесс продолжает работать, и слой catch-panic из tower_http может превратить её в 500. При abort одна паника в одном запросе роняет весь сервис, и контейнер перезапускается. Это не обязательно неправильно - падение с чистым перезапуском иногда лучше процесса в неизвестном состоянии, - но выбирайте это осознанно.

Стоит знать и то, что инкрементальная компиляция в релизном профиле отключена, так что релизная сборка каждый раз перекомпилирует ваш крейт с нуля. Зависимости при этом по-прежнему кэшируются в target/, поэтому первая сборка длинная, а десятая короткая. Никогда не выполняйте cargo clean как часть деплоя.

Время сборки и память на небольшом тарифе#

Именно эта часть определяет ваш тариф. Компилятор - программа, прожорливая по памяти, а пик приходится на финальную компоновку с lto и codegen-units = 1.

Размер проектаПервая релизная сборкаПересборка вашего крейтаПик памяти при сборке
Небольшой сервис, ~30 крейтов1-3 минуты10-30 секунд~1 GB
Типичный веб-сервис, 150-300 крейтов4-12 минут20-60 секунд1-2 GB
Тяжёлое дерево с LTO15 минут и больше1-3 минуты2-4 GB

Это диапазоны из опыта, а не обещания: реальная цифра зависит от вашего дерева зависимостей, того, насколько оно насыщено макросами, и купленной доли CPU. Отсюда три вывода:

  1. Самый маленький тариф - для работы, а не для сборки. На 1 ГБ и половине ядра сборка чего-либо нетривиального либо занимает двадцать минут, либо обрывается, когда упирается в лимит памяти. В RE:NODE контейнер на лимите останавливается и чисто перезапускается, а не уходит в подкачку, поэтому чересчур амбициозная сборка проявляется как перезапуск посреди компиляции, а не как медленная работа.
  2. Ограничивайте параллелизм раньше, чем амбиции. cargo build -j 1 или CARGO_BUILD_JOBS=1 компилирует по одному крейту за раз и заметно снижает пик памяти ценой времени по часам. Это разница между сборкой, которая заканчивается, и той, что нет.
  3. Включайте LTO в последнюю очередь. Собирайте с настройками по умолчанию, пока сервис не заработает. lto = "thin" и codegen-units = 1 дают несколько процентов скорости работы и меньший бинарник; сервер, который не собирается, они не стоят.

Альтернатива - собирать в другом месте и отправлять готовый бинарник. Соберите локально или в CI под нужную платформу, затем загрузите исполняемый файл по SFTP и выполните chmod +x. Это законный способ запускать Rust на тарифе в 1 ГБ, и его цена в том, что работающий артефакт больше не создаётся сервером из исходников, которые вы видите, - делайте сборку воспроизводимой и по возможности храните хеш коммита в бинарнике.

HTTPSHTTPРепозиторий GitHubисходники и Cargo.locktarget/кэш зависимостейКлиентыcargo buildрелизный профильСлот проксиTLS и заголовкиБинарникtarget/release/api
От push до работающего бинарника

Стоимость этого кэша по диску вполне реальна: каталог target/ для среднего веб-сервиса обычно занимает 1-3 ГБ, когда в нём лежат и отладочные, и релизные артефакты. Тарифы для приложений начинаются с 5 ГБ диска, так что при сборке на сервере за ним нужно следить. Удаление target/debug - обычно самый лёгкий гигабайт, который вы когда-либо освободите.

Привязка, порты и потоки#

Две настройки, и у обеих значение по умолчанию внутри контейнера неверно.

Первая - адрес привязки. 127.0.0.1 ничего не достигает снаружи контейнера; используйте 0.0.0.0 и берите порт из окружения, а не прописывайте его жёстко. В панелях на основе Pterodactyl выделенный порт приходит как переменная окружения, показанная на вкладке Startup; в других местах это часто PORT.

Вторая - число рабочих потоков tokio. Tokio определяет размер многопоточной среды выполнения по available_parallelism(). Свежие версии Rust учитывают квоты CPU из cgroup на Linux, но результат округляется и никогда не бывает меньше единицы, так что контейнер, ограниченный долей ядра, всё равно может получить больше потоков планировщика, чем ему нужно. Явное указание стоит одной строки и снимает вопрос:

src/main.rs
#[tokio::main(flavor = "multi_thread", worker_threads = 2)]async fn main() { }

Один-два рабочих потока - правильное значение для тарифов на 1-2 ГБ; выше подбирайте по доле CPU. Больше потоков при жёсткой квоте CPU не добавляют пропускной способности, а добавляют переключения контекста и, при квоте, более долгие остановки, когда контейнер притормаживают.

Сама конфигурация - обычные переменные окружения. Для нескольких хватит std::env::var; envy или figment разбирают всё окружение в структуру и падают при запуске, если чего-то не хватает, - а именно такое поведение вам и нужно. Что бы вы ни использовали, читайте каждую переменную один раз при старте, чтобы отсутствующее значение останавливало процесс сразу, а не в три часа ночи на редкой ветке кода. Где им место, описано в статье переменные окружения и секреты.

За прокси: заголовки, websockets и TLS#

Завершать TLS в самом сервисе можно через axum-server и rustls, но на хостинговом тарифе это обычно неверный выбор: тогда вам принадлежат продление сертификатов, редиректы с HTTP на HTTPS и перезагрузка при смене сертификата. Пусть этим занимается прокси, а вы отдавайте обычный HTTP на выделенном порту.

Что это значит для вашего кода:

  • Адрес клиента лежит в X-Forwarded-For, а не в соединении. В axum ConnectInfo даёт вам прокси; слой tower или ручное чтение заголовка даёт клиента. Доверяйте заголовку только потому, что знаете: его выставил прокси.
  • Схема лежит в X-Forwarded-Proto. Везде, где вы строите абсолютный URL - редиректы, ссылки в письмах, OAuth callback, - читайте её, иначе вы отправите людей на http:// и получите цикл редиректов.
  • Для websockets нужно пробрасывать заголовки апгрейда и иметь таймаут простоя длиннее вашего heartbeat. Вся история изложена в статье websockets за обратным прокси, и она одинаково применима к axum::extract::ws.

В тарифы для приложений входит слот прокси: направьте запись A на показанный адрес, и сертификат будет выпущен и продлён автоматически в окне в 21 день, а настоящий адрес клиента придёт в X-Forwarded-For. Остальную цепочку заголовков объясняет статья что делает обратный прокси.

Логи, паники и graceful shutdown#

Используйте tracing с tracing-subscriber, читайте фильтр из окружения и пишите в стандартный вывод, чтобы консоль его получала:

env
RUST_LOG=info,tower_http=debug,sqlx=warnRUST_BACKTRACE=1

RUST_BACKTRACE=1 ничего не стоит, пока что-то не запаникует, а потом это разница между полезным отчётом и словом «panicked». Оставьте debug = 1 в релизном профиле, если хотите видеть номера строк в этих backtrace; бинарник от этого раздувается, а вот используемая память - нет.

Обрабатывайте SIGTERM, потому что именно его отправляют при остановке или перезапуске. В axum завершение встроено:

src/main.rs
axum::serve(listener, app)    .with_graceful_shutdown(shutdown_signal())    .await    .unwrap();

Внутри shutdown_signal дождитесь и tokio::signal::ctrl_c(), и потока SignalKind::terminate(), затем вернитесь. Serve перестаёт принимать новые соединения, даёт завершиться запросам в работе и выходит. Без этого процесс убивают по истечении льготного периода, и всё, что было в работе, теряется, включая наполовину записанный ответ и любую буферизованную работу. Сторона проверки состояния разобрана в статье graceful shutdown и проверки состояния; для Rust это маршрут, возвращающий константу, и больше ничего.

Базы данных и пулы соединений#

Обычный выбор - sqlx и tokio-postgres с deadpool. Важно число размера пула, потому что пул держит свои соединения открытыми, пользуетесь вы ими или нет:

code
PgPoolOptions::new()    .max_connections(5)    .acquire_timeout(Duration::from_secs(5))    .connect(&database_url)    .await?

Пять - разумное значение по умолчанию для сервиса на одном-двух ядрах. Сервис на Rust держит один пул на процесс, а не на воркер, и это настоящее преимущество перед моделью «процесс на воркер» у gunicorn: десять воркеров Python по пулу на каждого - это пятьдесят соединений, а эквивалентный сервис на Rust - пять. Арифметику для другой стороны этого соединения даёт статья пулы соединений и лимиты.

Одна ловушка, специфичная для деплоя: запросы sqlx с проверкой во время компиляции требуют живой базы данных при сборке, если вы не подготовили их офлайн. Выполните cargo sqlx prepare локально, закоммитьте сгенерированный каталог .sqlx и задайте SQLX_OFFLINE=true в окружении сборки. Иначе сборка сервера упадёт с сообщением «set DATABASE_URL to use query macros» в самый неудобный момент. Слоты баз данных в панели приходят со сгенерированными хостом, пользователем и паролем; отдельный экземпляр PostgreSQL или MongoDB - это тариф хостинга баз данных.

Что идёт не так#

`linker 'cc' not found`. Для крейтов с нативным кодом сборке нужен набор инструментов C. На управляемом тарифе он входит в образ; на своей машине установите пакет с базовыми средствами сборки для вашего дистрибутива.

Сборка убита без ошибки. Закончилась память. Перейдите на -j 1, отключите LTO или соберите там, где RAM больше, и загрузите бинарник.

`version 'GLIBC_2.34' not found`. Бинарник собран с более новой glibc, чем на сервере. Собирайте на более старой базе или выберите musl для статического бинарника.

`Text file busy` при замене бинарника. Вы перезаписываете исполняемый файл, который запущен. Сначала остановите сервис или запишите под новым именем и переместите на место.

Connection refused, хотя в логе написано, что он слушает. Привязка к 127.0.0.1. Привяжитесь к 0.0.0.0.

Первый деплой занимает десять минут, а потом ещё один занимает десять минут снова. Изменилась версия зависимости, и кэшированные артефакты стали недействительны. Это ожидаемо после обновления Cargo.lock; если так происходит при каждом деплое, что-то очищает target/.

Память растёт под нагрузкой и не возвращается. Скорее всего, арены аллокатора, а не утечка. Попробуйте MALLOC_ARENA_MAX=2 и понаблюдайте за графиком, прежде чем что-либо переписывать.

Паника ничего не возвращает клиенту вместо 500. Без слоя catch-panic задача умирает, а соединение обрывается. Добавьте tower_http::catch_panic::CatchPanicLayer, чтобы клиент получил ответ, а ваши логи - панику.

FAQ#

Сколько памяти нужно веб-сервису на Rust?

Во время работы куда меньше, чем вы ожидаете: небольшой сервис на axum с пулом соединений с базой обычно простаивает на менее чем 20 МБ и выдерживает настоящий трафик в пределах 100 МБ. Вопрос памяти для Rust касается сборки, а не процесса, - именно ей нужен гигабайт и больше.

Можно ли собирать на самом маленьком тарифе?

Небольшой проект - да, с -j 1 и без LTO. Сервис с несколькими сотнями крейтов в дереве будет мучительным на 1 ГБ и половине ядра. Либо перейдите на ступень выше для тарифа, на котором собираете, либо собирайте в CI и загружайте бинарник.

axum или actix-web?

Любой. axum подходит лучше, если нужны middleware tower и экосистема hyper; у actix-web свой хорошо документированный мир, и он так же готов к продакшену. Производительность не решающий фактор на масштабе, где этот выбор делается.

Нужен ли обратный прокси перед ним?

Нужно что-то, что завершает TLS и продлевает сертификат, и прокси - самая простая вещь, которая это делает. Сам сервис - вполне способный HTTP-сервер, так что прокси нужен ради сертификатов, пробрасываемых заголовков и лимитов на запросы, а не ради скорости.

Нужно ли коммитить Cargo.lock?

Для бинарника - да, всегда, и собирайте с --locked, чтобы деплой падал громко, а не разрешал что-то новое. Для библиотеки принята противоположная конвенция, отсюда и путаница.

Почему консоль панели ничего не показывает?

Rust пишет в стандартный вывод без буферизации, когда это не терминал, так что обычная причина в том, что ничего пока не залогировано: сервис без установленного подписчика tracing не печатает ничего, сколько бы он ни делал. Установите подписчик и задайте RUST_LOG.


Комментарии

Полностью анонимно: без аккаунта, без почты, без cookie. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.

0/2000