Skip to content

Build an L7 load balancer

Stand up an HTTP load balancer that spreads traffic across three upstreams, health-checks them, retries failures and logs every request.

Request path

What you will build

ARION 1listener2retries3circuit breaker4least_request client cluster web

Outcome

This guide builds an HTTP load balancer: one listener on port 8080 that spreads every request across three upstream servers, takes unhealthy servers out of rotation automatically, retries failed requests, and writes one JSON access-log line per request to stdout. Use it whenever several copies of the same HTTP service should sit behind a single address.

Prerequisites

  • An arion binary — see install. Access-log output additionally needs a build with the access-log feature.
  • Three HTTP servers to balance. The verify section starts throwaway echo servers, so nothing real is required.
  • curl and, optionally, Docker for the echo servers.

The architecture

  • A listener on 0.0.0.0:8080 runs the HTTP connection manager with a single catch-all route.
  • The route sends everything to one cluster, web, which holds three static endpoints balanced with the LEAST_REQUEST policy.
  • The cluster probes each endpoint's /healthz every five seconds, caps concurrent requests with a circuit breaker, and the virtual host retries failures twice.

The complete config

# arion.yaml, composed from 6 features.
logging:
  log_level: info
access_logging:
  blocking: false
envoy_bootstrap:
  static_resources:
    listeners:
      - name: http_8080
        address:
          socket_address: { address: 0.0.0.0, port_value: 8080 }
        filter_chains:
          - name: http_8080_chain
            filters:
              - name: http_gateway
                typed_config:
                  "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
                  access_log:
                    - name: envoy.access_loggers.stdout
                      typed_config:
                        "@type": type.googleapis.com/envoy.extensions.access_loggers.stream.v3.StdoutAccessLog
                        log_format:
                          text_format: |
                            {"start_time":"%START_TIME%","method":"%REQ(:METHOD)%","path":"%REQ(X-ENVOY-ORIGINAL-PATH?:PATH)%","protocol":"%PROTOCOL%","response_code":"%RESPONSE_CODE%","response_flags":"%RESPONSE_FLAGS%","bytes_received":"%BYTES_RECEIVED%","bytes_sent":"%BYTES_SENT%","duration_ms":"%DURATION%","upstream_cluster":"%UPSTREAM_CLUSTER%","upstream_host":"%UPSTREAM_HOST%","authority":"%REQ(:AUTHORITY)%","user_agent":"%REQ(USER-AGENT)%","request_id":"%REQ(X-REQUEST-ID)%"}
                  http_filters:
                    - name: envoy.filters.http.router
                      typed_config:
                        "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
                  route_config:
                    name: http_8080_routes
                    virtual_hosts:
                      - name: backend
                        domains: ["*"]
                        retry_policy:
                          retry_on: 5xx,connect-failure
                          num_retries: 2
                        routes:
                          - match:
                              prefix: /
                            route:
                              cluster: web
    clusters:
      - name: web
        connect_timeout: 5s
        type: STATIC
        lb_policy: LEAST_REQUEST
        load_assignment:
          cluster_name: web
          endpoints:
            - lb_endpoints:
                - endpoint:
                    address:
                      socket_address: { address: 10.0.1.11, port_value: 8080 }
                - endpoint:
                    address:
                      socket_address: { address: 10.0.1.12, port_value: 8080 }
                - endpoint:
                    address:
                      socket_address: { address: 10.0.1.13, port_value: 8080 }
        health_checks:
          - timeout: 1s
            interval: 5s
            healthy_threshold: 3
            unhealthy_threshold: 2
            http_health_check:
              path: /healthz
        circuit_breakers:
          thresholds:
            - max_requests: 1000

Walkthrough

Arion-native keys

The two top-level keys before envoy_bootstrap are Arion's own: logging.log_level controls the process log, and access_logging.blocking: false keeps per-request log writes off the worker threads. Everything under envoy_bootstrap is standard Envoy configuration — see the configuration model.

