Surveillance18 octobre 2026 9 min de lecture

Pourquoi un /health qui renvoie 200 ne suffit pas en serverless

Votre endpoint de santé répond 200 pendant que la file grossit, que les nouvelles tentatives s'accumulent et que la file de lettres mortes se remplit. Nous passons en revue les pannes qu'un ping ne voit pas en serverless et ce qu'il faut faire à la place : un contrat de santé par composant, la surveillance des ressources cloud, des heartbeats pour les crons et des flux de bout en bout qui nomment l'étape cassée.

Cet article est aussi disponible en:esenpt

Presque tous les services ont un /health. Un moniteur l'appelle chaque minute, reçoit un 200 et affiche un point vert. Sur un serveur classique, cela voulait dire quelque chose : si le processus répond, le processus est vivant. En serverless, l'endpoint de santé n'est souvent qu'une fonction de plus, qui ne partage ni processus ni mémoire avec celles qui font le vrai travail. Il peut répondre 200 pendant des heures alors que les commandes restent bloquées dans une file. Dans cet article, nous voyons les pannes qui lui échappent, ce qu'il faut faire à la place et comment AAA Sentinel s'y prend.

Ce que vérifie vraiment un 200

Voici le /health le plus courant dans une API sur AWS Lambda. S'il répond, vous savez trois choses : API Gateway route, cette fonction démarre et son code ne lève pas d'exception. Rien de plus. Il n'a pas touché la base de données, ne sait pas si la file avance et n'a aucune idée de l'état de la fonction qui consomme cette file, peut-être en échec depuis une heure.

health.tsts
export const handler = async () => ({
  statusCode: 200,
  body: JSON.stringify({ status: 'ok' }),
})

En serverless, l'endpoint de santé et le code qui traite vos données sont des fonctions distinctes, avec leurs propres permissions, limites de concurrence et erreurs. Que l'une soit saine ne dit rien de l'autre.

Cinq pannes qu'un 200 ne voit pas

Ce sont celles qui reviennent le plus dans les architectures orientées événements, et aucune ne change la réponse du /health :

  • La file qui grossit : le consommateur va moins vite que ce qui arrive, ou il est bridé par la concurrence. L'API accepte les commandes et répond 202 ; le traitement se fait avec une demi-heure de retard.
  • Les nouvelles tentatives silencieuses : une invocation asynchrone de Lambda en échec est relancée, par défaut, jusqu'à deux fois de plus. Un message SQS non supprimé redevient visible à l'expiration de son délai de visibilité et il est traité à nouveau. Le système semble fonctionner, avec deux ou trois fois plus de travail et de coût.
  • La file de lettres mortes qui se remplit : quand un message épuise ses réceptions (maxReceiveCount), SQS le déplace vers la DLQ. Personne ne s'en aperçoit avant de l'ouvrir, et chaque message qui s'y trouve est quelque chose qui n'a pas eu lieu.
  • L'événement que personne ne consomme : PutEvents sur EventBridge réussit même si aucune règle ne correspond à l'événement. Un champ source renommé ou une règle désactivée, et les événements disparaissent sans la moindre erreur.
  • La dépendance en panne : le prestataire de paiement ou la base de données tombe, mais le /health ne les appelle pas, donc il reste vert.

Première brique : un contrat de santé par composant

Tant qu'à avoir un endpoint de santé, autant qu'il dise quelque chose d'utile. Il existe un format pour cela : health+json, un brouillon de l'IETF qui utilise le type application/health+json. La réponse porte un status global (pass, warn ou fail), la version du service et un objet checks avec une liste par composant, chacun avec son componentType, son observedValue, son status et l'heure de la mesure (time). La clé de chaque check suit en général la forme composant:mesure.

GET /healthhttp
HTTP/1.1 200 OK
Content-Type: application/health+json
Cache-Control: no-store

