Blog
Hosting // aktualizacja 20 września 2026

Przekroczony limit CPU na hostingu — jak znaleźć prawdziwą przyczynę

Hosting spowalnia konto, a alert nie mówi, która strona zawiniła. Komendy do analizy logów, trzy fałszywe tropy i przyczyna, która zwykle nie leży w kodzie.

Szafa serwerowa z czerwonym alarmem oznaczonym LIMIT i tabliczkami przekreślonych fałszywych tropów

Winowajcą okazały się skanery podatności: kilkaset żądań skumulowanych w jednej minucie, pod nieistniejące ścieżki — od /.env po /wp-admin/phpinfo.php. Drogie okazały się te bez kropki na początku nazwy, w rodzaju /wp-admin/phpinfo.php, /config/mail.php czy /actuator/loggers: każda z nich uruchamiała pełny bootstrap WordPressa. Odcina je blok reguł na początku .htaccess.

Najgłośniejsza kategoria w logu, czyli warianty /.env, nie kosztowała przy tym nic i nie kosztowała nigdy — odcinał je sam serwer, zanim jakakolwiek reguła zdążyła się do nich odnieść. Zmierzyłem to dopiero przy sprawdzaniu skuteczności blokad i opisuję niżej, bo to ta sama pomyłka co przy trzeciej pułapce: sygnał najbardziej rzucający się w oczy rzadko jest tym najdroższym.

Dojście do tego zajęło dwa dni, bo alert z hostingu nie mówi ani która witryna zawiniła, ani o której godzinie. Po drodze trzy tropy wyglądały przekonująco i wszystkie trzy okazały się nietrafione. Opisuję je razem z komendami, bo to właśnie one zjadają czas.

Samo zdanie o skanerach prowadzi jednak na manowce, jeśli zostawić je bez doprecyzowania. O koszcie decyduje liczba żądań, które doszły do interpretera PHP; wielkość serii nie mówi o nim nic. Sześćdziesiąt sześć żądań odrzuconych przez .htaccess przeszło u mnie bez śladu w statystykach, a dwadzieścia dwa odrzucone przy siedmiu uruchomionych i ubitych procesach PHP wysyciły cztery rdzenie. Po domknięciu bloku jedenaście kolejnych przelotów tego samego automatu nie uruchomiło ani jednego procesu PHP.

Co właściwie znaczy ten alert

Typowa treść wygląda tak:

You reached limit of 400 of total server CPU usage 6 times.
Your website was forced to load slower to reduce its CPU usage.

To komunikat CloudLinux, systemu izolującego konta na hostingu współdzielonym. Każde konto dostaje własny kontener LVE z limitami: CPU, pamięci, operacji dyskowych i liczby procesów.

Dwie rzeczy warto od razu wiedzieć.

400% to nie „czterokrotne przekroczenie". To równowartość czterech rdzeni. Limit 100% oznaczałby jeden rdzeń.

To nie jest blokada. Po przekroczeniu limitu procesy są spowalniane, aż zużycie spadnie. Strona działa, tylko wolniej. Dlatego problem potrafi trwać tygodniami, zanim ktokolwiek zareaguje.

Limit dotyczy całego konta, nie pojedynczej domeny. Przy kilkunastu witrynach na jednym koncie alert nie wskazuje winowajcy.

Zacznij od panelu, nie od konsoli

Pierwszy odruch to zalogować się przez SSH i sprawdzić zużycie:

lveinfo

Na koncie współdzielonym to nie zadziała. lveinfo odpytuje bazę statystyk LVE i wymaga uprawnień administratora, więc nie pomoże też na hostingu bez CageFS-a — a tam, gdzie CageFS jest, konto i tak nie widzi narzędzi systemowych. Nie warto na tym tracić czasu ani budować własnego monitoringu w zastępstwie. Administrator hostingu ma to polecenie pod ręką i widzi w nim wprost, który proces i która domena wywołały spowolnienie.

W panelu (DirectAdmin, cPanel, panel własny hostingu) szukaj sekcji Resource Usage, Użycie albo Statystyki obciążenia. Znajdziesz tam trzy rzeczy, których nie da się odtworzyć z poziomu shella.

Tabelę godzinową z kolumnami A (średnia), L (limit) i F (faults). Kolumna F to liczba przekroczeń w danej godzinie, czyli gotowa lista godzin do sprawdzenia.

Przełącznik jednostki czasu. To jest pozycja, którą przeoczyłem przy pierwszym podejściu i która kosztowała mnie dzień. Ta sama tabela potrafi pokazać dobę minuta po minucie zamiast godzina po godzinie — u mnie 610 wierszy zamiast jedenastu. Przy zdarzeniach trwających kilkanaście sekund różnica między jedną rozdzielczością a drugą decyduje o tym, czy w ogóle zobaczysz, czego szukasz.

Snapshot procesów z chwili przeciążenia, razem ze ścieżką do skryptu. To odpowiedź wprost na pytanie „co to generuje".

W opisywanym przypadku snapshot pokazał kilkanaście równoległych procesów index.php z jednej witryny, każdy zajmujący 25% CPU. Razem ponad 400%.

Pułapka pierwsza: średnie ukrywają szczyty

Tabela godzinowa pokazywała zużycie na poziomie 1–16% przy limicie 400%. Wyglądało to niewinnie, a mimo to w trzech godzinach doby odnotowano przekroczenia.

To normalne. Szczyt trwający czterdzieści sekund rozpuszcza się w średniej godzinowej prawie bez śladu. Przy diagnostyce interesują Cię minuty, nie godziny.

Przy minutach ta sama pułapka wraca o poziom niżej i tam już mnie złapała. W tabeli minutowej zdarzenie widać wyraźnie: średnia skacze z 2% na 86%, a liczba procesów z pięciu na dziewięć. Ale licznik procesów wejściowych, czyli żądań HTTP obsługiwanych równolegle, stoi przez całą dobę na czterech albo pięciu i nie drga nawet w minucie przekroczenia.

Wyciągnąłem z tego wniosek, że skoro żądania HTTP nie przybyło, to obciążenie przychodzi spoza serwera WWW, i zacząłem szukać w cronie, w poczcie i w zadaniach systemowych. Wniosek był fałszywy — logi z tych samych minut pokazały serie żądań HTTP.

Czemu ta kolumna nie drgnęła, nie ustaliłem. Pierwsze wyjaśnienie, jakie mi przyszło do głowy, czyli uśrednianie na sześćdziesiąt sekund przy zdarzeniu trwającym kilkanaście, nie broni się: w tej samej minucie i w tym samym oknie uśredniania liczba procesów wzrosła z pięciu na dziewięć. Nie broni się też dlatego, że ta kolumna przez całą dobę trzyma się przedziału od trzech do pięciu — także w minutach, w których konto nie robi nic, gdzie średnia liczba równoległych żądań powinna dążyć do zera. Cokolwiek ona mierzy, nie jest to chwilowa liczba żądań w obsłudze.

Zostaje z tego wniosek ogólniejszy i pewniejszy od mojej pierwszej hipotezy: licznik, który się nie rusza, nie jest dowodem, że nic się nie stało. Zanim zbudujesz na takim liczniku kierunek poszukiwań, sprawdź w logu, czy on w ogóle mierzy to, co sugeruje jego nazwa.

