Whyfire

Blog · 15 October 2026 · 7 minute read

Grafana Alloy vs node_exporter: what changes when the agent pushes

Tested with node_exporter 1.9.1, Grafana Alloy 1.20.1 and Prometheus 3.5.

Quick answer

If Prometheus can reach every server on port 9100 and you only want metrics, keep node_exporter: it's small and simple. Choose Alloy when servers can only connect out, or when you want metrics and logs from one agent. The real cost isn't Alloy's size; it's switching from pull to push. A dead server stops reporting up == 0, the server's clock becomes the timestamp, and every server needs a credential.

We switched our own agent from node_exporter to Alloy this week, on seven servers that have faults staged on them around the clock. Here's what's the same, what isn't, and the three things that broke.

They aren't the same kind of tool

node_exporter is an exporter. It answers on port 9100, and Prometheus fetches its metrics every scrape interval. It does nothing else.

Alloy is a collector. Its prometheus.exporter.unix component runs node_exporter's collectors inside Alloy; Alloy scrapes them itself and pushes the samples to Prometheus with remote write. The same agent can read the journal and log files and push those to Loki, which is why Grafana has deprecated Promtail in favour of it.

node_exporterAlloy
DirectionPrometheus connects in (9100)Alloy connects out (HTTPS)
Logsnojournal and files, to Loki
Binary21 MB545 MB
Memory in useabout 10 MBabout 70 MB
Server downup == 0series stop; you detect absence
TimestampsPrometheus's clockthe server's clock
Hub outagegap in the databuffered, sent afterwards

Same metrics, mostly

Alloy's unix exporter is built on Grafana's fork of node_exporter, which branched in October 2023, before node_exporter 1.7. Metric names are the same, so dashboards and rules mostly carry over. The differences we found against 1.9.1:

A minimal config that keeps the labels node_exporter dashboards expect:

prometheus.exporter.unix "node" {
  enable_collectors = ["systemd"]
}

discovery.relabel "node" {
  targets = prometheus.exporter.unix.node.targets
  rule {
    target_label = "job"
    replacement  = "node"
  }
  rule {
    target_label = "instance"
    replacement  = constants.hostname
  }
}

prometheus.scrape "node" {
  targets         = discovery.relabel.node.output
  scrape_interval = "15s"
  forward_to      = [prometheus.remote_write.hub.receiver]
}

prometheus.remote_write "hub" {
  endpoint {
    url               = "https://prometheus.example.com/api/v1/write"
    bearer_token_file = "/etc/alloy/token"
  }
}

Prometheus only accepts pushed samples when started with --web.enable-remote-write-receiver. And run Alloy with --disable-reporting (in /etc/default/alloy): anonymous usage reporting to Grafana is on by default. The Debian package's own config collects metrics but sends them nowhere until you replace it.

Footprint

Both side by side on the same machine for five minutes, metrics only, 15-second scrapes:

node_exporter 1.9.1Alloy 1.20.1
Binary21 MB545 MB
Resident memory (top)19 MB238 MB, 172 MB of it the binary's own pages
Memory in use10 MB72 MB
CPU, five-minute average0.2% of a core1.7% of a core
Series per scrape1,2641,170

Alloy's resident memory looks alarming in top, but most of it is pages of its own large binary, which the kernel can drop under pressure; the memory it actually uses is the "in use" row. The CPU figure includes start-up; on our seven 1 GB cloud servers, sending logs as well, each Alloy averages about 1% of a core over an hour. Disk is the bigger difference: about 550 MB installed, and while upgrading it needs room for the download as well.

What push changes

1. A dead server doesn't say it's dead

With node_exporter, Prometheus tries to scrape, fails, and records up == 0. With Alloy, up is produced on the server. A powered-off server sends nothing at all: its series simply stop, and up == 0 never fires. Alert on absence instead:

- alert: HostStoppedSending
  expr: |
    group by (instance) (present_over_time(up{job="node"}[1d]))
    unless
    group by (instance) (present_over_time(up{job="node"}[3m]))
  labels:
    severity: critical
  annotations:
    summary: "{{ $labels.instance }} sent metrics today but nothing for 3 minutes"

This is Grafana's recommended pattern, and it has a blind spot: a server that has been silent for a whole day drops out of the left-hand side and the alert resolves. The sturdier fix is a list of servers that should be sending, kept wherever you enrol them, and an alert on "listed, but no sample".

2. The server's clock is now the timestamp

Pulled samples are stamped by Prometheus. Pushed samples are stamped by the server. A server whose clock runs three minutes fast sends data that Prometheus treats as three minutes in the future: for three minutes, queries for "now" find nothing (our absence alert fired on a healthy server), and afterwards every change on that server appears three minutes later than it happened. If you work out which server changed first during an incident, that is exactly the wrong way round.

The usual skew check, node_time_seconds - timestamp(node_time_seconds), silently stops working too: both sides now come from the server's clock, so it always reads about zero. Alert on the kernel's own sync state instead, which works either way:

- alert: ClockNotSynchronised
  expr: node_timex_sync_status == 0
  for: 10m
  labels:
    severity: warning
  annotations:
    summary: "{{ $labels.instance }} clock is not synchronised (NTP)"

That catches a server whose time sync is off, not one that is synchronised to a wrong source. To measure the real offset, have each agent also scrape a small endpoint on the central server that returns its time, and compare.

3. Every server needs a credential

Pull needs a firewall rule; push needs a token and trust. Each server holds a bearer token (Alloy rereads bearer_token_file on every request, so you can rotate it without a restart) and must trust the receiver's certificate. If you use a private CA, note that setting ca_file replaces the system roots rather than adding to them, so moving the receiver to a public certificate later breaks every agent; we put the private CA and the system bundle in one file.

What you get back: no inbound port on any server, which is often what makes the change worth it.

Bonus: hub outages are buffered

Remote write keeps a write-ahead log on the server, so when Prometheus is down the samples wait and are sent afterwards. We restarted our hub's Prometheus mid-test and lost nothing. Two limits: Prometheus rejects samples much older than its newest ones unless you set out_of_order_time_window (it's off by default), and on a full disk Alloy 1.19 could truncate that log on replay; use 1.20.1 or later.

Which to choose

Why we switched

Whyfire investigates alerts: when one fires, it compares every server's metrics and logs with their own normal and works out which server changed first. We moved its agent to Alloy so servers only ever connect out to the hub, with metrics and logs from one install. Then we added the three fixes: the hub keeps the list of enrolled servers and alerts on any that stop sending, every agent reads the hub's clock and a server more than 30 seconds out raises an alert, and the investigation won't let an off-clock server decide who changed first.