Une API avec état : brancher une base privée sur sa fonction
Une fonction est sans état, et c'est là que la moitié des projets s'arrêtent. On monte l'autre chemin en entier : une base qui dort quand personne ne l'interroge, un schéma posé proprement, et un stock tenu comme un registre plutôt que comme un compteur que deux écritures peuvent écraser.
Une fonction est sans état. C'est ce qui la rend facturable à la seconde et scalable sans effort, et c'est aussi ce qui bloque la moitié des projets au bout de vingt minutes, à l'instant précis où il faut se souvenir de quelque chose. Le réflexe suivant coûte cher : un Postgres managé à quinze euros par mois pour une API qui reçoit trente requêtes par jour.
Voici l'autre chemin, de bout en bout : une base privée qui dort quand personne ne l'interroge, un schéma posé proprement, et une API qui tient ses comptes. Quatre appels et deux fonctions.
L'exemple est un inventaire de magasin : consulter le catalogue, entrer et sortir du stock. Assez petit pour tenir dans un article, assez réel pour buter sur la seule décision de conception qui compte vraiment ici.
Créer la base
curl -X POST -H "Authorization: Bearer $JWT" \
-d '{"name": "inventaire"}' \
https://zerolith.io/api/databasesC'est tout. Sans paramètres, vous obtenez le palier de stockage par défaut, 500 Mo, et le preset
medium pour l'instance (256 Mio de mémoire, 0,25 vCPU). Les paliers montent jusqu'à 10 Go par pas
de 500 Mo, et se relèvent à tout moment.
Ce que la réponse ne contient pas compte autant : le jeton d'authentification n'y est pas, et il n'est affiché nulle part, jamais. Ni dans l'interface, ni dans l'API, ni dans les logs. Il est monté dans vos pods depuis un objet en écriture seule. Vous ne le manipulez pas, donc vous ne pouvez pas le fuiter.
Techniquement, c'est du SQLite accessible en réseau, répliqué en continu vers un stockage objet. Pratiquement, elle vit dans votre espace isolé, n'est jamais exposée publiquement, et n'est joignable que par vos propres fonctions.
Le schéma : une fonction migratrice
Le serveur de base ne peut pas appeler vos fonctions. C'est une contrainte de conception assumée : rien dans la plateforme ne déclenche votre code sans que vous l'ayez demandé. Les migrations sont donc explicites. Vous désignez une fonction comme migratrice, et vous la déclenchez.
import os
import libsql_client
DDL = [
"""CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY,
sku TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
price_cents INTEGER NOT NULL DEFAULT 0
)""",
# Le stock n'est PAS une colonne : voir la section suivante.
"""CREATE TABLE IF NOT EXISTS movements (
id INTEGER PRIMARY KEY,
product_id INTEGER NOT NULL REFERENCES products(id),
delta INTEGER NOT NULL,
reason TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)""",
"CREATE INDEX IF NOT EXISTS movements_product ON movements(product_id)",
]
def handler(request):
db = libsql_client.create_client_sync(
url=os.environ["DATABASE_URL"],
auth_token=os.environ["DATABASE_AUTH_TOKEN"],
)
for statement in DDL:
db.execute(statement)
db.close()
return {"migrated": len(DDL)}libsql_client est déjà dans l'image du runtime Python, rien à installer. Déployez-la avec le type
migrate — c'est le type fait pour ça, et ce n'est pas un détail de confort :
curl -X POST -H "Authorization: Bearer $JWT" \
-d '{"name": "migrate", "kind": "migrate", "code": "<le code ci-dessus>"}' \
https://zerolith.io/api/functionsUne fonction migrate n'a ni service, ni route, ni nom d'hôte, et aucun pod entre deux
exécutions. Elle est donc privée par construction et non par un drapeau : il n'y a rien sur quoi
la passerelle pourrait admettre une requête, et public: true y est refusé plutôt qu'ignoré. Vous
n'avez pas non plus à payer le side-car de la passerelle interne pour un serveur HTTP dont
l'unique appelant est la plateforme elle-même. Le code, lui, ne change pas : le même handler, avec
le même argument request — il vaut method: "RUN" et rien d'autre.
Le type est fixé à la création et immuable ensuite. Un migrateur déjà déployé en http
continue de fonctionner ; pour le basculer, supprimez-le et recréez-le sous le même nom.
Reste à la désigner et à la déclencher :
# Désigner le migrateur (cela l'attache aussi, donc elle reçoit l'injection)
curl -X PUT -H "Authorization: Bearer $JWT" \
-d '{"function_id": "<id-de-migrate>"}' \
https://zerolith.io/api/databases/<id-base>/migrator
# Déclencher la migration, une fois, à la demande
curl -X POST -H "Authorization: Bearer $JWT" \
https://zerolith.io/api/databases/<id-base>/migrateLe CREATE TABLE IF NOT EXISTS n'est pas de la paresse. La migration est déclenchée à la main, donc
elle doit pouvoir être rejouée sans conséquence : c'est ce qui la rend sûre à relancer quand vous ne
savez plus si elle est passée.
La décision qui compte : un registre, pas un compteur
Voilà la seule vraie décision de conception de cet exemple, et elle n'a rien de spécifique au serverless. Elle est juste beaucoup plus visible ici.
La tentation, c'est une colonne stock qu'on incrémente. Le problème n'est pas la performance, mais
que deux écritures simultanées s'écrasent : deux pods réveillés en parallèle lisent 10, ajoutent
chacun 5, et écrivent 15 au lieu de 20. Sur une plateforme qui démarre des instances quand le trafic
monte, ce n'est pas un cas d'école, c'est le comportement par défaut.
Un registre de mouvements n'a pas ce problème, puisque chaque écriture est une insertion et jamais une mise à jour. Le stock devient la somme du registre, l'historique vient gratuitement (d'où vient chaque unité, quand, pourquoi), et il n'y a plus rien à écraser. En échange, on agrège à la lecture, ce qui, indexé et à cette échelle, ne se mesure pas.
C'est d'ailleurs la forme que la plateforme utilise elle-même pour le crédit de votre compte : un journal en ajout seul, jamais une colonne de solde réécrite.
import os
import libsql_client
STOCK = """SELECT p.id, p.sku, p.name, p.price_cents,
COALESCE(SUM(m.delta), 0) AS stock
FROM products p
LEFT JOIN movements m ON m.product_id = p.id
GROUP BY p.id
ORDER BY p.name"""
def db():
return libsql_client.create_client_sync(
url=os.environ["DATABASE_URL"],
auth_token=os.environ["DATABASE_AUTH_TOKEN"],
)
def handler(request):
client = db()
try:
if request.method == "POST":
body = request.json() or {}
# Un mouvement, pas une mise à jour : +5 à la réception, -1 à la vente.
client.execute(
"INSERT INTO movements (product_id, delta, reason) VALUES (?, ?, ?)",
[body["product_id"], body["delta"], body.get("reason", "manuel")],
)
return 201, {"ok": True}
rows = client.execute(STOCK).rows
return {"products": [dict(zip(r.keys(), r)) for r in rows]}
finally:
client.close()Une seule fonction sert la lecture et l'écriture, en s'appuyant sur request.method et
request.json(). Le contrat complet du handler est dans la documentation.
Attacher, puis appeler
curl -X POST -H "Authorization: Bearer $JWT" \
-d '{"function_id": "<id-de-inventaire-api>"}' \
https://zerolith.io/api/databases/<id-base>/attachL'attache injecte DATABASE_URL et DATABASE_AUTH_TOKEN dans le pod et fait tourner une nouvelle
révision. À partir de là, la fonction s'appelle comme n'importe quelle autre, avec votre clé d'API :
curl -H "Authorization: Bearer $ZEROLITH_KEY" "https://<url-de-la-fonction>"
curl -X POST -H "Authorization: Bearer $ZEROLITH_KEY" \
-d '{"product_id": 1, "delta": 5, "reason": "réception"}' \
"https://<url-de-la-fonction>"Deux détails qui évitent une demi-heure de perplexité :
- Une base attachée ne se supprime pas (409). Détachez-la d'abord. C'est délibéré : la
suppression détruit les données, et un
DELETEsur la mauvaise ressource ne doit pas y suffire. - Le palier de stockage est un plafond ferme. Au-delà, les écritures échouent. Il ne s'étend pas tout seul pour vous facturer davantage, ce qui serait plus pratique et nettement moins honnête.
Ce que ça coûte
Deux lignes sur la facture, et elles n'ont pas la même nature.
Le stockage est facturé sur le palier détenu, pas sur les octets utilisés, à 0,40 €/Go-mois, réglé chaque jour. Le palier par défaut de 500 Mo fait donc 0,20 €/mois, que la base soit pleine ou vide, endormie ou active. C'est ce que vous payez pour que les données existent encore demain.
Le calcul, lui, suit exactement les mêmes prix qu'une fonction de même taille. Le preset medium
réserve 0,25 Gio et 0,25 vCPU :
mémoire 0,25 Gio × 0,000004 € = 0,000001 €/s
processeur 0,25 vCPU × 0,0000125 € = 0,000003125 €/s
───────────────
0,000004125 €/sEt là, le même piège que pour un cron, celui qu'on ne lit jamais dans les billets sur le serverless. Une base ne s'endort pas à la fin de votre requête. La fenêtre d'inactivité par défaut est de 10 minutes, réglable de 6 secondes à 1 heure, donc une requête isolée maintient l'instance éveillée dix minutes :
600 s × 0,000004125 € ≈ 0,0025 € (un quart de centime)
Ce chiffre est une bonne nouvelle mal présentée : dix minutes de fenêtre, ça veut dire que trente requêtes dans la même demi-heure ne coûtent pas trente réveils, elles en coûtent un ou deux. Une fenêtre courte économise sur du trafic très espacé, une fenêtre longue sur du trafic groupé. Le réglage se change à tout moment, sans déplacer les données. Et si la latence du premier appel doit être constante, le mode « toujours active » garde une instance en permanence, au prix du calcul correspondant, cette fois en continu.
Pour un inventaire consulté quelques fois par jour, on parle donc de quelques dizaines de centimes
par mois, dominées par le stockage plutôt que par le calcul. Vérifiez-le sur votre propre compte :
GET /api/usage/summary ventile le mois en cours.
L'échéance qu'il faut connaître avant d'y mettre des données
Il n'y a qu'une seule échéance destructrice dans toute la plateforme, et la voici : si votre solde tombe à zéro, la base est suspendue, et après 7 jours son stockage est purgé définitivement. Le compte à rebours démarre à la suspension et un rechargement l'annule.
C'est un choix assumé, du stockage impayé ne peut pas être conservé indéfiniment, mais c'est le genre de règle qu'un article a le devoir de dire avant de vous encourager à y mettre quoi que ce soit. Elle vient avec son contrepoids, un export qui fonctionne :
# Lance un dump SQL, en tâche de fond
curl -X POST -H "Authorization: Bearer $JWT" \
https://zerolith.io/api/databases/<id-base>/export
# Puis relisez son état et récupérez le lien de téléchargement (valable 24 h)
curl -H "Authorization: Bearer $JWT" \
https://zerolith.io/api/databases/<id-base>/exportsLe dump est un fichier .sql.gz, et il se relit sans rien de Zerolith :
gunzip -c dump.sql.gz | sqlite3 restauration.db
Sortir vos données n'est ni une option payante ni une fonctionnalité à venir.
Et ensuite
Le même exercice sans état persistant, avec une tâche planifiée à la place, est dans l'autre article : une fonction, un cron, et un demi-centime par mois.
La page base de données explique le modèle d'isolation et ce qui se passe pendant le sommeil ; la section base de données de la documentation tient la référence à jour (variables injectées, paliers, migrations, exports). Les tarifs unitaires sont sur la page tarifs.
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 »