La configuration de ces sessions est normalement effectuée par la déclaration d’un ou plusieurs blocs providers dans le code. J’ai pu constater autour de moi un certain flou de pratiques d’écriture de ces blocs, notamment dans une structure de code utilisant des modules. Où faut-il déclarer ces blocs ? Quel nommage ? Comment gérer le partage des providers entre plusieurs modules ? Est-ce même souhaitable ? Quelle interaction ces providers ont-ils avec l’environnement d’exécution pour leur configuration ?
Je vous propose donc aujourd’hui d’étudier la façon dont sont utilisées ces déclarations de blocs providers par Terraform lorsqu’il applique le code et écrit le state associé aux applications et les conséquences de cette gestion. Je présenterai aussi quelques pratiques que j’ai pu appliquer dans mes productions, leurs avantages et inconvénients.
Cet article décrira plusieurs concepts autour des déclarations de blocs providers, il est donc prérequis d’avoir des connaissances sur le sujet, voici la documentation associée. Nous aborderons aussi l’héritage des providers dans une structure de code incluant des modules, voici la documentation associée.
Dans l’ensemble de l’article, je mentionnerai sous le nom de “ressource” Terraform aussi bien des data que des blocs resource, la manière dont Terraform gère ces éléments est la même quand à leur relation avec les déclarations de providers.
Les enjeux autour des déclarations de blocs providers
Comme mentionné en introduction, les providers Terraform sont au centre de son fonctionnement. Il y a donc de grands enjeux dans la balance :
- la capacité à paramétrer une session distante de manière sécurisée tout en respectant la bonne pratique de ne pas stocker les secrets de connexion dans la configuration directement (préférer les paramètres CLI ou variables d’environnement)
- la capacité à gérer plusieurs sessions dans le même code, pour pouvoir faire de la synchronisation entre plusieurs instances du service distant, par exemple multi comptes ou multi régions AWS
- la capacité à gérer le cycle de vie complet des ressources d’un code (aussi bien création, modification que suppression)
- des nommages efficaces permettant de facilement comprendre/documenter à quoi servent les différents alias de providers déclarés dans le code
- la capacité à séparer par lot logique la session utilisée pour créer certaines ressources
- la cohabitation et le partage de sessions entre plusieurs modules Terraform
- la capacité à réutiliser le code sous la forme de module dans des contextes plus étendus et divers (pour une publication open source qui devra être applicable dans le plus de cas possibles par exemple)
Si face à certains de ces enjeux on peut directement associer des bonnes pratiques nous indiquant quoi faire, pour d’autres il n’y a pas de bonnes pratiques mises en avant, plusieurs solutions sont possibles.
Avant d’apporter des éléments de réponse, exposons clairement comment les ressources d’un code Terraform, modulaire ou non, sont reliées à leurs providers et ce que cela implique.
Relations entre les ressources et les providers déclarés
Chaque ressource Terraform doit déclarer un type qui va automatiquement l’associer à un type de provider, ce provider est téléchargé et inclut en tant que librairie supplémentaire via la commande “init”.
La suite de cette section utilise ce projet en illustration, vous y trouverez plusieurs dossiers présentant différentes structures de code Terraform fonctionnelles ou non. Chaque dossier est accompagné d’un state qui a été produit en appliquant le code. Le but est d’illustrer les propos qui vont suivre en présentant les différences de comportement, avec et sans module dans la structure.
Les cas 1 et 2 illustrent un code très simple, sans module. On peut voir qu’à chaque fois, Terraform conservera dans les objets du state, un champ pour indiquer quel provider a créé la ressource (selon un adressage prenant en compte les modules).
A partir du cas 3, on touche à des structures incluant un module. Les conclusions resteront bien entendu applicables de la même manière si l’on utilise plusieurs modules, des sous-modules etc. Attention toutefois à respecter la même relation d’héritage que dans le cas correspondant.
Comme la documentation officielle l’indique, l’héritage des providers entre le contexte d’appel et ses sous-modules, peut se résumer en trois règles :
- Le provider par défaut du contexte d’appel est automatiquement hérité pour remplacer le provider par défaut implicite dans les sous-modules (voir cas 3). Toutefois l’héritage implicite est interrompu si l’on déclare ce même provider par défaut dans un module (voir cas 4). Il restera possible de passer explicitement le provider par défaut pour surcharger celui du module.
- Les providers avec alias ne sont pas implicitement hérités dans les modules appelés (d’où l’erreur du cas 9) mais peuvent être hérités si passés explicitement (voir cas 5 à 7). Il est d’ailleurs possible de passer explicitement dans un module un provider sous un alias différent de son contexte d’appel (cas 10). Cela est même possible en tant que provider par défaut.
- L’héritage explicite n’est plus possible et est simplement ignoré si le provider déclaré dans le module a des paramètres autres que son alias (voir cas 8). Ce phénomène est explicité dans la documentation par la notion de proxy configuration block, un provider déclaré dans un module ne permet le passage explicite d’un provider du contexte d’appel que s’il n’a aucun paramètre !
Vous avez donc ici une illustration des règles d’héritage que suit Terraform pour lier ses ressources aux providers déclarés dans le code.
Ce comportement a plusieurs effets de bord que vous avez peut-être déjà pu croiser :
- comme seul le nom du provider est conservé dans le state, si vous changez la configuration du provider entre deux exécutions (si vous changez le compte AWS sur lequel le provider se connecte par exemple), vous pouvez obtenir une situation où la nouvelle configuration ne permet plus de retrouver une ressource associée à ce provider lors d’un refresh et Terraform ne pourra pas en avoir conscience. Seuls les messages d’erreur des APIs du fournisseur pourront vous faire comprendre cette erreur.
- Si vous supprimez (ou renommez) un bloc provider sans supprimer toutes les ressources qui lui étaient associées, vous obtiendrez l’erreur du cas 9 qui indique que la déclaration d’un provider est manquante. L’erreur sera présente même s’il n’y a plus de trace des ressources dans le code, en effet, elles sont encore dans le state !
Il est facile de croiser ce cas de figure en supprimant du code un module déclarant son propre bloc provider interne utilisé dans ses propres ressources.
Etudions maintenant un dernier sujet : les interactions entre les providers Terraform et l’environnement d’exécution des commandes Terraform.
On l’a vu précédemment, les providers fournissent à Terraform la possibilité de dialoguer avec les APIs des fournisseurs de service. Bien que ce ne soit pas une règle, il n’est pas rare de voir les implémentations de ces providers réutiliser directement un SDK en Go, officiel ou non.
Ces SDK ont leurs propres interactions avec l’environnement d’exécution. Le plus souvent, les variables d’environnement sont utilisées comme des surcharges de la configuration passée par le code. Cette priorité rend possible de configurer le provider sans écrire une variable dans le code Terraform (ex : AWS_ACCESS_KEY_ID) ou en passant des fichiers dans l’environnement et les pointant via d’autres variables d’environnement (ex : AWS_SHARED_CREDENTIALS_FILE).
Les outils d’automatisation récents permettent un contrôle fin des variables et l’injection de fichiers dans l’environnement d’exécution, le même code peut donc être réutilisé dans un autre contexte. Cette fonctionnalité permet une forme d’injection de dépendances “native”, les configurations des providers deviennent des paramètres en eux-mêmes. Il n’est donc plus nécessaire de figer la configuration des providers Terraform dans le code.
En constatant ces comportements, j’ai défini des bonnes pratiques que j’utilise dans mes productions de code Terraform.
Propositions de bonnes pratiques
Il est optimiste d’espérer répondre à chaque cas individuel avec une règle unique fixe. J’ai bien conscience que chaque contexte aura ses propres contraintes, aussi bien techniques qu’organisationnelles.
Je vais donc expliciter ici les règles que j’essaie de suivre au jour le jour dans mes productions de code Terraform et leurs conséquences, avantages et inconvénients :
- Comme nous avons pu le constater plus haut, le fait d’ajouter des paramètres dans un bloc provider rend ce provider impossible à surcharger par héritage. Cela peut réduire fortement les possibilités de réutilisation du code. Il est donc intéressant de tendre au maximum vers des déclarations de providers les moins paramétrées possibles dans le code. En bonus, on permettra au code de gérer différents modes de configuration (ex : AWS SDK et l’utilisation de profils ou de clés access key en environnement, ou de fichiers de configuration). En contrepartie, il faudra donc référencer ces modèles de configuration dans la documentation associées au code puisqu’on n’aura plus la possibilité de les documenter dans les variables Terraform. Il est aussi à noter que certains modèles de configuration n’autorisent pas de delta entre plusieurs instances du provider. AWS_ACCESS_KEY_ID surchargera tous les providers par exemple, il vaut donc mieux utiliser les fichiers partagés (AWS_SHARED_CREDENTIALS_FILE).
- La problématique de suppression des providers sans suppression des ressources associées peut facilement être rencontrée si l’arborescence des providers place ceux-ci au même niveau que les ressources et entraîne donc leur suppression commune. Un élément de réponse consiste à faire remonter toutes les déclarations de configuration de provider à la racine du code Terraform, en utilisant les mécanismes d’héritage illustrés précédemment. Le code deviendra plus verbeux puisqu’il faudra passer explicitement les providers alias dans chaque module qui les utilisera. On aura par contre la possibilité de mutualiser la configuration en un point unique. On pourra conserver les logiques des modules hors du code racine, ce qui est un des principes de base de la modularité.
- Dans un code utilisant plusieurs providers, pour effectuer des opérations multi-comptes par exemple, il est souvent compliqué de trouver un nom unique explicitant toutes les utilisations du provider. Profiter des capacités d’héritage de providers permet de nommer les alias de providers déclarés dans un module pour documenter leur utilisation dans le contexte du module. Voici quelques exemples d’application :
- plutôt que nommer un provider AWS par un alias “us-east-1” dans un module qui va servir de session pour appeler le service CloudFront, il est possible de l’appeler “cdn”, afin de documenter son utilisation. Je rappellerai d’ailleurs que dans la partition AWS China, ce sont les régions Beijing et Ningxia qui servent de point d’entrée pour CloudFront, de quoi rendre le code directement réutilisable dans ces contextes.
- dans un module partageant des ressources AWS entre deux comptes avec besoin d’effectuer des opérations dans les deux comptes, on pourra nommer les providers “origin” et “destination” par exemple. Ce genre de nommage permet une meilleure expression logique de l’utilisation des providers dans le module. Il est aussi possible de considérer le provider par défaut comme l’origine par exemple et de juste nommer l’autre.
- A titre d’information, le contrôle de la version d’un bloc provider via le paramètre “version” est déprécié. Il est aujourd’hui conseillé de déclarer les versions de providers dans un bloc terraform/required_providers. Il n’est donc pas nécessaire d’indiquer la version souhaitée dans le bloc provider, on peut conserver le format proxy configuration block.
Conclusion
Nous avons étudié la manière dont Terraform associait puis conservait la relation entre les ressources et les providers référencés dans le code, ce fonctionnement permet beaucoup de flexibilité dans l’héritage des providers déclarés. La plupart des implémentations des providers interagissent de manière importante avec l’environnement d’exécution pour se configurer.
Il est donc possible de rendre son code grandement réutilisable, passant d’un contexte à un autre en changeant seulement l’environnement et des fichiers de configuration gérés par la CI. Bien entendu, il faudra adapter les solutions choisies au contexte d’entreprise.