Aller au contenu
Signalements sur Scaleway, en code

Signalements sur Scaleway, en code

200 Pratiquer ⏱ 1 h 30 terraformopentofuscalewayiachcl

À la fin, vous saurez

  • Organiser une configuration réelle en fichiers par domaine, avec ses variables et ses sorties
  • Traduire une architecture construite à la main en ressources du fournisseur Scaleway, en vérifiant chaque argument dans sa documentation
  • Réserver des adresses privées dans IPAM pour les connaître avant de créer les instances
  • Garder le mot de passe de la base hors de l'état avec une ressource éphémère et un argument en écriture seule
  • Lire le graphe de dépendances et en déduire l'ordre de création
  • Décliner la même configuration en préproduction et en production par des fichiers de variables

Prérequis

Testé avec random-provider 3.9.1 scaleway-provider 2.84.0 terraform 1.16.1 , vérifié le 5 octobre 2026

Pourquoi

Faites le compte. Dans le cours Le cloud : les fondamentaux, l'architecture de Signalements a demandé une cinquantaine de commandes scw, réparties sur quatre leçons, avec des identifiants à recopier d'une commande à l'autre (VPC_ID, PN_ID, NIC1, IP1...), des retouches à la main sur les instances, et un ordre à respecter scrupuleusement : la leçon 6 insistait sur la séquence « ajouter le chemin privé, basculer, vérifier, retirer le public ». Le résultat fonctionne. Mais posez trois questions à l'équipe qui l'a construit :

  • Pouvez-vous la recréer à l'identique demain, dans une autre région, pour un nouveau client ? Il faudrait rejouer les quatre leçons, sans se tromper d'ordre ni d'identifiant.
  • Qu'est-ce qui a changé depuis sa construction ? Personne ne le sait : une règle ajoutée en urgence au groupe de sécurité, un type d'instance changé dans la console, rien ne le trace.
  • La préproduction ressemble-t-elle à la production ? Elle a été construite à un autre moment, par une autre personne, à partir des mêmes leçons. Elle lui ressemble, probablement.

Cette leçon décrit toute cette architecture en code. Les leçons précédentes ont posé chaque pièce du langage et de l'outil ; il s'agit maintenant de les assembler sur un cas réel, avec les décisions qu'un cas réel impose : comment découper les fichiers, comment connaître l'adresse privée d'une instance avant qu'elle existe, comment créer un mot de passe de base de données sans l'écrire dans l'état, comment décliner deux environnements sans dupliquer le code.

Les concepts

De l'impératif au déclaratif, ressource par ressource

Chaque commande du cours cloud devient une ressource du fournisseur scaleway/scaleway. La correspondance n'est pas toujours un pour un, et les écarts sont instructifs :

Construit à la main (cours cloud)Décrit en codeCe qui change
scw vpc vpc createscaleway_vpcrien
scw vpc private-network create ... subnets.0=172.16.20.0/22scaleway_vpc_private_network avec un bloc ipv4_subnetrien
scw instance private-nic create, puis lecture de l'adresse dans IPAMscaleway_ipam_ip réservée d'avance, puis scaleway_instance_private_nic avec ipam_ip_idsl'adresse est connue avant l'instance
scw vpc-gw gateway create ... enable-bastion=true, adresses autorisées réglées dans la consolescaleway_vpc_public_gateway avec bastion_enabled et allowed_ip_rangesla restriction du bastion, impossible avec la CLI 2.62, s'écrit dans le code
scw instance server create, puis detach-ip et ip deletescaleway_instance_server sans argument d'adresse publiquel'instance n'a jamais d'adresse publique
scw rdb instance create, mot de passe généré par la CLI, puis ACL, puis point d'accès privé, puis suppression du publicscaleway_rdb_instance avec un bloc private_network et sans bloc load_balancerjamais de point d'accès public, mot de passe hors de l'état
scw lb lb create, backend create, frontend createscaleway_lb_ip, scaleway_lb, scaleway_lb_backend, scaleway_lb_frontendrien

Les deux dernières lignes montrent un avantage peu visible du déclaratif : on ne décrit pas un chemin (créer avec un accès public, puis le retirer), on décrit un état final, et l'état final n'a jamais eu d'accès public. Les étapes intermédiaires risquées disparaissent.

Où trouver les noms exacts

Le nom de chaque ressource et de chaque argument vient de la documentation du fournisseur, publiée sur le registre de Terraform et, à l'identique, dans le répertoire docs/resources du dépôt scaleway/terraform-provider-scaleway. Elle fait foi pour la version qu'elle décrit ; elle ne dit pas tout. Deux réflexes complètent la lecture :

  • terraform providers schema -json affiche le schéma du fournisseur réellement installé : chaque argument, son type, s'il est obligatoire, calculé, sensible, en écriture seule ou déprécié. C'est ce schéma que terraform validate applique. Toute la configuration de cette leçon a été validée contre le schéma du fournisseur 2.84.0.
  • Le code source du fournisseur, quand le comportement compte. On y découvre par exemple, plus bas dans cette leçon, dans quel ordre le fournisseur démarre une instance et lui attache ses réseaux privés, ce qu'aucune page de documentation ne dit.

Des valeurs connues avant l'apply

Le cours cloud a buté sur un problème d'ordre : l'unité systemd de l'instance devait connaître l'adresse privée de l'instance, que l'on ne découvrait qu'après avoir créé son interface privée. Il avait fallu corriger l'unité à la main, en notant que « la version durable consiste à écrire la configuration avec l'outil qui crée l'instance et connaît donc son adresse ».

Terraform résout ce genre de problème en inversant la dépendance : plutôt que d'attendre qu'IPAM attribue une adresse à l'interface, on réserve l'adresse dans IPAM (scaleway_ipam_ip avec un argument address), puis on demande à l'interface d'utiliser cette réservation. L'adresse devient une donnée de la configuration, connue dès le plan, utilisable par le répartiteur de charge sans attendre que les instances existent.

Les secrets qui ne doivent pas toucher l'état

La leçon 6 a montré que l'état contient la valeur de tous les attributs, y compris ceux marqués sensibles. Un mot de passe de base de données passé par l'argument password de scaleway_rdb_instance finirait donc en clair dans l'état, et dans chaque plan enregistré.

Terraform 1.10 a introduit les ressources éphémères (ephemeral resources), dont la valeur n'existe que le temps d'une exécution, et Terraform 1.11 les arguments en écriture seule (write-only arguments), que le fournisseur reçoit mais que Terraform ne conserve ni dans le plan ni dans l'état. La documentation de Terraform le dit simplement : le fournisseur utilise la valeur, puis Terraform « la jette sans la stocker dans le plan ou l'état ». Le fournisseur Scaleway expose password_wo sur scaleway_rdb_instance, accompagné de password_wo_version : comme Terraform ne garde pas la valeur, il ne peut pas savoir si elle a changé, et c'est l'incrément de la version qui déclenche l'envoi d'un nouveau mot de passe. OpenTofu a suivi Terraform sur ce terrain ; vérifiez dans sa documentation la version minimale qui accepte ces constructions avant de les utiliser avec tofu.

Un mot de passe que personne ne connaît ne sert à rien : l'application doit pouvoir le lire. On le range donc, dans la même exécution, dans Secret Manager, par un autre argument en écriture seule (data_wo de scaleway_secret_version). La leçon 8 de Scaleway en pratique montre comment l'instance le lit au démarrage.

En pratique

L'organisation du dépôt

Le dépôt signalements-iac contient une configuration racine, découpée par domaine, et un fichier de variables par environnement :

signalements-iac/
├── versions.tf               versions de Terraform et des fournisseurs
├── backend.tf                backend s3, configuration partielle (leçon 7)
├── backend-preprod.hcl       bucket et clé d'état de la préproduction
├── backend-prod.hcl          bucket et clé d'état de la production
├── providers.tf              configuration du fournisseur Scaleway
├── variables.tf              entrées de la configuration
├── locals.tf                 valeurs dérivées et tableau des instances
├── reseau.tf                 VPC, réseau privé, adresses, passerelle, bastion
├── calcul.tf                 groupes de sécurité, instances, interfaces privées
├── cloud-init.yaml.tftpl     modèle de configuration des instances
├── base.tf                   base PostgreSQL, mot de passe, secret
├── repartiteur.tf            répartiteur de charge
├── stockage.tf               bucket des pièces jointes
├── sorties.tf                valeurs utiles après l'apply
├── environnements/
│   ├── preprod.tfvars
│   └── prod.tfvars
└── .terraform.lock.hcl       versionné (leçon 4)

Le découpage par domaine n'a aucune signification pour Terraform, qui lit tous les fichiers .tf du répertoire comme un seul document ; il en a une pour les personnes : une modification du répartiteur touche repartiteur.tf, et la revue sait où regarder. Une configuration à plusieurs environnements peut aussi s'organiser en un répertoire par environnement avec des modules communs ; c'est le sujet du cours Terraform avancé : modules, tests, état. Ici, un seul répertoire et un fichier de variables par environnement suffisent, à condition que chaque environnement ait son propre état (leçon 7).

Versions et fournisseur

terraform {
  # password_wo (écriture seule) exige Terraform 1.11 ; vérifiez la version
  # minimale d'OpenTofu dans sa documentation avant de l'utiliser.
  required_version = ">= 1.11"

  required_providers {
    scaleway = {
      source  = "scaleway/scaleway"
      version = "~> 2.84"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.7"
    }
  }
}
# Les identifiants viennent de l'environnement (SCW_ACCESS_KEY, SCW_SECRET_KEY)
# ou du fichier de configuration de la CLI scw : jamais de ce dépôt.
provider "scaleway" {
  project_id = var.project_id
  region     = "fr-par"
  zone       = "fr-par-1"
}

Le fournisseur Scaleway cherche ses identifiants, dans cet ordre de priorité, dans les variables d'environnement SCW_ACCESS_KEY et SCW_SECRET_KEY, dans les arguments du bloc provider, puis dans le fichier de configuration de la CLI scw (~/.config/scw/config.yaml, avec ses profils). Le bloc ci-dessus n'en contient donc aucun : sur un poste, la configuration de la CLI du premier cours cloud suffit ; en intégration continue, des variables d'environnement (leçon 12). Le projet, lui, est explicite : un environnement, un projet, et l'on ne veut surtout pas qu'une valeur par défaut oubliée dans un profil envoie la production dans le mauvais projet.

Variables et valeurs locales

variable "project_id" {
  description = "Identifiant du projet Scaleway de l'environnement."
  type        = string
}

variable "environnement" {
  description = "Nom court de l'environnement, repris dans les noms et les étiquettes."
  type        = string

  validation {
    condition     = contains(["preprod", "prod"], var.environnement)
    error_message = "L'environnement doit valoir preprod ou prod."
  }
}

variable "version_signalements" {
  description = "Étiquette de l'image ghcr.io/lyneko-formation/signalements à déployer."
  type        = string
  default     = "1.2.0"
}

variable "type_instance" {
  description = "Type commercial des instances applicatives."
  type        = string
  default     = "PRO2-XXS"
}

variable "moteur_base" {
  description = "Moteur et version majeure de la base, tels que listés par scw rdb engine list."
  type        = string
  default     = "PostgreSQL-17"
}

variable "type_base" {
  description = "Type de nœud de la base PostgreSQL managée."
  type        = string
  default     = "DB-DEV-S"
}

variable "cle_ssh_publique" {
  description = "Clé SSH publique de l'équipe, déclarée dans le projet (bastion et instances)."
  type        = string
}

variable "plages_bastion" {
  description = "Adresses autorisées à joindre le bastion SSH, en notation CIDR."
  type        = list(string)
}

variable "suffixe_bucket" {
  description = "Suffixe qui rend le nom du bucket unique chez Scaleway."
  type        = string
}

variable "version_mot_de_passe_base" {
  description = "À incrémenter pour faire changer le mot de passe de la base (password_wo)."
  type        = number
  default     = 1
}
locals {
  etiquettes = ["app=signalements", "env=${var.environnement}", "gere-par=terraform"]

  # Une instance par zone, avec son adresse privée réservée dans IPAM.
  instances = {
    "sig-app-1" = { zone = "fr-par-1", adresse = "172.16.20.11" }
    "sig-app-2" = { zone = "fr-par-2", adresse = "172.16.20.12" }
  }

  zones = toset([for i in local.instances : i.zone])
}

Le tableau local.instances porte le seul vrai choix d'architecture de la partie calcul : une instance par zone, avec son adresse privée. Ajouter une troisième instance en fr-par-3 consiste à ajouter une ligne, et for_each (leçon 9) fait le reste. Les adresses 172.16.20.11 et 172.16.20.12, et 172.16.20.5 pour le répartiteur, sont celles du cours Le modèle TCP/IP ; elles évitent le début du bloc, que Scaleway réserve.

Le réseau

resource "scaleway_vpc" "principal" {
  name = "vpc-signalements"
  tags = local.etiquettes
}

resource "scaleway_vpc_private_network" "app" {
  name   = "pn-signalements"
  vpc_id = scaleway_vpc.principal.id
  tags   = local.etiquettes

  ipv4_subnet {
    subnet = "172.16.20.0/22"
  }
}

# Adresses privées réservées à l'avance : connues avant la création des
# instances, elles peuvent figurer dans le répartiteur de charge.
resource "scaleway_ipam_ip" "app" {
  for_each = local.instances

  address = each.value.adresse
  tags    = local.etiquettes

  source {
    private_network_id = scaleway_vpc_private_network.app.id
  }
}

resource "scaleway_ipam_ip" "lb" {
  address = "172.16.20.5"
  tags    = local.etiquettes

  source {
    private_network_id = scaleway_vpc_private_network.app.id
  }
}

resource "scaleway_iam_ssh_key" "equipe" {
  name       = "equipe-signalements"
  public_key = var.cle_ssh_publique
}

resource "scaleway_vpc_public_gateway_ip" "passerelle" {
  tags = local.etiquettes
}

resource "scaleway_vpc_public_gateway" "passerelle" {
  name              = "gw-signalements"
  type              = "VPC-GW-S"
  ip_id             = scaleway_vpc_public_gateway_ip.passerelle.id
  bastion_enabled   = true
  bastion_port      = 61000
  allowed_ip_ranges = var.plages_bastion
  tags              = local.etiquettes

  # Le bastion importe les clés du projet à son activation.
  depends_on = [scaleway_iam_ssh_key.equipe]
}

resource "scaleway_vpc_gateway_network" "app" {
  gateway_id         = scaleway_vpc_public_gateway.passerelle.id
  private_network_id = scaleway_vpc_private_network.app.id
  enable_masquerade  = true

  ipam_config {
    push_default_route = true
  }
}

Quelques lignes méritent une explication :

  • scaleway_ipam_ip.app réserve une adresse par instance. La documentation de la ressource montre cette forme exacte, une address et un bloc source qui désigne le réseau privé.
  • allowed_ip_ranges fixe la liste des adresses autorisées à joindre le bastion. C'est la restriction que le cours cloud faisait faire dans la console, faute de commande dans la CLI 2.62 ; le fournisseur Terraform l'expose, et la documentation précise qu'elle remplace la liste (« a definitive list »).
  • depends_on = [scaleway_iam_ssh_key.equipe] : le cours cloud rappelait que le bastion importe les clés du projet au moment de son activation. Terraform ne peut pas deviner ce lien, qui ne passe par aucune référence ; on le déclare (leçon 8).
  • ipam_config { push_default_route = true } : la passerelle annonce la route par défaut sur le réseau privé, comme l'option push-default-route=true de la CLI.

Le calcul

# Un groupe de sécurité par zone : la ressource est zonale.
resource "scaleway_instance_security_group" "app" {
  for_each = local.zones

  name                    = "sg-sig-app"
  zone                    = each.value
  stateful                = true
  inbound_default_policy  = "drop"
  outbound_default_policy = "accept"
  tags                    = local.etiquettes
}

