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

Единственное, что меня беспокоило — это авторизация. В мире нет ничего более желанного для ИБ и ненавистного для разработчиков, чем контроль доступа. И я знал, что довольно скоро мы доберёмся и до этого вопроса.

Если вам интересно распутать клубок асинхронного взаимодействия тысяч продюсеров и консьюмеров, узнать, где документация Kafka нас обманывает, а librdkafka и Confluent.Kafka не могут договориться и как один потерянный пакет может привести к Permission denied, добро пожаловать под хабракат. Эта история для тех, кто догадался, что недостаточно было «просто включить флажок в конфиге».

Привет, Хабр! Меня зовут Виктор Корейша и я — руководитель направления Managed Services в Ozon. Я и моя команда отвечаем за всю инфраструктуру асинхронного взаимодействия между сервисами, которую строим на базе Kafka. А ещё я ведущий подкастов «Кода кода» и «Три тимлида заходят в бар».

Эта статья написана по мотивам моего доклада для DevOps Conf 2025. Расскажу нашу историю про внедрение авторизации и аутентификации в Kafka. Инженеры по эксплуатации найдут в ней обзор решений реализации SASL-сервера, разработчики — историю о конфликтах в production-ready-клиентах, архитекторы — любопытные кейсы взаимодействия высоконагруженных систем, ну а менеджеры — эпос о внедрении технически сложных изменений в больших компаниях.

Kafka в Ozon

Kafka в Ozon — это основная шина данных для огромной экосистемы. У нас в проде два больших кластера, которые обслуживают порядка 100 тысяч партиций, и даже вне пиковых нагрузок мы прокачиваем около 17 миллионов сообщений в секунду (MPS). Это много. Достаточно, чтобы любое изменение, даже «почти безобидное», могло привести к деградации по latency и зааффектить бизнес.

Мы используем Kafka 2.7.1 с ZooKeeper. Каждый кластер растянут на три дата-центра, и каждая партиция имеет три реплики в разных ДЦ. Такая архитектура позволяет нам сделать доменами отказа и конкретный брокер, и стойку, и целый дата-центр. Что-то в этой статье может противоречить более новым версиям или другим конфигурациям кластера, особенно, если вы перешли на KRAFT.

Kafka в Ozon была и остаётся общим ресурсом. Любой клиент, если это сервис внутри контура, может писать и читать из любого топика. Список брокеров раздаётся всем подам сервисов через нашу систему service discovery. Это позволяет быстро внедрять новые интеграции: если кому-то нужны данные, то он может подключиться к их источнику, не требуя доработок от других команд.

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

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

Одно такое сообщение, случайно отправленное в топик с сотнями потребителей, могло приводить к далеко идущим последствиям, которые приходилось устранять в режиме инцидента многие часы. Да, такие кейсы можно контролировать, например, на уровне CI/CD. Но это сложно и не слишком надёжно.

Зачем нужна авторизация в Kafka

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

Контроль доступа к данным: Read/Write-права на конкретный топик для конкретного сервиса. Это не снимает с владельцев данных ответственности за то, что они кладут в топик. Но позволяет более точно контролировать, кому с этими данными работать можно, а кому нет. Чуть ниже мы ещё поговорим про доступ людей к данным.

Контролируемая интеграция. Без авторизации любой клиент может читать и писать что угодно, хоть раз в месяц, и это может вообще нигде не засветится. Сам протокол Kafka не требует никакой регистрации клиента, и «из коробки» мы никак не фиксируем, кто куда писал и тем более кто откуда читал. Так очень легко что-то сломать: поменять схему данных и не учесть, что не все потребители к такому готовы. Поэтому разработчикам безопаснее было сохранять легаси, добавляя поля, но никогда не убирая старые.

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

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

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

Выбираем способы аутентификации и авторизации

Быстрый поиск легко подскажет нам, что в Kafka уже есть ряд механизмов «из коробки». Всё это описано на сайте Confluent не только в виде «сухой» документации, но и базовым курсом по безопасности в Kafka. В документации к популярным клиентам также описывается, как поддержать ту или иную аутентификацию. Поэтому выглядело это как приключение на 20 минут: вошли и вышли прочитали и включили.

Аутентификация в Kafka

Kafka поддерживает два основных механизма аутентификации.

TLS-клиентская аутентификация. В этом варианте каждому клиенту выдаётся X.509-сертификат. Сертификат проверяется брокером, и Kafka считает, что клиент — это тот, кто указан в subject’е. Клиентский TLS обеспечивает шифрование трафика и является только способом аутентификации. Главный плюс: простая модель аутентификации — 1 TLS-сертификат = 1 клиент. Из минусов: даёт overhead на потребление CPU.

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

SASL (Simple Authentication and Security Layer) — это общее имя для протоколов, позволяющих использовать внешние системы управления пользователями.

Kafka поддерживает несколько вариантов SASL:

  • SASL/PLAIN — передача логина и пароля. Считается небезопасной без TLS. Может быть использована вместе с Active Directory или LDAP.

  • SASL/SCRAM — чуть «прокачанная» версия plain с использованием шифрования поверх.

  • SASL/OAUTHBEARER — аутентификация с использованием JWT-токенов (OAuth 2.0).

  • SASL/GSSAPI — через механизм Kerberos, например, используя Active Directory.

  • Возможны кастомные реализации, если нужно.

И наш выбор пал на OAUTHBEARER, который позволяет использовать токены, выпущенные внешними по отношению к Kafka IAM-системами, и управлять не только информацией о пользователе, но и его ролью. Выглядит ровно так, как то, что нам нужно!

