Automation is key today, nobody wants to do the same tasks over and over and again. Cloud without automation is not even possible. There are several tools around that help with automation and one of the most popular is Ansible. We already have several posts about Ansible on our blog platform but this one will be rather long. Setting up PostgreSQL high available architectures is our daily business and we as well try to automate as much as possible. We do not only automate to save time, even more important we automate to avoid human errors. What we will share with this post is how you could use Ansible to bring up a three node Patroni cluster from scratch.

Disclaimer: Please see what we show here as a kind of template. You might need to adjust several bits to fit into your environment, other bits for sure can be solved more elegant by using advanced features of Ansible. Anyway, using this template you should be able to bring up one PostgreSQL master instance, two replicas, Patroni and HAProxy in minutes on CentOS 7. This should work the same for Red Hat 7 but if you want to do the same on Debian based systems or SUSE you for sure need to adjust some of the Ansible tasks. This post does not explain how Ansible works nor does it explain what Patroni or HAProxy is.

The starting point is a CentOS minimal installation with just the postgres user and group created and sudo permissions for postgres. That’s it:

postgres@patroni1 ~]$ id -a
uid=1000(postgres) gid=1000(postgres) groups=1000(postgres) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023
[postgres@patroni1 ~]$ lsb_release -a
LSB Version:	:core-4.1-amd64:core-4.1-noarch:cxx-4.1-amd64:cxx-4.1-noarch:desktop-4.1-amd64:desktop-4.1-noarch:languages-4.1-amd64:languages-4.1-noarch:printing-4.1-amd64:printing-4.1-noarch
Distributor ID:	CentOS
Description:	CentOS Linux release 7.5.1804 (Core) 
Release:	7.5.1804
Codename:	Core
[postgres@patroni1 ~]$ sudo cat /etc/sudoers | grep postgres
postgres	ALL=(ALL)	NOPASSWD: ALL
[postgres@patroni1 ~]$ 

This is the Ansible directory structure on my workstation:

dwe@dwe:~/Documents/dbiProjects/dbi_dmk_postgres/dmk/ansible$ tree
.
├── _commands.sh
├── _init_dirs.sh
├── patroni
└── roles
    ├── common
    │   ├── files
    │   │   └── PostgreSQL-DMK-17-09.4.zip
    │   ├── handlers
    │   ├── meta
    │   ├── tasks
    │   │   └── main.yml
    │   ├── templates
    │   │   └── compile.sh.j2
    │   └── vars
    └── patroni
        ├── files
        │   ├── etcd.service
        │   └── patroni.service
        ├── handlers
        ├── meta
        ├── site.retry
        ├── site.yml
        ├── tasks
        │   └── main.yml
        ├── templates
        │   ├── etcd.conf.j2
        │   ├── haproxy.cfg.j2
        │   ├── hosts.j2
        │   ├── keepalived.conf.j2
        │   └── patroni.yml.j2
        └── vars

You can use the _init_dirs.sh script to create that but it is pretty much the Ansible default anyway:

dwe@dwe:~/Documents/dbiProjects/dbi_dmk_postgres/dmk/ansible$ cat _init_dirs.sh 
#!/bin/bash
touch patroni
mkdir roles/
mkdir roles/common
mkdir roles/common/tasks
mkdir roles/common/handlers
mkdir roles/common/templates
mkdir roles/common/files
mkdir roles/common/vars
mkdir roles/common/meta
mkdir roles/patroni
mkdir roles/patroni/tasks
mkdir roles/patroni/handlers
mkdir roles/patroni/templates
mkdir roles/patroni/files
mkdir roles/patroni/vars
mkdir roles/patroni/meta

What you always need with Ansible is the inventory and in our case it looks like this:

dwe@dwe:~/Documents/dbiProjects/dbi_dmk_postgres/dmk/ansible$ cat patroni 
[patroni-servers]
192.168.22.240 keepalived_role=MASTER keepalived_priority=102 ansible_hostname=patroni1 ansible_hostname_fqdn=patroni1.it.dbi-services.com
192.168.22.241 keepalived_role=SLAVE keepalived_priority=101 ansible_hostname=patroni2 ansible_hostname_fqdn=patroni2.it.dbi-services.com
192.168.22.242 keepalived_role=SLAVE keepalived_priority=100 ansible_hostname=patroni3 ansible_hostname_fqdn=patroni3.it.dbi-services.com

[patroni-servers:vars]
postgresql_version=11.1
postgresql_major_version=11
dmk_postgresql_version=11/db_1
etcd_vserion=3.3.10
postgres_user=postgres
postgres_group=postgres
dmk_version=17-09.4
cluster_name=PG1
blank=' '
virtual_ip=192.168.22.245

As you can see there are three machines and several variables defined. The *dmk* stuff if for our management kit, just ignore/delete that for your environment.

We have two roles, one common and one for Patroni. The common role is responsible for doing the common stuff and can be used for single instance PostgreSQL deployments as well so lets start with this one:

dwe@dwe:~/Documents/dbiProjects/dbi_dmk_postgres/dmk/ansible$ cat roles/common/tasks/main.yml 
- name: Install all dependencies for PostgreSQL 
  yum: name={{item}} state=present
  with_items:
   - gcc
   - openldap-devel
   - python-devel
   - readline-devel
   - redhat-lsb
   - bison
   - flex
   - perl-ExtUtils-Embed
   - zlib-devel
   - crypto-utils
   - openssl-devel
   - pam-devel
   - libxml2-devel
   - libxslt-devel
   - openssh-clients
   - bzip2
   - net-tools
   - wget
   - screen
   - unzip
   - sysstat
   - xorg-x11-xauth
   - systemd-devel
   - bash-completion

- name: Remove iwl packages
  yum: name={{item}} state=removed
  with_items:
   - iwl*

- name: upgrade all packages
  yum:
    name: '*'
    state: latest

- file:
    path: /u01/app/{{ postgres_user }}/local
    state: directory
    mode: 0700
    recurse: yes
    owner: "{{ postgres_user }}"
    group: "{{ postgres_group }}"

- file:
    path: /u01
    owner: "{{ postgres_user }}"
    group: "{{ postgres_user }}"
    mode: 0700

- file:
    path: /u01/app
    owner: "{{ postgres_user }}"
    group: "{{ postgres_group }}"
    mode: 0700

- file:
    path: /u01/app/{{ postgres_user }}
    owner: "{{ postgres_user }}"
    group: "{{ postgres_group }}"
    mode: 0700

- file:
    path: /u02/pgdata/
    state: directory
    mode: 0700
    owner: "{{ postgres_user }}"
    group: "{{ postgres_group }}"

- file:
    path: /u02/pgdata/{{ postgresql_major_version }}
    state: directory
    mode: 0700
    owner: "{{ postgres_user }}"
    group: "{{ postgres_group }}"

- file:
    path: /u02/pgdata/{{ postgresql_major_version }}/{{ cluster_name }}
    state: directory
    mode: 0700
    owner: "{{ postgres_user }}"
    group: "{{ postgres_group }}"

- file:
    path: /u99/pgdata/
    state: directory
    mode: 0700
    owner: "{{ postgres_user }}"
    group: "{{ postgres_group }}"

- file:
    path: /etc/pgtab
    state: touch
    owner: "{{ postgres_user }}"
    group: "{{ postgres_group }}"
    mode: 0600

- name: check if PostgreSQL source code exists
  stat: 
    path: /home/{{ postgres_user }}/source.tar.bz2
  register: source_available

- name: Download the PostgreSQL source code if it is not already there
  get_url:
    url: https://ftp.postgresql.org/pub/source/v{{ postgresql_version }}/postgresql-{{ postgresql_version }}.tar.bz2
    dest: /home/{{ postgres_user }}/source.tar.bz2
    mode: 0775
  when: source_available.stat.exists == false

- name: Check if PostgreSQL is already installed
  stat:
    path: /u01/app/{{ postgres_user }}/product/{{ dmk_postgresql_version }}/bin/postgres
  register: postrgresql_is_installed

- name: extract the sources when PostgreSQL is not already installed
  shell: cd /home/{{ postgres_user }}; tar -axf source.tar.bz2
  become: yes
  become_user: "{{ postgres_user }}"
  when: postrgresql_is_installed.stat.exists == false

- template:
    src: compile.sh.j2
    dest: /home/{{ postgres_user }}/postgresql-{{ postgresql_version }}/compile.sh
    owner: "{{ postgres_user }}"
    group: "{{ postgres_group }}"
    mode: 0700

- name: Install PostgreSQL from source code
  shell: cd /home/{{ postgres_user }}/postgresql-{{ postgresql_version }}; ./compile.sh
  become: yes
  become_user: "{{ postgres_user }}"
  when: postrgresql_is_installed.stat.exists == false

- name: check if DMK for PostgreSQL source code exists
  stat:
    path: /u01/app/{{ postgres_user }}/local/PostgreSQL-DMK-{{ dmk_version }}.zip
  register: dmk_source_available

- name: check if DMK for PostgreSQL is extracted
  stat:
    path: /u01/app/{{ postgres_user }}/local/dmk/bin/dmk.bash
  register: dmk_extracted

- name: Copy DMK source distribution
  copy:
    src: PostgreSQL-DMK-{{ dmk_version }}.zip
    dest: /u01/app/{{ postgres_user }}/local/PostgreSQL-DMK-{{ dmk_version }}.zip
    owner: "{{ postgres_user }}"
    group: "{{ postgres_group }}"
    mode: 0700
  when: dmk_source_available.stat.exists == false

- name: extract DMK
  shell: cd /u01/app/{{ postgres_user }}/local; unzip PostgreSQL-DMK-{{ dmk_version }}.zip
  become: yes
  become_user: "{{ postgres_user }}"
  when: dmk_extracted.stat.exists == false

- name: check if DMK is installed
  stat:
    path: /home/{{ postgres_user }}/.DMK_HOME
  register: dmk_installed

- lineinfile:
    path: /etc/pgtab
    line: 'pg{{ postgresql_version }}:/u01/app/{{ postgres_user }}/product/{{ dmk_postgresql_version }}:dummy:9999:D'
    create: no
  when: dmk_installed.stat.exists == false

- name: Execute DMK for the first time
  shell: /u01/app/{{ postgres_user }}/local/dmk/bin/dmk.bash; cat /u01/app/{{ postgres_user }}/local/dmk/templates/profile/dmk.postgres.profile >> /home/{{ postgres_user }}/.bash_profile
  become: yes
  become_user: "{{ postgres_user }}"
  when: dmk_installed.stat.exists == false

This should be more or less self explaining so we will only summarize what it does:

  • Install required packages for compiling PostgreSQL from source
  • Remove the iwl* packages
  • Update all packages to the latest release
  • Create the directory structure
  • Download the PostgreSQL source code, compile and install
  • Install our DMK

As said, this role can be included in any other PostgreSQL setup as it only does basic stuff. There is one template used here, which is compile.sh.j2:

dwe@dwe:~/Documents/dbiProjects/dbi_dmk_postgres/dmk/ansible$ cat roles/common/templates/compile.sh.j2 
PGHOME=/u01/app/postgres/product/{{ dmk_postgresql_version }}
SEGSIZE=2
BLOCKSIZE=8

./configure --prefix=${PGHOME} \
            --exec-prefix=${PGHOME} \
            --bindir=${PGHOME}/bin \
            --libdir=${PGHOME}/lib \
            --sysconfdir=${PGHOME}/etc \
            --includedir=${PGHOME}/include \
            --datarootdir=${PGHOME}/share \
            --datadir=${PGHOME}/share \
            --with-pgport=5432 \
            --with-perl \
            --with-python \
            --with-openssl \
            --with-pam \
            --with-ldap \
            --with-libxml \
            --with-libxslt \
            --with-segsize=${SEGSIZE} \
            --with-blocksize=${BLOCKSIZE} \
	    --with-systemd "
make -j 2 all
make install
cd contrib
make -j 2 install

This one is our standard way of bringing PostgreSQL onto the system and the only parameter is the PostgreSQL version we use for the directory name. No magic, simple stuff and that’s it for the common role.

Coming to the Patroni role. Here is it:

dwe@dwe:~/Documents/dbiProjects/dbi_dmk_postgres/dmk/ansible$ cat roles/patroni/tasks/main.yml 
---

- name: check if epel rpm already is there
  stat:
    path: /root/epel-release-latest-7.noarch.rpm
  register: epel_rpm_available

- name: Download the EPEL rpm
  get_url:
    url: http://dl.fedoraproject.org/pub/epel/epel-release-latest-7.noarch.rpm
    dest: /root/epel-release-latest-7.noarch.rpm
    mode: 0440
  when: epel_rpm_available.stat.exists == false

- name: check if epel repository is already installed
  stat:
    path: /etc/yum.repos.d/epel.repo
  register: epel_installed


- name: Install the EPEL rpm
  shell: yum localinstall -y /root/epel-release-latest-7.noarch.rpm
  args: 
    warn: false
  when: epel_installed.stat.exists == false

- name: Install all dependencies for Patroni
  yum: name={{item}} state=present
  with_items:
   - python-pip
   - PyYAML
   - bind-utils
   - keepalived
   - haproxy

# create the hosts file
- template:
    src: hosts.j2
    dest: /etc/hosts
    owner: root
    group: root
    mode: 0644

- name: Create the file to load the watchdog module
  file:
    path: /etc/modules-load.d/softdog.conf
    state: touch

- name: Add the watchdog module
  shell: modprobe softdog

- name: Change ownershhip of the watchdog device
  shell: chown postgres /dev/watchdog
  args:
    warn: false

- name: check if etcd sources already exist
  stat:
    path: /home/{{ postgres_user }}/etcd-v{{ etcd_vserion }}-linux-amd64.tar.gz
  register: etcd_source_available

- name: Download etcd
  get_url:
    url: https://github.com/etcd-io/etcd/releases/download/v{{ etcd_vserion }}/etcd-v{{ etcd_vserion }}-linux-amd64.tar.gz
    dest: /home/{{ postgres_user }}/etcd-v{{ etcd_vserion }}-linux-amd64.tar.gz
    mode: 0755
  when: etcd_source_available.stat.exists == false

- name: check if etcd is available in DMK
  stat:
    path: /u01/app/{{ postgres_user }}/local/dmk/bin/etcd
  register: etcd_copied_to_dmk

- name: extract etcd
  shell: cd /home/{{ postgres_user }}/; tar -axf etcd-v{{ etcd_vserion }}-linux-amd64.tar.gz
  become: yes
  become_user: "{{ postgres_user }}"
  when: etcd_copied_to_dmk.stat.exists == false

- name: copy etcd to DMK
  shell: cp /home/{{ postgres_user }}/etcd-v{{ etcd_vserion }}-linux-amd64/etcd* /u01/app/{{ postgres_user }}/local/dmk/bin/
  become: yes
  become_user: "{{ postgres_user }}"
  when: etcd_copied_to_dmk.stat.exists == false

- template:
    src: etcd.conf.j2
    dest: /u01/app/{{ postgres_user }}/local/dmk/etc/etcd.conf
    owner: "{{ postgres_user }}"
    group: "{{ postgres_group }}"
    mode: 0700

- name: Copy the etcd systemd service file
  copy:
    src: etcd.service
    dest: /etc/systemd/system/etcd.service
    owner: root
    group: root
    mode: 0755

- file:
    path: /u02/pgdata/etcd
    state: directory
    mode: 0700
    recurse: yes
    owner: "{{ postgres_user }}"
    group: "{{ postgres_group }}"

- name: force systemd to reread configs
  systemd:
    daemon_reload: yes

- name: Enable the systemd etcd service
  systemd:
    name: etcd
    enabled: yes

- name: Start the systemd etcd service
  shell: systemctl start etcd.service

- name: check if patroni is alraedy installed
  stat:
    path: /home/{{ postgres_user }}/.local/bin/patroni
  register: patroni_is_installed

- name: install and upgrade pip
  shell: pip install --upgrade pip
  when: patroni_is_installed.stat.exists == false
 
- name: install and upgrade setuptools
  become: yes
  become_user: "{{ postgres_user }}"
  shell: pip install --upgrade --user setuptools
  when: patroni_is_installed.stat.exists == false

- name: install psycopg2-binary
  become: yes
  become_user: "{{ postgres_user }}"
  shell: pip install --user psycopg2-binary
  when: patroni_is_installed.stat.exists == false

- name: install patroni
  become: yes
  become_user: "{{ postgres_user }}"
  shell: pip install --user patroni[etcd]
  when: patroni_is_installed.stat.exists == false

- file:
    src: /home/{{ postgres_user }}/.local/bin/patroni
    dest: /u01/app/{{ postgres_user }}/local/dmk/bin/patroni
    owner: "{{ postgres_user }}"
    group: "{{ postgres_user }}"
    state: link

- file:
    src: /home/{{ postgres_user }}/.local/bin/patronictl
    dest: /u01/app/{{ postgres_user }}/local/dmk/bin/patronictl
    owner: "{{ postgres_user }}"
    group: "{{ postgres_user }}"
    state: link

- template:
    src: patroni.yml.j2
    dest: /u01/app/{{ postgres_user }}/local/dmk/etc/patroni.yml
    owner: "{{ postgres_user }}"
    group: "{{ postgres_group }}"
    mode: 0600

- name: Copy the patroni systemd service file
  copy:
    src: patroni.service
    dest: /etc/systemd/system/patroni.service
    owner: root
    group: root
    mode: 0755

- name: force systemd to reread configs 
  systemd:
    daemon_reload: yes

- name: Enable the systemd etcd service
  systemd:
    name: patroni
    enabled: yes

# add the instance to /etc/pgtab so DMK is aware of if
- lineinfile:
    path: /etc/pgtab
    line: '{{ cluster_name }}:/u01/app/{{ postgres_user }}/product/{{ dmk_postgresql_version }}:/u02/pgdata/{{ postgresql_major_version }}/{{ cluster_name }}:5432:N'

- template:
    src: haproxy.cfg.j2
    dest: /etc/haproxy/haproxy.cfg
    owner: "{{ postgres_user }}"
    group: "{{ postgres_group }}"
    mode: 0600

- name: Enable the systemd haproxy service
  systemd:
    name: haproxy
    enabled: yes

# we need to set this so haproxy can be started
- name: Set selinux context for ha proxy
  shell: setsebool -P haproxy_connect_any=1

- template:
    src: keepalived.conf.j2
    dest: /etc/keepalived/keepalived.conf
    owner: "{{ postgres_user }}"
    group: "{{ postgres_group }}"
    mode: 0600
  with_items:
    - { role: "{{ hostvars[inventory_hostname].keepalived_role }}" , priority: "{{ hostvars[inventory_hostname].keepalived_priority }}" }

What it does:

  • Install the Extra Packages for Enterprise Linux (EPEL)
  • Install the dependencies for Patroni, HAProxy
  • Create the /etc/hosts file
  • Enable the watchdog service
  • Download and install etcd
  • Integrate etcd into systemd
  • Install Patroni, create the configuration files and integrate it into systemd
  • Install and configure HAProxy

This role uses several templates. The first one is used to create /etc/hosts:

dwe@dwe:~/Documents/dbiProjects/dbi_dmk_postgres/dmk/ansible$ cat roles/patroni/templates/hosts.j2 
#jinja2: trim_blocks:False
127.0.0.1   localhost localhost.localdomain localhost4 localhost4.localdomain4
::1         localhost localhost.localdomain localhost6 localhost6.localdomain6

{%- for h in ansible_play_hosts %}
{{ hostvars[h]['ansible_enp0s8']['ipv4']['address'] }} {{ hostvars[h]['ansible_hostname'] }}
{% endfor %}

The second one is used to create the etcd configuration:

dwe@dwe:~/Documents/dbiProjects/dbi_dmk_postgres/dmk/ansible$ cat roles/patroni/templates/etcd.conf.j2 
name: {{ ansible_hostname }}
data-dir: /u02/pgdata/etcd
initial-advertise-peer-urls: http://{{ hostvars[inventory_hostname]['ansible_enp0s8']['ipv4']['address'] }}:2380
listen-peer-urls: http://{{ hostvars[inventory_hostname]['ansible_enp0s8']['ipv4']['address'] }}:2380
listen-client-urls: http://{{ hostvars[inventory_hostname]['ansible_enp0s8']['ipv4']['address'] }}:2379,http://localhost:2379
advertise-client-urls: http://{{ hostvars[inventory_hostname]['ansible_enp0s8']['ipv4']['address'] }}:2379
initial-cluster:{{ blank }} {%- for h in ansible_play_hosts %}
{{ hostvars[h]['ansible_hostname'] }}=http://{{ hostvars[h]['ansible_enp0s8']['ipv4']['address'] }}:2380{% if not loop.last %},{% endif %}
{% endfor %}

The third one creates the Patroni configuration:

dwe@dwe:~/Documents/dbiProjects/dbi_dmk_postgres/dmk/ansible$ cat roles/patroni/templates/patroni.yml.j2 
scope: {{ cluster_name }}
#namespace: /service/
name: {{ ansible_hostname }}

restapi:
  listen: {{ hostvars[inventory_hostname]['ansible_enp0s8']['ipv4']['address'] }}:8008
  connect_address: {{ hostvars[inventory_hostname]['ansible_enp0s8']['ipv4']['address'] }}:8008
#  certfile: /etc/ssl/certs/ssl-cert-snakeoil.pem
#  keyfile: /etc/ssl/private/ssl-cert-snakeoil.key
#  authentication:
#    username: username
#    password: password

# ctl:
#   insecure: false # Allow connections to SSL sites without certs
#   certfile: /etc/ssl/certs/ssl-cert-snakeoil.pem
#   cacert: /etc/ssl/certs/ssl-cacert-snakeoil.pem

etcd:
  host: 127.0.0.1:2379

bootstrap:
  # this section will be written into Etcd:///config after initializing new cluster
  # and all other cluster members will use it as a `global configuration`
  dcs:
    ttl: 30
    loop_wait: 10
    retry_timeout: 10
    maximum_lag_on_failover: 1048576
    postgresql:
      use_pg_rewind: true
      use_slots: true
      parameters:
        wal_level: 'hot_standby'
        hot_standby: "on"
        wal_keep_segments: 8
        max_replication_slots: 10
        wal_log_hints: "on"
        listen_addresses: '*'
        port: 5432
        logging_collector: 'on'
        log_truncate_on_rotation: 'on'
        log_filename: 'postgresql-%a.log'
        log_rotation_age: '1440'
        log_line_prefix: '%m - %l - %p - %h - %u@%d - %x'
        log_directory: 'pg_log'
        log_min_messages: 'WARNING'
        log_autovacuum_min_duration: '60s'
        log_min_error_statement: 'NOTICE'
        log_min_duration_statement: '30s'
        log_checkpoints: 'on'
        log_statement: 'ddl'
        log_lock_waits: 'on'
        log_temp_files: '0'
        log_timezone: 'Europe/Zurich'
        log_connections: 'on'
        log_disconnections: 'on'
        log_duration: 'on'
        client_min_messages: 'WARNING'
        wal_level: 'replica'
        hot_standby_feedback: 'on'
        max_wal_senders: '10'
        shared_buffers: '128MB'
        work_mem: '8MB'
        effective_cache_size: '512MB'
        maintenance_work_mem: '64MB'
        wal_compression: 'off'
        max_wal_senders: '20'
        shared_preload_libraries: 'pg_stat_statements'
        autovacuum_max_workers: '6'
        autovacuum_vacuum_scale_factor: '0.1'
        autovacuum_vacuum_threshold: '50'
        archive_mode: 'on'
        archive_command: '/bin/true'
        wal_log_hints: 'on'
#      recovery_conf:
#        restore_command: cp ../wal_archive/%f %p

  # some desired options for 'initdb'
  initdb:  # Note: It needs to be a list (some options need values, others are switches)
  - encoding: UTF8
  - data-checksums

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

  # Additional script to be launched after initial cluster creation (will be passed the connection URL as parameter)
# post_init: /usr/local/bin/setup_cluster.sh

  # Some additional users users which needs to be created after initializing new cluster
  users:
    admin:
      password: admin
      options:
        - createrole
        - createdb
    replicator:
      password: postgres
      options:
        - superuser

postgresql:
  listen: {{ hostvars[inventory_hostname]['ansible_enp0s8']['ipv4']['address'] }}:5432
  connect_address: {{ hostvars[inventory_hostname]['ansible_enp0s8']['ipv4']['address'] }}:5432
  data_dir: /u02/pgdata/{{ postgresql_major_version }}/{{ cluster_name }}/
  bin_dir: /u01/app/{{ postgres_user }}/product/{{ dmk_postgresql_version }}/bin
#  config_dir:
  pgpass: /u01/app/{{ postgres_user }}/local/dmk/etc/pgpass0
  authentication:
    replication:
      username: replicator
      password: postgres
    superuser:
      username: postgres
      password: postgres
  parameters:
    unix_socket_directories: '/tmp'

watchdog:
  mode: automatic # Allowed values: off, automatic, required
  device: /dev/watchdog
  safety_margin: 5

tags:
    nofailover: false
    noloadbalance: false
    clonefrom: false
    nosync: false

Then we have the configuration template for HAProxy:

dwe@dwe:~/Documents/dbiProjects/dbi_dmk_postgres/dmk/ansible$ cat roles/patroni/templates/haproxy.cfg.j2 
#jinja2: trim_blocks:False
global
    maxconn 100

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

listen stats
    mode http
    bind *:7000
    stats enable
    stats uri /
    # stats auth haproxy:haproxy
    # stats refresh 10s

listen {{ cluster_name }}
    bind *:5000
    option httpchk
    http-check expect status 200
    default-server inter 3s fall 3 rise 2 on-marked-down shutdown-sessions
    {%- for h in ansible_play_hosts %}
    server postgresql_{{ hostvars[h]['ansible_enp0s8']['ipv4']['address'] }}_5432 {{ hostvars[h]['ansible_enp0s8']['ipv4']['address'] }}:5432 maxconn 100 check port 8008
    {% endfor %}

Finally the template for keepalived:

dwe@dwe:~/Documents/dbiProjects/dbi_dmk_postgres/dmk/ansible$ cat roles/patroni/templates/keepalived.conf.j2 
vrrp_script chk_haproxy {
    script "killall -0 haproxy"
    interval 2
    weight 2
}

vrrp_instance VI_1 {
    interface enp0s8
    state {{ item.role }} 
    virtual_router_id 51
    priority {{ item.priority }}
    virtual_ipaddress {
      {{ virtual_ip }}
  }
  track_script {
    chk_haproxy
  }
}

What is left are the systemd service files. The one for etcd:

dwe@dwe:~/Documents/dbiProjects/dbi_dmk_postgres/dmk/ansible$ cat roles/patroni/files/etcd.service 
#
# systemd integration for etcd 
# Put this file under /etc/systemd/system/etcd.service
#     then: systemctl daemon-reload
#     then: systemctl list-unit-files | grep etcd
#     then: systemctl enable etcd.service
#

[Unit]
Description=dbi services etcd service
After=network.target

[Service]
User=postgres
Type=notify
ExecStart=/u01/app/postgres/local/dmk/bin/etcd --config-file /u01/app/postgres/local/dmk/etc/etcd.conf
Restart=always
RestartSec=10s
LimitNOFILE=40000

[Install]
WantedBy=multi-user.target

For Patroni:

dwe@dwe:~/Documents/dbiProjects/dbi_dmk_postgres/dmk/ansible$ cat roles/patroni/files/patroni.service 
#
# systemd integration for patroni 
# Put this file under /etc/systemd/system/patroni.service
#     then: systemctl daemon-reload
#     then: systemctl list-unit-files | grep patroni
#     then: systemctl enable patroni.service
#

[Unit]
Description=dbi services patroni service
After=etcd.service syslog.target network.target

[Service]
User=postgres
Group=postgres
Type=simple
ExecStartPre=-/usr/bin/sudo /sbin/modprobe softdog
ExecStartPre=-/usr/bin/sudo /bin/chown postgres /dev/watchdog
ExecStart=/u01/app/postgres/local/dmk/bin/patroni /u01/app/postgres/local/dmk/etc/patroni.yml
ExecReload=/bin/kill -s HUP $MAINPID
KillMode=process
Restart=no
TimeoutSec=30

[Install]
WantedBy=multi-user.target

The last bit is the site definition which combines all of the above.

dwe@dwe:~/Documents/dbiProjects/dbi_dmk_postgres/dmk/ansible$ cat roles/patroni/site.yml 
---
# This playbook deploys a three node patroni PostgreSQL cluster with HAProxy

- hosts: patroni-servers
  become: true
  become_user: root

  roles:
    - common
    - patroni

Once all of that is in place the palybook can be executed:

dwe@dwe:~/Documents/dbiProjects/dbi_dmk_postgres/dmk/ansible$ ansible-playbook -i ../patroni patroni/site.yml -u postgres

This runs for a couple of minutes as especially upgrading all the operating system packages and comling PostgreSQL will take some time. Once it completed you only need to reboot the systems and your cluster is ready:

postgres@patroni1:/home/postgres/ [pg11.1] patronictl list 
+---------+----------+----------------+--------+---------+-----------+
| Cluster |  Member  |      Host      |  Role  |  State  | Lag in MB |
+---------+----------+----------------+--------+---------+-----------+
|   PG1   | patroni1 | 192.168.22.240 | Leader | running |       0.0 |
|   PG1   | patroni2 | 192.168.22.241 |        | running |       0.0 |
|   PG1   | patroni3 | 192.168.22.242 |        | running |       0.0 |
+---------+----------+----------------+--------+---------+-----------+

HAProy is running as well on all three nodes and you can check that by pointing your browser to any of the hosts on port 7000:
Selection_062

Hope that helps.