Началось все с того, что я оптимизировал отдачу ошибки HTTP 408 Request Timeout в сервере приложений Impress, работающем на Node.js. Как известно, у нодовского http.Server есть событие timeout, которое должно вызываться для каждого открытого сокета, если тот не закрылся за указанное время. Хочу уточнить, что не для каждого запроса т.е. не для каждого события request, функция которого имеет два аргумента (req, res), а именно для каждого сокета. Через один сокет может последовательно поступить много запросов в режиме keep-alive. Если мы задаем это событие, через server.setTimeout(2 * 60 * 1000, function(socket) {...}) то должны сами уничтожать сокет socket.destroy(). Но если не установить свой обработчик, то http.Server имеет встроенный, который уничтожит сокет через 2 минуты автоматически. На этом самом таймауте можно отдать ошибку 408 и считать инцидент исчерпанным. Если бы не одно но… С удивлением я обнаружил, что событие timeout вызывается и для тех сокетов, которые подвисли и для уже получивших ответ и для закрытых клиентской стороной, вообще для всех, находящихся в режиме keep-alive. Это странное поведение оказалось достаточно сложным, и я расскажу об этом ниже. Можно было бы вставить одну проверку в событие timeout, но со своим идеализмом я не удержался и полез исправлять баг на уровень глубже. Оказалось, что в http.Server режим keep-alive реализован не то что не по RFC, а откровенно не дописан. Вместо отдельного timeout для соединения и отдельного keep-alive timeout, там все на одном таймауте, который реализован на быстрых псевдо-таймерах (enroll/unenroll), но задан по умолчанию в 2 минуты. Это было бы не так страшно, если бы браузеры хорошо работали с keep-alive и переиспользовали его эффективно или закрывали бы неиспользуемые соединения.

Сначала результаты


После 12 строк изменений событие timeout начало срабатывать только когда сервер не отдал ответа клиенту и клиент его ждет. Таймаут соединения остался со значением по умолчанию 2 минуты, но появился еще http.Server.keepAliveTimeout со значением по умолчанию 5 секунд (как у Apache). Репозиторий с исправлениями: tshemsedinov/node (для node.js 0.12) и tshemsedinov/io.js (для io.js). Скоро я отправлю пул-реквесты соответственно в joyent/node и nodejs/node (бывший io.js, а сейчас в нем уже склеенные проекты).

Суть исправления в том, что таймаут соединения должен срабатывать если соединение зависло, оставив запрос без ответа, а если сокет открыт, но все запросы отвечены, то нужно ждать гораздо меньше, давая возможность прислать еще запрос в режиме keep-alive.

О побочном эффекте уже можно догадаться, освободилось очень много памяти и дескрипторов сокетов, что сразу вызвало в моих текущих высоконагруженных проектах повышение общей производительности более чем в 2 раза. А тут я покажу маленький тест с кодом, результаты которого видно на графиках ниже и дающий представление о том, что происходит.
Суть теста: создать 15 тыс соединений HTTP/1.1 (которые считаются keep-alive по умолчанию, даже без специальных заголовков) и проверить интенсивность создания и закрытия сокетов и расходы памяти. Тест выполнялся 200 секунд, каждые 10 секунд записывались данные. Графики слева — это Node.js 0.2.7 без исправлений, а справа — пропатченный и пересобранный Node.js. Синяя линяя — количество открытый сокетов, а красная — закрытые сокеты. Для этого, мне конечно же пришлось записать все сокеты в массив, что не позволяло полностью освобождать память. Поэтому есть два варианта клиентской части теста, с массивом сокетов, и без него, чтобы проверить память. Как и ожидалось, что сокеты освобождаются в 2 раза быстрее, а это значит, что они не занимают дескрипторов и не нагружают TCP/IP стек операционной системы, которая кроме ноды держит структуры данных и буферы для каждого дескриптора.
Синяя линяя — RSS (resident set size) — сколько занимает процесс всего, красная — heap total — выделенная для приложения память, зеленая — heap used — используемая память. Естественно, что вся освобожденная память может переиспользоваться для других сокетов, еще быстрее, чем при первом выделении.

