Premier déploiement en cinq minutes

14 min
Public
débutant, cluster Docker Desktop actif, kubectl v1.34.1 vérifié (leçon 03)
Durée
30 à 40 min
Module
1/8
Compétence visée
déployer une application sans YAML avec kubectl create deployment, l'exposer sur http://localhost:8080, la passer à trois réplicas, observer la répartition de charge et l'auto-réparation, puis tout supprimer proprement

En une image

Tu ouvres un kiosque avec un seul vendeur et une pancarte « API ». Le vendeur porte un badge avec son nom, et à chaque client il dit « bonjour, je suis jv2xw ». Quand la file s'allonge, tu dis au gérant « je veux trois vendeurs », et deux nouveaux badges apparaissent. Les clients se présentent au même guichet, mais c'est tantôt 4pl9z, tantôt nvshh qui répond : la charge se répartit. Si un vendeur s'en va, le gérant en a déjà appelé un autre avant que tu ne t'en aperçoives, parce que sa consigne, c'est « trois vendeurs », pas « ces trois-là ». C'est exactement ce que tu vas voir : traefik/whoami est un vendeur qui affiche son nom (le nom du Pod), le Deployment est le gérant, le Service est le guichet, et localhost:8080 est la porte du kiosque.

Comment ça marche

Quand tu tapes kubectl create deployment api --image=traefik/whoami:v1.10, il ne se crée pas un Pod mais trois objets emboîtés. Le Deployment api porte ton intention (« cette image, ce nombre de réplicas ») et sait gérer les mises à jour. Il crée un ReplicaSet api-9bfb55fc6, dont le seul métier est de maintenir exactement N Pods identiques ; le suffixe 9bfb55fc6 est un condensé du modèle de Pod (image, ports, labels), donc une nouvelle image donnera un nouveau ReplicaSet. Le ReplicaSet crée enfin les Pods api-9bfb55fc6-jv2xw, -4pl9z… avec un suffixe aléatoire de cinq caractères. Chaque Pod reçoit une IP interne (10.1.0.210) inaccessible depuis ton navigateur.

Pour y accéder, deux chemins. kubectl port-forward ouvre un tunnel temporaire depuis ton terminal vers un Pod : parfait pour vérifier, inutilisable pour répartir la charge, et il meurt avec Ctrl+C. Un Service est l'objet permanent : une IP stable dans le cluster (CLUSTER-IP), un nom DNS (api.premiers-pas.svc), et une liste d'adresses cibles tenue à jour par un contrôleur à partir des labels (app=api). De type LoadBalancer, il demande en plus une adresse externe ; sur Docker Desktop, le composant vpnkit-controller répond « localhost » et publie le port du Service sur ta machine. C'est ce qui rend http://localhost:8080 possible sans tunnel.

Cette leçon travaille en impératif : chaque commande dit au cluster ce qu'il doit faire, tout de suite. C'est le bon moyen d'obtenir une victoire rapide et de voir les objets apparaître. Dès le module suivant, tu écriras les mêmes objets en YAML et tu les appliqueras avec kubectl apply : c'est le mode déclaratif, rejouable et versionnable, celui de la vraie vie. Les deux produisent exactement les mêmes objets ; kubectl get deployment api -o yaml te montre déjà ce que create deployment a écrit à ta place.

Commande impérativeCe qu'elle crée ou changeÉquivalent déclaratif (module suivant)
kubectl create namespace premiers-pasun Namespacekind: Namespace
kubectl create deployment api --image=… --port=80un Deployment (+ ReplicaSet + Pod)kind: Deployment, spec.template.spec.containers[0].image
kubectl expose deployment api --type=LoadBalancer --port=8080 --target-port=80un Service qui cible app=apikind: Service, spec.type: LoadBalancer, ports[0].port: 8080, targetPort: 80
kubectl scale deployment api --replicas=3spec.replicas du Deploymentchanger replicas: 3 puis kubectl apply
kubectl delete pod <nom>supprime un Pod ; le ReplicaSet le remplacerien à écrire : c'est la réconciliation
ColonneDans get deployDans get rsDans get pods
READYPods prêts / voulus (3/3)Pods prêtsconteneurs prêts / total (1/1)
UP-TO-DATEPods au dernier modèle
DESIRED / CURRENTvoulus / existants
STATUSRunning, ContainerCreating, ImagePullBackOff

