evgen@spb: ~/2026/07/kuklovodim-cherez-openvox-chast-3-roli-profili-i-pravila/

Кукловодим через 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 — чтобы ноды качали пакеты из своей сети, а не зависели от медленного внешнего источника.

evgen@spb: ~/contacts
evgen@spbping -c1 evgen.info
64 bytes from evgen.info: отвечу в течение рабочего дня
evgen@spbcat contacts.txt
mail      ya@evgen.info
telegram  @EvgenOne
github    github.com/onegin
blog      evgen.info/blog
evgen@spbecho $ЗАЧЕМ_ПИСАТЬ
интересная инфраструктурная задача · вопрос по Proxmox или Icinga ·
желание обсудить, почему memtest молчал, а память была битая
evgen@spb
© 2026 Евгений Подолинский · Санкт-Петербург
синий здесь – не тот, о котором вы подумали.