~jpetazzo/De Docker à Kubernetes en passant par Compose (2/2)

Cette article est la suite du précédent. Aujourd’hui, on va entrer dans les détails pour voir comment adapter une application décrite par un fichier Compose afin de la faire tourner sur Kubernetes.

If you still can’t read French and wonder what this post is about: it’s an in-depth description of a technique that one can use to transform an app described by a Compose file into a set of Kubernetes resources.

J’aime bien écrire des articles pour mon blog, mais j’aime encore mieux former des gens brillants (par exemple, vous, chers lecteurs) à tous ces sujets : les conteneurs, Kubernetes, Docker … Du coup, petite annonce :

En avril, je dispenserai trois formations à Paris (en français) : bien démarrer avec les containers, déployer ses applications avec Kubernetes, opérer et administrer Kubernetes. Puis, en juin, il y aura Kubernetes for administrators and operators à San Jose (Californie), en anglais. Si vous connaissez quelqu’un que ça peut intéresser … N’hésitez pas à faire suivre ; merci beaucoup ! ♥

Résumé des épisodes précédents

On veut donc “Kubernetiser” le Compose file ci-dessous :

# Fichier Compose                                                  | traduction
version: "3"                                                       |
  services:                                                        |
    php:                                                           | deployment/php
      image: jpetazzo/apptruc:v1.2.3                               | deployment/php
      external_links:                                              | service/db
      - 'mariadb_db_1:db'                                          | service/db
      working_dir: /var/www/                                       | ignoré
      volumes:                                                     | \
      - './apache2/sites-available/:/etc/apache2/sites-available/' |  \
      - '/var/logs/apptruc/:/var/log/apache2/'                     |   \
      - '/var/volumes/apptruc/wp-config.php:/var/www/wp-config.php'|    \ volumes
      - '/var/volumes/apptruc/uploads:/var/www/wp-content/uploads' |    /
      - '/var/volumes/apptruc/composer:/root/.composer'            |   /
      - '/var/volumes/apptruc/.htaccess:/var/www/.htaccess'        |  /
      - '/var/logs/apptruc/app.log:/var/www/logs/application.log'  | /
      ports:                                                       | service/php
      - 8082:80                                                    | service/php
      healthcheck:                                                 | \
        test: ["CMD", "curl", "-f", "http://localhost/healthz"]    |  \
        interval: 30s                                              |   liveness probe
        timeout: 5s                                                |  /
        retries: 2                                                 | /
      extra_hosts:                                                 | hostAliases
      - 'sso.apptruc.fr:10.10.22.34'                               | hostAliases

Pour rappel, c’est un vrai fichier Compose utilisé par un de mes clients. J’ai uniquement changé les noms d’image et d’hôte par souci de confidentialité, mais en dehors de ça, cest un vrai fichier représentatif de ce qu’on trouve dans la nature.

J’ai annoté le fichier pour montrer (dans la partie droite) à quel concept ou ressource Kubernetes correspond chaque ligne.

Maintenant, voyons ça un peu plus en détail !

Où sont mes conteneurs ?

Tout d’abord. pour chaque service (au sens de Compose), j’ai créé un Deployment dans Kubernetes. Par simplicité, je nomme ce Deployment comme le service Compose (ici, php).

Pour générer le YAML de mon Deployment, j’utilise la commande suivante:

kubectl create deployment php \
        --image jpetazzo/apptruc:v1.2.3 \
        --dry-run -o yaml

Normalement, cette commande génère la description d’une ressource (ici, un deployment) puis crée cette ressource sur le cluster. Mais comme on utilise l’option --dry-run, on se contente de générer la description, sans créer la ressource. On assure l’affichage de cette description au format YAML avec (vous l’aurez sûrement deviné) le -o yaml.

Juste là, tout va bien.

Connexions sortantes

Ensuite, je vois une section external_links, qui va faire correspondre le conteneur mariadb_db_1 au nom db. Je vais donc créer un service Kubernetes qui va s’appeler db. Plusieurs options s’offrent à moi.

Dans le cas présent, il se trouve que la base de données mariadb_db_1 est exposée sur le port 3306 sur une machine appelée db.apptruc.fr. La solution la plus simple est alors de créer un service de type ExternalName. Concrètement, cela va se contenter d’ajouter un enregistrement DNS de type CNAME dans le DNS de Kubernetes (kube-dns ou CoreDNS, selon la version de Kubernetes qu’on utilise). Du coup, quand mon application va résoudre le nom db, le DNS de Kubernetes va lui dire “le nom db correspond au CNAME db.apptruc.fr ; au passage, l’adresse IP correspondante est 10.20.30.40.”

Si mon serveur MariaDB n’est pas dans le DNS (et que j’ai juste son adresse IP), mauvaise nouvelle : à l’heure où j’écris ces lignes (Kubernetes 1.12), un ExternalName ne peut pas pointer directement vers une adresse IP. Si je ne peux pas (ou ne veux pas) créer une entrée DNS pour mon serveur MariaDB, je peux utiliser nip.io. Grâce à nip.io, je peux obtenir un nom DNS pour n’importe quelle adresse IP. Il suffit d’ajouter .nip.io derrière l’adresse IP ! Autrement dit, si mon serveur MariaDB a l’adresse 10.20.30.40, je peux créer un ExternalName pointant vers 10.20.30.40.nip.io et le tour est joué.

(Notons au passage que même si nip.io est très pratique, l’utiliser crée un dépendance à un service externe. Cela implique aussi que notre cluster a accès à Internet. Ce n’est pas une contrainte très lourde dans la majorité des cas, sauf pour les gens qui font tourner des clusters totalement isolés de l’extérieur…)

Tout ça fonctionne uniquement si mon serveur MariaDB est exposé sur le port par défaut (3306). Comment faire si mon serveur est exposé sur un autre port ?

Option 1 : un ambassadeur. Dans le cas présent, je pourrais utiliser hamba. Cela me ferait ajouter un Deployment. L’ambassadeur va écouter sur le port 3306, et relayer chaque connexion vers l’adresse et port qu’on voudra. On pourrait aussi utiliser un proxy MySQL comme ambassadeur.

Option 2 : un service ClusterIP et un backend statique. Normalement, dans Kubernetes, un service obtient la liste des backends (ou endpoints) grâce à un sélecteur. Par exemple, le sélecteur peut indiquer “ce service correspond à tous les pods ayant le label app=toto”. À chaque fois qu’un pod ayant ce label apparaît ou disparaît, il est ajouté ou enlevé de la liste des backends pour le service. Cela revient à une reconfiguration dynamique de load balancer. Mais on peut aussi créer un service sans sélecteur, puis gérer les backends soi-même.

Comme faire en pratique ? Tout simplement en chargeant un fichier YAML similaire à l’exemple ci-dessous via kubectl create:

---
apiVersion: v1
kind: Service
metadata:
  name: db
spec:
  ports:
  - name: "3306"
    port: 3306
    protocol: TCP
    targetPort: 3306
  type: ClusterIP
---
apiVersion: v1
kind: Endpoints
metadata:
  name: db
subsets:
- addresses:
  - ip: 10.20.30.40     # Changer ça
  ports:
  - name: "3306"
    port: 12345         # Et ça
    protocol: TCP

(Remplacez 10.20.30.40 et 12345 par l’adresse IP et le port auquel le service doit correspondre, et le tour est joué!)

Évitez de déployer trop gras, trop salé ou trop sucré

La ligne suivante du fichier Compose est working_dir. Dans l’absolu, je pourrais répercuter cette directive dans le fichier YAML du déploiement php. Mais dans ce cas précis, je me suis posé la question : est-ce que cette directive est nécessaire ? Il s’avère qu’elle n’était pas utile, donc on s’en est débarrassé.

Il y a une petite leçon importante ici : d’un côté, c’est important de s’assurer qu’on a bien transcrit toutes les informations présentes dans le fichier Compose. De l’autre, recopier aveuglément les informations peut conduire à une accumulation de petites choses inutiles (voire contre-productives), dont on ne sait plus trop à quoi elles servent.