resource "scaleway_instance_server" "app" {
  for_each = local.instances

  name              = each.key
  zone              = each.value.zone
  type              = var.type_instance
  image             = "ubuntu_noble"
  security_group_id = scaleway_instance_security_group.app[each.value.zone].id
  tags              = local.etiquettes

  # Aucune adresse publique : ni ip_id, ni ip_ids, ni enable_dynamic_ip.
  cloud_init = templatefile("${path.module}/cloud-init.yaml.tftpl", {
    version_signalements = var.version_signalements
  })

  # La sortie vers Internet passe par la passerelle : elle doit exister avant.
  depends_on = [scaleway_vpc_gateway_network.app]
}

resource "scaleway_instance_private_nic" "app" {
  for_each = local.instances

  server_id          = scaleway_instance_server.app[each.key].id
  zone               = each.value.zone
  private_network_id = scaleway_vpc_private_network.app.id
  ipam_ip_ids        = [scaleway_ipam_ip.app[each.key].id]
  tags               = local.etiquettes
}

Le groupe de sécurité est zonal : il en faut un par zone, d'où le for_each sur local.zones. L'instance n'a ni ip_id, ni ip_ids, ni enable_dynamic_ip : la documentation du fournisseur indique que enable_dynamic_ip vaut false par défaut, et sans adresse réservée, l'instance n'a donc aucune adresse publique.

Warning

L'instance démarre avant d'avoir son réseau privé. Le code de création d'une instance dans le fournisseur (internal/services/instance/server.go, version 2.84) crée le serveur, dépose les données utilisateur, l'amène à l'état demandé (démarré par défaut), et seulement ensuite attache les réseaux privés. Pour une instance sans adresse publique, cela veut dire que cloud-init s'exécute au premier démarrage sans aucune route vers Internet : un module packages ou package_update échoue, et l'instance reste sans Docker. Il en va de même avec le bloc private_network de l'instance et avec une ressource scaleway_instance_private_nic séparée. Le modèle ci-dessous attend donc le réseau avant d'installer quoi que ce soit.

Le modèle de cloud-init reprend celui de la leçon 4 du cours cloud, avec trois différences :

#cloud-config
# Instance applicative Signalements, sans adresse publique.
# L'interface privée, et donc la route vers Internet par la passerelle,
# arrive après le premier démarrage : l'installation attend le réseau.

write_files:
  - path: /etc/signalements/env
    permissions: "0640"
    content: |
      APP_VERSION=${version_signalements}
  - path: /etc/systemd/system/signalements.service
    permissions: "0644"
    content: |
      [Unit]
      Description=API Signalements (conteneur)
      Requires=docker.service
      After=docker.service network-online.target
      Wants=network-online.target

      [Service]
      ExecStartPre=-/usr/bin/docker rm -f signalements
      ExecStart=/usr/bin/docker run --rm --name signalements \
        --env-file /etc/signalements/env \
        -p 8000:8000 \
        ghcr.io/lyneko-formation/signalements:${version_signalements}
      ExecStop=/usr/bin/docker stop signalements
      Restart=always
      RestartSec=5

      [Install]
      WantedBy=multi-user.target
  - path: /usr/local/sbin/attendre-le-reseau
    permissions: "0755"
    content: |
      #!/bin/sh
      # Attend une route par défaut et un accès au dépôt Ubuntu (dix minutes au plus).
      for i in $(seq 1 120); do
        ip -4 route show default | grep -q . && \
          apt-get update -qq && exit 0
        sleep 5
      done
      exit 1

runcmd:
  - [/usr/local/sbin/attendre-le-reseau]
  - [sh, -c, "DEBIAN_FRONTEND=noninteractive apt-get install -y -qq docker.io"]
  - [systemctl, daemon-reload]
  - [systemctl, enable, --now, signalements.service]
  • La version de l'image est une variable du modèle, ${version_signalements}, remplacée par templatefile au moment du plan. Changer de version, c'est changer une variable, et le plan montre que les données utilisateur changent.
  • L'installation passe par runcmd et commence par un petit script qui attend une route par défaut et un apt-get update réussi, dix minutes au plus. Les modules package_update et packages, exécutés plus tôt par cloud-init, auraient échoué faute de réseau.
  • Le port est publié sur toutes les interfaces (-p 8000:8000), et non plus sur 127.0.0.1. L'instance n'a pas d'interface publique : toutes ses interfaces sont dans le réseau privé, et c'est exactement là que le répartiteur doit la joindre. Si quelqu'un attachait un jour une adresse publique « pour déboguer », le groupe de sécurité (drop en entrée) la protégerait, puisqu'il filtre l'interface publique en dehors de l'instance.

Deux précautions de syntaxe dans les modèles : templatefile interprète ${...} comme une interpolation Terraform ; une variable du shell écrite ${VAR} dans le modèle devrait donc s'écrire $${VAR}. Le script ci-dessus utilise $(seq 1 120), une substitution de commande, que templatefile laisse intacte. Enfin, templatefile échoue si le modèle référence une variable qu'on ne lui a pas passée : une faute de frappe se voit au plan, pas au démarrage de l'instance.

Comme au cours cloud, la DATABASE_URL n'est pas dans les données utilisateur : elle contient un mot de passe, et les données utilisateur restent lisibles pendant toute la vie de l'instance.

La base de données

