Pourquoi les déploiements Blue-Green ?

Avant de plonger dans les détails, rappelons rapidement pourquoi les déploiements blue-green sont si appréciés :

  • Déploiements sans interruption
  • Facilité de retour en arrière en cas de problème
  • Possibilité de tester dans un environnement proche de la production
  • Réduction des risques et du stress pour votre équipe d'exploitation

Maintenant, imaginez faire tout cela avec la puissance des opérateurs Kubernetes. Excité ? Vous devriez l'être !

Mise en place : Notre contrôleur personnalisé

Notre mission, si nous l'acceptons (et nous l'acceptons), est de créer un contrôleur personnalisé qui gère les déploiements blue-green. Ce contrôleur surveillera les changements de notre ressource personnalisée et orchestrera le processus de déploiement.

Tout d'abord, définissons notre ressource personnalisée :

apiVersion: mycompany.com/v1
kind: BlueGreenDeployment
metadata:
  name: my-awesome-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: my-awesome-app
  template:
    metadata:
      labels:
        app: my-awesome-app
    spec:
      containers:
      - name: my-awesome-app
        image: myregistry.com/my-awesome-app:v1
        ports:
        - containerPort: 8080

Rien de très compliqué ici, juste un déploiement Kubernetes standard avec une petite touche - c'est notre type de ressource personnalisé !

Le cœur du sujet : La logique du contrôleur

Maintenant, plongeons dans la logique du contrôleur. Nous utiliserons Go parce que, eh bien, c'est magnifique (désolé, je n'ai pas pu résister).


package controller

import (
	"context"
	"fmt"
	"time"

	appsv1 "k8s.io/api/apps/v1"
	corev1 "k8s.io/api/core/v1"
	"k8s.io/apimachinery/pkg/api/errors"
	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
	"k8s.io/apimachinery/pkg/runtime"
	"k8s.io/apimachinery/pkg/types"
	"sigs.k8s.io/controller-runtime/pkg/client"
	"sigs.k8s.io/controller-runtime/pkg/controller"
	"sigs.k8s.io/controller-runtime/pkg/handler"
	"sigs.k8s.io/controller-runtime/pkg/manager"
	"sigs.k8s.io/controller-runtime/pkg/reconcile"
	"sigs.k8s.io/controller-runtime/pkg/source"

	mycompanyv1 "github.com/mycompany/api/v1"
)

type BlueGreenReconciler struct {
	client.Client
	Scheme *runtime.Scheme
}

func (r *BlueGreenReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
	log := r.Log.WithValues("bluegreen", req.NamespacedName)

	// Récupérer l'instance BlueGreenDeployment
	blueGreen := &mycompanyv1.BlueGreenDeployment{}
	err := r.Get(ctx, req.NamespacedName, blueGreen)
	if err != nil {
		if errors.IsNotFound(err) {
			// Objet non trouvé, retour. Les objets créés sont automatiquement collectés.
			return reconcile.Result{}, nil
		}
		// Erreur de lecture de l'objet - requeue la demande.
		return reconcile.Result{}, err
	}

	// Vérifier si le déploiement existe déjà, sinon en créer un nouveau
	found := &appsv1.Deployment{}
	err = r.Get(ctx, types.NamespacedName{Name: blueGreen.Name + "-blue", Namespace: blueGreen.Namespace}, found)
	if err != nil && errors.IsNotFound(err) {
		// Définir un nouveau déploiement
		dep := r.deploymentForBlueGreen(blueGreen, "-blue")
		log.Info("Création d'un nouveau déploiement", "Deployment.Namespace", dep.Namespace, "Deployment.Name", dep.Name)
		err = r.Create(ctx, dep)
		if err != nil {
			log.Error(err, "Échec de la création du nouveau déploiement", "Deployment.Namespace", dep.Namespace, "Deployment.Name", dep.Name)
			return reconcile.Result{}, err
		}
		// Déploiement créé avec succès - retour et requeue
		return reconcile.Result{Requeue: true}, nil
	} else if err != nil {
		log.Error(err, "Échec de la récupération du déploiement")
		return reconcile.Result{}, err
	}

	// S'assurer que la taille du déploiement est la même que celle du spec
	size := blueGreen.Spec.Size
	if *found.Spec.Replicas != size {
		found.Spec.Replicas = &size
		err = r.Update(ctx, found)
		if err != nil {
			log.Error(err, "Échec de la mise à jour du déploiement", "Deployment.Namespace", found.Namespace, "Deployment.Name", found.Name)
			return reconcile.Result{}, err
		}
		// Spec mis à jour - retour et requeue
		return reconcile.Result{Requeue: true}, nil
	}

	// Mettre à jour le statut de BlueGreenDeployment avec les noms des pods
	// Lister les pods pour ce déploiement
	podList := &corev1.PodList{}
	listOpts := []client.ListOption{
		client.InNamespace(blueGreen.Namespace),
		client.MatchingLabels(labelsForBlueGreen(blueGreen.Name)),
	}
	if err = r.List(ctx, podList, listOpts...); err != nil {
		log.Error(err, "Échec de la liste des pods", "BlueGreenDeployment.Namespace", blueGreen.Namespace, "BlueGreenDeployment.Name", blueGreen.Name)
		return reconcile.Result{}, err
	}
	podNames := getPodNames(podList.Items)

	// Mettre à jour status.Nodes si nécessaire
	if !reflect.DeepEqual(podNames, blueGreen.Status.Nodes) {
		blueGreen.Status.Nodes = podNames
		err := r.Status().Update(ctx, blueGreen)
		if err != nil {
			log.Error(err, "Échec de la mise à jour du statut de BlueGreenDeployment")
			return reconcile.Result{}, err
		}
	}

	return reconcile.Result{}, nil
}

// deploymentForBlueGreen retourne un objet de déploiement bluegreen
func (r *BlueGreenReconciler) deploymentForBlueGreen(m *mycompanyv1.BlueGreenDeployment, suffix string) *appsv1.Deployment {
	ls := labelsForBlueGreen(m.Name)
	replicas := m.Spec.Size

	dep := &appsv1.Deployment{
		ObjectMeta: metav1.ObjectMeta{
			Name:      m.Name + suffix,
			Namespace: m.Namespace,
		},
		Spec: appsv1.DeploymentSpec{
			Replicas: &replicas,
			Selector: &metav1.LabelSelector{
				MatchLabels: ls,
			},
			Template: corev1.PodTemplateSpec{
				ObjectMeta: metav1.ObjectMeta{
					Labels: ls,
				},
				Spec: corev1.PodSpec{
					Containers: []corev1.Container{{
						Image: m.Spec.Image,
						Name:  "bluegreen",
						Ports: []corev1.ContainerPort{{
							ContainerPort: 8080,
							Name:          "bluegreen",
						}},
					}},
				},
			},
		},
	}
	// Définir l'instance BlueGreenDeployment comme propriétaire et contrôleur
	controllerutil.SetControllerReference(m, dep, r.Scheme)
	return dep
}

// labelsForBlueGreen retourne les labels pour sélectionner les ressources
// appartenant au nom de CR bluegreen donné.
func labelsForBlueGreen(name string) map[string]string {
	return map[string]string{"app": "bluegreen", "bluegreen_cr": name}
}

// getPodNames retourne les noms des pods du tableau de pods passé en paramètre
func getPodNames(pods []corev1.Pod) []string {
	var podNames []string
	for _, pod := range pods {
		podNames = append(podNames, pod.Name)
	}
	return podNames
}

Ouf ! C'est un bon morceau de code, mais décomposons-le :

  1. Nous définissons une structure BlueGreenReconciler qui implémente la méthode Reconcile.
  2. Dans la méthode Reconcile, nous récupérons notre ressource personnalisée et vérifions si un déploiement existe.
  3. Si le déploiement n'existe pas, nous en créons un nouveau en utilisant deploymentForBlueGreen.
  4. Nous nous assurons que la taille du déploiement correspond à notre spec et mettons à jour si nécessaire.
  5. Enfin, nous mettons à jour le statut de notre ressource personnalisée avec les noms des pods.

