Skip to content
Vos logs ne devraient pas savoir dans quel cloud ils tournent
← ← Retour aux Réflexions Cloud

Vos logs ne devraient pas savoir dans quel cloud ils tournent

Il est 02 h 40 et le pipeline de commandes perd des événements. L'API tourne sur EKS. Le worker qui enrichit les commandes a migré vers GKE le trimestre dernier, parce que l'équipe ML vit là-bas. Le vieux service de facturation est encore sur AKS. Vous avez trois onglets ouverts : CloudWatch Logs Insights, Cloud Logging avec son propre langage de requête, et Azure Monitor avec KQL. Trois syntaxes. Trois façons d'écrire « les quinze dernières minutes ». CloudWatch affiche les horodatages en UTC, le portail Azure dans votre heure locale, Google dans ce que dit le navigateur. Vous corrélez à l'œil, vous copiez un identifiant de requête d'un onglet dans la barre de recherche du suivant, et vous vous trompez deux fois avant de tomber juste.

Personne n'a planifié ça. Chaque équipe a pris le logging que son cloud lui offrait, parce qu'il était là et préconfiguré, et chaque décision était localement correcte. L'incident, c'est la facture de toutes ces décisions qui arrive d'un coup.

L'instinct, c'est de régler ça avec de l'outillage : acheter quelque chose qui interroge les trois, écrire un script qui tire depuis trois API. Cela traite le symptôme. Le problème est architectural : vous avez laissé le cloud posséder vos logs. L'application a écrit sur stdout, la plateforme l'a ramassé, et à partir de cet instant le format, le langage de requête, la rétention et le prix ont été décidés par le fournisseur. Les logs sont une sortie de votre application. Là où ils finissent devrait être votre décision, prise une seule fois, et elle devrait survivre au prochain changement d'infrastructure.

Comment vous en êtes arrivé là

Chaque cloud vous donne du logging gratuitement, au sens où il n'y a rien à installer. Un conteneur sur EKS écrit sur stdout et, avec un add-on, les lignes apparaissent dans CloudWatch. Sur GKE l'agent est déjà dans l'image du nœud. Sur AKS vous cochez « Container Insights ». C'est le chemin de moindre résistance, et sur le premier cloud c'est vraiment le bon choix : vous avez quelque chose qui marche en un après-midi et vous passez au produit.

Le coût caché apparaît à trois moments. Le deuxième cloud, quand vous découvrez que rien de ce que vous avez construit sur le premier ne se transfère : ni les requêtes, ni les tableaux de bord, ni les alertes, ni les réflexes. La migration, quand un workload bouge et que ses six mois d'historique restent derrière, dans un stockage que vous continuez de payer. Et la facture d'ingestion, qui grandit avec votre trafic plutôt qu'avec votre effectif d'ingénieurs et qui a tendance à devenir la troisième ligne de la facture avant que quiconque ne s'en aperçoive.

CloudWatch Logs Cloud Logging Azure Monitor Logs
Langage de requête Logs Insights (syntaxe propre) Logging query language KQL
Ingestion (prix catalogue) 0,50 $ / Go 0,50 $ / Gio, les 50 premiers Gio gratuits 2,76 $ / Go niveau Analytics, 0,65 $ / Go niveau Basic
Rétention 0,03 $ / Go-mois 30 jours inclus 31 jours inclus
Export Subscription filter vers Kinesis, Firehose ou Lambda ; export batch vers S3 Log sinks vers Pub/Sub, Cloud Storage, BigQuery Diagnostic settings vers Event Hubs ou Storage ; Data Export

Trois services, trois langages de requête, trois modèles de prix et trois réponses différentes à « comment je sors mes logs d'ici ». Aucun n'est mauvais. Ils ne sont juste pas à vous.

Le principe : séparer l'émission du stockage

La solution est une frontière. Découpez le logging en trois couches et donnez à chacune un contrat que les autres n'ont pas le droit de casser.

