This is a proof of concept of a CI/CD pipeline using GitHub Actions for continuous integration and ArgoCD for continuous deployment/delivery
- A Kubernetes cluster (I'll be using Minikube)
- Kubectl configured to use that cluster
ArgoCD is a technology that covers the continuous deployment/distribution piece for our project with kubernetes. It automates the deployment of new k8s resources. What's great about ArgoCD is that it resides in our cluster, while listening to a repository where we have all our infrastructure as code. This follows GitOps, which is a set of practices to manage infrastructure and application configurations using Git. That means that to change the infrastructure of our cluster we only need to push the changes to a repository, and all will be automated!
Run:
kubectl create namespace argocd
kubectl apply -n argocd -f https://raw.githubusercontent.com/argoproj/argo-cd/stable/manifests/install.yaml
I'm testing this in my local machine, therefore, I'll use port-forwarding to connect to the API server. See 'Reference' to know how to expose the API server with another method.
In another terminal, run:
kubectl port-forward svc/argocd-server -n argocd 8080:443
Next, we'll need the admin account password. To get it, simply run and copy
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath="{.data.password}" | base64 -d && echo
After that open a web browser and go to your ArgoCD server IP or URL In our case is 'https://localhost:8080/'.
Type 'admin' for the username and paste the password to log in.
And that's it! ArgoCD is successfully installed in our kubernetes cluster!
Next, we'll be using the React Calculator from ahfarmer. The project is located in the 'app' directory of this repository.
We'll be creating a simple deployment and service to host a simple calculator application. First, let's take a look at the deployment file:
apiVersion: apps/v1
kind: Deployment
metadata:
labels:
app: calculator
name: calculator-deployment
spec:
replicas: 1
selector:
matchLabels:
app: calculator
template:
metadata:
labels:
app: calculator
spec:
containers:
- image: pedrolopez030200.jfrog.io/pedro-repo-docker-local/test-app:v1.0.3
name: calculator-container
ports:
- containerPort: 3000
imagePullSecrets:
- name: regcred
As we can see, we are creating a deployment resource within the 'calculator' application. It will only create one replica of each pod. The containers image is pulled from a private repository, and it will pull the credentials from a secret called regcred (More on that later). Feel free to change the image to your own image in a private repository or a public one from docker hub.
Then, let us see the service file
apiVersion: v1
kind: Service
metadata:
name: calculator-service
spec:
selector:
app: calculator
ports:
- protocol: TCP
port: 3001
targetPort: 3000
Here we are creating a Cluster-IP service that targets the port 3000 of our pods with the app=calculator
label.
We need the credentials of our private registry to get the docker image. We can store them in a secret object, but we are working with GitOps. That means that we will expose our secrets in our repository, and that's a security flaw. This is a very discussed problem every time someone brings up GitOps and some solutions have been proposed.
Luckily we can use a very simple solution. We will create and inject our secret into the 'calculator' namespace via kubectl. ArgoCD won't delete and our k8s resources will be able to use it. This is not an elegant nor scalable solution, but it will work for our purposes.
First, we need to create a namespace for our app and our secret:
kubectl create namespace calculator
To do this we have to type the following command:
kubectl -n calculator create secret docker-registry regcred --docker-server=<your-registry-server> --docker-username=<your-name> --docker-password=<your-pword>
Replacing "<your-registry-server>" with the address of your registry server, "<your-name>" with your docker username, and "<your-pword>" with your docker password.
To check if the secret has been created, we can run the following command:
kubectl -n calculator get secret regcred -o yaml
This will output our secret and we can check if the data is correct.
Go to the ArgoCD UI and click the 'New App' button.
- Write the name you desire for this app, I'm choosing 'calculator-app' (it can only be lowercase letters and hyphens)
- Select the 'dafult' project
- Check 'AUTO-CREATE NAMESPACE'
- Copy and paste the URL of the repository with our IaC. I'll use this repository URL.
- Write the path of the yml files in the 'Path' text box. In this case, is './infra'
- For 'Cluster URL' select the local cluster ('https://kubernetes.default.svc')
- Choose a namespace. I'll use 'calculator'. It must be the same namespace as our secret
After that, click on 'CREATE' and wait for the app to be created. We will see something like this:
Which is great! It means that ArgoCD found our repository and all went smoothly.
Now the moment of truth, click 'SYNC' and then synchronize the app.
If all goes well, we should be able to see this:
And we are done! To check the app, we need to expose the calculator-service. You can do this in many different ways, depending on if you are testing the app in your local machine or hosting it in a kubernetes cluster in the cloud. I'll use port-forwarding.
kubectl -n calculator port-forward service/calculator-service 30000:3001
If we open 'http://localhost:30000' we should see a calculator!
We made all the CD piece of our project and that's awesome! But what happens if we want to make a change to the calculator? We would need to create the image, push it into our private registry and change the tag of the image value in our deployment manifest. Here's where GitHub Actions comes into action, letting us automate this process. GitHub Actions is a technology that lets us automate our workflows. One advantage of Github Actions is that we can use other people's actions in our workflows, there is an entire marketplace of custom actions that we can use to avoid rewriting what other developers have already written.
GHA workflows are saved and stored in our repository. To enable them we need to create the .github/workflows
directory. Inside this folder, we can create all the .yml files that we want. Each of these files represents a workflow, a workflow is a set of jobs which are a set of steps to be executed. Jobs run simultaneously, but can be instructed to run sequentially. These workflows might trigger depending on different events (e.g. every push, every merge, etc) and we can set their conditions to trigger. What's great about this is that we can create our own steps (like custom shell commands) or use other people's creations.
To learn more about Github Actions check the Official Documentation
First, we need to think about what we want GHA to do when we need to deploy a new version of our project.
Based on what we've mentioned before, a good starting point is:
- Checkout our repo (Because GitHub Actions runs on cloud servers that are created when the job needs to be executed)
- Create the docker image with a new version tag
- Push the docker image to our private registry
- Update the 'calc-deploy.yml' file to have the new image tag
- Commit and push this tag change
This looks pretty nice, but there's another thing we have to decide, when will we trigger this workflow? We may think it's a good idea to trigger it every time we push something to the master branch, and maybe it is, but to avoid creating a ton of versions of our image, we can trigger the action every time we create a new tag, this way we can make a lot of updates/changes to our project and create a tag when we decide it's time to release a new version.
Let's get down to business to define the jobs. Here I'll show the yml file piece by piece and explain what each part means
# file name: create-image.yml
name: create-image
on:
push:
tags:
- 'v*'
Here we declare the name of the workflow. After that, we indicate when we want the workflow to be triggered with the 'on' key value. By writing push and tags we indicate to GH that we want to execute the workflow every time a tag that starts with a 'v' is created.
jobs:
create-and-push-image:
runs-on: ubuntu-latest
steps:
Here we indicate the jobs we want to run. The first one is called 'create and push image'. We indicate in what OS we want it to run and then we specify the steps of the job.
- name: get version
id: get-version
run: |
echo ::set-output name=VERSION::$(echo $GITHUB_REF | cut -d / -f 3)
This is our first step. we named it 'get version' and gave it an id to get the output values later. The 'run' tag contains and echo command with the ::set-output
instruction, which sets an output value called VERSION that reads the value of the tag name. We will use it to write the tag of our docker image.
- name: login-docker
run: echo ${{ secrets.JFROG_PASSWORD }} | docker login pedrolopez030200.jfrog.io -u ${{ secrets.JFROG_USERNAME }} --password
As the name suggests, this step if used for logging in to docker. It's important to realize that I'm using two GitHub secrets to access to my docker credentials.
- name: checkout
uses: actions/checkout@v2
Here's our first action. As we can see, with this simple instruction we instruct GHA to checkout our repository.
- name: create image
run: |
cd app
docker build -t test-app:${{ steps.get-version.outputs.VERSION }} .
echo 'Created Image with name:tag = test-app:${{ steps.get-version.outputs.VERSION }}'
Here we go to the app directory to create the docker image. As we can see, I'm using the value of the VERSION output of get version
to tag the image.
- name: get-images-id
id: image-id
run: |
echo ::set-output name=IMAGE_ID::$(docker images -q test-app:${{ steps.get-version.outputs.VERSION }})
This step is used to get the image id of our newly created image, and save it in an output called IMAGE_ID. It is necessary for pushing the image to our private registry
- name: upload image to registry
run: |
docker tag ${{ steps.image-id.outputs.IMAGE_ID }} pedrolopez030200.jfrog.io/pedro-repo-docker/test-app:${{ steps.get-version.outputs.VERSION }}
docker push pedrolopez030200.jfrog.io/pedro-repo-docker/test-app:${{ steps.get-version.outputs.VERSION }}
And this is the moment we push the image to our registry. First, we tag it using the IMAGE_ID and VERSION, then we push it.
- name: install yq
uses: mikefarah/yq@v4.15.1
- name: update infra yaml file
run: |
cd infra
yq e -i '.spec.template.spec.containers[0].image="pedrolopez030200.jfrog.io/pedro-repo-docker-local/test-app:${{ steps.get-version.outputs.VERSION }}"' calc-deployment.yml
cat calc-deployment.yml
Once it's pushed, we need to change the version tag in our calc-deployment.yml
file. To do so, we can use a tool called 'yq' (a YAML processor). First, we install it with an action from 'mikefatah' and then we go to the 'infra' directory and change it.
- name: push change
uses: dmnemec/copy_file_to_another_repo_action@main
env:
API_TOKEN_GITHUB: ${{ secrets.API_TOKEN_GITHUB }}
with:
source_file: infra/calc-deployment.yml
destination_repo: PedroLopezITBA/CI-CD-Github-Actions-and-Argo
destination_branch: main
destination_folder: infra
user_email: pelopez@itba.edu.ar
user_name: PedroLopezITBA
commit_message: update image version ${{ steps.get_version.outputs.VERSION }} in yml file
And finally, the only thing left, is to push the change. Here we are using an action from dmnemec to copy and push a file to any repository. This is useful if we have our infrastructure as code in a separate repository. As we can see, we will need a GitHub secret with an active Github Token (More Info).
And that is it! Our workflow is complete and after we push these changes it will run every time we create a new tag.
Now we can test it by changing something in our calculator app (maybe the color of a button), pushing it, and seeing the workflow creating a pushing the image, for argoCD to detect that our cluster is out of sync. Let's do it.
I'll change a color value inside app > src > component > Button.css
:
...
.component-button.orange button {
background-color: #0000FF; /*Change it to blue*/
color: white;
}
...
After that, we'll create a new release in GitHub with the tag v1.0.6
and this will trigger the workflow.
Once it is complete, it should push a change to our infra directory and argoCD will detect it.
Then, we proceed to Sync the application and we are done. After the pod finishes creating, we should be able to see the calculator with blue buttons. (You may need to restart the port-forwarding of the service if you are running this locally).
- React Calculator: Ahfarmer