Skip to content

Step 4 of 7 in the DevOps journey · see the full journey

2025

Sherlock Logs

A full observability platform. Prometheus, Grafana, Alertmanager and ELK, deployed from twenty Ansible roles.

Overview

I extended the Ansible-managed fleet with a dedicated monitoring host, a Prometheus alerting pipeline and centralised logs through Filebeat and ELK. The goal was to investigate the application as well as its hosts, and make dashboards and alerts part of the configuration.

context
Coursework
status
Archived learning project
role
Solo
started
Jul 2025
setting
Local VM lab running Prometheus, Grafana, Alertmanager and ELK alongside the application.
built with
  • Ansible
  • Prometheus 2.53
  • Grafana
  • Alertmanager
  • Elasticsearch 8.18
  • Logstash
  • +4

Key decisions

Collect metrics and logs through separate pipelines
Prometheus scrapes exporters and application counters; Filebeat and Logstash send logs into Elasticsearch for Kibana. Each pipeline has distinct dependencies and failure modes.
Match dashboards to the metrics actually emitted
I inspected candidate dashboard queries against this cAdvisor version, then provisioned compatible dashboards and added custom panels.
Include application and security signals
The API-error rule uses backend request counters, while Logstash turns SSH failure messages into structured event categories.

Demonstrated results

The log-shipping feedback loop was removed
After syslog grew to 18 GB, the shipping configuration excluded Filebeat and Logstash's own lines so the pipeline stopped re-ingesting its output.
Dashboard compatibility was checked through queries
Reading PromQL and metric names found a compatible community dashboard and supported custom VM, container and application views.
Alerts covered more than host availability
The declared rules include backend error rates and projected disk exhaustion, using application counters and fitted filesystem trends respectively.

Known limitations

Active backups did not carry into this stage
The backup host was replaced by the monitor, and the archived backup plays are disabled. Better observability did not preserve the previous backup workflow.
Log retention and cold-start load remain concerns
There is no index lifecycle deletion policy, and agents can ship their backlog together at startup. Resource tuning mitigated the contention rather than removing it.
A rebuild needs local credentials
An unconfigured checkout needs the WireGuard private key and Ansible vault password supplied before provisioning can complete.

Two paradigms, one machine

The stage's premise is running both monitoring approaches side by side: a pull pipeline scraping exporters into Prometheus, and a push pipeline shipping logs to Logstash. They have genuinely different failure modes. That turned out to be the lesson rather than a detail.

Four separate memory episodes, escalating:

Symptom Cause Fix
ES killed by the JVM a JVM heap larger than the VM could hold heap pinned in jvm.options
Kibana timing out OS + Prometheus + Grafana + ES + Kibana competing for one box the monitor grew to 6 GiB, 5 vCPU
Load average 45.55 every agent shipping its backlog at once pipeline.workers: 1
Crash loop plus a mystery enrollment screen apt upgrade pulled a newer ELK with X-Pack on by default X-Pack disabled in the rendered config, plus a health check that waits for cluster status before starting Logstash

Each of those was an intermediate step, and the numbers moved again afterwards. What the repo actually ships is monitor-server at 6144 MB and 5 vCPU, with both the Elasticsearch and Logstash heaps pinned to 1g. The 512m and 256m values quoted in the project README are stale. They survive only in a comment in the Logstash role that was never updated when the value changed.

The fourth had the most misleading symptom. Kibana showed an enrollment screen that had nothing to do with Kubernetes. The cause was an apt upgrade inside a shared role silently upgrading the whole ELK stack. The VM crashed before the configuration that disabled X-Pack could be applied.

The fix is a health check rather than a sleep:

polling /_cluster/health until the cluster status reports as yellow, guaranteeing that Elasticsearch is fully initialized before the playbook proceeds to start Logstash

The 18GB file

Filebeat ships /var/log/syslog. Logstash writes to /var/log/syslog. Filebeat ships those lines. Logstash logs about them.