L'application émet du JSON structuré avec les conventions sémantiques OpenTelemetry. Elle connaît son propre nom, service.name, et le trace_id de la requête qu'elle traite. Elle ne sait pas, et ne doit pas se soucier, dans quel cloud elle tourne. Pas de SDK pour CloudWatch, pas de bibliothèque cliente pour Cloud Logging. Stdout, ou OTLP vers localhost, et rien d'autre.

L'agent tourne à raison d'un par hôte ou par cluster : un OpenTelemetry Collector ou Grafana Alloy. C'est le seul composant autorisé à connaître le cloud. Il découvre cloud.provider, cloud.region et le nom du cluster via l'endpoint de métadonnées, les estampille sur chaque enregistrement, regroupe, compresse et expédie. Si vous changez de cloud, c'est la seule chose qui change, et comme on le verra plus bas, elle ne change même pas beaucoup.

Le backend est neutre : Loki pour le stockage, Grafana par-dessus. Il parle OTLP en entrée et LogQL en sortie. Il n'a aucune idée si une ligne de log vient de Virginie, de Francfort ou d'un ordinateur portable.

AWS · EKS pod api JSON + service.name + trace_id OTel Collector (DaemonSet) resourcedetection → cloud.provider=aws GCP · GKE pod api JSON + service.name + trace_id OTel Collector (DaemonSet) resourcedetection → cloud.provider=gcp Azure · AKS pod api JSON + service.name + trace_id OTel Collector (DaemonSet) resourcedetection → cloud.provider=azure OTLP/HTTP · zstd OTLP/HTTP · zstd Loki ingestion OTLP · object storage · un seul index Grafana {service_name="api"} |= "error"

Le contrat entre les couches, c'est OTLP et une poignée d'attributs de ressource. C'est tout. N'importe laquelle des trois couches peut être remplacée sans que les deux autres s'en aperçoivent.

L'exemple concret : un service, trois clusters

Le même service api tourne sur EKS, GKE et AKS. Ci-dessous, la configuration du collecteur déployée sur les trois en DaemonSet. C'est un seul fichier. Il n'y a aucun if cloud == aws nulle part dedans.

# otel-collector.yaml — identique sur EKS, GKE et AKS
receivers:
  otlp:
    protocols:
      grpc: { endpoint: 0.0.0.0:4317 }
      http: { endpoint: 0.0.0.0:4318 }
  filelog:
    include: [/var/log/pods/*/*/*.log]
    operators:
      - type: container          # parse les formats de ligne CRI-O / containerd / docker

processors:
  resourcedetection:
    detectors: [env, eks, gcp, aks, azure, ec2, system]
    timeout: 5s
    override: false              # ne jamais écraser ce que l'app a déjà défini
  k8sattributes:
    extract:
      metadata: [k8s.namespace.name, k8s.deployment.name, k8s.pod.name]
  batch:
    timeout: 5s
    send_batch_size: 4096

exporters:
  otlphttp/loki:
    endpoint: https://loki.observability.internal/otlp
    compression: zstd
    headers:
      X-Scope-OrgID: platform

service:
  pipelines:
    logs:
      receivers: [otlp, filelog]
      processors: [resourcedetection, k8sattributes, batch]
      exporters: [otlphttp/loki]

La ligne intéressante, c'est detectors. Le collecteur essaie chaque détecteur dans l'ordre ; ceux qui ne s'appliquent pas échouent en silence. Sur EKS, le détecteur eks fixe cloud.provider=aws, cloud.platform=aws_eks et le nom du cluster. Sur GKE, le détecteur gcp fixe cloud.provider=gcp, cloud.platform=gcp_kubernetes_engine et cloud.region. Sur AKS, le détecteur aks fixe cloud.provider=azure et cloud.platform=azure_aks. L'application n'a jamais touché à aucune de ces valeurs. Même image, même manifeste, trois réponses différentes, toutes correctes.

