Концепции

WebSocket

Нагрузочное тестирование WebSocket — сессии, стримы и ассерты сообщений

Обзор

perfscale нагружает WebSocket-endpoint'ы: открывает соединения, генерирует потоки сообщений из шаблонов, ждёт подходящие ответы и измеряет как handshake, так и прикладной round-trip сообщения — рядом с вашими HTTP/TCP/UDP-метриками.

WebSocket — часть open-source-движка: actions std/ws* доступны на любом плане.

Два стиля, свободно совместимых:

  • One-shot-сессия (std/ws@v1) — connect, обмен, close в одном шаге. Вся сессия — один сэмпл латенси, как FIX-сессия.
  • Живое соединение (std/ws-connect@v1 и компания) — соединение живёт между шагами внутри итерации и адресуется по id, который вернул connect. Позволяет чередовать WS с HTTP: подписка по WS, триггер по REST, ассерт push-сообщения.

One-shot-сессия

steps:
  - name: подписка и ожидание первого трейда
    uses: std/ws@v1
    with:
      url: wss://stream.example.com/feed
      messages:
        - send: '{"op":"subscribe","channel":"trades","id":"sub-${seq}"}'
          until_json: { type: trade }
    check:
      message_matches: { type: trade }
ПараметрПо умолчаниюОписание
urlЦель ws:// или wss:// (обязателен)
headersДополнительные заголовки handshake (auth-токены и т.п.)
subprotocolsПредлагаемые Sec-WebSocket-Protocol — список или одна строка (например graphql-ws)
skipTLSVerifyfalseПринять любой сертификат — только self-signed staging
connectionОбъект-профиль, задающий значения по умолчанию для любого поля
messages[]Что отправлять: строка-шаблон или { send, repeat, interval_ms, until_contains, until_json }
timeout10000Миллисекунды на всю сессию

Запись с правилом until_* дожидается подходящего ответа перед следующей — и даёт один сэмпл message RTT (см. Метрики).

Вывод:

{ "connected": true, "sent": 1, "received": 2, "messages": ["…"], "body": "…",
  "subprotocol": "graphql-ws", "duration_ms": 41.5,
  "metrics": { "ws_msgs_sent": 1, "ws_msgs_received": 2, "ws_msg_rtt": [8.1] } }

Шаг падает при ошибках handshake/транспорта или на записи, чьё правило until_* не совпало вовремя, — и всё равно отчитывается обо всём, что успело пройти до этого момента, плюс поле error с именем сбойной записи (message[i]: …). Ошибка handshake даёт { "connected": false, "error": "…", "duration_ms": … } и идёт в http_req_failed.

timeout — инлайн-параметр конкретного шага: timeout, записанный внутри профиля connection, игнорируется.

Живое соединение

# config.yaml — 25 конкурентных VU, у каждого своё соединение на итерацию
vus: 25
duration: 5m
# test.yaml
steps:
  - name: открыть фид
    uses: std/ws-connect@v1
    with: { url: "wss://stream.example.com/feed" }
    outputs: feed

  - name: подписаться
    uses: std/ws-send@v1
    with:
      id: "${{ feed.id }}"
      send: '{"op":"subscribe","id":"sub-${seq}"}'

  - name: дождаться подтверждения
    uses: std/ws-recv@v1
    with:
      id: "${{ feed.id }}"
      until_json: { type: subscribed }
    outputs: got

  - name: закрыть
    uses: std/ws-close@v1
    with: { id: "${{ feed.id }}" }
  • std/ws-connect@v1 — открывает соединение, возвращает { id, subprotocol, … }.
  • std/ws-send@v1 — шлёт send (текст, токены ${…} раскрываются на каждую отправку) или send_base64 (бинарный); repeat + interval_ms дают поток из N сообщений по одному шаблону.
  • std/ws-recv@v1 — читает до условия остановки: until_contains (подстрока), until_json (JSON-subset-совпадение) или просто count. Не дождались за timeout — шаг падает.
  • std/ws-ping@v1 — транспортный ping→pong; RTT в duration_ms шага.
  • std/ws-close@v1 — корректное закрытие с Close-handshake.

Соединение не переживает свою итерацию: всё, что сценарий оставил открытым, сбрасывается в конце итерации (жёстко — для аккуратного закрытия используйте ws-close).

Идентификаторы соединений

  • Id выдаются per-VU (ws-1, ws-2, …) и действительны только внутри текущей итерации этого VU — id не переходит ни в следующую итерацию, ни в другой VU.
  • std/ws-connect@v1 в блоке before: конфига бесполезен: контекст инициализации (и его сокеты) исчезает до старта VU.
  • Обращение к закрытому или неизвестному id роняет шаг с ошибкой "unknown connection id".

std/ws-connect@v1

Принимает те же параметры цели, что и one-shot-сессия (url, headers, subprotocols, skipTLSVerify, connection); timeout здесь ограничивает только handshake (по умолчанию 10000 мс). Вывод:

{ "id": "ws-1", "connected": true, "subprotocol": "graphql-ws", "duration_ms": 3.1 }

subprotocol — согласованный сервером протокол либо null, если сервер не выбрал ни одного. Handshake идёт в http_req_duration; неудачный handshake идёт в http_req_failed, а вывод имеет вид { "connected": false, "error": "…", "duration_ms": … }.

std/ws-send@v1

ПараметрПо умолчаниюОписание
idId соединения от std/ws-connect@v1 (обязателен)
sendодно из send/send_base64Текстовый payload; токены ${…} раскрываются на каждую отправку
send_base64одно из send/send_base64Бинарный payload (несовместим с send)
repeat1Сколько сообщений выпустить из одного шаблона
interval_ms0Пауза между повторными отправками, мс
timeout10000На весь цикл отправки, мс

Вывод: { "sent": N, "bytes": B, "duration_ms": …, "metrics": { "ws_msgs_sent": N } }. Ошибка транспорта роняет шаг и соединение; ошибка параметров (одновременно send и send_base64) оставляет его рабочим.

std/ws-recv@v1

ПараметрПо умолчаниюОписание
idId соединения (обязателен)
until_containsСтоп, когда сообщение содержит подстроку (несовместимо с until_json)
until_jsonСтоп, когда сообщение совпадает с объектом по JSON-subset — поля шаблона должны быть равны, лишние поля игнорируются
count1Без правила until_*: стоп после N сообщений с данными
timeout10000Дедлайн условия остановки, мс

Вывод:

{ "messages": ["…"], "body": "…", "count": 2, "matched": true, "duration_ms": 8.4,
  "metrics": { "ws_msgs_received": 2, "ws_msg_rtt": [7.9] } }

Текстовые фреймы приходят строками, бинарные — base64-строками; body — склеенная переводом строки текстовая форма, поэтому работает check: { body_contains: … }. matched показывает, достигнуто ли условие остановки, а все прочитанные по пути сообщения остаются в messages при любом исходе. Если пир закрыл соединение или транспорт умер раньше, шаг падает, в вывод добавляется поле error, а соединение сбрасывается; обычный таймаут тоже роняет шаг, но соединение остаётся рабочим. Ping/pong-фреймы, пришедшие во время чтения, игнорируются как шум транспорта.

std/ws-ping@v1

Вывод: { "pong": true, "duration_ms": 0.4 }. Принимает id и timeout (по умолчанию 10000 мс). RTT не агрегируется ни в одну гистограмму — при нужде ограничьте его через check: { duration_ms_lt: … }. Сообщения с данными, пришедшие во время ожидания pong, буферизуются для следующего std/ws-recv@v1. Отсутствие pong за timeout (или закрытое соединение) роняет шаг и соединение.

std/ws-close@v1

Отправляет Close-фрейм и ждёт подтверждение пира. Принимает id, code (по умолчанию 1000, нормальное закрытие), reason (по умолчанию пусто) — оба уходят в Close-фрейме — и timeout. Вывод: { "closed": true, "duration_ms": … } — возвращается, даже если пир не подтвердил за timeout: сокет в любом случае исчезает, а id освобождается.

Динамические сообщения

В текстовых payload'ах работают одинарные токены ${…}, раскрываемые на каждую отправку — в отличие от ${{ … }}, который резолвится один раз до запуска action:

ТокенРаскрывается в
${seq}Монотонный счётчик, уникален на сообщение (продолжает считать между отправками одного соединения)
${uuid}Случайный 32-hex id
${now} / ${now_ms} / ${now_iso}UTC-время (FIX-формат / unix ms / RFC 3339)
${rand(a,b)} / ${randf(a,b[,dp])}Случайное целое / дробное в [a, b]
${choice(x|y|z)}Случайный выбор

Неизвестные токены остаются как есть. Payload'ы send_base64 декодируются один раз и уходят без изменений — раскрытия токенов нет.

- uses: std/ws-send@v1
  with:
    id: "${{ feed.id }}"
    send: '{"op":"order","id":"ord-${seq}","px":${randf(1.05,1.15,5)}}'
    repeat: 100
    interval_ms: 50

Ассерты сообщений

Шаги приёма отдают список messages; std/check@v1 проверяет его с квантором any (достаточно одного совпавшего сообщения — в стримах есть heartbeat'ы и посторонние события):

check:
  message_contains: "trade"              # какое-то сообщение содержит подстроку
  message_matches: { type: trade }       # какое-то сообщение совпадает по JSON-subset
  messages_count_gte: 5                  # пришло не меньше 5 сообщений

Для детерминированных обменов адресуйте сообщение по индексу: check: { on: got.messages.0, message_matches: { type: welcome } }.

Метрики

  • Handshake и one-shot-сессии попадают в общую гистограмму латенси (http_req_duration) — перцентили сравнимы с HTTP/TCP/UDP.
  • ws_msg_rtt — прикладной RTT сообщения: от отправки до первого ответа, совпавшего с вашим until-правилом. На дашборде теста — p50 / p95 / max.
  • ws_msgs_sent / ws_msgs_received — скорость обмена сообщениями.
  • Ожидание server-push-потока намеренно не считается латенси — оно испортило бы общие перцентили.

Страница метрик распознаёт WebSocket-прогон автоматически и переключается на WS-набор плиток (throughput, message RTT, перцентили handshake).

Ограничения

Входящие протокольные лимиты идут из дефолтов WebSocket-библиотеки: сообщения до 64 МиБ, отдельные фреймы до 16 МиБ — большее входящее сообщение убивает соединение (а значит, и читающий его шаг). Значения таймаутов, счётчики repeat и список messages встроенных ограничений не имеют.