Search
Write a publication
Pull to refresh
138.73
Yandex Cloud & Yandex Infrastructure
Строим публичное облако и инфраструктуру Яндекса

Создаём конвейер обработки запросов в платформе Serverless

Reading time13 min
Views1K

За 10 лет, что существует Serverless‑подход, бессерверные функции стали для многих разработчиков чем‑то привычным и удобным. С их помощью можно быстро написать несколько строк кода для реализации конкретной бизнес‑логики и задеплоить, не думая о развёртывании, настройке и обслуживании инфраструктуры. Нужный код запустится автоматически при срабатывании триггера, как это принято в событийно‑ориентированной архитектуре. Но если таких функций в приложении потребуется очень много — что поможет сохранить нужную скорость работы и другие преимущества Serverless?

Меня зовут Сергей Ненашев, последний год я разрабатываю в Yandex Cloud сервис бессерверных функций Cloud Functions. В нашем облаке с ним можно запускать код в виде функции без создания и обслуживания виртуальных машин.

Пожалуй, важнейший процесс в этом сервисе — это обработка внешнего входящего запроса. Чтобы эта конструкция работала с минимальными задержками, нам понадобилось хорошенько продумать архитектуру обработки запросов и применить пару трюков. Я расскажу, как команда пришла к тем решениям, что работают сейчас, а также покажу, на что обратить внимание самим пользователям, чтобы запрос пробегал по всей инфраструктуре не более 10 мс.

С чего всё начиналось 

На старте мы сформулировали несколько требований для будущего сервиса:

  • Хотелось сделать среду, которая обеспечивает защищённое исполнение пользовательского кода. Пользователи не могут мешать друг другу, работая с нашей системой, а также не должно быть возможности повлиять на сервис в целом. При этом совершенно нормально, что они приносят нам любой код, включая тот, который делает exit 0, — мы должны это переживать.

  • Мы ориентировались на построение сервиса в парадигме pay as you go. Пользователь должен платить только за то время и ресурсы, которые он потребляет в моменте.

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

Казалось бы, очень понятные пункты. На первый взгляд, пять минут спроектировали, получили сервис, всё работает, — но, взглянув на архитектуру приложения, спустя пять лет разработки, мы обнаружили вот это:

Мы поняли, что в итоге получился довольно сложный сервис с неочевидными решениями. Сейчас расскажу самое интересное про то, как он устроен внутри.

Определимся с базовой терминологией

Для начала посмотрим на более простую схему того, как внешний запрос обрабатывается в системе, и разберёмся, из каких элементов всё состоит.

  • Function, она же функция — корневой объект нашей системы, который позволяет загружать код в Yandex Cloud. У функций есть версии (function version), они содержат параметры запуска этого кода и то, сколько ресурсов ему нужно. На версии можно вешать теги — это тот идентификатор, с помощью которого внешние запросы определяют, какую версию вызывать сейчас. Все эти объекты доступны пользователям через API.

  • Роутер (router на схеме) — это внутренний компонент нашей системы, который терминирует входящий внешний вызов. Все вызовы пользователей, которые осуществляются к сервису, идут через этот компонент. Он получает HTTPS‑вызовы и превращает их во внутренние структуры нашего сервиса.

  • Control Plane (CPL) — публичный API сервиса. Поверх него работает консоль, command‑line interface и все остальные компоненты сервиса.

  • Планировщик, он же scheduler — принимает решение о том, на каком движке будет исполняться вызов. Для этого он следит за тем, какие функции на каком железе запущены, как долго они запускались, сколько экземпляров исполняются, каково их среднее время исполнения.

  • Engine, он же движок — это компонент, который занимается низкоуровневой настройкой среды исполнения: работой с памятью, дисками, ресурсами, процессором, сетью.

  • Request — это внешний входящий запрос от пользователя, который попадает на роутер.

  • Задача, она же Job или Task — это внутреннее представление этого запроса.

  • Воркер (worker) — вещь, хорошо известная пользователям, правда, под другим именем. Это тот самый экземпляр функции, на который есть отдельная квота, его же я буду иногда называть исполнителем. Воркер запускает виртуальную машину, в которой запускается код пользователей.

  • Микро‑виртуальная машина — это настоящая виртуальная машина с настоящим linux‑ядром и урезанной операционной системой, в которой запускается среда исполнения и, в конечном итоге, ваш код.

  • Рантайм (runtime) — та самая среда исполнения, окружение. Это то, что пользователь выбирает, когда хочет запустить функцию: Node, Bash, Python — и другие языки, которые мы сейчас поддерживаем. Всё это делается через рантаймы.

