Aller au contenu
Serverless Functions et Jobs

Serverless Functions et Jobs

200 Pratiquer ⏱ 1 h 15 cloudscalewayserverlesspython

À la fin, vous saurez

  • Distinguer fonction, conteneur serverless, job et instance avec cron, et choisir selon la forme du travail
  • Empaqueter une fonction Python avec ses dépendances pour le runtime de Scaleway
  • Déclencher une fonction par une file de messages, et écrire un traitement idempotent qui distingue erreurs passagères et définitives
  • Définir un job planifié, lui donner des secrets par Secret Manager et une politique de réessai
  • Identifier les limites documentées de chaque service (durée, mémoire, réseau privé, concurrence) avant de lui confier un travail

Prérequis

Testé avec boto3 1.43.24 pillow 11.3.0 python-runtime python313 scaleway-cli 2.62.0 , vérifié le 5 octobre 2026

Pourquoi

Les photos jointes aux signalements arrivent telles que les téléphones les prennent : 4 000 pixels de large, 5 Mo, parfois tournées de 90 degrés. La liste des signalements les affiche en miniature, et l'application télécharge aujourd'hui la photo entière pour l'afficher sur 300 pixels. Les agents municipaux, sur le terrain, en 4G, attendent. Il faut une vignette par photo.

Faire ce travail dans l'API, au moment de l'envoi, est la mauvaise idée classique : redimensionner une image prend du processeur et de la mémoire, rallonge la requête de l'utilisateur, et, le lendemain d'un orage, quand des centaines de photos arrivent en même temps, sature les mêmes processus qui doivent répondre aux autres requêtes. Ce travail n'a pas besoin d'être fait pendant la requête ; il a besoin d'être fait bientôt, et une fois.

Deuxième besoin : chaque métropole veut un export quotidien de ses signalements, à importer dans son propre outil. C'est une tâche planifiée, qui dure quelques secondes et ne sert à rien le reste de la journée. L'équipe l'a d'abord écrite comme une tâche cron sur sig-app-1. Elle s'est exécutée deux fois le jour où les deux instances avaient la même crontab, et pas du tout le jour où sig-app-1 a été remplacée.

La leçon précédente a mis l'API sur Serverless Containers. Celle-ci confie la vignette à Serverless Functions, déclenchée par une file de messages, et l'export à Serverless Jobs, déclenché par un horaire. Et, surtout, elle explique comment choisir entre ces services, qui se recouvrent en partie.

Les concepts

Trois services, trois formes de travail

Serverless FunctionsServerless ContainersServerless Jobs
On fournitDu code dans un langage pris en charge, un gestionnaireUne image de conteneur qui sert du HTTPUne image de conteneur qui s'exécute jusqu'à sa fin
InvocationHTTP, horaire, file (SQS), sujet NATSHTTP, horaire, file, sujet NATSManuelle ou horaire
Requêtes simultanées par instance1Jusqu'à 80, réglableSans objet
Durée maximale60 minutes par requête60 minutes par requête24 heures par exécution
Mise à zéroOuiOuiSans objet : rien ne tourne entre deux exécutions
Réseau privéSortant, ouiSortant, ouiNon, au 5 octobre 2026
Secrets de Secret ManagerNon : variables secrètes propres au serviceNon : variables secrètes propres au serviceOui, par référence
FacturationInvocations et consommationConsommationConsommation

Le tableau, tiré de la documentation de Scaleway, contient l'essentiel de la décision :

  • une fonction convient à un traitement court, déclenché par un événement, écrit dans un des langages pris en charge (Node.js, Python, Go, PHP, Rust) ;
  • un conteneur serverless convient à un service HTTP, ou à un traitement événementiel qui a besoin d'un environnement que les runtimes ne fournissent pas ;
  • un job convient à une tâche qui démarre, travaille, et s'arrête avec un code de sortie, sans attendre de requête.

Une fonction serverless (en anglais function as a service) est un morceau de code que la plateforme exécute à la demande : on ne fournit ni serveur ni image, seulement le code et ses dépendances, et un gestionnaire (handler), la fonction que la plateforme appelle à chaque invocation. Un job planifié est une tâche à exécution unique, lancée par un horaire, dont le résultat se lit dans son code de sortie et ses journaux.

La fonction : runtimes, gestionnaire, empaquetage

Le runtime Python appelle une fonction de signature handle(event, context). event est un dictionnaire qui décrit la requête : body (une chaîne, à décoder soi-même), headers, method, path, queryStringParameters et isBase64Encoded. Le gestionnaire renvoie soit une valeur simple, soit un dictionnaire avec statusCode, body et headers, qui devient la réponse HTTP. Le nom du gestionnaire s'écrit <fichier>.<fonction> : handler.handle par défaut.

Le code est envoyé sous forme d'archive zip (100 Mio au plus, 500 Mio une fois décompressée). Les dépendances Python sont embarquées dans l'archive, dans un répertoire package/ à sa racine. Pour les bibliothèques qui contiennent du code compilé, comme Pillow, il faut installer les versions construites pour l'environnement du runtime, pas pour votre poste : la documentation fournit pour cela une image Docker par version de Python, rg.fr-par.scw.cloud/scwfunctionsruntimes-public/python-dep:<version>.

Les runtimes ont un cycle de vie : beta, available, puis deprecated, end_of_support (plus de création), end_of_life (plus de mise à jour). Au 5 octobre 2026, Python 3.11 à 3.14 sont available, Python 3.10 est en fin de support. Une fonction écrite aujourd'hui sera à migrer dans quelques années : c'est un coût récurrent que l'image d'un conteneur ou d'un job, figée par vous, n'impose pas de la même façon.

Les ressources d'une fonction se choisissent par paliers de mémoire, le processeur suit : 128 Mo et 70 mvCPU, 256 et 140, 512 et 280, jusqu'à 4 096 Mo et 2 240 mvCPU. Une instance ne traite qu'une requête à la fois ; la fonction monte jusqu'à 50 instances simultanées.

Le déclencheur par file, et ce qu'il promet

Un déclencheur (trigger) relie une source d'événements à la fonction. Avec une file de Scaleway Queues (compatible avec l'API Amazon SQS, leçon 9), la plateforme lit les messages et appelle la fonction par une requête POST dont le corps est celui du message. Trois règles documentées gouvernent ce qui suit :

  1. Réessai : si la fonction répond avec un code supérieur ou égal à 300, l'envoi est retenté, jusqu'à trois fois.
  2. Contre-pression : le déclencheur ne garde que les messages en cours de traitement, et ne lit les suivants que lorsque les précédents sont traités. La documentation limite ce mécanisme à 10 requêtes en vol au 5 octobre 2026 : au-delà de dix instances, la fonction n'est pas utilisée à pleine capacité par un déclencheur.
  3. Rétention : un pic de messages attend dans la file. Si la file supprime les messages au bout d'une durée trop courte, ils disparaissent avant d'être traités. La documentation donne une formule pour la rétention minimale : taille du pic, divisée par le débit de la fonction (nombre d'instances divisé par la durée d'un traitement), plus la durée d'un démarrage à froid.

Une file comme celle-ci promet une livraison au moins une fois : un message peut arriver deux fois, par exemple si la fonction a fait son travail mais que la réponse s'est perdue. Le traitement doit donc être idempotent : le refaire ne doit rien changer. Et il doit distinguer deux sortes d'échecs : une erreur passagère (le stockage ne répond pas) mérite un réessai ; une erreur définitive (le fichier n'est pas une image) n'en mérite pas, et la réessayer trois fois ne fait que gaspiller.

Le job : définition, exécution, réessai

Un job se décrit par une définition : l'image, la commande de démarrage (startup_command) et ses arguments, le processeur (en mvCPU), la mémoire et le stockage local (en Mio), la durée maximale, les variables d'environnement, les références de secrets. Chaque lancement crée une exécution (job run) avec son identifiant, son état et son code de sortie : 0 pour un succès, autre chose pour un échec.

Depuis juin 2026 :

  • une politique de réessai relance automatiquement une exécution qui sort avec un code non nul, jusqu'à cinq fois. Une exécution qui échoue sans code de sortie (image introuvable, erreur de la plateforme) n'est pas réessayée, ni une exécution interrompue à la main ;
  • une même définition peut avoir plusieurs déclencheurs horaires, chacun avec son fuseau et, au besoin, sa propre commande. L'ancien champ schedule, unique, est déprécié.

Un job peut référencer un secret de Secret Manager, sous forme de variable d'environnement ou de fichier ; chaque exécution lit le secret au démarrage, ce qui compte comme un accès facturé. C'est le seul des trois services qui offre cette intégration au 5 octobre 2026.

Ses limites : 24 heures par exécution, 6 vCPU et 16 Go de mémoire au plus, 10 Go de stockage éphémère, 20 références de secrets, une image linux/amd64 (2 Go recommandés au plus), et pas d'accès aux réseaux privés. Le conteneur tourne en root par défaut, sans privilèges, sans élévation possible et sans capabilities ; comme en v2 des conteneurs, /tmp vit en mémoire.

Chaque exécution reçoit des variables injectées, préfixées par SCW_SLS : identifiant de l'exécution (SCW_SLS_RESOURCE_ID), de la définition, du projet, image avec son empreinte (SCW_SLS_IMAGE), limites de ressources. Elles sont utiles pour journaliser et pour nommer des fichiers sans collision.

En pratique

Les commandes ont été vérifiées avec l'aide de la CLI scw 2.62 (API Functions v1beta1, API Jobs v1alpha2). Le code Python a été vérifié syntaxiquement, et la protection contre les images démesurées testée localement ; la leçon ne reproduit pas de sortie de la plateforme.

Une identité pour chaque traitement

La fonction lit photos/ et écrit vignettes/ ; le job écrit exports/. Chacun reçoit sa propre application IAM avec une clé d'API, sur le modèle de la leçon 7 du cours précédent :

ApplicationJeux de permissions, projet signalements-prod
sig-vignettesObjectStorageObjectsRead, ObjectStorageObjectsWrite
sig-exportObjectStorageObjectsWrite

Les jeux de permissions IAM s'appliquent à tous les buckets du projet. Pour réduire sig-vignettes aux seuls préfixes dont elle a besoin, ajoutez des déclarations à la politique du bucket, comme dans Le stockage : bloc, fichier, objet : lecture de photos/*, écriture de vignettes/*, pour le principal application_id:<id de sig-vignettes>.

La file des photos

L'API publie un message après chaque envoi de photo. La file photos-a-traiter se crée dans Scaleway Queues, détaillé à la leçon 9 ; ici, l'essentiel :

$ scw mnq sqs activate project-id="$PROJET" region=fr-par
$ aws sqs create-queue --queue-name photos-a-traiter \
    --attributes MessageRetentionPeriod=86400 \
    --endpoint-url https://sqs.mnq.fr-par.scaleway.com --profile mnq

Une rétention d'un jour couvre largement la formule de la documentation pour un pic de quelques milliers de photos : avec 10 requêtes en vol et une seconde par vignette, 5 000 photos se traitent en un peu plus de huit minutes.

La fonction

L'arborescence de la fonction :

vignettes/
├── handler.py
├── requirements.txt
└── package/          créé à l'étape suivante

requirements.txt épingle les versions :

pillow==11.3.0
boto3==1.43.24

Et handler.py :

"""Fonction Serverless : crée la vignette d'une photo de Signalements.

Déclenchée par la file photos-a-traiter. Corps attendu du message :
{"bucket": "signalements-pj-xxxx", "cle": "photos/2026/10/4812.jpg"}
"""
import io
import json
import logging
import os
import warnings

import boto3
from botocore.exceptions import ClientError
from PIL import Image, ImageOps, UnidentifiedImageError

logging.basicConfig(level=logging.INFO)
journal = logging.getLogger("vignettes")

LARGEUR = int(os.environ.get("LARGEUR_VIGNETTE", "320"))
# Refuser les images démesurées avant de les décoder (bombes de décompression) :
# Pillow avertit au-delà de MAX_IMAGE_PIXELS, et l'avertissement devient ici une erreur.
Image.MAX_IMAGE_PIXELS = 40_000_000
warnings.simplefilter("error", Image.DecompressionBombWarning)

# Créé une fois par instance, au démarrage à froid, puis réutilisé.
s3 = boto3.client(
    "s3",
    endpoint_url=os.environ.get("S3_ENDPOINT", "https://s3.fr-par.scw.cloud"),
    region_name="fr-par",
    aws_access_key_id=os.environ["S3_ACCESS_KEY"],
    aws_secret_access_key=os.environ["S3_SECRET_KEY"],
)


def reponse(code, message):
    return {"statusCode": code, "body": {"message": message}}


def handle(event, context):
    corps = event.get("body") or "{}"
    try:
        demande = json.loads(corps) if isinstance(corps, str) else corps
        bucket, cle = demande["bucket"], demande["cle"]
    except (ValueError, KeyError, TypeError):
        # Message mal formé : le réessayer ne changera rien.
        journal.error("message ignoré, mal formé : %.200s", corps)
        return reponse(200, "ignoré")

    if not cle.startswith("photos/"):
        journal.error("clé hors du préfixe photos/ : %s", cle)
        return reponse(200, "ignoré")
    cible = "vignettes/" + cle.removeprefix("photos/")

    # Idempotence : un message livré deux fois ne refait pas le travail.
    try:
        s3.head_object(Bucket=bucket, Key=cible)
        journal.info("vignette déjà présente : %s", cible)
        return reponse(200, "déjà fait")
    except ClientError as erreur:
        if erreur.response["Error"]["Code"] not in ("404", "NoSuchKey", "NotFound"):
            raise

    try:
        objet = s3.get_object(Bucket=bucket, Key=cle)
        with Image.open(io.BytesIO(objet["Body"].read())) as image:
            image = ImageOps.exif_transpose(image)
            image.thumbnail((LARGEUR, LARGEUR))
            tampon = io.BytesIO()
            image.convert("RGB").save(tampon, format="JPEG", quality=80)
    except (UnidentifiedImageError, Image.DecompressionBombError, Image.DecompressionBombWarning) as erreur:
        # Erreur définitive : on la journalise, on ne la fait pas réessayer.
        journal.error("image refusée %s : %s", cle, erreur)
        return reponse(200, "image refusée")
    except ClientError as erreur:
        # Erreur du stockage, souvent passagère : un code >= 300 provoque un nouvel essai.
        journal.warning("lecture impossible de %s : %s", cle, erreur)
        return reponse(503, "réessayer")

    s3.put_object(
        Bucket=bucket,
        Key=cible,
        Body=tampon.getvalue(),
        ContentType="image/jpeg",
    )
    journal.info("vignette écrite : %s (%d octets)", cible, tampon.tell())
    return reponse(200, "ok")

Ce qui compte dans ce code :

  • Le client S3 est créé hors du gestionnaire, une fois par instance. Chaque invocation suivante le réutilise, avec ses connexions ouvertes : c'est le moyen le plus simple de réduire la durée, donc le coût, de chaque appel.
  • Le message est validé avant tout travail, et une clé hors de photos/ est refusée : la fonction ne doit pas devenir un moyen de lire n'importe quel objet du bucket.
  • L'idempotence vient d'une vérification préalable : si la vignette existe, le travail est fait. Le nom de la vignette se déduit de celui de la photo ; deux livraisons du même message produisent le même objet.
  • Deux sortes d'erreurs : un fichier illisible ou démesuré renvoie 200 (le message est consommé, l'erreur est dans les journaux) ; une erreur du stockage renvoie 503, et le déclencheur réessaie. Une exception non prévue fait aussi échouer l'appel, donc le réessaie.
  • ImageOps.exif_transpose applique l'orientation enregistrée par le téléphone, sans quoi les vignettes de photos prises en portrait seraient couchées.
  • MAX_IMAGE_PIXELS et le filtre d'avertissement : une image de quelques kilo-octets peut déclarer des dimensions gigantesques et faire exploser la mémoire au décodage. Pillow se protège par défaut au-delà d'environ 89 millions de pixels (avertissement) et du double (erreur) ; la fonction abaisse le seuil et transforme l'avertissement en erreur.

Empaqueter et déployer

Les dépendances s'installent dans package/ avec l'image fournie par Scaleway pour la version de Python du runtime, pour que les parties compilées de Pillow correspondent à l'environnement d'exécution :

$ cd vignettes
$ docker run --rm -v "$PWD":/home/app/function --workdir /home/app/function \
    rg.fr-par.scw.cloud/scwfunctionsruntimes-public/python-dep:3.13 \
    pip install -r requirements.txt --target ./package
$ zip -r ../vignettes.zip handler.py package/
$ cd ..

Le zip contient le contenu du répertoire, pas le répertoire lui-même : la documentation met en garde contre les outils graphiques de compression, qui ajoutent un niveau de dossier et cassent la résolution du gestionnaire.

La commande deploy crée l'espace de noms et la fonction s'ils n'existent pas, envoie l'archive et lance la construction :

$ FN_NS=$(scw function namespace create name=sig-fonctions project-id="$PROJET" \
    region=fr-par -o json | jq -r .id)
$ scw function deploy namespace-id="$FN_NS" name=vignettes runtime=python313 \
    zip-file=vignettes.zip region=fr-par
$ FN_ID=$(scw function function list namespace-id="$FN_NS" region=fr-par -o json \
    | jq -r '.[] | select(.name == "vignettes") | .id')

Le gestionnaire par défaut, handler.handle, correspond au fichier. Réglez ensuite la fonction ; les clés de sig-vignettes sont lues sans être affichées :

$ read -rs -p "Clé d'accès de sig-vignettes : " CLE_ACCES; echo
$ read -rs -p "Clé secrète de sig-vignettes : " CLE_SECRETE; echo
$ scw function function update "$FN_ID" \
    memory-limit=512 timeout=60s privacy=private \
    min-scale=0 max-scale=10 \
    environment-variables.LARGEUR_VIGNETTE=320 \
    secret-environment-variables.0.key=S3_ACCESS_KEY \
    secret-environment-variables.0.value="$CLE_ACCES" \
    secret-environment-variables.1.key=S3_SECRET_KEY \
    secret-environment-variables.1.value="$CLE_SECRETE" \
    region=fr-par
$ unset CLE_ACCES CLE_SECRETE
  • memory-limit=512 choisit le palier 512 Mo et 280 mvCPU : une photo de 12 millions de pixels décodée occupe environ 36 Mo en RGB, et Python, boto3 et Pillow chargés en occupent plusieurs dizaines.
  • privacy=private : la fonction n'a aucune raison d'être appelée depuis Internet. Le déclencheur, lui, passe par la plateforme.
  • max-scale=10 : au-delà, le déclencheur n'enverrait de toute façon pas plus de dix requêtes en vol.

Relier la file

$ scw function trigger create name=photos function-id="$FN_ID" \
    scw-sqs-config.queue=photos-a-traiter \
    scw-sqs-config.mnq-project-id="$PROJET" \
    scw-sqs-config.mnq-region=fr-par \
    region=fr-par

Pour tester, déposez une photo dans le bucket, publiez le message à la main, puis cherchez la vignette :

$ aws s3 cp essai.jpg "s3://$BUCKET/photos/essai/essai.jpg" --profile scw
$ aws sqs send-message --queue-url "$URL_FILE" --profile mnq \
    --endpoint-url https://sqs.mnq.fr-par.scaleway.com \
    --message-body "{\"bucket\": \"$BUCKET\", \"cle\": \"photos/essai/essai.jpg\"}"
$ aws s3 ls "s3://$BUCKET/vignettes/essai/" --profile scw

Publiez le même message une seconde fois : les journaux de la fonction, dans Cockpit, doivent montrer vignette déjà présente. Publiez un message dont la clé désigne un fichier texte : image refusée, sans réessai.

Le job d'export

Le job interroge l'API publique de Signalements et écrit un CSV par jour. Pourquoi pas directement la base ? Parce que les jobs ne peuvent pas joindre un réseau privé au 5 octobre 2026, et que rouvrir un point d'accès public à la base pour un export nocturne, avec des adresses source imprévisibles, défairait tout le travail du cours précédent. Passer par l'API est d'ailleurs un meilleur découpage : c'est elle qui connaît les données.

Le script, exporter.py :

"""Job Serverless : exporte chaque nuit les signalements en CSV dans le bucket.

Variables attendues :
  SIGNALEMENTS_URL   adresse publique de l'API, par exemple https://signalements.exemple.fr
  BUCKET             bucket de destination
  S3_ACCESS_KEY      clé d'accès de l'application sig-export (référence Secret Manager)
  S3_SECRET_KEY      clé secrète de l'application sig-export (référence Secret Manager)
Code de sortie : 0 si l'export est écrit, 1 sinon (le job est alors réessayé).
"""
import csv
import datetime
import io
import json
import os
import sys
import urllib.request

import boto3


def lire_signalements(url):
    requete = urllib.request.Request(url + "/signalements", headers={"Accept": "application/json"})
    with urllib.request.urlopen(requete, timeout=30) as reponse:
        return json.load(reponse)


def en_csv(signalements):
    tampon = io.StringIO()
    ecrivain = csv.DictWriter(tampon, fieldnames=["id", "lieu", "description"], extrasaction="ignore")
    ecrivain.writeheader()
    ecrivain.writerows(signalements)
    return tampon.getvalue().encode("utf-8")


def main():
    jour = datetime.date.today().isoformat()
    # Une clé par jour : relancer le job le même jour réécrit le même fichier.
    cle = f"exports/{jour}.csv"
    signalements = lire_signalements(os.environ["SIGNALEMENTS_URL"])
    s3 = boto3.client(
        "s3",
        endpoint_url="https://s3.fr-par.scw.cloud",
        region_name="fr-par",
        aws_access_key_id=os.environ["S3_ACCESS_KEY"],
        aws_secret_access_key=os.environ["S3_SECRET_KEY"],
    )
    s3.put_object(Bucket=os.environ["BUCKET"], Key=cle, Body=en_csv(signalements),
                  ContentType="text/csv; charset=utf-8")
    print(f"{len(signalements)} signalements exportés dans {cle}", flush=True)


if __name__ == "__main__":
    try:
        main()
    except Exception as erreur:  # noqa: BLE001, tout échec doit donner un code non nul
        print(f"échec de l'export : {erreur}", file=sys.stderr, flush=True)
        sys.exit(1)

Le contrat avec la plateforme tient dans la dernière ligne : un échec, quel qu'il soit, sort avec le code 1, que la politique de réessai sait lire. L'idempotence vient du nom du fichier : relancer l'export le même jour réécrit le même objet. Le Dockerfile est minimal, et ne tourne pas en root :