Код тестов:
Клиентская часть теста
var net = require('net');
var count = 0;

keepAliveConnect();

function keepAliveConnect() {
  var c = net.connect({ port: 80, allowHalfOpen: true }, function() {
    c.write('GET / HTTP/1.1\r\nHost: localhost\r\n\r\n');
    if (count++ < 15000) keepAliveConnect();
  });
}
Серверная часть со счетчиками сокетов
var http = require('http');
var pad = '';
for (var i = 0; i < 10; i++) pad += '- - - - - - - - - - - - - - - - - ';
var sockets = [];

var server = http.createServer(function (req, res) {
  var socket = req.socket;
  sockets.push(socket);
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end(pad + 'Hello World\n');
});

setInterval(function() {
  var destroyedSockets = 0;
  for (var i = 0; i < sockets.length; i++) {
    if (sockets[i].destroyed) destroyedSockets++;
  }
  var m = process.memoryUsage(),
      a = [m.rss, m.heapTotal, m.heapUsed, sockets.length, destroyedSockets];
  console.log(a.join(','));
}, 10000);

server.listen(80, '127.0.0.1');
Серверная часть без счетчиков сокетов
var http = require('http');
var pad = '';
for (var i = 0; i < 10; i++) pad += '- - - - - - - - - - - - - - - - - ';

var server = http.createServer(function (req, res) {
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end(pad + 'Hello World\n');
});

setInterval(function() {
  var m = process.memoryUsage();
  console.log([m.rss, m.heapTotal, m.heapUsed].join(','));
}, 10000);

server.listen(80, '127.0.0.1');

Подробности проблемы


Если клиентская сторона не запрашивает keep-alive, то Node.js закрывает сокет сразу по вызову res.end() и ни какой утечки ресурсов не происходит. Поэтому все тесты в которых мы массово делаем http.get('/').on('error', function() {}) или curl http://domain.com/ или через ab (apache benchmark), показывают что все хорошо. А браузеры всегда хотят keep-alive, с которым плохо работают, как и нода. Проблема keep-alive в том, что через него можно отправлять несколько запросов только последовательно, в нем нет пакетного механизма, который бы маркировал, на какой из конкурентных запросов отвечает каждый из ответов. Согласен, это дико неудобно. В SPDY и HTTP/2 такой проблемы нет. Когда браузеры загружают страницу с множеством ресурсов, то они иногда используют keep-alive, но чаще отправляют правильные заголовки, внушая серверу, что нужно держать открытые соединения, а сами используют его совсем мало или вообще игнорируют, руководствуясь непонятной мне логикой. Вот Firebug и DevTools показывают, что запросы завершены, а сокеты висят. Даже если страница уже загрузилась полностью, при этом было создано несколько сокетов, они не закрыты, и нам нужно сделать один несчастный запрос к API, то мои наблюдения показывают, что браузеры всегда создают новое подключение, а сокеты так и держат, пока сервер их не закроет. Такие подвисшие сокеты и не считаются параллельными запросами, поэтому не влияют на ограничения браузеров (я так понимаю, что они маркируются как half-open, не используются и исключаются из счетчика). Это можно проверить, если закрыть браузер, то на сервере ноды сразу закроется целая пачка сокетов, не успевших выждать свои 2 минуты таймаута.

Со стороны ноды же установлен таймаут в 2 минуты, независимо от того, отправлен ли ответ на клиентскую сторону или нет. Понижать этот таймаут, например до 5 секунд — не выход, в результате будут обрываться соединения, которые объективно занимаю больше, чем 5 секунд. Нужен отдельный таймаут для keep-alive, отсчет которого начинается не сразу, а после последней активности в сокете, т.е. это реальное время ожидания очередного запроса от клиента.

Вообще, для полной реализации keep-alive нужно сделать гораздо больше, брать желаемое время таймаута из HTTP заголовков, присылаемых клиентом, отправлять клиенту фактическое установленное время таймаута в заголовках ответа, обрабатывать параметр max и Keep-Alive Extensions. Но современные браузеры не используют все эти вещи, во всяком случае, из проведенных мной экспериментов они игнорировали эти HTTP заголовки. Поэтому я успокоился малыми правками, давшими большие результаты.

Исправления в Node.js


Сначала я решил залатать проблему с лишними таймаутами простым способом, предотвращая emit события: ae9a1a5. Но для этого пришлось ознакомиться с кодом и мне не понравилось, как он написан. Местами есть комментарии, что так писать нельзя, что большие замыкания нужно декомпозировать, избавиться от вложенности функций, но никто не трогает эти библиотеки, потому, что потом тесты не соберешь и можно испортить уйме людей весь зависимый код. Ладно, все править не выйдет, но утечка сокетов не давала мне покоя. И я задумал решить проблему, уничтожая сокет после на ServerResponse.prototype.detachSocket, когда один res.end() уже послан, но это сломало много полезного поведения, связанного с keep-alive: 9d9484b. После экспериментов, чтения RFC и документации по другим серверам, стало очевидно, что нужно реализовывать keep-alive timeout, и что он отличается от просто таймаута соединения.

Исправления:
  1. Добавлен параметр server.keepAliveTimeout, который можно задавать вручную /lib/_http_server.js#L259
  2. Переименовал функцию события prefinish, чтобы использовать ее в другом месте /lib/_http_server.js#L455,L456
  3. Навесил событие finish, чтобы поймать момент, когда уже все отвечено. На нет удаляю из EventEmitter обработчики, повешенные на событие timeout сокета и вещаю событие, разрушающее сокет /lib/_http_server.js#L483,L491
  4. Для сервера https добавляем параметр keepAliveTimeout, потому, что он наследует все остальное из прототипа /lib/https.js#L51

Для Impress Application Server все эти изменения реализованы внутри, в виде красивой заплатки и эффект доступен даже без патча на Node.js, в его исходниках можно посмотреть, как просто это сделано. Кроме этого, на последних проектах мы добились и других, впечатляющих результатов, например, 10 млн постоянных соединений на 4 серверах, объединенных в кластер (по 2.5 млн на 1 сервер) на базе протокола SSE (Server-Sent Events), а сейчас готовимся сделать то же самое для вебсокетов. Реализовали прикладную балансировку для кластера Impress, связали узлы кластера своим протоколом на базе TCP, вместо используемого ранее ZMQ от чего получили ощутимое ускорение. Результаты этой работы я так же собираюсь частично опубликовать в следующих статьях. Многие говорят мне, что никому не нужна эта оптимизация и производительность, всем безразлично. Но, как минимум, на четырех живых высоконагруженных примерах, для моих заказчиков из КНР и для интерактивного телевизионного формата «Седьмое чувство», я наблюдаю общее повышение производительности от 2-3 раз до 1 порядка, а это уже существенно. Для этого мне пришлось отказаться и от принципа middleware, и переписать межпроцессовое взаимодействие, и реализовать прикладную балансировку (аппаратные балансировщики не справляются) и т.д. Об этом будет отдельная статья, про ужасы производительности при использовании middleware: «Что нода дала, то middleware забрал». Для чего я уже подготовил достаточно фактов, статистики и примеров, и имею что предложить взамен.

А хотите все и сразу, прямо сейчас?


Тогда нужно протестировать вот такую заплатку и не на базе своего билда, а показать ее влияние на официальную версию Node.js 0.12.7. Сейчас проверим, что будет, если добавить на событие request дополнительных 7 строк кода. Сокеты будут закрываться как нужно и даже ошибка с лишним событием timeout тоже исчезает, это понятно. А вот с памятью, ситуация конечно значительно лучше, но не настолько, как это при пересборке Node.js.
http.createServer(function (req, res) {
  var socket = req.socket;
  res.on('finish', function() {
    socket.removeAllListeners('timeout');
    socket.setTimeout(5000, function() {
      socket.destroy();
    });
  });
  res.writeHead(200, {'Content-Type': 'text/plain'});
  res.end('Hello World\n');
});

