Event-Driven Architecture chez Octo : épisode 4, robustesse et industrialisation

Dans l'épisode précédent, on a construit notre bus de bout en bout : un événement qui naît, qui voyage, qui se fait consommer, le tout sans qu'aucun maillon ne connaisse les autres. Sur le papier, c'est beau. En démo, ça marche. Et puis vient ce moment où ça part en prod pour de vrai, où les vrais utilisateurs débarquent, où les API tierces tombent au pire moment, et où vous comprenez qu'entre "ça marche" et "ça tient", il y a un tout un monde.

Cet épisode, c'est ce monde-là. On va voir comment on a rendu tout ça robuste et exploitable au quotidien, sans se réveiller à 3h du matin.

Et oui, vous le sentez venir, le personnage principal est de retour. Je vous avais promis qu'on reparlerait d'idempotence pour de vrai, avec du concret cette fois. Promesse tenue, c'est la première étape.

L'idempotence, pour de vrai cette fois

Bon. Ça fait trois articles que je vous bassine avec l'idempotence, il serait peut-être temps de vous montrer à quoi ça ressemble dans la vraie vie.

Petit rappel du problème, pour ceux qui auraient séché l'épisode 2 (pas bien). On est en at-least-once, un événement peut être livré plusieurs fois. Ajoutez à ça le replay d'une DLQ qu'on a vu dans l'épisode 3, et c'est sûr et certain, un jour, le même événement passera deux fois dans votre consumer. Si rien ne l'en empêche, on sera notifié 2 fois de l'arrivée de Camille, on lui crée deux tickets d'onboarding, et elle se demande dans quelle entreprise désorganisée elle a atterri.

La parade ? Avant de faire le boulot, on se demande "est-ce que je l'ai déjà fait, celui-là ?". Et pour s'en souvenir, on a besoin d'une mémoire partagée entre toutes les invocations de la Lambda. Chez nous, cette mémoire, c'est Redis (enfin, Valkey, mais l'idée est la même).

L'identité de l'événement

Tout repose sur une clé de cache qui identifie l'événement de manière déterministe. C'est le point crucial, le même événement métier doit toujours produire la même clé, sinon le dédoublonnage ne sert à rien. Chez nous, pour la notif de recrutement, ça donne ça :

const cacheKey = `${data['detail-type']}_mm_${data.detail.firstName}_${data.detail.lastName}_${data.detail.dateOfArrival}`;

Notez qu'on ne se base pas sur un ID technique d'événement, mais sur l'identité métier : le type, le prénom, le nom, la date d'arrivée. Pourquoi ? Parce que même si SmartRecruiters nous renvoyait le même recrutement sous deux formes légèrement différentes, on veut quand même le reconnaître comme "le même fait métier". La clé décrit ce qui s'est passé, pas le message qui le transporte.

Attention, ça marche uniquement si on est sûr d’avoir quelque chose d’unique. Ici Camille ne sera recrutée qu’une seule fois à la date X. Par contre pour les événements de mise à jour par exemple on ne pourra pas forcément générer de clé de cache “métier”, on va donc à ce moment mettre un cache sur l’ID de l’événement, comme ça si il est rejoué on ne fera pas la mise à jour une seconde fois.

On vérifie, puis on agit

Le service Redis est volontairement minimaliste. Un get et un set avec une durée d'expiration.

async get(key) {
  return await this.redisClient.get(key);
}

async set(key, value, duration) {
  return await this.redisClient.set(key, value, { EX: duration });
}

Et dans le consumer, l'idempotence tient en quelques lignes qui encadrent le traitement :

const cacheKey = `${data['detail-type']}_mm_${data.detail.firstName}_${data.detail.lastName}_${formatDateForCache(dateOfArrival)}`;

const redisService = new RedisService();

// Déjà traité ? On s'arrête là.
if (await redisService.get(cacheKey)) {
  logger.info(`Déjà traité : ${cacheKey}`);
  return;
}

// ... le vrai boulot, poster un message dans Mattermost, ...

// On marque comme traité, avec une durée de vie
await redisService.set(cacheKey, 'true', parseInt(process.env.CACHE_DURATION || '3600'));

C'est tout. Le premier passage ne trouve rien dans le cache, fait le travail, et pose la clé. Le deuxième passage trouve la clé, et s'arrête immédiatement. On n’a qu’une seule notification, le monde est sauvé.

