Saga de l'été : E02 Découverte de service et répartition de charge multi-cloud

Saga de l'été : E02 Découverte de service et répartition de charge multi-cloud

Cet article fait partie d’une série d’articles autour du thème "Construire des applications résilientes en multi-cloud" qui sera publiée tout au long de l’été.

Dans notre précédent article nous avons vu comment construire une infrastructure multi-cloud en reliant AWS et GCP via un VPN. Nous gardons toujours l’objectif de permettre à nos applications d’exister entre plusieurs fournisseurs de cloud avec tous les avantages que cela apporte.

Nous verrons ici comment mettre en place les mécanismes de découverte de services et la répartition de charge.
Cette nouvelle étape va s’appuyer sur la précédente infrastructure, donc n’hésitez pas à vous faire une piqûre de rappel en relisant le premier article : E01 Construction d’une infrastructure multi-cloud.

La création de l’infrastructure est assistée par Terraform, mais peut être réalisée manuellement sans problème.
Le code utilisé est publié au fur et à mesure des articles sur : https://github.com/bcadiot/multi-cloud.
Pour information, afin de simplifier l'article, certains éléments n'apparaissent pas (comme les security group et les variables Terraform) mais peuvent être récupérés depuis les sources publiées sur github.

Quel est l’objectif ?

Maintenant que nos deux zones réseaux peuvent communiquer ensemble il est temps de faire de même pour nos services applicatifs. Le but recherché est de permettre à nos applications de se découvrir mutuellement et de communiquer ensemble peu importe leur localisation réelle.

Nous allons installer un annuaire de service sur chaque zone pour y parvenir, et le choix s'est porté sur Consul d'HashiCorp.
Chaque zone réseau va disposer de son propre cluster Consul qui fournira son service aux serveurs situés dans le même datacenter. Puis nous allons relier les deux clusters Consul ensemble afin de permettre aux serveurs de découvrir ceux situés dans l'autre zone.

Lorsque nos clusters seront opérationnels, nous pourrons déployer la seconde phase de notre installation : la répartition de charge. Tous nos services étant hébergés dans des sous-réseaux privés, il est essentiel de prévoir une solution d'accès frontal pour y accéder. En outre cette solution devra être capable de se connecter à Consul afin de pouvoir déterminer où sont situés les services.
Le choix s'est porté sur Traefik qui a l'avantage d'être très performant et surtout nativement compatible avec la plupart des annuaires de services (Consul, Kubernetes, Mesos, etc...).

Le schéma ci-dessous représente notre architecture cible :

Évolution de l’infrastructure

Avant de commencer à travailler, nous avons quelques mises à jour à faire sur notre infrastructure de base définie lors de la précédente étape. Ces mises à jour sont mineures et n'avaient pas été réalisées sur le moment pour ne pas diluer l'information. Il s'agit principalement d'ajouter sur chaque zone cloud des sous-réseaux privés non routables depuis internet.

Enfin, afin de pouvoir utiliser la configuration Terraform de notre infra de base nous allons utiliser la ressource remote_state au sein de nos travaux de ce jour :

data "terraform_remote_state" "network" {  
  backend = "local"

  config {
    path = "../e01_network_layer/terraform.tfstate"
  }
}

Consul Love Cloud API

Consul est conçu dans une optique de simplicité et d'efficacité, à ce titre sa configuration est simple à écrire et à comprendre. Pour gagner encore plus en simplicité nous allons utiliser une des fonctionnalités les plus récentes de Consul, la découverte automatique de ses pairs en utilisant les API des cloud providers.
En indiquant à Consul le fournisseur de cloud sur lequel il s'exécute, et les tags associés aux instances Consul, il ira de lui-même interroger les API cloud pour obtenir les informations dont il a besoin pour se connecter.

{
    "bootstrap_expect": 3,
    "server": true,
    "datacenter": "europe-west1",
    "data_dir": "/var/consul",
    "log_level": "INFO",
    "enable_syslog": true,
    "retry_join": ["provider=gce tag_value=consul-servers"],
    "bind_addr": "172.27.3.130",
    "client_addr": "0.0.0.0"
}

