Upgrade to Pro — share decks privately, control downloads, hide ads and more …

100 crons par seconde, le Scheduler se venge

100 crons par seconde, le Scheduler se venge

On est partis d’un cron solitaire, tournant discrètement dans l’ombre d’un serveur.

Aujourd’hui, on orchestre un Scheduler Symfony distribué, déployé sur Kubernetes, capable de piloter des centaines de tâches en parallèle avec fiabilité.

Ce talk raconte cette migration pleine d’enseignements — entre verrous, scalabilité horizontale, observabilité, et une bonne dose de magie Symfony.

Vous y découvrirez comment un simple cron s’est transformé en un système robuste, mais aussi les nombreux pièges rencontrés en chemin :

- des problèmes d’observabilité difficiles à diagnostiquer,
- des locks Symfony qui bloquent tout le scheduler,
- et quelques surprises liées au scaling horizontal.

Bref, une aventure concrète, pleine de sueur, de logs, de metric, et de solutions ingénieuses.

Avatar for Jérémie Augustin

Jérémie Augustin

March 26, 2026
Tweet

Other Decks in Programming

Transcript

  1. 100 crons par seconde, le Scheduler se venge 2 Github:

    @jaugustin X : @pixeljer 2 Jérémie AUGUSTIN Software Architect Click&Boat
  2. 3 3

  3. 4 Voyage vers Symfony Scheduler • Lʼhistoire de nos tâches

    planifiés ◦ Naviguer avec l'ancien système • Migration vers le Symfony Scheduler ◦ La scalabilité ◦ Lʼobservabilité ◦ Lʼimpacte business 4
  4. 1 crontab dans un EC2 # /etc/crontab: * * *

    * * docker compose run php gate.php 9 9
  5. Timeline 11 11 • Durée variable de 10 sec à

    24h • Nombre variable de 0 à X tâches
  6. 13 Limitations • Lock des tâches 24h si crash •

    Log dans le container • Les devs n'ont pas accès • Déploiement à la main 13
  7. 22 k8s cronjob & compromis 22 • 3 tasks max

    • 1vCPu / 8Go • log opensearch • Réduit la fréquence
  8. 23 Limitations ✅ Log dans le container ⇒ Opensearch ✅

    Les devs n'ont pas accès ⇒plus besoin ✅ Déploiement à la main ⇒ helm 23
  9. 25 Limitations ✅ Log dans le container ⇒ Opensearch ✅

    Les devs n'ont pas accès ⇒plus besoin ✅ Déploiement à la main ⇒ helm ❌ Lock des tâches 24h si crash 25
  10. 29 Expérimental ? 🤔 29 • Une première tâche en

    production #[AsSchedule('default')] class DefaultScheduleProvider implements ScheduleProviderInterface { public function getSchedule(): Schedule { return (new Schedule())->add( RecurringMessage::every('2 hours', new DISynchronizeMessage('n')) ); } }
  11. 30 Symfony Scheduler 30 • Puis 2, 3, 4 …

    • Toujours aussi simple • 1 worker Pas de lock / Pas de cache
  12. 37 Lock par Scheduler = pas de parallélisation 37 •

    Scheduler_A ◦ RecuringMessageA ◦ RecuringMessageB ◦ RecuringMessageC ◦ RecuringMessageB
  13. 38 1 scheduler + 1 message + 1 worker =

    🔥💵💵💵 38 • Scheduler_A ◦ RecuringMessageA • Scheduler_B ◦ RecuringMessageB • Scheduler_C ◦ RecuringMessageC • Scheduler_D ◦ RecuringMessageD
  14. 41 Un lock maison par message 41 <?php #[AsEventListener (event:

    PreRunEvent::class)] public function onSchedulerPreRun (PreRunEvent $event): void { $messageId = $event->getMessageContext ()->id; // Lock par message empeche l'execution parallele $messageLock = $this->lockFactory->createLock("scheduler_msg_ {$messageId}", 300); if (!$messageLock->acquire()) { $event->shouldCancel(true); return; } // Lock par run - idempotence $triggerTime = $event->getMessageContext ()->triggeredAt->getTimestamp(); $runLock = $this->lockFactory->createLock("scheduler_run_ {$messageId}_{$triggerTime}", 86400); if (!$runLock->acquire()) { $messageLock->release(); $event->shouldCancel(true); } }
  15. 42 Un lock par message 42 1 scheduler + X

    messages + Y workers = 💚 • Scheduler_A ◦ RecuringMessageA ◦ RecuringMessageB ◦ RecuringMessageC ◦ RecuringMessageB
  16. Combien de worker on a besoin ? 🤔 45 45

    • Des metrics • Un dashboard • Des alertes
  17. Les metrics avec Prometheus ? 46 46 • Scheduler =

    daemon php • Pas de server HTTP • Pas de metrics
  18. Scheduler & Observabilité 48 48 $registry = new CollectorRegistry (new

    Prometheus\Storage\Redis($redisConfig), false); $http = new HttpServer(static function (ServerRequestInterface $request) use ($registry) { return Response::plaintext( (new RenderTextFormat ())->render($registry->getMetricFamilySamples ()) ); }); $http->listen(new SocketServer("0.0.0.0:9100") );
  19. Scheduler & Observabilité 50 50 • Metrics ◦ Execution /

    failure ◦ Durée ◦ Business • Alertes ◦ Si pas exécuté depuis 24h ◦ Spécifique
  20. 52 Les tâches migrés • Des commandes Symfony simple •

    Du code testable • Observable Log / Metrics / Alerte) 52
  21. 53 Migrer une tâche: • On priorise par le business

    Impact / Risk) • On évalue le besoin (sinon 🗑) • On évalue lʼarchitecture One shot, batch, queue) 53
  22. 54 Touch it, migrate it 54 • Migration progressive •

    50 tâches migrés • 10 tâches supprimés
  23. Le scheduler cʼest top, utilisez le! 55 55 1. Le

    scheduler scale 2. Observabilité dès le début 3. Migrer progressivement
  24. Memories made on water 57 Thank you! Jérémie AUGUSTIN Github:

    @jaugustin X : @pixeljer On recrute! www.clickandboat.com/jobs