# Mot de passe éphémère : jamais écrit dans l'état ni dans le plan.
ephemeral "random_password" "base" {
  length      = 32
  special     = true
  min_upper   = 1
  min_lower   = 1
  min_numeric = 1
  min_special = 1
}

resource "scaleway_rdb_instance" "principale" {
  name          = "sig-db"
  node_type     = var.type_base
  engine        = var.moteur_base
  is_ha_cluster = var.environnement == "prod"
  user_name     = "signalements"
  tags          = local.etiquettes

  password_wo         = ephemeral.random_password.base.result
  password_wo_version = var.version_mot_de_passe_base

  backup_schedule_frequency = 24
  backup_schedule_retention = 7

  # Un point d'accès privé et aucun bloc load_balancer : pas de point d'accès public.
  private_network {
    pn_id       = scaleway_vpc_private_network.app.id
    enable_ipam = true
  }
}

# Le même mot de passe éphémère, rangé dans Secret Manager pour l'application.
resource "scaleway_secret" "base" {
  name      = "db"
  path      = "/signalements/${var.environnement}"
  type      = "key_value"
  protected = true
  tags      = local.etiquettes
}

resource "scaleway_secret_version" "base" {
  secret_id = scaleway_secret.base.id

  data_wo = jsonencode({
    utilisateur  = "signalements"
    mot_de_passe = ephemeral.random_password.base.result
    hote         = scaleway_rdb_instance.principale.private_network[0].ip
    port         = tostring(scaleway_rdb_instance.principale.private_network[0].port)
  })
  data_wo_version = var.version_mot_de_passe_base
}
  • ephemeral "random_password" est une ressource éphémère du fournisseur hashicorp/random (le schéma de la version 3.9.1 expose random_password et random_bytes comme ressources éphémères). Sa valeur est produite à chaque exécution et n'est jamais enregistrée.
  • password_wo et password_wo_version : à la première création, le fournisseur reçoit le mot de passe. Aux exécutions suivantes, une nouvelle valeur éphémère est produite, mais tant que password_wo_version ne change pas, elle n'est pas envoyée. Pour changer le mot de passe, on incrémente version_mot_de_passe_base.
  • Le bloc private_network sans bloc load_balancer : la documentation de la ressource l'explique, un point d'accès public est créé par défaut si aucun réseau privé n'est donné, et le bloc load_balancer doit être déclaré si l'on veut un point d'accès public en plus du privé. Ici, la base n'a jamais d'adresse publique ; l'ACL ouverte à 0.0.0.0/0 qui inquiétait la leçon 2 du cours cloud ne concerne plus rien.
  • is_ha_cluster = var.environnement == "prod" : la haute disponibilité en production seulement. La documentation prévient qu'un changement de cette valeur recrée l'instance de base : c'est une décision à prendre avant la première création.
  • Le secret reçoit, dans la même exécution, le même mot de passe éphémère et l'adresse privée de la base, sérialisés en JSON. Son data_wo_version suit celle du mot de passe : les deux changent ensemble.

Le répartiteur et le stockage

resource "scaleway_lb_ip" "public" {
  zone = "fr-par-1"
  tags = local.etiquettes
}

resource "scaleway_lb" "principal" {
  name   = "lb-signalements"
  type   = "LB-S"
  zone   = "fr-par-1"
  ip_ids = [scaleway_lb_ip.public.id]
  tags   = local.etiquettes

  private_network {
    private_network_id = scaleway_vpc_private_network.app.id
    ipam_ids           = [scaleway_ipam_ip.lb.id]
  }
}

resource "scaleway_lb_backend" "app" {
  lb_id                  = scaleway_lb.principal.id
  name                   = "sig-app"
  forward_protocol       = "http"
  forward_port           = 8000
  forward_port_algorithm = "roundrobin"
  server_ips             = [for i in local.instances : i.adresse]

  health_check_port        = 8000
  health_check_delay       = "5s"
  health_check_timeout     = "2s"
  health_check_max_retries = 3

  health_check_http {
    uri    = "/sante"
    method = "GET"
    code   = 200
  }
}

resource "scaleway_lb_frontend" "http" {
  lb_id        = scaleway_lb.principal.id
  backend_id   = scaleway_lb_backend.app.id
  name         = "http"
  inbound_port = 80
}
resource "scaleway_object_bucket" "pieces_jointes" {
  name = "signalements-pj-${var.suffixe_bucket}"
  tags = { app = "signalements", env = var.environnement }

  versioning {
    enabled = true
  }

  lifecycle_rule {
    id      = "anciennes-versions"
    enabled = true

    noncurrent_version_expiration {
      noncurrent_days = 30
    }
  }
}

Le backend vise les adresses réservées, tirées du tableau local, sans attendre que les instances existent : le répartiteur peut être créé en parallèle des instances, et ses vérifications de santé les marqueront en service quand cloud-init aura fini. Le bucket active le versionnement et fait expirer les anciennes versions au bout de trente jours, comme le recommandait la leçon 5 du cours cloud.

Les sorties et les environnements

output "adresse_publique" {
  description = "Adresse du répartiteur de charge, à inscrire dans le DNS."
  value       = scaleway_lb_ip.public.ip_address
}

output "bastion" {
  description = "Commande de rebond SSH par le bastion."
  value       = "ssh -J bastion@${scaleway_vpc_public_gateway_ip.passerelle.address}:61000 root@<instance>.pn-signalements.internal"
}

