Всем привет! На связи Николай Мезинов из команды Тинькофф. Предлагаю разобрать интересную задачу — тестирование приложений с помощью микросервиса. Прочитав предыдущее предложение, можно подумать: «Microservice for testing? Are you kidding me?» И я с ухмылкой отвечу: «No, I am not».

Немного контекста

Наша команда техническая, мы делаем внутреннюю платформу разработки Spirit — техническую платформу для ИТ-специалистов Тинькофф. Она состоит из множества интегрированных подсистем, которые позволяют разработчикам писать продуктовый код. Это существенно сокращает time to market и обеспечивает оптимальную разработку и поставку продукта для всей группы компаний. Об истории создания платформы подробно рассказывал мой коллега @dmit8815:

Мы придерживаемся принципа you build it, you test it, you run it. Мы верим, что этот принцип позволяет снизить размытие ответственности между разными инженерами в команде, сфокусировав ответственность за какую-то фичу в руках одного инженера. Как следствие, у нас нет четкого разделения на разработчиков, тестировщиков, DevOps. 

Неотъемлемая часть нашей работы — мониторинг запущенных сервисов. Давайте рассмотрим связку тестирования и мониторинга в виде микросервисов для тестирования, которые мы называем проберами.

Какую задачу решаем

Наш продукт критический для всей группы компаний Тинькофф. Мы заявляем высокий SLA и должны его поддерживать. Для поддержки уровня SLA нам важно в реальном времени мониторить поведение наших сервисов. 

Аутентификация в платформе — это важнейшая ее часть, при этом нам необходимо получать различные кастомные метрики, на основании которых строятся дашборды в Grafana, например:

  • сколько загружалась страница;

  • была ли аутентификация успешной;

  • сколько времени потребовалось на аутентификацию;

  • сколько длился каждый шаг (аутентификации может происходить с помощью разных Identity-провайдеров).

Резюмируя: нам нужно периодически прогонять тестовый сценарий аутентификации и при этом сохранять метрики для дальнейшей визуализации.

Как решаем задачу

Определили цели, можно приступать к рассмотрению вариантов решения. При аутентификации используется корпоративный OAuth-провайдер, который мы не контролируем, то есть он представляет для нас внешнюю систему. Поэтому итоговым решением должно быть тестирование, где тестируемая система — это черный ящик (black box), о деталях реализации которой ничего не известно. Так мы наиболее точно воспроизведем поведение реального пользователя.

Первое решение, которое приходит в голову, — классическое e2e-тестирование, в процессе которого запускается обычный, не headless-браузер. Например, e2e-тестирование с использованием Cypress или Selenium позволит проверить, была ли аутентификация успешной. С другими метриками уже намного сложнее. 

Классическое e2e-тестирование заточено на использование в пайплайне и не подходит для мониторинга метрик в реальном времени. Поэтому это решение исключаем: оно решает лишь малую долю задачи.

После того как отказались от e2e, мы решили, что подобную задачу может решить микросервис, который мы называем коротким словом «пробер». Для полноты контекста обозначим, что это такое для нашего конкретного случая:

Пробер — серверное приложение, которое прогоняет тестовый сценарий с некоторым интервалом времени

Пробер аутентификации — серверное приложение, написанное на Node.js. Оно с некоторым интервалом времени запускает headless-браузер, который открывает веб-страницу и прогоняет тестовый сценарий, попутно снимая метрики

Преимущества выбранного подхода:

  • позволяет мониторить любые метрики в реальном времени, при этом тестируемая система также представляет собой black box;

  • можно снимать и мониторить любые метрики в режиме реального времени.

Идея с пробером покрывает все наши требования. На нем и остановимся.

Довольно рассуждений — покажите код

Мы начали с разработки тестового сценария. Условно говоря, пользователь зашел на страницу аутентификации, ввел логин, пароль и успешно зашел на сайт. В качестве фреймворка для написания тестового сценария выбрали playwright. 

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

// check-auth.ts
/**
 * Исполняет сценарий аутентификации через headless-браузер
 */
import { parentPort, workerData } from 'worker_threads';
import { chromium } from 'playwright';
 
