Kan de kanarie blijven fluiten?

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 4 nodes bedient waarvan er éé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 Google Cloud Services en maken we 3 nodes voor productie met versie 1.0 en 1 node als canary versie met versie 2.0 van de software. Een load-balancer dirigeert het verkeer naar alle 4 de nodes hetgeen resulteert in een selecte groep gebruikers die op versie 2.0 terecht komen.

Voorbereiding Google Cloud Services

De applicatie die we gaan gebruiken is een simpel script geschreven in ‘GO’ en dat dient gecompiled te worden. Gelukkig heeft de ‘Google Cloud Shell environment’ dit al standaard aanwezig dus we kunnen alles in de GCS doen. We dienen hiervoor uiteraard een account te hebben bij Google Cloud Services (GCS). Er is de mogelijkheid om een gratis proef versie te gebruiken met $300 credit, zie hiervoor https://console.cloud.google.com Maak een nieuw project aan en zorg ervoor dat ‘billing‘ aan staat en de API’s voor ‘Compute Engine‘ en ‘Container Engine‘ op ‘enabled‘ staan.

Na ingelogd te zijn bij GCS, activeer dan de ‘Cloud Shell‘ via het icon rechtsboven:

Het eerste commando wordt als tip meegegeven bij het starten van de Cloud Shell, het zetten van je PROJECT_ID.

$ export PROJECT_ID=$(gcloud config get-value project)

Hierna zetten we gelijk maar de zone waarin wij onze resources willen aanmaken:

$ gcloud config set compute/zone europe-west4-c

Vervolgens gaan we de Kubernetes Cluster maken en we noemen deze ‘nico-cluster’

$ gcloud container clusters create nico-cluster

Als controle kunnen we na het aanmaken de cluster-info en de credentials opvragen

$ kubectl cluster-info
$ gcloud container clusters get-credentials nico-cluster

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. 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 gebruiken in de Google Cloud Services. 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 Google

Kubernetes zit bovenop de GCS en we kunnen nu deze Google Container Registry aanspreken om de docker image in op te slaan. De URL is afhankelijk van het PROJECT_ID dat we eerder al hebben ge-exporteerd:

$ docker build -t gcr.io/$PROJECT_ID/app:1.0 .

Kubernetes heeft nu automatisch toegang tot de docker-images die in de nodes gebruikt gaan worden.

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.compileerWe 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: Deployment
apiVersion: extensions/v1beta1
metadata:
name: kubeapp-production
spec:
replicas: 3
template:
metadata:
name: kubeapp
labels:
app: kubeapp
env: production
spec:
containers:
- name: kubeapp
image: gcr.io/PROJECT_ID/app:1.0
imagePullPolicy: Always
readinessProbe:
httpGet:
path: /health
port: 8080
command: ["/app"]
ports:
- name: kubeapp
containerPort: 8080

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

De image voor de containers: moet van jouw PROJECT_ID uit de Google Container Registry (GCR) komen dus die string vervangen we door de eerder gedefinieerde PROJECT_ID:

$ sed -i.bak "s#PROJECT_ID#$PROJECT_ID#" app-production.yml

We gaan ook een ‘namespace‘ maken waarin we alles in uitrollen en noemen deze dan ‘production’:

$ kubectl create namespace production

Uiteraard moeten we deze namespace gebruiken in de commando’s. We gaan de Deployment uitrollen met:

$ kubectl --namespace=production apply -f app-production.yml

Controle van de pods kan met:

$ kubectl --namespace-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 --namespace=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:

compileercompileerkind: Service
apiVersion: v1
metadata:
   name: app-lb
spec:
   type: LoadBalancer
   ports:
   - name: http
     port: 80
     targetPort: 8080
     protocol: TCP
   selector:
     app: kubeapp compileerkind: Service
apiVersion: v1
metadata:
   name: app-lb
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 Google Cloud Services en zal automatisch het binnenkomende verkeer routeren naar de nodes 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 --namespace=production apply -f app-lb.yml

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

$ kubectl --namespace=production get services

Handig is nu om het externe IP adres in een variable 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 gcr.io/$PROJECT_ID/app:2.0 .

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

kind: Deployment
 apiVersion: extensions/v1beta1
 metadata:
   name: kubeapp-canary
 spec:
   replicas: 1
   template:
     metadata:
       name: kubeapp
       labels:
         app: kubeapp
         env: canary
     spec:
       containers:
       - name: kubeapp
         image: gcr.io/PROJECT_ID/app:2.0
         imagePullPolicy: Always
         readinessProbe:
           httpGet:
             path: /health
             port: 8080
         command: ["/app"]
         ports:
         - name: kubeapp
           containerPort: 8080compileerkind: Deployment
 apiVersion: extensions/v1beta1
 metadata:
   name: kubeapp-canary
 spec:
   replicas: 1
   template:
     metadata:
       name: kubeapp
       labels:
         app: kubeapp
         env: canary
     spec:
       containers:
       - name: kubeapp
         image: gcr.io/PROJECT_ID/app:2.0
         imagePullPolicy: Always
         readinessProbe:
           httpGet:
             path: /health
             port: 8080
         command: ["/app"]
         ports:
         - name: kubeapp
           containerPort: 8080

Wijzig wederom de string PROJECT_ID met jouw GCS project ID!

$ sed -i.bak "s#PROJECT_ID#$PROJECT_ID#" app-production.yml

Wat opvalt zijn de labels: 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 --namespace=production 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.