From 0c23862335ebda44ad83970f4bf74fd126de089b Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Mon, 7 Sep 2026 18:06:08 +0200 Subject: [PATCH] =?UTF-8?q?La=20collecte=20des=20journaux=20:=20Loki=20et?= =?UTF-8?q?=20Alloy,=20pour=20qu'un=20message=20survive=20=C3=A0=20son=20p?= =?UTF-8?q?od=20(#40)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Co-authored-by: Gabriel Radureau --- .gitea/workflows/helmcharts.yaml | 4 +- alloy/.helmignore | 23 +++ alloy/Chart.yaml | 16 ++ alloy/values.yaml | 275 ++++++++++++++++++++++++++++++ chart/values.yaml | 6 + grafana/values.yaml | 15 ++ loki/.helmignore | 23 +++ loki/Chart.yaml | 35 ++++ loki/values.yaml | 283 +++++++++++++++++++++++++++++++ 9 files changed, 678 insertions(+), 2 deletions(-) create mode 100644 alloy/.helmignore create mode 100644 alloy/Chart.yaml create mode 100644 alloy/values.yaml create mode 100644 loki/.helmignore create mode 100644 loki/Chart.yaml create mode 100644 loki/values.yaml diff --git a/.gitea/workflows/helmcharts.yaml b/.gitea/workflows/helmcharts.yaml index f4eb018..baa8718 100644 --- a/.gitea/workflows/helmcharts.yaml +++ b/.gitea/workflows/helmcharts.yaml @@ -336,9 +336,9 @@ jobs: needs: [filter-chart,library-charts] strategy: matrix: - # ⚠ Les NEUF charts applicatifs du dépôt — voir l'avertissement du job + # ⚠ Les ONZE charts applicatifs du dépôt — voir l'avertissement du job # `library-charts` ci-dessus. Un chart absent de cette liste n'est # JAMAIS construit, et sa PR est verte quand même. # chart: ${{ fromJson(needs.filter-chart.outputs.application_charts) }} - chart: [chart, crowdsec, grafana, hashicorp-vault, minio, pgbouncer, pgcat, prometheus, redis] + chart: [alloy, chart, crowdsec, grafana, hashicorp-vault, loki, minio, pgbouncer, pgcat, prometheus, redis] type: [application] \ No newline at end of file diff --git a/alloy/.helmignore b/alloy/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/alloy/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/alloy/Chart.yaml b/alloy/Chart.yaml new file mode 100644 index 0000000..a650593 --- /dev/null +++ b/alloy/Chart.yaml @@ -0,0 +1,16 @@ +apiVersion: v2 +name: alloy +description: >- + L'agent qui ramasse les journaux de chaque nœud et les pousse vers Loki. + Un DaemonSet — donc trois pods, un par Raspberry Pi. + +# Même raison que pour `loki/` : aucune dépendance au registre Helm interne, +# que le runner de CI ne résout pas (relevé du 2026-09-07). Voir loki/Chart.yaml. +dependencies: + - name: alloy + version: 1.12.1 + repository: https://grafana.github.io/helm-charts + +type: application +version: 0.1.0 +appVersion: "v1.19.2" diff --git a/alloy/values.yaml b/alloy/values.yaml new file mode 100644 index 0000000..d767c3f --- /dev/null +++ b/alloy/values.yaml @@ -0,0 +1,275 @@ +# ----------------------------------------------------------------------------- +# 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 diff --git a/chart/values.yaml b/chart/values.yaml index e6be51b..8f1c18f 100644 --- a/chart/values.yaml +++ b/chart/values.yaml @@ -10,3 +10,9 @@ tools: plausible: {} prometheus: {} minio: {} + # La collecte de journaux (tools#38). Deux applications parce que ce sont + # deux charts amont, et parce qu'on redémarre l'agent sans toucher au + # magasin — mais elles ne valent rien l'une sans l'autre : `alloy` pousse + # vers le service que `loki` publie. + loki: {} + alloy: {} diff --git a/grafana/values.yaml b/grafana/values.yaml index 0b48119..7364f14 100644 --- a/grafana/values.yaml +++ b/grafana/values.yaml @@ -573,6 +573,21 @@ grafana: &grafana_config url: http://prometheus-server.tools.svc.cluster.local access: proxy isDefault: true + # Les journaux du cluster (tools#38). Le chart `loki` d'à côté déploie + # le binaire unique ; `alloy` y pousse ce que disent les pods. + # ⚠ `loki.auth_enabled: false` côté serveur : aucun en-tête + # X-Scope-OrgID à porter ici. Si l'authentification multi-locataire + # était rallumée, cette source de données cesserait de répondre et il + # faudrait lui ajouter `httpHeaderName1: X-Scope-OrgID`. + - name: Loki + type: loki + url: http://loki.tools.svc.cluster.local:3100 + access: proxy + isDefault: false + jsonData: + # La rétention posée dans loki/values.yaml. Grafana s'en sert pour + # ne pas proposer une fenêtre que le magasin ne peut plus servir. + maxLines: 1000 # - name: CloudWatch # type: cloudwatch # access: proxy diff --git a/loki/.helmignore b/loki/.helmignore new file mode 100644 index 0000000..0e8a0eb --- /dev/null +++ b/loki/.helmignore @@ -0,0 +1,23 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*.orig +*~ +# Various IDEs +.project +.idea/ +*.tmproj +.vscode/ diff --git a/loki/Chart.yaml b/loki/Chart.yaml new file mode 100644 index 0000000..86bc8ff --- /dev/null +++ b/loki/Chart.yaml @@ -0,0 +1,35 @@ +apiVersion: v2 +name: loki +description: >- + Le garde-mémoire des journaux du cluster — Loki en binaire unique, stockage + fichier sur Longhorn. Ce qu'un pod a dit continue de se lire après sa mort. + +# ⚠ PAS de dépendance au registre Helm interne, CONTRAIREMENT à grafana et +# prometheus — et c'est délibéré. +# +# La bibliothèque `tool` ne sert QUE via les gabarits `templates/helm-chart*.yaml`, +# eux-mêmes gardés par `{{- if eq .Values.tool.kind "HelmChart" -}}`. Avec +# `kind: SubChart` — le mode de grafana, de prometheus, et le nôtre — ces +# gabarits ne rendent RIEN : la bibliothèque est un poids mort. +# +# Or ce poids mort a un coût mesuré : le runner de CI ne résout pas +# `gitea.arcodange.lab` (relevé du 2026-09-07, run 6728, `curl` code 6). Un +# repli sur `GITHUB_SERVER_URL` a été posé dans `helmcharts.yaml`, mais il reste +# un point de panne EN AMONT du `helm template`. Si la dépendance ne se +# télécharge pas, l'étape qui éprouve VRAIMENT ce chart ne s'exécute jamais et +# le job meurt sur une cause étrangère au chart. +# +# On ne dépend donc que de `grafana.github.io`, dont le même relevé a constaté +# qu'il répond depuis ce runner. Le jour où l'on veut basculer en +# `kind: HelmChart` (CRD k3s), on rajoute la dépendance ET les deux gabarits. +dependencies: + - name: loki + version: 7.3.0 + repository: https://grafana.github.io/helm-charts + +type: application +version: 0.1.0 +# ⚠ 3.6.11, PAS 3.6.12. L'index Helm annonce `appVersion: 3.6.12` pour le chart +# 7.3.0, mais le chart épingle `loki.image.tag: 3.6.11` dans ses valeurs — et +# c'est le tag qui est tiré. Vérifié sur le pod déployé. +appVersion: "3.6.11" diff --git a/loki/values.yaml b/loki/values.yaml new file mode 100644 index 0000000..5e1405b --- /dev/null +++ b/loki/values.yaml @@ -0,0 +1,283 @@ +# ----------------------------------------------------------------------------- +# Loki — binaire unique, stockage fichier, sur trois Raspberry Pi. +# ----------------------------------------------------------------------------- +# ⚠⚠ LES DÉFAUTS AMONT SONT ÉCRITS POUR UN CLUSTER DE SERVEURS, PAS POUR CECI. +# Mesurés sur le chart 7.3.0 (`helm show values grafana/loki --version 7.3.0`) : +# +# deploymentMode SimpleScalable → read + write + backend, 3 jeux +# chunksCache.enabled true → un memcached à … +# chunksCache.allocatedMemory 8192 → … 8 Gio, sur un nœud qui en +# alloue 5,6 (pi2) +# resultsCache.enabled true → un second memcached +# gateway.enabled true → un nginx de plus +# lokiCanary.enabled true → un DaemonSet de plus +# test.enabled true → un pod de test +# loki.commonConfig.replication_factor 3 → 3 exemplaires de chaque flux +# loki.storage.type s3 → un magasin objet qu'on n'a pas +# loki.auth_enabled true → multi-locataire (en-tête +# X-Scope-OrgID exigé à chaque appel) +# +# Laisser ces défauts, c'est exactement la deuxième propriété de tools#38 +# violée : « la collecte ne doit pas faire tomber ce qu'elle observe ». Chaque +# `false` ci-dessous est donc une décision, pas une omission. +# ----------------------------------------------------------------------------- +loki: + # -- Un seul processus, une seule réplique. Le reste des modes est éteint plus bas. + deploymentMode: SingleBinary + + loki: + # Locataire unique : personne d'autre que le homelab n'écrit ici. Laisser + # `true` obligerait chaque requête (et chaque source de données Grafana) à + # porter un en-tête X-Scope-OrgID, pour aucun bénéfice. + auth_enabled: false + + commonConfig: + path_prefix: /var/loki + # -- UNE réplique de chaque flux : il n'y a qu'un ingesteur. + # Le défaut amont (3) ferait refuser toute écriture, faute de quorum. + replication_factor: 1 + + storage: + type: filesystem + filesystem: + chunks_directory: /var/loki/chunks + rules_directory: /var/loki/rules + # ⚠ PAS de `admin_api_directory` ici. Le chart l'accepte (il est dans + # ses valeurs par défaut), mais le binaire OSS le REFUSE : + # failed parsing config: field admin_api_directory not found in + # type common.FilesystemConfig + # C'est un champ de l'édition entreprise (GEL). Mesuré : le pod part en + # CrashLoopBackOff, 3 redémarrages en 4 min. Le chart ne valide pas la + # config qu'il écrit — seul le démarrage réel le dit. + + # ⚠ Le chart n'écrit AUCUN schéma par défaut (`schemaConfig: {}`) et refuse + # de démarrer sans. tsdb + v13 est le couple courant de Loki 3.x. + # + # ⚠⚠ ET LA DATE `from` NE SE MET PAS AU JOUR DE L'INSTALLATION. + # C'est la faute qu'a faite le premier jet, et elle a coûté cher à + # diagnostiquer parce qu'elle ne ressemble à rien de ce qu'elle est. + # + # `from` dit « à partir de quand ce schéma s'applique », pas « depuis quand + # on garde ». Posée à la date du jour, Loki n'a AUCUN schéma pour les + # horodatages antérieurs et refuse la requête d'écriture ENTIÈRE : + # + # POST /loki/api/v1/push (500) + # failed to create stream: no schema config found for time 1786622254 + # + # Or l'agent lit aussi les fichiers ROTATÉS encore présents sur le disque + # (4.log, 9.log, 10.log…), qui contiennent des lignes vieilles de plusieurs + # mois — une remontait à 2026-04-13. ⚠ Et comme une poussée est un LOT, le + # 500 emporte les lignes FRAÎCHES qui voyageaient avec les vieilles. + # + # Symptôme observé : `kubectl logs` montrait 19 lignes du pod témoin, Loki + # en montrait 0, l'agent se déclarait sain, ses compteurs `dropped` étaient + # tous à zéro — et `loki_write_sent_entries_total` valait 0. Rien ne + # DÉSIGNAIT la cause côté agent : elle n'était lisible que dans le journal + # de Loki. (Journal qu'on ne pouvait lire que parce que le pod n'avait pas + # redémarré. L'ironie est le sujet même de l'issue #38.) + # + # La date est donc largement antérieure à tout ce que le cluster peut + # porter. Ce qui borne la conservation, c'est `retention_period` + + # le compacteur, pas ceci. + schemaConfig: + configs: + - from: "2020-01-01" + store: tsdb + object_store: filesystem + schema: v13 + index: + prefix: loki_index_ + period: 24h + + # ⚠⚠ UNE RÉTENTION QUI NE SUPPRIME RIEN EST UNE RÉTENTION QUI MENT. + # `limits_config.retention_period` seul ne fait RIEN : c'est le compacteur + # qui efface, et il ne le fait que si `retention_enabled` est vrai. Le + # couple est indissociable — l'un sans l'autre laisse le volume grossir + # jusqu'à saturation, en silence. + compactor: + working_directory: /var/loki/compactor + retention_enabled: true + delete_request_store: filesystem + + limits_config: + # -- 30 jours. Voir `RETENTION` en fin de fichier pour la mesure. + retention_period: 720h + # ⚠⚠ CE N'EST PAS UN RÉGLAGE DE CONSERVATION, ET LE CONFONDRE BLOQUE TOUT. + # + # Posé à 720 h pour « coller » à la rétention, il a produit un second + # blocage complet, juste après celui du schéma : l'agent lit aussi les + # fichiers rotatés présents sur le disque, dont certains remontent à + # AVRIL. Loki refusait alors chaque lot — + # + # entry ... has timestamp too old: 2026-04-13T13:47:05Z, + # oldest acceptable timestamp is: 2026-08-08T15:41:50Z + # + # — et comme l'agent REJOUE un lot refusé, la file ne se vidait jamais : + # `loki_write_sent_entries_total` restait à 0 pour toujours. Un lot + # définitivement refusé bloque la file derrière lui. + # + # Ce garde-fou existe contre les horloges DÉCALÉES, pas pour borner la + # conservation. On l'ouvre donc largement au-delà de ce que le disque + # peut porter ; ce qui borne vraiment, c'est `retention_period` + le + # compacteur, plus bas. + # + # ⚠ Conséquence assumée : au tout premier démarrage, l'agent remonte + # l'historique encore sur le disque, et le compacteur supprimera dans + # l'heure ce qui dépasse 30 jours. On paie une ingestion inutile UNE + # fois, en échange d'une file qui se vide. Le bénéfice de bord est + # qu'on récupère au passage l'histoire déjà écrite. + reject_old_samples: true + reject_old_samples_max_age: 8760h + max_cache_freshness_per_query: 10m + split_queries_by_interval: 15m + query_timeout: 300s + volume_enabled: true + # Débit mesuré du cluster : ~305 Mio/jour TOUS pods confondus, soit + # ~3,6 Kio/s. Les bornes amont (4 Mio/s par flux) sont hors sujet ici ; + # on les laisse, elles ne mordent pas. + allow_structured_metadata: true + + # Pas de vitrine : personne d'extérieur n'interroge ce Loki. + analytics: + reporting_enabled: false + + singleBinary: + replicas: 1 + persistence: + enabled: true + # ⚠ DEUX StorageClass portent le drapeau `(default)` sur ce cluster + # (`local-path` ET `longhorn`, mesuré le 2026-09-07). Un PVC sans classe + # explicite est donc un tirage au sort — et `local-path` ne survit pas au + # déplacement du pod, ce qui viderait la mémoire qu'on vient de bâtir. + # On NOMME la classe. + storageClass: longhorn + # 30 j × 305 Mio/j = 8,9 Gio BRUTS ; Loki compresse (facteur 5 à 10 sur + # du journal), donc 0,9 à 1,8 Gio attendus. 10 Gi laisse 5 à 10× de marge + # pour la croissance de kadans, sans immobiliser le disque. + size: 10Gi + resources: + requests: + cpu: 100m + memory: 256Mi + limits: + # Pas de limite CPU : l'étranglement d'un compacteur est pire que sa + # lenteur. La mémoire, elle, est PLAFONNÉE — c'est la ressource rare + # (pi2 mesuré à 109 % de son allouable le 2026-09-07). + memory: 512Mi + + # --------------------------------------------------------------------------- + # Tout ce qui suit existe pour un déploiement distribué. Ici, chacun de ces + # blocs est du poids mort sur des Raspberry Pi — on les éteint NOMMÉMENT + # plutôt que par un `deploymentMode` qui « devrait » suffire. + # --------------------------------------------------------------------------- + write: { replicas: 0 } + read: { replicas: 0 } + backend: { replicas: 0 } + ingester: { replicas: 0 } + distributor: { replicas: 0 } + querier: { replicas: 0 } + queryFrontend: { replicas: 0 } + queryScheduler: { replicas: 0 } + indexGateway: { replicas: 0 } + compactor: { replicas: 0 } + bloomPlanner: { replicas: 0 } + bloomBuilder: { replicas: 0 } + bloomGateway: { replicas: 0 } + patternIngester: { replicas: 0 } + ruler: { replicas: 0 } + overridesExporter: { replicas: 0 } + + # Les deux memcached. Le premier réclame 8 Gio à lui seul par défaut. + chunksCache: + enabled: false + resultsCache: + enabled: false + + # Un nginx devant un service que seul Grafana interroge, depuis le cluster. + gateway: + enabled: false + + # Un DaemonSet qui écrit des journaux pour vérifier qu'on lit les journaux : + # trois pods de plus sur trois nœuds, pour une propriété que le sabotage de + # la PR démontre mieux (écrire une ligne, tuer le pod, la relire). + lokiCanary: + enabled: false + + test: + enabled: false + + # Le chart sait déployer SON PROPRE MinIO. On en a déjà un, et on n'en veut + # pas ici : le stockage est le disque. + minio: + enabled: false + + monitoring: + dashboards: + enabled: false + rules: + enabled: false + serviceMonitor: + enabled: false + metricsInstance: + enabled: false + selfMonitoring: + enabled: false + grafanaAgent: + installOperator: false + + # Pas d'opérateur de déploiement progressif pour une réplique unique. + rollout_operator: + enabled: false + + # ⚠ UN SECOND CONTENEUR SANS PLAFOND ANNULE LE PLAFOND DU PREMIER. + # Le chart ajoute au pod un side-car `loki-sc-rules` (kiwigrid/k8s-sidecar) + # qui surveille des ConfigMaps de règles d'alerte. Mesuré sur le premier + # déploiement : il arrive avec `resources: {}` — ni requête ni limite — dans + # un pod posé sur pi2, le nœud à 108 % de son allouable. On l'éteint : le + # `ruler` est à 0 réplique, il n'y a aucune règle à ingérer, donc ce side-car + # surveille des ConfigMaps qui n'existent pas. + sidecar: + rules: + enabled: false + +# ----------------------------------------------------------------------------- +# RETENTION — l'arbitrage, et la mesure qui le porte +# ----------------------------------------------------------------------------- +# Mesuré le 2026-09-07 sur ce cluster, par l'API (donc ce que Loki ingérerait) : +# +# fenêtre de 10 min, tous pods : 2 182 015 octets +# fenêtre de 1 h, tous pods : 7 897 505 octets +# +# Les deux instruments ne s'accordent pas (l'heure ne vaut que 0,60 fois six +# fois les dix minutes) — et le désaccord est LE résultat. Par conteneur, le +# rapport 1 h / 10 min vaut ~6 partout (débit stable) SAUF : +# +# kube-system/traefik ratio 1,18 → la fenêtre d'une heure est TRONQUÉE +# tools/clickhouse-0 ratio 3,98 → tronquée aussi +# +# Vérifié directement, en demandant à chaque journal jusqu'où il remonte : +# +# traefik pod démarré il y a 12 j — journal remontant à 10 MINUTES +# clickhouse pod démarré il y a 8 j — journal remontant à 2 MINUTES +# +# Autrement dit, les deux composants les plus bavards du cluster gardent entre +# deux et dix minutes d'histoire. C'est l'issue tools#38 en un chiffre. +# +# Estimation corrigée (max(1 h, 10 min × 6) par conteneur, pour ne pas hériter +# de la troncature) : 13 333 645 octets/h = 305 Mio/jour bruts. +# +# 30 j bruts 8,94 Gio +# 30 j compressés (facteur 5) 1,79 Gio +# 30 j compressés (facteur 10) 0,89 Gio +# +# POURQUOI 30 JOURS et pas 7 : les deux pannes qui motivent ce lot ont duré des +# SEMAINES avant d'être vues (la mise à l'abri de kadans#1033) ou trois jours +# (tools#36). Une rétention de 7 jours aurait perdu le début des deux. 30 jours +# couvre le délai réel entre « ça casse » et « quelqu'un s'en aperçoit », et +# coûte 1 à 2 Gio — sur 99 Gio libres au plus serré des trois nœuds Longhorn. +# +# ⚠ CE QUI RENDRAIT CE CHIFFRE FAUX : la mesure est une photo d'un mardi +# après-midi. Elle ne dit rien d'un pod qui part en boucle d'erreur, cas où le +# débit d'un seul conteneur peut dépasser à lui seul tout le reste du cluster. +# La marge de 5 à 10× est là pour ça, pas pour la croissance ordinaire. +# -----------------------------------------------------------------------------