Testes de brokers

Health check de broker MQTT: scripts para monitorar disponibilidade

Scripts de health check em Bash, Python e Node.js que conectam, publicam e recebem uma mensagem, com códigos de saída para cron, Nagios e probes do Kubernetes.

Atualizado · 9 min de leitura

Health check de broker MQTT: scripts para monitorar disponibilidade

Um health check de broker MQTT deve provar a única coisa de que seus dispositivos dependem: um cliente consegue conectar, assinar, publicar e receber a própria mensagem em poucos segundos. Qualquer coisa a menos — um ping, uma porta aberta, um processo rodando — pode continuar verde enquanto o broker recusa logins em silêncio ou para de rotear.

Abaixo estão três scripts prontos para uso (Bash, Python, Node.js) que fazem esse round trip e retornam códigos de saída amigáveis para monitoramento, além de exemplos para cron, probes do Kubernetes e alertas.

O que um health check precisa verificar

VerificaçãoDetectaNão detecta
Porta TCP aberta (nc -z)Host fora do ar, firewall, listener não vinculadoFalhas de autenticação, roteamento quebrado, disco cheio, erros de TLS
Processo rodandoBroker que caiuTudo o que acontece enquanto ele está "no ar" mas doente
Apenas CONNECTCredenciais, TLS, versão do protocolo, limites de conexãoACLs de assinatura, roteamento de mensagens
Connect + subscribe + publish + receiveTudo acima, mais roteamento e latência de entregaProblemas em outros listeners ou outros topics

A última linha é a que você deve automatizar. Cada execução deve usar um client ID único (duas verificações com o mesmo ID derrubam uma à outra — veja boas práticas de client ID no MQTT) e um topic único, para que uma mensagem atrasada de uma execução anterior nunca faça a execução atual parecer saudável.

Se você quer um diagnóstico pontual mais amplo em vez de um probe recorrente, comece por como verificar o status de um broker MQTT ou explore o broker de forma interativa no cliente MQTT online.

Códigos de saída que as ferramentas de monitoramento entendem

Todos os scripts deste artigo usam a convenção de plugins do Nagios, que Icinga, Zabbix, Checkmk, Sensu e a maioria dos wrappers de cron entendem:

Código de saídaEstadoSignificado nos nossos scripts
0OKO round trip da mensagem teve sucesso dentro do limite de alerta
1WARNINGO round trip teve sucesso, mas foi mais lento que o limite
2CRITICALConexão, assinatura, publicação ou entrega falhou ou expirou
3UNKNOWNA própria verificação não conseguiu rodar (ferramenta ausente, argumentos inválidos)

Kubernetes, systemd e cron só distinguem zero de diferente de zero, então os mesmos scripts funcionam em qualquer lugar.

Bash: mosquitto_sub + mosquitto_pub

A verificação mais rápida usa os clientes de linha de comando do Mosquitto (apt install mosquitto-clients ou brew install mosquitto). Um subscriber em segundo plano espera exatamente uma mensagem com timeout, e o publisher envia um token aleatório para o mesmo topic:

mqtt-health.sh
#!/usr/bin/env bash
# Round-trip health check. Exit: 0 OK, 2 CRITICAL, 3 UNKNOWN.
HOST="${MQTT_HOST:-broker.connectmq.com}"
PORT="${MQTT_PORT:-8883}"
TIMEOUT="${MQTT_TIMEOUT:-5}"
TOPIC="testmqtt/health/$(hostname)-$$"
TOKEN="ok-$(date +%s)-$RANDOM"

command -v mosquitto_pub >/dev/null || { echo "UNKNOWN - mosquitto clients not installed"; exit 3; }

OPTS=(-h "$HOST" -p "$PORT")
[ "$PORT" = "8883" ] && OPTS+=(--capath /etc/ssl/certs)
[ -n "$MQTT_USER" ] && OPTS+=(-u "$MQTT_USER" -P "$MQTT_PASS")

OUT=$(mktemp)
mosquitto_sub "${OPTS[@]}" -i "health-sub-$$" -t "$TOPIC" -q 1 -C 1 -W "$TIMEOUT" >"$OUT" 2>&1 &
SUB_PID=$!
sleep 1   # give the subscriber time to receive its SUBACK

