Round-robin DNS and multiple VIPs:
balancing with no central component
This is the only balancing level that adds nothing to the request path — so nothing to make redundant, nothing extra to watch. Coarse, but free in failure surface.
Table of contents
1. The design: crossed priorities
Two or three nodes, as many floating addresses, and a single DNS record publishing them all. Each node is master of its own address and backup for the others: crossing the priorities does all the work.
| VIP A | VIP B | |
|---|---|---|
| Node 1 | priority 150 — master | priority 100 — backup |
| Node 2 | priority 100 — backup | priority 150 — master |
Under normal operation each node carries one address and traffic splits. If node 1 dies, node 2 takes VIP A in addition to its own: the service stays fully reachable, on both addresses, with an imbalance in load but no gap at all.
2. The configuration, in practice
It is the same keepalived as for a single VIP, with two blocks instead of one. Extract from node 1:
vrrp_instance VIP_A {
state MASTER
interface eth0
virtual_router_id 51
priority 150 # 100 on node 2
advert_int 1
virtual_ipaddress { 192.0.2.10/24 dev eth0 }
track_script { chk_service }
}
vrrp_instance VIP_B {
state BACKUP
interface eth0
virtual_router_id 52 # a DIFFERENT id from the first
priority 100 # 150 on node 2
advert_int 1
virtual_ipaddress { 192.0.2.11/24 dev eth0 }
track_script { chk_service }
}
The virtual_router_id trap
Each VRRP group needs a different id, and that id must be unique across the broadcast domain — not merely within your own configuration. Two separate clusters reusing the same number on the same VLAN will elect each other and fight over addresses that are none of their business. It is a hard failure to diagnose, because it comes from a neighbour rather than from you.
On the DNS side you then publish a single record carrying both addresses. The
track_script remains essential: without it,
keepalived only watches itself — that is
covered at level 2 of high availability.
3. What DNS does not do
Be clear about what you are buying, because these four limits are structural and no configuration works around them.
- • No health check. DNS publishes addresses, it never verifies that they answer.
keepalived's
track_scriptplays that role by pulling the failed node's address — not DNS. - • Caching and time to live. A resolver that cached a record keeps it until it expires. Any correction therefore takes at least one TTL to propagate, and some clients ignore that value entirely.
- • No weighting. A large machine gets as much as a small one. If your nodes are heterogeneous, distribution will be unfair and nothing corrects it.
- • DNS does not decide the distribution. It proposes an order; the resolver cache and the client behaviour decide the outcome, and the gap between two clients is considerable — that is the subject of the next section.
4. Who actually chooses the address
You often read that “the client picks one at random”. That is wrong at all three levels — the authoritative server, the resolver, the client — and it is the third that decides whether the design buys you real redundancy or nothing at all.
a. The authoritative server rotates the order
BIND does not draw lots: it rotates the record set order on every answer. With three addresses A, B and C:
| Query | Order served | First address |
|---|---|---|
| 1st | A → B → C | A |
| 2nd | B → C → A | B |
| 3rd | C → A → B | C |
It is a circular permutation, and it is deterministic. The point is settled at the source: BIND
9.21.14 removed random ordering, ISC noting that it “did not offer uniform distribution
of all permutations and it was not superior to the cyclic order in any way” —
and that “random
ordering is now an alias for cyclic ordering[1]”.
b. The recursive resolver sits in between
This is the level people forget. The rotation happens at the authoritative server, but your clients never talk to it: they query a recursive resolver, which caches the address set for the whole TTL. The real granularity is therefore not “per client” but “per resolver”. A whole site behind a single corporate resolver can receive the same order for the entire TTL. That is the true reason round-robin DNS distributes poorly across small populations: it is not a failure of randomness, it is the cache.
c. The client does as it pleases — and the gap is enormous
DNS returns several addresses, but it does not define how they will be used. Some clients try the first one until it works, others race them with a delay, and a few genuinely turn them into a group of targets.
| Client | What it does with the addresses | Distribution |
|---|---|---|
chronypool directive |
Instantiates several independent sources. “The default value is 4 and the maximum value is 16” (chrony.conf[2]). | Real, and the sources vote |
chronyserver directive |
One active address for that source, taken in resolver order. | None |
| Browser Happy Eyeballs, RFC 8305[3] |
Staggered attempts, first handshake wins, the rest are cancelled — then the winning connection is reused. | Connections, not requests |
| curl / libcurl | Staggered attempts (200 ms by default[4]), first successful connection kept. | Adequate failover |
Gonet.Dialer |
Races IPv6 against IPv4 after 300 ms[5], but tries same-family addresses sequentially. | Falls back on timeout |
| PostgreSQL libpq |
“All the hosts and addresses will be tried in order, until one succeeds.” The load_balance_hosts[6] option exists, but its default value is disable. |
None by default |
nginxupstream block |
“A domain name that resolves to several IP addresses defines multiple servers at once[7]”, distributed by weighted round-robin. | Real, done by nginx |
Two limits, or the page sells RR DNS as high availability
1. Happy Eyeballs means browsers.
The stagger in RFC 8305 has a name, the Connection Attempt Delay: “One recommended value for a default delay is 250 milliseconds”, the recommended minimum is 100 ms, a subsequent attempt “MUST NOT be started within 10 milliseconds of the previous attempt”, and the recommended ceiling is 2 seconds. In practice: against a dead address a user pays 250 ms, not a TCP timeout. That is what makes failover feel instant in a browser.
But machine-to-machine calls — monitoring, APIs, continuous integration, health probes — mostly fall back sequentially, on the connection timeout, meaning tens of seconds… when they fall back at all. So round-robin DNS gives good failover to humans and poor failover to machines. In infrastructure, that is exactly the population that matters.
2. The race stops at the connection.
If the server completes the handshake and then returns 500s, or simply hangs, nobody races any more: the client has its connection and sticks to it. DNS has no health check. That is the honest boundary between load balancing and high availability — and it is exactly what the next level up buys, with the health check keepalived applies to IPVS targets, then with the application-level check of HAProxy.
The contrast worth keeping
Over HTTP the client races and keeps one: the winning connection is then reused,
and a hundred requests do not produce a hundred address choices. Over NTP with
pool the client keeps several and makes them
vote. Same DNS record, exactly opposite uses — and that is also the argument that
settles pool
mode for NTP rather than a single address.
And one keyword cancels the whole thing: server instead
of pool, or a
load_balance_hosts left at its default. That is why we
check the configuration of the clients, not only that of the servers.
5. What actually happens on failure
This is where the design differs from plain round-robin DNS, and it is what makes it defensible. With bare round-robin on fixed addresses, losing a node leaves its address published and dead: one request in two fails until a human edits the zone.
Here, the address outlives the machine
Because each address is floating, it migrates to a survivor within one to three seconds. Both published addresses therefore stay reachable, carried by a single machine. DNS has nothing to correct, and in fact noticed nothing. The imbalance in load is the price paid, not unavailability.
A corollary not to forget when sizing: each node must be able to absorb the whole load, otherwise failover turns an outage into saturation. Same arithmetic as anycast, and it is calculated beforehand, not after.
6. When it is enough, and when it is not
It is the right call if…
- • Two or three nodes of comparable capacity
- • A stateless service, or one whose state is shared
- • Internal or moderate traffic
- • You want to add no component to the path
You need a step up if…
- • Nodes differ in size (weighting)
- • A target must be pulled within seconds
- • The application requires sticky sessions
- • Routing has to depend on the URL
The next step up is layer 4 with IPVS, which brings weighting and a health check — at the cost of a component to make redundant.
Frequently asked questions
How many VIPs should you declare?
As many as you have nodes, generally: each machine is master of one address and backup for the others. Beyond three or four the configuration becomes tedious to maintain and the post-failure imbalance becomes hard to reason about — that is the sign it is time for a real balancer.
What happens if both nodes fail at once?
Both addresses disappear from the network and the service is down: this design spreads load and survives the loss of one node, it works no miracles beyond that. It is also why we combine it with hypervisor high availability, which restarts lost machines and rebuilds redundancy.
Should you lower the DNS time to live?
Not here, and doing so is a common misunderstanding. Since the addresses float and migrate on their own, DNS never needs an urgent correction: the TTL can stay comfortable. Aggressively lowering it makes sense when failover happens by editing records, which is not the case.
Does this design work at a hosting provider?
It depends entirely on what the provider allows in terms of movable addresses. On a layer-2 segment where you control MAC address learning, yes. Otherwise you have to go through the provider's control plane, and failover is counted in tens of seconds rather than seconds.
Sources
The main technical claims on this page are supported by the following standards and primary documentation. Links checked on 23 August 2026.
- [1] ISC — BIND 9.21.14 release notes: “random ordering is now an alias for cyclic ordering”
-
[2]
chrony — chrony.conf:
serverandpooldirectives,maxsources(default 4, max 16) - [3] IETF — RFC 8305, Happy Eyeballs Version 2: connection attempt delay
- [4] curl — libcurl: CURLOPT_HAPPY_EYEBALLS_TIMEOUT_MS, 200 ms default between attempts
-
[5]
Go —
net.Dialer.FallbackDelay: “If zero, a default delay of 300ms is used”, between address families -
[6]
PostgreSQL — libpq:
load_balance_hosts, default valuedisable - [7] nginx — ngx_http_upstream_module: a name resolving to several addresses defines multiple servers
Does your balancing add a point of failure?
We audit what you have and say plainly whether a dedicated balancer is justified, or whether a simpler design would do the same job.
Get a quote