{
  "status": "warn",
  "version": "1.42.0",
  "checks": {
    "orders-table:responseTime": [
      {
        "componentType": "datastore",
        "observedValue": 38,
        "observedUnit": "ms",
        "status": "pass",
        "time": "2026-10-18T09:30:00.000Z"
      }
    ],
    "orders-dlq:messages": [
      {
        "componentType": "component",
        "observedValue": 3,
        "status": "warn",
        "time": "2026-10-18T09:30:00.000Z"
      }
    ]
  }
}
  • pass : le composant fonctionne.
  • warn : il fonctionne, mais quelque chose ne va pas. Ici, trois messages attendent dans la file de lettres mortes. Le service est dégradé, pas en panne, et répond 200.
  • fail : le composant ne fonctionne pas. Le service répond 503 pour qu'un équilibreur de charge ou un moniteur HTTP basique le comprenne aussi.

Le produire sans écrire le format à la main

Le paquet @aaa-sentinel/health sur npm, sans dépendances, construit cette réponse à partir de vos vérifications. Chacune reçoit un AbortSignal déclenché à l'expiration de son délai ; le statut global est le pire de tous. Ce handler produit exactement la réponse ci-dessus :

health.tsts
import { runHealthChecks } from '@aaa-sentinel/health'
import { DynamoDBClient, GetItemCommand } from '@aws-sdk/client-dynamodb'
import { SQSClient, GetQueueAttributesCommand } from '@aws-sdk/client-sqs'

const dynamo = new DynamoDBClient({})
const sqs = new SQSClient({})

// Lambda behind API Gateway or a function URL: the result is the HTTP response.
export const handler = () =>
  runHealthChecks(
    {
      'orders-table:responseTime': async (signal) => {
        const started = Date.now()
        await dynamo.send(
          new GetItemCommand({ TableName: process.env.ORDERS_TABLE, Key: { pk: { S: 'health' } } }),
          { abortSignal: signal },
        )
        return { componentType: 'datastore', observedValue: Date.now() - started, observedUnit: 'ms' }
      },
      'orders-dlq:messages': async (signal) => {
        const { Attributes } = await sqs.send(
          new GetQueueAttributesCommand({
            QueueUrl: process.env.ORDERS_DLQ_URL,
            AttributeNames: ['ApproximateNumberOfMessages'],
          }),
          { abortSignal: signal },
        )
        const messages = Number(Attributes?.ApproximateNumberOfMessages ?? 0)
        return { componentType: 'component', observedValue: messages, status: messages === 0 ? 'pass' : 'warn' }
      },
    },
    { version: process.env.APP_VERSION, timeoutMs: 2000 },
  )
  • Une vérification qui lève une erreur ou dépasse timeoutMs (2 s par défaut, 60 s au maximum) est un fail pour ce composant ; l'endpoint répond quand même.
  • Les messages d'erreur restent hors de la réponse sauf si vous passez exposeErrors: true. Ils contiennent souvent des hôtes ou des chaînes de connexion, et les endpoints de santé sont en général publics.
  • minimumStatus: 'warn' maintient le service à warn ou pire, pendant une maintenance par exemple.
  • Si vous utilisez déjà Spring Boot Actuator, ASP.NET Core Health Checks ou les endpoints Kubernetes, rien à changer : le moniteur de contrat de santé d'AAA Sentinel les lit tels quels.

Vérifiez ce que seul votre code sait : que la table répond avec vos permissions, que la clé du prestataire est toujours valide. Ce que le cloud mesure déjà, lisez-le dans le cloud ; c'est la brique suivante.

Deuxième brique : surveiller les ressources derrière

Certaines choses, un endpoint ne peut pas bien les mesurer. L'âge du plus ancien message d'une file, par exemple, n'est pas renvoyé par GetQueueAttributes : il se trouve dans la métrique CloudWatch ApproximateAgeOfOldestMessage. Et c'est un meilleur signal que le nombre de messages, car mille messages peuvent être un pic normal alors qu'un message vieux de quarante minutes est toujours un problème. C'est pourquoi AAA Sentinel propose des moniteurs de ressource cloud : ils sont passifs, n'appellent rien chez vous et lisent les métriques via la connexion cloud que vous avez déjà accordée pour les coûts.

Moniteur de ressource cloud · ce qui est surveillé sur AWS

