Oprava nepoužiteľného memcache v Django frameworku

20.11 | 18:19 | Mirecove dristy | Miroslav Bendík

Nedávno ma prekvapila nedostupnosť jedného z mojich serverov pri miernom zvýšení záťaže. Chyba bola prekvapivo v zle implementovanom cachovacom backende. V dnešnom článku ukážem diagnostiku chyby a moju opravu.

Z Django frameworku bolo vo verzii 4.1 odstránená podpora cache backendu MemcachedCache. Dôvodom odstránenia je ukončenie vývoja python-memcached.

Náhradou za neudržiavaný backend by mal byť PyLibMCCache založený na pylibmc a PyMemcacheCache založený na pymemcache. Obe alternatívy sú vraj stabilné a dajú sa použiť ako drop-in náhrada.

Server neodpovedá

Chtiac-nechtiac momentálne spravujem malú skupinu serverov, na ktorých prevádzkujem veľké množstvo webových aplikácií. V zásade moje servery fungujú ako masívne zdieľaný hosting. Všetci zákazníci bežia v cgroupách, majú čiastočne zdieľanú RAM, čiastočne zdieľané CPU, ale žiaden nemá plný prístup.

Jedného dňa mám hlásený výpadok, kedy celý web jedného zákazníka prestal odpovedať. Dosť neštandardná situácia, ak bol doteraz CPU využívaný na 10 %. K žiadnemu DOS útoku ani zásadnému zvýšeniu trafficu však nedošlo. Kde by mohol byť problém?

Diagnostika

Veľká časť webu (bavíme sa prevažne o e-commerce systémoch) je cachovaná. Vďaka tomu trvá vyrenderovanie webu okolo 10ms (skoro všade mám CPU AMD EPYC 7642 čo je vážne super železo). V jednom momente začal server odpovedať za 2 500 ms namiesto štandardných 10 ms. Zmena bola skoková a okamžite som videl zvýšenie záťaže SQL servera, čo naznačovalo výpadok cache.

V tomto momente som začal experimentovať s niektorými parametrami memcached daemona a PyMemcacheCache backendu, ale všetko bolo viac-menej bez výsledku. Presnejšie povedané keď som znížil čas reconnectu, alebo počet pokusov na reconnect, dokázal som znížiť odozvu servera pri záťaži na 500 ms z pôvodných 2 500 ms. Trocha sa zastavím ešte u šialenej hodnoty 2 500 ms. Bez cache trvá vyrenderovanie domovskej stránky 500 ms. Cache má timeout 1s a počet opakovaných pokusov 2, takže ak sa 2x nedokáže pripojiť (čo trvá 2s), vyrenderuje sa bez cache za zvyšnú pol sekundu.

Testujeme

Začal som experimentovať s vláknami na vlastnom stroji. Spustil teda príkaz ab -n 300 -c 5 -g vystup.tsv 'http://localhost:8000/', ktorý zaťažuje server s 5 paralelnými vláknami. Všetko beží stabilne. Druhý krát som opakoval ten istý test, ale tentoraz som povolil 5 paralelných vlákien servera. Bumbác zrazu server odpovedá 10x pomalšie so šialenou štandardnou odchýlkou.

Porovnanie rýchlosti a 5/95 percentilu
Obrázok 1: Porovnanie rýchlosti a 5/95 percentilu
Časy odpovede
Obrázok 2: Časy odpovede

Vysvetlenie

Ak server vybavuje naraz len jednu požiadavku, všetko funguje ako má. Ak musí obslúžiť viacej požiadaviek súčasne, začne vypadávať cache. To je extrémne nepríjemná situácia, pretože stačí pár požiadaviek za sebou a všetky ďalšie požiadavky už spôsobia DOS servera.

Evidentne sa tam niečo deje s vláknami. Po krátkom vyhľadávaní som zistil, že ani jeden backend nie je thread safe, takže pri súbežnom použití z viacerých vlákien dôjde zákonite k zmiešaniu packetov a vypnutiu cache v dôsledku chyby (výnimka sa nevyhadzuje, pretože nechcem vyhadzovať chybu pri obyčajnom výpadku memcached servera).

Podľa dokumentácie môže PyMemcacheCache fungovať aj s povolenými vláknami ak má nastavený parameter use_pooling. Dokumentácia toho znesie veľa, reálne to nepomohlo.

Oprava

Problém som sa rozhodol opraviť vytvorením samostatného spojenia pre každé vlákno. Teoreticky by bolo možné použiť zámky, ale radšej by som chcel maximalizovať priepustnosť. Môj blbý cache backend som ako obvykle zverejnil na githube. Možnosti nastavenia sú rovnaké ako v PyLibMCCache. Jednoduché nastavenie vyzerá napríklad takto:

CACHES = {
    'default': {
        'BACKEND': 'django_pylibmc_threadsafe.PyLibMCCache',
        'LOCATION': '127.0.0.1:11211',
        'KEY_PREFIX': '',
        'OPTIONS': {
            'binary': True,
            'ignore_exc': True,
            'behaviors': {
                'ketama': True,
            }
        }
    },
}

Výsledný časový priebeh po úprave vyzerá podstatne lepšie, aj keď priepustnosť je nižšia. Tu musím pripomenúť, že Python má GIL, takže podľa očakávania je priepustnosť napriek vyššiemu počtu vlákien nižšia. Vlákna sú však užitočné pri I/O operáciách, napríklad komunikácia s databázou.

Časy odpovede po oprave
Obrázok 3: Časy odpovede po oprave
Porovnanie rýchlosti
Obrázok 4: Porovnanie rýchlosti

Záver

Všetky memcache backendy v Djangu sú momentálne nepoužiteľné pri zapnutom multithread režime. Bacha na to. Problémom sa dá vyhnúť buď vypnutím threadov a používaním len multiprocesového režimu, alebo použitím vlastného (prípadne môjho) cache backendu.

    • RE: Oprava nepoužiteľného memcache v Django frameworku 21.11 | 10:48
      Avatar Richard Antix  Používateľ

      Hm, to je riadne špecializovaný zápis, ale určite si nájde svojho čitateľa. Možno som to v článku prehliadol, ale čo bola príčina, že naraz pôvodné nastavenia, ktoré doteraz fungovali k spokojnosti, bolo potrebné opravovať/meniť? Teda - je to v úvode, že išlo k zmenu frameworku, ale tá zmena, tá sa deje nejako automaticky (laicky - ako automatický update)?

      • RE: Oprava nepoužiteľného memcache v Django frameworku 21.11 | 12:51
        Avatar Miroslav Bendík Gentoo  Administrátor

        Podvozok pravidelne aktualizujem (v intervale vydávania LTS verzíí) kvôli bezpečnosti. V poslednej verzii bol vyhodený starý cachovací backend, takže som to nejak musel opraviť.