if ! ERR=$(mosquitto_pub "${OPTS[@]}" -i "health-pub-$$" -t "$TOPIC" -q 1 -m "$TOKEN" 2>&1); then
  kill "$SUB_PID" 2>/dev/null
  echo "CRITICAL - publish failed: $ERR"; rm -f "$OUT"; exit 2
fi

wait "$SUB_PID"
RECEIVED=$(cat "$OUT"); rm -f "$OUT"

if [ "$RECEIVED" = "$TOKEN" ]; then
  echo "OK - round trip via $HOST:$PORT"; exit 0
fi
echo "CRITICAL - no message within ${TIMEOUT}s (${RECEIVED:-no output})"; exit 2

Detalhes importantes:

  • -C 1 faz o mosquitto_sub sair após uma mensagem; -W faz com que ele desista após o timeout em vez de travar para sempre.
  • O script compara o payload com o token em vez de confiar apenas no código de saída, então uma mensagem perdida no topic não pode gerar um falso OK.
  • O texto de erro dos clientes (por exemplo Connection error: Connection Refused: not authorised.) vai parar na linha de saída, que é exatamente o que você quer ver em um alerta. Consulte qualquer reason code na ferramenta de reason codes MQTT.
  • /etc/ssl/certs é o diretório de CAs no Debian, Ubuntu e Alpine. Em outros sistemas, passe --cafile com o seu bundle de CA.

Python: round trip com limite de latência

A versão em Python (paho-mqtt 2.x) mede o tempo de round trip e retorna WARNING quando ele é mais lento que um limite. Cada etapa tem um prazo, então o script nunca trava um agente de monitoramento:

mqtt_health.py
#!/usr/bin/env python3
"""MQTT round-trip health check. Exit: 0 OK, 1 WARNING, 2 CRITICAL."""
import os, sys, threading, time, uuid
import paho.mqtt.client as mqtt

HOST = os.getenv("MQTT_HOST", "broker.connectmq.com")
PORT = int(os.getenv("MQTT_PORT", "8883"))
TIMEOUT = float(os.getenv("MQTT_TIMEOUT", "5"))
WARN_MS = float(os.getenv("MQTT_WARN_MS", "500"))

topic = f"testmqtt/health/{uuid.uuid4().hex[:12]}"
token = uuid.uuid4().hex
connected, subscribed, received = threading.Event(), threading.Event(), threading.Event()
state = {"connack": None, "suback": None, "t0": 0.0, "rtt": 0.0}


def on_connect(client, userdata, flags, reason_code, properties):
    state["connack"] = reason_code
    connected.set()
    if not reason_code.is_failure:
        client.subscribe(topic, qos=1)


def on_subscribe(client, userdata, mid, reason_codes, properties):
    state["suback"] = reason_codes[0]
    subscribed.set()
    if not reason_codes[0].is_failure:
        state["t0"] = time.perf_counter()
        client.publish(topic, token, qos=1)


def on_message(client, userdata, msg):
    if msg.payload.decode() == token:
        state["rtt"] = (time.perf_counter() - state["t0"]) * 1000
        received.set()


def finish(code, text):
    print(text)
    sys.exit(code)


client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, client_id=f"health-{uuid.uuid4().hex[:8]}")
client.on_connect, client.on_subscribe, client.on_message = on_connect, on_subscribe, on_message
if os.getenv("MQTT_USER"):
    client.username_pw_set(os.environ["MQTT_USER"], os.getenv("MQTT_PASS"))
if PORT == 8883:
    client.tls_set()  # system CA bundle

try:
    client.connect(HOST, PORT, keepalive=30)
except Exception as exc:  # DNS, refused, TLS errors, timeouts
    finish(2, f"CRITICAL - cannot connect to {HOST}:{PORT}: {exc}")

