Kube deploys! My CI/CD process.

20 minute read

Introduction

I was asked a while ago, by the awesome guys at CIVO, if I wanted to do a talk on their community meetup.
Holding a talk/presentation is something I have never done before (except in school and such), but I said yes!

During the age of Corona, everything is done over the net, so it was not a meetup in person but over zoom. I’m quite used to distance work and collaboration over the net, so that was almost better than in-person (although it would have been fun to swim over to UK and say hi some day!).

But what would I even talk about? I have worked a lot with kubernetes, and I do know a lot about it both as a user and as a cluster manager… I have built multiple clusters from “the ground up”… Now… CIVO is managed k3s, so talking about setting up a cluster on your own servers feels not only useless, but I would feel like an arse too! hehe.

I think that one of the things I could actually be useful for is how I work with kubernetes, as a user!
And if I mix in a bit of CI/CD, something I work with on a daily basis, it would possibly be a decent talk!

So, I decided to go with Continuous deployment on kubernetes using GitOps on GitLab.

Now, I decided that the best way to do this is to actually go with something simple and basic, jumping in on high-level application management and showing of one of my 500+ lines of CI scripts would just confuse people who don’t work with this kind of stuff, so, basic is best.

Well, the meetup was awesome (see youtube clips at the bottom of this post!) and I feel that I did a decent job for a first talk ever (Although… I haven’t been able to listen to it myself and I give you the right to decide that for yourself, hehe), but I feel like I could expand a bit more on the whole thing in a blog post!

So here it is, let’s start from the beginning with…

Getting the cluster running!

Setting up a kubernetes cluster is not an easy task, so I would really recommend using a managed service, at the least for more vital sites and services!
Civo and their #kube100 beta is a good starting point if you are new to kubernetes, it’s an easy setup and there is a lot of things you get “for free” with their marketplace and setup process.
This post will use a Civo cluster as the example cluster, and if you wish to read more about how to get started with CIVO, make sure you go read my post on it here!

With CIVO’s new CLI, we can easily create a new kubernetes cluster with a short command:

civo kubernetes create my-awesome-cluster --size g2.medium --nodes 5 --save --wait --switch

The flags does the following: --size sets what plan we want to use on our nodes, g2.medium is default, so the flag is not really needed, --nodes set the node count, 3 is default, but we want a sturdy cluster with lots’a nodes! --save, --wait and --switch makes sure that the CLI waits til the cluster is up and running (usually takes about a minute) and then saves the configuration to your global kube file, it also switches to the new context allowing you to start work with the cluster right away!

There are more flags to use, check them out when playing around with the CLI, some might be really useful for your case!

Setting up all the important stuff

When we deploy to kubernetes we will need a user that is able to update deployments. We won’t really change anything other than the containers in the deployment, but we will still need a “user” for it in the kubernetes cluster.

The most secure way to do this is to create a new Role and ServiceAccount which is bound to the namespace where the deployment is available. I usually create a deployer account on each namespace that requires automatic or bot-controlled deployments:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: deploy-role
  namespace: default
rules:
  - apiGroups: ["apps"]
    resources:
      - deployments
      - replicasets
      - pods
      - services
    verbs:
      - get
      - update
      - patch
      - list
      - watch

For security, I would personally recommend to remove the services resource and also the get, list and watch verbs, but it all depends on how much power you want to give to the role!

One important thing to notice is the apiGroups we allow the role to tamper with. That is, the apps api group.
A kubernetes cluster have a whole lot of api groups, but under apps we find the one we are looking for in this instance: apps.deployments, we allow the role to get, update, patch watch and also list all apps resources that are defined under the resources clause, that means, it can’t create new ones and it can’t delete any.

apiVersion: v1
kind: ServiceAccount
metadata:
  name: deployer
  namespace: default
---
kind: RoleBinding
apiVersion: rbac.authorization.k8s.io/v1
metadata:
  name: deploy-agent-binding
roleRef:
  kind: Role
  name: deploy-role
  apiGroup: rbac.authorization.k8s.io
subjects:
  - kind: ServiceAccount
    name: deployer
    namespace: default

The above resources creates a new ServiceAccount for the deployer. As you might see, it’s namespaced as well, that binds the account to the default namespace. A RoleBinding resource is used to tie the Role to the ServiceAccount.

