Odoo Alta Disponibilidad con Patroni y HAProxy: arquitectura HA

Cómo diseñar una arquitectura HA real para Odoo con Patroni, replicación PostgreSQL streaming, HAProxy y filestore compartido: RTO, RPO y pruebas de failover incluidos.

Por qué la Alta Disponibilidad es crítica en Odoo de producción

Un sistema Odoo en producción que gestiona pedidos, facturas, nóminas o logística no puede permitirse caídas imprevistas. Cada minuto de inactividad tiene un coste directo: pedidos que no se registran, operarios parados esperando albaranes, clientes que llaman. En instalaciones medianas (50-200 usuarios concurrentes) un downtime de 15 minutos puede suponer pérdidas de entre 2.000 y 20.000 €, dependiendo del sector.

Sin embargo, la gran mayoría de implantaciones Odoo en España corren sobre un único servidor con un único PostgreSQL. Si ese servidor falla —disco, kernel panic, corrupción de datos, update de paquete que rompe algo— no hay plan B. Esta guía describe la arquitectura de alta disponibilidad que hemos implementado en producción, con componentes open source, coste controlado y failover automático en menos de 30 segundos.

Conceptos clave: RTO, RPO y modos de fallo

Antes de diseñar la arquitectura conviene tener claros dos parámetros que definen los SLA:

  • RTO (Recovery Time Objective): tiempo máximo tolerable desde que ocurre el fallo hasta que el servicio vuelve a estar operativo. En la arquitectura que describimos, el RTO objetivo es < 30 segundos para fallos de nodo de base de datos.
  • RPO (Recovery Point Objective): máxima pérdida de datos aceptable. Con replicación síncrona, el RPO es 0 transacciones confirmadas. Con replicación asíncrona (más habitual para no impactar latencia), el RPO puede ser de 1-5 segundos.

Los modos de fallo que esta arquitectura cubre son: fallo de nodo PostgreSQL primario, fallo de nodo de aplicación Odoo, corrupción de datos en un único nodo, mantenimiento planificado con cero downtime, y saturación de carga.

Arquitectura de referencia: visión general

La arquitectura se compone de cuatro capas independientes que trabajan juntas:

         ┌──────────────────────────────────────────┐
         │         CLIENTES (navegadores, apps)      │
         └────────────────┬─────────────────────────┘
                          │
         ┌────────────────▼─────────────────────────┐
         │           HAProxy (activo/pasivo)          │
         │   :80/:443 → Odoo workers  :5432 → PG     │
         │   stats: :8404                             │
         └───────────┬──────────────┬────────────────┘
                     │              │
       ┌─────────────▼──┐    ┌──────▼──────────────┐
       │  Odoo Worker 1  │    │  Odoo Worker 2       │
       │  (activo)       │    │  (activo)            │
       │  8069/8072      │    │  8069/8072           │
       └──────┬──────────┘    └──────┬───────────────┘
              │   Filestore compartido (NFS/S3)        │
              └──────────────┬────────────────────────┘
                             │
         ┌───────────────────▼──────────────────────┐
         │            Capa PostgreSQL HA              │
         │                                           │
         │   ┌─────────┐  ┌─────────┐  ┌─────────┐  │
         │   │ PG Prim │  │ PG Rep1 │  │ PG Rep2 │  │
         │   │ (R/W)   │─▶│(standby)│  │(standby)│  │
         │   └─────────┘  └─────────┘  └─────────┘  │
         │        ▲             ▲            ▲        │
         │        └─────────────┴────────────┘        │
         │              Patroni + etcd                │
         └───────────────────────────────────────────┘

Capa 1: PostgreSQL HA con Patroni y etcd

Patroni es el estándar de facto para gestionar clusters PostgreSQL en alta disponibilidad. Se encarga de la elección de líder, la promoción automática de standby a primario y el reinicio controlado de nodos caídos. Trabaja junto a un sistema de consenso distribuido (etcd, Consul o ZooKeeper) para evitar el problema del split-brain.

Topología recomendada

  • 3 nodos PostgreSQL: 1 primario (R/W) + 2 réplicas streaming (sólo lectura)
  • 3 nodos etcd (puede coexistir en los mismos hosts que PostgreSQL en entornos medianos)
  • Replicación asíncrona por defecto; síncrona opcional para RPO=0 a costa de latencia de escritura

Fichero de configuración Patroni (patroni.yml)

scope: odoo-cluster
namespace: /service/
name: pg-node-1

restapi:
  listen: 0.0.0.0:8008
  connect_address: 10.0.1.11:8008

etcd3:
  hosts:
    - 10.0.1.11:2379
    - 10.0.1.12:2379
    - 10.0.1.13:2379

bootstrap:
  dcs:
    ttl: 30
    loop_wait: 10
    retry_timeout: 10
    maximum_lag_on_failover: 1048576   # 1 MB de lag máximo para failover
    postgresql:
      use_pg_rewind: true
      use_slots: true
      parameters:
        wal_level: replica
        hot_standby: "on"
        max_wal_senders: 10
        max_replication_slots: 10
        wal_log_hints: "on"
        archive_mode: "on"
        archive_command: "cp %p /var/lib/postgresql/wal_archive/%f"
        synchronous_commit: "off"   # asíncrono; cambiar a 'on' para RPO=0
  initdb:
    - encoding: UTF8
    - data-checksums

postgresql:
  listen: 0.0.0.0:5432
  connect_address: 10.0.1.11:5432
  data_dir: /var/lib/postgresql/16/main
  bin_dir: /usr/lib/postgresql/16/bin
  pgpass: /tmp/pgpass0
  authentication:
    replication:
      username: replicator
      password: <REPLICATION_PASSWORD>
    superuser:
      username: postgres
      password: <SUPERUSER_PASSWORD>
  parameters:
    unix_socket_directories: "."
    shared_buffers: "2GB"
    effective_cache_size: "6GB"
    maintenance_work_mem: "512MB"
    work_mem: "64MB"
    max_connections: 200
    log_min_duration_statement: 1000   # loguear queries > 1s

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

Repetir este fichero en cada nodo ajustando name y connect_address (pg-node-2 / pg-node-3). El servicio Patroni se gestiona con systemd:

# /etc/systemd/system/patroni.service
[Unit]
Description=Patroni PostgreSQL HA
After=network.target

[Service]
Type=simple
User=postgres
ExecStart=/usr/local/bin/patroni /etc/patroni/patroni.yml
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Capa 2: HAProxy como balanceador y conmutador de tráfico

HAProxy cumple dos funciones: balancear el tráfico HTTP de los workers Odoo entre varios nodos de aplicación, y enrutar las conexiones PostgreSQL al nodo primario actual (usando las comprobaciones de salud de Patroni vía REST API en el puerto 8008).

Fichero haproxy.cfg

global
    log /dev/log local0
    log /dev/log local1 notice
    maxconn 4096
    user haproxy
    group haproxy
    daemon

defaults
    log global
    mode http
    option httplog
    option dontlognull
    retries 3
    timeout connect 5s
    timeout client 30s
    timeout server 60s

#
# FRONTEND Odoo HTTP (workers)
#
frontend odoo_http
    bind *:80
    bind *:443 ssl crt /etc/ssl/certs/skanndar.pem
    http-request redirect scheme https unless { ssl_fc }
    default_backend odoo_workers

backend odoo_workers
    balance roundrobin
    option httpchk GET /web/health
    http-check expect status 200
    server odoo1 10.0.1.21:8069 check inter 5s fall 3 rise 2
    server odoo2 10.0.1.22:8069 check inter 5s fall 3 rise 2

#
# LONGPOLLING / GEVENT (puerto 8072)
#
frontend odoo_longpoll
    bind *:8072
    default_backend odoo_longpoll_backend

backend odoo_longpoll_backend
    balance source
    option httpchk GET /web/health
    http-check expect status 200
    timeout tunnel 3600s
    server odoo1 10.0.1.21:8072 check inter 5s fall 3 rise 2
    server odoo2 10.0.1.22:8072 check inter 5s fall 3 rise 2

#
# FRONTEND PostgreSQL (TCP mode)
# Patroni expone /master en 8008 cuando el nodo es primario
#
frontend pg_primary_frontend
    mode tcp
    bind *:5432
    default_backend pg_primary

backend pg_primary
    mode tcp
    option httpchk GET /master
    http-check expect status 200
    default-server inter 3s fastinter 1s fall 3 rise 2 on-marked-down shutdown-sessions
    server pg1 10.0.1.11:5432 check port 8008
    server pg2 10.0.1.12:5432 check port 8008
    server pg3 10.0.1.13:5432 check port 8008

#
# FRONTEND PostgreSQL réplicas (lectura)
#
frontend pg_replica_frontend
    mode tcp
    bind *:5433
    default_backend pg_replicas

backend pg_replicas
    mode tcp
    option httpchk GET /replica
    http-check expect status 200
    default-server inter 3s fall 3 rise 2
    server pg1 10.0.1.11:5432 check port 8008
    server pg2 10.0.1.12:5432 check port 8008
    server pg3 10.0.1.13:5432 check port 8008

#
# STATS
#
listen stats
    bind *:8404
    stats enable
    stats uri /stats
    stats refresh 5s
    stats auth admin:<STATS_PASSWORD>

La clave está en la comprobación de salud de PostgreSQL: HAProxy llama a GET /master sobre el puerto REST de Patroni (8008). Sólo el nodo primario responde 200; los standby responden 503. Esto garantiza que el tráfico de escritura siempre llega al primario actual, incluso tras un failover.

Capa 3: Workers Odoo y configuración de sesiones

Odoo en modo multi-worker lanza procesos independientes que deben poder ejecutarse en cualquier nodo de aplicación. Esto implica dos requisitos:

Filestore compartido

Los adjuntos, imágenes y documentos de Odoo se almacenan en el filestore (por defecto ~/.local/share/Odoo/filestore/). En un cluster multi-nodo, este directorio debe ser compartido. Las opciones son:

  • NFS montado en ambos nodos: sencillo, latencia baja en LAN. Usar NFSv4 con bloqueos habilitados.
  • S3 / compatible S3 (MinIO): opción preferida para entornos cloud. El módulo base_attachment_s3 de OCA permite usar S3 como backend de adjuntos de forma transparente.
  • GlusterFS: alternativa distribuida sin punto único de fallo para el propio filestore.

Sesiones de usuario

Por defecto Odoo almacena sesiones en ficheros locales (/tmp/sessions/). En un cluster multi-nodo esto provoca que el usuario pierda la sesión si una petición HTTP va al nodo que no tiene su fichero de sesión. La solución es almacenar sesiones en Redis o en PostgreSQL (módulo OCA session_db):

# odoo.conf (nodo de aplicación)
[options]
workers = 8
max_cron_threads = 2
db_host = 10.0.1.1         # VIP de HAProxy → primario PG
db_port = 5432
db_user = odoo
db_password = <DB_PASSWORD>
dbfilter = ^odoo_prod$
http_port = 8069
gevent_port = 8072
proxy_mode = True
limit_memory_hard = 2684354560
limit_memory_soft = 2147483648
limit_request = 8192
limit_time_cpu = 120
limit_time_real = 240
log_level = warn
logfile = /var/log/odoo/odoo.log
# Sesiones en Redis (requiere módulo OCA web_session_redis)
# redis_url = redis://10.0.1.30:6379/0

Longpolling y gevent

El módulo de chat y notificaciones en tiempo real de Odoo usa gevent en el puerto 8072. En un entorno HA, las conexiones longpolling deben ir siempre al mismo nodo para la misma sesión (sticky sessions por IP origen). El bloque balance source de HAProxy en el backend de longpolling lo garantiza. En Nginx, si se pone delante de HAProxy, usar ip_hash para las peticiones a /longpolling/.

Failover automático: qué ocurre paso a paso

Cuando el nodo primario de PostgreSQL cae, la secuencia es la siguiente:

  1. Patroni detecta que el primario no responde al heartbeat etcd (timeout configurable, por defecto 10 s).
  2. Los nodos restantes inician una elección de líder a través de etcd. El candidato con el WAL más reciente y sin lag excesivo gana.
  3. El nodo elegido ejecuta pg_ctl promote: el standby se convierte en primario. Este proceso tarda entre 5 y 15 segundos.
  4. Patroni actualiza la clave DCS para reflejar el nuevo líder.
  5. HAProxy detecta en el siguiente ciclo de health check (cada 3 s con fastinter 1s tras un fallo) que /master ahora responde 200 en el nuevo primario y 503 en los demás.
  6. El tráfico de escritura se redirige automáticamente. Las conexiones activas en Odoo se interrumpen con un error de conexión a la BD, pero Odoo reintenta automáticamente al reconectar.

Tiempo total de failover en producción: 15-30 segundos. Los usuarios verán un error 500 durante ese intervalo, pero no perderán datos confirmados.

Pruebas de failover: cómo validar la arquitectura

Una arquitectura HA no probada no es HA. Estas son las pruebas mínimas que deben ejecutarse antes de ir a producción y de forma periódica (chaos engineering):

