Concluded·10 Aug 2026 · 8 min read

Three sites into a redundant pair: a kill battery, and the one connection that pointed the wrong way

Ignition 8.3.8 ×5 (redundant pair + 3 edge sites) · Event Streams 1.3.8 · Kafka Connector 1.3.8 · Redpanda v26.2.1 ×3 RF-3 · Redpanda Connect 4.103.1 · TimescaleDB pg16 primary + streaming replica · HAProxy lts-alpine

A redundant pair, three sites feeding it, and a historian with a hot standby. The drawing is unremarkable — most estates look like this. The question was which of its failures actually cost plant data, and the honest answer took two runs of the same test, because the first run measured a rig I had wired backwards.

The rig

CentralIgnition 8.3.8 redundant pair, central-a master / central-b backup
SitesThree Ignition gateways — Rotterdam, Houston, Leipzig — each with a simulated device and an 81-tag provider
Estate modelRemote Tag Providers over the Gateway Network, one per site, mounted on the central pair
ProducerEvent Streams on the central master, 18 tags, per-change, keyed by tag path
BrokerRedpanda v26.2.1 ×3, topic estate.tags, 6 partitions, RF-3
ConsumerRedpanda Connect, consumer group, Bloblang splits [Site]path into columns
HistorianTimescaleDB hypertable, primary plus a streaming replica at ~5 ms replay lag
HMIPerspective on the pair, behind an HAProxy VIP
sites
RotterdamHoustonLeipzig
one gateway each, 81 tags, simulated device
central
masterbackup
redundant pair · owns the estate tag model
stream
broker 0broker 1broker 2
RF-3 · one topic, six partitions
consume
connect
consumer group · at-least-once + unique key
store
primaryreplica
hypertable · ~5 ms replay lag
The estate as built. Every layer below the central pair has a partner that can lose a member without the layer stopping; the sites do not, which is where the remaining risk turns out to live.

The instrument is one tag per site. Each site's Ramp0 advances once a second, so ten rows per ten-second bucket is health and anything less is loss, per site, with nowhere for a gap to hide. Every number below is that count.

The finding: a connection's direction decides whether data survives

The headline test is killing the master while the estate is running. The first time I ran it, the control plane behaved beautifully and the data plane died. central-b went active in about five seconds, the VIP re-pointed, the HMI stayed reachable on the same URL — and all three sites went to zero rows and stayed there for six and a half minutes, until I restarted the master by hand.

The backup was reporting RedundantState=OutOfDate, so the obvious suspect was sync drift. I re-synced it, confirmed Good, and killed the master again. Same result: control plane in eight seconds, data plane never.

That ruled out drift and pointed at the topology. Checking the vendor documentation rather than trusting the rig: an outgoing Gateway Network connection does not redirect to the backup on failover, and connections cannot be approved on the backup node at all. The supported arrangement is that the redundant master pair initiates the connections, because configuration made on the master replicates to the backup.

I had built it the other way. Each edge opened a connection to the central master, which is the intuitive direction — the data flows that way, so the connection feels like it should too. Nothing about that arrangement warns you. It runs perfectly until the master dies, at which point the backup has no link to any site.

edge opens the connection
site ──▶ master
backup has no link to any site
master kill costs 6 m 30 s
pair opens the connection
master ──▶ site
config replicates; backup already linked
master kill costs 0 rows
The same estate, the same kill, the same software. Only the arrow changed. An outgoing connection does not redirect to the backup on failover, and it cannot be approved on the backup at all — so the direction is what decides whether the data plane has anywhere to go.

Reversing it means three outgoing connections created on the master, which then appear on every edge twice, once for the master and once for the backup. After the reversal the same kill cost nothing: every site held ten of ten straight through, including the bucket containing the kill.

1on the mastercreate outgoing connection to each site
2on each siteuncheck Require Two Way Auth
3on each siteapprove two certificates
4on each siteapprove two connections
5on each sitedelete the old inbound connection
Reversing the direction on a three-site estate. Steps three and four are each doubled because the pair presents itself twice, and the last step is the one that is easy to skip and produces a connection that looks configured and never runs.

