Максим Шошин — Serverless. Под капотом Cloud Functions

Максим Шошин — Serverless. Под капотом Cloud Functions55:34

Информация о загрузке и деталях видео Максим Шошин — Serverless. Под капотом Cloud Functions

Автор:

DotNext — конференция для .NET‑разработчиков

Дата публикации:

30.08.2024

Просмотров:

559

Транскрибация видео

Спикер 3

Друзья, еще раз приветствую всех.

Рада за всех, кто успел пообедать.

Рада за всех, кто скоро пообедает.

А сейчас мы с вами будем слушать замечательный доклад Максима Шошина.

Что особенно интересно, Максим работает в Яндекс.Клауде и сейчас будет рассказывать про внутренности, что находится под капотом Cloud Functions.

А еще хочу поделиться с вами инсайдерской информацией про Максима.

Я тут его немножко пытала, пока вы все собирались.

Максим родился в Архангельске, вырос под Мурманском и видел настоящее северное сияние много раз.

А кто вообще видел северное сияние?

Поднимите руки, пожалуйста.

Вот вам я тоже завидую.

По-белому.

Итак, Максим, прошу.

Спикер 4

Привет!

Представьте себе вечер, в окнах уже горит свет, и если я иду мимо такого дома, я обязательно загляну в незашторенное окно.

Существует две гипотезы, почему люди заглядывают в открытые окна и двери.

Первая связана с безопасностью.

Мы боимся, что оттуда на нас выпрыгнет какой-то хищник.

Эта теория мне не нравится, гипотеза.

Мне нравится другая гипотеза, которая говорит о том, что мы удовлетворяем свою жажду познания, заглядывая в открытые окна и двери.

Мы сегодня с вами заглянем в Cloud Functions, в окно Cloud Functions.

Я знаю, что вы тоже любопытны, раз пришли сюда меня послушать.

Итак, многие считают, что в Яндексе нет .NET.

Это не совсем так.

Тут работают .NET-разработчики, например, я. И Яндекс.Клауд делает продукт для .NET-разработчиков.

Чувствуете противоречие, что с одной стороны нет .NET, а с другой стороны сервис для .NET-разработчиков?

Я хочу показать вам немножко цифры.

Это количество запусков функций за июль разбиты по разным языкам программирования, на которых эти функции написаны.

.NET у нас на четвертом месте.

Да, мы обогнали Java, находимся рядом с Go, но мы очень далеко от лидеров JavaScript и Python.

И мне, как .NET-разработчику с 20-летним стажем, как участнику SPB .NET Community, такое положение вещей кажется несколько обидным.

Поэтому я тут стою и буду рассказывать о Cloud Functions.

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

Рассказать, где он находится в серверлесс-мире сервисов, рассказать, как он работает, и погрузиться глубже, рассказать, как он устроен, как мы его делали, с какими сложностями сталкивались.

Я расскажу о самой главной проблеме Cloud Functions —

и как мы ее решали, но что за проблемы, расскажу потом.

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

Данная презентация доступна для скачивания, QR-кодик, ссылочка, там презентация, все ссылки, ссылки на примеры, пользуйтесь.

Ну и как говорили классики, у общества без цветовой дифференциации, скажем так, слайдов,

Нет цели, поэтому некоторые слайды у меня помечены вот такой фиолетовой штукенцией.

Это значит, что я тут рассказываю какую-то историю или поясняю не очень очевидное поведение.

Итак, поехали.

Прежде чем заглядывать...

В окно мы посмотрим на дом целиком.

То есть прежде чем говорить о Cloud Functions, давайте поговорим о серверах.

Я не буду давать определение, что такое сервер, я просто расскажу, что это такое на примерах.

Итак, наши коллеги из After Room сделали интерактивный сервис по проверке знаний правила дорожного движения.

используя Serverless технологии.

При запуске была маркетинговая акция и нагнали кучу людей.

И первая причина использования Serverless в том, что при разработке этого сервиса не нужно было сильно заморачиваться с масштабируемостью.

Serverless сервисы хорошо держат нагрузку из коробки и не требуют какого-то сложного администрирования.

Вторая причина экономическая.

Маркетинговая акция закончилась и количество пользователей полезло вниз.

Но и автору нам тоже стало платить меньше, потому что нет запуска функции, нет оплаты.

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

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

Ну и третья причина в том, что сервис — это целая экосистема сервисов.

То есть это кирпичики.

Каждый кирпичик делает что-то одно, но хорошо.

Например, у нас есть кирпичики для хранения данных.

Это Object Storage или различные менеджер базы данных.

У нас есть кирпичики для передачи данных.

Это Message Queue, это Data Streams.

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

Что-то сетевое, что позволяет скрыть набор ваших функций за единым фасадом.

Это API Gateway.

Причем этот сервис сделан на основе Cloud Functions.

То есть большие, серьезные, сложные, высоконагруженные приложения тоже можно писать на Cloud Functions.

Ну и кирпичики для безопасности.

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

Все эти кирпичики хорошо стыкуются друг с другом.

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

Итак, мы говорим о Cloud Functions.

Что такое функция?

Это на самом деле реакция на какое-то внешнее событие.

Это событие может быть вызов HTTP метода, появление сообщения в очереди, наступление какого-то конкретного времени.

Дальше наша система вызывает реакцию на это событие, описанное вашим кодом, и передает туда информацию о событии, что за событие туда произошло.

Реакция — это функция.

Она может быть написана на разных языках программирования, но мы сегодня говорим о .NET.

Чтобы написать функцию, нужно сделать публичный класс с публичным методом со специальным названием «FunctionHandler».

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

В данном случае это HTTP-запрос.

Ну а на выход — респонс.

Эти классы описываются прямо у вас, рядом с вашей функцией, в вашем коде, внутри нашей системы.

Описание события путешествует в виде JSON-документика, поэтому JSON-property name, то есть это для десерилизации в вашу модельку.

Для разных событий, как я уже говорил, разные модели.

В данном случае это HTTP-запрос, здесь есть заголовки, здесь есть тело запроса, ну а на выходе будет статус-код и тело ответа.

Хочу обратить внимание, что вычислительные ресурсы вашей функции

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

Как только он вернул результат, ну в данном случае таска закомплитилась, все, вычислительные ресурсы вашей функции отбираются.

Таким образом, бэкграунд какие-то работы в Cloud Functions не очень возможны.

Ваша функция выполняется на отдельной виртуальной машине, линуксовой виртуальной машине.

У нее есть один, одно ядро CPU, памяти, столько, сколько вы попросили при создании функции, сеть, диск.

Диск — это линуксовая операционная, ну, линуксовая файловая система, рутовая файловая система от Ubuntu, она read-only.

Писать вы можете файлики в папочку slash tmp.

Вам там доступно 512 мегабайт.

В function slash code в этой папочке лежит скомпилированная ваша функция.

Вот то, что вы написали исходником, кладется туда.

И в function slash runtime лежит serverless runtime.

Не путайте с .NET runtime.

Serverless runtime в нашем случае .NET процесс.

который с одной стороны общается с нашими компонентами системы, с другой стороны вызывает и передает управление вашей функцией.

Об этом процессе мы чуть-чуть попозже поговорим, а сейчас давайте я расскажу, как ваша функция попадает в папочку functions.vashcode.

Вы пишете исходный код на каком-то языке программирования, его надо скомпилировать.

Существует два способа.

доставки вашей функции к нам.

Первый классический, вы пишете C-sharp программку, написали, заархивировали в zip-чик и передали нам, выложив на S3, это object storage, либо через утилиту командной строки, либо через веб-мордочку, передали.

Дальше запускается компиляция уже на нашей стороне, причем для компиляции вашей функции используется наша функция.

Ну и результат компиляции мы складываем в Function Storage, это вот кирпичик Serverless, это Object Storage, складывается туда в том виде, в котором будет удобно потом засовывать ваш код в виртуалку, то есть в виде файловой системы туда складывается.

Другой способ Deploy отличается тем, что компиляцию можно перенести на вашу сторону.

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

У такого подхода есть несколько плюсов.

Первый плюс — то, что вы можете использовать свою билд-систему с вашими настройками, с вашими алертами, систему тестирования, CI на вашей стороне.

Второй плюс — то, что вы можете при написании кода использовать последний .NET SDK.

То есть вы можете писать на последнем C-Sharp, используя все самые последние плюшечки его.

Главное, чтобы...

Таргет рантайм был тот, который мы поддерживаем.

Сейчас, на данный момент, мы поддерживаем шестой .NET, то есть последний LTS.

Ну и самый интересный плюс в том, что вы можете писать даже на другом языке программирования, например, на F-sharp.

Главное, чтобы в результате получилась DLL, у нее был публичный класс с публичным методом со специальным названием FunctionHandler.

Все правильно.

Если такое есть, все заработает.

Итак, мы задеплоили, а теперь давайте посмотрим, как оно работает.

На примере автору у вас есть сайт, пользователь что-то нажимает, и из браузера пользователя идет запрос к вашей функции, HTTP-запрос.

Первое, куда он попадает, он попадает на наш компонент, он называется раутер.

Раутер смотрит, вообще функция такая есть.

Если есть, а требует ли эта функция авторизации?

Если требует авторизации, авторизирует запрос.

Выбирает последнюю версию вашей функции.

То есть когда вы деплоите, вы создаете версию функции.

Второй раз задеплоили, еще одна версия функции.

Вот здесь раутер фиксирует ту последнюю версию функции, с которой система дальше будет работать.

Следующий компонент.

Раутер спрашивает у планировщика,

Где же выполнить вашу функцию?

Планировщик, он у нас один на дата-центр.

В памяти своей он знает обо всем состоянии нашего кластера.

И по этому состоянию он говорит, вот на таком-то Engine выполняет дорогой раутер функцию.

Раутер идет в Engine, передает туда информацию о запросе, который пришел от пользователя.

В Engine есть очередь запросов, отдельная очередь для каждой версии функции.

Есть микровиртуалка, в эту микровиртуалку подключается, монтируется ваша функция, ваша скомпилированная функция.

Запускается сервер с runtime, передается управление вашей функцией, ваша функция отработала, выдает результат.

Раутер такой смотрит, ага, функция заняла столько-то времени.

Для функции было зарезервировано столько-то памяти, и эта информация улетает в биллинг.

Еще раутер чистит небезопасные, с нашей точки зрения, заголовки.

То есть из функции вы, например, не можете выставить куку.

Небезопасная вещь.

Ну и отдаем дальше.

Так это выглядит в общем случае.

Ну и у любой системы есть ограничения.

У Cloud Functions тоже они есть.

