Aller au contenu

Contraintes de la plateforme

Les mécanismes qui suivent sont tous documentés dans la spécification CWL, mais on ne pense que rarement à les utiliser d'emblée. Ce sont pourtant les causes d'échec les plus fréquentes sur la plateforme, et elles ont un point commun déroutant : le workflow reste valide, il échoue tard, et le message d'erreur ne désigne presque jamais la vraie cause.

AntFlow ne sait pas alimenter une entrée File

Entrées File

Un workflow dont une entrée est de type File est du CWL parfaitement valide ; simplement, l'interface ne propose pas de téléverser un fichier. Le workflow n'est donc pas lançable, alors que rien, ni dans le CWL ni à la validation, ne signale quoi que ce soit.

La parade tient en un motif, le stage-in : l'entrée du workflow devient une string - un chemin S3, qu'AntFlow sait très bien transmettre - et une première étape se charge de la transformer en fichiers locaux.

inputs:
  entree_path:
    type: string
    default: "s3://<votre-bucket>/donnees/entree.tif"

steps:
  stage_in:            # string -> Directory
    run: stage_in.cwl
  traitement:          # consomme le répertoire
    in:
      entree: stage_in/donnees

Le CommandLineTool du traitement reçoit un vrai répertoire : la contrainte est absorbée par l'étape de stage-in, sans contaminer le reste du workflow.

Exemple de script de stage-in