Two operational notes from doing the reversal. The receiving side is now the edge, so Require Two Way Auth has to come off there or the incoming handshake fails with certificate_unknown. And the old edge-initiated connections must be deleted rather than left in place, because Ignition permits one connection per gateway pair and the leftovers are rejected with Duplicate connection — the resource lives at data/config/resources/core/ignition/gateway-network-outgoing/<ip>_<port>/, and the port in that directory name is not always the one you expect.

The kill battery

Seven failures, each accounted against the per-site heartbeat.

failuredurationcost
WAN cut, one site~2 m 30 sthat site only — never backfilled
master gateway killrestored by handzero, once the connections point outward
broker killrejoined in 83 szero — leadership moved sub-second
consumer killreplaced in ~1 szero — resumed from committed offset
historian primary stopped1 m 38 szero — log held it, backlog drained
gateway + broker togetherrestored by handzero — layers fail over independently
five-minute soakzero — every interior bucket full
Seven deliberate failures, each counted against the per-site heartbeat rather than inferred from a status page. The single non-zero row is the one failure that happens before anything durable has been written.

The six zero-loss results are worth stating plainly because they are the boring, correct outcome of paying for replication. Deleting a broker moved leadership in under a second. Deleting the consumer cost nothing because the replacement resumed from its committed offset and read the backlog out of the log. A sixty-second historian outage was invisible in the final data: the sink logged connection refusals once a second, the log kept accepting writes, and the backlog drained on restore with event-time stamps intact. Killing a gateway and a broker simultaneously — the closest thing here to losing a physical host — also cost nothing, because each layer fails over independently and none of them waits for the others.

The WAN cut is a hole, not a backlog

The one test that loses data even when everything is configured correctly is cutting a single site's link. Rotterdam went to zero for the duration and stayed at zero after reconnection. The other two sites read ten of ten throughout, so the isolation is exact, but the missing readings never arrive.

This surprised me, and it should not have. A Remote Tag Provider is a live subscription, and a subscription has no memory. During the cut no change events fire, so nothing is queued anywhere; on reconnect the subscription simply resumes at the current value. Event Streams has a batch queue in front of the handler, but that is an in-memory smoothing buffer with a drop policy, not store-and-forward.

The fix is not a bigger buffer. It is moving the first durable write to the site.

link down
live tag mountsubscription — nothing queued, nothing replayed
store-and-forward at the sitebuffered locally, forwarded with original timestamps
Rows per ten-second bucket for one tag at one site, across a single link cut. Both paths carry the same readings from the same device; only one of them is allowed to forget.

Enabling the tag historian on the edge gateway, pointed at the central database, gives you store-and-forward for free: during the same cut it buffered locally, and on reconnect it forwarded the whole backlog with original timestamps. Same outage, same tag, ten of ten in every bucket. The live path stayed a hole in the same window, which is the useful shape of the answer — the two paths are not redundant with each other, they are doing different jobs. One is a real-time fan-out that may miss a window. The other is the audit record that must not.

The practitioner version of this one result — why a subscription has nothing to replay, and how to tell which of your own links is a record rather than a convenience — is in its own post.

Making the HMI survive the same kill

Ignition redundancy keeps the tag engine and the historian alive across a failover. It does not keep an HMI endpoint reachable, because the session was being served by the gateway that just died. That is a separate availability problem and it wants a separate answer, which is why the pair sits behind an HAProxy VIP.

The health check is the whole trick, and the obvious probe is useless: both nodes answer /StatusPing with RUNNING regardless of which one is active. The discriminator is /system/gwinfo, where the master reports RedundantNodeActiveStatus=Active and the backup reports Cold.

option httpchk
http-check send meth GET uri /system/gwinfo ver HTTP/1.1 hdr Host ignition-estate
http-check expect string RedundantNodeActiveStatus=Active
default-server inter 2s fall 2 rise 1
probeactive nodestandbyusable?
/StatusPingRUNNINGRUNNINGno — identical
/system/gwinfo…ActiveStatus=Active…ActiveStatus=Coldyes
Two probes against the same pair. The obvious one answers identically on both nodes, so a proxy built on it will happily send operators to a warm standby that is not serving anything.