Сравним результаты на графиках: слева — исходное состояние Node.js 0.12.7, посередине — добавление 7 строк в request и запущено на официальном 0.12.7, справа — пропатченный Node.js из моего репозитория. Причины этого ясны, я склонировал не 0.12.7, а немного более новую версию и от нее отталкивался. Конечно, все тесты кроме последнего проведены на моем репозитории, с патчем и без патча. А последний тест я сравнил с официальной версией 0.12.7, чтобы было понятно, как это повлияет на Ваш код уже сейчас.
Версия V8 в моем репозитории такая же, как и в 0.12.7, но очевидно, что в ноде случились оптимизации. Если подождать совсем немного, то можно будет пользоваться или приведенной выше заплаткой или исправления попадут в ноду. Результаты этих двух вариантов почти совпадают. Вообще, я собираюсь и дальше заниматься экспериментами и оптимизацией в этом направлении, а если у Вас будут идеи, то прошу — не стесняйтесь предлагать и подключаться к приведению кода самых критичных встроенных библиотек ноды в приличный вид. Поверьте, там много работы для специалиста любого уровня. Кроме того, изучение исходников — это самый лучший из известных мне способов освоения платформы.

Update: нашел еще одну проблему там с _last, его ни кто не вычислял. Теперь слил с соседними правками, протестировал и выложил пул-реквест и https://github.com/nodejs/node/pull/2534