Le script copie un fichier (s3://bucket/dossier/entree.tif) ou tout un répertoire (s3://bucket/dossier/) dans le répertoire de sortie de l'étape.

#!/usr/bin/env python3
"""Stage-in : copie un fichier ou un répertoire S3 dans un répertoire local."""
import argparse
import json
from pathlib import Path
from urllib.parse import urlparse


def make_client(config_path):
    import boto3  # importé ici : le reste du script reste testable sans boto3

    # JSON aux clés du client boto3 : endpoint_url, region_name, aws_access_key_id…
    cfg = json.loads(Path(config_path).read_text()) if config_path else {}
    print(f"s3 endpoint={cfg.get('endpoint_url')}")  # jamais le secret
    return boto3.client("s3", **cfg)


def main():
    p = argparse.ArgumentParser(description=__doc__)
    p.add_argument("--entree_path", required=True, help="s3://bucket/fichier ou s3://bucket/dossier/")
    p.add_argument("--s3-config", type=Path, help="fichier JSON de config S3")
    p.add_argument("--output-dir", default="donnees")
    a = p.parse_args()

    u = urlparse(a.entree_path)
    bucket, key = u.netloc, u.path.lstrip("/")
    s3 = make_client(a.s3_config)

    # Répertoire : toutes les clés sous le préfixe. Fichier : la clé elle-même.
    prefix = key.rstrip("/") + "/"
    pages = s3.get_paginator("list_objects_v2").paginate(Bucket=bucket, Prefix=prefix)
    keys = [o["Key"] for page in pages for o in page.get("Contents", [])] or [key]

    for k in keys:
        dst = Path(a.output_dir) / (k[len(prefix):] if k.startswith(prefix) else Path(k).name)
        dst.parent.mkdir(parents=True, exist_ok=True)
        s3.download_file(bucket, k, str(dst))
        print(f"ok   s3://{bucket}/{k} -> {dst}")


if __name__ == "__main__":
    main()

Plusieurs choix d'écriture le rendent exploitable par la plateforme, et méritent d'être repris dans vos propres scripts :

Choix Pourquoi
Sortie dans --output-dir relatif Le CWL récupère les sorties dans le répertoire de travail de l'étape ; un chemin absolu écrirait en dehors et le fichier serait perdu.
Aucune erreur S3 rattrapée Un téléchargement qui échoue arrête le script avec un code de retour non nul : c'est le seul signal d'échec que voit le moteur.
Endpoint affiché, secret jamais Distingue « mauvais endpoint » de « mauvaise clé » dans les journaux du conteneur.
Config lue depuis un JSON Le JSON est déposé par InitialWorkDirRequirement depuis un secret.
import boto3 dans la fonction Le reste du script reste testable sans dépendance S3.

Reste à décrire ce script en CWL. Le tool déclare en sortie le répertoire entier : un fichier seul, ou un format multi-fichiers comme le shapefile et ses annexes, y arrivent ensemble.

cwlVersion: v1.2
class: CommandLineTool
label: Stage-in depuis S3

baseCommand: [python3, /app/stage_in.py]

requirements:
  DockerRequirement:
    dockerPull: registry.gitlab.com/<groupe>/<projet>:0.1.0
  NetworkAccess:
    networkAccess: true            # indispensable : l'étape sort du conteneur
  InitialWorkDirRequirement:
    listing:
      - entryname: s3_config.json
        entry: $(inputs.s3_config)
  ResourceRequirement:
    ramMin: 1024
    ramMax: 1024
    coresMin: 0.5                  # attend surtout le réseau
    coresMax: 1

inputs:
  entree_path:
    type: string
    inputBinding:
      prefix: --entree_path
  s3_config:
    type: string                   # alimenté par un secret

arguments:
  - prefix: --s3-config
    valueFrom: s3_config.json
  - prefix: --output-dir
    valueFrom: donnees

outputs:
  donnees:
    type: Directory
    outputBinding:
      glob: donnees

Si l'étape suivante attend un fichier unique, déclarez plutôt la sortie en type: File avec glob: donnees/*.

Les secrets (S3 ou autre)

Une chaîne a souvent besoin d'identifiants : clés S3, jeton d'API, mot de passe. Ils ne doivent apparaître ni dans le code, ni dans l'image, ni dans le dépôt, ni dans une entrée ordinaire du workflow, qui les afficherait dans le formulaire et dans les journaux. AntFlow propose deux façons de les fournir :

Coffre Keycloak Variables CI/CD GitLab
Qui crée le secret chaque utilisateur, dans son profil AntFlow le mainteneur du projet GitLab
Portée les exécutions lancées par cet utilisateur toutes les exécutions du projet
Arrivée dans le workflow valeur d'une entrée déclarée secrète (cwltool:Secrets) variable d'environnement des étapes
Convient à des identifiants propres à chaque utilisateur des identifiants de service, communs au projet

Depuis le coffre Keycloak : une entrée secrète

  1. Créez le secret dans votre coffre, depuis votre profil AntFlow (voir Manage workflow secrets).
  2. Déclarez l'entrée comme secrète avec l'extension cwltool:Secrets, au niveau du workflow :

    cwlVersion: v1.2
    class: Workflow
    
    $namespaces:
      cwltool: http://commonwl.org/cwltool#
    
    hints:
      cwltool:Secrets:
        secrets: [s3_config]
    
    inputs:
      s3_config:
        type: string
        label: Configuration S3 (secret)
    
  3. Au lancement, le formulaire propose pour cette entrée la liste des secrets de votre coffre. Vous choisissez un nom : AntFlow transmet la valeur correspondante au workflow, sans qu'elle apparaisse dans le formulaire ni dans les journaux.

$namespaces indispensable

Sans lui, cwltool:Secrets n'est pas résolu. À la validation locale, cwltool signale Unknown hint http://commonwl.org/cwltool#Secrets : c'est attendu et sans conséquence, le hint étant interprété par la plateforme.

Depuis GitLab : une variable d'environnement

  1. Créez une variable CI/CD dans le projet GitLab de la chaîne (Settings → CI/CD → Variables), avec un nom qui commence par CWL__ (deux tirets bas) : CWL__AWS_CONFIG.
  2. AntFlow retire le préfixe et l'injecte dans l'environnement de chaque étape : CWL__AWS_CONFIG devient AWS_CONFIG. Les variables sans ce préfixe restent dans GitLab.
  3. Lisez-la dans le script, comme n'importe quelle variable d'environnement :

    import json, os
    
    cfg = json.loads(os.environ["AWS_CONFIG"])
    

En local, cwltool ne transmet pas l'environnement aux étapes : précisez-le avec cwltool --preserve-environment AWS_CONFIG ….

Écrire un secret dans un fichier

Beaucoup de scripts attendent un fichier (s3_config.json) plutôt qu'une valeur. Deux façons de le produire :

Façon Quand la choisir
Dans l'étape qui le consomme une seule étape utilise le secret, qui arrive par une entrée
Dans une étape dédiée, qui sort le fichier en File plusieurs étapes utilisent le secret, ou il arrive par une variable d'environnement

Dans l'étape qui le consomme

InitialWorkDirRequirement écrit le fichier dans le répertoire de travail de l'étape, juste avant son exécution, à partir de l'entrée secrète :

requirements:
  InitialWorkDirRequirement:
    listing:
      - entryname: s3_config.json
        entry: $(inputs.s3_config)

Rien ne circule entre les étapes : c'est la façon la plus simple.

Dans une étape dédiée

Une petite étape écrit le secret dans un fichier et le déclare en sortie File. Les étapes suivantes le reçoivent comme n'importe quel fichier :

- id: make_secret
  class: CommandLineTool
  label: Écrit le secret S3 dans un fichier
  requirements:
    InitialWorkDirRequirement:
      listing:
        - entryname: s3_config.json
          entry: $(inputs.s3_config)
  baseCommand: ["true"]          # le fichier est déjà écrit, rien à exécuter
  inputs:
    s3_config:
      type: string               # entrée déclarée dans cwltool:Secrets
  outputs:
    s3_config_file:
      type: File
      outputBinding:
        glob: s3_config.json
- id: make_secret
  class: CommandLineTool
  label: Écrit la variable AWS_CONFIG dans un fichier
  baseCommand: ["sh", "-c"]
  arguments:
    # échoue si la variable est absente, plutôt que de produire un fichier vide ;
    # la valeur n'est jamais affichée
    - valueFrom: >-
        [ -n "$AWS_CONFIG" ] || { echo "AWS_CONFIG absente" >&2; exit 1; };
        printf '%s' "$AWS_CONFIG" > s3_config.json
  inputs: []
  outputs:
    s3_config_file:
      type: File
      outputBinding:
        glob: s3_config.json

Dans le workflow, chaque étape qui en a besoin branche cette sortie :

steps:
  make_secret:
    run: "#make_secret"
    in: {}                           # ou { s3_config: s3_config } depuis le coffre
    out: [s3_config_file]
  traitement:
    run: "#traitement"
    in:
      s3_config: make_secret/s3_config_file
    out: [produit]

Chaque étape tourne dans un pod distinct : le fichier passe donc par le volume partagé du workflow. Ne le déclarez jamais en sortie du workflow, sans quoi il serait publié avec les résultats.

Bonnes pratiques

  • Jamais de secret en clair : ni dans le code, ni dans l'image, ni dans un default du CWL, ni dans une sortie du workflow.
  • Un secret par usage, avec un nom explicite : s3-cs-group, oidc-catalogue.
  • Affichez le contexte, jamais la valeur : l'endpoint S3 dans les journaux, pas la clé.
  • Le secret protège la valeur en amont (formulaire, journaux) ; il ne chiffre pas le fichier une fois déposé dans l'étape.

Dans la documentation AntFlow

ResourceRequirement - mémoire et CPU

Si votre traitement charge des données volumineuses en mémoire, cette section est sans doute la plus utile de la page. Faute de déclaration, l'étape hérite de la limite par défaut du cluster, souvent basse ; dès que le traitement la dépasse, le noyau tue le pod sans sommation (OOMKilled).

Le symptôme a de quoi égarer : aucune trace Python, aucun message applicatif, juste une étape qui se termine sur un code non nul. Rien n'indique que la mémoire est en cause, et le même traitement passe sans difficulté sur votre poste, où il dispose de toute la RAM de la machine.

requirements:
  ResourceRequirement:
    ramMin: 1024      # Mo - mémoire requise pour démarrer
    ramMax: 3026      # Mo - plafond alloué à l'étape
    coresMin: 1
    coresMax: 2
Champ Rôle
Min Conditionne la planification : la plateforme attend un nœud avec au moins cela de libre. Le fixer haut retarde le démarrage sans rien accélérer.
Max Plafond alloué. C'est lui qui protège de l'OOM - et lui qu'il faut relever quand un pod est tué.

D'où l'asymétrie à viser : un Min modeste pour être planifié rapidement, un Max confortable pour ne pas être tué en cours de route. Notez que les cœurs acceptent des valeurs fractionnaires (coresMin: 0.5), ce qui est tout indiqué pour une étape qui passe l'essentiel de son temps à attendre le réseau.

Comment choisir ces valeurs. Ce sont des ordres de grandeur, pas une mesure. La règle empirique : comptez plusieurs fois la taille du jeu de données sur disque. Les données compressées ou sérialisées occupent couramment 5 à 10 fois leur poids en fichier une fois chargées en mémoire (raster décompressé, géométries vectorielles désérialisées), et le traitement lui-même demande sa propre marge. En cas de doute, voyez large sur le Max : sur-réserver coûte peu, sous-réserver fait tuer le pod.

Pour mesurer plutôt qu'estimer :

docker run --rm -v "$PWD/data:/data:ro" <image> \
    /usr/bin/time -v python3 /app/mon_traitement.py /data/entree.tif sortie.tif \
    2>&1 | grep 'Maximum resident'

Maximum resident set size donne le pic réel en kilo-octets ; prenez environ le double pour ramMax.