Но есть нюанс. Apache Kafka действительно поддерживает SASL/OAUTHBEARER, но встроенная реализация — тестовая (unsecured JWT) и не используется в продакшне. Для боевого применения требуется кастомный LoginModule и Authorizer. Грубо говоря, вместо готового SASL-сервера там висит «заглушка», которую нужно заменить на работающее решение. То есть Kafka умеет принимать токены и ожидать, что вы предоставите ей валидатор, который проверит подпись токена, извлечёт роли или другие атрибуты, создаст объект (KafkaPrincipal), с которым Kafka сможет работать при авторизации. Как говорится, «добавьте SASL по вкусу».

Реализация SASL/OAUTHBEARER

Значит, настало время искать готовую реализацию.

BlackRock

В процессе ресёрча мы нашли open source-проект от международной финтех-компании BlackRock. Это сервер, реализующий полноценную аутентификацию и авторизацию на базе Kafka Security Framework. Он поддерживает приём токенов (OAuth 2.0), проверку подписи, авторизацию на основе ролей, реализацию всех нужных интерфейсов Kafka.

Казалось бы, всё, что нужно. Но у этого решения есть очень серьёзный недостаток: SASL/OAUTHBEARER начинает работать, только когда включаешь его на всех брокерах и для всех клиентов одновременно, т.е. плавно перевести сервисы с plaintext на oauth не представляется возможным. В кейсе авторов такой проблемы нет, т.к. они строили систему с нуля, а не переводили существующую.

Для масштаба Ozon это невозможно: у нас более 4000 различных сервисов, которые взаимодействуют с Kafka. Обновить их одновременно физически невозможно — процессы независимо распределены между десятками команд. Ну и подразумевается даунтайм, которого мы допустить не можем.

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

Кроме того, это решение, кажется, не поддерживается — последние коммиты были 5 лет назад. И архитектурно это прям внешний сервис, который встаёт в виде прослойки между IAM и Kafka. Что в целом выглядит не очень масштабируемым.

Strimzi

Следующим кандидатом стало решение от Strimzi — разработчиков популярного Kubernetes-оператора для Kafka. У них есть отдельный модуль strimzi-kafka-oauth, который реализует аутентификацию в виде Kafka-плагина, а не внешнего сервера.

Strimzi OAuth встраивается непосредственно в Kafka-брокер, работает внутри него и расширяет стандартные механизмы безопасности Kafka.

Ключевые плюсы Strimzi OAuth:

  • поддерживает интеграцию с внешним IAM-сервисом для валидации токенов. То есть умеет работать с JWT;

  • позволяет гибко управлять ролями, в том числе извлекать их из токена или через отдельные вызовы;

  • основан на стандартных интерфейсах Kafka. Использует класс KafkaPrincipal и расширяет его;

  • работает быстрее и стабильнее, чем решение через внешний сервер. Кэширует роли у себя, не создавая лишнюю нагрузку на IAM;

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

В общем, Strimzi нам подошёл. И тут же появился план переключения: мы оставляем листенер без аутентификации (стандартный порт — 9092), а рядом, на соседнем порту (9094), поднимаем новый листенер с аутентификацией. Стоит пояснить, что для реализации сетевого взаимодействия между Kafka-сервером и конечным клиентом (producer, consumer) в Kafka применяется абстракция kafka.listener (tcp-порт) с помощью которой Kafka регистрирует в операционной системе порт под определённую задачу:

-       межброкерное взаимодействие,

-       взаимодействие с клиентом без всякой аутентификации.

Опционально можно резервировать порты под механизмы SASL без отключения уже имеющихся интерфейсов для взаимодействия с клиентами.

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

Как работает Strimzi у нас

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

  2. Клиент Kafka получает JWT-токен и использует его при подключении к брокеру по SASL.

  3. Kafka-брокер, в котором установлен Strimzi Oauth-плагин:

  • принимает JWT-токен;

  • проверяет подпись (по публичному ключу);

  • извлекает роли (либо из токена, либо по внешнему API);

  • создаёт объект OAuthKafkaPrincipal, который наследует нативный KafkaPrincipal и содержит разрешения для клиента;

  • использует этот объект при проверке каждой операции (чтение, запись, join group и т.д.).

Авторизация

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

Если кратко: аутентификация — это «кто ты?», а авторизация — «что тебе разрешено?». Kafka поддерживает два основных подхода к авторизации.

1. ACL (Access Control Lists)

ACL — это классическая модель, в которой для каждого пользователя (или группы пользователей) явно задаются права: кто имеет доступ к какому ресурсу и с какими операциями.
В Kafka ресурсами являются:

  • топики,

  • consumer group’ы,

  • кластеры,

  • транзакции и пр.

Для каждой комбинации указывается, можно ли:

  • читать (Read);

  • писать (Write);

  • создавать (Create);

  • удалять (Delete);

  • использовать административные действия (Describe, Alter и др.).

Особенности:

  • ACL в Kafka реализованы и работают «из коробки»;

  • правила записываются в Apache ZooKeeper (в версиях до 3.x);

  • поддерживаются команды CLI (kafka-acls.sh) и Java API.

Минусы:

  • масштабирование: каждое изменение ACL приводит к необходимости синхронизации ZooKeeper. Это означает, что при больших объёмах — например, если нужно добавить тысячи записей, ZooKeeper становится узким местом. Он должен «сойтись» по каждой записи, и при высокой скорости обновлений это вызывает задержки;

  • управление: без внешнего инструмента для централизованной генерации и синхронизации правил администрирование становится сложным. Особенно если в системе — сотни или тысячи пользователей и сервисов;

  • хрупкость: ручное управление правами масштабно не работает. Один ошибочный ACL — и сервис либо ничего не может, либо, наоборот, получил доступ куда не должен.

2. RBAC (Role-Based Access Control)

RBAC — альтернативная и более масштабируемая модель. Apache Kafka не реализует RBAC natively, но допускает реализацию ролей через внешние Authorizer’ы. Например, Strimzi KeycloakAuthorizer, OPA, Apache Ranger позволяют внедрить полноценную ролевую модель. Вместо того чтобы прописывать права на каждого пользователя вручную, мы:

  1. задаём роли (например: reader, writer, admin);

  2. привязываем к этим ролям набор операций;

  3. назначаем пользователю одну или несколько ролей.

