Checking if querying directly MaxMind GeoIP database from HAProxy to retrieve ISP/other information for each request is a feasible way (comparing to generated map files with the same data).
Instructions are based on a bullseye container with minimal haproxy configuration and custom lua script to query the db and set variables accordingly
Clone lua-maxminddb to build required library for maxminddb bindings
gitclonehttps://github.com/fabled/lua-maxminddb.git
cdlua-maxminddb
# Edit Makefile to use lua 5.3 (currently in bullseye)
sed-i's/lua5\.2/lua5.3/'Makefile
make&&makeinstall
Download the GeoIP2-ISP.mmdb into custom directory (I used /etc/haproxy/)
-- geoip.lualocalmaxminddb=require("maxminddb")localdbpath="/etc/haproxy/GeoIP2-ISP.mmdb"localdb,err=maxminddb.open(dbpath)ifnotdbthencore.Alert("Error opening MaxMind DB: "..err)returnendlocalfunctionlookup_geoip(txn)-- local ip_address = txn.f:src()-- Use X-Fake-IP header for testing instead of actual srclocalip_address=tostring(txn:get_var("txn.x_fake_ip"))core.Alert(ip_address)-- debug, to removelocalresult,err=db:lookup(ip_address)ifnotresultthencore.Alert("Error looking up IP: "..err)txn.set_var(txn,"txn.isp","N/A")returnendlocalisp=result:get("isp")or"N/A"txn.set_var(txn,"txn.isp",isp)endcore.register_action("lookup_geoip",{"tcp-req","http-req"},lookup_geoip)
Create a minimal haproxy configuration to make this work (/etc/haproxy/haproxy.cfg) :
global
log stdout len 16384 local0
stats timeout 30s
user haproxy
group haproxy
daemon
lua-load /etc/haproxy/geoip.lua
defaults
log global
mode http
option httplog
timeout http-request 10s
timeout queue 1m
timeout connect 10s
timeout client 1m
timeout server 1m
timeout check 10s
frontend http
bind :8088
mode http
# http-response set-header X-Requestctl-Prov %[var(txn.reqctl)]
# Use X-Fake-IP header for testing instead of actual src
http-request set-var(txn.x_fake_ip) req.hdr(x-fake-ip)
http-request lua.lookup_geoip
http-after-response set-header X-Test-ISP %[var(txn.isp)]
default_backend ok
backend ok
http-request return status 200 content-type "text/plain" string "OK!"
Start haproxy in foreground with haproxy -db -V -f /etc/haproxy/minimal.cfg
Issue a simple request to the container: curl -v -H 'x-fake-ip: 8.8.8.8' localhost:8088
This should return something like:
* Trying 127.0.0.1:8088...
* Connected to localhost (127.0.0.1) port 8088 (#0)
> GET / HTTP/1.1
> Host: localhost:8088
> User-Agent: curl/7.88.1
> Accept: */*
> x-fake-ip: 8.8.8.8
>
< HTTP/1.1 200 OK
< content-length: 3
< content-type: text/plain
< x-test-isp: Google
<
* Connection #0 to host localhost left intact
OK!
HAProxy map lookup
The same container built above can be reused, obviously there's no need to install maxminddb bindings for lua and the haproxy configuration is different
Build a map file (according to HAProxy format) containing all maxmind subnets and associated ISP names
This will generate a /etc/haproxy/ip_to_isp file (renamed in ip_to_isp.map for clarity)
A minimal HAProxy configuration that achieve the same result as above could be:
global
log stdout len 16384 local0
stats timeout 30s
user haproxy
group haproxy
daemon
defaults
log global
mode http
option httplog
timeout http-request 10s
timeout queue 1m
timeout connect 10s
timeout client 1m
timeout server 1m
timeout check 10s
frontend http
bind :8088
mode http
# Use X-Fake-IP header for testing instead of actual src
http-request set-var(txn.isp) req.hdr(x-fake-ip),map_ip(/etc/haproxy/ip_to_isp.map)
http-after-response set-header x-test-isp %[var(txn.isp)]
default_backend ok
backend ok
http-request return status 200 content-type "text/plain" string "OK!"
Same curl request above should produce the same result
LUA lookup and cache map
As described also in the haproxy-geoip README file, an in-memory map to store the retrieved data (a cache) could be extremely beneficial for subsequent requests. This should be achieved by a configuration like:
global
log stdout len 16384 local0
stats timeout 30s
user haproxy
group haproxy
daemon
lua-load /etc/haproxy/geoip.lua
defaults
log global
mode http
option httplog
timeout http-request 10s
timeout queue 1m
timeout connect 10s
timeout client 1m
timeout server 1m
timeout check 10s
frontend http
bind :8088
mode http
# http-response set-header X-Requestctl-Prov %[var(txn.reqctl)]
# Use X-Fake-IP header for testing instead of actual src
## lookup from memory
http-request set-var(txn.x_fake_ip) req.hdr(x-fake-ip)
acl isp_in_map var(txn.x_fake_ip),map_ip(/etc/haproxy/haproxy_cache_isp.map) -m found
## Eventually set var
http-request set-var(txn.isp) req.hdr(x-fake-ip),map(/etc/haproxy/haproxy_cache_isp.map) if isp_in_map
## lookup from database if var doesn't exists
http-request lua.lookup_geoip if !{ var(txn.isp) -m found }
## write to map if not found
## as map are in-memory use runtime api to check the content
## See https://www.haproxy.com/documentation/haproxy-runtime-api/reference/show-map/
http-request set-map(/etc/haproxy/haproxy_cache_isp.map) %[req.hdr(x-fake-ip)] %[var(txn.isp)] if !isp_in_map
# http-request lua.lookup_geoip
http-after-response set-header X-Test-ISP %[var(txn.isp)]
default_backend ok
backend ok
http-request return status 200 content-type "text/plain" string "OK!"
Notice that the map must exists (touch /etc/haproxy/haproxy_cache_isp.map) but is entirely stored in memory by HAProxy (per-process). It can be inspected using the show-mapruntime api but cannot read directly from the filesystem and it's reset at every HAProxy process termination.
Benchmarks
In order to get some realistic results is useful to generate first a list of random IPs from the <IP> <ISP NAME> map file.
The following python script iterates over each line and generate one random IP from each network, leaving the ISP name as the second column (used by the benchmark script for verification). Usage is like: ./get_random_ip.py ip_to_isp.map > random_entries.list
#!/usr/bin/env python3importipaddressimportrandomimportsysdefmain():iflen(sys.argv)!=2:print(f"Usage: {sys.argv[0]} <input_file>")print(f" Output will be written to stdout\n")sys.exit(1)input_file=sys.argv[1]# Read all entries from the fileentries=[]try:withopen(input_file,'r')asf:forlineinf:line=line.strip()iflineandnotline.startswith('#'):parts=line.split(None,1)# Split on first whitespaceiflen(parts)==2:cidr,name=partsentries.append((cidr,name))exceptExceptionase:print(f"Error reading input file: {e}")sys.exit(1)forcidr,nameinentries:network=ipaddress.ip_network(cidr)hosts_no=network.num_addressesifnetwork.version==4andhosts_no>2:hosts=list(network.hosts())else:hosts=list(network)random_ip=str(random.choice(hosts))print(f"{random_ip}{name}")if__name__=="__main__":main()
A very simple benchmarks script to test timing under various concurrency.
Usage example: ./benchmark.py random_entries.list --target http://localhost:8088 --concurrency 100 --num-requests 1000 --random
#!/usr/bin/env python3importtimeimportargparseimportasyncio# import aiohttpimportstatisticsimporturllib3importrandomfromitertoolsimportcycle,islicefromtypingimportList,Tupleimportnumpyasnpasyncdefmain(args):ip_entries=[]withopen(args.input_file,'r')asf:l=f.readlines()ifargs.num_requests>len(l):print(f"File has only {len(l)} lines, asked {args.num_requests} requests, cycling over...")ifargs.random:lines=random.choices(l,k=args.num_requests)else:ifargs.num_requests>len(l):lines=list(islice(cycle(l),args.num_requests))else:lines=l[0:args.num_requests]forlineinlines:line=line.strip()ifnotlineorline.startswith('#'):# remove empty lines or commentscontinueparts=line.split(maxsplit=1)# ISP names can contain spacesiflen(parts)==2:# just to be sureip,name=partsip_entries.append((ip,name))# if something goes wrongifnotip_entries:print("No valid entries found in input file")return# ensure concurrency doesn't exceed the number of entries in the fileconcurrency=min(args.concurrency,len(ip_entries))print(f"Processing {len(ip_entries)} requests to {args.target} with concurrency {concurrency}")# just for readabilitybatch_size=concurrency# create a list of requests already divided in batchesbatches=[]foriinrange(0,len(ip_entries),batch_size):batches.append(ip_entries[i:i+batch_size])# To store all requests durationall_request_times=[]# Send requests in batchesfori,batchinenumerate(batches):ifargs.debug:print(f"Batch {i+1} of {len(batches)}")request_times=awaitprocess_batch(batch,args.target,args.debug)all_request_times.extend(request_times)# Calculate statistics over all requests timing# Time is expressed in seconds so need to convert to msmean_request_time_ms=statistics.mean(all_request_times)*1000print(f"\n# {args.test_name}")print("Results:")print(f" Total requests: {len(all_request_times)}, concurrency: {concurrency}")print(f" Mean time: {mean_request_time_ms:.3f} ms")print(f" Percentiles:")print(f" p75\tp95\tp99\tp99.9\tp99.99")print(f" {1000*np.percentile(all_request_times,75):.3f}\t{1000*np.percentile(all_request_times,95):.3f}\t{1000*np.percentile(all_request_times,99):.3f}\t{1000*np.percentile(all_request_times,99.9):.3f}\t{1000*np.percentile(all_request_times,99.99):.3f}")print(f" Min time: {1000*min(all_request_times):.3f} ms")print(f" Max time: {1000*max(all_request_times):.3f} ms")asyncdefprocess_batch(batch:List[Tuple[str,str]],target:str,debug:bool)->List[float]:"""Process a batch of requests concurrently """tasks=[]forip,nameinbatch:tasks.append(make_request(ip,name,target,debug))results=awaitasyncio.gather(*tasks)# Filter only successful requests with request time (rt) > 0request_times=[rtforsuccess,rtinresultsifrt>0]# Check eventually if sent header matches with expected valuefor(ip,name),(header_match,t)inzip(batch,results):status="OK"ifheader_matchelse"KO"ifdebug:print(f"header_match: {status}\tIP: {ip}\tISP: {name}\tElapsed time: {1000*t:.3f} ms")returnrequest_timesasyncdefmake_request(ip:str,name:str,target_host:str,debug:bool)->Tuple[bool,float]:"""Actually performs the request """headers={"X-Fake-IP":ip}start=time.time()# one connection for each request to simulate actual clientstry:resp=urllib3.request("GET",target_host,headers=headers)elapsed=time.time()-startres_header=resp.headers.get("X-Test-ISP")header_match=res_header==nameifres_headerelseFalsereturnheader_match,elapsed## Version with aiohttp, not used anymore as we only need one## connection per request# async with aiohttp.ClientSession() as session:# async with session.get(target_host, headers=headers) as resp:# elapsed = time.time() - start# if debug:# print(f"Elapsed: {1000 * elapsed:.3f}")# # check if the response header matches# res_header = resp.headers.get("X-Test-ISP")# header_match = res_header == name if res_header else False# return header_match, elapsedexceptExceptionase:print(f"Error while performing request for {ip}: {str(e)}")returnFalse,0.0if__name__=='__main__':parser=argparse.ArgumentParser(description="Simple benchmarking tool")parser.add_argument("input_file",help="Input file containing the map in <CIDR> <NAME> format")parser.add_argument("--target",default="http://localhost:8088",help="Target host url for requests (default: http://localhost:8088)")parser.add_argument("--concurrency",type=int,default=1,help="Number of concurrent requests (default: 1, max: 100)")parser.add_argument("--num-requests",type=int,default=100,help="Total number of requests to perform (default: 100)")parser.add_argument("--random",action='store_true',default=False,help="Randomize entries (allows duplicates) (default: False)")parser.add_argument("--debug",action='store_true',default=False,help="Debug mode (default: False)")parser.add_argument("--test-name",type=str,default="test",help="Test name (will only be printed as header for later reference)")args=parser.parse_args()ifargs.concurrency>10000:print("[WARN] Too many concurrent requests, limiting to 10000")args.concurrency=100000asyncio.run(main(args))
Benchmark results
On local container
Caution: All duration are expressed in milliseconds!
100k requests, concurrency = 100
Test name
p50
p75
p95
p99
p99.9
p99.99
max time
no-fetch
0.108
0.108
0.130
0.149
0.208
0.258
1.631
lua-fetch
0.117
0.118
0.147
0.161
0.212
0.275
1.721
lua-fetch-w-cache
0.367
0.546
0.711
0.759
0.940
2.467
4.045
mapfile
0.112
0.115
0.136
0.152
0.203
0.268
2.111
1M requests, concurrency = 1000
Test name
p50
p75
p95
p99
p99.9
p99.99
max time
no-fetch
0.108
0.108
0.130
0.150
0.202
0.287
1.690
lua-fetch
0.116
0.117
0.147
0.166
0.218
0.311
1.760
lua-fetch-w-cache
N/A
N/A
N/A
N/A
N/A
N/A
N/A
mapfile
0.112
0.112
0.136
0.157
0.212
0.295
2.012
1M requests, concurrency = 10k
Test name
p50
p75
p95
p99
p99.9
p99.99
max time
no-fetch
0.109
0.110
0.132
0.151
0.210
0.636
49.645
lua-fetch
0.117
0.118
0.148
0.168
0.226
0.778
56.741
lua-fetch-w-cache
N/A
N/A
N/A
N/A
N/A
N/A
N/A
mapfile
0.113
0.113
0.137
0.156
0.217
0.632
51.585
lua-fetch-w-cache benchmark with 1M of requests takes too long to terminate and has been excluded from the benchmark table
On depooled production host with custom haproxy configuration (cp7001)
Total requests: 100000, concurrency: 1000
p50 p75 p95 p99 p99.9 p99.99
0.162 0.170 0.186 0.220 0.255 0.399
Min time: 0.152 ms
Max time: 1.177 ms
Using benchmark-curl script targeting local container
(No random header sent, just plain requests with fixed X-Fake-IP set to 8.8.8.8, no header check on response)
The script runs something like curl -s -Z --parallel-max 100 -w '%{time_starttransfer}\n' -H X-Fake-IP: 8.8.8.8 -o /dev/null http://127.0.0.1:8087/[1-10000] and calculates the quantiles over results