Ansible, Puppet, Chef и управление конфигурацией
В предыдущей статье — https://courses.digitable.life/post/devops/09-terraform-and-iac/ — мы научились создавать инфраструктуру: Terraform поднял виртуальные машины, сети, балансировщики. Но на выходе у нас пустая машина с голым образом ОС. Кто-то должен поставить на неё пакеты, положить конфиги, создать пользователей, включить сервисы, накатить сертификаты и агента мониторинга.
Это и есть управление конфигурацией (configuration management, CM) — дисциплина о том, как привести состояние операционной системы к описанному в репозитории виду и удерживать его там. Terraform отвечает на вопрос «какие машины существуют», CM — на вопрос «что внутри каждой машины».
Статья построена так: сначала первопричина проблемы и два фундаментальных понятия (идемпотентность и сходимость), затем разбор трёх систем на рабочих конфигах, затем честное сравнение по стоимости, порогу входа и эксплуатационной нагрузке, и в конце — когда всё это вообще не нужно.
Проблема: состояние машины — это накопленная история
Возьмём первопричину. Свежая машина одинакова у всех: тот же образ, тот же набор пакетов. Через полгода эксплуатации две машины из одного образа отличаются друг от друга десятками деталей — потому что состояние Linux-хоста есть накопленная история всех команд, которые на нём выполнялись.
Кто-то зашёл по ssh и поправил nginx.conf, чтобы «быстро потушить инцидент». Кто-то поставил strace для отладки и забыл. unattended-upgrades обновил openssl на одной машине и не успел на второй, потому что там кончилось место в /var. Автор скрипта дописал строку в /etc/hosts.
Это явление называется дрейф конфигурации (configuration drift). Его два последствия неприятны по-разному:
- «Снежинки» (snowflake servers) — машины, которые никто не может воссоздать. Известный термин Мартина Фаулера: Snowflake Server. Такой сервер работает ровно до первого отказа диска, после чего команда узнаёт, что восстановить его невозможно.
- Невоспроизводимые баги. «На третьей ноде из пяти запросы иногда падают по таймауту» — потому что на ней остался старый
net.core.somaxconnот прошлогодней ручной правки.
Схема выше — самая полезная карта в этой теме. Управление конфигурацией занимает ровно один слой: состояние ОС. Ниже — Terraform и cloud-init, выше — CI/CD и оркестратор. Большая часть боли в реальных проектах возникает из-за смешивания слоёв: Ansible, который создаёт виртуалки в облаке; Terraform, который через remote-exec ставит пакеты; Puppet, который деплоит приложение.
Идемпотентность и сходимость: два понятия, на которых всё держится
Императивно против декларативно
Обычный bash-скрипт императивен: он описывает шаги.
# Императивно: описываем действия. Запусти дважды — получишь мусор.
apt-get install -y nginx
echo "worker_processes 4;" >> /etc/nginx/nginx.conf # второй запуск → строка дважды
useradd deploy # второй запуск → ошибка "user exists"
systemctl start nginx
Системы управления конфигурацией декларативны: они описывают желаемое состояние, а решение о необходимых действиях принимает сам инструмент, сравнив желаемое с текущим.
# Декларативно: описываем результат. Запускай сколько угодно раз.
- name: nginx установлен
ansible.builtin.package:
name: nginx
state: present
- name: пользователь deploy существует
ansible.builtin.user:
name: deploy
shell: /bin/bash
state: present
Идемпотентность
Идемпотентная операция — та, повторное применение которой не меняет результата. Формально: применение к системе один раз и применение N раз дают одно и то же конечное состояние.
Это не академическая тонкость, а практическое требование: без идемпотентности вы не можете запускать конфигурацию регулярно, а значит не можете бороться с дрейфом. Инструмент, который безопасно запускать только один раз, — это установщик, а не система управления конфигурацией.
Ключевой практический критерий: второй прогон подряд должен показать ноль изменений. Это и есть стандартный тест на идемпотентность, встроенный в Molecule и Test Kitchen.
Сходимость
Сходимость (convergence) — свойство системы приближаться к желаемому состоянию при каждом прогоне, даже стартуя из произвольного состояния. Понятие пришло из CFEngine Марка Бёрджесса и его теории обещаний: узел не «получает приказы», а «обещает» удерживать своё состояние и самостоятельно чинит расхождения.
Отсюда важный вывод, который часто теряют: ценность CM определяется не выразительностью языка, а частотой сходимости.
Команда, которая гоняет Ansible вручную раз в релиз, живёт с высокой «пилой» дрейфа. Команда с Puppet-агентом и интервалом 30 минут — с низкой. При этом Ansible ничем не хуже: его тоже можно запускать по расписанию (ansible-pull, cron, AWX/AAP, GitHub Actions по schedule). Просто по умолчанию так не делают.
Push против pull
Это главная архитектурная развилка в теме.
| Свойство | Push (Ansible) | Pull (Puppet, Chef, Salt-minion) |
|---|---|---|
| Что нужно на хосте | ssh + python3 | установленный и обновляемый агент |
| Кто инициирует | человек или CI | сам хост по таймеру |
| Порядок выката | вы его видите и контролируете | размазан по интервалу, хосты сходятся вразнобой |
| Новый хост | нужно добавить в инвентарь | сам подключится и получит свой каталог |
| Сеть | управляющий узел должен достучаться до хоста | хост должен достучаться до сервера (дружелюбнее к NAT и DMZ) |
| Дрейф между прогонами | накапливается | срезается каждый интервал |
| Масштаб 5000+ хостов | упирается в форки и SSH | масштабируется естественно, но нужен HA-сервер |
| Порог входа | часы | дни–недели |
Практический вывод: push проще начать, pull дешевле удерживать на масштабе. Граница проходит где-то в районе нескольких сотен долгоживущих хостов — и, что важнее, зависит не от количества, а от того, есть ли у вас люди, ходящие на серверы руками.
Отдельно стоит ansible-pull — режим, в котором Ansible клонирует репозиторий на самом хосте и применяет плейбук локально. Это push-инструмент в pull-обвязке, и на практике он живёт в двух нишах: сотни одинаковых edge-устройств и bootstrap-конфигурация внутри образа.
Ansible: агентless, YAML и низкий порог входа
Ansible создан Майклом ДеХааном в 2012 году, куплен Red Hat в 2015. Транспорт — SSH, язык описания — YAML, реализация модулей — Python. Агента нет: модуль копируется на хост, выполняется и удаляется.
Как устроен прогон на самом деле
Понимание этого механизма закрывает 80% вопросов «почему медленно» и «почему не работает»:
- Ansible читает инвентарь и строит список хостов play.
- Для каждого хоста поднимается SSH-соединение (по умолчанию с
ControlPersist). - Выполняется неявная задача
gather_facts— модульsetup, который собирает несколько сотен переменных о хосте. - Для каждой задачи модуль упаковывается в один Python-файл (механизм AnsiballZ), копируется во временный каталог на хосте, запускается, возвращает JSON, удаляется.
- Результат агрегируется,
notifyоткладывает handlers на конец play.
Ключевое следствие: каждая задача — это отдельная передача и запуск процесса Python на удалённом хосте. Отсюда линейная зависимость времени от числа задач и главные рычаги ускорения.
Структура репозитория
infra/
├── ansible.cfg
├── inventories/
│ ├── prod/
│ │ ├── hosts.yml
│ │ └── group_vars/
│ │ ├── all.yml
│ │ ├── web.yml
│ │ └── db/
│ │ ├── vars.yml
│ │ └── vault.yml # зашифровано ansible-vault
│ └── staging/
│ └── hosts.yml
├── roles/
│ └── nginx/
│ ├── defaults/main.yml # переменные с низшим приоритетом
│ ├── tasks/main.yml
│ ├── handlers/main.yml
│ ├── templates/nginx.conf.j2
│ ├── files/
│ ├── meta/main.yml
│ └── molecule/default/ # тесты роли
├── requirements.yml # внешние коллекции и роли
└── site.yml
Рабочий ansible.cfg
[defaults]
inventory = inventories/prod/hosts.yml
roles_path = roles
collections_path = collections
host_key_checking = True
retry_files_enabled = False
stdout_callback = yaml
callbacks_enabled = timer, profile_tasks # покажет, какие задачи тормозят
forks = 50 # по умолчанию 5 — почти всегда мало
interpreter_python = auto_silent
# Кэш фактов: не собирать факты заново на каждый прогон
gathering = smart
fact_caching = jsonfile
fact_caching_connection = .facts_cache
fact_caching_timeout = 7200
[ssh_connection]
# pipelining убирает лишние SSH round-trip'ы на каждую задачу.
# Требует отключённого requiretty в sudoers (в современных дистрибутивах уже так).
pipelining = True
ssh_args = -o ControlMaster=auto -o ControlPersist=300s -o PreferredAuthentications=publickey
[privilege_escalation]
become = True
become_method = sudo
become_ask_pass = False
Инвентарь
# inventories/prod/hosts.yml
all:
vars:
ansible_user: deploy
ansible_python_interpreter: /usr/bin/python3
children:
web:
hosts:
web-[01:06].prod.example.com:
vars:
nginx_worker_processes: auto
app_port: 8080
db:
hosts:
db-01.prod.example.com:
postgres_role: primary
db-02.prod.example.com:
postgres_role: replica
moscow:
hosts:
web-[01:03].prod.example.com:
db-01.prod.example.com:
Группы пересекаются намеренно: одна ось — роль (web, db), другая — размещение (moscow, frankfurt). Это позволяет писать --limit 'web:&moscow' — веб-серверы и в Москве.
Роль с правильными идиомами
# roles/nginx/tasks/main.yml
---
- name: Установить nginx
ansible.builtin.package:
name: "{{ nginx_package }}"
state: present
tags: [nginx, packages]
- name: Каталог для конфигов сайтов
ansible.builtin.file:
path: /etc/nginx/conf.d
state: directory
owner: root
group: root
mode: "0755"
- name: Основной конфиг nginx
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: "0644"
# Валидируем ДО записи: битый конфиг не попадёт на диск
validate: nginx -t -c %s
backup: true
notify: Перечитать конфиг nginx
tags: [nginx, config]
- name: Параметры ядра для высоконагруженного фронта
ansible.posix.sysctl:
name: "{{ item.name }}"
value: "{{ item.value }}"
sysctl_file: /etc/sysctl.d/60-nginx.conf
reload: true
loop:
- { name: net.core.somaxconn, value: "16384" }
- { name: net.ipv4.tcp_max_syn_backlog, value: "8192" }
- { name: net.ipv4.ip_local_port_range, value: "10240 65535" }
tags: [nginx, tuning]
- name: Сервис nginx включён и запущен
ansible.builtin.systemd:
name: nginx
state: started
enabled: true
daemon_reload: true
# Пример корректного использования command: с защитой от неидемпотентности
- name: Сгенерировать dhparam
ansible.builtin.command:
cmd: openssl dhparam -out /etc/nginx/dhparam.pem 2048
creates: /etc/nginx/dhparam.pem # если файл есть — задача пропускается
tags: [nginx, tls]
# roles/nginx/handlers/main.yml
---
- name: Перечитать конфиг nginx
ansible.builtin.systemd:
name: nginx
state: reloaded
Handler выполняется один раз в конце play, даже если его вызвали десять задач, — и только если хотя бы одна из них реально что-то изменила. Это прямое следствие идемпотентности: reload не нужен, если конфиг не поменялся.
Шаблон Jinja2
{# roles/nginx/templates/nginx.conf.j2 #}
# УПРАВЛЯЕТСЯ ANSIBLE. Ручные правки будут затёрты при следующем прогоне.
# Источник: roles/nginx/templates/nginx.conf.j2
user {{ nginx_user | default('www-data') }};
worker_processes {{ nginx_worker_processes | default('auto') }};
worker_rlimit_nofile {{ (ansible_facts.processor_vcpus * 8192) | int }};
events {
worker_connections {{ nginx_worker_connections | default(4096) }};
use epoll;
}
http {
keepalive_timeout {{ nginx_keepalive | default(65) }};
server_tokens off;
upstream app {
least_conn;
{% for host in groups['web'] %}
server {{ hostvars[host]['ansible_facts']['default_ipv4']['address'] }}:{{ app_port }} max_fails=3 fail_timeout=15s;
{% endfor %}
keepalive 32;
}
include /etc/nginx/conf.d/*.conf;
}
Заголовок «управляется Ansible» обязателен. Он в разы сокращает время на инцидентах: человек видит, что править файл руками бессмысленно.
Секреты: ansible-vault
# Создать зашифрованный файл переменных
$ ansible-vault create inventories/prod/group_vars/db/vault.yml
New Vault password:
Confirm New Vault password:
# Отредактировать
$ ansible-vault edit inventories/prod/group_vars/db/vault.yml
# Зашифровать одно значение, чтобы положить его в обычный YAML
$ ansible-vault encrypt_string 'S3cr3tP@ss' --name 'vault_postgres_password'
vault_postgres_password: !vault |
$ANSIBLE_VAULT;1.1;AES256
65396530353833383437613332633866...
# Прогон с паролем из внешнего источника
$ ansible-playbook site.yml --vault-password-file ./scripts/vault-pass.sh
Соглашение, которое стоит принять сразу: все секретные переменные именуются с префиксом vault_ и лежат только в vault.yml, а в обычных vars.yml на них ссылаются:
# vars.yml (в git открытым текстом)
postgres_password: "{{ vault_postgres_password }}"
Так grep -r vault_ мгновенно показывает все секреты, а diff в PR остаётся читаемым. Для крупных установок vault-файлы заменяют на внешний секрет-менеджер через lookup-плагины (community.hashi_vault.hashi_vault, AWS Secrets Manager) — см. https://courses.digitable.life/post/devops/17-security-in-pipeline/.
Проверка перед выкатом: check mode и diff
$ ansible-playbook site.yml --limit web-01.prod.example.com --check --diff
PLAY [Веб-серверы] ****************************************************
TASK [nginx : Основной конфиг nginx] **********************************
--- before: /etc/nginx/nginx.conf
+++ after: /etc/nginx/nginx.conf
@@ -2,7 +2,7 @@
user www-data;
-worker_processes 4;
+worker_processes auto;
worker_rlimit_nofile 65536;
changed: [web-01.prod.example.com]
TASK [nginx : Сервис nginx включён и запущен] *************************
ok: [web-01.prod.example.com]
RUNNING HANDLER [nginx : Перечитать конфиг nginx] *********************
changed: [web-01.prod.example.com]
PLAY RECAP ************************************************************
web-01.prod.example.com : ok=8 changed=2 unreachable=0 failed=0 skipped=1
--check --diff на проде — это ваш детектор дрейфа. Регулярный прогон в check-режиме по расписанию с алертом при changed != 0 даёт то же, что даёт Puppet-отчётность, почти бесплатно.
Производительность: что реально даёт эффект
Типичный порядок величин для плейбука из ~120 задач на 60 хостах (замеры сильно зависят от сети и задач, но соотношения устойчивы):
| Изменение | Время прогона | Комментарий |
|---|---|---|
Дефолты (forks=5, без pipelining) |
~22 мин | базовая точка |
forks = 50 |
~5 мин | самый дешёвый выигрыш, упирается в CPU управляющего узла |
+ pipelining = True |
~3 мин 20 с | убирает лишние SSH-операции на каждую задачу |
+ ControlPersist=300s |
~3 мин | экономит handshake между задачами |
+ fact caching |
~2 мин 30 с | setup — самая дорогая задача, если факты не нужны свежие |
+ gather_facts: false там, где факты не нужны |
~2 мин 10 с | точечно, в отдельных play |
+ free стратегия вместо linear |
~1 мин 50 с | хосты не ждут отстающих; ломает порядок, осторожно с rolling-выкатом |
Что не ускоряет: переписывание задач на shell. Наоборот, это ломает идемпотентность и check mode.
Про forks: значение ограничено CPU и памятью управляющего узла. Каждый форк — процесс Python. Практическое правило: начните с forks = 2 × число ядер управляющего узла, дальше смотрите на его load average.
Тестирование ролей: Molecule
# roles/nginx/molecule/default/molecule.yml
---
driver:
name: docker
platforms:
- name: ubuntu2404
image: geerlingguy/docker-ubuntu2404-ansible:latest
pre_build_image: true
command: /lib/systemd/systemd
privileged: true
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroupns_mode: host
- name: rocky9
image: geerlingguy/docker-rockylinux9-ansible:latest
pre_build_image: true
command: /usr/sbin/init
privileged: true
provisioner:
name: ansible
verifier:
name: ansible
scenario:
test_sequence:
- dependency
- lint
- syntax
- create
- converge
- idempotence # ← ключевой шаг: второй прогон должен дать changed=0
- verify
- destroy
$ molecule test
...
INFO Running default > idempotence
PLAY RECAP *************************************************************
ubuntu2404 : ok=8 changed=0 unreachable=0 failed=0
rocky9 : ok=8 changed=0 unreachable=0 failed=0
INFO Idempotence completed successfully.
Если idempotence падает — почти всегда виноват command/shell без creates/changed_when, либо lineinfile с нестабильным regexp, либо шаблон, в который подставляется timestamp.
Прогон из CI
# .github/workflows/ansible.yml
name: ansible
on:
pull_request:
paths: ["infra/**"]
push:
branches: [main]
paths: ["infra/**"]
schedule:
- cron: "0 */6 * * *" # детектор дрейфа каждые 6 часов
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pipx install ansible-core ansible-lint
- run: ansible-lint --profile production infra/
check:
needs: lint
runs-on: ubuntu-latest
environment: prod-readonly
steps:
- uses: actions/checkout@v4
- run: pipx install ansible-core
- name: SSH-ключ
run: |
install -m 700 -d ~/.ssh
echo "${{ secrets.ANSIBLE_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H bastion.example.com >> ~/.ssh/known_hosts
- name: Прогон в check-режиме
id: drift
working-directory: infra
env:
ANSIBLE_VAULT_PASSWORD_FILE: /dev/stdin
run: |
echo "${{ secrets.VAULT_PASS }}" | \
ansible-playbook site.yml --check --diff | tee /tmp/out.txt
if grep -qE 'changed=[1-9]' /tmp/out.txt; then
echo "drift=true" >> "$GITHUB_OUTPUT"
fi
- name: Сообщить о дрейфе
if: steps.drift.outputs.drift == 'true' && github.event_name == 'schedule'
run: echo "::warning::Обнаружен дрейф конфигурации на проде"
Применение (apply) на прод из CI требует ручного approval — паттерн тот же, что для terraform apply в https://courses.digitable.life/post/devops/09-terraform-and-iac/.
Puppet: модель ресурсов, каталог и настоящая сходимость
Puppet появился в 2005 году (Люк Кейнис), в 2022 компания Puppet вошла в состав Perforce. Это самая «строгая» из трёх систем: собственный декларативный DSL, компиляция каталога на сервере, агент с постоянным таймером.
Ключевые понятия
- Ресурс — единица состояния:
package,file,service,user,exec. Уровень абстракции над ОС (RAL, Resource Abstraction Layer):package { 'nginx': }разворачивается вaptилиdnfв зависимости от фактаos.family. - Каталог — скомпилированный граф ресурсов для конкретного узла. Компилируется на сервере из манифестов + данных Hiera + фактов узла.
- Facts — данные об узле, собираются Facter.
- Hiera — иерархическое хранилище данных, отделяющее «что» (код) от «сколько и каких» (данные).
- Отношения —
before/require/notify/subscribe. Порядок в файле не определяет порядок применения (по умолчанию), порядок задаёт граф.
Эта диаграмма показывает то, чего нет в push-модели: узел всегда находится в одном из состояний цикла, и «разошёлся» — нормальное, ожидаемое состояние, а не авария.
Манифест
# modules/profile/manifests/webserver.pp
class profile::webserver (
Integer[1,64] $worker_processes = 4,
Stdlib::Port $listen_port = 80,
Array[Stdlib::Fqdn] $server_names = [],
Boolean $manage_firewall = true,
) {
package { 'nginx':
ensure => installed,
}
file { '/etc/nginx/nginx.conf':
ensure => file,
owner => 'root',
group => 'root',
mode => '0644',
content => epp('profile/nginx.conf.epp', {
'worker_processes' => $worker_processes,
'listen_port' => $listen_port,
'server_names' => $server_names,
'vcpus' => $facts['processors']['count'],
}),
# Отношения: конфиг ставится после пакета и уведомляет сервис
require => Package['nginx'],
notify => Service['nginx'],
}
service { 'nginx':
ensure => running,
enable => true,
hasrestart => true,
hasstatus => true,
}
# Условная логика по фактам — сильная сторона Puppet
if $facts['os']['family'] == 'RedHat' and $facts['os']['selinux']['enabled'] {
selboolean { 'httpd_can_network_connect':
persistent => true,
value => 'on',
}
}
if $manage_firewall {
firewall { "100 разрешить http на ${listen_port}":
dport => [$listen_port],
proto => 'tcp',
jump => 'accept',
}
}
}
Обратите внимание на типизированные параметры класса (Integer[1,64], Stdlib::Port). Каталог не скомпилируется, если кто-то передаст worker_processes: "много". Ansible такой проверки не даёт: ошибка вылезет в рантайме на половине парка.
Hiera: разделение кода и данных
# hiera.yaml
version: 5
defaults:
datadir: data
data_hash: yaml_data
hierarchy:
- name: "Секреты по узлу"
lookup_key: eyaml_lookup_key
paths:
- "nodes/%{trusted.certname}.eyaml"
- "common.eyaml"
options:
pkcs7_private_key: /etc/puppetlabs/puppet/eyaml/private_key.pkcs7.pem
pkcs7_public_key: /etc/puppetlabs/puppet/eyaml/public_key.pkcs7.pem
- name: "Конкретный узел"
path: "nodes/%{trusted.certname}.yaml"
- name: "Окружение и роль"
paths:
- "env/%{facts.env}/role/%{facts.role}.yaml"
- "env/%{facts.env}.yaml"
- name: "Семейство ОС"
path: "os/%{facts.os.family}.yaml"
- name: "Общие значения"
path: "common.yaml"
# data/common.yaml
profile::webserver::worker_processes: 4
profile::webserver::listen_port: 80
# data/env/prod/role/frontend.yaml
profile::webserver::worker_processes: 16
profile::webserver::listen_port: 443
profile::webserver::server_names:
- www.example.com
- example.com
Иерархия читается сверху вниз, первое найденное значение выигрывает. Это одна из самых сильных идей Puppet, и её стоит копировать даже если вы на Ansible: там аналог — group_vars/host_vars с приоритетами, но иерархия не настраивается декларативно.
Прогон и noop
# Тестовый прогон без изменений — аналог ansible --check
$ puppet agent -t --noop
Info: Using environment 'production'
Info: Retrieving pluginfacts
Info: Caching catalog for web-01.prod.example.com
Info: Applying configuration version '1752640800'
Notice: /Stage[main]/Profile::Webserver/File[/etc/nginx/nginx.conf]/content:
--- /etc/nginx/nginx.conf 2026-07-10 11:02:14.000000000 +0300
+++ /tmp/puppet-file20260716-2841-1qk3n 2026-07-16 10:14:02.000000000 +0300
@@ -2,1 +2,1 @@
-worker_processes 4;
+worker_processes 16;
Notice: /Stage[main]/Profile::Webserver/File[/etc/nginx/nginx.conf]:
Would have changed 'content' (noop)
Notice: Class[Profile::Webserver]: Would have triggered 'refresh' from 1 event
Notice: Applied catalog in 4.31 seconds
# Реальный прогон
$ puppet agent -t
Notice: /Stage[main]/Profile::Webserver/File[/etc/nginx/nginx.conf]/content: content changed
'{sha256}9f2c...' to '{sha256}b41e...'
Notice: /Stage[main]/Profile::Webserver/Service[nginx]: Triggered 'refresh' from 1 event
Notice: Applied catalog in 6.02 seconds
Все отчёты стекаются в PuppetDB, откуда доступны запросом:
# Какие узлы не сходились дольше суток?
$ puppet query 'nodes { report_timestamp < "2026-07-15T10:00:00Z" }' | jq -r '.[].certname'
db-04.prod.example.com
web-11.prod.example.com
# Где нет управляемого ресурса — то есть модуль не применился?
$ puppet query 'resources { type = "Service" and title = "nginx" and parameters.ensure = "running" }' | jq length
23
Эта возможность — «спросить у базы, в каком состоянии парк» — то, чего в чистом Ansible нет из коробки. Именно за неё платят при переходе на pull-модель.
Роли и профили
Каноничный паттерн Puppet (описан Крейгом Данном, Designing Puppet: Roles and Profiles):
- Компонентный модуль — умеет управлять одной технологией (
puppetlabs/nginx). Переиспользуемый, публичный. - Профиль — ваша обёртка: «наш nginx с нашими TLS-настройками и нашим логированием».
- Роль — набор профилей: «фронтенд-сервер = базовый профиль + nginx + node-exporter».
class role::frontend {
include profile::base
include profile::webserver
include profile::monitoring
}
Правило: узел включает ровно одну роль; роль состоит только из include профилей; вся логика — в профилях. Этот паттерн настолько полезен, что его переносят в Ansible как трёхуровневую структуру ролей.
Chef: инфраструктура как настоящий код
Chef (Opscode, 2009; с 2020 года — часть Progress Software) выбрал противоположную Puppet сторону спектра: DSL — это внутренний DSL на Ruby, а значит доступна вся мощь языка. Рецепты выполняются сверху вниз в две фазы: compile (строится коллекция ресурсов) и converge (ресурсы применяются).
# cookbooks/webserver/recipes/default.rb
package 'nginx' do
action :install
end
directory '/etc/nginx/conf.d' do
owner 'root'
group 'root'
mode '0755'
recursive true
end
# Ruby выполняется на фазе compile — можно вычислять что угодно
worker_processes = node['cpu']['total'].to_i * 2
template '/etc/nginx/nginx.conf' do
source 'nginx.conf.erb'
owner 'root'
mode '0644'
variables(
worker_processes: worker_processes,
upstreams: search(:node, 'role:appserver').map { |n| n['ipaddress'] }
)
notifies :reload, 'service[nginx]', :delayed
end
service 'nginx' do
supports status: true, restart: true, reload: true
action [:enable, :start]
end
# Пользовательский ресурс — способ инкапсулировать повторяющийся паттерн
site 'example.com' do
root '/srv/www/example.com'
ssl true
end
# cookbooks/webserver/attributes/default.rb
default['webserver']['worker_connections'] = 4096
default['webserver']['keepalive'] = 65
Атрибуты Chef имеют шесть уровней приоритета (default, force_default, normal, override, force_override, automatic) — это одновременно гибко и известный источник путаницы: «почему у меня подставилось не то значение» отлаживается через node.debug_value('webserver', 'keepalive').
Тестирование в Chef исторически самое зрелое из трёх: Test Kitchen поднимает реальную ВМ или контейнер, ChefSpec проверяет фазу компиляции, InSpec — фактическое состояние.
# test/integration/default/nginx_test.rb — InSpec
describe package('nginx') do
it { should be_installed }
end
describe service('nginx') do
it { should be_enabled }
it { should be_running }
end
describe port(443) do
it { should be_listening }
its('protocols') { should include 'tcp' }
end
describe file('/etc/nginx/nginx.conf') do
its('mode') { should cmp '0644' }
its('content') { should match(/worker_processes\s+\d+;/) }
its('content') { should_not match(/ssl_protocols.*TLSv1\.0/) }
end
InSpec стоит знать отдельно от Chef: это самостоятельный инструмент compliance-as-code, и его прекрасно используют вместе с Ansible или Terraform для приёмки инфраструктуры.
Salt, CFEngine и другие
Полноты ради:
- Salt (SaltStack, 2011; VMware в 2020, далее Broadcom). Транспорт — ZeroMQ, отсюда лучшая в классе скорость на больших парках и мощная система событий (reactor). Умеет и push (
salt-ssh), и pull (salt-minion). Состояния — YAML + Jinja. Ниша: очень большие парки и событийная автоматизация. - CFEngine (1993) — прародитель всего, теория обещаний, очень лёгкий агент на C (десятки мегабайт памяти, секунды на прогон). Живёт там, где ресурсы узла критичны: встраиваемые системы, десятки тысяч узлов.
- cloud-init — не CM, а стандарт первичной инициализации облачной ВМ. Обязателен к знанию: именно он ставит агента или ssh-ключ, с которого начинается всё остальное.
- Bolt / Ansible ad-hoc — оркестрация разовых действий («перезапусти сервис на этих 12 узлах»), а не удержание состояния.
Эта линия важна для правильной оценки: сфера применения CM за десять лет сузилась, но не исчезла. Раньше Puppet деплоил приложение — теперь это делает Kubernetes. Раньше Chef собирал окружение — теперь это делает Dockerfile. Осталось то, что нельзя упаковать в контейнер: сама ОС ноды, bare-metal, сетевое оборудование, базы данных на выделенных серверах, рабочие станции.
Сравнение: что выбирать под свой масштаб
Сводная таблица
| Критерий | Ansible | Puppet | Chef | Salt |
|---|---|---|---|---|
| Модель | push (pull опционально) | pull | pull (push через knife) | обе |
| Язык описания | YAML + Jinja2 | собственный DSL | Ruby DSL | YAML + Jinja2 |
| Агент на хосте | нет (нужны ssh + python3) | да | да | да (или salt-ssh) |
| Порядок выполнения | строго сверху вниз | граф зависимостей | сверху вниз, две фазы | сверху вниз + require |
| Типизация параметров | нет | да, строгая | нет (Ruby) | нет |
| Инвентарь парка из коробки | нет | PuppetDB | Chef Server / Automate | Salt Mine |
| Порог входа | часы | недели | недели | дни |
| Потолок масштаба на «ванильной» установке | сотни хостов | десятки тысяч | десятки тысяч | десятки тысяч |
| Тестирование | Molecule, ansible-lint | rspec-puppet, Litmus, PDK | ChefSpec, Test Kitchen, InSpec | pytest-salt |
| Владелец | Red Hat / IBM | Perforce | Progress | Broadcom |
| Сообщество и модули | Ansible Galaxy, очень большое | Puppet Forge, зрелое | Supermarket, сузилось | средний |
Стоимость: не только лицензии
Главная ошибка при выборе — считать только цену лицензии. Реальная стоимость складывается из трёх частей: лицензии, инфраструктура, люди. Порядки величин на парк 200 узлов, в год:
| Статья | Ansible (open source) | Ansible Automation Platform | Puppet Enterprise | Puppet OSS (Puppetserver) |
|---|---|---|---|---|
| Лицензии | 0 | десятки тысяч $ по прайсу (договорные скидки обычны) | десятки тысяч $ по прайсу | 0 |
| Управляющая инфраструктура | 1 небольшая ВМ или раннер CI (~15 $–40/мес) | HA-кластер контроллера + БД (~250 $–500/мес) | 2 puppetserver + PostgreSQL/PuppetDB (~200 $–400/мес) | то же |
| Люди на поддержку | 0.1–0.2 FTE | 0.2–0.3 FTE | 0.3–0.5 FTE | 0.5–0.8 FTE (сами обновляете, сами чините компиляцию) |
| Что получаете сверх | ничего | RBAC, аудит, расписания, инвентарь, поддержка | те же + отчётность и compliance | сходимость и PuppetDB, но всё руками |
Публичные прайс-листы на Red Hat Ansible Automation Platform и Puppet Enterprise меняются и почти всегда обсуждаются индивидуально — проверяйте актуальные цифры у вендора. Устойчивый вывод другой: на масштабе 200 узлов доминирует не лицензия, а строка «люди». Полставки инженера в год дороже любой из этих лицензий в большинстве рынков.
Отсюда практическое правило выбора:
| Ваш масштаб | Что брать | Почему |
|---|---|---|
| 1–10 долгоживущих хостов | Ansible, запуск руками или из CI | Дрейф контролируем глазами, агенты не окупаются |
| 10–100 хостов, всё в облаке | Ansible из CI по расписанию + --check как детектор дрейфа |
Получаете 80% сходимости за 5% сложности |
| 10–100 хостов, много ручного доступа | Ansible + AWX/AAP либо Puppet OSS | Нужны аудит и регулярная сходимость |
| 100–1000 хостов, смешанный парк | Puppet или Salt | Ручной push уже не влезает в окно, нужна отчётность по парку |
| 1000+ хостов, bare-metal | Puppet, Salt или CFEngine + AAP для оркестрации | Только pull масштабируется, нужна БД состояния парка |
| Всё в Kubernetes, ноды одноразовые | Почти ничего: образ + cloud-init | CM вытеснен неизменяемой инфраструктурой |
Неизменяемая инфраструктура: когда CM не нужен вовсе
Самое честное, что можно сказать о CM в 2026 году: лучший способ бороться с дрейфом — не давать машине прожить достаточно долго, чтобы дрейф накопился.
Это подход неизменяемой инфраструктуры (immutable infrastructure): вместо того чтобы менять работающую машину, вы собираете новый образ и заменяете машину целиком. Дрейф структурно невозможен, потому что состояние не живёт.
срезает дрейф"] M4 --> M3 M3 -.->|ручные правки| M5["Дрейф"] M5 --> M4 end subgraph I["Неизменяемый путь"] direction TB I1["Packer + Ansible
собирают образ в CI"] --> I2["Образ в реестре,
протестирован InSpec"] I2 --> I3["Terraform подменяет
launch template"] I3 --> I4["Rolling replace
инстансов"] I4 --> I5["Старые ВМ уничтожены"] I5 -.->|новая версия| I1 end style M5 fill:#e76f51,color:#fff style I2 fill:#2a9d8f,color:#fff style I5 fill:#3d8bcd,color:#fff
Обратите внимание: в неизменяемом пути Ansible никуда не делся — он переехал внутрь сборки образа. Это лучший из известных компромиссов: вы пишете декларативные роли, но применяете их к чистой машине в CI, где идемпотентность гарантирована стартовым состоянием.
# packer/web.pkr.hcl
packer {
required_plugins {
amazon = { version = ">= 1.3.0", source = "github.com/hashicorp/amazon" }
ansible = { version = ">= 1.1.0", source = "github.com/hashicorp/ansible" }
}
}
source "amazon-ebs" "web" {
region = "eu-central-1"
instance_type = "t3.small"
ssh_username = "ubuntu"
ami_name = "web-{{ timestamp }}"
source_ami_filter {
filters = { name = "ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*" }
owners = ["099720109477"]
most_recent = true
}
}
build {
sources = ["source.amazon-ebs.web"]
# Те же роли, что и в мутабельном сценарии
provisioner "ansible" {
playbook_file = "../infra/site.yml"
extra_arguments = ["--tags", "baseline,nginx", "-e", "packer_build=true"]
}
# Приёмка образа теми же тестами, что и живой машины
provisioner "shell" {
inline = [
"sudo curl -sSL https://omnitruck.chef.io/install.sh | sudo bash -s -- -P inspec",
"sudo inspec exec /tmp/profiles/hardening --chef-license accept-silent",
]
}
post-processor "manifest" {
output = "manifest.json"
}
}
Что остаётся у CM даже при полностью неизменяемом подходе:
- Bare-metal и гипервизоры. Хост, на котором крутится ваш Kubernetes, сам не в контейнере.
- Базы данных на выделенных серверах. Их не пересоздают каждый релиз.
- Сетевое оборудование. Коллекции
cisco.ios,arista.eos— Ansible здесь фактический стандарт. - Разовая оркестрация. «Раскатать патч на 400 машин прямо сейчас» — задача для push-модели, и она не про сходимость.
- Рабочие станции и лаборатории. Puppet и Salt живут в парках десктопов.
Типичные ошибки
1. shell/command вместо модулей. Ломает check mode, идемпотентность и переносимость.
# Плохо: каждый прогон — changed, check mode бесполезен
- shell: echo "127.0.0.1 app.local" >> /etc/hosts
# Хорошо
- ansible.builtin.lineinfile:
path: /etc/hosts
line: "127.0.0.1 app.local"
state: present
# Если shell неизбежен — сделайте его честным
- ansible.builtin.command: /opt/app/bin/migrate --up
register: migrate
changed_when: "'applied' in migrate.stdout"
failed_when: migrate.rc != 0 and 'already up to date' not in migrate.stderr
2. Управление тем, что уже управляется. Прописать конфиг Kubernetes-ноды и через Ansible, и через kubeadm — гарантированная война двух источников истины. Правило: у каждого файла ровно один владелец.
3. Порядок вместо зависимостей (Puppet). Полагаться на позицию ресурса в файле там, где применение идёт по графу. Всегда пишите require/notify или используйте chaining-стрелки.
4. Секреты в git открытым текстом. Даже во «внутреннем» репозитории. git log вечен; ротация после утечки стоит дороже, чем настройка vault с самого начала.
5. Отсутствие тестов ролей. Роль без Molecule — это код без тестов, который выполняется с правами root на всём парке. Стоимость ошибки максимальна из всех типов кода, которые вы пишете.
6. latest вместо present. state: latest делает прогон неидемпотентным по времени: результат зависит от того, что сейчас в репозитории пакетов. Прод пятницы и прод понедельника разъедутся сами.
# Плохо в проде
- ansible.builtin.package: { name: nginx, state: latest }
# Хорошо: версия — часть кода и ревьюится в PR
- ansible.builtin.package: { name: "nginx=1.26.*", state: present }
7. Один гигантский плейбук на всё. Прогон 40 минут → его перестают запускать → дрейф. Режьте на роли и теги, используйте --limit и serial.
8. Игнорирование serial. Прогон, который одновременно перезапускает nginx на всех шести фронтах, — это самодельный outage.
- name: Обновление фронтов
hosts: web
serial: "25%" # четвертями, а не всё сразу
max_fail_percentage: 0 # первая же ошибка останавливает выкат
pre_tasks:
- name: Вывести из балансировки
community.general.haproxy:
socket: /var/run/haproxy.sock
backend: app
host: "{{ inventory_hostname }}"
state: disabled
delegate_to: "{{ groups['lb'][0] }}"
roles:
- nginx
post_tasks:
- name: Дождаться health-check
ansible.builtin.uri:
url: "http://{{ inventory_hostname }}:{{ app_port }}/healthz"
status_code: 200
retries: 30
delay: 2
register: hc
until: hc.status == 200
- name: Вернуть в балансировку
community.general.haproxy:
socket: /var/run/haproxy.sock
backend: app
host: "{{ inventory_hostname }}"
state: enabled
delegate_to: "{{ groups['lb'][0] }}"
Это уже rolling update, только на уровне ВМ — та же логика, что в стратегиях релизов из https://courses.digitable.life/post/devops/04-cd-and-release-strategies/.
Практика продакшена: чек-лист зрелости
Полезная шкала для самооценки — на каком уровне находится ваше управление конфигурацией:
| Уровень | Признаки | Что делать дальше |
|---|---|---|
| 0. Руками | ssh + документ в вики | Записать существующее состояние в роли, хотя бы для новых хостов |
| 1. Скрипты | bash в репозитории, запуск руками | Переписать в декларативные роли, добавить идемпотентность |
| 2. CM есть | Ansible в git, запуск инженером | Убрать запуск с ноутбука, перенести в CI |
| 3. CM в CI | Прогон из пайплайна, PR-ревью изменений | Добавить --check по расписанию, алерт на дрейф |
| 4. Сходимость | Регулярная сходимость, отчётность по парку | Тесты ролей в Molecule, приёмка InSpec |
| 5. Неизменяемость | Образы собираются в CI, ноды одноразовые | CM остаётся для базового слоя и bare-metal |
Несколько правил, которые окупаются на любом уровне:
- Запретите ssh на прод для людей — или хотя бы логируйте и алертьте. Пока люди ходят на серверы, дрейф будет всегда.
- Держите инвентарь динамическим. Статический
hosts.ymlв облаке всегда врёт. Используйтеamazon.aws.aws_ec2илиcommunity.general.yandex_cloud_computeплагины инвентаря. - Версионируйте всё, включая коллекции.
requirements.ymlс точными версиями,ansible-galaxy install -r requirements.yml --forceв CI. Плавающая версия роли из Galaxy — источник «вчера работало». - Логируйте прогоны централизованно. Callback-плагины Ansible умеют слать в Splunk, Loki, Elastic; Puppet — в PuppetDB. Без этого нельзя ответить «кто и когда поменял
sysctlна этой ноде» — см. https://courses.digitable.life/post/devops/16-observability-and-oncall/. - Разделите baseline и приложение. Baseline-роль (пользователи, ssh, ntp, мониторинг, hardening) применяется ко всему парку и меняется редко. Прикладные роли меняются часто. Смешивать их — значит трогать весь парк ради изменения в одном сервисе.
Мини-итог
- Управление конфигурацией решает одну задачу: удерживать состояние ОС равным описанному в git, борясь с дрейфом.
- Два фундамента — идемпотентность (повторный прогон ничего не меняет) и сходимость (система приближается к желаемому состоянию из любого стартового).
- Push (Ansible) проще начать: нет агента, YAML, часы до первого результата. Pull (Puppet, Chef, Salt) дешевле удерживать: сходимость по таймеру, база состояния парка, отчётность.
- Ценность CM определяется частотой сходимости, а не языком. Ansible в CI по расписанию даёт большую часть пользы Puppet.
- В стоимости на реальном масштабе доминируют люди, а не лицензии. Оценивайте эксплуатационную нагрузку, а не прайс-лист.
- Сфера CM сузилась: приложение уехало в контейнер, ВМ — в Terraform, выкат — в Kubernetes. Осталось важное: базовый слой ОС, bare-metal, сеть, БД на выделенных серверах.
- Лучшая архитектура на сегодня — CM внутри сборки образа (Packer + Ansible) плюс неизменяемая замена машин. Так вы получаете декларативность без дрейфа.
Источники
- Ansible Documentation — официальная документация, разделы Best Practices и Playbook Guide.
- Puppet Documentation — язык, Hiera, архитектура сервера.
- Chef Infra Documentation — ресурсы, атрибуты, Test Kitchen.
- Salt Project Documentation — состояния, реактор, транспорт.
- Kief Morris. Infrastructure as Code, 2nd ed. — O’Reilly, 2020. Лучшая книга о границах слоёв и о том, почему они текут.
- Jeff Geerling. Ansible for DevOps — ansiblefordevops.com, практическая книга с рабочими примерами.
- Mark Burgess. Promise Theory — markburgess.org/promises.html, теоретический фундамент сходимости.
- Martin Fowler. Snowflake Server и Phoenix Server — короткие тексты, определяющие словарь темы.
- Craig Dunn. Designing Puppet: Roles and Profiles — оригинальное описание паттерна.
- Molecule Documentation — тестирование ролей.
- InSpec Documentation — compliance as code, применим независимо от Chef.
- cloud-init Documentation — первичная инициализация облачных ВМ.
Что дальше
Мы прошли весь путь от кода до настроенной машины: CI собрал артефакт, контейнер его упаковал, Kubernetes запустил, Terraform создал инфраструктуру, а система управления конфигурацией привела ОС к нужному виду. Осталось разобраться, где всё это живёт и сколько стоит.
Следующая статья — про самое распространённое облако и про то, как в нём не разориться и не открыть S3-бакет всему интернету: AWS: базовые сервисы, модель ответственности, сеть, стоимость, типичные ошибки.