Dynamic HAProxy configs with puppet

I’ve posted a little about puppet and our teams ops in the past since my team has pretty heavily invested in the dev portion of the ops role. Our initial foray into ops included us building a pretty basic puppet role based system which we use to coordinate docker deployments of our java services.

We use HAProxy as our software load balancer and the v1 of our infrastructure managment had us versioning a hardcoded haproxy.cfg for each environment and pushing out that config when we want to add or remove machines from the load balancer. It works, but it has a few issues

  1. Cluster swings involve checking into github. This pollutes our version history with a bunch of unnecessary toggling
  2. Difficult to automate swings since its flat file config driven and requires the config to be pushed out from puppet

Our team did a little brainstorming and came up with a nice solution which is to data drive it from some sort of json blob. By abstracting who provides the json blob and just building out our ha proxy config from structured data we can move to an API to serve this up for us. Step one was to replace our haproxy.conf with some sort of flat file json. The workflow we have isn’t changing, but its setting us up for success. Step two is to tie in something like consul to provide the json for us.

The first thing we need to do to support this is get puppet to know how to load up json from either a file or from an api. To do that we built an extra puppet custom function which we put into our /etc/puppet/modules/custom/lib/puppet/functions folder:

require 'json'
require 'rest-client'

module Puppet::Parser::Functions
  newfunction(:json_provider, :type => :rvalue) do |args|

    begin
      url=args[0]

      info("Getting json from url #{url}")

      if File.exists?(url)
        raw_json = File.read(url)
      else
        raw_json = RestClient.get(url)
      end

      data = JSON.parse(raw_json)

      info("Got json #{data}")

      data
    rescue Exception => e
      warning("Error accessing url #{url} from args '#{args}' with exception #{e}")

      raise Puppet::ParseError, "Error getting value from url #{url} exception #{e}"
    end
  end
end

And we need to make sure the puppetmaster knows where all its gems are so we we’ve added

 if ! defined(Package['json']) {
    package { 'json':
      ensure   => installed,
      provider => 'gem'
    }
  }

  if ! defined(Package['rest-client']) {
    package { 'rest-client':
      ensure   => installed,
      provider => 'gem'
    }
  }

To our puppet master role .pp.

At this point we can define what our ha proxy json file would look like. A sample structure that we’ve settled on looks like this:

{
  "frontends": [
    {
      "name": "main",
      "bind": "*",
      "port": 80,
      "default_backend": "app"
    },
    {
      "name": "legacy",
      "bind": "*",
      "port": 8080,
      "default_backend": "app"
    }
  ],
  "backends": [
    {
      "name": "app",
      "options": [
        "balance roundrobin"
      ],
      "servers": [
        {
          "name": "api1",
          "host": "api1.cloud.dev:8080",
          "option": "check"
        },
        {
          "name": "api2",
          "host": "api1.cloud.dev:8080",
          "option": "check"
        }
      ]
    }
  ]
}

Using this structure we can dynamically build out our haproxy.conf using ruby’s erb templating that puppet hooks into. Below is our ha proxy erb template. It assumes that @config is in the current scope which should be a json object in the puppet file. While the config is pretty basic, we don’t use any ACLs or too many custom options, we can always tweak the base haproxy config or add more metadata to our json structure to support more options.

#---------------------------------------------------------------------
# Example configuration for a possible web application.  See the
# full configuration options online.
#
#   http://haproxy.1wt.eu/download/1.4/doc/configuration.txt
#
#---------------------------------------------------------------------

#---------------------------------------------------------------------
# Global settings
#---------------------------------------------------------------------
global
    # to have these messages end up in /var/log/haproxy.log you will
    # need to:
    #
    # 1) configure syslog to accept network log events.  This is done
    #    by adding the '-r' option to the SYSLOGD_OPTIONS in
    #    /etc/sysconfig/syslog
    #
    # 2) configure local2 events to go to the /var/log/haproxy.log
    #   file. A line like the following can be added to
    #   /etc/sysconfig/syslog
    #
    #    local2.*                       /var/log/haproxy.log
    #
    log         127.0.0.1 local2

    chroot      /var/lib/haproxy
    pidfile     /var/run/haproxy.pid
    maxconn     4000
    user        haproxy
    group       haproxy
    daemon

    # turn on stats unix socket
    stats socket /var/lib/haproxy/stats level admin