File SQS
Âge du plus ancien message et messages dans sa file de lettres mortes (tout message qui s'y trouve a déjà épuisé ses tentatives)
Fonction Lambda
Erreurs, throttles et âge de l'itérateur (IteratorAge) quand elle lit un flux
Table DynamoDB
Lectures et écritures limitées
Règle EventBridge
Invocations en échec vers ses cibles
Step Functions
Exécutions en échec ou hors délai
API Gateway
Erreurs 5XX et d'intégration
  • Sur Azure, il surveille Function Apps, Service Bus, Cosmos DB, API Management, les rubriques Event Grid et Logic Apps ; sur Google Cloud, Cloud Run, Cloud Functions, les abonnements Pub/Sub, les déclencheurs Eventarc, Workflows, Firestore et API Gateway.
  • Le coût figure sur votre facture, pas la nôtre : sur AWS, GetMetricData revient à environ 0,20 USD au plus par ressource et par mois avec une lecture toutes les 10 minutes. Sur Azure, cela tient en général dans le million de requêtes gratuites par mois, et sur Google Cloud, dans le million de séries lues gratuites par mois.

Troisième brique : des heartbeats pour les crons

Une tâche planifiée n'a pas d'endpoint à sonder. Si la planification EventBridge Scheduler est désactivée ou que le script meurt en cours de route, il n'y a aucune erreur à voir : il ne se passe rien, tout simplement. La solution consiste à inverser le sens. Le moniteur heartbeat vous donne une URL ; votre tâche l'appelle quand elle se termine bien, et si l'appel n'arrive pas dans la fenêtre que vous fixez, le moniteur passe en panne. Il accepte GET et POST, car beaucoup de planificateurs ne savent qu'appeler une URL.

nightly.shbash
#!/usr/bin/env bash
set -euo pipefail

node scripts/nightly-billing-export.js

# Only reached when the job succeeded: a failed or skipped run stays silent.
curl -fsS --max-time 10 --retry 3 "$HEARTBEAT_URL" > /dev/null
scheduled.tsts
// Scheduled Lambda (EventBridge Scheduler): the ping is the last line, after the real work.
export const handler = async () => {
  await exportBilling()
  await fetch(process.env.HEARTBEAT_URL, { method: 'POST', signal: AbortSignal.timeout(5000) })
}

Appelez l'URL seulement une fois la tâche terminée avec succès, jamais au démarrage. Un heartbeat au début dit que le cron s'est lancé, pas qu'il a exporté la facturation.

Quatrième brique : des flux qui nomment l'étape cassée

Les trois briques précédentes regardent des composants isolés. Un flux regarde toute la chaîne : AAA Sentinel déclenche une transaction de test (un message SQS, une invocation Lambda, un événement EventBridge ou un appel HTTP) portant un traceId de 32 caractères hexadécimaux et un traceparent W3C, puis vérifie qu'elle atteint chaque étape dans son délai. Une étape peut être un élément DynamoDB, un objet S3, une ligne dans CloudWatch Logs ou un appel de votre code vers une URL chez nous. Il y a de 1 à 5 étapes, chacune avec un délai compris entre 10 secondes et 15 minutes, et chaque saut est chronométré depuis le précédent. Les flux fonctionnent aujourd'hui sur AWS et s'exécutent au plus toutes les 15 minutes, avec un plafond de coût mensuel par flux.

worker.tsts
import { propagateTrace } from '@aaa-sentinel/health'
import { DynamoDBClient } from '@aws-sdk/client-dynamodb'
import { DynamoDBDocumentClient, PutCommand } from '@aws-sdk/lib-dynamodb'

const db = DynamoDBDocumentClient.from(new DynamoDBClient({}))

export const handler = async (event) => {
  for (const record of event.Records) {
    const trace = propagateTrace(record) // null for real traffic

    if (trace) {
      // Step 1 looks for ORDER#{traceId}; step 2 for the line "order stored {traceId}".
      await db.send(new PutCommand({ TableName: process.env.ORDERS_TABLE, Item: { pk: `ORDER#${trace.traceId}`, test: true } }))
      trace.log('order stored')
      continue // no charge, no email for the test transaction
    }

    const order = JSON.parse(record.body)
    await db.send(new PutCommand({ TableName: process.env.ORDERS_TABLE, Item: { pk: `ORDER#${order.id}`, ...order } }))
    await chargeCustomer(order)
  }
}

Verdict d'un flux de commande qui casse à l'étape 2

Déclenchement
Message dans la file orders, envoyé à 09:30:00
Étape 1 · élément ORDER#{traceId} dans DynamoDB
OK, 4 s après le déclenchement
Étape 2 · ligne « order stored {traceId} » dans CloudWatch Logs
N'est pas arrivée dans son délai de 60 s
Étape 3 · objet invoices/{traceId}.pdf dans S3
Non vérifiée
  • Votre code transmet le traceId à l'étape suivante, dans l'en-tête x-aaa-trace ou comme attribut de message. propagateTrace le trouve dans les événements SQS, SNS, EventBridge, DynamoDB Streams, Kinesis et API Gateway ou dans les en-têtes d'une requête HTTP, et ne lève jamais d'exception.
  • Seule la marque d'AAA Sentinel fait d'une requête un test : un traceparent issu de votre propre OpenTelemetry, c'est du trafic réel.
  • Le flux utilise un rôle à part, avec son modèle CloudFormation, qui ne peut écrire que dans les ressources que vous choisissez.
  • L'événement que personne ne consomme apparaît ici : le déclenchement réussit et la première étape n'arrive jamais.

Comment AAA Sentinel décide qu'un service est en panne

Une fausse alerte à trois heures du matin apprend à l'équipe à ignorer les suivantes. Un échec ne déclenche donc pas d'alerte du premier coup. Chaque moniteur passe par ces états : opérationnel, suspect, en confirmation, en panne, en rétablissement, puis opérationnel à nouveau. Rien n'est jamais envoyé depuis l'état suspect. Une URL est vérifiée à nouveau depuis une deuxième région AWS (les sondes partent d'eu-west-1 et la confirmation d'eu-central-1) ; une ressource ou un flux doit échouer plusieurs fois de suite. Seul l'état en panne ouvre un incident et envoie l'alerte, par e-mail, Slack, Microsoft Teams ou webhook signé, avec les mêmes canaux et politiques que les alertes de coûts.

  • Un warn health+json (ou un Degraded ASP.NET) laisse le service dégradé, pas en panne. Un UNKNOWN d'Actuator n'est pas un échec.
  • Les nouveaux moniteurs passent 24 heures en apprentissage : ils observent, n'alertent pas et proposent des seuils.
  • Les incidents s'ouvrent et se ferment seuls, avec chronologie, notes, prise en charge et un coût par minute que vous fixez, dans la devise de votre organisation.
  • Chaque sonde porte l'en-tête X-AAA-Signature, signé avec un secret propre à votre organisation, pour que votre API la reconnaisse. Nous ne publions pas d'IP fixes : la signature est le moyen de l'identifier.

Ce qu'il ne fait pas

Pour que vous sachiez où sont les limites :

  • Ce n'est pas un APM : il n'ingère pas de logs, ne fait pas de traçage distribué, ne stocke pas de métriques personnalisées et n'offre pas de tableaux de bord sur mesure.
  • Sur Azure, l'assistant de flux ne découvre pas la chaîne : vous lui indiquez où entre le test. Sur Google Cloud, le déclenchement entre par Pub/Sub ou HTTP.
  • Les paquets sont pour Node.js (@aaa-sentinel/health) et Python (aaa-sentinel-health). Dans les autres langages, votre code lit lui-même la marque x-aaa-trace ou aaaTraceId.
  • Il sonde depuis deux régions AWS, pas depuis un réseau de points répartis dans le monde.

Checklist pour démarrer

Avec ces briques en place, un point vert veut de nouveau dire que les commandes arrivent à destination.

  • Votre /health répond en health+json avec un check par dépendance, warn pour le dégradé et 503 pour la panne.
  • Chaque file a sa DLQ et un moniteur sur l'âge du plus ancien message.
  • Les fonctions qui consomment des événements ont leurs erreurs et throttles surveillés.
  • Chaque cron appelle son URL de heartbeat quand il se termine bien.
  • La chaîne qui compte le plus (la commande, le paiement, la facture) a un flux qui nomme l'étape qui casse.
  • Les 3 moniteurs http ou heartbeat gratuits de chaque plan couvrent le /health et le cron le plus critique ; le pack de surveillance ajoute le reste.