Masquer un démarrage à froid : deux fonctions qui se passent le relais
Une fonction froide répond en deux secondes ; réveiller une base en prend quatorze. Plutôt que de faire patienter le visiteur, on découpe en deux fonctions et on lance le réveil dès la première image. Un quiz jouable sert de démonstration, et il publie ses propres mesures.
Vous ouvrez la démo que vous venez de déployer, celle que vous vouliez montrer à quelqu'un. La page apparaît sans se faire attendre. Puis vous cliquez sur le bouton qui écrit en base, et l'écran se fige pendant quatorze secondes.
Rien n'est cassé. La fonction a démarré à froid, ce qui est rapide ; la base a démarré à froid, ce qui ne l'est pas, parce qu'elle doit en plus restaurer son état depuis le stockage objet. Les deux comportements sont exactement ceux que le scale-to-zero promet, et c'est précisément ce qui les rend gênants : le premier visiteur paie, et le premier visiteur est souvent celui à qui vous montrez le projet.
La réponse habituelle est de payer pour que ça n'arrive pas — garder une instance chaude. C'est défendable, et ça se facture. Il existe une autre voie, qui ne coûte rien et qui ne consiste pas à accélérer quoi que ce soit : déplacer le démarrage hors du chemin où quelqu'un attend.
Cet article monte la chose en entier. Le résultat est jouable : un quiz de dix questions qui réveille sa base pendant que vous y répondez, et qui vous montre les chiffres à la fin.
D'abord, mesurer ce que ça coûte vraiment
« Le démarrage à froid est lent » n'est pas exploitable. Ce qu'il faut savoir, c'est quelle partie est lente, parce que c'est elle qu'il faudra masquer.
La décomposition se fait avec trois mesures et un peu de patience — il faut laisser le namespace se vider entre chaque, sinon on mesure du chaud sans le savoir.
| Ce qu'on mesure | Résultat |
|---|---|
| la page seule, fonction froide, sans solliciter la base | elle est là, on ne l'attend pas |
un SELECT 1 sur une base endormie, chronométré côté serveur |
14,1 s |
| la même requête sur une base déjà debout | quelques millisecondes |
Ces quatorze secondes ne sont pas un démarrage de conteneur ordinaire : avant d'accepter sa première requête, la base doit restaurer son état depuis le stockage objet. Deux campagnes à trois jours d'écart donnent 13,6 s et 14,1 s. C'est reproductible, ce n'est pas un coup de malchance.
La conclusion tient en une ligne : la fonction n'est pas le problème. Son démarrage se confond avec un chargement de page ordinaire. Ce sont les quatorze secondes de la base qu'il faut couvrir, et il se trouve qu'elles n'ont aucune raison d'être sur le chemin du visiteur.
L'idée : une seule ligne, au bon endroit
Une page a besoin de sa base à la fin, pas au début. Entre l'instant où elle s'affiche et l'instant où elle écrit, il y a un temps mort que le visiteur remplit lui-même : il lit, il choisit, il tape. Ce temps mort ne sert à rien. Autant y mettre le réveil.
// On part chercher, on n'attend pas. Personne ne lit cette réponse. fetch(API + '/wake');
C'est tout. Le fetch n'est jamais attendu, son résultat n'intéresse personne : ce qui compte, c'est
que la requête parte, parce que c'est elle qui fait exister le pod de la base. Pendant les quarante
secondes suivantes, le visiteur joue. Quand il soumet enfin quelque chose, la base est debout depuis
longtemps.
Le démarrage à froid n'a pas été supprimé. Il n'a pas été raccourci d'une milliseconde. Il a simplement eu lieu ailleurs.
Ce qui rend le tour possible : deux fonctions, pas une
Voilà le point qu'on ne peut pas contourner. Si la page et la base vivaient dans la même fonction, il n'y aurait rien à masquer : le visiteur attendrait le réveil avant de voir la page, puisque c'est la même requête qui déclenche les deux.
Le découpage est donc structurel, pas cosmétique :
| Fonction | Base attachée | Ce qu'elle fait |
|---|---|---|
jeu |
aucune | rend la page HTML et se rendort. Elle n'a pas de DATABASE_URL et ne saurait qu'en faire. |
quiz-api |
oui | réveil, écriture du score, agrégats. La seule à parler à la base. |
La fonction qui s'affiche doit être la plus pauvre possible. Pas de base, pas de dépendance lourde, rien qui rallonge son propre démarrage — c'est elle que le visiteur attend vraiment.
Côté API, la route de réveil est aussi maigre que possible :
def _wake():
"""Réveille la base et rend le temps que ça a pris, vu du serveur.
Aucune donnée n'est lue : le but n'est pas d'obtenir une réponse mais de faire
exister le pod.
"""
started = time.monotonic()
db = _client()
try:
db.execute("SELECT 1")
finally:
db.close()
elapsed_ms = int((time.monotonic() - started) * 1000)
return {"ready": True, "query_ms": elapsed_ms, "was_cold": elapsed_ms > 1000}Un SELECT 1. Le but n'est pas de lire quoi que ce soit, mais de forcer l'existence du pod et de
payer la restauration tout de suite.
Lier deux fonctions sans rien coder en dur
Les deux fonctions doivent se trouver. La solution la plus simple — écrire l'URL de l'API dans la page — marche jusqu'au jour où quelqu'un redéploie la paire sous un autre compte, et se demande pendant vingt minutes pourquoi le quiz écrit dans votre base.
Sur Zerolith, deux fonctions du même compte ne diffèrent que par leur premier segment :
jeu-fn-<id> et quiz-api-fn-<id>. La page peut donc déduire l'adresse de sa jumelle de son propre
Host :
def _api_url(request):
explicit = os.environ.get(API_ENV)
if explicit:
return explicit.rstrip("/")
headers = {k.lower(): v for k, v in (request.headers or {}).items()}
host = (headers.get("host") or "").split(":")[0]
if host.startswith(UI_NAME + "-"):
return "https://" + API_NAME + "-" + host[len(UI_NAME) + 1 :]
return ""Avec une variable d'environnement qui reprend la main si vous nommez vos fonctions autrement. La paire est alors redéployable telle quelle, sans reconfiguration.
Le k.lower() de la ligne 5 n'est pas décoratif. request.headers est un dictionnaire
ordinaire qui conserve la casse d'origine : la clé est Host, pas host. Un
request.headers.get("host") renvoie None sans rien signaler, la page rend un 500, et le banc de
test — qui fabrique ses en-têtes en minuscules — reste vert. Cette version-là de la page a bien été
déployée en production avant d'être corrigée.
Le CORS, et le contrôle préalable
Les deux fonctions ont des noms d'hôte différents, donc des origines différentes. L'API doit renvoyer les en-têtes CORS, sinon le navigateur reçoit la réponse et refuse de la livrer à la page.
CORS = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
"Access-Control-Allow-Headers": "content-type",
"Cache-Control": "no-store",
}Et ça ne suffit pas. Ces en-têtes ne valent que pour les requêtes que le navigateur juge
« simples ». Un POST en application/json n'en est pas une : le navigateur envoie d'abord une
requête OPTIONS de contrôle préalable, et n'envoie le POST que si la réponse l'y autorise.
Il faut donc que quelque chose réponde à ce contrôle préalable, et ce quelque chose est votre
handler : le runtime lui passe OPTIONS comme n'importe quelle autre méthode. Vous décidez donc
vous-même de votre politique, en trois lignes.
if request.method == "OPTIONS":
return 204, b"", CORScurl -i -X OPTIONS https://quiz-api-<id>.api.zerolith.io/score \
-H 'Origin: https://jeu-<id>.api.zerolith.io' \
-H 'Access-Control-Request-Method: POST'
HTTP/2 204
access-control-allow-origin: *
access-control-allow-methods: GET, POST, OPTIONS
access-control-allow-headers: content-typeLa page, elle, poste en text/plain :
headers: {'content-type': 'text/plain;charset=UTF-8'}C'est un choix, pas une obligation. Un POST en text/plain est une requête simple : il part
sans contrôle préalable, donc sans l'aller-retour qui le précède — ce qui compte quand tout
l'article consiste à compter des millisecondes. Côté serveur rien ne change, request.json()
analyse les octets reçus sans consulter le Content-Type.
Combien de temps faut-il masquer ?
C'est la seule vraie question de conception. Un budget de quatorze secondes ne se couvre pas avec une animation de deux secondes.
Le quiz est calibré pour ça, et pas par hasard : dix questions, huit secondes chacune au maximum, et une explication après chaque réponse. Même quelqu'un qui répondrait au hasard sans rien lire ne peut pas finir en moins d'une vingtaine de secondes. La couverture ne dépend donc pas de la bonne volonté du visiteur ; elle est dans la mécanique.
C'est le critère à retenir pour votre propre cas : le temps que vous mettez devant le réveil doit être du temps que le visiteur passerait de toute façon. Un formulaire à remplir, un tunnel en plusieurs étapes, une page de règles à lire, un choix à faire. Si vous devez inventer une attente pour couvrir le démarrage, vous n'avez rien masqué — vous avez ajouté de la latence et changé son étiquette.
Et quand le temps mort n'existe pas — un lien profond qui doit afficher une donnée tout de suite —
cette technique ne s'applique pas. Il reste alors le vrai levier, celui qui se facture : une
instance maintenue chaude (min_scale, ou always_on sur une base). Ce n'est pas un échec, c'est
un curseur, et il vaut mieux le placer en connaissance de cause.
La démonstration publie ses propres chiffres
Un article qui affirme « quatorze secondes » demande qu'on le croie. Autant faire produire la preuve par la page elle-même.
Chaque partie enregistre trois colonnes qui n'ont rien à voir avec le quiz : si la page avait
préchauffé la base, combien de temps le réveil a pris, et combien de temps l'écriture finale a pris
vue du navigateur. GET /stats en sort la comparaison des deux régimes sur l'ensemble des
joueurs — et
?prewarm=0 rejoue la
même partie sans le préchauffage, pour que vous puissiez sentir la différence plutôt que la lire.
Deux détails se paient à la conception plutôt qu'après.
Voici ce qu'ont donné les deux premières parties enregistrées, jouées sur cette paire avec le namespace vidé avant chacune — aucun pod, ni fonction ni base :
| avec préchauffage | sans préchauffage | |
|---|---|---|
| écriture du score, vue du navigateur | 260 ms | 17 462 ms |
Soixante-sept fois. Même code, même base, même plateforme : la seule différence est l'instant où la première requête part.
Une précision que les chiffres méritent : dans la partie préchauffée, le réveil a coûté 16,5 s vues de la page — les 14,1 s de la base, plus le démarrage à froid de l'API elle-même, qui était froide aussi. C'est bien 16,5 s de démarrage qui ont eu lieu pendant que le joueur répondait, pour 260 ms d'attente réelle à l'arrivée.
La durée de l'écriture arrive par une deuxième requête. Un navigateur ne peut pas connaître la durée d'une requête avant qu'elle finisse, et c'est exactement cette durée qu'on veut publier. La page mesure, puis dépose la valeur par un second appel — minuscule, et sur une base désormais chaude.
Cette écriture est unique par partie. La clause tient en trois mots :
UPDATE games SET submit_ms = ? WHERE id = ? AND submit_ms IS NULL
Sans le IS NULL, n'importe qui pourrait réécrire la latence de n'importe quelle partie et déplacer
la médiane que la page affiche. Une partie ne peut être chronométrée qu'une fois, par celui qui
vient de la jouer. Et quand la route refuse, elle répond {"recorded": false} : dire
« enregistré » sur une écriture refusée serait une réponse qui ment.
Ce que ça coûte
Rien de plus qu'avant, et c'est le point.
La requête de réveil est un SELECT 1 : une invocation, quelques millisecondes de calcul. Le pod de
la base aurait démarré de toute façon — le visiteur allait écrire son score. Il démarre simplement
quarante secondes plus tôt, et c'est ce décalage qui est gratuit.
La seule dépense réelle est la deuxième fonction, qui ne coûte rien tant que personne ne l'appelle : au repos, une fonction n'a aucun pod. Sur une paire comme celle-ci, la facture est identique à celle d'une fonction unique qui aurait fait le même travail — à une invocation de plus par visite.
À comparer avec l'alternative : une base always_on maintient un pod en permanence, soit plusieurs
euros par mois pour la même absence d'attente.
Le déployer
Le code des trois fonctions est dans le dépôt d'exemples, avec son schéma et ses instructions :
quiz/.
Trois fonctions et une base : un migrateur de type migrate qui pose le schéma — sans route ni
nom d'hôte, donc privé par construction — l'API publique avec la base
attachée, et la page publique sans base — c'est le seul point où l'on peut se tromper de façon
intéressante. Si vous attachez une base à la fonction qui affiche, elle ne démarrera pas plus
lentement, mais vous n'aurez plus rien à masquer : les deux réveils redeviendront un seul.
Le raisonnement sur les bases privées, leur schéma et leur facturation est développé dans « Une API avec état », qui est le point de départ naturel si vous n'en avez encore jamais attaché une.
Essayez sur votre propre compte
L'inscription vient avec du crédit, de quoi déployer, planifier et mesurer tout ce qui précède sans sortir de carte bancaire.
Commencer »