SLB is a sessionless load balancer for UDP traffic, and solves problems inherent with using traditional (feature rich) load balancers for such traffic.
For simple, stateless UDP protocols there is no advantage in trying to maintain "affinity" (aka. "sessions") between clients and back-end instances. Traditional load balancers assume that affinity is helpful, and so they will try to route packets from a client to a consistent back-end server. By contrast, SLB evenly (randomly) distributes packets one-by-one over all available back-ends. This results in uniform loading of backends, and improved robustness when one backend instance fails (there will be an increase in packet loss for all clients rather than a total loss of traffic for some clients).
By default SLB will listen on ports 1812
and 1813
for incomming UDP packets and relay them to random backend targets it knows. The ports it listens on can with the --server-port-range
option, which accepts a single port (e.g. 541
) or a range of ports (e.g. 4000-5000
).
To make SLB aware of backends requires sending "watchdog" (aka. "keep alive") packets to the admin port (more on this below). By default the admin port is 1111
, but it can be configured using the --admin-port
option. If multiple network cards are present in your system, you can specify the IP using the --admin-ip
option. If the IP specified with --admin-ip
is in the multicast CIDR range (244.0.0.0/4
) SLB will automatically join that multicast group (more on this below).
Other options are described in the command help:
SimplestLoadBalancer:
Sessionless UDP Load Balancer sends packets to backends without session affinity.
Usage:
SimplestLoadBalancer [options]
Options:
--server-port-range <server-port-range> Set the ports to listen to and forward to backend targets
(default "1812-1813") [default: 1812-1813]
--admin-ip <admin-ip> Set the IP to listen on for watchdog events [default is first private IP]
--admin-port <admin-port> Set the port that targets will send watchdog events [default: 1111]
--client-timeout <client-timeout> Seconds to allow before cleaning-up idle clients [default: 30]
--target-timeout <target-timeout> Seconds to allow before removing target missing watchdog events [default: 30]
--default-target-weight <default-target-weight> Weight to apply to targets when not specified [default: 100]
--unwise Allows public IP addresses for targets [default: False]
--stats-period-ms <stats-period-ms> Sets the number of milliseconds between statistics messages printed to the
console (disable: 0, max: 65535) [default: 1000]
--default-group-id <default-group-id> Sets the group ID to assign to backends that when a registration packet doesn't
include one, and when port isn't assigned a group [default: 0]
--version Show version information
-?, -h, --help Show help and usage information
Backends aren't configured at the command line. Rather, they are dynamically registered and de-registered using periodic UDP packets sent to the admin port (--admin-port
). The content of those packets may differ based on how you use SLB in your environment.
If you're running a single SLB server, backends can be configured to send packets to that one IP and on the admin port. This is the simplest scenario. Each backend will send messages with two "magic bytes" to indicate "backend registration" for content:
0x11 0x11
SLB will interpret such a packet as "register the sender as a backend". Optionally, the messages can contain one or two additional bytes (weight and group ID) whose purpose will be discussed in more detail below.
0x11 0x11 [X] [X]
^ ^
| |
| one byte for group id
|
one byte for weight
In some environments registration packets won't be sent from backends themselves, and SLB supports such use cases. When a registration packet is sent from a "third party" the content will need to include the IP address of the backend being registered:
0x11 0x11 X X X X [X] [X]
^ ^ ^
| | |
| | one byte for group id
| |
| one byte for weight
|
four bytes for ip to add
Again, the weight and group ID bytes may optionally be appended.
When a more robust HA deployment with multiple SLBs is needed the communication between backends and SLB can be simplified by using a multicast group IP. This is helpful since each SLB must be aware of each backend. In such a case the SLB servers should make use the --admin-ip
option to specify a multicast address which will cause the SLBs to join the multicast group and hence all receive any message sent to that IP. The backends can be configured with that single IP, minimizing their workload and simplifying their configuration (particularly when SLBs are rotated in and out of service due to autoscaling and/or the use of spot instances).
Note that using a multicast IP requires either a switch that supports multicast, or (more likely) running in an AWS VPC configured with a multicast domain.
The admin packet formats are very simple as of version 2.0. In the simplest single-SLB use case a registration packet from a backend may consist of nothing more than two magic bytes (0x11
0x11
). Optionally, the packets can come from a different source (e.g. a management server) and incude four bytes to specify the ipv4 address of a backend. In either case, two additional optional bytes for traffic "weight" relative to other backends, and for the "group" to assign to the backend may be appended (more about groups below). In ASCII art:
0x11 0x11 [X X X X] [X] [X]
^ ^ ^
| | |
| | one byte for group id
| |
| one byte for weight
|
four bytes for ip to add
To immeadiately remove a target send a packet with 0x86
as the first byte instead of 0x11
(if sent from a management server, append the IP of the backend to remove):
0x86 0x11 [X X X X]
^
|
four bytes for ip to remove
Weights are used to control the relative amount of traffic delivered to each backend. If no weight is specified the default value of 100 (configurable with --default-target-weight
) will be applied to the backend, and each will receive the same volume of packets. That said, it's expected (and advisable) that backends tune the weight value in their admin packets based on their ability to handle traffic (perhaps reduced when CPU is high, updates are being applied, etc.). For example:
100
, 50
and 50
, respectively, then the first will receive 50% of the traffic and the second and third will each get 25%.31
and 31
, respectively, then each will receive 50% of the traffic.When using groups, the relative weights are evaluated versus other backends in the same group (not accross all groups).
It's important to send admin packets reliably and at a sufficient cadence. Each time an packet is received by SLB the backend's "last seen" time is updated. If 30 seconds (configurable with
--target-timeout
) passes without a backend being seen, it is removed and no further traffic will be sent to it.
By default, all backends will be used to service all ports served by the load balancer.
However, it's possible to assign individual ports to subsets of backends using SLB port assignment messages and providing group IDs in registration messages. Consider, for example, that you would like to have SLB load balance traffic for ports 1812-1813 but assign the traffic reaching each port to a different set of servers. To do so:
x66 x11
) with a port number (two bytes) and a group ID (one byte). These messages need not be repeated, and can be sent when a change to port group assignments is desired (there is no harm in repeating them, however, which can be convenient to ensure the ports are correctly assigned groups after service restarts).0x66 0x11 X X X
^ ^
| |
| one byte for group ID
|
two bytes for port number, litten endian
Using Linux bash
it's straightforward to send admin packets. This can be done using the netcat
(aka. nc
) command or the /dev/udp
filesystem. For example, if your load balancer is listening on the default admin port 1111
and you want to add a target with the IP 192.168.1.22
:
$ echo -e $(echo "x11x11$(echo "192.168.1.22" | tr "." "n" | xargs printf '\x%02X')") > /dev/udp/127.0.0.1/1111
Since it can be tedius to manually send those packets to keep a set of targets registered, you might create a small shell script, say lb.sh
:
#!/bin/bash
echo -ne $(echo "x11x11$(echo "192.168.1.22" | tr "." "n" | xargs printf '\x%02X')") > /dev/udp/127.1.1.1/1111
echo -ne $(echo "x11x11$(echo "192.168.1.23" | tr "." "n" | xargs printf '\x%02X')") > /dev/udp/127.1.1.1/1111
echo -ne $(echo "x11x11$(echo "192.168.1.24" | tr "." "n" | xargs printf '\x%02X')") > /dev/udp/127.1.1.1/1111
echo -ne $(echo "x11x11$(echo "192.168.1.25" | tr "." "n" | xargs printf '\x%02X')") > /dev/udp/127.1.1.1/1111
echo -ne $(echo "x11x11$(echo "192.168.1.26" | tr "." "n" | xargs printf '\x%02X')") > /dev/udp/127.1.1.1/1111
echo -ne $(echo "x11x11$(echo "192.168.1.27" | tr "." "n" | xargs printf '\x%02X')") > /dev/udp/127.1.1.1/1111
And then use the watch
command to call that script every few seconds:
$ watch -n10 ./lb.sh
Pre-built binaries for Linux and Windows x64, and Linux ARM are available as GitHub "Releases". This is a very simple .Net 8.0 project, so to build it run (assuming you have dotnet-sdk-8.0 installed):
dotnet build
You'll probably want to generate a native binary executable, which is convenient and offers some performance benefits.
For Linux:
dotnet publish -o ./ -c Release -r linux-x64 /p:PublishSingleFile=true /p:PublishTrimmed=true --self-contained
For Windows:
dotnet publish -o ./ -c Release -r win10-x64 /p:PublishSingleFile=true /p:PublishTrimmed=true --self-contained
Likewise, it's simple to run using dotnet run
in the project directory:
$ dotnet run
Or, if you've built a native executable:
$ ./SimplestLoadBalancer
Please feel free to create issues here on GitHub for questions and bug reports. There is no template provided, or requirements but please try to be as descriptive as possible - it will help ensure we are able to respond in a sensible way. See also, contributing.
Enjoy!