PaaS (Platform as a Service) — внутренняя платформа для запуска и разработки приложений. Если коротко, то наш PaaS позволяет легко и, можно сказать, при нулевом знании внутренней кухни создать свой сервис и начать пилить продуктовые компоненты. Более длинное объяснение — в этом видео. Под катом небольшой рассказ о том, с какими проблемами пришлось столкнуться при первом приближении к тестированию продукта, как происходил сам процесс тестирования платформенных решений на примерах и какую пользу это принесло.

Меня зовут Лариса Седнина, я работаю QA-инженером в Авито в юните QA Center of Excellence. Наш юнит — это центр экспертизы по обеспечению качества, основная задача которого в распространении лучших практик тестирования, помощи в настройке процесса тестирования и разработке инструментов для тестирования.

И однажды пришла задача — прийти в платформенную команду и выявить потребность в необходимости тестирования решений, которые выпускает команда (сервисы/библиотеки/процесс). Расскажу три кейса по тестированию наших PaaS сервисов, это были очень интересные задачи и порой вызов, чтобы придумать как их протестировать.

Что из себя представляет PaaS в цифрах?

  • Более 1000 пользователей сервиса (внутренние разработчики компании);

  • Более 1400 микросервисов;

  • Сотни деплоев в день;

  • 70 тысяч RPS к бэкенду.

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

Но сразу встает вопрос — как тестировать всю эту кухню, которая к тому же живет в нескольких дата центрах, и зачем? Давайте попробуем разобраться.

Для начала стоит сказать, что наш PaaS для пользователей — это командная утилита Avito CLI и дашборд PaaS, который помимо многих возможностей из cli также дает визуализацию по всем сервисам:

Avito CLI

Несколько примеров её работы:

  • avito service canary deploy — когда мы деплоим сервис на часть трафика в заданное окружение; 

  • avito start — запуск локального окружения в миникубе для разработки сервиса;

  • avito lint — проверка на линтеры, в данном случае для Golang.

PaaS дашборд:

А теперь рассмотрим что можно протестировать в PaaS на примерах.

Navigator

Посовещавшись с техлидом команды, первым подопытным решили взять opensource разработку Navigator. Это мультикластерная реализация паттерна Service mesh, в основе которого лежит envoy, с поддержкой канареечных релизов. Например, есть несколько кластеров Kubernetes, в которых крутится какое-то количество микросервисов. Этим сервисам не нужно знать в каком кластере и по какому адресу находится сервис, к которому он обращается. Навигатор позволяет им общаться как будто это один большой кластер.

В первую очередь было решено сделать тестовую модель библиотеки и посмотреть покрытие тестами, чтобы в принципе понять насколько покрыт тестами этот критически важный для нас сервис. Так как у нас есть инструмент для выгрузки тестов из кода — unit, интеграционные и e2e тесты были размечены в коде и выгружены в тестовую модель автоматически. Это помогло проанализировать покрытие тестами функционала и из итогов анализа поставить задачи на недостающие автотесты:

Дальше мы положили задачи в бэклог и подумали, а достаточно ли этого? Оказалось — нет, потому что спустя какое-то время на проде случился баг в алгоритме балансировки. В навигаторе есть логика понижения весов для балансировки трафика, и при вычислении веса в некоторых случаях переполнялся int, на envoy приходил битый конфиг, из-за этого мы не могли нормально роутить, и клиенту отдавалась ошибка.

Ну что ж, баг исправлен, шишка набита, выводы сделаны. Теперь попробуем в другом примере пойти со стороны задачи: внедрить какой-либо компонент в продакшен. Этой задачей стала страница кронов/воркеров.

Страница кронов

Предыстория у этой задачи интересная, я момент возникновения не застала, но старожилы рассказывали, что когда Авито разъезжалось в несколько ДЦ, то кроны сервиса могли независимо друг от друга запускаться в разных ДЦ, ничего не знать друг о друге, блокировать таблицы в БД, впадать в бесконечную рекурсию ожидания, когда один из двух кронов первым завершится, создавая этим трудности и пользователям, и разработчикам. Проблему эту пофиксили, но наблюдать за тем, какие задачи, сколько их, с каким статусом выполнялись в каждом из ДЦ всё равно надо.

По постановке задачи надо было вывести на дашборд список кронов/воркеров сервиса (есть сервисы на PHP, Go и Python), отобразив название, статус и историю запусков. Схематично и очень упрощенно это выглядело так:

С фронта дашборда по условной ручке /api/crons идет обращение в сервис crons-gateway, который в свою очередь обращается за данными из сервиса crons-list и в crons-events. В сервис crons-events при этом асинхронно поступают события о статусах запуска (success/progress/error). Что же здесь можно протестировать?

