Sommaire
Pourquoi vouloir gérer son Infra as Code depuis Kubernetes ?
Avant de voir le fonctionnement de Crossplane, intéressons-nous au problème qu’il cible.
Tout d’abord un constat : aujourd’hui, Kubernetes devient le standard d'exécution pour les applications. Par contre, en ce qui concerne l'Infrastructure as Code, d’autres solutions sont utilisées : Terraform, Ansible ou un service spécifique à un cloud provider comme Cloud Formation dans le cas d’AWS. Nous avons donc une diversité de technologies à maîtriser selon le type de déploiement “applicatif” ou “infrastructure". Par maîtrise, nous entendons le Domain Specific Language (DSL), l’éco-système et enfin la communauté :
- Le DSL, car écrire un chart Helm ou un module Terraform, c’est faire appel à des syntaxes différentes pour gérer la variabilisation, le templating et les fonctions intégrées.
- L'écosystème, car intégrer Helm ou Terraform dans une chaîne CI/CD repose sur des paradigmes, de la glue et des outils qui ne sont pas les mêmes.
- Enfin la communauté, car identifier les bonnes pratiques, les ressources additionnelles telles que des hooks pre-commit et les chart/modules qui ont de la valeur repose sur une expérience spécifique.
Qui plus est, la frontière entre “application” et “infrastructure” n’a plus de sens. Prenons le cas d’une application sur Kubernetes qui a besoin d’un bucket S3 : dans la majorité des cas, c’est mélanger un chart Helm pour l’application et du Terraform ou AWS CloudFormation pour le bucket S3 et sa configuration.
Mélange de technologies pour déployer un service
Pour terminer, Kubernetes est pensé pour être extensible au travers du pattern Operator. C'est-à-dire que des Custom Resource Definitions vont étendre les ressources que votre cluster peut gérer et un contrôleur est chargé de la réconciliation continue sur ces types de ressources. Rares sont les clusters qui ne font pas appel à des opérateurs comme External DNS, Cert-manager ou encore Prometheus Operator.
https://novakov-alexey.github.io/k8s-operator/
Pour résumer, nous avons d’un côté, des outils d’Infrastructure as Code qui supposent une montée en compétences pour bien les utiliser. De l’autre côté, Kubernetes devient de plus en plus le standard pour exécuter des applications. Ces dernières applications sont d’ailleurs souvent hybrides car mélangent des composants Kubernetes et des services cloud. Mélangeons tout cela et nous obtenons l’idée suivante : “quitte à utiliser Kubernetes comme socle d'exécution, étendons-le pour gérer les ressources cloud, voire toute l’infrastructure”.
Crossplane c’est quoi ?
C’est un framework basé sur des opérateurs pour orchestrer les applications et leurs infrastructures depuis Kubernetes. Sous licence Apache 2.0, Crossplane est principalement porté par Upbound qui a levé 60 millions de dollars en décembre 2021. Disponible en SaaS via upbound.io ou à installer en local sur vos clusters, le projet Crossplane est passé du statut CNCF sandbox à incubation à l’été 2021.
Pour la petite histoire, derrière upbound.io et donc la genèse de Crossplane se trouve une partie des créateurs de Rook (stockage cloud-native pour Kubernetes).
Une logique de providers
Installons Crossplane via son chart Helm :
kubectl create namespace crossplane-system
helm repo add crossplane-stable https://charts.crossplane.io/stable/
helm install crossplane --namespace crossplane-system crossplane-stable/crossplane
Comme avec Terraform, identifions le provider nous permettant de parler à l’infrastructure que nous souhaitons contrôler. Ces providers peuvent être officiels (AWS, GCP, Azure, AlibabaCloud, Rook ou Helm) ou communautaires (principalement via le répertoire git crossplane-contrib). Comme avec Terraform, ce sujet de providers est crucial puisque c’est ce qui va définir la liste des ressources que vous pourrez gérer.
Fin 2021, les providers officiels Crossplane pouvaient gérer 10 fois moins de ressources que les providers Terraform. Cet écart est maintenant comblé puisque le projet Terrajet annoncé en janvier 2022 génère automatiquement les providers Crossplane à partir de providers Terraform. Se pose alors la question des providers officiels : vont-ils être maintenus, supprimés ou intégrés eux-mêmes dans les providers Terrajet ?
10 fois moins de ressources pour les providers officiels, égalité entre Terraform et Terrajet
Installer et configurer un provider
Fondamentalement, un provider Crossplane c’est un nouvel opérateur.
L’installation d’un provider se fait par un nouvel objet “pkg.crossplane.io/v1/Provider”. Par exemple, pour AWS :
Pour l’installation
cat <<EOF | kubectl apply -f -
apiVersion: pkg.crossplane.io/v1
kind: Provider
metadata:
name: crossplane-provider-aws
spec:
ignoreCrossplaneConstraints: false
package: crossplane/provider-aws:v0.23.0
packagePullPolicy: IfNotPresent
revisionActivationPolicy: Automatic
revisionHistoryLimit: 0
skipDependencyResolution: false
EOF
Ensuite, vous devez indiquer les identifiants que le provider doit utiliser. Le plus simple est d’indiquer un secret Kubernetes, c’est ce que nous indiquons ci-dessous. Pour plus de facilité, vous pouvez alimenter ces secrets à partir de sources chiffrées dans un répertoire git avec l’opérateur Bitnami Sealed Secrets. Pour aller plus loin, vous pouvez aussi utiliser une intégration avec Hashicorp Vault.
# Indicate your profile if you get multiple ones
AWS_PROFILE=default && echo -e "[default]\naws_access_key_id = $(aws configure get aws_access_key_id --profile $AWS_PROFILE)\naws_secret_access_key = $(aws configure get aws_secret_access_key --profile $AWS_PROFILE)" > creds.conf
# Then create the k8s
kubectl create secret generic aws-creds -n crossplane-system --from-file=key=./creds.conf
# And del the creds.conf file
rm -rf creds.conf
cat <<EOF | kubectl apply -f -
kind: ProviderConfig
metadata:
name: default
spec:
credentials:
source: Secret
secretRef:
namespace: crossplane-system
name: aws-creds
key: key
EOF
À ce stade, nous sommes prêts à déployer des ressources sur un compte AWS depuis Crossplane :
Les ressources Crossplane à déployer pour piloter une infra AWS
Utiliser un provider
C’est l’heure de déclarer notre première instance de VPC AWS. Dans la terminologie Crossplane, on parle de “ressource managée” pour une instance de système externe gérée par un provider. Chaque type de ressource managée que propose un provider suit cette structure commune. Les champs les plus importants sont :
- spec : l’état que nous désirons
- deletionPolicy : indique ce qui doit être fait lorsque l’instance est supprimée côté Kubernetes. Les valeurs possibles sont “Delete” ou “Orphan”.
- forProvider : la section la plus importante car elle porte les paramètres de la ressource que nous souhaitons. Son schéma varie pour chaque type de ressource managée (VPC, autoscaling group, load balancer).
- providerConfigRef : un même provider peut avoir plusieurs configurations pour des régions ou tenants/comptes différents.
- publishConnectionDetailsTo : permet d’indiquer un secret Kubernetes portant les informations de connexion. Certaines ressources cloud génèrent des informations de connexion lorsqu’elles sont créées. C’est le cas d’une base de données, ou d’un cluster Kubernetes par exemple.
- writeConnectionSecretToRef : similaire à publishConnectionDetailsTo, mais dans un namespace.
- status : l’état observé
- atProvider : reprend la structure forProvider
- conditions : deux types de conditions sont attendues. “Synced” pour indiquer que l’instance est en cours synchronisation et “Ready” lorsque la ressource distante est conforme à l’état spécifié.
Exemple de ressource managée avec un VPC AWS
L’annotation external-name indique l’identifiant côté infrastructure distante. Sa valeur est automatiquement affectée lorsque la ressource est créée par un provider. Pour l’import dans Crossplane d’une ressource préexistante, nous devons indiquer son identifiant comme valeur de l’annotation lors de la création de l’objet Kubernetes.
En ce qui concerne les liens entre ressources, plusieurs mécanismes sont à notre disposition. Par exemple, dans le cas d’une ressource managée ec2.aws.crossplane.io/v1beta1/Subnet, le VPC au sein duquel le subnet doit être crée peut-être indiqué par :
- VpcIdRef : le nom de la ressource managée Crossplane. ‘My-basic-crossplane-vpc’, dans notre exemple.
- VpcIdSelector : un label selector. `stack=crossplane-demo`, dans notre exemple.
- VpcId : l’identifiant côté infrastructure distante si cette ressource n’est pas gérée par Crossplane sur le cluster local. `vpc-XXXXXXX`, dans notre exemple.
Fini les dérives
On parle de dérive (drift) lorsque des ressources gérées par une solution d’infra as code, sont modifiées en dehors de cette solution ; par exemple depuis la console web du cloud provider. Ainsi, c’est lors de la prochaine invocation de la solution d’Infra as Code que les modifications seront annulées pour revenir à l’état désiré. Les habitués de Terraform ou AWS CloudFormation connaissent bien la notion de drift puisqu’entre deux invocations de nombreux changements ont pu avoir lieu. Le risque de drift peut être réduit par des invocations automatiques et régulières depuis une chaîne de déploiement continu par exemple, mais c’est assez rare dans les faits .
Avec Crossplane, le contrôleur de chaque provider effectue en permanence les boucles de réconciliation sur les ressources managées qu’il gère pour remédier aux modifications faites en dehors de Crossplane. Pour éviter les dépassements de quotas d’appels API sur un cloud provider, les providers Crossplane intègrent des rate limiter pour lisser les appels.
Les boucles de réconciliations kubernetes: convergence en continue vers l’état demandé
Différents scopes pour séparer les rôles
Quel que soit le provider, toutes les ressources managées Crossplane sont en dehors de tout namespace car leur “scope” et celui du cluster.
Les ressources Kubernetes additionnelles fournies par le provider AWS Crossplane
Cela suppose donc de gérer les noms des ressources Crossplane sur un même cluster pour éviter les conflits.
Pourquoi ce choix de design ? Parce que Crossplane introduit une séparation des rôles entre les “infra builder” qui sont considérés comme des administrateurs du cluster et les “app operator”. Les “infra builder” ont les droits sur l’ensemble du cluster, donc les ressources scopées “cluster” que sont les ressources managées Crossplane. A contrario, les “app operator” n’ont pas le droit d'interagir directement avec des ressources managées mais uniquement de travailler au sein de namespaces.
Dans le cas où un “app operator” doit déclarer des ressources managées Crossplane, c’est le mécanisme de composition qui est mis en œuvre.
Les compositions pour abstraire la complexité
Les compositions Crossplane traitent deux problématiques :
- Permettre à un app operator de gérer des ressources managées, mais sans les manipuler directement.
- Abstraire la complexité de configuration de plusieurs ressources les encapsulant dans un objet de plus haut niveau.
Les compositions reposent sur 3 types d’objets Kubernetes pris en charge par le contrôleur Crossplane :
- Composite Resource Definition (XRD) : ceux qui sont familiers avec Kubernetes auront noté la similitude avec les Custom Resource Definition. Concrètement, c’est la même chose puisqu’il s’agit d’une “interface” : un nouveau type d’objet fourni par le serveur API. Ce nouveau type de ressource est nommé “ressource composite” (XR) dans la documentation Crossplane. On retrouve donc un attribut schema qui porte la structure du nouvel objet au format OpenAPI v3. On retrouve aussi un attribut pour le groupe d’API et le nom du nouveau type d’objets (kind). À la différence des Custom Resource Definition Kubernetes, les XRD supportent une entrée claimNames qui va permettre un provisionnement depuis un namespace.
- Composition : c’est une implémentation de notre ressource composite. En clair : quelles sont les ressources de base qui constituent notre abstraction et comment elles sont paramétrées. Plusieurs compositions sont possibles pour une même ressource composite dans le cas où vous êtes dans une démarche multicloud.
- Claim : c’est l’objet créé par un app operator au sein d”un namespace Kubernetes, pour indiquer que son application a besoin de ressources distantes. Évidemment, le type du claim doit correspondre au claimNames indiqué dans la XRD.
Prenons l’exemple d’un réseau multicloud. Nous créons une XRD qui déclare une ressource composite “CompositeNetwork” et un claim dont le nom sera “Network” :
apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
name: compositenetworks.aws.platformref.wescale.fr
spec:
# Comment claimNames to enforce static provisioning.
# Only creation of cluster scope resource
# "CompositeNetwork.aws.platformref.wescale.fr/v1alpha1" is possible
claimNames:
kind: Network
plural: networks
group: aws.platformref.wescale.fr
names:
kind: CompositeNetwork
plural: compositenetworks
versions:
- name: v1alpha1
served: true
referenceable: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
…
En matière d’implémentation, nous créons une composition pour AWS. Le lien vers l’interface se fait via la clé compositeTypeRef. Notre composition est constituée de VPCs, tables de routages, subnets, NAT Gateway. Ces éléments sont des ressources managées déclarées dans spec.resources :
kind: Composition
metadata:
name: compositenetworks.aws.platformref.wescale.fr
labels:
provider: aws
spec:
writeConnectionSecretsToNamespace: crossplane-system
compositeTypeRef:
apiVersion: aws.platformref.wescale.fr/v1alpha1
kind: CompositeNetwork
resources:
- base:
apiVersion: ec2.aws.crossplane.io/v1beta1
kind: VPC
spec:
forProvider:
region: eu-west-3
cidrBlock: 192.168.0.0/16
enableDnsSupport: true
enableDnsHostNames: true
Enfin, un app operator qui aurait besoin de créer dynamiquement un réseau déclare un objet “Network” dans son namespace :
kind: Network
metadata:
name: demo-aws-network
namespace: ns-team-a
spec:
id: demo-aws-network
Les Compositions Crossplane, ou l’analogie avec les interfaces, les classes et les instances
Notre composition fait appel à plusieurs ressources managées, certaines ont besoin d’en référencer d’autres. Par exemple, une Internet Gateway AWS doit connaître l’identifiant du VPC. Pour cela, nous pouvons utiliser les Labels Selectors qui évaluent des labels gérés de manière uniforme sur tous les objets d’une composition. Crossplane fournit des mécanismes tels que les patches ou patchSet pour copier des valeurs depuis la ressource composite vers les ressources managées. L’inverse est possible : des éléments de ressources managées peuvent copier vers des attributs de la ressource composite.
resources:
- base:
apiVersion: ec2.aws.crossplane.io/v1beta1
kind: VPC
spec:
forProvider:
region: eu-west-3
cidrBlock: 192.168.0.0/16
enableDnsSupport: true
enableDnsHostNames: true
patches:
- fromFieldPath: spec.id
toFieldPath: metadata.labels[networks.aws.platformref.wescale.fr/network-id]
Ces valeurs injectées ou récupérées peuvent être associées à des chaînes de caractères formatées, des opérations mathématiques ou utiliser des maps.
Les ressources composites peuvent faire appel aux ressources managées de providers Crossplane, mais aussi à n’importe quelle ressource disponible sur le cluster Kubernetes : pod, deployment, CRD d’un autre opérateur ou encore une ressource composite tierce.
Par exemple, nous pouvons considérer une abstraction “cluster Kubernetes“ (simplement nommée “cluster”). Sur AWS, ce claim crée deux ressources composites :
- un cluster EKS, lui-même composé de ressources managées du provider AWS : le control plane EKS mais aussi des objets node groups et rôles IAM.
- un ensemble de services, composés de ressources managées du provider Helm de manière à installer Prometheus.
Exemple de compositions imbriquées avec un “Cluster”
En jouant sur l’imbrication de ressources composites et les claims, nous pouvons traiter différents usages d’infrastructure :
- des ressources managées uniquement (cluster scope),
- des ressources managées qui forment un tout logique au sein d’une ressource composite (XR cluster scope),
- des ressources composites avec un provisioning dynamique depuis un namespace (claim dans un namespace),
- des ressources composites préprovisionnées mais qui exposent une chaîne de connexion ou des sorties dans un namespace (claim dans un namespace qui référence une ressource composite). Ce cas est utile pour des ressources longues à créer, comme une instance RDS par exemple.
Est-ce le moment d’oublier Terraform ?
Pour répondre à la question initiale : Crossplane, ça fonctionne plutôt bien. On peut gérer son infra et faire des abstractions pour en faciliter le maintien, tout en tirant profit de l’écosystème autour de Kubernetes. Par exemple, faciliter une approche GitOps via les outils Flux ou ArgoCD (à noter cette anomalie concernant la mauvaise gestion des ressources composites par ArgoCD), appliquer des règles de gouvernance via Gatekeeper ou Kyverno ou encore surveiller son Infrastructure as Code via des alertes Prometheus.
Cependant, pour adopter Crossplane, il y a deux “mais” :
- Vous devez être prêts à revoir votre flux de déploiement. Contrairement à Terraform ou AWS CloudFormation, il n’y a pas d’arbre de dépendances pour ordonnancer la création des ressources cloud. Votre stack va “tomber en marche” au fil de l’eau via les boucles de réconciliation en continu. Vos déploiements d’infra devront s'inscrire comme les déploiements d’application, dans une logique de monitoring via des alertes type Prometheus pour indiquer les ressources “not Ready”. Si vous encapsulez vos ressources dans des compositions, pas de “plan” pour connaître à l’avance les changements sur les ressources managées sous-jacentes.
- En l’état actuel, la stratégie de la communauté Crossplane concernant les providers officiels versus les providers Terrajet n’est pas encore claire. En attendant que ce point soit traité et qu’il n'y ait qu’un type de providers AWS/GCP/Azure, vous risquez de devoir modifier vos descripteurs Kubernetes.
Pour ceux qui veulent aller plus loin, vous trouverez ici la présentation faite au Snowcamp 2022. En complément, le repertoire git pour la démo associée.