Как путешествует входящий запрос

Из дикого интернета пользователь делает http‑вызов к сервису, который приходит в роутер.

В роутере начинается процесс, который называется идентификация:

  • происходит разбор запроса;

  • по данным этого запроса определяется, какая функция/версия должна запускаться;

  • и определяется, есть ли возможность у пользователя, который совершил вызов, вызывать эту функцию. Это делается через IAM — сервис Identity and Access Management.

Здесь есть первый трюк, которым мы активно пользуемся. Версии функции у нас не могут меняться со временем. Если пользователь вносит какие‑то изменения, он всегда создаёт новую версию функции. Для сервиса это очень удобно.

Раз версии неизменны, их можно положить в кеш, который будет обновляться в фоне. Терминация запроса роутером, идентификация, разбор и даже обращение в IAM при наличии такого кеша в 95 перцентиле занимают всего лишь одну миллисекунду. Без этого кеша тайминги были 40 миллисекунд, что было страшно.

Следующий этап — это выбор исполнителя. Роутер ходит в планировщик и спрашивает: «Скажи мне, на каком исполнителе мне вот этот запрос в данный момент обработать». Планировщик, обладая аналогичным кешем, знает, какие квоты у текущего пользователя, что это за версия функции, какие исполнители существуют, достаточно ли их для осуществления полноценного вызова, его обработки. В условиях нехватки исполнителей фоновый процесс добавляет необходимое их количество.

На это уходит порядка трёх миллисекунд (в 95 перцентиле), ещё одна миллисекунда — на взаимодействие между сервисами. Итого уже пять миллисекунд с момента начала обработки запроса.

Теперь мы знаем, куда отправлять запрос, какому исполнителю, в какой движок, и путешествие продолжается туда. В данном случае это уже не запрос, а обёртка, задача, которая содержит все данные запроса.

latency: 7ms (+1ms, +1ms) + q
latency: 7ms (+1ms, +1ms) + q

Впоследствии во всей инфраструктуре мы не оперируем данными http‑запроса. Мы работаем именно вот с этой задачей, в которой есть полный набор данных о том, что нужно запустить в рамках функции. Это аргументы, тело входящего запроса, а в случае интеграции — просто JSON, который пользователь или сервис туда отправил. Оно попадает в движок, встаёт в очередь исполнения версии функции и ждёт, пока за ним придёт один из исполнителей.

Если точнее, за ним приходит рантайм, где запущен наш код, который регулярно (после завершения кода обработки очередной задачи) приходит и говорит: «Отдай мне, пожалуйста, следующую задачу». Рантайм, который пришёл первым, получает задачу. Соответственно, его воркер становится активным, задача обрабатывается уже внутри рантайма.

latency: 8ms (+1ms) + с
latency: 8ms (+1ms) + с

Внутри рантайма эта задача превращается во внутренние структуры того языка, на котором написана функция. Например, для bash это будет, input stream. Рантайм понимает, какие структуры использовались в качестве аргумента, и осуществляет преобразования, если это требуется (например, заполняет структуру из входящего json). Это специфичное для каждого рантайма поведение реализуется для каждого языка отдельно.

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

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

Детали: MicroVM, ретраи

Наша микровиртуалка представляет из себя QEMU. Это та же самая виртуальная машина, которая используется в Cloud Compute.

Образ корневой файловой системы у нас общий для всех, мы его готовим и деплоим на этапе деплоя нашего приложения. Это очень урезанная версия Ubuntu. В ней подменён init‑процесс: мы не запускаем systemd, потому что это очень долго. У нас есть очень маленький init, который мы называем Bootstrap. На этапе инициализации он получает от движка команды на настройку сети, дисков, окружения и пользовательского кода, а затем — запускает рантайм, который после старта начинает взаимодействовать с движком сам.

Всё, что осталось сделать, — получить на стороне роутера ответ движка, завернуть его во внутреннее представление, не забыть сходить в биллинг и сказать, сколько времени на всё это потребовалось. Ну и заполнить http‑response, вернуть его пользователю.

latency: 10ms (+2ms)
latency: 10ms (+2ms)

Казалось бы, это всё. Но не совсем. Иногда задача, находящаяся в очереди на исполнение, может не выполниться. Это случается, если задача не дождалась, когда за ней пришёл один из исполнителей.

