Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

Route by names and paths with ACLs and maps

By updated

Improve this page

HAProxy logo

Routing with HAProxy means deciding, for each request, which backend serves it, and above all which request is served by none. This lesson is for someone who already knows how to install HAProxy and read a frontend: it teaches you to write ACLs (test rules on the request), to order them, to consult a map (a key-value table) for domain names, then to reject what has no route. The running thread is a real trap: four rules, each correct, that together send a request to the wrong place, and that no line-by-line reading exposes.

What you will learn

The lesson goes from the isolated rule to the proven routing table.

  • Write an ACL (a named test on the request) with a fetch (which extracts a value), a converter (which transforms it) and a condition.
  • Order use_backend and default_backend knowing that the first winning rule stops everything.
  • Reject an unknown name or a branch of paths without reaching a backend.
  • Route by name with a map, and change it live through the admin socket.
  • Prove each route at the backend that really served the request.

Prerequisites

The lesson assumes HAProxy is installed and that you can read an haproxy.cfg file.

How does HAProxy pick a backend?

HAProxy evaluates the rules of a frontend in a precise order and stops at the first one that designates a backend; a request that no rule picks falls into default_backend, or is served by nobody if there is none. Understanding this order is the core of the lesson, because every routing mistake comes from it.

A frontend is the configuration section that receives the clients' requests; a backend is the group of servers that serves them. In a frontend in http mode, three families of directives follow one another. The http-request rules run first: they can reject (deny), redirect or rewrite a request. Then come the use_backend rules, evaluated in the order you write them; the first whose condition is true picks the backend, and the following ones are no longer read. Last is default_backend, which applies when no use_backend has picked the request. This lesson assumes healthy backends: what happens when one of them goes down (health checks, removal, draining) is the subject of Choose the load-balancing algorithm and measure health checks.

Keep the consequence in mind: the order between two directives of the same type matters, whereas the order between different types is fixed by HAProxy, whatever the position in the file. An http-request deny rule written at the end of a frontend runs all the same before any use_backend.

A note on the source: the manual of branch 3.4 only says "evaluated in their declaration order" inside one family of rules. The order between families (the http-request rules before all the use_backend rules) comes from HAProxy's code, not from a sentence of the manual. Write your rejections before the use_backend lines, then: the file reads in the real order of execution.

What are an ACL, a fetch and a converter?

An ACL (Access Control List) is a named test on the request: a fetch extracts a value, a converter possibly transforms it, and a pattern compares it. You declare it with acl <name> <fetch> <pattern>, then cite it in an if <name> condition.

A fetch ("sample") goes and gets a piece of information: path gives the URL path, req.hdr(host) the Host header, src the client's address, method the HTTP method. A converter transforms this value before the comparison, for example lower to switch to lowercase. You chain it with a comma: req.hdr(host),lower. The pattern is what you compare to.

Conditions combine ACLs. Two ACLs side by side read as an AND (if a b), the word or gives an OR (if a or b), and the exclamation mark a negation (if !a). An ACL can also carry the same name on several lines: the lines add up as an OR, which lets you list several patterns under one name.

frontend public
bind :80
acl hote_blog req.hdr(host),lower -m str blog.pub.lab.test
acl chemin_docs path_beg /docs/
acl methode_lue method GET HEAD
use_backend docs if hote_blog chemin_docs methode_lue

This example is deliberately different from the lab's: three ACLs, one condition that combines them as an AND. Read the last line as a sentence: "send to docs if the host is blog.pub.lab.test, and the path starts with /docs/, and the method is GET or HEAD". The -m str option asks for an exact string comparison; without it, HAProxy applies the fetch's default method.

Which path criterion should you pick: exact, prefix or directory?

A prefix is not a path: path_beg /panier also picks up /panierarchive, which has nothing to do with /panier. The comparison criterion decides what the rule catches, and the choice depends on what you really mean.

FetchWhat it picksTrap
pathThe exact path, no more, no less/api/ is not /api
path_begAny path that starts with the patternPicks up neighbors that share the start of the name
path_endAny path that ends with the patternUseful for an extension, not for a branch
path_dirOne or more complete directories of the path, delimited by slashes, located anywhere/images/png/logo/32x32.png matches /png/logo or images/png, but not png: it is not a prefix, and the pattern can pick up a path from a completely different branch
path_regA regular expressionPowerful, more costly, harder to reread

Take a /docs branch not to be confused with /docs-archive. path_beg /docs catches both. The /docs/ pattern, with the trailing slash, rules out /docs-archive but no longer catches /docs alone. To cover both the resource and its branch, you declare two patterns under the same ACL name, the exact path and the prefix with a slash. To bound a branch from the root, path_beg with a trailing slash fits; path_dir would let through, to your backend, paths that contain the same name deeper down. Always compare the rule with the neighboring paths your service exposes, not only with the path you have in mind.

Two other sources of discrepancy. Paths are case-sensitive: /Docs/ is not /docs/, unless you add the -i option to the ACL. And the Host can carry a port (blog.pub.lab.test:8080), which req.hdr(host) returns as is: a map that only knows the name without a port will not find it. Normalize with lower, and if needed remove the port with the field(1,:) converter.

Why does rule order decide everything?

When two use_backend rules can pick the same request, only the first counts; a broad rule placed first makes all the following ones unreachable, even if they are written, readable and correct. This is the fault the lab makes you find, and it is the hardest to see because no line is wrong.

Decision tree for a shop request, path /panierarchive. Rule 1 consults the map of names: if it names the host, the request goes to the map's backend and the evaluation stops, without rules 2 and 3 being read. Otherwise rule 2 tests the /panier prefix, then rule 3 tests /api/v2/, then the default_backend.yes, stop herenoyes, stop herenoyes, stop herenoRequestboutique, /panierarchiveRule 1does the map name the Host ?Rule 2does the path start with /panier ?Rule 3does the path start with /api/v2/ ?default_backendBackend from the mappanier backendRefusal 404
A rule that always has an answer, placed first, closes the door on all the following ones. Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

The typical case is the map with a default value. A use_backend rule that consults a map and gives it a fallback name always has an answer: it designates a backend for any request whatsoever. Placed before the path exceptions, it deprives them of any chance to run. The remedy is to put the narrowest exceptions first, then the broad rules, and to write a rule that always has an answer only at the end.

The lab therefore requires the conjunction of two measurements: legitimate traffic is served, and the path exception is respected. The first half alone proves nothing, because it is satisfied by configuring nothing at all. A path exception only overrides the default table through the order of the use_backend lines, and it is this order that the second half checks.

A simple method to reread a routing table: sort the rules from the most specific to the most general, then ask, for each rule, "which request gets this far?". If the answer is "none", the rule is dead. This reasoning is also the one the lab's record asks you to write.

How do you reject a request without it touching a backend?

A rejection is done with an http-request deny rule, which runs before any rule that routes, or with a rejection backend that has no server and answers the error itself. A rejection that still reaches a backend is not a rejection: it is a route that answers an error after already having consumed a request on that backend.

The most direct gesture is http-request deny deny_status 404 if <condition>. It is evaluated before the use_backend lines, as seen above, so it does not depend on where you write it relative to them. Status 403 (forbidden) says "I know what you are asking for, it is refused"; 404 (not found) says "this does not exist". The lab accepts either: pick one and stick to it.

You have to tell three rejection situations apart. A branch to reject (/api/v2/ in the lab, which is not deployed: routing it to the API would make it answer as if it existed) is handled by a condition on the path. An unknown name is handled by the map's fallback value, which must designate a rejection backend, or by a rejecting default_backend. And the unexpected path under a known name is not rejected: the table gives each name a default route, and that is what applies. Be careful about what you claim: the lab rejects names with no route and the /api/v2/ branch, not every unlisted path.

What happens without default_backend? A request that no rule picks has no backend: HAProxy answers 503 Service Unavailable, and the manual of use_backend (branch 3.4) says so explicitly for a frontend without default_backend. It is a decision failure more than a deliberate rejection, and the lab's test only accepts 403 or 404. Write an explicit rejection rather than counting on the absence of a rule.

How do you route by name with a map?

A map is a file of "key value" pairs that HAProxy loads at startup and queries with the map converter, which takes the routing table out of the configuration. When you have ten names instead of two, ten lines of ACLs and use_backend become ten lines of data, readable and editable without touching the rules.

The file is plain text, one pair per line, with comments starting with #:

# published name -> default backend for that name
blog.pub.lab.test blog
wiki.pub.lab.test wiki

It is consulted with map(<file>,<fallback>) applied to a value extracted from the request. The rule use_backend %[req.hdr(host),lower,map(/etc/haproxy/maps/hotes.map,refus)] reads the Host header, turns it to lowercase, looks the result up in the file and gets a backend name. If the key is absent, the converter returns the fallback value, here refus, which must be an existing backend. The %[ ... ] notation means the backend name is computed at request time, and this is the trap: if the computed name matches no backend, HAProxy evaluates no other rule and applies default_backend, without any error when the configuration is validated (haproxy -c). A typo in the map or in the fallback therefore passes validation and is discovered in production.

A disabled or unpublished backend behaves differently: the use_backend rule that picks it is ignored, and the evaluation continues with the following rules.

The map carries the default route of each name; the path exceptions stay rules. It is a healthy split: the data that changes often (the names) lives in the file, the rare logic (the exceptions) in the configuration. A name absent from the table has no route, so the line that consults the map must designate a fallback, and this fallback must reject.

A frequent trap: a decorative map. It is loaded, but an ACL placed before it already decides the name. The map exists and decides nothing, and you notice the day you change it with no effect. The lab measures it: it changes an entry live and requires the routing to follow.

How do you change a map live, and what happens to the change?

The admin socket changes the map in the process's memory, immediately and without a reload; but the reload rereads the file, so the live change disappears if it is not also written to the file. Memory and file are two versions of the same table, and only the file survives.

The commands go through socat on the lab's UNIX socket:

Terminal window
echo 'show map' | sudo socat -t2 stdio /run/haproxy/admin.sock
echo 'get map /etc/haproxy/maps/hotes.map blog.pub.lab.test' | sudo socat -t2 stdio /run/haproxy/admin.sock
echo 'set map /etc/haproxy/maps/hotes.map blog.pub.lab.test wiki' | sudo socat -t2 stdio /run/haproxy/admin.sock

show map lists the loaded files, get map reads a key, set map changes the value of an existing key. For a new key, you use add map; careful, it does not overwrite: applied to a key already present, it creates a duplicate instead of changing it, which only set map does. To remove a key, del map. These commands produce no file: I do not show an output, because it depends on your table.

The lab's test plays this scenario in two halves. It changes an entry through the socket and checks that the routing follows, which proves the map really decides. Then it reloads HAProxy and checks that the routing has gone back to what the file says. The second half shows why a live change you want to keep is copied into the file: the reload rereads the file, and the original value comes back. In the lab, the change is the test's: it must not end up in the file, otherwise the routing does not go back after the reload. In operation, it is the opposite: a wanted change, which you made in memory, is copied into the file so that the reload keeps it.

The rule of use is therefore this: use the live change to act fast (cut a name, redirect during an incident), then write the same modification in the file, then reload with systemctl reload haproxy. A change that only exists in memory is a debt that is wiped at the next reload, or the next restart.

How do you prove a route is the right one?

A route is proven at the backend, never in the returned response: the response can look normal while coming from the wrong service. A default_backend panier answers 200 with a plausible body for any name. Only the backend knows whether it served the request.

The lab therefore makes each backend count the requests it served, and exposes this counter on the loopback interface (127.0.0.1). The validation reads the counters before and after each request, and requires a delta of 1 at the expected backend and 0 at all the others. For a rejection, it requires a 403 or 404 status and no counter moving: a rejection that touches a backend is not a rejection. Each request carries a unique token in the query string, which HAProxy does not count in path: it makes the log line recognizable without changing the decision.

Three families of cases are tested. The neighboring paths: /panier, /panier/ajouter, /panierarchive and /panier-public for the prefix rule. The unexpected names, drawn at random at each run: a list written in the test would be handled by name, whereas you want every name absent from the table to be rejected. And the case variants: a Host in uppercase must reach the same route as its lowercase version, which lower guarantees.

You can make the same measurement by hand. Read the counter of each backend as the lab makes it available, send a request with a chosen Host header, read the counters again:

Terminal window
curl -s -o /dev/null -w '%{http_code}\n' -H 'Host: blog.pub.lab.test' 'http://127.0.0.1/docs/?jeton=essai1'

This command gives you the status; the proof is the difference between the counters of your backends before and after. A 200 status alone proves nothing.

What should the decision record contain?

The record explains the order of your decisions and settles the two cases no rule resolves by itself: a prefix that catches a neighbor, and a branch to reject rather than route. It is read by a human, and its reasoning is not graded: the test only checks that it exists, that it has more than one line per case, and that it covers these two subjects.

A good record is a series of short answers to questions your reviewer would ask. In which order are the rules evaluated, and why this order? Which request would make each rule lie if it were moved? Which neighboring path did you test, and what did you measure? Why reject rather than route a branch that is not deployed? A record that lists keywords teaches you to write keywords; a record that tells the decision teaches you to reason.

Practice

The lab gives you an HAProxy in service, four healthy backends, an admin socket and a routing table that is wrong in four ways, each of which is correct on its own. You repair it so that every combination of name and path reaches the backend named in the acceptance criteria, neighboring paths included; you make sure an unexpected name reaches no backend; you show that the map decides and that a live change does not survive the reload; then you write the record. The validation reads the backend counters, not the responses.

Key points

  • The first winning use_backend rule stops the evaluation; the http-request rules run before all the use_backend lines, wherever they sit in the file.
  • An ACL combines a fetch (path, req.hdr(host)), a converter (lower) and a pattern; two ACLs in a condition read as an AND, or gives an OR.
  • A prefix is not a path: path_beg /docs picks up /docs-archive. Always compare with neighboring paths, and keep case in mind (-i).
  • A map with a fallback value always has an answer: placed first, it makes everything after it unreachable.
  • A computed use_backend (%[...]) whose name matches no backend falls back to default_backend, without error at validation: test every name, fallback included.
  • A rejection must touch no backend; an unexpected name is rejected by the map's fallback, an undeployed branch by a condition on the path.
  • A live map change (set map) lives in memory; the reload rereads the file, so the change is copied into the file. add map does not overwrite: it creates a duplicate.
  • A path exception only overrides the table through the order of the use_backend lines; the lab requires legitimate traffic served and the exception respected.
  • A route is proven at the backend by a counter that moves by 1 there and by 0 elsewhere, never by the response returned.
  • A known name keeps its default route for an unexpected path: the rejection covers names and named branches.

Knowledge check

Check that you have grasped the essentials of this guide. The questions cover only what has just been explained here.

Knowledge check

Check what you have learned with this interactive quiz

8 questions
8 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn