Содержание

API Gateway на Go: проксирование и кэширование

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

API-шлюз (gateway) даёт клиенту одну точку входа. Но сам он не умеет искать товары и проверять пароли — эту работу по-прежнему выполняют целевые сервисы. Обещание шлюза скромнее и сложнее одновременно: доставить HTTP-запрос нужному сервису и вернуть его ответ без незаметных искажений.

Проследим один запрос:

GET /api/v1/products?limit=20 HTTP/1.1 Host: api.example.local Authorization: Bearer <token>

Сначала проведём его вручную через http.Client, чтобы увидеть каждое решение. Затем остановим catalog-service, различим 502 и 504 и добавим кэш только для безопасного случая. WebSocket, события от сервера (Server-Sent Events, SSE) и полноценный прокси для рабочего окружения останутся за границей основного примера.

Что должен сохранить шлюз

браузер шлюз catalog-service │ │ │ │ GET /api/v1/products?limit=20 │ │ │ Authorization: Bearer… │ │ ├──────────────────────────────▶│ │ │ │ GET /api/v1/products?limit=20 │ │ │ Authorization: Bearer… │ │ ├──────────────────────────────▶│ │ │◀─ статус, заголовки и тело ───┤ │◀─ статус, заголовки и тело ───┤ │

В сторону целевого сервиса проходят:

  • HTTP-метод;
  • сопоставленный путь;
  • параметры запроса;
  • тело запроса;
  • сквозные заголовки, например Authorization и Content-Type;
  • контекст отмены исходного запроса.

В обратную сторону проходят статус, сквозные заголовки и тело ответа. Шлюз создаёт собственный статус только тогда, когда не получил HTTP-ответ от целевого сервиса.

Самая неприятная ошибка проксирования не всегда выглядит как падение. Если потерять ?limit=20, catalog-service может вернуть нормальный 200 OK, но не тот набор товаров. Поэтому сначала зафиксируем полный транспортный контракт, а потом напишем код.

Настраиваем один целевой сервис

Целевой сервис (upstream) — сервис, которому шлюз передаёт запрос. Для каталога это catalog-service, например http://localhost:8082 локально или http://catalog-service:8082 внутри Docker.

package gateway import ( "context" "errors" "fmt" "io" "log/slog" "net" "net/http" "net/url" "strings" "time" ) type Proxy struct { target *url.URL client *http.Client } func NewProxy(rawTarget string, timeout time.Duration) (*Proxy, error) { target, err := url.Parse(rawTarget) if err != nil { return nil, fmt.Errorf("parse target URL: %w", err) } if target.Scheme == "" || target.Host == "" { return nil, fmt.Errorf("target URL must contain scheme and host") } if timeout <= 0 { return nil, fmt.Errorf("timeout must be positive") } return &Proxy{ target: target, client: &http.Client{ Timeout: timeout, CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, }, }, nil }

Один http.Client живёт столько же, сколько шлюз, и переиспользует соединения. Перенаправление здесь запрещено: шлюз должен вернуть ответ выбранного целевого сервиса, а не молча отправить пользовательский токен по новому адресу.

Общий Client.Timeout ограничивает весь обмен, включая чтение тела. В более зрелой конфигурации отдельный http.Transport задаёт также сроки установки соединения, TLS-рукопожатия и получения заголовков ответа, но сначала нам достаточно одной понятной границы.

Строим запрос к целевому сервису без потерь

Скопируем базовый URL, добавим входной путь и сохраним исходные параметры запроса без повторного кодирования:

func (p *Proxy) newUpstreamRequest(r *http.Request) (*http.Request, error) { target := *p.target target.Path = joinPath(p.target.Path, r.URL.Path) target.RawQuery = r.URL.RawQuery out, err := http.NewRequestWithContext( r.Context(), r.Method, target.String(), r.Body, ) if err != nil { return nil, fmt.Errorf("create upstream request: %w", err) } out.ContentLength = r.ContentLength copyHeaders(out.Header, r.Header) removeHopByHop(out.Header) setForwardedHeaders(out.Header, r) return out, nil } func joinPath(base, incoming string) string { return strings.TrimRight(base, "/") + "/" + strings.TrimLeft(incoming, "/") } func copyHeaders(dst, src http.Header) { for key, values := range src { for _, value := range values { dst.Add(key, value) } } }

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

Тело не читается через io.ReadAll: r.Body передаётся потоком. Это важно для загрузки файлов и больших ответов. Конкретный маршрутизатор может убрать или заменить внешний префикс до вызова прокси; преобразование пути должно быть явной частью таблицы маршрутов, а не случайной операцией внутри транспорта.

Запустите сервисы и отправьте:

curl -i 'http://localhost:8000/api/v1/products?limit=20' \ -H "Authorization: Bearer $USER_TOKEN"

В журнале доступа catalog-service должны остаться метод GET, путь /api/v1/products и параметр limit=20. Если ответ требует пользовательский токен, целевой сервис также должен увидеть Authorization.

Не переносим заголовки одного соединения в другое

Authorization — сквозной заголовок: он нужен конечному сервису. Connection, Keep-Alive и Transfer-Encoding управляют только одним сетевым участком и не должны механически переходить на следующий. Такие заголовки называют заголовками отдельного соединения (hop-by-hop).

Кроме стандартного списка, сам Connection может перечислять дополнительные имена для удаления:

var hopByHopHeaders = []string{ "Connection", "Proxy-Connection", "Keep-Alive", "Proxy-Authenticate", "Proxy-Authorization", "Te", "Trailer", "Transfer-Encoding", "Upgrade", } func removeHopByHop(header http.Header) { for _, name := range strings.Split(header.Get("Connection"), ",") { if name = strings.TrimSpace(name); name != "" { header.Del(name) } } for _, name := range hopByHopHeaders { header.Del(name) } }

Шлюз также сообщает целевому сервису, откуда пришёл исходный запрос:

func setForwardedHeaders(header http.Header, r *http.Request) { header.Del("X-Forwarded-For") header.Del("X-Real-IP") header.Del("X-Forwarded-Proto") header.Del("X-Forwarded-Host") host, _, err := net.SplitHostPort(r.RemoteAddr) if err == nil { header.Set("X-Forwarded-For", host) header.Set("X-Real-IP", host) } proto := "http" if r.TLS != nil { proto = "https" } header.Set("X-Forwarded-Proto", proto) header.Set("X-Forwarded-Host", r.Host) }

На публичной границе мы удаляем и заново задаём входные X-Forwarded-*: клиент может подделать их, а RemoteAddr в редком нестандартном формате не должен оставлять поддельное значение нетронутым. Если перед шлюзом стоит доверенный контроллер входящего трафика (ingress) или CDN, цепочку можно сохранить только после проверки, что непосредственный отправитель входит в настроенный список доверенных прокси.

Заголовок Host хранится в Request.Host, а не в Request.Header. Новый запрос по умолчанию использует имя узла целевого сервиса — для обычного обратного прокси это правильное поведение. Публичный шлюз также должен отклонять неизвестные значения входного Host, прежде чем передавать его как X-Forwarded-Host.

Возвращаем ответ, а не пересобираем его

Теперь выполним запрос и перенесём ответ потоково:

func (p *Proxy) ServeHTTP(w http.ResponseWriter, r *http.Request) { out, err := p.newUpstreamRequest(r) if err != nil { http.Error(w, "cannot create upstream request", http.StatusInternalServerError) return } response, err := p.client.Do(out) if err != nil { if errors.Is(r.Context().Err(), context.Canceled) { return } writeProxyError(w, err) return } defer response.Body.Close() removeHopByHop(response.Header) copyHeaders(w.Header(), response.Header) w.WriteHeader(response.StatusCode) if _, err := io.Copy(w, response.Body); err != nil { slog.WarnContext( r.Context(), "copy upstream response", "error", err, ) } }

Если catalog-service ответил 404 или 500, Do всё равно вернёт нормальный response. Шлюз передаёт этот статус клиенту: ответ целевого сервиса не является транспортной ошибкой прокси.

После WriteHeader или первой записи тела статус уже отправлен клиенту. Если io.Copy оборвался позже, заменить ответ на красивый JSON нельзя; остаются закрытие соединения, структурированная запись в журнале и метрика.

Различаем недоступность и медленный ответ

Шлюз создаёт ошибку только тогда, когда HTTP-ответ не получен:

СитуацияСтатус шлюза
DNS, отказ соединения, неожиданный разрыв502 Bad Gateway
целевой сервис не успел ответить до тайм-аута504 Gateway Timeout
шлюз не смог построить запрос500 Internal Server Error
func writeProxyError(w http.ResponseWriter, err error) { var netErr net.Error if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout()) { http.Error(w, "upstream timeout", http.StatusGatewayTimeout) return } http.Error(w, "upstream unavailable", http.StatusBadGateway) }

Остановите catalog-service и повторите запрос: шлюз должен вернуть 502. Для проверки 504 добавьте в локальный тестовый обработчик паузу длиннее Client.Timeout, убедитесь в ответе и удалите паузу.

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

Добавляем кэш после исправного проксирования

