
Обеспечение надежного функционирования системы при развертывании обновления системы требует запуска тестов разного уровня - от модульных тестов отдельных компонентов до интеграционных тестов, проверяющих в 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 практики и инструменты. Узнать подробнее об уроке.
