Icinga 2.3

icinga_logo_200x69Mit fast 100 neuen Features ist die Version 2.3 einer unserer bisher größten Feature-Releases. So wurde beispielsweise die Konfigurationssprache um weitere Möglichkeiten erweitert (Funktionen, Flow Control, uvm.), noch dynamischere Konfiguration zu schreiben.
Eine genaue Beschreibung der wichtigsten Neuerungen gibt es im (englischen) Release-Blogpost zu finden.
Übrigens sind wir auf der diesjährigen CeBIT mit dem Icinga-Team vertreten und stehen gerne für Fragen zur Verfügung. Ihr findet uns am Icinga-Stand in Halle 6 Stand H16.

Icinga 2: Funktionen als Custom-Attribute

Seit der Version 2.2 unterstützt Icinga Arrays und Dictionaries in Custom-Attributen. Zusammen mit apply lassen sich diese wunderbar verwenden, um z.B. für einen Host mehrere HTTP-Vhosts zu definieren und diese jeweils durch einen einen einzelnen Service checken zu lassen.
Mit der Version 2.3, die planmäßig am 10. März erscheinen soll, wird es zusätzlich die Möglichkeit geben, für Custom-Attribute Funktionen zu verwenden:

object CheckCommand "random-text" {
  import "plugin-check-command"
  command = [ PluginDir + "/check_dummy", "0", "$text$" ]
  vars.text = {{ Math.random() * 100 }}
}

Durch die beiden geschweiften Klammern wird eine Funktion definiert. Diese wird von Icinga jedes Mal aufgerufen, wenn es den Wert des Custom-Attributs “text” benötigt. In diesem Beispiel würde dies dazu führen, dass der Check bei jeder Ausführung einen unterschiedlichen Zufallswert ausgibt.
Mit Hilfe von Funktionen ist es allerdings nicht nur möglich, einfache Werte zu berechnen. Der Benutzer kann mit if/else beispielsweise komplexere Logik einbauen:

vars.text = {{
  if (host.address == "127.0.0.1") {
    log("Dies ist ein Check für localhost.")
  }
  return "Test"
}}

Auch auf beliebige Attribute anderer Hosts oder Services lässt sich so zugreifen:

vars.text = {{
  "Der State von 'anderer-host' ist: " + get_host("anderer-host").state
}}

Neue Features in Icinga 2.3

Die nächste Major-Version von Icinga 2 wird einige interessante Features unterstützen, die es noch einfacher machen, Ausnahmen für Services zu definieren. Bis die 2.3 als Release-Version verfügbar ist, wird es zwar noch eine Weile dauern, aber hier gibt es schonmal einen kleinen Vorgeschmack:

Konditionale Statements

Auch bekannter als if/else: In 2.3 ist es möglich, Attribute nur dann zu setzen, wenn bestimmte andere Bedingungen erfüllt sind. Hier ein Beispiel:

object Host "localhost" {
  check_command = "hostalive"
  address = "127.0.0.1"
  vars.http_vhosts["icinga.org"] = {
    http_address = "icinga.org"
    interval = 1m
  }
  vars.http_vhosts["netways.de"] = {
    http_address = "netways.de"
  }
}
apply Service "vhost " for (vhost => config in host.vars.http_vhosts) {
  host_name = "localhost"
  check_command = "http"
  if (config.interval) {
    check_interval = config.interval
  } else {
    check_interval = 5m
  }
  assign where host.vars.http_vhosts
}

Entwicklungs-Konsole

Um Filterregeln für “apply” und auch andere Ausdrücke einfacher testen zu können, gibt es eine CLI-basierte Konsole, die beliebige Befehle auswerten kann und deren Ergebnis anzeigt:

$ icinga2 console
Icinga (version: v2.2.0-262-g7075607)
<1> => config = { http_address = "icinga.org", interval = 1m }
{"http_address":"icinga.org","interval":60.0}
<2> => if (config.interval) { check_interval = config.interval } else { check_interval = 5m }
60.0
<3> => check_interval
60.0

