Environnement de déploiement continu avec Jenkins Configuration As Code et Terraform dans EKS

ACEGIK
10 min readJul 13, 2020

--

Nous avons eu toujours besoin d’une plateforme de déploiement continue dans un cadre professionnel ou personnel (démonstration client, faire passer un entretien, tester des fonctionnalités, …). Avoir une plateforme fiable et robuste est un peu coûteux pour un besoin ponctuel. Pour cela, nous avons réalisé des scripts permettant de monter/arrêter tous les outils nécessaires avec le minimum d’intervention humaine.

Dans cet article, nous décrirons l’installation de Jenkins dans kubernetes en utilisant Terraform, Helm et JCasC. Puis nous déploierons une application en utilisant un pipeline Jenkins.

Concepts

  • Kubernetes : C’est une plateforme d’orchestration des services conteneurisés.
  • EKS : C’est le service kubernetes entièrement géré par AWS.
  • Terraform : C’est un outil de gestion d’infrastructure dans le cloud en tant que code (Infrastructure as Code).
  • Helm : C’est un gestionnaire de paquets pour Kubernetes.
  • Jenkins : C’est un outil d’intégration continue.
  • JCasC: C’est un plugin jenkins qui permet de personnaliser jenkins à partir d’un fichier de configuration.
  • Docker : C’est une plateforme de gestion de conteneur.

Prérequis

  • Compte AWS.
  • Terraform.
  • AWS IAM Authenticator.
  • wget (utilisé par le module EKS de terraform).

Architecture

Usine logicielle

Il existe plusieurs façons pour créer une plateforme CI/CD distribuée (Agent VM, docker, Kubernetes,…). Nous allons opter pour celle qui basée sur Kubernetes car elle répond aux besoins suivants :

  • Pouvoir exécuter des jobs en parallèle.
  • Instancier des agents à la demande.
  • Terminer les agents une fois le job est fini.

Les utilisateurs (Devs, ops, chef de projets) peuvent accéder à Jenkins via internet. Le trafic sera redirigé par un Loadblancer vers le master Jenkins déployé dans un pod dans une instance EC2 à la demande. Si l’utilisateur démarre un job, le master Jenkins instancie un agent dans un pod au sein d’une instance spot.

Architecture de l’usine logicielle.

Nous allons utiliser des fichiers de configuration afin de déployer l’infrastructure dans laquelle sera hébergée notre plateforme de déploiement continu. Ces fichiers sont organisés par module.

  • Root module : Ce module est le point d’entrée de Terraform. Il contient la déclaration des modules ainsi que le passage des variables entre eux, les providers et les variables d’entrée.
  • Network module : Ce module permet de créer toute la stack réseau.
  • Cluster module : Ce module permet d’approvisionner le cluster Kubernetes. Il prend en entrée l’identifiant de la stack réseau, crée et retourne la configuration nécessaire pour pouvoir se connecter au cluster.
  • Jenkins module : En se basant sur Helm et JCasC, ce module crée l’instance jenkins. Il prend en entrée la configuration du cluster Kubernetes et retourne le DNS du service Jenkins.
Arbre de dépendances terraform.

Environnement de déploiement

Pour pouvoir déployer nos applications, nous aurons besoin de deux clusters. Un premier destiné à la pré-production et un deuxième à la production. Pour créer ces deux environnements avec les mêmes scripts, nous allons utiliser les workspaces de Terraform.

Architecture de l’application.

Mise en pratique

Usine logicielle

Tout le code source est disponible dans les repos github suivants :

Nous n’allons pas revenir sur chaque ligne de code mais seulement sur quelques points.

main.tf dans le root module

module "jenkins" { 
source = "./modules/jenkins" #1
kubeconfig = module.cluster.kubeconfig #2
}
module "cluster" {
source = "./modules/cluster"

namespace = var.namespace
vpc = module.network.vpc #2
}
module "network" {
source = "./modules/network"
namespace = var.namespace
}
  • #1 : Source locale du module.
  • #2 : Variable d’entrée récupérée à partir de l’output d’un autre module.

outputs.tf dans le module root

output "passwords" {  
description = "users' password"
value = module.jenkins.passwords #1
}
output "lb_dns_name" {
description = "FQDN of ALB provisioned for jenkins service"
value = module.jenkins.jenkins_svc #2
}
  • #1 : Afficher les mots de passe des utilisateurs créés par JCasC.
  • #2 : Afficher le FQDN du service Jenkins.

main.tf le module cluster

module "eks" {    
source = "terraform-aws-modules/eks/aws" #1
cluster_name = local.cluster_name
subnets = var.vpc.public_subnets #2
vpc_id = var.vpc.vpc_id
worker_groups = [
{
name = "on-demand-1" #3
instance_type = "t2.medium"
asg_max_size = 1
kubelet_extra_args = "--node- labels=node.kubernetes.io/lifecycle=normal" #4
suspended_processes = ["AZRebalance"]
},
{
name = "spot-1" #5
spot_price = "0.016"
instance_type = "t2.medium"
asg_max_size = 1
kubelet_extra_args = "--node-labels=node.kubernetes.io/lifecycle=spot" #6
suspended_processes = ["AZRebalance"]
}
]}
  • #1: Source du module eks publié dans la registry des modules Terraform.
  • #2: Variable d’entrée récupérée à partir de l’output du module network.
  • #3: Instance ec2 on-demand.
  • #4: Label permettant ultérieurement d’affecter des pods à ce noeud.
  • #5: Instance ec2 spot.
  • #6: Label permettant ultérieurement d’affecter des pods à ce noeud.

main.tf dans le module jenkins

resource "random_id" "random_16" { 
byte_length = 16 * 3 / 4
}
resource "random_string" "suffix" {
length = 8
special = false
}
locals {
passwords = { #1
admin = random_id.random_16.b64_url
candidat = random_string.suffix.result
}
}
resource "local_file" "kube_config" {
content = var.kubeconfig
filename = "kubeconfig.yaml" #2
}
resource "helm_release" "jenkins" {
name = "jenkins"
repository = "https://kubernetes-charts.storage.googleapis.com/"
chart = "jenkins"
version = "2.1.0"

values = ["${file("${path.module}/values.yaml")}"]
set {
name = "master.JCasC.configScripts.securityrealm"
value = templatefile("${path.module}/jenkins-securityrealm.yaml", local.passwords) #3
}
set {
name = "master.JCasC.configScripts.authorizationstrategy"
value = file("${path.module}/jenkins-authorizationstrategy.yaml")
}
}
data "kubernetes_service" "jenkins" {
metadata {
name = "jenkins" #4
}
depends_on = [helm_release.jenkins]
}
  • #1: Mot de passe à affecter aux utilisateurs admin et candidat.
  • #2: Sauvegarde de la configuration de connexion au cluster EKS dans un fichier passé en paramètre au provider helm.
  • #3: Plugins jenkins à installer.
  • #4: Data source pour récupérer le FQDN du service Jenkins.

values.yaml

master: 
serviceType: "LoadBalancer" #1
servicePort: 80
additionalPlugins: #2
- "role-strategy:3.0"
- "pipeline-aws:1.41"
nodeSelector:
node.kubernetes.io/lifecycle: "normal" #3
agent:
nodeSelector:
node.kubernetes.io/lifecycle: "spot" #4
  • #1: Le service Jenkins sera de type Loadbalancer.
  • #2: Plugins Jenkins à installer.
  • #3: Affectation du pod du master Jenkins à une instance on-demande.
  • #4: Affectation des pods agents à une instance spot.

jenkins-securityrealm.yaml

jenkins:
securityRealm:
local:
allowsSignup: false
enableCaptcha: false
users:
- id: "admin"
name: "admin"
password: "${admin}" #1
properties:
- preferredProvider:
providerId: "default"
- mailer:
emailAddress: "admin@jenkins.com"
- id: "candidat-x"
name: "candidat-x"
password: "${candidat}" #1
properties:
- preferredProvider:
providerId: "default"
- mailer:
emailAddress: "candidat-x@jenkins.com"
  • #1: Les mots de passe des utilisateurs à créer seront alimentés par Terraform.

jenkins-authorizationstrategy.yaml

jenkins:
authorizationStrategy:
projectMatrix:
permissions:
- "Job/Build:authenticated" #1
- "Job/Cancel:authenticated"
- "Job/Configure:authenticated"
- "Job/Create:authenticated"
- "Job/Delete:authenticated"
- "Job/Discover:authenticated"
- "Job/Move:authenticated"
- "Job/Read:authenticated"
- "Job/Workspace:authenticated"
- "Overall/Administer:admin" #2
- "Overall/Read:authenticated"
  • #1: Les utilisateurs authentifiés peuvent exécuter des jobs.
  • #2: Seul l’utilisateur admin a les droits administratifs.

Nous sommes prêts pour déployer l’infrastructure d’usine logicielle. Commençons par un terraform init.

terraform initInitializing modules...Initializing the backend...Initializing provider plugins...Terraform has been successfully initialized!

suivi d’un terraform apply.

terraform applymodule.cluster.module.eks.data.aws_ami.eks_worker_windows: Refreshing state...module.cluster.module.eks.data.aws_caller_identity.current: Refreshing state...module.cluster.module.eks.data.aws_partition.current: Refreshing state...module.cluster.module.eks.data.aws_iam_policy_document.cluster_assume_role_policy: Refreshing state...module.network.data.aws_availability_zones.available: Refreshing state...module.cluster.data.aws_availability_zones.available: Refreshing state...module.cluster.module.eks.data.aws_ami.eks_worker: Refreshing state...module.cluster.module.eks.data.aws_iam_policy_document.workers_assume_role_policy: Refreshing state.......
Plan: 41 to add, 0 to change, 0 to destroy.
...

Au bout de 10–15 minutes, nous aurons le retour de terraform.

lb_dns_name = xxxxxxxxx-844234488.eu-west-2.elb.amazonaws.compasswords = {
"admin" = "xxxxxxA58GDs8uGm"
"candidat" = "xxxxxxxm3Qg"
}

En se connectant à l’url affichée, vous aurez la page d’authentification de Jenkins.

Page d’authentification de Jenkins.

Vous pouvez vous connecter en tant qu’administrateur ou en tant que simple utilisateur.

Environnement de déploiement

Maintenant que notre stack CI/CD est prête, nous allons préparer notre environnement de déploiement. Il s’agit de deux clusters EKS: un destiné à la pré-production et l’autre à la production.

Nous allons définir deux fichiers de variables :

preprod.tfvars

namespace = "preprod"
region = "eu-west-2"

prod.tfvars

namespace = "prod"
region = "eu-west-2"

Puis on crée les workspaces:

terraform workspace new preprodCreated and switched to workspace "preprod"!You're now on a new, empty workspace. Workspaces isolate their state, so if you run "terraform plan" Terraform will not see any existing state for this configuration.terraform workspace new prodCreated and switched to workspace "prod"!You're now on a new, empty workspace. Workspaces isolate their state, so if you run "terraform plan" Terraform will not see any existing state for this configuration.terraform workspace list
default
preprod
* prod

Nous allons commencer par construire le cluster de pré-production.

terraform workspace select preprod
Switched to workspace "preprod".
terraform apply -var-file=./variables/preprod.tfvars -auto-approve

Une fois le cluster de pré-production est up, nous exécutons les mêmes scripts sur l’environnement de production. Mais avant de changer de workspace nous sauvegardons le fichier de configuration Kubernetes afin de l’utiliser ultérieurement dans Jenkins.

terraform output kubectl_config > kubeconfig_eks-preprod

Maintenant, changeons le workspace et exécutons terraform avec le fichier de production en entrée.

terraform workspace select prod
Switched to workspace "prod".
terraform apply -var-file=./variables/prod.tfvars -auto-approve

De la même manière, nous sauvegardons le fichier de configuration.

terraform output kubectl_config > kubeconfig_eks-prod

Application à déployer

Nous allons tester notre infrastructure avec une application java. L’idée consiste en la construction d’une image docker et en le déploiement de l’application dans nos clusters via une pipeline Jenkins.

Commençons par forker le projet spring-petclinc. Nous allons par la suite ajouter les fichiers suivants:

  • Jenkinsfile: il décrit notre pipeline de déploiement grâce à une syntaxe déclarative.
  • Dockerfile: il décrit l’image docker à construire.
  • application.yaml: il décrit les objets Kubernetes à créer.

Jenkinsfile

pipeline {agent {
kubernetes { // #1
defaultContainer 'jnlp'
yaml """
apiVersion: v1
kind: Pod
spec:
containers:
- name: 'maven'
image: maven:3.3.9
command:
- cat
tty: true
- name: 'dind'
image: docker:dind
securityContext:
privileged: true
- name: 'kubectl'
image: tiborv/aws-kubectl
command:
- cat
tty: true
"""
}
}
environment { // #2
DOCKER_CREDENTIALS = credentials('ZAN_DOCKER_CREDENTIALS')
EKS_PREPROD_CONFIG = credentials('EKS_PREPROD_CONFIG')
EKS_PROD_CONFIG = credentials('EKS_PROD_CONFIG')
VERSION = ""
}
options {
skipDefaultCheckout(true)
buildDiscarder(logRotator(numToKeepStr: '5'))
disableConcurrentBuilds()
}
stages {
stage('Checkout') { // #3
steps {
script {
deleteDir()
checkout scm
def matcher = readFile('pom.xml') =~ '<version>(.+?)</version>'
def current_version = matcher ? matcher[0][1] : '0.1.0'
VERSION = current_version+'.'+BUILD_NUMBER
}
}
}
stage('Compile') {
steps {
container('maven') { // #4
script {
sh """
mvn versions:set -DnewVersion=${VERSION}
mvn clean compile
"""
}
}
}
}
stage('Test') {
steps {
container('maven') { // #5
sh "mvn verify"
}
}
post {
always {
junit allowEmptyResults: true, testResults: '**/target/surefire-reports/*.xml'
}
}
}
stage('Build Image') { // #6
steps {
container('dind') {
sh"""
mkdir target/working-dir
cp -R src/main/resources/docker/* target/working-dir/
cp target/spring-petclinic*.jar target/working-dir/
cd target/working-dir
docker build -t zandolsi/spring-petclnic:${VERSION} .
"""
}
}
}
stage('Push Image') { // #7
steps {
container('dind') {
sh"""
docker login -u ${DOCKER_CREDENTIALS_USR} -p ${DOCKER_CREDENTIALS_PSW}
docker push zandolsi/spring-petclnic:${VERSION}
"""
}
}
}
stage('Deploy to Preprod') { // #8
steps {
withAWS(credentials: 'AWS_CREDENTIALS', region: 'eu-west-2') {
container('kubectl') {
writeFile file: "$JENKINS_AGENT_WORKDIR/.kube/config", text: readFile(EKS_PREPROD_CONFIG)
sh"""
export KUBECONFIG=$JENKINS_AGENT_WORKDIR/.kube/config
sed -i 's/IMAGE_TAG/${VERSION}/g' application.yaml
kubectl apply -f application.yaml
kubectl get svc spring-petclinic
"""
}
}
}
}
stage('E2E Test') {
steps {
sh "echo executing e2e tests..."
}
}
stage('Deploy to Prod') { // #9
steps {
withAWS(credentials: 'AWS_CREDENTIALS', region: 'eu-west-2') {
container('kubectl') {
writeFile file: "$JENKINS_AGENT_WORKDIR/.kube/config", text: readFile(EKS_PROD_CONFIG)
sh"""
export KUBECONFIG=$JENKINS_AGENT_WORKDIR/.kube/config
kubectl apply -f application.yaml
kubectl get svc spring-petclinic
"""
}
}
}
}
}
}
  • #1: Définition du pod template à partir duquel l’agent Jenkins sera instancié.
  • #2: Utilisation des identifiants créés comme variable d’environnement.
  • #3: Récupération du code source.
  • #4: Compilation du code.
  • #5: Exécution des tests.
  • #6: Construction de l’image Docker.
  • #7: Envoi de l’image au registry Docker.
  • #8: Déploiement en pré-production.
  • #9: Déploiement en production