В Kafka, начиная с определённых версий, возможно использовать эту модель за счёт расширения KafkaPrincipal — объекта, который создаётся при аутентификации пользователя и живёт в течение всей сессии клиента. RBAC в Kafka реализуется кастомно через обёртку над KafkaPrincipal и логику проверки прав внутри плагина или Authorizer. Kafka сама по себе не предоставляет полноценную RBAC-модель.

Как это работает:

  • Kafka поддерживает до ~20 базовых операций (чтение, запись, join group, commit и т.д.);

  • когда клиент подключается, его KafkaPrincipal содержит роли, соответствующие этим операциям;

  • Kafka на каждом запросе смотрит: разрешена ли операция данной роли?

  • проверка делается локально — не нужно каждый раз обращаться во внешнюю систему или ZooKeeper.

Фактически объект authorizer (опция authorizer.class.name) становится основой для контроля доступа на каждую операцию — именно он решает, имеет ли клиент право выполнить конкретное действие с конкретным ресурсом.

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

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

ACL в нашем случае были непрактичны. У нас тысячи клиентов, десятки тысяч топиков. Обновления прав происходят часто. Временные задержки в ZooKeeper критичны при наших нагрузках.

RBAC же позволил нам хранить роли прямо внутри KafkaPrincipal, не перегружать Kafka лишними обращениями, централизованно управлять доступами на уровне IDM, а не на уровне CLI и скриптов. Кроме того, Strimzi OAuth уже предоставляет механизмы для работы с ролями — как из токена, так и через внешние вызовы.

Мы упростили модель ролей до минимально необходимого набора: оставили только доступ на чтение/запись в конкретный топик. Админские доступы выдаём отдельно вручную. А, например, доступ на создание consumer group мы оставили всем, т.к. он не даёт возможности получить или записать данные, которые не следовало бы получать.

Обе роли (Read/Write) создаём сразу при создании топика и удаляем при его удалении.

Как передавать роли

Есть два основных варианта как именно передавать роли в Kafka.

Вариант 1: вшивать роли в токен

При аутентификации мы передаём вместе с токеном все роли клиента. Эти роли извлекаются один раз и сохраняются внутри OAuthKafkaPrincipal на всё время жизни соединения.

Плюсы:

  • Kafka не делает лишних запросов — все данные уже есть;

  • быстрее обработка, меньше зависимостей.

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

  • роли живут столько же, сколько и токен. Если токен действителен, то и доступы внутри него тоже считаются актуальными — даже если в реальности они уже устарели;

  • изменить права на лету нельзя. Нужно ждать истечения токена или принудительно его обновлять;

  • размер токена может стать критичным. Например, если клиенту нужен доступ к тысяче топиков — тысяча ролей окажется внутри токена. Это увеличивает нагрузку на передачу, проверку, хранение, и может привести к тому, что токен просто не «пролезет» через балансировщики или сетевые лимиты. В Kafka нет фиксированного лимита на размер токена на уровне SASL. Однако слишком большие токены действительно влияют на производительность.

В системах с большой распределённой нагрузкой такие вещи всплывают неожиданно — в самый неподходящий момент. Мы решили не рисковать и выбрать другой вариант.

Вариант 2: передавать только идентификатор, а роли подтягивать отдельно

В этом подходе токен содержит только имя клиента. Kafka при подключении делает отдельный запрос во внутреннюю систему и получает актуальные роли.

Плюсы:

  • токен остаётся компактным;

  • роли можно централизованно обновлять без перевыпуска токенов;

  • нет риска, что токен «не пролезет» в инфраструктурные ограничения (например, через прокси или балансировщик).

Минусы:

  • появляется внешний вызов при подключении;

  • нужно поддерживать систему кэширования ролей внутри Kafka;

  • возможны проблемы при высокой нагрузке, если не контролировать частоту обращений.

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

Как это работает в Kafka на практике

В протоколе Kafka есть понятие «сессии», в которой хранится KafkaPrincipal (поле context.principal) и InetAddress (IP,port,hostname, family). Вот как работает создание сессии и получение ролей со Strimzi:

  • при первом подключении клиента Kafka-брокер получает от клиента токен;

  • JWT-токен резолвится и валидируется;

  • затем Kafka делает запрос во внешнюю IAM-систему и получает список ролей, связанных с этим клиентом;

  • эти роли сохраняются в памяти брокера.

После этого клиент продолжает работать с брокером, отправляя запросы (чтение, запись и т.д.). Kafka уже не обращается повторно за ролями — всё нужное хранится в памяти и доступ проверяется локально.

Права у клиента со временем могут измениться, поэтому кэш нельзя считать вечным. Обновлять роли всё равно нужно.

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

Но в этом подходе есть проблема. Когда роли обновляются асинхронно, это означает, что в системе работает таймер. В какой-то момент времени он срабатывает, и Kafka-брокер начинает обращаться во внешнюю систему, чтобы получить свежие роли. Такой таймер есть в Kafka «из коробки», и он запускается на каждом брокере независимо.

Один брокер обслуживает много клиентов и по таймеру он обращается к нашей IAM-системе за обновлением ролей. Это выглядит как пиковые всплески запросов (узкие «иголки»). По умолчанию Strimzi обновляет по 5 ролей за один запрос, на 200 брокеров в двух кластерах примерно 600K клиентов, а каждый клиент может держать соединение с несколькими брокерами. Например, продюсер пишет в столько брокеров, сколько партиций у всех топиков. Даже если увеличить батч до 50, то это 50-150 запросов подряд с одного брокера. В реальности картинка выглядела так:

Наша IAM-система, обрабатывающая запросы на роли, выдерживает, допустим, 500 RPS. Но у нас ведь не один брокер, а около 200. Если 10 или 20 из них в одну секунду запустят обновление ролей, нагрузка резко возрастает. И это может превысить возможности нашей системы. Возможно, вы думаете, что таймеры совпасть не могут, но, к сожалению, они запускаются не в случайный момент, а со стартом брокера. И стоит нам массово перезагрузить сервера, как мы рискуем синхронизировать их.

Конечно, со стороны IAM-системы есть RL, который не даст нам привести к деградации, а просто отбросит часть запросов. А со стороны брокера это будет выглядеть как ответ без ролей, и брокер, руководствуясь принципом наименьших привилегий, откажет в доступе. И часть запросов до того, как отработают ретраи, мы потеряем.

Чтобы избежать перегрузки, нам пришлось доработать Strimzi OAuth: добавить jitter в таймер запроса ролей (операция GRANTS_REFRESH). Мы изменили поведение Strimzi так, чтобы роли не запрашивались одновременно для всех клиентов. Вместо этого мы равномерно распределяем запросы по времени, уменьшая нагрузку на внешнюю систему.

Тут стоит оговориться, что речь идёт про Strimzi 0.12. В более поздних версиях часть из этих модификаций сделали в мейнстриме. Некоторые из наших доработок впоследствии были частично включены в новые версии Strimzi, но не в том виде, в котором мы их предлагали. К сожалению, мы не можем обновиться из-за Kafka 2.7 и вынуждены будем ещё какое-то время жить с форком.

Инструменты для эксплуатации. Раз уж полезли в код, то сразу добавили нужные возможности для эксплуатации. Например, ручку для сброса кэша ролей. Если наша внешняя система по ошибке прислала некорректные роли или в случае экстренной ситуации (например, по требованию службы безопасности), мы можем принудительно сбросить кэш и пересчитать все роли заново.

Улучшения наблюдаемости. До этого в продакшн-версии Strimzi почти не было инструментов для мониторинга, кроме числа подключений. Мы начали добавлять метрики и управляемые ручки, чтобы эксплуатация стала проще и прозрачнее.

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

Проблема с отсутствием понятных логов. Очень часто клиенты приходят с типичным вопросом: «У меня не работает Kafka-клиент». С вводом авторизации появилась ещё одна потенциальная проблема — запрет доступа из-за технических ошибок. В логах по умолчанию нет информации, запрещён ли конкретному клиенту доступ к конкретному топику. Логировать каждый запрос для наших объёмов — самоубийство, но мы решили логировать каждый permission denied. Их не должно быть много, т.к. адекватный клиент такие ошибки не ретраит.

А что с клиентами?

Мы специально выбирали из стандартных механизмов аутентификации и остановились на SASL/OAUTHBEARER, чтобы клиенты на разных языках его поддерживали. В Ozon для каждого языка есть платформенный фреймворк, который существенным образом состоит из обёрток для таких библиотек, связывающих их в единую систему и предоставляющих общие механизмы настройки, деплоя, кодогенерации, подключения к инфраструктуре и прочему.

Да, и именно это позволило нам сделать так, чтобы каждый сервис имел свой уникальный ClientID. Именно по этому полю мы и идентифицировали клиента до внедрения OAUTHBEARER-аутентификации. По нему строятся ограничения утилизации через kafka-quotas. Но это другая история…

И тут от нас требовалось доработать наши обёртки таким образом, чтобы обеспечить максимально простое переключение — буквально, одной «галочкой». Для этого мы доработали наш механизм Service Discovery и научили клиентский код при создании клиента:

  • забирать нужные эндпоинты с портом нового листенера;

  • получать креды и менять их на JWT-токен в IAM-сервере;

  • ходить в Kafka с этим токеном;

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

Мы не стали пытаться поменять механизм аутентификации «на ходу», а решили, что пересоздание клиента при рестарте пода сервиса нас вполне устраивает.

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

Проблемы с Confluent.Kafka

Чтобы не мучать читателя однотипными историями, приведу один показательный случай. Существует такой клиент, как Confluent.Kafka — это дефолтная C#-библиотека Kafka. Если вы пишете на C# — скорее всего, вы используете именно её, только она считается единственной полнофункциональной библиотекой Kafka, которая развивается официально на этом языке.

Библиотека Confluent.Kafka под капотом использует бинарник librd kafka — библиотеку, написанная на C. Пишет её основное сообщество Apache Kafka, и она считается каноничной. Ну что тут может пойти не так?

Давайте разберём, какой слой за что отвечает.

1.     Получение секретов. Чтобы авторизоваться в IAM, клиенту нужны креды, который он далее поменяет на JWT-токен. Можно получать OAuth2 JWT и иначе, например менять K8s-токен на него или как-то заранее подкладывать его в ENV-переменную пода. Креды хранятся в системе хранения секретов (например, Vault). За то, чтобы обратиться к системе секретов и получить нужные данные, отвечает наш клиентский код — то, что пишут мои коллеги в платформенном фреймворке.

2.     Передача токена в Kafka. Полученный токен нужно передать в библиотеку Confluent.Kafka, она инициализирует его при создании клиента в librd. Именно librdkafka отвечает за то, чтобы сходить за токеном и вставлять его в запросы к Kafka. Когда Kafka-клиенту нужно получить токен (например, по OAUTHBEARER), он вызывает метод login() у LoginModule. Этот модуль, в свою очередь, обращается к нашей внутренней IAM-системе, которая выдаёт токен.

3.     Работа с Kafka. Например, отправка сообщений, чтение или join group. Дальше клиентский код приложения вызывает что-то вроде subscribe() или joinGroup() в Confluent.Kafka. В самой Kafka существует около 20 базовых операций — чтение, запись, JoinGroup, SyncGroup, Fetch и так далее. А вот в librd kafka — около 40 различных вызовов. Ну а Confluent.Kafka ещё и предлагает некоторые варианты своего «сахара» сверху. Очевидно, что они не совпадают один в один, и это может приводить к проблемам в авторизации.