Un mot sur le EX / CACHE_DURATION. La clé n'est pas posée pour l'éternité, elle expire (1 heure par défaut chez nous). C'est la fenêtre de dédoublonnage. Ça évite que le cache gonfle indéfiniment, et c'est suffisant. Les doublons et les retries arrivent dans un laps de temps court, pas trois semaines plus tard. A vous de voir ce qui correspond le mieux à votre mode de fonctionnement, il est aussi possible de ne pas mettre de durée de cache mais alors plus vous avancerez dans le temps, plus le prix va grimper inexorablement…

Et si on relie ça à l'épisode 2, c'est exactement la stratégie "garder une trace des événements déjà traités" qu'on évoquait en théorie. Sauf que cette fois, c'est branché et ça tourne.

Visuellement, le même événement qui arrive deux fois :

Un événement arrive au consumer, qui vérifie dans Redis. Clé absente (premier passage) alors on traite et on pose la clé. Clé présente (doublon) alors on ignore.

Et si ça plante au milieu ?

Regardez bien l'ordre des opérations dans le pattern, on pose la clé de cache à la toute fin, une fois le travail terminé. Ce n'est pas un hasard. Si on la posait avant et que le traitement échouait, on aurait marqué comme "fait" quelque chose qui ne s'est jamais produit et le retry sauterait l'événement pour de bon. En posant la clé en dernier, un échec laisse la voie libre à une nouvelle tentative. Logique.

Sauf que… ça ouvre une autre porte. Imaginez un consumer qui fait plusieurs actions à la suite. Il crée une carte trello, met à jour des champs custom et la relie à une autre carte. S'il plante après la deuxième action mais avant de poser sa clé, que se passe-t-il ? Le retry repart du début, cache toujours vide, et refait les actions déjà effectuées. Re-création, re-maj… le doublon qu'on voulait justement éviter. L'idempotence par clé de cache vous protège du "rejoué deux fois en entier", mais pas du "rejoué après un échec à mi-parcours".

La règle à garder en tête, c'est qu'avec de l'at-least-once, votre consumer peut être interrompu à n'importe quel moment et relancé depuis le début. Donc il faut le concevoir pour que ce soit sans danger. Pour répondre à ça 3 stratégies.

Rejouabilité à l’extrême

D'abord, rendre chaque action rejouable. L'idéal, c'est que chaque effet de bord soit lui-même idempotent, un upsert plutôt qu'un insert, une action côté SaaS identifiée par une clé de déduplication que le système cible reconnaît, etc. Si rejouer une action ne fait rien de plus que la première fois, alors rejouer tout le handler ne casse rien. C'est l'approche la plus robuste, mais pas toujours possible quand on dépend d'API tierces qu'on ne maîtrise pas.

Dans les faits c’est assez rare d’être capable de le faire dans 100% des cas…

Clé de cache évolutive

C’est ma solution préférée. Jusque là on posait et lisait la clé de cache sans poser de question sur le contenu du cache. Mais il est tout à fait possible de faire évoluer le contenu de notre cache au fur et à mesure du traitement du consumer. Si on reprends notre exemple de carte Trello, voici ce qu’on a mis place.

const cacheKey = `${data['detail-type']}_trello_${data.detail.projectName}`;

const redisService = new RedisService();
let cache = await redisService.get(cacheKey);

// Déjà traité ? On s'arrête là.
if (cache === ‘DONE’) {
  logger.info(`Déjà traité : ${cacheKey}`);
  return;
}

// Si on n’a pas du tout de cache alors on fait notre première étape
If (!cache) { 
  // ... Création de la carte Trello...
  // On marque cette étape comme traitée
  cache = 'CREATED';
  await redisService.set(cacheKey, cache, 3600);
}

// Ici on vérifie que l’on est bien sur notre seconde étape
If (cache === 'CREATED') {
  // ... Mise à jour de la carte Trello…
  // On marque cette étape comme traitée
  cache = 'DONE';
  await redisService.set(cacheKey, cache, 3600);
}

Je n’ai mis que 2 étapes ici mais dans la pratique il nous arrive d’en avoir bien plus. Perso j’aime cette approche car on ne repart pas de zéro, si on a réussi à faire une partie du process alors autant gagner du temps sur le retry quand on sait qu’il a une chance de réussir plus tard. Dans le cas d’API tierces c’est très utile quand on arrive dans des cas de limite d’appels sur de courtes durées.