Ce qui est intéressant c'est le champ retry_join, celui-ci n'indique pas une adresse mais une configuration spéciale. Cette configuration permet à Consul de se connecter à GCE pour récupérer la liste des serveurs ayant le tag consul-servers. Et c'est tout !
Pour que cela fonctionne, il faut quelques permissions en lecture sur chaque cloud provider :
- Sur GCE il faut ajouter la permission suivante au service account : "https://www.googleapis.com/auth/compute.readonly" - Sur AWS il faut utiliser un instanceprofilename ayant la permission ec2:DescribeInstances

Pour utiliser la fonctionnalité sous cette forme, il faut cibler la version 0.9.1 minimum de Consul.

Pour terminer, cette fonctionnalité existe également pour Azure et SoftLayer, n'hésitez pas à voir la documentation.
Cette fonctionnalité n'est pas obligatoire, et il est tout à fait possible d'utiliser la commande consul join sur chaque node.

Déploiement du cluster Consul

Maintenant que nous avons une idée assez précise de la configuration Consul que nous voulons déployer, il ne nous reste plus qu'à créer les instances dans chaque zone.

resource "google_compute_instance" "consul" {  
  count        = 3
  name         = "server-gcp-consul-${count.index + 1}"
  machine_type = "${var.gcp_instance_type}"
  zone         = "${var.gcp_region}-${element(var.az_gcp, count.index)}"

  boot_disk {
    initialize_params {
      image = "${var.gcp_image}"
    }
  }

  scheduling {
    automatic_restart   = true
    on_host_maintenance = "MIGRATE"
  }

  tags = ["consul-servers"]

  network_interface {
    subnetwork = "${data.terraform_remote_state.network.gcp_priv_subnet}"
  }

  service_account {
    scopes = [
        "https://www.googleapis.com/auth/compute.readonly"
      ]
  }

  metadata_startup_script = "${data.template_file.gcp_bootstrap_consul.rendered}"
}

data "template_file" "gcp_bootstrap_consul" {  
  template = "${file("bootstrap_consul.tpl")}"

  vars {
    domain = "${var.domain}"
    zone = "$(curl http://metadata.google.internal/computeMetadata/v1/instance/zone -H \"Metadata-Flavor: Google\" | cut -d\"/\" -f4)"
    datacenter = "$(echo $${ZONE} | cut -d\"-\" -f1)-$(echo $${ZONE} | cut -d\"-\" -f2)"
    output_ip = "$(curl http://metadata.google.internal/computeMetadata/v1/instance/network-interfaces/0/ip -H \"Metadata-Flavor: Google\")"
    consul_version = "0.9.2"
    join = "\"retry_join\": [\"provider=gce tag_value=consul-servers\"]"
  }
}

La configuration GCP est basique, on notera tout de même l'utilisation des remotestate pour cibler le sous-réseau où se déployer, ainsi que l'utilisation de la ressource templatefile afin de factoriser le déploiement entre AWS et GCP.
On peut voir le positionnement de la permission du service_account pour autoriser la jonction automatique des Consul.

Le script de bootstrap est disponible sur github pour être analysé. Il procède à l'installation de Consul ainsi qu'un démon dnsmasq afin de fournir un DNS local connecté à l'interface DNS de Consul.

Quant à AWS, la même chose est déployée.

resource "aws_instance" "consul" {  
  count = 3
  instance_type = "${var.aws_instance_type}"
  ami = "${var.aws_image}"
  key_name = "${var.keypair}"
  iam_instance_profile = "ec2_describe_instances"

  vpc_security_group_ids = ["${aws_security_group.consul_servers.id}"]
  subnet_id = "${element(data.terraform_remote_state.network.aws_priv_subnet, count.index)}"
  associate_public_ip_address = false

  user_data = "${data.template_file.aws_bootstrap_consul.rendered}"

  tags {
    Name = "server-aws-consul-${count.index + 1}"
    Consul = "server"
  }

  depends_on = ["google_compute_instance.consul"]
}

Je n'ai pas recopié le bloc template_file qui est presque similaire, n'hésitez pas à aller voir sur github pour voir le contenu exact des fichiers de configuration.
Il est déjà possible de créer notre infrastructure à l'aide de la commande terraform apply.

Traefik Love Consul API

À présent, il est temps de regarder comment nous allons configurer Traefik pour offrir le service de répartition de charge en lisant sa configuration dynamiquement depuis Consul. L'idée est d'indiquer à Traefik de se connecter à un Consul local pour retrouver la liste des backend et des frontend à configurer.

defaultEntryPoints = ["http"]

[entryPoints]
  [entryPoints.http]
  address = ":80"

[web]
  address = ":8080"

[consulCatalog]
  endpoint = "127.0.0.1:8500"
  watch = true
  domain = "exemple.com"
  prefix = "traefik"
  constraints = ["tag==exposed"]

La configuration est simple, nous indiquons seulement le entrypoints, le point d'accès d'administration sur le port 8080 (dont nous limiterons l'accès au seul bastion via un security group), et la connexion Consul.

On notera le bloc consulCatalog qui permet d'indiquer les informations de connexions au Consul local. Enfin, nous créons une contrainte sur le tag afin de limiter l'exposition aux seuls services ayant le tag exposed.

Déploiement des Load Balancer Traefik

Terminons notre installation en créant les instances Traefik sur chaque zone. Nous en lançons deux par fournisseur de Cloud pour assurer la haute disponibilité, commençons par le déploiement sur GCP :

resource "google_compute_instance" "traefik" {  
  count        = 2
  name         = "server-gcp-traefik-${count.index + 1}"
  machine_type = "${var.gcp_instance_type}"
  zone         = "${var.gcp_region}-${element(var.az_gcp, count.index + 1)}"

  boot_disk {
    initialize_params {
      image = "${var.gcp_image}"
    }
  }

  scheduling {
    automatic_restart   = true
    on_host_maintenance = "MIGRATE"
  }

  tags = ["traefik", "consul-traefik"]

  network_interface {
    subnetwork = "${data.terraform_remote_state.network.gcp_pub_subnet}"
    access_config {
      // Auto generate
    }
  }

  service_account {
    scopes = [
        "https://www.googleapis.com/auth/compute.readonly"
      ]
  }

  metadata_startup_script = "${data.template_file.gcp_traefik_bootstrap.rendered}"

  depends_on = ["google_compute_instance.consul"]
}

La configuration est similaire à celle utilisée par les Consul, la seule différence est l'ajout d'un bloc access_config pour demander la création d'une IP publique dynamique. Il est possible d'utiliser une IP statique mais pour notre test nous n'en avons pas besoin.

Une petite vigilance à noter sur le positionnement réseau, nous plaçons les VM dans le sous-réseau public, et nous appliquons un tag spécifique. Attention à ne pas utiliser les tags de routage privé (type consul-clients) afin de s'assurer que ces VM puissent communiquer en direct avec internet sans être masquées derrière les bastions.

Continuons avec la configuration pour AWS :

resource "aws_instance" "traefik" {  
  count = 2
  instance_type = "${var.aws_instance_type}"
  ami = "${var.aws_image}"
  key_name = "${var.keypair}"
  iam_instance_profile = "ec2_describe_instances"

  vpc_security_group_ids = ["${aws_security_group.traefik.id}", "${aws_security_group.traefik_adm.id}", "${aws_security_group.consul_clients.id}"]
  subnet_id = "${element(data.terraform_remote_state.network.aws_pub_subnet, count.index + 1)}"
  associate_public_ip_address = true

  user_data = "${data.template_file.aws_traefik_bootstrap.rendered}"

  tags {
    Name = "server-aws-traefik-${count.index + 1}"
    Consul = "client"
  }

  depends_on = ["aws_instance.consul"]
}

Pas de surprise particulière, nous pouvons maintenant déployer notre infrastructure à l'aide de la commande terraform apply.

Si nous vérifions l'état de Consul, nous pouvons observer la réussite de nos opérations :

$ consul members
Node                  Address            Status  Type    Build  Protocol  DC  
server-gcp-consul-1   172.27.3.130:8301  alive   server  0.9.2  2         europe-west1  
server-gcp-consul-2   172.27.3.132:8301  alive   server  0.9.2  2         europe-west1  
server-gcp-consul-3   172.27.3.131:8301  alive   server  0.9.2  2         europe-west1  
server-gcp-traefik-1  172.27.3.3:8301    alive   client  0.9.2  2         europe-west1  
server-gcp-traefik-2  172.27.3.4:8301    alive   client  0.9.2  2         europe-west1

$ consul members -wan
Node                              Address            Status  Type    Build  Protocol  DC  
ip-172-30-3-157.us-west-2         172.30.3.157:8302  alive   server  0.9.2  2         us-west-2  
ip-172-30-3-170.us-west-2         172.30.3.170:8302  alive   server  0.9.2  2         us-west-2  
ip-172-30-3-203.us-west-2         172.30.3.203:8302  alive   server  0.9.2  2         us-west-2  
server-gcp-consul-1.europe-west1  172.27.3.130:8302  alive   server  0.9.2  2         europe-west1  
server-gcp-consul-2.europe-west1  172.27.3.132:8302  alive   server  0.9.2  2         europe-west1  
server-gcp-consul-3.europe-west1  172.27.3.131:8302  alive   server  0.9.2  2         europe-west1  

Test de notre déploiement

Il est temps de tester notre déploiement, pour cela nous allons créer deux serveurs temporaires dans chaque zone, nous assurer qu'ils s'enregistrent bien sur le Consul, puis nous leur ajouterons un service prise en charge par Traefik.

Par exemple pour AWS :

resource "aws_instance" "test" {  
  count        = "${var.test == true ? 2 : 0}"
  instance_type = "${var.aws_instance_type}"
  ami = "${var.aws_image}"
  key_name = "${var.keypair}"
  iam_instance_profile = "ec2_describe_instances"

  vpc_security_group_ids = ["${aws_security_group.consul_clients.id}", "${aws_security_group.traefik.id}"]
  subnet_id = "${element(data.terraform_remote_state.network.aws_priv_subnet, count.index)}"
  associate_public_ip_address = false

  user_data = "${data.template_file.aws_bootstrap_test.rendered}"

  tags {
    Name = "server-aws-test-${count.index + 1}"
    Consul = "client"
  }

  depends_on = ["aws_instance.consul"]
}

Je ne montrerai pas la configuration pour GCP, elle est similaire à ce qui à été fait avant, et est disponible sur github.
Vous noterez qu'on conditionne la création de ces serveurs à la valeur d'une variable. Le script de bootstrap quant à lui va installer Consul, Apache, et enregistrer ce dernier en tant que service local dans Consul.

terraform apply -var test=true  

Justement, le fichier de configuration web.json du service à positionner dans /etc/consul/ est composé ainsi :

{
    "service": {
        "name": "demo",
        "tags": ["traefik.tags=exposed"],
        "port": 80
    }
}

Nous créons un service nommé demo, qui définit le port 80 du serveur comme port de service, et nous y ajoutons les tags nécessaires à Traefik pour exposer le service. Il ne nous reste plus qu'à actualiser Consul :

$ consul reload

Si nous interrogeons l'API Consul locale, nous devrions voir apparaître le nouveau service.

$ curl "http://localhost:8500/v1/catalog/service/demo?pretty"
[
    {
        "ID": "d1c9e9b3-6986-98e5-3fb8-468e4b627b58",
        "Node": "server-gcp-test-1",
        "Address": "172.27.3.133",
        "Datacenter": "europe-west1",
        "TaggedAddresses": {
            "lan": "172.27.3.133",
            "wan": "172.27.3.133"
    },
        },
        "NodeMeta": {},
        "ServiceID": "demo",
        "ServiceName": "demo",
        "ServiceTags": [
            "traefik.tags=exposed"
        ],
        "ServiceAddress": "",
        "ServicePort": 80,
        "ServiceEnableTagOverride": false,
        "CreateIndex": 13,
        "ModifyIndex": 13
    },
...
]