Pułapka druga: ruch to nie obciążenie

Naturalne założenie brzmi: winna jest witryna z największym ruchem. Policzmy więc żądania na dobę dla każdej domeny.

Logi na hostingach współdzielonych bywają rotowane do archiwów dobowych, więc czyta się je strumieniem:

for d in ~/domains/*/; do
  n=0
  for f in "$d"logs/*.tar.gz; do
    [ -f "$f" ] || continue
    n=$((n + $(tar -xzOf "$f" 2>/dev/null | grep -c '" [0-9]\{3\} ')))
  done
  printf '%8d  %s\n' "$n" "$(basename "$d")"
done | sort -rn

Filtr '" [0-9]\{3\} ' jest tu istotny: archiwum zawiera log dostępowy razem z logiem błędów, a bez niego witryna sypiąca tysiącami ostrzeżeń PHP wychodzi na lidera ruchu. Pętla sumuje też archiwa w obrębie domeny, bo bywa ich kilka.

Na opisywanym koncie lider miał 21 tysięcy żądań na dobę. Witryna, która faktycznie powodowała przekroczenia, była na dziewiętnastym miejscu z tysiącem żądań.

Całe konto obsługiwało 77 tysięcy żądań dziennie, czyli poniżej jednego na sekundę. Przy takim ruchu limit czterech rdzeni jest nie do wyczerpania w normalnych warunkach. Problemem nie był wolumen, tylko koncentracja.

Pułapka trzecia: błędy w logach to objaw, nie zawsze przyczyna

Kolejny kuszący trop. Policzmy, która witryna generuje najwięcej wpisów błędów:

for f in ~/domains/*/logs/*.tar.gz; do
  d=$(basename "$(dirname "$(dirname "$f")")")
  e=$(tar -xzOf "$f" 2>/dev/null | grep -cE 'PHP (Fatal|Warning|Notice|Deprecated)')
  [ "$e" -gt 50 ] && printf '%6d  %s\n' "$e" "$d"
done | sort -rn

Wynik był jednoznaczny: jedna witryna z sześcioma tysiącami wpisów na dobę, kolejna w rankingu miała trzysta. Wygląda na trafienie w dziesiątkę.

Treść błędów też prowadziła do konkretu:

tar -xzOf ~/domains/twojadomena.pl/logs/*.tar.gz \
  | grep -oE 'in /home[^ ]+ on line [0-9]+' \
  | sort | uniq -c | sort -rn | head

Wyszły dwa miejsca w kodzie motywu, oba warte naprawy, i wymieniam je na końcu tekstu. Ale sześć tysięcy warningów dziennie to koszt zapisu do pliku, nie obciążenie procesora. Tabela z panelu pokazywała operacje dyskowe na poziomie 200 KB/s przy limicie 150 MB/s, czyli margines siedmiusetkrotny.

Ta witryna nie miała z przekroczeniami nic wspólnego. Zgubił mnie tu zwykły błąd wnioskowania: najgłośniejszy sygnał w logach niekoniecznie jest najdroższym.

Skanery podatności: wyzwalacz

Rozkład żądań co do minuty wygląda tak:

tar -xzOf ~/domains/twojadomena.pl/logs/*.tar.gz \
  | grep -oE '[0-9]{2}/[A-Za-z]{3}/[0-9]{4}:[0-9]{2}:[0-9]{2}' \
  | sort | uniq -c | sort -rn | head -15

Wynik był ten sam na każdej domenie w koncie: około 300 żądań skumulowanych w jednej minucie, kilka razy na dobę, przy tle na poziomie kilku żądań na godzinę.

Za każdą serią stał jeden adres IP:

tar -xzOf ~/domains/twojadomena.pl/logs/*.tar.gz \
  | grep '17/Sep/2026:16:47:' | awk '{print $1}' | sort | uniq -c | sort -rn

A ścieżki, o które pytał, nie pozostawiały wątpliwości:

/.env
/wp/.env
/api/.env
/laravel/.env
/wp-admin/phpinfo.php
/graphql
/console

To automat szukający wycieków kluczy API i niezabezpieczonych paneli. Enumeruje kilkaset ścieżek, każdą po razie lub dwa. Adresy rotują między przelotami, zwykle w zakresach dużych dostawców chmurowych, a ten sam automat przechodzi kolejno po wszystkich domenach serwera z tej samej listy.

Tak wygląda wyzwalacz. Dlaczego bywa drogi, wyjaśnia się dopiero przy porównaniu dwóch witryn.

Co naprawdę kosztuje: wzorzec „wszystko do jednego wejścia"

Tego samego dnia ten sam automat przejechał tą samą listą ścieżek po dwóch witrynach z tego samego konta, w odstępie kilku godzin. Na pierwszej wysycił cztery rdzenie i wywołał przekroczenie limitu. Na drugiej nie zostawił w statystykach żadnego śladu.

WitrynaOknoOdrzuconych przez .htaccessCiężkich odpowiedzi 404 z PHPUbitych procesów PHPŚrednia CPU
sklep na WooCommerce02:28–02:291391381086% w drugiej minucie, przekroczenie limitu
autorski sklep w PHP09:1866log dostępowy niepobrany0poniżej 11%, bez śladu

Dwie granice tego zestawienia trzeba postawić od razu. Okna mają różną długość: dla sklepu na WooCommerce to dwie minuty odczytane z logu dostępowego, dla drugiej witryny jedna minuta z logu błędów. Stąd dziesięć ubitych procesów w tym wierszu, a siedem w tabeli minutowej niżej — trzy pierwsze wypadły w minucie 02:28, siedem pozostałych w 02:29. Ważniejsza jest druga granica — na drugiej witrynie blokady złapały wszystko, co w logu widać, więc te liczby nie mówią, ile kosztowałoby ją żądanie, które przez blokady przeszło. Zgadza się z nimi równie dobrze prostsze wyjaśnienie: jej .htaccess pokrył całą listę skanera, a plik pierwszej witryny pokrywał połowę.

Wyjaśnienie, które opisuję niżej, odczytałem więc z konfiguracji obu witryn. Ma to jedną zaletę: czytelnik sprawdzi je u siebie w jednym pliku, bez czekania na skaner.

WordPress kieruje wszystko do jednego pliku wejściowego. Blok # BEGIN WordPress kończy się regułą: jeśli żądany plik nie istnieje i nie istnieje taki katalog, przepisz żądanie na index.php. PHP uruchamia wtedy pełny bootstrap, ładuje wtyczki i motyw, odpytuje bazę i dopiero wtedy zwraca 404. Tak samo działa Laravel, Symfony i praktycznie każdy współczesny framework PHP.

Druga witryna takiej reguły nie ma. To autorski sklep z jawnymi regułami mapującymi ładne adresy na konkretne pliki: listing.php, produkt.php, koszyk.php. Adres, który nie pasuje do żadnego wzorca, kończy się na systemie plików i nie uruchamia aplikacji.

Stąd wniosek, który odwraca kolejność myślenia o tym problemie. Skaner sam z siebie nie kosztuje prawie nic — to kilkaset żądań rozłożonych na kilkanaście sekund, czyli tyle, ile zwykła strona obsługuje przy jednym wejściu użytkownika. Kosztuje dopiero zderzenie skanera z wzorcem „wszystko, czego nie ma, idzie do jednego pliku wejściowego". Blokada w .htaccess działa, bo rozłącza te dwie rzeczy.

W logu błędów widać to wprost. Żądaniom, które doszły do PHP, odpowiadają wpisy:

Abort request processing by PID:3159787, kill: 1, begin time: 3, sent time: 3, req processed: 0

req processed: 0 znaczy, że proces wystartował i nie obsłużył ani jednego żądania. Serwer aplikacyjny odpala kolejne procesy, żeby nadążyć, ubija je po czasie, i limit konta wysyca się w kilkanaście sekund.

Żeby domknąć porównanie dwóch witryn do końca, brakuje jednego pomiaru: żądania na tej drugiej, które blokady przepuściły, z kodem odpowiedzi, rozmiarem i sprawdzeniem, czy w logu błędów stoi przy nim wpis o ubitym procesie. Jej logu dostępowego tego dnia nie pobrałem. Zostaje więc mechanizm odczytany z konfiguracji i pomiar z witryn, które ten wzorzec mają — ten drugi wypada jednoznacznie.

Co odróżnia serię kosztowną od darmowej

Doba zmierzona minuta po minucie, zestawiona z logiem błędów, pokazuje to na sześciu seriach jednego automatu:

SeriaWitrynaZablokowanych przez .htaccessUbitych procesów PHPŚrednia CPU w tej minuciePrzekroczenie
01:40autorski sklep360poniżej 11%nie
02:29sklep na WooCommerce22786%tak
04:42autorski sklep460poniżej 11%nie
06:12witryna A3552%tak, minutę później
09:18autorski sklep660poniżej 11%nie
09:25witryna B325poniżej 11%nie, 50% minutę później

To jest sześć serii jednego automatu na sześciu różnych domenach, nie sześć przelotów po jednej witrynie — dlatego kolumna z witryną jest tu potrzebna. Trzy kolumny z liczbami pochodzą przy tym z trzech różnych źródeł: zablokowane żądania i ubite procesy z logu błędów danej domeny, średnia CPU i przekroczenia z panelu, który mierzy całe konto.

Dlaczego oba logi podają inne liczby odrzuceń

Ta sama seria, ta sama minuta, dwa logi tej samej domeny: dostępowy ma 272 odpowiedzi 403, a log błędów 62 wiersze Access is denied by context rewrite. Rozbieżność wygląda na gubienie wierszy i przez pół dnia tak ją czytałem. Nie jest nim.

Przez ile wierszy log milczy, widać dopiero po ponumerowaniu żądań w kolejności, w jakiej przyszły. Milczenie kończy się dokładnie w tym miejscu, w którym skaner przechodzi z jednej grupy ścieżek na drugą:

210  /development/.env       bez wiersza w logu błędów
211  /production/.env        bez wiersza
212  /config/app/.env        bez wiersza
213  /phpinfo.php            od tego miejsca log pisze wszystko
214  /info.php
215  /php.php

Od żądania 213 do końca serii log błędów ma komplet, a jedyne dwie dziury w numeracji to żądania, które dostały 301, nie 403. Granica nie leży więc w czasie ani w tempie, tylko w ścieżce.

Sprawdzenie zajmuje jedno żądanie. Wybrałem domenę na tym samym koncie, która nie ma .htaccess w ogóle:

/.env         403
/.git/config  403
/phpinfo.php  404
/info.php     404

Ścieżki kropkowe są odcinane przez sam serwer, zanim jakakolwiek reguła z .htaccess zdąży się do nich odnieść. Dlatego nie zostawiają wiersza o odrzuceniu przez przepisanie — bo żadne przepisanie się nie odbyło. Blokada serwera ma przy tym wyjątek na .well-known: na tej samej domenie bez .htaccess adres /.well-known/acme-challenge/test oddaje 404, nie 403, więc odnawianie certyfikatów przez nią przechodzi.

W tej serii 208 z 272 odrzuceń było dziełem serwera, a 64 dziełem bloku reguł. Log błędów zapisał 62 wiersze i te dwa brakujące też się tłumaczą: trzy odrzucenia to POST / z pierwszych sekund serii, sprzed zakresu, który log numeruje. Wewnątrz zakresu nie brakuje niczego.

Dwa wnioski, oba praktyczne:

Log błędów liczy odrzucenia z Twojego .htaccess, log dostępowy wszystkie odpowiedzi 403. To są dwie różne wielkości i tak trzeba je czytać. Kolumna w tabeli wyżej pochodzi z logu błędów, więc opisuje pracę bloku, nie całość odmów.

Reguła na pliki kropkowe może być na Twoim serwerze zbędna. Sprawdź to u siebie tak samo: zapytaj o /.env domenę, która nie ma żadnych reguł. Jeśli dostaniesz 403, LiteSpeed robi to sam i pierwsza reguła bloku nic nie dokłada poza kosztem parsowania. Zostawiam ją w bloku wyżej, bo na Apache bez tej ochrony jest niezbędna, a nie każdy czyta ten tekst z LiteSpeedem pod spodem.

Licznik hit 11 times per second, który pojawia się w tym tekście przy stronie błędu, dotyczy przy okazji komunikatu File not found, a nie odrzuceń. Oba siedzą w tym samym pliku i tylko ten pierwszy bywa zwijany — dlatego w minutach z serią nie ma po nim śladu.

Liczba zablokowanych żądań nie przewiduje niczego: sześćdziesiąt sześć przeszło bez śladu, dwadzieścia dwa wysyciły cztery rdzenie. Serie kosztowne od darmowych odróżnia kolumna z ubitymi procesami.

Z jednym zastrzeżeniem, którego ta tabela sama nie pokazuje: ubity proces jest po części skutkiem, nie tylko przyczyną. Serwer aplikacyjny ubija workera po przekroczeniu czasu obsługi żądania, a ten czas rośnie, gdy konto zaczyna być dławione za przekroczenie limitu. Powstaje sprzężenie: dławienie wydłuża żądania, dłuższe żądania są ubijane, każde ubite to kolejny start. Kolumna z ubitymi procesami jest więc dobrym znacznikiem serii, która weszła w ten tryb, i złym miernikiem jej przyczyny.

Przesunięcie o minutę w dwóch wierszach bierze się stąd, że w logu dostępowym stoi czas przyjęcia żądania, a zużycie procesora księguje się wtedy, gdy praca faktycznie idzie. Przy seriach trwających kilkanaście sekund i przełomie minuty w środku serii to normalne.

Jednej rzeczy te dane nie pokazują i warto powiedzieć to wprost: zależności ilościowej tu nie ma. Pięć ubitych procesów o 06:12 skończyło się przekroczeniem limitu w następnej minucie, tyle samo o 09:25 dało w następnej minucie pięćdziesiąt procent i żadnego przekroczenia. Rekordowa seria całej doby, jedenaście ubitych procesów o 02:42 na jeszcze innej domenie tego konta, skończyła się na pięćdziesięciu procentach i też nie dotknęła limitu, a siedem procesów o 02:29 dało osiemdziesiąt sześć i przekroczenie.

Średnia minutowa i przekroczenie to zresztą dwie różne miary. Licznik przekroczeń reaguje na chwilowe dotknięcie limitu, a średnia rozkłada to samo zdarzenie na sześćdziesiąt sekund — w tej samej dobie minuta o średniej 35% ma przekroczenie, a minuta o średniej 50% go nie ma. Sześć punktów pomiarowych wystarcza, żeby odróżnić serię kosztowną od darmowej, i nie wystarcza, żeby przewidywać wysokość szczytu.

Rozwiązanie: odciąć przed PHP

Te żądania trzeba obsłużyć regułą w .htaccess, zanim uruchomi się jakikolwiek kod PHP. Blok wstawiony na początku pliku, przed sekcją # BEGIN WordPress:

<IfModule mod_rewrite.c>
RewriteEngine On

RewriteRule (^|/)\.(env|git|svn|aws|config|ssh|npmrc|DS_Store) - [F,L]
RewriteRule (^|/)phpinfo\.php$ - [F,L]
RewriteRule (^|/)wp-config\.php\. - [F,L]
RewriteRule (^|/)(graphql|console|actuator|telescope)(/|$) - [F,L]
RewriteRule \.(bak|old|save|swp|sql|log)$ - [F,L]
RewriteRule ~$ - [F,L]
RewriteRule ^(info|i|p|pi|php|pinfo|phpinfo|php-info|php_info|phpversion|server-info|server-status|test|debug|_phpinfo|old_phpinfo|admin_phpinfo|infophp|infos|config|configuration|settings|setup|bigdump|adminer|shell)\.php$ - [F,L]
RewriteRule ^_?(environment|profiler)(/|$) - [F,L]

RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_URI} !^/\.well-known/
RewriteRule (^|/)\. - [F,L]

RewriteCond %{REQUEST_FILENAME} !-f
RewriteCond %{REQUEST_URI} !^/\.well-known/
RewriteRule \.(js|mjs|cjs|ts|css|map|json|yml|yaml|toml|ini|env|conf|cfg|properties|key|pem|secret|sql|gz|bak|old|orig|save|swp|log|tfstate|tfvars|hcl|rb|py|sh|cgi|csv|lock|settings|backup)$ - [F,L]

RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(config|configs|conf|settings|secrets)/[^/]+\.php$ - [F,L]

RewriteCond %{REQUEST_FILENAME} !-f
RewriteRule ^(Dockerfile|Rakefile|credentials|env|phpinfo|id_rsa|wp-config)$ - [F,L]

RewriteCond %{QUERY_STRING} (^|&)phpinfo= [NC]
RewriteRule ^ - [F,L]
</IfModule>

ErrorDocument 403 /403.shtml
ErrorDocument 404 /404.shtml

Kolejność jest tu wszystkim. Reguły WordPressa kończą się przepisaniem wszystkiego na index.php, więc cokolwiek dopiszesz poniżej, nie zadziała.

Koszt obsługi takiego żądania spada z pełnego bootstrapu WordPressa do dopasowania wyrażenia regularnego.

Jedna reguła wymaga decyzji. Jeśli korzystasz z WPGraphQL albo headless WordPressa, usuń graphql z listy w regule o graphql i console — inaczej odetniesz własne API.

Druga rzecz, która wygląda na niekonsekwencję i nią nie jest: długa lista nazw plików PHP zaczyna się od ^, czyli łapie wyłącznie korzeń domeny, a phpinfo dostał osobną regułę z (^|/) i łapie każdy katalog. W .htaccess wzorzec RewriteRule widzi ścieżkę bez wiodącego ukośnika, więc ^info\.php$ nie dopasuje /wp-admin/info.php. Tak ma być: test.php, config.php i settings.php bywają prawdziwymi plikami wtyczek, a phpinfo.php w katalogu WordPressa nie ma prawa istnieć. Jeśli chcesz rozszerzyć tę listę na podkatalogi, przepisz ją na (^|/) razem z warunkiem RewriteCond %{REQUEST_FILENAME} !-f, dokładnie jak w dolnej części bloku.

Warunek, na którym stoi cała druga połowa bloku

Reguły z dolnej części mają przed sobą RewriteCond %{REQUEST_FILENAME} !-f. Ten warunek znaczy: zadziałaj wyłącznie wtedy, gdy żądany plik nie istnieje na dysku.

To jest różnica między blokadą, którą da się bezpiecznie rozszerzać, a blokadą, którą prędzej czy później odetniesz sobie własną witrynę. Bez tego warunku wpisanie json na listę rozszerzeń zamyka manifest.json, z którego korzystają aplikacje progresywne i ikony na ekranie startowym. Z tym warunkiem manifest.json przechodzi, bo leży na dysku, a /gcp-credentials.json dostaje 403, bo nie leży.

Górna grupa reguł tego warunku celowo nie ma i część rozszerzeń powtarza się w obu miejscach. Dla .bak, .sql, .swp czy .old istnienie pliku jest samo w sobie problemem — zrzut bazy leżący w katalogu domeny ma zostać niedostępny właśnie dlatego, że tam leży. Gdyby te rozszerzenia stały wyłącznie w dolnej liście, jedynym chronionym przypadkiem byłby plik, którego nie ma. Podział przebiega więc tak: na górze rozszerzenia, których witryna nie serwuje nigdy, na dole te, które mogą być zwykłą treścią.

Naturalny odruch przy pisaniu takiego bloku to wymienić nazwy plików, których szuka automat: service-account, credentials, keyfile, firebase-* i tak dalej. Takie podejście ma krótki termin przydatności. Między jednym przelotem a drugim, w odstępie dwóch dób, ten sam skaner dołożył pięć nowych kategorii ścieżek: pliki kropkowe konfiguracji chmurowych (/.boto, /.kube/config, /.s3cfg, /.netrc), stan infrastruktury (/terraform.tfstate, /serverless.yml, /app.yaml, /Dockerfile), klucze płatności i poczty (/stripe.json, /stripe/webhook_secret.env, /sendgrid.env), konfiguracje w podkatalogu (/config/mail.php, /config/stripe.php) oraz /?phpinfo=1 w parametrze adresu.

Lista nazw przegrywa z każdą aktualizacją słownika po drugiej stronie. Lista rozszerzeń z warunkiem !-f nie przegrywa, bo nie musi znać nazwy — wystarczy jej, że plik o takim rozszerzeniu na tej witrynie nie istnieje.

Na przelocie zmierzonym co do żądania 137 z 280 żądań skończyło się odmową, a 143 doszły do PHP. Liczba 137 obejmuje jednak odmowy serwerowe na ścieżkach kropkowych, opisane w sekcji o dwóch logach, więc udziału samego bloku z niej nie odczytam — rozbicia dla tej serii nie mam. Znaczenie ma druga liczba, mierzona wprost. Te 143 doszły do PHP i zostały w logu dostępowym razem ze ścieżkami — przepuściłem tę listę przez nowy blok przed wgraniem go gdziekolwiek i wyszło 133 dopasowania na 143. To jest więc symulacja na zapisanym ruchu, nie pomiar z kolejnego przelotu; pomiar przyszedł później i zgodził się z nią, bo po wdrożeniu do PHP nie doszło nic.

Czego !-f nie chroni

Warunek sprawdza obecność pliku na dysku, więc nie widzi rzeczy, które istnieją tylko wirtualnie — adresów obsługiwanych przez PHP, a wyglądających jak pliki.

Trzy przypadki, na które warto uważać na WordPressie:

Mapy witryny z wtyczek SEO. /sitemap_index.xml i cała reszta map z Yoasta czy Rank Matha nie istnieją jako pliki. Dlatego na liście rozszerzeń wyżej nie ma .xml i nie należy go tam dopisywać.

manifest.json i sw.js z wtyczek PWA. Część z nich serwuje te pliki przez template_redirect zamiast kłaść je w katalogu. Sprawdź je na każdej witrynie osobno, bo decyduje o tym zestaw wtyczek, więc wynik bywa inny na każdej instalacji.

robots.txt. Na liście go nie ma i nie chodzi wyłącznie o to, że bywa wirtualny. Odpowiedź 403 na robots.txt część robotów czyta jako zakaz indeksowania całej witryny.

Sprawdzenie przed wdrożeniem na kolejnych domenach zajmuje kilkanaście sekund:

for a in / /robots.txt /sitemap_index.xml /manifest.json /sw.js /.boto /?phpinfo=1; do
  printf '%s  %s\n' "$(curl -s -o /dev/null -w '%{http_code}' "https://twojadomena.pl$a")" "$a"
done

Pięć pierwszych adresów ma oddać to samo co przed zmianą. Dwa ostatnie mają oddać 403.

Strona błędu, bez której oszczędność jest o połowę mniejsza

Dwie linie ErrorDocument w bloku wyżej nie są ozdobą i długo ich tam nie było. To jest najbardziej zaskakująca rzecz, jaką znalazłem przy sprawdzaniu skuteczności tych blokad.

Bez nich każde odrzucone żądanie zostawiało w logu dwa wiersze zamiast jednego:

Access is denied by context rewrite.
File not found [/home/.../public_html/403.shtml]

DirectAdmin konfiguruje stronę błędu 403 na plik 403.shtml, którego w katalogach po prostu nie ma. Serwer odrzuca żądanie regułą, a potem idzie szukać strony, którą miałby pokazać, i jej nie znajduje.

Skala w szczycie serii: LiteSpeed sam raportuje narastające tempo, od hit 11 times per second aż do 122. To 122 nieudane wyszukania ścieżki i 122 zapisy do logu na sekundę.

Zastrzeżenie, żeby nie przesadzić z tą liczbą: to nie są operacje na dysku. Powtarzane sprawdzanie tej samej nieistniejącej ścieżki trafia w cache jądra i po pierwszym razie nie schodzi niżej, a zapis do logu jest buforowany. Mechanizm sam się zresztą wygasza — po kilkudziesięciu próbach serwer dopisuje shortcut to 404 i przestaje szukać. Kosztem są wywołania systemowe i rosnący log, nie ruch głowicy.

Naprawa to plik 403.shtml w katalogu domeny, kilkaset bajtów statycznego HTML-a, plus te dwie linie. Dowód, że działa, widać w logu od razu: zamiast pary wierszy zostaje jeden, a rozmiar odpowiedzi spada z 1242 bajtów domyślnej strony serwera na tyle, ile waży własna.

Przy okazji warto zrobić to samo z 404.shtml. Jeśli w logu widzisz File not found [404.shtml], każde nietrafione żądanie płaci ten sam podatek.

Lekka strona 404 oszczędza łącze

Blokady odcinają to, co przewidzisz. Reszta trafia do WordPressa i dostaje stronę 404 wygenerowaną przez motyw — w moim przypadku 37 905 bajtów. Jeden przelot skanera to przez to ponad pięć megabajtów HTML-a wysłanego do automatu, który go nawet nie czyta.

Kuszące jest policzyć to jako drugą połowę oszczędności na procesorze. Pomiar tego nie potwierdza. Zmierzyłem trzy witryny pod tą samą serią tego samego automatu: ich strony 404 ważyły 5, 17,5 i 37,4 kilobajta, liczba uruchomień aplikacji była w każdym przypadku zbliżona do stu czterdziestu, a szczyty CPU wyszły porównywalne. O koszcie procesora decyduje sam bootstrap, a rozmiar odpowiedzi wpływa na transfer i na czas wysyłki.

Lekki szablon 404 nadal warto zrobić, bo pięć megabajtów na przelot przy kilku przelotach dziennie to realny transfer. Ale jeśli szukasz oszczędności na procesorze, ta zmiana jej nie da — daje ją tylko niedopuszczenie żądania do PHP.

Co pokazał pomiar po wdrożeniu

Blok z warunkiem !-f stanął na osiemnastu domenach jednego popołudnia. Ten sam automat wrócił jeszcze tego samego dnia jedenaście razy.

Najczystsze porównanie daje witryna, którą ta sama lista odwiedziła rano i po południu:

GodzinaOdrzuconych, razem z odmowami serweraDoszło do PHPUbitych procesów
09:25 przed wdrożeniem1371437
16:09 po wdrożeniu27200

Obie liczby pochodzą z logu dostępowego, czyli z tego źródła, które widzi każde żądanie — i z tego powodu obejmują też odmowy serwerowe na ścieżkach kropkowych, opisane wyżej. W serii popołudniowej blok odpowiada za 64 z tych 272 odrzuceń. Liczy się kolumna obok: rano do PHP doszła równo połowa listy, po południu nie doszło nic.

Przelotów po wdrożeniu było jedenaście, na siedmiu różnych domenach, i ubitych procesów PHP było w sumie zero. Log dostępowy mam dla trzech z nich i tam serie liczyły od 272 do 280 żądań; w pozostałych ośmiu log błędów notuje od trzydziestu do stu czterdziestu pięciu odrzuceń, co jest liczbą zaniżoną z powodu opisanego wyżej. Przed wdrożeniem każda seria, która trafiała w lukę w blokadach, zostawiała od jednego do jedenastu ubitych procesów.

Licznik przekroczeń z panelu potwierdza to słabiej, niż mogłoby się wydawać, i tak trzeba go czytać: przekroczenia ustały tego dnia o 07:44, a blok stanął dopiero cztery i pół godziny później. Między jednym a drugim wypadła seria ze stu czterdziestoma uruchomieniami WordPressa, która dała pięćdziesiąt procent i żadnego faulta. Sam licznik mówi więc tylko tyle, że po wdrożeniu nic się nie pogorszyło. Dowód siedzi w logach, w kolumnie z żądaniami, które doszły do PHP.

Jedna kategoria przez blok nadal przechodzi: ścieżki bez rozszerzenia, w rodzaju /api/config, /actuator/loggers czy /_debugbar/open. W najdłuższej z dzisiejszych list było ich czterdzieści sześć na 326 żądań i żadne nie ubiło procesu. Wyliczanie ich z nazwy wraca do problemu z listą nazw, a blokowanie prefiksu /api/ na WordPressie bywa ryzykowne, bo warunek !-f nie chroni adresów, które obsługuje wtyczka.

Czego robić nie warto

Blokowanie po nazwie przeglądarki

W krążących po sieci „pakietach zabezpieczeń" do .htaccess znajdziesz listę stu kilkudziesięciu nazw botów:

RewriteCond %{HTTP_USER_AGENT} curl [NC,OR]
RewriteCond %{HTTP_USER_AGENT} python [NC,OR]
RewriteCond %{HTTP_USER_AGENT} java [NC,OR]
RewriteCond %{HTTP_USER_AGENT} scan [NC,OR]

Skaner z opisywanego przypadku przedstawiał się tak:

Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/131.0.0.0

Żaden wpis z tej listy go nie łapał. Blokowanie po nazwie działa wyłącznie na boty, które uczciwie się przedstawiają, a te akurat nie są problemem.

Za to curl i python na liście zablokują Twój własny monitoring i integracje, a java potrafi odciąć część klientów mobilnych.

Rozdmuchane pliki .htaccess

Te same pakiety dokładają około 160 dyrektyw RewriteCond, w większości wyrażeń regularnych sprawdzanych na parametrach adresu. Plik urasta do 15 kilobajtów i 400 linii.

Serwer przetwarza go przy każdym żądaniu, także przy obrazkach, arkuszach stylów i fontach. Strona ładująca 50 zasobów to 8000 ewaluacji wyrażeń regularnych na jedno wejście użytkownika.

Plik reklamowany jako ochrona przed obciążeniem sam to obciążenie generuje.

Po odchudzeniu do 55 linii zostają rzeczy, które faktycznie działają: blokady ścieżek, blokady plików, nagłówki cache i gzip.

Reguły, które nigdy nie działały

W trzech plikach z tego konta znalazłem taki zapis:

RewriteCond %{QUERY*STRING} (GLOBALS|_REQUEST)

Zmienna nazywa się QUERY_STRING. Zapis z gwiazdką wygląda na artefakt kopiowania z Markdowna, w którym podkreślenie zostało zinterpretowane jako znacznik kursywy — źródła nie znam, bo plik przyszedł z konfiguracją zastaną na koncie. Niezależnie od tego, jak powstał: cała sekcja antyinjekcyjna była martwa, dawała koszt parsowania i zero ochrony.

Sprawdź u siebie:

grep -l 'QUERY\*STRING' ~/domains/*/public_html/.htaccess