La sauce secrète : La magie Blue-Green

Maintenant, voici où la magie du déploiement blue-green se produit. Nous devons ajouter une logique pour créer à la fois des déploiements bleus et verts, et basculer entre eux. Améliorons notre contrôleur :


func (r *BlueGreenReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
	// ... (code précédent)

	// Créer ou mettre à jour le déploiement bleu
	blueDeployment := r.deploymentForBlueGreen(blueGreen, "-blue")
	if err := r.createOrUpdateDeployment(ctx, blueDeployment); err != nil {
		return reconcile.Result{}, err
	}

	// Créer ou mettre à jour le déploiement vert
	greenDeployment := r.deploymentForBlueGreen(blueGreen, "-green")
	if err := r.createOrUpdateDeployment(ctx, greenDeployment); err != nil {
		return reconcile.Result{}, err
	}

	// Vérifier s'il est temps de basculer
	if shouldSwitch(blueGreen) {
		if err := r.switchTraffic(ctx, blueGreen); err != nil {
			return reconcile.Result{}, err
		}
	}

	// ... (reste du code)
}

func (r *BlueGreenReconciler) createOrUpdateDeployment(ctx context.Context, dep *appsv1.Deployment) error {
	// Vérifier si le déploiement existe déjà
	found := &appsv1.Deployment{}
	err := r.Get(ctx, types.NamespacedName{Name: dep.Name, Namespace: dep.Namespace}, found)
	if err != nil && errors.IsNotFound(err) {
		// Créer le déploiement
		err = r.Create(ctx, dep)
		if err != nil {
			return err
		}
	} else if err != nil {
		return err
	} else {
		// Mettre à jour le déploiement
		found.Spec = dep.Spec
		err = r.Update(ctx, found)
		if err != nil {
			return err
		}
	}
	return nil
}

func shouldSwitch(bg *mycompanyv1.BlueGreenDeployment) bool {
	// Implémentez votre logique pour déterminer s'il est temps de basculer
	// Cela pourrait être basé sur un minuteur, un déclencheur manuel ou d'autres critères
	return false
}

func (r *BlueGreenReconciler) switchTraffic(ctx context.Context, bg *mycompanyv1.BlueGreenDeployment) error {
	// Implémentez la logique pour basculer le trafic entre bleu et vert
	// Cela pourrait impliquer la mise à jour d'une ressource de service ou d'ingress
	return nil
}

Cette version améliorée crée à la fois des déploiements bleus et verts et inclut des fonctions de remplacement pour déterminer quand basculer et comment basculer le trafic.

Tout rassembler

Maintenant que nous avons notre logique de contrôleur, nous devons configurer l'opérateur. Voici un fichier main.go de base pour commencer :


package main

import (
	"flag"
	"os"

	"k8s.io/apimachinery/pkg/runtime"
	utilruntime "k8s.io/apimachinery/pkg/util/runtime"
	clientgoscheme "k8s.io/client-go/kubernetes/scheme"
	_ "k8s.io/client-go/plugin/pkg/client/auth/gcp"
	ctrl "sigs.k8s.io/controller-runtime"
	"sigs.k8s.io/controller-runtime/pkg/log/zap"

	mycompanyv1 "github.com/mycompany/api/v1"
	"github.com/mycompany/controllers"
)

var (
	scheme   = runtime.NewScheme()
	setupLog = ctrl.Log.WithName("setup")
)

func init() {
	utilruntime.Must(clientgoscheme.AddToScheme(scheme))
	utilruntime.Must(mycompanyv1.AddToScheme(scheme))
}

