Knative : Exécutez vos fonctions et adoptez une architecture événementielle sur Kubernetes

Les fonctions et les conteneurs sont deux concepts clés dans le domaine du cloud computing. L’utilisation de fonctions et de conteneurs dans le cloud offre de nombreux avantages, notamment la scalabilité, l’efficacité, la portabilité et l’abstraction de l’infrastructure, ce qui permet aux développeurs de se concentrer sur le code de l’application plutôt que sur la gestion de l’infrastructure. Cependant le choix entre les fonctions et les conteneurs dépend largement des exigences spécifiques des applications.

Les fonctions, dans le contexte du cloud, font généralement référence aux Fonctions en tant que Service (FaaS). Les fonctions sont idéales pour les applications événementielles qui ont des charges de travail intermittentes et imprévisibles. Elles permettent une mise à l’échelle automatique et vous ne payez que pour le temps d’exécution de la fonction. Si les solutions FaaS possèdent un temps de démarrage très faible (moins d’une seconde), il existe aussi des limitations en ressources (CPU/RAM) et en temps d’exécution, donc elles ne sont pas idéales pour de lourds travaux applicatifs ou des applications à longue durée d’exécution. Le modèle FaaS est un service cloud où les applications sont développées, déployées et gérées à travers des fonctions isolées qui sont déclenchées par des événements spécifiques au CSP (Cloud Service Provider). Ces déclencheurs et certaines contraintes logicielles (comme les solutions nécessaires au déploiement du code) introduisent néanmoins un vendor-locking. Enfin, les équipes rencontrent au-delà d’une certaine taille critique une complexité dans la surveillance et le débogage qui peuvent être plus complexes que dans les modèles traditionnels en raison de la difficulté à corréler les événements et de la nature éphémère des fonctions. Des exemples populaires de FaaS incluent AWS Lambda, Google Cloud Functions et Azure Functions.

D’autre part, les conteneurs sont une méthode d’encapsulation et de déploiement d’applications dans un environnement isolé, connu sous le nom d’environnement de conteneur. Ils sont légers et portables, ce qui signifie qu’ils peuvent être exécutés de manière cohérente sur différentes plateformes et environnements. Cependant, ils sont souvent en permanence en exécution (nativement, le nombre d’instance ne tombe jamais à zéro) et réservent des capacités d'infrastructure qui sont alors facturées malgré leur non-utilisation effective. Les conteneurs “traditionnels” sont généralement plus lents au démarrage que les fonctions (plusieurs secondes, car ils ne sont pas développés/optimisés dans ce but), et ce délai peut même augmenter à plusieurs minutes si il est nécessaire de déployer une nouvelle machine virtuelle (“nœud”) pour exécuter de nouveaux conteneurs. De plus, les conteneurs nécessitent une gestion plus complexe et une orchestration, généralement à l’aide d’outils comme Kubernetes, pour gérer et automatiser le déploiement, le dimensionnement et la gestion des applications conteneurisées; Ces outils d’orchestration présentent une difficulté opérationnelle pour les équipes en charge de l’infrastructure, mais elles permettent d’accéder à une solution industrielle, scalable et open-source maintenant largement reconnue en production par les entreprises.

Chaque approche possède donc ses propres cas d’utilisation optimale et peut ne pas être adaptée à toutes les situations. Surtout les fonctions et les conteneurs correspondent à deux cas d'utilisations à priori opposés car les conteneurs permettent d’exécuter des charges de travail en continu et potentiellement lourdes, alors que les fonctions vont traiter des demandes intermittentes et à partir de d'événements consommés à la demande.
Dans les coulisses des CSP, il est cependant intéressant de noter que les CSP se basent sur des services conteneurisés optimisés pour déployer les fonctions : Google Cloud Functions se base sur Cloud Run, et Azure Functions propose plusieurs options d'hébergement dont Azure Container Apps.

La solution Knative propose de rendre largement accessible l’utilisation commune des conteneurs et des fonctions, en permettant à ces dernières de s’exécuter dans un environnement basé sur une architecture événementielle (non réservée aux seules fonctions) et intégré au sein des clusters Kubernetes. Dans cet article je vous propose une visite guidée de cette solution et ses apports aux spécificités propres aux fonctions et aux conteneurs. Knative est un projet CNCF open-source.


Fonctions Knative

https://knative.dev/docs/functions/

Il est nécessaire d’installer sur le cluster Kubernetes hôte les composants knative-serving qui permettront d’exécuter les fonctions. Afin de supporter le mécanisme de réveil des fonctions exécutées par Knative, l’installation de ces composants s’accompagne d’un networking layer. Kourier, développé par Knative, est celui proposé par défaut; une version allégée d’Istio et Contour sont également supportés par Knative.
Référence : Install Serving with YAML - Knative

Knative propose un CLI (Command Line Interface), func, permettant de construire des fonctions à partir de plusieurs langages (Node.js, Python, Go, Quarkus, Rust, Spring Boot & TypeScript). A partir d’un template fourni par Knative et adapté à votre langage de programmation, il vous sera possible de construire vos fonctions basée sur des événements http ou adapté au format cloudevents (certains langages comme Python se voient proposer en complément d’autres formats tels que flask).

Voici un exemple de template Node.js et basé sur des événements http :

const handle = async (context, body) => {

  // YOUR CODE HERE

  context.log.info("query", context.query);

  context.log.info("body", body);


  // If the request is an HTTP POST, the context will contain the request body

  if (context.method === 'POST') {

    return { body };

  } else if (context.method === 'GET') {

    // If the request is an HTTP GET, the context will include a query string, if it exists

    return {

      query: context.query,

    }

  } else {

    return { statusCode: 405, statusMessage: 'Method not allowed' };

  }

}

Un premier avantage de Knative est de ne pas nécessiter l’exécution de bibliothèque spécifique pour fonctionner. Une fois votre code prêt, quelques étapes vont nous permettre de déployer notre fonction sur un cluster Kubernetes créé au préalable.

Principale étape spécifique à la création de fonctions avec Knative, la commande ci-dessous va générer les artéfacts nécessaires à la conteneurisation de votre fonction.

// func create -l <langage-name> <fonction-name>

func create -l node helloworld

Les étapes suivantes correspondent au build de l’image docker et de son envoi sur un container registry distant.

func build [--registry <registry-url>] // Remplace le docker build

docker tag <local-image-name> <remote-image-name>

docker push <remote-image-name>

Enfin, il reste à déployer la fonction sur le cluster Kubernetes. Cette étape peut être manuelle en utilisant encore le CLI fourni par Knative.

func deploy   // Remplace kubectl apply

func invoke   // Réalise un appel à la fonction pour la déclencher

func describe // Remplace kubectl describe

Dans un contexte d’automatisation, voici le fichier YAML équivalent permettant de déployer notre application d’exemple.

apiVersion: serving.knative.dev/v1

kind: Service

metadata:

  name: helloworld

spec:

  template:

    spec:

      containers:

        - image: <remote-image-url>

          env:

            - name: TARGET

              value: "Go Sample v1"

            runAsNonRoot: true

            seccompProfile:

              type: RuntimeDefault

Il est possible d’ajouter de nombreuses propriétés accessibles aux Pods telles que des volumes, livenessProbe, readinessProbe, securityContext, resource requests et limit, etc.
Référence : Serving API - Knative

Pour tester votre fonction nouvellement déployée, voici quelques étapes utiles. L’exemple ci-dessous utilise le networking-layer Kourier avec un DNS par défaut (example.com).

$ kubectl get kservice

NAME         URL                             READY

helloworld   http://helloworld.example.com   True

Knative crée donc un objet KService qui représente la fonction déployée par le YAML précédent et automatiquement associé à un ingress (Ready = True).

$ kubectl get pod -w

NAME                                 READY   STATUS            AGE

helloworld-00001-deployment-765df9   0/2     Pending           0s

helloworld-00001-deployment-765df9   0/2     ContainerCreating 0s

helloworld-00001-deployment-765df9   1/2     Running           7s

helloworld-00001-deployment-765df9   2/2     Running           8s

helloworld-00001-deployment-765df9   2/2     Terminating       68s

helloworld-00001-deployment-765df9   1/2     Terminating       90s

helloworld-00001-deployment-765df9   0/2     Terminating       98s

On peut constater qu’un pod (et un sidecar nécessaire à Knative) est démarré au déploiement du KService. Comme il s’agit d’une fonction, si aucune requête entrante n’arrive, alors le nombre de pod va automatiquement tomber à zéro.

Alors que la fonction est dans un état d’attente avec zéro pod déployé, si une requête entrante arrive sur le service, alors la fonction va automatiquement démarrer pour traiter la requête.

$ kubectl --namespace kourier-system get service kourier 

NAME     TYPE          CLUSTER-IP     EXTERNAL-IP

kourier  LoadBalancer  172.20.141.29  xxx


$ curl -H "Host: helloworld.example.com" http://172.20.141.29 

Hello Sample v1!

$ kubectl get pod -w

NAME                                 READY   STATUS            AGE

helloworld-00001-deployment-765df9   0/2     Pending           0s

helloworld-00001-deployment-765df9   0/2     ContainerCreating 0s

helloworld-00001-deployment-765df9   1/2     Running           2s

helloworld-00001-deployment-765df9   2/2     Running           2s

helloworld-00001-deployment-765df9   2/2     Terminating       64s

helloworld-00001-deployment-765df9   1/2     Terminating       91s

helloworld-00001-deployment-765df9   0/2     Terminating       95s

Une fois la requête d’éveil effectuée et que le pod est Running, toute requête supplémentaire sera traitée par le même pod (voir il est possible de configurer pour augmenter le nombre de replicas en cas de forte sollicitation). Le temps d’éveil de la fonction est aussi plus court que lors de sa première création afin de traiter la requête le plus rapidement possible. Enfin, après une minute de non-sollicitation, le nombre de pod va automatiquement descendre à zéro.

Un point d’attention relevé est le temps de démarrage de la fonction/du pod en cas de forte sollicitation et si les nœuds du cluster Kubernetes sont déjà à leur maximum de capacité. Auquel cas nous explorons Karpenter pour accélérer la mise à disposition de nouveaux nœuds.

La partie Knative Serving, permettant l’exécution de fonctions sur Kubernetes, est une alternative prometteuse pour échapper au vendor-locking des CSP et elle constitue même une solution technologique intéressante des équipes déjà habituées à déployer des solutions conteneurisées. Si la performance est moins bonne que les services FaaS des CSP, capables de démarrer en moins d’une seconde, Knative est une solution viable pour la majorité des cas d’usage n’ayant pas des contraintes de temps de démarrage trop élevées.


Événements Knative

https://knative.dev/docs/eventing/

Il est nécessaire d’installer sur le cluster Kubernetes hôte les composants knative-eventing. Ces composants nécessitent les composants knative-serving présentés ci-dessus.
Référence : Installing Knative Eventing using YAML files

Knative permet de mettre en place des architectures événementielles au sein des clusters Kubernetes. Il est possible de créer des producteurs d’événements (“sources”) et des consommateurs (“sinks”), compatibles avec les composants natifs de Kubernetes et les composants spécifiques à Knative (liste complète des sources d’événements). Les événements échangés entre les composants utilisent le protocole HTTP et se conforment aux spécifications cloudevents.

Pour présenter cette fonctionnalité, voici un exemple permettant de mettre à jour les droits RBAC d’un cluster Kubernetes lors du tagging d’un namespace (ce qui nous servira d’événement) pour permettre à l’équipe associée au tag d’accéder à ce namespace en particulier. Lors de la modification du Namespace en ajoutant le tag, Knative va détecter le changement et appeler une application conteneurisée en transmettant l’événement sur le format cloudevents.

Voici la configuration Knative qui permet d’être à l’écoute d’un changement sur la ressource Namespace et qui va appeler un service qui sera décrit ci-après.

apiVersion: sources.knative.dev/v1

kind: ApiServerSource

metadata:

 name: namespace-event-source

spec:

 serviceAccountName: knative-server

 mode: Resource

 resources:

   - apiVersion: v1

     kind: Namespace

 sink:

   ref:

     apiVersion: v1

     kind: Service

     name: rbac-management-sink

Nous devons donc créer une application qui va recevoir l’événement au format cloudevents puis appliquer du code (non détaillé dans cet article) pour faire les modifications sur le cluster Kubernetes.

"use strict";

import express from 'express';

const { CloudEvent, HTTP, emitterFor, httpTransport } = require('cloudevents')

const PORT = process.env.PORT || 8080

const target = process.env.K_SINK

const app = express()

const emit = target ? emitterFor(httpTransport(target)) : null


const main = () => {

  app.listen(PORT, function () {

    console.log(`Cookie monster is hungry for some cloudevents on port ${PORT}!`)

  })

}


// Apply a ClusterRoleBinding on the Namespace

const manageNamespacePermissions = async (cloudEvent) => {

  // CUSTOM CODE HERE WHICH WILL CONSUME DATA IN THE EVENT

}


app.use((req, res, next) => {

  let data = ''

  req.setEncoding('utf8')

  req.on('data', function (chunk) {

    data += chunk

  })

  req.on('end', function () {

    req.body = data

    next()

  })

})


app.post('/', function (req, res) {

  try {

    const event = HTTP.toEvent({ headers: req.headers, body: req.body })

    console.log(`Accepted event: ${JSON.stringify(event, null, 2)}`)

    event.data.kind === "Namespace" ? manageNamespacePermissions(event) : console.log("Not a namespace event.");

  } catch (err) {

    console.error(err)

    res.status(415)

      .header('Content-Type', 'application/json')

      .send(JSON.stringify(err))

  }

})


main()

Cette application est déployée et associée au Service Kubernetes sous le nom de rbac-management-sink.

A l'exécution, lors du tagging d’un namespace, voici la trace de l’événement transmis en paramètre :

$ kubectl label --overwrite namespace acospain team=ippon

namespace labeled

$ kubectl logs rbac-management-sink-5d7cd55cfc-fprdj

Cookie monster is hungry for some cloudevents on port 8080!

Accepted event: {

  "id": "bfb13228-0489-490e-8f0d-6f58d79c65ce",

  "time": "2024-01-11T16:36:30.293Z",

  "type": "dev.knative.apiserver.resource.update",

  [...]

  "subject": "/apis/v1/namespaces//namespaces/acospain",

  "data": {

    "apiVersion": "v1",

    "kind": "Namespace",

    "metadata": {

      "creationTimestamp": "2023-03-17T16:35:01Z",

      "labels": {

        "kubernetes.io/metadata.name": "acospain",

        "team": "ippon"

      },

      "managedFields": [

        [...]

        "manager": "kubectl-label",

          "operation": "Update",

          "time": "2024-01-11T16:36:30Z"

        }

      ],

      "name": "acospain",

      [...]

    },

    "spec": {

      "finalizers": [

        "kubernetes"

      ]

    },

    "status": {

      "phase": "Active"

    }

  },

  "apiversion": "v1",

  "kind": "Namespace",

  "name": "acospain"

}

Knative Eventing permet donc de construire des applications réagissant à des événements issus du cluster ou externes, mais aussi de construire des applications composées de plusieurs micro-services et sur une architecture événementielle au sein d’un cluster Kubernetes et (potentiellement) sans dépendance externe.

En conclusion de cet article, Knative ouvre la voie pour exécuter des fonctions et des architectures événementielles sans être systématiquement dépendant des solutions proposées par les CSP, en profitant de la portabilité des conteneurs et de Kubernetes. Cependant, et comme souligné par mon binôme Tanguy, Knative ne saurait remplacer les usages les plus avancés de ces fonctionnalités proposées par les CSP car elles sont plus matures et plus complètes. Cette solution est donc une nouvelle possibilité offertes au DSI pour résoudre certains cas d’usages spécifiques et souhaitant limiter/éviter le vendor-locking.