Статическое Python приложение в образе контейнера на базе scratch
Автор: WoozyMastaНебольшое руководство о том, как можно собрать Python приложение в самодостаточный статически связанный двоичный файл и упаковать его в образ контейнера на базе scratch.
Размер итогового образа контейнера получится всего лишь от 13 мегабайт.
В самом начале статьи, сразу хочу ответить на вопрос, зачем это нужно?
- Это безопаснее, потому что вы поставляете гораздо меньше зависимостей в итоговый образ.
- Это безопаснее, потому что в итоговом образе нет shell и CLI утилит.
- Это безопаснее, потому что изменить скрипт в работающем контейнере куда сложнее, ведь это упакованный бинарный файл в
scratch
образе. - Вы усложняете процесс реверс-инжиниринга вашего приложения, просто упаковав с помощью UPX, а затем где-нибудь изменив заголовок, чтобы он
- все еще загружался в память и работал нормально, но при этом утилита UPX
- завершалась с ошибкой. Большинство сдастся, если утилита UPX откажется распаковывать приложение, а для более надежной упаковки и обфускации вы всегда можете реализовать свой упаковщик.
- Меньший размер. Бывает попадается какой-нибудь Python проект упакованный в образ контейнера на базе привычного, к примеру
docker.io/python:3.9-bullseye
, в него установлены необходимые системные компоненты, а в виртуальное окружение или прямо так установлены все необходимые зависимости. Ничего в этом особенного нет, так делает большинство. Но вот размер образа порой пугает. Хорошо, что у нас на узлах, где работает контейнеризированное приложение уже давно осели в кеш слоиdebian bullseye
(124М) и слоиpython 3.9
(770M), а вот зависимости приложения, часто могут занимать большой дополнительный объем. И этот слой почти всегда уникален для каждого приложения. Возьмем еще в расчет то, что образы python есть разных версий и на базе разных дистрибутивов разных версий. По итогу необходим довольно большой объем дискового кеша, что бы не скачивать каждый раз образы контейнера перед запуском pod. Особенно актуально в очень активном Kubernetes кластере разработки. - Процесс сборки приложения конечно здесь замедляется, из-за дополнительных шагов с компиляцией. Но выигрываем мы при публикации итогового компактного образа контейнера. Также экономим дисковое пространство реестра контейнеров, если речь идет о приватном registry, а не безграничном облачном сервисе.
- Ведь есть же Go, Rust, C/++ и Asm. — Да, но мы тут конкретно про Python говорим, а не про то, какой вкус у других фломастеров.
- Ради удовольствия. Просто мне было интересно сделать это.
Компоненты
Для простых приложений достаточно будет базового набора:
- pyinstaller (репозиторий проекта) — объединяет приложение Python и все его зависимости в один пакет. Пользователь может запустить упакованное приложение без установки интерпретатора Python или каких-либо модулей.
- UPX (репозиторий проекта) — упаковщик исполняемых файлов, использует UCL алгоритм сжатия данных. UCL был разработан настолько простым, что декомпрессор может быть реализован всего в нескольких сотнях байтов кода и не требует выделения дополнительной памяти для распаковки.
- staticx (репозиторий проекта) — объединяет динамические исполняемые файлы с их зависимостями от библиотек, чтобы их можно было запускать где угодно, как статические исполняемые файлы.
- FROM scratch — зарезервированный, минимальный образ контейнера,
scratch
, используется в качестве базы для создания контейнеров с нуля, не прибегая к каким либо готовым дистрибутивам.
Данный набор не решит всех возможных ситуаций, иногда может понадобиться предварительно скомпилировать или упаковать зависимости используемые вашими зависимостями. К примеру, это не будет работать просто так, если у вас в контейнере есть Flask + uWSGI + Nginx.
Приложение
Возьмем к примеру, условное приложение app.py
, файл с методами methods.py
и конфигурационный файл приложения config.yml
. Приложение имеет некий набор зависимостей описанных в файле requirements.txt
python-dotenv==0.20.0 python-gitlab==3.6.0 python-sonarqube-api==1.2.9 ruamel.yaml==0.17.21
Базовый Dockerfile
Скорее всего, типовой Dockerfile
был бы похож на что-то подобное:
FROM docker.io/python:3.9-bullseye WORKDIR "/app" COPY requirements.txt . RUN python -m pip install --no-cache-dir --requirement requirements.txt COPY app.py methods.py config.yml ./ CMD ["/app/app.py", "/app/config.yml"]
На выходе мы получим образ с суммарным размеров всех слоев 952M, где 894M занимает базовый образ docker.io/python:3.9-bullseye
.
podman inspect localhost/app:standart | jq .[].Size | numfmt --to=iec # 952M podman inspect docker.io/python:3.9-bullseye | jq .[].Size | numfmt --to=iec # 894M
Это всего лишь 58М на слой для нашего приложения, не так много. Неужели можно сделать меньше?
Упаковка приложения
Давайте упакуем приложение в один исполняемый файл, который включит в себя все рекурсивные зависимости приложения из requirements.txt
, методы из methods.py
и не будет требовать наличия интерпретатора Python.
Применим утилиту pyinstaller
pyinstaller \ --name app-compiled \ --onefile app.py \ --paths "$(python -m site --user-site)"
ℹ️ Для ключа --path
используется динамическая подстановка окружения пользователя, что позволит упаковать все необходимые зависимости в случае как при использовании виртуального окружения, так и без него.
Pyinstaller понадобятся пакеты python3-dev и build-essential, для успешной компиляции приложения. Также не лишним будет наличие в системе whell пакета, что ускорит процесс сборки, в сравнении с eggs.
Для сжатия бинарного файла используется UPX, но в нашем примере нет необходимости вызывать его отдельно. Pyinstaller сам проверяет наличие UPX в системе и использует его автоматически. По этому нам достаточно просто установить UPX в сборочную среду. Но отдельный вызов UPX может пригодится вам, если вы используете какой нибудь сторонний двоичный файл как в составе приложения, или используете его рядом или поверх приложения.
Пример Dockerfile
с использованием pyinstaller:
FROM docker.io/python:3.9-bullseye AS build WORKDIR "/app" # hadolint ignore=DL3008,DL3013 RUN set -eux && \ apt-get update; \ apt-get install --no-install-recommends -y \ python3-dev build-essential upx; \ apt-get clean; \ rm -rf /var/lib/apt/lists/*; \ python -m pip install --no-cache-dir --upgrade --force --ignore-installed pip; \ python -m pip install --no-cache-dir --upgrade wheel pyinstaller COPY requirements.txt . RUN python -m pip install --no-cache-dir --requirement requirements.txt COPY app.py methods.py ./ RUN pyinstaller \ --name app \ --onefile app.py \ --paths "$(python -m site --user-site)" && \ strip -s -R .comment -R .gnu.version --strip-unneeded dist/app; \ # Собираем итоговый образ FROM docker.io/debian:bullseye-slim # Copy components COPY --from=build /app/dist/ / COPY config.yml / ENTRYPOINT ["/app/app"] CMD ["/app/config.yml"]
На выходе мы получили образ контейнера с упакованным приложением и суммарным объемом слоёв 94M, где базовый образ занимает 81M, а само приложение занимает уже всего 13М, что уже меньше 58М из "стандартной" сборки.
podman inspect localhost/app:packed | jq .[].Size | numfmt --to=iec # 94M podman inspect docker.io/debian:bullseye-slim | jq .[].Size | numfmt --to=iec # 81M
В текущем примере UPX не дал ощутимого результата, бинарный файл после сжатия имеет размер 14542816, а до сжатия 14542840, всего лишь 24 байта. Но в вашем случае, здесь может быть совершенно другой результат, и вы можете уменьшить размер бинарного файла вплоть до 3 крат. Все зависит от размера проекта и упаковываемых ресурсов.
Также была применена утилита strip
, что позволило выиграть 248 байт при удалении метаданных. Мелочь, но приятно.
Линковка приложения
Казалось бы, почему нам просто не начать использовать упакованный экземпляр приложения в scratch образе? Дело в том, что исполняемый файл по прежнему имеет динамические зависимости.
Проверить это мы можем при помощи утилиты ldd
— осуществляющей вывод списка разделяемых библиотек, используемых исполняемыми файлами или разделяемыми библиотеками.
# ldd /app/app linux-vdso.so.1 (0x00007ffc623d8000) libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f982179c000) libz.so.1 => /lib/x86_64-linux-gnu/libz.so.1 (0x00007f982177f000) libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f982175d000) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f9821598000) /lib64/ld-linux-x86-64.so.2 (0x00007f98217a6000)
Такой экземпляр программы не сможет запустится на базе scratch
образа, пока мы не восстановим целостность всех зависимостей.
Для создания статически связанного двоичного файла, нам поможет staticx (не путать с музыкальной группой). Сам staticx мы установим из PyPi репозитория, а вот для его работы нам еще понадобится утилита patchelf — предназначенная для изменения динамического компоновщика и RPATH исполняемых файлов ELF.
Пример изменений Dockerfile
для использования staticx:
.... # Расширим прошлый пример Dockerfile установкой patchelf и staticx RUN apt-get install --no-install-recommends -y \ python3-dev build-essential patchelf upx && \ python -m pip install --no-cache-dir --upgrade wheel staticx pyinstaller .... # Расширим прошлый пример Dockerfile вызовом staticx RUN set -eux && \ pyinstaller --name app-compiled --onefile app.py --paths "$(python -m site --user-site)"; \ # Создаем статически линкованный бинарный файл \ staticx --strip dist/app-compiled dist/app; \ # Staticx делает strip, но утилита strip делает его лучше # Не пытайтесь запускать strip до staticx - вы всё сломаете strip -s -R .comment -R .gnu.version --strip-unneeded dist/app-compiled; \ # Создадим каталог tmp, он понадобится для последующей работы приложения \ mkdir -p dist/tmp; \ .... # Теперь уже результат упаковываем в scratch FROM scratch # Копируем готовый дистрибутив приложения COPY --from=build /app/dist/app /app ENTRYPOINT ["/app"] CMD ["/config.yml"]
На выходе теперь у нас полностью самодостаточный контейнер общим размером всего в 15М. Это конечно больше 13М из предыдущего примера, ведь мы также упаковали в него разделяемымые библиотеки такие как glibc, zlib и т.п. Но жертвуя этими 2М мы получаем безопасное окружение без shell и CLI утилит, что в любом случае меньше исходных 58М "стандартной" сборки.
podman inspect localhost/app:linked | jq .[].Size | numfmt --to=iec # 15M
Итоговый Dockerfile
В итоге получился вот такой Dockerfile
:
# Определим сборочный контейнер # ----------------------------- FROM docker.io/python:3.9-bullseye AS build # Укажем рабочий каталог WORKDIR "/app" # Установим зависимости # hadolint ignore=DL3008,DL3013 RUN set -eux && \ apt-get update; \ apt-get install --no-install-recommends -y \ # python3-dev и build-essential - понадобиться для pyinstaller \ # UPX - уменьшит размер итоговых файлов \ # build-essential и patchelf - нужен staticx \ python3-dev build-essential patchelf upx; \ apt-get clean; \ rm -rf /var/lib/apt/lists/*; \ # обновим pip и установим wheel, staticx и pyinstaller \ python -m pip install --no-cache-dir --upgrade --force --ignore-installed pip; \ python -m pip install --no-cache-dir --upgrade wheel staticx pyinstaller # Установим зависимости COPY requirements.txt . RUN python -m pip install --no-cache-dir --requirement requirements.txt # Скопируем приложение COPY app.py methods.py ./ # Создадим бинарный файл приложения RUN set -eux && \ # Создадим и упаковываем единый бинарный файл приложения \ pyinstaller \ --name app-compiled \ --onefile app.py \ --paths "$(python -m site --user-site)"; \ # Создаем статически линкованный бинарный файл \ staticx --strip dist/app-compiled dist/app; \ # Выиграем дополнительно несколько сотен байт, обрезав лишние метаданные \ strip -s -R .comment -R .gnu.version --strip-unneeded dist/app-compiled; \ # Создадим каталог tmp, он понадобится для последующей работы приложения \ mkdir -p dist/tmp; \ # Убедимся что права выставлены верно \ chmod -c 755 ./app; \ chown -c 0:0 ./app # pyinstaller по умолчанию использует UPX если он найден в системе # отдельный вызов UPX только сломает статически линкованное приложение # upx --no-progress --no-color --ultra-brute dist/app; \ # получите ошибку: Failed to find .staticx.archive section # но он может пригодится вам для предварительной упаковки сторонних # зависимостей перед использованием pyinstaller и staticx # Копируем ресурсы, конфигурацию и прочие файлы # этот шаг можно также пропустить, и упаковать все ресурсы в бинарный файл # на этапе его создания при помощи pyinstaller COPY config.yml ./dist/ # Собираем итоговый образ # ----------------------- FROM scratch # Копируем готовый дистрибутив приложения COPY --from=build /app/dist/app /app ENTRYPOINT ["/app"] CMD ["/config.yml"]
SSL
Возможно, внимательный читатель заметил, что в итоговый контейнер не добавлен ca-certificates.crt
бандл.
Дело в том, что в нашем примере пакеты python-gitlab
и python-sonarqube-api
используют стандартную библиотеку requests
, а она уже имеет встроенный набор доверенных корневых сертификатов, и всё это попадает в содержимое бинарного файла при работе pyinstaller.
Если вам необходимо добавить свои сертификаты или компонент вашего приложение использует системные сертификаты, добавьте их в контейнер и укажите в переменных окружения пути к ним:
.... # Обновим бандл с корневыми сертификатами RUN apt-get install --no-install-recommends -y ca-certificates .... # Скопируем сертификаты из сборочного контейнера COPY --from=build /etc/ssl/certs/ca-certificates.crt /ssl/ # Укажем путь к бандлу с корневыми сертификатами для requests ENV REQUESTS_CA_BUNDLE=/ssl/ca-certificates.crt # Укажем путь к бандлу с корневыми сертификатами для системы ENV SSL_CERT_DIR=/ssl/ ENV SSL_CERT_FILE=/ssl/ca-certificates.crt
Пример для практики
А здесь (gist) вы можете найти пример Hello World приложения и проверить описанное в статье сами.
А как же Flask + uWSGI + Nginx
Если данное руководство вам показалось интересным, дайте мне об этом знать, и я постараюсь описать в отдельной статье, то как я собирал статически связанное приложение на Flask и Bjoern, уместив это в пару десятков мегабайт scratch
образа, при этом RPS оказался выше, а потребление ресурсов меньше чем у uWSGI и Gunicorn.
На этом всё
Благодарю за ваше время и внимание! Компактных, надежных и безопасных контейнеров вам.