Broker-Tests

MQTT Last Will testen: unsaubere Verbindungsabbrüche simulieren

MQTT Last Will testen: Abstürze per kill -9, eingefrorene Prozesse und Netzausfälle simulieren, den Zeitpunkt messen und den MQTT-5-Will-Delay prüfen.

Aktualisiert · 9 Min. Lesezeit

MQTT Last Will testen: unsaubere Verbindungsabbrüche simulieren

Um einen MQTT Last Will zu testen, musst du den Client unsauber sterben lassen. Der Broker veröffentlicht den Will nur, wenn eine Verbindung ohne ordentliches DISCONNECT endet – deine App normal zu beenden beweist also gar nichts. Beende den Prozess mit kill -9, friere ihn mit kill -STOP ein oder kappe das Netzwerk, beobachte dann das Will-Topic von einem zweiten Client aus und notiere, wann der Will ankommt. Ein guter Test enthält auch den Negativfall: Ein sauberes Herunterfahren darf den Will nicht auslösen.

Dieser Guide liefert ein kleines Geräteskript mit Will, einen Beobachter, die Fehlerinjektionen für jeden Codepfad und Tests für den Will Delay von MQTT 5.

Wann der Broker den Will veröffentlicht

EreignisWill veröffentlicht?Typisches Timing
Prozessabsturz (kill -9, Segfault, OOM-Kill)JaFast sofort – das Betriebssystem schließt den Socket
Eingefrorener Prozess, Socket noch offenJaNach 1,5 × Keep-Alive ohne Traffic
Netzausfall, Stromausfall, Kabel gezogenJaNach 1,5 × Keep-Alive (kein FIN oder RST kommt an)
Protokollfehler, Broker schließt die VerbindungJaSofort
Sauberes DISCONNECT (Reason Code 0)Nein—
MQTT-5-DISCONNECT mit Reason 0x04JaSofort (plus Will Delay)
MQTT 5 mit Will Delay IntervalJa, außer der Client verbindet sich rechtzeitig neuAblauf des Delays oder Ende der Session, je nachdem, was zuerst eintritt

Die Keep-Alive-Zeile erklärt, warum „mein Will dauert ewig“ so häufig ist: Mit 60 Sekunden Keep-Alive wird ein totes Mobilfunkgerät nach rund 90 Sekunden gemeldet. Das ist korrektes Verhalten, kein Bug – zur Wahl des Intervalls siehe Keep Alive. Das Konzept selbst erklärt MQTT Last Will.

Das Gerät im Test

Dieses paho-mqtt-2.x-Skript verhält sich wie ein gut gebautes Gerät: Es registriert einen retained offline-Will, veröffentlicht beim Verbinden ein retained online und fährt bei SIGTERM sauber herunter (veröffentlicht selbst offline und trennt die Verbindung ordentlich). So hast du einen Prozess mit einem sauberen und mehreren unsauberen Exit-Pfaden.

device.py
import os, signal, sys, threading
import paho.mqtt.client as mqtt
from paho.mqtt.packettypes import PacketTypes
from paho.mqtt.properties import Properties

DEV = sys.argv[1] if len(sys.argv) > 1 else "dev1"
WILL_DELAY = int(os.environ.get("WILL_DELAY", "0"))      # seconds, MQTT 5
STATUS = f"testmqtt/lwt-test/{DEV}/status"

client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2,
                     client_id=f"testmqtt-lwt-{DEV}", protocol=mqtt.MQTTv5)
# client.username_pw_set("username", "password")

will_props = Properties(PacketTypes.WILLMESSAGE)
will_props.WillDelayInterval = WILL_DELAY
client.will_set(STATUS, "offline", qos=1, retain=True, properties=will_props)

def on_connect(client, userdata, flags, reason_code, properties):
    print(f"connected: {reason_code}, session present: {flags.session_present}", flush=True)
    client.publish(STATUS, "online", qos=1, retain=True)

client.on_connect = on_connect
stop = threading.Event()
signal.signal(signal.SIGTERM, lambda *_: stop.set())

connect_props = Properties(PacketTypes.CONNECT)
connect_props.SessionExpiryInterval = 300                # must be >= will delay
client.connect("broker.connectmq.com", 1883, keepalive=10,
               clean_start=False, properties=connect_props)
client.loop_start()
print("pid", os.getpid(), flush=True)

while not stop.is_set():
    stop.wait(1)

