What You'll Build
You will implement a three-step canary rollout for CricketPulse using GitHub Actions and Kubernetes replica-proportion traffic splitting — without requiring a full service mesh. The workflow deploys a new version to a canary Deployment, waits for health validation at each weight step (5% → 25% → 100%), and promotes or rolls back based on a simulated metric check. You will deliberately inject a failure at the 25% step to observe the automated rollback logic, then fix the issue and observe a successful promotion to 100%. This exercise uses replica-proportional traffic splitting achievable with standard Kubernetes Services, making it runnable in any standard cluster including kind or k3s.
Prerequisites
- A Kubernetes cluster accessible from GitHub Actions — k3s, kind, AWS EKS, GKE, or AKS. Alternatively use echo-based mock deploy steps if no cluster is available.
- kubectl configured and the kubeconfig stored as `KUBECONFIG_STAGING` in GitHub Secrets as a base64-encoded string.
- The container image from Lesson 12 in `ghcr.io` — or a simple mock image for the exercise.
- Understanding of Argo Rollouts concepts from Lesson 19 and Kubernetes Deployment basics from Lesson 17.
- Node.js installed locally to tag a 'broken' image variant for testing the rollback scenario.
Setup & Project Structure
Deploy the stable version of CricketPulse to the cluster with two separate Deployments sharing one Service. The canary-stable Deployment serves the majority of traffic via more replicas; the canary-new Deployment serves the canary percentage via fewer replicas. This approximation works without a service mesh — traffic distribution is proportional to replica count.
# Create Kubernetes manifests for replica-proportion canary
mkdir -p k8s/canary
# Stable deployment (20 replicas initially)
cat > k8s/canary/deployment-stable.yaml << 'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: cricketpulse-stable
namespace: cricketpulse-staging
spec:
replicas: 20
selector:
matchLabels: { app: cricketpulse, track: stable }
template:
metadata:
labels: { app: cricketpulse, track: stable }
spec:
containers:
- name: cricketpulse
image: ghcr.io/YOUR_USERNAME/cricketpulse:stable
ports: [{ containerPort: 3000 }]
readinessProbe:
httpGet: { path: /health, port: 3000 }
EOF
# Canary deployment (0 replicas initially)
cat > k8s/canary/deployment-canary.yaml << 'EOF'
apiVersion: apps/v1
kind: Deployment
metadata:
name: cricketpulse-canary
namespace: cricketpulse-staging
spec:
replicas: 0
selector:
matchLabels: { app: cricketpulse, track: canary }
template:
metadata:
labels: { app: cricketpulse, track: canary }
spec:
containers:
- name: cricketpulse
image: ghcr.io/YOUR_USERNAME/cricketpulse:canary
ports: [{ containerPort: 3000 }]
readinessProbe:
httpGet: { path: /health, port: 3000 }
EOF
# Single Service routing to both stable and canary pods
cat > k8s/canary/service.yaml << 'EOF'
apiVersion: v1
kind: Service
metadata:
name: cricketpulse
namespace: cricketpulse-staging
spec:
selector:
app: cricketpulse # Routes to ALL pods with app=cricketpulse (both tracks)
ports:
- port: 80
targetPort: 3000
EOF
echo 'Kubernetes canary manifests created'Step 1 — Foundation
Create the workflow with the build step and the 5% canary deployment. The canary image is built and pushed, then deployed as a single replica (1 of 20 total = ~5%). Health checks confirm the canary pod is ready before proceeding. At this step, 95% of production traffic is unaffected.
# File: .github/workflows/cricketpulse-canary.yml — Step 1
name: CricketPulse Canary Rollout
on:
push:
branches: [main]
workflow_dispatch:
inputs:
canary-image-tag:
description: 'Image tag for canary version'
required: false
type: string
jobs:
build-canary:
runs-on: ubuntu-latest
permissions: { contents: read, packages: write }
outputs:
canary-tag: ${{ steps.tag.outputs.tag }}
steps:
- uses: actions/checkout@v4
- id: tag
run: echo "tag=${{ inputs.canary-image-tag || github.sha }}" >> $GITHUB_OUTPUT
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with: { registry: ghcr.io, username: ${{ github.actor }}, password: ${{ secrets.GITHUB_TOKEN }} }
- uses: docker/build-push-action@v5
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}/cricketpulse:${{ steps.tag.outputs.tag }}
build-args: APP_VERSION=canary-${{ steps.tag.outputs.tag }}
cache-from: type=gha
cache-to: type=gha,mode=max
canary-5pct:
runs-on: ubuntu-latest
needs: build-canary
environment: staging
steps:
- uses: actions/checkout@v4
- uses: azure/setup-kubectl@v4
with: { version: 'v1.29.0' }
- name: Configure kubeconfig
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG_STAGING }}" | base64 -d > ~/.kube/config
- name: Deploy canary image (1 replica = ~5%)
run: |
kubectl set image deployment/cricketpulse-canary \
cricketpulse=ghcr.io/${{ github.repository }}/cricketpulse:${{ needs.build-canary.outputs.canary-tag }} \
-n cricketpulse-staging
kubectl scale deployment/cricketpulse-canary --replicas=1 -n cricketpulse-staging
kubectl scale deployment/cricketpulse-stable --replicas=19 -n cricketpulse-staging
- name: Wait for canary rollout
run: kubectl rollout status deployment/cricketpulse-canary -n cricketpulse-staging --timeout=120s
- name: Confirm canary pod ready
run: |
kubectl wait pod -l 'app=cricketpulse,track=canary' \
--for=condition=Ready -n cricketpulse-staging --timeout=60s
echo "Canary pod healthy at 5% weight (1/20 replicas)"Step 2 — Core Logic
Add the 25% step with a simulated metric check and automatic rollback logic. The analysis script checks whether the canary version tag contains 'broken' — a stand-in for a real Prometheus query. If analysis fails, stable replicas are restored to 20, canary is scaled to 0, and the job exits 1 to prevent promotion.
# Step 2: Add 25% step with analysis and rollback (append to jobs)
canary-25pct:
runs-on: ubuntu-latest
needs: [build-canary, canary-5pct]
environment: staging
steps:
- uses: actions/checkout@v4
- uses: azure/setup-kubectl@v4
with: { version: 'v1.29.0' }
- name: Configure kubeconfig
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG_STAGING }}" | base64 -d > ~/.kube/config
- name: Scale to 25% canary weight (5 canary, 15 stable)
run: |
kubectl scale deployment/cricketpulse-canary --replicas=5 -n cricketpulse-staging
kubectl scale deployment/cricketpulse-stable --replicas=15 -n cricketpulse-staging
kubectl rollout status deployment/cricketpulse-canary -n cricketpulse-staging --timeout=120s
- name: Simulated metric analysis at 25%
id: analysis
run: |
CANARY_TAG="${{ needs.build-canary.outputs.canary-tag }}"
echo "Analysing canary metrics for tag: ${CANARY_TAG}"
# Simulate failure: any tag containing 'broken' fails analysis
if echo "${CANARY_TAG}" | grep -q 'broken'; then
echo "ANALYSIS_RESULT=fail" >> $GITHUB_OUTPUT
echo "ANALYSIS FAILED: simulated error rate exceeded threshold"
else
echo "ANALYSIS_RESULT=pass" >> $GITHUB_OUTPUT
echo "ANALYSIS PASSED: metrics within thresholds"
fi
- name: Rollback if analysis failed
if: steps.analysis.outputs.ANALYSIS_RESULT == 'fail'
run: |
echo "=== CANARY ROLLBACK ==="
kubectl scale deployment/cricketpulse-canary --replicas=0 -n cricketpulse-staging
kubectl scale deployment/cricketpulse-stable --replicas=20 -n cricketpulse-staging
echo "Traffic restored to 100% stable. Canary reverted."
exit 1Step 3 — Integration & Enhancement
Add full promotion to 100% and a pipeline summary. The promotion only runs if the 25% analysis passed. It scales canary to 20 replicas, waits for rollout, then scales stable to 0 — completing the traffic handover. The new image then becomes the stable baseline for the next deployment cycle.
# Step 3: Full promotion and summary (append to jobs)
promote-100pct:
runs-on: ubuntu-latest
needs: [build-canary, canary-25pct]
environment: staging
steps:
- uses: azure/setup-kubectl@v4
with: { version: 'v1.29.0' }
- name: Configure kubeconfig
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG_STAGING }}" | base64 -d > ~/.kube/config
- name: Promote canary to 100%
run: |
kubectl scale deployment/cricketpulse-canary --replicas=20 -n cricketpulse-staging
kubectl rollout status deployment/cricketpulse-canary -n cricketpulse-staging --timeout=300s
kubectl scale deployment/cricketpulse-stable --replicas=0 -n cricketpulse-staging
echo "Canary promoted to 100%. Stable scaled to 0."
- name: Update stable deployment image for next cycle
run: |
kubectl set image deployment/cricketpulse-stable \
cricketpulse=ghcr.io/${{ github.repository }}/cricketpulse:${{ needs.build-canary.outputs.canary-tag }} \
-n cricketpulse-staging
echo "Stable updated to promoted image for next canary cycle"
rollout-summary:
runs-on: ubuntu-latest
needs: [canary-5pct, canary-25pct, promote-100pct]
if: always()
steps:
- run: |
cat >> $GITHUB_STEP_SUMMARY << 'SUMMARY'
## CricketPulse Canary Rollout Summary
| Stage | Result |
|-------|--------|
| 5% Canary | ${{ needs.canary-5pct.result }} |
| 25% Canary | ${{ needs.canary-25pct.result }} |
| 100% Promotion | ${{ needs.promote-100pct.result }} |
**Canary tag:** ${{ needs.build-canary.outputs.canary-tag }}
**Deployer:** ${{ github.actor }}
SUMMARYStep 4 — Testing & Verification
Run three test scenarios: a successful full promotion, an automated rollback at 25% (using a 'broken' tag), and verification of replica counts at each stage.
# Test scenarios
# Apply k8s manifests to cluster first
kubectl create namespace cricketpulse-staging --dry-run=client -o yaml | kubectl apply -f -
kubectl apply -f k8s/canary/ -n cricketpulse-staging
# SCENARIO 1: Successful rollout
git add . && git commit -m 'feat: canary score refresh algorithm v2' && git push origin main
# Expected: 5% → 25% → 100% all pass
# Replica counts:
# After 5%: canary=1, stable=19
# After 25%: canary=5, stable=15
# After 100%: canary=20, stable=0
# SCENARIO 2: Automated rollback at 25%
# Build and push an image with 'broken' in the tag:
# docker build -t ghcr.io/YOUR_USERNAME/cricketpulse:broken-v1 . && docker push ...
# Manually dispatch with: canary-image-tag = broken-v1
# Expected: 5% passes → 25% analysis FAILS → rollback (stable=20, canary=0) → job fails
# Verify after rollback:
kubectl get deployments -n cricketpulse-staging
# cricketpulse-stable 20/20
# cricketpulse-canary 0/0Warning: The replica-proportion traffic splitting in this exercise is an approximation — with 1 canary and 19 stable replicas you get approximately 5% canary traffic assuming kube-proxy distributes connections uniformly. In practice, connection persistence (keep-alive, sticky sessions) can skew the distribution. For production canary deployments requiring precise percentage control, use a service mesh (Istio), Argo Rollouts with Nginx ingress, or a cloud load balancer with weighted target groups.
Extension Challenge: Replace the simulated metric check in the 25% step with a real Prometheus query using the HTTP API. Query `sum(rate(http_requests_total{status=~"5.."}[2m])) / sum(rate(http_requests_total[2m]))` from a Prometheus instance in your cluster via curl, parse the result with jq, and fail the pipeline if the rate exceeds 0.01 (1%). This makes the canary analysis identical to production-grade systems like Flagger.
- Replica-proportion traffic splitting approximates canary percentage routing without a service mesh — suitable for exercises and low-precision production scenarios.
- The rollback step must scale canary to 0 and stable back to 20, then exit 1 to fail the job and prevent the promotion step from running.
- Each canary step is a separate job with `needs:` dependency — if the 25% job fails, the 100% promotion job is automatically skipped by GitHub Actions.
- The promoted canary image should be set as the new stable deployment image so the next deployment cycle starts from the current stable baseline.
- Always include a summary job with `if: always()` to report the final state of each canary step regardless of whether the rollout succeeded or was aborted.
- `workflow_dispatch` with a canary-image-tag input enables testing specific image tags including the rollback scenario without requiring a code change commit.