[Перевод] Макропроблема микросервисов
Всего за 20 лет разработка ПО перешла от архитектурных монолитов с единой базой данных и централизованным состоянием к микросервисам, где всё распределено по многочисленным контейнерам, серверам, ЦОДам и даже континентам. Распределённость упрощает масштабирование, но привносит и совершенно новые проблемы, многие из которых раньше решались с помощью монолитов.
Давайте с помощью краткого экскурса по истории сетевых приложений разберёмся, как мы пришли к сегодняшней ситуации. А затем поговорим о модели исполнения с сохранением состояния (stateful execution model), используемую в Temporal, и о том, как она решает проблемы сервис-ориентированных архитектур (service-oriented architectures, SOA). Я могу быть предвзятым, потому что руковожу продуктовым отделом в Temporal, но считаю, что за этим подходом будущее.
Короткий исторический урок
Двадцать лет назад разработчики почти всегда создавали монолитные приложения. Это простая и согласованная модель, аналогичная тому, как вы программируете в локальном окружении. По своей природе монолиты зависят от единой базы данных, то есть все состояния централизованы. В рамках одной транзакции монолит может менять любое своё состояние, то есть это даёт двоичный результат: сработало или нет. Для несогласованности нет места. То есть монолит прекрасен тем, что из-за сбойной транзакции не возникнет несогласованного состояния. И это означает, что разработчикам не нужно писать код, всё время гадая о состоянии разных элементов.
Долгое время монолиты имели смысл. Ещё не было множества подключённых пользователей, поэтому требования к масштабированию ПО были минимальны. Даже крупнейшие программные гиганты оперировали ничтожными по современным меркам системами. Лишь горстка компаний вроде Amazon и Google использовала «масштабные» решения, но то были исключения из правила.
Люди как ПО
В последние 20 лет требования к ПО постоянно растут. Сегодня приложения должны с первого же дня работать на глобальном рынке. Компании вроде Twitter и Facebook превратили онлайн-режим 24/7 в необходимое условие. Приложения больше не обеспечивают работу чего-либо, они сами превратились в пользовательский опыт. Сегодня у каждой компании должны быть программные продукты. «Надёжность» и «доступность» уже не свойства, а требования.
К сожалению, монолиты стали разваливаться, когда в требования добавились «масштабирование» и «доступность». Разработчикам и бизнесу потребовалось найти способы идти в ногу со стремительным глобальным ростом и требовательными ожиданиями пользователей. Пришлось искать альтернативные архитектуры, уменьшающие возникающие проблемы, связанные с масштабированием.
Ответом стали микросервисы (ну, сервис-ориентированные архитектуры). Изначально они представлялись прекрасным решением, потому что позволяли дробить приложения на относительно самодостаточные модули, которые можно независимо масштабировать. И поскольку каждый микросервис поддерживал своё собственное состояние, приложения больше не ограничивались вместимостью одной машины! Наконец-то разработчики могли создавать программы, удовлетворяющие требованиям по масштабированию в условиях растущего количества подключений. Также микросервисы дали командам и компаниям гибкость в работе за счёт прозрачности в ответственности и разделении архитектур.
Бесплатного сыра не бывает
Хотя микросервисы решили проблемы масштабирования и доступности, мешавшие росту ПО, не всё было безоблачно. Разработчики начали понимать, что у микросервисов есть серьёзные недостатки.
В монолитах обычно одна база данных и один сервер приложений. И поскольку монолит нельзя разделить, есть лишь два пути масштабирования:
- Вертикально: обновив оборудование для повышения пропускной способности или ёмкости. Такое масштабирование может быть эффективным, но получается дорого. И это точно не решит проблему навсегда, если вашему приложению нужно продолжать расти. А если достаточно расшириться, то в конце концов не хватит оборудования для апгрейда.
- Горизонтально: создавая копии монолита, каждая из которых обслуживает определённую группу пользователей или запросов. Такое масштабирование приводит к недоиспользованию ресурсов, а при достаточно больших размерах вообще перестаёт работать.
С микросервисами всё иначе, их ценность заключается в возможности иметь множество «типов» баз данных, очередей и прочих служб, которые масштабируются и управляются независимо друг от друга. Однако первая проблему, которую начали замечать при переходе на микросервисы, как раз заключалась в том, что теперь приходится заботиться о куче всевозможных серверов и баз данных.
Долгое время всё было пущено на самотёк, разработчики и эксплуатанты выкручивались самостоятельно. Трудно решать проблемы управления инфраструктурой, возникающие из-за микросервисов, в лучшем случае снижается надёжность приложений.
Однако на спрос возникает предложение. Чем шире распространялись микросервисы, тем больше росла мотивация разработчиков к решению инфраструктурных проблем. Медленно, но уверенно начали появляться инструменты, и пробел заполнили технологии вроде Docker, Kubernetes и AWS Lambda. Они очень сильно облегчили эксплуатацию микросервисной архитектуры. Вместо того, чтобы писать свой код для оркестрирования контейнерами и ресурсами, разработчики могут опираться на уже готовые инструменты. В 2020-м мы наконец-то достигли рубежа, когда доступность нашей инфраструктуры больше не мешает надёжности наших приложений. Прекрасно!
Конечно, мы ещё не живём в утопии идеально стабильного ПО. Инфраструктура больше не является источником ненадёжности приложений, это место занял код приложений.
Другая проблема микросервисов
В монолитах разработчики пишут код, который меняет состояния двоичным способом: либо что-то происходит, либо нет. А с микросервисами состояние распределяется по разным серверам. Чтобы изменить состояние приложения, нужно одновременно обновить несколько баз данных. Возникает вероятность, что одна БД успешно обновится, а другие упадут, оставив вас с несогласованным промежуточным состоянием. Но поскольку сервисы были единственным решением задачи горизонтального масштабирования, иного варианта у разработчиков не было.
Фундаментальной проблемой состояния, распределённого по сервисам, является то, что каждое обращение ко внешнему сервису будет иметь случайный результат с точки зрения доступности. Конечно, разработчики могут игнорировать проблему в коде и считать каждое обращение к внешней зависимости всегда успешным. Но тогда какая-нибудь зависимость может без предупреждения положить приложение. Поэтому разработчикам пришлось адаптировать свой код из эры монолитов к добавлению проверок на сбойность операций посреди транзакций. Ниже показано постоянное получение последнего записанного состояния из специального myDB-хранилища, чтобы избежать состояния гонки. К сожалению, даже такая реализация не спасает. Если состояние аккаунта меняется без обновления myDB, может возникнуть несогласованность.
public void transferWithoutTemporal(
String fromId,
String toId,
String referenceId,
double amount,
) {
boolean withdrawDonePreviously = myDB.getWithdrawState(referenceId);
if (!withdrawDonePreviously) {
account.withdraw(fromAccountId, referenceId, amount);
myDB.setWithdrawn(referenceId);
}
boolean depositDonePreviously = myDB.getDepositState(referenceId);
if (!depositDonePreviously) {
account.deposit(toAccountId, referenceId, amount);
myDB.setDeposited(referenceId);
}
}
Увы, невозможно написать код без ошибок. И чем сложнее код, тем вероятнее появление багов. Как вы могли ожидать, код работающий с «промежуточным», не только сложный, но и запутанный. Хоть какая-то надёжность лучше её отсутствия, так что разработчикам пришлось писать такой изначально забагованный код, чтобы поддерживать пользовательский опыт. Это стоит нам времени и сил, а работодателям — кучу денег. Хотя микросервисы прекрасно масштабируются, за это приходится платить удовольствием и продуктивностью разработчиков, а также надёжностью приложений.
Миллионы разработчиков каждый день тратят время на переизобретение одного из самых переизобретённых колёс — надёжности шаблонного кода. Современные подходы к работе с микросервисами просто не отражают требований к надёжности и масштабируемости современных приложений.
Temporal
Теперь мы добрались до нашего решения. Оно не одобрено Stack Overflow, и мы не утверждаем, что оно идеально. Мы лишь хотим поделиться своими идеями и услышать ваше мнение. А разве есть более подходящее место для получения обратной связи по улучшению кода, чем Stack?
До сегодняшнего дня не было решения, позволяющего использовать микросервисы без расхлёбывания вышеописанных проблем. Вы можете тестировать и эмулировать сбойные состояния, писать код с учётом падений, но эти проблемы всё-равно возникают. Мы считаем, что Temporal их решает. Это open-source (MIT, без дураков) stateful-среда для оркестрации микросервисов.
У Temporal два основных компонента: stateful-бэкенд, работающий на выбранной вами БД, и клиентский фреймворк на одном из поддерживаемых языков. Приложения создаются с помощью клиентского фреймворка и обычного старого кода, который автоматически сохраняет изменения состояния в бэкенде по мере исполнения. Вы можете использовать те же зависимости, библиотеки и цепочки сборки, что и при создании любого другого приложения. Честно говоря, бэкенд сильно распределён, так что это не как с J2EE 2.0. По сути, именно распределённость бэкенда обеспечивает почти бесконечное горизонтальное масштабирование. Temporal обеспечивает для уровня приложений согласованность, простоту и надёжность, как это сделали для инфраструктуры Docker, Kubernetes и бессерверная архитектура.
Temporal предоставляет ряд высоконадёжных механизмов для оркестрирования микросервисами. Но самое важное — сохранение состояния. Эта функция использует порождение событий для автоматического сохранения любых stateful-изменений в работающем приложении. То есть если падает компьютер, на котором выполняется Temporal, код автоматически перейдёт на другой компьютер, словно ничего не произошло. Это касается даже локальных переменных, потоков исполнения и прочих характерных для приложения состояний.
Приведу аналогию. Как разработчик, наверняка сегодня вы полагаетесь на версионирование SVN (это OG Git) для отслеживания сделанных вами в коде изменений. SVN просто сохраняет новые файлы, а затем ссылается на существующие файлы, чтобы избежать дублирования. Temporal — что-то вроде SVN (грубая аналогия) для stateful-истории работающих приложений. Когда ваш код меняет состояние приложения, Temporal автоматически безошибочно сохраняет это изменение (не результат). То есть Temporal не только восстанавливает упавшее приложение, он ещё и откатывает его назад, форкает и делает многое другое. Так что разработчикам больше не нужно создавать приложения с оглядкой на то, что сервер может упасть.
Это похоже на переход от ручного сохранения документов (Ctrl+S) после каждого введённого символа к автоматическому облачному сохранению Google Docs. Не в том смысле, что вы больше ничего не сохраняете вручную, просто больше нет какой-то одной машины, связанной с этим документом. Сохранение состояния означает, что разработчики могут писать намного меньше скучного шаблонного кода, который приходилось писать из-за микросервисов. Кроме того, больше не нужна специальная инфраструктура — отдельные очереди, кеши и базы данных. Это упрощает эксплуатацию и добавление новых фич. А также сильно облегчает ввод новичков в курс дела, потому что им не нужно разбираться в запутанном и специфичном коде управления состояниями.
Сохранение состояния реализуется и в виде «устойчивых таймеров». Это отказоустойчивый механизм, которым можно пользоваться с помощью команды Workflow.sleep
. Она работает точно так же, как нативная языковая команда sleep
. Однако с Workflow.sleep
можно безопасно усыплять на любой промежуток времени. Многие пользователи Temporal используют усыпление на недели, и даже годы. Это достигается с помощью хранения длительных таймеров в хранилище Temporal и отслеживания кода, который нужно разбудить. Повторюсь, даже если сервер упадёт (или вы его просто выключили), код перейдёт на доступную машину по срабатыванию таймера. Процессы сна не потребляют ресурсов, вы можете иметь их миллионы при ничтожных накладных расходах. Возможно, звучит слишком абстрактно, так что вот пример рабочего Temporal-кода:
public class SubscriptionWorkflowImpl implements SubscriptionWorkflow {
private final SubscriptionActivities activities =
Workflow.newActivityStub(SubscriptionActivities.class);
public void execute(String customerId) {
activities.onboardToFreeTrial(customerId);
try {
Workflow.sleep(Duration.ofDays(180));
activities.upgradeFromTrialToPaid(customerId);
while (true) {
Workflow.sleep(Duration.ofDays(30));
activities.chargeMonthlyFee(customerId);
}
} catch (CancellationException e) {
activities.processSubscriptionCancellation(customerId);
}
}
}
Помимо сохранения состояния Temporal предлагает набор механизмов для создания надёжных приложений. Функции активностей вызываются из рабочих процессов, но код, работающий внутри активности, не является stateful. Хотя они и не сохраняют своё состояние, активности содержат автоматические повторы, таймауты и heartbeat«ы. Активности очень полезны для инкапсулирования кода, который может сбоить. Допустим, ваше приложение использует банковский API, который часто недоступен. В случае с традиционным ПО вам понадобится обернуть весь код, вызывающий этот API, выражениями try/catch, логикой повторов и таймаутами. Но если вызывать банковский API из активности, то все эти функции предоставляются из коробки: если вызов сбоит, активность будет автоматически повторена. Всё это прекрасно, но иногда вы сами являетесь владельцем ненадёжного сервиса и хотите защитить его от DDoS. Поэтому вызовы активностей также поддерживают таймауты, подкреплённые длительными таймерами. То есть паузы между повторами активностей могут достигать часов, дней или недель. Это особенно удобно для кода, который должен отработать успешно, но вы не уверены, насколько быстро это должно произойти.
В этом видео за две минуты объясняется модель программирования в Temporal:
Другой сильной стороной Temporal является наблюдаемость работающего приложения. API наблюдения предоставляет SQL-подобный интерфейс для запроса метаданных из любого рабочего процесса (исполняемого или нет). Также можно определять и обновлять свои значения метаданных прямо внутри процесса. API наблюдения очень удобен для Temporal-операторов и разработчиков, особенно при отладке в ходе разработки. Наблюдение поддерживает даже пакетные действия с результатами запросов. Например, можно отправить kill-сигнал во все рабочие процессы, соответствующие запросу со временем создания > вчера. Temporal поддерживает функцию синхронного извлечения, позволяющую вытаскивать значения локальных переменных из работающих экземпляров. Это словно отладчик из вашего IDE поработал с production-приложениями. Например, так можно получить значение greeting
в работающем экземпляре:
public static class GreetingWorkflowImpl implements GreetingWorkflow {
private String greeting;
@Override
public void createGreeting(String name) {
greeting = "Hello " + name + "!";
Workflow.sleep(Duration.ofSeconds(2));
greeting = "Bye " + name + "!";
}
@Override
public String queryGreeting() {
return greeting;
}
}
Заключение
Микросервисы — вещь замечательная, это покупается ценой продуктивности и надёжности, которую платят разработчики и бизнес. Temporal создан для решения этой проблемы за счёт предоставления окружения, которое платит микросервисам за разработчиков. Предоставляемые из коробки сохранение состояния, автоматические повторы при сбоях и наблюдение — лишь часть возможностей Temporal, которые делают разумной разработку микросервисов.