When we built our server pinging application earlier in this chapter, it was probably pretty easy to imagine taking this to a more usable and valuable space.
Pinging a server is often the first step in a health check for a load balancer. Just as Go provides a usable out-of-the-box web server solution, it also presents a very clean Proxy
and ReverseProxy
struct and methods, which makes creating a load balancer rather simple.
Of course, a round-robin load balancer will need a lot of background work, specifically on checking and rechecking as it changes the ReverseProxy
location between requests. We'll handle these with the goroutines triggered with each request.
Finally, note that we have some dummy URLs at the bottom in the configuration—changing those to production URLs should immediately turn the server that runs this into a working load balancer. Let's look at the main setup for the application:
package main import ( "fmt" "log" "net/http" "net/http/httputil" "net/url" "strconv" "time" ) const MAX_SERVER_FAILURES = 10 const DEFAULT_TIMEOUT_SECONDS = 5 const MAX_TIMEOUT_SECONDS = 60 const TIMEOUT_INCREMENT = 5 const MAX_RETRIES = 5
In the previous code, we defined our constants, much like we did previously. We have a MAX_RETRIES
, which limits how many failures we can have, MAX_TIMEOUT_SECONDS
, which defines the longest amount of time we'll wait before trying again, and our TIMEOUT_INCREMENT
for changing that value between failures. Next, let's look at the basic construction of our Server
struct:
type Server struct { Name string Failures int InService bool Status bool StatusCode int Addr string Timeout int LastChecked time.Time Recheck chan bool }
As we can see in the previous code, we have a generic Server
struct that maintains the present state, the last status code, and information on the last time the server was checked.
Note that we also have a Recheck
channel that triggers the delayed attempt to check the Server
again for availability. Each Boolean passed across this channel will either remove the server from the available pool or reannounce that it is still in service:
func (s *Server) serverListen(serverChan chan bool) { for { select { case msg := <-s.Recheck: var statusText string if msg == false { statusText = "NOT in service" s.Failures++ s.Timeout = s.Timeout + TIMEOUT_INCREMENT if s.Timeout > MAX_TIMEOUT_SECONDS { s.Timeout = MAX_TIMEOUT_SECONDS } } else { if ServersAvailable == false { ServersAvailable = true serverChan <- true } statusText = "in service" s.Timeout = DEFAULT_TIMEOUT_SECONDS } if s.Failures >= MAX_SERVER_FAILURES { s.InService = false fmt.Println(" Server", s.Name, "failed too many times.") } else { timeString := strconv.FormatInt(int64(s.Timeout), 10) fmt.Println(" Server", s.Name, statusText, "will check again in", timeString, "seconds") s.InService = true time.Sleep(time.Second * time.Duration(s.Timeout)) go s.checkStatus() } } } }
This is the instantiated method that listens on each server for messages delivered on the availability of a server at any given time. While running a goroutine, we keep a perpetually listening channel open to listen to Boolean responses from checkStatus()
. If the server is available, the next delay is set to default; otherwise, TIMEOUT_INCREMENT
is added to the delay. If the server has failed too many times, it's taken out of rotation by setting its InService
property to false
and no longer invoking the checkStatus()
method. Let's next look at the method for checking the present status of Server
:
func (s *Server) checkStatus() { previousStatus := "Unknown" if s.Status == true { previousStatus = "OK" } else { previousStatus = "down" } fmt.Println("Checking Server", s.Name) fmt.Println(" Server was", previousStatus, "on last check at", s.LastChecked) response, err := http.Get(s.Addr) if err != nil { fmt.Println(" Error: ", err) s.Status = false s.StatusCode = 0 } else { s.StatusCode = response.StatusCode s.Status = true } s.LastChecked = time.Now() s.Recheck <- s.Status }
Our checkStatus()
method should look pretty familiar based on the server ping example. We look for the server; if it is available, we pass true
to our Recheck
channel; otherwise false
, as shown in the following code:
func healthCheck(sc chan bool) { fmt.Println("Running initial health check") for i := range Servers { Servers[i].Recheck = make(chan bool) go Servers[i].serverListen(sc) go Servers[i].checkStatus() } }
Our healthCheck
function simply kicks off the loop of each server checking (and re-checking) its status. It's run only one time, and initializes the Recheck
channel via the make
statement:
func roundRobin() Server { var AvailableServer Server if nextServerIndex > (len(Servers) - 1) { nextServerIndex = 0 } if Servers[nextServerIndex].InService == true { AvailableServer = Servers[nextServerIndex] } else { serverReady := false for serverReady == false { for i := range Servers { if Servers[i].InService == true { AvailableServer = Servers[i] serverReady = true } } } } nextServerIndex++ return AvailableServer }
The roundRobin
function first checks the next available Server
in the queue—if that server happens to be down, it loops through the remaining to find the first available Server
. If it loops through all, it will reset to 0
. Let's look at the global configuration variables:
var Servers []Server var nextServerIndex int var ServersAvailable bool var ServerChan chan bool var Proxy *httputil.ReverseProxy var ResetProxy chan bool
These are our global variables—our Servers
slice of Server
structs, the nextServerIndex
variable, which serves to increment the next Server
to be returned, ServersAvailable
and ServerChan
, which start the load balancer only after a viable server is available, and then our Proxy
variables, which tell our http
handler where to go. This requires a
ReverseProxy
method, which we'll look at now in the following code:
func handler(p *httputil.ReverseProxy) func(http.ResponseWriter, *http.Request) { Proxy = setProxy() return func(w http.ResponseWriter, r *http.Request) { r.URL.Path = "/" p.ServeHTTP(w, r) } }
Note that we're operating on a ReverseProxy
struct here, which is different from our previous forays into serving webpages. Our next function executes the round robin and gets our next available server:
func setProxy() *httputil.ReverseProxy { nextServer := roundRobin() nextURL, _ := url.Parse(nextServer.Addr) log.Println("Next proxy source:", nextServer.Addr) prox := httputil.NewSingleHostReverseProxy(nextURL) return prox }
The setProxy
function is called after every request, and you can see it as the first line in our handler. Next we have the general listening function that looks out for requests we'll be reverse proxying:
func startListening() { http.HandleFunc("/index.html", handler(Proxy)) _ = http.ListenAndServe(":8080", nil) } func main() { nextServerIndex = 0 ServersAvailable = false ServerChan := make(chan bool) done := make(chan bool) fmt.Println("Starting load balancer") Servers = []Server{{Name: "Web Server 01", Addr: "http://www.google.com", Status: false, InService: false}, {Name: "Web Server 02", Addr: "http://www.amazon.com", Status: false, InService: false}, {Name: "Web Server 03", Addr: "http://www.apple.zom", Status: false, InService: false}} go healthCheck(ServerChan) for { select { case <-ServerChan: Proxy = setProxy() startListening() return } } <-done }
With this application, we have a simple but extensible load balancer that works with the common, core components in Go. Its concurrency features keep it lean and fast, and we wrote it in a very small amount of code using exclusively standard Go.