На пути к тестируемому инфраструктурному коду
Программный код часто содержит баги, и это нормально. Мой первый скрипт был для раскладывания скриншотов по датам и названию игр. Тестировал его я, разумеется, руками, поэтому заняло это несколько дней. Знай я на тот момент хоть один фреймворк тестирования, весь процесс отладки занял бы считанные часы.
Сейчас я пишу больше инфраструктурного кода: утилиты бэкапов, скрипты мониторинга, Kubernetes-манифесты, Ansible-плейбуки, Terraform-модули, и, чуть не забыл, CI/CD. Иногда он бывает простым, иногда странным, но чаще — нетестируемым. Инфраструктурный код — это код в мешке: пока не запустишь — не узнаешь, что случится. В среднем я делаю 10—20 запусков пайплайна в Jenkins, чтобы довести код CI до рабочего состояния.
Ах да, не протестированный инфраструктурный код может быть просто опасным. В лучшем случае он может запуститься во всех окружениях и упасть во время сборки в production, в среднем — стереть бэкап или выдавать ложные показания в графане, в худшем — выдавать неконсистентный бэкап на протяжении полугода, пока вам не понадобится его восстановить.
Kipo and the Age of Wonderbeasts, 2017
Сейчас расскажу, как можно все эти беды свести к минимуму.
Настройте линтеры
Mypy, когда я запустил его на скрипте без аннотаций типов.
Допустим, у нас есть Kubernetes-манифест, Terraform-модуль, а также Python-скрипт для отправки уведомлений в Slack, и запускаются они из GitLab CI. Здесь всё может пойти не так, и под «всё» я имею ввиду «что угодно». Собственно, часть проблем из списка «что угодно» отлавливается на этапе статического анализа.
Для Kubernetes-кода можно написать свой скрипт, который читает YAML-файлы и проверяет как минимум наличие необходимых ключей в структуре. Вот, к примеру, валидация меток внутри Deployment:
import sys, yaml
data = None
with open(sys.argv[1]) as fd:
data = yaml.safe_load(fd)
if data["kind"] != "Deployment":
sys.exit()
selector = data["spec"]["selector"]["matchLabels"]
template = data["spec"]["template"]["metadata"]["labels"]
assert selector == template
Можно заменить его на уже существующий инструмент, однако он, возможно, не умеет разбирать новомодные CRD. Но можно скомбинировать оба подхода и получить валидатор структур для выявления плохих практик или явных ошибок — например, отсутствия привязки к SHA-сумме образа в поде.
Terraform-код проверяется тремя способами. Во-первых, при помощи стандартных инструментов Terraform — команд fmt, plan, validate. Во-вторых, существует популярный инструмент tflint, имеющий встроенные базовые проверки стиля кода, но с возможностью расширения своими правилами или плагинами сообщества.
В-третьих, tflint опирается на набор библиотек для работы с HashiCorp Configuration Language (HCL), их же использует и сам Terraform. HCL описывает общий синтаксис .tf/.hcl-файлов, а также алгоритм многоэтапной обработки конфигурации. Первый этап состоит из проверки синтаксиса и парсинга, следующие — из разбора и подстановки переменных из соседних файлов конфигурации, вызовов функций, обработки шаблонов и другого. HCL SDK состоит из обёртки hclsimple (высокоуровневое API для преобразования файлов в Go-структуры), из gohcl и hclparse (инструменты для разбора структуры HCL-текста), а также из утилиты hcldec, которая просто преобразовывает HCL в JSON, что полезно для парсинга не из Go, а из других языков. По своему опыту могу сказать, что работать с HCL SDK не так просто, но такова цена столь мощного языка.
Для проверки стиля и синтаксиса Python-скрипт для Slack можно пропускать через Pylint, можно также добавить mypy для остроты или дополнительные проверки к pylint по вкусу.
Получается, что статический анализ позволяет проверять ошибки в коде и конфигурации, и подходит для всего, за исключением, пожалуй, Groovy/Jenkins Pipeline (попробуйте его распарсить, я посмотрю) или конфигов со сложным синтаксисом (привет, Nginx!).
Его минус заключается в том, что это просто статический анализ, больше подходящий для выявления стилистических ошибок — логические или фактические ошибки из коробки с его помощью обнаружить не получится.
Внедрите режим проверки
Возможно, вы уже встречали нечто вроде режима проверки в DevOps-утилитах, действия которых могут быть разрушительными. Например, у инструмента бэкапов Restic при вызове команды forget (удаление резервных копий) есть параметр --dry-run
, при котором утилита не удаляет ничего, а просто выводит информацию о том, какие снимки данных были бы удалены согласно указанным политикам очистки. Ansible имеет параметр --check
, при котором его модули ничего не меняют на хостах, Terraform — plan
, по которому можно понять, что произойдёт при apply.
А что если в инфраструктурный код встроить такой режим, при котором система будет действовать в формате «только для чтения»? Или пайплайн, скажем, будет только доставать объекты из S3, модифицировать их согласно бизнес-логике, но не перезаписывать обратно? В теории можно получить безопасный пайплайн и убедиться в том, что как минимум часть кода работает.
Пишите скрипты отката, настройте версионирование
Я, когда надо откатывать пайплайн.
Этот вариант предлагает смотреть на каждый пайплайн как на своеобразную миграцию, с которой можно откатиться. Пайплайн развёртывает новую версию конфигов Nginx или DNS? Напишите скрипт или конфиг-бэкап, с которым можно моментально откатиться в случае ошибки.
Пример: модуль Ansible template при перезаписи файла main.conf будет копировать оригинал в main.conf.old.
Этого достаточно для ручного отката. Но лучше спланировать CI/CD таким образом, что при миграции БД перед запуском приложения вы напишете также скрипт отката на предыдущую версию, который будет автоматически запускаться в случае ошибки пайплайна. Это может потребовать переработки инструментария, например, уйти в сторону Immutable Infrastructure на основе Packer, однако эффект будет уникальный — вы сможете запускать пайплайны, не боясь ошибиться.
Изучите SDK используемого вами инструментария
Некоторые инструменты обладают вшитыми возможностями для тестирования. У Ansible есть фреймворк Molecule для тестирования ролей и проверки последствий их выполнения в Docker-контейнерах, и не только. Puppet SDK тоже имеет свою среду для тестирования. Helm-чарты вроде как тоже можно прогонять через юнит-тесты Helm.
Когда пришла таска на Молекулу.
Правда, тут есть два «но». Первое — вам, скорее всего, придётся переписывать код того же плейбука Ansible, чтобы его можно было бы протестировать. Переходить на TDD необязательно, но можно, главное — разделить код на тестируемые части. Йей, рефакторинг!
Реакция моего напарника (привет, Илья!) на слова «Давай перепишем!»
Но есть системы, для которых нельзя написать юнит-тесты. Например, CI/CD. Как быть в этом случае? По возможности, надо ориентироваться на инструменты с поддержкой юнит-тестирования. Если такой возможности нет, то одно из следующих решений вам наверняка подойдёт.
Вынесите CI в тестируемый код
В CI, как уже говорилось, с тестируемостью всё сложно, как и в любой другой расширяемой и кастомизируемой системе. В чём же сложность? Представьте, что у вас есть утилита с GUI, и вы написали несколько плагинов, которые цепляются «хуками» к основной системе и отрисовывают свои формы и панели на основном GUI. Такие компоненты будут нетестируемыми без запуска основной утилиты. А если утилита не предусматривает проверок компонентов by design — вы в ловушке.
То же самое с CI. CI/CD — это расширяемая правилами и скриптами утилита с внутренним интерфейсом, с которым вы активно взаимодействуете и без которого протестировать пайплайн невозможно. Ну, в первоначальном виде.
Но что если поменять правила игры и вынести логику CI в отдельную утилиту, которая легче поддавалась бы тестированию? Скажем, есть пайплайн, который через плагин Jenkins достаёт токен из Vault и с его помощью выкачивает артефакт из репозитория сборок через curl. В этом случае можно вынести логику выгрузки сборки в отдельный инструмент на каком-нибудь Python, покрыть его тестами с моками и ассертами, в общем, сделать из баша конфетку!
Я, когда начал разносить код CI по библиотекам и инструментам.
И кстати, разделите код и конфигурацию
Одним из правил хорошего тона в программировании является принцип «Разделяй и властвуй». Другими словами, полезно разделять код на доменные уровни, на компоненты. Но некоторые забывают, что ещё можно разделять «код» и «не код»! То есть выделять из общей кодовой базы тесты и конфигурацию. И хотя некоторые (Rust, Go) языки пропагандируют «сближение» тестов и кода, проводить decoupling кода и конфигурации можно в любом языке программирования. Помимо того, что это позволяет легче управлять приложением в разных окружениях (dev/stage/prod/…), вы сможете настраивать приложение без выполнения пересборки проекта, что, впрочем, очевидно.
Как это относится к теме статьи? Приведу пример из жизни «Домклика». Мы активно используем Jenkins и Vault, и случается, что кто-то ошибается в пути и поле key/value-записи, или же забывает выдать нужные политики для approle. Инженеры — тоже люди, которые, бывает, ошибаются в конфигурации пайплайна. И вынос конфигурации из кода даёт возможность провести дополнительные проверки, в данном случае — перед запуском пайплайна можно убедиться, что такое поле вообще есть в Vault и оно доступно из такой approle.
Короче говоря, деритесь с кодом, режьте его, легаси складывайте в морозилку, конфигурацию тестируйте.
Кодья драка!
* * *
На самом деле это переосмысление моего личного опыта — что бы я поменял в тех или иных проектах, будь у меня такая возможность. Возможно, что-то из перечисленного вам пригодится, а может, станет для вас отправной точкой для собственных открытий.
Ну или вдруг кадры-иллюстрации из «Кипо и Эра Чудесных Зверей» сподвигнут вас посмотреть сей замечательный сериал, я не знаю.