Mehrere virtuelle Server mit nginx und PHP-FPM für Wordpress (Teil 2 / 3)

Im zweiten Teil geht es um die individuelle Einrichtung der virtuellen Server für nginx.

Server oder virtual hosts?

Im Gegensatz zu den “virtual hosts” von Apache spricht man bei nginx von “servern”. Ich möchte das Aufgreifen und nutze im Folgenden einfach nur von “Server” wenn ich von einem individuellen Host oder virtuellem Server spreche. Wie bei Apache werden diese idealerweise in eigenständigen Konfig-Dateien definiert. Hier gibt es verschiedene Vorlieben, ob die Konfig-Dateien unter /etc/nginx/sites-available oder /etc/nginx/conf.d abgelegt werden.

Aus technischer Sicht macht es wirklich überhaupt gar keinen Unterschied. Bei der ersten Variante wird im Ordner /etc/nginx/sites-enabled mit einem symbolischen Link auf die tatsächliche Konfig-Datei an einem anderen Ort verwiesen. Um sie zu de-aktivieren, wird dann einfach der symbolische Link gelöscht. Das ist auch der klassische Apache-Weg.

Bei der zweiten Variante muss man die Konfig-Dateien im Order /etc/nginx/conf.d mit der Endung conf anlegen. Um den Server zu deaktivieren, entfernt man die Endung .conf. Entscheide selber, was dir lieber ist.

Eine beispielhafte Konfiguration für einen Server ist folgendermaßen aufgebaut. Die interessante Parameter beschreibe ich weiter unten etwas ausführlicher. Ich versuche möglichst viel mit Platzhaltern zu arbeiten (set $server “example_com;) um die Nutzung für neue Server zu vereinfachen. Leider funktioniert das bei nginx nicht für jeder Direktive. (So werden in nginx die Parameter genannt. Warum? Weil eine Direktive selber auch Parameter besitzen kann, wie du gleich sehen wirst.)

Außerdem habe aus Gründen der Übersicht sich wiederholdene Einstellungen in Dateien (sogenannte Snippets) ausgelagert. Diese befinden sich im Ordner /etc/nginx/snippets/. Diese Snippets werden an der entsprechenden Stelle mit include eingebunden.

fastcgi_cache_path /var/nginx/example_com/cache use_temp_path=off levels=1:2:2 keys_zone=cache_example_com:100m inactive=60m max_size=2048m;

server {
    # der erste Server-Block ist für HTTP 
    # mit listen lege ich die Ports fest, die zweite Zeile wird für IPv6 benötigt
    listen 80;
    listen [::]:80;

    # über welche Domain-Namen wird der Server angesprochen?
    server_name example.com www.example.com;

    # da ich HTTPS erzwinge, wird direkt dahin weitergeleitet
    return 301 https://$server_name$request_uri;

}

server {
    # der zweite Server-Block ist für HTTPS gedacht, hier gehts ans Eingemachte
    # siehe oben
    server_name nickyreinert.de www.nickyreinert.de;

    # Platzhalter setzen
    set $server "nickyreinert_de";

    # in welchem Ordner befinden sich die (öffentlichen) Dateien des Servers
    root /var/nginx/nickyreinert_de/htdocs;

    # diese Einstellungen musst du nicht selber vornehmen, der Certbot kümmert sich darum, siehe unten
    ssl_certificate /etc/letsencrypt/live/www.example.com/fullchain.pem; # managed by Certbot
    ssl_certificate_key /etc/letsencrypt/live/www.example.com/privkey.pem; # managed by Certbot
	
    # natürlich nutzen wir auch individuelle Log-Dateien:
    access_log /var/nginx/nickyreinert_de/logs/access.log main if=$log_this;
    error_log /var/nginx/nickyreinert_de/logs/error.log error;
	
    # an der Stelle binde ich die restlichen Einstellungen ein
    include snippets/default_https.conf;
    include snippets/gzip.conf;
    include snippets/wordpress.conf;
    include snippets/logging.conf;
    include snippets/caching.conf;
    include snippets/fastcgi-php.conf;
    include snippets/sitemap.conf;
    include snippets/safety.conf;

}

Der Cache

Was soll gecached werden?

Im 1. Teil habe ich das Thema ja schon kurz angerissen und zwei Direktiven beschrieben. Auf Server-Ebene will ich den Cache nun noch etwa feiner einstellen. Zunächst geht es an ein paare globale Einstellungen, die ich im Snippet /etc/nginx/snippets/caching.conf abgelegt habe.

Nicht jede Anfrage darf gecached werden, wie z.B. POST-Requests, die ja tendentiel eher unterschiedliche Daten bei jeder Anfrage enthalten. Für diese Unterscheidung nutze ich die Variable $no_cache. So kann ich mit einfachen if-Abfragen festlegen, welche Requests vom Cache ignoriert werden sollen, wie z.B:

  • POST-Requests
  • Requests, die einen Query-String enthalten (GET)
  • Requests, deren URL auf ein bestimmtes Muster passen
  • Requests von eingeloggten Bentzern (Wordpress-Spezifisch!)
  • Requests, bei denen das Cookie PHPSESSID gesetzt ist
set $no_cache 0;

if ($request_method = POST)
{
	set $no_cache 1;
}

if ($query_string != "")
{
	set $no_cache 1;
}

if ($request_uri ~* "/wp-admin/|/xmlrpc.php|wp-.*.php|/feed/|index.php|sitemap(_index)?.xml") {
	set $no_cache 1;
}   

if ($http_cookie ~* "comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_no_cache|wordpress_logged_in") {
	set $no_cache 1;
}

if ($http_cookie = "PHPSESSID")
{
	set $no_cache 1;
}  

Wie soll gecached werden?

fastcgi_cache_path /var/nginx/example_com/cache use_temp_path=off levels=1:2:2 keys_zone=cache_example_com:100m inactive=60m max_size=2048m;

Um den Zweck der Parameter hinter fastcgi_cache_path zu verstehen, werde ich grob erklären, wie der nginx-Cache funktioniert:

Über FastCGI wird zunächst die PHP-Datei an den PHP-Interpreter übergeben. Das Ergebnis, z.B. ein HTML-Dokument geht dann an den Empfänger. Ist diese Ressource als “cachable” markiert, legt nginx das zu Ergbnis außerdem in temporär in einen Ordner ab und kopiert es von dort in den eigentlich Cache-Ordner. Damit diese Resource später wiedergefunden wird, wird ein Schlüssel erstellt. Ein Liste (“Cache-Verzeichnis”) dieser Schlüssel und ein paar Meta-Daten (z.B. der letzte Abruf) werden im Arbeitsspeicher abgelegt.

Zugegeben: Eine wirklich stark vereinfachte Darstellung des Cachings mit nginx

Mit fastcgi_cache_path legst du also den eigentlichen Cache-Ordner fest. Danach deaktivierst du mit use_temp_path=off die Zwischenspeicherung in einem temporären Ordner, um den Cache-Prozess zu beschleunigen. Mit levels kannst du die Tiefe des Cache-Ordners festlegen. Jede Position steht zwischen den Doppelpunkten für ein Level, drei Level sind möglich. Die Ziffer legt fest, wieviel Zeichen die Dateinamen enthalten. Folgende Angabe reduziert die Tiefe z.B. auf 2 Level deren Ordnernamen 1 Zeichen enthalten:

levels=1:1

Der Parameter keys_zone gibt dem Bereich im Arbeitsspeicher einen eindeutigen Namen, der das “Cache-Verzeichnis” enthält. Das ist notwendig, da du auch andere Cache-Bereich anlegen kannst (z.b. den proxy_cache). Die Ziffer hinter dem Doppelpunkt gibt die Größe der Liste an. 1 MByte entspricht etwa 8.000 cache keys - mit 100 MB solltest du also eine Weile auskommen.

Mit inactive=60m legst du fest, wie lange ein Objekt im Cache gültig ist, in diesem Fall 60 Minuten. Wenn du mit Inhalten arbeitest, die sich sehr oft ändern, solltest du diesen Wert natürlich verkleinern. Schließlich kannst du mit max_size die tatsächlicheGröße des Caches im Dateisystem begrenzen.

Die Direktive fastcgi_cache_path wird nicht auf Server-Ebene angegeben, sondern global unter http. Du kannst damit beliebig viele Caches anlegen, musst aber unbedingt auf die Unterscheidbarkeit achten, damit nginx die Caches deiner unterschiedlichen Server nicht zusammenhaut. Wie macht sich das bemerkbar? Wenn du eine deiner Seiten lädst (www.example.com) und plötzlich auf einer völlig anderen deiner Seiten (Domain) landest (www.test.com), solltest du dir die Direktiven fastcgi_cache_path oder fastcgi_cache_key noch mal genauer anschauen.

Die PHP-Einstellungen

Jetzt wird es spannend um nicht zu sagen: etwas kompliziert. Die Einstellungen für den PHP-Interpreter in fastcgi-php.conf. Diese bezieht sich alleine auf Dateien, deren Dateiendung ich in location festlege. Zunächst nutzen wir ein paar Standard-Einstellungen aus der bei nginx mitgelieferten fastcgi.conf-Datei. Hier werden einige Werte festgelegt, wie z.B. Document Root, Protokolle usw. Das muss zwingend zu Beginn passieren, da wir einige Parameter weiter unten überschreiben. Außerdem wird noch die Standard-Script-Datei festgelegt, sollte keine Datei in der URL mitgegeben werden.

Mit fastcgi_cache verweise ich nun auf Cache-Zone, die ich oben bereits definiert habe. Hier kannst du mit Parameter arbeiten ($server). Mit fastcgi_cache_valid kann ich für jeden HTTP-Antwortcode festlegen, wie lange der Cache gültig ist. Ich verweise hier nur auf erfolgreiche Anfragen (HTTP 200). Weiter oben habe ich bereits festgelegt, welche Anfragen überhaupt gecached werden, hier kann ich diese Anfragen mit fastcgi_cache_bypass nun explizit ausklammern.

Danach folgt eine wordpress-exklusive Einstellung: Die PHP-Datei wird nur an den PHP-Interpreter weitergereicht, wenn sie sich nicht im Ordner “uploads” befinden. Das ist ein Sicherheitsfeature: Sollte irgendwie eine PHP-Datei mit schadhaften Code in den (üblicherweise) schreibbaren Ordner “Uploads” gelangen, wird nginx diesen niemals an PHP weitergeben.

location ~ \.(php|php.*)$ {

        include fastcgi.conf;
	fastcgi_index index.php;

        fastcgi_cache cache_$server
	fastcgi_cache_valid 200 60m;
	fastcgi_cache_bypass $no_cache;
	fastcgi_no_cache $no_cache;

	if ($uri !~ "^/uploads/") {

		fastcgi_pass unix:/run/php/php-fpm-$server.sock;

	}

	# die URL in $fastcgi_script_name und $fastcgi_path aufbrechen:
	fastcgi_split_path_info ^(.+\.php)(/.+)$;

	# try_files setzt $fastcgi_path_info zurück, deshalb neu festlegen
        set $path_info $fastcgi_path_info;

	# PHP-Dateien nur verarbeiten, wenn sie überhaupt existieren:
	try_files $fastcgi_script_name =404;

	fastcgi_param PATH_INFO $path_info;
	fastcgi_param SCRIPT_FILENAME /htdocs/$fastcgi_script_name;
	fastcgi_param SCRIPT_NAME $fastcgi_script_name;

}

Weiter geht es mit einer wichtigen Einstellung für Sicherheit und Geschwindigkeit. Wir haben oben zwar schon grob festgelegt, welche Dateien nicht als Script zu PHP geschickt werden. Das ist aber noch ziemlich wacklig (warum, das ist hier ganz gut beschrieben): Was wir bisher nicht vermeiden, ist der Aufruf von z.B. /test.jpg/index.php - die Datei index.php würde vom Interpreter nicht gefunden werden. Er würde demnach versuchen, test.jpg auszuführen und den Anhang als Parameter verstehen. Das wollen wir vermeiden.

Es gibt viele Möglichkeiten, das zu verhindern. Einige davon werden wir hier nutzen.

Mit fastcgi_split_path_info zerlegst du die URL in den Pfad und den Dateinamen um zielsicher zu erkennen, welcher Teil der URL auf eine Datei zeigt und was als Ordner verstanden wird. Die RegExe beinhaltet deswegen zwei Capture-Gruppen. Der Inhalt der ersten Gruppe (.+.php) wird in der Variable $fastcgi_script_name abgelegt, der der zweiten Gruppe (/.+) landet in $fastcgi_path_info.

Mit try_files bestimmst du nun, dass nur PHP-Dateien verarbeitet werden, die überhaupt exisiterien. Das Problem dabei ist, dass dadurch der Parameter $fastcgi_path_info zurückgesetzt wird (siehe auch hier). Deshalb wird dessen Inhalt einen Schritt davor in die Variable $path_info geschrieben. Danach legen die Parameter für FastCGI fest und greifen nun auf die eben per RegExe extrahierten Infos für das Script und den Pfad zurück:

fastcgi_param PATH_INFO $path_info;
fastcgi_param SCRIPT_FILENAME /htdocs/$fastcgi_script_name;
fastcgi_param SCRIPT_NAME $fastcgi_script_name;

Damit das ganze wirklich reibungslos funktioniert, musst du in der php.ini den Parameter cgi.fix_pathinfo auf 1 setzen - das ist zwar die Standardeinstellungen, schau aber trotzdem noch mal nach:

cgi.fix_pathinfo=1

Die HTTPS-Einstellungen

Die nächsten Parameter sind wieder etwas unkompliziert und auch selbsterklärend. Wir kommen zu den HTTP- und HTTPS-Einstellungen, die ich in einer Datei zusammengefasst habe (default_https.conf). Hier werden nur die Port-Einstellungen festgelegt, SSL korrekt eingerichtet und auf eine Standard-Datei verwiesen, wenn die Anfrage nicht auf eine Datei verweist:

# welche Datei wird standardmäßig aufgerufen?
index index.php;

# Nutze 443 als Port für HTTPS und aktiviere HTTP2
listen 443 ssl http2;
listen [::]:443 ssl http2;
# Verweis von Let's Encrypt:
include /etc/letsencrypt/options-ssl-nginx.conf;
ssl_dhparam /etc/letsencrypt/ssl-dhparams.pem;

Die GZIP-Einstellungen

Auch die Datei gzip.conf bedarf keiner großen Erklärung. Einen Großteil habe ich bereits global konfiguriert, hier werden auf Server-Ebene noch einige Einstellungen vorgenommen. Dabei setze ich das Kompressions-Level auf 3 und lege fest, welche Ressourcen komprimiert werden. Welches Level du wählst, hängt von deiner Hardware ab. Die Kompression kann die Auslieferung deiner Seite auf jeden Fall beschleunigen, einen etwas ausführlicheren Beitrag dazu findest du bei pingdom.com.

gzip             on;
gzip_comp_level  3;
gzip_types       text/plain text/html text/css application/javascript image/*;

Die Wordpress-Einstellungen

Weiter geht es mit der Datei wordpress.conf und noch ein paar Sicherheitsfeatures:

# Maximale Dateigröße für Uploads
client_max_body_size 64M;

location / {
	# Permalinks wieder funktionsfähig machen
	try_files $uri $uri/ /index.php?$args;
        limit_req zone=one burst=10;
}

Wie du siehst beziehe ich mir hier erneut auf die Rate Limit Einstellung aus dem ersten Teil in der Datei nginx.conf. Mit Verweis auf meine Zone (one) beschränke ich die Warteschlange auf 10: burst=10; Wenn als mehr Anfragen als erlaubt ankommen (ich hatte 5 pro Sekunde zugelassen), werden bis zu 10 der darauf folgenden Anfragen in eine Warteschlange gepackt. Die anderen Parameter habe ich inline erklärt.

Die Logging-Einstellungen

Auf zur Datei logging.conf. Diese Einstellungen betreffen nicht nur das Log-Verhalten ansich, sondern haben auch Auswirkungen auf Geschwindigkeit und Sicherheit. Ich lege nämlich fest, welche Anfragen nicht ins Log-File geschrieben werden bzw. gänzlich ignoriert werden. Eine aus führliche Dokumentation findest du auf diesem Blog. Die Einträge sind inline beschrieben und erklären sich, so blöd das klingt, eigentlich selber. Nicht jeder Aufruf muss auch im Log dokumentiert werden. Uns interessieren ja eigentlich nur Fehler oder ungewöhnliche Anfragen.

# nicht loggen: Bilder, XML, CSS, usw.
# außerdem das Cache-Datum auf 360 Tage setzen
location ~* \.(jpg|jpeg|gif|png|css|js|ico|xml)$ {
	access_log        off;
	log_not_found     off;
	expires           360d;
}    

# noch mehr nicht loggen: Doc, XLS, EXE, uvm.
# außerdem: den Cache komplett deaktivieren!
location ~* .(ogg|ogv|svg|svgz|eot|otf|woff|mp4|ttf|css|rss|atom|js|jpg|jpeg|gif|png|ico|zip|tgz|gz|rar|bz2|doc|xls|exe|ppt|tar|mid|midi|wav|bmp|rtf)$ {
	expires max;
	log_not_found off;
	access_log off;
}

# nicht loggen: versteckte Dateien die mit . anfange
# außerdem: Den Zugriff verweigern!
location ~ /\. {
	access_log off;
	log_not_found off; 
	deny all;
}

# nicht loggen: robots.txt
location = /robots.txt {
	access_log off;
	log_not_found off;
}

Eine Sitemap korrekt einbinden

Die Einstellungen im Snippet sitemap.conf kommen ein wenig den berühmten “Kanonen auf Spatzen” gleich. Im Grunde bilde ich eine ganze Reihe von Spezialfällen ab, die beim Betrieb von Wordpress und Sitemaps auftreten. Du kannst hier sicher einige Zeilen auslassen oder die Datei ganz ignorieren, wenn du ein komplett anderes Setup nutzt:

rewrite ^/sitemap_index.xml$ /index.php?sitemap=1 last;
rewrite ^/([^/]+?)-sitemap([0-9]+)?.xml$ /index.php?sitemap=$1&sitemap_n=$2 last;
location ~ ([^/]*)sitemap(.*).x(m|s)l$ {
        rewrite ^/sitemap.xml$ /sitemap_index.xml permanent;
        rewrite ^/([a-z]+)?-?sitemap.xsl$ /index.php?xsl=$1 last;
        rewrite ^/sitemap_index.xml$ /index.php?sitemap=1 last;
        rewrite ^/([^/]+?)-sitemap([0-9]+)?.xml$ /index.php?sitemap=$1&sitemap_n=$2 last;
}
rewrite ^/sitemap(-+([a-zA-Z0-9_-]+))?\.xml$ "/index.php?xml_sitemap=params=$2" last;
rewrite ^/sitemap(-+([a-zA-Z0-9_-]+))?\.xml\.gz$ "/index.php?xml_sitemap=params=$2;zip=true" last;
rewrite ^/sitemap(-+([a-zA-Z0-9_-]+))?\.html$ "/index.php?xml_sitemap=params=$2;html=true" last;
rewrite ^/sitemap(-+([a-zA-Z0-9_-]+))?\.html.gz$ "/index.php?xml_sitemap=params=$2;html=true;zip=true" last;

Sicherheits-Features

Zum Abschluss will ich noch ein paar Sicherheitsfeatures implementieren. In der Datei safety.conf passiert nicht viel, außer dass ich den Zugriff auf bestimmte kritische Dateien verbiete. Einiges davon bezieht sich explizit auf eine Wordpress-Installation. Was du aus diesen Einstellungen mitnehmen solltest, ist die Info, wie du mit location, einer RegExe und deny all den Zugriff auf bestimmte Ressourcen verbietest.

# Den Upload-Ordner zusätzlich sichern und nur den Zugriff auf HTML- und Medien-Dateien zulassen:
location ~* ^/wp-content/uploads/.*.(html|htm|shtml|php|js|swf)$ {
        deny all;
}

# In Wordpress die XML-RPC Schnittstelle deaktivieren, die ein beliebtes Angriffsziel darstellt:
location ^~ /xmlrpc.php {
        deny all;

}

# Apache nutzt unter anderem .htaccess - das ist für uns vielleicht nicht relevant, sollte sich aber trotzdem mal eine derartige Datei in unser Dateisystem verirren, schützen wir sie vor ungewollten Blicken und zwar für alle Dateien die mit einem Punkt anfangen:
location ~ /\. { {
        deny all;
}

# Theoretisch ist es nicht möglich, dass der Nutzer im Browser den Inhalt von PHP-Dateien sieht - trotzdem schaffen wir zusätzliche Sicherheit, indem wir die wp-config.php gar nicht erst ausliefern
location ~* wp-config.php {
        deny all;
}

# Brutforce erschweren, siehe unten
location ~ /wp-login.php {
        limit_req zone=one burst=1 nodelay;
        fastcgi_pass unix:/run/php/php-fpm-$server.sock;
}

Natürlich wollen wir nicht nur den Zugriff auf kritische Ressourcen verhindern, sondern ggf. auch andere Angriffsvektoren erschweren, wie z.B. BruteForce-Attacken. In einer Wordpress-Installation ist ein beliebter Angriffspunkt z.B. die Datei wp-login.php. Weiter oben haben wir schon mal festgelegt, wie oft eine Ressouce abgefragt werden kann. Für wp-login.php wollen wir diese Grenze noch etwa enger ziehen. Unser Setup erlaubt 5 Anfragen / Sekunde. Mit Burst verkürze ich zuerst die Warteschlange auf 1. Mit nodelay sorge ich nun dafür, dass Anfragen sofort beantwortet werden, aber der Slot in de Warteschlange nicht gleich wieder frei wird. Ergo werden direkt darauf folgende Zugriffe im erlaubten Zeitfenster mit dem HTTP-Fehler 503 (Service temporarly not available) abgelehnt.

Weiter gehts abschließend mit der Einrichtung von PHP in Teil 3.