Odoo Alta Disponibilitat amb Patroni i HAProxy: arquitectura HA

Com dissenyar una arquitectura HA real per a Odoo amb Patroni, replicació PostgreSQL streaming, HAProxy i filestore compartit: RTO, RPO i proves de failover inclosos.

Per què l'Alta Disponibilitat és crítica a Odoo en producció

Un sistema Odoo en producció que gestiona comandes, factures, nòmines o logística no es pot permetre caigudes imprevistes. Cada minut d'inactivitat té un cost directe: comandes que no es registren, operaris aturats esperant albarans, clients que truquen. En instal·lacions mitjanes (50-200 usuaris concurrents) un temps d'inactivitat de 15 minuts pot suposar pèrdues d'entre 2.000 i 20.000 €, depenent del sector.

Tot i això, la gran majoria d'implantacions Odoo a Espanya corren sobre un únic servidor amb un únic PostgreSQL. Si aquell servidor falla -- disc, kernel panic, corrucció de dades, una actualització de paquet que trenqui alguna cosa -- no hi ha pla B. Aquesta guia descriu l'arquitectura d'alta disponibilitat que hem implementat en producció, amb components de codi obert, cost controlat i failover automàtic en menys de 30 segons.

Conceptes clau: RTO, RPO i modes de fallada

Abans de dissenyar l'arquitectura convé tenir clars dos paràmetres que defineixen els SLA:

  • RTO (Recovery Time Objective): temps màxim tolerable des que es produeix la fallada fins que el servei torna a estar operatiu. En l'arquitectura que descrivim, l'RTO objectiu és < 30 segons per a fallades de node de base de dades.
  • RPO (Recovery Point Objective): màxima pèrdua de dades acceptable. Amb replicació síncrona, l'RPO és 0 transaccions confirmades. Amb replicació asíncrona (més habitual per no impactar la latència), l'RPO pot ser d'1-5 segons.

Els modes de fallada que aquesta arquitectura cobreix són: fallada de node PostgreSQL primari, fallada de node d'aplicació Odoo, corrucció de dades en un únic node, manteniment planificat amb zero temps d'inactivitat i saturació de càrrega.

Arquitectura de referència: visió general

L'arquitectura es compon de quatre capes independents que treballen conjuntament:

         ┌──────────────────────────────────────────┐
         │         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 amb Patroni i etcd

Patroni és l'estàndard de facto per gestionar clústers PostgreSQL en alta disponibilitat. S'encarrega de l'elecció del líder, la promoció automàtica d'un standby a primari i el reinici controlat de nodes caiguts. Treballa conjuntament amb un sistema de consens distribuït (etcd, Consul o ZooKeeper) per evitar el problema del split-brain.

Topologia recomanada

  • 3 nodes PostgreSQL: 1 primari (R/W) + 2 rèpliques streaming (només lectura)
  • 3 nodes etcd (pot coexistir als mateixos hosts que PostgreSQL en entorns mitjans)
  • Replicació asíncrona per defecte; síncrona opcional per a RPO=0 a costa de latència d'escriptura

Fitxer de configuració 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

Repetiu aquest fitxer a cada node ajustant name i connect_address (pg-node-2 / pg-node-3). El servei Patroni es gestiona amb 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 com a balancejador i commutador de tràfic

HAProxy compleix dues funcions: balancejar el tràfic HTTP dels workers Odoo entre diversos nodes d'aplicació, i enrutar les connexions PostgreSQL al node primari actual (usant les comprovacions de salut de Patroni via REST API al port 8008).

Fitxer 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 clau és la comprovació de salut de PostgreSQL: HAProxy crida GET /master sobre el port REST de Patroni (8008). Només el node primari respon 200; els standby responen 503. Això garanteix que el tràfic d'escriptura sempre arriba al primari actual, fins i tot després d'un failover.

Capa 3: Workers Odoo i configuració de sessions

Odoo en mode multi-worker llança processos independents que han de poder executar-se a qualsevol node d'aplicació. Això implica dos requisits:

Filestore compartit

Els adjunts, imatges i documents d'Odoo s'emmagatzemen al filestore (per defecte ~/.local/share/Odoo/filestore/). En un clúster multi-node, aquest directori ha de ser compartit. Les opcions són:

  • NFS muntat a tots dos nodes: senzill, latència baixa a LAN. Usar NFSv4 amb bloquejos habilitats.
  • S3 / compatible S3 (MinIO): opció preferida per a entorns al núvol. El mòdul OCA base_attachment_s3 permet usar S3 com a backend d'adjunts de manera transparent.
  • GlusterFS: alternativa distribuïda sense punt únic de fallada per al propi filestore.

Sessions d'usuari

Per defecte Odoo emmagatzema sessions en fitxers locals (/tmp/sessions/). En un clúster multi-node això fa que l'usuari perdi la sessió si una petició HTTP va al node que no té el seu fitxer de sessió. La solució és emmagatzemar sessions a Redis o a PostgreSQL (mòdul 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 i gevent

El mòdul de xat i notificacions en temps real d'Odoo utilitza gevent al port 8072. En un entorn HA, les connexions longpolling han d'anar sempre al mateix node per a la mateixa sessió (sticky sessions per IP origen). El bloc balance source de HAProxy al backend de longpolling ho garanteix. A Nginx, si es posa davant de HAProxy, usar ip_hash per a les peticions a /longpolling/.

Failover automàtic: què passa pas a pas

Quan el node primari de PostgreSQL cau, la seqüència és la següent:

  1. Patroni detecta que el primari no respon al heartbeat d'etcd (timeout configurable, per defecte 10 s).
  2. Els nodes restants inicien una elecció de líder a través d'etcd. El candidat amb el WAL més recent i sense lag excessiu guanya.
  3. El node elegit executa pg_ctl promote: el standby es converteix en primari. Aquest procés triga entre 5 i 15 segons.
  4. Patroni actualitza la clau DCS per reflectir el nou líder.
  5. HAProxy detecta en el següent cicle de health check (cada 3 s amb fastinter 1s després d'una fallada) que /master ara respon 200 al nou primari i 503 als altres.
  6. El tràfic d'escriptura es redirigeix automàticament. Les connexions actives a Odoo s'interrompen amb un error de connexió a la BD, però Odoo reintenta automàticament en reconnectar.

Temps total de failover en producció: 15-30 segons. Els usuaris veuran un error 500 durant aquell interval, però no perdran dades confirmades.

Proves de failover: com validar l'arquitectura

Una arquitectura HA no provada no és HA. Aquestes són les proves mínimes que s'han d'executar abans d'anar a producció i de manera periòdica (chaos engineering):

Test 1: Fallada del node primari

# 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 redirigeix el tràfic

# 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: Fallada d'un node d'aplicació 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 després de la recuperació del primari caigut

Quan el primari original torna a estar disponible, Patroni el reintegra automàticament com a standby usant pg_rewind per sincronitzar els WALs divergents. Verificar que use_pg_rewind: true està configurat a patroni.yml i que l'usuari de replicació té permisos de superusuari (necessari per a pg_rewind en versions anteriors a PG 16).

Errors comuns i com evitar-los

Split-brain

El split-brain ocorre quan dos nodes creuen simultàniament que són el primari. Patroni ho prevé mitjançant el quòrum d'etcd: si un node no pot escriure a etcd no pot ser primari. Usar sempre un nombre imparell de nodes etcd (3 o 5) per garantir el quòrum.

Filestore desincronitzat

Si el filestore no està correctament compartit, els adjunts creats en un node no es veuen des de l'altre. Símptoma: imatges de producte o documents que apareixen i desapareixen segons quin node serveix la petició. Solució: NFS amb opcions rsize=131072,wsize=131072,hard,intr o migrar a S3.

Connexions a la BD que no s'alliberen després del failover

Odoo obre connexions a PostgreSQL que poden quedar en estat zombie si el primari cau bruscament. Configurar tcp_keepalives_idle = 60 i tcp_keepalives_interval = 10 a PostgreSQL i al client Odoo (db_maxconn raonable, per defecte 64). PgBouncer com a pooler de connexions entre Odoo i PostgreSQL millora dràsticament la recuperació després del failover.

Cron jobs duplicats

En un clúster de dos nodes d'aplicació Odoo, els cron jobs s'executen a tots dos nodes si max_cron_threads > 0 en tots dos. Això pot causar duplicació de correus, factures generades dues vegades, etc. Solució: dedicar un únic node com a node de cron (max_cron_threads = 2 només en aquell node; en la resta max_cron_threads = 0).

Lag de replicació en hora punta

Amb càrregues de treball d'escriptura intensiva (importacions massives, tancaments comptables), la rèplica pot acumular lag. Si el lag supera maximum_lag_on_failover (1 MB per defecte), Patroni exclou aquell node dels candidats a failover. Monitoritzar el lag amb:

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

Resum de RTO i RPO assolibles

EscenariRTORPONotes
Fallada node PG primari< 30 s0-5 s (async) / 0 (sync)Failover automàtic Patroni
Fallada node aplicació Odoo< 15 s0HAProxy health check, cap estat a l'app
Corrucció dades (disc)< 5 minÚltim snapshot + WALRestaurar des de backup + replay WAL
Manteniment planificat00Rolling restart: treure node de HAProxy, actualitzar, reintegrar

Necessites implementar Alta Disponibilitat al teu Odoo?

Sol·licitar auditoria tècnica graüita

Monitorització d'Odoo amb ELK Stack i alertes de Telegram
Com centralitzar logs i mètriques d'Odoo amb Elasticsearch, Logstash/Filebeat i Kibana, analitzar el format de log propi d'Odoo i rebre alertes proactives en un bot de Telegram quan alguna cosa falla.