Эволюция монолитного приложения, еще один подход
В IT индустрии есть одна достаточно часто встречающаяся не простая проблема. Это старые монолитные приложения, которые приносят их текущим владельцам много денег прямо сейчас. Обычно эти приложения экстенсивно развивались долгие годы или даже десятки лет и достигли предела экстенсивного развития, когда даже далекие от техники люди в бизнесе понимают, что дальше так жить нельзя.
Выбросить такой монолит невозможно, бизнес остановится. Переписать тоже либо нельзя, либо очень дорого, по причинам хорошо описанным еще у Фредерика Брукса в Мифическом человеко-месяце. Во первых как они там под капотом работают в точности, по прошествии 20+ лет уже никто не знает. Во вторых, пока приложение переписывается, а обычно это 1–2 года, бизнес успевает уйти вперед и новое приложение надо докатывать до актуального состояния и так без конца.
На данный момент в таких случаях обычно используются два возможных подхода. Либо сделать новый продукт, с похожим, но все же новым функционалом. Либо распилить монолит на несколько достаточно небольших сервисов, а уже их постепенно переписывать.
Несколько лет назад к нам пришел заказчик с нераспиливаемым монолитом и необходимостью реализовать ряд новых нефункциональных требований как можно скорее. В статье я хочу рассказать, как мы с такой ситуаций справлялись.
Дано
Итак на входе у нас было монолитное приложение на Java 8, первый комит сделан в 1998 году. Приложение состоит из двух частей, одна часть web front end сделанный на JSP, вторая часть — сервер который отвечает за исполнение некоторых бизнес процессов. Обе части живут в одном репозитории и там есть хитрый процесс сборки который в зависимости от параметров собирает тот или иной сервис.
Забавная история получилась когда я собеседовал начинающего Java разработчика в команду. Узнав детали проекта он сказал, что не готов работать с кодовой базой которая была создана до его рождения
Бизнес процесс в виде весьма «лохматого» дерева объектов в общей сложности на сотни полей, целиком сидит в памяти. Он может исполняться до двух недель. В процессе исполнения он требует посылки множества сообщений пользователям и обработки ответов от них. Про бизнес заказчика я подробно рассказать не могу , часть этой истории все еще под соглашением о неразглашении. Но без конкретики, как мне кажется, воспринимать статью будет несколько сложно, приведу пример несколько видоизмененного сценария:
В следующую пятницу в первой половине дня нужна бригада из 3-х электриков в город Бридж Рок
Сообщение отправляется группе из 12 электриков первой очереди.
В течении суток надо получить не менее 3-х ответов,
Если есть три ответа, послать фамилии ответивших менеджеру
Если меньше трех, послать на группу эскалации второй очереди — там уже 50 электриков и ждать еще сутки
Если все еще меньше трех ответов то послать сообщение старшему менеджеру.
При этом для каждого человека есть отдельная логика посылки, по каким каналом, в какое время суток слать. Разных каналов в общем сложности 12 штук, включая всевозможную экзотику, например Blackberry, факс или TDD телефоны (https://en.wikipedia.org/wiki/Telecommunications_device_for_the_deaf), также надо в ряде случаев делать повторные посылки и т.п.
Схематически все это выглядело примерно вот так:
Процесс инициируется и дальше в течении какого-то времени, от часов до недель взаимодействует с пользователями. Сам процесс живет в памяти и сразу скажу, там все устроено достаточно сложно, чтобы простое решение — сделать все stateless не прошло. Собственно это было первое, что пришло мне в голову, но посмотрев на код и обсудив варианты с архитектором этого приложения мы поняли, что этот вариант не подходит.
Распилить на сервисы к сожалению тоже не получилось в силу очень высокой связанности кода и данных в приложении. Там по факту связанно все со всем, зачастую неявным образом. Активно используется паттерн Бизнес Объект — это классы которые инкапсулируют в себе данные, бизнес логику обработки этих данных и частично какую-то часть нефункциональных требований.
Отступим на шаг назад, что именно хотел достичь заказчик и почему ему надо было сделать изменения достаточно срочно? Как часто бывает при таких запросах, необходимость меняться пришла в результате поглощения одной большой компанией другой компании поменьше. Большая компания купила мелкую, владеющую отличным сервисом, приносящим высокую прибыль. После поглощения большая компания более пристально заглянула под капот и удивилась (обычно такие вещи надо выявлять в ходе аудита, но в данном случае что-то пошло не так). Говоря конкретно, срочно надо было решить следующие проблемы
Необходимо было иметь возможность выкатывать новые версии без остановки всего приложения на несколько часов. На момент, когда меня позвали помогать, редеплой требовал остановки backend сервиса, что требовало разрушения всех работающих на тот момент в нем процессов. Поэтому редеплои были редки и происходили воскресными ночами, что впрочем не решало всех проблем.
Backend сервер и так уже работал на достаточно мощной машине и вопрос масштабирования стоял очень остро. Новые владельцы были готовы привести много новых пользователей, но система не была готова их принять.
В принципе для решения проблемы №2 есть стандартный подход — single tenant service. Т.е. разделение всех пользователей на группы и построение под каждую группу отдельной инфраструктуры. Но у такого подхода есть проблемы
Он ведет к значительному росту расходов на серверную инфраструктуру в том числе и по причине того что вокруг всего этого достаточно много громоздкой вспомогательной инфраструктуры которая без переделок с разными тенантами работать не умеет.
Все равно не решает проблему с редеплоем без остановки сервиса.
Поясню почему так важно было сделать возможность редеплоя без остановки сервиса. До покупки, основной массе пользователей была важна доступность в рабочие часы, отправка чего либо в выходные была очень и очень редким случаем. Плюс основными клиентами были компании мелкого и среднего размера, им как правило не так важен SLA, они готовы подстроиться и смириться с периодическим выключением системы в рабочие часы. Новый владелец был готов привести много больших (и несколько очень больших) клиентов для который доступность 24×7 критически важна и которые любят требовать формальный SLA.
Короче, хороших вариантов решения не было, самый лучший план который был на момент начала работ у заказчика — собрать большую команду и за полтора-два года все переписать. Но поскольку в Стэнфорде Мифический Человеко-месяц входит в учебную программу, архитектор этой системы понимал, что с переписыванием не все так просто.
Как решали проблему
Я предложил заказчику решать проблему следующим образом. Вместо распила одного большого и страшного монолита на несколько сервисов (что увы было невозможно по вышеизложенным причинам), было предложено запустить несколько больших и страшных монолитов, каждый на своем железе и посадить каждый из монолитов в виртуальную клетку так, чтобы он думал, что он работает в одиночестве и клетка изолировала его от работы других монолитов. К счастью по базе данных там конфликтов не было и переделывать внутреннюю логику работы необходимости не было.
Основные проблемы при таком подходе были с обработкой ответов от пользователей. Когда пользователь отвечал на сообщение, надо было маршрутизировать ответ к тому экземпляру бизнес процесса который сообщение отправлял. Для маршрутизации сообщений и заодно реализации механизма back pressure было решено использовать Rabbit MQ. Если перерисовать картинку выше для одного канала доставки. скажем для email — то идея, в том виде в каком она была нарисована во время мозгового штурма на доске с Сан Диего (у заказчика там офис), выглядела примерно вот так:
В результате двух дней обсуждений мы выписали на доске все «входы и выходы» имеющиеся в монолите и для каждого из придумали как
Сделать так, чтобы монолит думал, что он один во вселенной и может свободно слать сообщения и получать ответы на них
Как по ответу на сообщение понять какому экземпляру монолита он предназначен.
Такое решение давало нам ответ на вопрос как запустить еще один новый экземпляр сервиса, но не давало ответ на вопрос — как остановить работающий сервис без потерь.
Остановка без потерь была сопряжена с двумя проблемами
Не понятно, как заморозить состояние процессов и потом его разморозить уже в новом экземпляре
Как не потерять ответы которые сервис получает, пока он заморожен.
Вторая проблема решалась маршрутизаторами и очередями в RabbitMQ. Ответы складывались в очереди и после разморозки состояния оттуда спокойно обрабатывались.
Проблема заморозки была самой серьезной и в общем виде не решалась никак. К счастью совместно с автором системы нам удалось придумать последовательность из примерно 20 шагов которая позволяла заморозить состояние и сохранить его в БД. Сервис от этого stateless к сожалению не стал т.к. процесс заморозки занимал где-то от 3 до 10 минут и включал в себя крайне не очевидные шаги вида «остановить вот эту нить и подождать пару минут пока вон тот буфер очистится». Собственно отладка этой последовательности была пожалуй самым сложным местом во всем проекте.
Если просуммировать все сделанные изменения, то в результате мы пришли к следующему
Три разных канала подачи команд были переделаны на использование RabbitMQ
Для каждого из каналов получения ответов от пользователей был сделан маршрутизатор, учитывающий специфику канала и умеющий маршрутизировать сообщения для конкретной среды передачи.
Мы научили сервис трюку который называется graceful shutdown
Мы научились «замороженное» состояние которое осталось после остановившегося сервиса делить на кусочки и «раздавать» по частям другим сервисам.
Также была сделана специальная логика обработки ответов которые пришли сервису находящемуся в процессе остановки. Т.е. мы обрабатывали ситуацию, когда ответ маршрутизируется экземпляру сервиса который в момент получения ответ начал останавливаться, но маршрутизатор об этом пока еще не знает. Вообще в этом месте возникла достаточно неожиданная сложность. В идеальной картине мира в целом хватало механизма Dead Letter Exchange. Если экземпляр сервиса не забрал ответ в течении минуты, то он видимо уже погашен и надо провести повторную машрутизацию. Когда сервисы тушатся и поднимаются ровно один за одним, этого хватало. Но если на проде начиналась неразбериха, несколько экземпляров одновременно тушились, потом поднимались опять тушились не дожидаясь завершения нормальной последовательности старта, то все начинало работать несколько криво. Нам пришлось сделать специальный механизм который позволял экземплярам сервиса договариваться между собой, кому же принадлежит тот этот иной процесс.
Реализация всех эти задач заняла у нас примерно два с половиной месяца работы команды из 6-и человек. После этого мы получили версию сервиса который мог редеплоиться без остановки при этом одновременно работающие на стенде несколько экземпляров сервиса позволяли горизонтально масштабировать нагрузку.
Далее перед нами встала следующая проблема. Откуда мы знаем, что такой тонкий процесс как остановка сервиса и ребалансировка работающих там процессов будет всегда работать бесшовно? Чтобы узнать ответ на этот вопрос был сделан специальный продукт «Test toolkit» симулирующий для основного сервиса одновременно входы и выходы и считающий все посланные сообщения. Схематически выглядело это так:
Генератор сценариев запускал несколько десятков процессов разных типов в секунду, генератор ответов симулировал случайные ответы пользователей, а верификатор проверял, что ничего не потерялось и при это каждый 15 мин перезапускал один из экземпляров сервиса.
Тулкит помог нам найти еще 4 edge case сценария и еще один застарелый баг с отправкой, в самом приложении сидевший в коде где-то с 2015 года. После того как система проработала около недели под тестовой нагрузкой отрабатывая последовательность рестарта каждые 15 минут без потери сообщений, мы были готовы выкатываться на прод (после чего мы еще долго убеждали продопсов, что все должно работать нормально и проводили тренинги по редеплою).
Выводы
Основная идея на которую я хотел обратить внимание в этой статье, это еще один способ эволюции монолитных приложений. Не переписывание и не распил на части, а изменение поверхностного слоя интерфейсов с другими системами при неизменном или почти неизменном ядре. Это позволят адаптировать монолит к новым не функциональным требованиям не меняя основную логику, которая как раз обычно сложнее всего поддается изменениям.
Способ этот относительно трудозатратный и получившийся результат требует тщательной верификации. Применять этот способ пожалуй стоит только в очень специальных случаях, например когда у нас есть большое сложное состояние сервера в памяти.
P.S.
За кадром осталась масса мелких технических деталей, таких как например кастомный control protocol на базе постоянно открытого TCP соединения которое нельзя закрывать пока есть хотя бы один работающий бизнес процесс (т.е. по факту никогда). Или пул быстрых распределенных семафоров с возможность передавать лок от одного сервера другому. Но об этом как-нибудь в другой раз
Громадное спасибо команде. Коллеги — это был реально очень крутой, сложный проект. Мы вместе справились. Спасибо!