Précédents blog posts de la série "Kit de survie K8S pour les dévs avec K3S"

# 🇫🇷 Kit de survie K8S pour les dévs avec K3S - Partie 7 - Vert.x

Dans le précédent article (opens new window), nous avons vu comment déployer une base Redis sur notre cluster. Et nous allons utiliser cette base comme "discovery backend" pour des microservices Vert.x (opens new window).

# Préalable

Eclipse Vert.x (opens new window) est un toolkit Java à la fois léger et complet et il propose de nombreux composants et notamment tout ce qu'il faut pour développer des microservices. Le développement avec Vert.x est plutôt agréable quand vous venez du monde Node.js, et encore plus si vous utilisez Vert.x en Kotlin (ou même en Scala), voici un exemple de code:

router.get("/api/hello").handler { context ->
  context.response().putHeader("content-type", "application/json;charset=UTF-8")
    .end(
      json { obj("message" to "👋 Hello World 🌍") }.encodePrettily()
    )
}

Les habitués de Express.js ne devrait pas avoir de mal à s'adapter.

Remarque: il existe aussi ES for Eclipse Vert.x (opens new window) de Paulo Lopes (opens new window). ES4X est "petit" runtime pour des apps JavaScript "tournant" sous graaljs avec l'aide de Vert.x. Encore une chose à tester bientôt 😉.

# Mes composants

Les composants de Vert.x que je vais utiliser pour mes microservices sont les suivants:

# Mon cas d'utilisation

Il est extrêmement simple. J'ai 2 microservices:

  • ping-service qui répond pong quand on l'appelle et qui s'enregistre dans le discovery Redis.
  • pong-service qui répond ping, mais va aussi appeler ping-service en cherchant son adresse dans le discovery Redis.
  • chaque pod "embarquant" un microservice doit s'assurer que la base Redis est disponible avant de se lancer.

Je vais vous expliquer le code des 2 microservices. Vous trouverez leur code complet ici:

# ping-service en Kotlin

La structure globale d'un microservice Vert.x ressemble à ceci:

class MainVerticle : AbstractVerticle() {

  private lateinit var discovery: ServiceDiscovery
  private lateinit var record: Record
  

La méthode stop(stopPromise: Promise<Void>) est déclenchée lorsque par exemple le pod du microservice est supprimé. Dans cette méthode j'ai ajouté le code nécessaire au désenregistrement du microservice. Mais attention, vous n'avez pas forcément la garantie que cette méthode sera appelée (imaginez le cluster qui plante sans prévenir).

  override fun stop(stopPromise: Promise<Void>) {
    println("Unregistration process is started (${record.registration})...")

    discovery.unpublish(record.registration) { ar ->
      when {
        ar.failed() -> {
          println("😡 Unable to unpublish the microservice: ${ar.cause().message}")
          stopPromise.fail(ar.cause())
        }
        ar.succeeded() -> {
          println("👋 bye bye ${record.registration}")
          stopPromise.complete()
        }
      }
    }
  }

La méthode start(startPromise: Promise<Void>) est bien sûr exécutée au démarrage du microservice. C'est ici que j'ai mis toute l'intelligence de mon service:

