GitOps with GitHub Actions and Argo CD

Those of you who read my previous post where I tried out Flux CD and Argo CD will know I have been tinkering with automated deployments to my homelab Kubernetes cluster/s.

Recently I picked up a short quickstart e-book on GitOps which I thought looked good for a refresh, reminder and hands-on practice about some opinionated methods to integrate and deploy code from laptop to environment, or in my case homelab. (I have no affiliation with the author or site, I just think it was a nice source of inspiration with some nice hands-on demos to try) And I think it’s always good to read about other opinions or working methods.

So this blog post is me putting into practice and using this book and its examples as inspiration for building an application and deploying it onto Kubernetes.

Now, I‘ve built my fair share of Docker images over the years and GitOps as a concept isn’t new to me but sometimes it’s just nice to have that inspiration guided to you, it’s been a while so it was good practice for me to get my hands on GitHub actions and deploying to my cluster, though I did also use Ago CD as well as Flux CD while going through the demo code.

Personally, I have no real preference. The book takes you through using Flux CD as the deployment option, but I just wanted to try both.

I’ve used Cloud Build with Terraform quite a bit, so I wanted to get my hands on building container images with GitHub Actions, as it has been on my to-do list to get more acquainted with for a while.

This book gives a nice example and inspiration on how to practice GitOps. Sometimes half the battle is finding the inspiration on what to code or deploy for practice, you can easily spend more time getting set up and figuring out what to deploy and code, that you forget why you were doing it in the first place.

Well, it happens to me sometimes anyway!

Spoiler! What I really liked was the automation of updating the Kubernetes manifest file with the new Docker container image version tag that had just been created.

The set up

I wanted to build a simple container that ran a hello world type thing that I could modify, develop and deploy to a cluster automatically.

I already had an application set up using Flux CD from before so I thought I would change it up and use Argo CD (I’d just re-deployed it to a different cluster) instead of Flux CD.

I created a Docker Hub private repo for the container image to live which I creatively called…… example-application.

I created a GitHub repo for the application “example-application“ I also added some repo secrets so GitHub can access the Docker repo, username and token will be referenced in the GitHub Action as secrets.REGISTRY_USER.and secrets.REGISTRY_TOKEN you can then add the value with your own Docker Hub username and token (I already have a secret in my Kubernetes cluster for pulling images from my private repos). I added one last repo secret, a GitHub personal access token for accessing the next GitHub repo.

The second GitHub repo for the Kubernetes manifest which is named “example-environment“has a repo secret for the GitHub personal access token so it can create Pull Requests and a Github repo variable DOCKER_HUB_IMAGE with a value for the name of the image I want to use in the manifest, so I don’t have to hardcode anything in the GitHub Actions, making them more reusable.

The “Application”

So my super noddy “Hello Argo CD!” will be written in Python and use the Flask web framework as it’s my most familiar language (I am no software developer, I dabble at best!).

from flask import Flask

app = Flask(__name__)

@app.route('/')
def index():
    return 'Hello Argo CD v1.0!'

app.run(host='0.0.0.0', port=8080)

Please don’t all point and laugh, I already added the disclaimer that I am no software developer….

I created a public Docker Hub repo called example-application, there’s nothing special or personal about it so a personal repo is OK for this demo.

I created a GitHub repo in which I store my application code for the application above.

Here’s the Dockerfile, which adds instructions on how to build and run the container:

FROM python:3.8-alpine
WORKDIR /py-app
COPY . .
RUN pip3 install flask
EXPOSE 8080
CMD ["python3", "main.py"]

I’m keeping it simple, using the python 3.8-alpine base image, declaring a directory to work in /py-app telling Docker to copy everything from the local directory to the Docker container work directory, I could have been selective here, should have been but for speed and simplicity grab everything.

RUN is telling Docker to run a command in the build container pip3 install flask in this case. EXPOSE is more of a label reminding you this container when it’s running will need port 8080 to be exposed in order for you to reach the web server that this will be running thanks to the final instruction, the command the container will run on execution or runtime.

Lights, Camera…… GitHub Actions!

