Recette: Réduction des démarrages à froid avec la précompilation du bytecode en python serverless

Allez tout au bas si vous voulez sauter la portion narrative et aller directement à la recette.

C’est une histoire de développement logiciel: Vous avez un projet en terrain vierge. (« Greenfield project? ») C’est magnifique et rapide. Puis vous ajoutez des fonctionnalités. Pendant cinq années. Puis vous réalisez que vos démarrages à froid sont passés de ~1 seconde à ~5.5 secondes.

Pour être juste, une tonne de choses se sont passées pendant ces cinq années et je m’étais déjà rendu compte que les démarrages à froid étaient rendus péniblement longs. J’avais déjà pris des mesures pour les «contrôler» autant que possible. Même si mes tests locaux m’avaient déjà démontré que de compiler les bytecode devrait théoriquement améliorer la performance de façon significative, mes essais en production n’ont jamais porté fruit. Malgré la précompilation, la production restait lente.

Puis est arrivé l’IA et du temps libre. Et la conversation était à peu près la suivante

🧑‍🦱Moi: Hey IA! Quand je précompile localement python en bytecode, je vois des améliorations au démarrage à froid. Je ne vois pas le même impact lorsque c’est fait en production. Peux-tu me dire ce qui se passe?

🤖Agent IA: Eh bien duh! Serverless définit la date à 1970-01-01 sur tous les fichiers lorsqu’il crée le package, ce qui signifie que l’interpréteur python refait tout le bytecode chaque fois qu’il démarre.

Trouvé ici. In lib/plugins/package/lib/zip-service.js:97-101:

zip.append(file.data, {
  name,
  mode,
  date: new Date(0), // necessary to get the same hash when zipping the same content
});

🧑‍🦱 –> 🤯 J’ÉTAIS SI PRÈS! À un détail près.

Et comme l’indique le commentaire dans le code, le framework serverless le faisait intentionnellement.

Ainsi a débuté mon apprentissage du fonctionnement de la génération, de l’invalidation du bytecode python et surtout de PEP 552. Il y a 3 manières de générer le bytecode qui ont un impact direct sur leur mode d’invalidation.

  1. Basé sur la date (TIMESTAMP): if .py timestamp >= .pyc timestamp, régénère.
  2. Basé sur un hash (CHECK_HASH): calcule le hash du fichier .py et met-le dans le fichier .pyc. S’ils ne correspondent pas, régénère.
  3. Fais-nous confiance, ne regarde pas (UNCHECKED_HASH): S’il y a un fichier .pyc, accepte-le aveuglément. Ne régénère rien.

L’option 1 ne fonctionne pas dans notre contexte, car le modèle « serverless » fixe la date au 1970-01-01. L’option basée sur le hash fonctionne, mais l’interpréteur passera du temps à recalculer le hash de tous les fichiers .py pour se rendre compte qu’il n’y a rien à régénérer à chaque démarrage à froid. L’option « Faites-nous confiance » est la plus rapide, car elle ne fait rien.

Pour donner du contexte, j’utilise le plugin serverless-python-requirements, qui regroupe toutes les dépendances python dans un fichier zip, avec l’application elle-même. J’ai déterminé qu’en utilisant le plugin serverless-scriptable-plugin, je pouvais générer les fichiers de bytecode. Il fallait alors choisir : le faire avant la création du fichier zip (via before:package:createDeploymentArtifacts) ou après (via after:package:createDeploymentArtifacts).

Dans les deux cas, tous les paquets tiers peuvent être précompilés en mode UNCHECKED_HASH, puisque la précompilation s’effectue dans le répertoire temporaire .serverless. Pour le code de notre application, c’est une autre histoire. Si nous utilisons le hook before, la compilation doit s’effectuer sur place, c’est-à-dire en dehors du répertoire .serverless, ce qui signifie que le mode UNCHECKED_HASH est risqué, car la mise à jour de nos fichiers de code .py ne régénérerait pas les fichiers .pyc… une source certaine de confusion. Le mode CHECK_HASH semble donc plus approprié dans ce cas, car il permet d’atteindre l’objectif avec un compromis minime. Un compromis d’autant plus minime que je soupçonne la plupart des applications d’avoir bien plus de fichiers de bytecode dans leurs dépendances que dans leurs applications elles-mêmes.

