На пути к тестируемому инфраструктурному коду

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

Сейчас я пишу больше инфраструктурного кода: утилиты бэкапов, скрипты мониторинга, Kubernetes-манифесты, Ansible-плейбуки, Terraform-модули, и, чуть не забыл, CI/CD. Иногда он бывает простым, иногда странным, но чаще — нетестируемым. Инфраструктурный код — это код в мешке: пока не запустишь — не узнаешь, что случится. В среднем я делаю 10—20 запусков пайплайна в Jenkins, чтобы довести код CI до рабочего состояния.

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

Kipo and the Age of Wonderbeasts, 2017Kipo and the Age of Wonderbeasts, 2017

Сейчас расскажу, как можно все эти беды свести к минимуму.

Настройте линтеры

Mypy, когда я запустил его на скрипте без аннотаций типов.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!).

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

Внедрите режим проверки

3d3174d121509fc7452e321fd1dabc90.png

Возможно, вы уже встречали нечто вроде режима проверки в 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 по библиотекам и инструментам.Я, когда начал разносить код CI по библиотекам и инструментам.

И кстати, разделите код и конфигурацию

Одним из правил хорошего тона в программировании является принцип «Разделяй и властвуй». Другими словами, полезно разделять код на доменные уровни, на компоненты. Но некоторые забывают, что ещё можно разделять «код» и «не код»! То есть выделять из общей кодовой базы тесты и конфигурацию. И хотя некоторые (Rust, Go) языки пропагандируют «сближение» тестов и кода, проводить decoupling кода и конфигурации можно в любом языке программирования. Помимо того, что это позволяет легче управлять приложением в разных окружениях (dev/stage/prod/…), вы сможете настраивать приложение без выполнения пересборки проекта, что, впрочем, очевидно.

Как это относится к теме статьи? Приведу пример из жизни «Домклика». Мы активно используем Jenkins и Vault, и случается, что кто-то ошибается в пути и поле key/value-записи, или же забывает выдать нужные политики для approle. Инженеры — тоже люди, которые, бывает, ошибаются в конфигурации пайплайна. И вынос конфигурации из кода даёт возможность провести дополнительные проверки, в данном случае — перед запуском пайплайна можно убедиться, что такое поле вообще есть в Vault и оно доступно из такой approle.

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

Кодья драка!Кодья драка!

* * *

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

Ну или вдруг кадры-иллюстрации из «Кипо и Эра Чудесных Зверей» сподвигнут вас посмотреть сей замечательный сериал, я не знаю.

© Habrahabr.ru