Без холивара «переписать все на Go»: проблема переносимости в Python и ее решение

в 12:22, , рубрики: delivery, github, Go, open source, python, Rust, Программирование, разработка, управление разработкой

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

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

Без холивара «переписать все на Go»: проблема переносимости в Python и ее решение - 1

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

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

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

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

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

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

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

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

Без холивара «переписать все на Go»: проблема переносимости в Python и ее решение - 2

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

  • Переносимость между основными дистрибутивами 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 устраивал нас в вопросе скорости разработки, поиска разработчиков и простой интеграции с другими инструментами в компании. Нельзя однозначно утверждать, что решение было принято верное, однако пока мы не пожалели.

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

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

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

Без холивара «переписать все на Go»: проблема переносимости в Python и ее решение - 4

Для демонстрации инструментов я написал небольшое приложение, которое не делает ничего полезного. Оно просто зависит от нескольких библиотек — 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 много преимуществ, например простота как использования, так и внутреннего устройства, также вариативность паковки — он позволяет собрать пакет под несколько платформ.

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

Без холивара «переписать все на Go»: проблема переносимости в Python и ее решение - 5

Когда все запущено, разницы во времени работы приложения не будет никакой. Но, к сожалению, нам важен именно перформанс первого запуска. Такова специфика тулинга. Здесь не нужно, чтобы сервер обрабатывал много запросов в секунду, но нужно, чтобы пользователь мог быстро стартануть, так что перформанс ограничивается первой секундой. А здесь время старта будет очень медленным, просто потому, что 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 (а дальше и по всем остальным инструментам) в виде такой таблички.

Без холивара «переписать все на Go»: проблема переносимости в Python и ее решение - 6

 

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

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

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

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

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

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

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

Без холивара «переписать все на Go»: проблема переносимости в Python и ее решение - 7

В принципе, результат неплохой. Только он не поддерживает системные зависимости, не позволяет взять с собой 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!

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

Без холивара «переписать все на Go»: проблема переносимости в Python и ее решение - 8

Результат неплохой, но хорошо подходит только тем, кто уверен, что целевой платформой всегда будет 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 раза медленнее.

Без холивара «переписать все на Go»: проблема переносимости в Python и ее решение - 9

Однако это лучше, чем 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

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

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

Без холивара «переписать все на Go»: проблема переносимости в Python и ее решение - 10

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. И специально для этого я ничего не делал (взял по дефолту) — по идее, все это можно еще ускорить.

Без холивара «переписать все на Go»: проблема переносимости в Python и ее решение - 11

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

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

Без холивара «переписать все на Go»: проблема переносимости в Python и ее решение - 12

Единственное, что хочется отметить, 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: %sn" , 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: %sn" , 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-утилиты я этого не увидел. Пробовал разные оптимизации, включая экспериментальные, но скорость запуска никак не увеличивалась.

Без холивара «переписать все на Go»: проблема переносимости в Python и ее решение - 13

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

Без холивара «переписать все на Go»: проблема переносимости в Python и ее решение - 14

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

Не надо забывать, что это будет не тот самый «питонячий» код, который вы писали. Это будет другой язык, скомпилированный вовсе не так, как задумал создатель 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 — подход неправильный.

Без холивара «переписать все на Go»: проблема переносимости в Python и ее решение - 15

Как читать платформенный тег на примере «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, можно просто сравнить его со списком платформ и понять, есть ли с ним проблема.

Без холивара «переписать все на Go»: проблема переносимости в Python и ее решение - 16

Формат имени пакета: {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, работает почти в два раза медленнее, чем собранное с актуальным питоном актуальным компилятором.

Без холивара «переписать все на Go»: проблема переносимости в Python и ее решение - 17

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

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

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

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

Без холивара «переписать все на Go»: проблема переносимости в Python и ее решение - 18

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

Без холивара «переписать все на Go»: проблема переносимости в Python и ее решение - 19

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

Выводы

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

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

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

Без холивара «переписать все на Go»: проблема переносимости в Python и ее решение - 20

Проект-песочница на 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-разработчика, будем вместе заниматься подобными изысканиями :)

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

Без холивара «переписать все на Go»: проблема переносимости в Python и ее решение - 21

Автор: Арсений Сапелкин

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js