Как уменьшить образ Docker для работы с устройствами IoT

от автора

На устройствах интернета вещей (IoT) зачастую слишком мало ресурсов, и их не хватает, чтобы подтягивать и использовать тяжеловесные образы Docker. В этой статье будет показано, как можно уменьшить образ Docker на 36-91% при помощи инструментов patchelf и strace, не перекомпилируя при этом контейнеризованные приложения. Также рассмотрим, как создавать минимальные образы для собственных приложений, написанных на Rust, Go, C/C++.

❯ Зачем уменьшать образ Docker?

В зависимости от того, каков размер образа Docker, и сколько в нём слоёв, зависит, сколько памяти и дискового пространства понадобится устройству для подтягивания и распаковки этого образа. У таких устройств как Raspberry Pi Zero совершенно не хватает ресурсов, чтобы распаковать, например, образ Home Assistant. Однако у Raspberry Pi Zero более чем достаточно ресурсов, чтобы запускать эту программу. Именно в таких случаях производительность Docker повысится, если уменьшить размер образа. Кроме того, если включать в образ только те файлы, которые действительно используются приложением, то уменьшается потенциальная зона атаки. Такой метод полезен не только при работе с устройствами IoT, но и применительно к серверам.

Не составляет труда уменьшать размер образа у таких контейнеризованных приложений, которые вы разрабатывали сами. Просто скомпилируйте статический двоичный файл и включите в окончательный вариант образа только этот файл. Но и при работе со сторонними приложениями есть несколько подходов, позволяющих обойтись без перекомпиляции. Среди таких подходов немаловажны те, при которых уменьшают образы контейнеризованных скриптов.

❯ Patchelf

 Photo by Craig McLachlan on Unsplash.

Photo by Craig McLachlan on Unsplash.

Если рассматриваемое приложение скомпилировано в двоичный файл формата ELF (обычно так происходит с файлами на C, C++, Fortran, Rust, Go, т.д.), то можно при помощи инструмента patchelf найти все библиотеки, используемые в приложении, и скопировать их в готовый образ.

Аббревиатура «ELF» означает «формат исполняемых и компонуемых файлов». В таком формате среди множества прочих метаданных указывается путь интерпретатора программы (напр., /lib64/ld-linux-x86-64.so.2 на платформе x86_64) и путь runtime search path, сокращённо rpath (напр., /lib64).

При помощи интерпретатора программы мы динамически загружаем в память сам файл ELF и все его зависимости (библиотеки), а после этого выполняем его. В Linux это можно сделать вручную: /lib64/ld-linux-x86-64.so.2 /bin/sh или просто /bin/sh.

Интерпретатор программы использует путь rpath, чтобы найти все её зависимости. В большинстве дистрибутивов Linux (единственные известные мне исключения — Guix и Nix) этот путь пуст, и интерпретатор ищет зависимости по жёстко заданным путям (напр., /lib64).

При помощи инструмента patchelf мы изменим интерпретатор и rpath, а при помощи readelf изучим файл ELF. Также нам пригодится инструмент ldd — он покажет как интерпретатор, так и все его зависимости.

# Debian $ readelf --headers /bin/sh | grep -A2 INTERP   INTERP         0x0000000000000318 0x0000000000000318 0x0000000000000318                  0x000000000000001c 0x000000000000001c  R      0x1       [Requesting program interpreter: /lib64/ld-linux-x86-64.so.2] $ readelf --dynamic /bin/sh | grep RUNPATH $ patchelf --set-interpreter /lib/ld-linux-x86-64.so.2 --set-rpath /lib /path/to/some/elf/binary $ ldd /bin/sh         linux-vdso.so.1 (0x00007ffce0f91000)         libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fedf9b66000)         /lib64/ld-linux-x86-64.so.2 (0x00007fedf9d6d000)

Как понятно из вывода, путь rpath в Debian пуст, а /bin/sh зависит только от libc. Вывод тех же самых команд в Guix будет существенно отличаться. Это просто пример, мы не будем подробно разбирать, почему в Guix используется непустой rpath.

