Привет! Я познакомился с Yandex Cloud Functions, когда готовил доклад на DotNext 2024. В процессе обнаружил особенности платформы. О них я и расскажу.

В статье объясню специфику Яндекс Функций. Покажу свой эксперимент с запуском приложений, которые опубликованы разными способами — в том числе с использованием AOT-компиляции. А в конце — сравнение результатов и выводы.

Немного про облачные функции

FaaS или функция как услуга — это один из компонентов бессерверных вычислений. Работает так: приложение лежит на сервере, а платформа запускает его по событию. Обычно событием становится http-запрос, сообщение в очереди, срабатывает триггер по времени и тому подобное. Основной плюс технологии — не нужно держать хост с постоянно запущенным серверным приложением. При вызове функции платформа сама поднимет необходимое окружение, скомпилирует и выполнит загруженный код или запустит приложение. Есть много реализаций FaaS-шаблона, но чаще используют эти: AWS Lambda, Microsoft Azure Functions и Google Cloud Functions.

Как работают Yandex Cloud Functions

Яндекс предлагает собственную реализацию FaaS под оригинальным названием Google Yandex Cloud Functions. В октябре 2024 г. из коробки поддерживаются разные среды выполнения, в том числе .NET 8.

Начало работы с платформой и тест функции «Hello, world»

Чтобы начать пользоваться облачной платформой Яндекса, нужно зарегистрировать платёжный аккаунт в Yandex Cloud Billing и привязать к нему карту. Новым пользователям Яндекс дает приветственный бонус — грант на использование сервисов. Мне этого с головой хватило на эксперименты.

Для запуска функций понадобится сервисный аккаунт. Чтобы создать его, нужно в консоли нажать «Сервисные аккаунты» → «Создать сервисный аккаунт». Затем прописать любое имя и обязательно дать роль functions.functionInvoker.

Теперь можно создавать функции — пусть будет приложение «Hello, world». В разделе Cloud Functions: «Создать функцию» → ввести имя → «Создать». Откроется окно выбора среды выполнения — нажать .NET и «Продолжить».

Окно выбора среды выполнения
Окно выбора среды выполнения

Когда я ставил эксперимент, выбранная галка добавления файлов с примерами кода не подгрузила ничего для среды .NET. Вроде сейчас поправили, но можно создать свой файл: вкладка «Редактор» → «Создать файл» и назвать его Handler.cs.

Эффект опции "Добавить файлы с примерами кода"
Эффект опции «Добавить файлы с примерами кода»

Теперь можно добавить содержимое файла. В поле «Точка входа» нужно внести MyTestApp.Handler, выбрать созданный ранее сервисный аккаунт и дефолтную сеть. Остальное можно не менять, а просто сохранить.

namespace MyTestApp;

public class Handler
{
  public string FunctionHandler(string arg) => $"Hello, {arg}!";
}

Проверить работу функции можно на вкладке «Тестирование». В поле «Входные данные» ввести произвольный текст и нажать «Запустить тест». В ответе функции должен появиться текст: Hello, <введённый текст>.

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

Для функции «Hello, world» разница между запусками с холодным стартом и подготовленным уже существенна. Время выполнения будет таким:
• холодный старт ~150 мс
• подготовленный экземпляр ~1 мс

А что Яндекс называет подготовленным экземпляром функции?

Может, под капотом всё .NET-приложение каждый раз запускается заново и проходит JIT-компиляцию? Тогда время работы 1 мс — это какое-то чудо.

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

Чтобы найти ответ, провожу эксперимент. Создаю новую функцию с кодом:

namespace StatelessTest;

public class Handler
{
    private static int _counter = 0;
    public string FunctionHandler(int arg)
    {
        _counter += arg;
        return $"Counter: {_counter}";
    }
}

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

Во вкладке «Тестирование» ввожу «1» в поле «Входные данные» и несколько раз запускаю тест. В ответе видно, как значение Counter начинает постепенно увеличиваться.

Тест функции со статическим полем. Значение Counter растет
Тест функции со статическим полем. Значение Counter растет

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

