Привет, Хабр! Меня зовут Дмитрий Салахутдинов, я принципал инженер в СберМаркете. Занимаюсь развитием Ruby-платформы и масштабированием системы через декомпозицию монолита на сервисы.
В статье хочу поделится опытом внедрения аутентификации на базе монолита. На мой взгляд, это первый необходимый подготовительный шаг к реализации фич в автономных сервисах рядом с монолитной системой. В каком-то смысле, это эволюционный способ перехода из монолитной в микро-сервисную архитектуру.
Постановка задачи
На упрощенном, представим, что задачей будет запустить автономный сервис «пользовательских сердечек ❤️», доступный аутентифицированному пользователю мобильного приложения.
Бизнес-логика может показаться малозначительной, что справедливо. Но поскольку целью статьи ставлю проиллюстрировать сам подход, пример я сознательно исказил, сделав его максимально простым.
Для аутентификации используется HTTP-заголовок Authorization
, содержащий токен клиентской сессии.
Клиентская витрина СберМаркета на тот момент была реализована в монолитном исполнении (Ruby/Rails, но в рамках этой статьи стек не принципиален). Аутентификация традиционно вплетена в монолит и «размазана» в разных частях проекта.
Некоторые детали я сознательно упростил, чтобы больше сфокусироваться на решении конкретной проблемы.
Реализовать бизнес-логику сердечек в отдельном сервисе не представляет особой сложности, если нам удастся понять их принадлежность. Но эта логика аутентификации исторически находится в монолите.
В контексте такой задачи аутентификация — это процесс понимания кто владелец сердечек.
Проксирование трафика через монолит
Наивное решение проблемы — не решать ее вовсе: пустить трафик через монолит, где мы бы аутентифицировали пользователя, и уже с этим знанием приходить в сервис. Такой вариант мы заранее отвергаем, поскольку сильная связность противоречит условиям задачи (сервис хотим автономный).
Хотя рациональное зерно в таком варианте есть. Стоит выделить в процессе обработки запроса этап аутентификации и базироваться на монолите, поскольку логика аутентификации сосредоточена в нем, а моменте отказаться от него невозможно. Так мы приходим к следующему логичному варианту.
Аутентификация «на базе монолита»
Сначала аутентифицируем запрос, потом направим его в нужный сервис. С точки зрения автономности — решение удачней. С точки зрения скорости обработки — хуже, вместо одного запроса имеем два. Но такова цена.
В схеме можно выделить 3 ключевых этапа:
Аутентифицировать каждый входящий запрос о специальный эндпоинт
/authn
стандартной логикой монолита.Результат аутентификации упаковать в специальный HTTP-заголовок ответа
X-Auth-Identity
.Если значение есть — пользователь известен; значения нет — трафик не аутентифицирован.Снабдить оригинальный запрос результатом аутентификации, HTTP-заголовком
X-Auth-Identity
с идентификатором пользователя.
Получим X-Auth-Identity
заголовок в сервисе мы поймем, с каким пользователем ассоциировать сердечки.
Минусы подхода очевидны: один запрос превращается в два, причем последовательных. Получаем накладные расходы на двойное сетевое взаимодействие: сначала с гейтвй-монолит для аутентификации, потом с гейтвей-сервис для обработки запроса.
При этом подход формально отвязывает реализацию бизнес-логики в сервисе от аутентификации, предоставляя новому сервису абстрагированный результат.
Берем его в разработку!
Реализация на API-гейтвее
Чтобы схема завелась нужна единая точка входа, централизованный способ управлять трафиком. Эту задачу решает API-гейтвей (про API-gateway есть хорошая статья на Хабре).
В легаси-системах все может быть сложней: со временем количество точек входа разрастается, появляются различные способы приема входящего трафика от разных типов клиентов. Могут использоваться разные доменные имена, разные технологии и т.д. Бессмысленно внедрять аутентификацию, не разобравшись с трафиком.
Собрать все воедино — это отдельная большая история (мы ее пропустим).
Технологический разброс реализации API-гейтвея широк (от Nginx и Envoy-proxy до самописного решения). Мы рассмотрим реализацию на базе Envoy-proxy, расширив его функциональность специфической логикой предобработки запросов с помощью фильтра (встроенного в Envoy механизма кастомизации).
Фильтр может быть исполнен в двух вариантах: скриптом на Lua либо подключаемой сборкой Web-Assembly. У нас в приоритете была скорость внедрения, поэтому выбор пал на Lua.
Ниже представлен пример кода Envoy-фильтра. Суть можно кратко описать одним предложением: «Размениваем входящий HTTP-заголовок Authorization
на внутренний X-Auth-Identity
с идентификатором пользователя».
function envoy_on_request(request_handle)
request_handle:headers():remove("X-Auth-Identity")
local token = request_handle:headers():get("Authorization")
if token == nil then return end
local cluster_name = "outbound|80||api.monolith.svc.cluster.local"
local authentication_request = {
[":method"] = "POST", [":path"] = "/authn",
[":authority"] = cluster_name,
["authorization"] = token
}
local response_headers, response_body = request_handle:httpCall(
cluster_name,
authentication_request,
"",
100
)
local identity = response_headers["X-Auth-identity"]
if identity == nil then return end
request_handle:headers():remove("Authorization")
request_handle:headers():add("X-Auth-Identity", identity)
end
Кратко разберем четыре интересных момента в работе скрипта:
Если заголовка
Authorization
нет — трафик аутентифицировать не надо, он и так анонимныйЕсли значение есть, отправляем его в эндпойнт аутентификации
/authn
монолита.Если аутентификация удалась в заголовках ответа получаем
X-Auth-Identity
, содержащий идентификатор пользователя. Если значения нет — аутентификация не прошла (токен не валидный, устаревший и т.д.)Вырезаем из запроса заголовок
Authorization
, если нам удалось обменять его на X-Auth-Identity
❗Важно: Не забыть удалять X-Auth-Identity
на случай, если кто-то передал его значение извне, решив заполучить чужие сердечки!
Поддержка тестового окружения (стейджинги)
Новая схема требует адаптации тестового окружения, особенно если в нем используется несколько наборов стендов. В таком случае необходимо проводить аутентификацию с использованием определенного инстанса монолита, потому что данные о сессиях пользователей могут разниться от одного стенда к другому. Нам важно дать возможность «собирать» стенд, тестируя функциональность с ожидаемым набором данных, в т.ч. аутентификационных.
Решение простое — ввести для тестовой среды специальный «инфраструктурный заголовок» X-Auth-Namespace
, значение которого будет указывать на неймспейс в Kubernetes, куда направлять аутентификационный запрос.
Если заголовок не передан — используем неймспейс дефолтного стейджа. Если передан — аутентификация производится на указанном стенде монолита. Код на API-гейтвее для стейджинг-окружения приобретает вариативность:
{{ if eq .Values.global.env "prod" -}}
local cluster_name = "outbound|80||api.monolith.svc.cluster.local"
{{ else -}}
local cluster_name = "outbound|80||api.<namespace>.svc.cluster.local"
local request_namespace = request_handle:headers():get("X-Auth-Namespace")
if request_namespace == nil
then
namespace = "default-monolith-namespace"
else
namespace = request_namespace
end
cluster_name = cluster_name:gsub("<namespace>", namespace)
{{- end }}
Для удобства ручного тестирования можно использовать Chrome-плагин ModHeader, он позволяет снабжать запросы дополнительным заголовком.
Реализация эндпоинта аутентификации
В примере ниже приведена реализация на Ruby. Ничего необычного, кроме того, что это отдельный эндпойнт, даже не рубист легко разберется в сути:
Находим пользователя по токену сессии.
Если аутентификация прошла — возвращаем 200 и uuid пользователя.
Если не прошла — возвращаем 403.
class AuthController < ApplicationController
def authn
user = authenticate_user
if user
response.headers['X-AUTH-IDENTITY'] = user.uuid
render status: 200
else
render status: 403
end
end
def authenticate_user
session = Session.active.find_by(access_token: access_token)
User.find_by(:id, session.user_id) if session
end
end
Полезно отдавать разные статусы ответов в зависимости от успешности аутентификации. В моем примере статус ни на что не влияет и на гейтвее никак не учитывается (но мог бы). Но так в будущем проще оформить метрики для мониторинга работы аутентификации, особенно если они идут из коробки.
❗Важный момент: эндпоинт предназначен для «внутреннего использования», и не должен быть доступен извне, поэтому закрываем к нему доступ. В противном случае, он может выдать нюансы внутренней реализации.
Специфика Ruby/Rails
Уделим немного внимания реализации на Ruby. Если вы не рубист — можете смело пропустить этот блок.
Rails-контроллер, Rails-middleware, Rack.
В исполнении Rails-контроллера выглядит громоздко и неэффективно. Нет нужны проходить весь Rails-стек, чтобы вычитать заголовок, и сходить БД. В качестве оптимизации возможно реализовать логику через Rails-middleware или перенести на уровень Rack. Чем дальше в Rack-стек, тем больше придется делать руками, но работать будет быстрей.
Мы ограничились выносом в Rails-middleware, это позволило максимально срезать стек вызовов без очень сложного рефакторинга кода.
Рефакторинг аутентификации
Большинство монолитов на Rails неявно используют warden — универсальный Rack-based фреймворк аутентификации. Обратимся к двум его важным фичам, позволяющим стройно отрефакторить многогранную логику аутентификации:
Стратегии аутентификации: архитектура гема позволяет стройно упорядочивать в цепочки разные способы аутентификации. К примеру, клиента мы можем аутентифицировать по токену мобильного приложения, а если его нет, то через Cookie.
Скоупы аутентификации — это некоторая абстракция, которая позволяет задать различные сценарии аутентификации из разных видов цепочек.
Опытный рубист заметит, что реализовать аутентификацию в виде стратегий стоило сразу. В реальности же код аутентификации в монолите расползается со временем, особенно если в обиход вводятся новые типы аутентификации и над их реализацией работают разные команды.
Больше подробностей по Ruby-части можно найти в докладе на руби-митапе (вот ссылка на обзор в моём телеграм-канале )
Запуск в продакшен
Выделение аутентификации в отдельный этап обработки запроса — большое изменения в системе:
появляется дополнительная инфраструктурная сложность;
будет сопровождаться рефакторингом логики аутентификации в монолите.
К его запуску стоит подойти обстоятельно. Ведь даже малозначительные отклонения в логике аутентификации могут вызвать серьезные проблемы: что будет, если новая схема развалится и мы не сможем аутентифицировать пользователей?
Этап полноценного тестирования немаловажен! Мне хочется уделить больше внимания техническим деталям плавного выпуска в продакшен, поэтому тестирование мы пропустим.
Ниже рассмотрим подходы, которые помогли постепенно перейти на новую схему и всегда иметь возможность отката. Все три способа являются различными вариациями фича-флагов, каждый будет по-своему полезен.
«Тестирование на продакшене»
Звучит странно, но идея очень простая. Включить фичу на API-гейтвее — только при наличие переданного HTTP-заголовка X-AUTH-Enabled: true
. При этом даже необязательно иметь ответную часть в монолите (заодно проверим как схема ведет себя, если ответной части нет)
function envoy_on_request(request_handle)
local enabled = request_handle:headers():get("X-Auth-Enabled")
if enabled = "true" then
# принудительное включение
end
end
Минус: не объясняет, как безопасно выкатить Envoy-фильтр на гейтвее.
Плюс: позволяет обкатать функционал в продакшен-среде без аффекта на реальных пользователей.
«Фича-флаг» на гейтвее
Поддержать плавную выпуск функциональности на гейтвее возможно через имитацию фича-флага в Lua. Это позволит постепенно переключать пользователей на новую схему, предполагающую дополнительный поход в монолит за аутентификацией.
function envoy_on_request(request_handle)
local number = math.random(1,1000)
if number < 100 then # включено на 10%
# включаем всю схему
end
end
Минус: требуется деплой Envoy-фильтра на любое изменение процента
Плюс: можно плавно подключать пользователей на любой процент
Как расширение первого варианта такой фича-флаг можно реализовать на клиенте, если это будет проще — передавать с клиента X-AUTH-Enabled
в определенном проценте пользователей.
Фича флаг в эндпоинте в аутентификации
Идея запустить эндпоинт аутентификации как пустышку (отдает 200 ОК) а дальше постепенно и плавно включать логику аутентификации на определенный процент пользователей.
def authn
# Flipper.enable_percentage_of_actors :authn, 10
return [200, {}, ''] unless Flipper.enabled?(:authn)
# тут логика аутентификации
...
end
Плюсы:
Удобно и привычно использовать фича-флаги в коде приложения (для Ruby популярный инструмент — Flipper).
Гибко настраивается, например для определенного пользователя, или по времени.
Динамически управляется (настоящий фича-флаг) из админ-панели.
Минусы:
Никак не уберегает от опасности накосячить на гейтвее.
Удалять оригинальный заголовок
Authorization
для этого варианта на API-гейтвее не нужно.
Готового рецепта успеха нет, скорее, управляя комбинацией различных вариантов, можно выбрать самый безопасный способ в каждой отдельной ситуации.
В нашем случае пользовались в основном флагами со стороны Ruby-монолита. А изменения на API-гейтвее выкатывали в технологическое окно в ночную смену.
Сохранение совместимости
Финальная схема предполагает получение сервисами результата аутентификации в виде стандартизованного заголовка X-Auth-Identity
. Монолит в этом случае не должен быть исключением, и его постепенно стоит привести к такой же схеме: обрабатывать X-Auth-Identity
, и не аутентифицировать запрос в случае наличия значения. Это избавит от повторной аутентификации в монолите (которая изначально там реализована), а так же выровняет «клиентскую» часть бизнес-логики монолита с другими сервисами.
Но в процессе плавного выпуска (и для возможности отката на старую схему) все же придется сохранять совместимость: монолит должен продолжать уметь аутентифицировать трафик и «как раньше», и «по-новому».
Мониторинг
Для наблюдения за процессом миграции на новую схему и для дальнейшего обслуживания позаботимся о метриках. Реализация зависит от стека разработки. Даже для Ruby/Rails объем работ варьируется от способа реализации (для Rails-контроллеров есть метрики «из-коробки», а для middleware нужно писать самостоятельно).
Приведу топ-список востребованных метрик, которыми мы пользовались в процессе:
Успешность/не успешность аутентификации. Это можно сделать через стандартные метрики web-приложения вашего фреймворка, в которые, скорее всего, уже включено распределение по статусом ответов (200 — аутентификация прошла, 403 — не прошла). Если упал рейт успеха — намудрили с пробросом заголовков.
Рейт запросов на эндпоинт аутентификации, гистограмма времени обработки запросов — помогают валидировать плавность раскатки функционала в период запуска, и аномалии в последующей работе (если упал рейт запросов — намудрили с трафиком).
Длительность запросов в БД (поиск сессии по токену). Это опционально, но потребуется для дальнейшей эксплуатации (тем более что базой для решения все равно остается монолит).
Ниже пример дашборда, которым мы пользовались. В монолите зачастую сосуществует множество видов аутентификации (пользователь с токеном, пользователь с cookie, сервисный аккаунт, партнер, другой сервис и т.д.), которые придется поддерживать в новой схеме. Мы не касались этого вопроса в статье, но все метрики выше будет полезно мониторить в разрезе различных типов аутентификации, потому что переход на новую схему скорее всего будет производится отдельно для каждого из них.
Стандартизация
Решение «на базе монолита» временное. Чтобы в дальнейшем упростить переход на что-то более надежное и системное, важно заранее заложить стандарт, абстрагирующий потребителей результата аутентификации от самой реализации.
Такой «протокол» в перспективе позволит подменить текущий монолитный эндпойнт на сервис аутентификации, не внося изменения в многочисленные сервисы, уже реализованные на базе стандарта.
Базовые рекомендации к разработке стандарта:
Нейминг: стандартизировать и зарезервировать http-заголовки, которые будут использоваться «внутри» для обогащения запроса результатами аутентификации. Дать им соответствующие названия, начинающиеся с
X-
(в соответствие с конвенцией X- означает extended и для специфичных заголовков стоит использовать этот префикс).Жестко зафиксировать набор заголовков и их значение, к примеру:
X-Auth-Identity — сквозной идентификатор пользователя (объекта аутентификации).
X-Auth-Type — использованный способ аутентификации (в том числе определяет тип объекта, например, пользователь, или интегрированный с нами партнер).
X-Auth-Namespace — специальный инфраструктурный заголовок для роутинга в нужный экземпляр «монолита» (сервис аутентификации) на стейджинге.
Предусмотреть сквозные идентификаторы для пользователей. В монолите вероятней всего используются числовые идентификаторы, которые генерируются автоматически БД при вставке. Вынос аутентификации — подходящий момент продумать процесс отказа от них в пользу более универсальных. К примеру, перейти на UUID. Это поможет, когда источником данных о пользователя станет другой сервис. Тем не менее, часть бизнес-логики, базирующейся на монолите может по-прежнему оперировать старым идентификатором, если выделить специальный legacy-заголовок под него:
X-Legacy-ID: 123
. Слово Legacy поможет уберечь потребителей от сильной завязки на старый идентификатор.
Системное решение
Текущее решение позиционирую исключительно как временное. Коснемся вкратце перехода на один из вариантов системного решения - автономный сервис аутентификации, назовем его auth-agent. Именно по такому пути монолитное решение трансформировалось в постоянное в Сбермаркете.
Хотя тема его разработки — это отдельная большая история, отмечу основные моменты, чтобы проиллюстрировать мысль о пользе унификации из предыдущего раздела:
Запускаем отдельный сервис (1), который будет обслуживать эндпоинт аутентификации. Чтобы он смог работать, ему нужны данные о пользователях (и их токенах), которые по началу нужно стримить из монолита, например, при помощи Kafka. На случай отставания данных, монолитный эндпоинт используется в качестве фолбека (3).
Дальше переносим в сервис логику «выдачи аутентификационных токенов» (2), в том числе куки. После этой операции сервис полноценно управляет аутентификацией. И фолбек на монолит становится не нужен (3)
Авторизация
В случае с сервисом «сердечек» бизнес-логика простая и не предполагает авторизацию. Но рано или поздно потребуется авторизация, к примеру, для интерфейса администратора или другого случая разделения прав пользователей.
Если в монолитном приложении действует ролевая модель (ассоциированный с пользователем набор ролей), то аналогичное временное решение на базе монолита можно переиспользовать и для передачи данных о ролях пользователя. Вводим дополнительный заголовок X-Auth-Roles
, содержащий информацию о наборе ролей пользователя.
В этом случае сервис на основании списка ролей самостоятельно авторизует пользователя, принимая решения о доступности функциональности для текущего пользователя.
Значение стандартизации заголовков здесь так же важно для дальнейшего безболезненного перехода к сервису авторизации, который может использовать, например, JWT с информацией о ролях пользователя в таком же формате.
Выводы
Аутентификация на базе монолита — это жизнеспособное быстрое решение, позволяющее на время закрыть вопрос с аутентификацией и не блокировать запуск новых автономных сервисов. Я предлагаю его на рассмотрение, если перед вами стоит аналогичная задача - плавно переходить в микро-сервисную архитектуру.
Оно не лишено недостатков, и может рассматриваться исключительно как временное, позволяющее выиграть время для разработки и внедрения системного. А при должном подходе к стандартизации не прийдется вносить изменения во множество сервисов при переходе к сервису авторизации.
Буду рад обсудить решение в комментариях!
Tech-команда Купера (ex СберМаркет) ведет соцсети с новостями и анонсами. Если хочешь узнать, что под капотом высоконагруженного e-commerce, следи за нами в Telegram и на YouTube. А также слушай подкаст «Для tech и этих» от наших it-менеджеров.