{"id":6685,"date":"2026-04-08T00:03:00","date_gmt":"2026-04-07T22:03:00","guid":{"rendered":"https:\/\/rootfan.com\/?p=6685"},"modified":"2026-04-08T14:24:43","modified_gmt":"2026-04-08T12:24:43","slug":"alta-disponibilidad-de-postgresql-con-patroni-etcd-haproxy-y-keepalived","status":"publish","type":"post","link":"https:\/\/rootfan.com\/es\/postgresql-high-availability-with-patroni-etcd-haproxy-and-keepalived\/","title":{"rendered":"Alta disponibilidad de PostgreSQL con Patroni, etcd, HAProxy y keepalived"},"content":{"rendered":"<p class=\"wp-block-paragraph\">PostgreSQL tiene una pila madura de alta disponibilidad de grado de producci\u00f3n que no cuesta nada en licencias y es sencilla de operar una vez que est\u00e1 configurada. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Este laboratorio crea un cl\u00faster de alta disponibilidad de seis nodos utilizando cuatro componentes de c\u00f3digo abierto: <strong>Patroni<\/strong> para la gesti\u00f3n de cl\u00fasteres y la conmutaci\u00f3n por error autom\u00e1tica, <strong>etcd<\/strong> como el almac\u00e9n de consenso distribuido, <strong>HAProxy<\/strong> para balanceo de carga y enrutamiento de conexiones, y <strong>keepalived<\/strong> para una IP virtual flotante que sobreviva a fallos de nodos de HAProxy.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">El resultado es un cl\u00faster donde se detecta una falla primaria y se elige una nueva primaria en menos de 30 segundos, sin requerir intervenci\u00f3n manual. <\/p>\n\n\n\n<!--more-->\n\n\n\n<p class=\"wp-block-paragraph\">Las conmutaciones son limpias y sin p\u00e9rdida de datos. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Todo el stack se gestiona a trav\u00e9s de una \u00fanica CLI (<code>patronictl<\/code>) que hace que las operaciones del d\u00eda a d\u00eda \u2014cambio, conmutaci\u00f3n por error, reinicializaci\u00f3n, cambios de configuraci\u00f3n\u2014 sean comandos sencillos en lugar de procedimientos de varios pasos.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Este laboratorio cubre todo desde cero: generaci\u00f3n de certificados TLS, formaci\u00f3n de cl\u00fasteres de etcd, configuraci\u00f3n de Patroni, configuraci\u00f3n de HAProxy, configuraci\u00f3n de VIP de Keepalived y verificaci\u00f3n completa de la conmutaci\u00f3n por error y el cambio de rol. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Cada paso se explica con la salida esperada y el diagn\u00f3stico de fallas para que sepa exactamente c\u00f3mo se ve el \u00e9xito en cada etapa.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<div class=\"wp-block-rank-math-toc-block\" id=\"rank-math-toc\"><h2>\u00cdndice<\/h2><nav><ul><li><a href=\"#postgre-sql-high-availability-with-patroni\">Alta disponibilidad de PostgreSQL con Patroni<\/a><\/li><li><a href=\"#1-architecture\">1. Arquitectura<\/a><\/li><li><a href=\"#2-prerequisites\">2. Prerrequisitos<\/a><ul><li><a href=\"#step-1-set-the-correct-timezone-on-all-nodes\">Paso 1 \u2014 Establecer la zona horaria correcta en todos los nodos<\/a><\/li><li><a href=\"#step-2-confirm-required-ports-are-open\">Paso 2: confirme que los puertos requeridos est\u00e9n abiertos<\/a><\/li><\/ul><\/li><li><a href=\"#3-postgre-sql-installation\">3. Instalaci\u00f3n de PostgreSQL<\/a><ul><li><a href=\"#step-1-install-postgre-sql-on-all-3-postgre-sql-nodes\">Paso 1: Instalar PostgreSQL en los 3 nodos de PostgreSQL<\/a><\/li><li><a href=\"#step-2-stop-and-disable-the-postgre-sql-service\">Paso 2 \u2014 Detener e inhabilitar el servicio de PostgreSQL<\/a><\/li><\/ul><\/li><li><a href=\"#4-etcd-installation\">4. Instalaci\u00f3n de etcd<\/a><ul><li><a href=\"#step-1-install-etcd-on-all-3-postgre-sql-nodes\">Paso 1: Instala etcd en los 3 nodos de PostgreSQL<\/a><\/li><li><a href=\"#step-2-create-the-etcd-system-user\">Paso 2 \u2014 Crear el usuario del sistema etcd<\/a><\/li><\/ul><\/li><li><a href=\"#5-tls-certificate-generation\">5. Generaci\u00f3n de Certificados TLS<\/a><ul><li><a href=\"#step-1-create-the-working-directory\">Paso 1 \u2014 Crear el directorio de trabajo<\/a><\/li><li><a href=\"#step-2-generate-the-certificate-authority\">Paso 2 \u2014 Generar la Autoridad Certificadora<\/a><\/li><li><a href=\"#step-3-generate-per-node-etcd-certificates\">Paso 3: Generar certificados etcd por nodo<\/a><\/li><li><a href=\"#step-4-generate-the-postgre-sql-server-certificate\">Paso 4 \u2014 Generar el certificado del servidor PostgreSQL<\/a><\/li><li><a href=\"#step-5-distribute-certificates-to-postgres-02-and-postgres-03\">Paso 5 \u2014 Distribuir certificados a postgres-02 y postgres-03<\/a><\/li><li><a href=\"#step-6-install-certificates-on-each-postgre-sql-node\">Paso 6 \u2014 Instalar certificados en cada nodo de PostgreSQL<\/a><\/li><li><a href=\"#step-7-create-the-combined-pem-file-for-patroni\">Paso 7: Crear el archivo PEM combinado para Patroni<\/a><\/li><\/ul><\/li><li><a href=\"#6-etcd-configuration\">6. Configuraci\u00f3n de etcd<\/a><ul><li><a href=\"#step-1-create-the-etcd-data-directory\">Paso 1 \u2014 Cree el directorio de datos de etcd<\/a><\/li><li><a href=\"#step-2-create-the-etcd-environment-file-on-each-node\">Paso 2: Cree el archivo de entorno de etcd en cada nodo<\/a><\/li><li><a href=\"#step-3-create-the-etcd-systemd-service-file\">Paso 3: Crear el archivo de servicio systemd de etcd<\/a><\/li><li><a href=\"#step-4-start-etcd-on-all-3-nodes\">Paso 4 \u2014 Iniciar etcd en los 3 nodos<\/a><\/li><li><a href=\"#step-5-verify-etcd-cluster-health\">Paso 5 \u2014 Verificar el estado del cl\u00faster de etcd<\/a><\/li><\/ul><\/li><li><a href=\"#7-patroni-installation-and-configuration\">7. Instalaci\u00f3n y configuraci\u00f3n de Patroni<\/a><ul><li><a href=\"#step-1-install-patroni\">Paso 1 - Instalar Patroni<\/a><\/li><li><a href=\"#step-2-create-patroni-yml-on-each-node\">Paso 2 \u2014 Crear patroni.yml en cada nodo<\/a><\/li><\/ul><\/li><li><a href=\"#8-starting-the-cluster\">8. Inicio del cl\u00faster<\/a><ul><li><a href=\"#step-1-start-patroni-on-postgres-01-first\">Paso 1 \u2014 Inicie Patroni en postgres-01 primero<\/a><\/li><li><a href=\"#step-2-verify-postgres-01-is-the-leader\">Paso 2: Verifica que postgres-01 sea el l\u00edder<\/a><\/li><li><a href=\"#step-3-start-patroni-on-postgres-02-and-postgres-03\">Paso 3: Iniciar Patroni en postgres-02 y postgres-03<\/a><\/li><li><a href=\"#step-4-change-initial-cluster-state-to-existing-on-all-nodes\">Paso 4: Cambiar initial-cluster-state a existing en todos los nodos<\/a><\/li><\/ul><\/li><li><a href=\"#9-ha-proxy-setup\">9. Configuraci\u00f3n de HAProxy<\/a><ul><li><a href=\"#step-1-install-ha-proxy\">Paso 1 \u2014 Instalar HAProxy<\/a><\/li><li><a href=\"#step-2-configure-ha-proxy\">Paso 2 \u2014 Configurar HAProxy<\/a><\/li><li><a href=\"#step-3-validate-config-and-reload-ha-proxy\">Paso 3: Validar configuraci\u00f3n y recargar HAProxy<\/a><\/li><\/ul><\/li><li><a href=\"#10-keepalived-setup\">10. Configuraci\u00f3n de keepalived<\/a><ul><li><a href=\"#step-1-install-keepalived\">Paso 1 \u2014 Instalar keepalived<\/a><\/li><li><a href=\"#step-2-create-the-ha-proxy-health-check-script\">Paso 2 \u2014 Crear el script de verificaci\u00f3n de estado de HAProxy<\/a><\/li><li><a href=\"#step-3-configure-keepalived\">Paso 3: Configurar keepalived<\/a><\/li><li><a href=\"#step-4-start-keepalived\">Paso 4 \u2014 Iniciar keepalived<\/a><\/li><\/ul><\/li><li><a href=\"#11-set-the-postgres-superuser-password\">11. Establecer la contrase\u00f1a de superusuario de postgres<\/a><\/li><li><a href=\"#12-verify-the-full-stack\">12. Verificar la pila completa<\/a><\/li><li><a href=\"#13-switchover-planned\">13. Cambio (planificado)<\/a><\/li><li><a href=\"#14-failover-unplanned\">14. Conmutaci\u00f3n por error (no planificada)<\/a><ul><li><a href=\"#simulate-a-primary-failure\">Simular una falla primaria<\/a><\/li><li><a href=\"#manual-failover-use-only-when-automatic-failover-has-not-triggered\">Conmutaci\u00f3n por error manual: use solo cuando la conmutaci\u00f3n por error autom\u00e1tica no se haya activado<\/a><\/li><\/ul><\/li><li><a href=\"#15-day-to-day-operations\">15. Operaciones del d\u00eda a d\u00eda<\/a><ul><li><a href=\"#check-cluster-status\">Verificar estado del cl\u00faster<\/a><\/li><li><a href=\"#pause-and-resume-automatic-failover\">Pausar y reanudar la conmutaci\u00f3n por error autom\u00e1tica<\/a><\/li><li><a href=\"#restart-postgre-sql-on-a-node\">Reiniciar PostgreSQL en un nodo<\/a><\/li><li><a href=\"#reload-configuration\">Recargar configuraci\u00f3n<\/a><\/li><li><a href=\"#edit-cluster-dcs-configuration\">Editar configuraci\u00f3n del cl\u00faster DCS<\/a><\/li><li><a href=\"#reinitialise-a-failed-standby\">Reiniciar un standby fallido<\/a><\/li><\/ul><\/li><li><a href=\"#16-synchronous-mode-zero-data-loss\">16. Modo Sincr\u00f3nico (Cero P\u00e9rdida de Datos)<\/a><\/li><li><a href=\"#17-monitoring\">17. Monitoreo<\/a><ul><li><a href=\"#patroni-rest-api\">API REST de Patroni<\/a><\/li><li><a href=\"#replication-lag\">Latencia de replicaci\u00f3n<\/a><\/li><\/ul><\/li><li><a href=\"#18-full-reset\">18. Restablecimiento completo<\/a><ul><li><a href=\"#step-1-stop-patroni-and-etcd-on-all-postgre-sql-nodes\">Paso 1 \u2014 Detenga Patroni y etcd en todos los nodos de PostgreSQL<\/a><\/li><li><a href=\"#step-2-wipe-etcd-and-postgre-sql-data-directories\">Paso 2 \u2014 Borrar los directorios de datos de etcd y PostgreSQL<\/a><\/li><li><a href=\"#step-3-recreate-directories-with-correct-ownership\">Paso 3 \u2014 Recrear directorios con la propiedad correcta<\/a><\/li><li><a href=\"#step-4-restore-acl-permissions-on-etcd-certificates\">Paso 4: Restaurar los permisos de ACL en los certificados de etcd<\/a><\/li><li><a href=\"#step-5-reset-etcd-initial-cluster-state-to-new\">Paso 5 \u2014 Restablecer ETCD_INITIAL_CLUSTER_STATE a \u201cnew\u201d<\/a><\/li><li><a href=\"#step-6-start-etcd-on-all-nodes\">Paso 6 \u2014 Iniciar etcd en todos los nodos<\/a><\/li><li><a href=\"#step-7-start-patroni-on-all-nodes\">Paso 7 \u2014 Iniciar Patroni en todos los nodos<\/a><\/li><\/ul><\/li><li><a href=\"#19-common-issues\">19. Problemas comunes<\/a><\/li><li><a href=\"#20-key-commands-reference\">20. Referencia de comandos clave<\/a><\/li><\/ul><\/nav><\/div>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"postgre-sql-high-availability-with-patroni\">Alta disponibilidad de PostgreSQL con Patroni<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Cubre la arquitectura de Patroni, la configuraci\u00f3n de TLS, la conmutaci\u00f3n por error, el cambio de roles y las operaciones diarias.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Medio ambiente<\/strong> Seis servidores en total. etcd se ejecuta en los mismos nodos que PostgreSQL; no hay servidores etcd dedicados.<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Rol<\/th><th>Nombre de host<\/th><th>PI<\/th><\/tr><\/thead><tbody><tr><td>Nodo HAProxy 1<\/td><td>haproxy-01<\/td><td>192.168.0.200<\/td><\/tr><tr><td>Nodo HAProxy 2<\/td><td>haproxy-02<\/td><td>192.168.0.201<\/td><\/tr><tr><td>nodo 3 HAProxy<\/td><td>haproxy-03<\/td><td>192.168.0.202<\/td><\/tr><tr><td>PostgreSQL + etcd + Patroni 1<\/td><td>postgres-01<\/td><td>192.168.0.203<\/td><\/tr><tr><td>PostgreSQL + etcd + Patroni 2<\/td><td>postgres-02<\/td><td>192.168.0.204<\/td><\/tr><tr><td>PostgreSQL + etcd + Patroni 3<\/td><td>postgres-03<\/td><td>192.168.0.205<\/td><\/tr><tr><td>IP Virtual (VIP)<\/td><td>\u2014<\/td><td>192.168.0.210<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"1-architecture\">1. Arquitectura<\/h2>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n          +----------+   +----------+   +----------+\n          |  etcd    |   |  etcd    |   |  etcd    |\n          | Patroni  |   | Patroni  |   | Patroni  |\n          |   +PG    |   |   +PG    |   |   +PG    |\n          | node1    |   | node2    |   | node3    |\n          | PRIMARY  |   | STANDBY  |   | STANDBY  |\n          +----------+   +----------+   +----------+\n                \\              |              \/\n                 \\             |             \/\n          +----------+   +----------+   +----------+\n          | HAProxy  |   | HAProxy  |   | HAProxy  |\n          |  node1   |   |  node2   |   |  node3   |\n          | (MASTER) |   | (BACKUP) |   | (BACKUP) |\n          +----------+   +----------+   +----------+\n                \\              |              \/\n                 \\             |             \/\n              &amp;#91;keepalived VIP: 192.168.0.210:5432]\n                              |\n                        Applications\n<\/pre><\/div>\n\n\n<ul class=\"wp-block-list\">\n<li><strong>etcd<\/strong>almac\u00e9n de clave-valor distribuido co-ubicado en cada nodo de PostgreSQL. Mantiene el estado del cl\u00faster (l\u00edder actual, lista de miembros). Requiere un n\u00famero impar de nodos para el qu\u00f3rum \u2014 3 nodos toleran 1 fallo, 5 nodos toleran 2.<\/li>\n\n\n\n<li><strong>Patroni<\/strong>: daemon en cada nodo de PostgreSQL. Administra la replicaci\u00f3n, monitorea la salud y coordina la conmutaci\u00f3n por error a trav\u00e9s de etcd.<\/li>\n\n\n\n<li><strong>HAProxy<\/strong>: three dedicated nodes route application connections to the current primary by checking Patroni&#8217;s REST API.<\/li>\n\n\n\n<li><strong>keepalived<\/strong>: gestiona la VIP usando VRRP. Un nodo HAProxy mantiene la VIP a la vez. Si ese nodo falla, la VIP se mueve autom\u00e1ticamente al siguiente nodo HAProxy.<\/li>\n\n\n\n<li><strong>Toda la comunicaci\u00f3n est\u00e1 cifrada con TLS<\/strong>: tr\u00e1fico de pares etcd, tr\u00e1fico de clientes etcd, API REST de Patroni y conexiones de PostgreSQL.<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"2-prerequisites\">2. Prerrequisitos<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"step-1-set-the-correct-timezone-on-all-nodes\">Paso 1 \u2014 Establecer la zona horaria correcta en todos los nodos<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Ejecutar en los 6 servidores (postgres-01\/02\/03 y haproxy-01\/02\/03). <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Los servidores usan UTC por defecto; config\u00faralo a tu zona horaria local antes que nada. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Las marcas de tiempo desajustadas o incorrectas causan confusi\u00f3n en los registros y la validaci\u00f3n de certificados.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\nsudo timedatectl set-timezone Europe\/Madrid\ntimedatectl\n# Expected: Time zone: Europe\/Madrid (CET\/CEST, +0100\/+0200)\n# NTP service should show: active\n# System clock synchronized: yes\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"step-2-confirm-required-ports-are-open\">Paso 2: confirme que los puertos requeridos est\u00e9n abiertos<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Antes de empezar, confirme que los siguientes puertos est\u00e1n abiertos:<\/p>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Fuente<\/th><th>Destino<\/th><th>Puerto<\/th><th>Prop\u00f3sito<\/th><\/tr><\/thead><tbody><tr><td>Nodos de PostgreSQL<\/td><td>Nodos de PostgreSQL<\/td><td>2379<\/td><td>Cliente de etcd (Patroni \u2192 etcd)<\/td><\/tr><tr><td>Nodos de PostgreSQL<\/td><td>Nodos de PostgreSQL<\/td><td>2380<\/td><td>comunicaci\u00f3n entre pares de etcd<\/td><\/tr><tr><td>Nodos de PostgreSQL<\/td><td>Nodos de PostgreSQL<\/td><td>5432<\/td><td>Replicaci\u00f3n de PostgreSQL<\/td><\/tr><tr><td>Nodos de PostgreSQL<\/td><td>Nodos de PostgreSQL<\/td><td>8008<\/td><td>API REST de Patroni<\/td><\/tr><tr><td>Nodos de HAProxy<\/td><td>Nodos de PostgreSQL<\/td><td>8008<\/td><td>Comprobaci\u00f3n de estado de HAProxy<\/td><\/tr><tr><td>Nodos de HAProxy<\/td><td>Nodos de HAProxy<\/td><td>112\/VRRP<\/td><td>elecci\u00f3n de VIP de keepalived<\/td><\/tr><tr><td>Solicitudes<\/td><td>VIP<\/td><td>5432<\/td><td>Conexiones de clientes<\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"3-postgre-sql-installation\">3. Instalaci\u00f3n de PostgreSQL<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"step-1-install-postgre-sql-on-all-3-postgre-sql-nodes\">Paso 1: Instalar PostgreSQL en los 3 nodos de PostgreSQL<\/h3>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01, postgres-02, postgres-03\nsudo apt update\nsudo apt install -y postgresql-common\n# postgresql-common: provides the PGDG repository setup script\n\nsudo \/usr\/share\/postgresql-common\/pgdg\/apt.postgresql.org.sh\n# This script adds the official PostgreSQL apt repository (postgresql.org)\n# and imports its GPG key \u2014 ensures you get the latest PostgreSQL version,\n# not the older version bundled with Ubuntu\n\nsudo apt update\nsudo apt install -y postgresql-18 postgresql-contrib-18\n# Install version 18 explicitly \u2014 the generic &quot;postgresql&quot; meta-package installs Ubuntu&#039;s\n# default bundled version (16) in addition to the PGDG version, leaving two versions installed\n# Always specify the version number to avoid this\n# postgresql-contrib-18: additional modules including pg_rewind, which Patroni uses\n# to resync the old primary after a failover without a full base backup\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"step-2-stop-and-disable-the-postgre-sql-service\">Paso 2 \u2014 Detener e inhabilitar el servicio de PostgreSQL<\/h3>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01, postgres-02, postgres-03\nsudo systemctl stop postgresql\n# Patroni manages PostgreSQL startup entirely\n# If PostgreSQL is already running when Patroni starts, Patroni will fail with:\n# &quot;postmaster is already running&quot;\n\nsudo systemctl disable postgresql\n# Prevents PostgreSQL from starting automatically on boot\n# Patroni&#039;s own systemd service starts PostgreSQL when the node joins the cluster\n<\/pre><\/div>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"4-etcd-installation\">4. Instalaci\u00f3n de etcd<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"step-1-install-etcd-on-all-3-postgre-sql-nodes\">Paso 1: Instala etcd en los 3 nodos de PostgreSQL<\/h3>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01, postgres-02, postgres-03\nsudo apt-get install -y wget curl\n\nwget https:\/\/github.com\/etcd-io\/etcd\/releases\/download\/v3.6.10\/etcd-v3.6.10-linux-amd64.tar.gz\n# Download the etcd binary directly from GitHub releases\n# The apt package is often outdated \u2014 always install from the official releases\n# Check https:\/\/github.com\/etcd-io\/etcd\/releases for the latest stable version\n\ntar xvf etcd-v3.6.10-linux-amd64.tar.gz\n# xvf: extract (x), verbose (v), from file (f)\n\nsudo mv etcd-v3.6.10-linux-amd64\/etcd \/usr\/local\/bin\/\nsudo mv etcd-v3.6.10-linux-amd64\/etcdctl \/usr\/local\/bin\/\n# etcd: the etcd server binary\n# etcdctl: the etcd client CLI \u2014 used for health checks and inspecting cluster state\n\n# Verify the installation\netcd --version\n# Expected: etcd Version: 3.6.10\n# If &quot;etcd: command not found&quot;: \/usr\/local\/bin is not in PATH \u2014 run: export PATH=$PATH:\/usr\/local\/bin\n\netcdctl version\n# Expected: etcdctl version: 3.6.10\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"step-2-create-the-etcd-system-user\">Paso 2 \u2014 Crear el usuario del sistema etcd<\/h3>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01, postgres-02, postgres-03\nsudo useradd --system --home \/var\/lib\/etcd --shell \/bin\/false etcd\n\n# --system: creates a system account with no login shell by default\n# --home \/var\/lib\/etcd: etcd stores its data here\n# --shell \/bin\/false: prevents interactive login \u2014 etcd runs as a daemon only\n<\/pre><\/div>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"5-tls-certificate-generation\">5. Generaci\u00f3n de Certificados TLS<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Todos los certificados se generan una vez en postgres-01 y luego se distribuyen a los otros nodos.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">La clave privada de CA (<code>ca.clave<\/code>) permanece en postgres-01 despu\u00e9s de que se completa la distribuci\u00f3n; no lo copie a otros nodos.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"step-1-create-the-working-directory\">Paso 1 \u2014 Crear el directorio de trabajo<\/h3>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01\nmkdir ~\/certs &amp;amp;&amp;amp; cd ~\/certs\n# All certificate files are created here before being copied to each node\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"step-2-generate-the-certificate-authority\">Paso 2 \u2014 Generar la Autoridad Certificadora<\/h3>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01\nopenssl genrsa -out ca.key 2048\n# genrsa: generate an RSA private key; 2048: key length in bits\n\nopenssl req -x509 -new -nodes -key ca.key -subj &quot;\/CN=etcd-ca&quot; -days 7300 -out ca.crt\n# req -x509: create a self-signed certificate (not a signing request)\n# -new -nodes: new certificate, no passphrase on the private key\n# -subj &quot;\/CN=etcd-ca&quot;: the certificate&#039;s Common Name \u2014 identifies this as the cluster CA\n# -days 7300: valid for 20 years\n# ca.crt: distributed to every node as the root of trust\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"step-3-generate-per-node-etcd-certificates\">Paso 3: Generar certificados etcd por nodo<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Each node gets its own certificate with its IP as a Subject Alternative Name (SAN). TLS hostname verification requires the server&#8217;s IP to appear in the SAN \u2014 without it, connections will fail.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01 \u2014 generate all three node certificates here, then distribute\n\n# Certificate for postgres-01 (192.168.0.203)\nopenssl genrsa -out etcd-node1.key 2048\n\ncat &amp;gt; temp.cnf &amp;lt;&amp;lt;EOF\n&amp;#91; req ]\ndistinguished_name = req_distinguished_name\nreq_extensions = v3_req\n&amp;#91; req_distinguished_name ]\n&amp;#91; v3_req ]\nsubjectAltName = @alt_names\n&amp;#91; alt_names ]\nIP.1 = 192.168.0.203\nIP.2 = 127.0.0.1\nEOF\n\n# temp.cnf: OpenSSL config that adds the node IP as a SAN\n# IP.2 = 127.0.0.1: allows etcdctl to connect locally without specifying a remote address\n\nopenssl req -new -key etcd-node1.key -out etcd-node1.csr \\\n  -subj &quot;\/CN=etcd-node1&quot; \\\n  -config temp.cnf\n# req -new: generate a certificate signing request (CSR)\n# -subj: the certificate&#039;s identity \u2014 CN identifies the node in logs\n\nopenssl x509 -req -in etcd-node1.csr -CA ca.crt -CAkey ca.key \\\n  -CAcreateserial -out etcd-node1.crt -days 7300 \\\n  -sha256 -extensions v3_req -extfile temp.cnf\n# x509 -req: sign the CSR with the CA to produce a certificate\n# -CAcreateserial: creates ca.srl to track serial numbers across certificates\n# -extensions v3_req -extfile temp.cnf: embed the SANs into the signed certificate\n\nopenssl x509 -in etcd-node1.crt -text -noout | grep -A1 &quot;Subject Alternative Name&quot;\n# Verify the SAN was embedded \u2014 Expected: IP Address:192.168.0.203, IP Address:127.0.0.1\n# If the SAN is missing: the -extensions and -extfile flags were not applied correctly\n\nrm temp.cnf\n\n# Certificate for postgres-02 (192.168.0.204)\nopenssl genrsa -out etcd-node2.key 2048\n\ncat &amp;gt; temp.cnf &amp;lt;&amp;lt;EOF\n&amp;#91; req ]\ndistinguished_name = req_distinguished_name\nreq_extensions = v3_req\n&amp;#91; req_distinguished_name ]\n&amp;#91; v3_req ]\nsubjectAltName = @alt_names\n&amp;#91; alt_names ]\nIP.1 = 192.168.0.204\nIP.2 = 127.0.0.1\nEOF\n\nopenssl req -new -key etcd-node2.key -out etcd-node2.csr \\\n  -subj &quot;\/CN=etcd-node2&quot; -config temp.cnf\n\nopenssl x509 -req -in etcd-node2.csr -CA ca.crt -CAkey ca.key \\\n  -CAcreateserial -out etcd-node2.crt -days 7300 \\\n  -sha256 -extensions v3_req -extfile temp.cnf\n\nopenssl x509 -in etcd-node2.crt -text -noout | grep -A1 &quot;Subject Alternative Name&quot;\n# Expected: IP Address:192.168.0.204, IP Address:127.0.0.1\nrm temp.cnf\n\n\n# Certificate for postgres-03 (192.168.0.205)\nopenssl genrsa -out etcd-node3.key 2048\n\ncat &amp;gt; temp.cnf &amp;lt;&amp;lt;EOF\n&amp;#91; req ]\ndistinguished_name = req_distinguished_name\nreq_extensions = v3_req\n&amp;#91; req_distinguished_name ]\n&amp;#91; v3_req ]\nsubjectAltName = @alt_names\n&amp;#91; alt_names ]\nIP.1 = 192.168.0.205\nIP.2 = 127.0.0.1\nEOF\n\nopenssl req -new -key etcd-node3.key -out etcd-node3.csr \\\n  -subj &quot;\/CN=etcd-node3&quot; -config temp.cnf\n\nopenssl x509 -req -in etcd-node3.csr -CA ca.crt -CAkey ca.key \\\n  -CAcreateserial -out etcd-node3.crt -days 7300 \\\n  -sha256 -extensions v3_req -extfile temp.cnf\n\nopenssl x509 -in etcd-node3.crt -text -noout | grep -A1 &quot;Subject Alternative Name&quot;\n# Expected: IP Address:192.168.0.205, IP Address:127.0.0.1\nrm temp.cnf\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"step-4-generate-the-postgre-sql-server-certificate\">Paso 4 \u2014 Generar el certificado del servidor PostgreSQL<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Un certificado compartido cubre todos los nodos de PostgreSQL. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Se utiliza tanto para conexiones PostgreSQL como para la API REST de Patroni.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01\nopenssl genrsa -out server.key 2048\n\nopenssl req -new -key server.key -out server.req\n# You will be prompted for certificate details \u2014 the Common Name is not critical\n# since connections are verified by IP SAN, not CN\n# Warning &quot;No -copy_extensions given&quot; is harmless \u2014 the server cert does not need SANs\n\nopenssl req -x509 -key server.key -in server.req -out server.crt -days 7300\n# Self-signed server certificate \u2014 signed directly with server.key, not the CA\n# Patroni and PostgreSQL use this certificate to identify themselves to clients\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"step-5-distribute-certificates-to-postgres-02-and-postgres-03\">Paso 5 \u2014 Distribuir certificados a postgres-02 y postgres-03<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">postgres-01 mantiene sus propios certificados en ~\/certs, no se necesita scp para \u00e9l.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01\nscp ~\/certs\/ca.crt ~\/certs\/etcd-node2.crt ~\/certs\/etcd-node2.key \\\n  ~\/certs\/server.crt ~\/certs\/server.key fernando@192.168.0.204:\/tmp\/\n\nscp ~\/certs\/ca.crt ~\/certs\/etcd-node3.crt ~\/certs\/etcd-node3.key \\\n  ~\/certs\/server.crt ~\/certs\/server.key fernando@192.168.0.205:\/tmp\/\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"step-6-install-certificates-on-each-postgre-sql-node\">Paso 6 \u2014 Instalar certificados en cada nodo de PostgreSQL<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Todos los certificados residen en <code>\/etc\/etcd\/certs\/<\/code> en cada nodo. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">El directorio pertenece a <code>etcd:etcd<\/code> as\u00ed que el demonio etcd puede leer sus certificados. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">En <code>postgres<\/code> el usuario obtiene acceso de lectura a trav\u00e9s de ACL para que Patroni pueda conectarse a etcd.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Importante:<\/strong> establecer los permisos del archivo antes de bloquear el directorio. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Despu\u00e9s <code>chmod 700<\/code> el shell no puede expandir globs dentro del directorio como un usuario no root; usa nombres de archivo expl\u00edcitos.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01, postgres-02, postgres-03\nsudo mkdir -p \/etc\/etcd\/certs\nsudo apt-get install -y acl\n# acl: provides setfacl \u2014 needed to grant postgres user access without changing ownership\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\"><strong>En postgres-01<\/strong> \u2014 copiar desde ~\/certs (los archivos nunca estuvieron en \/tmp en este nodo):<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\nsudo cp ~\/certs\/ca.crt \/etc\/etcd\/certs\/\nsudo cp ~\/certs\/etcd-node1.crt \/etc\/etcd\/certs\/\nsudo cp ~\/certs\/etcd-node1.key \/etc\/etcd\/certs\/\nsudo cp ~\/certs\/server.crt \/etc\/etcd\/certs\/\nsudo cp ~\/certs\/server.key \/etc\/etcd\/certs\/\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\"><strong>En postgres-02<\/strong> \u2014 mover desde \/tmp:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\nsudo mv \/tmp\/ca.crt \/etc\/etcd\/certs\/\nsudo mv \/tmp\/etcd-node2.crt \/etc\/etcd\/certs\/\nsudo mv \/tmp\/etcd-node2.key \/etc\/etcd\/certs\/\nsudo mv \/tmp\/server.crt \/etc\/etcd\/certs\/\nsudo mv \/tmp\/server.key \/etc\/etcd\/certs\/\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\"><strong>En postgres-03<\/strong> \u2014 mover desde \/tmp:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\nsudo mv \/tmp\/ca.crt \/etc\/etcd\/certs\/\nsudo mv \/tmp\/etcd-node3.crt \/etc\/etcd\/certs\/\nsudo mv \/tmp\/etcd-node3.key \/etc\/etcd\/certs\/\nsudo mv \/tmp\/server.crt \/etc\/etcd\/certs\/\nsudo mv \/tmp\/server.key \/etc\/etcd\/certs\/\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\"><strong>En los tres nodos<\/strong> \u2014 Establece permisos y luego bloquea el directorio:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">los certificados de etcd deben ser propiedad de <code>etcd<\/code> \u2014 el demonio de etcd se ejecuta como este usuario. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Los certificados del servidor deben ser propiedad de <code>postgres<\/code> \u2014 PostgreSQL exige que su clave privada SSL sea propiedad del usuario de la base de datos o de root. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Utilizando <code>etcd<\/code> La propiedad obligar\u00e1 a PostgreSQL a negarse a iniciarse con: \u201cel archivo de clave privada debe ser propiedad del usuario de la base de datos o de root\u201d.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# Set file permissions first \u2014 must happen before chmod 700 on the directory\n# After chmod 700, the shell cannot expand globs as a non-root user\n\n# etcd certs: owned by etcd\n# On postgres-01:\nsudo chown etcd:etcd \/etc\/etcd\/certs\/etcd-node1.crt \/etc\/etcd\/certs\/etcd-node1.key \/etc\/etcd\/certs\/ca.crt\nsudo chmod 600 \/etc\/etcd\/certs\/etcd-node1.key\nsudo chmod 644 \/etc\/etcd\/certs\/etcd-node1.crt \/etc\/etcd\/certs\/ca.crt\n\n# On postgres-02:\nsudo chown etcd:etcd \/etc\/etcd\/certs\/etcd-node2.crt \/etc\/etcd\/certs\/etcd-node2.key \/etc\/etcd\/certs\/ca.crt\nsudo chmod 600 \/etc\/etcd\/certs\/etcd-node2.key\nsudo chmod 644 \/etc\/etcd\/certs\/etcd-node2.crt \/etc\/etcd\/certs\/ca.crt\n\n# On postgres-03:\nsudo chown etcd:etcd \/etc\/etcd\/certs\/etcd-node3.crt \/etc\/etcd\/certs\/etcd-node3.key \/etc\/etcd\/certs\/ca.crt\nsudo chmod 600 \/etc\/etcd\/certs\/etcd-node3.key\nsudo chmod 644 \/etc\/etcd\/certs\/etcd-node3.crt \/etc\/etcd\/certs\/ca.crt\n\n# Server certs: owned by postgres (all three nodes \u2014 same files on each)\nsudo chown postgres:postgres \/etc\/etcd\/certs\/server.crt \/etc\/etcd\/certs\/server.key\nsudo chmod 600 \/etc\/etcd\/certs\/server.key\nsudo chmod 644 \/etc\/etcd\/certs\/server.crt\n\n# Lock the directory \u2014 run this last, after all file permissions are set\nsudo chown etcd:etcd \/etc\/etcd\/certs\nsudo chmod 700 \/etc\/etcd\/certs\n\n# Grant the postgres user read access to the directory and all files inside it\n# Patroni needs to read the etcd certs to connect with TLS\nsudo setfacl -R -m u:postgres:rX \/etc\/etcd\/certs\n# -R: apply recursively to all files; rX: read + execute on directories (to traverse)\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"step-7-create-the-combined-pem-file-for-patroni\">Paso 7: Crear el archivo PEM combinado para Patroni<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Patroni&#8217;s <code>restapi.certfile<\/code> espera un \u00fanico archivo que contenga tanto el certificado como la clave privada.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01, postgres-02, postgres-03\nsudo sh -c &#039;cat \/etc\/etcd\/certs\/server.crt \/etc\/etcd\/certs\/server.key \\\n  &amp;gt; \/etc\/etcd\/certs\/server.pem&#039;\n# Concatenates certificate then key into one file\nsudo chown postgres:postgres \/etc\/etcd\/certs\/server.pem\nsudo chmod 600 \/etc\/etcd\/certs\/server.pem\n# server.pem contains the private key \u2014 PostgreSQL requires 0600 and postgres ownership\n\n# Verify the PEM file is valid\nsudo openssl x509 -in \/etc\/etcd\/certs\/server.pem -text -noout\n# Expected: certificate details including validity dates and subject\n# If &quot;unable to load certificate&quot;: the PEM file is malformed \u2014 recreate it\n\n# Verify final permissions on all cert files\nsudo ls -la \/etc\/etcd\/certs\/\n# Expected output (postgres-01 shown \u2014 node number differs on 02\/03):\n#   drwx------+  2 etcd     etcd      ca.crt etcd-node1.crt etcd-node1.key server.crt server.key server.pem\n#   -rw-r--r--+  1 etcd     etcd      ca.crt\n#   -rw-r--r--+  1 etcd     etcd      etcd-node1.crt\n#   -rw-------+  1 etcd     etcd      etcd-node1.key\n#   -rw-r--r--+  1 postgres postgres  server.crt\n#   -rw-------+  1 postgres postgres  server.key   \u2190 must be 0600, postgres-owned\n#   -rw-------+  1 postgres postgres  server.pem   \u2190 must be 0600, postgres-owned\n# PostgreSQL will refuse to start if server.key or server.pem is group- or world-readable\n<\/pre><\/div>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"6-etcd-configuration\">6. Configuraci\u00f3n de etcd<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"step-1-create-the-etcd-data-directory\">Paso 1 \u2014 Cree el directorio de datos de etcd<\/h3>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01, postgres-02, postgres-03\nsudo mkdir -p \/var\/lib\/etcd\nsudo chown -R etcd:etcd \/var\/lib\/etcd\n# etcd stores its WAL and snapshot data here \u2014 must be owned by the etcd user\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"step-2-create-the-etcd-environment-file-on-each-node\">Paso 2: Cree el archivo de entorno de etcd en cada nodo<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">etcd est\u00e1 configurado mediante variables de entorno cargadas por el servicio systemd. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Solo los valores espec\u00edficos del nodo difieren entre los nodos.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# \/etc\/etcd\/etcd.env \u2014 postgres-01 (192.168.0.203)\n\nETCD_NAME=&quot;postgresql-01&quot;\n# ETCD_NAME: unique identifier for this member within the cluster\n\nETCD_DATA_DIR=&quot;\/var\/lib\/etcd&quot;\n# ETCD_DATA_DIR: where etcd stores its WAL and snapshots\n\nETCD_INITIAL_CLUSTER=&quot;postgresql-01=https:\/\/192.168.0.203:2380,postgresql-02=https:\/\/192.168.0.204:2380,postgresql-03=https:\/\/192.168.0.205:2380&quot;\n# ETCD_INITIAL_CLUSTER: all members at bootstrap time \u2014 must be identical on all three nodes\n\nETCD_INITIAL_CLUSTER_STATE=&quot;new&quot;\n# new: this is a fresh cluster bootstrap\n# Important: change to &quot;existing&quot; after the cluster is running (see section 9 step 3)\n\nETCD_INITIAL_CLUSTER_TOKEN=&quot;etcd-cluster&quot;\n# ETCD_INITIAL_CLUSTER_TOKEN: prevents nodes from accidentally joining the wrong cluster\n\nETCD_INITIAL_ADVERTISE_PEER_URLS=&quot;https:\/\/192.168.0.203:2380&quot;\n# ETCD_INITIAL_ADVERTISE_PEER_URLS: address this node advertises to other etcd members for peer traffic\n\nETCD_LISTEN_PEER_URLS=&quot;https:\/\/0.0.0.0:2380&quot;\n# ETCD_LISTEN_PEER_URLS: address etcd listens on for peer connections from other etcd members\n\nETCD_LISTEN_CLIENT_URLS=&quot;https:\/\/0.0.0.0:2379&quot;\n# ETCD_LISTEN_CLIENT_URLS: address etcd listens on for client connections (Patroni connects here)\n\nETCD_ADVERTISE_CLIENT_URLS=&quot;https:\/\/192.168.0.203:2379&quot;\n# ETCD_ADVERTISE_CLIENT_URLS: address this node advertises to clients \u2014 must be reachable from Patroni\n\n# TLS for client connections (Patroni \u2192 etcd)\nETCD_CLIENT_CERT_AUTH=&quot;true&quot;\n# ETCD_CLIENT_CERT_AUTH: require clients to present a valid certificate (mutual TLS)\nETCD_TRUSTED_CA_FILE=&quot;\/etc\/etcd\/certs\/ca.crt&quot;\n# ETCD_TRUSTED_CA_FILE: CA certificate used to verify client certificates\nETCD_CERT_FILE=&quot;\/etc\/etcd\/certs\/etcd-node1.crt&quot;\n# ETCD_CERT_FILE: certificate presented to clients connecting to this node\nETCD_KEY_FILE=&quot;\/etc\/etcd\/certs\/etcd-node1.key&quot;\n# ETCD_KEY_FILE: private key for the above certificate\n\n# TLS for peer connections (etcd node \u2194 etcd node)\nETCD_PEER_CLIENT_CERT_AUTH=&quot;true&quot;\n# ETCD_PEER_CLIENT_CERT_AUTH: require peer nodes to present a valid certificate\nETCD_PEER_TRUSTED_CA_FILE=&quot;\/etc\/etcd\/certs\/ca.crt&quot;\nETCD_PEER_CERT_FILE=&quot;\/etc\/etcd\/certs\/etcd-node1.crt&quot;\nETCD_PEER_KEY_FILE=&quot;\/etc\/etcd\/certs\/etcd-node1.key&quot;\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Para postgres-02 (192.168.0.204): cambiar <code>ETCD_NOMBRE<\/code> a <code>postgresql-02<\/code>, ambas direcciones IP a <code>192.168.0.204<\/code>, y los nombres de archivo del certificado\/la clave a <code>etcd-node2.crt<\/code> \/ <code>etcd-node2.key<\/code>. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Para postgres-03 (192.168.0.205): cambiar <code>ETCD_NOMBRE<\/code> a <code>postgresql-03<\/code>, ambas direcciones IP a <code>192.168.0.205<\/code>, y los nombres de archivo del certificado\/la clave a <code>etcd-node3.crt<\/code> \/ <code>etcd-node3.key<\/code>.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"step-3-create-the-etcd-systemd-service-file\">Paso 3: Crear el archivo de servicio systemd de etcd<\/h3>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01, postgres-02, postgres-03\n# Create \/etc\/systemd\/system\/etcd.service with the following content:\n<\/pre><\/div>\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n&amp;#91;Unit]\nDescription=etcd key-value store\nDocumentation=https:\/\/github.com\/etcd-io\/etcd\nAfter=network-online.target\nWants=network-online.target\n\n&amp;#91;Service]\nType=notify\n# Type=notify: systemd waits for etcd to send a readiness signal before marking it as started\nWorkingDirectory=\/var\/lib\/etcd\nEnvironmentFile=\/etc\/etcd\/etcd.env\n# EnvironmentFile: loads all ETCD_* variables from the file created in step 2\nExecStart=\/usr\/local\/bin\/etcd\nRestart=always\n# Restart=always: systemd restarts etcd if it exits for any reason\nRestartSec=10s\nLimitNOFILE=40000\n# LimitNOFILE: raise the open file descriptor limit \u2014 etcd opens many files under load\nUser=etcd\nGroup=etcd\n\n&amp;#91;Install]\nWantedBy=multi-user.target\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"step-4-start-etcd-on-all-3-nodes\">Paso 4 \u2014 Iniciar etcd en los 3 nodos<\/h3>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01, postgres-02, postgres-03\nsudo systemctl daemon-reload\n# daemon-reload: required after creating or modifying a systemd unit file\n\nsudo systemctl enable etcd\n# enable: start etcd automatically on boot\n\nsudo systemctl start etcd\n# start: start the etcd service now\n\nsudo systemctl status etcd\n# Expected: Active: active (running)\n# If &quot;Active: failed&quot;: check journalctl -xeu etcd.service for details\n# Common causes:\n#   - cert not found: verify paths in etcd.env match files in \/etc\/etcd\/certs\/\n#   - permission denied on key: run &quot;sudo chown etcd:etcd \/etc\/etcd\/certs\/*.key&quot;\n#   - port in use: run &quot;ss -tlnp | grep 237&quot; to find what is on ports 2379\/2380\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"step-5-verify-etcd-cluster-health\">Paso 5 \u2014 Verificar el estado del cl\u00faster de etcd<\/h3>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01\netcdctl endpoint health\n# Expected:\n#   127.0.0.1:2379 is healthy: successfully committed proposal: took = 2.3ms\n# This checks only the local node \u2014 the full cluster check is below\n\n# Full cluster health check with TLS credentials\nsudo etcdctl \\\n  --endpoints=https:\/\/192.168.0.203:2379,https:\/\/192.168.0.204:2379,https:\/\/192.168.0.205:2379 \\\n  --cacert=\/etc\/etcd\/certs\/ca.crt \\\n  --cert=\/etc\/etcd\/certs\/etcd-node1.crt \\\n  --key=\/etc\/etcd\/certs\/etcd-node1.key \\\n  endpoint health\n# --cacert: CA certificate to verify the server certificates\n# --cert \/ --key: client certificate and key for mutual TLS\n# Expected:\n#   https:\/\/192.168.0.203:2379 is healthy: successfully committed proposal: took = 2.3ms\n#   https:\/\/192.168.0.204:2379 is healthy: successfully committed proposal: took = 2.1ms\n#   https:\/\/192.168.0.205:2379 is healthy: successfully committed proposal: took = 2.4ms\n# If any node is unhealthy: check journalctl -u etcd on that node\n# If all nodes unhealthy: check firewall on port 2380 between nodes\n\n# Check leader election \u2014 one node should be the leader\nsudo etcdctl \\\n  --endpoints=https:\/\/192.168.0.203:2379,https:\/\/192.168.0.204:2379,https:\/\/192.168.0.205:2379 \\\n  --cacert=\/etc\/etcd\/certs\/ca.crt \\\n  --cert=\/etc\/etcd\/certs\/etcd-node1.crt \\\n  --key=\/etc\/etcd\/certs\/etcd-node1.key \\\n  endpoint status --write-out=table\n# Expected: a table with one node showing IS LEADER = true\n# If no leader: quorum is not established \u2014 verify all three nodes are running\n<\/pre><\/div>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"7-patroni-installation-and-configuration\">7. Instalaci\u00f3n y configuraci\u00f3n de Patroni<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"step-1-install-patroni\">Paso 1 - Instalar Patroni<\/h3>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01, postgres-02, postgres-03\nsudo apt install -y patroni\n# On Ubuntu 22.04+, the apt package includes the etcd v3 client library\n# If your distro&#039;s package does not include it: pip install patroni&amp;#91;etcd3]\n# &amp;#91;etcd3]: selects the etcd v3 API backend \u2014 required for etcd 3.5+\n# The older &amp;#91;etcd] flag uses the deprecated v2 HTTP API\n\nsudo mkdir -p \/etc\/patroni\/\n# Patroni reads its configuration from a YAML file in this directory\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"step-2-create-patroni-yml-on-each-node\">Paso 2 \u2014 Crear patroni.yml en cada nodo<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Solo <code>nombre<\/code>, <code>restapi.connect_address<\/code>, <code>postgresql.direcci\u00f3n_conexi\u00f3n<\/code>, y las rutas de certificado\/clave de etcd difieren entre los nodos.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# \/etc\/patroni\/config.yml \u2014 postgres-01 (192.168.0.203)\n\nscope: postgresql-cluster\n# scope: cluster name \u2014 must be identical on all Patroni nodes\n# Used as the key prefix in etcd to separate multiple Patroni clusters\n\nnamespace: \/service\/\n# namespace: etcd key prefix \u2014 all cluster state is stored under \/service\/postgresql-cluster\/\n\nname: postgresql-01\n# name: unique name for this node within the cluster\n\netcd3:\n  hosts: 192.168.0.203:2379,192.168.0.204:2379,192.168.0.205:2379\n  # etcd3: use the etcd v3 API (gRPC) \u2014 required for etcd 3.5+\n  # The older &quot;etcd:&quot; key uses the v2 HTTP API which is deprecated\n  protocol: https\n  # protocol: https \u2014 Patroni connects to etcd over TLS\n  cacert: \/etc\/etcd\/certs\/ca.crt\n  # cacert: CA certificate to verify the etcd server certificates\n  cert: \/etc\/etcd\/certs\/etcd-node1.crt\n  # cert: client certificate presented to etcd for mutual TLS\n  key: \/etc\/etcd\/certs\/etcd-node1.key\n  # key: private key for the client certificate\n\nrestapi:\n  listen: 0.0.0.0:8008\n  # listen: Patroni&#039;s REST API listens on all interfaces on port 8008\n  # HAProxy queries this API to determine which node is the current primary\n  connect_address: 192.168.0.203:8008\n  # connect_address: the address other nodes use to reach this node&#039;s REST API \u2014 must be the actual IP\n  certfile: \/etc\/etcd\/certs\/server.pem\n  # certfile: combined cert+key PEM file \u2014 enables TLS on the REST API\n  # HAProxy uses check-ssl when querying \/primary \u2014 the API must serve a certificate\n\nbootstrap:\n  dcs:\n    ttl: 30\n    # ttl: leader lease duration in seconds\n    # If the primary does not renew within ttl seconds, it is considered failed\n    # and a standby is promoted. Lower = faster failover; higher = more tolerance for slow networks.\n    loop_wait: 10\n    # loop_wait: how often Patroni checks cluster health (seconds)\n    retry_timeout: 10\n    # retry_timeout: how long Patroni retries a failed etcd or PostgreSQL operation before giving up\n    maximum_lag_on_failover: 1048576\n    # maximum_lag_on_failover: standbys more than 1MB behind the primary will not be promoted\n    # Prevents promoting a very stale standby that would cause significant data loss\n    postgresql:\n      parameters:\n        ssl: &#039;on&#039;\n        # ssl: enable TLS on PostgreSQL connections\n        ssl_cert_file: \/etc\/etcd\/certs\/server.crt\n        ssl_key_file: \/etc\/etcd\/certs\/server.key\n      pg_hba:\n        # pg_hba: Patroni writes this pg_hba.conf on bootstrap\n        # hostssl: TLS is required \u2014 plain connections are rejected\n        - hostssl replication replicator 127.0.0.1\/32 md5\n        - hostssl replication replicator 192.168.0.203\/32 md5\n        - hostssl replication replicator 192.168.0.204\/32 md5\n        - hostssl replication replicator 192.168.0.205\/32 md5\n        # Replication connections from all three PostgreSQL nodes\n        - hostssl all all 127.0.0.1\/32 md5\n        - hostssl all all 0.0.0.0\/0 md5\n        # Application connections \u2014 TLS required, password authentication\n\n  initdb:\n    - encoding: UTF8\n    - data-checksums\n    # data-checksums: enables page-level checksums \u2014 required for pg_rewind\n    # Detects corrupted blocks; slight write overhead (typically under 2%)\n\npostgresql:\n  listen: 0.0.0.0:5432\n  connect_address: 192.168.0.203:5432\n  # connect_address: this node&#039;s actual IP \u2014 used by standbys to connect for replication\n  data_dir: \/var\/lib\/postgresql\/data\n  # data_dir: PostgreSQL data directory \u2014 Patroni manages this directory entirely\n  bin_dir: \/usr\/lib\/postgresql\/18\/bin\n  # bin_dir: directory containing pg_ctl, pg_basebackup, pg_rewind, initdb\n  # Adjust the version number to match your PostgreSQL installation\n  authentication:\n    superuser:\n      username: postgres\n      password: strongpassword\n      # Patroni uses these credentials for internal management connections\n      # Change before production use\n    replication:\n      username: replicator\n      password: replpassword\n      # Patroni creates this role automatically during bootstrap\n      # Change before production use\n  parameters:\n    max_connections: 100\n    shared_buffers: 256MB\n    # shared_buffers: PostgreSQL&#039;s main memory cache \u2014 typically 25% of total RAM\n    # 256MB is a conservative default; increase based on available memory\n\ntags:\n  nofailover: false\n  # nofailover: set to true on a node you never want automatically promoted (e.g. a DR standby)\n  noloadbalance: false\n  # noloadbalance: set to true to exclude this node from read replica routing\n  clonefrom: false\n  nosync: false\n\nctl:\n  insecure: true\n  # insecure: skip TLS certificate verification when patronictl calls the Patroni REST API\n  # Required for switchover and failover commands \u2014 without it patronictl fails with an SSL error\n  # Read-only commands (patronictl list) work without this because they use etcd, not the REST API\n  # Do NOT add cacert or certfile here \u2014 a server certfile causes a bad TLS handshake\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Para postgres-02 (192.168.0.204): cambiar <code>nombre<\/code> a <code>postgresql-02<\/code>, ambos <code>direcci\u00f3n_conexi\u00f3n<\/code> valores a <code>192.168.0.204<\/code>, y cert\/clave de etcd a <code>etcd-node2.crt<\/code> \/ <code>etcd-node2.key<\/code>. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Para postgres-03 (192.168.0.205): cambiar <code>nombre<\/code> a <code>postgresql-03<\/code>, ambos <code>direcci\u00f3n_conexi\u00f3n<\/code> valores a <code>192.168.0.205<\/code>, y cert\/clave de etcd a <code>etcd-node3.crt<\/code> \/ <code>etcd-node3.key<\/code>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"8-starting-the-cluster\">8. Inicio del cl\u00faster<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"step-1-start-patroni-on-postgres-01-first\">Paso 1 \u2014 Inicie Patroni en postgres-01 primero<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">El primario previsto debe comenzar primero. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Si un standby se inicia antes de que exista un primario en etcd, espera; no causa un error. Iniciar primero el primario evita una carrera de elecci\u00f3n de l\u00edder de tres v\u00edas.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01\nsudo systemctl enable patroni\n# enable: start Patroni automatically on boot\nsudo systemctl restart patroni\n# Patroni initialises the PostgreSQL data directory (initdb), starts PostgreSQL,\n# acquires the leader lease in etcd, and configures itself as primary\n\njournalctl -u patroni -f\n# -f: follow \u2014 stream new log lines as they appear\n# Watch for: &quot;promoted self to leader&quot; \u2014 confirms postgres-01 is the primary\n# Press Ctrl+C to stop following once confirmed\n# If &quot;could not connect to etcd&quot;: verify etcd is running and TLS certs are correct\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"step-2-verify-postgres-01-is-the-leader\">Paso 2: Verifica que postgres-01 sea el l\u00edder<\/h3>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01\n# patronictl reads ca.crt from \/etc\/etcd\/certs\/ \u2014 that directory is mode 700.\n# Run with sudo, or it will fail with an SSL certificate load error.\nsudo patronictl -c \/etc\/patroni\/config.yml list\n# Expected:\n# + Cluster: postgresql-cluster +----+-----------+\n# | Member        | Host              | Role   | State   | TL | Lag in MB |\n# +---------------+-------------------+--------+---------+----+-----------+\n# | postgresql-01 | 192.168.0.203    | Leader | running |  1 |           |\n# +---------------+-------------------+--------+---------+----+-----------+\n# If State is &quot;start failed&quot;: check journalctl -u patroni for the PostgreSQL error\n# If no Leader after 30 seconds: etcd health check (section 7 step 5)\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"step-3-start-patroni-on-postgres-02-and-postgres-03\">Paso 3: Iniciar Patroni en postgres-02 y postgres-03<\/h3>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-02 and postgres-03\nsudo systemctl enable patroni &amp;amp;&amp;amp; sudo systemctl restart patroni\n# Patroni detects the existing primary in etcd, runs pg_basebackup from postgres-01,\n# and starts PostgreSQL as a streaming standby\n\n# Verify all three nodes\nsudo patronictl -c \/etc\/patroni\/config.yml list\n# Expected:\n# + Cluster: postgresql-cluster -----+----+-----------+\n# | Member        | Host           | Role    | State   | TL | Lag in MB |\n# +---------------+----------------+---------+---------+----+-----------+\n# | postgresql-01 | 192.168.0.203 | Leader  | running |  1 |           |\n# | postgresql-02 | 192.168.0.204 | Replica | running |  1 |         0 |\n# | postgresql-03 | 192.168.0.205 | Replica | running |  1 |         0 |\n# +---------------+----------------+---------+---------+----+-----------+\n# Lag in MB = 0: standbys are fully caught up with the primary\n# If a node shows &quot;stopped&quot;: check journalctl -u patroni on that node\n# If pg_basebackup failed: verify port 5432 is open between nodes\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"step-4-change-initial-cluster-state-to-existing-on-all-nodes\">Paso 4: Cambiar initial-cluster-state a existing en todos los nodos<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Tras un arranque exitoso, <code>estado-inicial-del-cl\u00faster<\/code> debe ser cambiado de <code>nuevo<\/code> a <code>existente<\/code>. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Esto evita que un nodo inicie accidentalmente un cl\u00faster nuevo si se reinicia de forma aislada m\u00e1s tarde.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01, postgres-02, postgres-03\n# Edit \/etc\/etcd\/etcd.env and change:\n#   ETCD_INITIAL_CLUSTER_STATE=&quot;new&quot;\n# to:\n#   ETCD_INITIAL_CLUSTER_STATE=&quot;existing&quot;\n\nsudo systemctl restart etcd\n# Restart to apply the change\n# Expected: etcd rejoins the existing cluster cleanly\n# Verify: run the endpoint health command from section 7 step 5\n<\/pre><\/div>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"9-ha-proxy-setup\">9. Configuraci\u00f3n de HAProxy<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">HAProxy routes all application connections to the current primary by querying Patroni&#8217;s REST API. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Solo los principales devuelven HTTP 200 en <code>\/primario<\/code> \u2014 los standbys devuelven HTTP 503. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Tres nodos HAProxy proporcionan redundancia; keepalived (secci\u00f3n 11) mueve el VIP entre ellos.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"step-1-install-ha-proxy\">Paso 1 \u2014 Instalar HAProxy<\/h3>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On haproxy-01, haproxy-02, haproxy-03\nsudo apt install -y haproxy\n\nhaproxy -v\n# Expected: HAProxy version 2.x.x\n# If an older version: add the HAProxy apt repository for the latest version\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"step-2-configure-ha-proxy\">Paso 2 \u2014 Configurar HAProxy<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">La configuraci\u00f3n es id\u00e9ntica en los tres nodos HAProxy.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# \/etc\/haproxy\/haproxy.cfg\n\nfrontend postgres_frontend\n    bind *:5432\n    mode tcp\n    # mode tcp: HAProxy forwards raw TCP \u2014 PostgreSQL is not an HTTP protocol\n    timeout client 30s\n    # timeout client belongs in the frontend only \u2014 HAProxy will warn and ignore it in a backend\n    default_backend postgres_backend\n\nbackend postgres_backend\n    mode tcp\n    option tcp-check\n    option httpchk GET \/primary\n    # httpchk: HAProxy queries this endpoint on each node&#039;s Patroni REST API\n    # Only the current primary returns HTTP 200 on \/primary; standbys return 503\n    # This is how HAProxy knows which node to route write traffic to\n    http-check expect status 200\n    timeout connect 5s\n    # timeout connect and timeout server belong in the backend only\n    timeout server 30s\n    server postgresql-01 192.168.0.203:5432 port 8008 check check-ssl verify none\n    server postgresql-02 192.168.0.204:5432 port 8008 check check-ssl verify none\n    server postgresql-03 192.168.0.205:5432 port 8008 check check-ssl verify none\n    # port 8008: HAProxy checks the Patroni REST API port, not the PostgreSQL port\n    # check-ssl: use TLS when querying the REST API (Patroni REST API has TLS enabled)\n    # verify none: skip certificate CN verification \u2014 acceptable in a private cluster\n    # where nodes are identified by IP\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"step-3-validate-config-and-reload-ha-proxy\">Paso 3: Validar configuraci\u00f3n y recargar HAProxy<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Siempre valida la configuraci\u00f3n antes de recargar. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">HAProxy se negar\u00e1 a recargar si la configuraci\u00f3n tiene errores.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On haproxy-01, haproxy-02, haproxy-03\nsudo haproxy -c -f \/etc\/haproxy\/haproxy.cfg\n# -c: check config only, do not start\n# -f: path to config file\n# Expected: Configuration file is valid\n# If you see warnings about timeout client\/server in the wrong section,\n# check that timeout client is in the frontend and timeout connect\/server are in the backend\n<\/pre><\/div>\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On haproxy-01, haproxy-02, haproxy-03\nsudo systemctl reload haproxy\n# reload: applies the new configuration without dropping existing connections\n# Expected: no output on the terminal; confirm success in the logs below\n<\/pre><\/div>\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\nsudo journalctl -u haproxy --since &quot;1 minute ago&quot;\n# Expected lines (timestamps will differ):\n#   systemd&amp;#91;1]: Reloading haproxy.service - HAProxy Load Balancer...\n#   systemd&amp;#91;1]: Reloaded haproxy.service - HAProxy Load Balancer.\n# If you see &quot;Failed&quot;: check sudo haproxy -c output first\n<\/pre><\/div>\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\nsudo tail -f \/var\/log\/syslog | grep haproxy\n# Watch the HAProxy logs to confirm it is checking the PostgreSQL nodes\n# Expected: repeated health check lines; one node should show &quot;UP&quot; (the primary)\n# If all nodes show &quot;DOWN&quot;: HAProxy cannot reach port 8008 \u2014 check firewall\n<\/pre><\/div>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"10-keepalived-setup\">10. Configuraci\u00f3n de keepalived<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">keepalived gestiona la VIP usando VRRP. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Un nodo HAProxy tiene la VIP (el MAESTRO). <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If the MASTER&#8217;s health check fails or the node goes down, keepalived on a BACKUP node claims the VIP. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Las aplicaciones siempre se conectan al VIP - Los fallos del nodo HAProxy son transparentes.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"step-1-install-keepalived\">Paso 1 \u2014 Instalar keepalived<\/h3>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On haproxy-01, haproxy-02, haproxy-03\nsudo apt update &amp;amp;&amp;amp; sudo apt install -y keepalived\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"step-2-create-the-ha-proxy-health-check-script\">Paso 2 \u2014 Crear el script de verificaci\u00f3n de estado de HAProxy<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">keepalived ejecuta este script cada 2 segundos. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Un c\u00f3digo de salida distinto de cero le indica a keepalived que este nodo no debe mantener el VIP.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On haproxy-01, haproxy-02, haproxy-03\n# Create \/etc\/keepalived\/check_haproxy.sh with the following content:\n<\/pre><\/div>\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n#!\/bin\/bash\n\nPORT=5432\n\nif ! pidof haproxy &amp;gt; \/dev\/null; then\n    # pidof haproxy: checks whether the HAProxy process is running\n    echo &quot;HAProxy is not running&quot;\n    exit 1\n    # exit 1: non-zero tells keepalived this node is unhealthy \u2014 VIP moves to a BACKUP\nfi\n\nif ! ss -ltn | grep -q &quot;:${PORT}&quot;; then\n    # ss -ltn: list listening TCP sockets; grep checks whether port 5432 is bound\n    echo &quot;HAProxy is not listening on port ${PORT}&quot;\n    exit 2\nfi\n\nexit 0\n# exit 0: tells keepalived this node is healthy and should keep (or receive) the VIP\n<\/pre><\/div>\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On haproxy-01, haproxy-02, haproxy-03\nsudo useradd -r -s \/bin\/false keepalived_script\n# -r: system account; -s \/bin\/false: no interactive login\n# keepalived runs health check scripts as this user when enable_script_security is active\n\nsudo chmod +x \/etc\/keepalived\/check_haproxy.sh\nsudo chown keepalived_script:keepalived_script \/etc\/keepalived\/check_haproxy.sh\nsudo chmod 700 \/etc\/keepalived\/check_haproxy.sh\n# 700: owner execute-only \u2014 keepalived requires the script not be writable by other users\n# when enable_script_security is active (configured in keepalived.conf below)\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"step-3-configure-keepalived\">Paso 3: Configurar keepalived<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Cada nodo tiene un diferente <code>estado<\/code> y <code>prioridad<\/code>. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">El MAESTRO (prioridad 100) mantiene inicialmente el VIP. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Si falla, el BACKUP con la siguiente prioridad m\u00e1s alta (90) reclama el VIP.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# \/etc\/keepalived\/keepalived.conf \u2014 haproxy-01 (MASTER)\n\nglobal_defs {\n    enable_script_security\n    # enable_script_security: prevents keepalived from running scripts owned by root\n    # or writable by anyone \u2014 prevents privilege escalation through health check scripts\n    script_user keepalived_script\n    # script_user: the OS user keepalived uses to execute health check scripts\n}\n\nvrrp_script check_haproxy {\n    script &quot;\/etc\/keepalived\/check_haproxy.sh&quot;\n    interval 2\n    # interval: run the health check every 2 seconds\n    fall 3\n    # fall: mark this node as failed after 3 consecutive failing checks (6 seconds total)\n    rise 2\n    # rise: mark this node as recovered after 2 consecutive passing checks (4 seconds)\n}\n\nvrrp_instance VI_1 {\n    state MASTER\n    # MASTER: this node holds the VIP initially on startup\n    interface enp0s3\n    # interface: the network interface where the VIP is assigned\n    # Run &quot;ip link show&quot; to find your interface name \u2014 may be ens3, enp0s3, eth0, etc.\n    # In this lab the interface is enp0s3 (VirtualBox default)\n    virtual_router_id 51\n    # virtual_router_id: identifies this VRRP group \u2014 must be identical on all three nodes\n    priority 100\n    # priority: the node with the highest priority wins the VIP election\n    # haproxy-01 wins by default (100 &amp;gt; 90 &amp;gt; 80)\n    advert_int 1\n    # advert_int: send VRRP advertisements every 1 second\n    authentication {\n        auth_type PASS\n        auth_pass changeme123\n        # auth_pass: shared password between all keepalived nodes \u2014 change before production use\n    }\n    virtual_ipaddress {\n        192.168.0.210\n        # The VIP \u2014 applications connect to this address on port 5432\n    }\n    track_script {\n        check_haproxy\n        # track_script: tie this VRRP instance to the HAProxy health check\n        # If the script exits non-zero, this node&#039;s effective priority drops\n        # below the BACKUPs and the VIP moves\n    }\n}\n<\/pre><\/div>\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# \/etc\/keepalived\/keepalived.conf \u2014 haproxy-02 (BACKUP, second priority)\n# Identical to haproxy-01 except:\n#   state BACKUP\n#   priority 90\n<\/pre><\/div>\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# \/etc\/keepalived\/keepalived.conf \u2014 haproxy-03 (BACKUP, lowest priority)\n# Identical to haproxy-01 except:\n#   state BACKUP\n#   priority 80\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"step-4-start-keepalived\">Paso 4 \u2014 Iniciar keepalived<\/h3>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On haproxy-01, haproxy-02, haproxy-03\nsudo systemctl enable --now keepalived\n<\/pre><\/div>\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\nsudo journalctl -u keepalived -f\n# Watch the keepalived logs \u2014 expected to see VRRP state transitions\n# haproxy-01 should log: &quot;(VI_1) Entering BACKUP STATE&quot; then &quot;(VI_1) Entering MASTER STATE&quot;\n#   (briefly enters BACKUP on startup, wins election after ~4 seconds due to priority 100)\n# haproxy-02 and haproxy-03 should log: &quot;(VI_1) Entering BACKUP STATE&quot;\n# Press Ctrl+C to stop following\n#\n# NOTE: &quot;Truncating auth_pass to 8 characters&quot; is a warning, not an error.\n# keepalived only uses the first 8 characters of auth_pass regardless of what you set.\n# This is fine as long as all nodes use the same password string \u2014 they will all truncate identically.\n<\/pre><\/div>\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# Verify the VIP is active on haproxy-01\nping -c 3 192.168.0.210\n# Expected: 3 packets transmitted, 3 received\n# If 0 received: VIP is not assigned to any node \u2014 check keepalived logs on all three HAProxy nodes\n<\/pre><\/div>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"11-set-the-postgres-superuser-password\">11. Establecer la contrase\u00f1a de superusuario de postgres<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Patroni gestiona su propio <code>pg_hba.conf<\/code> en <code>\/var\/lib\/postgresql\/data\/pg_hba.conf<\/code> \u2014 no la ubicaci\u00f3n predeterminada del paquete en <code>\/etc\/postgresql\/18\/main\/pg_hba.conf<\/code>. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Patroni&#8217;s file contains only <code>hostssl<\/code> entradas y ninguna entrada de socket Unix local. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Esto significa <code>sudo -u postgres psql<\/code> fallar\u00e1 hasta que se a\u00f1ada una entrada local.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01 \u2014 check Patroni&#039;s actual pg_hba.conf\nsudo cat \/var\/lib\/postgresql\/data\/pg_hba.conf\n# Expected: &quot;Do not edit this file manually! It will be overwritten by Patroni!&quot;\n# followed by hostssl entries only \u2014 no local socket entry\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">A\u00f1ade una entrada local temporal para que el usuario de SO postgres pueda conectarse mediante socket:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01\necho &quot;local all postgres peer&quot; | sudo tee -a \/var\/lib\/postgresql\/data\/pg_hba.conf\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Recargue la configuraci\u00f3n usando pg_ctl \u2014 pg_reload_conf() no se puede usar porque a\u00fan no hay una conexi\u00f3n de socket:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01\nsudo -u postgres \/usr\/lib\/postgresql\/18\/bin\/pg_ctl reload -D \/var\/lib\/postgresql\/data\n# Expected: server signaled\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Establecer la contrase\u00f1a:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01\nsudo -u postgres psql -c &quot;ALTER USER postgres PASSWORD &#039;strongpassword&#039;;&quot;\n# Expected: ALTER ROLE\n# IMPORTANT: this password must match postgresql.authentication.superuser.password in config.yml\n# If they differ, Patroni cannot connect to its local PostgreSQL instance and will report\n# unknown LSN \u2014 the node will appear stuck after a switchover or restart\n# The password is now stored in the database \u2014 it will survive Patroni overwriting pg_hba.conf\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Patroni sobrescribir\u00e1 <code>pg_hba.conf<\/code> en su pr\u00f3ximo ciclo y eliminar la entrada local; eso es esperado y est\u00e1 bien. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">La contrase\u00f1a persiste en el cat\u00e1logo de la base de datos sin importar qu\u00e9.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"12-verify-the-full-stack\">12. Verificar la pila completa<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Ejecute estas comprobaciones en orden despu\u00e9s de completar todos los pasos de configuraci\u00f3n.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# Check 1: etcd cluster is healthy (run on postgres-01)\nsudo etcdctl \\\n  --endpoints=https:\/\/192.168.0.203:2379,https:\/\/192.168.0.204:2379,https:\/\/192.168.0.205:2379 \\\n  --cacert=\/etc\/etcd\/certs\/ca.crt \\\n  --cert=\/etc\/etcd\/certs\/etcd-node1.crt \\\n  --key=\/etc\/etcd\/certs\/etcd-node1.key \\\n  endpoint health\n# Expected: all three nodes report &quot;is healthy&quot;\n\n# Check 2: Patroni cluster has a leader and two replicas (run on any PostgreSQL node)\nsudo patronictl -c \/etc\/patroni\/config.yml list\n# Expected: one Leader, two Replica, all State = running, Lag = 0\n\n# Check 3: VIP is responding\nping -c 3 192.168.0.210\n# Expected: 3 packets received\n# If 0 received: VIP not assigned \u2014 check keepalived on all HAProxy nodes\n\n# Check 4: PostgreSQL is reachable through the VIP\npsql -h 192.168.0.210 -U postgres -c &quot;SELECT inet_server_addr(), pg_is_in_recovery();&quot;\n# Expected: inet_server_addr = 192.168.0.203 (the primary), pg_is_in_recovery = f\n# pg_is_in_recovery = f: confirms this is the primary (standbys return t)\n# If connection refused: HAProxy is not routing \u2014 check haproxy status and \/primary endpoint\n\n# Check 5: replication is streaming\npsql -h 192.168.0.210 -U postgres -c &quot;SELECT client_addr, replay_lag FROM pg_stat_replication;&quot;\n# Expected: two rows (one per standby), replay_lag = 00:00:00 or NULL (caught up)\n# If no rows: standbys are not streaming \u2014 check Patroni logs on standby nodes\n\n# Check 6: insert data on primary, read it from both standbys\npsql -h 192.168.0.210 -U postgres -c &quot;CREATE TABLE test (id serial, val text);&quot;\npsql -h 192.168.0.210 -U postgres -c &quot;INSERT INTO test (val) VALUES (&#039;replication works&#039;);&quot;\npsql -h 192.168.0.204 -U postgres -c &quot;SELECT * FROM test;&quot;\n# Expected: one row with val = &#039;replication works&#039;\n# Connects directly to postgres-02 (a standby) to confirm the data replicated\n# If &quot;relation does not exist&quot;: replication is not running \u2014 check pg_stat_replication\npsql -h 192.168.0.205 -U postgres -c &quot;SELECT * FROM test;&quot;\n# Expected: same row \u2014 confirms postgres-03 is also replicating\npsql -h 192.168.0.210 -U postgres -c &quot;DROP TABLE test;&quot;\n# Clean up the test table\n<\/pre><\/div>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"13-switchover-planned\">13. Cambio (planificado)<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Un cambio de rol traslada el rol principal a un standby espec\u00edfico sin p\u00e9rdida de datos. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Patroni espera a que el candidato en espera se ponga al d\u00eda por completo antes de promover.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong>Requisito previo <code>ctl:<\/code> secci\u00f3n en config.yml:<\/strong> En <code>ctl:<\/code> la secci\u00f3n debe estar presente con <code>inseguro: verdadero<\/code> antes de ejecutar el cambio. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Sin \u00e9l, patronictl no puede autenticarse en la API REST de Patroni y el cambio de roles fallar\u00e1 con un error SSL. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Comandos de solo lectura como <code>patronictl lista<\/code> no requieren la API REST (usan etcd), por lo que la secci\u00f3n faltante no es obvia hasta que intentas una operaci\u00f3n de escritura.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# Verify the ctl section is present on all PostgreSQL nodes before attempting switchover\nsudo tail -5 \/etc\/patroni\/config.yml\n# Expected:\n#   ctl:\n#     insecure: true\n# If missing: add it and reload patroni (sudo systemctl reload patroni)\n# NOTE: do not add cacert or certfile to the ctl section \u2014 only insecure: true\n# Adding a server certfile causes a bad TLS handshake and switchover still fails\n<\/pre><\/div>\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On any PostgreSQL node\nsudo patronictl -c \/etc\/patroni\/config.yml switchover postgresql-cluster \\\n  --leader postgresql-01 \\\n  # --leader: the current primary being demoted\n  # NOTE: older Patroni versions used --master here \u2014 newer versions use --leader\n  --candidate postgresql-02\n  # --candidate: the standby being promoted\n# patronictl will show the current topology and ask for confirmation \u2014 press Enter for &quot;now&quot;, then y\n\n# Patroni performs these steps automatically:\n# 1. Pauses writes on the primary (checkpoint)\n# 2. Waits for the candidate to confirm it has applied all WAL\n# 3. Demotes the current primary to standby\n# 4. Promotes the candidate to primary\n# 5. Reconfigures the old primary as a standby using pg_rewind\n<\/pre><\/div>\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\nsudo patronictl -c \/etc\/patroni\/config.yml list\n# Expected: postgresql-02 now shows Role = Leader on a new timeline; postgresql-01 shows Role = Replica\n# postgresql-01 may briefly show &quot;stopped&quot; \u2014 this is pg_rewind running; wait 10 seconds and recheck\n# If postgresql-01 stays &quot;stopped&quot;: pg_rewind failed\n#   Fix: sudo patronictl -c \/etc\/patroni\/config.yml reinit postgresql-cluster postgresql-01\n<\/pre><\/div>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"14-failover-unplanned\">14. Conmutaci\u00f3n por error (no planificada)<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Cuando el primario falla, Patroni lo detecta autom\u00e1ticamente:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li>El primario no renueva su contrato de l\u00edder en etcd dentro de <code>ttl<\/code> segundos (30 por defecto)<\/li>\n\n\n\n<li>Patroni en los nodos restantes realiza una elecci\u00f3n en etcd<\/li>\n\n\n\n<li>El modo de espera con menos latencia que est\u00e1 dentro <code>retraso_m\u00e1ximo_en_conmutaci\u00f3n_por_error<\/code> es elegido y ascendido<\/li>\n\n\n\n<li>Otros repuestos se reconectan al nuevo principal usando <code>pg_rewind<\/code><\/li>\n<\/ol>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"simulate-a-primary-failure\">Simular una falla primaria<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Detener Patroni en el primario actual para simular un fallo. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Patroni administra PostgreSQL; detener Patroni tambi\u00e9n detiene PostgreSQL en ese nodo.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01 (the current primary)\nsudo systemctl stop patroni\n# This simulates a primary crash \u2014 PostgreSQL stops and the leader lease expires\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">En postgres-02 o postgres-03, observe la conmutaci\u00f3n por error autom\u00e1tica:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-02 or postgres-03\nwatch -n2 &quot;sudo patronictl -c \/etc\/patroni\/config.yml list&quot;\n# Expected sequence over ~30 seconds (ttl):\n# 1. postgresql-01 disappears or shows &quot;stopped&quot;\n# 2. One of the remaining nodes shows Leader on a new timeline\n# 3. The other remaining node shows Replica streaming\n# Press Ctrl+C when the new leader is confirmed\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Restablecer postgres-01<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01\nsudo systemctl start patroni\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Comprueba el cl\u00faster \u2014 postgres-01 deber\u00eda volver a unirse como r\u00e9plica:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\nsudo patronictl -c \/etc\/patroni\/config.yml list\n# Expected: postgres-01 shows Replica streaming, Lag = 0\n# If postgres-01 shows &quot;start failed&quot;: check sudo journalctl -u patroni -n 30 --no-pager\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Vuelve a poner postgres-01 como el principal cuando est\u00e9 listo:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\nsudo patronictl -c \/etc\/patroni\/config.yml switchover postgresql-cluster \\\n  --leader &amp;lt;new-leader&amp;gt; \\\n  --candidate postgresql-01\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"manual-failover-use-only-when-automatic-failover-has-not-triggered\">Conmutaci\u00f3n por error manual: use solo cuando la conmutaci\u00f3n por error autom\u00e1tica no se haya activado<\/h3>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\nsudo patronictl -c \/etc\/patroni\/config.yml failover postgresql-cluster \\\n  --leader postgresql-01 \\\n  --candidate postgresql-02 \\\n  --force\n  # --force: skip the confirmation prompt\n  # Use only when the primary is confirmed down\n  # Without --force, patronictl prompts you to confirm before proceeding\n<\/pre><\/div>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"15-day-to-day-operations\">15. Operaciones del d\u00eda a d\u00eda<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"check-cluster-status\">Verificar estado del cl\u00faster<\/h3>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\nsudo patronictl -c \/etc\/patroni\/config.yml list\n# Shows all members, roles (Leader\/Replica), state (running\/stopped\/start failed),\n# timeline, and replication lag in MB\n# Run this first whenever diagnosing any cluster issue\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"pause-and-resume-automatic-failover\">Pausar y reanudar la conmutaci\u00f3n por error autom\u00e1tica<\/h3>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# Pause \u2014 Patroni will not promote any standby while paused\n# Use during planned maintenance to prevent accidental failover\nsudo patronictl -c \/etc\/patroni\/config.yml pause postgresql-cluster\n\n# Resume \u2014 restore automatic failover\nsudo patronictl -c \/etc\/patroni\/config.yml resume postgresql-cluster\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"restart-postgre-sql-on-a-node\">Reiniciar PostgreSQL en un nodo<\/h3>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# Always restart PostgreSQL through patronictl \u2014 never directly via systemctl\n# Running &quot;systemctl restart postgresql&quot; bypasses Patroni and causes inconsistent state\nsudo patronictl -c \/etc\/patroni\/config.yml restart postgresql-cluster postgresql-02\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"reload-configuration\">Recargar configuraci\u00f3n<\/h3>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# Apply postgresql.conf changes across all nodes without a restart\nsudo patronictl -c \/etc\/patroni\/config.yml reload postgresql-cluster\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"edit-cluster-dcs-configuration\">Editar configuraci\u00f3n del cl\u00faster DCS<\/h3>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# Edit settings stored in etcd (ttl, maximum_lag_on_failover, synchronous_mode, etc.)\nsudo patronictl -c \/etc\/patroni\/config.yml edit-config postgresql-cluster\n# Opens the current configuration in your $EDITOR\n# Changes take effect immediately after saving \u2014 no restart required\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"reinitialise-a-failed-standby\">Reiniciar un standby fallido<\/h3>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# If pg_rewind fails after a failover, wipe and rebuild the standby from the primary\nsudo patronictl -c \/etc\/patroni\/config.yml reinit postgresql-cluster postgresql-03\n# Wipes data_dir on postgresql-03 and runs pg_basebackup from the current primary\n# Wipes the standby and rebuilds it from the current primary\n<\/pre><\/div>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"16-synchronous-mode-zero-data-loss\">16. Modo Sincr\u00f3nico (Cero P\u00e9rdida de Datos)<\/h2>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\nsudo patronictl -c \/etc\/patroni\/config.yml edit-config postgresql-cluster\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">A\u00f1adir o actualizar:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\nsynchronous_mode: true\n# true: commits on the primary wait for at least one standby to confirm before returning\n# Patroni sets synchronous_standby_names automatically \u2014 no manual configuration needed\n\nsynchronous_mode_strict: false\n# false (default): if no synchronous standby is available, the primary continues writing\n# true: if no synchronous standby is available, the primary stops accepting writes entirely\n#       use true only when zero data loss is mandatory and availability can be sacrificed\n<\/pre><\/div>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"17-monitoring\">17. Monitoreo<\/h2>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"patroni-rest-api\">API REST de Patroni<\/h3>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# Check HTTP status code only \u2014 this is what HAProxy checks internally\ncurl -k -o \/dev\/null -w &quot;%{http_code}\\n&quot; https:\/\/192.168.0.203:8008\/primary\n# Expected on the primary: 200\n# Expected on a replica: 503\n# -k: skip certificate verification; -o \/dev\/null: discard body; -w: print status code only\n\ncurl -k -o \/dev\/null -w &quot;%{http_code}\\n&quot; https:\/\/192.168.0.203:8008\/replica\n# Expected on a replica: 200\n# Expected on the primary: 503\n\n# Check full JSON response (useful for debugging)\ncurl -k https:\/\/192.168.0.203:8008\/primary\n# Response includes: role, server_version, timeline, replication state of each standby\n\ncurl -k https:\/\/192.168.0.203:8008\/cluster | python3 -m json.tool\n# Full cluster status in formatted JSON\n# Expected: all members listed with roles, state, and lag\n\ncurl -k https:\/\/192.168.0.203:8008\/health\n# Expected: HTTP 200 if the node is running normally\n<\/pre><\/div>\n\n\n<h3 class=\"wp-block-heading\" id=\"replication-lag\">Latencia de replicaci\u00f3n<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Con\u00e9ctate directamente al nodo principal. <code>pg_stat_replication<\/code> solo tiene filas en la primaria, no en las r\u00e9plicas. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">HAProxy tambi\u00e9n enruta el puerto 5432 en el VIP al principal, por lo que cualquiera de los dos funciona.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# Option 1 \u2014 direct to primary\npsql -h 192.168.0.203 -U postgres -c &quot;SELECT client_addr, replay_lag, sync_state FROM pg_stat_replication;&quot;\n\n# Option 2 \u2014 through VIP (HAProxy routes all connections on port 5432 to the primary)\npsql -h 192.168.0.210 -p 5432 -U postgres -c &quot;SELECT client_addr, replay_lag, sync_state FROM pg_stat_replication;&quot;\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Salida esperada:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n  client_addr  | replay_lag | sync_state\n---------------+------------+------------\n 192.168.0.204 |            | async\n 192.168.0.205 |            | async\n(2 rows)\n<\/pre><\/div>\n\n\n<ul class=\"wp-block-list\">\n<li>Una fila por standby conectado<\/li>\n\n\n\n<li><code>replay_lag = NULL<\/code> (vac\u00edo): en espera est\u00e1 completamente al d\u00eda \u2014normal en reposo<\/li>\n\n\n\n<li><code>retardo de respuesta<\/code> en crecimiento: el standby se est\u00e1 rezagando. revise la red y los registros de Patroni del standby<\/li>\n\n\n\n<li>0 filas: ninguna instancia en espera transmitiendo \u2014 verificar <code>patronictl lista<\/code> para confirmar los estados de las r\u00e9plicas; si las r\u00e9plicas se muestran en funcionamiento, compruebe <code>primary_conninfo<\/code> en <code>postgresql.auto.conf<\/code> en espera cada uno<\/li>\n<\/ul>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"18-full-reset\">18. Restablecimiento completo<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Utilice este procedimiento para reconstruir todo el cl\u00faster desde cero; por ejemplo, despu\u00e9s de un fallo en el laboratorio, una configuraci\u00f3n err\u00f3nea irrecuperable o para volver a ejecutar el laboratorio desde la Secci\u00f3n 9. Los certificados TLS se conservan en todos los nodos. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Solo se borra el estado del cl\u00faster etcd y los datos de PostgreSQL.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Ejecuta todos los comandos en esta secci\u00f3n en <strong>postgres-01, postgres-02 y postgres-03<\/strong> a menos que se indique lo contrario.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"step-1-stop-patroni-and-etcd-on-all-postgre-sql-nodes\">Paso 1 \u2014 Detenga Patroni y etcd en todos los nodos de PostgreSQL<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Patroni debe detenerse antes que etcd para que pueda liberar limpiamente su bloqueo de liderazgo. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Si etcd se detiene primero, Patroni pierde su conexi\u00f3n DCS y puede colgarse.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">En postgres-01:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01\nsudo systemctl stop patroni\n# Stops PostgreSQL gracefully via Patroni \u2014 do not use systemctl stop postgresql directly\n\nsudo systemctl stop etcd\n# Stops the etcd member on this node\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Repetir en postgres-02 y postgres-03 antes de continuar.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"step-2-wipe-etcd-and-postgre-sql-data-directories\">Paso 2 \u2014 Borrar los directorios de datos de etcd y PostgreSQL<\/h3>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01, postgres-02, postgres-03\nsudo rm -rf \/var\/lib\/etcd\/\n# Removes all etcd WAL and snapshot data \u2014 the etcd cluster will re-bootstrap from scratch\n\nsudo rm -rf \/var\/lib\/postgresql\/data\/\n# Removes all PostgreSQL data files \u2014 Patroni will re-initialise via pg_basebackup on standbys\n<\/pre><\/div>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"step-3-recreate-directories-with-correct-ownership\">Paso 3 \u2014 Recrear directorios con la propiedad correcta<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><code>rm -rf<\/code> elimina el directorio en s\u00ed, no solo su contenido. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Los directorios deben ser recreados antes de que etcd y Patroni puedan escribir en ellos.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01, postgres-02, postgres-03\nsudo mkdir -p \/var\/lib\/etcd\/\nsudo chown etcd:etcd \/var\/lib\/etcd\/\n# etcd runs as the etcd user \u2014 it must own its data directory\n\nsudo mkdir -p \/var\/lib\/postgresql\/data\nsudo chown postgres:postgres \/var\/lib\/postgresql\/data\n# Patroni runs as the postgres user \u2014 it must own the PostgreSQL data directory\n<\/pre><\/div>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"step-4-restore-acl-permissions-on-etcd-certificates\">Paso 4: Restaurar los permisos de ACL en los certificados de etcd<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\"><code>rm -rf<\/code> en el directorio de datos no afecta <code>\/etc\/etcd\/certs\/<\/code>, pero si tambi\u00e9n borraste el directorio de certificados durante la soluci\u00f3n de problemas, el <code>postgres<\/code> el usuario habr\u00e1 perdido el acceso de lectura a los certificados de etcd. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Ejecuta este paso para restaurar esos permisos.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">En postgres-01:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01\nsudo setfacl -m u:postgres:r \/etc\/etcd\/certs\/ca.crt\nsudo setfacl -m u:postgres:r \/etc\/etcd\/certs\/etcd-node1.crt\nsudo setfacl -m u:postgres:r \/etc\/etcd\/certs\/etcd-node1.key\n# Grants the postgres OS user read access to the etcd TLS files\n# Required because Patroni (running as postgres) connects to etcd over TLS\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">En postgres-02:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-02\nsudo setfacl -m u:postgres:r \/etc\/etcd\/certs\/ca.crt\nsudo setfacl -m u:postgres:r \/etc\/etcd\/certs\/etcd-node2.crt\nsudo setfacl -m u:postgres:r \/etc\/etcd\/certs\/etcd-node2.key\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">En postgres-03:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-03\nsudo setfacl -m u:postgres:r \/etc\/etcd\/certs\/ca.crt\nsudo setfacl -m u:postgres:r \/etc\/etcd\/certs\/etcd-node3.crt\nsudo setfacl -m u:postgres:r \/etc\/etcd\/certs\/etcd-node3.key\n<\/pre><\/div>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"step-5-reset-etcd-initial-cluster-state-to-new\">Paso 5 \u2014 Restablecer ETCD_INITIAL_CLUSTER_STATE a \u201cnew\u201d<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Despu\u00e9s del primer arranque, <code>etcd.env<\/code> en todos los nodos fue cambiado a <code>existente<\/code>. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Para un reinicio completo, debe configurarse de nuevo a <code>nuevo<\/code> as\u00ed que etcd trata esto como una formaci\u00f3n de cl\u00faster nueva.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Haz una copia de seguridad y edita en cada nodo <code>\/etc\/etcd\/etcd.env<\/code>:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">En postgres-01:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01\nsudo cp \/etc\/etcd\/etcd.env \/etc\/etcd\/etcd.env.$(date +%Y%m%d)\nsudo vi \/etc\/etcd\/etcd.env\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Cambio:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\nETCD_INITIAL_CLUSTER_STATE=&quot;existing&quot;\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Para:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\nETCD_INITIAL_CLUSTER_STATE=&quot;new&quot;\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Repetir en postgres-02 y postgres-03.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"step-6-start-etcd-on-all-nodes\">Paso 6 \u2014 Iniciar etcd en todos los nodos<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">etcd debe estar ejecut\u00e1ndose en los tres nodos antes de que Patroni se inicie. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Inicia etcd en los tres nodos antes de continuar con el Paso 7.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01, postgres-02, postgres-03\nsudo systemctl start etcd\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Verifica que los tres miembros est\u00e9n sanos antes de iniciar Patroni:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01\nETCDCTL_API=3 etcdctl \\\n  --cacert=\/etc\/etcd\/certs\/ca.crt \\\n  --cert=\/etc\/etcd\/certs\/etcd-node1.crt \\\n  --key=\/etc\/etcd\/certs\/etcd-node1.key \\\n  --endpoints=https:\/\/192.168.0.203:2379,https:\/\/192.168.0.204:2379,https:\/\/192.168.0.205:2379 \\\n  endpoint health\n# Expected: all three endpoints report &quot;is healthy&quot;\n# If any node is unhealthy: check journalctl -u etcd on that node before starting Patroni\n<\/pre><\/div>\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h3 class=\"wp-block-heading\" id=\"step-7-start-patroni-on-all-nodes\">Paso 7 \u2014 Iniciar Patroni en todos los nodos<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Inicie Patroni en postgres-01 primero. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Patroni en postgres-01 iniciar\u00e1 un nuevo PostgreSQL principal. <\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Solo despu\u00e9s de que postgres-01 est\u00e9 en funcionamiento y muestre como L\u00edder, se deben iniciar postgres-02 y postgres-03; se unir\u00e1n como r\u00e9plicas a trav\u00e9s de pg_basebackup.<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01 \u2014 start first\nsudo systemctl start patroni\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Espera hasta que postgres-01 muestre como L\u00edder:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01\npatronictl -c \/etc\/patroni\/config.yml list\n# Expected: postgresql-01 shows as Leader, state running\n# Wait for this before starting postgres-02 and postgres-03\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Entonces inicie Patroni en los nodos restantes:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-02\nsudo systemctl start patroni\n<\/pre><\/div>\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-03\nsudo systemctl start patroni\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Verificaci\u00f3n final:<\/p>\n\n\n<div class=\"wp-block-syntaxhighlighter-code\" data-no-translation=\"\"><pre class=\"brush: plain; title: ; notranslate\" title=\"\">\n# On postgres-01\npatronictl -c \/etc\/patroni\/config.yml list\n# Expected:\n# + Cluster: postgres-cluster --------+----+-----------+\n# | Member        | Host            | Role    | State   | TL | Lag in MB |\n# +---------------+-----------------+---------+---------+----+-----------+\n# | postgresql-01 | 192.168.0.203:5432 | Leader | running |  1 |           |\n# | postgresql-02 | 192.168.0.204:5432 | Replica | running |  1 |         0 |\n# | postgresql-03 | 192.168.0.205:5432 | Replica | running |  1 |         0 |\n# +---------------+-----------------+---------+---------+----+-----------+\n# TL 1: fresh cluster, timeline resets to 1 on full reset\n# If a node shows &quot;start failed&quot;: check journalctl -u patroni on that node\n<\/pre><\/div>\n\n\n<p class=\"wp-block-paragraph\">Contin\u00fae desde la Secci\u00f3n 9, Paso 3, para volver a verificar el cl\u00faster y la configuraci\u00f3n. <code>ETCD_INITIAL_CLUSTER_STATE=existente<\/code>.<\/p>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"19-common-issues\">19. Problemas comunes<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Problema<\/th><th>Causa<\/th><th>Arreglar<\/th><\/tr><\/thead><tbody><tr><td>Ning\u00fan l\u00edder electo<\/td><td>etcd quorum perdido<\/td><td>Restaurar etcd; verificar el puerto 2380 entre los nodos de PostgreSQL<\/td><\/tr><tr><td>Patroni no puede conectarse a etcd<\/td><td>Configuraci\u00f3n incorrecta de TLS<\/td><td>Verificar las rutas de cacert\/cert\/key en config.yml; comprobar permisos setfacl<\/td><\/tr><tr><td>Nodo atascado en <code>inicio fallido<\/code><\/td><td>PostgreSQL no se inicia<\/td><td>Verificar journalctl -u patroni; corregir la configuraci\u00f3n, luego <code>patronictl reinit<\/code><\/td><\/tr><tr><td>En espera, no est\u00e1 transmitiendo<\/td><td>Credenciales incorrectas o pg_hba<\/td><td>Verifica primary_conninfo en postgresql.auto.conf; verifica replicator en pg_hba<\/td><\/tr><tr><td><code>pg_rewind<\/code> falla tras conmutaci\u00f3n por error<\/td><td><code>wal_log_hints<\/code> no habilitado<\/td><td>Habilite wal_log_hints = on antes de inicializar el cl\u00faster; use <code>patronictl reinit<\/code> como respaldo<\/td><\/tr><tr><td>VIP no responde<\/td><td>keepalived no se est\u00e1 ejecutando<\/td><td>Verifica el estado de systemctl de keepalived; verifica journalctl -u keepalived en todos los nodos HAProxy<\/td><\/tr><tr><td>HAProxy enrutando a nodo incorrecto<\/td><td>API REST de Patroni no accesible<\/td><td>Verifica que el puerto 8008 est\u00e9 abierto; verifica que Patroni se est\u00e9 ejecutando en todos los nodos de PostgreSQL<\/td><\/tr><tr><td>El cl\u00faster de etcd se reforma al reiniciar<\/td><td>estado-inicial-del-cl\u00faster todav\u00eda \u201cnuevo\u201d<\/td><td>Cambie a \u201cexistente\u201d en etcd.env en todos los nodos y reinicie etcd<\/td><\/tr><tr><td>PostgreSQL se inicia fuera de Patroni<\/td><td><code>systemctl iniciar postgresql<\/code> ejecutar directamente<\/td><td>Detener PostgreSQL; reiniciar v\u00eda <code>patronictl reiniciar<\/code><\/td><\/tr><\/tbody><\/table><\/figure>\n\n\n\n<hr class=\"wp-block-separator has-alpha-channel-opacity\"\/>\n\n\n\n<h2 class=\"wp-block-heading\" id=\"20-key-commands-reference\">20. Referencia de comandos clave<\/h2>\n\n\n\n<figure class=\"wp-block-table\"><table><thead><tr><th>Comando<\/th><th>Descripci\u00f3n<\/th><\/tr><\/thead><tbody><tr><td><code>patronictl lista<\/code><\/td><td>Mostrar todos los miembros del cl\u00faster, roles, estado y latencia de replicaci\u00f3n<\/td><\/tr><tr><td><code>patronictl switchover<\/code><\/td><td>Cambio planificado a un nodo espec\u00edfico: p\u00e9rdida de datos cero<\/td><\/tr><tr><td><code>patronictl failover --force<\/code><\/td><td>Fuerza conmutaci\u00f3n por error \u2014 usar solo cuando se confirme que el primario est\u00e1 inactivo<\/td><\/tr><tr><td><code>patronictl pausa<\/code><\/td><td>Desactivar la conmutaci\u00f3n autom\u00e1tica por error - utilizar durante el mantenimiento planificado<\/td><\/tr><tr><td><code>curriculum vitae del patr\u00f3n<\/code><\/td><td>Reactivar la conmutaci\u00f3n por error autom\u00e1tica<\/td><\/tr><tr><td><code>patronictl reiniciar<\/code><\/td><td>Reiniciar PostgreSQL en un nodo a trav\u00e9s de Patroni \u2014 nunca usar systemctl directamente<\/td><\/tr><tr><td><code>patronictl reload<\/code><\/td><td>Recargar postgresql.conf en todos los nodos del cl\u00faster<\/td><\/tr><tr><td><code>patronictl reinit<\/code><\/td><td>Borrar y reconstruir un standby a partir del primario actual<\/td><\/tr><tr><td><code>patronictl editar-config<\/code><\/td><td>Editar la configuraci\u00f3n del cl\u00faster DCS almacenada en etcd<\/td><\/tr><tr><td><code>curl -k https:\/\/:8008\/primary<\/code><\/td><td>HTTP 200 si este nodo es actualmente el primario<\/td><\/tr><tr><td><code>curl -k https:\/\/:8008\/replica<\/code><\/td><td>HTTP 200 si este nodo es actualmente una r\u00e9plica<\/td><\/tr><tr><td><code>curl -k https:\/\/:8008\/cluster<\/code><\/td><td>Estado completo del cluster en JSON<\/td><\/tr><tr><td><code>etcdctl salud del endpoint<\/code><\/td><td>Comprobar el estado de todos los miembros del cl\u00faster etcd<\/td><\/tr><tr><td><code>ip addr show enp0s3<\/code><\/td><td>Confirma qu\u00e9 nodo de HAProxy tiene actualmente la VIP (interfaz predeterminada de VirtualBox)<\/td><\/tr><\/tbody><\/table><\/figure>","protected":false},"excerpt":{"rendered":"<p>Gu\u00eda de laboratorio paso a paso para construir un cl\u00faster PostgreSQL HA de 6 nodos con Patroni, etcd, HAProxy y keepalived. Cubre la configuraci\u00f3n TLS, failover, switchover, monitoreo y operaciones diarias.<\/p>","protected":false},"author":1,"featured_media":6695,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"_jetpack_newsletter_access":"","_jetpack_dont_email_post_to_subs":false,"_jetpack_newsletter_tier_id":0,"_jetpack_memberships_contains_paywalled_content":false,"_jetpack_feature_clip_id":0,"_jetpack_memberships_contains_paid_content":false,"footnotes":"","jetpack_post_was_ever_published":false},"categories":[126],"tags":[145,144],"class_list":["post-6685","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-postgresql","tag-high-availability","tag-patroni"],"jetpack_sharing_enabled":true,"jetpack_featured_media_url":"https:\/\/i0.wp.com\/rootfan.com\/wp-content\/uploads\/pexels-photo-36043291-1.jpeg?fit=1880%2C1253&ssl=1","_links":{"self":[{"href":"https:\/\/rootfan.com\/es\/wp-json\/wp\/v2\/posts\/6685","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/rootfan.com\/es\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/rootfan.com\/es\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/rootfan.com\/es\/wp-json\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/rootfan.com\/es\/wp-json\/wp\/v2\/comments?post=6685"}],"version-history":[{"count":13,"href":"https:\/\/rootfan.com\/es\/wp-json\/wp\/v2\/posts\/6685\/revisions"}],"predecessor-version":[{"id":6702,"href":"https:\/\/rootfan.com\/es\/wp-json\/wp\/v2\/posts\/6685\/revisions\/6702"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/rootfan.com\/es\/wp-json\/wp\/v2\/media\/6695"}],"wp:attachment":[{"href":"https:\/\/rootfan.com\/es\/wp-json\/wp\/v2\/media?parent=6685"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/rootfan.com\/es\/wp-json\/wp\/v2\/categories?post=6685"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/rootfan.com\/es\/wp-json\/wp\/v2\/tags?post=6685"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}