Use Multi-Stage Docker Builds For Statically-Linked Rust Binaries
I’m making a static website in Rust. Last time I did this, I used Docker to automate the deployment. I was frustrated at how much bandwidth I was using shuffling around these massive build images, but the convenience was too hard to pass up and I wasn’t rebuilding the image often, so just left it.
With this new method, my final production Docker image for the whole application is 6.85MB. I can live with that.
I’m using Askama for templating, which actually compiles your typechecked templates into your binary. The image assets I have are all SVG, which is really XML, so I can use include_str!() for those along with things like manifest.json and robots.txt and all CSS, which includes their entire file contents directly in my compiled binary as a &'static str. As a result, I don’t really need a full Rust build environment or even any asset files present to run the compiled output.
This time around, I did my homework and found this blog post by @alexbrand, which demonstrates this technique. Instead of just bundling up with all the build dependencies in place, you can use a multi-stage build to generate the compiled output first and then copy it into a minimal container for distribution. Here’s my adaptation for this project:
That’s it! The top section labelled builder uses the rust:1.40.0 base image, which has everything needed to build my binary with rust. It targets x86_64-unknown-linux-musl. The musl library is an alternative libc designed for static linking as opposed to dynamic. Rust has top-notch support for this (apparently). This means the resulting binary is entirely self-contained - it has no environment requirements at all.
The second section, which defines the actual distribution, just starts from scratch, not even alpine or whatever other minimal Docker base image I’d otherwise use. You can use COPY --from=builder to reference the previous Docker stage. This docker image has nothing at all in it. This means my image really just contains my binary, no Linux userland to be found! All with one invocation of docker build.
The middle part, with cargo new, makes a dummy application leveraging the docker cache for dependencies. This means that while you’re developing, subsequent runs of docker build won’t need to rebuild every dependency in your Rust application every time, it will only rebuild what’s changed just like building locally. Marvelous!
The remote server pulls down my whopping 6.85MB image and spins it up. I was immediately able to connect. This minuscule image just sips at disk space, memory, and CPU, so I’m going to be able to stretch my $5/month lowest-possible-tier DigitalOcean droplet as far as it can possibly go. The flashbacks I’m having from trying to do something similar with Clojure are terrifying…
Add in some scripts so you don’t have to remember those commands, and my whole build and deploy process is distilled to a few keystrokes.
Why would I use anything else?
For those keeping score, yes, I’ve already scrapped Stencil in favor of Askama/Hyper. Within a day I had re-implemented all previous work in about a half of the code and a small fraction of the bundle size. Yes, there’s a bigger post (and GitHub template) about it brewing, and no, I’m not even sorry. KISS and all…
Photo by Richard Sagredo on Unsplash