W innym pliku z tego samego konta stała reguła blokująca robota Yandeksa zapisana jako RewriteCond %{HTTP_USER_AGENT} YandexBot^. Znak ^ w wyrażeniu regularnym oznacza początek tekstu, więc wzorzec „YandexBot, po którym zaczyna się tekst" nie dopasuje się nigdy. Reguła stała tam latami i nie zrobiła nic.

Pięć sposobów, w jakie .htaccess cicho psuje stronę

Przy okazji porządkowania wyszło, że kilka witryn na koncie zwracało 404 na stronie głównej. Od kiedy, nie wiadomo, bo nikt tego nie zgłosił.

Niedomknięta dyrektywa. Plik urwany w połowie bloku:

<Files xmlrpc.php>
  Order Allow,Deny
  Deny from all

Brak </Files> na końcu. Objaw: 404 na wszystkim, co idzie przez PHP, przy działających plikach statycznych.

Flaga S=N przenosząca się między blokami. Popularna sekcja „Block the include-only files" zawiera:

RewriteRule !^wp-includes/ - [S=3]

[S=3] znaczy „pomiń następne trzy reguły". Skopiowana do pliku o innej strukturze przeskakuje poza swój blok i pomija reguły przepisujące na index.php. Efekt: 404 na całej witrynie poza plikami istniejącymi fizycznie.

