Как написать автотесты деплоя и сэкономить нервы DevOps-инженеров
Привет! Меня зовут Артём Комаренко, я работаю на позиции QA Lead в команде PaaS в СберМаркете. Хочу поделиться историей, как мы придумывали способ быстро убедиться, что очередные изменения в скриптах деплоя не разломают процесс выкатки во всей компании.
Статья будет полезна QA-специалистам и DevOps-инженерам, которые хотят автоматизировать тесты инфраструктуры. Вы узнаете как и с помощью чего можно проверить такую сущность как деплой.
В статье я буду рассказывать о примерном ходе работы, опуская специфику конкретно нашей компании. Фрагменты кода также будут отражать только идею, без промежуточных переменных, с упрощенными наименованиями, без точного количество аргументов и т.п.
<a name="why">Зачем всё это?</a>
В Сбермаркете мы разрабатываем PaaS, и одной из важных частей нашей платформы является CI/CD pipeline. То есть наши пользователи-разработчики получают «из коробки» готовый pipeline, в котором уже есть различные задачи по запуску тестов, линтеров и прочих ништяков, а также задачи по выкатке приложения на тестовые стенды и созданию релиза для выкатки на прод.
И вот однажды ко мне пришел лид команды DevOps с запросом «Хочу автотесты!»
<a name="plan">План действий</a>
Мы определили, что для PaaS важно убедиться, что разработчик, который первый раз воспользовался нашими инструментами, сможет без проблем выкатить свежесозданный сервис. А для DevOps инженеров было важно знать, что после внесения изменений в скрипты всё ещё можно спокойно деплоить новую версию приложения поверх существующей.
Таким образом определился базовый набор сценариев:
деплой нового сервиса;
деплой существующего сервиса.
Сценарии отличаются началом, где мы получаем сервис, и списком проверок. И в общем виде выглядят так:
Создать новый (или клонировать существующий) сервис локально.
Внести и запушить изменения в удаленный репозиторий.
Найти пайплайн МРа.
Выполнить джобу деплоя.
Проверить, что джоба завершилась успешно.
Проверить, что в неймспейсе появились нужные поды/контейнеры.
Остальные проверки.
Три кита, на которых держится автотест:
Для работы с локальным репозиторием нам нужен git, соответственно была выбрана библиотека GitPython.
Работать с gitlab было решено по API, для этого как раз подходит библиотека python-gitlab.
С k8s так же решено работать по API, и здесь так же есть библиотека kubernetes.
С вводными определились, можно приступать к написанию теста.
<a name="script">Скриптуем в лоб</a>
Для начала мне нужно было выстроить логику теста, понять как взаимодействовать с сущностями. Поэтому я решил написать тест как обычный скрипт. Этот этап не требуется повторять, можно сразу писать по правилам тестового фреймворка.
Основные действия я вынес в методы хелперов:
хелпер для взаимодействия с локальным и удаленным репозиторием;
хелпер для работы с Kubernetes.
Для красивых проверок я использовал библиотеку assertpy.
if name == '__main__':
repo = RepositoryHelper(remote_host)
kubectl = Kubernetes(service_name)
repo.clone(service_name)
os.chdir(service_path)
repo.make_local_changes()
repo.push_local_branch(branch_name)
repo.create_mr(branch_name)
repo.find_pipeline(mr)
repo.run_job('deploy', pipeline)
assert_that(job).has_status('success')
kubernetes.find_pod('app')
assert_that(app_pod).has_status('running')
Скрипт сработал. И тут же я столкнулся с первой трудностью – следующий прогон падал, потому что состояние тестового репозитория изменилось. Нужно за собой прибраться: дописываем в конце нашего скрипта инструкции по откату изменений в репозитории, закрытию МРа, удалению ветки, чистке неймспейса.
if name == '__main__':
...
repo.checkout_local_branch(recovery)
repo.push_local_branch(recovery, force=True)
repo.close_mr(mr)
repo.remove_remote_branch(branch_name)
kubectl.remove_namespace(namespace_name)
Теперь можно запускать прогоны один за другим, все работает. Но как только что-то пошло не так, тест упал — чистка не произошла. Пришло время использовать привычный тестовый фреймворк – pytest.
<a name="test">Тест здорового человека</a>
Основным вызовом при переходе к pytest стало понять что и как перенести в фикстуры, чтобы в тесте осталась только логика самого теста. В фикстуры переехал функционал по подготовке локальной среды к тесту, инициализации хелперов, клонированию репозитория, и самое важное — чистка по завершению.
Как итог, наш скрипт преобразился в стандартный тест:
class TestDeploy:
def test_deploy_new_service(repo, kubernetes, branch_name):
repo.local.make_changes()
repo.local.push(branch_name)
repo.remote.create_mr(branch_name)
repo.remote.find_pipeline(mr)
repo.remote.run_job('deploy', pipeline)
assert_that(job).has_status('success')
kubernetes.find_pod('app')
assert_that(app_pod).has_status('running')
За счет очистки в финализаторах фикстур, тест стал проходить даже если в середине произойдет сбой. А также теперь можно создавать и запускать любое количество тестов, а не по одному, как было раньше.
<a name="cicd">Перемещаемся в пайплайн</a>
При запуске локально тесты работают. Но нам нужно переместить их в пайплайны. Сразу перейду к списку того, что потребовалось тюнить:
Git. На локальной машине для идентификации используется ssh-ключ, а в пайплайне – нет. Если для работы с удаленным репозиторием вместо ssh использовать http-протокол, то после вызова команды потребуется ввести логин и пароль. Но у git'а есть возможность указать значение
store
в настройкуcredential.helper
, и тогда можно сохранить креды в форматеhttps://{user}:{token}@{host}
в файл.git-credentials
Учетная запись. На локальной машине используется моя собственная учетка, логин и пароль знаю только я, доступы есть везде, куда нужно. Но транслировать свои креды на удаленную машину – плохая идея. Мы создали сервисную учетку с доступами только до требуемых проектов.
<a name="manydevs">Раз разработчик, два разработчик…</a>
Следующим вызовом стала проблема параллельного запуска тестов. Когда я их писал, то и запускал только я один. Но теперь их используют несколько разработчиков. Прогоны начали мешать друг другу, скапливаться в очереди, потому что тестовый репозиторий всего один.
Нужно создать больше тестовых репозиториев. Но как тест узнает, какой репозиторий использовать? В рантайме эту информацию не удержать, случайный выбор не даёт гарантий.
Я решил создать отдельный сервис, который заберет на себя эту работу.
<a name="box">Коробка с болванчиками</a>
Я использовал наш PaaS и написал небольшой сервис на Golang. Список тестовых репозиториев и их статус хранятся в Postgres.
Сервис предоставляет три gRPC-ручки:
LockRepo — блокирует самый давно неиспользуемый сервис и отдает его данные;
UnlockRepo — разблокирует указанный сервис;
GetReposList — возвращает список всех сервисов.
Также рядом с сервисом есть кронджоба, которая разблокирует сервисы, которые заблокировали и забыли.
Блокировку и разблокировку тестового сервиса я вынес в фикстуру:
@pytest.fixture()
def project_info():
repo = DummiesBox.lock_repo()
yield repo
DummiesBox.unlock_repo(repo.id)
И теперь каждый тест деплоя проходит на отдельном сервисе, их состояние изолированно друг от друга. Плюс у инженеров есть время отладить тестовый сервис, если тест найдет ошибку, потому что вперед выделяются более старые сервисы.
<a name="future">Развитие</a>
На двух простых тестах мы не остановились.
Уже сделано:
Мы расширили скоуп проверок в каждом тесте, проверяем как вместе с сервисом разворачиваются его ресурсы: postgres, redis и т.п.
У нас есть несколько вариантов деплоя: стабильный стейдж, отдельным подом, отдельный стейдж. Добавили тесты для каждого.
PaaS поддерживает несколько языков, для каждого свой деплой. Добавили тесты для основных языков.
Наработки автотестов деплоя позволили реализовать автотесты рейтлимитера, в которых мы точечно проверяем как он отработал в зависимости от настроек.
Сейчас основные направления для дальнейшего развития:
Автотесты для деплоя на прод.
Проверка оставшихся языков, для которых есть наш деплой.
Отстрел сервиса в процессе деплоя.
<a name="problems">Проблемы</a>
Разумеется, трудности встречались регулярно и до конца никогда не исчезнут. Инфраструктура может сбоить, что приводит к падению теста. Вот примеры некоторых проблем:
сетевая проблема в облаке (недоступно, долго отвечает, 500-тит);
кончились ноды и kubernetes не может поднять под;
заняты все раннеры в CI/CD;
спам тестов из-за частых коммитов;
неявное изменение бизнес логики, когда тест в целом проходит, но иногда падает, а на что смотреть — непонятно.
Для минимизации этих проблем мы итеративно улучшаем наши тесты: добавляем явные ожидания, retry-механизмы для нестабильных запросов, переделываем способы запуска тестов из пайплайна.
<a name="overall">Резюме или А что по цифрам?</a>
Основной набор состоит из 5 e2e-тестов. При нормальных условиях тест проходит за ~15 минут. Бегают они параллельно.
Тестовый набор запускается минимум 1 раз на МР и в среднем 10-15 раз в день, в зависимости от нагрузки инженерной команды. В месяц выходит порядка 250 запусков тестового набора.
Выполнение этих же операций вручную занимает в разы больше времени и представляет собой не самую интересную часть работы. Автотесты позволяют нам находить ошибки на ранних этапах, экономят время и никогда не устают.
Если вы хотите попробовать такие тесты у себя, то чтобы превратить их в рабочий код нужно:
Реализовать методы взаимодействия с репозиторием и kubernetes. Обычно для этого достаточно взять готовую функции из официальных библиотек и добавить логи.
Добавить ожидания для пайплайнов и задач. Я использую библиотеку waiting для этого.
Добавить проверки для всех своих ресурсов. В общем случае я проверяю:
статус задач в пайплайне;
наличие и статус подов в kubernetes;
статус контейнеров внутри подов в kubernetes.
Реализовать способ поставки тестовых репозиториев. У меня это отдельный сервис, но возможно вы найдете другой способ.
Буду рад, если наши наработки вам пригодятся. Если вы писали автотесты для деплоя как-то по-другому, то интересно будет услышать, в чём мы разошлись!
Tech-команда СберМаркета завела соцсети с новостями и анонсами. Если хочешь узнать, что под капотом высоконагруженного e-commerce, следи за нами в Telegram и на YouTube. А также слушай подкаст «Для tech и этих» от наших it-менеджеров.