Przejdź do treści
Lekcja 93 z 117

Kurs › Moduł 10 › Lekcja 93

Stos LAMP od zera

⏱ ok. 12 min czytania· moduł 10: Usługi i serwery

Po tej lekcji: kursant stawia od zera Apache, MariaDB i PHP przez PHP-FPM na jednym serwerze, uruchamia pierwszą aplikację, która czyta i zapisuje dane w bazie, i umie znaleźć, która z tych czterech warstw się zepsuła.

Wstęp

przeglądarka, adres lamp.test, lista kapci i formularz „Dodaj”

Wpisujesz w formularz „Kapcie z Wi-Fi”, cena 299, klikasz „Dodaj”. Strona się odświeża i kapcie są na liście. Nic wielkiego? To przez Twój serwer przeszło właśnie cztery warstwy: system, serwer WWW, język programowania i baza danych. Każda mogła się wywalić na swój sposób.

Dziś stawiamy to od zera. I zepsujemy po kolei, żebyś wiedział, jak wygląda awaria każdej warstwy. Bo kiedyś telefon zadzwoni z tekstem „strona nie działa”. I to będzie cały opis problemu.

Co to jest LAMP

LAMP to skrót od czterech składników: Linux, Apache, MySQL albo MariaDB i PHP. Na takim zestawie stoi mnóstwo stron w internecie, od bloga cioci po duże sklepy. WordPress, najpopularniejszy system do stron, jest napisany właśnie pod LAMP.

Znowu restauracja. Linux to budynek. Apache to kelner na sali, przyjmuje zamówienia i podaje talerze. PHP to kucharz, który dla każdego gościa przygotowuje danie od nowa. A MariaDB to spiżarnia, z której kucharz bierze składniki.

Czemu kucharz w ogóle jest potrzebny? Plik index.html to danie z lodówki. Kelner bierze gotowe i podaje. Ale lista kapci z bazy jest inna dla każdego klienta i zmienia się co minutę. Ktoś musi ją ugotować na życzenie. To robi PHP.

Zamiast Apache bywa Nginx, wtedy mówi się LEMP. Od Nginxa, wymawianego „engine-x”. Zamiast PHP bywa Python. Pomysł ten sam.

PHP-FPM, czyli kuchnia za ścianą

PHP można podłączyć do Apache na dwa sposoby.

Stary: moduł mod_php. Kucharz stoi na sali i jest jednocześnie kelnerem. Każdy kelner nosi w sobie całą kuchnię, nawet jak niesie tylko szklankę wody albo obrazek. Ciężko. I działa tylko z najstarszym trybem pracy Apache, prefork, w którym każdy klient ma osobny proces.

Nowy: PHP-FPM, FastCGI Process Manager. Kuchnia jest osobno, za ścianą. Kelner podaje sam wodę i obrazki, a zamówienie na danie przekazuje przez okienko do kuchni. W kuchni pracuje stała ekipa kucharzy, a kierownik kuchni pilnuje, ilu ich jest. Apache może wtedy pracować w szybszym trybie event, a PHP ma własne procesy, własnego użytkownika i własne limity.

Instalacja

sudo apt install apache2 mariadb-server php-fpm php-mysql
sudo a2enmod proxy_fcgi setenvif
sudo a2enconf php8.3-fpm
sudo systemctl restart apache2

php-mysql to sterownik, dzięki któremu PHP w ogóle umie rozmawiać z MariaDB. proxy_fcgi to okienko między salą a kuchnią. A a2enconf php8.3-fpm włącza gotowy plik, który mówi Apache: „wszystko, co kończy się na .php, podaj do kuchni”. Zajrzyjmy do niego:

<FilesMatch ".+\.ph(?:ar|p|tml)$">
    SetHandler "proxy:unix:/run/php/php8.3-fpm.sock|fcgi://localhost"
</FilesMatch>

proxy:unix:/run/php/php8.3-fpm.sock to adres kuchni. Nie port sieciowy, tylko plik gniazda na dysku. Taki domofon między pokojami w tym samym budynku.

sudo dnf install httpd mariadb-server php php-fpm php-mysqlnd
sudo systemctl enable --now httpd mariadb php-fpm
sudo firewall-cmd --permanent --add-service=http
sudo firewall-cmd --reload

Na RHEL sterownik nazywa się php-mysqlnd. Konfiguracja dla Apache jest w /etc/httpd/conf.d/php.conf, a ustawienia kuchni w /etc/php-fpm.d/www.conf. Na Ubuntu ta sama kuchnia mieszka w /etc/php/8.3/fpm/pool.d/www.conf. Wersja PHP w obu rodzinach: 8.3.

