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.