Блог

Как исправить ошибку 1006 на WebSocket

October 03, 2020

Эта статья рассчитана на тех, кто при использовании веб сокетов уже столкнулся с ошибкой 1006 или на тех, кто хочет узнать немного о нюансах работы сокетов на nodejs.

Исходя из документации stackoverflow (ну ладно, на MDN тоже можно почитать), ошибка 1006 означает “Abnormal Closure”, то есть нештатное закрытие соединения, а на практике это может означать ping timeout, во всяком случае именно такая причина этой ошибки была в моем случае.

Здесь, правда, стоит упомянуть еще один нюанс. В моем случае сокет сервер был написан с помощью библиотеки ws и логика пингования клиентов строилась на том, что в случае, если мы не дождались pong, мы вызываем ws.terminate(), что, по идее, и есть Abnormal Closure, так как в документации к этому методу написано, что соединение будет закрыто без каких либо церемоний. В этом случае ошибка 1006 вполне логична, так как перед закрытием соединения не отправлялся соответствующий фрейм.

Причин у ошибки может быть несколько (мое субъективное мнение):

  • неправильно настроены таймауты на ws сервере или на nginx, если он у вас используется как прокси
  • нехватка CPU
  • загруженная очередь отправки сообщений

Вот об этих проблемах мы и пообщаемся в посте.

Настройка корректных таймаутов на ws и nginx серверах

Сперва стоит проверить всю логику (как на клиенте так и на сервере), которая может привести к закрытию подключения. Скорее всего в эту категорию попадает логика связанная с ping/pong подключения. Обычно фрейм ping отправляет сервер, а клиент, согласно протоколу, должен ответить на такое сообщение фреймом pong.

Этот опрос используется сервером для того, чтобы отследить мертвые подключения, которые были оборваны не по протоколу. Каждые N секунд, сервер рассылает всем клиентам сообщение (фрейм) ping и ждет ответа pong. Если pong не приходит в течении определенного времени (или, например, до следующего опроса клиентов) — соединение с клиентом считается поломанным и обычно это называют ошибкой Ping Timeout. Кроме ping/pong, клиент/сервер вполне могут использовать любые данные приходящие “с того конца” для того, чтобы выставлять подключению флаг “alive” (то есть отмечать, что подключение живое и не было разорвано).

Клиент тоже имеет право отправлять ping на сервер и ожидать от него в ответ pong. Однако обычно пингованием занимается именно сервер. Точно также никто не запрещает кому-либо отправлять фрейм pong не смотря на то, что запроса ping не было.

На этом с теоретическим экскурсом, пожалуй закончим. Надеюсь мое пояснение было хоть немного понятным. Далее задача изучить код как клиента так и сервера, а так же документацию используемых библиотек для работы с WebSocket. Надо узнать какие именно таймауты установлены для пингов и убедиться, что ping/pong отправляются достаточно часто, чтобы не происходили таймауты.

Кроме того, довольно часто перед сокет серверами ставят сервер nginx (reverse proxy) для роутинга и балансинга коннектов. Если у вас тоже используется такой подход, то нужно глянуть какие таймауты установлены на стороне nginx и убедиться, что nginx не режет ваши подключения. За это отвечают следующие опции:

proxy_connect_timeout 60s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;

Детальную документацию можно почитать на официальном сайте nginx. Так как nginx просто проксирует, он не умеет отправлять ping и, соответственно таймауты считаются от момента получения/отправки данных. Это означает, что ваш сокет сервер должен отправлять ping достаточно часто, чтобы не сработал таймаут на стороне nginx.

В случае, если ошибка вылазит в первые секунды после коннекта, это может означать проблему во время Upgrade запроса. В этом случае есть смысл проверить настройки прокси (если такие используются) или, возможно на сервере присутствует какой-то middleware, который приводит к ошибкам или обрезает часть заголовков при первых запросах перед переходом на WebSocket соединение. Но это тема другого поста.

Нехватка CPU

В случае, если клиенту или WebSocket серверу не хватает CPU, это будет приводить к тому, что в Event Loop ноды/браузера будет накапливаться слишком большая очередь из команд ожидающих выполнения. В том числе, команд/колбеков связанных с обработкой ping/pong.

Чтобы исправить эту проблему, необходимо использовать профайлер, чтобы найти узкие места в вашем коде и оптимизировать их. В более сложных случаях нужно будет разделять ваше приложение на несколько воркеров (или веб воркеров), чтобы разгрузить основной поток и дать возможность обрабатывать пинг.

Чтобы решить что анализировать первым — сервер или клиент, необходимо изучить их детали имплементации, но, я бы начинал с клиента, так как именно на клиенте вероятнее всего будут проблемы с тем, что пинг получен, а до отправки понга дело доходит слишком поздно.

