Rust в Docker: минимальные образы и cross-compilation

Как собирать Rust-сервисы в минимальные Docker-образы: multi-stage builds, musl vs glibc, cross-compilation, оптимизация размера. Практические рецепты для CI/CD

Четвёртая статья серии про Rust в backend. В предыдущей собрали сервис на Axum — теперь упакуем его в Docker так, чтобы образ весил единицы мегабайт, а сборка в CI не занимала вечность. Разберём multi-stage, musl против glibc с его подводными камнями, оптимизацию бинарника и кеширование зависимостей.

Тяжёлый тулчейн сборки дистиллируется в минимальный образ на выходе

В статье

Зачем гнаться за размером

Маленький образ — это не эстетика, а эксплуатация:

  • Скорость деплоя. Образ тянется на каждую ноду при каждом обновлении. 8 MB против 200 MB — это секунды против минут, особенно на десятках реплик.
  • Поверхность атаки. Нет shell, пакетного менеджера и системных библиотек — нечего эксплуатировать. scratch или distroless резко сужают attack surface.
  • Стоимость реестра и трафика. Меньше слоёв и байт — дешевле хранение и pull, особенно в self-hosted registry.

Rust здесь в выигрышной позиции: компилируется в нативный бинарник без runtime, и при статической линковке его можно положить в пустой образ.

Multi-stage: builder и runtime

Базовый приём тот же, что и для любого компилируемого языка (общий разбор multi-stage): тяжёлый тулчейн остаётся в стадии сборки, в финальный образ попадает только бинарник. Разница — в выборе runtime-базы:

  • scratch — пустой образ. Только статический musl-бинарник. Самый маленький, но нет ни certs, ни shell, ни tzdata — всё нужное копируем явно.
  • distroless (gcr.io/distroless/cc или static) — нет shell и пакетов, но есть CA-сертификаты, непривилегированный пользователь, tzdata. Удобный компромисс, особенно для glibc-бинарников.
  • alpine — есть musl и shell, образ ~5–8 MB. Когда нужен busybox для отладки.

musl vs glibc и подводные камни

Чтобы попасть в scratch, бинарник должен быть полностью статическим. Glibc статически линковать проблематично, поэтому используют musl: target x86_64-unknown-linux-musl даёт самодостаточный бинарник без динамических зависимостей.

Цена статики на musl — несколько подводных камней, о которых лучше знать заранее:

  • TLS-сертификаты. В scratch нет /etc/ssl/certs. Любой исходящий HTTPS (запрос к API, БД по TLS) упадёт с ошибкой проверки сертификата, пока вы не скопируете ca-certificates.crt в образ. Самая частая «загадочная» поломка.
  • DNS-резолвер musl исторически беднее glibc (нюансы с nsswitch, TCP-fallback). На практике сетевые крейты (hyper/reqwest с резолвером hickory-dns) обходят это; но при использовании системного резолвера стоит проверить разрешение имён в scratch.
  • Аллокатор musl медленнее glibc на alloc-интенсивной нагрузке. Если профиль показывает, что упираетесь в аллокации, подключите jemalloc или mimalloc глобальным аллокатором — часто возвращает потерянную производительность.

Если статика не нужна любой ценой — берите glibc + distroless/cc: чуть больше образ (~20–25 MB), но без musl-нюансов.

Оптимизация бинарника

Размер самого бинарника управляется профилем релиза в Cargo.toml:

[profile.release]
strip = true          # убрать символы отладки
lto = true            # link-time optimization
codegen-units = 1     # лучше оптимизация (медленнее сборка)
panic = "abort"       # без раскрутки стека — меньше кода
opt-level = "z"       # оптимизация по размеру ("s" — компромисс, 3 — по скорости)

Нюанс: opt-level = "z" минимизирует размер, но может замедлить hot-path. Для сервера часто лучше opt-level = 3 + lto + strip — образ всё равно мал, а скорость не страдает. panic = "abort" убирает unwinding (меньше кода), но ломает catch_unwind — учитывайте, если на него полагаетесь. Что именно раздувает бинарник — покажет cargo-bloat.

Кеширование сборки в CI

Главная боль Rust в CI — компиляция всех зависимостей с нуля на каждом билде. Наивный Dockerfile инвалидирует кеш при любом изменении исходника и пересобирает весь граф зависимостей. Решение — cargo-chef: он отделяет слой зависимостей от слоя вашего кода, и пока Cargo.lock не менялся, зависимости берутся из Docker-кеша.

FROM rust:1-alpine AS chef
RUN apk add --no-cache musl-dev && cargo install cargo-chef
WORKDIR /app

FROM chef AS planner
COPY . .
RUN cargo chef prepare --recipe-path recipe.json

FROM chef AS builder
COPY --from=planner /app/recipe.json recipe.json
# Слой зависимостей кешируется, пока recipe.json (Cargo.lock) не изменился
RUN cargo chef cook --release --target x86_64-unknown-linux-musl --recipe-path recipe.json
COPY . .
RUN cargo build --release --target x86_64-unknown-linux-musl

FROM scratch
COPY --from=builder /app/target/x86_64-unknown-linux-musl/release/app /app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
USER 1000:1000
EXPOSE 8080
ENTRYPOINT ["/app"]

Дополнительно sccache кеширует артефакты компиляции между билдами — это помогает там, где cargo-chef не спасает (например, при частой смене состава зависимостей).

Cross-compilation под другую архитектуру

Пока хост и цель — оба x86_64 Linux, связки cargo-chef + musl-target выше достаточно. Но всё чаще прод едет на ARM64 (Graviton в AWS, Ampere и другие — та же производительность дешевле), а CI-раннеры остаются x86_64. Тогда нужна настоящая cross-компиляция: и другой target, и линкер под чужую архитектуру, и цепочка C-зависимостей, собранная под неё.

Самый простой рабочий путь — cargo-zigbuild: он подставляет zig универсальным кросс-линкером и снимает обычную боль с cross-линковкой.

FROM --platform=$BUILDPLATFORM rust:1-alpine AS builder
# cargo-zigbuild использует zig как линкер, но сам zig не ставит — доустанавливаем через pip:
RUN apk add --no-cache musl-dev python3 py3-pip && \
    pip install ziglang --break-system-packages && \
    cargo install cargo-zigbuild && \
    rustup target add aarch64-unknown-linux-musl
WORKDIR /app
COPY . .
# zig как линкер: один тулчейн собирает под любую цель без ручной настройки cross-линковки
RUN cargo zigbuild --release --target aarch64-unknown-linux-musl

# Финальную стадию пинуем на целевую arm64 — иначе манифест образа возьмёт архитектуру
# сборочного хоста (amd64), и arm64-бинарник окажется в amd64-помеченном образе.
FROM --platform=linux/arm64 scratch
COPY --from=builder /app/target/aarch64-unknown-linux-musl/release/app /app
COPY --from=builder /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
USER 1000:1000
EXPOSE 8080
ENTRYPOINT ["/app"]

Собирать этот образ нужно через buildx с явной целевой платформой, иначе --platform-пин финальной стадии не спасёт от неверной архитектуры манифеста:

docker buildx build --platform linux/arm64 -f Dockerfile.cross -t rust-app:arm64 --load .
docker image inspect rust-app:arm64 --format '{{.Architecture}}'   # -> arm64

Альтернативы: cross держит готовые Docker-образы с тулчейнами под каждую цель и запускает сборку внутри них (cross build --target aarch64-unknown-linux-musl --release), не требуя ничего ставить на хост; а docker buildx build --platform linux/amd64,linux/arm64 собирает сразу мультиархитектурный образ, разводя цели через $TARGETPLATFORM. Для x86_64→x86_64 всё это лишнее — хватает cargo-chef + musl из примера выше. Этот Dockerfile.cross лежит в cookbook рядом с основными: прогон через docker buildx build --platform linux/arm64 подтвердил, что cargo-zigbuild собирает aarch64-unknown-linux-musl-бинарник, итоговый образ честно помечен arch=arm64 (docker image inspect … --format '{{.Architecture}}') и укладывается в те же ~0.5 MB, что и x86_64-вариант. Две оговорки: (1) финальную стадию обязательно пинить FROM --platform=linux/arm64 scratch и собирать через buildx --platform linux/arm64, иначе манифест образа возьмёт архитектуру хоста (amd64) при arm64-бинарнике внутри; (2) cargo install cargo-zigbuild не тянет сам zig, его ставим отдельно через pip install ziglang, иначе сборка падает с «Failed to find zig».

Сравнение размеров

Порядок величин для типичного HTTP-сервиса (значения зависят от зависимостей):

Стек База Размер образа
Rust (musl) scratch ~0.5–15 MB
Rust (glibc) distroless/cc ~10–25 MB
Go scratch/distroless ~10–20 MB
Node.js node-slim ~120 MB+
Java JRE ~150–300 MB (jlink меньше)

Rust на scratch — один из самых компактных вариантов: нативный бинарник плюс сертификаты, и больше в образе ничего нет.

На runnable-демо к этой статье (минимальный Axum-сервис) оба образа собраны и замерены docker image inspect (десятичные МБ, как в docker images): musl/scratch — 0.53 MB (статический бинарник ~0.5 MB после strip+lto+opt-level="z" плюс CA-сертификаты), glibc + distroless/cc — 9.5 MB (база distroless/cc-debian12 плюс динамический бинарник). Числа зависят от зависимостей — на сервисе с БД и TLS бинарник будет больше, — но порядок величин и разрыв между scratch и distroless виден сразу. Оба Dockerfile’а (cargo-chef + musl + scratch и glibc + distroless) лежат в digital-cookbook, rust/docker-minimal/.

Это предпоследняя статья серии. В заключительной — production-паттерны Rust-сервиса: конфигурация, логирование, graceful shutdown, обработка ошибок.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии