Speed! Moaar Speed! – WordPress mit Nginx + fastcgi_cache + optional Domain Mapping

WordPress ohne Caching ist langsam – und wer will schon eine langsame Website haben? Nicht nur die Nutzer sind genervt, auch Google, und das ist dann schlecht für die Suchmaschinenplatzierung, also ist es auch Bestandteil von SEO. Also – was tun?

Der klassische Weg ist wohl ganz klar W3 Total Cache oder WP Super Cache. Damit kann man zwar schöne Ladezeiten erreichen, aber es geht noch schneller und mit mehr Features. Dieses Tutorial wird sich zunächst einmal nur das Caching für anonyme Besucher behandeln (und Domain Mapping sowie SSL im Backend beachten, was ein recht perfektes WordPress Setup ergibt), weitere Techniken für das Beschleunigen von WordPress selbst wird es in einem weiteren Post geben. Hier das Ergebnis, lediglich Piwik verhindert, dass dort nicht 98 – 99 % steht:

Pingdom More Speed

Voraussetzungen für dieses Tutorial ist zwingend ein eigener (virtueller) Server, am besten ohne Plesk oder dergleichen. Außerdem sind Linux-Konsolen-Basiskenntnisse sehr hilfreich – aber die braucht man eh als Server Admin. In diesem Tutorial wird Ubuntu 12.04 Server verwendet, in anderen Distributionen müssen die Befehle ggf. entsprechend angepasst werden.

Einrichtung von nginx

Zunächst einmal die Einrichtung von Nginx. Um Caching zu ermöglichen, brauchen wir eine Nginx-Version mit eincompiliertem fastcgi_cache. Dies ist leider nicht bei dem normalen nginx Paket der Fall, jedoch aber bei nginx-naxsi. Dies installieren wir mit sudo apt-get install nginx-naxsi.

Hierzu sind im Wiki dieses äusserst schnellen Webservers vollständige Konfigurationdateien hinterlegt. Zu beachten ist, dass alle rewrite Regeln in der Konfiguration sind, es gibt keine .htaccess bei Nginx. Für Domain Mapping und SSL im Backend müssen wir diese noch ein wenig modifizieren, das sähe dann so aus:

Datei /etc/nginx/nginx.conf

[...]

http {

types_hash_max_size 2048;
# server_tokens off;
map_hash_bucket_size 64;

server_names_hash_bucket_size 64;
client_max_body_size 128M;

[...]

fastcgi_cache_path /var/run/nginx-cache levels=1:2 keys_zone=WORDPRESS:256m max_size=512m inactive=60m;
fastcgi_cache_key "$scheme$request_method$host$request_uri";
fastcgi_cache_use_stale error timeout invalid_header http_500;

include /etc/nginx/templates/blogid;

}

Bei dieser Konfigurationsdatei habe ich mal alles rausgeworfen was nicht relevant für unsere Aufgabe ist. Einerseits müssen wir die Standard-Werte für Server und Maps erhöhen, das geschieht im ersten Abschnitt. Andererseits definieren wir einen maximal 512 MB großen Cache für WordPress. Die Zeile mit fastcgi_cache_use_stale ist dabei ein sehr spannendes Feature; sie sorgt dafür, dass Nginx bei einem Error 500 z.B. durch ein fehlerhaftes PHP Script in WordPress trotzdem eine Seite ausliefert – die gecachte nämlich, auch dann, wenn der Cache eigentlich veraltet ist. Das ist einer der Zusatzfeatures von fastcgi_cache im Vergleich zu den WordPress-internen Caching-Methoden. Außerdem wird eine Map mit den Blog IDs inkludiert (siehe nächste Datei).

Datei /etc/nginx/templates/blogid

map $http_host $blogid {
default -999;
include /srv/www/wordpress/httpdocs/wp-content/uploads/nginx-helper/map.conf;
}

Hier wird die abhängig von der Domain die Blog ID zugeordnet. Das ist wichtig für die Auswahl des richtigen Ordners in /wp-content/blogs.dir/ bei einer Installation mit aktivierter Multisite-Funktionalität (und damit wichtig für Domain Mapping). Die Datei /srv/www/wordpress/httpdocs/wp-content/uploads/nginx-helper/map.conf wird durch das nginx-helper Plugin generiert, auf dieses komme ich später noch zu sprechen.

Datei /etc/nginx/sites-available/org.sectio-aurea

server {
server_name sectio-aurea.org www.sectio-aurea.org;
access_log /var/log/nginx/org.sectio-aurea.access.log;
error_log /var/log/nginx/org.sectio-aurea.error.log;
include /etc/nginx/templates/wordpress;

access_log /var/log/nginx/cache.log cache;
}
server {
server_name sectio-aurea.org;

access_log /var/log/nginx/org.sectio-aurea.access.log;
error_log /var/log/nginx/org.sectio-aurea.error.log;

ssl on;
ssl_certificate /etc/ssl/private/org.sectio-aurea.chain.complete;
ssl_certificate_key /etc/ssl/private/org.sectio-aurea.key;

ssl_session_timeout 5m;

ssl_protocols SSLv3 TLSv1;
ssl_ciphers ALL:!ADH:!EXPORT56:RC4+RSA:+HIGH:+MEDIUM:+LOW:+SSLv3:+EXP;
ssl_prefer_server_ciphers on;

include /etc/nginx/templates/wordpress-ssl;
}

Diese jeweiligen Parameter dürfte recht selbsterklärend sein. Interessant ist die Funktion include, da wir dort alle nicht domainabhängigen Konfigurationsdateien zentral gespeichert werden können. Das macht insbesondere bei großen WordPress-Setups mit vielen Domains sehr viel Sinn. Datei /etc/nginx/templates/wordpress

listen 80;
root /srv/www/wordpress/httpdocs;
index index.php;
client_max_body_size 128M;
set $skip_cache 0;
if ($request_method = POST) {
  set $skip_cache 1;
}
if ($request_uri ~* "(/wp-admin/|/xmlrpc.php|/wp-(app|cron|login|register|mail).php|wp-.*.php|/feed/|index.php|wp-comments-popup.php|wp-links-opml.php|wp-locations.php|sitemap(_index)?.xml|[a-z0-9_-]+-sitemap([0-9]+)?.xml)") {
  set $skip_cache 1;
}
if ($http_cookie ~* "comment_author|wordpress_[a-f0-9]+|wp-postpass|wordpress_no_cache|wordpress_logged_in") {
  set $skip_cache 1;
}
location / {
  try_files $uri $uri/ /index.php?q=$uri&$args;
}
location ~ ^/files/(.*)$ {
  try_files /wp-content/blogs.dir/$blogid/$uri /wp-includes/ms-files.php?file=$1 ;
  access_log off;
  log_not_found off;
  expires max;
}
location ^~ /wp-content/blogs.dir {
  internal; alias /srv/www/wordpress/httpdocs/wp-content/blogs.dir ;
  access_log off;
  log_not_found off;
  expires max;
}
if (!-e $request_filename) {
  rewrite /wp-admin$ $scheme://$host$uri/ permanent;
}
location ~* ^.+\.(ogg|ogv|svg|svgz|eot|otf|woff|mp4|ttf|rss|atom|jpg|jpeg|gif|png|ico|zip|tgz|gz|rar|bz2|doc|xls|exe|ppt|tar|mid|midi|wav|bmp|rtf)$ {
  access_log off;
  log_not_found off;
  expires max;
}
location ~ \.php$ {
  location ~ /wp-(admin|login) {
    return 301 https://$host$request_uri;
  }
  try_files $uri = 404;
  fastcgi_index index.php;
  fastcgi_pass unix:/var/run/php5-wordpress.sock;
  include fastcgi_params; fastcgi_split_path_info ^(.+\.php)(/.+)$;
  fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
  fastcgi_param SCRIPT_NAME $fastcgi_script_name;
  fastcgi_cache_bypass $skip_cache;
  fastcgi_no_cache $skip_cache;
  fastcgi_cache WORDPRESS;
  fastcgi_cache_valid 60m;
}
location ~ /purge(/.*) {
  fastcgi_cache_purge WORDPRESS "$scheme$request_method$host$1";
}
location = /favicon.ico {
  log_not_found off;
  access_log off;
}
location = /robots.txt {
  access_log off;
  log_not_found off;
}
location ~ /\. {
  deny all;
  access_log off;
  log_not_found off;
}
location ~* /(?:uploads|files)/.*\.php$ {
  deny all;
}