Now, when the SA and Role resources are deployed to the cluster, the deployer user will be able to log on to the cluster and update the deployments in the default namespace.

With the new service account up and running, we need to fetch the token and certificate that the account uses.

> kubectl apply -f deployer.yaml

# Fetch the account token name of the deployer user (will be something like 'deployer-token-lp6sf). 
export KUBE_SERVICE_ACCOUNT=$(kubectl get serviceaccounts deployer -o json | jq -r ".secrets[0].name")

# Obtain the certificate and the token that will be used.
export KUBE_CA_CRT=$(kubectl get secret $KUBE_SERVICE_ACCOUNT -o json | jq -r '.data."ca.crt"')
export KUBE_TOKEN=$(kubectl get secret $KUBE_SERVICE_ACCOUNT -o json | jq -r ".data.token" | base64 -d)

With the token and the certificate we can easily create a kubeconfig file for our new deploy user:

apiVersion: v1
clusters:
- cluster:
    certificate-authority-data: $KUBE_CA_CRT
    server: https://$KUBE_API_URI:6443
  name: civo-k3s-demo
contexts:
- context:
    cluster: civo-k3s-demo
    user: civo-k3s-demo
    namespace: default
  name: civo-k3s-demo
current-context: civo-k3s-demo
kind: Config
preferences: {}
users:
  - name: civo-k3s-demo
    user:
      token: $KUBE_TOKEN
      user: $KUBE_SERVICE_ACCOUNT

This configuration we will later upload to the CI/CD runners, which is why we really want to make sure it only have access to just what it should and not your whole cluster.

Different CI/CD engines

There are loads of different “engines” for CI/CD, I prefer to use the ones that are directly connected to the git provider I use, but there are others that are really nice too. You will have to evaluate them for yourself to decide on which you feel most fit your needs. In my case, GitLab was the best choice.

GitLab have it’s own automatic DevOps setups which are really nice, especially if you connect your cluster directly to GitLab. This is not how I roll, I always need to have full control over everything… hehee

I will use GitLab type YAML configurations in this post, as that is what I write the best, but I will try to keep them as simple and easy to explain as possible.

So, when you have an engine for your jobs, you should be able to add a file or variable as a “secret”. That is the var/file should not be possible - for people who does not have admin access to the jobs - to extract.
This is where we upload the kubeconfig.

If it’s possible to upload it as a file, make sure you create an environment variable pointing to the file path and name it KUBECONFIG so that we can easily access it.
If it’s a variable we go with, one easy way to do it is to add it as a b64 string and decode it into a file in a command in the ci job.

Either way, at the end, we should have a configuration file ready for when we need to deploy from the pipeline.

CI

What are you thinking?!

I wanted to take a short moment to describe how I think when I work with CI/CD pipelines.
Pipelines are aromatization jobs which should do the stuff that you don’t want to do by yourself every time.
When I build my pipelines, I always start out with writing them as I would do it manually, every single step I would use if I was doing it at my local computer should be added to the pipeline.
The easiest way to do this - in my opinion - is to either write it all down, step by step, in a dockerfile. Each step should have its own RUN so that you can cache all the working steps, and then I test, and add and test until it works as if I was doing it by hand.

Now, at this point the script is usually not optimized at all. It will likely take more time to run than I would want it to in a production setting, so after I have a working script, I start refining it.
When refining, I don’t always write it as I would do it manually, while, I try to keep it as close to my personal manual workflow in the terminal as possible.
Refining is a never ending task so when it’s good enough, it’s usually time to stop!

The stages!

There are many ways to write pipelines, I will focus on the way I do it, but if wanted, a pipeline could basically be a single script which runs when you push to your git repository.
Most CI/CD Engines have some type of stage system, that is, a way to define which jobs runs in what order. Most also have some way of storing artifacts between the jobs. You could run a single job with all your workload in it, but I personally find it a lot better to split it up, and allow jobs which can run parallel do that while the jobs which have dependencies will wait for its turn.

So in my scripts I usually make use of the following basic stages:

  • test
  • build
  • containerize
  • scan
  • deploy

You could use more, or less, it’s all up to you!

What does the different stages include? Well, if the names doesn’t explain themselves…

Test: This is the stage where all the code is tested. This could be standard unit tests or stuff like dependency scans or code analysis.
Build: In this stage, the code is built for production, an artifact is created from each job and is then later used in the containerize jobs.
Containerize: Deploying to kubernetes without a image too assign to the containers is hard… So this step is used to actually build the images and to push them to the registry that is used.
Scan: In the test stage, scanning of dependencies and such is useful, but when you have your container ready, its usually a good idea to scan the image too. This way you might find issues which are due to the base image you use or some binaries that the image include. I personally use Trivy for my scans, but there are a lot of others on the market.
Deploy: As a final stage, the new build should be deployed.

All of the above stages are not really required to run on every branch or every pull request. That would be quite odd. Deployment for example, we don’t want to run on any other branches than the ones that we actually use for deployments, containerization and image scan is not really needed on all environments either, while the test job probably should run on all merge requests and all pushes to a branch.

My standard table for this is can be seen in the following table:

stage branches
test all + pr
build develop, feature-branch, master
containerize develop, feature-branch, master
scan develop, master
deploy develop, master + tag

My environment uses three different type of deployments: dev, stage and prod.
Production deploys are only done on tags on the master branch. Develop deploys on the dev branch (when pushed) and stage deploys are made when something is pushed to the master branch.

Feature branches are not deployed, but images are built for them so that they can be tested locally or deployed to the cluster in case it is needed.

We won’t talk much about the dev and stage environments, rather just focus on the master branch, but having a decent strategy for testing before deploying to production could be a good thing to consider!

So, to slim the whole process down, the deploy script that I will explain in this post will use the following stages:

  • test
  • build
  • containerize
  • deploy

All the jobs will presume that there is only a master branch in the repository. And production deploys will require a tag to be created.

Test

Testing is usually quite an important part of the deployment flow. Some tests are less important than others, but I would say that we always will want to stop the build if such as unit tests doesnt pass.

As an example I will break down a simple unit test job in the format that I use on gitlab for nodejs applications:

npm.test:
  stage: test
  image: jitesoft/node:latest
  script:
    - npm ci
    - npm run test

All this job does is to tell the CI engine that the job is being ran in the test stage, it uses a node image (one of mine!) and runs the following two commands: npm ci and npm run test.
This is basically the same thing we would do in the terminal when testing our code, right?

Build

Building an application could mean quite a bit of different things. Some applications have really complex build scripts, while some are a simple one line.
We will stick to the node application example to make use of a more simple build script.

Presume that the package.json have a build:prod script defined, which run all the required steps used to build the application.

npm.build.app:
  stage: build
  image: jitesoft/node:latest
  variables:
    NODE_ENV: development
  script:
    - npm ci
    - npm run build
  artifacts:
    paths:
      - dist/

npm.build.modules:
  stage: build
  image: jitesoft/node:latest
  variables:
    NODE_ENV: production
  script:
    - npm ci
  artifacts:
      paths:
        - node_modules/

In the above case, the job has actually been split up into two jobs. This is so that we can run the whole process a bit faster (as they will run at the same time).

The first job will build our application code and place it in the dist directory, the variables property defines the NODE_ENV environment variable to use the development environment. This because we most likely have a bunch of packages which are required to build the code in the devDependencies of the package.json file.
It builds the code into the dist folder and we mark the dist folder as an artifact (which will be uploaded to the CI server).

The second job will not build the app, but rather just install the production dependencies so that they can be used when running the application in production. Just as with the dist directory, an artifact is created for the node_modules directory. The environment is set to production as we don’t want to package all development dependencies and add them to the deployed application.

Containerize

Containerizing an application is quite easy if you have ever used docker. All we need to do is to create an image with the files that we want to deploy.
We will stick to the earlier node app to keep some type of red line in this, so the basic script would look something like this:

containerize:
  stage: containerize
  needs:
    - npm.build.app
    - npm.build.modules
  stage: containerize
  image: registry.gitlab.com/jitesoft/dockerfiles/misc:latest
  script:
    - docker buildx build -t ${CI_REGISTRY_IMAGE}:latest --build-arg VERSION=${CI_COMMIT_TAG} -t ${CI_REGISTRY_IMAGE}:${CI_COMMIT_TAG} --push --platform linux/amd64 --progress plain .
  rules:
    - if: $CI_COMMIT_TAG

This containerization script uses an image which includes docker and the buildx cli plugin. I often use the buildx plugin so that I can build for more than one platform/architecture and it also gives us a few extra niceties to use when it comes to experimental syntax.