Список товаров запрашивается часто и меняется сравнительно редко. Для публичного GET /api/v1/products?limit=20 можно применить ленивое заполнение кэша (cache-aside):

  1. шлюз строит ключ из маршрута, пути и нормализованных параметров запроса;
  2. при попадании в кэш возвращает сохранённый статус, разрешённые заголовки и тело;
  3. при промахе вызывает прокси;
  4. сохраняет только полный безопасный ответ с небольшим TTL.

Для этого эксперимента отправляйте публичный запрос без Authorization и файлов cookie. Первые два ответа должны быть наблюдаемы:

HTTP/1.1 200 OK X-Cache: MISS
HTTP/1.1 200 OK X-Cache: HIT

На втором запросе в журнале доступа catalog-service не появляется новая запись. Другой параметр, например limit=50, получает другой ключ кэша и новый MISS.

Если ответ зависит от Authorization, файлов cookie, языка или другого заголовка, общий ключ может выдать данные одного пользователя другому. Начинайте с явно публичного списка товаров; персональные маршруты безопаснее сначала исключить.

Перехватываем только ограниченный ответ

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

type captureWriter struct { http.ResponseWriter status int body bytes.Buffer limit int overflow bool writeErr error } func (w *captureWriter) WriteHeader(status int) { if w.status != 0 { return } w.status = status w.ResponseWriter.WriteHeader(status) } func (w *captureWriter) Write(data []byte) (int, error) { if w.status == 0 { w.WriteHeader(http.StatusOK) } n, err := w.ResponseWriter.Write(data) if err == nil && n != len(data) { err = io.ErrShortWrite } if err != nil { w.writeErr = err } remaining := w.limit - w.body.Len() if remaining > 0 && n > 0 { part := data[:n] if len(part) > remaining { part = part[:remaining] } _, _ = w.body.Write(part) } if n > remaining { w.overflow = true } return n, err } func (w *captureWriter) Unwrap() http.ResponseWriter { return w.ResponseWriter }

Фрагмент использует bytes, io и net/http. После next.ServeHTTP(capture, r) сохраняйте запись только при всех условиях:

  • метод — GET;
  • выбранная политика разрешает кэшировать этот маршрут, а запрос не содержит Authorization или файлов cookie;
  • статус — полный 200 OK, а не 206 Partial Content;
  • overflow == false и writeErr == nil;
  • Cache-Control не содержит no-store или private;
  • каждый заголовок из Vary включён в ключ, а Vary: * запрещает сохранение;
  • TTL явно задан и невелик.

Сохраните только разрешённые заголовки, например Content-Type; не сохраняйте Set-Cookie и диагностический X-Cache: MISS. После изменения товара удалите связанные ключи или включите версию ресурса в ключ. Реализация Redis-клиента, TTL и ленивого заполнения кэша продолжена в статье «Redis в Go: клиент, кэширование и TTL».

Где заканчивается ручной прокси

Ручная реализация делает HTTP-модель видимой, но показанный joinPath не сохраняет все варианты процентного кодирования, например закодированную косую черту. Мы также не реализовали ответы 1xx, завершающие заголовки (trailers), смену протокола и двунаправленную потоковую передачу.

Для рабочего окружения используйте httputil.ReverseProxy или проверенный шлюз. Стандартный прокси уже поддерживает Rewrite, ModifyResponse, ErrorHandler и корректнее обрабатывает многие детали протокола.

Это не отменяет архитектурных решений: всё равно нужно настроить тайм-ауты транспорта, доверенные заголовки X-Forwarded-*, ограничения, маршрутизацию и наблюдаемость. WebSocket и события от сервера не должны проходить через кэш-обёртку обычного JSON-ответа.

Что проверить перед использованием

  • Один запрос сохраняет метод, сопоставленный путь, параметры, тело и нужные сквозные заголовки.
  • Заголовки отдельного соединения удаляются в обоих направлениях.
  • X-Forwarded-* формируются только из доверенной части цепочки.
  • Статус и тело целевого сервиса возвращаются без замены; сетевой сбой даёт 502, тайм-аут — 504.
  • Кэш применяется только к ограниченному полному 200 OK публичного GET.
  • Параметры запроса и все разрешённые варианты ответа участвуют в ключе кэша.
  • Для потоковой передачи и сложного HTTP используется подходящий обратный прокси без буферизации.

Базовые правила клиента, сервера и контекста запроса собраны в статье «HTTP и сети в Go».

Следующий шаг после статьи

Закрепите тему в реальном проекте и продолжайте обучение на курсах Praxis.

Продолжить изучение

Выбери следующую статью по маршруту или углубись в смежную тему.

Похожие статьи