Côté réception, Loki 3 ingère l'OTLP nativement et transforme les attributs de ressource soit en labels indexés, soit en métadonnées structurées. Par défaut, service.name, k8s.namespace.name et cloud.region deviennent des labels. Nous ajoutons cloud.provider à cette liste, parce que « quel cloud » est la première chose par laquelle on groupe pendant un incident :

# loki.yaml (extrait)
limits_config:
  otlp_config:
    resource_attributes:
      attributes_config:
        - action: index_label
          attributes: [cloud.provider, cloud.platform]

Tout le reste attaché par le collecteur, comme le nom du pod ou le déploiement, atterrit dans les métadonnées structurées : interrogeable, mais pas indexé, pour que la cardinalité des labels reste plate.

Maintenant, les requêtes. Toutes les erreurs du service api, sur les trois clouds, en une ligne :

{service_name="api"} |= "error"

La même chose, sur GCP seulement :

{service_name="api", cloud_provider="gcp"} |= "error"

Le taux d'erreurs par cloud sur les cinq dernières minutes, c'est-à-dire le panneau que vous voulez vraiment sur le tableau de bord d'incident :

sum by (cloud_provider) (
  rate({service_name="api"} | json | level="error" [5m])
)

Et la requête que nous faisions à l'œil à 02 h 40, en suivant une seule requête à travers les trois clusters :

{service_name=~"api|order-worker|billing"} | json | trace_id="4bf92f3577b34da6a3ce929d0e0e4736"

Ce que Grafana renvoie pour la dernière, condensé :

2026-09-03 02:41:07.113  cloud_provider=aws    service_name=api           POST /orders 202                       trace_id=4bf9…4736
2026-09-03 02:41:07.201  cloud_provider=gcp    service_name=order-worker  enrich start order_id=88231            trace_id=4bf9…4736
2026-09-03 02:41:09.870  cloud_provider=gcp    service_name=order-worker  level=error upstream timeout billing   trace_id=4bf9…4736
2026-09-03 02:41:09.871  cloud_provider=azure  service_name=billing       level=error connection reset by peer   trace_id=4bf9…4736

Quatre lignes, trois clouds, un seul format d'horodatage, un seul fuseau, triées. Le service de facturation sur AKS réinitialise les connexions et le worker sur GKE tombe en timeout à cause de lui. Ça a pris onze secondes à trouver au lieu de quarante minutes, et rien dans l'application n'a changé pour rendre cela possible.

Pour le développement local et pour un premier essai, voici tout le backend :

# docker-compose.yml — Loki + Grafana, OTLP en entrée sur :3100/otlp
services:
  loki:
    image: grafana/loki:3.5
    command: -config.file=/etc/loki/loki.yaml
    ports: ["3100:3100"]
    volumes:
      - ./loki.yaml:/etc/loki/loki.yaml:ro
      - loki-data:/loki

  grafana:
    image: grafana/grafana:12.1
    ports: ["3000:3000"]
    environment:
      GF_AUTH_ANONYMOUS_ENABLED: "true"
      GF_AUTH_ANONYMOUS_ORG_ROLE: Admin
    volumes:
      - ./grafana-datasource.yaml:/etc/grafana/provisioning/datasources/loki.yaml:ro
    depends_on: [loki]

volumes:
  loki-data: {}

Pointez l'exporteur du collecteur vers http://localhost:3100/otlp, ouvrez Grafana sur le port 3000, et les requêtes ci-dessus fonctionnent sans modification. C'est tout l'intérêt : l'ordinateur du développeur n'est qu'une valeur de plus de cloud_provider.

La vraie objection : l'egress

Quelqu'un la soulèvera en revue de conception, et il aura raison : des logs qui quittent un cloud coûtent de l'argent. Chaque fournisseur facture les octets qui partent vers Internet ou vers un autre fournisseur, environ 0,09 à 0,12 $ par Go. À 100 Go par jour de logs bruts, cela fait jusqu'à 360 $ par mois et par cloud, et on a l'impression de payer pour partir.

