CI/CD, инфраструктура и облака Ansible, Puppet, Chef и управление конфигурацией
0%

Ansible, Puppet, Chef и управление конфигурацией

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). Его два последствия неприятны по-разному:

  1. «Снежинки» (snowflake servers) — машины, которые никто не может воссоздать. Известный термин Мартина Фаулера: Snowflake Server. Такой сервер работает ровно до первого отказа диска, после чего команда узнаёт, что восстановить его невозможно.
  2. Невоспроизводимые баги. «На третьей ноде из пяти запросы иногда падают по таймауту» — потому что на ней остался старый 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% вопросов «почему медленно» и «почему не работает»:

  1. Ansible читает инвентарь и строит список хостов play.
  2. Для каждого хоста поднимается SSH-соединение (по умолчанию с ControlPersist).
  3. Выполняется неявная задача gather_facts — модуль setup, который собирает несколько сотен переменных о хосте.
  4. Для каждой задачи модуль упаковывается в один Python-файл (механизм AnsiballZ), копируется во временный каталог на хосте, запускается, возвращает JSON, удаляется.
  5. Результат агрегируется, 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): вместо того чтобы менять работающую машину, вы собираете новый образ и заменяете машину целиком. Дрейф структурно невозможен, потому что состояние не живёт.

Обратите внимание: в неизменяемом пути 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 даже при полностью неизменяемом подходе:

  1. Bare-metal и гипервизоры. Хост, на котором крутится ваш Kubernetes, сам не в контейнере.
  2. Базы данных на выделенных серверах. Их не пересоздают каждый релиз.
  3. Сетевое оборудование. Коллекции cisco.ios, arista.eos — Ansible здесь фактический стандарт.
  4. Разовая оркестрация. «Раскатать патч на 400 машин прямо сейчас» — задача для push-модели, и она не про сходимость.
  5. Рабочие станции и лаборатории. 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) плюс неизменяемая замена машин. Так вы получаете декларативность без дрейфа.

Источники

Что дальше

Мы прошли весь путь от кода до настроенной машины: CI собрал артефакт, контейнер его упаковал, Kubernetes запустил, Terraform создал инфраструктуру, а система управления конфигурацией привела ОС к нужному виду. Осталось разобраться, где всё это живёт и сколько стоит.

Следующая статья — про самое распространённое облако и про то, как в нём не разориться и не открыть S3-бакет всему интернету: AWS: базовые сервисы, модель ответственности, сеть, стоимость, типичные ошибки.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов