Featured image of post Terraform Associate #8 — Mise en pratique : hub & spoke sur GCP

Terraform Associate #8 — Mise en pratique : hub & spoke sur GCP

Mise en application de la série Terraform Associate : construction d'une architecture réseau hub & spoke sur GCP, avec un hub et deux spokes (dev et prod).

Après les sept premiers modules de ma préparation à la certification Terraform Associate (fondamentaux, IaC, workflow, configuration, modules, state,…), place à la pratique : je mets ces connaissances en application en construisant une architecture réseau hub & spoke sur GCP.

Introduction

Une architecture hub & spoke, c’est comme un aéroport de correspondance : un hub central qui concentre les ressources et services partagés, et des spokes qui y sont raccordés mais qui n’ont pas de liaison directe entre eux. Tout le trafic transite par le hub.

Appliqué à un réseau cloud, ça donne :

  • un hub : un VPC central qui héberge les ressources partagées (passerelle NAT, connectivité vers l’extérieur, éventuellement pare-feu et DNS centralisés) ;
  • des spokes : des VPC isolés par environnement, ici dev et prod, connectés au hub mais étanches l’un par rapport à l’autre.

L’intérêt : mutualiser ce qui peut l’être tout en gardant une isolation forte entre environnements, un cas d’usage classique pour tester en conditions réelles les notions vues dans les articles précédents (modules, state, workflow).

À la fin de ce tutoriel, vous saurez construire cette architecture sur GCP avec Terraform.

Prérequis

  • Avoir suivi les articles précédents de la série Terraform Associate, notamment sur les modules et le state.
  • Un compte GCP avec un projet.
  • Terraform installé et le CLI gcloud configuré.

Architecture

Voici l’architecture visée :

VPCSubnetCIDR
hub-vpchub-subnet10.0.0.0/24
spoke-dev-vpcspoke-dev-subnet10.1.0.0/24
spoke-prod-vpcspoke-prod-subnet10.2.0.0/24

Chaque spoke aura un peering bidirectionnel avec le hub (hub_to_spoke / spoke_to_hub); aucun peering entre les différent spoke, pas de NAT ni IP publique pour les VM.

Iterations

1. Mise en place de l’infrastructure de base

Ici l’idée a été de partir d’une configuration simple en laissant volontairement des pistes d’améliorations. On part d’une configuration qui fonctionne et petit à petit on vise quelque chose d’optimisé et portable entreprise.

Voici l’architecture obtenue :

.
├── .gitignore
├── .terraform.lock.hcl
├── README.md
├── compute.tf
├── firewall.tf
├── main.tf
├── peering.tf
├── routing.tf
├── subnet.tf
├── variables.tf
└── vpc.tf

Une structure plate, un fichier par type de ressource (VPC, subnet, firewall, peering, routing, compute), le tout dans un seul state local.

La totalité de la configuration est disponible ici.

2. Refactoring à l’aide de modules

Si on analyse la configuration précédente, on voit rapidement que pas mal de configuration est dupliquée :

resource "google_compute_instance" "hub_vm" {
  name         = "hub-vm"
  machine_type = "e2-micro"
  zone         = "europe-west1-b"

  boot_disk {
    initialize_params {
      image = "debian-cloud/debian-13"
    }
  }

  network_interface {
    network    = google_compute_network.hub.id
    subnetwork = google_compute_subnetwork.hub.id
    # pas de access_config => pas d'IP publique
  }
}

resource "google_compute_instance" "spoke_vm" {
  for_each     = var.spokes
  name         = "${each.key}-vm"
  machine_type = "e2-micro"
  zone         = "europe-west1-b"

  boot_disk {
    initialize_params {
      image = "debian-cloud/debian-13"
    }
  }

  network_interface {
    network    = google_compute_network.spoke[each.key].id
    subnetwork = google_compute_subnetwork.spoke[each.key].id
  }
}

Créons donc un module compute:

main.tf

resource "google_compute_instance" "this" {
  name         = "${var.name}-vm"
  machine_type = "${var.machine_type}"
  zone         = "europe-west1-b"

  boot_disk {
    initialize_params {
      image = "debian-cloud/debian-13"
    }
  }

  network_interface {
    network    = var.network_interface.network
    subnetwork = var.network_interface.subnetwork
  }
}

variables.tf

variable "name" {
  type        = string
  description = "Nom logique de la machine"
}

variable "machine_type" {
  type        = string
  description = "Type de la machine"
  default     = "e2-micro"
}

variable "network_interface"{
    type      = object({
      network = string
      subnetwork = string
    })
}

outputs.tf

output "compute_id" {
  value = google_compute_instance.this.id
}

output "network_name" {
  value = google_compute_instance.this.name
}

Dans notre module root il suffit alors d’appeler ce module via :

module "hub_vm" {
  source = "./modules/compute"
  name   = "hub"
  network_interface = {
    network    = module.hub.network_id
    subnetwork = module.hub.subnetwork_id
  }
}

module "spoke_vm" {
  source   = "./modules/compute"
  for_each = var.spokes
  name     = "spoke-${each.key}"
  network_interface = {
    network    = module.spoke[each.key].network_id
    subnetwork = module.spoke[each.key].subnetwork_id
  }
}

Version fonctionnelle ici

3. Ajout de Lifecycle

Le VPC hub est une ressource critique de notre infrastructure puisque les deux spoke passe par lui. Afin de faire échouer un plan si on tente un destroy sur cette ressource on peut s’appuyer sur le meta argument lifecycle.

resource "google_compute_network" "hub" {
  name                    = "hub-vpc"
  auto_create_subnetworks = false

  lifecycle {
    prevent_destroy = true
  }
}

4. Bloc de validation

Dans la même idée on peut mettre en place une règle de validation sur le module vpc pour empêcher le chevauchement de cidr avec le hub.

modules/vpc/variables.tf

variable "cidr" {
  type        = string
  description = "Subnet CIDR range (e.g. 10.0.0.0/24)"

  validation {
    condition = var.cidr != "10.0.0.0/24"
    error_message = "The cidr range shouldn't overlap with hub's one."
  }
}

Un plan qui tente ce chevauchement aura ce message d’erreur :

│ Error: Invalid value for variable
│   on main.tf line 29, in module "spoke":
│   29:   cidr     = each.value.cidr
│     ├────────────────
│     │ var.cidr is "10.0.0.0/24"
│ The cidr range shouldn't overlap with hub's one.
│ This was checked by the validation rule at
│ modules/vpc/variables.tf:15,3-13.

5. Data sources

Autre ajout mineur mais qui permet de mettre en exergue l’instruction data, l’image Debian est codée en dur (ex: debian-cloud/debian-13). Si dans 6 mois Google dépréciée cette image, l’apply échoue. Avec data "google_compute_image" { family = "debian-13", project = "debian-cloud" }, Terraform récupère toujours la dernière image au moment de l’apply, sans maintenance manuelle.

data "google_compute_image" "debian" {
  family  = "debian-13"
  project = "debian-cloud"
}