client.loop_start()
try:
    if not connected.wait(TIMEOUT):
        finish(2, "CRITICAL - no CONNACK")
    if state["connack"].is_failure:
        finish(2, f"CRITICAL - connection refused: {state['connack']}")
    if not subscribed.wait(TIMEOUT):
        finish(2, "CRITICAL - no SUBACK")
    if state["suback"].is_failure:
        finish(2, f"CRITICAL - subscribe refused: {state['suback']}")
    if not received.wait(TIMEOUT):
        finish(2, f"CRITICAL - message not delivered within {TIMEOUT:.0f}s")
    rtt = state["rtt"]
    level, code = ("WARNING", 1) if rtt > WARN_MS else ("OK", 0)
    finish(code, f"{level} - round trip {rtt:.0f} ms | rtt={rtt:.1f}ms;{WARN_MS:.0f}")
finally:
    client.disconnect()
    client.loop_stop()

O texto após | são dados de desempenho do Nagios, então ferramentas que geram gráficos vão plotar o tempo de round trip de graça. Separar falhas de CONNACK, SUBACK e entrega faz diferença às 3 da manhã: "subscribe refused" aponta para uma mudança de ACL, "no CONNACK" para um broker sobrecarregado ou meio morto, e "not delivered" para roteamento ou bridging. Se as assinaturas forem recusadas inesperadamente, ACL do MQTT negada silenciosamente explica as causas mais comuns.

Node.js: MQTT.js 5

A mesma lógica com o MQTT.js 5 e sua API de promises. Um único timer global garante que o processo termine mesmo que algum callback nunca dispare:

mqtt-health.mjs
import mqtt from 'mqtt';
import { randomUUID } from 'node:crypto';

const url = process.env.MQTT_URL ?? 'mqtts://broker.connectmq.com:8883';
const timeoutMs = Number(process.env.MQTT_TIMEOUT_MS ?? 5000);
const warnMs = Number(process.env.MQTT_WARN_MS ?? 500);
const topic = `testmqtt/health/${randomUUID().slice(0, 12)}`;
const token = randomUUID();

function finish(code, text) {
  console.log(text);
  process.exit(code);
}
setTimeout(() => finish(2, `CRITICAL - no round trip within ${timeoutMs} ms`), timeoutMs);

const client = mqtt.connect(url, {
  clientId: `health-${randomUUID().slice(0, 8)}`,
  username: process.env.MQTT_USER,
  password: process.env.MQTT_PASS,
  connectTimeout: timeoutMs,
  reconnectPeriod: 0, // a health check must not retry silently
});

client.on('error', (err) => finish(2, `CRITICAL - ${err.message}`));

client.on('connect', async () => {
  try {
    const granted = await client.subscribeAsync(topic, { qos: 1 });
    if (!granted[0] || granted[0].qos > 2) finish(2, 'CRITICAL - subscribe refused');

    let t0 = 0;
    client.on('message', (_topic, payload) => {
      if (payload.toString() !== token) return;
      const ms = Math.round(performance.now() - t0);
      const [code, level] = ms > warnMs ? [1, 'WARNING'] : [0, 'OK'];
      client.end(true, () => finish(code, `${level} - round trip ${ms} ms | rtt=${ms}ms;${warnMs}`));
    });
    t0 = performance.now();
    await client.publishAsync(topic, token, { qos: 1 });
  } catch (err) {
    finish(2, `CRITICAL - ${err.message}`);
  }
});

Definir reconnectPeriod: 0 é importante: por padrão o MQTT.js reconecta para sempre, o que é ótimo para aplicações e péssimo para um probe que deve falhar rápido. Credenciais erradas aparecem como Connection refused: Not authorized ou Connection refused: Bad username or password, dependendo de como o broker as reporta.

Rodando a verificação de forma agendada

Cron com alertas

crontab -e
# every minute; alert only on non-zero exit
* * * * * /opt/checks/mqtt-health.sh >/tmp/mqtt-health.log 2>&1 || /opt/checks/notify.sh "$(cat /tmp/mqtt-health.log)"

A maioria dos agentes de monitoramento (Nagios, Icinga, Checkmk, Zabbix com external checks) consegue rodar qualquer um dos scripts diretamente como plugin. Serviços de uptime com um tipo de monitor MQTT também funcionam, mas um script dá a você controle sobre topics, QoS e limites. Para um dashboard completo, combine o probe com métricas do broker, como descrito em monitoramento de uptime MQTT e monitoramento com Prometheus e Grafana.

