вступление

С самого начала я искал наиболее эффективные способы запуска бессерверных функций 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, вы можете найти следующие полезные ссылки в качестве общего дополнения к этому посту:

Наконец, присоединяйтесь к Fn Army в Slack!