Где могут быть проблемы?

Ошибка при получении секретов — если система недоступна или конфигурация неправильная.

Неправильная передача токена — если клиентский код или настройки kafka-clients не работают как надо.

Ошибки в LoginModule — если при обращении в нашу систему за токеном возникают сбои.

Проблемы на стороне Kafka — если токен валидный, но не даёт нужных прав.

Именно поэтому важно понимать, какой компонент за что отвечает, чтобы быстро локализовать и устранить проблему.

Но всё это не страшно. Гораздо опаснее проблемы, которые возникают на стыке «зон ответственности». Рассмотрим конкретную ситуацию. Есть метод из Confluent.Kafka — QueryWatermarkOffsets, он нужен для получения самых старых и самых новых оффсетов в партициях топика. Но «под капотом» его выполнение сначала вызывает в librd-функцию rd_kafka_topic_partition_list_query_leaders, которая, в свою очередь, получает список лидеров партиций, по которым и будет запрашивать оффсеты. При этом librd при попытке получить лидеров для каждой партиции, т.е. ещё до запросов оффсетов, ходит без JWT — потому что эта операция, по задумке авторов, не должна была «стартовать сессию».

Что получаем? Permission denied внутри librd без возможности обработать клиентским кодом. После чего библиотека отключается от брокера и пытается пересоздать клиент. Пересоздание запрашивает метаданные, после чего снова пытается получить лидеров. Снаружи мы видим «зависшую» обработку партиции и 100-500 RPS на запрос метадаты от пода, что, в свою очередь, очень напрягает весь кластер Kafka.

Понятно, что если в клиентском коде сделать хоть один Consume до вызова QueryWatermarkOffsets, то ошибки не происходит и всё работает. Ну и такие несостыковки можно полечить на стороне обёртки, вызывая методы в известном порядке.

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

Когда Kafka-клиент не получает доступ к какому-то ресурсу (например, из-за отсутствия токена или ошибочной авторизации), он не всегда корректно обрабатывает отказ. В некоторых реализациях клиент продолжает попытки — раз за разом, думая, что проблема временная. Такие клиенты могут буквально «зациклиться», отправляя повторные запросы, не прекращая попыток. Причём это происходит автоматически, без участия пользователя.

За несколько месяцев активного внедрения мы нашли десятки подобных багов в четырёх разных Kafka-клиентах. Обнаружить это удалось благодаря детализированным логам в Strimzi и на стороне IAM. Вывод: наблюдаемость — наше всё, когда речь идёт о внедрениях такого масштаба.

Разбираемся с правами

Когда клиенты были написаны и протестированы, скрипт отката проверен, пришло время готовить масштабный план включения. Чтобы клиент работал с авторизацией, ему сначала нужно выдать соответствующие права. Раньше этих прав не было вообще, ведь Kafka была «открытой». Теперь же доступ нужно контролировать, а значит — каждому клиенту придётся настроить нужные разрешения.

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

Вернёмся в точку А, где у нас ещё нет аутентификации. Как в такой ситуации определить, какие сервисы и куда обращаются? У нас сотни команд и тысячи топиков. Сами топики под контролем — они создаются через внутреннюю систему, которую можно представить как приватное облако. Таким образом, у каждого топика есть владелец. Но знает ли он всех потребителей? К сожалению, далеко не всегда.

И точных данных о том, кто с какими топиками работает, — этого у нас нет в явном виде. Мы можем пойти к командам и сказать: «Ребята, пожалуйста, опишите, какие вам нужны права. Вот шаблон, вот инструкция».

Но это не спасает от ошибок. Кто-то забудет, кто-то перепутает, кто-то пропустит уведомление. И самое главное — у нас нет способа проверить правильность этих данных до включения авторизации. То есть в момент, когда мы активируем её, какое-то количество клиентов могут «упасть», и разбираться с ними придётся вручную. Это очень серьёзные риски, потому что Kafka — основной инструмент асинхронного взаимодействия в компании.

Хорошая идея — попробовать собрать информацию о доступах на стороне клиентов. Ведь в их коде явно указано, с какими топиками они работают: читают, пишут, создают consumer group и так далее. Кажется логичным: раз эти данные уже есть в коде, почему бы их не извлечь автоматически? Но могут быть и не в коде, а в конфигурации, и не явно, а каким-то шаблоном, и что тогда?

Внимательный читатель заметит, что можно взвести какую-то метрику в нашей обёртке и собирать данные по ней. Но у нас много клиентов на разных языках программирования. Это значит, что чтобы автоматически собирать данные о доступах, нужно реализовать поддержку для каждого языка отдельно. И сделать это одинаковым образом, чтобы ничего не пропустить. А ведь есть ещё какие-то особые решения, например, коробочные — для них тоже придётся всё это делать. Навскидку, представьте, что в рамках такого проекта пришлось бы дописать 1C, DataHub, ClickHouse, Spark, Asterix да мало ли ещё что.

Но даже это — не основная проблема. Главное — у нас огромное количество клиентов, и даже если мы внедрим обновление, которое будет собирать нужную информацию, оно разъедется по системам не сразу. Мы не сможем быстро убедиться, что все его поставили. Можно, конечно, просить команды обновиться вручную — но это долго, неэффективно и непредсказуемо.

И самое неприятное — у нас нет надёжного способа проверить, что сбор метрик работает корректно во всех случаях. Да и с периодом сбора могут быть вопросы, ведь не все пишут и читают из Kafka регулярно, для кого-то это инструмент, работающий, например, чтобы собрать годовой отчёт в налоговую. Мы не можем быть уверены, что вытащим все зависимости и обращения к Kafka.

А может, просто всё логировать на брокере?

Такую идею точно кто-то предлагал: мол, включим логи через log4j. Чтобы не упереться в iops’ы диска, сформируем их в UDP-пакеты отправим их на соседний сервер, и пусть он всё анализирует.

