Введение
Каждый из нас так или иначе живет бок о бок с инфраструктурой. У каждого она своя — у кого то все уже модно и в кубере, а кто то по старинке держит все на железных серверах. Кто-то живет в облаке, а кому-то милей родной датацентр.
Я хочу рассказать Вам о том, что мы понимаем под инфраструктурой и как мы облегчили себе работу с ней, автоматизируя ее поддержку и выполнение операционных задач с помощью Ansible, Molecula, Docker, Gitlab CI и Packer .
Как я уже писал выше- у каждого инфраструктура своя. Неповторимый дизайн, любовно окучиваемое легаси и еще тысяча и одна индивидуальная черта, делающая ее непохожей на чью либо другу. Чтобы прийти с вами к единому понимаю, быть так сказать на одной волне, я вначале кратко опишу что мы подразумеваем под термином “инфраструктура” и с какого рода и размера инфраструктурой я и моя команда имеем дело каждый день.
О чем я НЕ буду рассказывать:
- о том, как мы выбирали инструменты — мы просто их выбрали, так решили, у каждого из читателей, может быть свой выбор и свое мнение на этот счет;
- не буду заниматься сравнением выбранных инструментом с их возможными аналогами — мы пользуемся тем, что у нас есть в проекте и тем что мы выбрали. Обзоров и сравнений на просторах интернета есть огромное множество — не вижу смысла делать еще один.
- не буду писать про primitives ansible и что-либо, что здесь будет упоминаться, и как начать с ними работать.
Всё, что здесь упоминается — это довольно простые вещи, мы не выходили никуда за рамки документации для этих инструментов, здесь никакого rocket science, каких-то крутых work-around. Всё, что здесь описано, можно найти, посмотреть, почитать в официальной документации, и я не вижу смысла на этом заострять внимание.
Эта статья является логичным (а может не очень) продолжением предыдущей, где я рассказывал про наш опыт работы с Terraform (https://habr.com/ru/company/dins/blog/470543/). Так этот инструмент входит в наш стэк управления инфраструктуры и ряд приемов и практик мы уже освоили на примере работы с ним, поэтому по ходу статьи будет достаточно много отсылок.
Итак, приступим…
Инфраструктура
Одна из основных наших задач, как Operations команды является поддержка инфраструктуры проекта. В нашем случае речь идет как про окружения разработка так и про Production окружения. Чуть ниже я покажу список всего, во что нам приходится запускать свои натруженные мозолистые руки, а пока поверьте на слово — их много и они очень разные.
Наш проект живет в облаке Amazon (AWS), поэтому все, что я тут буду рассказывать мы испытывали для наших облачных реалий. Хотя мне кажется что оно также будет справедливо как для пользователей других облаков, так и для тех, кто живет в своем ДЦ.
Возможно я буду не прав, но когда я смотрел какие-то курсы, обучающие видео или доклады на тему хоть как то касались управления инфраструктурой, там это выглядело так:
- “Берете вы значит свою инфраструктуру, обмазываете ее популярной системой управления конфигурацией, не забудьте сохранить это все в Git — инфраструктура как код же, все дела… И получаете счастье!”. Вместо популярной системы подставьте нужное.
Нет, так это не работает в мало-мальски большом проекте. Может если у вас вся инфраструктура это десяток виртуальных машин у какого-нибудь хостера, которые живут так годами — тогда да. У нас тут все “чуть чуть” сложнее… Хотя на звание самой сложной инфраструктуры мы тоже не претендуем — у ребят из Яндекса из ВК жизнь явно веселей.
Так вот, на момент написания этой статьи у нас в общей сложности более полутора тысяч виртуальных серверов, неравномерным слоем “размазанных” по более чем полусотни окружений, которые в свою очередь распределены по десятку VPC а те — по нескольким AWS аккаунтам. Не мы такие, жизнь такая. Так бизнесу удобно с точки зрения безопасности, процессов разработки, бюджетов и биллинга, а нам с этим надо жить.
- “Ну подумаешь, всего каких то полторы тысячи серверов” — скажет кто то. — “Я у себя могу на ноутбуке столько поднять и настроить парой ролей и что” (шутка конечно. Про ноутбук, не про ход мыслей)
Дьявол как всегда кроется в деталях…
Во первых эти сервера могут иметь разные версии Операционных Систем. Не мне вам объяснять как порой в одном проекте могут вместе уживаться разные дистрибутивы Linux, а даже если он один — вполне могут быть разные версии одного и того же дистрибутива.
Во вторых, эти сервера — это разные сервера приложений, баз данных, инфраструктурные вещи и тому подобное. На них помимо основного приложения могут быть установленными ( и обычно так и есть) довольно большой список вспомогательного программного обеспечения — от агентов мониторинга и сбора логов, до каких то вещей, специфичных для команды Security.
В третьих — разные окружения, разные типы окружений, разные аккаунты и тп вносят свою специфику — девелоперские окружения, регрессии, стресс окружения, стейджинги, UAT, продакшен, демо стенды и тп — часто они представляют из себя “снежинки” с абсолютно нетиповой, “кастомной” конфигурацией (мы боремся с этим где можем, и это тема для отдельного доклада как мы это делаем. Тем не менее где то может быть только так и никак иначе и тут уже ничего не попишешь). Суть в том. что у каждого из них есть своя специфика и ее нужно учитывать в управлении. Нельзя просто взять и перетащить конфигурацию с одного места в другое и ждать, что она сходу заведется. Нет, такого не будет.
Ну и вишенка на торте — это горячо любимое нами Legacy и технический долг. Есть вещи которые достались проекту “в нагрузку” с тех пор как он был стартапом, есть что то что устарело просто в силу времени и тд и тп.
Поддерживать все это в ручном режиме, никак не автоматизируя и не стандартизируя нельзя. Это дорого, это вносит дополнительный хаос. Проблемы будут расти как снежный ком, пока в какой то момент команда уже будет не в состоянии поддерживать то, что есть и даже привлечение новых людей ( затыкание технического долга человеческим ресурсом) не поможет — новичкам нужно будет долго и муторно рассказывать почему тут сделано так а не иначе, членам команды будет стыдно за “Авгиевы конюшни”, кто то со временем будет все сильней и сильней демотивироваться и перегорать и в конце концов уйдет. Короче с рутиной надо что-то делать.
Отлично, про это я тут вам нажаловался, теперь — что же мы наконец подразумеваем под этой самой пресловутой “инфраструктурой”, черт побери?
Вот на этой схеме видно, что из всего стека мы называем инфраструктурой.
Нам немного повезло в том плане, что первые три уровня для нас закрывает Амазон.
Мы о них не переживаем, нам они просто предоставляются как часть сервиса AWS. Четвертый уровень закрывает Terraform — это первая отсылка к предыдущей статье (то, как мы этим управляем с помощью Terraform, я там подробно рассказывал).
Итак, остается пятый уровень, о проблемах с которым я и писал выше. До этого он у нас не был чем либо покрыт, либо был покрыт частично. Управление им, его сопровождение периодически вызывало множество вопросов, множество проблем, требовало затраты многих сил как меня, так и членов моей команды.
Еще один небольшой нюанс — внимательный читатель наверняка уже заметил странную аббревиатуру SWE. Скорее всего вы уже сталкивались с этим, просто у вас оно зовется иначе.
Итак — SWE или SoftWare Environment — программное окружение, в котором будут жить и работать наши приложения. Оно включает в себя:
- Дистрибутив операционной системы с стандартным набором пакетов и обновлений, вышедших к определенному моменту
- Версию ядра
- Дополнительные пакеты, необходимые нам для обслуживания
- Тюнинг различных настроек, в том числе производительности и безопасности
- Щаблон разметки дискового пространства и набор файловых систем
По сути это некоторый слепок преднастроенной операционной системы, который у нас заказывают разработчики. Вначале он поступает в лабораторные окружения, где на базе него идет разработка, потом во все прочие — регрессию, стейджинги и продакшен.
Мы реализуем это на базе AMI (Amazon Machine Images), однако подобная шаблонизация вполне применима в любой инфраструктуре как мне кажется.
Вопрос в том, что с одной стороны- использование таких образов снимает с нас достаточно большой пласт проблем, ведь мы четко знаем что для каждой компоненты у нас есть свой, уже сформированный runtime, со всеми необходимыми зависимостями. Однако на практике, как я уже говорил — нам приходится довольно часто и много изменять мелкие настройки этих образов при деплойменте в разные окружения (как минимум входные точки для систем авторизации, мониторинга и сбора логов в той же лабе будут отличаться от продакшена). Плюс создание, сопровождение и обновление этих образов- тоже не самая тривиальная задача.
В какой то момент мы приняли решение — “хватит это терпеть” и начали что-то делать.
Проблемы (зачеркнуто) Задачи
Итак, если подвести краткий итог всем нашим страданиям, вы выделили для себя 3 набора задач, которые необходимо было решить:
- Доставка конфигурации на сервера. Казалось бы — взяли любимую систему управления конфигурацией, шаблонизировали конфиги — и поехали. НО! Большое число серверов, динамичность их появления и удаления. Разные дистрибутивы или разные версии дистрибутива — у нас сейчас их как минимум три. Разные рантаймы приложений (java, nodejs, python, etc…), разные типы компонент (базы данных, очереди, сервера приложений, кеши и тп). Все это собирается в различных комбинациях и этим как-то нужно управлять.
- Доставка конфигураций, специфичных для окружения. Как я уже говорил- окружений у нас много, они разные и в каждом своей специфики хватает. Держать это в голове — плохо. Держать в документации и каждый раз в нее лазить и вычитывать — можно но не хочется. Ресурсы времени и мозга они довольно ограниченные. Хочется один раз описать это дело для окружения и чтобы оно потом само учитывало нюансы. Ну или предупреждало, если что-то изменилось, пошло не так и требует пристального внимания.
- Сопровождение SWE. Эти образы нужно создавать, проверять-тестировать, обновлять. Нам могут прийти новые требования от разработчиков, от security, от других отделов — их нужно учесть и включить не только в новые образы но и в текущие. И тут помимо прочих — есть одна загвоздка. Мы не можем просто взять и выкатить обновление на все сервера когда нам вздумается, особенно в продакшене. А если мы что-то обновляем на работающих серверах, согласно процессу у нас должен быть четкий план проверки и план отката на любое вносимое изменение. По большей части это ограничения процессов, которые обоснованы юридически. Тем не менее — это тоже нам нужно учитывать.
Ложку дегтя нам добавлял еще тот факт, что до тех пор, пока мы не занялись самостоятельным решением этих вопросов, полностью или частично он лежал на плечах других команд, что вносило дополнительные организационные издержки — необходимы дополнительные коммуникации, у каждой из команд свои задачи, свои приоритеты, свой темп работы. Совсем другие инструменты и процессы, которые нам неподконтрольны, нами не управляются, не поддерживаются и не совсем нас устраивают.
Например сопровождение SWE — этот процесс у ребят был полу ручной — после изготовления нового SWE, должна была быть развернута тестовая машина на его базе, передана команде security, где они в свою очередь используя свои руководства и инструменты должны были провести его тестирование и дать формальный “ок” на использование этого образа. Такой процесс является для нас непрозрачным, плохо оцениваемым по времени и в целом не удобным.
Как вы понимаете вот из таких мелочей складывается ежедневная рутина — долгая, сложная, с кучей мест где можно “споткнуться”.
Не зная страха, не ведая преград
Помимо всего прочего, прежде чем начинать решать эти задачи мы решили сформировать список требований к набору инструментов:
- Один инструмент там где это возможно, либо разумный минимум.
- Возможность запуска большого числа параллельных задач, с сохранением гибкости (в любой момент разбить на группы и подгруппы, запуск для одного экземпляра и тд)
- Иметь возможность использовать API облачного провайдера для инвентаризации ресурсов. Худшее что можно придумать это вести вручную или в каком-то полу-автоматическом режиме базу инвентаризации хостов в постоянно меняющейся среде. Сегодня у нас может быть 10 серверов этого типа, а завтра мы решаем что нам их надо уже 50 и еще мы введем им дополнительное разделение по какому-то признаку.
- Интеграция с CI или возможность легко встроить инструмент в CI pipeline. Мы уже управляем инфраструктурой при помощи Terraform, выкатывая изменения через CI (это вторая отсылочка к тому моему докладу). Нам казалось логичным, если мы закрываем последний уровень управления инфраструктурой почему бы тоже это дело с CI не интегрировать, чтобы это все приезжало единой пачкой? Это выглядит естественно.
Что мы начали решать? Первое: мы выбрали инструмент. Наш выбор пал на Ansible. Почему — вопрос отдельный, он за скобками доклада.
Первым же делом, мы получили бонус в виде удобного средства параллельного выполнения команд и задач на наших серверах с динамическим инвентори. Достаточно было просто настроить dynamic inventory через AWS API и привязаться к тегам ресурсов, как нам больше не пришлось городить какие то странные конструкции с помощью Paralell SSH и bash-порно, чтобы решать какие-то типовые задачи.
Как например добавление на все машины нового сервисного пользователя или обновление пароля root и тп. Или замена значения в строке конфигурационного файла (тут речь не про то, чтобы сгенерировать и подложить новый конфиг, а действительно когда надо поменять просто одну строчку).
Давайте на примере:
Замена строки в файле — Ansible (в формате ad-hoc команды)
1 2 3 4 5 |
ansible ${SERVER_GROUP} -m replace \ -a "path=${CONFIG_PATH} \ regexp='ORIGINAL_STRING' \ replace='NEW_STRING' \ backup=yes" |
То же самое — bash + pssh.
1 2 3 4 5 |
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, не найдя оригинальной строки, ничего не изменит, но бэкап будет создан лишний раз.
Или создание сервисного пользователя — Ansible (в формате ad-hoc команды)
1 2 3 4 5 6 |
ansible ${SERVER_GROUP} -m user \ -a "name=${USER_NAME} \ groups=${GROUP_LIST} \ shell=/sbin/nologin \ append=yes \ state=present" |
То же самое — bash + pssh.
1 2 |
pssh -h ${SERVER_GROUP}_hosts_files -- \ useradd -G ${GROUP_LIST} -s /sbin/nologin ${USER_NAME} |
Опять же — не идемпотентно. Да, при повторном запуске новый юзер не будет создан, но мы будем получать ошибку о том что такой пользователь существует, в то время как Ansile просто проинформирует нас что ему нечего менять. Полезно когда вы добавляете в группу серверов новых участников и нужно провести донастройку — меньше информационного шума для операторов.
То есть просто выбрав хороший правильный инструмент мы сразу получили нативный для облака динамический инвентори и возможность идемпотентного и прозрачного(с точки зрения оператора) выполнения команд. Неплохо да?
Команды это конечно хорошо, но изначально мы не этого хотели. Итак, чтобы решить задачи доставки конфигураций, специфичных для серверов и окружений, а также управления SWE мы очень быстро перешли к созданию собственных ролей, чтобы затем, соединяя их в playbooks получать сценарии настройки серверов и окружений.
Роли
Да, в интернете огромное множество готовых ролей — Ansible Galaxy и GitHub (например ) станут вам отличным источником готовых ролей или примеров, но мы пошли по пути создания своих велосипедов и все роли писали сами, не переиспользуя готовые. Подглядывали — да. Подсматривали хорошие решения, штудировали паралельно и документацию, и stackoverflow. Хорошо это или плохо – вопрос другой, но нам так было спокойнее, потому что мы точно знали, что ничего не упустили, что роли делают именно то, что нужно нам и так, как мы хотим.
Хочу немного остановиться на процессе создания ролей. Именно на процессе — как правильно сделать роль для Ansible, как и в принципе про его использование написано множество статей, да и официальная документация даст вполне исчерпывающий ответ.
Нас много — команда состоит из 9 человек. Так или иначе все члены команды выступают в роли контрибьюторов, внося свой вклад в них. В то же время, для настройки того или иного окружения был использован определенный набор ролей, определенных версий.
Представьте ситуацию — вы скачали из репозитория некоторый набор ролей, написали на их базе playbook и настроили окружение. Далее проходит время, Ваш коллега расширяет или каким либо еще образом видоизменяет это окружение и ему нужно повторно произвести настройку вновь развернутых узлов. Он абсолютно так же скачивает из репозитория актуальную версию роли и применяет ее.
Проблема в том, что между моментом когда вы использовали эти роли и моментом как их применял ваш коллега — прошло какое то время и роли могли претерпеть значительное изменение. Например могла быть сломана обратная совместимость — список, наименование входных параметров, логика работы, версии устанавливаемых и настраиваемых ролями компонент и т.д. Чем это грозит? Тем что окружение может быть сломано или в лучшем случае — прийти в не консистентное состояние.
Еще более зловещими красками эта проблема начинает играть в случае, как у нас- много окружений, много специфики, время когда роль доберется из лабы в прод, при том что в лабе на продолжает использоваться чаще ( окружений то больше), и вероятность внесения изменений выше.
Мы прекрасно осознавали проблему еще в самом начале, поэтому решили убить проблему на корню. Благо уже был опыт, полученный при работе с Terraform ( еще одна отсылочка) и практики IaC.
Ваши Ansible roles и ansible playbooks — это код. Пусть это YAML код но тем не менее. А любой код должен:
- Версионироваться
- Тестироваться
- Вестись учет зависимостей
Проще всего решить первую и последнюю проблему — версионирования и учета зависимостей.
Версионирование
Каждая роль хранится в отдельном репозитории, в системе контроля версий. В нашем случае это Git. Никаких “универсальных ролей чтобы сразу бах и весь сервер настроить”. Одна задача — одна роль — один репозиторий. Да, есть некоторые “базовые” роли, которые проводят общую настройку,внося более чем одно изменение — например меняют список репозиториев, проверяют что временная зона выставлена в UTC и т.п., отключены ненужные службы, но это прям самая-самая база. Это то, что действительно можно применить ко всем нашим серверам. Тут скорее вместо желания “включить туда все что только можно”, действует обратное правило — “Совершенство достигнуто не тогда, когда нечего добавить, а когда нечего убрать.”
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
$ 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 └── wazuh_agent |
В итоге, это все превращается в merge request в мастер, который назначается на кого-то из коллег. Автор изменения, совместно с ним проводят ревью, вносят замечания, правки и т.д. Главное чтобы не было бесконтрольного внесения изменений в одно лицо, иначе очень быстро в репозитории наступит бардак из чьих-то имхо. Плюс так мы страхуем наших менее опытных коллег, помогаем им с пониманием каких-то не очень очевидных моментов.Стабильная версия всегда лежит в ветке master, если же кто-то из моих коллег захочет внести изменение — он создает ветку для изменения, отделяясь от мастера, готовит изменения, проверяет и коммитит, пушит. Стараемся придерживаться правила — один коммит на одно атомарное изменение. Например — шаблон конфигурационного файла для рендеринга и новые переменные, добавленные для него в роль должны идти одним коммитом с понятным, внятным комментарием — что и зачем добавлено. Как альтернатива — номер Jira тикета с кратким описанием.
Если вы обратили внимание, в нашей модели работы мы придерживаемся тн “короткоживующих веток” в git — нет никаких вечно живущих dvelop, feature и тп. Изменение- ветка.
После того, как изменение роли завершено (в некоторых случаях это может не ограничиться одним MR), новое состояние мастер ветки отмечается тэгом версии, согласно SemVer нотации — номер меняется от характеры внесенных изменений, было ли это мажорное изменение, ломающее обратную совместимость, минорное или просто патч.
Помимо этого каждая роль сопровождается подробным README с указанием переменных-параметров, значений по умолчанию, пометками какие параметры являются ключевым (Mandatory), списком зависимостей если таковые есть и примерами использования.
Все здесь описанное может показаться очевидными вещами многим из читателей, однако как показывает практика проводимых собеседований, знать то все знают, или догадываются а вот применять это многие не применяют.
Учет зависимостей
Теперь, имея возможность точно привязаться к конкретной версии роли, мы стали закреплять за окружениями список используемых в нем ролей и версий. Этим мы убиваем сразу двух зайцев — во первых, инженер, который будет обслуживать это окружение больше не будет задаваться вопросом что там должно быть использовано — вот, все перед глазами. А во вторых мы оберегаем себя от описанной выше проблемы расхождения версий. Если сервера А и В настраивались одной версией, то и добавленный позже сервер С также должен быть настроен ей.
Чтобы закрепить этот список зависимостей и избавить инженера от головной боли по поиску и установки ролей на свою машину, в репозитории описывающем инфраструктуру каждого окружения лежит файл requirements.yml, примерно следующего вида:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
--- - 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 каталоги, в которых задавали значения переменных общие для всего окружения или специфичные для определенного сервиса или хоста:
1 2 3 4 5 6 7 8 |
$ tree -L 2 group_vars/ group_vars/ ├── all │ └── vars.yml ├── service_1 │ └── vars.yml └── service_2 └── vars.yml |
Конечно в таком виде можно хранить только какие-то не секретные значения, как например адрес сервера мониторинга или какой-нибудь стандартный конфигурационный параметр. Но как быть с логинами, паролями, ключами, токенами и пр вещами, которые нельзя вот так просто взять и отправить в git-репозиторий?
Есть несколько способов — например использовать сервисы типа hashicorp vault иди локальный ansible-vault, однако мы в итоге остановились на использовании AWS SSM parameter store.
Он безопасный, отказоустойчивый, нам не нужно его сопровождать, доступ можно получить только авторизовавшись в AWS и имея права читать из него. В нем можно хранить как общие значения для всех окружений определенного типа, так и специфичные — он поддерживает довольно гибкую иерархическую структуру.
Вот тут отлично видно как общие переменные, так и специфичные для какого то конкретного окружения.
Реализуется это всё крайне просто — в общем файле переменных или в файле сервиса, перечисляются используемые переменные — какие то с конкретными значениями, какие то с подключением в ssm:
1 2 3 4 5 6 7 8 9 10 11 12 |
--- 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 с ссылками на зависимости (модули и их версии), включающий индивидуальные параметры инфраструктуры (tfvars файл) и ссылки на общие параметры (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 — дополнительный признак чтобы различать сервера одного типа — например основной и резервный кластер.
Как выглядит наш Dynamic Inventory — первым делом мы используем тег «Environment» для того, чтобы отфильтровать все сервера, входящие в то или иное окружение. Для того, чтобы когда мы вносим изменения в одно девелоперское окружение, мы не зацепили другое. Дальше мы выбираем дополнительные теги, как ключевые группы, например: Component, Pool, type и сюда можно добавить еще, сколько вам будет угодно. И таким образом можем дать команду прокатить изменения, например, по такому-то набору компонент по таким-то базам данных.
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
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, тем самым предоставляя список ресурсов в более наглядном виде.
Вот так инвентори выглядит без этого плагина:
1 2 3 4 5 6 7 8 9 |
ansible-inventory --graph @all: |--@aws_ec2: | |--xxx-yyy-srv01 | |--xxx-yyy-srv02 |--@component01: | |--xxx-yyy-srv01 |--@component02: | |--xxx-yyy-srv02 |
А вот так с ним:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
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 Linux2, и Centos 7. Соответственно, у нас есть контейнеры с этими же базовыми образами, мы брали эту роль, прогоняли ее первый раз для каждого вида ОС в параллели, смотрели, что она выполнилась успешно, не упала; прогоняли второй, смотрели результат — что она не внесла дополнительных изменений, то есть она идемпотентна. Это самое простое и дешевое тестирование, которое можно сделать. Да, оно не идеально, но это лучше, чем ничего, оно дает вам уже хоть какую-то уверенность.
Прогонять это вручную нам не хотелось. Как я уже говорил, мы давно и прочно используем CI для выкатки изменений Terraform, и мы решили, что «тестировать вручную – нет», пусть это сделает компьютер. Как это сделать, чтобы еще и человек не ленился?
Пусть на каждый коммит, уходящий на сервер GitLab запускается простой pipeline, который будет это дело тестировать. Соответственно, когда инженер хочет внести какое-то изменение в роль, он создает новую ветку для изменения, пишет код, делает commit изменений, push на сервер. На сервере по событию запускается pipeline, который состоит из минимум 3 этапов:
- Вначале идет lint (статический анализ кода) — проверяет корректность синтаксиса, соответствие best practices, отсутствие очевидных ошибок и расхождений.
- Второй шаг — тест на безошибочное применение роли и повторное применение с тестированием идемпотентности, как описано выше.
- Третий шаг- прогон дополнительных тестов. Например не установка пакета на чистую систему, а апгрейд или даунгрейд, удаление пакета, установка без настройки и тп.
Например, у нас есть роль установки агента системы мониторинга — она должна уметь, как минимум, три вещи:
- Просто установить пакет, без настройки;
- Поставить, настроить и запустить,
- Удалить, подчистив все хвосты.
В чем еще плюс CI – мы можем это сразу, в одно действие прогнать по всем используемым нами дистрибутивам, минимизируя какую-либо ручную работу для оператора. То есть, линтовка происходит единожды, потом просто прогон каких-то дефолтных тестов, что роль накатилась и накаталась идемпотентно, второй шаг сразу параллельно. И дополнительные тесты – вот они, пожалуйста, третьим. Всё. Это занимает несколько минут буквально, и потом, если ничего тебе в почту не упало, значит все хорошо. Если что-то упало – садись, разбирайся, и не смей это все дело вливать в мастер. Позови коллегу, разберитесь вдвоем.
Однако, все это конечно хорошо, тот факт, что роль была успешно и идемпотентно применена, не гарантирует вам, что настроенный вами сервис запустится. Об этом я уже тоже упоминал.
Поэтому нужен инструмент, при помощи которого можно было проверить, что еще и после всех этих действий у нас сервис корректно запустится. После некоторых изысканий мы решили для себя остановиться на таком фреймворке для тестирования, как Molecule.
C одной стороны в Molecula нет ничего сложного а с другой, мы не решали каких-то сверхзадач, а просто взяли стандартные примеры из документации, и реализовали развертывание тестовой инфраструктуры,установку роли и запуск тестов. Тесты довольно простые, они выглядят буквально таким образом:
1 2 3 4 5 |
# 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 не выключен;
1 2 3 4 |
# Ensure telnet server is not enabled def test_service_telnet(host): s = host.service('telnet') assert not s.is_enabled |
1 2 3 4 |
# Ensure tftp server is not enabled def test_service_tftp(host): s = host.service('tftp') assert not s.is_enabled |
проверить, что у нас выключен уязвимый сервис;
1 2 3 4 5 6 7 |
# 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 |
проверить, что у нас параметр ядра проброшен, включен.
Для тестирования molecula мы решили быть максимально близки к реальной жизни, поэтому для тестов она запускает настоящий EC2 инстанс. Небольшой, достаточный для проведения тестов, после чего он автоматически уничтожается.
Что нам дало тестирование.
Оно дало нам уверенность в том, что все, что мы сделали – работает. Ну хотя бы с большой долей вероятности. Что наши изменения ничего не сломали, и при этом автоматизация этого тестирования через CI не сделала решение этой задачи достаточно дорогим.
Единожды это реализовав, настроив, подготовив все необходимое, с точки зрения CI, каких-то контейнеров, той же молекулы, теперь любое изменение протестировать очень просто: коммит в репозитории и все, инженер очень быстро получает обратную связь.
Сопровождение SWE
Как я уже писал выше, тот процесс, что у нас был, не очень нас устраивал и мы решили, что для нашего проекта возьмем его в свои руки.
У нас уже были почти все необходимые для этого инструменты. У нас был Ansible для того, чтобы как-то кастомизировать образ; у нас была молекула для того, чтобы это дело тестировать; у нас был GitLab CI для того, чтобы автоматизировать эту выкатку. У нас не было только того, что нам будет эти образы готовить и для этого мы взяли Packer (https://www.packer.io/).
Не буду подробно останавливаться на том что это такое и как с ним работать, расскажу о том, что мы для себя приготовили.
Мы реализовали подготовку SWE/AMI в три подхода.
На первом этапе идет создание “базового” AMI. Мы берем за основу одну из “чистых”, поставляемых Amazon AMI, запускаем псевдо-инстанс с помощью “amazon-ebssurrogate”, подключаем к нему шифруемые средствами AWS тома, разбиваем их на разделы удобным нам образом, устанавливаем на них базовую AMI (например Amazon Linux 2), минимальную настройку и из того что получилась, формировалась уже наше базовое AMI.
На втором шаге Packer запускает инстанс уже на основе нашей базовой AMI, скачивает и запускает необходимый набор Ansible ролей, с набором значение по умолчанию и тэгами, которые приводят к некоторой базовой установке и настройке дополнительных компонент и самой ОС. На этом же шаге настраивается cloud unit, который должен единожды отработать, после того, как из новой AMI будет первично развернута машина – например, пропорционально увеличить размеры дисковых разделов. После чего, из полученной конфигурации создается вторая- готовая к употреблению AMI.
Тут можно было бы реализовать еще один этап, когда на базе готовой универсальной AMI создается AMI для специфического приложения- например “вшивается” runtime с зависимостями, или устанавливается и первично настраивается движок базы данных но мы пока этого не делаем.
И, наконец, на третьем этапе мы берем готовую AMI и тестируем ее с помощью Molecula. Она запускает EC2 Instance, дожидается его запуска и прогоняет по нему набор тестов.
И тут мы получили очень интересный дополнительный бонус. Когда у нас до этого создавалось AMI-SWE, прежде, чем его можно было использовать, Security просили: «Ребята, мы хотим его протестировать, проверить». Мы такие: «Так, стоп. Тестирование, проверка – мы это где-то уже слышали. У нас есть свой инструмент, зачем вы будете это делать? Дайте нам требования, дайте нам примеры, как это делаете вы, согласно чему вы это делаете, и мы это автоматизируем». У нас Security использует некоторый набор этих самых требований, в число которых, например, входит CIS benchmark. В нем описано, что должно быть настроено, как это проверить, и даже есть готовая утилита, которая потом запускается на машине и позволяет построить отчет.
Мы просто взяли весь этот набор требований, взяли набор тестов, который был. Этот набор был написан на bash-скриптах, мы все это дело переписали. То есть, требования мы реализовали, где-то докрутив наши роли, где-то сделав отдельную роль чисто под hardening, все тесты мы запихали в молекулу, и эту саму отправку репорта сделали в чат с Security командой, добавив еще один шаг. В конце запуск их утилиты аудирования, которая просто строит отчет.
В итоге это стало выглядеть вот так: у нас есть первые два шага – это, собственно, работа Packer вместе с Ansible, создание AMI; тесты Molecule и отправка нотификации в чатик с security.
Теперь вместо того, чтобы security просили: «Вы сделали новый SWE, новую AMI – разверните инстанс, отдайте нам, и мы ее погоняем», это все делается за них. И в наш общий с ними чат прилетает нотификация: «Ребята, готова новая амишка. Тесты прогнаны, вот репорт, откройте-почитайте и посмотрите, согласны — не согласны». Секьюрити нам просто пишут в чат — вот этот pass-rate тестов ОК или не ОК. Если не ОК – садимся, разбираемся, решаем вопрос. Если же все хорошо, то мы понимаем, что можем идти дальше. И теперь процесс, который занимал неделю-полторы, занимает ничего (если не считать прочтение отчета).
Интеграция с Terraform и инфраструктурным CI
Уже достаточно давно мы живем с полностью автоматизированным применением инфраструктурных изменений через связку Terraform + Gitlab CI. Как этот процесс у нас был реализован опять же можно прочитать в моей предыдущей статье (версия на хабре).
В итоге сейчас у нас для каждого окружения есть репозиторий, который хранит настройки для CI и Terraform код, описывающий его, с ссылкой на лежащий в s3 TFstate файл ( для каждого окружения свой а для некоторых еще и несколько).
Несмотря на эту красоту, ansible роли какое-то время жили отдельно, без какой либо связи с репозиториями окружений. Почти. Не считая Requirements файла. То есть инженер должен был, изменив конфигурацию, как-то переделав окружение, взять эти роли и пойти их применить. Ну хоть их список и версии были ему известны — уже хорошо. Однако в рамках одного окружения могут быть (а обычно так и есть) — инстансы, развернутые из разных SWE, а это получается что для каждого надо держать свой requirements.yml, да еще помнить какой для чего. Ужас!
Мы решили что хватит это терпеть!
В какой то момент у нас появились репозитории, содержащие конфигурацию и CI для создания SWE. Вот он — идеальный источник истины. Зачем хранить в репозитории окружения списки ролей для разных swe, если он и так лежит в репозитории этого SWE. Еще и вместе с playbook, производящим настройку! Просто бери и пере используй, создавай один единственный source of truth!
Как организовать эту связь, причем сделать ее строго контролируемой (то есть версионируемой, отслеживаемой). Мы решили применить Git submodules. Возможно это и не лучшее решение, но нас оно пока целиком устраивает. Сейчас я расскажу Вам как это работает, а если вам придет в голову идея лучше-пожалуйста поделитесь, возможно мы и правда что-то упустили.
В репозитории каждого окружения настраиваются ссылки на репозитории используемых в этом окружении SWE. Результатом такой настройки становится появление в корне проекта файла gitmodules примерно вот такого содержимого:
1 2 3 4 5 6 |
[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 |
Плюс появляются каталоги, содержимое которых является по сути своей содержимым тех самых репозиториев на момент определенного коммита
То есть помимо того, что у нас есть такая связь из всех зависимых проектов с единым источником истины, каждый из них еще и привязан к конкретной версии нашего SWE репозитория и “сдвинуть” эту привязку можно только принудительно, переключив на определенный коммит. Тем самым мы опять же подстраховались что кто-то внесет изменения в наш репозиторий с swe, сломает какую-нибудь обратную совместимость и у нас везде все поедет. Нет.
С другой стороны, к сожалению, gut submodules не умеют как то работать с тегированием, или как-то иначе документировать эту связность. Условно говоря- сходу не очень просто понять к какой версии мы привязались, и что нам нужно будет в своем репозитории поменять если мы хотим переключиться на более свежую версию источника.
Мы решили этот вопрос достаточно простой мерой — теперь у нас репозитории содержащие конфигурацию SWE снабжены аккуратно поддерживаемым файлом CHANGE.log. И в случае переключения субмодуля, нам достаточно сравнить этот файл для обоих коммитов ( на котором мы находимся сейчас и на которой планируем переключиться), чтобы понять — нет ли нам необходимости что-то обновить в репозитории окружения. Например в новой версии SWE используется новая версия какой-нибудь Ansible роли, которая требует новых переменных или в которой переименовываются старые, меняются дефолты и тд. Сравнение версий Change.log нас спасет. Вот пример:
Кто же ведет эти изменения? На этот вопрос, наша вики страничка, описывающая данный процесс, имеет вполне конкретный ответ:
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 и получилась вот такая немного пугающая картинка:
Но если немного разобраться, на самом деле ничего сложного в тут нет.
Изначально у нас присутствует только красная часть схемы, за исключением YML файлов, git submodules и конфигурационного файла для Ansible — это репозиторий, содержащий инфраструктурный код Terraform для данного окружения.
Далее у нас появляются Ярко фиолетовые элементы — это Ansbile роли, каждая в своем репозитории.
Следующим шагом- появление голубых сущностей — это репозиторий с конфигурацией создания SWE, где есть все необходимое, в том числе ссылка на нужные версии нужных ролей через requirements файл.
Завершающим этапом становится появление связки из git submodules с SWE репозиторием. В Репозиторий окружения добавляется файл с конфигурацией inventory, конфигурацией самого Ansible, субмодулей, папка group vars, содержащая общие конфигурационные значения для всех элементов окружения и отдельные добавки или переопределения на уровне тех сервисов-компонент, кому они нужны.
За счет связующего звена в виде git submodules, у нас появляется эдакие “призрачные” сущности requirements и playbook — каждый для своего SWE, равно как и список ролей.
То есть, когда у нас Терраформ выкатывает конфигурацию, в CI есть команда, которая через тот или иной субмодуль для того или иного SWE вытянет все необходимые роли? вытянет необходимый плейбук.
Теперь, когда инженер создает новые ресурсы с помощью Terraform, он коммитит вот это дело в репозитории того или иного окружения, запускается CI.
CI в свою очередь валидирует terraform код, строит план и применяет изменения (этот шаг у нас ручной, с проверкой плана оператором, но по факту там нужно нажать лишь одну кнопку чтобы это все поехало дальше). И как только этот шаг пройден и изменение инфраструктуры произошло, запускается следующий набор шагов, где Ansible динамически обнаружив все вновь появившиеся ресурсы, или, допустим, если на старых ресурсах что-то должно быть изменено (например, мы изменили что-то в роли), проходит по ним и конфигурирует, приводя к ожидаемому виду. То есть, подготовка инфраструктуры из ручных действий теперь требует только какой-то визуальной проверки оператором. Все.
Заключение
Как нам кажется, всех поставленных целей мы для себя достигли. Мы закрыли все уровни управления инфраструктурой, которые для себя обозначили. И сделав это, мы получили процесс более прозрачным. Теперь достаточно просто открыть репозиторий любого окружения, и ты видишь, как оно должно быть настроено, чем оно настраивается, не нужно вспоминать, не нужно лезть в документацию – все хранится там в виде кода, теперь уже по всем уровням. Собственно, всё.