- PVSM.RU - https://www.pvsm.ru -

Компьютеры на сегодня достигли таких скоростей, что могут записывать результат каждой нетривиальной задачи в постоянное хранилище. Это, в свою очередь, позволяет им прекрасно восстанавливаться после временного сбоя путём повторного выполнения по журналу всех завершённых задач до момента этого сбоя. Выполнив эти задачи, система спокойно продолжает работу с точки, где она была прервана. При достаточном внимании и осторожности такой механизм можно реализовать с минимальным влиянием на модель программирования или производительность, что, безусловно, очень ценно. Не так ли?
Однако реализация механизма устойчивого выполнения сопряжена с рядом непростых проблем, самой сложной из которых, как и во многих аспектах инфраструктуры, является безопасное обновление кода.
В типичном случае без механизма устойчивого выполнения обновление кода обычно не создаёт особых проблем. Если предположить, что реализована некая форма повторного воспроизведения, то может получиться так: запрос начинается в старой версии кода, прерывается посреди выполнения, а затем воспроизводится уже в обновлённой версии. На практике это не проблема, если оба обработчика идемпотентны (а для реализации воспроизведения они должны быть таковыми), получают одинаковые входные параметры и имеют плюс-минус совместимое поведение. Может сложиться ситуация, когда перемешается часть аспектов бизнес-логики старого и нового кода, но в большинстве случаев это не создаст серьёзных проблем.
А вот в контексте устойчивого выполнения даже лёгкие изменения в обработчике во время выполнения запроса могут привести к его сбою и необходимости ручного вмешательства. Рассмотрим пример, в котором мой обработчик является частью потока оформления заказа. В этом случае мне может потребоваться добавить в начало потока шаг вызова внешнего сервиса, чтобы проверить, не действует ли для продукта скидка. По логике я ожидаю, что активные запросы, которые уже прошли момент, в который был внедрён новый шаг, затронуты не будут. Но на деле получается иначе.
Любой активный запрос, который начался в старой версии кода и заново воспроизводится в новой, провалится или даже вызовет неопределённое поведение. Дело в том, что он будет воспроизводиться через шаг, когда должна выполняться проверка скидки, не обнаружит в своём журнале записи об этой операции и затруднится с выбором дальнейшего действия. Журнал в этом случае утрачивает согласованность с кодом.

Это проблема иммутабельности. Код, выполняющий определённый запрос, никогда не должен менять своё поведение, несмотря на возможность воспроизведения запросов спустя большой промежуток времени после их старта.
У каждой платформы с механизмом устойчивого выполнения есть своё решение этой проблемы. Разберём некоторые из них.
Устойчивые функции, по сути, представляют собой потребителей событий, которые развёртываются на платформе Azure Functions. Как правило, они не выполняют друг друга, а реализуют устойчивость для набора методов внутри одной развёрнутой функции. Код в этом случае является мутабельным, и воспроизведение всегда будет происходить с использованием последней версии функции.
Рекомендованной стратегией обновления является развёртывание новой версии кода параллельно со старой, чтобы активные запросы не видели обновления. Для реализации этого механизма предлагается два метода:
В Azure предложили правильное решение. Очень хорошо, когда есть возможность сделать так, чтобы активные запросы продолжили работать с той версией кода, с которой начали. Но у этого решения всё же есть недостаток. В идеале нам нужно, чтобы новые вызовы к заданной устойчивой функции автоматически использовали последнюю версию кода. Развёртывание новой устойчивой функции и обновление вызывающих является достаточно муторным процессом. Поэтому на практике некоторые могут предпочесть смириться с этой проблемой в случае небольших изменений и согласиться на периодические сбои.
Воркеры Temporal — это потребители событий, развёрнутые в выделенной инфраструктуре, например в виде контейнеров Kubernetes. В результате код получается мутабельным; вы можете просто развернуть новый образ контейнера.
За последние годы возникло несколько хороших способов обработки версионирования, но оптимальным на данный момент является версионирование воркеров [1]. В этой модели за воркером (который наверняка будет включать код множества рабочих потоков) необходимо закрепить ID сборки. В результате при получении работы он будет запрашивать только те повторные выполнения, которые уже были начаты в этой сборке. По умолчанию настраивается один ID сборки. В этом случае воркер также будет получать запросы, которые начинаются впервые.
Чтобы этот механизм работал, закреплённая за воркером сборка должна продолжать выполнение, пока все активные запросы в ней не завершатся. Если мы рассматриваем короткий рабочий поток, то это редко является проблемой, хотя всё равно требует внимания при удалении старых воркеров — а их в конечном итоге нужно удалять, так как в выделенной инфраструктуре их поддержание требует ресурсов.
А вот в случае длительных рабочих потоков это решение уже не подходит. Повторное воспроизведение может теоретически происходить через месяцы или даже годы после начала выполнения — это одна из наиболее интересных возможностей Temporal. В таких сценариях есть две проблемы, касающиеся длительного хранения кода.
Во-первых, затраты. Если мои запросы выполняются в течение месяца, и я вношу за этот месяц 5 прерывающих выполнение изменений, то мне потребуется запустить 5 воркеров параллельно. Во-вторых, есть вопрос безопасности и надёжности — а именно выполнение в инфраструктуре произвольно старого кода. Изменения могут не касаться бизнес-логики, но будут затрагивать параметры подключения базы данных или зависимости, имеющие уязвимости. Также могут вноситься изменения, которые потребуется применять к активным запросам. То есть должна присутствовать крайняя точка, когда мы сочтём код слишком старым для выполнения. И нам потребуется периодически бэкпортировать изменения в старые сборки, которые продолжают выполняться.
В случаях, когда версионирования воркеров недостаточно, Temporal предлагает API для патчинга рабочих потоков. С их помощью вы можете добавлять или удалять шаги, окружая их инструкциями if. Это позволит обеспечить, чтобы новые стадии выполнялись только для новых вызовов и никогда для воспроизведений, или, наоборот, чтобы при воспроизведении продолжали выполняться удалённые шаги. Такой гибкий механизм поможет вам разобраться со сложными случаями, но здесь нужно учитывать, что эти патчи будут накапливаться в коде, и их нужно удалять с особой осторожностью.
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 [2].
Автор: Дмитрий Брайт
Источник [3]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/azure/390399
Ссылки в тексте:
[1] версионирование воркеров: https://docs.temporal.io/workers#worker-versioning
[2] каналу Discord: https://discord.gg/p264c2sCtY
[3] Источник: https://habr.com/ru/companies/ruvds/articles/797137/?utm_source=habrahabr&utm_medium=rss&utm_campaign=797137
Нажмите здесь для печати.