❯ monitoring · Running

Métriques cluster en temps réel sur un blog statique — sans casser GitOps Live Cluster Metrics on a Static Blog — Without Breaking GitOps

Comment afficher des métriques Kubernetes en temps réel sur un blog Astro statique via un CronJob in-cluster et un mount ConfigMap, sans aucun commit git ni rebuild GHA. How to display real-time Kubernetes metrics on a static Astro blog using an in-cluster CronJob and a ConfigMap mount, with zero git commits and zero GHA rebuilds.

· 9 min de lecture 9 min read ·
k3s-clustermonitoringgitopsk8svictoriametricspromqlargocdnginxastro
🇫🇷 Lire cet article en français

The starting problem is crude and simple: I have a static Astro blog, served by nginx in a K8s pod, deployed through ArgoCD from a Git repo. I wanted to show live cluster metrics on the home page — CPU, memory, running pods, per-node uptime — so it would be an honest showcase of the infrastructure, not a frozen screenshot.

The real constraint is that I did not want to sacrifice GitOps purity for it. Zero automated commits polluting the git log. Zero image rebuilds burning CI. Zero public endpoint widening the attack surface. A static blog must stay static in the build sense — that is its strength.

The anti-patterns I ruled out

Option A: a GHA cron that commits metrics.json

The intuitive idea: a GitHub Actions workflow runs every 15 minutes, curls a publicly exposed Prometheus endpoint, commits src/data/metrics.json, pushes. Astro rebuilds, ArgoCD redeploys.

Immediate problem: that generates 96 commits a day in the git log. Git is not a time-series database. ArgoCD detects the new commit, triggers a sync, which causes a rolling update of the Deployment, which… reloads the same metrics, now 15 seconds old. And if the workflow misses a run, the kubelet restarts, and continuity is lost.

The git log has to stay readable by a human. Commits reading chore: update metrics 2026-05-18T14:30:00Z every quarter of an hour are a first-order anti-pattern.

Option B: a self-hosted GHA runner

Running a GitHub Actions runner inside the cluster to get access to the internal API. That solves the network access problem, but it creates one more infrastructure dependency: the runner has to be up, registered, holding a valid GitHub token. That is infrastructure to maintain in service of cosmetic monitoring.

Option C: a public endpoint on VictoriaMetrics

Expose VictoriaMetrics through an Ingress, call the API from browser JS. Immediate CORS problem (VM does not serve Access-Control-Allow-Origin headers). Security problem: VictoriaMetrics has no authentication enabled in my homelab config — exposing it publicly means exposing every raw metric to the internet. No.

The architecture I settled on

The solution fits in one sentence: a K8s CronJob writes a ConfigMap, the ConfigMap is mounted as a file in the nginx pod, the blog fetches it in JS.

┌─────────────────────────────────────────────────────┐
│  namespace: metrics-snapshot                        │
│                                                     │
│  CronJob metrics-snapshot (*/15 * * * *)            │
│    └─ alpine/k8s:1.34.3                             │
│         curl → VictoriaMetrics (internal :8428)     │
│         jq → /tmp/metrics-live.json                 │
│         kubectl apply ConfigMap blog-metrics        │
│                        │                            │
└────────────────────────┼────────────────────────────┘
                         │ cross-namespace write
                         ▼
┌─────────────────────────────────────────────────────┐
│  namespace: blog                                    │
│                                                     │
│  ConfigMap blog-metrics                             │
│    └─ metrics-live.json (~1.5 KB)                   │
│         │                                           │
│         │ volumeMount (no subPath)                  │
│         ▼                                           │
│  Deployment blog (nginx)                            │
│    /usr/share/nginx/html/api/metrics-live.json      │
│         │                                           │
│         │ fetch /api/metrics-live.json              │
│         ▼                                           │
│  Browser → JS hydrate + re-fetch every 30s          │
└─────────────────────────────────────────────────────┘

Zero git commits. Zero GHA rebuilds. Zero public endpoint.

The CronJob runs inside the cluster, reaches VictoriaMetrics over internal cluster DNS (vmsingle-victoria-metrics-single-server.monitoring.svc.cluster.local:8428), and patches a ConfigMap in another namespace through a minimal cross-namespace RBAC.

The CronJob: curl, jq, kubectl on alpine

The first version used bitnami/kubectl. In August 2025, Bitnami pulled its public tags from Docker Hub — the job started going ImagePullBackOff. Pivot to alpine/k8s:1.34.3: multi-arch (native ARM64 on the RPis), ships kubectl + curl + jq in a single lightweight Alpine image.

The script is POSIX shell inside the container’s args. Two helper functions:

# PromQL → JSON dict {label: value} indexed by label $2
vec() {
  curl -fsS --max-time 15 --data-urlencode "query=$1" "${VM_URL}/api/v1/query" \
    | jq -c --arg k "$2" '
        reduce .data.result[] as $r ({};
          .[$r.metric[$k]] = ($r.value[1] | tonumber)
        )
      '
}

# Scalar PromQL → number (fallback 0)
scalar() {
  curl -fsS --max-time 15 --data-urlencode "query=$1" "${VM_URL}/api/v1/query" \
    | jq -r '.data.result[0].value[1] // "0"' \
    | awk '{if($0=="" || $0=="null" || $0=="NaN") print 0; else print $0}'
}

Then the PromQL queries. The most instructive ones:

CPU per node (instance is the IP:port of node_exporter):

100 * (1 - avg by(instance)(rate(node_cpu_seconds_total{mode="idle"}[5m])))

Disk per node (ext4 only, root mountpoint):

100 * (1 - sum by(instance)(node_filesystem_avail_bytes{mountpoint="/",fstype="ext4"})
         / sum by(instance)(node_filesystem_size_bytes{mountpoint="/",fstype="ext4"}))

Running pods per node — this is where it gets interesting:

count by(node)(kube_pod_info and on(namespace,pod) (kube_pod_status_phase{phase="Running"} == 1))

Why the and on(namespace,pod) join? Because kube_pod_status_phase does not expose the node label. It is kube_pod_info that carries node, namespace and pod. The join filters kube_pod_info down to the pods whose phase is Running according to kube_pod_status_phase, joining on (namespace, pod). Without it, you would count every pod known to kube-state-metrics regardless of its state.

Ready nodes (cluster scalar):

count(kube_node_status_condition{condition="Ready",status="true"} == 1)

The final assembly uses a single jq -n with --argjson to build the JSON atomically:

{
  "lastUpdated": "2026-05-18T14:30:00Z",
  "cluster": {
    "nodes": { "ready": 6, "total": 6 },
    "pods":  { "running": 87 },
    "pvc":   { "bound": 23 },
    "cpuPct": 12.4,
    "memPct": 58.1,
    "uptimeSeconds": 864000
  },
  "nodes": [
    {
      "name": "master-1", "role": "control-plane",
      "ready": true, "cpuPct": 9.2, "memPct": 61.3,
      "diskPct": 44.1, "uptimeSeconds": 864000,
      "pods": 8, "load1": 0.42
    },
    ...
  ]
}

Then a single command to write the ConfigMap:

kubectl -n blog create configmap blog-metrics \
  --from-file=metrics-live.json=/tmp/metrics-live.json \
  --dry-run=client -o yaml \
  | kubectl apply -f -

The create --dry-run | apply pattern is idempotent: it creates the CM if it does not exist, patches it otherwise. No need for kubectl patch with a JSON heredoc.

Minimal cross-namespace RBAC

The CronJob runs in the metrics-snapshot namespace but has to write in the blog namespace. A Role (not a ClusterRole) in the blog namespace, with a RoleBinding pointing at the ServiceAccount from the other namespace:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: blog-metrics-writer
  namespace: blog
rules:
- apiGroups: [""]
  resources: ["configmaps"]
  resourceNames: ["blog-metrics"]   # restricted to this CM only
  verbs: ["get", "patch", "update"]
- apiGroups: [""]
  resources: ["configmaps"]
  verbs: ["create"]                  # create without resourceNames (it may not exist yet)
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: blog-metrics-writer
  namespace: blog
subjects:
- kind: ServiceAccount
  name: metrics-snapshot
  namespace: metrics-snapshot        # cross-namespace
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: blog-metrics-writer

Least privilege holds: the SA can only touch one named ConfigMap in one namespace.

Mounting the ConfigMap in nginx — the subPath trap

The Deployment mounts the ConfigMap at /usr/share/nginx/html/api:

volumeMounts:
- name: metrics
  mountPath: /usr/share/nginx/html/api
  readOnly: true
# No subPath!
volumes:
- name: metrics
  configMap:
    name: blog-metrics
    optional: true   # the pod starts even if the CM does not exist yet

subPath is forbidden here. When you mount a ConfigMap volume with subPath, kubelet creates a fixed bind mount at pod start time. Later updates to the ConfigMap are not propagated into the pod — the file stays frozen at its initial value until the next restart.

Without subPath, kubelet uses an atomic symlink: it creates a new versioned directory and repoints the symlink at it. ConfigMap updates propagate into the pods without a restart, in about 1–2 minutes (tunable through kubelet’s --sync-frequency). That is exactly what we want.

The downside: you mount a whole directory, not a single file. So /usr/share/nginx/html/api/ contains nothing but metrics-live.json. That is entirely sufficient.

ArgoCD vs. the drifting ConfigMap

ArgoCD manages the metrics-snapshot namespace — the CronJob, the RBAC, and an empty “seed” ConfigMap. It also manages blog-metrics in the blog namespace as an initial ConfigMap.

Problem: every 15 minutes the CronJob overwrites the ConfigMap’s data with fresh metrics. ArgoCD sees a drift between its desired state (the seed CM from the repo) and the real state (the CM with live data). Without extra configuration, it resets it on the next sync.

