{"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>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>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>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>Las conmutaciones son limpias y sin p\u00e9rdida de datos. <\/p>\n\n\n\n<p>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>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>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>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><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              &#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>tres nodos dedicados enrutan las conexiones de la aplicaci\u00f3n al principal actual comprobando la API REST de Patroni.<\/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>Ejecutar en los 6 servidores (postgres-01\/02\/03 y haproxy-01\/02\/03). <\/p>\n\n\n\n<p>Los servidores usan UTC por defecto; config\u00faralo a tu zona horaria local antes que nada. <\/p>\n\n\n\n<p>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>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>Todos los certificados se generan una vez en postgres-01 y luego se distribuyen a los otros nodos.<\/p>\n\n\n\n<p>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; 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>Cada nodo recibe su propio certificado con su IP como un Nombre Alternativo del Sujeto (SAN). La verificaci\u00f3n de nombre de host TLS requiere que la IP del servidor aparezca en el SAN; sin \u00e9l, las conexiones fallar\u00e1n.<\/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 &gt; temp.cnf &lt;&lt;EOF\n&#91; req ]\ndistinguished_name = req_distinguished_name\nreq_extensions = v3_req\n&#91; req_distinguished_name ]\n&#91; v3_req ]\nsubjectAltName = @alt_names\n&#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 &gt; temp.cnf &lt;&lt;EOF\n&#91; req ]\ndistinguished_name = req_distinguished_name\nreq_extensions = v3_req\n&#91; req_distinguished_name ]\n&#91; v3_req ]\nsubjectAltName = @alt_names\n&#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 &gt; temp.cnf &lt;&lt;EOF\n&#91; req ]\ndistinguished_name = req_distinguished_name\nreq_extensions = v3_req\n&#91; req_distinguished_name ]\n&#91; v3_req ]\nsubjectAltName = @alt_names\n&#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>Un certificado compartido cubre todos los nodos de PostgreSQL. <\/p>\n\n\n\n<p>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>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>Todos los certificados residen en <code>\/etc\/etcd\/certs\/<\/code> en cada nodo. <\/p>\n\n\n\n<p>El directorio pertenece a <code>etcd:etcd<\/code> as\u00ed que el demonio etcd puede leer sus certificados. <\/p>\n\n\n\n<p>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><strong>Importante:<\/strong> establecer los permisos del archivo antes de bloquear el directorio. <\/p>\n\n\n\n<p>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><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><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><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><strong>En los tres nodos<\/strong> \u2014 Establece permisos y luego bloquea el directorio:<\/p>\n\n\n\n<p>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>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>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>Patroni <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  &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>etcd est\u00e1 configurado mediante variables de entorno cargadas por el servicio systemd. <\/p>\n\n\n\n<p>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>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>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&#91;Unit]\nDescription=etcd key-value store\nDocumentation=https:\/\/github.com\/etcd-io\/etcd\nAfter=network-online.target\nWants=network-online.target\n\n&#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&#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&#91;etcd3]\n# &#91;etcd3]: selects the etcd v3 API backend \u2014 required for etcd 3.5+\n# The older &#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>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>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>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>El primario previsto debe comenzar primero. <\/p>\n\n\n\n<p>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; 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>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>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>HAProxy dirige todas las conexiones de la aplicaci\u00f3n al primario actual consultando la API REST de Patroni. <\/p>\n\n\n\n<p>Solo los principales devuelven HTTP 200 en <code>\/primario<\/code> \u2014 los standbys devuelven HTTP 503. <\/p>\n\n\n\n<p>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>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>Siempre valida la configuraci\u00f3n antes de recargar. <\/p>\n\n\n\n<p>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&#91;1]: Reloading haproxy.service - HAProxy Load Balancer...\n#   systemd&#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>keepalived gestiona la VIP usando VRRP. <\/p>\n\n\n\n<p>Un nodo HAProxy tiene la VIP (el MAESTRO). <\/p>\n\n\n\n<p>Si el chequeo de salud del MASTER falla o el nodo se cae, keepalived en un nodo BACKUP reclama el VIP. <\/p>\n\n\n\n<p>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; 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>keepalived ejecuta este script cada 2 segundos. <\/p>\n\n\n\n<p>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 &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>Cada nodo tiene un diferente <code>estado<\/code> y <code>prioridad<\/code>. <\/p>\n\n\n\n<p>El MAESTRO (prioridad 100) mantiene inicialmente el VIP. <\/p>\n\n\n\n<p>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 &gt; 90 &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>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>El archivo de Patroni contiene solo <code>hostssl<\/code> entradas y ninguna entrada de socket Unix local. <\/p>\n\n\n\n<p>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>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>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>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>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>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>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>Un cambio de rol traslada el rol principal a un standby espec\u00edfico sin p\u00e9rdida de datos. <\/p>\n\n\n\n<p>Patroni espera a que el candidato en espera se ponga al d\u00eda por completo antes de promover.<\/p>\n\n\n\n<p><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>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>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>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>Detener Patroni en el primario actual para simular un fallo. <\/p>\n\n\n\n<p>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>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>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>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>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 &lt;new-leader&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>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>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>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>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>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>Solo se borra el estado del cl\u00faster etcd y los datos de PostgreSQL.<\/p>\n\n\n\n<p>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>Patroni debe detenerse antes que etcd para que pueda liberar limpiamente su bloqueo de liderazgo. <\/p>\n\n\n\n<p>Si etcd se detiene primero, Patroni pierde su conexi\u00f3n DCS y puede colgarse.<\/p>\n\n\n\n<p>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>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><code>rm -rf<\/code> elimina el directorio en s\u00ed, no solo su contenido. <\/p>\n\n\n\n<p>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><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>Ejecuta este paso para restaurar esos permisos.<\/p>\n\n\n\n<p>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>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>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>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>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>Haz una copia de seguridad y edita en cada nodo <code>\/etc\/etcd\/etcd.env<\/code>:<\/p>\n\n\n\n<p>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>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>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>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>etcd debe estar ejecut\u00e1ndose en los tres nodos antes de que Patroni se inicie. <\/p>\n\n\n\n<p>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>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>Inicie Patroni en postgres-01 primero. <\/p>\n\n\n\n<p>Patroni en postgres-01 iniciar\u00e1 un nuevo PostgreSQL principal. <\/p>\n\n\n\n<p>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>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>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>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>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":{"rank_math_focus_keyword":"","rank_math_title":"","rank_math_description":"","rank_math_robots":"","rank_math_og_title":"","rank_math_og_description":"","jetpack_post_was_ever_published":false,"_jetpack_newsletter_access":"","_jetpack_dont_email_post_to_subs":false,"_jetpack_newsletter_tier_id":0,"_jetpack_memberships_contains_paywalled_content":false,"_jetpack_memberships_contains_paid_content":false,"footnotes":""},"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_featured_media_url":"https:\/\/i0.wp.com\/rootfan.com\/wp-content\/uploads\/pexels-photo-36043291-1.jpeg?fit=1880%2C1253&ssl=1","jetpack_sharing_enabled":true,"_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}]}}