output "base_point_d_acces_prive" {
  description = "Adresse et port privés de la base."
  value       = "${scaleway_rdb_instance.principale.private_network[0].ip}:${scaleway_rdb_instance.principale.private_network[0].port}"
}

output "bucket" {
  value = scaleway_object_bucket.pieces_jointes.name
}
# environnements/preprod.tfvars
environnement    = "preprod"
project_id       = "11111111-1111-1111-1111-111111111111"
type_base        = "DB-DEV-S"
suffixe_bucket   = "preprod-7f3a"
plages_bastion   = ["198.51.100.7/32"]
cle_ssh_publique = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... equipe@signalements"
# environnements/prod.tfvars
environnement    = "prod"
project_id       = "22222222-2222-2222-2222-222222222222"
type_base        = "DB-DEV-M"
suffixe_bucket   = "prod-7f3a"
plages_bastion   = ["198.51.100.7/32"]
cle_ssh_publique = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... equipe@signalements"

Les deux environnements ne diffèrent que par leurs valeurs : projet, taille de la base, suffixe du bucket. La structure, elle, est la même par construction. Les valeurs des types de base sont des exemples : listez les types proposés avec scw rdb node-type list, et les moteurs avec scw rdb engine list, comme au cours cloud.

Valider sans rien créer

Avant le premier plan, deux commandes ne demandent aucun identifiant et ne contactent pas Scaleway :

$ terraform fmt -recursive
$ terraform validate
Success! The configuration is valid.

validate vérifie la syntaxe, les références, les types et les arguments contre le schéma des fournisseurs installés par init. Il ne vérifie pas que le type PRO2-XXS existe en fr-par-2, ni que le nom du bucket est libre : seul le plan, puis l'apply, interrogent l'API.

Lire le graphe

terraform graph produit le graphe des dépendances au format DOT, sans identifiant. En ne gardant que les arêtes, on obtient, pour cette configuration :

$ terraform graph | grep -- '->'
"scaleway_instance_private_nic.app" -> "scaleway_instance_server.app";
"scaleway_instance_private_nic.app" -> "scaleway_ipam_ip.app";
"scaleway_instance_server.app" -> "scaleway_instance_security_group.app";
"scaleway_instance_server.app" -> "scaleway_vpc_gateway_network.app";
"scaleway_ipam_ip.app" -> "scaleway_vpc_private_network.app";
"scaleway_ipam_ip.lb" -> "scaleway_vpc_private_network.app";
"scaleway_lb.principal" -> "scaleway_ipam_ip.lb";
"scaleway_lb.principal" -> "scaleway_lb_ip.public";
"scaleway_lb_backend.app" -> "scaleway_lb.principal";
"scaleway_lb_frontend.http" -> "scaleway_lb_backend.app";
"scaleway_vpc_gateway_network.app" -> "scaleway_vpc_private_network.app";
"scaleway_vpc_gateway_network.app" -> "scaleway_vpc_public_gateway.passerelle";
"scaleway_rdb_instance.principale" -> "ephemeral.random_password.base";
"scaleway_rdb_instance.principale" -> "scaleway_vpc_private_network.app";
"scaleway_vpc_private_network.app" -> "scaleway_vpc.principal";
"scaleway_vpc_public_gateway.passerelle" -> "scaleway_iam_ssh_key.equipe";
"scaleway_vpc_public_gateway.passerelle" -> "scaleway_vpc_public_gateway_ip.passerelle";

Une flèche se lit « dépend de ». L'ordre de création s'en déduit, par vagues que Terraform exécute en parallèle (dix opérations simultanées par défaut) :

  1. ce qui ne dépend de rien : le VPC, la clé SSH, l'adresse de la passerelle, l'adresse du répartiteur, les groupes de sécurité, le bucket ;
  2. le réseau privé ; la passerelle ;
  3. les adresses réservées ; le rattachement de la passerelle au réseau ; la base ;
  4. les instances (qui attendent la passerelle) ; le répartiteur ;
  5. les interfaces privées ; le backend ; puis le frontend ; et le secret, quand la base a son adresse.

La destruction suit l'ordre inverse. Le graphe confirme aussi ce que la configuration voulait : la base ne dépend pas des instances, le répartiteur non plus.

Ce que le plan contiendra

Le premier plan, lancé avec les identifiants du projet de préproduction, annoncera la création de 23 ressources : 9 pour le réseau (le VPC, le réseau privé, trois adresses réservées, la clé SSH, l'adresse et la passerelle, son rattachement), 6 pour le calcul (deux groupes de sécurité, deux instances, deux interfaces), 3 pour la base et son secret (l'instance, le secret, sa version), 4 pour le répartiteur et 1 bucket. La ressource éphémère n'est pas comptée : elle n'est ni créée ni gérée.

$ terraform init -backend-config=backend-preprod.hcl
$ terraform plan -var-file=environnements/preprod.tfvars -out=preprod.tfplan
$ terraform apply preprod.tfplan

Le backend lit ses propres identifiants (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY), distincts de ceux du fournisseur, comme l'explique la leçon 7.

La création prend plusieurs minutes, dominées par la base et le répartiteur. Une fois terminée, terraform output adresse_publique donne l'adresse à interroger, et l'API répond sur /sante quand cloud-init a fini sur les deux instances.

Pour la production, le même code, un autre fichier de variables, et surtout un autre état :

$ terraform init -reconfigure -backend-config=backend-prod.hcl
$ terraform plan -var-file=environnements/prod.tfvars -out=prod.tfplan

