A reimplementation of keepalived 2.4.3 in Go — VRRP, the LVS health checkers, and BFD.
The goal is a drop-in replacement: same configuration file, same diagnostics, same behaviour on the wire and in the kernel.
Status: not production software. Here be dragons
Two reasons that hold up, and one honest cost.
Memory safety, in code that parses hostile input. The daemon reads bytes it does not control: VRRP advertisements off the wire, BFD packets, HTTP bodies and SMTP banners from health-checked backends. That is the kind of code where a bounds check in the wrong place costs you the machine, and Go removes the whole class for free. Every wire-format parser is also fuzzed for an hour a target, which found a stack overflow and an out-of-memory in this port's own configuration preprocessor.
A state machine you can actually test. internal/vrrp/fsm has no I/O of any
kind — events in, a list of actions out, with the clock as a parameter. That
makes the state × event cross-product and the 4×4 sync-group transition matrix
exhaustively testable. They are not in the C original, where the state handlers
call vrrp_send_adv, netlink and thread_add_timer directly.
The cost: Go is worse at hard deadlines, and the fix is not free. keepalived
is one thread that waits for its deadline and does the work when it arrives, so
a single sched_setscheduler covers the whole path. Go splits those — the
advertisement is written by the runner's locked thread, but the wake-up comes
from whichever M the runtime's timer happens to be on. At a 10 ms interval
under CPU contention, worst inter-advert gap against a ~36 ms budget:
| keepalived | this port | |
|---|---|---|
| no real-time priority | 27.0 ms | 61.5 ms |
vrrp_rt_priority 50 |
10.7 ms | 10.8 ms |
Roughly twice as bad untuned. The mitigation is to promote every thread in
/proc/self/task rather than one, which this does — and then the two are
within a millisecond. If you run sub-100 ms intervals, set vrrp_rt_priority;
the daemon warns at startup if you have not.
Concretely, and all of it checkable:
| Configuration syntax | The same grammar, including $NAME= definitions, ~SEQ/~LST repetition, @id host conditionals and include |
| Accept / reject | 66 recorded configurations, same verdict as keepalived 2.4.3 |
| Diagnostics | 81 messages, character for character |
-t exit status |
0 when accepted, 5 when rejected, as C does |
| Command-line flags | -f -P -C -D -n -t -V -X mean what they mean in keepalived |
| Wire format | VRRPv2 over IPv4, VRRPv3 over IPv4 and IPv6, byte-for-byte against 6 captured advertisements |
See Migrating from keepalived for what is not the same.
The port is written against the keepalived C source, not against the RFCs, and where the two disagree it follows keepalived. An implementation that is more correct than its peers is one that cannot form a virtual router with them.
VRRPv2 here drops an advertisement whose interval differs from its own, for
instance; RFC 3768 requires no such check, but keepalived does it. Every such
case is listed in docs/keepalived-deltas.md with
the C file and line it came from.
Your configuration file should not need to change. What changes is around it.
Logging. keepalived logs to syslog by default; this writes to stderr with a
keepalived: prefix. Under systemd that is already what you want — the journal
captures stderr — but a unit file that redirects to a log file needs adjusting,
and -t --config-test=FILE has no equivalent here: -t writes its diagnostics
to stderr and sets the exit status.
# keepalived.service
ExecStart=/usr/local/bin/keepalived -n -f /etc/keepalived/keepalived.conf
StandardOutput=journal
StandardError=journal
-n (don't fork) is the right mode under systemd with Type=simple, as it is
for the C daemon.
Three binaries, not one. The supervisor forks keepalived-vrrp and
keepalived-check, and looks for them next to itself. Install all three into
the same directory. A container image needs all three in the layer.
Flags. Eight are compatible: -f -P -C -D -n -t -V -X. Check any others
against --help; -v in particular is spelled -version here.
Capabilities. Same as keepalived: CAP_NET_RAW for the VRRP socket and
CAP_NET_ADMIN for addresses, routes and nftables. A container needs both, or
--privileged.
Subsystems that are thinner. If you poll SNMP, drive D-Bus, or rely on
ha_suspend, read What is missing before switching — those
three are implemented but not to C's full surface.
The supervisor forks two child binaries, so build all three into one directory:
go build -o bin/ ./cmd/...
That gives bin/keepalived, bin/keepalived-vrrp and bin/keepalived-check.
The children are looked up next to the supervisor, so keep them together.
A minimal configuration:
vrrp_instance VI_1 {
state BACKUP
interface eth0
virtual_router_id 51
priority 100
advert_int 1
virtual_ipaddress {
192.168.1.100/24
}
}
Check it, then run it:
bin/keepalived -t -f keepalived.conf # exit 0 accepted, 5 rejected
sudo bin/keepalived -n -D -f keepalived.conf
-n keeps it in the foreground and -D turns on detailed logging. With no
peer advertising a higher priority it takes the address within a few seconds:
keepalived-vrrp: (VI_1) Entering BACKUP STATE (init)
keepalived-vrrp: (VI_1) Entering MASTER STATE
It needs CAP_NET_RAW and CAP_NET_ADMIN — for a VRRP socket and for
assigning the address.
No cgo. One dependency, golang.org/x/sys.
Three binaries. keepalived is a supervisor that parses the configuration
once to learn which children are needed, starts them, and then only supervises —
restart a dead child, forward reload and dump signals, shut down within a
bounded time. It holds no protocol state, which is what makes a crash in the
VRRP child survivable. keepalived-vrrp and keepalived-check are the two
children. keepalived splits the same way.
The VRRP state machine has no I/O. internal/vrrp/fsm takes an event and
the current time and returns a list of actions; sockets, netlink, timers and
logging all live outside it. internal/vrrp/runner is the single select loop
that owns a domain, feeds the machine, and performs what comes back.
The unit of state is a domain — every instance in the process plus its sync groups — rather than a single instance, because sync-group propagation mutates sibling instances. One goroutine owns a sync group, or an instance that has no group. Nothing else touches that state, so there are no locks on the failover path.
Health checkers are one goroutine each, running their own probe-and-wait
loop at that checker's delay_loop. Results funnel back through a single mutex
before touching quorum arithmetic or the IPVS table, so the ordering of
"backend went down" against "quorum lost" against "install the sorry server" is
serialised rather than raced.
Reload rebuilds rather than mutates. A new configuration builds a whole new object graph, and instances whose configuration did not change carry their state machine and what they hold in the kernel across to it. Sockets, VMAC interfaces and the kernel parameters the daemon changed outlive a generation, because tearing them down and recreating them on reload would be a failover.
cmd/keepalived supervisor
cmd/keepalived-vrrp VRRP daemon
cmd/keepalived-check health-check daemon
internal/config the parser, byte-compatible with C's diagnostics
internal/vrrp/fsm the state machine — pure, no I/O
internal/vrrp/runner the loop that owns a domain and drives it
internal/vrrp/proto wire format, checksums, HMAC auth extension
internal/vrrp/socket raw sockets, IP_HDRINCL, multicast, GTSM
internal/vrrp/kernel addresses, routes, rules, firewall
internal/vrrp/track interface, script, file and process trackers
internal/vrrp/vmac virtual MAC interfaces
internal/check the checkers and their IPVS application
internal/bfd BFD sessions
internal/firewall nftables rules
internal/netlinkx rtnetlink and generic netlink
internal/sysctl per-interface kernel parameters
go test -race ./... # skips what needs a capability, naming which
test/privileged/run.sh # the rest, in a user namespace — no root needed
test/fuzz/gate.sh # 30 fuzz targets, one hour each
The recorded corpus is in testdata/golden, so the compatibility tests run on a
bare clone. KEEPALIVED_SRC points them at a keepalived checkout for
re-recording.
Two tools measure how much of the configuration actually does something:
go run ./tools/parity # settings that are read outside the parser
go run ./tools/inert # settings no test notices being broken
VRRP v2 and v3 over IPv4 and IPv6, unicast and multicast, with virtual MAC
interfaces, virtual routes and rules, and accept_mode enforced through
nftables.
- Sync groups, so several instances fail over together.
- Tracking —
track_interface,track_scriptwith rise/fall hysteresis,track_file,vrrp_track_process,track_bfd. - Notifications — per-state and generic scripts, notify FIFOs, and SMTP alerts for instances, sync groups and real servers.
- LVS — ten checker types, IPVS application, quorum and sorry servers, session persistence.
- BFD sessions per RFC 5880/5881.
Each was compared against the running C daemon, not only against its source: on live interfaces for VRRP and tracking, through a recording SMTP server for the alerts, and against the kernel's IPVS table for persistence.
Three subsystems are thinner than keepalived's:
- SNMP carries the columns a monitoring system polls, not all of C's tables.
- D-Bus
PrintStatsanswers and returns nothing, because there are no per-instance counters yet. ha_suspendfollows the virtual address rather than a VRRP instance's state.
Smaller gaps: the preferred_lft, track_group and use_vmac qualifiers on a
virtual address are recognised and reported rather than applied; virtual_routes
takes add, prepend and append but not replace; and the nftables rule for
a VMAC's multicast membership reports is implemented in its drop-only form.
The daemon reports its own limits at startup, so a configuration relying on one of these says so when you run it.
Sub-100 ms advertisement intervals need vrrp_rt_priority. At a 10 ms
interval a backup declares its master down after about 36 ms, and a contended
general-purpose scheduler does not meet a 36 ms deadline — in either
implementation. Untuned, this port's worst-case gap is roughly twice C's; with
a real-time policy the two are within a millisecond. See
Why do this at all for the numbers and the reason. The
daemon warns at startup if you run short intervals without it.
No IPv6 for VRRPv2, which is not a gap: RFC 3768 has no IPv6.
Untriaged test-coverage gaps. tools/inert reports sites where breaking the
code on purpose fails no test — roughly a third of the candidate sites in
internal/check. Each is either dead code or code no test covers, and the tool
cannot tell which. They have not been worked through.
The warning at the top is not a formality, and it is not because the project is abandoned. Concretely, before it comes off:
- External review. Nobody but the author has read this code. For a daemon that parses hostile input and decides which machine owns an address, that is the largest single risk.
- A real deployment. It has never run outside a test namespace. Nothing here has met a switch that does something unexpected, a driver that reorders packets, or six months of uptime.
- Sustained load and soak testing. The measurements are minutes long. Slow leaks, fragmentation and clock drift do not show up in minutes.
- The
tools/inertsurvivors triaged, so "tested" means the same thing everywhere in the tree. - The three thin subsystems finished, or documented as permanently out of scope.
There is no schedule. It is a personal project.
See CONTRIBUTING.md. The rule that is not obvious: parity with keepalived beats correctness, so a patch fixing a bug this port copies on purpose will usually be declined.
Security reports: SECURITY.md.
GPL-2.0-or-later — see LICENSE. Every source file carries an
SPDX-License-Identifier.
This is a derivative work of keepalived, Copyright (C) 2001-2017 Alexandre Cassen acassen@gmail.com and contributors, licensed GPL-2.0-or-later. It is a reimplementation written against the keepalived source, with that source cited by file and line throughout, and is covered by the same license.
keepalived is at https://github.com/acassen/keepalived. This project is not affiliated with or endorsed by its maintainers.