Files
composes/alloy/README.md
T
tiennm99 feb6f035c8 fix(alloy): mount /run/udev for diskstats device properties
node-exporter's diskstats collector reads udev device properties from
/run/udev/data; without the mount it logs an error and drops those labels.
2026-10-06 16:50:22 +07:00

149 lines
7.8 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# alloy
[Grafana Alloy](https://grafana.com/docs/alloy/latest/) shipping host and
container telemetry to Grafana Cloud, with remote config from Grafana Fleet
Management.
One container runs both the `node_exporter` (host) and `cadvisor` (container)
collectors. A second, tiny container proxies a read-only slice of the Docker
API to it. The Alloy config is embedded inline via Compose `configs:`, so
there is no `config.alloy` on disk.
`dockerproxy` publishes
`127.0.0.1:2375` — Alloy runs with host networking, so it has no compose
network to reach the proxy over.
## What it collects
| Source | Component | Notes |
|---|---|---|
| Host metrics | `prometheus.exporter.unix` | CPU, memory, load, disk I/O, filesystem, network, uname, boot time, systemd, vmstat, sockstat — the default collector set minus `ipvs/btrfs/infiniband/xfs/zfs` |
| Container metrics | `prometheus.exporter.cadvisor` | CPU, memory, fs usage/limit, network, `last_seen`. Docker labels are not copied onto the series (`store_container_labels = false`): Coolify gives its containers 50–80 labels, which pushes series past Grafana Cloud's 60-label limit and gets them rejected |
| Container logs | `loki.source.docker` | All running containers, labeled `container`, `stream`, `instance` |
| Journal logs | `loki.source.journal` | systemd journal, labeled `unit`, `boot_id`, `transport`, `level` |
| File logs | `loki.source.file` | `/var/log/syslog`, `/var/log/messages`, `/var/log/*.log` |
| Remote config | `remotecfg` | Polls Grafana Fleet Management every 60s |
Metric filtering copies the `keep`-lists from the upstream Grafana Cloud
integrations verbatim ([Linux Node](https://grafana.com/docs/grafana-cloud/monitor-infrastructure/integrations/integration-reference/integration-linux-node/#metrics),
[Docker](https://grafana.com/docs/grafana-cloud/monitor-infrastructure/integrations/integration-reference/integration-docker/#metrics)).
Logs are unfiltered.
## Environment
All nine are required; `docker compose up` fails fast if any is unset.
| Variable | Purpose |
| --- | --- |
| `ALLOY_HOSTNAME` | Container hostname, and the Loki/Prometheus `instance` label |
| `REMOTECFG_URL` | Fleet Management endpoint |
| `REMOTECFG_ID` | Fleet Management agent id |
| `REMOTECFG_USER` | Fleet Management user id |
| `PROM_URL` / `PROM_USER` | Prometheus remote-write endpoint and user id |
| `LOKI_URL` / `LOKI_USER` | Loki push endpoint and user id |
| `GRAFANA_TOKEN` | One Cloud Access Policy token, scopes `metrics:write` + `logs:write` + `fleet-management:read` |
`GRAFANA_TOKEN` is also passed into the container as `GCLOUD_RW_API_KEY`.
Fleet Management's auto-generated `self_monitoring_*` pipelines read the token
from that fixed name; without it their remote-write gets an empty password and
Grafana Cloud answers `401 invalid token`, so the collector shows no health
data in Fleet Management.
Find the values under Grafana Cloud → your stack → **Details** on each data
source, and under Fleet Management. The same token serves `remotecfg`,
Prometheus and Loki basic-auth.
```bash
cp .env.example .env # then fill in the values
docker compose up -d
```
Run the same file on every host, changing `ALLOY_HOSTNAME` and `REMOTECFG_ID`
per host. Filter in Grafana with `instance=~"..."`.
## Privileges
The upstream Grafana Cloud docker integration runs `privileged: true` with the
raw Docker socket mounted read-write. Nothing in this config needs that, and
the combination is host-root-equivalent: `privileged` grants every capability
and unmasks `/proc` and `/sys`, and a container that can talk to the Docker
socket can start another container that mounts `/` writable. The socket's `:ro`
flag does not help — it stops the socket *file* being replaced, not the API
being used.
What it runs instead:
| Setting | Reason |
| --- | --- |
| `cap_drop: [ALL]` + `cap_add: [DAC_OVERRIDE]` | The image's entrypoint runs as uid 0 and reads host files owned by other users — the journal, paths under `/rootfs`, `/var/log`. Dropping every capability leaves it unable to open them, and unable to create its own storage directory. `DAC_OVERRIDE` restores exactly that and nothing else; `SYS_ADMIN`, `NET_ADMIN`, `SYS_PTRACE`, `MKNOD` and the rest stay dropped. |
| `no-new-privileges:true` | No setuid binary in the image can regain what was dropped. |
| `mem_limit: 2g`, `pids_limit: 512` | Steady state is around 900 MB; the limit stops a leak taking the host down with it. |
| `dockerproxy` instead of `/var/run/docker.sock` | See below. |
`read_only: true` is deliberately absent. Compose materialises an inline
`configs:` entry by writing it into the container, and refuses to do that on a
read-only service — `cannot create config ... : \`file\` is the sole supported
option`. Keeping the config inline is worth more than the read-only rootfs
here; adding it back means moving the config to a `config.alloy` file on disk
and switching the `configs:` entry to `file:`.
`network_mode: host` stays. `/proc/net` is a symlink to `/proc/self/net` and
resolves against the reading process's network namespace, so bind-mounting the
host's `/proc` to `/rootproc` is not enough — without host networking the
netdev, netstat, sockstat and conntrack families would describe the container's
veth pair instead of `eth0`. That is roughly 45 of the 157 kept metrics.
`/:/rootfs:ro` also stays, and is the widest remaining exposure: the container
can read every file on the host. The filesystem collector needs to `statfs()`
each mount point, and a bind mount cannot grant that without granting reads.
Dropping it would cost disk-space monitoring. Treat the container as
secret-bearing — it holds `GRAFANA_TOKEN` regardless.
## Docker API access
`prometheus.exporter.cadvisor` and `loki.source.docker` both need the Docker
API, so it cannot simply be removed. `dockerproxy` runs
[tecnativa/docker-socket-proxy](https://github.com/Tecnativa/docker-socket-proxy)
with the socket mounted read-only and publishes it on `127.0.0.1:2375`, which
Alloy reaches over host networking.
`POST` is revoked by default in that image, which is the point: container
create, `exec`, start and kill are all refused with 403, so a compromise of
Alloy can no longer become root on the host. The granted sections are the ones
the two components actually call:
| Variable | Called by |
| --- | --- |
| `CONTAINERS` | `discovery.docker` listing, `loki.source.docker` inspect and log read, cadvisor metadata |
| `NETWORKS` | `discovery.docker` — Prometheus' Docker SD resolves network names per container, and returns **zero targets** without it |
| `IMAGES`, `INFO`, `VERSION` | cadvisor |
| `EVENTS` | cadvisor container watch (granted by default) |
What this does not do: `GET /containers/{id}/json` still returns every
container's environment variables. The proxy closes the escalation path, not
the disclosure one.
## Mounts
| Mount | Why |
|---|---|
| `/proc:/rootproc:ro` | node-exporter cpu/mem/load, via `procfs_path` |
| `/sys:/sys:ro` | node-exporter and cadvisor cgroups |
| `/:/rootfs:ro` | filesystem collector, via `rootfs_path` |
| `/dev/disk/:/dev/disk:ro` | node-exporter diskstats device labels |
| `/run/udev:/run/udev:ro` | node-exporter diskstats udev device properties (`/run/udev/data`) |
| `/var/lib/docker:ro` | cadvisor container metadata |
| `/var/log:/var/log:ro` | `loki.source.journal` and `loki.source.file` |
| `/etc/machine-id:ro` | Stable host id for the journal reader |
| `alloy-data` | WAL and remotecfg cache — the only writable path |
`dockerproxy` mounts `/var/run/docker.sock:ro` and nothing else.
## Version pinning
Both images use `latest`. Neither project publishes a moving major tag:
`grafana/alloy` ships only exact `v1.x.y` tags, and `tecnativa/docker-socket-proxy`
only exact `v0.x.y` tags (its bare `0` tag is a stale leftover). `latest` is the
closest equivalent, so new releases arrive on the next redeploy.