Как уменьшить образ Docker для работы с устройствами IoT
- пятница, 13 сентября 2024 г. в 00:00:03
На устройствах интернета вещей (IoT) зачастую слишком мало ресурсов, и их не хватает, чтобы подтягивать и использовать тяжеловесные образы Docker. В этой статье будет показано, как можно уменьшить образ Docker на 36-91% при помощи инструментов patchelf
и strace
, не перекомпилируя при этом контейнеризованные приложения. Также рассмотрим, как создавать минимальные образы для собственных приложений, написанных на Rust, Go, C/C++.
В зависимости от того, каков размер образа Docker, и сколько в нём слоёв, зависит, сколько памяти и дискового пространства понадобится устройству для подтягивания и распаковки этого образа. У таких устройств как Raspberry Pi Zero совершенно не хватает ресурсов, чтобы распаковать, например, образ Home Assistant. Однако у Raspberry Pi Zero более чем достаточно ресурсов, чтобы запускать эту программу. Именно в таких случаях производительность Docker повысится, если уменьшить размер образа. Кроме того, если включать в образ только те файлы, которые действительно используются приложением, то уменьшается потенциальная зона атаки. Такой метод полезен не только при работе с устройствами IoT, но и применительно к серверам.
Не составляет труда уменьшать размер образа у таких контейнеризованных приложений, которые вы разрабатывали сами. Просто скомпилируйте статический двоичный файл и включите в окончательный вариант образа только этот файл. Но и при работе со сторонними приложениями есть несколько подходов, позволяющих обойтись без перекомпиляции. Среди таких подходов немаловажны те, при которых уменьшают образы контейнеризованных скриптов.
Если рассматриваемое приложение скомпилировано в двоичный файл формата 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)
Давайте с помощью 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 | Комментарий |
| 12,5 | 9% от |
| 143,4 | |
| 9,0 | 64% от |
| 14,1 |
|
Результаты говорят сами за себя. По сравнению с образом Stubby для Debian наш получился на 91% меньше, а с образом Stubby для Alpine — на 36%. Только и потребовалось, что включить в образ лишь те файлы, которые на самом деле использует Stubby. Впечатляет.
Patchelf полностью автоматизирует копирование зависимостей и интерпретатора программы, однако все остальные файлы придётся копировать вручную. Кроме того, если ваша программа не компилируется в двоичный файл ELF (например, написана на NodeJS, Python), то вам не повезло. В таком случае может помочь strace
.
Этот инструмент перехватывает системные вызовы, выполняемые двоичным файлом, и вводит на экран их аргументы. Strace пользуется тем же API ядра, что и отладчики, поэтому может существенно замедлять ту программу, которая трассируется таким методом. К счастью, этот инструмент понадобится нам лишь на этапе сборки образа Docker.
Именно эту программу я не смог установить на 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 | Размер, % |
| 590 | 31 |
| 1886 | 100 |
Нам удалось уменьшить образ на 69%. В данном случае наиболее важно, что Raspberry Pi Zero может подтянуть новый образ и запустить его, не упираясь в предел дискового пространства.
Очевидное ограничение strace
заключается в том, что файлы фронтенда не копируются автоматически, поскольку файлы считываются лишь в тех случаях, когда сделан соответствующий HTTP-запрос. Разумеется, некоторые HTTP-запросы можно выполнять при помощи curl
, но обычно нужны все файлы фронтенда. Гораздо проще скопировать в финальный образ их все.
Работать с собственными образами Docker гораздо проще, чем со сторонними. Вы можете скомпилировать вашу программу либо в статический, либо в динамически связанный двоичный файл, а затем при помощи инструмента patchelf
скопировать зависимости и интерпретатор. В этом разделе будет рассказано, как компилировать статические двоичные файлы для Rust, Go и C/C++. Как правило, для сборки проекта используется библиотека musl и сочетающийся с ней инструмент musl-gcc
, но в некоторых языках этот процесс организован проще.
Чтобы применить в проекте библиотеку 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 не используетя библиотека 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++ на 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
Существует множество способов уменьшить размер образа Docker:
Включать только необходимые зависимости при помощи patchelf
;
Включать только необходимые файлы при помощи strace
;
Скомпилировать собственную программу в статический двоичный файл, в котором содержатся все зависимости.
В среднем удаётся уменьшить размер образа примерно на 50% (как минимум, судя по нашим экспериментам). Компактные образы Docker удобны для работы на устройствах с ограниченным объёмом ресурсов, например, на Raspberry Pi Zero. Правда, больше всего платформа выигрывает от сокращения потенциальной площади атаки, особенно, если в вашем образе не содержится таких инструментов как wget
, curl
, а также интерпретаторов оболочки.
Новости, обзоры продуктов и конкурсы от команды Timeweb.Cloud — в нашем Telegram-канале ↩