Skip to main content

Bring your own Load Balancer

· 18 min read
Kinjal Raykarmakar

Cover

In this article, I'm trying to explain how to build a Load Balancer from scratch using just C programming language and no other 3rd party libraries at all. I'll be using various techniques here including UNIX socket programming, multi-threading, thread pools and safety, data structures and containerizing. I'll try my best to explain all the concepts!

I took this application as a personal challenge of trying to understand the abstraction behind complex systems and implementing the same with very simple tools and technologies. I restricted myself to use C as the only programming language. The system might seem a bit complex at first, but dividing it into multiple smaller systems will make it easier!

caution

This article will contain some concepts which are not explained from scratch. They include data structures like: Linked List, Circular Linked List, Queue and techniques like: Sockets, Multithreading, Round Robin, File handling, CMake, Docker etc. Beginner-level knowledge on these topics might be considered as a prerequisite!


Load Balancer

A load balancer is an application which sits in front of web applications to distribute and forward incoming traffic to the target web applications efficiently. This is done so that the web severs don't get overwhelmed by a huge amount of requests in a high traffic system.

Also, load balancer help in scaling up systems. By analyzing the memory and CPU signatures of the target web severs, it can help judge whether there's a need to bring up additional instances. Once up, a load balancer can route traffics to the new instances. On the other hand, with regular health checks, it can automatically detect the unhealthy instances and choose not to route traffic to them!

Here I'll build a load balancer that'll follow a simple round-robin algorithm to distribute the traffic. Additionally, there will be passive health checks that will be triggered every 30s. I've also added a priority based target group system to group backend resources by path/routes.

info

This article will give you an idea of how things work under the hood. For actual code, please refer to the GitHub Repository here. I'll suggest opening up the repository while reading this article for better following along. Many places in this article, you'll find just the function signatures or code snippets. For the full code, please refer the repository!


Low level Networking

Let's start by dealing the low level networking problems! Low level networking here means handling HTTP requests by raw UNIX sockets only. This includes working with socket file descriptors, connecting, binding etc. But lets first start with socket!

Sockets

Sockets are used for communication between different processes. Ideally in a client-server situation, socket is the underlying technology used by several protocols to establish connections to send/receive data. Unix sockets are actually file descriptors (unique non-negative integer identifier for a resource) on which we can use functions like recv and send to receive and send data respectively

Structures

I've set up a few structures to handle the socket and address info of the servers:

include/networking.h
struct socket_name_info {
char host[INET6_ADDRSTRLEN];
char port[NI_MAXSERV];
};

struct socket_connection {
int socket_fd;
struct sockaddr_in address;
struct socket_name_info socket_name;
};
  • The constants INET6_ADDRSTRLEN and NI_MAXSERV denotes the maximum length of an IPv6 address and the maximum length of a service info
  • sockaddr_in is a structure that deals with internet addresses

Creating the socket_connection object

To create the socket_connection object, we'll need to do the following:

src/networking.c
int get_socket(struct socket_connection *server_socket, char *address, unsigned int port) {
/** Code */
}
  • Create the socket file descriptor with the socket function. I'm setting the following:
    • domain to AF_INET for allowing the socket connection to be on IPv4 protocol
    • type to SOCK_STREAM to provided a two-way connection
  • Set the socket options to reuse local addresses with the setsockopt function
  • Set the appropriate host and port options for the sockaddr_in structure
  • Get the details of the service for the socket_name_info structure using getnameinfo
  • Finally save everything in the socket_connection structure
  • This function also returns 0 if everything goes fine, or else returns a negative number on failure

More functions for networking

Binding

src/networking.c
int bind_to_socket(struct socket_connection server_socket) {
/** Code */
}

The socket created in the above step need to be associated with an address. Just creating the socket file descriptor doesn't let us connect to it until and unless, we've an address for it. This is done with the bind() function

Connecting

src/networking.c
int connect_to_socket(struct socket_connection server_socket) {
/** Code */
}

connect() is used to connect to the socket to send and receive data. After a successful connection only, we can use other methods on the socket file descriptor to undergo various network operations

Listening

src/networking.c
int listen_to_socket(struct socket_connection server_socket, unsigned int backlog) {
/** Code */
}

To accept incoming connections on the socket, we'll need to listen on it. The listen() function does the same job! Listening to the socket comes with a backlog. A backlog is the maximum number till which the queue of pending connections can grow to, in the particular socket we're trying to listen on. The value of backlog depends on the programmer and there is no such preferred value

Convert hostname to IP address

src/networking.c
int hostname_to_ip(char *hostname, unsigned int port, char *ip) {
/** Code */
}

