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:
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:
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:
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.
; /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 = dynamicutrzymuje kilka procesów w gotowości i dokłada kolejne pod obciążeniem.ondemandoszczędza RAM na serwerach z wieloma rzadko odwiedzanymi stronami, ale pierwszy request po przerwie czeka na start procesu.staticma sens tylko wtedy, gdy serwer obsługuje jedną stronę i znasz jej ruch.pm.max_childrento 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ść10w przykładzie to tylko punkt startowy; policz własną.pm.max_requests = 500restartuje proces po obsłużeniu 500 żądań. To zabezpieczenie przed wyciekami pamięci we wtyczkach.request_slowlog_timeoutzapisuje 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:
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ę:
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.
; /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=1irevalidate_freq=60– OPcache co najwyżej raz na 60 sekund sprawdza, czy plik się zmienił. Kuszące jest ustawienievalidate_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.
sudo apt install redis-server
W /etc/redis/redis.conf ustaw:
bind 127.0.0.1 -::1
protected-mode yes
maxmemory 256mb
maxmemory-policy allkeys-lru
save ""
appendonly no
bindiprotected-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 ""iappendonly 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! */:
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:
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;.
# /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/*.phpstała polocation ~ \.php$, podrzucony do katalogu uploads skrypt PHP zostałby wykonany. Dlatego blokady są na górze.try_files $uri =404w lokacji PHP chroni przed przekazaniem do FPM nieistniejących plików.expires 30ddla 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, bocurl localhostczęsto łączy się przez IPv6.xmlrpc.phpblokujesz, jeśli nie używasz aplikacji mobilnej ani Jetpacka.
Sprawdź konfigurację, przeładuj serwer i zweryfikuj HTTP/2:
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_phpużyj PHP-FPM przezmod_proxy_fcgi(a2enmod proxy_fcgi setenvifia2enconf php8.4-fpm).mod_phpwymusza MPMprefork, 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 zpreforkHTTP/2 działa z poważnymi ograniczeniami: jedno żądanie naraz na połączenie.HTTP/2 włączasz przez
a2enmod http2i dyrektywęProtocols h2 http/1.1w vhoście SSL..htaccesskosztuje. PrzyAllowOverride AllApache przy każdym żądaniu szuka plików.htaccessw całej ścieżce katalogów. Jeśli masz dostęp do konfiguracji vhosta, przenieś reguły tam i ustawAllowOverride 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:
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ń:
wrk -t2 -c20 -d30s --latency https://example.com/
k6 pozwala napisać scenariusz w JavaScripcie, ustawić progi akceptacji i testować przez HTTP/2:
// 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)
}
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.
