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_s3de 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:
- Patroni detecta que el primario no responde al heartbeat etcd (timeout configurable, por defecto 10 s).
- 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.
- El nodo elegido ejecuta
pg_ctl promote: el standby se convierte en primario. Este proceso tarda entre 5 y 15 segundos. - Patroni actualiza la clave DCS para reflejar el nuevo líder.
- HAProxy detecta en el siguiente ciclo de health check (cada 3 s con
fastinter 1stras un fallo) que/masterahora responde 200 en el nuevo primario y 503 en los demás. - 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
| Escenario | RTO | RPO | Notas |
|---|---|---|---|
| Fallo nodo PG primario | < 30 s | 0-5 s (async) / 0 (sync) | Failover automático Patroni |
| Fallo nodo aplicación Odoo | < 15 s | 0 | HAProxy health check, ningún estado en app |
| Corrupción datos (disco) | < 5 min | Último snapshot + WAL | Restaurar desde backup + replay WAL |
| Mantenimiento planificado | 0 | 0 | Rolling restart: sacar nodo de HAProxy, actualizar, reintegrar |