Как определить и протестировать SLO

SLOs или Service Level Objectives (цели уровня обслуживания) сложно тестировать, а еще сложнее  —  определить без метода проб и ошибок. Grafana K6 позволяет упростить этот процесс.

Покажем, как с помощью K6 определить SLO для ряда конечных точек на демонстрационном сервисе K6, а затем напишем многоразовые тесты производительности этих SLO, чтобы мониторить сервис с течением времени.

Ресурсы K6

K6 предоставляет набор ресурсов, которые можно использовать при создании тестов для определенных типов приложений. Ресурсы содержат ряд подробных руководств по использованию k6, а также инструменты и интеграции для облегчения нагрузочного тестирования. Мы же рассмотрим тестовые серверы.

test-api.k6.io  —  отличный выбор для взаимодействия со SLO, поскольку здесь есть множество различных типов конечных точек.

Стандартные API K6

Используя Crocodile API, мы получим:

  • ряд общедоступных конечных точек POST;
  • ряд конечных точек POST для регистрации и аутентификации;
  • ряд частных конечных точек, содержащих конечные точки GET, POST, PUT, PATCH и DELETE.

В этой статье будем использовать k6 для определения SLO, приемлемых для сервиса, и создадим серию тестов, которые позволят выяснить, могут ли какие-либо изменения в этих API подвергнуть риску SLO.

Определение SLO

Цель уровня обслуживания  —  это полное описание работы сервиса, которое состоит из SLI или Service Level Indicators (показателей уровня обслуживания).

SLO помогает понять, на чем нужно сосредоточиться для повышения качества сервисов приложения и что другие команды и клиенты могут ожидать от этих сервисов.

Допустим, 99,9% всех запросов к приложению должны выполняться менее чем за 200 мс.

Итоговый показатель SLO для приложения складывается из общего времени безотказной работы для каждого типа запросов, от которых можно обоснованно ожидать разного поведения.

Например, если SLO требует, чтобы 99,9% запросов выполнялись менее чем за 200 мс, но одна конечная точка используется для загрузки файлов, размер которых регулярно превышает гигабайт, то без SLO из нескольких компонентов порог для большинства конечных точек будет слишком высоким, или же порог для конечной точки загрузки окажется слишком низким.

Чтобы определить SLO, начнем с тестового скрипта k6, который предоставляется на странице API.

Вывод скрипта, предоставленного K6

После выполнения скрипта получаем базовую информацию о том, чего можно ожидать от сервиса в целом. Запросы заняли в среднем 302,94 мс, включая обновление, удаление и создание элементов, создание пользователя, его аутентификацию и перечисление пользователей.

Пример SLO для этого сервиса может выглядеть следующим образом:

99.9% of requests will return a successful response code within 400ms(99,9% запросов будут возвращать успешный код ответа в течение 400 мс).

В рамках этой SLO нам не о чем беспокоиться, поскольку остается определенное пространство для оптимизации при столкновении с какими-то сетевыми проблемами, проблемами бэкенда или чем-то еще, что могло бы немного снизить производительность приложения.

Однако, как и в предыдущем примере, следует разбить эти тесты на несколько частей и использовать k6 для изучения того, действительно ли эта SLO подходит для данного сервиса.

Разделение аутентификации и авторизации

Сначала нужно разделить запросы по функциям. Поскольку часть REST API, связанная с регистрацией и авторизацией, выполняет иную функцию, чем остальная часть API, она может работать по-разному. Поэтому для нее наверняка понадобится выделить отдельную SLO.

Для этого добавим package.json k6 и зависимость k6:

{
"dependencies": {
"k6": "^0.0.0"
}
}

Затем обратим внимание на существующий файл и создадим несколько сценариев, которые можно протестировать отдельно.

Создадим два разных тестовых файла, чтобы можно было исследовать каждый тип запроса отдельно. Первый файл с названием test-auth.js будет выглядеть следующим образом:

import { describe } from 'https://jslib.k6.io/functional/0.0.3/index.js';
import { Httpx, Request, Get, Post } from 'https://jslib.k6.io/httpx/0.0.2/index.js';
import { randomIntBetween, randomItem } from "https://jslib.k6.io/k6-utils/1.1.0/index.js";

