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
| Compte | Rôle | Portée | Remarque |
|---|---|---|---|
lab-batch-nocturne-sched | workflows.invoker | projet | gcloud n'offre pas de liaison sur un workflow ; ce compte n'a aucun autre droit |
lab-batch-nocturne-wf | run.jobsExecutorWithOverrides | le job | lancer le job en changeant ses variables et son nombre de tâches |
run.viewer | projet | lecture de l'opération longue par le connecteur (run.operations.get) | |
storage.objectUser | le bucket | marqueurs, statistiques, rapport | |
pubsub.publisher | le sujet de fin | ||
aiplatform.user, logging.logWriter | projet | synthèse Gemini, journaux d'exécution | |
lab-batch-nocturne-job | storage.objectUser | le bucket | lire les entrées, écrire les tranches |
aiplatform.user | projet | classement 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
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
fiLa notification, dans le workflow, publiée avant l'écriture du marqueur de fin :
- 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
okavant 7 h (métriquepubsub.googleapis.com/topic/send_message_operation_countdu sujet de fin à zéro sur la fenêtre), pas seulement sur la présence d'unechec. - 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 avecgcloud scheduler jobs runaprès chaque modification. - Notification en double. Une relance après une panne entre la publication et le marqueur renvoie un second
okpour la même date. Parade : les abonnés dédupliquent par l'attributdate, 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.invokerau compte du workflow lui-même, ourun.adminau lieu dejobsExecutorWithOverrides, 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 ?
L'absence de notification d'échec indique que le workflow n'a pas atteint son bloc except, très probablement qu'il n'a pas démarré. La question est donc en amont : Scheduler a-t-il déclenché, et avec quel résultat ? Un problème dans le job ou un quota aurait produit une exécution FAILED et une notification echec. Relancer cloud-up masquerait la cause.
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 ?
Le workflow passe à chaque exécution un fichier et un nombre de tâches différents : c'est une exécution avec overrides, qui demande la permission run.jobs.runWithOverrides. run.invoker ne la contient pas. L'alternative serait un job par fichier, sans overrides : plus de ressources à gérer pour un gain de sécurité nul.
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 :
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.