# ----------------------------------------------------------------------------- # 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/__//0.log # -> /mnt/arcodange/docker/containers//-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/__//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 // « ». // // 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