Liquibase dans un init container sur GCP

Il est courant de trouver dans nos applications des outils de gestion des versions et scripts (No)SQL tels que Liquibase, Flyway, SchemaHero ou encore Mongock (pour MongoDB). Le but ici n’est pas de revenir sur l’utilité ou le fonctionnement d’un tel outil mais plutôt de donner un retour d’expérience dans un contexte particulier.

La problématique : Liquibase et les scripts longs à exécuter dans un container applicatif

Nous déployons en effet nos applications dans un contexte d’exécution Kubernetes géré par GCP (Google Kubernetes Engine) et nous avons parfois besoin d’exécuter sur des tables PostgreSQL contenant plusieurs centaines de millions de lignes de nouveaux changelogs Liquibase contenant des DDL (par exemple : la création d’indexes) ou DML (par exemple : l’alimentation d’une colonne). Le problème ici vient de la valeur de la readiness probe qui était trop basse (quelques minutes). La readiness probe permet de notifier Kubernetes lorsqu’une application dans un pod est prête à accepter le trafic. Lorsque la readiness probe échoue à démarrer, Kubernetes arrête le trafic jusqu’à ce que celle-ci passe. Il peut donc y avoir interruption de service, ce qui était notre cas. Mécaniquement, la liveness probe étant également basse et définie à quelques minutes, Kubernetes redémarrait les pods et les scripts Liquibase qui étaient en cours d’exécution ne s’arrêtaient pas proprement. De ce fait, au redémarrage lorsque Liquibase tentait d’exécuter à nouveau il y avait des locks (Liquibase met à jour la table DATABASECHANGELOGLOCK lorsqu’il exécute des scripts et prend la main) et nous nous retrouvions dans un état instable, les scripts ne finissant jamais de s’exécuter. Nous avions envisagé d’augmenter les valeurs des liveness et readiness probes mais cela nous posait problème car en cas d’erreur au rolling update nous avions besoin d’être notifiés rapidement.

Notre problème portait donc sur le redémarrage intempestif des pods mais si vos changelogs s’exécutent en peu de temps sur une faible volumétrie et que vous voulez vous assurer de ne pas avoir de problèmes de locks lors du déploiement de vos micro-services, vous pouvez configurer Liquibase via une librairie pour qu’il utilise un lock au niveau session (ici pour PostgreSQL il s’agirait d’un advisory lock).

La solution mise en place : les init containers

C’est là qu’interviennent les init containers de Kubernetes ! Ces derniers sont des containers un peu particuliers car ils n’ont pas les mêmes valeurs de liveness et readiness et ne sont pas destinés à lancer des applicatifs mais plutôt des outils techniques tels que Liquibase. Non seulement cette solution est conseillée par l’équipe de Liquibase elle-même mais en plus elle résout le problème des locks soulevé plus haut et permet donc une exécution complète des scripts (même longs) et ce sans interruption de service.

La description de la mise en place qui suit part de l’article de l’équipe de Liquibase sur lequel nous nous sommes basés et que nous avons dû adapter à nos besoins car elle manquait de contenu dans notre contexte (Kubernetes géré sur GCP, Jenkins...). Dans votre cas vous pourriez également avoir besoin d’adapter la solution.

Étape 1 : Créer un Dockerfile

L’idée ici est de créer une image Docker à partir d’une image de base liquibase (ici en 4.4.3 mais vous pouvez mettre la version que vous souhaitez en fonction de vos besoins) et de récupérer l’outil cloud_sql_proxy pour créer un proxy (ici lancé en tâche de fond avec & à la fin de la commande RUN) pour se connecter à la base de données localement dans le container. Ensuite on fait appel à Liquibase en passant les informations nécessaires de connexion à la base de données dans l’init container.

Si ces informations ne sont pas passées vous obtiendrez cette erreur :

Unexpected error running Liquibase: Connection could not be created to jdbc:postgresql://127.0.0.1:5432/db_name with driver org.postgresql.Driver. Connection to 127.0.0.1:5432 refused. Check that the hostname and port are correct and that the postmaster is accepting TCP/IP connections.

Attention à bien mettre en source (à gauche) dans la commande ADD le chemin relatif au fichier liquibase.Dockerfile (ici un dossier de l’adapter PostgreSQL de l’infrastructure sur un projet en architecture hexagonale). De même en destination (à droite) attention à bien mettre le bon dossier car le container contient de base un dossier /liquibase avec des fichiers importants (donc l’exécutable liquibase). Par exemple si en source on a chemin/vers/db/changelog (avec le fichier YAML master à la racine du dossier db et les changelogs dans le dossier changelog), en destination il faudra mettre /liquibase/db/changelog. Optez par exemple pour db/changelog comme ci-dessous :

liquibase-changelogs-folder

Contenu du liquibase.Dockerfile :

FROM liquibase/liquibase:4.4.3

RUN wget https://dl.google.com/cloudsql/cloud_sql_proxy.linux.amd64 -O cloud_sql_proxy

RUN chmod +x cloud_sql_proxy

ADD infrastructure/postgres-adapter/src/main/resources/db/changelog /liquibase/db/changelog

ADD liquibase.sh /liquibase/liquibase.sh

CMD ["sh", "-c", "./liquibase.sh"]

L’idée est d’utiliser la commande CMD de Docker pour faire appel à un script sh pour lancer cloud_sql_proxy en tâche de fond avec des variables d’environnements. Le faire avant avec une commande RUN n’aurait pas permis de récupérer la valeur de ces variables car le résultat de celle-ci se serait perdu dans la couche Docker créée à cette occasion.

Contenu du liquibase.sh :

#!/bin/sh
./cloud_sql_proxy -instances=${DB_PROJET}:${DB_REGION}:${DB_INSTANCE}=tcp:5432 -credential_file=/secret/cloudsql/credentials.json &
docker-entrypoint.sh --url=${DB_URL} --username=${DB_USERNAME} --password=${DB_PASSWORD}
--classpath=/liquibase/db/changelog --changeLogFile=db.changelog-master.yaml update

Nous reviendrons plus tard sur la mise à disposition via un volume du fichier credentials.json (contenant un Service Account GCP) lors de la création de l’init container.

Il est nécessaire de faire un chmod +x liquibase.sh avant le docker build pour tester car l’utilisateur par défaut dans le container est liquibase et les fichiers sont copiés avec l’utilisateur et le groupe root. Le script liquibase.sh appartenant à root ne peut donc pas être exécuté par l’utilisateur liquibase. L’autre solution est de faire sans script sh et de regrouper l’ensemble des commandes du script liquibase.sh dans le CMD du Dockerfile comme suit :

FROM liquibase/liquibase:4.4.3

RUN wget https://dl.google.com/cloudsql/cloud_sql_proxy.linux.amd64 -O cloud_sql_proxy

RUN chmod +x cloud_sql_proxy

ADD infrastructure/postgres-adapter/src/main/resources/db/changelog /liquibase/db/changelog

