Aller au contenu
English

Données persistantes

  • Leçon 9
  • 50 min
  • Module 3 : Réseau, configuration et persistance

Ce que vous saurez faire

Conservez les données importantes quand les conteneurs sont remplacés, et perdez seulement ce qui ne compte pas. Vous ajoutez un historique des classifications dans PostgreSQL. Vous exécutez l’API et sa base de données ensemble avec une seule commande.

Avant de commencerCe qu’il faut savoir et avoir sous la mainEnviron 50 minutes

Ce que vous devez savoir

Ce qu’il vous faut sur votre ordinateur

Testé avec Docker Desktop 4.81.0 (Docker Engine 29.6.1, Compose v5.2.0) sur macOS, avec une puce Apple. Les étapes pour Windows et Linux ne sont pas testées.

Amira démarre l’API, écrit un fichier dans le conteneur et l’examine :

Terminal
docker run -d --name ticket-api ticket-api:1.1.0-dev
docker exec ticket-api sh -c "echo hello > /tmp/note.txt"
docker exec ticket-api cat /tmp/note.txt
docker diff ticket-api

La sortie (après l’identifiant du conteneur) :

hello
C /tmp
A /tmp/note.txt

docker diff énumère les changements dans la couche accessible en écriture du conteneur. C’est la mince couche au-dessus de l’image (Images et conteneurs en cours d’exécution). C signifie modifié, et A signifie ajouté. Elle remplace maintenant le conteneur par un nouveau, créé à partir de la même image, comme à chaque mise à jour :

Terminal
docker rm -f ticket-api
docker run -d --name ticket-api ticket-api:1.1.0-dev
docker exec ticket-api cat /tmp/note.txt

La sortie (après le nom du conteneur et le nouvel identifiant) :

cat: /tmp/note.txt: No such file or directory

Le fichier se trouvait dans la couche accessible en écriture de l’ancien conteneur. Cette couche a été supprimée avec le conteneur. Un conteneur est jetable. Vous le remplacez à chaque nouvelle version, changement de paramètre ou redémarrage sur un nouveau serveur. Tout ce qui doit survivre doit être stocké à l’extérieur.

Terminal
docker rm -f ticket-api
Stockage Ce que c’est Survit à un nouveau conteneur? À utiliser pour
La couche accessible en écriture Fichiers qu’un conteneur écrit dans son propre système de fichiers Non Fichiers temporaires
Volume nommé Stockage nommé que Docker gère à l’extérieur de tous les conteneurs Oui, jusqu’à la suppression du volume Fichiers d’une base de données sur votre ordinateur
Montage lié Fichier ou dossier de votre ordinateur, visible dans le conteneur Oui : c’est votre fichier Fichier secret, comme dans la leçon précédente
Base de données externe Serveur de base de données hors du conteneur de l’application, souvent un service infonuagique géré Oui Enregistrements, comme l’historique, en production
Stockage d’objets Service infonuagique pour les fichiers, comme Azure Blob Storage Oui Fichiers téléversés, rapports, fichiers de modèles entraînés

Un volume est un espace de stockage pour les conteneurs qui se trouve à l’extérieur de ceux-ci. Un conteneur le monte à un chemin, et les fichiers à ce chemin vont dans le volume. Un volume nommé reste toutefois sur un seul ordinateur. Un service infonuagique qui démarre votre conteneur sur différents serveurs ne peut pas l’utiliser. Dans le nuage, les enregistrements vont dans une base de données gérée, et les fichiers dans le stockage d’objets (module 4).

Cette leçon ajoute un historique à l’API : une ligne dans une base de données pour chaque classification. La base de données est PostgreSQL, dans son propre conteneur, avec ses fichiers dans un volume nommé.

L’historique conserve le résultat (catégorie, priorité, confiance, version du modèle, heure et identifiant de requête), pas le texte du billet. Un billet peut contenir des noms, des adresses ou des numéros de commande. L’historique sert à compter et à faire le suivi d’un identifiant de requête. Ni l’un ni l’autre n’exige le texte. Les données que vous ne stockez pas ne peuvent pas être divulguées.

Le code de l’historique est fourni : le cours porte sur les conteneurs, pas sur Python. Vous ajoutez un module et le raccordez dans quatre fichiers.

Téléchargez le fichier dans ticket_api :

Fenêtre de terminal
curl -fsSL -o ticket_api/history.py https://raw.githubusercontent.com/Nextia-Intelligence/nextia-learning-labs/main/C18/files/history.py

-f fait échouer curl lors d’une erreur HTTP au lieu d’enregistrer la page d’erreur. Si le téléchargement ne fonctionne pas, copiez le code ci-dessous dans ticket_api/history.py.

Afficher ticket_api/history.py
ticket_api/history.py
"""Keep a record of the classifications in PostgreSQL.
The record has the result of each classification, never the ticket's text.
History is optional: without DATABASE_URL the API classifies tickets and
keeps no record.
"""
import psycopg
from psycopg.rows import dict_row
CREATE_TABLE = """
CREATE TABLE IF NOT EXISTS classifications (
id bigserial PRIMARY KEY,
request_id text NOT NULL,
category text NOT NULL,
priority smallint NOT NULL,
confidence real NOT NULL,
model_version text NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
)
"""
class HistoryUnavailable(Exception):
"""History is off, or the database does not answer."""
class History:
def __init__(self, url: str):
self.url = url
self.table_ready = False
def connect(self) -> psycopg.Connection:
try:
return psycopg.connect(self.url, connect_timeout=3, row_factory=dict_row)
except psycopg.OperationalError as error:
raise HistoryUnavailable("the database does not answer") from error
def setup(self) -> None:
"""Make the table if it does not exist."""
with self.connect() as conn:
conn.execute(CREATE_TABLE)
self.table_ready = True
def check(self) -> None:
with self.connect() as conn:
conn.execute("SELECT 1")
def add(self, request_id: str, result: dict) -> None:
if not self.table_ready:
self.setup()
with self.connect() as conn:
conn.execute(
"INSERT INTO classifications"
" (request_id, category, priority, confidence, model_version)"
" VALUES (%s, %s, %s, %s, %s)",
(
request_id,
result["category"],
result["priority"],
result["confidence"],
result["model_version"],
),
)
def recent(self, limit: int) -> list[dict]:
if not self.table_ready:
self.setup()
with self.connect() as conn:
return conn.execute(
"SELECT request_id, category, priority, confidence, model_version,"
" created_at FROM classifications ORDER BY id DESC LIMIT %s",
(limit,),
).fetchall()

