Где - то там контейнер с маленьким крабом...
Где - то там контейнер с маленьким крабом...

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

Комментарии (13)


  1. aegoroff
    11.10.2023 10:53
    +4

    Hello world можно сделать еще меньше - добавьте эти строчки в Cargo.toml

    [profile.release]
    strip = true
    lto = true
    panic = 'abort'
    

    Для безопасности попробуйте еще https://console.cloud.google.com/gcr/images/distroless/GLOBAL/static-debian12:latest/details?pli=1
    Но вы должны быть готовы что не сможете никак отлаживать контейнер, т.к. там вообще ничего нет, ни шелла ни даже glibc


    1. Luchnik22 Автор
      11.10.2023 10:53
      +2

      Спасибо, это очень хорошая идея - оптимизировать сборку, буду дальше пробовать и экспериментировать


      1. aegoroff
        11.10.2023 10:53

        можете глянуть мои наработки https://github.com/aegoroff/bstore - образ весит 20 мегабайт, но там внутри SQLite есть и много чего еще


    1. alekssamos
      11.10.2023 10:53

      Ещё можно установить UPX и написать upx -9 ./path/to/compiled_program
      И файл будет ещё меньше места занимать на диске,
      но распаковываться будет , скорее всего, в оперативную память.

      Может кому-нибудь пригодится, не для докера, а просто.


    1. mbrav
      11.10.2023 10:53

      А можно сбросить ещё 60% при помощи upx:

      upx --best --lzma my_rust_binary


  1. Borz
    11.10.2023 10:53
    +3

    Так, если вы написали уже сервис на Go, то почему решили вернуться на Rust?


    1. Luchnik22 Автор
      11.10.2023 10:53
      +3

      Тут скорее вопрос в плоскости саморазвития, у меня нет ответа почему Go или Rust лучше. Возможно когда будет написана парочка микросервисов на Rust, то ответ на этот вопрос появится.

      P.S. Тот сервис так и живёт на Go


  1. blood_develop
    11.10.2023 10:53

    По go мало информации, надеялся на сравнение. Но за гайд большое благодарю


    1. Heggi
      11.10.2023 10:53

      По Go уже была статья, там минимальный образ аналогичного размера получился.

      https://habr.com/ru/articles/460535/


  1. Gorthauer87
    11.10.2023 10:53
    +1

    Счастье это закончится на первом крейте с сишными зависимостями. А ещё, надо не забыть про сертификаты и всякое такое.


    1. domix32
      11.10.2023 10:53
      +1

      Мне интересно в какой момент вам понадобятся такие крейты для вебни. Какой-то чудо tls крафтить?


  1. polRk
    11.10.2023 10:53
    -2

    Ну бред же полный!!!

    Возьмите nodejs http2, накиньте graphql-yoga или еще что попроще - envelop, hive. Потом соберите это все в 1 бандл без внешних зависимостей. На выходе 30-70mb потребление памяти


    1. vmarunin
      11.10.2023 10:53
      +2

      То есть примерно столько же сколько и "PostgreSQL, MinIO, Nginx потребляли до 30 Мб оперативной памяти каждый"
      Понятно, что nodejs это про скорость разработки и унификацию, а не про экономию памяти...
      Но у автора то цель - простой сервис и минимум памяти