#---------------------------------------------------------------------
# common defaults that all the 'listen' and 'backend' sections will
# use if not designated in their block
#---------------------------------------------------------------------
defaults
    mode                    http
    log                     global
    option                  httplog
    option                  dontlognull
    option http-server-close
    option forwardfor       except 127.0.0.0/8
    option                  redispatch
    retries                 3
    timeout http-request    10s
    timeout queue           1m
    timeout connect         10s
    timeout client          1m
    timeout server          1m
    timeout http-keep-alive 10s
    timeout check           10s
    maxconn                 3000

listen stats :1936
    mode http
    stats enable
    stats hide-version
    stats realm Haproxy\ Statistics
    stats uri /
    stats auth admin:password
#---------------------------------------------------------------------
# main frontend which proxys to the backends
#---------------------------------------------------------------------
<% @config["frontends"].each do |frontend| %>
frontend  <%= frontend["name"] %> <%= frontend["bind"] %>:<%= frontend["port"] %>
    default_backend             <%= frontend["default_backend"] %>
<% end %>
#---------------------------------------------------------------------
# backends
#---------------------------------------------------------------------

<% @config["backends"].each do |backend| %>
backend <%= backend["name"] %>
    <%- if backend["options"] != nil -%>
        <%- backend["options"].each do |option| -%>
    <%= option %>
        <%- end -%>
    <%- end -%>
    <%- backend["servers"].each do |server| -%>
    server  <%= server["name"] %> <%= server["host"] %> <%= server["option"] %>
    <%- end -%>
<% end %>

This builds out a simple set of named frontends that point to a set of backends. We can populate backends for the different swing configurations (A cluster, B cluster, etc) and then toggle the default frontend to swing.

But, we still have to provide for a graceful reload. There is a lot of documentation out there on this, but the gist is that you want to cause clients to retry under the hood while you restart, so that the actual requester of the connection doesn’t notice a blip in service. To do that we can leverage the codified structure as well with another template

#!/bin/bash

# hold/pause new requests
<% @config["frontends"].each do |frontend| %>
/usr/sbin/iptables -I INPUT -p tcp --dport <%= frontend["port"] %> --syn -j DROP
<% end %>

sleep 1

# gracefully restart haproxy
/usr/sbin/haproxy -f /etc/haproxy/haproxy.cfg -p /var/run/haproxy.pid -sf $(cat /var/run/haproxy.pid)

# allow new requests to come in again
<% @config["frontends"].each do |frontend| %>
/usr/sbin/iptables -D INPUT -p tcp --dport  <%= frontend["port"] %> --syn -j DROP
<% end %>

This inserts a rule for each frontend port to drop SYN packets silenty. SYN is the first packet type used in the tcp 3 way handshake and by dropping it the client will retry a few times after some interval to reconnect. This does mean the initial client will experience a slight delay, but their request will go through vs getting completely dropped.

Now our final haproxy.pp file looks like

class custom::loadbalancers::dynamic_ha(
  $load_balance_path = undef,
  $identity = undef # a unique seed to make sure the haproxy reloads dont stomp
)
{

  if $load_balance_path  == undef {
    fail 'Pass in a load balance source path. Can be either a file on disk or a GET json url'
  }

  if $identity == undef {
    fail "Identity for ha should be unique and set. This creates a temp file for reloading the haproxy gracefully"
  }

  package { 'haproxy':
    ensure => installed
  } ->

  service { 'haproxy':
    enable => true,
    ensure => running,
  }  ->

  package { 'haproxyctl':
    ensure    => installed,
    provider  => "gem"
  }

  $config = json_provider($load_balance_path)

  $rand = fqdn_rand(1000, $identity)

  $file = "/tmp/$identity-ha-reload.sh"

  file { '/etc/haproxy/haproxy.cfg':
    ensure   => present,
    mode     => 644,
    notify   => Exec['hot-reload'],
    content  => template("custom/app/ha.conf.erb")
  }

  file { $file:
    content  => template("custom/app/ha_reload.conf.erb"),
    mode     => 0755
  } ->
  exec { 'hot-reload' :
    require     => File[$file],
    command     => $file,
    path        => "/usr/bin:/usr/sbin",
    refreshonly => true
  }
}

With this, we can now drive everything from either a json file, or from a GET rest endpoint that provides JSON. We’re planning on using consul as a simple key value store with an api to be able to drive the json payload. At that point our swings get the current json configuration, change the default endpoint for the frontned, post it back, and issue a puppet command to the ha proxies via salt nodegroups and we’re all good!

Post a comment

You may use the following HTML:
<a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>