22. WebSocket
Двунаправленный Real-Time, Hijacking и масштабирование в Go
«Компьютерная сеть — не просто механизм скачивания страниц, а провод под током, связывающий воедино процессы по всему миру».
— Брайан Керниган
Двунаправленный реалтайм: Когда инициатива у сервера
В предыдущих главах мы подробно исследовали классическую модель «Запрос-Ответ» в REST и мультиплексированные потоки в 18. gRPC streaming. Если gRPC представляет собой непревзойденный стандарт для внутреннего межсервисного общения (Backend-to-Backend), то при работе с клиентскими браузерами (Single Page Applications) инженеры сталкиваются с жесткими аппаратными и протокольными ограничениями.
Браузерные движки не предоставляют JavaScript-коду прямого доступа к низкоуровневым бинарным фреймам HTTP/2, из-за чего gRPC-стриминг в вебе возможен только через тяжелые сторонние прокси (gRPC-Web). Если нам необходимо разработать интерактивный чат, биржевой торговый терминал с обновлением котировок в реальном времени, сервис совместного редактирования документов или многопользовательскую веб-игру, нам требуется протокол, который нативно поддерживается всеми браузерами и позволяет серверу самостоятельно выталкивать (Push) данные клиенту в любой непредсказуемый момент времени.
Абсолютным стандартом для этой задачи является протокол WebSocket (RFC 6455).
Для инженера уровня Senior WebSocket — это не просто «сокет в браузере». Это долгоживущий полнодуплексный Stateful-протокол поверх TCP, который фундаментально меняет подходы к масштабированию, развертыванию (Deployment) и управлению памятью в рантайме Go.
Анатомия протокола: От HTTP к сырому TCP
С точки зрения операционной системы и сетевого оборудования, жизненный цикл WebSocket начинается как совершенно обычный HTTP/1.1 запрос. Это гениальный архитектурный трюк, позволивший веб-сокетам беспрепятственно проходить сквозь корпоративные брандмауэры, прокси-серверы и балансировщики (Nginx, Envoy), работающие на стандартных портах 80 (HTTP) и 443 (HTTPS).
Шаг 1: Handshake (Протокольное рукопожатие)
Клиент отправляет стандартный HTTP GET запрос, снабженный специальными заголовками согласования протокола:
GET /chat HTTP/1.1
Host: api.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Шаг 2: Hijacking (Угон сетевого соединения)
Если сервер готов перейти на веб-сокеты, рантайм Go через интерфейс http.Hijacker буквально отбирает физический сокет (net.Conn) у встроенного HTTP-сервера. HTTP-мультиплексор полностью прекращает управление этим соединением. Сервер отвечает статусом 6. Статусы HTTP 101 Switching Protocols, вычисляет криптографический заголовок Sec-WebSocket-Accept (SHA-1 от ключа клиента с добавлением магической константы UUID) и передает открытый дескриптор в распоряжение цикла WebSocket:
flowchart TD
Client["Веб-браузер / JS Client"] -->|"HTTP GET /chat<br>Upgrade: websocket"| Server["Go net/http Server"]
Server -->|"HTTP 101 Switching Protocols<br>Sec-WebSocket-Accept"| Client
Server -->|"Угон сокета<br>http.Hijacker -> net.Conn"| WSHandler["Go WebSocket Handler"]
WSHandler <-->|"Полнодуплексные бинарные фреймы<br>(без HTTP заголовков)"| Client
WSHandler <-->|"Служебные Ping-Pong фреймы<br>(Heartbeat)"| Client
classDef cli fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#e2e8f0;
classDef srv fill:#1e293b,stroke:#10b981,stroke-width:1px,color:#e2e8f0;
classDef ws fill:#1e293b,stroke:#f59e0b,stroke-width:1px,color:#e2e8f0;
class Client cli;
class Server srv;
class WSHandler ws;
Идиоматичный Go: Архитектура на горутинах
В экосистеме Go стандартом де-факто для реализации WebSocket является библиотека github.com/gorilla/websocket (а также современный альтернативный пакет github.com/coder/websocket, ранее развивавшийся как nhooyr.io/websocket).
Классический паттерн обслуживания одного WebSocket-клиента в Go всегда требует выделения двух специализированных горутин:
readPump— горутина-читатель, непрерывно заблокированная на чтении входящих сообщений из сокета.writePump— горутина-писатель, читающая исходящие байты из буферизованного Go-канала и записывающая их в сокет.
Попытка объединить чтение и запись в одном цикле приведет к зависанию: медленный клиент или переполненный TCP-буфер заблокируют чтение входящих команд.
package chat
import (
"log"
"net/http"
"time"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
// В production обязательно проверяйте Origin для защиты от CSWSH атак!
CheckOrigin: func(r *http.Request) bool { return true },
}
// Client оборачивает TCP сокет
type Client struct {
conn *websocket.Conn
send chan []byte // Буферизованный канал для исходящих сообщений
}
// Обработчик HTTP
func ServeWS(w http.ResponseWriter, r *http.Request) {
// Угон соединения: net.Conn переходит к gorilla/websocket
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("upgrade error: %v", err)
return
}
client := &Client{
conn: conn,
send: make(chan []byte, 256),
}
// Запускаем две независимые горутины
go client.writePump()
go client.readPump()
}
Проблема мертвых душ: Ping / Pong и утечки памяти
Физический сетевой кабель несовершенен. Если пользователь смартфона заходит в подземный паркинг или туннель метро, сотовая вышка теряет сигнал мгновенно, без отправки пакета закрытия TCP FIN.
Для ядра Linux и вашего Go-сервера такое соединение продолжает числиться активным («полуоткрытый сокет»). Сервер продолжает удерживать в оперативной памяти дескриптор сокета, горутины readPump и writePump, буферы каналов и структуры клиента. На масштабе в 100 000 мобильных пользователей это приводит к тихой утечке гигабайтов RAM (Memory Leak).
Системный механизм TCP Keep-Alive часто сбрасывается облачными балансировщиками (AWS ALB/NLB). Для решения этой проблемы спецификация WebSocket ввела прикладные контрольные фреймы: Ping и Pong.
// Идиоматичный ReadPump с контролем дедлайнов
func (c *Client) readPump() {
defer func() {
c.conn.Close()
}()
// Если клиент не ответил на Ping за 60 секунд - он мертв
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
// При получении Pong от клиента - сдвигаем дедлайн смерти
c.conn.SetPongHandler(func(string) error {
c.conn.SetReadDeadline(time.Now().Add(60 * time.Second))
return nil
})
for {
_, message, err := c.conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
log.Printf("error: %v", err)
}
break // Выход из цикла завершает горутину и освобождает память
}
// Обработка message...
}
}
Горутина writePump настраивается на регулярную отправку контрольного кадра Ping каждые 50 секунд (с помощью time.Ticker). Если за 60 секунд ответный Pong не вернулся, метод ReadMessage() разблокируется с ошибкой таймаута ввода-вывода, цикл завершается, сокет закрывается, и сборщик мусора Go моментально освобождает память.
Масштабирование WebSockets: Архитектурный вызов
В классических stateless REST-сервисах (3. REST. Основные принципы) горизонтальное масштабирование тривиально: мы запускаем 10 новых реплик в Kubernetes, и балансировщик Nginx равномерно распределяет запросы между ними.
Веб-сокеты фундаментально Stateful (хранят состояние соединения). Клиент привязан к конкретному физическому процессу Go на все время существования сессии, которая может длиться часами.
Представьте распределенную проблему:
- Пользователь Алиса подключена к инстансу
Pod-1. - Пользователь Боб подключен к инстансу
Pod-2. - Алиса отправляет в чат сообщение для Боба.
Каким образом Pod-1 доставит байты в сокет Боба, дескриптор которого физически находится в адресном пространстве оперативной памяти Pod-2?
Решение: Централизованная шина Pub/Sub (Redis или NATS)
Для межнодовой маршрутизации событий внедряется распределенный брокер:
- Пользователь Алиса отправляет сообщение в
Pod-1. Pod-1публикует событие в брокер:PUBLISH chat_room_1 "Привет".- Все реплики кластера (
Pod-1,Pod-2,Pod-N) подписаны на топикSUBSCRIBE chat_room_1. Pod-2принимает событие из брокера Redis, находит Боба в своей локальной мапеmap[string]*Clientи помещает байты в его персональный каналclient.send.
flowchart TD
UserA["Алиса (User A)"] <-->|"WebSocket Соединение"| Pod1["Go Сервис Pod-1"]
UserB["Боб (User B)"] <-->|"WebSocket Соединение"| Pod2["Go Сервис Pod-2"]
Pod1 -->|"PUBLISH chat_room_1"| Redis["Брокер Redis / NATS Pub-Sub"]
Redis -->|"Broadcast"| Pod1
Redis -->|"Broadcast"| Pod2
Pod2 -->|"Локальный поиск в памяти<br>client.send <- data"| UserB
classDef usr fill:#1e293b,stroke:#38bdf8,stroke-width:1px,color:#e2e8f0;
classDef pod fill:#1e293b,stroke:#10b981,stroke-width:1px,color:#e2e8f0;
classDef pub fill:#1e293b,stroke:#f59e0b,stroke-width:1px,color:#e2e8f0;
class UserA,UserB usr;
class Pod1,Pod2 pod;
class Redis pub;
Итог
- WebSocket — это полнодуплексный двунаправленный протокол реального времени, идеально подходящий для интерактивных браузерных приложений (чаты, трейдинг, игры).
- Идиоматичная архитектура в Go: на каждого клиента выделяется пара горутин (
readPumpиwritePump) с буферизованным каналом передачи, исключающим фатальные паники при конкурентной записи. - Heartbeat-механизм (Ping/Pong): обязательное условие для своевременного обнаружения «мертвых» полуоткрытых сокетов и защиты сервера от утечек дескрипторов и памяти.
- Горизонтальное масштабирование Stateful-сервисов: требует интеграции с централизованной шиной сообщений (Redis Pub/Sub или NATS) для межсервисной маршрутизации событий.
Веб-сокеты обеспечивают максимальную гибкость, однако платой за это являются сложная маршрутизация через брокеры и постоянное удержание двух горутин на каждое соединение. Но что делать, если нашему приложению не требуется полнодуплексный обмен, а серверу нужно лишь в одну сторону транслировать уведомления или биржевые котировки в браузер? Существует ли более легковесный стандарт, работающий поверх обычного HTTP без «угона» сокетов и бинарных фреймов? Да, и эта технология детально рассматривается в нашей следующей статье: 23. Server Sent Events.