Почему так сложно создавать автономные Python-приложения — ИИ для бизнеса

Почему так сложно создавать автономные Python-приложения

Прослушать статью

Почему упаковка Python-приложения и его зависимостей в один исполняемый файл — такая проблема? Виновата динамичность Python.

Если у разработчиков Python и есть одна постоянная претензия к любимому языку, то звучит она примерно так: почему так трудно взять Python-программу и развернуть ее как автономный артефакт — так, как это делают C, C++, Rust, Go и даже Java? Неужели всем обязательно сначала устанавливать Python runtime, чтобы использовать Python-программу? И почему все обходные пути для этой задачи такие неуклюжие?

Одна из особенностей, которая делает Python таким привлекательным, — его dynamism, — одновременно и причина того, почему Python-приложения так трудно упаковывать и доставлять. Не невозможно, но сложно. Упакованные Python-приложения в итоге превращаются в крупные пакеты — как правило, не меньше десятка мегабайт. К тому же инструменты для создания таких пакетов не самые дружелюбные и удобные.

Так что же именно в динамичности Python создает эти проблемы?

Преимущества и риски динамичности Python

Когда мы говорим о Python как о «динамическом» языке, это означает не только то, что Python-приложения выполняются интерпретатором. Это также означает, что многие решения о поведении Python-приложений принимаются во время выполнения, а не заранее.

Многие удобства Python проистекают именно из такого подхода. Переменные не нужно объявлять заранее, а когда они больше не используются, сборщик мусора удаляет их автоматически. Импорты можно объявлять заранее, но их также можно генерировать во время выполнения — и теоретически они могут импортировать код откуда угодно. Код Python тоже можно генерировать и интерпретировать во время выполнения.

У этой гибкости есть цена: трудно предсказать, что именно Python-программа сделает во время выполнения. Одна из причин, по которой это так сложно, в том, что код в Python-программе теоретически может быть изменен другим кодом. Библиотеку можно импортировать, переопределить в ней методы и даже изменить ее bytecode. Оптимизировать Python для высокой производительности трудно именно потому, что многие оптимизации опираются на знание того, что код будет делать заранее (хотя новый JIT и другие изменения помогают).

Из этого следуют два важных вывода:

  1. Самый надежный способ запустить Python-программу — через экземпляр Python runtime, чтобы можно было воспроизвести все динамические поведения Python. Любое решение, превращающее Python-приложение в некий перераспространяемый пакет, должно включать runtime в той или иной форме. А любое решение, которое включало бы «ровно столько» Python runtime, сколько нужно для запуска этой программы, нарушило бы обещания, заложенные в dynamism Python.
  2. Упаковать Python-приложение для автономного использования сложно, потому что трудно предсказать, какие возможности Python понадобятся приложению во время выполнения. Не невозможно, но достаточно трудно, чтобы это было далеко не тривиально. Это также означает, что любые сторонние библиотеки, которые нужны приложению, должны быть включены в пакет целиком.

Сторонние библиотеки: все или ничего

Python-приложения требуют очень четких деклараций о том, какие библиотеки им нужны для запуска, через pyproject.toml или requirements.txt. Более того, динамичность Python означает, что нельзя делать никаких предположений о том, какие именно части этих библиотек действительно будут использоваться.

В мире C++ или Rust можно собирать статически слинкованные бинарники, которые исключают любой код, не вызываемый из вашей программы. Python-библиотеки не могут работать так: любая часть библиотеки может быть вызвана любой программой в любой момент. Поэтому включать нужно всю библиотеку целиком — вместе со всеми ее зависимостями, включая бинарники.

Следовательно, любая попытка упаковать Python-приложение как автономный исполняемый файл должна включать все его зависимости. В результате пакет может получиться очень большим — настолько, что это отпугнет тех, кто не хочет передавать пользователям, скажем, артефакт размером 300MB. Но dynamism Python требует включать все.

Теоретически можно было бы проследить путь вызовов Python-программы и выполнить «tree-shaking» — удалить все, что никогда не вызывается. Но это работало бы только для конкретного запуска программы. Гарантировать, что это сработает для любого запуска программы, включая те, где используется динамичность Python, практически невозможно.

Единственное рабочее решение: полный комплект

Все эти проблемы означают, что у нас есть лишь несколько способов надежно развернуть Python-программу:

  • Установить ее в уже существующий Python interpreter. Это самый распространенный сценарий, но он требует настройки копии интерпретатора. В лучшем случае это отдельный шаг, который становится особенно сложным, если в системе уже есть несколько версий Python. Кроме того, именно этого сценария люди и хотят избежать, поскольку стремятся как можно проще распространять свое приложение.
  • Вместе с программой и ее зависимостями упаковать и интерпретатор. Так поступают проекты вроде PyInstaller и Nuitka. Минусы в том, что итоговые поставки обычно получаются довольно большими, а для их создания нужно изучить особенности этих проектов. Но они работают.
  • Использовать систему вроде Docker для упаковки программы. Docker-контейнеры влекут за собой собственный набор компромиссов. С одной стороны, вы получаете абсолютно все, что нужно для запуска программы, включая любые системные зависимости. С другой — итоговый контейнер может оказаться очень тяжелым. И, разумеется, использование Docker означает принятие еще одной программной экосистемы.

Некоторые новые решения пытаются закрыть одну конкретную болевую точку или другую, чтобы сделать всю проблему менее неприятной. Например, PyApp использует Rust, чтобы собрать самораспаковывающийся бинарник, который устанавливает нужный дистрибутив Python, ваше приложение и все его зависимости. У него есть два серьезных недостатка: для сборки нужен компилятор Rust, а само приложение должно быть устанавливаемым пакетом, использующим стандарт pyproject.toml. Первое требование, вероятно, окажется более серьезным препятствием; большинству Python-проектов сегодня нужен тот или иной pyproject.toml.

Другое решение — то, которое написал сам автор статьи: pydeploy. Оно тоже требует, чтобы проект можно было установить через pip install. В остальном pydeploy не нужно ничего, кроме стандартной библиотеки Python, чтобы создать автономную поставку с включенным Python runtime. Его главный недостаток сейчас в том, что он работает только в Microsoft Windows, хотя теоретически мог бы работать на любой операционной системе.

Возможно, когда-нибудь

Все крупные изменения, которые сейчас предлагают для Python, такие как новый native JIT и полная concurrency или multithreading, призваны усилить поведение Python как динамического языка. Любые предложения, направленные на изменение этой динамичности, по сути означали бы создание нового языка с другими ожиданиями от его поведения.

Хотя были попытки создать варианты Python, которые устраняют то или иное ограничение (например, Mojo), оригинальный Python, при всех своих ограничениях, остается огромным центром притяжения. По мере дальнейшего развития языка всегда есть шанс, что однажды появится «благословенное», нативное для Python решение проблемы распространения автономных Python-приложений. А пока имеющиеся у нас решения, возможно, не слишком изящны, но по крайней мере они есть.


Материал — перевод статьи с английского.

Оригинал: Why it’s so hard to create stand-alone Python apps