  override fun start(startPromise: Promise<Void>) {

    // ===== Discovery ===
    val redisPort= System.getenv("REDIS_PORT")?.toInt() ?: 6379
    val redisHost = System.getenv("REDIS_HOST") ?: "127.0.0.1" // "redis-master.database"
    val redisAuth = System.getenv("REDIS_PASSWORD") ?: null
    val redisRecordsKey = System.getenv("REDIS_RECORDS_KEY") ?: "vert.x.ms" // the redis hash
  • j'instancie un objet de type ServiceDiscoveryOptions qui contient le nécessaire pour se connecter à la base Redis
  • je crée ensuite un ServiceDiscovery qui va se connecter à la base Redis (grâce à l'objet serviceDiscoveryOptions)
    val serviceDiscoveryOptions = ServiceDiscoveryOptions()

    discovery = ServiceDiscovery.create(vertx,
      serviceDiscoveryOptions.setBackendConfiguration(
        json {
          obj(
            "host" to redisHost,
            "port" to redisPort,
            "auth" to redisAuth,
            "key" to redisRecordsKey
          )
        }
      ))

    // create the microservice record
    val serviceName = System.getenv("SERVICE_NAME") ?: "john-doe-service"
    val serviceHost = System.getenv("SERVICE_HOST") ?: "john-doe-service.127.0.0.1.nip.io"
    val serviceExternalPort= System.getenv("SERVICE_PORT")?.toInt() ?: 80

Maintenant, je crée un Record décrivant les spécificités du microservice:

    record = HttpEndpoint.createRecord(
      serviceName,
      serviceHost, // or internal ip
      serviceExternalPort, // exposed port (internally it's 8080)
      "/api"
    )

Et je peux ajouter les meta data de mon choix à ce record:

    // --- adding some meta data ---
    record.metadata = json {
      obj(
        "api" to jsonArrayOf("/hello", "/knock-knock")
      )
    }

Ensuite, j'instancie un Router pour mon application, et je lui définis une ou plusieurs "routes":

    val router = Router.router(vertx)
    router.route().handler(BodyHandler.create())

    router.get("/api/hello").handler { context ->
      context.response().putHeader("content-type", "application/json;charset=UTF-8")
        .end(
          json { obj("message" to "👋 Hello World 🌍") }.encodePrettily()
        )
    }

    router.get("/api/knock-knock").handler { context ->
      println("👋 knock-knock")
      context.response().putHeader("content-type", "application/json;charset=UTF-8")
        .end(
          json {
            obj(
              "from" to serviceName,
              "message" to "🏓 pong"
            )
          }.encodePrettily()
        )
    }

Je peux aussi servir des pages statiques (par exemple index.html). Pour retrouver les éléments html et javascript, allez voir dans: /src/main/resources/webroot:

    // serve static assets
    router.route("/*").handler(StaticHandler.create().setCachingEnabled(false))

Maintenant, il est temp de lancer notre microservice:

    // internal port
    val httpPort = System.getenv("PORT")?.toInt() ?: 8080

    vertx
      .createHttpServer()
      .requestHandler(router)
      .listen(httpPort) { http ->
        if (http.succeeded()) {
          startPromise.complete()
          println("🏓 Ping started on $httpPort")

Au moment du démarrage, je vais aller publier mon record (les infos du microservice) dans le discovery backend (discovery.publish(record))

          /* 👋 === publish the microservice record to the discovery backend === */
          discovery.publish(record) { asyncRes ->
            when {
              asyncRes.failed() -> {
                val errorMessage = "😡 Not able to publish the microservice: ${asyncRes.cause().message}"
                println(errorMessage)
              } // ⬅️ failed

              asyncRes.succeeded() -> {
                val successMessage = "🎉😃 ${serviceName} is published! ${asyncRes.result().registration}"
                println(successMessage)
                println("🖐 $serviceName :")
                println(record.toJson()?.encodePrettily())
              } // ⬅️ succeed

            } // ⬅️ when
          } // ⬅️ publish

          println("🌍 HTTP server started on port ${httpPort}")
        } else {
          startPromise.fail(http.cause())
        }
      }
  }
}

Et c'est tout! Voyons donc comment déployer tout cela.

# Déployer ping-service

Pour déployer ping-service, je vais utiliser les mêmes principes que ceux décrits dans un précédent blog post de cette série: https://k33g.gitlab.io/articles/2020-02-23-K3S-04-BETTER-DEPLOY.html (opens new window). Mais nous allons d'abord préparer notre plateforme.

# Docker

Dans un premier temps, nous allons "pousser" l'image azul/zulu-openjdk-alpine:latest dans notre registry privée. Ainsi à chaque nouveau build, nous éviterons de passer obligatoirement par le Docker Hub:

registry_domain="registry.dev.test"
image_name_to_pull="azul/zulu-openjdk-alpine:latest"

docker pull ${image_name_to_pull}
docker tag ${image_name_to_pull} ${registry_domain}:5000/${image_name_to_pull}
docker push ${registry_domain}:5000/${image_name_to_pull}

curl http://${registry_domain}:5000/v2/_catalog

Du coup, dans le Dockerfile de l'application, j'ai modifié la directive FROM:

#FROM azul/zulu-openjdk-alpine:latest
FROM registry.dev.test:5000/azul/zulu-openjdk-alpine:latest

# --- copy application files
ENV WEB_APP_JAR="ping-1.0.0-SNAPSHOT-fat.jar"
COPY target/${WEB_APP_JAR} .

# --- run the application
CMD java -jar $WEB_APP_JAR

# Fichier "template" de déploiement

Soyez bien attentifs, la ruse est ici 😉. Mon fichier yaml ressemble fortement à celui utilisé dans les précédents articles, mais j'ai ajouté 2 rubriques:

  • initContainers qui va me permettre de ne démarrer mon pod qu'à partir du moment où le pod Redis est actif. Ce qui est très pratique, notamment lors du re-démarrage du cluster, car vous n'avez pas la possibilité de définir explicitement un ordre de démarrage pour vos applications.
  • env qui me permet de passer des variables d'environnement à mon service
---
# Service
apiVersion: v1
kind: Service
metadata:
  name: ${APPLICATION_NAME}
spec:
  selector:
    app: ${APPLICATION_NAME}
  ports:
    - port: ${EXPOSED_PORT}
      targetPort: ${CONTAINER_PORT}
---
# Deployment
apiVersion: apps/v1
kind: Deployment
metadata:
  name: ${APPLICATION_NAME}
spec:
  replicas: 1
  selector:
    matchLabels:
      app: ${APPLICATION_NAME}
  template:
    metadata:
      labels:
        app: ${APPLICATION_NAME}
    spec:

Les initContainers vous permettent d'influencer le comportement au démarrage d'un pod. Pour créer ce conteneur init j'utilise l'image registry.dev.test:5000/mytools:0.0.0. Je décris la création de cette image dans un blog post précédant: https://k33g.gitlab.io/articles/2020-02-29-K3S-06-VOLUMES.html (opens new window). Ce container va contenir un client Redis qui va me permettre de tester si la base Redis est disponible: until redis-cli -h redis-master.database ping et lorsque la base répondra PONG, mon pod "service" pourra démarrer:

      initContainers:
        - name: check-redis
          image: registry.dev.test:5000/mytools:0.0.0
          command: [
            'sh', '-c',
            'until redis-cli -h redis-master.database ping; do echo "👋 Redis?"; sleep 3; done; echo "🎉 Redis!"'
          ]
      containers:
        - name: ${APPLICATION_NAME}
          image: ${DOCKER_USER}/${IMAGE_NAME}:${TAG}
          ports:
            - containerPort: ${CONTAINER_PORT}

Dans la rubrique env je définis toutes les variables d'environnent dont j'ai besoin:

          env:
            - name: REDIS_PORT
              value: "6379"
            - name: REDIS_HOST
              value: "redis-master.database"
            - name: SERVICE_NAME
              value: "ping-service"
            - name: SERVICE_HOST
              value: "${HOST}"
            - name: SERVICE_PORT
              value: "80"
          imagePullPolicy: Always
          resources:
            limits:
              cpu: "1"
              memory: "128Mi"
            requests:
              cpu: "1"
              memory: "128Mi"
---
# Ingress
apiVersion: extensions/v1beta1
kind: Ingress
metadata:
  name: ${APPLICATION_NAME}
spec:
  rules:
    - host: ${HOST}
      http:
        paths:
          - backend:
              serviceName: ${APPLICATION_NAME}
              servicePort: ${EXPOSED_PORT}

# On déploie "ping-service"

Pour cela nous utilisons la même méthode que les fois précédentes:

Dans un premier temps, on git commit les modifications:

git add .
git commit -m "🎉 ping service updated"

Puis on exporte les variables d'environnement nécessaires:

export APPLICATION_NAME=$(basename $(git rev-parse --show-toplevel))
export DOCKER_USER="registry.dev.test:5000"
export IMAGE_NAME="${APPLICATION_NAME}-img"
export TAG=$(git rev-parse --short HEAD)
export IMAGE="${DOCKER_USER}/${IMAGE_NAME}:${TAG}"

On build le fichier jar du microservice:

# build jar
./mvnw clean package

Puis on build notre image Docker et on la "pousse" vers notre registry privée:

docker build -t ${IMAGE_NAME} .
docker tag ${IMAGE_NAME} ${IMAGE}
docker push ${IMAGE}

Ensuite, exportez KUBECONFIG avec le chemin vers votre fichier de configuration k3s.yaml (adaptez en fonction)

export KUBECONFIG=../create-cluster/k3s.yaml

Puis récupérez l'IP du cluster et renseignez les variables d'nvironnement:

export CLUSTER_IP=$(multipass info basestar | grep IPv4 | awk '{print $2}')

export NAMESPACE="training"

export CONTAINER_PORT=8080
export EXPOSED_PORT=80

export BRANCH=$(git symbolic-ref --short HEAD)
export HOST="${APPLICATION_NAME}.${BRANCH}.${CLUSTER_IP}.nip.io"

Générez le fichier de déploiement à partir du template:

envsubst < ./deploy.template.yaml > ./kube/deploy.${TAG}.yaml

Et enfin, déployer l'application crâce à kubectl

kubectl apply -f ./kube/deploy.${TAG}.yaml -n ${NAMESPACE}

echo "🌍 http://${HOST}"
echo "🤜 http://${HOST}/api/knock-knock"

L'url pour accéder à votre service devrez ressembler à quelque chose comme ceci: http://ping.master.192.168.64.17.nip.io/api/knock-knock

# Vérifications

Dans K9S vous pouvez aller consulter les logs de votre pod, vérifier que la base Redis a bien répondue:

alt k9s

Ensuite pour se connecter à la base Redis "de l'extérieur", on va faire du "port forwarding":

export KUBECONFIG=$PWD/k3s.yaml
kubectl port-forward --namespace database svc/redis-master 6379:6379 &

Ainsi je peux aller me connecter avec un client Redis installé sur mon poste (j'utilise Medis (opens new window) sous OSX) et voir que mon service a bien créer ses informations d'enregistrement dans la base Redis:

alt k9s

Et enfin, bien sûr, faites un curl pour appeler votre service:

alt k9s

en fait j'utilise l'utilitaire HTTPie (opens new window)

# On "scale" le service

Augmentons le nombre de pods de notre service, passons de 1 à 3:

export KUBECONFIG=../create-cluster/k3s.yaml

APPLICATION_NAME=$(basename $(git rev-parse --show-toplevel))
NAMESPACE="training"
NB_REPLICAS=3

kubectl scale --replicas=${NB_REPLICAS} deploy ${APPLICATION_NAME} -n ${NAMESPACE}

Dans K9S nous avons bien 3 pods pour "ping-service":

alt k9s

Et vous pouvez voir que chaque pod a été enregistré dans Redis:

alt k9s

# On "down-scale" le service

Repassez votre microservice à 1 seul pod:

export KUBECONFIG=../create-cluster/k3s.yaml

APPLICATION_NAME=$(basename $(git rev-parse --show-toplevel))
NAMESPACE="training"
NB_REPLICAS=1

kubectl scale --replicas=${NB_REPLICAS} deploy ${APPLICATION_NAME} -n ${NAMESPACE}

Et retournez voir Redis:

alt k9s

Il n'y a plus qu'un seul enregistrement (les 2 autres pods ont déclenché la méthode stop du microservices)

Pour continuer à jouer, j'ai ajouter un second service "pong-service".

# pong-service (toujours en Kotlin)

pong-service est quasi en tous points semblable à ping-service, à une exception près, quand on "l'appelle" il "appelle" ping-service. Je vous décris ici comment je procède (et pour le code complet, allez récupérer https://gitlab.com/learn-k3s/pong (opens new window)).

Le seul changement intervient dans la définition de la route router.get("/api/knock-knock"):

router.get("/api/knock-knock").handler { context ->

Je vais utiliser l'objet discovery pour retrouver les informations de "ping-service":

  // search ping service
  discovery.getRecord(json{obj("name" to "ping-service")}) { ar ->
    when {
      ar.failed() -> {
        println("😡 enable to find ping-service in the backend discovery")
        context.response().putHeader("content-type", "application/json;charset=UTF-8")
          .end(
            json {
              obj("message" to "😡 enable to find ping-service in the backend discovery")
            }.encodePrettily()
          )
      } // ⬅️ failed

Si tout va bien, je vais créer un WebClient (pingClient) à partir de l'instance de ServiceReference "pointant" sur ping-service grâce aux informations retournées par le discovery:

      ar.succeeded() -> {
        println("😀 I found ping-service in the backend discovery")

        val pingRecord: Record = ar.result()
        println(pingRecord.toJson().encodePrettily())

        val reference: ServiceReference = discovery!!.getReference(pingRecord)
        val pingClient: WebClient = reference.getAs(WebClient::class.java)

        println("😍 pingClient created")
        println("🖖 knock-knock")

Et j'appelle l'api knock-knock de ping-service:

        pingClient.get("/api/knock-knock").send { pingResponse ->
          when {
            pingResponse.failed() -> {
              println("😡 ouch! cannot connect to ping-service: ${pingResponse.cause().message}")
              context.response().putHeader("content-type", "application/json;charset=UTF-8")
                .end(
                  json {
                    obj("error" to pingResponse.cause().message)
                  }.encodePrettily()
                )
            } // ⬅️ failed

Et je retourne une payload json contenant les informations de pong-service et le résultat de l'appel de ping-service (par pong-service):

            pingResponse.succeeded() -> {
              println("🎉")
              println(pingResponse.result().bodyAsJsonObject().encodePrettily())

              context.response().putHeader("content-type", "application/json;charset=UTF-8")
                .end(
                  json {
                    obj(
                      "responseFromPong" to "🏓 ping",
                      "callingPing" to pingResponse.result().bodyAsJsonObject()
                    )
                  }.encodePrettily()
                )
            } // ⬅️ succeed
          } // when
        } // pingClient.get
      } // ⬅️ succeed
    } // when
  } // discovery
} // route

# Tester le service

Pour tester "pong-service", c'est exactement la même procédure que pour "ping-service" (vous pouvez aussi allez voir le code de build.sh et deploy.sh dans le repository du service).

Une fois "pong-service" déployé, faites un curl sur http://pong.master.192.168.64.17.nip.io/api/knock-knock:

alt k9s

Et si vous allez voir les logs de "ping-service", vous pouvez voir qu'il a bien été sollicité:

alt k9s

Voilà c'est tout pour aujourd'hui. Alos, on est loin d'avoir fait le tour de tout ce qui est possible avec des microservices Vert.x. Par exemple, quelle serait la meilleure stratégie pour nettoyer le backend discovery après un plantage? Peut-être, en déléguant cette tâche à un autre microservice qui interrogerait le "health check" de chaque microservice enregistrés dans le backend discovery, et qui après plusieurs tentatives infructureuses sur un appel de service à l'aide d'un "circuit breaker" supprimerait l'enregistrement "orphelin". Vous avez maintenant les exemples et l'infrastructure nécessaire pour jouer avec ceci:

Pour finir, un peu de pub pour les copains:

Et un grand merci, une nouvelle fois à Louis Tournayre (opens new window) pour m'avoir mis sur la piste des InitContainers 😃

👋 à bientôt pour la suite

Last Articles