Комментарии (32)


  1. arvitaly
    23.08.2015 16:37
    +4

    Огромное спасибо! Буквально на днях столкнулся с memory leak при множественных http-запросах. Для Express добавил ваш код сюда node_modules\express\lib\response.js и память перестала уходить! Вот интересно, ждать патча от NodeJS или просить добавить в framework'и костыль?


    1. MarcusAurelius
      23.08.2015 16:43
      +6

      Можно добавить, а потом ждать. Когда будет патч, то ничего не испортится, патч с заплаткой не конфликтуют.


      1. Old_Chroft
        24.08.2015 01:21
        +8

        … патч с заплаткой не конфликтуют.

        Черт, как я отстал от жизни… всегда думал что patch и заплатка — одно и то же :-)


        1. Antispammer
          25.08.2015 10:01
          +5

          Скорее «патч с костылями не конфликтуют» ;-)


  1. Scratch
    23.08.2015 17:11
    -1

    Очень вовремя, мы как раз собираемся выпускать в production большой проект на ноде


  1. Mithgol
    23.08.2015 21:51

    Скоро я отправлю пул-реквесты соответственно в joyent/node и nodejs/node (бывший io.js, а сейчас в нём уже склеенные проекты).
    Насколько скоро? (Хотя бы примерно?)


    1. MarcusAurelius
      23.08.2015 22:33
      +3

      Заранее сложно сказать, осталось еще несколько моментов, которые я хочу протестировать не только нодовскими тестами, а в условиях реальной нагрузки. Например, склеить свои изменения с правкой donnerjack13589 по поводу смены prefinish на finish, потому, что я ее случайно затер, перенеся бездумно свою правку из ноды в io.js.



  1. affka
    24.08.2015 03:54
    +2

    Большое спасибо на находку и ее исследования!
    А при данном баге память постоянно течет? Т.е. даже после каких-то долгих таймаутов (2 минуты, например) не очищается?
    У нас есть high-load приложение на ноде, 3-4 тысячи пользователей онлайн на каждом из серверов, запросов очень много. Память течет, исследовать настолько глубоко нет времени/средств, пришлось делать под 20 воркеров на каждом сервере (которые расходуют по 10-20% цпу) и перезагружать раз в сутки эти воркеры, чтобы очистить память. Если делаем мало воркеров (10, например), то в пиках нагрузки они начинают грузить проц под 100%, не могут обработать запрос и цпу не снижается даже сли нагрузку вообще убрать.
    Как думаете, ваш багфикс сможет нам помочь?


    1. MarcusAurelius
      24.08.2015 06:16

      Дескрипторы сокетов и память накапливаются не навсегда, срок освобождения — 2 минуты, но этого достаточно, чтобы скорость утечки была выше скорости освобождения и чтобы ушла вся память (1-2Гб), а процесс залип (бывает) или вывалился (это немного лучше, чем залип). В приведенном тесте делается 15к запросов за 200 секунд, это очень мало, всего 75 rps, но уходит 100 Мб. А при 3к-5к rps (сильно зависит еще от того, как приложение расходует память) уже и достигается критический объем утечки, чтобы все навернулось.


      1. affka
        24.08.2015 07:34

        А что значит «критический объем утечки»? Почему после какого-то предела по памяти нода залипает по цпу?
        У нас на серверах памяти много, по ней не упираемся в какие-то лимиты, но если нода начинает много памяти кушать, то она начинает залипать по цпу… и это проблема.

        У нас ~500 запросов в секунду в пиках, каждый из воркеров потребляет ~200 мб памяти


        1. MarcusAurelius
          24.08.2015 08:04
          +1

          По умолчанию, если не делать --max_old_space_size, то у ноды же известное ограничение по памяти, а если подымать его упомянутым ключом, то менеджер памяти становится не эффективным, залипает (в зависимости от сложности структур данных и интенсивности их выделения/освобождения) Вы знаете. У нас ~150k rps на группе серверов в целом и до 2.5 млн открытых сокетов на каждом сервере (20/40 core/thread, 256 Гб памяти). И это стало проблемой. А у людей памяти то часто бывает поменьше и для них это совсем критическая проблема — процессы перегружаются каждые 10 минут.


          1. Moxa
            24.08.2015 13:20

            не думали на жаву перейти с такими нагрузками? я на своем домашнем 4х ядерном амд поднимал 2кк коннектов, хватило гигов 12ти памяти


            1. MarcusAurelius
              24.08.2015 13:28

              У нас не все 256 Гб используются на ноду, на 2.5 млн соединений порядка 40 Гб уходит, но там еще много всякой бизнес-логики, структур данных в памяти, не знаю точно, сколько под сокетные буферы из этого.


              1. affka
                24.08.2015 13:44

                А у вас нода без перезапусков работает? Утечек вообще никаких нет?
                Мы стараемся данные держать где-нить в Redis'е. При обильном использовании памяти нода быстро уходила в цпу 100%, правда это еще было при версии ноды ~0.5…


                1. MarcusAurelius
                  24.08.2015 14:10

                  Без перезапусков, мы сделали для этого все возможное и это себя оправдало. Несколько серверов, на каждом много процессов и все соединены через шину событий и синхронизируют состояние между процессами. Раньше делали это через ZMQ, а теперь по своему протоколу. И да, у нас свой сервер приложений Impress: habrahabr.ru/post/247543 На других фреймворках есть утечки, и они такие, что перезапуск процессов считается нормальной практикой. У нас если процессы и перезапускаются, то не все вместе, и вновь поднятые забирают состояние из соседних. Процессы падают только от ошибок в прикладном поде, которые после некоторого времени тестовой эксплуатации ликвидируются. Есть специальные процессы (менеджеры сервера приложений) которые не обрабатывают бизнес-логики и запросов от клиента вообще, поэтому они не падают и гарантировано держат состояние, кроме того, даже этих специальных процессов несколько, на каждом сервере свой.


                  1. affka
                    24.08.2015 14:55
                    +1

                    Спасибо за подробный ответ! У нас тоже есть немного схожего с вашей архитектурой: тоже есть мастер процесс, который следит за остальными и не работает с запросами и бизнес логикой, процессы перезагружаются раз в сутки в разнобой (не все разом), нормально завершая свою работу (обрабатывая последние запросы и не принимая следующие), а состояние все в редисе хранится.
                    Но я очень рад слышать, что все-таки можно иметь приложение без утечек, нам уже казалось это из ряда фантастики для node.js.

                    У нас в глубине приложения используются express и sockjs. Есть какой-то опыт с ними по поводу утечек?


                    1. MarcusAurelius
                      24.08.2015 15:09
                      +1

                      Только не на принципе middleware, с тщательно отобранными модулями и с очень хорошо вычищенным прикладным кодом. Кроме того, мы оборачиваем прикладной код в песочницы (sandboxes) см. vm.createContext и при утечке можно уничтожать песочницу, а не процесс целиком, и вся выделенная в ней память уйдет. Новую песочницу можно создавать до удаления старой и после синхронизации стейта просто подменять их.


          1. negodnik
            16.09.2015 23:47

            Если не секрет, что за проект с таким rps?


            1. MarcusAurelius
              17.09.2015 02:23

              Проектов таких у меня несколько, их можно разделить на три группы:
              1. Телевизионные интерактивные шоу на принципе второго экрана (уже все готово),
              2. Телеметрия технологических процессов и серверных инфраструктур (внедряется и на подходе еще),
              3. Финансовая аналитика и корпоративные информационные системы реального времени (в работе).


  1. roller
    24.08.2015 12:48

    Напомнило http://geektimes.ru/post/259898/


  1. wickedweasel
    25.08.2015 11:38
    +1

    Помнится, раньше тоже таймеры в node.js чинил и оптимизировал. В частности, починил регрессию в node.js, из-за которой clearTimeout() работал в 15 раз медленней — github.com/joyent/node/issues/4225

    И даже статейку написал про внутреннее устройство таймеров — habrahabr.ru/company/alawar/blog/155509. Не уверен, правда, что она все еще актуальна, особенно в свете выхода io.js.


    1. MarcusAurelius
      25.08.2015 12:59
      +1

      Кстати, таймеры для таймаута сокетов сделаны через enroll/unenroll, которые не приводят к созданию отдельного таймера для каждого сокета. Мне это понравилось, потому, что при 2.5 млн одновременных соединений мне сложно представить, сколько бы CPU уходило на таймеры.


  1. VoidVolker
    28.08.2015 09:25

    А влияет ли данный патч на соединения через веб-сокеты?


    1. MarcusAurelius
      28.08.2015 10:45

      Только косвенно: на сервере обычно параллельно идут HTTP API запросы и WS запросы, чем лучше освобождается память и прикрываются соединения от API запросов, тем лучше для вебсокетов. По сути, запросы, висящие 2 минуты, не отличаются от WS по использовнию ресурсов и когда они быстрее завершаются, это хорошо.


      1. VoidVolker
        28.08.2015 13:33

        А если используется только WS? Например веб-сервер — это ngnix, а само приложение общается с сервером только через WS.


        1. MarcusAurelius
          28.08.2015 14:07

          Для этого случая ни как не влияет. Но тут можно существенно оптимизировать, убрав nginx из цепочки. В такой конфигурации от него нет смысла, кроме двойного использования ресурсов и задержки.


          1. VoidVolker
            28.08.2015 22:18

            А чем плох такой вариант? nginx отдает страницы, стили, скрипты, картинки и ни в какой цепочке не учавствует, а само приложение общается с серверной частью через ws. Т.е., node используется только как WS сервер. Мне кажется такое разделение вполне логичным — никакого оверхеда на всякие заголовки http и все такое прочее.


            1. MarcusAurelius
              29.08.2015 02:23

              Если между нодовским ws сервером и браузером ничего не стоит, то это хорошо. А статика отдается из nginx с другого порта, а лучше — с CDN. Если использовать CORS, то все ок. Но я люблю так: без CORS, нода отдает ws + API + HTML, а вот всю остальную статику отдает nginx или CDN. Конечно нода тоже должна уметь отдавать статику, чтобы ее забирал и кешировал CDN, раздавая потом ее с поддомена, например static.domainname.com. Если отдавать ws с того же хоста и порта, что и HTML, то можно обойтись и без CORS.


  1. Scratch
    04.09.2015 11:09

    Чет не торопятся вмерживать


  1. Scratch
    17.09.2015 11:10

    А в каком состоянии сейчас патч? Я так понимаю, от него не отказались совсем, но и вмерживать не торопятся. Там какие то принципиальные недочеты нашли?


    1. MarcusAurelius
      17.09.2015 11:29

      Патч развивается, все можно почитать на странице с пул-реквестом: github.com/nodejs/node/pull/2534 Это слишком нежное место ноды, чтобы вот так сразу все включить в релиз. Сейчас решение уже немного другое, чем было при написании статьи, потому, что нужно еще восстанавливать обычный таймаут, если за время keepAliveTimeout восстановится активность в сокете и подобные нюансы. Сейчас уже все в порядке, все тесты проходят, ждем склейки.