DAMPF im Kessel
PHP-FPM (FastCGI) vs. Apache mod_php
Der Artikel zum DAMPF-Stack beschreibt die Inbetriebnahme des LAMP-Stacks bestehend aus Linux, dem Apache HTTP Server, MariaDB und PHP. Das L wurde zu D wie Debian konkretisiert, und PHP wurde um FPM ergänzt: den FastCGI Process Manager. Von einem Leser kam dabei die folgende Frage auf:
Ich würde nun gerne wissen, wie viel schneller DAMPF im Gegensatz zu DAMP ist. Kann vielleicht jemand ein Script schreiben, wo über einen Webservice eine Vielzahl an Anfragen einmal “langsam” mit
mod_php
und einmal mit Dampf mit PHP-FPM durchgeführt wird?
Das ist ein berechtigtes Anliegen! Doch wie soll das ganze überprüft werden? Mir erscheint folgende Testanordnung sinnvoll:
- Man benötigt ein serverseitiges Skript, das eine mehr oder weniger hohe Last erzeugt.
- Weiter muss ein Client simuliert werden, der einerseits viele und/oder “schwere” Anfragen absetzt, die serverseitig eine hohe Last generieren. Ausserdem muss dieser Client sinnvolle Statistiken über die Anfragedauern ausgeben können.
An die Arbeit!
Serverseitiges Skript: Primzahlfaktorisierung
Jede natürliche Zahl >= 2 kann als ein Produkt von Primzahlen ausgedrückt werden:
- 12 = 2 * 2 * 3
- 13 = 13
- 15 = 3 * 5
- 27 = 3 * 3 * 3
Um von einer natürlichen Zahl x
die Primfaktoren zu erhalten, kann man
folgendermassen vorgehen:
- Man findet die Primzahlen bis
x
(bzw. zur Quadratwurzel vonx
als Optimierung, die hier nicht weiter begründet werden soll). - Man versucht die Zahl
x
durch die Primzahlen in aufsteigender Reihenfolge zu dividieren.- Funktioniert die Division restlos, hat man einen neuen Primfaktor gefunden. Man fährt mit dem Rest und der gleichen Primzahl fort.
- Andernfalls versucht man die Division mit der nächsten Primzahl.
- Der Vorgang ist fertig, wenn entweder der Rest bei 1 angelangt ist, oder
wenn die Primzahlen durchprobiert worden sind: In diesem Fall ist der Rest
auch eine Primzahl und somit ein Primfaktor von
x
.
Die Primzahlfaktorisierung ist etwa zum Kürzen von Brüchen sinnvoll ‒ oder aber zum Knacken von RSA-Schlüsseln; letzteres mit sehr viel grösseren Zahlen.
Primzahlen können folgendermassen gefunden werden:
function primes_up_to($n) {
$primes = array();
if ($n < 2) {
return $primes;
}
for ($i = 2; $i <= $n; $i++) {
if (is_prime($i)) {
$primes[] = $i;
}
}
return $primes;
}
function is_prime($x) {
for ($i = 2; $i <= $x / 2; $i++) {
if ($x % $i == 0) {
return false;
}
}
return true;
}
Die Funktion primes_up_to
findet alle Primzahlen von 2 bis und mit n
. Hierzu
verwendet sie die Hilfsfunktion is_prime
, welche für eine bestimmte Zahl x
überprüft, ob es eine Primzahl ist.
Diese Implementierung ist ineffizient ‒ und somit für einen Lasttest ideal.
Die Faktorisierung einer einzelnen Zahl x
funktioniert folgendermassen:
function factorize($x) {
$factors = array();
$primes = primes_up_to(sqrt($x));
$n = count($primes);
for ($i = 0; $x > 1 && $i < $n) {
$prime = $primes[$i];
if ($x % $prime == 0) {
$factors[] = $prime;
$x /= $prime;
} else {
$i++;
}
}
if ($x > 1) {
$factors[] = $x;
}
return $factors;
}
Damit der Client mehrere Primzahlen in einer einzigen Anfrage faktorisieren lassen kann, bietet folgende Funktion die Faktorisierung von Zahlen in einem bestimmten Wertebereich an:
function factorize_range($a, $b) {
$factors = array();
if ($a > $b) {
return $factors;
}
for ($i = $a; $i <= $b; $i++) {
$factors[$i] = factorize($i);
}
return $factors;
}
Solche Sachen könnte man natürlich auch etwas eleganter mit filter
, map
,
reduce
formulieren; doch dazu bei anderer Gelegenheit…
Das PHP-Skript soll Benutzeranfragen entgegennehmen und im Klartext beantworten:
header("Content-Type: text/plain");
if (!array_key_exists("lower", $_GET) || !array_key_exists("upper", $_GET)) {
die("usage: ?lower=[lower]&upper=[upper]");
}
$result = factorize_range($_GET["lower"], $_GET["upper"]);
foreach ($result as $n => $fs) {
echo("{$n}:\t");
foreach ($fs as $f) {
echo("{$f} ");
}
echo("\n");
}
Es wird also mit den GET-Parametern lower
und upper
aufgerufen. Auf unserem
DAMPF-Server als prime_factorization.php
hinterlegt, kann es folgendermassen
aufgerufen werden:
$ curl 'http://localhost/prime_factorization.php?lower=100&upper=109'
100: 2 2 5 5
101: 101
102: 2 3 17
103: 103
104: 2 2 2 13
105: 3 5 7
106: 2 53
107: 107
108: 2 2 3 3 3
109: 109
So wird es Zeit, etwas DAMPF in den Kessel zu bringen!
Clientseitig Last generieren: Der request0r
Um Performanceunterschiede zwischen mod_php
und PHP-FPM ermitteln zu können,
müssen mehrere Requests gleichzeitig abgesetzt werden. Sowas liesse sich gut mit
einem Shell-Skript, dem curl
-Befehl und dem Operator &
umsetzen, womit
Prozesse im Hintergrund ausgeführt werden können. Das Auswerten der einzelnen
Laufzeiten wird aber damit eher umständlich.
Ein kleines Go-Programm namens request0r
soll hier Abhilfe schaffen. Das
Projekt ist auf GitHub zu finden.
Das Programm lässt sich mit Go bauen und folgendermassen ausführen:
$ go build request0r.go
$ ./request0r -w 2 -r 10 'http://localhost/prime_factorization.php?lower=100&upper=109'
Requests:
Total Passed Failed Mean
20 20 0 1.183614ms
Percentiles:
0% 25% 50% 75% 100%
829.967µs 950.424µs 1.082723ms 1.368022ms 1.693025ms
Es werden zwei Worker gestartet, die je sequenziell zehn Requests ausführen und
deren Antwortzeit messen. (Weicht der Antwortstatus von 200 ab, wird der Request
als gescheitert verbucht und dessen Laufzeit ignoriert. Der erwartete Zustand
könnte mit dem Flag -s
überschrieben werden.)
Als Statistik wird einerseits ausgegeben, wie viele Requests insgesamt abgesetzt
worden sind (Total
: Anzahl Worker mit der Anzahl Requests pro Worker
multipliziert) und wie viele Requests davon erfolgreich zurückkamen (Passed
)
bzw. gescheitert sind (Failed
).
Das arithmetische Mittel der Antwortzeiten wird als Mean
ausgewiesen, welches
einen guten Indikator für die Performance darstellt. Ein differenzierteres Bild
ergibt der Blick auf die Perzentile: Der schnellste Request (0%
), der
langsamste (100%
) sowie diejenigen auf verschiedenen Schwellen (25%
, 50%
‒
der Median, 75%
) werden ebenfalls ausgewiesen, womit sich Ausreisser besser
erkennen lassen.
Im obigen Beispiel kamen die 25% der schnellsten Anfragen in weniger als einer Millisekunde zurück, während die längste Anfrage fast 1.7 Millisekunden auf sich warten liess. Weil das arithmetische Mittel mit 1.18 Millisekunden über dem Median von 1.08 Millisekunden liegt, gibt es offenbar stärkere Ausreisser nach oben (d.h. langsamere) als nach unten (d.h. schnellere).
Client und Server wären also bereit um mal ordentlich DAMPF im Kessel zu machen!
Wechsel zwischen mod_php
und PHP-FPM
Für die Lasttests soll einfach zwischen mod_php
und PHP-FPM hin- und
hergewechselt werden können. Hierzu wird ein kleines Skript namens
toggle-fpm.sh
zur Verügung gestellt:
#!/usr/bin/bash
set -eu
function usage {
printf "usage: %s: [enable/disable]\n" $0
exit 1
}
if [ "$#" -ne 1 ]; then
usage
elif [ "$1" = 'enable' ]; then
sudo a2dismod php8.2 >/dev/null
sudo a2enconf php8.2-fpm >/dev/null
sudo a2enmod proxy_fcgi >/dev/null
sudo systemctl restart apache2.service
elif [ "$1" = 'disable' ]; then
sudo a2disconf php8.2-fpm >/dev/null
sudo a2dismod proxy_fcgi >/dev/null
sudo a2enmod php8.2 >/dev/null
sudo systemctl restart apache2.service
else
usage
fi
Mit ./toggle-fpm.sh enable
wird PHP-FPM
aktiviert; mit ./toggle-fpm.sh disbable
wird es deaktiviert und mod_php
aktiviert. Die derzeitig aktive PHP-Implementierung lässt sich via phpinfo()
in Erfahrung bringen.
So soll PHP-FPM deaktiviert werden, um etwas Last unter mod_php
zu erzeugen:
./toggle-fpm.sh disable
Worauf unsere PHP-Infoseite meldet:
Server API: Apache 2.0 Handler
Lasttest
Es sollen zwei Testreihen ausgeführt werden:
- viele kurze Requests
- Faktorisierung von 100 bis 999
- vier Worker mit je 250 Requests
- wenige lange Requests
- Faktorisierung von 10^9 bis (10^9)+10
- vier Worker mit je zehn Requests
Client und Server laufen auf einer virtuellen Maschine mit Debian 12 Bookworm, welcher 2 CPUs und 1024 MB Memory zur Verfügung stehen.
$ ./request0r -w 4 -r 250 'http://localhost/prime_factorization.php?lower=100&upper=999'
Requests:
Total Passed Failed Mean
1000 1000 0 8.236957ms
Percentiles:
0% 25% 50% 75% 100%
3.032697ms 4.006084ms 6.557438ms 10.872339ms 36.512993ms
$ ./request0r -w 4 -r 10 'http://localhost/prime_factorization.php?lower=1000000000&upper=1000000010'
Requests:
Total Passed Failed Mean
40 40 0 10.35933729s
Percentiles:
0% 25% 50% 75% 100%
9.231410123s 10.165946763s 10.259091202s 10.383718102s 11.504672585s
Und nach der Aktivierung von PHP-FPM (./toggle-fpm.sh enable
):
$ ./request0r -w 4 -r 250 'http://localhost/prime_factorization.php?lower=100&upper=999'
Requests:
Total Passed Failed Mean
1000 1000 0 7.723427ms
Percentiles:
0% 25% 50% 75% 100%
2.944284ms 5.398297ms 7.420241ms 9.426545ms 25.895501ms
$ ./request0r -w 4 -r 10 'http://localhost/prime_factorization.php?lower=1000000000&upper=1000000010'
Requests:
Total Passed Failed Mean
40 40 0 9.771434526s
Percentiles:
0% 25% 50% 75% 100%
9.378479225s 9.643530472s 9.724563396s 9.8192836s 10.709784636s
Auch wiederholte Testfälle ergeben das folgende Bild: PHP-FPM ist nicht nur im
arithmetischen Mittel leicht schneller als mod_php
(7.72 vs. 8.24
Millisekunden bzw. 9.77 vs. 10.38 Sekunden), sondern weist auch weniger
Ausreisser nach oben aus. Beim Median sieht das Bild aber anders aus: hier hat
zwar mod_php
die Nase bei vielen kurzen Requests leicht vorne (6.56 vs. 7.42
Millisekunden), PHP-FPM ist aber bei wenigen langen Requests schneller (9.72 vs.
10.26 Sekunden).
Übungen
Nun stellen sich zwei Fragen:
- Sind diese Messungen überhaupt statistisch relevant?
- Liesse sich PHP-FPM nicht noch etwas tunen?
Wer sich damit beschäftigen möchte, kann gerne folgende Übungen bearbeiten:
- Anhand des ursprünglichen DAMPF-Setups und der hier vorliegenden Anleitung soll das Setup nachgebaut und getestet werden. Erscheinen vergleichbare und v.a. wiederholbare Messresultate? Mit welchen Parametern (Anzahl Worker, Anzahl Requests, Unter- und Obergrenze der Faktorisierung pro Request) erhält man welche Zeiten?
- Unter
/etc/php/8.2/fpm/pool.d/www.conf
liesse sich der Prozess-Pool für PHP-FPM umkonfigurieren. Standardmässig wird ein dynamischer Prozesspool verwendet (pm = dynamic
). Interessante Optionen wärenpm.max_children
,pm.start_servers
,pm.min_spare_servers
undpm.max_spare_servers
, womit sich die Anzahl PHP-Prozesse steuern lässt. Weitere Direktiven mit dem Präfixpm
könnten auch eine Einfluss auf die Performance haben.