На самом деле можно протестировать всё — от компонентов (crons-list, crons-events) до более высокоуровневого API, и, в том числе, конечное отображение данных на дашборде. Расскажу про несколько интересных багов, с которыми встретились и которые можно назвать показательными.

Пример 1: Не было сортировки истории запусков

Когда была готова ручка, при запросе данных с нее список с историей последних запусков приходил без сортировки. При опросе разработчиков оказалось, что должна быть обратная сортировка по дате, от самого раннего к самому старому. А в нашем случае брались два любых события и выводились в ответе.

Пример 2: Дублирование имени кронов

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

Пример 3: Клик на любое имя крона разворачивал у всех кронов деревья с историей

Представим, что ситуацию из заголовка видно на скриншоте. Сначала это немного обескуражило, потому что ты кликаешь на второй элемент в списке, а разворачиваются все. А если у первого крона запусков не один, а тысячи? Пользователю будет неприятно увидеть это в production.

Но пришел на помощь фронтендер и помог с этим багом расправиться.

Результаты

Мы все это протестировали, посмотрели на задачу и сделали очередные выводы: меньше багов доехало до прода — счастливее пользователь, довольнее техлид.

Канареечный деплой

И тут мы подобрались к самому любимому нашему виду багов — это баги на проде. Но обо всём по порядку.

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

Справка: Canary deployment — это выкатка релиза на заданный процент продуктового трафика, метод снижения риска внедрения новой версии программного обеспечения в  «производственную среду».

Один из примеров, когда канареечный деплой полезен, если при выкатке новой версии сервиса в продакшн необходимо аккуратно инвалидировать весь кэш, а прогрев кэша — операция дорогая, может занимать много времени, а сервис super-critical. Или если вы сомневаетесь, что «бомба не рванет» на проде и хотите «пощупать» релиз и, в случае чего, быстро откатить назад, чтобы это не задело 100% пользователей.

И тут на продакшене происходит инцидент: сервис успешно задеплоился под каким-то процентом канарейки, но при особом стечении обстоятельств произошла паника в горутине, и поды канареечного релиза начали друг за другом падать со статусом CrashLoopBackOff. Это такой случай, когда pod-ы бесконечно пытаются подняться, крэшатся, и опять запускаются, и снова крэшатся, и снова пытаются подняться. И так без конца, никогда не пройдя readiness probe-ы. Из-за чего оставшиеся «здоровые» pod-ы сервиса со старой версией релиза перестали справляться с нагрузкой, так как на них полился весь имеющийся трафик, и сервис стал отдавать ошибки пользователям. Дополнительно к этому, вишенкой на торте, билд с отменой или изменением процента канарейки в пайплайне деплоя успешен, но readiness probe-ы у pod-ов нового релиза всё так же не проходят.

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

Подготовка

Чтобы найти ошибку, надо думать как ошибка, быть как ошибка: быстрым и внезапным. Для этого я надела шапку разработчика, создала свой собственный сервис на Golang с помощью команды нашей утилиты Avito CLI — avito service create — которая под капотом делает всю рутинную работу за меня — создает репозиторий в git, проект в sentry, директорию на рабочей машине для локальной разработки, шаблон проекта и т.д. На выходе работы команды у нас получается настроенный пустой проект сервиса, садись да код пиши.

Попытка 0

Для начала, изучив инцидент по шагам, я решила использовать внутреннюю библиотеку api-composition, которая инкапсулирует в себе набор инструментов для работы с внешними потребителями. Выпор пал на него, так как в инциденте был замечен код, который использовал эту библиотеку в момент паники.

Задача стояла реализовать простой handler, за который можно было подёргать, чтобы принудительно вызвать панику в pod-e. Код приложения выглядел примерно так :

Приложение:
func main() {

	_, err = os.Stat("/tmpfs/aragorn-fatality")

	if os.IsNotExist(err) {
		log.Info(ctx, "aragorn-fatality does not exists")

	}

	if err == nil {
		os.Exit(1)

	}
}

И код handler-а:

Простой handler:
func (h *Handler) Handle(ctx context.Context, request fatality.Request, response fatality.Response) error {

	response.OK(&fatality.OKRespData{
		Result: []components.ServiceResourceMetrics{},
	})

	if queryVar, err := request.Parameters.Query(); err == nil {

		if queryVar.IsFatal {

			return os.WriteFile("/tmpfs/aragorn-fatality", []byte(""), os.ModePerm)

		}
	}
	return nil
}

Деплоим сервис, дожидаемся, когда пройдут readiness probe-ы, и дёргаем за ручку, чтобы вызвать нашу отложенную панику и падение pod-ов.

Получилось ли? Конечно же, нет, потому что панику успешно отлавливала платформа и аккуратно поднимала pod обратно. Тогда попробуем другой вариант.

Попытка 1

Подготовка — использовать также api-composition, но сделать более топорный вызов паники из handler-а.

Вызов паники:
func (h *Handler) Handle(ctx context.Context, request fatality.Request, response fatality.Response) error {

	response.OK(&fatality.OKRespData{
		Result: []components.ServiceResourceMetrics{},
	})

	if queryVar, err := request.Parameters.Query(); err == nil {

		if queryVar.IsFatal {

			names := []string{
				"lobster",
				"sea urchin",
				"sea cucumber",
			}

			fmt.Println(names[len(names)])
		}
	}
	return nil
}

Что здесь происходит: если пришел определенный параметр из Query, то мы просто говорим, что хотим выбрать третий элемент из нашего массива. Но здесь нет третьего элемента, потому что, как и во многих языках программирования, нумерация массивов в Go начинается с нуля (0, 1, 2), что, соответственно, вызывает панику.

Сказано — сделано, задеплоили код, дёрнули ручку и… снова не получилось! Успеха не было, потому что какая-то платформенная библиотека эту панику по-прежнему корректно обрабатывала и наши pod-ы успешно перезапускались.

Попытка 2

Снова изучив логи инцидента, я обратила внимание, что там использовалась устаревшая библиотека app-boilerplate для запуска клиента приложения. Она всё ещё на поддержке, но не развивается, поэтому не стоит ее списывать со счета.

Приложение:
func main() {

	app := appBoilerplate.New(appOpts...)

	app.RegisterMiddleware(observability.ServerMW)

	handler := aws.NewHandler()

	app.RegisterHTTPHandler("/v1/fatality", handler.V1())

	err = app.Run()

	if err != nil {
		app.Logger.Error("Error")
	}
}

Теперь очередь handler-а:

Handler:
func (h *handler) V1() http.Handler {

	return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
		h.handleFatality(resp, req)
	})

}

func (h *handler) handleFatality(resp http.ResponseWriter, req *http.Request) {

	isFatal := req.URL.Query().Get("isFatal")

	if isFatal != "" {

		names := []string{

			"lobster",
			"sea urchin",
			"sea cucumber",
		}

		fmt.Println(names[len(names)])
	}
}

Но вариант оказался снова не рабочим, а значит, надо сделать перерыв на кофе и подумать ещё.

Попытка 3

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

В итоге код приложения менять не нужно, а код handler-а стал таким:

Handler:
func (h *handler) handleFatality(resp http.ResponseWriter, req *http.Request) {

	isFatal := req.URL.Query().Get("isFatal")

	if isFatal != "" {

		go func() {

			err := os.WriteFile("/tmpfs/aragorn-fatality", []byte(""), os.ModePerm)

			if err != nil {

				fmt.Print("Error!")

			}

			names := []string{
				"lobster",
				"sea urchin",
				"sea cucumber",
			}

			fmt.Println(names[len(names)])
		}()
	}
}

И тут получилось! Паника никак не обрабатывалась, pod-ы стали падать с ошибкой CrashLoopBackOff.

А что дальше?

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

По итогу работы у нас есть:

  • Стабильное воспроизведение проблемы;

  • Баг в CI с успешным деплоем канарейки с не прошедшими readiness probe-ами pod-ов;

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

Взяв всё что у нас имеется, я пришла к разработчикам. Первое, что мне сказали: «Не надо допускать паник в горутинах». Но так как мир не идеален, то могут случаться ситуации, когда паника возникает в абсолютно неконтролируемых условиях. Например, у нас был кейс, который проявлялся только при высокой нагрузке и только если не отвечал какой-то зависимый сервис.

Один из вариантов решения данной проблемы — это навесить мониторинг на количество перезапусков pod-ов сервисов, выводить на графики сколько раз сервис рестартовал за последний час, какое количество подов сейчас живое, сколько они занимают ресурсов.

Также дополнительной мерой, по запросу от продуктовых команд, можно сделать алерты для критичных сервисов, что поды слишком часто рестартуют за определенный промежуток времени. А баги в CI исправили в тот же день как мы их нашли.

Итог

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

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

И, даже несмотря на то, что задачи порой такие абстрактные, для них все равно нужен QA инженер.

28-29 апреля в Москве впервые пройдет TestDriven Conf 2022 — профессиональная конференция для senior тестировщиков и QA-инженеров. Она будет посвящена всем вопросам автоматизации в тестировании и рядом.

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