А точно ли экземпляр класса Handler создаётся заново для каждого вызова функции?

Продолжая эксперимент, я убрал модификатор static у поля _counter и протестировал функцию. В ответе значение Counter начинает постепенно увеличиваться!

Тест функции с экземплярным полем. Значение Counter тоже растет
Тест функции с экземплярным полем. Значение Counter тоже растет

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

Когда система работает не так, как ожидаешь
Когда система работает не так, как ожидаешь

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

Другой вариант: просто не использовать предоставляемую среду выполнения .NET, а навертеть свою, с преферансом и куртизанками без сохранения состояния.

Подготовка приложения

Задача: найти способ запускать .NET-приложение так, чтобы оно честно стартовало заново при каждом вызове функции. И желательно, чтобы каждый раз при запуске не происходила JIT-компиляция — это занимает время.

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

С помощью self-contained можно запускать приложение, не требующее .NET-окружения. Это описано в официальной документации. Ещё можно настроить публикацию в один файл для удобства и добавить тримминг, чтобы уменьшить размер. В итоге должно получиться консольное HelloWorld-приложение с такими настройками в .csproj-файле:

<PropertyGroup>
    <TargetFramework>net8.0</TargetFramework>
    <PublishSingleFile>true</PublishSingleFile>
    <SelfContained>true</SelfContained>
    <RuntimeIdentifier>linux-x64</RuntimeIdentifier>
    <PublishTrimmed>true</PublishTrimmed>
</PropertyGroup>

Такое приложение можно запускать на Linux x64 без дополнительной установки .NET — и это то, что надо! Кстати, конфигурацию SelfContained=true можно не добавлять — она здесь для наглядности. Этот флаг установится автоматически после указания идентификатора среды.

В итоге задача с .NET-окружением в среде выполнения решена. Хотя такое приложение всё ещё использует JIT-компиляцию и при частых вызовах функции будет сильно проигрывать по скорости запуска приложениям на .NET от Яндекс. Но и это решается — с помощью Native AOT-публикации. Для этого .csproj-файл приложения должен иметь строки:

<PropertyGroup>
    <PublishAot>true</PublishAot>
    <RuntimeIdentifier>linux-x64</RuntimeIdentifier>
</PropertyGroup>

Так можно полностью отказаться от JIT-компиляции и использовать только AOT-компиляцию. Тогда весь код приложения перед запуском будет переведён в машинные команды, а во время выполнения никакой дополнительной компиляции не будет. Это сократит время старта приложения.

Но есть и минусы: публикация Native AOT фактически наследует как фичи, так и ограничения self-contained публикации в один файл с триммингом. Это придётся учитывать при разработке или адаптации приложения.

Итак, с приложением понятно. Осталось разобраться, как его запускать.

Запуск приложения в Yandex Cloud Functions

Я решил пойти по самому очевидному для меня пути:

  • Локально подготовить приложение, не требующее окружения .NET

  • Залить в какое-то пространство, доступное функции

  • Вызвать это приложение из функции

  • Profit

На просторах интернета я не нашёл никаких следов того, что кто-то пытался запускать в Яндекс Функциях .NET-приложения без использования соответствующей среды. Тем более Native AOT-приложения. Так что пришлось стать первопроходцем в этом вопросе.

Сначала нужно научиться вызывать любые исполняемые файлы внутри Яндекс Функций. Для работы я выбрал среду выполнения Bash / 22.04 — это единственная среда, не подразумевающая использования какого-то конкретного языка программирования.

Неуспешные попытки и странные ответы поддержки

Первое, что я попробовал, — использовать Yandex Object Storage. Это хранилище помогает создавать бакеты и загружать в них файлы. Так как функции позволяют работать с файлами из бакета, идея выглядела перспективной.

Смонтировал бакет, прописал для него сетевой путь. Добавил скрипт из одной строчки — собственно, вызова исполняемого файла из бакета. Запустил функцию и получил ошибку 126 с невнятным описанием: BashScriptExecutionError. 

