Создатель Ansible Playbook для автоматизации

Профессиональный шаблон для генерации идемпотентных Ansible playbook с ролями, безопасностью и инструкциями по запуску.

// промпт
Ты — старший инженер по автоматизации инфраструктуры (DevOps) с многолетней экспертизой в Ansible. Создай готовый к продакшену, идемпотентный Ansible playbook для задачи: **{{zadaca_avtomatizacii}}**. ## Контекст - **Целевая ОС / дистрибутив:** {{celevaia_os}} - **Группа хостов (inventory):** {{gruppa_xostov}} - **Окружение:** {{okruzenie}} (dev / staging / production) - **Сервис или стек для настройки:** {{servis_ili_stek}} Текущий inventory или конфигурация (если есть): ``` {{tekushhaia_konfiguraciia_ili_inventory}} ``` ## Что нужно сделать 1. **Спроектируй структуру** — разбей логику на роли (`roles/`), вынеси переменные в `defaults`/`vars`, шаблоны в `templates`, секреты в Ansible Vault. 2. **Напиши playbook** — задачи с понятными `name`, корректным `become`, тегами и условным выполнением (`when`). 3. **Обеспечь надёжность** — `handlers` для перезапуска сервисов, `block`/`rescue`/`always` для обработки ошибок и отката, проверки `assert`/`failed_when`. 4. **Опиши запуск** — пример `inventory`, команду `ansible-playbook` с нужными флагами и проверку через `--check`/`--diff`. ## Требования и лучшие практики - Идемпотентность: повторный запуск не должен менять состояние. - Правильная область видимости и именование переменных, без хардкода. - Усиление безопасности по умолчанию (минимум привилегий, SSH, файрвол). - Совместимость с указанной версией Ansible и целевой ОС. ## Формат ответа - **Структура каталогов** — дерево файлов проекта. - **Код playbook и ролей** — в блоках YAML с краткими комментариями к нетривиальным шагам. - **Переменные** — таблица: имя, назначение, значение по умолчанию. - **Запуск и проверка** — команды и ожидаемый результат. - **Заметки** — потенциальные риски, требования к привилегиям и идеи по расширению. Если деталей не хватает, задай уточняющие вопросы перед генерацией кода.
Заполните переменные

Пример запуска

Сгенерировано Claude Opus 5.5, 24 сентября 2026 г.

Значения переменных

Задача автоматизации
Развернуть отказоустойчивый кластер PostgreSQL 15 (1 primary + 2 replica) с потоковой репликацией и автоматическим failover через Patroni + etcd, плюс HAProxy для балансировки подключений
Целевая ос
Ubuntu 22.04 LTS (x86_64)
Группа хостов
db_servers (родительская группа с подгруппами db_primary и db_replica), плюс отдельная группа lb_servers для HAProxy
Окружение
staging
Сервис или стек
PostgreSQL 15 + Patroni 3.x + etcd 3.5 (DCS) + HAProxy 2.8 (балансировка чтения/записи)
Текущая конфигурация или inventory
[db_primary] pg-node1 ansible_host=10.20.1.11 patroni_role=primary [db_replica] pg-node2 ansible_host=10.20.1.12 patroni_role=replica pg-node3 ansible_host=10.20.1.13 patroni_role=replica [db_servers:children] db_primary db_replica [etcd_servers] pg-node1 ansible_host=10.20.1.11 pg-node2 ansible_host=10.20.1.12 pg-node3 ansible_host=10.20.1.13 [lb_servers] lb-node1 ansible_host=10.20.1.20 [staging:children] db_servers etcd_servers lb_servers [staging:vars] ansible_user=deploy ansible_ssh_private_key_file=~/.ssh/id_staging_ed25519 ansible_python_interpreter=/usr/bin/python3 postgresql_version=15 postgresql_data_dir=/var/lib/postgresql/15/main postgresql_port=5432 patroni_scope=staging-pg-cluster patroni_namespace=/service/ etcd_cluster_token=staging-etcd-cluster-01 haproxy_listen_port_write=5433 haproxy_listen_port_read=5434 pg_replication_user=replicator pg_admin_user=pgadmin # Текущее состояние: три голых сервера Ubuntu 22.04 после базовой установки ОС, # PostgreSQL, Patroni и etcd ещё не установлены. На pg-node1 вручную стоял # PostgreSQL 14 из apt для тестов — его нужно корректно удалить перед установкой # управляемого через Patroni кластера версии 15. На lb-node1 HAProgress не настроен, # сервис haproxy не установлен. Firewall (ufw) включён везде с политикой deny incoming, # открыт только порт 22 для admin-подсети 10.20.0.0/24.

Отказоустойчивый кластер PostgreSQL 15 на Patroni, etcd и HAProxy (Ansible)

Допущения

Контекста достаточно, поэтому код ниже готов к использованию. Все места, где я что-то предполагал, вынесены в переменные.

# Допущение Где изменить
1 Узлы имеют выход в интернет к apt.postgresql.org, ppa.launchpadcontent.net, github.com и pypi.org Раздел «Заметки», п. 6
2 Подсеть приложений неизвестна. Порты 5433/5434 открыты только для admin-подсети 10.20.0.0/24 haproxy_client_cidrs
3 etcd работает без TLS. Доступ к 2379/2380 ограничен через ufw только узлами кластера. Для staging это допустимо, для production нужен TLS Раздел «Заметки», п. 2
4 PG14 на pg-node1 тестовый. Перед удалением делается холодный бэкап в tar.gz pg_legacy_*
5 Control node: ansible-core ≥ 2.15, Python 3.10+ requirements.yml
6 Версии зафиксированы: etcd 3.5.17, Patroni 3.3.2, HAProxy 2.8.x из PPA vbernat. Перед запуском проверьте последние патч-релизы внутри этих веток defaults/ ролей

Три вопроса, которые стоит уточнить до production:

  • Какие подсети приложений должны ходить в HAProxy?
  • Нужен ли VIP (keepalived) для HAProxy? Сейчас lb-node1 — единая точка отказа.
  • Нужен ли синхронный режим репликации (RPO = 0)?

1. Структура каталогов

pg-ha-cluster/
├── ansible.cfg
├── requirements.yml
├── site.yml
├── inventories/
│   └── staging/
│       ├── hosts.ini
│       └── group_vars/
│           ├── all/
│           │   ├── main.yml          # общие переменные кластера
│           │   └── vault.yml         # секреты (ansible-vault encrypt)
│           ├── db_servers.yml        # ufw-правила PG/Patroni, sysctl
│           ├── etcd_servers.yml      # ufw-правила etcd
│           └── lb_servers.yml        # ufw-правила HAProxy
└── roles/
    ├── common/                       # пакеты, chrony, sysctl, SSH hardening, ufw, /etc/hosts
    │   ├── defaults/main.yml
    │   ├── handlers/main.yml
    │   ├── tasks/{main,firewall,ssh}.yml
    │   └── templates/sshd-hardening.conf.j2
    ├── pg_legacy_cleanup/            # безопасное удаление PostgreSQL 14
    │   ├── defaults/main.yml
    │   └── tasks/main.yml
    ├── etcd/                         # etcd 3.5 из официальных бинарников
    │   ├── defaults/main.yml
    │   ├── handlers/main.yml
    │   ├── tasks/{main,install,configure}.yml
    │   └── templates/{etcd.conf.yml.j2,etcd.service.j2}
    ├── postgresql/                   # бинарники PG15 из PGDG, без auto-cluster
    │   ├── defaults/main.yml
    │   └── tasks/main.yml
    ├── patroni/                      # Patroni 3.x в venv, bootstrap, DCS-конфиг, пользователи
    │   ├── defaults/main.yml
    │   ├── handlers/main.yml
    │   ├── tasks/{main,install,configure,service,post_bootstrap}.yml
    │   └── templates/{patroni.yml.j2,patroni.service.j2}
    ├── haproxy/                      # HAProxy 2.8, write/read-сплит, откат конфига
    │   ├── defaults/main.yml
    │   ├── handlers/main.yml
    │   ├── tasks/main.yml
    │   └── templates/haproxy.cfg.j2
    └── cluster_verify/               # end-to-end проверка кластера
        ├── defaults/main.yml
        └── tasks/{main,patroni,haproxy}.yml

2. Базовые файлы проекта

ansible.cfg

[defaults]
inventory               = inventories/staging/hosts.ini
roles_path              = roles
forks                   = 10
host_key_checking       = True
retry_files_enabled     = False
interpreter_python      = auto_silent
callback_result_format  = yaml
# Кэш фактов нужен, чтобы запуски с --tags/--limit видели факты других хостов
gathering               = smart
fact_caching            = jsonfile
fact_caching_connection = .ansible_facts_cache
fact_caching_timeout    = 3600

[privilege_escalation]
# become включается точечно на уровне play, а не глобально
become = False

[ssh_connection]
pipelining = True
ssh_args   = -o ControlMaster=auto -o ControlPersist=60s

requirements.yml

---
collections:
  - name: community.general      # ufw, archive, timezone
    version: ">=8.0.0"
  - name: community.postgresql   # postgresql_user
    version: ">=3.4.0"
  - name: ansible.posix          # sysctl
    version: ">=1.5.0"

inventories/staging/hosts.ini

В ini остались только топология и параметры подключения. Параметры кластера перенесены в group_vars/all/main.yml: их удобнее ревьюить в YAML, и так не возникает дублирования.

[db_primary]
pg-node1 ansible_host=10.20.1.11 patroni_role=primary

[db_replica]
pg-node2 ansible_host=10.20.1.12 patroni_role=replica
pg-node3 ansible_host=10.20.1.13 patroni_role=replica

[db_servers:children]
db_primary
db_replica

[etcd_servers]
pg-node1
pg-node2
pg-node3

[lb_servers]
lb-node1 ansible_host=10.20.1.20

[staging:children]
db_servers
etcd_servers
lb_servers

[staging:vars]
ansible_user=deploy
ansible_ssh_private_key_file=~/.ssh/id_staging_ed25519
ansible_python_interpreter=/usr/bin/python3

inventories/staging/group_vars/all/main.yml

---
env_name: staging

# --- PostgreSQL ---
postgresql_version: 15
postgresql_data_dir: "/var/lib/postgresql/{{ postgresql_version }}/main"
postgresql_bin_dir: "/usr/lib/postgresql/{{ postgresql_version }}/bin"
postgresql_port: 5432

# --- Пользователи БД (пароли из vault через индирекцию) ---
pg_superuser: postgres
pg_replication_user: replicator
pg_rewind_user: rewind_user
pg_admin_user: pgadmin
pg_superuser_password: "{{ vault_pg_superuser_password }}"
pg_replication_password: "{{ vault_pg_replication_password }}"
pg_rewind_password: "{{ vault_pg_rewind_password }}"
pg_admin_password: "{{ vault_pg_admin_password }}"

# --- Patroni ---
patroni_scope: staging-pg-cluster
patroni_namespace: /service/
patroni_restapi_port: 8008
patroni_restapi_password: "{{ vault_patroni_restapi_password }}"

# --- etcd ---
etcd_cluster_token: staging-etcd-cluster-01
etcd_client_port: 2379
etcd_peer_port: 2380

# --- HAProxy ---
haproxy_listen_port_write: 5433
haproxy_listen_port_read: 5434
haproxy_stats_port: 7000
haproxy_stats_password: "{{ vault_haproxy_stats_password }}"
haproxy_client_cidrs:            # TODO: добавить подсеть приложений
  - 10.20.0.0/24

# --- Сеть / безопасность ---
common_admin_cidrs:
  - 10.20.0.0/24

# PG14 на pg-node1 — тестовый, удаление разрешено явно (в prod оставить false)
pg_legacy_cleanup_confirm: true

# --- Вычисляемые списки IP (только из inventory, без фактов → работает с --limit) ---
cluster_db_ips: "{{ groups['db_servers'] | map('extract', hostvars, 'ansible_host') | list }}"
cluster_etcd_ips: "{{ groups['etcd_servers'] | map('extract', hostvars, 'ansible_host') | list }}"
cluster_lb_ips: "{{ groups['lb_servers'] | map('extract', hostvars, 'ansible_host') | list }}"
etcd_client_endpoints: >-
  {{ cluster_etcd_ips | map('regex_replace', '^(.*)$', 'http://\1:' ~ etcd_client_port) | join(',') }}

inventories/staging/group_vars/all/vault.yml

Ниже содержимое до шифрования. Зашифруйте файл командой ansible-vault encrypt --vault-id staging@prompt ….

---
vault_pg_superuser_password: "CHANGE_ME_min16chars_superuser"
vault_pg_replication_password: "CHANGE_ME_min16chars_replication"
vault_pg_rewind_password: "CHANGE_ME_min16chars_rewind"
vault_pg_admin_password: "CHANGE_ME_min16chars_pgadmin"
vault_patroni_restapi_password: "CHANGE_ME_min16chars_restapi"
vault_haproxy_stats_password: "CHANGE_ME_min16chars_stats"

inventories/staging/group_vars/db_servers.yml

---
# Роль common собирает все переменные с префиксом ufw_rules_* → группы не перетирают друг друга
ufw_rules_postgresql:
  - port: "{{ postgresql_port }}"
    proto: tcp
    src: "{{ cluster_db_ips + cluster_lb_ips }}"
    comment: "PostgreSQL: replication + HAProxy"
  - port: "{{ patroni_restapi_port }}"
    proto: tcp
    src: "{{ cluster_db_ips + cluster_lb_ips + common_admin_cidrs }}"
    comment: "Patroni REST API: health-checks + patronictl"

common_sysctl:
  vm.swappiness: 1
  net.ipv4.tcp_keepalive_time: 60
  net.ipv4.tcp_keepalive_intvl: 10
  net.ipv4.tcp_keepalive_probes: 6

inventories/staging/group_vars/etcd_servers.yml

---
ufw_rules_etcd:
  - port: "{{ etcd_client_port }}"
    proto: tcp
    src: "{{ cluster_etcd_ips + cluster_db_ips }}"   # клиенты — Patroni
    comment: "etcd client"
  - port: "{{ etcd_peer_port }}"
    proto: tcp
    src: "{{ cluster_etcd_ips }}"
    comment: "etcd peer"

inventories/staging/group_vars/lb_servers.yml

---
# DB-узлы добавлены, чтобы cluster_verify проверил путь "клиент → HAProxy → PG" end-to-end
ufw_rules_haproxy:
  - port: "{{ haproxy_listen_port_write }}"
    proto: tcp
    src: "{{ haproxy_client_cidrs + cluster_db_ips }}"
    comment: "HAProxy PG write"
  - port: "{{ haproxy_listen_port_read }}"
    proto: tcp
    src: "{{ haproxy_client_cidrs + cluster_db_ips }}"
    comment: "HAProxy PG read"
  - port: "{{ haproxy_stats_port }}"
    proto: tcp
    src: "{{ common_admin_cidrs }}"
    comment: "HAProxy stats"

3. Playbook site.yml

---
# Порядок plays критичен: etcd → PG binaries → Patroni primary → replicas (по одной) → LB → verify
- name: Preflight — проверка окружения и входных данных
  hosts: staging
  gather_facts: true
  any_errors_fatal: true
  tags: [always]
  tasks:
    - name: Проверить версию Ansible на control node
      ansible.builtin.assert:
        that: ansible_version.full is version('2.15', '>=')
        fail_msg: "Требуется ansible-core >= 2.15, найдено {{ ansible_version.full }}"
      run_once: true

    - name: Проверить ОС и архитектуру
      ansible.builtin.assert:
        that:
          - ansible_distribution == 'Ubuntu'
          - ansible_distribution_version == '22.04'
          - ansible_architecture == 'x86_64'
        fail_msg: "Поддерживается только Ubuntu 22.04 x86_64 ({{ inventory_hostname }}: {{ ansible_distribution }} {{ ansible_distribution_version }})"

    - name: Проверить топологию inventory
      ansible.builtin.assert:
        that:
          - groups['db_primary'] | length == 1
          - groups['db_replica'] | length >= 1
          - groups['etcd_servers'] | length >= 3
          - groups['etcd_servers'] | length is odd
          - groups['lb_servers'] | length >= 1
        fail_msg: "Нужен ровно 1 primary, >=1 replica, нечётное число (>=3) членов etcd и >=1 LB"
      run_once: true

    - name: Проверить согласованность patroni_role и групп
      ansible.builtin.assert:
        that:
          - patroni_role is defined
          - (patroni_role == 'primary') == (inventory_hostname in groups['db_primary'])
        fail_msg: "patroni_role у {{ inventory_hostname }} не соответствует группе"
      when: inventory_hostname in groups['db_servers']

    - name: Проверить наличие и длину секретов из vault (значения не выводятся)
      ansible.builtin.assert:
        that:
          - lookup('ansible.builtin.vars', item, default='') | length >= 16
          - "'CHANGE_ME' not in lookup('ansible.builtin.vars', item, default='')"
        fail_msg: "Секрет {{ item }} не задан, короче 16 символов или не заменён"
        quiet: true
      loop:
        - vault_pg_superuser_password
        - vault_pg_replication_password
        - vault_pg_rewind_password
        - vault_pg_admin_password
        - vault_patroni_restapi_password
        - vault_haproxy_stats_password
      run_once: true

- name: Базовая настройка и hardening ОС
  hosts: staging
  become: true
  roles:
    - role: common
      tags: [common, firewall, ssh]

- name: Удаление legacy PostgreSQL (14) с бэкапом
  hosts: db_servers
  become: true
  roles:
    - role: pg_legacy_cleanup
      tags: [cleanup]

- name: Развёртывание etcd 3.5 (DCS)
  hosts: etcd_servers
  become: true
  any_errors_fatal: true          # без кворума etcd дальше идти нельзя
  roles:
    - role: etcd
      tags: [etcd]

- name: Установка бинарников PostgreSQL 15
  hosts: db_servers
  become: true
  roles:
    - role: postgresql
      tags: [postgresql]

- name: Patroni — bootstrap primary
  hosts: db_primary
  become: true
  any_errors_fatal: true
  roles:
    - role: patroni
      tags: [patroni]

- name: Patroni — подключение реплик по одной
  hosts: db_replica
  become: true
  serial: 1                        # pg_basebackup по очереди, чтобы не нагружать primary
  max_fail_percentage: 0
  roles:
    - role: patroni
      tags: [patroni]

- name: Patroni — DCS-конфигурация и прикладные пользователи
  hosts: db_servers
  become: true
  tasks:
    - name: Выполнить post-bootstrap задачи на лидере
      ansible.builtin.include_role:
        name: patroni
        tasks_from: post_bootstrap
      tags: [patroni, patroni_config]

- name: Развёртывание HAProxy 2.8
  hosts: lb_servers
  become: true
  roles:
    - role: haproxy
      tags: [haproxy]

- name: End-to-end проверка кластера
  hosts: db_servers:lb_servers
  become: true
  roles:
    - role: cluster_verify
      tags: [verify]

4. Роли

4.1 roles/common

defaults/main.yml

---
common_packages:
  - acl                 # нужен для become_user: postgres без world-readable temp-файлов
  - chrony              # точное время критично для etcd/Patroni
  - ca-certificates
  - curl
  - gnupg
  - ufw
common_timezone: Etc/UTC
common_admin_cidrs: []
common_ssh_port: 22
common_ssh_hardening_enabled: true
common_manage_hosts_file: true
common_sysctl: {}

tasks/main.yml

---
- name: Установить базовые пакеты
  ansible.builtin.apt:
    name: "{{ common_packages }}"
    state: present
    update_cache: true
    cache_valid_time: 3600

- name: Установить часовой пояс
  community.general.timezone:
    name: "{{ common_timezone }}"

- name: Включить chrony
  ansible.builtin.systemd_service:
    name: chrony
    state: started
    enabled: true

- name: Применить параметры ядра
  ansible.posix.sysctl:
    name: "{{ item.key }}"
    value: "{{ item.value }}"
    sysctl_file: /etc/sysctl.d/60-pg-cluster.conf
    reload: true
    state: present
  loop: "{{ common_sysctl | dict2items }}"

- name: Прописать узлы кластера в /etc/hosts
  ansible.builtin.blockinfile:
    path: /etc/hosts
    marker: "# {mark} ANSIBLE MANAGED: {{ env_name }} pg cluster"
    block: |
      {% for h in groups['staging'] | unique %}
      {{ hostvars[h].ansible_host }} {{ h }}
      {% endfor %}
  when: common_manage_hosts_file | bool

- name: Hardening SSH
  ansible.builtin.import_tasks: ssh.yml
  when: common_ssh_hardening_enabled | bool
  tags: [ssh]

- name: Настройка файрвола
  ansible.builtin.import_tasks: firewall.yml
  tags: [firewall]

tasks/ssh.yml

---
# Префикс 00- важен: в Ubuntu 22.04 в sshd работает first-match и существует 50-cloud-init.conf
- name: Развернуть drop-in с hardening sshd
  ansible.builtin.template:
    src: sshd-hardening.conf.j2
    dest: /etc/ssh/sshd_config.d/00-hardening.conf
    owner: root
    group: root
    mode: "0644"
    validate: /usr/sbin/sshd -t -f %s
  notify: Reload ssh

templates/sshd-hardening.conf.j2

# {{ ansible_managed }}
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
X11Forwarding no
AllowAgentForwarding no
MaxAuthTries 3
LoginGraceTime 30
ClientAliveInterval 300
ClientAliveCountMax 2

tasks/firewall.yml

---
# Сначала разрешающие правила, затем проверка политики — так не отрежем SSH
- name: Разрешить SSH из admin-подсетей
  community.general.ufw:
    rule: allow
    port: "{{ common_ssh_port | string }}"
    proto: tcp
    from_ip: "{{ item }}"
    comment: "SSH admin"
  loop: "{{ common_admin_cidrs }}"

- name: Собрать ufw-правила всех групп хоста (переменные ufw_rules_*)
  ansible.builtin.set_fact:
    _common_ufw_rules: >-
      {{ query('ansible.builtin.varnames', '^ufw_rules_.+')
         | map('extract', vars) | flatten }}

- name: Применить сервисные ufw-правила (источник → порт)
  community.general.ufw:
    rule: allow
    port: "{{ item.0.port | string }}"
    proto: "{{ item.0.proto | default('tcp') }}"
    from_ip: "{{ item.1 }}"
    comment: "{{ item.0.comment | default(omit) }}"
  loop: "{{ _common_ufw_rules | subelements('src') }}"
  loop_control:
    label: "{{ item.1 }} -> {{ item.0.port }}/{{ item.0.proto | default('tcp') }}"

- name: Установить политику deny incoming и включить ufw
  community.general.ufw:
    state: enabled
    direction: incoming
    policy: deny
    logging: "low"

handlers/main.yml

---
- name: Reload ssh
  ansible.builtin.systemd_service:
    name: ssh
    state: reloaded

4.2 roles/pg_legacy_cleanup

defaults/main.yml

---
pg_legacy_versions: ["14"]
pg_legacy_cleanup_confirm: false          # предохранитель: без явного true роль упадёт
pg_legacy_backup_dir: /var/backups/postgresql-legacy

tasks/main.yml

---
- name: Собрать список пакетов
  ansible.builtin.package_facts:
    manager: apt

- name: Определить установленные legacy-версии PostgreSQL
  ansible.builtin.set_fact:
    _pg_legacy_installed: >-
      {{ pg_legacy_versions | select('regex', '.+')
         | map('regex_replace', '^(.*)$', 'postgresql-\1')
         | select('in', ansible_facts.packages.keys()) | list }}

- name: Удалить legacy PostgreSQL
  when: _pg_legacy_installed | length > 0
  block:
    - name: Проверить, что удаление разрешено явно
      ansible.builtin.assert:
        that: pg_legacy_cleanup_confirm | bool
        fail_msg: >-
          На {{ inventory_hostname }} найдены {{ _pg_legacy_installed }}.
          Установите pg_legacy_cleanup_confirm=true, чтобы разрешить удаление.

    - name: Получить список legacy-кластеров
      ansible.builtin.command: pg_lsclusters --no-header
      register: _pg_legacy_clusters
      changed_when: false
      failed_when: false
      check_mode: false

    - name: Отфильтровать кластеры legacy-версий
      ansible.builtin.set_fact:
        _pg_legacy_cluster_list: >-
          {{ _pg_legacy_clusters.stdout_lines | default([])
             | map('split') | selectattr('0', 'in', pg_legacy_versions) | list }}

    - name: Создать каталог для бэкапа
      ansible.builtin.file:
        path: "{{ pg_legacy_backup_dir }}"
        state: directory
        owner: root
        group: root
        mode: "0700"

    - name: Остановить legacy-кластеры
      ansible.builtin.systemd_service:
        name: "postgresql@{{ item.0 }}-{{ item.1 }}"
        state: stopped
        enabled: false
      loop: "{{ _pg_legacy_cluster_list }}"
      loop_control:
        label: "{{ item.0 }}/{{ item.1 }}"

    # Кластер остановлен → архив консистентен (холодный бэкап)
    - name: Сделать холодный бэкап данных и конфигов
      community.general.archive:
        path:
          - "/var/lib/postgresql/{{ item }}"
          - "/etc/postgresql/{{ item }}"
        dest: "{{ pg_legacy_backup_dir }}/pg{{ item }}-{{ ansible_date_time.iso8601_basic_short }}.tar.gz"
        format: gz
        mode: "0600"
      loop: "{{ pg_legacy_versions }}"

    - name: Удалить пакеты legacy PostgreSQL (purge)
      ansible.builtin.apt:
        name: >-
          {{ _pg_legacy_installed
             + (pg_legacy_versions | map('regex_replace', '^(.*)$', 'postgresql-client-\1') | list)
             + ['postgresql'] }}
        state: absent
        purge: true
        autoremove: false   # не трогаем postgresql-common — он нужен PG15

    - name: Удалить остаточные каталоги legacy-кластеров
      ansible.builtin.file:
        path: "{{ item.1 }}{{ item.0 }}"
        state: absent
      loop: "{{ pg_legacy_versions | product(['/var/lib/postgresql/', '/etc/postgresql/']) | list }}"

  rescue:
    - name: Откат — снова запустить legacy-кластеры, если пакеты ещё на месте
      ansible.builtin.systemd_service:
        name: "postgresql@{{ item.0 }}-{{ item.1 }}"
        state: started
      loop: "{{ _pg_legacy_cluster_list | default([]) }}"
      failed_when: false

    - name: Прервать выполнение с диагностикой
      ansible.builtin.fail:
        msg: >-
          Не удалось удалить legacy PostgreSQL на {{ inventory_hostname }}.
          Бэкап (если создан): {{ pg_legacy_backup_dir }}. Проверьте вывод выше.

4.3 roles/etcd

defaults/main.yml

---
# В apt Ubuntu 22.04 только etcd 3.3 → ставим официальные бинарники с проверкой SHA256
etcd_version: "3.5.17"
etcd_arch: amd64
etcd_release_base: "https://github.com/etcd-io/etcd/releases/download/v{{ etcd_version }}"
etcd_tarball: "etcd-v{{ etcd_version }}-linux-{{ etcd_arch }}.tar.gz"
etcd_bin_dir: /usr/local/bin
etcd_user: etcd
etcd_data_dir: /var/lib/etcd
etcd_config_dir: /etc/etcd
etcd_bind_ip: "{{ ansible_host }}"
etcd_client_port: 2379
etcd_peer_port: 2380
etcd_initial_cluster_state: new      # на уже сформированном кластере etcd игнорирует этот параметр
etcd_heartbeat_interval: 100
etcd_election_timeout: 1000
etcd_quota_backend_bytes: 2147483648
etcd_auto_compaction_retention: "1"
etcd_health_retries: 20

tasks/main.yml

---
- name: Установить etcd
  ansible.builtin.import_tasks: install.yml

- name: Сконфигурировать etcd
  ansible.builtin.import_tasks: configure.yml

- name: Запустить и проверить etcd
  block:
    # Первый старт выполняется параллельно на всех узлах: с Type=notify etcd
    # ждёт кворума, поэтому последовательный старт зависнет
    - name: Запустить etcd (параллельно на всех членах)
      ansible.builtin.systemd_service:
        name: etcd
        state: started
        enabled: true
        daemon_reload: true
      register: etcd_service_start

    - name: Применить отложенный rolling-restart (если конфиг изменился на работающем кластере)
      ansible.builtin.meta: flush_handlers

    - name: Дождаться здоровья кластера etcd
      ansible.builtin.command: >-
        {{ etcd_bin_dir }}/etcdctl --endpoints={{ etcd_client_endpoints }}
        endpoint health --cluster
      environment:
        ETCDCTL_API: "3"
      register: _etcd_health
      retries: "{{ etcd_health_retries }}"
      delay: 5
      until: _etcd_health.rc == 0
      changed_when: false
      check_mode: false

    - name: Получить список членов etcd
      ansible.builtin.command: >-
        {{ etcd_bin_dir }}/etcdctl --endpoints={{ etcd_client_endpoints }} member list -w json
      environment:
        ETCDCTL_API: "3"
      register: _etcd_members
      changed_when: false
      check_mode: false
      run_once: true

    - name: Проверить число членов etcd
      ansible.builtin.assert:
        that: (_etcd_members.stdout | from_json).members | length == groups['etcd_servers'] | length
        fail_msg: "Число членов etcd не совпадает с inventory"
      run_once: true
  rescue:
    - name: Собрать журнал etcd
      ansible.builtin.command: journalctl -u etcd -n 60 --no-pager
      register: _etcd_journal
      changed_when: false
      failed_when: false

    - name: Прервать с диагностикой
      ansible.builtin.fail:
        msg: "etcd не поднялся на {{ inventory_hostname }}:\n{{ _etcd_journal.stdout | default('') }}"

tasks/install.yml

---
- name: Создать системного пользователя etcd
  ansible.builtin.user:
    name: "{{ etcd_user }}"
    system: true
    shell: /usr/sbin/nologin
    home: "{{ etcd_data_dir }}"
    create_home: false

- name: Проверить установленную версию etcd
  ansible.builtin.command: "{{ etcd_bin_dir }}/etcd --version"
  register: _etcd_installed
  changed_when: false
  failed_when: false
  check_mode: false

- name: Скачать и установить бинарники etcd
  when: _etcd_installed.rc != 0 or ('etcd Version: ' ~ etcd_version) not in _etcd_installed.stdout
  block:
    - name: Скачать архив etcd с проверкой SHA256
      ansible.builtin.get_url:
        url: "{{ etcd_release_base }}/{{ etcd_tarball }}"
        dest: "/tmp/{{ etcd_tarball }}"
        checksum: "sha256:{{ etcd_release_base }}/SHA256SUMS"
        mode: "0600"

    - name: Создать временный каталог
      ansible.builtin.file:
        path: /tmp/etcd-install
        state: directory
        mode: "0700"

    - name: Распаковать архив
      ansible.builtin.unarchive:
        src: "/tmp/{{ etcd_tarball }}"
        dest: /tmp/etcd-install
        remote_src: true
        extra_opts: ["--strip-components=1"]

    - name: Установить бинарники
      ansible.builtin.copy:
        src: "/tmp/etcd-install/{{ item }}"
        dest: "{{ etcd_bin_dir }}/{{ item }}"
        remote_src: true
        owner: root
        group: root
        mode: "0755"
      loop: [etcd, etcdctl, etcdutl]
      notify: Restart etcd
  always:
    - name: Удалить временные файлы
      ansible.builtin.file:
        path: "{{ item }}"
        state: absent
      loop:
        - "/tmp/{{ etcd_tarball }}"
        - /tmp/etcd-install

tasks/configure.yml

---
- name: Создать каталоги etcd
  ansible.builtin.file:
    path: "{{ item.path }}"
    state: directory
    owner: "{{ item.owner }}"
    group: "{{ item.owner }}"
    mode: "{{ item.mode }}"
  loop:
    - { path: "{{ etcd_config_dir }}", owner: root, mode: "0755" }
    - { path: "{{ etcd_data_dir }}", owner: "{{ etcd_user }}", mode: "0700" }

- name: Развернуть конфигурацию etcd
  ansible.builtin.template:
    src: etcd.conf.yml.j2
    dest: "{{ etcd_config_dir }}/etcd.conf.yml"
    owner: root
    group: "{{ etcd_user }}"
    mode: "0640"
  notify: Restart etcd

- name: Развернуть systemd unit etcd
  ansible.builtin.template:
    src: etcd.service.j2
    dest: /etc/systemd/system/etcd.service
    owner: root
    group: root
    mode: "0644"
  notify: Restart etcd

templates/etcd.conf.yml.j2

# {{ ansible_managed }}
name: "{{ inventory_hostname }}"
data-dir: "{{ etcd_data_dir }}"
listen-peer-urls: "http://{{ etcd_bind_ip }}:{{ etcd_peer_port }}"
listen-client-urls: "http://{{ etcd_bind_ip }}:{{ etcd_client_port }},http://127.0.0.1:{{ etcd_client_port }}"
initial-advertise-peer-urls: "http://{{ etcd_bind_ip }}:{{ etcd_peer_port }}"
advertise-client-urls: "http://{{ etcd_bind_ip }}:{{ etcd_client_port }}"
initial-cluster: "{% for h in groups['etcd_servers'] %}{{ h }}=http://{{ hostvars[h].ansible_host }}:{{ etcd_peer_port }}{{ ',' if not loop.last }}{% endfor %}"
initial-cluster-token: "{{ etcd_cluster_token }}"
initial-cluster-state: "{{ etcd_initial_cluster_state }}"
heartbeat-interval: {{ etcd_heartbeat_interval }}
election-timeout: {{ etcd_election_timeout }}
quota-backend-bytes: {{ etcd_quota_backend_bytes }}
auto-compaction-mode: periodic
auto-compaction-retention: "{{ etcd_auto_compaction_retention }}"
enable-v2: false
logger: zap
log-level: info
log-outputs: [stderr]

templates/etcd.service.j2

# {{ ansible_managed }}
[Unit]
Description=etcd distributed key-value store ({{ etcd_cluster_token }})
Documentation=https://etcd.io/docs/
After=network-online.target
Wants=network-online.target

[Service]
Type=notify
User={{ etcd_user }}
Group={{ etcd_user }}
ExecStart={{ etcd_bin_dir }}/etcd --config-file {{ etcd_config_dir }}/etcd.conf.yml
Restart=on-failure
RestartSec=5
LimitNOFILE=65536
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
ReadWritePaths={{ etcd_data_dir }}

[Install]
WantedBy=multi-user.target

handlers/main.yml

---
# throttle: 1 — rolling restart с сохранением кворума (2 из 3).
# Если etcd только что стартовал в этом прогоне, повторный рестарт не нужен.
- name: Restart etcd
  ansible.builtin.systemd_service:
    name: etcd
    state: restarted
    daemon_reload: true
  throttle: 1
  when: not (etcd_service_start is defined and etcd_service_start is changed)

4.4 roles/postgresql

defaults/main.yml

---
postgresql_version: 15
postgresql_pgdg_key_url: https://www.postgresql.org/media/keys/ACCC4CF8.asc
postgresql_pgdg_keyring: /etc/apt/keyrings/pgdg.asc
postgresql_packages:
  - "postgresql-{{ postgresql_version }}"
  - "postgresql-client-{{ postgresql_version }}"
  - python3-psycopg2         # для модулей community.postgresql

tasks/main.yml

---
- name: Создать каталог для apt-ключей
  ansible.builtin.file:
    path: /etc/apt/keyrings
    state: directory
    mode: "0755"

- name: Загрузить GPG-ключ PGDG
  ansible.builtin.get_url:
    url: "{{ postgresql_pgdg_key_url }}"
    dest: "{{ postgresql_pgdg_keyring }}"
    mode: "0644"

- name: Подключить репозиторий PGDG (signed-by)
  ansible.builtin.apt_repository:
    repo: "deb [signed-by={{ postgresql_pgdg_keyring }}] https://apt.postgresql.org/pub/repos/apt {{ ansible_distribution_release }}-pgdg main"
    filename: pgdg
    state: present

# Если не отключить автосоздание кластера ДО установки пакета, postgresql-15 создаст 15/main
# и займёт порт 5432 и data_dir, которые должен инициализировать Patroni
- name: Запретить автосоздание кластера пакетом postgresql-common
  ansible.builtin.copy:
    dest: /etc/postgresql-common/createcluster.d/00-patroni.conf
    content: |
      # {{ ansible_managed }} — кластером управляет Patroni
      create_main_cluster = false
    owner: root
    group: root
    mode: "0644"

- name: Установить PostgreSQL {{ postgresql_version }}
  ansible.builtin.apt:
    name: "{{ postgresql_packages }}"
    state: present
    update_cache: true

- name: Убедиться, что пакет не создал собственный кластер
  ansible.builtin.stat:
    path: "/etc/postgresql/{{ postgresql_version }}/main"
  register: _pg_pkg_cluster

- name: Проверить отсутствие неуправляемого кластера
  ansible.builtin.assert:
    that: not _pg_pkg_cluster.stat.exists
    fail_msg: >-
      Найден кластер /etc/postgresql/{{ postgresql_version }}/main, созданный пакетом.
      Удалите его вручную (pg_dropcluster --stop {{ postgresql_version }} main) — Patroni должен инициализировать data_dir сам.

- name: Отключить и остановить системный postgresql.service (Patroni управляет PG сам)
  ansible.builtin.systemd_service:
    name: postgresql
    state: stopped
    enabled: false

- name: Замаскировать unit кластера, чтобы исключить двойной старт
  ansible.builtin.systemd_service:
    name: "postgresql@{{ postgresql_version }}-main"
    masked: true

- name: Подготовить родительский каталог data_dir
  ansible.builtin.file:
    path: "{{ postgresql_data_dir | dirname }}"
    state: directory
    owner: postgres
    group: postgres
    mode: "0750"

4.5 roles/patroni

defaults/main.yml

---
patroni_version: "3.3.2"
patroni_psycopg2_version: "2.9.10"
patroni_venv: /opt/patroni
patroni_config_dir: /etc/patroni
patroni_config_file: "{{ patroni_config_dir }}/patroni.yml"
patroni_restapi_port: 8008
patroni_restapi_user: patroni
patroni_log_level: INFO
patroni_start_retries: 60          # × 10 с; для больших баз увеличить (pg_basebackup)
patroni_allow_restart: true        # в prod: false + ручной patronictl restart/switchover

patroni_initdb_options:
  - encoding: UTF8
  - locale: C.UTF-8
  - data-checksums

# Глобальная конфигурация в DCS: применяется при bootstrap, а затем
# синхронизируется идемпотентно через REST PATCH /config (post_bootstrap.yml)
patroni_dcs_config:
  ttl: 30
  loop_wait: 10
  retry_timeout: 10
  maximum_lag_on_failover: 1048576
  postgresql:
    use_pg_rewind: true
    use_slots: true
    parameters:
      max_connections: 200
      shared_buffers: "512MB"
      effective_cache_size: "1536MB"
      work_mem: "8MB"
      maintenance_work_mem: "128MB"
      wal_level: "replica"
      hot_standby: "on"
      wal_log_hints: "on"           # обязателен для pg_rewind
      max_wal_senders: 10
      max_replication_slots: 10
      wal_keep_size: "1GB"
      password_encryption: "scram-sha-256"
      logging_collector: "on"
      log_min_duration_statement: 1000
      log_line_prefix: "%m [%p] %q%u@%d "

patroni_pg_hba_extra: []
patroni_tags:
  nofailover: false
  noloadbalance: false
  clonefrom: false
  nosync: false

pg_admin_password_update: on_create  # always — для ротации пароля

tasks/main.yml

---
- name: Установить Patroni
  ansible.builtin.import_tasks: install.yml

- name: Сконфигурировать Patroni
  ansible.builtin.import_tasks: configure.yml

- name: Запустить Patroni
  ansible.builtin.import_tasks: service.yml

tasks/install.yml

---
# В apt Ubuntu 22.04 Patroni 2.1 → ставим 3.x в изолированный venv с фиксированной версией
- name: Установить зависимости для venv
  ansible.builtin.apt:
    name: [python3-venv, python3-pip]
    state: present

- name: Установить Patroni в virtualenv
  ansible.builtin.pip:
    name:
      - "patroni[etcd3]=={{ patroni_version }}"
      - "psycopg2-binary=={{ patroni_psycopg2_version }}"
    virtualenv: "{{ patroni_venv }}"
    virtualenv_command: python3 -m venv
  notify: Restart patroni

- name: Создать симлинки patroni/patronictl
  ansible.builtin.file:
    src: "{{ patroni_venv }}/bin/{{ item }}"
    dest: "/usr/local/bin/{{ item }}"
    state: link
  loop: [patroni, patronictl]

tasks/configure.yml

---
- name: Создать каталог конфигурации Patroni
  ansible.builtin.file:
    path: "{{ patroni_config_dir }}"
    state: directory
    owner: postgres
    group: postgres
    mode: "0750"

# diff: false — в файле пароли; --diff не должен выводить их в лог
- name: Развернуть patroni.yml
  ansible.builtin.template:
    src: patroni.yml.j2
    dest: "{{ patroni_config_file }}"
    owner: postgres
    group: postgres
    mode: "0600"
  diff: false
  notify: Reload patroni

- name: Развернуть systemd unit Patroni
  ansible.builtin.template:
    src: patroni.service.j2
    dest: /etc/systemd/system/patroni.service
    owner: root
    group: root
    mode: "0644"
  notify: Restart patroni

tasks/service.yml

---
- name: Проверить, инициализирован ли data_dir на узле
  ansible.builtin.stat:
    path: "{{ postgresql_data_dir }}/PG_VERSION"
  register: _patroni_pgdata

# Ключ initialize в DCS означает, что кластер уже существует (например, после failover
# primary может быть другой узел) → ждём /health, а не /primary
- name: Проверить наличие ключа initialize в etcd
  ansible.builtin.command: >-
    /usr/local/bin/etcdctl --endpoints={{ etcd_client_endpoints }}
    get {{ patroni_namespace | regex_replace('/$', '') }}/{{ patroni_scope }}/initialize --print-value-only
  environment:
    ETCDCTL_API: "3"
  register: _patroni_init_key
  changed_when: false
  check_mode: false
  delegate_to: "{{ groups['etcd_servers'][0] }}"
  run_once: true

- name: Определить endpoint ожидания готовности
  ansible.builtin.set_fact:
    _patroni_wait_endpoint: >-
      {%- if patroni_role == 'primary' and (_patroni_init_key.stdout | length == 0) -%}/primary
      {%- elif patroni_role == 'replica' and not _patroni_pgdata.stat.exists -%}/replica
      {%- else -%}/health{%- endif -%}

- name: Запустить Patroni и дождаться готовности
  block:
    - name: Включить и запустить patroni.service
      ansible.builtin.systemd_service:
        name: patroni
        state: started
        enabled: true
        daemon_reload: true
      register: patroni_service_start

    - name: Применить отложенные reload/restart
      ansible.builtin.meta: flush_handlers

    - name: Дождаться готовности узла ({{ _patroni_wait_endpoint }})
      ansible.builtin.uri:
        url: "http://{{ ansible_host }}:{{ patroni_restapi_port }}{{ _patroni_wait_endpoint }}"
        status_code: 200
      register: _patroni_ready
      retries: "{{ patroni_start_retries }}"
      delay: 10
      until: _patroni_ready.status == 200
      check_mode: false
  rescue:
    - name: Собрать журнал Patroni
      ansible.builtin.command: journalctl -u patroni -n 80 --no-pager
      register: _patroni_journal
      changed_when: false
      failed_when: false

    # Узел, который так и не инициализировался, останавливаем, чтобы он не
    # участвовал в гонке за лидерство. Существующие данные НЕ удаляем.
    - name: Остановить неинициализированный узел
      ansible.builtin.systemd_service:
        name: patroni
        state: stopped
      when: not _patroni_pgdata.stat.exists

    - name: Прервать с диагностикой
      ansible.builtin.fail:
        msg: "Patroni на {{ inventory_hostname }} не достиг {{ _patroni_wait_endpoint }}:\n{{ _patroni_journal.stdout | default('') }}"

tasks/post_bootstrap.yml

---
- name: Определить текущего лидера
  ansible.builtin.uri:
    url: "http://{{ ansible_host }}:{{ patroni_restapi_port }}/primary"
    status_code: [200, 503]
  register: _patroni_primary_probe
  check_mode: false

- name: Зафиксировать роль лидера
  ansible.builtin.set_fact:
    patroni_is_leader: "{{ _patroni_primary_probe.status == 200 }}"

- name: Проверить, что лидер ровно один
  ansible.builtin.assert:
    that: >-
      ansible_play_hosts | map('extract', hostvars, 'patroni_is_leader')
      | select | list | length == 1
    fail_msg: "Ожидался ровно 1 лидер Patroni — возможен split-brain или кластер не поднят"
  run_once: true

- name: Задачи на лидере
  when: patroni_is_leader | bool
  block:
    - name: Получить текущую DCS-конфигурацию
      ansible.builtin.uri:
        url: "http://{{ ansible_host }}:{{ patroni_restapi_port }}/config"
        return_content: true
      register: _patroni_dcs_current
      check_mode: false

    # PATCH выполняется только при реальном расхождении → повторный прогон даёт changed=0
    - name: Синхронизировать DCS-конфигурацию
      ansible.builtin.uri:
        url: "http://{{ ansible_host }}:{{ patroni_restapi_port }}/config"
        method: PATCH
        body_format: json
        body: "{{ patroni_dcs_config }}"
        url_username: "{{ patroni_restapi_user }}"
        url_password: "{{ patroni_restapi_password }}"
        force_basic_auth: true
        status_code: 200
      when: (_patroni_dcs_current.json | combine(patroni_dcs_config, recursive=True)) != _patroni_dcs_current.json
      changed_when: true
      no_log: true

    - name: Создать административного пользователя (без SUPERUSER)
      community.postgresql.postgresql_user:
        name: "{{ pg_admin_user }}"
        password: "{{ pg_admin_password }}"
        role_attr_flags: "LOGIN,CREATEDB,CREATEROLE"
        update_password: "{{ pg_admin_password_update }}"
        login_unix_socket: /var/run/postgresql
        port: "{{ postgresql_port }}"
      become_user: postgres
      no_log: true

templates/patroni.yml.j2

# {{ ansible_managed }}
scope: {{ patroni_scope }}
namespace: {{ patroni_namespace }}
name: {{ inventory_hostname }}

log:
  level: {{ patroni_log_level }}

restapi:
  listen: {{ ansible_host }}:{{ patroni_restapi_port }}
  connect_address: {{ ansible_host }}:{{ patroni_restapi_port }}
  authentication:            # защищает небезопасные методы (PATCH/POST); GET health-check открыты
    username: {{ patroni_restapi_user }}
    password: "{{ patroni_restapi_password }}"

etcd3:
  hosts:
{% for ip in cluster_etcd_ips %}
    - {{ ip }}:{{ etcd_client_port }}
{% endfor %}

bootstrap:
  dcs:
    {{ patroni_dcs_config | to_nice_yaml(indent=2) | indent(4) }}
  initdb:
{% for opt in patroni_initdb_options %}
    - {{ opt if opt is string else (opt | to_json) }}
{% endfor %}

postgresql:
  listen: {{ ansible_host }},127.0.0.1:{{ postgresql_port }}
  connect_address: {{ ansible_host }}:{{ postgresql_port }}
  use_unix_socket: true
  data_dir: {{ postgresql_data_dir }}
  bin_dir: {{ postgresql_bin_dir }}
  pgpass: /var/lib/postgresql/.pgpass_patroni
  authentication:
    superuser:
      username: {{ pg_superuser }}
      password: "{{ pg_superuser_password }}"
    replication:
      username: {{ pg_replication_user }}
      password: "{{ pg_replication_password }}"
    rewind:
      username: {{ pg_rewind_user }}
      password: "{{ pg_rewind_password }}"
  parameters:
    unix_socket_directories: /var/run/postgresql
  create_replica_methods:
    - basebackup
  basebackup:
    checkpoint: fast
  pg_hba:
    - local   all          {{ pg_superuser }}                         peer
    - local   all          all                                        scram-sha-256
    - host    all          all              127.0.0.1/32              scram-sha-256
{% for ip in cluster_db_ips %}
    - host    replication  {{ pg_replication_user }}  {{ ip }}/32     scram-sha-256
    - host    all          {{ pg_superuser }},{{ pg_rewind_user }}  {{ ip }}/32  scram-sha-256
{% endfor %}
{% for ip in cluster_lb_ips %}
    - host    all          all              {{ ip }}/32               scram-sha-256
{% endfor %}
{% for line in patroni_pg_hba_extra %}
    - {{ line }}
{% endfor %}

tags:
  {{ patroni_tags | to_nice_yaml(indent=2) | indent(2) }}

templates/patroni.service.j2

# {{ ansible_managed }}
[Unit]
Description=Patroni HA for PostgreSQL {{ postgresql_version }} ({{ patroni_scope }})
After=network-online.target{% if inventory_hostname in groups['etcd_servers'] %} etcd.service{% endif %}

Wants=network-online.target

[Service]
Type=simple
User=postgres
Group=postgres
ExecStart={{ patroni_venv }}/bin/patroni {{ patroni_config_file }}
ExecReload=/bin/kill -s HUP $MAINPID
KillMode=process
TimeoutSec=30
Restart=on-failure
RestartSec=5
LimitNOFILE=65536
NoNewPrivileges=true
ProtectSystem=full
ProtectHome=true
PrivateTmp=true

[Install]
WantedBy=multi-user.target

handlers/main.yml

---
# SIGHUP: Patroni перечитывает локальный конфиг без остановки PostgreSQL
- name: Reload patroni
  ansible.builtin.systemd_service:
    name: patroni
    state: reloaded
  when: not (patroni_service_start is defined and patroni_service_start is changed)

# Рестарт на лидере вызывает failover → по одному узлу и только если разрешено
- name: Restart patroni
  ansible.builtin.systemd_service:
    name: patroni
    state: restarted
    daemon_reload: true
  throttle: 1
  when:
    - patroni_allow_restart | bool
    - not (patroni_service_start is defined and patroni_service_start is changed)

4.6 roles/haproxy

defaults/main.yml

---
haproxy_ppa: "ppa:vbernat/haproxy-2.8"   # в Ubuntu 22.04 из коробки 2.4
haproxy_version_pin: "2.8.*"
haproxy_bind_ip: "{{ ansible_host }}"
haproxy_maxconn: 2000
haproxy_backend_maxconn: 200
haproxy_stats_user: admin
haproxy_timeout_connect: 4s
haproxy_timeout_client: 30m
haproxy_timeout_server: 30m
haproxy_timeout_check: 5s
haproxy_check_inter: 3s
haproxy_check_fall: 3
haproxy_check_rise: 2
# /replica?lag= исключает отставшие реплики. Замените на "/read-only?lag=16MB",
# если при падении всех реплик чтение должно уходить на primary
haproxy_read_check_uri: "/replica?lag=16MB"

tasks/main.yml

---
- name: Установить software-properties-common
  ansible.builtin.apt:
    name: software-properties-common
    state: present
    update_cache: true
    cache_valid_time: 3600

- name: Подключить PPA HAProxy 2.8
  ansible.builtin.apt_repository:
    repo: "{{ haproxy_ppa }}"
    state: present

- name: Установить HAProxy {{ haproxy_version_pin }}
  ansible.builtin.apt:
    name: "haproxy={{ haproxy_version_pin }}"
    state: present

- name: Проверить версию HAProxy
  ansible.builtin.command: haproxy -v
  register: _haproxy_version
  changed_when: false
  check_mode: false
  failed_when: "'version 2.8' not in _haproxy_version.stdout"

- name: Применить конфигурацию HAProxy с откатом
  block:
    - name: Развернуть haproxy.cfg (с валидацией и бэкапом)
      ansible.builtin.template:
        src: haproxy.cfg.j2
        dest: /etc/haproxy/haproxy.cfg
        owner: root
        group: haproxy
        mode: "0640"
        validate: haproxy -c -f %s
        backup: true
      diff: false                      # в конфиге пароль stats
      register: _haproxy_cfg

    # reload выполняется внутри блока, а не хендлером: так rescue перехватит ошибку
    - name: Перезагрузить HAProxy при изменении конфига
      ansible.builtin.systemd_service:
        name: haproxy
        state: reloaded
      when: _haproxy_cfg is changed

    - name: Включить автозапуск HAProxy
      ansible.builtin.systemd_service:
        name: haproxy
        state: started
        enabled: true

    - name: Проверить, что порты слушаются
      ansible.builtin.wait_for:
        host: "{{ haproxy_bind_ip }}"
        port: "{{ item }}"
        timeout: 30
      loop:
        - "{{ haproxy_listen_port_write }}"
        - "{{ haproxy_listen_port_read }}"
        - "{{ haproxy_stats_port }}"
  rescue:
    - name: Откат — восстановить предыдущий haproxy.cfg
      ansible.builtin.copy:
        src: "{{ _haproxy_cfg.backup_file }}"
        dest: /etc/haproxy/haproxy.cfg
        remote_src: true
        owner: root
        group: haproxy
        mode: "0640"
      when: _haproxy_cfg.backup_file is defined

    - name: Откат — перезапустить HAProxy со старым конфигом
      ansible.builtin.systemd_service:
        name: haproxy
        state: restarted
      when: _haproxy_cfg.backup_file is defined

    - name: Прервать выполнение
      ansible.builtin.fail:
        msg: "Не удалось применить конфигурацию HAProxy на {{ inventory_hostname }}; выполнен откат к предыдущей версии."

templates/haproxy.cfg.j2

# {{ ansible_managed }}
global
    log /dev/log local0
    log /dev/log local1 notice
    chroot /var/lib/haproxy
    stats socket /run/haproxy/admin.sock mode 660 level admin
    stats timeout 30s
    user haproxy
    group haproxy
    daemon
    maxconn {{ haproxy_maxconn }}

defaults
    log     global
    mode    tcp
    option  tcplog
    option  dontlognull
    retries 2
    timeout connect {{ haproxy_timeout_connect }}
    timeout client  {{ haproxy_timeout_client }}
    timeout server  {{ haproxy_timeout_server }}
    timeout check   {{ haproxy_timeout_check }}

listen stats
    mode http
    bind {{ haproxy_bind_ip }}:{{ haproxy_stats_port }}
    bind 127.0.0.1:{{ haproxy_stats_port }}
    stats enable
    stats uri /
    stats refresh 10s
    stats auth {{ haproxy_stats_user }}:{{ haproxy_stats_password }}

# Запись: health-check через Patroni REST — 200 только у текущего лидера.
# on-marked-down shutdown-sessions рвёт сессии к бывшему primary при failover
listen postgres_write
    bind {{ haproxy_bind_ip }}:{{ haproxy_listen_port_write }}
    option httpchk
    http-check send meth OPTIONS uri /primary
    http-check expect status 200
    default-server inter {{ haproxy_check_inter }} fall {{ haproxy_check_fall }} rise {{ haproxy_check_rise }} on-marked-down shutdown-sessions
{% for h in groups['db_servers'] %}
    server {{ h }} {{ hostvars[h].ansible_host }}:{{ postgresql_port }} maxconn {{ haproxy_backend_maxconn }} check port {{ patroni_restapi_port }}
{% endfor %}

# Чтение: round-robin по здоровым репликам с допустимым лагом
listen postgres_read
    bind {{ haproxy_bind_ip }}:{{ haproxy_listen_port_read }}
    balance roundrobin
    option httpchk
    http-check send meth OPTIONS uri {{ haproxy_read_check_uri }}
    http-check expect status 200
    default-server inter {{ haproxy_check_inter }} fall {{ haproxy_check_fall }} rise {{ haproxy_check_rise }} on-marked-down shutdown-sessions
{% for h in groups['db_servers'] %}
    server {{ h }} {{ hostvars[h].ansible_host }}:{{ postgresql_port }} maxconn {{ haproxy_backend_maxconn }} check port {{ patroni_restapi_port }}
{% endfor %}

handlers/main.yml

---
- name: Reload haproxy
  ansible.builtin.systemd_service:
    name: haproxy
    state: reloaded

4.7 roles/cluster_verify

defaults/main.yml

---
verify_max_lag_bytes: 16777216
verify_retries: 12

tasks/main.yml

---
- name: Проверить топологию Patroni
  ansible.builtin.include_tasks: patroni.yml
  when: inventory_hostname == groups['db_servers'][0]

- name: Проверить HAProxy
  ansible.builtin.include_tasks: haproxy.yml
  when: inventory_hostname in groups['lb_servers']

tasks/patroni.yml

---
- name: Получить топологию кластера из Patroni
  ansible.builtin.uri:
    url: "http://{{ ansible_host }}:{{ patroni_restapi_port }}/cluster"
    return_content: true
  register: _verify_cluster
  check_mode: false
  retries: "{{ verify_retries }}"
  delay: 5
  until: >-
    _verify_cluster.status == 200 and
    (_verify_cluster.json.members | selectattr('role', 'equalto', 'leader') | list | length) == 1 and
    (_verify_cluster.json.members | selectattr('role', 'in', ['replica', 'sync_standby'])
       | selectattr('state', 'in', ['streaming', 'running']) | list | length) == (groups['db_servers'] | length - 1)

- name: Проверить лаг реплик
  ansible.builtin.assert:
    that:
      - item.lag is number
      - item.lag | int <= verify_max_lag_bytes
    fail_msg: "Реплика {{ item.name }}: лаг {{ item.lag }} превышает {{ verify_max_lag_bytes }} байт"
    quiet: true
  loop: "{{ _verify_cluster.json.members | rejectattr('role', 'equalto', 'leader') | list }}"
  loop_control:
    label: "{{ item.name }}"

- name: Проверить маршрутизацию через HAProxy (write → primary, read → replica)
  ansible.builtin.command: >-
    psql -h {{ cluster_lb_ips[0] }} -p {{ item.port }} -U {{ pg_admin_user }} -d postgres
    -tAc "select pg_is_in_recovery()"
  environment:
    PGPASSWORD: "{{ pg_admin_password }}"
    PGCONNECT_TIMEOUT: "5"
  register: _verify_route
  changed_when: false
  check_mode: false
  no_log: true
  retries: "{{ verify_retries }}"
  delay: 5
  until: _verify_route.rc == 0 and _verify_route.stdout | trim == item.expected
  loop:
    - { port: "{{ haproxy_listen_port_write }}", expected: "f" }
    - { port: "{{ haproxy_listen_port_read }}", expected: "t" }

- name: Итог по кластеру
  ansible.builtin.debug:
    msg: "{{ _verify_cluster.json.members | map(attribute='name') | zip(_verify_cluster.json.members | map(attribute='role'), _verify_cluster.json.members | map(attribute='state')) | list }}"

tasks/haproxy.yml

---
- name: Получить статистику HAProxy (CSV)
  ansible.builtin.uri:
    url: "http://127.0.0.1:{{ haproxy_stats_port }}/;csv"
    url_username: "{{ haproxy_stats_user }}"
    url_password: "{{ haproxy_stats_password }}"
    force_basic_auth: true
    return_content: true
  register: _verify_stats
  check_mode: false
  no_log: true
  retries: "{{ verify_retries }}"
  delay: 5
  until: >-
    (_verify_stats.content | default('')).splitlines()
    | select('match', '^postgres_write,') | reject('match', '^postgres_write,(FRONTEND|BACKEND),')
    | select('search', ',UP,') | list | length == 1

- name: Проверить число UP-серверов в пулах
  ansible.builtin.assert:
    that:
      - _write_up | length == 1
      - _read_up | length == (groups['db_servers'] | length - 1)
    fail_msg: "HAProxy: write UP={{ _write_up | length }} (ожидался 1), read UP={{ _read_up | length }}"
  vars:
    _lines: "{{ _verify_stats.content.splitlines() }}"
    _write_up: "{{ _lines | select('match', '^postgres_write,') | reject('match', '^postgres_write,(FRONTEND|BACKEND),') | select('search', ',UP,') | list }}"
    _read_up: "{{ _lines | select('match', '^postgres_read,') | reject('match', '^postgres_read,(FRONTEND|BACKEND),') | select('search', ',UP,') | list }}"

5. Переменные

