Przejdź do treści
Wszystkie wpisy

1 października 20269 min czytania

Szybki WordPress na VPS: PHP-FPM, OPcache, Redis i HTTP/2

Cztery warstwy, które dają WordPressowi na VPS-ie najwięcej przy najmniejszym ryzyku: własna pula PHP-FPM, OPcache, Redis jako cache obiektów i Nginx z HTTP/2. Z konfiguracją, uzasadnieniem wartości i sposobem pomiaru.

Szybki WordPress na VPS: PHP-FPM, OPcache, Redis i HTTP/2

Domyślne ustawienia PHP i serwera WWW są dobrane tak, żeby działały wszędzie, a nie żeby działały szybko na konkretnym serwerze. Przy WordPressie albo sklepie WooCommerce na VPS-ie zaczynam od czterech warstw, które dają najwięcej przy najmniejszym ryzyku: puli PHP-FPM, OPcache, cache obiektów w Redisie i Nginx z HTTP/2. Przy każdej dyrektywie piszę, skąd się wzięła jej wartość i jak sprawdzić, czy zmiana coś dała.

Przykłady dotyczą Debiana 13 (PHP 8.4, Nginx 1.26) i Ubuntu 24.04 LTS (PHP 8.3, Nginx 1.24). Na Ubuntu 24.04 podmień 8.4 na 8.3 w ścieżkach i nazwach usług. Różnice dla Apache znajdziesz w osobnej sekcji.

Zasada numer jeden: najpierw zmierz

Bez pomiaru bazowego nie wiesz, czy optymalizacja pomogła, czy tylko „wydaje się szybciej”. Zanim cokolwiek zmienisz, zapisz czasy odpowiedzi dla kilku typowych adresów: strony głównej, wpisu, kategorii i – jeśli masz sklep – strony produktu.

Najprostsze narzędzie to curl z parametrem -w:

bash
curl -so /dev/null -w 'DNS: %{time_namelookup}s | TCP: %{time_connect}s | TLS: %{time_appconnect}s | TTFB: %{time_starttransfer}s | Total: %{time_total}s | HTTP/%{http_version}\n' https://example.com/

Najważniejszy jest TTFB (time_starttransfer) – czas do pierwszego bajtu odpowiedzi. To w nim widać pracę PHP i bazy danych. Pojedynczy pomiar niewiele mówi, więc powtórz go kilkanaście razy:

bash
for i in $(seq 1 20); do
  curl -so /dev/null -w '%{time_starttransfer}\n' https://example.com/
done | sort -n | awk '{a[NR]=$1} END {print "mediana TTFB:", a[int((NR+1)/2)] "s"}'

Mierz z innej maszyny niż serwer i jako niezalogowany użytkownik (zalogowani zwykle omijają cache). Zapisz wyniki – wrócisz do nich na końcu.

Krok 1: PHP-FPM z własną pulą

PHP-FPM to menedżer procesów, który trzyma gotowe do pracy procesy PHP. Nginx przekazuje im żądania przez FastCGI. Zainstaluj pakiety:

bash
sudo apt install php-fpm php-mysql php-redis php-curl php-xml php-mbstring php-zip php-gd php-intl

Zamiast edytować domyślną pulę www, utwórz osobną dla każdej strony, działającą jako osobny użytkownik systemowy. Włamanie na jedną stronę nie da wtedy dostępu do plików drugiej.

ini
; /etc/php/8.4/fpm/pool.d/example.conf
[example]
user = example
group = example
listen = /run/php/php8.4-fpm-example.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660

pm = dynamic
pm.max_children = 10
pm.start_servers = 3
pm.min_spare_servers = 2
pm.max_spare_servers = 5
pm.max_requests = 500

request_terminate_timeout = 60s
request_slowlog_timeout = 5s
slowlog = /var/log/php8.4-fpm-example-slow.log

pm.status_path = /fpm-status
ping.path = /fpm-ping

php_admin_value[memory_limit] = 256M
php_admin_value[upload_max_filesize] = 64M
php_admin_value[post_max_size] = 64M

