RE:NODE
Обзор хостинга

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

Deploy a Rust web service: axum, release builds and memory

Putting an axum or actix-web service into production: release profiles, build time and RAM on small plans, binding, tokio threads and graceful shutdown.

Эта статья пока на английском. Мы её переводим.

0 прочтений

The good news about deploying Rust is that the artefact is one binary with no runtime to install, and it will happily serve a busy site in twenty megabytes of memory. The awkward news is that producing that binary is the expensive part, and it is expensive in exactly the resource a small plan has least of. A release build of a modest web service wants a gigabyte or two of RAM and several minutes of CPU; the thing it produces then idles in a fraction of what a comparable Python or Node service needs.

So the whole deployment question for Rust is: where does the build happen, and what does the running process need afterwards. This post answers both, plus the settings that change build time and binary size, how to bind correctly inside a container, how many tokio worker threads you actually want on a fraction of a core, and what breaks.

What a Rust service needs at run time#

Almost nothing. The compiler links your code and your dependencies into a single executable. There is no interpreter, no virtual environment, no node_modules, and no package manager on the server. What the binary still needs is:

  • A C library it was linked against. The default target x86_64-unknown-linux-gnu links dynamically against glibc, so the binary requires a glibc at least as new as the one it was built with. This is the cause of version 'GLIBC_2.34' not found when a binary built on a new distribution is copied to an older one. Building against x86_64-unknown-linux-musl produces a statically linked binary that runs anywhere, at a small performance cost in allocation-heavy work.
  • TLS certificates, if it makes outbound HTTPS calls. Crates using native-tls read the system store; rustls with webpki-roots carries its own, which is one less thing to depend on.
  • Memory measured in tens of megabytes. A small axum service with a database pool typically idles under 20 MB of resident memory and grows with connections and buffers, not with code size.

One glibc detail is worth knowing before you are confused by it. Under concurrency, glibc's allocator creates per-thread arenas and rarely returns freed memory to the operating system, so resident memory can climb and then plateau at a level well above what your program is really holding. Setting MALLOC_ARENA_MAX=2 in the environment usually flattens it, and switching to jemalloc or mimalloc through a crate is the heavier fix. Do not chase this before you have a graph showing it.

axum or actix-web#

Both are mature, both are fast enough that your database will be the bottleneck, and both run on tokio. The difference is in the shape of the code.

axum is built on hyper and tower, so its middleware is tower middleware, which the wider ecosystem also uses. Handlers are plain async functions and the routing is data:

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 has its own actor-derived runtime layered on tokio, its own middleware traits, and a builder-style server:

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

Pick axum if you want to reuse tower layers and the hyper ecosystem, actix-web if you prefer its API or need something only it has. Do not pick either on benchmark numbers; the gap between them is far smaller than the gap between one database round trip and two.

Note the .workers(2) in the actix example. By default it starts one worker per logical CPU, which is the same trap discussed below for tokio.

Building: debug, release and the profile that matters#

cargo build produces a debug binary: no optimisation, overflow checks on, and often five to ten times slower at run time. It is never what you deploy. cargo build --release produces the optimised binary at target/release/<name>.

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

--locked makes the build fail rather than silently update Cargo.lock. For an application - as opposed to a library - the lock file belongs in the repository, and --locked is what makes committing it mean something.

The release profile is tunable, and the knobs trade build time for run-time speed and binary size:

Cargo.toml
[profile.release]lto = "thin"codegen-units = 1strip = "symbols"
SettingDefault in releaseWhat changing it does
opt-level3"s" or "z" optimise for size instead of speed
ltofalse"thin" or true inline across crates; slower, larger build memory
codegen-units161 gives better code and a slower, more memory-hungry build
stripfalse"symbols" removes debug symbols, often halving the binary
debugfalse1 keeps line tables so panics have useful backtraces
panic"unwind""abort" is smaller and faster, but any panic kills the process

panic = "abort" deserves a pause. With unwinding, a panic inside an axum handler kills that request's task and the process carries on, and tower_http's catch-panic layer can turn it into a 500. With abort, one panic in one request takes the whole service down and the container restarts. That is not automatically wrong - a crash that restarts clean is sometimes better than a process in an unknown state - but choose it deliberately.

Also worth knowing: incremental compilation is off in the release profile, so a release build recompiles your crate from scratch every time. Dependencies are still cached in target/, which is why the first build is long and the tenth is short. Never cargo clean as part of a deploy.

Build time and memory on a small plan#

This is the part that decides your plan. The compiler is a memory-hungry program, and the final link with lto and codegen-units = 1 is the peak.

Project sizeFirst release buildRebuild of your cratePeak build memory
Small service, ~30 crates1-3 minutes10-30 seconds~1 GB
Typical web service, 150-300 crates4-12 minutes20-60 seconds1-2 GB
Heavy tree with LTO on15 minutes or more1-3 minutes2-4 GB

Those are ranges from experience, not promises - the real figure depends on your dependency tree, how much of it is macro-heavy, and the CPU share you bought. Three things follow:

  1. The smallest tier is for running, not building. With 1 GB and half a core, a build of anything non-trivial either takes twenty minutes or gets stopped when it reaches the memory limit. On RE:NODE the container is stopped and restarted clean at the limit rather than being allowed to swap, so an over-ambitious build shows up as a restart mid-compile rather than a slow one.
  2. Limit parallelism before you limit ambition. cargo build -j 1 or CARGO_BUILD_JOBS=1 compiles one crate at a time and cuts peak memory substantially, at the cost of wall-clock time. It is the difference between a build that finishes and one that does not.
  3. Turn LTO on last. Build with the defaults until the service is running. lto = "thin" and codegen-units = 1 are worth a few percent of run-time speed and a smaller binary; they are not worth an unbuildable server.

The alternative is to build somewhere else and ship the binary. Build locally or in CI for the right target, then upload the executable over SFTP and chmod +x it. This is a legitimate way to run Rust on a 1 GB plan, and its price is that the running artefact is no longer produced by the server from source you can see - keep the build reproducible, and keep the commit hash in the binary if you can.

HTTPSHTTPGitHub reposource and Cargo.locktarget/cached dependenciesClientscargo buildrelease profileProxy slotTLS and headersBinarytarget/release/api
From a push to a running binary

The disk cost of that cache is real: a target/ directory for a middling web service is commonly 1-3 GB once debug and release artefacts are both in it. Application plans start at 5 GB of disk, so building on the server means watching it. Deleting target/debug is usually the easiest gigabyte you will ever free.

Binding, ports and threads#

Two settings, both of which have a default that is wrong inside a container.

The first is the bind address. 127.0.0.1 reaches nothing outside the container; use 0.0.0.0 and take the port from the environment rather than hard-coding it. On panels derived from Pterodactyl the allocated port arrives as an environment variable shown on the Startup tab; elsewhere it is often PORT.

The second is the tokio worker thread count. Tokio sizes its multi-threaded runtime from available_parallelism(). Recent Rust versions do take cgroup CPU quotas into account on Linux, but the result is rounded and never smaller than one, so a container throttled to a fraction of a core can still end up with more scheduler threads than it has any use for. Setting it explicitly costs one line and removes the question:

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

One or two worker threads is right for the 1-2 GB tiers; match the CPU share above that. More threads on a hard CPU quota do not add throughput, they add context switching and, on a quota, longer stalls when the container is throttled.

Configuration itself is ordinary environment variables. std::env::var is enough for a handful; envy or figment deserialise the whole environment into a struct and fail at start-up if something is missing, which is the behaviour you want. Whichever you use, read every variable once during start-up so a missing value stops the process immediately rather than at three in the morning on a rare code path. Environment variables and secrets covers where they belong.

Behind a proxy: headers, websockets and TLS#

Terminating TLS in the service is possible with axum-server and rustls, and is usually the wrong choice on a hosting plan: you then own certificate renewal, HTTP to HTTPS redirects and the reload when a certificate changes. Let the proxy do it and serve plain HTTP on your allocated port.

What that means for your code:

  • The client's address is in X-Forwarded-For, not in the connection. In axum, ConnectInfo gives you the proxy; a tower layer or a manual header read gives you the client. Trust the header only because you know the proxy set it.
  • The scheme is in X-Forwarded-Proto. Anywhere you build an absolute URL - redirects, links in emails, OAuth callbacks - read it, or you will send people to http:// and cause a redirect loop.
  • Websockets need the upgrade headers forwarded and an idle timeout longer than your heartbeat. Websockets behind a reverse proxy is the whole story, and it applies identically to axum::extract::ws.

Application plans include a proxy slot: point an A record at the address shown, and the certificate is issued and renewed automatically inside a 21-day window, with the real client address arriving in X-Forwarded-For. What a reverse proxy does explains the rest of the header chain.

Logging, panics and graceful shutdown#

Use tracing with tracing-subscriber, read the filter from the environment, and write to standard output so the console has it:

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

RUST_BACKTRACE=1 costs nothing until something panics, and then it is the difference between a useful report and the word "panicked". Keep debug = 1 in the release profile if you want line numbers in those backtraces; it inflates the binary but not the memory in use.

Handle SIGTERM, because that is what a stop or restart sends. Axum has shutdown built in:

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

Inside shutdown_signal, await both tokio::signal::ctrl_c() and a SignalKind::terminate() stream, then return. Serve stops accepting new connections, lets in-flight requests finish, and exits. Without it, the process is killed after the grace period and whatever was in flight is dropped - including a half-written response and any buffered work. Graceful shutdown and health checks covers the health endpoint side, which for Rust is a route returning a constant and nothing more.

Databases and connection pools#

sqlx and tokio-postgres with deadpool are the common choices. The number that matters is the pool size, because a pool holds its connections open whether or not you are using them:

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

Five is a reasonable default for a service on one or two cores. A Rust service holds one pool per process rather than one per worker, which is a genuine advantage over the process-per-worker model of gunicorn - ten Python workers with a pool each is fifty connections, where the equivalent Rust service is five. Connection pools and limits has the arithmetic for the other side of that connection.

One deployment-specific trap: sqlx's compile-time checked queries need a live database at build time unless you prepare them offline. Run cargo sqlx prepare locally, commit the .sqlx directory it generates, and set SQLX_OFFLINE=true in the build environment. Otherwise your server build fails with "set DATABASE_URL to use query macros" at the least convenient moment. Panel database slots come with a generated host, user and password; a separate PostgreSQL or MongoDB instance is a database hosting plan.

What goes wrong#

`linker 'cc' not found`. The build needs a C toolchain for crates with native code. On a managed plan this is part of the image; on your own machine install the distribution's build essentials package.

The build is killed with no error. Out of memory. Drop to -j 1, turn off LTO, or build somewhere with more RAM and upload the binary.

`version 'GLIBC_2.34' not found`. The binary was built against a newer glibc than the server has. Build on an older base, or target musl for a static binary.

`Text file busy` when replacing the binary. You are overwriting an executable that is running. Stop the service first, or write to a new name and move it into place.

Connection refused, but the log says it is listening. Bound to 127.0.0.1. Bind 0.0.0.0.

The first deploy takes ten minutes, then one takes ten minutes again. A dependency version changed, so the cached artefacts were invalidated. That is expected after a Cargo.lock update; if it happens on every deploy, something is clearing target/.

Memory climbs under load and never comes back down. Probably allocator arenas rather than a leak. Try MALLOC_ARENA_MAX=2 and watch the graph before rewriting anything.

A panic returns nothing to the client instead of a 500. Without a catch-panic layer, the task dies and the connection drops. Add tower_http::catch_panic::CatchPanicLayer so the client gets a response and your logs get the panic.

FAQ#

How much memory does a Rust web service need?

At run time, far less than you expect: a small axum service with a database pool commonly idles under 20 MB and handles real traffic inside 100 MB. The memory question for Rust is about the build, not the process - that is what needs a gigabyte or more.

Can I build on the smallest plan?

A small project, yes, with -j 1 and no LTO. A service with a few hundred crates in its tree will be painful at 1 GB and half a core. Either move up a tier for the plan you build on, or build in CI and upload the binary.

axum or actix-web?

Either. axum fits better if you want tower middleware and the hyper ecosystem; actix-web has its own well-documented world and is equally production-ready. Performance is not a deciding factor at the scale where the choice is being made.

Do I need a reverse proxy in front of it?

You need something to terminate TLS and renew the certificate, and a proxy is the simplest thing that does. The service itself is a competent HTTP server, so the proxy is there for certificates, forwarded headers and request limits rather than for speed.

Should Cargo.lock be committed?

For a binary, yes, always, and build with --locked so a deploy fails loudly rather than resolving something new. For a library the convention is the opposite, which is where the confusion comes from.

Why does the panel console show nothing?

Rust writes to standard output unbuffered when it is not a terminal, so the usual cause is that nothing has logged yet - a service with no tracing subscriber installed prints nothing at all, however much it is doing. Install a subscriber and set RUST_LOG.


Комментарии

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

0/2000