Créer son propre opérateur Kubernetes
En ce moment, j’apprends énormement sur Kubernetes et en Golang, progresser dans ces domaines est un de mes objectifs pour cette année. J’ai donc décidé de reprendre le format de mon article sur NATS pour vous présenter un autre sujet que j’ai eu l’occasion de dig récemment : les opérateurs Kubernetes.
Mais on ne va pas simplement énoncer des vérités générales sur ce qu’est un opérateur, on va plutôt en créer un ensemble et voir les différentes étapes du développement. Cela nous permettra d’itérer sur un projet concret et de voir les différentes options qui s’offrent à nous.
Je me dois quand même de faire un petit disclaimer : je ne suis ni expert Golang, ni expert Kubernetes. Je sais d’avance qu’il y aura des énormités dans ce que je vais écrire. Aussi, n’hésitez pas à me faire part de vos retours pour que je puisse m’améliorer mais surtout : n’appliquez pas à la lettre ce que je vais écrire ici, je ne garantis pas que ce soit la meilleure manière de faire.
Petit disclaimer terminé, je vais vous présenter le projet sur lequel on va travailler.
Mon projet
Mon idée n’a rien de révolutionnaire mais elle a le mérite de s’expliquer simplement et de permettre de voir différents aspects de la création d’un opérateur. Venons-en au fait : je souhaite créer un opérateur Kubernetes qui permet de générer un secret aléatoire en respectant une taille précise spécifiée. Une idée plutôt simple, possible avec certains programmes existants (notamment en couplant ça avec Vault), mais je vais essayer de le faire le plus simplement possible (KISS) et sans me reposer sur des logicels tiers.
On s’y attaque ?
Qu’est-ce qu’un opérateur Kubernetes ?
Avant de commencer quoi que ce soit, nous avons déjà besoin de savoir ce qu’est un opérateur.
Je vais m’appuyer sur la définition de RedHat:
Un opérateur Kubernetes est un contrôleur spécifique à une application qui permet d’étendre les fonctions de l’API Kubernetes afin de créer, configurer et gérer des instances d’applications complexes au nom d’un utilisateur Kubernetes.
Un opérateur est alors une application présente dans Kubernetes (ou ayant un accès à l’API-Server) pour réaliser des opérations sur des ressources (qui peuvent être native, ou custom). Un opérateur est très souvent stateless, mais ça n’est pas non plus une règle absolue.
On peut retrouver de nombreux opérateurs disponibles sur OperatorHub, qui regroupe énormément de programmes utilisables directement dans votre cluster Kubernetes. Dès que vous avez un besoin : “There is an operator for that”.
Objectifs
Histoire de ne pas trop s’éparpiller, voici les objectifs que je me suis fixé pour ce projet :
- Pouvoir définir un secret à générer à partir d’une template :
- Pouvoir choisir la taille du secret.
- Activer ou non la présence de caractères spéciaux.
- Déclencher la génération du secret :
- Par une annotation sur un objet.
- Par un CRD
RandomSecret
.
- Assurer une haute disponibilité (en permettant d’avoir plusieurs pods de l’opérateur en même temps).
Jusque-là, on n’a rien de bien compliqué. Voyons ça comme un contrat de service pour nos utilisateurs.
Very MVP
Si on devait faire ça salement, on pourrait se contenter d’un script bash. Commençons par celui qui pourrait générer un secret “from-scratch” (comme si on passait par une Custom Ressource) :
#!/bin/bash
NAMESPACE=$1
SECRET_NAME=$2
SECRET_KEY=$3
RANDOM_SECRET=$(openssl rand -base64 32)
kubectl create secret generic $SECRET_NAME --from-literal=${SECRET_KEY}=${RANDOM_SECRET} -n $NAMESPACE
L’autre cas d’usage serait de partir sur un secret déjà existant et de le mettre à jour. On pourrait imaginer un script comme ceci :
#!/bin/bash
NAMESPACE=$1
SECRET_NAME=$2
SECRET_KEY=$3 # Will be created if not exists
RANDOM_SECRET=$(openssl rand -base64 32)
kubectl get secret $SECRET_NAME -n $NAMESPACE > /dev/null 2>&1
if [ $? -eq 0 ]; then
kubectl get secret $SECRET_NAME -o json | jq '.data["'${SECRET_KEY}'"]="'${RANDOM_SECRET}'"' | kubectl apply -f -
else
kubectl create secret generic $SECRET_NAME --from-literal=${SECRET_KEY}=${RANDOM_SECRET} -n $NAMESPACE
fi
On peut techniquement faire notre operateur en Bash (en rajoutant un watch
pour surveiller les opérations, rajouter la vérification des labels…), mais en plus d’être peu fiable, on apprendrait pas grand chose.
Le choix des armes
Notre principal outil sera le SDK de Kubernetes, il nous permettra de gérer les ressources Kubernetes sans devoir construire nous-mêmes nos requêtes vers l’API-Server. Nous pouvons trouver ce SDK dans plusieurs langages (Go, Python, Java, etc.), mais pour être cohérent avec mes autres projets (et parce que c’est celui qu’est le plus proche de Kubernetes), je vais choisir le Go.
Pour le reste, on va essayer de faire au plus simple et éviter l’usage d’un framework comme KubeBuilder.
Langage, check ! Objectifs, check !
Let’s go, on ouvre notre IDE !
Initialisation du projet
Comme pour tout projet en Go, on va initialiser notre projet avec go mod init github.com/qjoly/randomsecret
. Chose faite, marquons notre premier commit après un touch main.go
.
On va commencer par écrire un petit script nous permettant de lister les différents secrets à gérer dans tous les namespaces.
Dans la fonction secrets.IsSecretManaged()
, on vérifie si le secret est géré par notre opérateur à l’aide des annotations.
// main.go
package main
func main() {
cfg, err := rest.InClusterConfig()
if err != nil {
var kubeconfigPath string
if os.Getenv("KUBECONFIG") != "" {
kubeconfigPath = os.Getenv("KUBECONFIG")
} else {
homeDir, err := os.UserHomeDir()
if err != nil {
log.Fatalf("Error getting user home directory: %v", err)
}
kubeconfigPath = filepath.Join(homeDir, ".kube", "config")
}
klog.Info("Using kubeconfig: ", kubeconfigPath)
flag.Parse()
cfg, err = clientcmd.BuildConfigFromFlags("", kubeconfigPath)
if err != nil {
klog.Fatalf("Error building kubeconfig: %v", err)
}
}
clientset, err := kubernetes.NewForConfig(cfg)
if err != nil {
klog.Info(fmt.Sprintf("Error creating clientset: %v", err))
}
kubeSecrets, err := clientset.CoreV1().Secrets("").List(context.Background(), metav1.ListOptions{})
if err != nil {
klog.Info(fmt.Sprintf("Error listing secrets: %v", err))
}
klog.Info(fmt.Sprintf("Found %d secrets\n", len(kubeSecrets.Items)))
for _, secret := range kubeSecrets.Items {
if len(secret.Annotations) > 0 {
if secrets.IsSecretManaged(secret) {
klog.Info(fmt.Sprintf("Secret %s is managed\n", secret.Name))
// Implémenter le traitement du secret
}
}
}
}
package types
const (
OperatorPrefixAnnotation = "secret.a-cup-of.coffee/"
OperatorEnabledAnnotation = OperatorPrefixAnnotation + "enable"
OperatorSecretKeyAnnotation = OperatorPrefixAnnotation + "key"
OperatorDefaultSecretKey = "randomSecret"
)
package secrets
// getRandomSecretKey returns the key used to store the random secret
// Check if the annotations map contains the key annotation
// If it does, return the key
func getRandomSecretKey(secret v1.Secret) string {
key, ok := secret.Annotations[types.OperatorSecretKeyAnnotation]
if !ok {
klog.Info("Secret does not have a key annotation, using default key")
return types.OperatorDefaultSecretKey
}
klog.Info("Secret has a key annotation, using key: ", key)
return key
}
func IsSecretManaged(secret v1.Secret) bool {
value, ok := secret.Annotations[types.OperatorEnabledAnnotation]
if !ok {
return false
}
if value != "true" {
return false
}
return ok
}
func isAlreadyHandled(secret v1.Secret) bool {
_, ok := secret.Data[getRandomSecretKey(secret)]
if !ok {
return false
}
return true
}
func HandleSecrets(secret v1.Secret) {
klog.Info(fmt.Sprintf("Handling secret: %s with key %s", secret.Name, getRandomSecretKey(secret)))
klog.Info(fmt.Sprintf("Already Configured: %v", isAlreadyHandled(secret)))
}
À partir de cette base, faisons la liste de ce que nous pouvons faire :
- Lister les secrets dans un namespace donné (ou tous les namespaces) ;
- Vérifier si un secret est géré par notre opérateur ;
- Vérifier si un secret a déjà été traité ;
- Récupérer la configuration d’un secret (via des annotations).
Il nous reste maintenant le plus gros du travail : patch le secret pour ajouter notre chaine de caractères aléatoire.
Patch un secret
Nous allons utiliser le client Kubernetes pour mettre à jour le secret, en faisant l’équivalent d’un kubectl patch
en Go. Voici le code nous le permettant :
func generateRandomSecret(annotations map[string]string) string {
klog.Info("Generating random secret")
pattern := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
// Check if the special char annotation is present and different from false
// Then add special characters to the pattern otherwise use the default pattern
if annotations[types.OperatorSpecialCharAnnotation] != "" && annotations[types.OperatorSpecialCharAnnotation] != "false" {
pattern += "!@#$%^&*()_+"
}
length := 32
// Check if the length annotation is present
// If it is, parse the value and use it as the length of the secret
if annotations[types.OperatorLengthAnnotation] != "" {
fmt.Sscanf(annotations[types.OperatorLengthAnnotation], "%d", &length)
}
rand.Seed(uint64(time.Now().UnixNano()))
secret := make([]byte, length)
for i := range secret {
secret[i] = pattern[rand.Intn(len(pattern))]
}
return string(secret)
}
func patchSecret(clientset *kubernetes.Clientset, secret v1.Secret, key string, value string) {
if secret.Data == nil {
secret.Data = make(map[string][]byte)
}
secret.Data[key] = []byte(value)
_, err := clientset.CoreV1().Secrets(secret.Namespace).Update(context.Background(), &secret, metav1.UpdateOptions{})
if err != nil {
klog.Info(fmt.Sprintf("Error patching secret %s: %v", secret.Name, err))
}
klog.Info(fmt.Sprintf("Secret %s patched with key %s", secret.Name, key))
}
Ainsi, dès qu’un secret est créé ou modifié, nous allons patcher le secret pour y ajouter notre chaine de caractères aléatoire.
Je pense que c’est la méthode la plus simple à mettre en place. Toutefois, elle n’est peut-être pas la plus optimale, notamment parce qu’elle nous oblige à écouter les événements de création et de modification de secrets (en plus de devoir gérer les cas où un événement est reçu quand l’opérateur n’est pas prêt). De plus, ce n’est pas instantané (il y a un délai entre la création du secret et le patch) et nous devons gérer les permissions nécessaires pour que notre programme ait accès aux secrets des namespaces.
Première étape de notre opérateur : check ! Il patche désormais les secrets en fonction des annotations. Testons-le !
It works !
Mais là, nous sommes dans le cas d’un lancement ponctuel de notre opérateur (autant dire que c’est inutile). Il nous faut maintenant le déployer dans un cluster Kubernetes et le faire tourner en permanence. Comme expliqué, l’idée est d’écouter les évenements de création et de modification de secrets pour les patcher dès qu’ils sont créés.
Rester disponible
Nous allons introduire une boucle de réconciliation dans notre opérateur. Chaque déclenchement de la boucle va lister les secrets et les traiter. Chaque boucle déclenchera quant à elle le traitement de tous les secrets (c’est aussi une manière de gérer les secrets qui ont été créés avant le démarrage de l’opérateur).
Remarque
Pour rappel, une boucle de réconciliation est un évenement qui se déclenche pour vérifier si l’état des ressources est conforme à l’état désiré. C’est un pattern majoritairement utilisé dans Kubernetes.
Créons alors une boucle de réconciliation, déclenchée aux événements suivants :
- Au démarrage de l’opérateur (pour traiter les secrets déjà existants) ;
- Dès qu’un secret est créé ou modifié et possèdant les annotations nécessaires.
package main
// ...
func main() {
k := kube.NewClient()
clientset := k.Clientset
secretWatch := cache.NewListWatchFromClient(
clientset.CoreV1().RESTClient(),
"secrets",
metav1.NamespaceAll,
fields.Everything(),
)
secretHandler := cache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) {
secret, ok := obj.(*v1.Secret)
if !ok {
log.Println("Failed to cast to Secret")
return
}
if secrets.IsSecretManaged(*secret) {
fmt.Println("Added, Found secret", secret.Name)
}
},
UpdateFunc: func(_, newObj interface{}) {
// We only care about the new object
secret, ok := newObj.(*v1.Secret)
if !ok {
log.Println("Failed to cast to Secret")
return
}
if secrets.IsSecretManaged(*secret) {
fmt.Println("Updated, Found secret", secret.Name)
}
},
}
secretInformer := cache.NewSharedInformer(secretWatch, &v1.Secret{}, 0)
secretInformer.AddEventHandler(secretHandler)
endSignal := make(chan struct{})
go secretInformer.Run(endSignal)
// Garde le processus en vie
select {}
// ...
}
Décortiquons un peu ce code :
- On crée un
secretWatch
qui va écouter les événements autour des secrets dans tous les namespaces ; - On crée le
secretHandler
qui va être déclenché à chaque événement venant desecretWatch
mais en ajoutant un filtre pour ne traiter que les événements de création et de modification de secrets ; secretInformer
va faire la liaison entresecretWatch
etsecretHandler
.
Avant de passer à la suite (c.a.d. lancer la réconciliation), on va quand même tester ça :
go run main.go
I0118 08:59:51.666753 14232 kube.go:57] Using kubeconfig: /Users/qjoly/.kube/config
Added, Found secret non-empty-secret
Added, Found secret test-empty-secret
Dès que je lance mon operateur, il détecte les secrets déjà existants dans la AddFunc
. Ce n’est pas vraiment ce que l’on souhaite (cela veut dire que la boucle de réconciliation va se lancer autant de fois qu’il y a de secrets déjà existants).
On va devoir adapter ça pour n’accepter l’évenement de création que si la date de création du secret est postérieure à la date de démarrage de l’opérateur. Voici ce que ça donne :
package main
// ...
func main() {
k := kube.NewClient() // On récupère notre client Kubernetes depuis notre package kube
clientset := k.Clientset
+ startTime := time.Now()
secretWatch := cache.NewListWatchFromClient(
clientset.CoreV1().RESTClient(),
"secrets",
metav1.NamespaceAll,
fields.Everything(),
)
secretHandler := cache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) {
secret, ok := obj.(*v1.Secret)
if !ok {
log.Println("Failed to cast to Secret")
return
}
+ if secret.CreationTimestamp.Time.Before(startTime) {
+ return
+ }
if secrets.IsSecretManaged(*secret) {
fmt.Println("Added, Found secret", secret.Name)
}
},
UpdateFunc: func(_, newObj interface{}) {
// We only care about the new object
secret, ok := newObj.(*v1.Secret)
if !ok {
log.Println("Failed to cast to Secret")
return
}
if secrets.IsSecretManaged(*secret) {
fmt.Println("Updated, Found secret", secret.Name)
}
},
}
secretInformer := cache.NewSharedInformer(secretWatch, &v1.Secret{}, 0)
secretInformer.AddEventHandler(secretHandler)
endSignal := make(chan struct{})
go secretInformer.Run(endSignal)
}
On devrait maintenant avoir le comportement attendu. Testons ça !
go run main.go
I0118 09:11:00.216301 15497 kube.go:57] Using kubeconfig: /Users/qjoly/.kube/config
# Et seulement si on crée un secret après le démarrage de l'opérateur
Added, Found secret non-empty-secret
Parfait, on est maintenant capable d’agir à partir des événements de création et de modification de secrets. Mais il nous reste encore un point à traiter : la réconciliation.
Réconciliation
On va enfin mettre en place notre boucle de réconciliation !
Voici techniquement les étapes qu’elle va suivre :
- Lister les secrets ;
- Traiter les secrets qui ne l’ont pas déjà été.
Pour rappel, on ne veut pas traiter que le secret ayant déclenché la boucle, mais tous les secrets. Ainsi, directement dans le package main
, on va rajouter la fonction reconcile
:
func reconcile(clientset *kubernetes.Clientset) error {
kubeSecrets, err := clientset.CoreV1().Secrets("").List(context.Background(), metav1.ListOptions{})
if err != nil {
klog.Info(fmt.Sprintf("Error listing secrets: %v", err))
return err
}
klog.Info(fmt.Sprintf("Found %d secrets\n", len(kubeSecrets.Items)))
for _, secret := range kubeSecrets.Items {
if secrets.IsSecretManaged(secret) {
secrets.HandleSecrets(clientset, secret)
}
}
return nil
}
Ta-da ! Nous avons notre boucle de réconciliation. Notre opérateur est maintenant capable de patcher les secrets dès qu’ils sont créés ou modifiés pour peu qu’ils possèdent les annotations nécessaires.
Mais le travail est loin d’être terminé, il nous reste encore du pain sur la planche.
Gestion des signaux
Dès lors qu’on ship une application sur Kubernetes, il est important de gérer au mieux les douze cloud-factors regroupant les bonnes pratiques à suivre pour une application en cloud. Parmi ces règles, on retrouve à la onzième place : “Maximisez la robustesse avec des démarrages rapides et des arrêts gracieux”, et cela s’applique bien évidemment à notre opérateur.
Concrètement, ça va être assez simple : on va écouter les signaux envoyés par Kubernetes pour arrêter proprement notre application en prenant soin de terminer les traitements en cours (pour ne pas s’arrêter en plein milieu d’une réconciliation).
Actuellement, avec notre pauvre select{}
et la variable endSignal
(qui n’est jamais mise à jour), notre opérateur ne fait rien d’autre que de rester en vie et ne gère pas les signaux. Un arrêt brutal de l’opérateur est la seule issue.
Pour corriger ça, nous pouvons créer un canal qui va écouter les signaux d’arrêt et qui va envoyer un signal à notre boucle de réconciliation pour qu’elle s’arrête proprement.
Ainsi, juste après avoir déclaré nos informers, ajoutons le code suivant :
// ...
secretInformer.AddEventHandler(secretHandler)
endSignal := make(chan struct{})
go secretInformer.Run(endSignal)
defer close(endSignal)
// Gestions des signaux
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
<-sigChan
endSignal <- struct{}{}
fmt.Println("Received shutdown signal, exiting...")
}
Bye-bye select{}
, on est désormais en attente de sigChan
qui attend lui-même un signal d’arrêt donné par signal.Notify()
.
Il ne nous reste plus qu’à implémenter l’attente de la fin du job en cours après reception du signal d’arrêt.
+ working := false
secretHandler := cache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) {
secret, ok := obj.(*v1.Secret)
if !ok {
log.Println("Failed to cast to Secret")
return
}
if secret.CreationTimestamp.Time.Before(startTime) {
return
}
if secrets.IsSecretManaged(*secret) {
+ working = true
klog.Info(fmt.Sprintf("Added, Found secret %s", secret.Name))
err = reconcile(clientset)
if err != nil {
klog.Info(fmt.Sprintf("Error reconciling secrets: %v", err))
}
+ working = false
}
},
UpdateFunc: func(_, newObj interface{}) {
secret, ok := newObj.(*v1.Secret)
if !ok {
log.Println("Failed to cast to Secret")
return
}
if secrets.IsSecretManaged(*secret) {
+ working = true
klog.Info(fmt.Sprintf("Updated, Found secret %s", secret.Name))
err = reconcile(clientset)
if err != nil {
klog.Info(fmt.Sprintf("Error reconciling secrets: %v", err))
}
+ working = false
}
},
}
// ...
<-sigChan
endSignal <- struct{}{}
klog.Info("Received shutdown signal, exiting...")
+ for range [10]int{} {
+ if !working {
+ klog.Warning("Job ended, terminating...")
+ break
+ }
+ time.Sleep(2 * time.Second)
+ klog.Warning("Waiting for job to end...")
+ }
+ klog.Info("End of the program")
Grâce à ce petit bout de code, on gère maintenant les arrêts de notre opérateur, et on ne craint plus un rollout
qui prendrait un temps fou si on ne gérait pas les signaux 😌 (sans ça, à chaque demande d’arrêt, le Kubelet enverrait un SIGTERM qui échouerait, et un SIGKILL serait envoyé 30 secondes plus tard).
Mais d’ailleurs, si on fait un rollout, par défaut Kubernetes va déployer une nouvelle version de notre opérateur avant de stopper l’ancienne (sauf si on utilise le strategy.Type:Recreate
). Cela veut dire qu’il va y avoir un moment où deux versions vont tourner en même temps… c’est catastrophique si elles commencent à se marcher sur les pieds.
Traitons cette partie ensemble pour éviter tout problème (et même mieux: gérer la haute disponibilité de notre opérateur).
Haute disponibilité (leader election)
Actuellement, si je déploie deux versions de mon opérateur, les deux vont réagir aux mêmes évenements. Dès la création d’un secret, les deux instances vont vouloir le traiter alors qu’une seule devrait le faire. Il y a même de fortes chances que les deux opérateurs patchent le secret en même temps, ce qui va créer un conflit.
Bref, on ne veut pas qu’il y ait deux pods qui traitent les secrets en même temps.
Durant l’écriture de mon article sur NATS, j’étais tombé sur ce projet GitHub qui utilise le SDK NATS comme leader election pour créer un cluster Raft. C’est exactement ce qu’on pourrait faire, mais j’ai trouvé une nouvelle méthode qui semble un peu plus simple à mettre en place : le leader election de Kubernetes.
Ça fait maintenant un moment que je garde cet article dans mes favoris dans l’espoir de vous le présenter un jour. That’s my moment !
L’idée incroyable de cet article est d’utiliser les baux (leases) de Kubernetes pour élire un leader. Cela nous permet de choisir un pod spécifique pour traiter les secrets et de ne pas avoir à gérer nous-mêmes la logique de leader election.
Le pod leader va régulièrement renouveler son bail pour garder son rôle tandis que les autres pods vont essayer de lui prendre. Si le leader ne renouvelle pas son bail à temps, un autre pod prendra sa place.
Je vais alors vous montrer le fichier kube.go
qui contient à la fois la définition de notre client Kubernetes, et la logique de leader election. Je vais également modifier le package main
pour intégrer le fait d’attendre d’être leader avant de démarrer la réconciliation.
J’ai décidé de simplifier nos interactions avec le client Kubernetes en dédiant un package à cela. C’est plus facile à maintenir.
package kube
type (
KubeClient struct {
Clientset *kubernetes.Clientset
Cfg *rest.Config
el *leaderelection.LeaderElector
namespace string
}
)
const (
lockName = "random-secret"
)
var (
identity = fmt.Sprintf("%d", os.Getpid())
// identity = identity = os.Getenv("HOSTNAME") // Lorsque nous serons dans un pod Kubernetes
)
func NewClient() *KubeClient {
client := &KubeClient{
Cfg: getConfig(),
}
var err error
client.Clientset, err = kubernetes.NewForConfig(client.Cfg)
if err != nil {
klog.Info(fmt.Sprintf("Error creating clientset: %v", err))
}
client.namespace = os.Getenv("NAMESPACE")
if client.namespace == "" {
client.namespace = "default"
}
return client
}
func getConfig() *rest.Config {
cfg, err := rest.InClusterConfig()
if err != nil {
var kubeconfigPath string
if os.Getenv("KUBECONFIG") != "" {
kubeconfigPath = os.Getenv("KUBECONFIG")
} else {
homeDir, err := os.UserHomeDir()
if err != nil {
log.Fatalf("Error getting user home directory: %v", err)
}
kubeconfigPath = filepath.Join(homeDir, ".kube", "config")
}
klog.Info("Using kubeconfig: ", kubeconfigPath)
flag.Parse()
cfg, err = clientcmd.BuildConfigFromFlags("", kubeconfigPath)
if err != nil {
klog.Fatalf("Error building kubeconfig: %v", err)
}
}
return cfg
}
func (k *KubeClient) LeaderElection() {
cfg, err := ctrl.GetConfig()
if err != nil {
panic(err.Error())
}
l, err := rl.NewFromKubeconfig(
rl.LeasesResourceLock,
k.namespace,
lockName,
rl.ResourceLockConfig{
Identity: identity,
},
cfg,
time.Second*10,
)
if err != nil {
panic(err)
}
el, err := leaderelection.NewLeaderElector(leaderelection.LeaderElectionConfig{
Lock: l,
LeaseDuration: time.Second * 15,
RenewDeadline: time.Second * 10,
RetryPeriod: time.Second * 2,
Name: lockName,
Callbacks: leaderelection.LeaderCallbacks{
OnStartedLeading: func(ctx context.Context) { klog.Info("I am the leader!") },
OnStoppedLeading: func() { klog.Info("I am not the leader anymore!") },
OnNewLeader: func(identity string) { klog.Info("the leader is %s\n", identity) },
},
})
if err != nil {
panic(err)
}
go el.Run(context.Background())
k.el = el
for !el.IsLeader() {
klog.Info("Waiting to become leader")
time.Sleep(2 * time.Second)
}
}
func (k *KubeClient) CheckLeader() bool {
return k.el.IsLeader()
}
func (k *KubeClient) WaitForLeader() {
for !k.el.IsLeader() {
klog.Info("Waiting to become leader")
time.Sleep(2 * time.Second)
}
}
Détaillons un peu ce code :
NewClient
: Crée un client Kubernetes qu’on pourra appeler pour récupérer le clientset ;LeaderElection
: Interroge l’API-Server pour créer ou obtenir un lease et déterminer si le pod est leader ;CheckLeader
: Vérifie si le pod est leader ;WaitForLeader
: Attend que le pod soit leader.
Dans ce système là, nous demandons des baux de 15 secondes, renouvelés toutes les 10 secondes.
func main() {
k := kube.NewClient()
+ k.LeaderElection()
// La suite du code n'est exécutée que si le pod est leader
clientset := k.Clientset
err := reconcile(clientset)
if err != nil {
klog.Info(fmt.Sprintf("Error reconciling secrets: %v", err))
}
secretHandler := cache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) {
+ if !k.CheckLeader() {
+ k.WaitForLeader()
+ }
secret, ok := obj.(*v1.Secret)
if !ok {
log.Println("Failed to cast to Secret")
return
}
if secret.CreationTimestamp.Time.Before(startTime) {
return
}
if secrets.IsSecretManaged(*secret) {
working = true
klog.Info(fmt.Sprintf("Added, Found secret %s", secret.Name))
err = reconcile(clientset)
if err != nil {
klog.Info(fmt.Sprintf("Error reconciling secrets: %v", err))
}
working = false
}
},
UpdateFunc: func(_, newObj interface{}) {
+ if !k.CheckLeader() {
+ k.WaitForLeader()
+ }
secret, ok := newObj.(*v1.Secret)
if !ok {
log.Println("Failed to cast to Secret")
return
}
if secrets.IsSecretManaged(*secret) {
working = true
klog.Info(fmt.Sprintf("Updated, Found secret %s", secret.Name))
err = reconcile(clientset)
if err != nil {
klog.Info(fmt.Sprintf("Error reconciling secrets: %v", err))
}
working = false
}
},
}
// ...
Le système vu ci-dessus n’est pas entièrement infaillible. L’article dans lequel j’ai découvert cette pratique explique que nous pouvons tomber dans un cas complexe où le leader traite un secret, le pod est stoppé temporairement, un autre pod devient leader et traite le même secret tandis que le premier pod recommence à le traiter également. C’est un cas de figure assez complexe, mais qui peut arriver. Afin de l’éviter, j’ai ajouté une vérification avant le traitement d’un secret pour s’assurer que le pod est bien leader (mais ça n’est quand même pas infaillible).
J’ai trouvé cette astuce incroyablement pratique et simple à mettre en place (grâce au fait que nous déléguons la logique de leader election à Kubernetes). Grâce à ça, on peut théoriquement scale notre opérateur sans craindre de conflit.
On valide tout ça avec un petit test :
Dans cette démo, j’utilise mon laptop de développement pour simuler plusieurs pods, on utilise ainsi le PID pour identifier le pod leader. Dans un cluster Kubernetes, on doit plutôt utiliser l’hostname du pod puisque le PID est toujours le même pour tous les pods.
Voila 🎉, on gère maintenant la haute disponibilité sur notre opérateur !
Créer nos propres ressources (CRD)
Pour le moment, notre opérateur se contente “uniquement” de patcher les secrets. Mais on pourrait aller plus loin et créer nos propres ressources pour déclencher la génération de secrets. Cela nous permettrait de gérer les secrets de manière plus fine et de ne pas avoir à les patcher.
Pour cela, nous devons nous plonger dans les Custom Ressources Definition (CRD) de Kubernetes. Commençons par expliquer ce qu’est une CRD :
Une Custom Ressource Definition (CRD) est une ressource Kubernetes que nous allons pouvoir définir nous-mêmes. C’est un schéma nous permettant de créer nos propres ressources qui n’existent pas nativement dans Kubernetes.
Imaginons que nous voulons créer une ressource RandomSecret
pour générer des secrets aléatoires. Nous allons devoir définir le schéma de cette ressource puis créer un controller qui va gérer cette ressource.
Écrivons l’objet RandomSecret
attendu :
apiVersion: secret.a-cup-of.coffee/v1
kind: RandomSecret
metadata:
name: random-secret
spec:
key: randomSecret
length: 32
secretName: random-secret
specialChar: true
static:
key: value1
key2: value2
key3: value3
Voici le schéma de notre ressource RandomSecret
:
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: randomsecrets.secret.a-cup-of.coffee
spec:
group: secret.a-cup-of.coffee
names:
kind: RandomSecret
plural: randomsecrets
singular: randomsecret
shortNames:
- rasec
- ransec
- ras
- ran
scope: Namespaced
versions:
- name: v1
served: true
storage: true
schema:
openAPIV3Schema:
type: object
properties:
apiVersion:
type: string
enum:
- secret.a-cup-of.coffee/v1
kind:
type: string
enum:
- RandomSecret
metadata:
type: object
spec:
type: object
properties:
secretName:
type: string
description: The name of the secret to create.
key:
type: string
description: The key under which the secret value will be stored.
length:
type: integer
description: Length of the generated secret.
minimum: 1
specialChar:
type: boolean
description: Whether special characters should be included in the secret.
static:
type: object
additionalProperties:
type: string
description: A map of static key-value pairs to include in the secret.
required:
- secretName
- key
- length
À partir de la Custom Resource Definition ci-dessus, on définit l’API secret.a-cup-of.coffee/v1
de notre ressource Kind: RandomSecret
. Les champs obligatoires sont key
, length
et secretName
, tandis que specialChar
et static
sont optionnels.
Créons notre premier RandomSecret :
kubectl apply -f - <<EOF
apiVersion: secret.a-cup-of.coffee/v1
kind: RandomSecret
metadata:
name: random-secret
namespace: default
spec:
key: randomSecret
secretName: random-secret
length: 32
EOF
Vérifier que notre ressource a bien été créée :
kubectl get ransec
NAME AGE
random-secret 16s
C’est bien, mais il manque quelque chose… J’aimerais voir quel est le secret généré directement depuis kubectl sans devoir ouvrir le yaml de la ressource. Je vais donc modifier le CRD pour ajouter le champ additionalPrinterColumns
:
spec:
version:
- name: v1
additionalPrinterColumns:
- name: Secret
type: string
description: The name of the secret to create.
jsonPath: .spec.secretName
- name: Age
type: date
description: The time at which the secret was created.
jsonPath: .metadata.creationTimestamp
Voici le résultat :
kubectl get ransec
NAME SECRET AGE
random-secret random-secret 71m
C’est vachement mieux ! Mais on ne fait que créer une ressource qui ne fait rien pour le moment, il nous reste quand même à créer un controller (notre opérateur) qui va gérer cette ressource et créer les secrets correspondants.
Controller pour les CRDs
Pour gérer nos nouvelles ressources, nous allons devoir, tout comme pour les secrets, écouter les événements de création et de modification. Pour cela, nous allons devoir définir un dynamicClient
, cela ne change pas réellement de ce que nous avons déjà fait à la seule différence qu’on ne surveille pas une ressource custom comme une ressource native. Ce client va nous permettre de communiquer avec l’API-Server à partir d’un schéma représentant notre ressource (là où la librairie clientset
n’utilise que des ressources natives).
randomSecretGVR = schema.GroupVersionResource{
Group: "secret.a-cup-of.coffee",
Version: "v1",
Resource: "randomsecrets",
}
randomSecretWatch := &cache.ListWatch{
ListFunc: func(options metav1.ListOptions) (runtime.Object, error) {
return dynamicClient.Resource(randomSecretGVR).Namespace(metav1.NamespaceAll).List(context.Background(), options)
},
WatchFunc: func(options metav1.ListOptions) (watch.Interface, error) {
return dynamicClient.Resource(randomSecretGVR).Namespace(metav1.NamespaceAll).Watch(context.Background(), options)
},
}
randomSecretHandler := cache.ResourceEventHandlerFuncs{
AddFunc: func(obj interface{}) {
if !k.CheckLeader() {
k.WaitForLeader()
}
unstructuredObj, ok := obj.(runtime.Object)
if !ok {
log.Println("Failed to cast to Unstructured object")
return
}
randomSecret, err := randomsecrets.ToRandomSecret(unstructuredObj)
if err != nil {
log.Printf("Failed to convert object to RandomSecret: %v\n", err)
return
}
if randomSecret.CreationTimestamp.Before(startTime) {
return
}
klog.Infof("Added, Found RandomSecret %s", randomSecret.Name)
working = true
err = randomsecrets.ReconcileRandomSecrets(k, randomSecretGVR)
if err != nil {
klog.Infof("Error reconciling RandomSecrets: %v", err)
}
working = false
},
UpdateFunc: func(_, newObj interface{}) {
if !k.CheckLeader() {
k.WaitForLeader()
}
unstructuredObj, ok := newObj.(runtime.Object)
if !ok {
log.Println("Failed to cast to Unstructured object")
return
}
randomSecret, err := randomsecrets.ToRandomSecret(unstructuredObj)
if err != nil {
log.Printf("Failed to convert object to RandomSecret: %v\n", err)
return
}
klog.Infof("Updated, Found RandomSecret %s", randomSecret.Name)
working = true
err = randomsecrets.ReconcileRandomSecrets(k, randomSecretGVR)
if err != nil {
klog.Infof("Error reconciling RandomSecrets: %v", err)
}
working = false
},
}
secretInformer := cache.NewSharedInformer(secretWatch, &v1.Secret{}, 0)
secretInformer.AddEventHandler(secretHandler)
randomSecretInformer := cache.NewSharedInformer(randomSecretWatch, &unstructured.Unstructured{}, 0)
randomSecretInformer.AddEventHandler(randomSecretHandler)
type (
KubeClient struct {
Clientset *kubernetes.Clientset
Cfg *rest.Config
el *leaderelection.LeaderElector
namespace string
+ DynamicClient dynamic.Interface
}
)
func NewClient() *KubeClient {
client := &KubeClient{
Cfg: getConfig(),
}
var err error
client.Clientset, err = kubernetes.NewForConfig(client.Cfg)
if err != nil {
klog.Fatal(fmt.Sprintf("Error creating clientset: %v", err))
}
client.namespace = os.Getenv("NAMESPACE")
if client.namespace == "" {
client.namespace = "default"
}
+ client.DynamicClient, err = dynamic.NewForConfig(client.Cfg)
+ if err != nil {
+ klog.Fatal("Failed to create dynamic client: %v", err)
+ }
return client
}
Il ne reste plus qu’à intégrer la logique de génération de secret dans notre controller (et oui, pour l’instant la fonction randomsecrets.ToRandomSecret
n’existe pas). On va alors créer un package randomsecrets
qui va contenir les fonctions nécessaires à la convertion de notre ressource en objet Secret
et à la génération du secret.
Maintenant, nous devons apprendre à réagir lorsqu’un package RandomSecret
est créé ou modifié. Nous commençons par créer une méthode pour convertir une ressource Kubernetes en struct RandomSecret
:
package randomsecrets
func ToRandomSecret(unstructuredObj runtime.Object) (types.RandomSecret, error) {
// On aurait pu utiliser un yaml.Unmarshal pour parser l'objet unstructuredObj
// mais j'ai choisi de le faire à la main pour plus de clarté
var randomSecret types.RandomSecret
objMap, err := runtime.DefaultUnstructuredConverter.ToUnstructured(unstructuredObj)
if err != nil {
return randomSecret, err
}
creationTimestampStr := objMap["metadata"].(map[string]interface{})["creationTimestamp"].(string)
creationTimestamp, err := time.Parse(time.RFC3339, creationTimestampStr)
if err != nil {
return randomSecret, err
}
name := objMap["metadata"].(map[string]interface{})["name"].(string)
specMap := objMap["spec"].(map[string]interface{})
length := specMap["length"].(int64)
var specialChar bool
if specMap["specialChar"] == nil {
specialChar = true
} else {
specialChar = specMap["specialChar"].(bool)
}
key := specMap["key"].(string)
secretName := specMap["secretName"].(string)
var static map[string]string
if specMap["static"] != nil {
static = specMap["static"].(map[string]string)
}
NameSpace := objMap["metadata"].(map[string]interface{})["namespace"].(string)
randomSecret = types.RandomSecret{
Name: name,
Length: length,
SpecialChar: specialChar,
Key: key,
SecretName: secretName,
Static: static,
CreationTimestamp: creationTimestamp,
NameSpace: NameSpace,
}
return randomSecret, nil
}
func ReconcileRandomSecrets(k *kube.KubeClient, randomSecretGVR schema.GroupVersionResource) error {
dynamicClient := k.DynamicClient
randomSecretsList, err := dynamicClient.Resource(randomSecretGVR).Namespace("").List(context.Background(), metav1.ListOptions{})
if err != nil {
klog.Infof("Error listing RandomSecrets: %v", err)
return err
}
klog.Infof("Found %d RandomSecrets\n", len(randomSecretsList.Items))
for _, item := range randomSecretsList.Items {
randomSecret, err := ToRandomSecret(&item)
if err != nil {
klog.Infof("Failed to parse RandomSecret: %v", err)
continue
}
isSecretAlreadyCreated, err := k.IsSecretCreated(randomSecret.Name, randomSecret.NameSpace)
if err != nil {
klog.Infof("Failed to check if secret %s is already created: %v", randomSecret.Name, err)
continue
}
if !isSecretAlreadyCreated {
err = k.CreateSecret(randomSecret)
if err != nil {
klog.Infof("Failed to create secret %s: %v", randomSecret.Name, err)
}
} else {
klog.Infof("Secret %s already exists", randomSecret.Name)
}
}
return nil
}
package kube
// ...
func (k *KubeClient) CreateSecret(randomSecret types.RandomSecret) error {
spec := make(map[string]string)
spec[randomSecret.Key] = secrets.GenerateRandomSecret(int(randomSecret.Length), randomSecret.SpecialChar)
for k, v := range randomSecret.Static {
spec[k] = v
}
secret := &v1.Secret{
ObjectMeta: metav1.ObjectMeta{
Name: randomSecret.Name,
Namespace: randomSecret.NameSpace,
},
StringData: spec,
}
clientset := k.Clientset
_, err := clientset.CoreV1().Secrets(randomSecret.NameSpace).Create(context.Background(), secret, metav1.CreateOptions{})
if err != nil {
return fmt.Errorf("failed to create secret: %v", err)
}
fmt.Printf("Secret %s created in namespace %s\n", randomSecret.Name, randomSecret.NameSpace)
return nil
}
func (k *KubeClient) IsSecretCreated(secretName string, namespace string) (bool, error) {
clientset := k.Clientset
_, err := clientset.CoreV1().Secrets(namespace).Get(context.TODO(), secretName, metav1.GetOptions{})
if err != nil {
if kerror.IsNotFound(err) {
return false, nil
}
return false, err
}
return true, nil
}
Reste une dernière chose à faire, j’aimerais bien que notre controller puisse afficher un Ready pour indiquer que la ressource a bien été traitée.
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
name: randomsecrets.secret.a-cup-of.coffee
spec:
group: secret.a-cup-of.coffee
names:
kind: RandomSecret
plural: randomsecrets
singular: randomsecret
shortNames:
- rasec
- ransec
- rsec
- ras
- ran
scope: Namespaced
versions:
- name: v1
served: true
storage: true
additionalPrinterColumns:
- name: Secret
type: string
description: The name of the secret to create.
jsonPath: .spec.secretName
- name: Age
type: date
description: The time at which the secret was created.
jsonPath: .metadata.creationTimestamp
+ - name: State
+ type: string
+ description: The current state of the secret.
+ jsonPath: .status.state
schema:
openAPIV3Schema:
type: object
properties:
apiVersion:
type: string
enum:
- secret.a-cup-of.coffee/v1
kind:
type: string
enum:
- RandomSecret
metadata:
type: object
+ status:
+ type: object
+ properties:
+ state:
+ type: string
+ description: The current state of the secret.
spec:
type: object
properties:
secretName:
type: string
description: The name of the secret to create.
key:
type: string
description: The key under which the secret value will be stored.
length:
type: integer
description: Length of the generated secret.
minimum: 1
specialChar:
type: boolean
description: Whether special characters should be included in the secret.
static:
type: object
additionalProperties:
type: string
description: A map of static key-value pairs to include in the secret.
required:
- secretName
- key
- length
func ReconcileRandomSecrets(k *kube.KubeClient, randomSecretGVR schema.GroupVersionResource) error {
dynamicClient := k.DynamicClient
randomSecretsList, err := dynamicClient.Resource(randomSecretGVR).Namespace("").List(context.Background(), metav1.ListOptions{})
if err != nil {
klog.Infof("Error listing RandomSecrets: %v", err)
return err
}
klog.Infof("Found %d RandomSecrets\n", len(randomSecretsList.Items))
for _, item := range randomSecretsList.Items {
randomSecret, err := ToRandomSecret(&item)
if err != nil {
klog.Infof("Failed to parse RandomSecret: %v", err)
continue
}
isSecretAlreadyCreated, err := k.IsSecretCreated(randomSecret.Name, randomSecret.NameSpace)
if err != nil {
klog.Infof("Failed to check if secret %s is already created: %v", randomSecret.Name, err)
continue
}
if !isSecretAlreadyCreated {
err = k.CreateSecret(randomSecret)
if err != nil {
klog.Infof("Failed to create secret %s: %v", randomSecret.Name, err)
updateStatusReady(randomSecret, k, false)
}
updateStatusReady(randomSecret, k, true)
} else {
updateStatusReady(randomSecret, k, true)
klog.Infof("Secret %s already exists", randomSecret.Name)
}
}
return nil
}
func updateStatusReady(randomSecret types.RandomSecret, k *kube.KubeClient, ready bool) {
dynamicClient := k.DynamicClient
randomSecretGVR := types.RandomSecretGVR
randomSecretObj, err := dynamicClient.Resource(randomSecretGVR).Namespace(randomSecret.NameSpace).Get(context.Background(), randomSecret.Name, metav1.GetOptions{})
if err != nil {
klog.Infof("Failed to get RandomSecret %s: %v", randomSecret.Name, err)
return
}
readyString := "NotReady"
if ready {
readyString = "Ready"
}
randomSecretObj.Object["status"] = map[string]interface{}{
"state": readyString,
}
_, err = dynamicClient.Resource(randomSecretGVR).Namespace(randomSecret.NameSpace).Update(context.Background(), randomSecretObj, metav1.UpdateOptions{})
if err != nil {
klog.Infof("Failed to update status of RandomSecret %s: %v", randomSecret.Name, err)
}
}
Nous pouvons désormais voir l’état de notre ressource directement depuis kubectl :
kubectl get randomsecrets
NAME SECRET AGE STATE
random-secret random-secret 95s Ready
MutatingWebhook
Si nous ne voulons pas utiliser notre nouvelle ressource “RandomSecrets” pour générer des secrets, nous pouvons toujours créer notre secret en appliquant nos annotations afin d’informer notre opérateur de la modification à effectuer. Je vous avais parlé d’une potentielle alternative évitant d’avoir à patcher les secrets a posteriori. Sortons alors l’artillerie lourde et utilisons un MutatingWebhook afin de modifier le secret à la volée directement lors de sa création. C’est techniquement plus compliqué à mettre en place, mais on diminue les actions nécessaires à l’application de la modification.
C’est quoi un MutatingWebhook ?
Commençons par le commencement : qu’est-ce qu’un MutatingWebhook ?. Lorsqu’on envoie une requête de création ou de modification de ressource à l’API-Server, notre requête passe par de nombreux composants comme le MutatingAdmissionController
qui va contacter les MutatingWebhook
pour modifier la requête avant qu’elle ne passe dans les mains des autres controllers.
Ainsi, grâce à un MutatingWebhook, nous allons pouvoir modifier la requête de création d’un secret afin d’ajouter notre chaine de caractères aléatoire. Cela nous permettra de ne pas avoir à écouter les événements de création des secrets et de ne pas avoir à les patcher nous-mêmes.
Avant de nous concentrer sur le code, voyons comment configurer notre MutatingWebhook :
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
name: random-secret-mutating-webhook
webhooks:
- name: random-secret.a-cup-of.coffee
clientConfig:
service:
name: random-secret-operator
namespace: default
path: /mutate
caBundle: "onverracaplustard"
rules:
- operations: ["CREATE", "UPDATE"]
apiGroups: [""]
apiVersions: ["v1"]
resources: ["secrets"]
admissionReviewVersions: ["v1"]
sideEffects: None
timeoutSeconds: 10
Pour résumer :
- On crée un
MutatingWebhookConfiguration
qui va pointer vers le servicerandom-secret
dans le namespacedefault
; - On appelle le MutatingWebhook pour les opérations de création et de modification des secrets ;
- On demande à ce que Kubernetes utilise un CA pour valider le certificat du serveur web. (on règlera ça plus tard).
Côté code, nous allons devoir exposer un serveur web (et créer le service correspondant dans le cluster).
Ce qui est assez marrant, c’est qu’il n’est plus nécessaire de faire des requêtes à l’API-Server, c’est plutôt elle qui va nous en envoyer.
Notre MutatingWebhook
Commençons par répondre à la question du “sous quel format va-t’on recevoir les requêtes de l’API-Server ?” en présentant le format de la requête d’admission :
kind: AdmissionReview
apiVersion: admission.k8s.io/v1
request:
uid: '12345'
kind:
group: ''
version: v1
kind: Secret
resource:
group: ''
version: v1
resource: secrets
name: test-secret
namespace: default
operation: CREATE
object:
apiVersion: v1
kind: Secret
metadata:
name: test-secret
namespace: default
annotations:
secret.a-cup-of.coffee/enable: 'true'
data:
key: dmFsdWU=
Dans cet objet, nous retrouvons à la fois le contexte de la requête (le nom de la ressource, le namespace, l’opération, etc.) et l’objet à muter (.request.object
). C’est ce dernier que nous allons devoir modifier, s’il possède nos annotations, pour ajouter notre chaine de caractères aléatoire.
Qu’est-ce qu’on répond à l’API-Server ? Le même objet, mais en ajoutant le champ “response” qui définit si la requête est autorisée et les patchs à appliquer sous le format JSONPatch [{"op": "add", "path": "/data/password", "value": "cGFzc3dvcmQx"}]
kind: AdmissionReview
apiVersion: admission.k8s.io/v1
request:
uid: '12345'
kind:
group: ''
version: v1
kind: Secret
resource:
group: ''
version: v1
resource: secrets
name: test-secret
namespace: default
operation: CREATE
userInfo: {}
object:
apiVersion: v1
kind: Secret
metadata:
name: test-secret
namespace: default
annotations:
secret.a-cup-of.coffee/enable: 'true'
labels:
existing-label: value
data:
key: dmFsdWU=
oldObject:
options:
+response:
+ uid: '12345'
+ allowed: true
+ patch: W3sib3AiOiAiYWRkIiwgInBhdGgiOiAiL2RhdGEvcGFzc3dvcmQiLCAidmFsdWUiOiAiY0dGemMzZHZjbVF4In1d
Nous allons implémenter un nouveau package mutatingwebhook
contenant notre serveur web et les fonctions pour traiter les requêtes de l’API-Server. Voici le code de notre serveur web :
package mutating
// ...
func Run() {
http.HandleFunc("/mutate", func(w http.ResponseWriter, r *http.Request) {
var admissionReview admissionv1.AdmissionReview
if err := json.NewDecoder(r.Body).Decode(&admissionReview); err != nil {
http.Error(w, fmt.Sprintf("Error decoding request: %v", err), http.StatusBadRequest)
return
}
klog.Infof("Received request: %s in namespace %s", admissionReview.Request.Name, admissionReview.Request.Namespace)
decoder := admission.NewDecoder(runtime.NewScheme())
patchType := admissionv1.PatchTypeJSONPatch
admissionResponse := admissionv1.AdmissionResponse{
UID: admissionReview.Request.UID,
Allowed: true,
PatchType: &patchType,
}
secret := &corev1.Secret{}
if err := decoder.DecodeRaw(admissionReview.Request.Object, secret); err != nil {
http.Error(w, fmt.Sprintf("Error decoding secret: %v", err), http.StatusBadRequest)
return
}
if !secrets.IsSecretManaged(*secret) {
klog.Infof("Secret %s is not managed, returning", secret.Name)
patchBytes, err := json.Marshal(secret)
if err != nil {
http.Error(w, fmt.Sprintf("Error marshalling patch: %v", err), http.StatusInternalServerError)
return
}
admissionResponse.Patch = patchBytes
admissionReview.Response = &admissionResponse
if err := json.NewEncoder(w).Encode(admissionReview); err != nil {
http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
return
}
return
}
admissionResponse.Patch = secrets.GeneratePatch(*secret)
admissionResponse.Result = &metav1.Status{
Status: metav1.StatusSuccess,
}
admissionReview.Response = &admissionResponse
klog.Infof("Patching secret %s in namespace %s", secret.Name, secret.Namespace)
klog.Infof("Response: %s", string(admissionReview.Response.Patch))
if err := json.NewEncoder(w).Encode(admissionReview); err != nil {
http.Error(w, fmt.Sprintf("Error encoding response: %v", err), http.StatusInternalServerError)
return
}
})
klog.Info("Starting webhook server on :8080")
if err := http.ListenAndServe(":8080", nil); err != nil {
klog.Errorf("Failed to start server: %v\n", err)
}
}
package main
// ...
func main() {
k := kube.NewClient()
k.LeaderElection()
clientset := k.Clientset
dynamicClient := k.DynamicClient
go mutating.Run()
Pour tester que notre serveur web répond bien comme demandé, nous pouvons faire un curl
pour envoyer une requête à notre MutatingWebhook :
curl -k \
-X POST \
-H "Content-Type: application/json" \
-d '{
"kind": "AdmissionReview",
"apiVersion": "admission.k8s.io/v1",
"request": {
"uid": "12345",
"kind": {
"group": "",
"version": "v1",
"kind": "Secret"
},
"resource": {
"group": "",
"version": "v1",
"resource": "secrets"
},
"name": "test-secret",
"namespace": "default",
"operation": "CREATE",
"object": {
"apiVersion": "v1",
"kind": "Secret",
"metadata": {
"name": "test-secret",
"namespace": "default",
"annotations": {
"secret.a-cup-of.coffee/enable": "true"
},
"labels": {
"existing-label": "value"
}
},
"data": {
"key": "dmFsdWU="
}
}
}
}' \
http://localhost:8080/mutate | jq
Et pour tester l’objet qu’on renvoie :
curl -k \
-X POST \
-H "Content-Type: application/json" \
-d '{
"kind": "AdmissionReview",
"apiVersion": "admission.k8s.io/v1",
"request": {
"uid": "12345",
"kind": {
"group": "",
"version": "v1",
"kind": "Secret"
},
"resource": {
"group": "",
"version": "v1",
"resource": "secrets"
},
"name": "test-secret",
"namespace": "default",
"operation": "CREATE",
"object": {
"apiVersion": "v1",
"kind": "Secret",
"metadata": {
"name": "test-secret",
"namespace": "default",
"annotations": {
"secret.a-cup-of.coffee/enable": "true"
},
"labels": {
"existing-label": "value"
}
},
"data": {
"key": "dmFsdWU="
}
}
}
}' \
http://localhost:8080/mutate | jq .response.patch | base64 -d
C’est bon, on a réussi à créer notre MutatingWebhook, il ne reste plus qu’à tester ça dans Kubernetes, qu’est-ce qui pourrait mal se passer ? :-)
… en appliquant un secret, on se rend compte qu’il n’est pas modifié comme on le voudrait. Dans les logs de notre API-Server, on remarque qu’il y a un petit soucis :
W0123 18:54:04.701866 1 dispatcher.go:225] Failed calling webhook, failing closed random-secret.a-cup-of.coffee: failed calling webhook "random-secret.a-cup-of.coffee": failed to call webhook: Post "https://r
andom-secret-operator.default.svc:443/mutate?timeout=10s": no service port 443 found for service "random-secret-operator"
En y regardant de plus près, on se rend compte que nous devons absolument utiliser un certificat pour que l’API-Server puisse valider notre serveur web. C’est une étape obligatoire pour des raisons de sécurité auxquelles nous devons nous plier (même si on précise le port 8080, l’API-Server ne va pas se connecter si le certificat n’est pas valide).
Et là, c’est le drame…
Je vais vous remontrer la ressource MutatingWebhookConfiguration :
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
name: random-secret-mutating-webhook
webhooks:
- name: random-secret.a-cup-of.coffee
clientConfig:
service:
name: random-secret-operator
namespace: default
path: /mutate
caBundle: "onverracaplustard"
rules:
- operations: ["CREATE", "UPDATE"]
apiGroups: [""]
apiVersions: ["v1"]
resources: ["secrets"]
admissionReviewVersions: ["v1"]
sideEffects: None
timeoutSeconds: 10
En effet, il nous reste encore à créer le caBundle
pour notre MutatingWebhook. Croyez moi, ça ne va pas être une partie de plaisir. Ce champ détermine la clé publique que l’API-Server va devoir utiliser pour valider le certificat de notre serveur web.
Comment peut-on se débrouiller pour générer ce caBundle
? Une solution serait de demander à l’utilisateur de générer des certificats auto-signés avec openssl (ou via mkcert), puis de configurer l’opérateur ainsi que le MutatingWebhook pour utiliser les clés. C’est une solution qui fonctionne et assez simple à mettre en place, surtout si on passe par du Helm pour déployer notre opérateur.
On pourrait alors fournir un fichier values comme celui-ci:
mutatingWebhook:
caBundle: "-----BEGIN CERTIFICATE-----\njesuisunsupercaBundle..."
privateKey: "-----BEGIN PRIVATE-----\ncertificatsuperprivé...."
publicKey: "-----BEGIN PUBLIC-----\ncettesolutionmevapas...."
D’ailleurs,c’est ce que fait le prometheus-operator. On retrouve cette solution un peu partout étant donné qu’elle est la plus simple à mettre en place.
Je vais essayer de résumer mon feeling à propos de cette solution : je sais bien que 99% des personnes qui seront tentés d’utiliser mon opérateur ne vont pas générer de nouveaux certificats et vont utiliser les mêmes certificats que ceux par défaut. De plus, les trois utilisateurs qui voudront générer des certificats uniques vont se retrouver à devoir utiliser openssl
ou un autre outil pour générer des certificats.
On va donc essayer de se casser un peu la tête pour proposer une méthode qui ne nécessite aucune action venant de l’utilisateur tout en permettant d’avoir un certificat unique dans le secret.
En réalité, il n’y a pas de solution miracle : quelqu’un doit bien générer ce certificat (certains utilisent également cert-manager), mais la seule option pour que ce soit transparent pour l’utilisateur est de déléguer ça à un pod. En partant de ce principe, établissons un plan d’action.
Astuce
Comme dit ci-dessus cert-manager est capable de gérer pour nous la gestion des certificats, ce qui simplifie grandement cette étape. C’est une solution que je recommande totalement. Mais comme l’objectif est d’apprendre, je vais volontairement faire un peu plus compliqué.
Nous allons créer un job qui se chargera de générer les certificats, de les stocker dans un secret et de créer le MutatingWebhook en utilisant la clé publique du certificat dans le champ caBundle
. Le secret sera utilisé par notre pod, qui devra logiquement attendre que le job ait fini de générer les certificats pour se lancer.
On peut résumer notre plan à ceci :
De plus, nous devons également créer le serviceaccount, le Role et le RoleBinding pour que notre pod puisse créer le MutatingWebhook.
apiVersion: v1
kind: ServiceAccount
metadata:
name: random-secret-job-sa
namespace: default
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: mutating-webhook-clusterrole
rules:
- apiGroups: ["admissionregistration.k8s.io"]
resources: ["mutatingwebhookconfigurations"]
verbs: ["create"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: mutating-webhook-clusterrolebinding
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: mutating-webhook-clusterrole
subjects:
- kind: ServiceAccount
name: random-secret-job-sa
namespace: default
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: secrets-role
namespace: default
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["create", "get"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: secrets-rolebinding
namespace: default
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: secrets-role
subjects:
- kind: ServiceAccount
name: random-secret-job-sa
namespace: default
---
apiVersion: batch/v1
kind: Job
metadata:
name: random-secret-job-job
namespace: default
spec:
template:
metadata:
name: random-secret-job
spec:
serviceAccountName: random-secret-job-sa
containers:
- name: webhook-creator
image: nixery.dev/shell/kubectl/openssl
command:
- "/bin/sh"
- "-c"
- |
openssl genrsa -out ca.key 4096
openssl req -new -x509 -days 365 -key ca.key -subj "/C=FR/ST=01/L=Lyon/O=Coffee Inc." -out ca.crt
SERVICE=random-secret-operator
NAMESPACE=default
RELEASE_NAME=random-secret-operator
openssl req -newkey rsa:4096 -nodes -keyout tls.key -subj "/C=FR/ST=01/L=Lyon/O=Coffee Inc./CN=$SERVICE.$NAMESPACE.svc.cluster.local" -out tls.csr
openssl x509 -req -extfile <(printf "subjectAltName=DNS:$SERVICE.$NAMESPACE.svc.cluster.local,DNS:$SERVICE.$NAMESPACE.svc.cluster,DNS:$SERVICE.$NAMESPACE.svc,DNS:$SERVICE.$NAMESPACE.svc,DNS:$SERVICE.$NAMESPACE,DNS:$SERVICE") -days 3650 -in tls.csr -CA ca.crt -CAkey ca.key -CAcreateserial -out tls.crt
kubectl create secret tls $RELEASE_NAME --cert=tls.crt --key=tls.key -n $NAMESPACE
cat <<EOF | kubectl create -f -
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
name: random-secret-mutating-webhook
webhooks:
- name: random-secret.a-cup-of.coffee
clientConfig:
service:
name: random-secret-operator
namespace: $NAMESPACE
path: "/mutate"
caBundle: $(cat ca.crt | base64 | tr -d '\n')
rules:
- operations: ["CREATE", "UPDATE"]
apiGroups: [""]
apiVersions: ["v1"]
resources: ["secrets"]
admissionReviewVersions: ["v1"]
sideEffects: None
EOF
restartPolicy: OnFailure
Ne vous inquiétez pas des répétitions des namespaces et des noms de services. Nous simplifierons cela plus tard à l’aide de Helm.
Pour l’image, j’utilise nixery.dev/shell/kubectl/openssl
. Nixery est un service en ligne pour générer à la volée des images Docker contenant des outils présents dans les dépots Nix. Dans notre cas, nous n’avons besoin que de kubectl
et openssl
pour générer nos certificats et créer nos ressources.
Si j’applique ce job dans mon cluster, j’ai un nouveau secret random-secret-operator
de type kubernetes.io/tls
qui contient les clés privées et publiques de mon certificat.
$ kubectl get secret -o wide
NAME TYPE DATA AGE
random-secret-operator kubernetes.io/tls 2 3m2s
Il ne reste plus qu’à l’utiliser dans notre opérateur. Nous modifions donc le code pour qu’il expose son serveur web en HTTPS et qu’il utilise le certificat stocké dans le secret qui sera mappé dans /cert
.
apiVersion: apps/v1
kind: Deployment
metadata:
name: random-secret-operator
namespace: default
spec:
replicas: 1
selector:
matchLabels:
app: random-secret-operator
template:
metadata:
labels:
app: random-secret-operator
spec:
serviceAccountName: random-secret-operator
containers:
- name: random-secret-operator
image: ghcr.io/qjoly/randomsecret:dev
imagePullPolicy: Always
ports:
+ - containerPort: 443
+ name: https
resources:
limits:
memory: "512Mi"
cpu: "500m"
requests:
memory: "250Mi"
cpu: "250m"
+ volumeMounts:
+ - name: tls-certificates
+ mountPath: /certs
+ readOnly: true
+ volumes:
+ - name: tls-certificates
+ secret:
+ secretName: random-secret-operator
+ items:
+ - key: tls.crt
+ path: tls.crt
+ - key: tls.key
+ path: tls.key
On peut tester si notre certificat est bien utilisé via le client openssl :
$ kubectl port-forward svc/random-secret-operator 8443:443 &
$ openssl s_client -connect localhost:8443 2>/dev/null | grep "subject="
subject=/C=FR/ST=01/L=Lyon/O=Coffee Inc./CN=random-secret-operator.default.svc.cluster.local
Parfait ! On a bien notre certificat qui est utilisé pour exposer notre serveur en HTTPS.
Nous pouvons maintenant demander à l’API-Server de créer des secrets afin de voir si notre MutatingWebhook fonctionne correctement.
kubectl apply --dry-run=server -o yaml -f - <<EOF
apiVersion: v1
kind: Secret
metadata:
name: test-secret
annotations:
secret.a-cup-of.coffee/enable: "true"
data:
key: dmFsdWU=
EOF
Dernière étape : Chart helm
Terminons ce projet en rendant notre opérateur facilement déployable grâce à une chart Helm. Celui-ci se chargera de :
- Déployer le job pour générer les certificats et créer le MutatingWebhook ;
- Installer les CRDs ;
- Déployer l’opérateur.
Je ne vais pas vous détailler la création d’un chart Helm, en revanche, il y a un petit aspect que je voudrais aborder : la gestion d’une ressource externe (le mutatingwebhook) dans un chart Helm.
Effectivement, le mutating-webhook est une ressource externe à notre chart, mais si on désinstalle l’opérateur : on se retrouve avec un webhook qui ne redirige vers un service qui n’existe plus (à cause de cela, l’API-Server renverra une erreur à chaque création de secret). Pour supprimer cette ressource, il existe de nombreuses solutions, j’ai essayé de pré-créer le webhook/secret et utiliser notre job pour le mettre à jour (en changeant notre kubectl create
, par un kubectl patch
), mais je n’étais pas réellement satisfait de cette solution. J’ai donc migré vers une autre méthode : de la même manière qu’on crée le job pour ajouter notre webhook, on peut également utiliser un job pour le supprimer.
annotations:
helm.sh/hook: pre-delete
spec:
template:
metadata:
name: {{ include "random-operator.fullname" . }}-cleanup-job
spec:
serviceAccountName: {{ include "random-operator.fullname" . }}-cleanup-job
containers:
- name: webhook-creator
image: nixery.dev/shell/kubectl/openssl
command:
- "/bin/sh"
- "-c"
- |
set -e
kubectl delete secret -n {{ .Release.Namespace }} {{ .Values.secretName }}
kubectl delete mutatingwebhookconfiguration {{ include "random-operator.fullname" . }}-webhook
restartPolicy: OnFailure
Ainsi, lorsqu’on désinstalle notre opérateur, le job de suppression va supprimer le webhook et le secret associé. À savoir également que nous utilisons les hooks Helm pour décider de quand nos deux jobs vont se lancer :
- Le premier job (qui génère les certificats et crée le webhook) est lancé lors de l’installation de l’opérateur
helm.sh/hook: pre-install
; - Le second (qui supprime le webhook et le secret) est lancé lors de la désinstallation de l’opérateur
helm.sh/hook: pre-delete
.
Conclusion
Si vous souhaitez voir le code source en entier, vous pouvez le retrouver sur mon dépôt GitHub, n’hésitez pas à me faire des retours si vous avez des questions ou des suggestions.
Comme vous avez pu le voir, c’est techniquement pas si compliqué de créer un opérateur Kubernetes (en dehors de la maitrise des concepts Kubernetes, on a pas dû implémenter de logique très complexe). C’est un bon moyen d’apprendre comment fonctionne Kubernetes (et encore, nous aurions pu aller bien plus loin en ajoutant la gestion des OwnerReferences
, ou des Finalizer
pour gérer nos RandomSecrets). Comme dit en début d’article, le but premier de ce projet est avant tout un prétexte pour apprendre et le code actuel n’est pas forcément à utiliser dans un contexte professionnel.
Avoir appris à bricoler mon propre opérateur en Go me conforte dans l’idée que Kubernetes est très flexible et quoiqu’en disent les anti-Kubernetes, on peut souvent bricoler quelque chose pour arriver à nos fins.
Bon kawa ! ☕️