export let options = {
thresholds: {
checks: [{threshold: 'rate == 1.00', abortOnFail: true}],
},
vus: 1,
iterations: 1
};

const USERNAME = `user${randomIntBetween(1, 100000)}@example.com`;
const PASSWORD = 'superCroc2019';

let session = new Httpx({baseURL: 'https://test-api.k6.io'});

export default function authTest(){
describe(`01. Create a test user ${USERNAME}`, (t) => {

let resp = session.post(`/user/register/`, {
first_name: 'Crocodile',
last_name: 'Owner',
username: USERNAME,
password: PASSWORD,
});

t.expect(resp.status).as("status").toEqual(201)
.and(resp).toHaveValidJson();
})

describe(`02. Authenticate the new user ${USERNAME}`, (t) => {

let resp = session.post(`/auth/token/login/`, {
username: USERNAME,
password: PASSWORD
});

t.expect(resp.status).as("Auth status").toBeBetween(200, 204)
.and(resp).toHaveValidJson()
.and(resp.json('access')).as("auth token").toBeTruthy();

let authToken = resp.json('access');
// set the authorization header on the session for the subsequent requests.
session.addHeader('Authorization', `Bearer ${authToken}`);

})


}

Мы создали функцию по умолчанию authTest, которая будет выполнять только два теста: создание пользователя и его аутентификация. Как следует из опций, мы работаем только с одним виртуальным пользователем:

export let options = {
thresholds: {
checks: [{threshold: 'rate == 1.00', abortOnFail: true}],
},
vus: 1,
iterations: 1
};

Рандомизируем имя пользователя и пароль, чтобы не столкнуться с проблемами, вызванными константами USERNAME и PASSWORD.

Теперь, запустив k6 run test-auth.js, получаем следующее:

Результаты test-auth.js

Глядя на поле http_req_duration, мы видим, что для этих запросов SLO, определенная как 99.9% of requests will return a successful response code within 500ms (99.9% запросов вернут успешный код ответа в течение 500 мс), будет слишком низкой, даже с запасом погрешности почти в 100 мс. Слишком большое количество запросов на аутентификацию по сравнению со стандартными запросами API вызвало бы предупреждения SLO несмотря на то, что с самой платформой все в порядке.

Учитывая эту информацию, более подходящее определение SLO выглядело бы следующим образом:

99.9% of requests will return a successful response code within 500ms(99,9% запросов возвращают код успешного ответа в течение 500 мс).

99.9% of authentication requests will return a successful response code within 1300ms(99,9% запросов на аутентификацию вернут успешный код ответа в течение 1300 мс).

Использование немного большего значения для запросов аутентификации в контексте SLO предоставит запас погрешности, но в этом случае мы все еще будем получать уведомления, если что-то пойдет не так с сервисом.

Однако первая SLO может быть искажена включением запросов аутентификации в первоначальный тест, и, возможно, ее также придется настроить. Чтобы прояснить этот момент, создадим еще один файл test-api.js, который будет выглядеть следующим образом:

import { describe } from 'https://jslib.k6.io/functional/0.0.3/index.js';
import { Httpx, Request, Get, Post } from 'https://jslib.k6.io/httpx/0.0.2/index.js';
import { randomIntBetween, randomItem } from "https://jslib.k6.io/k6-utils/1.1.0/index.js";

export let options = {
thresholds: {
checks: [{threshold: 'rate == 1.00', abortOnFail: true}],
},
vus: 1,
iterations: 1
};

const USERNAME = `user${randomIntBetween(1, 100000)}@example.com`;
const PASSWORD = 'superCroc2019';

let session = new Httpx({baseURL: 'https://test-api.k6.io'});

export default function crocTest() {

// создание пользователя
let register_resp = session.post(`/user/register/`, {
first_name: 'Crocodile',
last_name: 'Owner',
username: USERNAME,
password: PASSWORD,
});

let resp = session.post(`/auth/token/login/`, {
username: USERNAME,
password: PASSWORD
});


let authToken = resp.json('access');
// установка заголовка авторизации в сессии для последующих запросов.
session.addHeader('Authorization', `Bearer ${authToken}`);


describe('01. Fetch public crocs', (t) => {
let responses = session.batch([
new Get('/public/crocodiles/1/'),
new Get('/public/crocodiles/2/'),
new Get('/public/crocodiles/3/'),
new Get('/public/crocodiles/4/'),
], {
tags: {name: 'PublicCrocs'},
});

responses.forEach(response => {
t.expect(response.status).as("response status").toEqual(200)
.and(response).toHaveValidJson()
.and(response.json('age')).as('croc age').toBeGreaterThan(7);
});
})


describe('02. Create a new crocodile', (t) => {
let payload = {
name: `Croc Name`,
sex: randomItem(["M", "F"]),
date_of_birth: '2019-01-01',
};

let resp = session.post(`/my/crocodiles/`, payload);

t.expect(resp.status).as("Croc creation status").toEqual(201)
.and(resp).toHaveValidJson();

session.newCrocId=resp.json('id');
})

describe('03. Fetch private crocs', (t) => {

let response = session.get('/my/crocodiles/');

t.expect(response.status).as("response status").toEqual(200)
.and(response).toHaveValidJson()
.and(response.json().length).as("number of crocs").toEqual(1);
})

describe('04. Update the croc', (t) => {
let payload = {
name: `New name`,
};

let resp = session.patch(`/my/crocodiles/${session.newCrocId}/`, payload);

t.expect(resp.status).as("Croc patch status").toEqual(200)
.and(resp).toHaveValidJson()
.and(resp.json('name')).as('name').toEqual('New name');

let resp1 = session.get(`/my/crocodiles/${session.newCrocId}/`);

})

describe('05. Delete the croc', (t) => {

let resp = session.delete(`/my/crocodiles/${session.newCrocId}/`);

t.expect(resp.status).as("Croc delete status").toEqual(204);
});

}

Для тестирования API придется создать пользователя и аутентифицировать его. Однако чтобы это не отображалось в тестах, в начале новой функции crocTest добавим секцию для создания пользователя и аутентификации, не добавляя ее в тесты, которые будут измеряться с помощью describe.

// создание пользователя 
let register_resp = session.post(`/user/register/`, {
first_name: 'Crocodile',
last_name: 'Owner',
username: USERNAME,
password: PASSWORD,
});

let resp = session.post(`/auth/token/login/`, {
username: USERNAME,
password: PASSWORD
});

let authToken = resp.json('access');
// установка заголовка авторизации в сессии для последующих запросов.
session.addHeader('Authorization', `Bearer ${authToken}`);

Теперь, запустив k6 run test-api.js, получаем новый набор метрик:

test-api.js

Как видно по http_req_duration, этот набор сервисов в среднем достигает того же уровня, что и в первом примере. Похоже, что набор из двух SLO будет работать, если ничего не упустить в производительности этого API.

Но что насчет общедоступных и частных API? И что насчет конечных точек POST, PUT, PATCH и DELETE? Чтобы получить полное представление о производительности приложения и определить наиболее точные SLO, придется изучить различия и в этих конечных точках.

Общедоступные и частные API

Для получения полного представления о работе сервиса нужно разбить запросы по типам. В этом случае можно сделать общий вывод по всему сервису, но сначала посмотрим, чем отличаются общедоступные конечные точки от частных.

Для сервиса Croc доступны только два общедоступных API: один для получения списка всех “крокодилов”, а другой  —  для получения конкретного “крокодила”.

Для тестирования общедоступных конечных точек подойдет более простой скрипт без какой-либо аутентификации.

import { describe } from 'https://jslib.k6.io/functional/0.0.3/index.js';
import { Httpx, Request, Get, Post } from 'https://jslib.k6.io/httpx/0.0.2/index.js';
import { randomIntBetween, randomItem } from "https://jslib.k6.io/k6-utils/1.1.0/index.js";

export let options = {
thresholds: {
checks: [{threshold: 'rate == 1.00', abortOnFail: true}],
},
vus: 1,
iterations: 1
};

let session = new Httpx({baseURL: 'https://test-api.k6.io'});

export default function crocTest() {

describe('01. Fetch public crocs', (t) => {
let responses = session.batch([
new Get('/public/crocodiles/1/'),
new Get('/public/crocodiles/2/'),
new Get('/public/crocodiles/3/'),
new Get('/public/crocodiles/4/'),
], {
tags: {name: 'PublicCrocs'},
});

responses.forEach(response => {
t.expect(response.status).as("response status").toEqual(200)
.and(response).toHaveValidJson()
.and(response.json('age')).as('croc age').toBeGreaterThan(7);
});
})


describe('02. List public crocs', (t) => {

let response = session.get('/public/crocodiles/');

t.expect(response.status).as("response status").toEqual(200)
.and(response).toHaveValidJson();
})

}

