Dans le dernier post, nous avons vu comment créer un cluster Kubernetes (K8s) prêt pour la production sur AWS avec Kops. Maintenant voyons comment, couplé avec des services managés AWS, on peut utiliser ce cluster pour héberger une application hautement disponible : Gitlab.

Connaitre Terraform, AWS et Kubernetes sera un plus pour la compréhension de cet article.

Tout le code source utilisé dans ce post est disponible sur Github.

Architecture de Gitlab

Gitlab est un concurrent open-source de Github. Il est composé de plusieurs parties :

  • Une base de données relationnelle (PostgreSQL est le défaut),
  • Un système de fichiers « distribué » pour les dépôts Git,
  • Un serveur Redis pour le cache et les sessions,
  • Une application « core » avec des serveurs Unicorn, SSH et et Sidekiq (tout cela est dans l’image Docker créée par Sameer Naik – merci !).

Problème

Le principal problème lorsque l’on crée une application hautement disponible sur K8s/AWS est le stockage, que ce soit sous la forme d’une base de données ou d’un filesystem. Les disques EBS que l’on peut attacher aux instances EC2 auraient été les candidats naturels car Kubernetes sait les gérer. Mais un disque EBS est lié à une availability zone et n’est donc pas hautement disponible. Vous pouvez transporter des données d’une AZ à une autre avec un snapshot, mais ce n’est vraiment pas pratique.

Architecture cible

Pour assurer la haute disponibilité sur notre installation Gitlab, nous allons utiliser 2 services AWS :

  • AWS RDS (Relational Database Service) pour fournir une base PostgreSQL HA,
  • AWS EFS (Elastic Filesystem) pour un filesystem HA, accessible via NFS.

Cela a l’air simple, mais l’implémentation EFS a un petit piège : il y a un point de montage différente pour chaque AZ.

Redis et Gitlab en tant que tels seront des déploiements K8s.

Voici à quoi ça ressemble :

gitlab-kubernetes.png

Comme vous pouvez le voir sur ce schéma, nous aurons en fait une instance Gitlab par AZ. Dans chaque AZ, Gitlab a besoin d’accéder au stockage EFS, mais le point de montage change d’une AZ à une autre. Donc pour fournir de la haute disponibilité, nous avons besoin d’au moins 2 instances Gitlab qui utilisent 2 points de montage différents.

Implémentation

Ressources AWS – Terraform

Import des ressources Kops

Nous allons utiliser Terraform pour créer les ressources AWS nécessaires pour Gitlab. Nous allons avoir besoin d’interagir avec les ressources créées par Kops (le VPC et les subnets) : nous devons donc les importer dans Terraform. Heureusement, la fonctionnalité import a été récemment ajoutée à Terraform ! Il faut juste exécuter les commandes suivantes avec les bons IDs :

terraform import aws_vpc.kops_vpc vpc-xxxxxx
terraform import aws_subnet.kops_suba subnet-xxxxxx
terraform import aws_subnet.kops_subb subnet-xxxxxx
terraform import aws_subnet.kops_subc subnet-xxxxxx

Une fois les commandes exécutées, vous devez créer un fichier kops.tf qui contient ces ressources, sinon Terraform essayera de les détruire au prochain run :

# Resource managed by KOPS DO NOT TOUCH
resource "aws_vpc" "kops_vpc" {
  cidr_block = "10.0.0.0/22"
  tags {
    Name = "k8s.myzone.net"
    KubernetesCluster = "k8s.myzone.net"
  }
}
# Resource managed by KOPS DO NOT TOUCH
resource "aws_subnet" "kops_suba" {
  vpc_id = "${aws_vpc.kops_vpc.id}"
  cidr_block = "10.0.0.128/25"
  tags {
    Name = "eu-west-1a.k8s.myzone.net"
    KubernetesCluster = "k8s.myzone.net"
  }
}
# Resource managed by KOPS DO NOT TOUCH
resource "aws_subnet" "kops_subb" {
  vpc_id = "${aws_vpc.kops_vpc.id}"
  cidr_block = "10.0.1.0/25"
  tags {
    Name = "eu-west-1b.k8s.myzone.net"
    KubernetesCluster = "k8s.myzone.net"
  }
}
# Resource managed by KOPS DO NOT TOUCH
resource "aws_subnet" "kops_subc" {
  vpc_id = "${aws_vpc.kops_vpc.id}"
  cidr_block = "10.0.1.128/25"
  tags {
    Name = "eu-west-1c.k8s.myzone.net"
    KubernetesCluster = "k8s.myzone.net"
  }
}

