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 запрос, снабженный специальными заголовками согласования протокола:

HTTP
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 всегда требует выделения двух специализированных горутин:

  1. readPump — горутина-читатель, непрерывно заблокированная на чтении входящих сообщений из сокета.
  2. writePump — горутина-писатель, читающая исходящие байты из буферизованного Go-канала и записывающая их в сокет.

Попытка объединить чтение и запись в одном цикле приведет к зависанию: медленный клиент или переполненный TCP-буфер заблокируют чтение входящих команд.

Go
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.

Go
// Идиоматичный 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)

Для межнодовой маршрутизации событий внедряется распределенный брокер:

  1. Пользователь Алиса отправляет сообщение в Pod-1.
  2. Pod-1 публикует событие в брокер: PUBLISH chat_room_1 "Привет".
  3. Все реплики кластера (Pod-1, Pod-2, Pod-N) подписаны на топик SUBSCRIBE chat_room_1.
  4. 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;
  

Итог

  1. WebSocket — это полнодуплексный двунаправленный протокол реального времени, идеально подходящий для интерактивных браузерных приложений (чаты, трейдинг, игры).
  2. Идиоматичная архитектура в Go: на каждого клиента выделяется пара горутин (readPump и writePump) с буферизованным каналом передачи, исключающим фатальные паники при конкурентной записи.
  3. Heartbeat-механизм (Ping/Pong): обязательное условие для своевременного обнаружения «мертвых» полуоткрытых сокетов и защиты сервера от утечек дескрипторов и памяти.
  4. Горизонтальное масштабирование Stateful-сервисов: требует интеграции с централизованной шиной сообщений (Redis Pub/Sub или NATS) для межсервисной маршрутизации событий.

Веб-сокеты обеспечивают максимальную гибкость, однако платой за это являются сложная маршрутизация через брокеры и постоянное удержание двух горутин на каждое соединение. Но что делать, если нашему приложению не требуется полнодуплексный обмен, а серверу нужно лишь в одну сторону транслировать уведомления или биржевые котировки в браузер? Существует ли более легковесный стандарт, работающий поверх обычного HTTP без «угона» сокетов и бинарных фреймов? Да, и эта технология детально рассматривается в нашей следующей статье: 23. Server Sent Events.