Alerte care se declanșează o singură dată, nu o dată pe server
Una dintre alertele noastre operaționale, „credite scăzute” pentru aplicația publicată a unui constructor, trebuie să ajungă la noi cel mult o dată pe zi. Într-o după-amiază a sosit de cinci ori. Cauza: fiecare copie a API-ului nostru își amintea în propria memorie ce alerte trimisese deja, deci două servere o trimiteau de două ori, iar fiecare deploy ștergea acea memorie și le lăsa să o trimită din nou. Același tipar făcea și ca un job din fundal să-și facă treaba de două ori. Acum ambele se coordonează printr-un depozit partajat, iar ambele aleg varianta sigură dacă acel depozit nu este disponibil.
Simptomul
Mythex trimite alerte echipei atunci când ceva cere atenție: un serviciu e picat, un job s-a blocat, iar soldul de credite al unui constructor scade cât timp aplicația lui este publicată. Fiecare alertă are o fereastră de deduplicare, o perioadă în care aceeași alertă este trimisă o singură dată. Pentru alerta de credite scăzute, fereastra este de 24 de ore.
Cinci copii ale aceleiași alerte într-o după-amiază însemnau că fereastra nu se respecta. Mai rău, veneau în perechi, la aproximativ 11 minute distanță.
De ce s-a întâmplat
Pachetul nostru de alerte ținea deduplicarea în memorie: un tabel „cheie de alertă → când a fost trimisă ultima dată”, în interiorul procesului care rulează. Asta funcționează când există un singur proces care nu se repornește niciodată. Producția nu are niciuna dintre cele două:
- Două replici ale API-ului. Producția rulează două copii ale API-ului. Fiecare avea propriul tabel, deci fiecare trimitea alerta o dată. Asta explică perechile.
- Deploy-urile. Fiecare deploy înlocuiește ambele replici cu procese noi și tabele goale. După un deploy, alerta pleca din nou, de parcă n-ar fi fost trimisă niciodată.
Intervalul de ~11 minute dintre cele două alerte din fiecare pereche venea din jobul care generează această alertă. Rulează pe ambele replici și ia un lock, deci replicile îl câștigă pe rând, iar fiecare a ridicat alerta când îi venea rândul.
Exista și un defect mai discret în același cod. Ca să nu crească tabelul la nesfârșit, se curăța singur când ajungea la 500 de chei: șterge fiecare intrare mai veche de 15 minute, indiferent de fereastra ei. Sub sarcină, o deduplicare de 24 de ore putea dura un sfert de oră.
Soluția
O singură înregistrare partajată per alertă
Înainte ca API-ul să trimită o alertă, acum îi revendică cheia de deduplicare în Redis, un depozit în memorie partajat, cu care vorbesc ambele replici și care nu se repornește când se repornește API-ul. Revendicarea este o singură operație: „setează această cheie doar dacă nu există și expir-o după fereastră”. Replica care o revendică prima trimite alerta; cealaltă găsește cheia ocupată și rămâne tăcută. Un proces nou după un deploy găsește cheia încă acolo.
Cheia și fereastra provin din aceeași regulă pe care o folosea pachetul de alerte, acum partajată între ele, astfel încât cele două straturi nu pot diferi în privința a ce înseamnă „aceeași alertă”.
Eșuează deschis, nu închis
Decizia cea mai importantă a fost ce facem când Redis nu poate fi contactat. Am ales să trimitem alerta oricum. Dacă Redis este picat sau nu răspunde în 1,5 secunde, alerta pleacă. O alertă duplicată este zgomot. O alertă pierdută înseamnă că cineva nu a fost anunțat.
Alertele cele mai serioase, P0, nu sunt reținute deloc de deduplicare.
Fiecare intrare își păstrează propria fereastră
În deduplicarea în memorie a pachetului de alerte, fiecare intrare stochează acum momentul în care i se termină fereastra, iar curățarea elimină doar intrările a căror fereastră s-a încheiat. O cheie de 24 de ore rămâne 24 de ore, oricâte alte alerte ar sosi.
Același defect, în altă parte
Același tipar dublase și o parte din munca de fundal: un job de reconciliere care ține evidența stării fiecărui workspace în concordanță cu cea a furnizorului nostru de sandbox-uri. Fiecare rulare listează fiecare sandbox din cont (mii în producție, aproximativ cinci secunde) plus fiecare proiect care rulează sau este în pauză. Ambele replici îl rulau la fiecare două minute.
Scrierile lui sunt idempotente (rularea lor de două ori dă același rezultat ca rularea o dată), deci nimic nu se strica. Doar că era de două ori mai multă muncă. Acum fiecare fereastră de două minute este revendicată în Redis, la fel cum își revendicau deja contoarele de consum, iar doar replica care revendică rulează trecerea. Dacă Redis nu poate fi contactat, trecerea rulează oricum: a face munca de două ori este inofensiv, a nu o face nu este.
Cum am testat
Testele modelează configurația reală. Încarcă modulul de alerte de două ori, ca două „replici” separate, le indică spre un depozit partajat, ridică aceeași alertă din fiecare, apoi încarcă o a treia copie proaspătă care ține locul „după un deploy” și o ridică din nou. Se trimite exact o alertă. Alte teste verifică faptul că alerta altui constructor pleacă în continuare, că o alertă P0 este trimisă de pe ambele replici și că alerta pleacă și atunci când depozitul este picat sau depășește timpul de așteptare. Corecția de curățare are propriul test: o cheie de 24 de ore supraviețuiește altor 600 de alerte sosite 20 de minute mai târziu.
Pentru jobul de reconciliere, două replici care bat în același timp, plus o a treia bătaie în aceeași fereastră, produc o singură listare.
Ce am învățat
- Starea din memorie aparține fiecărui proces. Orice trebuie să fie adevărat între servere («trimis o singură dată», «rulat o singură dată») trebuie să locuiască undeva unde o împart.
- Deploy-urile sunt reporniri. Starea care supraviețuiește doar până la următoarea repornire nu supraviețuiește unei zile obișnuite de lucru.
- Decide în ce sens eșuezi. Pentru alerte, un depozit partajat picat ar trebui să însemne mai multe alerte, nu mai puține.
- Caută tiparul, nu doar instanța. Alerta a fost cazul zgomotos; jobul de reconciliere dublat era același defect, care costa discret munca.
Nimic din toate acestea nu se vede din interiorul Mythex. Pentru mai multe despre cum ținem platforma stabilă, vezi cum am ajuns la zero erori 502 la deploy-uri și fișierul de swap lipsă. Dacă ideea de a rula mai multe copii ale unui serviciu ți-e nouă, ghidul nostru despre ce este un server acoperă noțiunile de bază.