# /etc/filebeat/filebeat.yml
exclude_lines: ['logstash', 'filebeat']

The template marks this line as the critical fix. Before it, a single vagrant provision filled the root partition:

Logstash and Filebeat were writing their own operational logs to /var/log/syslog. Filebeat was then reading these same log entries and shipping them back to Logstash, which would process them and log about it, creating an infinite loop that rapidly filled the disk.

troubleshooting.md keeps the lesson in one line: if /var/log is the largest directory, a service is generating excessive logs. That was the cause here.

Picking a dashboard that works

Three community Grafana dashboards failed before a fourth was chosen. The strategy that worked was not guesswork:

download the candidate's JSON model from the Grafana API, inspect its PromQL, and pick one whose queries use the exact base metric names that our cAdvisor instance provides

The deployment also used container_label_restartcount where the expected restart counter was not available. That is a property of this exporter setup, not a general claim that all cAdvisor versions expose restart counts this way.

Application and security signals

HighApiErrorRate consumes the Go service's backend_http_requests_total counter, so it can identify an application-error condition separately from host health. The Logstash pipeline also parses SSH failures into categories such as invalid users, failed passwords and rejected keys. These are implemented rules and parsing paths; they are not evidence of monitoring a production incident.

Sherlock, and what its twelve rules actually say

sherlock.rules.yml declares twelve alerts across four groups: HostAlerts, ContainerAlerts, ObservabilityStackAlerts, and an AdvancedAlerts group that is where the interesting two live.

The disk alert combines a forecast with a recent-trend check.

- alert: DiskWillFillUpIn24Hours
  expr: |
    predict_linear(node_filesystem_free_bytes{mountpoint="/"}[4h], 24 * 3600) <= 0
    and
    deriv(node_filesystem_free_bytes{mountpoint="/"}[15m]) < 0

predict_linear fits a line to four hours of free-space samples and predicts the value 24 hours into the future. The first clause checks whether that forecast is zero or below; it does not test for a zero crossing in the past.

The second clause requires a negative free-space trend over the last 15 minutes. That shorter window can suppress an alert when recent cleanup has reversed the longer-term decline. Both clauses use fitted trends, so this is a conditional forecast, not a guarantee that the disk will fill.

The same file carries a scratch copy of it. TestDiskWillFillUpIn30Minutes runs over a 10-minute window with severity: "testing", retained beside the normal rules in this lab configuration. Twelve declared rules include this scratch rule, and the reason grep -c 'alert:' on this file reports thirteen: line 110 is a comment beginning "# Advanced trend-based alert:". The count that reached the project page was the right one.

One rule depends on the application, not the exporters.

- alert: HighApiErrorRate
  expr: |
    sum(rate(backend_http_requests_total{code=~"5.."}[5m]))
    / sum(rate(backend_http_requests_total[5m])) * 100 > 5

backend_http_requests_total is instrumented in the Go service rather than supplied by node_exporter. It is the only rule in either file that cannot work unless the application is exposing metrics. The file does not document that dependency. It is also what makes this rule the most valuable of the twelve: it is the only one that notices the backend returning 500s while every host is green.

And a typo that shipped. LowDiskSpaceWarning, the rule that fires below 50% free, has the summary:

summary: "Dick space if below 50% on {{ $labels.instance }}"

That notification text remains in the archived rule. Reviewing the expression is only part of reviewing an alert; its message also needs to be useful to the recipient.

Against Automation Alchemy

This project starts from the fleet Automation Alchemy already built and keeps that foundation almost unchanged. The same six machines on 192.168.56.20–25, the same vagrant up → Ansible path, the same nginx round-robin and DOCKER-USER rule, the same Jenkins pipeline. What differs is what the sixth machine became, and everything that follows from that.

The swap happened in a single commit on 9 July 2025. backup-server was commented out of both the Vagrantfile and the inventory. monitor-server appeared at .26 in its place. WireGuard moved to the load balancer, and Prometheus and Grafana moved with it.

Automation Alchemy Sherlock Logs
Sixth machine backup-server, a nightly rsync mirror monitor-server, 6 GiB and 5 vCPU
Metrics node_exporter only plus cAdvisor :8088 and the Go exporter :9091
Application metrics endpoint existed, never scraped backend_http_requests_total, scraped
Logs none Filebeat → Logstash :5044 → Elasticsearch → Kibana
Alerting alertmanagers commented out 12 rules, 4 groups → Alertmanager → email, send_resolved: true
Dashboards 1 community, fetched at deploy 5 Grafana and 5 Kibana, 8 of them committed
Remote access WireGuard role, with a routing workaround WireGuard on the load balancer

Two of those rows close gaps this site already describes elsewhere. The /metrics endpoint that Automation Alchemy exposes and never scrapes is exactly what HighApiErrorRate consumes here. The same page that says nothing could observe the round-robin split is the page that fixed it.

What did not carry over. Backups are the real one. The two roles are still in the repository, complete with the cron job and the rsync script. Both plays are commented out of site.yml, backup_paths is empty, and backup_server_host still points at a machine the Vagrantfile no longer defines. Nothing can run it. Log retention is worse than absent. The daily filebeat-* indices have no ILM policy and no deletion phase, on the same disk that filled up once already.

An unconfigured fresh clone cannot provision this project without the WireGuard private key and Ansible vault password. Keeping secrets outside Git is appropriate, but they remain prerequisites for reproducing this archived environment.

This stage kept the fleet and replaced the backup host with better observability. The disabled backup plays and absent retention policy are important qualifications when comparing it with the preceding configuration.

Interactive models

Explore the model below to inspect the implementation. This is a reconstruction from the project, not a live service.

VM and Kubernetes alert comparison

AlertGallery

11 shipped rules across 4 groups.

4 critical

the same rule, both environments

  • High CPU load

    vm: `> 80` — 80% of the guest's own CPUs

    k8s: `> 20` — 80% of the container, normalised against a 16-CPU host

    The same condition reads 20 on one environment and 80 on the other. Neither number is wrong; they measure different things. But an alert copied between the two files silently changes what it means, which is why the k8s file needed a comment explaining that 80 became 20.

  • Container memory over limit

    vm: All containers with a limit set, page cache excluded

    k8s: One named pod: `container-mem-test-pod`

    The general rule was narrowed to the test workload rather than generalised. On the cluster it could only fire for the pod used to verify the limit behaviour.

  • Cluster health

    vm: `ElasticsearchClusterHealthYellow` / `…Red`, `for: 1m` / `0m`

    k8s: `ElasticsearchStatusYellow` / `…Red`, `for: 2m` / `0m`

    Same three signals, renamed between environments. The yellow window differs too — one minute on VMs, two in the cluster — with no comment explaining the change.

  • Rules that never made it to Kubernetes

    vm: LowMemory, LowDiskSpaceCritical, LowDiskSpaceWarning, ContainerRestarting, DiskWillFillUpIn24Hours, HighApiErrorRate

    k8s: —

    Six rules from the VM set have no counterpart. Four of them have a differently-shaped replacement in the k8s set; `HighApiErrorRate` and the disk-trend prediction were simply not carried over.

counts: sherlock 12 defined / 11 shipped in 4 groups · prometheus 12 in 3

ansible/roles/prometheus_server/files/sherlock.rules.yml

client:visible

Reconstructed VM and Kubernetes rule sets. Use the rule-set selector to compare the thresholds and coverage of the two stages.
Stack and source figures

how it fits together

metrics
  • Prometheus 2.53 + Alertmanager A pull pipeline on a dedicated monitor, scraping host, container and application metrics. Twelve rules across four groups include one scratch test rule.
dashboards
  • Grafana Five dashboards: three hand-built against this cAdvisor, plus the two community ones whose queries actually matched. Reading a candidate's JSON model before deploying it is how 193 was found.
logging
  • ELK (Elasticsearch 8.18 + Filebeat + Kibana) Centralised log aggregation. Versions pinned in the role defaults, because a version skew leaves a .kibana alias that the current version refuses to read.
shipping
  • Filebeat + Logstash The log pipeline. Harvest paths are restricted to the intended logs. An unrestricted agent reads its own output and fills the disk.
configuration
  • Ansible Six hosts: app, two web servers, load balancer, CI and the dedicated monitor. Provisioning also needs the local WireGuard private key and Ansible vault password.
VMs
6 The README says 7, but backup-server is commented out in both the Vagrantfile and the inventory.
Ansible roles
20
YAML files
137
alerts
12 Across 4 groups.
commits
107
Full issue record · 9 entries

what broke, and what happened

  • 7 fixed / 9
  • 1 workaround / 9
  • 1 unresolved / 9

Every entry below says what broke and what became of it. An unresolved one is listed as unresolved rather than dropped.

  1. fixed

    Filebeat ingested its own logs, and the loop filled the disk

    `/var/log/syslog` reached 18GB and provisioning died with `No space left on device`. Logstash and Filebeat wrote to syslog, Filebeat shipped it back, Logstash logged about it.

    then: The harvest paths are restricted to the specific logs intended for shipping, so the pipeline no longer reads its own output.

  2. fixed

    Elasticsearch was killed by the JVM heap it asked for

    `There is insufficient memory for the Java Runtime Environment to continue.`

    then: Heap sizes and VM resources were adjusted. The archived monitor has 6 GiB and 5 vCPU, with Elasticsearch and Logstash heaps set to 1g. The later X-Pack startup issue is recorded separately.

  3. workaround

    Then Kibana starved, and then everything starved at once

    `uptime` showed load average 45.55 with all vCPUs pinned, while every Filebeat agent shipped its whole backlog simultaneously and all three ELK components ran their own initialisation.

    then: Mitigated by provisioning order and heap sizing rather than eliminated. Every agent still ships its backlog at once on a cold start, and that is what pins the vCPUs.

  4. fixed

    A startup race that presented as a mysterious Kubernetes enrollment screen

    An `apt upgrade` in the common role pulled a newer ELK that enabled X-Pack security by default. The VM crashed before that configuration could apply.

    then: X-Pack explicitly disabled in the rendered Elasticsearch config, so an unattended upgrade can no longer switch security on behind Ansible's back.

  5. fixed

    Two-node Elasticsearch deadlocked on discovery because the peer did not exist yet

    `monitor-server` retried `discovery.seed_hosts` for an unprovisioned `ci-server` and never initialised. Fixed by setting `network.publish_host` so a node cannot identify itself via its NAT address.

    then: `network.publish_host` pinned per node, so each publishes its own routable address instead of the NAT one, and discovery stops waiting on a peer that is not there yet.

  6. fixed

    Three community Grafana dashboards failed against this cAdvisor version, one after another

    ID 14282 queried metric names this cAdvisor does not provide; ID 13946 did arithmetic between metrics whose labels do not match one-to-one.

    then: Resolved by reading the candidate dashboards' JSON from the Grafana API and matching their PromQL against the metric names this cAdvisor actually emits. ID 193 was compatible and is provisioned, and three hand-built dashboards now cover the VM, container and application panels directly.

  7. fixed

    A version-skewed Kibana index alias produced a 502 with no useful error

    `The .kibana_8.18.4 alias refers to a newer version of Kibana`. A newer Kibana had been run once, leaving indices the current version could not read.

    then: Versions pinned in the role defaults (`filebeat_version: "8.18.4"`), so the ELK components cannot drift apart across a rebuild.

  8. fixed

    Logstash refused to start because of an invisible byte-order mark in a template file

    then: The template is written without a BOM, which is invisible in an editor and fatal to Logstash's parser on the first `#` character.

  9. unresolved

    The chart-default Filebeat docker module no longer exists in the packaged version

    then: Unresolved. The module was dropped from the packaged version, so container metrics come from the alternatives rather than the default that is named in the documentation.