Le hook after est déclenché une fois que le fichier zip final a été créé. Il est donc possible de le décompresser, de générer tout le bytecode avec UNCHECKED_HASH, puis de le recompresser. Le bytecode est ainsi optimisé, au détriment du temps de «génération» (build).

Au final, j’ai opté pour le hook avant, car le gain en termes de démarrage à froid de l’option after était négligeable, et étant donné que nous utilisons des machines de compilation peu coûteuses, la minute supplémentaire de temps de build n’en valait pas la peine.

Je n’ai pas noté tous les chiffres, mais en voici quelques-uns, tous relevés sur la même fonction après un nombre suffisant de démarrages à froid pour que les résultats soient statistiquement significatifs.

DescriptionMédiane msRéduction % par rapport à la référence
Référence : pas de bytecode précompilé5485
Tout précompilé avec CHECK_HASH322941.13
Paquets tiers précompilés avec UNCHECKED_HASH, code applicatif précompilé avec CHECK_HASH305644.28

Recette

Cette recette a été testée avec osls v3.76.1, v4.0.0 et v4.1.0. Si vous ne savez pas ce qu’est osls, il s’agit d’une version basée sur la dernière version v3 du très populaire framework Serverless. Je vous le recommande vivement si vous cherchez un moyen de bénéficier des mises à jour de sécurité tout en restant sur la version « v3 ». Si vous utilisez encore la version officielle de Serverless, cette recette fonctionnera très probablement aussi avec cette version v3, mais je ne l’aie pas testée avec la version v4.

1. Installez serverless-scriptable-plugin et ajoutez-le à votre fichier serverless.yml sous plugins.
2. Toujours dans serverless.yml , sous custom, ajoutez ceci (ajustez vos répertoires):

  scriptHooks:
    before:package:createDeploymentArtifacts: bash precompile_bytecode.sh
      public.ecr.aws/sam/build-${self:provider.runtime}

3. Le code pour precompile_bytecode.sh (Et oui, mon bash-fu n’est pas tellement bon. J’ai utilisé un agent de codage.)

#!/usr/bin/env bash

# Robot Generated
# Precompiles Python bytecode in place, in the deps and app-source directories, before Serverless
# zips them up. Must run on `before:package:createDeploymentArtifacts`, after
# `serverless-python-requirements` has installed and slimmed its deps directory but before
# Serverless (and that same plugin's own after-hook, which injects the deps into the zip) touch
# either directory.
#
# Usage: precompile_bytecode.sh <docker-image> <source-dir>
#
# <docker-image> must match the deployed Lambda runtime's Python build exactly, or the bytecode
# magic number won't match and Python would silently recompile from source at runtime instead of
# using the cache -- fails the build below rather than letting that go unnoticed.
set -u

docker_image="${1:?usage: $0 <docker-image> <source-dir>}"
source_dir="${2:?usage: $0 <docker-image> <source-dir>}"
deps_dir=.serverless/requirements

# Passed as real argv words to the container (mode/dir pairs), not interpolated into the `sh -c`
# script text below, so directory names can't be misparsed as shell syntax.
targets=()
present_targets=""
for spec in "unchecked-hash:$deps_dir" "checked-hash:$source_dir"; do
  mode="${spec%%:*}"
  dir="${spec#*:}"
  [ -d "$dir" ] || continue
  targets+=("$mode" "$dir")
  present_targets+="$dir:$mode "
done

if [ "${#targets[@]}" -eq 0 ]; then
  echo "precompile_bytecode: neither $deps_dir nor $source_dir found, nothing to do"
  exit 0
fi

start_time=$(date +%s)

if docker run --rm --platform linux/amd64 \
  --user "$(id -u):$(id -g)" \
  -v "$(pwd):/var/task" -w /var/task \
  "$docker_image" \
  sh -c '
    set -e
    while [ "$#" -ge 2 ]; do
      mode="$1"; dir="$2"; shift 2
      python3 -m compileall -q -j 0 --invalidation-mode "$mode" -- "$dir"
    done
  ' sh "${targets[@]}"
then
  echo "precompile_bytecode: added bytecode to $present_targets"
  failed=0
else
  echo "precompile_bytecode: ERROR - failed to precompile $present_targets" >&2
  echo "You may disable this script in serverless.yml to no longer precompile."
  failed=1
fi

end_time=$(date +%s)
echo "precompile_bytecode: completed in $((end_time - start_time))s"

exit "$failed"

Image de couverture: Gros plan sur des glaçons qui fondent sur une surface noire par Ray Suarez

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.