Le ménage

Une dernière solution que vous avez peut-être vu venir c’est de faire le ménage. Si une étape échoue, on annule ce qu'on a déjà fait avant de laisser le retry repartir d'une page blanche. Supprimer la carte à moitié créée, effacer le fichier temporaire, et surtout ne pas laisser traîner une clé de cache posée "à moitié". C'est le moment d'utiliser le del de notre service Redis :

const redisService = new RedisService();

try {
  // ... on enchaîne les actions ...
  await redisService.set(cacheKey, 'true', 3600);
} catch (error) {
  // Le ménage, on annule ce qui doit l'être et on libère la clé
  await cleanupPartialWork();
  await redisService.del(cacheKey);   // pour ne pas bloquer la prochaine tentative
  throw error;                        // Ultra important de mettre le consumer en erreur pour que SQS retente / envoie DLQ
}

Chez nous on utilise en général le ménage quand on envoie notre message en DLQ car je trouve bizarre de laisser un traitement à semi-traité pour une durée qui peut être longue. Dans le cas de retry automatique on sait qu'au pire on aura un état bizarre pendant 2-3 minutes, mais avec un message en DLQ bah ça peut durer longtemps… Si pour x ou y raison on ne traite pas le message manuellement rapidement ça peut créer des effets de bords.

L'objectif réaliste, c'est de concevoir des consumers qu'on peut rejouer sans dégât, et de nettoyer derrière soi quand on ne peut pas. Le throw final, lui, n'est pas un oubli, c'est exactement ce qui permet à SQS de reprendre la main pour retenter, puis basculer en DLQ si vraiment ça ne passe pas.

L'observabilité

On l'avait identifié dès l'épisode 1 comme notre plus gros caillou dans la chaussure… Quand un événement traverse trois, quatre, cinq systèmes, savoir où ça a coincé devient un sport. Alors on s'est construit un petit système d'observabilité, et c'est probablement ce dont je suis le plus fier dans tout ce chantier.

L'idée de départ : et si chaque consumer racontait ce qu'il fait ? Quand il commence, quand il finit, combien de temps il a mis, s'il a planté et pourquoi. Plutôt que de mettre ce code dans chaque consumer (et de l'oublier dans la moitié des cas), on l'a sorti dans un wrapper qui enrobe tous nos consumers.

Un wrapper qui raconte la vie du consumer

Souvenez-vous, dans l'épisode 3, tous nos handlers se terminaient par cette ligne un peu mystérieuse :

export const handler = withConsumerLifecycle(rawHandler);

Le wrapper prend votre handler "métier" et l'entoure d'instrumentation. Pour chaque message, il émet un événement de cycle de vie à trois moments. started avant de lancer le traitement, puis succeeded ou failed selon le résultat, avec à chaque fois la durée, le nom du consumer, et le contexte de l'événement.

export function withConsumerLifecycle(rawHandler) {
  return async (event, context) => {
    for (const record of event.Records) {
      const startedAt = Date.now();
      const recordContext = extractRecordContext(record);

      await emitLifecycleEvent('started', functionName, { consumer, ...recordContext });

      try {
        const result = await rawHandler(record);
        await emitLifecycleEvent('succeeded', functionName, {
          consumer, durationMs: Date.now() - startedAt, ...recordContext,
        });
      } catch (error) {
        await emitLifecycleEvent('failed', functionName, {
          consumer, durationMs: Date.now() - startedAt,
          error: { message: error.message, name: error.name }, ...recordContext,
        });
        throw error;  // on relance : SQS reprend la main (retry, puis DLQ)
      }
    }
  };
}

L'observabilité passe donc par le bus

Et là où ça devient élégant, ces événements de cycle de vie sont publiés sur le même bus EventBridge. Le consumer ne se contente pas de bosser, il émet des événements sur ce qu'il est en train de faire :

await eventbridgeClient.send(new PutEventsCommand({
  Entries: [{
    EventBusName: busName,
    Source: 'octo.bus.consumer',
    DetailType: 'consumer_lifecycle',
    Detail: JSON.stringify({ phase, ...payload }),
  }],
}));