Sometimes there's a need to convert the hostname to IP addresses (For example, localhost -> 127.0.0.1), so that we have a consistent style of handling addresses within the system. This function cycles through the list of available addresses and sets the IP. The getaddrinfo() function comes handy in this case


Handling incoming requests

Once we're all set with the low level networking setup, we'll need to start accepting some requests in our Load balancer. To do that, we'll first need to work on the listening server. This is the main load balancer server which will receive all the request. We're not handling the forwarding work yet!

src/server.c
int create_server(struct socket_connection *server_socket, char *host,
unsigned int port, unsigned int backlog) {
/** Code */
}

The above function creates the socket file descriptor, bind an address and then start listening on this socket.

To accept the connections, we'll need to use the accept() function. This function creates a new file descriptor for the connection and returns the same

src/server.c
int accept_incoming_connection(struct socket_connection server_socket) {
/** Code */
}

We'll need to continuously accept the connection, hence we'll need to place the above function in an infinite while loop!


A basic Reverse Proxy

A reverse proxy is an application which routes incoming client requests to backend services. If you look closely, you'll see that a Load Balancer is essentially a high level reverse proxy. Hence, let/s implement a simple reverse proxy to work with our current setup.

To build a reverse proxy, we'll need to forward requests. We're already capturing the requests in the accept_incoming_connection function. We'll now need to read the data from the incoming connection and then create another socket connection to the backend to send the request.

To package all the information of a backend, we'll use the following structure:

struct target_backend {
char name[100]; // name of the backend
char host[INET6_ADDRSTRLEN];
unsigned int port;
int is_healthy; // 0 - unhealthy , 1 - healthy
};

To forward incoming request to a particular target backend, we can use the following code snippet. This is a toned down version of the final code, as we'll fiddling around with this to add more functionality later!

/* Read the client's request */
// new_connection is a socket file descriptor created by accept() call
bytes_read = recv(new_connection, buffer, BUFSIZE - 1, 0);

// selecting a target will be coming up later!
struct target_backend target;

/* Create a target socket */
// creating and connecting to the target. src/server.c
if (connect_to_target(&target_socket, target.host, target.port) < 0) {
return NULL;
}

/* Forward the client's request to the target server */
send(target_socket.socket_fd, buffer, strlen(buffer), 0);

/* Read the response from the target and forward it to the client */
while (
(bytes_read = recv(target_socket.socket_fd, buffer, BUFSIZE - 1, 0))) {
send(new_connection, buffer, bytes_read, 0);
}

/* close sockets */
close(target_socket.socket_fd);
close(new_connection);

Multi-Threading and Request Queue

When a huge number of connections come in, it'll be difficult for the server to keep up with the requests. Threads come at a rescue here. The idea is to enqueue an incoming connection to a simple queue and then dequeue it to process later. If we work towards building a thread safe queue, then multiple each thread can dequeue its own connection and handle them in parallel.

The src/queue.c file illustrates a simple thread safe queue. It works along with a thread pool, which is a set of pre-defined threads. Thread safety is done with the help of a mutex. Also, to improve performance, condition variables are used. It helps to send signals between threads, so that the threads become active only when needed to!

When the server starts up, we can initialize the thread pool. The THREAD_POOL_SIZE can be a configurable value depending on the programmer's need!

src/threads.c
void init_thread_pool() {
for (int i = 0; i < THREAD_POOL_SIZE; i++) {
pthread_create(&thread_pool[i], NULL, thread_handler, NULL);
}
}

To work with the request queue and threading together, the following functions are used. As you can see, we enqueue the connection on the request queue and then one of the threads from the thread pool picks up the connection and handles the same. This might look like a producer-consumer system too!

src/connection.c
void *thread_handler(void *arg) {
while (1) { // do not kill the threads
int *p_new_connection = dequeue_connection();

if (p_new_connection != NULL) {
// we've a waiting connection, so handle it!
handle_connection(p_new_connection); // the actual handling logic goes in here!
}
}
}

// this was the connection loop to handle incoming connection
void connection_loop(struct socket_connection client_socket) {
int new_connection;

while (1) {
/* Accept incoming client connections */
new_connection = accept_incoming_connection(client_socket);
if (new_connection < 0) continue;

int *p_new_connection = (int *)malloc(sizeof(int));
*p_new_connection = new_connection;

enqueue_connection(p_new_connection);
}
}

Round Robin Algorithm with unhealthy targets

Once we're done with handling the requests efficiently, it's time to start working towards our load balancer logic. Round-robin algorithm is one of the common algorithm used to distribute traffic across a set of backend services. It's simple and ridiculously efficient when equally dividing the requests.

