kategorie:

menu:


Systém pro push distribuci malých balíků dat mnoha uživatelům

03.04.2013   ::    topic: Programování

Jak co nejrychleji rozeslat jeden balík velkému počtu uživatelů na internetu? Tento zápisek se pokusí shrnout ty najzajmavější problémy které jsme museli řešit při <strike>vývoji</stri­ke> optimalizaci aplikace pro proudové aktualizace Avast! antiviru.

Proudové aktualizace Avast! antiviru.

Pokud je nalezena nová hrozba v podobě počítačového viru, červa, trojského koně či jiné nebezpečné havěti, je zapotřebí aby se potřebné aktualizace virové databáze dostali k uživatelům co nejrychleji. Nový vir umístěný na kompromitovaný web s vysokou návštěvností může každou minutu napadnout tisíce počítačů. Klasické aktualizace kdy se antivir jednou za čas dotazuje serveru vytvářejí zbytečnou prodlevu kdy je počítač proti nové hrozbě bezbranný. Proto je Avast! antivirus neustále připojen na server který mu pošle aktualizaci v okamžiku jejího vydání. Celosvětová komunita uživatelů Avast! antiviru ale čítá zhruba 160 milionů uživatelů z nichž bývají v jeden okamžik online až 32 miliony. Tento počet přináší na servery streamující aktualizace značné nároky.

Koncept push aktualizací je v Avastu již něco přes rok, na serverové straně ale nebyl ideální. Dostal jsem za úkol části stávající aplikace přepsat a pokusit se odstranit neduhy kterými systém trpěl. Výsledek je o poznání stabilnější, rychlejší a lépe škáluje.

Základní informace:
  • Servery mají instalováno 48 GiB RAM, 6 procesorových hyper-thread jader, běží na Linuxu CentOS.
  • Serverová aplikace je napsána v Javě, síťová vrstva je založna na frameworku JBoss Netty.

Optimalizace

Starší verze aplikace přistupovala z mnoha vláken k některým neefektivně synchronizovaným sdíleným kolekcím, které byly úzkým hrdlem při rozesílání updatů. Takové kolekce lze většinou jednoduše najít a optimalizovat pro daný druh přístupu. Například pro paralelní iterování velkých kolekcí jsem vytvořil BlockParallelI­terator.

V některých místech byla vlákna uspávána na empiricky zjištěnou dobu či byla jinak omezována aby data k odeslání nebyla produkována rychleji než jsou skutečně odesílána. Těchto omezení jsem se chtěl zbavit nebo regulaci založit na skutečném stavu serveru.

Java heap

Komunikační protokol je klasický HTTP (funguje i pro většinu uživatelů za proxy) s dlouhou prodlevou odpovědi (long-pool technika). Data jsou „zabalena“ do binárního formátu Google Protobuffer. Každá odeslaná zpráva se skládá ze dvou částí – hlavičky a „příkazu“. Krom speciálních případů jsou jako příkaz odesílány podepsané balíky s aktualizacemi.

Průměrná velikost jedné aktualizace je 8 KiB a frekvence vydávání bývá 7 za hodinu. Při 32 M klientů ve špičkách se jedná o datový tok 480 MiB/s na všechna datacentra ((8 Ki * 32 M * 7) / 3600) / 1 Mi = 480 MiB/s. Pro každý z dvacítky serverů připadá zhruba 24 MiB/s, což v dnešní době není nijak významný tok.

Problém ale nastane když se jeden nebohý server rozhodne co nejrychleji rozeslat tento balík dat svým až 2 M klientů (1,6 M průměrně). Pro každého klienta je potřeba alokovat buffer obsahující http hlavičku, informace o příkazu a data samotného příkazu. I když aplikace má k dispozici 20 GiB heap, při větších update balících není z čeho alokovat. I kdyby byl celý heap použit na buffery k odesílání, kritická velikost balíku je 10 KiB (20 GiB / 2 M = 10 KiB).

Protokol jsem měnit nemohl, naštěstí šlo ale lehce oddělit malou hlavičku která je pro každého klienta jiná od vlastního balíku s aktualizacemi který lze sdílet pro všechny klienty. Odpověď jsem namísto jediné http odpovědi rozdělil do http chunků. Sdílený http chunk musí být alokovaný jako přímo mapovaný (direct memory) ByteBuffer. Pokud by byl použit Netty ChannelBuffer alokovaný na heapu, jeho obsah by byl překopírován na nejnižší úrovni Netty kde probíhá předávání dat do TCP stacku pomocí „java.nio“, takže data by reálně sdílena nebyla a prostorem v heapu by bylo plýtváno stejně. Za tímto účelem sdílení http chunků jsem vytvořil HttpChunkCache.

Kernel TCP memory

V okamžiku kdy jsem vyřešil problémy s heapem, odstranil umělé zabržďování vláken a optimalizoval některé části aplikace, objevil se jiný problém. V okamžiku vydání nového updatu přestal server na několik minut odpovídat na všech portech. Poté co se situace uklidnila jsme v logu jádra našli následující zprávu: Out of socket memory.

Příčina byla zjevná. Aplikace produkuje data rychleji než je kernel stíhá odesílat. Druhou možnou příčinou této zprávy je mnoho osiřelých (orphan) socketů, to ale nebyl náš případ. Ve všech diskuzních fórech a blozích je jednoduchá rada – zvětšit paměť pro TCP stack. My ale už měli nastaveno 6 GiB a moc výše jsme nemohli. Do Netty pipeline jsem tedy přidal regulační vrstvu která na základě využití TCP paměti může pozdržet odchozí data. Nastavené limity jednoduše čte ze souboru /proc/sys/net­/ipv4/tcp_mem a aktuální stav z /proc/net/soc­kstat. Pak už bylo jen otázkou experimentů nastavit správné limity a účinný „brzdící“ algoritmus.

Výsledek

Optimalizací bylo samozřejmě více, toto ale byly základní myšlenky které nám umožnili odstranit limit na maximální velikost updatu, snížit paměťovou náročnost a zkrátit dobu distribuce jednotlivých updatů pod pět minut (průměrně minuta pro 8 KiB velký update).

Několik drobných problémů zůstává stále otevřeno. Především se zdá že aplikace leakuje. Využití heapu je po několika týdnech o poznání větší. Zatím se žádné problémy nestihly objevit, aktualizace spojená s restartem vždy přišla dříve. Najít leakující místo ale není vůbec jednoduché, zvláště když velikost heapu v produkčním prostředí je 20 GiB.