4. GitOps with Argo CD
Until now you deployed with kubectl apply from your laptop: whoever has the credentials changes the cluster, and nothing records what is running. GitOps inverts the direction: the desired state lives in Git, and an agent inside the cluster — Argo CD — pulls it and keeps the cluster equal to it, forever. CI never touches the cluster; it only writes to Git. The theory is in the delivery class.
flowchart LR
dev[git push] --> ci[CI: test, scan, image]
ci -->|image sha-…| ghcr[(ghcr.io)]
ci -->|success| deploy[Deploy workflow]
deploy -->|"commit: new tag in k8s/overlays/gitops"| git[(GitHub repo)]
argo[Argo CD in the cluster] -->|polls every 3 min| git
argo -->|apply, prune, self-heal| k8s[boxoffice namespace]
k8s -->|pull image| ghcr The platform components
Use the kind cluster of lab 10 (recreate it with step 1 of lab 10 if you deleted it; Traefik and metrics-server are needed). First remove what lab 10 deployed by hand — from now on Argo CD owns the namespace:
Install Argo CD, Argo Rollouts (step 5 uses it, and the GitOps overlay already contains a Rollout) and the small Prometheus of step 5. The large CRDs need server-side apply:
kubectl create namespace argocd
kubectl apply -n argocd --server-side --force-conflicts \
-f https://raw.githubusercontent.com/argoproj/argo-cd/v3.5.5/manifests/install.yaml
kubectl create namespace argo-rollouts
kubectl apply -n argo-rollouts --server-side \
-f https://github.com/argoproj/argo-rollouts/releases/download/v1.10.1/install.yaml
kubectl apply -f k8s/monitoring/prometheus.yaml
kubectl -n argocd rollout status deployment/argocd-server --timeout=300s
You should see deployment "argocd-server" successfully rolled out, and kubectl get pods -n argo-rollouts and kubectl get pods -n monitoring should show one running pod each.
Argo CD reports an Ingress as Healthy only when the controller has written an address into its status, and a Traefik exposed as a NodePort writes none: the Application would stay Progressing forever. Make Traefik publish 127.0.0.1:
helm upgrade traefik traefik/traefik --version 42.0.0 --namespace traefik --reuse-values \
--set providers.kubernetesIngress.publishedService.enabled=false \
--set providers.kubernetesIngress.ingressEndpoint.ip=127.0.0.1
After a minute, kubectl -n boxoffice get ingress shows 127.0.0.1 under ADDRESS (once the Application has created the Ingress, below).
Open the Argo CD UI in a second terminal and log in as admin:
kubectl -n argocd port-forward svc/argocd-server 8443:443 # keep it running
kubectl -n argocd get secret argocd-initial-admin-secret -o jsonpath='{.data.password}' | base64 -d; echo
Browse to https://localhost:8443 (accept the self-signed certificate) and use that password.
Access to your repository and images
Your repository and your packages are private, so the cluster needs read-only credentials. Create a fine-grained personal access token on GitHub (Settings → Developer settings → Fine-grained tokens) for your boxoffice repository with Contents: read, and a classic token with read:packages for the images (or make the boxoffice-catalog package public and skip the second secret). Never commit them:
export GH_USER=<your GitHub user, lowercase>
export REPO_TOKEN=<the fine-grained token>
export PKG_TOKEN=<the read:packages token>
kubectl -n argocd create secret generic boxoffice-repo \
--from-literal=type=git \
--from-literal=url=https://github.com/$GH_USER/boxoffice.git \
--from-literal=username=$GH_USER --from-literal=password=$REPO_TOKEN
kubectl -n argocd label secret boxoffice-repo argocd.argoproj.io/secret-type=repository
kubectl create namespace boxoffice
kubectl -n boxoffice create secret docker-registry ghcr-pull \
--docker-server=ghcr.io --docker-username=$GH_USER --docker-password=$PKG_TOKEN
The GitOps overlay
A new overlay, next to lab 10's local one: the same base, the image from GHCR, the Rollout of step 5, and a patch that exposes /actuator/prometheus.
# The environment Argo CD deploys (lab 11). CI changes only the "images" entry below.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: boxoffice
resources:
- ../../base
- catalog-services.yaml
- catalog-rollout.yaml
- catalog-analysis.yaml
images:
- name: boxoffice/catalog
newName: ghcr.io/your-user/boxoffice-catalog # your GitHub user, lowercase
newTag: sha-0000000000000000000000000000000000000000 # written by .github/workflows/deploy.yaml
secretGenerator:
- name: db-credentials # lab only: use Sealed Secrets / External Secrets in real projects
namespace: boxoffice
literals:
- DB_USER=boxoffice
- DB_PASSWORD=boxoffice
patches:
# the Rollout runs the pods from now on: the Deployment only lends it its pod template
- target: { kind: Deployment, name: catalog }
patch: |-
- op: replace
path: /spec/replicas
value: 0
- op: add
path: /spec/template/spec/imagePullSecrets
value: [{ name: ghcr-pull }]
- op: add # expose /actuator/prometheus for the canary analysis
path: /spec/template/spec/containers/0/env/-
value: { name: MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE, value: "health,info,metrics,prometheus" }
- op: add # publish the latency buckets (_bucket) that the p99 analysis needs
path: /spec/template/spec/containers/0/env/-
value: { name: MANAGEMENT_METRICS_DISTRIBUTION_PERCENTILESHISTOGRAM_HTTP_SERVER_REQUESTS, value: "true" }
# the HPA now scales the Rollout
- target: { kind: HorizontalPodAutoscaler, name: catalog }
patch: |-
- op: replace
path: /spec/scaleTargetRef
value: { apiVersion: argoproj.io/v1alpha1, kind: Rollout, name: catalog }
# Argo Rollouts points these two Services at the stable and the canary pods (it adds the
# rollouts-pod-template-hash to their selectors). Prometheus scrapes them, so every series
# carries service="catalog-stable" or service="catalog-canary".
apiVersion: v1
kind: Service
metadata:
name: catalog-stable
annotations:
prometheus.io/scrape: "true"
spec:
selector:
app: catalog
ports:
- name: http
port: 8082
targetPort: http
---
apiVersion: v1
kind: Service
metadata:
name: catalog-canary
annotations:
prometheus.io/scrape: "true"
spec:
selector:
app: catalog
ports:
- name: http
port: 8082
targetPort: http
apiVersion: argoproj.io/v1alpha1
kind: Rollout
metadata:
name: catalog
spec:
# no "replicas": the HPA owns it
revisionHistoryLimit: 3
selector:
matchLabels:
app: catalog
workloadRef: # reuse the pod template of the Deployment in base/
apiVersion: apps/v1
kind: Deployment
name: catalog
scaleDown: never # Git already sets the Deployment to 0 replicas
strategy:
canary:
stableService: catalog-stable
canaryService: catalog-canary
analysis: # runs in the background from the first step
templates:
- templateName: catalog-slo
startingStep: 1
steps:
- setWeight: 25
- pause: { duration: 2m }
- setWeight: 50
- pause: { duration: 2m }
# after the last step the canary becomes the new stable
apiVersion: argoproj.io/v1alpha1
kind: AnalysisTemplate
metadata:
name: catalog-slo
spec:
metrics:
- name: error-rate
interval: 30s
failureLimit: 1 # a second bad measurement aborts and rolls back
successCondition: len(result) == 0 || isNaN(result[0]) || result[0] < 0.01
provider:
prometheus:
address: http://prometheus.monitoring:9090
query: |
sum(rate(http_server_requests_seconds_count{service="catalog-canary", status=~"5.."}[1m]))
/
sum(rate(http_server_requests_seconds_count{service="catalog-canary"}[1m]))
- name: p99-latency
interval: 30s
failureLimit: 1
successCondition: len(result) == 0 || isNaN(result[0]) || result[0] < 0.5
provider:
prometheus:
address: http://prometheus.monitoring:9090
query: |
histogram_quantile(0.99, sum by (le) (
rate(http_server_requests_seconds_bucket{service="catalog-canary"}[1m])))
Replace your-user in newName with your GitHub user (lowercase) and leave newTag as it is: the Deploy workflow writes it after the next successful CI run. kubectl kustomize k8s/overlays/gitops must print the manifests without errors.
The Deploy workflow
# Continuous delivery by GitOps (lab 11): when CI succeeds on main, point the GitOps overlay at
# the image built from that commit. Argo CD, inside the cluster, sees the commit and deploys it.
# This workflow never talks to the cluster: it only writes to Git.
name: Deploy
on:
workflow_run:
workflows: [CI]
types: [completed]
branches: [main]
permissions:
contents: write # commit the new image tag
concurrency:
group: deploy
cancel-in-progress: false # deploys run one after the other, never in parallel
jobs:
promote:
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'push'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: main
- name: Set the catalog image of the gitops overlay to this commit
env:
SHA: ${{ github.event.workflow_run.head_sha }}
run: |
OWNER="${GITHUB_REPOSITORY_OWNER,,}"
cd k8s/overlays/gitops
kustomize edit set image "boxoffice/catalog=ghcr.io/${OWNER}/boxoffice-catalog:sha-${SHA}"
git diff --stat
- name: Commit and push (a commit made with GITHUB_TOKEN does not trigger CI again)
env:
SHA: ${{ github.event.workflow_run.head_sha }}
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add k8s/overlays/gitops/kustomization.yaml
git diff --cached --quiet && { echo "already deployed"; exit 0; }
git commit -m "deploy: catalog sha-${SHA::7}"
git push
It runs when CI completes on main, edits the images entry of the overlay with kustomize edit set image (Kustomize is preinstalled on GitHub's Ubuntu runners) and pushes the commit. Kustomize rewrites the whole file when it edits it: the comments disappear and the lists are re-indented, which is expected. A commit pushed with the workflow's GITHUB_TOKEN does not start new workflow runs, and CI ignores changes to k8s/** anyway, so there is no loop. Because Deploy commits to main, your local main falls behind after every deploy: from now on run git pull --rebase before each git push.
Branch protection
If main requires pull requests, the bot cannot push to it. Either allow the GitHub Actions bot to bypass the rule, or change the workflow to open a pull request with the new tag (a human — or a policy — approves each deployment).
The Application
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: boxoffice
namespace: argocd
spec:
project: default
source:
repoURL: https://github.com/your-user/boxoffice.git
targetRevision: main
path: k8s/overlays/gitops
destination:
server: https://kubernetes.default.svc
namespace: boxoffice
syncPolicy:
automated:
prune: true # delete what was removed from Git
selfHeal: true # revert manual changes in the cluster
syncOptions:
- CreateNamespace=true
Replace your-user in repoURL, commit and push everything, then register the Application (the only kubectl apply of the flow — from now on Argo CD applies):
git add .github k8s && git commit -m "lab 11: gitops" && git push
kubectl apply -f k8s/argocd/application.yaml
kubectl -n argocd get applications -w
Until the first Deploy commit lands, catalog cannot start (newTag is still the placeholder) and the Application shows Degraded — expected. When the push triggers CI and then Deploy, Argo CD picks up the new commit (it polls every 3 minutes; Refresh in the UI forces it). You should see the Application go to Synced and Healthy, the tree of resources in the UI (Namespace, StatefulSet db, Deployment redis, Rollout catalog with its pods…), and curl localhost/catalog/events/1 answering 200 through Traefik.
Drift and self-healing
Change the cluster by hand and watch Argo CD undo it:
kubectl -n boxoffice scale deployment redis --replicas=0
kubectl -n boxoffice get deployment redis -w
Within seconds Argo CD reports OutOfSync and, with selfHeal: true, restores 1 replica: the cluster can only be changed through Git. To roll back a release, you git revert the deploy commit.