image

У всех есть инфраструктура, и у всех она разная: кому-то милей родной дата-центр, кто-то живет в облаке или multi-cloud. В нашем случае инфраструктура — это 1500+ хостов на полусотне оружений, распределенные по десятку VPC, а те — по нескольким AWS-аккаунтам. Кажется, не так уж и много, но есть своя специфика: разные операционные системы, нетиповая конфигурация, Legacy и прочие тонкости, усложняющие процесс поддержания всей этой системы.

В этой статье я расскажу, как мы облегчили работу с инфраструктурой и автоматизировали ее поддержку и выполнение операционных задач с помощью Ansible, Molecule, Docker, Gitlab CI и Packer.

Вы узнаете:

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

О чем я НЕ буду рассказывать:

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

Этот материал — продолжение статьи, в которой я рассказывал про наш опыт работы с Terraform. Рекомендую прочитать ее, чтобы понимать отсылки.

Итак, приступим…

Инфраструктура


Мы, как Operations Team, занимаемся поддержкой инфраструктуры проекта. В нашем случае это окружение разработки и продакшен. На схеме ниже я покажу список всего, во что мы запускаем свои натруженные мозолистые руки, и поверьте на слово: задач много и они очень разные.

Итак, что мы подразумеваем под инфраструктурой:

image

Нам повезло, что первые три уровня инфраструктуры обслуживает Amazon: они просто предоставляются как часть сервиса AWS.

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

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

Еще один небольшой нюанс. Внимательный читатель наверняка уже заметил странную аббревиатуру SWE. Она расшифровывается как Software Environment и обозначает программное окружение, в котором будут жить и работать наши приложения. Скорее всего вы уже сталкивались с SWE, просто у вас оно зовется иначе: golden image, исходный образ или основной образ.

image

SWE включает в себя:

  • Дистрибутив операционной системы со стандартным набором пакетов и обновлений, вышедших к определенному моменту
  • Версию ядра
  • Дополнительные пакеты необходимые для обслуживания
  • Тюнинг различных настроек, в том числе производительности и безопасности
  • Шаблон разметки дискового пространства и набор файловых систем

По сути это «слепок» преднастроенной операционной системы, который у нас заказывают разработчики. Вначале он поступает в лабораторные окружения, где на его базе идет разработка, потом во все прочие — регрессию, стейджинги и продакшен.

Мы реализуем этот процесс на базе AMI (Amazon Machine Images), однако подобная шаблонизация вполне применима в любой инфраструктуре.

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

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

Дьявол в деталях


Наш проект живет в облаке Amazon (AWS). Поэтому все практики, о которых я буду рассказывать, мы испытывали для наших облачных реалий. Хотя многие из них применимы как для пользователей других облаков, так и для тех, кто живет в своем дата-центре.

Когда я смотрел какие-то курсы, обучающие видео или доклады на тему управления инфраструктурой, там это выглядело так:
«Берете вы, значит, свою инфраструктуру, обмазываете ее популярной системой управления конфигурацией, сохраняете это все в Git (инфраструктура как код же, все дела) — и получаете счастье!»

Вместо популярной системы подставьте нужное.

Однако в крупных проектах это так не работает. Разве что если вся ваша инфраструктура — десяток виртуальных машин, которые годами живут у какого-то хостера. У нас все сложнее: на момент написания статьи у нас было более полутора тысяч виртуальных серверов, неравномерным слоем «размазанных» по более чем полусотни окружений. Они в свою очередь распределены по десятку VPC, а те — по нескольким AWS-аккаунтам. Так бизнесу удобно с точки зрения безопасности, процессов разработки, бюджетов и биллинга, а нам с этим надо жить. Но на звание самой сложной инфраструктуры тоже не претендуем — у ребят из Яндекса или ВК жизнь явно веселей.

«Подумаешь, всего каких-то полторы тысячи хостов — скажет кто то, — Я могу на своем ноутбуке столько поднять и настроить парой ролей, и что?». Шутка, конечно. Про ноутбук, не про ход мыслей. Отвечаю: дьявол, как всегда, кроется в деталях.

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

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

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

Ну и «вишенка на торте» — это горячо любимые нами Legacy и технический долг. Это вещи, которые остались в проекте с тех пор, как он был стартапом. Некоторые из них могли устареть, потерять актуальность, перестать выполнять бизнес-задачу.

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