Звучит просто, но на деле не работает. Почему? Потому что log4j внутри однопоточный и все операции всего брокера будут упираться в этот поток. Моментально вы получите 100% загрузку IO-тредов, каждый из которых упрётся в логирование. Дальше растёт Request Queue, данные перестают туда помещаться, и брокер перестаёт обрабатывать и запись, и чтение.

А что, если собирать данные где-то посередине?

Вариант поставить проксирующий сервер мы отбросили сразу. Kafka-брокеры у нас находятся на типовых серверах, как и большинство всей инфраструктуры. Эти сервера равномерно распределены по стойкам, рядам и ЦОДам. И поставить перед каждым брокером что-то физическое — абсолютно нереальная задача.

Пробовали мы смотреть в сторону зеркалирования трафика средствами сети. Или построение маршрута через дополнительные балансировщики. Всё это давало очень большой оверхед по утилизации сети. И добавляло 5-10 ms latency в q99. Нам не подходило.

Ну а что, если за пределы серверов, на которых подняты брокеры, не выходить, а написать, например, ebpf-программку, собирающую нужные нам данные? Тут мы натолкнулись на ряд сложностей. Начиная от того, что подсунуть ebpf-пробу в JVM — та ещё задачка. И заканчивая тем, что брокеры могли начать «пухнуть по памяти» из-за того, что, чтобы разобрать Kafka-протокол, нам нужно собрать целый пакет. А значит, мы должны хранить в памяти кадры тех пакетов, которые ещё не пришли полностью. Если начинаются любые задержки в сети или клиенты обрывают соединения, то таких «обрывков» накапливается очень много. Ну или мы начинаем их дропать, что тоже может быть опасно.

Ну а что, если собирать данные там, где они точно уже есть?

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

Упрощённо: основной поток обработки внутри Kafka можно представить как пул network threads, которые забирают данные из сокета и возвращают их обратно. И пул IO threads, которые делают основную обработку: сжатие/разжатие, конвертацию к нужной версии протокола, чтение и запись данных и т.д. Между ними есть Request и Response queue. Собственно, настройка количества первых и вторых тредов, а также размеров этих очередей — один из основных способов подстройки кластера под вашу нагрузку и используемое железо/виртуалки.

Внутри кода Kafka существует центральный метод, через который проходят все клиентские операции. Этот метод находится в классе KafkaApi и называется KafkaApi.handle(). Мы дописали туда свой компонент и назвали его PerIPCollector. Он собирает минимально необходимую нам информацию: IP клиента, тип операции и счётчик. Благодаря тому, что количество возможных IP (v4), умноженное на количество возможных операций (~20), — это конечное и не слишком большое число, то общий объём памяти ограничен сверху, т.е. не может бесконечно «распухать». Эти данные мы кладём в hashMap, которая чистится по TTL.

После успешной обработки запроса данные о нем попадают в отдельную очередь, из которой уже вставляются в hashMap. И размер этой очереди мы ограничиваем, чтобы не упереться в это место. Это сделано на случай, если мы по какой-то причине не будем успевать вставлять в hashMap. Тогда данные просто «прольются», но не вызовут деградации обработки. А чтобы не нагружать диск, мы сбрасываем на него данные счётчиков (содержимое hashMap) по таймеру.

После этого остаётся только построить снаружи систему, которая матчит IP на конкретные сервисы и составляет карту связности клиентов и топиков. На старте мы сделали это парой Python-скриптов, а позже переработали в сервис, который постоянно собирает эти данные, сохраняет в удобном виде и выдаёт по gRPC API.

К сожалению, даже такой подход не даёт 100% точности. Во-первых, мы собирали на протяжении некоторого окна, в которое могут не попасть какие-то операции, например, с DLQ (dead leater queue) топиками. То есть такими, куда попадают сообщения, которые не смог обработать основной код и к их обработке придётся вернуться вручную.

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

Переключаем клиентов

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

Клиентам теперь нужно перейти на другие эндпоинты, где работает авторизация. Раньше был один порт без авторизации — условно, 9092, а теперь рядом — отдельный порт с авторизацией, куда должны направляться новые подключения. На них, напомню, со стороны Kafka висят разные листенеры. Такой подход нужен, чтобы обеспечить плавный переход без одновременного обрыва всех клиентов.

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

Чтобы бизнес продолжал работать при переходе, все права должны быть корректно настроены заранее. Но вы помните: мы собрали права неидеально — возможны упущения и ошибки в правах.

Другой момент: несмотря на все нагрузочные тесты, у нас не было полной уверенности, что если всем одновременно включить авторизацию, то мы не словим какой-либо деградации на уровне Kafka-кластера, IAM-системы или ещё где-то.

Поэтому мы приняли решение не дёргать «рубильник» со своей стороны, а попросить команды, отвечающие за различных клиентов, контролируемо включить походы с авторизацией. А команд, напомню, сотни. И если мы, как ответственные за эксплуатацию Kafka, хотим это контролировать, нам нужны инструменты наблюдения. Для этого, в нашей платформенной обёртке, мы решили внедрить дополнительную метрику: метод аутентификации и авторизации, которым пользуется клиент.

Эти метрики мы собираем через Prometheus. А на базе них наши коллеги из информационной безопасности написали специальный сервис — внутренний инструмент, условно назовём его AuthWatcher. Который собирает данные о клиентах, определяет, кто ходит с авторизацией, а кто без, и автоматически создаёт задачи в таск-трекере на ответственных за те или иные сервисы.

Автоматизация настроена: метрики собираются, задачи ставятся автоматически. Но понятно, что сам процесс миграции займёт время. Кто-то закрывает такие задачи в тот же день, кто-то — в течение недели, а кто-то — только через месяц. А где-то автоматика даёт сбои и ставит задачу не на того ответственного или так, что она теряется в общем списке.

Но самое главное, что не все клиенты обладают нужными нам метриками. Напомню, что существуют коробочные продукты, где такие метрики внедрить было слишком трудоёмко. Да и не про все мы знали.