Sama blokada bezpośredniego dostępu do plików w wp-includes bywa nadal rekomendowana jako warstwa obrony w głąb i nie o nią mam pretensję. Problemem jest wyłącznie [S=N], bo ta flaga liczy reguły, a nie bloki — przepisz sekcję na formę, która na niej nie polega.

Składnia nieobsługiwana przez serwer. Konstrukcja działająca na Apache:

<Files wp-login.php>
  <Limit GET POST>
    Order Deny,Allow
    Deny from all
  </Limit>
</Files>

LiteSpeed potrafi odrzucić na niej cały plik. Zamiennik:

<Files wp-login.php>
Require all denied
</Files>

Odcina to logowanie również Tobie, więc w praktyce idzie z tym lista dozwolonych adresów — Require ip 203.0.113.7 obok Require all denied — albo uwierzytelnianie Basic na tym jednym pliku. Wklejenie samego Require all denied kończy się telefonem od klienta, który nie może wejść do panelu.

Reguła z wzorcem .*, której jedynym ogranicznikiem jest warunek. Ten przypadek popełniłem sam i kosztował dwadzieścia godzin.

Do bloku blokad dołożyłem regułę odcinającą enumerację użytkowników przez REST API:

RewriteCond %{REQUEST_URI} ^.*wp-json/wp/v2/users(?!/me) [NC]
RewriteRule .* - [F,L]