Voici ce qu’il fait : il se connecte à PostgreSQL avec psycopg, le pilote PostgreSQL pour Python. Il attend au maximum 3 secondes. Il crée la table classifications si elle n’existe pas. add (ajouter) écrit une ligne, recent (récent) lit les lignes les plus récentes, et check (vérifier) sert à /ready. Tout problème de connexion devient HistoryUnavailable. Le reste de l’application n’a donc besoin de connaître qu’une seule erreur.

Ajoutez psycopg à requirements.txt, sous les paquets nécessaires à l’exécution de l’API :

requirements.txt
# Needed to run the API
fastapi[standard]==0.142.2
psycopg[binary]==3.3.6

Dans pyproject.toml, changez la version et ajoutez le même paquet :

pyproject.toml
version = "1.0.0"
version = "1.1.0"
description = "Classifies support tickets into a category and a priority."
requires-python = ">=3.12"
dependencies = ["fastapi[standard]==0.142.2"]
dependencies = ["fastapi[standard]==0.142.2", "psycopg[binary]==3.3.6"]

[binary] installe une version prête à l’emploi de psycopg qui contient la bibliothèque cliente PostgreSQL. L’image allégée n’a donc besoin d’aucun paquet système supplémentaire.

Dans ticket_api/models.py, ajoutez une importation au début, puis deux modèles après CategoryList :

ticket_api/models.py
from datetime import datetime
from typing import Literal
ticket_api/models.py (add after CategoryList)
class HistoryItem(BaseModel):
"""One classification that the API made. It has no ticket text."""
request_id: str = Field(description="The request that made the classification.")
category: Category
priority: int = Field(ge=1, le=3)
confidence: float = Field(ge=0, le=1)
model_version: str
created_at: datetime = Field(description="When the API made it (UTC).")
class HistoryList(BaseModel):
items: list[HistoryItem] = Field(description="The newest classification first.")

Dans ticket_api/errors.py, importez l’erreur et ajoutez un gestionnaire après celui de ClassifierUnavailable :

ticket_api/errors.py
from ticket_api.classifier import ClassifierUnavailable
from ticket_api.history import HistoryUnavailable
from ticket_api.models import ErrorDetail, ErrorResponse
ticket_api/errors.py (add after the ClassifierUnavailable handler)
@app.exception_handler(HistoryUnavailable)
async def history_unavailable(request: Request, exc: HistoryUnavailable):
logger.warning("history unavailable: %s id=%s", exc, request.state.request_id)
return error_response(
request,
503,
"history_unavailable",
"The history is not available. Try again later.",
)

Étape 4 : enregistrer chaque résultat et les énumérer

Section intitulée « Étape 4 : enregistrer chaque résultat et les énumérer »

Dans ticket_api/routes.py, faites trois changements : les importations, la fin de classify et un nouveau point de terminaison en fin de fichier :

