Хорошая новость о деплое на 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, которое использует и вся остальная экосистема. Обработчики - обычные асинхронные функции, а маршрутизация - это данные:
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 и сервер в стиле билдера:
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>.
$ cargo build --release --locked$ ./target/release/api--locked заставляет сборку упасть, а не молча обновить Cargo.lock. Для приложения, в отличие от библиотеки, lock-файл принадлежит репозиторию, и именно --locked делает его коммит осмысленным.
Релизный профиль настраивается, и параметры обменивают время сборки на скорость работы и размер бинарника:
[profile.release]lto = "thin"codegen-units = 1strip = "symbols"| Параметр | Значение по умолчанию в release | Что даёт изменение |
|---|---|---|
opt-level | 3 | "s" или "z" оптимизируют по размеру, а не по скорости |
lto | false | "thin" или true встраивают код между крейтами; сборка медленнее и требует больше памяти |
codegen-units | 16 | 1 даёт более качественный код и более медленную, более прожорливую по памяти сборку |
strip | false | "symbols" убирает отладочные символы, часто вдвое уменьшая бинарник |
debug | false | 1 сохраняет таблицы строк, чтобы у паник были полезные 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 |
| Тяжёлое дерево с LTO | 15 минут и больше | 1-3 минуты | 2-4 GB |
Это диапазоны из опыта, а не обещания: реальная цифра зависит от вашего дерева зависимостей, того, насколько оно насыщено макросами, и купленной доли CPU. Отсюда три вывода:
- Самый маленький тариф - для работы, а не для сборки. На 1 ГБ и половине ядра сборка чего-либо нетривиального либо занимает двадцать минут, либо обрывается, когда упирается в лимит памяти. В RE:NODE контейнер на лимите останавливается и чисто перезапускается, а не уходит в подкачку, поэтому чересчур амбициозная сборка проявляется как перезапуск посреди компиляции, а не как медленная работа.
- Ограничивайте параллелизм раньше, чем амбиции.
cargo build -j 1илиCARGO_BUILD_JOBS=1компилирует по одному крейту за раз и заметно снижает пик памяти ценой времени по часам. Это разница между сборкой, которая заканчивается, и той, что нет. - Включайте LTO в последнюю очередь. Собирайте с настройками по умолчанию, пока сервис не заработает.
lto = "thin"иcodegen-units = 1дают несколько процентов скорости работы и меньший бинарник; сервер, который не собирается, они не стоят.
Альтернатива - собирать в другом месте и отправлять готовый бинарник. Соберите локально или в CI под нужную платформу, затем загрузите исполняемый файл по SFTP и выполните chmod +x. Это законный способ запускать Rust на тарифе в 1 ГБ, и его цена в том, что работающий артефакт больше не создаётся сервером из исходников, которые вы видите, - делайте сборку воспроизводимой и по возможности храните хеш коммита в бинарнике.
Стоимость этого кэша по диску вполне реальна: каталог 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, но результат округляется и никогда не бывает меньше единицы, так что контейнер, ограниченный долей ядра, всё равно может получить больше потоков планировщика, чем ему нужно. Явное указание стоит одной строки и снимает вопрос:
#[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, а не в соединении. В axumConnectInfoдаёт вам прокси; слой tower или ручное чтение заголовка даёт клиента. Доверяйте заголовку только потому, что знаете: его выставил прокси. - Схема лежит в
X-Forwarded-Proto. Везде, где вы строите абсолютный URL - редиректы, ссылки в письмах, OAuth callback, - читайте её, иначе вы отправите людей наhttp://и получите цикл редиректов. - Для websockets нужно пробрасывать заголовки апгрейда и иметь таймаут простоя длиннее вашего heartbeat. Вся история изложена в статье websockets за обратным прокси, и она одинаково применима к
axum::extract::ws.
В тарифы для приложений входит слот прокси: направьте запись A на показанный адрес, и сертификат будет выпущен и продлён автоматически в окне в 21 день, а настоящий адрес клиента придёт в X-Forwarded-For. Остальную цепочку заголовков объясняет статья что делает обратный прокси.
Логи, паники и graceful shutdown#
Используйте tracing с tracing-subscriber, читайте фильтр из окружения и пишите в стандартный вывод, чтобы консоль его получала:
RUST_LOG=info,tower_http=debug,sqlx=warnRUST_BACKTRACE=1RUST_BACKTRACE=1 ничего не стоит, пока что-то не запаникует, а потом это разница между полезным отчётом и словом «panicked». Оставьте debug = 1 в релизном профиле, если хотите видеть номера строк в этих backtrace; бинарник от этого раздувается, а вот используемая память - нет.
Обрабатывайте SIGTERM, потому что именно его отправляют при остановке или перезапуске. В axum завершение встроено:
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. Важно число размера пула, потому что пул держит свои соединения открытыми, пользуетесь вы ими или нет:
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. Мы храним имя, которое вы ввели, текст и время - больше ничего. Количество ссылок ограничено, разметка не отображается.