Leçon 4 — Construire

Planification, notification et droits

8 min de lecture

Un traitement nocturne a deux moments où il peut échouer sans que personne ne le sache : quand il ne démarre pas, et quand il se termine mal. Le script cron d'origine cumulait les deux : la VM redémarrée après une mise à jour n'avait plus son crontab, et un échec n'envoyait rien. Le service qualité découvrait le problème à 9 h, devant un tableau vide. Le déclenchement doit être fiable et réessayé, la fin doit être annoncée, échec compris, et chaque composant ne doit avoir que les droits de son rôle.

Le mécanisme

Le déclenchement. Cloud Scheduler n'exécute rien lui-même : à l'heure dite, il envoie une requête HTTP. Ici, un POST sur l'API Workflow Executions, qui crée une exécution du workflow. L'appel est authentifié par un jeton OAuth du compte lab-batch-nocturne-sched : les API Google (*.googleapis.com) attendent un jeton d'accès OAuth, alors qu'un service Cloud Run attend un jeton d'identité OIDC. Le corps contient l'argument de l'exécution, lui-même une chaîne JSON :

{"argument": "{}"}

Un argument vide signifie « la veille » : le workflow calcule la date en heure de Paris. Si l'appel échoue (403, 5xx, délai de 60 s dépassé), Scheduler réessaie jusqu'à 3 fois, au moins 60 s plus tard.

L'heure. La planification 30 3 * * * en Europe/Paris suit l'heure locale. Entre 02:00 et 03:00, cette heure n'existe pas la nuit du passage à l'heure d'été et existe deux fois la nuit du retour à l'heure d'hiver ; la documentation de Cloud Scheduler prévient d'« anomalies d'exécution » dans ce cas. 03:30 est hors de cette plage.

La fin. Le workflow publie un message sur lab-batch-nocturne-fin, avec l'attribut statut (ok ou echec) et un résumé JSON. Les consommateurs s'abonnent : une alerte vers l'astreinte, un message dans le canal de l'équipe qualité, un tableau de bord. Le workflow n'a pas à les connaître.

sequenceDiagram
  participant S as "Cloud Scheduler"
  participant W as "Workflows"
  participant J as "Cloud Run jobs"
  participant P as "Pub/Sub fin"
  participant A as "Abonnés (alerte, canal)"
  S->>W: POST executions (OAuth, argument "{}")
  W-->>S: 403 (droit pas encore propagé)
  Note over S: réessai après 60 s
  S->>W: POST executions
  W-->>S: 200, exécution créée
  W->>J: jobs.run x3 (2 à la fois)
  J-->>W: terminés
  W->>P: publish statut=ok
  P->>A: livraison

Les droits

CompteRôlePortéeRemarque
lab-batch-nocturne-schedworkflows.invokerprojetgcloud n'offre pas de liaison sur un workflow ; ce compte n'a aucun autre droit
lab-batch-nocturne-wfrun.jobsExecutorWithOverridesle joblancer le job en changeant ses variables et son nombre de tâches
run.viewerprojetlecture de l'opération longue par le connecteur (run.operations.get)
storage.objectUserle bucketmarqueurs, statistiques, rapport
pubsub.publisherle sujet de fin
aiplatform.user, logging.logWriterprojetsynthèse Gemini, journaux d'exécution
lab-batch-nocturne-jobstorage.objectUserle bucketlire les entrées, écrire les tranches
aiplatform.userprojetclassement Gemini

roles/run.jobsExecutorWithOverrides plutôt que roles/run.invoker : le second permet de lancer le job tel qu'il est configuré, pas de changer ses variables d'environnement à l'exécution, ce que fait le workflow (INPUT_URI, OUTPUT_PREFIX, taskCount).

La mise en œuvre

files/infra/cloud-up.sh (extrait)
SCHED_OPTS=(--location="$REGION" --schedule="30 3 * * *" --time-zone=Europe/Paris --http-method=POST
  --uri="https://workflowexecutions.googleapis.com/v1/projects/$PROJECT/locations/$REGION/workflows/$P/executions"
  --oauth-service-account-email="$SCHED_SA" --message-body='{"argument": "{}"}'
  --attempt-deadline=60s --max-retry-attempts=3 --min-backoff=60s)
if $G scheduler jobs describe "$P-nuit" --location="$REGION" >/dev/null 2>&1; then
  $G scheduler jobs update http "$P-nuit" "${SCHED_OPTS[@]}" >/dev/null
else
  $G scheduler jobs create http "$P-nuit" "${SCHED_OPTS[@]}" >/dev/null
fi

La notification, dans le workflow, publiée avant l'écriture du marqueur de fin :

files/infra/workflow.yaml (extrait)
            - notifier:
                call: googleapis.pubsub.v1.projects.topics.publish
                args:
                  topic: ${topic}
                  body:
                    messages:
                      - data: ${base64.encode(json.encode(resume))}
                        attributes:
                          statut: ok
                          date: ${date}

Pour tester sans attendre la nuit, gcloud scheduler jobs run lab-batch-nocturne-nuit force un déclenchement, avec la même requête, le même compte et la même politique de réessai qu'à 03:30. C'est ce que fait make e2e.

Résultat obtenu (2026-10-16)

Le premier déclenchement forcé, une minute après make cloud-up, journalisé par Cloud Scheduler :