const authenticate = async ({
  webAppUrl,
  userName,
  userPassword,
  timeout,
}: WorkerParams): Promise<Durations> => {
  const browser = await chromium.launch({
    headless: true,
    args: [
      '--no-sandbox',
      '--disable-dev-shm-usage',
      '--disable-setuid-sandbox',
      '--window-size=1920,1080',
    ],
  });
  try {
    const page = await browser.newPage(); // Открываем браузер
    await page.setDefaultTimeout(timeout);
    await page.setDefaultNavigationTimeout(timeout);
    // Не приводим исходный код класса AuthTimeDutrations, чтобы не отвлекать от сути. Инстанс класса умеет засекать время прохождения каждого этапа аутентификации
    const authSteps = new AuthTimeDurations();
    const stopTotalAuthDuration = authSteps.startOverallAuthDuration();
    await page.goto(webAppUrl); // Переходим на страницу аутентификации
    const loginButton = await page.locator('text=Войти через IdentityProvider');
    authSteps.markStep('Devplatform page loaded', DevplatformAuthPageLoadedLabel); // Помечаем пройденный этап, засекаем время
    await loginButton.click({ force: true });
    const identityProviderTotalDurationStop = authSteps.startIdentityProviderTotalDuration();
    const userNameInput = await page.locator('#userNameInput');
    authSteps.markStep('IndentityProvider page opened and loaded', IdentityProviderPageLoadedLabel); // Помечаем пройденный этап, засекаем время
    await userNameInput.type(userName); // Вводим логин
    await page.type('#passwordInput', userPassword); // Вводим пароль
    // Кликаем по кнопке сабмита
    await Promise.all([
      page.waitForNavigation({ waitUntil: 'commit' }),
      page.click('#submitButton'),
    ]);
    identityProviderTotalDurationStop();
    authSteps.markStep('Success identityProvider processed and redirected to devplatform', IdentityProviderProceedLabel);
 
    await page.waitForSelector('text=Выбрать проект');
    stopTotalAuthDuration();
    await browser.close();
    return authSteps.getStageDurations();
  } catch (e) {
    await browser.close();
    throw e;
  }
};
 
// Отсылаем из воркера сообщение о пройденной аутентификации
authenticate(workerData).then((data) => {
  parentPort?.postMessage(data);
});

Мы договорились в самом начале, что нам необходимо не только знать, была ли аутентификация успешной, но и снимать кастомные prometheus-метрики. Prometheus-метрики будем обновлять в основном треде. Запуск тестового сценария будем планировать с помощью самописного шедулера. 

Логичный вопрос: «Зачем писать собственный шедулер, если есть множество готовых решений?» Причина в том, что существующие шедулеры в основном поддерживают cron-синтаксис, но не учитывают контекст предыдущего запуска — следующий запуск возможен только после окончания предыдущего. Приведем основные фрагменты исходного кода.

Начнем с воркера, который запускает тестовый сценарий, описанный выше:

import { Worker } from 'worker_threads';
 // Запускает воркер и ждет от него результатов
function runProbe(params: WorkerParams): Promise<any> {
  return new Promise((resolve, reject) => {
    // Запуск воркера, рассмотренного на предыдущем этапе
    const worker = new Worker('./build/check-auth.js', {
      workerData: params,
      // Опция нужна, чтобы при ошибках в асинхронных функциях мы получали корректное сообщение об ошибке и событие error
      execArgv: [...process.execArgv, '--unhandled-rejections=strict'],
    });
    worker.on('message', resolve);
    worker.on('error', reject);
    worker.on('exit', (code: number) => {
      if (code !== 0) reject(new Error(`Worker stopped with exit code ${code}`));
    });
  });
}

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

// Шедулер, который запускает проверку аутентификации с некоторым интервалом времени и учитывает предыдущий запуск
const schedule = ({
  job,
  timeoutInSeconds,
  onError,
}: {
  job: () => Promise<void>;
  timeoutInSeconds: number;
  onError: (e: Error) => void;
}) => {
  let timerId: NodeJS.Timeout;
  let currentPromise = Promise.resolve();
  let stopped = false;
  function tick() {
    currentPromise = Promise.resolve()
      .then(job)
      .catch(onError)
      .finally(() => {
        // Возможно, что остановили, когда сценарий уже прошел, но еще не стартовал, — поэтому проверяем переменную и НЕ запускаем по расписанию
        if (stopped) return;
        timerId = setTimeout(tick, timeoutInSeconds * 1000);
      });
  }
  return {
    start: () => {
      tick();
    },
    stop: () => {
      stopped = true;
      clearTimeout(timerId);
      return currentPromise;
    },
  };
};

И в конце нам потребуется основная функция, которая запускает тестовый сценарий с помощью кастомного шедулера, ждет от него результатов и обновляет prometheus-метрики:

// Основная функция, которая запускает тестовый сценарий с помощью шедулера, ждет результатов и обновляет prometheus-метрики
const authCheckerScheduler = ({
  userPassword,
  userName,
  webAppUrl,
  probeIntervalInSeconds,
  timeout,
  proxy,
}: Params) => {
  const scheduler = schedule({
    job: () => {
      // Запустили воркер и ждем от него результатов — сколько времени выполнялся каждый шаг при аутентификации
      return runProbe({
        userPassword,
        userName,
        webAppUrl,
        timeout,
        proxy,
      }).then((stageDurations: Durations) => {
        // После получения информации о том, сколько выполнялся каждый этап аутентификации, обновляем prometheus-метрики в нужном формате
        stageDurations.metrics.forEach((m) => {
          const duration = m.time / 1000;
          authStageDurationMetric.labels(m.prometheusLabel).observe(duration);
          labelToMetric(m.prometheusLabel).set(duration);
        });
 
        const authWithoutIdentityProviderInSeconds =
          (stageDurations.authTotalDuration - stageDurations.identityProviderDuration) / 1000;
        const authTotalDurationInSeconds = stageDurations.authTotalDuration / 1000;
        authSuccessTimingWithoutIdentityProviderMetricOverall.observe(authWithoutIdentityProviderInSeconds);
        authSuccessTimingWithoutIdentityProviderMetricCurrent.set(authWithoutIdentityProviderInSeconds);
        authSuccessTimingMetricOverall.observe(authTotalDurationInSeconds);
        authSuccessTimingMetricCurrent.set(authTotalDurationInSeconds);
        authSuccessMetricTotalNumber.inc();
        lastAuthSuccessStateMetric.set(lastAuthStateMetricValue.success);
        return undefined;
      });
    },
    timeoutInSeconds: probeIntervalInSeconds,
    onError: (err) => { // в случае ошибки помечаем в prometheus, что аутентификация была неуспешной
      lastAuthSuccessStateMetric.set(lastAuthStateMetricValue.fail);
      logger.error(err);
    },
  });
 
  return scheduler;
};

Кратко повторим, что мы сделали:

  1. Написали тестовый сценарий аутентификации на playwright.

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

  3. Сделали сценарий аутентификации в виде воркера, чтобы запускать отдельно от основного треда.

  4. В основном треде с помощью шедулера вызывали воркер, ждали от него результатов по тестовому сценарию.

  5. После получения результатов обновляли prometheus-метрики.

Посмотрим, что же отдает нам теперь эндпоинт /metrics:

...
# HELP sla_devplatform_authentication_successful total success auth probe number
# TYPE sla_devplatform_authentication_successful counter
sla_devplatform_authentication_successful 4
 
# HELP sla_devplatform_authentication_seconds overall success auth probe duration
# TYPE sla_devplatform_authentication_seconds histogram
sla_devplatform_authentication_seconds_bucket{le="0.005"} 0
sla_devplatform_authentication_seconds_bucket{le="0.01"} 0
sla_devplatform_authentication_seconds_bucket{le="0.025"} 0
sla_devplatform_authentication_seconds_bucket{le="0.05"} 0
sla_devplatform_authentication_seconds_bucket{le="0.1"} 0
sla_devplatform_authentication_seconds_bucket{le="0.25"} 0
sla_devplatform_authentication_seconds_bucket{le="0.5"} 0
sla_devplatform_authentication_seconds_bucket{le="1"} 0
sla_devplatform_authentication_seconds_bucket{le="2.5"} 4
sla_devplatform_authentication_seconds_bucket{le="5"} 4
sla_devplatform_authentication_seconds_bucket{le="10"} 4
sla_devplatform_authentication_seconds_bucket{le="+Inf"} 4
sla_devplatform_authentication_seconds_sum 8.681718063999899
sla_devplatform_authentication_seconds_count 4
 
# HELP sla_devplatform_authentication_seconds_current last success auth probe duration
# TYPE sla_devplatform_authentication_seconds_current gauge
sla_devplatform_authentication_seconds_current 2.1950614569997415
 
# HELP sla_devplatform_authentication_last_successful last success auth probe state, 1 - successful, 0 - failed
# TYPE sla_devplatform_authentication_last_successful gauge
sla_devplatform_authentication_last_successful 1
 
# HELP sla_devplatform_authentication_without_identity_provider_seconds overall success auth probe duration without identity_provider
# TYPE sla_devplatform_authentication_without_identity_provider_seconds histogram
sla_devplatform_authentication_without_identity_provider_seconds_bucket{le="0.005"} 0
sla_devplatform_authentication_without_identity_provider_seconds_bucket{le="0.01"} 0
sla_devplatform_authentication_without_identity_provider_seconds_bucket{le="0.025"} 0
sla_devplatform_authentication_without_identity_provider_seconds_bucket{le="0.05"} 0
sla_devplatform_authentication_without_identity_provider_seconds_bucket{le="0.1"} 0
sla_devplatform_authentication_without_identity_provider_seconds_bucket{le="0.25"} 0
sla_devplatform_authentication_without_identity_provider_seconds_bucket{le="0.5"} 0
sla_devplatform_authentication_without_identity_provider_seconds_bucket{le="1"} 0
sla_devplatform_authentication_without_identity_provider_seconds_bucket{le="2.5"} 4
sla_devplatform_authentication_without_identity_provider_seconds_bucket{le="5"} 4
sla_devplatform_authentication_without_identity_provider_seconds_bucket{le="10"} 4
sla_devplatform_authentication_without_identity_provider_seconds_bucket{le="+Inf"} 4
sla_devplatform_authentication_without_identity_provider_seconds_sum 6.257014720998704
sla_devplatform_authentication_without_identity_provider_seconds_count 4
 
# HELP sla_devplatform_authentication_without_identity_provider_seconds_current last success auth without identity_provider probe duration
# TYPE sla_devplatform_authentication_without_identity_provider_seconds_current gauge
sla_devplatform_authentication_without_identity_provider_seconds_current 1.6220274379989132
…

Все наши кастомные метрики экспортируются в /metrics, выходим на финишную прямую. Осталось лишь визуализировать результаты.

Наглядность, больше наглядности

Визуализировать время прохождения аутентификации будем в Grafana. Для построения графиков воспользуемся языком PromQL. Наиболее интересная метрика — время аутентификации. Нас интересует как полное время аутентификации, так и время без корпоративного Identity-провайдера, который является для нас внешней системой. Запросы имеют вид:

sla_devplatform_authentication_seconds_current{env="$env", group=~"devplatform|spirit-probers"}
sla_devplatform_authentication_without_identity_provider_seconds_current{env="$env", group=~"devplatform|spirit-probers"}

Введем эти запросы в Grafana и увидим четыре графика, так как у нас работают две реплики в k8s.

Полное время аутентификации меньше 3 секунд — приемлемо
Полное время аутентификации меньше 3 секунд — приемлемо

Заключение

Мы рассмотрели тестирование с помощью микросервиса, и оказалось, что Microservice for testing — это совсем не страшно. На выходе получаем много преимуществ, которых не даст классическое e2e-тестирование.

Делитесь опытом и задавайте вопросы в комментариях!

Комментарии (1)


  1. leebart
    31.08.2023 17:51

    Николай, спасибо за труд, было очень интересно. А какие минусы подхода? Зачем делать отдельный воркер если тред ждёт auth все равно? Как часто запускаются такие тесты и что вы делаете если время выполнения 10с, как пример.