Аудит-логи на базе Афины

84371ad2ac52c7318d93185c9e91a031.jpg

Логирование бывает разным. Часто в проектах можно встретить следующие виды логов:

  •  Системные об ошибках и исключениях;

  •  Авторизационные о попытках входа;

  •  Почтовые о работе, например, SMTP сервера;

  •  Логи доступа к базе данных;

  •  Аудит-логи о действиях пользователя.

Наша история будет про аудит-логирование. Я попробую рассказать её так, чтобы вы не уснули со скуки, и добавлю интересные вставки по реализации. Цель — дать возможность разобраться в архитектуре и причинах выбора именно такого подхода к решению проблемы.

Когда нужны аудит-логи?

Однажды у одного из клиентов нашей системы возникла проблема. Ему нужно было отследить изменение конкретного заказа, который был закрыт уже достаточно давно. Само собой, в системе использовалось программное удаление (soft delete), т.е. по факту ничего не удалялось. Нужный заказ был поднят из базы, он хранил в себе даты создания и закрытия, статус и десятки других значений… Но этого оказалось мало. Кто, как и когда менял заказ было неизвестно.

805df3d1ba44aaf16d19fc0e5db021e0.jpg

Так у нас появилась первая задача, связанная с аудитом. Необходимо было сделать так, чтобы вся информация о работе с заказом, каким-то образом хранилась в базе. 

Как мы добавляли аудит-логи?

Добавили сущность Workflow.

В нашем понимании Workflow — это жизненный путь заказа, от момента его создания и до закрытия. Эта сущность в БД обросла небольшой обвязкой, из которой хотелось бы выделить такую сущность как WorkflowTransition. Из названия становится понятно, что WorkflowTransition хранит информацию о переходе из одного состояния в другое. Если быть точным, то в нем хранится два состояния, одно — до, второе — после изменения статуса. Хранятся эти состояния в сериализованном виде, в формате JSON (дальше вы поймете почему). Теперь каждое редактирование заказа приводило к тому, что система сохраняла достаточное количество информации для того, чтобы мы знали кто, когда и как изменял заказ. 

Ура, заработало! Но это был только первый шажок.

Добавили поле Type.

Зачем мы выделили сущность Workflow? Почему нельзя было сразу создать сущность типа OrderTransition и ссылаться напрямую на заказ?

Ответ — универсальность.

Если добавить в Workflow поле Type, то сразу становится понятным, что теперь мы можем не ограничиваться логированием только заказов, а можем логировать абсолютно любую сущность. Ведь снэпшоты сущностей хранятся в текстовом виде и это снимает с нас много ограничений, но, буду честен, накладывает и определенные трудности.

Нужно больше логов…

Наши заказчики стали еще счастливее! Мы дали им инструмент для быстрого поиска корня их бизнес проблем — страничку с аудит логами. Но прошло время… Заказы копились, росло и количество клиентов. Наша «нерезиновая» БД плакала и распухала. 

4f9a3c212cb218ca2757166d6c1b790c.jpg

По сути, мы не очень рационально использовали ресурсы, храня информацию, которая, в большинстве случаев, оставалась невостребованой. Но жертвовать было нечем — требовалось хранить всё, хранить всегда и, иногда, ещё и искать в этом безобразии нужные записи.

И тут нам на помощь пришли облака. Сейчас вы могли подумать, что это же очевидно, скучно и не очень то и удобно… Но поспешу вас заверить, что это не совсем так, а скорее наоборот. Это оказалось мега-удобно и даже экономически выгоднее! И так, что же мы сделали?  

Миграция в облако.

Мы добавили в систему самописный сервис — он каждый день искал старые аудит-логи и мигрировал их в S3-хранилище. Старыми считались логи старше шести месяцев. Пользователи, как раньше, могли искать нужные записи аудита в привычном интерфейсе. А те логи, которые перебрасывались в S3 на долгое хранение, немного преобразовывались, архивировались и записывались в определенном формате по бакетам, которые создавались отдельно для каждого клиента. Безопасная безопасность! :)

Можно подумать, что это всё банально и скучно. Что теперь делать с этими архивами? Искать руками? Писать парсилку? Зачем нужно преобразовывать и соблюдать какой-то формат?

Да всё просто! Тут нам на помощь приходит  ̶б̶о̶г̶и̶н̶я̶ ̶м̶у̶д̶р̶о̶с̶т̶и̶  Афина, а если точнее, то аналитический сервис Athena от Амазон. А все вышеуказанные преобразования нужны для того, чтобы наши логи хранились в форматах «открытых таблиц и файлов». 

Объясню проще. Если мы правильно сложим наши данные, то Афина сама их распарсит и, более того, даст нам возможность искать записи при помощи SQL-подобных запросов, используя в запросах как имена папок, так и содержимое сжатых JSON файлов. А раз у нас будет возможность писать запросы для поиска логов на SQL, так почему бы не сделать так, чтобы наши клиенты могли сами искать нужную информацию, не задумываясь, где она хранится, в обычной продовской базе или в архивах облачных сервисов?  

И мы это сделали, написав обертку над API Афины в виде кастомного IQueryable. 

Хочу обратить ваше внимание на то, что в обычной базе у нас хранятся аудит логи за полгода. Это покрывает 99% потребностей клиентов. Остальные данные за несколько лет хранятся в достаточно дешевом сторадже, а обращения к Афине тарифицируются по запросам, коих буквально единицы. Но чтобы еще больше сэкономить, было решено уведомить клиентов о том, что они по умолчанию ищут информацию за последние 6 месяцев, но если вдруг нужно, клиент может чекнуть галочку «хочу искать за весь доступный период», и тогда подключается поиск через API Афины. 