Чтобы выявить тех, кто так и не перешёл на авторизацию, мы используем тот же механизм, который добавили внутрь Kafka на предыдущем этапе. Изначально метрик было слишком много, чтобы по ним делать выводы. Но когда 99% клиентов уже перешли, мы можем использовать оставшихся. И мы добавили анализ таких походов и запись в отдельный Kafka-топик всех, кто пришёл в листенер без аутентификации. Оттуда можно прочитать и проанализировать, кто остался.

Классно! Мы построили конвейер. Но как заставить всех им пользоваться? Очевидно, что тут нужна большая административная работа. Вообще-то, я менеджер, и изначально моя статья должна была быть о том, как всех пинать. Но меня попросили также рассказать, что происходит под капотом — с технической точки зрения. И вот мы с вами оказались тут :)

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

Всё, о чём мы говорили до этого момента, — это всё ещё только подготовка инфраструктуры. Мы пока не дошли до производительности в миллионы запросов в секунду. Но сейчас дойдём.

Гарантия доставки и потери пакетов

Помните схему, где создаётся сессия, а затем по отдельным запросам извлекаются роли из принципала (KafkaPrincipal). Так вот, если в момент получения ролей TCP-соединение устанавливается слишком долго, по любой причине, то клиент может потерять доступ на всё время своей жизни. То есть, как правило, до перезапуска пода. Как это происходит?

Напомню, что получение JWT-токена и создание OAuthKafkaPrincipal (1) происходят в начале сессии и повторяются раз в несколько часов или при переподключениях клиента. Получение ролей (2) происходит при первом запросе в рамках сессии. Ну а последующие запросы в рамках сессии (3) не требуют перезапроса ролей и валидируются данными, которые уже лежат в OAuthKafkaPrincipal.

У клиента (1) есть свой тайм-аут — время ожидания ответа, после которого он считает, что запрос утерян. А у Strimzi, в свою очередь, тоже есть время, за которое мы должны получить данные о ролях (2), и это время строго меньше тайм-аута клиента. Если тайм-аут вышел, то, по принципу наименьших привилегий, мы откажем клиенту в доступе.

Можно подумать, что клиент просто попробует создать сессию снова при следующем запросе (3). Но для клиента это выглядит как перманентная ошибка (retryable = false), которую не имеет смысла пытаться решить ретраем. Он просто помечает себе, что тут доступа нет и дальше не пытается сделать новый запрос. Если бы это было иначе, то клиенты без доступа постоянно нагружали бы кластер и сеть.

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

Представьте, у какого-то сервиса работает, допустим, 50 подов (реплик в K8s). Каждый из них читает свою партицию в Kafka. И тут по какой-то причине остаётся только 49 подов на 50 партиций топика. В худшем случае, мы можем попасть в цикл отказа по принципу «десяти негритят»: одному «негритёнку» достаётся две партиции, он не справляется с нагрузкой, падает по памяти, и всё сыпется. Эта ситуация становится особенно опасной, если в коде никто не ожидал такого поведения — и не предусмотрел защиту.

Но ещё опаснее — потерять доступ экземпляру сервиса, который должен писать в Kafka. Такое может привести даже к потере данных.

Спасение? Или новая точка отказа?

Мы пытались улучшать систему и её стабильность различными способами. Но в итоге решили сделать это радикально: на том же самом кластерном узле (на той же машине), где крутится Kafka-брокер, запустить дополнительный сервис, который будет локально кэшировать у себя роли. Так как в памяти у этого сервера уже лежат роли внутри Strimzi, то мы никак не ухудшим безопасность. Зато можем значительно поднять стабильность — получить ещё одну девятку.

Мы добавляем прослойку, которая умеет локально запрашивать роли, делает это часто и стабильно, разгружает основную систему авторизации и позволяет получать роли в нужный момент, не мешая основным процессам. Ну а чувствительные запросы — такие как проверка подписи — прозрачно проксирует. Мы назвали этот прокси-демон Kafka offline permissions.

Красные стрелки — как было раньше: Strimzi напрямую ходит в IAM-систему. Зелёные стрелки — мы добавляем прослойку:

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

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

Мы получаем:

  • быстрый запуск брокера. Если брокер упал и стартует с нуля, мы можем использовать локальные данные с диска и не ждать, пока все внешние системы ответят, и подняться в максимально короткие сроки. Повторюсь, TTL абсолютный и не будет такого, что мы выдали права, которые отобрали до отключения брокера, если их время уже истекло;

  • надёжность при авариях. Раз уж мы сделали промежуточный слой, логично было встроить в него и инструменты для эксплуатации. Например, возможность вручную задать роли, если штатная система недоступна. Конечно, это делается не напрямую, а только с подтверждением и через специальный механизм выпуска токена и аппрува от ИБ. Но при инциденте, когда всё рушится, это даёт возможность быстро восстановить права. Ну и, конечно, есть способ сброса кэша, если права нужно отобрать срочно;

  • мониторинг и метрики. Мы добавили метрики по числу ролей, частоте обращений за ролями и прочую техническую телеметрию, чтобы понимать, что именно происходит внутри и где могут быть узкие места. И алертинг, если что-то идёт не так.

Эксплуатация и обновление Kafka offline permissions

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

К счастью, мы точно знаем, что весь сервер работает под управлением Linux, а значит, мы можем использовать те инструменты, которые он предоставляет, и не придумывать ничего особенного. А именно:

  • мы прикрутили к слушающему сокету опцию SO_REUSEPORT — она позволяет открыть несколько слушающих сокетов на одном порту, и входящие соединения будут распределяться между этими сокетами самим ядром;

  • сам процесс прокси-демона ушёл под управление systemd с нотификацией о состоянии готовности;

  • перечитывание конфигурации сделано через отправку сигнала процессу и не требует остановки работы.

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

