Skip to main content

Déployer kubernetes 1.13 sur OpenStack grâce à Terraform

26 déc. 2018

Nous utilisons beaucoup OpenStack chez Enix, notamment pour automatiser le mise en place des clusters kubernetes utilisés lors de nos formations. Que ce soit au travers de l'interface web Horizon ou via la CLI, le plaisir de déployer des Machines Virtuelles en masse ne se tari jamais !

J'ai quelques années d'utilisation d'AWS derrière moi et le passage au cloud privé a été vraiment facile. Mais dans les deux cas, le montage et démontage de multiples machines virtuelles reste toujours aussi chronophage. C'est donc sur les bons conseils d'Antoine et Romain que j'ai testé Terraform; dans le but premier d'automatiser le déploiement.

Que vous soyez pro ou simple débutant de l'Infrastructure-as-code, j'espère que cet article vous apportera quelques petites astuces. J'ai fait face à de nombreux problèmes dans le cadre de cette mise en place et je partage donc avec vous mon retour d'expérience !

Vous trouverez dans cet article un bon nombre de trucs et astuces. Je vous mets également à disposition un plan Terraform complet et fonctionnel sur github démontrant la faisabilité. Enfin, n'hésitez pas à me poser des questions si le sujet vous intéresse !

La mission, donc, si vous l'acceptez : Déployer kubernetes 1.12 1.13 avec intégration OpenStack, option cloud-controller-manager off-tree, se basant sur une couche réseau kube-router, via terraform comme unique dépendance sur la station de travail.

Terraform OpenStack kubernetes

Pourquoi ne pas utiliser RKE ou Kubespray ?

Figurez-vous qu'on utilise pas mal ces deux installateurs ici au boulevard de Sébastopol !

RKE

RKE est ultra rapide pour installer un cluster, autant d'un point de vue configuration que pour les actions effectives d'installation. L'équipe Rancher nous sort souvent des perles, ce n'est donc pas étonnant.

Je suis tout de même un peu embêté car jusqu'à il y a peu (avant cette pull request), il était obligatoire d'installer un network add-on supporté par RKE. Et évidemment kube-router n'en fait pas partie !

C'est aussi un peu compliqué en termes de maintenance :

  • avoir un kubelet dans un container qui est géré par Docker, lui même manipulé par le kubelet en question ...
  • passage obligé par l'outil rke dés lors qu'on souhaite modifier la configuration du control plane, ou bien perdre son temps à manipuler des confs très spécifiques à RKE

kubespray

kubespray est un ultime couteau suisse permettant d'installer un cluster kubernetes. Terraform est pris en charge pour provisionner les noeuds sur votre cloud provider préféré (public ou privé comme OpenStack). C'est ultra featured, ça force le respect.

Mais là encore, je ne suis pas totalement convaincu. Que c'est long, que c'est long, au secours !

  • kubespray est largement basé sur ansible
    • ansible est séquentiel
    • il y a beaucoup d'actions
  • la dépendance à d'autres outils se fait sentir : il y a souvent des notes indiquant que la dernière version en date d'ansible pose problème (vu sur les changelogs)

S'ajoute a ces petits problèmes une sensation de faire face à un monstre tentaculaire, configurable dans le moindre détail mais via plusieurs fichiers différents, comme dirait Antoine : c'est touffu. La lourdeur du projet pose aussi problème quand au support des dernières versions en date de kubernetes : ça peut parfois prendre quelques semaines.

Kubeadm à la rescousse

Si il y a bien un composant qui représente à lui seul les évolutions de kubernetes, c'est kubeadm, je veux dire par là que les choses bougent, et très vite. Au départ, cet article a débuté sur une version 1.11 1.12. Je me retrouve à vous présenter une installation kubernetes en 1.13.

J'utilise l'installation par modules de kubeadm afin de phaser l'installation via les provisioners Terraform dont je vais parler plus bas ... et ça n'a pas manqué dans le changelog de la 1.13 :

kubeadm has graduated kubeadm alpha phase commands to kubeadm init phase. This means that the phases of creating a control-plane node are now tightly integrated as part of the init command.

On m'explique que les lignes de commandes ont changé

Ce phasage par module me permet notamment de générer les certificats liés à etcd sans pour autant le lancer via un static pod. Je peux donc déployer le cluster etcd en dehors du cluster kubernetes. J'ai également besoin du phasage par module pour installer le control plane de kubernetes sur plusieurs noeuds.

Notez qu'il existe une fonction expérimentale kubeadm join --experimental-control-plane qui permet d'ajouter un control plane en même temps que l'on déploie un nouveau noeud. Mais j'ai choisi de ne pas utiliser ce raccourci. Les évolutions récentes de kubeadm permettent d'envisager une installation de cluster kubernetes sans l'aide d'aucun autre outil.

Etape 1 : déployer les machines virtuelles

Je veux donc déployer un cluster kubernetes sur OpenStack.

Si vous déployez souvent sur un cloud provider (public ou privé), que ce soit un ensemble de machines, ou bien même une seule avec une configuration bien spécifique; les interfaces web parfois ergonomiquement limites ou le nombre de lignes en CLI vont vite vous fatiguer !

Les offres cloud sont bien plus complexes qu'une box chez Scaleway ou OVH. Je note trois raisons principales :

  • Il faut gérer la structure du ou des réseau(x) (subnet, gateway, ip publique)
  • Il faut gérer la sécurité (security groups)
  • Il faut gérer les espaces de stockage de la machine
  • ... et beaucoup d'autres services à valeur ajoutée

Ceci est vrai pour les Cloud Providers publiques : Google Cloud Platform, Amazon Web Services, Microsoft Azure, Alibaba Cloud, etc ... comme pour les privés : OpenStack, Cloudstack, OpenNebula, ...

Une solution: Terraform

Terraform est une application écrite en go par Hashicorp qui transforme les APIs des Cloud Providers en language déclaratif.

Enfin, c'est plutôt l'inverse : à partir d'un fichier plan qui décrit le système voulu (mon cluster kubernetes par exemple), Terraform va se connecter au(x) (multiples) Cloud Provider(s) et tout mettre en place. Plutôt que de faire ça bêtement, il va savoir via son fichier tfstate, si tel ou tel élément est déjà en place et n'ajouter, supprimer ou modifier que les éléments nécessaires.

Pour bien comprendre le concept de déclaratif, je vous conseille de regarder Jérôme en parler à propos de kubernetes.

Il existe de nombreux objets que vous pouvez déclarer via Terraform : les serveurs, les réseaux, le stockage, les règles de sécurité. Ce sont autant de briques de base qui vous aident à construire votre cathédrale informatique.

Terraform modélise pour vous un graphe de dépendance entre chaque objet décrit par votre plan. Cela permet de reprendre sur erreur lorsqu'une action s'est mal passée sur un objet, et cela permet aussi tout faire tourner en parallèle. Par rapport à un script de déploiement séquentiel, c'est à mon sens un énorme point positif.

L'intégration OpenStack avec Terraform se fait au travers d'un plugin qui s'installe automatiquement lors d'un terraform init. J'en profite pour préciser que nous avons (chez Enix) toujours eu un excellent contact avec Joe Topjian qui est l'un des mainteneurs, merci à lui ! Au passage, j'en profite pour indiquer que nous avons aussi travaillé avec Alibaba pour solutionner certains problèmes sur leur plugin. Tout ceci pour vous dire que la communauté autour de Terraform est réactive, ne vous en privez pas !

Il y a tout un tas de primitives pour templatiser des scripts ou fichiers. On peut par exemple injecter l'adresse ip publique du serveur que Terraform vient de lancer afin de configurer un second serveur. Il existe également un objet template_cloudinit_config qui vous permet de travailler les cloud config les plus complexes. C'est très pratique pour injecter des éléments spécifiques pour chaque noeud du cluster, comme par exemple une variable de votre plan Terraform, telle que la version de Docker à installer.

