Один вход для всех: как мы строили Gateway и выходили из хаоса nginx + Lua
- четверг, 2 апреля 2026 г. в 00:00:03
Всем привет, меня зовут Кирилл Вересников, я бэкенд-разработчик в iSpring.
Мы делаем iSpring LMS — платформу для корпоративного онлайн-обучения. Исторически это был модульный монолит на PHP, а затем система начала постепенно дополняться микросервисами. Самые нагруженные и часто меняющиеся части мы выносили из монолита, а новый функционал всё чаще сразу делали в микросервисах.
При этом маршрутизация продолжала жить по старому. Чтобы вынести очередной сервис или добавить новый, нужно было идти обратно в монолит и менять nginx-конфиги: добавлять правила проксирования, разбираться с порядком подключения, а иногда и дописывать Lua-скрипты, которые выполняли дополнительную логику до передачи запроса в сервис.
В итоге микросервисы у нас уже появлялись, а точка входа в систему оставалась прежней: сложной, с историческими правилами и всё сильнее связанной с монолитом.
Эта статья будет полезна тем, кто:
постепенно выносит части монолита в сервисы;
устал от старых nginx-конфигов, которые годами копились ради обратной совместимости;
ищет способ стандартизировать входной трафик и убрать бизнес-логику из прокси;
выбирает между nginx и envoy.
Откуда взялась проблема и почему старую схему уже нельзя было развивать.
Почему простое «вынести nginx наружу» не решало задачу.
Почему мы выбрали Envoy и Go.
Как устроен Gateway: Envoy, Gateway на Go, XDS, ext_authz и interceptors.
Как мы описываем роутинг через DSL и что всё-таки оставили на Lua.
Какие нюансы нашли в Envoy, как тестировали и как выкатывали Gateway в прод.
Какой инцидент мы поймали уже после основного релиза и чему он нас научил.
Что получили в итоге и куда хотим двигаться дальше.
iSpring LMS рос не только в коде, но и в количестве точек входа. Постепенно у нас появилось три основных типа входящего трафика:
браузеры;
публичное API;
mobile API.
Формально всё это была одна система, но на входе она выглядела как набор разных маршрутов и правил, которые накапливались годами.

