Что делать, когда инфраструктура, сдерживавшая злоумышленников, внезапно начинает мешать легитимным клиентам? В Dodo Payments такой момент наступил в 23:00 в четверг — и стал точкой невозврата. В новом переводе от команды Spring АйО подробно разберем переход с классического ingress-контроллера на полноценный service mesh. Миграция заняла 11 недель и навсегда изменила подход к платформенной инженерии.
Всё началось с сообщения в Slack в 23:00 в четверг:
«API возвращает 429 для легитимных пользователей. Пострадал крупный клиент»
Наш механизм ограничения запросов делал именно то, что мы ему задали — и в этом была проблема.
Контекст: наша инфраструктура до Istio
Мы — Dodo Payments, компания, создающая платёжную инфраструктуру. Наша платформа обрабатывает транзакции для бизнеса в разных странах — от платёжных ссылок и подписок до выплат.
Наша архитектура выглядела так, как можно ожидать от современной платформы на базе Kubernetes:
Интернет → Cloudflare → Nginx Ingress → Сервисы → Базы данных
Cloudflare обеспечивал защиту от DDoS и терминирование SSL-сессий на периметре. Контроллер Nginx Ingress направлял трафик внутрь нашего кластера Kubernetes. Отдельные сервисы реализовывали бизнес-логику.
Эта схема хорошо служила нам на этапе раннего роста. Nginx — проверенное решение с отличной документацией и большой экосистемой. Мы могли настраивать маршрутизацию, вводить базовые ограничения запросов и обрабатывать SSL без особых сложностей.
Но платёжные платформы сталкиваются с уникальными вызовами. Мы не обслуживаем статичный контент и не запускаем типичное SaaS-приложение. Каждый запрос может инициировать движение денежных средств. Требования к безопасности очень высоки. Атаки — изощрённые. И цена ошибки измеряется в реальных деньгах — наших и наших клиентов.
С ростом масштаба три проблемы становились всё серьёзнее.
Проблема №1: Парадокс ограничения запросов
Платёжные API — это излюбленная цель для злоумышленников. Атаки на подбор карт, перебор учётных данных, автоматизированные сканирования — мы видели всё. Ограничение запросов — не просто полезная мера, а вопрос выживания.
С Nginx у нас был один основной инструмент: ограничение по IP-адресу.
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;Комментарий от Михаила Поливаха
Это стандартный рейт лимитинг в nginx с использованием абстракций, семантически реализующих Leaky Bucket алгоритм.
Вообще в рейт лимитинге http запросов два самых популярных алгоритм рейт лимитинга - leaky bucket и token bucket. Попробуйте кстати в Java реализовать их самостоятельно. Увлекательное и в целом довольно простое упражнение.
В случае nginx создается зона (в семантике алгоритма - bucket), и дальше мы говорим, что запросы по таким-то критериям (чаще всего по IP Address, по ключу доступа в заголовке и т.д.) попадают в такой-то bucket (то есть в зону). И дальше уже по алгоритму.
Выглядит просто. Но здесь всё и начинает рушиться.
Проблема корпоративной сети
Один из наших крупных клиентов запускал интеграцию из корпоративного офиса. Пятьдесят разработчиков, работающих с нашим sandbox API, все выходили в интернет через один и тот же публичный IP. В результате — они регулярно упирались в лимиты даже при обычной разработке.
Мы увеличивали лимиты для их диапазона IP. А через месяц другая компания сталкивалась с той же проблемой.
Проблема распределённых атак
Тем временем злоумышленники использовали тысячи IP-адресов через резидентные прокси-сети. Наши ограничения по IP были для них бесполезны. Пока мы успевали заблокировать один адрес, они уже переключались на следующий.
Проблема легитимных пиков
Некоторые клиенты имеют вполне обоснованные всплески трафика. Например, SaaS-компания, проводящая биллинг в конце месяца, может за короткое время отправить тысячи запросов. Это не злоупотребление — это их бизнес-модель. Но наш лимитер не умел различать такие случаи.
Что нам было действительно нужно — это ограничение запросов на основе идентичности, а не сетевой топологии:
Аутентифицированные пользователи должны получать индивидуальные квоты
Разные тарифы клиентов — разные лимиты
Отдельные, высоконагруженные эндпоинты — отдельные правила
Неизвестные/неаутентифицированные запросы — консервативная обработка
Комментарий от Михаила Поливаха
На эту тему кстати есть интересные материалы от Cloudflare и в целом ресерчи. Если интересно - напишите, пожалуйста, в комментариях. Мы учтем.
С помощью Nginx это можно реализовать через Lua-скрипты и внешние хранилища данных. Мы попробовали. Но код быстро стал хрупким, трудным для тестирования и пугающим в обслуживании. Каждое изменение в логике ограничения запросов ощущалось как обезвреживание бомбы.
Проблема №2: Разрастание механизмов аутентификации
Наши требования к аутентификации развивались быстрее, чем успевала адаптироваться инфраструктура.
У нас были:
API-ключи для серверных интеграций
JWT-токены для пользовательского дашборда и административного интерфейса
Подписи webhook’ов от платёжных провайдеров
Внутренние сервисные токены для взаимодействия между сервисами
Каждый из этих методов имел свою собственную логику валидации, собственные схемы извлечения заголовков и собственные типы сбоев.
С Nginx часть аутентификации обрабатывалась на уровне ingress, а часть в коде приложений. Это разделение было непоследовательным. Одни сервисы проверяли JWT самостоятельно, другие полагались на ingress. Когда возникала необходимость внести изменения в логику аутентификации, приходилось править сразу в нескольких местах — и надеяться, что ничего не упустили.
Хуже того, не существовало единого источника правды, чтобы ответить на простой вопрос:
«Какая аутентификация требуется для этого эндпоинта?»
Конфигурация была разбросана по конфигам Nginx, коду приложений и middleware. Аудит был мучительным. Ошибки — легко допустить и сложно обнаружить.
Проблема №3: Слепая зона в наблюдаемости
Когда происходили сбои, мы с трудом могли ответить на элементарные вопросы:
Какова p99-задержка для аутентифицированных и неаутентифицированных запросов?
Какие клиентские аккаунты генерируют наибольшее количество ошибок?
Как задержка распределяется по географическим регионам?
Какой объём запросов на этот конкретный эндпоинт за последний час?
Nginx предоставляет access-логи. Это хорошее начало. Но чтобы превратить их в полезную аналитику, нужен полноценный пайплайн: парсинг, обогащение, агрегация, визуализация.
Мы подключили Prometheus для сбора метрик с Nginx, но эти метрики были слишком общими. Мы знали общее количество запросов, но не могли разрезать данные по измерениям, важным для нашего бизнеса.
Нам нужны были дашборды, отображающие:
Паттерны запросов по тарифам клиентов
Частоту ошибок по категориям эндпоинтов
Распределение задержек в зависимости от метода аутентификации
Построить такую систему на базе логов Nginx было теоретически возможно, но потребовало бы серьёзных усилий по разработке собственного инструментария.
Переломный момент
Инцидент в четверг вечером заставил нас начать разговор, которого мы долго избегали.
Один из легитимных клиентов попадал под ограничение запросов, потому что использовал тот же диапазон IP-адресов, что и другой клиент, проводивший интенсивные интеграционные тесты. Оба клиента были платными. Ни один не нарушал правил. Но наша инфраструктура не могла их различить.
Мы временно решили проблему, добавив allowlist по IP и клиентские исключения. Но было очевидно — так дальше продолжаться не может. Каждое новое исключение превращалось в очередное условие в конфигурации. Всё становилось всё более запутанным и трудным в обслуживании.
Нам нужен был принципиально другой подход.
Появляется Service Mesh
Service mesh — это выделенный инфраструктурный слой, предназначенный для управления взаимодействием между сервисами. Вместо того чтобы каждый сервис реализовывал собственную сетевую логику (повторы, таймауты, circuit breakers, авторизацию), эти функции переносятся на прокси, который работает рядом с сервисом.
Доминирующий паттерн — использование sidecar-прокси: каждый pod получает дополнительный контейнер (обычно это Envoy), который перехватывает весь сетевой трафик. Эти прокси управляются централизованной контрольной плоскостью, которая распространяет конфигурацию.

