Compose
Objectifs
- Estimer son travail
- Créer un Makefile
- Utiliser Traefik
- Connecter les services d'un Docker Compose
- Appliquer les twelve-factor app
Rendu
- Rapport individuel en Markdown sur Cyberlearn avant le prochain cours
- Nom du fichier:
lab03-compose-{nom}.md
- Délai: 1 semaine
- Nom du fichier:
Tâches
Estimer son travail
- Estimez le temps total nécessaire pour réaliser ce laboratoire
- Découpez le travail en tâches pour faciliter l'estimation
- A la fin du rapport, comparez le temps estimé avec le temps réellement passé:
Tâche Temps estimé Temps réel Commentaire ... 30m 45m ... ... ... ... ... Total 2h 1h30 ...
Git
- Mettez tout votre travail sur une branche
feature/03-compose
et faites une merge request (MR) surmain
en m'ajoutant comme reviewer - Séparez votre travail en commits cohérents
- Utilisez des messages de commit clairs et concis
Créer un Makefile
- Créez un
Makefile
à la racine pour automatiser les tâches suivantes:make install
pour installer les dépendances (backend et frontend)make dev-backend
pour démarrer le backend en mode développementmake dev-frontend
pour démarrer le frontend en mode développementmake dev-database
pour démarrer uniquement la database
- Indiquez votre démarche dans le rapport
Utiliser Traefik
Pour éviter des probèmes de CORS avec le frontend, nous allons utiliser Traefik comme reverse proxy afin de servir le frontend et le backend sur le même domaine.
Ajoutez Traefik au
docker-compose.yml
afin de rediriger les requêtes comme suit:- Commençant par
/
vers le frontend - Commençant par
/api
vers le backend
- Commençant par
Utilisez la rule PathPrefix pour rediriger les requêtes vers le bon service
Pour le backend, utilisez le StripPrefix afin de supprimer le préfixe
/api
avant de rediriger la requête- Une fois le middleware créé, il faut l'ajouter au service. Voir l'exemple
De plus, il faut configurer le root_path de FastAPI pour qu'il corresponde au préfixe
/api
Afin de pouvoir changer la valeur du
root_path
grâce à une variable d'environnement, modifiez le fichiermain.py
comme suit:pythonfrom typing import Union from os import getenv // [!code focus] from fastapi import FastAPI app = FastAPI(root_path=getenv("ROOT_PATH")) // [!code focus] @app.get("/") def read_root(): return {"Hello": "World"} @app.get("/items/{item_id}") def read_item(item_id: int, q: Union[str, None] = None): return {"item_id": item_id, "q": q}
Indiquez votre démarche dans le rapport
Solution compose.yml
services:
reverse-proxy:
image: traefik:v2.10
command:
- "--providers.docker=true"
- "--entrypoints.web.address=:80"
ports:
- "80:80"
volumes:
- /var/run/docker.sock:/var/run/docker.sock
frontend:
labels:
- "traefik.http.routers.frontend.rule=PathPrefix(`/`)"
backend:
labels:
- "traefik.http.routers.backend.rule=PathPrefix(`/api`)"
- "traefik.http.routers.backend.middlewares=backend-stripprefix"
- "traefik.http.middlewares.backend-stripprefix.stripprefix.prefixes=/api"
Connecter le backend à la database
On va utiliser SQLAlchemy pour connecter le backend à la database en suivant la documentation de FastAPI
- Installez le package
sqlalchemy
etpsycopg2
dans le backendpoetry add sqlalchemy psycopg2
- Créez/modifiez les fichiers suivants dans
/backend/backend/
afin d'avoir un service CRUD :
- Installez le package
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
# Remplacez les valeurs par les valeurs de votre database
DATABASE_URL = "postgresql://user:password@postgresserver/db"
engine = create_engine(DATABASE_URL, echo=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
from sqlalchemy import Column, Double, Integer, String
from .database import Base
class Product(Base):
__tablename__ = "products"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, index=True, nullable=False)
description = Column(String, index=True)
price = Column(Double, index=True, nullable=False)
from pydantic import BaseModel
class ProductBase(BaseModel):
name: str
description: str | None = None
price: float
class ProductCreate(ProductBase):
pass
class Product(ProductBase):
id: int
class ConfigDict:
from_attributes = True
from os import getenv
from fastapi import Depends, FastAPI, HTTPException
from sqlalchemy.orm import Session
from . import models, schemas
from .database import SessionLocal, engine
models.Base.metadata.create_all(bind=engine)
app = FastAPI(root_path=getenv("ROOT_PATH"))
@app.get("/")
def read_root():
return {"Hello": "World"}
def get_db():
db = SessionLocal()
try:
yield db
finally:
db.close()
@app.post("/products/", response_model=schemas.Product)
def create_product(product: schemas.ProductCreate, db: Session = Depends(get_db)):
db_product = models.Product(**product.model_dump())
db.add(db_product)
db.commit()
db.refresh(db_product)
return db_product
@app.get("/products/", response_model=list[schemas.Product])
def read_products(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
return db.query(models.Product).offset(skip).limit(limit).all()
@app.get("/products/{product_id}", response_model=schemas.Product)
def read_product(product_id: int, db: Session = Depends(get_db)):
db_product = (
db.query(models.Product).filter(models.Product.id == product_id).first()
)
if db_product is None:
raise HTTPException(status_code=404, detail="Product not found")
return db_product
@app.delete("/products/{product_id}", response_model=schemas.Product)
def delete_product(product_id: int, db: Session = Depends(get_db)):
db_product = read_product(product_id, db)
db.delete(db_product)
db.commit()
return db_product
- Démarrez le backend
poetry run uvicorn backend.main:app --reload
et testez les endpoints sur http://localhost:8000/docs - Vérifiez que le Docker Compose fonctionne toujours
docker compose up --build
et corrigez au besoin- Indiquez vos corrections dans le rapport
- Modifiez le backend pour qu'il suive les twelve-factors app et configurez-le correctement dans le Docker Compose
- Utilisez les mêmes variables d'environnement que la database ainsi que leur valeur par défaut
- Extraire le mot de passe de la database dans un .env pour s'assurer qu'il soit le même dans les deux services
- Ajoutez une vraie dépendance à la database dans le backend en utilisant un healthcheck, voici un exemple
- Changez le mot de passe de la database et vérifiez que le backend fonctionne toujours (il faudra peut-être supprimer le volume de la database pour la recréer)
- Pour lancer le backend en utilisant le
.env
, on va utiliser python-dotenvpoetry add --group dev python-dotenv
poetry run dotenv -f ../.env run uvicorn backend.main:app --reload
- Indiquez vos modifications (autres que celles indiquées) dans le rapport
Solution /backend/backend/database.py
from os import getenv
from sqlalchemy import create_engine
from sqlalchemy.orm import declarative_base, sessionmaker
postgres_user = getenv("POSTGRES_USER", "postgres")
postgres_password = getenv("POSTGRES_PASSWORD", "postgres")
postgres_host = getenv("POSTGRES_HOST", "localhost")
postgres_db = getenv("POSTGRES_DB", postgres_user)
DATABASE_URL = (
f"postgresql://{postgres_user}:{postgres_password}@{postgres_host}/{postgres_db}"
)
engine = create_engine(DATABASE_URL, echo=True)
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
Base = declarative_base()
Solution /backend/Dockerfile
Il faut ajouter des librairies supplémentaires pour que le backend fonctionne avec PostgreSQL:
RUN apk add --no-cache \
musl-dev \
postgresql-dev
Ajoutez un frontend
- Dans le dossier
/frontend/
, ajoutez/modifiez les fichiers suivants afin de configurer les environnements et le proxy :
VITE_BACKEND_URL=/api
/// <reference types="vite/client" />
interface ImportMetaEnv {
readonly VITE_BACKEND_URL: string;
}
interface ImportMeta {
readonly env: ImportMetaEnv;
}
import { fileURLToPath, URL } from "node:url";
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
// https://vitejs.dev/config/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
"@": fileURLToPath(new URL("./src", import.meta.url)),
},
},
server: {
proxy: {
"/api": {
target: "http://127.0.0.1:8000",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, ""),
},
},
},
});
On va utiliser Milligram comme framework CSS :
npm install milligram
Modifier le fichier
frontend/src/main.ts
comme suit :tsimport "milligram/dist/milligram.min.css"; import { createApp } from "vue"; import App from "./App.vue"; createApp(App).mount("#app");
Supprimez tous les fichiers sous
/frontend/src/assets/
et/frontend/src/components/
Sous
/frontend/src/
, créez/modifiez les fichiers suivants :
<script setup lang="ts">
import ProductManager from "./components/ProductManager.vue";
</script>
<template>
<div class="container">
<ProductManager />
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, reactive } from "vue";
interface CreateProduct {
name: string;
description?: string;
price: number;
}
interface Product {
id: number;
name: string;
description?: string;
price: number;
}
const products = ref<Product[]>([]);
const product = reactive<CreateProduct>({
name: "",
price: 0,
});
onMounted(() => {
updateTable();
});
function updateTable() {
fetch(`${import.meta.env.VITE_BACKEND_URL}/products/`)
.then((response) => response.json())
.then((data) => {
console.log(data);
products.value = data;
});
}
function createProduct(product: CreateProduct) {
fetch(`${import.meta.env.VITE_BACKEND_URL}/products/`, {
method: "POST",
body: JSON.stringify(product),
headers: {
"Content-Type": "application/json",
},
}).then(() => {
product.name = "";
product.description = undefined;
product.price = 0;
updateTable();
});
}
function deleteProduct(id: number) {
fetch(`${import.meta.env.VITE_BACKEND_URL}/products/${id}`, {
method: "DELETE",
}).then(() => {
updateTable();
});
}
</script>
<template>
<div class="row">
<div class="column">
<table>
<thead>
<tr>
<th>#</th>
<th>Name</th>
<th>Description</th>
<th>Price</th>
<th></th>
</tr>
</thead>
<tbody>
<tr v-for="product in products" :key="product.id">
<td>{{ product.id }}</td>
<td>{{ product.name }}</td>
<td>{{ product.description }}</td>
<td>{{ product.price }}</td>
<td>
<button
class="button button-outline"
@click="deleteProduct(product.id)"
>
Delete
</button>
</td>
</tr>
</tbody>
</table>
</div>
<div class="column">
<form>
<fieldset>
<label for="name">Name</label>
<input v-model="product.name" type="text" id="name" />
<label for="description">Description</label>
<textarea v-model="product.description" id="description" />
<label for="price">Price</label>
<input v-model="product.price" type="number" id="price" />
<input
class="button-primary"
type="submit"
value="Create"
@click.prevent="createProduct(product)"
/>
</fieldset>
</form>
</div>
</div>
</template>
- Démarrez le frontend
npm run dev
et testez l'application (avec le backend et la database démarrés) - Vérifiez que le Docker Compose fonctionne
Docker Registry
- Poussez les images Docker sur le GitLab Registry
- Documentation
- Les noms des images sont préfixés par l'adresse du registry (défaut au Docker Hub)
- Exemple:
registry.gitlab.com/username/project/image:tag
- Exemple:
- Doit fonctionner avec
docker compose push