Saga de l'été : E01 Construction d’une infrastructure multi-cloud

Saga de l'été : E01 Construction d’une infrastructure 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é.

Nous utilisons massivement les offres de services des grands fournisseurs de cloud pour construire nos applications et infrastructures. En alliant simplicité et efficacité, il s’agit maintenant pour beaucoup de monde du mode de déploiement par défaut.

Il est possible d’aller encore plus loin afin de profiter de tous les avantages du cloud sans se lier à un fournisseur particulier, et permettre à nos applications d’exister entre plusieurs fournisseurs de cloud. Déployer une application en mode multi-cloud apporte des avantages entre autres une résilience supplémentaire, une liberté de choix quant à la facturation, et une diversité des technologies utilisées.

Nous verrons ici comment mettre en place l’infrastructure de base qui servira de socle à tous nos déploiements 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 ?

Avant tout, nous devons commencer par construire le réseau et les interconnexions entre nos fournisseurs de cloud. Pour notre infrastructure, nous choisissons de nous installer chez AWS et chez GCP.
Pour les besoins de la démonstration, nous allons choisir deux régions distantes pour chaque fournisseur de cloud afin de vérifier que cela fonctionne correctement malgré la latence. Il est néanmoins possible de changer ces régions afin de correspondre à chaque besoin. Nous avons choisi de nous placer en Oregon (us-west-2) pour AWS et en Belgique pour GCP (europe-west1).

Dans chaque cloud, nous devons préparer une zone réseau, procéder à une connexion VPN pour permettre la communication directe, et s’assurer que le routage est effectif entre les deux zones.
Enfin nous créons un bastion qui servira de point d’entrée au sein de chaque fournisseur de cloud.
Le schéma ci-dessous représente notre architecture cible :

Un VPC de chaque côté

Commençons par créer nos VPC indépendants sur chaque cloud.

Pour AWS, nous devons créer un VPC, des subnets dans chaque zone de disponibilité. Nous allons choisir comme CIDR 172.30.3.0/24 que nous allons découper pour chacun de nos sous-réseaux.

resource "aws_vpc" "nomad" {  
  cidr_block = "172.30.3.0/24"
}

resource "aws_subnet" "nomad" {  
  count = 3

  cidr_block        = "${cidrsubnet(aws_vpc.nomad.cidr_block, 2, count.index)}"
  availability_zone = "${var.region_aws}${element(var.az_aws, count.index)}"
  vpc_id            = "${aws_vpc.nomad.id}"
}

Pour GCP, nous créons un network mais nous allons désactiver l’autocréation des subnets. Nous créons un subnet manuellement et lui affectons le range 172.27.3.0/26.

resource "google_compute_network" "nomad" {  
  name = "nomad"
  auto_create_subnetworks = "false"
}

resource "google_compute_subnetwork" "default-nomad" {  
  name          = "default-nomad"
  ip_cidr_range = "172.27.3.0/26"
  network       = "${google_compute_network.nomad.self_link}"
}

resource "google_compute_router" "nomad" {  
  name    = "router-nomad"
  network = "${google_compute_network.nomad.self_link}"

  bgp {
    asn = "${var.bgp_gcp}"
  }
}

Note : Nous déclarons déjà notre routeur car c'est une ressource nécessaire pour l'étape suivante. Vous noterez que nous affectons un numéro ASN pour notre routeur. Cette configuration est importante, nous verrons plus tard comment nous allons l'utiliser.

Nous pouvons lancer un terraform applypour créer ce que nous avons spécifié.
Nos deux VPC sont opérationnels, et nous pouvons commencer à travailler dessus.

Relier en direct AWS et GCP

Il est temps à présent de connecter nos deux infrastructures, nous allons créer les connexions VPN et les mettre en relation.

La configuration est sensiblement différente entre AWS et GCP. Tout est plus automatisé sur AWS avec moins de choix, et un peu plus complexe côté GCP mais avec plus de possibilités.

Sur AWS nous devons déclarer une passerelle VPN AWS, une passerelle AWS cliente (GCP pour nous), et la connexion entre les deux :

resource "aws_vpn_gateway" "vpn_gateway" {  
  vpc_id = "${aws_vpc.nomad.id}"
}

resource "aws_customer_gateway" "customer_gateway" {  
  bgp_asn    = "${var.bgp_gcp}"
  ip_address = "${google_compute_address.vpn_static_ip.address}"
  type       = "ipsec.1"
}

resource "aws_vpn_connection" "nomad" {  
  vpn_gateway_id      = "${aws_vpn_gateway.vpn_gateway.id}"
  customer_gateway_id = "${aws_customer_gateway.customer_gateway.id}"
  type                = "ipsec.1"
}

Vous noterez que sur notre customer gateway, nous spécifions un numéro ASN, il s'agit du même numéro déclaré à l'étape précédente. Nous expliquerons un peu plus tard la raison de ce paramètre.

Pour GCP la configuration est un peu plus complexe car il faut déclarer l'IP d'entrée, les règles de pare-feux, et créer enfin les deux tunnels VPN avec AWS via IPSec.

resource "google_compute_vpn_gateway" "target_gateway" {  
  name    = "vpn-aws"
  network = "${google_compute_network.nomad.self_link}"
}

resource "google_compute_address" "vpn_static_ip" {  
  name   = "vpn-static-ip"
}

resource "google_compute_forwarding_rule" "nomad_esp" {  
  name        = "vpn-gw-1-esp"
  ip_protocol = "ESP"
  ip_address  = "${google_compute_address.vpn_static_ip.address}"
  target      = "${google_compute_vpn_gateway.target_gateway.self_link}"
}

resource "google_compute_forwarding_rule" "nomad_udp500" {  
  name        = "vpn-gw-1-udp-500"
  ip_protocol = "UDP"
  port_range  = "500-500"
  ip_address  = "${google_compute_address.vpn_static_ip.address}"
  target      = "${google_compute_vpn_gateway.target_gateway.self_link}"
}

resource "google_compute_forwarding_rule" "nomad_udp4500" {  
  name        = "vpn-gw-1-udp-4500"
  ip_protocol = "UDP"
  port_range  = "4500-4500"
  ip_address  = "${google_compute_address.vpn_static_ip.address}"
  target      = "${google_compute_vpn_gateway.target_gateway.self_link}"
}

resource "google_compute_vpn_tunnel" "nomad-1" {  
  name               = "vpn-tunnel-1"
  target_vpn_gateway = "${google_compute_vpn_gateway.target_gateway.self_link}"
  shared_secret      = "${aws_vpn_connection.nomad.tunnel1_preshared_key}"
  peer_ip            = "${aws_vpn_connection.nomad.tunnel1_address}"
  router             = "${google_compute_router.nomad.name}"
  ike_version        = 1

  depends_on = [
    "google_compute_forwarding_rule.nomad_esp",
    "google_compute_forwarding_rule.nomad_udp500",
    "google_compute_forwarding_rule.nomad_udp4500",
  ]
}

resource "google_compute_vpn_tunnel" "nomad-2" {  
  name               = "vpn-tunnel-2"
  target_vpn_gateway = "${google_compute_vpn_gateway.target_gateway.self_link}"
  shared_secret      = "${aws_vpn_connection.nomad.tunnel2_preshared_key}"
  peer_ip            = "${aws_vpn_connection.nomad.tunnel2_address}"
  router             = "${google_compute_router.nomad.name}"
  ike_version        = 1

  depends_on = [
    "google_compute_forwarding_rule.nomad_esp",
    "google_compute_forwarding_rule.nomad_udp500",
    "google_compute_forwarding_rule.nomad_udp4500",
  ]
}

Après avoir de nouveau lancé terraform apply nous voyons que les connexions VPN sont en train de se construire, et au bout de quelques minutes la liaison entre les deux clouds est établie !
C'est très bien mais le travail n'est pas terminé ; si on veut pouvoir utiliser cette infra pour nos futurs déploiements.

Bastion et accès externe

