Поиск проблем производительности NodeJs приложения (с примерами)

в 10:56, , рубрики: cpu, infinite loop, inspect, node.js, nodejs, profiling

Из-за однопоточной архитектуры Node.js важно быть настороже высокой производительности вашего приложения и избегать узких мест в коде, которые могут привести к просадкам в производительности и отнимать ценные ресурсы CPU у серверного приложения.
В этой статье речь пойдет о том, как производить мониторинг загрузки CPU nodejs-приложения, обнаружить ресурсоемкие участки кода, решить возможные проблемы со 100% загрузкой ядра CPU.


image

1. CPU-профайлинг приложения. Инструменты

К счастью, у разработчиков есть удобные инструменты для обнаружения и визуализации “хот-спотов” загрузки CPU.

Chrome DevTools Inspector

В первую очередь, это профайлер, встроенный в Chrome DevTools, который будет связываться с NodeJs приложением через WebSocket (стандартный порт 9229).

Запустите nodejs-приложение с флагом --inspect

(по умолчанию будет использоваться стандартный порт 9229, который можно изменить через --inspect=<порт>).

Если NodeJs сервер в докер-контейнере, нужно запускать ноду с --inspect=0.0.0.0:9229 и открыть порт в Dockerfile или docker-compose.yml

Откройте в браузере chrome://inspect

image

Найдите ваше приложение в “Remote Target” и нажмите “inspect”.

Откроется окно инспектора, схожее со стандартным “браузерным” Chrome DevTools.
Нас интересует вкладка “Profiler”, в которой можно записывать CPU профайл в любое время работы приложения:

image

После записи собранная информация будет представлена в удобном таблично-древовидном виде с указанием времени работы каждой ф-ии в ms и % от общего времени записи (см. ниже).

Возьмем для экспериментов простое приложение (можно склонировать отсюда), эксплуатирующее узкое место в либе cycle (используемой в другой популярной либе winston v2.x) для эмуляции JS кода с высокой нагрузкой на CPU.

Будем сравнивать работу оригинальной либы cycle и моей исправленной версии.

Установите приложение, запустите через npm run inspect. Откройте инспектор, начните запись CPU профайла. В открывшейся странице http://localhost:5001/ нажмите "Run CPU intensive task", после завершения (алерта с текстом “ok”) завершите запись CPU профайла. В результате можно увидеть картину, которая укажет на наиболее прожорливые ф-ии (в данном случае — runOrigDecycle() и runFixedDecycle(), сравните их %):

image

NodeJs Profiler

Другой вариант — использование встроенного в NodeJs профайлера для создания отчетов о CPU производительности. В отличие от инспектора, он покажет данные за все время работы приложения.

Запустите nodejs-приложение с флагом --prof

В папке приложения будет создан файл вида isolate-0xXXXXXXX-v8.log, в который будут записываться данные о “тиках”.

Данные в этом файле неудобны для анализа, но из него можно сгенерировать человеко-читаемый отчет с помощью команды
node --prof-process <файл isolate-*-v8.log>

Пример такого отчета для тестового приложения выше тут
(Чтобы сгенерировать самому, запустите npm run prof)

Существуют также некоторые npm-пакеты для профайлинга — v8-profiler
, предоставляющий JS-интерфейс к API V8 профайлера, а также node-inspector (устарел после выхода встроенного в Chrome DevTools-based профайлера).

2. Решение проблемы блокирующего JS-кода без инспектора

Предположим, так случилось, что в коде закрался бесконечный цикл или другая ошибка, приводящая к полной блокировке выполнения JS-кода на сервере. В этом случае единственный поток NodeJs будет заблокирован, сервер перестанет отвечать на запросы, а загрузка ядра CPU достигнет 100%. Если инспектор еще не запущен, то его запуск вам не поможет выловить виновный кусок кода.

В этом случае на помощь может прийти дебаггер gdb.

Для докера нужно использовать
--cap-add=SYS_PTRACE
и установить пакеты
apt-get install libc6-dbg libc-dbg gdb valgrind
Итак, нужно подключиться к nodejs процессу (зная его pid):
sudo gdb -p <pid>

После подключения ввести команды:

b v8::internal::Runtime_StackGuard
p 'v8::Isolate::GetCurrent'()
p 'v8::Isolate::TerminateExecution'($1)
c
p 'v8::internal::Runtime_DebugTrace'(0, 0, (void *)($1))
quit

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

В результате этого выполнение текущего блокирующего JS-кода в текущем “тике” будет остановлено, приложение продолжит свою работу (если вы используете Express, сервер сможет обрабатывать поступающее запросы дальше), а в стандартный поток вывода NodeJs-приложения будет выведен stack trace.

Он довольно длинный, но в нем можно найти полезную информацию — стек вызовов JS функций.

Находите строки такого вида:

--------- s o u r c e   c o d e ---------
function infLoopFunc() {x0a    //this will lock serverx0a    while(1) {;}x0a}
-----------------------------------------

Они должны помочь определить “виноватый” код.

Для удобства написал скрипт для автоматизации этого процесса с записью стека вызовов в отдельный лог-файл: loop-terminator.sh

Также см. пример приложения с его наглядным использованием.

3. Обновляйте NodeJs (и npm-пакеты)

Иногда вы не виноваты :)

Наткнулся на забавный баг в nodejs < v8.5.0 (проверил на 8.4.0, 8.3.0), который при определенных обстоятельствах вызывает 100% загрузку 1 ядра CPU.
Код простого приложения для повторения этого бага находится тут.

Смысл в том, что приложение запускает WebSocket-сервер (на socket-io) и запускает один дочерний процесс через child_process.fork(). Следующая последовательность действий гарантированно вызывает 100% загрузку 1 ядра CPU:

  1. К WS-северу подключается клиент
  2. Дочерний процесс завершается и пересоздается
  3. Клиент отключается от WS

Причем приложение все еще работает, Express сервер отвечает на запросы.
Вероятно, баг находится в libuv, а не в самой ноде. Истинную причину этого бага и исправляющий его коммит в changelog’ах я не нашел. Легкое “гугление” привело к подобным багам в старых версиях:

https://github.com/joyent/libuv/issues/1099
https://github.com/nodejs/node-v0.x-archive/issues/6271

Решение простое — обновить ноду до v8.5.0+.

4. Используйте дочерние процессы

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

Создайте отдельное NodeJs-приложение и запускайте его из главного через child_process.fork(). Для связи между процессами используйте IPC-канал. Разработать систему обмена сообщениями между процессами довольно легко, ведь ChildProcess — потомок EventEmitter.
Но помните, что создавать слишком большое количество дочерних NodeJs процессов не рекомендуется.

Говоря о производительности, другой не менее важной метрикой является потребление RAM. Существуют инструменты и техники для поиска утечек памяти, но это тема для отдельной статьи.

Автор: Денис

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js