C’est particulièrement vrai dans des (longs) fichiers de configuration, et tout particulièrement des fichiers générés. Ces fichiers ont tendance à être longs (un programme sera toujours moins paresseux qu’un humain et ne rechignera jamais à ajouter des lignes!) et pas toujours commentés.

Il m’est arrivé bien trop souvent de faire le ménage dans un configuration de plusieurs centaines de lignes, la réduisant à moins de dix lignes utiles. Tout le reste, c’était des valeurs par défaut, ou bien sans incidence sur l’application. Le résultat, c’est une configuration beaucoup plus lisible, facile à comprendre, et facile à porter ou traduire lorsqu’on change de système ou tout simplement qu’on fait une montée en version.

Monter les volumes

Puis, on a une ribambelle de volumes. On a pu les classer en trois catégories :

La configuration et les logs sont répartis sur plusieurs répertoires. On aurait pu créer plusieurs volumes de configuration et plusieurs volumes de logs, mais on a choisi une méthode légèrement différente.

Pour commencer, on rassemble tous les fichiers de configuration identifiés dans un répertoire config, puis on transforme ce répertoire en une ConfigMap Kubernetes avec la commande suivante :

kubectl create configmap config --from-file=config \
        --dry-run -o yaml > configmap-config.yaml

Cette ConfigMap sera montée sous forme de volume (par exemple dans /config), ce qui va rematérialiser le contenu du répertoire config dans chaque container de l’application.

Puis, on va modifier la commande de lancement de l’application, afin de créer des liens symboliques vers tous ces fichiers. Ainsi, à l’emplacement de chaque fichier de configuration attendu par l’application, on aura un lien symbolique pointant vers le fichier de configuration contenu dans /config, et ce répertoire correspond à une ConfigMap Kubernetes.

On procède de manière similaire pour les logs. Là encore, chaque fichier ou répertoire de log de l’application est remplacé par un lien symbolique vers /logs, et /logs est un volume.

Voici un extrait du fichier YAML du Deployment php:

command:
- "sh"
- "-c"
- |
  set -e
  ln -sf /config/wp-config.php /var/www/wp-config.php
  ln -sf /config/.htaccess /var/www/.htaccess
  mkdir /etc/apache2/sites-include
  ln -sf /config/url-redirections /etc/apache2/sites-include/url-redirections
  ln -sf /config/000-default.conf /etc/apache2/sites-available/000-default.conf
  [ -d /logs/apache2 ] || mv /var/log/apache2 /logs/apache2
  ln -sf /logs/apache2 /var/log/apache2
  ln -sf /logs/application.log /var/www/logs/application.log
  exec sudo apachectl -DFOREGROUND

Il y a pas mal de choses à dire sur cette section command !

Enfin, pour les assets, la meilleure méthode serait (idéalement!) de remplacer ce répertoire partagé par un object store. Mais cela implique des modifications assez lourdes sur l’application, donc en attendant, on peut utiliser (par exemple) un partage NFS.

Les volumes et les ConfigMaps sont des concepts complexes. Si vous voulez en savoir plus à ce sujet, vous pouvez consulter :

Connexions entrantes

On poursuit avec la section ports. Cette application se trouve derrière un load balancer HAProxy, configuré pour envoyer les requêtes sur le port 8082 de l’hôte Docker où elle se trouve. On va garder le même schéma, mais on va utiliser un service de type NodePort et configurer HAProxy pour envoyer les requêtes vers tous les nœuds de notre cluster Kubernetes, sur le port alloué.

Si on avait voulu aller plus loin, on aurait pu créer un Ingress. Cela aurait permis de remplacer le load balancer HAProxy par un mécanisme mieux intégré à Kubernetes, comme Traefik par exemple.

Dans ce cas précis, mon client souhaitait garder ses load balancers existants afin de migrer plus progressivement. C’est une démarche très saine, qui limite la quantité de nouveaux outils à prendre en main pour les équipes opérationnelles. Du coup, on utilise un NodePort pour coller au plus près à l’existant.

Pour en savoir plus sur les Ingress, vous pouvez consulter la documentation Kubernetes ou notre support de formation.

Sondes