Une fois ce fichier créé, vous devriez pouvoir exécuter un terraform plan et Terraform ne devrait rien modifier.

Création des ressources RDS et EFS

Une fois les ressources réseau de Kops importées, vous pouvez provisionner ce dont on a besoin pour Gitlab dans un fichier gitlab.tf :

variable "node_sg_id" {
  # Here, the security group created by Kops for the worker nodes
  default = "sg-xxxxxx"
}
variable "master_sg_id" {
  # Here, the security group created by Kops for the master nodes
  default = "sg-xxxxxx"
}
resource "aws_efs_file_system" "gitlab_nfs" {
  tags {
    Name = "k8s.myzone.net"
    KubernetesCluster = "k8s.myzone.net"
  }
}
resource "aws_security_group" "EFS_K8s" {
  name = "EFS_K8s"
  description = "Allow NFS inbound traffic"
  vpc_id = "${aws_vpc.kops_vpc.id}"
  ingress {
    from_port = 0
    to_port = 0
    protocol = "-1"
    security_groups =  ["${var.node_sg_id}", "${var.master_sg_id}"]
  }
  egress {
    from_port = 0
    to_port = 0
    protocol = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
  tags {
    Name = "EFS_K8s"
    KubernetesCluster = "k8s.myzone.net"
  }
}
resource "aws_efs_mount_target" "gitlab_nfsa" {
  file_system_id = "${aws_efs_file_system.gitlab_nfs.id}"
  subnet_id = "${aws_subnet.kops_suba.id}"
  security_groups = ["${aws_security_group.EFS_K8s.id}"]
}
resource "aws_efs_mount_target" "gitlab_nfsb" {
  file_system_id = "${aws_efs_file_system.gitlab_nfs.id}"
  subnet_id = "${aws_subnet.kops_subb.id}"
  security_groups = ["${aws_security_group.EFS_K8s.id}"]
}
resource "aws_efs_mount_target" "gitlab_nfsc" {
  file_system_id = "${aws_efs_file_system.gitlab_nfs.id}"
  subnet_id = "${aws_subnet.kops_subc.id}"
  security_groups = ["${aws_security_group.EFS_K8s.id}"]
}
output "NFS_mount_points" {
  value = "${aws_efs_mount_target.gitlab_nfsa.dns_name} ${aws_efs_mount_target.gitlab_nfsb.dns_name} ${aws_efs_mount_target.gitlab_nfsc.dns_name}"
}
resource "aws_db_subnet_group" "gitlab_pgsql" {
  name = "gitlab_pgsql"
  subnet_ids = ["${aws_subnet.kops_suba.id}", "${aws_subnet.kops_subb.id}", "${aws_subnet.kops_subc.id}"]
  tags {
    Name = "Gitlab PgSQL"
    KubernetesCluster = "k8s.myzone.net"
  }
}
resource "aws_security_group" "gitlab-pgsql" {
  name = "gitlab-pgsql"
  description = "Allow PgSQL inbound traffic"
  vpc_id = "${aws_vpc.kops_vpc.id}"
  ingress {
    from_port = 5432
    to_port = 5432
    protocol = "TCP"
    security_groups =  ["${var.node_sg_id}", "${var.master_sg_id}"]
  }
  egress {
    from_port = 0
    to_port = 0
    protocol = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
  tags {
    Name = "gitlab-pgsql"
    KubernetesCluster = "k8s.myzone.net"
  }
}
resource "aws_db_instance" "gitlab-pgsql" {
  allocated_storage       = "50"
  engine                  = "postgres"
  engine_version          = "9.3.14"
  identifier              = "gitlab-pgsql"
  instance_class          = "db.t2.medium"
  storage_type            = "gp2"
  name                    = "gitlab_production"
  password                = "yourpassword"
  username                = "gitlab"
  backup_retention_period = "30"
  backup_window           = "04:00-04:30"
  maintenance_window      = "sun:04:30-sun:05:30"
  multi_az                = true # <= important!
  port                    = "5432"
  vpc_security_group_ids  = ["${aws_security_group.gitlab-pgsql.id}"]
  db_subnet_group_name    = "${aws_db_subnet_group.gitlab_pgsql.name}"
  storage_encrypted       = false
  auto_minor_version_upgrade = true
  tags {
    Name        = "gitlab-pgsql"
    KubernetesCluster = "k8s.myzone.net"
  }
}
output "PgSQL_endpoint" {
  value = "${aws_db_instance.gitlab-pgsql.endpoint}"
}

Après un terraform plan/apply, les endpoints NFS et PostgreSQL devraient être affichés.

Qu’est-ce que ce code crée ?

  • Une instance RDS/PostgreSQL avec l’option multi-AZ activée,
  • Un filesystem EFS et ces points de montage associés dans chaque AZ,
  • Les différents security groups requis.

Ressources Kubernetes

Une fois que nous avons créé tout ce dont nous avons besoin sur AWS, on peut passer à K8s !

Pour chaque bout de yaml dans cet article, vous pouvez créer les ressources associées en mettant le code dans un fichier.yaml puis en exécutant kubectl apply -f fichier.yaml.

Persistent Volumes

Commençons par les objets de type PersistentVolume. Comme nous avons 3 endpoints EFS, nous avons besoin de 3 PersistentVolumes, même si c’est pour accéder aux mêmes données. Remarquez qu’on labélise chaque PV avec l’AZ : nous allons utiliser ces labels pour choisir le PV dans les deployments Gitlab.

Dans la déclaration des PVs, il faut utiliser les endpoints donnés par votre dernier run Terraform.

---
apiVersion: v1
kind: PersistentVolume
metadata:
  name: gitlab.data.efs.a
  labels:
    usage: gitlab-data
    zone: eu-west-1a
spec:
  capacity:
    storage: 100Gi
  accessModes:
    - ReadWriteMany
  nfs:
    server: eu-west-1a.fs-xxxxxx.efs.eu-west-1.amazonaws.com
    path: "/"
---
apiVersion: v1
kind: PersistentVolume
metadata:
  name: gitlab.data.efs.b
  labels:
    usage: gitlab-data
    zone: eu-west-1b
spec:
  capacity:
    storage: 100Gi
  accessModes:
    - ReadWriteMany
  nfs:
    server: eu-west-1b.fs-xxxxxx.efs.eu-west-1.amazonaws.com
    path: "/"
---
apiVersion: v1
kind: PersistentVolume
metadata:
  name: gitlab.data.efs.c
  labels:
    usage: gitlab-data
    zone: eu-west-1c
spec:
  capacity:
    storage: 100Gi
  accessModes:
    - ReadWriteMany
  nfs:
    server: eu-west-1c.fs-xxxxxx.efs.eu-west-1.amazonaws.com
    path: "/"

Redis

Nous n’avons pas besoin d’une installation compliquée pour Redis, allons donc au plus simple :

---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: redis
spec:
  replicas: 1
  template:
    metadata:
      labels:
        name: redis
    spec:
      containers:
      - name: redis
        image: redis
        ports:
        - containerPort: 6379
---
apiVersion: v1
kind: Service
metadata:
  name: redis
spec:
  ports:
  - port: 6379
    protocol: TCP
    targetPort: 6379
  selector:
    name: redis

Le Deployment fera en sorte qu’il y aura toujours un serveur Redis (replicas: 1) et le Service permettra d’y accéder simplement avec l’adresse redis.

Gitlab Deployment

Comme indiqué précédemment, nous avons besoin d’au moins 2 instances Gitlab, réparties sur 2 AZs. Nous allons donc créer un Deployment Gitlab par AZ. Le code présenté ici est pour l’AZ a. Pour les AZs b et c, il faut juste copier/coller et changer le a par b ou c là où il faut.

Il y deux choses importantes à noter :

  • Nous utilisons des variables d’environnement pour configurer l’image Docker Gitlab, et les mots de passe sont stockés dans un secret Kubernetes. Les secrets sont « chiffrés » en base64, il ne faut donc pas les inclure dans votre dépôt Git.
  • Chaque Deployment doit être restreint à une AZ, nous utilisons donc des filtres pour sélectionner les ressources selon leurs labels.

Par exemple, le PersistentVolumeClaim va utiliser le sélecteur suivant pour choisir le bon endpoint EFS :

selector:
  matchLabels:
    usage: gitlab-data
    zone: eu-west-1a

De manière similaire, nous devons restreindre le déploiement Gitlab à une AZ, et nous devons donc sélectionner sur quel nœud il doit tourner :

nodeSelector:
  failure-domain.beta.kubernetes.io/zone: eu-west-1a

Voici le code complet pour une seule AZ :

---
apiVersion: v1
kind: Secret
metadata:
  name: gitlab-secrets
type: Opaque
data:
  db-key-base: base64-encoded-key
  secret-key-base: base64-encoded-key
  otp-key-base: base64-encoded-key
  db-pass: base64-encoded-password
  root-pass: base64-encoded-password
---
kind: PersistentVolumeClaim
apiVersion: v1
metadata:
  name: gitlab.data.efs.a
spec:
  accessModes:
    - ReadWriteMany
  resources:
    requests:
      storage: 100Gi
  selector:
    matchLabels:
      usage: gitlab-data
      zone: eu-west-1a
---
apiVersion: extensions/v1beta1
kind: Deployment
metadata:
  name: gitlab-a
spec:
  replicas: 1
  template:
    metadata:
      labels:
        name: gitlab-a
        app: gitlab
    spec:
      nodeSelector:
        failure-domain.beta.kubernetes.io/zone: eu-west-1a
      containers:
      - name: gitlab-a
        image: sameersbn/gitlab:8.12.6
        imagePullPolicy: Always
        ports:
        - containerPort: 80
        env:
        - name: TZ
          value: Europe/Paris
        - name: GITLAB_TIMEZONE
          value: Paris
        - name: GITLAB_SECRETS_DB_KEY_BASE
          valueFrom:
            secretKeyRef:
              name: gitlab-secrets
              key: db-key-base
        - name: GITLAB_SECRETS_SECRET_KEY_BASE
          valueFrom:
            secretKeyRef:
              name: gitlab-secrets
              key: secret-key-base
        - name: GITLAB_SECRETS_OTP_KEY_BASE
          valueFrom:
            secretKeyRef:
              name: gitlab-secrets
              key: otp-key-base
              - name: GITLAB_ROOT_PASSWORD
                valueFrom:
                  secretKeyRef:
                    name: gitlab-secrets
                    key: root-pass
              - name: GITLAB_HOST
                value: git.default.cluster.local
              - name: GITLAB_PORT
                value: "80"
              - name: GITLAB_SSH_PORT
                value: "22"
              - name: GITLAB_NOTIFY_ON_BROKEN_BUILDS
                value: "true"
              - name: GITLAB_NOTIFY_PUSHER
                value: "false"
              - name: DB_TYPE
                value: postgres
              - name: DB_HOST
                # Value given by Terraform
                value: gitlab-pgsql.xxxxxx.eu-west-1.rds.amazonaws.com
              - name: DB_PORT
                value: "5432"
              - name: DB_USER
                value: gitlab
              - name: DB_PASS
                valueFrom:
                  secretKeyRef:
                    name: gitlab-secrets
                    key: db-pass
              - name: DB_NAME
                value: gitlab_production
              - name: REDIS_HOST
                value: redis
              - name: REDIS_PORT
                value: "6379"
              ports:
              - name: http
                containerPort: 80
              - name: ssh
                containerPort: 22
              volumeMounts:
              - mountPath: /home/git/data
              livenessProbe:
                httpGet:
                  path: /
                  port: 80
                initialDelaySeconds: 180
                timeoutSeconds: 5
              readinessProbe:
                httpGet:
                  path: /
                  port: 80
                initialDelaySeconds: 5
                timeoutSeconds: 1
            volumes:
            - name: data
              persistentVolumeClaim:
                claimName: gitlab.data.efs.a

Service Gitlab

Presque terminé ! Quand les 3 Deployements Gitlab sont créés, on peut les lier avec un Service :

apiVersion: v1
kind: Service
metadata:
  name: gitlab
spec:
  ports:
  - name: http
    port: 80
    protocol: TCP
    targetPort: 80
  - name: ssh
    port: 22
    protocol: TCP
    targetPort: 22
  selector:
    app: gitlab
  type: LoadBalancer

Remarquez le type: LoadBalancer : il va automatiquement créer un ELB pour rendre Gitlab accessible depuis l’extérieur !

Conclusion

Bon, c’était beaucoup à la fois ! Mais maintenant vous avez un Gitlab hautement disponible qui va pouvoir facilement scaler si besoin ! Seuls quelques petites choses manquent pour avoir une installation à l’état de l’art : du HTTPS et des runners pour GitlabCI (j’aborderai peut-être le sujet dans un prochain post).

La chose principale dont il faut se souvenir, c’est qu’il est possible d’héberger des applications stateful sur Kubernetes, pour peu que vous ayez un stockage hautement disponible. Dans ce cas, nous avons utilisé RDS et EFS et nous avons dû trouver des contournements pour certaines propriétés d’EFS, mais cette méthode pourrait être appliquée à de nombreuses applications. Comprendre les principes sous-jacents à cette installation vous permettra de les adapter à votre cas.

Auteur : Theo Chamley