Проблемы Задачи


В результате наших страданий мы выделили 3 задачи:

  • Доставка конфигурации на сервера. Казалось бы: берешь любимую систему управления конфигурацией, шаблонизируешь конфиги, и все готово. Но у нас много серверов, используются разные дистрибутивы или их версии, рантаймы приложений (java, nodejs, python) и типы компонент (базы данных, очереди, сервера приложений, кеши). Компоненты собираются в различные комбинации, которыми нужно управлять.
  • Доставка конфигураций, специфичных для окружения. Окружений у нас много, они разные, и в каждом хватает своей специфики. Держать это в голове — плохая идея. Держать в документации и каждый раз к ней обращаться — уже лучше, но тоже не хочется. А хочется один раз описать это дело для окружения, чтобы оно само учитывало нюансы или предупреждало, если что-то изменилось, пошло не так или требует пристального внимания.
  • Сопровождение SWE. Эти образы нужно создавать, проверять, тестировать и обновлять. Если приходят новые требования от других команд, их нужно учесть и включить не только в новые образы, но и в текущие. И тут есть одна загвоздка. Мы не можем выкатывать обновления на все сервера, когда нам вздумается, особенно в продакшене. А если мы что-то обновляем на работающих серверах, то согласно процессу у нас должны быть четкий план проверки и отката на любое вносимое изменение. По большей части эти ограничения обоснованы юридически, но мы тоже должны их учитывать.

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

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

Не зная страха, не ведая преград


Прежде чем решать эти задачи, мы решили сформировать список требований к набору инструментов:
  • Мы хотели, чтобы инструментов было немного, а лучше — один.
  • Инструменты должны давать возможность запускать много параллельных задач, но сохранять гибкость.
  • Мы хотели использовать API облачного провайдера для инвентаризации ресурсов. Это на случай, если завтра мы решим увеличить количество серверов с 30 до 50 и ввести дополнительное разделение.
  • Нам была нужна интеграция с CI или возможность легко встроить инструмент в CI pipeline. Мы уже управляем инфраструктурой при помощи Terraform, выкатывая изменения через CI . Поэтому интегрировать инструмент с инфраструктурой казалось логичным шагом.

Всем условиям отвечал Ansible. С ним удобно выполнять параллельные команды и задачи на наших серверах с динамическим инвентори. Достаточно настроить dynamic inventory через AWS API, привязаться к тегам ресурсов и больше не придется городить странные конструкции с помощью Paralell SSH и bash-порно для решения типовых задач. Это работает, например, для добавления нового сервисного пользователя на все машины, обновления пароля root или замены значения в строке конфигурационного файла.

Рассмотрим на примере
Замена строки в файле Ansible (в формате ad-hoc команды):
ansible ${SERVER_GROUP} -m replace    -a "path=${CONFIG_PATH}    regexp='ORIGINAL_STRING'    replace='NEW_STRING'    backup=yes"


То же самое с bash+pssh:
DATE=$(date +"%H:%M:%S_%m-%d-%Y")
pssh -h ${SERVER_GROUP}_hosts_files --     cp ${CONFIG_PATH}  ${CONFIG_PATH}.backup_${DATE}
pssh -h ${SERVER_GROUP}_hosts_files --     sed -i -e 's/ORIGINAL_STRING/NEW_STRING/g' ${CONFIG_PATH}

Набор команд не идемпотентный. И да, sed ничего не изменит, если не найдет оригинальную строку, но бэкап будет создан.

Пример 2: создание сервисного пользователя Ansible (в формате ad-hoc команды):
ansible ${SERVER_GROUP} -m user    -a "name=${USER_NAME}    groups=${GROUP_LIST}    shell=/sbin/nologin    append=yes    state=present"

То же самое, но bash+pssh:
pssh -h ${SERVER_GROUP}_hosts_files --     useradd -G ${GROUP_LIST} -s /sbin/nologin ${USER_NAME}

Опять не идемпотентно. При повторном запуске новый юзер не будет создан, но мы получим ошибку, что такой пользователь существует. В то же время Ansible просто проинформирует, что ему нечего менять. Эта фича полезна, когда вы добавляете в группу серверов новых участников, и нужно провести донастройку. Операторам проще сориентироваться в информации.

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

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

Роли


