В посте речь пойдет о написании утилиты для нагрузочного тестирования HTTP сервисов на Node.js, а также описание самого инструмента и области его использования.
Предыстория
За выходные нужно было срочно провести нагрузочное нашего сервиса. Первым делом я отправился ставить Яндекс Танк, но оказалось, что у парней по прежнему все заточено под Debian. Ok Google, рабочая машина у меня на маке, виртуалку ставить ради этого совсем не хотелось, поэтому я пошел на тестовый сервер, где оказалась беда с зависимостями и недостаточно памяти. Достукиваться до админа в выходные совсем не хотелось, а руки все больше чесались написать несложную и интересную утилиту самому. Так появился Stress.
Я не отговариваю вас от танка или jMeter, но если нужен быстрый и простой (поставил-запустил) инструмент, надеюсь он вам пригодиться.
Почему Node.js?
Во-первых, асинхронность языка поможет нам максимально упростить написание кода для одновременного выполнения запросов на одном ядре.
Во-вторых, удобный встроенный cluster для воркеров и канал связи для них.
В-третьих, встроенный http-сервер и socket.io для отчетов в браузере.
Расширяемость
Нерасширяемый инструмент — мертвый инструмент. В нашем случае может понадобиться кастомизация для:
- обработки ответа от удаленного сервера
- стратегии отправки запросов
- агрегирования полученных результатов
- рисования грасивых графиков в браузере
Все это модули вашей конкретной стратегии, которую я решил назвать атакером. Атакеров может быть много и их можно писать самим. Каждый атакер состоит из следующих модулей:
- Dispatcher коммуницирует воркеры между собой и репортером
- Reporter анализирует данные и формирует отчеты
- Receiver анализирует тело ответа и считает статистику
- Frontend рисует графики в браузере
На данный момент создан только один атакер Step. Его поведение аналогично танковскому step, но этого достаточно для большинства задач. Также он пишет в логи все запросы, агрегированные результаты и рисует график.
Написание кода
С виду несложная архитектура омрачается необходимостью работать с параллельными запросами. Как известно Node.js имеет только один рабочий тред и при запуске одновременного большого числа http запросов они начнут вставать в очереди, увеличивая латенси. Поэтому мы сразу форкаем воркеры на количество ядер и общаемся через встроенный канал JSON сообщениями.
Stress.prototype.fork = function (cb) {
var self = this;
var pings = 0;
var worker;
if (cluster.isMaster) {
for (var i = 0; i < numCPUs; i++) {
worker = cluster.fork();
worker.on("message", function (msg) {
var data = JSON.parse(msg);
if (data.type === "ping") {
pings++;
if (pings === self.workers.length) cb(); // Все воркеры подняты, можно начинать
} else {
self.attack.masterHandler(data); // Рабочее сообщение от воркера
}
});
self.workers.push(worker); // Тут они у нас все
}
} else {
process.send(JSON.stringify({type: "ping"}));
process.on("message", function (msg) {
var data = JSON.parse(msg);
if (data.taskIndex === undefined) {
process.send("{}");
} else {
workerInstance.run(data); // логика воркера
}
});
}
};
Dispatcher призван ровно распределить запросы между всеми ядрами.
В конструкторе вызываем этот метод параллельно со всякими подготовительными делами в init:
async.parallel([
this.init.bind(this),
this.fork.bind(this)
], function () {
if (cluster.isMaster) {
self.next();
}
});
Метод next начинает итерировать таски, указанные в конфиге:
Stress.prototype.next = function () {
var task = this.tasks[this.currentTask];
if (!task) {
console.log("nDone");
process.exit();
} else {
var attacker = this.attackers[task.attack.type];
this.attack = new attacker.dispatcher(this.workers, this.currentTask, this.attackers);
this.attack.on("done", this.next.bind(this));
this.attack.run();
this.currentTask++;
}
};
Dispatcher вместе с Reporter'ом заправляет всем, что касается текущего таска. Сам по себе воркер совсем простой и представляет собой обертку вокруг request
task.request.jar = request.jar(new FileCookieStore(config.cookieStore));
async.each(arr, function (_, next) {
request(task.request, receiver.handle.bind(receiver, function (result) {
result.pid = process.pid;
result.reqs = reqs;
result.url = task.request.url;
result.duration = duration;
reporter.logAll(result);
next();
}));
}, function () {
process.send(JSON.stringify(receiver.report));
});
Как видно, все что лежит в объекте request является options для одноименной библиотеки, позволяя использовать в конфиге все ее возможности. Также при запросах используется tough-cookie-filestore, что позволит нам строить из тасков цепочки реквестов, ведь для полноценного тестирования часто нужно бывает проверить на нагрузки закрытые части сервиса.
Помимо прочего, Dispatcher может без труда прокидывать данные, которые сагрегировал для него Reporter куда угодно, например на клиент, где их ждет Google Chart.
Step.prototype.masterHandler = function (data) {
this.answers++;
if (Object.keys(data).length) this.summary.push(data);
if (this.answers === this.workers.length) {
var aggregated = this.attacker.reporter.logAggregate(this.summary);
this.attacker.frontend.emit("data", {
aggregated: aggregated,
step : this.currentStep
});
this.answers = 0;
this.currentStep = this.currentStep + this.task.attack.step;
this.run();
}
};
Если не забыть выставить в конфиге webReport = true и перейти по ссылке в консоли, можно смотреть как растет латенси при увеличивающихся RPS:
Установка и запуск
git clone https://github.com/yarax/stress
cd stress
npm i
npm start
В папке configs лежит дефолтный файл с реквестами к гуглу, там же можно создать свой конфиг и запустить как
npm start myConfigName
Буду рад, если кому-то статья окажется полезной, а также pull requests welcome :)
Автор: yarax