Четвёртая статья серии про Rust в backend. В предыдущей собрали сервис на Axum — теперь упакуем его в Docker так, чтобы образ весил единицы мегабайт, а сборка в CI не занимала вечность. Разберём multi-stage, musl против glibc с его подводными камнями, оптимизацию бинарника и кеширование зависимостей.
В статье
- Зачем гнаться за размером
- Multi-stage: builder и runtime
- musl vs glibc и подводные камни
- Оптимизация бинарника
- Кеширование сборки в CI
- Cross-compilation под другую архитектуру
- Сравнение размеров
Зачем гнаться за размером
Маленький образ — это не эстетика, а эксплуатация:
- Скорость деплоя. Образ тянется на каждую ноду при каждом обновлении. 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, обработка ошибок.
Комментарии