На устройствах интернета вещей (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-канале

Перейти ↩

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

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


  1. gudvinr
    12.09.2024 08:16
    +1

    Для IoT чаще всего докер вообще не нужен.

    Если статично собрать бинарники - их можно на хосте запускать без зависимостей.

    HA или какие-то другие питоновские приложения обычно через venv запускаются и будут нормально жить без установки системных пакетов.

    При таком подходе можно сделать read-only root и жить не тужить, т.к. такие девайсы практически всегда выделяются для единственной цели и на них крутится ПО, которое редко меняется. Если там стоит HA, он и будет там стоять. Если pihole, то кроме него вряд ли что-то ещё будет и т.д.

    Если за любой SBC закинуть хоть сколько-нибудь интенсивную нагрузку, работать с несколькими компонентами там будет просто невозможно.

    Контейнеры там могут быть полезны только если хочется по-приколу кластер собрать и сделать бомж-кубер, чтобы тренировать оркестрацию на кошках