На скриптовых языках удобно разрабатывать… И на этом удобство заканчивается. Вне машины разработчика начинаются проблемы. Особенно если вы пишете какой-то прикладной тулинг — cli-утилиты, вспомогательные приложения в вашем SDK и прочее. Вы даже не можете рассчитывать на то, что у пользователя будет pip, чтобы он смог поставить все ваши зависимости, вам все нужно организовать самостоятельно.

В этой статье поговорим о различных формах распространения Python-приложений. Какие есть подходы и инструменты, почему они могут не сработать и как чинить возникающие проблемы.

Если вам покажется, что в чем-то я ошибаюсь, добро пожаловать в комментарии. Буду рад услышать любые альтернативные точки зрения. Кроме, как я уже отметил в заголовке, рекомендации переписать все на Go/Rust/You name it :) Этот холивар мы уже проходили.

Для начала коротко вводные.

Я — Арсений Сапелкин, тимлид в команде, которая занимается инструментами разработчика для собственной микроядерной операционной системы «Лаборатории Касперского» KasperskyOS. Наша ОС существенно отличается от других систем, поскольку внутри — не Linux-ядро. Отличий довольно много, даже процессы здесь запускаются и взаимодействуют друг с другом не так, как мы привыкли. Поэтому тулинг нам часто приходится делать свой, стараясь упростить жизнь разработчика.

У нас есть эмулятор KasperskyOS — это форк-qemu с нашей логикой поверх него. Есть консольная cli-утилита с широким функционалом и большим количеством зависимостей. Через нее разработчик может быстро сгенерировать приложение, собрать и задеплоить устройство, запустить эмулятор и т. п. По сути, для него это единая точка входа. У нас также есть плагин для VS Code, который позволяет почти все то же самое делать в этом редакторе.

Почти все это мы пишем на Python и поставляем в составе deb-пакетов. У нас также есть модули, переиспользуемые в других проектах на Python. Задача — упаковать все это так, чтобы быть уверенными в работоспособности тулинга вне машины разработчика. При этом речь пойдет только про *nix, потому что наш SDK пока что предназначен только для этого семейства ОС.

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

Требования к тулингу

Начнем с требований, которые мы предъявляем к тулингу в SDK.

Обязательно:

  • Переносимость между основными дистрибутивами Linux. Некоторые компании могут позволить себе делать сборку под каждый Linux, но это дорого и имеет свои сложности. В идеале хочется, чтобы наши тулы работали везде примерно одинаково.

  • Разворачивание без pip и прочих Python-инструментов. Хотелось бы избежать стандартной проблемы Python-приложений, когда пользователь сам должен быть немного разработчиком — он должен скачать себе pip и только с помощью его сможет что-то запустить.

  • Полная совместимость с докером — наши инструменты должны работать внутри докера без ограничений.

Было бы неплохо в контексте тулинга:

  • Поставка в виде одного файла.

  • Поставка без установщика. Сейчас установщик у нас есть, но не факт, что так будет всегда.

  • Обработка зависимостей (возможно, даже включая сам Python).

В целом ни для кого не секрет, что Python для этого не очень подходит. Со времени возникновения языка такая проблема вообще не ставилась и не решалась, этот язык изначально для другого.

Python hasn’t ever had a consistent story for how I give my code to someone else, especially if that someone else isn’t a developer and just wants to use my application.

© Russell Keith-Magee

Но в этой статье я хочу показать, что не все так плохо. Если вам, как и нам, все-таки хватает причин писать на Python, есть много способов решить эти проблемы.

Почему мы все-таки пишем на Python

Первая и основная причина в том, что еще до того, как мы начали системно подходить к этому вопросу, на Python было написано уже достаточно много инструментов. Вторая причина в том, что среди сотрудников компании язык очень распространен, буквально все знают его как второй. В итоге Python устраивал нас в вопросе скорости разработки, поиска разработчиков и простой интеграции с другими инструментами в компании. Нельзя однозначно утверждать, что решение было принято верное, однако пока мы не пожалели.

Вот это реальное видео с питоном, которого посадили в управляемую трубу с ногами, кажется удачной метафорой для темы статьи (не переживайте, с пятнистым красавцем экспериментаторы вели себя максимально вежливо).
Вот это реальное видео с питоном, которого посадили в управляемую трубу с ногами, кажется удачной метафорой для темы статьи (не переживайте, с пятнистым красавцем экспериментаторы вели себя максимально вежливо).

Тестовый проект (пациент)

Для демонстрации инструментов я написал небольшое приложение, которое не делает ничего полезного. Оно просто зависит от нескольких библиотек — pycurl, psycopg2, clickе (далее станет понятно, почему я выбрал именно эти библиотеки). Также у приложения есть свое C-расширение, которое просто проверяет, что все правильно импортируется и работает.

Его код на самом деле не принципиален, но он доступен на GitHub. Там есть скрипты, которые собирают приложения с помощью разных способов упаковки (внимание, спойлеры!), описанных ниже, а также запускают бенчмарки и тесты.

Пока нам важно, что если все идет хорошо, то приложение работает как-то так:

-> % python3 -m myapp.myapp run
psycopg2 works! 
pycurl works! 
myextension works! 

Ну что же, пациент готов, давайте экспериментировать.

Pex (Python EXecutable)

Начнем с самого первого и достаточно простого способа, который не требует почти никаких приседаний. Это pex, 2.4k.

Вы знаете, что Python умеет интерпретировать zip-архивы? Формат ZIP-архива, являющегося приложением, описан в PEP 441. Работает он достаточно просто — прямо в zip-архиве первой строчкой идет шебанг, который и показывает ОС, что его надо запустить через Python. Далее происходит некоторая магия, которая нам сейчас не особо интересна.

-> % cat myapp.pex 
#!/usr/bin/env python3.11 
PK^C^D^@!^K^@^@^@.bootstrap/^C^@^@^@^@ ^@^@^@^O^@^@^@.bootstrap/pex/^@ 
...

Pex — это инструмент, который позволяет засунуть в этот архив все, что нам нужно. Например, можно добавить любое сложное окружение (весь virtualenv) и носить его с собой.

-> % pex $(pip freeze) -o my_virtualenv.pex
-> % deactivate
-> %  ./my_virtualenv.pex
Python 3.11.5
(InteractiveConsole)
>>>import .... 

Можно добавить наш пакет, чтобы запускалось конкретное приложение.

-> % pex . -c myapp -o myapp.pex
-> %  ./myapp.pex run
psycopg2 works! 
pycurl works!
myextension works!

Инструмент разработан компанией (тогда еще) Twitter для упрощения деплоя веб-сервисов. Как пишут авторы, благодаря pex для обновления становится достаточным вызов scp.

У pex много преимуществ, например простота как использования, так и внутреннего устройства, также вариативность паковки — он позволяет собрать пакет под несколько платформ.

Но вот с перформансом может возникнуть проблема.

Когда все запущено, разницы во времени работы приложения не будет никакой. Но, к сожалению, нам важен именно перформанс первого запуска. Такова специфика тулинга. Здесь не нужно, чтобы сервер обрабатывал много запросов в секунду, но нужно, чтобы пользователь мог быстро стартануть, так что перформанс ограничивается первой секундой. А здесь время старта будет очень медленным, просто потому, что ZIP-архив — это не бесплатно. Его надо прочитать, куда-то распаковать и т. д. На моем маленьком примере простого вывода хелпа утилита работала в 10 раз медленнее.

Но давайте попробуем pex. Команда от нас ничего не требует — все подхватывается автоматически и круто работает.

-> % pex . -c myapp -o myapp.pex
-> % ./myapp.pex run
psycopg2 works!
pycurl works!
myextension works!

Попробуем запустить на другой машине с той же самой Ubuntu, что и у нас (ну и, естественно, с тем же Python, чтобы интерпретировать pex). И происходит что-то странное: 

-> % docker run ubuntu_with_python:latest ./myapp.pex run