Само собой, Афина поддерживает и множественные условия, и сортировку результатов. Это почти полноценный привычный SQL со своими нюансами, поэтому нам не составило большого труда объединить результаты из базы и AWS, сохранив функционал фильтрации и сортировки.

В итоге мы получили такую архитектуру наших аудит-логов:

531810759350cb347db0b0a163252382.png

Чего мы добились:

1. Система обзавелась аудит логами для любых сущностей.

2. Логи хранят детальную информацию: кто, что, как и когда менял.

3. Быстрый и простой доступ к последним логам.

4. Продовская база данных не растёт, а хранит логи только за нужное время.

5. Старые логи хранятся в облаке и имеют удобный SQL-интерфейс для работы.

6. Единый интерфейс для поиска как свежих, так и архивных записей.

7. Доступ к архивным логам есть только у их владельца.

8. Можно легко копировать логи из облака — они уже заархивированы и структурированы.

Потребности клиентов полностью удовлетворены!

485d34b0c3e12c464abe3495f5027de1.jpg

Скучные технические детали

Теперь немного технической информации об Афине для тех, кого заинтересовал такой подход. Готового решения не будет, но я постараюсь рассказать обо всём что вам пригодится. Эдакий онбординг в Афину.

Первое, что нужно сделать — зарегистрироваться в AWS, создать AIM пользователя и группу, добавить необходимые для работы с Афиной права AmazonAthenaFullAccess и AWSQuicksightAthenaAccess. Это позволит работать с S3. 

Афина не только читает оттуда данные, но и сохраняет результат запроса. Здесь я хочу пояснить как работает выполнение запроса в Афине. Под капотом используется движок Presto, но вам не придется настраивать какую-либо инфраструктуру. Это серверлес-решение, которое позволяет выполнять запросы параллельно, автоматически расширяясь при необходимости. Афина позволяет использовать множество различных способов коммуникации:  

Чтобы создавать таблицы автоматически можно настроить интеграцию с AWS Data Glue, но в нашем случае таблица создавалась вручную — так было нужно. Все ваши запросы сохраняются Афиной и складываются в определенный S3 бакет, который вы указываете в настройках. 

Работа с AWS-сервисом в коде была исключительно проста. Всё сводилось к написанию одного сервиса поверх AWS SDK, который вызывал асинхронный метод StartQueryExecutionRequest(), возвращающий QueryExecutionId, и ждал результатов, опрашивая периодически Athena API, вызывая у клиента метод GetQueryExecution(). Ну да, еще была реализация интерфейса IQueryable, но это не относится напрямую к Афине.  

Теперь немного о формате, в котором хранятся данные на S3.

Допустим, у нас есть некий JSON или CSV файл, который нужно поместить в хранилище и потом писать запросы, чтобы найти его, когда понадобится. Для нас важно время когда заказ был закрыт, следовательно, искать аудит логи по времени будет самым частым кейсом. Путь по которому файл будет сохранен должен выглядеть примерно так:

s3://bucket-for-client1/orders/year=2023/month=01/…

Этот путь должен соответствовать такой схеме:

s3:////partition-1=/partition-2=/

В нашем случае year и month партишены, которые при написании SQL-запроса используются в качестве колонок, то есть запрос может включать такие условия:

… WHERE year = ‘2023’ AND month = ‘1’...

Но нужно помнить, что при ручном создании таблицы мы должны явно указать эти партишены:

CREATE EXTERNAL TABLE IF NOT EXISTS `Audit`.`Orders`(
  `id` string,
  …)
PARTITIONED BY (
  `year` int,
  `month` int)
ROW FORMAT SERDE
  'org.apache.hive.hcatalog.data.JsonSerDe'
LOCATION
  …

Если при миграции наших логов нужно сохранять их в новую папку, например, наступил новый год или месяц, то надо будет сказать Афине, что у нас появилось новое значение партишена:

ALTER TABLE Audit.Orders
ADD IF NOT EXISTS
PARTITION (year = '2023', month = '01')
LOCATION
…

Тем самым мы даем понять, что это составной партишен, т.е. значения 2023 и 01 используются в паре, что влияет на оптимизацию будущих запросов.

При выполнении запроса Афина будет сканировать все данные. Это может быть накладно, если таковых много. Но если в запросе фигурируют партишены, то проблема решается сама собой. Сканироваться будут только те файлы, которые хранятся в указанных в запросе партишенах. Кстати, чем меньше данных сканируется, тем меньше мы платим за запрос! ;)

В нашем случае, содержимое JSON-файлов, сохраняемых в S3, как ни странно, является сериализованным в JSON объектом. Вы можете складывать в один файл несколько объектов, но не кладите их в массив, а просто добавляйте каждый новый объект с новой строки. Иначе Афина будет считать, что вы отдаете ей не список записей, а один массив. Логично же? :)

{"id”:”1”, "name”:”name1”}
{"id”:”2”, "name”:”name1”}
{"id”:”3”, "name”:”name1”}

Если ваш сериализованный объект имеет поле, где содержится другой объект или массив объектов, то вы можете хранить такие данные в одной таблице, используя такие типы данных как Array и Struct.

CREATE EXTERNAL TABLE IF NOT EXISTS `Audit`.`Orders`(
   `id` string,
   `values` array<
          struct <
            `FieldName`: string,
            `ValueBefore`: string,
            `ValueAfter`: string> >)
  …
)

Есть еще один приятный момент. В вопросе безопасности Афина тоже на высоте. Она умеет работать с зашифрованными данными из коробки.

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

Я надеюсь, что наш опыт будет вам полезен. Ведь когда знаешь о возможностях какой-либо технологии, как её варить и в каких целях использовать, тогда сможешь применить свои знания в нужный момент.            

© Habrahabr.ru