вступление
С самого начала я искал наиболее эффективные способы запуска бессерверных функций Python. Это означает, что, будучи частью команды и сообщества открытого исходного кода, я обязался решать все известные проблемы, связанные с производительностью бессерверных функций Python. Этот пост посвящен проблемам с производительностью и способам их диагностики. Обратите внимание, что этот пост может оказаться полезным не только для тех, кто работает без серверов, но и для разработчиков Python в целом.
Примечание
В данный конкретный момент Fn официально поддерживает множество различных сред выполнения, и еще больше сред выполнения официально не поддерживаются, но с концепцией «начальных образов» их можно использовать сразу, не отправляя какие-либо части в исходный код Fn.
Основная проблема, связанная почти со всеми средами выполнения, которые поддерживает Fn, называется «холодный запуск». Это тип проблемы, связанный с количеством времени, которое требуется для запуска приложения.
Пока мы работали над улучшением UX для функций, мы столкнулись с неприятной ситуацией: функции Python не могут запуститься в течение 5 секунд в рамках ограниченных ресурсов, когда остальные функции, основанные на других FDK (Java, Node, Go, Ruby), работают. вовремя. Итак, было решено исследовать проблему и выяснить, почему функции Python так долго работают.
Диагностика производительности Python 3
Все исследования и расследования были связаны с Python 3.7.1, потому что эта конкретная версия имеет новую систему импорта по сравнению с любыми другими версиями 3.X. Не буду описывать, что изменилось, очень рекомендую посмотреть официальную документацию, прежде чем читать остальное.
PYTHONPROFILEIMPORTTIME=1
В Python 3.7 многое изменилось. Одной из очень полезных функций является измерение времени импорта. У интерпретатора появился новый аргумент:
-X importtime
это помогает определить, сколько времени занимает каждый импорт. Он показывает имя модуля, кумулятивное время (включая вложенный импорт) и собственное время (исключая вложенный импорт). Тот же результат доступен с переменной окружения:
PYTHONPROFILEIMPORTTIME=1
Итак, наши исследования начались здесь: мы создали демо-функцию hello-world и решили запустить ее в контейнере с очень ограниченными ресурсами:
docker run --rm -ti --cpu-quota=10000 --cpu-period=100000 --cpu-shares=128 --memory=128m --kernel-memory=128m --memory-swap=128m --entrypoint /bin/bash func/hello-world:0.0.1
С некоторыми настройками env (добавлены новые переменные: FN_FORMAT
и PYTHONPROFILEIMPORTTIME=1
) мы запустили функцию, чтобы увидеть, сколько времени требуется для запуска функции:
Как видите, совокупное время составило ~8 секунд, если вы внимательно посмотрите, то заметите, что большую часть времени потребляют 3 основных компонента FDK: aiohttp, asyncio, xml.
Почему aiohttp, asyncio, xml?
Python FDK объявляет явные требования:
- async-ready (предполагается, что базовая структура способна работать с сопрограммами Python 3.5+ с использованием синтаксиса async/await)
- хорошо поддерживаемый
- нет ручного разбора HTTP
На данный момент FDK — это оболочка поверх HTTP-сервера, работающая поверх сокета домена Unix. Таким образом, aiohttp казался здесь хорошим вариантом: обработчик запросов — это сопрограмма, хорошо поддерживаемая (и, предположительно, когда-нибудь ставшая частью стандартной библиотеки), идеальный кандидат. Что касается asyncio, то без него далеко не уедешь, если мы говорим о выполнении асинхронных операций, а также asyncio содержит реализацию цикла событий. Тем не менее, основная технология FDK — это цикл обработки событий от asyncio.
Но почему там xml? Ну, была идея сделать FDK обрабатывающим данные в объект, например, если кто-то отправит контент application/json, FDK применит json.loads к запросу содержание. Используя ту же идею, если пользователь отправляет XML — FDK разберет его в объект XML. К сожалению, это не сработало. Принимая во внимание, что импорт xml lib занимает почти 1,5 секунды, было решено избавиться от него и от обработки данных в целом и предоставить сырые данные, пусть люди сами решают, что они хотят с этим.
Проблема с программированием синхронизации HTTP
Если вы когда-нибудь пытались реализовать базовый HTTP-сервер с помощью Python, вы знаете, что для его работы вам нужно всего несколько строк кода:
import http.server import socketserver import io class Handler(http.server.SimpleHTTPRequestHandler): def do_GET(self): self.send_response(200) self.send_header('Content-type', 'text/plain') self.end_headers() self.wfile.write(b"hello from the other side") return print('Server listening on port 8000...') httpd = socketserver.TCPServer(('', 8000), Handler) httpd.serve_forever()
К сожалению, этот код не работает в большинстве случаев использования Python. Код в do_GET
является синхронным, потому что сам сервер является синхронным, а это означает, что вы не можете запускать фоновые задачи, фактически не блокируя основной поток, то есть блокируя HTTP-сервер. Как вы знаете, Python является однопоточным, а это означает, что любой ввод-вывод, происходящий внутри, является причиной того, что GIL переключается между ожидающими блокировки. Самое главное, что вы не можете запустить цикл событий в обработчике запросов, потому что это также заблокирует сервер. Для базовой технологии FDK очень важно быть асинхронным, что делает http.server непригодным для использования.
Асинхронная модель программирования HTTP
Хорошо, простой HTTP-сервер Python 3 не работает для FDK. Итак, что мы можем сделать здесь? К сожалению, ответ не является тривиальным, потому что стандартная библиотека Python 3 не очень хорошо справляется со своей работой.
asyncio вызвал большой интерес у сообщества Python, поскольку это решение большинства проблем с блокировкой ввода-вывода. Одной из замечательных особенностей была возможность реализации модели программирования асинхронных сокетов на основе C EPOLL. Большинство разработчиков были довольны этим, но остальная часть сообщества хотела более высокоуровневых функций, потому что никто не хотел иметь дело с обработчиками асинхронных сокетов, они хотели реализовать самые популярные протоколы, такие как HTTP, но поверх asyncio. Но тут сообщество разделилось и появились такие проекты, как aiohttp. К сожалению, в последней версии Python 3.7.2rc1 по-прежнему нет реализации протокола HTTP поверх asyncio.
Итак, aiohttp тяжелый и требует много времени для запуска, стандартной библиотеке Python нечего предложить. Вот почему мы решили заняться низкоуровневой асинхронной разработкой, управляемой событиями, чтобы сохранить модель асинхронного программирования.
Реализация пользовательского HTTP-сервера на основе asyncio
Для реализации асинхронного HTTP-сервера нам понадобилось:
- сервер асинхронных сокетов
- сайт домена асинхронного сокета UNIX
- анализатор HTTP
Самой большой проблемой для нас была необходимость реализовать собственный парсер HTTP-запросов/ответов. Итак, мы погуглили и нашли проект H11. Этот проект предоставляет API для анализа запроса/ответа и очень простой API. Самой большой ценностью для нас было то, что комбинация H11 и асинхронного сервера сокетов превращается в настоящий асинхронный HTTP-сервер.
unix_srv = loop.run_until_complete( asyncio.start_unix_server( event_handler, path="/tmp/srv.sock", loop=loop, limit=constants.IO_LIMIT ) )
где event_handler
:
async def pure_handler(request_reader, response_writer): log.log("in pure_handler") connection = h11.Connection(h11.SERVER) try: request, body = await read_request( connection, request_reader) await routine.write_response( connection, body, response_writer) except Exception as ex: await write_error(ex, connection, response_writer)
в то время как read_request
что-то в строках этого:
request, body = None, io.BytesIO() while True: buf = await request_reader.read(constants.IO_LIMIT) connection.receive_data(buf) while True: event = connection.next_event() if isinstance(event, h11.Request): request = event if isinstance(event, h11.Data): body.write(event.data) if isinstance(event, h11.EndOfMessage): return request, body if isinstance(event, (h11.NEED_DATA, h11.PAUSED)): break if isinstance(event, h11.ConnectionClosed): raise Exception("connection closed")
это самый простой способ реализовать асинхронный HTTP-сервер, отвечающий всем требованиям FDK:
- асинхронный/ожидающий-нативный
- событийный
- одновременный
Проблема с импортом Python
Возвращаясь к требованиям FDK и времени запуска, комбинация asyncio с H11 может запуститься в течение 2,8 секунд по сравнению с 8 секундами aiohttp. Звучит как победа, нет?
К сожалению нет. Есть еще одна вещь, которая довольно сильно влияет на FDK — код клиента. FDK был разработан для запуска кода клиента в качестве реакции на событие (HTTP-запрос и т. д.). И самая проблематичная его часть — это слово «бежать».
В терминах Python «запустить» означает, что код проходит несколько этапов инициализации:
- прочитать исходный код
- инициализировать модуль
- выполнять импорт
- инициализировать функции, классы и код уровня модуля
Старый FDK делал все это при запуске, поэтому в большинстве случаев код клиента взрывал FDK по двум причинам:
- код клиента может выполнять множество операций импорта и запускать некоторый код на уровне модуля
- код клиента управляет точкой входа FDK:
import fdk def handler(ctx, data=None): return "Hello from the other side" if __name__ == "__main__": fdk.handle(handler)
Эти две проблемы на самом деле связаны, поскольку код клиента контролирует точку входа в функцию, которую модуль выполняет при запуске. Это означает, что клиент может выполнять любые операции в этом модуле, которые повлияют на время запуска. С точки зрения FDK проблема заключается в следующем: FDK не контролирует свою собственную инициализацию. Таким образом, необходимо реализовать решение, которое фактически задержит инициализацию кода клиента до первого события (первого HTTP-запроса). И есть только один способ добиться этого: заставить FDK контролировать свою инициализацию, управляя точкой входа в код клиента.
Отложенный импорт
Одна из довольно интересных функций Python 3.7.1 — это importlib
возможностей. С некоторой магией можно отложить инициализацию кода клиента прямо перед первым звонком. Взгляните на следующий пример:
from importlib import util class Function(object): def __init__(self, func_module_path, entrypoint="handler"): self.spec, self.mod = self.import_from_source(func_module_path) self._executed = False self._entrypoint = entrypoint @staticmethod def import_from_source(func_module_path): func_module_spec = util.spec_from_file_location( "func", func_module_path ) func_module = util.module_from_spec(func_module_spec) return func_module_spec, func_module def handler(self): if self._executed is False: self.spec.loader.exec_module(self.mod) self._executed = True return getattr(self.mod, self._entrypoint)
Этот код создает набор из загрузчика модуля и спецификации модуля. И самое приятное в этом то, что модуль клиента будет выполняться в момент вызова handler
, что делает выполнение модуля отложенным до первого события.
С 'python"
по 'fdk"
В большинстве случаев, когда вы работаете с Python, вы используете следующий вызов CLI для выполнения своего кода:
python app.py
Таким образом, в этом случае точкой входа в ваше приложение является python
, а не любой код, который у вас есть ниже if __name__ == "__main__"
. Это означает, что прежде чем интерпретатор доберется до if __name__ == "__main__"
, он выполнит все, что находится в вашем модуле (импорт, классы, функции, код уровня модуля и т. д.).
Для FDK мне пришлось изменить точку входа, чтобы заставить FDK управлять своей собственной инициализацией, и в итоге я получил следующий сценарий:
import fdk import os import sys from fdk import customer_code def main(): if len(sys.argv) < 1: print("Usage: fdk <func_module> [entrypoint]") sys.exit("at least func module must be specified") if not os.path.exists(sys.argv[1]): sys.exit("Module: {0} doesn't exist".format(sys.argv[1])) if len(sys.argv) > 2: handler = customer_code.Function( sys.argv[1], entrypoint=sys.argv[2]) else: handler = customer_code.Function(sys.argv[1]) fdk.handle(handler)
Как видите, я вставляю этот скрипт между двумя сущностями:
- переводчик
- код клиента
чтобы предотвратить инициализацию кода клиента при запуске, поэтому финальная команда для запуска FDK с кодом клиента выглядит так:
fdk func.py handler
что семантически равно:
python fdk.scripts.fdk:main() func.py handler
Со всеми этими изменениями FDK сильно эволюционирует, позволяя разработчикам и пользователям получать более надежный опыт.
Выводы
Имея солидный опыт работы с Python, я смог сделать FDK лучше и быстрее с точки зрения операций ввода-вывода по сравнению с тем, что было раньше. Однако некоторые неравномерные изменения, такие как избавление от aiohttp, не совсем тривиальны, поскольку требовалось найти решения, которые не затрагиваются проблемой холодного запуска, более того, могут нормально работать в рамках очень ограниченных ресурсов.
Процесс диагностики не так прост, как может показаться. Недостаток этого обновления в том, что мне пришлось выбрать uvloop, потому что могут быть люди, которые делают свою разработку в Windows, там uvloop не поддерживается. Но, тем не менее, FDK можно улучшить с помощью uvloop, добавив его в зависимости (requirement.txt).
К сожалению, не все библиотеки рассчитаны на быструю инициализацию своего кода, но я надеюсь, что вскоре почти все библиотеки примут importlib
, чтобы сократить время запуска и инициализировать код только тогда, когда он был запрошен.
Не стесняйтесь задавать любые вопросы в разделе комментариев ниже. Подпишитесь на мой блог. Постараюсь и дальше публиковать на должном уровне.
Если вы хотите узнать больше о проекте Fn, вы можете найти следующие полезные ссылки в качестве общего дополнения к этому посту:
- Взгляните на существующие FDK.
- Подпишитесь на канал Fn YouTube.
- Чтобы узнать больше о платформе Fn, посетите домашнюю страницу проекта.
Наконец, присоединяйтесь к Fn Army в Slack!