Создатель Ansible Playbook для автоматизации
Профессиональный шаблон для генерации идемпотентных Ansible playbook с ролями, безопасностью и инструкциями по запуску.
Пример запуска
Сгенерировано 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. Заметки
Риски и ограничения
lb-node1— единая точка отказа. Для production нужны два LB с keepalived (VRRP VIP) иnet.ipv4.ip_nonlocal_bind=1. Альтернатива — libpq multi-hosttarget_session_attrs=read-writeна стороне клиента.- 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-аутентификацию.
- выпустить CA и сертификаты (например,
- Split-brain при сетевой изоляции. Без watchdog старый primary может принимать записи до истечения TTL (30 с). Решения:
- включить
softdogиwatchdog: {mode: required}в Patroni; - если потеря данных недопустима, включить
synchronous_mode: trueвpatroni_dcs_config.
- включить
- Рестарт Patroni на лидере вызывает failover. Для production ставьте
patroni_allow_restart: false. Изменения, требующие рестарта (например,shared_buffers), применяйте черезpatronictl restart --pendingили делайте switchover перед рестартом. - Параметры DCS после bootstrap меняются только через REST PATCH (задача в
post_bootstrap.yml). Правка секцииbootstrap.dcsвpatroni.ymlна живой кластер не влияет, поэтому источник истины — переменнаяpatroni_dcs_config. - Нужен доступ в интернет. В закрытом контуре вынесите артефакты во внутренние зеркала (Nexus/Artifactory, apt-mirror, devpi) и переопределите
etcd_release_base,haproxy_ppa,postgresql_pgdg_key_url. - Удаление PG14 необратимо. Остаётся только tar.gz в
/var/backups/postgresql-legacy. Скопируйте его с сервера, если данные нужны. - Повторный bootstrap. Если узел с данными удалён, а в etcd остался ключ
/service/staging-pg-cluster/initialize, узел присоединится как реплика. Это корректное поведение. Полный пересоздание кластера делается так:patronictl remove staging-pg-clusterи очисткаdata_dirна всех узлах, строго вручную. --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 и встроенный
/metricsPatroni (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-обновление и возврат лидерства.
Похожие промпты
Ревью и рефакторинг PowerShell-скрипта
Разбирает существующий скрипт PowerShell: что сломается на реальных данных, где ошибки глушатся вместо обработки и что переписать в первую очередь, а что оставить.
Скрипт PowerShell под задачу
Пишет скрипт PowerShell с обработкой ошибок, логированием и режимом пробного прогона — так, чтобы его можно было запустить на рабочей машине и не бояться последствий.
Диагностика проблем VPN-подключения
Методично диагностирует проблемы VPN: соединение не устанавливается, скорость падает, утекает DNS — по одному шагу, с командами под вашу ОС.
Помощник по настройке WireGuard и OpenVPN
Пошагово разворачивает собственный VPN-сервер на WireGuard или OpenVPN: готовые команды и конфиги, firewall, клиентские профили и проверка утечек.