Как исправить ошибку 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, но все же надеюсь кому-то эта информация окажется полезной.