Hlavní navigace

Jak se staví CDN: konfigurace serverů a reverzní proxy

3. 12. 2020
Doba čtení: 27 minut

Sdílet

V minulém článku jsme popsali jaké komponenty ke stavbě CDN potřebujete, a dnes se zaměříme na SW konfigurace serverů a samotné reverzní proxy, která bude obsah kešovat, aby byla data vždy co nejblíž koncovým návštěvníkům.

Primárním cílem tohoto článku není dát vám konkrétní hodnoty jednotlivých nastavení (i když něco doporučíme), ale říct si, na co všechno se zaměřit a na co si dát pozor. Konkrétní hodnoty totiž i my sami v čase ladíme a optimalizujeme podle provozu a nasbíraných indicií z monitoringu. Je proto zásadní pochopit jednotlivá nastavení a nastavit je s ohledem na váš HW a očekávaný provoz.

Operační systém

U nás v SiteOne máme drtivou většinu serverů běžících na Linuxu – konkrétně na distribucích Gentoo a Debian. V případě CDN nám ale všechny servery běží na Debian 10 (buster), proto i případné detailní tipy obsahují cesty/nastavení v Debianu.

V oblasti OS a kernelu se doporučujeme zaměřit na následující parametry, kterými zásadně ovlivníte, jak objemný provoz každý ze serverů dokáže odbavit, aniž by odmítal TCP spojení či narážel na jiné limity:

  • Konfigurace /etc/security/limits.conf  – nastavte výrazně vyšší soft a hard limity zejména u nproc a nofile pro proces nginx (desítky až stovky tisíc).
  • Kernel nastavujte ideálně přes sysctl.conf a zaměřte se hlavně na parametry, které vidíte i v doporučené konfiguraci níže. Každý parametr je dobré si nastudovat, pochopit, jak ovlivňuje váš provoz, a nastavit ho podle toho.
  • Pokud máte kernel 4.9+ můžete aktivovat algoritmus TCP BBR, abyste snížili RTT a zvýšili rychlost doručení obsahu. Parametry: net.ipv4.tcp_congestion_control=bbr, net.core.default_qdisc=fq (více info v článku na Cloudflare).
  • Zkontrolujte příkazem netstat -i hodnotu RX-DRP, a pokud je už hodnota po pár dnech v milionech a neustále roste, zvyšte RX/TX buffery na síťovce. Aktuální nastavení i max. hodnoty zjistíte příkazem ethtool -g VÁŠ-IFACE a novou hodnotu nastavíte pomocí ethtool -G, takže např. ethtool -G ens192 rx 2048 tx 2048. Aby nastavení přežilo i reboot, volejte příkaz i v post-up skriptech v /etc/network/interfaces, či /etc/rc.local. Pokud upravujete síťové rozhraní, kterým jste na server připojení, tak pozor, protože při změně dojde k restartu rozhraní.
  • Txqueuelen na síťovkách doporučujeme zvednout z výchozích 1 000, podle vaší konektivity a síťové karty.
  • IO scheduler na jednotlivých discích/polích nastavte podle toho, jakou storage používáte – /sys/block/*/queue/scheduler. Pokud používáte SSD či NVME, doporučujeme hodnotu none.
  • Iptables či router – doporučujeme nastavit nějaké hard limity na počet současných spojení z jedné IP adresy a počet spojení za určitý čas. V případě DoS útoku můžete velkou část provozu odfiltrovat efektivně již na síťové úrovni. Limity ale nastavujte i s ohledem na možné návštěvníky za NAT-em (více legitimních návštěvníků za jednou IP adresou je typická situace např. u mobilních operátorů či menších lokálních ISP).

Při nastavování jednotlivých parametrů zvažujte to, jak vypadá typický provoz návštěvníka, který obsah z CDN načítá. Zásadní je HTTP/2, díky kterému již obvykle jeden návštěvník naváže pouze jedno TCP spojení pro stažení veškerého obsahu na stránce. Můžete si dovolit kratší timeouty TCP spojení, keepalive, menší buffery. Hodně vám za reálného provozu napoví sbírané metriky, např.: počty TCP spojení v jednotlivých stavech. Pokud chcete odbavovat desítky tisíc návštěvníků v řádu vteřin či minut, zapomeňte na výchozí hodnoty různých timeoutů v minutách a testujte hodnoty v řádu jednotek až desítek vteřin.

Doporučená konfigurace kernelu

Hodnoty jednotlivých nastavení berte pouze jako naše doporučení, které se nám osvědčilo pro server se 4–8 GB RAM, 4–8 vCPU a síťovými kartami Intel X540-AT2 či Intel I350. Některé direktivy mají i o řád vyšší či nižší hodnoty, než je výchozí stav v distribucích. Obvykle jde o úpravy, které mají za cíl zvýšit schopnost zvládat efektivně silný provoz a minimalizovat dopady DoS či DDoS útoku. Důležité je také zmínit, že konfigurace je pro server s vypnutou podporou IPv6.

fs.aio-max-nr = 524288
fs.file-max = 611160
kernel.msgmax = 131072
kernel.msgmnb = 131072
kernel.panic = 15
kernel.pid_max = 65536
kernel.printk = 4 4 1 7
kernel.sysrq = 0
net.core.default_qdisc = fq
net.core.netdev_max_backlog = 262144
net.core.optmem_max = 16777216
net.core.rmem_max = 16777216
net.core.somaxconn = 65535
net.core.wmem_max = 16777216
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.all.log_martians = 1
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.all.secure_redirects = 0
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv4.conf.default.accept_source_route = 0
net.ipv4.conf.default.rp_filter = 1
net.ipv4.conf.default.secure_redirects = 0
net.ipv4.conf.default.send_redirects = 0
net.ipv4.ip_forward = 0
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_congestion_control = bbr
net.ipv4.tcp_fin_timeout = 10
net.ipv4.tcp_keepalive_intvl = 10
net.ipv4.tcp_keepalive_probes = 5
net.ipv4.tcp_keepalive_time = 60
net.ipv4.tcp_low_latency = 1
net.ipv4.tcp_max_orphans = 10000
net.ipv4.tcp_max_syn_backlog = 65000
net.ipv4.tcp_max_tw_buckets = 1440000
net.ipv4.tcp_moderate_rcvbuf = 1
net.ipv4.tcp_no_metrics_save = 1
net.ipv4.tcp_notsent_lowat = 16384
net.ipv4.tcp_rfc1337 = 1
net.ipv4.tcp_rmem = 4096 87380 16777216
net.ipv4.tcp_sack = 0
net.ipv4.tcp_slow_start_after_idle = 0
net.ipv4.tcp_synack_retries = 2
net.ipv4.tcp_syncookies = 1
net.ipv4.tcp_syn_retries = 2
net.ipv4.tcp_timestamps = 0
net.ipv4.tcp_tw_reuse = 1
net.ipv4.tcp_window_scaling = 0
net.ipv4.tcp_wmem = 4096 65536 16777216
net.ipv6.conf.all.disable_ipv6 = 1
net.ipv6.conf.default.disable_ipv6 = 1
net.ipv6.conf.lo.disable_ipv6 = 1
vm.dirty_background_ratio = 2
vm.dirty_ratio = 60
vm.max_map_count = 262144
vm.overcommit_memory = 1
vm.swappiness = 1

Reverzní proxy a cache

Na všech PoP serverech potřebujete kritickou komponentu CDN – reverzní proxy s robustní cache podporou. Nejoblíbenější jsou Varnish, Squid, Nginx, Traefik, H2O a s omezenou funkčností např. i HAProxy. Za zvážení stojí i Tengine, postavený na Nginxu a přidávající hodně zajímavých funkcionalit.

V kontextu CDN je funkčnost reverzní proxy poměrně jasná – podle URL a hlaviček požadavku najít obsah v cache a pokud v ní není, nebo již vypršela jeho platnost, tak ho stáhnout z Origin serveru a uložit do cache, aby se již požadavek dalšího návštěvníka odbavil rychleji, z cache na daném PoPu.

My jsme si nakonec vybrali webový server Nginx, protože ho již dlouhá léta úspěšně používáme na většině serverů. Veškeré konfigurace a různé varianty vhostů i optimální funkční, výkonnostní a bezpečnostní nastavení máme v Ansible. Co se týče konkrétní verze, doporučujeme poslední 1.19.x, kde už je i zdokonalená implementace HTTP/2, společně s OpenSSL 1.1.1 kvůli TLSv1.3.

Oproti našim běžným výchozím hodnotám určeným pro aplikační servery jsme u CDN, stejně jako u kernelu, výrazně snížily různé buffery, timeouty či limitní hodnoty. Naše CDN je optimalizovaná pro statický obsah a pro odbavování pouze GET/HEAD/OPTIONS požadavků. Když už nemusíme podporovat POST ani uploady, tak jsme mohli výrazně zpřísnit parametry, jak na klientské straně, tak na straně backendové (požadavky na zdrojové origin servery).

Následující text předpokládá, že již máte s Nginxem alespoň základní zkušenosti – i proto zde nejsou konkrétní snippety konfigurace, ale spíše různá doporučení nad rámec základního použití, které obvykle v tutoriálech k použití Nginxu nenajdete a na provoz CDN mají významný vliv.

Cache je klíčovou funkcionalitou CDN, proto doporučujeme:

  • Nastudujte si příručku High-Performance Caching. Co se týče proxy cache, pečlivě si nastudujte a pochopte všechny proxy_cache*_ direktivy a jejich parametry. Začněte s proxy_cache_path a atributy levels, key_zone, _inactive_či max_size. U odlehlých sekundárních PoPů můžete mít inactive např. i na týdny či měsíce – cache manager udrží déle i obsah, na který se již déle nepřistoupilo a tím zvýšíte urychlující účinek CDN a cache hit-ratio i u PoPů, ze kterých se obsah konkrétních URL nestahuje tak často.
  • Nastavte optimálně direktivu proxy_cache_valid, kterou ovlivňujete to, jak dlouho se budou kešovat které HTTP kódy. Pokud se rozhodnete kešovat chybové kódy, např. 400 Bad Request, tak ty cachujte jenom po velmi krátkou dobu, abyste případně minimalizovali dopady případného „cache poisoningu“.
  • Pokud nechcete, aby se u nějakého originu bralo při ukládání cache v potaz jeho “řízení cache” skrz response hlavičky, můžete využít proxy_ignore_headers a ignorovat typicky hlavičky Cache-Control, _Expires_či Vary.
  • U cache věnujte pozornost i proxy_cache_use_stale, čím ovlivníte jak se cache bude chovat v případě, že je nedostupný origin. My jsme se rozhodli, že když náhodou origin nefunguje a cache již expirovala, stejně původní obsah návštěvníkovi vrátíme. Podpoří to vysokou dostupnost. Nastavte i updating, aby se po exspiraci načetl návštěvníkovi obsah okamžitě z cache (bez čekání na origin), ale na pozadí se hned pro budoucí návštěvníky obsah aktualizoval z originu. Eliminujete tím efekt občasného zpomalení, kdy jednou za čas nějaký návštěvník „odnese“ potřebu v CDN aktualizovat expirovaný obsah dané URL.
  • Rozhodněte se, co nastavíte do proxy_cache_key. Chcete např. do cache klíče zahrnout i případný query string, který se často používá pro “verzování” souborů a potlačení cache původní verze souboru?
  • Aktivujte proxy_cache_lock, aby se cache plnila/chovala optimálně i při vysoké paralelizaci a rozhodněte se, jak nastavíte proxy_cache_min_uses .

Dále se zaměřte i na následující tipy a nastavení, které ovlivňují výkon Nginxu:

  • Pokud to vaše platforma umožňuje, nastavte use epool. Pokud máte kernel 4.5+, bude se využívat EPOLLEXCLUSIVE.
  • listen direktivity hlavního vhosta svojí CDN (moje.cdn.cz) použijte reuseport, aby požadavky na jednotlivé Nginx workery rozkládal kernel, je to násobně efektivnější. U listen direktivy nastudujte i parametry backlog a fastopen. Můžete aktivovat i deferred, aby se požadavek do Nginxu dostal až v momentu skutečného přijetí prvních dat od klienta, co může lépe řešit některé typy DDoS útoků.
  • Aktivujte http2 u listen direktivy a udržujte si vždy bezpečnou sadu ssl_ciphers (s ohledem na verze prohlížečů, které chcete podporovat).
  • Pokud si to můžete s ohledem na podporované prohlížeče dovolit, podporujte pouze TLSv1.2 a TLSv1.3.
  • Procesor CDN serveru budou nejvíce vytěžovat gzip/brotli komprese a SSL/TLS komunikace. Nastavte ssl_session_cache, abyste minimalizovali SSL/TLS handshakes. Doporučujeme shared, aby se cache sdílela mezi všemi workery. Velikost cache např. 50 MB, čím se do cache vejde cca. 200 000 sessions. Abyste minimalizovali počet SSL/TLS handshakes, můžete zvýšit ssl_session_timeout. Pokud nechcete používat SSL cache na serveru, aktivujte ssl_session_tickets, aby byla session cache aktivní alespoň v prohlížeči.
  • U nastavení SSL aktivujte 0-RTT u TLSv1.3 (ssl_early_data on), které zásadně sníží latenci, ale pochopte a zvažte možnost Replay útoku.
  • Pokud chcete docílit minimálního TTBF (na úkor vyšší zátěže při přenosu velkých souborů), nastudujte a nastavte rozumně nízké ssl_buffer_size a http2_chunk_size. Případně si do Nginxu nasaďte patch od Cloudflare, který podporuje dynamické nastavení – stačí googlit direktivu ssl_dyn_rec_size_lo.
  • Zaměřte se i na pochopení a nastavení KeepAlive a to jak na straně klienta, tak v upstreamech – pomůže to zefektivnit komunikaci s origin servery. KeepAlive HTTP/2 se řídí direktivou http2_idle_timeout (default: 3min), koukněte i na http2_recv_timeout. Držet zbytečně dlouho otevřená spojení výrazně snižuje počet návštěvníků, které jste pak schopní obsloužit. Taky to má vliv na to, jak velký DDoS útok jste pak schopní ustát. Je dobré mít povědomí o tom, jak funguje connection-tracking (jak v Linuxu, tak případně i na routerech, když je server za NATem), jak to souvisí s nastavením limit_conn a jak se to jako celek chová v případě, že na servery přistupují statisíce klientů, nebo jste pod DDoS útokem na L7.
  • Pokud potřebujete detekovat změnu IP adresy originu a nemáte placený Nginx Plus s atributem resolve u upstream serveru, můžete místo definice upstreamu použít pouze proxy_pass https://www.mujorigin.cz;. V tomto režimu si proxy_pass hlídá TTL v DNS domény a případně IP adresu/adresy aktualizuje.
  • Nastudujte i direktivy lingering_close, lingering_time a lingering_timeout, které určují, jak rychle se mají uzavírat neaktivní spojení. Pro lepší rezistenci proti útokům má smysl výchozí časy snížit. U HTTP/2 spojení se ale lingering_* direktivy uplatňují až od verze Nginx 1.19.1.
  • Navyšte ULIMIT v /etc/default/nginx a taky nastavte vyšší LimitNOFILE v /etc/systemd/system/nginx­.service.d/nginx.conf.
  • Rychlému odbavování souborů a požadavků pomáhají i sendfile, tcp_nopush a tcp_nodelay. Aby klienti s rychlým připojením stahující velké soubory nevytěžili celý worker proces, nastavte rozumně i sendfile_max_chunk.
  • Pokud odbavujete i velmi velké soubory a pozorujete zpomalení ostatních požadavků, zvažte použití aio. Nezapomeňte pak nastavit vhodně direktivu directio, která definuje max. velikost souboru, který se ještě pošle skrz sendfile a větší již přes aio. Nám se jako optimální jeví hodnota 4MB, takže všechny JS/CSS/fonty i většina obrázků se odbavuje skrz sendfile a obvykle z FS cache, takže to nedělá ani žádné IO.
  • Zaměřte se na i direktivy kolem open_file_cache. S optimálním nastavením a dostatkem RAM budete mít téměř nulové IOPS, i když budete odbavovat stovky Mbps.
  • Abyste zvládali vysoký počet současných návštěvníků a chránili se před útoky, zásadně snižte client_max_body_size, client_header_timeout, client_body_timeout a send_timeout.
  • U nastavení access logů nastudujte parametry buffer a flush, abyste minimalizovali IOPS související se zápisem logů. Pozor však na to, že toto zároveň způsobí, že logy nebudou zapisované 100% chronologicky. Access logy ideálně ukládejte na jiný disk než cache data.
  • U upstreamů si můžete vyhrát s load balancingem (pokud se dá k originu přistupovat přes více IP adres) a atributy weightči backup. V aktuální verzi už je volně k dispozici i užitečný atribut max_conns, který byl dlouho pouze v placené verzi.
  • Pokud chcete mít i nějakou formu auto-retry logiky (pro případ krátkých nedostupností originu), tak to můžete vyřešit například pomocí více upstream-serverů na stejný origin, ale mezi ně strčit vhosta s krátkým Lua kódem, který zajistí sleep mezi retry požadavky.
  • Použijte vlastní nastavení resolver a zvažte jako primární resolver použít lokální dnsmasq.
  • Nastudujte si, jak v Nginxu funguje Cache Manager, který začne úřadovat hlavně v momentě, kdy se cache zaplní.
  • Nelze zde zmínit vše, ale na chování proxy a cache mají vliv i další atributy, které doporučujeme nastudovat a nastavit i: proxy_buffering, proxy_buffer_size, proxy_buffers, proxy_read_timeout, output_buffers, reset_timedout_connection.
  • Když s Nginxem budete používat i dynamické moduly (v našem případě pro brotli kompresi a WAF), s každým upgradem Nginxu musíte překompilovat i všechny moduly vůči nové verzi Nginxu. Pokud to neuděláte, Nginx vám po upgrade nenastartuje kvůli konfliktů tzv. signatures u *.so modulů. Celý proces upgradu Nginxu je proto lepší si zautomatizovat, protože při např. apt upgrade skončíte s nefunkčním Nginxem. Součásti této automatizace by mělo být využití možnosti provést Nginx upgrade on-the-fly, kdy Nginx běží nadále starou instanci (z paměti) a zároveň spustí (nebo to alespoň zkusí) novou instanci z aktuální binárky a modulů. Tím zajistíte, že se vám při upgrade neztratí ani jeden požadavek, i když vám nový Nginx po upgrade z nějakého důvodu nenaběhne. Celý tento proces je u většiny distribucí v init skriptech pod akcí upgrade, tzn. např. service nginx upgrade. Abyste zabránili nechtěnému upgrade Nginxu při globálním upgradu balíčků, používejte  apt-mark hold/unhold nginx.

V závislosti na tom, jaký obsah a chování originů chcete podporovat, budete potřebovat nastudovat a případně i ladit chování CDN cache s ohledem na hlavičku Cache-Control nebo třeba dost zásadně i hlavičku Vary. Kupříkladu pokud origin řekne v response Vary: User-Agent, součástí cache klíče by měl být i user-agent klienta, jinak se může velmi lehce stát, že někomu třeba na desktop vrátíte nakešované HTML pro mobilní verzi. To už ale záleží na tom, jaké scénáře a typy obsahu chcete/nechcete podporovat. Podchycování těchto scénářů často znamená nemalou práci a navíc snižování efektivity cache. Obvykle si pak už nevystačíte s nativními direktivami Nginxu a budete si muset některé scénáře ošetřit pomocí Lua skriptů.

Na závěr ještě zmíním, že v případě Nginxu máte k dispozici i placenou verzi Nginx Plus, která nabízí různé užitečné funkcionality, live dashboard a další moduly navíc. Důležitá je např. direktiva resolve u upstream serveru, která ve spojení s direktivou resolver umí detekovat změnu IP adresy originu. Cena za jednu instanci je ale v tisících dolarů ročně, takže by její použití dávalo smysl pouze u velkého komerčního řešení. Pokud nemáte tisíce dolarů a chtěli byste mít přesto realtime pohled na provoz v Nginxu, doporučujeme za 49$ koupit Luameter (demo). Funguje dobře, ale pokud budete odbavovat stovky požadavků za vteřinu a hodně unikátních URL, počítejte se zvýšenou zátěží a nároky na RAM. My ho máme v defaultu vypnutý a aktivujeme ho pouze při ladění.

Ukázková konfigurace Nginx

Níže jsme připravili ukázkovou průměrnou základní konfiguraci Nginxu, který v tomto modelovém příkladu nedělá reverzní proxy před celou doménou, ale poskytuje CDN endpoint https://moje.cdn.cz/mujorigin.cz/*.(css|js|jpg|jpeg|png|gif|ico), který obsah načítá z originu https://www.mujorigin.cz/*. Průměrnou proto, že my některé direktivy dále měníme vzhledem k HW jednotlivých PoP serverů a zároveň v ní nejsou některé další bezpečnostní mechanismy, které nechceme odkrývat. Na serverech je tato konfigurace samozřejmě rozdělena do samostatných konfiguračních souborů, které v našem případě generujeme přes Ansible.

Obzvláště různé nastavení je pak na úrovni definice pro jednotlivé locations/originy, protože u nich můžete chtít jinak složené cache-klíče, platnosti cache, limity, ignorovat cookies, mít/nemít podporu WebP či AVIF, validaci refereru, aktivní CORS-related nastavení, nebo třeba použít slice modul, kdy pak zase musíte kešovat i kód 206 a cache klíč musí obsahovat i $slice_range. Stejně tak pro některé originy můžete chtít zcela ignorovat Cache-Control hlavičky a všechno kešovat na pevnou dobu, či jiné per-origin speciality.

Konfigurace zároveň obsahuje různé per-origin adresáře či soubory – ty vám samozřejmě musí zakládat vaše automatizace, kterou nový origin do své CDN zavádíte. Berte to tedy opravdu pouze jako vodítko, jak jednotlivé funkcionality uchopit a nastavit.

worker_processes 4;
worker_rlimit_nofile 100000;
pcre_jit on;

events {
  use epoll;
  worker_connections 16000;
  multi_accept on;
}

http {

  # IP whitelist, na ktery se nemaji aplikovat zadne conn/rate omezeni
  geo $ip_whitelist {
    default        0;
    127.0.0.1      1;
    10.225.1.0/24  1;
  }
  map $ip_whitelist $limited_ip {
    0  $binary_remote_addr;
    1  "";
  }

  limit_conn_zone $limited_ip zone=connsPerIP:20m;
  limit_conn connsPerIP 30;
  limit_conn_status 429;

  limit_req_zone $limited_ip zone=reqsPerMinutePerIP:50m rate=500r/m;
  limit_req zone=reqsPerMinutePerIP burst=700 nodelay;
  limit_req_status 429;

  client_max_body_size 64k;
  client_header_timeout 10s;
  client_body_timeout 10s;
  client_body_buffer_size 16k;
  client_header_buffer_size 4k;

  send_timeout 10s;
  connection_pool_size 512;
  large_client_header_buffers 8 16k;
  request_pool_size 4k;

  http2_idle_timeout 60s;
  http2_recv_timeout 10s;
  http2_chunk_size 16k;

  server_tokens off;
  more_set_headers "Server: Moje-CDN";

  include /etc/nginx/mime.types;
  variables_hash_bucket_size 128;
  map_hash_bucket_size 256;

  gzip on;
  gzip_static on; # hleda soubor *.gz a vrati ho rovnou z disku (kompresi zajistuje nas extra proces na pozadi)
  gzip_disable "msie6";
  gzip_min_length 4096;
  gzip_buffers 16 64k;
  gzip_vary on;
  gzip_proxied any;
  gzip_types image/svg+xml text/plain text/css application/json application/x-javascript application/javascript text/xml application/xml application/xml+rss text/javascript text/x-component font/truetype font/opentype image/x-icon;
  gzip_comp_level 4; # neni moc rozhodujici, protoze pro 99% statickych textovych souboru vytvarime staticke *.gz

  brotli on;
  brotli_static on; # hleda soubor *.br a vrati ho rovnou z disku (kompresi zajistuje nas extra proces na pozadi)
  brotli_types text/plain text/css application/javascript application/json image/svg+xml application/xml+rss;
  brotli_comp_level 6; # neni moc rozhodujici, protoze pro 99% statickych textovych souboru vytvarime staticke *.br

  output_buffers 1 32k;
  postpone_output 1460;

  sendfile on;
  sendfile_max_chunk 1m;
  tcp_nopush on;
  tcp_nodelay on;

  keepalive_timeout 10 10;
  ignore_invalid_headers on;
  reset_timedout_connection on;

  open_file_cache          max=50000 inactive=30s;
  open_file_cache_valid    10s;
  open_file_cache_min_uses 2;
  open_file_cache_errors   on;

  proxy_buffering           on;
  proxy_buffer_size         16k;
  proxy_buffers             64 16k;
  proxy_temp_path           /var/lib/nginx/proxy;
  proxy_cache_min_uses      2;

  proxy_ignore_client_abort on;
  proxy_intercept_errors    on;
  proxy_next_upstream       error timeout invalid_header http_500 http_502 http_503 http_504;
  proxy_redirect            off;
  proxy_connect_timeout     60;
  proxy_send_timeout        180;
  proxy_cache_lock          on;
  proxy_read_timeout        10s;

  # nastaveni duveryhodnych IP subnetu, ze kterych se ma respektovat X-Forwarded-For
  set_real_ip_from          127.0.0.1/32;
  set_real_ip_from          10.1.2.0/24;
  real_ip_header            X-Forwarded-For;
  real_ip_recursive         on;

  ######################################################################
  ## Ukazkova konfigurace pro:                                        ##
  ## https://moje.cdn.cz/mujorigin.cz/* -> https://www.mujorigin.cz/* ##
  ######################################################################

  upstream up_www_mujorigin_cz {
    server www.mujorigin.cz:443 max_conns=50;

    keepalive 20;
    keepalive_requests 50;
    keepalive_timeout 5s;
  }

  proxy_cache_path /var/lib/nginx/tmp/proxy/www.mujorigin.cz levels=1:2 keys_zone=cache_www_mujorigin_cz:20m inactive=720h max_size=10g;

  server {

    server_name moje.cdn.cz;

    listen lan-ip:443 ssl default_server http2 reuseport deferred backlog=32768;
    ssl_prefer_server_ciphers on;
    ssl_ciphers EECDH+AESGCM:EDH+AESGCM;
    ssl_certificate /etc/nginx/ssl/moje.cdn.cz.nginx-bundle.crt;
    ssl_certificate_key /etc/nginx/ssl/moje.cdn.cz.key;
    ssl_session_cache shared:SSL_moje_cdn_cz:50m;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_dhparam /etc/ssl/webserver_dhparams.pem;
    ssl_early_data on;

    lingering_close on;
    lingering_time 10s;
    lingering_timeout 5s;

    resolver 127.0.0.1; # dnsmasq s logovanim, abychom meli predstavu o DNS provozu, ktery Nginx dela

    ...


    location ~* ^/mujorigin\.cz/(.+\.(css|js|jpg|jpeg|png|gif|ico))$ {
      set $origin_uri "/$1$is_args$args";
      root /var/www/mujorigin.cz;
      access_log  /var/log/nginx/www.mujorigin.cz/ssl.access.log main buffer=4k flush=5m;
      error_log   /var/log/nginx/www.mujorigin.cz/ssl.error.log notice;

      if ($request_method !~ ^(GET|HEAD|OPTIONS)$ ) {
        more_set_headers "Content-Type: application/json";
        return 405 '{"code": 405, "message": "Method Not Allowed"}';
      }

      more_clear_headers "Strict-Transport-Security";
      more_set_headers "Strict-Transport-Security: max-age=31536000";
      more_set_headers "X-Content-Type-Options: nosniff";
      more_set_headers 'Link: ; rel="canonical"';

      expires 1y; # vynuti cachovani v prohlizecich po dobu 1 roku (pouzivat pouze uvedomele, kdyz mate jistotu, ze pri zmene obsahu souboru na originu dojde i ke zmene URL)

      modsecurity on;
      modsecurity_rules_file /etc/nginx/modsecurity/mujorigin.cz.conf;

      # u pozadavku, ktere spadaji do CORS (napr. fonty) povolujeme nacitat obsah pouze z vybranych domen
      set $headerCorsAllowOrigin "";
      if ($http_origin ~ '^https?://(localhost|moje\.cdn\.cz|www\.mujorigin\.cz)') {
          set $headerCorsAllowOrigin "$http_origin";
      }
      if ($request_method = 'OPTIONS') {
          more_set_headers "Access-Control-Allow-Origin: $headerCorsAllowOrigin";
          more_set_headers "Access-Control-Allow-Methods: GET, HEAD, OPTIONS";
          more_set_headers "Access-Control-Max-Age: 3600";
          more_set_headers "Content-Length: 0";
          return 204;
      }

      # obsah umoznime nacitat pouze z domeny originu (zabrani napr. zobrazovani nasich obrazku na cizich domenach)
      valid_referers none blocked server_names *.mujorigin.cz;
      if ($invalid_referer) {
          more_set_headers "Content-Type: application/json";
          return 403 '{"code": 403, "message": "Forbidden Resource - invalid referer"}';
      }

      set $webp "";
      set $file_for_webp "";
      if ($http_accept ~* webp) {
          set $webp "A";
      }
      if ($request_filename ~ (.+\.(png|jpe?g))$) {
          set $file_for_webp $1;
      }
      if (-f $file_for_webp.webp) {
          set $webp "${webp}E";
      }
      if ($webp = AE) {
          rewrite ^/(.+)$ /webp/$1 last;
      }

      proxy_cache cache_www_mujorigin_cz;
      proxy_cache_key "$request_uri"; # schemu ani host nepotrebujeme, protoze ukladame do per-origin cache a podporujeme pouze HTTPS
      proxy_cache_use_stale error timeout invalid_header updating http_429 http_500 http_502 http_503 http_504;
      proxy_read_timeout 20s;
      proxy_cache_valid 200              720h;
      proxy_cache_valid 301              4h;
      proxy_cache_valid 302              1h;
      proxy_cache_valid 400 401 403 404  30s;
      proxy_cache_valid 500 501 502 503  30s;
      proxy_cache_valid 429              10s;


      # kvuli keep-alive na originy
      proxy_http_version 1.1;
      proxy_set_header Connection "";

      proxy_set_header "Via" "Moje-CDN";
      proxy_set_header "Early-Data" $ssl_early_data; # pro moznost v aplikaci detekovat Replay utok
      proxy_set_header Accept-Encoding ""; # z originu chceme prijimat a do cache ukladame vzdy obsah v RAW podobe, protoze mame proces pro pripravu statickych *.gz a *.br verzi

      proxy_set_header        Host                    www.mujorigin.cz;
      proxy_set_header        X-Forwarded-For         $remote_addr;
      proxy_set_header        X-Forwarded-Host        $host:$server_port;
      proxy_set_header        X-Forwarded-Server      $host;
      proxy_set_header        X-Forwarded-Proto       $scheme;

      if (-f $request_filename) {
          more_set_headers "X-Cache: HIT";
      }

      if (!-f $request_filename) {
          proxy_pass https://up_www_mujorigin_cz$origin_uri;
      }

    }

    # interni location pro webp
    location ~* ^/webp(/mujorigin\.cz/(.*))$ {
      internal;
      root /var/www/mujorigin.cz;
      set $origin_uri "/$2";
      access_log /var/log/nginx/www.mujorigin.cz/ssl.access.webp.log main buffer=4k flush=5m;
      expires 366d;
      more_set_headers 'Link: ; rel="canonical"';
      more_clear_headers 'Vary';
      more_set_headers "Vary: Accept";
      more_set_headers "X-Cache: HIT";
      try_files $1.webp $1 =404;
    }

  }

}

Statická komprese jako zásadní pomocník

Provedli jsme namátkový test dvou komerčních CDN, které mají servery v Praze a ani jeden poskytovatel tuto skvělou funkcionalitu/možnost evidentně nevyužívá. Komerční CDN musí provádět kompresi obsahu pomocí brotli či gzip při každém požadavku, což zásadně vytěžuje jejich CPU a několikanásobně zvyšuje i response time, na to ale doplácí návštěvník.

Otestovali jsme, jak dlouho trvá naší a komerčním CDN přenést osm javascriptových souborů (od 1 do 500 kB) v HTTP/2 streamu – naše CDN to zvládla za 45 ms, komerční CDN za 170 až 200 ms. Navíc i když používají kompresi brotli, byly soubory větší o 14 % My totiž používáme maximální úroveň komprese. Testovali jsme normálně v Chromu a k oběma CDN máme latenci 1 ms, protože my i jejich PoPy jsme v Praze.

Jak tedy kompresi vyřešit? V Nginxu můžete u gzipu i brotli aktivovat tzv. statickou kompresi (gzip_static on; brotli_static on;). Ta umožňuje při pochopení a správné implementaci zcela zásadně snížit zátěž na CPU a zároveň zrychlit načtení u návštěvníka.

Funguje to tak, že když je statická komprese aktivní a prohlížeč požaduje např. /js/file.js, tak se Nginx podívá na disk, jestli tam není již před-komprimovaný soubor /js/file.js.gz či /js/file.js.br. Pokud tam takový soubor existuje, tak ho rovnou odešle (bez toho, aby trápil CPU jeho kompresí). Typ komprese, kterou prohlížeč podporuje, posílá v hlavičce Accept-Encoding (br má prioritu před gzip, pokud ho prohlížeč podporuje).

Nginx za vás soubory s přípomou .br či .gz sám nevytváří. Stejně tak nezkouší z originů tyto soubory stahovat. Frontend buildy často tyto *.br či *.gz ke svým JS/CSS vytváři v rámci kompilace, ale zde se prostě nevyužijí. Toto si musíte zajistit u své CDN sami. My jsme si udělali proces na pozadí, který nepřetržitě analyzuje access logy a vybírá z nich požadavky „200 OK“ na textové soubory, které ještě svojí *.br či *.gz nemají.

Díky tomu, že je to proces na pozadí, můžete si dovolit u komprese zvolit nejvyšší, nejefektivnější, ale tím pádem i nejpomalejší level komprese. Jednorázově sice trošku potrápíte CPU, ale odměnou vám budou o dalších 5 – 15 % nižší přenosy. Na rychlost dekomprese v prohlížečích to má navíc minimální vliv (najdete na to benchmarky). Nezapomeňte pak na domyšlení způsobu, jak budete naopak již expirované *.br či *.gz uklízet po jejich expiraci. A taky, jak a jestli vůbec si ošetříte i situaci, kdy je v query stringu např. ?v=1.0.5 pro vynucení stažení nové verze souboru.

Ať už si statickou kompresi implementujete jakkoliv, zajistěte si atomické chování souborů v průběhu komprimace. Jinými slovy – finální *.br či *.gz soubor nejdřív ukládejte vedle a až když je soubor finálně hotový, přejmenujte ho do cílového umístění, kde ho očekává Nginx. Nestane se vám, že by si někdo stáhl nevalidní (pouze částečný) soubor, když se návštěvník trefí do momentu, kdy komprimujete.

Vzhledem k tomu, že obvykle cachujeme obsah v prohlížeči po dobu měsíců, takový návštěvník by měl stažený např. nefunkční JS/CSS až do doby smazání cache, což je velmi nepříjemné. Všichni víme, jak neprofesionální je, když vývojáři říkají klientovi, aby si smazal cache prohlížeče.

TIP: Pokud si nezajistíte proces na pozadí, který pro vás statickou kompresi bude obstarávat, radši nechte statickou kompresi vypnutou. Zbytečně si totiž budete zvyšovat IOPS, kdy Nginx bude pořad hledat *.gz či *.br varianty.

Konverze JPG/PNG do WebP/AVIF

Pokud chcete snížit datové přenosy obrázků o 30 až 90 % (podle toho, jak hodně už jsou optimalizované zdrojové obrázky), můžete si zajistit chytrou konverzi obrázků do moderního formátu WebP či AVIF. K formátu AVIF ale ještě pozor – je sice plně podporovaný a dobře funkční v Google Chrome, ale podpora ve Firefoxu je zatím experimentální a tam ještě vykazuje různé chyby popsané v tomto ticketu, které se projeví např. nezobrazením některých obrázků. Ve výchozím stavu je ale tato experimentání podpora vypnutá, takže Firefox image/avifAccept hlavičky požadavku neposílá.

Pro inspiraci, takto jsme podporu WebP/AVIF implementovali my:

  • Proces na pozadí analyzuje access logy a hledá nejčastěji načítané obrázky o definované minimální datové velikosti.
  • Pomocí konvertorů cwebp a cavif zdrojový obrázek např. /images/source.jpg zkonvertujeme do /images/source.jpg.webp (atomickým způsobem, stejně jako u statické komprese).
  • V Nginxu máme logiku, která při výskytu image/avif resp. image/webpAccept hlavičce požadavku zkusí poslat požadovaný soubor s příponou .avif či .webp, pokud na disku existuje. Řešení může být postavené na kombinaci map a try_files či skládání obsahu proměnné a IFů.

Pokud pro to budeme mít reálnou potřebu, časem možná tento proces centralizujeme. Tzn. tento proces si nebude provádět každý server zvlášť, ale bude ho řídit nějaký centrální systém, který může vhodné obrázky k optimalizaci vytipovávat z centrálních logů, vést tak statistiky reálné datové úspory podle přenosů, atp. Přináší to jistou míru flexibility a možnost hromadně provádět některé operace. Ovšem na druhou stranu se nám líbí, že decentralizace těchto procesů a maximální autonomie jednotlivých PoPů minimalizuje riziko, že se nějaká chyba dostane plošně do celé CDN. Výhodou je také to, že si každý PoP optimalizuje svůj nejvíce načítaný obsah podle tamních návštěvníků.

Vyhledávače

Je nutné si uvědomit, že když nasadíte CDN a najednou se v HTML obrázky načítají z jiné domény (pokud náhodou CDN nepoužíváte jako proxy pro celý web/doménu), tak je vyhledávače nebudou indexovat jako patřící k vaší doméně, ale k doméně CDN. A to nechcete.

Řešením je v Nginxu zajistit kanonizaci pomoci HTTP hlavičky Link, která vyhledávači řekne, kde je vlastně skutečný zdroj (origin). Díky tomu nebude indexovat obrázek pod doménou CDN, ale pod zdrojovou doménou uvedenou v Link hlavičce. Pro optimální indexování obrázků doporučujeme generovat i sitemapu pro obrázky.

Příklad: URL https://moje.cdn.cz/mujori­gin.cz/obrazek.jpg by měla vrátit HTTP hlavičku

Link: https://www.mujorigin.cz/obrazek.jpg; rel="canonical"

Použití CDN v projektech

Primární a preferovaný způsob použití naší CDN je velmi jednoduchý a plyne i z ukázky konfigurace Nginxu.

Když chceme nasadit CDN pro obsah např. na www.mujorigin.cz, tak stačí, aby vývojáři webu zajistili, že se místo např. /js/script.js tento soubor adresuje jako  https://moje.cdn.cz/mujorigin.cz/js/scripts.js.

Základ URL tvoří naše GeoCDN doména, následovaná doménou originu (bez „www“) a zakončeno cestou k souboru na originu.

To, které domény originu naše CDN podporuje, řídí správci CDN skrz Ansible. V Ansible můžou správci pro každý origin nastavit i nějaké specifické chování. Pro každý origin je navíc možné určovat, jaký typ obsahu je podporovaný, omezovat tvary URL, definovat vlastní WAF pravidla, atp.

Tip: pokud chcete nasadit CDN na váš web bez nutnosti jediného zásahu do aplikačního kódu a používáte Nginx, můžete si velmi snadno pomoct nativním Nginx modulem sub. Ten vám umožní jednoduchým způsobem nahradit cesty k vybraným souborům tak, aby se adresovaly z CDN (typicky v HTML nebo CSS).

Příklad:

sub_filter '<link href="/' '<link href="https://moje.cdn.cz/mujorigin.cz/';
sub_filter '<script src="/' '<script src="https://moje.cdn.cz/mujorigin.cz/';
sub_filter '<img src="/' '<img src="https://moje.cdn.cz/mujorigin.cz/';
sub_filter_types 'text/css' 'application/json' 'application/javascript'; # text/html je zahrnuté automaticky, navíc ale chceme nahrazovat i obsah v JSON API či CSS stylech a javascriptech
sub_filter_once off; # chceme nahradit všechny nalezené výskyty

Z příkladu je patrné, že to vyžaduje href/src jako první atribut HTML tagu. Regulární výrazy bohužel sub_filter nepodporuje. Pokud to pro vás není dostatečné, můžete toto nahrazování vyřešit v aplikačním kódu. Nejspíš používáte šablonovací systém, který vás obvykle nutí používat nějakou formu base-path proměnné, takže by to měla být hračka.

Pozor: aby vám nahrazování obsahu fungovalo, musíte nastavit i proxy_set_header Accept-Encoding "";, aby origin textový obsah nekomprimoval a bylo v něm možné nahrazovat řetězce.

Poznámka: vzhledem k tomu, že CDN není nasazená jako reverzní proxy pro celou origin doménu, se obsah do prohlížeče načte rychleji. Důvod je ten, že si prohlížeč dovolí více paralelizace (HTML a assety se načítají z různých IP adres), takže výsledný čas sestrojení a vykreslení stránky, je kratší. V režimu reverzní proxy před celým originem hodně pomáhá HTTP/2 multiplexing a prioritizace, ale když prohlížeč může načítat obsah z více různých IP adres, je přesto o něco efektivnější.

Bezpečnost, ochrana proti DoS/DDoS útokům a monitoring

S pomocí předchozího článku o komponentách CDN a tohoto článku byste měli být schopní si CDN zprovoznit se všemi základními funkčnostmi.

Věříme, že vám tento článek pomohl a někdo v něm třeba našel i myšlenky či nastavení, které mu pomůžou i ke zlepšení běžného webového či aplikačního serveru.

Root tip

Pokud má někdo při pohledu na navržená nastavení další tipy, nebo naopak vidí v naši konfiguraci nějaké hrozby, budeme rádi, když se o ně podělí v diskusi. Jednotlivá nastavení sami ladíme roky a reflektují různé potřeby i absolvované útoky na naše projekty, takže je to neustálý a nikdy nekončící proces. Navíc, nasimulovat reálný provoz pro ověření efektu některých nastavení je velmi obtížné, takže každá prožitá zkušenost se počítá a budeme za sdílení vděční.

V budoucím a zároveň posledním článku ze série Jak se staví CDN se ještě zaměříme na různé provozní aspekty provozu CDN – jak ochránit originy, ubránit se v rámci možností DoS/DDoS útokům a jak mít provoz celé CDN skutečně pod kontrolou.

Autor článku

Vedoucí vývoje a infrastruktury ve společnosti SiteOne.cz. Od roku 2004 vyvíjel, vede vývoj a zodpovídá za provoz stovek webových projektů s miliony denních pageviews.