Запустив k6 run test-public-apis.js, получаем следующий результат:

test-public-apis.js

Эти общедоступные API работают значительно ниже среднего значения 300 мс, показывая результат 191 мс в среднем для http_req_duration. Поэтому эти две конечные точки наверняка будут снижать средние показатели. Однако, не видя результатов других конечных точек, пока нельзя сказать, нужно ли корректировать SLO.

Разобрав конечные точки аутентификации и авторизации, перейдем к частным API, перечисленным на test-api.k6.io.

Частные API

Чтобы подготовиться к тестированию частных API, создадим нового пользователя с именем [email protected] и паролем superCroc2019. Будем работать с ним, чтобы не создавать пользователя каждый раз заново.

import { describe } from 'https://jslib.k6.io/functional/0.0.3/index.js';
import { Httpx, Request, Get, Post } from 'https://jslib.k6.io/httpx/0.0.2/index.js';
import { randomIntBetween, randomItem } from "https://jslib.k6.io/k6-utils/1.1.0/index.js";

export let options = {
thresholds: {
checks: [{threshold: 'rate == 1.00', abortOnFail: true}],
},
vus: 1,
iterations: 1
};


const PASSWORD = 'superCroc2019';
const USERNAME = '[email protected]';

let session = new Httpx({baseURL: 'https://test-api.k6.io'});


export default function postCrocTest() {

let resp = session.post(`/auth/token/login/`, {
username: USERNAME,
password: PASSWORD
});

let authToken = resp.json('access');
// установка заголовка авторизации в сессии для последующих запросов.
session.addHeader('Authorization', `Bearer ${authToken}`);


describe('01. Create a new crocodile', (t) => {
let payload = {
name: `Croc Name`,
sex: randomItem(["M", "F"]),
date_of_birth: '2019-01-01',
};

let resp = session.post(`/my/crocodiles/`, payload);

t.expect(resp.status).as("Croc creation status").toEqual(201)
.and(resp).toHaveValidJson();

session.newCrocId=resp.json('id');
})

describe('02. Fetch private crocs', (t) => {

let response = session.get('/my/crocodiles/');

t.expect(response.status).as("response status").toEqual(200)
.and(response).toHaveValidJson()
.and(response.json().length).as("number of crocs").toEqual(1);
})

describe('03. Update the croc', (t) => {
let payload = {
name: `New name`,
};

let resp = session.patch(`/my/crocodiles/${session.newCrocId}/`, payload);

t.expect(resp.status).as("Croc patch status").toEqual(200)
.and(resp).toHaveValidJson()
.and(resp.json('name')).as('name').toEqual('New name');

let resp1 = session.get(`/my/crocodiles/${session.newCrocId}/`);

})

describe('04. Fetch created private croc', (t) => {

let response = session.get(`/my/crocodiles/${session.newCrocId}/`);

t.expect(response.status).as("response status").toEqual(200)
.and(response).toHaveValidJson();
})


describe('05. Delete the croc', (t) => {

let resp = session.delete(`/my/crocodiles/${session.newCrocId}/`);

t.expect(resp.status).as("Croc delete status").toEqual(204);
});

}

Запустив этот последний исследовательский скрипт с помощью k6 run test-private-api.js, видим, глядя на http_req_duration, что средняя продолжительность запросов для частных API в среднем более чем на 100 мс быстрее, чем для общедоступных конечных точек.

test-private-api.js

Судя по результатам тестирования, для хорошей наглядности по данному приложению понадобится трехуровневая SLO:

99.9% of public requests will return a successful response code within 300ms(99,9% публичных запросов будут возвращать успешный код ответа в течение 300 мс).

99.9% of private requests will return a successful response code within 500ms(99,9% частных запросов будут возвращать успешный код ответа в течение 500 мс).

99.9% of authentication requests will return a successful response code within 1300ms(99,9% запросов на аутентификацию будут возвращать успешный код ответа в течение 1300 мс).