Wzorzec w RewriteRule dopasowuje każdy adres. Od zablokowania całej witryny dzieli tę regułę wyłącznie to, czy warunek nad nią zadziała. Nie zadziałał — pięć witryn oddawało 403 na stronie głównej, dopóki nie zauważyłem tego przypadkiem, przy zupełnie innej diagnostyce. Nikt nie zgłosił.

Której dokładnie konstrukcji silnik nie strawił, nie ustaliłem. Podmieniłem całą regułę naraz i strony wróciły, więc winowajca jest potwierdzony na poziomie tych dwóch linii, a nie pojedynczego zapisu. Kandydaci: negatywny lookahead (?!/me), zbędne ^.* na początku wzorca, albo sam układ warunku z regułą łapiącą wszystko. Rozdzielenie tego wymagałoby wstawiania wariantów po kolei na żywych witrynach klientów i nie uważam, żeby było warte ceny.

Wersja, którą wgrałem tego samego dnia — bez .* i bez lookaheada, dwa zwykłe warunki:

RewriteCond %{REQUEST_URI} wp-json/wp/v2/users [NC]
RewriteCond %{REQUEST_URI} !wp-json/wp/v2/users/me [NC]
RewriteRule ^ - [F,L]

Działa i stoi na koncie, ale łamie zasadę, którą za chwilę z tej awarii wyprowadzam, i warto to powiedzieć wprost zamiast liczyć, że nikt nie zauważy. Wzorzec ^ dopasowuje każdy adres tak samo jak .*, więc jeśli oba warunki zawiodą tak, jak zawiódł lookahead, skutek jest ten sam: 403 na całej witrynie. Zawężenie siedzi wyłącznie w warunkach.

