Node.JS: заменили модуль SSH2 на OpenSSH и снизили задержки eventloop в 15 раз
Почему SSH?
Нам нужно выполнять shell и sql команды на серверах с PostgreSQL, например чтение файла лога, снятие статистики, поиск блокировок. Консольный доступ на большинстве серверов уже реализован через SSH, а с доступом к экземплярам PostgreSQL не так просто — нужно устанавливать новые соединения ко всем экземплярам, а для этого открывать сетевые порты и управлять конфигами pg_hba.conf, прописав в них IP-адреса серверов мониторинга, да и передавать данные по сети в открытом виде нехорошо, а для SSL тоже нужны отдельные настройки.
Поэтому логично выполнять все операции через SSH, используя возможность запуска нескольких сеансов через одно соединение.
Работаем с модулем SSH2
Весь ssh-трафик идет через модуль ssh2
Об архитектурных решениях подробно написано в этой статье и в продолжении.
При использовании модуля ssh2 и большой нагрузке может возникнуть неприятный побочный эффект, связанный с тем, что модуль написан на Javascript, а значит работает в основном потоке вместе с остальным кодом приложения. Помимо увеличения задержек в event loop это дополнительно нагружает GC и thread pool.
На некоторых серверах задержки event loop в часы пик вырастали до неприличных значений:
Прием данных модулем ssh2 приводит к большим задержкам
Для решения этой проблемы мы решили выгрузить все ssh-операции в отдельный процесс, но вместо модуля ssh2 использовать консольный ssh-клиент из пакета OpenSSH.
Пробуем перейти на OpenSSH
Этот ssh-клиент позволяет выполнять операции в режиме совместного использования одного соединения. Для этого вначале устанавливаем master-соединение и создаем контрольный сокет-файл /tmp/ssh.sock для взаимодействия с slave-процессами:
ssh -M -S /tmp/ssh.sock -i id_rsa -l username pg_hostname
а затем запускаем процесс в режиме slave, например для выполнения консольных команд:
ssh -S /tmp/ssh.sock pg_hostname command
или для установки тоннеля и проброса соединения к локальному сокету на удаленный хост:
ssh -S /tmp/ssh.sock -O forward -L /tmp/postgresql.sock:127.0.0.1:5432 pg_hostname
Запуск процесса ssh выполняем с помощью child_process.spawn, а входящий поток данных получаем из его stdout в виде stream.Readable.
Для унификации создали новый модуль system-ssh с такими же как у ssh2 методами и параметрами — connect, end, exec, forwardOut и дополнительно forwardOutLocalSocket, и опубликовали его в npm реестре.
Схема работы с новым модулем:
Вынесли ssh из основного процесса
Таким образом в основном процессе осталась только функция запуска дочернего процесса, в который вынесены все операции по обслуживанию ssh-соединений.
При небольшом количестве ssh-соединений такой вариант вполне работоспособен.
Но у нас в мониторинге около 2000 экземпляров PostgreSQL и запускать такое количество дочерних процессов нерационально. Для разгрузки event loop основного процесса достаточно перевести на новую модель только серверы с относительно большим потоком данных. В качестве фильтра для перевода на system-ssh мы поставили границу 1.25 Mbps, а для возвращения на старую модель inproc — 0.75 Mpbs. Таким образом количество дочерних процессов остается небольшим, при этом большая часть трафика обрабатывается вне основного процесса:
на новой модели работают только серверы с большим ssh-трафиком
Вот только при запуске spawn в Node.JS появляются задержки из-за синхронных операций с heap, причем чем больше памяти используется процессом, тем они больше:
On Unix-like operating systems, the child_process.spawn()
method performs memory operations synchronously before decoupling the event loop from the child. Applications with a large memory footprint may find frequent child_process.spawn()
calls to be a bottleneck. For more information, see V8 issue 7381.
В нашем случае воркер-процесс хранит в памяти различные кэши и серверы часто переносятся между воркерами для балансировки, поэтому влияние задержек spawn довольно большое.
SSH-прокси
Для решения этой проблемы мы вынесли запуск всех консольных ssh в отдельный прокси-процесс и плюсом получили возможность использования единственного ssh-соединения для всех экземпляров PostgreSQL на сервере.
Вынесли запуск ssh на прокси-процесс с подключением через net.Socket
Поток данных из stdout процесса перенаправили в Unix domain socket, примерно так:
Пример кода для передачи через socket
// sshproxy
const { Client } = require('system-ssh');
const sshConnection = new Client();
const socketPath = '/tmp/host_1/stream_1.sock';
sshConnection.exec('tail -F postgresql.log', (error, stream) => {
const server = net.createServer({noDelay: true}, (socket) => {
stream.pipe(socket).pipe(stream);
})
server.on('error', (err) => {
if (err.code === 'EADDRINUSE') {
// сокет уже есть, пробуем подключиться к нему
const clientSocket = new net.Socket();
clientSocket.on('error', (clientError) => {
if (clientError.code === 'ECONNREFUSED') {
// сокет никто не использует, тогда удаляем и пробуем снова
fs.unlinkSync(socketPath);
server.listen(socketPath);
}
});
clientSocket.connect({path: socketPath}, () => {
// подключились, значит кто-то уже использует этот сокет
clientSocket.destroy();
server.close();
});
}
})
server.on('listening', () => {
// сокет готов, можно подключаться со стороны воркера
})
server.listen(socketPath);
})
// worker
const clientSocket = new net.Socket();
clientSocket.connect({path: socketPath}, () => {
// подключились к сокету
clientSocket.on('data', (data) => {
// обрабатываем поступившие данные
// или просто clientSocket.pipe(dataHandler)
})
})
И получили значительное увеличение задержек на воркере.
Похоже net.Socket здесь нам не подойдет, пробуем заменить на named pipe (FIFO):
заменили net.Socket на FIFO
Код для FIFO проще, в sshproxy поверх него создаем пишущий поток, а в воркере — читающий
Пример кода для FIFO
// sshproxy
const { exec } = require('child_process');
const fs = require('fs');
const { Client } = require('system-ssh');
const sshConnection = new Client();
const fifo = '/tmp/host_1/stream_1.fifo'
const cmdFifo = exec(`test -p ${fifo} || mkfifo ${fifo}`, (error, stdout, stderr) => {
fs.open(fifo, 'w', (error, fd) => {
sshConnection.exec('tail -F postgresql.log', {stdio: ['pipe', fd, 'pipe']}, (error, stream) => {
const fifoStream = fs.createWriteStream(fifo, {fd});
stream.pipe(fifoStream);
})
})
})
// worker
let stream = fs.createReadStream(fifo);
stream.on('data', (data) => {
// обрабатываем поступившие данные
// или просто stream.pipe(dataHandler)
})
И получаем снижение задержек на загруженных воркерах в 15 раз:
Задержки воркеров снизились
и как следствие — практически полное отсутствие очередей записи в БД:
Очереди записи в БД
UV_THREADPOOL
Без ложки дегтя как обычно не обошлось, в данном случае при работе с FIFO надо учитывать, что все асинхронные файловые операции в Node.JS выполняются в uv_threadpool, а его размер ограничен и по умолчанию равен 4. Если все 4 потока в этом пуле будут заняты, то другие операции будут висеть в очереди.
Учитывая то, что в этом же пуле выполняются операции dns.lookup () и асинхронные crypto API, которых у нас в воркере довольно много (так как большая часть серверов осталась работать по старой схеме с модулем ssh2), при большом количестве загруженных FIFO можно получить снижение производительности.
Причиной этого является также то, что файловая обвязка поверх FIFO выполняет блокирующие операции, например fs.open (fifo) будет ждать и занимать поток uv_threadpool до тех пор, пока FIFO не будет открыт с другой стороны. Выходом, как пишут здесь, могло бы быть использование неблокирующего ввода-вывода, но модуль fs в Node.JS так не умеет, а тот что умеет — net.Socket нам не подходит.
В нашем же случае один sshproxy обслуживает одновременно 20–30 серверов PostgreSQL и негативное влияние на производительность отсутствует.