Step 4 of 7 in the DevOps journey · see the full journey
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.
- 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/healthuntil the cluster status reports asyellow, 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.
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
client:visible
Stack and source figures
how it fits together
- metrics
-
-
Prometheus 2.53 + AlertmanagerA pull pipeline on a dedicated monitor, scraping host, container and application metrics. Twelve rules across four groups include one scratch test rule.
-
- dashboards
-
-
GrafanaFive 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 + LogstashThe log pipeline. Harvest paths are restricted to the intended logs. An unrestricted agent reads its own output and fills the disk.
-
- configuration
-
-
AnsibleSix 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.