Но первое ограничение — то, что там нет бэкграунд-работ, потому что у функции, как только она вернула результат, мы отбираем процессор.

Ну что такое бэкграунд-работа?

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

Ну или периодическая задача — это когда вам нужно раз в пять минут что-то проверить, что-то запустить, что-то еще что-то.

Такие ограничения легко обходятся.

Синхронная задача обходится с помощью message queue.

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

Ну а периодическая задача у нас есть функциональность, которая запускает триггеры просто по функции, просто по расписанию.

Вообще функция это квинтэссенция микросервисного подхода.

функции нет состояния.

То есть когда вы пишете функцию, вы не можете рассчитывать, что оно будет.

Да и вообще вам хранить это состояние толком-то и негде.

Функция может переподняться, состояние все потеряется.

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

Ну а для асинхронных задач использовать Message Queue и Cloud Functions сами из коробки могут запускать функции по расписанию.

Итак, мы поговорили, что такое сервер, посмотрели на дом, а теперь подходим ближе и давайте заглянем через забор.

Заглядываем через забор, а там вечеринка.

Вечеринка по поводу того, что Яндекс.Клауду 10 дней назад исполнилось 5 лет.

Функции чуть моложе, они появились в 2019 году, и основным сценарием использования в начале было «Навыки Алисы».

Это тоже классический сервис с сильно плавающей нагрузкой, но эта нагрузка плавает в течение дня.

То есть утром вы проснулись, спросили Алису, вечером пошли на работу, Алиса отдыхает, ну, днем.

А вечером пришла жена, дети, Алису мучают своими вопросами.

А ночью всем спят.

Итак, навыки были самым интересным.

массовым сценарием.

Они, кстати, сейчас, использование Cloud Functions в навыках Алисы бесплатно сейчас, кстати.

Итак, они были самым массовым сценарием, но последние несколько лет Алиса уступила свое первенство

Знаете кому?

Догадывайтесь.

Ну, вообще это чат-ботики.

Здесь вот голосовое сообщение нарисовано.

Я терпеть голосовые сообщения не люблю, вообще не пользуюсь.

И мне один знакомый сказал, как правильно надо отвечать на голосовые сообщения.

Надо отвечать вот таким QR-кодиком.

Здесь написано, что извините, но у меня нет возможности прослушать ваши сообщения.

Вообще кто-то пользуется голосовыми сообщениями?

Ну, если вам придет такой QR-кодик, знайте, в кого кидать помидоры.

А сейчас для примера мы напишем ботика, который по присванному ему текстовому сообщению сгенерирует этот QR-кодик.

Telegram умеет вызывать вебхук для любого взаимодействия с вашим ботиком.

То есть в качестве адреса вебхуки мы установим адрес функции.

Таким образом, Telegram будет дергать нашу функцию.

и передавать в теле запросов все взаимодействие, что там с ватичным ботиком происходило.

Итак, мы пишем публичный класс с публичным методом function handler, подключаем библиотеку telegram.bot и telegram.bots.

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

Первое, что мы делаем, это десериализуем то, что нам прислал телеграм.

Десериализуем модельку messageUpdate, то есть

мы с нами взаимодействуем с нашим ботиком в виде сообщения.

Проверяем, что сообщение текстовое.

Не надо моему ботику присылать голосовухи.

Ну и дальше с помощью библиотеки ImageSharp QR-код генерируем QR-код, генерируем картинку по этому коду и отправляем это все в чатик.

Ботик простой до невозможности.

Потребовалось мне, наверное...

Часа полтора, чтобы ботик написать, и то большую часть этого времени я разбирался, как работать с Телеграмом, что он присылает, как правильно выставить эту несчастную веб-хуку в Телеграме.

По ссылке есть примеры, в том числе Телеграм-ботик, еще я там выложил какие-то примеры, которые я считаю, что, возможно, будут вам интересны.

Мы с вами общаемся с ботиками практически ежедневно.

У вас есть идеи, какие проблемы, что больше всего бесит вас в таком общении?

На самом деле больше всего бесит, когда Telegram-бот просто не работает.

Давайте посмотрим, что может пойти не так.

Вы все суперпрограммисты.

В коде ваших функций багов нет.

Интернет тоже сделали бородатые умные чуваки.

Пакетики там ходят, когда и куда надо доходят, не теряются, всё хорошо, но остаётся наша система.

Так, что может пойти не так?

Раутер у нас стоит за балансировщиками, то есть так или иначе на какой-то живой раутер у нас придет запрос.

Дальше раутер спрашивает, где же выполнить эту функцию, и спрашивает у нас у планировщика.

Планировщик один в дата-центре, казалось бы, вот она, точка, единая точка отказа.

На самом деле не все так плохо, потому что в этом случае планировщик пойдет к… Раутер пойдет к планировщику в соседний дата-центр,

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

Не очень хорошо, но на самом деле у нас есть механика, когда мы раутер выводим из-под нагрузки.

То есть когда у нас планировщик обновляется, весь трафик переезжает в соседний дата-центр.

Ну а следующая ошибка, когда раутер пошел к энжину, а энжин...

Тоже обновляется или еще что-то там, неважно.

В этом случае раутер повторно идет к планировщику, получает новый адрес engine, то есть он идет с blacklist engine, то есть если вот тот engine вернешь, того engine мне не надо, не возвращай его, пожалуйста.

Получили адрес, выполнили все хорошо.

С ошибками вроде все.

Давайте посмотрим, как это работает все.

Мы задеплоили ботика и решили к нему обратиться.

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

Ну, все просто.

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

Выбрали, все хорошо.

Теперь я хочу показать, как этот алгоритм работает на обновление engine.

Engine много, обновлять их тоже нужно.

Нужно их обновлять регулярно.

Мы пишем код, что-то делаем.

Мы используем rolling update.

То есть прежде чем что-то поднять, мы создаем новый engine.

Как только он поднялся, гасим какой-то из существующих.

И вот если сейчас придет запрос на создание нового engine, нам очень невыгодно, если он придет на еще не обновленный engine.

То есть если он упадет, если мы там будем создавать.

Но на самом деле такого не происходит.

Новые экземпляры engine создаются на только что обновленном engine, потому что на нем больше всего свободных ресурсов.

То есть вот этот простой алгоритм решает нам еще такую задачу.

Так, мы поговорили о том, как выбирается engine для создания нового экземпляра функции.

Ну а если...

у нас уже экземпляр функции создан где-то, то мы просто возвращаем адрес этого энжина и все.

Если таких энжинов несколько, то мы смотрим на очереди и возвращаем адрес того энжина, у которого длина запросов в очереди меньше.

Но это еще не все.

В 10% случаях мы просто идем, создаем новый экземпляр функции.

Зачем?

На самом деле эти 10% в случае, если нагрузка на функцию резко возрастает, позволяют нам чуть-чуть лучше масштабироваться.

Итак, мы задеплоили ботика, написали ему сообщение, и он даже нам что-то ответил.

Давайте посмотрим на логи вызова функции.

Выглядят они как-то вот так.

Здесь есть основная информация, которая пойдет в биллинг, например, buildDuration.

Это время выполнения вашей функции, округленное до 100 миллисекунд в большую сторону.

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

И здесь вот очень интересная вещь.

Есть initDuration.

Это значит, что экземпляр функции поднимался первый раз.

Если мы обратимся к ботику еще раз, то этого initDuration не будет.

Это значит, что запрос...

попал на уже поднятый экземпляр и им обработался.

И время тут тоже гораздо меньше.

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

Если мы напишем нашему ботику, то мы снова попадем на этот initDuration.

Это называется холодный старт.

Именно это и есть самая главная проблема Cloud Functions.

Минимизацией этого времени занимаются все наши компоненты.

Ну,

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

И она бывает несколько рандомная, то есть вы не управляете этой штукой.

Что входит в InitDuration в это время?

В это время включается...

Время запуска процесса Serverless Runtime, который запускается внутри виртуалки.

Serverless Runtime грузит вашу DLL-ку из диска.

запускает ее, JIT компилирует и запускает статические конструкторы, статическую инициализацию.

То есть вся статика будет вот в этом вашем InitDuration.

Статика — это не хорошо, не плохо в функциях, просто нужно понимать, что она пойдет в холодный старт.

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

Давайте поговорим о виртуалках.

Почему вообще виртуалки, а не какие-то контейнеры?

Потому что мы в Яндекс.Клауде думаем о безопасности, и именно виртуализация дает необходимую изоляцию функций друг от друга.

Мы запускаем функции от разных клиентов, мы не хотим, чтобы доступ был у одного клиента в другую функцию.

Контейнеры, к сожалению, пока что еще не настолько хороши.

Ну, а почему микровиртуалка?

Потому что она кушает много-мало ресурсов и стартует очень быстро.

Для виртуализации мы используем Кему.

Это open-source виртуализация под Linux.

Патчи.

То есть оно у нас почти не пропатчено.

Оно пропатчено для Яндекс.Клауда, то есть для больших виртуалок.

Но ничего специального мы для себя, для Cloud Functions не делали.

Итак, как виртуалка работает с точки зрения дисковой операционной системы, файловой системы.

Итак, у нас есть read-only рутовая файловая система, и она одинаковая для всех функций.

Мы сделали такой трюк, что положили эту рутовую файловую систему в хостовую операционную систему, в энжин.

в память положили и переиспользуем ее между всеми виртуалками.

Кажется небезопасно, но на самом деле все в порядке, потому что, во-первых, она read-only, а внутри функции у вас рута нету.

А во-вторых, она лежит на хостовой операционной системе в памяти, которая помечена как read-only, то есть на уровне процесса, на уровне хостовой операционной системы вы туда ничего записать не сможете.

Прежде чем что-то запустить, какую-то вашу функцию, надо вообще загрузить операционную систему.

В Linux процессом запуска операционной системы занимается процесс Bootstrap.

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

В эти 64 мегабайта памяти должно влезть ядро и должен влезть этот процесс.

Мы поэтому Bootstrap написали на C++, мучаемся, колемся, но так как меняется редко, все хорошо.

Так, следующий трюк, который позволяет нам экономить до 50 миллисекунд на старте, это то, что мы не используем загрузчик операционной системы, мы не используем виртуальный BIOS, мы сразу грузим Linux ядро.

Ядро у нас Linux 5.15, патченое-перепатченое.

Запуск ядра занимает до 160 миллисекунд.

И как мы этого добились?

На самом деле мы при...

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

Нет у нас поддержки интеловских видеокарт, нет такого железа в Яндекс.Клауде, не нужно такое ядро.

Поэтому ядро получается маленьким и быстро стартует.

Итак, как же мы боремся с холодным стартом?

На самом деле мы просто поддерживаем пул уже запущенных виртуальных машин, уже запущенных операционной системой.

Итак, мы запустили виртуалку с 64 МБ памяти, дождались, пока запустится операционка, поставили на паузу и положили в пул.

Таких виртуалок в пуле много, поэтому именно 64 МБ памяти, чтобы виртуалка, которая ничего не делает, просто лежит, не кушала много ресурсов.

Итак, пришел запрос на поднятие нового экземпляра функции.

Мы берем виртуалку из пула.

Запустили ее, смонтировали файловую систему, то есть скачали из Function Storage вашу функцию, смонтировали файловую систему, добавили памяти столько, сколько вы попросили, выставили переменные окружения и запустили сервер вас рантайм.

Это процесс.

Первым делом, что он делает, то что он лезет в Engine в очередь и берет из этой очереди задачу на выполнение.

Давайте поговорим про очередь задач.

Во-первых, это на Engine очередь.

First in, first out.

Очередь одна на одну версию функции.

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

То есть могут быть запущены несколько виртуальных машин.

Задачи берутся по очереди.

Взяли задачу.

Отдали эту задачу на выполнение функции, функция завершилась, мы лезем за следующей задачей.

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

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

Ну, как это выглядит?

Появилась задача, мы взяли со стека, то есть экземпляры функции выстраиваются со стека, взяли со стека первый экземпляр, отдали ему, работает, появилась вторая задача, отдали второй функции, потому что она в вершине стека оказалась.

Первая закончила, вторая закончила, и второй экземпляр функции оказался на вершине стека.

То есть если придет сейчас задача, мы и отдадим экземпляру номер 2.

Ну, вообще, зачем мы...

Так вот делаем.

Почему мы так распределяем работу между энжинами?

Можно там каким-нибудь раунд-робином, еще что-то.

На самом деле есть идея в том, что... Смотрите, если у нас нагрузка упала в...

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

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

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

Итак, это ситуация, когда у нас мало нагрузки.

Давайте посмотрим, что будет, если нагрузки, наоборот, будет много.

Ну, мы работаем с овербукингом, то есть если все наши клиенты одновременно придут к нам и вызовут одновременно все свои функции, нам будет очень плохо.

А с другой стороны, вам, если вашу функцию будут дедосить, то мы-то берем денежку за каждое выполнение функции.

То есть наши клиенты тоже потеряют кучу денег.

В общем, ни та, ни другая ситуация нам не интересна.

Ни вам, ни нам.

Поэтому у нас существует механизм квотирования, то есть это какие-то искусственные ограничения, например, на количество одновременно запущенных экземпляров или одновременно выполняемых запросов.

Таким образом, это помогает нам планировать ресурсы, а вам как-то ограничить нагрузку на ваши функции.

То есть если квоты проверяются планировщиком, если квота превышена, мы возвращаем 429, то есть too many requests.

Но в случае одновременно запущенных экземпляров мы так не делаем, потому что это может привести к отказу в обслуживании.

Например, вы сделали ботика, ботик достаточно нагруженный, и вот эту всю нагрузку,

обрабатывают вот эти четыре экземпляра функции.

Допустим, у нас для примера квота на одновременно запущенные экземпляры тоже четыре.

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

То есть любой следующий запрос попытается поднять вот эту новую версию функции.

А квота уже скушена.

Что делать?

В этом случае мы смотрим, а есть ли еще какие-то экземпляры функции, отличные от текущей.

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

Таким образом, мы с одной стороны остались в квоте, а с другой стороны на любой запрос мы не кинули 429, то есть все продолжает работать.

У этого алгоритма есть интересные последствия.

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

Что такое асинхронный пайплайн?

Запустили функцию, она отработала, положила сообщение в очередь.

По этому сообщению поджигается вторая функция, вторая функция поджигает третью через очередь, третья поджигает функцию четвертую.

Третья поджигает четвертую функцию.

Для примера квота скушена.

Все.

Что делать?

А pipeline еще продолжается.

Четвертая функция пытается поджечь пятую функцию.

Квота съедена.

Выбираем жертву.

Жертва – это функция номер один, так как она дольше всего ничего не делала.

Тушим, поднимаем.

Все замечательно.

Вот если сейчас мы перезапустим наш pipeline, то есть заново начнем.

Функция номер один –

Клота съедена, выбираем жертву.

Жертва – это функция номер два, так как она дольше всего ничего не делала.

Тушим, поднимаем, все замечательно, отрабатывает.

Кладем сообщение в очередь, по которому поджигается вторая функция.

То есть та функция, которую мы только что прибили.

Ну и так далее.

Каждая функция в такой ситуации попадает на холодный старт.

Это очень плохо.

Так что если вы что-то делаете подобное, у вас много функций, смотрите внимательно за метриками холодных стартов.

И если вы нарвались на такую ситуацию, придите к нам, попросите повышение квоты одновременно запущенных экземпляров.

Итак, мы заглянули во двор, а теперь давайте заглядывать в окно.

Заглядываем в окно, а там кухня.

Сейчас мы поговорим о внутренней кухне нашего сервиса.

В планировщике живет процесс автоскейлер.

Это фоновый процесс, он запускается раз в 500 миллисекунд или реже, если нагрузки нет.

Вообще, самая главная его задача – это заработать нам бабло.

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

Итак, как происходит масштабирование вверх?

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

Если длина очереди больше на 30%, то мы идем и создаем экземпляр функций прямо на этом же энжине.

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

Но это еще не все.

Мы смотрим еще в разрезе одной версии функций по всем нашим энжинам.

Смотрим сумму длин очередей, смотрим на количество экземпляров функций.

Если одно больше другого на 30%, то мы идем и создаем экземпляр на новом энжине.

Создалось, начинает разгребаться.

Итак, как мы создаем новый экземпляр?

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

Linux Kemo позволяет делать так, чтобы добавлять память прямо в работающую виртуальную машину.

То есть memory hotplug.

Добавляем 128 мегабайт, все хорошо.

512 падает.

Начали разбираться, оказалось, что внезапно для добавления новой памяти нужна память.

Помните, я говорил, что верталка стартует с 64 мегабайтами памяти, там лежит ядро, там лежит процесс Bootstrap, и на самом деле есть чуть-чуть вот еще какой-то памяти свободной.

которой хватает для разметки новой 128 мегабайт памяти, ее хватает, этой кусочкой свободной, а для 512 уже не хватает.

Что мы сделаем?

Мы добавляем память в два этапа.

Вначале 128 мегабайт, а потом столько, сколько вы попросили для функции.

То есть максимум у нас 4 гигабайта на функцию.

Итак, создали, смонтировали диск, добавили память.

Запускаем сервер с рантайм.

Ну, как мы запускаем?

В кему есть виртуальная консоль.

Ну, та же самая консоль, которую вы пишете, там, командочки, только виртуальные.

Замечательно.

Команды проходят, а вот выставление переменных окружений не проходит.

Оказалось, что это виртуальная консоль.

На самом деле консоль, у нее есть...

буфер в полтора килобайта, все, что как бы попадает в этот буфер, все выполняется.

Все, что как бы выше этого буфера обрезается без всяких ошибок, все плохо.

Ну, что мы делаем?

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

Следующая проблема, мы стартули...

Тартанули виртуалку, а она пролежала полдня в пуле.

В это время ничего с ней не происходило.

Часики не тикали, время отстало.

Поэтому перед выполнением вашей функции, перед каждым ее запуском, мы явно проставляем в виртуалке время текущее.

У нас, по-моему, три способа синхронизации этого времени есть.

Итак, мы проставили время, обрабатываем запрос, взяли задачу из очереди, передали вашей функции, она обработала этот запрос, вернула управление, и в этот момент мы отбираем у вашей виртуалки процессор, то есть ставим виртуалку на паузу.

Ну а после вообще планировщик, этот процесс автоскейлер может вообще решить, что виртуалка больше не нужна.

Итак, как мы даунскейлимся?

С одной стороны, мы бы и рады не тушить ваши экземпляры функции, но тогда мы съедим все наши ресурсы, нам это невыгодно.

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

Много холодных стартов нам тоже невыгодно с точки зрения ресурсов, да и вообще это нечестно.

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

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

Но вот насколько долго, это зависит.

Зависит от параметров самой функции.

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

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

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

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

А теперь хочу рассказать историю.

Вот смотрите, мы прооптимизировали виртуалку, чтобы она там быстро стартовала, быстро запускалась, все хорошо.

А может ли с этим быть проблема?

То есть может ли быть проблема от того, что виртуалка слишком быстро стартует?

На самом деле риторический вопрос, да, может.

Несколько лет назад мы рассматривали вот такой график.

Это график холодного старта одной и той же функции.

Она то инициализировалась за 40 миллисекунд, то инициализация занимала секунду и больше.

Начали раскапывать, оказалось, что во всем виновата линуксовая DNS-клиент.

То есть функция не живет в…

То есть прежде чем что-то выполнить, она куда-то идет.

Чтобы куда-то сходить, нужно знать, куда сходить.

То есть мы на старте отправляем DNS-запрос, если мы не получили на него ответа, то запускается механизм ретраев, и это время настраивается в линуксе, настраивается оно только в секундах, то есть минимум секунды.

Секунда для нас это вечность.

В общем, что сделали?

Мы пошли, подправили стандартную библиотеку Lipsy, и сейчас у нас, по-моему, 250 миллисекунд.

Ну, вообще, мы на самом деле нашли причину, почему так происходит, почему DNS запрос улетает, а обратно он не возвращается.

DNS отвечает все нормально, но...

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

То есть DNS-сервер просто не знает, куда отправить этот DNS-ответ.

В общем, это мы полечили с помощью переписывания

Сетевого стека на Engine.

Про сети я рассказывать не буду.

Если что, в дискуссионной зоне расскажу.

Там много всего страшного закопано.

Итак, мы сделали.

Мы все сделали.

Мы выбрали Engine, запустили виртуалку.

смонтировали туда функцию, добавили память и запускаем сервер с рантайм.

Давайте о сервере с рантайме и поговорим.

Это .NET процесс, живет в микровиртуалке.

С одной стороны он общается с engine, с другой стороны он общается с вашей функцией.

Ну, что он делает?

Он вначале загружает вашу DLL в память.

Он берет задачу из engine, из очереди.

Он десерилизует эту задачу, потому что

В Engine хранится JSON-документики.

Ну и в конце концов он вызывает вашу функцию, ваш function handler, дожидается, когда ваша функция вернет результат и отправляет результат в Engine и дальше.

Как мы получаем задачу из очереди?

На самом деле мы к Engine входим стандартным HTTP-клиентом, но по магическому адресу, IP-адресу.

Этот HTTP-клиент возвращает HTTP-респонс с JSON-чиком.

JSON-чик дестерилизуется с помощью System.txt.json.

Здесь может быть проблема.

Смотрите, что если ваша функция использует более новый System.txt.json, чем скомпилированный наш сервер с runtime.

В .NET есть механизм, есть класс AssemblyLoadContext, который позволяет загружать

Две версии одной и той же сборки.

Его мы как раз и используем.

То есть он еще позволяет выгружать сборки, так что если вы что-то плагиноподобное пишете, смотрите на него.

Так, мы десерилизовали.

Теперь нам нужно вызвать ваш метод.

Вызываем мы тоже очень просто.

Используем Dynamic.

На самом деле достойно работает.

Как и любой другой компонент нашей системы,

Serverless Runtime тоже борется со временем холодного старта.

Как мы боремся?

Мы скомпилировали Serverless Runtime с включенной опцией Ready-to-Run.

То есть мы при компиляции знаем, на какой операционной системе он будет запущен, какой процессор там будет жить.

То есть в момент компиляции

Компилятор в .NET не только генерирует и выкладывает в DLL-ку его, но еще и складывает машинный код.

Таким образом, на старте процесса нам не нужно запускать JIT-компиляцию, а это экономит нам время.

Смотрите, ваша функция работает в очень ограниченных ресурсах, то есть одно ядро, память ограничена.

В общем, это все очень напоминает запуск в ограниченном докер-контейнере.

Поэтому мы выставили переменную окружение .NET running in container, чтобы подсказать вашему коду, что вы работаете в таком ограниченном окружении.

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

Это стандартное переменное окружение, оно выставляется в базовых Microsoft Docker образах, именно поэтому на нее все и смотрят.

Теперь давайте поговорим про память.

В функциях используется Workstation GC.

Он, во-первых, меньше делает GC-паузы, потребляет меньше памяти, ну и в любом случае на однопроцессорной системе

доступен только в Workstation GC.

Серверный GC вы там не запустите.

Это пока так.

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

Поэтому все работает у нас максимально по умолчанию.

Но вы можете, зная ваш профиль нагрузки, чуть-чуть

настроить .NET Runtime.

Настройка возможна через его переменное окружение.

То есть можно включить-выключить Tired, Profile Guided Optimization, ну еще там много чего, но сервер на ГЦ вы так не выставите, потому что процессор один.

Если вам нужно, смотрите документацию Microsoft, там все аккуратно, подробно написано.

Но некоторые вещи настроить через переменное окружение нельзя.

Например, Threadpool.

Он настраивается в JSON-файлике, runtime.json, я не помню, как называется.

Поэтому мы работаем с настройками ThreadPool по умолчанию.

Ну, вообще, это на самом деле задача нам на будущее, чтобы позволить вам как-то настраивать ThreadPool или вообще его как-то подтюнить.

Из будущего еще мы...

Хотим как-то компилировать ваш код тоже с включенной опцией Reddit.run, чтобы он быстрее инициализировался, быстрее запускался.

Возможно, там ahead-of-time компиляция, если мы будем компилировать и серверов с runtime, и вашу функцию вместе.

Но это мы будем думать, решать, смотреть, как это все, потому что тут есть и плюсы, и минусы.

Ну и ноябрь не за горами.

В ноябре выходит следующий.

LTS-версия .NET, мы его тоже собираемся поддерживать.

Итак, мы заглянули в окно Cloud Functions, а теперь я вас зову войти к нам через дверь.

Если вы не пользовались серверами Cloud Functions, попробуйте.

Вообще в серверовом подходе есть что-то такое интересное.

У нас есть free tier, его должно хватить на pet проекты или на попробовать его точно хватит.

Ну а Яндекс.Алиса, навыки Яндекс.Алиса, они вообще бесплатные.

Ну а если вы пользовались уже, приходите поговорить, что понравилось, что не понравилось, и давайте мы сделаем этот сервис чуть-чуть лучше.

Ну и напоследок хочу показать вам слайд, с которого все начиналось.

Сейчас использование .NET это 5%.

Но мне бы очень хотелось, чтобы оно выросло.

Но это уже зависит от вас.

Теперь шаг за вами.

Спасибо большое.

Так, смотрите, на конференцию я приехал не один.

Я приехал с коллегой, ключевым разработчиком из...

Cloud Functions, и в дискуссионной зоне мы будем вместе отвечать на ваши вопросы.

Ну а сейчас я слушаю ваши вопросы.

Спикер 3

Спасибо.

Максим, спасибо тебе огромное.

Было безумно интересно, классно.

Еще хотела отметить, презентация просто бомба.

Так, ну что, друзья, вижу руки.

Прошу, пожалуйста.

Спикер 1

Здравствуйте, спасибо за доклад.

Вопрос такой, если вы так сильно чувствительны к задержкам, то почему JSON, а не протобав в очередях лежит?

Спикер 4

Мы думаем об этом.

Спикер 5

Спасибо.

Спикер 4

То есть вообще там много чего.

На самом деле GRPC у нас бегает внутри нашей системы, то есть вся коммуникация по GRPC, протобафу, но вот описание мы передаем в JSON.

Первая причина в том, что у нас не только .NET, а всякие питоны, джаваскрипты и прочие штуки, которые...

Нам бы хотелось, чтобы оно все единообразно бегало.

Ну а во-вторых, возможно, мы что-то тут поменяем, но когда, не могу сказать.

Спикер 1

Можно сразу второй вопрос?

Конечно.

Вот про контейнеры вы говорили, вместо микро-ВМ пытались использовать, а почему не получилось?

Спикер 4

Мы не пытались использовать, это только из-за безопасности.

Именно виртуализация гарантирует вам, что ваши данные, ваши вычисления не будут доступны из соседней функции.

Мы об этом очень сильно беспокоимся.

Наша security не позволяет нам использовать контейнер в этом случае.

Спикер 1

Спасибо огромное.

Спикер 3

Спасибо огромное.

Вот еще рука, пожалуйста.

Спикер 6

А как быть, если нужны какие-то данные из базы данных и хочется потом что-то сохранить?

Сохраняйте в базу данных.

А там как коннекшн поднимать?

Если чуть пониже опуститься?

Спикер 4

Если чуть пониже опуститься, то, во-первых, очень удобно использовать серверы с базой данных, которые не держат коннекшн.

Это раз.

Во-вторых, если… Ну, то есть целая проблема, когда TCP соединение рвется вдруг, если.

Но когда у нас запустили микровиртуалку, обработали запрос,

Все, запрос обработали, соединение сделали, оно есть, живет.

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

То есть если у вас виртуалка живет достаточно долгое время, то TCP-коннекшен не порвется.

Ну а если прибьет, то порвется.

Спикер 3

Спасибо огромное.

Есть ли у нас еще вопросы?

Вижу руку, пожалуйста.

Спикер 2

Спасибо за доклад.

У меня два вопроса.

Первый.

Вы сказали, что округляете в большую сторону время выполнения.

Кажется, это не совсем честно.

Спикер 4

Мы думали об этом.

Amazon, на самом деле, по-моему, округляет до десятков миллисекунд, но пока так.

А с другой стороны…

Даже не знаю, что ответить.

Мы измеряем, билим вашу функцию в так называемых гигабайт-часах.

То есть во фритире доступны 10 гигабайт-часов.

Это достаточно много.

И 100 миллисекунд, разница не такая большая в этих объемах.

Спикер 2

И второй вопрос.

Сегодня был доклад Анатолия Кулакова.

И там основной посыл был, что отказ от вендор локина и всего такого.

Как бы вы ответили хейтерам?

Спикер 4

Хейтерам я как бы вендор лок все-таки зову на нас залочиться.

Но на самом деле функция это код.

Код, который просто запускается.

Джейсончик на вход.

приняли, запустили вашу функцию.

В целом, если вдруг, да ничего не случится, но если вдруг вы захотите куда-то переехать, то ваш код обработки этого JSON, обработки этой модели останется.

То есть если у вас там какая-то сложная логика, то потребуется какое-то время на переезд, но что делать?

Не так, чтобы совсем критично.

Самая важная часть, то есть ваш код, ваша логика, она останется неизменной, возможно.

Спикер 5

Спасибо.

Вы сказали, что запускаете не в докере, а у вас готовая виртуалка, да?

Да, да, да.

А если мне нужно поставить специфический Linux-овый пакет, чтобы он заработал, есть какая-то возможность кастомизировать виртуалку?

Спикер 4

Смотрите, вот эти Cloud Functions, это вот сервис, он работает только так.

Но у нас на вот этой технологии все еще есть сервис Cloud...

Как?

Cloud Containers.

То есть это вы берете докер, любой докер, и запускаете.

Оно работает точно так же, как функция.

То есть пришел запрос, она вызвалась, потушилась.

Спикер 5

В отрыве от технологий.

Я могу любой.

Спикер 4

Да, практически любой.

Там даже чуточку немножко по-другому сделано.

Идея такая же.

Спикер 3

Так, есть ли у нас еще вопросы прямо сейчас?

Бежу руку, пожалуйста.

Спикер 6

Если у кого нет вопросов, я еще один задам.

Давай.

Как быть со всякими стартапами?

То есть когда мы пишем .NET приложение, у нас в стартапе много чего происходит.

Самое ценное, наверное, создание DI контейнера.

А вот с Cloud Function как-то…

Можно какую-то вот такую инфраструктуру поднимать?

Спикер 4

Смотрите, вообще у меня есть такая идея, чтобы сделать какой-то бридж между контейнером и, например, ISPNet-приложением.

То есть вы пишете стандартное ISPNet-приложение, как обычное приложение, но запускается оно в Cloud Functions.

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

Ну, DI-контейнер запускается все...

То есть надо запускать ручками в текущей ситуации.

Собрали контейнер, что-то там из него вытащили, как-то его поиспользовали.

Ничего специального нет.

Спикер 3

Так, есть ли у нас еще руки?

Если вы стесняетесь задавать вопросы здесь и сейчас, я знаю, что иногда так бывает, не понимаю, почему, я ничего никогда не стесняюсь.

Но я точно знаю, что так иногда случается.

Так вот, мы потом перейдем в дискуссионную зону, и там можно будет задать вопросы без микрофона, на которые Максим обязательно ответит.

Правда?

Спикер 4

Да, конечно.

Спасибо вам большое.

Спикер 3

Спасибо спикеру огромное, Максим.

Это было здорово.