Najważniejsze decyzje:

  • pm = dynamic utrzymuje kilka procesów w gotowości i dokłada kolejne pod obciążeniem. ondemand oszczędza RAM na serwerach z wieloma rzadko odwiedzanymi stronami, ale pierwszy request po przerwie czeka na start procesu. static ma sens tylko wtedy, gdy serwer obsługuje jedną stronę i znasz jej ruch.

  • pm.max_children to górny limit równoległych procesów – najważniejsza liczba w całym pliku. Za mała oznacza kolejkę żądań, za duża – wyczerpanie RAM i swap, co jest znacznie gorsze. Wartość 10 w przykładzie to tylko punkt startowy; policz własną.

  • pm.max_requests = 500 restartuje proces po obsłużeniu 500 żądań. To zabezpieczenie przed wyciekami pamięci we wtyczkach.

  • request_slowlog_timeout zapisuje stack trace każdego żądania dłuższego niż 5 sekund. Dzięki temu dowiesz się, która wtyczka spowalnia stronę.

Jak policzyć pm.max_children? Uruchom stronę z domyślną pulą, przeklikaj kilka cięższych podstron i sprawdź średnie zużycie pamięci przez proces:

bash
ps --no-headers -o rss -C php-fpm8.4 | awk '{s+=$1; n++} END {printf "%.1f MB na proces\n", s/n/1024}'

Następnie: pm.max_children = (RAM dostępny dla PHP) / (średni RSS procesu). „RAM dostępny dla PHP” to pamięć całkowita minus MySQL/MariaDB, Redis, Nginx, system i zapas. Wynik zaokrąglam w dół, bo za mały limit oznacza kolejkę, a za duży swap.

Sprawdź konfigurację i przeładuj usługę:

bash
sudo php-fpm8.4 -t && sudo systemctl reload php8.4-fpm

Status puli (pm.status_path) pokaże m.in. max children reached. Jeśli ta wartość rośnie, pula jest za mała albo PHP działa za wolno.

Krok 2: OPcache

PHP przy każdym żądaniu musi sparsować i skompilować pliki źródłowe. WordPress z kilkunastoma wtyczkami to tysiące plików. OPcache trzyma skompilowany kod bajtowy w pamięci współdzielonej, więc kompilacja odbywa się raz. W pakietach Debiana i Ubuntu OPcache jest włączony, ale z domyślnymi limitami, które przy większych instalacjach bywają za małe.

ini
; /etc/php/8.4/fpm/conf.d/99-opcache-tuning.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=1
opcache.revalidate_freq=60
  • memory_consumption (MB) – pamięć na kod bajtowy. Gdy się skończy, OPcache zaczyna odrzucać pliki albo się restartuje.

  • interned_strings_buffer (MB) – pamięć na powtarzające się ciągi znaków (nazwy klas, funkcji, kluczy tablic). Domyślna wartość bywa szybko zapełniana przez WordPressa z wtyczkami.

  • max_accelerated_files – limit liczby plików. PHP zaokrągla go w górę do najbliższej liczby pierwszej z wewnętrznej listy.

  • validate_timestamps=1 i revalidate_freq=60 – OPcache co najwyżej raz na 60 sekund sprawdza, czy plik się zmienił. Kuszące jest ustawienie validate_timestamps=0, ale wtedy PHP nie zobaczy żadnych zmian w plikach aż do przeładowania PHP-FPM. W WordPressie, który sam aktualizuje wtyczki z panelu, to przepis na dziwne błędy po aktualizacji. Wyłączaj walidację tylko wtedy, gdy wdrażasz kod wyłącznie z CI i przeładowujesz FPM przy każdym deployu.

JIT (dostępny od PHP 8.0) zostaw wyłączony – w typowym WordPressie czas upływa głównie na zapytaniach do bazy i operacjach I/O, a nie na obliczeniach.

Pułapka: OPcache w CLI i w FPM to dwa osobne światy. php -i | grep opcache pokazuje ustawienia CLI. Stan cache FPM sprawdzisz skryptem uruchomionym przez przeglądarkę (np. opcache_get_status(false)), który od razu po sprawdzeniu usuniesz z serwera.

Krok 3: Redis jako obiektowy cache

WordPress ma wbudowany cache obiektów (WP_Object_Cache), ale domyślnie żyje on tylko w trakcie jednego żądania. Z Redisem wyniki zapytań, opcje i transienty są współdzielone między żądaniami, więc baza dostaje mniej pracy. Najwięcej zyskują sklepy WooCommerce, fora i panel administracyjny.

bash
sudo apt install redis-server

W /etc/redis/redis.conf ustaw:

conf
bind 127.0.0.1 -::1
protected-mode yes
maxmemory 256mb
maxmemory-policy allkeys-lru
save ""
appendonly no
  • bind i protected-mode – Redis słucha tylko lokalnie. Wystawiony do internetu Redis bez hasła to klasyczny sposób na przejęcie serwera.

  • maxmemory + allkeys-lru – po zapełnieniu limitu Redis usuwa najdawniej używane klucze, zamiast odmawiać zapisu.

  • save "" i appendonly no – to cache, a nie baza danych. Po restarcie WordPress odbuduje go sam, a ty oszczędzasz operacje dyskowe.