Du coup, notre observabilité est elle-même event-driven. On peut brancher ce qu'on veut sur les événements consumer_lifecycle, un dashboard, un système d'alerting, un consumer qui calcule des stats… Eat your own dog food comme on dit, et toute la mécanique de découplage des épisodes précédents joue aussi pour notre monitoring.

Un événement métier est traité par un consumer utillisant le wrapper lifecycle. En plus de faire son travail, le consumer émet des événements consumer_lifecycle sur le bus EventBridge, qui alimentent l'observabilité.

La ligne du bas, c'est le travail du consumer (il fait son boulot métier). La ligne du haut, c'est sa "voix off" , il raconte au bus ce qu'il fait, et ça alimente notre observabilité, sans qu'on ait écrit une ligne d'instrumentation dans le handler métier lui-même. 100% transparent et surtout tous nos consumers vont donc raconter la même chose, ce qui va nous faciliter la vie lors d’analyse de bug par exemple.

Le correlation id pour suivre un événement à la trace

Reste un problème… Quand un événement en déclenche un autre à travers plusieurs systèmes, comment relier tout ça ? Avec le correlation id, exactement la bonne pratique qu'on avait posée en bonus à l'épisode 2.

Le wrapper extrait cet identifiant de l'événement, puis l’ajoute à chaque ligne de log via des logs structurés.

const recordContext = extractRecordContext(record);
// correlationId, detailType, source, modelId, et le ApproximateReceiveCount de SQS

await runWithLogContext({ correlationId: recordContext.correlationId, consumer }, async () => {
  logStructured('info', 'consumer.lifecycle.started', { consumer, ...recordContext });
  // ... traitement ...
});

Résultat, quand quelque chose foire, on filtre sur le correlation id et on voit d'un coup tout le parcours de l'événement à travers tous nos systèmes. Petit bonus, on récupère aussi le ApproximateReceiveCount de SQS, donc on voit immédiatement si un message a été retenté plusieurs fois.

L'observabilité ne doit jamais casser le métier

Un dernier détail, et pas des moindres. Émettre un événement de cycle de vie, c'est un appel réseau, et un appel réseau, ça peut échouer ou traîner en longueur. Hors de question que notre monitoring fasse planter le traitement qu'il est censé observer. Donc l'émission est non bloquante, un timeout court (via un AbortController), et toute erreur d'émission est loguée mais jamais propagée.

const abortController = new AbortController();
const timeoutId = setTimeout(() => abortController.abort(), lifecycleEmitTimeoutMs);
try {
  await eventbridgeClient.send(command, { abortSignal: abortController.signal });
} catch (error) {
  logStructured('error', 'consumer.lifecycle.emit_failed', { phase, error_message: error?.message });
  // on n'échoue jamais le traitement à cause d'un log raté
} finally {
  clearTimeout(timeoutId);
}

C'est le genre de détail qu'on n'a pas forcément en tête au début, et qu'on apprend souvent à ses dépens. Votre instrumentation doit être plus solide que ce qu'elle observe, sinon elle devient elle-même une source de pannes.

L'alerting

L'observabilité, c'est bien, mais personne n'a envie de fixer un dashboard à longueur de journée en attendant qu'un chiffre devienne rouge. Ce qu'on veut, c'est être prévenus quand quelque chose part en vrille. Et devinez où on se fait prévenir ? Sur Mattermost, évidemment, c'est déjà notre centre névralgique, autant y concentrer les alertes.

Les erreurs aussi sont des événements

Vous commencez à connaître la chanson. Chez nous, tout est événement. Les erreurs ne font pas exception. On a vu juste avant que les consumers racontent leur vie dans des événements. Et bien on a un système un peu similaire pour nos producers, vous vous rappelez ceux qui servent pour gérer les outils sur lesquels on n’a pas la main directement. Quand ils plantent, ils ne se contentent pas de logger dans leur coin, ils publient un événement producer_error dans le bus.

export async function emitProducerError(err, payload = null) {
  await new EventBridgeClient().send(new PutEventsCommand({
    Entries: [{
      Source: 'bus.producer',
      DetailType: 'producer_error',
      Detail: JSON.stringify({
        producer: process.env.AWS_LAMBDA_FUNCTION_NAME,
        error: err.message,
        stack: err.stack,
        payload,
      }),
      EventBusName: process.env.BUS,
    }],
  }));
}

