Een tijdje terug liep ik tegen het probleem aan dat een bepaalde API endpoint van een applicatie duizenden keren per minuut wordt aangeroepen en de PHP de hoeveelheid requests niet meer aankon.
De specifieke endpoint geeft dynamische resultaten terug die gebaseerd is op URL, tijd, datum, of eventuele andere context. Hoewel veel requests uniek is zal toch de meerderheid van de 'unieke' data dezelfde response terugsturen. Om CPU te besparen van de PHP server, heb ik gekeken naar de mogelijkheden om zo veel mogelijk processen intern te cachen.
In deze tutorial ga ik meer vertellen over mijn oplossing die ik gevonden heb.
Let wel op: We hebben het hier over het gebruiken van 'publieke' cache. Lees onderaan deze tutorial hoe je de publieke cache beter kan onderscheiden van andere gebruikers van bijvoorbeeld je login systeem.
Wat is NGINX?
Mocht je nog niet eerder gehoord hebben van NGINX, dan is het goed om te weten dat dit een webserver is die je bezoekers ontvangt, net zoals Apache. Persoonlijk prefereer ik al geruime tijd de webserver NGINX boven Apache, dat komt vooral door de verhoogde prestaties en het efficiënte RAM gebruik waardoor je meer bezoekers kan ontvangen,
Cachen op verschillende niveaus
Cachen kan op veel verschillende niveaus, een van de populaire manier om data te cachen is op applicatie niveau door middel van Redis of Memcached. Dat zijn razendsnelle databases die data voor je tijdelijk opslaan aan de hand van een key
. Meestal verlopen die keys na verloop van tijd waarna de applicatie automatisch nieuwe data ophaalt uit je productie database en het resultaat weer tijdelijk opslaat in je Redis database.
Op deze manier voorkom je dat er bij elke request 'verse' data uit je database gehaald moet worden.
Cachen in NGINX niveau
Cachen op NGINX niveau gaat iets anders in z'n werk, maar het idee is hetzelfde.
In dit geval zal de NGINX kijken of de data nog vers genoeg is. Indien dit het geval is zal jouw applicatie de request niet ontvangen en komt deze niet verder dan NGINX-laag die om je applicatie zit. Dit komt omdat NGINX de vorige response naar die URL al eens eerder heeft gecached en tijdelijk opslaat. Hierdoor blijft de CPU van je applicatie bespaard.
Mocht de data verouderd zijn, dan laat NGINX de request door naar je PHP/NodeJS/Ruby server. Het resultaat zal weer worden gecached voor de volgende keer dat iemand op die URL terecht komt.
Interne Proxy Cache
In mijn geval heb ik binnen één NGINX server verschillende sublagen. Er is één laag die al het verkeer ontvangt en doorstuurt naar de laag eronder. Deze laag praat met de betreffende software die je geschreven hebt, dit kan alles zijn, zoals (maar niet gelimiteerd tot) bijvoorbeeld Symfony, Laravel, een NodeJS applicatie zijn.
In jouw geval is een tweede laag misschien niet nodig.
Zones
NGINX biedt mogelijkheid om een cache-zone aan te maken die data tijdelijk opslaat. Dit betekent dat je op één webserver verschillende URLs en data anders kan behandelen. In dit voorbeeld maken we gebruik van één proxy cache.
De eerste stap is dan ook om naar je NGINX default.conf
configuratie bestand te gaan. In de meeste gevallen vind je deze in /etc/nginx/sites-available/default.conf
.
Voeg bovenin het bestand de volgende regel toe:
proxy_cache_path /var/tmp/nginx levels=1:2 keys_zone=zone_api_cache:1m inactive=1h;
Hierbij is het handig om het volgende te weten over de bovenstaande lijn:
proxy_cache_path
is een caching module binnen NGINX die we aanspreken
/var/tmp/nginx
is het pad waar NGINX tijdelijk de cache resultaten opslaat als bestanden
levels=1:2
defineert de maximale mappenhiërarchie, de hoeveelheid mappen die NGINX gebruikt om de responses op slaan
keys_zone=zone_api_cache
is de caching key. Deze gebruik je in je NGINX server om aan te geven van welke cache die gebruik moet maken
:1m
is de hoeveelheid bytes die maximaal opgeslagen wordt. In dit geval is het 1MB
inactive=1h
geeft de maximale tijd aan dat een response gecached mag worden. Gecachte data die niet wordt aangeroepen binnen die periode zal (ongeacht de versheid) worden verwijderd. Dit kan in minuten en uren (de default is 10 minuten
)
Er zijn veel meer configuraties mogelijk, deze kan je teruglezen in deze documentatie van NGINX.
De NGINX server instellen
In dit voorbeeld definieer ik twee lagen, dit verschilt per situatie.
De proxy_pass
In dit voorbeeld maken we gebruik van de NGINX proxy die leid naar je applicatie. Het IP 127.0.0.1
is voor lokaal server verkeer, ook wel bekend onder localhost
. 8081
is de poort naar de interne NGINX server. De poort 8081
kan je ook aanpassen naar de poort van je applicatie of een andere webserver, zoals een NodeJS of Ruby applicatie.
proxy_pass http://127.0.0.1:8081;
De proxy cache instellen
In het kort een aantal belangrijke properties:
proxy_cache
: verwacht de key
van de zone die je eerder hebt ingesteld
proxy_cache_methods
: verwacht de request methodes die gecached moeten worden, zoals GET
, POST
, PUT
, PATCH
of DELETE
.
Wanneer je een methode niet definieert zal de request niet gecached worden en verkrijgt het altijd verse
data van je applicatie
proxy_cache_key
: verwacht een 'unieke' tekst die NGINX zal gebruiken om te hashen, dit herleid naar het gecachte bestand op je server (mocht deze bestaan en overeenkomen). De tekst wordt volgens de documentatie omgezet naar MD5 voor de bestandsnaam.
proxy_cache_valid
: verwacht de response status codes die gecached moeten worden. Alle response codes die niet in de lijst voorkomen zullen niet gecached worden. De volgende request krijgt dan opnieuw verse
data. Hierin geef je daarnaast aan hoe lang de data moet worden gecached, in mijn geval 1m
(1 minuut)
Deze property kan je meerdere keren gebruiken, dit houd in dat verschillende response status codes andere cache-valid lengtes kan krijgen.
proxy_cache_use_stale
: Bepaalt in welke gevallen een verouderd, in de cache opgeslagen antwoord kan worden gebruikt tijdens communicatie met de proxyserver.
Extra header(s) toevoegen
Soms kan het handig zijn om te zien hoe vers de data van je response is en hoe de server de request heeft behandeld. NGINX heeft veel parameters waar je gebruik van kan maken, bijvoorbeeld de $upstream_cache_status
parameter. Deze zal een HIT
, BYPASS
, EXPIRED
of MISS
status tekst toevoegen aan je headers ter indicatie. Voorbeeld:
add_header X-Cached $upstream_cache_status
De onderstaande configuratie komt te staan binnen je http
blok (meestal al gedefinieerd buiten het configuratie bestand) en is voorzien van de benodigde commentaar.
# Publieke server
# De schil om je interne server of applicatie
server {
listen 80;
# Stuur alle requests door naar de proxy
location / {
try_files $uri @api_backend;
}
# Proxied requests 'gewoon' door naar je applicatie zonder te cachen
# Het is belangrijk dat de proxy buffers goed ingesteld is
location @api_backend {
proxy_buffer_size 128k;
proxy_buffers 4 256k;
# Het IP 127.0.0.1 en de poort van je applicatie
proxy_pass http://127.0.0.1:8081;
}
# Het pad (of de paden) die gecached worden
# Die sturen we door naar de controller @cached_routes
location ~* ^/een-api-route/(.*)$ {
try_files $uri @cached_routes;
}
# Er kunnen meerde paden zijn die gebruik maakt van dezelfde controller
location ~* ^/andere-route/(.*)$ {
try_files $uri @cached_routes;
}
# De proxy cache manager
location @cached_routes {
# Het IP 127.0.0.1 en de poort van je applicatie
proxy_pass http://127.0.0.1:8081;
proxy_cache zone_api_cache; # De zone cache die je bij stap 1 hebt ingesteld
proxy_cache_methods GET POST; # Alleen GET of POST requests cachen (in dit geval worden PATCH en DELETE requests niet gecached)
proxy_cache_key "$uri";
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_cache_valid 200 402 404 500 1m; # Response cachen voor 1 minuut
proxy_cache_use_stale updating;
add_header X-Cached $upstream_cache_status;
}
}
# Interne voorbeeld server
# Niet publiek toegankelijk
# Dit hoeft geen aparte NGINX server te zijn, dit kan ook een NodeJS of Ruby applicatie zijn
server {
listen 8081;
# Eventuele root, error_log etc
# In mijn geval staat hier de rewrites voor de PHP applicatie
}
Bijzondere cache-bypass instellen
Soms kan het nodig zijn dat je niet wilt dat er iets gecached wordt, bijvoorbeeld als er een bepaalde parameter gezet is of de request van een specifieke host komt.
In dat geval kun je een variabele zetten binnen je blok. Ik noem deze variabele $skip_cache
en gebruik het met de proxy_cache_bypass
en proxy_no_cache
parameters.
location @cached_routes {
proxy_pass http://127.0.0.1:8081; # Ook hier
# Standaard op 0, altijd cachen
set $skip_cache 0;
# Wanneer de host 'devnl.nl' is, cache de response dan niet
if ($host = 'devnl.nl') {
set $skip_cache 1;
}
proxy_cache zone_api_cache;
proxy_cache_methods GET POST;
proxy_cache_key "$uri";
proxy_buffer_size 128k;
proxy_buffers 4 256k;
proxy_cache_min_uses 1;
proxy_cache_valid 200 402 404 500 1m;
proxy_cache_use_stale updating;
proxy_cache_bypass $skip_cache; # -> nieuw
proxy_no_cache $skip_cache; # -> nieuw
add_header X-Cached $upstream_cache_status;
add_header X-Backend cached-backend;
}
De no-cache
pragma header
Standaard wanneer je de NGINX proxy gebruikt zal deze de no-cache
header negeren. Dit is om te voorkomen dat iemand die een DOS aanval uitvoert deze header kan gebruiken en je server toch overbelast raakt omdat er niets meer gecached wordt.
Heb je een applicatie waar je soms wilt forceren om verse data te krijgen en je denkt dat dit veilig kan? Gebruik dan de volgende proxy_cache_bypass
configuratie.
proxy_cache_bypass $http_pragma;
Cache key specifieker defineren
In mijn geval wilde ik ook dat de request body
ook gebruikt wordt als key (de JSON die wordt meegestuurd). De reden was in mijn geval dat er één URL-pad is waar alle requests van een specifieke site heen gaan, maar mijn applicatie een stukje data uit de request body
uitleest om tot een resultaat te komen.
Gelukkig bestaat er een proxy_cache_key
, deze heb ik al eerder genoemd. Standaard is deze ingesteld op "$uri"
, wat inhoud dat de URL de enige unieke parameter is die de cache-key uniek maakt. Er wordt niet gekeken naar headers
of de request body
.
In het onderstaande voorbeeld verander ik de cache key
naar de URL en de $request_body
. Mocht de body van de request overeenkomen dan kan deze uit de cache worden gehaald. Deze methode houd wel in dat er meer unieke cachebestanden worden geschreven.
proxy_cache_key "$request_uri|$request_body"
Caching met authenticatie (datalekken voorkomen)
Heb je een authenticatie laag ingebouwd? Dan moet je opletten en oppassen met de publieke cache: wanneer gebruiker 1 een URL gebruikt, zal gebruiker 2 de data te zien krijgen die gegenereerd was voor gebruiker 1.
Om dit te voorkomen is het belangrijk dat je de proxy_pass
goed instelt.
Je kan met de volgende code instellen dat de cache wordt overgeslagen en ingelogde gebruikers altijd verse data terugkrijgen (mocht je de authorization header gebruiken).
proxy_cache_bypass $http_authorization;
Gebruikersdata ook cachen
Mocht je nou willen dat gebruikersdata ook gecached wordt, dan zal je moeten sleutelen met de proxy_cache_key
en moet je geen proxy_cache_bypass
instellen voor de authorization
header.
Let op: Dit is vooralsnog geen waterdichte methode, mocht je dit verkeerd instellen, dan kan het leiden tot data-lekken.
In dit voorbeeld gebruik ik de zelfde methode als bij Cache key specifieker defineren
, alleen gebruik je dan de $http_authorization
header. In principe zou je authorization header uniek moeten zijn per gebruiker (mocht je een andere header gebruiken, gebruik deze dan).
Het is belangrijk dat je de cache key
zo specifiek en uniek mogelijk maakt, maar wel identificeerbaar met je gebruiker.
proxy_cache_key "$request_uri|$http_authorization"
Je zou ervoor kunnen kiezen om het IP adres van de gebruiker mee te laten wegen (de $remote_addr
variable dat beschikbaar is in NGINX). Let er wel op dat je het juiste IP-adres gebruikt.
proxy_cache_key "$request_uri|$remote_addr|$http_authorization"
TIP: Mocht je gebruik maken van Cloudflare, dan krijg je Cloudflares proxy-IP en wordt je content minder uniek gecached.
Slot
Door gebruik te maken van deze configuratie heb ik een webserver ingesteld die in 24 uur meer dan 1 miljoen requests kan verwerken. Daarvan gaan er daadwerkelijk maar tienduizenden naar de achterliggende PHP-FPM webserver gaan 🚀
Ik hoop dat je door deze tutorial iets hebt bijgeleerd. Mocht je vragen hebben, laat het weten door een topic aan te maken en je vraag te stellen.