To raczej nie jest podstawowy konfig i próżno szukać go na stronie WordPress’a, więc odradzam tę zabawę jeśli nie zna się zbyt dobrze nginx’a.

Ponieważ serwerek, na którym działa stronka to sprzęcik z Atomem 330 i mocy na CPU zbyt wiele nie ma to popularne pluginy (np. W3 Total Cache) potencjalnie zwiększające wydajność tak na prawdę zmulały stronkę jeszcze bardziej. Pluginów sprawdziłem kilka i każdorazowo efekt był podobny - stronka działała wolniej niż bez ich pomocy.

Druga sprawa to zwiększony ruch - w takiej konfiguracji już przy kilku osobach równocześnie przeglądających blog, serwerek zwyczajnie nie radził sobie z dynamicznym generowaniem strony.

Pomysł na rozwiązanie problemu z wydajnością polegał na odpaleniu cache’ującego reverse proxy przed właściwą stroną, z krótkim okresem ważności cache’u (max kilka sekund) tak by przy dużym obciążeniu strony serwować głównie z cache’u (tylko co pewien czas ktoś będzie miał niefart i będzie musiał zaczekać na wygenerowanie strony), przy czym komentarze i panel administracyjny działają z pominięciam cache’owania (czyli każdorazowo trafiają przez proxy do aplikacji).

Ważne jednak by osoba wysyłająca komentarz mogła wynik swojego działania zobaczyć od razu na stronie. Ponieważ komentarze wysyłane są metodą HTTP POST to w momencie odebrania takiego połączenia będzie ustawiane ciasteczko dezaktywujące cache dla danego połączenia na kilka sekund (do momentu jego wygaśnięcia).

Poniżej plik konfiguracyjny, który należy zapisać np. w: /etc/nginx/sites-available/wordpress

# na początek ustawiamy lokalizację dla cache'u
proxy_cache_path /var/cache/nginx/wordpress levels=1:2 keys_zone=WORDPRESS:10m inactive=24h max_size=100m;
# nie chcę stronki z www na początku więc cały ruch przekierowują
# na stronkę bez www
server {
  listen 10.0.1.2:80;
  server_name www.example.com;
  rewrite ^ http://example.com$request_uri? permanent;
}
# tutaj ma miejsce magia - główny host obsługujący stronkę
# to tak na prawdę cache'ujące proxy serwujące okresowo
# generowane pliki
server {
  listen 10.0.1.2:80 default;
  access_log /var/log/nginx/wordpress.access.log;
  server_name example.com;

  # ten rewrite przerzuca do panelu admina nawet jeśli
  # na końcu nie wpiszemy ukośnika
  # bez niego też to działa ale przekierowanie jest przetwarzane
  # przez skrypt stronki i działa wolniej
  rewrite ^/wp-admin$ /wp-admin/ last;

  # cache'ujemy tylko odpowiedzi 200 i przez 60s
  # (moja stronka nie obsługuje zbyt dużego ruchu i rzadko
  # jest modyfikowana - np. przez komentarze - więc 60s jest OK,
  # na bardziej aktywnych stronkach można się pokusić o ustawienie
  # 1~3s przez co stronka jest praktycznie dynamiczna ale mimo to
  # cache zapewni obsługę nawet kilku tys. zapytań na sekundę
  proxy_cache_valid 200 60s;

  # informacje dla backendu na jakiego host się wbijamy
  # i z jakiego "prawdziwego" IP
  proxy_set_header Host $http_host;
  proxy_set_header X-Real-IP $remote_addr;
  proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

  # możemy ukryć niektóre nagłówki np. by nie podpowiadać
  # z jakich pluginów korzystamy w WordPressie
  proxy_hide_header X-Powered-By;

  # kilka ustawień timeout'ów
  proxy_connect_timeout 60;
  proxy_read_timeout 120;
  proxy_send_timeout 120;

  # Ważne - poniższa opcja ustawia w jaki sposób generowane są
  # nazwy plików w cache'u,
  # dodając np. kolejne zmienne możemy zróżnicować cache dla
  # pewnych grup użytkowników
  proxy_cache_key "$scheme$request_method$host$request_uri";

  # domyślna lokalizacja
  location / {
    # ustawiamy domyślną wartość zmiennej
    set $no_cache "";

    # If non GET/HEAD, don't cache & mark user as uncacheable for 1 second via cookie
    # jeśli metoda inna niż GET/HEAD to oznacz usera przez cookie jako niecachowanego
    # na czas 60s (ustawiany poniżej)
    if ($request_method !~ ^(GET|HEAD)$) {
      set $no_cache "1";
    }

    # jeśli zalogowany to nie cache'uj
    if ($http_cookie ~* "comment_author_|wordpress_(?!test_cookie)|wp-postpass_" ) {
      set $no_cache "1";
    }

    # jeżeli któryś z wcześniejszych warunków jest spełniony
    # to ustawiamy cookie, które poinformuje nas by nie cachować
    # kolejnych zapytań
    # (z powodu "dziwnego" zachowania if w nginx'ie ustawienie
    # tego bezpośrednio we wcześniejszych warunkach nie działa)
    if ($no_cache = "1") {
      add_header Set-Cookie "_mcnc=1; Max-Age=61; Path=/";
      add_header X-Microcachable "0";
    }

    # jeśli cookie jest ustawione to pomijamy cache i serwujemy
    # świeżą treść
    if ($http_cookie ~* "_mcnc") {
      set $no_cache "1";
    }

    # dwie poniższe opcje zapewniają pominięcia cache'owania
    # w przypadku gdy wystąpi któryś z wcześniejszych warunków
    proxy_no_cache $no_cache;
    proxy_cache_bypass $no_cache;

    # Serwujemy cache jeśli strona jest obecnie odświeżana
    # lub wystąpi błąd
    proxy_cache_use_stale error timeout invalid_header updating
        http_500 http_502 http_503 http_504;

    # pliki większe niż 1M nie będą cache'owane
    proxy_max_temp_file_size 1M;
    # cache'ujemy tylko odpowiedzi 200 i przez 60s
    # (moja stronka nie obsługuje zbyt dużego ruchu i rzadko
    # jest modyfikowana - np. przez komentarze - więc 60s jest OK,
    # na bardziej aktywnych stronkach można się pokusić o ustawienie
    # 1~3s przez co stronka jest praktycznie dynamiczna ale mimo to
    # cache zapewni obsługę nawet kilku tys. zapytań na sekundę
    proxy_cache_valid 200 60s;
    # zmieniamy domyślny klucz cache'owania tak by uwzględniał
    # naszą zmienną
    proxy_cache_key "$scheme://$host$request_uri $no_cache";

    # wskazujemy konkretną lokalizację cache'u
    proxy_cache WORDPRESS;
    # podajemy lokalizację backendu (nie widziałem sensu
    # by udostępniać go na zewnętrznym adresie)
    proxy_pass http://127.0.0.1:81;

    # można ustawić dodatkowo cache'owanie strony w przeglądarce
    # (całkiem niezależnie od tego co będzie w cache'u na serwerze)
    expires 60s;
  }

  # dla panelu administracyjnego ustawiamy proxy bez cache'u
  location ~* wp\-(admin|login) {
    # dostęp do panelu administracyjnego dodatkowo chronimy
    # hasłem - po co?
    # bo w tym katalogu są różne fajne skrypty, w których już
    # nie raz znaleziono dziury
    auth_basic "Go Away";
    auth_basic_user_file htpasswd;

    # proxy bez cache'u
    proxy_pass http://127.0.0.1:81;
  }

  # statykę cache'ujemy mocniej niż treści dynamiczne
  # a czemu nie puszczam jej bezpośrednio? bo wyplute przez
  # backend zostaną skompresowane i w takiej postaci zachowają
  # się w cache'u - gdybym serwował je bezpośrednio to nginx
  # kompresowałby np. css'y/js'y przy każdym dostępie do nich
  location ~* \.(jpg|png|gif|jpeg|css|js|mp3|wav|swf|mov|doc|pdf|xls|ppt|docx|pptx|xlsx)$ {
    # cache'ujemy statykę przez 2 godziny
    proxy_cache_valid 200 120m;
    # dodatkowo ustawiamy długie cache'owanie w przeglądarkach
    expires 864000;

    # puszczamy wszystko w proxy + cache
    proxy_pass http://127.0.0.1:81;
    proxy_cache WORDPRESS;

    # wyłączam logowanie dostępu do statyki nawet w przypadku błędów
    # to mało istotne
    log_not_found off;
    access_log off;
  }
  # jeszcze inaczej ustawiam cache dla RSS'ów
  location ~* \/[^\/]+\/(feed|\.xml)\/? {
    # cache'ujemy RSS'y przez 45 minut
    if ($http_cookie ~* "comment_author_|wordpress_(?!test_cookie)|wp-postpass_" ) {
      set $no_cache 1;
    }

    proxy_cache_key "$scheme://$host$request_uri $no_cache";
    proxy_cache_valid 200 45m;
    proxy_cache MYSITE;
    proxy_pass http://127.0.0.1:81;
  }
}
# to teraz konfiguracja serwera serwującego treści dynamiczne
server {
  # nasłuchujemy lokalnie bo z zewnątrz strona dostępna
  # jest przez proxy
  listen 127.0.0.1:81;
  error_log /var/log/nginx/mysite.error.log;

  root /var/www/wordpress;
  index index.php;

  # logujemy prawdziwe IP dzięki odpowiednim nagłówkom
  # przesyłanym przez proxy
  set_real_ip_from 127.0.0.0/24;
  real_ip_header X-Real-IP;

  # tutaj praktycznie klasyka - z tym że zamiast na końcu
  # wskazywać index.php robię najpierw kilka rewrite'ów
  # np. dla przeniesionych stron, itp
  location / {
    try_files $uri $uri/ @rewrites;
  }

  location @rewrites {
    rewrite /main           http://example.com/about/? permanent;
    rewrite /projekty       http://example.com/category/projects/? permanent;
    rewrite /tag/gd         http://example.com? permanent;
    rewrite /category/hobby http://roman.com? permanent;
    rewrite ^ /index.php last;
  }

# na bardziej obleganych stronach limitowanie wyszukiwania może
# pomóc, ale to nie mój przypadek
# location /search { limit_req zone=mysitesearch burst=3 nodelay; rewrite ^ /index.php; }

  location ~* \.(?:ico|css|js|gif|jpe?g|png)$ {
    # cache'owanie atrybutów statycznych plików
    open_file_cache max=1000 inactive=120s;
    open_file_cache_valid 45s;
    open_file_cache_min_uses 2;
    open_file_cache_errors off;
    # maksymalne cache'owanie statyki w przeglądarkach
    expires max;
  }

  # no i na końcu obsługa skryptów php
  location ~* \.php$ {
    # albo plik istnieje i go serwujemy albo dajemy Forbidden
    try_files $uri =403;

    # kilka standardowych ustawień
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    # blokujemy możliwość wykonywania skryptów z katalogu upload
    # (nawet jeśli komuś uda się je wepchnąć)
    if ($uri !~ "^/wp-content/uploads/") {
      fastcgi_pass php-fastcgi;
    }

    # informacje dla proxy jak długo może cache'ować
    add_header Cache-Control "max-age:60, public";
    expires 60s;
  }

  # blokuję dostęp do plików zaczynających się od kropki
  location ~ /\. { access_log off; log_not_found off; deny all; }

  # wyłączam logowanie do nieistotnych plików
  location = /robots.txt { allow all; access_log off; log_not_found off; }
  location = /favicon.ico { access_log off; log_not_found off; }
}

Konfiguracja potrzebuje jednego folderu na cache, do którego dostęp do zapisu ma nginx (użytkownik na którym działa proces):

mkdir -p /var/cache/nginx/wordpress
chown -R www-data:www-data /var/cache/nginx/wordpress

A żeby zaczął działać trzeba go “włączyć” i przeładować nginx’a:

cd /etc/nginx/sites-available/
ln -s wordpress /etc/nginx/sites-enabled/wordpress
service nginx reload

Jedyna rzecz, której brakuje w tym configu to konfiguracja backend’u do PHP’a (u mnie nazwana php-fastcgi) - może kiedyś zrobię HOWTO o konfiguracji NGINX+PHP, ale na tą chwilę zakładam że sobie poradzisz 😃

Benchmark

Sprawdźmy jak to wygląda teraz:

ab -n 1000 -c 10 https://gagor.pl/
This is ApacheBench, Version 2.3 < $Revision: 1430300 $>
Copyright 1996 Adam Twiss, Zeus Technology Ltd, http://www.zeustech.net/
Licensed to The Apache Software Foundation, http://www.apache.org/

Benchmarking gagor.pl (be patient)
Completed 100 requests
Completed 200 requests
Completed 300 requests
Completed 400 requests
Completed 500 requests
Completed 600 requests
Completed 700 requests
Completed 800 requests
Completed 900 requests
Completed 1000 requests
Finished 1000 requests


Server Software:        nginx
Server Hostname:        gagor.pl
Server Port:            80

Document Path:          /
Document Length:        36263 bytes

Concurrency Level:      10
Time taken for tests:   8.716 seconds
Complete requests:      1000
Failed requests:        22
   (Connect: 0, Receive: 0, Length: 22, Exceptions: 0)
Write errors:           0
Total transferred:      36601094 bytes
HTML transferred:       36263088 bytes
Requests per second:    114.73 [#/sec] (mean)
Time per request:       87.159 [ms] (mean)
Time per request:       8.716 [ms] (mean, across all concurrent requests)
Transfer rate:          4100.91 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        1   16   7.1     15      55
Processing:    17   67  97.0     53     789
Waiting:        5   28  55.6     19     456
Total:         37   83  98.0     68     803

Percentage of the requests served within a certain time (ms)
  50%     68
  66%     74
  75%     78
  80%     80
  90%     87
  95%     95
  98%    665
  99%    755
 100%    803 (longest request)

Dla mnie bomba 😃

Pomysł na taki rodzaj cache’owania zaczerpnąłem stądexternal link .