Чтобы развернуть веб-сервис на Rust на сервере Cargo, нужно сделать правильно четыре вещи: репозиторий с Cargo.toml и закоммиченным Cargo.lock в корне, программа, которая слушает 0.0.0.0 и порт из переменной окружения SERVER_PORT, строка в стандартном выводе после начала прослушивания (по ней панель понимает, что сервер запустился) и тариф с достаточным объёмом памяти, чтобы скомпилировать дерево зависимостей - это совсем не то число, которое нужно готовому бинарнику. Когда эти четыре пункта выполнены, остаётся подключить GitHub и привыкнуть заглядывать в консоль. Речь идёт именно о языке Rust - если вы искали survival-игру, вам нужен гайд по выделенному серверу Rust.
Этот материал идёт в том порядке, в котором вы будете сталкиваться с темой: что происходит при старте сервера, подготовка репозитория, порт и строка определения старта, сколько длится сборка и сколько RAM она съедает на каждом тарифе, конфигурация и секреты, деплой из GitHub и чтение логов, когда что-то идёт не так. Для более глубокой настройки самого сервиса - рабочие потоки tokio, поведение аллокатора, корректная остановка, пулы соединений - у нас есть отдельная статья про деплой веб-сервиса на Rust с axum. Эта же - о том, как пройти путь от репозитория до работающего и доступного процесса.
Что происходит при старте сервера Cargo#
Сервер Rust (Cargo) на RE:NODE - это контейнер с toolchain Rust, в файлах которого лежит ваш проект. Готовый образ вашего приложения он не запускает. При каждом старте он собирает и запускает ваш код через cargo, и из этого следует вывод, который стоит усвоить сразу: старт - это компиляция. Перезапуск - это компиляция. Деплой - это компиляция. Cargo умеет не повторять уже сделанную работу, поэтому большинство таких компиляций короткие, но первая короткой не бывает никогда.
Если вы подключили репозиторий GitHub, порядок при каждом старте такой: загрузить выбранную ветку, затем собрать, затем запустить. Диаграмма показывает весь жизненный цикл.
Новый сервер не пуст. Пока вы ничего не задеплоили, в нём лежит крошечный проект на Rust со стандартной библиотекой - Cargo.toml и src/main.rs, который на адресе сервера отдаёт страницу «что делать дальше». Он нужен, чтобы первый запуск показал что-то работающее, а не падение, и записывается только в пустой сервер; ваши файлы он никогда не перезаписывает. Откройте адрес один раз, убедитесь, что сервер отвечает, и замените проект своим.
Сама команда запуска видна на вкладке Startup. Прочитайте её один раз, чтобы знать, собирает ли она в режиме release и какой бинарник запускает - дальнейшие разделы предполагают release-сборку, а только такую и стоит деплоить.
Подготовьте репозиторий#
Cargo нужно совсем немного, но в правильном месте. Сервер собирает проект из корня того, что загрузил, поэтому Cargo.toml должен лежать на верхнем уровне репозитория, а не в папке backend/. Если сервис живёт в workspace, положите workspace-Cargo.toml в корень и убедитесь, что по умолчанию запускается именно нужный бинарник.
Чек-лист:
- Закоммитьте `Cargo.lock`. Для бинарника ему место в репозитории. Без него каждая сборка заново разрешает зависимости, и два деплоя одного и того же коммита могут дать разные программы.
- Игнорируйте `target/`. Это кэш сборки, часто в несколько гигабайт, и он привязан к конкретной машине. Ему место в
.gitignore, но не в коммите. - Выберите бинарник по умолчанию. Если в пакете больше одного бинарника (
src/bin/*.rsплюсsrc/main.rs),cargo runотказывается угадывать. Укажитеdefault-runв[package], чтобы угадывать не пришлось. - Никогда не коммитьте `.env`. Секреты хранятся на сервере, а не в Git. Подробнее об этом ниже.
- Зафиксируйте toolchain, если зависите от свежей возможности. Поле
rust-versionвCargo.tomlзаставит старый компилятор упасть с понятным сообщением вместо страницы запутанных ошибок.
Минимальный Cargo.toml для сервиса на axum с профилем release, настроенным на сборку на небольшом сервере, а не на последние проценты скорости:
[package]name = "api"version = "0.1.0"edition = "2021"default-run = "api"[dependencies]axum = "0.8"tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal"] }tracing = "0.1"tracing-subscriber = { version = "0.3", features = ["env-filter"] }[profile.release]strip = "symbols"debug = falseНомера версий приведены как пример; берите то, что выдаст cargo add в тот день, когда вы это делаете. Обратите внимание, чего в этом профиле нет: ни lto = true, ни codegen-units = 1. Оба параметра делают бинарник чуть быстрее и меньше, а сборку - заметно медленнее и прожорливее. На тарифе в 1 GB они могут превратить сборку, которая завершается, в сборку, которая не завершается. Добавите их позже, когда убедитесь, что сборка помещается.
И .gitignore, с которого должен начинаться любой репозиторий на Rust:
/target.envПривяжитесь к выделенному порту#
К вашему тарифу прилагается один выделенный порт. Панель кладёт его в окружение как SERVER_PORT, и ваша программа должна слушать именно этот порт на 0.0.0.0. Две ошибки объясняют большинство обращений вида «оно работает, но я не могу достучаться»:
- Прослушивание `127.0.0.1` или `localhost`. Внутри контейнера это означает «доступно только изнутри этого контейнера». В логе написано, что программа слушает, а снаружи никто подключиться не может.
- Порт, прописанный в коде. Туториалы любят
3000и8080. Ваш выделенный порт - ни тот, ни другой, если только не по совпадению.
Читайте порт из окружения и падайте с громким сообщением, если его нет, вместо того чтобы тихо переключаться на порт, который никто не пробрасывает:
use axum::{routing::get, Router};use tokio::net::TcpListener;#[tokio::main]async fn main() { tracing_subscriber::fmt() .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) .init(); let port: u16 = std::env::var("SERVER_PORT") .expect("SERVER_PORT is not set") .parse() .expect("SERVER_PORT is not a number"); let app = Router::new().route("/health", get(|| async { "ok" })); let listener = TcpListener::bind(("0.0.0.0", port)).await.unwrap(); println!("listening on 0.0.0.0:{port}"); axum::serve(listener, app).await.unwrap();}Эквивалент на actix-web - та же идея, только другой билдер:
#[actix_web::main]async fn main() -> std::io::Result<()> { let port: u16 = std::env::var("SERVER_PORT").expect("SERVER_PORT").parse().unwrap(); let server = HttpServer::new(|| App::new().route("/health", web::get().to(health))) .workers(2) .bind(("0.0.0.0", port))?; println!("listening on 0.0.0.0:{port}"); server.run().await}Параметр .workers(2) важен на небольшом тарифе. По умолчанию actix-web запускает по одному воркеру на каждый логический CPU, который видит, а контейнер, ограниченный половиной ядра, всё равно может видеть число ядер всей машины. На младших тарифах двух воркеров вполне достаточно.
Если хотите, чтобы тот же код работал на вашем ноутбуке, оставьте запасной вариант только для локальной разработки - за явной переменной или проверкой cfg(debug_assertions), - чтобы release-сборка на сервере никогда не привязывалась молча не к тому порту.
Строка определения старта#
На этом шаге спотыкаются почти все, и ровно один раз. Панель решает, что сервер закончил запуск, следя в консоли за определённым текстом. Пока она этот текст не увидит, сервер показывается как «Starting», как бы хорошо ваш сервис ни работал на самом деле. На сервере Cargo этот текст вы задаёте сами на вкладке Startup, и это должна быть строка, которую ваша программа действительно печатает.
Проще всего сделать так, как в примерах выше: напечатать одну простую, неизменную строку после привязки к порту и использовать её фрагмент как текст определения старта.
listening on 0.0.0.0:Несколько деталей делают это надёжным:
- Печатайте её после успешного `bind`, а не в начале
main. Тогда «запущен» означает «принимает соединения», а именно это вы и хотите видеть в панели. - Используйте `println!`, а не только `tracing`. Стандартный вывод Rust буферизуется построчно, поэтому
println!попадает в консоль сразу, как только выполняется. Строкаtracingтоже подойдёт, но её формат зависит от настроек subscriber, и безобидное изменение формата (метки времени, цвета, вывод в JSON) может перестать совпадать с текстом. - Не включайте номер порта в текст определения, если не уверены, что он никогда не изменится. Фрагмент
listening on 0.0.0.0:переживёт смену порта, а полная строка - нет. - Держите её отдельно от вывода сборки. Во время сборки cargo печатает строки
CompilingиFinished. Никогда не используйте текст, который печатает сам cargo, иначе панель сочтёт сервер запущенным ещё до того, как ваша программа стартовала.
Время сборки и память на каждом тарифе#
Вот неприятная часть хостинга Rust. Программа, которую вы деплоите, в простое занимает десятки мегабайт. Компилятору, который её собирает, может понадобиться заметно больше гигабайта на несколько минут, с пиком на финальной линковке. Тариф должен подходить под сборку, а не только под бинарник.
Тарифы Rust (Cargo) на RE:NODE начинаются от $4 в месяц и делятся на пять уровней:
| Тариф | RAM / диск / CPU | Сборка на сервере |
|---|---|---|
| Starter | 1 GB / 5 GB / 0.5 vCPU | Небольшие сервисы, по одной задаче за раз |
| Standard | 2 GB / 10 GB / 1 vCPU | Типичный сервис на axum или actix |
| Advanced | 4 GB / 20 GB / 1.5 vCPU | Большие деревья, sqlx, местами LTO |
| Pro | 6 GB / 35 GB / 2 vCPU | Тяжёлые деревья, LTO без забот |
| Premium | 8 GB / 50 GB / 3 vCPU | Workspace, несколько бинарников |
Правый столбец - наше практическое правило, а не гарантия. Стоимость сборки зависит от того, сколько крейтов в Cargo.lock, насколько они насыщены макросами (serde derive, sqlx, всё, где есть процедурные макросы) и нужно ли компилировать C-зависимости. По опыту, ориентир примерно такой: небольшой сервис с парой десятков крейтов первую release-сборку делает за несколько минут на одном ядре; типичный веб-сервис с двумя-тремя сотнями крейтов собирается дольше, а на половине ядра это может занять десять минут и больше. Последующие сборки, затрагивающие только ваш код, гораздо быстрее, потому что скомпилированные зависимости лежат в target/ и используются повторно.
Важно понимать, что происходит, когда сборка не помещается в тариф, потому что на ошибку это не похоже. На RE:NODE контейнер, достигший лимита памяти, останавливается ядром и запускается заново с чистого листа, а не уходит в swap. Поэтому сборка, которой не хватило памяти, выглядит так: консоль обрывается посреди Compiling, и сервер стартует снова, зачастую упираясь в ту же стену. Если вы видите такую картину, дело в памяти, и есть три способа это исправить, в порядке предпочтения:
- Собирайте по одному крейту за раз. По умолчанию Cargo компилирует крейты параллельно, и каждая параллельная задача держит собственную память. Если выставить число задач равным единице, пик резко снижается ценой времени сборки.
- Уберите LTO и `codegen-units = 1` из профиля release. Это самые дорогие настройки на этапе линковки.
- Перейдите на тариф выше. Если сервису нужны триста крейтов, ему нужен тариф, который в состоянии их скомпилировать. Смена тарифа не пересобирает сервер.
Первое исправление можно хранить прямо в репозитории, тогда оно путешествует вместе с кодом и применяется к каждой сборке на сервере:
[build]jobs = 1Диску нужно уделить столько же внимания. Каталог target/ для среднего веб-сервиса обычно занимает от одного до трёх гигабайт, а если в нём лежат и debug-, и release-артефакты, то больше. Диск Starter в 5 GB может целиком заполниться одним кэшем сборки. Если вы когда-либо запускали на сервере debug-сборку, удаление target/debug в файловом менеджере легко освобождает гигабайт. Но не удаляйте весь target/ по привычке - так вы выбросите кэш зависимостей, и при следующем старте всё скомпилируется заново.
Для совсем маленьких тарифов есть законная альтернатива: соберите release-бинарник в CI или на своей машине под x86_64-unknown-linux-gnu (или -musl для статического бинарника), загрузите его по SFTP и запускайте его. Вы теряете удобство сборки из исходников при каждом старте, и это работает, только если команда запуска запускает ваш бинарник, а не cargo, так что прежде чем пробовать, проверьте вкладку Startup.
Переменные окружения и секреты#
Конфигурация хранится в переменных окружения, читается один раз при старте, и программа отказывается запускаться, если какой-то переменной нет. Так забытый URL базы данных остановит деплой в консоли, а не сломает всё в три часа ночи на первом запросе, которому он нужен.
На сервере Cargo есть два места, где можно задать значения:
- Вкладка Startup. На ней показаны переменные сервера, включая
SERVER_PORT. Используйте её для переменных, которые панель выводит там. - Файл `.env` в файлах сервера. Создайте его в файловом менеджере или загрузите по SFTP и подгружайте крейтом
dotenvyв началеmain. В Git он не попадает никогда.
DATABASE_URL=postgres://app_user:generated-password@db-host:5432/appRUST_LOG=info,tower_http=info,sqlx=warnRUST_BACKTRACE=1APP_BASE_URL=https://api.example.comfn main() { // Загружает .env, если он есть; настоящие переменные окружения важнее. let _ = dotenvy::dotenv(); // ...}dotenvy::dotenv() не перезаписывает переменные, уже заданные в окружении, поэтому SERVER_PORT из панели всегда победит случайное значение в файле. Именно такого поведения вы и хотите.
Если вы подключили репозиторий к GitHub, предварительная проверка на вкладке GitHub сообщает о закоммиченном файле .env по имени, не открывая его. Если она когда-нибудь его пометит, удалите его из репозитория и замените все секреты, которые в нём были, потому что в истории они остались. Общие правила - где хранить секреты, как их менять, что никогда не писать в лог - описаны в статье про переменные окружения и секреты.
В тарифы для приложений входят слоты баз данных: создайте слот в панели, и вы получите сгенерированные хост, пользователя и пароль, которые нужно подставить в DATABASE_URL. Если ваш сервис использует проверяемые на этапе компиляции запросы sqlx, сборке нужна либо живая база, либо подготовленные данные запросов: выполните cargo sqlx prepare локально, закоммитьте каталог .sqlx и задайте SQLX_OFFLINE=true, чтобы при сборке на сервере не пытались обратиться к базе. Отдельный сервер PostgreSQL, если вы перерастёте слот, - это тариф хостинга баз данных.
Деплой из GitHub#
Загрузка исходников по SFTP работает, но именно деплой из GitHub делает жизнь с сервером Cargo приятной. Панель подключается через GitHub App с недолговечными токенами, поэтому приватные репозитории работают без того, чтобы вы где-либо вставляли персональный токен доступа. GitHub - единственный поддерживаемый провайдер.
На вкладке GitHub сервера вы привязываете репозиторий и ветку, и там есть два переключателя:
- Pull on every start. Каждый старт загружает ветку перед сборкой. Тогда перезапуск из панели одновременно является и обновлением.
- Deploy on push. Push в ветку перезапускает работающий сервер или запускает остановленный, и тот загружает и собирает новый коммит.
Каждый деплой оставляет запись, так что видно, какой коммит и когда ушёл. Перед привязкой предварительная проверка читает репозиторий - список файлов и несколько небольших файлов, но не полный клон - и перечисляет, что сломается, начиная с худшего, с готовым исправлением для вставки: закоммиченный .env, привязка не к тому хосту или порту и похожие ошибки, которые иначе проявились бы только после первого старта. Она проверяет очевидные сценарии сбоев, но ваш код не компилирует, так что ошибка типов по-прежнему появится в консоли.
Две вещи специфичны для Rust, и о них стоит позаботиться заранее:
- Деплой - это перезапуск, а перезапуск - это сборка. Старый процесс останавливается, новый коммит компилируется, и только потом новый бинарник начинает слушать. Для изменения в вашем собственном коде это обычно от секунд до минуты; для изменения в
Cargo.lock, обновляющего широко используемую зависимость, это могут быть несколько минут, в течение которых сервис недоступен. Объединяйте обновления зависимостей, а не отправляйте их по одному, и выкатывайте их, когда никто не полагается на сервис. - Сломанная сборка означает сервер, который не поднимается. Если новый коммит не компилируется, старого бинарника, который бы обслуживал запросы в это время, нет. Запускайте
cargo build --releaseи тесты локально или в CI до того, как push дойдёт до ветки деплоя, и подумайте о деплое из отдельной ветки, в которую попадают только прошедшие проверку слияния.
О том, как сократить это окно, подробнее в статье про деплой без простоя на небольшом сервере: там описано, что достижимо, а что нет с одним процессом на одном порту, а в статье про предварительные проверки, которые ловят сломанные деплои проверка на вкладке GitHub разобрана подробно.
Логи и консоль#
Всё, что ваша программа пишет в стандартный вывод и стандартный поток ошибок, появляется в консоли панели вживую и без фильтров, рядом с графиками памяти, CPU и диска относительно лимитов тарифа. Эта консоль - первое место, куда нужно смотреть при любой проблеме из этой статьи: вывод сборки, строка определения старта, паники и ваши собственные логи оказываются именно там.
Для структурированного логирования стандартный выбор - tracing с tracing-subscriber, пишущим в стандартный вывод, а переменная RUST_LOG задаёт, что пропускается:
RUST_LOG=info,tower_http=debug,sqlx=warn,hyper=warnФормат такой: уровень по умолчанию, затем переопределения по крейтам. tower_http=debug вместе с TraceLayer на роутере логирует каждый запрос со статусом и задержкой - это самая полезная одиночная строка, которую можно добавить при отладке. Когда закончите, верните info; логи запросов на уровне debug на нагруженном сервисе - просто шум.
Задайте RUST_BACKTRACE=1. Это ничего не стоит, пока что-то не упадёт с паникой, а потом превращает «thread 'tokio-runtime-worker' panicked» в стек, с которым можно работать. Если нужны номера строк в этих backtrace, задайте в профиле release debug = "line-tables-only"; бинарник станет больше, но процесс не станет прожорливее.
Следите за графиком памяти во время первой сборки и в первый час трафика. Пик сборки покажет, правильно ли подобран тариф. Работающий процесс должен выйти на ровную линию заметно ниже лимита; линия, которая при постоянной нагрузке стабильно ползёт вверх, заслуживает разбора до того, как упрётся в потолок. В статье о логах, которые стоит хранить описано, что хранить и как долго, когда сервис вышел на стабильный режим.
Домен и HTTPS#
Отдавайте обычный HTTP на выделенном порту и оставьте TLS слоту прокси. В тарифы для приложений он входит: направьте A-запись вашего домена на показанный адрес, и сертификат будет выпущен и продлён автоматически в окне 21 день. Ваш сервис никогда не касается файла сертификата.
За прокси настоящий адрес клиента находится в заголовке X-Forwarded-For, а не в самом соединении, поэтому ConnectInfo в axum вернёт вам прокси, а не посетителя. Если вы ограничиваете частоту запросов или ведёте лог по IP, читайте заголовок. Везде, где вы строите абсолютные URL - редиректы, колбэки OAuth, ссылки в письмах, - используйте настроенный базовый URL, например APP_BASE_URL, а не угадывайте его по запросу. Остальную часть цепочки заголовков объясняет статья что делает reverse proxy, а websocket за reverse proxy пригодится, если вы используете axum::extract::ws.
Решение проблем#
Сервер вечно висит на «Starting». Текст определения старта на вкладке Startup не совпадает ни с чем, что печатает ваша программа. Скопируйте в него фрагмент своей строки о прослушивании и перезапустите.
Консоль обрывается посреди `Compiling`, и сервер перезапускается. Сборка упёрлась в лимит памяти. Задайте jobs = 1 в .cargo/config.toml, уберите LTO из профиля release или перейдите на тариф выше.
`error: could not find Cargo.toml`. Проект лежит в подкаталоге репозитория. Переместите его в корень или положите туда workspace-Cargo.toml.
`cargo run` could not determine which binary to run. В пакете несколько бинарников. Добавьте default-run в [package].
Оно работает, но адрес не отвечает. Привязка идёт к 127.0.0.1 или к порту, прописанному в коде. Привяжитесь к 0.0.0.0 и читайте SERVER_PORT.
`Address already in use`. Порт уже занят чем-то ещё в том же дереве процессов - часто это второй listener, который вы добавили для метрик на том же порту. У одного тарифа один выделенный порт; отдавайте метрики на маршруте основного роутера.
Каждый деплой идёт так же долго, как первый. Что-то очищает target/, либо Cargo.lock не закоммичен, и зависимости каждый раз разрешаются по-разному. Закоммитьте lock-файл и не трогайте кэш.
`set DATABASE_URL to use query macros`. sqlx пытается проверять запросы на этапе компиляции. Закоммитьте каталог .sqlx из cargo sqlx prepare и задайте SQLX_OFFLINE=true.
Диск заполнен. Обычно виноват target/. Удалите target/debug, если он есть, и проверьте, что вы не собираете debug и release одновременно.
FAQ#
Это про игру Rust или про язык программирования Rust?
Про язык. Хостинг Rust (Cargo) запускает программы, написанные на Rust и собранные через cargo, - веб-сервисы, ботов, воркеры. Survival-игра - отдельный продукт со своей настройкой, она описана в нашем гайде по выделенному серверу Rust.
Какой тариф нужен для сервиса на axum?
Сам сервис запустился бы и на самом маленьком тарифе. Решает сборка. Небольшой сервис собирается на 1 GB с одной задачей компиляции за раз; типичному веб-сервису с несколькими сотнями крейтов гораздо комфортнее на тарифе 2 GB и выше. Следите за графиком памяти во время первой сборки и выбирайте тариф по тому, что он покажет.
Почему перезапуск идёт так долго?
Потому что каждый старт собирает проект через cargo, прежде чем запустить его. Когда зависимости закэшированы в target/, перезапуск без изменений в коде - это в основном cargo, подтверждающий, что пересобирать нечего, а изменение в вашем коде перекомпилирует только ваш крейт. Снова медленно становится из-за изменённого Cargo.lock.
Можно ли запускать что-то, кроме веб-сервера?
Да. Бот для Discord или Telegram, обработчик очереди или планировщик задач работают так же: программа запускается, печатает строку определения старта и продолжает работать. Слушать порт ей необязательно, если до неё не нужно достучаться, хотя небольшой health-маршрут всё равно полезен для проверки снаружи.
Обязательно ли деплоить из GitHub?
Нет. Можно загрузить исходники через файловый менеджер или SFTP и перезапустить сервер, и он соберёт то, что лежит в его файлах. Деплой из GitHub - удобный путь и единственный Git-провайдер, к которому подключается панель.
Можно ли использовать nightly toolchain?
Сервер собирает проект toolchain-ом из своего образа, и способа переключить его на nightly мы не описываем. Если вы зависите от возможности из nightly, практический путь - собрать где-то ещё и загрузить бинарник; сначала проверьте команду запуска, чтобы убедиться, что она умеет запускать готовый бинарник.