resource "google_compute_instance" "this" {
  name         = "${var.name}-vm"
  machine_type = "${var.machine_type}"
  zone         = "europe-west1-b"

  boot_disk {
    initialize_params {
      #Utilisation de la data créée plus haut
      image = data.google_compute_image.debian.self_link
    }
  }

6. Séparation des states

Enfin, un changement un peu plus costaud mais qui peut réellement être pertinent en entreprise : séparé la partie réseau dans un state dédié.

A ce stade, modifier une VM oblige à revoir un plan qui inclut tout le réseau (VPC, firewall, routes), ce qui est risqué. En séparant en deux stacks (network/ et compute/), l’équipe réseau pourrait appliquer ses changements indépendamment de l’équipe applicative. La partie compute lit ensuite les infos du VPC via un bloc data qui pointe vers le state network au lieu de dupliquer les valeurs en dur : si le réseau change, le compute suit automatiquement au prochain apply.

Le découpage final peut être retrouvé ici.

Avant :

.
├── main.tf
├── providers.tf
├── variables.tf
└── modules/
    ├── compute/
    ├── firewall/
    ├── peering/
    ├── routing/
    └── vpc/

Après :

.
├── modules/
│   ├── compute/
│   ├── firewall/
│   ├── peering/
│   ├── routing/
│   └── vpc/
├── network/
│   ├── main.tf
│   ├── outputs.tf
│   ├── providers.tf
│   └── variables.tf
└── compute/
    ├── main.tf
    └── providers.tf

Le dossier modules/ reste commun aux deux stacks. Ce qui était à la racine (main.tf, providers.tf, variables.tf, donc un seul state) est éclaté en deux : network/ (VPC, subnets, firewall, peering, routing) et compute/ (VMs), chacun avec son propre backend et donc son propre state.

compute/ n’a plus de variables.tf : il récupère les infos réseau via un bloc data "terraform_remote_state" qui pointe vers le state de network/, plutôt que de dupliquer des valeurs en dur.

HCP Terraform

Comme nous l’avons vu dans l’article précédent, HCP Terraform est la plateforme cloud d’Hashicorp. J’ai voulu l’essayer pour découvrir plus en détail ses capacités.

Configuration

Tout démarre via la création d’un compte Terraform/HCP, vous avez ensuite la possibilité de lier ce compte à un VCS. Pour ma part j’ai choisi de le faire pour bénéficier de l’intégration VCS avec Github.

Enfin il faut créer une organisation, bernedotcom pour ma part.

Dernière étape : créer un workspace pour cela vous avez deux possibilités :

  • Lancer terraform login depuis votre poste local + terraform init
  • Le créer manuellement

Migration du backend

Il faut désormais indiquer à notre configuration que le backend est HCP désormais via l’instruction cloud. Suite à la séparation des states j’ai deux workspaces à gérer : gcp-hub-and-spoke-network et gcp-hub-and-spoke-compute.

Voici un exemple de configuration dans network/providers.tf

terraform {
  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "7.45.0"
    }
  }
  required_version = ">= 1.15"

  cloud {
    organization = "bernedotcom"
    workspaces {
      name = "gcp-hub-and-spoke-network"
    }
  }
}

Un coup de terraform init pour valider tout ça et c’est terminé pour cette partie.

Service account

Auparavant terraform était lancé depuis mon poste local et utilisait mon compte personnel GCP pour appliquer la configuration (ce n’est évidemment pas une bonne pratique). Maintenant c’est HCP qui va lancer les plan & apply depuis une machine distante, il faut alors l’autoriser à faire ce type d’opérations sur mon projet GCP.

Pour cela deux possibilités :

  • Un token d’authorization (peu secure)
  • Workload identity (plus complexe à mettre en place mais beaucoup plus robuste).

J’ai opté pour la workload identity ici :

# 1. Activer les APIs nécessaires
gcloud services enable iam.googleapis.com iamcredentials.googleapis.com sts.googleapis.com --project=hubandspoke-demo

# 2. Créer le Workload Identity Pool
gcloud iam workload-identity-pools create "hcp-terraform-pool" \
  --project="hubandspoke-demo" \
  --location="global" \
  --display-name="HCP Terraform"

# 3. Créer le provider OIDC dans ce pool, restreint à org=bernedotcom / workspaces=gcp-hub-and-spoke-network,gcp-hub-and-spoke-compute
gcloud iam workload-identity-pools providers create-oidc "hcp-terraform-provider" \
  --project="hubandspoke-demo" \
  --location="global" \
  --workload-identity-pool="hcp-terraform-pool" \
  --display-name="HCP Terraform Provider" \
  --attribute-mapping="google.subject=assertion.sub,attribute.terraform_organization_name=assertion.terraform_organization_name,attribute.terraform_workspace_name=assertion.terraform_workspace_name,attribute.terraform_run_phase=assertion.terraform_run_phase" \
  --attribute-condition="assertion.terraform_organization_name==\"bernedotcom\" && assertion.terraform_workspace_name in [\"gcp-hub-and-spoke-network\", \"gcp-hub-and-spoke-compute\"]" \
  --issuer-uri="https://app.terraform.io"

# 4. Créer le service account que Terraform utilisera
gcloud iam service-accounts create "hcp-terraform-runner" \
  --project="hubandspoke-demo" \
  --display-name="HCP Terraform runner"

# 5. Lui donner les droits nécessaires sur le projet (couvre VPC, subnets, firewall, routeur/NAT, peering, VM)
gcloud projects add-iam-policy-binding hubandspoke-demo \
  --member="serviceAccount:hcp-terraform-runner@hubandspoke-demo.iam.gserviceaccount.com" \
  --role="roles/compute.networkAdmin"

gcloud projects add-iam-policy-binding hubandspoke-demo \
  --member="serviceAccount:hcp-terraform-runner@hubandspoke-demo.iam.gserviceaccount.com" \
  --role="roles/compute.instanceAdmin.v1"

# roles/compute.networkAdmin exclut explicitement les firewalls, il faut ce rôle en plus
gcloud projects add-iam-policy-binding hubandspoke-demo \
  --member="serviceAccount:hcp-terraform-runner@hubandspoke-demo.iam.gserviceaccount.com" \
  --role="roles/compute.securityAdmin"

# 6. Autoriser le pool WIF à impersonate ce service account (uniquement depuis le workspace gcp-hub-and-spoke-network & compute)
gcloud iam service-accounts add-iam-policy-binding "hcp-terraform-runner@hubandspoke-demo.iam.gserviceaccount.com" \
  --project="hubandspoke-demo" \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/hcp-terraform-pool/attribute.terraform_workspace_name/gcp-hub-and-spoke-network"

gcloud iam service-accounts add-iam-policy-binding "hcp-terraform-runner@hubandspoke-demo.iam.gserviceaccount.com" \
  --project="hubandspoke-demo" \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/projects/123456789012/locations/global/workloadIdentityPools/hcp-terraform-pool/attribute.terraform_workspace_name/gcp-hub-and-spoke-compute"

Ensuite, dans les workspaces HCP Terraform (bernedotcom/gcp-hub-and-spoke-network(compute) → Settings → Variables), ajouter en variables d’environnement :

  • TFC_GCP_PROVIDER_AUTH = true
  • TFC_GCP_RUN_SERVICE_ACCOUNT_EMAIL = hcp-terraform-runner@hubandspoke-demo.iam.gserviceaccount.com
  • TFC_GCP_WORKLOAD_PROVIDER_NAME = projects/123456789012/locations/global/workloadIdentityPools/hcp-terraform-pool/providers/hcp-terraform-provider

Tour d’horizon de l’UI

Grâce à l’intégration VCS le plan est ensuite automatiquement déclenché lors d’un nouveau commit sur la branche cible (main dans mon cas).

Voici le résultat obtenu :

HCP Terraform Plan

Il est également possible d’obtenir la liste des derniers runs :

HCP Terraform last runs

Une fois un plan valide, il est alors possible de l’approuver manuellement pour application. Voici le résultat une fois l’apply lancé :

HCP Terraform run details

Vérification

Une fois la totalité des ressources créées il s’agit de vérifier le bon fonctionnement de l’architecture; pour cela rien de compliqué :

gcloud compute ssh spoke-dev-vm --zone=europe-west1-b --tunnel-through-iap

# from spoke-dev-vm
ping 10.0.0.2   # hub — should succeed
ping 10.2.0.2   # spoke-prod — should time out

Conclusion

Cette mise en place d’une architecture hub & spoke aura permis de mettre en application concrètement les notions vues tout au long de ma préparation à la certification Terraform Associate. Je suis également satisfait de cet essai d’HCP Terraform : la plateforme est agréable à utiliser et tout s’est déroulé sans difficulté, de la configuration du workspace jusqu’à la workload identity. On obtient une solution GitOps friendly clé en main, sans avoir à gérer soi-même un backend distant ni un pipeline CI/CD pour Terraform.

La version finale de l’implémentation est disponible ici.

Généré avec Hugo
Thème Stack conçu par Jimmy