Deploy PostgreSQL HA (Patroni, etcd, HAproxy) on Ubuntu servers using Ansible

Published on Author admin

Deploy PostgreSQL HA (Patroni, etcd, HAproxy) on Ubuntu servers using Ansible

Architecture

Verify commands

#etcd
etcdctl --endpoints=http://10.10.10.111:2379,http://10.10.10.112:2379,http://10.10.10.113:2379 member list
etcdctl --endpoints=http://10.10.10.111:2379,http://10.10.10.112:2379,http://10.10.10.113:2379 endpoint health
etcdctl --endpoints=http://10.10.10.111:2379,http://10.10.10.112:2379,http://10.10.10.113:2379 endpoint status

#patroni
patronictl -c /etc/patroni/patroni.yml topology
ls /var/lib/postgresql/18/main -l
/usr/lib/postgresql/18/bin/pg_controldata /var/lib/postgresql/18/main

#haproxy
watch 'echo "show stat" | nc -U /var/lib/haproxy/stats | cut -d "," -f 1,2,5-11,18,24,27,30,36,50,37,56,57,62 | column -s, -t'

#postgresql
export PGPASSWORD=postgres
#primary RW
psql -h 10.10.10.120 -p 5432 -U postgres -c "SELECT inet_server_addr(), inet_server_port(), pg_is_in_recovery();"
#replicas RO
for i in {1..5}; do
psql -h 10.10.10.120 -p 5433 -U postgres -c "SELECT inet_server_addr(), inet_server_port(), pg_is_in_recovery();"
done

Ansible inventory file:

[patroni]
pg1 ansible_host=10.10.10.101 node_name=pg1 ansible_user=root ansible_ssh_private_key_file=~/.ssh/id_rsa
pg2 ansible_host=10.10.10.102 node_name=pg2 ansible_user=root ansible_ssh_private_key_file=~/.ssh/id_rsa
pg3 ansible_host=10.10.10.103 node_name=pg3 ansible_user=root ansible_ssh_private_key_file=~/.ssh/id_rsa

[etcd]
etcd1 ansible_host=10.10.10.111 node_name=etcd1 ansible_user=root ansible_ssh_private_key_file=~/.ssh/id_rsa
etcd2 ansible_host=10.10.10.112 node_name=etcd2 ansible_user=root ansible_ssh_private_key_file=~/.ssh/id_rsa
etcd3 ansible_host=10.10.10.113 node_name=etcd3 ansible_user=root ansible_ssh_private_key_file=~/.ssh/id_rsa

[haproxy]
#vip 10.10.120
haproxy1 ansible_host=10.10.10.121 node_name=haproxy1 ansible_user=root ansible_ssh_private_key_file=~/.ssh/id_rsa
haproxy2 ansible_host=10.10.10.122 node_name=haproxy2 ansible_user=root ansible_ssh_private_key_file=~/.ssh/id_rsa

Ansible playbook:

---
- hosts: all
  become: yes

  tasks:
    ##########################################################################
    # TOOLS INSTALL
    ##########################################################################
    - name: Install netstat
      apt:
        name:
          - net-tools
          - tshark
          - termshark
        state: present
        update_cache: yes


- hosts: etcd
  become: yes
  vars:
    etcd_version: "v3.6.6"

  tasks:

    ##########################################################################
    # ETCD INSTALL
    ##########################################################################
    - name: Download etcd release
      get_url:
        url: "https://github.com/etcd-io/etcd/releases/download/{{ etcd_version }}/etcd-{{ etcd_version }}-linux-amd64.tar.gz"
        dest: /tmp/etcd.tar.gz
        mode: "0644"

    - name: Extract etcd tarball
      unarchive:
        src: /tmp/etcd.tar.gz
        dest: /usr/local/
        remote_src: yes

    - name: Install etcd and etcdctl
      copy:
        src: "/usr/local/etcd-{{ etcd_version }}-linux-amd64/{{ item }}"
        dest: "/usr/local/bin/{{ item }}"
        mode: "0755"
        remote_src: yes
      loop:
        - etcd
        - etcdctl

    - name: Ensure etcd directories
      file:
        path: "{{ item }}"
        state: directory
      loop:
        - /var/lib/etcd
        - /etc/etcd

    - name: Create etcd systemd unit with v3 enabled
      copy:
        dest: /etc/systemd/system/etcd.service
        content: |
          [Unit]
          Description=etcd key-value store
          After=network-online.target
          Wants=network-online.target

          [Service]
          Environment=ETCDCTL_API=3
          ExecStart=/usr/local/bin/etcd \
            --name {{ inventory_hostname }} \
            --data-dir /var/lib/etcd \
            --listen-client-urls http://{{ ansible_host }}:2379 \
            --advertise-client-urls http://{{ ansible_host }}:2379 \
            --listen-peer-urls http://{{ ansible_host }}:2380 \
            --initial-advertise-peer-urls http://{{ ansible_host }}:2380 \
            --initial-cluster-token patroni-cluster \
            --initial-cluster "{% for h in groups['etcd'] %}{{ hostvars[h].inventory_hostname }}=http://{{ hostvars[h].ansible_host }}:2380{% if not loop.last %},{% endif %}{% endfor %}" \
            --initial-cluster-state new \
            #--enable-v2=true

          Restart=always
          RestartSec=5

          [Install]
          WantedBy=multi-user.target

    ##########################################################################
    # ENSURE ETCD AND PATRONI ARE RUNNING
    ##########################################################################
    - name: Ensure etcd is running and enabled
      systemd:
        name: etcd
        state: started
        enabled: yes
        daemon_reload: yes


- hosts: patroni
  become: yes
  vars:
    patroni_scope: "patroni_cluster"
    patroni_pg_version: 18
    patroni_data_dir: "/var/lib/postgresql/{{ patroni_pg_version }}/main"
    bootstrap_node: "{{ groups['patroni'][0] }}"  # first node bootstraps PostgreSQL

  tasks:

    ##########################################################################
    # ADD POSTGRESQL REPO
    ##########################################################################
    - name: Add PostgreSQL APT repository key
      ansible.builtin.apt_key:
        url: https://www.postgresql.org/media/keys/ACCC4CF8.asc
        state: present

    - name: Add PostgreSQL APT repository
      ansible.builtin.apt_repository:
        repo: "deb http://apt.postgresql.org/pub/repos/apt {{ ansible_lsb.codename }}-pgdg main"
        state: present
        filename: "pgdg"

    ##########################################################################
    # POSTGRESQL INSTALL
    ##########################################################################
    - name: Install PostgreSQL
      apt:
        name:
          - postgresql-{{ patroni_pg_version }}
          - postgresql-client-{{ patroni_pg_version }}
          - python3-psycopg2
        state: present
        update_cache: yes

    - name: Stop default PostgreSQL service
      service:
        name: postgresql
        state: stopped
        enabled: no

    ##########################################################################
    # DATA DIRECTORY MANAGEMENT
    ##########################################################################
    - name: Check if Patroni cluster is already running
      command: patronictl -c /etc/patroni/patroni.yml list
      register: patroni_status
      ignore_errors: yes
      changed_when: false

    - name: Set flag if cluster is bootstrapped
      set_fact:
        cluster_bootstrapped: "{{ patroni_status.rc == 0 and 'Leader' in patroni_status.stdout }}"

    - name: Ensure data directory exists (only if cluster not bootstrapped)
      file:
        path: "{{ patroni_data_dir }}"
        state: directory
        owner: postgres
        group: postgres
        mode: "0700"
      when: not cluster_bootstrapped

    - name: Remove existing data directory (only if cluster not bootstrapped)
      file:
        path: "{{ patroni_data_dir }}"
        state: absent
      when: not cluster_bootstrapped

    - name: Recreate empty data directory (only if cluster not bootstrapped)
      file:
        path: "{{ patroni_data_dir }}"
        state: directory
        owner: postgres
        group: postgres
        mode: "0700"
      when: not cluster_bootstrapped

    ##########################################################################
    # PATRONI INSTALL
    ##########################################################################
    - name: Install Patroni
      apt:
        name: patroni
        state: present

    - name: Create Patroni config directory
      file:
        path: /etc/patroni
        state: directory

    - name: Write Patroni config
      copy:
        dest: /etc/patroni/patroni.yml
        mode: 0644
        content: |
          scope: {{ patroni_scope }}
          name: {{ inventory_hostname }}

          etcd3:
            hosts:
              {% for h in groups['etcd'] %}
              - {{ hostvars[h].ansible_host }}:2379
              {% endfor %}

          restapi:
            listen: {{ ansible_host }}:8008
            connect_address: {{ ansible_host }}:8008

          postgresql:
            listen: {{ ansible_host }}:5432
            connect_address: {{ ansible_host }}:5432
            data_dir: {{ patroni_data_dir }}
            bin_dir: /usr/lib/postgresql/{{ patroni_pg_version }}/bin
            authentication:
              superuser:
                username: postgres
                password: postgres
              replication:
                username: replicator
                password: replicator
            parameters:
              wal_keep_size: 128MB
              max_wal_senders: 10
              hot_standby: "on"
              shared_buffers: "512MB"
            users:
              replicator:
                password: replicator
                options: [replication]

          {% if inventory_hostname == bootstrap_node %}
          bootstrap:
            dcs:
              ttl: 30
              loop_wait: 10
              retry_timeout: 10
              maximum_lag_on_failover: 1048576
              postgresql:
                use_pg_rewind: true
                parameters:
                  wal_level: replica
                  hot_standby: "on"

            initdb:
              - encoding: UTF8
              - locale: en_US.UTF-8
              - auth-host: md5

            # pg_hba entries including replication and superuser
            pg_hba:
              - host all all 0.0.0.0/0 md5
              - host replication replicator 0.0.0.0/0 md5
              - host all postgres 0.0.0.0/0 md5

            {% else %}
            # no bootstrap section for followers
            {% endif %}

    ##########################################################################
    # PATRONI SYSTEMD SERVICE
    ##########################################################################
    - name: Create Patroni systemd service
      copy:
        dest: /etc/systemd/system/patroni.service
        content: |
          [Unit]
          Description=Patroni HA PostgreSQL
          After=network.target

          [Service]
          User=postgres
          ExecStart=/usr/bin/patroni /etc/patroni/patroni.yml
          Restart=always

          [Install]
          WantedBy=multi-user.target

    ##########################################################################
    # ENSURE ETCD AND PATRONI ARE RUNNING
    ##########################################################################

    #- name: Wait for...
    #  pause:
    #    prompt: "Check logs or Patroni status, then press Enter to continue"

    - name: Ensure Patroni is running and enabled
      systemd:
        name: patroni
        state: started
        enabled: yes
        daemon_reload: yes



- hosts: haproxy
  become: yes
  vars:
    patroni_nodes: "{{ groups['patroni'] }}"
    haproxy_port_rw: 5432
    haproxy_port_ro: 5433
    vip_address: 10.10.10.120
    vip_interface: enp1s0

    keepalived_state: "{{ 'MASTER' if inventory_hostname == groups['haproxy'][0] else 'BACKUP' }}"
    keepalived_priority: "{{ 150 if inventory_hostname == groups['haproxy'][0] else 100 }}"

  tasks:

    ##########################################################################
    # INSTALL PACKAGES
    ##########################################################################
    - name: Install HAProxy and Keepalived
      apt:
        name:
          - haproxy
          - keepalived
        state: present
        update_cache: yes

    ##########################################################################
    # HAPROXY CONFIG
    ##########################################################################
    - name: Configure HAProxy for Patroni
      copy:
        dest: /etc/haproxy/haproxy.cfg
        mode: 0644
        content: |
          global
            log /dev/log local0
            maxconn 2048

            stats socket /var/lib/haproxy/stats mode 660 level admin
            stats timeout 30s

          defaults
            log global
            timeout connect 5s
            timeout client  30s
            timeout server  30s

          frontend pg_rw
            bind *:{{ haproxy_port_rw }}
            default_backend pg_rw_backend

          backend pg_rw_backend
            option httpchk GET /master
            http-check expect status 200
          {% for h in patroni_nodes %}
            server {{ h }} {{ hostvars[h].ansible_host }}:5432 check port 8008
          {% endfor %}

          frontend pg_ro
            bind *:{{ haproxy_port_ro }}
            default_backend pg_ro_backend

          backend pg_ro_backend
            option httpchk GET /replica
            http-check expect status 200
          {% for h in patroni_nodes %}
            server {{ h }} {{ hostvars[h].ansible_host }}:5432 check port 8008
          {% endfor %}

    - name: Enable and restart HAProxy
      systemd:
        name: haproxy
        state: restarted
        enabled: yes

    ##########################################################################
    # KEEPALIVED CONFIG
    ##########################################################################
    - name: Configure Keepalived
      copy:
        dest: /etc/keepalived/keepalived.conf
        mode: 0644
        content: |
          vrrp_instance VI_1 {
            state {{ keepalived_state }}
            interface {{ vip_interface }}
            virtual_router_id 51
            priority {{ keepalived_priority }}
            advert_int 1

            authentication {
              auth_type PASS
              auth_pass patroni_vip
            }

            virtual_ipaddress {
              {{ vip_address }}
            }
          }

    - name: Enable and restart Keepalived
      systemd:
        name: keepalived
        state: restarted
        enabled: yes