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 :
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