Следующим шагом будет использование K6 для кодификации этих SLO в скрипте, чтобы при внесении в приложение изменений, негативно влияющих на производительность, получить предупреждение. Сделаем это, используя концепцию порогов и сценариев в K6.

Сценарии и пороговые значения в K6

В k6 порог учитывает процент ответов, которые могут не сработать, время ответа на 99-й процентиль, 95-й процентиль, 100-й процентиль или любую другую комбинацию пользовательских показателей. Используя концепцию сценариев, можно установить отдельные пороговые значения для каждой категории API, чтобы тестировать их отдельно.

В определении SLO есть две основные части: 99,9% запросов будут возвращаться успешно и ответы будут приходить в течение 300, 500 и 1300 мс.

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

Сначала нужно создать новый файл api-slos.js, в котором будут собраны все проведенные тесты и кодифицированы SLO. Чтобы создать несколько различных сценариев, будем менять константу options следующим образом:

export const options = {
scenarios: {
authentication_test: {
// какое-либо название сценария
executor: 'per-vu-iterations',
vus: 1,
iterations: 1,
tags: { test_type: 'authentication' },
env: {
USERNAME: `user${randomIntBetween(1, 100000)}@example.com`,
PASSWORD: 'superCroc2019'
},
exec: 'authTest',
},
public_apis_test: {
executor: 'per-vu-iterations',
vus: 1,
iterations: 1,
tags: { test_type: 'public_apis' },
exec: 'publicApis',
},
private_apis_test: {
executor: 'per-vu-iterations',
vus: 1,
iterations: 1,
tags: { test_type: 'private_apis' },
exec: 'privateApis',
},
}
};

Основные детали, о которых следует помнить: теперь у нас есть блок scenarios, позволяющий передавать случайные переменные среды в метод authTest, куда ссылается тег exec.

Ссылаться на эти переменные среды будем следующим образом:

describe(`01. Create a test user ${__ENV.USERNAME}`, (t) => {

let resp = authSession.post(`/user/register/`, {
first_name: 'Crocodile',
last_name: 'Owner',
username: __ENV.USERNAME,
password: __ENV.PASSWORD,
});

t.expect(resp.status).as("status").toEqual(201)
.and(resp).toHaveValidJson();
})

Кроме того, определим VUS и итерации отдельно для каждого теста, а также теги, чтобы результаты были четко распределены в итоговом наборе метрик.

Далее нужно добавить блок thresholds в опции. Теперь пороговые значения будут представлять собой словарь со ссылками на теги. Установим пороговые значения в соответствии с SLO в мс.

thresholds: {
'http_req_duration{test_type:public_apis}': ['avg<300'],
'http_req_duration{test_type:private_apis}': ['avg<500'],
'http_req_duration{test_type:authentication}': ['avg<1300'],
},

В завершение создадим три различные функции под названием authTest , privateApis и publicApis и определим в каждой из них тесты, использованные в предыдущем разделе.

Окончательный файл api-slos.js должен выглядеть следующим образом:

import { Httpx, Get } from 'https://jslib.k6.io/httpx/0.0.2/index.js';
import { randomIntBetween, randomItem } from "https://jslib.k6.io/k6-utils/1.1.0/index.js";


export const options = {
scenarios: {
authentication_test: {
executor: 'per-vu-iterations',
vus: 1,
iterations: 1,
tags: { test_type: 'authentication' },
env: {
USERNAME: `user${randomIntBetween(1, 100000)}@example.com`,
PASSWORD: 'superCroc2019'
},
exec: 'authTest',
},
public_apis_test: {
executor: 'per-vu-iterations',
vus: 1,
iterations: 1,
tags: { test_type: 'public_apis' },
exec: 'publicApis',
},
private_apis_test: {
executor: 'per-vu-iterations',
vus: 1,
iterations: 1,
tags: { test_type: 'private_apis' },
exec: 'privateApis',
},
},
thresholds: {
'http_req_duration{test_type:public_apis}': ['avg<300'],
'http_req_duration{test_type:private_apis}': ['avg<500'],
'http_req_duration{test_type:authentication}': ['avg<1300'],
},
};

let session = new Httpx({baseURL: 'https://test-api.k6.io'});
let authSession = new Httpx({baseURL: 'https://test-api.k6.io'});
let publicSession = new Httpx({baseURL: 'https://test-api.k6.io'});


