Привет %username%, эта статья про то, как поместить Rust приложение в Docker и получить образ размером с бинарный файл (6 Мб). А также про причины, которые привели к переходу с NodeJS на Rust, отдельная пара слов о проблемах вначале, переходе на Go, и том, как команда Rust устранила эти проблемы.
TL;DR Dockerfile в конце статьи и ссылка на example репозиторий
Прожорливый JavaScript
История начинается со времён, когда Digital Ocean стоил 5$ за дроплет с 512 Мб оперативки, всё было прекрасно, до тех пор пока на нём крутился только Nginx раздающий статику, которому этого более чем достаточно. Затем понадобилось добавить NodeJS, базу данных, файловое хранилище для загрузки картинок - всё это хотелось изолировать друг от друга, поэтому нужен Docker, а ещё это всё было хобби и строить кластера за 100$ на AWS совсем не хотелось, но уже в 512 Мб уложиться не получалось - большое потребление памяти было сначала странным, так как пользователем сервиса был только один я.
Как так могло выйти, что форма регистрации и загрузка картинок требовала свыше 512 Мб серверной памяти? docker stats на тот момент показал, что PostgreSQL, MinIO, Nginx потребляли до 30 Мб оперативной памяти каждый (с лимитами 128 Мб), а вот NodeJS падал с OutOfMemoryError. Экспериментальным путём было выявлено, что для NodeJS контейнера требуется минимум 300 Мб. Cначала показалось, что это связано с тем, что используются зависимости для GraphQL, но фактический пустое приложение на Express (фреймворк для веб-приложений) уже потребляло ~100 Мб. Последующий снепшот памяти раскрыл, что треть всей потребляемой памяти занимают строки - причём строки исходного кода. Оказалось это фундаментальная часть языка JavaScript и того как работает функция .toString()
Если исходный код зависимостей (node_modules) весит 300 Мб, то потенциально приложение может потреблять это количество памяти, а то и больше.
Поэтому решено было поискать новый способ разрабатывать веб приложения. И тогда мне показался очень перспективным язык Rust.
Начинали на Rust, закончили на Go
Первое знакомство с Rust у меня не задалось. Нужно было написать небольшой микросервис Email подписки, всё просто - по урлу /subsсribe необходимо получать строку и затем сохранять её в PostgreSQL. Задачка на час максимум. В общем код был написан, осталось его только уложить в контейнер. Первая версия Dockerfil'а:
FROM rust:latest
WORKDIR /usr/src/myapp
COPY . .
RUN cargo install --path .
CMD ["myapp"]
В целом не сильно беспокоило, что образ контейнера весит 1.7 Гб, ведь дискового пространства было много, а кеши всегда прогреты. Зато со скоростью сборки было явно что - то не так - холодная сборка контейнера могла занимать до 60 минут и с некоторой вероятностью падала по таймауту. А также после каждого обновления/добавления пакетов, сборка снова длилась час.
Выяснялось, что проблема заключалась в том, что cargo использовал для скачивания зависимостей crates индекс в виде tar архива - то есть фактический скачивал ~170 Мб кода с гитхаба, к сожалению github не позволял скачивать большие репозитории быстро и ограничивал скорость до 64 Кб/сек. Этот индекс нужен для быстрого поиска пакетов и их актуальных версий. Чтобы хоть как - то закешировать этот index приходилось идти на костыли - создавать фейк проект и через добавления зависимости загружать этот индекс:
RUN cargo new --bin build-index \
&& cd build-index \
&& cargo add rand_core \
&& cd .. \
&& rm -rf build-index
Конечно этот костыль плохо работал, так как со временем всё ровно нужно было подтянуть свежую версию индекса.
Поэтому в тот момент был потрачен час другой на переписывание микросервиса на Go, с последующим анализом Issue в репозитории Rust команды. Кстати, у ребят уже тогда была идея, как оптимизировать скачивание по HTTP, и только той части индекса, которая действительно необходима.
Второй шанс Rust
По итогу идея со Sparse Index взлетела - на сегодня этот механизм уже находится в stable релизе и успешно работает. Поэтому можно попробовать ещё раз возвращение к Rust контейнерам. И первое что приходит в голову - разделить на два этапа: большой контейнер для сборки (builder) и конечный лёгкий контейнер в котором запускается приложение. Звучит неплохо:
FROM rust:1.72.0 as builder
WORKDIR /usr/src/app
COPY . .
RUN cargo build --release
FROM rust:alpine3.17
COPY --from=builder /usr/src/app/target/release/docker-test /usr/local/bin/docker-test
CMD ["docker-test"]
Тем самым можно получить контейнер размером 841 Мб и приложение, которое не запускается. Если начать разбираться, то выяснится - образы Rust тянут за собой слой инструментов, которые нужны для сборки и компиляции приложения, однако они не нужны в конечном запускаемом контейнере. Поэтому можно использовать голый alpine: "FROM: alpine:3.17", тогда размер приложения уменьшится до 13.4 Мб, но проблема останется прежней - приложение не запускается со словами
exec /usr/local/bin/docker-test: no such file or directory
Если войти в контейнер, то бинарный файл есть, и если его проверить с помощью утилиты ldd, то можно увидеть, что не хватает части зависимостей
/usr/local/bin # ldd
musl libc (x86_64)
Version 1.2.3
Dynamic Program Loader
Usage: /lib/ld-musl-x86_64.so.1 [options] [--] pathname
/usr/local/bin # ldd ./docker-test
/lib64/ld-linux-x86-64.so.2 (0x7f46e4b18000)
Error loading shared library libgcc_s.so.1: No such file or directory (needed by ./docker-test)
libm.so.6 => /lib64/ld-linux-x86-64.so.2 (0x7f46e4b18000)
libc.so.6 => /lib64/ld-linux-x86-64.so.2 (0x7f46e4b18000)
Error loading shared library ld-linux-x86-64.so.2: No such file or directory (needed by ./docker-test)
Error relocating ./docker-test: _Unwind_Resume: symbol not found
Error relocating ./docker-test: _Unwind_GetRegionStart: symbol not found
Error relocating ./docker-test: _Unwind_SetGR: symbol not found
Error relocating ./docker-test: _Unwind_GetDataRelBase: symbol not found
Error relocating ./docker-test: _Unwind_DeleteException: symbol not foundError relocating ./docker-test: _Unwind_GetLanguageSpecificData: symbol not found
Error relocating ./docker-test: _Unwind_RaiseException: symbol not found
Error relocating ./docker-test: _Unwind_GetIP: symbol not found
Error relocating ./docker-test: _Unwind_Backtrace: symbol not found
Error relocating ./docker-test: _Unwind_GetIPInfo: symbol not found
Error relocating ./docker-test: _Unwind_GetTextRelBase: symbol not found
Error relocating ./docker-test: _Unwind_SetIP: symbol not found
Это сбивало с толку и я долго не мог понять, каких зависимостей не хватает. На самом деле приложение было неправильно скомпилировано, и в таком случае нужно компилятору явно указать под какой target (какую платформу) собирать - в данном случае x86_64-unknown-linux-musl
Почти идеально
Добавляем правильный target:
...
RUN rustup target add x86_64-unknown-linux-musl
RUN cargo build --target x86_64-unknown-linux-musl --release
FROM alpine:3.17
COPY --from=builder /usr/src/app/target/x86_64-unknown-linux-musl/release/docker-test /usr/local/bin/docker-test
CMD ["docker-test"]
Что-ж, теперь приложение быстро компилируется и запускается, а контейнер весит 13.6 Мб
Если хотите, чтобы приложение ещё меньше весило, то "FROM: alpine:3.17" можно заменить на "FROM: scratch" - то есть сделать запуск на "голом" линуксе, такой образ будет весить 6.15 Мб - ровно столько, сколько весит размер исполняемого файла, в таком контейнере не будет никаких curl, sh и прочих приложений - сам образ весит 0 байт.
Итоговый Dockerfile
FROM rust:1.72.0 as builder
WORKDIR /usr/src/app
RUN rustup target add x86_64-unknown-linux-musl
COPY Cargo.toml Cargo.lock ./
COPY src src
RUN cargo build --target x86_64-unknown-linux-musl --release
FROM scratch
COPY --from=builder /usr/src/app/target/x86_64-unknown-linux-musl/release/docker-test /usr/local/bin/docker-test
CMD ["docker-test"]
Такой контейнер потребляет 1.3 Мб оперативной памяти, считаю отличным результатом для простого hello-world веб-приложения с фреймворками axum и tokio. Схожие результаты были с Go контейнерами микросервисов. Также была опробована статическая линковка, но видимых результатов это не дало, возможно из - за того, что проект небольших размеров или что - то неправильно было приготовлено.
Спасибо что прочитали, надеюсь этот опыт будет полезен.
P.S. Ещё было кеширование зависимости между 7 и 8 строчками кода, но там начали возникать странные эффекты
Комментарии (13)
Borz
11.10.2023 10:53+3Так, если вы написали уже сервис на Go, то почему решили вернуться на Rust?
Luchnik22 Автор
11.10.2023 10:53+3Тут скорее вопрос в плоскости саморазвития, у меня нет ответа почему Go или Rust лучше. Возможно когда будет написана парочка микросервисов на Rust, то ответ на этот вопрос появится.
P.S. Тот сервис так и живёт на Go
blood_develop
11.10.2023 10:53По go мало информации, надеялся на сравнение. Но за гайд большое благодарю
Gorthauer87
11.10.2023 10:53+1Счастье это закончится на первом крейте с сишными зависимостями. А ещё, надо не забыть про сертификаты и всякое такое.
domix32
11.10.2023 10:53+1Мне интересно в какой момент вам понадобятся такие крейты для вебни. Какой-то чудо tls крафтить?
polRk
11.10.2023 10:53-2Ну бред же полный!!!
Возьмите nodejs http2, накиньте graphql-yoga или еще что попроще - envelop, hive. Потом соберите это все в 1 бандл без внешних зависимостей. На выходе 30-70mb потребление памятиvmarunin
11.10.2023 10:53+2То есть примерно столько же сколько и "PostgreSQL, MinIO, Nginx потребляли до 30 Мб оперативной памяти каждый"
Понятно, что nodejs это про скорость разработки и унификацию, а не про экономию памяти...
Но у автора то цель - простой сервис и минимум памяти
aegoroff
Hello world можно сделать еще меньше - добавьте эти строчки в Cargo.toml
Для безопасности попробуйте еще https://console.cloud.google.com/gcr/images/distroless/GLOBAL/static-debian12:latest/details?pli=1
Но вы должны быть готовы что не сможете никак отлаживать контейнер, т.к. там вообще ничего нет, ни шелла ни даже glibc
Luchnik22 Автор
Спасибо, это очень хорошая идея - оптимизировать сборку, буду дальше пробовать и экспериментировать
aegoroff
можете глянуть мои наработки https://github.com/aegoroff/bstore - образ весит 20 мегабайт, но там внутри SQLite есть и много чего еще
alekssamos
Ещё можно установить UPX и написать upx -9
./path/to/compiled_program
И файл будет ещё меньше места занимать на диске,
но распаковываться будет , скорее всего, в оперативную память.
Может кому-нибудь пригодится, не для докера, а просто.
mbrav
А можно сбросить ещё 60% при помощи
upx
:upx --best --lzma my_rust_binary