Очень большой поток исходящих сообщений

А вот здесь начинается самое интересное. Именно этот случай стал причиной несколькодневной дебаг сессии.

Сначала давайте очень простенько рассмотрим как происходит отправка данных по сокетам от сервера. В случае с nodejs, сокет соединение — это просто стрим. То есть при каждом вызове socket.send(data), сервер складывает данные для отправки в стрим. А стрим, в свою очередь передает это дело операционной системе для отправки клиенту. Однако, если ОС уже занята отправкой данных, стрим буфферизирует новые данные и ожидает, когда ОС освободится. Чем больше объем данных и чем медленнее соединение, тем дольше эти данные будут стримиться на клиент.

А теперь давайте представим, что клиент в первые 10 секунд соединения отправляет более тысячи очень мелких запросов серверу, а сервер в ответ должен отправить тысячи пачек данных, вот только размер данных ответа в разы больше, чем размер соответствующего запроса. То есть на ответ серверу потребуется значительно больше времени. В случае с сокет сервером ситуация выглядит довольно безобидно, все данные быстро складываются в стрим и нода свободна, процессор простаивает и ожидает новых задач. Излишнего потребления ресурсов нету, все работает как часы. В том числе и запрос ping, который отправляется по расписанию.

Вот только ответ от клиента не вложится в таймаут. Проблема в том, что прежде чем ОС отправит ping клиенту, она сначала должна отправить все те тысячи сообщений, которые запросил клиент и только потом очередь дойдет до ping. Но ведь время до таймаута отсчитывается не с момента получения команды ping клиентом, а с момента, как выполнился код на стороне nodejs, отправивший пинг в очередь на отправку на клиент.

Как же справиться с этой проблемой? У меня есть два решения этой проблемы. Быстрое и правильное.

Ручная отправка pong (быстрое решение)

Начать отправлять команду pong с клиента каждые Х секунд не дожидаясь пинга от сервера. Если ваша библиотека не позволяет отправлять понг, можно отправлять любое легковесное сообщение, которое даст понять вашему серверу, что с соединением все ок. Примерная имплементация может выглядеть так (библиотека ws):

const WebSocket = require('ws');

const ws = new WebSocket('wss://example.com');
// частота с которой предположительно будут приходить
// пинги от сервера
const serverPingInterval = 30000;
let manualPongIntervalId;

ws.on('open', () => {
  manualPongIntervalId = setInterval(
    () => ws.pong(),
    // умножаем на 0.6-0.9, чтобы отправлять pong немного чаще
    // (на всякий случай)
    serverPingInterval * 0.7,
  );
});

ws.on('close', (code) => {
  console.log(`Connection closed with code: ${code}`);

  clearInterval(manualPongIntervalId);
});

Контроль RPS (правильное решение)

Нужно улучшить имплементацию клиента и сервера таким образом, чтобы ограничить количество запросов, размеры ответов и их частоту. Если ограничить максимальный размер отправляемых в одном фрейме данных, это даст возможность с большой вероятностью отправить ping в паузе между ответами.

Кроме того, надо ограничить количество ответов в секунду для каждого подключения (rps). Это количество должно быть таким, чтобы, опять же, гарантировать, наличие окна для отправки pong в перерывах между ответами. Для имплементации ограничения скорости можно попробовать использовать библиотеку на подобии bottleneck, async.

И напоследок бонус. Пока я искал кое-какую инфу для этого поста, я наткнулся на информацию о свойстве сокета socket.bufferedAmount, которое хранит текущий объем буферизированных данных, ожидающих отправки по сети. Это свойство можно использовать, чтобы понимать действительно в данный момент есть смысл добавлять новую порцию данных в стрим отправки. Можно использовать дополнительные библиотеки для работы с очередями, но вот так могла бы выглядеть имплементация “на коленке”:

const queue = [];

setInterval(() => {
  if (socket.bufferedAmount == 0) {
    socket.send(queue.pop());
  }
}, 100);

function send(data) {
  queue.push(data);
}

Выводы

В этой статье я поделился своим опытом борьбы с ошибкой 1006 при работе с WebSocket. В моем случае причиной ошибки был пинг таймаут, который был исправлен путем ручной отправки понг фреймов со стороны клиента. Теперь мои сокеты больше не падают! Также в статье были рассмотрены другие кейсы, которые могут стать причиной Ping Timeout как по вине сервера/прокси, так и по вине клиента.

Возможно мой случай был весьма специфичным и я не изобрел универсальный рецепт решения проблем с ошибкой 1006, но все же надеюсь кому-то эта информация окажется полезной.