Po restarcie (sudo systemctl restart redis-server) dodaj do wp-config.php, powyżej komentarza /* That's all, stop editing! */:

PHP
define( 'WP_REDIS_HOST', '127.0.0.1' );
define( 'WP_REDIS_PORT', 6379 );
define( 'WP_REDIS_PREFIX', 'example.com:' );
define( 'WP_REDIS_TIMEOUT', 1 );
define( 'WP_REDIS_READ_TIMEOUT', 1 );

Prefiks jest obowiązkowy, jeśli na jednym Redisie działa kilka stron – bez niego instalacje nadpisywałyby sobie nawzajem klucze. Samo połączenie realizuje drop-in object-cache.php; najwygodniej zainstalować go wtyczką Redis Object Cache i włączyć przez WP-CLI:

bash
wp plugin install redis-cache --activate
wp redis enable
wp redis status

Status powinien pokazać Connected. Czy cache pracuje, sprawdzisz przez redis-cli info stats (pola keyspace_hits i keyspace_misses) albo w Query Monitor.

Krok 4: Nginx i HTTP/2

HTTP/2 przesyła wiele plików równolegle w jednym połączeniu TLS. W Nginx od wersji 1.25.1 włączasz go osobną dyrektywą http2 on;. W starszych wersjach, np. 1.24 z Ubuntu 24.04, dopisujesz http2 do listen: listen 443 ssl http2;.

nginx
# /etc/nginx/sites-available/example.com.conf
server {
    listen 80;
    listen [::]:80;
    server_name example.com www.example.com;
    return 301 https://example.com$request_uri;
}

server {
    listen 443 ssl;
    listen [::]:443 ssl;
    http2 on;                         # Nginx >= 1.25.1; starsze: listen 443 ssl http2;
    server_name example.com;

    root /var/www/example.com;
    index index.php;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_session_cache   shared:SSL:10m;   # wznowienia sesji TLS bez pełnego handshake’u
    ssl_session_timeout 1d;

    client_max_body_size 64m;             # musi pasować do upload_max_filesize w PHP

    gzip on;
    gzip_vary on;
    gzip_comp_level 5;
    gzip_min_length 1024;
    gzip_types text/css application/javascript application/json image/svg+xml application/xml text/xml;

    location / {
        try_files $uri $uri/ /index.php?$args;
    }

    # reguły regex są sprawdzane po kolei – blokady muszą być przed \.php$
    location = /xmlrpc.php { deny all; }
    location ~ /\.(?!well-known) { deny all; }
    location ~* ^/wp-content/uploads/.*\.php$ { deny all; }

    location ~* \.(?:css|js|mjs|woff2?|svg|png|jpe?g|gif|webp|avif|ico)$ {
        expires 30d;
        access_log off;
        try_files $uri =404;
    }

    location ~ ^/(fpm-status|fpm-ping)$ {
        allow 127.0.0.1;
        allow ::1;
        deny all;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_pass unix:/run/php/php8.4-fpm-example.sock;
    }

    location ~ \.php$ {
        try_files $uri =404;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_pass unix:/run/php/php8.4-fpm-example.sock;
        fastcgi_buffer_size 32k;
        fastcgi_buffers 16 16k;
        fastcgi_read_timeout 60s;
    }

}

