111 lines
4.5 KiB
Markdown
111 lines
4.5 KiB
Markdown
|
|
# gatus
|
||
|
|
|
||
|
|
Deploys [Gatus](https://github.com/TwiN/gatus) — health checks, a status page,
|
||
|
|
and alerting — **as the upstream container image**, on the
|
||
|
|
`observability` group.
|
||
|
|
|
||
|
|
## Why the container
|
||
|
|
|
||
|
|
Upstream publishes **no binary release assets**. The image is the only artefact
|
||
|
|
they ship and therefore the only one they test, so it is what `docker run` in
|
||
|
|
their README gets you, and it is what this role deploys.
|
||
|
|
|
||
|
|
Building from source is entirely possible — their Dockerfile is a bare
|
||
|
|
`CGO_ENABLED=0 go build`, the Vue dashboard is compiled in via `//go:embed
|
||
|
|
static` in `web/static.go`, and the sqlite driver is pure-Go
|
||
|
|
`modernc.org/sqlite` so nothing needs linking. This role did that at first. The
|
||
|
|
reasons it doesn't now:
|
||
|
|
|
||
|
|
- It produces a binary upstream never ran.
|
||
|
|
- It means compiling the AWS SDK, gRPC and the Google API libraries on the
|
||
|
|
smallest box in the estate. On this VPS that load was heavy enough that
|
||
|
|
unrelated Ansible tasks timed out while it ran.
|
||
|
|
|
||
|
|
The cost of the container is a daemon on the machine whose job is to notice
|
||
|
|
when everything else breaks. That is a real trade, made deliberately.
|
||
|
|
|
||
|
|
## Pinned by digest, not by tag
|
||
|
|
|
||
|
|
```yaml
|
||
|
|
gatus_image_digest: "sha256:c5f210d0…"
|
||
|
|
gatus_image: "ghcr.io/twin/gatus@{{ gatus_image_digest }}"
|
||
|
|
```
|
||
|
|
|
||
|
|
A tag is mutable — `v5.36.0` can be repushed — so pinning the tag alone is a
|
||
|
|
weaker promise than it looks. The digest is a content address: if it resolves,
|
||
|
|
it is byte-for-byte the image reviewed here, and `docker compose pull` either
|
||
|
|
fetches exactly that or fails. `gatus_version` is kept alongside it purely so a
|
||
|
|
human can read which release it is; **the two must be updated together.**
|
||
|
|
|
||
|
|
## What `FROM scratch` means for running it
|
||
|
|
|
||
|
|
The image has no `/etc/passwd`, so there is no user to drop to by name and the
|
||
|
|
default is root. The compose file runs it by numeric id (`10001:10001`) and the
|
||
|
|
data directory on the host is owned to match. Everything else is locked down to
|
||
|
|
approximate what the systemd unit used to do natively:
|
||
|
|
|
||
|
|
| systemd | compose |
|
||
|
|
|---|---|
|
||
|
|
| `ProtectSystem=strict` | `read_only: true` |
|
||
|
|
| `NoNewPrivileges=true` | `security_opt: [no-new-privileges:true]` |
|
||
|
|
| `CapabilityBoundingSet=` | `cap_drop: [ALL]` |
|
||
|
|
| `AmbientCapabilities=CAP_NET_RAW` | `cap_add: [NET_RAW]` (for `icmp://`) |
|
||
|
|
|
||
|
|
## Configuration is a directory, not a file
|
||
|
|
|
||
|
|
`GATUS_CONFIG_PATH` points at `/opt/gatus/config`, and Gatus merges every `*.yaml`
|
||
|
|
underneath it — maps deep-merge, lists append. This role owns exactly one file:
|
||
|
|
|
||
|
|
```
|
||
|
|
/opt/gatus/config/00-base.yaml web, storage, ui, alerting, security (this role)
|
||
|
|
/opt/gatus/config/endpoints/*.yaml one file per service (gatus_endpoint)
|
||
|
|
```
|
||
|
|
|
||
|
|
**A primitive defined in two files is ambiguous and upstream refuses it.** So
|
||
|
|
anything that is not a list belongs in `00-base.yaml` and nowhere else. Endpoints
|
||
|
|
are lists, so each service's file appends cleanly — the same shape as
|
||
|
|
`caddy_site`, where each service contributes its own vhost.
|
||
|
|
|
||
|
|
## Pull and push
|
||
|
|
|
||
|
|
Gatus polls. For anything with a reachable HTTP or TCP surface that is the
|
||
|
|
better check, because it tests the path a user actually takes. The monitoring
|
||
|
|
host joins the headscale mesh via `infra/920`, so internal boxes are reachable
|
||
|
|
by MagicDNS name and can be polled directly rather than having to report in.
|
||
|
|
|
||
|
|
For state with no pollable surface — ZFS pool health, UPS mains status, disk
|
||
|
|
usage, backup freshness — Gatus has **external endpoints**, a push API:
|
||
|
|
|
||
|
|
```
|
||
|
|
POST /api/v1/endpoints/{group}_{name}/external?success=true&error=&duration=
|
||
|
|
Authorization: Bearer <token>
|
||
|
|
```
|
||
|
|
|
||
|
|
with `heartbeat.interval` to alert when nothing reports in. That is the same
|
||
|
|
shape as the generic `healthcheck_push_url` already wired into every service
|
||
|
|
role, so those scripts need a URL, a POST, and an auth header — not a rewrite.
|
||
|
|
|
||
|
|
## Variables
|
||
|
|
|
||
|
|
See `defaults/main.yml`. The ones that matter:
|
||
|
|
|
||
|
|
| Variable | Default | Note |
|
||
|
|
|---|---|---|
|
||
|
|
| `gatus_version` / `gatus_image_digest` | `v5.36.0` / `sha256:c5f210d0…` | must move together |
|
||
|
|
| `gatus_bind_address` | `127.0.0.1` | **never bind publicly** — the push API shares this listener |
|
||
|
|
| `gatus_storage_type` | `sqlite` | `memory` loses all history on restart |
|
||
|
|
| `gatus_alerting` | `{}` | pass-through; any provider Gatus supports |
|
||
|
|
| `gatus_allow_icmp` | `true` | adds back `NET_RAW` for `icmp://` checks |
|
||
|
|
|
||
|
|
`gatus_alerting` empty is valid and is the current state: every condition is
|
||
|
|
still evaluated and recorded, there is just nowhere to shout yet.
|
||
|
|
|
||
|
|
## Verifying
|
||
|
|
|
||
|
|
```bash
|
||
|
|
docker ps --filter name=gatus
|
||
|
|
docker logs gatus --tail 50
|
||
|
|
curl -s localhost:8080/health
|
||
|
|
ls /opt/gatus/config/endpoints/
|
||
|
|
```
|