Producers comme consumers, leurs ratés remontent sous forme d'événements. Et un événement, on sait le router, il suffit de brancher un consumer dessus pour réagir.

Nos notifications dans notre système de chat

C'est le rôle d'un consumer dédié, notify_bus_error. Son boulot, repérer les erreurs, retrouver leur origine, trouver les logs correspondantes et quelle file est concernée. Une fois tout ça récupéré il ne lui reste plus qu’à poster une alerte dans le bon channel Mattermost. Elle reconstruit même le nom de la DLQ à partir de la file principale pour pointer directement où aller fouiller.

Le message qui arrive sur Mattermost nous dit donc, en clair “tel consumer a planté, voici l'erreur, les messages en échec sont dans telle DLQ et le lien vers les logs Cloudwatch filtrés par correlation Id”. Tout ce qu’il faut pour se lancer dans l’analyse hyper rapidement.

Notre anti-spam

Voici un cas classique de l'alerting, quand ça casse, ça casse souvent en rafale. Une API tierce tombe, et ce sont cinquante messages qui échouent à la suite. Sans précaution, c'est cinquante pings Mattermost en trente secondes, et un channel inutilisable au moment précis où on en a besoin.

On a donc mis en place une petite parade avec notre cache Redis. Une fois qu'une alerte est partie pour un consumer donné, on pose une clé et on se tait pendant un moment. Et oui, c'est le même Redis qu’on a vu juste au-dessus mais on s'en sert ici pour dédoublonner les alertes plutôt que les traitements. Une brique, deux usages.

Un événement arrive au consumer, qui vérifie dans Redis. Clé absente (premier passage) alors on traite et on pose la clé. Clé présente (doublon) alors on ignore.

Vous reconnaissez ce schéma ? Et oui c’est la même logique de garde que pour l'idempotence, mais appliquée aux alertes cette fois. C'est ce qui évite de transformer le channel en sapin de Noël quand une API tierce tombe.

Prévenir l'utilisateur, pas juste l'équipe…

Un dernier petit bonus sur l’alerting, et celui-là je l'aime bien. Certaines erreurs ne sont pas des bugs, c'est juste qu'un utilisateur a lancé une action sans tous les paramètres requis. Quand on a la main sur le code on va gérer ses problèmes avant même d’envoyer notre événement, mais quand on n’a pas la main, comment on fait ? Et bien, plutôt que d'alerter l'équipe pour un truc qu'on ne peut pas corriger, on prévient directement l'utilisateur concerné sur Mattermost, avec un message gentil et explicite (le gabarit est stocké dans DynamoDB, ce qui permet de le modifier sans redéployer, c’est même notre PO qui s’en occupe avec les utilisateurs) :

const item = await dynamodbService.getItem('producer_missing_params', 'mattermost_message');

const message = item.value.S
  .replace(/\[action\]/g, action)
  .replace(/\[missingParams\]/g, missingParams);

await mattermostService.sendMessageToUsers(userId, message);

C'est tout bête, mais ça transforme une erreur silencieuse en un petit coup de pouce "hé, il te manque ça pour que ton action passe". Du self-service, et un ticket de support en moins.

Comment ajouter un consumer en trois minutes ?

On a maintenant des consumers robustes et observables. Mais si en ajouter un nouveau demandait un temps énorme en mise en place d’infra, de déploiement, on aurait juste déplacé le problème. L'enjeu, c'est que créer un consumer soit rapide, fiable et sans bavure.

Le retour de Terraform

Toute la mécanique d'un consumer (la règle EventBridge, la file SQS, sa DLQ, la Lambda, l'event source mapping, les rôles IAM) est encapsulée dans un seul module Terraform. Du coup, déclarer un consumer revient à remplir une entrée dans une map :

locals {
  consumers = {
    activity_created_trello = {
      queue_name                 = "bus-consumer-project_created_trello-${terraform.workspace}"
      deadletter_queue_name      = "bus-consumer-project_created_trello-${terraform.workspace}-deadletter"
      visibility_timeout_seconds = 300
      fifo_queue                 = false
      max_receive_count          = 4
      batch_size                 = 50
      event_pattern_json         = jsonencode({ "detail-type" = ["project_created"] })
      timeout                    = 300
      environment_variables = {
        MODEL_TYPE     = "project"
        BOARD_ID       = terraform.workspace == "prod" ? "<prod_board>" : "<demo_board>"
        TRELLO_SECRET_KEY = "<a_little_secret_key>"
      }
    }

    # ... les autres consumers, sur le même modèle ...
  }
}

