Node drain
Node drain takes one TBMQ node out of service gracefully. The node reports itself as out of service so the load balancer stops routing new connections to it, refuses new MQTT connections, and then disconnects its current sessions in batches. Once the node has no sessions left, you can stop or restart it without a sudden mass disconnect.
Use it before maintenance on a single node of a cluster: a rolling upgrade, a node replacement, or scaling the cluster down.
How a drain works
Section titled “How a drain works”Starting a drain on a node does the following:
- The node’s
nodeDrainhealth component switches toOUT_OF_SERVICE, which makes/actuator/healthreturn503. See Health API. - New MQTT CONNECT requests are refused right away. The MQTT listeners stay open, so a TCP probe on the MQTT port still succeeds and the node is not restarted by a liveness check while it drains.
- After a grace period for the load balancer (
NODE_DRAIN_LOAD_BALANCER_WAIT_MS, 20 seconds by default), the node starts disconnecting its sessions. It requests up toNODE_DRAIN_BATCH_SIZEdisconnects per batch, one batch everyNODE_DRAIN_BATCH_INTERVAL_MS. - The drain finishes as
DRAINEDwhen no sessions remain on the node, or asTIMED_OUTif sessions are still connected whenNODE_DRAIN_TIMEOUT_MShas passed since the drain started. The timeout includes the load balancer grace period.
Each batch re-reads the node’s session registry once the previous pass has gone through every session, so a client whose CONNECT was already in progress when the drain started is not refused; it connects and is disconnected by a later batch.
A drain applies to the node that receives the request only. It does not touch other nodes in the cluster.
A drained session is not closed by a client-sent DISCONNECT with reason code 0x00, so the client’s
Last Will is processed as for any other
ungraceful disconnect.
Before you drain
Section titled “Before you drain”The drain takes a node out of the load balancer only if the load balancer checks /actuator/health. Make sure the
readiness probe, or the load balancer’s health check, targets /actuator/health on the HTTP port, and keep liveness on
the MQTT port so the node is not restarted while it drains. See the readiness example in
Health API.
The reference deployments do not do this: the Kubernetes manifests use a tcpSocket readiness probe on port 1883,
and the Docker Compose HAProxy uses plain TCP checks on the MQTT ports. The MQTT listeners stay open during a drain, so
with those checks the node is not taken out of the load balancer. New connections keep reaching it and are refused
with the reason codes below, NODE_DRAIN_LOAD_BALANCER_WAIT_MS has no effect, and disconnected clients can be routed
straight back to the same node. Switch the health check first, or use another way to stop routing traffic to the node.
The grace period must cover detection time. The first batch starts NODE_DRAIN_LOAD_BALANCER_WAIT_MS after the drain
starts (20 seconds by default), whether or not the load balancer has noticed the node is unhealthy by then. Either
tighten the health check or raise the wait so that it is at least the time the check needs to fail:
- Kubernetes: roughly
periodSeconds × failureThreshold, plus up totimeoutSecondsfor a probe that hangs. The readiness example on the Health API page (periodSeconds: 30,failureThreshold: 3) needs up to 90 seconds, so with it setNODE_DRAIN_LOAD_BALANCER_WAIT_MSto90000or more, or shorten the probe interval. - Docker Compose HAProxy:
inter×fall. Withinter 5sand HAProxy’s defaultfall 3that is 15 seconds, which fits the default wait.
Docker Compose
Section titled “Docker Compose”In the reference haproxy.cfg, each of the four MQTT listen blocks (mqtt-in, mqtts-in, mqtt-ws-in,
mqtt-wss-in) checks its backends with a plain TCP check. Add an HTTP check against the broker’s HTTP port 8083 to
each block, and point the server lines at it with check port 8083. The block below is the reference mqtt-in with
only those two changes; every other line stays as shipped:
listen mqtt-in bind *:${MQTT_PORT} mode tcp option clitcpka # For TCP keep-alive timeout client 3h timeout server 3h option tcplog balance leastconn option httpchk GET /actuator/health server tbMqtt1 tbmq1:1883 check port 8083 inter 5s resolvers docker_resolver resolve-prefer ipv4 server tbMqtt2 tbmq2:1883 check port 8083 inter 5s resolvers docker_resolver resolve-prefer ipv4HAProxy treats an HTTP 503 as a failed check, so a draining node is marked down and receives no new connections, while
connections already open are not cut. Apply the same two changes to the other three blocks: add the option httpchk line
and add port 8083 after check on each server line, keeping each line’s existing backend address. Edit the file as described in
Docker Compose cluster setup,
then reload HAProxy.
Drain a node
Section titled “Drain a node”The drain API is part of the REST API and requires the SYS_ADMIN authority. Call the node you want to drain directly (its own address or pod address), not through the load balancer: the request is handled by whichever node receives it.
In addition, starting or cancelling a drain requires the
Administrator role. A Viewer can read the status but
gets 403 on POST and DELETE.
-
Read the node’s service ID. Send a
GETrequest to the node and takeserviceIdfrom the response:Terminal window curl -X GET \--header "X-Authorization: Bearer $ACCESS_TOKEN" \'http://NODE_ADDRESS:8083/api/node/drain' -
Start the drain. Pass the service ID as
expectedServiceId:Terminal window curl -X POST \--header "X-Authorization: Bearer $ACCESS_TOKEN" \'http://NODE_ADDRESS:8083/api/node/drain?expectedServiceId=tbmq1' -
Poll
GET /api/node/drainuntilstateisDRAINED. If it becomesTIMED_OUT, sessions are still connected: either cancel the drain, or continue and stop the node anyway, which disconnects the remaining sessions on shutdown. -
Stop the node.
expectedServiceId guards against draining the wrong node, for example when a request is routed to a different
node than intended. It must equal the node’s service ID, which is TB_SERVICE_ID when set and generated at startup
otherwise.
Drain API
Section titled “Drain API”| Method | Path | Description |
|---|---|---|
POST |
/api/node/drain?expectedServiceId=<id> |
Starts the drain and returns the status. Calling it again while the node is not ACTIVE changes nothing and returns the current status. |
GET |
/api/node/drain |
Returns the drain status of the node that receives the request. |
DELETE |
/api/node/drain?expectedServiceId=<id> |
Cancels the drain and returns the node to ACTIVE. Sessions already disconnected are not restored. |
POST and DELETE return 400 with error code 31 (BAD_REQUEST_PARAMS) when expectedServiceId is missing or does
not match the node that received the request.
A finished drain (DRAINED or TIMED_OUT) stays in that state, and the node stays out of service, until you cancel
it with DELETE or restart the node.
Status response
Section titled “Status response”{ "serviceId": "tbmq1", "state": "DRAINING", "initialSessions": 25000, "remainingSessions": 12000, "disconnectRequests": 13000, "startedAt": 1790000000000, "completedAt": 0}| Field | Description |
|---|---|
serviceId |
Service ID of the node that answered. |
state |
ACTIVE, DRAINING, DRAINED, or TIMED_OUT. |
initialSessions |
Number of sessions on the node when the drain started. 0 while ACTIVE. |
remainingSessions |
Number of sessions currently on the node. |
disconnectRequests |
Number of disconnects requested since the drain started. |
startedAt |
Time the drain started, in milliseconds since the Unix epoch. 0 while ACTIVE. |
completedAt |
Time the drain finished, in milliseconds since the Unix epoch. 0 until the drain reaches DRAINED or TIMED_OUT. |
What clients see
Section titled “What clients see”| Situation | MQTT 5.0 | MQTT 3.1.1 |
|---|---|---|
| New connection during a drain | CONNACK 0x9C Use another server |
CONNACK 0x03 Server unavailable |
| Session disconnected by a drain batch | DISCONNECT 0x9C Use another server |
Connection closed, no DISCONNECT packet |
MQTT 3.1.1 has no Use another server code, so those clients get the closest available one. Clients should reconnect through the load balancer, which routes them to another node once it treats the drained node as unhealthy — see Before you drain.
The disconnects are reported in the same places as other disconnects:
- The
clientDisconnects_totalmetric counts them, with no reason label. See Prometheus metrics. - The
CLIENT_DISCONNECTEDintegration event hasdisconnectReasonUSE_ANOTHER_SERVER. - A refused connection produces a
CLIENT_CONNECTION_FAILEDevent, withreasonCONNECTION_REFUSED_USE_ANOTHER_SERVERfor an MQTT 5.0 client andCONNECTION_REFUSED_SERVER_UNAVAILABLEfor an MQTT 3.1.1 client.
See Integrations for the event format.
Configuration
Section titled “Configuration”| Environment variable | Default | Description |
|---|---|---|
NODE_DRAIN_BATCH_SIZE |
1000 |
Maximum number of sessions asked to disconnect in one batch. |
NODE_DRAIN_BATCH_INTERVAL_MS |
1000 |
Delay between batches, in milliseconds. |
NODE_DRAIN_LOAD_BALANCER_WAIT_MS |
20000 |
Grace period, in milliseconds, between the node becoming unhealthy and the first batch. Set it to at least the time your load balancer needs to stop routing new connections to an unhealthy node. |
NODE_DRAIN_TIMEOUT_MS |
300000 |
Maximum drain duration, in milliseconds, before the drain is marked TIMED_OUT. |
The settings are read from the service.node-drain section of thingsboard-mqtt-broker.yml. Size the batch and
interval so that the reconnecting clients do not overload the rest of the cluster: the defaults disconnect up to 1000
clients per second, and all of them reconnect to the remaining nodes.
Audit log
Section titled Audit logStarting and cancelling a drain are recorded in the audit log
as Drain started and Drain cancelled actions on the Service info entity. Attempts that fail because
expectedServiceId is missing or wrong are recorded as failed actions; a request rejected for missing permissions is not
recorded.
Was this helpful?