Ein optimales Webserver-Setup für WordPress – Teil 1

Bisher war ich immer recht zufrieden mit der Geschwindigkeit meiner Seite. 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:

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:

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.

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):

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:

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.

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 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 – 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.

Nginx organsisiert den FastCGI-Cache in Zonen (für jeden Server) und cache keys, die grob gesagt eine PHP-Datei repräsentieren und natürlich eindeutig sein müssen. Im http-Bereich der globalen nginx-Konfiguration legst du zunächst mit dem Parameter fastcgi_cache_key fest, wie nginx diese cache keys zusammensetzen soll. Außerdem schicken wir im per add_header den Status des Caches mit. Die Variable upstream_cache_status kann z.B. in HIT, MISS oder EXPIRED aufgelöst werden.

Die restliche Einstellung findet für jeden Host individuell statt und ist dort entsprechend beschrieben!

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.

2 Kommentare

  1. Arno Nüm sagt:

    Super geschrieben. Kurze Frage, wann ist Deine Geburtstagsfeier?

    1. nicky sagt:

      Hallo Arno

      vielen Dank für dein Feedback. Zu deiner Frage: Ja.

      Beste Grüße
      Nicky

Start the Discussion!

Deine E-Mail-Adresse wird nicht veröffentlicht. Erforderliche Felder sind mit * markiert.

Diese Website verwendet Akismet, um Spam zu reduzieren. Erfahre mehr darüber, wie deine Kommentardaten verarbeitet werden.