Mettez à l'échelle vos runners GitLab de manière simple et sécurisée avec docker-autoscaler

Introduction

Lors de la mise en place de pipelines d’intégration continue et de déploiement continu, nous avons besoin de runners afin d’exécuter les différentes actions composant ces dernières.

Aussi, GitLab propose différents exécuteurs qui répondent à des besoins spécifiques. Je vous propose d’en détailler certains ci-dessous afin d’en comprendre les principales différences ainsi que les besoins auxquels ils répondent.

Deux principaux aspects sont généralement à prendre en compte lors de la mise en place de runners : la sécurité et la scalabilité. Nous allons donc voir comment GitLab répond à ces problématiques en proposant un nouvel exécuteur : le docker autoscaler.

Etat de l’art - les exécuteurs

GitLab propose différentes méthodes afin d’exécuter des jobs de pipelines, permettant de répondre à divers besoins, en fonction du contexte et des contraintes de chacun. Détaillons ici les exécuteurs principaux que nous avions à disposition jusqu’à présent.

L’exécuteur shell

Cet exécuteur permet d’exécuter des jobs dans un shell, sur la machine où est installé GitLab runner. Il présente l’avantage d’être simple à mettre en place, mais présente aussi de nombreux inconvénients.

Tout d’abord, il faut que la machine soit préconfigurée avec les outils dont vous avez besoin dans vos jobs, ou alors il faut installer ces différents outils au niveau de chaque job. Cependant, les jobs étant exécutés sur la même machine, les actions de l’un impacte directement les actions des autres, et il n’est pas possible d’utiliser différentes versions d’un même outil sans reconfiguration systématique au niveau de chaque job.

De plus, comme nous venons de le voir, un job en impacte directement un autre étant donné qu’ils sont exécutés sur la même machine, et ce sans aucun cloisonnement. Cela représente donc un important risque de sécurité : un utilisateur malveillant pourrait sans aucune difficulté impacter les jobs des autres et potentiellement voler des secrets ou autres informations sensibles présentes dans ces jobs.

Enfin, cet exécuteur ne peut répondre à des besoins importants de mises à l’échelle, étant donné que l’on a qu’une seule machine qui exécute les jobs.

L’exécuteur Docker

Cet exécuteur est similaire au précédent, dans le sens où les jobs sont exécutés sur la même machine que le GitLab runner. En revanche, chaque job est lancé dans un conteneur, ce qui permet d’assurer un cloisonnement entre les processus et donc d’améliorer significativement la sécurité.

En réalité, le cloisonnement, rendu possible grâce aux namespaces linux, ne s’arrête pas aux processus. Si cela vous intéresse, j’avais abordé ce sujet dans cet article.

De plus, il est possible de spécifier à GitLab l’image docker utilisée pour chaque job, ce qui permet d’avoir les outils strictement nécessaires et appropriés, et d’avoir potentiellement des versions différentes du même outil entre différents jobs.

Cependant, comme l’exécuteur shell, cet exécuteur ne peut répondre à des besoins importants de mises à l’échelle.

D’autre part, les jobs étant exécutés dans des conteneurs, il est parfois nécessaire que ces derniers soient lancés en mode privilégié, afin de permettre des actions comme de la construction d’images docker avec docker in docker, ou encore l’utilisation de testcontainers. Ce cas d’usage représente un risque élevé de sécurité étant donné qu’il est possible de s’échapper d’un tel conteneur et d’accéder à l’hôte, permettant de fait d’accéder aux autres jobs s’exécutant sur cette même machine.

L’exécuteur Kubernetes

Cet exécuteur permet d’exécuter des jobs dans des pods sur un cluster Kubernetes.

Il présente les mêmes avantages et inconvénients que l’exécuteur précédent, à savoir le cloisonnement des processus dans des conteneurs, la possibilité de spécifier une image docker pour chaque job, et le risque élevé de sécurité lors de l’utilisation de pod privilégié.

