Create a Rust web service, from zero to cloud
This tutorial will describe how to... install Rust, create a new project and manage dependencies, Set up a simple web server, and compile the app and deploy to a virtual server. I'll be working on Ubuntu 20.04 but most of the setup should be the same on macOS or a different flavor of Linux.
Rust is a fantastic general-purpose language with a rich and expressive type system, but one of the reasons the language is so loved is the overall developer experience.
Writing software can be very complex in any language. Working with the language and tools, however, should not be. This is an area where Rust shines!
This tutorial will describe how to...
- Install Rust
- Create a new project and manage dependencies
- Set up a simple web server
- Compile the app and deploy to a virtual server
I'll be working on Ubuntu 20.04 but most of the setup should be the same on macOS or a different flavor of Linux.
Getting set up
Installing Rust
Rust is best installed by a shell script that downloads the rustup
tool, gives a brief overview on the components that will be installed (as well as where they'll go), and prompts us to confirm options.
➜ curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
info: downloading installer
Welcome to Rust!
This will download and install the official compiler for the Rust
programming language, and its package manager, Cargo.
... snip
Current installation options:
default host triple: x86_64-unknown-linux-gnu
default toolchain: stable (default)
profile: default
modify PATH variable: yes
1) Proceed with installation (default)
2) Customize installation
3) Cancel installation
The defaults are sensible so I'll accept them and let the script do its work...
info: profile set to 'default'
info: default host triple is x86_64-unknown-linux-gnu
info: syncing channel updates for 'stable-x86_64-unknown-linux-gnu'
info: latest update on 2021-07-29, rust version 1.54.0 (a178d0322 2021-07-26)
... snip
Rust is installed now. Great!
To get started you may need to restart your current shell.
This would reload your PATH environment variable to include
Cargo's bin directory ($HOME/.cargo/bin).
To configure your current shell, run:
source $HOME/.cargo/env
... and in about 30 seconds (given 60-70 Mbps download speed) all of Rust's glorious tools will be installed, including:
- The
rustc
compiler - The
cargo
package, dependency, and build manager - The
rustfmt
code formatting tool - The
clippy
linter, named after the best thing Microsoft ever created (and made even better by a third party)
It also recommends running source $HOME/.cargo/env
so the PATH
variable in the current shell is updated - future shell sessions should just work, since rustup
updates profiles like ~/.bash_profile
automatically.
If you're following along and have second thoughts at any point, you can simply run rustup self uninstall
to clean your system so that it's free of Rust.
Creating a new project
Now let's create a brand new project that does nothing more than print the typical Hello, world!
to the screen.
➜ cargo new hello
Created binary (application) `hello` package
➜ cd hello
➜ cargo run
Compiling hello v0.1.0 (/home/kevlarr/projects/know/rust-zero-to-cloud/hello)
Finished dev [unoptimized + debuginfo] target(s) in 0.56s
Running `target/debug/hello`
Hello, world!
We cheated a little bit, because cargo
automatically creates a "Hello, world!" app for us by scaffolding a project...
.
├── Cargo.lock
├── Cargo.toml
└── src
└── main.rs
... with a single source file.
We then just cargo run
to compile our new app and run the executable at target/debug/hello
. We could have created, compiled, and executed the file manually...
➜ echo 'fn main() { println!("Hello, world!"); }' > hello.rs
➜ rustc hello.rs
➜ ./hello
Hello, world!
... but cargo
enforces a standard way of laying out a project, managing dependencies, configuring the compiler, and even combining multiple related projects into a workspace.
The most useful cargo
commands are:
cargo run
to check for errors, compile, and then run our appcargo build
to check for errors and compilecargo check
to only check for errors
Compiling can be pretty slow, so use cargo check
while coding for the fastest feedback cycle.
Building a web server
Now let's be nice humans and create a web service that compliments whomever visits our site - feel free to clone the repo.
Adding a framework
We'll start by bootstrapping a new project.
➜ cargo new bee-nice
Created binary (application) `bee-nice` package
➜ cd bee-nice
Actix Web is one of the most established web frameworks, so let's add that as a dependency to our Cargo.toml
manifest file. (Rocket is also an excellent choice, and Axum looks very promising.)
And we'll update our app so that it starts a server with a single route.
A few things:
#[get("/")]
and#[actix_web::main]
are attribute-like procedural macros, which transform our code and enable more succinct code than functions couldasync
&await
can be complicated, but there's a great 'book' to help
Now we cargo build
to download dependencies and then compile our own application code.
➜ cargo build
Updating crates.io index
Downloaded pin-project v1.0.8
Downloaded unicode-bidi v0.3.6
Downloaded miniz_oxide v0.4.4
... lots of other crates
Compiling proc-macro2 v1.0.28
Compiling unicode-xid v0.2.2
Compiling syn v1.0.74
... lots of other crates
Compiling actix-web v3.3.2
Compiling bee-nice v0.1.0 (/home/kevlarr/projects/bee-nice)
Finished dev [unoptimized + debuginfo] target(s) in 1m 21s
Most of that 1m 21s
was spent compiling dependencies. Unless we remove the target/debug/deps
folder or update dependency versions, we won't need to recompile them again.
If we cargo run
we won't see much output, since our application isn't logging anything....
➜ cargo run
Finished dev [unoptimized + debuginfo] target(s) in 0.06s
Running `target/debug/bee-nice`
... but our browser should at least be able to greet the world now!
Returning a compliment
"Hello, world!" might be polite, but it's still not a compliment to the user.
Rust frameworks tend to be modular, and Actix Web is no exception - there is no built-in HTML templating and no automatic static file serving. We will set those up with the Actix Files plugin and Handlebars template engine.
Again, we'll add our new dependencies (including Serde so we can make a custom object to hold the data for the HTML template) to Cargo.toml
.
And then we make some big changes to src/main.rs
, including...
- ... updating our server to optionally accept a
BIND_ADDRESS
environment variable, if we don't wantlocalhost:8080
- ... adding in a
Files
service that will map requests for/public/:some/:file/:path
to files in the./web/public
directory - ... creating a
Handlebars
instance mapped to the./web/templates
directory and injecting it into application state to be retrieved in request handlers
A few other things here:
.unwrap()
is a great way to 'ignore' aboutResult
andOption
types during development, but we should prefer more robust error handling in productionasync fn compliment(hb: Data<Handlebars<'_>>)
is an example of Actix Web's powerful "extractor" pattern, which allows request handlers to specify what data they want to extract from a request or application stateHttpResponse::Ok().content_type(..)
is an example of the builder pattern, a useful strategy for overcoming the lack of function overloads, optional arguments, keyword arguments, etc.
Next, create a basic stylesheet and an HTML template with placeholder variables for an adjective
and a verb
to be supplied at runtime.
Recompile and execute via cargo run
and you should see a friendlier site!
To the clouds
We typically wrap our applications in a Docker image and deploy to some service that natively runs containers. With languages like Rust or Go that compile directly to binaries, we often don't need that level of abstraction because there's far less to manage when running an application:
- There's no underlying runtime or VM
- Dependencies are often statically-linked into the binary
- Rust doesn't even need to be installed to run an application
For now, let's just deploy our app (which consists of the compiled binary, the HTML template, and the CSS file) to a virtual server running Ubuntu 20.04 that...
- ... can accept public traffic
- ... we have root access to via SSH
I'm using a DigitalOcean Droplet because they're simple - and very cheap!
A better binary
Because both my local machine and virtual server are running Ubuntu 20.04, I can simply run...
➜ cargo build --release
... to generate a more-optimized binary. As a comparison, the "release" version is less than 1/10th the size:
➜ find . -name 'bee-nice' -exec ls -lh {} \;
-rwxr-xr-x 1 kevlarr kevlarr 8.1M Aug 19 11:20 ./target/release/bee-nice
-rwxr-xr-x 2 kevlarr kevlarr 100M Aug 19 13:42 ./target/debug/bee-nice
If you are on a different OS than the remote server, you have several options:
- You can try your hand at cross-compilation (which might require troubleshooting)
- You can use the official Docker image to compile for a target platform, either with or without running the app inside a container (see the "Compile your app inside the Docker container" section)
Deploying to the remote machine
Assuming we compiled directly (or used Docker just to compile), let's copy over the relevant files into /opt/bee-nice
on the remote server, making sure that the binary is in the same directory as the ./web
folder.
➜ ssh <USER>@<HOST> "mkdir /opt/bee-nice"
➜ scp target/release/bee-nice <USER>@<HOST>:/opt/bee-nice
➜ scp -r web <USER>@<HOST>:/opt/bee-nice
Now let's remotely start the server in a detached session, binding to 0.0.0.0:80
so any HTTP traffic to our remote server's IP address will go straight to our app. Also, because we naively hard-coded relative paths like web/templates
we need to make sure that the current working directory of the process is correct, hence running with cd /opt/bee-nice && ./bee-nice
rather than /opt/bee-nice/bee-nice
.
➜ ssh <USER>@<HOST> "cd /opt/bee-nice && BIND_ADDRESS=0.0.0.0:80 screen -d -m ./bee-nice"
While this is a terrible idea for a production application, it's enough for now to show how simple it can be. Notice how there's nothing to install on the remote server, other than our compiled app? Pretty nice - and easy to containerize and deploy properly.
If all works as planned, you should now be able to share a link to your beautiful site with anyone whom you want to make marginally happier!
Conclusion
Rust has modern, well-designed tooling that eases pain points seen in other languages, making it trivial to manage different language versions and pull third-party code into our projects. Additionally, working with compiled binaries can greatly simplify deploying and scaling web services.
Overall, beyond being an enjoyable language, Rust offers an enjoyable experience outside of the code itself, which can be as important to developer happiness - and project success - as having a good type system or an active open-source community.