Ключевое осознание: сетевая логика становится частью программируемой инфраструктуры, а не приложения.
Мы рассмотрели несколько решений: Istio, Linkerd и Consul Connect. Победил Istio, потому что:
Envoy в качестве data plane: надёжный, расширяемый, с отличной поддержкой рейт лимтинга
Гибкое управление трафиком: VirtualServices и DestinationRules дали нам нужную степень контроля над маршрутизацией
Модель безопасности: встроенные mTLS, валидация JWT и политики авторизации
Экосистема: тесная интеграция с Kubernetes и активное сообщество
Подробности: как Istio решил наши проблемы
Многомерное ограничение запросов
Это стала ключевая функция для нас.
Istio сам по себе не реализует ограничение запросов — он делегирует эту задачу внешнему сервису ограничения, встроенному в Envoy. Но именно в этом заключается огромная мощь подхода.
Архитектура работает следующим образом:

Вся магия — в дескрипторах. Вместо простого ограничения по IP, вы определяете иерархию атрибутов, которые извлекаются из каждого запроса:
actions:
- request_headers:
header_name: "x-user-id"
descriptor_key: "user_id"
- request_headers:
header_name: "x-customer-tier"
descriptor_key: "tier"
- remote_address: {}Из каждого запроса извлекаются ID пользователя, тариф клиента и IP-адрес. Затем сервис ограничения применяет правила на основе этих дескрипторов:
descriptors:
- key: user_id
rate_limit:
requests_per_unit: 100
unit: second
- key: tier
value: "enterprise"
rate_limit:
requests_per_unit: 500
unit: second- key: tier
value: "starter"
rate_limit:
requests_per_unit: 50
unit: secondТеперь наша система ограничения запросов действительно имеет смысл:
Каждый аутентифицированный пользователь получает своё собственное «ведро»
Клиенты на тарифе Enterprise получают более высокие лимиты, чем пользователи на тарифе Starter
Неизвестные запросы по-прежнему ограничиваются по IP — с консервативными настройками по умолчанию
Проблема корпоративной сети? Решена. У каждого разработчика — свой пользовательский ID, и они не конкурируют за общую квоту.
Проблема распределённых атак? Стало значительно сложнее. Злоумышленникам теперь нужны валидные учётные данные, а не просто новые IP-адреса.
Проблема легитимных пиков? Теперь мы можем назначать клиентам соответствующие тарифные уровни в зависимости от их нужд.
Валидация JWT на периметре
Istio может выполнять валидацию JWT-токенов ещё до того, как запрос попадёт в ваши сервисы. Это кажется простой функцией, но на практике имеет серьёзные последствия.
jwtRules:
- issuer: "https://auth.example.com"
jwksUri: "https://auth.example.com/.well-known/jwks.json"
outputClaimToHeaders:
- header: "x-user-id"
claim: "sub"
- header: "x-user-role"
claim: "role"Когда приходит запрос с валидным JWT:
Envoy запрашивает ключи подписи с JWKS-эндпойнта (и кэширует их)
Проверяет подпись и срок действия токена
Извлекает claims и преобразует их в заголовки
Передаёт запрос с обогащёнными заголовками дальше
Ваши бэкенд-сервисы получают, например:
x-user-id: user_123 и x-user-role: admin— и могут безоговорочно доверять этим заголовкам. Не требуется библиотека для JWT, не нужен код для верификации подписи — всё это обрабатывается на уровне ingress.
А если токен недействителен — запрос блокируется на периметре и даже не доходит до приложения.
Политики авторизации как код
Именно здесь безопасность становится аудируемой.
apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
name: admin-access
spec:
rules:
- to:
- operation:
paths: ["/admin/*"]
when:
- key: request.headers[x-user-role]
values: ["admin", "superadmin"]
- to:
- operation:
methods: ["GET"]
paths: ["/api/public/*"]Эта политика говорит следующее:
Доступ к путям вида
/admin/*разрешён только при наличии заголовкаx-user-roleсо значениемadminилиsuperadminДоступ к путям
/api/public/*с методом GET разрешён без ограничений
Когда кто-то задаёт вопрос: «Кто имеет доступ к административным эндпоинтам?», вы просто показываете этот YAML-файл. Он находится под версионным контролем, проходит код-ревью в pull request’ах и применяется единообразно во всём кластере.
Сравните это с прежним подходом: логика авторизации была раскидана по конфигам Nginx, middleware и коду сервисов.
Ресурс AuthorizationPolicy стал для нас единым источником истины.
Наблюдаемость, которая действительно помогает
Прокси Envoy по умолчанию собирают подробную телеметрию:
Метрики запросов: количество, гистограммы задержек, коды ответов — с разбивкой по источнику, получателю и методу
TCP-метрики: количество соединений, объём переданных данных, длительность соединения
Метрики контрольной плоскости: статус синхронизации конфигурации, срок действия сертификатов
Но главное, что стало поворотным моментом для нас: метрики Envoy включают заголовки запросов как измерения.
Мы помечаем запросы тегами: тариф клиента, метод аутентификации, бизнес-категория. Prometheus собирает эти метрики. Grafana позволяет строить дашборды с нужными срезами:
«Покажи p99-задержку для enterprise-клиентов на эндпоинте оплаты»
«Какой процент ошибок у webhook’ов по сравнению с API-запросами?»
«Как распределяется трафик по тарифным планам за последнюю неделю?»
Эти вопросы больше не требуют «построить собственный пайплайн лог-анализа» — достаточно написать PromQL-запрос.
Распределённый трейсинг практически достался бесплатно. Envoy автоматически передаёт trace-заголовки. Подключите Jaeger или Zipkin — и вы получите полный трассинг запросов через все сервисные границы.
Путь внедрения
Мы не нажали кнопку и не перешли на Istio за одну ночь. Вот как мы подошли к миграции.
Этап 1: Shadow Mode (2 недели)
Мы развернули Istio рядом с существующим Nginx ingress. Оба принимали трафик, но источником истины оставался Nginx. Istio работал в режиме наблюдения — мы отслеживали метрики, тестировали конфигурации и осваивали новую операционную модель.
Ключевые уроки:
Модель конфигурации Envoy сильно отличается от Nginx. То, что делалось просто в Nginx (например, маршрутизация по regex), в Istio требует других подходов.
Потребление ресурсов оказалось выше ожидаемого. Отдельные sidecar-прокси Envoy легковесны, но при масштабировании их суммарная нагрузка ощутима.
istioctl analyze стал нашим лучшим инструментом — он помогал находить ошибки в конфигурациях до их выката в прод.
Этап 2: Некритичные пути (3 недели)
Мы начали миграцию с внутренних инструментов и неключевых эндпоинтов. Административная панель, внутренние API, окружения для разработки — всё то, сбои в чём не повлекли бы проблем для клиентов.
Этот этап помог выявить ряд нюансов и крайних случаев:
Некоторые сервисы ожидали заголовки в определённом формате, который Envoy нормализовал иначе
Требовалась настройка таймаутов — стандартные значения Envoy были более агрессивными, чем в Nginx
Эндпоинты для health check'ов потребовали отдельной обработки, чтобы избежать требований к аутентификации
Этап 3: Продуктовый трафик (4 недели)
Мы мигрировали продуктовый трафик поэтапно, используя разделение трафика. Начали с 1%, наблюдали за метриками, увеличили до 10%, снова проверили — и так далее.
Комментарий от Михаила Поливаха
В более общем плане такой тип раскатки называют "Canary Deployment" - ситуация, когда на инфраструктуру представляющую собой новый релиз, маршрутизируется лишь небольшая часть трафика, чтобы не ловить масштабных инцидентов.
route:
- destination:
host: api-service
weight: 90
- destination:
host: api-service-istio
weight: 10Постепенный rollout выявил проблемы, которые не обнаружились при тестировании:
Некоторые клиенты отправляли некорректные заголовки, которые Nginx принимал, а Envoy — отклонял
Некоторые провайдеры webhook использовали нестандартные шаблоны повторных попыток, которые срабатывали на лимиты
Задержки по регионам варьировались — Envoy добавлял несколько миллисекунд, что имело значение в отдельных сценариях
Этап 4: Полная миграция (2 недели)
Когда 100% трафика пошло через Istio, мы оставили Nginx в роли запасного варианта ещё на две недели. После этого полностью его отключили.
Общий срок миграции: примерно 11 недель от первого развёртывания до полного перехода.
Наши ошибки
Чрезмерное усложнение лимитов
Наша первая конфигурация ограничения запросов была слишком сложной. Мы задали разные лимиты для каждой комбинации: эндпоинт, тариф клиента, метод аутентификации, время суток. Это стало невозможно анализировать и поддерживать.
Мы радикально упростили всё: три тарифа (starter, growth, enterprise), несколько категорий эндпоинтов (обычные, высоконагруженные, чувствительные) и чёткое поведение по умолчанию. Сложность снизилась, предсказуемость выросла.
Игнорирование сценариев отказа
Сначала мы настроили систему так, чтобы при недоступности Redis все запросы отклонялись (fail closed). Это казалось безопасным вариантом.
Затем во время деплоя произошёл кратковременный сетевой сбой Redis. В течение 30 секунд мы отклонили все запросы к нашему API. Включая подтверждения платежей. Плохой сценарий.
Теперь мы используем стратегию fail open для большинства эндпоинтов (разрешаем трафик, если система лимитов недоступна), и fail closed — только для действительно чувствительных операций. Это вопрос модели угроз: злоумышленник, использующий короткий сбой Redis, наносит меньше вреда, чем мы, блокирующие легитимные платежи.
Недооценка кривой обучения
У Istio есть несколько уровней абстракции, на усвоение которых требуется время:
VirtualService: правила маршрутизации HTTP-трафика
DestinationRule: балансировка нагрузки, настройки соединений, обнаружение аномалий
Gateway: Ingress/egress конфигурация
ServiceEntry: описание внешних сервисов
EnvoyFilter: прямые патчи конфигурации Envoy
AuthorizationPolicy: правила управления доступом
RequestAuthentication: правила валидации JWT
У каждого ресурса — своя семантика, свои крайние случаи и собственные ошибки. Мы допускали промахи, которые были бы очевидны при наличии большего опыта.
Закладывайте больше времени на обучение, чем кажется необходимым.
Результаты
Шесть месяцев спустя после миграции — вот где мы находимся:
Ограничение запросов стало осмысленным: жалобы клиентов на лимиты сократились на 80%. Мы блокируем больше злоупотреблений, затрагивая при этом меньше легитимных пользователей.
Улучшена безопасность: вся аутентификация происходит на периметре. Политики авторизации доступны для аудита и находятся под версионным контролем.
Наблюдаемость кардинально изменилась: у нас теперь есть дашборды, которые действительно отвечают на бизнес-вопросы. MTTR (среднее время устранения инцидентов) в продакшене значительно сократилось — мы быстрее находим источник проблем.
Улучшился опыт разработчиков: новые сервисы автоматически получают ограничения, аутентификацию и наблюдаемость. Командам не нужно реализовывать сетевую логику — они получают её из платформы.
Возросла операционная нагрузка: это издержки. Управлять Istio сложнее, чем Nginx. Мы вложились в обучение, документацию и инструменты. Контрольную плоскость нужно мониторить, обновления требуют внимательного планирования.
Стоит ли вам переходить?
Service mesh подходит не всем. Вот как стоит подойти к оценке:
Istio имеет смысл, если:
Вам нужно сложное управление трафиком (ограничения по идентичности, разделение трафика, circuit breakers)
Безопасность и соответствие требованиям критичны для бизнеса
У вас несколько команд, разворачивающих сервисы, и вы хотите единые сетевые политики
Пробелы в наблюдаемости реально мешают работе
Вы готовы инвестировать в обучение и поддержку дополнительной инфраструктуры
Оставайтесь с Nginx, если:
Ваши требования к маршрутизации просты
Достаточно ограничений по IP
У вас маленькая команда, и вы не готовы к дополнительной нагрузке
Сервисы однородны и не требуют дифференцированных политик
Eсли Nginx работает у вас сейчас и вы не сталкиваетесь с описанными проблемами, вам, скорее всего, пока не нужен service mesh. Внедряйте его тогда, когда боль станет реальной, а не когда появится ощущение, что это современно.
Для нас, с учётом требований к безопасности платёжной платформы, сложных ограничений и высоких требований к наблюдаемости, Istio оказался правильным выбором. И эта инвестиция себя оправдала.
Основные выводы
Ограничение по идентичности лучше, чем по IP — особенно для API, где есть аутентификация. Реализация сложнее, но поведение гораздо адекватнее.
Аутентификация на периметре упрощает всю внутреннюю архитектуру. Когда шлюз гарантирует достоверность заголовков, бэкенды могут им доверять без повторной проверки.
Политики авторизации как код делают безопасность прозрачной и проверяемой. Ответ на вопрос «кто имеет доступ к чему» должен находиться в YAML-файле, а не в цепочке вызовов по коду.
Fail open или fail closed — критическое архитектурное решение. Тщательно продумайте модель угроз перед выбором.
Постепенная миграция — обязательна. Shadow mode, неключевые пути, поэтапное переключение трафика — каждый шаг выявляет свои проблемы.
Операционная нагрузка реальна. Service mesh — это сложнее. Убедитесь, что выгоды оправдывают затраты в вашей конкретной ситуации.
Мы строим платёжную инфраструктуру в Dodo Payments. Если вам интересны распределённые системы, финтех и платформенная инженерия — мы нанимаем.

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