L'étape suivante est de créer les points d'entrée d'administration dans chaque VPC, ainsi que les points de sortie pour les machines virtuelles qui seront hébergées.
Théoriquement, cette étape pourrait être passée si toutes nos VM disposent d'adresses IP publiques, néanmoins c'est une bonne pratique de ne pas rendre accessible notre infrastructure sur internet. Cela implique de limiter l'administration à un seul point d'entrée, un bastion qui lui disposera d'une IP publique, et afin que nos machines virtuelles aient accès à internet, nous devons créer une passerelle NAT.

Techniquement, nous allons créer une machine virtuelle qui servira de bastion sur chaque cloud provider. Pour l'accès externe, nous utiliserons la ressource NAT Gateway sur AWS, quant à GCP nous activerons le masquerading sur notre bastion et allons déclarer cette VM comme route par défaut de notre routeur cloud.
Note : Nous ne déclarons qu’une seule instance comme route par défaut afin de simplifier. Pour un déploiement réel il est préférable de prévoir un mécanisme de haute disponibilité.

La configuration AWS prévue est celle-ci :

# Bastion specs

resource "aws_instance" "bastion" {  
  instance_type = "${var.aws_instance_type}"
  ami = "${var.aws_image}"
  key_name = "${var.keypair}"

  vpc_security_group_ids = ["${aws_security_group.bastion.id}"]
  subnet_id = "${element(aws_subnet.nomad.*.id, count.index)}"
  associate_public_ip_address = true
}

# NAT GW specs

resource "aws_internet_gateway" "gw" {  
  vpc_id = "${aws_vpc.nomad.id}"
}

Sur GCP la configuration est la suivante :

# Bastion specs

resource "google_compute_instance" "bastion" {  
  name         = "gcp-bastion"
  machine_type = "${var.gcp_instance_type}"
  zone         = "${var.region_gcp}-${element(var.az_gcp, count.index)}"
  can_ip_forward = true

  disk {
    image = "${var.gcp_image}"
  }

  scheduling {
    automatic_restart   = true
    on_host_maintenance = "MIGRATE"
  }

  tags = ["bastion"]

  network_interface {
    subnetwork = "${google_compute_subnetwork.default-nomad.name}"
    access_config {
      // Auto generate
    }
  }

  metadata_startup_script = "${file("bootstrap_gcp_bastion.sh")}"
}

# NAT GW specs

resource "google_compute_route" "nat_routing" {  
  name        = "nat-routing"
  dest_range  = "0.0.0.0/0"
  network     = "${google_compute_network.nomad.name}"
  next_hop_instance_zone = "${var.region_gcp}-${element(var.az_gcp, count.index)}"
  next_hop_instance = "${google_compute_instance.bastion.name}"
  priority    = 800
  tags = ["nomad-servers", "nomad-clients", "consul-servers", "consul-clients"]
}

Comme pour les étapes précédents, la commande terraform apply va nous créer ces éléments d'infrastructures, et nous allons pouvoir d'ores et déjà nous connecter à nos bastions.

La magie du routage dynamique

Pour terminer, la dernière étape est de configurer les composants de routage afin de permettre la communication IP entre nos deux VPC cloud. En effet, actuellement même si le VPN est connecté, nous n'avons pas encore configuré les routeurs pour indiquer à nos instances virtuelles comment communiquer entre elles.

Si vous vous souvenez, dans les précédentes étapes nous avons pré-configuré des numéros ASN pour le protocole BGP. Nous sommes maintenant arrivés à l'étape où nous allons les utiliser.
BGP est un protocole de routage dynamique, c'est parfait pour nous car ainsi chaque routeur cloud va annoncer à l'autre les routes qu'il gère. Ainsi, nous n'avons pas à déclarer manuellement les routes de chaque côté, nos routeurs le feront pour nous.

Sur AWS nous devons déclarer une table de routage précisant qu'il y a propagation des routes via la passerelle VPN. Ceci est possible car nous avons déclaré un routeur dynamique géré via BGP dans les précédentes étapes.

resource "aws_route_table" "pub" {  
  vpc_id = "${aws_vpc.nomad.id}"

  propagating_vgws = ["${aws_vpn_gateway.vpn_gateway.id}"]

  route {
    cidr_block = "0.0.0.0/0"
    gateway_id = "${aws_internet_gateway.gw.id}"
  }
}

resource "aws_route_table_association" "pub" {  
  count = 3

  subnet_id      = "${element(aws_subnet.nomad.*.id, count.index)}"
  route_table_id = "${aws_route_table.pub.id}"
}

Pour GCP nous avons déjà préconfiguré une bonne partie de notre routage dynamique, l'étape suivante consiste à déclarer notre VPN dans notre routeur cloud, puis le routeur BGP d'AWS.
Attention : Si vous utilisez Terraform en version inférieur à 0.10 lisez les notes plus bas.

resource "google_compute_router_interface" "nomad-1" {  
  name       = "interface-1"
  router     = "${google_compute_router.nomad.name}"
  ip_range   = "${aws_vpn_connection.nomad.tunnel1_cgw_inside_address}/30"
  vpn_tunnel = "${google_compute_vpn_tunnel.nomad-1.name}"
}

resource "google_compute_router_interface" "nomad-2" {  
  name       = "interface-2"
  router     = "${google_compute_router.nomad.name}"
  ip_range   = "${aws_vpn_connection.nomad.tunnel2_cgw_inside_address}/30"
  vpn_tunnel = "${google_compute_vpn_tunnel.nomad-2.name}"
}

resource "google_compute_router_peer" "nomad-1" {  
  name                      = "peer-1"
  router                    = "${google_compute_router.nomad.name}"
  peer_ip_address           = "${aws_vpn_connection.nomad.tunnel1_vgw_inside_address}"
  peer_asn                  = "${var.bgp_aws}"
  advertised_route_priority = 100
  interface                 = "${google_compute_router_interface.nomad-1.name}"
}

resource "google_compute_router_peer" "nomad-2" {  
  name                      = "peer-2"
  router                    = "${google_compute_router.nomad.name}"
  peer_ip_address           = "${aws_vpn_connection.nomad.tunnel2_vgw_inside_address}"
  peer_asn                  = "${var.bgp_aws}"
  advertised_route_priority = 100
  interface                 = "${google_compute_router_interface.nomad-2.name}"
}

Note : Avant la version 0.10 de Terraform il n'y a pas d'attribut exporté indiquant le numéro ASN d'AWS. Pour cela, vous devrez probablement relancer la commande terraform apply pour modifier le numéro BGP AWS de la variable bgp_aws suivant la valeur qui sera affichée dans la sortie XML de la customer configuration (vpnconnection/vpngateway/bgp/asn).
Si vous êtes en version 0.10 ou supérieur, remplacez la variable bgp_aws par ${aws_vpn_connection.nomad.tunnel1_bgp_asn} et idem pour le second tunnel.

Conclusion

Notre infrastructure est maintenant pleinement en place, nous pouvons nous y connecter et la tester.

$ terraform output

aws_bastion_ip = [  
    54.69.203.153,
    172.30.3.60
]
gcp_bastion_ip = [  
    104.199.63.67,
    172.27.3.2
]

Nous voyons les ip publiques et privées de chacun de nos bastions, si tout est correct nous pouvons nous connecter à l'un des bastions, et communiquer avec l'autre via son IP privée.
Testons en nous connectant au bastion GCP et interrogeant l'IP privée du bastion AWS :

$ ssh 104.199.63.67
[bcadiot@gcp-bastion ~]$ ping 172.30.3.60
PING 172.30.3.60 (172.30.3.60) 56(84) bytes of data.  
64 bytes from 172.30.3.60: icmp_seq=1 ttl=64 time=169 ms  
64 bytes from 172.30.3.60: icmp_seq=2 ttl=64 time=168 ms  
64 bytes from 172.30.3.60: icmp_seq=3 ttl=64 time=169 ms  
^C
--- 172.30.3.60 ping statistics ---
3 packets transmitted, 3 received, 0% packet loss, time 2003ms  
rtt min/avg/max/mdev = 168.878/169.296/169.851/0.529 ms  

Tout est parfait, nous avons relié nos deux fournisseurs de cloud et il est maintenant possible de continuer notre série sur la construction d'application multi-cloud résilientes.

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