Как стать автором
Обновить
2431.85
RUVDS.com
VDS/VPS-хостинг. Скидка 15% по коду HABR15

Иммутабельность в механизме Durable Execution: проблемы и решение

Уровень сложностиСредний
Время на прочтение7 мин
Количество просмотров2.3K
Автор оригинала: Jack Kleeman

За последние годы мы наблюдаем всплеск разработки инструментов и платформ, обеспечивающих Durable Execution (устойчивое выполнение). Немного поясню его принцип.

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

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

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

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

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



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

Передовые решения


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

▍ Azure Durable Functions


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

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

  1. Копирование и вставка изменённых методов всего рабочего потока в качестве новых методов в том же самом артефакте (в Functions App они называются «функциями») и обновление вызывающих на использование для запросов этих новых методов. Однако для обновлений также потребуется развёртывать новые версии, а значит проделывать это нужно будет рекурсивно, пока цепочка вызовов не приведёт к точке входа, в связи с чем такой подход не рекомендуется.
  2. Не обновлять существующий деплой, а сделать полностью новый для всего пакета кода и обновить устойчивые функции, вызывающие API, на использование этого нового деплоя. В таком случае активные вызовы продолжат выполняться относительно старого кода.

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

▍ Temporal


Воркеры Temporal — это потребители событий, развёрнутые в выделенной инфраструктуре, например в виде контейнеров Kubernetes. В результате код получается мутабельным; вы можете просто развернуть новый образ контейнера.

За последние годы возникло несколько хороших способов обработки версионирования, но оптимальным на данный момент является версионирование воркеров. В этой модели за воркером (который наверняка будет включать код множества рабочих потоков) необходимо закрепить ID сборки. В результате при получении работы он будет запрашивать только те повторные выполнения, которые уже были начаты в этой сборке. По умолчанию настраивается один ID сборки. В этом случае воркер также будет получать запросы, которые начинаются впервые.

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

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

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

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

▍ AWS Step Functions


AWS Step Functions описываются в виде JSON с помощью языка рабочих потоков под названием ASL (Amazon States Language). И хотя здесь вам придётся писать код в виде лямбда-функций, устойчивое выполнение распространяется только на стадии внутри определения рабочего потока. И это определение полностью иммутабельно — обновления создают новую версию, которая используется для выполнения новых рабочих потоков, но активные процессы всегда используют ту версию, с которой начали выполняться. Это полностью решает проблемы. Сохранение старых версий ничего не стоит; в конце концов, сохраняется всего один файл.

Природа рабочих потоков Step Functions такова, что при сохранении старых версий вам редко нужно беспокоиться в случае применения патчей или изменения инфраструктуры. Вся суть заключается в лямбдах, которые вызывает рабочий поток, и которые не являются объектом устойчивого выполнения, а значит не имеют проблем версионирования, помимо стандартного отслеживания запросов/ответов.

▍ Наше решение


Создавая Restate, мы хотели совместить всё лучшее из этих подходов. Step Functions пока что обеспечивают оптимальный пользовательский опыт — здесь, благодаря иммутабельным рабочим потокам, вам не нужно думать о версионировании, но при этом вы вынуждены писать эти потоки на ASL.

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



В Restate «рабочие потоки» больше походят на стандартный код, нежели на рабочие потоки. Говоря конкретнее, они представлены в виде обработчиков RPC. Здесь нет никаких потребителей событий. Среда выполнения всегда отправляет запросы к вашим сервисам, которые могут выполняться как долгоживущие контейнеры или лямбда-функции. Вам лишь нужно зарегистрировать конечную точку HTTP или Lambda с помощью Restate, который будет определять, какие сервисы на ней выполнять, создавать новую версию этих сервисов и начинать использовать эту версию для новых запросов.

В качестве побочного эффекта использования лямбда-функций мы получаем иммутабельность кода. Опубликованные версии лямбда-функций являются иммутабельными — любое обновление кода или конфигурации ведёт к развёртыванию новой версии. При этом версии можно сохранять бессрочно и вызывать старые точно так же, как новые — без дополнительных затрат. За счёт интеграции механизма абстрагирования версий с помощью Lambda мы можем обеспечить тот же опыт работы, что и Step Functions. Активные запросы всегда будут выполняться относительно того кода, с которого начались, а новые будут использовать последнюю его версию.

Тем не менее очень длительные обработчики по-прежнему представляют проблему. Несмотря на то, что у лямбда-функций обычно немного зависимостей, помимо AWS SDK, всё равно могут потребоваться патчи безопасности, и инфраструктура может измениться таким образом, что старые версии лямбда-функций станут нефункциональны. Более того, если мы ограничим продолжительность наших запросов, скажем, часом, то получим проблему с другими видами деплоев, в частности с контейнерами Kubernetes. Нам нужна возможность сохранять старые версии кода в течение часа. Самый простой способ реализовать это — развернуть оба контейнера в одном поде Kubernetes, обслуживая их на разных портах или по разным путям. Со временем мы планируем предоставить операторы и инструменты непрерывной интеграции, которые упростят этот процесс.

Тем не менее остаётся сложность с обработчиками, выполняющимися по несколько недель. Возможно, нам следует задаться вопросом: «Почему люди вообще пишут такой код?» И эту тему мы разберём во второй части статьи. Ну а пока, если вы как раз пишете код подобным образом и хотите обсудить это, присоединяйтесь к нашему каналу Discord.

Telegram-канал со скидками, розыгрышами призов и новостями IT ?
Теги:
Хабы:
Всего голосов 29: ↑28 и ↓1+40
Комментарии0

Публикации

Информация

Сайт
ruvds.com
Дата регистрации
Дата основания
Численность
11–30 человек
Местоположение
Россия
Представитель
ruvds