Loadtesting en optimalisatie van een bedrijfskritische applicatie
Load testing and optimizing a business-critical app
In dit artikel leg ik uit welke uitdagingen we tegenkwamen en welke oplossingen we hebben ingevoerd tijdens de ontwikkeling en productierelease van een project waaraan we ongeveer anderhalf jaar hebben gewerkt: de StembureauApp (“PollingStationApp”). Dit is een softwaretool die het stemproces in Nederlandse stemlokalen op verkiezingsdagen ondersteunt.
De app helpt bij het scannen van stemmen, tellen, invullen van validatiechecklists en beheren van de aanwezigheid van medewerkers. Ook kan de controlekamer het hele proces volgen, zodat het gemeentehuis op elk moment precies weet in welke stap elk stemlokaal zich bevindt. Al deze functies maken het proces, de logistiek en de communicatie voor gemeenten veel eenvoudiger en sneller.
Meer functionele details over de StembureauApp kun je lezen in Wouters blogpost .
Op dit moment gebruiken tien Nederlandse gemeenten, waaronder grote steden als Rotterdam, Eindhoven en Utrecht, de StembureauApp. Samen hebben deze gemeenten in totaal 1.500 stemlokalen en bedienen ze ongeveer twee miljoen Nederlandse kiezers (ongeveer 15% van alle kiezers in Nederland).
Een heel belangrijk punt om te benadrukken is dat de applicatie op de verkiezingsdag echt goed moet presteren. We kunnen ons geen performanceproblemen veroorloven, omdat die het democratische proces zouden kunnen verstoren.
Om dit artikel niet te uitgebreid te maken, richt ik me vooral op performancetesten en optimalisatietechnieken.
Laten we de technische details induiken. De back-end van StembureauApp is geschreven in Python/Django en de front-end in Angular. Docker werd gebruikt om microservices te maken die draaiden in een Kubernetes cluster. De twee belangrijkste voordelen van Kubernetes: resourcetoewijzing op basis van de grootte van de gemeente (het aantal stemlokalen op een verkiezingsdag) en namespace-isolatie , zodat datalekken tussen gemeenten onderling onmogelijk zijn.
We gebruikten verschillende tools om waardevolle inzichten te krijgen in de performance van de infrastructuur en onze backend. Ik ga iets dieper in op de belangrijkste:
We gebruikten de loadtestingtool Locust om de verschillende stappen te simuleren die een stemlokaal op een verkiezingsdag doorloopt, zoals openen, stemmen, tellen, enzovoort. We definieerden verschillende gebruikersgedragingen die aansloten op wat we tijdens een verkiezingsdag verwachtten, inclusief medewerkers en backofficegebruikers. Ook simuleerden we gebruikers die gedurende de dag in- en uitloggen en tussendoor zelfs zware data-exports genereren.
Locust genereert standaard grafieken en statistieken. Daarnaast logden we alles in Kibana en breidden we de nginx-logs uit, zodat we de resultaten konden vergelijken en konden vaststellen of een probleem in de backend, het netwerk of de client zat.
Voorbeeld van Locust-grafieken:

We gebruikten de benchmarkingtool Bombardier om de concurrency op verschillende niveaus van de applicatie te meten. Dit gaf ons specifiekere end-to-endinzichten in het aantal HTTPS-requests per seconde, de latency van de verbindingen en de throughput.
Om de netwerkperformance te analyseren, in plaats van de back-endperformance, Iperf3 bleek een zeer nuttige tool om ons informatie te geven over pure netwerk-throughput. We konden Iperf3 op verschillende lagen in het netwerk draaien om elk afzonderlijk niveau te valideren.
We schakelden uitgebreide logging in Nginx in met de volgende metrics:
log_format json_combined escape=json
'{'
'"time_local":"$time_local",'
'"remote_addr":"$remote_addr",'
'"remote_user":"$remote_user",'
'"request":"$request",'
'"status": "$status",'
'"body_bytes_sent":"$body_bytes_sent",'
'"request_time":"$request_time",'
'"upstream_addr":"$upstream_addr",'
'"upstream_connect_time":"$upstream_connect_time",'
'"upstream_response_time":"$upstream_response_time",'
'"http_referrer":"$http_referer",'
'"http_user_agent":"$http_user_agent"'
'}';
Vooral de request_time en upstream_response_time waren het nuttigst om te bepalen waar vertraging vandaan kwam.
Op dit punt vraag je je misschien af waarom we de client als mogelijke bottleneck beschouwden. Korte uitleg: de iPads waarop de applicatie draaide, maakten via een speciale simkaart verbinding met een privaat netwerk, en ook dat moest getest worden.
We logden veel data naar ElasticSearch, dus Kibana bleek een onmisbare tool om onze metrics te visualiseren.
Voorbeelddashboard tijdens een van onze loadtests:

In Kibana werden ook de “delta’s”, de tijdsverschillen tussen de request time en upstream response time , zichtbaar:

Als je naar de laatste screenshot kijkt, zie je dat de eerste twee rijen een vrij groot tijdsverschil hebben tussen de request time en de upstream. Omdat het exportendpoint duur is, zou je kunnen denken dat dit verschil ontstond doordat de client de response payload moest downloaden, maar tussen Nginx en Gunicorn was er geen netwerkvertraging.
Kijk ook naar de onderste twee rijen. De back-end verwerkt de POST-request in ongeveer 0,3 seconde, maar de gebruiker ervoer het endpoint als traag: ongeveer 3 seconden. In dit geval was de payload van de response helemaal niet groot; het was een heel kleine JSON-payload.
Dit wees ons direct naar de netwerklaag tussen de client en het Kubernetes-cluster (een bandbreedteprobleem). Wanneer die zijn limiet bereikte, kwamen zulke delta’s veel vaker voor en werd algemene “traagheid” ervaren.
Met deze specifieke metrics konden we vaststellen of de bottleneck uit het netwerk kwam of uit de backend zelf. Als het probleem in de backend had gezeten, konden we tijd investeren in het optimaliseren van de meest gebruikte endpoints en dit steeds opnieuw testen totdat de resultaten goed waren.
Nu we meer metrics en inzicht in mogelijke bottlenecks hadden, konden we beginnen met het optimaliseren van de code en configuraties.
Ik bespreek de volgende technieken en tools die we gebruikten om de applicatie te optimaliseren, te verbeteren en robuuster te maken:
Autoscaling werkte niet zoals verwacht. In de praktijk is dit een project waarbij de load voorspelbaar is, en wanneer schalen nodig is, ben je eigenlijk al te laat.
Omdat het opstarten van een nieuwe pod ongeveer tien tot twintig seconden duurt, staan nieuwe requests dan al in de wachtrij en veroorzaken ze extra load. Omdat alle hardware al aan ons was toegewezen, berekenden we simpelweg wat we tijdens pieken nodig hadden en schaalden we vooraf op.
Caching is in de meeste projecten een bekend onderwerp, en natuurlijk pasten we het hier ook toe. Wel waren er een paar aanpassingen nodig, omdat de standaard Django cache_page onvoldoende bleek.
Endpoints die baat konden hebben bij caching werden gewrapt met onze eigen cachemanager, die een semaphore (mutex) gebruikt waardoor slechts een request tegelijk de cache kan vullen. Alle andere requests serveren gewoon eerdere data uit Memcached . De reden hiervoor was dat we wilden voorkomen dat meerdere gelijktijdige requests hetzelfde werk deden. Tijdens de loadtests hielp dit om wachtrijen van requests te voorkomen (responstijden die na verloop van tijd oplopen).
Er was een specifiek GET-endpoint dat zo vaak werd aangeroepen dat het uiteindelijk te veel Gunicorn-workers verbruikte. De eerste, eenvoudige oplossing was om het aantal pods te verhogen zodat er meer threads beschikbaar waren (een pod heeft vier Gunicorn-workers). Er was echter een efficiëntere optie: het endpoint direct vanuit Nginx serveren.
Er was een cron pod die de waarden op de achtergrond bijwerkte en opsloeg in Memcached. Vervolgens haalde Nginx de response direct uit Memcached in plaats van de backend aan te roepen. Dat was veel sneller en verlaagde de load enorm.
Natuurlijk waren er hierbij een paar security-overwegingen, maar die hebben we netjes opgelost.

Endpoint-isolatie is een erg mooie functie van Kubernetes. Je kunt dezelfde pod deployen, maar met een ander label, bijvoorbeeld “backend-replica”. Daarna kun je een lijst met endpoints definiëren die deze nieuwe service aanroepen, terwijl de rest naar de standaard backend-pods gaat.
Het voordeel hiervan is dat een endpoint dat uiteindelijk te veel resources kan opslokken, geïsoleerd kan worden zodat het de belangrijke endpoints niet beïnvloedt. Bovendien is deze functie uitstekend te combineren met de volgende optimalisatie (databasereplicatie).
Zo ziet dat eruit in een Nginx-configuratie:
location ~ (/stembureauapp/rest/export/) {
proxy_pass http://stembureau-backend-service-replica.elements.svc.cluster.local;
}location /stembureauapp/rest/ {
proxy_pass http://stembureau-backend-service.elements.svc.cluster.local;
}
Een ander voordeel is dat je deze aanpassing kunt doen zonder ook maar een regel code in de back-end te wijzigen. Beide services met pods zijn exact gelijk; alleen worden specifieke endpoints naar de ene of de andere service doorgestuurd.
Multi-database kan in vrijwel elk Django-project worden gebruikt, omdat je eenvoudig een database router kunt definiëren en schrijfbewerkingen via de master kunt laten lopen en leesbewerkingen via de replica.
Dit kan soms echter lastig zijn, omdat de synchronisatie tussen master en replica enige tijd kan duren. Voor sommige processen is zo'n vertraging niet acceptabel, of erger nog: er kunnen race conditions ontstaan.
Met de vorige verbetering (endpoint-isolatie) kun je alleen die endpoints de replica-database laten gebruiken. Je isoleert dus niet alleen de pods voor specifieke endpoints, maar ook de database!
Het genereren van exports in de applicatie is misschien wel het beste voorbeeld van deze optimalisatie. Exports zijn read-only operaties zonder functioneel belang, maar kunnen computationeel erg zwaar zijn. Je wilt niet dat ze invloed hebben op de kern van de applicatie, niet op de backend en ook niet op de database.
ORM-profiling is een algemene performancetaak die je in elk project kunt doen, maar het is toch goed om hier te noemen. Omdat de backend een API is die is gemaakt met het Django REST framework, gebruikten we Django Silk om endpoints te inspecteren en te bepalen hoeveel resources ze gebruiken en hoeveel databasequeries ze uitvoeren. Zo konden we zien waar optimalisatie mogelijk was.
De meeste optimalisaties hadden te maken met prefetch_related of select_related om zware SQL-queries drastisch te verminderen.

Dat is het belangrijkste!
Op de dag van de verkiezingen voor het Europees Parlement 2019, op 23 mei 2019, zagen we dat de applicatie probleemloos draaide bij alle gemeenten. Er waren geen fouten en alle endpoints reageerden sneller dan voorspeld.
Het was een groot succes en we waren allemaal erg blij met de resultaten!