Aller au contenu

Vérifiez vos déploiements Terraform avec des smoke tests intégrés grâce aux postconditions

Avec Terraform, intégrez des tests directement dans vos déploiements grâce aux fonctions check et postcondition. Validez en temps réel que tout fonctionne après un apply, et repérez instantanément si une erreur a mis votre application hors service.

Et si vous vérifiiez vos déploiements Terraform avec des smoke tests intégrés grâce aux postconditions ?

Terraform a introduit, il y a quelques versions les fonctions check et postcondition, qui permettent de tester son déploiement avec Terraform.

Lorsque l'on déploie des ressources avec Terraform, même si l'apply s'est bien déroulé, on n'est pas toujours à l'abri de faire une erreur de configuration, et notre application n'est plus disponible. Même si votre application est déjà supervisée, intégrer un test à la fin d’un terraform apply permet de savoir immédiatement si le déploiement a cassé quelque chose.

🚀 Déploiement de Cloud Run

Prenons un exemple simple avec une application déployée via Cloud Run à partir d’une image NGINX.

resource "google_cloud_run_v2_service" "deploy" {
  location            = local.region
  name                = "demo-check-service-ame"
  deletion_protection = false
  ingress             = "INGRESS_TRAFFIC_INTERNAL_LOAD_BALANCER"
  template {
    containers {
      image = "nginx:1.27"
      ports {
        container_port = 80
      }
      startup_probe {
        initial_delay_seconds = 0
        timeout_seconds       = 1
        period_seconds        = 3
        failure_threshold     = 1
        tcp_socket {
          port = 80
        }
      }
      liveness_probe {
        http_get {
          path = "/"
        }
      }
    }
  }
}

J'ai également besoin de quelques ressources pour exposer publiquement Cloud Run derrière un load-balancer et un certificat :

locals {
  target_domain = "run.demo.ameausoone.tech"
}

# l'instance Cloud Run est accessible publiquement
resource "google_cloud_run_service_iam_member" "public-access" {
  location = google_cloud_run_v2_service.deploy.location
  project  = google_cloud_run_v2_service.deploy.project
  service  = google_cloud_run_v2_service.deploy.name
  role     = "roles/run.invoker"
  member   = "allUsers"
}

# Création d'un Network Endpoint Group 
resource "google_compute_region_network_endpoint_group" "serverless_neg" {
  name                  = "serverless-neg"
  network_endpoint_type = "SERVERLESS"
  region                = local.region
  cloud_run {
    service = google_cloud_run_v2_service.deploy.name
  }
}

# J'appelle le module terraform pour créer mon load-balancer
module "lb-http" {
  source                          = "terraform-google-modules/lb-http/google//modules/serverless_negs"
  version                         = "~> 12.0"
  project                         = local.gcp_project_id
  name                            = "group-http-lb"
  ssl                             = true
  managed_ssl_certificate_domains = ["run.demo.ameausoone.tech"]
  https_redirect                  = true
  labels                          = { "app" = "cloud-run-example" }

  backends = {
    default = {
      groups = [
        {
          group = google_compute_region_network_endpoint_group.serverless_neg.id
        }
      ]
      enable_cdn = false
      iap_config = {
        enable = false
      }
      log_config = {
        enable = false
      }
    }
  }
}

data "google_dns_managed_zone" "dns_zone" {
  name = "demo-ameausoone-tech"
}

# Et l'enregistrement DNS pour matcher le domaine "run.demo.ameausoone.tech" avec l'ip publique de mon load-balancer.
resource "google_dns_record_set" "record" {
  managed_zone = data.google_dns_managed_zone.dns_zone.name
  name         = "${local.target_domain}."
  type         = "A"
  ttl          = 300
  rrdatas      = [module.lb-http.external_ip]
}

🆕 Premier test

À ce stade, mon application est déployée et accessible :

Et nous pouvons intégrer un premier test du service pour vérifier s'il est bien disponible avec un check http sur le service :

data "http" "service" {
  url        = "https://${local.target_domain}"
  depends_on = [module.lb-http]
  lifecycle {
    postcondition {
      condition     = self.status_code == 200
      error_message = "Cloud Run service not ready"
    }
  }
  retry {
    attempts     = 4
    min_delay_ms = 10000
  }
}

Au premier déploiement, il y a cette erreur :