Listener and connection manager

The listener http_8080 binds 0.0.0.0:8080 with one filter chain. Its only network filter is the HTTP connection manager, which parses requests and hands them to the http_filters list — here just the router, the terminal filter that forwards to a cluster.

Access log

The connection manager's access_log writes to stdout using a text_format whose template is a JSON object built from command operators such as %RESPONSE_CODE% and %UPSTREAM_HOST%. Each request becomes one machine-parseable line naming the method, path, status, duration and which upstream served it. Operators and sinks are covered in access logs.

Route and retries

route_config declares one virtual host matching every domain (domains: ["*"]) with a single route: prefix / to cluster web. The virtual host also carries a retry_policy — retry_on: 5xx,connect-failure with num_retries: 2 — which applies to every route in the host, so a request that hits a failing upstream is transparently re-sent up to twice. See routing.

The cluster

web is a STATIC cluster: three fixed endpoints, a 5-second connect_timeout, and lb_policy: LEAST_REQUEST, which favors the endpoint with the fewest requests in flight — a good default when response times vary. Policies and endpoint discovery are compared in upstreams and load balancing.

Health checks and the circuit breaker

health_checks sends GET /healthz to every endpoint each 5 seconds with a 1-second timeout. Two consecutive failures (unhealthy_threshold: 2) eject an endpoint from rotation; three passes (healthy_threshold: 3) bring it back. circuit_breakers caps the cluster at 1000 concurrent requests, shedding load instead of queueing without bound. Both are explained in health checks and resilience.

Run and verify

Start three echo servers, each answering with its own name:

docker run -d --rm -p 5678:5678 hashicorp/http-echo -text=one
docker run -d --rm -p 5679:5678 hashicorp/http-echo -text=two
docker run -d --rm -p 5680:5678 hashicorp/http-echo -text=three

Without Docker, python3 -m http.server 5678 (one port per terminal) works too — the responses are directory listings instead of names.

The recipe points at 10.0.1.11–13:8080. For a local run, change the three endpoint addresses to 127.0.0.1 ports 5678, 5679 and 5680, then start Arion:

arion --config arion.yaml

Send thirty requests and count who answered:

for i in $(seq 1 30); do curl -s http://127.0.0.1:8080/; done | sort | uniq -c
  11 one
  10 two
   9 three

Exact counts vary run to run, but the split stays close to even: idle echo servers answer instantly, so least-request sees no reason to favor anyone. Each request also produces one JSON line on Arion's stdout naming the upstream_host that served it.

Now stop one upstream (docker stop its container). Within about ten seconds — two failed checks, five seconds apart — the endpoint leaves rotation and the loop shows only two names. Restart it and it returns after three passing checks.

Notice what the loop does not show: errors. In the window between the upstream dying and its second failed health check, requests that land on the dead endpoint fail to connect — and retry_on: connect-failure re-sends them to a healthy one, so the client sees nothing.

Production notes

  • Health-check cadence is a trade-off: shorter interval and a lower unhealthy_threshold detect failures faster but probe harder and eject flappier. Start conservative and tighten with evidence.
  • LEAST_REQUEST suits mixed workloads; ROUND_ROBIN is predictable for uniform ones. Changing lb_policy is a one-line edit — see upstreams and load balancing.
  • connect_timeout bounds only connection establishment. Set request-level timeouts on routes so slow upstreams cannot hold requests open indefinitely.
  • Retrying 5xx re-sends requests that may have partially executed. Keep it for idempotent traffic, or narrow retry_on to connect-failure.
  • STATIC endpoints are fixed at start. If upstream addresses change, use a DNS-resolved cluster type or serve endpoints from a control plane.

Next

Add authentication and rate limiting in front of the same cluster in the API gateway guide, or split traffic between two versions in canary with weighted clusters. Cluster fields are listed in the cluster reference.

Next

Take the configuration further

Continue

Start typing to search the site.

↑ ↓ to move Enter to open Esc to close