В интернете есть много готовых ролей. Их можно найти, например, на Ansible Galaxy или GitHub. Однако мы писали все роли сами, не используя готовые. Подсматривали хорошие решения, штудировали паралельно и документацию, и stackoverflow. Нам так было спокойнее, потому что мы точно знали, что ничего не упустили, и роли делают именно то, что нужно нам, и так, как мы хотим.

Хочу остановиться на процессе создания ролей в нашей команде. Именно на процессе, ведь про тонкости работы с Ansible написано множество статей, да и официальная документация даст вполне исчерпывающий ответ.

Нас много: в команде 9 человек. Все выступают в роли контрибьюторов и вносят свой вклад в создание ролей. В то же время для настройки окружений используется определенный набор ролей и версий.

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

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

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

Мы понимали проблему еще в начале, и решили устранить ее на корню. Благо уже был похожий опыт при работе с Terraform и IaC.

Ansible Roles и Ansible Playbooks — это код, пусть и YAML-код. А любой код должен:
  • Версионироваться
  • Тестироваться
  • Вести учет зависимостей

Проще всего решить проблемы версионирования и учета зависимостей.

Версионирование


Каждая роль хранится в системе контроля версий отдельного репозитория. В нашем случае это Git. Мы не используем универсальные роли, чтобы настроить весь сервер сразу. Одна задача, одна роль, один репозиторий. Тут действует правило: совершенство достигнуто не тогда, когда нечего добавить, а когда нечего убрать.

Да, есть некоторые базовые роли, которые проводят общую настройку и вносят изменения. Например, меняют список репозиториев или проверяют, что временная зона выставлена в UTC. Но это настройки применимые ко всем нашим серверам.
$ tree -L 1 ansible/roles/ops/
ansible/roles/ops/
+-- audit
+-- awslogs
+-- base
+-- dnsmasq
+-- cronjob
+-- filebeat
+-- sudoers
+-- telegraf
+-- zabbix_agent
+-- hardening
+-- ldap_client
+-- mailx
+-- ntp
+-- osquery_rc
+-- proxy
+-- snmp
+-- ssh
+-- symantec
L-- wazuh_agent

Стабильная версия всегда лежит в ветке master. Если понадобится ее изменить, придется отделиться от мастера и создать ветку для изменения, и в ней вносить изменения, проверять, коммитить и пушить. Стараемся придерживаться правила, что на одно атомарное изменение происходит один коммит. Например, шаблон конфигурационного файла для рендеринга и новые переменные, добавленные для него в роль, должны идти одним коммитом с понятным комментарием, что и зачем добавлено. Как альтернатива — номер Jira-тикета с кратким описанием.

Это превращает merge request в master, который назначается на кого-то из коллег. Автор изменения вместе с назначенным коллегой проводят ревью, вносят замечания или правки. Главное, чтобы изменения не вносились не единолично, иначе очень быстро наступит бардак из чьих-то имхо. Еще мы так страхуем менее опытных коллег, помогаем им понять неочевидные моменты.

image

Мы придерживаемся принципа «короткоживущих веток» в git: у нас нет вечных develop, feature и так далее. Одно изменение — одна ветка.

Когда изменение роли завершено, новое состояние мастер-ветки отмечается тегом версии согласно нотации SemVer. Номер зависит от характера внесенных изменений: мажорное или минорное, ломающее обратную совместимость или просто патч.

image

Каждая роль сопровождается подробным README с указанием переменных и ключевых (mandatory) параметров, значений по умолчанию, списка зависимостей и примеров использования.

image

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

Учет зависимостей


Теперь, имея возможность привязаться к конкретной версии роли, мы стали закреплять за окружениями список используемых в нем ролей и версий. Этим мы убиваем двух зайцев: во-первых, инженер, который будет обслуживать это окружение, больше не будет задаваться вопросом, что там должно быть использовано, — все перед глазами. А во-вторых, мы оберегаем себя от описанной выше проблемы расхождения версий. Если сервера А и В настраивались одной версией, то и добавленный позже сервер С также должен быть настроен ей.

Чтобы закрепить этот список зависимостей и избавить инженера от необходимости поиска и установки ролей на свою машину, в репозитории, описывающем инфраструктуру каждого окружения, лежит файл requirements.yml примерно следующего вида:
---
- src: git@SERVER_FQDN:PATH/role_1.git
  scm: git
  version: '1.8.0'

