Monitoring #

Logs tell what already happened. Monitoring tells what is happening right now — and warns before a problem becomes an incident. Automating monitoring setup with Ansible ensures every server is monitored immediately from the moment it’s first provisioned, with consistent configuration. This article discusses how to deploy a Prometheus + Grafana monitoring stack using Ansible, from agent installation to alert rules that can be reviewed in pull requests.

The important difference with the logging article: logging is an immutable event-level record, monitoring is a stream of aggregated numeric metrics. Both complement each other — logs answer “why did the CPU go up?”, monitoring answers “which host’s CPU went up?”. This article focuses on monitoring; for logs see the logging article, and for how to collect them from many services see the metric-collection article.

Anatomy of the Prometheus Stack #

Prometheus isn’t just a time-series database — it’s a complete ecosystem with components that have specific roles. Understanding this architecture helps us debug problems and design high availability:

flowchart LR
    A["Node Exporter<br/>:9100"] -->|"scrape HTTP"| P["Prometheus<br/>:9090"]
    B["App Exporter<br/>:9090+"] -->|"scrape HTTP"| P
    C["Pushgateway<br/>:9091"] -->|"push"| P
    P -->|"query PromQL"| AM["Alertmanager<br/>:9093"]
    P -->|"remote_write"| R["Remote Storage<br/>Thanos / Cortex"]
    P -->|"datasource"| G["Grafana<br/>:3000"]
    AM -->|"route"| SL["Slack / PagerDuty / Email"]
    G -->|"dashboard"| U["SRE / Developer"]

    style A stroke:#b45309,stroke-width:2px
    style B stroke:#b45309,stroke-width:2px
    style C stroke:#b45309,stroke-width:2px
    style P stroke:#1d4ed8,stroke-width:2px
    style AM stroke:#be185d,stroke-width:2px
    style G stroke:#15803d,stroke-width:2px
    style U stroke:#7e22ce,stroke-width:2px

The important data flow direction: Prometheus pulls (scrapes) metrics from targets, the targets don’t send (push) to Prometheus. Exception: Pushgateway is used for short-running batch jobs that can’t be scraped.

Prometheus’ pull model has operational consequences we need to understand: if a target behind a firewall isn’t reachable from Prometheus, its metrics won’t come in. For targets in another VPC or behind NAT, use federation or external_labels relabeling during scrape. For microservices in Kubernetes, service discovery via the prometheus.io/scrape annotation is more scalable than static config.

The Four Prometheus Metric Types #

Before writing alert rules or dashboards, understand first the four metric types Prometheus can store. This understanding determines function and storage:

TypeCharacteristicsExample Use CaseFunction in PromQL
CounterOnly increases, resets to 0 on restartTotal HTTP requests, bytes sentrate(), increase()
GaugeArbitrary value, can go up/downCPU usage, memory used, queue depthused directly, or avg_over_time()
HistogramValue distribution in bucketsRequest latency, response sizehistogram_quantile()
SummaryLike histogram, quantile computed client-sideSLA latency trackingdirect quantile

For 90% of use cases, Counter and Gauge are enough. Use histograms when we need p99 latency or distributions. Summaries are rarely used because the quantile is computed client-side — we lose cross-instance aggregation flexibility.


Installing Node Exporter #

Node Exporter is the agent exposing system metrics (CPU, memory, disk, network) in Prometheus format. It must be installed on all production servers, ideally as part of a common role so every new server is automatically monitored:

# roles/node-exporter/tasks/main.yml
---
- name: Create the node_exporter user
  user:
    name: node_exporter
    system: true
    shell: /usr/sbin/nologin
    home: /var/lib/node_exporter
    create_home: false

- name: Download Node Exporter
  get_url:
    url: >
      https://github.com/prometheus/node_exporter/releases/download/
      v{{ node_exporter_version }}/node_exporter-{{ node_exporter_version }}.linux-amd64.tar.gz      
    dest: /tmp/node_exporter.tar.gz
    checksum: "sha256:{{ node_exporter_checksum }}"

- name: Extract Node Exporter
  unarchive:
    src: /tmp/node_exporter.tar.gz
    dest: /tmp/
    remote_src: true

- name: Install the Node Exporter binary
  copy:
    src: "/tmp/node_exporter-{{ node_exporter_version }}.linux-amd64/node_exporter"
    dest: /usr/local/bin/node_exporter
    owner: root
    group: root
    mode: '0755'
    remote_src: true

- name: Deploy the systemd unit file
  template:
    src: node_exporter.service.j2
    dest: /etc/systemd/system/node_exporter.service
  notify:
    - Reload systemd
    - Restart node_exporter

- name: Run Node Exporter
  systemd:
    name: node_exporter
    state: started
    enabled: true
{# roles/node-exporter/templates/node_exporter.service.j2 #}
[Unit]
Description=Prometheus Node Exporter
Documentation=https://prometheus.io/docs/guides/node-exporter/
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=node_exporter
Group=node_exporter
ExecStart=/usr/local/bin/node_exporter \
  --web.listen-address=:9100 \
  --collector.filesystem.mount-points-exclude=^/(sys|proc|dev|host|etc|var/lib/docker/.+)($$|/) \
  --collector.netclass.ignored-devices=^(veth.*|docker.*|br-.*) \
  --collector.netdev.device-exclude=^(veth.*|docker.*|br-.*) \
  --no-collector.wifi
Restart=always
RestartSec=5
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target

Notice the --collector.netclass.ignored-devices=^(veth.*|docker.*|br-.*) flag — without it, the node_network_* metrics on Docker servers get bombarded with metrics from virtual container interfaces, making physical interface monitoring difficult. Choose the collectors we need; the default Node Exporter enables all collectors, some of which are noisy or irrelevant.


Prometheus Configuration with Inventory-Driven Targets #

Prometheus scrape targets must be able to change over time — new servers added, old servers decommissioned, services scaled. Jinja2 templates that read the inventory automatically adapt — no need to manually edit at deployment time:

# roles/prometheus/tasks/main.yml
---
- name: Deploy the Prometheus configuration
  template:
    src: prometheus.yml.j2
    dest: /etc/prometheus/prometheus.yml
    owner: prometheus
    group: prometheus
    mode: '0644'
    validate: promtool check config %s
  notify: Reload Prometheus
{# roles/prometheus/templates/prometheus.yml.j2 #}
global:
  scrape_interval: {{ prometheus_scrape_interval | default('15s') }}
  evaluation_interval: {{ prometheus_evaluation_interval | default('15s') }}
  external_labels:
    cluster: {{ cluster_name }}
    environment: {{ env }}
  query_log_file: /var/log/prometheus/queries.log

alerting:
  alertmanagers:
    - static_configs:
        - targets:
          - alertmanager:9093

rule_files:
  - /etc/prometheus/rules/*.yml

scrape_configs:
  # Prometheus scraping itself
  - job_name: prometheus
    static_configs:
      - targets: ['localhost:9090']

  # Node Exporter from all servers in the inventory
  - job_name: node_exporter
    static_configs:
{% for host in groups['all'] %}
      - targets: ['{{ hostvars[host]['ansible_default_ipv4']['address'] }}:9100']
        labels:
          hostname: {{ host }}
          environment: {{ env }}
          os: {{ hostvars[host].get('ansible_os_family', 'unknown') | lower }}
{% endfor %}
    relabel_configs:
      - source_labels: [__address__]
        target_label: __address__
        replacement: '${1}:9100'

  # Application metrics if available
  - job_name: {{ app_name }}
    static_configs:
{% for host in groups['appservers'] %}
      - targets: ['{{ hostvars[host]['ansible_default_ipv4']['address'] }}:{{ app_metrics_port | default(9090) }}']
        labels:
          hostname: {{ host }}
          tier: application
{% endfor %}
    metrics_path: /metrics

The validate: promtool check config %s parameter is mandatory — it runs promtool on the remote host to validate config syntax and structure before the file is overwritten. Without validation, a wrong configuration makes Prometheus fail to reload and we lose monitoring without knowing the cause.

Anti-Pattern Pair: Hardcoded Targets vs Inventory-Driven #

{# ANTI-PATTERN: targets hardcoded in the config file, must be manually edited when servers are added #}
{# roles/prometheus/templates/prometheus.yml.j2 #}
scrape_configs:
  - job_name: node_exporter
    static_configs:
      - targets:
          - '10.0.1.10:9100'
          - '10.0.1.11:9100'
          - '10.0.1.12:9100'
        labels:
          environment: production
# Problem: every new server must be manually added to this file, then committed,
# then the playbook run. During auto-scaling (5 servers at once),
# targets will lag behind and new servers won't be monitored.

{# CORRECT: read from the Ansible inventory dynamically #}
{# roles/prometheus/templates/prometheus.yml.j2 #}
scrape_configs:
  - job_name: node_exporter
    static_configs:
{% for host in groups['all'] %}
      - targets: ['{{ hostvars[host]['ansible_default_ipv4']['address'] }}:9100']
        labels:
          hostname: {{ host }}
          environment: {{ env }}
{% endfor %}
# Result: run the playbook on a new server → automatically added to the target list.
# During auto-scaling, re-run the playbook after the instance is ready.
# No file editing or committing needed.

Alert Rules as Code #

Alert rules define the conditions that trigger notifications. Storing them as files managed by Ansible ensures the same alerts apply in all environments and can be reviewed via pull requests before being deployed to production:

# roles/prometheus/tasks/alert-rules.yml
---
- name: Create the rules directory
  file:
    path: /etc/prometheus/rules
    state: directory
    owner: prometheus
    mode: '0755'

- name: Deploy alert rules
  template:
    src: "{{ item }}"
    dest: "/etc/prometheus/rules/{{ item | basename | replace('.j2', '') }}"
    owner: prometheus
    mode: '0644'
  with_fileglob:
    - "templates/rules/*.j2"
  notify: Reload Prometheus

- name: Validate the rules syntax
  command: promtool check rules /etc/prometheus/rules/*.yml
  register: rules_check
  changed_when: false
  failed_when: rules_check.rc != 0
# roles/prometheus/templates/rules/system.yml.j2
---
groups:
  - name: system
    rules:
      - alert: HighCPUUsage
        expr: 100 - (avg by(instance) (irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > {{ alert_cpu_threshold | default(85) }}
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "High CPU usage on {{ '{{' }} $labels.instance {{ '}}' }}"
          description: "CPU usage {{ '{{' }} $value | humanizePercentage {{ '}}' }} for the last 5 minutes"
          runbook_url: "https://runbooks.example.com/cpu-high"

      - alert: LowDiskSpace
        expr: (node_filesystem_avail_bytes{mountpoint="/"} / node_filesystem_size_bytes{mountpoint="/"}) < {{ alert_disk_threshold | default(0.15) }}
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "Disk almost full on {{ '{{' }} $labels.instance {{ '}}' }}"
          description: "Disk remaining {{ '{{' }} $value | humanizePercentage {{ '}}' }} (mountpoint /)"

      - alert: HostDown
        expr: up == 0
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Host unresponsive: {{ '{{' }} $labels.instance {{ '}}' }}"
          description: "Prometheus cannot scrape {{ '{{' }} $labels.instance {{ '}}' }} for 2 minutes"

      - alert: HighMemoryUsage
        expr: (1 - (node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes)) > {{ alert_memory_threshold | default(0.90) }}
        for: 10m
        labels:
          severity: warning
        annotations:
          summary: "High memory usage on {{ '{{' }} $labels.instance {{ '}}' }}"
          description: "Memory usage {{ '{{' }} $value | humanizePercentage {{ '}}' }} (threshold 90%)"

The four alerts above are the baseline that must exist in all environments. Add application-specific alerts as needed. Important: every alert we create must have a runbook URL in its annotation — when an alert fires at 3 AM, the on-call team must be able to immediately get mitigation instructions without scrolling through Slack history.

Severity and Routing #

Not all alerts are equally important. Use the severity label for routing in Alertmanager — critical to PagerDuty, warning to a Slack channel, info to weekly email:

# roles/alertmanager/tasks/main.yml — routing config
- name: Deploy the Alertmanager configuration
  template:
    src: alertmanager.yml.j2
    dest: /etc/alertmanager/alertmanager.yml
    validate: amtool check-config %s
  notify: Restart Alertmanager
# roles/alertmanager/templates/alertmanager.yml.j2
route:
  group_by: ['alertname', 'cluster', 'service']
  group_wait: 30s
  group_interval: 5m
  repeat_interval: 4h
  receiver: 'default'
  routes:
    - match:
        severity: critical
      receiver: 'pagerduty'
      group_wait: 10s
      repeat_interval: 1h
    - match:
        severity: warning
      receiver: 'slack-warn'
      group_wait: 1m
      repeat_interval: 4h
    - match_re:
        severity: ^(info|debug)$
      receiver: 'null'

receivers:
  - name: 'default'
    slack_configs:
      - channel: '#alerts-general'
        send_resolved: true

  - name: 'pagerduty'
    pagerduty_configs:
      - service_key: "{{ vault_pagerduty_service_key }}"
        send_resolved: true
        description: '{{ .CommonAnnotations.summary }}'

  - name: 'slack-warn'
    slack_configs:
      - channel: '#alerts-warning'
        send_resolved: true
        title_link: 'https://grafana.example.com/alerting/grafana/{{ .GroupLabels.alertname }}'

  - name: 'null'
Avoid alert spam. Alerts firing every 5 minutes make people ignore ALL alerts, including the important ones. Rule of thumb: every alert must be actionable (something can be done), rare (not firing daily), and specific (clear what’s wrong). If our alert fires every hour and no action is taken, the threshold is wrong or the alert is irrelevant — disable it or raise the threshold.

Setting Up Grafana with Automatic Provisioning #

Grafana set up manually through UI clicking will drift within weeks: person A adds a datasource, person B changes a dashboard, and nobody remembers the actual configuration. YAML file provisioning ensures datasources and dashboards are always identical across all environments:

# roles/grafana/tasks/main.yml
---
- name: Install Grafana
  apt:
    name: "grafana={{ grafana_version }}"
    state: present

- name: Deploy the Grafana configuration
  template:
    src: grafana.ini.j2
    dest: /etc/grafana/grafana.ini
    owner: root
    group: grafana
    mode: '0640'
  notify: Restart Grafana

- name: Provision the Prometheus datasource
  template:
    src: datasource-prometheus.yml.j2
    dest: /etc/grafana/provisioning/datasources/prometheus.yml
    owner: root
    group: grafana
    mode: '0640'
  notify: Restart Grafana

- name: Provision automatic dashboards
  copy:
    src: "files/dashboards/{{ item }}"
    dest: "/etc/grafana/provisioning/dashboards/{{ item }}"
    owner: root
    group: grafana
    mode: '0640'
  with_fileglob:
    - "*.json"
  notify: Restart Grafana
{# templates/datasource-prometheus.yml.j2 #}
apiVersion: 1

datasources:
  - name: Prometheus
    type: prometheus
    access: proxy
    url: http://{{ prometheus_host }}:9090
    isDefault: true
    editable: false
    jsonData:
      timeInterval: 15s
      httpMethod: POST
      manageAlerts: true
      prometheusType: Prometheus
# templates/dashboards/dashboards.yml — provider config
apiVersion: 1

providers:
  - name: 'default'
    orgId: 1
    folder: 'Production'
    type: file
    disableDeletion: true
    updateIntervalSeconds: 30
    allowUiUpdates: false
    options:
      path: /var/lib/grafana/dashboards
      foldersFromFilesStructure: true

disableDeletion: true and allowUiUpdates: false prevent changes through the UI — all changes must go through Ansible. For dev/staging environments, set allowUiUpdates: true so developers can experiment.


A Healthy Monitoring Setup State #

Monitoring that’s already deployed must keep being monitored for its own condition. If monitoring goes down during an incident, we become blind. Use meta-monitoring to ensure the monitoring itself stays alive:

stateDiagram-v2
    [*] --> Setup: "Ansible deploy"
    Setup --> Scrape: "Prometheus scrape targets"
    Scrape --> Healthy: "all targets up"
    Scrape --> Degraded: "1-2 targets down"
    Scrape --> Broken: ">50% targets down"
    Healthy --> Scrape: "scrape interval"
    Degraded --> Alerting: "Alertmanager notify"
    Alerting --> Scrape: "target recovered"
    Broken --> [*]: "monitoring blind"
    Healthy --> [*]: "planned maintenance"

Alerts for monitoring-down itself:

# roles/prometheus/templates/rules/monitoring-self.yml.j2
---
groups:
  - name: monitoring-health
    rules:
      - alert: PrometheusTargetDown
        expr: up == 0
        for: 5m
        labels:
          severity: warning
        annotations:
          summary: "Prometheus cannot scrape {{ '{{' }} $labels.instance {{ '}}' }}"
          description: "Job {{ '{{' }} $labels.job {{ '}}' }} target {{ '{{' }} $labels.instance {{ '}}' }} down 5 minutes"

      - alert: PrometheusDown
        expr: absent(up{job="prometheus"})
        for: 2m
        labels:
          severity: critical
        annotations:
          summary: "Prometheus itself is unmonitored"
          description: "Prometheus `up` metric is missing — Prometheus likely crashed"

      - alert: AlertmanagerDown
        expr: absent(alertmanager_alerts)
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: "Alertmanager cannot be reached by Prometheus"

      - alert: TooManyTargetsDown
        expr: 100 * (sum by(job) (up == 0) / count by(job) (up)) > 50
        for: 5m
        labels:
          severity: critical
        annotations:
          summary: ">50% of {{ '{{' }} $labels.job {{ '}}' }} targets down"
          description: "Monitoring blind for this job"
The PrometheusDown alert is critical without a route to a specific receiver — we can do this with receiver: pagerduty in Alertmanager. But be careful: if Prometheus is dead, Prometheus can’t send alerts. The solution: run Alertmanager separately on a different host, and monitor Alertmanager with a health check from an external system (could also be a ping from a different host, or use a SaaS uptime monitoring service).

Relabel Config: Automatic Label Cleanup #

Prometheus metrics come with many default labels we don’t always need. relabel_configs cleans and standardizes labels before they’re stored in the TSDB. The most useful example: drop unnecessary labels and add environment:

# Add to a specific job's scrape_configs
scrape_configs:
  - job_name: node_exporter
    static_configs:
      - targets: ['host1:9100', 'host2:9100']
    relabel_configs:
      # Drop the original instance label, replace with hostname
      - source_labels: [__address__]
        regex: '([^:]+)(:\d+)?'
        target_label: hostname
        replacement: '${1}'

      # Drop empty labels
      - action: labeldrop
        regex: '__meta_kubernetes_pod_label_(?!app|name).*'

      # Only scrape targets with the env label
      - source_labels: [__meta_ec2_tag_Environment]
        regex: 'production'
        action: keep

action: keep is very useful when scrape targets are mixed and we only want a subset. Example: in a shared cluster with staging and production, keep only those with the Environment=production tag.


Decision Tree: Prometheus or Alternatives? #

The monitoring stack choice determines many downstream things. This decision tree is for initial orientation:

flowchart TD
    A["Need monitoring<br/>for?"] -->|"Kubernetes-native"| K["Prometheus +<br/>Grafana + Loki"]
    A -->|"Classic VM / bare metal"| B{"Metric volume<br/>per second?"}

    B -->|"< 1M"| E["Prometheus<br/>self-hosted"]
    B -->|"1-10M"| F["Prometheus +<br/>Thanos / Mimir"]
    B -->|"> 10M"| G["Hosted solution<br/>Datadog / Grafana Cloud"]

    A -->|"Cloud-native AWS"| H["CloudWatch +<br/>managed Prometheus"]
    A -->|"Strict compliance"| I["Vendor with<br/>SLA & support"]

    K --> J["scrape config<br/>via annotations"]
    E --> L["static or file_sd"]
    F --> M["object storage<br/>S3 / GCS"]

    style A stroke:#b45309,stroke-width:2px
    style E stroke:#15803d,stroke-width:2px
    style F stroke:#15803d,stroke-width:2px
    style G stroke:#be185d,stroke-width:2px
CriteriaPrometheus Self-HostedHosted (Datadog/Cloud)
Cost per host/monthInfrastructure only (~$5-20)$15-30 per host
Custom metricsFree, unlimitedPaid per metric
ConfigurationYAML in Git (as code)UI / API, drift risk
OperationsTeam needs Prometheus expertiseVendor handles scaling
Lock-inLow (open format)High
Best forDevOps/SRE teams already using K8sSmall teams without dedicated ops

Anti-Pattern Pair: Reload Without Validation vs With Validation #

# ANTI-PATTERN: copying the config file directly without validation
- name: Deploy the Prometheus configuration
  copy:
    src: prometheus.yml
    dest: /etc/prometheus/prometheus.yml
  notify: Reload Prometheus
# Problem: if there's a typo or structural error, Prometheus fails to reload.
# Monitoring goes down without an alert firing — because the monitoring itself is down.
# We only realize it when someone reports "Grafana dashboard is empty" some time later.

# CORRECT: validate the syntax before the file is overwritten
- name: Deploy the Prometheus configuration
  template:
    src: prometheus.yml.j2
    dest: /etc/prometheus/prometheus.yml
    validate: promtool check config %s   # ✓ promtool runs remotely, returns 0 if OK
  notify: Reload Prometheus
# Result: if the config is wrong, the task fails before the file is written. Prometheus
# keeps using the valid old config. We get a clear error message
# in the Ansible output: which line, which field is wrong.

promtool is Prometheus’ official CLI for config validation. Install it as a role dependency:

# roles/prometheus/tasks/dependencies.yml
- name: Download promtool
  get_url:
    url: "https://github.com/prometheus/prometheus/releases/download/v{{ prometheus_version }}/prometheus-{{ prometheus_version }}.linux-amd64.tar.gz"
    dest: /tmp/prometheus.tar.gz

- name: Install the promtool binary
  unarchive:
    src: /tmp/prometheus.tar.gz
    dest: /usr/local/bin/
    include: ['promtool']
    extra_opts: ['--strip-components=1']
    remote_src: true
    creates: /usr/local/bin/promtool

Anti-Pattern Pair: Same Threshold for All Hosts vs Role-Based Thresholds #

# ANTI-PATTERN: a single threshold for all servers
- alert: HighCPUUsage
  expr: 100 - (avg by(instance) (irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 85
  for: 5m
# Problem: database servers that are genuinely CPU-intensive for legitimate queries
# will trigger an alert every 5 minutes. The alert becomes noise and tends to be ignored.

# CORRECT: different thresholds per role
- alert: HighCPUUsageDatabase
  expr: |
    100 - (avg by(instance) (irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 90    
  for: 10m
  labels:
    role: database
  annotations:
    summary: "DB server high CPU: {{ '{{' }} $labels.instance {{ '}}' }}"

- alert: HighCPUUsageApp
  expr: |
    100 - (avg by(instance) (irate(node_cpu_seconds_total{mode="idle"}[5m])) * 100) > 75    
  for: 5m
  labels:
    role: application
  annotations:
    summary: "App server high CPU: {{ '{{' }} $labels.instance {{ '}}' }}"
# Result: alerts specific to role, realistic thresholds for each
# workload. DB alerts fire at 90% (very high), app alerts at
# 75% (stricter because apps usually aren't CPU-bound).

A more scalable approach: separate alert definitions into per-role files (rules/database.yml, rules/application.yml, rules/worker.yml) and load them via a rule_files glob in the main config. Thresholds can differ, and when adding a new service just add a file.


Connecting Monitoring with Logging #

Monitoring tells “high CPU on host X”. Logging tells “why is CPU high on host X”. Both are most powerful when combined in the same dashboard. The logging article already explained the Loki setup; here we’ll see how to unify labels:

# roles/prometheus/tasks/integration.yml
---
- name: Ensure Prometheus labels are consistent with Loki
  lineinfile:
    path: /etc/prometheus/prometheus.yml
    regexp: '^    environment:'
    line: "    environment: {{ env }}"
    insertbefore: '^scrape_configs:'
  notify: Reload Prometheus

Grafana can display log panels (Loki) and metric panels (Prometheus) in one dashboard. Just click an anomaly point on the CPU graph to directly see the error logs on the same host with automatic filters.


Summary #

  • Node Exporter on every managed node exposes system metrics — install it on all servers as part of a common role, not as a separate step.
  • Prometheus config templates use hostvars and groups to automatically add all servers in the inventory as scrape targets — no manual editing needed when new servers are added.
  • validate: promtool check config %s to validate the Prometheus configuration before saving — a wrong configuration can make Prometheus fail to reload without any alert firing.
  • Alert rules as template files managed by Ansible are monitoring as code — reviewable in Git, consistent across all environments, and rollbackable via git revert.
  • Grafana provisioning via YAML files allows datasources and dashboards to be configured automatically when Grafana restarts — no manual UI clicking that’s prone to drift.
  • Pin versions of Prometheus, Node Exporter, and Grafana in defaults/main.yml — uncontrolled minor updates can break dashboards or change metric formats.
  • Alerts must have a runbook_url in the annotation — when an alert fires, the on-call person needs a direct link to mitigation instructions, not to scroll through Slack history.
  • Monitor the monitoring itself: PrometheusDown, AlertmanagerDown, and TooManyTargetsDown alerts ensure we know when blind spots occur.

← Previous: Logging Next: Alerting →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact