Паковка python-утилит в бинарник

Пакуем python-код в единый, независимый, standalone бинарник.

2022.08.30

Паковка

Для того, чтобы любая утилита могла приносить какую-то пользу, её надо доставить конечному пользователю. В контексте python-приложений этот вопрос можно считать не до конца закрытым.

Например, традиционно принято распространять модули через индекс PyPi. Этот индекс традиционно работает с тремя типами пакетов: source distributions, Wheel и Egg (оба относятся к build distributions). Все эти типы пакетов решают одну и ту же проблему дистрибуции приложения, и не понятно, какой из них лучше.

Ещё больше проблем возникнет, если мы хотим отдать наше приложение не-python разработчику, или вообще не разработчику: надо установить python определённой версии, установить соответствующий pip, подумать о возможных конфликтах зависимостей и т.д.

Нетрадиционные варианты

Рассмотрим нетрадиционные варианты поставки python-приложений. Здесь перечисленны совсем не все (можно извращаться до бесконечности), а только интересные мне:

Очевидно, вариант с бинарным файлом выглядит лучше остальных. Рассмотрим, как его можно реализовать на практике.

Паковка python-приложений в бинарный файл.

Для упаковки python-приложения в бинарный файл нам потребуется некая инфраструктура, которая запакует интерпретатор, приложение, его ресурсы и зависимости в единую сущность.

В простейшем случае можно обойтись bash-скриптом с приписанным в конце tar-архивом:

Рассмотрим уже готовые реализации паковщиков python-приложений.

Паковщики

В недрах Github’а можно найти несчётное количество реализаций паковки python-приложений, но надо выбрать несколько для сравнений. Основные критерии сравнения:

Самые интересные находки:

Название Платформы Релиз
pyinstaller Win/macOS/Unix 2022 9.5k
pyoxidizer Win/macOS/Unix 2022 4.2k
cx-freeze Win/macOS/Unix 2022 956
py2app macOS 2022 208
py2exe Win 2022 463
exxo Unix 2016 460
bbfreeze Win/macOS/Unix 2014 92

Под заданные критерии лучше всего подходят pyinstaller и pyoxidizer.

pyinstaller

pyinstaller работает как классический self-extracting archive: при запуске бинарника он распаковывает ресурсы в %tmp%, запускает интерпретатор с правильными путями, схлопывается и подчищает за собой мусор. Это влечёт за собой проблемы с производительностью при запуске приложения: каждый раз мучать диск распаковкой ресурсов - дорого.

Соберём пример приложения с помощью pyinstaller’а:

╰─❯ pip install -U pyinstaller

╰─❯ echo "print('Hello world')" > ./app.py

╰─❯ pyinstaller ./app.py
...

╰─❯ file ./dist/app/app
./dist/app/app: ELF 64-bit LSB executable, x86-64...

Проверим производительность полученного бинарного файла, по сравнению с обычным python’ом:

╰─❯ hyperfine "./dist/app/app" "python3.8 ./app.py" --warmup 10
Command Mean [ms] Min [ms] Max [ms] Relative
./dist/app/app 22.8 ± 0.9 21.7 25.5 1.65 ± 0.11
python3.8 ./app.py 13.8 ± 0.8 12.9 17.8 1.00
Summary
  'python3.8 ./app.py' ran
    1.65 ± 0.11 times faster than './dist/app/app'

По результатам заметно, что бинарник, сгенерированный pyinstallerом в полтора раза медленее обычного интерпретатора python’а.

pyoxidizer

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

Соберём тот же самый пример приложения через pyoxidizer:

╰─❯ pip install -U pyoxidizer

╰─❯ pyoxidizer init-config-file example

╰─❯ cd example

╰─❯ echo "print('Hello world')" > ./app.py

╰─❯ vim pyoxidizer.bzl
// добавить строчку в конец функции make_exe
python_config.run_filename = "./app.py"

╰─❯ pyoxidizer build
...

╰─❯ file ./build/x86_64-unknown-linux-gnu/debug/install/example
./build/x86_64-unknown-linux-gnu/debug/install/example: ELF 64-bit LSB shared object, x86-64

Сравним производительность полученного бинарника с голым интерпретатором:

╰─❯ hyperfine "./.../example" "python3.8 ./app.py" --warmup 10
Command Mean [ms] Min [ms] Max [ms] Relative
./.../example 15.2 ± 0.8 14.3 20.5 1.10 ± 0.08
python3.8 ./app.py 13.8 ± 0.6 12.9 16.9 1.00
Summary
  'python3.8 ./app.py' ran
    1.10 ± 0.08 times faster than './build/x86_64-unknown-linux-gnu/debug/install/example'

Намного лучше, чем с pyinstaller’ом.

