Déployer une application Python avec Docker
Dans cet article, vous allez découvrir comment déployer, avec Docker, une application Python. Pour les besoins de l’article, j’utiliserai le Framework FastAPI qui nous permettra de déployer une API REST.
Commençons par un peu de théorie sur la virtualisation puis nous passerons à la pratique. Aucune connaissance préalable n’est requise. À la fin de cet article, vous serez en mesure de le faire vous-même.
Isolation de systèmes : Machines Virtuelles et Conteneurs
Les machines virtuelles
Une machine virtuelle ou VM est un environnement entièrement virtualisé qui fonctionne sur une machine physique. Elle exécute son propre système d’exploitation (OS) et bénéficie des mêmes équipements qu’une machine physique : CPU, mémoire RAM, disque dur et carte réseau. Plusieurs machines virtuelles avec différents OS peuvent coexister sur le même serveur physique : Linux, MacOS, Windows…
Les conteneurs
Plus jeune dans l’univers informatique, Docker est un outil qui peut empaqueter une application et ses dépendances dans un conteneur isolé. Celui-ci pourra être exécuté sur n’importe quel serveur. Il ne s’agit pas de virtualisation, mais de conteneurisation, une forme plus légère qui s’appuie sur certaines parties de la machine hôte pour son fonctionnement. Cette approche permet d’accroître la flexibilité et la portabilité d’exécution d’une application.
Synthèse
Voici un tableau synthétisant les différences entre Machines Virtuelles et Conteneurs :
| Machine virtuelle | Conteneur Docker |
|---|---|
| Isolation des processus au niveau matériel | Isolation des processus au niveau du système d’exploitation |
| Chaque machine virtuelle a un système d’exploitation distinct | Chaque conteneur peut partager le système d’exploitation |
| Démarre en quelques minutes | Démarre en quelques secondes |
| Les machines virtuelles sont de quelques Go | Les conteneurs sont généralement légers (Ko / Mo) |
| Les VM prêtes à l’emploi sont difficiles à trouver | Les conteneurs Docker pré-construits sont facilement disponibles |
| Les VM peuvent facilement se déplacer vers un nouvel hôte | Les conteneurs sont détruits et recréés au lieu de se déplacer |
| La création de VM prend un temps relativement plus long | Les conteneurs peuvent être créés en quelques secondes |
| Plus d’utilisation des ressources | Moins d’utilisation des ressources |
Créer une application
Avant de traiter la problématique de la conteneurisation, nous devons créer une application.
L’application Python que nous allons mettre en place, pour les besoins de l’article, est une API composée d’une requête GET et dont voici le code :
from fastapi import FastAPI
app = FastAPI()
@app.get('/')
async def root() -> str:
return 'Salut à tous !'
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
À ce stade, vous devriez pouvoir exécuter l’application avec rien de plus que la commande suivante :
python "main.py"
Créer un Dockerfile
Maintenant que nous avons une API fonctionnelle, nous allons créer un fichier nommé Dockerfile. Le fichier Dockerfile est un fichier texte qui décrit l’environnement utilisé par l’application et dont j’expliquerai chaque ligne en détail.
Choisir une distribution
La première ligne du Dockerfile doit faire référence à une distribution. La communauté Python met à disposition librement sur le Hub Docker plusieurs distributions. Comme je travaille avec la version 3.11 de Python et que par défaut l’OS qu’ils exploitent est de type Unix, je ne spécifie que la version du langage :
FROM python:3.11
Utilisateur et répertoire de travail
Les deux lignes suivantes ajoutent un groupe d’utilisateurs, un utilisateur avec le nom user et un répertoire personnel sous le répertoire /home/user. Elles garantissent que tout ce que vous faites à l’intérieur du conteneur est fait avec un utilisateur régulier. Ces lignes permettent d’anticiper et d’éviter les problèmes de privilèges plus tard :
RUN useradd -ms /bin /bash user
USER user
Ensuite, pour ne pas avoir à spécifier le répertoire personnel de travail à chaque commande (par défaut /), il faut le changer. Cela se fait à l’aide de WORKDIR :
WORKDIR /home/user
Installer les dépendances
Maintenant que l’environnement est prêt, nous devons nous occuper des dépendances. Pour installer FastAPI et uvicorn dans mon environnement de développement, j’ai fait appel à la commande pip install fastapi uvicorn. Toutefois, en production, il est absolument nécessaire de figer la version des librairies. Pour cela, j’utilise pip freeze dans mon environnement de développement et je récupère la version de FastAPI et uvicorn. J’obtiens les versions suivantes : 0.63.0 pour FastAPI et 0.13.4 pour uvicorn.
Je n’ai plus qu’à copier les deux lignes dans un fichier requirements.txt :
fastapi==0.100.0
uvicorn==0.23.1
On commence par copier requirements.txt dans le Dockerfile :
COPY requirements.txt .
Puis, on appelle pip pour installer les librairies définies dans requirements.txt :
RUN pip install -r requirements.txt && rm requirements.txt
Vous remarquerez qu’une fois les librairies ont été installées, je supprime requirements.txt.
La présence des && qui sépare l’installation et la suppression indique que les 2 actions seront effectuées l’une après l’autre au sein d’une même étape unique.
Copier les sources et construire l'image du conteneur
Pour finir, qu’en est-il du code source ? Nous allons pouvoir enfin le copier :
COPY src .
Il m’est difficile de deviner où vous placez vos fichiers de test, mais il est très probable que vous ne les vouliez pas dans votre conteneur Docker. Gardez cela à l’esprit si vous souhaitez optimiser votre conteneur. Par souci de simplicité, cette partie a été laissée de côté.
Comme notre application est une API, elle a besoin de communiquer sur un port précis. Or, le port utilisé par notre application est 8 000. Nous utiliserons donc l’instruction EXPOSE pour ouvrir ce port. De plus, grâce à cette instruction, nous pouvons facilement inventorier les ports exposés dans l’environnement de production.
EXPOSE 8000
La dernière ligne concerne le démarrage de l’application. Il nous suffit de reprendre la ligne de commande que l’on exécutait manuellement :
ENTRYPOINT ["python", "main.py"]
Pour finir, il ne nous reste plus qu’à générer l’image du conteneur tout en lui donnant un nom.
Pour cela, il nous suffit d’ouvrir une invite de commande et exécuter la commande suivante :
docker image build . -t "ma_petite_api"
Le résultat attendu sera du type :
...
Successfully built 41712ca5cdad
Successfully tagged ma_petite_api:latest
Et maintenant, il ne nous reste plus qu’à faire tourner le conteneur. Nous allons utiliser la commande suivante pour charger un conteneur de l’image précédemment construite :
docker container run -t ma_petite_api:latest -p 8000:8000
Le résultat attendu est le même que celui que vous aviez en appelant vous-même le script :
INFO: Started server process [1]
INFO: Waiting for application startup.
INFO: Application startup complete.
INFO: Uvicorn running on http://0.0.0.0:8000
Pour finir, même si vous fermez votre invite de commande, sachez que votre service sera toujours accessible puisque docker continuera de faire tourner votre conteneur en arrière plan.
Code complet
Avant de conclure, voici le code complet que vous devriez avoir si vous avez suivi l’ensemble des étapes précédemment exposées dans cet article :
src/main.py
from fastapi import FastAPI
app = FastAPI()
@app.get('/')
async def root() -> str:
return 'Salut à tous !'
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
requirements.txt
fastapi==0.100.0
uvicorn==0.23.1
Dockerfile
FROM python:3.11
RUN useradd -ms /bin /bash user
USER user
WORKDIR /home/user
COPY requirements.txt .
RUN pip install -r requirements.txt && rm requirements.txt
COPY src .
EXPOSE 8000
ENTRYPOINT ["python", "main.py"]
Le mot de la fin
Dans cet article, nous avons vu comment créer une application Python exécutée dans un conteneur Docker. À ce stade, vous devriez pouvoir le faire vous-même pour n’importe quel projet. En complément, je vous invite vraiment à pousser plus loin l’exploration de tout ce qu’il est possible de faire avec Docker. C’est un univers passionnant qui ouvre beaucoup de perspective.
Côté exploration, intéressez-vous à l’optimisation de vos Dockerfile (nettoyer les dépendances, bind mount, …) ainsi qu’à la création et gestion d’un écosystème composé de plusieurs Conteneurs Docker (docker compose). Sinon, pour approfondir au maximum le sujet, vous pouvez aussi suivre notre cours sur Docker !
Travailler avec les conteneurs est quelque chose que je recommande fortement. C’est une compétence devenue incontournable dans le monde des développeurs qui vous simplifiera la vie pour développer et tester vos applications sereinement.
Si vous avez d’autres questions, n’hésitez pas à les poser dans les commentaires.
Bonjour. Merci pour le tutoriel.
J’ai 2 questions.
Dans la phase de développement du code Python, est ce qu’on peut le faire dans le conteneur ou est ce que le développement se fait hors du conteneur ?
La 2ème question suit la première, on a créé une image et fait tourner le conteneur. Mais on a un bug dans le code. Il faut corriger le code et refaire une nouvelle image ?
Merci
Bonjour et merci pour ta question.
J’allais poster bientôt un article sur ce sujet (jeudi) comme quoi !
La phase de développement se fait hors conteneur dans un environnement (pyenv / conda) dédié.
Avant d’aller plus avant dans les détails, il faut garder en mémoire que le Dockerfile décrit comment builder (construire) une image fonctionnelle de ton code qui fonctionnera partout de la même manière peut importe la machine ou l’OS de base qui l’exécutera.
Quand tu démarres (instancie) une image, on appelle cela un conteneur, c’est l’image “en vie”.
Durant la phase de développement, c’est toujours très intéressant de faire fonctionner ton code dans un conteneur. Pourquoi ? Car de cette façon tu garanties que le comportement de code sera identique en production.
Alors comment faire ?
Dans les faits, tu devrais rebuilder à chaque fois l’image. Clairement, c’est long et galère. Mais, en étant malin on peut se passer de builder l’image en phase de dev. Comment ?
Et bien, dans le Dockerfile, tu activeras, avec fastapi, le flag –reload, qui permettra au conteneur de voir si les fichiers sources ont changés. Enfin, il n’est pas forcément obliger de refaire une nouvelle image si tu n’ajoutes pas de dépendance supplémentaire à ton code. L’idée, c’est de builder une image, puis, dans la commande qui run le conteneur, de monter en volume ton code source de façon à remplacer le code source qui a été sauvegarder dans l’image.
Ainsi, quand ton conteneur se charge, il voit comme volume le code source de ta machine local, et grâce au –reload, il recharge le code automatiquement à chaque de fichier ! La classe non ? 🙂
Exemple concret:
Mettons que notre code source python (ou pas) soit dans le répertoire /app du docker.
Il est possible de remplacer le contenu du docker /app par mon répertoire courant de travail.
Pour cela je vais créer un lien entre le répertoire /app du docker et mon répertoire courant de travail localement sur machine. On appelle cela le bind mount.
En exemple avec le répertoire courant:
docker run -p 8000:8000 -w /app -v "C:/Chemin_vers_mon_repertoire_local:/app" -t nom-de-mon-app .Pq rm requirement.txt?
Bonjour Nis,
La raison est simple, c’est pour alléger le layer docker.
Aujourd’hui je dirais même plus, il n’est plus nécessaire de
COPYlesrequirements.txtet il est possible de le monter directement à la volée dans le conteneur en faisant par exemple :RUN --mount=requirement.txt,target=/requirement.txt \pip install requirements.txt
Cela à plusieurs avantages :
– Dans le
DockerfilelesCOPYreflètent alors uniquement les libs nécessaires à l’image ;– Cela allège les layers ;
– Cela permet de gérer par exemple des dépendances confidentielles.