Если ваш WebSocket за HAProxy падает в 502 Bad Gateway, почти всегда виноваты timeouts, неверный Upgrade, HTTP/2 на фронте или избыточная оптимизация keep-alive. Разберём, как работает WebSocket в HAProxy, какие таймауты реально играют после 101 Switching Protocols, где чаще всего рождается 502, и как применять stick-table для липких сессий и защиты от перегруза. Для wss понадобится TLS: здесь пригодятся SSL-сертификаты.
Как WebSocket проходит через HAProxy
WebSocket начинается как обычный HTTP/1.1 GET с заголовками Connection: Upgrade и Upgrade: websocket. Бэкенд отвечает 101 Switching Protocols, после чего прокси переводит подключение в туннель (поток байтов без HTTP-фрейминга). В HAProxy этот момент критичен: перестают действовать часть HTTP-таймаутов, зато вступают в силу timeout tunnel, а также общие timeout client и timeout server. Для устойчивости полезно включать option tcpka на длинных соединениях.
Отсюда выводы:
- Если не настроить
timeout tunnel, соединения будут обрываться по более коротким HTTP-таймаутам. - Если фронт говорит с клиентом по HTTP/2, а вы ждёте HTTP/1.1 Upgrade, возможны неожиданные обрывы и 502.
- Проверки живости (health checks) не должны ходить по WebSocket-эндпойнту: ответ 101 ломает многие политики проверки и выбивает сервер из пула.
Где рождается 502 и как распознать причину
Типичные источники 502 при WebSocket через HAProxy:
- Сработал таймаут: слишком короткий
timeout tunnel,timeout serverилиtimeout client. Например, приложение шлёт ping раз в минуту, а туннель стоит 30 секунд. - HTTP/2 на фронте и попытка Upgrade: клиент договорился на h2 по ALPN, но Upgrade для WebSocket нужен именно на HTTP/1.1 (если не используется RFC 8441).
- Заголовки Upgrade/Connection не проходят или переписываются: агрессивные правила могут сломать апгрейд.
- Health-check-и ожидают 200, а бэкенд отвечает 101 при Upgrade — сервер помечается как down и отдаёт 502.
- Не тот режим балансировки в stateful-приложении: клиент прыгает между инстансами, теряет состояние и инициирует повторное соединение; на пике это может проявляться как 502.
По логам HAProxy удобно отличать обрыв по таймауту от закрытия со стороны бэкенда: смотрите длительность, termination state и кто закрыл первым. Захватывайте заголовки Upgrade/Connection, чтобы убедиться, что апгрейд случился.
Рабочая базовая конфигурация (HTTP mode + Upgrade)
Минимальный, но практичный каркас для WebSocket через HAProxy 2.x. Он разделяет путь для WebSocket, настраивает корректные таймауты и не допускает HTTP/2 на фронте там, где нужен Upgrade.
# haproxy.cfg (фрагменты)
global
log 127.0.0.1 local0 info
# При необходимости увеличьте буферы, если крупные заголовки
# tune.bufsize 65536
# tune.maxrewrite 8192
defaults
mode http
log global
option httplog
option dontlognull
option tcpka
timeout connect 5s
timeout client 1m
timeout server 1m
timeout http-request 10s
timeout http-keep-alive 1m
timeout tunnel 1h
# Отдельный фронт для WSS с гарантированным HTTP/1.1 (без h2)
frontend fe_wss
bind :443 ssl crt /etc/haproxy/certs/site.pem alpn http/1.1
http-response set-header X-Proxy Via-HAProxy
# Для диагностики: логируем заголовки апгрейда
log-format "%ci:%cp [%tr] %ft %b/%s %TR/%Tw/%Tc/%Tr/%Tt %ST %B %ac/%fc/%bc/%sc/%rc %sq/%bq %{+Q}r upg=%[req.hdr(Upgrade)] conn=%[req.hdr(Connection)]"
acl is_websocket hdr(Upgrade) -i websocket
acl is_upgrade hdr(Connection) -i upgrade
use_backend be_app_ws if is_websocket is_upgrade
default_backend be_app_http
backend be_app_http
balance roundrobin
option httpchk GET /health HTTP/1.1\r\nHost:\ app.local\r\nConnection:\ close
server app1 10.0.0.10:8080 check inter 2s fall 3 rise 2
server app2 10.0.0.11:8080 check inter 2s fall 3 rise 2 slowstart 5s
backend be_app_ws
balance source
option http-keep-alive
option tcpka
# Для stateful сокетов желательно прилипание по IP
stick-table type ip size 200k expire 1h
stick on src
# Важно: НЕ включать httpclose/http-server-close здесь
server app1 10.0.0.10:8080 check inter 2s fall 3 rise 2
server app2 10.0.0.11:8080 check inter 2s fall 3 rise 2
Комментарии к настройкам:
alpn http/1.1на фронтенде исключает HTTP/2 для WebSocket-эндпойнта. Это снимает половину загадочных 502, связанных с Upgrade.timeout tunnel 1hкритичен: после 101 ваша сессия живёт в туннеле. Если приложение отправляет ping раз в 30–60 секунд, часовой туннель не разрывает соединения преждевременно.balance sourceиstick on srcобеспечивают «липкость» к серверу для stateful-приложений.option httpchkв HTTP-бэкенде проверяет обычный путь/health, а не WebSocket-эндпойнт. Ответ должен быть 200/204, а не 101.
Какие timeouts действительно важны для WebSocket
Разложим по полочкам ключевые таймауты, влияющие на стабильность WebSocket:
timeout http-request— ожидание полного HTTP-запроса клиента до апгрейда. Слишком короткий таймаут даст 408/502 на старте.timeout clientиtimeout server— общий простой чтения/записи на сторонах. После апгрейда они учитываются, но при наличииtimeout tunnelименно он определяет поведение туннеля.timeout tunnel— главный параметр после 101. Если трафик молчит дольше этого значения, HAProxy разорвёт соединение. Практично ставить 30–120 минут.timeout http-keep-alive— влияет до апгрейда; слишком маленькое значение может оборвать соединение перед Upgrade.timeout connect— важно при пиках/холодном старте; маленькое значение даст 502 при установке серверного соединения.
Практическое правило: держите
timeout tunnelзаведомо больше интервала пингов приложения и TCP keepalive. Если клиент шлёт ping раз в 25–30 секунд, ставьте минимум 5–10 минут; для фоновых сокетов — 30–60 минут.
Диагностика и разбор логов
Для ускоренной диагностики включите расширенный формат логов с захватом Upgrade/Connection — это покажет, были ли заголовки у клиента и прошли ли они до бэкенда:
log-format "%ci:%cp [%tr] %ft %b/%s %TR/%Tw/%Tc/%Tr/%Tt %ST %B %ac/%fc/%bc/%sc/%rc %sq/%bq %{+Q}r upg=%[req.hdr(Upgrade)] conn=%[req.hdr(Connection)]"
Сценарии и трактовка:
- В логах
upg=websocket,conn=upgrade, но большой%Ttи%ST=502— вероятен таймаут туннеля или закрытие бэкендом. Проверьтеtimeout tunnelи логи приложения. - Пустые
upg/connпри обращении к WebSocket-пути — клиент не отправил Upgrade, либо фронт принудил h2. - 502 сразу после попытки — возможно, бэкенд помечен как down. Проверьте health-check-и и маршрут именно к WS-бэкенду.
HTTP/2 и WebSocket: как не наступить на грабли
Частая причина 502 — ALPN с h2 на фронте, когда приложение и клиент используют HTTP/1.1 Upgrade. Чтобы исключить конфликт, выделите отдельный bind/фронтенд под WebSocket с alpn http/1.1 (как в примере выше). Альтернативы:
- Разделить по SNI: один сертификат/бандл и два
bind— с h2 для обычного трафика и без h2 для домена/поддомена, где живёт WebSocket. - Разделить по пути: направлять
/ws,/socket.io/и т. п. на фронт без h2.
Если хотите глубже в тему h2 и совместимость прокси, посмотрите разбор нюансов HTTP/2 и gRPC в HAProxy. Для классического WebSocket Upgrade оставайтесь на HTTP/1.1.

Health-check-и для пулов с WebSocket
Ловушка — проверять живость по WebSocket-пути. Бэкенд будет отвечать 101, а не 200, и серверы выпадут из пула. Решение — обычный HTTP-здоровый эндпойнт, например /health в отдельном HTTP-бэкенде:
backend be_app_http
option httpchk GET /health HTTP/1.1\r\nHost:\ app.local\r\nConnection:\ close
server app1 10.0.0.10:8080 check inter 2s fall 3 rise 2
Держите разделение: Upgrade-трафик идёт в be_app_ws, проверки живости — в be_app_http. Если у приложения один порт, пропускайте checks на обычный путь, а WebSocket обслуживайте на другом URL.
Защита и липкость с помощью stick-table
Stick-table в HAProxy дают две вещи для WebSocket: прилипаемость клиента к инстансу и контроль нагрузки (лимиты). Ниже примеры трекинга и rate-limit на рукопожатие:
frontend fe_wss
bind :443 ssl crt /etc/haproxy/certs/site.pem alpn http/1.1
acl is_ws hdr(Upgrade) -i websocket
acl is_up hdr(Connection) -i upgrade
stick-table type ip size 200k expire 30m store conn_cur,conn_rate(30s),http_req_rate(30s)
http-request track-sc0 src if is_ws is_up
# Не более 50 одновременных сокетов с одного IP
http-request deny deny_status 429 if { sc_conn_cur(0) gt 50 }
# Не более 20 апгрейдов за 10 секунд
http-request deny deny_status 429 if { sc_http_req_rate(0) gt 20 }
use_backend be_app_ws if is_ws is_up
default_backend be_app_http
Эти лимиты прикрывают аномалии и помогают сохранить доступность бэкенда под всплесками соединений. Подробнее о возможностях — в материале подробный разбор stick-table и rate-limit.

