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_s3permet 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:
- Patroni detecta que el primari no respon al heartbeat d'etcd (timeout configurable, per defecte 10 s).
- 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.
- El node elegit executa
pg_ctl promote: el standby es converteix en primari. Aquest procés triga entre 5 i 15 segons. - Patroni actualitza la clau DCS per reflectir el nou líder.
- HAProxy detecta en el següent cicle de health check (cada 3 s amb
fastinter 1sdesprés d'una fallada) que/masterara respon 200 al nou primari i 503 als altres. - 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
| Escenari | RTO | RPO | Notes |
|---|---|---|---|
| Fallada node PG primari | < 30 s | 0-5 s (async) / 0 (sync) | Failover automàtic Patroni |
| Fallada node aplicació Odoo | < 15 s | 0 | HAProxy health check, cap estat a l'app |
| Corrucció dades (disc) | < 5 min | Últim snapshot + WAL | Restaurar des de backup + replay WAL |
| Manteniment planificat | 0 | 0 | Rolling restart: treure node de HAProxy, actualitzar, reintegrar |