Nginx – konfiguracja pod WordPress’a

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ąd.

Apache – reverse proxy z cache’owaniem

Ostatnio trafiłem na ciekawy problem, który wielokrotnie rozwiązywałem w nginx’ie ale tym razem musiałem zrobić to w Apache. Pewna stronka działa sobie na HTTPS’ie i chciałem by wszystkie powiązane z nią pliki były serwowane z jej adresu szyfrowanym połączeniem by nie pojawiały się w przeglądarce monity że “część ruchu nie jest szyfrowana”. Tyle że część potrzebnych plików była już obecnie serwowana na innym serwerze (w innej domenie) poprzez HTTP.

Mogłem albo skopiować te pliki i wykombinować jakiś mechanizm synchronizujący albo wykorzystać proxy + cache. Drugie rozwiązanie wydało mi się prostsze i ładniejsze 🙂

Na początek włączamy w Apache’m odpowiednie moduły:

a2enmod proxy
a2enmod proxy_http

Teraz przykładowa konfiguracja vhosta:

<VirtualHost *:80>
#podstawowa konfiguracja vhosta
ServerAdmin webmaster@example.com
ServerName www.example.com
ServerAlias example.com

# wyłącza działanie Apache'go jako przekazującego proxy (forwarding proxy)
ProxyRequests Off

# nie chciałem by błędy HTTP z backendowego serwera były przekazywane
# zamiast nich będą błędy Apache'go
ProxyErrorOverride On

# przykład prostego reverse proxy - wystarczą dwa poniższe polecenia
# ProxyPass proxuje ruch do danego serwera pod wskazanym URL'em
ProxyPass /stats/ http://google-anal.com/
# ProxyPassReverse modyfikuje nagłówki odpowiedzi ze zdalnego serwera
# tak by odpowiedź wyglądała na wysłaną z lokalnego serwera
ProxyPassReverse /stats/ http://google-anal.com/

# ciekawszy przykład proxowania z dodatkowymi ustawieniami cachowania
# najpierw konfiguracja cache'u dyskowego
<IfModule mod_disk_cache.c>
 # CacheRoot to wymagany parametr - ścieżka w której znajduje się cache
 CacheRoot /var/cache/apache2/mod_disk_cache/example

 # ponieważ przewidywałem że cache'owanych będzie kilka GB małych plików
 # to by listowanie ich w cache'u było efektywne warto wykorzystać wielo
 # poziomowe zagłębienie katalogów - wtedy na każdym poziomie, w danym
 # katalogu będzie stosunkowo mało plików, indeksy mniejsze, listowanie
 # szybsze
 CacheDirLevels 5
 CacheDirLength 2

 # teraz czas na konfiguracje cache'u
 <IfModule mod_cache.c>
  # pozwolę sobie na zignorowanie nagłówków Expires/Cache-Control
  # z aplikacji sam lepiej wiem że te pliki nie zmieniają się
  # zbyt często
  CacheIgnoreCacheControl On
  # pliki stracą ważność w cache'u po tygodniu
  CacheDefaultExpire 604800

  # ponieważ zasoby nie różnią się dla różnych zalogowanych użytkowników
  # zignoruję cookies'y
  CacheIgnoreHeaders Set-Cookie

  # nie chcę by proxy weryfikowało czy pojawiła się nowa wersja obrazka
  # bo raczej rzadko pojawiają się zmiany
  CacheIgnoreNoLastMod On

  # cache uruchamiany dla dwóch "subkatalogów"
  # http://example.com/images oraz http://example.com/files
  CacheEnable disk /images
  CacheEnable disk /files
 </IfModule>
</IfModule>

# włączamy mod_rewrite - będzie za chwilkę potrzebny
RewriteEngine on

# poprzednio do uruchomienia proxy wykorzystałem opcję ProxyPass,
# ale często potrzebujemy bardziej zaawansowanego przekierowania
# i wtedy warto wykorzystać mod_rewrite do modyfikacji URL'i w locie
# koniecznie z flagą [P]
RewriteRule ^/images/(.+)/(.+) http://10.0.0.100:8080/example-images/get.php?id=$1&width=$2 [P]
<Location /images>
 # jak powyżej - modyfikacja zwrotnych nagłówków
 ProxyPassReverse /example-images/

 # kilka nagłówków z backendu ukrywam by nie były przekazywane dalej
 Header unset Server
 Header unset Expires
 Header unset ETag
 # akurat nagłówek Via można wyłączyć w konfiguracji modułu proxy
 # ale ponieważ bywa przydatny przy debugowaniu to w niektórych miejscach
 # wolę gdy jest ustawiony - a w innych nie
 Header unset Via

 # ręczne ustawienie nagłówka Cache-Control i zezwolenie
 # na cachowanie przez inne proxy lub przeglądarki
 Header set Cache-Control "max-age=604800, public"
</Location>

# i drugie przekierowanie
RewriteRule ^/files/(.+)/(.+) http://10.0.0.100:8080/example-files/$1/$2 [P]
<Location /files>
 ProxyPassReverse /example-files/
 Header unset Server
 Header unset Via
 Header unset ETag
 Header unset Expires
 Header set Cache-Control "max-age=604800, public"
</Location>

# standardowa konfiguracja
DocumentRoot /var/www/example
<Directory /var/www/example>
 Options -Indexes FollowSymLinks Includes
 AllowOverride None
 Order allow,deny
 Allow from all
</Directory>

LogLevel warn
CustomLog /var/log/apache2/example_access.log combined
</VirtualHost>

X-Forwarded-For + mod_rpaf – logowanie rzeczywistych adresów IP na Apache za reverse proxy

Gdy już ustawimy reverse proxy przed Apache szybko można zauważyć że w logach zamiast adresów IP zdalnych użytkowników pojawia się tylko jeden adres: adres naszego proxy. Również z poziomu php’a jako adres klienta widać IP naszego proxy.

By poradzić sobie z tym problemem trzeba na serwerze reverse proxy ustawić przekazywanie informacji o oryginalnym adresie IP klienta w nagłówku X-Forwarded-For. W przypadku gdy reverse proxy działa na nginx’e wystarczy dodać taki wpis:

proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;

Teraz w trzeba zainstalować moduł mod_rpaf dla Apachego, który to zajmie się interpretacją nagłówka i podmianą IP proxy prawdziwym IP. Na Debianie wystarczy wpisać:

apt-get install libapache2-mod-rpaf

Po instalacji należy w pliku /etc/apache2/mods-available/rpaf.conf w opcji RPAFproxy_ips dopisać adresy IP serwerów proxy, np (oczywiście wpisz swoje adresy):

RPAFproxy_ips 127.0.0.1 10.24.0.5

Ważne by były to zaufane adresy IP – bo w tym miejscu pozwalamy by z tych lokalizacji możliwe było nadpisanie adresu IP np. w logach. Jeżeli pozwolimy na modyfikację adresów IP z zewnątrz to atakujący może wykorzystać to by nadpisać swój prawdziwy adres fałszywym.

Pozostało uruchomić moduł i zrestartować Apachego aby go załadował:

a2enmod rpaf
invoke-rc.d apache2 restart

Teraz zarówno w logach Apache’go jak i skryptach PHP’a będzie przekazywane rzeczywiste IP użytkownika.