Монолит принимал весь трафик, а nginx внутри него:
раздавал статику;
проксировал API;
загружал пользовательские сессии;
выполнял проверки;
исполнял часть бизнес-логики на Lua.
Поначалу это выглядело рабочей схемой. Но со временем конфиги стали расти слишком быстро. Новые правила приходилось явно добавлять в общие файлы nginx, потому что для части маршрутов был важен порядок подключения. Конфиги писались в разном стиле: где-то это были обычные location-блоки, а где-то — комбинации вроде access_by_lua_block и rewrite_by_lua_block, часть из которых определялась на верхних уровнях и переопределялась ниже. Чтобы понять, как реально отработает конкретный запрос, нужно было держать в голове и структуру nginx-конфига, и порядок фаз обработки, и поведение Lua.
Отдельная проблема — исторические конфиги. Монолитное приложение существует с 2009 года и за это время несколько раз существенно менялось. Текущая мажорная версия используется с 2019 года, но всё ещё наследует часть функциональности исходной системы, пусть и в заметно меньшем объёме. Поэтому вместе с актуальными правилами в конфигурации продолжали жить и старые, когда-то добавленные ради обратной совместимости. Спустя годы уже не всегда понятно, используются ли они до сих пор или просто остались в системе по инерции.
Ещё хуже было то, что вместе с маршрутами в nginx переезжала и логика. Например, если нужно было отдавать файл из хранилища, мы добавляли правило в nginx, а рядом Lua, который авторизовывал запрос по URL и только потом позволял отдать файл. Были и гораздо более сложные скрипты, например загрузка сессий.
Из-за этого добавление нового сервиса выглядело неприятно с обеих сторон:
у сервиса появлялась зависимость от того, как устроен входной слой в монолите;
у монолита рос объём прокси-логики, который должен знать о новых сервисах и их особенностях.
В Kubernetes это было не так заметно за счёт абстракции Service, но для on-premise поставок схема начинала приносить проблемы. Не все сервисы поставляются в таком окружении, поэтому приходилось городить дополнительные костыли и генерировать части nginx-конфигов через env.
Отдельно страдал maintenance-режим. В нашей мультитенантной системе можно закрыть на обслуживание конкретный аккаунт или целый инстанс. Но из-за нескольких точек входа этот режим работал не всегда целиком: например, браузер уже переставал пускать пользователя, а мобильное приложение и API ещё продолжали работать. Чтобы maintenance сработал корректно, его нужно было включать в трёх разных местах.
В какой-то момент стало понятно, что это уже не просто «старые конфиги, которые надо прибрать». Схема начала мешать развитию:
большая часть входящего трафика продолжала идти через монолит;
rollout монолита доходил до 40 минут, потому что nginx должен был корректно доработать долгие запросы;
Lua в составе монолита практически не тестировался: для проверки нужно было поднимать окружение целиком;
новые правила всё чаще добавлялись копипастой, а итоговое поведение становилось всё менее очевидным;
наблюдаемость на входе в систему была размазана по нескольким путям.
Нужно было не просто переписать несколько конфигов, а поменять саму точку входа.
Первой мыслью было сделать минимальный шаг: оставить nginx как есть, просто вынести его из монолита в отдельный deployment и постепенно привести конфиги в порядок.
На практике это не решало главную проблему. Мы получили бы тот же самый nginx, только в другом месте:
с теми же конфигами, где каждый мог писать почти что угодно;
с той же зависимостью от Lua;
с тем же риском, что новые правила быстро снова превратят конфигурацию в хаос;
с той же необходимостью помнить про внутреннюю механику nginx, чтобы безопасно добавить новый маршрут.
То есть «почистить конфиги» и «устранить причину, по которой она снова захламится» — это две разные задачи. Нас интересовала вторая.
Отдельно мы быстро отбросили вариант строить всю логику прямо на ingress. Для нас ingress — это слой TLS-терминации и маршрутизации до gateway, но не место, куда хочется встраивать свою предметную логику. Нам нужен был отдельный компонент со своей конфигурацией, своими правилами и своей жизнью, который можно запускать не только в Kubernetes, но и, например, в docker compose. Плюс такой подход позволяет безболезненно менять сам ingress, не переписывая прикладной роутинг.
Против nginx у нас было сразу несколько практических аргументов.
Во-первых, отказаться от Lua в нашей ситуации было нельзя. На момент начала проекта Wasm-расширения для nginx не давали того набора возможностей, который у нас уже использовался в Lua.
Во-вторых, даже если вынести nginx в отдельный deployment, мы всё равно остались бы на тех же самых конфигах с той же свободой писать их как угодно. А нам нужна была не просто новая упаковка старой схемы, а жёсткий стандарт конфигурации.
В-третьих, мы хотели динамически собирать и отдавать конфигурацию, а не жить в режиме «правим набор файлов, пересобираем и деплоим».
Envoy подошёл под наши задачи лучше, потому что давал:
динамическую загрузку конфигурации через XDS;
готовый механизм ext_authz для обработки запроса до передачи в upstream;
хорошую наблюдаемость на входе в систему;
удобную интеграцию с gRPC;
экосистему, в которой такие сценарии — нормальная, а не побочная история.
Отдельным плюсом было то, что Envoy давно стал стандартным кирпичиком для service mesh-решений. Это не было решающим аргументом само по себе, но добавляло уверенности, что мы идём по хорошо изученному пути, а не собираем экзотику.
Здесь ответ очень простой: у нас сильная экспертиза в Go, и мы любим писать на Go. Для такой инфраструктурной логики это означало:
понятный и поддерживаемый код;
нормальную типизацию;
хорошие unit- и integration-тесты;
низкий порог входа для команды.
Нам важно было не просто «куда-то вынести логику из Lua», а вынести её в среду, которую команда умеет поддерживать годами.
Gateway у нас состоит из двух контейнеров в одном pod:
Envoy — data plane, который принимает и маршрутизирует трафик;
Gateway на Go — control plane и точка, в которой живёт наша логика авторизации и модификации запросов.
Мы специально запускаем их рядом в одном pod и используем общий network namespace. Это позволяет Envoy общаться с Gateway через localhost без лишней сети между ними.

