2. Reverse proxy

Nginx как Reverse Proxy: анатомия proxy_pass, трансляция заголовков и буферизация

В современной архитектуре распределенных бэкендов обратный прокси (Reverse Proxy) — это незаменимый архитектурный рубеж. Прямой деплой микросервисов на Go в открытый интернет без промежуточного прокси почти всегда ведет к фатальным эксплуатационным проблемам: уязвимости сетевого периметра, исчерпанию пула горутин из-за медленных клиентов, сложностям с динамической маршрутизацией и хаосу в SSL/TLS сертификатах.

В отличие от прямого прокси (Forward Proxy), который устанавливается на стороне клиентов (например, корпоративный прокси в офисе, скрывающий внутренние IP-адреса рабочих станций при выходе во внешнюю сеть), Reverse Proxy развертывается на стороне инфраструктуры сервиса. Он выступает единым фасадом для миллионов внешних пользователей, принимая весь входящий интернет-трафик и деликатно распределяя его по закрытым внутренним подсетям бэкенда.


1. Как работает директива proxy_pass под капотом

Главная рабочая лошадка конфигурации Nginx — директива proxy_pass. Когда запрос попадает в соответствующий блок location, Nginx разворачивает сложную цепочку низкоуровневых операций:

  1. Прием клиентского подключения: Воркер Nginx принимает TCP-соединение от клиента, инициирует TLS Handshake (при HTTPS) и считывает заголовки HTTP-запроса. Если в запросе присутствует тело (body), Nginx читает его в память в пределах лимита client_body_buffer_size.
  2. Переиспользование пула соединений (Upstream Keepalive): Открытие нового TCP-соединения на каждый входящий HTTP-запрос — непозволительная роскошь: три системных вызова на рукопожатие (SYN, SYN-ACK, ACK), медленный старт TCP (Slow Start) и накопление сокетов в TIME_WAIT. Nginx поддерживает пул постоянно открытых TCP-соединений к Go-бэкенду (keepalive). При получении запроса воркер берет готовое соединение из пула, избегая системного вызова connect(), либо открывает новое, если пул исчерпан.
  3. Запись в сокет бэкенда: Nginx формирует очищенный и обогащенный служебными заголовками HTTP-запрос и асинхронно передает его в сокет Go-приложения через кольцевой буфер ядра.
  4. Вычитывание ответа и дисковый сброс: Как только Go-сервис сгенерировал данные, Nginx вычитывает их на максимальной скорости локальной шины. Если ответ превышает размер буферов оперативной памяти (proxy_buffers), Nginx сбрасывает избыток во временные файлы на диске (proxy_temp_path), не заставляя Go ждать медленного клиента.
  5. Трансляция клиенту: Nginx передает ответ клиенту в темпе, определяемом пропускной способностью его интернет-провайдера.
flowchart LR
    classDef process  fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#f8fafc
    classDef success  fill:#064e3b,stroke:#34d399,stroke-width:1px,color:#f8fafc
    classDef error    fill:#7f1d1d,stroke:#f87171,stroke-width:1px,color:#f8fafc
    classDef warning  fill:#78350f,stroke:#fbbf24,stroke-width:1px,color:#f8fafc
    classDef entry    fill:#1e3a5f,stroke:#38bdf8,stroke-width:2px,color:#f8fafc
    classDef aux      fill:#334155,stroke:#64748b,stroke-width:1px,color:#f8fafc
    classDef system   fill:#312e81,stroke:#a78bfa,stroke-width:1px,color:#f8fafc
    classDef data     fill:#132d29,stroke:#2dd4bf,stroke-width:1px,color:#f8fafc
    classDef network  fill:#881337,stroke:#fb7185,stroke-width:1px,color:#f8fafc
    classDef accent   fill:#1e1b4b,stroke:#818cf8,stroke-width:1px,color:#f8fafc
    A["Клиент из интернета"]:::entry -->|"1. HTTPS запрос"| B["Nginx - Reverse Proxy"]
    B -->|"2. HTTP запрос через keepalive"| C["Go Backend 1"]
    B -->|"2. HTTP запрос через keepalive"| D["Go Backend 2"]
    C -->|"3. HTTP ответ"| B
    D -->|"3. HTTP ответ"| B
    B -->|"4. Расшифровка и отправка"| A
  

