Кластер PostgreSQL высокой надежности на базе Patroni, Haproxy, Keepalived

в 12:55, , рубрики: Ansible, devops, haproxy, keepalived, linux, patroni, postgresql, Администрирование баз данных, системное администрирование

Привет!

Встала передо мной недавно задача: настроить максимально надежный кластер серверов PostgreSQL версии 9.6.

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

Планируя кластер я проштудировал много статей, как из основной документации к PostgreSQL, так и различных howto, в том числе с Хабра, и пробовал настроить стандартный кластер с RepMgr, эксперементировал с pgpool.
В целом оно заработало, но у меня периодически всплывали проблемы с переключениями, требовалось ручное вмешательство для восстановления после аварий, и т.д.
В общем я решил поискать еще варианты.
В итоге где-то (уже не вспомню точно где) нашел ссылку на прекрасный проект Zalando Patroni, и все заверте…

Введение

Patroni — это демон на python, позволяющий автоматически обслуживать кластера PostgreSQL с различными типами репликации, и автоматическим переключением ролей.
Его особенная красота, на мой взгляд в том, что для поддержания актуальности кластера и выборов мастера используются распределенные DCS хранилища (поддерживаются Zookeeper, etcd, Consul).
Таким образом кластер легко интегрируется практически в любую систему, всегда можно выяснить кто в данный момент мастер, и статус всех серверов запросами в DCS, или напрямую к Patroni через http.
Ну и просто это красиво. :)

Я потестировал работу Patroni, пробовал ронять мастера и другие сервера, пробовал наливать разные базы (~25 Гб база автоматически поднимается с нуля на 10Гб сети за несколько минут), и в целом мне проект Patroni очень понравился
После полной реализации описанной ниже схемы я проводил тестирование простым бенчером, который ходил в базу по единому адресу, и переживал падения всех элементов кластера (мастер сервера, haproxy, keepalived).
Задержка при передаче роли новому мастеру составляла пару секунд.
При возвращении бывшего мастера в кластер, или добавлении нового сервера, смены ролей не происходит.

Для автоматизации разворачивания кластера и добавления новых серверов, решено было использовать привычный Ansible (я дам ссылки на получившиеся роли в конце статьи).
В качестве DCS выступает уже применяемый у нас Consul.

У статьи две основные цели: показать пользователям PostgreSQL что есть такая прекрасная штука как Patroni (упоминаний в рунете вообще и на Хабре в частности, практически нет), и заодно немного поделиться опытом использования Ansible на простом примере, тем кто только начинает с ним работать.

Я постараюсь разъяснить все действо сразу на примере разбора Ansible ролей и плейбуков.
Те, кто не использует Ansible, смогут перенести все действия в любимое средство автоматизированного управления серверами, либо выполнить их же вручную.
Поскольку большая часть yaml скриптов будет длинной, я буду заворачивать их в спойлер.
Рассказ будет разделен на две части — подготовка серверов и разворачивание непосредственно кластера.
Тем кто хорошо знаком с Ansible первая часть интересна не будет, поэтому рекомендую перейти сразу ко второй.

Часть I

Для этого примера я использую виртуальные машины на базе Centos 7.
Виртуалки разворачиваются из шаблона который периодически обновляется (ядро, системные пакеты), но эта тема выходит за рамки данной статьи.
Отмечу только, что никакого прикладного или серверного софта на виртуалках заранее не установлено.
Также вполне подойдут любые облачные ресурсы, например с AWS, DO, vScale, и т.п.
Для них есть скрипты динамического инвентаря и интеграции с Ansible, либо можно прикрутить Terraform, так что весь процесс создания и удаления серверов c нуля может быть автоматизирован.

Для начала нужно создать инвентарь используемых ресурсов для Ansible.
Ansible у меня (и по умолчанию) расположен в /etc/ansible.
Создаем инвентарь в файле /etc/ansible/hosts:

[pgsql]
cluster-pgsql-01.local
cluster-pgsql-02.local
cluster-pgsql-03.local

У нас используется внутренняя доменная зона .local, поэтому у серверов такие имена.

Далее нужно подготовить каждый сервер к установке всех необходимых компонентов, и рабочих инструментов.

Для этой цели создаем плейбук в /etc/ansible/tasks:

/etc/ansible/tasks/essentialsoftware.yml

---

- name: Install essential software
  yum: name={{ item }} state=latest
  tags: software
  with_items:
   - ntpdate
   - bzip2
   - zip
   - unzip
   - openssl-devel
   - mc
   - vim
   - atop
   - wget
   - mytop
   - screen
   - net-tools
   - rsync
   - psmisc
   - gdb
   - subversion
   - htop
   - bind-utils
   - sysstat
   - nano
   - iptraf
   - nethogs
   - ngrep
   - tcpdump
   - lm_sensors
   - mtr
   - s3cmd
   - psmisc
   - gcc
   - git
   - python2-pip
   - python-devel

- name: install the 'Development tools' package group
  yum:
    name: "@Development tools"
    state: present

Набор пакетов Essential служит для создания на любом сервере привычного рабочего окружения.
Группа пакетов Development tools, некоторые библиотеки -devel и python нужны pip-у для сборки Python модулей к PostgreSQL.

Мы используем виртуальные машины на базе VmWare ESXi, и для удобства администрирования в них нужно запускать агент vmware.
Для этого мы запустим открытый агент vmtoolsd, и опишем его установку в отдельном плейбуке (поскольку не все сервера у нас виртуальные, и возможно для каких-то из них этот таск не понадобится):

/etc/ansible/tasks/open-vm-tools.yml

---

- name: Install open VM tools for VMWARE
  yum: name={{ item }} state=latest
  tags: open-vm-tools
  with_items:
   - open-vm-tools

- name: VmWare service start and enabling
  service: name=vmtoolsd.service state=started enabled=yes
  tags: open-vm-tools

Для того чтобы завершить подготовку сервера к установке основной части софта, в нашем случае, понадобятся следующие шаги:
1) настроить синхронизацию времени с помощью ntp
2) установить и запустить zabbix агент для мониторинга
3) накатить требуемые ssh ключи и authorized_keys.
Чтобы не слишком раздувать статью деталям не относящимися к собственно кластеру, я кратко процитирую ansible плейбуки, выполняющие эти задачи:

NTP:

/etc/ansible/tasks/ntpd.yml

---
    - name: setting default timezone
      set_fact:
        timezone: name=Europe/Moscow
      when: timezone is not defined

    - name: setting TZ
      timezone: name={{ timezone }}
      when: timezone is defined
      tags:
      - tz
      - tweaks
      - ntp
      - ntpd

    - name: Configurating cron for ntpdate
      cron: name="ntpdate" minute="*/5" job="/usr/sbin/ntpdate pool.ntp.org"
      tags:
      - tz
      - tweaks
      - ntp
      - ntpd

    - name: ntpd stop and disable
      service: name=ntpd state=stopped enabled=no
      tags:
      - tz
      - tweaks
      - ntp
      - ntpd
      ignore_errors: yes

    - name: crond restart and enabled
      service: name=crond state=restarted enabled=yes
      tags:
      - tz
      - tweaks
      - ntp
      - ntpd

Вначале проверяется, не выставлена ли для сервера персональная таймзона, и если нет, то выставляется Московская (таких серверов у нас большинство).
Мы не используем ntpd из-за проблем с уплыванием времени на виртуалках ESXi, после которого ntpd отказывается синхронизировать время. (И tinker panic 0 не помогает)
Поэтому просто запускаем кроном ntp клиент раз 5 минут.

Zabbix-agent:

/etc/ansible/tasks/zabbix.yml

---

    - name: set zabbix ip external
      set_fact:
        zabbix_ip: 132.xx.xx.98
      tags: zabbix

    - name: set zabbix ip internal
      set_fact:
        zabbix_ip: 192.168.xx.98
      when: ansible_all_ipv4_addresses | ipaddr('192.168.0.0/16')
      tags: zabbix

    - name: Import Zabbix3 repo
      yum: name=http://repo.zabbix.com/zabbix/3.0/rhel/7/x86_64/zabbix-release-3.0-1.el7.noarch.rpm state=present
      tags: zabbix

    - name: Remove old zabbix
      yum: name=zabbix2* state=absent
      tags: zabbix

    - name: Install zabbix-agent software
      yum: name={{ item }} state=latest
      tags: zabbix
      with_items:
        - zabbix-agent
        - zabbix-release

    - name: Creates directories
      file: path={{ item }}  state=directory
      tags:
      - zabbix
      - zabbix-mysql
      with_items:
        - /etc/zabbix/externalscripts
        - /etc/zabbix/zabbix_agentd.d
        - /var/lib/zabbix

    - name: Copy scripts
      copy: src=/etc/ansible/templates/zabbix/{{ item }} dest=/etc/zabbix/externalscripts/{{ item }} owner=zabbix group=zabbix  mode=0755
      tags: zabbix
      with_items:
        - netstat.sh
        - iostat.sh
        - iostat2.sh
        - iostat_collect.sh
        - iostat_parse.sh
        - php_workers_discovery.sh

    - name: Copy .my.cnf
      copy: src=/etc/ansible/files/mysql/.my.cnf dest=/var/lib/zabbix/.my.cnf owner=zabbix group=zabbix  mode=0700
      tags:
      - zabbix
      - zabbix-mysql

    - name: remove default configs
      file: path={{ item }} state=absent
      tags: zabbix
      with_items:
        - /etc/zabbix_agentd.conf
        - /etc/zabbix/zabbix_agentd.conf

    - name: put zabbix-agentd.conf to default place
      template: src=/etc/ansible/templates/zabbix/zabbix_agentd.tpl dest=/etc/zabbix_agentd.conf owner=zabbix group=zabbix force=yes
      tags: zabbix

    - name: link zabbix-agentd.conf to /etc/zabbix
      file: src=/etc/zabbix_agentd.conf dest=/etc/zabbix/zabbix_agentd.conf state=link
      tags: zabbix

    - name: zabbix-agent start and enable
      service: name=zabbix-agent state=restarted enabled=yes
      tags: zabbix

При установке Zabbix конфиг агента накатывается из шаблона, нужно поменять только адрес сервера.
Сервера расположенные в пределах нашей сети ходят на 192.168.х.98, а сервера не имеющие в нее доступа, на реальный адрес этого же сервера.

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

Настала пора накатить на сервера созданную конфигурацию.
Вообще я выполняю установку всех компонентов и самого кластера в один шаг, уже при наличии полного конфига, но мне кажется что для целей данного туториала будет лучше разделить это на два шага, соответственно по главам.

Создаем плейбук для группы серверов:

/etc/ansible/cluster-pgsql.yml

---
- hosts: pgsql

  pre_tasks:
    - name: Setting system hostname
      hostname: name="{{ ansible_host }}"

    - include: tasks/essentialsoftware.yml
    - include: tasks/open-vm-tools.yml
    - include: tasks/ntpd.yml

  post_tasks:
    - include: tasks/zabbix.yml

  roles:
     - ssh.role
     - ansible-role-patroni

Запускаем обработку всех серверов:
~# ansible-playbook cluster-pgsql.yml --skip-tags patroni

Если вы полностью скачали мой пример из гитхаб репозитория, то у вас будет также в наличии и роль Patroni, которую нам пока отрабатывать не нужно.
Аргумент --skip-tags заставляет Ansible пропустить шаги помеченные этим тегом, поэтому роль ansible-role-patroni выполняться сейчас не будет.
Если же ее на диске нет, то ничего страшного и не произойдет, Anisble просто проигнорирует этот ключ.

Ansible у меня заходит на сервера сразу пользователем root, а если вам потребуется пускать ansible под непревилегированного пользователя, стоит дополнительно добавить в шаги требующие рутовых прав специальный флаг «become: true», который побудит ansible использовать вызовы sudo для этих шагов.

Подготовка закончена.

Часть II

Приступаем к разворачиванию непосредственно кластера.

Поскольку для настройки кластера требуется много работы (установить PostgreSQL и все компоненты, залить для них индивидуальные конфиги), я выделил весь этот процесс в отдельную роль.
Роли в Ansible позволяют сгруппировать наборы смежных тасков, и тем упрощают написание скриптов и поддержку их в рабочем состоянии.

Шаблон роли для установки Patroni я взял тут: https://github.com/gitinsky/ansible-role-patroni, за что спасибо его автору.
Для своих целей я переработал имеющийся и добавил свои плейбуки haproxy и keepalived.

Роли у меня лежат в каталоге /etc/ansible/roles.
Создаем каталог для новой роли, и подкаталоги для ее компонентов:
~# mkdir /etc/ansible/roles/ansible-role-patroni/tasks
~# mkdir /etc/ansible/roles/ansible-role-patroni/templates

Помимо PostgreSQL наш кластер будет состоять из следующих компонентов:

1) haproxy для отслеживания состояния серверов и перенаправления запросов на мастер сервер.
2) keepalived для обеспечения наличия единой точки входа в кластер — виртуального IP.

Все плейбуки выполняемые данной ролью перечисляем в файле, запускаемом ansible по умолчанию:

/etc/ansible/roles/ansible-role-patroni/tasks/main.yml

- include: postgres.yml
- include: haproxy.yml
- include: keepalived.yml

Далее начинаем описывать отдельные таски.

Первый плейбук устанавливает PostgreSQL 9.6 из родного репозитория, и дополнительные пакеты требуемые Patroni, а затем скачивает с GitHub саму Patroni:

/etc/ansible/roles/ansible-role-patroni/tasks/postgres.yml

---

- name: Import Postgresql96 repo
  yum: name=https://download.postgresql.org/pub/repos/yum/9.6/redhat/rhel-7-x86_64/pgdg-centos96-9.6-3.noarch.rpm state=present
  tags: patroni
  when: install is defined

- name: Install PGsql96
  yum: name={{ item }} state=latest
  tags: patroni
  with_items:
    - postgresql96
    - postgresql96-contrib
    - postgresql96-server
    - python-psycopg2
    - repmgr96
  when: install is defined

- name: checkout patroni
  git: repo=https://github.com/zalando/patroni.git dest=/opt/patroni
  tags: patroni
  when: install is defined

- name: create /etc/patroni
  file: state=directory dest=/etc/patroni
  tags: patroni
  when: install is defined

- name: put postgres.yml
  template: src=postgres0.yml dest=/etc/patroni/postgres.yml backup=yes
  tags: patroni
  when: install is defined

- name: install python packages
  pip: name={{ item }}
  tags: patroni
  with_items:
    - python-etcd
    - python-consul
    - dnspython
    - boto
    - mock
    - requests
    - six
    - kazoo
    - click
    - tzlocal
    - prettytable
    - PyYAML
  when: install is defined

- name: put patroni.service systemd unit
  template: src=patroni.service dest=/etc/systemd/system/patroni.service backup=yes
  tags: patroni
  when: install is defined

- name: Reload daemon definitions
  command: /usr/bin/systemctl daemon-reload
  tags: patroni

- name: restart
  service: name=patroni state=restarted enabled=yes
  tags: patroni

Кроме установки ПО данный плейбук также заливает конфигурацию для текущего сервера Patroni, и systemd юнит для запуска демона в системе, после чего запускает демон Patroni.
Шаблоны конфигов и systemd юнит должны лежать в каталоге templates внутри роли.

Шаблон конфига Patroni:

/etc/ansible/roles/ansible-role-patroni/templates/postgres.yml.j2

name: {{ patroni_node_name }}
scope: &scope {{ patroni_scope }}

consul:
  host: consul.services.local:8500

restapi:
  listen: 0.0.0.0:8008
  connect_address: {{ ansible_default_ipv4.address }}:8008
  auth: 'username:{{ patroni_rest_password }}'

bootstrap:
  dcs:
    ttl: &ttl 30
    loop_wait: &loop_wait 10
    maximum_lag_on_failover: 1048576 # 1 megabyte in bytes
    postgresql:
      use_pg_rewind: true
      use_slots: true
      parameters:
        archive_mode: "on"
        wal_level: hot_standby
        archive_command: mkdir -p ../wal_archive && cp %p ../wal_archive/%f
        max_wal_senders: 10
        wal_keep_segments: 8
        archive_timeout: 1800s
        max_replication_slots: 5
        hot_standby: "on"
        wal_log_hints: "on"

pg_hba:  # Add following lines to pg_hba.conf after running 'initdb'
  - host replication replicator 192.168.0.0/16 md5
  - host all all 0.0.0.0/0 md5

postgresql:
  listen: 0.0.0.0:5432
  connect_address: {{ ansible_default_ipv4.address }}:5432
  data_dir: /var/lib/pgsql/9.6/data
  pg_rewind:
    username: superuser
    password: {{ patroni_postgres_password }}
  pg_hba:
  - host all all 0.0.0.0/0 md5
  - hostssl all all 0.0.0.0/0 md5
  replication:
    username: replicator
    password: {{ patroni_replicator_password }}
    network:  192.168.0.0/16
  superuser:
    username: superuser
    password: {{ patroni_postgres_password }}
  admin:
    username: admin
    password: {{ patroni_postgres_password }}
  restore: /opt/patroni/patroni/scripts/restore.py

Поскольку для каждого сервера кластера требуется индивидуальная конфигурация Patroni, его конфиг лежит в виде шаблона jinja2 (файл postgres0.yml.j2), и шаг template заставляет ansible транслировать этот шаблон с заменой переменных, значения из которых берутся из отдельного описания для каждого сервера.

Переменные, общие для всего кластера укажем в прямо в инвентаре, который примет теперь следующий вид:

/etc/ansible/hosts

[pgsql]
cluster-pgsql-01.local
cluster-pgsql-02.local
cluster-pgsql-03.local

[pgsql:vars]
patroni_scope: "cluster-pgsql"
patroni_rest_password: flsdjkfasdjhfsd
patroni_postgres_password: flsdjkfasdjhfsd
patroni_replicator_password: flsdjkfasdjhfsd
cluster_virtual_ip: 192.xx.xx.125
</spoiler>


А отдельную для каждого сервера - в каталоге host_vars/имя_сервера:

<spoiler title="/etc/ansible/host_vars/pgsql-cluster-01.local/main.yml">
<source lang="yaml">
patroni_node_name: cluster_pgsql_01
keepalived_priority: 99

Расшифрую для чего нужны некоторые переменные:

patroni_scope — название кластера при регистрации в Consul
patroni_node_name — название сервера при регистрации в Consul
patroni_rest_password — пароль для http интерфейса Patroni (требуется для отправки команд на изменение кластера)
patroni_postgres_password: пароль для юзера postgres. Он устанавливается в случае создания patroni новой базы.
patroni_replicator_password — пароль для юзера replicator. От его имени осуществляется репликация на слейвы.

Также в этом файле перечислены некоторые другие переменные, используемые в других плейбуках или ролях, в частности тот может быть настройка ssh (ключи, пользователи), таймзона для сервера, приоритет сервера в кластере keepalived, и.т.п.
Конфигурация для остальных серверов аналогична, соответственно меняется имя сервер и приоритет (например 99-100-101 для трех серверов).

Установка и настройка haproxy:

/etc/ansible/roles/ansible-role-patroni/tasks/haproxy.yml

---

- name: Install haproxy
  yum: name={{ item }} state=latest
  tags:
    - patroni
    - haproxy
  with_items:
    - haproxy
  when: install is defined

- name: put config
  template: src=haproxy.cfg.j2 dest=/etc/haproxy/haproxy.cfg backup=yes
  tags:
    - patroni
    - haproxy

- name: restart and enable
  service: name=haproxy state=restarted enabled=yes
  tags:
    - patroni
    - haproxy

Haproxy устаналивается на каждом хосте, и содержит в своем конфиге ссылки на все сервера PostgreSQL, проверяет какой сервер сейчас является мастером, и отправляет запросы на него.
Для этой проверки используется прекрасная фича Patroni — REST интерфейс.
При обращении на урл server:8008 (8008 это порт по умолчанию) Patroni возвращает отчет по состоянию кластера в json, а также отражает кодом ответа http является ли данный сервер мастером. Если является — будет ответ с кодом 200. Если же нет, ответ с кодом 503.

Очень советую обратится в документацию на Patroni, http интерфейс там достаточно интересный, позволяется также принудительно переключать роли, и управлять кластером.
Аналогично, это можно делать при помощи консольной утилиты patronyctl.py, из поставки Patroni.

Конфигурация haproxy достаточно простая:

/etc/ansible/roles/ansible-role-patroni/templates/haproxy.cfg

global
maxconn 800

defaults
log global
mode tcp
retries 2
timeout client 30m
timeout connect 4s
timeout server 30m
timeout check 5s

frontend ft_postgresql
bind *:5000
default_backend postgres-patroni

backend postgres-patroni
  option httpchk

  http-check expect status 200
  default-server inter 3s fall 3 rise 2

  server {{ patroni_node_name }} {{ patroni_node_name }}.local:5432 maxconn 300 check port 8008
  server {{ patroni_node_name }} {{ patroni_node_name }}.local:5432 maxconn 300 check port 8008
  server {{ patroni_node_name }} {{ patroni_node_name }}.local:5432 maxconn 300 check port 8008

В соответствии с этой конфигурацией haproxy слушает порт 5000, и отправляет трафик с него на мастер сервер.
Проверка статуса происходит с интервалом в 1 секунду, для перевода сервера в даун требуется 3 неудачных ответа (код 500), для переключения сервера назад — 2 удачных ответа (с кодом 200).
В любой момент времени можно обратиться непосредственно на любой haproxy, и он корректно запроксирует трафик на мастер сервер.
Также в комплекте с Patroni есть шаблон для настройки демона confd, и пример его интеграции с etcd, что позволяет динамически менять конфиг haproxy при удалении или добавлении новых серверов.
Я же пока делаю достаточно статичный кластер, лишняя автоматизация в данной ситуации, имхо, может привести к непредвиденным проблемам.

Нам хотелось, чтобы на клиентах особые изменения логики, отслеживание серверов на живости и т.д. не требовались, поэтому мы делаем единую точку входа в кластер с помощью keepalived.

Демон keepalived работает по протоколу vrrp со своими соседями, и в результате выборов одного из демонов как главного (приоритет указан в конфиге, и шаблонизирован в переменную keepalived_priority в host_vars для каждого сервера), он поднимает у себя виртуальный ip адрес.
Остальные демоны терпеливо ждут. Если текущий основной сервер keepalived по какой-то причине умрет либо просигналит соседям аварию, произойдут перевыборы, и следуюший по приоритету сервер заберет себе виртуальный ip адрес.

Для защиты от падения haproxy демоны keepalived выполняют проверку, запуская раз в секунду команду «killall -0 haproxy». Она возвращает код 0 если процесс haproxy есть, и 1 если его нет.
Если haproxy исчезнет, демон keepalived просигналит аварию по vrrp, и снимет виртуальный ip.
Виртуальный IP сразу же подхватит следующий по приоритету сервер, с живым haproxy.

Установка и настройка keepalived:

/etc/ansible/roles/ansible-role-patroni/tasks/keepalived.yml

---

- name: Install keepalived
  yum: name={{ item }} state=latest
  tags:
    - patroni
    - keepalived
  with_items:
    - keepalived
  when: install is defined

- name: put alert script
  template: src=alert.sh.j2 dest=/usr/local/sbin/alert.sh backup=yes mode=755
  tags:
    - patroni
    - keepalived
  when: install is defined

- name: put config
  template: src=keepalived.conf.j2 dest=/etc/keepalived/keepalived.conf backup=yes
  tags:
    - patroni
    - keepalived

- name: restart and enable
  service: name=keepalived state=restarted enabled=yes
  tags:
    - patroni
    - keepalived

Кроме установки keepalived, этот плейбук также копирует простой скрипт для отправки алертов через телеграм. Скрипт принимает сообщение в виде переменной, и просто дергает curl-ом API телеграма.
В этом скрипте только нужно указать свои токен и ID группы telegram для отсылки оповещений.

Конфигурация keepalived описана в виде jinja2 шаблона:

/etc/ansible/roles/ansible-role-patroni/templates/keepalived.conf.j2


global_defs {
   router_id {{ patroni_node_name }}
}

vrrp_script chk_haproxy {
        script "killall -0 haproxy"
        interval 1
        weight -20
        debug
        fall 2
        rise 2
}

vrrp_instance {{ patroni_node_name }} {
        interface ens160
        state BACKUP
        virtual_router_id 150
        priority {{ keepalived_priority }}
        authentication {
            auth_type PASS
            auth_pass secret_for_vrrp_auth
        }
        track_script {
                chk_haproxy weight 20
        }
        virtual_ipaddress {
                {{ cluster_virtual_ip }}/32 dev ens160
        }
        notify_master "/usr/bin/sh /usr/local/sbin/alert.sh '{{ patroni_node_name }} became MASTER'"
        notify_backup "/usr/bin/sh /usr/local/sbin/alert.sh '{{ patroni_node_name }} became BACKUP'"
        notify_fault "/usr/bin/sh /usr/local/sbin/alert.sh '{{ patroni_node_name }} became FAULT'"

}

В переменные patroni_node_name, cluster_virtual_ip и keepalived_priority транслируются соответствующие данные из host_vars.

Также в конфиге keepalived указан скрипт для отправки сообщений о смене статуса в telegram канал.

Накатываем полную конфигурацию кластера на сервера:
~# ansible-playbook cluster-pgsql.yml

Поскольку Ansible идемпотентен, т.е. выполняет шаги только если они не были выполнены ранее, можно запустить плейбук без дополнительных параметров.
Если же не хочется дольше ждать, или вы уверены что сервера полностью готовы, можно запустить ansible-playbook с ключом -t patroni.
Тогда будут выполнены только шаги из роли Patroni.

Отмечу что я не указываю отдельно роли серверов — мастер или слейв. Данная конфигурация создаст пустую базу, и мастером просто станет первый сконфигурированный сервер.
При добавлении новых серверов Patroni увидит через DCS что мастер кластера уже есть, автоматически скопирует с текущего мастера базу, и подключит к нему слейв.
В случае запуска слейва отставшего на какое-то время от мастера, Patroni автоматически вольет изменения при помощи pg_rewind.

Убеждаемся что все сервера запустились и выбрали себе роли:
~# journalctl -f -u patroni

Сообщения со слейва (сервер cluster-pgsql-01):

спойлер

Feb 17 23:50:32 cluster-pgsql-01.local patroni.py[100626]: 2017-02-17 23:50:32,254 INFO: Lock owner: cluster_pgsql_02; I am cluster_pgsql_01
Feb 17 23:50:32 cluster-pgsql-01.local patroni.py[100626]: 2017-02-17 23:50:32,255 INFO: Lock owner: cluster_pgsql_02; I am cluster_pgsql_01
Feb 17 23:50:32 cluster-pgsql-01.local patroni.py[100626]: 2017-02-17 23:50:32,255 INFO: does not have lock
Feb 17 23:50:32 cluster-pgsql-01.local patroni.py[100626]: 2017-02-17 23:50:32,255 INFO: no action. i am a secondary and i am following a leader

Сообщения с мастера (в данном случае это сервер cluster-pgsql-02):

спойлер

Feb 17 23:52:23 cluster-pgsql-02.local patroni.py[4913]: 2017-02-17 23:52:23,457 INFO: Lock owner: cluster_pgsql_02; I am cluster_pgsql_02
Feb 17 23:52:23 cluster-pgsql-02.local patroni.py[4913]: 2017-02-17 23:52:23,874 INFO: Lock owner: cluster_pgsql_02; I am cluster_pgsql_02
Feb 17 23:52:24 cluster-pgsql-02.local patroni.py[4913]: 2017-02-17 23:52:24,082 INFO: no action. i am the leader with the lock
Feb 17 23:52:33 cluster-pgsql-02.local patroni.py[4913]: 2017-02-17 23:52:33,458 INFO: Lock owner: cluster_pgsql_02; I am cluster_pgsql_02
Feb 17 23:52:33 cluster-pgsql-02.local patroni.py[4913]: 2017-02-17 23:52:33,884 INFO: Lock owner: cluster_pgsql_02; I am cluster_pgsql_02
Feb 17 23:52:34 cluster-pgsql-02.local patroni.py[4913]: 2017-02-17 23:52:34,094 INFO: no action. i am the leader with the lock

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

~# systemctl stop patroni

спойлер

Feb 17 23:54:03 cluster-pgsql-02.local patroni.py[4913]: 2017-02-17 23:54:03,457 INFO: Lock owner: cluster_pgsql_02; I am cluster_pgsql_02
Feb 17 23:54:03 cluster-pgsql-02.local patroni.py[4913]: 2017-02-17 23:54:03,880 INFO: Lock owner: cluster_pgsql_02; I am cluster_pgsql_02
Feb 17 23:54:04 cluster-pgsql-02.local patroni.py[4913]: 2017-02-17 23:54:04,092 INFO: no action. i am the leader with the lock
Feb 17 23:54:11 cluster-pgsql-02.local systemd[1]: Stopping Runners to orchestrate a high-availability PostgreSQL...
Feb 17 23:54:13 cluster-pgsql-02.local patroni.py[4913]: waiting for server to shut down.... done
Feb 17 23:54:13 cluster-pgsql-02.local patroni.py[4913]: server stopped

А вот что в этот момент произошло на слейве:

спойлер

Feb 17 19:54:12 cluster-pgsql-01 patroni.py: 2017-02-17 23:54:12,353 INFO: does not have lock
Feb 17 19:54:12 cluster-pgsql-01 patroni.py: 2017-02-17 23:54:12,776 INFO: no action. i am a secondary and i am following a leader
Feb 17 19:54:13 cluster-pgsql-01 patroni.py: 2017-02-17 23:54:13,440 WARNING: request failed: GET http://192.xx.xx.121:8008/patroni (HTTPConnectionPool(host='192.xx.xx.121', port=8008
): Max retries exceeded with url: /patroni (Caused by NewConnectionError('<requests.packages.urllib3.connection.HTTPConnection object at 0x1f12750>: Failed to establish a new connection: [Er
rno 111] Connection refused',)))
Feb 17 19:54:13 cluster-pgsql-01 patroni.py: 2017-02-17 23:54:13,444 INFO: Got response from cluster_pgsql_03 http://192.xx.xx.122:8008/patroni: {"database_system_identifier": "63847
30077944883705", "postmaster_start_time": "2017-02-17 05:36:52.388 MSK", "xlog": {"received_location": 34997272728, "replayed_timestamp": null, "paused": false, "replayed_location": 34997272
728}, "patroni": {"scope": "clusters-pgsql", "version": "1.2.3"}, "state": "running", "role": "replica", "server_version": 90601}
Feb 17 19:54:13 cluster-pgsql-01 patroni.py: server promoting
Feb 17 19:54:13 cluster-pgsql-01 patroni.py: 2017-02-17 23:54:13,961 INFO: cleared rewind flag after becoming the leader
Feb 17 19:54:14 cluster-pgsql-01 patroni.py: 2017-02-17 23:54:14,179 INFO: promoted self to leader by acquiring session lock
Feb 17 19:54:23 cluster-pgsql-01 patroni.py: 2017-02-17 23:54:23,436 INFO: Lock owner: cluster_pgsql_01; I am cluster_pgsql_01
Feb 17 19:54:23 cluster-pgsql-01 patroni.py: 2017-02-17 23:54:23,857 INFO: Lock owner: cluster_pgsql_01; I am cluster_pgsql_01
Feb 17 19:54:24 cluster-pgsql-01 patroni.py: 2017-02-17 23:54:24,485 INFO: no action. i am the leader with the lock

Этот сервер перехватил роль мастера на себя.

А теперь вернем сервер 2 обратно в кластер:

~# systemctl start patroni

Заголовок спойлера

