
Per un nostro cliente stiamo svolgendo delle attività di svecchiamento su alcuni prodotti, migrando gradualmente verso architetture a microservizi. Uno dei prodotti in questione ha seguito per molto tempo la politica del “non toccare ciò che funziona”, con il risultato di diventare ingestibile con il passare del tempo. Alcune classi che rappresentano la logica business sono cresciute a dismisura, diventando molto complesse da modificare, con una copertura dei test insufficiente e con una logica di business condivisa tra i vari domini che convivono nella stessa codebase.
Tutti questi fattori hanno reso difficile manutenere il sistema ed è stato quindi deciso di evolvere gradualmente il codice in diversi servizi. Purtroppo però la logica business è rimasta condivisa tra i domini e, attualmente, questa condivisione rappresenta il problema più importante.
Per suddividere correttamente la logica tra vari (micro)servizi e continuare a deployare valore senza introdurre regressioni o malfunzionamenti inaspettati, c’è bisogno di assicurarci che le modifiche apportate non cambino il comportamento atteso del software e, come sempre, la migliore arma è avere una codebase ben coperta da test.
Gli Unit Test sono molto utili allo scopo, ma non bastano a renderci confidenti sulle modifiche alla logica. Ci serve di poter testare anche le interazioni tra due o più (micro)servizi generati, effettuandoli in un ambiente temporaneo quanto più simile possibile a quello di produzione, chiamato Ephemeral Environment. Ci serve cioè una suite di Test di integrazione.
Per riuscire nel nostro intento è molto utile il concetto di Infrastructure as Code (IaC), che consiste nel rappresentare l’infrastruttura (network, virtual machine, load balancer, e connection) in modo descrittivo, attraverso il codice. Seguendo il principio per cui lo stesso codice genera sempre la stessa libreria, lo stesso modello di IaC genera sempre lo stesso environment ogni volta che viene applicato. In questo modo, descrivendo il nostro ambiente target, possiamo condividere tra team sviluppo e operation la stessa configurazione.
Quello che abbiamo fatto è stato utilizzare un framework di IaC in modo da ricreare un Ephemeral Environment, in cui i nostri servizi potessero girare simulando l’ambiente di produzione. Questo ci ha consentito anche di isolare tutte le dipendenze esterne fuori dallo scope della nostra logica. Ricreato l’ambiente, abbiamo costruito una suite di integration test che potesse validare la logica e semplificare l’aggiunta di nuove feature al prodotto.
Tra i tanto framework IaC sul mercato, è stato scelto Pulumi, che mette a disposizione una CLI, un runtime, librerie e un servizio hostato che lavorano insieme. Consente di fare delivery, provisioning, updating e gestione di infrastrutture in Cloud, supportando diversi linguaggi, tra cui TypeScript, JavaScript, Python, Go, e.NET, e i loro tool nativi, librerie e package manager. Pulumi lavora su tutti o quasi i cloud provider o in alternativa anche su un’istanza di Kubernetes (k8s), volendo testare in locale possiamo quindi utilizzare anche minikube o kind.
Un programma Pulumi presenta i seguenti componenti:
- Program: un insieme di file scritti in uno dei linguaggi a scelta.
- Project: una directory contenente il programma con i metadati associati.
- Stack: rappresenta un’istanza del progetto. Spesso ogni stack corrisponde a differenti cloud environment.
Vediamo come eseguire il deploy con Pulumi su Azure Kubernetes Service (AKS) e come lanciare una suite di test nell’ambiente creato. Il codice completo lo potete trovare qui. Ho leggermente modificato il classico esempio di guestbook da deployare in Kubernetes, partendo da quello che trovate qui.
Creiamo il progetto Pulumi con il comando:
`pulumi new kubernetes-typescript`
In questo modo, viene creato per noi il template di un progetto TypeScript che può essere deployato su un cluster Kubernetes. Bisogna ora installare i componenti con il package manager del linguaggio selezionato, nel nostro caso npm, e inizializzare uno stack.
```
npm install .
pulumi stack init azure
```
Nel template generato, troviamo il file `Pulumi.yaml` in cui sono settate le configurazioni generali. È inoltre possibile avere delle configurazioni specifiche per stack e per settare una configurazione basta dare il comando:
```
pulumi config set password <password> --secret
```
Prima di vedere come configurare il deploy diamo un occhio all’applicazione. Si tratta di un semplice guestbook che utilizza un cluster di Redis per memorizzare i messaggi lasciati dagli ospiti.
Per sviluppare il guestbook possiamo utilizzare un servizio Redis locale, magari deployato con Docker, ma per isolare completamente l’ambiente ho preferito utilizzare un mock di Redis:
```
var Redis = require('ioredis-mock')
var redis = new Redis({
data: {
'messages': [message]
}
})
```
Sviluppato il guestbook, ne realizziamo una immagine docker che andremo a caricare su un Azure Container Registry (ACR) collegato al Resource Group su cui è presente anche l’istanza di AKS.
Vediamo il DockerFile:
```
FROM node:slim
WORKDIR /app
## Copy package.json and package-lock.json before copy other files for better build caching
COPY ["./package.json", "./package-lock.json", "/app/"]
RUN npm install
COPY [ ".", "/app/" ]
EXPOSE 3000
CMD ["npm", "start"]
```
Eseguiamo la build del container e andiamo a caricarlo su ACR.
```
docker build . --tag=guestbook:v1
docker tag guestbook:v1 <my-acr-name>.azurecr.io/guestbook
docker push <my-acr-name>.azurecr.io/guestbook
Andiamo ora a vedere come Pulumi ci consente di descrivere l’architettura in modo programmatico. Nel progetto all’interno della cartella cloud, troviamo due file: index.ts e k8sjs.ts.
index.js
```
import * as pulumi from "@pulumi/pulumi";
import * as k8sjs from "./k8sjs";
const config = new pulumi.Config();
const redisLeader = new k8sjs.ServiceDeployment("redis-leader", {
image: "redis",
ports: [6379],
});
const redisReplica = new k8sjs.ServiceDeployment("redis-replica", {
image: "pulumi/guestbook-redis-replica",
ports: [6379],
});
const frontend = new k8sjs.ServiceDeployment("frontend", {
replicas: 3,
image: "<my-acr-name>.azurecr.io/guestbook:latest",
ports: [3000],
allocateIpAddress: true,
});
export let frontendIp = frontend.ipAddress;
```
Vediamo che index.ts descrive l’infrastruttura ad alto livello, sfruttando la classe ServiceDeployment creata in k8sjs.ts. Nel codice sono definite due istanze di Redis, di cui una leader e una replica. Inoltre, è specificato un deployment della nostra applicazione, con replica impostato a 3 in modo da avere ridondanza in caso di fallimenti.
Vediamo la classe ServiceDeployment :
```
import * as k8s from "@pulumi/kubernetes";
import * as k8stypes from "@pulumi/kubernetes/types/input";
import * as pulumi from "@pulumi/pulumi";
/**
* ServiceDeployment is an example abstraction that uses a class to fold together the common pattern of a Kubernetes Deployment and its associated Service object.
*/
export class ServiceDeployment extends pulumi.ComponentResource {
public readonly deployment: k8s.apps.v1.Deployment;
public readonly service: k8s.core.v1.Service;
public readonly ipAddress?: pulumi.Output<string>;
constructor(name: string, args: ServiceDeploymentArgs, opts?: pulumi.ComponentResourceOptions) {
super("k8sjs:service:ServiceDeployment", name, {}, opts);
const labels = { app: name };
const container: k8stypes.core.v1.Container = {
name,
image: args.image,
resources: args.resources || { requests: { cpu: "100m", memory: "100Mi" } },
env: [{ name: "GET_HOSTS_FROM", value: "dns" }],
ports: args.ports && args.ports.map(p => ({ containerPort: p })),
};
this.deployment = new k8s.apps.v1.Deployment(name, {
spec: {
selector: { matchLabels: labels },
replicas: args.replicas || 1,
template: {
metadata: { labels: labels },
spec: { containers: [ container ] },
},
},
}, { parent: this });
this.service = new k8s.core.v1.Service(name, {
metadata: {
name: name,
labels: this.deployment.metadata.labels,
},
spec: {
ports: args.ports && args.ports.map(p => ({ port: p, targetPort: p })),
selector: this.deployment.spec.template.metadata.labels,
type: args.allocateIpAddress ? ( "LoadBalancer") : undefined,
},
}, { parent: this });
if (args.allocateIpAddress) {
this.ipAddress = this.service.status.loadBalancer.ingress[0].ip;
}
}
}
export interface ServiceDeploymentArgs {
image: string;
resources?: k8stypes.core.v1.ResourceRequirements;
replicas?: number;
ports?: number[];
allocateIpAddress?: boolean;
}
```
In questo codice è presente una classe che utilizza le librerie di Pulumi per unire due concetti di k8s: il deployment e il service.
Nel deployment, se passato, viene specificato il numero di repliche di default impostato a 1. Se specificato il flag allocateIpAddress, il service verrà configurato come “LoadBalancer” e l’indirizzo IP assegnato sarà esposto tra i campi della classe. In caso contrario, il tipo impostato ad undefined espone semplicemente l’indirizzo IP all’interno del cluster k8s.
Con Pulumi è quindi possibile sviluppare l’applicazione e definire il design dell’infrastruttura utilizzando lo stesso linguaggio, in questo caso JavaScript/TypeScript.
Andiamo ora a deployare la nostra applicazione su Azure, operazione che richiede un unico comando da terminale: pulumi up. È inoltre possibile avere una preview prima di eseguire il deploy con il comando pulumi preview, che ci mostra le modifiche che verranno apportate senza modificare l’attuale deploy.
Pulumi offre inoltre una suite di integration test in Go: andiamo a vederne un esempio di utilizzo in cui creiamo un ephemeral environment ed eseguiamo test.
```
package examples
import (
"bytes"
"encoding/json"
"fmt"
"io"
"io/ioutil"
"net/http"
"os"
"path"
"testing"
"time"
"github.com/pulumi/pulumi/pkg/v2/testing/integration"
"github.com/stretchr/testify/assert"
)
func TestGuestbook(t *testing.T) {
cwd, err := os.Getwd()
if err != nil {
t.FailNow()
}
test := integration.ProgramTestOptions{
Dir: path.Join(cwd, "cloud"),
Quick: true,
SkipRefresh: true,
ExtraRuntimeValidation: func(t *testing.T, stack integration.RuntimeValidationStackInfo) {
var frontend = stack.Outputs["frontendIp"].(string)
checkHTTPResult(t, frontend)
checkMessageEndpoint(t, frontend)
},
}
integration.ProgramTest(t, &test)
}
func checkHTTPResult(t *testing.T, output interface{}) bool {
hostname := "http://" + output.(string) + ":3000"
body := doGet(t, hostname, 5*time.Minute)
if !assert.Contains(t, body, "<html>") {
return false
}
return true
}
type dataMessage struct {
messages []string
}
func checkMessageEndpoint(t *testing.T, output interface{}) bool {
hostname := "http://" + output.(string) + ":3000/messages"
message := dataMessage{
messages: []string{"a message"},
}
request, err := json.Marshal(message)
if !assert.Nil(t, err) {
return false
}
body := doPost(t, hostname, bytes.NewBuffer(request), 5*time.Minute)
body = doGet(t, hostname, 5*time.Minute)
if !assert.JSONEq(t, "{\"messages\": []}", body) {
return false
}
return true
}
```
Dando semplicemente il comando go test da riga di comando si riesce a tirare su l’ambiente opportunamente configurato, eseguire i nostri test definiti nella option ExtraRuntimeValidation e infine tirare giù l’ambiente.
Abbiamo visto come, utilizzando Pulumi, è possibile tirare su un’infrastruttura semplicemente scrivendo codice JavaScript/TypeScript e come eseguire test di integrazione utilizzando gli strumenti messi a disposizione dallo stesso framework.
Spero di avervi incuriosito.
Al prossimo articolo!