В логах можно было найти что-то про Permission denied. Я предположил, что проблема в неправильно настроенных правах — либо в бакете, либо у сервисного аккаунта, а может и там, и там. Но многочасовые попытки подобрать работающую комбинацию выданных ролей ни к чему не привели. 

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

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

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

Здравствуйте! Ошибка Permission deniedвозникает вследствие того, что запускать исполняемые файлы напрямую из смонтированного хранилища нельзя. Чтобы запустить исполняемый файл, его нужно скопировать из смонтированного хранилища в любое место в файловой системе, в котором есть возможность запуска исполняемых файлов и так же важно проверить есть ли необходимые права на его выполнение. Пример такого места, которое будет доступно для вызова из Cloud Functions -/tmp

Ну вот. А я с бакетом и настройкой ролей бился — сразу бы сказали. /tmp доступен в функциях без монтирования чего-либо. Дорабатываю скрипт, чтобы он перекладывал файл из бакета в /tmp,и пробую запустить. Опять получаю Permission denied, опять манипуляции с ролями ничего не дают. Снова пишу в поддержку, приходит ответ:

Обратите внимание, что исполняемые файлы не получится запустить в каталоге /tmp, так как в нем установлен флаг NOEXEC, который не позволяет выполнение файлов. Вместо этого рекомендуем использовать, например, рабочую директорию (/function/code) для запуска исполняемых файлов.

Ох уж эти увлекательные переписки с поддержкой
Ох уж эти увлекательные переписки с поддержкой

Рабочей оказалась такая схема:

  • Нужно взять исполняемый файл <filename>, прописать ему права на выполнение командой chmod +x <filename>

  • Создать скрипт <scriptname>.sh из одной строки: ./<filename>

  • Упаковать оба файла в архив командой zip <archivename> <filename> <scriptname>.sh

  • Загрузить получившийся архив в функцию через ZIP-Архив

  • Profit

Сравнение результатов

Я сравнил результаты запусков HelloWorld-приложения для среды .NET Яндекса и Native AOT-приложения в среде Bash / 22.04. Для наглядности ещё добавил к сравнению результаты запуска self-contained single file-приложения с триммингом в среде Bash / 22.04. Среднее время — всегда за 100 запусков, а поставщик метрик — Яндекс Функции.

.NET окружение

Native AOT

Self-contained

Среднее время выполнения при холодном старте

216.6 ms

28.82 ms

100.8 ms

Среднее время выполнения подготовленного экземпляра

1.2 ms

3.62 ms

51.52 ms

Размер приложения

-

1 540 KB

13 106 KB

Выделенная память

87 MB

43 MB

58 MB

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

Как я научил Яндекс гуглить

Написал приложение, которое принимает на вход строку, затем передаёт её в google search, из списка результатов берёт первые 10 и возвращает пользователю список из объектов с заголовками и ссылками на страницу.

Запросы в Гугл я отправлял с помощью https://github.com/vivet/GoogleApi. Для работы с Native AOT его пришлось немного доработать: поднять версию .NET до восьмой и добавить кастомный JsonSerializerContext для работы сериализации. В итоге всё взлетело. Такое приложение имеет сериализацию, десериализацию, отправляет http-запрос и обрабатывает ответ. В общем, делает какую-то работу, которая вполне походит на реальную задачу. Метрики собирал те же, что и для HelloWorld-приложения, и вот что получилось:

.NET окружение

Native AOT

Self-contained

Среднее время выполнения при холодном старте

1016.2 ms

583.52 ms

1 420.56 ms

Среднее время выполнения подготовленного экземпляра

354.1 ms

486.87 ms

1 343.15 ms

Размер приложения

-

9 840 KB

15 835 KB

Выделенная память

123 MB

74 MB

91 MB