Et un seul bloc de module qui itère sur toute la map avec un for_each :

module "consumer" {
  source   = "./modules/consumer"
  for_each = local.consumers

  name      = each.key
  workspace = terraform.workspace

  queue_name            = each.value.queue_name
  deadletter_queue_name = each.value.deadletter_queue_name
  fifo_queue            = each.value.fifo_queue
  max_receive_count     = each.value.max_receive_count
  event_pattern_json    = each.value.event_pattern_json
  event_bus_name        = aws_cloudwatch_event_bus.bus.name
  redis_server          = "${aws_elasticache_serverless_cache.bus.endpoint[0].address}:${aws_elasticache_serverless_cache.bus.endpoint[0].port}"
  generic_layer_arn     = data.aws_lambda_layer_version.generic.arn
  environment_variables = each.value.environment_variables
  # ... subnets, security groups, …
}

Le résultat est assez satisfaisant. Pour brancher une nouvelle réaction sur le bus, on ajoute une entrée dans local.consumers, on écrit le handler métier, et terraform apply fabrique tout le reste. Rule + SQS + DLQ + Lambda + IAM, c’est pas moins de 20 ressources d'un coup, exactement configurés comme tous les autres. La cohérence est garantie par le module, fini les “j’ai oublié de créer la DLQ 🙁” ou encore “Roh… Mais pourquoi ma lambda n’est jamais appelée ?”.

Petite remarque tirée de ce bloc, d'ailleurs, vous voyez les valeurs en dur du genre TRELLO_SECRET_KEY ou OCTOPOD_SECRET ? Spoiler, c'est exactement le genre de chose qui devrait vivre dans Secrets Manager, pas dans le code. No stress on y revient dans pas longtemps.

Les Lambda layers

Les services communs (le client SmartRecruiters, le logger, le redisService, le consumerLifecycle, …) ne sont pas dupliqués dans chaque Lambda. Ils vivent dans des layers partagés qu'on attache à toutes les Lambdas. Ça allège chaque package, et une correction dans un service profite à tout le monde d'un coup. C'est ces fameux /opt/services/... qu'on importait dans les handlers depuis un moment déjà.

Attention par contre dans l’idéal on ne met pas tout dans un seul et unique layer. En soit ça marche (attention à la taille limite quand même) mais cela ralentit le lancement des lambdas. C’est ce qu’on appelle le Cold Start. Si une lambda n’est pas appelée régulièrement, elle rentre dans une phase de sommeil profond un peu comme Blanche Neige. Pour son prochain réveil il lui faut remettre en place tous les fichiers, les siens et ceux de ses layers… Donc si on a un giga layer mais que seul un fichier nous intéresse on va charger beaucoup pour rien et donc ralentir le lancement initial. C’est moins vrai si on a une lambda qui est appelée toutes les minutes par exemple, à ce moment-là AWS met une sorte de cache pour permettre d’aller plus vite.

Un environnement individualisé pour chacun

On déploie le même code sur plusieurs environnements (dev, démo, prod), et c'est là que les workspaces Terraform entrent en jeu. Vous l'avez vu dans tous les exemples, le ${terraform.workspace} glissé dans les noms de ressources. Un même apply, exécuté sur des workspaces différents, fabrique des bus, des files et des Lambdas isolés par environnement (octo-bus-demo, octo-bus-prod, …).

Chaque dev à ainsi son environnement de travail indépendant mais iso-prod. On peut faire des tests d’infra, de mise en place de nouveaux consumers sans se marcher dessus.

Build & deploy via Gitlab

Côté livraison, deux scripts font le boulot. build.sh zippe chaque Lambda et chaque layer avec quelques optimisations pour réduire la taille au maximum (vous vous rappelez du Cold Start ?) compression max, nettoyage des node_modules de tout ce qui ne sert pas à l'exécution. deploy.sh pousse ensuite les artefacts vers AWS et gère la rotation des versions de layers.

