Today’s microservices powered architectures require the ability to make frequent application delivery changes in an automated and reliable way. One of HAProxy’s top microservices users performs 20 thousand backend server updates per day per physical machine, with thousands of machines in their fleet. HAProxy is able to fully address such requirements through its extensive Runtime API, which can be leveraged to dynamically scale backend servers up and down during runtime.

We recently published a blog post on HAProxy’s hitless reloads and upgrades which described a new, completely safe reload mechanism in HAProxy. However, reloads can still cause other types of issues and are best kept to a minimum; for example, frequent reloads combined with long running connections can result in a temporary accumulation of old processes or an increase in load.

To minimize reloads, we can use HAProxy’s Runtime API which can be accessed over a TCP or Unix socket. The API provides “live” access to numerous HAProxy features, including: changing backend server addresses, ports, weights, and states; enabling and disabling backends; updating maps, ACLs, and TLS ticket keys; providing session lists, recent error logs, and other debugging information. By taking advantage of the backend-related runtime functionality, we can dynamically scale backend servers without triggering any reloads.

In this blog post we will be using the Runtime API to implement dynamic scaling. Consul will be used for service discovery. The general approach described here is reusable and could be adapted to any service discovery tool or microservices orchestration system you might be using.

The HAProxy Runtime API – Introduction

The HAProxy Runtime API is exposed over a TCP or Unix socket.

The Runtime API is fully described in the HAPEE Management Guide, section 9.3, and can be enabled with the following global HAProxy configuration:

global
   ...
   stats socket /var/run/hapee-lb.sock mode 666 level admin
   stats socket ipv4@127.0.0.1:9999 level admin
   stats timeout 2m
   ...

Once the Runtime API is enabled, it can be accessed manually by using the command “socat”:

$ echo "<command>" | socat stdio /var/run/hapee-lb.sock


$ echo "<command>" | socat stdio tcp4-connect:127.0.0.1:9999

The Runtime API exposes a number of useful features and options, all executable in runtime without reloading the service.
The output of the “help” command displays the main groups of functions available. Here is the output from HAProxy version 1.8-dev2. If your HAProxy is missing any particular command, please ensure that you are using one of the newer releases – the Runtime API has evolved over time.

$ echo "help" | socat stdio /var/run/hapee-lb.sock

  help           : this message
  prompt         : toggle interactive mode with prompt
  quit           : disconnect
  show errors    : report last request and response errors for each proxy
  clear counters : clear max statistics counters (add 'all' for all counters)
  show info      : report information about the running process
  show stat      : report counters for each proxy and server
  show schema json : report schema used for stats
  disable agent  : disable agent checks (use 'set server' instead)
  disable health : disable health checks (use 'set server' instead)
  disable server : disable a server for maintenance (use 'set server' instead)
  enable agent   : enable agent checks (use 'set server' instead)
  enable health  : enable health checks (use 'set server' instead)
  enable server  : enable a disabled server (use 'set server' instead)
  set maxconn server : change a server's maxconn setting
  set server     : change a server's state, weight or address
  get weight     : report a server's current weight
  set weight     : change a server's weight (deprecated)
  show sess [id] : report the list of current sessions or dump this session
  shutdown session : kill a specific session
  shutdown sessions server : kill sessions on a server
  clear table    : remove an entry from a table
  set table [id] : update or create a table entry's data
  show table [id]: report table usage stats or dump this table's contents
  disable frontend : temporarily disable specific frontend
  enable frontend : re-enable specific frontend
  set maxconn frontend : change a frontend's maxconn setting
  show servers state [id]: dump volatile server information (for backend <id>)
  show backend   : list backends in the current running config
  shutdown frontend : stop a specific frontend
  set dynamic-cookie-key backend : change a backend secret key for dynamic cookies
  enable dynamic-cookie backend : enable dynamic cookies on a specific backend
  disable dynamic-cookie backend : disable dynamic cookies on a specific backend
  show stat resolvers [id]: dumps counters from all resolvers section and
                          associated name servers
  set maxconn global : change the per-process maxconn setting
  set rate-limit : change a rate limiting value
  set timeout    : change a timeout setting
  show env [var] : dump environment variables known to the process
  show cli sockets : dump list of cli sockets
  add acl        : add acl entry
  clear acl <id> : clear the content of this acl
  del acl        : delete acl entry
  get acl        : report the patterns matching a sample for an ACL
  show acl [id]  : report available acls or dump an acl's contents
  add map        : add map entry
  clear map <id> : clear the content of this map
  del map        : delete map entry
  get map        : report the keys and values matching a sample for a map
  set map        : modify map entry
  show map [id]  : report available maps or dump a map's contents
  show pools     : report information about the memory pools usage

To see the complete Runtime API documentation, please refer to the HAPEE Management Guide, section 9.3.

Dynamic Scaling

Introduction

For our dynamic scaling example, we are going to use two machines, ‘virtdeb1’ and ‘virtdeb2’, both running Debian 8.  They are each running Apache (on port 8080) and Consul (on port 8500).

Consul

There is a convenient guide for configuring Consul available, but to give you quick a representation of the configuration, here is the Consul state as reported by Consul in our example setup, after it has been configured:

$ ./consul members
Node Address Status Type Build Protocol DC
virtdeb2 192.168.122.66:8301 alive server 0.8.3 2 dc1
virtdeb1 192.168.122.185:8301 alive server 0.8.3 2 dc1

The web service is also configured in Consul via the following JSON file in the services directory:

{
   "service": {
      "name": "my-cluster",
      "port": 8500,
      "tags": ["web"],
      "check": {
         "script": "wget localhost:8080 > /dev/null 2>&1",
         "interval": "30s"
      }
   }
}

Please note that we are not running the health checks in the above example very frequently, as those are best handled by HAProxy. Depending on your environment, you might not want health checks in Consul at all, but in our example they are used to conveniently and automatically remove a server from the list once it stops responding.

Now we can run wget -qO -O - localhost:8500/v1/catalog/service/my-cluster and see a JSON response which has both nodes listed in it.

HAProxy

For HAProxy, we are going to start with a simple, but complete configuration file:

global
   maxconn 10000
   log 127.0.0.1 local2 info
   pidfile /var/run/hapee-1.7/hapee-lb.pid
   user daemon
   group daemon
   stats socket /var/run/hapee-lb.sock mode 666 level admin
   daemon
defaults
   mode http
   log global
   option httplog
   timeout connect 10s
   timeout client 300s
   timeout server 300
frontend fe_main
   bind *:80
   default_backend be_template
backend be_template
   balance roundrobin
   option httpchk HEAD /

We will be adding additional configuration lines to it as we progress with our example setup.

Configuration Updates

There are two general methods for updating the HAProxy configuration:

  • Updating the configuration dynamically using the HAProxy Runtime API (completely avoiding a reload), optionally saving the current state to the runtime state file
  • Building the HAProxy configuration file from a template and invoking a reload (+ using the recently added hitless reloads capability)

Please note that you don’t have to choose one of these approaches – you could combine them. In this blog post we are going to show how to configure the Runtime API approach (plus using the state file), as well as how to configure a hybrid approach using the Runtime API (with no state file) and with configuration file built from a template. The hybrid approach could be optimal for your environment when you want to implement dynamic scaling, but also keep the running configuration and the contents of the configuration file identical.

Runtime API

The following configuration file additions will help us configure HAProxy for effective use of the Runtime API:

First, we could use the server templates feature (which was recently added to the HAProxy Enterprise Edition (get a free trial) and to the development branch of HAProxy Community Edition) to quickly define template/placeholder slots for up to n backend servers:

server-template websrv 1-100 192.168.122.1:8080 check disabled

This configuration is equal to writing out server websrvX 192.168.122.1:8080 check disabled 100 times, but automatically replacing X with a number incrementing from 1 to 100, inclusive. The servers are added in the disabled state, and it is expected that your server template range (“1-100”) will be larger than the number of servers you currently have, to allow for runtime/dynamic scaling to up to n configured backend server slots.

After the configuration is in place, we could manually invoke the Runtime API to configure or update the servers. For example, we could update the IP address and port of ‘websrv1’, and change it from a ‘disabled’ to a ‘ready’ state:

echo "set server be_template/websrv1 addr 192.168.122.42 port 8080" | socat stdio /var/run/hapee-lb.sock
echo "set server be_template/websrv1 state ready" | socat stdio /var/run/hapee-lb.sock

In addition to using the server-template directive, we are also going to ensure that any runtime changes are written out to the state file, and that the state file is loaded on reload/restart:

global
   server-state-file /usr/local/haproxy/haproxy.state
defaults
   load-server-state-from-file global

Please note that the state file is not updated automatically. To save state, you would run echo "show servers state" | socat stdio /var/run/haproxy.sock > /usr/local/haproxy/haproxy.state after making changes to the states and before invoking an HAProxy reload or restart.

The above configuration will allow us to automatically persist most of the settings that can be changed using the Runtime API. However, please note that at the moment it will not persist the port numbers set in runtime. The ability to save ports to the state file in HAProxy is coming soon, but if your environment depends on the varying port numbers and you would want to implement it right now, you could simply switch to using a configuration file template, explained below.

Another possible reason for preferring a configuration file template over the “server-template” and “server-state-files” directives would be to minimize “drift” between the literal contents of the configuration file and the configuration which is loaded and running in your HAProxy processes.

Configuration File from a Template

Templates can be written for any templating engine, but in this case we are going to use jinja2 for Python. Instead of adding the “server-template” line mentioned above into your HAProxy configuration file, you would copy the whole configuration file to a new, template file (named e.g. “haproxy.tmpl”). Then, in the template, you would replace the “server-template” line (or the list of backend servers) with the following:

{{ backends_be_template }}

This template would then be evaluated by a script to replace the placeholder with the actual list of backend servers and ports, and to produce the final configuration file. The list of servers/ports might come from your orchestration system or any other source. An example of such a script is included in the next section.

Using a Hybrid Approach

In a hybrid approach, we would want to use the Runtime API to apply changes to a running HAProxy instance immediately, but we would also want to keep the configuration file in sync using the template-based approach. So, a helper script should do the following:

  1. Get a list of backends from the orchestration system
  2. Compare the list with the backends active in HAProxy
  3. Adjust the backend addresses and ports using the Runtime API
  4. Rebuild the HAProxy configuration with server lines and write it to disk

Without further ado, here is an example Python script which implements the above steps. (If you get excess space when copying, please click the icon “raw” in the upper right of the content box.)

#!/usr/bin/python
import requests
import socket
import sys
from jinja2 import Template

Consul_api_server="http://localhost:8500"
Consul_service="my-cluster"
Haproxy_servers=["/var/run/hapee-lb.sock"]
Backend_name="be_template"

#The following are only used for building the configuration from template
Haproxy_template_file="/home/haproxy/haproxy/haproxy.tmpl"
Haproxy_config_file="/home/haproxy/haproxy/haproxy.cfg"
Haproxy_spare_slots=4
Backend_base_name="websrv"

def send_haproxy_command(server, command):
   if haproxy_server[0] == "/":
      haproxy_sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
   else:
      haproxy_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
   haproxy_sock.settimeout(10)
   try:
      haproxy_sock.connect(haproxy_server)
      haproxy_sock.send(command)
      retval = ""
      while True:
         buf = haproxy_sock.recv(16)
         if buf:
            retval += buf
         else:
            break
      haproxy_sock.close()
   except:
      retval = ""
   finally:
      haproxy_sock.close()
   return retval

def build_config_from_template(backends):
   backend_block=""
   i=0
   for backend in backends:
      i+=1
      backend_block += "   server %s%d %s:%s cookie %s%d check\n" % (Backend_base_name, i,backend[0], backend[1],Backend_base_name, i)
   for disabled_slot in range(0,Haproxy_spare_slots):
      i+=1
      backend_block += "   server %s%d 0.0.0.0:80 cookie %s%d check disabled\n" % (Backend_base_name, i,Backend_base_name, i)
   try:
      haproxy_template_fh = open(Haproxy_template_file, 'r')
      haproxy_template = Template(haproxy_template_fh.read())
      haproxy_template_fh.close()
   except:
      print("Failed to read HAProxy config template")
   template_values = {}
   template_values["backends_%s"%(Backend_name)] = backend_block
   try:
      haproxy_config_fh = open(Haproxy_config_file,'w')
      haproxy_config_fh.write(haproxy_template.render(template_values))
      haproxy_config_fh.close()
   except:
      print("Failed to write HAProxy config file")

if __name__ == "__main__":
   #First, get the servers we need to add
   try:
      consul_json = requests.get("%s/v1/catalog/service/%s" % (Consul_api_server, Consul_service))
      consul_json.raise_for_status()
      consul_service = consul_json.json()
   except:
      print("Failed to get backend list from Consul.")
      sys.exit(1)
   backend_servers=[]
   for server in consul_service:
      backend_servers.append([server['Address'], server['ServicePort']])
   if len(backend_servers) < 1:
      print("Consul didn't return any servers.")
      sys.exit(2)
   #Now update each HAProxy server with the backends in question
   for haproxy_server in Haproxy_servers:
      haproxy_slots = send_haproxy_command(haproxy_server,"show stat\n")
      if not haproxy_slots:
         print("Failed to get current backend list from HAProxy socket.")
         sys.exit(3)
      haproxy_slots = haproxy_slots.split('\n')
      haproxy_active_backends = {}
      haproxy_inactive_backends = []
      for backend in haproxy_slots:
         backend_values = backend.split(",")
         if len(backend_values) > 80 and backend_values[0] == Backend_name:
            server_name = backend_values[1]
            if server_name == "BACKEND":
               continue
            server_state = backend_values[17]
            server_addr = backend_values[73]
            if server_state == "MAINT":
               #Any server in MAINT is assumed to be unconfigured and free to use (to stop a server for your own work try 'DRAIN' for the script to just skip it)
               haproxy_inactive_backends.append(server_name)
            else:
               haproxy_active_backends[server_addr] = server_name
      haproxy_slots = len(haproxy_active_backends) + len(haproxy_inactive_backends)
      for backend in backend_servers:
         if "%s:%s" % (backend[0],backend[1]) in haproxy_active_backends:
            del haproxy_active_backends["%s:%s" % (backend[0],backend[1])] #Ignore backends already set
         else:
            if len(haproxy_inactive_backends) > 0:
               backend_to_use = haproxy_inactive_backends.pop(0)
               send_haproxy_command(haproxy_server, "set server %s/%s addr %s port %s\n" % (Backend_name, backend_to_use, backend[0], backend[1]))
               send_haproxy_command(haproxy_server, "set server %s/%s state ready\n" % (Backend_name, backend_to_use))
            else:
               print("WARNING: Not enough backend slots in backend")
      for remaining_server in haproxy_active_backends:
         send_haproxy_command(haproxy_server, "haproxy_server, set server %s/%s state maint\n" % (Backend_name, remaining_server))
   #Finally, rebuild the HAProxy configuration for restarts/reloads
   build_config_from_template(backend_servers)

For the script to work, you would need the “requests” and “jinja2” packages installed for Python; using pip or your operating system’s package manager should accomplish the task.

Running the Script

The script could be run on the machine running HAProxy, invoked from cron.

The script could also be run on a separate server on which Consul is running. In that case, you would need a mechanism for transferring the finished template to the HAProxy server, and you would need to modify the “Haproxy_servers” variable to point to a remote IP and port rather than a Unix socket, to be able to access the Runtime API. (Specific example of modifying the script to connect to a remote machine is included near the bottom of this post.)

Finally, instead of using cron for periodically invoking the script, you could use the “watches” section in Consul:

{
   "watches": [
      {
         "type": "service",
         "service": "my-cluster",
         "handler": "/usr/bin/python /usr/local/haproxy/update_haproxy_from_consul.py"
      }
   ]
}

With a “watches” section, whenever a new server is added or removed from the configuration, Consul would automatically run the script.

Modifying / Improving the Script

The script could be modified and improved in any way you would prefer.

If the option “server-template” suits your needs and you do not need to use a configuration file template, you might wish to edit the script and remove jinja2 invocations. This would be done by commenting out the line “from jinja2 import Template” near the top of the script, and by replacing the last line of the script (“build_config_from_template(…)”) with a command that saves the state to the state file.

Other common modifications you might want to implement in the script would be to support multiple HAProxy processes, or to support HAProxy instances on machines other than the one running the script. Both are briefly explained below:

Multiple Processes

If in your HAProxy configuration you have ‘nbproc’ value set to more than ‘1’, you might notice a warning about the stats sockets not being bound to a specific process. To get around that, create one socket for each process in the global section:

    nbproc 3
    stats socket       /var/run/hapee-lb-1.sock mode 660 level admin process 1
    stats socket       /var/run/hapee-lb-2.sock mode 660 level admin process 2
    stats socket       /var/run/hapee-lb-3.sock mode 660 level admin process 3

Then add each socket to the “Haproxy_servers” variable near the top of the script:

Haproxy_servers=["/var/run/hapee-lb-1.sock", "/var/run/hapee-lb-2.sock", "/var/run/hapee-lb-3.sock"]

Now all three processes will be updated with the same backends.

Script Running on Another Server

As mentioned, the Runtime API is also accessible over TCP, so you can enable the TCP-based API and update the HAProxy configurations on other machines using the same script.

   stats socket 192.168.122.185:8181 level admin

If you are using nbproc > 1, the configuration would look like this:

   stats socket 192.168.122.185:8181 level admin process 1
   stats socket 192.168.122.185:8182 level admin process 2
   stats socket 192.168.122.185:8183 level admin process 3

Then, in the script you would specify IPs and ports instead of (or in addition to) Unix sockets:

Haproxy_servers=[("192.168.122.185",8181),("192.168.122.185",8182),("192.168.122.185",8183)]

And the above configuration tips could used for any other Runtime API functionality.

Conclusion

By using the HAProxy Runtime API we can dynamically scale backend servers and generally update the “live” configuration without requiring a reload.

We can conveniently use the new server-template feature to configure up to n backend server slots.
If you would like to use server-template before waiting for the stable release of HAProxy 1.8, please see our HAProxy Enterprise Edition – Trial Version.

The general approach shown in this blog post is reusable and could be adapted to any service discovery tool or microservices orchestration system you might be using. Contact HAProxy Technologies if you would like us to provide you with expert advice on how to best integrate the solution into your existing infrastructure.

Stay tuned for more blog posts on using microservices with HAProxy, and happy scaling!