Привет, Хабр! Меня зовут Саша Козлов, я занимаюсь разработкой инфраструктуры и системным администрированием в Авито последние три с половиной года. Я расскажу, как мы масштабировали и модернизировали со временем нашу работу с инфраструктурным кодом и вывели её на качественно новый уровень.  Мы отвечаем за полный цикл управления оборудованием: от закупки и монтажа в ДЦ до доставки до конечного потребителя. Речь пойдёт об уроках, которые мы вынесли за последние несколько лет, работая с тысячей единиц оборудования и несколькими тысячами единиц конфигурирования.  Речь пойдёт об инструментах «первого поколения» IaC, таких как Ansible, Chef, Salt, Puppet. Если вы имеете дело с on-premises инфраструктурой, и у вас нет своего облака на виртуализации, то, скорее всего, вы уже используете один из этих инструментов. Исторически в Авито используется Puppet. В какой-то момент мы столкнулись с необходимостью обновить его, поскольку использовали очень старую версию, которую Puppetlabs давно перестали поддерживать. Но мы поняли, что сделать это невозможно, поскольку кодовую базу, которая на тот момент у нас была, невозможно было просто перенести на последнюю версию. Это заставило нас критически подойти к тому, как мы разрабатываем инфраструктурный код и вообще — правильный ли инструмент мы используем. На первый взгляд, инструменты управления инфраструктурой очень разные. Некоторые требуют, чтобы на нодах был запущен специальный агент. Где-то используется push-модель, где-то pull, но в принципе ничего не мешает запускать ansible на каждой машине по крону и получить pull-конфигурации. С одной стороны — Python, Jinja-шаблоны и YAML-программирование, с другой — DSL и Ruby. Многие инструменты включают в свою экосистему дополнительные компоненты: дашборды, хранилище конфигурации вроде PuppetDB, централизованный компонент, который хранит и распространяет на все ноды ваш инфраструктурный код. С любым инструментом подобного рода не получится работать с инфраструктурным кодом ровно так же, как работает с кодом разработчик сервиса. Далеко не все изменения откатываются через git revert, сама выкатка этих изменений не происходит одновременно на всей инфраструктуре, а применяется постепенно. Конфигурация в любом случае остаётся мутабельной, а это значит, что всё равно придётся держать в голове текущее состояние машины. А → А’ не то же самое, что 0 → А’. Многие такие особенности приходится учитывать. Мы решили не слишком фокусироваться на сравнении инструментов между собой и выборе из них несуществующего идеального, а оставить знакомый нам Puppet. Всё-таки кодовая база — это то, с чем в основном работает разработчик и гораздо важнее, как устроен именно технологический процесс работы с кодом. Поэтому вместо безупречного инструмента мы сконцентрировались на улучшении тулинга и практик, которые применяем при разработке: Концентрация на практиках разработки помогает поддерживать качество кода, соблюдать единый кодстайл, упрощает и ускоряет внесение изменений в код. Какой бы вы ни выбрали инструмент — важно в первую очередь понимать его возможности и ограничения. Тут мы подходим ко второй важной мысли. Любой инструмент имеет свои сильные и слабые стороны. Поэтому важно понимать границы его применимости: где его следует использовать и, что не менее важно, где не следует.  Puppet очень гибок, местами даже слишком. Он в постоянном цикле приводит состояние инфраструктуры к описанному в коде виду, т.к. использует pull-модель. Как следствие этого — недостаточно хорошую обратную связь и задержку при применении изменений. По умолчанию конфигурация применяется раз в 30 минут, поэтому для задач, требующих более динамичного управления, Puppet не подходит. Кроме этого, в нём нет event-driven автоматизации, как в Salt, что также ограничивает область его применения. Об ограничениях инструментов вроде мутабельности инфраструктуры и отсутствия гарантий отката инфракода я упомянул выше, с ними приходится считаться. Поскольку применять их мы собирались для настройки железных серверов и stateful-контейнеров, мы не рассчитывали, что сможем всякий раз применять конфигурацию с чистого состояния. Хотя такую возможность мы себе обеспечили. Для себя мы определили границы так: применяем Puppet для конфигурирования железных нод и stateful-контейнеров, но не используем его для деплоя сервисов, конфигурации рантайма инфраструктурных сервисов и других быстро меняющихся вещей. Например, мы постепенно отходим от управления DNS-записями через Puppet, который сложился у нас исторически. Мы хотим, чтобы управление было более динамическим, поэтому этим занимается инфраструктурный сервис, предоставляющий свой API. Мы долго складывали весь инфраструктурный код в одной репе, но по мере увеличения команды это стало проблемой. Стало сложнее договариваться о том, как лучше писать код. Появились сабрепозитории для тех частей кода, которыми владеют инженеры из других команд, а работать с сабмодулями в git не очень удобно. При этом изменения в основных частях кода выкатывались сразу на всю огромную инфраструктуру Авито. Цена ошибки стала очень велика. Поэтому мы выделили основные сущности: control repo и модули. Каждая находится в отдельном git-репозитории. Сами тут ничего особенно не придумали, просто обратились к лучшим практикам для Puppet-кода, которые описаны в статье "The roles and profiles method". Control repo — это репозиторий с инфраструктурным кодом. В нём находится тот код, который выкатывается через CI-систему на паппет-сервер и применяется на машинах. Роль — это некоторая типовая конфигурация, способ сгруппировать машины с одинаковыми настройками. Это самый высокоуровневый слой абстракции — условно, ответ на вопрос «чем занимается эта машина?». Примеры ролей: k8s-нода кластера Х, нода Kafka в стейджинг окружении для шины данных, ClickHouse-нода и т.п. Профили — это дополнительный слой абстракции, который не всегда понятно как использовать. В общем случае он позволяет выделить какие-то части конфигурации и при необходимости обернуть модуль дополнительной логикой. В документации Puppet профилем называется класс-обёртка, в котором компонуются модули, относящиеся к одному стеку технологий. Но на практике эта рекомендация часто оказывается слишком абстрактной, и не всегда ясно, что относится к одному стеку, а что к разным. Роли и профили описываются в control repo, а модули подключаются в неё как внешние зависимости и применяются внутри профилей или ролей. Модуль — это подключаемая библиотека, код, который занимается настройкой определённого инфраструктурного компонента, как правило переиспользуемый. Модули релизятся отдельно от control repo, версионируются по semver и подключаются как зависимости. У нас сейчас порядка 50 модулей: модуль для работы с секретами, настройки различных БД, конфигурирования кластеров Kubernetes и другие. Вопрос, как структурировать код, на самом деле не очень прост в случае с Puppet. Единственно правильного ответа на него нет. Далеко не всегда ясно, что должно считаться одной ролью, а в каких случаях профиль нужно пилить на несколько. Нужно ли сразу делить код на модули, или можно до поры до времени складывать всё в control repo? Здесь всё зависит от размера инфраструктуры, её разнородности и количества инженеров, которые занимаются разработкой инфракода. Для начала вполне подойдёт просто иметь control repo и весь код писать в ней, организуя его в профили. У нас в Авито инфраструктурой занимается несколько команд, для каждой важны разные вещи. Одним важнее стабильность, а не скорость внесения изменений, поэтому они обложили код кучей тестов. Другим не подошла стандартная схема тестирования в Docker, и пришлось настроить свою. Поэтому у нас несколько control repo, по одной на такую команду. А чтобы можно было переиспользовать решения и не дублировать код, мы пишем модули. Вот несколько полезных статей, которые помогли нам разобраться в том, как лучше структурировать код: А ещё на своём Гитхаб-аккаунте мы поделились шаблоном модуля и control repo, который используем у себя. В шаблонах можно посмотреть пример того, как организовать код, а ещё там есть инструменты, которые нужны для запуска всех видов тестов: Ну хорошо, есть роли, профили и модули — но как именно определяется то, какой код на какой машине выполнять? Достаточно долго мы использовали node definitions и регулярные выражения, чтобы определять то, какие машины каким кодом настраиваем. Такой подход нормально работает на небольшой инфраструктуре, но по мере её роста вы скорее всего получите что-то похожее на это: Одна неправильно закрытая скобка тут может привести к полному краху. Поиск по такому коду не работает — невозможно быстро разобраться, какой код выполняется на отдельно взятой машине. В общем, такой подход казался разумным в начале, но со временем оказался неудачным. Для привязки машин к написанным ролям Puppetlabs предлагает использовать External Node Classifier. External Node Classifier — это компонент, который хранит в себе то, какую роль следует применить к каждой ноде. Он даёт возможность в инфракоде описывать только роли, а логику привязки ролей к конкретным машинам вынести во внешнюю систему. На вход ENC принимает имя ноды и отдаёт некий набор параметров, которые в коде становятся доступны как top-scope variables. В каждой control repo есть примерно такой код, который обеспечивает привязку ноды к роли, и это единственное место, где используется node definition: В качестве ENC может выступать любая система, ведь логику получения и формирования ответа вы пишете сами. Такой системой в нашем случае стала CMDB, внутренняя система учёта оборудования и его комплектующих. Наш CMDB основан на netbox от Digital Ocean, сильно доработанном под наши нужды. Он плотно интегрирован с razor, системой провижнинга железа, и умеет собирать всевозможные данные о железе, которое установлено в дата-центре. Для подготовки сервера к работе в интерфейсе CMDB мы указываем нужный паппет-сервер и роль, отправляем сервер на переналивку и через 15-20 минут получаем готовый к работе сервер, к которому уже применена конфигурация, описанная в роли. Почему так важно иметь простой способ получить «чистую» конфигурацию? Для того, чтобы уменьшить влияние Configuration Drift, который со временем будет неизбежно накапливаться. Configuration Drift — это неконсистентность конфигурации машины, описанной в коде, по отношению к её фактическому состоянию. Его источники — это ручные изменения на машинах, тестирование гипотез, ресурсы, которые при изменении вышли из-под управления Puppet. Чем чаще код применяется с нуля, тем лучше он проверен, и тем больше вы уверены в том, что с ним не возникнет проблем. Это особенно актуально для той части инфраструктуры, которая не хранит состояния и без труда может быть переналита с нуля. Например, ноды k8s-кластеров. В идеале нужно иметь возможность получить чистое состояние, отправив машину в переналивку простым API-вызовом. Мы делаем это через netbox API, который расширили под свои нужды. Под капотом там загрузка по PXE и netboot-образ Debian, шаблонизация preseed'ов на основе параметров, переданных в API, много низкоуровневой настройки и взаимодействия по IPMI через Redfish API. И, конечно же, болей, связанных с разной его реализацией у разных вендоров. Понятно, что машину с базой данных на сотни гигабайт вы не можете так просто переналить, поэтому цена ошибки при изменениях на них намного выше. Чтобы минимизировать вероятность ошибки, нужно иметь возможность протестировать свой код. Сначала у нас не было никакого способа протестировать и проверить инфраструктурный код до выкатки. Цикл обратной связи был таким: ты вносишь изменения в код, пушишь в мастер, и после этого узнаешь, выполняет код задачу, или ты сделал дурацкую синтаксическую ошибку. Это приводило к тому, что проводить ревью было практически невозможно — решение отлаживается на продакшене или в лучшем случае на тестовой машине, но код уже должен быть в мастере. Как следствие — низкое качество кода и не очень хорошая коммуникация. Код без тестов невозможно рефакторить. Со временем это приводит к “write-only” кодовой базе, в которую никому не хочется залезать. Когда мы перешли с 3.7 на 6 версию Puppet, то поняли, что появилась куча крутых инструментов для тестирования. Последовательность тестирования инфраструктурного кода выглядит так: На этих трёх шагах мы делаем следующее: Кластеры Kubernetes мы разворачиваем при помощи Puppet и делаем это в стиле "Kubernetes The Hard Way", то есть контролируем настройку компонентов кластера до самых мелочей. Понятно, что возможностей совершить ошибку масса, поэтому имеет смысл проводить хотя бы высокоуровневый smoke-тест, который будет проверять основной позитивный сценарий работы кластера. В итоге код, который разверачивает наши кластеры, мы тестируем так.  Приёмочные тесты, написанные на Beaker поднимают три виртуальные машины из подготовленных образов, доставляют туда секреты, применяют внутри паппет-код, который готовит кластер из трех нод. В конце мы ~~запускаем smoke-тест: ~~ применяем деплоймент с нджинксом, сервис и проверяем его доступность через ингресс.  Код тестов, которые выполняют эту проверку, выглядят так: Если все шаги прошли без ошибок, тест считается успешным. Приёмочные тесты мы запускаем на CI на каждое изменение. Тесты нужно запускать на CI и делать интеграцию с PR. А через политики мерджа PR устанавливаются требования ко всем изменениям: аппрув от ревьюеров и успешное прохождение тестов. Наверное, для многих это очевидная вещь, но без строгих правил, которые имплементируются через CI и политики PR, всегда работающие в мастере тесты не сделать, а править в тестах последствия чужой работы никому не захочется. Вот список инструментов, которые помогут помочь настроить тестирование инфракода: И неплохая статья про юнит-тестирование: Если хотите, чтобы разработчики инфракода использовали CI/CD или проверяли свой код до пуша в репу — снабдите их инструментом, который по умолчанию поддерживает необходимый workflow. В паппет-экосистеме есть отличный инструмент — PDK, который активно используется в сообществе. Но его мы использовать не стали, потому что он был несовместим с нашим инструментарием, и пришлось бы многое дорабатывать. В PDK в первую очередь не хватило возможности работать с control repo и тестирования с использованием Kitchen и Docker. Для тестирования PDK использует Beaker, у которого довольно высокий порог входа. Поэтому мы написали свой инструмент, который позволяет:  Когда разработчик инфракода создаёт новый модуль при помощи development tool, он сразу получает репозиторий с настроенными сборками на CI для статического анализа, запуска тестов и релиза модуля. После бутстрапа проекта в репозитории уже будет настроен инструментарий для работы с кодом (puppet-rspec, puppet-linter, test-kitchen) и настроены гит хуки, которые запускают нужный инструмент на коммит или пуш. В созданном репозитории по умолчанию применяются политики, запрещающие пуш в мастер и мердж ветки, если тесты упали или код не прошел ревью. Кстати, в качестве IDE для разработки паппет-кода неплохо подходит VSCode, в нём есть замечательный плагин с хорошей поддержкой DSL, автодополнением и сниппетами.  Важно, что все эти вещи доступны при создании нового модуля по умолчанию, «из коробки». Это позволило нам в достаточно короткие сроки распространить хорошие практики на все команды, которые разрабатывают инфракод. Как я уже писал выше, переиспользуемый код мы выносим в модули, которые подключаются как внешние зависимости. Напомню, что модуль — это отдельный проект со своим релизным циклом. После релиза модуль собирается в архив и грузится во внутренний репозиторий модулей: Forge. В публичном Puppet Forge есть множество модулей, которые пишут в сообществе. В управлении зависимостями в Puppet есть много странных и неочевидных вещей. Например, зависимости в модулях можно указывать двумя разными способами: metadata.json и Puppetfile. Они немного отличаются по своим возможностям, и разные инструменты по-разному их обрабатывают. Официально поддерживаемого инструмента по разрешению зависимостей нет. Есть librarian-puppet, который, в отличие от официального r10k, умеет резолвить зависимости второго и высших порядков. Но проект давно не развивается и не имеет нормальной документации, хотя задачу свою в целом выполняет. За неимением лучшего инструмента взяли его — он применяется везде, где требуется резолв зависимостей: в acceptance-тестах и при деплое кода на паппет-сервер. Достаточно долго мы искали подходящий путь. Сначала подключали модули как гит репу, а версионировали git-тегами, вот так: Такой способ работает, но требует явно указывать версию модуля, и не даёт завязаться на мажорную или минорную версию. Для более гибкого версионирования придётся поднять собственный локальный Puppet Forge, загружать модули в него, а зависимости резолвить через librarian-puppet. Модули мы версионируем по semver, по отношению к тому, какой интерфейс предоставляют публичные классы внутри модуля. То есть если интерфейс изменился обратно несовместимо, поднимается мажорная версия, в противном случае — минорная: В инструментарий для работы с инфракодом добавили несколько вещей, полезных для работы с зависимостями. Например, можно проверить, нет ли более новых версий модуля, который ты используешь в коде. Выглядит это так: Вот несколько полезных ссылок по теме: Почему так важно иметь code style в разработке инфраструктурного кода? Всё как и с любым другим кодом — инфраструктурный код пишется один раз, а читается потом десятками людей на протяжении лет. Поэтому нужен некий стандарт, описывающий лучшие практики, который облегчит чтение кода и его поддержку, адаптацию новых инженеров. Он также упрощает и процесс review кода — проще урегулировать спорные моменты, кинув ссылкой на подходящий пункт из документа. В Puppet с этим всё отлично. The puppet language style guide содержит все основные рекомендации по написанию кода. Puppet-lint, который мы запускаем на CI, проверяет соблюдение этих рекомендаций и позволяет добавлять в него собственные проверки. Внутри Авито мы его немного расширили и дополнили его своими, более высокоуровневыми правилами и рекомендациями. Их мы выложили вместе с шаблонами модулей и control repo: Поскольку мы используем свой development kit, важно облегчить процесс онбординга новых инженеров и адаптации «старых»: Управление секретами для инфракода должно быть удобным и простым, иначе секреты начинают коммитить в код. В Puppet управление секретами удобно делать через интеграцию Hiera и Vault. На Гитхабе есть hiera-backend, который позволяет получать их значения прямо из vault через hiera_lookup. Для работы с секретами в Авито есть модуль-обёртка, который позволяет либо получить секрет в переменную, либо положить файл-секрет в определённое место в системе. Первый способ работает через функцию, которая вызывается в коде вот так: В этом примере в переменную token_data получаем значение поля 'data' из секрета tokens.csv, который находится в Vault. В самом Vault секреты хранятся иерархически, также, как в Hiera: Таким образом, если мы хотим иметь общее значение секрета для всех машин, достаточно положить его в common. Если оно разное для каждой роли или ноды — в roles/ или nodes/.
 Второй способ — это дефайн и специальный формат секрета в волте, который содержит различные метаданные файла-секрета, такие как имя файла, владелец, права на файл. Такой способ мы используем для хранения TLS-сертификатов и ключей. Для тех, кто хочет подробнее разобраться в том, как всё это работает, мы выложили модуль для управления секретами на Гитхаб. В нём также можно посмотреть примеры того, как тестировать манифесты и функции на Puppet: В Puppet есть интересный механизм, который позволяет выкатить код из ветки control repo. Все ветки репозитория control repo отображаются на окружении, а каждая нода принадлежит к одному из них. Информация об этом отдаётся из ENC вместе с именем роли. Всё это позволяет выкатить код градуально, на ноды под боевой нагрузкой. В случае успеха ветка мержится в мастер, и нода возвращается в общее окружение. В случае неудачи — выводится из-под нагрузки и переналивается в стабильную конфигурацию. Это ещё один, финальный, способ протестировать свои изменения, и не уронить систему. Если говорить о достоинствах Puppet, я бы назвал отличную документацию, развитый тулинг вокруг, очень большую гибкость и расширяемость инструмента. Если не хватает возможностей DSL — его можно расширять на Ruby. Hiera — отличное решение для хранения параметров, которые передаются в роли. Можно обеспечить максимальную стандартизацию инфраструктуры, при этом сохранить возможность внести изменения с гранулярностью до конкретной машины. Данные в Hiera можно выносить в различные бэкенды, а если не хватает подходящего, можно написать свой. Самый большой недостаток Puppet — это, пожалуй, высокий порог входа и огромное количество способов решить одну и ту же задачу. Это обратная сторона гибкости. Даже спустя годы работы с этим инструментом иногда всё равно непонятно, как лучше организовать код: как использовать профили, какие параметры выносить в Hiera, а какие оставлять в коде. Мы смогли выработать какие-то правила, но это достаточно специфичная тема, поэтому её оставим за рамками статьи. Puppet наследовал у Ruby подход, когда у задачи имеется несколько решений. Это может быть интересно, если ты в одиночку пилишь небольшой pet project. Но если ты работаешь с инфраструктурой, где крайне важна стабильность и простота, в команде из нескольких десятков инженеров — это очень вредит. Обычно получается так: если инструмент позволяет решать одну задачу несколькими способами, то будет выбран самый простой и, скорее всего, неправильный. Ещё один недостаток — плохая обратная связь при внесении изменений. Он в принципе присущ всем инструментам, работающим по pull-модели. Неизвестно, в какой момент времени применятся изменения, которые попали в мастер. Для некоторых задач это становится критичным, и приходится изобретать велосипеды, чтобы pull превратить в push. Bolt, который Puppetlabs предлагает для подобных задач, выглядит странно, сложно интегрируется с PuppetDB, и от его использования мы пока отказались. Чтобы повысить эффективность и прозрачность работы с инфраструктурой и получить качественный код, который будет просто переиспользовать, мы: