❯ 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
🇬🇧 Read this article in English

Le problème de départ est bête et méchant : j’ai un blog statique Astro, servi par nginx dans un pod K8s, déployé via ArgoCD depuis un dépôt Git. Je voulais afficher les métriques live du cluster sur la page d’accueil — CPU, mémoire, pods actifs, uptime par nœud — pour que ce soit une vitrine honnête de l’infrastructure, pas une capture d’écran figée.

La contrainte réelle, c’est que je ne voulais pas sacrifier la pureté GitOps pour ça. Zéro commit automatique qui pollue le git log. Zéro rebuild d’image qui coûte du CI. Zéro endpoint public qui élargit la surface d’attaque. Un blog statique doit rester statique au sens build — c’est sa force.

Les anti-patterns que j’ai écartés

Option A : GHA cron qui commit metrics.json

L’idée intuitive : un workflow GitHub Actions tourne toutes les 15 minutes, curl vers un endpoint Prometheus exposé publiquement, commit src/data/metrics.json, push. Astro rebuilde, ArgoCD redéploie.

Problème immédiat : ça génère 96 commits par jour dans le git log. Git n’est pas une base de données de séries temporelles. ArgoCD détecte le nouveau commit, déclenche un sync, ce qui provoque un rolling update du Deployment, ce qui… recharge les mêmes métriques vieilles de 15 secondes. Et si le workflow rate une exécution, le kubelet fait un restart, et on perd la continuité.

Le git log doit rester lisible pour un humain. Des commits chore: update metrics 2026-05-18T14:30:00Z toutes les quarts d’heure, c’est un anti-pattern de premier ordre.

Option B : GHA runner self-hosted

Faire tourner un runner GitHub Actions dans le cluster pour avoir accès à l’API interne. Ça résout le problème d’accès réseau, mais ça crée une dépendance d’infrastructure supplémentaire : le runner doit être up, enregistré, disposer d’un token GitHub valide. C’est de l’infra à maintenir pour rendre service à du monitoring cosmétique.

Option C : Endpoint public sur VictoriaMetrics

Exposer VictoriaMetrics via un Ingress, appeler l’API depuis le JS du navigateur. Problème CORS immédiat (VM ne sert pas de headers Access-Control-Allow-Origin). Problème sécurité : VictoriaMetrics n’a pas d’authentification activée dans ma config homelab — exposer publiquement signifie exposer toutes les métriques brutes à internet. Non.

L’architecture retenue

La solution tient en une phrase : un CronJob K8s écrit une ConfigMap, la ConfigMap est montée comme fichier dans le pod nginx, le blog la fetch en JS.

┌─────────────────────────────────────────────────────┐
│  namespace: metrics-snapshot                        │
│                                                     │
│  CronJob metrics-snapshot (*/15 * * * *)            │
│    └─ alpine/k8s:1.34.3                             │
│         curl → VictoriaMetrics (interne :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 (sans subPath)                │
│         ▼                                           │
│  Deployment blog (nginx)                            │
│    /usr/share/nginx/html/api/metrics-live.json      │
│         │                                           │
│         │ fetch /api/metrics-live.json              │
│         ▼                                           │
│  Navigateur → JS hydrate + re-fetch toutes 30s     │
└─────────────────────────────────────────────────────┘

Zéro commit git. Zéro rebuild GHA. Zéro endpoint public.

Le CronJob tourne dans le cluster, accède à VictoriaMetrics via le DNS interne du cluster (vmsingle-victoria-metrics-single-server.monitoring.svc.cluster.local:8428), et patche une ConfigMap dans un autre namespace via un RBAC cross-namespace minimal.

Le CronJob : curl, jq, kubectl en alpine

La première version utilisait bitnami/kubectl. En août 2025, Bitnami a retiré ses tags publics de Docker Hub — le job commençait à faire ImagePullBackOff. Pivot vers alpine/k8s:1.34.3 : multi-arch (ARM64 natif sur les RPi), ships kubectl + curl + jq dans une seule image Alpine légère.

Le script est un shell POSIX dans l’args du container. Deux fonctions utilitaires :

# PromQL → dict JSON {label: valeur} indexé par 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)
        )
      '
}

# PromQL scalaire → nombre (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}'
}

Puis les requêtes PromQL. Voici les plus instructives :

CPU par nœud (instance est l’adresse IP:port de node_exporter) :

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

Disk par nœud (ext4 uniquement, mountpoint racine) :

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

Pods running par nœud — c’est là que ça devient intéressant :

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

Pourquoi le join and on(namespace,pod) ? Parce que kube_pod_status_phase n’expose pas le label node. C’est kube_pod_info qui porte node, namespace, et pod. Le join filtre kube_pod_info aux seuls pods dont la phase est Running selon kube_pod_status_phase, en joinant sur (namespace, pod). Sans ça, on compterait tous les pods connus de kube-state-metrics indépendamment de leur état.

Nœuds ready (scalaire cluster) :

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

L’assemblage final utilise un seul jq -n avec --argjson pour construire le JSON atomiquement :

{
  "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
    },
    ...
  ]
}

Puis une seule commande pour écrire la 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 -

Le pattern create --dry-run | apply est idempotent : crée si la CM n’existe pas, patche sinon. Pas besoin de kubectl patch avec un heredoc JSON.

RBAC cross-namespace minimal

Le CronJob tourne dans le namespace metrics-snapshot mais doit écrire dans le namespace blog. Un Role (pas ClusterRole) dans le namespace blog, avec un RoleBinding qui pointe vers le ServiceAccount de l’autre namespace :

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: blog-metrics-writer
  namespace: blog
rules:
- apiGroups: [""]
  resources: ["configmaps"]
  resourceNames: ["blog-metrics"]   # restreint à cette CM uniquement
  verbs: ["get", "patch", "update"]
- apiGroups: [""]
  resources: ["configmaps"]
  verbs: ["create"]                  # create sans resourceNames (elle peut ne pas exister)
---
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

Le principe de moindre privilège est respecté : le SA ne peut toucher qu’une seule ConfigMap nommée dans un seul namespace.

Le mount ConfigMap dans nginx — le piège du subPath

Le Deployment monte la ConfigMap dans /usr/share/nginx/html/api :

volumeMounts:
- name: metrics
  mountPath: /usr/share/nginx/html/api
  readOnly: true
# Pas de subPath !
volumes:
- name: metrics
  configMap:
    name: blog-metrics
    optional: true   # le pod démarre même si la CM n'existe pas encore

subPath est interdit ici. Quand on monte un volume ConfigMap avec subPath, kubelet crée un bind mount fixe au moment du démarrage du pod. Les mises à jour ultérieures de la ConfigMap ne sont pas propagées dans le pod — le fichier reste figé à la valeur initiale jusqu’au prochain restart.

Sans subPath, kubelet utilise un lien symbolique atomique : il crée un nouveau répertoire versionné et fait pointer le symlink dessus. Les mises à jour de ConfigMap sont propagées dans les pods sans redémarrage, en ~1-2 minutes (configurable via --sync-frequency sur kubelet). C’est exactement ce qu’on veut.

L’inconvénient : on monte un répertoire entier, pas un fichier unique. Donc /usr/share/nginx/html/api/ ne contient que metrics-live.json. C’est amplement suffisant.

ArgoCD vs la ConfigMap qui dérive

ArgoCD gère le namespace metrics-snapshot — le CronJob, le RBAC, et une ConfigMap “seed” vide. Il gère aussi blog-metrics dans le namespace blog comme ConfigMap initiale.

Problème : toutes les 15 minutes, le CronJob overwrite la data de la ConfigMap avec des métriques fraîches. ArgoCD voit un drift entre son état désiré (la CM seed du repo) et l’état réel (la CM avec données live). Sans configuration supplémentaire, il la remet à zéro au prochain sync.

Solution : ignoreDifferences dans la spec de l’Application ArgoCD :

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

ArgoCD ignore le champ /data lors de la comparaison d’état. Il continuera à gérer la CM (la créer, gérer ses métadonnées), mais laissera le CronJob propriétaire du contenu.