- src: git@SERVER_FQDN:PATH/role_2.git
  scm: git
  version: '0.9.1'

- src: git@SERVER_FQDN:PATH/role_3.git
  scm: git
  version: '2.12.0'

- src: git@SERVER_FQDN:PATH/role_4.git
  scm: git
  version: '1.5.2'
…

Он передается команде ansible-galaxy, которая автоматически ставит необходимые роли нужных версий. Чтобы не возникало путанницы между ролями, вместе с этим файлом в репозитории каждого окружения лежит ansible.cfg. Он сообщаете ansible-galaxy, что роли надо скачивать не в папку по умолчанию, а в поддиректорию roles. Она находится в каталоге окружения. Там же размещается файл gitignore, который просит git игнорировать изменения в этой папке, чтобы скачанная роль не попала в репозиторий окружения.

Еще на шаг ближе к IaC


Вышеописанное скорее всего напомнило вам парадигму IaC. Нам тем более напомнило, ведь мы уже использовали такой же подход к Terraform.

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

Сказано — сделано. Для этого мы использовали каталоги group_vars и host_vars (https://docs.ansible.com/ansible/latest/user_guide/intro_inventory.html#organizing-host-and-group-variables). В них задавали общие для всего окружения или специфичные для определенного сервиса или хоста значения переменных:
$ tree -L 2 group_vars/
group_vars/
+-- all
¦   L-- vars.yml
+-- service_1
¦   L-- vars.yml
L-- service_2
    L-- vars.yml

Конечно, в таком виде можно хранить только не секретные значения: адрес сервера мониторинга или, например, стандартный конфигурационный параметр. Но как быть с логинами, паролями, ключами, токенами и прочими данными, которые нельзя просто так отправить в git-репозиторий?

Есть несколько способов: например, использовать сервисы вроде Hashicorp Vault или локальный Ansible-vault. Мы в итоге остановились на использовании AWS SSM parameter store.

Он безопасный, отказоустойчивый, его не нужно сопровождать. Чтобы получить доступ, нужно авторизоваться в AWS и получить право читать из него. В нем можно хранить как общие значения для всех окружений определенного типа, так и специфичные — в нем можно создать гибкую иерархическую структуру.

image

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

---
ansible_user: root
ansible_password: "{{ lookup('aws_ssm', '/ec2/root-password', region='us-east-1', decrypt=True) }}"
telegraf_influx_server: "influxdb.fqdn"
telegraf_influx_server_port: "8086"
zabbix_agent_package_name: zabbix-agent-2.2.12
zabbix_hosts:
  - server_name.fqdn
zabbix_host_port: "10051"
zabbix_agent_port: "10050"
root_password: "{{ lookup('aws_ssm', '/ec2/root-password', region='us-east-1', decrypt=True) }}"
base_hostname: "{{ inventory_hostname }}"

Теперь репозиторий окружения хранит исчерпывающее описание настроек инфраструктуры — до уровня операционной системы и всех вспомогательных компонент включительно!
Еще раз: один репозиторий = конфигурация одного окружения.

Репозиторий включает в себя:
  • Код Terraform с ссылками на зависимости (модули и их версии)
  • Индивидуальные параметры инфраструктуры (tfvar- файл)
  • Ссылки на общие параметры (remote-states и outputs)
  • Код Ansible со списком ролей и версий
  • Настройки запуска Ansible и все параметры окружения (специфичные для окружения и общие).


Управление списками хостов


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

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

Использование статического inventory не упрощает обслуживание инфраструктуры, поэтому этот вариант оставим на крайний случай.

Мы выбрали dynamic inventory на базе плагина для AWS EC2. Ansible inventory с помощью плагина совершает lookup через AWS EC2 API, используя заданные вами правила и фильтры.
Мы решили привязаться к тегам. Согласно политике нашей компании, любой ресурс в AWS, который можно протегировать, должен быть протегирован. Это удобный способ для таких вещей, как инвентаризация, маркировка, создание наглядного биллинга для облегчения поиска.
О построении схемы тегирования я подробно рассказывал в статье про Terraform. Ниже кратко приведу основные моменты.

У каждого инстанса EC2 есть набор обязательных (это определено на уровне компании для всех проектов) и опциональных (а это уже наша инициатива) тегов. Изначально мы сделали их для биллинга, но оказалось, хорошая схема тегирования может часто пригодиться.

Обязательные теги:
  • Name — уникальное имя ресурса
  • Team — какой команде данный ресурс принадлежит. Очень удобно в Dev-окружениях.
  • Department — в ведении какого департамента находится этот ресурс (из чьего кармана платим): Dev, QA, Ops и так далее.
  • Environment — к какому окружению относится ресурс. Можно, например, заменить его на проект или что-то подобное.

Опциональные теги:
  • Subsystem — подсистема к которой относится компонент.
  • Type — тип компонента: балансировщик, хранилище, приложение или база данных.
  • Component — сам компонент, его название во внутренней нотации.
  • Termination date — дата, когда компонент нужно удалить. Если его удаление не предвидится, ставим «Permanent». У нас в девелоперских и некоторых стейджовых окружениях есть стейдж для стресс-тестирования, который поднимается на стресс-сессии. Мы не держим эти ресурсы постоянно и указываем дату, когда они должны быть уничтожен.
  • Pool — дополнительный признак, чтобы различать сервера одного типа. Например, основной или резервный кластер.


image

Рассмотрим наш Dynamic Inventory. Первым делом используем тег «Environment», чтобы отфильтровать все сервера, входящие в то или иное окружение. Так при внесении изменений в одно девелоперское окружение мы не зацепим другое. Дальше выбираем дополнительные теги — ключевые группы. Например: Component, Pool, Type и другие. Так мы можем дать команду применить изменения, например, по особому набору компонент и определенным базам данных.

plugin: aws_ec2
regions:
 - us-east-1
filters:
 tag:Environment: ENV_NAME
hostnames:
 - tag:Name
 - dns-name
 - private-ip-address
keyed_groups:
 - key: tags.Component
   separator: ''
 - key: tags.Pool
   separator: ''
 - key: tags.Type
   separator: ''
compose:
 ansible_host: private_ip_address

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

Однако одним плагином для построения inventory мы не ограничились. Мы воспользовались плагином constructed, чтобы поиграться со структурой и объединить сервера в мета-группы по удобным нам признакам. Это удобно: не нужно плодить и усложнять структуру тегов. А если признак, на котором основывается объединение в эти мета-группы, может легко и часто меняться, изменение будет автоматически учтено и отражено. Бонусом плагин позволяет создавать красивый вывод ansible-inventory, тем самым предоставляя список ресурсов в более наглядном виде.

Вот так инвентори выглядит без этого плагина:
ansible-inventory --graph
@all:
  |--@aws_ec2:
  |  |--xxx-yyy-srv01
  |  |--xxx-yyy-srv02
  |--@component01:
  |  |--xxx-yyy-srv01
  |--@component02:
  |  |--xxx-yyy-srv02

А вот так с ним:
ansible-inventory --graph
@all:
  |--@aws_ec2:
  |  |--xxx-yyy-srv01
  |  |--xxx-yyy-srv02
  |  |--xxx-yyy-srv03
  |  |--xxx-yyy-srv04
  |--@subsystem_1:
  |  |--@component_1:
  |  |  |--xxx-yyy-srv01
  |  |--@component_2:
  |  |  |--xxx-yyy-srv02
  |--@subsystem_1:
  |  |--@component_3:
  |  |  |--xxx-yyy-srv03
  |  |--@component_4:
  |  |  |--xxx-yyy-srv04


Тестирование


При создании и использовании ролей есть три проблемы:
  • Версионирование
  • Тестирование
  • Ведение учета зависимостей

Первую и третью мы решили, а вот как быть с тестированием — оставалось неясным. Разработка ролей — это разработка программного обеспечения, а оно обязательно должно проходить стадию тестирования, это неотъемлемая часть SDLC (Software Development Life Cycle).

Тестировать роли можно по-разному. Вот сценарии, с которыми сталкивался лично я:
  • Отказ от тестирования — самый худший вариант. У нас нет уверенности, что после внесения изменений роль продолжит работать или будет работать так, как мы этого от нее ожидаем. Вы написали или взяли чью-то роль и просто используете ее, по пути устраняя все возникающие проблемы.
  • Разовое ручное тестирование при изменении. Каждый раз, внося изменение в роль, прогоняете ее вручную или с какой-либо автоматизацией. Например, на установленной на компьютере виртуальной машине. Этот вариант имеет право на жизнь, однако по-хорошему нужно протестировать все возможные сценарии использования роли в условиях вашей инфраструктуры. Это вносит большие накладные расходы и не позволяет проверить идемпотентность роли.
  • Ручная или автоматическая проверка роли на идемпотентность. Тестируем, что ваша роль не просто работает, но и не вносит никаких дополнительных изменений при повторном запуске. Лучше, конечно, в автоматическом режиме. Прикрутите, например, CI, который при каждом изменений будет запускать тест.
  • Автоматическое тестирование идемпотентности и различных условий выполнения роли. Примерно то же, что и выше, но с учетом особенностей среды. Например, учитывая использование нескольких видов и версий дистрибутива операционной системы, разных версий пакетов. Тут не обойтись без автоматизации, потому что проверять каждое изменение вручную в таком случае слишком трудозатратно.
  • Автоматическое тестирование идемпотентности, условий применения и результатов выполнения роли. Все то же самое, что в предыдущем варианте, но с проверкой фактического результата. Успешно отработавшая роль не гарантирует запуск сервиса после ее изменений. Вдруг вы опечатались в шаблоне файла конфигурации или забыли добавить действие по выставлению верного набора прав для файлов. Это, на мой взгляд, самый полный и правильный вид тестирования, но одновременно и самый дорогой.


Как делали мы


Вначале мы использовали недорогой вариант: для каждой роли сделали прогон на тестовой сущности, которая воспроизводила аналогичную сущность в живом окружении и была на нее максимально похожа. Мы реализовали это на базе Docker-контейнеров. В нашей инфраструктуре используется три вида дистрибутивов: Amazon Linux 1, Amazon Linux 2 и Centos 7. У нас есть контейнеры с этими же базовыми образами. Мы брали роль, прогоняли ее для каждого вида ОС в параллели и смотрели, что она выполнилась успешно и не упала. Прогоняли второй раз, чтобы убедиться, что она идемпотентна и не вносит дополнительных изменений. Это самое простое и дешевое тестирование, которое можно сделать. Да, оно не идеально, но уже дает вам уверенность в том, что роль будет успешно завершена и сможет многократно применяться на существующих ресурсах без риска сломать что-либо.

image

Прогонять эти тесты вручную нам не хотелось. Мы давно используем CI для выкатки изменений Terraform и решили, что тестированием будет заниматься компьютер. Но как это автоматизировать, чтобы все, что не относится напрямую к разработке роли действия, делалось компьютером, а не человеком?

На каждый коммит, уходящий на сервер GitLab, запускается простой pipeline, который тестирует измененную роль. Когда инженер хочет внести изменение в роль, он создает новую ветку для изменения, пишет код, делает commit изменений и push на сервер. На сервере по событию запускается pipeline, который состоит из минимум трех этапов:

  • Вначале идет lint (статический анализ кода) — проверяет корректность синтаксиса, соответствие best practices, отсутствие очевидных ошибок и расхождений.
  • Второй шаг — тест на безошибочное применение роли и повторное применение с тестированием идемпотентности, как описано выше.
  • Третий шаг — прогон дополнительных тестов. Например, не установка пакета на чистую систему, а апгрейд или даунгрейд, удаление пакета или установка без настройки.

image

Скажем, у нас есть роль установки агента системы мониторинга. Она должна уметь, как минимум, три вещи:
  • Просто установить пакет без настройки
  • Поставить, настроить и запустить
  • Удалить и подчистить «хвосты»

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

Линтовка происходит единожды, потом осуществляется прогон дефолтных тестов. Они установят, что роль накатилась успешно и идемпотентно для всех используемых платформ.

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

image

Если роль успешно и идемпотентно применена, не значит, что сервис запустится. Это нужно дополнительно проверять с помощью другого интсрумента. Мы остановились на фреймворке для тестирования Molecule.
image
В Molecule нет ничего сложного. Кроме того, мы не решали каких-то сверхзадач, а просто взяли стандартные примеры из документации и реализовали развертывание тестовой инфраструктуры, установку роли и запуск тестов. Тесты довольно простые, они выглядят буквально таким образом:

# Ensure SELinux is not disabled in bootloader configuration
def test_selinux_is_not_disabled_in_bootloader(host):
    f = host.file('/boot/grub2/grub.cfg')
    assert not f.contains("selinux=0")
    assert not f.contains("enforcing=0")

Тест, который проверяет включен ли SELinux.
# Ensure telnet server is not enabled 
def test_service_telnet(host):
    s = host.service('telnet')
    assert not s.is_enabled

# Ensure tftp server is not enabled 
def test_service_tftp(host):
    s = host.service('tftp')
    assert not s.is_enabled

Тест, который проверяет, что у нас выключен уязвимый сервис.
# Ensure IP forwarding is disabled (Scored)
def test_ip_forwarding_is_disabled(host):
    v1 = host.sysctl('net.ipv4.ip_forward')
    assert v1 == 0

    v2 = host.sysctl('net.ipv6.conf.all.forwarding')
    assert v2 == 0

Тест, который проверяет, что параметр ядра проброшен и включен.

Мы создали для Molecule максимально близкие к реальности условия. Она запускает настоящий EC2 инстанс — небольшой, но достаточный для проведения тестов. После них он автоматически уничтожается.

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

Сопровождение SWE


Мы решили взять сопровождение SWE в свои руки. У нас были почти все необходимые для этого инструменты: Ansible для кастомизации образа, Molecule для тестов, GitLab CI автоматизирования выкатки. У нас не было только инструмента для подготовки образов, и мы взяли Packer.

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

Мы реализовали подготовку SWE/AMI в три этапа.

image

На первом этапе прошло создание базового образа AMI. Мы взяли за основу один из «чистых» AMI от Amazon, и запустили псевдо-инстанс с помощью «amazon-ebssurrogate». Потом мы подключили к нему тома, шифруемые средствами AWS, разбили их на удобные разделы и установили на них базовый образ AMI (Amazon Linux 2).

На втором шаге Packer запустил инстанс уже на основе нашей базовой AMI, скачал и запустил необходимый набор ролей Ansible с набором значений по умолчанию и тегами, которые приводят к некоторой базовой установке и настройке дополнительных компонент и самой ОС. На этом же шаге был настроен cloud init, который производит первичную настройку при первом запуске виртуальной машины, созданной из этого AMI. Например, пропорционально увеличивает размеры дисковых разделов. После этого из полученной конфигурации создается второй готовый к работе AMI.

Тут можно реализовать еще один этап, когда на базе готового универсального AMI создается еще один для специфического приложения. В него, например, можно «вшить» runtime с зависимостями или установить и первично настроить движок базы данных.

И, наконец, на третьем этапе мы взяли готовую AMI и протестировали ее с помощью Molecule. Она запустила EC2 Instance, дождалась его запуска и прогонала по нему набор тестов.
И тут мы получили интересный бонус.

Когда мы раньше создавали AMI-SWE, Security просили передать его им для теста. Теперь, имея свой инструмент, мы запросили требования для проверки и примеры, чтобы автоматизировать тестирование. В число требований Security входит CIS benchmark. В нем описано, что должно быть настроено, как это проверить, и даже есть готовая утилита, которая потом запускается на машине и позволяет построить отчет.

image

Мы взяли набор требований и набор тестов, которые были у Security. Тесты были написан на bash-скриптах, и мы все это дело переписали на Molecule. Мы реализовали требования Security, где-то докрутив наши роли, где-то сделав отдельную роль чисто под hardening. Все тесты добавили в Molecule и настроили отправку отчета в чат с командой Security. В конце был запуск их утилиты аудирования, которая просто строит отчет.

image

В итоге у нас есть два этапа, состоящие из ряда шагов:
  1. Работа Packer вместе с Ansible, создание AMI
  2. Тестирование с помощью Molecule и отправка уведомления в чатик с Security

После этого Security или подтверждают, что pass-rate тестов нормальный, или отправляют на доработку. Процесс, который занимал раньше неделю-полторы, занимает ничего (если не считать прочтение отчета).

image

Интеграция с Terraform и инфраструктурным CI


Мы живем с полностью автоматизированным применением инфраструктурных изменений через связку Terraform + Gitlab CI. Как мы реализовали этот процесс — читайте в моей предыдущей статье.

В репозитории каждого окружения хранятся код Terraform с его описанием (со ссылкой на файл tfstate, находящийся в S3) и настройки для CI. Для каждого окружения это свой файл, а для некоторых их бывает несколько.

Раньше наши Ansible-роли жили отдельно от репозиториев окружений, не считая связи через файл Requirements. Меняя конфигурацию, инженер должен был взять эти роли и применить их. Список и версии были ему известны. Но в рамках одного окружения, как правило, были инстансы, развернутые из разных SWE, и для каждого окружения нужно было держать свой requirements.yml, да еще помнить какой для чего. Ужас!

image

Мы решили, что хватит это терпеть!

В примерно в это же время у нас появились репозитории, содержащие конфигурацию и CI для создания SWE. Вот он — идеальный источник истины. Не нужно хранить в репозитории окружения списки ролей для разных SWE, если он и так лежит в репозитории этого SWE. Еще и вместе с playbook, производящим настройку. Просто бери и используй, создавай один единственный source of truth!

image

Мы решили применить Git submodules, чтобы организовать эту связь и сделать ее строго версионируемой и отслеживаемой. Возможно, это и не единственное решение, но нас оно пока устраивает.

Сейчас я расскажу, как это работает у нас, а если у вас будут идеи лучше — пожалуйста, поделитесь, это будет очень полезно.

image

В репозитории каждого окружения настраиваются ссылки на репозитории SWE, используемых в этом окружении. Результатом настройки становится появление в корне проекта файла gitmodules примерно вот такого содержания:

[submodule "legacy-swe"]
    path = legacy-swe
    url = https://git.FQDN/PATH/legacy-swe.git
[submodule "common"]
    path = common
    url = https://git.FQDN/PATH/common-swe.git

Кроме этого появляются каталоги, содержимое которых является отображением тех самых репозиториев на момент определенного коммита:

image

Помимо того, что у нас есть связь из всех зависимых проектов с единым источником истины, каждый из них еще и привязан к конкретной версии нашего SWE-репозитория. Сдвинуть эту привязку можно только принудительно, переключив ее на определенный коммит. Тем самым мы подстраховались на случай, если кто-то внесет изменения в наш SWE-репозиторий, сломает какую-нибудь обратную совместимость и у нас все «поедет».

Однако, к сожалению, gut submodules не умеют работать с тегированием или иначе документировать эту связность. Сходу непросто понять, к какой версии мы привязались, и что нужно в репозитории поменять, если мы хотим переключиться на более свежую версию источника.

Мы решили этот вопрос достаточно просто — теперь репозитории, содержащие конфигурацию SWE, снабжены аккуратно поддерживаемым файлом CHANGE.log. В случае переключения субмодуля нам достаточно сравнить этот файл для двух коммитов: на котором мы находимся сейчас и на которой планируем переключиться. Так мы поймем, нет ли необходимости что-то обновить в репозитории окружения. Например, в новой версии SWE используется новая версия какой-нибудь Ansible-роли, которая требует новых переменных, или в которой переименовываются старые или меняются дефолты. Нас спасет сравнение версий Change.log. Вот пример:

image

Кто ведет эти изменения? На этот вопрос внутренняя страница с описанием процесса имеет вполне конкретный ответ:
Q: Who should add the description of changes to the CHANGELOG file?
A: The person who is preparing the merge request. Besides the planned changes, the CHANGELOG file should be updated as well.

В конечном итоге мы это сделали для всех SWE и окружений, добавили в CI, и получилась вот такая (немного пугающая) картинка:

image

Но если немного разобраться, ничего сложного тут нет.

Изначально у нас присутствует только красная часть схемы, за исключением YML-файлов, git submodules и конфигурационного файла для Ansible — это репозиторий, содержащий инфраструктурный код Terraform для данного окружения.

Далее у нас появляются ярко-фиолетовые элементы — это Ansible-роли, каждая в своем репозитории.

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

Завершающим этапом становится появление связки из git submodules с SWE-репозиторием. В репозиторий окружения добавляется файлы с конфигурацией inventory, Ansible, субмодули и папки group vars. В ней содержатся общие конфигурационные значения для всех элементов окружения и отдельные добавки или переопределения на уровне тех сервисов-компонент, кому они нужны.

За счет связующего звена в виде git submodules у нас появляются «призрачные» сущности requirements и playbook — каждая для своего SWE, равно как и список ролей.
Когда Terraform выкатывает конфигурацию, в CI запускается команда, которая через подходящий для SWE субмодуль скачает все необходимые роли и плейбуки.

Теперь, когда инженер создает новые ресурсы с помощью Terraform, он коммитит изменения в репозиторий окружения и запускает CI:

image

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

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

Заключение


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