Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Обеспечение надежного функционирования системы при развертывании обновления системы требует запуска тестов разного уровня - от модульных тестов отдельных компонентов до интеграционных тестов, проверяющих в 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 запросов g6 поддерживает 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/g6
. Создадим файл реализации теста:
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 практики и инструменты. Узнать подробнее об уроке.