╷
│ Error: Error making request
│ 
│   with data.http.service,
│   on gcp-check.tf line 15, in data "http" "service":
│   15: data "http" "service" {
│ 
│ Error making request: GET https://run.demo.ameausoone.tech giving up after 5 attempt(s): Get "https://run.demo.ameausoone.tech": remote error: tls: handshake failure
╵

C'est tout à fait normal, puisqu'il faut un certain temps au load-balancer pour générer le certificat public. En relançant terraform apply quelques minutes plus tard :

data.http.service: Reading...
data.http.service: Read complete after 1s [id=https://run.demo.ameausoone.tech]

Le certificat managé a été généré et déployé sur le load-balancer. Tout se déroule correctement.

Le problème est que ce test est un peu basique, il y a quelques options pour s'authentifier sur un service, mais les possibilités de datasource http restent limitées, sans compter que ce test s'effectue directement depuis la machine qui exécute Terraform et pourrait être bloqué par le réseau.


Comment tester cette URL à l'aide d'un service plus avancé ?

🏁 Uptime checks de Google

Parmi les services proposés dans Cloud Monitoring de Google, on trouve les Uptime checks : ce service vérifie la disponibilité d’un endpoint HTTP, TCP ou gRPC à intervalles réguliers depuis plusieurs régions du monde.

Uptime checks permet également de surveiller autant des URLs publiques que privées, de vérifier le code retour, d'envoyer des requêtes POST, de vérifier le contenu de la page retournée, ou de vérifier la validité de mon service http.

Donc mettons en place un Uptime check sur l'instance Cloud Run. Vérifions que le service est up, et que la page renvoyée contient bien nginx.

resource "google_monitoring_uptime_check_config" "website_check" {
  display_name = "check-run-demo-ameausoone-tech"
  timeout      = "5s"
  period       = "60s"
  selected_regions = [
    "EUROPE",
    "ASIA_PACIFIC",
    "USA_VIRGINIA",
  ]
  content_matchers {
    content = "nginx"
    matcher = "CONTAINS_STRING"
  }
  http_check {
    path         = "/"
    port         = "443"
    use_ssl      = true
    validate_ssl = true
    accepted_response_status_codes {
      status_class = "STATUS_CLASS_2XX"
    }
  }
  monitored_resource {
    type = "uptime_url"
    labels = {
      host = local.target_domain
    }
  }
  depends_on = [google_cloud_run_v2_service.deploy]
}

💨 Smoke test

C'est maintenant que le code devient un peu plus complexe : Terraform n'est pas conçu pour effectuer du monitoring, mais pour appliquer une configuration. Il n'existe pas de datasource pour interroger l'état d'un service. Nous allons donc devoir contourner cette limitation en utilisant la datasource HTTP.

Nous allons utiliser la datasource HTTP pour interroger l'API Cloud Monitoring, récupérer l'état d'un Uptime check et vérifier si le service est disponible. Pour cela, nous devrons extraire les métriques d'Uptime check et les interpréter dans Terraform.

Dernier point : un délai est nécessaire entre le déploiement de l'instance Cloud Run et la disponibilité des métriques d'Uptime check. Il faut donc ajouter un délai d'attente entre la création ou la modification de Cloud Run, la configuration de l'Uptime check et la récupération des métriques.

# Création d'un sleep de 4 minutes le temps de récupérer les métriques.
resource "time_sleep" "wait" {
  create_duration = "240s"
  lifecycle {
    # J'indique ici à Terraform de re-déclencher le wait s'il y a une modification sur cloud run ou sur l'uptime check.
    replace_triggered_by = [google_monitoring_uptime_check_config.website_check, google_cloud_run_v2_service.deploy]
  }
}

Interrogeons l'API Cloud Monitoring à l'aide de PromQL pour récupérer les métriques d'Uptime check :

locals {
  offset_duration        = "1m"
}

data "http" "prometheus_query_data" {
  depends_on = [time_sleep.wait]
  url        = "https://monitoring.googleapis.com/v1/projects/${local.gcp_project_id}/location/global/prometheus/api/v1/query_range?alt=json"
  method     = "POST"

  request_headers = {
    Accept        = "*/*"
    Authorization = "Bearer ${data.google_client_config.default.access_token}"
    Content-Type  = "application/json"
  }
  
  # Utilisation de PromQL pour récupérer les métriques avec un offset de 1 minute
  request_body = jsonencode({
    query = "monitoring_googleapis_com:uptime_check_check_passed{monitored_resource=\"uptime_url\",check_id=\"${google_monitoring_uptime_check_config.website_check.uptime_check_id}\"}",
    start = timeadd(timestamp(), "-${local.offset_duration}"),
    end   = timestamp(),
    step  = "60s"
  })
  # Accessoirement, je vérifie que ma requête s'est bien déroulée.
  lifecycle {
    postcondition {
      condition     = self.status_code == 200
      error_message = "Failed to query Prometheus API"
    }
  }
}

Le résultat de notre requête liste toutes les métriques de nos tests réalisées sur la dernière minute.

    response_body        = jsonencode(
        {
            data   = {
                result     = [
                    {
                        metric = {
                            __name__            = "monitoring_googleapis_com:uptime_check_check_passed"
                            check_id            = "check-run-demo-ameausoone-tech-lNAtAZO97aY"
                            checked_resource_id = "run.demo.ameausoone.tech"
                            checker_location    = "apac-singapore"
                            host                = "run.demo.ameausoone.tech"
                            monitored_resource  = "uptime_url"
                            project_id          = "************"
                        }
                        values = [
                            [
                                1745399715,
                                "1",
                            ],
                            [
                                1745399775,
                                "1",
                            ],
                        ]
                    },
[...]

🤔 Interprétation du résultat

Maintenant, interprétons ces résultats avec Terraform dans des locals :

locals {
   # Nous parssons le résultat en JSON et récupérons l'état de chaque mesure
  prometheus_values_flattened = flatten([
    for series in jsondecode(data.http.prometheus_query_data.response_body).data.result : [
      for val in series.values : val[1]
    ]
  ])
  
  # 0 = failed, 1 = succeeded 
  uptime_succeeded = "1"

   # Nous vérifions ici que tous les contrôles sont au vert
  all_values_are_one = alltrue([
    for v in local.prometheus_values_flattened : v == local.uptime_succeeded
  ])
}

🧪 Dernière étape: checker ces résultats avec une postcondition

Via une null_resource, vérifions que tous les checks sont ok:

resource "null_resource" "check_all_check_passed" {
  depends_on = [data.http.prometheus_query_data]
  provisioner "local-exec" {
    command = "echo 'All uptime checks passed: ${local.all_values_are_one}'"
  }
  lifecycle {
    postcondition {
      condition     = local.all_values_are_one
      error_message = "Some recent uptime checks failed for service https://${local.target_domain}, check the results."
    }
  }
}

Voyons maintenant ce qui se passe si nous modifions la surveillance pour provoquer une erreur. Nous allons configurer la recherche d'une chaîne absente de la page.

Terraform plan indique une modification de l'uptime check

  # google_monitoring_uptime_check_config.website_check will be updated in-place
  ~ resource "google_monitoring_uptime_check_config" "website_check" {
        id               = "projects/**********/uptimeCheckConfigs/check-run-demo-ameausoone-tech-lNAtAZO97aY"
        name             = "projects/**********/uptimeCheckConfigs/check-run-demo-ameausoone-tech-lNAtAZO97aY"
        # (8 unchanged attributes hidden)

      ~ content_matchers {
          ~ content = "nginx" -> "unknown"
            # (1 unchanged attribute hidden)
        }

        # (2 unchanged blocks hidden)
    }

Puis la ressource time_sleep.wait va être recréée

  # time_sleep.wait will be replaced due to changes in replace_triggered_by
-/+ resource "time_sleep" "wait" {
      ~ id              = "2025-04-23T09:15:12Z" -> (known after apply)
        # (1 unchanged attribute hidden)
    }
[...]
time_sleep.wait: Creating...
time_sleep.wait: Still creating... [10s elapsed]
[...]
time_sleep.wait: Creation complete after 3m0s [id=2025-04-23T12:50:13Z]

La datasource cloudmonitoring est ensuite évaluée et checkée avec la postcondition :

data.http.prometheus_query_data: Reading...
data.http.prometheus_query_data: Read complete after 1s [id=https://monitoring.googleapis.com/v1/projects/sfeirtraining-sandbox/location/global/prometheus/api/v1/query_range?alt=json]
╷
│ Error: Resource postcondition failed
│ 
│   on gcp-check.tf line 116, in resource "null_resource" "check_all_check_passed":
│  116:       condition     = local.all_values_are_one
│     ├────────────────
│     │ local.all_values_are_one is false
│ 
│ Some recent uptime checks failed for service https://run.demo.ameausoone.tech, check the results.

Donc l'apply Terraform a bien vérifié que mon service était ko.

Avec le chemin inverse: fixons Uptime check, et relançons un terraform apply:

data.http.prometheus_query_data: Reading...
data.http.prometheus_query_data: Read complete after 0s [id=https://monitoring.googleapis.com/v1/projects/sfeirtraining-sandbox/location/global/prometheus/api/v1/query_range?alt=json]

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

terraform apply s'est bien déroulé, et le service est up !

✅ Conclusion

Ce pattern complexifie significativement le code et s'écarte quelque peu de l'usage habituel de Terraform ; il est donc à utiliser avec parcimonie. Cependant, pour des projets Terraform sensibles, il peut être pertinent de vérifier que le service répond toujours correctement.

Il ne faut pas abuser de ce pattern, mais vérifier la partie 'visible de l'iceberg' du service permet de détecter plus rapidement une erreur de configuration et de corriger le problème sans délai. Ce mécanisme de test intégré contribue à fiabiliser vos pipelines Terraform, tout en conservant un cycle court entre déploiement et vérification. C’est simple, efficace, et particulièrement utile dans une approche GitOps.

La fonction check de Terraform permet une surveillance similaire, mais de manière continue lorsqu'elle est exécutée dans Terraform Cloud.

🧩 Pour aller plus loin

Dernier