До этого урока ты гонял через nginx классику: пришёл запрос, ушёл ответ, соединение можно закрывать или держать в keepalive. Этой модели хватает для сайтов и REST API. Но как только в проекте появляется чат, биржевой тикер, онлайн-дашборд, нотификации, gRPC между сервисами или поток событий от сервера - привычная схема ломается. Соединение живёт минутами и часами, данные текут в обе стороны, и буфер, который раньше тебя выручал, теперь становится врагом.
Симптомы знакомые: WebSocket валится с ошибкой 400 или просто не апгрейдится, gRPC отдаёт upstream sent invalid response, а Server-Sent Events доходят до браузера пачкой через минуту вместо потока в реальном времени. Девять из десяти таких тикетов - это nginx, который ведёт себя ровно так, как его настроили: режет заголовки апгрейда, буферизует ответ, рубит долгое соединение по короткому таймауту. Разберёмся, как заставить nginx websocket proxy работать честно, как проксировать gRPC и SSE, и почему тут всё держится на трёх-четырёх директивах, которые легко забыть.

WebSocket: проблема Upgrade и nginx upgrade connection
WebSocket начинается как обычный HTTP/1.1-запрос, но с особым набором заголовков. Клиент шлёт:
Код: Выделить всё
GET /ws HTTP/1.1
Host: chat.example.com
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
В чём подвох с nginx. Заголовки Connection и Upgrade - это так называемые hop-by-hop заголовки. По спецификации HTTP они относятся к одному сетевому участку, а не сквозные. Прокси обязан их не пробрасывать как есть, а обрабатывать сам. Поэтому nginx по умолчанию вырезает эти заголовки при проксировании. Бэкенд их не видит, апгрейда не происходит, клиент получает обычный ответ вместо 101 - рукопожатие провалено.
Чтобы nginx proxy websocket заработал, апгрейд надо пробросить руками. И тут есть тонкость: пробрасывать Connection: upgrade в лоб нельзя. Если по этому же location пойдёт обычный HTTP-запрос (а так и будет - тот же бэкенд часто отдаёт и страницы, и сокеты), ему не нужен upgrade, ему нужен close или keepalive. Нужна развилка по значению входящего заголовка. Эту развилку и делает классический map - именно он стоит в http.conf эталонного проекта:
Код: Выделить всё
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
Дальше в location, который проксирует сокет, нужно собрать nginx upgrade connection целиком:
Код: Выделить всё
location /ws/ {
proxy_pass http://chat_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
}
- proxy_http_version 1.1 - обязательно. Механизм Upgrade появился только в HTTP/1.1. Заметь: с nginx 1.29.7 (вошло в stable 1.30) соединение к бэкенду по умолчанию уже HTTP/1.1 с keepalive, раньше был HTTP/1.0 close. Но для совместимости со старыми версиями и для явности эту строку всё равно ставят - и для апгрейда она критична.
- proxy_set_header Upgrade $http_upgrade - возвращаем вырезанный заголовок Upgrade обратно, беря его из исходного запроса клиента.
- proxy_set_header Connection $connection_upgrade - та самая переменная из map. Для сокета уйдёт Connection: upgrade, для обычного запроса - Connection: close.
- proxy_buffering off - выключаем буферизацию ответа. Для WebSocket это не строго обязательно (фреймы и так проходят после апгрейда), но буфер мешает интерактиву и съедает память на долгом соединении. На двунаправленном туннеле он только во вред.
- proxy_read_timeout / proxy_send_timeout - вот это забывают чаще всего. По умолчанию proxy_read_timeout - 60 секунд. Это значит: если по сокету 60 секунд нет данных от бэкенда, nginx молча закрывает соединение. Для чата, где между сообщениями бывают долгие паузы, это смерть. Поднимаем до часа или больше. Альтернатива - heartbeat-пинги от приложения чаще, чем таймаут, но таймаут всё равно полезно растянуть.
nginx grpc: проксирование gRPC поверх HTTP/2
gRPC - это RPC-фреймворк от Google, который технически работает поверх HTTP/2: каждый вызов - это HTTP/2-поток с телом в формате protobuf и трейлерами. Ключевое отличие от WebSocket: gRPC не использует Upgrade. Он требует именно HTTP/2 от начала и до конца, мультиплексирование потоков в одном соединении и поддержку трейлеров (HTTP-заголовки после тела - там лежит grpc-status). Через обычный proxy_pass gRPC проксировать нельзя: модуль proxy не умеет HTTP/2 к бэкенду и потеряет трейлеры. Для этого есть отдельный модуль ngx_http_grpc_module и директива grpc_pass.
Чтобы nginx grpc заработал, нужны три вещи: HTTP/2 на входе, grpc_pass на бэкенд, и аккуратные таймауты. Минимальный рабочий конфиг с TLS-терминацией:
Код: Выделить всё
upstream grpc_backend {
server 10.0.0.10:50051;
server 10.0.0.11:50051;
keepalive 32;
}
server {
listen 443 ssl;
http2 on;
server_name grpc.example.com;
ssl_certificate /etc/nginx/ssl/fullchain.pem;
ssl_certificate_key /etc/nginx/ssl/privkey.pem;
location / {
grpc_pass grpc://grpc_backend;
grpc_set_header X-Real-IP $remote_addr;
grpc_read_timeout 3600s;
grpc_send_timeout 3600s;
}
}
- http2 on; - именно отдельная директива, а не listen ... http2. С nginx 1.25.1 параметр listen http2 устарел, правильный синтаксис - separate http2 on; в server-блоке. gRPC без HTTP/2 на входе невозможен в принципе - клиент шлёт HTTP/2-фреймы.
- grpc_pass grpc://... для plaintext-бэкенда (TLS терминируется на nginx, до бэкенда идёт чистый HTTP/2). Если бэкенд сам с TLS - grpc_pass grpcs://. Очень частая ошибка - перепутать схему: написать grpc:// к TLS-бэкенду или наоборот, и получить непонятный сброс соединения.
- grpc_set_header, а не proxy_set_header. У grpc-модуля свой набор директив-зеркал: grpc_read_timeout, grpc_send_timeout, grpc_connect_timeout, grpc_buffer_size. Директивы proxy_* на grpc_pass не действуют.
- keepalive в upstream - почти обязателен. gRPC-канал - это долгоживущее HTTP/2-соединение, по которому идёт много вызовов. Без пула keepalive nginx будет открывать новый коннект к бэкенду слишком часто.
Про health checks: активных проверок здоровья в open-source nginx нет, только пассивные max_fails/fail_timeout. Если нужны активные health checks по gRPC и slow_start для прогрева - это бесплатно есть в Angie, форке на базе nginx. Для чистого nginx довольствуйся пассивной логикой.
nginx sse и nginx стриминг: убить буфер
Server-Sent Events (SSE) - это односторонний поток событий от сервера к браузеру поверх обычного HTTP. Никакого Upgrade, никакого HTTP/2 не требуется: сервер держит соединение открытым с Content-Type: text/event-stream и шлёт строки данных по мере событий. Браузерный EventSource их принимает в реальном времени. Сюда же относится любой chunked-стриминг: построчный вывод логов, прогресс длинной операции, потоковая генерация текста от LLM-бэкенда.
И вот здесь nginx стриминг убивает одна включённая по умолчанию настройка - proxy_buffering on. Когда буферизация включена, nginx читает ответ бэкенда в свой буфер и отдаёт клиенту только когда буфер заполнен или ответ закончился. Для статики это хорошо: бэкенд быстро освобождается, клиент тянет в своём темпе. Для потока это катастрофа: события копятся в буфере nginx и доходят до браузера пачкой с задержкой в секунды или минуты. Realtime превращается в not-real-at-all. Это та самая грабля, ради которой написан этот урок.
Лечение - выключить буферизацию на потоковом location:
Код: Выделить всё
location /events {
proxy_pass http://sse_backend;
proxy_http_version 1.1;
proxy_set_header Connection '';
proxy_buffering off;
proxy_cache off;
proxy_read_timeout 3600s;
add_header X-Accel-Buffering no;
chunked_transfer_encoding on;
}
- proxy_buffering off - главное. Теперь nginx отдаёт каждый чанк клиенту сразу, как получил его от бэкенда. Поток течёт в реальном времени.
- proxy_set_header Connection '' - чистим заголовок Connection, чтобы keepalive к бэкенду работал корректно (актуально для совместимости и при proxy_http_version 1.1 к старым бэкендам).
- proxy_cache off - кэш и стриминг несовместимы. Кэшировать бесконечный поток событий бессмысленно и опасно.
- X-Accel-Buffering: no - тонкий и недооценённый инструмент. Это не директива конфига, а заголовок, который сам бэкенд может прислать в ответе, чтобы динамически попросить nginx не буферизовать именно этот ответ. Удобно, когда буферизацию хочется глобально оставить on (для обычных ответов), но для конкретного потокового эндпоинта выключить со стороны приложения, не трогая конфиг nginx. nginx видит X-Accel-Buffering: no и отключает буфер для этого ответа.
Типичные грабли
- Забыли map, написали Connection upgrade в лоб. Тогда обычные запросы к тому же location получают Connection: upgrade и ломаются, либо apps жалуются на странное поведение keepalive. Всегда через переменную $connection_upgrade.
- Variable connection_upgrade is not defined. nginx -t падает, потому что блок map забыли положить в контекст http (а сунули в server или вообще пропустили). map живёт только в http.
- WebSocket рвётся ровно через 60 секунд. Дефолтный proxy_read_timeout. Лечится длинным таймаутом плюс heartbeat от приложения.
- SSE доходит пачкой. proxy_buffering on. Выключить на потоковом location или прислать X-Accel-Buffering: no с бэкенда.
- gRPC: upstream sent invalid response. Чаще всего на входе нет http2 on, либо использован proxy_pass вместо grpc_pass, либо перепутаны grpc:// и grpcs://.
- gRPC балансируется не как ждали. Помни: распределяются соединения, а не RPC внутри мультиплексированного HTTP/2-канала.
- Apple/корпоративный прокси режет апгрейд. Если перед nginx стоит ещё один прокси или CDN, апгрейд надо пробросить на каждом хопе цепочки. Один забытый прокси посередине - и сокет не поднимется.
Поднимем эхо-сокет и пропустим его через nginx. Нужен любой бэкенд, отдающий WebSocket - возьмём готовый тестовый.
- Шаг 1. Запусти простой ws-эхо-сервер. Если есть Node: создай файл и поставь websocket-библиотеку, либо возьми любой готовый ws-echo на порту 9001. Проверь локально wscat -c ws://127.0.0.1:9001 - должно соединяться и эхать твои строки.
- Шаг 2. В http-контексте nginx добавь блок map (если его ещё нет):
Код: Выделить всё
map $http_upgrade $connection_upgrade { default upgrade; '' close; } - Шаг 3. Добавь server с location для сокета:
Код: Выделить всё
server { listen 8080; location /ws/ { proxy_pass http://127.0.0.1:9001/; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_read_timeout 3600s; } } - Шаг 4. Проверь конфиг и перезагрузи: nginx -t и nginx -s reload. Вывод nginx -t должен сказать syntax is ok и test is successful.
- Шаг 5. Подключись через nginx: wscat -c ws://127.0.0.1:8080/ws/. Введи строку - должно вернуться эхо. Сокет работает через прокси.
- Шаг 6. Сломай специально: закомментируй строку proxy_set_header Upgrade, сделай reload, подключись снова. Получишь ошибку рукопожатия - наглядно видно, что апгрейд вырезается без явного проброса. Верни строку обратно.
- Шаг 7. Проверь таймаут: поставь proxy_read_timeout 10s, подключись и молчи 11 секунд - nginx разорвёт сокет. Это та самая грабля коротких таймаутов. Верни 3600s.
- Почему заголовки Connection и Upgrade нельзя просто пробросить как есть, и зачем для этого нужен блок map $http_upgrade $connection_upgrade?
- Чем проксирование gRPC отличается от обычного proxy_pass и почему для него обязателен http2 on на входе?
- Какая директива чаще всего ломает SSE и потоковую отдачу, и какими двумя способами (со стороны конфига и со стороны бэкенда) это лечится?
- Почему WebSocket-соединение может стабильно рваться примерно раз в минуту, даже если конфиг апгрейда корректен?
Не-обычный HTTP в nginx держится на коротком списке директив, которые легко забыть. WebSocket - это map $http_upgrade $connection_upgrade плюс проброс Upgrade/Connection, proxy_http_version 1.1 и длинные таймауты; в эталонном проекте это вынесено в global_proxy.conf один раз на всё. gRPC - это grpc_pass поверх обязательного http2 on, со своими grpc_set_header и пониманием, что балансируются соединения, а не RPC. SSE и стриминг - это в первую очередь proxy_buffering off и X-Accel-Buffering: no, иначе realtime превращается в задержку. Запомни главную мысль урока: буферизация и короткие таймауты - твои друзья на обычном HTTP и твои враги на всём, что живёт долго и течёт потоком.