По замерам выходит, что:

  • Приложение Self-contained решает проблему возможного смешения информации из разных запросов, но по всем характеристикам проигрывает Native AOT-приложению. Потому выбывает из гонки.

  • Среда выполнения .NET от Яндекс Функций показывает лучшее время выполнения для подготовленного экземпляра, но долго отрабатывает при холодном старте из-за JIT-компиляции.

  • Приложение Native AOT показывает лучшее время выполнения при холодном старте, но несколько проигрывает среде выполнения .NET для подготовленного экземпляра.

Что получается

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

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

Выбор остаётся за вами. Благодарю за прочтение!

Комментарии (8)


  1. AgentFire
    31.10.2024 20:47

    Уважаемый автор, нижайшепрошу прощения, но мне кажется, что вы проиграли пошли не туда ещё на этапе "Подготовка приложения".

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

    Видите ли, ее можно решить написав небольшую обёртку с DI (мы же хотим красиво), поставив лок против race condition, и все красиво запаковать в минимальное количество абстракций.

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


    1. m3ta10ph0b Автор
      31.10.2024 20:47

      Наверняка можно и так, но проблему долгого запуска при холодном старте это всё равно не решит.
      А так - да, тоже одно из решений. В целом я уверен, что есть и другие решения, которые можно применить. Я описал одно из них. В чём проигрыш - не понял, ну ладно, Вам виднее.
      По поводу "мы же хотим красиво" - не знаю, как по мне так было бы красиво, если бы Яндекс Функции позволяли не писать такой костыль, как Вы предложили, а поддерживали бы stateless из коробки.


    1. Voland69
      31.10.2024 20:47

      Решить конечно можно, но чтобы решить надо знать, в то время как ASP .NET нас приучил, что оно stateless из коробки и каждый запрос выполняется в своем изолированном scope и общие моменты надо явно проговаривать как singleton, а тут получается наоборот и это контринтуитивно.


      1. m3ta10ph0b Автор
        31.10.2024 20:47

        Всё именно так, и потому и приходится изобретать всякое)


    1. m3ta10ph0b Автор
      31.10.2024 20:47

      Признаться, яннп, как должно выглядеть ваше решение и причём тут race condition. Но если вы пришлёте работающий Hello, world — это будет действительно полезный комментарий. Уверен, многие скажут за него спасибо. Да и я тогда в статью ваше решение добавлю. С указанием авторства, разумеется. Тем более раз обёртка небольшая - не должно занять много времени


      1. AgentFire
        31.10.2024 20:47

        Учитывая ваш текст из статьи, описываемый вами .NET хост просто подключает вашу библиотеку к себе в аппдомен и держит его там, дёргая ваши методы через рефлексию (или кодген). Благодаря этому наблюдению у меня сложилась картина, как можно это использовать без какой-либо боли, описанной вами в статье (но я мог что-то пропустить, так что жду обратной связи)

        Вот микропример того, что я имел ввиду: https://pastebin.com/bG6DyJxn

        Что такой подход даёт:

        1. App.Run можно вызывать из любых потоков, в т.ч. из нескольких одновременно.

        2. В BuildContainer инициализируете ваш DI, этот код будет запущен лишь один раз на весь хостовый процесс/аппдомен.

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

        Как использовать: рисуете свой хендлер, внутри оборачиваете свой бизнес-код в делегат:

        public class Handler
        {
        public string FunctionHandler(int arg)
        {
        return App.Run(provider =>
        {
        // Здесь бизнес-код, все сервисы доступны через provider.
        return $"Hello world_{arg}";
        });
        }
        }


  1. xFFFF
    31.10.2024 20:47

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


    1. m3ta10ph0b Автор
      31.10.2024 20:47

      Ценное замечание, спасибо!
      Действительно, как минимум в некоторых других популярных решениях с .NET похожая ситуация. По крайней мере так было раньше. Я осознанно не включал это в разбор, чтобы не сводить дискуссию до уровня "Если сосед обосрался, то и мне можно". Цель публикации — подсветить конкретные проблемы с .NET на конкретной платформе. Если мои решения помогут при работе с другими платформами — так даже лучше.

      А вообще было бы интересно посмотреть на сравнительный анализ запуска .NET-приложений в разных сервисах, согласен.