Tests de brokers

Tester le Last Will MQTT : simuler des déconnexions brutales

Testez le Last Will MQTT en simulant crashs (kill -9), processus gelés et coupures réseau, mesurez quand il se déclenche et vérifiez le will delay MQTT 5.

Mis à jour · 9 min de lecture

Tester le Last Will MQTT : simuler des déconnexions brutales

Pour tester un Last Will MQTT, il faut faire mourir le client salement. Le broker ne publie le will que lorsqu'une connexion se termine sans DISCONNECT en bonne et due forme — arrêter normalement votre application ne prouve donc rien. Tuez le processus avec kill -9, gelez-le avec kill -STOP ou coupez le réseau, puis observez le topic du will depuis un second client et notez quand il arrive. Un bon test inclut aussi le cas négatif : un arrêt propre ne doit pas déclencher le will.

Ce guide fournit un petit script d'appareil avec will, un observateur, les injections de pannes qui déclenchent chaque chemin de code et des tests du will delay de MQTT 5.

Quand le broker publie le will

ÉvénementWill publié ?Délai typique
Crash du processus (kill -9, segfault, OOM kill)OuiPresque immédiat — le système ferme le socket
Processus gelé, socket toujours ouvertOuiAprès 1,5 × keep-alive sans trafic
Coupure réseau, panne de courant, câble débranchéOuiAprès 1,5 × keep-alive (aucun FIN ni RST n'arrive)
Erreur de protocole, le broker ferme la connexionOuiImmédiat
DISCONNECT propre (reason code 0)Non—
DISCONNECT MQTT 5 avec reason 0x04OuiImmédiat (plus le will delay)
MQTT 5 avec Will Delay IntervalOui, sauf si le client se reconnecte à tempsExpiration du délai ou fin de session, selon ce qui arrive en premier

La ligne keep-alive explique pourquoi « mon will met une éternité » est si fréquent : avec un keep-alive de 60 secondes, un appareil cellulaire mort est signalé au bout d'environ 90 secondes. C'est le comportement correct, pas un bug — voir keep alive pour choisir l'intervalle. Le concept lui-même est présenté dans Last Will MQTT.

L'appareil testé

Ce script paho-mqtt 2.x se comporte comme un appareil bien conçu : il enregistre un will retenu offline, publie un online retenu à la connexion et, sur SIGTERM, s'arrête proprement (il publie lui-même offline et se déconnecte correctement). Vous obtenez ainsi un seul processus avec un chemin de sortie propre et plusieurs chemins sales.

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()

Dans un second terminal, lancez un observateur qui affiche un horodatage avec chaque message :

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

%I est un horodatage ISO-8601 et %r le flag retain : le will arrive comme message en direct (0), tandis qu'un ancien offline d'une exécution précédente apparaît avec 1 juste après l'abonnement. Vous pouvez aussi surveiller testmqtt/lwt-test/# dans le client MQTT en ligne, pratique quand vous coupez le réseau de la machine qui exécute l'appareil.

Scénario 1 : crash avec kill -9

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

L'observateur affiche offline en une seconde environ. SIGKILL ne peut pas être intercepté, le client n'envoie donc jamais de DISCONNECT ; le noyau ferme la connexion TCP et le broker la traite comme une déconnexion brutale. Un OOM kill ou un segfault se comportent de la même façon.

Scénario 2 : arrêt propre (contrôle négatif)

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

L'observateur affiche offline exactement une fois — publié par l'appareil lui-même, pas par le broker. Si un second offline apparaît peu après, le client est sorti sans DISCONNECT propre (par exemple, le processus s'est terminé avant l'envoi du disconnect). Réussir ce cas compte autant que le crash : un will qui se déclenche à chaque déploiement inonde les dashboards de fausses alertes.

Scénario 3 : gel et coupure réseau

Un processus gelé garde son socket ouvert, seul le keep-alive peut donc le détecter :

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"

Pour une vraie coupure réseau, bloquez le trafic sur la machine de l'appareil. Exécutez l'observateur ailleurs (ou dans le navigateur via le port WebSocket sécurisé), car ces règles bloquent aussi votre mosquitto_sub local :

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

Avec des conteneurs, docker pause reproduit le gel et docker network disconnect la coupure. Pour des exécutions reproductibles en CI, placez Toxiproxy entre le client et le broker et utilisez son toxic timeout.

Mesurez l'écart entre le dernier online et le offline dans la sortie de l'observateur. S'il est nettement supérieur à 1,5 × votre keep-alive, le client négocie un keep-alive différent de celui que vous pensez, ou un proxy ou load balancer intermédiaire maintient la connexion TCP en vie côté broker.

Scénario 4 : will delay MQTT 5

Le Will Delay Interval permet à un appareil de survivre à une brève coupure sans s'annoncer hors ligne. Le broker publie le will quand le délai expire ou quand la session se termine, selon ce qui arrive en premier, et l'annule complètement si le client se reconnecte à temps. Testez les deux issues :

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"

Si le test A produit encore un offline, vérifiez trois réglages : la reconnexion doit utiliser le même client ID, Clean Start doit être désactivé (un clean start termine l'ancienne session, ce qui déclenche le will aussitôt) et le session expiry doit être au moins égal au will delay — avec la valeur par défaut de MQTT 5, 0, la session se termine à la déconnexion et le délai est ignoré. Le guide du will delay interval détaille cette interaction.

Automatiser le test de crash

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()

Ajoutez le cas propre comme second test qui envoie SIGTERM et vérifie qu'un seul offline arrive. Si le test de crash échoue, parcourez Last Will non envoyé : mauvais topic de will, ACL qui refuse le topic du will, ou broker qui l'abandonne silencieusement. Associez le will à un message online retenu, comme ici, pour construire une détection de présence fiable.

Observez les messages Last Will en direct

Un Last Will n'est testé qu'une fois que vous l'avez vu se déclencher lors d'un crash, d'un gel et d'une coupure réseau — et rester silencieux lors d'un arrêt propre. Notez les délais à côté de vos réglages de keep-alive et de will delay, et vous saurez exactement combien de temps vos dashboards mettent à remarquer qu'un appareil a disparu.

Questions fréquentes

Pourquoi mon Last Will MQTT n'est-il pas envoyé quand j'arrête le client ?

Parce que le client s'est arrêté proprement. Un Last Will n'est publié que si la connexion se termine sans DISCONNECT normal : crash, panne réseau ou timeout de keep-alive. Un Ctrl+C dans une application qui appelle disconnect() est une sortie propre, le broker jette donc le will. Utilisez kill -9 ou coupez le réseau pour le tester.

Combien de temps le broker met-il à publier le Last Will ?

Si le processus client meurt, le système d'exploitation ferme le socket et le will est publié presque immédiatement. Si le réseau disparaît silencieusement, le broker attend que 1,5 fois l'intervalle de keep-alive s'écoule sans trafic. En MQTT 5, un Will Delay Interval ajoute un délai supplémentaire.

Pourquoi mon will est-il publié alors que le client s'est reconnecté pendant le will delay ?

Parce que la session s'est terminée. En MQTT 5, le will est publié quand le délai expire ou quand la session se termine, selon ce qui arrive en premier. Si le session expiry interval vaut 0, ou si le client se reconnecte avec Clean Start activé, l'ancienne session se termine et le will part. Reconnectez-vous avec le même client ID, Clean Start désactivé et un session expiry au moins égal au will delay.