Pas à pas

Toutes les commandes portent -n premiers-pas. Si tu utilises l'alias k de la leçon 03, remplace kubectl par k.

  1. Créer le namespace du module. Un tiroir vide dans lequel tout ce qui suit sera rangé, et que la dernière étape supprimera d'un coup.

    bash
    kubectl create namespace premiers-pas

    Sortie réelle :

    text
    namespace/premiers-pas created

    Ce qu'il faut voir : created. Si tu lis AlreadyExists, tu l'avais gardé de la leçon 03 : ce n'est pas grave, continue.

  2. Créer le Deployment api. Une image publique, un tag figé (v1.10, jamais latest : sans tag précis, deux machines peuvent télécharger deux versions différentes et « ça marche chez moi » revient), et le port que le conteneur écoute.

    bash
    kubectl create deployment api --image=traefik/whoami:v1.10 --port=80 -n premiers-pas
    kubectl get deploy,rs,pods -n premiers-pas

    Sortie réelle (huit secondes après la création) :

    text
    deployment.apps/api created
    NAME                  READY   UP-TO-DATE   AVAILABLE   AGE
    deployment.apps/api   1/1     1            1           8s
    
    NAME                            DESIRED   CURRENT   READY   AGE
    replicaset.apps/api-9bfb55fc6   1         1         1       8s
    
    NAME                      READY   STATUS    RESTARTS   AGE
    pod/api-9bfb55fc6-jv2xw   1/1     Running   0          8s

    Ce qu'il faut voir : trois objets pour une commande. Lis le nom du Pod de droite à gauche : jv2xw = ce Pod précis (aléatoire), 9bfb55fc6 = le ReplicaSet, donc le modèle de Pod, api = le Deployment. Si STATUS dit ContainerCreating, l'image se télécharge (quelques secondes pour ses 10,6 Mo) ; retape la commande.

  3. Regarder où vit le Pod. -o wide ajoute l'IP du Pod et le nœud qui l'héberge.

    bash
    kubectl get pods -n premiers-pas -o wide

    Sortie réelle :

    text
    NAME                  READY   STATUS    RESTARTS   AGE   IP           NODE             NOMINATED NODE   READINESS GATES
    api-9bfb55fc6-jv2xw   1/1     Running   0          8s    10.1.0.210   docker-desktop   <none>           <none>

    Ce qu'il faut voir : une IP en 10.1.0.x, réseau interne du cluster, et le nœud docker-desktop. Ouvre http://10.1.0.210 dans ton navigateur : rien ne répond, cette adresse n'existe que dans la VM. C'est tout le sujet des deux étapes suivantes.

  4. Ouvrir un tunnel avec port-forward. Le terminal se bloque tant que le tunnel est ouvert ; c'est voulu.

    bash
    kubectl port-forward deployment/api 8080:80 -n premiers-pas

    Sortie réelle :

    text
    Forwarding from 127.0.0.1:8080 -> 80
    Forwarding from [::1]:8080 -> 80
    Handling connection for 8080

    Ouvre http://localhost:8080 dans le navigateur (la ligne Handling connection apparaît à chaque requête). Page affichée, telle que curl -s http://localhost:8080 la rend :

    text
    Hostname: api-9bfb55fc6-jv2xw
    IP: 127.0.0.1
    IP: ::1
    IP: 10.1.0.210
    IP: fe80::48b:d4ff:fe47:6adb
    RemoteAddr: 127.0.0.1:43450
    GET / HTTP/1.1
    Host: localhost:8080
    User-Agent: curl/8.21.0
    Accept: */*

    Ce qu'il faut voir : Hostname: api-9bfb55fc6-jv2xw, le nom du Pod, et son IP 10.1.0.210. Reviens au terminal et tape Ctrl+C : le tunnel se ferme, http://localhost:8080 ne répond plus. Un tunnel n'est pas une exposition.

  5. Exposer avec un Service LoadBalancer. --port est le port du Service (ce que tu tapes dans le navigateur), --target-port celui du conteneur.

    bash
    kubectl expose deployment api --type=LoadBalancer --port=8080 --target-port=80 -n premiers-pas
    kubectl get svc -n premiers-pas

    Sortie réelle :

    text
    service/api exposed
    NAME   TYPE           CLUSTER-IP       EXTERNAL-IP   PORT(S)          AGE
    api    LoadBalancer   10.101.121.221   localhost     8080:30153/TCP   3s

    Ce qu'il faut voir : EXTERNAL-IP vaut localhost, c'est la signature de Docker Desktop (sur un cloud tu lirais une adresse publique après une minute ; sur kind, <pending> pour toujours). PORT(S) dit 8080:30153 : 8080 est le port publié sur ta machine, 30153 un port de nœud attribué automatiquement dont tu n'as pas besoin ici. Recharge http://localhost:8080 sans aucun port-forward : la page whoami revient.

  6. Passer à trois réplicas et voir la charge se répartir. Le Deployment change de consigne ; le ReplicaSet crée deux Pods de plus ; le Service les ajoute à sa liste dès qu'ils sont prêts.

    bash
    kubectl scale deployment api --replicas=3 -n premiers-pas
    kubectl get pods -n premiers-pas

    Sortie réelle :

    text
    deployment.apps/api scaled
    NAME                  READY   STATUS    RESTARTS   AGE
    api-9bfb55fc6-4pl9z   1/1     Running   0          7s
    api-9bfb55fc6-jv2xw   1/1     Running   0          82s
    api-9bfb55fc6-nvshh   1/1     Running   0          7s

    Recharge http://localhost:8080 six fois (ou, dans un second terminal, curl -s http://localhost:8080 | findstr Hostname sous Windows, | grep Hostname sous bash). Six sorties réelles consécutives :

    text
    Hostname: api-9bfb55fc6-nvshh
    Hostname: api-9bfb55fc6-jv2xw
    Hostname: api-9bfb55fc6-4pl9z
    Hostname: api-9bfb55fc6-4pl9z
    Hostname: api-9bfb55fc6-4pl9z
    Hostname: api-9bfb55fc6-nvshh

    Ce qu'il faut voir : trois noms différents, dans un ordre qui n'est pas un tour de rôle strict (la répartition est aléatoire, pas circulaire). Le navigateur, lui, garde parfois la même connexion ouverte et te montre trois fois le même nom : ferme l'onglet et rouvre-le, ou utilise curl. Pour voir les Pods naître en direct plutôt qu'après coup, garde un second terminal avec kubectl get pods -n premiers-pas -w (-w pour watch, Ctrl+C pour sortir) : la pratique 05 s'en sert à chaque étape.

  7. Supprimer un Pod et regarder le remplacement. Tu tues un vendeur ; le gérant en rappelle un avant que tu aies fini de lire.

    bash
    kubectl delete pod api-9bfb55fc6-jv2xw -n premiers-pas
    kubectl get pods -n premiers-pas

    Sortie réelle (la seconde commande lancée immédiatement après la première) :

    text
    pod "api-9bfb55fc6-jv2xw" deleted from premiers-pas namespace
    NAME                  READY   STATUS    RESTARTS   AGE
    api-9bfb55fc6-4pl9z   1/1     Running   0          66s
    api-9bfb55fc6-nvshh   1/1     Running   0          66s
    api-9bfb55fc6-xg8qt   1/1     Running   0          3s

    Ce qu'il faut voir : jv2xw a disparu, xg8qt a 3s d'âge : le ReplicaSet a constaté « 2 Pods, j'en veux 3 » et en a créé un, avec un nouveau nom. RESTARTS reste à 0 : ce n'est pas un redémarrage du même Pod, c'est un Pod neuf. Le site n'a jamais cessé de répondre, les deux autres Pods absorbaient le trafic.

  8. Lire les journaux. kubectl logs accepte un Pod ou, plus pratique, le Deployment (il en choisit un).

    bash
    kubectl logs deployment/api -n premiers-pas

    Sortie réelle :

    text
    Found 3 pods, using pod/api-9bfb55fc6-4pl9z
    2026/09/11 01:41:49 Starting up on port 80

    Ce qu'il faut voir : whoami est silencieux, une ligne au démarrage et c'est tout. Pour un Pod précis : kubectl logs api-9bfb55fc6-4pl9z -n premiers-pas ; pour suivre en direct : -f. C'est ici que tu liras, plus loin dans le cours, les erreurs d'une application qui plante.

  9. Faire l'inventaire du tiroir. get all résume les quatre types d'objets que tu as créés.

    bash
    kubectl get all -n premiers-pas

    Sortie réelle :

    text
    NAME                      READY   STATUS    RESTARTS   AGE
    pod/api-9bfb55fc6-4pl9z   1/1     Running   0          72s
    pod/api-9bfb55fc6-nvshh   1/1     Running   0          72s
    pod/api-9bfb55fc6-xg8qt   1/1     Running   0          9s
    
    NAME          TYPE           CLUSTER-IP       EXTERNAL-IP   PORT(S)          AGE
    service/api   LoadBalancer   10.101.121.221   localhost     8080:30153/TCP   75s
    
    NAME                  READY   UP-TO-DATE   AVAILABLE   AGE
    deployment.apps/api   3/3     3            3           2m27s
    
    NAME                            DESIRED   CURRENT   READY   AGE
    replicaset.apps/api-9bfb55fc6   3         3         3       2m27s

    Ce qu'il faut voir : 3/3 partout, un seul Service, un seul ReplicaSet. Tu as créé deux objets à la main (Deployment, Service) ; le cluster a créé les quatre autres.

  10. Jeter un œil au YAML écrit à ta place. Tout objet Kubernetes est un document YAML stocké dans etcd ; -o yaml te le rend tel quel. C'est l'avant-goût du module 2.

    bash
    kubectl get deployment api -n premiers-pas -o yaml

    Sortie réelle (les champs metadata de suivi et une partie du status sont coupés) :

    yaml
    apiVersion: apps/v1
    kind: Deployment
    metadata:
      generation: 2
      labels:
        app: api
      name: api
      namespace: premiers-pas
    spec:
      replicas: 3
      selector:
        matchLabels:
          app: api
      template:
        spec:
          containers:
          - image: traefik/whoami:v1.10
            imagePullPolicy: IfNotPresent
            name: whoami
            ports:
            - containerPort: 80
    status:
      availableReplicas: 3
      readyReplicas: 3
      replicas: 3

    Ce qu'il faut voir : spec est ce que tu as demandé (replicas: 3 depuis ton scale, d'où generation: 2), status ce que le cluster observe ; la boucle de réconciliation de la leçon 01 ne fait que rapprocher le second du premier. Les valeurs que tu n'as jamais tapées (imagePullPolicy: IfNotPresent, et une section strategy: RollingUpdate coupée ici) sont des défauts que le module 3 t'apprendra à régler. Le même Deployment et son Service, écrits à la main en YAML complet, sont dans le kit du cours : 01-installer-kubernetes-avec-docker-desktop/04-api-declaratif.yaml (kubectl apply -f sur ce fichier recrée les deux objets ; vérifié : deployment.apps/api created, service/api created).

  11. Tout supprimer d'un coup et le prouver. Supprimer le namespace supprime tout ce qu'il contient ; la commande bloque jusqu'à ce que ce soit fait (47 secondes sur la machine du cours).

    bash
    kubectl delete namespace premiers-pas
    kubectl get all -n premiers-pas
    kubectl get namespace premiers-pas

    Sortie réelle :

    text
    namespace "premiers-pas" deleted
    No resources found in premiers-pas namespace.
    Error from server (NotFound): namespaces "premiers-pas" not found

    Ce qu'il faut voir : plus rien, et http://localhost:8080 ne répond plus (le navigateur affiche « Ce site est inaccessible », curl répond Connection refused). Le port 8080 est rendu à ta machine : la leçon 05 et les modules suivants le réutilisent.

Si ça coince

  • kubectl get pods -n premiers-pas montre api-… 0/1 ImagePullBackOff (ou ErrImagePull) → le tag de l'image n'existe pas ou Docker Hub refuse de servir l'image. kubectl describe pod <nom> -n premiers-pas, section Events, donne la raison ; sur la machine du cours, avec le tag inexistant traefik/whoami:v9.99 : Failed to pull image "traefik/whoami:v9.99": Error response from daemon: failed to resolve reference "docker.io/traefik/whoami:v9.99": docker.io/traefik/whoami:v9.99: not found. Un autre message possible, 429 Too Many Requests, signale que Docker Hub limite les téléchargements anonymes : attends ou connecte-toi avec docker login. Corrige le tag avec kubectl set image deployment/api whoami=traefik/whoami:v1.10 -n premiers-pas (la pratique 05 le fait pas à pas).

  • kubectl get svc affiche EXTERNAL-IP <pending> au lieu de localhost → sur Docker Desktop, cela signifie presque toujours que le port demandé est déjà publié par un autre Service LoadBalancer (message obtenu en exposant un second Service sur 8080 : il reste <pending>, le premier garde le port). kubectl get svc -A montre qui occupe le port ; choisis-en un autre (--port=8081) ou supprime le doublon. Si tu n'es pas sur Docker Desktop (kind, minikube), <pending> est l'état normal : utilise port-forward.

  • kubectl port-forward répond Unable to listen on port 8080 (suivi, sous Windows, de bind: Only one usage of each socket address … is normally permitted) → le port local 8080 est déjà pris par une autre application. Non reproduit sur la machine du cours, où un LoadBalancer sur 8080 et un port-forward sur 8080 ont cohabité sans erreur (Windows accepte une écoute sur 0.0.0.0 et une autre sur 127.0.0.1), ce qui rend le résultat ambigu. Dans tous les cas, change le port local : kubectl port-forward deployment/api 9090:80 -n premiers-pas, puis http://localhost:9090.

  • Le navigateur montre toujours le même Hostname après scale --replicas=3 → HTTP keep-alive : le navigateur réutilise sa connexion vers le même Pod. Ce n'est pas un défaut de répartition. Ferme l'onglet, rouvre, ou compare avec curl, qui ouvre une connexion neuve à chaque appel.

  • Error from server (NotFound): deployments.apps "api" not found-n premiers-pas oublié ; kubectl a cherché dans default. Toutes les commandes de ce cours portent -n.

À retenir

  • kubectl create deployment api --image=traefik/whoami:v1.10 --port=80 -n premiers-pas crée trois objets : Deployment api → ReplicaSet api-9bfb55fc6 → Pod api-9bfb55fc6-jv2xw ; le nom d'un Pod se lit de droite à gauche.
  • L'IP d'un Pod (10.1.0.x) n'est joignable que dans le cluster ; kubectl port-forward est un tunnel temporaire vers un Pod, un Service est l'accès permanent et réparti.
  • kubectl expose deployment api --type=LoadBalancer --port=8080 --target-port=80 : sur Docker Desktop, EXTERNAL-IP vaut localhost et http://localhost:8080 répond sans tunnel.
  • kubectl scale deployment api --replicas=3 : trois Pods, et Hostname change d'un appel curl à l'autre, dans un ordre aléatoire.
  • Supprimer un Pod géré par un Deployment ne fait que le remplacer par un Pod neuf (nouveau suffixe, RESTARTS 0) : c'est la réconciliation, pas un redémarrage.
  • kubectl delete namespace premiers-pas supprime tout le contenu du tiroir en une commande ; kubectl get namespace premiers-pas doit ensuite répondre NotFound.
  • kubectl get <objet> -o yaml montre le document que create et expose ont écrit à ta place : spec = demandé, status = observé.
  • Prochaine leçon : la pratique guidée, où tu installes le kit du cours (labo.ps1 / labo.sh), refais ce déploiement, le casses deux fois et le répares.

Pour aller plus loin

Sur Docker Desktop, seul le port du LoadBalancer (8080) est publié sur localhost ; le port de nœud qui l'accompagne (30153 dans 8080:30153) ne répond pas depuis ta machine (vérifié : connexion refusée), alors qu'un Service de type NodePort créé seul répond bien sur localhost:<port>. Sur un cluster cloud, c'est l'inverse qui compte : LoadBalancer provoque la création d'un équilibreur facturé chez le fournisseur (ELB, Cloud Load Balancing, Azure LB) et EXTERNAL-IP devient une adresse publique après une à deux minutes ; c'est pourquoi on n'expose pas chaque application ainsi mais derrière un unique Ingress, que le cours introduit plus loin. Les trois types de Service et leur portée exacte : kubernetes.io/docs/concepts/services-networking/service/.