Tutoriel : Comment déployer Kubernetes chez Exoscale avec Packer et Terraform ? Création de l'infrastructure (3/3)

Publié le

Dans les précédents articles, nous avions traité les parties suivantes :
Maintenant, nous pouvons mettre en place l'infrastructure proprement dite.

Composition de l'infrastructure

Notre infrastructure va implémenter les éléments suivants :

  • un réseau privé

Pour le control-plane:

  • des règles de pare-feu (security group)
  • la machine virtuelle (IP: 10.0.0.1)
  • sa connection au réseau privé

Pour les workers:

  • des règles de pare-feu (security group)
  • une règle d'anti-affinity (affinity group) pour favoriser la haute disponibilité, tout évitant que les workers soient déployés sur le même hyperviseur
  • un pool d'instances (IP: 10.0.0.10 à 10.0.0.253)

Préparation de l'infrastructure

Éléments en commun

Nous aurons besoins de définir les paramètres du provider (Exoscale) et d'une paire de clé SSH qui peut être générée automatiquement avec la ressource exoscale_ssh_keypair. Nous construisons un réseau privé dont l'adressage est géré par Exoscale (ressource exoscale_network).

Dans le fichier terraform/main.tf :

provider exoscale {
  key     = var.api_key
  secret  = var.api_secret

  timeout = 120
}

resource exoscale_ssh_keypair provisioning_key {
  name = var.name
}

resource exoscale_network private_network {
  name = var.name
  display_text = format("private network for %s", var.name)
  zone = var.zone

  start_ip = var.private_net_start_ip
  end_ip = var.private_net_end_ip
  netmask = var.private_net_netmask
}

Ces ressources référencent des variables, qu'il faut définir dans le fichier terraform/variables.tf :

variable "api_key" {
}

variable "api_secret" {
}

variable "zone" {
}

variable "name" {
  default = "kubernetes"
}

variable "private_net_start_ip" {
  default = "10.0.0.10"
}

variable "private_net_end_ip" {
  default = "10.0.0.253"
}

variable "private_net_netmask" {
  default = "255.255.255.0"
}

Le provider Exoscale va être téléchargé automatiquement par Terraform, nous le définissons dans terraform/providers.tf :

terraform {
  required_providers {
    exoscale = {
      source = "exoscale/exoscale"
    }
  }
  required_version = ">= 0.13"
}

Le control-plane

Le security group (pare-feu) du control plane est défini avec une ressource de type exoscale_security_group, et une autre ressource de type exoscale_security_group_rules. En entrant, nous autorisons le port 22 (ssh) qui est nécessaire pour administrer la machine virtuelle. En sortant, nous autorisons les ports 22 (ssh), 80 et 443 (http et https) pour télécharger les mises à jour, 11371 (hkp) pour ajouter des clés GPG, et 53/udp pour permettre la résolution DNS.

// control plane security-group
ressource exoscale_security_group control_plane_firewall {
  name = format("%s-control-plane", var.name)
  description = format("security group for %s", var.name)
}

ressource exoscale_security_group_rules control_plane_firewall_ingress {
  security_group_id = exoscale_security_group.control_plane_firewall.id

  // allow incoming 'ssh'
  ingress {
    protocol  = "TCP"
    ports = [22]
    cidr_list = ["0.0.0.0/0", "::/0"]
  }

  // allow 'ssh', 'http', 'https', and 'hkp'
  egress {
    protocol  = "TCP"
    ports = [22, 80, 443, 11371]
    cidr_list = ["0.0.0.0/0", "::/0"]
  }

  // allow 'dns'
  egress {
    protocol  = "UDP"
    ports = [53]
    cidr_list = ["0.0.0.0/0", "::/0"]
  }
}

C'est tout pour le security-group du control-plane. Idéalement, ICMP devrait aussi être autorisée. Nous définissons ensuite le nécessaire pour mettre en place le control-plane à partir du mécanisme de cloud-init.

Dans le fichier terraform/main.tf, nous ajoutons :

// control-plane agent
data template_cloudinit_config control_plane_cloud_init {
  gzip = false
  base64_encode = false

  part {
    filename = "init.cfg"
    content_type = "text/cloud-config"
    content = templatefile("${path.module}/templates/control-plane/cloud-init.yaml", {
      kubeadm_configuration = templatefile("${path.module}/templates/control-plane/etc/kubernetes/kubeadmcfg.yaml", {
        pod_subnet = "10.244.0.0/16"
        service_subnet = "10.245.0.0/16"
        dns_domain = "cluster.internal"
        control_plane_private_ip_address = var.control_plane_ip_address
      })
      autoscaler_manifests = templatefile("${path.module}/templates/control-plane/tmp/exoscale-cluster-autoscaler.yaml", {
        exoscale_api_endpoint = base64encode("https://api.exoscale.com/v1")
        exoscale_api_key = base64encode(var.api_key)
        exoscale_api_secret = base64encode(var.api_secret)
      })
      ccm_manifests = templatefile("${path.module}/templates/control-plane/tmp/exoscale-cloud-control-manager.yaml", {
        exoscale_api_endpoint = base64encode("https://api.exoscale.com/v1")
        exoscale_api_key = base64encode(var.api_key)
        exoscale_api_secret = base64encode(var.api_secret)
      })
    })
  }
}

Ce bloc va créer un cloud-init dont le but est d'installer 3 fichiers construits à partir de variables et d'un template.

Le premier est un fichier de configuration pour kubeadm: /etc/kubernetes/kuebadmcfg.yaml. Ce fichier permettra à Kubeadm de connaître les bons paramètres pour bootstraper le cluster Kubernetes, principalement les subnets pour les Pods et pour les Services, mais aussi l'adresse IP de diffusion de l'API server. Le contenu du template est à placer dans terraform/templates/control-plane/etc/kubernetes/kubeadmcfg.yaml :

apiVersion: kubeadm.k8s.io/v1beta2
kind: InitConfiguration
bootstrapTokens:
localAPIEndpoint:
  advertiseAddress: ${control_plane_private_ip_address}
  bindPort: 6443
---
apiVersion: kubeadm.k8s.io/v1beta2
kind: ClusterConfiguration
kubernetesVersion: 1.20.0
apiServer:
  extraArgs:
    service-node-port-range: 30000-32767
networking:
  podSubnet: ${pod_subnet}
  serviceSubnet: ${service_subnet}
  dnsDomain: ${dns_domain}
---
apiVersion: kubelet.config.k8s.io/v1beta1
kind: KubeletConfiguration
cgroupDriver: systemd

Le deuxième fichier mis en place par le cloud-init contient l'ensemble des définitions pour le cluster autoscaler d'Exoscale. Son contenu sera déployé dans /tmp/exoscale-cluster-autoscaler.yaml.

Le contenu du template est à placer dans terraform/templates/control-plane/tmp/exoscale-cluster-autoscaler.yaml. Son code est un peu long, il est disponible ici, dans le dépôt du projet.

(Ce code n'est pas sorti de mon chapeau), il vient de l'exemple proposé par Exoscale dans le dépôt officiel du cluster auto-scaler, avec des modifications mineures concernant le déploiement de l'API key/secret.

Le troixième fichier mis en place par le cloud-init contient le code pour le déploiement du Cloud Controller Manager. Le contenu du template est à placer dans terraform/templates/control-plane/tmp/exoscale-cloud-control-manager.yaml.

De même que pour le Cluster Autoscaler, ce code est un peu long, et il est disponible ici séparément, dans le dépôt du projet.

Ce code vient lui aussi de l'exemple proposé par Exoscale, cette fois-ci dans le dépôt de leur implémentation du Cloud Controller Manager, avec les mêmes modifications mineures concernant le déploiement de l'API key/secret.

Le contenu du cloud-init est construit à partir du template que nous écrivons dans terraform/templates/control-plane/tmp/exoscale-cloud-control-manager.yaml :

#cloud-config

write_files:
  # Kubeadm configuration
  - path: /etc/kubernetes/kubeadmcfg.yaml
    content: |
      ${indent(6, kubeadm_configuration)}

  # Exoscale cluster autoscaler deployment manifests
  - path: /tmp/exoscale-cluster-autoscaler.yaml
    permissions: "0600"
    content: |
      ${indent(6, autoscaler_manifests)}

  # Exoscale cloud controller manager deployment manifests
  - path: /tmp/exoscale-cloud-control-manager.yaml
    permissions: "0600"
    content: |
      ${indent(6, ccm_manifests)}

runcmd:
  # Initialize Kubernetes control-plane
  - kubeadm init --config /etc/kubernetes/kubeadmcfg.yaml --upload-certs
  # Set local kube configuration
  - mkdir -p /root/.kube
  - cp -i /etc/kubernetes/admin.conf /root/.kube/config
  - chown root:root /root/.kube/config
  # Wait for the API server to be responsive
  - until kubectl --kubeconfig /root/.kube/config get nodes; do sleep 2; done
  # Install Calico as CNI
  - curl https://docs.projectcalico.org/manifests/calico.yaml -o /root/calico.yaml
  - kubectl --kubeconfig /root/.kube/config apply -f /root/calico.yaml
  # Set region label and providerID on the control-plane node
  - export VM_HOSTNAME=$(hostname)
  - export VM_ID=$(curl http://metadata.exoscale.com/latest/meta-data/instance-id)
  - export VM_AVAILABILITY_ZONE=$(curl http://metadata.exoscale.com/latest/meta-data/availability-zone)
  - export PATCH={\"spec\":{\"providerID\":\"exoscale://$VM_ID\"}}
  - kubectl --kubeconfig /root/.kube/config label node $VM_HOSTNAME topology.kubernetes.io/region=$VM_AVAILABILITY_ZONE
  - kubectl --kubeconfig /root/.kube/config patch node $VM_HOSTNAME -p $PATCH
  # Install Exoscale cluster autoscaler
  - kubectl --kubeconfig /root/.kube/config apply -f /tmp/exoscale-cluster-autoscaler.yaml
  # Install Exoscale cloud controller manager
  - kubectl --kubeconfig /root/.kube/config apply -f /tmp/exoscale-cloud-control-manager.yaml

Le fonctionnement de ce cloud-init est le suivant :

D'abord, nous installons la configuration de Kubeadm, et les deux déploiements pour le Cluster Autoscaler et pour le Cloud Controller Manager.

Ensuite, cloud-init va procéder à l'installation du control-plane :

  • Initialisation du control-plane avec Kubeadm
  • Mise en place de la configuration dans /root/.kube/config pour faciliter l'utilisation de kubectl
  • Installation de Calico (note: l'idéal serait de figer le fichier de déploiement dans le dépôt, plutôt que de le télécharger depuis le site de Calico, comme pour les composants spécifiques à Exoscale).
  • Ajout d'annotations au control-plane. Ici nous récupérons l'ID et la zone de la VM au moyen du serveur de metadonnées d'Exoscale.
  • Installation du Cluster Autoscaler configuré pour Exoscale.
  • Installation de Cloud Controller Manager d'Exoscale.

Dans le fichier terraform/main.tf, nous ajoutons :

// control-plane agent
resource exoscale_compute control_plane {
  zone = var.zone
  template_id = var.control_plane_template_id
  size = var.control_plane_size
  disk_size = var.control_plane_disk_size
  display_name = format("%s-control-plane", var.name)

  key_pair = exoscale_ssh_keypair.provisioning_key.name

  user_data = data.template_cloudinit_config.control_plane_cloud_init.rendered

  affinity_group_ids = []
  security_group_ids = [exoscale_security_group.control_plane_firewall.id]
}

resource exoscale_nic control_plane {
  compute_id = exoscale_compute.control_plane.id
  network_id = exoscale_network.private_network.id
  ip_address = var.control_plane_ip_address
}

Ces ressources provisionnent une machine virtuelle et l'associent au réseau privé. Nous ajoutons les variables nécessaires dans terraform/variables.tf :

variable  "control_plane_ip_address" {
  default = "10.0.0.1"
}

variable "control_plane_template_id" {
}

variable "control_plane_size" {
    default = "Medium"
}

variable "control_plane_disk_size" {
    default = "25"
}

Les workers

Les workers sont implémentés par un pool de machines virtuelles. C'est très pratique pour ajuster leur nombre. C'est aussi nécessaire pour que le Cluster Autoscaler fonctionne (chez Exoscale, ce composant va éditer le nombre de machines virtuelles du pool). Les services de type LoadBalancer nécessitent aussi que nous travaillons avec un pool de machines virtuelles, car ce type de service est implémenté avec un Network Load Balancer.

Nous définissons d'abord le security group pour les workers. Cette politique n'autorise que les ports 30000 à 32767 en TCP et UDP. Ces ports sont ceux que nous pouvons exposer via un service en NodePort (donc aussi nécessaires aux services de type LoadBalancer). En sortant, nous appliquons les mêmes règles que pour le control-plane. L'accès SSH n'est pas pertinent sur ces machines, mais il reste toujours possible par le réseau privé, en passant par le control-plane, en tant que bastion.

Dans le fichier terraform/main.tf, nous ajoutons :

resource exoscale_security_group_rules nodes_firewall_rules {
  security_group_id = exoscale_security_group.nodes_firewall.id

  // allow incoming connections to TCP 'NodePort' services
  ingress {
    protocol  = "TCP"
    ports = ["30000-32767"]
    cidr_list = ["0.0.0.0/0", "::/0"]
  }

  // allow incoming connections to UDP 'NodePort' services
  ingress {
    protocol  = "UDP"
    ports = ["30000-32767"]
    cidr_list = ["0.0.0.0/0", "::/0"]
  }

  // allow 'ssh', 'http', 'https', and 'hkp'
  egress {
    protocol  = "TCP"
    ports = [22, 80, 443, 11371]
    cidr_list = ["0.0.0.0/0", "::/0"]
  }

  // allow 'dns'
  egress {
    protocol  = "UDP"
    ports = [53]
    cidr_list = ["0.0.0.0/0", "::/0"]
  }
}

Le reste des définition est plutôt simple, nous définissons un groupe d'anti affinity, le cloud-init, et le pool d'instances :

resource "exoscale_affinity" "nodes_affinity" {
  name = format("%s-nodes", var.name)
  description = format("anti affinity for %s", var.name)
  type = "host anti-affinity"
}

// nodes agents
data template_cloudinit_config nodes_cloud_init {
  gzip = false
  base64_encode = false

  part {
    filename = "init.cfg"
    content_type = "text/cloud-config"
    content = templatefile("${path.module}/templates/nodes/cloud-init.yaml", {
      private_key = exoscale_ssh_keypair.provisioning_key.private_key
      control_plane_private_ip_address = var.control_plane_ip_address
    })
  }
}

resource exoscale_instance_pool nodes {
  zone = var.zone
  name = var.name
  
  template_id = var.node_template_id
  
  size = 1
  service_offering = var.node_service_offering
  
  disk_size = var.node_disk_size
  
  description = format("node pool for %s", var.name)
  user_data = data.template_cloudinit_config.nodes_cloud_init.rendered
  key_pair = exoscale_ssh_keypair.provisioning_key.name

  affinity_group_ids = [exoscale_affinity.nodes_affinity.id]
  security_group_ids = [exoscale_security_group.nodes_firewall.id]
  network_ids = [exoscale_network.private_network.id]

  timeouts {
    delete = "10m"
  }
}

Le cloud-init des workers n'a rien à voir avec celui du control-plane. Son template est dans terraform/templates/nodes/cloud-init.yaml :

#cloud-config

write_files:
  # Install the deployment SSH key, required to connect to the control-plane node when joining the cluster
  - path: /root/.ssh/id_rsa
    permissions: "0600"
    content: |
      ${indent(6, private_key)}

runcmd:
  # Reset Kubeadm
  - kubeadm reset --force
  # Wait for control-plane to be ready
  - until ssh -o StrictHostKeyChecking=no ${control_plane_private_ip_address} kubectl get nodes; do sleep 2; done
  # Execute kubeadm's join command (generated from control-plane)
  - $(ssh -o StrictHostKeyChecking=no ${control_plane_private_ip_address} kubeadm token create --print-join-command)
  # set provider informations on the node
  - VM_HOSTNAME=$(hostname)
  - VM_ID=$(curl http://metadata.exoscale.com/latest/meta-data/instance-id)
  - VM_AVAILABILITY_ZONE=$(curl http://metadata.exoscale.com/latest/meta-data/availability-zone)
  - ssh -o StrictHostKeyChecking=no ${control_plane_private_ip_address} exo-set-worker-node $VM_HOSTNAME $VM_ID $VM_AVAILABILITY_ZONE

Ici, cloud-init va installer la clé SSH pour se connecter au control-plane, et installer l'instance en tant que worker. Pour se faire, cloud-init récupère la commande pour joindre le cluster (kubeadm token create --print-join-command), et l'exécute dans la foulée.

Il ajoute ensuite des annotations. Comme sur le control-plane, nous récupèrons l'ID et la zone de la VM au moyen du serveur de metadonnées d'Exoscale et nous mettons en place le providerID et la zone au moyen du script exo-set-worker-node present sur le control-plane.

Note sur le Cluster Autoscaler et Cloud Control Manager

Le fonctionnement du Cluster Autoscaler et du Cloud Control Manager dépend fortement du cloud provider utilisé. Chez Exoscale, ces composants ne sont visiblement pas encore matures à 100%.
En effet, le Cluster Autoscaler fonctionne en upscale, mais le downscale semble encore expérimental si l'on en croit les commentaires du manifest de deploiement.

Le Cluster Autoscaler nécessite qu'un providerID et une zone soient associés à chaque nœud. Normalement, ces annotations sont mises en place automatiquement par le Cloud Control Manager. Ce fonctionnement semble planifié si on se réfère au README du dépôt. Cependant la partie "Node Controller" de ce composant ne semble pas encore implémentée. Je ne trouve rien dans le code qui y ressemble en tout cas.

C'est pour cette raison que nous avons dû implémenter un script qui définit le providerID et la zone pour chaque nœud du cluster.

Il faut donc continuer à suivre l'évolution de ces outils dans les mois à venir.

Création de l'infrastructure

Pour rappel, le code du projet est disponible sur GitHub.

Nous allons enfin créer l'infrastructure 🎉🥳.

export EXOSCALE_API_KEY=EXOxxxxxxxxxxxxxxxxxxxxxxxx
export EXOSCALE_API_SECRET=my-secret

make terraform.init
make terraform.apply

Nous obtenons la sortie suivante :

2021-02-10-Terraform-Apply.png

Du côté de chez Exoscale, dans l'interface de gestion, nous retrouvons bien les éléments de notre infrastructure, notamment dans la liste des instances :

2021-02-10-Exoscale-Instances.png

Pour utiliser le cluster Kubernetes manuellement, nous avons accès à kubectl sur le compte root du control plane. Il est possible de s'y connecter facilement avec la commande :

make ssh-cp

Destruction de l'infrastructure

Pour éviter une surfacturation, si vous n'avez plus besoin du cluster, vous pouvez supprimer l'ensemble de l'infrastructure avec la commande :

make terraform.destroy

Pour aller plus loin

Je n'ai pas encore testé le downscale qui semble être expérimental avec le Cluster Autoscaler.

Cette infrastructure n'est pas vraiment prête pour la production, il faudrait encore faire des tests.

En particulier, rien ne semble garantir que l'adresse IP d'un nœud est figée une fois pour toute. Il faudrait donc désenregistrer un worker quand celui-ci
s'arrête (peut être via un script lancé avant l'arrêt du réseau, ou mieux encore avec du monitoring actif depuis le control-plane).

Il faudrait aussi supprimer automatiquement un nœud lorsque la machine virtuelle associée n'existe plus ; cette tâche est normalement effectuée par le Cloud Control Manager.

Donc il reste encore un peu de travail du côté d'Exoscale pour finaliser ces composants. Affaire à suivre 😉.

Conclusion

C'est la fin de ce tutoriel, nous avons réussi à faire la mise en place d'un cluster Kubernetes pleinement intégré au cloud d'Exoscale.

Nous avons vu qu'il est plutôt simple de mettre au point une infrastructure avec du code (IaC). Il faut quand même préciser qu'il y a eu un petit travail de recherche en amont sur les composants propres à Exoscale. Sinon le reste est plutôt proche de ce que nous avions vu dans le tutoriel sur Kubeadm avec Vagrant. L'ajout notable qui vient du concept d'IaC, est certainement la reproductibilité de cette installation et sa rapidité d'exécution, une fois le code mis au point.

PHILIPPE CHEPY

Administrateur Système et Développeur