Shipping Rust CLIs with Docker

Published on 08/6/2020

I recently found a nice and clean way of building and distributing Rust CLI apps using docker. For my work I created a Rust app that wraps some AWS SDK functions to make my day-to-day work with AWS easier. This CLI is very focused on the way we work at Synoa and therefore unfortunately not open source. The tech does not matter much, as we can just create a tiny example “app” for this blog post.

Rust code

Below is the example Rust code we will use.

use std::env;

fn main() {
    let args: Vec<String> = env::args().collect();
    println!("{:?}", args);
}

This code only prints whatever arguments were passed to the script.

Building the binary - in docker

Next we will build the binary in docker using a “multi-stage build” setup. This way we do not need to manage our local Rust environment, for example we don’t need to keep our Rust targets up-to-date or make sure other contributors have the same environment setup - the compiling is all done inside Docker.

We start by declaring a builder container. This container is only used for compiling the binary.

FROM clux/muslrust:1.45.0-stable as builder
WORKDIR /volume
COPY . .
RUN cargo build --release

These four lines do the following:

  • Create a container based on clux/muslrust
  • Give it a name of builder (so we can reference it later)
  • Declare the working directory to be /volume
  • Copy over all files from the current directory to /volume
  • Run the cargo build --release command which builds our Rust binary

Creating the docker image

Next, in the same Dockerfile, we declare the actual image. This is where we copy the compiled binary from the “builder” container.

FROM alpine
# Copy the compiled binary from the builder container
COPY --from=builder /volume/target/x86_64-unknown-linux-musl/release/docker-cli-sample .
# Pass all arguments etc to binary
ENTRYPOINT [ "/docker-cli-sample" ]

So what happens here?

  • First with FROM alpine we use the slim Alpine Linux as base image. Depending on what our binary is doing we could also use FROM scratch to not use a base image at all. For my case I chose Alpine because we needed to make HTTPS calls and they didn’t work in a “scratch” image.
  • COPY copies the binary from the builder container and places it in the root directory of our container
  • ENTRYPOINT [ "/docker-cli-sample" ] means we execute the docker-cli-sample binary when we run the container

All together, the Dockerfile looks like this.

FROM clux/muslrust:1.45.0-stable as builder
WORKDIR /volume
COPY . .
RUN cargo build --release

FROM alpine
COPY --from=builder /volume/target/x86_64-unknown-linux-musl/release/docker-cli-sample .
ENTRYPOINT [ "/docker-cli-sample" ]

Building the image and running the container

We can build the image now with the docker build command. Open a terminal and type:

docker build -t kevingimbel/rust-docker-cli-sample:1.0 .  

Then run the image in a container. --rm makes sure the container is removed after it is executed, as we do not need it anymore.

$ docker run --rm kevingimbel/rust-docker-cli-sample:1.0 -hello -world
["/docker-cli-sample", "-hello", "-world"]

Setting up the CLI and docker

To execute this container like a CLI script we add the following to ~/.bashrc (for Bash) or ~/.zshrc (for zsh).

alias docker-rust-cli='docker run --rm kevingimbel/rust-docker-cli-sample:1.0'

Source the file by running the following, then test the command.

# bash
source ~/.bashrc
# zsh
source ~/.zshrc

Now we can execute the command just like any other CLI. The arguments are all passed to the script, just as if it was a “normal” binary somewhere in our $PATH.

$ docker-rust-cli hello from docker
["/docker-cli-sample", "hello", "from", "docker"]

Advanced: volumes

We could end this post here, but there’s one “advanced” topic I want to highlight: Volumes. If our script would create or download files we could not access them because they are only inside the container and the container. To prevent this we need to add a volume.

A volume can be added with -v in the docker command.

alias docker-rust-cli='docker run --rm -v $(pwd):/cmd-root-dir kevingimbel/rust-docker-cli-sample:1.0'

WORKDIR

Now we need to make sure that our cli app puts created files in the /cmd-root-dir directory. This can be done by specifying the WORKDIR in the Dockerfile. To do this we add a new line above ENTRYPOINT as shown below.

FROM clux/muslrust:1.45.0-stable as builder
WORKDIR /volume
COPY . .
RUN cargo build --release

FROM alpine
COPY --from=builder /volume/target/x86_64-unknown-linux-musl/release/docker-cli-sample .
WORKDIR /cmd-root-dir
ENTRYPOINT [ "/docker-cli-sample" ]

WORKDIR will create the directory if it doesn’t exist. To test the changes, we can adjust the rust script to write to a file.

use std::env;
use std::fs;

fn main() -> std::io::Result<()> {
    let args: Vec<String> = env::args().collect();
    println!("{:?}", args);
    fs::write("docker-cli-sample.log", format!("Args: {:?}", args))?;
    Ok(())
}

This will write the arguments into the docker-cli-sample.log. Because we set WORKDIR this will execute in the /cmd-root-dir inside the container. To actually get the log, we can now mount the volume with -v in our alias.

alias docker-rust-cli='docker run --rm -v $(pwd):/cmd-root-dir kevingimbel/rust-docker-cli-sample:1.0'

$(pwd) always evaluates to the current directory. This only works if we use sigle-quotes (') in the alias!

So finally, running the command now will yield us the log in the current directory.

$ docker-rust-cli
["/docker-cli-sample", "hello", "world"]
$ cat docker-cli-sample.log
Args: ["/docker-cli-sample", "hello", "world"]

Advanced: managing versions

For a bit more comfort we can use a variable for the docker image tag so we can update easier. The .bashrc or .zshrc then looks like:

export MY_CLI_VERSION="1.0"
alias docker-rust-cli='docker run --rm -v $(pwd):/cmd-root-dir kevingimbel/rust-docker-cli-sample:$MY_CLI_VERSION'

And there’s that! Everybody else with access to the docker image can now use our CLI by adding the alias and optionally version to their .bashrc or .zshrc.

Recap

So what did we learn?

  • We can use multi-stage builds to build our code using docker. This is done by creating a container with FROM image:tag as builder
  • Rust binaries can be run in a small image such as alpine or even in a blank image using FROM scratch
  • We can use an alias to comfortably run the long docker command
  • By using WORKDIR and volumes we can extract files from the container and save them in the current directory outside the container

The source code of the example Rust CLI can be found on GitHub at kevingimbel/docker-cli-sample. A working docker image can be found on Docker Hub at kevingimbel/rust-docker-cli-sample.

The docker sample can be run with

docker run --rm kevingimbel/rust-docker-cli-sample:1.0 hello from docker

Categories

Tags

Like this?

If this content has helped you or your company, consider making a donation to a good cause.

For example, consider donating to the following causes:

A Selfie of me

Kevin Gimbel

is a DevOps Engineer and avid (Video) Gamer. He’s also interested in Sci-Fi, Cyberpunk, and dystopian books.

Wearing a mask is a good idea!

You can find out more about me if you wish.