ГлавнаяБлогAPI-шлюз на Go и Deno: как мы переписали монолит политик
Алгоритмы

API-шлюз на Go и Deno: как мы переписали монолит политик

Узнайте, как Go+Deno API-шлюз заменил спагетти-код на TypeScript-плагины. Практический опыт с двумя схемами аутентификации, Unix-сокетами и патч-протоколом.

Al
Редакция Algolitalgolit.ru
12 мин чтения24 июня 2026 г.

Почему мы переписали API-шлюз: от спагетти к плагинам

Три микросервиса, две несовместимые модели аутентификации, один публичный endpoint — и Go-шлюз, превратившийся в груду условных операторов strings.HasPrefix. Вот как мы переписали его на Go + TypeScript, и почему в итоге получилась песочница с двумя средами выполнения.

Если вы уже думаете: «Очередной API-шлюз», — я вас понимаю. Но Conduit — не просто прокси-сервер. Мы поговорим о программируемых шлюзах, песочницах, Unix-сокетах и инженерных решениях, которые сделали эту разработку неожиданно увлекательной.

Краткое содержание

Мы построили небольшой Go-шлюз перед тремя сервисами с двумя разными схемами аутентификации (кастомные HMAC-сессии и JWT). Он работал, но стал неподдерживаемым: каждое исключение в маршрутизации требовало правки Go и переразвёртывания. Мы переписали его как Conduit: Go остался на сетевом пути (маршрутизация, проксирование, CORS, защита от SSRF), а песочница Deno выполняет политики в виде обычных TypeScript-плагинов, общаясь с Go через Unix-сокет со строгим протоколом снапшотов и патчей. Итог: аутентификация, логирование и правила маршрутизации стали файлами, которые можно редактировать и сохранять без перекомпиляции шлюза.

Исходная архитектура: три сервиса, один шлюз

У нас было три сервиса и один публичный API:

  • chat-service — обмен сообщениями в реальном времени
  • user-service — профили, сессии, идентификация
  • admin-service — внутренние инструменты, привилегированные операции

Трафик пользователей и чатов работал по схеме кастомная сессия + HMAC: Authorization: Session <id> с подписью X-Signature, X-Timestamp, X-Nonce, подкреплённой Redis для сессий и защиты от повторного использования. Не JWT. Не OAuth. Административный трафик использовал JWT, проверяемый на границе и передаваемый с доверенными заголовками X-Gateway-Admin-*, чтобы admin-service пропускал повторную валидацию.

Разные модели доверия, разные формы заголовков, разные исключения для публичных путей — всё это должно было работать на одном хосте, потому что клиентам всё равно, сколько сервисов работает за ним. Мы построили очевидное: маленький Go-шлюз. Пути с префиксами сервисов выбирали upstream. Функция route.Select() выбирала режим аутентификации. Растущая стопка условных операторов решала, какие пути публичные:

/admin-service/*  → admin upstream   + JWT (кроме login, refresh, external-api)
/user-service/*   → user upstream    + HMAC (кроме auth, device, internal, …)
/chat-service/*   → chat upstream    + HMAC для пользователей, JWT для /api/v1/admin/*

Оно работало. И было уродливо.

Где на самом деле жила проблема

Не в надёжности. Запросы попадали в нужный upstream, валидация JWT/HMAC работала, сессии продлевали TTL. Проблема была в расширяемости:

res := route.Select(r.URL.Path)
proxy := proxies[res.Backend]
h, ok := applyAuth(w, res.Auth, cfg, sessionStore, nonceStore, proxy)
h.ServeHTTP(w, r)

Чисто на бумаге. Но adminAuth(), userAuth() и chatAuth() были каждой стеной из strings.HasPrefix, несущих в себе «племенные знания» о том, какие пути являются исключениями. Каждое новое исключение для публичного пути означало правку Go и переразвёртывание шлюза. Выяснить, почему путь ведёт себя определённым образом, означало прочитать три функции и свериться с README размером с небольшой сервис.

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

Тот шлюз не был выброшен. Он доказал, что модель маршрутизации верна. Он просто доказал, что модель реализации нужно изменить. Так родился Conduit.

Цели дизайна: реальные ограничения, а не пожелания

1. Свобода реализации

Если вы умеете писать на TypeScript, вы можете расширять шлюз. Никаких DSL, никакого Lua-конфига, никакого формата плагинов с маркетплейсом: просто положите файл в ./plugins, экспортируйте объект с хуками жизненного цикла:

import type { GatewayContext } from "../runtime/shared/types.ts";

export default {
  beforeRequest(ctx: GatewayContext): void {
    if (!ctx.request?.headers?.Authorization) {
      ctx.reject!(401, "missing token");
    }
  },
};

2. Низкие операционные издержки

Никакой control plane, никаких требований к Kubernetes, никаких церемоний с sidecar. Базовая настройка — один бинарник Go, управляющий Deno, читающий JSON-конфиг:

go run ./cmd/conduit -config conduit.config.json

3. Чёткое разделение ответственности

СлойОбязанность
Go-шлюзHTTP-вход, маршрутизация, проксирование к upstream, CORS, лимиты тела, таймауты, защита от SSRF
Deno runtimeВыполнение плагинов в песочнице, пул воркеров
ПлагиныБизнес-политика: аутентификация, логирование, трансформации, управление маршрутами

Go никогда не узнаёт о внутренностях плагинов. Runtime никогда не владеет маршрутизацией. Плагины никогда не касаются сетевой границы напрямую. Эти инварианты записаны, потому что их нарушение порождает кросс-языковые баги, которые тесты не ловят надёжно.

4. Предсказуемые сценарии отказов

Неправильно работающий плагин не должен ронять шлюз. Таймауты хуков логируются и продолжаются. Падения воркеров заменяют изолят, а не процесс. Если весь runtime недоступен, failPolicy решает: closed (503, без прокси) или open (пропустить плагины, проксировать всё равно), настраивается для каждого маршрута.

5. Невидимость в продакшене

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

Архитектура: два процесса, один сокет

На каждый запрос:

  1. Клиент попадает в Go-шлюз.
  2. Предварительный запрос CORS (OPTIONS) обрабатывается в Go; плагины его никогда не видят.
  3. Go строит сериализуемый снапшот контекста из запроса.
  4. Хук beforeRequest каждого плагина запускается в порядке имён файлов.
  5. Go проксирует один раз в выбранный (или выбранный плагином) upstream.
  6. Ответ upstream прикрепляется к контексту.
  7. Хук afterResponse каждого плагина запускается в том же порядке.
  8. Go пишет ответ клиенту.

Upstream-сервисы вообще не знают о существовании Conduit: никакого SDK, никакой саморегистрации. Мультисервисная маршрутизация — это конфиг, а не код:

{
  "routes": [
    {
      "path": "/user-service/*",
      "upstream": "http://user-service:2001"
    },
    {
      "path": "/admin-service/*",
      "upstream": "http://admin-service:2002"
    },
    {
      "path": "/chat-service/*",
      "upstream": "http://chat-service:2003"
    }
  ]
}

Одна цепочка плагинов, много бэкендов, политика применяется единообразно на границе.

Самая сложная часть: передача контекста через границу процессов

Настоящая проблема первого шлюза была не в логике маршрутизации. Она была в том, где живёт состояние. Изменяемые объекты запроса, передаваемые через middleware, создавали скрытую связанность, и это становится хуже, когда вы пересекаете границу процессов: вы не можете передать Deno живой http.Request и надеяться на лучшее.

Контракт Conduit:

  • Go снапшотит запрос в сериализуемый JSON.
  • Снапшот пересекает кадр с префиксом длины по Unix-сокету в Deno.
  • Плагин работает с клонированным, локальным для хука контекстом с замороженным входящим запросом.
  • Плагин возвращает патч: минимальную дельту, а не мутированный объект.

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

Почему тела никогда не пересекают провод полностью: всё, что больше 64 КБ, остаётся в BodyStore на стороне Go. Плагины получают токен ссылки stream://, а не сырые байты. Мегабайты остаются в Go; метаданные пересекают сокет.

Плагины не владеют запросом. Они предлагают изменения к нему.

Это предложение — весь дизайн. Сигналы reject/forward, правила диффа патча, замороженный снапшот — всё это существует, чтобы сделать «предлагай, не владей» безопасным и дешёвым.

Модель плагинов

Плагины остаются намеренно маленькими:

ХукКогда
onLoadОдин раз при запуске
beforeRequestПеред проксированием к upstream
afterResponseПосле получения ответа от upstream
onErrorКогда хук в этом плагине неожиданно выбрасывает исключение

ctx.reject() — это намеренное управление потоком, а не ошибка; onError предназначен для реальных сюрпризов.

Плагины загружаются в порядке имён файлов, точка:

plugins/
  001-auth.ts           ← выполняется первым
  002-logging.ts        ← видит ctx.user от аутентификации
  003-header-rewrite.ts
  004-route-control.ts

Никакого графа зависимостей, никакой скрытой системы приоритетов. 001-auth.ts выполняется перед 002-logging.ts из-за имени файла, и это можно проверить grep'ом в 3 часа ночи.

Поверхность ctx намеренно узкая:

ЧленРоль
ctx.requestСнапшот входящего запроса (только чтение)
ctx.responseСтроитель исходящего ответа (setStatus, setHeader, setBody)
ctx.userИдентичность, установленная плагинами аутентификации
ctx.stateХранилище ключ/значение для запроса, общее для хуков
ctx.logСтруктурированное логирование с корреляцией трассировки
ctx.reject(status, msg)Остановить pipeline, вернуть HTTP-ошибку
ctx.forward(url)Проксировать на альтернативный upstream (валидируется шлюзом)
ctx.servicesОпциональные помощники http и cache

Никаких глобальных синглтонов, никакого изменяемого объекта запроса. Аутентификация устанавливает ctx.user; логирование читает его. Это весь контракт координации.

Что произошло, когда мы это запустили

Первое развёртывание было личным: три сервиса за одним портом, проверки HMAC в одном файле плагина, проверки JWT в другом, логирование в третьем. Спагетти из проверки префиксов превратилось в три файла, которые можно читать сверху вниз.

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

Момент, когда я понял, что оно работает, был не бенчмарком. Я забыл, что оно работает, а потом остановил Docker-контейнер, чтобы отладить что-то несвязанное, и увидел, как всё ломается. Это критерий успеха, который меня действительно волновал: не функциональная паритетность с Kong, а невидимость при нормальной работе.

Сценарии отказов

СобытиеПоведение
Таймаут хука плагина (по ум. 100мс)Предупреждение в лог; запрос продолжает проксироваться
Крах воркера плагинаИзолят заменяется; процесс шлюза выживает
Весь Deno runtime недоступенfailPolicy: "closed" → 503; "open" → прокси без плагинов
ctx.reject(401)Pipeline останавливается; остальные хуки пропускаются
Неверный ctx.forward()Логируется, игнорируется; используется исходный маршрут
Отказ upstreamСтандартная 502

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

Самый рискованный режим отказа — не крах. Это скрытое семантическое изменение: плагин, который неправильно устанавливает ctx.user или проглатывает заголовок, сохраняет систему на 200 OK, пока поведение тихо дрейфует под ним. Вот почему каждая строка лога содержит [trace:<id>] и почему reject и выброшенные ошибки рассматриваются как различные сигналы.

Чем это не является

  • Не Envoy: нет xDS, нет экосистемы WASM-фильтров в масштабе.
  • Не Kong: нет административного UI, нет маркетплейса плагинов.
  • Не service mesh.
  • Не для многогигабайтных стриминговых ответов. afterResponse буферизирует тело, чтобы плагины могли его инспектировать/перезаписывать, что стоит памяти пропорционально размеру ответа.

Что мы получаем взамен: кодовая база, читаемая за полдня, плагины, которые редактируешь и сохраняешь вместо перекомпиляции, отсутствие облачной зависимости для определения политики, и ограниченные затраты на IPC (один тёплый Unix-сокет, патчи вместо полных полезных нагрузок, ссылки на тело вместо сырых байтов).

Это лёгкое, а не бесплатное. Четыре плагина через две фазы — до восьми круговых обходов на запрос. Субмиллисекунда на локальном сокете. Неправильный инструмент, если вам нужна побайтовая обработка потоков в масштабе; правильный инструмент, если у вас есть горстка сервисов, развивающаяся пограничная политика и команда, которая уже пишет на TypeScript.

Практический вывод: что делать прямо сейчас

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

#API-шлюз#Go#Deno#TypeScript#микросервисы
Al
Редакция Algolit

Пишем про алгоритмы, подготовку к собеседованиям и карьеру в IT — так, чтобы было понятно и полезно.

Хочешь закрепить знания на практике?

Решай задачи на Algolit — интерактивная платформа для обучения

Начать бесплатно →