Кукловодим через OpenVox. Часть 3. Роли, профили и правила
В первой части мы подняли сервер, во второй — подключили ноды. Всё это было подготовкой. Теперь начинается то, ради чего вся затея: собственно кукловодство. Будем писать правила, по которым голый сервер превращается в то, чем он должен быть.
Это самая большая часть цикла, потому что здесь — вся суть. Я разберу, где что лежит, как устроена архитектура, и дам готовые рецепты на частые задачи: добавить пакет, завести пользователя, настроить SSH, подцепить мониторинг. Всё это — примеры из живой инфраструктуры, а не из учебника.
Где что лежит
Первое, что нужно уложить в голове раз и навсегда: какие каталоги редактируемые, а какие — результат раскатки, куда лезть руками нельзя.
| Путь | Что это | Править напрямую? |
|---|---|---|
/root/control-repo/ | Редактируемый код (git-репозиторий) | Да — только здесь |
/srv/puppet/control-repo.git/ | Bare-репозиторий (origin для r10k) | Нет (наполняется через push) |
/etc/puppetlabs/code/environments/production/ | Раскатанный код (r10k) | Нет — перезапишется |
/etc/puppetlabs/r10k/r10k.yaml | Конфиг r10k | Редко, вручную |
/etc/puppetlabs/puppet/puppet.conf | Конфиг сервера/агента | Редко, вручную |
Главное правило: все правила — манифесты, данные, шаблоны — правятся только в
/root/control-repoи раскатываются через git-цикл. Файлы в/etc/puppetlabs/code— это результат раскатки. Любая ручная правка там исчезнет при следующемr10k deploy.
Структура control-repo
/root/control-repo/
├── environment.conf # modulepath = site-modules:modules:$basemodulepath
├── hiera.yaml # иерархия данных
├── Puppetfile # внешние модули с Forge
├── data/ # ДАННЫЕ (значения)
│ ├── common.yaml # дефолты для ВСЕХ нод
│ └── nodes/ # переопределения для конкретной ноды
│ └── <fqdn>.yaml
├── manifests/
│ └── site.pp # классификация: какая нода → какая роль
└── site-modules/ # КОД (логика)
├── role/manifests/ # роли: base.pp, webserver.pp, ...
└── profile/
├── manifests/ # профили: ssh, ntp, motd, users, packages, ...
└── templates/ # шаблоны конфигов (.epp)
Архитектура: roles & profiles + Hiera
Это классический паттерн Puppet, и он стоит того, чтобы понять его один раз:
- role — «чем является сервер». Роль почти пустая, она только собирает профили. У ноды всегда одна роль.
- profile — «технологический кусок»: пользователи, пакеты, Zabbix, nginx. Профили комбинируются в роли.
- Hiera — данные, отделённые от кода. Логика живёт в профиле, а значения — в YAML.
- Иерархия: значения ищутся сверху вниз, верхний слой перекрывает нижний. Сейчас у меня так:
nodes/<fqdn>.yaml(частное) →common.yaml(общее).
Смысл разделения проще всего показать на связке. Профиль profile::users читает lookup('profile::users::accounts', ...), а сами пользователи описаны данными в common.yaml. Чтобы добавить пользователя на одну конкретную ноду, я дописываю его в data/nodes/<fqdn>.yaml — и не трогаю код вообще. Логика отдельно, данные отдельно.
Рабочий цикл
Любое изменение — те же четыре шага, что и всегда:
cd /root/control-repo
# ... правишь нужные файлы ...
git add -A
git commit -m 'понятное описание изменения'
git push origin production
/opt/puppetlabs/puppet/bin/r10k deploy environment production -pv
После этого изменение применится либо автоматически на всех нодах при плановом прогоне (демон, примерно раз в 30 минут), либо немедленно на конкретной ноде командой puppet agent -t.
И повторю правило из второй части: перед раскаткой на боевую ноду с непонятным состоянием — сначала сухой прогон puppet agent -t --noop.
Рецепты на частые задачи
Добавить пакет всем нодам
Файл site-modules/profile/manifests/packages.pp, список $base_packages:
$base_packages = [
'vim', 'htop', 'iftop', 'zip', 'mc', 'wget', 'curl',
'bash-completion', 'git', 'locales', 'apt-transport-https',
'ca-certificates', 'gnupg', 'sudo', 'tzdata',
]
package { $base_packages:
ensure => installed, # installed/present — НЕ latest (обновлениями не управляем)
}
Добавил имя в массив, закоммитил, запушил, задеплоил.
Но есть нюанс: пакет может быть не во всех дистрибутивах. Например, btop появился только с Debian 12, на Debian 11 его нет — и прогон упадёт с Unable to locate package btop. Ставим по версии ОС:
$btop_available = $facts['os']['name'] ? {
'Debian' => versioncmp($facts['os']['release']['major'], '12') >= 0,
'Ubuntu' => versioncmp($facts['os']['release']['major'], '22') >= 0,
default => false,
}
if $btop_available {
package { 'btop': ensure => installed }
}
Добавить или изменить пользователя
Пользователи — это данные в Hiera. Файл data/common.yaml (для всех) или data/nodes/<fqdn>.yaml (для одной ноды):
profile::users::accounts:
sysadm:
sudo: true
ssh_key_type: 'ssh-ed25519' # или 'ssh-rsa'
ssh_key: 'AAAAC3Nza...ТЕЛО_КЛЮЧА...' # БЕЗ префикса типа и без хвоста-комментария
Логика — в site-modules/profile/manifests/users.pp: он создаёт пользователя, кладёт ключ, выдаёт sudo через /etc/sudoers.d/ с проверкой visudo. Важная деталь: ключ вставляется только телом — без ssh-rsa/ssh-ed25519 в начале (это отдельное поле ssh_key_type) и без user@host в конце.
Настройка SSH
Запрещаем парольный вход под root (только по ключу) и фиксируем порт. Управляем через drop-in в /etc/ssh/sshd_config.d/ — основной sshd_config не трогаем, он обновляется пакетом.
class profile::ssh (
Integer $port = 22,
Enum['yes','no','prohibit-password'] $permit_root_login = 'prohibit-password',
) {
package { 'openssh-server': ensure => installed }
service { 'ssh':
ensure => running,
enable => true,
require => Package['openssh-server'],
}
file { '/etc/ssh/sshd_config.d/99-openvox.conf':
ensure => file,
owner => 'root',
group => 'root',
mode => '0644',
content => epp('profile/sshd_openvox.conf.epp', {
'port' => $port,
'permit_root_login' => $permit_root_login,
}),
require => Package['openssh-server'],
notify => Service['ssh'],
}
}
Шаблон sshd_openvox.conf.epp:
<%- | Integer $port, String $permit_root_login | -%>
# Managed by OpenVox
Port <%= $port %>
PermitRootLogin <%= $permit_root_login %>
PermitRootLogin prohibit-password — root входит только по ключу, пароль запрещён. Значение no запретит root полностью (тогда полагаемся на sysadm + sudo).
Осторожно — SSH-правилами можно отрезать себе доступ. Применяйте их строго по шагам: убедитесь, что есть рабочий вход по ключу; держите запасной доступ через KVM или консоль провайдера; сначала
puppet agent -t --noop(посмотреть diff); потом боевой прогон; и обязательно проверьте результат не закрывая текущую сессию —sshd -tна синтаксис,sshd -T | grep -iE 'port|permitrootlogin'на эффективные значения, и заход из второго терминала. Только убедившись, что новый вход работает, закрывайте старую сессию.
Проверьте, что drop-in вообще подхватывается. В основном
/etc/ssh/sshd_configдолжна быть строкаInclude /etc/ssh/sshd_config.d/*.conf(в Debian 12/13 она есть по умолчанию). Проверка:grep -n Include /etc/ssh/sshd_config.
Мониторинг: Zabbix-агент
Конфиг генерится из шаблона zabbix_agentd.conf.epp. Правим шаблон, коммитим, деплоим — профиль сам перезапустит сервис при изменении конфига через notify. Адрес сервера мониторинга — данные Hiera:
# data/common.yaml
profile::zabbix_agent::server: 'zabbix.example.com'
Полное управление конфигом означает ответственность за все пути. Профиль перезаписывает
zabbix_agentd.confцеликом, поэтому он же обязан гарантировать наличие всех нужных каталогов (/etc/zabbix/zabbix_agentd.conf.d,/var/log/zabbix-agent) черезfile { ... ensure => directory }. Иначе на «нестандартных» нодах сервис просто не стартует.
Отдельная история — мажорный апгрейд версии агента, и вот тут я набил шишку, которой стоит поделиться:
Порядок ресурсов при апгрейде критичен. Каталоги (
conf.d, лог) надо создавать сbefore => Package['zabbix-agent'], то есть до пакета. Причина: post-install скрипт пакета стартует сервис, а старый конфиг версии 6.0 обычно содержитInclude=/etc/zabbix/zabbix_agentd.conf.d/*.conf. Если каталога нет — агент 7.0 падает на старте,dpkgвозвращает ошибку, Puppet прерывается, не дойдя до создания каталога, и получается замкнутый круг. Правильный порядок в профиле: репозиторий/ключ/пиннинг → каталоги → пакет → конфиг → сервис.
Обкатывайте апгрейд на одной чистой ноде. Мажорный переход (6.0 → 7.0) не запускают вслепую на всю группу. Берём одну ноду, прогоняем
puppet agent -t, проверяемzabbix_agentd -Vиsystemctl is-active zabbix-agent. И только убедившись, что апгрейд проходит за один прогон без ручного вмешательства — пускаем на весь парк.
Синхронизация времени (chrony)
Расхождение часов ломает TLS, логи и аутентификацию — это базовая гигиена, которую легко забыть.
class profile::ntp (
Array[String] $servers = ['pool.ntp.org'],
) {
package { 'chrony': ensure => installed }
file { '/etc/chrony/chrony.conf':
ensure => file,
owner => 'root',
group => 'root',
mode => '0644',
content => epp('profile/chrony.conf.epp', { 'servers' => $servers }),
require => Package['chrony'],
notify => Service['chrony'],
}
service { 'chrony':
ensure => running,
enable => true,
require => Package['chrony'],
}
}
Серверы переопределяются через Hiera — например, если весь парк в одном регионе:
profile::ntp::servers:
- 'ru.pool.ntp.org' # ближе по задержке
Конфликт с systemd-timesyncd. chrony и timesyncd оба хотят рулить временем. Пакет chrony обычно сам отключает timesyncd, но на «обжитых» нодах стоит проверить:
systemctl status systemd-timesyncd— и если он активен рядом с chrony, отключить.
Автообновления безопасности
Ставим unattended-upgrades и настраиваем автоматическую установку только security-обновлений. По умолчанию — без автоперезагрузки: патчи применяются, а ребут делаем вручную в удобное окно.
class profile::updates (
Boolean $auto_reboot = false,
String $auto_reboot_time = '04:30',
) {
package { 'unattended-upgrades': ensure => installed }
file { '/etc/apt/apt.conf.d/50unattended-upgrades':
ensure => file,
owner => 'root',
group => 'root',
mode => '0644',
content => epp('profile/50unattended-upgrades.epp', {
'auto_reboot' => $auto_reboot,
'auto_reboot_time' => $auto_reboot_time,
}),
require => Package['unattended-upgrades'],
}
service { 'unattended-upgrades':
ensure => running,
enable => true,
require => Package['unattended-upgrades'],
}
}
Автоперезагрузку можно включить точечно через Hiera, только там, где ребут допустим:
# data/nodes/<fqdn>.yaml
profile::updates::auto_reboot: true
profile::updates::auto_reboot_time: '04:30'
Почему не
ensure => latestна всех пакетах. Установку и обновление я намеренно разделяю:profile::packagesдержитensure => installed(управляет составом пакетов), а обновления безопасности накатываетunattended-upgradesконтролируемо.ensure => latestна всём подряд обновлял бы пакеты в случайный момент прогона, с рестартами сервисов посреди рабочего дня.
Исключить ноду из профиля
Рано или поздно появится нода, которой конкретный профиль не нужен. Классический пример: на сервере мониторинга уже стоит zabbix-agent2, и классический zabbix-agent там лишний. Делаем профиль управляемым через флаг:
class profile::zabbix_agent (
String $server,
Boolean $manage = true,
) {
if $manage {
# ... установка и настройка ...
}
}
И выключаем на конкретной ноде в data/nodes/<fqdn>.yaml:
---
profile::zabbix_agent::manage: false
Per-node слой стоит в иерархии выше common, поэтому переопределение действует только для этой ноды, остальных не касаясь.
Реальный кейс: конфликт пользователей с хостинг-панелями. На сервере с панелью Hestia уже был системный пользователь
sysadm— веб-аккаунт панели, настроенный на SFTP-only. А мойprofile::usersсоздаёт своего админскогоsysadmс sudo и ключом. Два разных пользователя с одним именем конфликтуют: Puppet переопределяет параметры панельного юзера (shell, группы), и вход под ним начинает выдаватьThis service allows sftp connections only. Чтобы этого избежать,profile::usersтоже стоит сделать управляемым флагом и исключать такие ноды через per-node Hiera — либо переименовать админского пользователя Puppet в что-то уникальное вродеsysadmadm. На серверах с панелями — обязательно--noopперед боевым прогоном.
Добавить новую роль
role::base — общий фундамент для всех нод. Специфику выносим в отдельные роли поверх него:
# site-modules/role/manifests/webserver.pp
class role::webserver {
include profile::base # общая база
include profile::nginx # специфика веб-сервера
}
И привязываем ноду в manifests/site.pp:
node 'web-01.example.com' {
include role::webserver
}
node default {
include role::base
}
Проверка и откат
Первое, что стоит взять в привычку, — проверять синтаксис до коммита. Это ловит ошибки за секунду, не тратя целый цикл deploy → прогон:
# один файл
/opt/puppetlabs/bin/puppet parser validate site-modules/profile/manifests/packages.pp
# все манифесты разом
/opt/puppetlabs/bin/puppet parser validate site-modules/profile/manifests/*.pp
Пустой вывод — синтаксис в порядке.
Puppet — это не Ruby и не Python. Оператор
*здесь означает умножение чисел, а не повторение строки:'-' * 100выдаст ошибкуcannot be converted to Numeric. Линию-разделитель приходится писать литералом. Ещё один довод в пользуpuppet parser validateдо push.
И то, ради чего вообще нужен git, — безопасный откат:
cd /root/control-repo
git log --oneline # найти хороший коммит
git revert <hash> # безопасно отменить изменение
git push origin production
/opt/puppetlabs/puppet/bin/r10k deploy environment production -pv
Как читать вывод прогона
Puppet идемпотентен — он применяет только разницу между текущим и желаемым состоянием. Поэтому вывод прогона читается так:
Notice: /Stage[main]/.../ensure: created— ресурс был создан или изменён.- Пометка
(corrective)— Puppet исправил дрейф: кто-то поменял руками, а Puppet вернул как надо. - Прогон без строк изменений (
Applied catalog in 0.1s) — система уже в нужном состоянии, менять нечего. Это правильно и ожидаемо при повторных прогонах.
Именно последний пункт — суть всего подхода. Когда прогон не меняет ничего, потому что всё уже так, как описано в коде, — значит, инфраструктура действительно управляется кодом, а не живёт своей жизнью.
Что в итоге
За три части мы прошли путь от пустого сервера до управляемого парка: подняли OpenVox, подключили ноды, разложили настройки по ролям, профилям и Hiera. Теперь любой сервер настраивается не руками по SSH, а коммитом в git — с историей, откатом и предсказуемым результатом.
Это и есть кукловодство в чистом виде: вы описываете, каким должен быть мир, а система приводит его в это состояние и удерживает. А главное — теперь любую настройку можно объяснить, найдя её в истории коммитов, и повторить на новом сервере одной командой.
Основной цикл на этом закончен. Но есть ещё одна деталь, которая делает инфраструктуру по-настоящему автономной: в дополнительной, четвёртой части поднимем локальное зеркало репозитория OpenVox — чтобы ноды качали пакеты из своей сети, а не зависели от медленного внешнего источника.