If you wish to read up more about the buildx plugin, feel free to check out this post!

When the image is built, we tag it with the latest and tag of the current pipeline. The rules property only tells the gitlab ci scheduler that the job should only run on tags (where the $CI_COMMIT_TAG variable will be auto-populated by the engine to contain the current tag name).
The job have a needs clause defined so that all the artifacts from the jobs in the list are downloaded to the job!

Ofcourse, all of this requires a dockerfile, and the most simple one we could go with is a node image of some sort:

FROM jitesoft/node-base:latest
RUN mkdir /usr/local/app
ADD ./dist /usr/local/app/dist
ADD ./node_modules /usr/local/app/node_modules
ADD ./package.json /usr/local/app/

EXPOSE 3000
CMD ["/usr/local/bin/node", "/usr/local/app/"]

The image only copies the dist, node_modules and package.json files to the image and defines the CMD and exposes the port that is used by the application.
The image should be tested locally a couple of times, so that we haven’t messed something up, but if it runs as it should, it’s ready to be built!

The deployment!

What would a kubernetes deployment be without the actual deployment file?
That’s a good question, probably not much fun - would be my answer. So, at this point in the deployment process or rather in the process of writing a deploy script, we should run the initial deployment, that is, we want the pipeline to create an image that we can use when we deploy the application.
You could start off the deployment by just deploying another container instead, but I prefer to do a manual deploy with the real application as a first deploy, so that I know everything goes right and it works as intended.

So, the deployment would look something like this:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: my-awesome-app
  namespace: default
spec:
  selector:
    matchLabels:
      app: my-app
      env: production
      project: my-project
  replicas: 3
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxUnavailable: 1
  template:
    spec:
      containers:
        - name: my-node-container
          imagePullPolicy: Always
          image: registry.whereever.com/my-node-app:latest          
          resources:
            requests:
              cpu: 30m
              memory: 64Mi
            limits:
              cpu: 300m
              memory: 512Mi
          ports:
            - containerPort: 3000
              name: web

This deployment uses the image that we just created in the registry, it have limits and requests set for resources (something you should always do!) and it is placed in the correct namespace (this is important, as our deployer only have access to the default namespace).
As you might notice, we are using 3 replicas, and we only allow 1 to be unavailable at a time. This will create three identical pods with the same image and resources as defined in the template spec, so take all the requests and limits resources and multiply with three to see how much the cluster will require of a node to be able to schedule it successfully.

When you feel ready, you can deploy the deployment with kubectl:

kubectl apply -f my-deployment.yml

You will ofcourse require a few more resources than just the deployment to be able to access it, such as a Service and some type of ingress. But I’ll not get in to that here!

Deploy script

Now we have a deployment up and running, all that is left is to create the actual deploy script in the CI pipeline.

A really simple deploy script could look something like this:

.deploy:
  stage: deploy
  image: jitesoft/kubectl:latest
  variables:
    IMAGE: "registry.whereever.com/my-node-app:${$CI_COMMIT_TAG}"
  script:
    - kubectl set image deployment.apps/my-awesome-app my-node-container=${IMAGE}
  rules:
    - if: $CI_COMMIT_TAG

Just as with the containerization script in the earlier stage, we will only run this on tags.
The image used here is a standard kubectl image and the command we invoke is basically just a patch request of the deployment so that it changes the image to the latest build image.

In this stage, we really need to be able to access that kubeconfig file we created for the deployer earlier. So, you can either add it as a secret file, if the CI/CD engine allows for such (in that case, you will want an env variable to point to the file-path which is named KUBECONFIG), or you could add the whole file as a variable and then echo it into a file, which you then export a env variable for (KUBECONFIG=/path/to/config), just be sure to remove the file afterwards, so that no one else can use it!

When all is ready, just push the new CI file to the repository and add a tag to see how the deployment job runs!

Thats it! It’s a fully working awesome auto-deployment script! Every time you push a tag to the master branch, your new pipeline will kick in and test, build, containerize and finally deploy the image to your kubernetes cluster!

Final words

CI/CD and Kubernetes is a huge field, there are sooo much that could be done in so many ways, the one I describe in this post might not fit all, honestly, for some projects it doesn’t even fit me, but it is a simple enough (while still functional) deployment pipeline.

I hope you enjoyed the post!

Youtube - Me on Civo community meetup
Youtube - The whole Civo community meetup