FROM python:3.13-slim
RUN pip install --no-cache-dir boto3==1.43.24
WORKDIR /app
COPY exporter.py .
USER 65534
ENTRYPOINT ["python3", "/app/exporter.py"]

L'image, construite pour linux/amd64, est poussée dans le registre (leçon 7) sous rg.fr-par.scw.cloud/signalements-<suffixe>/signalements-export:1.0.0.

Les secrets du job

Les deux clés de sig-export vont dans Secret Manager (leçon 8), sous le chemin /signalements/export :

$ SEC_ACCES=$(scw secret secret create name=s3-access-key path=/signalements/export \
    type=opaque project-id="$PROJET" region=fr-par -o json | jq -r .id)
$ SEC_SECRETE=$(scw secret secret create name=s3-secret-key path=/signalements/export \
    type=opaque project-id="$PROJET" region=fr-par -o json | jq -r .id)
$ read -rs -p "Clé d'accès de sig-export : " V; echo
$ scw secret version create "$SEC_ACCES" data="$V" region=fr-par > /dev/null
$ read -rs -p "Clé secrète de sig-export : " V; echo
$ scw secret version create "$SEC_SECRETE" data="$V" region=fr-par > /dev/null; unset V

Définir, référencer, planifier

$ JOB_ID=$(scw jobs definition create name=sig-export \
    image-uri=rg.fr-par.scw.cloud/signalements-<suffixe>/signalements-export:1.0.0 \
    cpu-limit=280 memory-limit=512 local-storage-capacity=1000 \
    job-timeout=600s retry-policy.max-retries=3 \
    environment-variables.SIGNALEMENTS_URL=https://signalements.exemple.fr \
    environment-variables.BUCKET="$BUCKET" \
    project-id="$PROJET" region=fr-par -o json | jq -r .id)
$ scw jobs secret create job-definition-id="$JOB_ID" \
    secrets.0.secret-manager-id="$SEC_ACCES" secrets.0.secret-manager-version=1 \
    secrets.0.env-var-name=S3_ACCESS_KEY \
    secrets.1.secret-manager-id="$SEC_SECRETE" secrets.1.secret-manager-version=1 \
    secrets.1.env-var-name=S3_SECRET_KEY \
    region=fr-par
$ scw jobs trigger create job-definition-id="$JOB_ID" name=chaque-nuit \
    cron-config.schedule='30 2 * * *' cron-config.timezone=Europe/Paris \
    region=fr-par
  • cpu-limit=280, memory-limit=512 : en mvCPU et en Mio. L'export lit quelques milliers de lignes ; il n'a pas besoin des valeurs par défaut (1 120 mvCPU et 2 048 Mo).
  • job-timeout=600s : dix minutes au plus. Sans limite raisonnable, un appel bloqué occuperait la plateforme jusqu'à 24 heures, à vos frais.
  • retry-policy.max-retries=3 : une API momentanément indisponible à 2 h 30 n'empêche pas l'export.
  • secret-manager-version=1 : la référence est épinglée sur une version. Changer de clé (leçon 8) sera un acte explicite : créer la version 2, puis mettre à jour la référence.
  • Le déclencheur porte son fuseau : 2 h 30, heure de Paris, été comme hiver.

Lancez une exécution à la main pour vérifier, et attendez son résultat :

$ RUN_ID=$(scw jobs definition start "$JOB_ID" region=fr-par -o json | jq -r '.job_runs[0].id')
$ scw jobs run wait "$RUN_ID" region=fr-par
$ scw jobs run get "$RUN_ID" region=fr-par -o json | jq '{state, reason, exit_code, attempts}'

state vaut succeeded ou failed, exit_code porte le code du script, reason la cause côté plateforme (exited_with_error, timeout, secret_not_found, image_not_found...), attempts le nombre d'essais. Pour lister les échecs de la semaine : scw jobs run list job-definition-id="$JOB_ID" state=failed region=fr-par.

Sous le capot

Ce que la plateforme fait de votre zip. À chaque déploiement, Scaleway construit, à partir de l'archive et du runtime choisi, l'image qui exécutera la fonction ; la construction peut durer jusqu'à 40 minutes au plus selon la documentation. Le runtime embarque un petit serveur HTTP qui reçoit les invocations, construit le dictionnaire event et appelle votre gestionnaire. Une fonction est donc un conteneur dont vous ne fournissez que le code : c'est pourquoi on peut, selon la documentation, migrer une fonction vers Serverless Containers quand le runtime devient trop étroit.

Le déclencheur est un consommateur de file. La plateforme lit la file pour vous, envoie chaque message en POST à la fonction, et ne retire le message de la file qu'une fois la réponse obtenue. La contre-pression, ne lire que ce que l'on peut traiter, évite de vider la file dans des instances saturées. Le réessai en cas de code supérieur ou égal à 300 est celui du déclencheur, pas de la file. La file peut, elle, avoir sa propre file des messages non livrés (leçon 9), utile pour ne pas perdre ce qui échoue trois fois.

Le job est un conteneur qui ne sert rien. Une exécution démarre le conteneur dans un bac à sable gVisor, lui passe la commande et les variables, lit les secrets référencés dans Secret Manager au démarrage et les injecte, puis attend la fin du processus principal. Son code de sortie devient celui de l'exécution. Il n'y a ni port, ni sonde, ni mise à l'échelle : seulement un nombre de répliques choisi au lancement (replicas), pour paralléliser un traitement en plusieurs exécutions identiques.

Pièges courants

Handler not found ou une construction en échec. Le zip contient un dossier de trop (compression graphique), ou le gestionnaire ne correspond pas au chemin du fichier. Listez l'archive avec unzip -l : handler.py doit être à la racine.

Pillow ne se charge pas. Les dépendances ont été installées avec le pip du poste, pour une autre version de Python ou une autre bibliothèque C. Installez-les avec l'image python-dep de la version du runtime.

Too many retries, sub-runtime server did not come up. Le runtime n'a pas pu démarrer le serveur de la fonction à temps : trop de bibliothèques importées, trop de travail à l'initialisation, ou trop peu de ressources. La documentation recommande de n'importer que le nécessaire et de monter d'un palier.

Les mêmes vignettes refaites, ou une file qui ne se vide jamais. Un traitement non idempotent refait son travail à chaque livraison ; un traitement qui renvoie 500 sur une erreur définitive est réessayé trois fois pour rien. Les deux défauts ont la même cause : ne pas avoir pensé la livraison au moins une fois.

Des messages perdus après un orage. La rétention de la file était plus courte que le temps de traitement du pic, compte tenu des 10 requêtes en vol. Recalculez avec la formule de la documentation, et prévoyez une file des messages non livrés.

Un job qui « réussit » sans rien faire. Le script attrape une exception, l'affiche, et sort avec 0. La plateforme ne lit que le code de sortie : un échec doit sortir avec un code non nul.

Un job qui ne joint pas la base. Pas de réseau privé pour les jobs au 5 octobre 2026. Passez par l'API, ou confiez la tâche à un conteneur serverless rattaché au réseau privé et déclenché par un horaire (leçon 5).

Une tâche à 2 h 30 qui s'exécute à 3 h 30. Un horaire sans fuseau est interprété en UTC pour certains services ; précisez toujours Europe/Paris, ou travaillez explicitement en UTC.

Sécurité

Des identités par traitement. La fonction et le job ont chacun leur application IAM, leur clé, et les seuls droits nécessaires. Le jour où l'une des clés fuit, on la révoque sans toucher à l'autre, et le rayon d'impact se limite à un préfixe du bucket, pas à tout le projet.

Les entrées d'une file sont des entrées non fiables. Le message vient de l'API, mais la file peut recevoir des messages de quiconque détient des identifiants de publication. La fonction valide le format, refuse les clés hors de photos/, et se protège contre les fichiers piégés : les bombes de décompression d'images sont un vecteur connu de déni de service, et les bibliothèques de traitement d'images ont régulièrement des vulnérabilités ; épinglez Pillow et mettez-le à jour (cours Gestion des vulnérabilités).

Secrets : deux régimes. Les variables secrètes d'une fonction sont propres au service et se renseignent par la CLI ou la console : quiconque peut modifier la fonction peut les changer. Les références de secrets d'un job lisent Secret Manager à chaque exécution : la rotation se fait dans Secret Manager, et l'accès se contrôle par IAM, jusqu'au secret près (leçon 8).

Un job tourne en root par défaut. Sans privilèges ni capabilities, mais root quand même dans son conteneur. USER 65534 dans l'image ne coûte rien.

En production

  • Mesurez avant de choisir le palier. La durée moyenne d'une invocation et sa mémoire maximale, visibles dans Cockpit, fixent le palier ; trop petit, la fonction est lente ou tuée, trop grand, elle coûte plus cher par seconde.
  • Surveillez la file plutôt que la fonction. L'âge du plus vieux message en attente dit, mieux que tout, si le traitement suit (leçons 9 et 14).
  • Suivez le cycle de vie des runtimes. Une alerte de calendrier sur la fin de support de python313, et une ligne dans la feuille de route de l'équipe.
  • Alertez sur les jobs en échec. Depuis août 2026, Serverless Jobs propose des alertes préconfigurées dans Cockpit ; un export qui échoue trois nuits de suite ne doit pas être découvert par la métropole cliente.
  • Quand revenir à une instance. Un traitement qui tourne en continu, qui a besoin d'un GPU, d'un disque persistant ou du réseau privé dans les deux sens, coûtera moins cher et sera plus simple sur une instance ou dans Kapsule. Le serverless est imbattable pour le travail intermittent.

