Puppet — это система управления конфигурацией. Он используется для приведения хостов к нужному состоянию и поддержания этого состояния.
Я работаю с Puppet уже больше пяти лет. На мой взгляд, его официальная документация хороша для тех, кто уже знаком с Puppet, а для новичка она сложна — сразу даётся много новых терминов; непонятно, в каком порядке читать. Эта статья — по сути переведённая компиляция ключевых моментов из официальной документации, которая позволит новичкам быстро вникнуть в суть Puppet. Я переупорядочил информацию, чтобы постепенно рассказать про все сущности и термины.
Базовая информация
Схема работы Puppet — клиент-серверная, хотя поддерживается и вариант работы без сервера с ограниченной функциональностью.
Используется pull-модель работы: по умолчанию раз в полчаса клиенты обращаются к серверу за конфигурацией и применяют её. Если вы работали с Ansible, то там используется другая, push-модель: администратор инициирует процесс применения конфигурации, сами по себе клиенты ничего применять не будут.
При сетевом взаимодействии используется двустороннее TLS-шифрование: у сервера и клиента есть свои закрытые ключи и соответствующие им сертификаты. Обычно сервер выпускает сертификаты для клиентов, но в принципе возможно использование и внешнего CA.
Знакомство с манифестами
В терминологии Puppet к паппет-серверу подключаются ноды (nodes). Конфигурация для нод пишется в манифестах на специальном языке программирования — Puppet DSL.
Puppet DSL — декларативный язык. На нём описывается желаемое состояние ноды в виде объявления отдельных ресурсов, например:
- Файл существует, и у него определённое содержимое.
- Пакет установлен.
- Сервис запущен.
Ресурсы могут быть взаимосвязаны:
- Есть зависимости, они влияют на порядок применения ресурсов.
Например, «сначала установи пакет, затем поправь конфигурационный файл, после этого запусти сервис». - Есть уведомления — если ресурс изменился, он отправляет уведомления подписанным на него ресурсам.
Например, если изменяется конфигурационный файл, можно автоматически перезапускать сервис.
Кроме того, в Puppet DSL есть функции и переменные, а также условные операторы и селекторы. Также поддерживаются различные механизмы шаблонизации — EPP и ERB.
Puppet написан на Ruby, поэтому многие конструкции и термины взяты оттуда. Ruby позволяет расширять Puppet — дописывать сложную логику, новые типы ресурсов, функции.
Во время работы Puppet манифесты для каждой конкретной ноды на сервере компилируются в каталог. Каталог — это список ресурсов и их взаимосвязей после вычисления значения функций, переменных и раскрытия условных операторов.
Синтаксис и кодстайл
Вот разделы официальной документации, которые помогут разобраться с синтаксисом, если приведённых примеров будет недостаточно:
Вот пример того, как выглядит манифест:
# Комментарии пишутся, как и много где, после решётки.
#
# Описание конфигурации ноды начинается с ключевого слова node,
# за которым следует селектор ноды — хостнейм (с доменом или без)
# или регулярное выражение для хостнеймов, или ключевое слово default.
#
# После этого в фигурных скобках описывается собственно конфигурация ноды.
#
# Одна и та же нода может попасть под несколько селекторов. Про приоритет
# селекторов написано в статье про синтаксис описания нод.
node 'hostname', 'f.q.d.n', /regexp/ {
# Конфигурация по сути является перечислением ресурсов и их параметров.
#
# У каждого ресурса есть тип и название.
#
# Внимание: не может быть двух ресурсов одного типа с одинаковыми названиями!
#
# Описание ресурса начинается с его типа. Тип пишется в нижнем регистре.
# Про разные типы ресурсов написано ниже.
#
# После типа в фигурных скобках пишется название ресурса, потом двоеточие,
# дальше идёт опциональное перечисление параметров ресурса и их значений.
# Значения параметров указываются через т.н. hash rocket (=>).
resource { 'title':
param1 => value1,
param2 => value2,
param3 => value3,
}
}
Отступы и переводы строк не являются обязательной частью манифеста, однако есть рекомендованный style guide. Краткое изложение:
- Двухпробельные отступы, табы не используются.
- Фигурные скобки отделяются пробелом, двоеточие пробелом не отделяется.
- Запятые после каждого параметра, в том числе последнего. Каждый параметр — на отдельной строке. Исключение делается для случая без параметров и одного параметра: можно писать на одной строке и без запятой (т.е.
resource { 'title': }
иresource { 'title': param => value }
). - Стрелки у параметров должны быть на одном уровне.
- Стрелки взаимосвязи ресурсов пишутся перед ними.
Расположение файлов на паппетсервере
Для дальнейших объяснений я введу понятие «корневая директория». Корневая директория — это директория, в которой находится Puppet-конфигурация для конкретной ноды.
Корневая директория различается в зависимости от версии Puppet и использования окружений. Окружения — это независимые наборы конфигурации, которые хранятся в отдельных директориях. Обычно используются в сочетании с гитом, в таком случае окружения создаются из веток гита. Соответственно, каждая нода находится в том или ином окружении. Это настраивается на самой ноде, либо в ENC, про что я расскажу в следующей статье.
- В третьей версии («старый Паппет») базовой директорией была
/etc/puppet
. Использование окружений опциональное — мы, например, их не используем со старым Паппетом. Если окружения используются, то они обычно хранятся в/etc/puppet/environments
, корневой директорией будет директория окружения. Если окружения не используются, корневой директорией будет базовая. - Начиная с четвёртой версии («новый Паппет») использование окружений стало обязательным, а базовую директорию перенесли в
/etc/puppetlabs/code
. Соответственно, окружения хранятся в/etc/puppetlabs/code/environments
, корневая директория — директория окружения.
В корневой директории должна быть поддиректория manifests
, в которой лежит один или несколько манифестов с описанием нод. Кроме того, там должна быть поддиректория modules
, в которой лежат модули. Что такое модули, я расскажу чуть позже. Кроме того, в старом Паппете также может быть поддиректория files
, в которой лежат различные файлы, которые мы копируем на ноды. В новом Паппете же все файлы вынесены в модули.
Файлы манифестов имеют расширение .pp
.
Пара боевых примеров
Описание ноды и ресурса на ней
На ноде server1.testdomain
должен быть создан файл /etc/issue
с содержимым Debian GNU/Linux \n \l
. Файл должен принадлежать пользователю и группе root
, права доступа должны быть 644
.
Пишем манифест:
node 'server1.testdomain' { # блок конфигурации, относящийся к ноде server1.testdomain
file { '/etc/issue': # описываем файл /etc/issue
ensure => present, # этот файл должен существовать
content => 'Debian GNU/Linux \n \l', # у него должно быть такое содержимое
owner => root, # пользователь-владелец
group => root, # группа-владелец
mode => '0644', # права на файл. Они заданы в виде строки (в кавычках), потому что иначе число с 0 в начале будет воспринято как записанное в восьмеричной системе, и всё пойдёт не так, как задумано
}
}
Взаимосвязи ресурсов на ноде
На ноде server2.testdomain
должен быть запущен nginx, работающий с подготовленной заранее конфигурацией.
Декомпозируем задачу:
- Нужно, чтобы был установлен пакет
nginx
. - Нужно, чтобы была скопированы конфигурационные файлы с сервера.
- Нужно, чтобы был запущен сервис
nginx
. - В случае обновления конфигурации нужно перезапускать сервис.
Пишем манифест:
node 'server2.testdomain' { # блок конфигурации, относящийся к ноде server2.testdomain
package { 'nginx': # описываем пакет nginx
ensure => installed, # он должен быть установлен
}
# Прямая стрелка (->) говорит о том, что ресурс ниже должен
# создаваться после ресурса, описанного выше.
# Такие зависимости транзитивны.
-> file { '/etc/nginx': # описываем файл /etc/nginx
ensure => directory, # это должна быть директория
source => 'puppet:///modules/example/nginx-conf', # её содержимое нужно брать с паппет-сервера по указанному адресу
recurse => true, # копировать файлы рекурсивно
purge => true, # нужно удалять лишние файлы (те, которых нет в источнике)
force => true, # удалять лишние директории
}
# Волнистая стрелка (~>) говорит о том, что ресурс ниже должен
# подписаться на изменения ресурса, описанного выше.
# Волнистая стрелка включает в себя прямую (->).
~> service { 'nginx': # описываем сервис nginx
ensure => running, # он должен быть запущен
enable => true, # его нужно запускать автоматически при старте системы
}
# Когда ресурс типа service получает уведомление,
# соответствующий сервис перезапускается.
}
Чтобы это работало, нужно примерно такое расположение файлов на паппет-сервере:
/etc/puppetlabs/code/environments/production/ # (это для нового Паппета, для старого корневой директорией будет /etc/puppet)
├── manifests/
│ └── site.pp
└── modules/
└── example/
└── files/
└── nginx-conf/
├── nginx.conf
├── mime.types
└── conf.d/
└── some.conf
Типы ресурсов
Полный список поддерживаемых типов ресурсов находится в документации, здесь же я опишу пять базовых типов, которых в моей практике хватает для решения большинства задач.
file
Управляет файлами, директориями, симлинками, их содержимым, правами доступа.
Параметры:
- название ресурса — путь к файлу (опционально)
- path — путь к файлу (если он не задан в названии)
- ensure — тип файла:
absent
— удалить файлpresent
— должен быть файл любого типа (если файла нет — будет создан обычный файл)file
— обычный файлdirectory
— директорияlink
— симлинк
- content — содержимое файла (подходит только для обычных файлов, нельзя использовать вместе с source или target)
- source — ссылка на путь, из которого нужно копировать содержимое файла (нельзя использовать вместе с content или target). Может быть задана как в виде URI со схемой
puppet:
(тогда будут использованы файлы с паппет-сервера), так и со схемойhttp:
(надеюсь, понятно, что будет в этом случае), и даже со схемойfile:
или в виде абсолютного пути без схемы (тогда будет использован файл с локальной ФС на ноде).
Примеры:
puppet:///modules/<modulename>/<filename>
будет брать файл с паппетсервера по пути<корневая директория puppet>/modules/<modulename>/files/<filename>
file:///path/to/file
будет брать файл с самой ноды, на которой запущен паппет (не с паппетсервера!), по пути/path/to/file
.
- target — куда должен указывать симлинк (нельзя использовать вместе с content или source)
- owner — пользователь, которому должен принадлежать файл
- group — группа, которой должен принадлежать файл
- mode — права на файл (в виде строки)
- recurse — включает рекурсивную обработку директорий
- purge — включает удаление файлов, которые не описаны в Puppet
- force — включает удаление директорий, которые не описаны в Puppet
package
Устанавливает и удаляет пакеты. Умеет обрабатывать уведомления — переустанавливает пакет, если задан параметр reinstall_on_refresh.
Параметры:
- название ресурса — название пакета (опционально)
- name — название пакета (если не задано в названии)
- provider — пакетный менеджер, который нужно использовать
- ensure — желаемое состояние пакета:
present
,installed
— установлена любая версияlatest
— установлена последняя версияabsent
— удалён (apt-get remove
)purged
— удалён вместе с конфигурационными файлами (apt-get purge
)held
— версия пакета заблокирована (apt-mark hold
)любая другая строка
— установлена указанная версия
- reinstall_on_refresh — если
true
, то при получении уведомления пакет будет переустановлен. Полезно для source-based дистрибутивов, где пересборка пакетов может быть необходима при изменении параметров сборки. По умолчаниюfalse
.
service
Управляет сервисами. Умеет обрабатывать уведомления — перезапускает сервис.
Параметры:
- название ресурса — сервис, которым нужно управлять (опционально)
- name — сервис, которым нужно управлять (если не задано в названии)
- ensure — желаемое состояние сервиса:
running
— запущенstopped
— остановлен
- enable — управляет возможностью запуска сервиса:
true
— включен автозапуск (systemctl enable
)mask
— замаскирован (systemctl mask
)false
— выключен автозапуск (systemctl disable
)
- restart — команда для перезапуска сервиса
- status — команда для проверки статуса сервиса
- hasrestart — указать, поддерживает ли инитскрипт сервиса перезапуск. Если
false
и указан параметр restart — используется значение этого параметра. Еслиfalse
и параметр restart не указан — сервис останавливается и запускается для перезапуска (но в systemd используется командаsystemctl restart
). - hasstatus — указать, поддерживает ли инитскрипт сервиса команду
status
. Еслиfalse
, то используется значение параметра status. По умолчаниюtrue
.
exec
Запускает внешние команды. Если не указывать параметры creates, onlyif, unless или refreshonly, команда будет запускаться при каждом прогоне Паппета. Умеет обрабатывать уведомления — запускает команду.
Параметры:
- название ресурса — команда, которую нужно выполнить (опционально)
- command — команда, которую нужно выполнить (если она не задана в названии)
- path — пути, в которых искать исполняемый файл
- onlyif — если указанная в этом параметре команда завершилась с нулевым кодом возврата, основная команда будет выполнена
- unless — если указанная в этом параметре команда завершилась с ненулевым кодом возврата, основная команда будет выполнена
- creates — если указанный в этом параметре файл не существует, основная команда будет выполнена
- refreshonly — если
true
, то команда будет запущена только в том случае, когда этот exec получает уведомление от других ресурсов - cwd — директория, из которой запускать команду
- user — пользователь, от которого запускать команду
- provider — с помощью чего запускать команду:
- posix — просто создаётся дочерний процесс, обязательно указывать path
- shell — команда запускается в шелле
/bin/sh
, можно не указывать path, можно использовать глоббинг, пайпы и прочие фичи шелла. Обычно определяется автоматически, если есть всякие спецсимволы (|
,;
,&&
,||
и так далее).
cron
Управляет кронджобами.
Параметры:
- название ресурса — просто какой-то идентификатор
- ensure — состояние кронджоба:
present
— создать, если не существуетabsent
— удалить, если существует
- command — какую команду запускать
- environment — в каком окружении запускать команду (список переменных окружения и их значений через
=
) - user — от какого пользователя запускать команду
- minute, hour, weekday, month, monthday — когда запускать крон. Если какой-то из этих аттрибутов не указан, его значением в кронтабе будет
*
.
В Puppet 6.0 cron как бы удалили из коробки в puppetserver, поэтому нет документации на общем сайте. Но он есть в коробке в puppet-agent, поэтому ставить его отдельно не надо. Документацию по нему можно посмотреть в документации к пятой версии Паппета, либо на Гитхабе.
Про ресурсы в общем
Требования к уникальности ресурсов
Самая частая ошибка, с которой мы встречаемся — Duplicate declaration. Эта ошибка возникает, когда в каталог попадают два и более ресурса одинакового типа с одинаковым названием.
Поэтому ещё раз напишу: в манифестах для одной ноды не должно быть ресурсов одинакового типа с одинаковым названием (title)!
Иногда есть необходимость поставить пакеты с одинаковым названием, но разными пакетными менеджерами. В таком случае нужно пользоваться параметром name
, чтобы избежать ошибки:
package { 'ruby-mysql':
ensure => installed,
name => 'mysql',
provider => 'gem',
}
package { 'python-mysql':
ensure => installed,
name => 'mysql',
provider => 'pip',
}
В других типах ресурсов есть аналогичные параметры, помогающие избежать дубликации, — name
у service, command
у exec, и так далее.
Метапараметры
Некоторые специальные параметры есть у каждого типа ресурса, независимо от его сущности.
Полный список метапараметров в документации Puppet.
Краткий список:
- require — в этом параметре указывается, от каких ресурсов зависит данный ресурс.
- before — в этом параметре указывается, какие ресурсы зависят от данного ресурса.
- subscribe — в этом параметре указывается, от каких ресурсов получает уведомления данный ресурс.
- notify — в этом параметре указывается, какие ресурсы получают уведомления от данного ресурса.
Все перечисленные метапараметры принимают либо одну ссылку на ресурс, либо массив ссылок в квадратных скобках.
Ссылки на ресурсы
Ссылка на ресурс — это просто упоминание ресурса. Используются они в основном для указания зависимостей. Ссылка на несуществующий ресурс вызовет ошибку компиляции.
Синтаксис у ссылки следующий: тип ресурса с большой буквы (если в названии типа содержатся двойные двоеточия, то с большой буквы пишется каждая часть названия между двоеточиями), дальше в квадратных скобках название ресурса (регистр названия не меняется!). Пробелов быть не должно, квадратные скобки пишутся сразу после названия типа.
Пример:
file { '/file1': ensure => present }
file { '/file2':
ensure => directory,
before => File['/file1'],
}
file { '/file3': ensure => absent }
File['/file1'] -> File['/file3']
Зависимости и уведомления
Как уже было сказано ранее, простые зависимости между ресурсами транзитивны. Кстати, будьте внимательны при проставлении зависимостей — можно сделать циклические зависимости, что вызовет ошибку компиляции.
В отличие от зависимостей, уведомления не транзитивны. Для уведомлений действуют следующие правила:
- Если ресурс получает уведомление, он обновляется. Действия при обновлении зависят от типа ресурса — exec запускает команду, service перезапускает сервис, package переустанавливает пакет. Если для ресурса не определено действие при обновлении, то ничего не происходит.
- За один прогон Паппета ресурс обновляется не больше одного раза. Это возможно, так как уведомления включают в себя зависимости, а граф зависимостей не содержит циклов.
- Если Паппет меняет состояние ресурса, то ресурс отправляет уведомления всем подписанным на него ресурсам.
- Если ресурс обновляется, то он отправляет уведомления всем подписанным на него ресурсам.
Обработка неуказанных параметров
Как правило, если у какого-то параметра ресурса нет значения по умолчанию и этот параметр не указан в манифесте, то Паппет не будет менять это свойство у соответствующего ресурса на ноде. Например, если у ресурса типа file не указан параметр owner
, то Паппет не будет менять владельца у соответствующего файла.
Знакомство с классами, переменными и дефайнами
Предположим, у нас несколько нод, на которых есть одинаковая часть конфигурации, но есть и различия — иначе мы могли бы описать это всё в одном блоке node {}
. Конечно, можно просто скопировать одинаковые части конфигурации, но в общем случае это плохое решение — конфигурация разрастается, при изменении общей части конфигурации придётся править одно и то же во множестве мест. При этом легко ошибиться, ну и вообще принцип DRY (don’t repeat yourself) не просто так придумали.
Для решения такой проблемы есть такая конструкция, как класс.
Классы
Класс — это именованный блок паппет-кода. Классы нужны для переиспользования кода.
Сначала класс нужно описать. Само по себе описание не добавляет никуда никакие ресурсы. Класс описывается в манифестах:
# Описание класса начинается с ключевого слова class и его названия.
# Дальше идёт тело класса в фигурных скобках.
class example_class {
...
}
После этого класс можно использовать:
# первый вариант использования — в стиле ресурса с типом class
class { 'example_class': }
# второй вариант использования — с помощью функции include
include example_class
# про отличие этих двух вариантов будет рассказано дальше
Пример из предыдущей задачи — вынесем установку и настройку nginx в класс:
class nginx_example {
package { 'nginx':
ensure => installed,
}
-> file { '/etc/nginx':
ensure => directory,
source => 'puppet:///modules/example/nginx-conf',
recure => true,
purge => true,
force => true,
}
~> service { 'nginx':
ensure => running,
enable => true,
}
}
node 'server2.testdomain' {
include nginx_example
}
Переменные
Класс из предыдущего примера совсем не гибок, потому что он всегда приносит одну и ту же конфигурацию nginx. Давайте сделаем так, чтобы путь к конфигурации стал переменным, тогда этот класс можно будет использовать для установки nginx с любой конфигурацией.
Это можно сделать с помощью переменных.
Внимание: переменные в Puppet неизменяемые!
Кроме того, обращаться к переменной можно только после того, как её объявили, иначе значением переменной окажется undef
.
Пример работы с переменными:
# создание переменных
$variable = 'value'
$var2 = 1
$var3 = true
$var4 = undef
# использование переменных
$var5 = $var6
file { '/tmp/text': content => $variable }
# интерполяция переменных — раскрытие значения переменных в строках. Работает только в двойных кавычках!
$var6 = "Variable with name variable has value ${variable}"
В Puppet есть пространства имён, а у переменных, соответственно, есть область видимости: переменная с одним и тем же именем может быть определена в разных пространствах имён. При разрешении значения переменной переменная ищется в текущем неймспейсе, потом в объемлющем, и так далее.
Примеры пространства имён:
- глобальное — туда попадают переменные вне описания класса или ноды;
- пространство имён ноды в описании ноды;
- пространство имён класса в описании класса.
Чтобы избежать неоднозначности при обращении к переменной, можно указывать пространство имён в имени переменной:
# переменная без пространства имён
$var
# переменная в глобальном пространстве имён
$::var
# переменная в пространстве имён класса
$classname::var
$::classname::var
Договоримся, что путь к конфигурации nginx лежит в переменной $nginx_conf_source
. Тогда класс будет выглядеть следующим образом:
class nginx_example {
package { 'nginx':
ensure => installed,
}
-> file { '/etc/nginx':
ensure => directory,
source => $nginx_conf_source, # здесь используем переменную вместо фиксированной строки
recure => true,
purge => true,
force => true,
}
~> service { 'nginx':
ensure => running,
enable => true,
}
}
node 'server2.testdomain' {
$nginx_conf_source = 'puppet:///modules/example/nginx-conf'
include nginx_example
}
Однако приведённый пример плох тем, что есть некое «тайное знание» о том, что где-то внутри класса использует переменная с таким-то именем. Гораздо более правильно сделать это знание общим — у классов могут быть параметры.
Параметры класса — это переменные в пространстве имён класса, они задаются в заголовке класса и могут быть использованы как обычные переменные в теле класса. Значения параметров указывается при использовании класса в манифесте.
Параметру можно задать значение по умолчанию. Если у параметра нет значения по умолчанию и значение не задано при использовании, это вызовет ошибку компиляции.
Давайте параметризуем класс из примера выше и добавим два параметра: первый, обязательный — путь к конфигурации, и второй, необязательный — название пакета с nginx (в Debian, например, есть пакеты nginx
, nginx-light
, nginx-full
).
# переменные описываются сразу после имени класса в круглых скобках
class nginx_example (
$conf_source,
$package_name = 'nginx-light', # параметр со значением по умолчанию
) {
package { $package_name:
ensure => installed,
}
-> file { '/etc/nginx':
ensure => directory,
source => $conf_source,
recurse => true,
purge => true,
force => true,
}
~> service { 'nginx':
ensure => running,
enable => true,
}
}
node 'server2.testdomain' {
# если мы хотим задать параметры класса, функция include не подойдёт* — нужно использовать resource-style declaration
# *на самом деле подойдёт, но про это расскажу в следующей серии. Ключевое слово "Hiera".
class { 'nginx_example':
conf_source => 'puppet:///modules/example/nginx-conf', # задаём параметры класса точно так же, как параметры для других ресурсов
}
}
В Puppet переменные типизированы. Есть много типов данных. Типы данных обычно используются для валидации значений параметров, передаваемых в классы и дефайны. Если переданный параметр не соответствует указанному типу, произойдёт ошибка компиляции.
Тип пишется непосредственно перед именем параметра:
class example (
String $param1,
Integer $param2,
Array $param3,
Hash $param4,
Hash[String, String] $param5,
) {
...
}
Классы: include classname vs class{'classname':}
Каждый класс является ресурсом типа class. Как и в случае с любыми другими типами ресурсов, на одной ноде не может присутствовать два экземпляра одного и того же класса.
Если попробовать добавить класс на одну и ту же ноду два раза с помощью class { 'classname':}
(без разницы, с разными или с одинаковыми параметрами), будет ошибка компиляции. Зато в случае использования класса в стиле ресурса можно тут же в манифесте явно задать все его параметры.
Однако если использовать include
, то класс можно добавлять сколько угодно раз. Дело в том, что include
— идемпотентная функция, которая проверяет, добавлен ли класс в каталог. Если класса в каталоге нет — добавляет его, а если уже есть, то ничего не делает. Но в случае использования include
нельзя задать параметры класса во время объявления класса — все обязательные параметры должны быть заданы во внешнем источнике данных — Hiera или ENC. О них мы поговорим в следующей статье.
Дефайны
Как было сказано в предыдущем блоке, один и тот же класс не может присутствовать на ноде более одного раза. Однако в некоторых случаях нужно иметь возможность применять один и тот же блок кода с разными параметрами на одной ноде. Иными словами, есть потребность в собственном типе ресурса.
Например, для того, чтобы установить модуль PHP, мы в Авито делаем следующее:
- Устанавливаем пакет с этим модулем.
- Создаём конфигурационный файл для этого модуля.
- Создаём симлинк на конфиг для php-fpm.
- Создаём симлинк на конфиг для php cli.
В таких случаях используется такая конструкция, как дефайн (define, defined type, defined resource type). Дефайн похож на класс, но есть отличия: во-первых, каждый дефайн является типом ресурса, а не ресурсом; во-вторых, у каждого дефайна есть неявный параметр $title
, куда попадает имя ресурса при его объявлении. Так же как и в случае с классами, дефайн сначала нужно описать, после этого его можно использовать.
Упрощённый пример с модулем для PHP:
define php74::module (
$php_module_name = $title,
$php_package_name = "php7.4-${title}",
$version = 'installed',
$priority = '20',
$data = "extension=${title}.so\n",
$php_module_path = '/etc/php/7.4/mods-available',
) {
package { $php_package_name:
ensure => $version,
install_options => ['-o', 'DPkg::NoTriggers=true'], # триггеры дебиановских php-пакетов сами создают симлинки и перезапускают сервис php-fpm - нам это не нужно, так как и симлинками, и сервисом мы управляем с помощью Puppet
}
-> file { "${php_module_path}/${php_module_name}.ini":
ensure => $ensure,
content => $data,
}
file { "/etc/php/7.4/cli/conf.d/${priority}-${php_module_name}.ini":
ensure => link,
target => "${php_module_path}/${php_module_name}.ini",
}
file { "/etc/php/7.4/fpm/conf.d/${priority}-${php_module_name}.ini":
ensure => link,
target => "${php_module_path}/${php_module_name}.ini",
}
}
node server3.testdomain {
php74::module { 'sqlite3': }
php74::module { 'amqp': php_package_name => 'php-amqp' }
php74::module { 'msgpack': priority => '10' }
}
В дефайне проще всего поймать ошибку Duplicate declaration. Это происходит, если в дефайне есть ресурс с константным именем, и на какой-то ноде два и более экземпляра этого дефайна.
Защититься от этого просто: все ресурсы внутри дефайна должны иметь название, зависящее от $title
. В качестве альтернативы — идемпотентное добавление ресурсов, в простейшем случае достаточно вынести общие для всех экземпляров дефайна ресурсы в отдельный класс и инклюдить этот класс в дефайне — функция include
идемпотентна.
Есть и другие способы достигнуть идемпотентности при добавлении ресурсов, а именно использование функций defined
и ensure_resources
, но про это расскажу в следующей серии.
Зависимости и уведомления для классов и дефайнов
Классы и дефайны добавляют следующие правила к обработке зависимостей и уведомлений:
- зависимость от класса/дефайна добавляет зависимости от всех ресурсов класса/дефайна;
- зависимость класса/дефайна добавляет зависимости всем ресурсам класса/дефайна;
- уведомление класса/дефайна уведомляет все ресурсы класса/дефайна;
- подписка на класс/дефайн подписывает на все ресурсы класса/дефайна.
Условные операторы и селекторы
if
Тут всё просто:
if ВЫРАЖЕНИЕ1 {
...
} elsif ВЫРАЖЕНИЕ2 {
...
} else {
...
}
unless
unless — это if наоборот: блок кода будет выполнен, если выражение ложно.
unless ВЫРАЖЕНИЕ {
...
}
case
Тут тоже ничего сложного. В качестве значений можно использовать обычные значения (строки, числа и так далее), регулярные выражения, а также типы данных.
case ВЫРАЖЕНИЕ {
ЗНАЧЕНИЕ1: { ... }
ЗНАЧЕНИЕ2, ЗНАЧЕНИЕ3: { ... }
default: { ... }
}
Селекторы
Селектор — это языковая конструкция, похожая на case
, только вместо выполнения блока кода она возвращает значение.
$var = $othervar ? { 'val1' => 1, 'val2' => 2, default => 3 }
Модули
Когда конфигурация маленькая, её легко можно держать в одном манифесте. Но чем больше конфигурации мы описываем, тем больше классов и нод становится в манифесте, он разрастается, с ним становится неудобно работать.
Кроме того, есть проблема переиспользования кода — когда весь код в одном манифесте, сложно этим кодом делиться с другими. Для решения этих двух проблем в Puppet есть такая сущность, как модули.
Модули — это наборы классов, дефайнов и прочих Puppet-сущностей, вынесенных в отдельную директорию. Иными словами, модуль — это независимый кусок Puppet-логики. Например, может быть модуль для работы с nginx, и в нём будет то и только то, что нужно для работы с nginx, а может быть модуль для работы с PHP, и так далее.
Модули версионируются, также поддерживаются зависимости модулей друг от друга. Есть открытый репозиторий модулей — Puppet Forge.
На паппет-сервере модули лежат в поддиректории modules корневой директории. Внутри каждого модуля стандартная схема директорий — manifests, files, templates, lib и так далее.
Структура файлов в модуле
В корне модуля могут быть следующие директории с говорящими названиями:
manifests
— в ней лежат манифестыfiles
— в ней лежат файлыtemplates
— в ней лежат шаблоныlib
— в ней лежит Ruby-код
Это не полный список директорий и файлов, но для этой статьи пока достаточно.
Названия ресурсов и имена файлов в модуле
Ресурсы (классы, дефайны) в модуле нельзя называть как угодно. Кроме того, есть прямое соответствие между названием ресурса и именем файла, в котором Puppet будет искать описание этого ресурса. Если нарушать правила именования, то Puppet просто не найдёт описание ресурсов, и получится ошибка компиляции.
Правила простые:
- Все ресурсы в модуле должны быть в неймспейсе модуля. Если модуль называется
foo
, то все ресурсы в нём должны называтьсяfoo::<anything>
, либо простоfoo
. - Ресурс с названием модуля должен быть в файле
init.pp
. - Для остальных ресурсов схема именования файлов следующая:
- префикс с именем модуля отбрасывается
- все двойные двоеточия, если они есть, заменяются на слеши
- дописывается расширение
.pp
Продемонстрирую на примере. Предположим, я пишу модуль nginx
. В нём есть следующие ресурсы:
- класс
nginx
описан в манифестеinit.pp
; - класс
nginx::service
описан в манифестеservice.pp
; - дефайн
nginx::server
описан в манифестеserver.pp
; - дефайн
nginx::server::location
описан в манифестеserver/location.pp
.
Шаблоны
Наверняка вы и сами знаете, что такое шаблоны, не буду расписывать здесь подробно. Но на всякий случай оставлю ссылку на Википедию.
Как использовать шаблоны: значение шаблона можно раскрыть с помощью функции template
, которой передаётся путь к шаблону. Для ресурсов типа file используем вместе с параметром content
. Например, так:
file { '/tmp/example': content => template('modulename/templatename.erb')
Путь вида <modulename>/<filename>
подразумевает файл <rootdir>/modules/<modulename>/templates/<filename>
.
Кроме того, есть функция inline_template
— ей на вход передаётся текст шаблона, а не имя файла.
Внутри шаблонов можно использовать все переменные Puppet в текущей области видимости.
Puppet поддерживает шаблоны в формате ERB и EPP:
Вкратце про ERB
Управляющие конструкции:
<%= ВЫРАЖЕНИЕ %>
— вставить значение выражения<% ВЫРАЖЕНИЕ %>
— вычислить значение выражение (не вставляя его). Сюда обычный идут условные операторы (if), циклы (each).<%# КОММЕНТАРИЙ %>
Выражения в ERB пишутся на Ruby (собственно, ERB — это Embedded Ruby).
Для доступа к переменным из манифеста нужно дописать @
к имени переменной. Чтобы убрать перевод строки, появляющийся после управляющей конструкции, нужно использовать закрывающий тег -%>
.
Пример использования шаблона
Предположим, я пишу модуль для управления ZooKeeper. Класс, отвечающий за создание конфига, выглядит примерно так:
class zookeeper::configure (
Array[String] $nodes,
Integer $port_client,
Integer $port_quorum,
Integer $port_leader,
Hash[String, Any] $properties,
String $datadir,
) {
file { '/etc/zookeeper/conf/zoo.cfg':
ensure => present,
content => template('zookeeper/zoo.cfg.erb'),
}
}
А соответствующий ему шаблон zoo.cfg.erb
— так:
<% if @nodes.length > 0 -%>
<% @nodes.each do |node, id| -%>
server.<%= id %>=<%= node %>:<%= @port_leader %>:<%= @port_quorum %>;<%= @port_client %>
<% end -%>
<% end -%>
dataDir=<%= @datadir %>
<% @properties.each do |k, v| -%>
<%= k %>=<%= v %>
<% end -%>
Факты и встроенные переменные
Зачастую конкретная часть конфигурации зависит от того, что в данный момент происходит на ноде. Например, в зависимости от того, какой релиз Debian стоит, нужно установить ту или иную версию пакета. Можно следить за этим всем вручную, переписывая манифесты в случае изменения нод. Но это несерьёзный подход, автоматизация гораздо лучше.
Для получения информации о нодах в Puppet есть такой механизм, как факты. Факты — это информация о ноде, доступная в манифестах в виде обычных переменных в глобальном пространстве имён. Например, имя хоста, версия операционной системы, архитектура процессора, список пользователей, список сетевых интерфейсов и их адресов, и многое, многое другое. Факты доступны в манифестах и шаблонах как обычные переменные.
Пример работы с фактами:
notify { "Running OS ${facts['os']['name']} version ${facts['os']['release']['full']}": }
# ресурс типа notify просто выводит сообщение в лог
Если говорить формально, то у факта есть имя (строка) и значение (доступны различные типы: строки, массивы, словари). Есть набор встроенных фактов. Также можно писать собственные. Сборщики фактов описываются как функции на Ruby, либо как исполняемые файлы. Также факты могут быть представлены в виде текстовых файлов с данными на нодах.
Во время работы паппет-агент сначала копирует с паппетсервера на ноду все доступные сборщики фактов, после чего запускает их и отправляет на сервер собранные факты; уже после этого сервер начинает компиляцию каталога.
Факты в виде исполняемых файлов
Такие факты кладутся в модули в директорию facts.d
. Разумеется, файлы должны быть исполняемыми. При запуске они должны выводить на стандартный вывод информацию либо в формате YAML, либо в формате "ключ=значение".
Не забывайте, что факты распространяются на все ноды, которые находятся под управлением паппет-сервера, на который выкатывается ваш модуль. Поэтому в скрипте озаботьтесь проверкой того, что в системе есть все необходимые для работы вашего факта программы и файлы.
#!/bin/sh
echo "testfact=success"
#!/bin/sh
echo '{"testyamlfact":"success"}'
Факты на Ruby
Такие факты кладутся в модули в директорию lib/facter
.
# всё начинается с вызова функции Facter.add с именем факта и блоком кода
Facter.add('ladvd') do
# в блоках confine описываются условия применимости факта — код внутри блока должен вернуть true, иначе значение факта не вычисляется и не возвращается
confine do
Facter::Core::Execution.which('ladvdc') # проверим, что в PATH есть такой исполняемый файл
end
confine do
File.socket?('/var/run/ladvd.sock') # проверим, что есть такой UNIX-domain socket
end
# в блоке setcode происходит собственно вычисление значения факта
setcode do
hash = {}
if (out = Facter::Core::Execution.execute('ladvdc -b'))
out.split.each do |l|
line = l.split('=')
next if line.length != 2
name, value = line
hash[name.strip.downcase.tr(' ', '_')] = value.strip.chomp('\'').reverse.chomp('\'').reverse
end
end
hash # значение последнего выражения в блоке setcode является значением факта
end
end
Текстовые факты
Такие факты кладутся на ноды в директорию /etc/facter/facts.d
в старом Паппете или /etc/puppetlabs/facts.d
в новом Паппете.
examplefact=examplevalue
---
examplefact2: examplevalue2
anotherfact: anothervalue
Обращение к фактам
Обратиться к фактам можно двумя способами:
- через словарь
$facts
:$facts['fqdn']
; - используя имя факта как имя переменной:
$fqdn
.
Лучше всего использовать словарь $facts
, а ещё лучше указывать глобальный неймспейс ($::facts
).
Вот нужный раздел документации.
Встроенные переменные
Кроме фактов, есть ещё некоторые переменные, доступные в глобальном пространстве имён.
- trusted facts — переменные, которые берутся из сертификата клиента (так как сертификат обычно выпускается на паппет-сервере, агент не может просто так взять и поменять свой сертификат, поэтому переменные и «доверенные»): название сертификата, имя хоста и домена, расширения из сертификата.
- server facts —переменные, относящиеся к информации о сервере — версия, имя, IP-адрес сервера, окружение.
- agent facts — переменные, добавляемые непосредственно puppet-agent'ом, а не facter'ом — название сертификата, версия агента, версия паппета.
- master variables — переменные паппетмастера (sic!). Там примерно то же самое, что в server facts, плюс доступны значения конфигурационных параметров.
- compiler variables — переменные компилятора, которые различаются в каждой области видимости: имя текущего модуля и имя модуля, в котором было обращение к текущему объекту. Их можно использовать, например, чтобы проверять, что ваши приватные классы не используют напрямую из других модулей.
Дополнение 1: как это всё запускать и дебажить?
В статье было много примеров puppet-кода, но совсем не было рассказано, как же этот код запускать. Что ж, исправляюсь.
Для работы Puppet достаточно агента, но для большинства случаев нужен будет и сервер.
Агент
Как минимум с пятой версии пакеты puppet-agent из официального репозитория Puppetlabs содержат в себе все зависимости (ruby и соответствующие gem'ы), поэтому сложностей с установкой никаких нет (говорю про Debian-based дистрибутивы — RPM-based дистрибутивами мы не пользуемся).
В простейшем случае для применения puppet-конфигурации достаточно запустить агент в беcсерверном режиме: при условии, что puppet-код скопирован на ноду, запускаете puppet apply <путь к манифесту>
:
atikhonov@atikhonov ~/puppet-test $ cat helloworld.pp
node default {
notify { 'Hello world!': }
}
atikhonov@atikhonov ~/puppet-test $ puppet apply helloworld.pp
Notice: Compiled catalog for atikhonov.localdomain in environment production in 0.01 seconds
Notice: Hello world!
Notice: /Stage[main]/Main/Node[default]/Notify[Hello world!]/message: defined 'message' as 'Hello world!'
Notice: Applied catalog in 0.01 seconds
Лучше, конечно, поднять сервер и запустить агенты на нодах в режиме демона — тогда раз в полчаса они будут применять конфигурацию, скачанную с сервера.
Можно имитировать push-модель работы — зайти на интересующую вас ноду и запустить sudo puppet agent -t
. Ключ -t
(--test
) на самом деле включает несколько опций, которые можно включать и по отдельности. Среди этих опций следующие:
- не работать в режиме демона (по умолчанию агент запускается в режиме демона);
- завершить работу после применения каталога (по умолчанию агент продолжит работу и будет применять конфигурацию раз в полчаса);
- писать подробный лог работы;
- показывать изменения в файлах.
У агента есть режим работы без изменений — им можно пользоваться в случае, когда вы не уверены, что написали корректную конфигурацию, и хотите проверить, что именно поменяет агент во время работы. Включается этот режим параметром --noop
в командной строке: sudo puppet agent -t --noop
.
Кроме того, можно включить отладочный лог работы — в нём puppet пишет обо всех действиях, которые он производит: о ресурсе, который в данный момент обрабатывает, о параметрах этого ресурса, о том, какие программы запускает. Разумеется, это параметр --debug
.
Сервер
Полноценную настройку паппетсервера и деплой на него кода в этой статье я не буду рассматривать, скажу лишь, что из коробки ставится вполне работоспособная версия сервера, не требующая дополнительной настройки для работы в условиях небольшого количества нод (скажем, до ста). Большее количество нод уже потребует тюнинга — по умолчанию puppetserver запускает не больше четырёх воркеров, для большей производительности нужно увеличить их число и не забыть увеличить лимиты памяти, иначе большую часть времени сервер будет garbage collect'ить.
Деплой кода — если нужно быстро и просто, то смотрите (на r10k)[https://github.com/puppetlabs/r10k], для небольших инсталляций его вполне должно хватить.
Дополнение 2: рекомендации по написанию кода
- Выносите всю логику в классы и дефайны.
- Держите классы и дефайны в модулях, а не в манифестах с описанием нод.
- Пользуйтесь фактами.
- Не делайте if'ов по хостнеймам.
- Не стесняйтесь добавлять параметры для классов и дефайнов — это лучше, чем неявная логика, спрятанная в теле класса/дефайна.
А почему я рекомендую так делать — объясню в следующей статье.
Заключение
На этом закончим со введением. В следующей статье расскажу про Hiera, ENC и PuppetDB.