# graceful shutdown: report offline ourselves, then a normal DISCONNECT (no will)
client.publish(STATUS, "offline", qos=1, retain=True).wait_for_publish(5)
client.disconnect()
client.loop_stop()

Starte in einem zweiten Terminal einen Beobachter, der zu jeder Nachricht einen Zeitstempel ausgibt:

Observer
mosquitto_sub -h broker.connectmq.com -p 1883 -t 'testmqtt/lwt-test/#' -F '%I %r %t %p'

%I ist ein ISO-8601-Zeitstempel und %r das Retain-Flag: Der Will kommt als Live-Nachricht (0) an, während ein veraltetes offline aus einem früheren Lauf direkt nach dem Subscribe mit 1 erscheint. Du kannst testmqtt/lwt-test/# auch im Online-MQTT-Client beobachten – praktisch, wenn du das Netzwerk auf dem Rechner kappst, auf dem das Gerät läuft.

Szenario 1: Absturz mit kill -9

Crash the device
python device.py dev1 &          # prints: pid 41237
kill -9 41237

Der Beobachter gibt etwa innerhalb einer Sekunde offline aus. SIGKILL lässt sich nicht abfangen, der Client sendet also nie DISCONNECT; der Kernel schließt die TCP-Verbindung und der Broker wertet das als unsauberen Verbindungsabbruch. Ein OOM-Kill oder Segfault verhält sich genauso.

Szenario 2: sauberes Herunterfahren (Negativkontrolle)

Stop the device cleanly
python device.py dev1 & PID=$!
sleep 3; kill -TERM "$PID"

Der Beobachter zeigt genau ein offline – vom Gerät selbst veröffentlicht, nicht vom Broker. Siehst du kurz danach ein zweites offline, hat sich der Client ohne sauberes DISCONNECT beendet (zum Beispiel, weil der Prozess endete, bevor das Disconnect gesendet war). Diesen Fall richtig hinzubekommen ist genauso wichtig wie den Absturz: Ein Will, der bei jedem Deployment feuert, flutet Dashboards mit Fehlalarmen.

Szenario 3: Einfrieren und Netzausfall

Ein eingefrorener Prozess hält seinen Socket offen, also kann nur Keep-Alive ihn erkennen:

Freeze, then resume
python device.py dev1 & PID=$!
sleep 3; kill -STOP "$PID"   # paho's network thread stops: no PINGREQ
# observer: "offline" after ~15 s (1.5 x keep-alive of 10 s)
kill -CONT "$PID"            # the client wakes up, reconnects and publishes "online"

Für einen echten Netzausfall verwirfst du den Traffic auf dem Geräterechner. Lass den Beobachter woanders laufen (oder im Browser über den Secure-WebSocket-Port), denn diese Regeln blockieren auch dein lokales mosquitto_sub:

Blackhole the broker (Linux, root)
BROKER_IP=$(dig +short broker.connectmq.com | head -1)
sudo iptables -I OUTPUT -p tcp -d "$BROKER_IP" --dport 1883 -j DROP
sudo iptables -I INPUT  -p tcp -s "$BROKER_IP" --sport 1883 -j DROP
# ... wait for the will, then restore:
sudo iptables -D OUTPUT -p tcp -d "$BROKER_IP" --dport 1883 -j DROP
sudo iptables -D INPUT  -p tcp -s "$BROKER_IP" --sport 1883 -j DROP

In Containern reproduziert docker pause das Einfrieren und docker network disconnect den Ausfall. Für wiederholbare CI-Läufe setzt du Toxiproxy zwischen Client und Broker und nutzt dessen timeout-Toxic.

Miss in der Ausgabe des Beobachters den Abstand zwischen dem letzten online und dem offline. Ist er deutlich länger als 1,5 × dein Keep-Alive, handelt der Client ein anderes Keep-Alive aus als gedacht, oder ein Proxy oder Load Balancer dazwischen hält die TCP-Verbindung auf Broker-Seite am Leben.

Szenario 4: Will Delay in MQTT 5

Mit dem Will Delay Interval übersteht ein Gerät einen kurzen Aussetzer, ohne sich offline zu melden. Der Broker veröffentlicht den Will, wenn der Delay abläuft oder die Session endet – je nachdem, was zuerst eintritt –, und lässt ihn ganz weg, wenn sich der Client rechtzeitig neu verbindet. Teste beide Ergebnisse:

Will delay: reconnect in time vs. stay away
# A: crash and come back within the delay -> no will
WILL_DELAY=30 python device.py dev2 & PID=$!
sleep 3; kill -9 "$PID"
sleep 10
WILL_DELAY=30 python device.py dev2 & PID=$!      # "session present: True", no offline

# B: crash and stay away -> will about 30 s after the broker notices the loss
sleep 3; kill -9 "$PID"

Liefert Test A trotzdem ein offline, prüfe drei Einstellungen: Der Reconnect muss dieselbe Client-ID verwenden, Clean Start muss aus sein (ein Clean Start beendet die alte Session und löst den Will sofort aus) und die Session Expiry muss mindestens so lang sein wie der Will Delay – mit dem MQTT-5-Standardwert 0 endet die Session beim Disconnect und der Delay wird ignoriert. Der Guide zum Will Delay Interval erklärt das Zusammenspiel im Detail.

Den Absturztest automatisieren

test_lwt.py
import signal, subprocess, threading, time
import paho.mqtt.client as mqtt

TOPIC = "testmqtt/lwt-test/ci/status"

def test_will_is_published_on_crash():
    got = threading.Event()

    def on_message(client, userdata, msg):
        if msg.payload == b"offline" and not msg.retain:   # live will, not stale state
            got.set()

    obs = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2)
    obs.on_message = on_message
    obs.connect("broker.connectmq.com", 1883)
    obs.subscribe(TOPIC, qos=1)
    obs.loop_start()

    dev = subprocess.Popen(["python", "device.py", "ci"])
    time.sleep(3)                                # connect and publish "online"
    dev.send_signal(signal.SIGKILL)
    t0 = time.monotonic()
    try:
        assert got.wait(10), "Last Will not received within 10 s"
        print(f"will arrived after {time.monotonic() - t0:.1f} s")
    finally:
        obs.publish(TOPIC, b"", qos=1, retain=True).wait_for_publish(5)   # clear status
        obs.disconnect()
        obs.loop_stop()

Ergänze den sauberen Fall als zweiten Test, der SIGTERM sendet und prüft, dass genau ein offline ankommt. Schlägt der Absturztest fehl, arbeite Last Will wird nicht gesendet durch: falsches Will-Topic, eine ACL, die das Will-Topic verweigert, oder ein Broker, der ihn stillschweigend verwirft. Kombiniere den Will wie hier mit einer retained online-Nachricht, um eine zuverlässige Presence Detection zu bauen.

Last-Will-Nachrichten live beobachten

Ein Last Will ist erst getestet, wenn du gesehen hast, dass er bei einem Absturz, einem Einfrieren und einem Netzausfall feuert – und bei einem sauberen Herunterfahren still bleibt. Notiere die Zeiten neben deinen Keep-Alive- und Will-Delay-Einstellungen, dann weißt du genau, wie lange deine Dashboards brauchen, um zu merken, dass ein Gerät weg ist.

Häufig gestellte Fragen

Warum wird mein MQTT Last Will nicht gesendet, wenn ich den Client stoppe?

Weil der Client sauber beendet wurde. Ein Last Will wird nur veröffentlicht, wenn die Verbindung ohne normales DISCONNECT endet: bei einem Absturz, einem Netzwerkfehler oder einem Keep-Alive-Timeout. Ctrl+C in einer App, die disconnect() aufruft, ist ein sauberer Exit – der Broker verwirft den Will. Teste mit kill -9 oder einer Netzwerkunterbrechung.

Wie lange dauert es, bis der Broker den Last Will veröffentlicht?

Stirbt der Client-Prozess, schließt das Betriebssystem den Socket und der Will wird fast sofort veröffentlicht. Verschwindet das Netzwerk still, wartet der Broker, bis das 1,5-Fache des Keep-Alive-Intervalls ohne Traffic vergangen ist. In MQTT 5 kommt ein Will Delay Interval noch obendrauf.

Warum wird mein Will veröffentlicht, obwohl sich der Client innerhalb des Will Delay neu verbunden hat?

Weil die Session beendet wurde. In MQTT 5 wird der Will veröffentlicht, wenn der Delay abläuft oder die Session endet – je nachdem, was zuerst eintritt. Ist das Session Expiry Interval 0 oder verbindet sich der Client mit Clean Start neu, endet die alte Session und der Will geht raus. Verbinde dich mit derselben Client-ID, ohne Clean Start und mit einer Session Expiry, die mindestens so lang ist wie der Will Delay.