Cette commande peut être passée sur n'importe lequel des serveurs disposant d'un Consul opérationnel. L'affichage du service se limitera aux nodes présents dans le datacenter Consul interrogé. Il est néanmoins possible de vérifier cela dans l'autre datacenter en ajoutant un argument dc à la requête :

$ curl "http://localhost:8500/v1/catalog/service/demo?pretty&dc=us-west-2"
[
    {
        "ID": "e7d87d39-82dd-01e1-b13b-ba47889997cc",
        "Node": "ip-172-30-3-155",
        "Address": "172.30.3.155",
        "Datacenter": "us-west-2",
        "TaggedAddresses": {
            "lan": "172.30.3.155",
            "wan": "172.30.3.155"
        },
        "NodeMeta": {},
        "ServiceID": "demo",
        "ServiceName": "demo",
        "ServiceTags": [
            "traefik.tags=exposed"
        ],
        "ServiceAddress": "",
        "ServicePort": 80,
        "ServiceEnableTagOverride": false,
        "CreateIndex": 19,
        "ModifyIndex": 19
    },
...
]

La dernière étape consiste à vérifier la bonne intégration dans Traefik, pour cela nous allons d'abord nous connecter à l'interface d'administration, puis tester les requêtes.

L'interface d'administration étant limitée au seul accès du bastion, nous ouvrons un tunnel SSH :

