Нагрузочный тест c помощью Go

    Добрый день, Хабрахабр.
    Вы, вероятно, знакомы с JMeter. Если в кратце — очень удобный инструмент для проведения нагрузочного тестирования, имеет огромный функционал и много-много полезных фишек. Но статья не о нем.

    С чего началось

    В нашем проекте есть довольно нагруженный узел, JMeter помогал долгое время. Проффилирование и оптимизации дали свой профит, но все уперлось в маленькую проблему. JMeter не мог создать очень большой трафик, а если более точно, то после 10 секунд нужного нам режима, происходил OutOfMemory и тестирование прекращалось, в некоторых случаях проблемы не было, но скорость отправки запросов заметно уменьшалась, при этом загрузка CPU — 400%, решалось перезапуском программы. Пользоваться было крайне не удобно.
    Итак, мы имеем проблему, и ее нужно решить, первое, что пришло в голову — сделать свой мини-тест, отвечающий минимальным требованиям. Давно было интересно попробовать Go на вкус. Так родилось приложение go-meter. При написании возникало очень много вопросов, ответов на которые либо не было, либо они не объясняли проблему, поэтому я решил поделиться опытом и примером рабочего кода, если Вам интересно, прошу подкат.

    Предисловие

    Думаю писать о том, что это за язык не имеет смысла, вы всегда можете посмотреть тур по языку, который раскрывает основные элементы. Как устанавливать и настраивать окружение тоже не стоит, в документации все написано на вполне понятном языке.
    Почему выбрал именно Go? Тут есть несколько критериев, очень важных для меня: он быстро работает, кроссплатформенный, есть потоки, которыми просто управлять, необычный. Конечно, Вы скажите, что написать это можно и на любом другом языке. Я с Вами согласен, но задачей было не только написать, но и узнать что-то новое.

    Приступим

    Не долго думая было решено хранить профиль теста в JSON формате, после запуска приложения читается профиль и запускается тестирование. Во время тестирования в консоль выводится сводная таблица(время ответа, количество запросов в секунду и процентное отношение ошибок, предупреждений и удачных запросов). С JSON все просто, для этого нужно сделать структуры для каждого элемента, открыть и прочитать файл:
    func (this *Settings) Load(fileName string) error {
    	file, e := ioutil.ReadFile(fileName); if e != nil {
    		return e
    	}
    	e = json.Unmarshal(file, this); if e != nil {
    		return e
    	}
    	return nil
    }
    


    Пойдем дальше. После запуска нам нужно запустить N-потоков, и после отработки каждого из них агрегировать данные, далее выводить красиво в консоль. Для этого в этом интересном языке есть Channels. Своего рода «трубы» между разными потоками. Не нужно никаких синхронизаций, блокировок, все сделано за нас. Идея такая: поток отправляет запрос, определяет результат и об этом сообщает в основной поток, который в свою очередь ждет пока все потоки не отработают и выводить все полученные данные. Потоки у нас будут общаться по средствам передачи структуры:
    type Status struct {
    	IsError bool
    	IsWarning bool
    	IsSuccess bool
    	Duration *time.Duration
    	Size int64
    	IsFinished bool
    	Error *error
    	FinishedAt *time.Time
    	StartedAt *time.Time
    }
    

    Каждый поток у нас будет выполнять M-раз HTTP запрос к указанному ресурсу. Если у нас POST запрос, то еще отправляя определенные данные, которые хочет пользователь:
    func StartThread(setts *settings.Settings, source *Source, c chan *Status){
    	iteration := setts.Threads.Iteration
    	//Формируем объект key, value для заголовков запроса
    	header := map[string]string{}
    	for _, s := range setts.Request.Headers {
    		keyValue := regexp.MustCompile("=").Split(s, -1)
    		header[keyValue[0]] = keyValue[1]
    	}
    
    	sourceLen := len(*source)
    
    	//необходимый URL
    	url := setts.Remote.Protocol + "://" + setts.Remote.Host + ":" + strconv.Itoa(setts.Remote.Port) + setts.Request.Uri
    	if iteration < 0 {
    		iteration = sourceLen
    	}
    	index := -1
    	for ;iteration > 0; iteration-- {
    		status := &Status{false, false, false, nil, 0, false, nil, nil, nil}
    		index++
    		if index >= sourceLen {
    			if setts.Request.Source.RestartOnEOF {
    				index = 0
    			} else {
    				index--
    			}
    		}
    		//Получаем данные для отправки запроса
    		var s *bytes.Buffer
    		if strings.ToLower(setts.Request.Method) != "get" {
    			s = bytes.NewBuffer((*source)[index])
    		}
    		//Создаем HTTP запрос
    		req, err := http.NewRequest(setts.Request.Method, url, s); if err != nil {
    			status.Error = &err
    			status.IsError = true
    			c <- status
    			break
    		}
    		//Выставляем заголовки
    		for k,v := range header {
    			req.Header.Set(k,v)
    		}
    		//Засекаем время
    		startTime := time.Now()
    		//Отправляем запрос
    		res, err := http.DefaultClient.Do(req); if err != nil {
    			status.Error = &err
    			status.IsError = true
    			c <- status
    			break
    		}
    		endTime := time.Now()
    		//Записываем служебную информацию
    		status.FinishedAt = &endTime
    		status.StartedAt = &startTime
    		diff := endTime.Sub(startTime)
    		//Проверяем статус ответа и причисляем в одной из 3 групп (Error, Warning, Success)
    		checkStatus(setts.Levels, res, diff, status)
    		//Закрываем соединение
    		ioutil.ReadAll(res.Body)
    		res.Body.Close()
    		//Оповещаем главный поток
    		c <- status
    		//Если установлена в настройках задержка, выполняем ее
    		if setts.Threads.Delay > 0 {
    			sleep := time.Duration(setts.Threads.Delay)
    			time.Sleep(time.Millisecond * sleep)
    		}
    	}
    	//Оповещаем главный поток о завершении работы
    	status := &Status{false, false, false, nil, 0, true, nil, nil, nil}
    	c <- status
    }
    


    Осталось только запустить наши потоки при старте программы и слушать от них данные
    c := make(chan *Status, iteration * setts.Threads.Count)
    for i := 0; i < setts.Threads.Count; i++{
    	go StartThread(&setts, source, c)
    }
    for i := iteration * setts.Threads.Count; i>0 ; i-- {
    	counter(<-c)
    }
    fmt.Println("Completed")
    


    Вместо заключения

    Это самые интересные моменты, на мой взгляд. Все исходики доступны на GitHub, там можно посмотреть весь цикл работы с примером использования. По факту, с данной задачей этот чудо язык справился с лихвой, при генерации трафика объемом в 3 раза больше чем было в случае с JMeter загрузка процессора редко превышает 15%.
    Если будет интересно, расскажу о процессе написание HTTP Restfull Web сервиса с хранилищем в MongoDB и Redis.

    Спасибо за внимание!
    Поделиться публикацией

    Похожие публикации

    Комментарии 30
      +3
      Отличная статья!
      Продолжение приветствуется категорически.
        +1
        Как я понимаю, главная фишка jmeter не в мощности создаваемой нагрузки а в том, что потом можно посмотретьна различные графики по отзывчивости сервиса на разных уровнях нагрузки.
        А если вам именно тупая долбилка нужна, возьмите wrk github.com/wg/wrk (мой форк умеет брать список урлов из файла github.com/seriyps/wrk) или httpperf.

        А то, что у вас замер времени останавливается до того, как считается BODY ответа это намеренно сделано?
          +1
          В копилку долбилок: github.com/yandex-load/yandex-tank. Говорят, достаточно удобный.
            +3
            А эту даже сетапить не нужно — loader.io/
              0
              танк он другого уровня инструмент. Он, как и tsung предназначен для запуска с нескольких машин. На одном сервере он себя не проявит.
                0
                Почему не проявит? Наоборот, в большинстве случаев одной машинки более, чем достаточно.
                  0
                  Как минимум упретесь в сеть или количество одновременно открытых сокетов (65535 максимально, из них 65000 может и получится взять). В конце концов нагрузочное тестирование больших обьемов начнет показывать упирание в ресурсы машины, на которой ведется тестирование, а не ресурс, который Вы тестируете. А это не, что нам нужно измерять.

                  При разработки сервиса по тестированию именно разбиение теста на мелкие фрагменты (100 клиентов на 1000 машин) давало правильные цифры нагрузки на ресурс (только сбор и агрегация данной информации — это отдельная проблема). Но это я по своему опыту сужу.
                    0
                    Ну так это особенности ОС, а не инструмента. В доках есть настройки, которые позволят свести к минимуму влияние этих особенностей. Кроме того, танк может стрелять с одной машинки с нескольких айпишников. Понятное дело, что если вы уперлись в сеть, то ничего из этого не поможет, но и никакой другой инструмент не сможет прокачать через сеть больше, чем через нее может пролезть.

                    На практике с одного танка получалось более 100 000 запросов в секунду. Это почти 9 миллиардов в сутки — немногие могут похвастаться такими рейтами. Опять же, на практике — очень редко, когда бывает нужно два и более танка. Иначе давно бы уже отладили распределенные тесты. А так в этом просто нет необходимости.
                      0
                      > 100 000 запросов в секунду

                      Это просто запросы? А параллельных клиентов сколько, которые посылают запросы? Ну и 100000 * 10 КБ POST body ~ 1 Гигабайт в секунду. Утилита могла просто тестировать свою нагрузку.

                      Я не спорю, может Танк и крут, но пока я не видел утилиты, что может выжать более 50К параллельных клиентов (которые посылают запросы независимо) на одной машине. Да еще и условия самой машины должны быть оптимальны (50К ядерной машины я не видел, а значит и параллельность этих клиентов минимальна — ограничена в CPU + Network)

                      ЗЫ по докам 30000 эго предел в параллельных юзерах на тест
                        0
                        10 Гбитный интерфейс между машинками, машинки в одном свитче. Не знаю, сколько в среднем у нас было на запрос при таких тестах, но вы учтите, что серваку еще распарсить полученный запрос надо. Это уж точно более затратно, чем просто отправить его, т.е. сервак ляжет раньше танка. А что, ваш сервис держит 100К запросов в секунду при 10-килобайтных запросах?

                        В процессе тестирования мы наблюдаем за нагрузкой на танк и следим за тем, чтобы проц, диск и сеть не кончились. Если есть сомнения, добавляем второй танк.

                        Число параллельных запросов и рейт связаны через среднее время ответа вот так:
                        L = λW (закон Литтла), где L — уровень параллелизма, λ — входящий рейт, W — среднее время ответа.
                        Т.е. при 100 000 запросов в сек и временах ответа 15 мс, будет 15 * 0.001 * 100 000 = 1500 параллельных запросов. Это меньше, чем 30 000.

                        Еще, паралельные юзеры != параллельные запросы. У юзеров есть еще think time между запросами.

                        50 К ядер на 50 К клиентов тоже не нужно, потому что клиент не требует постоянной работы проца. Ну и не надо забывать, что с противоположной стороны провода тоже не магический кристалл, а земная машинка с процессором, памятью и дисками. И ваши клиенты, сколько бы их ни было, встанут в очередь для того, чтобы пролезть через провод (один).
                          0
                          > 10 Гбитный интерфейс между машинками, машинки в одном свитче

                          Тестирование в локальной сети с реальным миром немного отличается.

                          > А что, ваш сервис держит 100К запросов в секунду при 10-килобайтных запросах?

                          Нет, не держит, а создает (все эти параметры меняются). Один из наших продуктов — сервис для нагрузочного тестирования.

                          > Еще, паралельные юзеры != параллельные запросы. У юзеров есть еще think time между запросами.

                          Не знаю как у Танка, у нас это настраивается. По умолчанию = 0 (тоесть не «засыпать»). Я говорил про такой виде теста, когда нет think time.

                          > 50 К ядер на 50 К клиентов тоже не нужно, потому что клиент не требует постоянной работы проца

                          Если он не засыпает — то требует.

                          > Ну и не надо забывать, что с противоположной стороны провода тоже не магический кристалл, а земная машинка с процессором, памятью и дисками. И ваши клиенты, сколько бы их ни было, встанут в очередь для того, чтобы пролезть через провод (один).

                          Многие наши клиенты тестируют и свои системы, которые держат и 1 000 000 клиентов. Многие достигают этого через DNS записи (DNS Round Robin, Geolocation Routing Policy). Мы позволяет им тестировать с разных регионов — так тест похож более на реальный.

                          Не буду спорить по поводу Танка, но на одной машине я пока не верю, что можно сделать высоконагруженное тестирование (хотя понятие «высоконагруженное» у каждого инженера разное).
                            0
                            Понял, в вашем случае распределенные тесты — действительно необходимость.
                            Мы в наших тестах стараемся исключить внешние факторы, поэтому чаще всего это машинки в одном ДЦ, а при больших нагрузках — в одном свитче. Даже есть кейсы, когда все тестируется в рамках одной машинки, а генератор и сервис раскидываются по ядрам.
              +1
              В нашем случае очень важно время отклика сервера (время получения BODY можно опустить), есть определенные временные границы, в которые должен вписаться ответ(120мс, после этого соединение просто закрывается с другой стороны). И в нашем случае важно считать не просто время, но и какого типа ответ пришел (200, 204 или ...) и данный тест писался из реальных потребностей и должен был обхватывать конкретную узкую задачу. Я очень слаб в C/C++ по этому дорабатывать под свою задачу имеющийся проект было бы более затратно по времени, по этому сделал именно так. И было очень интересно попробовать Go
                0
                Достаточно известный Siege: www.joedog.org/siege-home/
                  +1
                  Он параллелится запуская потоки, что не так эффективно, как epool например. В какой то момент вы начинаете мерить не производительность сервера а производительность siege.
                  В wrk используется http парсер от nginx. Потом он запускает потоков по числу процессоров и в каждом из них стартует event loop с epool / kqueue / select — в зависимости от того, что доступно. Очень эффективная утилита.
                    0
                    Умеет ли он ходить на несколько урлов по порядку, сохраняя куки?
                      0
                      не. Stateless.
                        –1
                        Тогда его область применения крайне мала. В нагрузочном тестировании крайне желательно повторить действия типичного пользователя.
                –1
                Про сервис очень интересно было бы почитать.
                  +2
                  Меня смутило эта ваша любовь к начальному counter`у в значении -1 :)
                  А так — здорово, рад что сообщество начинает что-то выкладывать на golang, сам уже имею два успешных внедрения на продакшне.
                    0
                    Если я Вас правильно понял, то поясню :) изначально в случае ошибки поток продолжал работу и использовалось continue, и что бы счетчики все считались делал это в начале цикла, потом сделал прекращение цикла, а данный блок не переписывал, так и остался начальный индекс -1
                    0
                    А как организовывали красивый вывод в консоль?
                      +1
                      Загляните в исходники, там один файл за это отвечает, и просто выводит агрегиолванные данные в виде таблички
                      0
                      Почему бы не использовать агентов jmeter, если вы проседаете по CPU на одном инстансе jmeter.
                      Они вполне хорошо справляются с задачей создания распределенной нагрузки с разных узлов.
                        0
                        Кто там говорил что Java быстра?
                          +1
                          JMeter падал с OOM на 10 параллельных загрузках 100МБ файла при 3ГБ выделенных для Java и, съедая при этом 100% от обоих ядер.
                          Написал скрипт на Ruby+EM, который делал 100 таких загрузок параллельно, потреблял 20МБ памяти и 5% процессора. Там, правда всего один файл был строк на 15.
                            0
                            Зафолловил ГитХаб и добавил звездочку проекту:) Отдельное спасибо за пост, на хабре golangовые посты — редкость.
                            Кстати, как полезная мелочь, скомпилируйте и добавьте релиз для go-meter. Это удобно для тех, у кого Go не установлен и нечем скрмпилировать.
                              +4
                              Весьма радует, что вы выбрали и осилили Go для решения данной задачи: код достаточно хорошо работает и решает свои задачи, несмотря на небольшое знание языка. Однако, при этом код изобилует потенциальными проблемами, которые неизбежно будут копировать ваши читатели.
                              Например, вместо:
                              func (this *Settings) Load(fileName string) error {
                              file, e := ioutil.ReadFile(fileName); if e != nil {
                              return e
                              }
                              e = json.Unmarshal(file, this); if e != nil {
                              return e
                              }
                              return nil
                              }

                              Лучше пользоваться ридерами — как в случае больших конфигов, так и просто идеологически правильнее:

                              func (this *Settings) Load(fileName string) (e error) {
                              var file os.File
                              if file, e = os.Open(fileName); e != nil {
                              return
                              }
                              defer file.Close()
                              decoder := json.NewDecoder(file)
                              e = decoder.Decode(this)
                              return
                              }

                              Плюс много мелких деталей, делающий код лаконичнее: именованный возврат, defer'ы, функции внутри условия.

                              По возможности, лучше не читать из канала по счётчику: в случае проблем программа может банально зависнуть в ожидании канала. Лучше закрывать каналы в defer'е и принимать содержимое канала через range.

                              Вы передаёте Status по ссылке и при этом меняете — у вас нет никаких гарантий, что counter на момент прочтения будет читать именно тот Status, который был отправлен.

                              Вы создаёте очень большой канал: это совершенно лишний расход памяти.

                              Полностью инициализировать все переменные в структуре не обязательно: по умолчанию они будут созданы нулевыми, в зависимости от типа, т.е. вместо status := &Status{false, false, false, nil, 0, true, nil, nil, nil} можно делать status := &Status{isFinished: true}

                              Вообще говоря, есть куча мелочей, которые могут значительно ускорить код: один раз создавать http.Request'ы, вместо diff := time.Now().Sub(lastPrint); if diff.Seconds() > 1 { PrintStatus(); } использовать time.Tick в отдельной goroutine'е, использовать ссылки только там, где нужно, оптимизировать выделение памяти внутри циклов, итд…

                              PS Возможно, под ваши нужды хватило бы функционала Benchmark'ов в testing'е.
                                +1
                                Спасибо за замечания, есть куда развивать и учиться само собой. Постараюсь поправить все моменты и доконца проникнуться новым подходом. В процессе написания возникало много вопросов, с поиском ответов было сложновато. Самое сложное было найти ответ на: лучшая практика решения… задачи. К примеру как организовать проект, как организовать ведение логов и т.д. Ответов не много приходится учиться на своих ошибках
                                  0
                                  На самом деле, большинство таких вопросов отпадает после прочтения «Effective Go» и изучения Codewalk'ов. На практике тоже можно, просто дольше и мучительнее.

                                  Кстати, попробуйте тесты в Go: они здесь вполне простые и удобные (включая нагрузочные тесты), и пригодны для пресловутой «разработки через тестирование» (включая оптимизацию производительности).

                                  А так, не стесняйтесь, спрашивайте: rocket science здесь нет.

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

                              Самое читаемое