La section healthcheck est remplacée par une liveness probe dans le Deployment. Je ne vais pas entrer dans les détails (cet article est déjà assez long comme ça), et simplement mentionner que cela permet de détecter si le conteneur a un problème, et le redémarrer automatiquement le cas échéant. Pour en savoir plus sur ces sondes, et sur la différence entre les sondes de liveness et de readiness, je vous invite à consulter la documentation ou bien encore une fois notre support de formation.

Connexions sortantes (bis)

Enfin, la section extra_hosts permet d’injecter des entrées DNS supplémentaires. Dans le cas présent, le nom sso.apptruc.fr correspond à une adresse IP publique, et (dans le cas précis du réseau de ce client) utiliser cette adresse IP publique fait passer le trafic par le firewall. La section extra_hosts permet de surcharger ce nom DNS afin de lui faire correspondre l’adresse IP privée du service, et y accéder directement, sans passer par le firewall. (C’est une topologie spécifique à ce client, mais qu’on retrouve dans d’autres circonstances ; par exemple, dans une infrastructure cloud, lorsqu’une machine interne accède à un service interne, mais via son adresse IP externe.)

Cette section extra_hosts peut se traduire via une section hostAliases dans le Deployment. (C’est particulièrement bien expliqué dans la documentation Kubernetes.)

Cela dit, si on a plusieurs Deployment qui accèdent à un service de cette façon, on peut aussi souhaiter mettre en place quelque chose qui surcharge le nom DNS de ce service automatiquement pour tous les services.

Pour des noms courts (comme db ou api) on peut créer un service Kubernetes (comme expliqué plus haut pour db), mais pour un nom contenant des points (comme sso.apptruc.fr) cela n’est pas possible, car on ne peut pas avoir de point dans le nom d’une ressource Kubernetes. On peut, en revanche, configurer le DNS de Kubernetes pour “détourner” les requêtes pour sso.apptruc.fr afin de renvoyer une adresse IP de notre choix. Là aussi, il s’agit d’une opération non triviale. Si vous voulez en savoir plus à ce sujet, vous pouvez consulter cet excellent article en anglais.

Une autre solution est de changer le code afin d’accéder à sso (au lieu de sso.apptruc.fr) puis créer un service sso.

Conclusions

Ouf ! On a converti notre application. Et comme vous pouvez le constater sur cet exemple en conditions réelles, les outils automatiques ont leurs limites. Un outil comme Kompose, aussi sophistiqué soit-il, n’aurait pas pu créer automatiquement un partage NFS pour nous. Les outils actuels ne sont pas capables de deviner quels fichiers sont des fichiers de configuration (et peuvent être encapsulés dans une ConfigMap) et quels fichiers sont des logs (et peuvent être placés dans un volume EmptyDir partagé avec un conteneur sidekick les relayant vers notre plateforme de logging). Peut-être que ça viendra, mais on n’y est pas encore.

Comme évoqué dans l’article précédent, il est plus efficace de prendre le problème par les deux bouts : d’un côté, utiliser un outil comme Kompose pour automatiser le boulot ; de l’autre, analyser le résultat, comprendre ce qui n’est pas traduit correctement, le corriger à la main, mais à terme, modifier le fichier Compose en amont de manière à ce que Kompose puisse mieux faire son travail lors de la prochaine passe.

Dans tous les cas, on n’y coupe pas : il faut se familiariser avec Kubernetes !

J’en profite donc pour mentionner mes prochaines formations à Paris et en Californie !

Il y aura:

Les formations à Paris sont en français. Si vous voulez monter en compétence vitesse grand V, vous pouvez enchaîner les 3 formations (elles sont conçues pour fonctionner ensemble).

La formation à San Jose aura lieu dans le cadre de la conférence Velocity.

Je peux aussi assurer des formations sur mesure pour votre équipe. N’hésitez pas à me contacter.

Si vous vous demandez à quoi ressemblent ces formations, nos supports sont en libre accès sur le site http://container.training/, ainsi que quelques vidéos de précédentes formations. Cela vous permettra de juger au mieux si elles sont adaptées à votre besoin.

This work by Jérôme Petazzoni is licensed under a Creative Commons Attribution-NonCommercial-ShareAlike 4.0 International License.