Rust et Docker: Optimiser la taille de l'image

Rust et Docker: Optimiser la taille de l'image
J'aime bien les photos de porte-conteneurs. Photo by Ian Taylor / Unsplash

Pour mon API de récupération de flux RSS, je veux faire une image docker, parce que je suis grave à la mode comme mec, et qu'il faut avouer que c'est quand même super pratique pour déployer des truc sans se faire chier, d'autant plus avec docker compose

Il vient cependant deux soucis avec docker en général, et rust en particulier. Le premier est la taille des images qui peuvent facilement, si on fait pas gaffe, taper le giga, voir plus, pour une pauvre API. Le deuxième étant les temps de compilation en rust qui sont quand même pas bien rapide, ce qui est déjà relou à la base, mais encore plus quand on a que quelques centaines de minutes gratos sur la CI de Gitlab ou de Github.

On va commencer par réduire la taille de l'image.  Déjà voyons la taille du binaire release produite, par cargo, avant toutes optimisations:

cargo build --release
# One eternity later
ls -aslh target/release | grep rss-aggregator

-rwxr-xr-x   2 eric eric  22M 16 mai   22:17 rss-aggregator
Ouais, 22Mo c'est pas ouf du tout.

On peut déjà optimiser ça en ajoutant quelques menus options dans le Cargo.toml sur notre profile de release. Ca se fait au prix d'un temps de compilation plus long, mais bon, on est plus à 20 minutes près!

[profile.release]
strip = true
opt-level = "s"
lto = true
codegen-units = 1
Les explications sur ces options se trouvent dans la bible de cargo.
cargo build --release
# Two eternity later
ls -aslh target/release | grep rss-aggregator

-rwxr-xr-x   2 eric eric  9,3M 16 mai   22:19 rss-aggregator
9.3 Mo! C'est beaucoup mieux!

Maintenant, on attaque le gros du sujet, l'image docker. Une approche naïve du truc voudrait qu'on prenne l'image officiel de rust, on compile avec, et yolo comme disent les vieux

FROM rust:latest AS builder

# On crée un user sans privilèges, ça mange pas de pain
ENV USER=rss-aggregator
ENV UID=10001
RUN adduser \
    --disabled-password \
    --gecos "" \
    --home "/nonexistent" \
    --shell "/sbin/nologin" \
    --no-create-home \
    --uid "${UID}" \
    "${USER}"

# On copie ce dont on a besoin
WORKDIR app
COPY Cargo.toml Cargo.toml
COPY Cargo.lock Cargo.lock
COPY .env .env
COPY configuration.yaml configuration.yaml
COPY entity/ entity/
COPY migrations/ migrations/
COPY src/ src/

# On compile tout ça
RUN cargo build --release

# On dit d'utiliser notre user sans privilèges
USER rss-aggregator:rss-aggregator

EXPOSE 8080
ENTRYPOINT ["target/release/rss-aggregator"]

Résultat?

REPOSITORY        TAG       IMAGE ID       CREATED          SIZE
rss-aggregator    latest    c5ba02ced00b   12 seconds ago   2.59GB
Non mais j'ai que 10GO de SSD sur mon instance, calmez-vous!

2.59 GB pour un binaire qui fait à la base 9.3 Mo. Bah non, on va pas faire comme ça.

Vu que rust n'a pas besoin d'un interpréteur ou d'une machine virtuelle comme python ou java, on peut juste compiler le binaire quelque part, et récupérer uniquement le résultat de cette compilation dans une image un peu plus light. Voyons ça

FROM rust:latest AS builder
ARG DEBIAN_FRONTEND=noninteractive

# On crée un user sans privilèges, ça mange toujours pas de pain
ENV USER=rss-aggregator
ENV UID=10001
RUN adduser \
    --disabled-password \
    --gecos "" \
    --home "/nonexistent" \
    --shell "/sbin/nologin" \
    --no-create-home \
    --uid "${UID}" \
    "${USER}"

WORKDIR app
COPY Cargo.toml Cargo.toml
COPY Cargo.lock Cargo.lock
COPY .env .env
COPY configuration.yaml configuration.yaml
COPY entity/ entity/
COPY migrations/ migrations/
COPY src/ src/

# On compile comme d'habitude
RUN cargo build --release

# Ici on utilise une nouvelle image qui va servir de runtime
# Buster-slim est une image debian presque légère; ~70 Mo
FROM debian:buster-slim as runtime
ARG DEBIAN_FRONTEND=noninteractive

# On copie le binaire de l'image servant de builder et le user
COPY --from=builder /app/target/release/rss-aggregator /usr/local/bin
COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /etc/group /etc/group
# On copie les resources statics  
COPY static/ static/

USER rss-aggregator:rss-aggregator
EXPOSE 8080

ENTRYPOINT ["rss-aggregator"]

Et maintenant

REPOSITORY        TAG           IMAGE ID       CREATED          SIZE
rss-aggregator    latest        4f07dfc12c5b   27 seconds ago   78.9MB

Ah ben oui, c'est beaucoup mieux! Mais on peut mieux faire grâce à Alpine. Cette distribution est ultra légère mais se base sur musl en lieux et place de glibc, ce qui peut poser des soucis. Et l'image docker fait... 5.57 Mo. Il faut cependant dire à cargo qu'on cible musl, mais ça se fait super bien.

FROM rust:latest AS builder
ARG DEBIAN_FRONTEND=noninteractive

# On ajoute ce qu'il faut pour compiler pour musl
RUN rustup target add x86_64-unknown-linux-musl
RUN apt update && apt install -y musl-tools musl-dev
RUN update-ca-certificates

ENV USER=rss-aggregator
ENV UID=10001

RUN adduser \
    --disabled-password \
    --gecos "" \
    --home "/nonexistent" \
    --shell "/sbin/nologin" \
    --no-create-home \
    --uid "${UID}" \
    "${USER}"

WORKDIR app
COPY Cargo.toml Cargo.toml
COPY Cargo.lock Cargo.lock
COPY .env .env
COPY configuration.yaml configuration.yaml
COPY entity/ entity/
COPY migrations/ migrations/
COPY src/ src/

# On spécifie qu'on veut compiler pour musl
RUN cargo build --target x86_64-unknown-linux-musl --release

### Notre image de runtime
FROM alpine

COPY --from=builder /etc/passwd /etc/passwd
COPY --from=builder /etc/group /etc/group
# Ne pas oublier le x86_64-unknown-linux-musl dans la target
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/rss-aggregator /usr/local/bin
COPY static/ static/

USER rss-aggregator:rss-aggregator
EXPOSE 8080

ENTRYPOINT ["rss-aggregator"]

Ce qui nous donne une image de...

REPOSITORY       TAG           IMAGE ID       CREATED          SIZE
rss-aggregator   latest        fdc57ae50e68   45 seconds ago   15.3MB

15.3 MB! C'est devenu raisonnable!

Il faut garder à l'esprit par contre que certaine lib que vous utilisez dans votre code peuvent ne pas gérer musl. Ce fut mon cas avec reqwest qui par défaut veut un openssl glibc. J'ai pu corriger ça en spécifiant la feature native-tls-vendored pour ce paquet, même si j'avoue pas trop comprendre ce que ça fait.

Au final, on se retrouve avec une image complète plus petite que le binaire pré-optimisations, je trouve ça plutôt pas mal!

Il reste le problème du temps de génération des images docker. Mais ça, ça fera l'objet d'un autre post. Principalement quand j'aurais trouvé une solution acceptable.