Типовые ловушки и анти-паттерны
- Включён httpclose/http-server-close в WS-бэкенде. После 101 соединение потоковое; принудительное закрытие на уровне HTTP рушит туннель.
- Слишком маленькие буферы. Если заголовки крупные (много cookies), повышайте
tune.bufsizeиtune.maxrewrite. - TCP keepalive выключен. Для длинноживущих сокетов включайте
option tcpka. - Смешение h2 и Upgrade на одном bind без явной логики. Если оставляете h2, маршрутизируйте пути на фронт с принудительным HTTP/1.1.
- Ожидание 200 от health-check-а на WS-пути. Всегда проверяйте отдельный HTTP-эндпойнт.
Когда имеет смысл TCP mode
Иногда WS-путь проще отдать через mode tcp, особенно если нужен «прозрачный» туннель без HTTP-логики на фронте, а маршрутизация делается по SNI/порту. Минусы — нет анализа заголовков и HTTP-rate-limit; плюсы — минимальная вовлечённость HAProxy в протокол. Пример простого TCP-фронта с длинными таймаутами:
frontend fe_wss_tcp
mode tcp
bind :8443 ssl crt /etc/haproxy/certs/site.pem
option tcplog
option tcpka
timeout client 1h
default_backend be_wss_tcp
backend be_wss_tcp
mode tcp
balance source
option tcpka
timeout server 1h
server app1 10.0.0.10:8080 check
server app2 10.0.0.11:8080 check
TCP-режим используйте, если не полагаетесь на HTTP Upgrade-логику и не делаете маршрутизацию по заголовкам/пути. Для стабильности продакшена удобнее управлять такими нагрузками на выделенном VDS.
Чек-лист быстрых проверок при 502
- Путь WebSocket обслуживается фронтом с
alpn http/1.1(без h2)? - В логах фронта видны
Upgrade: websocketиConnection: upgrade? timeout tunnelдостаточно велик и согласован с ping/pong вашего приложения?- Health-check-и проверяют обычный HTTP-эндпойнт, а не WS-путь?
- Нет ли
httpclose/http-server-closeв WS-бэкенде? - Буферы (
tune.bufsize) покрывают размер заголовков? - Включён ли
option tcpkaдля долгоживущих соединений через NAT/фаерволы? - Для stateful-приложения включены липкие сессии (balance source + stick on src)?
Итоги
Причина 502 при WebSocket за HAProxy почти всегда в деталях конфигурации: неверные или недостаточные timeouts, конфликт HTTP/2 с Upgrade, агрессивные закрытия соединений или ошибочные health-check-и. Настройте фронт под HTTP/1.1 для WS, дайте туннелю щедрый timeout tunnel, отделите проверку живости от Upgrade-пути и используйте stick-table для липкости и защиты. Такая схема стабильно переживает NAT и сетевые колебания без 502, а у вас остаются прозрачные точки наблюдения и рычаги управления нагрузкой.