Переменная Назначение Значение по умолчанию
postgresql_version Мажорная версия PG 15
postgresql_data_dir Каталог данных (initdb выполняет Patroni) /var/lib/postgresql/15/main
postgresql_bin_dir Бинарники PG /usr/lib/postgresql/15/bin
postgresql_port Порт PG 5432
pg_superuser / pg_replication_user / pg_rewind_user / pg_admin_user Системные и административные роли БД postgres / replicator / rewind_user / pgadmin
vault_pg_*_password, vault_patroni_restapi_password, vault_haproxy_stats_password Секреты (≥16 символов, проверяются в preflight) — (обязательны, vault)
patroni_scope / patroni_namespace Имя кластера и префикс ключей в DCS staging-pg-cluster / /service/
patroni_version Версия Patroni (pip, venv) 3.3.2
patroni_restapi_port REST API и health-check 8008
patroni_dcs_config Глобальные параметры кластера (ttl, параметры PG) см. roles/patroni/defaults
patroni_start_retries Ожидание старта узла (×10 с) 60
patroni_allow_restart Разрешить автоматический рестарт Patroni хендлером true
pg_admin_password_update on_create / always (ротация пароля) on_create
etcd_version Версия etcd (официальный релиз с проверкой SHA256) 3.5.17
etcd_cluster_token Токен начального кластера staging-etcd-cluster-01
etcd_client_port / etcd_peer_port Порты etcd 2379 / 2380
etcd_data_dir Данные etcd /var/lib/etcd
haproxy_listen_port_write / _read Порты записи и чтения 5433 / 5434
haproxy_stats_port Статистика HAProxy (только admin-подсеть) 7000
haproxy_read_check_uri Health-check пула чтения /replica?lag=16MB
haproxy_client_cidrs Кто может подключаться к HAProxy ["10.20.0.0/24"]
common_admin_cidrs Admin-подсети (SSH, REST, stats) ["10.20.0.0/24"]
common_ssh_hardening_enabled Drop-in для hardening sshd true
ufw_rules_* Наборы правил файрвола по группам в group_vars/<group>.yml
pg_legacy_versions Версии PG для удаления ["14"]
pg_legacy_cleanup_confirm Предохранитель удаления false (в staging true)
verify_max_lag_bytes Допустимый лаг реплик при проверке 16777216

6. Запуск и проверка

# 0. Зависимости
python3 -m pip install "ansible-core>=2.15,<2.19" ansible-lint
ansible-galaxy collection install -r requirements.yml

# 1. Секреты: заполнить и зашифровать
ansible-vault encrypt --vault-id staging@prompt inventories/staging/group_vars/all/vault.yml

# 2. Статические проверки
ansible-inventory --graph
ansible-playbook site.yml --syntax-check
ansible-lint site.yml roles/

# 3. Доступность и привилегии
ansible staging -m ansible.builtin.ping
ansible staging -b -m ansible.builtin.command -a "id -u"      # ожидается 0

# 4. Dry-run. На «голых» серверах имеет смысл для базового слоя:
#    задачи, завязанные на ещё не установленные бинарники, в check-режиме упадут
ansible-playbook site.yml --vault-id staging@prompt --check --diff --tags common,firewall

# 5. Боевой прогон
ansible-playbook site.yml --vault-id staging@prompt

# 6. Проверка идемпотентности: повторный запуск
ansible-playbook site.yml --vault-id staging@prompt
#    Ожидается: PLAY RECAP ... changed=0 failed=0 на всех хостах

# 7. После развёртывания --check --diff показывает дрейф конфигурации
ansible-playbook site.yml --vault-id staging@prompt --check --diff

# Точечные прогоны
ansible-playbook site.yml --vault-id staging@prompt --tags haproxy
ansible-playbook site.yml --vault-id staging@prompt --tags patroni_config   # только DCS-параметры
ansible-playbook site.yml --vault-id staging@prompt --tags verify

Ручная проверка:

# На любом DB-узле
sudo -u postgres patronictl -c /etc/patroni/patroni.yml list

Ожидаемый результат:

+ Cluster: staging-pg-cluster ----+---------+-----------+----+-----------+
| Member   | Host       | Role    | State     | TL | Lag in MB |
+----------+------------+---------+-----------+----+-----------+
| pg-node1 | 10.20.1.11 | Leader  | running   |  1 |           |
| pg-node2 | 10.20.1.12 | Replica | streaming |  1 |         0 |
| pg-node3 | 10.20.1.13 | Replica | streaming |  1 |         0 |
+----------+------------+---------+-----------+----+-----------+
ETCDCTL_API=3 etcdctl --endpoints=http://10.20.1.11:2379 endpoint status --cluster -w table

# Маршрутизация (из admin-подсети)
psql "host=10.20.1.20 port=5433 user=pgadmin dbname=postgres" -c "select pg_is_in_recovery();"  # f
psql "host=10.20.1.20 port=5434 user=pgadmin dbname=postgres" -c "select pg_is_in_recovery();"  # t

# Тест failover (staging): контролируемый switchover
sudo -u postgres patronictl -c /etc/patroni/patroni.yml switchover --leader pg-node1 --candidate pg-node2 --force

После switchover порт 5433 должен начать вести на pg-node2 в течение примерно inter × rise, то есть около 6–10 секунд.


7. Заметки

Риски и ограничения

  1. lb-node1 — единая точка отказа. Для production нужны два LB с keepalived (VRRP VIP) и net.ipv4.ip_nonlocal_bind=1. Альтернатива — libpq multi-host target_session_attrs=read-write на стороне клиента.
  2. etcd и Patroni REST работают без TLS. Их защищает только ufw (allowlist IP). Для production нужно:
    • выпустить CA и сертификаты (например, community.crypto);
    • включить client-transport-security и peer-transport-security в etcd;
    • в Patroni задать etcd3.protocol: https и restapi.certfile;
    • включить в etcd RBAC-аутентификацию.
  3. Split-brain при сетевой изоляции. Без watchdog старый primary может принимать записи до истечения TTL (30 с). Решения:
    • включить softdog и watchdog: {mode: required} в Patroni;
    • если потеря данных недопустима, включить synchronous_mode: true в patroni_dcs_config.
  4. Рестарт Patroni на лидере вызывает failover. Для production ставьте patroni_allow_restart: false. Изменения, требующие рестарта (например, shared_buffers), применяйте через patronictl restart --pending или делайте switchover перед рестартом.
  5. Параметры DCS после bootstrap меняются только через REST PATCH (задача в post_bootstrap.yml). Правка секции bootstrap.dcs в patroni.yml на живой кластер не влияет, поэтому источник истины — переменная patroni_dcs_config.
  6. Нужен доступ в интернет. В закрытом контуре вынесите артефакты во внутренние зеркала (Nexus/Artifactory, apt-mirror, devpi) и переопределите etcd_release_base, haproxy_ppa, postgresql_pgdg_key_url.
  7. Удаление PG14 необратимо. Остаётся только tar.gz в /var/backups/postgresql-legacy. Скопируйте его с сервера, если данные нужны.
  8. Повторный bootstrap. Если узел с данными удалён, а в etcd остался ключ /service/staging-pg-cluster/initialize, узел присоединится как реплика. Это корректное поведение. Полный пересоздание кластера делается так: patronictl remove staging-pg-cluster и очистка data_dir на всех узлах, строго вручную.
  9. --check на чистых серверах ограничен. Задачи, которые зависят от ещё не установленных пакетов (etcdctl, haproxy -v), в check-режиме упадут. Полноценный dry-run возможен после первого развёртывания.

Привилегии

  • deploy нужен sudo без пароля (или используйте --ask-become-pass). become включается на уровне play, preflight выполняется без root.
  • Сервисы работают от непривилегированных пользователей etcd, postgres и haproxy, с hardening в systemd (NoNewPrivileges, ProtectSystem).
  • Для pgadmin не выдаётся SUPERUSER, только CREATEDB и CREATEROLE.
  • Файлы с секретами: patroni.yml (0600), haproxy.cfg (0640). Задачи с ними выполняются с diff: false и no_log, чтобы пароли не попадали в логи CI.

Идеи по расширению

  • Бэкапы и PITR: pgBackRest или WAL-G в S3, с archive_command в patroni_dcs_config.
  • Мониторинг: postgres_exporter и встроенный /metrics Patroni (3.x), метрики etcd и HAProxy (prometheus-exporter в 2.8), алерты на смену лидера и лаг.
  • PgBouncer перед HAProxy или на DB-узлах для пулинга соединений.
  • Тестирование: Molecule-сценарий (3 контейнера или VM) с шагами converge, idempotence и verify. Отдельный сценарий chaos: systemctl stop patroni на лидере с проверкой переключения HAProxy.
  • Отдельный playbook switchover.yml для плановых работ: switchover, rolling-обновление и возврат лидерства.

Похожие промпты

IT и Администрирование

Ревью и рефакторинг PowerShell-скрипта

Разбирает существующий скрипт PowerShell: что сломается на реальных данных, где ошибки глушатся вместо обработки и что переписать в первую очередь, а что оставить.

IT и Администрирование

Скрипт PowerShell под задачу

Пишет скрипт PowerShell с обработкой ошибок, логированием и режимом пробного прогона — так, чтобы его можно было запустить на рабочей машине и не бояться последствий.

IT и Администрирование

Диагностика проблем VPN-подключения

Методично диагностирует проблемы VPN: соединение не устанавливается, скорость падает, утекает DNS — по одному шагу, с командами под вашу ОС.

IT и Администрирование

Помощник по настройке WireGuard и OpenVPN

Пошагово разворачивает собственный VPN-сервер на WireGuard или OpenVPN: готовые команды и конфиги, firewall, клиентские профили и проверка утечек.