Пакуем python-код в единый, независимый, standalone бинарник.
2022.08.30
Для того, чтобы любая утилита могла приносить какую-то пользу, её надо доставить конечному пользователю. В контексте python-приложений этот вопрос можно считать не до конца закрытым.
Например, традиционно принято распространять модули через индекс PyPi. Этот индекс традиционно работает с тремя типами пакетов: source distributions, Wheel и Egg (оба относятся к build distributions). Все эти типы пакетов решают одну и ту же проблему дистрибуции приложения, и не понятно, какой из них лучше.
Ещё больше проблем возникнет, если мы хотим отдать наше приложение не-python разработчику, или вообще не разработчику: надо установить python определённой версии, установить соответствующий pip, подумать о возможных конфликтах зависимостей и т.д.
Рассмотрим нетрадиционные варианты поставки python-приложений. Здесь перечисленны совсем не все (можно извращаться до бесконечности), а только интересные мне:
we ship a whole fucking Windows VM to avoid problems with Python depencencies
Очевидно, вариант с бинарным файлом выглядит лучше остальных. Рассмотрим, как его можно реализовать на практике.
Для упаковки 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 работает как классический 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’ом:
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 |
По результатам заметно, что бинарник, сгенерированный pyinstaller
ом в полтора раза медленее обычного интерпретатора python’а.
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
Сравним производительность полученного бинарника с голым интерпретатором:
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)
Pyoxidizer поддерживает использование C-модулей, но с некоторыми пометками:
Ещё немного инфы есть в packaging pitfals.
Рекомендуются специальные дистрибутивы, подготовленные для максимальной портируемости. У них есть свои проблемы и ограничения, такие как: * 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 для них, но не перечисляет конкретно.
Кратко просуммирую написанное выше: