On 23/01/2024 22:44, Zach Pearson wrote:
Hello,

We are using haproxy in a few different environments/functions and the configurations are getting complicated to manage. We are already using Jinja2 to generate environment specific configs but was wondering if anyone has suggestions on tools or processes we can adopt to help us manage our configs.

Not sure how much you need to handle, but here's how we do it.
Our config is still in the "hand-written is ok" range, but with enough to be really awful via a bunch of "use_backend if" (8 frontends, 70 backends): - put your path/domain+path => backend matches in 2 dedicated files to be used as a HAProxy map
- use map_ matches to reference it and do most of the routing
- then keep frontends entirely about sanitizing inputs
- and service-specific config purely inside the relevant backends, which will be their own file with jinja includes

Ends up looking something like that (for us):

# pathonly.map
    ## things you want to match no matter the domain
    /favicon generic_favicon
    ...

# hostpath.map
    ## things you match with domain+path
    domain1#/           backend1
    domain1#/someroute/ backend2
    ...
    domain2#/           backend3
    ...etc

# haproxy.cfg
    global
    ...

    {{ include "frontend1.j2" }}
    {{ include "frontend2.j2" }}
    ...

    {{ include "backend1.j2" }}
    {{ include "backend2.j2" }}

# frontend_common.j2
    acl backend_match var(txn.selected_backend) -m found

    ... initial sanitizing etc ...

# (if sanitizing step didn't select a backend already, eg IP ban handling)
    http-request set-var(txn.route_path) path,lower if !backend_match
http-request set-var(txn.route_dompath) hdr(host),concat(\#,txn.route_path) if !backend_match

    # try path-only match first
http-request set-var(txn.selected_backend) var(txn.route_path),map_beg(/path/to/pathonly.map) if !backend_match

    # otherwise try domain+path
http-request set-var(txn.selected_backend) var(txn.route_dompath),map_beg(/path/do/dompath.map) if !backend_match

    use_backend %[var(txn.selected_backend,generic_404)]

# frontend1.j2
    frontend frontend1
      bind ...
      {{ include "frontend_common.j2" }}

One good result is that I was quite surprised by how many frontends+backends we actually have. One bad result is that includes+macros make finding the source of a bad config a real pain sometimes.

Also it's not super friendly to people discovering HAProxy, nor is it the most readable, but you already lost the readability fight after the 25th line of "use_backend foo-bar if { hdr(Host) -i foo.domain } { path_beg -i /bar }" anyway.

However, if you're already past the humanly-manageable step (or have too many humans needing to cooperate on proxy configs), I see no good solution other than the service registry model, similar to how ingress controllers are implemented using k8s APIs: - a program calls an API of yours (ICs call k8s, you have to make/find your own)
- which returns sets of frontend, routes, certs
- and their matching backends
- your program then updates HAProxy via the dataplane/socket API to match

Assuming upstream teams can then self-register their service instances on that API, it's mostly all dealt with for you.

As far as I've seen in the open:
- on the registry API side, Consul was essentially designed for this
- on the API<>HAProxy sync side, the haproxytech ingress controller implementation could probably give you ideas (or be partially repurposed for non-k8s use, which I said I'd open a GH issue for ages ago, and then forgot to do...)

As an aside, I imagine that this is roughly how cloud providers implement their LBaaS/APIGateway solutions, as it's similar in spirit to the cloud-init+metadata-server setup they nearly all use for VM management.

Tristan

Reply via email to