Attention lors de l'utilisation de cloud config : Terraform va lancer l'instance avec sa configuration, l'API OpenStack va lui répondre c'est fait, mais dans les faits, le machine vient à peine de démarrer. Sachez donc que :

  • les scripts ou installation de package peuvent finir en échec
  • les téléchargement et installations effectuées par cloud-init peuvent prendre quelques minutes

OpenStack n'en est pas conscient, et par voix de conséquence Terraform non plus. C'est la parfaite occasion pour utiliser un provisioner remote-exec qui va se connecter en SSH puis attendre que le fichier /var/lib/cloud/instance/boot-finished apparaisse.

Très vite donc, une envie d'executer des scripts sur les machines qui viennent d'être déployées se fait sentir ...

Etape 2 : installer kubernetes

Pour effectuer des tâches sur les machines virtuelles fraichement déployées, il existe des Provisioners pour vous aider. Ce sont habituellement des attributs de l'objet serveur qui peuvent aussi être attachés à un objet factice null_ressource. L'objet en question permet de créer des étapes supplémentaires dans le graphe de dépendance. En combinant les deux on bénéficie d'une grande flexibilité pour lancer des actions ou scripts, à un moment bien précis.

Pour lancer des commandes sur un serveur, on trouve plusieurs options :

  • remote-exec Terraform se connecte et exécute sur la machine
  • local-exec lance un script en local
  • file upload un fichier sur la machine

Dans un but purement éducatif, j'ai utilisé le moins possible de scripts au profit de commandes lancés en remote-exec via la sous-option inline. Lorsqu'on fournit plusieurs commandes (un tableau), il est de bon ton de mettre set -e en premier car le comportement par défaut ne vérifie pas le code de retour de chaque commande.

Vous retrouverez dans le repository l'ensemble des actions effectuées avec dépendances adéquates :

  • mise en place des configurations à base de template sur les noeuds master
  • installation d'un load balancer interne au cluster
  • génération des certificats sur le premier noeud master
  • réplication des certificats sur les autres noeuds master
  • génération des certificats etcd de chaque noeud master et lancement
  • lancement du control plane sur chaque noeud master
  • configuration à chaud du cluster via l'API (kubeadm init phase et specs customs)
  • installation de kube-router en daemon set
  • et pour chaque worker, un kubeadm join

Les fichiers qui sont à votre disposition regorgent d'astuces que je vous invite à découvrir. Par exemple, l'utilisation de depends_on permet de forcer la dépendance dans le graphe Terraform, les triggers vous assurent de relancer les actions liées à l'objet si jamais un noeud doit être ajouté, ...

Il n'existe pas de conditions de test dans un plan Terraform, vous ne trouverez ni if ni then ni else. Yevgeniy Brikman propose une solution se basant sur le nombre d'instances et l'arithmétique disponible dans les plans Terraform. Problem solved !

En pratique, je souhaite que les noeuds Master de mon cluster kubernetes soient joignables. Et j'ai quelques réseaux d'admin sécurisés Enix à disposition. J'ai donc rendu mon plan Terraform programmable afin de choisir entre une IP publique ou privée (d'admin).

Lorsque j'utilise une IP du réseau d'administration, la machine possède deux interfaces réseaux. Attention donc à cloud-init qui ne prend pas en charge de deuxième interface dans la plupart des distribution Linux récentes. Il faut une configuration ad-hoc et une version récente >=18.3 de clout-init.

Etape 3 : L'intégration cloud provider de kubernetes

Passons de l'autre côté de la Force, kubernetes peut s'intègrer avec le Cloud Provider utilisé. Dans les faits cela permet,

  • d'exposer un service via un type LoadBalancer
    • gèré par le Cloud Provider
    • permettant à mes noeuds sans IP publique d'héberger des services joignables sur Internet
  • de demander un PersistentVolume
    • sans se soucier de la machinerie derrière (cinder, SAN, iscsi, ...)
  • de passer à l'échelle en ajoutant des noeuds à la volée
  • ... bref pour ces fêtes de fin d'année, c'est l'option à mettre au pied du sapin