Prototypen

Alle eingebauten Datentypen (d.h. Strings, Zahlen, Arrays und Dictionaries) verfügen nun über Methoden. Mit Hilfe dieser Methoden können z.B. Dictionaries manipuliert werden:

<1> => vhosts = { "icinga.org" = { http_address = "icinga.org" }, "netways.de" = { http_address = "netways.de" } }
{"icinga.org":{"http_address":"icinga.org"},"netways.de":{"http_address":"netways.de"}}
<2> => vhosts.remove("icinga.org")
null
<3> => vhosts
{"netways.de":{"http_address":"netways.de"}}
<4> => vhosts.len()
1.0

Mit Dictionary#remove würden sich so z.B. bestimmte Dictionary-Items entfernen lassen, falls diese bei einem bestimmten Host bzw. Service nicht vorhanden sein sollen.

Warum SSL nicht funktioniert

Zugegeben, der Titel ist sehr provokativ formuliert. Es soll hier auch gar nicht darum gehen, irgendwelche Sicherheitslücken direkt im SSL-Protokoll (bzw. TLS) oder einer der vielzähligen Implementierungen aufzuzeigen. Vielmehr möchte ich zeigen, dass Verschlüsselung, wie sie heutzutage an vielen Stellen eingesetzt wird, oft nicht mehr tut, als den Benutzer in Sicherheit zu wiegen.
Viele Webseiten unterstützen inzwischen Verschlüsselung – nicht zuletzt, da beispielsweise Google dies seit einigen Monaten in die Bewertung des Rankings einfließen lässt.
Seine Ziele erreicht SSL hier allerdings leider nur bedingt. Es wird zwar sichergestellt, dass die Webseite wie auch Benutzereingaben auf dem Transportweg z.B. durch transparente Proxies nicht verändert werden.
Trotz Verschlüsselung lassen sich von einem Angreifer, der die Netzwerkverbindung passiv abhören kann, dennoch viele Informationen ableiten:
1. Üblicherweise unterstützen Webseiten Redirects, die von HTTP auf HTTPS umleiten. Dabei wird allerdings die erste HTTP-Anfrage ohne Verschlüsselung abgesendet, aus der sich beispielsweise die vollständige URL erfahren lässt.
Zwar gibt es für dieses Problem die HTTP-Erweiterung HSTS, aber zum einen wird sie nur von den wenigstens Webseiten unterstützt und zum anderen greift sie nicht beim ersten Request.
2. Webseiten integrieren prinzipbedingt oftmals Bilder oder JavaScript-Dateien von anderen Webseiten. Selbst wenn diese auch über HTTPS-URLs eingebettet wurden, lässt sich zumindest feststellen, welchen Organisationen diese externen Webseiten gehören.
Beim Aufrufen eines WordPress-Blogs wäre es so beispielsweise nicht verwunderlich, IP-Adressen von Facebook, Google, Twitter und WordPress zu sehen, was z.B. darauf hindeuten würde, dass der Blog über Plugins für Social Buttons verfügt.
3. Anhand der in Punkt 2 ermittelten Daten lässt sich bereits ein recht guter Fingerabdruck erstellen. Noch eindeutiger wird dieser, wenn man die Download-Größe der Webseiten-Requests beobachtet. Viele Webseiten inkludieren beispielsweise eine unterschiedliche Anzahl an Bildern, die dazu beitragen, dass sich die Download-Größe von Seite zu Seite signifikant unterscheidet. Der technische Begriff dafür ist Traffic Analysis.
Um mit diesen Informationen Rückschlüsse auf die aufgerufene URL zu ziehen, müsste der Angreifer über einen Index aller aufrufbaren Seiten verfügen, um deren Download-Größe mit der tatsächlich aufgerufenen Seite zu vergleichen. Bei öffentlich zugänglichen Seiten ließe sich dieser Index mit Hilfe eines Web-Crawlers mit moderatem Aufwand erstellen.
Die beschriebenen Probleme gibt es natürlich nicht nur bei Webseiten. Wenn ich beispielsweise einen Blick in mein E-Mail-Postfach werfe, finde ich gerade einmal eine Handvoll signierter E-Mails und eine einzige verschlüsselte E-Mail. Alle anderen E-Mails liegen unverschlüsselt auf dem E-Mailserver meines Providers (Google).
Fragen Sie einfach einmal einen Ihrer Bekannten, ob deren E-Mails verschlüsselt sind: Dank großflächiger Werbemaßnahmen wie “E-Mail made in Germany” und De-Mail wird Ihnen dieser die Frage sicher bejahen. Dumm nur, dass dabei nur die Verbindung zum E-Mailserver der Provider verschlüsselt ist, dieser aber dann die Mails im Klartext einsehen kann.

Config-Probleme mit Icinga 2 untersuchen

Um Konfigurationsfehler bei Icinga 2 in Zukunft noch einfacher untersuchen zu können, wird es in der nächste Woche erscheinenden Version 2.1 ein neues Tool geben:

# icinga2-list-objects
Object 'api' of type 'ApiListener':
  * templates = ['api']
    % modified in /etc/icinga2/features-enabled/api.conf, lines 5:1-11:1
  * bind_port = '5665'
    % modified in /etc/icinga2/features-enabled/api.conf, lines 10:3-10:20
  * __name = 'api'
  * ca_path = '/etc/icinga2/pki/ca.crt'
    % modified in /etc/icinga2/features-enabled/api.conf, lines 8:3-8:46
  * cert_path = '/etc/icinga2/pki/ztv.beutner.name.crt'
    % modified in /etc/icinga2/features-enabled/api.conf, lines 6:3-6:62
  * type = 'ApiListener'
  * key_path = '/etc/icinga2/pki/ztv.beutner.name.key'
    % modified in /etc/icinga2/features-enabled/api.conf, lines 7:3-7:61
[...]

Zunächst einmal listet icinga2-list-objects alle in den Konfigurationsdateien definierten Objekte auf. Auch Objekte, die mit “apply” erstellt wurden, sind in dieser Liste enthalten, wodurch sich mit wenig Aufwand prüfen lässt, ob “apply”-Regeln so funktionieren, wie man es sich vorstellt.
Zusätzlich werden zu jedem Objekt die Attribute inkl. deren Werte angezeigt. Zu jedem Attribut wird außerdem mit angegeben, an welchen Stellen in der Konfiguration es gesetzt bzw. überschrieben wurde.
Im Troubleshooting-Guide werden noch weitere Tipps beschrieben, wie Config-Fehler erkannt und behoben werden können.

Jabber-Notifications mit Icinga 2

Um mit Icinga 2 einfach Notifications an Jabber-Kontakte senden zu können, habe ich mir folgendes Script geschrieben:

#!/usr/bin/env python
import xmpp, os, sys
if len(sys.argv) < 3:
    print "Syntax:", sys.argv[0], " "
    sys.exit(1)
jid = xmpp.protocol.JID(os.environ["XMPP_USER"])
cl = xmpp.Client(jid.getDomain(), debug = [])
con = cl.connect()
cl.auth(jid.getNode(), os.environ["XMPP_PASSWORD"])
cl.sendInitPresence()
msg = xmpp.Message(sys.argv[1], sys.argv[2])
msg.setAttr('type', 'chat')
cl.send(msg)

Das Script wird dabei in /etc/icinga2/scripts mit dem Dateinamen jabber-notification.py abgelegt werden. Zusätzlich muss noch die Python-XMPP-Library installiert werden, für die es z.B. in Debian das Paket python-xmpp gibt.
Um das Script mit Icinga verwenden zu können, müssen zunächst folgende allgemeine Templates definiert werden:

template NotificationCommand "jabber-template" {
  import "plugin-notification-command"
  command = [
    SysconfDir + "/icinga2/scripts/jabber-notification.py",
    "$xmpp_recipient$",
    "$xmpp_message$"
  ]
  vars.xmpp_recipient = "$jabber$"
  // Wir übergeben den Benutzernamen und das Passwort per Environment-
  // Variablen an das Script, damit diese nicht per ps(1)
  // für andere Benutzer einsehbar sind.
  env = {
    XMPP_USER = "$xmpp_user$"
    XMPP_PASSWORD = "$xmpp_password$"
  }
}
template NotificationCommand "jabber-host-notification" {
  import "jabber-template"
  vars.xmpp_message = {{{Notification Type: $notification.type$
Host: $host.display_name$
Address: $address$
State: $host.state$
Date/Time: $icinga.long_date_time$
Additional Info: $host.output$
Comment: [$notification.author$] $notification.comment$}}}
}
template NotificationCommand "jabber-service-notification" {
  import "jabber-template"
  vars.xmpp_message = {{{Notification Type: $notification.type$
Service: $service.name$
Host: $host.display_name$
Address: $address$
State: $service.state$
Date/Time: $icinga.long_date_time$
Additional Info: $service.output$
Comment: [$notification.author$] $notification.comment$}}}
}

Den beiden Templates "jabber-host-notification" und "jabber-service-notification" fehlen dabei noch die Custom Attribute für den Jabber-Benutzernamen und -Passwort. Um diese anzugeben, definieren wir zwei Commands:

object NotificationCommand "jabber-host-netways" {
  import "jabber-host-notification"
  vars.xmpp_user = "jabber-user@example.org"
  vars.xmpp_password = "passwort"
}
object NotificationCommand "jabber-service-netways" {
  import "jabber-service-notification"
  vars.xmpp_user = "jabber-user@example.org"
  vars.xmpp_password = "passwort"
}

Anschließend können wir diese Commands für Notifications verwenden:

object User "gunnar" {
  vars.jabber = "gunnar@beutner.name"
}
apply Notification "jabber-host" to Host {
  command = "jabber-host-netways"
  users = [ "gunnar" ]
  assign where true
}
apply Notification "jabber-service" to Service {
  command = "jabber-service-netways"
  users = [ "gunnar" ]
  assign where true
}

Die Command-Templates sind dabei so parametrisiert, dass in den einzelnen Notifications bei Bedarf auch die Texte für die Jabber-Messages über das Custom-Attribut xmpp_message überschrieben werden können.

Benutzerfreundliche Fehlermeldungen

Der erste Schritt bei Fehlern ist meist der Versuch, anhand von Logmeldungen nachzuvollziehen, was die Anwendung zuletzt gemacht hat, bevor das Problem aufgetreten ist. Hier zeigt sich dann schnell, wie gut die Anwendung darauf vorbereitet ist, dem Benutzer bei der Fehlersuche zu helfen. Dazu einige Beispiel:

[2014-05-28 09:47:04 +0200] <Q #0x7f5a08c68780 W #0x7f5a08c688c0> critical/remote: Cannot connect to host 'voip.beutner.name' on port '5665'

Auf den ersten Blick sieht es so aus, als würde die Verbindung nicht aufgebaut werden können. Aber es fehlen einige wichtige Informationen, um die Logmeldung in den Kontext einordnen zu können:

  • Wer hat aus welchem Grund versucht die Verbindung aufzubauen bzw. was für eine Art Verbindung ist es? (Datenbank? Cluster? Was ganz anderes?)
  • Warum ist der Verbindungsaufbau gescheitert (“Connection refused” in diesem Fall, wenn man mit strace nachschaut, warum der connect()-Aufruf fehlschlägt)?
  • Welche Auswirkungen hat dies (in diesem Fall wird der Verbindungsaufbau periodisch erneut versucht, wodurch sich auch die Frage stellt, ob der Fehler wirklich “critical” ist)?

Die andere Seite des Spektrums bietet eine wahre Informationsüberflutung und ist mindestens genauso schlecht:

Caught unhandled exception.
Current time: 2014-05-28 09:46:51 +0200
***
* Application version: v2.0.0-beta1-8-g641ff1f
* Installation root: /home/gbeutner/i2
* Sysconf directory: /home/gbeutner/i2/etc
* Local state directory: /home/gbeutner/i2/var
* Package data directory: /home/gbeutner/i2/share/icinga2
* State path: /home/gbeutner/i2/var/lib/icinga2/icinga2.state
* PID path: /home/gbeutner/i2/var/run/icinga2/icinga2.pid
* Application type: icinga/IcingaApplication
***
/home/gbeutner/icinga2/lib/base/application.cpp(671): Throw in function void icinga::Application::UpdatePidFile(const icinga::String &, pid_t)
Dynamic exception type: boost::exception_detail::clone_impl<boost::exception_detail::error_info_injector<std::runtime_error> >
std::exception::what: Could not open PID file '/home/gbeutner/i2/var/run/icinga2/icinga2.pid'
[icinga::StackTrace*] =
(0) libbase.so: void boost::throw_exception<boost::exception_detail::error_info_injector<std::runtime_error> >(boost::exception_detail::error_info_injector<std::runtime_error> const&) (+0xb3) [0x7fec91f6b323] (throw_exception.hpp:61)
(1) libbase.so: void boost::exception_detail::throw_exception_<std::runtime_error>(std::runtime_error const&, char const*, char const*, int) (+0x66) [0x7fec91f64c96] (exception.hpp:276)
(2) libbase.so: icinga::Application::UpdatePidFile(icinga::String const&, int) (+0xe7) [0x7fec91f5de87] (application.cpp:671)
(3) libbase.so: icinga::Application::Run() (+0xd9) [0x7fec91f627f9] (basic_string.h:287)
(4) icinga2: Main() (+0x740c) [0x4250cc] (??:0)
(5) icinga2: main (+0x25) [0x4252a5] (??:0)
(6) libc.so.6: __libc_start_main (+0xfd) [0x7fec9023dead] (libc-start.c:276)
(7) ./sbin/icinga2() [0x41dbf9]
[icinga::ContextTrace*] =
***
* This would indicate a runtime problem or configuration error. If you believe this is a bug in Icinga 2
* please submit a bug report at https://dev.icinga.org/ and include this stack trace as well as any other
* information that might be useful in order to reproduce this problem.
***
Aborted