Fix: ignoreDifferences in the ArgoCD Application spec:

ignoreDifferences:
- group: ""
  kind: ConfigMap
  name: blog-metrics
  namespace: blog
  jsonPointers: ["/data"]

ArgoCD ignores the /data field when comparing state. It keeps managing the CM (creating it, managing its metadata) but leaves the CronJob owning the content.

nginx: disabling cache on /api/

By default nginx caches responses — and so does Cloudflare. A JSON file served with default headers gets cached and the browser sees metrics several hours old.

location /api/ {
    expires -1;
    add_header Cache-Control "no-cache, no-store, must-revalidate";
    add_header Content-Type application/json;
    try_files $uri =404;
}

expires -1 forces an Expires in the past. no-cache, no-store, must-revalidate is the triple lock: no browser cache, no intermediate cache (CDN), mandatory revalidation. The page’s JS calls fetch('/api/metrics-live.json', { cache: 'no-store' }) to confirm the intent client-side.

The frontend: a CustomEvent to share a single fetch

Two Astro components display the metrics: ClusterLive (aggregated cluster view) and NodeGrid (per-node detail). If each fetched independently, we would issue two requests every 30 seconds — pointless.

ClusterLive is the conductor: it fetches once on load, then every 30 seconds, and rebroadcasts through a CustomEvent on window:

async function hydrate() {
  const res = await fetch('/api/metrics-live.json', { cache: 'no-store' });
  if (!res.ok) return;
  const m = await res.json();
  paint(m);
  window.dispatchEvent(new CustomEvent('cluster-metrics', { detail: m }));
}

hydrate();
setInterval(hydrate, 30_000);

NodeGrid subscribes to the event:

window.addEventListener('cluster-metrics', (ev) => {
  paint(ev.detail);
});

NodeGrid also has a self-heal mode: if no data has been broadcast after 2 seconds (the case of a page without ClusterLive), it fetches once by itself.

The ASCII bars

The blog’s aesthetic is terminal. Progress bars are Unicode block characters:

const BAR_WIDTH = 10;

function bar(pct: number): string {
  const clamped = Math.max(0, Math.min(100, pct || 0));
  const filled = Math.round((clamped / 100) * BAR_WIDTH);
  return `[${'█'.repeat(filled)}${'░'.repeat(BAR_WIDTH - filled)}]`;
}

A node at 42% CPU renders as:

cpu  [████░░░░░░]  42%
mem  [██████░░░░]  61%
disk [████░░░░░░]  44%

It fits the rest of the design system — no charting library, no canvas, just monospace.

v1 → v2: how the schema evolved

Commit 98b4c4c (v1) emitted a flat scalar { cpu, mem, pods, uptime } aggregated for the whole cluster. Good enough for a first version.

Commit 88a8438 (v2) added the nodes[] section with per-RPi detail. The challenge was label alignment: node_cpu_seconds_total is labelled by instance (the node_exporter IP), not by the Kubernetes node name. kube_pod_info uses node (the K8s name). That meant maintaining a static lookup table in the script:

CNODES='{
  "master-1":{"role":"control-plane"},
  "master-2":{"role":"control-plane"},
  ...
}'

And building the final JSON by iterating over the K8s names as primary keys, looking values up by instance or node depending on the metric. Not solving this in PromQL is a deliberate choice — a label join between two metric families coming from two different exporters would be fragile and hard to read.

Actual cost

  • CPU: ~3–5 seconds of execution per run × 96 runs/day ≈ 7 CPU-minutes per day across the cluster. Negligible.
  • Memory: the CronJob pod requests 64 Mi, limit 128 Mi. It lives 10–15s and disappears.
  • Storage: the ConfigMap is ~1.5 KB. Zero PVC.
  • Job TTL: ttlSecondsAfterFinished: 600 — finished pods are cleaned up after 10 minutes.
  • History: successfulJobsHistoryLimit: 1, failedJobsHistoryLimit: 2. Only the last success is kept.

What we avoided

By not touching Git and not rebuilding the image:

  • No git log pollution (96 commits/day would have made git log --oneline useless)
  • No load on the GitHub API or the CI runners
  • No rolling update of the Deployment every 15 minutes
  • No extra public network surface

The ConfigMap is ephemeral by nature: if it disappears, the blog still starts (optional: true), shows dashes instead of values, and picks it back up on the CronJob’s next run. Resilient by design.


The pattern transfers to any in-cluster data you want to expose on a static UI without a public endpoint: backup state, app healthchecks, n8n queue metrics. A CronJob, a ConfigMap, a mount. Nothing more.

For the stack feeding these metrics (VictoriaMetrics, VMAlert, Grafana), see Observability with VictoriaMetrics + Grafana and its follow-up, Observability v2.

k3s-clustermonitoringgitopsk8svictoriametricspromqlargocdnginxastro