Lambda & EventBridge : architecture événementielle
Ce tutoriel conçoit un pipeline événementiel sur Lambda et EventBridge qui résiste aux messages empoisonnés, aux tempêtes de retry, aux pics de trafic et à la latence de cold start, plutôt que de ne gérer que le chemin heureux.
EventBridge : router par pattern, pas par fonction
Ne mettez pas de logique métier dans la règle et n'envoyez pas chaque événement vers une seule Lambda généraliste. Définissez des patterns d'événements étroits pour que chaque consommateur ne voie que ce qu'il est censé traiter.
{
"source": ["com.acme.orders"],
"detail-type": ["OrderCompleted"],
"detail": {
"status": ["completed"],
"customerId": [{ "exists": true }]
}
}Cette simple vérification exists empêche un payload à customerId manquant d'atteindre le consommateur : il est soit intercepté plus tôt dans le pipeline, soit routé vers une règle séparée « événement malformé » qui alerte un humain. Le pattern matching est gratuit ; utilisez-le comme couche de validation autant que comme routeur.
Le bus et la règle vivent dans Terraform avec tout le reste (je garde le pattern ci-dessus dans patterns/order-completed.json) :
resource "aws_cloudwatch_event_bus" "orders" {
name = "orders"
}
resource "aws_cloudwatch_event_rule" "order_completed" {
name = "order-completed"
event_bus_name = aws_cloudwatch_event_bus.orders.name
event_pattern = file("${path.module}/patterns/order-completed.json")
}Concurrence Lambda : réservée et provisionnée, délibérément
Deux réglages comptent ici : la concurrence réservée plafonne le nombre d'exécutions concurrentes qu'une fonction peut consommer, ce qui empêche une source d'événements bruyante d'affamer toutes les autres fonctions de votre compte sur le pool de concurrence régional partagé ; la concurrence provisionnée maintient un nombre défini d'environnements d'exécution « chauds », éliminant les cold starts pour cette part du trafic.
La concurrence provisionnée ne peut pas être attachée à $LATEST, seulement à une version publiée ou à un alias. La fonction publie donc une version à chaque changement de code (publish = true), un alias live pointe dessus, et le pool chaud est rattaché à l'alias :
data "aws_iam_policy_document" "lambda_assume" {
statement {
actions = ["sts:AssumeRole"]
principals {
type = "Service"
identifiers = ["lambda.amazonaws.com"]
}
}
}
resource "aws_iam_role" "order_processor" {
name = "order-processor"
assume_role_policy = data.aws_iam_policy_document.lambda_assume.json
}
# CloudWatch Logs + sqs:ReceiveMessage/DeleteMessage/GetQueueAttributes
resource "aws_iam_role_policy_attachment" "order_processor_sqs" {
role = aws_iam_role.order_processor.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaSQSQueueExecutionRole"
}
# Le job de CI construit le bundle dans dist/order-processor avant terraform plan
data "archive_file" "order_processor" {
type = "zip"
source_dir = "${path.module}/dist/order-processor"
output_path = "${path.module}/build/order-processor.zip"
}
resource "aws_lambda_function" "order_processor" {
function_name = "order-processor"
role = aws_iam_role.order_processor.arn
runtime = "nodejs24.x"
handler = "index.handler"
filename = data.archive_file.order_processor.output_path
source_code_hash = data.archive_file.order_processor.output_base64sha256
memory_size = 512
timeout = 30
publish = true
reserved_concurrent_executions = 50
environment {
variables = {
IDEMPOTENCY_TABLE = aws_dynamodb_table.processed_events.name
}
}
}
resource "aws_lambda_alias" "live" {
name = "live"
function_name = aws_lambda_function.order_processor.function_name
function_version = aws_lambda_function.order_processor.version
}
resource "aws_lambda_provisioned_concurrency_config" "order_processor_warm" {
function_name = aws_lambda_alias.live.function_name
qualifier = aws_lambda_alias.live.name
provisioned_concurrent_executions = 10
}Si votre pipeline dépose déjà les artefacts de build dans S3, remplacez filename/source_code_hash par s3_bucket/s3_key ; le reste ne change pas.
La concurrence provisionnée est facturée qu'elle soit invoquée ou non. Pour une fonction Node.js, un cold start dure généralement quelques centaines de millisecondes, davantage avec un gros bundle ou un code d'initialisation lourd. Je la réserve au trafic exposé au client, là où cette latence se ressent, et je laisse les consommateurs batch ou d'arrière-plan démarrer à froid.
SQS comme tampon : de la contre-pression
Câbler EventBridge directement sur Lambda signifie que le scaling de la concurrence Lambda est dicté par le débit de rafale de la source d'événements. Ajoutez une file SQS entre le bus et la fonction, et vous obtenez trois choses que l'invocation directe ne vous donne pas : un tampon qui absorbe les pics, du batching, et un emplacement pour attacher une DLQ.
EventBridge a besoin d'une autorisation explicite pour écrire dans la file. Sans queue policy, la règle matche, la livraison échoue et, une fois ses retries épuisés, EventBridge abandonne les événements. Limitez la policy à cette seule règle :
data "aws_iam_policy_document" "order_buffer" {
statement {
sid = "AllowOrderCompletedRule"
actions = ["sqs:SendMessage"]
resources = [aws_sqs_queue.order_buffer.arn]
principals {
type = "Service"
identifiers = ["events.amazonaws.com"]
}
condition {
test = "ArnEquals"
variable = "aws:SourceArn"
values = [aws_cloudwatch_event_rule.order_completed.arn]
}
}
}
resource "aws_sqs_queue_policy" "order_buffer" {
queue_url = aws_sqs_queue.order_buffer.id
policy = data.aws_iam_policy_document.order_buffer.json
}
resource "aws_cloudwatch_event_target" "to_queue" {
rule = aws_cloudwatch_event_rule.order_completed.name
event_bus_name = aws_cloudwatch_event_bus.orders.name
arn = aws_sqs_queue.order_buffer.arn
}Lambda consomme ensuite la file via un event source mapping, avec ses propres contrôles de concurrence et de batching. Il cible l'alias live pour utiliser les environnements chauds, et ReportBatchItemFailures permet au handler de faire échouer des messages individuels plutôt que le batch entier :
resource "aws_lambda_event_source_mapping" "queue_to_lambda" {
event_source_arn = aws_sqs_queue.order_buffer.arn
function_name = aws_lambda_alias.live.arn
batch_size = 10
maximum_batching_window_in_seconds = 5
function_response_types = ["ReportBatchItemFailures"]
scaling_config {
maximum_concurrency = 20
}
}maximum_concurrency sur l'event source mapping est un second seuil, plus granulaire, que la concurrence réservée sur la fonction elle-même. Utilisez-le quand une fonction est alimentée par plusieurs files et que vous voulez des limites par file.
La DLQ : là où les événements empoisonnés vont mourir tranquillement
Plutôt que de retenter indéfiniment un événement irrécupérable, routez-le vers une dead-letter queue après un nombre borné de tentatives. Une personne, ou une alarme, peut ensuite regarder ce qui y arrive.
resource "aws_sqs_queue" "order_dlq" {
name = "order-processor-dlq"
message_retention_seconds = 1209600 # 14 jours, le maximum
}
resource "aws_sqs_queue" "order_buffer" {
name = "order-buffer"
# Au moins 6x le timeout de la fonction (30s) plus la fenêtre de batching (5s)
visibility_timeout_seconds = 190
redrive_policy = jsonencode({
deadLetterTargetArn = aws_sqs_queue.order_dlq.arn
maxReceiveCount = 5
})
}Avec un event source mapping SQS, Lambda invoque la fonction de manière synchrone, donc le chemin d'échec passe par cette redrive policy. Un message que le handler n'arrive pas à traiter réapparaît après le visibility timeout, et au bout de maxReceiveCount réceptions, SQS le déplace dans la DLQ. Les destinations on_failure de Lambda ne s'appliquent qu'aux invocations asynchrones et ne servent donc à rien ici. Le visibility timeout compte aussi. S'il est plus court que le temps que Lambda peut passer sur un batch, les messages réapparaissent alors qu'ils sont encore en cours de traitement, et le travail est fait en double avant même que la logique du handler n'intervienne.
Mettez une alarme CloudWatch sur ApproximateNumberOfMessagesVisible de la DLQ pour qu'un événement empoisonné réveille quelqu'un au lieu de dormir là pendant deux semaines.
Handlers idempotents : le prix de la livraison « au moins une fois »
EventBridge et SQS garantissent tous deux une livraison « au moins une fois », jamais exactement une fois, combiné aux retries de Lambda, n'importe quel événement peut atteindre votre handler deux, trois fois ou plus. Si « traiter cette commande » signifie « débiter cette carte », une livraison dupliquée non dédupliquée est un débit dupliqué.
La solution est une table de déduplication indexée sur la clé d'idempotence propre à l'événement. J'utilise l'ID d'événement EventBridge ou une clé métier comme ID de commande + statut, celle qui reste stable à travers les retries (l'ID d'événement survit aux relivraisons d'EventBridge et de SQS, mais pas à un producteur qui republie la même commande) :
resource "aws_dynamodb_table" "processed_events" {
name = "processed-events"
billing_mode = "PAY_PER_REQUEST"
hash_key = "eventId"
attribute {
name = "eventId"
type = "S"
}
ttl {
attribute_name = "expiresAt"
enabled = true
}
}
resource "aws_iam_role_policy" "order_processor_dedupe" {
name = "processed-events-table"
role = aws_iam_role.order_processor.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem", "dynamodb:DeleteItem"]
Resource = aws_dynamodb_table.processed_events.arn
}]
})
}La version naïve, « écrire l'enregistrement, puis faire le travail », a un défaut. Si le débit échoue en cours de route, l'enregistrement dit déjà « fait » et le retry l'ignore pour toujours. L'enregistrement a donc besoin d'un état. Réservez la clé en IN_PROGRESS avec un verrou à expiration courte, exécutez l'effet de bord, puis marquez-la COMPLETED. Si l'effet de bord lève une erreur, libérez la réservation pour que le retry puisse s'exécuter. Si la fonction plante ou dépasse son timeout, le verrou expire et la livraison suivante prend le relais. Le handler renvoie les messages en échec à SQS via batchItemFailures :
// index.mjs
import { DynamoDBClient, ConditionalCheckFailedException } from "@aws-sdk/client-dynamodb";
import {
DynamoDBDocumentClient,
PutCommand,
GetCommand,
UpdateCommand,
DeleteCommand,
} from "@aws-sdk/lib-dynamodb";
const ddb = DynamoDBDocumentClient.from(new DynamoDBClient({}));
const TABLE = process.env.IDEMPOTENCY_TABLE;
const LOCK_SECONDS = 60; // plus long que le timeout de la fonction
const RECORD_TTL_SECONDS = 7 * 24 * 3600; // plus long que toute fenêtre de relivraison
async function claim(key) {
const now = Math.floor(Date.now() / 1000);
try {
await ddb.send(
new PutCommand({
TableName: TABLE,
Item: {
eventId: key,
status: "IN_PROGRESS",
lockExpiresAt: now + LOCK_SECONDS,
expiresAt: now + RECORD_TTL_SECONDS,
},
// Nouvelle clé, ou une tentative précédente morte sans terminer
ConditionExpression:
"attribute_not_exists(eventId) OR (#s = :inProgress AND lockExpiresAt < :now)",
ExpressionAttributeNames: { "#s": "status" },
ExpressionAttributeValues: { ":inProgress": "IN_PROGRESS", ":now": now },
})
);
return true;
} catch (err) {
if (err instanceof ConditionalCheckFailedException) return false;
throw err;
}
}
async function processOnce(key, sideEffect) {
if (!(await claim(key))) {
const { Item } = await ddb.send(
new GetCommand({ TableName: TABLE, Key: { eventId: key }, ConsistentRead: true })
);
if (Item?.status === "COMPLETED") return; // livraison dupliquée : on acquitte
throw new Error(`event ${key} is in progress elsewhere`); // retenter plus tard
}
try {
await sideEffect();
} catch (err) {
// Libère la réservation pour que la livraison suivante puisse retenter immédiatement
await ddb.send(new DeleteCommand({ TableName: TABLE, Key: { eventId: key } }));
throw err;
}
await ddb.send(
new UpdateCommand({
TableName: TABLE,
Key: { eventId: key },
UpdateExpression: "SET #s = :completed",
ExpressionAttributeNames: { "#s": "status" },
ExpressionAttributeValues: { ":completed": "COMPLETED" },
})
);
}
export const handler = async (event) => {
const batchItemFailures = [];
for (const record of event.Records) {
try {
const orderEvent = JSON.parse(record.body); // événement EventBridge complet
await processOnce(orderEvent.id, () => chargeCustomer(orderEvent.detail));
} catch (err) {
console.error({ messageId: record.messageId, error: err.message });
batchItemFailures.push({ itemIdentifier: record.messageId });
}
}
// Seuls les messages en échec retournent dans la file (puis, à terme, dans la DLQ)
return { batchItemFailures };
};chargeCustomer est votre effet de bord. Il reste une petite fenêtre où le débit réussit mais l'écriture COMPLETED échoue. Je transmets donc aussi la même clé comme clé d'idempotence au fournisseur de paiement quand il en propose une, ce qui rend une réexécution rare inoffensive de bout en bout. Le TTL empêche la table de croître indéfiniment. DynamoDB supprime toutefois les éléments expirés de façon différée, d'où l'attribut lockExpiresAt dédié au verrou au lieu de compter sur le TTL.
En production, j'utilise généralement l'utilitaire d'idempotence de Powertools for AWS Lambda (TypeScript) plutôt que de maintenir ce code à la main. Il implémente le même schéma en cours/expiration/terminé sur une table DynamoDB, et met aussi le résultat en cache. Le code ci-dessus correspond à ce qu'il fait sous le capot, et je préfère le comprendre avant de confier des paiements à une bibliothèque. Les clés d'idempotence comptent surtout pour ce qui a un effet de bord externe, comme les paiements, les emails ou les décréments de stock. Les opérations qui se contentent de lire, transformer et écrire dans votre propre base sont souvent déjà idempotentes et n'ont pas besoin de table de déduplication.
Le chemin complet, de bout en bout
EventBridge, SQS, les limites de concurrence Lambda, les écritures conditionnelles, vous avez probablement déjà utilisé chacun individuellement. Ce qui distingue un pipeline qui encaisse une mauvaise semaine de production d'un pipeline qui réveille quelqu'un à 2h du matin, c'est d'avoir les quatre en place avant l'arrivée du premier événement empoisonné.
Envie de faire tourner ça en production ?
Ce tutoriel couvre les concepts et l'architecture. Si vous voulez l'implémenter dans votre propre infrastructure, ou monter en compétence pour posséder ce sujet durablement, je propose du mentorat individuel construit autour de votre environnement réel, pas une formation générique.
Ce tutoriel
- Concepts clés et architecture principale
- Extraits de code illustratifs
- Le raisonnement derrière chaque décision
Mentorat individuel
- Des sessions de travail sur votre propre environnement
- Des réponses directes aux cas particuliers que vous rencontrez
- Un retour sur votre implémentation réelle
- Un accompagnement continu pendant que vous la construisez
Tutoriels similaires
Provisionner un VPC AWS sécurisé avec Terraform
Un VPC AWS prêt pour la production en Terraform : sous-réseaux, NAT, tables de routage et valeurs par défaut plus sûres.
Site statique sécurisé sur AWS S3 & CloudFront
Hébergez un site statique sur S3 derrière CloudFront avec Origin Access Control, TLS ACM et invalidation de cache.
Failover PostgreSQL multi-région, testé
Réplication en streaming, runbook de promotion répété et tests de restauration nocturnes pour PostgreSQL multi-région.