eliminera Uppgiftsbehandlingsavbrott genom att ersätta RabbitMQ med Apache Kafka utan stillestånd

skalning av backend-infrastruktur för att hantera hypertillväxt är en av de många spännande utmaningarna med att arbeta på DoorDash. I mitten av 2019 mötte vi betydande skalningsutmaningar och frekventa avbrott med selleri och RabbitMQ, två tekniker som driver systemet som hanterar det asynkrona arbetet som möjliggör kritiska funktioner på vår plattform, inklusive orderkassa och Dasher-uppdrag.

Vi löste snabbt detta problem med ett enkelt, Apache Kafka-baserat asynkront uppgiftsbehandlingssystem som stoppade våra avbrott medan vi fortsatte att iterera på en robust lösning. Vår ursprungliga version implementerade den minsta uppsättningen funktioner som behövs för att rymma en stor del av befintliga Selleriuppgifter. En gång i produktionen fortsatte vi att lägga till stöd för fler Sellerifunktioner samtidigt som vi tog itu med nya problem som uppstod vid användning av Kafka.

problemen vi mötte med selleri och RabbitMQ

RabbitMQ och selleri var missionskritiska delar av vår infrastruktur som drev över 900 olika asynkrona uppgifter på DoorDash, inklusive orderkassa, handelsorderöverföring och Dasher-platsbehandling. Problemet DoorDash mötte var att RabbitMQ ofta gick ner på grund av överdriven belastning. Om uppgiftsbehandlingen gick ner gick DoorDash effektivt ner och order kunde inte slutföras, vilket resulterade i intäktsförlust för våra handlare och Dashers och en dålig upplevelse för våra konsumenter. Vi mötte problem på följande fronter:

  • tillgänglighet: avbrott orsakade av efterfrågan minskad tillgänglighet.
  • skalbarhet: RabbitMQ kunde inte skala med tillväxten av vår verksamhet.
  • observerbarhet: RabbitMQ erbjöd begränsade mätvärden och Selleriarbetare var ogenomskinliga.
  • driftseffektivitet: omstart av dessa komponenter var en tidskrävande, manuell process.

varför vårt asynkrona uppgiftsbehandlingssystem inte var mycket tillgängligt

det största problemet vi mötte var avbrott, och de kom ofta när efterfrågan var på topp. RabbitMQ skulle gå ner på grund av belastning, överdriven anslutning churn, och andra skäl. Beställningar skulle stoppas, och vi måste starta om vårt system eller ibland till och med ta upp en helt ny mäklare och manuellt failover för att återhämta sig från avbrottet.

När vi dyker djupare in i tillgänglighetsproblemen hittade vi följande delproblem:

  • selleri tillåter användare att schemalägga uppgifter i framtiden med en nedräkning eller ETA. Vår tunga användning av dessa nedräkningar resulterade i märkbara belastningsökningar på mäklaren. Några av våra avbrott var direkt relaterade till en ökning av uppgifter med nedräkningar. Vi beslutade slutligen att begränsa användningen av nedräkningar till förmån för ett annat system som vi hade på plats för schemaläggning arbete i framtiden.
  • plötsliga utbrott av trafik skulle lämna RabbitMQ i ett försämrat tillstånd där uppgiftsförbrukningen var signifikant lägre än väntat. Enligt vår erfarenhet, detta kunde bara lösas med en RabbitMQ studsa. RabbitMQ har ett koncept för flödeskontroll där det kommer att minska hastigheten på anslutningar som publicerar för snabbt så att köer kan hålla jämna steg. Flödeskontroll var ofta, men inte alltid, involverad i dessa tillgänglighetsnedbrytningar. När Flödeskontrollen sparkar in ser utgivarna det effektivt som nätverksfördröjning. Nätverksfördröjning minskar våra svarstider; om latensen ökar under topptrafik kan betydande avmattningar leda till att kaskaden när förfrågningar staplas uppströms.
  • våra python uwsgi webbarbetare hade en funktion som heter harakiri som var aktiverat för att döda alla processer som översteg en timeout. Under avbrott eller avmattningar resulterade harakiri i en anslutningskurn till RabbitMQ-mäklarna eftersom processer dödades upprepade gånger och startades om. Med tusentals webbarbetare som kör vid varje given tidpunkt, skulle någon långsamhet som utlöste harakiri i sin tur bidra ännu mer till långsamhet genom att lägga till extra belastning på RabbitMQ.
  • i produktionen upplevde vi flera fall där uppgiftsbehandling i Sellerikonsumenterna slutade, även i avsaknad av betydande belastning. Våra utredningsinsatser gav inte bevis på några resursbegränsningar som skulle ha stoppat bearbetningen, och arbetarna återupptog bearbetningen när de studsade. Detta problem var aldrig rot orsakade, även om vi misstänker ett problem i selleri arbetarna själva och inte RabbitMQ.

sammantaget var alla dessa tillgänglighetsproblem oacceptabla för oss eftersom hög tillförlitlighet är en av våra högsta prioriteringar. Eftersom dessa avbrott kostade oss mycket när det gäller missade beställningar och trovärdighet behövde vi en lösning som skulle ta itu med dessa problem så snart som möjligt.

varför vår äldre lösning inte skala

det näst största problemet var skala. DoorDash växer snabbt och vi nådde snabbt gränserna för vår befintliga lösning. Vi behövde hitta något som skulle hålla jämna steg med vår fortsatta tillväxt eftersom vår äldre lösning hade följande problem:

att slå den vertikala skalningsgränsen

vi använde den största tillgängliga enda nod RabbitMQ-lösningen som var tillgänglig för oss. Det fanns ingen väg att skala vertikalt längre och vi började redan driva den noden till dess gränser.

läget för hög tillgänglighet begränsade vår kapacitet

på grund av replikering minskade det primära sekundära läget för hög tillgänglighet (HA) genomströmningen jämfört med alternativet med en enda nod, vilket gav oss ännu mindre utrymme än bara lösningen med en enda nod. Vi hade inte råd att handla genomströmning för tillgänglighet.

För det andra minskade det primära sekundära HA-läget i praktiken inte svårighetsgraden av våra avbrott. Failovers tog mer än 20 minuter att slutföra och skulle ofta fastna kräver manuell ingripande. Meddelanden förlorades ofta också i processen.

Vi var snabbt slut på utrymme som DoorDash fortsatte att växa och driva vår uppgift bearbetning till sina gränser. Vi behövde en lösning som kunde skala horisontellt när våra bearbetningsbehov växte.

hur selleri och RabbitMQ erbjöd begränsad observerbarhet

att veta vad som händer i något system är grundläggande för att säkerställa tillgänglighet, skalbarhet och operativ integritet.

När vi navigerade i de problem som beskrivs ovan märkte vi att :

  • vi var begränsade till en liten uppsättning RabbitMQ-mätvärden tillgängliga för oss.
  • Vi hade begränsad synlighet i selleri arbetarna själva.

Vi behövde kunna se realtidsmått för varje aspekt av vårt system vilket innebar att observerbarhetsbegränsningarna också behövde åtgärdas.

de operativa effektivitetsutmaningarna

vi mötte också flera problem med att driva RabbitMQ:

  • Vi var ofta tvungna att failover vår RabbitMQ-nod till en ny för att lösa den ihållande nedbrytningen vi observerade. Denna operation var manuell och tidskrävande för de inblandade ingenjörerna och måste ofta göras sent på kvällen, utanför topptider.
  • Det fanns inga egna selleri-eller RabbitMQ-experter på DoorDash som vi kunde luta oss på för att hjälpa till att utforma en skalningsstrategi för denna teknik.

teknisk tid för drift och underhåll av RabbitMQ var inte hållbar. Vi behövde något som bättre uppfyllde våra nuvarande och framtida behov.

potentiella lösningar på våra problem med selleri och Kaninmq

med de problem som beskrivs ovan ansåg vi följande lösningar:

  • ändra Sellerimäklaren från RabbitMQ till Redis eller Kafka. Detta skulle göra det möjligt för oss att fortsätta använda selleri, med en annan och potentiellt mer tillförlitlig backing datastore.
  • Lägg till multi-broker support till vår Django app så att konsumenterna kan publicera till N olika mäklare baserat på vilken logik vi ville ha. Uppgiftsbehandling kommer att bli splittrad över flera mäklare, så varje mäklare kommer att uppleva en bråkdel av den ursprungliga belastningen.
  • uppgradera till nyare versioner av selleri och RabbitMQ. Nyare versioner av selleri och RabbitMQ förväntades fixa tillförlitlighetsproblem, köpa oss tid eftersom vi redan extraherade komponenter från vår Django monolith parallellt.
  • migrera till en anpassad lösning som stöds av Kafka. Denna lösning kräver mer ansträngning än de andra alternativen vi listade, men har också större potential att lösa alla problem vi hade med den äldre lösningen.

varje alternativ har sina fördelar och nackdelar:

  • Kafka stöds inte av selleri ännu
  • tar inte upp det observerade problemet där Selleriarbetare slutar bearbeta uppgifter
  • inga förbättringar av selleri observerbarhet
  • trots intern erfarenhet hade vi inte använt Kafka i skala på DoorDash.
alternativ fördelar nackdelar
Redis som mäklare
  • förbättrad tillgänglighet med ElasticCache och multi-az stöd
  • förbättrad mäklarobserverbarhet med elasticcache som mäklare
  • förbättrad operativ effektivitet
  • intern operativ erfarenhet och expertis med Redis
  • en mäklarbyte är rak som ett stödalternativ i selleri
  • harakiri connection Churn försämrar inte Redis prestanda signifikant
  • inkompatibel
  • Enkelnod Redis skalar inte horisontellt
  • inga förbättringar av selleri observerbarhet
  • denna lösning tar inte upp det observerade problemet där Selleriarbetare slutade bearbeta uppgifter
Kafka som mäklare
  • Kafka kan vara mycket tillgängligt
  • Kafka är horisontellt skalbar
  • förbättrad observerbarhet med Kafka som mäklare
  • förbättrad operativ effektivitet
  • DoorDash hade In-house Kafka expertis
  • en mäklare Swap är rakt fram som ett alternativ som stöds i selleri
  • Harakiri connection churn försämrar inte signifikant Kafka-prestanda
flera mäklare
  • förbättrad tillgänglighet
  • horisontell skalbarhet
  • inga förbättringar av observerbarheten
  • inga operativa effektivitetsförbättringar
  • åtgärdar inte det observerade problemet där Selleriarbetare slutar behandla uppgifter
  • åtgärdar inte problemet med Harakiri-inducerad anslutningskurn
uppgradera versioner
  • kan förbättra problemet där RabbitMQ fastnar i ett försämrat tillstånd
  • kan förbättra problemet där selleriarbetare fastnar
  • kan köpa oss utrymme för att genomföra en långsiktig strategi
  • inte garanterat att fixa våra observerade buggar
  • kommer inte omedelbart åtgärda våra problem med tillgänglighet, skalbarhet, observerbarhet och operativ effektivitet
  • nyare versioner av RabbitMQ och selleri krävs nyare versioner av Python.
  • tar inte upp problemet med Harakiri-inducerad anslutningskurn
Anpassad Kafka-lösning
  • Kafka kan vara mycket tillgänglig
  • Kafka är horisontellt skalbar
  • förbättrad observerbarhet med Kakfa som mäklare
  • förbättrad operativ effektivitet
  • intern Kafka-expertis
  • /li>
  • en Mäklarbyte är rak-Foward
  • harakiri connection Churn försämrar inte signifikant Kafka-prestanda
  • adresserar det observerade problemet där selleriarbetare slutar bearbeta uppgifter
  • kräver mer arbete för att genomföra än alla andra alternativ
  • trots in-house erfarenhet, vi hade inte drivit Kafka i skala på DoorDash

vår strategi för onboarding Kafka

Med tanke på vårt system upptid, vi utarbetat vår onboardingstrategi bygger på följande principer för att maximera pålitlighetsfördelarna på kortast möjliga tid. Denna strategi involverade tre steg:

  • slå marken igång: Vi ville utnyttja grunderna i lösningen vi byggde när vi itererade på andra delar av den. Vi liknar denna strategi med att köra en racerbil medan vi byter i en ny bränslepump.
  • designval för en sömlös adoption av utvecklare: vi ville minimera bortkastad ansträngning från alla utvecklare som kan ha resulterat från att definiera ett annat gränssnitt.
  • inkrementell utrullning med noll driftstopp: I stället för en stor flashig release testas i naturen för första gången med en högre risk för fel, vi fokuserade på sjöfarten mindre oberoende funktioner som kan individuellt testas i naturen under en längre tid.

att slå på marken

att byta till Kafka representerade en stor teknisk förändring i vår stack, men en som var mycket nödvändig. Vi hade inte tid att slösa eftersom vi varje vecka förlorade affärer på grund av instabiliteten i vår legacy RabbitMQ-lösning. Vår första och främsta prioritet var att skapa en minsta livskraftig produkt (MVP) för att ge oss tillfällig stabilitet och ge oss det utrymme som behövs för att iterera och förbereda oss för en mer omfattande lösning med bredare antagande.

vår MVP bestod av producenter som publicerade task Fully Qualified Names (FQN) och inlagda argument till Kafka medan våra konsumenter läste dessa meddelanden, importerade uppgifterna från FQN och utförde dem synkront med de angivna argumenten.

den minimala livskraftiga Produktarkitekturen(MVP) som vi bestämde oss för att bygga inkluderade ett interimsläge där vi skulle publicera ömsesidigt exklusiva uppgifter till både arvet (röda streckade linjer) och de nya systemen (gröna heldragna linjer), före det slutliga tillståndet där vi skulle sluta publicera uppgifter till RabbitMQ.1

Figur 1: Den minimala livskraftiga Produktarkitekturen(MVP) som vi bestämde oss för att bygga inkluderade ett interimsläge där vi skulle publicera ömsesidigt exklusiva uppgifter till både arvet (röda streckade linjer) och de nya systemen (gröna fasta linjer), före det slutliga tillståndet där vi skulle sluta publicera uppgifter till RabbitMQ.

designval för en sömlös adoption av utvecklare

Ibland är utvecklaranpassning en större utmaning än utveckling. Vi gjorde det enklare genom att implementera ett omslag för selleriets @task-anteckning som dynamiskt dirigerade uppgiftsinlämningar till något av systemen baserat på dynamiskt konfigurerbara funktionsflaggor. Nu kan samma gränssnitt användas för att skriva uppgifter för båda systemen. Med dessa beslut på plats måste ingenjörsteam inte göra något ytterligare arbete för att integrera med det nya systemet, utan att implementera en enda funktionsflagga.

Vi ville rulla ut vårt system så snart vår MVP var klar, men det stödde ännu inte alla samma funktioner som selleri. Selleri tillåter användare att konfigurera sina uppgifter med parametrar i sin uppgiftsannotering eller när de skickar in sin uppgift. För att vi ska kunna starta snabbare skapade vi en vitlista över kompatibla parametrar och valde att stödja det minsta antalet funktioner som behövs för att stödja en majoritet av uppgifterna.

vi ökade snabbt uppgiftsvolymen till Kafka-baserade MVP, med början med låg risk och lågprioriterade uppgifter först. Några av dessa var uppgifter som körde vid lågtrafik, vilket förklarar spikarna i metriska avbildade ovan.

Figur 2: Vi ökade snabbt uppgiftsvolymen till Kafka-baserade MVP, med början med låg risk och lågprioriterade uppgifter först. Några av dessa var uppgifter som körde vid lågtrafik, vilket förklarar spikarna i metriska avbildade ovan.

Som framgår av Figur 2, med de två besluten ovan, lanserade vi vår MVP efter två veckors utveckling och uppnådde en 80% minskning av RabbitMQ-uppgiftsbelastningen ytterligare en vecka efter lanseringen. Vi hanterade vårt primära problem med avbrott snabbt, och under projektets gång stödde vi fler och fler esoteriska funktioner för att möjliggöra utförande av de återstående uppgifterna.

inkrementell utrullning, noll driftstopp

möjligheten att byta Kafka-kluster och växla mellan RabbitMQ och Kafka dynamiskt utan affärspåverkan var oerhört viktigt för oss. Denna förmåga hjälpte oss också i en mängd olika operationer som klusterunderhåll, lastutsläpp och gradvisa migreringar. För att implementera denna utrullning använde vi dynamiska funktionsflaggor både på meddelandets inlämningsnivå och på meddelandeförbrukningssidan. Kostnaden för att vara helt dynamisk här var att hålla vår arbetsflotta igång med dubbel kapacitet. Hälften av denna flotta ägnades åt RabbitMQ och resten till Kafka. Att köra arbetsflottan med dubbel kapacitet beskattade definitivt vår infrastruktur. Vid ett tillfälle snurrade vi till och med upp ett helt nytt Kubernetes-kluster bara för att hysa alla våra arbetare.

under den inledande utvecklingsfasen tjänade denna flexibilitet oss bra. När vi hade mer förtroende för vårt nya system tittade vi på sätt att minska belastningen på vår infrastruktur, till exempel att köra flera konsumtionsprocesser per arbetsmaskin. När vi övergick olika ämnen, vi kunde börja minska arbetarnas räkningar för RabbitMQ samtidigt som vi behöll en liten reservkapacitet.

ingen lösning är perfekt, iterera efter behov

med vår MVP i produktion hade vi det utrymme som behövdes för att iterera och polera vår produkt. Vi rankade varje saknad Sellerifunktion efter antalet uppgifter som använde den för att hjälpa oss att bestämma vilka som ska genomföras först. Funktioner som används av endast ett fåtal uppgifter genomfördes inte i vår anpassade lösning. Istället skrev vi om dessa uppgifter för att inte använda den specifika funktionen. Med denna strategi flyttade vi så småningom alla uppgifter från selleri.

använda Kafka introducerade också nya problem som behövde vår uppmärksamhet:

  • head-of-the-line blockering vilket resulterade i uppgiftsbehandlingsfördröjningar
  • distributioner utlöste partitionsåterbalansering vilket också resulterade i förseningar

Kafka: s head-of-the-line blockeringsproblem

Kafka-ämnen är partitionerade så att en enskild konsument (per konsumentgrupp) läser meddelanden för sina tilldelade partitioner i den ordning de anlände. Om ett meddelande i en enda partition tar för lång tid att behandlas, kommer det att stoppa konsumtionen av alla meddelanden bakom den i den partitionen, vilket ses i Figur 3 nedan. Detta problem kan vara särskilt katastrofalt när det gäller ett högt prioriterat ämne. Vi vill kunna fortsätta att bearbeta meddelanden i en partition om en fördröjning inträffar.

i Kafkas head-of-the-line blockeringsproblem blockerar ett långsamt meddelande i en partition (i rött) alla meddelanden bakom det från att behandlas. Andra partitioner skulle fortsätta att bearbeta som förväntat.

Figur 3: I Kafkas head-of-the-line-blockeringsproblem blockerar ett långsamt meddelande i en partition (i rött) alla meddelanden bakom det från att behandlas. Andra partitioner skulle fortsätta att bearbeta som förväntat.

medan parallellism i grunden är ett Pythonproblem, är begreppen för denna lösning också tillämpliga på andra språk. Vår lösning, avbildad i Figur 4, nedan, var att hysa en Kafka-konsumentprocess och flera uppgiftsutförandeprocesser per arbetare. Kafka-konsumentprocessen ansvarar för att hämta meddelanden från Kafka och placera dem i en lokal kö som läses av uppgiftsutförandeprocesserna. Det fortsätter att konsumera tills den lokala kön träffar en användardefinierad tröskel. Denna lösning gör att meddelanden i partitionen kan flöda och endast en uppgift-exekveringsprocess kommer att stoppas av det långsamma meddelandet. Tröskeln begränsar också antalet meddelanden under flygning i den lokala kön (som kan gå vilse i händelse av en systemkrasch).

Figur 4: Vår icke-blockerande Kafka-arbetare består av en lokal meddelandekö och två typer av processer: en kafka-konsumentprocess och flera uppgiftsexekutorprocesser. Medan en kafka-konsument kan läsa från flera partitioner, för enkelhetens skull kommer vi att skildra bara en. Detta diagram visar att ett meddelande med långsam bearbetning (i rött) bara blockerar en enda uppgiftsexekutor tills den är klar, medan andra meddelanden bakom den i partitionen fortsätter att behandlas av andra uppgiftsexekutorer.

Figur 4: vår icke-blockerande Kafka-arbetare består av en lokal meddelandekö och två typer av processer: en Kafka-konsumentprocess och flera uppgiftsexekutorprocesser. Medan en kafka-konsument kan läsa från flera partitioner, för enkelhetens skull kommer vi att skildra bara en. Detta diagram visar att ett meddelande med långsam bearbetning (i rött) bara blockerar en enda uppgiftsexekutor tills den är klar, medan andra meddelanden bakom den i partitionen fortsätter att behandlas av andra uppgiftsexekutorer.

disruptiveness av distribuerar

Vi distribuerar vår Django app flera gånger om dagen. En nackdel med vår lösning som vi märkte är att en distribution utlöser en ombalans av partitionsuppdrag i Kafka. Trots att man använde en annan konsumentgrupp per ämne för att begränsa ombalanseringsomfånget, orsakade distributioner fortfarande en tillfällig avmattning i meddelandebehandlingen, eftersom uppgiftsförbrukningen var tvungen att sluta under ombalansering. Avmattningar kan vara acceptabla i de flesta fall när vi utför planerade utgåvor, men kan vara katastrofala när vi till exempel gör en nödlösning för att snabbkorrigera ett fel. Konsekvensen skulle vara införandet av en kaskad bearbetningsavmattning.

nyare versioner av Kafka och klienter stöder inkrementell kooperativ ombalansering, vilket massivt skulle minska den operativa effekten av en ombalansering. Att uppgradera våra kunder för att stödja denna typ av ombalansering skulle vara vår lösning att välja framåt. Tyvärr stöds inte inkrementell kooperativ ombalansering ännu i vår valda Kafka-klient.

Key wins

med avslutningen av detta projekt insåg vi betydande förbättringar när det gäller drifttid, skalbarhet, observerbarhet och decentralisering. Dessa vinster var avgörande för att säkerställa fortsatt tillväxt av vår verksamhet.

inga fler upprepade avbrott

vi stoppade de upprepade avbrott nästan så snart vi började rulla ut denna anpassade Kafka tillvägagångssätt. Avbrott resulterade i extremt dåliga användarupplevelser.

  • genom att implementera endast en liten delmängd av de mest använda Sellerifunktionerna i vår MVP kunde vi skicka arbetskod till produktion på två veckor.
  • med MVP på plats kunde vi avsevärt minska belastningen på RabbitMQ och selleri när vi fortsatte att härda vår lösning och implementera nya funktioner.

uppgiftsbehandling var inte längre den begränsande faktorn för tillväxt

med Kafka i hjärtat av vår arkitektur byggde vi ett uppgiftsbehandlingssystem som är mycket tillgängligt och horisontellt skalbart, vilket gör att DoorDash och dess kunder kan fortsätta sin tillväxt.

massivt förstärkt observerbarhet

eftersom detta var en anpassad lösning kunde vi baka i fler mätvärden på nästan alla nivåer. Varje kö, arbetare och uppgift var fullt observerbar på en mycket detaljerad nivå i produktions-och utvecklingsmiljöer. Denna ökade observerbarhet var en enorm vinst inte bara i produktionssyn utan också när det gäller utvecklarens produktivitet.

Operativ decentralisering

med observerbarhetsförbättringarna kunde vi templatisera våra varningar som Terraformmoduler och uttryckligen tilldela ägare till varje enskilt ämne och implicit alla 900-plus-uppgifter.

en detaljerad handbok för uppgiftsbehandlingssystemet gör information tillgänglig för alla ingenjörer att felsöka operativa problem med sina ämnen och arbetare samt utföra övergripande Kafka-klusterhanteringsoperationer, efter behov. Den dagliga verksamheten är självbetjäning och stöd behövs sällan från vårt Infrastrukturteam.

slutsats

Sammanfattningsvis slog vi taket på vår förmåga att skala RabbitMQ och var tvungna att leta efter alternativ. Alternativet vi gick med var en anpassad Kafka-baserad lösning. Även om det finns vissa nackdelar med att använda Kafka, hittade vi ett antal lösningar, beskrivna ovan.

när kritiska arbetsflöden är starkt beroende av asynkron uppgiftsbehandling är det av yttersta vikt att säkerställa skalbarhet. När du upplever liknande problem, ta gärna inspiration från vår strategi, som gav oss 80% av resultatet med 20% av ansträngningen. Denna strategi är i allmänhet ett taktiskt tillvägagångssätt för att snabbt mildra tillförlitlighetsproblem och köpa mycket tid för en mer robust och strategisk lösning.

bekräftelser

författarna vill tacka Clement Fang, Corry Haines, Danial Asif, Jay Weinstein, Luigi Tagliamonte, Matthew Anger, Shaohua Zhou och Yun-Yu Chen för att ha bidragit till detta projekt.

foto av tian kuan på Unsplash

Lämna ett svar

Din e-postadress kommer inte publiceras.