Aller au contenu

Mon second déploiement avec Kompose

Nous devons recréer un cluster k3d d'une manière que le port interne 80 (où le contrôleur ingress traefik écoute) soit exposé au système (en port 8180). Nous allons aussi configurer le cluster avec deux noeuds "worker" virtuels.

k3d cluster delete mycluster &&\
k3d cluster create mycluster-lb -p "8180:80@loadbalancer" --agents 2

Puis changer de contexte et de namespace comme au début du tutoriel :

kubectx k3d-mycluster-lb &&\
k create ns tuto &&\
kubens tuto

De Docker à k8s avec Kompose

Maintenant que nous avons vu toutes les étapes qui mènent au déploiement complet d'une application Jupyter, nous reprenons les 3 fichiers générés avec Kompose dans la partie du tutoriel sur Docker qui sont un déploiement, un service de type ClusterIP et un PersistentVolumeClaim. Nous les plaçons dans un dossier à part afin de les appliquer ensemble :

jupyter-service.yaml
jupyter-deployment.yaml
jupyter-claim0-persistentvolumeclaim.yaml

Pour appliquer tous au cluster k8s :

k apply -f . && k get all

Et vous devez obtenir :

NAME                           READY   STATUS         RESTARTS   AGE
pod/jupyter-7b8bbbf8b7-brpjq   0/1     ErrImagePull   0          14s

NAME              TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)    AGE
service/jupyter   ClusterIP   10.43.169.51   <none>        8888/TCP   14s

NAME                      READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/jupyter   0/1     1            0           14s

NAME                                 DESIRED   CURRENT   READY   AGE
replicaset.apps/jupyter-7b8bbbf8b7   1         1         0       14s

Il y a une erreur de type ErrImagePull dans le pod et cela est du au nom de l'image qu'il faut changer dans le yaml (nous utilisons l'image de base de Jupyter).

...
    spec:
      containers:
        - image: jupyter/base-notebook # <-- modifié
          name: jupyter
          env:
            - name: JUPYTER_PORT
              value: "8888"

...

Variable d'environnement

Il faut aussi ajouter la variable d'environnement JUPYTER_PORT pour que le conteneur fonctionne. L'initialisation standard ajoute une mauvaise valeur dans le cas d'un cluster multi-node.

Ensuite réappliquer les manifests.

k apply -f .

Comme lors du premier déploiement, il y a des ReplicaSet associé au déploiement. Il est possible de voir l'historique des updates de pod via :

k rollout history deployment jupyter
deployment.apps/jupyter
REVISION  CHANGE-CAUSE
1         <none>
2         <none>

Volumes

Nous avons utilisé des nouveaux objets que sont les Volumes/persistentVolumeClaim pour garder les données entre redémarrages de pod. Il est possible de les afficher avec :

k get pv
NAME                                       CAPACITY   ACCESS MODES   RECLAIM POLICY   STATUS   CLAIM                 STORAGECLASS   VOLUMEATTRIBUTESCLASS   REASON   AGE
pvc-8bb4749f-5b6c-45ab-b1a5-ef850ea39353   100Mi      RWO            Delete           Bound    tuto/jupyter-claim0   local-path     <unset>                          10s

Ce volume est créé à partir d'un PersistentVolumeClaim (PVC) qui déclare des caractéristiques d'un Volume (il est aussi possible de créer le volume manuellement, mais c'est peu pratique et ce n'est pas souvent utilisé).

k get pvc
NAME             STATUS   VOLUME                                     CAPACITY   ACCESS MODES   STORAGECLASS   VOLUMEATTRIBUTESCLASS   AGE
jupyter-claim0   Bound    pvc-901b9698-5ee0-47e6-b465-4bf5dabb448a   100Mi      RWO            local-path     <unset>                 16m

Nous pouvons inspecter le manifest responsable du PersistentVolumeClaim :

cat jupyter-claim0-persistentvolumeclaim.yaml 
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  labels:
    io.kompose.service: jupyter-claim0
  name: jupyter-claim0
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 100Mi

L'option clé du PVC est le StorageClass (SC, on utilise StorageClass par défaut si manqué) :

k get sc
NAME                   PROVISIONER             RECLAIMPOLICY   VOLUMEBINDINGMODE      ALLOWVOLUMEEXPANSION   AGE
local-path (default)   rancher.io/local-path   Delete          WaitForFirstConsumer   false                  177m

Ici le seul SC fournie avec k3d est de type local-path. Les données de ces volumes sont stockés dans un dossier local des noeud. On peut avoir des SCs différentes en fonction de l'installation particuliaire de k8s.

Remarque

Une autre option importante est le AccessModes. Avec ReadWriteOnce (RWO) le volume peut être attaché à un seul noeud en mode read-write, ReadOnlyMany (ROX) permet d'attacher le volume à tous les noeuds en mode readonly et ReadWriteMany (RWX) est utilisé si on a besoin d'écrire sur le volume à partir des différents noeuds de façon concourante.

Exposer le port web de l'application

Dans les parties précédentes, nous avons utilisé soit le service de type NodePort soit le port-forwarding pour accéder à l'application. Pour exposer l'application web (protocole HTTP/HTTPS) nous allons créer une autre ressource k8s : Ingress.

Créez le manifest :

myjupyter-lb.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: jupyter 
  annotations:
    ingress.kubernetes.io/ssl-redirect: "false"
spec:
  rules:
  - http:
      paths:
      - path: /
        pathType: Prefix
        backend:
          service:
            name: jupyter 
            port:
              number: 8888

Et l'appliquez :

k apply -f myjupyter-lb.yaml

Nous pouvons accéder à la définition d'ingress :

k describe ingress jupyter
Name:             jupyter
Labels:           <none>
Namespace:        tuto
Address:          172.19.0.2,172.19.0.3,172.19.0.4
Ingress Class:    traefik
Default backend:  <default>
Rules:
  Host        Path  Backends
  ----        ----  --------
  *           
              /   jupyter:8888 (10.42.2.6:8888)
Annotations:  ingress.kubernetes.io/ssl-redirect: false
Events:       <none>

L'application Jupyter est maintenant accessible :

open http://localhost:8180

Configuration d'application

Nous allons configurer le mot de passe de l'application Jupyter Lab en utilisant le Configmap et le Secret. Nous allons utiliser quelques techniques fréquentes.

Le Configmap sera utilisé pour stocker un script qui trouve un hash du mot de passe et l'écrit au fichier de la configuration. Le Secret gardera le mot-de-passe lui-même.

Écrivons le script dans un fichier .py :

initpwd.py
#!/usr/bin/env python

from jupyter_server.auth import passwd
import os
import json

if os.getenv('JUPYTER_PWD') is not None:
    pwd = passwd(os.getenv('JUPYTER_PWD'))


    cfg_dir = os.path.join(os.getenv('HOME'), '.jupyter')
    os.makedirs(cfg_dir, exist_ok=True)
    cfg_json = os.path.join(cfg_dir, 'jupyter_server_config.json')

    with open(cfg_json, 'w') as fd:
        json.dump({"IdentityProvider": {"hashed_password": pwd}}, fd)
else:
    print("JUPYTER_PWD environment variable is not set.")

A partir de ce fichier, nous créons le manifeste k8s (nous utilisons pour cela --dry-run=client pour créer le manifeste en local sans l'appliquer pour le moment) :

k create cm initscript --from-file initpwd.py --dry-run=client -o yaml | tee configmap.yaml

Nous vérifions le manifeste :

Manifeste de Configmap
configmap.yaml
apiVersion: v1
data:
  initpwd.py: |
    #!/usr/bin/env python

    from jupyter_server.auth import passwd
    import os
    import json

    if os.getenv('JUPYTER_PWD') is not None:
        pwd = passwd(os.getenv('JUPYTER_PWD'))

        cfg_dir = os.path.join(os.getenv('HOME'), '.jupyter')
        os.makedirs(cfg_dir, exist_ok=True)
        cfg_json = os.path.join(cfg_dir, 'jupyter_server_config.json')

        with open(cfg_json, 'w') as fd:
            json.dump({"IdentityProvider": {"hashed_password": pwd}}, fd)
    else:
        print("JUPYTER_PWD environment variable is not set.")
kind: ConfigMap
metadata:
  creationTimestamp: null
  name: initscript

Et nous l'appliquons :

k apply -f configmap.yaml

De même manière nous créons et appliquons le manifeste du Secret :

 k create secret generic jupyter-pwd --from-literal password=very_secure_jupyter_pwd --dry-run=client -o yaml | tee secret.yaml &&\
 k apply -f secret.yaml
secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: jupyter-pwd
type: Opaque
data:
  password: dmVyeV9zZWN1cmVfanVweXRlcl9wd2Q=

Note

Notez que les valeurs sont codé avec base64 par défaut. Ce n'est pas un cryptage. En production nous devons utiliser des techniques supplémentaires pour protéger les secrets.

Pour consulter la valeur du secret il est utile d'utiliser le mode de sortie jsonpath qui permet choisir une partie du manifeste d'objet d'API k8s :

k get secrets jupyter-pwd -o jsonpath='{.data.password}' | base64 -d ; echo

very_secure_jupyter_pwd

Ce mode de sortie est disponible pour toutes les ressources k8s.

Nous modifions ensuite le Deploiement en ajoutant le conteneur d'initialisation initContainer qui est exécuté avant le conteneur principal. Nous y montons l'initscript qui est stocké dans Configmap comme un fichier. Pour partager le fichier de configuration entre initContainer et conteneur principal on utilise un volume de type emptyDir qui est le volume éphémère.

Nouveau Deployment
jupyter-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  annotations:
    kompose.cmd: kompose --file compose.yaml convert
    kompose.version: 1.35.0 (9532ceef3)
  labels:
    io.kompose.service: jupyter
  name: jupyter
spec:
  replicas: 1
  selector:
    matchLabels:
      io.kompose.service: jupyter
  strategy:
    type: Recreate
  template:
    metadata:
      annotations:
        kompose.cmd: kompose --file compose.yaml convert
        kompose.version: 1.35.0 (9532ceef3)
      labels:
        io.kompose.service: jupyter
    spec:
      containers:
        - image: jupyter/base-notebook 
          name: jupyter
          ports:
            - containerPort: 8888
              protocol: TCP
          env:
            - name: JUPYTER_PORT
              value: "8888"
            - name: JUPYTER_PWD
              valueFrom:
                secretKeyRef:
                  name: jupyter-pwd
                  key: password
          volumeMounts:
            - mountPath: /home/jovyan/work/local
              name: jupyter-claim0
            - name: jupyter-confdir
              mountPath: /home/jovyan/.jupyter
      initContainers:
        - image: jupyter/base-notebook
          name: pwd-init
          command: 
            - python 
            - /initpwd.py
          volumeMounts:
            - name: initscript
              mountPath: /initpwd.py
              subPath: initpwd.py
            - name: jupyter-confdir
              mountPath: /home/jovyan/.jupyter
      restartPolicy: Always
      volumes:
        - name: jupyter-claim0
          persistentVolumeClaim:
            claimName: jupyter-claim0
        - name: initscript
          configMap:
            name: initscript
        - name: jupyter-confdir
          emptyDir: {}

Mince !

Nous devons avoir une instance d'application Jupyter avec un mot de passe configuré accessible au http://localhost:8180. Mais il semble que la configuration de mot de passe n'est pas appliquée. Essayez de localiser la faute et fixez-la.

Solution

Le problème c'est que le variable d'environnement a été défini pour le conteneur principal, tandis qu'il faut être mis au conteneur init. C'est évident en analysant les logs du conteneur init :

k logs jupyter-7f9588fbf5-hpqxn -c pwd-init 
JUPYTER_PWD environment variable is not set.

Le manifeste correct est :

jupyter-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  annotations:
    kompose.cmd: kompose --file compose.yaml convert
    kompose.version: 1.35.0 (9532ceef3)
  labels:
    io.kompose.service: jupyter
  name: jupyter
spec:
  replicas: 1
  selector:
    matchLabels:
      io.kompose.service: jupyter
  strategy:
    type: Recreate
  template:
    metadata:
      annotations:
        kompose.cmd: kompose --file compose.yaml convert
        kompose.version: 1.35.0 (9532ceef3)
      labels:
        io.kompose.service: jupyter
    spec:
      containers:
        - image: jupyter/base-notebook 
          name: jupyter
          ports:
            - containerPort: 8888
              protocol: TCP
          env:
            - name: JUPYTER_PORT
              value: "8888"
          volumeMounts:
            - mountPath: /home/jovyan/work/local
              name: jupyter-claim0
            - name: jupyter-confdir
              mountPath: /home/jovyan/.jupyter
      initContainers:
        - image: jupyter/base-notebook
          name: pwd-init
          command: 
            - python 
            - /initpwd.py
          volumeMounts:
            - name: initscript
              mountPath: /initpwd.py
              subPath: initpwd.py
            - name: jupyter-confdir
              mountPath: /home/jovyan/.jupyter
          env:
            - name: JUPYTER_PWD
              valueFrom:
                secretKeyRef:
                  name: jupyter-pwd
                  key: password
      restartPolicy: Always
      volumes:
        - name: jupyter-claim0
          persistentVolumeClaim:
            claimName: jupyter-claim0
        - name: initscript
          configMap:
            name: initscript
        - name: jupyter-confdir
          emptyDir: {}

Autres spécificités

Gestion des ressources

Les spécifications des ressources sont optionnels, mais elles sont aussi conseillés. Il existe deux types des demandes de ressources, request et limit. Si on met le request pour le conteneur dans un Pod, le scheduler les utilise pour décider dans quel Node mieux l'exécuter. Le kubelet assure aussi que conteneur possède toujours au moins request quantité de ressources.

Les limites sont assurées par kubelet. Les limites de CPU sont strictes, assurées par le mécanisme de cgroups. Aucun processus dans le conteneur ne peut l'utiliser plus que déclaré. Par contre, si un conteneur dépasse une limite de mémoire, il peut être tué par le noyau de système.

Nous ajoutons la spécification des ressources dans le déploiement :

jupyter-deployment.yaml
...
spec:
  ...
  template:
  ...
    spec:
      containers:
        - image: jupyter/base-notebook 
          name: jupyter  
          resources:
            requests:
              cpu: 500m
              memory: 500Mi
            limits:
              cpu: "1"
              memory: 1024Mi
...

Après avoir appliqué la configuration, exécutons la commande pour suivre le status des pods :

k get pods -w

Dans l'interface de Jupyter après avoir saisi le mot de passe, nous créons un nouveau notebook et exécutons le code pour consumer la mémoire sans restriction :

with open('/dev/zero', 'r') as fd:
    somevar = fd.read()

Nous allons voir un Kernel died unexpectedly dans l'interface web et dans la console où nous pouvons suivre les pods :

NAME                       READY   STATUS    RESTARTS   AGE
jupyter-58c5c6f65b-c7nsg   1/1     Running   0          55s
jupyter-58c5c6f65b-c7nsg   0/1     OOMKilled   0          2m19s
jupyter-58c5c6f65b-c7nsg   1/1     Running     1 (2s ago)   2m20s

Sondes

Les sondes sont de plusieurs types :

  • livenessProbe est utilisé pour détecter quand redémarrer un conteneur. Par exemple, si à la cause de bug dans l'application le process est en cours d'exécution, mais il est incapable de traiter les requêtes.
  • readinessProbe est utilisé pour savoir quand un conteneur est prêt à accepter le trafic. Lorsqu'un Pod n'est pas prêt, il est retiré des équilibreurs de charge des Services.
  • startupProbe est utilisé pour savoir quand une application d'un conteneur a démarré. Si une telle probe est configurée, elle désactive les contrôles de liveness et readiness jusqu'à cela réussit, en s'assurant que ces probes n'interfèrent pas avec le démarrage de l'application.

Chaque type de sonde peut :

  • exécuter une commande dans le conteneur
  • envoyer une requête HTTP au serveur qui s'exécute dans le conteneur
  • ouvrir un socket TCP vers le conteneur sur le port spécifié

Ajoutez une sonde de vie au conteneur principal de l'application Jupyter :

jupyter-deployment.yaml
...
  livenessProbe:
    httpGet:
      path: /api
      port: 8888
    initialDelaySeconds: 5
    timeoutSeconds: 1
    periodSeconds: 10
    failureThreshold: 3
...

Question

Utilisez un script python /etc/jupyter/docker_healthcheck.py fourni dans le conteneur pour livenessProbe. Nous donnons la commande comme une liste en .livenessProbe.exec.command (voir docs)

Success
...
livenessProbe:
  exec:
    command:
    - /etc/jupyter/docker_healthcheck.py
  initialDelaySeconds: 5
  timeoutSeconds: 1
  periodSeconds: 10
  failureThreshold: 3
...