Implementação de Ambiente HA com Patroni e Citus
- 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.
- 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:
- Degradação no desempenho de escrita de dados
- Garantia de consistência entre réplicas mais fraca comparada ao streaming replication nativo do PG
- 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
- 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
- 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
- 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.
- 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;
- 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
- 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.