La leçon 7 détaille le stockage de l'état dans Object Storage ; l'essentiel ici est qu'une erreur de variable ne puisse jamais faire planifier la production avec l'état de la préproduction.

Détruire

$ terraform destroy -var-file=environnements/preprod.tfvars

Deux ressources résistent, et c'est voulu. Le secret est protégé (protected = true) : la destruction échoue tant que la protection n'est pas retirée dans le code et appliquée. Le bucket refuse d'être supprimé s'il contient des objets ; l'argument force_destroy = true le viderait d'abord, ce que l'on n'écrit pas sur un bucket de production. Ces deux garde-fous transforment une destruction accidentelle en échec bruyant.

Sous le capot

Comment templatefile s'intègre au plan. La fonction lit le fichier et rend une chaîne au moment de l'évaluation de la configuration. Le fournisseur Scaleway envoie ensuite cette chaîne comme donnée utilisateur cloud-init de l'instance. Pour Terraform, cloud_init est un attribut comme un autre : modifier le modèle change la valeur, et le plan propose une modification. La documentation du fournisseur ne dit pas si ce changement recrée l'instance ; dans tous les cas, cloud-init ne réexécute pas une configuration de premier démarrage sur une instance déjà initialisée. La conséquence est la même qu'au cours cloud : on remplace l'instance pour appliquer une nouvelle configuration (terraform apply -replace='scaleway_instance_server.app["sig-app-1"]', leçon 8).

Comment un argument en écriture seule passe sans être stocké. Dans le protocole entre Terraform et les fournisseurs, un attribut peut être déclaré en écriture seule dans le schéma. Terraform le transmet au fournisseur dans la requête de création ou de modification, puis le remplace par une valeur nulle avant d'enregistrer le plan et l'état. Le fournisseur ne peut donc jamais comparer la valeur courante à la nouvelle : c'est pourquoi le fournisseur Scaleway, comme les autres, ajoute un attribut de version ordinaire, lui stocké, dont le changement signale qu'il faut réenvoyer la valeur.

Pourquoi les adresses réservées n'introduisent pas de cycle. La réservation dépend seulement du réseau privé ; l'interface dépend de la réservation et de l'instance ; l'instance ne dépend d'aucune des deux. Sans réservation, le backend du répartiteur aurait dû lire les adresses sur les interfaces, et donc attendre les instances ; le cloud-init de l'instance n'aurait pas pu connaître sa propre adresse sans créer un cycle instance → interface → instance, que Terraform refuse.

Pièges courants

Les paquets qui ne s'installent pas. Symptôme : les instances sont créées, le répartiteur les marque hors service, cloud-init status --long (par le bastion) signale une erreur du module package_update. Cause : l'ordre de démarrage expliqué plus haut. Remède : attendre le réseau dans runcmd, ou, mieux, une image dorée qui contient déjà Docker (Scaleway en pratique, leçon 2).

Un argument qui recrée sans prévenir. Plusieurs arguments forcent le remplacement de la ressource : is_ha_cluster, user_name et private_network sur la base, le type d'une instance quand replace_on_type_change est vrai. Le plan l'annonce par -/+ et la mention forces replacement : lisez-le avant chaque apply, surtout sur une ressource avec état.

Le mot de passe qui ne change pas. Vous modifiez la longueur de la ressource éphémère, l'apply ne fait rien sur la base. C'est normal : seul l'incrément de password_wo_version déclenche l'envoi.

Le type de base qui n'existe pas. validate accepte n'importe quelle chaîne pour node_type ; l'erreur n'arrive qu'à l'apply, après la création du réseau. Vérifiez les valeurs avec scw rdb node-type list avant d'écrire un fichier de variables.

Deux environnements, un état. Oublier -reconfigure -backend-config=backend-prod.hcl en changeant d'environnement fait planifier la production contre l'état de la préproduction : le plan propose de détruire tout ce qui existe et de tout recréer. Un plan qui annonce 23 destructions est un plan que l'on n'applique pas.

La clé publique dans le fichier de variables. Elle n'est pas secrète, mais le fichier de variables pourrait un jour recevoir une valeur qui l'est. Ce qui est secret passe par des variables d'environnement TF_VAR_... ou par un gestionnaire de secrets, jamais par un .tfvars versionné.

Sécurité

  • Rien de secret dans le dépôt. Le code ne contient ni clé d'API, ni mot de passe ; le mot de passe de la base n'est même pas dans l'état. Vérifiez-le après un apply : terraform show -json | grep -c mot_de_passe doit répondre 0, et l'attribut password de la base n'y contient aucune valeur.
  • Ce qui reste dans l'état. Les adresses, les identifiants de ressources, la configuration cloud-init : rien de secret, mais une carte précise de l'infrastructure. L'état reste un document sensible (leçon 6).
  • La surface exposée se lit dans le code. Deux points d'entrée publics seulement : l'adresse du répartiteur (ports 80, et 443 une fois le certificat ajouté) et celle du bastion, limité à plages_bastion. Une revue de code peut le vérifier en cherchant les ressources d'adresse publique, ce qu'aucune inspection de console ne permet aussi sûrement.
  • Les droits de celui qui applique. La clé d'API qui exécute cette configuration crée des réseaux, des instances, des bases, des secrets et des clés SSH : c'est une clé puissante. La leçon 12 la confie au pipeline seul, avec un périmètre limité au projet de l'environnement.