Hier sind schon einige Zeilen mit dabei, welche direkt mit dem Caching zu tun haben. Wir definieren am Anfang, dass alles gecached werden soll, und schalten den Cache dann z.B. für den Adminbereich oder bei der Anwesenheit von Cookies ab, weil er dort nichts bringen würde.

Interessant sind auch noch die rewriting Regeln, dort insbesondere try_files /wp-content/blogs.dir/$blogid/$uri /wp-includes/ms-files.php?file=$1 ; . Dort wird schon in der Nginx Konfiguration je nach URL der richtige Ordner im blogs.dir ausgewählt. Die Variable $blogid wurde in /etc/nginx/nginx.conf mit eingebunden.

Ein wichtiges Sicherheitsfeature kommt ganz am Ende noch dazu: es können keine PHP Dateien im upload / blogs.dir ausgeführt werden. Dies sind die einzigen beiden Orte, wo PHP Schreibrechte benötigt. Das heisst: es gibt keine Möglichkeit mehr, wie ein Angreifer Dateien auf den Webserver ablegen und ausführen kann. Entweder hat der Angreifer keine Schreibrechte – oder er kann die Dateien nur downloaden, nicht aber ausführen, womit er dann zusammengenommen wenig Unsinn machen kann (Stichwort: Remote Shell). Dieser Mechanismus ist nur ein Teil eines vernünftigen Sicherheitskonzeptes, aber ein sehr effektiver, da er das Ausnutzen der allermeisten Lücken in Themes oder Plugins unterbindet.

Ein anderes Sicherheitsfeature ist ein verschlüsseltes Backend. Dies realisiere ich mit kostenlosen StartSSL Zertifikaten und alles über eine IP mit SNI. Leider werden sowohl StartSSL als auch SNI nicht von allen Browsern unterstützt, so dass die Seite selbst ohne Verschlüsselung ausgeliefert werden sollte, ansonsten bekommen manche Nutzer hässliche Fehlermeldungen. Dies erfordert an mehreren Punkten Nacharbeit.

Außerdem haben wir noch die /purge/ Zeile, welche wir benötigen, um z.B. bei einem neuen Post den Cache zu löschen.

/etc/nginx/templates/wordpress-ssl

listen 31.172.41.1:443;

root /srv/www/wordpress/httpdocs;

index index.php;
client_max_body_size 128M;

set $skip_cache 0;

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

if ($request_uri ~* "(/wp-admin/|/xmlrpc.php|/wp-(app|cron|login|register|mail).php|wp-.*.php|/feed/|index.php|wp-comments-popup.php|wp-links-opml.php|wp-locations.php|sitemap(_index)?.xml|[a-z0-9_-]+-sitemap([0-9]+)?.xml)") {
set $skip_cache 1;
}

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

location ~ ^/files/(.*)$ {
try_files /wp-content/blogs.dir/$blogid/$uri /wp-includes/ms-files.php?file=$1 ;
access_log off;
log_not_found off;
expires max;
}

location ^~ /blogs.dir {
internal;
alias /srv/www/wordpress/httpdocs/wp-content/blogs.dir ;
access_log off;
log_not_found off;
expires max;
}

if (!-e $request_filename) {
rewrite /wp-admin$ $scheme://$host$uri/ permanent;
}

location ~* ^.+\.(ogg|ogv|svg|svgz|eot|otf|woff|mp4|ttf|rss|atom|jpg|jpeg|gif|png|ico|zip|tgz|gz|rar|bz2|doc|xls|exe|ppt|tar|mid|midi|wav|bmp|rtf)$ {
access_log off;
log_not_found off;
expires max;
}

location ~ /wp-(admin|login|includes|content) {
try_files $uri $uri/ /index.php?q=$uri&$args;
location ~ \.php$ {
try_files $uri = 404;
fastcgi_index index.php;
fastcgi_pass unix:/var/run/php5-wordpress.sock;
include fastcgi_params;

fastcgi_split_path_info ^(.+\.php)(/.+)$;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param SCRIPT_NAME $fastcgi_script_name;

fastcgi_read_timeout 120;
fastcgi_cache_bypass $skip_cache;
fastcgi_no_cache $skip_cache;
fastcgi_cache WORDPRESS;
fastcgi_cache_valid 60m;
}
}

location / {
return 301 http://$host$request_uri;
}

location ~ /purge(/.*) {
fastcgi_cache_purge WORDPRESS "$scheme$request_method$host$1";
}

location = /favicon.ico {
log_not_found off;
access_log off;
}

location = /robots.txt {
access_log off;
log_not_found off;
}

location ~ /\. {
deny all;
access_log off;
log_not_found off;
}

Diese Zeilen kennen wir bereits weitestgehend aus dem unverschlüsselten Template. Interessant ist hier höchstens noch die Weiterleitung auf die unverschlüsselte Seite, wenn wir uns nicht im Backend befinden.

Einrichten von PHP

Damit wäre Nginx jetzt konfiguriert. Nun brauchen wir noch PHP. Um genau zu sein php-fpm. Durch dieses Setup bekommen wir gleich die Funktionalität, die beim Apache mit suexec bereitgestellt wird – nur komfortabler. Installieren tun wir PHP mit sudo apt-get install php-fpm. Jede Websoftware braucht eine eigene Konfigurastionsdatei, also begeben wir uns in den Ordner /etc/php5/fpm/pool.d mit cd /etc/php5/fpm/pool.d. Dann kopieren wir die default.conf: cp default.conf wordpress.conf. Diese passen wir nun an: Die relevanten Zeilen sind folgende:

[wordpress] #statt [default]

user = wordpress
group = wordpress

listen = /var/run/php5-wordpress.sock

Den Socket /var/run/php5-wordpress.sock kennen wir schon aus der Nginx-Konfiguration. Natürlich braucht es auch einen Systemuser und eine Systemgruppe namens wordpress – und natürlich sollten die Dateien der WordPressinstallation mit Ausnahme vom upload / blogs.dir Ordner nicht dem Systemuser wordpress gehören, damit PHP nirgends Schreibrechte hat wo es das nicht dringend braucht. Das war alles – so leicht ist die PHP-Bereitstellen und die suexec-Funktionalität mit Nginx.

Einrichtung von WordPress

Nun ist alles bereit, um WordPress anzupassen. Ich gehe hier nicht auf die Erstellung eines Blog-Netzwerkes oder die Einrichtung von Domain Mapping ein, dazu gibt es genug Tutorials. Aber WordPress braucht einige kleine Anpassungen, damit es gut mit dem Nginx Caching funktioniert.

Einerseits brauchen wir das nginx-helper Plugin. Dieses liefert uns Unterstützung für das Domain Mapping (Stichwort blogid von weiter oben) und Unterstützung beim Cache leeren. All diese Features aktivieren wir in der Admin-Oberfläche der Netzwerkverwaltung. Das Plugin weist einen darauf hin, wenn Ordner fehlen, Rechte neu zu vergeben sind etc, also genau die Fehlermeldungen lesen!

Das verschlüsselte Backend macht gleich an zwei Stellen Probleme. Einerseits werden die gecachten Dateien nicht immer korrekt gelöscht, da der Löschbefehl ja aus dem verschlüsselten Backend ausgeht. Er versucht also verschlüsselte gecachte Seiten zu löschen – gecached wurden aber unverschlüsselte Seiten. Dies löst man, indem man (aber nur wenn das Admin Interface auch wirklich verschlüsselt ist) in der Datei /wp-content/plugins/nginx-helper/purger.php in Zeile 204 ganz an den Anfang der Funktion purgeUrl folgende zwei Zeilen ergänzen:

if (substr($url, 0, 5) == 'https')
$url = 'http' . substr($url, 5);

Damit löschen wir immer die unverschlüsselten Seiten. Verschlüsselte Seiten werden ja eh nicht gecached, da nur das Backend verschlüsselt wird – und das wird nicht gecached.

[UPDATE] Den gesamten Cache zu löschen funktioniert leider auch nicht, wenn man WordPress PHP (siehe PHP-FPM Einrichtung) unter einem anderen User laufen lässt wie nginx. Letzterer läuft unter www-data, ersteres unter wordpress, somit kann PHP nicht wie im Plugin nginx-helper geplant auf Dateiebene alle Cahce-Files löschen. Um dies zu beheben, müssen wir manuell alle Purge-Befehle aufrufen. Dies ist in nginx-helper bereits vorbereitet, daher müssen wir die erste Zeile der Funktion function true_purge_all() in derDatei /wp-content/plugins/nginx-helper/purger.php ersetzen durch $this->purge_them_all(); . Voila, purge all funktioniert.[/UPDATE]

Ein zweites nerviges Problem ist, dass der Editor von WordPress im Backend dann bei internen Links bzw. bei Medien wie Bildern auch auf die verschlüsselten Medien verlinkt. Das unterbinden wir durch ein kleines Zusatzplugin, welches ich ssl-url-fix getauft habe und aus einer einzigen PHP-Datei namens /wp-content/plugins/nginx-helper/ssl-url-fix/ssl-url-fix.php besteht:

<?php
/*
Plugin Name: SSL URL Fix
Plugin URI: https://binary-butterfly.de/
Description: SSL URL Fix
Version: 1.0
Author: Ernesto Ruge
Author URI: https://binary-butterfly.de/
*/
function fix_ssl_url_save($content) {
$host = substr(get_site_url(), is_ssl() ? 8 : 7);

$content = str_replace('https://' . $host, 'http://' . $host, $content);

return($content);
}
add_filter( 'content_save_pre', 'fix_ssl_url_save', 999, 1);
function fix_ssl_url_load($content, $uid) {
$host = substr(get_site_url(), is_ssl() ? 8 : 7);

$content = str_replace('http://' . $host, 'https://' . $host, $content);

return($content);
}
add_filter( 'content_edit_pre', 'fix_ssl_url_load', 10, 2 );
?>

Dies hat nichts direkt mit dem Caching zu tun, aber es nervt um so mehr, wenn einige Besucher die Medien nicht sehen können, daher hier die Erwähnung.

Resultat: Highspeed-Wordpress

Ansonsten habe ich eine gute Nachricht: wir sind fertig! Wir haben nun ein Setup, welches wirklich schnell und belastbar ist – in einem kurzen Test habe ich 2000 Requests auf die reine Index-Seite (ohne css / js Dateien) machen können, und die CPU meines eher kleinen virtuellen Servers hat sich dabei weiterhin gelangweilt. Außerdem wird die gecachte Index-Seite in zum Teil in unter 25 ms komplett ausgeliefert. Wir stoßen damit schnell an die Grenzen des Clients, die Website zu rendern – das verursacht bei meinen Seiten die größte Verzögerung im Lade-Wasserfall z.B. von Pingdom (hier ein exemplarisches Beispiel). Ausserdem wird Piwik sehr sehr deutlich sichtbar – aber das wird ja eh erst am Ende der Seite geladen. Die im Wasserfall erst am Ende geladenen Bilder aus der CSS Datei verbleiben auch im Browser Cache, so dass der Nutzer eine extrem schnelle Website vorfinden wird.

Einen Fallstrick gibt es noch, wozu ich aber keine generalisierte Anleitung zu geben kann: das Caching wird nur dann aktiv, wenn keine Cookies gesetzt sind. Manche (aber glücklicherweise sehr wenige) Plugins setzen aber Cookies, meist sinnloserweise. Bei mir war das beim Plugin Awesome Flickr Gallery der Fall. Dies muss man manuell herauspatchen. Um derartige Probleme herauszufinden hilft das Firefox-Plugin Web-Developer.

Ansonsten wünsche ich viel Spaß bei einem extrem schnellen WordPress! Ergänzungen und Korrekturen dieser Anleitung sind natürlich gerne gesehen. Für weitere Informationen kann ich auch noch dieses und dieses und dieses und dieses Tutorial empfehlen, auf diesen basiert in weiten Teilen meine Konfiguration – und da werden einige Details noch deutlich anschaulicher erklärt.

Als letztes noch die Kategorie bekannte Bugs: das Preview beim designen von Themens funktioniert in den meisten Browsern nicht, weil da auf einer verschlüsselten Seite eine unverschlüsselte angezeigt werden soll, was neuere Browser blocken. Wer da eine gute Idee hat wie man DAS lösen kann – immer her damit.

Eine Antwort zu “Speed! Moaar Speed! – WordPress mit Nginx + fastcgi_cache + optional Domain Mapping”

Schreibe einen Kommentar

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