The second host, and the failover we finally built

The application has run on one host since 2019. The documented recovery time of ninety-four seconds is the time to reboot, which assumes the host comes back — and in March a customer contract arrived with an availability commitment that made that assumption unacceptable.

The symptom

the recovery figures, all measured in drills:

  a container crash          14s, automatic
  a reboot                   94s, automatic
  the runtime stopping        4m, needs a human
  the host failing            unbounded. a rebuild
                             from the provisioning
                             script plus a restore,
                             measured at 64 minutes
                             on a good day.

and the commitment: 99.9% monthly, which is 43 minutes.

Sixty-four minutes for a host failure against a monthly budget of forty-three is the arithmetic that made this a project. One host failure a year exceeds the commitment on its own, and the previous decision — documented and reviewed three times — had been correct precisely because no such commitment existed.

Why it happens

A second host is a project and one host has never failed permanently, so the cost is concrete and the risk is hypothetical. That is the correct read until somebody signs a contract, at which point the risk acquires a number.

The fix

What the requirement actually is

the contract says 99.9% monthly, and the useful
questions are the ones it does not answer:

  measured how?          agreed: successful responses
                         to a health endpoint, from an
                         external monitor, one-minute
                         resolution.
  planned maintenance?   excluded, with 72 hours'
                         notice.
  what counts as down?   the checkout path. a slow
                         report is not an outage.
  the penalty?           a service credit. not
                         termination.

which turns an abstract requirement into a number and
a scope, and the scope is what makes it achievable.

Three architectures

  active-passive, manual promotion
    a standby that replicates and serves nothing.
    failover is a human decision plus a runbook.
    recovery: ~10 minutes, dominated by the human.
    cost: one more host.

  active-passive, automatic promotion
    the same, with a consensus mechanism deciding.
    recovery: ~60 seconds.
    cost: a third node to break ties, and a split-brain
    risk that is now automated.

  active-active
    both serving, a load balancer, and a database that
    accepts writes in one place regardless.
    recovery: seconds, for the application tier only.
    cost: session affinity, a load balancer whose own
    failure is the thing being protected against, and
    a much larger change.

chosen: the first.

Automatic promotion was rejected because a consensus mechanism is a component that fails in ways this team has never debugged, and its failure mode is two writable databases. Ten minutes of manual failover against a forty-three minute monthly budget leaves room for four incidents a month, which is enough.

The database

-- on the standby
CHANGE REPLICATION SOURCE TO
  SOURCE_HOST='10.0.1.2', SOURCE_USER='repl',
  SOURCE_AUTO_POSITION=1;
START REPLICA;

SET GLOBAL read_only = ON;
SET GLOBAL super_read_only = ON;

-- and a heartbeat, written by the primary every second,
-- which is the only honest lag measurement
CREATE TABLE heartbeat (id TINYINT PRIMARY KEY, beat DATETIME(6));
# the promotion, and the guard that comes first
lag=$(mysql -h "$PRIMARY" -N -e 
  'SELECT TIMESTAMPDIFF(SECOND, MAX(beat), NOW()) FROM heartbeat' 
  2>/dev/null) || lag=unreachable

if [ "$lag" != unreachable ]; then
  echo "the primary is REACHABLE and $lag seconds behind."
  echo "this is not a failover. investigate."
  exit 1
fi

mysql -e 'STOP REPLICA; RESET REPLICA ALL;
          SET GLOBAL super_read_only = OFF;
          SET GLOBAL read_only = OFF;'

Refusing to promote while the old primary answers is what prevents two writable databases, and it means a network partition between the two produces a refusal rather than a split brain. That is the correct failure — a failover that will not proceed is recoverable and two primaries are not.

The application, and the state that could not be replicated

  uploads       already in an object store since 2023.
                no change.
  the cache     shared since 2020. no change.
  sessions      in the cache, which everybody assumed
                made them shared. they are — and the
                session cookie's domain attribute was
                set per host in an nginx include that
                had been copied.
                → host B issued cookies for a slightly
                  different domain, so a failover
                  logged everybody out.
  the ACME
  account key   not in the provisioning script. the
                new host would have registered a
                second account and hit a rate limit.
  a cron entry  added in January for a one-off
                backfill that finished in February,
                still present, and would have run
                twice.

Three findings, none of them configuration and all of them state that a provisioning script does not describe. The session cookie is the one that would have made a successful failover look like a failure — everybody logged out is indistinguishable from an outage to the person it happens to.

Traffic

DNS with a 60-second TTL, rather than a load balancer.

measured in a drill, by requests per second on each
host after the change:

  0-4 min     88% moved
  4-12 min    97%
  12-40 min   99.4%
  40+ min     a long tail — one corporate resolver and
              a mobile network, both of which floor the
              TTL

which means failover is four minutes for most and
unbounded for a few. the runbook says so.

a load balancer removes the tail and introduces a
component whose failure is the thing being protected
against.

A TTL is a request rather than an instruction and some resolvers floor it, which is the honest characterisation and is worse than the number anybody would have quoted. Four minutes for the great majority is within the budget; the tail affects a small number of clients for an unbounded period and there is no way to fix it with DNS.

The failover drill

first drill, announced, on a Saturday:

  00:00  the primary is stopped
  00:02  the alert fires
  00:04  a human acknowledges
  00:06  the promotion guard runs and passes
  01:10  the database is promoted
  02:40  the application configuration is updated —
         a file edit, because the write host is a
         constant baked into the image
  04:10  DNS changed
  08:20  88% of traffic on the new host
  09:40  the cache is warm

9 minutes 40 seconds, against a runbook estimate of
four. and four steps the runbook did not have.
the four missing steps:

  1  the write host is a constant in the image. a
     promotion needs a deploy, or an environment
     variable — we changed it to the latter.
  2  the monitoring exporter connects with a user that
     has REPLICATION CLIENT on the old primary only.
  3  the cache is cold on the new host, and the warmer
     runs on deploy rather than on promotion.
  4  WP-CLI needs a working database connection, so
     step 1 has to be a file edit rather than an
     option update.

second drill, unannounced, in April: 6 minutes 10.

A runbook that has never been executed is a hypothesis, and four missing steps out of eleven is the normal rate. The unannounced second drill is the one that matters — the first one was performed by the person who wrote the runbook, which tests the runbook against the knowledge that produced it.

Verifying it worked

$ ./bin/host-diff app-1 app-2
  packages: identical   units: identical   sysctl: identical

$ ./bin/replication-lag --p99 --since=30d
  180ms

$ ./bin/failover-drill --unannounced
  time to serve from the standby: 6m 10s
  requests failed during the window: ~1,100

$ ./bin/availability --since=2026-03 --external-monitor
  99.97%

# and the guard, tested deliberately
$ ./bin/promote --dry-run    # with the primary running
  the primary is REACHABLE and 0 seconds behind.
  this is not a failover. investigate.

Six minutes ten against a forty-three minute monthly budget leaves room for six failovers a month, which is comfortable. The eleven hundred failed requests during the window are the honest cost — a manual failover is not seamless and the commitment is about availability rather than about perfection.

What this costs

Two of everything: two hosts to patch, two to monitor, two provisioning runs to keep identical, and a host diff that is a manual command rather than a scheduled one. The standby serves nothing and costs the same as the primary, which is the price of the commitment and is the cheapest of the three architectures.

A split-brain risk that is now real rather than theoretical. The guard refuses to promote while the primary answers, which is correct and means a partition produces a refusal — and a refusal during a genuine outage is an extra decision for a human at three in the morning, which is exactly when nobody should be making one.

And the decision has to be made by a person, which is the whole design and is the part that will fail. Six minutes assumes somebody is awake, acknowledges within two, and follows eleven steps correctly. The published commitment is business hours with best effort overnight, which three people can honestly offer and which the contract does not quite say.