Почему SSH ?

Нам нужно выполнять shell и sql команды на серверах с PostgreSQL, например чтение файла лога, снятие статистики, поиск блокировок. Консольный доступ на большинстве серверов уже реализован через SSH, а с доступом к экземплярам PostgreSQL не так просто — нужно устанавливать новые соединения ко всем экземплярам, а для этого открывать сетевые порты и управлять конфигами pg_hba.conf, прописав в них IP‑адреса серверов мониторинга, да и передавать данные по сети в открытом виде нехорошо, а для SSL тоже нужны отдельные настройки.

Поэтому логично выполнять все операции через SSH, используя возможность запуска нескольких сеансов через одно соединение.

Работаем с модулем SSH2

Весь ssh-трафик идет через модуль ssh2
Весь ssh-трафик идет через модуль ssh2

Об архитектурных решениях подробно написано в этой статье и в продолжении.

При использовании модуля ssh2 и большой нагрузке может возникнуть неприятный побочный эффект, связанный с тем, что модуль написан на Javascript, а значит работает в основном потоке вместе с остальным кодом приложения. Помимо увеличения задержек в event loop это дополнительно нагружает GC и thread pool.

На некоторых серверах задержки event loop в часы пик вырастали до неприличных значений:

Прием данных модулем ssh2 приводит к большим задержкам
Прием данных модулем 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-соединений.

При небольшом количестве ssh-соединений такой вариант вполне работоспособен.

Но у нас в мониторинге около 2000 экземпляров PostgreSQL и запускать такое количество дочерних процессов нерационально. Для разгрузки event loop основного процесса достаточно перевести на новую модель только серверы с относительно большим потоком данных. В качестве фильтра для перевода на system‑ssh мы поставили границу 1.25 Mbps, а для возвращения на старую модель inproc — 0.75 Mpbs. Таким образом количество дочерних процессов остается небольшим, при этом большая часть трафика обрабатывается вне основного процесса:

на новой модели работают только серверы с большим ssh-трафиком
на новой модели работают только серверы с большим 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
Вынесли запуск 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
заменили 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 и негативное влияние на производительность отсутствует.

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


  1. Getequ
    00.00.0000 00:00

    "Но у нас в мониторинге около 2000 экземпляров PostgreSQL" - почему так много экземпляров? Просто не могу представить для чего может понадобиться держать столько серверов


    1. MGorkov Автор
      00.00.0000 00:00
      +2

      У нас много систем https://sbis.ru/all_services


    1. Aquahawk
      00.00.0000 00:00
      +1

      многие компании ведут бизнес по разному и возникают разные потребности, многие удивляются когда слышат по 500 гит реп, или что в одном из проектов отдаётся около миллиона файлов статики, как игра может жрать миллион файлов? Может. Все ради того, чтобы пользователь загрузил ровно то, что ему нужно и ни байтом больше, в идеальном сжатии. Или кликхаус в котором триллионы записей и скорость пополнения измеряется миллардами в сутки, это то, с чем я лично сталкиваюсь постоянно. Или Фейсбук 10 лет назад столкнулся с проблемой с производительностью гита, когда у них было в гите полтора миллиона файлов и 4 млн коммитов. https://habr.com/ru/post/137615/ Иногда есть спрос, есть маржинальность и надо вывозить, хотя пока не погрузишься в проблематику, кажется, а нафига столько и что можно проще. Иногда можно, а иногда - нет.