Le principal avantage de cet exécuteur par rapport à l’exécuteur docker est la mise à l’échelle permise grâce à Kubernetes. En effet, GitLab runner crée un pod par job, et profite ainsi des nombreux serveurs composant le cluster. De plus, une majorité de clusters Kubernetes implémente des solutions de mise à l’échelle automatique des nœuds, comme nous l’avons vu dans un précédent article avec Karpenter.

L’exécuteur docker+machine

Cet exécuteur permet d’exécuter des jobs dans des conteneurs, et est donc très similaire à l’exécuteur docker.

La principale différence avec ce dernier réside dans la mise à l’échelle. GitLab runner va créer des machines virtuelles à la volée, permettant de répartir les jobs sur différentes machines, dont le nombre varie en fonction de la demande.

La particularité de l’exécuteur docker+machine est la possibilité de n’exécuter qu’un seul job par machine. Ainsi, l’utilisation de conteneurs privilégiés est bien moins risquée étant donné qu’un seul job est exécuté à la fois, et que la machine est détruite à la fin, remplacée par une nouvelle.

Cet exécuteur répond donc aux principaux besoins énoncés en introduction, à savoir la sécurité et la scalabilité. Cependant, Docker l’a déprécié pour progressivement l’abandonner, obligeant GitLab à créer un fork afin de continuer à le maintenir. Dès la création de ce fork, GitLab a annoncé qu’ils ne corrigeraient que les problèmes critiques et ne proposeraient aucune nouvelle fonctionnalité, n’ayant probablement pas la bande passante pour reprendre l’entièreté du projet.

C’est ainsi qu’est né le besoin d’un nouvel exécuteur.

Une nouvelle solution - l’exécuteur docker-autoscaler

Ce nouvel exécuteur est similaire à l’exécuteur docker+machine, puisque qu’il s’agit de son successeur.

GitLab a fait le choix de repartir sur un projet from scratch, et de prendre une approche un peu différente de ce qui était fait avec docker+machine.

Tout d’abord, tout comme son prédécesseur, il enveloppe l’exécuteur docker afin de lancer des jobs dans des conteneurs et de bénéficier des mêmes options et fonctionnalités.

Afin de contrôler une flotte de serveurs, GitLab passe par l’utilisation de plugins. Ces plugins permettent de supporter différents fournisseurs de cloud, tels que AWS, Azure et GCP. Il s’agit d’un binaire à installer en plus de GitLab runner, en fonction du fournisseur choisi.

Il est important de noter que les plugins pour AWS, Azure et GCP sont actuellement en beta.

En plus de ces plugins, GitLab runner a besoin de quelques ressources. Par exemple, pour AWS, cet exécuteur a besoin des ressources suivantes :

  • Une AMI contenant docker : elle sera utilisée pour initialiser chaque nouvelle EC2
  • Un autoscaling group : il sera géré par GitLab runner qui ajoutera ou supprimera des instances en fonction de la demande en jobs côté GitLab
  • Une politique IAM : elle sera utilisée par GitLab runner afin de pouvoir modifier les caractéristiques de l’autoscaling group et se connecter aux différentes instances

On entend parfois parler de “runner manager”, qui est le processus responsable de créer le nécessaire (instances, pods, conteneurs, …) afin d’exécuter des jobs GitLab. Un runner de type “kubernetes” peut exécuter plusieurs jobs en parallèle sur plusieurs pods, mais il apparaît comme un seul runner dans l’interface de GitLab. Ce processus peut s’exécuter n’importe où (dans un cluster Kubernetes, dans une EC2, …), dès lors qu’il a les droits nécessaires afin de créer des instances / pods / conteneurs en fonction du type d’exécuteur choisi (politique IAM ci-dessus).

Comme vous pouvez le constater, GitLab a essayé de simplifier au maximum la configuration, ce qui est plutôt réussi.

Mettre en place des runners GitLab avec l’exécuteur docker-autoscaler

Maintenant que vous en savez plus, nous allons passer à la pratique en déployant des runners GitLab avec l’exécuteur docker-autoscaler sur le cloud d’AWS.

Prérequis

Vous devez avoir accès à un compte AWS.

Bien que GitLab runner puisse être déployé de différentes manières (chart Helm, conteneur, service linux, etc), je vous recommande de le déployer à l’aide du chart Helm officiel dans un cluster Kubernetes si vous en disposez d’un. J’ai donc au préalable déployé un cluster EKS.

Bien évidemment, si vous ne disposez pas de cluster Kubernetes, il est beaucoup plus simple et rapide de déployer GitLab runner dans une EC2 ou un conteneur Fargate. La configuration GitLab runner reste sensiblement la même.

Créer une image docker gitlab-runner avec le plugin AWS

Comme nous l’avons vu, nous avons besoin du plugin AWS.

Nous pouvons donc créer une image docker afin d’ajouter le plugin à l’image de base fournie par GitLab :

FROM registry.gitlab.com/gitlab-org/gitlab-runner:alpine-v16.9.0

ARG AWS_FLEETING_PLUGIN_VERSION=v0.4.0

RUN wget -O /usr/bin/fleeting-plugin-aws https://gitlab.com/gitlab-org/fleeting/fleeting-plugin-aws/-/releases/${AWS_FLEETING_PLUGIN_VERSION}/downloads/fleeting-plugin-aws-linux-arm64 && \
    chmod +x /usr/bin/fleeting-plugin-aws

Créer les ressources AWS nécessaires

Créons à présent les ressources AWS qui seront utilisées par GitLab runner. Nous utiliserons ici Terraform pour le faire.

Commençons par créer l’autoscaling group :

resource "aws_autoscaling_group" "gitlab_runner_autoscaling_group" {
 name                  = lower("${var.tf_stack}-${var.environment}-gitlab-runner")
 desired_capacity      = 0
 min_size              = 0
 max_size              = 10
 vpc_zone_identifier   = var.private_subnets_ids
 suspended_processes   = ["AZRebalance"]
 protect_from_scale_in = true


 launch_template {
   id      = aws_launch_template.gitlab_runner_launch_template.id
   version = "$Latest"
 }
}

Il faut que les capacités désirées et minimales soient à 0. De plus, il est nécessaire de suspendre le processus AZRebalance et d’ajouter la protection de scale in.

Il est possible de spécifier plusieurs sous-réseaux privés afin d'augmenter la résilience et la disponibilité des runners.

À présent, créons le launch template associé :

resource "aws_launch_template" "gitlab_runner_launch_template" {
 name                   = lower("${var.tf_stack}-${var.environment}-gitlab-runner")
 instance_type          = var.instance_type
 image_id               = var.image_id
 user_data              = filebase64("${path.module}/init.sh")
 vpc_security_group_ids = [aws_security_group.gitlab_runner_security_group.id]


 block_device_mappings {
   device_name = "/dev/sda1"


   ebs {
     volume_size           = var.instance_volume_size
     delete_on_termination = true
     encrypted             = true
   }
 }


 tag_specifications {
   resource_type = "instance"


   tags = {
     Name = lower("${var.tf_stack}-${var.environment}-gitlab-runner-ci-privileged")
   }
 }


 tags = {
   Name = lower("${var.tf_stack}-${var.environment}-gitlab-runner")
 }
}

Ici, pour éviter de devoir créer une AMI et par simplicité, nous utilisons l’AMI ubuntu avec les user data suivantes :

#!/bin/bash


apt update
apt install -y ca-certificates curl gnupg
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo \
 "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu \
 $(. /etc/os-release && echo "$VERSION_CODENAME") stable" | \
 tee /etc/apt/sources.list.d/docker.list > /dev/null
apt update
apt install -y docker-ce docker-ce-cli containerd.io


usermod -a -G docker ubuntu

Ce script installe docker sur la machine et ajoute l’utilisateur ubuntu au groupe docker.

Pour une utilisation en production, je vous conseille de construire une AMI ayant déjà docker d’installé dessus, ou d’utiliser l’AMI officielle de docker.