Wersja zgodna z zasadą przenosi zawężenie do wzorca reguły i zostawia w warunku samo odstępstwo:

RewriteCond %{REQUEST_URI} !wp-json/wp/v2/users/me [NC]
RewriteRule ^wp-json/wp/v2/users - [F,L]

Teraz pomyłka w warunku kosztuje jedną ścieżkę zamiast wszystkich.

Jedna poprawka do tego wzorca, zanim go skopiujesz. Wersja wgrana dopasowywała podciąg w dowolnym miejscu adresu, więc łapała też /index.php/wp-json/wp/v2/users; wersja zakotwiczona na ^ już nie. Kotwica ma więc brzmieć (^|/)wp-json/wp/v2/users — zawężenie zostaje w regule, a segment index.php przestaje być obejściem.

I tak żadna z tych wersji nie wystarcza, co sprawdziłem, zanim to napisałem. WordPress wystawia REST API także przez parametr zapytania, niezależnie od ustawień bezpośrednich odnośników. Trzy witryny z wgranym blokiem, trzy zapytania:

AdresOdpowiedź
/wp-json/wp/v2/users403
/?rest_route=/wp/v2/users200 i pełna lista użytkowników, od 1,2 do 11,7 kB JSON-a
/?rest_route=%2Fwp%2Fv2%2Fusers200 na jednej z witryn, z imionami i nazwiskami w odpowiedzi

Reguła patrzy na REQUEST_URI, a przy tej formie wywołania ścieżką jest samo /. Warunek na QUERY_STRING domyka pierwszy wariant, ale nie drugi, bo %{QUERY_STRING} widzi ciąg surowy, a rest_route=/wp/v2/users nie dopasuje rest_route=%2Fwp%2Fv2%2Fusers. Pokrycie obu form wymaga wzorca z alternatywą na każdym ukośniku i nadal nie obejmuje form, których nie sprawdziłem.

Tu kończy się warstwa, w której warto to robić. Blokada enumeracji użytkowników jest kontrolą bezpieczeństwa, nie kontrolą obciążenia: chodzi o kilka żądań dziennie, więc argument o oszczędzaniu bootstrapu nie działa. Dwie linijki w mu-pluginie pokrywają wszystkie warianty naraz, bo działają po znormalizowaniu trasy przez WordPressa:

add_filter('rest_endpoints', function ($endpoints) {
  if (current_user_can('list_users')) {
    return $endpoints;
  }
  unset($endpoints['/wp/v2/users'], $endpoints['/wp/v2/users/(?P<id>[\d]+)']);
  return $endpoints;
});

Warunek na uprawnienia nie jest ozdobą. Bez niego znika też lista autorów w edytorze bloków, bo panel dokumentu pobiera ją tym samym adresem /wp/v2/users. Na witrynie z jednym administratorem nikt tego nie zauważy, na witrynie z redakcją zauważy redaktor — i zgłosi to tak samo jak poprzednie awarie z tego tekstu, czyli wcale.

Sprawdzenie na jednej instalacji zajmuje minutę: otwórz edytor wpisu jako administrator i rozwiń listę autorów, potem wywołaj oba adresy w oknie prywatnym.

Reguła na REQUEST_URI zostaje w .htaccess jako druga warstwa — jest darmowa i odcina najczęstszą formę wywołania przed PHP. Filtr w mu-pluginie zamyka resztę. Reszta bloku zostaje tam, gdzie była, bo tam stawką jest niedopuszczenie żądania do interpretera.

Zasada do zapamiętania jest prostsza niż diagnoza: jeśli piszesz RewriteRule .*, a o tym, czy reguła odpali, decyduje RewriteCond nad nią, to dowolny błąd w tym warunku wyłącza całą witrynę. Wzorzec reguły powinien sam zawężać dopasowanie zawsze, gdy rozróżnia ścieżka.

Gdy rozróżnia parametr zapytania, nie możesz tego zrobić i trzeba to powiedzieć wprost, zamiast udawać, że zasada jest bez wyjątków. Wzorzec RewriteRule nie widzi query stringu, więc zostaje ^ i całe zawężenie siedzi w warunkach — tak wygląda blokada /?phpinfo=1 w bloku z początku tekstu. Takie reguły wymagają osobnego sprawdzenia strony głównej po każdej zmianie, bo to one wywracają witrynę, gdy warunek zawiedzie. To jest zresztą drugi powód, dla którego enumeracja użytkowników wylądowała wyżej w mu-pluginie zamiast w kolejnej regule na QUERY_STRING.

Blokada rozszerzenia bez sprawdzenia, czy plik istnieje. Ten przypadek o włos popełniłem sam.

Pisząc listę rozszerzeń do zablokowania, miałem na niej .xml. Wyleciała stamtąd na kilka minut przed wgraniem, kiedy sprawdziłem w logu, co pod tym rozszerzeniem odpowiada kodem 200. Okazało się, że cztery mapy witryny z Yoasta — a żadna z nich nie istnieje jako plik. Reguła bez warunku !-f wycięłaby je wszystkie naraz, a zobaczyłbym to dopiero po tygodniach w Search Console.

Ta sama pułapka dotyczy manifest.json, sw.js i wszystkiego, co wtyczka serwuje przez template_redirect. Adres wyglądający jak plik nie musi być plikiem. Warunek RewriteCond %{REQUEST_FILENAME} !-f przed regułą zamyka całą tę klasę błędów, bo plik, który na dysku jest, przechodzi zawsze.

Szybki test, czy problem leży w pliku:

cp .htaccess ~/htaccess-backup-$(date +%s)
mv .htaccess .htaccess.off
curl -s -o /dev/null -w '%{http_code}\n' https://twojadomena.pl/
mv .htaccess.off .htaccess

Kopia idzie pierwsza, a plik na czas testu zostaje w tym samym katalogu pod inną nazwą. Przeniesienie go do /tmp wygląda niewinnie, dopóki sekwencji coś nie przerwie — wtedy witryna zostaje bez .htaccess, a plik leży w katalogu, który hosting potrafi wyczyścić. Przywróć nazwę od razu po sprawdzeniu, niezależnie od wyniku.

Gdy logów nie widać z konsoli