Kilka rzeczy wartych omówienia:

  • Kolejność reguł regex ma znaczenie. Nginx sprawdza lokacje z wyrażeniami regularnymi po kolei i wybiera pierwszą pasującą. Gdyby blokada /wp-content/uploads/*.php stała po location ~ \.php$, podrzucony do katalogu uploads skrypt PHP zostałby wykonany. Dlatego blokady są na górze.

  • try_files $uri =404 w lokacji PHP chroni przed przekazaniem do FPM nieistniejących plików.

  • expires 30d dla zasobów statycznych jest bezpieczne, bo WordPress dokleja do adresów CSS i JS parametr ?ver=, który zmienia się po aktualizacji.

  • Status FPM jest dostępny tylko z localhosta. Pamiętaj o allow ::1, bo curl localhost często łączy się przez IPv6.

  • xmlrpc.php blokujesz, jeśli nie używasz aplikacji mobilnej ani Jetpacka.

Sprawdź konfigurację, przeładuj serwer i zweryfikuj HTTP/2:

bash
sudo nginx -t && sudo systemctl reload nginx
curl -sI https://example.com/ | head -1          # oczekiwane: HTTP/2 200
curl -s -D - -o /dev/null --compressed https://example.com/ | grep -i content-encoding

Różnice przy Apache

Jeśli zostajesz przy Apache, zasady są te same, ale mechanizmy inne:

  • Zamiast mod_php użyj PHP-FPM przez mod_proxy_fcgi (a2enmod proxy_fcgi setenvif i a2enconf php8.4-fpm). mod_php wymusza MPM prefork, w którym każdy proces Apache ładuje całe PHP – nawet przy serwowaniu obrazka.

  • Przejdź na MPM event (a2dismod mpm_prefork, a2enmod mpm_event). Dokumentacja Apache zaznacza, że z prefork HTTP/2 działa z poważnymi ograniczeniami: jedno żądanie naraz na połączenie.

  • HTTP/2 włączasz przez a2enmod http2 i dyrektywę Protocols h2 http/1.1 w vhoście SSL.

  • .htaccess kosztuje. Przy AllowOverride All Apache przy każdym żądaniu szuka plików .htaccess w całej ścieżce katalogów. Jeśli masz dostęp do konfiguracji vhosta, przenieś reguły tam i ustaw AllowOverride None.

  • OPcache, pula FPM i Redis konfigurujesz identycznie jak wyżej.

Pomiar po zmianach

Powtórz pomiary curl z początku, a potem sprawdź zachowanie pod obciążeniem. Testuj swój serwer, nie cudzy, i nie odpalaj testów obciążeniowych na hostingu współdzielonym.

ab (z pakietu apache2-utils) jest najprostszy, ale obsługuje tylko HTTP/1.0 – dobry do szybkiego porównania „przed i po” warstwy PHP:

bash
ab -n 500 -c 10 -k https://example.com/

wrk generuje większe obciążenie przy HTTP/1.1 i podaje rozkład opóźnień:

bash
wrk -t2 -c20 -d30s --latency https://example.com/

k6 pozwala napisać scenariusz w JavaScripcie, ustawić progi akceptacji i testować przez HTTP/2:

JavaScript
// k6-wordpress.js
import http from 'k6/http'
import { check, sleep } from 'k6'

const BASE = __ENV.BASE_URL || 'https://example.com'

export const options = {
  stages: [
    { duration: '30s', target: 10 }, // rozgrzewka do 10 wirtualnych użytkowników
    { duration: '1m', target: 10 },  // stałe obciążenie
    { duration: '15s', target: 0 },
  ],
  thresholds: {
    http_req_failed: ['rate<0.01'],   // mniej niż 1% błędów
    http_req_duration: ['p(95)<800'], // 95% odpowiedzi poniżej 800 ms
  },
}

export default function () {
  const res = http.get(`${BASE}/`)
  check(res, {
    'status 200': (r) => r.status === 200,
    'HTTP/2': (r) => r.proto === 'HTTP/2.0',
  })
  sleep(1)
}
bash
BASE_URL=https://example.com k6 run k6-wordpress.js

Query Monitor (wtyczka) pokazuje wnętrze pojedynczego żądania: liczbę i czas zapytań SQL, najwolniejsze zapytania wraz z wtyczką, która je wywołała, trafienia obiektowego cache i zewnętrzne żądania HTTP. Włączaj ją tylko na czas diagnozy.

Wyniki zapisuję w prostej tabeli, zawsze dla tych samych adresów i o podobnej porze dnia:

Etap

Mediana TTFB

p95 (k6)

Zapytania SQL

Stan wyjściowy

…

…

…

+ PHP-FPM i OPcache

…

…

…

+ Redis

…

…

…

+ HTTP/2

…

…

…

Zmieniaj jedną warstwę naraz i mierz po każdej zmianie.

Co dalej

Żadna z tych zmian nie jest spektakularna sama w sobie. Dopiero razem, czyli pula PHP-FPM z policzonym pm.max_children, OPcache z wystarczającą pamięcią, Redis odciążający bazę i Nginx z HTTP/2, sprawiają, że przy zwykłym ruchu serwer przestaje być wąskim gardłem. Następny krok to cache całych stron, np. fastcgi_cache w Nginx, ale najpierw warto mieć pomiar wyjściowy i te cztery warstwy. Jeśli chcesz, żebym przejrzał serwer twojego sklepu albo strony, zajrzyj na strony sklepy WooCommerce i strony internetowe.

Napisz