# Guix $ readelf --headers /bin/sh | grep -A2 INTERP   INTERP         0x0000000000000318 0x0000000000400318 0x0000000000400318                  0x0000000000000050 0x0000000000000050  R      0x1       [Requesting program interpreter: /gnu/store/gsjczqir1wbz8p770zndrpw4rnppmxi3-glibc-2.35/lib/ld-linux-x86-64.so.2] $ readelf --dynamic /bin/sh | grep RUNPATH  0x000000000000001d (RUNPATH)            Library runpath: [/gnu/store/lxfc2a05ysi7vlaq0m3w5wsfsy0drdlw-readline-8.1.2/lib:/gnu/store/bcc053jvsbspdjr17gnnd9dg85b3a0gy-ncurses-6.2.20210619/lib:/gnu/store/gsjczqir1wbz8p770zndrpw4rnppmxi3-glibc-2.35/lib:/gnu/store/930nwsiysdvy2x5zv1sf6v7ym75z8ayk-gcc-11.3.0-lib/lib:/gnu/store/930nwsiysdvy2x5zv1sf6v7ym75z8ayk-gcc-11.3.0-lib/lib/gcc/x86_64-unknown-linux-gnu/11.3.0/../../..] $ ldd /bin/sh         linux-vdso.so.1 (0x00007ffe777f6000)         libreadline.so.8 => /gnu/store/lxfc2a05ysi7vlaq0m3w5wsfsy0drdlw-readline-8.1.2/lib/libreadline.so.8 (0x00007efca9070000)         libhistory.so.8 => /gnu/store/lxfc2a05ysi7vlaq0m3w5wsfsy0drdlw-readline-8.1.2/lib/libhistory.so.8 (0x00007efca9063000)         libncursesw.so.6 => /gnu/store/bcc053jvsbspdjr17gnnd9dg85b3a0gy-ncurses-6.2.20210619/lib/libncursesw.so.6 (0x00007efca8ff1000)         libgcc_s.so.1 => /gnu/store/930nwsiysdvy2x5zv1sf6v7ym75z8ayk-gcc-11.3.0-lib/lib/libgcc_s.so.1 (0x00007efca8fd7000)         libc.so.6 => /gnu/store/gsjczqir1wbz8p770zndrpw4rnppmxi3-glibc-2.35/lib/libc.so.6 (0x00007efca8dd9000)         /gnu/store/gsjczqir1wbz8p770zndrpw4rnppmxi3-glibc-2.35/lib/ld-linux-x86-64.so.2 (0x00007efca90c9000)

❯ Пример: Stubby

Давайте с помощью patchelf уменьшим образ Docker для приложения Stubby — это инструмент разрешения имён, поддерживающий передачу DNS-over-TLS. За основу возьмём образ Debian, но данный процесс абсолютно типичен и не привязан ни к данному дистрибутиву Linux, ни к этому приложению.

Сначала напишем файл Dockerfile, который сначала устанавливает Stubby и все требуемые пакеты из репозиториев Debian, а на втором этапе копирует в окончательный образ только необходимые файлы, причём, этот образ создаётся с чистого листа.

# Dockerfile FROM debian:latest AS builder  # install stubby and patchelf RUN apt-get update && apt-get install -y stubby ca-certificates patchelf  # copy and run patchelf script COPY patchelf.sh /tmp/patchelf.sh RUN /tmp/patchelf.sh  # create the final image from scratch (i.e. without the base image) FROM scratch  # copy only the /out directory that contains the files that are actually used by stubby COPY --from=builder /out /  EXPOSE 53/udp EXPOSE 53/tcp  CMD ["/bin/stubby"]

Далее пишем скрипт patchelf, определяющий, какие файлы необходимо скопировать. Этот файл копирует все зависимости, интерпретатор программы, сам бинарник, файл с конфигурацией и, наконец, конфигурационные файлы библиотеки OpenSSL, а также список доверенных SSL-сертификатов.

#!/bin/sh set -ex mkdir -p /out/lib /out/bin /out/etc /out/var/cache/stubby /out/var/run /out/usr/lib # copy the libraries that stubby uses ldd /usr/bin/stubby |     sed -rne 's/.*=> (.*) \(.*\)$/\1/p' |     while read -r path; do         cp "$path" /out/lib     done # copy the interpreter cp /lib64/ld-linux-x86-64.so.2 /out/lib # copy stubby and its configuration file cp /usr/bin/stubby /out/bin/stubby # make stubby listen on all addresses to access it from outside the container sed -i 's/127\.0\.0\.1/0.0.0.0/g' /etc/stubby/stubby.yml cp -r /etc/stubby /out/etc/stubby # copy openssl library configuration and certificates cp -r /etc/ssl /out/etc/ssl cp -r /usr/lib/ssl /out/usr/lib/ssl find /out/etc/ssl/certs -not -type d -not -name ca-certificates.crt -delete rm -rf /out/usr/lib/ssl/misc # patch stubby binary to use the copied interpreter and libraries patchelf --set-interpreter /lib/ld-linux-x86-64.so.2 --set-rpath /lib /out/bin/stubby ldd /out/bin/stubby find /out # check that stubby works chroot /out /bin/stubby -V

Теперь собираем образ и убеждаемся, что он работает корректно.

$ docker build --tag stubby:debian-patchelf . $ docker inspect docker inspect -f "{{ .Size }}" stubby:debian-patchelf 13120030 $ docker run --init --rm --publish 53:53/udp stubby:debian-patchelf stubby -l # in the other terminal window $ dig @127.0.0.1 +short google.com 142.251.220.206