Exercices

1. Choisir le service (niveau 200). Pour chaque besoin, choisissez fonction, conteneur serverless, job ou instance, et justifiez par une ligne du tableau des concepts : (a) recalculer chaque nuit des statistiques en lisant directement la base PostgreSQL, sans point d'accès public ; (b) un webhook appelé par un fournisseur de SMS quand un message est délivré ; (c) réindexer 2 millions de photos, environ 3 heures de calcul ; (d) un service de conversion de documents qui dépend de LibreOffice.

Solution

(a) Un conteneur serverless rattaché au réseau privé et déclenché par un horaire, ou une tâche sur une instance du réseau privé : un job ne joint pas le réseau privé au 5 octobre 2026. (b) Une fonction : un traitement court, HTTP, intermittent. Un conteneur conviendrait aussi. (c) Un job, éventuellement en plusieurs répliques : plus d'une heure de calcul exclut fonctions et conteneurs (60 minutes par requête). (d) Un conteneur serverless ou un job selon la forme d'invocation : LibreOffice ne fait partie d'aucun runtime de fonction.

2. Rétention de la file (niveau 200). Un orage provoque 12 000 envois de photos en dix minutes. Chaque vignette prend 1,5 seconde, un démarrage à froid 20 secondes. Avec les 10 requêtes en vol du déclencheur, calculez la rétention minimale de la file selon la formule de la documentation, et dites si une rétention de 15 minutes suffit.

Solution

Débit : (1 / 1,5) × 10 ≈ 6,7 messages par seconde. Pic : 12 000 messages. Rétention minimale : 12 000 / 6,7 + 20 ≈ 1 800 + 20 = 1 820 secondes, environ 30 minutes, sans compter que les messages arrivent étalés sur dix minutes (ce qui réduit un peu l'attente) et que la valeur de production doit être plus élevée que ce minimum. Quinze minutes ne suffisent pas : une partie des messages serait supprimée avant traitement. Une rétention d'une journée, peu coûteuse, règle la question.

3. Idempotence (niveau 200). Une collègue propose de nommer chaque vignette d'après l'heure de son calcul (vignettes/<horodatage>.jpg), « pour éviter d'écraser quoi que ce soit ». Que se passe-t-il quand un message est livré deux fois ? Proposez un nommage idempotent et expliquez pourquoi la vérification préalable par head_object est, en plus, utile.

Solution

Chaque livraison produit une nouvelle vignette : des doublons s'accumulent, le stockage grossit, et l'API ne sait pas laquelle afficher. Un nom déduit de celui de la photo (vignettes/ + chemin de la photo) rend le résultat identique quelle que soit la livraison : c'est l'idempotence. La vérification par head_object évite en plus de refaire le calcul, donc de payer l'invocation complète et de solliciter le stockage, quand la vignette existe déjà ; sans elle, le résultat serait juste mais plus cher.

Récapitulatif

  • Fonction : du code et un gestionnaire, une requête par instance, 60 minutes au plus, des runtimes à suivre. Conteneur : une image HTTP, concurrence réglable. Job : une image qui s'exécute jusqu'à sa fin, 24 heures au plus, code de sortie.
  • Les dépendances d'une fonction Python vont dans package/, installées avec l'image python-dep du runtime.
  • Un déclencheur par file livre au moins une fois, réessaie trois fois sur un code supérieur ou égal à 300, et ne garde que 10 requêtes en vol : le traitement doit être idempotent et distinguer erreurs passagères et définitives ; la rétention de la file se calcule.
  • Un job a une politique de réessai (jusqu'à cinq), plusieurs déclencheurs horaires avec fuseau, et des références de secrets Secret Manager, épinglées sur une version.
  • Au 5 octobre 2026, les jobs ne joignent pas les réseaux privés, et seuls les jobs lisent Secret Manager.
  • Une identité IAM par traitement, des entrées validées, et USER non privilégié dans les images.

Pour aller plus loin

  • Le dépôt serverless-examples de Scaleway, cité par la documentation, pour d'autres déclencheurs et langages.
  • La documentation d'Amazon SQS sur la livraison au moins une fois, qui vaut pour toute file compatible, et le principe des files de messages non livrés.
  • La leçon 9, qui détaille Scaleway Queues, NATS et Topics and Events.
  • La leçon 8, pour la rotation des clés référencées par le job.
Voir ma constellation →

Sources