Now I need to create some GitHub actions to automate building the Docker image, tagging the image, then pushing to my Docker Hub repo.

The book demo code is a really good place to start:

name: new Release
on:
  push:
    tags:
      - v*

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v2

      - name: Build and Push Container Image
        uses: docker/build-push-action@v1
        with:
          username: ${{ secrets.REGISTRY_USER }}
          password: ${{ secrets.REGISTRY_TOKEN }}
          dockerfile: Dockerfile
          repository: ${{ secrets.REGISTRY_USER }}/${{ github.event.repository.name }}
          tag_with_ref: true
          tag_with_sha: false

  release:
    runs-on: ubuntu-latest
    needs: build
    steps:
      - name: Add TAG_NAME env property
        run:  echo "TAG_NAME=`echo ${GITHUB_REF#refs/tags/}`" >> $GITHUB_ENV
      - name: Open PR in Environment Repository for new App Version
      # This is the GitHib Action that will be in the 
      # example-environment GitHub repo which is mentioned ENV_REPO
        env:
          ENV_REPO: ${{ github.event.repository.owner.name }}/example-environment
        uses: benc-uk/workflow-dispatch@v1.2
        with:
          workflow: ApplicationVersion.yaml 
          token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
          inputs: '{"tag_name": "${{ env.TAG_NAME }}", "app_repo": "${{ github.event.repository.name }}", "image": "${{ github.event.repository.full_name }}:${{ env.TAG_NAME }}"}'
          ref: refs/heads/main
          repo: ${{ env.ENV_REPO }}

I had to update some bits and workflow versions other than that, the code the book gives you works great and with a bit of Google-Fu you will find your way around different actions in no time.

Build and Push Image

With the “new Release” Action, we have a build job, the first step name: Checkout code checks out the code from the GitHub repo and the next step name: Build and Push Container Image does just that, it builds according to the Dockerfile that I wrote earlier and we’re giving the Action and pushing the image when it’s built using the secrets that I created on the repo earlier, as we absolutely do not want to be committing any sensitive or secret data into a Git repo! Private or not, make sure you don’t!

Then on to the next Job “release“ where we create a release for the artifact or, deploying the application/container image but to do that I am creating a Pull request to another GitHub Repo!

This is the really cool part, we could just manually do this of course but in a real world use case we would want to deploy this to a dev or test environment, automatically because humans tend to forget things when we’re busy or forget steps etc so we can predictably, accurately and ensure this happens timely and correctly by automating where we can.

Create Pull request

Hopping over to the example-environment GitHub repo and we’ll look at the code I’m using for the Kubernetes manifest yaml file, nothing too advanced here:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: example-application
spec:
  replicas: 1
  selector:
    matchLabels:
      app: example-application
  template:
    metadata:
      labels:
        app: example-application
    spec:
      containers:
      - image: docker-repo-name/example-application:v1.16.5
        name: example-application
        ports:
        - containerPort: 8080
      imagePullSecrets:
        - name: dockerhub
---
apiVersion: v1
kind: Service
metadata:
  name: example-application
  labels:
    app: example-application
spec:
  type: LoadBalancer
  selector:
    app: example-application
  ports:
  - port: 8080

But what else we’ll add here is the GitHub Action which is being called from the GitHub Action in the example-application repo, stay with me on this!

name: New Application Version

on:
  workflow_dispatch:
    inputs:
      tag_name:
        required: true
      app_repo:
        required: true
      image:
        required: true

jobs:
  update-image-tag:
    runs-on: ubuntu-latest
    steps:
      - name: Wrap Input
        run: |
          echo "APP_REPO=${{ github.event.inputs.app_repo }}" >> $GITHUB_ENV
          echo "TAG_NAME=${{ github.event.inputs.tag_name }}" >> $GITHUB_ENV
          echo "IMAGE=${{ vars.DOCKER_HUB_IMAGE }}" >> $GITHUB_ENV
          echo "DEPLOY_FILE_PATH=applications/${{ github.event.inputs.app_repo }}/deployment.yaml" >> $GITHUB_ENV
      - uses: actions/checkout@v2
      - uses: azure/setup-kubectl@v1
        id: install
      - name: patch deployment manifest
        run: kubectl patch --filename=${{ env.DEPLOY_FILE_PATH }} --patch='{"spec":{"template":{"spec":{"containers":[{"name":"${{ env.APP_REPO }}","image":"${{ env.IMAGE }}:${{ env.TAG_NAME }}"}]}}}}' --local=true -o yaml > tmp.yaml
      - name: commit change
        run: |
            git config user.name ${{ github.actor }}
            git config user.email '${{ github.actor }}@users.noreply.github.com'
            rm -f ${{ env.DEPLOY_FILE_PATH }}
            mv tmp.yaml ${{ env.DEPLOY_FILE_PATH }}
            git add ${{ env.DEPLOY_FILE_PATH }}
            git diff-index --quiet HEAD || git commit -m "Set ${{ env.APP_REPO }} to version ${{ env.TAG_NAME }}"
      - name: Create Pull Request
        uses: peter-evans/create-pull-request@v3
        with:
          token: ${{ secrets.PERSONAL_ACCESS_TOKEN }}
          commit-message: Update report
          committer: GitHub <noreply@github.com>
          author: ${{ github.actor }} <${{ github.actor }}@users.noreply.github.com>
          signoff: false
          branch: new_release_${{ env.APP_REPO }}-${{ env.TAG_NAME }}
          title: 'Set ${{ env.APP_REPO }} to version ${{ env.TAG_NAME }}'
          body: |
            This PR was automatically created.
            Please review and merge to deploy.

Once the Docker build and push steps has ran on the example-application repo GitHub Action, the “Open PR in Environment Repository for new App Version“ step in the release job is then creating a PR in this example-environment repo using the image and tag just built and pushed to the Docker Hub Repo to update the Kubernetes manifest file with the new image tag.

The step name: commit change then has the commands to add and commit the tmp.yaml which has the new image tag code, essentially updating the example-environment GitHub repo on our behalf.

The process should go as follows:

Create/update the Python application and Dockerfile, push to the main branch and create a release with a new tag, v1.16.6 for example:

The release job will have the image and tag to update the Kubernetes manifest file and use that to create the PR:

Let’s see what this PR is updating:

Looks good to me, I can review and merge that to the main branch now updating my Kubernetes manifest file without having to use a terminal or edit the manifest myself!

Show me the pods!

So we went to all that trouble, where is my application? Where are the pods?! I didn’t go through the Argo CD set up, its pretty straight forward, I’ll add a link at the end but that’s where the CD tools come into there own!

I added the example-environment repo as an application in Argo CD, as we can see on the right

Clicking into the application

I can also look at the pod events by clicking on the pod itself

I can jump onto my terminal and run kubctl get pods

and see the pod is running, and go to my internal IP address which is being exposed by my cluster

there’s my application! Built, pushed to a repo and deployed all using Git as a central source of truth without having to manually do anything!

Summary and conclusion

Hopefully, you can see the benefits and reasons for going through all this trouble.

Modify your code and go through the release process a few times. You’ll start to see how your release cadence and velocity increase, as well as the build and release process itself becoming predictable and standardised.

This to me, is the epitome of GitOps, having a single source of truth and a single standard and effective method of building software and deployment of an application.

With Argo CD in place, any changes you make to the example-environment GitHub repo are then reconciled by Argo CD and reflected in the Kubernetes Cluster, automating the deployments.

Flux CD works great for the Continuous deployment as well, so it’s worth trying both out.

This has led me down a rabbit hole where to take this forward I’m planning on looking into using the Release-Please GitHub Action, Release Please automates CHANGELOG generation, the creation of GitHub releases, and version bumps for your projects.

I hope this has helped anyone with getting started with GitOps or even just serves as some inspiration on what to try or practice next as much as the quickstart free e-book on GitOps did for me, add some changes to the process or code as I have or follow it closely, as long as you get some practice in, it will become permanent.

Thanks for reading and as always if you have any comments, questions, feedback or spotted any mistakes please get in touch!