Как стать автором
Обновить
Купер
Кодим будущее доставки товаров

Как мы реализовали аутентификацию трафика для MSA на базе монолита

Уровень сложностиСредний
Время на прочтение13 мин
Количество просмотров1.9K

Привет, Хабр! Меня зовут Дмитрий Салахутдинов, я принципал инженер в СберМаркете. Занимаюсь развитием Ruby-платформы и масштабированием системы через декомпозицию монолита на сервисы.

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

Постановка задачи

На упрощенном, представим, что задачей будет запустить автономный сервис «пользовательских сердечек ❤️», доступный аутентифицированному пользователю мобильного приложения.

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

Для аутентификации используется HTTP-заголовок  Authorization, содержащий токен клиентской сессии.

Клиентская витрина СберМаркета на тот момент была реализована в монолитном исполнении (Ruby/Rails, но в рамках этой статьи стек не принципиален). Аутентификация традиционно вплетена в монолит и «размазана» в разных частях проекта.

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

Задача: реализовать автономный сервис сердечек (для понравившихся товаров)
Задача: реализовать автономный сервис сердечек (для понравившихся товаров)

Реализовать бизнес-логику сердечек в отдельном сервисе не представляет особой сложности, если нам удастся понять их принадлежность. Но эта логика аутентификации исторически находится в монолите.

В контексте такой задачи аутентификация — это процесс понимания кто владелец сердечек.

Аутентификация (в контексте задачи) — это процесс понимания кто владелец "сердечек".
Аутентификация (в контексте задачи) — это процесс понимания кто владелец "сердечек".

Проксирование трафика через монолит

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

Пустить трафик через монолит (не подходит)
Пустить трафик через монолит (не подходит)

Хотя рациональное зерно в таком варианте есть. Стоит выделить в процессе обработки запроса этап аутентификации и базироваться на монолите, поскольку логика аутентификации сосредоточена в нем, а моменте отказаться от него невозможно. Так мы приходим к следующему логичному варианту.

Аутентификация «на базе монолита»

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

Схема аутентификации: 1. запрос на аутентификацию в монолит 2. возврат результата. 3. использование результата для обработки исходного запроса в новом сервисе
Схема аутентификации: 1. запрос на аутентификацию в монолит 2. возврат результата. 3. использование результата для обработки исходного запроса в новом сервисе

В схеме можно выделить 3 ключевых этапа:

  1. Аутентифицировать каждый входящий запрос о специальный эндпоинт /authn стандартной логикой монолита.

  2. Результат аутентификации упаковать в специальный HTTP-заголовок ответа X-Auth-Identity.Если значение есть — пользователь известен; значения нет — трафик не аутентифицирован.

  3. Снабдить оригинальный запрос результатом аутентификации, 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. Ничего необычного, кроме того, что это отдельный эндпойнт, даже не рубист легко разберется в сути:

  1. Находим пользователя по токену сессии.

  2. Если аутентификация прошла — возвращаем 200 и uuid пользователя.

  3. Если не прошла — возвращаем 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, сервисный аккаунт, партнер, другой сервис и т.д.), которые придется поддерживать в новой схеме. Мы не касались этого вопроса в статье, но все метрики выше будет полезно мониторить в разрезе различных типов аутентификации, потому что переход на новую схему скорее всего будет производится отдельно для каждого из них.

Стандартизация

Решение «на базе монолита» временное. Чтобы в дальнейшем упростить переход на что-то более надежное и системное, важно заранее заложить стандарт, абстрагирующий потребителей результата аутентификации от самой реализации.

Такой «протокол» в перспективе позволит подменить текущий монолитный эндпойнт на сервис аутентификации, не внося изменения в многочисленные сервисы, уже реализованные на базе стандарта.

Переход от монолитной реализации к сервисной упростится за счет стандартизации
Переход от монолитной реализации к сервисной упростится за счет стандартизации

Базовые рекомендации к разработке стандарта:

  1. Нейминг: стандартизировать и зарезервировать http-заголовки, которые будут использоваться «внутри» для обогащения запроса результатами аутентификации. Дать им соответствующие названия, начинающиеся с X- (в соответствие с конвенцией X- означает extended и для специфичных заголовков стоит использовать этот префикс).

  2. Жестко зафиксировать набор заголовков и их значение, к примеру:

    • X-Auth-Identity — сквозной идентификатор пользователя (объекта аутентификации).

    • X-Auth-Type — использованный способ аутентификации (в том числе определяет тип объекта, например, пользователь, или интегрированный с нами партнер).

    •  X-Auth-Namespace — специальный инфраструктурный заголовок для роутинга в нужный экземпляр «монолита» (сервис аутентификации) на стейджинге.

  3. Предусмотреть сквозные идентификаторы для пользователей. В монолите вероятней всего используются числовые идентификаторы, которые генерируются автоматически БД при вставке. Вынос аутентификации — подходящий момент продумать процесс отказа от них в пользу более универсальных. К примеру, перейти на 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-менеджеров.

Теги:
Хабы:
Всего голосов 41: ↑40 и ↓1+41
Комментарии8

Публикации

Информация

Сайт
kuper.ru
Дата регистрации
Дата основания
Численность
1 001–5 000 человек
Местоположение
Россия
Представитель
Купер

Истории