Convertir un projet Docker Compose en Kubernetes, partie 1
Depuis sa sortie en 2015, la hype autour de Kubernetes (K8s) n'a cessée de croître. L'outil a même écrasé son rival Swarm, qui bénéficiait pourtant d'un avantage solide en étant porté par les créateurs de la solution de conteneurisation dominante : Docker.
Cet orchestrateur de conteneurs a la réputation d'être complexe à appréhender. Cassons ce mythe via une expérimentation "en douceur". Nous allons essayer d'y migrer une application actuellement orchestrée avec Docker Compose grâce à un outil de conversion automatique.
À l'issue de cette première partie, vous aurez un environnement Kubernetes local ainsi qu'une application web pouvant y être exécutée.
Un peu de contexte
Présentation de Kubernetes
Cette plateforme permet l'automatisation du déploiement et la gestion d'applications conteneurisées sur un cluster de serveurs. Les faisant fonctionner comme une seule entité et ainsi permettre une haute disponibilité et une résilience accrue des applications s'y exécutant.
Son nom provient du Grec et peut être traduit par "timonier", désignant celui qui tient la barre du navire et qui fait en sorte d'appliquer les directives du capitaine. Dans le monde de K8s, le capitaine, c'est vous et vos ordres prennent la forme de fichiers définissant des diverses entités pour permettre le déploiement, le maintien ainsi que la mise à l'échelle d'application.
Avantages et inconvénients
Quelques raisons qui peuvent justifier son utilisation :
- Simplification de la gestion d'une multitude d'applications conteneurisées
- Mise à l'échelle dynamique des applications en fonction des besoins
- Permet une haute disponibilité des services
- Automatisation des mises à jours, retour en arrière, et mise à jour progressive d'une application
Et des contextes pour lesquels l'utilisation de Kubernetes me semble moins bénéfique :
- Dans des projets de faible envergure avec peu d'utilisateurs prévus
- Quand la disponibilité du service proposé n'est pas un enjeu majeur
- Au démarrage d'un nouveau projet ?
Architecture de l'application existante
Pour tester en condition réelle la migration d'un projet orchestré via Docker Compose vers Kubernetes il nous faut ... une application existante. J'ai donc choisi d'utiliser une version allégée du jeu de Hex que l'on a développé avec Jean-Baptiste durant notre intégration chez Marmelab.
L'application n'est ni parfaitement fonctionnelle, ni très jolie esthétiquement, mais elle bénéficie d'une architecture proche de ce qu'on trouve dans nos projets clients.
Pour récupérer le projet :
$ git clone --branch hex-game-k8s-starter https://github.com/atilbian/Hex/
$ cd Hex/
L'application est construite à partir des éléments suivants :
- Backend NestJS (un framework basé sur Express)
- Frontend généré côté serveur par NestJS
- Base de données PostgreSQL
- Proxy inverse NGINX
- Back office React Admin
Après un essai infructueux d'utilisation de Amazon Elastic Container Service, nous avions fait le choix de déployer la partie backend sur une machine virtuelle via Amazon EC2.
Le back office est quant à lui hébergé sur Amazon S3.
Nous avions 2 environnements distincts, un de développement et un de production, chacun étant défini par un fichier Compose spécifique.
Un troisième fichier, docker-compose.base.yaml
, contient la configuration partagée entre les deux environnements :
version: '3'
services:
nestjs:
container_name: nestjs
build:
context: ./web-app/
depends_on:
- postgres
environment:
- WAIT_HOSTS=postgres:5432
env_file:
- ./web-app/.env
postgres:
container_name: postgres
image: postgres:10.4
restart: always
ports:
- 5432:5432
env_file:
- ./.docker/db/.env.db
Et voici le contenu du fichier docker-compose.prod.yaml
:
version: '3'
services:
nestjs:
build:
dockerfile: ./Dockerfile.prod
expose:
- 3000
postgres:
volumes:
- db-data-prod:/var/lib/postgresql/data
nginx:
container_name: nginx
image: nginx
build:
context: ./.docker/nginx/
dockerfile: Dockerfile.nginx
ports:
- 80:80
depends_on:
- nestjs
restart: always
volumes:
db-data-prod:
Nous pouvons maintenant démarrer les différents services de l'environnement de développement :
$ docker-compose -f docker-compose.base.yaml -f docker-compose.dev.yaml up -d
Vérifions que l'application fonctionne bien :
$ curl http://localhost
Si l'envie vous prend, vous pouvez même jouer contre l'intelligence artificielle, mais attention elle n'est ni très maline ni particulièrement optimisée.
Démarrage de la migration
Conversion assistée par Kompose
On va pouvoir se concentrer sur ce qui nous intéresse, la migration de ce projet sur K8s.
Pour se faciliter le travail on va essayer un outil de conversion automatique de Docker Compose vers Kubernetes : Kompose.
Une fois installé, on peut tenter de convertir notre projet avec cette commande :
$ kompose convert -f docker-compose.base.yaml -f docker-compose.prod.yaml
INFO Kubernetes file "nestjs-service.yaml" created
INFO Kubernetes file "nginx-service.yaml" created
INFO Kubernetes file "postgres-service.yaml" created
INFO Kubernetes file "nestjs-deployment.yaml" created
INFO Kubernetes file "web-app--env-configmap.yaml" created
INFO Kubernetes file "nginx-deployment.yaml" created
INFO Kubernetes file "postgres-deployment.yaml" created
INFO Kubernetes file "docker-db--env-db-configmap.yaml" created
INFO Kubernetes file "db-data-prod-persistentvolumeclaim.yaml" created
Des fichiers yaml ont été générés, ils définissent les objets K8s dont on a besoin, ainsi que leurs propriétés. Kompose utilise une matrice de conversion pour passer des fichiers Compose aux .yaml de K8s, elle est décrite ici : https://kompose.io/conversion/.
Pour éclairer un peu ce qui vient de se passer, il faut d'abord introduire la plus petite primitive Kubernetes : le pod.
: Un pod (terme anglo-saxon décrivant un groupe de baleines ou une gousse de pois) est un groupe d'un ou plusieurs conteneurs (comme des conteneurs Docker). https://kubernetes.io/fr/docs/concepts/workloads/pods/pod/.
Faisons maintenant un point sur les différentes entités utilisées :
- Deployment : déclaration du comportement de création et mise à jour de pods
- Service : point d'entrée réseau d'un ensemble de pods identiques
- ConfigMap : gestion des variables environnement consommées par les pods
- PersistentVolumeClaim : définit un volume de données persistant utilisable par des pods
Il existe d'autres éléments constitutifs d'une architecture K8s.
Les différentes primitives disponibles. Source.
Voici un diagramme offrant une vue globale de la hiérarchie entre les principaux objets manipulables.
Maintenant que nous avons les fichiers définissant les ressources requises, il nous faut un cluster pour les instancier et les manipuler.
Déploiement dans un cluster local
Plusieurs solutions valables existent, pour ma part j'ai décidé d'utiliser le cluster K8s local fourni par Docker Desktop car l'outil est déjà installé sur ma machine. Cet article compare différentes solutions existantes.
Limite de Docker Desktop
Le cluster géré par la solution Docker Desktop est limité à un seul node, c'est à dire une seule machine physique ou virtuelle permettant l'exécution des pods. Dans un contexte de production il est souhaitable d'augmenter le nombre de noeuds pour exploiter le potentiel de K8s.
Il est nécessaire d'installer ensuite l'outil en ligne de commande kubectl qui est notre principal levier d'action sur notre cluster en permettant par exemple de déployer une application, la mettre à jour ou encore d'obtenir ses logs.
Nous pouvons à présent tenter d'exécuter notre jeu de Hex dans l'environnement K8s mis en place.
Pour cela nous allons utiliser une commande phare qui permet d'appliquer nos fichiers de configuration à notre cluster pour qu'il génère les objets associés.
Dans le dossier contenant les fichiers créés par Kompose, exécutez la commande suivante :
$ kubectl apply -f .
persistentvolumeclaim/db-data-prod configured
configmap/docker-db--env-db configured
deployment.apps/nestjs created
service/nestjs configured
deployment.apps/nginx created
service/nginx configured
deployment.apps/postgres created
service/postgres configured
configmap/web-app--env configured
L'opération s'est bien déroulée et nous avons créé nos premières ressources K8s en mode déclaratif.
Tentons d'inspecter ce qui s'est passé au sein de notre cluster en listant les pods présents, comme ceci :
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
nestjs-6485fd7cc5-6mfl7 0/1 ContainerCreating 0 3s
nginx-6ccd78bcd9-v8n8j 0/1 ContainerCreating 0 3s
postgres-7f964d64b9-7nb2p 1/1 Running 0 3s
Nous avons des pods relatifs à nos services Docker Compose qui existent, mais le status de certains semblent indiquer que la création est en cours, attendons un peu avant de retenter l'opération.
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
nestjs-6485fd7cc5-6mfl7 0/1 ImagePullBackOff 0 53s
nginx-6ccd78bcd9-v8n8j 1/1 Running 0 53s
postgres-7f964d64b9-7nb2p 1/1 Running 0 53s
Cette fois-ci le résultat est différent ! On a 2 pods qui ont l'air de bien fonctionner, mais le démarrage du pod NestJS a généré une erreur liée à la récupération de son image Docker. La documentation peut nous en apprendre plus à ce sujet : https://kubernetes.io/docs/concepts/containers/images/#imagepullbackoff.
Nous allons devoir mettre (un tout petit peu) les mains dans le cambouis pour résoudre ce problème.
Gestion des images Docker
D'abord, essayons de récupérer des informations supplémentaires à partir de l'id du pod en erreur.
$ kubectl logs nestjs-6485fd7cc5-6mfl7
Error from server (BadRequest): container "nestjs" in pod "nestjs-6485fd7cc5-6mfl7" is waiting to start: trying and failing to pull image
On en apprend pas beaucoup plus. On peut essayer d'ouvrir le fichier qui définit le pod de notre backend.
Dans le fichier nestjs-deployment.yaml
, on peut retrouver trouver l'image Docker utilisée par nos conteneurs :
image: hex
On utilise l'image hex
. Mais pour l'instant elle n'existe que sur notre machine. Comme l'indique la documentation, il faut pousser notre image dans un registre en ligne avant de l'utiliser.
On doit donc d'abord créer un repository dans un registre Docker, j'ai choisi d'utiliser Docker Hub mais on peut en choisir un autre.
Ensuite, on peut builder notre image en utilisant le nom correspondant et la pousser dans le repository :
$ docker build -f Dockerfile.prod -t atilbian/hex:0.0.1 .
$ docker push atilbian/hex:0.0.1`
Il nous reste à cibler cette image dans notre Compose de production.
nestjs:
- build:
- dockerfile: ./Dockerfile.prod
+ image: atilbian/hex:0.0.1
Puis on reproduit les étapes précédentes :
$ kompose convert -f docker-compose.base.yaml -f docker-compose.prod.yaml
$ kubectl apply -f .
$ kubectl get pods
NAME READY STATUS RESTARTS AGE
nestjs-669fb77d9c-wmfnc 1/1 Running 0 6s
nginx-6ccd78bcd9-9pq9m 1/1 Running 0 6s
postgres-7f964d64b9-8mdl5 1/1 Running 0 6s
$ kubectl get deployments nestjs -o wide
NAME READY UP-TO-DATE AVAILABLE AGE CONTAINERS IMAGES SELECTOR
nestjs 1/1 1 1 42s nestjs atilbian/hex:0.0.1 io.kompose.service=nestjs
Notre pod tourne et on utilise bien l'image poussée dans notre repository, youpi !
Par contre, on aurait dû avoir le même souci avec NGINX car on utilise aussi une image locale. Cependant, on ne voit pas d'erreur. C'est parce que le nom de notre image local est le même que celui de l'image officielle. Donc on récupère bien une image, mais pas celle que l'on souhaite. Subtil.
Pour valider cette hypothèse on peut regarder si notre fichier de configuration est présent dans le conteneur.
$ kubectl exec nginx-6ccd78bcd9-r4nvp -- sh -c "ls /etc/nginx/conf.d"
default.conf
Ce n'est pas le cas, donc le pod utilise l'image officielle NGINX et non la nôtre.
Pour faire au plus simple on va se contenter de pousser notre image NGINX dans le registre et la référencer dans notre Compose comme on l'a fait pour NestJS. Mais il y a d'autres approches possibles, par exemple, on pourrait garder l'image officielle mais injecter la configuration spécifique via un configmap dans les conteneurs Nginx.
Exposition de notre service NGINX
Maintenant que tout est corrigé, l'application devrait être accessible depuis http://localhost. Mais non ce n'est toujours pas le cas. En fait, on est tout proche du but. Par défaut le port 80 de NGINX est uniquement accessible à l'intérieur de notre cluster. On doit donc trouver un moyen de le rendre accessible depuis l'extérieur.
Pour permettre l'accès externe, on peut créer un port forwarding provisoire qui va créer un tunnel entre notre machine et le pod du proxy inverse :
$ kubectl port-forward nginx-65b7bfb657-kw5vv 80:80
Forwarding from 127.0.0.1:80 -> 80
Forwarding from [::1]:80 -> 80
Et accéder à notre site via http://localhost. Et là ça marche.
Pour pérenniser cette manipulation, on peut utiliser les labels pour modifier le résultat le conversion de Kompose.
Rajoutons ces lignes dans notre fichier Compose de production :
nginx:
container_name: nginx
image: atilbian/hex-nginx:0.0.1
ports:
- 80:80
depends_on:
- nestjs
restart: always
+ labels:
+ kompose.service.type: nodeport
+ kompose.service.nodeport.port: 32000
On va ensuite reconvertir notre projet :
$ kompose convert -f docker-compose.base.yaml -f docker-compose.prod.yaml
La commande apply
de kubectl permet non seulement de créer des ressources mais aussi de mettre à jour les celles existantes. Il nous suffit d'utiliser la même syntaxe que précédemment :
$ kubectl apply -f .
Nous devrions maintenant avoir accès à l'application via le port choisi : http://localhost:32000.
En production, il faudrait choisir une approche différente, via un ingress placé devant nos services, comme par exemple le ingress-nginx. Cette ressource permet de :
- Router des requêtes clients vers des services internes au cluster
- Équilibrer la charge entre les différents noeuds de notre cluster
- Gérer des certificats SSL
Nous avons atteint le but initial, c'est à dire faire fonctionner notre application à l'intérieur d'un cluster K8s, et cela presque sans encombre.
Cependant, la route est encore longue avant une utilisation en production. La partie 2 de cet article pour objectif de vous donner les principales pistes à creuser pour y parvenir.
Tableau de correspondances des commandes entre Docker et K8s
Pour finir en douceur, voici un tableau repris et enrichi de commandes Docker et leurs équivalents K8s. Il pourrait vous servir dans la suite de ce voyage initiatique :
Action | Docker | Kubernetes |
---|---|---|
Démarrer un conteneur | docker run --nom nginx nginx | kubectl create deployment nginx --image=nginx |
Voir les conteneurs | docker ps | kubectl get pods |
Obtenir la configuration | docker inspect nom | kubectl describe nom |
Afficher les logs | docker logs nom | kubectl logs nom |
Accéder au conteneur | docker exec -ti nom /bin/bash | kubectl exec -ti nom -- /bin/bash |
Nettoyage | docker system prune -a | kubectl delete all --all |
Conclusion
Nous avons réussi à exécuter une application précédemment orchestrée via Docker Compose dans notre propre cluster local.
L'usage de Kompose nous a grandement facilité la tâche en établissant la colonne vertébrale de notre migration via la création des ressources nécessaires.
On a en prime découvert quelques commandes qui nous resserviront par la suite.
Cependant, nous n'avons mis qu'un pied dans l'univers Kubernetes, et pour être honnête notre application ne bénéficie pour le moment pas de ses avantages, pire encore, sa gestion pâtit d'une complexité augmentée.
Dans la seconde partie de cet article, nous utiliserons quelques fonctionnalités clés de K8s et débroussaillerons la voie pour une mise en production future.
Le code final de la partie 1 se trouve ici.