Обеспечение надежного функционирования системы при развертывании обновления системы требует запуска тестов разного уровня - от модульных тестов отдельных компонентов до интеграционных тестов, проверяющих в staging-окружении работу системы в целом. Но не менее важны для оценки готовности системы к большой кратковременной пиковой нагрузке (или злонамеренным атакам) выполнение нагрузочных тестов. В июле 2021 года компания Grafana Inc приобрела продукт k6, который изначально был ориентирован на запуск высокопроизводительных распределенных нагрузочных тестов, и это положительно повлияло на его дальнейшее развитие как встраиваемого инструмента для запуска тестов в облачных инфраструктурах или Kubernetes. В этой статье мы рассмотрим один из возможных сценариев использования k6 для тестирования сервиса в конвейере CI/CD.
Прежде всего отметим, что k6 может работать и как автономный инструмент тестирования (в виде выполняемого файла или docker-контейнера) и как управляемый кластер для организации распределенной нагрузки (например, с использованием k6-operator, который создает дополнительный тип ресурса в Kubernetes K6 для управления запуском необходимого количества процессов в кластере и определить для них контекст выполнения). Мы рассмотрим только вариант с использованием изолированного процесса, но при необходимости эти же тесты могут быть применены в распределенном сценарии использования.
K6 реализован на go и может быть установлен как через пакетный менеджер (homebrew, winget/choco, apt/dnf) или запущен из Docker-образа grafana/k6. Для описания теста используется сценарий на JavaScript (с поддержкой ES6), который выполняется в специальном окружении, предоставляющим доступ к управлению конфигурацией (через экспорт объекта options) и к описанию теста (экспорт функции default).
Для выполнения теста используются методы из модуля k6/http (get, post, put, patch, del), которые могут объединяться в группы (batch). Запрос может быть дополнен заголовками и содержанием. Результат может быть проверен через метод check с лямбда-функцией для проверки объекта ответа (например check(res, { 'status was 200': (r) => r.status == 200 });
). Также можно создавать собственные метрики и изменять их значение при получении определенных состояний (например, подсчитывать ошибки), для этого в модуле k6/metrics есть реализации счетчика (Counter), скорости (Rate), серии значений с выделением минимального, максимального и текущего (последнего) значения (Gauge). Запросы могут выполняться в цикле, в том числе с разделением интервалом (через вызов sleep).
Кроме http запросов k6 поддерживает grpc (модуль k6/net/grpc), Web Sockets (k6/ws). При получении ответа можно выполнить разбор html (модуль k6/html). Также можно получать информацию о текущем тесте (через модуль k6/execution).
Кроме этого существует большое количество расширений, которые добавляют возможности для управления ресурсами инфраструктуры (например xk6-browser поможет организовать тестирование веб-сайтов с помощью headless-браузера). xk6-amqp управляет AMQP-брокером и позволяет создавать exchange/queue/binding и взаимодействовать с очередями, xk6-kubernetes для манипуляции ресурсами кластером Kubernetes и др.)
Попробуем разработать простое приложение на Python и сэмулировать в сборочном конвейере нагрузочный тест для проверки сохранения адекватного времени доступа при увеличении количества одновременных подключений. Для реализации сборочного конвейера будем использовать возможности Gitlab с использованием Docker Runner (но здесь может использоваться Github Actions, Jenkins и любой другой инструмент).
Создадим минимальное приложение для тестирования на Flask:
from flask import Flask
app = Flask(__name__)
@app.route("/")
def hello_world():
return "Hello, World"
app.run(host='0.0.0.0')
и создадим Dockerfile
:
FROM python
RUN pip install flask
WORKDIR /opt
ADD main.py /opt
CMD python main.py
Теперь подготовим конфигурацию для тестирования, для этого будем использовать образ контейнера grafana/k6
. Создадим файл реализации теста:
import http from 'k6/http';
export default function () {
http.get('http://localhost:5000');
}
И запустим наш сервер. Для доступа к серверу из теста объединим два контейнера в одну сеть:
docker network create test
docker build -t testserver .
docker run -itd --network test testserver
И запустим нагрузочное тестирование, для этого укажем продолжительность выполнения теста (--duration) и количество виртуальных пользователей (--vus).
sudo docker run --network test -i --rm grafana/k6 run --vus 100 --duration 10s - <test.k6
Результатом выполнения будет отчет, включающий временные замеры по всем этапам http-подключения, для нас наиболее интересна продолжительность итерации:
running (10.1s), 000/100 VUs, 10908 complete and 0 interrupted iterations
default ✓ [ 100% ] 100 VUs 10s
data_received..................: 2.0 MB 200 kB/s
data_sent......................: 884 kB 88 kB/s
http_req_blocked...............: avg=316.24µs min=99.84µs med=151.79µs max=40.04ms p(90)=181.33µs p(95)=191.9µs
http_req_connecting............: avg=140.68µs min=62.51µs med=98.56µs max=36.79ms p(90)=118.12µs p(95)=125.86µs
http_req_duration..............: avg=91.65ms min=1.87ms med=91.17ms max=110.26ms p(90)=94.28ms p(95)=98.92ms
{ expected_response:true }...: avg=91.65ms min=1.87ms med=91.17ms max=110.26ms p(90)=94.28ms p(95)=98.92ms
http_req_failed................: 0.00% ✓ 0 ✗ 10908
http_req_receiving.............: avg=300.97µs min=36.12µs med=177.21µs max=7.01ms p(90)=670.17µs p(95)=727.9µs
http_req_sending...............: avg=63.18µs min=23.13µs med=40.73µs max=30.66ms p(90)=52.84µs p(95)=58.23µs
http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s
http_req_waiting...............: avg=91.29ms min=1.32ms med=90.83ms max=109.35ms p(90)=93.87ms p(95)=98.55ms
http_reqs......................: 10908 1081.901504/s
iteration_duration.............: avg=92.03ms min=2.7ms med=91.38ms max=140.05ms p(90)=94.52ms p(95)=99.51ms
iterations.....................: 10908 1081.901504/s
vus............................: 100 min=100 max=100
vus_max........................: 100 min=100 max=100
Можем увидеть, что при 100 пользователей потерь подключений не было (поскольку vus в среднем сохраняется 100), средняя продолжительность итерации 92.03 мс, медиана - 91.38 мс, 90-й перцентиль по времени 94.52 мс, 95-й перцентиль - 99.51 мс. Запустим теперь тест с 10000 пользователей.
http_req_failed................: 8.09% ✓ 1149 ✗ 13041
iteration_duration.............: avg=9.73s min=118.66ms med=2.24s max=35.29s p(90)=30s p(95)=30.04s
vus............................: 1145 min=0 max=10000
vus_max........................: 10000 min=3532 max=10000
Можно увидеть, что в среднем обработалось только 1145 подключений (а в некоторые итерации все запросы отбивались, min vus = 0, http_req_failed 8%). Время обработки запросов и 90 и 95 перцентиль выше 30 секунд, медианное время 2.24 с. Кажется, было бы хорошо остановить тест сразу, когда время ответа начинает превышать пороговое, например, 1 секунду, и сообщить об этом как о провале нагрузочного теста.
Полученные метрики могут накапливаться (--summary-trend-stats перечисляет метрики по которым будет анализироваться тренд), отправляться во внешние системы (в JSON, CSV, Prometheus, InfluxDB, Datadog, New Relic),
Добавим опции в тест и перенесем туда определение vus, duration (также может быть указан список stages для запуска многостадийного теста с указанной продолжительностью и количеством пользователей), а также добавим пороговые значения (thresholds) для остановки при превышении скорости появления ошибочных запросов. Также можно определить сценарий с определением executor для управления количеством пользователей, например ramping-vus для постепенного увеличения подключений (в этом случае startVUs определяет начальное значение и stages для определения промежуточных значений и продолжительности для их достижения). Для сложных сценариев может быть задан executor externally-controlled для программного управления интенсивностью запросов через cli или через REST API).
import http from 'k6/http';
export const options = {
scenarios: {
growing_scenario: {
executor: "ramping-vus",
startVUs: 100,
stages: [
{ duration: '20s', target: 1000 },
],
}
},
thresholds: {
http_req_failed: ['rate<0.005'],
http_req_duration: ['p(95)<500'],
},
};
export default function () {
http.get('http://testserver:5000');
}
Тест проверяет на возрастающем количестве подключений в течении 20 секунд от 100 до 1000 пользователей. Успешным будет считаться выполнение при менее 0.5% ошибок и при 95-м перцентиле менее 500 мс. При превышении пороговых значений будет возвращен ненулевой код возврата, который воспринимается CI/CD как ошибка выполнения шага сценария. Создадим теперь необходимые сценарии для сборки контейнера и автоматического выполнения нагрузочного тестирования и добавим функцию handleSummary(data) в тест для создания json-артефакта из результатов тестирования (и сохранения его в gitlab):
import http from 'k6/http';
export const options = {
scenarios: {
growing_scenario: {
executor: "ramping-vus",
startVUs: 100,
stages: [
{ duration: '20s', target: 1000 },
],
}
},
thresholds: {
http_req_failed: ['rate<0.005'],
http_req_duration: ['p(95)<500'],
},
};
export default function () {
http.get('http://testserver:5000');
}
export function handleSummary (data) {
return {
'stdout': textSummary(data, { indent: ' ', enableColors: true }),
'./summary.json': JSON.stringify(data),
}
}
И соответствующий .gitlab-ci.yml:
stages:
- build
- test
test:
services:
- name: "dmitriizolotov/testserver"
alias: testserver
stage: test
image:
name: grafana/k6
entrypoint: [""]
script:
- k6 run test.k6
artifacts:
paths:
- summary.json
expire_in: 30 days
build:
stage: build
image:
name: gcr.io/kaniko-project/executor:debug
entrypoint: [""]
script:
- mkdir -p /kaniko/.docker
- echo '{"auths":{"https://index.docker.io/v1/":{"auth":"..."}}}' >/kaniko/.docker/config.json
- >-
/kaniko/executor
--cache-dir=/cache
--context "${CI_PROJECT_DIR}"
--dockerfile "${CI_PROJECT_DIR}/Dockerfile"
--destination dmitriizolotov/testserver
Теперь нагрузочное тестирование будет использовать сервис из созданного на первом шаге контейнера и оценивать поведение под нарастающей нагрузкой. Для реалистичности gitlab-runner должен запускаться на staging-серверах, чтобы контейнер проверяемого под нагрузкой процесса работал в условиях, приближенных к production-окружению. Кроме прочего, при выполнении теста сохраняется json-артефакт, содержащий данные об итогах прохождения теста и он может использоваться в дальнейшем для анализа изменений значений по мере развития кода.
При необходимости распределенного выполнения теста сценарий будет немножко иным и будут использоваться возможности k6-operator и ресурс K6 для запуска распределенного теста на staging-кластере.
Использование k6 для нагрузочного тестирования в конвейере сборки может повысить надежность развертываемых систем, обнаружить деградацию по производительности и обнаружить потенциально узкие места, которые могут привести к серьезным проблемам с доступностью при аномальном росте нагрузки на систему.
Как из инженера службы поддержки стать SRE? Об этом уже 7 июля расскажет мой коллега Анатолий Бурнашев на бесплатном уроке курса SRE практики и инструменты. Узнать подробнее об уроке.
Комментарии (3)
AndyStatic
06.07.2022 09:48Спасибо за статью по k6.
У вас есть пример скрипта для k6, как тестировать приложение c подзапросами, где есть первичный http.get('http://testserver:5000'), который вызывает еще несколько rest/graphql get чтоб их ответы отобразить на странице? Ведь скорость загрузки страницы — это совокупность всех запросов. В данном случае k6 измерит только статичный начальный Get?
Например в JMeter во время записи, рекордер позволяет сгруппировать все подзапросы для страницы и измерить скорость для всей группы запросов как нагрузку для одной страницы.
Или для этого и необходим batch, но придется все анализировать и кодить вручную? Спасибо.polarnik
06.07.2022 16:14+1Можно собрать k6 с дополнительным модулем xk6-browser (задача непростая) или скачать собранный. Использовать функцию setup, чтобы один раз открыть страницу и получить ее ресурсы используя Route — https://try.playwright.tech/?e=intercept-requests.
// Log and continue all network requests page.route('**', (route, request) => { if(request.method() == 'GET' && request.url().toString().indexOf('testserver:5000/')>0) { console.log(request.url()); // тут копирование в список } route.continue(); });
Положить ссылки на ресурсы в список или в map, где ключ будет базовой ссылкой, а значениями — список ссылок на связанные ресурсы. Написать два теста. Один, который загружает только базовую ссылку, пусть в 95% случаев и второй, которой имитирует загрузку с пустым кешем браузера, пусть в 5% случаев. Загрузка с пустым кешем — это загрузка всех ссылок
Это сложный способ описал. Простой может кто-то еще подскажет
polarnik
Статья хорошая. Для целей отслеживания деградаций я бы порекомендовал заменить подход с нагрузкой в пользователях/потоках без пауз: startVUs: 100, target: 1000 на профиль с фиксированной нагрузкой. Так можно будет оценивать время ответа.
Если этого не сделать, то вот приложение обрабатывало запросы от 100 потоков пусть за 100 мсек и мы получали 1000 rps. Вот оно стало оптимальнее, и готово обрабатывать запросы за 50 мсек, но в него приходит нагрузка в 2000 rps и мы получаем все равно тормоза и те же 100 мсек - не видим разницы.
В подходе с пулом подключений основная метрика не http_req_duration а
http_reqs......................: 10908 1081.901504/s
Если стало ниже, чем вчера - стало хуже.
А вот если подавать постоянную нагрузку, пусть в 150 rps, например, как на продуктиве или близко, вот так
https://k6.io/docs/using-k6/scenarios/executors/constant-arrival-rate
то будет тест для регрессионного тестирования, в котором можно замерять время ответа.