ticket_api/routes.py (the top)
import asyncio
from typing import Annotated, get_args
from fastapi import APIRouter, Depends, Request
import logging
from fastapi import APIRouter, Depends, Query, Request
from fastapi.concurrency import run_in_threadpool
from ticket_api.classifier import ClassifierUnavailable, KeywordClassifier
from ticket_api.history import HistoryUnavailable
from ticket_api.models import (
Category,
CategoryList,
Classification,
ErrorResponse,
HistoryList,
TicketIn,
)
from ticket_api.security import require_api_key
logger = logging.getLogger("ticket_api")
router = APIRouter(prefix="/v1", tags=["tickets"])
ticket_api/routes.py (the end of classify, and the new endpoint)
return Classification(
result = Classification(
category=prediction.category,
priority=prediction.priority,
confidence=prediction.confidence,
model_version=classifier.version,
)
history = request.app.state.history
if history is not None:
try:
await run_in_threadpool(
history.add, request.state.request_id, result.model_dump()
)
except HistoryUnavailable:
logger.warning("history not saved id=%s", request.state.request_id)
return result
@router.get(
"/history",
tags=["history"],
summary="List recent classifications",
description=(
"Returns the newest classifications first. History is off when the "
"server has no DATABASE_URL."
),
responses={
401: {
"model": ErrorResponse,
"description": "The API key is missing or wrong.",
},
503: {
"model": ErrorResponse,
"description": "History is off, or the database does not answer.",
},
},
dependencies=[Depends(require_api_key)],
)
async def list_history(
request: Request,
limit: Annotated[int, Query(ge=1, le=100, description="How many to return.")] = 10,
) -> HistoryList:
history = request.app.state.history
if history is None:
raise HistoryUnavailable("history is off: DATABASE_URL is not set")
items = await run_in_threadpool(history.recent, limit)
return HistoryList(items=items)

Regardez le try (essayer) dans classify (classifier). Si la base de données ne répond pas, la classification renvoie quand même 200. Le journal reçoit alors un avertissement. C’est un choix de conception. La classification est la tâche principale de l’API. L’historique est un enregistrement secondaire : son échec ne doit donc pas interrompre la tâche principale. Mais cet échec doit être visible : dans le journal et dans /ready (prochaine étape). /v1/history exige la clé, comme /v1/classify.

ticket_api/main.py
from ticket_api.config import Settings, SettingsError, load_settings
from ticket_api.errors import add_error_handlers, error_response
from ticket_api.history import History, HistoryUnavailable
from ticket_api.models import ErrorResponse, Health, Readiness
ticket_api/main.py (in create_app)
app = FastAPI(
title="Ticket Classifier API",
version="1.0.0",
version="1.1.0",
description=DESCRIPTION,
openapi_tags=[
{"name": "tickets", "description": "Classify support tickets."},
{"name": "history", "description": "See recent classifications."},
{"name": "health", "description": "Check that the service runs."},
],
ticket_api/main.py (after make_classifier)
app.state.classifier = make_classifier(settings.classifier_mode)
app.state.history = None
if settings.database_url:
app.state.history = History(settings.database_url)
try:
app.state.history.setup()
except HistoryUnavailable:
logger.warning("the database does not answer yet: history is not ready")
else:
logger.info("DATABASE_URL is not set: history is off")
ticket_api/main.py (in ready)
def ready() -> Readiness:
app.state.classifier.predict("readiness check")
if app.state.history is not None:
app.state.history.check()
return Readiness(status="ready")

Sans DATABASE_URL, l’API fonctionne comme avant, sans historique. Si la base de données n’est pas prête au démarrage de l’API, l’API démarre quand même. Elle crée la table plus tard, à la première requête.

Ajoutez le fichier tests/test_history.py. Il contient 8 tests : 4 pour l’historique et 4 pour les paramètres de la leçon précédente. FakeHistory garde les lignes dans une liste Python. Les tests n’ont donc pas besoin de base de données.

Afficher tests/test_history.py
tests/test_history.py
from datetime import datetime, timezone
import pytest
from ticket_api.config import SettingsError, load_settings
from ticket_api.history import HistoryUnavailable
class FakeHistory:
"""Keeps the history in a list, so the tests need no database."""
def __init__(self, down=False):
self.rows = []
self.down = down
def check(self):
if self.down:
raise HistoryUnavailable("down")
def add(self, request_id, result):
self.check()
created_at = datetime.now(timezone.utc)
self.rows.append({"request_id": request_id, **result, "created_at": created_at})
def recent(self, limit):
self.check()
return list(reversed(self.rows))[:limit]
@pytest.fixture
def history_client(make_client):
def make(down=False):
client = make_client()
client.app.state.history = FakeHistory(down)
return client
return make
def test_history_is_off_without_database_url(client):
response = client.get("/v1/history")
assert response.status_code == 503
assert response.json()["error"]["code"] == "history_unavailable"
def test_classification_is_recorded_without_the_text(history_client):
client = history_client()
response = client.post("/v1/classify", json={"subject": "Parcel lost"})
items = client.get("/v1/history").json()["items"]
assert len(items) == 1
assert items[0]["category"] == "shipping"
assert items[0]["request_id"] == response.headers["X-Request-ID"]
assert "subject" not in items[0]
def test_history_limit(history_client):
client = history_client()
for _ in range(3):
client.post("/v1/classify", json={"subject": "Refund"})
assert len(client.get("/v1/history?limit=2").json()["items"]) == 2
assert client.get("/v1/history?limit=0").status_code == 422
def test_database_down_still_classifies_but_is_not_ready(history_client):
client = history_client(down=True)
assert client.post("/v1/classify", json={"subject": "Refund"}).status_code == 200
assert client.get("/ready").status_code == 503
assert client.get("/v1/history").status_code == 503
def test_api_key_from_a_file(tmp_path, monkeypatch):
key_file = tmp_path / "api_key"
key_file.write_text("from-a-file\n")
monkeypatch.setenv("API_KEY_FILE", str(key_file))
assert load_settings().api_key == "from-a-file"
def test_missing_secret_file_stops_the_app(monkeypatch):
monkeypatch.setenv("API_KEY_FILE", "/no/such/file")
with pytest.raises(SettingsError):
load_settings()
def test_required_api_key(monkeypatch):
monkeypatch.delenv("API_KEY", raising=False)
monkeypatch.delenv("API_KEY_FILE", raising=False)
monkeypatch.setenv("REQUIRE_API_KEY", "true")
with pytest.raises(SettingsError):
load_settings()
def test_secrets_are_not_in_the_settings_repr(monkeypatch):
monkeypatch.setenv("API_KEY", "do-not-print")
monkeypatch.setenv("DATABASE_URL", "postgresql://u:do-not-print@db/x")
assert "do-not-print" not in repr(load_settings())

Dans l’environnement virtuel, Amira installe le nouveau paquet, recrée le fichier de verrouillage et exécute les tests :

Terminal
python -m pip install -r requirements.txt
python -m pip freeze > requirements-lock.txt
python -m pytest

La sortie (la fin) :

FAILED tests/test_contract.py::test_openapi_matches_the_reviewed_contract - A...
1 failed, 24 passed in 0.24s

Prédisez : pourquoi le test du contrat échoue-t-il? L’API a un nouveau point de terminaison et une nouvelle version. Le document OpenAPI a donc changé. Ce changement est voulu. Elle exporte donc à nouveau le contrat, comme dans le cours sur les API, puis relance les tests :

Terminal
python -m ticket_api.export_contract
python -m pytest

La sortie (la fin) :

Wrote contract/openapi.json
...
25 passed in 0.30s

L’image installe les paquets à partir de requirements-run.txt. Ce fichier doit donc aussi contenir psycopg. Recréez-le dans un conteneur, comme dans L’essentiel du Dockerfile : créer requirements-run.txt dans un conteneur. Cette fois, incluez psycopg dans la commande :

Terminal
docker run --rm python:3.14.8-slim-trixie sh -c 'pip install -q --root-user-action=ignore --disable-pip-version-check "fastapi[standard-no-fastapi-cloud-cli]==0.142.2" "psycopg[binary]==3.3.6" && pip freeze' > requirements-run.txt

Vérifiez le résultat : wc -l requirements-run.txt et grep psycopg requirements-run.txt (PowerShell : (Get-Content requirements-run.txt).Count et Select-String psycopg requirements-run.txt). La sortie :

52 requirements-run.txt
psycopg==3.3.6
psycopg-binary==3.3.6

52 lignes : les 50 du module 2 et les deux paquets psycopg.

Le projet a maintenant besoin de deux conteneurs qui travaillent ensemble : l’API et PostgreSQL. Vous pourriez les démarrer avec deux commandes docker run, un réseau, un volume et plusieurs options. Docker Compose fait tout cela à partir d’un seul fichier. Compose est une partie de Docker qui démarre un groupe de conteneurs à partir d’un fichier nommé compose.yaml. Chaque conteneur dans ce fichier est un service.

D’abord, le mot de passe de la base de données. Il va dans .env, qui reste hors de Git. Un modèle sans véritable secret va dans Git pour que les autres sachent quelles valeurs définir. Créez .env.example :

.env.example
# Copy this file to .env and change the value. Never commit .env.
POSTGRES_PASSWORD=change-me-local-only

Créez ensuite .env à partir de ce fichier. Il remplace le .env de la leçon précédente : les paramètres de l’API sont maintenant dans compose.yaml.

Fenêtre de terminal
cp .env.example .env

Ouvrez .env et remplacez le mot de passe par un nouveau. Le mot de passe protège seulement la base de données sur votre ordinateur. Utilisez tout de même un mot de passe que vous n’utilisez nulle part ailleurs.

Créez ensuite compose.yaml dans le dossier du projet :

compose.yaml
# The ticket API and its database, on your computer.
# Start: docker compose up -d --build
# Stop: docker compose down (keeps the data)
# Reset: docker compose down --volumes (deletes the data)
services:
api:
build: .
image: ticket-api:1.1.0
ports:
- "127.0.0.1:8000:8000"
environment:
API_KEY_FILE: /run/secrets/api_key
DATABASE_URL: postgresql://tickets:${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}@db:5432/tickets
REQUIRE_API_KEY: "true"
secrets:
- api_key
depends_on:
db:
condition: service_healthy
db:
image: postgres:18.6-trixie
environment:
POSTGRES_USER: tickets
POSTGRES_DB: tickets
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env}
volumes:
- pgdata:/var/lib/postgresql
healthcheck:
test: ["CMD", "pg_isready", "-U", "tickets", "-d", "tickets"]
interval: 2s
timeout: 3s
retries: 15
secrets:
api_key:
file: ./secrets/api_key.txt
volumes:
pgdata:

Ligne par ligne :

Lignes Signification
build: . et image: ticket-api:1.1.0 Construire l’image à partir du Dockerfile de ce dossier et lui donner l’étiquette ticket-api:1.1.0.
ports: "127.0.0.1:8000:8000" Comme -p 127.0.0.1:8000:8000 : seul cet ordinateur peut se connecter. Les guillemets empêchent YAML de lire la valeur comme un nombre.
environment: Les paramètres de l’API, comme avec -e. REQUIRE_API_KEY: "true" : ici, la clé est obligatoire.
DATABASE_URL: ...@db:5432/tickets L’adresse de la base de données. Le nom d’hôte est db, le nom du service. Compose place les deux services sur un même réseau. Chaque service y porte son nom, comme dans Ports et accès au service.
${POSTGRES_PASSWORD:?Set POSTGRES_PASSWORD in .env} Compose remplace ceci par la valeur de .env. :? signifie obligatoire : sans cette valeur, Compose s’arrête avec ce message (définir POSTGRES_PASSWORD dans .env).
secrets: - api_key Monter le secret Compose api_key dans le conteneur, en lecture seule, à /run/secrets/api_key. API_KEY_FILE pointe vers ce chemin.
depends_on: db: condition: service_healthy Démarrer l’API seulement quand la vérification de l’état de santé de la base de données réussit.
image: postgres:18.6-trixie L’image officielle de PostgreSQL, PostgreSQL 18.6 sur Debian 13, fixée à une étiquette précise.
POSTGRES_USER, POSTGRES_DB, POSTGRES_PASSWORD L’image crée cet utilisateur et cette base de données au premier démarrage avec un volume vide.
volumes: - pgdata:/var/lib/postgresql Monter le volume nommé pgdata à /var/lib/postgresql, où PostgreSQL 18 garde ses fichiers. Voir Le chemin de montage de PostgreSQL 18.
healthcheck: Une vérification de l’état de santé est une commande que Docker exécute régulièrement dans le conteneur pour vérifier le service. pg_isready demande à PostgreSQL s’il accepte les connexions : toutes les 2 secondes, au maximum 15 fois.
secrets: api_key: file: Le secret Compose vient de ./secrets/api_key.txt, le fichier de la leçon précédente.
volumes: pgdata: Déclarer le volume nommé. Compose le crée la première fois.

Le fichier .env sert à Compose, pas aux conteneurs. Compose le lit pour remplir ${...} dans compose.yaml. Les conteneurs reçoivent seulement ce que environment: énumère. Le conteneur de l’API ne reçoit donc pas POSTGRES_PASSWORD, seulement l’URL qui le contient.

Prédisez : que se passe-t-il si .env manque? Amira a essayé avant de créer le fichier :

error while interpolating services.api.environment.DATABASE_URL: required variable POSTGRES_PASSWORD is missing a value: Set POSTGRES_PASSWORD in .env

Compose s’est arrêté avant de créer quoi que ce soit.

Votre ordinateur, l'hôte. curl ou un navigateur appelle http://127.0.0.1:8000, le port publié, qui transmet au port 8000 du conteneur. Un téléphone sur le Wi-Fi ne peut pas accéder au port publié. Dans le réseau Compose ticket-api_default : le conteneur api (ticket-api:1.1.0, écoute sur 0.0.0.0:8000) appelle le conteneur db (postgres:18.6-trixie, port 5432, non publié) à db:5432. À l'extérieur des conteneurs : le fichier secrets/api_key.txt sur l'hôte est monté en lecture seule dans api à /run/secrets/api_key, et le volume nommé ticket-api_pgdata est monté dans db à /var/lib/postgresql. Les conteneurs sont remplacés; le volume et le fichier sur l'hôte restent.
L'API et sa base de données dans Compose : un réseau, un port publié sur 127.0.0.1 et deux types de stockage à l'extérieur des conteneurs.
Description textuelle du diagramme

Votre ordinateur est l’hôte. curl ou un navigateur sur cet ordinateur appelle http://127.0.0.1:8000, le port publié. Docker transmet l’appel au port 8000 du conteneur api. Un téléphone sur le Wi-Fi ne peut pas se connecter, car le port est publié seulement sur 127.0.0.1. Le conteneur api (ticket-api:1.1.0, qui écoute sur 0.0.0.0:8000) et le conteneur db (postgres:18.6-trixie) partagent le réseau Compose ticket-api_default. L’API appelle la base de données à db:5432. Le port 5432 n’est pas publié. Seuls les conteneurs de ce réseau peuvent donc y accéder. Deux éléments sont à l’extérieur des conteneurs : le fichier de l’hôte secrets/api_key.txt, monté en lecture seule dans api à /run/secrets/api_key, et le volume nommé ticket-api_pgdata, monté dans db à /var/lib/postgresql. Les conteneurs sont remplacés. Le volume et le fichier sur l’hôte restent.

Amira démarre les deux services. --build construit d’abord l’image de l’API :

Terminal
docker compose up -d --build

La sortie (les lignes de construction sont abrégées) :

Image ticket-api:1.1.0 Building
...
Image ticket-api:1.1.0 Built
Network ticket-api_default Creating
Network ticket-api_default Created
Volume ticket-api_pgdata Creating
Volume ticket-api_pgdata Created
Container ticket-api-db-1 Creating
Container ticket-api-db-1 Created
Container ticket-api-api-1 Creating
Container ticket-api-api-1 Created
Container ticket-api-db-1 Starting
Container ticket-api-db-1 Started
Container ticket-api-db-1 Waiting
Container ticket-api-db-1 Healthy
Container ticket-api-api-1 Starting
Container ticket-api-api-1 Started

La première fois, Compose télécharge aussi postgres:18.6-trixie. Les noms viennent du nom du projet, qui est le nom du dossier : ticket-api. Le réseau est donc ticket-api_default, le volume est ticket-api_pgdata, et les conteneurs sont ticket-api-db-1 et ticket-api-api-1. Regardez l’ordre : la base de données d’abord, puis Waiting (en attente) jusqu’à l’état Healthy (sain), puis l’API.

Une séquence avec quatre participants : vous, Docker Compose, db et api. Vous exécutez docker compose up -d --build. Compose construit ticket-api:1.1.0 et crée le réseau et le volume. Compose démarre db avec le volume à /var/lib/postgresql. Dans une boucle, toutes les 2 secondes et au plus 15 fois, Compose exécute la vérification de santé pg_isready dans db, qui répond « pas prêt », puis « prêt ». Quand db est sain, Compose démarre api avec le secret à /run/secrets/api_key. api se connecte à db:5432 et crée la table. Compose vous indique que ticket-api-api-1 a démarré. Vous appelez curl 127.0.0.1:8000/ready, et api répond que son état est prêt.
depends_on avec condition : service_healthy fait attendre l'API jusqu'à ce que la base de données réponde, pas seulement qu'un conteneur fonctionne.
Description textuelle du diagramme

Vous exécutez docker compose up -d --build. Compose construit ticket-api:1.1.0 et crée le réseau et le volume. Il démarre db avec le volume à /var/lib/postgresql. Puis, toutes les 2 secondes et au plus 15 fois, il exécute la vérification de santé pg_isready dans db. Celle-ci répond « pas prêt », puis « prêt ». Quand db est sain, Compose démarre api, avec le secret à /run/secrets/api_key. L’API se connecte à db:5432 et crée la table. Compose affiche que ticket-api-api-1 a démarré. Votre appel curl 127.0.0.1:8000/ready reçoit {"status":"ready"}.

Terminal
docker compose ps

La sortie :

NAME IMAGE COMMAND SERVICE CREATED STATUS PORTS
ticket-api-api-1 ticket-api:1.1.0 "fastapi run --host …" api 3 seconds ago Up Less than a second 127.0.0.1:8000->8000/tcp
ticket-api-db-1 postgres:18.6-trixie "docker-entrypoint.s…" db 3 seconds ago Up 2 seconds (healthy) 5432/tcp

Le port 5432 de la base de données n’est pas publié : seule l’API, sur le même réseau, peut y accéder. docker compose logs api affiche le journal de l’API, avec les mêmes lignes de démarrage qu’avant. Elle vérifie que l’API est prête, envoie deux billets, puis lit l’historique :

Terminal
curl -s http://127.0.0.1:8000/ready
curl -s -X POST http://127.0.0.1:8000/v1/classify -H "Content-Type: application/json" -H "X-API-Key: local-dev-key" -d @requests/billing.json
curl -s -X POST http://127.0.0.1:8000/v1/classify -H "Content-Type: application/json" -H "X-API-Key: local-dev-key" -d '{"subject":"Parcel lost"}'
curl -s http://127.0.0.1:8000/v1/history -H "X-API-Key: local-dev-key"

La sortie (une ligne par commande; la dernière est longue) :

{"status":"ready"}
{"category":"billing","priority":1,"confidence":0.9,"model_version":"keywords-1.0"}
{"category":"shipping","priority":1,"confidence":0.7,"model_version":"keywords-1.0"}
{"items":[{"request_id":"1fe5dfeafae4","category":"shipping","priority":1,"confidence":0.7,"model_version":"keywords-1.0","created_at":"2026-10-07T16:58:28.691060Z"},{"request_id":"d2ef963e6b97","category":"billing","priority":1,"confidence":0.9,"model_version":"keywords-1.0","created_at":"2026-10-07T16:58:28.663288Z"}]}

Les plus récents d’abord, avec l’heure en UTC, sans le texte des billets. PowerShell peut modifier les guillemets dans -d '{"subject":"Parcel lost"}' avant que curl.exe les reçoive. Si vous obtenez 422, enregistrez le JSON dans un fichier à l’extérieur du projet, par exemple $HOME\parcel.json. Utilisez ensuite -d "@$HOME\parcel.json". (Non testé sur Windows.)

Pour consulter la base de données, elle exécute psql, le client en ligne de commande de PostgreSQL, dans le conteneur db. docker compose exec est l’équivalent de docker exec avec le nom du service :

Terminal
docker compose exec db psql -U tickets -d tickets -c "SELECT id, request_id, category, priority, created_at FROM classifications;"

La sortie :

id | request_id | category | priority | created_at
----+--------------+----------+----------+-------------------------------
1 | d2ef963e6b97 | billing | 1 | 2026-10-07 16:58:28.663288+00
2 | 1fe5dfeafae4 | shipping | 1 | 2026-10-07 16:58:28.69106+00
(2 rows)

Les deux mêmes lignes, dans l’ordre de leur écriture. Le SELECT demande cinq colonnes de chaque ligne de la table classifications.

Enfin, voici ce que Docker conserve sur le conteneur de l’API :

Terminal
docker inspect ticket-api-api-1 --format "{{json .Config.Env}}"

La sortie (abrégée) :

["REQUIRE_API_KEY=true","API_KEY_FILE=/run/secrets/api_key","DATABASE_URL=postgresql://tickets:change-me-local-only@db:5432/tickets",...]

La clé est un fichier, comme prévu. Mais le mot de passe de la base de données est dans DATABASE_URL, en texte clair. Ici, c’est un mot de passe local qui protège seulement une base de données sans port publié. Dans le nuage, le mot de passe serait un secret de la plateforme. L’application pourrait aussi utiliser une identité plutôt qu’un mot de passe (module 4). L’API peut également lire l’URL dans un fichier avec DATABASE_URL_FILE, de la même façon qu’avec API_KEY_FILE.

Pour tout arrêter :

Terminal
docker compose down
docker volume ls --filter name=ticket-api

La sortie :

Container ticket-api-api-1 Stopping
Container ticket-api-api-1 Stopped
Container ticket-api-api-1 Removing
Container ticket-api-api-1 Removed
Container ticket-api-db-1 Stopping
Container ticket-api-db-1 Stopped
Container ticket-api-db-1 Removing
Container ticket-api-db-1 Removed
Network ticket-api_default Removing
Network ticket-api_default Removed
DRIVER VOLUME NAME
local ticket-api_pgdata

down supprime les conteneurs et le réseau, mais pas le volume. Le prochain docker compose up -d crée de nouveaux conteneurs, et l’historique est toujours là.

Beaucoup d’anciens tutoriels montent le volume de PostgreSQL à /var/lib/postgresql/data. Depuis PostgreSQL 18, l’image officielle conserve ses fichiers dans /var/lib/postgresql/18/docker, un dossier pour chaque version majeure. Une mise à niveau ultérieure peut ainsi conserver les anciens et les nouveaux fichiers côte à côte. L’image déclare le dossier parent, /var/lib/postgresql, comme son volume :

Terminal
docker image inspect postgres:18.6-trixie --format "{{json .Config.Volumes}}"
docker compose exec db ls /var/lib/postgresql/18

La sortie :

{"/var/lib/postgresql":{}}
docker

Prédisez : que se passe-t-il avec l’ancien chemin, pgdata:/var/lib/postgresql/data? Amira l’a essayé dans une copie de compose.yaml, avec un nouveau volume vide :

Container ticket-api-db-1 Waiting
Container ticket-api-db-1 Error dependency db failed to start
dependency failed to start: container ticket-api-db-1 exited (1)

Le conteneur de la base de données s’est arrêté, et l’API n’a jamais démarré. docker compose ps -a affiche son état : Created (créé). Le journal de la base de données :

db-1 | Error: in 18+, these Docker images are configured to store database data in a
db-1 | format which is compatible with "pg_ctlcluster" (specifically, using
db-1 | major-version-specific directory names). This better reflects how
db-1 | PostgreSQL itself works, and how upgrades are to be performed.
...
db-1 | Counter to that, there appears to be PostgreSQL data in:
db-1 | /var/lib/postgresql/data (unused mount/volume)
...
db-1 | The suggested container configuration for 18+ is to place a single mount
db-1 | at /var/lib/postgresql which will then place PostgreSQL data in a
db-1 | subdirectory, allowing usage of "pg_upgrade --link" without mount point
db-1 | boundary issues.

Le message parle d’anciennes données, mais le volume était vide : l’image refuse tout montage à l’ancien chemin. Montez le volume à /var/lib/postgresql, comme dans le compose.yaml de ce cours. L’image échoue avec un message clair, ce qui est bien. Une base de données qui démarrerait en écrivant ses fichiers hors du volume les perdrait sans message.

  1. Faites les étapes 1 à 7 de Ajouter l’historique. Vous devriez obtenir 25 passed (25 tests réussis) et 52 lignes dans requirements-run.txt.
  2. Créez .env.example, .env et compose.yaml comme dans Écrire compose.yaml. Changez le mot de passe dans .env.
  3. Exécutez git status. Vérifiez que .env et secrets/ ne sont pas dans la liste, mais que .env.example et compose.yaml y sont.
  4. Démarrez les services : docker compose up -d --build.
  5. Exécutez docker compose ps. Attendez que db affiche (healthy) (sain) et que api affiche Up (en cours d’exécution).
  6. Appelez /ready. Vous devriez obtenir {"status":"ready"}. Si curl affiche (52) Empty reply from server (réponse vide du serveur), l’API démarre encore : attendez quelques secondes.
  7. Envoyez deux billets à /v1/classify avec la clé, puis appelez /v1/history avec la clé. Vous devriez obtenir deux éléments, le plus récent d’abord.
  8. Exécutez la requête psql dans le service db. Vous devriez obtenir les deux mêmes lignes.
  9. Remplacez le README.md du projet par la version ci-dessous.
  10. Laissez les services fonctionner pour l’exercice du module.
Afficher README.md
README.md
# ticket-api
A small web API that classifies support tickets. It runs in a container, alone or with a PostgreSQL database that keeps a history of the classifications.
## Run it in a container
You need Docker. The API listens on port 8000 in the container.
```sh
docker build -t ticket-api:1.1.0 .
docker run --rm -p 127.0.0.1:8000:8000 -e API_KEY=local-dev-key ticket-api:1.1.0
```
Check it: `curl http://127.0.0.1:8000/health` gives `{"status":"ok"}`.
## Run it with the database
```sh
cp .env.example .env # then change the password in .env
mkdir -p secrets
printf 'local-dev-key' > secrets/api_key.txt
docker compose up -d --build
```
- `docker compose down` stops and removes the containers. The data stays in the volume `ticket-api_pgdata`.
- `docker compose down --volumes` also deletes the data.
## Settings
| Setting | Required | Default | What it does |
|---|---|---|---|
| `API_KEY` or `API_KEY_FILE` | When `REQUIRE_API_KEY` is `true` | none | The key that clients send in `X-API-Key`. `API_KEY_FILE` is the path of a file that holds it. |
| `REQUIRE_API_KEY` | No | `false` | `true`: the API does not start without a key. |
| `DATABASE_URL` or `DATABASE_URL_FILE` | No | none | A PostgreSQL URL. Without it, history is off. |
| `ALLOWED_ORIGINS` | No | none | Sites that may call the API from a browser, separated by commas. |
| `SHOW_DOCS` | No | `true` | `false` hides `/docs`, `/redoc` and `/openapi.json`. |
| `CLASSIFIER_MODE`, `CLASSIFIER_TIMEOUT`, `LOG_LEVEL` | No | `keywords`, `2.0`, `INFO` | As in the API course. |
Never put a key or a password in the Dockerfile, in `compose.yaml` or in Git. `.env` and `secrets/` are in `.gitignore` and `.dockerignore`.
## Work on the code
```sh
python3 -m venv .venv # Windows: python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\Activate.ps1
python -m pip install -r requirements-lock.txt
python -m pytest
```
`requirements-run.txt` is the exact list of packages in the image. Make it again inside a container, so that it is the same on every computer:
```sh
docker run --rm python:3.14.8-slim-trixie sh -c 'pip install -q --root-user-action=ignore --disable-pip-version-check "fastapi[standard-no-fastapi-cloud-cli]==0.142.2" "psycopg[binary]==3.3.6" && pip freeze' > requirements-run.txt
```

Changez une condition : arrêtez seulement la base de données avec docker compose stop db. Avant de tester, prédisez le code d’état de /health, de /ready et de POST /v1/classify (avec la clé). Ensuite, testez et lisez les dernières lignes de docker compose logs api. Redémarrez la base de données avec docker compose start db.

Afficher la réponse
{"status":"ok"}
200
{"error":{"code":"history_unavailable","message":"The history is not available. Try again later.","request_id":"57da74011ff3","fields":[]}}
503
{"category":"billing","priority":1,"confidence":0.7,"model_version":"keywords-1.0"}
200

Il s’agit de /health, de /ready et d’une classification de {"subject":"Charged twice for March"}, chacun avec -w "\n%{http_code}\n". Le processus fonctionne (/health 200). Il n’est pas prêt, car l’historique ne répond pas (/ready 503). Il classe quand même (200). Le journal montre l’effet secondaire :

WARNING ticket_api: history unavailable: the database does not answer id=57da74011ff3
INFO ticket_api: GET /ready 503 18ms id=57da74011ff3
...
WARNING ticket_api: history not saved id=5ecc59a5fbbf
INFO ticket_api: POST /v1/classify 200 8ms id=5ecc59a5fbbf

Après docker compose start db, /ready renvoie de nouveau {"status":"ready"}. La classification faite pendant la panne n’est pas dans l’historique : l’API ne la conserve pas pour plus tard. C’est pourquoi /ready doit signaler le problème. Dans le module 5, la plateforme infonuagique lit /ready et n’envoie aucun trafic à une copie qui n’est pas prête. Elle ne la redémarre toutefois pas.

Erreur courante

L'historique est vide après docker compose down et up

Symptôme : l’historique fonctionnait et avait même survécu au redémarrage du conteneur de la base de données. Après docker compose down et docker compose up -d, /v1/history renvoie {"items":[]}. docker volume ls affiche un volume dont le long nom contient des chiffres et des lettres, mais aucun ticket-api_pgdata.

Cause : les données n’ont jamais été dans un volume nommé. Dans ce test, Amira a supprimé les deux sections volumes: de compose.yaml. L’image PostgreSQL déclare /var/lib/postgresql comme un volume. Docker a donc créé un volume anonyme : un volume sans nom, pour ce seul conteneur. docker compose up -d --force-recreate db le réutilise, donc les données semblaient en sécurité. docker compose down supprime le conteneur. Le prochain up crée un nouveau conteneur avec un nouveau volume anonyme. L’ancien reste sur votre ordinateur, inutilisé. Sans aucun volume, les données disparaissent encore plus tôt. Les fichiers dans la couche inscriptible d’un conteneur disparaissent avec lui.

Solution :

  1. Attribuez un volume nommé à toutes les données à conserver, aux deux endroits : - pgdata:/var/lib/postgresql sous le service, et pgdata: sous volumes: à la fin du fichier.
  2. Vérifiez après docker compose up -d : docker volume ls --filter name=ticket-api doit afficher ticket-api_pgdata.
  3. Trouvez l’ancien volume anonyme avec docker inspect <container> --format "{{json .Mounts}}" avant de supprimer le conteneur. Supprimez-le avec docker volume rm <its name>. Supprimez seulement les volumes que vous savez être les vôtres.

Pour un vrai service d’IA : la même erreur fait perdre plus qu’un historique. Pensez à un service qui écrit des commentaires, des étiquettes ou un index vectoriel dans les fichiers de son conteneur. Il fonctionne parfaitement dans une démonstration, puis perd tout à la première mise à jour. Décidez pour chaque fichier : jetable, dans un volume sur un ordinateur, ou dans une base de données ou un stockage d’objets qui survit à tous les conteneurs.

Vérifiez votre compréhension

Quelles commandes suppriment les conteneurs, et quelle commande supprime aussi l'historique?

Afficher la réponse

docker compose down arrête et supprime les conteneurs et le réseau. Le volume nommé ticket-api_pgdata reste, donc l’historique survit. docker compose down --volumes supprime aussi le volume, et avec lui l’historique. docker compose up -d --force-recreate api remplace seulement le conteneur de l’API. Ni le volume ni la base de données ne sont modifiés.

Vérifiez votre compréhension

Pourquoi DATABASE_URL utilise-t-il le nom d'hôte db, et non localhost ou 127.0.0.1?

Afficher la réponse

L’URL est utilisée dans le conteneur de l’API. À cet endroit, localhost et 127.0.0.1 désignent le conteneur de l’API lui-même, qui n’a pas de base de données. Compose place les deux services sur le réseau ticket-api_default, où chaque service est accessible par son nom. Ainsi, db:5432 permet d’atteindre le conteneur de la base de données. Le port 5432 n’est pas publié, donc les programmes à l’extérieur du réseau ne peuvent pas accéder à la base de données.

Exemple détaillé. L’API et la base de données d’Amira fonctionnent depuis l’exercice guidé. Elle prouve quelles données survivent au remplacement du conteneur de l’API :

Terminal
docker compose exec api sh -c "echo hello > /tmp/note.txt"
docker compose exec api cat /tmp/note.txt
docker compose up -d --force-recreate api
docker compose exec api cat /tmp/note.txt
curl -s http://127.0.0.1:8000/v1/history -H "X-API-Key: local-dev-key"

La sortie (l’historique est abrégé) :

hello
Container ticket-api-db-1 Running
Container ticket-api-api-1 Recreate
Container ticket-api-api-1 Recreated
Container ticket-api-db-1 Waiting
Container ticket-api-db-1 Healthy
Container ticket-api-api-1 Starting
Container ticket-api-api-1 Started
cat: /tmp/note.txt: No such file or directory
{"items":[{"request_id":"1fe5dfeafae4","category":"shipping",...},{"request_id":"d2ef963e6b97","category":"billing",...}]}

--force-recreate api crée un nouveau conteneur d’API même si rien n’a changé. Le fichier de l’ancien conteneur a disparu. Les deux classifications sont toujours là, car elles sont dans la base de données, dans le volume. Elle fait la même chose avec docker compose down, puis docker compose up -d : encore deux éléments. Ensuite, elle supprime volontairement les données :

Terminal
docker compose down --volumes
docker compose up -d
curl -s http://127.0.0.1:8000/v1/history -H "X-API-Key: local-dev-key"

La sortie (les dernières lignes de down, puis le nouvel historique) :

Volume ticket-api_pgdata Removing
Network ticket-api_default Removing
Volume ticket-api_pgdata Removed
Network ticket-api_default Removed
...
{"items":[]}

Votre tâche. Exécutez l’API avec sa dépendance et recréez les deux conteneurs. Montrez ensuite, preuves à l’appui, quelles données ont survécu :

  1. Démarrez les services et envoyez trois billets avec des sujets différents.
  2. Écrivez un fichier dans le conteneur de l’API.
  3. Recréez les deux conteneurs avec docker compose up -d --force-recreate api db.
  4. Montrez que le fichier a disparu, que l’historique contient encore trois éléments et que la base de données contient trois lignes (SELECT count(*) FROM classifications;).
  5. Écrivez deux phrases : quel état est jetable, quel état survit, et pourquoi.
Afficher une réponse modèle
Fenêtre de terminal
docker compose up -d
curl -s -X POST http://127.0.0.1:8000/v1/classify -H "Content-Type: application/json" -H "X-API-Key: local-dev-key" -d '{"subject":"Refund for order 1182"}'
curl -s -X POST http://127.0.0.1:8000/v1/classify -H "Content-Type: application/json" -H "X-API-Key: local-dev-key" -d '{"subject":"Cannot log in"}'
curl -s -X POST http://127.0.0.1:8000/v1/classify -H "Content-Type: application/json" -H "X-API-Key: local-dev-key" -d '{"subject":"Parcel lost"}'
docker compose exec api sh -c "echo hello > /tmp/note.txt"
docker compose up -d --force-recreate api db
docker compose exec api cat /tmp/note.txt
curl -s "http://127.0.0.1:8000/v1/history?limit=5" -H "X-API-Key: local-dev-key"
docker compose exec db psql -U tickets -d tickets -c "SELECT count(*) FROM classifications;"

Les résultats du test : les trois billets ont donné billing (facturation, priorité 2), login (connexion) et shipping (livraison). Les deux conteneurs ont été recréés (Recreate (recréer), Recreated (recréé), puis Healthy pour db). cat: /tmp/note.txt: No such file or directory (aucun fichier ou dossier de ce nom). L’historique contenait trois éléments, avec shipping en premier. Le nombre était :

count
-------
3
(1 row)

Le fichier était dans la couche inscriptible du conteneur de l’API, qui est supprimée avec le conteneur : il est jetable. Les classifications étaient dans les fichiers de PostgreSQL, dans le volume nommé ticket-api_pgdata, à l’extérieur de tous les conteneurs. Elles survivent jusqu’à docker compose down --volumes.

Vérifiez votre compréhension

Nommez l'option -p qui permet seulement à votre ordinateur d'accéder au port 8000 d'un conteneur, et le réglage qui empêche l'API de billets de démarrer sans clé.

Afficher la réponse

-p 127.0.0.1:8000:8000 (dans Compose : "127.0.0.1:8000:8000" sous ports:). REQUIRE_API_KEY=true : avec ce réglage, une clé manquante produit Settings error: REQUIRE_API_KEY is true, but API_KEY is not set (erreur de configuration : REQUIRE_API_KEY est vrai, mais API_KEY n’est pas défini) et le code de sortie 1.

Vérifiez votre compréhension

Vous exécutez docker compose down, puis docker compose up -d. Prédisez ce qui reste : le fichier /tmp/note.txt dans le conteneur de l'API, l'historique, le réseau, les conteneurs.

Afficher la réponse

L’historique est toujours là : il est dans le volume nommé ticket-api_pgdata, que down ne supprime pas. Le fichier a disparu, car il était dans la couche inscriptible de l’ancien conteneur. Le réseau et les conteneurs sont nouveaux : down les a supprimés, et up les a recréés avec les mêmes noms. Seul down --volumes supprime l’historique.

Vérifiez votre compréhension

Vérification de fin : confirmez, avec des commandes et leurs résultats, que les données nécessaires survivent, contrairement à l'état jetable du conteneur.

Afficher la réponse

Les preuves à présenter à une personne qui révise votre travail, tirées de l’exercice du module :

  1. Avant : /v1/history (avec la clé) énumère les classifications, et docker compose exec api cat /tmp/note.txt affiche hello (bonjour).
  2. docker compose up -d --force-recreate api db remplace les deux conteneurs.
  3. Après : cat /tmp/note.txt renvoie No such file or directory (aucun fichier ou dossier de ce nom; l’état jetable a disparu). /v1/history contient les mêmes éléments, et SELECT count(*) donne le même nombre (les données nécessaires ont survécu).
  4. docker volume ls --filter name=ticket-api affiche ticket-api_pgdata : l’endroit où les données survivent.
  5. L’explication : la couche inscriptible appartient à un conteneur, et le volume n’appartient à aucun conteneur.

Démontré : les cinq éléments, avec des sorties réelles. Partiellement démontré : les commandes sans les sorties avant et après, ou sans explication.

Récapitulatif

Dans ce module, vous avez :

  • publié le port de l’API seulement sur 127.0.0.1, trouvé le problème de l’adresse d’écoute 0.0.0.0 et permis aux conteneurs de se trouver par leur nom sur un réseau;
  • donné différents réglages à une même image au démarrage, conservé la clé dans un fichier secret en lecture seule (API_KEY_FILE) et fait arrêter l’application en cas de clé manquante (REQUIRE_API_KEY);
  • ajouté l’historique dans PostgreSQL (version 1.1.0, 25 tests) et exécuté l’API et sa base de données avec Compose. Les données sont dans le volume nommé ticket-api_pgdata;
  • prouvé que l’historique survit aux nouveaux conteneurs, contrairement aux fichiers dans un conteneur.

Réinitialisation. Pour tout arrêter et supprimer les données de l’exercice :

Fenêtre de terminal
docker compose down --volumes
docker image rm ticket-api:1.1.0-dev
docker volume ls
docker system df

Conservez ticket-api:1.1.0 : le module 5 l’utilise. Vérifiez dans docker volume ls que ticket-api_pgdata a disparu. Si votre projet ne fonctionne plus, copiez l’instantané end-of-m03. Recréez ensuite .env et secrets/api_key.txt, comme l’indique son README.md.

Ensuite, vous quittez votre ordinateur : découvrez les services de base de chaque nuage et leur correspondance avec les besoins de l’API sur Azure.

Leçon suivante : Régions et types de services