Deploy PHP aplikací, bez výpadku a pod (velkou) zátěží

V následujících pár odstavcích bych se s vámi rád podělil o zkušenosti, které jsme nasbírali při implementaci „neprůstřelného“ deploye naší PHP aplikace na jednom z nejnavštěvovanějších českých serverů. Pokud provozujete malý webík, který obslouží jednotky requestů za sekundu, nedozvíte se pravděpodobně nic potřebného. Jestli však provozujete větší službu s cca 100 requesty za vteřinu (ve špičce) a ještě aplikace běží na více webových serverech, tak jako ta naše, nasazujete často a rádi a naopak neradi přicházíte o requesty, tak jako my, pak věřím, že by pro vás následující text mohl být zajímavý.

Nejprve trochu uvedu do děje. Naše aplikace je napsaná v Nette, běží na více webových serverech, na každém serveru je k dispozici user cache a samozřejmě opcode cache (pro oboje používáme XCache). Plus aplikace využívá sdílenou cache pro všechny servery (memcache a Redis). Jak jsem psal v úvodu, tak novou verzi aplikaci nasazujeme kdykoli je třeba. Nečekáme na slabší návštěvnost a nasazujeme vždy za provozu. Až do nedávna nám pokaždé při nasazení aplikace skončilo několik requestů chybou. Při největší návštěvnosti jich bylo v řádu jednotek, ale i tak jsme chtěli daný stav vyřešit a nepřijít ani o jeden request.

V minulosti jsme aplikaci nasazovali způsobem, který většina z vás asi zná a používá. Root webu, nastavený ve virtual hostu Apache, je symbolický odkaz, který vede vždy do adresáře s aktuálními zdrojáky k aplikaci. Samotné nasazení nové verze pak vypadá tak, že se soubory nahrají do nového adresáře, symlink se změní, aby odkazoval do tohoto adresáře, promaže se nějaká ta cache a je nasazeno. Pokud máte více serverů, uděláte buď stejný postup na každém z nich nebo je sesynchronizujete nějakým jiným způsobem (u nás to probíhá pomocí rsync). Podobným způsobem to na našich serverech fungovalo do té doby, než jsme upgradovali operační systém Debian 6 „Squeeze“ na Debian 7 „Wheezy“ kvůli přechodu na PHP 5.4. Od té doby (pouze při velké zátěži, za malého provozu fungovalo vše dobře) přestal Apache nějak zvládat změnu symlinku a klidně po dobu až několika minut vracel prázdné odpovědi. Nejdříve jsme z tohoto chování podezírali XCache, kterou jsme začali používat až po upgradu PHP na 5.4, ale stejně se vše chovalo i s jinou opcode cachí a dokonce i bez ní. Podařilo se nám vygooglit, že několik uživatelů má stejné problémy jako my, ale nenašli jsme žádný recept co s tím a dodnes jsme nepřišli na to, kde byl problém. Nakonec jsme tuto situaci vyřešili tak, že místo změny symlinku, jsme výměnu root adresáře provedli pomocí přejmenování adresářů (nová verze aplikace se nahrála do adresáře www_new, aktuální www se přejmenoval na www_old a www_new na www – toto bohužel narozdíl od změny symlinku nelze udělat atomicky, takže vždy pár requestů skončí chybou), což kupodivu Apache zkousnul. Takto jsme fungovali asi rok, než jsme si našli čas, zkusit tuto ne zrovna ideální situaci nějak vyřešit.

Naše první nápady se upínali k možnosti restartovat Apache takovým způsobem, kdy se staré requesty nechají dokončit a nové se zavolají s případnou změněnou konfigurací (graceful restart v Debianu známější jako reload). Pro nás by to znamenalo změnit document_root na adresář s novou verzí zdrojových kódů aplikace. Vše fungovalo hezky, pokud se greaceful restart prováděl mimo špičku. Ve špičce se Apache nenechal zahanbit a pro jistotu po restartu na pár minut umřel. Tohle byl pro Apache na našich webových serverech poslední hřebíček do rakve. Už dlouho jsme pro statická data na jiném serveru používali k plné spokojenosti nginx a rozhodli jsme se jím nahradit Apache všude.

S ngixem už začalo pracovat nahrazení symlinku podle očekávání, nastal však jiný problém. Nedokážu si vysvětlit, proč se stejné chování neprojevilo, když jsme používali PHP jako modul s Apachem, ale jde o to, že PHP si interně cachuje realpath pro všechny soubory, které používá (cache se používá i pro magické konstanty __DIR__ a __FILE__) a tuto cache si drží defaultně 2 minuty. Pokud použijete PHP s ngixem (přes fastcgi, php-fpm) a s rootem webu jako symlink, tak se toto chování hned projeví a to tak, že po nahrazení symlinku s rootem webu, dostáváte ještě po dvě minuty (maximálně) starou verzi aplikace. Tato cache se sice dá v php.ini vypnout, ale kvůli údajnému zhoršení výkonu se to nedoporučuje. Bohužel tato cache nelze globálně vymazat, lze deaktivovat pouze pro jeden request (další request už zase cache použije). Všechny tutoriály pro zprovoznění ngixu s PHP přes fastcgi co jsem našel, předávají cestu k PHP souboru, který se má zpracovat s nepřeloženými symbolickými odkazy. Může za to tento řádek, který velice pravděpodobně máte i ve své konfiguraci:

fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;

Dejme tomu, že máme root webu jako symbolický odkaz /var/www/app/www, který ukazuje do adresáře s aktuálními zdrojovými kódy aplikace /var/www/app/releases/v1 a server přijme request na soubor index.php. Nginx zavolá PHP a předá mu cestu k php souboru skrze parametr SCRIPT_FILENAME jako /var/www/app/www/index.php. PHP si při zpracování zjistí, že adresář /var/www/app/www je symbolický odkaz a uloží si do cache přeloženou cestu k souboru index.php jako /var/www/app/releases/v1/index.php, tento soubor také zpracuje a vrátí výsledek. Teď nahrajeme na server novou verzi aplikace do adresáře /var/www/app/releases/v2 a změníme symlink /var/www/app/www, aby ukazoval do tohoto adresáře. Server přijme nový požadavek opět na soubor index.php, opět zavolá PHP a opět mu předá parametr SCRIPT_FILENAME jako /var/www/app/www/index.php. PHP přijme požadavek a podívá se, jestli nemá tuto cestu v cachi. A ouha, má a cache říká, že soubor /var/www/app/www/index.php je ve zkutečnosti odkaz na soubor /var/www/app/releases/v1/index.php. No a tak zpracuje a vrátí výstup ze souboru starší verze aplikace.

Abychom tomuto chování co nejelegantněji předešli, chtělo by to umět PHP předávat soubor ke zpracování už s přeloženou cestou a ne jako symlink. A nginx toto naštěstí umožňuje. Tato funkčnost není nikde moc prezentována (přes modul mod_realdoc to umí i Apache, kde by to podle mě mohlo být potřeba, pokud budete PHP provozovat jako fastcgi) a špatně se nám hledala, ale nakonec se podařilo. Celé je to docela jednoduché a staší nahradit proměnnou $document_root za $realpath_root. Takže celý řádek konfiguratce bude vypadat následovně:

fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;

Pro jistotu přidáme ještě řádek:

fastcgi_param DOCUMENT_ROOT $realpath_root;

To pokud by někdo v kódu používal $_SERVER['DOCUMENT_ROOT'] a nezapomeneme tyto řádky zakomentovat v konfiguračním souboru nginx fastcgi_params nebo je uvedeme až po řádku include fastcgi_params.

Nyní už máme připravený webový server a poslední věc, na kterou si musíme dát pozor je, že pokud v linuxu nahrazujeme symbolický odkaz (často příkazem ln -snf new current), není tato operace atomická a symbolický odkaz na nějakou tu mili/mikro sekundu nebude existovat. Pro atomické nahrazení se musí použít malý trik. Vytvoří se dočasný nový symbolický odkaz a tento nový symbolický odkaz se přesune na původní, který chceme nahradit. Celá operace pak vypadá následovně:

ln -s new current_tmp && mv -Tf current_tmp current

Tak a to je celé to kouzlo, díky kterému jsme schopni nasazovat nové verze aplikace i při největší zátěži a nepřijít přitom ani o jeden request.

Shrnutí a doplnění

  • nebojte se nahradit Apache za nginx! Pokud máte společný server pro PHP a statické soubory, určitě pocítíte i výkonové zlepšení. U nás máme statické soubory na jiném serveru a tak ve výkonu moc velký rozdíl nepozorujeme, ale i tak máme s nginx samé pozitivní zkušenosti. Hodně lidí se bojí, že přijde o .htaccess soubory, ale ruku na srdce, jak často je upravujete? Nginx sice nic podobného nepodporuje (prý je to žrout výkonu, kdyby někdo věděl o nějakém konkrétním měření, rádi se na něj podíváme :-)), ale všechno jde napsat přímo do konfigurace
  • nastavte nginx tak, aby rozpoznával symbolické odkazy (někde na fóru jsme se dočetli, že by volání do systému na rozpoznání symbolického odkazu mělo sebrat nějaký ten výkon, ale na našich serverech jsme nic podobného nenaměřili). Nastavení fastcgi může vypadat nějak následovně:
    location ~ \.php$ {
        try_files     $uri =404;
    
        fastcgi_pass         unix:/var/run/php5-fpm.sock;
    
        include  fastcgi_params;
    
        fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
        fastcgi_param DOCUMENT_ROOT $realpath_root;
    }
    
  • změnu symbolického odkazu na root webu provádějte atomicky, přes vytvoření dočasného symbolického odkazu na adresář s novou verzi aplikace a přesunutím tohoto nového symlinku přes původní:
    ln -s app_new_version www_tmp && mv -Tf www_tmp www
    

    – pokud máte více webových serverů, musíte tento příkaz zavolat na každém z nich! U nás například používáme pro synchronizaci souborů rsync, ale kdybychom nechali synchronizovat i symbolický odkaz na root adresář, nenahradil by se atomicky!

  • aby vše fungovalo, je potřeba, aby každá verze aplikace měla vlastní temp adresář, kam se vám budou ukládat jednotlivé cache pro konrétní verzi aplikace (šablony, robot loader, …)
    – pokud používáte na serveru user cahe, kde jsou data závislá na verzi aplikace (může se jejich struktura měnit s novou verzí aplikace), tak je vhodné klíče v cachi prefixovat verzí aplikace nebo použít namespace, pokud je to možné – takto by aplikace nemusela dobře fungovat v době mezi změnou symlinky a promazáním user cache!
    – databázové změny pro nové verze se musí dělat zpětně kompatibilní se starší verzí (aby na databázi mohla v pořádku fungovat stará i nová verze aplikace)
    – celé to má i tu výhodu, že pokud by nová verze z nějakého důvodu nefungovala dobře, lze se jednoduše vrátit k původní prostou změnou symlinku rootu webu na adresář se starší verzí aplikace
  • možné problémy při takovémto kontinuálním způsobu nasazování mohou nastat se sdílenou cachí. Tam se často uchovávají objekty, které nechceme přegenerovat s každou novou verzí aplikace a pokud je pro správnou funkčnost aplikace potřeba cache promazat (pokud se třeba mění její struktura), nelze to udělat atomicky s nasazením aplikace. A právě v čase mezi změnou symlinku a promazáním cache nemusí aplikace fungovat dobře.

4 komentáře u „Deploy PHP aplikací, bez výpadku a pod (velkou) zátěží

  1. .htaccess je pomalejší už z logiky věci, že při každém requestu se musí zkontrolovat všechny adresáře, jestli v nich náhodou není .htaccess.

    Druhou možností místo $realpath_root je změnit v rámci deploye konfiguraci ngixnu a provést reload. Nginx staré requesty v klidu dokončí s původní konfirgurací a nové už spustí z nové verze. Tahle verze by asi měla být trochu výkonější, protože se s každým requestem nemusí vyhodnocovat realpath. Přesto jsme také použili $realpath_root 🙂

    A jinak je ještě dobré zvyšit ttl a velikost realpath cache v PHP. V tomto způsobu deploye není důvod ji držet na původních nízkých hodnotách.

  2. To jsem zapomněl zmínit, že s nginx (narozdíl od Apache) fungoval graceful (reload) restart pod zátěží úplně v pohodě. Servery si, ale nespravujeme sami a tak každé řešení, kdy se šahá co nejméně do konfigurace je pro nás lepší 🙂

    Co se týče .htaccess souborů, pokud budu brát, že použiju Apache pouze na PHP, tak tam budu mít nějaký rewrite na front controller, který bude přímo v rootu, takže Apachi by podle mě mělo stačit podívat se pouze do tohoto adresáře, jestli v něm neni nějaký .htaccess soubor a ještě bych doufal, že ho bude mít nacachovaný a pokud se nezmění timestamp, tak ho nebude ani znova parsovat. Jiné to samozřejmě bude, při načítání nějakého statického souboru a budu chtít soubor zalezlý v 10 podadresářích, tam už si dovedu představit nějaký ten výkonový dopad… Ale do Apache moc nevidim, tak bych se nerad pouštěl do nějakých hlubších diskuzí 🙂

  3. Děkuji za informaci, jedeme na Debianu, takže dokud se tato verze jádra neobjeví v něm, stejně to nemůžeme použít 🙁

Napsat komentář

Vaše emailová adresa nebude zveřejněna.