Våfflor med hjortronsylt och grädde – ett klassiskt exempel på när helheten kan bli bättre än de ingående beståndsdelarna. Runt oss finns det gott om exempel på hur just tre olika delar kompletterar varandra och bidar en balanserad helhet. Inom komplex systemarkitektur finns det en trio som fungerar alldeles särskilt bra tillsammans, och som rubriken antyder kommer det att handla om mikrotjänster, Docker och Node.js.
Jag kommer att börja med att beskriva mikrotjänster, med dess fördelar, men också utmaningar. Sedan ser vi hur man med Node.js och Docker kan komma runt många brister och därtill förstärka fördelarna, vilket verkligen visar hur bra de kompletterar varandra.
Särskilt om man arbetar inom agil utveckling blir det en stark kombination när man levererar många, små och inkrementella releaser, vilket förhoppningsvis ska framstå som tydligt mot slutet av artikeln.
Sedan några år har begreppet mikrotjänster seglat upp som lite av ett modeord inom systemutveckling. Mikrotjänster handlar i grund och botten om att adressera ett gammalt problem – att bryta ned en monolitisk arkitektur till mindre och mer hanterliga delkomponenter som tillsammans kan leverera en helhet, precis som man tidigare gjort med t.ex. enterprise java beans, COM med mera.
Det som nu är nytt är att man brukar säga att mikrotjänster ska vara helt autonoma. De kör som regel i en egen process, de hanterar sin egen data, de har inga beroenden och de kommunicerar genom ett formaliserat API, oftast baserat på HTTP och JSON. Detta ger en rad fördelar:
Det blir överblickbart – varje tjänst har sin egen, oberoende kodbas, och om det handlar om ett stort system kan olika förvaltningsteam ansvara för olika tjänster. Nya utvecklare kan introduceras utan att behöva en lång startsträcka för att sätta sig in i djupa strukturer och interna samband mellan olika delar av systemet.
Det underlättar releaser – varje release kan bestå av förändringar i en enskild tjänst utan att man behöver röra de andra tjänster som utgör hela systemet.
Det blir tydligare gränssnitt för test – en tjänst består av en ”black box” vars enda interaktionsmöjlighet ligger i dess API.
Det är lätt att ta till sig – man kan testa de olika tjänsterna ”för hand” genom en vanlig webbläsare och bilda sig en uppfattning kring hur de fungerar
Olika delområden inom ett system kan vara lämpliga att hantera med olika teknologier – t.ex. NoSQL kontra traditionell relationsdatabas, eller ett funktionellt språk kontra ett objektorienterat. Eftersom det bara är tjänstens API som är relevant för omvärlden kan man använda de bäst lämpade verktygen för att hantera varje delområde.
Som vanligt kommer det goda till ett pris. Att hantera ett system som utgörs av kanske femtio separata processer kräver mer av såväl den sammanhållande arkitekturen som av övervakning för att säkerställa att alla tjänster är uppe och snurrar. Att varje tjänst är autonom och kommunicerar via HTTP innebär också att man behöver baka in en ”webbserver” i varje enskild tjänst.
Kraven på vad som ska finnas installerat på värddatorn i form av databashanterare, binärer, ramverk med mera blir omfattande, och i värsta fall hamnar man i situationer där olika tjänster behöver olika versioner av det som erbjuds av operativsystemet, eller att man tvingas synkronisera versionsuppgraderingar mellan flera olika tjänster.
Vore detta den slutgiltiga verklighet man hade att förhålla sig till är frågan om fördelarna egentligen uppväger nackdelarna. Som tur är behöver det inte vara så, som vi snart ska se.
När Google bestämde sig för att ge sig in på webbläsarmarknaden nöjde man sig i vanlig ordning inte med att acceptera gamla sanningar. Ett område man tyckte var bristfälligt var att JavaScript gick hopplöst långsamt – vilket också bidrog till att öppna en marknad för olika proprietära webbläsartillägg såsom Flash med mera. Detta kunde man inte acceptera och man valde helt enkelt att implementera en helt ny JavaScript-interpretator under namnet V8 med fullt fokus på prestanda.
Man valde också att leverera V8 som öppen källkod, helt fri för andra att använda. En snabb och fri JavaScript-implementation är för värdefull att bara användas till att skapa responsiva webbsidor, och ur detta föddes Node.js – i grund och botten något så enkelt som JavaScript på serversidan.
Det som gör Node.js speciellt är dock framförallt två saker – dess genomgående asynkrona mönster och dess ekosystem runt ”npm” (node package manager) som utgör världens största katalog av standardbibliotek. Att skapa en webbserver som kan leverera dynamiskt innehåll kan man åstadkomma med tio rader kod:
var express = require('express');
var app = express();
app.get('/', function (req, res) {
res.send('Hello World!\n');
});
app.listen(3000, function () {
console.log('Example app listening on port 3000!');
});
Ovanstående programkod illustrerar också det asynkrona beteendet – när en klient (webbläsare eller tjänstekonsument) öppnar http://localhost:3000/
kommer den anonyma funktionen anropas som skickar tillbaka texten ”Hello World!”. Denna modell är mycket mer snabb och effektiv än traditionella webbservrars trådpooler.
Den stora finessen med Node.js är hur anmärkningsvärt lite kod som behövs för att paketera en tjänst. I exemplet ovan levererar man typiskt JSON i stället för oformaterad text, och JSON som står för ”JavaScript Object Notation” är från början en del av språket.
Nu har vi ett lättviktigt sätt att erbjuda hela kommunikationsprotokollet och databärare (JSON) kring tjänsterna och hamnar i ett mycket bättre utgångsläge än tidigare. Fortfarande har vi däremot kvar ett riktigt träsk av konfigurationshantering om vi ska kunna tillgodose alla behov av persistens, understödjande bibliotek och binärer som behöver vara installerade på värddatorn för att tjänsterna ska fungera som tänkt. Till detta kan läggas att vi gärna håller oss med en systemtestmiljö, en integrationstestmiljö och en acceptanstestmiljö jämte produktionsmiljön. Alla dessa ska vara identiskt konfigurerade för att inte riskera att fel smyger sig med ut i produktion. Hur ska någon orka med detta?
Om man leker med tanken att varje tjänst kunde vara installerad på sin egen server skulle många problem försvinna. Varje konfiguration skulle vara förhållandevis enkel, och det skulle inte vara ett bekymmer att olika tjänster har olika versioner av understödjande komponenter och bibliotek. Men om femtio mikrotjänster innebär femtio servrar för att kunna leverera ett system blir det både väldigt dyrt och väldigt arbetskrävande.
Om man tänker ett steg vidare skulle man ju kunna virtualisera alla dessa servrar och låta dem köra på en och samma fysiska miljö. Bättre, men det kommer fortfarande att vara femtio OS-instanser och om varje instans allokerar två gigabyte minne och trettio gigabyte lagring kommer servern att behöva 100 gigabyte RAM och 1,5 terrabye lagring – innan vi ens har börjat prata om vilka resurser som själva mikrotjänsterna skulle behöva.
Man skulle vilja kunna virtualisera ytterligare en nivå – varje process skulle behöva se operativsystemet som om den vore ensam användare av filsystem och andra resurser samtidigt som man skulle vilja att operativsystemet under skalet skötte allt med gemensamma resurser. På samma sätt som en hypervisor skapar flera virtuella hårdvaror av en enda fysisk skulle en sådan komponent skapa ett virtuellt operativsystem av en ensam fysisk installation.
Här kommer container-tekniken in i bilden, och bland dessa är Docker förmodligen den som fått mest fotfäste. Det framstår nästan som magi när man startar femtio containrar som var och en ser ut att vara helt fristående operativsystem inom loppet av några sekunder, med några hundra megabyte minnesoverhead och någon gigabyte extra diskutrymme – totalt för alla instanser!
Det som gör det möjligt är att alla containrar ser sin egen rot i filsystemet och att bara ändringar jämfört med det underliggande filsystemet sparas per instans, så en instans som enbart innehåller en basinstallation av operativsystemet tar i princip ingen plats alls
Nu börjar det bli riktigt intressant – och det blir bättre. Det går att skapa skript för vilken konfiguration varje given avbild (image) ska ha (d.v.s. vad ska finnas utöver det underliggande bas-operativsystemet), så om en mikrotjänst behöver en databashanterare, en VPN-klient, ett kryptopaket eller vad det än månde vara kan det specificeras i skriptet och innan containern startar för första gången kommer konfigurationen att säkerställas. Aldrig mer brutna beroenden!
Lägger man nu också till att vi har behov av minst tre produktionslika testmiljöer blir det ännu bättre – samma avbild kan användas till hur många instanser som helst, så jobbet är redan gjort. Eftersom det är så liten overhead att starta en container kan man köra igång en miljö vid behov. Varje utvecklare kan ha sin egen miljö – eller kanske ännu hellre – en delmängd av alla tjänster, var och en i en egen, produktionslik container, beroende på vad man jobbar med för tillfället.
Med ovan beskrivna trippel har man uppnått många fördelar och samtidigt kunnat eliminera många av utmaningarna. Är detta en patentlösning på all mjukvaruarkitektur? Nej – det vore att gå för långt. Här är några exempel som borde höja varningsflagg:
Det bör användas till system av någorlunda dignitet. Så länge varje enskild utvecklare har hela kodbasen i huvudet är det troligen bättre med en traditionell monolitisk arkitektur.
Man behöver hantera en komplexare driftsituation och om man inte alls är beredd att arbeta mer mot DevOps kan det medföra jobbiga driftsöverlämningar.
Om man inte alls vill eller får jobba med open source blir det också knepigt, och licensmodeller är ofta inte anpassade till container-baserad driftsättning.
Allt som har med container-teknik att göra är mognare och lättare att arbeta med när man arbetar med Linux som driftsmiljö. Det är dock fullt möjligt att köra Docker ”native” i Windows-miljö. Om man kommer som utvecklare från Microsoft-miljö skulle jag ändå satsa på en uppsättning med Windows Server i botten, .NET Core om man så önskar för tjänsteimplementation, men med Linux som virtualiserat operativsystem.