Cette fonctionnalité est implémentée depuis bien longtemps par kubernetes, mais les choses ont beaucoup bougé dernièrement. Au départ, on ajoutait un flag --cloud-provider=OpenStack au kubelet sur les noeuds et au kube-controller-manager, mais depuis la 1.13 une nouvelle approche passe en beta. Je suivais ça de près mais je n'ai malheureusement pas terminé l'écriture de mon article avant, saperlipopette !

En bref, il devient trop compliqué pour la communauté kubernetes de se synchroniser avec tous les cloud providers privés et publiques, et ce, à chaque release. Il a donc été décidé de sortir tout le code relatif aux Cloud providers pour le centraliser dans un nouveau composant du control plane : cloud-controller-manager.

Afin de ne pas créer de rupture, kubernetes continue encore aujourd'hui de publier le code source de ce composant dans les releases telles que la 1.13, on appelle ça in-tree.

Mais les nouveaux cloud providers (et bientôt les anciens) n'ont pas le choix, il faut qu'il maintiennent eux-mêmes le code relatif à leurs APIs, dans un repository externe au projet kubernetes, on appelle ça off-tree. Pour OpenStack c'est ici. Vous l'aurez donc compris, il existe à l'heure actuelle 3 facons d'intégrer un Cloud Provider avec votre cluster kubernetes

  • kube-controller-manager, deprecated, c'est le plus simple et ça marche
  • cloud-controller-manager en in-tree, limité aux anciens cloud providers
  • cloud-controller-manager en off-tree, la solution pérenne

Que ce soit in-tree ou off-tree, utiliser cloud-controller-manager a quelques implications : le code de votre Cloud Provider n'est plus dans kubelet. kubelet ne peut plus détecter l'IP de son noeud par lui-même, il doit se connecter à cloud-controller-manager, et cela pose problème pour le bootstrap TLS d'un kubeadm join. Votre cloud-controller-manager doit aussi pouvoir manipuler les objets kubernetes (si RBAC vous parle), tels que les nodes.

En bref, les kubelet s'initialisent donc avec --cloud-provider=external et démarrent avec un taint node.cloudprovider.kubernetes.io/uninitialized tant qu'ils n'ont pas obtenu d'IP.