nginx : désactiver le cache sur /api/

Par défaut nginx cache les réponses — et Cloudflare aussi. Un fichier JSON servi avec les headers par défaut sera mis en cache et le navigateur verra des métriques vieilles de plusieurs heures.

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 force un Expires dans le passé. no-cache, no-store, must-revalidate est le triple verrou : pas de cache navigateur, pas de cache intermédiaire (CDN), revalidation obligatoire. Le JS de la page fait un fetch('/api/metrics-live.json', { cache: 'no-store' }) pour confirmer l’intention côté client.

Le frontend : CustomEvent pour partager un seul fetch

Deux composants Astro affichent les métriques : ClusterLive (vue agrégée cluster) et NodeGrid (détail par nœud). Si chacun fetchait indépendamment, on aurait deux requêtes toutes les 30 secondes — inutile.

ClusterLive est le chef d’orchestre : il fetch une fois au chargement, puis toutes les 30 secondes, et rebroadcast via un CustomEvent sur 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 souscrit à l’événement :

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

NodeGrid a aussi un mode self-heal : si au bout de 2 secondes aucune donnée n’a été broadcastée (cas d’une page sans ClusterLive), il fetch lui-même une fois.

Les barres ASCII

L’esthétique du blog est terminal. Les barres de progression sont en caractères Unicode bloc :

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)}]`;
}

Un nœud à 42% CPU s’affiche :

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

Ça colle avec le reste du design system — pas de librairie de charting, pas de canvas, juste de la monospace.

v1 → v2 : l’évolution du schéma

Le commit 98b4c4c (v1) émettait un scalaire plat { cpu, mem, pods, uptime } agrégé pour le cluster entier. Suffisant pour une première version.

Le commit 88a8438 (v2) a ajouté la section nodes[] avec le détail par RPi. Le challenge était l’alignement des labels : node_cpu_seconds_total est labellisé par instance (l’IP du node_exporter), pas par le nom Kubernetes du nœud. kube_pod_info utilise node (le nom K8s). Il a fallu maintenir une table de correspondance statique dans le script :

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

Et construire le JSON final en itérant sur les noms K8s comme clés primaires, en lookupant les valeurs par instance ou node selon la métrique. C’est un choix délibéré de ne pas résoudre ça en PromQL — une jointure label entre deux familles de métriques issues de deux exporters différents serait fragile et peu lisible.

Coût réel

  • CPU : ~3-5 secondes d’exécution par run × 96 runs/jour ≈ 7 minutes CPU/jour cluster. Négligeable.
  • Mémoire : le pod CronJob demande 64 Mi, limite 128 Mi. Vit 10-15s et disparaît.
  • Stockage : la ConfigMap fait ~1.5 KB. Zéro PVC.
  • TTL des jobs : ttlSecondsAfterFinished: 600 — les pods terminés sont nettoyés au bout de 10 minutes.
  • Historique : successfulJobsHistoryLimit: 1, failedJobsHistoryLimit: 2. Seul le dernier succès est conservé.

Ce qu’on a évité

En ne touchant pas à Git et en ne reconstruisant pas l’image :

  • Pas de pollution du git log (96 commits/jour auraient rendu git log --oneline inutilisable)
  • Pas de charge sur l’API GitHub ni sur les runners CI
  • Pas de rolling update du Deployment toutes les 15 minutes
  • Pas de surface réseau publique supplémentaire

La ConfigMap est éphémère par nature : si elle disparaît, le blog démarre quand même (optional: true), affiche des tirets à la place des valeurs, et la reprend au prochain run du CronJob. Resilient by design.


Le pattern est transposable à n’importe quelle donnée in-cluster qu’on voudrait exposer sur une UI statique sans endpoint public : état des backups, healthcheck des apps, métriques de queue n8n. Un CronJob, une ConfigMap, un mount. Rien de plus.

Pour la stack qui alimente ces métriques (VictoriaMetrics, VMAlert, Grafana), voir Observabilité avec VictoriaMetrics + Grafana et sa suite, Observabilité v2.

k3s-clustermonitoringgitopsk8svictoriametricspromqlargocdnginxastro