However, there can be a situation where certain backend services might be down, and we do not want to route traffic to them. Hence, we'll need to tweak the round-robin algorithm such that it ignores the unhealthy targets. Also, as we're dealing with requests in multiple threads now, it's better to make this algorithm thread safe too!

In the heart of this algorithm, lies a simple Circular Linked List. Circular linked list is like a normal linked list, but the last node is pointed back to the first node. Hence, while iterating a circular link list, it'll never overflow after the last node, but will start back again from the first node. This is actually the logic behind the round-robin algorithm

The follow structure is used for a single node in the round-robin circular linked list:

include/config.h
struct round_robin_node {
struct target_backend backend;
struct round_robin_node *next;
};

Additionally, to maintain the circular linked list, two points are used. The head pointer points to the start of the list while the current pointer points to the currently used target.

Insertion can be done easily. Find the insertion code in the src/round_robin.c file. The interesting part is getting the next target from the circular linked list. In theory, if we move the current pointer to the next one, it should work. However, we need to deal with unhealthy targets too:

src/round_robin.c
struct target_backend get_next_backend(
struct round_robin_node *round_robin_head,
struct round_robin_node **round_robin_current, pthread_mutex_t mutex) {
pthread_mutex_lock(&mutex);
struct round_robin_node *temp = *round_robin_current;

if (temp == NULL) { // first time here, start with the head
temp = round_robin_head;
*round_robin_current = round_robin_head;
} else {
temp = temp->next;
}

// loop back till current
while (temp->next != *round_robin_current) {
if (temp->backend.is_healthy == 1) { // choose only if healthy
*round_robin_current = temp;
pthread_mutex_unlock(&mutex);
return temp->backend; // got a healthy one!
}
temp = temp->next;
}

if (temp->backend.is_healthy == 1) { // choose only if healthy
*round_robin_current = temp;
pthread_mutex_unlock(&mutex);
return temp->backend; // got a healthy one!
} else {
temp = temp->next;
}

pthread_mutex_unlock(&mutex);
return temp->backend;
}

What happens when all the targets are unhealthy? - The load balancer cannot do much here and needs to route traffic to one of the unhealthy ones!


Target Groups with priority

Target groups are a group of targets that are linked to a particular path of the load balancer. Lets say we've the following target groups:

Target Group NamePathPriorityDefault?
tg1/foo/*1No
tg2/bar/*2No
tg3/meh/*1Yes

Now, if we receive a request with a path /bar/search?q=chocolates, the load balancer will first go through the paths according to the priority and regex match them. Here is how it goes:

  • Check tg1 - Fails
  • Check tg2 - Passes => Route to this target group

Few things to note here:

  • If none of the paths match, then the request is routed to the default route.
  • Priorities are descending. In other words: 1 is a higher priority than 2.
  • If all the target groups have the same priority, then the priority is decided internally by the insertion index. That means, the target groups inserted first will have an higher priority
  • If no default target group is mentioned, then the highest priority target group is used as the default one

Target groups list can be created with Linked Lists and we can smartly handle the priority by sorting the targets groups by priority while inserting them only!

src/target_group.c
void target_group_list_sorted_insert(struct target_group target_group)

The path matching on the target group list will look like this:

src/target_group.c
void find_target_group_with_path(char *path, struct target_group **tg) {
pthread_mutex_lock(&target_group_mutex);

struct target_group_list_node *temp = target_groups_head;
struct target_group *default_tg = &target_groups_head->tg;

regex_t path_regex;
int regex_result;

while (temp != NULL) {
// extracting out the default one while iterating the list!
if (temp->tg.is_default == 1) { // found the default!
default_tg = &temp->tg;
}

regcomp(&path_regex, temp->tg.path, 0);
regex_result = regexec(&path_regex, path, 0, NULL, 0);
if (regex_result == 0) {
pthread_mutex_unlock(&target_group_mutex);
*tg = &temp->tg;
return;
}

temp = temp->next;
}

pthread_mutex_unlock(&target_group_mutex);
*tg = default_tg;
}

Additionally, to handle routing from the incoming connection, we can abstract out the routing logic in a separate function while handling the connection:

src/connection.c
int handle_routing_target(char *request_buffer, struct target_backend *p_target,
int new_connection) {
/** Code */
}

Passive Health checks

To monitor the health of the target backends, we'll need to run passive health checks. These health checks will run once every 30 seconds. In each run, it'll do a simple HTTP call to the root route of the backend service and check if it returns a 200 status code to make sure it's healthy. If any other status code is returned and/or the HTTP connection fails, then we'll mark the target as unhealthy. This operation is done in a separate thread all together so that it doesn't interfere with the main server thread.

