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 разворачивает сложную цепочку низкоуровневых операций:
- Прием клиентского подключения: Воркер Nginx принимает TCP-соединение от клиента, инициирует TLS Handshake (при HTTPS) и считывает заголовки HTTP-запроса. Если в запросе присутствует тело (body), Nginx читает его в память в пределах лимита
client_body_buffer_size. - Переиспользование пула соединений (Upstream Keepalive): Открытие нового TCP-соединения на каждый входящий HTTP-запрос — непозволительная роскошь: три системных вызова на рукопожатие (
SYN,SYN-ACK,ACK), медленный старт TCP (Slow Start) и накопление сокетов вTIME_WAIT. Nginx поддерживает пул постоянно открытых TCP-соединений к Go-бэкенду (keepalive). При получении запроса воркер берет готовое соединение из пула, избегая системного вызоваconnect(), либо открывает новое, если пул исчерпан. - Запись в сокет бэкенда: Nginx формирует очищенный и обогащенный служебными заголовками HTTP-запрос и асинхронно передает его в сокет Go-приложения через кольцевой буфер ядра.
- Вычитывание ответа и дисковый сброс: Как только Go-сервис сгенерировал данные, Nginx вычитывает их на максимальной скорости локальной шины. Если ответ превышает размер буферов оперативной памяти (
proxy_buffers), Nginx сбрасывает избыток во временные файлы на диске (proxy_temp_path), не заставляя Go ждать медленного клиента. - Трансляция клиенту: 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 обязан проставлять семейство прокси-заголовков:
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:
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 напрямую в мир):
- Go-сервис генерирует тяжелый JSON-отчет размером 2 МБ за 5 миллисекунд.
- Клиент подключен через перегруженную сотовую сеть 3G и способен принимать данные со скоростью не более 200 КБ/с.
- Сокетный буфер отправки ядра переполняется за первые 64 КБ.
- Горутина Go блокируется на вызове
w.Write()(или тратит такты в Netpoller), удерживая в куче (Heap) срез на 2 МБ в течение 10 секунд. - При 1 000 таких клиентов в памяти зависает 2 ГБ живых аллокаций. Сборщик мусора Go (GC) входит в фазу паники, вызывая частые циклы Stop-The-World и сжигая CPU.
Сценарий с Nginx:
- Go-сервис выплевывает 2 МБ в сокет Nginx за 2 миллисекунды (пропускная способность локальной петли 10–40 Гбит/с).
- Go-хендлер завершает работу, горутина возвращается в пул, а память буфера мгновенно маркируется сборщиком мусора как свободная.
- Nginx сохраняет ответ в свои компактные C-структуры (
proxy_buffers 16 64k;), а при нехватке памяти сбрасывает их во временный файл на диск. - 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 запрос, но в процессе рукопожатия клиент запрашивает переключение протокола через заголовки:
Connection: Upgrade
Upgrade: websocket
По спецификации HTTP/1.1 заголовки Connection и Upgrade являются hop-by-hop (действуют только на одно соединение). Nginx по умолчанию удаляет их при проксировании. Чтобы WebSocket заработал сквозь Nginx к вашему Go-бэкенду, требуется явная настройка:
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;
}
Итог
- Reverse Proxy как защитный экран: Nginx скрывает физическую топологию бэкенда, агрегирует соединения через
upstream keepaliveи защищает Go от сетевых аномалий. - Заголовки
X-Forwarded-*: Жизненно важный мост для передачи информации (Host,X-Real-IP,X-Forwarded-For,X-Forwarded-Proto) от Nginx к Go. Ошибки в их обработке ведут к багам с авторизацией и редиректами. - Безопасность IP: Нельзя наивно доверять первому значению
X-Forwarded-For; используйте модульrealipв Nginx для отсечения спуфинга. - Буферизация (Response Buffering): Защищает кучу и сборщик мусора Go от медленных клиентов, но должна быть отключена (
proxy_buffering off;) для SSE и потокового контента. - WebSocket Upgrade: Требует явной трансляции заголовков
UpgradeиConnection, а также увеличенияproxy_read_timeout.
В следующей лекции мы разберем, как Nginx балансирует нагрузку между множеством инстансов Go-сервисов, какие существуют алгоритмы распределения трафика и как отслеживать здоровье бэкендов: 3. Load balancing.