h-app-roxy ¶
Application server inside haproxy –
haproxy is “The Reliable, High Performance TCP/HTTP Load Balancer” according to the slogan on haproxy.org. I absolutely enjoy putting it in front of all the services I deploy. haproxy is absolutely rock-solid. The applications behind it? Not absolutely! Is there a better way to improve the reliability of your services than by putting them directly into haproxy and removing the unreliable backend from the equation?
In my previous post I explored how one can use Lua to intercept requests before they are being passed to a backend. This post will explore so-called “applets” or “services” to build an URL shortener backend into haproxy itself! As in the previous post the timeline is fictitious to allow for an easier read.
All the code snippets in this post are licensed under the terms of the MIT license.
The first applet ¶
An URL shortener obviously needs a form for users to input their URLs. I copied the “Hello World” example from the official documentation and modified it to output a simple HTML page:
core.register_service("shorturl", "http", function(applet)
applet:set_status(200)
applet:add_header("content-type", "text/html")
applet:start_response()
applet:send([[
<html>
<head><title>Short URL service</title></head>
<body>
<form action="." method="post">
<input type="url" name="url" placeholder="URL" />
<button type="submit">Submit</button>
</form>
</body>
</html>]])
end)
global nbthread 1 lua-load /usr/share/haproxy/shorturl.lua frontend fe bind *:8080 http-request use-service lua.shorturl
Processing POST requests ¶
haproxy now was sending out my simple HTML page whenever I accessed the frontend. Next I needed to handle the POST request when a user submitted the form. Instead of using multiple applets and performing routing using ACLs in haproxy.cfg I opted to just pass everything to the Lua script and deciding what to do using Lua. That way I get a self contained application.
Adding a routing function ¶
At first I used a simple if-elseif-else chain to perform the routing based on applet.path and applet.method, but this later evolved into a declarative routing table similar to Ruby on Rails, Laravel, Phoenix, express or whatever framework you prefer. I’m only showing the latter, because all of you know what an “if” is:
local function app(controllers)
return function(applet)
pattern_match = false
for i, v in ipairs(controllers) do
if applet.path:match("^" .. v["pattern"] .. "$") then
pattern_match = true
if applet.method == v["method"] then
return v["controller"](applet, applet.path:match("^" .. v["pattern"] .. "$"))
end
end
end
if pattern_match then
error(applet, 405)
else
error(applet, 404)
end
end
end
app is a function that takes a table of controllers and returns a function that can be passed to register_service. To perform the routing I loop over the controller table, check whether the pattern matches the requested path, then check whether the method matches the request method. If both match I call the controller function and pass the applet, as well as the groups in the matched pattern (the latter is required for the controller that performs the redirect to the target URL).
The error function is a helper function to return a response with a specific HTTP status code:
local function error(applet, number) applet:set_status(number) applet:start_response() applet:send(number) end
The app is then registered like this:
core.register_service("redis", "http", app{
{ pattern="/", method="GET", controller=getRoot },
{ pattern="/", method="POST", controller=postRoot },
{ pattern="/(%w+)", method="GET", controller=getShortUrl }
})
Actually processing the POST request ¶
haproxy does not parse the HTTP request body itself. Thus I needed to parse the application/x-www-form-urlencoded request myself. While researching how to do this easiest in Lua I stumbled upon the Captures section in the Lua manual, which gives a working decode function. Perfect!
Now I just need to read and parse the body to get the required fields. Add in some validation of the Content-Type for good measure:
function postRoot(applet) if applet.headers["content-type"] == nil or #applet.headers["content-type"] > 1 or applet.headers["content-type"][0] ~= "application/x-www-form-urlencoded" then return error(applet, 415) end local body = applet:receive() local variables = decode(body) if variables["url"] == nil then return error(applet, 400) end end
variables["url"] now contains the to-be-shortened URL.
Choosing a database ¶
This one is easy: The haproxy documentation gives an example on how to use the lua-redis library with haproxy. Thus Redis it is. I augmented it with Thierry Fournier’s connection pool implementation. Note a small typo in there: r.release(conn) in renew should read r:release(conn).
To make the usage less verbose I added a helper function that aborts the haproxy request, if a call fails (while still making sure to give the connections back into the pool):
local redis = require("redis-pool").new("172.17.0.2", 6379, 20)
function call_redis(method, ...)
local conn = redis:get(1000)
if conn == nil then
error("Could not get connection")
end
local client = conn.client
if not pcall(client.ping, client) then
redis:renew(conn)
return call_redis(method, ...)
end
local result = table.pack(pcall(client[method], client, ...))
if not table.remove(result, 1) then
redis:renew(conn)
error("Dead connection")
end
redis:release(conn)
return table.unpack(result)
end
Storing the URL ¶
This one is straight forward: Generate a random key and store the URL in Redis. Afterwards show the generated key to the user.
function postRoot(applet)
if applet.headers["content-type"] == nil or #applet.headers["content-type"] > 1 or applet.headers["content-type"][0] ~= "application/x-www-form-urlencoded" then
return error(applet, 415)
end
local body = applet:receive()
local variables = decode(body)
if variables["url"] == nil then
return error(applet, 400)
end
while true do
local key = math.random(1, 255)
if call_redis("setnx", key, variables["url"]) then
applet:set_status(200)
applet:add_header("content-type", "text/html")
applet:start_response()
local response = [[
<html>
<head><title>Short URL service</title></head>
<body>
<a href="%s">%s</a>
</body>
</html>]]
applet:send(response:format(key, key))
return
end
end
end
Performing the redirect ¶
The last part is the redirect: Read the key from the parameter, look it up and perform redirect.
local redirect = function(applet, key)
local value = call_redis("get", key)
if value then
applet:set_status(303)
applet:add_header("content-type", "text/html")
applet:add_header("location", value)
applet:start_response()
local response = [[
<html>
<head>
<meta http-equiv="refresh" content="%s">
</head>
<body>
<a href="%s">%s</a>
</body>
</html>]]
applet:send(response:format(value, value, value))
else
error(applet, 404)
end
end
Cleaning it up ¶
After I finished the script I cleaned it up, by decorating the applet object with helper methods. For example the error function can now be called as applet:send_error.
local function App(applet)
function applet:parse_body()
if self.headers["content-type"] == nil or #self.headers["content-type"] > 1 or self.headers["content-type"][0] ~= "application/x-www-form-urlencoded" then
return nil
end
return decode(self:receive())
end
function applet:send_error(number)
core.Debug("Sending error: " .. number)
self:set_status(number)
self:start_response()
self:send(number)
end
function applet:send_redirect(location, code)
code = code or 303
core.Debug("Sending redirect: " .. code)
applet:set_status(code)
applet:add_header("content-type", "text/html")
applet:add_header("location", location)
applet:start_response()
local response = [[
<html>
<head>
<meta http-equiv="refresh" content="%s">
</head>
<body>
<a href="%s">%s</a>
</body>
</html>]]
self:send(response:format(location, location, location))
end
end
The router now decorates the applet using: App(applet):
local function h_app_roxy(controllers)
return function(applet)
App(applet)
applet:add_header("X-Powered-By", "h-app-roxy")
local pattern_match = false
for i, v in ipairs(controllers) do
if applet.path:match("^" .. v["pattern"] .. "$") then
pattern_match = true
if applet.method == v["method"] then
core.Debug("Found matching controller: " .. v["method"] .. " " .. v["pattern"])
return v["controller"](applet, applet.path:match("^" .. v["pattern"] .. "$"))
end
end
end
if pattern_match then
applet:send_error(405)
else
applet:send_error(404)
end
end
end
Wrapping it up ¶
The result of this journey is a re-usable application framework “h-app-roxy”. It currently comes with an easy to use request router that delegates to controller functions.
Using this framework a simple URL shortener application was built as a proof of concept. This URL shortener application can reply with about 2500 redirects per second on the authors laptop, measured using ab with a concurrency level of 150.
Abusing the Lua API of haproxy like this surfaced one MAJOR bug inside of haproxy: If Redis closed the connection, while haproxy was trying to read from it, haproxy crashed with a segmentation fault. The bug was fixed by the author of this post. The bugfix will be released with haproxy 1.8.9.
I’ve installed a live demo with a few small modifications on my server: I modified the routing to expect the /demo/prefix, I added some CSS and any generated URLs expire after 60 seconds. I don’t want to be liable for links pointing to nefarious content.