Et tout ça est orchestré par GitLab CI, en deux étapes, déclenchées sur les branches demo et prod automatiquement.

stages:
  - archiving
  - deploy

archive:
  stage: archiving
  rules:
    - if: '$CI_COMMIT_BRANCH == "demo" || $CI_COMMIT_BRANCH == "prod"'
  script:
    - ./scripts/build.sh
  artifacts:
    paths:
      - artifacts

deployment:
  stage: deploy
  rules:
    - if: '$CI_COMMIT_BRANCH == "demo" || $CI_COMMIT_BRANCH == "prod"'
  script:
    - ./scripts/deploy.sh $CI_COMMIT_BRANCH

Un merge sur demo, et la CI archive puis déploie sur l'environnement de démo. Pareil pour prod. Le $CI_COMMIT_BRANCH passé à deploy.sh sélectionne le bon workspace.

Nos galères…

Aucune archi ne se construit sans se cogner sur quelques murs. Voici les bosses qu'on s'est prises, avec à chaque fois la leçon qu'on en a tirée. Si on peut éviter quelques maux de crânes c’est toujours ça de gagner.

Le CDC qui ne capture rien

La galère que je vous tease depuis trois articles. On met en place le CDC sur la base de données de notre Legacy (épisode 3), DMS est vert, la réplication tourne, tout a l'air parfait… et pourtant, pas un seul événement n'arrive sur le bus. Rien. Le néant.

Le coupable ? PostgreSQL ne capturait pas les changements parce que son wal_level n'était pas réglé sur logical. Sans ça, le journal de transactions ne contient pas assez d'infos pour que DMS puisse reconstruire les changements ligne par ligne. Et le pire, c'est que ça ne hurle pas, ça fonctionne "à moitié" en silence, ce qui est bien plus pénible qu'une erreur franche.

La correction, sur RDS, c'est un simple paramètre dans le Parameter Group :

rds.logical_replication = 1

Suivi d'un reboot de l'instance (eh oui, ce paramètre est statique, un simple apply ne suffit pas).

Leçon : pour faire du CDC sur Postgres, la réplication logique doit être activée au niveau de la base, et ça se prévoit, surtout en prod où rebooter une instance, ça se planifie.

Les secrets en clair

Je vous l'ai signalé deux fois déjà dans cet article, alors autant l'assumer franchement… On a des tokens et des secrets en dur dans notre Terraform et nos variables d'environnement (clés Trello, secret Octopod, token de bot Mattermost…). Ça marche, mais c'est exactement ce qu'il ne faut pas faire, un secret dans le code, c'est un secret qui finit un jour dans un historique Git, un zip partagé, ou une capture d'écran.

Le bon réflexe, c'est AWS Secrets Manager (ou Parameter Store), les Lambdas vont chercher le secret au runtime, et le code ne contient qu'une référence. Oui on perd un peu de temps sur notre lambda car on fait des appels mais on gagne en sécurité.

C'est notre chantier en cours, et la leçon est limpide : externalisez vos secrets dès le début, parce que les rapatrier après coup quand ils sont éparpillés partout, c'est bien moins drôle.

Les cold starts en VPC

On a déjà évoqué le sujet des Cold Starts, et bien petite subtilité supplémentaire, nos Lambdas tournent dans un VPC (pour parler à la base, à Redis, etc.). C'est nécessaire, mais ça a un coût, les cold starts sont plus longs qu'une Lambda "nue". Ajoutez à ça le temps d'ouvrir une connexion à Redis ou à la base à chaque invocation, et une Lambda qui devait répondre en 100 ms en met soudain 800 au réveil.

Pas de solution miracle, mais quelques réflexes :

  • réutiliser les clients/connexions quand c'est possible (les instancier au niveau module plutôt qu'à chaque appel)
  • garder les Lambdas légères (d'où le nettoyage des node_modules dans le build.sh),
  • et accepter que pour des traitements asynchrones derrière une SQS, quelques centaines de millisecondes de cold start ne sont franchement pas un drame.

Leçon : le VPC n'est pas gratuit, mais pour de l'événementiel asynchrone, c'est un compromis tout à fait acceptable.

FIFO, à manier avec parcimonie

On l'a vu à l'épisode 3, quand l'ordre compte, on passe en FIFO. Mais le FIFO a un prix. Le débit est plafonné, on traite souvent les messages un par un (batch_size = 1), et il faut gérer le MessageGroupId et le dédoublonnage. Bref, c'est plus lent et plus contraignant.

Notre leçon : ne sortez le FIFO que quand l'ordre est vraiment critique (typiquement nos mises à jour qui ne doivent pas s'écraser dans le désordre). Pour tout le reste, le standard, plus rapide et plus souple, fait parfaitement le job. Choisir FIFO "au cas où", c'est s’ajouter une contrainte dont on n'a pas besoin.

