zurück zum Artikel

OpenSSL: Implementierung innerhalb eines Client- und Server-Programms, Teil 3

Oliver Müller

Den funktionalen Anwendungstest besteht das Gespann aus WOPR-Server und -Client aus den vergangenen beiden Artikeln ohne Probleme. WOPR macht das, was erwartet wird. Wie sieht es jedoch im Dauerbetrieb und unter Last aus?

Das Gespann aus WOPR-Server und -Client aus den vergangenen beiden Artikeln funktioniert zweifellos, und zwar auch unverändert auf einer Vielzahl von Systemumgebungen. Den funktionalen Anwendungstest besteht WOPR ohne Probleme. WOPR macht das, was erwartet wird. Wie sieht es jedoch im Dauerbetrieb und unter Last aus?

In den vergangenen beiden Beiträgen wuchs ein einfacher OpenSSL-Server samt zugehörigem Client heran. Dieser Artikel betrachtet nun einige praktische Aspekte, die es im Dauerbetrieb zu berücksichtigen gilt.

Produziert sei zunächst kontinuierliche Last. Über eine einfache (Endlos-)Schleife auf der Unix-Shell lässt sich ein auf 127.0.0.1:2506 lauschender WOPR-Server automatisiert mit Terminal-Sessions unter Last setzen. Um die Last zu erhöhen, ist die Schleife in mehreren Shell-Fenstern beziehungsweise auf mehreren Konsolen zu starten.

while [ 1 ]; do
( echo help games; sleep 1
echo joshua ; sleep 1
echo list games; sleep 1
echo disconnect )|./terminal 127.0.0.1:2506 servercert.pem
done
Mehr Infos

OpenSSL – Struktur und Beispielcode

Den für die Umsetzung in der Praxis benötigten Beispielcode findet man hier [3] (mueller_wopr-1.01.tar).

Das Resultat ist anfänglich wie erwartet: Ein Terminal etabliert eine Verbindung zum Server, setzt einige Befehle ab, trennt sich wieder vom Server und beginnt von Neuem. Die Meldungen rattern monoton über den Bildschirm. Doch plötzlich kommt alles unerwartet zum Stillstand. Der Server hält wie versteinert inne und bearbeitet keine weiteren Verbindungsversuche.

Wäre der Programmablauf inkorrekt, wäre zu erwarten, dass sich das Programm mit Fehlermeldungen oder mit einem Core-Dump verabschiedet. Da es nach anfänglich korrekter Funktion einfach stehen bleibt, liegt es nahe, dass es gegen eine systemdefinierte Begrenzung läuft. Von den Limits auf Unix (siehe ulimit -a) kommen nur die Anzahl offener Dateien und der allozierte Arbeitsspeicher in Betracht. Maximale Datei- und Coredump-Größen können das Problem nicht verursachen. Die Anzahl der (Kind-)Prozesse wäre bei fork() noch eine Überlegung wert. Da sich die diese Prozesse jedoch nach der Bearbeitung der Terminals wieder beenden, können maximal so viele Terminals zur gleichen Zeit mit dem Server verbunden sein, wie while-Schleifen in Shell-Fenstern beziehungsweise Konsolen gestartet wurden. Wäre das die Ursache des Fehlers, würden Meldungen der Art fork: resource temporarily unavailable darauf hindeuten.

Um nun die Übeltäter zu identifizieren, ist der WOPR-Server (erneut!) zu starten. Je nachdem ob das System eher UNIX System V oder BSD folgt, ermittelt danach ps -af|fgrep wopr oder ps ax|fgrep wopr die PID des Servers. Hat man diese als Argument dem folgenden Korn-Shell-Skript chkrsc als Parameter übergeben, startet darauf die Überwachung von Memory und File-Deskriptoren. Das Skript funktioniert auf den gängigen Unix-/Linux-Systemen, die eine Korn-Shell, GNU diff und lsof bereitstellen. Außen vor bleiben Systeme, die im ps keine Informationen über das verwendete RAM ausgeben oder kein lsof zur Verfügung stellen können, wie Cygwin und z/OS UNIX System Services.

#!/bin/ksh
if [ -z "$1" -o "$3" ] ; then
echo "Usage: ${0##*/} PID [ps-options]"
exit 1
fi

# script's name without path
ME=${0##*/}

# observed PID
OSVPID=$1

# options for ps
unset PSPREFIX
if [ "$2" ] ; then
PSOPT="$2"
else
case `uname` in
OS/390)
echo "OS/390 and z/OS are not supported." >&2
echo "ps which provides information about memory" >&2
echo "(main storage) usage is needed by $ME" >&2
echo "but the ps of UNIX System Services doesn't" >&2
echo "provide these information." >&2
exit 1
;;
CYGWIN*)
echo "Cygwin is not supported." >&2
echo "ps with -o flag and lsof are needed by $ME" >&2
echo "but aren't provided by Cygwin." >&2
exit 1
;;
AIX)
PSOPT="-o pid,ppid,rssize,vsz,command -p $OSVPID";;
OSF1)
PSOPT="-o pid,ppid,sz,vsz,comm,args -p $OSVPID";;
HP-UX)
PSPREFIX="UNIX95= "
PSOPT="-e -o pid,ppid,sz,vsz,comm,args -p $OSVPID";;
Linux)
PSOPT="-o pid,ppid,rss,vsz,command -p $OSVPID";;
NetBSD|OpenBSD|FreeBSD)
PSOPT="-a -o pid,ppid,rss,vsz,command -p $OSVPID";;
SunOS)
PSOPT="-o pid,ppid,rss,vsz,comm -p $OSVPID";;
*) echo "Operating system `uname` not configured yet." >&2
echo "Use command line argument ps-options, please." >&2
exit 1
;;
esac
fi

function trap_cleanup {
rm -f /tmp/$ME.?.$$.txt >/dev/null 2>&1
exit
}

function openfiles {
lsof -n -P -p $OSVPID 2>/dev/null | awk '
### EMBEDDED AWK : BEGIN ###
$1 == "COMMAND" { next }
{ for(n=4;n<NF;n++) { printf("%s ", $(n)) }
if($(NF) !~ /^\(.*\)$/) print $(NF)
else print "" }
### EMBEDDED AWK : END ###
' | sort
}

# set trap handler for clean up
# in case of SIGTERM and SIGINT
trap trap_cleanup INT TERM

x=start
openfiles > /tmp/$ME.a.$$.txt
while [ 1 ] ; do
xx=`$PSPREFIX ps $PSOPT | awk '$1 != "PID" { print $0 }'`
if [ "$x" != "$xx" ] ; then
echo $xx
x="$xx"
fi
openfiles > /tmp/$ME.b.$$.txt
y=`diff -U 0 /tmp/$ME.a.$$.txt /tmp/$ME.b.$$.txt`
if [ "$y" ] ; then
echo "$y" | awk '$1 == "+++" || $1 == "---"
|| $1 == "@@" { next } { print $0 }'
mv /tmp/$ME.b.$$.txt /tmp/$ME.a.$$.txt
fi
done

Daraufhin meldet das Skript eine Zeile der Form:

8521 8373 2332 21704 ./wopr *:2506 servercert.pem private.key

Sie zeigt von links nach rechts: PID, PPID, allozierten physischen (RSS) und virtuellen Speicher sowie die Kommandozeile des untersuchten Prozesses. Das Skript gibt bei jeder Veränderung dieser Werte jeweils eine neue solche Zeile aus. Des Weiteren listet es das Öffnen und das Schließen von Dateien.

Sobald sich ein Terminal auf den WOPR-Server verbindet, ändert sich die Anzeige:

+4u IPv4 46050 0t0 TCP 127.0.0.1:2506->127.0.0.1:50913
8521 8373 2368 21704 ./wopr *:2506 servercert.pem private.key

