About

This is a simple introduction to GitOps using GitLab, Flux, and Helm charts. Objective of this is to deploy a small microservice application written in Go on to a Kubernetes cluster with minimal manual interactions. There are few notes to consider; the pipelines, triggers, conditions, etc. are kept in a simplest form to keep things easier to understand and replicated. There are many strategies to test, build, and deploy applications as well as create deployment pipelines. I believe readers can customize, improve, and make it more secure to suite their needs.

Note: Source code used in this is used for learning purposes only. If you are using them for production work, please consider your project infrastructure and security needs.

I used GitLab Community Edition (v17.0.1) with a local Kubernetes cluster (v1.28.10) and Flux (v2.3.0) deployed on Proxmox VMs.

Full repo can be found here: https://github.com/dhamith93/gitops-sample

Repo structure

├── app
│   ├── api
│   │   └── maths
│   ├── client
│   └── internal
├── charts
│   ├── client
│   └── maths-api
└── clusters
    └── playground
        ├── calculator
        └── flux-system

This is the structure of the repo. app directory contains the application source code. charts directory contains the helm charts for two services. clusters directory contains Kubernetes and flux resources to deploy the application on to a cluster.

Application

Application is a tiny calculator which takes an expression from the user and returns the result. client service handles the frontend and maths-api takes the expression, resolves it and returns an answer. maths-api also have few unit tests configured. Both services have Docker files to create an Alpine linux based docker image.

Client service and the maths-api service both uses env variables to get the port number to listen to. The client service also get the endpoint to the maths-api through a env variable.

Version number for both client service and maths-api are stored in .version file in application source root directory. Changes to this file will be used to trigger test, build, and deploy pipeline jobs.

Building and releasing the application using GitLab-CI

stages:
  - test
  - build
  - release

include:
  - local: '$CI_PROJECT_DIR/app/client/.gitlab-ci.yml'
  - local: '$CI_PROJECT_DIR/app/api/maths/.gitlab-ci.yml'
  - local: '$CI_PROJECT_DIR/charts/client/.gitlab-ci.yml'
  - local: '$CI_PROJECT_DIR/charts/maths-api/.gitlab-ci.yml'

This is the .gitlab-ci.yml file in the root directory of the repo. Included four CI configuration files need to be created for the application and the helm charts. Let’s start with the application building.

CI variables

Variable
GL_PAT Password or access token for the docker registry
IMAGE_REGISTRY Docker registry host
IMAGE_REGISTRY_USER Docker registry username

Client service

Complete CI file: https://github.com/dhamith93/gitops-sample/blob/master/app/client/.gitlab-ci.yml

Build stage

Go language version 1.22 docker image is used to build the client service. Build stage will compile the source code and retain the executable with the .version file as an artifact. There is a filter setup to trigger the build pipeline. The pipeline only triggers when the .version file is modified and committed.

Note: Here I used the .version file for the simplicity. This can be set up using many ways as per the language/framework the application written in. Using git tags is another alternative. I picked the .version file because, this is a monorepo and both services will have separate version numbers.

build-client:
  image: golang:1.22.4-alpine3.20
  stage: build
  artifacts:
    paths:
    - "$CI_PROJECT_DIR/app/client/client"
    - "$CI_PROJECT_DIR/app/client/.version"
  script:
  - cd "$CI_PROJECT_DIR/app/client/"
  - go build -o client
  only:
    changes:
    - app/client/.version

Release stage

The release stage is depended on the build stage. This stage uses a docker image with docker-in-docker service to build the docker image for the client service using the included dockerfile.

FROM alpine:3.20
ADD client /client
ADD frontend /frontend
CMD ./client

The dockerfile is very simple one. It uses Alpine linux image and copies the executable and the frontend directory, which contains html and javascript source files.

Just like the build stage, the release stage also triggers when there is a change to the .version file. All CI variables stated above are needed for this stage to log in to the container repo and push the image. .version file is used to get the version number to add it as a tag to the image.

release-client-docker-img:
  image: docker:27.1.1
  stage: release
  dependencies: 
  - build-client
  services:
  - name: docker:27.1.1-dind
    alias: docker
  variables:
    DOCKER_HOST: tcp://docker:2375/
    DOCKER_DRIVER: overlay2
    DOCKER_TLS_CERTDIR: ""
  script:
  - echo $GL_PAT | docker login $IMAGE_REGISTRY -u $IMAGE_REGISTRY_USER --password-stdin
  - VERSION=`cat $CI_PROJECT_DIR/app/client/.version`
  - cd "$CI_PROJECT_DIR/app/client/"
  - docker build -t client:latest -t $IMAGE_REGISTRY_PATH/client_v:$VERSION -t $IMAGE_REGISTRY_PATH/client_v:latest .
  - docker image push $IMAGE_REGISTRY_PATH/client_v --all-tags
  only:
    changes:
    - app/client/.version

maths-api service

CI pipelines and the dockerfile for building and releasing the maths-api service is similar to the client service. Only addition is the test stage. Which uses golang docker image to run the unit tests under the internal maths module. Build stage is depended on the test stage and the release stage is depended on the build stage. Which means if any unit tests get failed, the whole pipeline will stop.

FROM alpine:3.20
ADD maths_api /maths_api
CMD ./maths_api
test-maths-api:
  image: golang:1.22.4-alpine3.20
  stage: test
  script: 
    - cd "$CI_PROJECT_DIR/app/internal/maths"
    - go test .
  only:
    changes:
      - app/api/maths/.version

build-maths-api:
  image: golang:1.22.4-alpine3.20
  stage: build
  artifacts:
    paths:
      - "$CI_PROJECT_DIR/app/api/maths/maths_api"
      - "$CI_PROJECT_DIR/app/api/maths/.version"
  dependencies:
    - test-maths-api
  script: 
    - cd "$CI_PROJECT_DIR/app/api/maths"
    - go build -o maths_api
  only:
    changes:
      - app/api/maths/.version

release-maths-api-docker-img:
  image: docker:27.1.1
  stage: release
  dependencies: 
  - build-maths-api
  services:
  - name: docker:27.1.1-dind
    alias: docker
  variables:
    DOCKER_HOST: tcp://docker:2375/
    DOCKER_DRIVER: overlay2
    DOCKER_TLS_CERTDIR: ""
  script:
  - echo $GL_PAT | docker login $IMAGE_REGISTRY -u $IMAGE_REGISTRY_USER --password-stdin
  - VERSION=`cat $CI_PROJECT_DIR/app/api/maths/.version`
  - cd "$CI_PROJECT_DIR/app/api/maths"
  - docker build -t maths_api:latest -t $IMAGE_REGISTRY_PATH/maths_api_v:$VERSION -t $IMAGE_REGISTRY_PATH/maths_api_v:latest .
  - docker push $IMAGE_REGISTRY_PATH/maths_api_v --all-tags
  only:
    changes:
    - app/api/maths/.version

Once all the pipelines for both services are completed, docker images will be pushed to the container image registry.

Setting up Flux

Configuring Flux was simple enough. I followed the official docs for bootstrapping Flux for GitLab (https://fluxcd.io/flux/installation/bootstrap/gitlab/). As mentioned in the docs, to set up Flux, Kubernetes cluster admin rights and full access to the GitLab project are needed. In my repo, clusters/playground is the path for the flux-system manifests.

Helm charts

Link to Helm chart source: https://github.com/dhamith93/gitops-sample/tree/master/charts

Helm charts for client and maths-api service are also similar. Only difference being the environmental variables specifying the port numbers to listen to, and the maths-api endpoint specified for the client service. values.yaml file specifies the app name, port, replica count, env vars, and image name with the tag.

Building the Kubernetes manifest file

CI vars

Variable
CI_KNOWN_HOSTS Known host entry for GitLab host taken from ~/.ssh/known_hosts
SSH_PUSH_KEY Private key for the deploy key RSA key pair

There are build and release stages implemented for the helm charts in /charts/<service>/.gitlab-ci.yml files. The build stage creates the Kubernetes manifest file for the service using the helm template command. The command will use the values specified in the values.yaml file in the helm chart and output the filled up template. The output then redirected to the file /clusters/playground/calculator/<service>/release.yml. The path for the release.yml file is already created on the repo before hand to avoid any errors during the build. This manifest file will be used by Flux to ultimately deploy the application to the Kubernetes cluster. For this stage alpine/helm image is used.

Even though the build stage creates the release.yml manifest to be deployed, it is not yet readable to Flux. The release stage which is depended on the build stage will take the release.yml file then it will commit it to the repo. For this stage, a GitLab project deploy key with write access is used to gain write access to the repo. alpine/git image is used with the release stage.

Both build and release stages are triggered by any changes to /charts/<service>/ directory. Again since the both services are very much similar, the build and release stages for both services are also similar. Only difference being the paths.

build-maths-api-helm-chart:
  image: 
    name: alpine/helm:3.15.1
    entrypoint: [""] 
  stage: build
  artifacts:
    paths:
      - "$CI_PROJECT_DIR/clusters/playground/calculator/maths-api/release.yml"
  script:
    - helm template maths-api $CI_PROJECT_DIR/charts/maths-api > $CI_PROJECT_DIR/clusters/playground/calculator/maths-api/release.yml
  only:
    changes:
      - charts/maths-api/**/*

release-maths-api-helm-chart:
  image: 
    name: alpine/git:v2.45.1
    entrypoint: [""]
  stage: release
  dependencies:
    - build-maths-api-helm-chart
  before_script:
    - mkdir ~/.ssh/
    - echo "${CI_KNOWN_HOSTS}" > ~/.ssh/known_hosts
    - echo "${SSH_PUSH_KEY}" > ~/.ssh/id_rsa
    - chmod 600 ~/.ssh/id_rsa
    - git config user.email "ci@gitlab.local"
    - git config user.name "CI"
    - git remote remove ssh_origin || true
    - git remote add ssh_origin "git@$CI_GIT_HOST:$CI_PROJECT_PATH.git"
  script:
    - git add $CI_PROJECT_DIR/clusters/playground/calculator/maths-api/release.yml
    - git commit -m "Adding maths-api heml template output"
    - git push ssh_origin HEAD:$CI_COMMIT_REF_NAME
  only:
    changes:
      - charts/maths-api/**/*

Next steps

Since this is done in an existing local environment, there is no IaC to provision any infrastructure. But tools like Terraform or Ansible can be configured in the same repo with similar CI pipelines to automate/autoscale infrastructure provisioning as needed.

Conclusion

Now with all the CI stages are configured, once the source code changes for either service and .version file is updated with the new version, build stage and docker image release stage will be triggered. During these stages, unit test will be running, then the source code will be compiled, and finally the docker image will be built with the version tag and pushed in to the container repo.

Then when for example in the Helm chart’s values.yaml file is modified with the new image tag, another CI pipeline with build and release stage will be triggered. Which will create a single manifest file for Flux and commit it to the same repo.

With Flux running, it will see the new manifest and it will either install or upgrade the manifest into the Kubernetes cluster.

This is a very simplistic take on GitOps for understanding the process and steps behind it. There are many ways to improve this. For example, you can set this up to be deployed to dev, qa, and prod environments. You can have this configured to be run only when MR is approved to the main/master branch. You can also add or remove extra CI stages as needed. Specially for provisioning infrastructure.

I believe GitLab with Flux is very flexible and straightforward way to get into GitOps.