Two details cost a while each. Without ver HTTP/1.1 and a Host header the check fails outright with "HTTP content check did not match", because the gateway's Jetty wants a proper HTTP/1.1 request. And timeout tunnel has to be long, or Perspective's websocket upgrade is cut from under the session. With those in place the VIP followed the failover in about five seconds and the operator's URL never changed.

Configuration notes that are not in the manual

Authoring gateway configuration as files works, with edges.

  • A file scan registers new project resources, not new gateway ones. Dropping both an Event Stream and a Kafka Service Connector on disk and scanning registered the stream and silently ignored the connector, which then had to be created through the interface. The stream ran green at 17 events per second while delivering nothing, because the handler referenced a connector that did not exist and the failure strategy was IGNORE.
  • A scan run while the licence has lapsed does nothing at all, with no error. Modules pause when the trial expires, so the scan cannot instantiate anything. Two of my scans failed for this reason before I noticed the timer.
  • Perspective views load at project start, not on a config scan. A new view dropped on disk stays invisible until the project reloads.
  • Perspective has no HTTP binding. Bindings are tag, property, expression and query. Pulling non-Ignition metrics — broker health, proxy state, replication lag — into a native view needs a gateway script writing to tags, so the infrastructure dashboard here is a separate page rather than a contortion.
a stream runs green at 17 events/sec and delivers nothing
└─ its handler names a connector that a file scan never registered
a file scan completes and changes nothing at all
└─ the licence had lapsed, so the modules were paused
a new screen stays invisible after a scan
└─ views load at project start, not on scan
broker and proxy health cannot be bound into a screen
└─ there is no HTTP binding — only tag, property, expression, query
Four ways this stack fails without saying so. Each was found by a number that did not move, not by an error; the left column is what you see, and none of it looks like a fault.

The run where the test proved nothing

One result in this lab was invalid and worth writing down, because it looked perfect.

Testing store-and-forward on a container running with bridge networking, I cut the link with iptables -I OUTPUT -d <central> -j DROP and measured a flawless backfill across the outage. It was flawless because there was no outage. A bridge container's traffic traverses the FORWARD and DOCKER chains and is translated on the way out, so an OUTPUT rule never touches it. The earlier gateway cuts were valid only because those containers run with host networking, where locally generated traffic does go through OUTPUT.

The correct rule for a bridge container is iptables -I DOCKER-USER, and the general version of this mistake is a post of its own. The useful part is not the rule, it is the pair of signals that would have caught it immediately: the client never logged a disconnect, and the local queue never grew. A fault-injection test has to prove the fault was injected. A healthy output is not evidence of resilience if the thing you switched off was still on.

signalrule on OUTPUTrule on DOCKER-USER
client logged a disconnectneverwithin 15 s
local queue depth during outage0grew to 260
queue depth after reconnect00
buckets across the windowall fullall full
conclusionproved nothingbackfill is real
Two runs of the same test, reported identically by the data and differently by everything else. The bottom row is the only one that changed meaning; the row above it is why a healthy output is not evidence.

What transfers

  • Open Gateway Network connections from the redundant pair toward the sites, never from the sites inward. Redundancy replicates configuration made on the master, and that is the only reason the backup has anywhere to send data.
  • Treat a live tag mount as a real-time convenience, not a record. If a window of readings matters, the first durable write belongs at the site.
  • Health-check the property you actually care about. RUNNING is not active, and a green tile is not a moving number.
  • When a fault-injection test reports zero loss, verify the fault happened before you believe the zero.

The practitioner argument built on the failover number is in the companion post. The producer-side thesis this estate was built to test — that everything downstream of the first durable write is forgiving — came out of the earlier single-gateway rig.

Every site in this estate ran a licensed gateway, which is what made the architecture simple and the licensing expensive. The same link cut, run against a site with no SCADA product on it at all, is in the zero-licence edge lab.

This estate ran flat — one subnet, every host reachable from every other. What its traffic looks like measured over a full day, and the zone-and-conduit policy that falls out of it, is in the segmentation lab.

Newsletter

New essays, by email.

SCADA, cloud, AI, and the plant floor — a short email when something new is published. No noise, unsubscribe anytime.