Например, у нас код, который регулярно работает дольше, чем максимальное время исполнения, которое указано для версии функции, — и сервис так же регулярно вынужден останавливать его. Тайм‑аут, к сожалению, это жёсткое прерывание исполнения функции, и он означает остановку виртуальной машины. То есть, когда задача помещалась в очередь, система прогнозировала, что через условные 5 мс завершится исполнение одной из обрабатываемых задач на одном из воркеров, и этот воркер придёт за новой задачей. Но он не пришёл, потому что через шесть миллисекунд упал, и ещё пять миллисекунд задача ожидала, когда новый воркер инициализируется, потому что у него холодный старт (а это, во‑первых, запуск среды исполнения, во‑вторых, запуск пользовательского кода, который, как правило, занимает время).

В этом случае мы возвращаем задачу обратно в роутер и на роутере принимаем решение.

  • Если задача не запускалась, то есть мы гарантированно знаем, что запуска в окружении и в пользовательском коде не было, мы снова пойдём в этап выбора исполнителей и попросим планировщик: «Скажи, пожалуйста, где можно ещё попробовать?» Это мы делаем на случай, если есть какая‑то локальная проблема.

  • Если это локальная проблема нашей инфраструктуры, то пользователь не должен от этого страдать. Мы снова пойдём в планировщик, он выдаст нам другого исполнителя на другом движке, и мы попробуем снова.

  • Если по истечении трёх таких попыток на разных движках мы видим, что проблема сохраняется, то, скорее всего, это не наши проблемы, а баг в пользовательском коде. Мы прекращаем попытки и возвращаем пользователю код ошибки: либо 429 (если при очередной попытке мы наткнулись на превышение квот), либо 504 (время исполнения/ожидания превышено), в зависимости от того, что было в запуске.

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

Очереди задачи, повторы попыток очень сильно влияют на время обработки запросов. Поэтому пользователям важно:

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

  • сокращать время на инициализацию кода, потому что init — это то время, которое запрос живёт в очереди. Чем меньше init вашего кода, тем быстрее начнётся обработка запросов.

  • следить за временем и корректностью кода. Если пользователь превышает execution time‑out, мы останавливаем воркер, а это реинициализация, холодный старт, увеличение времени и накопившиеся задачи в очереди.

И ещё небольшая рекомендация: попробуйте concurrency. Это фича, которая появилась недавно. Для некоторых рантаймов она позволяет обрабатывать больше, чем один запрос одновременно.

Теперь о хитростях, которые позволяют нам с этим работать эффективнее.

Трюки продвинутого Cloud Functions

Первый трюк: упреждение. Мы держим не только тот объём воркеров‑исполнителей, которые нужны для обработки запросов, имеющихся у пользователя в моменте. Мы идём на упреждение и создаём в фоне дополнительные воркеры в расчёте на то, что возможен скачок нагрузки в будущем. Эти новые запросы будут обработаны бóльшим числом воркеров, которые к тому моменту уже созданы. Так мы уменьшаем время нахождения задачи в очереди.

Второй трюк: предварительный прогрев. Совсем недавно мы пошли чуть дальше. Если раньше мы брали запрос, брали новый воркер и в рамках этого воркера делали инициализацию пользовательского кода, то процесс мог быть довольно долгим. А пользователь, который делает запрос, ждал, пока код функции инициализируется.

Сейчас мы помимо создания воркеров в фоне также их инициализируем. Когда приходит запрос, он либо обрабатывается каким‑то воркером, который закончил работу в моменте, либо будет обрабатываться новым воркером, в котором есть уже инициализированный заранее код функции. Запуск кода начинается быстрее.

Третий трюк: заимствование ресурсов. Представьте, что мы создали функцию, опубликовали её наружу в рамках своего процесса, в неё ходят пользователи, всё отлично. Квоты утилизируются практически на максимум, нас всё устраивает, всё прекрасно работает. Но тут мы решили создать новую версию. К чему это приводит?

Новая версия создалась, на неё переехал тег latest. Через какое‑то время в роутер пришёл запрос, он спрашивает: «Кто сейчас latest?». Он получает из CPL новую версию функции и идёт к планировщику с вопросом: «У меня тут новая версия, давай для неё сделаем воркер». А планировщик отвечает: «Не могу, квоты же заняты». И вот вам отказ в обслуживании. Казалось бы, на ровном месте.

В случае, если мы находим такую ситуацию, мы идём во все существующие воркеры, находим те, которые в рамках квоты не используются. Скажем, созданные на упреждение или те, которые завершают работу. Берём тот, что использовался меньше других (а если таковых нет — тот, что только что завершился), удаляем его и мгновенно на его месте создаём воркер на новую версию. 

Соответственно, следующие запросы частично будут приходить на него, а частично будут проходить по тому же самому циклу. Через какое‑то время все старые воркеры, которые обрабатывают старые запросы к старой версии функции, доработают и будут превращены в новые практически незаметно для вас.

Четвёртый трюк: адаптивная нагрузка. Наш мир противоречив. С одной стороны, мы хотим останавливать воркеры, которые не используются. Мы насоздавали их, но нагрузка закончилась, возник какой‑то простой. Почему бы их не удалить и не создать на их месте те, которые бы выполняли другие функции, другие версии других клиентов? Экономим ресурсы. А с другой стороны, мы понимаем, что рестарт и холодный старт — это плохо, и хотим оставлять воркер, даже если в него сейчас не попадает нагрузка, на максимально возможное время, чтобы сократить холодный старт.

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

Пятый трюк: долгоживущие функции. Мы увеличили execution timeout до часа и вдвое увеличили ресурсы, которые доступны для функции.

К чему это приводит? Когда мы работали над этим сценарием, мы делали его в противовес существующему способу работы с функцией. Как это работало пять лет назад и как заработало недавно? Пользователь создавал функцию, у неё было какое‑то короткое время на инициализацию, приходил запрос, который поджигал выполнение, и потом в рамках воркера запускался второй, третий, четвертый, пятый, шестой запрос. Пользователь получал ответ практически мгновенно. Когда мы говорим про долгоживущие функции, то есть до 60 минут работы, нам не важно, когда будет получен ответ. Конечно, мы его хотим получить как можно быстрее. Но 50–100 миллисекунд на фоне одного часа — это не то время, которое нужно ускорять, особенно для внешнего пользователя. Вы же не будете просить пользователя в браузере запустить запрос, ответ на который получите в этом браузере через час. Поэтому в этом сценарии мы сконцентрировались на том, что, по сути, запуск долгоживущей функции — это поджигание некоторой отложенной работы, которая должна завершиться в фоне.

Это нам даёт то, что практически на каждый вызов будет создаваться новый воркер. При этом у нас есть упреждение, адаптивная нагрузка. А оно говорит следующее: экземпляры функции должны жить после того, как они закончили работу. Напомню, что там 8 ГБ. Упреждение говорит: если пришёл один запрос, давайте мы создадим несколько воркеров на будущую нагрузку. А их не будет, потому что это единичные вызовы.

Казалось бы, огромная нагрузка на систему, но здесь мы пошли по следующему пути. В механизм, о котором я говорил в случае управления нагрузкой, мы добавили ещё и понятие «вес функции». Чем больше ресурсы функции, время её исполнения, тем более агрессивно будет работать остановка её воркера. Это будет происходить раньше. Кажется, в описанном сценарии, это валидный кейс. И при этом мы существенно уменьшаем нагрузку на наш кластер, соответственно, можем таких функций запускать сильно больше.

Всё бы ничего, но, к сожалению, есть такая проблема, как гарантированное время исполнения. 60 минут, которые пользователь запрашивает для функции, обычно действительно составляют ровно 60 минут. Но иногда случаются моменты, когда мы должны вывести сервер из эксплуатации на технические работы. В этом случае провайдер за десять минут до остановки работы предупреждает пользователей о том, что функция завершится.

Если мы фиксируем уже потраченное время и понимаем, что execution timeout за вычетом этого времени превышает десять минут, скорее всего, функция в эти десять минут не уложится. В этом случае мы отправим в процесс сигнал, на который пользователи должны среагировать. Будет десять минут на то, чтобы, например, положить в какую‑нибудь базу всё накопленное за 50–40 минут непрерывного труда функции и выйти. Потом можно возобновить эту работу, вернуть наружу ID объекта, и вызвать функцию второй раз. Мы понимаем, что это не очень удобно. Мы обязательно сделаем автоматизацию, чтобы повторный ретрай на новом месте начинался с того места, которое пользователь вернул.


Напоследок хочу сказать, что тот сервис функций, который мы делали, используется сейчас для таких вещей, как сборка функций. Когда пользователь приносит код функции с зависимостями, для её сборки мы запускаем функцию. Когда пользователь создаёт API Gateway, мы запускаем функцию внутри. Когда пользователь просит запустить контейнер, то по своей сути запускается функция на стероидах. Другой наш сервис, Serverless Workflows тоже внутри использует функции для того, чтобы изолировать пользовательский код и пользовательскую логику друг от друга. Мы до сих пор используем те самые фичи, которые закладывали пять лет назад. А если это работает для нас, то надеюсь, будет так же хорошо работать и для клиентов.

Tags:
Hubs:
+18
Comments0

Articles

Information

Website
yandex.ru
Registered
Employees
over 10,000 employees
Location
Россия
Representative
Вера Сомова