The HTTP call is illustrated in the following function

src/health_check.c
int health_check_target(struct target_backend target) {
/** Code */
}

The health check thread is set up while the server is initializing, and a health check is also done for the first time before actually listening to incoming requests


Handling special request/response

Health report

While doing the routing in the handle_routing_target function, we can look for some special routes to handle them differently

src/connection.c
int handle_routing_target(char *request_buffer, struct target_backend *p_target,
int new_connection) {
/** Code */

// health report
if (strcmp(url, "/__health") == 0) {
logger("[ROUTE] %s => Health Report", url);
handle_health_route(new_connection);
return 1; // already handled
}

/** Code */
}

Here, we're checking whether the incoming rout is /__health and handling it differently. We're not routing the traffic to one of the backend services, but instead sending out a static JSON. The JSON looks like the following:

Response
{
"target_groups": [
{
"path": "/foo/*",
"priority": 1,
"default": true,
"targets": [
{ "name": "serverA", "status": "up" },
{ "name": "serverB", "status": "up" },
{ "name": "serverC", "status": "up" },
{ "name": "serverD", "status": "down" },
{ "name": "serverE", "status": "up" }
]
},
{
"path": "/bar/*",
"priority": 2,
"default": false,
"targets": [
{ "name": "serverF", "status": "up" },
{ "name": "serverG", "status": "up" }
]
}
]
}

503 response

503 status code response can occur when we fail to establish connection to the target backend server. We can look for such errors in the handle_connection function

src/connection.c
void *handle_connection(void *p_new_connection) {
/** Code */

/* Create a target socket */
if (connect_to_target(&target_socket, target.host, target.port) < 0) {
handle_503(target_socket, new_connection);
return NULL;
}

/** Code */
}

Here the handle_503 function returns static HTML which says: "Service Unavailable"


Configuration

Configuration File

To create target groups, backends etc., we'll need to do some kind of configuration. Configurations are handled by a configuration file named as: byolb.config

The config file's syntax looks like the following:

byolb.config
tg {
path /foo/*
priority 1
default

target serverA localhost 80
target serverB localhost 5001
target serverC localhost 5002
target serverD localhost 5003
target serverE localhost 5004
}

tg {
path /bar/*
priority 2

target serverF localhost 5010
target serverG localhost 5011
}

To parse the above file, a simple line wise parsing is done and the request data structures (like target groups, backend targets, etc.) are created using the following function. This function is called before the server starts. The file's location can be controlled by the CONFIG_FILE environment variable

src/config_file.c
void read_config_file() {
/** Code */
}

Environment variables

Reading the environment variables is an easy job ans can be done by the getenv function in C. However, some variables are needed in particular format or types. To parse them correctly and also to assign default values in absence of the environment variables is done in the src/env.c file.

Currently, these are the environment variables allowed:

Environment variablesTypeDefaultBehaviour
HOSTStringlocalhostThe Host on which the load balancer will run
PORTInteger2209The Port on which the load balancer will run
BACKLOGInteger10The amount of listening backlog
LOG_ERRORSInteger0Wheather to log errors in verbose. Set this to 1 to log
CONFIG_FILEString/etc/opt/byolb/byolb.configThe absolute path to the configuration file

Containerizing

Most of the applications you're planning to run on the Cloud uses containers in one or the other way. Though this load balancer is not a production ready application, still I've tried to build this through a simple Dockerfile

caution

I've built the Docker image on a M1 Mac which is ARM based. I've not tested this in other platforms! If you fail to create the docker image on your platform, do open an issue!

Dockerfile
FROM ubuntu:focal

ARG DEBIAN_FRONTEND=noninteractive
RUN apt-get update && apt-get install build-essential cmake -y --no-install-recommends

# for debugging!
RUN apt-get install -y iputils-ping curl

WORKDIR /byolb

COPY . .

RUN ./scripts/clean.sh
RUN cmake --build ./build --target clean
RUN cmake --build ./build --target all

RUN mkdir -p /etc/opt/byolb

VOLUME [ "/etc/opt/byolb" ]

ENTRYPOINT [ "./build/src/lb" ]

Explore the docker image in DockerHub

What have we done?

In this article, we've started with the low level networking stuffs and slowly build towards our actual goal - a Load Balancer! We've seen several data structures and have explored techniques and algorithms to build this complex system.

But, as previously mentioned, this Load Balancer is just a hobby project and not a production ready application at all. There are still multiple sectors in which we can improve it - like identifying Denial of Service attacks, auto-scaling, handling non-HTTP protocols, logging, monitoring, etc. There are various industry grade Load Balancers like ngnix, HAProxy, etc. which implements such features!