Dockerfile

FROM openjdk:8-jre-alpine
RUN mkdir -p /usr/local/services
ADD spring-petclinic-*.jar /usr/local/services/spring-petclinic.jar
ADD run.sh docker-entrypoint.sh
RUN chmod +x docker-entrypoint.sh
ENTRYPOINT ["sh", "docker-entrypoint.sh"]

application.yaml

apiVersion: apps/v1
kind: Deployment
metadata:
name: spring-petclinic
labels:
app: spring-petclinic
spec:
selector:
matchLabels:
app: spring-petclinic
template:
metadata:
labels:
app: spring-petclinic
spec:
containers:
- name: spring-petclinic
image: zandolsi/spring-petclnic:IMAGE_TAG #1
ports:
- containerPort: 8080
---
apiVersion: v1
kind: Service
metadata:
name: spring-petclinic
spec:
ports:
- port: 80
protocol: TCP
targetPort: 8080
type: LoadBalancer
selector:
app: spring-petclinic
  • #1: Le tag de l’image sera injecté par Jenkins.

Après avoir pusher nos modifications dans Github, nous pouvons configurer le job Jenkins.

Si vous avez choisi de créer un repository Github privé, vous aurez besoin d’ajouter vos identifiants dans les credentials Jenkins. Nous aurons besoins des identifiants Docker, des clés AWS et les configurations Kubernetes des deux environnements.

  • GitHub: cliquer Administrer Jenkins → Manage Credentials → Global → Ajouter de identifiants , sélectionner Username with password, entrer vos identifiants et attribuer un id à ce credential.
  • Docker Hub: cliquer Administrer Jenkins → Manage Credentials → Global → Ajouter de identifiants , sélectionner Username with password, entrer vos identifiants et attribuer un id à ce credential.
  • AWS: cliquer Administrer Jenkins → Manage Credentials → Global → Ajouter de identifiants , sélectionner AWS Credentials, entrer vos identifiants et attribuer un id à ce credential.
  • PreProd: cliquer Administrer Jenkins → Manage Credentials → Global → Ajouter de identifiants , sélectionner Secret File, sélectionner le fichier de configuration Kubernetes pré-production et attribuer un id à ce credential.
  • Prod: cliquer Administrer Jenkins → Manage Credentials → Global → Ajouter de identifiants , sélectionner Secret File, sélectionner le fichier de configuration Kubernetes production et attribuer un id à ce credential.

A partir de la page d’accueil de Jenkins, cliquer New item, donner un nom (par exemple petclinic), sélectionner Multibranch Pipeline et cliquer OK.

Page de création d’un nouveau projet dans Jenkins.
Page de configuration du projet dans Jenkins.

Jenkins va scanner le repository à la recherche des branches contenant un Jenkinsfile.

Résultat du scan effectué par Jenkins.

S’il trouve un, il démarre automatiquement un build. Si vous avez bien suivi les instructions précédentes , vous verrez toutes les étapes en vert.

Page de vue du pipeline.

On peut récupérer les urls de notre application en pré-production et en production via la console.

Output de l’étape de déploiement en pré-production.
Page d’accueil de l’application en pré-production.
Output de l’étape de déploiement en production.
Page d’accueil de l’application en production.

Conclusion

Dans cet article, nous avons détaillé les étapes nécessaires pour mettre en place Jenkins dans un cluster EKS à partir des fichiers configurations (Terraform et JCasC). Ensuite, nous avons déployé une application via un pipeline Jenkins.

Par ailleurs, n’oubliez pas d’éteindre la lumière (terraform destroy).

--

--

No responses yet