2. Проблема заголовков: «Кто звонит?»

Пожалуй, самая коварная ловушка при внедрении Reverse Proxy — потеря контекста оригинального сетевого соединения.

Поскольку Nginx устанавливает собственное TCP-соединение с вашим Go-сервисом, вызов стандартного метода r.RemoteAddr вернет внутренний IP-адрес самого Nginx (например, 172.16.0.1 или 127.0.0.1), а поле r.Host может оказаться именем апстрим-группы вместо доменного имени, которое пользователь ввел в адресной строке браузера.

Если Go-бэкенд полагается на IP-адрес для rate-limiting, геолокации, логирования аудита или защиты от брутфорса, система моментально заблокирует сам Nginx или даст зеленый свет злоумышленникам.

Для корректной трансляции метаданных Nginx обязан проставлять семейство прокси-заголовков:

NGINX
location /api/ {
    proxy_pass http://go_backend:8080;

    # Передаем оригинальный Host из запроса пользователя
    proxy_set_header Host $host;

    # Передаем физический IP-адрес непосредственного клиента
    proxy_set_header X-Real-IP $remote_addr;

    # Добавляем IP клиента в цепочку прокси-серверов
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

    # Сообщаем бэкенду исходный протокол (http или https)
    proxy_set_header X-Forwarded-Proto $scheme;
}

X-Forwarded-For и уязвимость IP Spoofing

Заголовок X-Forwarded-For (XFF) имеет формат: client_ip, proxy1_ip, proxy2_ip.

Здесь кроется критическая уязвимость безопасности: любой клиент может вручную отправить поддельный заголовок X-Forwarded-For: 1.1.1.1. Директива Nginx $proxy_add_x_forwarded_for просто добавит реальный IP в конец: 1.1.1.1, attacker_real_ip. Если ваше Go-приложение доверяет первому IP из этого списка, вы подвергаетесь атаке IP Spoofing.

Извлечение реального IP в Go: Идиоматичный подход

Для защиты от спуфинга Go-приложение должно валидировать входящие адреса. Стандартная библиотека Go не содержит встроенного валидатора цепочек XFF, поэтому разработчики используют надежные экстракторы вроде github.com/oapi-codegen/runtime или пишут строгий middleware:

Go
package main

import (
    "net"
    "net/http"
    "strings"
)

// RealIPMiddleware извлекает реальный IP клиента с защитой от спуфинга
func RealIPMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Сначала проверяем X-Real-IP, который гарантированно выставил наш Nginx
        if ip := r.Header.Get("X-Real-IP"); ip != "" {
            r.RemoteAddr = ip
        } else if ip := r.Header.Get("X-Forwarded-For"); ip != "" {
            // В реальном проде нужно брать последний доверенный IP, 
            // а не первый! Это упрощенный пример.
            ips := strings.Split(ip, ",")
            r.RemoteAddr = strings.TrimSpace(ips[0])
        }

        // Нормализуем адрес, отсекая порт, если он присутствует
        if host, _, err := net.SplitHostPort(r.RemoteAddr); err == nil {
            r.RemoteAddr = host
        }

        next.ServeHTTP(w, r)
    })
}

3. Буферизация: Защита Go от медленных клиентов (Response Buffering)

По умолчанию в Nginx включена буферизация ответов (proxy_buffering on). Это одна из ключевых причин, почему Nginx бережет оперативную память бэкенда.