❯ Результаты

Сравниваем полученный в результате образ с аналогами при помощи команды docker inspect. Альтернативные образы основаны на Debian и Alpine, создавались без применения скрипта — patchelf.

Образ

Размер, MiB

Комментарий

stubby:debian-patchelf

12,5

9% от stubby:debian

stubby:debian

143,4

stubby:alpine-patchelf

9,0

64%от stubby:alpine

stubby:alpine

14,1

Результаты говорят сами за себя. По сравнению с образом Stubby для Debian наш получился на 91% меньше, а с образом Stubby для Alpine — на 36%. Только и потребовалось, что включить в образ лишь те файлы, которые на самом деле использует Stubby. Впечатляет.

❯ Ограничения

Patchelf полностью автоматизирует копирование зависимостей и интерпретатора программы, однако все остальные файлы придётся копировать вручную. Кроме того, если ваша программа не компилируется в двоичный файл ELF (например, написана на NodeJS, Python), то вам не повезло. В таком случае может помочь strace.

❯ Strace

 Photo by Lance Grandahl on Unsplash.

Photo by Lance Grandahl on Unsplash.

Этот инструмент перехватывает системные вызовы, выполняемые двоичным файлом, и вводит на экран их аргументы. Strace пользуется тем же API ядра, что и отладчики, поэтому может существенно замедлять ту программу, которая трассируется таким методом. К счастью, этот инструмент понадобится нам лишь на этапе сборки образа Docker.

❯ Пример: Home Assistant

Именно эту программу я не смог установить на Raspberry Pi Zero, когда попытался воспользоваться официальным образом Docker. При попытке подтянуть этот образ параллельно загружается множество слоёв, а затем оказывается, что их невозможно извлечь, поскольку не хватает дискового пространства. Мне пришлось временно вставить флешку и перенести на неё каталог /var/lib/docker, а потом подтянуть образ и вернуть этот каталог на Raspberry Pi — только так всё получилось, и образ запустился.

Теперь создадим новый образ Docker для Home Assistant — однослойный. Он будет занимать на диске минимум места по сравнению с исходным.

Сначала создадим Dockerfile с официальным образом и будем от него отталкиваться.

# Dockerfile FROM ghcr.io/home-assistant/home-assistant:stable AS builder  RUN apk update && apk add strace  COPY strace.sh /tmp/strace.sh RUN /tmp/strace.sh  FROM scratch  COPY --from=builder /out /  # default Home Assistant port EXPOSE 8123/tcp  # default Home Assistant command CMD ["/usr/local/bin/python3", "-m", "homeassistant", "--config", "/config"]

Затем напишем скрипт strace, он найдёт все файлы, к которым обращается Home Assistant и скопирует их в финальный образ.

#!/bin/sh set -ex mkdir -p /out/lib /out/usr/local/bin /out/usr/bin /out/usr/local/lib # copy ffmpeg and its dependencies ldd /usr/bin/ffmpeg |     sed -rne 's/.*=> (.*) \(.*\)$/\1/p' |     while read -r path; do         cp "$path" /out/lib     done cp /lib/ld-musl-x86_64.so.1 /out/lib cp /usr/local/bin/python3 /out/usr/local/bin/python3 cp /usr/bin/ffmpeg /out/usr/bin/ffmpeg # copy frontend files manually mkdir -p /out/usr/local/lib/python3.11/site-packages cp -r /usr/local/lib/python3.11/site-packages/hass_frontend /out/usr/local/lib/python3.11/site-packages/hass_frontend # copy all the files that home assistant actually opens strace -f -e open,stat,lstat timeout 30s python3 -m homeassistant --config /config 2>&1 |     sed -rne 's/.*(open|stat)\(.*"([^"]+)".*/\2/p' |     grep -vE '^/(dev|proc|sys|tmp)' |     sort -u |     while read -r path; do         if ! test -e "$path"; then             continue         fi         if test -d "$path"; then             # create directories             mkdir -p /out/"$path"         else             # copy files             mkdir -p /out/"$(dirname "$path")"             cp -n "$path" /out/"$path" 2>/dev/null || true         fi     done # recreate config directory rm -rf /out/config mkdir /out/config

Теперь нужно собрать образ и убедиться, что он работает корректно.

$ docker build --tag home-assistant:strace . $ docker run --rm --publish=8123:8123/tcp home-assistant:strace \     python3 -m homeassistant --config /config # now open https://127.0.0.1:8123/ in the browser

❯ Результаты

Мы сравнили размер полученного образа с исходным при помощи команды docker inspect.

Образ

Размер,MiB

Размер, %

home-assistant:strace

590

31

ghcr.io/home-assistant/home-assistant:stable

1886

100

Нам удалось уменьшить образ на 69%. В данном случае наиболее важно, что Raspberry Pi Zero может подтянуть новый образ и запустить его, не упираясь в предел дискового пространства.

❯ Ограничение

Очевидное ограничение strace заключается в том, что файлы фронтенда не копируются автоматически, поскольку файлы считываются лишь в тех случаях, когда сделан соответствующий HTTP-запрос. Разумеется, некоторые HTTP-запросы можно выполнять при помощи curl, но обычно нужны все файлы фронтенда. Гораздо проще скопировать в финальный образ их все.

❯ Ваши собственные образы

 Photo by Levi Guzman on Unsplash.

Photo by Levi Guzman on Unsplash.

Работать с собственными образами Docker гораздо проще, чем со сторонними. Вы можете скомпилировать вашу программу либо в статический, либо в динамически связанный двоичный файл, а затем при помощи инструмента patchelf скопировать зависимости и интерпретатор. В этом разделе будет рассказано, как компилировать статические двоичные файлы для Rust, Go и C/C++. Как правило, для сборки проекта используется библиотека musl и сочетающийся с ней инструмент musl-gcc, но в некоторых языках этот процесс организован проще.

❯ Статические двоичные файлы Rust

Чтобы применить в проекте библиотеку musl, потребуется установить основанный на musl инструментарий, а затем скомпилировать проект под целевую платформу.

$ rustup toolchain add stable --target x86_64-unknown-linux-musl  # here we remove debugging information and optimize for size $ env RUSTFLAGS='-Copt-level=z -Cstrip=symbols' \     cargo build --release --target x86_64-unknown-linux-musl

Теперь собираете образ Docker, в который включён только итоговый двоичный файл.

FROM scratch COPY target/x86_64-unknown-linux-musl/release/app /bin/app CMD ["/bin/app"]

Как видите, в результате получен образ, в котором содержится лишь двоичный файл, без зависимостей. Таким образом, при работе со статическими двоичными файлами Docker служит просто удобным распределительным механизмом.

❯ Статические двоичные файлы Go

В Go не используетя библиотека musl, но есть собственная статическая реализация libc. В таком случае компилировать статические двоичные файлы становится ещё проще.

$ env CGO_ENABLED=0 go build -ldflags '-s -w' -o app ./cmd/app

Теперь собираем образ Docker примерно так же, как и в случае с двоичным файлом Rust.

FROM scratch COPY app /bin/app CMD ["/bin/app"]

❯ Статические двоичные файлы C/C++

В данном случае попробуем заменить компилятор C/C++ на musl-gcc и активируем статическую компиляцию в GCC при помощи флага линковки -static. Также потребуется перекомпимлировать таким образом все зависимости. Именно поэтому такой подход особенно проблематичен с зависимостями, при работе с которыми по тем или иным причинам предпочитается динамическое связывание. Например, при использовании таких фич GNU libc, которые не поддерживают динамическое связывание, при динамической загрузке других библиотек или при применении сложных сборочных инструкций, которые слишком сложно вручную перестроить на статическое связывание. Вот почему, как правило, при работе с двоичными файлами C/C++ используется patchelf.

В следующем листинге показано, как скомпилировать статический двоичный файл для проекта на основе cmake.

$ cat > CMakeLists.txt << 'EOF' project (HelloWorld) add_executable (app app.c) EOF  $ cat > app.c << 'EOF' #include <stdio.h> int main() {     printf("Hello world\n");     return 0; } EOF  $ mkdir build-musl $ cd build-musl $ env CC=musl-gcc LDFLAGS='-static' cmake -DCMAKE_BUILD_TYPE=Release .. $ make [ 50%] Building C object CMakeFiles/app.dir/app.c.o [100%] Linking C executable app [100%] Built target app $ ldd ./app         not a dynamic executable

❯ Заключение

 Photo by Walter Walraven on Unsplash.

Photo by Walter Walraven on Unsplash.

Существует множество способов уменьшить размер образа Docker:

  • Включать только необходимые зависимости при помощи patchelf;

  • Включать только необходимые файлы при помощи strace;

  • Скомпилировать собственную программу в статический двоичный файл, в котором содержатся все зависимости.

В среднем удаётся уменьшить размер образа примерно на 50% (как минимум, судя по нашим экспериментам). Компактные образы Docker удобны для работы на устройствах с ограниченным объёмом ресурсов, например, на Raspberry Pi Zero. Правда, больше всего платформа выигрывает от сокращения потенциальной площади атаки, особенно, если в вашем образе не содержится таких инструментов как wgetcurl, а также интерпретаторов оболочки.

Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud в нашем Telegram-канале

Перейти ↩

📚 Читайте также:


ссылка на оригинал статьи https://habr.com/ru/articles/842536/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *