To monitor Odoo on Docker Swarm you need more than CPU graphs. Tasks move between nodes, containers are replaced on every deploy, and the most useful signals (workers recycled for memory, requests killed for running too long, scheduled actions that time out) only appear in Odoo's own log. This guide builds a current, open-source monitoring stack for Odoo on Swarm: Prometheus with native Swarm discovery, node-exporter and cAdvisor for metrics, Grafana Alloy and Loki for logs, and the Odoo-specific queries and alerts that make it worth having.
The examples assume an Odoo stack deployed as erp (service erp_odoo), as in the Odoo on Docker Swarm pillar guide.
What to monitor in an Odoo cluster
| Layer | Tool | Questions it answers |
|---|---|---|
| Nodes | node-exporter | Is a node down, out of disk, or out of memory? |
| Containers | cAdvisor | How much CPU and memory is each Odoo task using, compared with its limit? |
| Swarm | Prometheus Swarm discovery, docker service ps | Are tasks restarting? Are any stuck in Pending? |
| Odoo | Odoo log in Loki | Slow requests, worker recycling and timeouts, cron failures, websocket errors |
| PostgreSQL | postgres_exporter, PostgreSQL log | Connections, locks, slow queries, database size |
Why not GELF, Fluentd and Graylog
Older Swarm guides send container logs with the GELF logging driver to an address like udp://graylog:12201. That cannot work on Swarm. Logging drivers run inside the Docker daemon on the host, and the host resolves names with its normal DNS, not with the overlay network's DNS. The daemon never finds a service called graylog. You would have to publish the GELF port and point every node at a host address, and you lose docker service logs in the process. The Graylog, MongoDB and Elasticsearch versions in those guides are also long past end of life.
The setup below leaves the default json-file logging driver alone. A small agent on each node reads container logs through the Docker API and ships them to Loki, so docker service logs keeps working.
The monitoring stack
Create the config files next to the stack file (shown in the next sections), then deploy with docker stack deploy -c monitoring.yml mon. Pin each image to the current release when you deploy; the tags below are examples.
services:
prometheus:
image: prom/prometheus:v3.5.0
user: root # needs to read the Docker socket for Swarm discovery
command:
- --config.file=/etc/prometheus/prometheus.yml
- --storage.tsdb.path=/prometheus
- --storage.tsdb.retention.time=30d
configs:
- source: prometheus_yml
target: /etc/prometheus/prometheus.yml
- source: alerts_yml
target: /etc/prometheus/alerts.yml
volumes:
- prometheus-data:/prometheus
- /var/run/docker.sock:/var/run/docker.sock:ro
networks: [monitoring]
deploy:
placement:
constraints: [node.role == manager]
node-exporter:
image: prom/node-exporter:v1.9.1
command:
- --path.rootfs=/host
- --path.procfs=/host/proc
- --path.sysfs=/host/sys
volumes:
- type: bind
source: /
target: /host
read_only: true
bind:
propagation: rslave
networks: [hostnet]
deploy:
mode: global
cadvisor:
image: ghcr.io/google/cadvisor:0.55.1
volumes:
- /:/rootfs:ro
- /var/run:/var/run:ro
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /dev/disk/:/dev/disk:ro
networks: [monitoring]
deploy:
mode: global
labels:
prometheus-job: cadvisor
prometheus-port: "8080"
loki:
image: grafana/loki:3.5.0
volumes:
- loki-data:/loki
networks: [monitoring]
deploy:
placement:
constraints: [node.labels.monitoring == true]
alloy:
image: grafana/alloy:v1.10.0
command: ["run", "/etc/alloy/config.alloy", "--storage.path=/var/lib/alloy/data"]
configs:
- source: alloy_config
target: /etc/alloy/config.alloy
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
networks: [monitoring]
deploy:
mode: global
grafana:
image: grafana/grafana:12.1.0
environment:
GF_SECURITY_ADMIN_PASSWORD__FILE: /run/secrets/grafana_admin_password
secrets: [grafana_admin_password]
volumes:
- grafana-data:/var/lib/grafana
networks: [monitoring]
networks:
monitoring:
driver: overlay
hostnet:
external: true
name: host
volumes:
prometheus-data:
loki-data:
grafana-data:
configs:
prometheus_yml:
file: ./prometheus.yml
alerts_yml:
file: ./alerts.yml
alloy_config:
file: ./config.alloy
secrets:
grafana_admin_password:
external: trueNotes on the choices:
- No
depends_on.docker stack deployignores it. Every component here retries its connections, which is the Swarm way to handle start order. - Configs, not bind-mounted files. Swarm configs reach whichever node a task lands on. A bind-mounted
prometheus.ymlonly exists on the node where you created it. Configs cannot be edited in place, so rename them (for exampleprometheus_yml_v2) when the file changes. - node-exporter on the host network so its network metrics describe the node, not a container. Prometheus reaches it at each node's address on port 9100; allow that port between swarm nodes only.
- cAdvisor without
--privileged. Swarm services cannot run privileged, so a few metrics that depend on it (for example per-process metrics, which cAdvisor's docs say need--privileged) may be missing. Container CPU, memory and network metrics work. - Prometheus on a manager, because Swarm discovery talks to the manager API through the Docker socket. Loki and Prometheus store data on a local volume, so pin them to one node with a constraint.
- Grafana publishes no port here. Put it behind your reverse proxy with HTTPS, the same way as Odoo, and create the admin password secret first.
Prometheus with Swarm service discovery
Prometheus has native Swarm discovery (dockerswarm_sd_configs). It is better than DNS discovery with tasks.<service> because every target arrives with labels for the service, node and task state.
global:
scrape_interval: 30s
rule_files:
- /etc/prometheus/alerts.yml
scrape_configs:
- job_name: node
dockerswarm_sd_configs:
- host: unix:///var/run/docker.sock
role: nodes
relabel_configs:
- source_labels: [__meta_dockerswarm_node_address]
target_label: __address__
replacement: $1:9100
- source_labels: [__meta_dockerswarm_node_hostname]
target_label: instance
- job_name: swarm-tasks
dockerswarm_sd_configs:
- host: unix:///var/run/docker.sock
role: tasks
relabel_configs:
# only tasks that should be running
- source_labels: [__meta_dockerswarm_task_desired_state]
regex: running
action: keep
# only services that opted in with a prometheus-job label
- source_labels: [__meta_dockerswarm_service_label_prometheus_job]
regex: .+
action: keep
# scrape over the monitoring network only
- source_labels: [__meta_dockerswarm_network_name]
regex: mon_monitoring
action: keep
# use the port from the prometheus-port label
- source_labels: [__address__, __meta_dockerswarm_service_label_prometheus_port]
regex: '([^:]+):\d+;(\d+)'
target_label: __address__
replacement: $1:$2
- source_labels: [__meta_dockerswarm_service_label_prometheus_job]
target_label: job
- source_labels: [__meta_dockerswarm_node_hostname]
target_label: nodeAny service can opt in by adding prometheus-job and prometheus-port under deploy.labels and joining the mon_monitoring network. To collect PostgreSQL metrics this way, run postgres_exporter in your Odoo stack on both the backend and monitoring networks.
Collecting Odoo logs with Alloy and Loki
Alloy runs on every node, discovers the local containers, labels each log line with its Swarm service and stack, and pushes to Loki:
discovery.docker "local" {
host = "unix:///var/run/docker.sock"
}
discovery.relabel "swarm" {
targets = []
rule {
source_labels = ["__meta_docker_container_label_com_docker_swarm_service_name"]
target_label = "service"
}
rule {
source_labels = ["__meta_docker_container_label_com_docker_stack_namespace"]
target_label = "stack"
}
}
loki.source.docker "containers" {
host = "unix:///var/run/docker.sock"
targets = discovery.docker.local.targets
relabel_rules = discovery.relabel.swarm.rules
forward_to = [loki.write.default.receiver]
}
loki.write "default" {
endpoint {
url = "http://loki:3100/loki/api/v1/push"
}
}In Grafana, add Prometheus (http://prometheus:9090) and Loki (http://loki:3100) as data sources. {service="erp_odoo"} now shows the Odoo log from every replica on every node in one place.
Odoo writes everything you need to stdout by default: each line carries a timestamp, process ID, level, database, logger name and message. Keep log_level at info in production. debug floods Loki and costs real disk.
Odoo-specific signals worth watching
These come straight from Odoo's server code, so the patterns are stable. Message texts below are from Odoo 18; older versions word some of them differently.
Slow requests
Every HTTP request line ends with three numbers: the SQL query count, the time spent in SQL, and the remaining (Python) time, in seconds. For example: "POST /web/dataset/call_kw/sale.order/read HTTP/1.1" 200 - 48 0.091 0.412. Pull them out with LogQL to find slow endpoints:
{service="erp_odoo"} |= "HTTP/1.1"
| regexp `"(?P<method>[A-Z]+) (?P<path>\S+) HTTP/1.1" (?P<status>\d{3}) - (?P<queries>\d+) (?P<sql_time>[0-9.]+) (?P<py_time>[0-9.]+)`
| py_time > 3 or sql_time > 3
High sql_time points at the database (missing index, lock waits). High py_time with few queries points at Python code, often a custom module or a large report. A query count in the thousands for one request is the classic N+1 problem in a custom module.
Worker memory and time limits
virtual memory limit (...) reached: a worker passedlimit_memory_softand is recycled after finishing its request. Occasional lines are normal. Many per hour mean the limit is too low for your workload or a request loads too much data.WorkerHTTP (pid) timeout after Nsat level ERROR: a request ran longer thanlimit_time_realand the worker was killed. The user saw an error. This is the most important line to alert on.CPU time limit (...) reached: the same forlimit_time_cpu.
Pair these with the cAdvisor memory graph for erp_odoo. If the container approaches its Swarm memory limit, the kernel kills processes before Odoo's own limits act. The guide to Odoo memory limits in Docker explains how to size limit_memory_* against the container limit.
Websocket (longpolling) process
Gevent virtual memory limit reached: the process that serves websockets (port 8072) passed its memory limit and restarts. Live chat and notifications drop for everyone connected.- Requests to
/websocketanswered with status 400 andCouldn't bind the websocket. Is the connection opened on the evented port (8072)?: the proxy is sending websocket traffic to port 8069. Fix the route.
Scheduled actions (cron)
Job 'Name' (id) done in 12.345s: duration of each run. Graph it to see jobs creeping up.Job 'Name' (id) timed outandserver action #n failed: failed runs. Email queues, bank syncs and recurring invoices live here, and failures are silent for users until something is missing.
Alerts
groups:
- name: odoo-swarm
rules:
- alert: NodeDown
expr: up{job="node"} == 0
for: 2m
labels: {severity: critical}
- alert: NodeDiskLow
expr: node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"} < 0.15
for: 10m
labels: {severity: warning}
- alert: OdooMemoryNearLimit
expr: |
container_memory_working_set_bytes{container_label_com_docker_swarm_service_name="erp_odoo"}
/ (container_spec_memory_limit_bytes{container_label_com_docker_swarm_service_name="erp_odoo"} > 0)
> 0.9
for: 10m
labels: {severity: warning}
- alert: OdooTaskChurn
# more than 3 different Odoo containers in 15 minutes for a 1-replica service
expr: |
count by (container_label_com_docker_swarm_service_name) (
count_over_time(container_start_time_seconds{container_label_com_docker_swarm_service_name="erp_odoo"}[15m])
) > 3
labels: {severity: critical}For the log-based signals, create Grafana alert rules on Loki queries, for example:
sum(count_over_time({service="erp_odoo"} |= "timeout after" [10m])) > 0
sum(count_over_time({service="erp_odoo"} |~ "Job .* timed out|server action #[0-9]+ failed" [1h])) > 0
Route alerts to email or chat with Grafana alerting or Alertmanager. Alert on what users feel (killed requests, failing cron jobs, a node down) before raw CPU, which is often high on a healthy Odoo during reports.
Quick checks without dashboards
docker service ls # replicas running vs desired
docker service ps erp_odoo --no-trunc # task history, errors, nodes
docker service logs erp_odoo --since 30m --timestamps | grep -E "ERROR|timeout after"
docker stats --no-stream # per-container usage on this node
When the numbers say something is wrong, the Odoo on Swarm troubleshooting guide takes it from there. For log handling on a single Compose host, see Odoo Docker logs.
Every Easy Instance includes live and historical CPU, memory, storage and worker metrics, plus searchable Odoo logs, in the dashboard, with no monitoring stack to run. See features and pricing, or start a 10-day free trial with no credit card.