Quand on utilise deux cartes réseaux par noeud (une pour mon réseau d'admin vous vous rapellez ?), cloud-controller-manager voit deux IPs et donne à kubelet la dernière, qui correspond au réseau d'admin, ce qui ne m'arrange pas première depuis la 1.13.1 ce qui est parfait.

OpenStack horizon

Petite anecdote pour la fin,

  • cloud-controller-manager tourne dans un pod;
  • il est possible de le faire tourner en DaemonSet;
  • kube-router doit joindre l'API kubernetes pour connaitre l'IP du noeud;
  • cloud-controller-manager doit reconnaitre le noeud pour lui assigner une IP;
  • pour créer les pods relatifs à un DaemonSet, il faut obtenir une IP de la part du network add-on;
  • vous sentez le problème venir ...
    • on peut aller se brosser pour que ça se bootstrap proprement tout ça !

En lancant clout-controller-manager en static pod, ça se passe beaucoup mieux !

Etape 4 : installer kube-router

Enix opère un nombre non négligeable d'infrastructures réseau pour le compte de ses clients. Nos retours d'expérience nous poussent à éviter, tant que faire se peut, toute encapsulation. Nous sommes donc tombés très vite amoureux de kube-router.

kube-router permet d'éviter toute encapsulation en annoncant les subnets (les IPs) des pods et des services aux autres noeuds au travers de BGP. Les IPs internes au cluster sont donc directement accessibles depuis n'importe quel noeud grace aux tables de routage du kernel. On ne peut pas faire plus simple, plus performant, c'est du rock solid.

Dés lors que des IPs du cluster kubernetes passent sur le réseau gèré par OpenStack sans encapsulation, vous ferez face à des problèmes de sécurité. Il n'est pas normal de laisser passer les paquets provenant et à destination des CIDR du cluster kubernetes sur les interfaces réseau. Vous devrez donc instancier les ports de chaque machine manuellement et leur adjoindre une configuration allowed_address_pairs comme suit:

resource "openstack_networking_port_v2" "k8s_port" {
  count = "${var.nodes_count}"
  network_id = "${var.internal_network_id}"
  admin_state_up = "true"
  fixed_ip {
    subnet_id = "${var.internal_network_subnet_id}"
  }
  allowed_address_pairs {
    ip_address = "${var.k8s_pod_cidr}"
  }
  allowed_address_pairs {
    ip_address = "${var.k8s_service_cidr}"
  }
  security_group_ids = ["${var.security_group_id}"]
}

Notez que le security_group n'est alors plus indiqué au niveau du serveur, mais au niveau du port.

kube-router implémente aussi les network policies au travers d'iptables. Vous savez peut-être que certains de network add-ons conseillés pour kubernetes ne le supporte pas, c'est donc une bonne nouvelle. Enfin, kube-router implémente l'équivalent de kube-proxy via ipvs, vous faites donc l'économie d'un service à maintenir dans votre cluster.

En ce qui concerne, l'installation, elle est plutôt aisée, puisqu'à base de DaemonSet. Attention toutefois, les recettes yaml ne sont pas toujours ultra clean. J'ai du notamment ajouter des tolerations pour que le DaemonSet se lance sur les noeuds qui s'initialisent.

Un poil de sécurité

Je déconseille fortement d'utiliser les variables user_name et password du Provider OpenStack qui ont tendance (dans 100% des cas) à se retrouver dans votre Terraform state ... en clair. Il est extrèmement aisé d'obtenir un token avec une durée limitée pour le temps de l'installation.

openstack --os-auth-url=https://api.r1.nxs.enix.io/v3 --os-identity-api-version=3 --os-username=abuisine --os-user-domain-name=Default --os-project-name=enix/kubernetes token issue

il suffit ensuite d'exporter la variable d'environnement export TF_VAR_openstack_token=<token> qui va tout simplement être interprétée par Terraform pour éviter une demande de saisie interactive systématique.

Il est plutôt habituel, aussi, de donner une clef publique SSH à Terraform afin qu'il l'intègre via cloud-config/cloud-init dans les authorized_keys des machines virtuelles instanciées. Mais cela ne suffit pas dés lors que l'on utlise des provisioners remote-exec. On peut alors être tenté de passer la clef privée en paramêtre du plan ... c'est une très mauvaise idée également. Préferrez utiliser votre ssh-agent afin que rien ne se retrouve dans le Terraform state.

Enfin, cloud-controller-manager pour OpenStack utilise un fichier de configuration qui ne supporte que les champs username et password. Vous allez me dire "c'est bon, j'ai compris, je ne dois pas les passer en paramètre du plan Terraform", pas si simple ...

Une option possible est d'utiliser un password provider (tel que Vault) auquel on passe en paramètre un token relatif à l'installation. Valable peu de temps, il permet de récupérer le mot de passe OpenStack depuis le host qui en a besoin, sans que rien de compromettant ne soit stocké dans le Terraform state.

Une conclusion ... parmi d'autres

Vous l'aurez compris, tout ceci n'est qu'une démonstration, probablement inmaintenable en l'état. Cela m'a toutefois permis de faire le tour des problématiques et de comprendre dans le détail certains aspects. Je retiens notamment,

  • la dépendance en graph des objets de Terraform, ce qui rend l'outil rapide,
  • la simplicité d'utilisation d'OpenStack (tant que ce n'est pas toi qui administre le control plane OpenStack),
  • et l'évolution des concepts d'installation autour de kubernetes, s'il ne doit en rester qu'un, ce sera probablement kubeadm.

Au final, je sors mon joker pour tout ce qui est de la gestion des Persistent Volumes via cloud-controller-manager, le Container Storage Interface vient de passer en 1.0, je vais donc attendre que ça sédimente un peu avant d'en parler.

Voilà, merci à tous pour ce temps de lecture et rendez-vous en 2019 pour la suite !