At work, I own a Rust service that runs in an Azure Function. Among other things, the Functions runtime handles restarting the service should it be needed; fortunately, the service is incredibly stable and reliable. That said, I have done almost nothing with Docker (since I guess I’m living in the mid 2010s), and I really should learn more about it, as I expect I may need to deploy Rust services through Docker at some point.

I started looking at some simple Docker examples, but they all seem to use Node as a starting point. I don’t want to start there and try to work my way back, so instead, I figured I’d start with a simple Rust service and see if I can start from scratch.

As a total Docker newbie, here’s a fairly brief summary of my misadventures.

Build a Simple Server

Let’s start with the service itself. Wanting to keep this incredibly simple (in this case, avoiding Rust async), I found OxHTTP, a very simple synchronous HTTP server. We’ll start with a new project that uses it:

$ cargo new rust-server
$ cd rust-server
$ cargo add oxhttp

The provided example is just about perfect for what we want; I’ll just slightly tweak it by wrapping it in a main function:

fn main() {
    use oxhttp::Server;
    use oxhttp::model::{Response, Status};
    use std::time::Duration;
    // Builds a new server that returns a 404 everywhere except for "/" where it returns the body 'home'
    let mut server = Server::new(|request| {
        if request.url().path() == "/" {
        } else {
    // Raise a timeout error if the client does not respond after 10s.
    // Listen to localhost:8080
    server.listen(("localhost", 8080)).unwrap();

Build this with cargo build --release and test it out. I’ve configured my ~/.cargo/config to specify a common build directory:

target-dir = "/home/adam/cargo-target"

This means that I can run my built server with ~/cargo-target/release/rust-server, and when I visit http://localhost:8080 in my browser, I see the HTTP response “home”. The server now works, so I copied the binary into the current working directory.

Simple Docker image

Next, we’ll need to build a Dockerfile. As I said, I know just about nothing about Docker, but I want to avoid the Node route. It seems pretty much everything is built off of Alpine, so I’ll start there:

FROM alpine:latest
COPY rust-server rust-server
CMD ["rust-server"]

Building this is quick and completes without issue:

$ docker build -t my-rust-server:latest .
[+] Building 0.6s (7/7) FINISHED
 => [internal] load build definition from Dockerfile                                                                                 0.1s
 => => transferring dockerfile: 113B                                                                                                 0.0s
 => [internal] load .dockerignore                                                                                                    0.0s
 => => transferring context: 2B                                                                                                      0.0s
 => [internal] load metadata for                                                                     0.0s
 => [internal] load build context                                                                                                    0.1s
 => => transferring context: 4.70MB                                                                                                  0.1s
 => CACHED [1/2] FROM                                                                                0.0s
 => [2/2] COPY rust-server rust-server                                                                                               0.2s
 => exporting to image                                                                                                               0.1s
 => => exporting layers                                                                                                              0.1s
 => => writing image sha256:108dbb6764b4e6c94cc3bde571eb2157dceb57aab1ac3f393577174c1175a282                                         0.0s
 => => naming to                                                                             0.0s

Then I run it:

$ docker run -t my-rust-server:latest
docker: Error response from daemon: failed to create shim task: OCI runtime create failed: runc create failed: unable to start container process: exec: "rust-server": executable file not found in $PATH: unknown.
ERRO[0001] error waiting for container: context canceled

It seems that COPY foo foo places foo into the root directory (ie, /), which isn’t in $PATH, I guess? So let’s try putting it into /bin/:

FROM alpine:latest
COPY rust-server /bin/rust-server
CMD ["/bin/rust-server"]
$ docker run -t my-rust-server:latest
exec /bin/rust-server: no such file or directory

This is a different error, so something changed. But it’s still not finding it? Let’s inspect the container:

$ docker run -it my-rust-server:latest /bin/sh
/ # ls /bin/rust-server
/ # file /bin/rust-server
/bin/sh: file: not found

The binary is definitely there. I tried file to see what the system thinks the binary is, but Alpine doesn’t have it. Instead, we can try ldd to get details:

/ # ldd /bin/rust-server
        /lib64/ (0x7fa7b4984000)
Error loading shared library No such file or directory (needed by /bin/rust-server) => /lib64/ (0x7fa7b4984000) => /lib64/ (0x7fa7b4984000) => /lib64/ (0x7fa7b4984000) => /lib64/ (0x7fa7b4984000)
Error loading shared library No such file or directory (needed by /bin/rust-server)
Error relocating /bin/rust-server: _Unwind_Resume: symbol not found
Error relocating /bin/rust-server: _Unwind_Backtrace: symbol not found

Ohh, so it’s not that my Docker image can’t find my binary but that when trying to run my binary, it can’t find the dynamically-linked libgcc. A quick search on how to install packages in Alpine (since it’s not Debian-based, I can’t use apt) shows that it uses apk, and libgcc exists in Alpine’s package repository. Adding this to the Dockerfile:

FROM alpine:latest
COPY rust-server /bin/rust-server

# These are new:
RUN apk update
RUN apk add libgcc

CMD ["/bin/rust-server"]

Running this still gives the no such file or directory error. So let’s inspect with ldd again:

/ # ldd /bin/rust-server
        /lib64/ (0x7f1e6d596000) => /usr/lib/ (0x7f1e6d2bc000) => /lib64/ (0x7f1e6d596000) => /lib64/ (0x7f1e6d596000) => /lib64/ (0x7f1e6d596000) => /lib64/ (0x7f1e6d596000)
Error loading shared library No such file or directory (needed by /bin/rust-server)
Error relocating /bin/rust-server: __res_init: symbol not found
Error relocating /bin/rust-server: gnu_get_libc_version: symbol not found

libgcc is no longer a problem, but ld-linux still is. And it appears that ld-linux is part of gcompat. After adding RUN apk add gcompat, rebuilding, and rerunning, the message “Error loading shared library” goes away, but the “__res_init” and “gnu_get_libc_version” errors remain.

I did some further sleuthing and found a suggestion on Reddit to use this hack to make it work, but instead of continuing down this rabbit hole, I decided to try another approach I saw: musl.

Static linking with musl

Rust can compile to a number of build targets; in my dev environment (Ubuntu in WSL2), the default is:

$ rustc -vV | grep host
host: x86_64-unknown-linux-gnu

We can find supported targets with rustup target list. Doing this shows that there’s x86_64-unknown-linux-musl. Let’s install this toolchain and compile the server:

$ rustup target add x86_64-unknown-linux-musl 
$ cargo build --target=x86_64-unknown-linux-musl --release
$ mv rust-server rust-server-old
$ cp ~/cargo-target/x86_64-unknown-linux-musl/release/rust-server .

We can compare the old binary to the new one:

$ file rust-server-old rust-server
rust-server-old: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/, for GNU/Linux 3.2.0, BuildID[sha1]=523b84a693e7b90bcf8332d2eecd51cc9bfbe45a, with debug_info, not stripped
rust-server:     ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, with debug_info, not stripped

$ ldd rust-server-old (0x00007ffea2be5000) => /lib/x86_64-linux-gnu/ (0x00007fd1fa870000) => /lib/x86_64-linux-gnu/ (0x00007fd1fa668000) => /lib/x86_64-linux-gnu/ (0x00007fd1fa449000) => /lib/x86_64-linux-gnu/ (0x00007fd1fa245000) => /lib/x86_64-linux-gnu/ (0x00007fd1f9e54000)
        /lib64/ (0x00007fd1fad44000)

$ ldd rust-server
        statically linked

(Side note: the old and new binaries are 4.5Mb and 4.9Mb, respectively, showing the cost of statically linking. However, if we strip both binaries, their sizes – and the marginal difference – drop: 751Kb and 865Kb, respectively.)

Running the musl binary

Since the new binary is statically linked, we don’t need to install extra apk packages, so the Dockerfile is now back to:

FROM alpine:latest
COPY rust-server /bin/rust-server
CMD ["/bin/rust-server"]

This builds very quickly, and calling docker run now has a service running. Going to http://localhost:8080 should work, right?

$ curl localhost:8080
curl: (7) Failed to connect to localhost port 8080: Connection refused

Ah, but we need to publish the container’s port to the host:

$ docker run -p 8080:8080 -t my-rust-server:latest

# In another terminal (because `docker run` is blocking):
$ curl localhost:8080
curl: (52) Empty reply from server

So it’s connecting but not getting any data?

$ telnet localhost 8080
Connected to localhost.
Escape character is '^]'.
Connection closed by foreign host.

$ telnet localhost 8081
telnet: Unable to connect to remote host: Connection refused

The connection on 8080 is opened but immediately closed; the attempt on 8081 fails, as expected, because there’s nothing on that port – it’s showing that there’s something different about 8080. So Docker is forwarding the port, and something is listening. Surely that’s our server.

Looking at the Rust code, we notice that we’re listening on localhost, port8080:

server.listen(("localhost", 8080)).unwrap();

But wait: it turns out that there’s a difference: is the normal loopback address, and localhost:xxxx is the hostname for is slightly different, it’s an address used to refer to all IP addresses on the same machine. Or no specific IP address.

Simply changing from “localhost” to “”, recompiling, rebuilding the image, and rerunning does the trick

$ curl localhost:8080

Bonus: Saving the image:

I am familiar (but have no experience) with Docker Hub, and I have only briefly played with Azure Container Registry, I thought I’d first start with the simplest option: saving the image to a file:

$ docker save my-rust-server:latest | gzip > my-rust-server.tar.gz

$ ls -lh my-rust-server.tar.gz
-rw-r--r-- 1 adam adam 4.5M Feb 24 16:30 my-rust-server.tar.gz

Now let’s remove the image from Docker, make sure we can re-load it, and run it again:

$ docker image rm my-rust-server:latest
Untagged: my-rust-server:latest
Deleted: sha256:0ed0fda582a3c568fdb8f4a313a464ce3244442d1f1d36934be9bb29e8b9e4fd

$ docker images | grep my-rust

$ docker load < my-rust-server.tar.gz
Loaded image: my-rust-server:latest

$ docker images | grep my-rust
my-rust-server   latest    732da9278f98   18 minutes ago   12.1MB

$ docker run -it my-rust-server:latest /bin/sh
/ # ls /bin/rust-server