INFO   AttemptStarted   scheduledTime 2026-10-16T13:30:04Z  url https://workflowexecutions.googleapis.com/v1/projects/<projet>/…/executions
ERROR  AttemptFinished  status PERMISSION_DENIED  debugInfo "URL_ERROR-ERROR_OTHER. Original HTTP response code number = 403"

La liaison workflows.invoker venait d'être créée et n'était pas encore propagée. Scheduler a réessayé 60 s plus tard : l'exécution du workflow porte l'heure de création 13:31:05. Sans --max-retry-attempts, la nuit aurait été perdue.

Les notifications reçues sur lab-batch-nocturne-fin-sub pendant l'e2e, un succès puis un échec volontaire (date sans fichier) :

{'statut': 'ok', 'date': '2026-10-15', 'data': {'avis': 29, 'date': '2026-10-15', 'graves': 15,
  'rapport': 'gs://lab-batch-nocturne-<projet>/rapports/2026-10-15/rapport.json', 'statut': 'ok',
  'tokens_jobs': 4298, 'tokens_synthese': 1670, 'tranches': 6}}
{'statut': 'echec', 'date': '2000-01-01', 'data': {'date': '2000-01-01',
  'erreur': '"aucun fichier sous gs://lab-batch-nocturne-<projet>/entrant/2000-01-01/"', 'statut': 'echec'}}

Un piège découvert au passage : gcloud workflows run rend le code 0 même quand l'exécution échoue. Le test lit donc le champ state (FAILED) de l'exécution, pas le code de sortie de la commande.

Ce qui casse en production

  • Le déclenchement échoue en silence. Scheduler a épuisé ses réessais (compte supprimé, API désactivée) : aucune exécution n'est créée, donc aucune notification d'échec non plus. Parade : une alerte sur l'absence de notification ok avant 7 h (métrique pubsub.googleapis.com/topic/send_message_operation_count du sujet de fin à zéro sur la fenêtre), pas seulement sur la présence d'un echec.
  • Argument mal formé. --message-body='{"argument": {}}' (objet au lieu de chaîne) est refusé par l'API (400) ; Scheduler réessaie trois fois, puis abandonne. Parade : tester le corps exact avec gcloud scheduler jobs run après chaque modification.
  • Notification en double. Une relance après une panne entre la publication et le marqueur renvoie un second ok pour la même date. Parade : les abonnés dédupliquent par l'attribut date, ou acceptent le doublon (un second message dans un canal d'équipe).
  • Heure locale ambiguë. Une planification à 02:30 Paris s'exécute de façon imprévisible deux nuits par an. Parade : une heure hors de 02:00-03:00, ou une planification en UTC si l'heure locale importe peu ; dans les deux cas, l'idempotence par date absorbe une exécution en trop.
  • Droit accordé au mauvais niveau. Accorder workflows.invoker au compte du workflow lui-même, ou run.admin au lieu de jobsExecutorWithOverrides, fonctionne et passe inaperçu. Parade : une ligne par droit dans le script, avec sa justification, relue en revue.

La règle de décision

Planification en heure locale seulement si le métier l'exige, à une heure qui n'est jamais ambiguë, avec des réessais de déclenchement. Une notification pour chaque fin, succès et échec, publiée sur un sujet que des consommateurs indépendants écoutent, et une alerte sur l'absence de succès. Un compte de service par composant, chaque droit sur la ressource la plus étroite que gcloud permet, les exceptions écrites et justifiées.

Pratique

Le lundi matin, aucun rapport. Aucune notification echec n'est arrivée. Par quoi commencez-vous ?

Un collègue propose de donner roles/run.invoker au compte du workflow au lieu de run.jobsExecutorWithOverrides, « c'est le rôle standard ». Que se passe-t-il ?

Exercice. Écrivez un consommateur de la notification de fin : une fonction Python qui lit lab-batch-nocturne-fin-sub et poste un message dans un canal d'équipe (simulé par un print) : « Rapport du 2026-10-15 : 29 avis, 15 graves » ou « ÉCHEC du 2026-10-15 : ». Elle doit tolérer une notification en double.

Voir la solution
import json
from google.cloud import pubsub_v1

PROJECT, deja = "mon-projet", set()  # en production : un stockage partagé (Firestore, Redis)
sub = pubsub_v1.SubscriberClient()
path = sub.subscription_path(PROJECT, "lab-batch-nocturne-fin-sub")


def recevoir(m: pubsub_v1.subscriber.message.Message) -> None:
    cle = (m.attributes.get("date"), m.attributes.get("statut"))
    if cle not in deja:
        d = json.loads(m.data)
        print(f"Rapport du {d['date']} : {d['avis']} avis, {d['graves']} graves" if d["statut"] == "ok"
              else f"ÉCHEC du {d['date']} : {d['erreur']}")
        deja.add(cle)
    m.ack()  # acquitter aussi les doublons, sinon ils reviennent


sub.subscribe(path, callback=recevoir).result()

La clé (date, statut) laisse passer un ok après un echec pour la même date (une relance réussie après un échec), mais pas deux ok.

  • Je planifie un workflow par Cloud Scheduler avec un jeton OAuth et des réessais de déclenchement.
  • Je choisis une heure de planification qui n'est jamais ambiguë.
  • Je publie une notification de fin pour le succès comme pour l'échec, et j'alerte sur l'absence de succès.
  • J'accorde à chaque compte le rôle de son action, sur la ressource la plus étroite possible.
Suivant