Про режимы работы

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

И для этого мы добавили два «режима работы» (в довесок к штатному), которые управляются через конфигурацию (конечно, на лету):

  • proxy-all — мы можем перевести демона в режим проксирования вообще всех запросов в принципал в случае, если обнаружили какую-то серьёзную проблему и исправить её моментально не получается;

  • allow-all — если на стороне IAM-системы зафиксирована порча данных, из-за которых принципиально всё работает корректно, но вот права не выдаются или выдаются, но не те. В таком режиме на любой запрос с валидным токеном мы отвечаем «можно всё» вне зависимости от того, что у нас сейчас хранится в табличках выданных прав (которые продолжаем обновлять в фоне, конечно).

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

И как? Работает?

У нас регулярно проходят учения по отключению целого дата-центра. Когда дата-центр отключается или возвращается в строй, все брокеры получают много новых клиентов, ровно треть от всех. И каждый новый клиент заставляет делать запросы к IAM-системе за ролями. Основная проблема: запросы делаются последовательно в каждом IO-thread. Пришёл клиент → пошли за ролями → обработали → следующий запрос. Эта операция похода за ролями блокирует конкретный IO-thread, и до получения ответа или тайм-аута он просто ждёт.

В итоге все IO-треды встают в ожидании и не могут продолжать обработку. Утилизация IO-тредов кратковременно упирается в «полку», очередь растет, брокер перестает принимать новые запросы. Это значит, что мы получаем деградацию. На практике такое занимало около двух минут.

Более того, запросы за ролями не были оптимальны — мы запрашивали столько ролей, сколько сейчас клиентов было в ожидании, и не могли всегда набрать максимально возможную пачку. Выглядело это так:

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

С появлением компонента Kafka Offline Permissions ситуация улучшилась: часть ролей кэшировалась на диске, другие агрегировались в максимально эффективные пачки, и мы сразу получили данные без лишних запросов. IO thread’ы не ждали — ответ приходил мгновенно от Kafka Offline Permissions. А вот как работали запросы к IAM-системе:

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

Дело в том, что в момент включения дата-центра запускаются не только брокеры, но и все остальные сервисы. Не все делают это мгновенно: кто-то ждёт, пока master базы переключится, кто-то наливает кэши. Синхронные запросы начинают обрабатываться так быстро, как возможно, а вот асинхронная обработка вполне может подождать несколько секунд. Поэтому в первые секунды входящих запросов почти нет. А Kafka Offline Permissions работает настолько быстро, что ничего не тормозит в самих брокерах — всё готово раньше, чем появляются реальные запросы.

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

Рубильник

И вот он — финальный рывок. К этому моменту, насколько мы знаем, все перешли на листенер на порту 9094 с SASL/OAUTHBEARER, то есть ходят в Kafka с аутентификацией.

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

Пора закрывать двери без аутентификации. Но отключить листенер можно только с рестартом брокера. И если что-то пойдёт не так, то придётся снова рестартить весь кластер. Чтобы все партиции оставались доступными и не было большого влияния из-за переезда лидеров, делать это можно только по несколько брокеров за раз. Причём каждая пачка должна находиться в одном дата-центре. Иначе точно какая-то партиция попадёт двумя репликами на два одновременно перезагружаемых брокера, а это остановка записи (с настройкой min.insync=2 и RF=3). В общем, операция небыстрая и небезболезненная.

Зато гораздо проще можно закрыть порт средствами, внешними по отношению к брокеру. Например, через iptables. И сделали мы это вот так:

iptables -A INPUT -i lo -p tcp -m tcp --dport 9092 -j ACCEPT

iptables -A INPUT -p tcp -m tcp --dport 9092 -j DROP

У нас есть набор локальных тулов для инженеров эксплуатации, и мы пока оставили их на хорошо изученном и безопасном порту 9092, но ходят они только внутри одного хоста. Одно правило нужно для работы таких тулов через 9092, а другое режет всё остальное, что летит на 9092. Выбран вариант с -j DROP. т.е. отброс пакета без уведомления отправителя каким-либо способом. Это применяется как к уже открытым соединениями, так и к новым.

И вот представьте, спустя долгие месяцы подготовки, команда инженеров собралась на звонок. Как в голливудском фильме — кто-то дал обратный отсчёт. Капля пота стекала со лба главного архитектора проекта, слеза радости у инженера по информационной безопасности. Катили, конечно, через ansible постепенно: один брокер, один дата-центр и весь кластер.

— Метрики — в порядке, аффекта на брокер нет. Катим дальше.

— Готовлю плейбук на первый дата-центр. Поехали.

— Я вижу какие-то ошибки в логах.

<...>

— Всё хорошо, нашли владельцев, они забыли отключить легаси-процесс. Реального аффекта нет.

— Выкатываем на весь кластер. И…

<...> Минута напряжённого молчания. Все вглядываются в метрики, логи. Смотрят, нет ли обращений. <...>

— Запуск состоялся. Всем спасибо. Когда-нибудь я выйду на пенсию и напишу об этом книгу напишу об этом на Хабр.

Выводы

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

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

1.     Технические проблемы всегда связаны с организационными. Это важный момент, который я повторяю сознательно. Ещё несколько лет назад я этого не понимал.

2.     Любое узкое место становится критичным при масштабировании. На этапе разработки его можно не заметить, но в бою оно обязательно «стрельнёт». Особенно в системах с большим количеством клиентов и высоким трафиком.

3.     Не каждая система, которая «production-ready», действительно готова к продакшн-нагрузкам. Это вывод, к которому я прихожу уже не в первый раз — в проектах с разной тематикой и разным стеком. Уровень зрелости системы виден только в момент реальных изменений и нагрузок. А любые абстракции текут, даже если библиотеки делали настоящие профессионалы.

Комментарии (2)


  1. pnmv
    26.06.2025 06:29

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

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


  1. XmaksvellX
    26.06.2025 06:29

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