Backdoor на проде или Future Flag

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

TL;DR

Личный сетап: самописный интерфейс FutureBean с методом toggle(boolean enable), переключающий внутреннюю переменную бина через RMI для любого своего потомка. Да, согласен, звучит сложновато, так что давайте рассмотрим подробней:

Да что такое этот ваш фича флаг?

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

Есть очень сложные варианты создание продуктов, когда в последствии его запаковывают и передают на сторону клиента. Путь его дальнейшего использование нам известен лишь приблизительно, и воссоздать прод на своей стороне, добиться той же картины, что и на клиентской части, практически не представляется возможным. Однако, в обоих случаях проектной и продуктовой разработки, чтобы уберечь заказчика, пользователей и, конечно, себя, стоит в процессе планирования архитектуры закладывать и создавать специальные “ручки” (рычаги). Эти ручки позволяют вам в любой момент времени управлять состоянием продукта или отдельно взятого сервиса, изменяя данные или порядок механизмов их обработки “на лету”. В результате мы получаем почти что API для админки нашего сервиса, которую в последствии как раз можно развить в консоль или даже отдельный веб-интерфейс. Сегодня поговорим об одном из таких рычагов — фича флаг — для включения, выключения и переключения дополнительного и нового функционала прямо на проде.

Ввводные

поскольку java — наиболее близкий для меня язык, все примеры будут касаться именно его, но большинство паттернов легко реализуемы и на других языках

  1. У нас есть два микросервиса
  2. Один — подключен к реляционной базе данных (postgres), в которую после обработки сохраняет аудит данные пользователей: кто изменял какие какие-то параметры, где, когда и по какой причине. В дальнейшем буду называть его Keeper — хранитель
  3. У него есть API, позволяющий запрашивать фильтрованные данные
  4. Второй сервис их аггрегирует, категоризирует, проводит операции кластеризации и нормализации для последующей визуалиции для аналитиков, менеджеров и других внутренних клиентов. Назовем незамысловато — Processor
  5. Проблема — данных так много, что 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 для переключения и управления трафиком с одного функционала на другой. Задача не тривиальна, но решаема достаточно быстро. В первую очередь определимся с шагами нашего переезда, сделаем план — самую важную часть проектирования любого функционала. Постараемся предположить все необходимые действия от текущей точки до “прекрасного сервиса будущего”:

  1. Решим, что выкатывать и включать две части нового функционала (сохранения и получения) мы будем по-отдельности: сначала начнем данные дублировать в новое место, проверив, что сохраняются они корректно, а уже после начнем их оттуда получать. Промежуточные версии сервиса называем snapshot. Например — 1.5.24-SNAPSHOT
  2. Данные у нас хранятся долго, поэтому предусмотрим механизм миграции — так как при включении метода сохранения, все новые данные у нас начнут сохранятся и в новое хранилище, все старье мы вытащим существующим механизмом получения информации из БД и переложим в Cassandra
  3. После запуска миграции, мы получаем идентичные данные в обоих хранилищах — время включать новый метод получения данных!
  4. После проверки корректности работы обоих методов, оставляем их “наблюдаться”
  5. По прошествии времени убеждаемся, что все работает ожидаемо — отличный момент, чтобы вырезать весь старый код, убрать механизм переключения и выкатить новую, stable версию

Что же, на бумаге план звучит не плохо, перейдем к реализации? К вопросу хранения состояний вернемся позже, сначала — его переключение

Switch

Представим, что у нас есть некая boolean переменная STORAGE_FUTURE, а механизм переключения сохранения данных у нас реализован примерно следующим образом:

class DataStorageSaver { public static volatile boolean STORAGE_FUTURE = false; private DataShardRepository repository; // старое сохранение в БД private CassandraShardTemplate template; // новое в Cassandra <...> public boolean saveData(Collection<DataShard> data) { for (var shard : data) { repository.save(shard); // сохранение в бд не вырезаем if (STORAGE_FUTURE) { // дублируем данные, если фича включена template.save(shard); log.info(”Also saved to Cassandra!”) } } } }

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

Теперь приложение может выбирать, сохранять ли данные как обычно, либо еще и дублировать их в новое хранилище. Состояние есть, но как им управлять? Как изменять конечное состояние нашей ручки? Вариантов как всегда несколько, но в общем случае нам нужен сервис, который будет или изменять флаг в конкретном классе (push способ), либо хранить данные о состоянии фича флагов, а уже сам класс будет ходить и запрашивать его изменения (pull метод). Выбор, как всегда, зависит от ситуации. Большое количество переключений, а может и другие операции над сервисами, помимо переключения флагов, порождает большое количество запросов к прокси-сервису. Поэтому если хотим получать результат мгновенно — push метод всегда в приоритете. Ну а я покажу оба:

📌 в самом конце покажу наиболее интересный AOP способ, чтобы использовать Spring на максимум

RMI ♻

Remote Method Invocation — некогда популярный способ межсервисного общения. Он не совсем подходит под наши цели — по RMI вызов происходит непосредственно из другого Java сервиса. Да и Spring его задепрекейтил уже давно (что не мешает его использовать в java-классах). Однако, если вы планируете делать разветвление фич, с возможностью через админку переключать реализации и механизмы вплоть до конкретных пользователей, создание отдельного сервиса и собственного RMI не такая уж плохая идея. Но это для игры в долгую, рассмотрим более быстрые и приземистые варианты

Pull

Представим, что некий FutureManager у нас уже есть — он хранит состояния наших переключателей, а по имени созданной фичи мы можем его получить

class DataStorageSaver { private static final String FUTURE_NAME = "DATA_STORAGE_FUTURE_TOGGLE"; private FutureManager manager; <...> public boolean saveData(Collection<DataShard> data) { for (var shard : data) { repository.save(shard); if (manager.get(FUTURE_NAME)) { // Каждый раз запрашиваем статус template.save(shard); log.info(”Also saved to Cassandra!”) } } } }

Проблема очевидна — нам приходится каждый раз обращаться к менеджеру состояний за актуальными данными, как-то их кешировать в самом менеджере, что всегда влечет за собой повышенные временные затраты. Однако, если кеш сделан грамотно, а запросов не много — такое решение имеет место быть.

Push

Более удачное решение – пуш метод, и для него FutureManager должен самостоятельно изменять состояние нашего класса с помощью, например, метода toggle(enable)

class DataStorageSaver { private static final AtomicBoolean STORAGE_FUTURE = new AtomicBoolean(false); <...> public boolean saveData(Collection<DataShard> data) { for (var shard : data) { repository.save(shard); // сохранение в бд не вырезаем if (STORAGE_FUTURE.get()) { // дублируем данные, если фича включена template.save(shard); log.info(”Also saved to Cassandra!”) } } } public boolean toggle(boolean enable) { STORAGE_FUTURE.set(enable); } }

В данном случае проблема нагрузки решена — менеджер в момент изменения флага из вне, сразу же изменяет его закешированную копию в классе.

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

FutureManager x FutureBean

Сделаем примерную реализацию FutureManager, и дополним наш контекст абстрактным классом FutureBean, от которого будут наследоваться уже остальные любые бины, в которых нам может потребоваться изменить флаг

// каждый наследуемый бин сразу получит метод toggle abstract class FutureBean { private final AtomicBoolean future = new AtomicBoolean(false); public boolean toggle(boolean enable) { future.set(enable); return future.get(); } } @Service @AllArgsConstructor class FutureManager { private Map<String, FutureBean> futures; public boolean toogle(String beanName, boolean enable) { if (!futures.containsKey(beanName)) { return false; } return futures.get(beanName) .toggleFuture(enable); } }

Spring решает проблему и зависимостей! Конструкция Map<String, FutureBean> futures говорит DI контейнеру, что в этот класс нужно внедрить все бины-наследники класса FutureBean, в качестве ключа будет имя внедренного бина. Теперь для переключения состояния в каком-либо классе, нужно лишь унаследовать его от FutureBean и дать осмысленное название в аннотации @Service(”meaningful-name”)

Database table

Статусы нужно где-то хранить! Иначе после перезапуска все ваши фича флаги и любые другие изменения в переменных пропадут. Отличный вариант — использовать уже существующее подключение к БД, создав табличку future_toggles с такой структурой:
id | name | enabled | enabled_date
в которую будем прописывать статус каждой отдельно взятой фичи. Сами флаги никуда не пропадут, а все что нужно дополнить в коде — лишь добавить загрузку всех флагов из БД в локальные состояния. Сделать это можно, например, так:

@Service @AllArgsConstructor class FutureManager { private Map<String, FutureBean> futures; private FutureStatesRepository repository; @PostConstruct public void init() { repository.findAll() .stream() .filter(f -> futures.containsKey(f.getName())) .forEach(f -> futures.get(f.getName()) .toggle(f.getEnable())); } }

Конечно, вариантов сохранений еще больше, чем вариантов переключений. Хранить в NoSQL, получать Kafka-events, сохранять, в конце-концов, в локальный файл и вычитывать его на старте. Как всегда — выбор за вами :)

❓Используете ли вы подобные фича-флаги в работе? Считаете ли, что приложение должно быть гибким и иметь такую возможность модернизации “на лету”, или в своем конечном состоянии продукт должен быть “монолитен”? Жду ваши комментарии и вопросы!

🗣 TELEGRAM CHANNEL — все новое появляется здесь

0
Комментарии
-3 комментариев
Раскрывать всегда