Perché i Deployment Blue-Green?

Prima di addentrarci nei dettagli, facciamo un rapido riepilogo del perché i deployment blue-green sono così apprezzati:

  • Deployment senza downtime
  • Facile rollback se qualcosa va storto
  • Possibilità di testare in un ambiente simile alla produzione
  • Rischio e stress ridotti per il tuo team operativo

Ora, immagina di fare tutto questo con la potenza degli Operator di Kubernetes. Sei entusiasta? Dovresti esserlo!

Preparare il Terreno: Il Nostro Controller Personalizzato

La nostra missione, se decidiamo di accettarla (e lo facciamo), è creare un controller personalizzato che gestisca i deployment blue-green. Questo controller monitorerà i cambiamenti della nostra risorsa personalizzata e orchestrerà il processo di deployment.

Prima di tutto, definiamo la nostra risorsa personalizzata:

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

Niente di troppo complicato qui, solo un deployment standard di Kubernetes con una particolarità: è il nostro tipo di risorsa personalizzata!

Il Cuore della Questione: La Logica del Controller

Ora, immergiamoci nella logica del controller. Useremo Go perché, beh, è fantastico (scusa, non ho resistito).


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)

	// Recupera l'istanza di BlueGreenDeployment
	blueGreen := &mycompanyv1.BlueGreenDeployment{}
	err := r.Get(ctx, req.NamespacedName, blueGreen)
	if err != nil {
		if errors.IsNotFound(err) {
			// Oggetto non trovato, ritorna. Gli oggetti creati vengono automaticamente raccolti come spazzatura.
			return reconcile.Result{}, nil
		}
		// Errore nella lettura dell'oggetto - rimetti in coda la richiesta.
		return reconcile.Result{}, err
	}

	// Controlla se il deployment esiste già, altrimenti creane uno nuovo
	found := &appsv1.Deployment{}
	err = r.Get(ctx, types.NamespacedName{Name: blueGreen.Name + "-blue", Namespace: blueGreen.Namespace}, found)
	if err != nil && errors.IsNotFound(err) {
		// Definisci un nuovo deployment
		dep := r.deploymentForBlueGreen(blueGreen, "-blue")
		log.Info("Creazione di un nuovo Deployment", "Deployment.Namespace", dep.Namespace, "Deployment.Name", dep.Name)
		err = r.Create(ctx, dep)
		if err != nil {
			log.Error(err, "Impossibile creare un nuovo Deployment", "Deployment.Namespace", dep.Namespace, "Deployment.Name", dep.Name)
			return reconcile.Result{}, err
		}
		// Deployment creato con successo - ritorna e rimetti in coda
		return reconcile.Result{Requeue: true}, nil
	} else if err != nil {
		log.Error(err, "Impossibile ottenere il Deployment")
		return reconcile.Result{}, err
	}

	// Assicurati che la dimensione del deployment sia la stessa della specifica
	size := blueGreen.Spec.Size
	if *found.Spec.Replicas != size {
		found.Spec.Replicas = &size
		err = r.Update(ctx, found)
		if err != nil {
			log.Error(err, "Impossibile aggiornare il Deployment", "Deployment.Namespace", found.Namespace, "Deployment.Name", found.Name)
			return reconcile.Result{}, err
		}
		// Specifica aggiornata - ritorna e rimetti in coda
		return reconcile.Result{Requeue: true}, nil
	}

	// Aggiorna lo stato di BlueGreenDeployment con i nomi dei pod
	// Elenca i pod per questo deployment
	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, "Impossibile elencare i pod", "BlueGreenDeployment.Namespace", blueGreen.Namespace, "BlueGreenDeployment.Name", blueGreen.Name)
		return reconcile.Result{}, err
	}
	podNames := getPodNames(podList.Items)

	// Aggiorna status.Nodes se necessario
	if !reflect.DeepEqual(podNames, blueGreen.Status.Nodes) {
		blueGreen.Status.Nodes = podNames
		err := r.Status().Update(ctx, blueGreen)
		if err != nil {
			log.Error(err, "Impossibile aggiornare lo stato di BlueGreenDeployment")
			return reconcile.Result{}, err
		}
	}

	return reconcile.Result{}, nil
}

// deploymentForBlueGreen restituisce un oggetto Deployment 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",
						}},
					}},
				},
			},
		},
	}
	// Imposta l'istanza di BlueGreenDeployment come proprietario e controller
	controllerutil.SetControllerReference(m, dep, r.Scheme)
	return dep
}

// labelsForBlueGreen restituisce le etichette per selezionare le risorse
// appartenenti al nome del CR bluegreen dato.
func labelsForBlueGreen(name string) map[string]string {
	return map[string]string{"app": "bluegreen", "bluegreen_cr": name}
}

// getPodNames restituisce i nomi dei pod dell'array di pod passato
func getPodNames(pods []corev1.Pod) []string {
	var podNames []string
	for _, pod := range pods {
		podNames = append(podNames, pod.Name)
	}
	return podNames
}

Uff! È un bel po' di codice, ma analizziamolo:

  1. Definiamo una struttura BlueGreenReconciler che implementa il metodo Reconcile.
  2. Nel metodo Reconcile, recuperiamo la nostra risorsa personalizzata e controlliamo se esiste un deployment.
  3. Se il deployment non esiste, ne creiamo uno nuovo usando deploymentForBlueGreen.
  4. Assicuriamo che la dimensione del deployment corrisponda alla nostra specifica e aggiorniamo se necessario.
  5. Infine, aggiorniamo lo stato della nostra risorsa personalizzata con i nomi dei pod.

Il Segreto: La Magia Blue-Green

Ora, ecco dove avviene la magia del deployment blue-green. Dobbiamo aggiungere la logica per creare sia i deployment blue che green e passare da uno all'altro. Miglioriamo il nostro controller:


func (r *BlueGreenReconciler) Reconcile(ctx context.Context, req reconcile.Request) (reconcile.Result, error) {
	// ... (codice precedente)

	// Crea o aggiorna il deployment blue
	blueDeployment := r.deploymentForBlueGreen(blueGreen, "-blue")
	if err := r.createOrUpdateDeployment(ctx, blueDeployment); err != nil {
		return reconcile.Result{}, err
	}

	// Crea o aggiorna il deployment green
	greenDeployment := r.deploymentForBlueGreen(blueGreen, "-green")
	if err := r.createOrUpdateDeployment(ctx, greenDeployment); err != nil {
		return reconcile.Result{}, err
	}

	// Controlla se è il momento di passare
	if shouldSwitch(blueGreen) {
		if err := r.switchTraffic(ctx, blueGreen); err != nil {
			return reconcile.Result{}, err
		}
	}

	// ... (resto del codice)
}

func (r *BlueGreenReconciler) createOrUpdateDeployment(ctx context.Context, dep *appsv1.Deployment) error {
	// Controlla se il deployment esiste già
	found := &appsv1.Deployment{}
	err := r.Get(ctx, types.NamespacedName{Name: dep.Name, Namespace: dep.Namespace}, found)
	if err != nil && errors.IsNotFound(err) {
		// Crea il deployment
		err = r.Create(ctx, dep)
		if err != nil {
			return err
		}
	} else if err != nil {
		return err
	} else {
		// Aggiorna il deployment
		found.Spec = dep.Spec
		err = r.Update(ctx, found)
		if err != nil {
			return err
		}
	}
	return nil
}

func shouldSwitch(bg *mycompanyv1.BlueGreenDeployment) bool {
	// Implementa la tua logica per determinare se è il momento di passare
	// Questo potrebbe basarsi su un timer, un trigger manuale o altri criteri
	return false
}

func (r *BlueGreenReconciler) switchTraffic(ctx context.Context, bg *mycompanyv1.BlueGreenDeployment) error {
	// Implementa la logica per passare il traffico tra blue e green
	// Questo potrebbe comportare l'aggiornamento di una risorsa di servizio o ingress
	return nil
}

Questa versione migliorata crea sia i deployment blue che green e include funzioni segnaposto per determinare quando passare e come passare il traffico.

Mettere Tutto Insieme

Ora che abbiamo la logica del nostro controller, dobbiamo configurare l'operator. Ecco un file main.go di base per iniziare:


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'indirizzo a cui si lega l'endpoint delle metriche.")
	flag.BoolVar(&enableLeaderElection, "enable-leader-election", false,
		"Abilita l'elezione del leader per il gestore del controller. Abilitando questo, ci sarà solo un gestore del controller attivo.")
	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, "impossibile avviare il gestore")
		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, "impossibile creare il controller", "controller", "BlueGreen")
		os.Exit(1)
	}

	setupLog.Info("avvio del gestore")
	if err := mgr.Start(ctrl.SetupSignalHandler()); err != nil {
		setupLog.Error(err, "problema nell'esecuzione del gestore")
		os.Exit(1)
	}
}

Deployment e Test

Ora che abbiamo il nostro operator pronto, è il momento di distribuirlo e testarlo. Ecco una lista di controllo rapida:

  1. Costruisci l'immagine del tuo operator e caricala su un registro di container.
  2. Crea i ruoli RBAC necessari e i binding per il tuo operator.
  3. Distribuisci il tuo operator nel tuo cluster Kubernetes.
  4. Crea una risorsa personalizzata BlueGreenDeployment e guarda la magia accadere!

Ecco un esempio di come creare un BlueGreenDeployment:


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

Trappole e Insidie

Prima di correre a implementare questo in produzione, tieni a mente questi punti:

  • Gestione delle risorse: Eseguire due deployment contemporaneamente può raddoppiare l'uso delle risorse. Pianifica di conseguenza!
  • Migrazioni del database: Fai attenzione agli schemi del database che non sono retrocompatibili.
  • Sessioni sticky: Se la tua app si basa su sessioni sticky, dovrai gestirle con attenzione durante il passaggio.
  • Test: Testa accuratamente il tuo operator in un ambiente non di produzione prima. Fidati, ti ringrazierai più tardi.

Conclusione

Ecco fatto! Un Operator Kubernetes personalizzato che gestisce i deployment blue-green come un campione. Abbiamo coperto molti argomenti, dalle risorse personalizzate alla logica del controller e persino alcuni suggerimenti per il deployment.

Ricorda, questo è solo l'inizio. Puoi estendere questo operator per gestire scenari più complessi, aggiungere monitoraggio e allerta, o persino integrarlo con la tua pipeline CI/CD.

"Con grande potere derivano grandi responsabilità" - Zio Ben (e ogni ingegnere DevOps di sempre)

Ora vai avanti e distribuisci con fiducia! E se incontri problemi, beh... è per questo che esistono i rollback, giusto?

Spunti di Riflessione

Mentre implementi questo nei tuoi progetti, considera quanto segue:

  • Come potresti estendere questo operator per gestire i deployment canary?
  • Quali metriche sarebbero utili da raccogliere durante il processo di deployment?
  • Come potresti integrare questo con strumenti esterni come Prometheus o Grafana?

Buona programmazione, e che i tuoi deployment siano sempre verdi (o blu, a seconda delle tue preferenze)!