In dieser Fehlermeldung sind soviele Informationen versteckt, dass es für den Benutzer schon teilweise schwierig wird, überhaupt die eigentliche Fehlermeldung (“Could not open PID file ‘/home/gbeutner/i2/var/run/icinga2/icinga2.pid'”) zu finden. Und trotzdem fehlt hier eigentlich eine ganz entscheidende Information: Warum ist das Öffnen der Datei denn eigentlich gescheitert?
Für mich als Entwickler sind solche Fehlermeldungen natürlich sehr praktisch: Ich weiss genau – abgesehen vom fehlenden Fehlercode, in welcher Zeile und zu welchem Zeitpunkt der Fehler aufgetreten ist; die Fehlermeldung beinhaltet die Pfade zu wichtigen Dateien. Diese Details helfen mir, das Problem zu reproduzieren bzw. evtl sogar direkt anhand des Stacktraces zu finden.
Die beiden Beispiele verdeutlichen einige Eigenschaften, die Fehlermeldungen haben sollen, um den Benutzer bei der Fehlersuche zu helfen:

  • Sie sollten kurz und einfach zu verstehen sein (keine Stacktraces, Klassennamen, o.ä.)
  • Sie sollten alle wichtigen Informationen enthalten (Dateinamen, Fehlercode)
  • Sie sollten den Benutzer in Richtung der Problemlösung leiten (z.B. bei EPERM -> Hinweis auf Datei-Berechtigungen)
  • Zusätzlich sollten die für Entwickler wichtigen Informationen aber trotzdem bereitgehalten werden; vielleicht als Referenz auf eine separate Log-Datei, die dann auch gerne seitenlange Stacktraces enthalten kann

Dass bei den Logmeldungen von Icinga 2 dieses Ideal noch nicht erreicht ist, haben wir im Bugtracker zusammengefasst und arbeiten fleissig daran, dies noch zu verbessern. Wer beim Testen noch auf etwas unhandliche Fehlermeldungen stößt, sollte dazu Issues erstellen, damit wir diese für die Beta 2 noch anpassen können.

Icinga 2 und Auto-Discovery mit SNMP

Nachdem ich meine eigenen VMs lange Zeit mit “Monitoring per Jabber” überwacht habe (sprich: User schreiben mir Jabber-Nachrichten/Mails/u.ä., wenn etwas nicht mehr geht), habe ich mich endlich entschlossen, diese automatisiert zu überwachen.
Hierbei wollte ich meinen Usern auch gleich die Möglichkeit geben, zusätzlich zu den von mir vordefinierten Checks, eigene Services zu überwachen, ohne ihnen dabei direkt Zugriff auf meine Monitoring-Config zu geben.
Bei meinen Tests hat sich SNMP dabei als recht flexibel erwiesen. Zunächst bekommt jede VM eine eigene Community zugewiesen, damit die VMs nicht gegenseitig auf ihre Monitoring-Daten zugreifen können. Der jeweilige Community-Name ist dabei der HMAC-Hash der IP-Adresse. Der HMAC-Schlüssel ist dabei geheim und wird auf den VMs selbst nirgendwo gespeichert.
Die eigene Community bekommen die VMs über ein PHP-Script:

<?php
$pkey = "hmac_schlüssel_hier";
echo substr(hash_hmac("sha256", $_SERVER["REMOTE_ADDR"], $pkey), 0, 12);

Das Installationsscript für die VMs sieht dabei in etwa so aus:

#!/bin/sh
set -e
apt-get install -y snmpd nagios-plugins-basic
COMMUNITY=$(curl https://beutner.name/api/hostkey.php)
cat >/etc/snmp/snmpd.conf <<CONFIG
agentAddress udp:0.0.0.0:161
view systemonly included .1.3.6.1.4.1.8072.1.3
rocommunity $COMMUNITY default -V systemonly
rouser authOnlyUser
extend apt /opt/monitoring/exec_plugin /usr/lib/nagios/plugins/check_apt
extend disk /opt/monitoring/exec_plugin /usr/lib/nagios/plugins/check_disk -w 10 -c 5 -p /
extend swap /opt/monitoring/exec_plugin /usr/lib/nagios/plugins/check_swap -w 50 -c 25
extend procs /opt/monitoring/exec_plugin /usr/lib/nagios/plugins/check_procs -w 500 -c 1000
extend users /opt/monitoring/exec_plugin /usr/lib/nagios/plugins/check_users -w 10 -c 25
extend load /opt/monitoring/exec_plugin /usr/lib/nagios/plugins/check_load -w 5 -c 10
CONFIG
service snmpd restart
mkdir -p /opt/monitoring
cat >/opt/monitoring/exec_plugin <<SCRIPT
#!/bin/sh
output=\$(\$@ 2>&1)
status=\$?
echo \$status \$output
SCRIPT
chmod +x /opt/monitoring/exec_plugin

Bei meiner SNMP-Config habe ich bewusst darauf verzichtet, “normale” OIDs abzufragen, sondern verwende ausschließlich extend. Der Vorteil davon ist, dass ich auf Seite der Monitoring-VM ein einziges Plugin zum Checken aller Services benötige.
Um zu erkennen, welche Services eine VM hat, habe ich ein Script geschrieben, das anhand eines SNMP-Walks Icinga-Templates erstellt. Dieses läuft als Cronjob und aktualisiert in regelmäßigen Abständen die Config, sofern die jeweiligen Hosts erreichbar sind:

$ /opt/monitoring/discover-snmp.py --key hmac_schlüssel_hier 213.95.47.212
IP address: 213.95.47.212, SNMP Community: xxxxxxx
template Host "snmp-extend:213.95.47.212" {
  macros["community"] = "xxxxxxx",
  services["apt"] = {
    templates = [ "snmp-extend-service" ],
    check_command = "snmp-extend",
    macros["plugin"] = "apt"
  },
  services["disk"] = {
    templates = [ "snmp-extend-service" ],
    check_command = "snmp-extend",
    macros["plugin"] = "disk"
  },
  services["load"] = {
    templates = [ "snmp-extend-service" ],
    check_command = "snmp-extend",
    macros["plugin"] = "load"
  },
  services["swap"] = {
    templates = [ "snmp-extend-service" ],
    check_command = "snmp-extend",
    macros["plugin"] = "swap"
  },
  services["procs"] = {
    templates = [ "snmp-extend-service" ],
    check_command = "snmp-extend",
    macros["plugin"] = "procs"
  },
  services["users"] = {
    templates = [ "snmp-extend-service" ],
    check_command = "snmp-extend",
    macros["plugin"] = "users"
  },
}

Das Check-Plugin für den “snmp-extend”-Check fragt dabei mit snmpget die entsprechende OID ab. Prinzipiell wird hier SNMP eigentlich nur als Transport für die Check-Results verwendet.
Um das ganze noch etwas benutzerfreundlicher zu gestalten, wären noch einige Änderungen notwendig:

  • idealerweise gäbe es statt dem Installationsscript ein updatebares Debian-Paket (oder RPM, MSI, etc. – wobei ich selbst eigentlich nur Debian-VMs habe)
  • Die Services sollten jeweils in einer einzelnen Datei sein, dies würde das Aktualisieren der snmpd-Config erleichtern
  • Je nach Umgebung wäre es praktisch, snmpd auf einem anderen Port zu starten – und eine separate Config-Datei zu verwenden
  • SNMPv3 unterstützt Verschlüsselung; für meinen Anwendungsfall ist dies aber nicht notwendig, da die anderen VMs entweder auf demselben Server sind oder ausschließlich per VPN erreichbar sind

Statische Code-Analyse mit Coverity

Vor nicht allzu langer Zeit hatte ich bereits über das Code-Analyse-Feature von clang gebloggt. Ein anderes, eher aus dem kommerziellen Umfeld bekanntes Tool ist Coverity SAVE, das neben C und C++ auch C# und Java unterstützt.
Interessant für Projekte wie Icinga ist es deswegen, weil Coverity es gestattet, Open Source-Projekte registrieren zu lassen und diese dann regelmäßig z.B. per Integration in das eigene Build-System gratis überprüfen zu lassen. Dieses Angebot haben inzwischen etliche prominente Projekte angenommen, so beispielsweise auch der Linux-Kernel und der Apache-Webserver.
Seit Kurzem ist dort nun auch Icinga vertreten, wobei die eigentliche Arbeit damit nun erst anfängt – nämlich, die Issues zu kategorisieren und anschließend Patches zu bauen. Dabei findet Coverity einige interessante Bugs, die sonst so vermutlich Niemandem aufgefallen wären:
Coverity Scan: Icinga 1.x

Heap-Fragmentation analysieren

Bei Dateisystemen ist der Fragmentations-Effekt ziemlich bekannt:

  1. Zunächst werden viele Dateien erstellt.
  2. Einige dieser Dateien werden anschließend gelöscht.
  3. Wenn nun eine große Datei angelegt wird und anderweitig kein Platz mehr frei ist, wird diese in mehrere Teile aufgesplittet, um die entstandenen Lücken zu füllen. – Es entsteht Fragmentation.

Während das Problem bei Dateisystemen durch Neuanordnung der Dateiteile (Defragmentation) vergleichsweise einfach gelöst werden kann, gibt es beim Arbeitsspeicher ein ähnliches Problem, das sich nicht ganz so einfach in den Griff bekommen lässt.
Für malloc() muss die libc immer mehr Speicher vom Kernel anfordern, wenn kein Speicherbereich mehr frei ist, der groß genug ist. Dabei kann die libc bestehende Allocations nicht im Speicher verschieben, da sie nicht weiss, wo die Anwendung Pointer auf diese Allocations besitzt. Eine nachträgliche Defragmentierung ist somit nicht möglich.
Um dieses Problem zu veranschaulichen, habe ich einen Profiler geschrieben (fprofile.cpp), der malloc(), realloc() und free() instrumentiert, um beobachten zu können, wie sich die Speicherbenutzung bei meinem Testprogramm (Icinga 2) mit der Zeit verändert.
Der Profiler ist eine Library, die prinzipiell aus zwei Teilen besteht:

  • Einem eigenen Allocator: Der Profiler benötigt für die Profil-Daten selbst Speicher. Wenn ich hier malloc() verwenden würde, würde der Profiler eventuell selbst zur Heap-Fragmentation beitragen. Deswegen habe ich einen vergleichsweise naiven Allocator geschrieben, der intern mmap() verwendet.
  • Dem eigentlichen Profiler: Um malloc(), realloc() und free() durch meine eigenen Funktionen ersetzen zu können, muss die Library per LD_PRELOAD geladen werden. Der Profiler merkt sich, welche Speicherbereiche von malloc() “belegt” wurden und schreibt diese Informationen sekündlich als Bild in eine Datei. Die Wahl ist dabei auf das PNM-Format gefallen, da es trivial zu schreiben ist und keine externen Libraries benötigt (http://en.wikipedia.org/wiki/Portable_anymap).

Der Profiler kann mit folgendem Befehl kompiliert werden:

g++ -fPIC -shared -g -o libfprofiler.so fprofiler.cpp -pthread

Anschließend können wir unsere eigene Anwendung wie folgt profilen:

LD_PRELOAD=./libfprofiler.so ./sbin/icinga2 -c ...

Der Profiler erstellt nun im aktuellen Verzeichnis seine Reports:

$ ls *.pnm
frame100.pnm  frame105.pnm  frame110.pnm  frame115.pnm  frame120.pnm
frame101.pnm  frame106.pnm  frame111.pnm  frame116.pnm  frame121.pnm
frame102.pnm  frame107.pnm  frame112.pnm  frame117.pnm  frame122.pnm
frame103.pnm  frame108.pnm  frame113.pnm  frame118.pnm  frame123.pnm
frame104.pnm  frame109.pnm  frame114.pnm  frame119.pnm  frame124.pnm

Die Dateien können wir nun mit einem beliebigen Bildbearbeitungsprogramm öffnen (GIMP, EOG, usw.).
Jeder Pixel steht dabei für eine Speicherseite (4kB). Weiß bedeutet, dass zumindest ein Teil der Seite belegt ist. Schwarze Pixel stehen für komplett freie Seiten. Wenn zwischen den Adressen von zwei Allocations mehr als 100MB Unterschied ist, zeichnet der Profiler eine blaue Linie.
In den folgenden beiden Beispielreports ist der Speicherverbrauch von Icinga 2 zu sehen. Im ersten Bild ist Icinga 2 dabei gerade mit dem Parsen der Config fertig geworden – während das zweite Bild den Stand zeigt, nachdem Icinga 2 alle Speicherbereiche freigegeben hat, die der Config-Parser intern benötigt hat:
frame120
frame130
Sehr schön sieht man im zweiten Bild, wie der Heap nun ziemlich viele Lücken aufweist. Auch wenn rein rechnerisch nur ein Bruchteil des Speichers tatsächlich verwendet wird, kann die libc diese Speicherbereiche nicht an den Kernel zurückgeben.
Im Allgemeinen gibt es folgende Lösungsansätze, um Heap-Fragmentation zu vermeiden:

  • Unterschiedliche Heaps für Objekte mit unterschiedlicher Lebensdauer verwenden (z.B. eigener Heap für den Config-Parser)
  • Anzahl der gleichzeitig vorhandenen Allocations verringern
  • Speicherverbrauch allgemein verringern

Abschließend noch ein Tipp: Mit ffmpeg kann man aus den *.pnm-Dateien einen Film generieren:

ffmpeg -f image2 -r 1 -pattern_type glob -i 'frame*.pnm' -r 24 output.mp4