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

Bisher war ich immer recht zufrieden mit der Geschwindigkeit meiner selbstgehosteten Wordpress-Seiten. Im Schnitt hat es nicht länger als 2 Sekunden gedauert, bis die Inhalte aufgebaut waren. Mal mehr, mal weniger. Und das schien mir ein akzeptabler Wert zu sein. Ich nutzte eine der üblichen Standard-Installationen, die da draußen wohl weit verbreitet ist: Apache2 mit mod_php. Der PHP-Interpreter ist dabei “Teil” des Apache2-Servers. Das ist unkompliziert und schnell zu installieren und somit einfach eine pragmatische Lösung und auch deshalb wohl sehr weit verbreitet. Aber: Die einfachsten Lösungen sind oft nicht die besten. Geschweige denn, die sichersten.

Ziel

Um es kurz zu machen: Das Ziel ist es, einen sicheren und schnellen Web-Server mit Nginx, PHP-FPM und chroot aufzusetzen, mit dem sich mehrere getrennte Webseiten betreiben lassen. Um der Sache einen Zweck zu geben, werde ich mich im Folgenden an Wordpress orientieren.

Warum chroot? Wenn sich mehrere Wordpress-Installationen einen (virtuellen) Server teilen, ist es fast schon fahrlässig diese einfach in ein paar Unterordner zu packen und die Domains darauf zeigen zu lassen. Wird eine Wordpress-Installation kompromittiert, ist es für den Angreifer nicht sonderlich schwer, sich im gesamten System zu auszubrreiten. Mit chroot sorge ich dafür, dass jede Wordpress-Instanz sich nur in ihrem eigenen Verzeichnis bewegen kann. Das ist in etwa zu vergleichen mit der PHP-Direktive open_basedir aber noch etwas restriktiver.

Warum PHP-FPM? Weil es sicherer und schneller ist und weil mod_php nur unter Apache2 funktioniert. Hier stand anfangs auch FastCGI zur Wahl.  CGI bedeutet Common Gateway Interface. Mit dieser Schnittstelle können Anfragen über einen Port oder einen Datei-Socket an den PHP-Interpreter weitergeleitet werden, der dazu aber immer wieder komplett neu gestartet wird. Bei FastCGI, einer Weiterentwicklung, wird der Interpreter nicht jedes mal neu gestartet, sondern läuft permanent im Hintergrund.

Und FPM schließlich steht für FastCGI Process Manager, eine weitere Weiterentwicklung. Ein Neuerung ist unter anderem, dass nun mehrere PHP-Interpreter im Hintergrund laufen. Einen tieferen Überblick über die Grundlagen und Unterschiede bietet dieser Artikel.

Und warum nginx? Meine Seite ist nicht der größte Krümel auf dem Kuchenblech, weshalb die Performance-Vorteile vielleicht kaum ins Gewicht fallen. Dennoch: Nginx ist leichtfüßiger als der mit allen möglichen Paketen ausgestattete Apache. Außerdem hatte ich bisher frustriert versucht, PHP-FPM mit chroot unter Apache zum Laufen zu bringen. Ohne Erfolg.

Und den Zahn muss ich allen nginx-Kritikern gleich einmal ziehen: nginx ist nicht komplizierter zu bedienen als Apache. Wer sich bisher für Apache durch die Config-Dateien gewühlt hat, bekommt das locker auch mit nginx hin. Beide Server nehmen sich in Punkte Komplexität, Community und Dokumentation aus meiner Sicht nichts.

Da das ganz jetzt schon ziemlich umfangreich ist, ich den Beitrag in zwei Teile getrennt. Viel Spass beim Lesen.

Installation

Alles beginnt mit einem apt für nginx und zwei wichtigen Helfern:

apt install nginx nscd python-certbot-nginx

Nscd steht für Name Service Cache Daemon und dient dazu, DNS-Anfragen auch im chroot zu ermöglichen, gleichzeitig anhand eines internen Caches aber auch zu beschleunigen. Die genauen Hintergründe sind hier beschrieben. Außerdem nutze ich die SSL-Zertifikate von Let’s Encrypt, da diese kostenlos sind und sich die Re-Zertifizierung außerdem bequem automatisieren lässt. Ich muss also den entsprechenden certbot für nginx installieren.

Ordnerstruktur

Chroot (change root) bedeutet, dass einem Prozess (sprich: der entsprechend konfigurierten Website) ein eigenes Root-Verzeichnis vorgegaugelt wird. Das ist sehr sinnvoll, weil der Prozess so nicht auf die gesamte Partition zugreifen kann. Das erschwert die Sache allerdings auch, da ihm wichtige Systemfunktionen zur Verfügung gestellt werden müssen, die sich sonst irgendwo auf der Partition befinden. Die Lösung dafür lautet mount. Grundsätzlich forderte chroot mir bei der Konfiguration sämtlicher Pfade etwas mehr Konzentration ab, da das Root-Verzeichnis nun nicht mehr unter / sondern z.B. unter /var/www/nickyreinert/ liegt.

Jede Website bekommt grundsätzlich erstmal ein eigenes Verzeichnis, in dem sich jedoch nun nicht nur - wie gewohnt - die Ressourcen der Webseite befinden. Hier werden System-Funktionen, Sockets etc. eingebunden, die PHP und nginx für die einwandfreie Funktion benötigen. Die Ordner-Struktur sieht also folgendermaßen aus:

/ <- tatsächlicher root-Ordner des Systems /var /var/www /var/www/nickyreinert_de <- root-Ordner für diese Website - cache - data - dev - etc - htdocs - logs - sessions - tmp - usr - var /var/www/foobar_de <- root-Ordner für eine andere Website - …

Htdocs, logs, tmp und sessions sind fester und individueller Bestandteil des Ordners. Alle anderen sind Verweise auf die tatsächlichen System-Order und werden daher per mount lesend eingebunden.

Um die Ordner und die fixen Bestandteile einmal initial anzulegen, nutze ich folgendes Script. Als erster Parameter wird der Name der Website erwartet.

#!/bin/sh cd /var/www/ mkdir $1 cd $1 mkdir -p htdocs logs tmp sessions cache chown root:sudo htdocs chown $1:www-data logs chown $1:www-data sessions chmod 700 sessions

Um nun noch das das mounten zu erleichtern, nutze ich das Init-Script von kthx.at, das ich noch etwas angepasst habe (Unterstützung für sendmail und php-gettext):

#!/bin/bash

BEGIN INIT INFO

Provides: php5-fpm-chroot-setup

Required-Start: nscd

Required-Stop:

Default-Start: 2 3 4 5

Default-Stop: 0 1 6

Short-Description: Mounts needed sockets and other data into a previously set up chroot environment.

END INIT INFO

Hier die Dateien und Ordner die in die Chroot-Umgebung gemountet werden sollen

CHROOT_FILES="/usr/lib/sendmail /etc/hosts /etc/resolv.conf /etc/ssl/certs /usr/share/ca-certificates /dev/null /dev/random /dev/urandom /dev/zero /var/run/mysqld /var/run/nscd /usr/share/zoneinfo /usr/share/php/php-gettext"

siehe unten!

CACHE_FOLDER="/var/run/nginx/_SERVER_"

case “$1” in restart|force-reload|start) # Aufräumen bevor wir aufbauen $0 stop 2>/dev/null

$0 stop

    for chrootdir in /var/nginx/\*; do
        # Nur in Ordnern mit eigenem /tmp Verzeichnis als Markierung einen Chroot aufsetzen
        if \[ -d "${chrootdir}/tmp" \]; then
            # Berechtigungen von /tmp korrigieren
            chmod 777 "${chrootdir}/tmp"
            chmod +t "${chrootdir}/tmp"

            echo "Setting up ${chrootdir}..."
            for f in $CHROOT\_FILES; do
                if \[ -d "$f" \]; then
                    # $f ist ein Pfad zu einem Verzeichnis
                    mkdir -p "${chrootdir}${f}"
                    mount --bind -o ro "${f}" "${chrootdir}${f}"
                else
                    # $f ist ein Pfad zu einer Datei
                    mkdir -p "${chrootdir}$(dirname "${f}")"
                    touch "${chrootdir}${f}"
                    mount --bind -o ro "${f}" "${chrootdir}${f}"
                fi
            done
            # willst du den Cache-Ordner auf eine existierende RAM-Disk mounten,
            # kommentiere diesen Bereich aus und setze CACHE\_FOLDER auf den 
            # entsprechenden Zielordner

for c in $CACHE_FOLDER; do

# f enthält _SERVER_, was als Platzhalter dient

server=$(basename ${chrootdir})

c=${c/_SERVER_/$server}

if [ ! -d “${c}” ]; then

mkdir -p ${c}

fi

echo “Setting up cache in $c”

mkdir -p “${chrootdir}/cache”

mount –bind -o rw “${c}” “${chrootdir}/cache”

done

        fi
    done
;;

stop)
    for chrootdir in /var/nginx/\*; do

        if \[ -d "${chrootdir}/tmp" \]; then
            echo "Destructing ${chrootdir}..."
            for f in $CHROOT\_FILES; do
                umount "${chrootdir}${f}"
                if \[ -d "${chrootdir}${f}" \] && \[ ! $(ls -A "${chrootdir}${f}") \]; then
                    # Leerer Ordner, kann man löschen
                    rmdir "${chrootdir}${f}"
                elif \[ -f "${chrootdir}${f}" \]; then
                    # Datei, kann man löschen
                    rm "${chrootdir}${f}"
                fi
            done
        fi
    done
;;

\*)
    echo "Usage: $N {start|stop|restart|force-reload}" >&2
    exit 1
;;

esac

exit 0

Soll das Script bei jedem Systemstart geladen werden, legst du es unter /etc/init.d/php-fpm-chroot-setup ab und setzt das Ausführen-Flag (chmod +x). Danach wird es für den Systemstart vorgemerkt:

update-rc.d php-fpm-chroot-setup defaults

Die globale Konfiguration für nginx

Meine globale Konfiguration (für gewöhnlich unter /etc/nginx/nginx.conf) für nginx sieht folgendermaßen aus. Die Standard-Parameter von nginx werde ich nicht näher erläutern sondern nur kurz inline kommentieren. Wichtige Anpassungen erkläre ich darunter etwas genauer.

# in welcher Datei soll die Programm-Id abgelegt werden:
pid /run/nginx.pid;
# der Benutzer, unter dem nginx gestartet wird:
user www-data;
# Beschreibung siehe unten
worker_processes 8;

events {
		# Beschreibung siehe unten
		worker_connections 768;
		# soll jeder Worker mehr als eine Verbindung gleichzeitig annehmen? Standard: off
		multi_accept off;
}


http {
		##
		# Basic Settings
		# Beschreibung siehe unten
		##

		sendfile on;
		tcp_nopush on;
		tcp_nodelay on;

		client_body_timeout 12;
		client_header_timeout 12;
		keepalive_timeout 65;
    send_timeout 10;

		types_hash_max_size 2048;
		server_names_hash_bucket_size 128;
    # server_name_in_redirect off;

		limit_req_zone $binary_remote_addr zone=one:10m rate=5r/s;

		include /etc/nginx/mime.types;
		default_type application/octet-stream;

		# Verhindere, dass nginx auf Fehlerseiten die Versionsnummer mitliefert
		# Frei nach dem Motto "securtiy through obscurity"
		server_tokens off;

		##
		# Logging Settings
		# Beschreibung siehe unten
		##

		log_format cache_status '[$time_local] "$request"  $upstream_cache_status';

		log_format main '$time_local|$ip_anonymized|$remote_user|'
				'"$request" $status $body_bytes_sent '
				'"$http_referer" "$http_user_agent" $upstream_cache_status';

		map $remote_addr $ip_anonym1 {
		    default 0.0.0;
		    "~(?P<ip>(\d+)\.(\d+)\.(\d+))\.\d+" $ip;
		    "~(?P<ip>[^:]+:[^:]+):" $ip;
		}

		map $remote_addr $ip_anonym2 {
		    default .0;
		    "~(?P<ip>(\d+)\.(\d+)\.(\d+))\.\d+" .0;
		    "~(?P<ip>[^:]+:[^:]+):" ::;
		}

		map $ip_anonym1$ip_anonym2 $ip_anonymized {
		    default 0.0.0.0;
		    "~(?P<ip>.*)" $ip;
		}

		map $http_ignoreMe $log_this {
		    ~true 0;
		    default 1;
		}

		access_log /var/log/nginx/access.log main;
		error_log /var/log/nginx/error.log;

		##
		# SSL Settings
		# Beschreibung siehe unten
		##

		ssl_session_cache shared:SSL:5m;
		ssl_session_timeout 1h;
		add_header Strict-Transport-Security "max-age=15768000; includeSubDomains" always;

		ssl_protocols TLSv1 TLSv1.1 TLSv1.2; # Dropping SSLv3, ref: POODLE
		ssl_prefer_server_ciphers on;
	  ssl_ciphers ECDH+AESGCM:ECDH+AES256:ECDH+AES128:DHE+AES128:!ADH:!AECDH:!MD5;

		##
		# Cache
		# Beschreibung siehe unten
		#

		fastcgi_cache_key "$scheme$request_method$host$request_uri";
		add_header X-Cache $upstream_cache_status;

		##
		# Gzip Settings
		# Beschreibung siehe unten
		##

		gzip on;
		gzip_vary on;
		gzip_min_length 10240;
		gzip_proxied expired no-cache no-store private auth;
		gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml;
		gzip_disable "MSIE [1-6]\.";

		##
		# Virtual Host Configs
		# wo befinden sich die Einstellungen für die Server / virtual hosts?
		# welche Variante du nutzt, ist Geschmackssache und dir überlassen
		##

		include /etc/nginx/conf.d/*.conf;
	#	include /etc/nginx/sites-enabled/*.conf;

}

worker_processes - Natürlich kannst du nginx mit einem einzigen Prozess laufen lassen. Du kannst aber auch dafür sorgen, dass sich mehrere Prozesse um die Beantwortung der Anfragen kümmern. Es empfiehlt sich für jeden Prozessor-Kern einen Prozess zu starten. Mit dem Wert “auto” kümmert sich nginx selber darum. Mit 

grep processor /proc/cpuinfo | wc -l

findest du heraus, wieviele Kerne dein System hat, um diesen Wert manuell zu setzen.

worker_connections - Dieser Wert legt fest, wieviele Anfragen jeder einzelne worker process verarbeiten kann. Hat nginx also 8 simultane worker processes gestartet und ist dieser Wert  auf 1024 eingestellt, wird nginx insgesamt 8.192 Verbindungen gleichzeitig vertragen. Der Wert für diese Direktive wird allerdings durch die Anzahl gleichzeitiger offener Dateien für einen Prozess begrenzt. Diese erfährst du mit ulimit -n.

sendfile, tcp_nopush und tcp_nodelay - Jetzt geht es ein wenig ans Eingemachte. Diese Parameter können einerseits einen wichtigen Geschwindigkeitsgewinn bedeuten oder völlig sinnlos sein. Da mir aber kein negative Impact bekannt ist, möchte ich an der Stelle pauschal erwähnen, diesen Parameter zu aktivieren. Wenn ich mich hier irre, lasst mir gerne einen Kommentar dazu da. Sendfile optimiert die Art, wie auf eine angefragte Datei zugegriffen wird. Tcp_nopush sorgt dafür, dass die Antwort in einem Paket verschickt wird und tcp_nodelay schließlich vermeidet das Buffern von Daten die zum Versand bereit liegen. Planst du den Einsatz von Cache, solltest du unbedingt prüfen, wie sich diese Parameter dann auswirken, da ein Cache durchaus ein Kontraindikator sein kann!

client_body_timeout, client_header_timeout - Diese Parameter werden die tatsächliche Geschwindigkeit weniger beeinflussen, sondern nur dafür sorgen, dass der HTTP Fehler 408 (Request time out) schneller ausgeliefert wird.

keepalive_timeout und send_timeout - Diese Parameter machen vermutlich eher Sinn, wenn du mit wirklich vielen (organischen) Verbindungen konfrontiert wirst. Sie sorgen dafür, dass nicht genutzte Verbindungen schneller geschlossen werden und der Prozess so neue Anfragen annehmen kann.

limit_req_zone - Mit dieser Direktive legst du fest, wie viele Anfragen der Server innerhalb eines Zeitraums annimmt, bevor er mit einem Fehler antwortet. Als Indikator habe ich die IP-Adresse gewählt ($binary_remote_addr), mit $server_name lässt sich das Limit je Server einstellen. Mit zone lege ich einen Namen für diese Einstellung fest. So kann ich z.B. mehrer Zonen für beliebige Orte oder Ordner einrichten. 10m beschreibt die Größe des Speichers, in dem die IP-Adressen abgelegt werden. 10 MByte sollte für etwa 160.000 IP-Adressen reichen. Rate legt fest, wie viele Anfragen pro Sekunde erlaubt sind. Mit burst kann eine Warteschlange eingerichtet werden, die (hier) 20 Anfragen zurückstellt um sie dann abzuarbeiten.

server_names_hash_bucket_size - Damit kommst du unter Umständen in Berührung, wenn nginx dich mit der Fehlermeldung “could not build the server_names_hash, you should increase server_names_hash_bucket_size” begrüßt. Die Direktive beschreibt ihre Funktion eigentlich schon ganz gut: Die Größe des Buckets für die Hash-Werte der Server-Namen. Oder: Dein Server-Name ist zu groß und passt nicht in den Eimer.

Logging

An erster Stelle definiere ich meine eigenen Log-Templates main und cache_status. Beachte, dass ich die IP-Adresse nur anonymisiert übernehme. Dies übernimmt die map-Direktive, die per regulärem Ausdruck das letzte Tupel der IP-Adresse entfernt. Das ganze ist hier etwas genauer dokumentiert. Ebenfalls mit map lese ich einen HTTP-Header aus, um das Logging vom Client aus zu deaktivieren - warum ich das mache, ist hier beschrieben.

Schließlich lege ich mit access_log und error_log fest, an welchem Ort die Log-Files per default abgelegt werden. Das ändere ich später natürlich noch auf Server-Ebene.

Der Cache

In der globalen Konfig-Datei werde ich nur zwei Direktiven vorgeben, die für alle Server gleich sind. Mit der Direktive fastcgi_cache_key, lege ich fest, wie nginx die Cache-Keys erstellt. Hier sollte natürlich jeder Server unterscheidbar sein.

fastcgi_cache_key "$scheme$request_method$host$request_uri";

Außerdem soll jede Antwort einen Header enthalten, der den Cache-Status enthält. Mit der Variable upstream_cache_status kann z.B. ich so HIT, MISS oder EXPIRED übermitteln.

add_header X-Cache $upstream_cache_status;

Wie der Cache bei nginx funktioniert und auf den wichtigsten Parameter fastcgi_cache_path gehe ich im 2. Teil genauer ein.

Welches Dateisystem für den Cache - tempfs oder ramfs?

Der FastCGI-Cache ist dafür gedacht, die Auslieferung der PHP-Dateien zu beschleunigen. Es macht nämlich durchaus Sinn, eine PHP-Datei nicht jedes mal durch den PHP-Interpreter zu jagen, wenn sich am Inhalt nichts geändert hat. Dazu wird die “interpretierte” PHP-Datei einfach in einem Cache-Ordner abgelegt und bei Bedarf abgerufen. Dieser Ordner kann sich auf der Festplatte oder im Arbeitsspeicher befinden. Auf die Unterschiede gehe ich hier kurz ein:

Im Init-Script (siehe oben) wird dir ein großer, auskommentierter Block aufgefallen sein. Mein Setup ist darauf ausgelegt, dass der Cache auf der Festplatte abgelegt wird. Es ist aber wie gesagt auch möglich, eine RAM-Disk zu nutzen, wobei der Arbeitsspeicher als Ablage dient. Das ist in den meisten Fällen weitaus schneller ist als die Festplatte. Man unterscheidet zwischen zwei nutzbaren Dateisystemen: ramfs und tempfs.

Der Vorteil von ramfs ist, dass direkt der Arbeitsspeicher genutzt wird. Der Nachteil ist: Es gibt keine Größenbeschränkung. Mit den falschen Einstellungen kann man also ungewollt den Arbeitsspeicher volllaufen lassen. Bei tempfs kann zwar eine Obergrenze angegeben werden. Es kann aber sein, dass das Dateisystem selber eine Swap-Partition zum Zwischenspeichern nutzt (vor allem dann, wenn die vorgegeben Speichergrenze erreicht ist). Ein Test mit tempfs und normaler Festplatte hat bei mir ergeben, dass der Cache um den Faktor 10 langsamer wird. Aus diesem Grund ist der Bereich hier deaktiviert. Um das Thema kümmere ich mich also vielleicht an anderer Stelle noch mal

SSL

Natürlich gehört auch SSL zu meinem Server-Setup. Ich nutze dazu Let’s Encrypt in Verbindung mit dem certbot, da das so ziemlich den ganzen Prozess automatisiert. Der Parameter ssl_session_cache beschreibt, wie groß der Cache für Session-Caches ist. Der Standardwert von 5 MByte sollte hier völlig ausreichen und reicht für knapp 20.000 Sessions. Auch beim ssl_session_timeout kann der Standardwert übernommen werden. Nach 1 Stunde verfällt also die SSL-Session. Außerdem sorgen wir mit add_header Strict-Transport-Security dafür, dass nur Verbindungen über HTTPS aufgebaut werden können (HTTP Strict Transport Security, HSTS).

Schließlich solltest du über ssl_protocols die verwendeten SSL-Protokolle einschränken. Die meisten modernen Browser kommen mit TLS 1.2 schon ganz gut klar und seit August 2018 gibt es auch TLS 1.3. Ältere Versionen haben hier nichts mehr verloren, um z.B. Lücken wie Poodle keine Angriffsfläche zu bieten. Außerdem kannst du mit ssl_prefer_server_ciphers und ssl_ciphers festlegen, welche Verschlüsselungsmethoden akzeptiert werden sollen. Auch hier gibt es schwache und langsame Methoden. Mozilla bietet dafür übrigens ein Online-Tool an, dessen Einstellung ich für einen guten Kompromiss zwischen Kompatibilität und Sicherheit halte

GZIP - Kompression

Neben dem Cache ist Kompression eine sinnvolle Maßnahme um den Seitenaufbau noch etwas zu beschleunigen. Die Kompression aktivierst du mit - Überraschung - gzip on.

Mit gzip_vary sorgst du dafür, dass komprimierte und unkomprimierte Ressourcen gecached werden. Der Parameter gzip_min_length legt fest, wie groß eine Ressource mindestens sein muss, um komprimiert zu werden. Mit gzip_proxied sorgst du dafür, dass Anfragen von Proxies komprimierte Daten bekommen und gzip_types definiert die Ressourcen-Typen, die komprimiert werden. Und schließlich sorgen wir noch dafür, dass Anfragen vom alten Internet Explorer nicht komprimiert werden, da dieser damit nicht arbeiten kann: gzip_disable.

Das war es mit der Einrichtung von nginx. Weiter geht es im 2. Teil mit den Servern bzw. wie sie unter Apache genannt werden: virtual hosts.