Implementação de Ambiente de Alta Disponibilidade PostgreSQL Patroni+Citus (Configuração de Leitura-Escrita)

Implementação de Ambiente HA com Patroni e Citus

  1. Introdução

Citus representa uma extensão extremamente valiosa que permite ao PostgreSQL executar escala horizontal, tratando-se essencialmente de um banco de dados HTAP distribuído implementado como plugin do PostgreSQL. Este documento apresenta brevemente as técnicas de alta disponibilidade do Citus e demonstra na prática os passos para estabelecer um ambiente HA com Citus utilizando Patroni.

  1. Solução Técnica

2.1 Seleção de Esquema HA para Citus

Um cluster Citus compreende um nó coordinator (CN) e N nós worker. A alta disponibilidade do nó coordinator pode utilizar qualquer solução genérica de HA para PostgreSQL, ou seja, configurar dois servidores PostgreSQL como primário e standby através de streaming replication; para os nós worker, além de utilizar a solução nativa de HA do PG semelhante ao CN, existe também uma alternativa de alta disponibilidade com múltiplas réplicas de shards.

O esquema de alta disponibilidade com múltiplas réplicas era a abordagem padrão para workers nas versões iniciais do Citus (quando citus.shard_count tinha valor padrão 2). Esta solução oferece implementação simples e a falha de um nó worker não afeta as operações. Contudo, ela apresenta desvantagens significativas:

  1. Degradação no desempenho de escrita de dados
  2. Garantia de consistência entre réplicas mais fraca comparada ao streaming replication nativo do PG
  3. Limitações funcionais, como incompatibilidade com a arquitetura Citus MX

Portanto, o cenário de aplicação para este esquema é limitado. A documentação oficial do Citus indica que pode ser apropriado apenas para operações append-only, não sendo mais recomendado como solução de alta disponibilidade (no Citus 6.1, o valor padrão de citus.shard_count foi alterado de 2 para 1).

Recomenda-se então utilizar streaming replication nativo do PG tanto para o coordinator quanto para os workers do Citus.

2.2 Seleção de Ferramentas de Suporte HA para PostgreSQL

O streaming replication nativo do PG para HA não apresenta grande complexidade em termos de implementação e manutenção. No entanto, para alcançar maior automação, especialmente failover automático, existem ferramentas de terceiros disponíveis. As opções mais utilizadas incluem:

  • PAF (PostgreSQL Automatic Failover)
  • repmgr
  • Patroni

Uma comparação detalhada está disponível em: https://scalegrid.io/blog/managing-high-availability-in-postgresql-part-1/

O Patroni utiliza DCS (Distributed Configuration Store, como etcd, ZooKeeper, Consul) para armazenagem de metadados, garantindo estrita consistência e alta confiabilidade. Além disso, oferece funcionalidades robustas.

Este documento apresenta a implementação de HA PostgreSQL utilizando Patroni.

2.3 Soluções de Troca de Tráfego para Clientes

Após o failover entre primário e standby do PostgreSQL, os clientes que acessam o banco de dados devem se conectar ao novo nó primário. As abordagens mais comunsincluem:

  • HAProxy
    • Vantagens: Confiável, suporta balanceamento de carga
    • Desvantagens: Perda de desempenho, necessidade de configurar HA do próprio HAProxy
  • VIP
    • Vantagens: Sem perda de desempenho, não consome recursos da máquina
    • Desvantagens: IPs do primário e standby devem estar na mesma sub-rede
  • URL Multi-Host no Cliente
    • Vantagens: Sem perda de desempenho, não consome recursos, não depende de VIP, fácil implementação em ambiente cloud
    • Desvantagens: Suportado apenas por alguns drivers (atualmente inclui pgjdbc, libpq e derivados como python e php)

Para as características do cluster Citus, as soluções recomendadas são:

Para conexão de aplicações ao Citus: - URL multi-host no cliente: Recomendado especialmente para aplicações Java se o driver suportar - VIP

Para conexão do Citus CN aos Workers: - VIP - Modificação dinâmica de metadados dos workers no CN quando ocorrem falhas

Este documento demonstra duas arquiteturas diferentes nas seções experimentais a seguir.

Arquitetura Padrão

  • CN conecta-se ao nó primário do Worker através do IP real
  • Scripts de monitoramento no CN detectam o status dos Workers e atualizam os metadados dinamicamente em caso de failover

Arquitetura com Leitura-Escrita Separada

  • CN conecta-se aos Workers através de VIP de escrita-leitura e VIP de apenas leitura
  • Scripts de callback do Patroni controlam dinamicamente qual VIP cada nó utiliza
  • Nos Workers, scripts de callback do Patroni associam o VIP de escrita-leitura
  • Nos Workers, o keepalived associa dinamicamente o VIP de apenas leitura
  1. Ambiente Experimental

Principais Software

  • CentOS 7.8
  • PostgreSQL 12
  • Citus 10.4
  • patroni 1.6.5
  • etcd 3.3.25

Recursos de Máquinas e VIP

  • Citus CN
    • node1: 192.168.234.201
    • node2: 192.168.234.202
  • Citus Worker
    • node3: 192.168.234.203
    • node4: 192.168.234.204
  • etcd
    • node4: 192.168.234.204
  • VIP (Citus CN)
    • VIP leitura-escrita: 192.168.234.210
    • VIP apenas leitura: 192.168.234.211

Preparação do Ambiente

Configurar sincronização de relógio em todos os nós:

yum install -y ntpdate
ntpdate time.windows.com && hwclock -w

Se utilizar firewall, abrir portas para postgres, etcd e patroni:

  • postgres: 5432
  • patroni: 8008
  • etcd: 2379/2380

Alternativamente, desabilitar o firewall:

setenforce 0
sed -i.bak "s/SELINUX=enforcing/SELINUX=permissive/g" /etc/selinux/config
systemctl disable firewalld.service
systemctl stop firewalld.service
iptables -F

  1. Instalação do etcd

Como o tema deste documento não é HA do etcd, será implantado um único nó etcd no node4 apenas para fins experimentais. Em ambiente de produção, são necessários pelo menos 3 servidores independentes.

Instalar pacotes necessários

yum install -y gcc python-devel epel-release

Instalar etcd

yum install -y etcd

Editar arquivo de configuração /etc/etcd/etcd.conf

ETCD_DATA_DIR="/var/lib/etcd/default.etcd"
ETCD_LISTEN_PEER_URLS="http://192.168.234.204:2380"
ETCD_LISTEN_CLIENT_URLS="http://localhost:2379,http://192.168.234.204:2379"
ETCD_NAME="etcd0"
ETCD_INITIAL_ADVERTISE_PEER_URLS="http://192.168.234.204:2380"
ETCD_ADVERTISE_CLIENT_URLS="http://192.168.234.204:2379"
ETCD_INITIAL_CLUSTER="etcd0=http://192.168.234.204:2380"
ETCD_INITIAL_CLUSTER_TOKEN="cluster1"
ETCD_INITIAL_CLUSTER_STATE="new"

Iniciar etcd

systemctl start etcd
systemctl enable etcd

  1. Instalação de HA com PostgreSQL + Citus + Patroni

Instalar software relacionado nas instâncias que executarão PostgreSQL.

Instalar PostgreSQL 12 e Citus

yum install -y https://download.postgresql.org/pub/repos/yum/reporpms/EL-7-x86_64/pgdg-redhat-repo-latest.noarch.rpm
yum install -y postgresql12-server postgresql12-contrib
yum install -y citus_12

Instalar Patroni

yum install -y gcc epel-release
yum install -y python-pip python-psycopg2 python-devel
pip install --upgrade pip
pip install --upgrade setuptools
pip install patroni[etcd]

Criar diretório de dados PostgreSQL

mkdir -p /pgsql/data
chown postgres:postgres -R /pgsql
chmod -R 700 /pgsql/data

Criar arquivo de serviço do Patroni em /etc/systemd/system/patroni.service

[Unit]
Description=Runners to orchestrate a high-availability PostgreSQL
After=syslog.target network.target

[Service]
Type=simple
User=postgres
Group=postgres
ExecStart=/usr/bin/patroni /etc/patroni.yml
ExecReload=/bin/kill -s HUP $MAINPID
KillMode=process
TimeoutSec=30
Restart=no

[Install]
WantedBy=multi-user.target

Criar arquivo de configuração do Patroni /etc/patroni.yml

Exemplo de configuração para node1:

scope: cn
namespace: /service/
name: pg1

restapi:
  listen: 0.0.0.0:8008
  connect_address: 192.168.234.201:8008

etcd:
  host: 192.168.234.204:2379

bootstrap:
  dcs:
    ttl: 30
    loop_wait: 10
    retry_timeout: 10
    maximum_lag_on_failover: 1048576
    master_start_timeout: 300
    synchronous_mode: false
    postgresql:
      use_pg_rewind: true
      use_slots: true
      parameters:
        listen_addresses: "0.0.0.0"
        port: 5432
        wal_level: logical
        hot_standby: "on"
        wal_keep_segments: 1000
        max_wal_senders: 10
        max_replication_slots: 10
        wal_log_hints: "on"
        max_connections: "100"
        max_prepared_transactions: "100"
        shared_preload_libraries: "citus"
        citus.node_conninfo: "sslmode=prefer"
        citus.replication_model: streaming
        citus.task_assignment_policy: round-robin

  initdb:
  - encoding: UTF8
  - locale: C
  - lc-ctype: zh_CN.UTF-8
  - data-checksums

  pg_hba:
  - host replication repl 0.0.0.0/0 md5
  - host all all 0.0.0.0/0 md5

postgresql:
  listen: 0.0.0.0:5432
  connect_address: 192.168.234.201:5432
  data_dir: /pgsql/data
  bin_dir: /usr/pgsql-12/bin

  authentication:
    replication:
      username: repl
      password: "123456"
    superuser:
      username: postgres
      password: "123456"

  basebackup:
    max-rate: 100M
    checkpoint: fast

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

Para outros nós PG, ajustar os seguintes 4 parâmetros no patroni.yml:

  • scope: node1 e node2 definem como "cn"; node3 e node4 definem como "wk1"
  • name: node1 a node4 definem respectivamente "pg1" a "pg4"
  • restapi.connect_address: definir conforme IP de cada nó
  • postgresql.connect_address: definir conforme IP de cada nó

Iniciar Patroni

Iniciar Patroni em todos os nós:

systemctl start patroni

Na primeira inicialização dentro de um mesmo cluster, a instância Patroni será executada como leader e criará a instância PostgreSQL e os usuários necessários. As inicializações subsequentes clonam dados do nó leader.

  1. Configuração do Citus

Verificar status do cluster cn

[root@node1 ~]# patronictl -c /etc/patroni.yml list
+ Cluster: cn (6869267831456178056) +---------+----+-----------+-----------------+
| Member |       Host      |  Role  |  State  | TL | Lag in MB | Pending restart |
+--------+-----------------+--------+---------+----+-----------+-----------------+
|  pg1   | 192.168.234.201 |        | running |  1 |       0.0 |        *        |
|  pg2   | 192.168.234.202 | Leader | running |  1 |           |                 |
+--------+-----------------+--------+---------+----+-----------+-----------------+

Verificar status do cluster wk1

[root@node3 ~]# patronictl -c /etc/patroni.yml list
+ Cluster: wk1 (6869267726994446390) ---------+----+-----------+-----------------+
| Member |       Host      |  Role  |  State  | TL | Lag in MB | Pending restart |
+--------+-----------------+--------+---------+----+-----------+-----------------+
|  pg3   | 192.168.234.203 |        | running |  1 |       0.0 |        *        |
|  pg4   | 192.168.234.204 | Leader | running |  1 |           |                 |
+--------+-----------------+--------+---------+----+-----------+-----------------+

Configurações de ambiente

Para facilitar operações diárias, configurar variável de ambiente global:

echo 'export PATRONICTL_CONFIG_FILE=/etc/patroni.yml' >/etc/profile.d/patroni.sh

Adicionar variáveis de ambiente em ~postgres/.bash_profile:

export PGDATA=/pgsql/data
export PATH=/usr/pgsql-12/bin:$PATH

Conceder permissão sudo ao postgres:

echo 'postgres        ALL=(ALL)       NOPASSWD: ALL'> /etc/sudoers.d/postgres

Criar extensão Citus

Nos nós primários de cn e wk:

create extension citus;

No nó primário do cn, adicionar o nó primário do wk1:

SELECT * from master_add_node('192.168.234.204', 5432, 1, 'primary');

Configurar acesso aos Workers

Modificar /pgsql/data/pg_hba.conf nos primários e standbys dos Workers para permitir conexão sem senha do CN:

host all all 192.168.234.201/32 trust
host all all 192.168.234.202/32 trust

Recarregar configuração:

su - postgres
pg_ctl reload

Criar tabela distribuída para teste

create table tb1(id int primary key, c1 text);
set citus.shard_count = 64;
select create_distributed_table('tb1', 'id');
select * from tb1;

  1. Configuração de Troca Automática de Tráfego nos Workers

O IP do Worker configurado anteriormente era o IP do nó primário do Worker no momento. Quando ocorre failover no Worker, este IP precisa ser atualizado.

A implementação pode ser feita através de scripts que monitoram o status primário/standby dos Workers e atualizam automaticamente os metadados do Citus com o novo IP. Abaixo está um exemplo de imlpementação.

Adicionar configuração ao Patroni

Adicionar a seguinte configuração ao /etc/patroni.yml nos nós primário e standby do Citus CN:

citus:
  loop_wait: 10
  databases:
  - postgres

  workers:
  - groupid: 1
    nodes:
    - 192.168.234.203:5432
    - 192.168.234.204:5432

Criar script de troca automática de tráfego

Criar script em /pgsql/citus_controller.py:

#!/usr/bin/env python2
# -*- coding: utf-8 -*-

import os
import time
import argparse
import logging
import yaml
import psycopg2


def obter_pg_role(url):
    resultado = 'desconhecido'
    try:
        with psycopg2.connect(url, connect_timeout=2) as conn:
            conn.autocommit = True
            cursor = conn.cursor()
            cursor.execute("select pg_is_in_recovery()")
            linha = cursor.fetchone()
            if linha[0] == True:
                resultado = 'secundario'
            elif linha[0] == False:
                resultado = 'primario'
    except Exception as e:
        logging.debug('obter_pg_role() falhou. url:{0} erro:{1}'.format(
                    url, str(e)))

    return resultado

def atualizar_worker(url, role, groupid, nodename, nodeport):
    logging.debug('chamando atualizar worker. role:{0} groupid:{1} nodename:{2} nodeport:{3}'.format(
                    role, groupid, nodename, nodeport))
    try:
        sql = "select nodeid,nodename,nodeport from pg_dist_node where groupid={0} and noderole = '{1}' order by nodeid limit 1".format(
                                                                        groupid, role)
        conn = psycopg2.connect(url, connect_timeout=2)
        conn.autocommit = True
        cursor = conn.cursor()
        cursor.execute(sql)
        linha = cursor.fetchone()
        if linha is None:
            logging.error("não foi possível encontrar nodeid com groupid={0} noderole = '{1}'".format(groupid, role))
            return False
        
        nodeid = linha[0]
        oldnodename = linha[1]
        oldnodeport = str(linha[2])

        if oldnodename == nodename and oldnodeport == nodeport:
            logging.debug('pulando: nodename:nodeport atual é igual')
            return False

        sql = "select master_update_node({0}, '{1}', {2})".format(nodeid, nodename, nodeport)
        resultado = cursor.execute(sql)
        logging.info("Alterado nó worker {0} de '{1}:{2}' para '{3}:{4}'".format(nodeid, oldnodename, oldnodeport, nodename, nodeport))
        return True
    except Exception as e:
        logging.error('atualizar_worker() falhou. role:{0} groupid:{1} nodename:{2} nodeport:{3} erro:{4}'.format(
                    role, groupid, nodename, nodeport, str(e)))
        return False


def main():
    parser = argparse.ArgumentParser(description='Script para configuração automática de workers Citus')
    parser.add_argument('-c', '--config', default='citus_controller.yml')
    parser.add_argument('-d', '--debug', action='store_true', default=False)
    args = parser.parse_args()

    if args.debug:
        logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=logging.DEBUG)
    else:
        logging.basicConfig(format='%(asctime)s %(levelname)s: %(message)s', level=logging.INFO)

    # ler arquivo de configuração
    f = open(args.config,'r')
    conteudo = f.read()
    config = yaml.load(conteudo, Loader=yaml.FullLoader)

    cn_connect_address = config['postgresql']['connect_address']
    username = config['postgresql']['authentication']['superuser']['username']
    password = config['postgresql']['authentication']['superuser']['password']
    databases = config['citus']['databases']
    workers = config['citus']['workers']

    loop_wait = config['citus'].get('loop_wait', 10)
 
    logging.info('iniciando loop principal')
    contador = 0
    while True:
        contador += 1
        logging.debug("##### início do loop principal [{}] #####".format(contador))

        dbname = databases[0]
        cn_url = "postgres://{0}/{1}?user={2}&password={3}".format(
                                    cn_connect_address, dbname, username, password)
        if obter_pg_role(cn_url) == 'primario':
            for worker in workers:
                groupid = worker['groupid']
                nodes = worker['nodes']
    
                ## obter função dos nós worker
                primario = []
                secundario = []
                for node in nodes:
                    wk_url = "postgres://{0}/{1}?user={2}&password={3}".format(
                                    node, dbname, username, password)
                    role = obter_pg_role(wk_url)
                    if role == 'primario':
                        primario.append(node) 
                    elif role == 'secundario':
                        secundario.append(node) 
    
                logging.debug('Info função groupid:{0} primario:{1} secundario:{2}'.format(
                                        groupid, primario, secundario))

                ## atualizar nó worker
                for dbname in databases:
                    cn_url = "postgres://{0}/{1}?user={2}&password={3}".format(
                                        cn_connect_address, dbname, username, password)
                    if len(primario) == 1:
                        nodename = primario[0].split(':')[0]
                        nodeport = primario[0].split(':')[1]
                        atualizar_worker(cn_url, 'primario', groupid, nodename, nodeport)

        time.sleep(loop_wait)

if __name__ == '__main__':
    main()

Criar serviço do script

Criar arquivo /etc/systemd/system/citus_controller.service:

[Unit]
Description=Atualização automática do IP do worker primário no Citus CN
After=syslog.target network.target

[Service]
Type=simple
User=postgres
Group=postgres
ExecStart=/bin/python /pgsql/citus_controller.py -c /etc/patroni.yml
KillMode=process
TimeoutSec=30
Restart=no

[Install]
WantedBy=multi-user.target

Iniciar script de troca automática

Iniciar nos nós primário e standby do CN:

systemctl start citus_controller

  1. Separação de Leitura-Escrita

Com a configuração anterior, o Citus CN não acessa os standbys dos Workers. É possível utilizar esses nós para implementar separação de leitura-escrita, fazendo o standby do CN priorizar o acesso aos standbys dos Workers, e em caso de falha desses, acessar os primários.

O Citus suporta nativamente separação de leitura-escrita, adicionando os nós primário e standby de um Worker como dois "workers" separados no mesmo grupo, com funções "primary" e "secondary". No entanto, devido ao requisito de unicidade de nodename:nodeport nos metadados pg_dist_node, a abordagem anterior de atualização dinâmica não suporta ambos primary e secondary simultaneamente.

Existem duas soluções possíveis:

Método 1: Usar nomes de host fixos nos metadados (wk1, wk2...) e o script de controle resolver esses nomes para IPs diferentes no /etc/hosts, conforme o papel do nó.

Método 2: Vincular VIP de leitura-escrita e VIP de apenas leitura dinamicamente nos Workers. Nos metadados do Citus, o VIP de leitura-escrita funciona como worker com função "primary", e o VIP de apenas leitura como worker com função "secondary".

A seguir, será configurado utilizando o Métodoodo 2.

Adicionar workers com VIPs

No nó primário do CN, ao criar o cluster Citus, adicionar o VIP de leitura-escrita (192.168.234.210) e o VIP de apenas leitura (192.168.234.211) como workers primary e secondary, respectivamente:

SELECT * from master_add_node('192.168.234.210', 5432, 1, 'primary');
SELECT * from master_add_node('192.168.234.211', 5432, 1, 'secondary');

Configurar CN standby para usar workers secundários

No nó standby do CN, configurar o seguinte parâmetro:

alter system set citus.use_secondary_nodes=always;
select pg_reload_conf();

Esta alteração só afeta novas sessões. Para aplicar imediatamente,杀掉 existentes sessões após a alteração.

Testar separação de leitura-escrita

Executar a mesma query SQL nos nós primário e standby do CN para verificar se são enviadas para Workers diferentes.

CN primário (sem citus.use_secondary_nodes=always):

postgres=# explain select * from tb1;
                                  QUERY PLAN
-------------------------------------------------------------------------------
 Custom Scan (Citus Adaptive)  (cost=0.00..0.00 rows=100000 width=36)
   Task Count: 32
   Tasks Shown: One of 32
   ->  Task
         Node: host=192.168.234.210 port=5432 dbname=postgres
         ->  Seq Scan on tb1_102168 tb1  (cost=0.00..22.70 rows=1270 width=36)
(6 rows)

CN standby (com citus.use_secondary_nodes=always):

postgres=# explain select * from tb1;
                                  QUERY PLAN
-------------------------------------------------------------------------------
 Custom Scan (Citus Adaptive)  (cost=0.00..0.00 rows=100000 width=36)
   Task Count: 32
   Tasks Shown: One of 32
   ->  Task
         Node: host=192.168.234.211 port=5432 dbname=postgres
         ->  Seq Scan on tb1_102168 tb1  (cost=0.00..22.70 rows=1270 width=36)
(6 rows)

Callback dinâmico para parâmetro use_secondary_nodes

Como o CN também pode sofrer failover, o parâmetro citus.use_secondary_nodes precisa ser ajustado dinamicamente. Isso pode ser implementado através de script de callback do Patroni.

Criar script /pgsql/switch_use_secondary_nodes.sh:

#!/bin/bash

DBNAME=postgres
KILL_ALL_SQL="select pg_terminate_backend(pid) from pg_stat_activity where backend_type='client backend' and application_name <> 'Patroni' and pid <> pg_backend_pid()"

action=$1
role=$2
cluster=$3


log()
{
  echo "switch_use_secondary_nodes: $*"|logger
}

alter_use_secondary_nodes()
{
  value="$1"
  oldvalue=`psql -d postgres -Atc "show citus.use_secondary_nodes"`
  if [ "$value" = "$oldvalue" ] ; then
    log "valor antigo de use_secondary_nodes já é '${value}', pulando alteração"
    return
  fi

  psql -d ${DBNAME} -c "alter system set citus.use_secondary_nodes=${value}" >/dev/null
  rc=$?
  if [ $rc -ne 0 ] ;then
    log "falha ao alterar use_secondary_nodes para '${value}' rc=$rc"
    exit 1
  fi

  psql -d ${DBNAME} -c 'select pg_reload_conf()' >/dev/null
  rc=$?
  if [ $rc -ne 0 ] ;then
    log "falha ao chamar pg_reload_conf() rc=$rc"
    exit 1
  fi

  log "alterado use_secondary_nodes para '${value}'"

  ## encerrar todas as conexões existentes
  killed_conns=`psql -d ${DBNAME} -Atc "${KILL_ALL_SQL}" | wc -l`
  rc=$?
  if [ $rc -ne 0 ] ;then
    log "falha ao encerrar conexões rc=$rc"
    exit 1
  fi
  
  log "encerradas ${killed_conns} conexões"

}

log "switch_use_secondary_nodes iniciado args:'$'"

case $action in
  on_start|on_restart|on_role_change)
    case $role in
      master)
        alter_use_secondary_nodes never
        ;;
      replica)
        alter_use_secondary_nodes always
        ;;
      *)
        log "função incorreta '$role'"
        exit 1
        ;;
    esac
    ;;
  *)
    log "ação incorreta '$action'"
    exit 1
    ;;
esac

Modificar o arquivo de configuração do Patroni /etc/patroni.yml para configurar as callbacks:

postgresql:
  ...

  callbacks:
    on_start: /bin/bash /pgsql/switch_use_secondary_nodes.sh
    on_restart: /bin/bash /pgsql/switch_use_secondary_nodes.sh
    on_role_change: /bin/bash /pgsql/switch_use_secondary_nodes.sh

Recarregar configuração do Patroni em todos os nós:

patronictl reload cn

Após executar switchover no CN, pode-se observar a alteração do parâmetro use_secondary_nodes.

Publicado em 7-23 17:29