Na koncie z CageFS bieżące logi bywają poza zasięgiem użytkownika. /var/log/httpd/domains/ nie istnieje, katalog podstawiony przez CageFS jest pusty, a w ~/domains/*/logs/ leżą wyłącznie dobowe archiwa. Rotacja idzie w środku nocy, więc wszystko, co wydarzyło się po niej, jest z powłoki niewidoczne — czyli dokładnie to, czego szukasz, gdy alert przyszedł rano.

Wyjście jest w panelu. DirectAdmin udostępnia log dostępowy i log błędów osobno dla każdej domeny:

https://<konto>.hostido.net.pl/CMD_SHOW_LOG?domain=<domena>&type=error
https://<konto>.hostido.net.pl/CMD_SHOW_LOG?domain=<domena>&type=log

Widok sięga do ostatniej rotacji i obejmuje kilkaset do kilku tysięcy wierszy na domenę. U mnie to wystarczyło, żeby rozstrzygnąć sprawę bez czekania do następnego dnia. Logi starsze niż bieżąca doba bierze się już normalnie z archiwów.

Kod został na koniec

Warningi z logów wskazały przy okazji dwa realne błędy w motywie: filtr wp_nav_menu_objects budujący menu rekurencyjnie, po kilkaset zapytań SQL na jedno wyświetlenie strony, i post_type_link wołający przy każdym permalinku wp_get_object_terms(), czyli funkcję bez cache. Oba warto naprawić.

Żaden nie miał związku z przekroczeniami limitu i to jest tu najważniejsze zdanie. Oba filtry rozbieram w osobnym tekście: zapytania N+1 w filtrach WordPressa. Przy nagłym przekroczeniu limitu zaczynanie od kodu kosztuje dni i zwykle nie trafia w przyczynę.

Jest natomiast osobna kategoria błędów w kodzie, która z tym tematem ma wszystko wspólne: martwe odwołania w motywie. Na witrynie z największym ruchem na tym koncie motyw wskazywał na /styles.css w korzeniu domeny i na skrypt z własnego katalogu assets, a żaden z tych plików nie istniał. Oba adresy szły więc do index.php i oddawały pełną stronę 404 z motywu, 37 kilobajtów, przy każdej odsłonie. W niecałe dziesięć godzin dało to 206 dodatkowych uruchomień WordPressa i 7,7 megabajta transferu, a przeglądarka nie pokazywała nic, bo brakujący arkusz stylów ignoruje po cichu.

To jest ten sam mechanizm co przy skanerze, tylko wyzwalany przez własny kod. Szukaj go tak:

tar -xzOf ~/domains/twojadomena.pl/logs/*.tar.gz \
  | grep -oE '"GET [^"]+" 404 [0-9]{5,}' | sort | uniq -c | sort -rn | head

Filtr na pięciocyfrowy rozmiar odpowiedzi jest tu istotny: odsiewa zwykłe czterysta czwórki i zostawia te, które oddały pełną stronę z motywu.

Kolejność, która oszczędza czas

Gdybym miał przejść tę drogę jeszcze raz:

Panel hostingu, od razu w rozdzielczości minutowej. Godziny przekroczeń, przełącznik jednostki czasu na minuty i snapshot procesów. Widok godzinowy podsunął mi trzy różne błędne wnioski, zanim znalazłem ten przełącznik.

Support, jeśli panel nic nie pokazuje. Administratorzy mają dostęp do lveinfo i widzą wprost, który proces i która domena wywołały spowolnienie. To jedno pytanie zamiast dwóch dni pracy.

Logi co do minuty, nie co do godziny. Szukasz koncentracji, nie wolumenu. Jeśli powłoka ich nie widzi, sięgnij po podgląd z panelu.

Blokady ścieżek w .htaccess na wszystkich witrynach, niezależnie od wyniku diagnostyki. To tanie i działa prewencyjnie.

Przegląd .htaccess pod kątem martwych i szkodliwych reguł. Przy okazji wyjdą strony, które nie działają.

Dopiero na końcu kod. Optymalizacja zapytań ma sens, ale rzadko bywa przyczyną nagłych przekroczeń. Te zwykle przychodzą z zewnątrz.

Co zrobić, żeby nie wracać do tematu

Cloudflare przed witrynami. Darmowy plan nie odetnie wszystkich skanerów, bo zarządzane reguły WAF są w nim ograniczone, ale zdejmie z serwera ruch botów z listy znanych źródeł i cały ruch statyczny. Przy kilkunastu stronach na jednym koncie to największy zysk przy najmniejszym nakładzie.

Prosty monitoring kodów odpowiedzi. Zadanie cron raz dziennie, sprawdzające listę adresów i wysyłające wiadomość tylko przy problemie. Trzy strony z 404 chodziły tak nie wiadomo jak długo.

Wartość tej pozycji zmierzyłem przy pisaniu tego tekstu. Sklep z tego samego konta zaczął oddawać 500 na każdym adresie idącym przez PHP, bo interpreter przestał mieć prawo odczytu własnego wp-config.php — plik miał prawa 600 od pół roku i nikt go nie dotykał, zmieniło się coś po stronie serwera. Zauważyłem to przypadkiem, przy sprawdzaniu zupełnie innej reguły, kilkanaście minut po starcie awarii. Bez tego przypadku sklep stałby do rana.

Usunięcie z konta domen, które nie mają witryny. Nocna rotacja logów razem z przeliczaniem statystyk weszła u mnie do pierwszej dziesiątki najdroższych minut doby: średnia 45% CPU w tej jednej minucie, przy dobowej średniej w okolicach 2%. Ten koszt skaluje się z liczbą domen na koncie, a nie z ich ruchem — domena wygaszona, przekierowana albo z samą pocztą płaci w nim tyle samo co witryna z ruchem, a do tego zostaje na liście celów skanera.

Na obietnicę zdjęcia przekroczeń to za mało i tak trzeba tę pozycję czytać: rotacja ani razu nie wywołała faulta, więc oszczędza koszt, który nie był wiążący. Argument zostaje, bo kilkanaście domen bez witryny na jednym koncie podnosi tło, a przy następnej serii skanera liczy się właśnie to, od jakiego poziomu ona startuje.

Kopia .htaccess przed każdą zmianą. Jedna komenda, a oszczędza cały wieczór:

mkdir -p ~/htaccess-backup
for f in ~/domains/*/public_html/.htaccess; do
  [ -f "$f" ] || continue
  d=$(basename "$(dirname "$(dirname "$f")")")
  cp "$f" ~/htaccess-backup/"$d".htaccess
done

Pięć pytań, które i tak padną

Czy przekroczenie limitu CPU oznacza, że strona była zaatakowana? Nie w sensie włamania. Skanowanie podatności to szum tła internetu, dostaje je każda witryna publiczna. Problemem jest koszt obsługi tych żądań, nie samo ich istnienie.

Czy wystarczy zablokować adres IP skanera? Nie. Adresy rotują przy każdym przelocie, zwykle z zakresów dużych dostawców chmurowych. Blokować trzeba ścieżki, nie źródła.

Czy to problem WordPressa? Tylko w takim stopniu, w jakim WordPress kieruje wszystko, czego nie ma na dysku, do jednego pliku wejściowego. Tak samo robi Laravel, Symfony i większość współczesnych aplikacji PHP, więc kosztem obciążone są one wszystkie. Sprawdzisz to u siebie w .htaccess, bez czekania na skaner: szukaj reguły przepisującej na index.php wszystko, co nie jest istniejącym plikiem ani katalogiem.

Czy przejście na droższy pakiet rozwiąże problem? Podniesie limit, ale przy takim profilu obciążenia raczej odsunie objawy niż usunie przyczynę. Najpierw sprawdź, czy limit wyczerpuje ruch użytkowników, czy automaty.

Ile witryn można trzymać na jednym koncie? Nie ma reguły, ale przy kilkunastu warto pamiętać, że limit jest wspólny. Jedna zaniedbana instalacja spowalnia wszystkie pozostałe, więc diagnostykę zaczynam od przeglądu całego konta.


Alert o przekroczeniu limitu prawie nigdy nie wskazuje winowajcy, a pierwszy przekonujący trop bywa fałszywy. Zacznij od panelu w rozdzielczości minutowej, w logach szukaj żądań, które doszły do PHP, a kod zostaw na koniec.