eliminering af Opgavebehandlingsafbrydelser ved at erstatte Apache Kafka uden nedetid

skalering af backend-infrastruktur til håndtering af hypervækst er en af de mange spændende udfordringer ved at arbejde hos DoorDash. I midten af 2019 stod vi over for betydelige skaleringsudfordringer og hyppige afbrydelser, der involverede selleri og kanin, to teknologier, der driver systemet, der håndterer det asynkrone arbejde, der muliggør kritiske funktionaliteter på vores platform, herunder ordrebekræftelse og Dasher-opgaver.

vi løste hurtigt dette problem med et simpelt, Apache Kafka-baseret asynkront opgavebehandlingssystem, der stoppede vores udfald, mens vi fortsatte med at gentage en robust løsning. Vores oprindelige version implementerede det mindste sæt funktioner, der var nødvendige for at rumme en stor del af eksisterende selleri-opgaver. Når vi var i produktion, fortsatte vi med at tilføje support til flere selleri-funktioner, mens vi adresserede nye problemer, der opstod, når vi brugte Kafka.

de problemer, vi stod overfor ved hjælp af selleri og selleri

Rabbit og selleri var missionskritiske stykker af vores infrastruktur, der drev over 900 forskellige asynkrone opgaver på DoorDash, herunder ordrebekræftelse, overførsel af handelsordrer og behandling af Dasher-placering. Problemet, som DoorDash stod overfor, var, at Kaninkv ofte gik ned på grund af overdreven belastning. Hvis opgavebehandlingen gik ned, gik DoorDash effektivt ned, og ordrer kunne ikke gennemføres, hvilket resulterede i indtægtstab for vores købmænd og Dashers og en dårlig oplevelse for vores forbrugere. Vi stod over for problemer på følgende fronter:

  • tilgængelighed: udfald forårsaget af efterspørgsel reduceret tilgængelighed.
  • skalerbarhed: kunne ikke skaleres med væksten i vores forretning. Observabilitet tilbød begrænsede målinger, og Selleriarbejdere var uigennemsigtige.
  • driftseffektivitet: genstart af disse komponenter var en tidskrævende, manuel proces.

hvorfor vores asynkrone opgavebehandlingssystem ikke var meget tilgængeligt

dette største problem, vi stod overfor, var afbrydelser, og de kom ofte, når efterspørgslen var på sit højeste. Rabbit ville gå ned på grund af belastning, overdreven tilslutning churn, og andre grunde. Ordrer ville blive standset, og vi bliver nødt til at genstarte vores system eller nogle gange endda opdrage en helt ny mægler og manuelt failover for at komme sig efter afbrydelsen.

Ved at dykke dybere ned i tilgængelighedsproblemerne fandt vi følgende underproblemer:

  • selleri giver brugerne mulighed for at planlægge opgaver i fremtiden med en nedtælling eller eta. Vores tunge brug af disse nedtællinger resulterede i mærkbare belastningsstigninger på mægleren. Nogle af vores udfald var direkte relateret til en stigning i opgaver med nedtællinger. Vi besluttede i sidste ende at begrænse brugen af nedtællinger til fordel for et andet system, vi havde på plads til planlægning af arbejde i fremtiden.pludselige trafikudbrud ville efterlade Rabbit i en forringet tilstand, hvor opgaveforbruget var betydeligt lavere end forventet. Efter vores erfaring kunne dette kun løses med en rabbitmk-hoppe. Det vil reducere hastigheden af forbindelser, der udgiver for hurtigt, så køerne kan holde trit. Strømningskontrol var ofte, men ikke altid, involveret i disse tilgængelighedsnedbrydelser. Når Strømningskontrol starter, ser udgiverne det effektivt som netværksforsinkelse. Netværksforsinkelse reducerer vores responstider; hvis latenstiden stiger under spidstrafik, kan betydelige afmatninger resultere i, at kaskade, når anmodninger hober sig op opstrøms.
  • vores python-medarbejdere havde en funktion kaldet harakiri, der var i stand til at dræbe alle processer, der overskred en timeout. Under afbrydelser eller afmatninger resulterede harakiri i en forbindelse til kaninmæglerne, da processer gentagne gange blev dræbt og genstartet. Med tusindvis af internetarbejdere, der kører til enhver tid, vil enhver langsomhed, der udløste harakiri, igen bidrage endnu mere til langsomhed ved at tilføje ekstra belastning til Rabbit.
  • i produktionen oplevede vi flere tilfælde, hvor Opgavebehandling i selleri forbrugerne stoppede, selv i mangel af betydelig belastning. Vores efterforskningsindsats gav ikke bevis for nogen ressourcebegrænsninger, der ville have stoppet behandlingen, og arbejderne genoptog behandlingen, når de blev hoppet. Dette problem blev aldrig rod forårsaget, selvom vi har mistanke om et problem hos Selleriarbejderne selv og ikke kanin.

samlet set var alle disse tilgængelighedsproblemer uacceptable for os, da høj pålidelighed er en af vores højeste prioriteter. Da disse udfald kostede os meget med hensyn til ubesvarede ordrer og troværdighed, havde vi brug for en løsning, der ville løse disse problemer så hurtigt som muligt.

hvorfor vores arvsløsning ikke skalerede

det næste største problem var skala. DoorDash vokser hurtigt, og vi nåede hurtigt grænserne for vores eksisterende løsning. Vi var nødt til at finde noget, der ville holde trit med vores fortsatte vækst, da vores arvsløsning havde følgende problemer:

rammer den lodrette skaleringsgrænse

Vi brugte den største tilgængelige Enkeltknudeløsning, der var tilgængelig for os. Der var ingen vej til at skalere lodret længere, og vi begyndte allerede at skubbe den knude til dens grænser.

tilstanden med høj tilgængelighed begrænsede vores kapacitet

På grund af replikation reducerede tilstanden primær-sekundær høj tilgængelighed (HA) gennemstrømning sammenlignet med indstillingen enkelt knude, hvilket efterlod os med endnu mindre headroom end blot løsningen med en enkelt knude. Vi havde ikke råd til at handle gennemstrømning for tilgængelighed.

for det andet reducerede den primære-sekundære HA-tilstand ikke i praksis sværhedsgraden af vores udfald. Failovers tog mere end 20 minutter at gennemføre og ville ofte sidde fast og kræve manuel indgriben. Beskeder blev ofte tabt i processen samt.

Vi var hurtigt ved at løbe tør for headroom, da DoorDash fortsatte med at vokse og skubbe vores Opgavebehandling til sine grænser. Vi havde brug for en løsning, der kunne skaleres vandret, efterhånden som vores behandlingsbehov voksede.

hvordan selleri og kanin tilbød begrænset observerbarhed

at vide, hvad der foregår i ethvert system, er grundlæggende for at sikre dets tilgængelighed, skalerbarhed og operationelle integritet.

da vi navigerede over de problemer, der er skitseret ovenfor, bemærkede vi, at:

  • Vi var begrænset til et lille sæt Kaninkv-målinger, der var tilgængelige for os.
  • Vi havde begrænset synlighed i selleri arbejderne selv.

vi havde brug for at kunne se realtidsmålinger af alle aspekter af vores system, hvilket betød, at observerbarhedsbegrænsningerne også skulle løses.

udfordringerne med operationel effektivitet

Vi stod også over for flere problemer med drift af Kaninknude:

  • vi måtte ofte failover vores Kaninknude til en ny for at løse den vedvarende nedbrydning, vi observerede. Denne operation var manuel og tidskrævende for de involverede ingeniører og måtte ofte udføres sent om aftenen uden for spidsbelastningstider.
  • Der var ingen interne selleri-eller Kanineksperter hos DoorDash, som vi kunne læne os på for at hjælpe med at udtænke en skaleringsstrategi for denne teknologi.

teknisk tid brugt på drift og vedligeholdelse var ikke bæredygtig. Vi havde brug for noget, der bedre opfyldte vores nuværende og fremtidige behov.

potentielle løsninger på vores problemer med selleri og kanin

med de ovenfor beskrevne problemer overvejede vi følgende løsninger:

  • Skift Sellerimægleren fra kanin til Redis eller Kafka. Dette ville give os mulighed for at fortsætte med at bruge selleri, med en anden og potentielt mere pålidelig backing datastore.
  • Tilføj multi-broker support til vores Django app, så forbrugerne kunne offentliggøre til N forskellige mæglere baseret på den logik, vi ønskede. Opgavebehandling vil blive sharded på tværs af flere mæglere, så hver mægler vil opleve en brøkdel af den oprindelige belastning.
  • Opgrader til nyere versioner af selleri og kanin. Nyere versioner af selleri og kanin forventes at løse pålidelighedsproblemer, købe os tid, da vi allerede udtrækkede komponenter fra vores Django-monolit parallelt.
  • migrere til en brugerdefineret løsning bakkes op af Kafka. Denne løsning kræver mere indsats end de andre muligheder, vi nævnte, men har også mere potentiale til at løse ethvert problem, vi havde med den ældre løsning.

hver mulighed har sine fordele og ulemper:

  • Kafka understøttes ikke af selleri endnu
  • løser ikke det observerede problem, hvor Selleriarbejdere stopper behandlingsopgaver
  • ingen forbedringer af selleriobservabilitet
  • på trods af intern erfaring havde vi ikke betjent Kafka i skala hos DoorDash.
  • forbedret tilgængelighed
  • vandret skalerbarhed
  • ikke garanteret at rette vores observerede fejl
  • løser ikke straks vores problemer med tilgængelighed, skalerbarhed, observerbarhed og driftseffektivitet
  • nyere versioner af Python kræves nyere versioner af Python.
  • løser ikke problemet med harakiri-induceret forbindelse churn
Option Pros ulemper
Redis som mægler
  • forbedret tilgængelighed med ElasticCache og multi forbedret mæglerobservabilitet med elasticcache som mægler
  • forbedret driftseffektivitet
  • intern operationel erfaring og ekspertise med Redis
  • en Mæglerbytte er lige-fremad som en understøttet mulighed i selleri
  • harakiri connection churn forringer ikke Redis-ydelsen væsentligt
  • inkompatibel med Redis clustered mode
  • Single node Redis skaleres ikke vandret
  • ingen selleri observerbarhedsforbedringer
  • denne løsning løser ikke det observerede problem, hvor selleri arbejdere stoppede med at behandle opgaver
Kafka som mægler
  • Kafka kan være meget tilgængelig
  • Kafka er vandret skalerbar
  • forbedret observerbarhed med Kafka som mægler
  • forbedret driftseffektivitet
  • DoorDash havde intern Kafka-ekspertise
  • en mæglerbytte er lige frem som en understøttet mulighed i selleri
  • harakiri connection churn forringer ikke Kafka-ydeevnen signifikant
flere mæglere
  • ingen observerbarhedsforbedringer
  • ingen operationelle effektivitetsforbedringer
  • løser ikke det observerede problem, hvor Selleriarbejdere stopper behandlingsopgaver
  • adresserer ikke det observerede problem, hvor Selleriarbejdere stopper med at behandle opgaver
  • adresserer ikke problemet med harakiri-induceret forbindelse churn
opgraderingsversioner
  • kan forbedre problemet, hvor selleriarbejdere sidder fast
  • kan købe os headroom for at implementere en langsigtet strategi
brugerdefineret Kafka-løsning
  • Kafka kan være meget tilgængelig
  • Kafka er vandret skalerbar
  • forbedret observerbarhed med Kakfa som mægler
  • forbedret driftseffektivitet
  • i-hus Kafka ekspertise
  • en mæglerændring er lige-fremad
  • harakiri connection churn forringer ikke Kafka-ydeevnen væsentligt
  • løser det observerede problem, hvor selleri-arbejdere stopper med at behandle opgaver
  • kræver mere arbejde med at implementere end alle de andre muligheder
  • på trods af intern erfaring havde vi ikke betjent Kafka i skala på DoorDash

vores strategi for onboarding af Kafka

i betragtning af vores krævede system oppetid, vi udtænkte vores onboarding-strategi baseret på følgende principper for at maksimere Pålidelighedsfordelene på kortest tid. Denne strategi involverede tre trin:

  • rammer jorden kører: Vi ønskede at udnytte det grundlæggende i den løsning, vi byggede, da vi gentog på andre dele af den. Vi sammenligner denne strategi med at køre en racerbil, mens vi bytter i en ny brændstofpumpe.
  • designvalg for en problemfri vedtagelse af udviklere: vi ønskede at minimere spildt indsats fra alle udviklere, der måtte være resultatet af at definere en anden grænseflade.
  • trinvis udrulning med nul nedetid: I stedet for at en stor prangende udgivelse blev testet i naturen for første gang med en større chance for fejl, fokuserede vi på at sende mindre uafhængige funktioner, der kunne testes individuelt i naturen over en længere periode.

rammer jorden kører

Skift til Kafka repræsenterede en større teknisk ændring i vores stak, men en, der var hårdt brug for. Vi havde ikke tid til at spilde, da vi hver uge mistede forretningen på grund af ustabiliteten i vores arv. Vores først og fremmest prioritet var at skabe et minimum levedygtigt produkt (MVP) for at give os midlertidig stabilitet og give os den plads, der er nødvendig for at gentage og forberede os på en mere omfattende løsning med bredere vedtagelse.

vores MVP bestod af producenter, der offentliggjorde opgavekvalificerede Navne (FKN ‘er) og syltede argumenter til Kafka, mens vores forbrugere læste disse meddelelser, importerede opgaverne fra FKN’ et og udførte dem synkront med de angivne argumenter.

den minimale levedygtige produktarkitektur(MVP), vi besluttede at bygge, omfattede en midlertidig tilstand, hvor vi ville offentliggøre gensidigt eksklusive opgaver til både arven (røde stiplede linjer) og de nye systemer (grønne faste linjer), før den endelige tilstand, hvor vi ville stoppe med at offentliggøre opgaver til Rabbit.1

Figur 1: Den minimale levedygtige produktarkitektur(MVP), vi besluttede at bygge, omfattede en midlertidig tilstand, hvor vi ville offentliggøre gensidigt eksklusive opgaver til både arven (røde stiplede linjer) og de nye systemer (grønne solide linjer), før den endelige tilstand, hvor vi ville stoppe med at offentliggøre opgaver til Rabbit.

designvalg for en problemfri vedtagelse af udviklere

nogle gange er udvikleradoption en større udfordring end udvikling. Vi gjorde det lettere ved at implementere en indpakning til selleri ‘ s @task annotation, der dynamisk dirigerede opgaveindsendelser til begge systemer baseret på dynamisk konfigurerbare funktionsflag. Nu kunne den samme grænseflade bruges til at skrive opgaver til begge systemer. Med disse beslutninger på plads måtte ingeniørhold ikke gøre noget yderligere arbejde for at integrere med det nye system, hvilket forhindrede implementering af et enkelt funktionsflag.

vi ønskede at rulle vores system ud, så snart vores MVP var klar, men det understøttede endnu ikke alle de samme funktioner som selleri. Selleri giver brugerne mulighed for at konfigurere deres opgaver med parametre i deres opgaveanmærkning, eller når de sender deres opgave. For at give os mulighed for at starte hurtigere oprettede vi en hvidliste over kompatible parametre og valgte at understøtte det mindste antal funktioner, der var nødvendige for at understøtte et flertal af opgaver.

vi øgede hurtigt opgavevolumen til den Kafka-baserede MVP, startende med opgaver med lav risiko og lav prioritet først. Nogle af disse var opgaver, der kørte uden for spidsbelastningstider, hvilket forklarer piggene i metrikken afbildet ovenfor.

figur 2: Vi øgede hurtigt opgavevolumen til den Kafka-baserede MVP, startende med opgaver med lav risiko og lav prioritet først. Nogle af disse var opgaver, der kørte uden for spidsbelastningstider, hvilket forklarer piggene i metrikken afbildet ovenfor.

som det ses i figur 2, med de to beslutninger ovenfor, lancerede vi vores MVP efter to ugers udvikling og opnåede en 80% reduktion i opgavebelastningen en anden uge efter lanceringen. Vi behandlede vores primære problem med udfald hurtigt, og i løbet af projektet støttede flere og flere esoteriske funktioner for at muliggøre udførelse af de resterende opgaver.

trinvis udrulning, nul nedetid

muligheden for at skifte Kafka-klynger og skifte mellem Kafka og Kafka dynamisk uden forretningsmæssig påvirkning var ekstremt vigtig for os. Denne evne hjalp os også i en række operationer såsom klyngevedligeholdelse, belastningsafgivelse og gradvise migrationer. At implementere denne udrulning, vi brugte dynamiske funktionsflag både på meddelelsesindsendelsesniveau såvel som på meddelelsesforbrugssiden. Omkostningerne ved at være fuldt dynamisk her var at holde vores arbejderflåde kørende med dobbelt kapacitet. Halvdelen af denne flåde var afsat til Kaninkv, og resten til Kafka. At køre arbejderflåden med dobbelt kapacitet beskattede bestemt vores infrastruktur. På et tidspunkt spundet vi endda en helt ny Kubernetes-klynge bare for at huse alle vores arbejdere.

i den indledende fase af udviklingen tjente denne fleksibilitet os godt. Når vi havde mere tillid til vores nye system, så vi på måder at reducere belastningen på vores infrastruktur, såsom at køre flere forbrugende processer pr. Da vi overgik forskellige emner, vi var i stand til at begynde at reducere antallet af arbejdere for Kaninkv samtidig med at vi opretholdt en lille reservekapacitet.

ingen løsning er perfekt, gentag efter behov

Med vores MVP i produktion havde vi det loftshøjde, der var nødvendigt for at gentage og polere vores produkt. Vi rangerede hver manglende selleri-funktion efter antallet af opgaver, der brugte den til at hjælpe os med at beslutte, hvilke der skal implementeres først. Funktioner, der kun blev brugt af nogle få opgaver, blev ikke implementeret i vores brugerdefinerede løsning. I stedet skrev vi disse opgaver om for ikke at bruge den specifikke funktion. Med denne strategi flyttede vi til sidst alle opgaver fra selleri.

brug af Kafka introducerede også nye problemer, der havde brug for vores opmærksomhed:

  • Head-of-the-line blokering, hvilket resulterede i forsinkelser i Opgavebehandling
  • implementeringer udløst partitionsbalancering, hvilket også resulterede i forsinkelser

Kafka ‘ s Head-of-the-line blokeringsproblem

Kafka-emner er opdelt således, at en enkelt forbruger (pr. forbrugergruppe) læser meddelelser for sine tildelte partitioner i den rækkefølge, de ankom. Hvis en meddelelse i en enkelt partition tager for lang tid at blive behandlet, vil den stoppe forbruget af alle meddelelser bag den i den partition, som det ses i figur 3 nedenfor. Dette problem kan være særligt katastrofalt i tilfælde af et højt prioriteret emne. Vi vil være i stand til at fortsætte med at behandle meddelelser i en partition i tilfælde af, at der sker en forsinkelse.

i Kafka ' s Head-of-the-line blokeringsproblem blokerer en langsom besked i en partition (i rødt) alle meddelelser bag den fra at blive behandlet. Andre partitioner vil fortsætte med at behandle som forventet.

figur 3: I Kafka ‘ s Head-of-the-line blokeringsproblem blokerer en langsom besked i en partition (i rødt) alle meddelelser bag den fra at blive behandlet. Andre partitioner vil fortsætte med at behandle som forventet.

mens parallelisme grundlæggende er et Python-problem, er begreberne i denne løsning også gældende for andre sprog. Vores løsning, afbildet i figur 4, Under, var at huse en Kafka-forbrugerproces og flere opgaveudførelsesprocesser pr. Kafka-consumer-processen er ansvarlig for at hente meddelelser fra Kafka og placere dem i en lokal kø, der læses af opgaveudførelsesprocesserne. Det fortsætter med at forbruge, indtil den lokale kø rammer en brugerdefineret tærskel. Denne løsning gør det muligt for meddelelser i partitionen at flyde, og kun en opgaveudførelsesproces vil blive stoppet af den langsomme meddelelse. Tærsklen begrænser også antallet af meddelelser under flyvning i den lokale kø (som kan gå tabt i tilfælde af et systemnedbrud).

figur 4: Vores ikke-blokerende Kafka-medarbejder består af en lokal meddelelseskø og to typer processer: en kafka-forbrugerproces og flere opgaver-eksekutorprocesser. Mens en Kafka-forbruger kan læse fra flere partitioner, for enkelhed vil vi kun skildre en. Dette diagram viser, at en besked med langsom behandling (i rødt) kun blokerer en enkelt opgaveksekutor, indtil den er afsluttet, mens andre meddelelser bag den i partitionen fortsat behandles af andre opgaveksekutorer.

figur 4: Vores ikke-blokerende Kafka-medarbejder består af en lokal meddelelseskø og to typer processer: en Kafka-forbruger proces og flere opgave-eksekutor processer. Mens en Kafka-forbruger kan læse fra flere partitioner, for enkelhed vil vi kun skildre en. Dette diagram viser, at en besked med langsom behandling (i rødt) kun blokerer en enkelt opgaveksekutor, indtil den er afsluttet, mens andre meddelelser bag den i partitionen fortsat behandles af andre opgaveksekutorer.

disruptiveness af deploys

Vi implementerer vores Django app flere gange om dagen. En ulempe med vores løsning, som vi bemærkede, er, at en implementering udløser en rebalance af partitionsopgaver i Kafka. Emne for at begrænse genbalanceringsomfanget, forårsagede implementeringer stadig en øjeblikkelig afmatning i meddelelsesbehandling, da opgaveforbruget måtte stoppe under genbalancering. Afmatninger kan være acceptable i de fleste tilfælde, når vi udfører planlagte udgivelser, men kan være katastrofale, når vi f.eks. Konsekvensen ville være indførelsen af en cascading behandling afmatning.

nyere versioner af Kafka og klienter understøtter trinvis kooperativ rebalancering, hvilket massivt ville reducere den operationelle virkning af en rebalance. Opgradering af vores kunder til at understøtte denne type rebalancering ville være vores valgte løsning fremover. Desværre er Inkremental kooperativ rebalancering endnu ikke understøttet i vores valgte Kafka-klient endnu.

Key vinder

med afslutningen af dette projekt realiserede vi betydelige forbedringer med hensyn til oppetid, skalerbarhed, observerbarhed og decentralisering. Disse gevinster var afgørende for at sikre den fortsatte vækst i vores forretning.

ikke flere gentagne udfald

Vi stoppede de gentagne udfald næsten så snart vi begyndte at udrulle denne brugerdefinerede Kafka-tilgang. Udfald resulterede i ekstremt dårlige brugeroplevelser.

  • ved kun at implementere en lille delmængde af de mest anvendte selleri-funktioner i vores MVP var vi i stand til at sende arbejdskode til produktion om to uger.
  • med MVP på plads var vi i stand til at reducere belastningen på Rabbitm og selleri betydeligt, da vi fortsatte med at hærde vores løsning og implementere nye funktioner.

Opgavebehandling var ikke længere den begrænsende faktor for vækst

med Kafka i hjertet af vores arkitektur byggede vi et opgavebehandlingssystem, der er meget tilgængeligt og vandret skalerbart, så DoorDash og dets kunder kan fortsætte deres vækst.

massivt forstærket observerbarhed

da dette var en brugerdefineret løsning, kunne vi bage i flere målinger på næsten alle niveauer. Hver kø, arbejdstager og opgave var fuldt observerbar på et meget granulært niveau i Produktions-og udviklingsmiljøer. Denne øgede observerbarhed var en enorm gevinst ikke kun i produktionsfornemmelse, men også med hensyn til udviklerproduktivitet.

operationel decentralisering

med observabilitetsforbedringerne var vi i stand til at templatisere vores alarmer som Terraform-moduler og eksplicit tildele ejere til hvert enkelt emne og implicit alle 900 plus opgaver.

en detaljeret betjeningsvejledning til opgavebehandlingssystemet gør information tilgængelig for alle ingeniører til at fejle operationelle problemer med deres emner og medarbejdere samt udføre overordnede Kafka-klyngestyringsoperationer efter behov. Den daglige drift er selvbetjent, og der er sjældent brug for support fra vores infrastrukturteam.

konklusion

for at opsummere ramte vi loftet for vores evne til at skalere Kaninkv og måtte se efter alternativer. Alternativet vi gik med var en brugerdefineret Kafka-baseret løsning. Mens der er nogle ulemper ved at bruge Kafka, fandt vi en række løsninger, beskrevet ovenfor.

når kritiske arbejdsgange stærkt er afhængige af asynkron Opgavebehandling, er det yderst vigtigt at sikre skalerbarhed. Når du oplever lignende problemer, er du velkommen til at tage inspiration fra vores strategi, som gav os 80% af resultatet med 20% af indsatsen. Denne strategi er generelt en taktisk tilgang til hurtigt at afbøde pålidelighedsproblemer og købe hårdt brug for tid til en mere robust og strategisk løsning.

anerkendelser

forfatterne vil gerne takke Clement Fang, Corry Haines, Danial Asif, Luigi Tagliamonte, Matthæus vrede, Shaohua og Yun-Yu Chen for at bidrage til dette projekt.

foto af tian kuan på Unsplash

Skriv et svar

Din e-mailadresse vil ikke blive publiceret.