Il y a trois réponses honnêtes, chacune avec un compromis.

Compresser et échantillonner à l'agent. Les logs JSON se compressent cinq à dix fois avec zstd, et le collecteur le fait avant que quoi que ce soit ne traverse le réseau. Ajoutez une politique d'échantillonnage qui garde toutes les erreurs, toutes les lignes portant une trace échantillonnée et une ligne de debug sur dix, et vos 100 Go deviennent 10 à 20 Go. L'egress tombe à 15–40 $ par mois. Le compromis : vous n'avez plus chaque ligne et vous devez décider à l'avance de celles dont vous n'avez pas besoin. Pour la plupart des équipes, c'est la bonne réponse et la seule dont elles auront jamais besoin.

Un Loki par cloud, Grafana fédéré. Faites tourner un petit Loki dans chaque cloud, qui écrit dans le stockage objet de ce cloud, et ajoutez chacun comme source de données dans un seul Grafana. Un panneau à sources mixtes interroge les trois et fusionne le résultat. L'egress est nul : seuls les résultats de requête sortent, et ce sont des kilooctets. Le compromis : trois déploiements Loki à opérer, et les requêtes inter-clouds sont une fusion dans Grafana plutôt qu'un index unique, donc « tout trier par date » fonctionne, mais « joindre sur trace_id entre clouds » est plus lent. Choisissez cette option quand les règles de résidence des données gardent de toute façon les logs dans la région.

Centraliser sur le cloud dominant. Si 80 % de vos workloads sont sur AWS, mettez Loki là-bas et payez l'egress sur les 20 %. Un index, un stockage, un seul chemin de requête, le plus simple à opérer. Le compromis : c'est l'option la plus chère, et elle rend discrètement un cloud plus égal que les autres, ce que vous essayiez justement d'éviter.

1 · Compresser + échantillonner à l'agent 100 Go/jour zstd + échantillonnage 10–20 Go/jour egress Loki (central) egress ≈ 20–45 $ / mois compromis : vous choisissez ce qui est jeté 2 · Un Loki par cloud + Grafana fédéré Grafana Loki aws Loki gcp Loki azure résultats de requête seulement (kilooctets) egress ≈ 0 $ compromis : trois Loki à opérer 3 · Centraliser sur le cloud dominant AWS · 80 % des workloads Loki GKE · 10 % AKS · 10 % 0,09–0,12 $ / Go sur chaque octet qui traverse egress ≈ 180–240 $ / mois (brut) compromis : un cloud plus égal que les autres

Pour 100 Go par jour répartis également entre les trois clouds, la facture d'egress pour les trois options, au prix catalogue et sans engagement :

Option Octets sortants par mois Coût d'egress
Brut, centralisé ~2 To (deux clouds sur trois) ~180–240 $
Compressé et échantillonné, centralisé ~200–400 Go ~20–45 $
Un Loki par cloud, requêtes fédérées résultats de requête seulement ~0 $

Ce qui reste natif, et c'est très bien

Cet article parle des logs applicatifs. Votre cloud produit aussi des logs de plan de contrôle : CloudTrail, VPC Flow Logs, Azure Activity Log, GCP Audit Logs. Laissez-les où ils sont. Ils sont générés par la plateforme, ils sont le plus utiles avec l'outillage de la plateforme (GuardDuty, Defender, Security Command Center), et les déplacer ne vous apporte rien, sauf si un auditeur exige une archive unique. Si ce jour arrive, exportez-les via un sink ou un subscription filter dans le même stockage objet et interrogez-les aussi depuis Grafana. D'ici là, c'est une distraction.

La ligne est simple : si votre code l'a émis, ça passe par le collecteur et ça ne connaît pas le cloud. Si le cloud l'a émis, le cloud peut le garder.

Le coût

Les chiffres qui font l'argument. Hypothèses : 100 Go par jour, 30 jours de rétention, prix catalogue de septembre 2026, aucune remise entreprise, environ 3 To ingérés par mois.

