Monitor Odoo on Docker Swarm: Prometheus, Loki, Alerts

Monitor Odoo on Docker Swarm with Prometheus Swarm discovery, cAdvisor, node-exporter, Alloy and Loki, plus Odoo log signals and alerts that matter.

Updated . 8 min read

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

LayerToolQuestions it answers
Nodesnode-exporterIs a node down, out of disk, or out of memory?
ContainerscAdvisorHow much CPU and memory is each Odoo task using, compared with its limit?
SwarmPrometheus Swarm discovery, docker service psAre tasks restarting? Are any stuck in Pending?
OdooOdoo log in LokiSlow requests, worker recycling and timeouts, cron failures, websocket errors
PostgreSQLpostgres_exporter, PostgreSQL logConnections, 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.

monitoring.yml
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: true

Notes on the choices:

  • No depends_on. docker stack deploy ignores 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.yml only exists on the node where you created it. Configs cannot be edited in place, so rename them (for example prometheus_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.

prometheus.yml
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: node

Any 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:

config.alloy
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 passed limit_memory_soft and 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 Ns at level ERROR: a request ran longer than limit_time_real and the worker was killed. The user saw an error. This is the most important line to alert on.
  • CPU time limit (...) reached: the same for limit_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 /websocket answered with status 400 and Couldn'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 out and server 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

alerts.yml
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.

Rather not run the server yourself?

Get managed Odoo hosting with backups, SSL, monitoring and updates handled for you. Free for 10 days.