Простой и быстрый фреймворк для стресс-тестирования приложений

в 9:56, , рубрики: java, netty, performance tests, тестирование, метки: , , ,

Веб-приложений под высокие нагрузки в последнее время создается все больше, но вот с фреймворсками позволяющими гибко их стресс тестировать — не густо. Их конечно много разных (см. голосование в конце поста) но кто-то куки не поддерживает, кто-то нагрузку слабую дает, кто-то очень тяжеловесен, да и годятся они в основном для совсем однотипных запросов, т.е. динамически генерить каждый запрос используя свою логику и при этом еще максимально быстро (и в идеале на java чтобы допилить если что) — не нашел таких.

Поэтому было решено писать свой. Основные требования: быстрота и динамическая генерация запросов. При этом быстрота это не просто тысячи RPS, а в идеале — когда стресс упирается только в пропускную способность сети и работает с любой свободной машины.

Движок

С требованиями ясно, теперь нужно решить на чем это все будет работать, т.е. какой http/tcp клиент использовать. Конечно мы не хотим использовать устаревшую модель thread-per-connection (нить на соединение), потому что сразу упремся в несколько тысяч rps в зависимости от мощности машины и быстроты переключения контекстов в jvm. Т.о. apache-http-client и им подобные отметаются. Здесь надо смотреть на т.н. неблокирующие сетевые клиенты, построенные на NIO.

К счастью в java мире в этой нише давно присутствует стандарт де-факто опенсорсный Netty, который к тому очень универсален и низкоуровневый, позволяет работать с tcp и udp.

Архитектура

Для создания своего отправщика нам понадобится ChannelUpstreamHandler обработчик в терминах Netty, из которого и будет посылать наши запросы.

Далее нужно выбрать высокопроизводительный таймер для отправки максимально возможного количества запросов в секунду (rps). Здесь можно взять стандартный ScheduledExecutorService, он в принципе с этим справляется, однако на слабых машинах лучше использовать HashedWheelTimer (входит в состав Netty) из-за меньших накладных расходов при добавлении задач, только требует некоторого тюнинга. На мощных машинах между ними практически нет разницы.

И последнее, чтобы выжать максимум rps с любой машины, когда неизвестны какие лимиты по соединениям в данной ОСи или общая текущая нагрузка, надежней всего воспользоваться методом проб и ошибок: задать сначала какое-нибудь запредельное значение, например миллион запросов в секунду и далее ждать на каком количестве соединений начнутся ошибки при создании новых. Опыты показали что предельное количество rps обычно чуть поменьше этой цифры.
Т.е. берем эту цифру за начальное значение rps и потом если ошибки повторяются уменьшаем ее на 10-20%.

Реализация

Генерация запросов

Для поддержки динамической генерации запросов создаем интерфейс с единственный методом, который наш стресс будет вызывать чтобы получать содержимое очередного запроса:

public interface RequestSource {
    /**
     * @return request contents
     */
    ChannelBuffer next();
}

ChannelBuffer — это абстракция потока байтов в Netty, т.е. здесь должно возвращаться все содержимое запроса в виде потока байт. В случае http и других текстовых протоколов — это просто байтовое представление строки (текста) запроса.
Также в случае http необходимо ставить 2 символа новой строки в конце запроса(nn), это является признаком конца запроса и для Netty (не пошлет запрос в противном случае)

Отправка

Чтобы отправлять запросы в Netty — сначала нужно явно подключиться к удаленному серверу, поэтому на старте клиента запускаем периодические подключения с частотой в соответствие с текущим rps:

scheduler.startAtFixedRate(new Runnable() {
    @Overrid
    public void run() {
       try {
            ChannelFuture future = bootstrap.connect(addr);
            connected.incrementAndGet();
        } catch (ChannelException e) {
            if (e.getCause() instanceof SocketException) {
                processLimitErrors();            
            }
            ...
        }, rpsRate);

После успешного подключения, сразу посылаем сам запрос, поэтому наш Netty обработчик удобно будет наследовать от SimpleChannelUpstreamHandler где для этого есть специальный метод. Но есть один нюанс: новое подключение обрабатывается т.н. главном потоке («boss»), где не должны присутствовать долгие операции, чем может являться генерация нового запроса, поэтому придется перекладывать в другой поток, в итоге сама отправка запроса будет выглядеть примерно так:

private class StressClientHandler extends SimpleChannelUpstreamHandler {        
        ....
        @Override
        public void channelConnected(ChannelHandlerContext ctx, final ChannelStateEvent e) throws Exception {
            ...
            requestExecutor.execute(new Runnable() {
                @Override
                public void run() {
                    e.getChannel().write(requestSource.next());
                }
            });
            ....
        }
    }

Обработка ошибок

Далее — обработка ошибок создания новых соединений когда текущая частота отправки запросов слишком большая. И это самая нетривиальная часть, вернее сложно сделать это платформонезависимо, т.к. разные операционные системы ведут себя по разному в этой ситуации. Например linux выкидывает BindException, windows — ConnectException, а MacOS X — либо одно из этих, либо вообще InternalError (Too many open files). Т.о. на мак-оси стресс ведет себя наиболее непредсказуемо.

В связи с этим, кроме обработки ошибок при подключении, в нашем обработчике тоже необходимо это делать (попутно подсчитывая количество ошибок для статистики):

private class StressClientHandler extends SimpleChannelUpstreamHandler {        
        ....
        @Override
        public void exceptionCaught(ChannelHandlerContext ctx, ExceptionEvent e) throws Exception {
            e.getChannel().close();

            Throwable exc = e.getCause();
            ...
            if (exc instanceof BindException) {
                be.incrementAndGet();
                processLimitErrors();
            } else if (exc instanceof ConnectException) {
                ce.incrementAndGet();
                processLimitErrors();
            } 
            ...
        }
            ....
   }

Ответы сервера

Напоследок, надо решить что будем делать с ответами от сервера. Поскольку это стресс тест и нам важна только пропускная способность, здесь остается только считать статистику:

private class StressClientHandler extends SimpleChannelUpstreamHandler {
  @Override
        public void messageReceived(ChannelHandlerContext ctx, MessageEvent e) throws Exception {
            ...
            ChannelBuffer resp = (ChannelBuffer) e.getMessage();
            received.incrementAndGet();
            ...
        }
}

Здесь же может быть и подсчет типов http ответов (4xx, 2xx)

Весь код

Весь код с дополнительными плюшками вроде чтения http шаблонов из файлов, шаблонизатором, таймаутами и тп. лежит в виде готового maven проекта на GitHub (ultimate-stress). Там же можно скачать готовый дистрибутив (jar файл).

Выводы

Все конечно упирается в лимит открытых соединений. Например на linux при увеличении некоторых настроек ОС (ulimit и т.п.), на локальной машине удавалось добиться около 30K rps, на современном железе. Теоритечески кроме лимита соединений и сети больше ограничений быть не должно, на практике все же накладные расходы jvm дают о себе знать и фактический rps на 20-30% меньше заданного.

Автор: yetanothercoder

Источник



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