circuit

CI/CD with GitLab, Kubernetes (EKS) and Helm




Photo by Max Duzij on Unsplash

Photo by Max Duzij on Unsplash

Introduction

In this post, we are going to build and deploy a simple blog web page developed in Python using flask. Then, we will build a CI/CD process using GitLab and deploy it to an EKS cluster. Therefore, to accomplish it, we will use docker, create an EKS cluster, set up GitLab and its integration with Kubernetes, build our continuous integration and delivery in GitLab, custom our YAML files using helm, publish our app using ingress and finally, test a rollback deployment process. All the source code is available through this GitLab repository or with the GitHub repository. Let’s go ahead.

GitLab

It’s not new that GitLab is a powerful CI/CD tool for software development. In addition to providing continuous integration and continuous delivery, GitLab also helps to provide Continuous Deployment.

As you know, the Continuous Integration process works by pushing small code to your application codebase hosted in a Git repository, and to every push, run a pipeline of scripts to build, test, and validate the code changes.

Continuous Delivery and Deployment consist of a step further CI, deploying your application to production at every push to the default branch of the repository.

In this post, we will not cover all the GitLab concepts like GitLab runner, explore all the CI/CD variables, deploy an application to different environments, jobs artifacts, and so on… We will only focus on the CI/CD process and all that needs to do to accomplish it.

The App

The app that will be deployed is so simple (app.py). It just renders a custom web page (templates/app/index.html). This big app simulates a web blog, but, to do the process easier, we just do it as a static web page. You can download the app source code as the entire CI/CD code through this GitLab repository or with the GitHub repository.

Here is the structure of the app:

App’s structure

app.py

from flask import Flask, render_template
import flask
app = Flask(__name__)

@app.route('/')
def index():
    return render_template('app/index.html')

if __name__ == '__main__':
    app.run(debug=True,host='0.0.0.0')

The templates/app/index.html was omitted because it is big and does not matter with the main purpose.

Docker Registry and Dockerizing

Next up we’re going to set up a Docker registry to which we can push our application that we’ll dockerize. You can choose any register you want. Here, we are going to use the docker registry, so, if you don’t have an account yet you can create one on the Docker Hub Page.

Building our image

First of all, let’s dockerize and build our image. So we are going to create a Dockerfile in our root directory with the following content:

FROM alpine:latest

ENV PYTHONUNBUFFERED=1

RUN mkdir /app

RUN apk add --update --no-cache python3 && ln -sf python3 /usr/bin/python

RUN python3 -m ensurepip

RUN pip3 install --no-cache --upgrade pip setuptools

WORKDIR /app/

ADD . /app/

RUN pip install -r requirements.txt

CMD ["python", "/app/app.py"]

Before setting up and building these configurations in a CICD pipeline, It’s interesting to test in our local environment. Note that we are not using multistage building for this setup, but it’s strongly recommended in a production environment.

By default, flask and gunicorn will expose our app to 5000 port. Therefore, in the same Dockerfile directory, let’s test our image:

docker build -t filipemotta/flask-app:latest .

After build, let’s test our app:

docker run -d -p 5000:5000 filipemotta/flask-app:latest

Then access http://localhost:5000 and you should access the application.

Sample APP

Once you’ve accessed the application in your local environment it’s time to go to the next steps.

Creating Kubernetes a Cluster in EKS

In this post we won’t get details about EKS, I will leave it for future posts. For now, we are going to just provision a simple Kubernetes Cluster in EKS with only one node.

GitLab Setup and Integration with Kubernetes

Now we are going to set up a Kubernetes cluster. As we know, there are many ways to accomplish it… but for now, we’ll use eksctl — The official CLI for Amazon EKS.

Basically, you’ll need kubectl, eksctl and Required IAM permissions. For more details, you can use the link specified above or use Getting started with Amazon EKS — eksctl.

Let’s create our first EKS Cluster with a single Node :).

eksctl create cluster --name=flask-app-demo --nodes=1 --node-type t3.small

After few minutes, you can interact with your node:

❯ kubectl get nodes
NAME                                           STATUS   ROLES    AGE    VERSION
ip-192-168-79-235.us-west-2.compute.internal   Ready    <none>   1d1h   v1.20.4-eks-6b7464

CI — Building and pushing our image through GitLab

Let’s start building our image through GitLab

Service Account

First of all, we need to create a service-account to interact with our cluster. In our repository, there is a file in k8s/git-lab-sa-yml specifying a service account and associate it with a specific ClusterRoleBinding. For purpose learning, we’ll specify a cluster-admin role, but if used in a production environment it is necessary according to their specific needs.

git-lab-sa-yml

---

apiVersion: v1

kind: ServiceAccount
metadata:
  name: gitlab-service-account
---

apiVersion: rbac.authorization.k8s.io/v1

kind: ClusterRoleBinding
metadata:
  name: gitlab-service-account-role-binding
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole
  name: cluster-admin
subjects:
  - kind: ServiceAccount
    name: gitlab-service-account
    namespace: default

Setting variables

Then we need to set up environment variables in our Gitlab account. In GitLab, navigate to your repository -> Settings -> CI / CD -> variables ( expand ). Then, we are going to add the following configuration:

  • CI_REGISTRY_USER — Your docker hub user

  • CI_REGISTRY_PASSWORD — Your docker password you use to login to the docker hub

  • SERVER — The endpoint to the Kubernetes API for our cluster. You can find it running the following command:

    ❯ kubectl cluster-info
    Kubernetes master is running at https://BB2EFEF7EC0858A603SDFF9A67BEBA1.gr7.us-west-2.eks.amazonaws.com
    
  • CERTIFICATE_AUTHORITY_DATA — This is the CA configuration for the Kubernetes cluster. There are some ways to get it, one of them is in your ~/.kube/config file.

  • USER_TOKEN — This value is the service account token created earlier. You can get this value running the following command:

    kubectl describe secret gitlab-service-account-token-mnf4
    

Use kubectl get secret before to get the secret’s name.

Use protected flag for sensitive data.

Building our Image through Gitlab

The next step is to create .gitlab-ci.yml file in your root repository. This file is where you configure specific instructions for GitLab CI/CD defining the structure and order of jobs that the runner should execute and the decisions the runner should make when specific conditions are encountered. The file uses the YAML configuration syntax in GitLab CI to define the actions that should be taken, the order in which they should be performed, under what conditions they should be performed, and the resources needed to complete each task.

Now we are going to develop our pipeline. Before that, ensure you have runners available to run your jobs. If you don’t have a runner, install GitLab Runner and register a runner for your project.

Our pipeline there will have basically two stages — build and deploy. Keep in mind that the production pipeline generally has many stages like tests, security issues, build, deploy and many jobs to support the entire pipeline.

There, let’s start with a build stage.

image: docker:latest
services:
  - docker:dind

stages:
  - build

variables:
  CONTAINER_TAG: ${CI_COMMIT_SHORT_SHA}
  REPO_DOCKERHUB: ${CI_REGISTRY_USER}/flask-app

build:
  stage: build
  before_script:
    - docker login -u ${CI_REGISTRY_USER} -p ${CI_REGISTRY_PASSWORD}
  script:
    - docker build -t ${REPO_DOCKERHUB}:${CONTAINER_TAG} .
    - docker tag ${REPO_DOCKERHUB}:${CONTAINER_TAG} ${REPO_DOCKERHUB}:${CONTAINER_TAG}
    - docker tag ${REPO_DOCKERHUB}:${CONTAINER_TAG} ${CI_REGISTRY_USER}/flask-app:latest
    - docker push ${REPO_DOCKERHUB}:${CONTAINER_TAG}

First, the file starts to declare an image, in this case, we’ve used a (docker image inside of another docker image)[https://hub.docker.com/_/docker], so we can create a docker image of our application to publish in a container registry.

The services keyword defines a Docker image that runs during a job linked to the docker image that the image keyword defines. This allows you to access the service image during build time.

The variables keyword is used for the behavior of jobs and pipelines, stores values you want to re-use it and avoid hard-coding values in your .gitlab-ci.yml file. Here is variables keyword we defined custom variables to be used globally or locally, that is we can use inside a job ( local variable ) or as the top level (in this case), used for all jobs and stages (global variable).

You can use pre-definides variables like CI_COMMIT_SHORT_SHA in our pipeline file. Beside this link, you can print output all the variables available for a job through this command.

stages:
  - export
  ...
env:
  stage: export
  script:
    - export

We’ve used CI_COMMIT_SHORT_SHA to store the commit revision the project is built for and stores it in a CONTAINER_TAG variable. Also, we’ve defined REPO_DOCKERHUB that is a repository name of our container registry.

After that, we’ve declared the first job called build that is a kind of step on the stage declared earlier. So, before running all the steps to build our image let’s login in our container registry using keyword before_script. The script keyword we’ve used to really build our images. After that are very common steps to publish images in any container register, so only attend for we’ve published our images with a short tag, because with it is possible to track and rollback our deployment if needed. Note that we’ve used Dockerfile config images created earlier to build our image.

See an example:

Docker Hub

Deploying our app through GitLab

After doing the first steps to our Continuous Integration, let’s start to integrate our Kubernetes Cluster in CI/CD process. The first goal is to do a Kubernetes deployment to run our application and start to use helm to custom our YAML files.

Deploy Stage — gitlab-ci.yml

Here is the fully .gitlab-ci.yml file:

image: docker:latest
services:
  - docker:dind

stages:
  - build
  - deploy

variables:
  CONTAINER_TAG: ${CI_COMMIT_SHORT_SHA}
  REPO_DOCKERHUB: filipemotta/flask-app

build:
  stage: build
  before_script:
    - docker login -u ${CI_REGISTRY_USER} -p ${CI_REGISTRY_PASSWORD}
  script:
    - docker build -t ${REPO_DOCKERHUB}:${CONTAINER_TAG} .
    - docker tag ${REPO_DOCKERHUB}:${CONTAINER_TAG} ${REPO_DOCKERHUB}:${CONTAINER_TAG}
    - docker tag ${REPO_DOCKERHUB}:${CONTAINER_TAG} filipemotta/flask-app:latest
    - docker push ${REPO_DOCKERHUB}:${CONTAINER_TAG}

deploy:
  stage: deploy
  image: dtzar/helm-kubectl
  script:
    - kubectl config set-cluster k8s --server="${SERVER}"
    - kubectl config set clusters.k8s.certificate-authority-data ${CERTIFICATE_AUTHORITY_DATA}
    - kubectl config set-credentials gitlab --token="${USER_TOKEN}"
    - kubectl config set-context default --cluster=k8s --user=gitlab
    - kubectl config use-context default
    - helm upgrade flask-app ./flask-app --install --set deployment.image.tag=${CI_COMMIT_SHORT_SHA}
  only:
   - main

Note that we wrote another job called deploy. This deploy will be part of the deploy stage and declares a built-in image that is ready for use for both Kubernetes and helm. There are many ways that you can integrate and deploy your image with Kubernetes, but my choice for this post is that. In the first steps, we’ve set up our cluster with env variables in the CI/CD GitLab setting earlier.

Using helm

I’ve created the helm chart called flask-app creating a chart directory along with the common files and directories used in a common chart.

Helm tree

Please to custom values, pay attention for both values.yaml and Chart.yaml files. Our first deployment file is described below:

Setting the Deployment yml

└── templates/ └── deployment.yaml/

apiVersion: apps/v1
kind: Deployment
metadata:
  name: deploy-{{ .Chart.Name }}
  namespace: {{ .Values.deployment.namespace }}
  labels:
    app: {{ .Values.deployment.app }}
spec:
  replicas: {{ .Values.deployment.replicas }}
  selector:
    matchLabels:
      app: {{ .Values.deployment.app }}
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 33%
  template:
    metadata:
      labels:
        app: {{ .Values.deployment.app }}
    spec:
      containers:
        - name: {{ .Values.deployment.capp }}
          image: filipemotta/flask-app:{{ .Values.deployment.image.tag }}
          ports:
            - containerPort: {{ .Values.deployment.cport }}
          livenessProbe:
            httpGet:
              path: /
              port: {{ .Values.deployment.cport }}
            initialDelaySeconds: 2
            periodSeconds: 2
          readinessProbe:
            httpGet:
              path: /
              port: {{ .Values.deployment.cport }}
            initialDelaySeconds: 2
            periodSeconds: 2

Important to mention this file was set up with 3 replicas, using the Rolling update strategy (default strategy), tag image that used the latest tag in the container registry and the container port that is our application port (5000) according to our docker built image.

Running our first CI/CD process

Now, we are going to commit our changes to the repository. The expected behavior is that when committed, GitLab starts the build and deploy stages. In the end, we expect the app was deployed in the Kubernetes cluster and there are three pods to accomplish the deployment required with the last image deployed in the container registry. Let’s try:

filipemotta in flask-app on  main
❯ kubectl get deployment
NAME               READY   UP-TO-DATE   AVAILABLE   AGE
deploy-flask-app   3/3     3            3           6m15s
...
filipemotta in flask-app on  main took 2s
❯ kubectl get pods
NAME                                READY   STATUS    RESTARTS   AGE
deploy-flask-app-548bddb5fd-4qm68   1/1     Running   0          5m51s
deploy-flask-app-548bddb5fd-hd8lr   1/1     Running   0          3m29s
deploy-flask-app-548bddb5fd-l4rj5   1/1     Running   0          3m32s

As we can see, now have one deployment with 3 three pods running. In the GitLab interface, we can see the status of the pipeline we’ve created inside your project -> CICD -> pipelines and related jobs.

CICD Stages

Note that all the staged steps we’ve configured in the previous section and tag of the image that is building.

Notice that our new image is already in our docker hub registry.

Docker hub new image

CD — Publishing our App using service and ingress

Now it’s time to publish our app using service and ingress. Under the template helm folder, first, let’s create the service config YAML file.

Service yaml

└── templates/

  • └── deployment.yaml

  • └── service.yaml

    apiVersion: v1
    kind: Service
    metadata:
     creationTimestamp: null
     labels:
       app: {{ .Values.deployment.app }}
     name: service-{{ .Chart.Name }}
    spec:
     ports:
     - port: {{ .Values.service.ports.port }}
       protocol: TCP
       targetPort: {{ .Values.service.ports.targetPort }}
     selector:
       app: {{ .Values.deployment.app }}
     type: {{ .Values.service.type }}
    status:
     loadBalancer: {}
    

and here is value.yaml section related to a service:

service:
  type: LoadBalancer
  ports:
      port: 80
      targetPort: 5000

So, we are going to deploy a service with LoadBalance type in the 80 port getting an endpoint in the port 5000 (see related labels). In the cloud environments, we can set up the LoadBalancer type and get a valid external IP do access outside the cluster. It is not necessary to have a public IP in our service because we’ll use an ingress to access our service.

❯ kubectl get service service-flask-app
NAME                TYPE           CLUSTER-IP     EXTERNAL-IP                                                               PORT(S)        AGE
service-flask-app   LoadBalancer   10.100.59.49   a7f08460c49d54667876271bfa9e1651-1419988148.us-west-2.elb.amazonaws.com   80:31171/TCP   16m2s

Yes, with loadbalancer type, it is possible to access our app using the EXTERNAL-IP, but for now, we’ll use an ingress to publish our app with certain prerequisites and mapping and hold our URL with ingress address. Therefore, let’s deploy another file in a template helm folder, an ingress config file.

Ingress yaml

Before config our ingress file you have to deploy your ingress controller. In my case, I’ve deployed it nginx-ingress-controller using Network load balancer (NLB) to expose the NGINX Ingress controller behind a Service of Type=LoadBalancer. (See the official documentation).

└── templates/

  • └── deployment.yaml

  • └── service.yaml

  • └── ingress.yaml

    apiVersion: networking.k8s.io/v1
    kind: Ingress
    metadata:
    name: ingress-{{ .Chart.Name }}
    annotations:
      nginx.ingress.kubernetes.io/rewrite-target: /
    spec:
    defaultBackend:
      service:
        name: {{ .Values.svcnotfound.name }}
        port:
          number: {{ .Values.svcnotfound.port }}
    rules:
    - host: {{ .Values.ingress.host }}
      http:
        paths:
        - path: /
          pathType: Prefix
          backend:
            service:
              name: service-{{ .Chart.Name }}
              port:
                number: {{ .Values.service.ports.port }}
    

and here is value.yaml section related with ingress:

ingress:
  host: gitlab.filipemotta.me

As we can note, there is a rule to catch the gitlab.filipemotta.me redirect to our service specified earlier. To reach Continuous Delivery in time when we push our code to the repository, we’ve to set up our DNS using wildcard registering with your ingress address supplied.

*.filipemotta.me	A	Simple	INGRESS_ADRESS

In this way, all the subdomains from *.filipemotta.me will redirect to our service that will balance to our endpoints (pods) but this is not exactly what we exactly want. We would like all the requests to gitlab.filipemotta.me redirect to our service, but all the others, redirect to another subdomain. Therefore, pay attention in our ingress file, we’ve configured the default backend to point to another service that will be deployed with a default nginx page that we called page not found. So, all the requests to our ingress that do not catch with gitlab.filipemotta.me will redirect to service nginx ( page not found ).

└── templates/

  • └── deployment.yaml

  • └── service.yaml

  • └── ingress.yaml

  • └── pod-service-page-not-found.yaml


    apiVersion: v1
    kind: Pod
    metadata:
    creationTimestamp: null
    labels:
      run: {{ .Values.podnotfound.labels.run }}
    name: {{ .Values.podnotfound.name }}
    spec:
    containers:
    - image: {{ .Values.podnotfound.image }}
      name: {{ .Values.podnotfound.name }}
      resources: {}
    dnsPolicy: ClusterFirst
    restartPolicy: Always
    status: {}
    ---
    apiVersion: v1
    kind: Service
    metadata:
    creationTimestamp: null
    labels:
      run: {{ .Values.svcnotfound.labels.run }}
    name: {{ .Values.svcnotfound.name }}
    spec:
    ports:
    - port: 80
      protocol: TCP
      targetPort: 80
    selector:
      run: {{ .Values.podnotfound.labels.run }}
    status:
    loadBalancer: {}
    

It’s Ok, all the configurations were done. Let’s commit and push our code into the repository to trigger the CI/CD process.

See our web app deploy using gitlab CI CD process:

Blog Page

Rollback the deployment

As you can see before, we’ve deployed two posts in the web blog.

Let’s get the image tag to mark it.

❯ kubectl describe deployments.apps deploy-flask-app
Name:                   deploy-flask-app
Namespace:              default
CreationTimestamp:      Fri, 06 Aug 2021 13:58:23 -0300
Labels:                 app=prod
                        app.kubernetes.io/managed-by=Helm
Annotations:            deployment.kubernetes.io/revision: 4
                        meta.helm.sh/release-name: flask-app
                        meta.helm.sh/release-namespace: default
Selector:               app=prod
Replicas:               3 desired | 3 updated | 3 total | 3 available | 0 unavailable
StrategyType:           RollingUpdate
MinReadySeconds:        0
RollingUpdateStrategy:  33% max unavailable, 1 max surge
Pod Template:
  Labels:  app=prod
  Containers:
   c-flask-app:
    Image:        filipemotta/flask-app:eac9140a

Please, pay attention to the revision and tag image in our description deployment. Now we are going to do some changes adding one more post in our blog text and push the code.

See the result:

Blog Page Broken

As you can see, there are some issues in our frontend app, but we can deal with them easier. Let’s retrieve our deployment revision:

filipemotta in flask-app on  main took 4s
❯ kubectl rollout history deployment
deployment.apps/deploy-flask-app
REVISION  CHANGE-CAUSE
2         <none>
3         <none>
4         <none>
5         <none>
6         <none>

So, let’s rollback to revision 4 that worked well before.

filipemotta in flask-app on  main
❯ kubectl rollout undo deployment deploy-flask-app --to-revision=4
deployment.apps/deploy-flask-app rolled back

Now we’ve successfully rolled back our app to a stable version. You can describe the deployment and verify the tag image now refers to the previous version.

Blog Rolled Back

Another approach, telling the truth, more reliable and trackable, is to roll back the app via git using merge request revision to a main branch.

Conclusion

Please, as EKS is a managed service you will pay for it. So, if you follow this tutorial, delete the respective resources.

filipemotta in flask-app on  main
❯ eksctl delete cluster --name=flask-app

I really hope this post was useful.




Continue Learning