Probes do Kubernetes

Para um pod de Mosquitto auto-hospedado, um probe exec que faz um round trip local é muito mais útil que um probe tcpSocket. A imagem oficial eclipse-mosquitto já contém os clientes de linha de comando:

mosquitto-deployment.yaml (trecho)
containers:
  - name: mosquitto
    image: eclipse-mosquitto:2
    ports:
      - containerPort: 1883
    readinessProbe:
      tcpSocket:
        port: 1883
      periodSeconds: 5
    livenessProbe:
      exec:
        command:
          - sh
          - -c
          - >
            mosquitto_sub -h 127.0.0.1 -i k8s-probe-sub -t testmqtt/health/k8s -C 1 -W 5 &
            sleep 1;
            mosquitto_pub -h 127.0.0.1 -i k8s-probe-pub -t testmqtt/health/k8s -m ok;
            wait $!
      initialDelaySeconds: 10
      periodSeconds: 30
      timeoutSeconds: 8
      failureThreshold: 3

Se o seu listener exige autenticação, adicione -u/-P a partir de um Secret montado como variáveis de ambiente e libere o usuário do probe nas suas ACLs. Mantenha timeoutSeconds maior que o timeout do próprio script e ajuste failureThreshold para que uma única verificação lenta durante uma pausa de garbage collection não reinicie o broker. Mais detalhes de implantação estão em Mosquitto no Kubernetes.

Monitorando um broker gerenciado

Com um broker gerenciado você não controla o processo, mas continua dono do lado cliente do contrato: seu hostname, suas credenciais, suas ACLs e o caminho de rede a partir dos seus dispositivos. Uma verificação externa de round trip detecta credenciais expiradas, mudanças acidentais de ACL e problemas de rede do seu lado que o monitoramento do próprio provedor não consegue enxergar.

Testando a própria verificação

Um health check que nunca dispara é pior do que nenhum. Antes de confiar nele, force cada modo de falha uma vez:

  1. Rode normalmente e confirme o código de saída 0: ./mqtt-health.sh; echo $?.
  2. Use uma senha errada e confirme 2 com uma mensagem "not authorised".
  3. Aponte para uma porta fechada (MQTT_PORT=1999) e confirme que ele falha dentro do timeout em vez de travar.
  4. Defina MQTT_WARN_MS=1 no script Python ou Node e confirme que o caminho WARNING retorna 1.
  5. Acompanhe testmqtt/health/# no cliente online enquanto a verificação roda para ver o token chegar.

Veja as mensagens do health check ao vivo

Um bom health check de MQTT é pequeno, rápido e honesto: uma conexão, uma assinatura, uma mensagem, um timeout rígido e um código de saída claro. Mantenha o script no seu repositório ao lado da configuração do broker, rode-o de dentro e de fora da sua rede e você vai saber dos problemas do broker antes dos seus dispositivos.

Perguntas frequentes

Verificar se a porta 1883 está aberta é um bom health check de MQTT?

Não. Uma porta TCP aberta só prova que algo está escutando. Um broker pode aceitar conexões TCP e ainda assim rejeitar todo CONNECT, recusar assinaturas ou parar de rotear mensagens. Um health check de verdade conecta, assina, publica e espera pela própria mensagem.

Com que frequência um health check de MQTT deve rodar?

O comum é a cada 30 a 60 segundos para monitoramento externo e a cada 10 a 30 segundos para probes do Kubernetes. Use um client ID e um topic únicos por execução e mantenha o timeout menor que o intervalo, para que as verificações nunca se sobreponham.

Quais códigos de saída um script de health check deve retornar?

Siga a convenção do Nagios: 0 para OK, 1 para WARNING (por exemplo, um round trip lento), 2 para CRITICAL (falha de conexão, assinatura ou entrega) e 3 para UNKNOWN (a própria verificação não conseguiu rodar). Cron, systemd e Kubernetes só se importam com zero versus diferente de zero.