En production

  • Le HTTPS. Le frontend du port 443 demande un certificat (scaleway_lb_certificate avec un bloc Let's Encrypt), donc un nom de domaine qui pointe sur l'adresse du répartiteur : on l'ajoute quand le DNS est en place (Scaleway en pratique, leçon 12).
  • Les instances par groupe d'autoscaling plutôt qu'en liste fixe, dès que la bêta de Scaleway le permet ; le tableau local devient un modèle d'instance.
  • Des modules. Quand une deuxième application suivra le même schéma (réseau, instances, base, répartiteur), on factorisera en modules versionnés : cours Terraform avancé : modules, tests, état.
  • Le coût d'un environnement. Deux instances, une base, une passerelle, un répartiteur et quatre adresses : la méthode d'estimation ligne par ligne de la leçon 8 du cours cloud s'applique au plan, qui donne la liste exacte des ressources. Une préproduction se détruit le soir et se recrée le matin en quelques minutes : c'est l'un des bénéfices les plus concrets du code.
  • Chez Lyneko, les applications tournent sur Kapsule et leurs ressources Scaleway sont décrites de cette façon ; le cours Kapsule : Kubernetes managé chez Scaleway en montrera la configuration.

Exercices

1. Ajouter une troisième instance (niveau 200). Ajoutez sig-app-3 en fr-par-3, adresse 172.16.20.13. Combien de fichiers modifiez-vous, et combien de ressources le plan crée-t-il ?

Solution

Un seul fichier, locals.tf, et une seule ligne dans local.instances. Le plan crée cinq ressources : un groupe de sécurité (nouvelle zone, donc nouvel élément de local.zones), une adresse réservée, une instance, une interface privée, et il modifie le backend du répartiteur, dont server_ips gagne une adresse. Si la zone existait déjà, le groupe de sécurité ne serait pas créé.

2. Changer de version de l'application (niveau 200). Passez version_signalements à 1.3.0. Que montre le plan ? Comment déployer réellement la nouvelle version ?

Solution

Le plan montre un changement de l'attribut cloud_init des deux instances, puisque le modèle rendu contient la nouvelle étiquette. Mais cloud-init ne réapplique pas une configuration de premier démarrage : il faut remplacer les instances, une par une pour garder le service, avec terraform apply -var-file=... -replace='scaleway_instance_server.app["sig-app-1"]', attendre que le répartiteur la remette en service, puis la seconde. Une fois l'application déployée par un pipeline ou un outil GitOps, la version sortira de Terraform : l'infrastructure et l'application n'évoluent pas au même rythme.

3. Le point d'accès public oublié (niveau 200). Un collègue ajoute load_balancer {} au bloc de la base « pour se connecter depuis son poste ». Que se passe-t-il au plan, et que proposez-vous à la place ?

Solution

Le plan ajoute un point d'accès public à la base, avec une ACL ouverte par défaut à tout Internet. Proposez à la place la connexion par le bastion (ssh -J vers une instance, puis psql vers l'adresse privée de la base), ou un tunnel SSH local par le bastion vers le port de la base. Rien à changer dans le code, et aucune surface exposée.

4. Faire tourner le mot de passe (niveau 200). Décrivez la modification et ses effets, et ce qui doit se passer côté application.

Solution

Incrémenter version_mot_de_passe_base (dans le fichier de variables de l'environnement, ou par -var). À l'apply, une nouvelle valeur éphémère est envoyée à la base (password_wo) et au secret (data_wo) dans la même exécution. L'application, elle, garde l'ancien mot de passe en mémoire tant qu'elle n'a pas relu le secret : il faut la redémarrer juste après, ce qui coupe le service quelques secondes. La rotation sans coupure, avec deux comptes qui alternent, est décrite dans Scaleway en pratique, leçon 8.

Récapitulatif

  • Une configuration réelle se découpe par domaine (reseau.tf, calcul.tf, base.tf...), avec un fichier de variables et un état par environnement.
  • Chaque nom de ressource et d'argument vient de la documentation du fournisseur, et se vérifie contre le schéma installé (terraform providers schema -json, terraform validate).
  • Décrire l'état final supprime les étapes intermédiaires risquées : la base et les instances n'ont jamais d'accès public.
  • Réserver les adresses privées dans IPAM les rend connues avant les instances : le répartiteur et cloud-init peuvent s'en servir sans cycle.
  • Une ressource éphémère et des arguments en écriture seule (password_wo, data_wo, avec leur version) gardent le mot de passe hors de l'état et du plan ; le secret le rend lisible par l'application.
  • Le fournisseur démarre l'instance avant d'attacher ses réseaux privés : sans adresse publique, cloud-init doit attendre le réseau.
  • terraform graph montre les dépendances ; l'ordre de création en découle, la destruction suit l'ordre inverse.
  • Secret protégé et bucket non vide font échouer une destruction accidentelle.

Pour aller plus loin

  • La documentation de chaque ressource utilisée, dans le répertoire docs/resources du dépôt du fournisseur, en particulier les exemples de rdb_instance (points d'accès), ipam_ip (adresse réservée) et vpc_public_gateway (bastion).
  • Le guide Using Write-Only Arguments du fournisseur Scaleway, qui liste toutes les ressources qui en proposent.
  • La leçon 11, pour reprendre dans cette configuration les ressources déjà créées à la main pendant le cours cloud, sans les détruire.
Voir ma constellation →

Sources