Sprawdzamy kuchnię:

sudo php-fpm8.3 -t                 # [RHEL] php-fpm -t
ps -o pid,user,rss,cmd -C php-fpm8.3
NOTICE: configuration file /etc/php/8.3/fpm/php-fpm.conf test is successful
  PID USER       RSS CMD
30734 root     10400 php-fpm: master process (/etc/php/8.3/fpm/php-fpm.conf)
30735 www-data 14604 php-fpm: pool www
30736 www-data 14604 php-fpm: pool www

Znajomy układ z Nginxa. Kierownik jako root, kucharze jako www-data. [RHEL] jako apache. Grupa kucharzy nazywa się pulą, po angielsku pool. Domyślna nazywa się www.

Baza dla aplikacji

Bazę kapcie i użytkownika kapcie_app zrobiliśmy w poprzedniej lekcji. Jeśli zaczynasz od zera, to po sudo mariadb-secure-installation:

CREATE DATABASE kapcie CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'kapcie_app'@'localhost' IDENTIFIED BY 'Tajne-Haslo-123';
GRANT ALL PRIVILEGES ON kapcie.* TO 'kapcie_app'@'localhost';

I tabela produkty z dwiema parami kapci z poprzedniej lekcji.

Pierwsza aplikacja

Najpierw porządek w folderach. To ważne i wiele osób robi to źle:

/var/www/lamp/
├── config.php        ← hasło do bazy, POZA folderem strony
└── public/
    └── index.php     ← tylko to widzi świat

DocumentRoot wskazuje na public. Plik z hasłem leży piętro wyżej, więc nikt nie wpisze go w przeglądarkę. To jak trzymanie sejfu na zapleczu, a nie na ladzie.

/var/www/lamp/config.php:

<?php
return [
    'dsn'   => 'mysql:host=localhost;dbname=kapcie;charset=utf8mb4',
    'user'  => 'kapcie_app',
    'haslo' => 'Tajne-Haslo-123',
];

/var/www/lamp/public/index.php:

<?php
$cfg = require __DIR__ . '/../config.php';

try {
    $db = new PDO($cfg['dsn'], $cfg['user'], $cfg['haslo'], [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    ]);
} catch (PDOException $e) {
    error_log('Baza nie odpowiada: ' . $e->getMessage());
    http_response_code(500);
    exit('Sklep chwilowo nieczynny.');
}

if ($_SERVER['REQUEST_METHOD'] === 'POST' && !empty($_POST['nazwa'])) {
    $stmt = $db->prepare('INSERT INTO produkty (nazwa, cena) VALUES (?, ?)');
    $stmt->execute([$_POST['nazwa'], (float) ($_POST['cena'] ?? 0)]);
}

$produkty = $db->query('SELECT nazwa, cena FROM produkty ORDER BY id')->fetchAll();
?>
<!doctype html>
<meta charset="utf-8">
<title>Sklep z kapciami</title>
<h1>Sklep z kapciami</h1>
<ul>
<?php foreach ($produkty as $p): ?>
  <li><?= htmlspecialchars($p['nazwa']) ?> — <?= number_format($p['cena'], 2, ',', ' ') ?> zł</li>
<?php endforeach; ?>
</ul>
<form method="post">
  <input name="nazwa" placeholder="Nazwa"> <input name="cena" placeholder="Cena">
  <button>Dodaj</button>
</form>
<p><small>PHP <?= PHP_VERSION ?>, tryb: <?= PHP_SAPI ?>, proces: <?= getmypid() ?></small></p>

Nie musisz umieć PHP. Ale admin powinien rozpoznać trzy rzeczy. new PDO(...) to połączenie z bazą, host=localhost znaczy „przez plik gniazda na tym samym serwerze”. prepare ze znakami zapytania to bezpieczne wkładanie danych od użytkownika do zapytania. Bez tego ktoś wpisze w nazwę kapci kawałek SQL i skasuje Ci tabelę. To się nazywa SQL injection i jest starsze niż większość kursantów tego kanału. A htmlspecialchars robi to samo przy wyświetlaniu.

Prawa do plików, jak w module o uprawnieniach:

sudo chown -R root:www-data /var/www/lamp     # [RHEL] root:apache
sudo chmod 640 /var/www/lamp/config.php

Kucharz czyta hasło, ale nie może go zmienić. Reszta świata nie widzi go wcale.

[RHEL] SELinux: pliki w /var/www dostają kontekst httpd_sys_content_t same. Jeśli baza stałaby na innym serwerze, potrzebny jest jeszcze setsebool -P httpd_can_network_connect_db 1.

VirtualHost i pierwszy test

[Ubuntu] /etc/apache2/sites-available/lamp.conf, [RHEL] /etc/httpd/conf.d/lamp.conf:

<VirtualHost *:80>
    ServerName lamp.test
    DocumentRoot /var/www/lamp/public
    DirectoryIndex index.php index.html

    <Directory /var/www/lamp/public>
        Require all granted
    </Directory>

    ErrorLog ${APACHE_LOG_DIR}/lamp_error.log
    CustomLog ${APACHE_LOG_DIR}/lamp_access.log combined
</VirtualHost>

DirectoryIndex index.php mówi: jak ktoś wejdzie na sam adres, najpierw szukaj index.php.

echo '127.0.0.1 lamp.test' | sudo tee -a /etc/hosts
sudo a2ensite lamp
sudo apachectl configtest
sudo systemctl reload apache2
curl -s http://lamp.test/
<h1>Sklep z kapciami</h1>
<ul>
  <li>Kapcie z pomponem — 49,99 zł</li>
  <li>Kapcie z GPS — 199,00 zł</li>
</ul>
...
<p><small>PHP 8.3.6, tryb: fpm-fcgi, proces: 28994</small></p>

Widzisz fpm-fcgi? To dowód, że strona przeszła przez kuchnię za ścianą. A numer procesu to jeden z kucharzy z listy ps sprzed chwili.

Teraz formularz, ale z terminala. curl -d wysyła dane tak, jak przeglądarka po kliknięciu „Dodaj”:

curl -s -d 'nazwa=Kapcie z Wi-Fi&cena=299' http://lamp.test/ | grep '<li>'
  <li>Kapcie z pomponem — 49,99 zł</li>
  <li>Kapcie z GPS — 199,00 zł</li>
  <li>Kapcie z Wi-Fi — 299,00 zł</li>

Cztery warstwy, jeden curl. Kapcie z Wi-Fi są w bazie. Po co kapciom Wi-Fi? Nie wiem. Ale się sprzedają.

Psujemy po kolei

Ta część jest ważniejsza niż instalacja. Każda warstwa psuje się inaczej i zostawia inny ślad. Rób to na maszynie wirtualnej, nie na serwerze, na którym ktoś kupuje kapcie.

Kuchnia nie działa. Zatrzymuję PHP-FPM:

sudo systemctl stop php8.3-fpm      # [RHEL] php-fpm
curl -si http://lamp.test/ | head -1
sudo tail -1 /var/log/apache2/lamp_error.log
HTTP/1.1 503 Service Unavailable
[proxy_fcgi:error] ... AH01079: failed to make connection to backend: httpd-UDS

503 i „nie mogę się połączyć z zapleczem”. Kelner puka w okienko kuchni, a tam nikogo. httpd-UDS to Unix Domain Socket, czyli nasz domofon. Kiedy widzisz 503 przy stronie w PHP, pierwsze pytanie: czy FPM żyje?

Spiżarnia zamknięta. Zmieniam hasło w config.php na złe:

HTTP/1.1 500 Internal Server Error
Sklep chwilowo nieczynny.

Klient widzi grzeczny komunikat, bo tak napisaliśmy aplikację. A admin w logu Apache widzi prawdę:

[proxy_fcgi:error] ... AH01071: Got error 'PHP message: Baza nie odpowiada: SQLSTATE[HY000] [1045] Access denied for user 'kapcie_app'@'localhost' (using password: YES)'

To nasza linijka z error_log. Kuchnia krzyknęła przez okienko, kelner zapisał to w zeszycie. Kod 1045 znasz z poprzedniej lekcji: złe hasło albo zły użytkownik.

Kelner nie wie, że jest kuchnia. To jest typowy błąd, więc o nim za chwilę.

Skrypt kontrolny

Cztery warstwy to cztery rzeczy do sprawdzenia. Zamiast pamiętać, zróbmy z tego skrypt. Wszystko, co tu jest, znasz z modułu o skryptach:

#!/bin/bash
# Szybki test stosu LAMP: Apache, PHP-FPM, MariaDB i strona.
set -uo pipefail

URL="${1:-http://localhost/}"
GNIAZDO="${GNIAZDO:-/run/php/php8.3-fpm.sock}"
bledy=0

ok()   { printf '  [OK]   %s\n' "$1"; }
zle()  { printf '  [BŁĄD] %s\n' "$1" >&2; bledy=$((bledy + 1)); }

echo "Sprawdzam stos LAMP:"

if apachectl configtest >/dev/null 2>&1; then
    ok "konfiguracja Apache"
else
    zle "konfiguracja Apache (uruchom: apachectl configtest)"
fi

if [[ -S "$GNIAZDO" ]]; then
    ok "gniazdo PHP-FPM $GNIAZDO"
else
    zle "brak gniazda PHP-FPM $GNIAZDO"
fi

if mariadb-admin ping >/dev/null 2>&1; then
    ok "MariaDB odpowiada"
else
    zle "MariaDB nie odpowiada"
fi

kod=$(curl -s -o /dev/null -w '%{http_code}' "$URL")
if [[ "$kod" == "200" ]]; then
    ok "strona $URL zwraca 200"
else
    zle "strona $URL zwraca $kod"
fi

if (( bledy > 0 )); then
    echo "Problemów: $bledy"
    exit 1
fi
echo "Wszystko działa."

[[ -S ... ]] to test „czy to plik gniazda”, kuzyn -f i -d z lekcji o warunkach. Uruchamiamy z sudo, bo mariadb-admin ping wchodzi jako root przez unix_socket. [RHEL] podajesz swoje gniazdo w zmiennej GNIAZDO, ścieżkę znajdziesz w linijce listen pliku /etc/php-fpm.d/www.conf.

sudo ./sprawdz-lamp.sh http://lamp.test/
Sprawdzam stos LAMP:
  [OK]   konfiguracja Apache
  [OK]   gniazdo PHP-FPM /run/php/php8.3-fpm.sock
  [OK]   MariaDB odpowiada
  [OK]   strona http://lamp.test/ zwraca 200
Wszystko działa.

I po zatrzymaniu FPM:

  [OK]   konfiguracja Apache
  [BŁĄD] brak gniazda PHP-FPM /run/php/php8.3-fpm.sock
  [OK]   MariaDB odpowiada
  [BŁĄD] strona http://lamp.test/ zwraca 503
Problemów: 2

Od razu widać, który klocek wypadł. ShellCheck nie ma uwag. A kod wyjścia 1 przy błędzie znaczy, że za kilka lekcji podepniemy ten skrypt pod monitoring.

Jak to działa pod spodem

Przeglądarka łączy się z Apache na porcie 80. Apache patrzy na nagłówek Host, wybiera VirtualHost lamp.test i szuka pliku w DocumentRoot. Plik kończy się na .php, więc SetHandler mówi: nie wysyłaj go, przekaż dalej.

Moduł proxy_fcgi otwiera plik gniazda /run/php/php8.3-fpm.sock i rozmawia z FPM protokołem FastCGI. Przekazuje ścieżkę do skryptu, nagłówki i dane z formularza. Wolny kucharz z puli bierze zamówienie, wykonuje index.php, łączy się z MariaDB przez jej plik gniazda /run/mysqld/mysqld.sock, pobiera wiersze, składa HTML i oddaje go Apache. Apache dokleja swoje nagłówki i odsyła do przeglądarki.

Wszystko, co PHP napisze na swoje wyjście błędów, wraca przez FastCGI do Apache. Dlatego nasz error_log wylądował w lamp_error.log z dopiskiem PHP message.

Ilu kucharzy pracuje, ustala pula w www.conf:

pm = dynamic
pm.max_children = 5
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 3

max_children = 5 znaczy: najwyżej pięciu kucharzy naraz. Szósty klient czeka w kolejce. Kucharz na naszej liście ps zajmował około 14 MB, ale w prawdziwej aplikacji bywa to kilkadziesiąt, a nawet kilkaset MB. Zatrudnisz za dużo kucharzy, zabraknie RAM-u. Za mało, klienci będą czekać. Liczysz: ile pamięci masz dla PHP, podzielone przez średnią wagę kucharza.

Ściąga flag

KomendaFlagaCo robi
a2enmodproxy_fcgi setenvif[Ubuntu] włącza okienko do FPM
a2enconfphp8.3-fpm[Ubuntu] kieruje pliki .php do FPM
php-fpm8.3 / php-fpm-tsprawdza konfigurację FPM
php-v / -mwersja / lista modułów (czy jest pdo_mysql)
curl-d 'a=1&b=2'wysyła formularz metodą POST
curl-si / -o /dev/null -w '%{http_code}'nagłówki / sam kod odpowiedzi
mariadb-adminpingczy baza żyje
apachectl-Mlista załadowanych modułów (czy jest proxy_fcgi)
www.confpm.max_childrenmaksymalna liczba procesów PHP
setsebool -Phttpd_can_network_connect_db 1[RHEL] PHP może łączyć się z bazą po sieci