У нас есть свой DSL, которым описываются сервисы, маршруты, rewrite, interceptors и параметры фильтров. Gateway читает этот DSL, превращает его в protobuf-конфигурацию и отдаёт Envoy по XDS.
То есть цепочка выглядит так:
DSL → Gateway → protobuf/XDS → Envoy.
Это важно по двум причинам. Во-первых, разработчики сервиса работают не с сырой конфигурацией Envoy, а с коротким декларативным описанием. Во-вторых, мы сами задаём рамки, что в такой конфигурации вообще можно и нельзя делать.
Когда мы разобрали старые nginx-конфиги, стало видно, что большая часть нетривиального поведения происходит до передачи запроса в upstream:
загрузка сессий;
CSRF-проверки;
авторизация;
модификация запроса;
установка некоторых заголовков.
Для таких задач подошёл ext_authz: Envoy получает запрос от клиента, отправляет его в Gateway на проверку, а Gateway уже либо разрешает запрос и возвращает изменения, либо сразу останавливает обработку.
Мы сознательно остановились именно на ext_authz, а не на ext_proc. В наших кейсах почти вся логика нужна до передачи запроса в upstream. Сценариев, где нужно серьёзно трогать ответ, у нас немного: в основном это красивые error pages для 4xx/5xx и добавление заголовков к ответу.
Довольно быстро стало ясно, что одной большой «магической» точки с логикой нам тоже не хочется. Поэтому мы ввели свою абстракцию interceptors и разделили её на два уровня.
Это общая логика, которая нужна многим сервисам и должна жить централизованно. Например:
загрузка сессий;
проверка CSRF;
установка CSP-заголовков.
Это логика, которая принадлежит конкретным сервисам и знает их предметную область. Например:
проверка доступа к аттачам в мессенджере;
проверка доступа к новостям;
проверка доступа к контенту.
Поток запроса здесь выглядит так:
Envoy принимает запрос от клиента.
Через ext_authz отправляет его в Gateway вместе с metadata конкретного interceptor.
Gateway определяет, какой interceptor нужно вызвать.
Если interceptor внешний, Gateway пересылает запрос в соответствующий сервис по API.
Сервис возвращает ответ: разрешить запрос дальше или сразу вернуть клиенту 401/403/другой ответ.

