Pull to refresh
77.13
Холдинг Т1
Многопрофильный ИТ-холдинг

Самый маленький Docker образ Rust приложения

Level of difficultyMedium
Reading time5 min
Views9.3K
Где - то там контейнер с маленьким крабом...
Где - то там контейнер с маленьким крабом...

Привет %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()

Так выглядит снепшот памяти пустого NodeJS приложения
Так выглядит снепшот памяти пустого NodeJS приложения

Если исходный код зависимостей (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 строчками кода, но там начали возникать странные эффекты

Tags:
Hubs:
Total votes 45: ↑44 and ↓1+43
Comments16

Articles

Information

Website
t1.ru
Registered
Founded
Employees
over 10,000 employees
Location
Россия
Representative
Холдинг Т1