Небольшое руководство о том, как можно собрать 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 и архитектурных решений.

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


  1. HemulGM
    12.07.2022 07:35
    +2

    "Костыль + костыль + костыль"
    В итоге получаем:
    - более медленный запуск (pyinstaller + upx)
    - отсутствие возможности использовать общие зависимости


    1. WoozyMasta Автор
      12.07.2022 08:17

      Еще можно добавить, что страницы памяти будут выделятся не по требованию, и на каждый экземпляр запущенного приложения будет выделен как минимум объем памяти = размеру бинарника.

      Спасибо за ответ, надеюсь в комментариях удастся собрать все плюсы и минусы, а также знающие люди предложат альтернативы, по типу Cython, Nuitka, PyOxidizer, py2exe.

      И возможно узнаем правду, что же лучше использовать для таких задач, Go, Rust или всё же дальше мучать Python.


  1. melnikovio
    12.07.2022 08:11
    +4

    А как же без отладки скриптов правкой прямо в контейнере на проде? :(