Для транспорта внешних interceptor’ов у нас есть два варианта:
gRPC — основной путь для сервисов на Go;
REST — для тех случаев, где gRPC по каким-то причинам неудобен, например для PHP-сервисов.
При этом внешнему сервису не нужно тянуть в себя никакие зависимости от Envoy. Ему достаточно реализовать простой API-контракт: получить данные запроса и вернуть решение — пропустить дальше или остановить.
В сильно упрощённом виде это выглядит так:
Gateway -> External interceptor: - method - path - headers - path/query parameters - custom parameters из конфигурации External interceptor -> Gateway: - OK: запрос можно пропустить дальше или - Forward response: вернуть клиенту готовый 401/403/...
Такой слой позволил убрать бизнес-логику из Lua, перенести её ближе к тем сервисам, которые ей действительно владеют, и при этом сохранить единый входной контур.
Одной из целей проекта была стандартизация конфигурации. Мы не хотели, чтобы каждый следующий разработчик снова думал:
в какой nginx-файл это вставить;
в каком порядке подключаются правила;
что сработает раньше: rewrite, access или что-то ещё;
как добавить путь для нужной бизнес-логики.
Теперь базовый конфиг сервиса выглядит коротко и предсказуемо. Для обычного случая это буквально одно правило маршрутизации по pattern/prefix, target upstream и список interceptor’ов.
kind: gateway/service/v1 name: todolist routes: - pattern: ^/todolist/.*$ target: todolist:8082 rewrite: old: ^/todolist/(.*)$ new: /\1 interceptors: - type: sessionInterceptor - type: csrfInterceptor
Такой конфиг можно добавить за несколько минут, и для этого не нужно разбираться во внутренней кухне nginx или envoy.
Если сервису нужна собственная авторизационная логика, конфиг остаётся декларативным: разработчик описывает маршрут, подключает внешний interceptor и передаёт ему параметры. Всё остальное — как Gateway доставит запрос, как свяжет маршрут с interceptor’ом и как вернёт решение обратно — остаётся внутри платформенного слоя.
kind: gateway/service/v1 name: todolist interceptor: address: todolist:8081 routes: - pattern: ^/api/public/v1/task/create$ target: todolist:8082 interceptors: - type: sessionInterceptor - type: csrfInterceptor - type: external id: todolist parameters: method: authorize_create
Таких конфигураций мы в итоге перенесли больше чем для 60 сервисов. При этом около 50 конфигов удалось свести к одному простому стандарту, а ещё примерно 10 заметно упростить и унифицировать.
Полностью отказаться от Lua мы не смогли. В Envoy нет некоторых вещей, которые в nginx у нас уже использовались:
internal rewrite — когда запрос не получает 30x-редирект, а повторно проходит через маршрутизацию уже по новому пути;
error pages — перехват 4xx/5xx от upstream и возврат подготовленной HTML-страницы;
точечная доработка ответа в нескольких узких сценариях.
Для этого мы оставили минимальные Lua-фильтры. Но принципиальная разница в том, что теперь Lua — это не место, где прячется бизнес-логика системы. Это небольшой утилитарный слой с понятными точками входа envoy_on_request и envoy_on_response, который покрыт интеграционными тестами Gateway.
Когда начинаешь использовать Envoy не как «просто ещё один прокси», а как основу для своего gateway, довольно быстро находишь вещи, которые нужно дотягивать самому.
По умолчанию при получении SIGTERM Envoy резко завершал активные соединения. Для клиентов это выглядело как 502.
Решение было таким:
написали обёртку на Go;
ловим SIGTERM;
переводим Envoy в drain через admin API;
ждем завершения активных соединений;
только после этого завершаем процесс.
После этого остановка стала предсказуемой и безопасной.
Стандартный лог Envoy про Lua выглядел примерно в стиле «ошибка в Lua: ожидали не nil» — и на этом всё. Из такого сообщения непонятно:
какой именно фильтр упал;
на каком запросе это произошло;
по какому пути идти дальше в диагностике.
Мы добавили свой декоратор ошибок, который обогащает лог контекстом: имя фильтра, request id, и текст ошибки. После этого Lua-ошибки перестали быть слепой зоной.
Envoy рендерит свою конфигурацию через protobuf-схемы и умеет показывать её в admin API. Это очень помогает при диагностике.
Когда мы начали передавать параметры interceptor’ов и Lua-фильтров через metadata в виде grpc.Any, оказалось, что admin API больше не может нормально показать итоговый конфиг: Envoy просто не знает, как отрисовать неизвестный тип.
В итоге мы ушли от grpc.Any к сериализации в JSON с последующей упаковкой в structpb. После этого конфиг снова можно было смотреть и отлаживать через admin API.
Часть таймаутов Envoy по умолчанию для нас оказалась слишком агрессивной. Мы собрали статистику по запросам и вынесли настройки в DSL, чтобы задавать их на уровне сервиса или конкретного маршрута.
Например:
timeout: 1m вместо дефолтных 15 секунд;
idleTimeout: 10m, а в отдельных случаях — возможность увеличить его или отключить.
Это кажется мелочью, но на практике сильно влияет на стабильность работы длинных запросов.
Gateway — критичный компонент. Он стоит на пути каждого запроса, поэтому качественное тестирование для него обязательное условие.
Мы использовали два уровня тестов:
unit-тесты — для Go-логики, interceptor’ов, работы с Redis и внешними сервисами;
интеграционные тесты — для связки Envoy + Gateway + Lua-фильтры, чтобы проверять реальное поведение конфигурации.
Проектом занимались три core-инженера. Сначала один человек спроектировал решение, а затем реализацию делали уже командой.
Разработка и перенос заняли примерно 3–4 месяца. При этом фичевые команды продолжали жить своей жизнью и менять маршрутизацию. Чтобы старый и новый мир не разошлись окончательно, мы договорились: любые изменения в проксировании проходят через ревью со стороны команды Gateway.
Система у нас мультитенантная, поэтому включить новый роутинг сразу для всех было нельзя. В случае ошибки это означало бы массовую деградацию для всех клиентов.
Поэтому релиз шёл в два этапа.
Сначала мы выборочно переводили конкретные аккаунты на Gateway через ingress:
выделяли правило;
переключали на Gateway нужный домен или аккаунт;
проверяли, что сценарии работают корректно.
Это позволяло убедиться, что Gateway правильно обслуживает реальный трафик, но пока ещё в ограниченном количестве.
Когда Gateway уже стабильно обслуживал конкретные аккаунты, мы перешли к канареечному релизу через ingress-nginx и начали увеличивать долю трафика по процентам.
На этом этапе тоже нашлись нюансы. Например, мы столкнулись с тем, что канареечная логика ingress-nginx в некоторых случаях работала не так, как подсказывает интуиция: часть запросов могла идти в канарейку даже при 0 весе, а для некоторых сценариев приходилось явно разводить backend’ы. Это не стало блокером, но показало, что rollout через ingress-nginx тоже требует отдельной внимательности.
Во время релиза мы в первую очередь следили за тем, чтобы система вела себя стабильно и без явной деградации.
Смотрели:
потребление памяти;
задержки запросов;
задержки обращений к interceptor’ам;
количество 4xx и 5xx;
ошибки Lua;
поведение и старого пути, и нового, пока они работали параллельно.
Метрики и логи собирали как из Envoy/Gateway, так и из nginx внутри монолита, потому что на этапе миграции обе схемы должны были работать согласованно.
Отдельный практический плюс новой наблюдаемости проявился уже в эксплуатации: по метрикам Gateway стало видно, как быстро отвечают upstream-сервисы. Так мы, например, замечали аномально выросшую задержку у конкретного сервиса и могли быстро передать это дежурному на исследование.
Основной релиз Gateway к этому моменту уже давно прошёл. Система работала в проде стабильно, и мы добавляли в неё новый функционал.
В какой-то момент понадобилось добавить basicAuth для части маршрутов — для технических ручек. Задача выглядела локальной: расширить модель DSL, добавить новый фильтр и включить его там, где это нужно.
Релиз был обычным:
собрали новую версию Gateway;
обновили конфиги;
задеплоили её в прод.
Дальше произошло неприятное: проблема затронула не только маршруты с basicAuth, а вообще весь входящий трафик. Для пользователей это выглядело как случайные 401 на части запросов.
Во время rollout в кластере одновременно жили старая и новая версии Gateway. Мы ожидали, что каждая копия Envoy будет читать конфигурацию только из локального Gateway рядом с собой.
На практике в этом месте сыграли сразу два фактора:
у Gateway была оставлена возможность читать конфигурацию из двух источников;
service mesh в кластере маршрутизировал трафик между pod’ами не так, как мы предполагали, если смотреть только на Service-конфигурацию.
В результате Envoy мог получить части конфигурации не из одного, а из двух разных экземпляров Gateway. Так в одном месте смешивались старая и новая версии конфигурации. Фильтр basicAuth, который должен был быть точечным, оказывался включённым глобально и начинал влиять на весь трафик.
Первым эффективным действием стал откат gateway на старую версию. Это остановило дальнейшее распространение проблемы, но полностью ситуацию не исправило сразу, потому что часть старых pod’ов ещё продолжала жить.
После разбора стало понятно, что помогло бы добить все старые pod’ы Gateway и сделать rollout restart новой версии, чтобы конфигурация заново собралась из одного источника и выправилась.
Постоянный фикс был очень простой и очень показательный: мы убрали из конфигурации Gateway возможность читать настройки из двух мест и явно оставили только localhost.
Для меня это был важный урок из эксплуатации: в gateway нет по-настоящему локальных изменений. Даже небольшая фича на входе в систему потенциально затрагивает всех пользователей, поэтому требования к rollout, диагностике и ясности конфигурации здесь выше, чем для обычного сервиса.
Самый важный эффект для компании — скорость изменений.
После выноса маршрутизации из монолита:
rollout монолита ускорился с 40 минут до 5;
добавление новых маршрутов и правил стало быстрее и предсказуемее;
сама точка входа в систему стала проще в сопровождении.
Для команды выигрыш не менее ощутимый:
вместо набора исторических nginx-конфигов появился один стандартный DSL;
новый маршрут можно добавить без глубокого знания внутренностей прокси сервера;
бизнес-логику авторизации можно писать на Go во внешних interceptor’ах;
диагностика стала проще благодаря единой точке входа и нормальной наблюдаемости.
Если говорить совсем предметно, то переход дал нам несколько конкретных результатов:
вместо трёх основных точек входа вся маршрутизация теперь собрана в Gateway;
около 50 конфигов удалось привести к одному простому стандарту;
ещё примерно 10 конфигов мы заметно упростили и унифицировали;
rollout для критичного входного слоя сократился с 40 минут до 5;
наблюдаемость стала лучше: теперь проще видеть коды ответов, задержки и проблемы конкретных upstream-сервисов.

Следующие шаги у нас довольно прагматичные:
перестать зашивать конфигурацию в образ или ConfigMap и вынести её в отдельное хранилище, чтобы менять правила без пересборки и деплоя;
продолжать убирать исторические прослойки вроде отдельных mobile/public gateway и упрощать маршрутизацию;
дальше снижать долю Lua и оставлять его только там, где без него действительно не обойтись.
Для нас Gateway получился не просто новой технологией на входе. Это был способ наконец стандартизировать слой маршрутизации, который долго жил внутри монолита по своим правилам и мешал развивать систему дальше.