Documentation
¶
Overview ¶
Package bulb implements a basic network load balancer for educational purposes.
Goal ¶
BULB implements functionality for an HTTP(S) server that proxies the requests it receives to a pool of backend HTTP(S) servers, spreading the load between them. It provides various different strategies for selecting which backend server should handle each request. BULB's primary purpose is to demonstrate basics of the topic and how they can be implemented in Go. Performance, security, and efficiency are secondary goals.
Design ¶
The design is quite simple, consisting of backends, backend pools, strategies, and load balancers. Each is described below in the order in which they are constructed.
Backends ¶
Each backend server is represented by an instance of Backend. This contains the URL describing the path from the load balancer to the backend host. It also contains the backend-specific configuration for health checks. This includes generating the HTTP(S) requests that should be sent to this backend during health checks, and the logic for determining whether a response demonstrates good health. Backend-agnostic health check behaviour is configured in the load balancer, documented below.
The most common case will be a backend that takes health checks with the GET method on the same port, using the path `/healthz`. Such a backend can be constructed using SimpleBackend as follows:
backend, err := bulb.SimpleBackend(addr, "/healthz", "GET")
Backend pools ¶
Backends are grouped together in pools. This allows careful management of the set of backends, so that new backends can be added at runtime without causing a harmful data race. Idiomatically, the pool is created from an existing set of backends with NewPool, with new backends added at runtime with Pool.AddBackends. Alternatively, the zero value of a pool can be created with `var pool bulb.Pool` and the backends added later with `pool.AddBackends`.
Pools are used by load balancing strategies to select backends. The list of backends can be accessed either by looping over the iterator returned by Pool.All, Pool.Healthy, or Pool.Unhealthy, or by indexing into it using Pool.Get/Pool.GetHealthy. Both approaches are safely guarded by a mutex to ensure that adding more backends does not produce a data race. Pool.Get ensures that the given index is reduced modulo the number of backends if necessary, so that the result is always in bounds.
Strategies ¶
The load balancing process uses a strategy to select which backend should handle each request. Different strategies have different performance and behavioural characteristics. This package implements the following two strategies, which are documented below:
The HashIP strategy hashes the IP address of the client making the request and uses the result as an index into the pool of backends. This has reasonable performance and ensures that successive requests by the same client will be handled by the same backend. However, this property may not hold if new backends are added, the backend becomes unhealthy, or the client's IP address changes. This may lead to load being balanced unevenly across backends.
The RoundRobin strategy sends successive requests to sequential backends. This is simple and quick, but does not consider the current load of each backend.
Users can also implement a custom Strategy by implementing the interface.
Load balancers ¶
Having prepared a pool of backends and a load balancing strategy, a LoadBalancer can now be prepared using New. Additional configuration for a load balancer can be passed using one or more Option values. For example, the error logger can be configured using WithLogger and health checks can be enabled using WithHealthChecks. See below for documentation on health checks. The load balancer is a net/http.Handler, so it is typically used as the handler in a *net/http.Server or a *net/http/httptest.Server.
Health checks ¶
Health checks are an important part of load balancing. They are used to identify backends that have become unavailable or suffered some kind of error. Once a backend has become unhealthy, subsequent requests are not sent to the backend until it becomes healthy again. This maximises the health of the overall service. When backends are added to a backend pool, they are marked healthy by default. Once a load balancer's health checks are started by calling LoadBalancer.StartHealthChecks, the load balancer will send regular health checks to check whether the backend is still healthy. The health checks are configured using the HealthCheckConfig passed to the load balancer using the WithHealthChecks option.
Example ¶
An example load balancer, with health checks, is as follows:
// Construct the set of backends.
backends := []*bulb.Backend{
bulb.SimpleBackend("http://10.0.0.1:8080/", "/healthz", http.MethodHead),
bulb.SimpleBackend("http://10.0.0.2:8080/", "/healthz", http.MethodHead),
bulb.SimpleBackend("http://10.0.0.3:8080/", "/healthz", http.MethodHead),
}
// Gather the backends into a pool.
pool, err := bulb.NewPool(backends)
if err != nil {
// Handle the error.
}
strategy := bulb.RoundRobin()
config := &bulb.HealthCheckConfig{
Interval: 30 * time.Second,
FailureThreshold: 3,
}
balancer, err := bulb.New(pool, strategy, bulb.WithHealthChecks(config))
if err != nil {
// Handle the error.
}
// Start the health checks in a background
// goroutine.
cancel, err := balancer.StartHealthChecks()
if err != nil {
// Handle the error.
}
defer cancel() // Stop the health checks once we're done.
// Start the HTTP server for the load balancer.
err = http.ListenAndServe(addr, balancer)
if err != nil {
// Handle the error.
}
Index ¶
- type Backend
- func (b *Backend) FailedHealthChecks() uint64
- func (b *Backend) FailedHealthChecksSinceLastPass() uint64
- func (b *Backend) HealthCheckFailed(threshold uint64)
- func (b *Backend) HealthCheckPassed()
- func (b *Backend) IsHealthy() bool
- func (b *Backend) MarkHealthy()
- func (b *Backend) MarkUnhealthy()
- func (b *Backend) PassedHealthChecks() uint64
- func (b *Backend) Validate() error
- type HealthCheckConfig
- type HealthChecker
- type HealthValidator
- type LoadBalancer
- type Option
- type Pool
- type Strategy
Constants ¶
This section is empty.
Variables ¶
This section is empty.
Functions ¶
This section is empty.
Types ¶
type Backend ¶
type Backend struct {
// URL describes the network path from the load balancer to this
// backend server. If URL is nil, the backend will be rejected
// by the load balancer during admission, and [Backend.Validate]
// will return an error.
//
// URL must not be modified once the backend has been admitted to
// a load balancer.
URL *url.URL
// HealthCheck is an optional configuration for performing health
// checks against the backend. If HealthCheck is not nil, it will
// be called to generate the HTTP requests used to perform health
// checks.
HealthCheck HealthChecker
// HealthValidator is an optional health check validator used when
// performing health checks. If HealthCheck is nil, no health checks
// are performed for this backend, so HealthValidator will be ignored.
//
// If HealthValidator is nil and health checks are performed, a
// [HealthValidate200] is used by default.
HealthValidator HealthValidator
// contains filtered or unexported fields
}
Backend represents a backend server that can handle requests. The load balancer spreads requests across one or more backends.
func SimpleBackend ¶
SimpleBackend is a helper function for creating backends from a main URL, health check path, and health check method. It builds a backend with the given URL, and basic health checks.
The health check method must be either http.MethodGet or http.MethodHead.
func (*Backend) FailedHealthChecks ¶
FailedHealthChecks returns the number of times this backend has responded to a health check with an unhealthy response.
This counter increments monotonically.
func (*Backend) FailedHealthChecksSinceLastPass ¶
FailedHealthChecksSinceLastPass returns the number of times this backend has responded to a health check with an unhealthy response, since the last healthy response.
This counter is reset by healthy responses to health checks.
func (*Backend) HealthCheckFailed ¶
HealthCheckFailed increments the number of health checks failed by this backend. If the resulting number of failures since the last successful health check exceeds the threshold given, then the backend is marked unhealthy.
func (*Backend) HealthCheckPassed ¶
func (b *Backend) HealthCheckPassed()
HealthCheckPassed increments the number of health checks passed by this backend and marks the backend as healthy.
func (*Backend) IsHealthy ¶
IsHealthy returns whether the backend is healthy and ready to receive requests.
func (*Backend) MarkHealthy ¶
func (b *Backend) MarkHealthy()
MarkHealthy marks the backend as healthy and ready to receive requests. If the backend is already healthy, this will have no effect.
Most callers should use Backend.HealthCheckPassed instead.
func (*Backend) MarkUnhealthy ¶
func (b *Backend) MarkUnhealthy()
MarkUnhealthy marks the backend as unhealthy and prevents future requests from being sent to it until is is marked healthy again. If the backend is already unhealthy, this will have no effect.
Most callers should use Backend.HealthCheckFailed instead.
func (*Backend) PassedHealthChecks ¶
PassedHealthChecks returns the number of times this backend has responded to a health check with a healthy response.
This counter increments monotonically.
type HealthCheckConfig ¶
type HealthCheckConfig struct {
// Client is the HTTP client used to perform health checks.
// If Client is nil, [http.DefaultClient] is used.
Client *http.Client
// Interval is the time between health checks on each backend.
//
// The actual intervals will vary somewhat, so may be slightly
// greater or less than Interval.
//
// If Interval is 0, an interval of 30 seconds is used.
Interval time.Duration
// FailureThreshold is the maximum number of consecutive health
// checks that a backend can fail before it is marked unhealthy.
// That is, if FailureThreshold is 0, then failing a single health
// check marks the backend as unhealthy.
FailureThreshold uint64
}
HealthCheckConfig contains the information necessary to configure health checks for a load balancer's backends.
type HealthChecker ¶
A HealthChecker can be called to generate HTTP requests for checking the health of a Backend. Most HealthChecker needs can be met by a BasicHealthChecker, which generates simple HTTP requests based on a target URL and method. More complex needs can be met by implementing a custom HealthChecker.
Each request produced by a HealthChecker will not be modified and will be used only once. The context provided will never be nil. The health checker must not modify the request after returning it.
func BasicHealthChecker ¶
func BasicHealthChecker(method string, target *url.URL) HealthChecker
BasicHealthChecker produces requests using the given HTTP method and target URL.
type HealthValidator ¶
A HealthValidator can be called to determine whether a health check response should be considered healthy. Most needs will be met by a HealthValidate200.
A HealthValidator can also be used optionally to record metrics and other performance data.
func HealthValidate200 ¶
func HealthValidate200() HealthValidator
HealthValidate200 returns a simple HealthValidator that expects the response to include a 200 HTTP status code.
type LoadBalancer ¶
type LoadBalancer struct {
// contains filtered or unexported fields
}
LoadBalancer implements a basic usable load balancer.
func New ¶
func New(pool *Pool, strategy Strategy, options ...Option) (*LoadBalancer, error)
New creates a load balancer from the given pool of backends and load balancing strategy.
Options can be provided to configure the load balancer.
func (*LoadBalancer) ServeHTTP ¶
func (b *LoadBalancer) ServeHTTP(w http.ResponseWriter, r *http.Request)
ServeHTTP handles a request by using the load balancing strategy to select a backend and then proxy the request to it.
func (*LoadBalancer) StartHealthChecks ¶
func (b *LoadBalancer) StartHealthChecks() (cancel func(), err error)
StartHealthChecks starts a new goroutine that performs health checks against the backends, as configured using WithHealthChecks.
The cancel function returned will stop subsequent health checks and terminate the spawned goroutine. If StartHealthChecks is called but no health checks have been configured, an error is returned.
type Option ¶
type Option func(*LoadBalancer) error
Option represents a method for configuring load balancers.
func WithHealthChecks ¶
func WithHealthChecks(config *HealthCheckConfig) Option
WithHealthChecks configures the load balancer to perform health checks on each backend, according to the health check configuration provided.
If the config is nil, no health checks will be performed.
func WithLogger ¶
WithLogger configures the load balancer to use the given logger.
type Pool ¶
type Pool struct {
// contains filtered or unexported fields
}
Pool represents a set of backends.
The role of the pool is to ensure that accesses to the set of backends is thread-safe. This uses a sync.RWMutex to protect access. Adding a backend to the pool write-locks the pool, whereas iterating over the pool only read-locks it.
func NewPool ¶
NewPool creates a new pool with the given backends. If any of the backends is invalid (its Backend.Validate method returns an error), NewPool will return an error.
func (*Pool) AddBackends ¶
AddBackends adds one or more backends to the pool. If any of the backends is invalid (its Backend.Validate method returns an error), AddBackends will return an error and make no changes to the pool.
func (*Pool) All ¶
All returns a safe iterator over the backends. This uses a read-lock over the set of backends.
func (*Pool) Get ¶
Get safely returns the backend at the given index, modulo the number of backends available. This uses a read-lock over the set of backends.
If no backends are available, Get returns nil.
func (*Pool) GetHealthy ¶
GetHealthy safely returns the backend at the given index, modulo the number of backends available. This uses a read-lock over the set of backends. GetHealthy will skip over any unhealthy backends.
If no backends are available, or if every backend is unhealthy, Get returns nil.
type Strategy ¶
type Strategy interface {
// Select chooses a backend to handle the given HTTP
// request. Select must not modify the request at all.
// Select can iterate through the backends using [Pool.All],
// but must not add new backends using [Pool.AddBackends].
//
// If no backend can be selected, the strategy should
// return a nil backend and an appropriate error.
Select(backends *Pool, request *http.Request) (*Backend, error)
}
Strategy selects a backend to handle an HTTP request.
func HashIP ¶
func HashIP() Strategy
HashIP returns a load balancing Strategy that hashes the client IP address to determine which backend to use. This ensures that successive requests from a single client will be passed to the same backend, provided there are no changes to the client's IP address or the pool of backends.
func RoundRobin ¶
func RoundRobin() Strategy
RoundRobin returns a load balancing Strategy that uses the round robin approach. It sends successive requests to sequential backends.