
Всем привет! Меня зовут Герман Кравец, я больше десяти лет в IT. В МойОфис работаю руководителем группы Календаря в отделе разработки Mailion — это наша отказоустойчивая корпоративная почта для крупного бизнеса.
В этой статье расскажу, как мы с командой искали новое решение для нашего API Gateway: зачем вообще понадобилось его менять, с какими проблемами столкнулись и как проходили все этапы — от первых «что-то идёт не так» до финального рефакторинга и запуска нового Gateway в прод.
Будет немного боли, немного архитектуры и чуть-чуть магии. Если вам интересно, как решать нетривиальные задачи в продуктовой разработке, где стоит использовать готовые решения, а где всё писать вручную, или просто хочется узнать, как мы сократили простои на регрессе с 4–6 часов до пары минут, — добро пожаловать под кат!
Ключевые особенности API Gateway
API Gateway — это единая точка входа для всех запросов, особенно когда в системе много микросервисов. Публиковать наружу порты каждого из них неудобно, да и с точки зрения безопасности и мониторинга это быстро превращается в хаос. Gateway решает эту проблему: он берёт на себя маршрутизацию, защиту и контроль трафика.
Помимо этого, он помогает обрабатывать и модифицировать запросы на лету. В реальных системах это происходит постоянно: запросы нужно обогащать дополнительными метаданными, информацией о пользователе или клиенте, добавлять данные для статистики и аналитики. Gateway становится универсальным фильтром, через который проходит всё взаимодействие между клиентом и микросервисами.
С его помощью также значительно упрощается аутентификация и авторизация: достаточно один раз проверить, имеет ли конкретный клиент доступ к нужному ресурсу, и дальше распространять эти права централизованно. Встроенные механизмы безопасности позволяют защитить систему от DDoS-атак, ограничить частоту запросов (Rate Limiting) и контролировать подозрительную активность.
API Gateway отвечает и за наблюдаемость: через него проходят логи, метрики и трейсы, что делает анализ работы всей системы прозрачным.
Главный плюс подхода в том, что внешний мир не зависит от внутренней архитектуры. Gateway предоставляет единый публичный контракт — по нему с нами интегрируются клиенты и смежные системы. При этом внутренняя структура может меняться сколько угодно: можно оптимизировать алгоритмы, переписывать сервисы или перестраивать связи между ними без риска что-то «сломать» для пользователя.
Это основные фишки, которые будут важны для нашей истории, а дальше расскажу, как мы использовали API Gateway.
API Gateway и мы
Так у нас выглядела архитектура до того, как мы занялись поиском нового решения.

С внешним миром API Gateway взаимодействует через HTTP и WebSockets, а внутри это набор плагинов, сгенерированных на основе прото-файлов, написанных вручную, и всей структуры системы. Когда мы начали искать новое решение, стало очевидно: плагинов накопилось прилично: раздача клиентской статики, работа с картинками, файлами, их было около восьми, не считая десятков вспомогательных. Всё общение между сервисами шло по gRPC, и таких сервисов в системе насчитывалось больше семидесяти. Их все нужно было как-то безопасно и стабильно опубликовать наружу.
Как мы пришли к жизни такой? Проект Gateway мы начали разрабатывать примерно в 2017 году. Основным веб-сервисом выбрали Caddy — тогда это был довольно мощный инструмент с гибкой системой плагинов и возможностью писать свои. Мы вручную написали шестнадцать активных плагинов, по сути, шестнадцать отдельных репозиториев с разным уровнем вложенности и зависимостей. А вдобавок создали мощный инструмент, который генерировал эти плагины из proto. Можно сказать, что это был отдельный продукт, заточенный именно под генерацию плагинов для Caddy.
Почему в своё время сделали именно такой выбор, сейчас можно только предполагать — исходный владелец сервиса уже не работает в компании, а значит, остаётся только анализировать решения по следам кода. Вероятно, решающими факторами стали несколько моментов.
Во-первых, поддержка HTTP/2: мы активно используем gRPC и gRPC-стримы, в том числе на клиентской стороне, и наличие полноценной поддержки протокола тогда было критично.
Во-вторых, модульность и возможность генерации плагинов. Инструмент действительно мощный, и такая гибкость на этапе активной разработки казалась идеальным решением.
Третий аргумент — Go-ориентированность. Наши бэкендеры все пишут на Go, ��оэтому порог входа был минимальным. Нужно добавить кастомный функционал — просто написал плагин или форкнул нужный модуль.
Ну и, наконец, раздача статики из коробки. Caddy справлялся с этим не хуже NginX, поэтому выбор выглядел вполне оправданным.
Звучит классно… Так в чём же проблема?
На практике начали всплывать проблемы — и их оказалось немало. Главная — bus-фактор: ключевые знания о сервисе ушли вместе с людьми, а владельца у компонента не осталось. Поверх этого наложились сильная связанность со статикой, самописные генераторы плагинов и аж шестнадцать дочерних репозиториев. Сборки занимали от тридцати до шестидесяти минут: каждый из репозиториев тянул за собой зависимые сборки и деплой.
Конфигурация тоже доставляла боль. Caddy первой версии использует Caddyfile, нечто вроде псевдо-YAML, и работать с ним было сложно даже для опытных инженеров.
Ситуацию усугубляли C++-зависимости, которые повышали порог вхождения в проект, замедляли скорость сборки как локально,так и в CI/CD.
Когда мы собрали всё это воедино, стало ясно, что система достигла точки, где поддерживать её дальше уже дороже, чем переписать. Мы были слегка в шоке от масштабов накопившихся проблем и поняли, что пора что-то менять.
Начало работ
На старте у нас были жёсткие ограничения по ресурсам: фичи горят, баги горят, а сверху ещё навалился огромный ком техдолга. Выделить под это отдельную команду не получилось, поэтому техдолгом занимался один backend-разработчик в низком приоритете, между коммитами в основной релиз и правками продовых багов. В помощь ему подключили DevOps-инженера — тоже не на full time, а по мере возможности.
В такой конфигурации мы решили идти двумя параллельными путями. Первый — сложный: поискать альтернативы текущему решению и оценить, во что выльется миграция.
Второй — попроще и побыстрее: разделить статику и API Gateway, чтобы хоть немного разгрузить систему и перестать таскать лишнее между сборками.
Делим статику

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

Этот пайплайн ещё и в Jenkins, в общем, очень больно.
Кроме того, любое изменение или push в одну из статик заставлял пересобираться их все и передеплоиваться полностью вместе с Gateway. То есть на регрессе, когда мы активно стабилизировали продукт, выкатывали фичи, догоняли код фриз и фича фриз, порядка 120 разработчиков фиксили баги, пайплайн на всё это триггерился и API Gateway мог лежать 4-6 часов. От этого люто страдали команды FE, BE и QA.
Мы решили отделить статику от Gateway и пошли по самому очевидному пути — взяли nginx в качестве базового образа для статики и заодно использовали его как балансировщик. Решение оказалось не только простым, но и прагматичным: nginx уже был согласован с ИБ и юристами, использовался в других командах, а значит — не требовал бюрократии.
Инструмент популярный, сообщество большое, документация понятная, и самое главное, у нас уже были все нужные компетенции. Любой разработчик мог что-то поправить, а команда поддержки кастомизировать конфигурацию прямо на площадке заказчика.
В итоге получилась архитектура, в которой впереди стоит nginx-балансировщик, за ним — API Gateway, а статики живут отдельно и разворачиваются независимо. Никаких вольюмов, никаких общих сборок — каждый компонент выкатывается сам по себе.

Результат почувствовали сразу: мы начали работать по новым пайплайнам, по новой архитектуре, и снизили время deploy с 30 до 2 минут. И боли на регрессе прекратились, потому что пайплайны выкатки стали незаметными, а время простоя API Gateway снизилось до считанных минут.
Обновляем Golang
Мы — продуктовая компания, и у нас есть собственный отдел ИБ, который проверяет все продукты на уязвимости. У коллег есть свои инструменты для анализа, но они не всегда успевают за обновлениями языков и библиотек. Поэтому разрешение на использование новой версии Go мы получаем только тогда, когда их стек готов это переварить.
В этот раз, наконец, дали добро на Go 1.21. Отлично — побежали обновлять сервисы. Всё шло по плану: обновили зависимости, подтянули библиотеки, ничего критичного не меняли, код не трогали. Локальная сборка прошла, запускаем... и сразу ловим панику.
Окей, так быть точно не должно, надо искать, где собака зарыта.
Gateway у нас построен на Caddy v1, а тот, в свою очередь, зависит от ряда библиотек.

Проблема в том, что актуальная open-source версия qTLS поддерживает максимум Go 1.15, и именно на этом уровне начинает рушиться ядро Caddy. Самое неприятное, что паника срабатывает не при компиляции, а только при запуске.
Мы пошли в отладку и довольно быстро докопались до корня: знакомьтесь, функция init() в одной из библиотек.

Внутри — проверки вроде structsEqual для нескольких структур. Если сравнить TLS из стандартной библиотеки с тем, что лежит внутри qTLS, то они совпадают буквально один в один. И сразу возникает закономерный вопрос: зачем вообще делать такое сравнение на этапе инициализации?
Дальше ещё интереснее. Ошибка, которая валится в лог, указывает на несовпадение структур. Первое, на что мы наткнулись, — различие в количестве полей.

На этом моменте стало ясно: заплатками это не вытащить. Нужно всё выносить, перепроверять зависимости и фактически перекапывать Gateway заново, чтобы перейти на новую версию Go и при этом не сломать прод.
Упрощаем архитектуру
На момент начала работы у нас было 16 репозиториев, и уровень вложенности у некоторых доходил до шести. Каждый отвечал за свой кусок плагинов и зависимостей. Казалось удобно — микросервисный подход, всё по науке. Но на практике это вылилось в настоящий CI/CD-кошмар.
Пайплайн для одного репозитория занимал около пяти минут: сборка, тесты, сканеры безопасности — полный набор. А из-за вложенности одно изменение на самом нижнем уровне тянуло за собой цепочку пересборок. В итоге простая правка одной строчки кода могла превращаться в полчаса ожидания, пока вся цепочка отрабатывает. Cкорость вывода изменений в прод страдала, а разработчики страдали вместе с ней. Даже не говоря уже о случаях, когда на инфраструктуре что-то падало и весь CI/CD превращался в домино.
В какой-то момент стало очевидно, что нужно выбираться из этого болота. Мы объединили всё в один репозиторий, убрав ненужную вложенность. Теперь каждый пайплайн стабильно выполняется за те же пять минут, но без накопительного эффекта. Всё стало проще, прозрачнее и быстрее.
Радуемся, архитектуру поправили, идём дальше. Пора было искать альтернативы Caddy v1.
Caddy v2
Первым делом решили проверить очевидное — может, сам Caddy уже эволюционировал. Вбиваем в Google, открываем официальную документацию и сразу видим: есть вторая версия! Да ещё и с поддержкой Go 1.21. Отлично, наконец-то шанс обновиться без костылей.
Начали разбираться. Архитектурно Caddy v2 похож на своего предшественника: тот же модульный подход, тот же Go под капотом, активная разработка. Появилась и приятная новинка — JSON-конфигурация. После их псевдо-YAML в первой версии это просто глоток свежего воздуха.
Главный плюс JSON-конфига — возможность hot-reload: можно обновлять настройки плагинов на лету, без полного перезапуска сервиса. Захотел — подхватил новый конфиг, перезагрузил нужный модуль и продолжаешь работать. Красота.
Казалось бы, решение найдено. Но, как обычно, без подводных камней не обошлось.
Во-первых, bus-фактор никуда не делся. Один человек из всей команды (а нас больше сотни) изучит новый стек, разберётся в конфигурации, соберёт систему и станет единственной точкой знаний. Дальше классика: отпуск? нельзя. больничный? не вовремя. Горячая пора релиза, и этот человек буквально живёт в деплое. Такой сценарий недопустим, если мы хотим держать стабильный продукт.
Во-вторых, документация у v2 — это боль. Два соседних плагина: у одного есть описание, у другого тишина. Конфигурации половины плагинов задокументированы только в формате JSON, другой половины только в Caddyfile, и между ними нет совместимости. Даже ключи параметров могут отличаться. Это сразу оборачивается проблемой поддержки: DevOps-ы и сопровождение не смогут быстро разобраться, а значит, продукт станет заложником своей сложности.
Дальше, неприятное открытие. В Caddy v1 можно было сделать небольшой костыль: считать исходный config-файл и построчно проверить каждый параметр, вытащить сквозные ссылки между плагинами. В v2, с переходом на JSON, эту возможность убрали, вероятно, из соображений безопасности. В результате стало невозможно реализовать привычный контекст между модулями.
И наконец — порядок инициализации. Если итоговый JSON-файл формируется генератором не в той последовательности, в какой планировалась загрузка модулей, сервис может повести себя непредсказуемо. Иногда просто меняешь два блока местами и всё, поведение при запуске другое.
При нашей сложной связности между плагинами это недопустимо: сервис может стартовать «не так», а часть модулей просто отвалится. В Caddy v1 у нас была обёртка, которая обеспечивала единый контекст загрузки — в v2 такого механизма нет вообще.
Пишем с нуля
После всех экспериментов стало ясно: зачем страдать с коробочными решениями, которые вроде бы предлагают модульность, но по факту не вписываются в архитектуру нашего сервиса и плагинов? Мы пришли к очевидному выводу — проще и надёжнее написать своё решение с нуля, полностью подконтрольное команде. Тогда можно будет кастомизировать всё, что угодно, и не зависеть от чьей-то документации, обновлений или внезапных несовместимостей.
Но тут важно не впасть в другую крайность — не делать «всё своё» руками. В начале статьи я уже упоминал, что раньше мы писали собственные генераторы, которые создавали плагины для Caddy прямо из прото-файлов на лету. Эти генераторы со временем разрослись до состояния отдельных монстров — по объёму кода они превосходили большинство наших микросервисов.
Повторять эту историю не хотелось. Писать третий генератор, если завтра опять изменится вектор — это бессмысленный оверхед. Нам нужно было решение, где архитектура остаётся под контролем, но при этом есть готовый, устойчивый фреймворк с предсказуемой производительностью и зрелым сообществом.
Ключевые критерии были простые: стабильность, высокая скорость обработки запросов, нормальные бенчмарки и адекватное поведение под нагрузкой. Пусть сейчас Gateway не был узким местом по RPS — мы хотели предусмотреть запас на будущее.

Выбор в итоге пал на Fiber.
Fiber
Почему он, а не FastHTTP? Когда мы выбирали фреймворк, времени было в обрез: фичи горели, инфраструктура стояла на паузе, и писать низкоуровневую обвязку с нуля было просто некогда. FastHTTP, конечно, быстрый и мощный, но требует ручной сборки экосистемы вокруг — middleware, логирования, ошибок, хэндлеров. На это нужны недели, которых у нас не было.
Fiber, наоборот, подошёл идеально по балансу «готовое / контролируемое». Это, по сути, аналог Express.js в мире Go: простой, понятный, с нормальной документацией и логичной архитектурой. Порог вхождения низкий — любой разработчик может быстро разобраться, а коллеги с фронтенда даже получили возможность при необходимости зайти, поправить заголовки или плагин вручную, не залезая в дебри бэкенда.
Fiber оказался лаконичным и предсказуемым, а документация — человеческой: всё описано, читается легко, без копания в исходниках. Построив архитектуру на нём, мы сразу выиграли в читаемости и поддерживаемости кода.
Плюс, JSON-конфигурация у Fiber оказалась очень близка к тому, как устроены наши остальные gRPC-сервисы. В Caddy-файлах синтаксис был уникальный и никак не стыковался с инфраструктурой продукта, а тут всё единообразно: те же структуры, те же подходы. Это важно не только для разработчиков, но и для DevOps-ов и сопровождения — всё знакомо, всё на автомате. Мы не изобретаем велосипед, а просто берём то, что уже хорошо работает у соседей.
Начали тестировать и сразу влетели в проблему. Мы активно используем стримы, и примерно половина клиентских запросов работает именно через них. Первые тесты шли нормально: запросы отрабатывали, ответы возвращались, всё красиво.
Но потом — бах. На десятом, пятидесятом или сотом запросе (зависело от случая) страница переставала отвечать. Проверяем — сервис упал в панику. Проблема воспроизводилась хаотично: иногда с первого запроса, иногда только после сотого.
Разбор показал: Fiber не поддерживает HTTP/2, а у нас стримы как раз шли поверх него, через обвязку gRPC Gateway. В результате — несовпадение дескрипшенов запросов на уровне ядра net/http, и сервер просто рушился.
На этом этапе стало ясно: починить такое в лоб не получится. Мы снова оказались у развилки и пошли искать другой фреймворк, который умеет работать с HTTP/2 из коробки.
Gin
Когда начали искать фреймворк с нормальной поддержкой HTTP/2, вариантов оказалось не так уж много. После серии тестов и чтения исходников остановились на Gin.
Из всех кандидатов у него оказались лучшие документация и комьюнити, внятная архитектура и богатый набор middleware из коробки. Порог вхождения низкий даже для тех, кто раньше с ним не работал. Да, по RPS Gin немного проседает по сравнению с Fiber, но для нас это не критично: Gateway никогда не был узким местом по производительности, зато стабильность и поддерживаемость для нас приоритет.
Интегрировав Gin, запустили всё прекрасно. Но наши приключения на этом не закончились. Разработчику всегда попадётся на глаза что-то, что нужно подрефачить.
Рефакторинг
У нас было большое дублирование соединений. Каждый плагин по идеологии Caddy — это инкапсулированная, изолированная единица. Поэтому, чтобы прокинуть наши gRPC-соединения, их приходилось дублировать в каждом плагине и каждой конфигурации, хотя по факту все они стучались в один и тот же сервис. Мы сделали единую точку — фабрику соединений, которая по запросу «дай мне соединение к такому-то сервису» проверяет: если соединения нет — создаёт его, если есть — просто переиспользует и отдаёт плагину. Так мы сократили количество соединений по ключевым сервисам с восьми до одного, что в будущем заметно снизит нагрузку.
C++-зависимость была нашей болью на протяжении всего существования Gateway. Это одна из причин, почему при виде задач по этому проекту разработчики думали: «О нет, только не он». Ни нормального readme, ни инструкций: запускаешь и получаешь сообщение «Отдай мне библиотеку». На Linux это ещё можно было пережить — где-то в Confluence можно найти, что именно нужно поставить. А вот на Mac библиотек просто нет, и приходилось проходить через эту боль вручную.
Разобравшись, откуда растут ноги, я выяснил, что у нас есть плагин для работы с аватарками, который тянул зависимость соседнего модуля, тоже работающего с аватарками. А у соседа под капотом жила библиотека libmagic, используемая для изменения размеров, кропов и прочих операций с изображениями.

В коде всё выглядело просто: интерфейс, конструктор и обработчик запроса. В конструктор подтягивался дочерний модуль соседа, и тот — libmagic. Обработчик же делал запрос к соседнему сервису, получал данные и отдавал их клиенту.

Мы посмотрели внимательнее — действительно ли всё это нужно, ведь мы работаем по gRPC? Открыли прото соседа и обнаружили метод, который полностью закрывал нашу потребность. Мы удалили зависимость, переписали обработчик так, чтобы он просто вызывал этот метод через gRPC-стрим, и всё заработало. 40–50 строк кода заменились несколькими строками, ушла боль с C++, сборка стала заметно быстрее, а настройка проще. Теперь проект собирается чистым Go, без лишних зависимостей, а разработчики наконец могут просто запустить, скомпилировать и работать без шаманства.
Приятные плюшки
Первое, что мы почувствовали, — ушёл bus-фактор. Теперь любой бэкенд-разработчик в команде может спокойно запустить наш Gateway локально и разобраться, как он работает.
Никаких «коробочных» ограничений, странных зависимостей и «магии». Код открыт, структурирован, модули логично разбиты по бизнес-областям. Если нужно что-то поправить — просто проваливаешься в нужный блок и сразу понимаешь, где внести изменения.
Конфигурация стала лаконичной и унифицированной. Теперь она полностью соответствует подходам, принятым в других наших сервисах: понятна разработчикам, девопсам и поддержке.
Раньше конфиг-файл старого Gateway был настоящей болью — около трёх тысяч строк ручного кода. В нём нужно было заполнять параметры для всех плагинов сразу: нельзя было отключить ненужные, даже если они не использовались при инициализации.
Некоторые плагины требовали интеграции с NATS и другими инфраструктурными зависимостями, поэтому любое изменение превращалось в мучение. Генератора не было вовсе: сервис был «белой вороной», особенным и непонятным.
Теперь всё иначе. Мы собрали локальный конфиг из примерно 200 строк — только базовые блоки: HTTP, аутентификация и авторизация. Всё остальное стало управляемым: если плагин нужен, то включаешь его в конфиг, не нужен — просто не указываешь.
Плагины раньше не знали об общих блоках и дублировали кучу кода. Мы решили это с помощью механизма мёржа. Теперь достаточно указать дефолтное соединение с gRPC-балансировщиком, а Gateway сам подхватывает недостающие настройки. Если конфиг для конкретного сервиса пустой, он подставляет базовые параметры: ключи, таймауты, адреса — и спокойно ходит к балансировщику с готовыми данными. Это позволило заметно сократить размер конфигурации и сделать её человекочитаемой.
Архитектура упростилась, а вместе с ней и процесс разработки. Всё стало прозрачнее, быстрее и предсказуемее.
Мы также интегрировались с корпоративным PaaS-решением, которое коллеги из другой команды используют для централизованной работы с логами и трейсам. Раньше Gateway мешал полноценной интеграции: именно он был входной точкой для трейсов, а из-за ограничений старой версии Go мы не могли подключить нужные SDK. После обновления языка и переписывания Gateway мы наконец подтянули нужный пакет и успешно встали в общую систему мониторинга.
И, наконец, мы избавились от CVE. История получилась показательной. Пока мы активно рефакторили Gateway, не спешили его выкатывать в релизы — и вовремя: через один релиз коллеги из ИБ сообщили, что найденные уязвимости в Go 1.19 получили критичный статус. Это означало риск блокировки релиза. К счастью, к тому моменту мы уже были готовы с новой версией Gateway и в следующий релиз ушли в прод именно с ним, полностью закрыв проблему.
Такой вот получился тернистый, но очень полезный путь. Мы переписали Gateway почти с нуля, избавились от боли, старых зависимостей и хаоса в конфигах, а заодно укрепили связь между командами и встроились в экосистему компании гораздо плотнее. Если интересно узнать больше — пишите в комментариях, с удовольствием расскажу детали и подводные камни.
А если вам близка тематика масштабных инфраструктурных решений, распределённых систем и высоконагруженных сервисов — заходите в наши вакансии.
Будем рады пообщаться с теми, кто хочет строить такие же сложные и красивые системы вместе с нами.