func main() {
	var metricsAddr string
	var enableLeaderElection bool
	flag.StringVar(&metricsAddr, "metrics-addr", ":8080", "L'adresse à laquelle le point de terminaison des métriques se lie.")
	flag.BoolVar(&enableLeaderElection, "enable-leader-election", false,
		"Activer l'élection du leader pour le gestionnaire de contrôleur. L'activation de cette option garantira qu'il n'y a qu'un seul gestionnaire de contrôleur actif.")
	flag.Parse()

	ctrl.SetLogger(zap.New(zap.UseDevMode(true)))

	mgr, err := ctrl.NewManager(ctrl.GetConfigOrDie(), ctrl.Options{
		Scheme:             scheme,
		MetricsBindAddress: metricsAddr,
		LeaderElection:     enableLeaderElection,
		Port:               9443,
	})
	if err != nil {
		setupLog.Error(err, "impossible de démarrer le gestionnaire")
		os.Exit(1)
	}

	if err = (&controllers.BlueGreenReconciler{
		Client: mgr.GetClient(),
		Log:    ctrl.Log.WithName("controllers").WithName("BlueGreen"),
		Scheme: mgr.GetScheme(),
	}).SetupWithManager(mgr); err != nil {
		setupLog.Error(err, "impossible de créer le contrôleur", "controller", "BlueGreen")
		os.Exit(1)
	}

	setupLog.Info("démarrage du gestionnaire")
	if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
		setupLog.Error(err, "problème lors de l'exécution du gestionnaire")
		os.Exit(1)
	}
}

Déploiement et tests

Maintenant que nous avons notre opérateur prêt, il est temps de le déployer et de le tester. Voici une liste de contrôle rapide :

  1. Construisez l'image de votre opérateur et poussez-la vers un registre de conteneurs.
  2. Créez les rôles et liaisons RBAC nécessaires pour votre opérateur.
  3. Déployez votre opérateur sur votre cluster Kubernetes.
  4. Créez une ressource personnalisée BlueGreenDeployment et regardez la magie opérer !

Voici un exemple de création d'un BlueGreenDeployment :


apiVersion: mycompany.com/v1
kind: BlueGreenDeployment
metadata:
  name: my-cool-app
spec:
  replicas: 3
  image: mycoolapp:v1

Pièges et astuces

Avant de vous lancer dans la mise en œuvre de cela en production, gardez ces points à l'esprit :

  • Gestion des ressources : Exécuter deux déploiements simultanément peut doubler votre utilisation des ressources. Planifiez en conséquence !
  • Migrations de base de données : Faites attention aux schémas de base de données qui ne sont pas rétrocompatibles.
  • Sessions persistantes : Si votre application repose sur des sessions persistantes, vous devrez gérer cela avec soin lors du basculement.
  • Tests : Testez soigneusement votre opérateur dans un environnement non production d'abord. Croyez-moi, vous vous en remercierez plus tard.

Conclusion

Et voilà ! Un opérateur Kubernetes personnalisé qui gère les déploiements blue-green comme un pro. Nous avons couvert beaucoup de terrain, des ressources personnalisées à la logique du contrôleur et même quelques conseils de déploiement.

Rappelez-vous, ce n'est que le début. Vous pouvez étendre cet opérateur pour gérer des scénarios plus complexes, ajouter de la surveillance et des alertes, ou même l'intégrer à votre pipeline CI/CD.

"Avec un grand pouvoir vient une grande responsabilité" - Oncle Ben (et tous les ingénieurs DevOps)

Maintenant, allez-y et déployez avec confiance ! Et si vous rencontrez des problèmes, eh bien... c'est à cela que servent les retours en arrière, n'est-ce pas ?

Réflexions

En implémentant cela dans vos propres projets, considérez les points suivants :

  • Comment pourriez-vous étendre cet opérateur pour gérer les déploiements canari ?
  • Quelles métriques seraient utiles à collecter pendant le processus de déploiement ?
  • Comment pourriez-vous intégrer cela avec des outils externes comme Prometheus ou Grafana ?

Bon codage, et que vos déploiements soient toujours verts (ou bleus, selon votre préférence) !