Signalements sur Scaleway, en code
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 code | Ce qui change |
|---|---|---|
scw vpc vpc create | scaleway_vpc | rien |
scw vpc private-network create ... subnets.0=172.16.20.0/22 | scaleway_vpc_private_network avec un bloc ipv4_subnet | rien |
scw instance private-nic create, puis lecture de l'adresse dans IPAM | scaleway_ipam_ip réservée d'avance, puis scaleway_instance_private_nic avec ipam_ip_ids | l'adresse est connue avant l'instance |
scw vpc-gw gateway create ... enable-bastion=true, adresses autorisées réglées dans la console | scaleway_vpc_public_gateway avec bastion_enabled et allowed_ip_ranges | la restriction du bastion, impossible avec la CLI 2.62, s'écrit dans le code |
scw instance server create, puis detach-ip et ip delete | scaleway_instance_server sans argument d'adresse publique | l'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 public | scaleway_rdb_instance avec un bloc private_network et sans bloc load_balancer | jamais de point d'accès public, mot de passe hors de l'état |
scw lb lb create, backend create, frontend create | scaleway_lb_ip, scaleway_lb, scaleway_lb_backend, scaleway_lb_frontend | rien |
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 -jsonaffiche 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 queterraform validateapplique. 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.appréserve une adresse par instance. La documentation de la ressource montre cette forme exacte, uneaddresset un blocsourcequi désigne le réseau privé.allowed_ip_rangesfixe 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'optionpush-default-route=truede 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 partemplatefileau 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
runcmdet commence par un petit script qui attend une route par défaut et unapt-get updateréussi, dix minutes au plus. Les modulespackage_updateetpackages, 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 sur127.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é (dropen 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 fournisseurhashicorp/random(le schéma de la version 3.9.1 exposerandom_passwordetrandom_bytescomme ressources éphémères). Sa valeur est produite à chaque exécution et n'est jamais enregistrée.password_woetpassword_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 quepassword_wo_versionne change pas, elle n'est pas envoyée. Pour changer le mot de passe, on incrémenteversion_mot_de_passe_base.- Le bloc
private_networksans blocload_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 blocload_balancerdoit ê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/0qui 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_versionsuit 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) :
- 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 ;
- le réseau privé ; la passerelle ;
- les adresses réservées ; le rattachement de la passerelle au réseau ; la base ;
- les instances (qui attendent la passerelle) ; le répartiteur ;
- 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_passedoit répondre 0, et l'attributpasswordde 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_certificateavec 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 graphmontre 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/resourcesdu dépôt du fournisseur, en particulier les exemples derdb_instance(points d'accès),ipam_ip(adresse réservée) etvpc_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.
Sources
- Fournisseur Terraform de Scaleway, documentation des ressources (dépôt scaleway/terraform-provider-scaleway, répertoire docs/resources)
- Fournisseur Terraform de Scaleway, guide Using Write-Only Arguments
- Fournisseur Terraform de Scaleway, code de création d'une instance (internal/services/instance/server.go)
- Fournisseur Terraform de Scaleway, page d'accueil : ordre de recherche des identifiants (docs/index.md)
- HashiCorp, Terraform : Write-only arguments