In this blog post I'll walk you through the journey of automating the deployment of a web app in golang with CI/CD pipelines and using Gitops approach.
We'll go through containerizing our app to deploying it on kubernetes to using ArgoCD for deployment.
Requirements
- A kubernetes cluster You can use any of the managed services from cloud providers for kubernetes or if your system has enough resources to provision a kubernetes cluster you can setup a local kubernetes cluster using Minikube/kind
- A github account A free account is sufficient enough as we will be using Github Actions for Continuous Integration (CI)
- A dockerhub account We'll be using dockerhub to pull container images
- Eagerness to learn
- Not giving up This is the important one you'll face problems and you should be able to troubleshoot them and resolve them. I had to troubleshoot a lot of times before I can complete this project.
Lets start
Containerizing the app with Multi-stage docker builds
For the web app to be available on all machines we have to containerize it and when containerization is the word what's best than docker.
I created a Dockerfile to run the app but I have used multi-stage format
Why multi-stage builds ?
The reason is simple if I create an image in a single stage it would consume more space on the machine whereas by using multi-stage builds we optimize the final size of the image by seperating build and runtime environments and also reduce the attack surface of our image for better security
Here's how you can do it
- Create a dockerfile
- Compile the application in the build stage
- Copy the compiled binary to a minimal base image
- Build the image and push it to dockerhub so the image can be used by Github Actions in CI
# Start with a base image
FROM golang:1.22 as base
WORKDIR /app
COPY go.mod ./
RUN go mod download
COPY . .
RUN go build -o main .
#######################################################
# Reduce the image size using multi-stage builds
# We will use a distroless image to run the application
FROM gcr.io/distroless/base
# Copy the binary from the previous stage
COPY --from=base /app/main .
# Copy the static files from the previous stage
COPY --from=base /app/static ./static
# Expose the port on which the application will run
EXPOSE 8080
# Command to run the application
CMD ["./main"]
Now that we have the dockerfile let's build it and deploy it to dockerhub
docker build -t pankaj892/webapp:v1 .
We try to check whether app works as expected on local machine
docker run -p 8080:8080 pankaj892-webapp:v1
Lets push it to dockerhub
docker push pankaj892/webapp:v1 .
Kubernetes cluster creation
You can create a cluster locally using mininkube/kind or use any one of the managed solutions on cloud. I'll be using Elastic Kubernetes Service(EKS) from AWS.
You can launch a cluster in EKS using console or from commandline. I'll be using the commandline
eksctl create cluster--instance-selector-vcpus=2 --instance-selector-memory=4 --name <name-of-cluster> --region <region-code>
This will choose only those machine types for node groups which have 2 vCPUs and memory of 4gb
Helm chart creation and configuration
We can deploy all the resources one by one but it would be difficult to manage them as they scale this is where Helm comes in it acts as a package manager to manage all of our resources with the use of charts
Create a helm chart
helm create web-app
Helm will create files for our use but we don't need most of them for our project.
Create the following files and add them in the helm directory
Deployment
# This is a sample deployment manifest file for a simple web application.
apiVersion: apps/v1
kind: Deployment
metadata:
name: web-app
labels:
app: web-app
spec:
replicas: 1
selector:
matchLabels:
app: web-app
template:
metadata:
labels:
app: web-app
spec:
containers:
- name: web-app
image: {{ .Values.image.repository }}:{{ .Values.image.tag }}
ports:
- containerPort: 8080
Service
# Service for the application
apiVersion: v1
kind: Service
metadata:
name: web-app
labels:
app: web-app
spec:
ports:
- port: 80
targetPort: 8080
protocol: TCP
selector:
app: web-app
type: ClusterIP
Ingress
# Ingress resource for the application
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: web-app
annotations:
nginx.ingress.kubernetes.io/rewrite-target: /
spec:
ingressClassName: nginx
rules:
- host: web-app.local
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web-app
port:
number: 80
Update the values file to this
# Default values for web-app.
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
replicaCount: 1
image:
repository: pankaj892/webapp
pullPolicy: IfNotPresent
# Overrides the image tag whose default is the chart appVersion.
tag: "11440208844"
imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""
service:
type: ClusterIP
port: 80
ingress:
enabled: false
className: ""
annotations: {}
# kubernetes.io/ingress.class: nginx
# kubernetes.io/tls-acme: "true"
hosts:
- host: chart-example.local
paths:
- path: /
pathType: ImplementationSpecific
tls: []
# - secretName: chart-example-tls
# hosts:
# - chart-example.local
Helm part is done now let's move on to deploying our CI
Continuous Integration (CI) with Github Actions
Github Actions allow us to automate the build process of our app based on some events in our repo like push,pull.
Let's create our pipeline file
Workflow file is stored in (.github/workflows/cicd.yml)
# CICD using GitHub actions
name: CI and Deploy to ArgoCD
# Exclude the workflow to run on changes to the following files
on:
push:
branches:
- main
paths-ignore:
- 'helm/**'
- 'README.md'
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Go 1.22
uses: actions/setup-go@v2
with:
go-version: 1.22
- name: Build binary
run: go build -o web-app
- name: Test code
run: go test ./...
push:
runs-on: ubuntu-latest
needs: build
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to DockerHub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and Push action
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: ${{ secrets.DOCKERHUB_USERNAME }}/webapp:${{github.run_id}}
update-newtag-in-helm-chart:
runs-on: ubuntu-latest
needs: push
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
token: ${{ secrets.TOKEN }}
- name: Update tag in Helm chart
run: |
sed -i 's/tag: .*/tag: "${{github.run_id}}"/' helm/web-app/values.yaml
- name: Commit and push changes
run: |
git config --global user.email "pankaj28988@gmail.com"
git config --global user.name "pankaj892"
git add helm/web-app/values.yaml
git commit -m "fix: Update tag in Helm chart"
git push
This workflow file first builds our image from dockerfile then pushes it to dockerhub and then updates the tag of the image in our charts.yaml file in helm.
Setting up ArgoCD for Continuous Delivery
We will use argocd for our Cd pipeline since argocd will be able to pick up changes from our git repo and update them in the app.
Lets install argocd on our cluster
kubectl create namespace argocd
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
To access the argocd server we need to change the service to loadbalancer type
kubectl patch svc argocd-server -n argocd -p '{"spec": {"type": "LoadBalancer"}}'
For windows this would be
kubectl patch svc argocd-server -n argocd -p '{\"spec\": {\"type\": \"LoadBalancer\"}}'
If it doesn't work just edit service through kubectl and change the type to LoadBalancer it should work
Now get the ip of the service
kubectl get svc argocd-server -n argocd
We got the ip but we need the password to login to argocd
kubectl get secret argocd-initial-admin-secret -n argocd -o jsonpath="{.data.password}" | base64 --decode
This command would get the password and decode the password since the password is encoded in base64 format
After logging in click on New Project > Add the name for your project > Add the repo so that argocd can sync the repo argocd will automatically look for values file and pick that up after that click on submit
Ingress and DNS Mapping
We built our pipeline but how do we access our app you can't put in the cluster url from EKS everytime to access it we need to use an ingress for this
I am using Nginx Ingress from AWS so that I can access the app
Deploy the ingress on our cluster
kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/controller-v1.11.1/deploy/static/provider/aws/deploy.yaml
Now ingress is deployed and we need to add the ip of our cluster from EKS in our local hosts file for linux its /etc/hosts for windows it is in C:\Windows\System32\etc\hosts
<ip-of-cluster> web-app.local
Now we can access our app on web-app.local
We have done all the steps lets test our app
As you can see the url at the top is what we defined in our hosts file
We have the app running lets add something and commit to our repo so argocd can pick up that change and deploy to app
I made a change to me repo and this should trigger the pipeline
Pipeline has started and after its completion lets see whether argocd picks up that change
Yes we see changes in our app argocd did pick up changes and synced our app with the latest changes
If you made it this far then congrats!!!
This project has been a great learning experience for me right from deploying kubernetes on AWS to creating my pipelines and deployments and troubleshooting them. This project helped me create an end-to-end devops pipeline for a go app and it can be scalable based on needs. I plan to explore more like maybe deploying the eks clutser using terraform or cloudformation stacks and refine more.
If you get stuck somewhere you can reference this repo
Let me know in the comments how was your experience building this pipeline.
Top comments (0)