Сценарий без Nginx (Go напрямую в мир):

  1. Go-сервис генерирует тяжелый JSON-отчет размером 2 МБ за 5 миллисекунд.
  2. Клиент подключен через перегруженную сотовую сеть 3G и способен принимать данные со скоростью не более 200 КБ/с.
  3. Сокетный буфер отправки ядра переполняется за первые 64 КБ.
  4. Горутина Go блокируется на вызове w.Write() (или тратит такты в Netpoller), удерживая в куче (Heap) срез на 2 МБ в течение 10 секунд.
  5. При 1 000 таких клиентов в памяти зависает 2 ГБ живых аллокаций. Сборщик мусора Go (GC) входит в фазу паники, вызывая частые циклы Stop-The-World и сжигая CPU.

Сценарий с Nginx:

  1. Go-сервис выплевывает 2 МБ в сокет Nginx за 2 миллисекунды (пропускная способность локальной петли 10–40 Гбит/с).
  2. Go-хендлер завершает работу, горутина возвращается в пул, а память буфера мгновенно маркируется сборщиком мусора как свободная.
  3. Nginx сохраняет ответ в свои компактные C-структуры (proxy_buffers 16 64k;), а при нехватке памяти сбрасывает их во временный файл на диск.
  4. Nginx в своем неблокирующем цикле событий неспешно отдает байты медленному 3G-клиенту, не нагружая процессор и память Go.
sequenceDiagram
    autonumber
    participant Client as Медленный клиент (3G)
    participant Nginx as Nginx (Reverse Proxy)
    participant Go as Go Backend Service

    Client->>Nginx: HTTP GET /api/heavy-report
    Nginx->>Go: proxy_pass HTTP GET /api/heavy-report
    Note over Go: Вычисление отчета (5 мс)
    Go-->>Nginx: Отдает 2 МБ ответа за 2 мс (Localhost)
    Note over Go: Хендлер завершен! Память освобождена
    Note over Nginx: proxy_buffering on: ответ сохранен в буфере/диске
    loop Медленная отдача (10 секунд)
        Nginx-->>Client: Потоковая передача мелкими пакетами
    end
  

4. WebSockets и протокольный апгрейд (HTTP Upgrade)

Протокол WebSocket стартует как обычный HTTP/1.1 запрос, но в процессе рукопожатия клиент запрашивает переключение протокола через заголовки:

HTTP
Connection: Upgrade
Upgrade: websocket

По спецификации HTTP/1.1 заголовки Connection и Upgrade являются hop-by-hop (действуют только на одно соединение). Nginx по умолчанию удаляет их при проксировании. Чтобы WebSocket заработал сквозь Nginx к вашему Go-бэкенду, требуется явная настройка:

NGINX
location /ws/ {
    proxy_pass http://go_backend:8080;
    proxy_http_version 1.1; # WebSockets требуют обязательного HTTP 1.1

    # Переписываем служебные заголовки для успешного Upgrade
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";

    # Таймауты для долгоживущих соединений
    proxy_read_timeout 86400s; # 24 часа, иначе Nginx разорвет WS через дефолтные 60 секунд
    proxy_send_timeout 86400s;
}

Итог

  1. Reverse Proxy как защитный экран: Nginx скрывает физическую топологию бэкенда, агрегирует соединения через upstream keepalive и защищает Go от сетевых аномалий.
  2. Заголовки X-Forwarded-*: Жизненно важный мост для передачи информации (Host, X-Real-IP, X-Forwarded-For, X-Forwarded-Proto) от Nginx к Go. Ошибки в их обработке ведут к багам с авторизацией и редиректами.
  3. Безопасность IP: Нельзя наивно доверять первому значению X-Forwarded-For; используйте модуль realip в Nginx для отсечения спуфинга.
  4. Буферизация (Response Buffering): Защищает кучу и сборщик мусора Go от медленных клиентов, но должна быть отключена (proxy_buffering off;) для SSE и потокового контента.
  5. WebSocket Upgrade: Требует явной трансляции заголовков Upgrade и Connection, а также увеличения proxy_read_timeout.

В следующей лекции мы разберем, как Nginx балансирует нагрузку между множеством инстансов Go-сервисов, какие существуют алгоритмы распределения трафика и как отслеживать здоровье бэкендов: 3. Load balancing.