Небольшое руководство о том, как можно собрать 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 приложения и проверить описанное в статье сами.
Размер этого hello-world приложения на выходе составляет 13494905
байт (13М), pyinstaller 11М сборки Python и staticx 2М упаковки динамических зависимостей.
Пример статического Hello World приложения
А как же Flask + uWSGI + Nginx
Если данное руководство вам показалось интересным, дайте мне об этом знать, и я постараюсь описать в отдельной статье, то как я собирал статически связанное приложение на Flask и Bjoern, уместив это в пару десятков мегабайт scratch
образа, при этом RPS оказался выше, а потребление ресурсов меньше чем у uWSGI и Gunicorn.
На этом всё
Благодарю за ваше время и внимание! Компактных, надежных и безопасных контейнеров вам.
Присоединяйтесь в телеграмм канал, где я иногда публикую заметки на тему DevOps, SRE и архитектурных решений.
HemulGM
"Костыль + костыль + костыль"
В итоге получаем:
- более медленный запуск (pyinstaller + upx)
- отсутствие возможности использовать общие зависимости
WoozyMasta Автор
Еще можно добавить, что страницы памяти будут выделятся не по требованию, и на каждый экземпляр запущенного приложения будет выделен как минимум объем памяти = размеру бинарника.
Спасибо за ответ, надеюсь в комментариях удастся собрать все плюсы и минусы, а также знающие люди предложат альтернативы, по типу Cython, Nuitka, PyOxidizer, py2exe.
И возможно узнаем правду, что же лучше использовать для таких задач, Go, Rust или всё же дальше мучать Python.