Migrer un applicatif d'AWS Lambda vers Scaleway

Nos applicatifs tournaient sur AWS depuis plusieurs années. Puis une exigence est arrivée, non négociable : pouvoir les opérer sur un cloud souverain européen. La contrainte n'était pas de quitter AWS mais de ne pas en dépendre, c'est-à-dire de garder une seule base de code applicative, capable de tourner aussi bien sur AWS que sur Scaleway.
Pourquoi Scaleway
La contrainte de départ était la souveraineté : un cloud européen, des données hébergées en Europe, une réversibilité assumée. Ce critère seul ne désigne pas Scaleway. L'écosystème européen ne manque pas d'acteurs crédibles : OVHcloud, 3DS Outscale (qualifié SecNumCloud), Clever Cloud ou encore Infomaniak. Nous ne cherchions pas le « meilleur » cloud dans l'absolu, mais celui qui minimisait le coût de portage d'une application pensée pour AWS.
C'est là que Scaleway s'est détaché, pour trois raisons concrètes. D'abord un catalogue serverless proche du modèle AWS qui nous évitait de repenser l'architecture. Ensuite des APIs compatibles : Object Storage compatible S3, messaging compatible SQS/SNS, autant de code en moins à réécrire. Enfin une chaîne d'outillage mature (CLI, SDK, provider Terraform) pour industrialiser le déploiement comme sur AWS. D'autres auraient pu convenir ; celui-ci collait le mieux à notre existant. Le reste de l'article raconte ce qu'il a fallu faire, concrètement, pour qu'un applicatif pensé pour Lambda vive sur Scaleway sans réécrire sa logique métier.
Trois portes d'entrée serverless
Scaleway propose trois briques serverless, et le premier travail a été de choisir la bonne.
| Critère | Functions | Containers | Jobs |
|---|---|---|---|
| Ce que vous livrez | Du code dans un langage supporté | N'importe quelle image de conteneur | N'importe quelle image de conteneur |
| Langages | Node, PHP, Python, Go, Rust | N'importe lequel | N'importe lequel |
| Conteneurs personnalisés | Non | Oui | Oui |
| Modèle d'exécution | Requête-réponse, événementiel | Service HTTP stateless | Batch jusqu'à la fin |
| Durée d'exécution max | Jusqu'à 1 h par requête | Jusqu'à 1 h par requête | Jusqu'à 24 h |
| Déclencheurs | HTTP, CRON, messaging | HTTP, CRON, messaging | Manuel ou planifié |
| Idéal pour | Code événementiel léger | APIs, microservices, apps web | Data processing, migrations, batch |
Référence : « Differences between Serverless Jobs, Containers and Functions », scaleway.com (nouvelle fenêtre)
Deux de ces portes se sont refermées vite, et pour de bonnes raisons.
Functions ne prend pas d'image personnalisée. On y déploie du code dans un langage supporté (Node, PHP, Python, Go, Rust), sans fournir sa propre image ni son entrypoint. C'est le point bloquant pour nous : notre application devait être livrée comme une image maison, réutilisable telle quelle. S'y ajoutent une exécution limitée à une requête par instance et une inadéquation aux tâches longues en arrière-plan. Très pratique pour du code événementiel léger, trop contraint pour notre cas.
Jobs répond à un tout autre besoin. C'est le modèle des traitements batch qui s'exécutent jusqu'à leur terme, jusqu'à 24 heures, en dehors du schéma requête-réponse. Idéal pour une migration ou du data processing, mais inutilisable pour exposer une API : il n'y a tout simplement pas d'endpoint HTTP à appeler.
Restent les Containers : n'importe quelle image, n'importe quel langage, aucune durée d'exécution maximale, autoscaling, et un vrai modèle requête-réponse web. C'est la seule brique qui nous laissait livrer notre propre image et y glisser la couche d'adaptation dont nous avions besoin. Le choix était fait.
La bascule : le maillon invisible qui disparaît
Sur AWS, ce qu'on oublie, c'est qu'une Lambda ne reçoit jamais une requête HTTP.
Une Lambda ne reçoit pas une requête, elle reçoit un événement : un objet structuré fabriqué par l'API Gateway, qui assure aussi le routage, le CORS et la mise en forme des erreurs.
Sur Scaleway Containers, ce maillon n'existe pas : le conteneur reçoit un simple flux HTTP sur un port, et c'est tout.
01_bascule_aws_scaleway.png
Figure 1 : Sur AWS, une étape supplémentaire (l'API Gateway) s'intercale entre le client et le code ; sur Scaleway, le client atteint directement le conteneur qui porte le code applicatif.
Le point important : le code applicatif doit être identique des deux côtés. Ce qui change, c'est l'emballage. On construit deux images à partir de la même base de code : sur AWS, l'image s'appuie sur le runtime Lambda ; sur Scaleway, elle s'appuie sur une image de base maison qui démarre un serveur HTTP et rejoue le contrat d'événement que l'application attend.
02_une_appli_deux_images.png
Figure 2 : Une seule base de code applicative, empaquetée dans deux images distinctes, une par cloud.
Reconstruire la passerelle, morceau par morceau
Tout le travail a donc consisté à écrire une fine couche, embarquée dans l'image de base, qui refait ce que l'API Gateway offrait. Nous avons choisis d'utiliser le server HTTP natif (nouvelle fenêtre) de Python afin d'éviter des packages inutiles et de ralentir le cold start du container.
1. Recevoir la requête brute et la traduire en événement. Le serveur HTTP lit la requête et fabrique le même objet qu'AWS aurait passé à la fonction, au format v2.0. Le handler applicatif ne voit aucune différence.
event = {
"version": "2.0",
"rawPath": path_parts[0],
"rawQueryString": path_parts[1] if len(path_parts) > 1 else "",
"httpMethod": method,
"body": raw_body,
"headers": self.headers,
"requestContext": {"http": {"method": method, "protocol": self.protocol_version}},
}
result = callback(event, None) # on appelle le handler exactement comme sur AWS
2. Router par verbe HTTP, et refuser le reste. On génère dynamiquement un gestionnaire par méthode ; celles qui ne sont pas dans ALLOWED_METHODS répondent 405.
def create_do_handler(method):
if method in allowed_methods:
def do_method(self): self._handle(method)
else:
def do_method(self): self._method_not_allowed() # 405 Method Not Allowed
return do_method
for method in SUPPORTED_METHODS: # HEAD, GET, POST, PATCH, PUT, DELETE
setattr(RequestHandler, f"do_{method}", create_do_handler(method))
3. Gérer les erreurs proprement. Une exception applicative ne doit jamais fuiter en production ; en développement, on veut au contraire tout voir. D'où le commutateur DEBUG.
try:
result = callback(event, None)
except BaseException as e:
if DEBUG:
result = {"statusCode": 500, "body": json.dumps({"error": str(e)})}
else:
result = {"statusCode": 500, "body": json.dumps({"error": "Internal Server Error"})}
logger.error(e)
4. Gérer le CORS et les requêtes preflight. Le navigateur envoie un OPTIONS avant la vraie requête ; on répond avec les en-têtes autorisés.
def do_OPTIONS(self):
if self.headers.get("Access-Control-Request-Method") in allowed_methods:
self.send_response(204)
for header, value in preflight_headers.items():
self.send_header(header, value)
self.end_headers()
Mis bout à bout, voici le cycle de vie d'une requête à travers cette passerelle maison :
03_cycle_requete_shim.png
Figure 3 : Le cycle de vie d'une requête à travers la passerelle maison, du flux HTTP brut à la réponse.
Packaging et déploiement
Côté image, tout tient en quelques lignes. On part de l'image de base, on copie le code applicatif dans LAMBDA_TASK_ROOT, et on désigne le handler exactement comme sur AWS. L'entrypoint est configurable, et le comportement se pilote par variables d'environnement : ALLOWED_METHODS (les verbes exposés), DEBUG (erreurs détaillées ou génériques), et PORT (imposé par Scaleway, 8080 par défaut).
FROM scaleway-serverless-entrypoint-python:latest
ENV ALLOWED_METHODS=GET,POST
COPY . ${LAMBDA_TASK_ROOT}
CMD ["mon_module.ma_fonction"] # le même handler que sur AWS
Le serveur démarre sur le port fourni par la plateforme, puis sert les requêtes jusqu'à l'arrêt :
port = int(getenv("PORT")) # fourni par Scaleway
server = HTTPServer(("", port), make_handler(ALLOWED_METHODS.split(","), handler))
server.serve_forever()
Ce que nous avons volontairement laissé de côté
Cette passerelle fait le strict nécessaire pour notre cas, et pas davantage. Nous avons sciemment mis de côté plusieurs sujets, tous faisables si le besoin se présente : l'authentification et l'autorisation, le rate limiting, la gestion fine des path parameters, le support des corps binaires ou en base64, et une observabilité poussée (traces, métriques). Ce ne sont pas des angles morts, ce sont des choix : notre applicatif n'en avait pas besoin.
Un mot sur les briques Scaleway qu'on nous cite parfois comme alternatives. Scaleway a bien un produit nommé Edge Services, mais ce n'est pas un équivalent de l'API Gateway d'AWS : c'est une couche edge de type CDN (cache, WAF, domaine personnalisé, certificat TLS, routage vers plusieurs backends), dans l'esprit de CloudFront. Elle ne fait ni la transformation d'une requête en événement, ni la réécriture d'URL, ni la gestion du CORS que l'API Gateway assurait. Quant à l'ancien Serverless Gateway self-hosted, à base de Kong, il est déprécié. Autrement dit, aucune brique managée ne reprend aujourd'hui le rôle précis que jouait l'API Gateway devant notre Lambda : le shim reste nécessaire.
En conclusion
Porter un applicatif Lambda vers Scaleway pour des raisons de souveraineté, ce n'est pas convertir du code : c'est retrouver ce que le cloud managé faisait pour nous sans qu'on le remarque, et décider, morceau par morceau, ce qu'on veut reprendre en main.
La souveraineté a un coût d'ingénierie. Ici, il tient en une petite couche d'adaptation bien délimitée, qui garde une seule et même application portable d'un cloud à l'autre.