La rétro-compatibilité

C’est un sujet qui existe dans plein de domaines et la gestion de nos événements n’y fait pas exception. On avait parlé dans l’épisode 2 d’avoir une version dans nos événements et bien c’est maintenant que ça sert.

Il est possible et même quasiment sûr à 100% qu'à un moment donné on ait besoin de faire évoluer le contenu de nos événements. Que l’on rajoute des informations ou qu’on en supprime le problème sera le même, est-ce qu’on est capable de faire évoluer et déployer tous les producers et consumers lié à cet événement en même temps ? La réponse est souvent non…

Dans ces cas-là il faut être capable de gérer plusieurs versions d’un même événement sinon vous allez vous retrouver avec des échecs qui vont être compliqués à gérer. Et clairement, d’expérience, essayer de transformer un événement d’une ancienne version vers la nouvelle à la main c’est pas ce qu’il y a de plus fun…

Leçon : Si il y a évolution du contrat d’interface de vos événements alors il faut faire évoluer la version et gérer pendant un temps du multi-version dans vos consumers.

Ce qu'on referait pareil

Pour ne pas finir que sur les bobos, la grande majorité de nos choix, on les referait sans hésiter. Bien sûr, on apprend de nos erreurs et on améliorerait certains éléments tout de suite.

EventBridge comme colonne vertébrale, le découplage producer/consumer, la SQS en tampon, l'idempotence par cache, l'observabilité event-driven… tout ça tient ses promesses au quotidien. Les galères ci-dessus, ce sont des détails d'implémentation, pas des remises en cause de l'architecture.

Et franchement, pour une petite équipe, avoir un système aussi découplé et extensible avec aussi peu d'infra à gérer, c'est exactement ce qu'on visait.

Rideau (enfin, presque)

Et voilà. Quatre épisodes plus tard, on a fait le tour complet : le pourquoi, la théorie, la pratique, et aujourd'hui la robustesse.

Si je devais résumer en une phrase ce que cette aventure nous a apporté : on est passés d'un écosystème où chaque outil parlait dans son coin, avec des incohérences pénibles et des intégrations en spaghetti, un besoin humain d’aller vérifier/copier-coller dans plusieurs outils, à un système où ajouter une réaction prend trois minutes, où une panne se voit et se signale toute seule, et où un événement peut traverser cinq systèmes sans que personne ne se marche dessus. Le tout avec une infra qu'une petite équipe peut maintenir sans y laisser ses nuits. Faire beaucoup avec peu, vous commencez à connaître mon dada.

Est-ce que c'est parfait ? Non, vous avez vu les galères. Est-ce qu'on le referait ? Sans hésiter.

Et comme une bonne histoire ne se termine jamais vraiment, il me reste quelques sujets dans ma besace dont j'aimerais vous parler :

  • le monitoring et les logs en détail. Je vous ai montré les briques, mais tout ce qu'on a construit autour pour rendre ça lisible par les devs et par notre PO, ça mérite son propre article (et c'est un peu ma fierté)
  • la doc dans un système distribué. Parce que quand vos événements vivent partout, documenter qui émet quoi et qui écoute quoi devient un sujet à part entière
  • et tout le pan analytics qu'on a à peine effleuré. Lié au monitoring mais surtout comment on déverse nos événements dans un entrepôt de données (Firehose, S3, et compagnie) pour en tirer de la vraie valeur métier.

Mais ça, ce sera pour d'autres fois. En attendant, merci d'avoir suivi cette série jusqu'au bout. J'espère qu'elle vous aura donné l'envie, et surtout les clés, de vous lancer dans l'Event-Driven Architecture. En tout cas personnellement j’avoue que si j’ai besoin de faire parler plusieurs outils mon premier réflexe sera l’EDA !

Et d'ici là, vous savez quoi ? Restez idempotents.