CMD ["sh", "-c", "./cloud_sql_proxy -instances=${DB_PROJET}:${DB_REGION}:${DB_INSTANCE}=tcp:5432 -credential_file=/secret/cloudsql/credentials.json &
docker-entrypoint.sh --url=${DB_URL} --username=${DB_USERNAME} --password=${DB_PASSWORD}
--classpath=/liquibase/db/changelog --changeLogFile=db.changelog-master.yaml update"]

Le port est laissé en dur mais il pourrait très bien être passé également en variable d’environnement.

Pour tester l’image et l’init container en local, il suffit de construire l’image avec la commande suivante (le . importe pour indiquer à Docker que le contexte d’exécution est le dossier en cours) :

docker build -t liquibase-init-container -f liquibase.Dockerfile .

Puis de lancer le container en passant les variables d’environnements et le dossier où est stocké un éventuel credentials.json (service_account) récupéré depuis l’environnement adéquat sur GCP :

docker run \
-e DB_URL='jdbc:postgresql://localhost:5432/your-database' \
-e DB_USERNAME='your_username' \
-e DB_PASSWORD='your_password' \
-e DB_PROJET='your_gcp_project' \
-e DB_REGION='your_gcp_region' \
-e DB_INSTANCE='your_gcp_db_instance' \
-v ~/path/to/credentials:/secret/cloudsql \
-it liquibase-init-container

Une fois l'image Docker définie, il est ensuite nécessaire de la construire et la pousser sur le dépôt Docker via la CI.

Étape 2 : Construire et pousser l’image de l’init container sur la CI

En fonction de comment vos images sont construites cela sera différent. Sur notre projet nous utilisons Jenkins comme outil de CI avec un Jenkins.yml pour passer certaines valeurs et le traditionnel Jenkinsfile pour définir les steps des pipelines.

Contenu du Jenkinsfile.yml :

docker:
  app_image:
    image_name: "microservice"
  init_container_image:
    image_name: "microservice-liquibase-init-container"

Contenu du Jenkinsfile :

stage('Package') {
    steps {
            script {
                    container('maven') {
                         util.dockerBuild(jobContext.app_image, jobContext.version)
                         util.dockerBuild(jobContext.init_container_image, jobContext.version, "./", "./liquibase.Dockerfile")
                    }
            }
    }
}

Ci-dessous, le code source de la méthode Groovy util.dockerBuild définie dans notre librairie partagée pour Jenkins et utilisée ci-dessus dans le Jenkinsfile :

/************
 *  Build and push image with default Dockerfile
 * @param image : image name (registry/image)
 * @param version : image version (usally the gitcommit or environment version)
 * @param dockerfileDir : path of dockerfiles directory
 * @param dockerFilePath : path of dockerfile, default: ./Dockerfile
 * @param push (default: true): should the image be pushed to the remote registry
 ***********/
void dockerBuild(String image, String version, String dockerfileDir = "./", String dockerFilePath = "./Dockerfile", boolean push = true) {
    sh """
      echo "Moving to ${dockerfileDir}"
      cd ${dockerfileDir}
      echo "Building docker images ${image}:${version} / ${image}:latest..."
      docker build -f ${dockerFilePath} --label repo=${image} --label commit.id=${version} --no-cache -t ${image}:${version} .
      if [[ ${env.BRANCH_NAME} == "master" ]]; then
        docker tag ${image}:${version} ${image}:latest
      fi
    """
    if (push) {
        sh """
            echo "Pushing ${image}:${version} / ${image}:latest..."
            docker push ${image}:${version}
            if [[ ${env.BRANCH_NAME} == "master" ]]; then
                docker push ${image}:latest
            fi
        """
    }
}

Une fois l'image Docker construite et poussée sur le dépôt Docker par la CI, il est possible de l'utiliser au sein d'un init container.

Étape 3 : Utiliser l’image de l’init container dans Kubernetes

Dans notre cas, les manifestes Kubernetes en YAML sont dans un dossier deployments à la racine des applications et le step Jenkins de déploiement va les récupérer de cette manière pour déployer sur l’environnement cible sur GCP :

parseTemplate.buildFolderWithNamespace("deployments", "deploy", namespace, jobContext, "**/*.yaml", true);

Il est donc nécessaire de modifier le manifeste Kubernetes en YAML du Deployment de l’application en y ajoutant un initContainer :

---
apiVersion: apps/v1
kind: Deployment
metadata:
  namespace: api
  name: {{SERVICE_NAME}}-api
  annotations:
    repo: "{{REPO}}"
    commit.id: "{{COMMIT_ID}}"
spec:
  replicas: 3
  selector:
    matchLabels:
      app: {{SERVICE_NAME}}-api
  template:
    metadata:
      labels:
        app: {{SERVICE_NAME}}-api
      annotations:
        cluster-autoscaler.kubernetes.io/safe-to-evict: "true"
    spec:
      initContainers:
        - name: microservice-liquibase-init-container
          image: {{INIT_CONTAINER_IMAGE}}
          imagePullPolicy: Always
          env:
            - name: DB_URL
              valueFrom:
                secretKeyRef:
                  name: {{DB_INSTANCE}}-database-credentials
                  key: URL
            - name: DB_USERNAME
              valueFrom:
                secretKeyRef:
                  name: {{DB_INSTANCE}}-database-credentials
                  key: USERNAME
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: {{DB_INSTANCE}}-database-credentials
                  key: PASSWORD
            - name: DB_PROJECT
              value: {{DB_PROJECT}}
            - name: DB_REGION
              value: {{DB_REGION}}
            - name: DB_INSTANCE
              value: {{DB_INSTANCE}}
          volumeMounts:
            - name: cloudsql-instance-credentials
              mountPath: /secret/cloudsql
              readOnly: true
...
      volumes:
        - name: cloudsql-instance-credentials
          secret:
            secretName: cloudsql-instance-credentials

Dans ce déploiement, bien que 3 replicas soient définis, seul le premier init container associé au premier replica qui se lance lancera Liquibase car un lock est posé en base dans la table DATABASECHANGELOGLOCK.

Dans le fichier de déploiement, les volumes sont déclarés globalement au pod. C’est pourquoi nous pouvons exploiter le volume cloudsql-instance-credentials au sein de l’init container.

En complément des commandes décrites plus haut (docker build... puis docker run...), il est également possible de tester l’init container en local en utilisant Minikube pour installer un cluster dans une VM locale et y appliquer vos manifestes YAML pour les tester.

Étape 4 : Finitions et vérifications

Enfin, il est nécessaire de désactiver Liquibase dans Spring Boot en mettant à false la propriété liquibase.enabled dans vos fichiers de configuration, excepté peut-être de votre configuration locale, où vous n’aurez pas de problématiques liées aux pods, pour continuer de tester les changelogs en dehors de l’init container.

Pour vérifier la bonne utilisation, vous pouvez ajouter un nouveau changelog et aller dans la console GCP dans le menu "Kubernetes Engine" puis "Workloads", sélectionner votre application et aller dans l’onglet "Logs". Vous devriez voir des logs similaires sur votre pod :

2021-11-20T08:20:18.371573786Z Starting Liquibase at 08:20:18 (version 4.4.3 #53 built at 2021-08-05 18:32+0000)
2021-11-20T08:20:18.371611631Z Liquibase Version: 4.4.3
2021-11-20T08:20:19.726421322Z Liquibase command 'update' was executed successfully.

En base de données, vous pouvez également vérifier que vos changelogs sont bien ajoutés avec la requête suivante :

SELECT * FROM DATABASECHANGELOG;

En conclusion

Liquibase est un bon outil de gestion des versions de scripts SQL mais, comme nous venons de le voir, dans un contexte d’exécution et de déploiement Kubernetes géré sur GCP il est parfois nécessaire de le lancer dans un init container dédié. Ce cas de figure se rencontre sans doute également avec Flyway et pourrait donc être transposé.

Et vous, avez-vous déjà été confronté à cette situation ? Sur un autre cloud provider qui gère du Kubernetes ? Dans quel contexte ? N’hésitez pas à partager vos avis et expériences en commentaire !

Author image
Développeur Full/Stack - Tech Lead - Architecte Solutions - Formateur - Contributeur JHipster - Passionné par le violoncelle, la lecture, le cinéma, l'astronomie, la botanique et le sport
Lille LinkedIn