Можно выжать и больше производительности, например в случае с большим количеством импортов. Почитать об этом можно в статье одного из разработчиков pyoxidizer’а, где автор проверял производительность при помощи импортирования почти всей stdlib:

 pyenv installed CPython 3.7.2

# Cold disk cache.
$ time ~/.pyenv/versions/3.7.2/bin/python < import_stdlib.py
real   0m0.405s
user   0m0.165s
sys    0m0.065s

# Hot disk cache.
$ time ~/.pyenv/versions/3.7.2/bin/python < import_stdlib.py
real   0m0.193s
user   0m0.161s
sys    0m0.032s

# PyOxidizer with PGO CPython 3.7.2

# Cold disk cache.
$ time target/release/pyapp < import_stdlib.py
real   0m0.227s
user   0m0.145s
sys    0m0.016s

# Hot disk cache.
$ time target/release/pyapp < import_stdlib.py
real   0m0.152s
user   0m0.136s
sys    0m0.016s

Посмотрим подробнее на PyOxidizer.

Перекись питона

PyOxidizer — довольно молодой проект, написанный на модном, стильном, молодёжном rust’е. Состоит из набора модулей, позволяющих встраивать интерпретатор python’а в rust-код, управлять им и паковать ресурсы в бинарный файл.

Насколько мне известно, развитие встроенного в Rust интерпретатора Python’а началось с пакета inline_python, который позволял инлайнить python-скрипты прямо в rust-код:

use inline_python::python;

fn main() {
    let who = "world";
    let n = 5;
    python! {
        for i in range('n):
            print(i, "Hello", 'who)
        print("Goodbye")
    }
}

Под капотом он использует pyo3 — библиотеку для связи python’а и rust’а. Этот же движок используется и в pyembed, который входит в состав pyoxidizer’а и отвечает за управление интерпретатором:

fn do_it(interpreter: &MainPythonInterpreter) -> {
    interpreter.with_gil(|py| {
         match py.eval("print('hello, world')") {
            Ok(_) => print("python code executed successfully"),
            Err(e) => print("python error: {:?}", e),
        }
    });
}

oxidized_import — кастомный движок импортов, реализующий загрузку ресурсов (в том числе из памяти), их сканирование и сериализацию.

Для создания standalone-бинарников используется непосредственно pyoxidizer, комбинирующий в себе pyembed и oxidized_import.

В документации описано, как это работает. Краткий и очень вольный пересказ: * собирается pyembed с оптимизированным для встраивания бинарником интерпретатора; * собирается архив с исходниками и зависимостями python-утилиты; * из pyembed и архива ресурсов собирается готовый бинарник.

Загрузка ресурсов

В стандартном python’е для загрузки ресурсов с диска — например, шаблонов, данных, изображений и etc. — часто используется глобальная переменная __file__, указывающая на путь к текущему файлу в системе:

def get_resource(name):
    """Return a file handle on a named resource next to this module."""
    module_dir = os.path.abspath(os.path.dirname(__file__))
    resource_path = os.path.join(module_dir, name)

    return open(resource_path, 'rb')

С этой переменной есть некоторые проблемы — официальная документация говорит о том, что в определённых случаях __file__ не будет задан: >The pathname of the file from which the module was loaded, if it was loaded from a file. The __file__ attribute may be missing for certain types of modules, such as C modules that are statically linked into the interpreter. For extension modules loaded dynamically from a shared library, it’s the pathname of the shared library file.

В случае с pyoxidizer’ом, __file__ теряет весь свой смысл, т.к. модули загружаются из памяти. Заметки из документации pyoxidizer’а: >It isn’t clear whether __file__ is actually required and what all is derived from existence of __file__. It also isn’t clear what __file__ should be set to if it wouldn’t be a concrete filesystem path. Can __file__ be virtual? Can it refer to a binary/archive containing the module?

По умолчанию, __file__ в pyoxidized-бинарниках не задан, и рекомендуется использовать ResourceAPI (Python 3.7+):

def get_resource(name):
    """Return a file handle on a named resource next to this module."""
    # get_resource_reader() may not exist or may return None, which this
    # code doesn't handle.
    reader = __loader__.get_resource_reader(__name__)
    return reader.open_resource(name)

C-расширения

Pyoxidizer поддерживает использование C-модулей, но с некоторыми пометками:

Ещё немного инфы есть в packaging pitfals.

Дистрибутивы python’а

Рекомендуются специальные дистрибутивы, подготовленные для максимальной портируемости. У них есть свои проблемы и ограничения, такие как: * Backspace Key Doesn’t work in Python REPL * Windows Static Distributions are Extremely Brittle * Static Linking of musl libc Prevents Extension Module Library Loading * Incompatibility with PyQt on Linux

PyOxidizer заявляет, что содержит workarounds для них, но не перечисляет конкретно.

Выводы

Кратко просуммирую написанное выше:

Ссылки