export function authTest(){
describe(`01. Create a test user ${__ENV.USERNAME}`, (t) => {

let resp = authSession.post(`/user/register/`, {
first_name: 'Crocodile',
last_name: 'Owner',
username: __ENV.USERNAME,
password: __ENV.PASSWORD,
});

t.expect(resp.status).as("status").toEqual(201)
.and(resp).toHaveValidJson();
})

describe(`02. Authenticate the new user ${__ENV.USERNAME}`, (t) => {

let resp = authSession.post(`/auth/token/login/`, {
username: __ENV.USERNAME,
password: __ENV.PASSWORD
});

t.expect(resp.status).as("Auth status").toBeBetween(200, 204)
.and(resp).toHaveValidJson()
.and(resp.json('access')).as("auth token").toBeTruthy();

})

}

export function publicApis() {


describe('01. Fetch public crocs', (t) => {
let responses = publicSession.batch([
new Get('/public/crocodiles/1/'),
new Get('/public/crocodiles/2/'),
new Get('/public/crocodiles/3/'),
new Get('/public/crocodiles/4/'),
], {
tags: {name: 'PublicCrocs'},
});

responses.forEach(response => {
t.expect(response.status).as("response status").toEqual(200)
.and(response).toHaveValidJson()
.and(response.json('age')).as('croc age').toBeGreaterThan(7);
});
})


describe('02. List public crocs', (t) => {

let response = publicSession.get('/public/crocodiles/');

t.expect(response.status).as("response status").toEqual(200)
.and(response).toHaveValidJson();
})

}

export function privateApis() {
const PASSWORD = 'superCroc2019';
const USERNAME = '[email protected]';


let resp = session.post(`/auth/token/login/`, {
username: USERNAME,
password: PASSWORD
});

let authToken = resp.json('access');
// установка заголовка авторизации в сессии для последующих запросов.
session.addHeader('Authorization', `Bearer ${authToken}`);


describe('01. Create a new crocodile', (t) => {
let payload = {
name: `Croc Name`,
sex: randomItem(["M", "F"]),
date_of_birth: '2019-01-01',
};

let resp = session.post(`/my/crocodiles/`, payload);

t.expect(resp.status).as("Croc creation status").toEqual(201)
.and(resp).toHaveValidJson();

session.newCrocId=resp.json('id');
})

describe('02. Fetch private crocs', (t) => {

let response = session.get('/my/crocodiles/');

t.expect(response.status).as("response status").toEqual(200)
.and(response).toHaveValidJson()
.and(response.json().length).as("number of crocs").toEqual(1);
})

describe('03. Update the croc', (t) => {
let payload = {
name: `New name`,
};

let resp = session.patch(`/my/crocodiles/${session.newCrocId}/`, payload);

t.expect(resp.status).as("Croc patch status").toEqual(200)
.and(resp).toHaveValidJson()
.and(resp.json('name')).as('name').toEqual('New name');

let resp1 = session.get(`/my/crocodiles/${session.newCrocId}/`);

})

describe('04. Fetch created private croc', (t) => {

let response = session.get(`/my/crocodiles/${session.newCrocId}/`);

t.expect(response.status).as("response status").toEqual(200)
.and(response).toHaveValidJson();
})


describe('05. Delete the croc', (t) => {

let resp = session.delete(`/my/crocodiles/${session.newCrocId}/`);

t.expect(resp.status).as("Croc delete status").toEqual(204);
});

}

Теперь можно провести тестирование SLO.

tets-slos.js

Каждый из тегов test_type выводится на основе http_req_duration, как и кумулятивные метрики. Если какой-либо из этих показателей не соответствует заданному порогу, рядом с тестом появляется красный знак “X”, и тест завершается неудачей.

Нечто подобное можно включить в набор тестов или этап тестирования конвейера для предупреждения о любых изменениях в производительности API.

Читайте также:

Читайте нас в TelegramVK и Дзен


Перевод статьи Sarah: What is My SLO and How do I Test It?

Предыдущая статья6 современных возможностей JavaScript, о которых не знает большинство разработчиков
Следующая статьяРаскрываем возможности контейнеризации. Зачем дата-сайентистам Docker и Kubernetes?