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
| Ereignis | Will veröffentlicht? | Typisches Timing |
|---|---|---|
Prozessabsturz (kill -9, Segfault, OOM-Kill) | Ja | Fast sofort – das Betriebssystem schließt den Socket |
| Eingefrorener Prozess, Socket noch offen | Ja | Nach 1,5 × Keep-Alive ohne Traffic |
| Netzausfall, Stromausfall, Kabel gezogen | Ja | Nach 1,5 × Keep-Alive (kein FIN oder RST kommt an) |
| Protokollfehler, Broker schließt die Verbindung | Ja | Sofort |
Sauberes DISCONNECT (Reason Code 0) | Nein | — |
MQTT-5-DISCONNECT mit Reason 0x04 | Ja | Sofort (plus Will Delay) |
| MQTT 5 mit Will Delay Interval | Ja, außer der Client verbindet sich rechtzeitig neu | Ablauf 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.
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:
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
python device.py dev1 & # prints: pid 41237
kill -9 41237Der 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)
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:
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:
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 DROPIn 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:
# 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
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.

