Quentin Santos

Obsessed with computers since 2002

Author: Quentin Santos

  • Tiny Docker Containers with Rust

    The Magic

    Thanks to Python, I have gotten used to Docker images that takes minutes (or dozens of minutes) to build and hundreds of megabytes to store and upload.

    FROM python3:3.14
    
    # install everything
    RUN apt-get update \
        && apt-get install -y --no-install-recommends
        libsome-random-library0 libanother-library0 \
        libmaybe-its-for-pandas-i-dunno0 libit-goes-on0 liband-on0 \
        liband-on-and-on0 \
        && rm -rf /var/lib/apt/lists/*
    
    # install more of everything
    COPY requirements.txt .
    RUN pip3 install -r requirements.txt
    
    ENTRYPOINT ["gunicorn", "--daemon", "--workers=4", "--bind=0.0.0.0:8080", "app:create_app()"]

    But there is another way. The Rust way. Where you build your image in a second, and it only takes 5 MB1.

    FROM scratch
    COPY target/release/my-binary .
    ENTRYPOINT ["./my-binary"]

    The Trick

    This is possible thanks to the points below.

    1. Rust is compiled to binary
    2. Rust is statically2 compiled to binary
    3. You can easily use musl3 with Rust

    The first point means that there is no need for a runtime to interpret the script or the bytecode.

    The second point means that the binary contains the code for all the libraries4. And, more specifically, only the required code. So no need to install them externally, you remove some overhead, and the total size is reduced.

    With these two, you get down to just a few runtime dependencies. Namely, glibc and the ELF loader5.

    $ ldd target/release/my-binary
    linux-vdso.so.1 (0x00007fffbf7f9000)
    libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f7a759b2000)
    libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f7a758d3000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f7a756f2000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f7a762a6000)

    But you can go further! We do not have to use glibc. It turns out you can just statically compile musl. And with Rust, using musl is only a matter of installing musl-tools and adding --target=x86_64-unknown-linux-musl to your cargo build command.

    And, since we just got rid of our last runtime dependency, we do not even need the ELF loader! So now, we get:

    $ ldd target/x86_64-unknown-linux-musl/release/my-binary
    	statically linked

    All the user code is now in a single file and will talk to the kernel directly using int 0x80.

    Conclusion

    This is a nice trick, and can help you if you really care about small Docker images, but it does have a drawback: targeting musl typically gives you lower performance. So keep this in mind!

    1. Sure, I am simplifying things here. In practice, you would also build the binary in a Dockerfile, and then use staging. But that does not change the point. ↩︎
    2. Static linking means including external libraries within the binary, as in opposition to dynamic libraries, where they are not included. Binaries that were dynamically linked will need to be provided with the external libraries at runtime (.so files on Linux, .dll files on Windows). ↩︎
    3. musl is an implementation of libc, the standard library for C. Virtually all binaries depend on the libc. glibc is the most common implementation of libc on Linux. ↩︎
    4. Of course, it won’t work if a library is using FFI to talk to OpenSSL for instance ↩︎
    5. The ELF loader is another program that helps your program to start up. In particular, it tells your program where to find the dynamically linked libraries. ↩︎