Canary app via LoadBalancer

Indien er een nieuwe versie van een software-pakket uitgerold moet gaan worden, kan het risico’s op fouten verminderd worden door eerst deze versie te (laten) testen door een selecte groep gebruikers. In de DevOps-wereld wordt dit het ‘canary-deployment‘ genoemd, wat zijn oorsprong heeft in de mijnbouw waar kanaries meegenomen werden en als er CO-gas aanwezig was en de kanaries gingen hieraan dood, dan was het een teken voor de mijnbouwers om de mijn te verlaten vóórdat zij hier hinder van ondervonden.

In DevOps dus een veel voorkomende techniek waarbij bijvoorbeeld een load-balancer service 4 nodes bedient waarvan één van de nodes een canary-version bevat. Monitoren van de gebruikers naar deze node en hun response kan dan uitsluitsel geven om al dan niet de nieuwe versie globaal uit te gaan rollen.

Als voorbeeld gaan we een Kubernetes deployment maken op de Raspberri PI cluster en maken we 3 pods voor productie met versie 1.0 en 1 pod als canary versie met versie 2.0 van de software. Een LB-Service dirigeert het verkeer naar alle 4 de pods met een verhouding van 80% naar productie en 20% naar canary, hetgeen resulteert in een selecte groep gebruikers die op versie 2.0 terecht komt.

Voorbereiding Applicatie

De applicatie die we gaan gebruiken is een simpel script geschreven in ‘GO’ en dat dient gecompiled te worden. Hiervoor gebruik ik één van de RPI’s om ‘golang‘ te installeren, zodoende zal met compilen een ARM64-versie van de go-app gemaakt worden.

$ sudo apt install -y golang
$ go version

De Applicatie bouwen

Aangezien we gebruik maken van ‘gopath’ maken we eerst de volgende directories en gaan vervolgens naar de app directory:

$ mkdir -p $HOME/gopath/src/app/{1.0,2.0}
$ cd $HOME/gopath/src/app

Maak een app.go bestand met de volgende inhoud:

package main       

import (       
       "fmt"           
       "net/http"            
)       

const version string = "1.0"       

func getFrontpage(w http.ResponseWriter, r *http.Request) {       
        fmt.Fprintf(w, "Congratulations! Version %s of your application is running on Kubernetes.", version)        
}       

func health(w http.ResponseWriter, r *http.Request) {               
        w.WriteHeader(http.StatusOK)  
}       
        
func getVersion(w http.ResponseWriter, r *http.Request) {       
        fmt.Fprintf(w, "%s\n", version)
}       
                                
func main() {       
        http.HandleFunc("/", getFrontpage)               
        http.HandleFunc("/health", health)       
        http.HandleFunc("/version", getVersion)       
        http.ListenAndServe(":8080", nil)       
 }

De versie van de applicatie wordt in een constante geplaatst en deze wordt geprint m.b.v. de GO http-package. In dit geval wordt ‘1.0’ als versie getoond indien de http-request “/version” bevat. /health geeft een 200 OK terug. De laatste regel geeft aan dat deze service luistert op TCP-poort 8080.

We kunnen nu de app versie 1.0 bouwen met het volgende commando:

$ go build -tags netgo -o ./1.0/app

Docker container maken

Nadat we de app hebben gemaakt, gaan we deze in een docker-container plaatsen en deze dan uploaden naar de Docker Hub. De Dockerfile ziet er zo uit:

FROM alpine:latest       

# In case no version is passed
ARG version=1.0
COPY ./$version/app /app
EXPOSE 8080
ENTRYPOINT ["/app"]

Docker image naar Docker Hub

Uiteraard dien je een account te hebben bij Docker Hub en inloggen op de CLI gaat met:

$ docker login

Vervolgens kan de applicatie in een image geplaatst worden, daarna voorzien worden van een tag die gelijk is aan het id van de image en tenslotte pushen we de image naar Docker Hub in de namespace van je account (mijn namespace is ‘nokkie’):

$ docker build -t app:1.0 .
$ TAG=$(docker images | grep app | grep 1.0 | awk {'print $3'} | uniq)
$ docker tag $TAG nokkie/app:1.0
$ docker push nokkie/app:1.0

Kubernetes Deployment

We gaan een kubernetes deployment bestand maken waarin we de production omgeving voorzien van 3 nodes (replicas) en we gebruiken de image die we hiervoor als versie 1.0 in docker hebben geplaatst. We gaan een kubernetes deployment bestand maken waarin we de production omgeving voorzien van 3 nodes (replicas) en we gebruiken de image die we hiervoor als versie 1.0 in docker hebben geplaatst.

Maak een app-production.yml bestand aan met de volgende inhoud:

---
kind: Namespace
apiVersion: v1
metadata:
  name: production
---
kind: Deployment
apiVersion: apps/v1
metadata:
   name: app-production
   namespace: production
spec:
   replicas: 3
   template:
     metadata:
       name: kubeapp
       labels:
         app: kubeapp
         env: production
     spec:
       containers:
       - name: kubeapp
         image: docker.io/nokkie/app:1.0
         imagePullPolicy: IfNotPresent
         readinessProbe:
           httpGet:
             path: /health
             port: 8080
         command: ["/app"]
         ports:
         - name: kubeapp
           containerPort: 8080

Zoals je kunt zien in de spec: is de labels: app=’kubeapp’ genoemd en staat de replicas: op 3. De readinessProbe is de ‘health-check’ die we met ‘/health’ kunnen opvragen op poort 8080.

We gaan de Deployment uitrollen met:

$ kubectl apply -f app-production.yml

Controle van de pods kan met:

$ kubectl -n production get pods -o wide

Een handig commando om te gebruiken is ‘get events’ hetgeen laat zien wat er gebeurt in de Kubernetes omgeving:

$ kubectl -n production get events -w

Load Balancer maken

Nu zijn er dus 3 nodes up-and-running die allen luisteren op TCP poort 8080 en dus gaan we nu een load-balancer aanmaken die inkomend verkeer verdeelt over deze 3 nodes. Maak een bestand genaamd app-lb.yml met de volgende inhoud:

kind: Service
apiVersion: v1
metadata:
   name: app-lb
   namespace: production
spec:
   type: LoadBalancer
   ports:
   - name: http
     port: 80
     targetPort: 8080
     protocol: TCP
   selector:
     app: kubeapp

De Service van type LoadBalancer kan gebruikt worden door Kubernetes. Het type LoadBalancer maakt een load-balancer in de namespace en zal automatisch het binnenkomende verkeer routeren naar de pods in de ‘backend’. We maken hier gebruik van een ‘Selector‘ die kijkt naar de “app: kubeapp” in de pods.

Aanmaken van de LB gaat met:

$ kubectl apply -f app-lb.yml

De LoadBalancer’s externe IP adres kunnen we opvragen met:

$ kubectl get services app-lb -n production

Handig is nu om het externe IP adres in een variabele te zetten zodat we deze kunnen gebruiken om de pagina op te vragen:

$ export SERVICE_IP=1.2.3.4
$ curl http://$SERVICE_IP/version

Als het goed is komt daar “1.0” als resultaat uit.


De canary versie

De canary-versie is de app met versie 2.0. Aanmaken gaat net zoals hiervoor met de 1.0 versie maar dan voor 2.0:
– pas de constante aan in het app.go bestand

const version string = "1.0" 

wordt dan:

const version string = "2.0" 

en builden van de app gaat dan met:

$ go build -tags netgo -o ./2.0/app

– wijzig de version naar 2.0 in Dockerfile en build deze naar GCE:

ARG version=2.0
$ docker build -t app:2.0 .

– maak een app-canary.yml bestand met de volgende inhoud:

kind: Deployment
apiVersion: apps/v1
metadata:
   name: kubeapp-canary
   namespace: production
spec:
   replicas: 1
   template:
     metadata:
       name: kubeapp
       labels:
         app: kubeapp
         env: canary
     spec:
       containers:
       - name: kubeapp
         image: docker.io/nokkie/app:2.0
         imagePullPolicy: IfNotPresent
         readinessProbe:
           httpGet:
             path: /health
             port: 8080
         command: ["/app"]
         ports:
         - name: kubeapp
           containerPort: 8080

Wat opvalt is de label env: canary en de replicas: 1 hetgeen een ratio van 3:1 ten opzichte van de stable versie geeft. En uiteraard de app-versie 2.0. Tijd om de canary-pod toe te voegen:

$ kubectl apply -f app-canary.yml

Met het ‘get pods’ commando zien we de toevoeging:

$ kubectl --namespace=production get pods -o wide

We hoeven nu de LB niet aan te passen aangezien deze al een ‘Selector’ heeft voor de pods met label ‘app: kubeapp’.

Test

Als test kunnen we een loop maken die met een interval van 1 seconde steeds de http-pagina oproept middels curl:

$ for i in `seq 1 10`; do curl http://$SERVICE_IP/version; sleep 1; done

De output zal dan 1.0 of 2.0 zijn, afhankelijk op welke pod je terecht komt.