Введение
У меня есть pet project, которым я занимаюсь в свободное время. Этот проект полностью посвящён инфраструктурным экспериментам. Для управления конфигурацией я использую SaltStack. SaltStack — это централизованная система управления инфраструктурой. Это значит, есть мастер-сервер, который настраивает подчинённые серверы.
За время жизни проекта я наступил на небольшой набор граблей, но в итоге пришёл к очень удобному подходу работы с ним. В общем про это и статья — как оно все начиналось и к чему пришло.
Когда деревья были большими
Весь проект был монолитным, в нем было всё:
- состояния (states) — инструкции-описания как и что настраивать;
- структуры данных (pillars) — данные, которые используются в состояниях. Например:
- список системных пакетов под какую-то задачу;
- логин/пароль от Docker hub'a, которые используются в состояниях по разворачиванию Docker контейнеров;
списки серверов и назначенные им состояния и данные.
Весь проект лежал в одном git репозитории, который был подключён к мастер-серверу через gitfs. Это жутко удобно — не надо заботиться об актуализации данных на мастер-сервере. SaltStack сам собирает все из репозитория.
Я мог бы поднять тестовую копию своей инфраструктуры и тестировать все через неё, используя отдельную ветку в git-репозитории. Но поднять копию инфраструктуры для тестов дорого:
- по деньгам, если это облака;
- по времени, в любом случае — надо же взять и сделать, и поддерживать в рабочем состоянии.
С другой стороны, "бой" и так один сплошной "тест" и ничего страшного, если поломаю (ну как "не страшно", обидно бывает). А раз не страшно, то каждое изменение, в том числе и промежуточное, я деплоил через push в репозиторий. Commit-лог стал выглядеть жутко, мягко говоря:
- попробуем решить проблему по установке пакета…;
- ещё одна попытка исправить ошибку…;
- магия…;
- ну теперь точно все;
- ну теперь точно все №2;
- какие-то изменения, которые забыл в прошлый раз;
На самом деле не все было так плохо, но в целом картинку пример передаёт правильную %)
Дальше стало ещё хуже — со временем я начал забывать, какое состояние что именно делает и как оно это делает. Все-таки это личный проект и работаю над ним я не всегда, а время от времени. README файл уже плохо решал эту проблему.
Также была ещё и связанность состояний через данные. Разные состояния использовали одни и те же данные, если структуру данных изменить для одного состояния — другие гарантировано ломались. Из-за этого в какой-то момент времени "боевая" конфигурация находится в состоянии "кишки наружу". Я могу чинить баг на протяжении нескольких дней, а значит, в это время какое-то из состояний могло оставаться нерабочим. В целом это говорило о плохой продуманности архитектуры проекта.
Но все эти минусы меня мало волновали. В моем режиме я готов был с ними жить. Не мог я мириться только с одним — меняя что-то в структуре данных, я забывал проверить все состояния. Ловить потом такие "отложенные" ошибки долго и стремно.
Решение — писать тесты
Я осознал, что если я напишу тесты, то у меня будет гарантия, что если я что-то изменил, то автотесты проверят результат работы всех состояний. Ура! Все вполне просто. Задача ясна: хочу проверять результат работы состояний в проекте.
Итак, что у нас есть для тестирования? Результат работы SaltStack'a — это конфигурационные файлы, сервисы, Docker контейнеры, настройки фаервола, SElinux и так далее. Вот это все отлично тестируется с помощью Serverspec тестов.
Я начал вспоминать конференции, где был, вспоминать статьи, какие встречал на эту тему. В общем, на русском из актуального и хорошего в голове крутился только один автор — Игорь Курочкин IgorITL, кого я вживую слушал на DevConf'e 2015. Можно посмотреть его доклад "Тестируем инфраструктуру как код":
Ещё я нашёл неплохую статью для понимания проблемы "Agile DevOps: Test-driven infrastructure".
После прочтения всех материалов я понял, что для моей задачи подходит инструмент KitchenCI, так как он:
- работает с SaltStack;
- запускает инфраструктурный код где угодно — Vagrant, Docker, lxc и куча разных облаков;
- поддерживает тестовые фреймворки: bats, RSpec, Serverspec и другие.
Я посчитал, что, кажется, теперь я все знаю. Есть теория, в голове всё уложилось — теперь-то уж точно можно начать писать тесты, не так ли?
Первый блин комом
Посмотрел я на проект и увидел, что как-то теория в моей голове ну вообще никак не укладывается на мою реальность. Как подойти к моему проекту? Куда класть тесты? Как их запускать?
В поисках ответа я опять полез читать внимательно документацию KitchenCI. К сожалению, в ней сильно отвлекает специализация этого инструмента на Chef и его особенности. Примеры опять же все для него.
Давайте посмотрим на KitchenCI чуть внимательнее. В этом инструменте мы оперируем следующими объектами:
- драйвера — плагины, с помощью которых KitchenCI запускает виртуальные машины. Например vagrant, docker, digitlocean и т.д. По умолчанию используется vagrant и меня это устраивает полностью — хочу тесты гонять локально;
- движок (provisioner), на котором описана наша конфигурация. По умолчанию это Chef в режиме masterless;
- платформа — имя образа, который будет использован, как база для нашей тестовой виртуальной машины;
- наборы тестов для запуска (suite). Если ничего не менять, то KitchenCI будет пытаться найти тесты в директории default, именно такое имя у набора по умолчанию;
У меня же используется SaltStack. Гугл нам подсказывает, что есть сторонний проект 'kitchen-salt', который реализует provisioner salt_solo для SaltStack. Там же есть подробный урок и пример, как это использовать.
Прочитав документацию по KitchenCI и kitchen-salt, я вынес главное — тестируются отдельные рецепты (в терминологии Chef'a), а не вся конфигурация целиком. В SaltStack'е аналогом Chef'овских рецептов являются формулы — независимые состояния, вынесенные в самостоятельный проект. Эти формулы используются для повторного использования кода в других проектах. Например, целая пачка таких формул доступна на GitHub.
В этом и заключается основная причина, почему мой проект "не подходит" для KitchenCI — он монолитный. В голове закрутились слова "рефакторинг", "связанность кода", "модульный подход" и подобное. Я взгрустнул. Как не программист я и слов-то таких знать не должен.
Рефакторинг проекта
Challenge accepted! Насколько я помню первое правило рефакторинга, у него должна быть ясная, достижимая и измеримая цель. Обычно это развёрнутый ответ на вопрос "Для чего мы вносим изменения?". В моем случае это было сформулировано следующим образом:
- все состояния основного проекта должны быть вынесены в отдельные дочерние проекты-формулы;
- каждая формула должна иметь README файл со своим описанием;
- каждая формула должна сопровождаться
pillar.example
файлом, с примером структуры хранения данных, которую ожидает данное состояние; - каждая формула должна быть оформлена в соответствии с требованиями и рекомендациями, которые можно найти в официальной документации.
Составив список задач, я опять загрустил, попил кофе и пошёл делать. Состояние за состоянием превращались в отдельные формулы. Вынося состояние из основного проекта, я вносил в конфигурацию мастер-сервера ссылку на новую формулу. Таким образом, работоспособность проекта особо не страдала на протяжении всей переделки.
Из-за связанности части состояний пришлось пересмотреть структуру хранения данных — я сделал её более независимой, разбил на не пересекающиеся части для разных формул и при этом старался избежать дублирования данных. Это было, пожалуй, самым сложным. Из-за этого часть логики некоторых состояний была перенесена в другие.
Итогом работы стало почти два десятка отдельных формул, простых и понятных, с примерами используемых данных и минимальной документацией. Итоговая структура данных стала заметно проще. Даже на этом этапе я ощутил положительный результат — я теперь знал, что мои формулы независимы, и можно смелее вносить в них изменения.
Тестирование
Как только я начал рассматривать отдельную формулу как объект тестирования, у меня сразу же сложилась картинка в голове о том, как применять KitchenCI. Давайте разберём процесс тестирования на примере простейшей формулы "Common packages". Данная формула устанавливает системные пакеты, которые я ожидаю встретить на любом из своих серверов. Это просто привычные для меня утилиты.
NB! Дальше по тексту, все команды выполняются в корне проекта формулы.
Вот так выглядит изначальная файловая структура формулы:
.git
common-packages/init.sls
pillar.example
README.md
Состояние init.sls
:
packages:
pkg.latest:
- pkgs:
{%- if pillar['packages'] is defined %}
{%- for package in pillar['packages'] %}
- {{ package }}
{% endfor %}
{% endif %}
Пример данных, pillar.example
:
packages:
- bind-utils
- whois
- git
- psmisc
- mlocate
- openssl
- bash-completion
- net-tools
Для работы KitchenCI нам потребуется установленные Vagrant и ruby (и gem bundler, конечно). Создадим Gemfile
cо списком требуемых ruby gems в корне проекта нашей формулы:
source "https://rubygems.org"
gem "test-kitchen"
gem "kitchen-salt"
gem "kitchen-vagrant"
Устанавливаем перечисленные зависимости:
$ bundle install
Попросим KitchenCI создать нам структуру и файлы заглушки для тестов:
$ sudo kitchen init -P salt_solo
У нас появились:
- директория для интеграционных тестов набора по умолчанию:
test/integration/default
- файл
chefignore
, который мы смело можем удалить, это "наследство" тесной интеграции KitchenCI и Chef'a -
файл
.gitignore
(если он не был вами создан ранее), куда добавились строки:.kitchen/ .kitchen.local.yml
- и самый главный файл
.kitchen.yml
со следующим содержимым
---
driver:
name: vagrant
provisioner:
name: salt_solo
platforms:
- name: ubuntu-14.04
- name: centos-7.2
suites:
- name: default
run_list:
attributes:
Вносим в .kitchen.yml
описание нашей формулы:
---
driver:
name: vagrant
provisioner:
name: salt_solo
formula: common-packages # <- имя нашей формулы
pillars-from-files:
packages.sls: pillar.example # <- используем pillar.example, чтобы быть уверенным за работоспособность примера
pillars: # <- сюда мы вкладываем структуру данных (pillar'ы), повторяя как файловую структуру так и содержимое файлов!
top.sls:
base:
'*':
- packages
state_top: # <- содержимое state.sls где мы назначаем нашу формулу
base:
'*':
- common-packages
platforms:
- name: centos-7.2 # <--- у меня все под CentOS 7, поэтому я убрал лишние платформы
suites:
- name: default
run_list:
attributes:
В общем, все готово. Давайте создадим виртуальную машину, настроим её и прогоним в ней формулу:
$ kitchen converge centos-7.2
Да, KitchenCI выполнил для нас следующие действия:
- создал виртуальную машинку на базе CentOS 7;
- установил и настроил SaltStack в masterless режиме внутри этой машины;
- применил формулу;
- выдал подробные логи о всех вышеперечисленных шагах.
Хо-хо! Я теперь могу разрабатывать формулы и фиксить в них баги без необходимости коммитить промежуточные изменения в мастер и выкладывать их на "бой". "Боевая" инфраструктура будет заметно стабильнее и, кажется, мой commit-лог теперь будет не стыдно показать, если вдруг придётся.
Можно посмотреть руками результат работы формулы, зайдя внутрь машинки:
$ kitchen login centos-7.2
Я научился с помощью KitchenCI запускать формулы и проверять их работоспособность. Проверять руками — это здорово. Но где же автотесты? Давайте все-таки проверять результат работы формулы автотестами.
Для этого выполним следующие шаги:
- Создаём директорию
./test/integration/default/serverspec
- И в неё размещаем файл packages_spec.rb
Внимание! Суффикс _spec обязателен. Почитать об этом и других нюансах и в целом познакомиться со Serverspec можно на официальном сайте: http://serverspec.org/.
require 'serverspec'
# Required by serverspec
set :backend, :exec
describe package('bind-utils') do
it { should be_installed }
end
describe package('whois') do
it { should be_installed }
end
describe package('git') do
it { should be_installed }
end
describe package('psmisc') do
it { should be_installed }
end
describe package('mlocate') do
it { should be_installed }
end
describe package('openssl') do
it { should be_installed }
end
describe package('bash-completion') do
it { should be_installed }
end
describe package('net-tools') do
it { should be_installed }
end
Чтобы сэкономить время и не ждать опять, пока машинка будет создана и настроится с нуля, давайте просто попросим KitchenCI прогнать тесты:
$ kitchen verify centos-7.2
Вот и вся магия.
KitchenCI позволяет сделать все вышеперечисленные шаги одной командой: kitchen test. Будет создана виртуальная машина, прогонятся формула и тесты, затем машинка будет уничтожена.
Функциональное тестирование
kitchen-salt может тестировать не только отдельные формулы, но и их наборы. То есть, вы вполне можете тестировать итоговый результат работы нескольких формул. Такая проверка покажет, могут ли ваши формулы работать совместно и дают ли они ожидаемый результат. Все это возможно благодаря различным комбинациям опций provisioner’a: https://github.com/simonmcc/kitchen-salt/blob/master/provisioner_options.md. А это значит, что я вполне мог и к исходному виду моего проекта привязать KitchenCI и тесты, но как мне кажется в итоге получилось значительно лучше.
Выводы
Теперь я потихоньку покрываю тестами свои старые формулы и пишу новые, причём пишу намного быстрее, чем раньше. И я в любой момент уверен в работоспособности своих формул, как новых, так и старых. Да, несмотря на временные затраты на рефакторинг и написание тестов, я получил явный прирост в работе со своим карманным проектом. Теперь нет опасений, что отложив проект на продолжительный период времени, я не смогу продолжить его из-за сложности самого проекта или непонятно почему не работающих формул. Да, рефакторинг сожрал несколько дней моего личного времени. Да, тесты писать скучно, но они дают чувство уверенности в проекте. Классное такое чувство.
Буду рад ответить на вопросы, выслушать замечания и советы на будущее :)
Ссылки
- Документация SaltStack: https://docs.saltstack.com/en/latest/
- Provisioner
salt_solo
: https://github.com/simonmcc/kitchen-salt - KitchenCI: http://kitchen.ci
- Serverspec: http://serverspec.org
Автор: Plesk