Публикуем первую часть перевода очередного материала из серии, посвящённой тому, как в Instagram работают с Python. В первом материале этой серии речь шла об особенностях серверного кода Instagram, о том, что он представляет собой монолит, который часто меняется, и о том, как статические средства проверки типов помогают этим монолитом управлять. Второй материал посвящён типизации HTTP-API. Здесь речь пойдёт о подходах к решению некоторых проблем, с которыми столкнулись в Instagram, используя Python в своём проекте. Автор материала надеется на то, что опыт Instagram пригодится тем, кто может столкнуться с похожими проблемами.
Обзор ситуации
Давайте рассмотрим следующий модуль, который, на первый взгляд, выглядит совершенно невинно:
import re
from mywebframework import db, route
VALID_NAME_RE = re.compile("^[a-zA-Z0-9]+$")
@route('/')
def home():
return "Hello World!"
class Person(db.Model):
name: str
Какой код будет выполнен в том случае, если кто-то импортирует этот модуль?
- Сначала выполнится код, связанный с регулярным выражением, компилирующий строку в объект шаблона.
- Затем будет выполнен декоратор
@route
. Если основываться на том, что мы видим, то можно предположить, что тут, возможно, производится регистрация соответствующего представления в системе URL-мэппинга. Это означает, что обычный импорт этого модуля приводит к тому, что где-то ещё меняется глобальное состояние приложения. - Теперь мы собираемся выполнить весь код тела класса
Person
. Тут может содержаться всё, что угодно. У базового классаModel
может иметься метакласс или метод__init_subclass__
, который, в свою очередь, может содержать ещё какой-то код, выполняемый при импорте нашего модуля.
Проблема №1: медленные запуск и перезапуск сервера
Единственная строка кода этого модуля, которая (возможно) не выполняется при его импорте, это return "Hello World!"
. Правда, с уверенность мы это утверждать не можем! В результате оказывается, что импортировав этот простой модуль, состоящий из восьми строк (и при этом даже ещё не воспользовавшись им в своей программе) мы, возможно, вызываем запуск сотен или даже тысяч строк Python-кода. И это — не говоря о том, что импорт данного модуля вызывает модификацию глобального URL-мэппинга, находящегося в каком-то другом месте программы.
Что делать? Перед нами — часть следствия того, что Python является динамическим интерпретируемым языком. Это позволяет нам успешно решать различные задачи методами метапрограммирования. Но что, всё же, не так с этим кодом?
На самом деле, этот код в полном порядке. Это так до тех пор, пока некто использует его в сравнительно маленьких кодовых базах, над которыми работают небольшие команды программистов. Этот код не вызывает неприятностей до тех пор, пока тот, кто им пользуется, может гарантированно поддерживать некоторый уровень дисциплины в том, как именно используются возможности Python. Но некоторые аспекты подобного динамизма могут стать проблемой в том случае, если в проекте имеются миллионы строк кода, над которым работают сотни программистов, многие из которых не обладают глубокими знаниями Python.
Например, одна из замечательных возможностей Python заключается в скорости выполнения шагов поэтапной разработки. А именно, результат изменений кода можно увидеть буквально сразу же после выполнения таких изменений, без необходимости компиляции кода. Но если речь идёт о проекте размером в несколько миллионов строк (и о довольно запутанной диаграмме зависимостей этого проекта), то этот плюс Python начинает превращаться в минус.
На запуск нашего сервера нужно более 20 секунд. А иногда, когда мы не уделяем должного внимания оптимизации, это время увеличивается примерно до минуты. Это означает, что разработчику нужно 20-60 секунд на то, чтобы увидеть результаты изменений, внесённых в код. Это относится и к тому, что можно видеть в браузере, и даже к скорости запуска модульных тестов. Этого времени человеку, к сожалению, достаточно для того, чтобы на что-то отвлечься и забыть о том, что он до этого делал. Большая часть данного времени, в буквальном смысле, тратится на импорт модулей и на создание функций и классов.
В некотором роде это — то же самое, что ждать результатов компиляции программы, написанной на каком-то другом языке. Но обычно компиляцию можно выполнять инкрементно. Речь идёт о том, что можно перекомпилировать только то, что поменялось, и то, что напрямую зависит от изменённого кода. В результате обычно компиляция проектов, выполняемая после внесения в них небольших изменений, производится быстро. Но при работе с Python, из-за того, что команды импорта могут иметь какие угодно побочные эффекты, не существует надёжного и безопасного способа инкрементной перезагрузки сервера. При этом масштабы изменений неважны и нам приходится каждый раз полностью перезапускать сервер, импортируя все модули, пересоздавая все классы и функции, перекомпилируя все регулярные выражения, и так далее. Обычно с момента последнего перезапуска сервера не меняется 99% кода, но нам, для ввода в строй изменений, всё равно приходится раз за разом делать одно и то же.
В дополнение к замедлению разработчиков это ведёт и к непродуктивной трате серьёзных объёмов системных ресурсов. Дело в том, что мы работаем в режиме непрерывного развёртывания изменений, что означает постоянные перезагрузки кода продакшн-сервера.
Собственно говоря, вот наша первая проблема: медленные запуск и перезапуск сервера. Эта проблема возникает из-за того, что во время импорта кода системе приходится постоянно проделывать большой объём повторяющихся действий.
Проблема №2: побочные эффекты небезопасных команд импорта
Вот ещё одна задача, которую, как оказалось, разработчики часто решают во время импорта модулей. Это — загрузка настроек из сетевого хранилища конфигураций:
MY_CONFIG = get_config_from_network_service()
В дополнение к тому, что это способствует замедлению запуска сервера, это ещё и небезопасно. Если сетевой сервис окажется недоступным, то это приведёт не просто к тому, что мы получим сообщения об ошибках, касающихся невозможности выполнить некоторые запросы. Это приведёт к тому, что сервер не сможет запуститься.
Давайте сгустим краски и представим, что кто-то добавил в модуль, отвечающий за инициализацию важного сетевого сервиса, некий код, выполняющийся во время импорта. Разработчик просто не знал о том, куда ему добавить этот код, поэтому он поместил его в модуль, который импортируется на ранних этапах запуска сервера. Оказалось, что эта схема работает, поэтому решение было признано удачным и работа продолжилась.
Но потом ещё кто-то добавил ещё куда-то команду импорта, на первый взгляд безобидную. В результате же, через цепочку импортов глубиной в двенадцать модулей, это привело к тому, что модуль, загружающий настройки из сети, теперь импортируется до модуля, который инициализирует соответствующий сетевой сервис.
Теперь получается, что мы пытаемся воспользоваться сервисом до его инициализации. Система, естественно, даёт сбой. В лучшем случае, если речь идёт о системе, взаимодействия в которой полностью детерминированы, это способно привести к тому, что разработчик потратит час-два на то, чтобы выяснить то, как незначительное изменение привело к сбою в чём-то, с ним, как кажется, не связанным. Но в более сложных ситуациях это может привести к «падению» проекта в продакшне. При этом нет универсальных способов использования линтеров для борьбы с подобными проблемами или для их предотвращения.
Корень проблемы кроется в двух факторах, взаимодействие которых и приводит к разрушительным последствиям:
- Python позволяет модулям иметь произвольные и небезопасные побочные эффекты, проявляющиеся во время импорта.
- Порядок импорта кода не задаётся явным образом и не контролируется. В масштабах какого-то проекта некий «всеобъемлющий импорт» — это то, что складывается из команд импорта, содержащихся во всех модулях. При этом порядок импорта модулей может меняться в зависимости от используемой входной точки системы.
Продолжение следует…
Уважаемые читатели! Сталкивались ли вы с проблемами, касающимися медленного запуска Python-проектов?
Автор: ru_vds