Test 1: Fallo del nodo primario

# Desde el nodo pg-node-1 (primario)
sudo systemctl stop patroni
# O simulando un crash:
sudo kill -9 $(pgrep -f "postgres: patroni")

# Observar la elección en tiempo real:
patronict -c "host=10.0.1.11,10.0.1.12,10.0.1.13 port=8008" topology
# Resultado esperado: pg-node-2 o pg-node-3 pasa a Leader en < 30s

Test 2: Verificar que HAProxy redirige el tráfico

# Desde una máquina externa con psql instalado:
watch -n 1 "psql -h 10.0.1.1 -p 5432 -U odoo -c 'SELECT pg_is_in_recovery(), inet_server_addr();'"
# Resultado esperado: pg_is_in_recovery = f (es el primario)
# Tras el failover debe cambiar la IP pero el resultado seguir siendo f

Test 3: Fallo de un nodo de aplicación Odoo

# Parar Odoo en uno de los nodos
ssh odoo-node-1 "sudo systemctl stop odoo"
# HAProxy debe dejar de enviar tráfico a ese nodo en < 15s (3 checks x 5s)
# Verificar en las stats de HAProxy: http://10.0.1.1:8404/stats

Test 4: pg_rewind tras recuperación del primario caído

Cuando el primario original vuelve a estar disponible, Patroni lo reintegra automáticamente como standby usando pg_rewind para sincronizar los WALs divergentes. Verificar que use_pg_rewind: true está configurado en patroni.yml y que el usuario de replicación tiene permisos de superusuario (necesario para pg_rewind en versiones anteriores a PG 16).

Errores comunes y cómo evitarlos

Split-brain

El split-brain ocurre cuando dos nodos creen simultáneamente que son el primario. Patroni lo previene mediante el quórum de etcd: si un nodo no puede escribir en etcd no puede ser primario. Usar siempre número impar de nodos etcd (3 o 5) para garantizar el quórum.

Filestore desincronizado

Si el filestore no está correctamente compartido, los adjuntos creados en un nodo no son visibles desde el otro. Síntoma: imágenes de producto o documentos que aparecen y desaparecen según qué nodo sirve la petición. Solución: NFS con opciones rsize=131072,wsize=131072,hard,intr o migrar a S3.

Conexiones a la BD que no liberan tras el failover

Odoo abre conexiones a PostgreSQL que pueden quedar en estado zombie si el primario cae bruscamente. Configurar tcp_keepalives_idle = 60 y tcp_keepalives_interval = 10 en PostgreSQL y en el cliente Odoo (db_maxconn razonable, por defecto 64). PgBouncer como pooler de conexiones entre Odoo y PostgreSQL mejora dramáticamente la recuperación tras failover.

Cron jobs duplicados

En un cluster de dos nodos de aplicación Odoo, los cron jobs se ejecutan en ambos nodos si max_cron_threads > 0 en los dos. Esto puede causar duplicación de emails, facturas generadas dos veces, etc. Solución: dedicar un único nodo como nodo de cron (max_cron_threads = 2 sólo en ese nodo; en el resto max_cron_threads = 0).

Lag de replicación en hora punta

Con workloads de escritura intensiva (importaciones masivas, cierres contables), la réplica puede acumular lag. Si el lag supera maximum_lag_on_failover (1 MB por defecto), Patroni excluye ese nodo de los candidatos a failover. Monitorizar el lag con:

SELECT application_name, state, sent_lsn, replay_lsn,
       (sent_lsn - replay_lsn) AS lag_bytes
FROM pg_stat_replication;

Resumen de RTO y RPO alcanzables

EscenarioRTORPONotas
Fallo nodo PG primario< 30 s0-5 s (async) / 0 (sync)Failover automático Patroni
Fallo nodo aplicación Odoo< 15 s0HAProxy health check, ningún estado en app
Corrupción datos (disco)< 5 minÚltimo snapshot + WALRestaurar desde backup + replay WAL
Mantenimiento planificado00Rolling restart: sacar nodo de HAProxy, actualizar, reintegrar

¿Necesitas implementar Alta Disponibilidad en tu Odoo?

Solicitar auditoría técnica gratuita

Monitorización de Odoo con ELK Stack y alertas Telegram
Cómo centralizar logs y métricas de Odoo con Elasticsearch, Logstash/Filebeat y Kibana, parsear los logs propios de Odoo y recibir alertas proactivas en un bot de Telegram cuando algo falla.