Backdoor на проде или Future Flag
Важно, что сама по себе фича «флаг» ничего в себе не несет, и зачастую только от уровня «проницательности» и компетентности непосредственно команды разработки вы получите полезный рычаг управления состоянием приложения, а не бэкдор. Сегодня мы рассмотрим пример такой ручки для изменения состояния новой функциональности в реальном времени прямо на проде. Однако подобный механизм можно широко и богато использовать для аудита, дополнительного контроля и управления жизненным циклом приложения в целом.
TL;DR
Личный сетап: самописный интерфейс FutureBean с методом toggle(boolean enable), переключающий внутреннюю переменную бина через RMI для любого своего потомка. Да, согласен, звучит сложновато, так что давайте рассмотрим подробней:
Да что такое этот ваш фича флаг?
Мир создания продукта настолько тернист, что как снежинки — практически не найдешь двух компаний с одинаковым алгоритмом добавления нового функционала в проект. Мы здесь не затрагиваем тему всяких Аджайлов и Ватерфолов — это лишь способ контроля реализации задач проекта; мы же возьмем более частный случай процесса передачи/выкатки/деплоя ее на продакшен.
Есть очень сложные варианты создание продуктов, когда в последствии его запаковывают и передают на сторону клиента. Путь его дальнейшего использование нам известен лишь приблизительно, и воссоздать прод на своей стороне, добиться той же картины, что и на клиентской части, практически не представляется возможным. Однако, в обоих случаях проектной и продуктовой разработки, чтобы уберечь заказчика, пользователей и, конечно, себя, стоит в процессе планирования архитектуры закладывать и создавать специальные “ручки” (рычаги). Эти ручки позволяют вам в любой момент времени управлять состоянием продукта или отдельно взятого сервиса, изменяя данные или порядок механизмов их обработки “на лету”. В результате мы получаем почти что API для админки нашего сервиса, которую в последствии как раз можно развить в консоль или даже отдельный веб-интерфейс. Сегодня поговорим об одном из таких рычагов — фича флаг — для включения, выключения и переключения дополнительного и нового функционала прямо на проде.
Ввводные
поскольку java — наиболее близкий для меня язык, все примеры будут касаться именно его, но большинство паттернов легко реализуемы и на других языках
- У нас есть два микросервиса
- Один — подключен к реляционной базе данных (postgres), в которую после обработки сохраняет аудит данные пользователей: кто изменял какие какие-то параметры, где, когда и по какой причине. В дальнейшем буду называть его Keeper — хранитель
- У него есть API, позволяющий запрашивать фильтрованные данные
- Второй сервис их аггрегирует, категоризирует, проводит операции кластеризации и нормализации для последующей визуалиции для аналитиков, менеджеров и других внутренних клиентов. Назовем незамысловато — Processor
- Проблема — данных так много, что Postgres уже не справляется, Keeper во всю тормозит как при отдаче, так и при сохранении данных
Решение на лицо — заменить PostgreSQL на что-то более отказоустойчивое для больших данных, с широкими возможностями в масштабировании.
Вопросов больше чем ответов, верно? Представим, что с хранилищем уже определились — это будет Cassandra. Но что делать с уже существующими данными в БД? Как осуществить бесшовный переезд, чтобы конечные пользователи этого не заметили? Будет ли downtime, что будет с данными, идущими в Keeper прямо сейчас, и с запросами к Processor’у?
Создать сервис или переделать существующий на новое хранилище — очень объемная задача с большим количеством подводных камней. Даже проведя всесторонне тестирование нельзя быть уверенным, что получится просто выкатить новую версию и не огрести проблем. Балансировщики отчасти решают проблему, но выводят сервис из “игры” целиком. А если мы хотим, чтобы он продолжил функционировать, быстро откатив лишь конкретный функционал к той версии, что была и стабильно работала до этого?
Архитектура
Для начала, нам нужно изменить подход к разработке фичей. Точнее, изменить его для больших и влияющих на трафик фич.
💡 Сохранить старое
Самое важное — по минимуму трогать текущий и работающий функционал, при этом изолировав его. Звучит очевидно, но на деле мало кто применяет. Фактически, нам нужно в рамках существующего Keeper сервиса, основываясь и реализуя его интерфейсы, создать новый persistent-слой — слой хранения данных в Cassandra, изменяя существующий контракт по минимуму. Данный подход потребует от вас дополнительных абстракций и для каждого конкретного продукта/проекта/микросервиса может быть принципиально иным; в нашем (и подавляющем большинстве) случае — должна быть легкая и тонкая “прослойка”, между функционалом и вызывающей его частью. Говоря проще (или абстрактней) — любой функционал так или иначе сводится к общему интерфейсу для его вызова и работы с ним. Иначе такие сервисы называют черными ящиками — мы знаем, какую информацию он принимает и отдает, каких контрактов обработки придерживается, однако, конкретика механизмов и алгоритмов анализа и преобразования полученных данных для нас невидима, а самое главное — не важна в принципе. За счет такой прослойки, четких контрактов и качественных интерфейсов, мы можем менять их реализации настолько гибко, насколько мы ограничены рамками методов наших интерфейсов.
Итак, весь наш Keeper сервис сводится к двум методам:
- /api/v1/save + data — сохранить данные аудита
- /api/v1/data?id=1w12dsa&userId=7314&dataFilter=’table user_info’ — получить данные, отфильтровав их по параметрам
В итоге для каждого из методов мы получаем черный ящик:
вызвали input метод → получили output ответ
и каждый из этих методов мы можем проксировать, направляя поток трафика, при необходимости переводя его со старого функционала на новый и обратно.
Все что нам теперь нужно — создать API для переключения и управления трафиком с одного функционала на другой. Задача не тривиальна, но решаема достаточно быстро. В первую очередь определимся с шагами нашего переезда, сделаем план — самую важную часть проектирования любого функционала. Постараемся предположить все необходимые действия от текущей точки до “прекрасного сервиса будущего”:
- Решим, что выкатывать и включать две части нового функционала (сохранения и получения) мы будем по-отдельности: сначала начнем данные дублировать в новое место, проверив, что сохраняются они корректно, а уже после начнем их оттуда получать. Промежуточные версии сервиса называем snapshot. Например — 1.5.24-SNAPSHOT
- Данные у нас хранятся долго, поэтому предусмотрим механизм миграции — так как при включении метода сохранения, все новые данные у нас начнут сохранятся и в новое хранилище, все старье мы вытащим существующим механизмом получения информации из БД и переложим в Cassandra
- После запуска миграции, мы получаем идентичные данные в обоих хранилищах — время включать новый метод получения данных!
- После проверки корректности работы обоих методов, оставляем их “наблюдаться”
- По прошествии времени убеждаемся, что все работает ожидаемо — отличный момент, чтобы вырезать весь старый код, убрать механизм переключения и выкатить новую, stable версию
Что же, на бумаге план звучит не плохо, перейдем к реализации? К вопросу хранения состояний вернемся позже, сначала — его переключение
Switch
Представим, что у нас есть некая boolean переменная STORAGE_FUTURE, а механизм переключения сохранения данных у нас реализован примерно следующим образом:
внутри template.save обрабатываем возможные ошибки от Cassandra, чтобы случайно не выстрелить себе в ногу (на этот случай мы сначала выполняем старую логику, а уже после вызываем новую) и вперед — такую версию можно смело деплоить на прод, никаких проблем она не принесет
Теперь приложение может выбирать, сохранять ли данные как обычно, либо еще и дублировать их в новое хранилище. Состояние есть, но как им управлять? Как изменять конечное состояние нашей ручки? Вариантов как всегда несколько, но в общем случае нам нужен сервис, который будет или изменять флаг в конкретном классе (push способ), либо хранить данные о состоянии фича флагов, а уже сам класс будет ходить и запрашивать его изменения (pull метод). Выбор, как всегда, зависит от ситуации. Большое количество переключений, а может и другие операции над сервисами, помимо переключения флагов, порождает большое количество запросов к прокси-сервису. Поэтому если хотим получать результат мгновенно — push метод всегда в приоритете. Ну а я покажу оба:
📌 в самом конце покажу наиболее интересный AOP способ, чтобы использовать Spring на максимум
RMI ♻
Remote Method Invocation — некогда популярный способ межсервисного общения. Он не совсем подходит под наши цели — по RMI вызов происходит непосредственно из другого Java сервиса. Да и Spring его задепрекейтил уже давно (что не мешает его использовать в java-классах). Однако, если вы планируете делать разветвление фич, с возможностью через админку переключать реализации и механизмы вплоть до конкретных пользователей, создание отдельного сервиса и собственного RMI не такая уж плохая идея. Но это для игры в долгую, рассмотрим более быстрые и приземистые варианты
Pull
Представим, что некий FutureManager у нас уже есть — он хранит состояния наших переключателей, а по имени созданной фичи мы можем его получить
Проблема очевидна — нам приходится каждый раз обращаться к менеджеру состояний за актуальными данными, как-то их кешировать в самом менеджере, что всегда влечет за собой повышенные временные затраты. Однако, если кеш сделан грамотно, а запросов не много — такое решение имеет место быть.
Push
Более удачное решение – пуш метод, и для него FutureManager должен самостоятельно изменять состояние нашего класса с помощью, например, метода toggle(enable)
В данном случае проблема нагрузки решена — менеджер в момент изменения флага из вне, сразу же изменяет его закешированную копию в классе.
Но что, если таких классов у нас очень много? Неужели менеджер теперь должен хранить в себе все классы с изменяемыми состояниями, чтобы в нужный момент их вызвать? Spring может помочь нам в решении этой насущной проблемы.
FutureManager x FutureBean
Сделаем примерную реализацию FutureManager, и дополним наш контекст абстрактным классом FutureBean, от которого будут наследоваться уже остальные любые бины, в которых нам может потребоваться изменить флаг
Spring решает проблему и зависимостей! Конструкция Map<String, FutureBean> futures говорит DI контейнеру, что в этот класс нужно внедрить все бины-наследники класса FutureBean, в качестве ключа будет имя внедренного бина. Теперь для переключения состояния в каком-либо классе, нужно лишь унаследовать его от FutureBean и дать осмысленное название в аннотации @Service(”meaningful-name”)
Database table
Статусы нужно где-то хранить! Иначе после перезапуска все ваши фича флаги и любые другие изменения в переменных пропадут. Отличный вариант — использовать уже существующее подключение к БД, создав табличку future_toggles с такой структурой:
id | name | enabled | enabled_date
в которую будем прописывать статус каждой отдельно взятой фичи. Сами флаги никуда не пропадут, а все что нужно дополнить в коде — лишь добавить загрузку всех флагов из БД в локальные состояния. Сделать это можно, например, так:
Конечно, вариантов сохранений еще больше, чем вариантов переключений. Хранить в NoSQL, получать Kafka-events, сохранять, в конце-концов, в локальный файл и вычитывать его на старте. Как всегда — выбор за вами :)
❓Используете ли вы подобные фича-флаги в работе? Считаете ли, что приложение должно быть гибким и иметь такую возможность модернизации “на лету”, или в своем конечном состоянии продукт должен быть “монолитен”? Жду ваши комментарии и вопросы!
🗣 TELEGRAM CHANNEL — все новое появляется здесь