Files
arcodangeandClaude Opus 5 0c23862335
Helm Charts / Detect changed charts (push) Successful in 22s
Helm Charts / Library charts tool (push) Skipped
Helm Charts / Application charts crowdsec (push) Skipped
Helm Charts / Application charts hashicorp-vault (push) Skipped
Helm Charts / Application charts minio (push) Skipped
Helm Charts / Application charts pgbouncer (push) Skipped
Helm Charts / Application charts pgcat (push) Skipped
Helm Charts / Application charts prometheus (push) Skipped
Helm Charts / Application charts redis (push) Skipped
Helm Charts / Application charts loki (push) Successful in 14s
Helm Charts / Application charts chart (push) Successful in 34s
Helm Charts / Application charts grafana (push) Failing after 49s
Helm Charts / Application charts alloy (push) Successful in 52s
La collecte des journaux : Loki et Alloy, pour qu'un message survive à son pod (#40)
Alloy plutôt que Promtail, que l'amont a marqué déprécié. Rétention 30 jours,
calée sur 305 Mio/jour mesurés — et la mesure a révélé pire que ce que l'issue
supposait : traefik ne gardait que 10 minutes d'histoire, clickhouse 2.

La propriété tient, éprouvée : jeton écrit, pod supprimé, `kubectl logs` ne
répond plus, Loki rend encore les lignes.

Ferme #38.

Co-Authored-By: Claude Opus 5 <[email protected]>
Co-authored-by: Gabriel Radureau <[email protected]>
2026-09-07 18:06:08 +02:00

276 lines
12 KiB
YAML

# -----------------------------------------------------------------------------
# Grafana Alloy — l'agent de collecte, un pod par nœud.
# -----------------------------------------------------------------------------
# POURQUOI ALLOY ET PAS PROMTAIL (l'arbitrage de tools#38, mesuré le 2026-09-07)
#
# Relevé sur l'index du dépôt Helm de Grafana lui-même
# (`curl https://grafana.github.io/helm-charts/index.yaml`) :
#
# chart versions dernière publication `deprecated:` dans l'index
# promtail 141 2025-10-31 true
# alloy 57 2026-08-27 (absent)
# loki 344 2026-08-10 (absent)
#
# Promtail n'est pas « en fin de vie bientôt » : son chart porte le drapeau
# `deprecated: true` dans l'index, et sa dernière publication a plus de dix mois.
# Déployer AUJOURD'HUI, sur une installation NEUVE, un agent que l'amont a déjà
# rangé, c'est se donner une dette à échéance connue pour économiser de la
# mémoire — et l'économie, elle, est à vérifier, pas à supposer.
#
# Le coût réel d'Alloy sur CE matériel est mesuré après déploiement et reporté
# dans la PR ; c'est ce que le plafond `resources` ci-dessous encadre.
#
# ARM64 : vérifié au registre, manifeste multi-plateforme (jeton anonyme
# Docker Hub) — `grafana/alloy:v1.19.2` publie linux/amd64, **linux/arm64**,
# ppc64le, s390x. `grafana/loki:3.6.12` publie amd64, **arm64**, arm/v7.
# -----------------------------------------------------------------------------
alloy:
controller:
# C'est déjà le défaut du chart ; on l'écrit parce que c'est LA décision
# structurante — un agent par nœud, donc trois fois le coût mémoire.
type: daemonset
# Le volume que consomme le montage `alloy.mounts.extra` ci-dessous : la
# racine Docker de ces machines, où aboutissent les liens de /var/log/pods.
volumes:
extra:
- name: docker-containers
hostPath:
path: /mnt/arcodange/docker/containers
# `Directory` (et non `DirectoryOrCreate`) EXPRÈS : si un jour un
# nœud n'a pas ce chemin, on veut que son agent refuse de démarrer
# et le DISE, plutôt que de monter un dossier vide et de rapporter
# zéro journal en se déclarant sain — le défaut exact qu'on vient
# de traverser.
type: Directory
alloy:
# -- Le montage de /var/log : c'est par là que l'agent lit les journaux.
# On tail des FICHIERS plutôt que d'interroger l'API du kubelet : sur trois
# Pi, faire transiter tous les journaux par l'API du cluster ajouterait au
# serveur d'API une charge qu'il n'a pas à porter.
mounts:
varlog: true
# ⚠⚠ CE CLUSTER TOURNE SOUS DOCKER, PAS SOUS CONTAINERD — et son magasin
# d'images n'est pas là où le chart le cherche. Les deux faits ensemble
# rendent ce drapeau inutilisable.
#
# Mesuré le 2026-09-07 (`kubectl get nodes -o …containerRuntimeVersion`) :
# pi1 docker://29.1.3
# pi2 docker://28.4.0
# pi3 docker://27.4.0
#
# Et sous /var/log/pods, les journaux ne sont PAS des fichiers : ce sont
# des liens symboliques vers la racine Docker, DÉPLACÉE sur un disque
# externe (identique sur pi1 et pi3, vérifié pod par pod) :
#
# /var/log/pods/<ns>_<pod>_<uid>/<conteneur>/0.log
# -> /mnt/arcodange/docker/containers/<id>/<id>-json.log
#
# `dockercontainers: true` monterait `/var/lib/docker/containers`, qui
# n'existe pas ici. On monte donc la vraie racine, plus bas, en `extra`.
dockercontainers: false
# ⚠ SANS CE MONTAGE, LA COLLECTE EST SILENCIEUSEMENT VIDE — et c'est le
# mode d'échec le plus traître de ce lot, parce que TOUT A L'AIR SAIN :
# les six composants d'Alloy se déclarent `healthy`, aucune erreur de
# poussée, Loki répond `ready`. Le seul relevé qui le dit est
# `loki_source_file_files_active_total = 0`, et l'export vide de
# `local.file_match`. `ls` montre les liens, `stat` échoue sur la cible.
extra:
- name: docker-containers
mountPath: /mnt/arcodange/docker/containers
readOnly: true
# ⚠ LA MÉMOIRE EST LA RESSOURCE RARE, ET UN DAEMONSET LA PREND TROIS FOIS.
# Mesuré le 2026-09-07 avant tout déploiement : pi1 79 %, pi2 109 %,
# pi3 58 % de leur mémoire ALLOUABLE. pi2 est en surengagement — c'est
# précisément le nœud où un agent de trop bascule la machine.
#
# Le plafond n'est donc pas une formalité : il est ce qui garantit que
# l'agent meurt AVANT le service qu'il observe. Un OOM d'Alloy est un
# incident de collecte ; un OOM de MinIO ou de pgbouncer est un incident
# tout court.
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
# Pas de limite CPU (l'étranglement d'un agent lui fait perdre des
# lignes en silence, ce qui est le défaut qu'on essaie de supprimer).
#
# ⚠ 384 Mi, ET C'EST UN CHIFFRE MESURÉ, PAS UN CHIFFRE ROND.
# Le premier jet plafonnait à 256 Mi. Relevé sur la pile qui tourne,
# pendant la reprise de l'historique (`kubectl top pods --containers`) :
#
# agent de pi1 214 Mi ← 84 % du plafond de 256 Mi
# agent de pi2 98 Mi
# agent de pi3 83 Mi
#
# Aucun OOMKill constaté (0 redémarrage sur les trois), mais 84 % n'est
# pas une marge : un agent tué perd sa position de lecture, et un trou
# dans la collecte est exactement ce que ce lot existe pour supprimer.
#
# ⚠ Relever un PLAFOND ne consomme rien : il borne le pire cas. C'est la
# REQUÊTE (128 Mi, inchangée) que le planificateur réserve.
#
# ⚠ L'écart entre les trois nœuds n'est pas du bruit : il suit le nombre
# de fichiers suivis, donc le nombre de conteneurs du nœud. Un nœud qui
# se remplit fera monter son agent.
memory: 384Mi
# Pas de grappe : chaque agent lit SON nœud, il n'a rien à coordonner.
clustering:
enabled: false
configMap:
create: true
# -----------------------------------------------------------------------
# La configuration Alloy. Trois étages : découvrir, lire, pousser.
# -----------------------------------------------------------------------
content: |-
// ÉTAGE 1 — découvrir les pods de CE nœud, et de lui seul.
//
// ⚠ Sans le filtre par nœud, chacun des trois agents découvrirait les
// pods des TROIS nœuds, chercherait leurs fichiers en local, n'en
// trouverait aucun pour les deux autres, et referait ce travail en
// boucle. Le filtre se pose côté serveur d'API (`field`), pas en
// relabel : ce qui n'est pas envoyé ne coûte rien.
discovery.kubernetes "pods_du_noeud" {
role = "pod"
selectors {
role = "pod"
field = "spec.nodeName=" + sys.env("HOSTNAME")
}
}
// ÉTAGE 2 — traduire les métadonnées Kubernetes en étiquettes Loki,
// et fabriquer le chemin du fichier journal.
//
// ⚠ LES ÉTIQUETTES SONT LE SEUL INDEX DE LOKI, ET CHAQUE COMBINAISON
// DISTINCTE EST UN FLUX. On garde ce par quoi on cherche vraiment
// (namespace, pod, conteneur, nœud) et RIEN de ce qui varie sans
// qu'on l'interroge : mettre ici une étiquette à forte cardinalité
// (un identifiant de requête, un horodatage) est la façon classique
// de faire tomber un Loki. Le reste du contenu reste cherchable en
// plein texte, simplement non indexé.
discovery.relabel "journaux_de_pods" {
targets = discovery.kubernetes.pods_du_noeud.targets
rule {
source_labels = ["__meta_kubernetes_namespace"]
target_label = "namespace"
}
rule {
source_labels = ["__meta_kubernetes_pod_name"]
target_label = "pod"
}
rule {
source_labels = ["__meta_kubernetes_pod_container_name"]
target_label = "container"
}
// ⚠ `__meta_kubernetes_POD_node_name`, PAS `__meta_kubernetes_node_name`.
// Le second n'existe QUE pour `role = "node"` ; sous `role = "pod"` il
// est vide, et un relabel dont la source est vide ne pose pas
// l'étiquette — SANS erreur, sans avertissement, sans rien. Mesuré :
// l'étiquette `node` était simplement absente de Loki, sur une
// collecte par ailleurs saine. On ne s'en aperçoit qu'en DEMANDANT
// les valeurs de l'étiquette et en trouvant la liste vide.
rule {
source_labels = ["__meta_kubernetes_pod_node_name"]
target_label = "node"
}
rule {
source_labels = ["__meta_kubernetes_pod_label_app_kubernetes_io_name"]
target_label = "app"
}
// Le chemin réel posé par le kubelet :
// /var/log/pods/<ns>_<pod>_<uid>/<conteneur>/0.log
// On vise par l'UID (unique) et le nom du conteneur.
rule {
source_labels = ["__meta_kubernetes_pod_uid", "__meta_kubernetes_pod_container_name"]
separator = "/"
action = "replace"
replacement = "/var/log/pods/*$1/*.log"
target_label = "__path__"
}
}
// ÉTAGE 3 — lire les fichiers, décoder le format CRI, pousser.
local.file_match "journaux_de_pods" {
path_targets = discovery.relabel.journaux_de_pods.output
}
loki.source.file "journaux_de_pods" {
targets = local.file_match.journaux_de_pods.targets
forward_to = [loki.process.journaux_de_pods.receiver]
}
// ⚠⚠ `stage.docker`, PAS `stage.cri` — ces nœuds tournent sous Docker
// (mesuré : docker://29.1.3, 28.4.0, 27.4.0), donc chaque ligne est un
// objet JSON {"log":…,"stream":…,"time":…} et non le format CRI
// « <horodatage> <flux> <F|P> <message> ».
//
// Ce n'est pas cosmétique : sans le bon décodeur, l'horodatage stocké
// serait celui de la LECTURE et non celui de l'écriture, et les lignes
// arriveraient encore encapsulées dans leur JSON. Or dater une panne à
// la minute est PRÉCISÉMENT l'usage qui motive ce lot (tools#36).
loki.process "journaux_de_pods" {
stage.docker {}
forward_to = [loki.write.loki.receiver]
}
loki.write "loki" {
endpoint {
url = "http://loki.tools.svc.cluster.local:3100/loki/api/v1/push"
}
}
# L'agent a besoin de LIRE les pods du serveur d'API pour les découvrir.
# Le chart pose un ClusterRole en lecture seule (pods, nodes, services,
# endpoints) — rien qui écrive.
rbac:
create: true
serviceAccount:
create: true
# Pas de collecte de métriques d'Alloy par Prometheus pour l'instant : le
# ServiceMonitor suppose l'opérateur Prometheus, que ce cluster n'a pas
# (le chart `prometheus` d'ici est le chart communautaire, pas l'opérateur).
serviceMonitor:
enabled: false
ingress:
enabled: false
# ⚠⚠ LE MÊME PIÈGE QUE LE SIDE-CAR DE LOKI, DEUXIÈME OCCURRENCE — et celle-ci
# est plus sournoise, parce qu'elle RESSEMBLE à un plafond.
#
# Le chart ajoute au pod un second conteneur, `config-reloader`, qui recharge
# Alloy quand sa ConfigMap change. Mesuré sur le premier déploiement, ses
# ressources amont sont :
#
# resources:
# requests: { cpu: 10m, memory: 50Mi }
# ← et RIEN d'autre
#
# Une requête SANS limite n'est pas un plafond : c'est une réservation. Le
# conteneur peut croître jusqu'à épuiser le nœud, et comme il partage le
# cgroup du pod, il emporte Alloy avec lui. Sur un DaemonSet, cela vaut sur
# les TROIS nœuds — pi2 compris, mesuré à 108 % de son allouable.
#
# ⚠ On ne peut pas l'éteindre : sans lui, un changement de configuration
# n'atteindrait l'agent qu'au prochain redémarrage du pod. On le PLAFONNE.
configReloader:
enabled: true
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 64Mi