Es ist eine neue geöffnete Datei hinzugekommen (+4u), nämlich ein TCP-Socket. Sobald der Client terminiert, ändert sich wieder das Bild:

-4u IPv4 46050 0t0 TCP 127.0.0.1:2506->127.0.0.1:50913
+4u sock 0,6 0t0 46050 can't identify protocol

Das TCP-Socket wechselt von "verbunden mit 127.0.0.1:50913" auf "can't identify protocol". Interessanterweise bleibt es geöffnet, andernfalls hätten sich die Anzahlen von +4u und -4u aufgehoben. Das geschieht bei jeder Verbindung eines Clients. Offensichtlich bleiben hier File-Deskriptoren beziehungsweise Sockets geöffnet.

Das ist ein typisches Problem bei fork()-basierten Netzwerkprogrammen. In der Methode
WOPR_server::run() holt client = BIO_pop(abio) ein BIO mit der Client-Verbindung aus der aktuellen Accept-Chain. Mit dem BIO ist automatisch ein geöffnetes Socket und damit ein File-Deskriptor verbunden. In dem Server ist somit eine neue geöffnete Datei hinzugekommen, und sie ist nach dem fork() sowohl im Eltern- als auch im Kindprozess vorhanden. Schließlich ist letzterer eine Kopie des ersteren. Wohingegen der Client spätestens beim Terminieren sein Socket schließt, erfolgt das beim Server niemals. Der betreffende File-Deskriptor bleibt bei ihm geöffnet. Er ändert lediglich seinen Status von einer konkreten TCP-Verbindung auf "undefiniertes Protokoll". Früher oder später läuft der Server daher gegen sein Limit geöffneter Dateien.

Die Lösung ist einfach: In dem switch über der PID ist beim default-Label für den Elternprozess einfach die Zeile

close(BIO_get_fd(client, NULL));

aufzunehmen. Sie ermittelt den File-Deskriptor aus dem BIO-Objekt client und schließt es durch close(). Da die Operation bereits nach dem fork() und nur im Elternprozess erfolgt, bleibt die File-Deskriptor-Kopie im Kindprozess (also im Client) geöffnet. Der Client kann somit ohne weiteres die eingegangene Verbindung bedienen.

Warum so umständlich und nicht einfach BIO_pop() komplett in den Client verlagern? Ganz einfach: Der BIO_pop() ist in jedem Fall auch für den Server notwendig. Der Aufruf entfernt das BIO mit der eingegangenen Verbindung aus der Accept-Chain. Wenn dieser Aufruf im Server nicht erfolgt, würde das Client-BIO die Accept-Chain "verstopfen". Weitere eingehende Verbindungen wären dann unerreichbar. Außerdem blieben dadurch die File-Deskriptoren der Sockets trotzdem im Server geöffnet.

Wer jedoch die eingangs eingeführte while-Schleife gegen den mit chkrsc überwachten Prozess laufen lässt, stellt noch etwas anderes fest. Zeilen der Form

8521 8373 2376 21704 ./wopr *:2506 servercert.pem private.key

tauchen immer wieder auf. Der dritte Wert (RSS; Resident Set Size) steigt ebenfalls kontinuierlich. Ein deutliches Zeichen für ein Memory Leak: Speicher wird alloziert, aber nicht wieder freigegeben. Nach dem Abbrechen der while-Schleife durch Ctrl + C geht der Speicher allerdings nicht weiter hoch. Das Problem hängt also mit dem Verbinden von Clients zusammen.

Um dem Fehler auf die Schliche zu kommen, sei ein wenig mit den Limits für RSS und dem virtuellen Speicher gespielt sowie in der Folge die RSS auf ein wenig höheres Maß begrenzt, als WOPR beim Start alloziert, zum Beispiel 2350, und den virtuellen Speicher auf den beim Start allozierten Wert, hier also 21704. Auf der Bash-Shell erledigt das beispielsweise ulimit -m 2350 -v 21704. Nach einem Neustart von WOPR und dem Ansetzen der while-Schleife dauert es nicht lange, bis Meldungen der folgenden Form erscheinen:

2070:error:1409C041:SSL 
routines:SSL3_SETUP_BUFFERS:malloc failure:s3_both.c:620:

Ein kritischer Blick in WOPR_server::run() offenbart schnell das Geheimnis. Noch vor fork() erzeugt ssl = SSL_new(ctx) einen neuen SSL-Kontext. Ähnlich wie beim File-Deskriptor zuvor wird dieser Kontext beim Kindprozess explizit durch SSL_free() geschlossen. Beim Elternprozess hingegen bleiben der SSL-Kontext dauerhaft geöffnet und sein Speicher alloziert.

Die Lösung ist ebenfalls einfach: Der Elternprozess beziehungsweise der Server benötigt den Kontext überhaupt nicht. Es macht daher Sinn, das Erzeugen des SSL-Kontexts nach fork() und einzig in den Kindprozess zu verlagern.

Eine von beiden Fehlern bereinigte Variante ist WOPR-Version 1.01. Wer sie startet und chkrsc darauf ansetzt, stellt fest, dass die Zeilenschlacht von Version 1.00 nicht mehr auftritt.

Nebenläufige Programme haben ihre besonderen Tücken und Eigenheiten. Dies gilt nicht nur für Multithreaded-Programme mit prozessinterner Nebenläufigkeit. Auch über mehrere Prozesse parallelisierte Abläufe benötigen ein besonderes Augenmerk. Die Beispielsituationen aus diesem Beitrag zeigen das deutlich.

Jeder fork() kopiert auch File-Deskriptoren und andere Verbindungs-Handles. Sie existieren daher sowohl im Eltern-, als auch im Kindprozess und sind somit doppelt referenziert. Dieses Detail gerät leicht und oft aus den Augen und aus dem Sinn. Die Auswirkungen sind im Dauerbetrieb fatal, wie WOPR 1.00 zeigt.

Ebenso gilt es klar zu überlegen, wo neu erzeugte Datenstrukturen wirklich nötig sind. Alles vor dem fork() Erzeugte bleibt auch im Elternprozess erhalten. Wird es dort nicht (auch) wieder freigegeben, entsteht ein typisches Memory Leak. Fatal bei Netzwerkprogrammen ist daran: Das Speicherleck liegt im zentralen Elternprozess, der das Lauschen auf den TCP-Port übernimmt. Stürzt dieser Elternprozess ab, gehen neue Verbindungen ins Leere.

Die beiden Fehlerbeispiele aus diesem Beitrag sind klassisch für die Programmierung mit fork(). Die einzelnen, betriebssystemseitig getrennten Prozesse gaukeln jeweils für sich genommen eine vermeintliche rein sequenzielle Welt vor. Ein fork() ist jedoch ganzheitlich gesehen eine Verzweigung im Programmablauf mit zwei unabhängigen parallel ablaufenden Strängen. Die beiden Stränge führen allerdings nie wieder zusammen. Zusammen mit den verdoppelten Daten und Handles ergibt das eine Mischung mit Konfliktpotenzial, die beachtet sein will.

Oliver Müller
ist Geschäftsführer der OMSE Software Engineering GmbH. Er leitet die Bereiche Software Engineering, Nearshoring und IT-Sicherheit.
(ane [4])


URL dieses Artikels:
https://www.heise.de/-1157455

Links in diesem Artikel:
[1] https://www.heise.de/ratgeber/OpenSSL-Implementierung-innerhalb-eines-Client-und-Server-Programms-Teil-1-1050619.html
[2] https://www.heise.de/ratgeber/OpenSSL-Implementierung-innerhalb-eines-Client-und-Server-Programms-Teil-2-1058771.html
[3] ftp://ftp.heise.de/pub/ix/developer/
[4] mailto:ane@heise.de