Backend Ingestion Rétention (30 jours) Total mensuel
CloudWatch Logs, classe Standard 1 500 $ ~90 $ ~1 600 $ plus 0,005 $ / Go scanné par les requêtes
Google Cloud Logging ~1 475 $ (50 Gio gratuits) incluse ~1 475 $
Azure Monitor, niveau Analytics 8 280 $ incluse (31 jours) ~8 300 $
Azure Monitor, niveau Basic 1 950 $ incluse ~1 950 $, avec un KQL restreint
Loki sur stockage objet, 3 nœuds 0 $ ~10–15 $ (300–400 Go compressés) ~350–500 $ calcul et stockage

La ligne de Loki est dominée par les trois instances qui le font tourner, et celles-ci ne grandissent pas avec votre volume de logs tant que vous n'êtes pas bien au-delà d'un téraoctet par jour. Les lignes des services managés grandissent linéairement avec chaque octet. À 10 Go par jour, la différence est une erreur d'arrondi et vous devriez prendre l'option native et vous occuper de votre produit. À 100 Go par jour, elle paie un ingénieur. À un téraoctet par jour, elle paie l'équipe.

Un astérisque : vous opérez maintenant Loki. C'est un coût réel, en heures, absent de ce tableau. Il est petit, Loki est un seul binaire qui lit et écrit du stockage objet, mais il n'est pas nul, et si personne dans l'équipe ne veut en être responsable, le Loki hébergé de Grafana Cloud se situe entre les deux colonnes en prix et vous enlève l'exploitation.

Alternatives, brièvement

Loki n'est pas le seul backend neutre, et l'architecture se moque de celui que vous choisissez tant qu'il ingère de l'OTLP.

VictoriaLogs est plus léger que Loki, n'a aucune angoisse de cardinalité, et son langage de requête est plus simple. Choisissez-le si vous avez une petite équipe et beaucoup de champs à forte cardinalité comme des identifiants utilisateur. OpenSearch vous donne une vraie recherche plein texte et un écosystème énorme, au prix d'un cluster qui se soucie du heap et des shards. Choisissez-le si vos recherches sont vraiment des recherches de texte, pas des consultations de labels. SigNoz regroupe logs, traces et métriques dans un seul produit sur ClickHouse avec sa propre interface. Choisissez-le si vous voulez une seule chose à installer et que vous n'avez pas déjà Grafana.

Quel que soit votre choix, la configuration du collecteur ci-dessus ne change pas. Seul l'endpoint de l'exporteur change.

Conclusion

Des logs qui ne savent pas où ils tournent sont des logs qui survivent à la prochaine décision d'infrastructure. Déplacez un service d'EKS vers GKE et son historique ne reste pas derrière. Ajoutez un quatrième cloud, ou un cluster bare-metal chez un client, et il apparaît comme une nouvelle valeur de cloud_provider dans une requête que vous avez déjà. Renégociez avec un fournisseur sans que votre observabilité soit otage de la conversation.

Nous ne sommes pas arrivés là à partir d'une présentation. Notre Agent Factory fait tourner des agents IA dans les environnements des clients, et les clients ont les clouds qu'ils ont. L'un est entièrement sur AWS, un autre est sur Azure à cause d'un accord avec Microsoft, un autre fait tourner Kubernetes sur site et ne laisse aucune télémétrie sortir du bâtiment. Si les logs de la plateforme dépendaient du cloud de la plateforme, nous maintiendrions trois stacks d'observabilité et nous corrélerions à l'œil à 02 h 40. À la place, chaque agent émet de l'OTLP, un collecteur par cluster y estampille le cloud, et nous avons une seule requête pour « montre-moi chaque exécution d'agent en échec de la dernière heure » qui fonctionne partout.

Le cloud est un détail d'infrastructure. Traitez-le comme tel. Si vous voulez de l'aide pour tracer cette frontière dans votre propre stack, parlons-en.