# ssh -L 8080:IP_PRIVEE_TRAEFIK:8080 IP_PUBLIQUE_BASTION
ssh -L 8080:172.27.3.4:8080 35.195.115.253  

Si vous ouvrez votre navigateur et allez sur localhost:8080 vous devriez accéder au dashboard Traefik :

Tout fonctionne bien, à présent testons les requêtes, trois possibilités s'offrent à nous :
- Soit vous avez la chance d'être propriétaire du domaine exemple.com (on peut rêver), dans ce cas il vous suffit d'ajouter les quatres IP publiques des Traefik comme destination A du domaine demo.exemple.com - Soit vous changez la variable domain dans les variables Terraform et vous modifiez vos DNS comme indiqué ci-dessus pour que le domaine demo.votredomaine.tld pointe vers les Traefik - Soit vous préférez tester en local, auquel cas modifier le /etc/hosts d'un des bastions fera très bien l'affaire. C'est cette option que nous allons utiliser.

Connectez-vous à l'un des bastions, et ajoutez à la fin de votre fichier /etc/hosts quatre lignes pointant vers les IP publiques des Traefik. Note : votre Linux va s'arrêter au premier enregistrement trouvé, donc nous allons devoir commenter les lignes déjà testées si on veut vérifier que les quatre Traefik fonctionnent bien.

#52.36.120.25 demo.exemple.com
#35.161.127.1 demo.exemple.com
#35.195.200.165 demo.exemple.com
35.195.182.101 demo.exemple.com  

Enfin, nous testons simplement à l'aide de la commande curl en modifiant le fichier hosts autant de fois que nécessaire :

[root@gcp-bastion ~]# vi /etc/hosts

#Traefik 1 AWS
root@gcp-bastion ~]# curl demo.exemple.com  
welcome to webserver : ip-172-30-3-149  
[root@gcp-bastion ~]# curl demo.exemple.com        
welcome to webserver : ip-172-30-3-184  
#Traefik 2 AWS
[root@gcp-bastion ~]# curl demo.exemple.com        
welcome to webserver : ip-172-30-3-149  
[root@gcp-bastion ~]# curl demo.exemple.com        
welcome to webserver : ip-172-30-3-184

#Traefik 1 GCP
[root@gcp-bastion ~]# curl demo.exemple.com        
welcome to webserver : server-gcp-test-1.c.sandbox-bcadiot.internal  
[root@gcp-bastion ~]# curl demo.exemple.com        
welcome to webserver : server-gcp-test-2.c.sandbox-bcadiot.internal  
#Traefik 2 GCP
[root@gcp-bastion ~]# curl demo.exemple.com        
welcome to webserver : server-gcp-test-1.c.sandbox-bcadiot.internal  
[root@gcp-bastion ~]# curl demo.exemple.com        
welcome to webserver : server-gcp-test-2.c.sandbox-bcadiot.internal  

Félicitations, tout fonctionne, et on constate que les Traefik distribuent le trafic entre tous les serveurs !

Conclusion

Nous avons créé nos annuaires de services et nos répartiteurs de charge sur nos deux fournisseurs de cloud et il est maintenant possible de continuer notre série sur la construction d'application multi-cloud résilientes.

Si tester le déploiement vous intéresse, le code utilisé est disponible sur https://github.com/bcadiot/multi-cloud.

Rendez-vous très bientôt pour le prochain article !