File "/root/.pex/installed_wheels/53...b9/
psycopg2-2.9.7-cp311-cp311-linux_x86_64.whl/psycopg2/__init__.py"
, line 51, in <module>
    from psycopg2._psycopg import ( # noqa
ImportError: libpq.so.5: cannot open shared object file: No such file or directory

На той системе, куда мы поставили наше приложение, нет библиотеки libpq, которая требуется одной из зависимостей — psycopg. Эту проблему можно решить и для pex самостоятельно, о чем мы скажем позже, но сам pex нам в этом не поможет, так что здесь мы не будем на этом останавливаться.

Предлагаю подвести итог по pex (а дальше и по всем остальным инструментам) в виде такой таблички.

 

Обозначения следующие:

  • Sys deps — возможность поставки системных зависимостей;

  • Py deps — автоматическая рекурсивная обработка «питонячих» зависимостей;

  • Docker — нормальная работоспособность в Docker без допнастроек;

  • Python — возможность поставки самого Python (чтобы можно было разворачивать у пользователя, у которого он не стоит);

  • W/o installer — поставка без необходимости запуска установщика;

  • Performance — запуск без потерь в скорости.

В принципе, результат неплохой. Только он не поддерживает системные зависимости, не позволяет взять с собой Python и просаживает производительность. Но если вы контролируете окружение и не требовательны к скорости запуска, это очень интересный способ поставки.

Пакуемся в deb-пакет

Более наивная работа с такими зависимостями — это spotify/dh-virtualenv, ⭐1.6k.

Этот инструмент пакует Python-приложение в deb-пакет, таким образом позволяя управлять зависимостями через системный менеджер пакетов.

Пакет dh-virtualenv использовать не обязательно, аналог легко написать самому. dh-virtualenv просто комбинирует virtualenv и deb-пакет, позволяя прописывать системные зависимости. И все это делается буквально за пару строк, к примеру:

debian/rules

%:
    dh $@ --with python-virtualenv
override_dh_virtualenv:
    dh_virtualenv --setuptools --python /usr/bin/python3

debian/control 

...
Build-Depends: python3-dev, python3-setuptools, python3-pip, dh-virtualenv
...
Package: myapp
Depends: ${shlibs:Depends}, ${misc:Depends}, libcurl4-openssl-dev, libpq-dev, python3 (>= 3.8)

 Собирается одной командой:

-> % dpkg-buildpackage -us -uc -b

Теперь пакет можно установить на другой машине:

-> % dpkg-deb -I myapp_0.1-1_amd64.deb
…
    Depends: libc6 (>= 2.28), libcurl4 (>= 7.56.1),
        libexpat1 (>= 2.1~beta3), libpq5 (>= 10~~),
        libssl1.1 (>= 1.1.0), zlib1g (>= 1:1.2.0),
            libcurl4-openssl-dev, libssl-dev, libpq-dev
...
-> % dpkg -i myapp_0.1-1_amd64.deb
-> % /opt/venvs/myapp/bin/myapp run
psycopg2 works!
pycurl works!
myextension works!

Подведем итоги:

Результат неплохой, но хорошо подходит только тем, кто уверен, что целевой платформой всегда будет Debian. Инструмент используется в Spotify для деплоя Python-сервисов.  

AppImage

Если погуглить вопрос поставки портабельных приложений под Linux, можно быстро наткнуться на такие инструменты, как flatpak, snap и AppImage. Рассмотрим AppImage, в нашем случае он подходит лучше, так как не требует установщика и специального тулинга на машине пользователя.

Чтобы запаковать с помощью AppImage Python-окружение и приложение, есть готовый проект python-appimage,⭐157. В случае моего подопытного приложения все делается одной командой.

-> % python-appimage build app .
...
-> % ./myapp-x86_64.AppImage run
...

В дополнение python-appimage публикует готовые пакеты с портативным интерпретатором, т. е. они будут работать везде.

Стоит сказать пару слов о том, как AppImage-файл устроен внутри. В начале файла есть небольшой исполняемый кусок, который оставшийся файл монтирует как squash-fs, и дальше уже с этой системы происходит запуск приложения. Работает этот механизм довольно быстро, но все-таки до нативного Python по скорости запуска будет далеко — AppImage на моем приложении оказался в 3–4 раза медленнее.

Однако это лучше, чем pex. На мой взгляд, все, что в рамках 300 мс, нормально для консольного инструмента.

Но есть проблема с Docker. Монтирование и в целом использование fuse требует определенных привилегий, поэтому AppImage не запустится в контейнере, запущенном без опций: “--cap-add SYS_ADMIN --device /dev/fuse:mrw --cap-add MKNOD”

-> % sudo docker run debian-fuse:latest myapp.AppImage --help
fuse: device not found, try 'modprobe fuse' first
open dir error: No such file or directory

Для кого-то это может быть неважно, но для нас этот фактор критичен.

Если подводить итоги, то в принципе все неплохо.

AppImage подойдет тем, для кого Docker не актуален, а скорость запуска не принципиальна. К примеру, для многих графических приложений.

Пакуемся в самораспаковывающийся бинарник

Если нужен просто исполняемый файл — причем без использования fuse и без установщика — можно довольно быстро найти популярный pyinstaller. Но здесь я решил рассказать не про него, а про pyoxidizer. Мой коллега, Евгений Пистун, провел исчерпывающее исследование на эту тему, и по итогам нашим фаворитом стал именно pyoxidizer, ⭐5.2k. Это довольно интересный проект.

Pyoxidizer

Pyoxidizer хорош своей очень гибкой системой конфигурирования. В качестве языка конфигурации используется язык Starlark, похожий на сам Python. Ниже на примере моего приложения описано, какой Python нужно запаковать в бинарник, какой пакет установить.

def make_exe():
    dist = default_python_distribution(python_version = '3.8')
    …
    exe = dist.to_python_executable(
    …
    for resource in exe.pip_install(["myapp"]):
        exe.add_python_resource(resource)
    ...

register_target("exe", make_exe)
...

Доступно много всяких настроек — можно попросить установить в это окружение пакеты с PyPI, добавить ресурсов, указывать, откуда подтягивать эти библиотеки (из памяти или с диска). Работает под macOS, Linux и Windows.

Что самое вкусное — это производительность. На своем приложении я даже сначала не поверил — pyoxidizer работает быстрее, чем запуск нативного Python. И специально для этого я ничего не делал (взял по дефолту) — по идее, все это можно еще ускорить.

Это прикольно, но не думаю, что pyoxidizer действительно будет существенно быстрее во всех случаях. Однако будем считать, что платы производительностью за эту упаковку почти нет.

Подводя итоги:

Единственное, что хочется отметить, pyoxidizer хорошо подойдет для тех, у кого много свободного времени. На то, чтобы получить хоть как-то работающий бинарник, у меня ушло 40 минут. По-моему, там все действительно сложно.

Компилируемся в честный исполняемый файл

Осталось рассмотреть последний и самый интересный способ — конвертацию Python-кода в С с последующей его компиляцией.

Компилировать можно с помощью специальных инструментов, например:

  • nuitka

  • codon

  • cython

Но cython и codon нам не подходят, так как влияют на то, как нам приходится писать код, а мы хотим не подстраивать код под способ паковки. Здесь гораздо лучше подойдет nuitka. Я про нее расскажу далее, но пока несколько слов о том, почему это вообще возможно и достаточно легко — почему за один вечер можно на коленке написать отдаленное подобие такого инструмента.

Python C API

У самой распространенной реализации Python-интерпретатора — cpython — есть очень хорошо описанное C API, давайте продемонстрируем, что любую строку Python можно при помощи него перевести на C.

Например, у нас есть приложение, которое импортирует пару модулей, выполняет какие-то функции и выводит результат на экран:

import json
from urllib import request
response = request.urlopen('https://api64.ipify.org?format=json')
ip_info = json.load(response)
print(f"Public IP: {ip_info['ip']}")

 Чтобы перевести его на C, понадобится Python.h.

#include <Python.h>
int main ()
{
    Py_Initialize();
    ... 
}

Импортировать остальные модули мы можем практически такими же командами.

Прошу учесть, что этот код написан для примера и у него не будет обработки ошибок. Пожалуйста, не пишите так код на C. Каждая строчка здесь потенциально может взорваться.

import json
from urllib import request
PyObject *jsonModule = PyImport_ImportModule("json");
PyObject *requestModule = PyImport_ImportModule("urllib.request");

Получим и вызовем нужные нам функции:

response = request.urlopen('https://api64.ipify.org?format=json') 
ip_info = json.load(response) 

PyObject *urlopen = PyObject_GetAttrString(requestModule, "urlopen"); 
PyObject *args = Py_BuildValue("(s)" , "https://api64.ipify.org?format=json"); 
PyObject *response = PyObject_CallObject(urlopen, args); 

PyObject *loadFunc = PyObject_GetAttrString(jsonModule, "load"); 
PyObject *ip_info = PyObject_CallObject(loadFunc, Py_BuildValue("(O)" , response));

И в конце выводим результат.

Python: 
print(f"Public IP: {ip_info['ip']}") 

C: 
PyObject *pyStr = PyObject_Str(ipString); 
const char *str = PyUnicode_AsUTF8(pyStr); 
printf("Public IP: %s\n" , str); 

Получился чистый С, который можно собрать, и он не будет ни от чего зависеть, кроме libc (если статически слинковаться с libpython).

Py_Initialize(); 
PyObject *jsonModule = PyImport_ImportModule("json"); 
PyObject *requestModule = PyImport_ImportModule("urllib.request"); 

PyObject *urlopen = PyObject_GetAttrString(requestModule, "urlopen"); 
PyObject *args = Py_BuildValue("(s)" , "https://api64.ipify.org?format=json"); 
PyObject *response = PyObject_CallObject(urlopen, args); 

PyObject *loadFunc = PyObject_GetAttrString(jsonModule, "load"); 
PyObject *ip_info = PyObject_CallObject(loadFunc, Py_BuildValue("(O)" , response)); 

PyObject *ipString = PyObject_GetItem(ip_info, Py_BuildValue("s" , "ip")); 
PyObject *pyStr = PyObject_Str(ipString); 
const char *str = PyUnicode_AsUTF8(pyStr); 
printf("Public IP: %s\n" , str);

Примерно так работают все подобные инструменты. Если зайти в код nuitka, обнаружим там аналогичную генерацию С-кода. Насколько я понял, он использует парсер самого cpython — прогоняет его и делает огромное количество оптимизаций. Дальше код отправляется в gcc или clang.

Nuitka

Nuitka, ⭐10.7k — это на самом деле Анютка. Создатель инструмента назвал его в честь своей супруги, нашей соотечественницы.

Поговорим о плюсах Nuitka:

  1. Рекурсивный обход всех зависимостей — nuitka обрабатывает не только переданный пакет, но и все его зависимости, включая транзитивные.

  2. В отличие от pyoxidizer, для работы с nuitka не нужно изучать никаких странных языков типа Starlark или писать дополнительных конфигурационных файлов. Все можно сделать одной командой. Первое приложение я смог скомпилировать в бинарник уже через минуту — ничего не пришлось изучать.

  3. Возможна статическая линковка с Python (понадобится специальная его сборка). Это значит, что не нужно будет искать на машине пользователя Python или тащить его с собой. Само приложение будет иметь в себе все, что требуется.

-> % python -m nuitka --standalone myapp.py --onefile -o myapp

-> % ldd myapp
linux-vdso.so.1 (0x00007ffe29b2b000)
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f5b5f9ba000)
/lib64/ld-linux-x86-64.so.2 (0x00007f5b5fc0e000)

Автор начинал этот проект с мыслью ускорить код на Python. Но на примере cli-утилиты я этого не увидел. Пробовал разные оптимизации, включая экспериментальные, но скорость запуска никак не увеличивалась.

При этом скорость запуска все же неплохая. Замедление в полтора раза. Если подводить итоги, кажется, что все очень круто. Системные зависимости nuitka какой-то магией нашла — увидела, что Python-модуль пытается импортировать системные библиотеки, нашла их на машине, где все собиралось, и затащила к себе. Все зависимости Python, очевидно, тоже. Установщика нет, перформанс отличный.

Минусы, конечно, тоже есть.

Не надо забывать, что это будет не тот самый «питонячий» код, который вы писали. Это будет другой язык, скомпилированный вовсе не так, как задумал создатель Python. К слову, Гвидо ван Россум ненавидит этот проект и публично его критикует.

Как и в pyoxidizer, какие-то вещи могут пропасть. Например, в pyoxidizer пропадает переменная _file — она просто не работает. У nuitka похожая история с sys.path. Если вы в программе меняете это значение, nuitka может этого не заметить. Также она может не заметить динамический импорт плагинов в коде. Я ради интереса пробовал перевести в С и скомпилировать Pytest с каким-то плагином. Чтобы это сработало, пришлось сделать небольшое приседание, потому что nuitka не видела, что плагин тоже нужно сконвертировать, — мы сделали это руками. Но после этого все прекрасно заработало. Этот пример можно найти на GitHub по этой ссылке.

Такого рода подводные камни всегда будут вылезать. С этим придется смириться.

И нужно быть осторожнее с лицензиями. Существуют такие лицензии, которые, например, разрешают использование в виде исходного кода, но не в виде статически слинкованного объектного файла. Не зная этого, условия можно случайно нарушить. Но все это легко проверяется специальными скриптами.

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

python -m nuitka --standalone myapp.py --onefile -o myapp

На выходе получаем бинарный файл. Проверим, что он действительно самодостаточен:

-> % docker run -v$PWD:$PWD -w$PWD ubuntu:latest ./myapp run
psycopg2 works!
pycurl works!
myextension works!

Класс, все работает. Но в то же время у другого клиента…

-> % docker run -v$PWD:$PWD -w$PWD ubuntu:20.04 ./myapp run
./myapp: ...libc.so.6: version `GLIBC_2.33' not found (required by ./myapp)
./myapp: ...libc.so.6: version `GLIBC_2.34' not found (required by ./myapp)

Это произошло потому, что, зайдя на территорию С и всех этих сложных штук, мы забыли, что нужно учитывать зависимость всех исполняемых файлов (и вообще всего в этом мире) от glibc. Это стандартная библиотека языка С. Если на машине, где собирали пакет, glibc была свежей версии, то получившийся исполняемый файл может требовать свежую версию и на машине клиента.

Вот так это выглядит: myapp ссылается на свежие glibc 2.34 и 2.33:

-> % objdump -T ./myapp | grep GLIBC
...
000...000 DF *UND* 000...000 (GLIBC_2.34) __libc_start_main
...
000...000 DF *UND* 000...000 (GLIBC_2.33) fstat
...

А glibc на машине клиента версии 2.31:

-> % docker run ubuntu:20.04 ldd --version
ldd (Ubuntu GLIBC 2.31-0ubuntu9.9) 2.31

Упс...

Даже если вы запускаете обычный код Python, проблема все равно может вас задеть, потому что существуют C-расширения. На PyPI полно пакетов, в которые включены не очень хорошо собранные бинарники с плохими зависимостями.

Беспроблемные пакеты — те, у которых вообще нет C-расширений или которые собраны правильно.

Как правильно собирать?

Во-первых, надо собираться со старой glibc. Glibc имеет обратную совместимость — если что-то собрано на Ubuntu 17, на Ubuntu 20 оно тоже заработает. А вот обратное не гарантируется.

Во-вторых, если у ваших пакетов есть внешние зависимости, надо их взять с собой. И это значит, что библиотеки, которые пакуются с собой, тоже должны быть правильно собраны.

Для решения этой проблемы был нужен какой-то контракт, и в Python он есть и называется manylinux. Это серия тегов, стандартизирующих совместимость бинарного пакета с версиями Linux.

Наверное, вы видели wheel-пакеты с расширением C, у которых platform tag (последняя секция перед .whl) — manylinux***. Как раз этот тег и говорит нам, что пакет может быть совместим с большим количеством Linux-ов.

Вообще Linux-дистрибутивы — это огромная куча-мала. Поэтому не получится пообещать «anylinux» или «everylinux». Manylinux — это такой прагматичный подход. Пакет будет работать не везде, но на большинстве распространенных дистрибутивов. Этот платформенный тег стандартизировали всего около 10 лет назад.

Эволюция manylinux:

  • PEP 425 (2012) — стандартизированы теги платформы для пакетов {distribution}-{version}(-{build tag})?-{python tag}-{abi tag}-{platform tag}.whl;

  • PEP 513 (2016) — тег manylinux1 — это первый тег, который обещал, что пакет будет работать на всех распространенных дистрибутивах Linux старше CentOS 7;

  • PEP 571 (2018) — тег manylinux2010 — подразумевались все распространенные дистрибутивы Linux, выпущенные после 2010 года;

  • PEP 599 (2019) — тег manylinux2014;

  • PEP 600 (2019) — последняя, более общая система тегов manylinux_x_y manylinux_{GLIBCMAJOR}_${GLIBCMINOR}_${ARCH}. Видимо, на этом этапе поняли, что каждый раз выпускать новый PEP — подход неправильный.

Как читать платформенный тег на примере «manylinux_2_5_x86_64»:

  • GLIBC_2.5 — максимально допустимая версия базовых символов glibc, т. е. автор пакета гарантирует, что wheel с этим тегом внутри не ссылается на символы glibc более свежих версий.

  • Жестко фиксированный список (⭐477) допустимых зависимостей, например libgcc_s.so.1, libstdc++.so.6, libpthread.so.0 и еще с десяток других, имеющихся в практически всех дистрибутивах с glibc 2.5.

  • Архитектура x86_64.

То есть читается как «работает на практически всех мейнстримовых дистрибутивах linux x86_64 с версией glibc 2.5 и выше». Звучит запутанно, но если к этому привыкнуть, все не так страшно. Почти любой manylinux — это хорошо.

Все это дается далеко не бесплатно, и проблем у manylinux тоже хватает:

  • Усложненная сборка. Нельзя просто взять и собрать у себя на машине пакет с тегом manylinux. Нужно специальное окружение. Проект PyPI поставляет специальные Docker-ы — свой для каждого тега manylinux.

  • Большие бинарники, потому что нужна статическая линковка всех библиотек.

  • Использование устаревших библиотек (вспоминаем про security-риски). Это самый серьезный минус. Допустим, у вас есть в инфраструктуре сервис, который использует OpenSSL. В ответ на очередную уязвимость Zero Day приходит обновление. Можно обновить пакет на уровне вашей ОС и надеяться, что в системе больше нет старых версий OpenSSL. Но на самом деле в каком-нибудь пакете с manylinux она может остаться.

Поэтому, перед тем как пытаться охватить все распространенные дистрибутивы, подумайте, нужна ли вам эта штука. Вы же знаете, какой у вас Linux. Возможно, стоит собрать пакет только под него? Есть даже целый проект no-manylinux, отключающий использование этого тега.

Хотя большая часть мейнстримовых пакетов давно поставляются с тегом manylinux, есть исключения.

Примеры пакетов без тега manylinux:

  • pycurl

  • libvirt

  • PyGObject

  • pycairo

  • netifaces

  • pycrypto

  • gssapi

  • pycups

  • pykerberos

Для многих пакетов это оправданно. Например, логично, что pykerberos использует системную библиотеку, а не свою собственную. То же самое касается libvirt.

Как можно отловить проблемные пакеты

Если в названии файла есть platform tag, можно просто сравнить его со списком платформ и понять, есть ли с ним проблема.

Формат имени пакета: {python tag}-{abitag}-{platform tag}.whl

В pycurl-7.45.2-cp38-cp38-linux_x86_64.whl тег платформы — это linux_x86_64. Но это вообще не гарантия. Я могу собрать какой-то свой дистрибутив Linux, использовав вместо libc что-то другое неизвестной версии. И собравшись там, тоже получу тег linux_x86_64. Однако работать пакет нигде не будет. То есть на самом деле нам надо проверить все наши зависимости. Это можно сделать с помощью трех строк в bash или Python — кому как нравится.

allowed_tags = ["any", "manylinux1"...
platform_tags = parse_wheel_filename(filename).platform_tags
assert any(tag in allowed_tags for tag in platform_tags)

Мы для этого написали простой скрипт, который проверяет все пакеты.

В скрипт можно передать путь до Wheelhouse, и он найдет все ошибки.

-> % pip3 wheel -r requirements.txt --wheel-dir wheelhouse
-> % whl-tags-checker.py wheelhouse any manylinux2014_x86_64 manylinux_2_5_x86_64
Error: wheel package without supported platform tags was found:
pycurl-7.45.2-cp311-cp311-linux_x86_64.whl.
Error: wheel package without supported platform tags was found:
psycopg2-2.9.7-cp311-cp311-linux_x86_64.whl.

Так мы увидим, что pycurl и psycopg собраны у нас на машине и не manylinux, т. е. не могут считаться безопасно портируемыми.

Чтобы это исправить, как я уже писал, есть специальные docker-ы, ⭐1.3k. Можно просто собираться в них.

-> % docker run quay.io/pypa/manylinux2010_x86_64
-> % /opt/python/cp38-cp38/bin/pip wheel pycurl -w weelhouse
…
    Created wheel for pycurl:
    filename=pycurl-7.45.2-cp38-cp38-linux_x86_64.whl
...

А следующим шагом натравить на пакет специальную утилиту, которая называется auditwheel. Это еще одна официальная утилита от проекта PyPI, которая принимает на вход колесо и проверяет, соответствует ли оно тегу manylinux.

-> % auditwheel repair pycurl-7.45.2-cp38-cp38-linux_x86_64.whl
Repairing pycurl-7.45.2-cp38-cp38-linux_x86_64.whl
Previous filename tags: linux_x86_64
New filename tags: manylinux_2_17_x86_64, manylinux....
Previous WHEEL info tags: cp38-cp38-linux_x86_64
New WHEEL info tags: cp38-cp38-manylinux_2_17_x86_64, ...
Fixed-up wheel written to
...pycurl-7.45.2-cp38-cp38-manylinux_2_17_x86_64.ma....

Главное после этого — не забывать устанавливать колеса из wheelhouse, чтобы pip не попытался выкачать из сети неправильную версию.

pip3 install --no-index --find-links ./wheelhouse

Подведем итоги.

Правильный платформенный тег важен независимо от выбранного способа поставки, будь то nuitka, pex, appimage и т. д. Если вы хотите, чтобы это работало где-то еще, платформенные теги надо обязательно проверять. И собираться так, чтобы ничего не испортить, — т. е. использовать правильные инструменты.

При этом переносимость не бесплатна — помимо безопасности мы можем потерять еще и производительность. Например, тестовое приложение, собранное через gcc6 с Python 3.8 через nuitka, работает почти в два раза медленнее, чем собранное с актуальным питоном актуальным компилятором.

Причин может быть много, но самая очевидная — gcc свежей версии применяет какие-то оптимизации, как и свежий Python.

Делаем все сами

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

Нужно просто взять с собой правильный Python, т. е. тот, который сам по себе соответствует тегу manylinux:

Итоговая таблица получилась не очень полезная, но все же:

Этот вариант хорошо подходит при наличии готовой схемы распространения SDK.

Выводы

Готового тулинга, который работает хорошо, уже очень много. То есть классическая проблема поставки Python-приложения значительно уменьшилась. При этом нужно соблюдать несколько правил. И, конечно, можно наткнуться на какие-нибудь подводные камни. Без этого, к сожалению, никуда.

В целом за последние 10 лет Python сделал много шагов в правильном направлении, что очень радует. Есть попытки пойти еще дальше — я наткнулся на пока еще не принятый PEP 711, который предлагает стандартизировать колеса для Python. То есть предполагается сделать полностью переносимое колесо, в котором уже будет Python.

Напоследок я свел все в одну табличку. Если вы не знаете, с чего начать, посмотрите на эти результаты и выберите, что больше подходит.

Проект-песочница на GitHub. Для его работы не нужно ничего, кроме Docker. Там есть тесты переносимости — приложение просто запускается на Ubuntu 17, 20, 22; Debian 8, 9, 10; Open Suse и т. д. А еще там есть бенчмарки, которые показывают, что pyoxidizer всех рвет.

-> % make build

-> % make portability-test
Success: myapp_nuitka_onefile passed on ubuntu:17.04
Success: myapp_nuitka_onefile passed on ubuntu:20.04
Success: myapp_nuitka_onefile passed on debian:9
Success: myapp_nuitka_onefile passed on opensuse/leap:15.0
... etc ...

-> % make benchmark
Benchmarking startup time...

Summary
    pyoxidizer/myapp --help ran
        3.21 ± 0.26 times faster than myapp_nuitka_as_folder/myapp --help
        5.72 ± 0.71 times faster than myapp_nuitka_onefile --help
        7.04 ± 1.53 times faster than myapp.AppImage --help

Ну а если вам был интересен мой эксперимент — приходите к нам в «Лабораторию Касперского» на роль Python-разработчика, будем вместе заниматься подобными изысканиями :)

Дополнительные материалы:

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


  1. baldr
    25.04.2024 12:49
    +4

    А как же pyinstaller? Он тоже позволяет сделать самораспаковывающийся архив, внутри будет Python и все библиотеки.

    Есть на гитхабе проект, где в докере собирается всё под любую платформу - я собирал для Linux и Windows (под Wine).

    Есть, конечно, нюансы. Оригинальный проект не обновлялся лет 5, но есть новые форки, и самому можно поправить для новых версий Python. Но 40 минут не хватит, это да.


    1. Magn Автор
      25.04.2024 12:49
      +4

      Вместо pyinstaller решил исследовать pyoxidizer, и нам он понравился больше, о чём пишу в статье :)
      При этом pyinstaller тоже отличный инструмент, просто он функционально примерно ту же нишу занимает что и pyoxidizer, так что решил не раздувать статью.


      1. baldr
        25.04.2024 12:49
        +1

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


        1. Magn Автор
          25.04.2024 12:49
          +1

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


    1. mr-giz
      25.04.2024 12:49

      Да, pyinstaller классная вещь. А по проекту с докером не совсем понятно, зачем он нужен. Там конфигурации всего ничего. Сам так собираю бинарники под разные платформы.


      1. baldr
        25.04.2024 12:49

        Ну как ничего - тот же wine или VC_redist. Да и вообще собирать лучше в контролируемом окружении.


  1. high_panurg
    25.04.2024 12:49
    +1

    Я бы выбрал pyinstaller.
    pyoxidizer уже год как не обновляется, а в nuitka пока так и не завезли python3.12


  1. high_panurg
    25.04.2024 12:49

    Я бы выбрал pyinstaller.
    pyoxidizer уже год как не обновляется, а в nuitka пока так и не завезли python3.12


  1. funca
    25.04.2024 12:49
    +2

    обираться со старой glibc. Glibc имеет обратную совместимость — если что-то собрано на Ubuntu 17, на Ubuntu 20 оно тоже заработает. А вот обратное не гарантируется.

    Они периодически ломают и обратную совместимость на уровне ABI. В общем случае можно ориентироваться на мажорную версию. Но в истории были нюансы. Подробнее тут https://gcc.gnu.org/onlinedocs/libstdc++/manual/abi.html.


    1. slonopotamus
      25.04.2024 12:49
      +1

      glibc != libstdc++


  1. funca
    25.04.2024 12:49
    +1

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

    Интересно, упаковка библиотек под LGPL в исполняемый файл вместе с приложением, будет автоматически триггерить требования этой лицензии, связанные с необходимостью раскрытия способа сборки (т.е. на практике - всех ваших исходников)?


    1. mk2
      25.04.2024 12:49
      +2

      Тут кстати pyinstaller таит в себе подвох - он под линуксом по умолчанию берёт с собой библиотеку libreadline - которая даже не LGPL, а GPL3. И весь код сразу попадает под copyleft требования GPL. Если не посмотреть, можно случайно попасть.

      А PyOxidizer об этом наоборот подумал и имеет возможность поставляться с libedit.

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


      1. baldr
        25.04.2024 12:49
        +1

        Как страшно жить!


  1. aryk38
    25.04.2024 12:49

    Почему мы все-таки пишем на Python?? ну вот такие мы .... (уровень развития).

    можно например написать на Жаве, пихнуть все в JAR и дать клиенту. Но ... (уровень развития).

    насколько я понимаю для .... (уровень развития) придумали Докер. ну или там VM. но ...


  1. werevolff
    25.04.2024 12:49

    Правительство США: так, разработчики Америки, отныне мы рекомендуем Вам писать на Rust, вместо Java и Golang, поскольку он является более прозрачным и, следовательно, безопасным, в вопросах использования памяти!

    Разработчик Kaspersky OS: так, разработчики России, сейчас я вас научу, как можно запихнуть ваш python код в экзешник и выполнить на любом компе!

    Я ничего не упустил?


    1. falconandy
      25.04.2024 12:49
      +6

      Если в первом абзаце вы ссылаетесь на доумент BACK TO THE BUILDING BLOCKS: A PATH TOWARD SECURE AND MEASURABLE SOFTWARE, то Rust в нем явно упоминается лишь один раз, а ваше толкование несколько "преувеличено":

      The space ecosystem is not immune to memory safety vulnerabilities, however there are several constraints in space systems with regards to language use. First, the language must allow the code to be close to the kernel so that it can tightly interact with both software and hardware; second, the language must support determinism so the timing of the outputs are consistent; and third, the language must not have – or be able to override – the “garbage collector,” a function that automatically reclaims memory allocated by the computer program that is no longer in use.xvi These requirements help ensure the reliable and predictable outcomes necessary for space systems.
      According to experts, both memory safe and memory unsafe programming languages meet these requirements. At this time, the most widely used languages that meet all three properties are C and C++, which are not memory safe programming languages. Rust, one example of a memory safe programming language, has the three requisite properties above, but has not yet been proven in space systems. Further progress on development toolchains, workforce education, and fielded case studies are needed to demonstrate the viability of memory safe languages in these use cases. In the interim, there are other ways to achieve memory safe outcomes at scale by using secure building blocks. Therefore, to reduce memory safety vulnerabilities in space or other embedded systems that face similar constraints, a complementary approach to implement memory safety through hardware can be explored.


      1. werevolff
        25.04.2024 12:49

        Разумеется, преувеличено. Я и не скрывал иронии.


        1. Apv__013
          25.04.2024 12:49
          +2

          А к чему Ваша ирония?


          1. werevolff
            25.04.2024 12:49

            К тому, что в современном мире IT набирает обороты тенденция отхода от языков с низким порогом вхождения, типа Java или Golang - в пользу языков с лучшим управлением памятью. И связано это с многочисленными инцидентами безопасности. Можно вспомнить истории с уязвимостями ЦПУ, когда можно было получить доступ к памяти устройства. Истории с размещением вредоносного кода в репозиториях сверхвысокоуровневых языков (NPM, PyPi). Истории с размещением вредоносного кода в библиотеках, входящих в состав дистрибутивов Linux. Даже, истории с санкциями, когда целому вендору закрывали доступ к репозиторию Google Play. Всё это как бы намекает на то, что вскоре может образоваться тенденция использовать языки с более прозрачным контролем памяти, или языки без обширных неконтролируемых репозиториев, пакеты в которых невозможно проверить ввиду их обилия.

            Статья, безусловно, интересная. Просто иронично, что материал о сборке python приложений для их поставки на исполняемую машину, выходит в это время. И выходит из-под пера разработчика ОС, позиционирующейся, как безопасный аналог западных решений.


            1. sdramare
              25.04.2024 12:49
              +4

              К тому, что в современном мире IT набирает обороты тенденция отхода от языков с низким порогом вхождения, типа Java или Golang - в пользу языков с лучшим управлением памятью.

              Нет такой тенденции, достаточно посмотреть любую статистику по языкам.

              И связано это с многочисленными инцидентами безопасности

              И чем же управление памятью с GC хуже чем borrow check в плане безопастности? Если вы про недавние рекомендации вашингтона, то там речь шла про С++, если про отрывок выше, то там речь про системы реального времени для космоса, для которых важна детерминированость времени работы(space systems). Нет, CRUD микросервисы к таким системам не относятся.

              Можно вспомнить истории с уязвимостями ЦПУ,

              Можно вспомнить. И каким образом условный раст защитит вас от Spectre? Вы, видимо, не понимаете как работает side channel attack.

              Истории с размещением вредоносного кода в репозиториях сверхвысокоуровневых языков (NPM, PyPi)

              Буквально недавно была история размещения уязвимости в XZ, написано на С. Какая в принципе взаимосвязь между языком и аттакой на репозиторий?

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

              А причем тут гугл плей, санкции и контроль память?

              Просто иронично, что материал о сборке python приложений для их поставки на исполняемую машину, выходит в это время

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


              1. werevolff
                25.04.2024 12:49

                Может, и не разбираюсь, но, если верить публикациям, и если это не первоапрельский розыгрыш, то та же корпорация Google уже форсирует переход на Rust в некоторых командах:

                https://www.securitylab.ru/news/547188.php

                https://www.opennet.ru/opennews/art.shtml?num=60556

                А, учитывая письмо Вашингтона, это выглядит именно как следствие закручивания гаек безопасности в проектах американских компаний.

                А ещё, я менее токсичен, чем вы. Несомненный плюс.


                1. sdramare
                  25.04.2024 12:49
                  +3

                  Первая статья "C++ в прошлом, Rust – наше будущее"

                  Вторая статья "Google выделил миллион долларов на улучшение переносимости между С++ и Rust"

                  Как это относится к "в современном мире IT набирает обороты тенденция отхода от языков с низким порогом вхождения, типа Java или Golang - в пользу языков с лучшим управлением памятью"

                  Или вы С++ ставите в один ряд с Java и Golang, не токсичный вы наш?


                  1. funca
                    25.04.2024 12:49

                    Google выделил миллион долларов на улучшение переносимости между С++ и Rust

                    Кстати, причины такой щедрости здесь недавно описывали: у Google Chrome, который в основном написан на C++, возникли проблемы с boringssl, после того как куски последней переписали на Rust. Команда не смогла с этим справиться в режиме обычного багфиксинга. Поэтому кто-то сверху решил закидать проблему деньгами. Речи о каких-то глобальных изменениях для Гугла естественно не идёт. В глобальном плане, разработку захватывают нейронки, а им в общем-то без разницы на каком языке сочинять код.


                  1. werevolff
                    25.04.2024 12:49

                    Прямым образом относится. Google переводит код с голанг - на раст, пока в блоге Касперского предлагают не рассматривать голанг, и информируют читателей, чем собрать исполняемый файл из пайтон-кода. Собственно, с этого и начинается ветка. Ну, почти: вместо тезиса о переписывании гуглом кода на "Ржавом", я упомянул комментарий АНБ относительно безопасных языков, новость о котором датируется примерно теми же датами, которыми датируются первые упоминания о вливании гуглом бюджета в переписывание кода на раст:

                    https://habr.com/ru/news/796901/

                    Хабр, видимо превратился в Одноклассники, если местным звëздам, залайканным пенсионерами, приходится объяснять каждый панч.


                    1. funca
                      25.04.2024 12:49
                      +2

                      я упомянул комментарий АНБ относительно безопасных языков, новость о котором датируется примерно теми же датами

                      Лучше читайте первоисточники, а не интерпретации журналистов. Администрация Байдена агитирует за повышение безопасности софта на всех уровнях. Но ни кто до сих пор не понимает как и в чем эту самую безопасность измерять, чтобы в итоге решить повысилась она или понизилась и на что ушли бюджетные деньги.

                      Предыдущая попытка разработать такие методики ни к чему не привела. Это известная проблема, о чем собственно и упоминантся в докладе. Остальное там вода, про то как все страшно, и как бы стало все замечательно, если бы такие методики изменения все же появились.


    1. Apoheliy
      25.04.2024 12:49
      +3

      По-моему, Касперский и Ко написал поболее программного обеспечения, чем правительсво сша.

      Так что лучше верьте Касперскому.

      (можно использовать как сарказм; а можно и не использовать)


      1. werevolff
        25.04.2024 12:49

        Хочу заметить, что Касперский и Ко написали этот софт, преимущественно, на ЯП, созданных в США, либо, компаниями, работавшими на мин обороны США (AT&T), либо, на языках, компиляторы или интерпретаторы которых были написаны с помощью этих американских языков. Простой пример - язык C, созданный в Лаборатории Белла, которая вышла из AT&T.


        1. Apv__013
          25.04.2024 12:49

          Асгард — это не место, это люди


  1. Self_Perfection
    25.04.2024 12:49
    +3

    Ого, окно Овертона в действии.

    На мой взгляд, все, что в рамках 300 мс, нормально для консольного инструмента.

    Серьёзно? Это же очень медленно! Даже 100мс уже неприятно ощущаются.


    1. 0x131315
      25.04.2024 12:49

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


  1. xxxDef
    25.04.2024 12:49
    +2

    Сначала узкоспециализированный язык для инженеров становится мейнстримом, а потом оказывается что у него специфические проблемы потому что он узкоспециализированный язык для инженеров.

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


    1. sdramare
      25.04.2024 12:49

      Потому что это экономически выгодно. Если у вас есть инженер(сисадмин/девопс/named it), который хорошо знает инфраструктуру, то дешевле попросить его написать cli тул на чем он там может, хоть питон, хоть брейнфак, чем подключать программиста. Да, потом в каких-то случаях придется что-то менять. А может и не придется, может эти тулы напишут один раз и забудут - события будущего наступят неизвестно когда, а платить зарплату надо здесь и сейчас.