⚠ Typowy błąd

Instalujesz wszystko, wchodzisz na stronę, a zamiast kapci widzisz:

<?php
$cfg = require __DIR__ . '/../config.php';

try {

Kod źródłowy PHP. W przeglądarce. Dla każdego.

Apache nie wie, że pliki .php trzeba wysłać do kuchni, więc podaje je jak zwykły tekst. Kelner zaniósł gościowi przepis zamiast dania. Dostałem to, wyłączając a2disconf php8.3-fpm. Na żywo zdarza się po instalacji php-fpm bez a2enconf, po aktualizacji PHP, kiedy nazwa pliku konfiguracji zmienia wersję, albo po skopiowaniu VirtualHosta z innego serwera.

I teraz zobacz, czemu config.php leży poza public. Gdyby był w środku, każdy mógłby w tej chwili przeczytać hasło do Twojej bazy.

Naprawa:

sudo a2enmod proxy_fcgi setenvif
sudo a2enconf php8.3-fpm
sudo apachectl configtest && sudo systemctl reload apache2
sudo apachectl -M | grep proxy_fcgi

A potem i tak zmień hasło do bazy. Nie wiesz, kto zdążył je zobaczyć.

✎ Zadanie

W MariaDB utwórz bazę sklep i użytkownika, który ma dostęp tylko do niej, z hasłem. Zrób zrzut tej bazy do pliku z dzisiejszą datą w nazwie i sprawdź, że plik nie jest pusty.

Pokaż rozwiązanie

Baza sklep, użytkownik, który widzi tylko ją, i zrzut z datą. Logujemy się jako administrator bazy. Na świeżej instalacji MariaDB root bazy loguje się przez gniazdo, więc wystarczy sudo:

sudo mariadb
CREATE DATABASE sklep;
CREATE USER 'sklep'@'localhost' IDENTIFIED BY 'Tajne-Haslo-123';
GRANT ALL PRIVILEGES ON sklep.* TO 'sklep'@'localhost';
FLUSH PRIVILEGES;
SHOW GRANTS FOR 'sklep'@'localhost';
GRANT USAGE ON *.* TO `sklep`@`localhost` IDENTIFIED BY PASSWORD '*6494...'
GRANT ALL PRIVILEGES ON `sklep`.* TO `sklep`@`localhost`

sklep.* to klucz tylko do jednego pokoju: wszystkie tabele w bazie sklep i nic więcej. Sprawdzamy, czy klucz nie pasuje do innych drzwi:

mariadb -u sklep -p -e 'CREATE DATABASE inna;'
ERROR 1044 (42000) at line 1: Access denied for user 'sklep'@'localhost' to database 'inna'

O to chodziło. Teraz zrzut. Hasła nie wpisujemy w komendę, bo trafiłoby do historii i do listy procesów. Kładziemy je do pliku, który czyta tylko właściciel:

printf '[client]\nuser=sklep\npassword=Tajne-Haslo-123\n' > ~/.sklep.cnf
chmod 600 ~/.sklep.cnf
mariadb-dump --defaults-extra-file=~/.sklep.cnf --single-transaction sklep > sklep-$(date +%F).sql
[ -s sklep-$(date +%F).sql ] && echo "Plik ma treść"
ls -lh sklep-*.sql
Plik ma treść
-rw-r--r-- 1 root root 2.0K Nov  3 16:35 sklep-2026-11-03.sql

-s w nawiasach to pytanie „czy plik istnieje i nie jest pusty”. --defaults-extra-file musi być pierwszą opcją, inaczej MariaDB go nie przyjmie.

[RHEL] Komendy są identyczne, a starsza nazwa mysqldump działa na obu rodzinach jako drogowskaz do mariadb-dump.

Plik z kopią bazy, który ma zero bajtów, to jak sejf, który przy otwarciu okazuje się szafką na buty. Dlatego zawsze sprawdzamy rozmiar.

Co dalej

Postawiliśmy cztery warstwy jedna na drugiej, każdą ręcznie. A gdyby całą aplikację razem ze wszystkim, czego potrzebuje, zapakować w jedno pudełko i postawić jedną komendą na dowolnym serwerze? W następnej lekcji kontenery: Docker i Podman, obraz, kontener, porty, wolumeny i sieci.