Maintenant, créons le security group associé :

resource "aws_security_group" "gitlab_runner_security_group" {
 name   = lower("${var.tf_stack}-${var.environment}-gitlab-runner")
 vpc_id = var.vpc_id


 tags = {
   Name = lower("${var.tf_stack}-${var.environment}-gitlab-runner")
 }
}


resource "aws_vpc_security_group_egress_rule" "gitlab_runner_security_group_egress_rule" {
 security_group_id = aws_security_group.gitlab_runner_security_group.id
 cidr_ipv4         = "0.0.0.0/0"
 ip_protocol       = "-1"
}


resource "aws_vpc_security_group_ingress_rule" "gitlab_runner_security_group_ingress_rule" {
 security_group_id = aws_security_group.gitlab_runner_security_group.id
 cidr_ipv4         = var.vpc_cidr_block
 ip_protocol       = "tcp"
 from_port         = 22
 to_port           = 22
}

Rien de très compliqué à ce niveau là comme vous pouvez le constater.

Comme nous l’avons vu, nous avons besoin d’un rôle IAM assumable par le runner GitLab et ayant les droits d'interagir avec l’autoscaling group :

data "aws_iam_policy_document" "gitlab_runner_assume_role_policy" {
 statement {
   actions = ["sts:AssumeRoleWithWebIdentity"]
   effect  = "Allow"


   condition {
     test     = "StringEquals"
     variable = "${replace(var.eks_cluster_openid_connect_provider_url, "https://", "")}:sub"
     values   = ["system:serviceaccount:gitlab-runner:gitlab-runner-ci-privileged"]
   }


   principals {
     identifiers = [var.eks_cluster_openid_connect_provider_arn]
     type        = "Federated"
   }
 }
}


resource "aws_iam_role" "gitlab_runner_iam_role" {
 name               = lower("${var.tf_stack}-${var.environment}-gitlab-runner")
 assume_role_policy = data.aws_iam_policy_document.gitlab_runner_assume_role_policy.json


 tags = {
   Name = lower("${var.tf_stack}-${var.environment}-gitlab-runner")
 }
}

Dans notre exemple, nous déployons le runner GitLab dans un cluster EKS. Nous utilisons donc la mécanique de IAM Roles for Service Accounts (IRSA), décrite dans cet article de Timothée AUFORT.

Ensuite, nous créons la politique IAM suivante :

data "aws_region" "current" {}


data "aws_caller_identity" "current" {}


resource "aws_iam_policy" "gitlab_runner_iam_policy" {
 name = lower("${var.tf_stack}-${var.environment}-gitlab-runner")


 policy = jsonencode({
   "Version" : "2012-10-17",
   "Statement" : [
     {
       "Effect" : "Allow",
       "Action" : [
         "autoscaling:SetDesiredCapacity",
         "autoscaling:TerminateInstanceInAutoScalingGroup"
       ],
       "Resource" : "${aws_autoscaling_group.gitlab_runner_autoscaling_group.arn}"
     },
     {
       "Effect" : "Allow",
       "Action" : [
         "autoscaling:DescribeAutoScalingGroups",
         "ec2:DescribeInstances"
       ],
       "Resource" : "*"
     },
     {
       "Effect" : "Allow",
       "Action" : "ec2-instance-connect:SendSSHPublicKey",
       "Resource" : "arn:aws:ec2:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:instance/*",
       "Condition" : {
         "StringEquals" : {
           "ec2:ResourceTag/aws:autoscaling:groupName" : "${aws_autoscaling_group.gitlab_runner_autoscaling_group.name}"
         }
       }
     }
   ]
 })


 tags = {
   Name = lower("${var.tf_stack}-${var.environment}-gitlab-runner")
 }
}

GitLab propose ici d’appliquer le principe de moindre privilège afin de réduire la potentielle surface d’attaque.

Il ne nous reste plus qu’à attacher cette politique à notre rôle :

resource "aws_iam_role_policy_attachment" "gitlab_runner_iam_role_policy_attachment" {
 role       = aws_iam_role.gitlab_runner_iam_role.name
 policy_arn = aws_iam_policy.gitlab_runner_iam_policy.arn
}

Et voilà ! Nous sommes prêts pour déployer le runner GitLab dans notre cluster EKS.

Déployer GitLab runner

Nous pouvons déployer GitLab runner à l’aide du chart Helm officiel et de la commande suivante :

helm install gitlab-runner gitlab-runner \
--repo https://charts.gitlab.io \
--namespace gitlab-runner \
--create-namespace \
--values values.yaml

La configuration se trouve dans le fichier values.yaml suivant :

image:
 image: my.registry.io/gitlab-runner
 tag: alpine-v16.9.0
gitlabUrl: https://gitlab.com
runnerToken: the-runner-token
terminationGracePeriodSeconds: 3600
concurrent: 10
checkInterval: 3
rbac:
 create: true
 serviceAccountAnnotations:
   eks.amazonaws.com/role-arn: arn:aws:iam::696173926462:role/test-gitlab-runner
runners:
 executor: docker-autoscaler
 config: |
   [[runners]]
     concurrent = 10
     check_interval = 3
     executor = "docker-autoscaler"
     [runners.docker]
       image = "ubuntu:22.04"
       pull_policy = "if-not-present"
       privileged = false
       services_privileged = true
       allowed_privileged_services = ["docker:*-dind"]
     [runners.autoscaler]
       plugin = "fleeting-plugin-aws"
       capacity_per_instance = 1
       max_use_count = 1
       max_instances = 10
       [runners.autoscaler.plugin_config]
         name = "test-gitlab-runner"
       [runners.autoscaler.connector_config]
         username = "ubuntu"
         use_external_addr = true
       [[runners.autoscaler.policy]]
         idle_count = 1
         idle_time = "30m"

Nous configurons GitLab runner ainsi :

  • nous utilisons notre image GitLab runner incluant le plugin AWS
  • nous utilisons l’exécuteur docker-autoscaler
  • nous configurons l’autoscaler pour utiliser le plugin AWS
  • nous définissons le nombre maximal de jobs en parallèle sur une même instance à 1 afin de minimiser les risques de sécurité vu précédemment
  • de la même manière, nous définissons le nombre maximal d’utilisation de chaque instance à 1
  • nous définissons le nombre maximal d’instances sur la même valeur que la capacité maximale de l’autoscaling group, à savoir 10
  • nous configurons l’autoscaler pour qu’il utilise l’autoscaling group test-gitlab-runner et l’utilisateur ubuntu
  • enfin, nous configurons l’autoscaler afin de toujours avoir 1 instance en attente (IDLE) et ainsi minimiser le temps d’attente des jobs

Dans cet exemple, nous mettons l’accent sur la sécurité en définissant le nombre maximal de jobs sur une même instance à 1. Cela a donc un impact direct sur le temps d’attente des jobs, temps nécessaire à l’instanciation d’une nouvelle instance. Pour limiter ce temps d’attente, il est possible d’augmenter le nombre d'instances en attente (IDLE), mais cela peut avoir un impact sur les coûts. Sinon, il est tout à fait possible d’autoriser plusieurs jobs à s’exécuter sur la même machine, que ce soit en parallèle ou non, au détriment de la sécurité pour ce qui est des conteneurs privilégiés.

Et voilà ! Vous savez désormais comment déployer des runners GitLab avec l’exécuteur docker-autoscaler, vous promettant scalabilité mais aussi et surtout sécurité !

Conclusion

Dans cet article, nous avons vu différents exécuteurs de runner développés par GitLab afin d’exécuter des jobs de pipelines, ainsi que leurs intérêts et leurs limites.

La nouvelle solution proposée est prometteuse, notamment pour répondre à des besoins de sécurité et de scalabilité, bien qu’elle soit officiellement encore en bêta.

Sources