Feb 18 00:02:11 cluster-pgsql-02.local systemd[1]: Started Runners to orchestrate a high-availability PostgreSQL.
Feb 18 00:02:11 cluster-pgsql-02.local systemd[1]: Starting Runners to orchestrate a high-availability PostgreSQL...
Feb 18 00:02:13 cluster-pgsql-02.local patroni.py[56855]: 2017-02-18 00:02:13,186 INFO: Lock owner: cluster_pgsql_01; I am cluster_pgsql_02
Feb 18 00:02:13 cluster-pgsql-02.local patroni.py[56855]: 2017-02-18 00:02:13,190 WARNING: Postgresql is not running.
Feb 18 00:02:13 cluster-pgsql-02.local patroni.py[56855]: 2017-02-18 00:02:13,190 INFO: Lock owner: cluster_pgsql_01; I am cluster_pgsql_02
Feb 18 00:02:13 cluster-pgsql-02.local patroni.py[56855]: 2017-02-18 00:02:13,398 INFO: Lock owner: cluster_pgsql_01; I am cluster_pgsql_02
Feb 18 00:02:13 cluster-pgsql-02.local patroni.py[56855]: 2017-02-18 00:02:13,400 INFO: starting as a secondary
Feb 18 00:02:13 cluster-pgsql-02.local patroni.py[56855]: 2017-02-18 00:02:13,412 INFO: rewind flag is set
Feb 18 00:02:13 cluster-pgsql-02.local patroni.py[56855]: 2017-02-18 00:02:13,609 INFO: Lock owner: cluster_pgsql_01; I am cluster_pgsql_02
Feb 18 00:02:13 cluster-pgsql-02.local patroni.py[56855]: 2017-02-18 00:02:13,609 INFO: Lock owner: cluster_pgsql_01; I am cluster_pgsql_02
Feb 18 00:02:13 cluster-pgsql-02.local patroni.py[56855]: 2017-02-18 00:02:13,609 INFO: changing primary_conninfo and restarting in progress
Feb 18 00:02:13 cluster-pgsql-02.local patroni.py[56855]: 2017-02-18 00:02:13,631 INFO: running pg_rewind from user=superuser host=192.xx.xx.120 port=5432 dbname=postgres sslmode=prefer sslcompression=1
Feb 18 00:02:13 cluster-pgsql-02.local patroni.py[56855]: servers diverged at WAL position 8/26000098 on timeline 25
Feb 18 00:02:13 cluster-pgsql-02.local patroni.py[56855]: rewinding from last common checkpoint at 8/26000028 on timeline 25
Feb 18 00:02:14 cluster-pgsql-02.local patroni.py[56855]: Done!
Feb 18 00:02:14 cluster-pgsql-02.local patroni.py[56855]: 2017-02-18 00:02:14,535 INFO: postmaster pid=56893
Feb 18 00:02:14 cluster-pgsql-02.local patroni.py[56855]: < 2017-02-18 00:02:14.554 MSK > LOG: redirecting log output to logging collector process
Feb 18 00:02:14 cluster-pgsql-02.local patroni.py[56855]: < 2017-02-18 00:02:14.554 MSK > HINT: Future log output will appear in directory "pg_log".
Feb 18 00:02:15 cluster-pgsql-02.local patroni.py[56855]: localhost:5432 - accepting connections
Feb 18 00:02:15 cluster-pgsql-02.local patroni.py[56855]: localhost:5432 - accepting connections
Feb 18 00:02:15 cluster-pgsql-02.local patroni.py[56855]: 2017-02-18 00:02:15,790 INFO: Lock owner: cluster_pgsql_01; I am cluster_pgsql_02
Feb 18 00:02:15 cluster-pgsql-02.local patroni.py[56855]: 2017-02-18 00:02:15,791 INFO: Lock owner: cluster_pgsql_01; I am cluster_pgsql_02
Feb 18 00:02:15 cluster-pgsql-02.local patroni.py[56855]: 2017-02-18 00:02:15,791 INFO: does not have lock
Feb 18 00:02:15 cluster-pgsql-02.local patroni.py[56855]: 2017-02-18 00:02:15,791 INFO: establishing a new patroni connection to the postgres cluster
Feb 18 00:02:16 cluster-pgsql-02.local patroni.py[56855]: 2017-02-18 00:02:16,014 INFO: no action. i am a secondary and i am following a leader

Patroni обнаружила что подключается к кластеру с имеющимся мастером, и обновив базу до текущего состояния, корректно приняла на себя роль слейва.

Попробуем создать ошибку на другом слое кластера, остановив haproxy на основном сервере keepalived.
По приоритету, эту роль у меня принимает второй сервер:

[root@cluster-pgsql-02 ~]# ip a
2: ens160: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP qlen 1000
link/ether 00:50:56:a9:b8:7b brd ff:ff:ff:ff:ff:ff
inet 192.xx.xx.121/24 brd 192.168.142.255 scope global ens160
valid_lft forever preferred_lft forever
inet 192.xx.xx.125/32 scope global ens160 <---- виртуальный адрес кластера
valid_lft forever preferred_lft forever
inet6 fe80::xxx::4895:6d90/64 scope link
valid_lft forever preferred_lft forever

Остановим haproxy:
~# systemctl stop haproxy; journalctl -fl

Feb 18 00:18:54 cluster-pgsql-02.local Keepalived_vrrp[25018]: VRRP_Script(chk_haproxy) failed
Feb 18 00:18:56 cluster-pgsql-02.local Keepalived_vrrp[25018]: VRRP_Instance(cluster_pgsql_02) Received higher prio advert
Feb 18 00:18:56 cluster-pgsql-02.local Keepalived_vrrp[25018]: VRRP_Instance(cluster_pgsql_02) Entering BACKUP STATE
Feb 18 00:18:56 cluster-pgsql-02.local Keepalived_vrrp[25018]: VRRP_Instance(cluster_pgsql_02) removing protocol VIPs.
Feb 18 00:18:56 cluster-pgsql-02.local Keepalived_vrrp[25018]: Opening script file /usr/bin/sh
Feb 18 00:18:56 cluster-pgsql-02.local Keepalived_healthcheckers[25017]: Netlink reflector reports IP 192.xx.xx.125 removed

Keepalived отловил проблему, и убрал с себя виртуальный адрес, а также просигналил об этом соседям.

Смотрим что произошло на втором сервере:

Feb 18 00:18:56 cluster-pgsql-01.local Keepalived_vrrp[41190]: VRRP_Instance(cluster_pgsql_01) forcing a new MASTER election
Feb 18 00:18:56 cluster-pgsql-01.local Keepalived_vrrp[41190]: VRRP_Instance(cluster_pgsql_01) forcing a new MASTER election
Feb 18 00:18:56 cluster-pgsql-01.local Keepalived_vrrp[41190]: VRRP_Instance(cluster_pgsql_01) forcing a new MASTER election
Feb 18 00:18:56 cluster-pgsql-01.local Keepalived_vrrp[41190]: VRRP_Instance(cluster_pgsql_01) forcing a new MASTER election
Feb 18 00:18:57 cluster-pgsql-01.local Keepalived_vrrp[41190]: VRRP_Instance(cluster_pgsql_01) Transition to MASTER STATE
Feb 18 00:18:58 cluster-pgsql-01.local Keepalived_vrrp[41190]: VRRP_Instance(cluster_pgsql_01) Entering MASTER STATE
Feb 18 00:18:58 cluster-pgsql-01.local Keepalived_vrrp[41190]: VRRP_Instance(cluster_pgsql_01) setting protocol VIPs.
Feb 18 00:18:58 cluster-pgsql-01.local Keepalived_vrrp[41190]: VRRP_Instance(cluster_pgsql_01) Sending gratuitous ARPs on ens160 for 192.xx.xx.125
Feb 18 00:18:58 cluster-pgsql-01.local Keepalived_vrrp[41190]: Opening script file /usr/bin/sh
Feb 18 00:18:58 cluster-pgsql-01.local Keepalived_vrrp[41190]: VRRP_Instance(cluster_pgsql_01) Received lower prio advert, forcing new election
Feb 18 00:18:58 cluster-pgsql-01.local Keepalived_vrrp[41190]: VRRP_Instance(cluster_pgsql_01) Sending gratuitous ARPs on ens160 for 192.xx.xx.125
Feb 18 00:18:58 cluster-pgsql-01.local Keepalived_healthcheckers[41189]: Netlink reflector reports IP 192.xx.xx.125 added
Feb 18 00:18:58 cluster-pgsql-01.local Keepalived_vrrp[41190]: VRRP_Instance(cluster_pgsql_01) Received lower prio advert, forcing new election
Feb 18 00:18:58 cluster-pgsql-01.local Keepalived_vrrp[41190]: VRRP_Instance(cluster_pgsql_01) Sending gratuitous ARPs on ens160 for 192.xx.xx.125
Feb 18 00:19:03 cluster-pgsql-01.local Keepalived_vrrp[41190]: VRRP_Instance(cluster_pgsql_01) Sending gratuitous ARPs on ens160 for 192.xx.xx.125

Дважды произошли перевыборы (потому что третий сервер кластера успел отправить свой анонс до первых выборов), сервер 1 принял на себя роль ведущего, и выставил виртуальный IP.

Убеждаемся в этом:

[root@cluster-pgsql-01 log]# ip a
2: ens160: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc mq state UP qlen 1000
link/ether 00:50:56:a9:f0:90 brd ff:ff:ff:ff:ff:ff
inet 192.xx.xx.120/24 brd 192.xx.xx.255 scope global ens160
valid_lft forever preferred_lft forever
inet 192.xx.xx.125/32 scope global ens160 <---- виртуальный адрес кластера присутствует!
valid_lft forever preferred_lft forever
inet6 fe80::1d75:40f6:a14e:5e27/64 scope link
valid_lft forever preferred_lft forever

Теперь виртуальный IP присутствует на сервере, не являющимся мастером репликации. Однако это не имеет значения, поскольку в базу мы обращаемся через haproxy, а она мониторит состояние кластера независимо, и отправляет запросы всегда на мастер.

При возврате в строй haproxy на втором сервере снова происходят перевыборы (keepalived с бОльшим приоритетом встает в строй), и виртуальный IP возвращается на свое место.

В редких случаях бывает что слейв не может догнаться до мастера (например он упал очень давно и wal журнал успел частично удалиться). В таком случае можно полностью очистить директорию с базой на слейве:
«rm -rf /var/lib/pgsql/9.6/data», и перезапустить Patroni. Она сольет базу с мастера целиком.
(Осторожно с очисткой «ненужных» баз, внимательно смотрите на каком сервере вы выполняете команду!!!)

Послесловие

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

В конфигурации с асинхронной репликацией, раскладывание нагрузки на чтение может привести к непредвиденным ответам, если слейв отстанет от мастера, это нужно учитывать.
Стриминговая (асинхронная) репликация не обеспечивает консистентности кластера в любой момент времени, и для этого нужна синхронная репликация.
В этом режиме мастер сервер будет ждать получения подтверждений о копировании и применении транзакций на слейвы, что замедлит работу базы. Однако если потери транзакций недопустимы (например какие-то финансовые приложения), синхронная репликация это ваш выбор.
Patroni поддерживает все варианты, и если синхронная репликация вам подойдет больше, вам всего лишь понадобится изменить значение нескольких полей в конфигах Patroni.
Вопросы разных методов репликации прекрасно разобраны в документации к Patroni.

Кто-то наверное предложит использовать pgpool который сам, по сути, покрывает весь функционал этой системы. Он может и мониторить базы, и проксировать запросы, и выставлять виртуальный IP, а также осуществляет пулинг коннектов клиентов.
Да, он все это может. Но на мой взгляд схема с Patroni гораздо прозрачнее (конечно это только мое мнение), и во время экспериментов с pgpool я ловил странное поведение с его вочдогом и виртуальными адресами, которое не стал пока слишком глубоко дебажить, решив поискать другое решение.
Конечно возможно, что проблема тут только моих в руках, и позже я к тестированию pgpool планирую вернуться.
Однако, в любом случае, pgpool не сможет полностью автоматически управлять кластером, вводом новых и (особенно) возвратом сбойных серверов, работать с DCS.
На мой взгляд это самый интересный функционал Patroni.

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

Огромное спасибо Zalando за Patroni, и авторам исходного проекта Governor, который послужил основой для Patroni, а также Алексу Чистякову за шаблон роли для Ansible.

Полный код плейбуков и шаблонов Ansible, описанных в статье лежит тут.
Буду благодарен за доработки от гуру Ansible и PostgreSQL. :)

Основные использованные статьи и источники:
Несколько вариантов кластеров PgSQL:
https://habrahabr.ru/post/301370/
https://habrahabr.ru/post/213409/
https://habrahabr.ru/company/etagi/blog/314000/

Пост о Patroni в блоге Zalando
Проект Patroni
ansible-role-patroni Алекса Чистякова
Governor — к сожалению разработка давно заморожена.

Автор: citius

Источник

Поделиться

* - обязательные к заполнению поля