An upstream group names candidate backend endpoints, and its balancing method chooses among eligible peers. Round robin distributes selections by weight; least connections considers active connections and weights; hash methods use a selected key for affinity. These decisions describe routing policy. They do not automatically measure CPU utilization, guarantee equal response times, or make local session state durable when a peer disappears.

When it breaks

A common measurement error is inspecting the wrong address. curl’s remote IP identifies the server that curl connected to, which is usually the proxy in this architecture. An access log’s client-address field is likewise not a backend distribution report. To assess peer selection, inspect actual upstream addresses or a controlled backend identity, and retain attempt details when retries can contact several peers.

Even request counts do not imply even work. A long report and a tiny cached response each count as a request but can occupy very different resources. A configured weight expresses an intended relative share; it is not an automatic measurement of machine capacity. Doubling a weight does not prove that per-core load or latency became equal across different backends.

Affinity has different failure modes. Many users can share one source address, so IP hashing can concentrate traffic rather than distribute individual users. A failed or changed peer set can alter placement, and local session state does not move merely because requests do. ip_hash should not be described as nginx’s configurable consistent hash ring; the explicit hash ... consistent method is separate.

Check it quickly

Save this as check-balancing.py and run it with Python, Docker, and the pinned images. The recorded setup uses one nginx 1.28.0 worker and two identifiable HTTP peers on separate ports inside one disposable backend container. The shared machine is intentional: the test measures selection, not comparative hardware capacity. All client access uses host loopback:

from contextlib import contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory
import http.client,json,socket,subprocess,time,uuid

NGINX='nginx@sha256:552e7481ca93ffccd046aa658dbbed22caefbc09c66fa7cd247cbb90b8a5c609'
PYTHON='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84'
def docker(*args,check=True):
    result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=120)
    if check:assert result.returncode==0,result.stderr
    return result
def wait(predicate):
    deadline=time.monotonic()+15
    while time.monotonic()<deadline:
        try:
            if predicate():return
        except OSError:pass
        time.sleep(.1)
    raise AssertionError('fixture did not become ready')

@contextmanager
def lab(configuration,files=None,backend=None,extra_ports=()):
    name='xbcatm-nginx-'+uuid.uuid4().hex[:10];tags=[name+':proxy',name+':backend'];containers=[name+'-proxy',name+'-backend']
    try:
        with TemporaryDirectory(prefix=name+'-') as directory:
            root=Path(directory);(root/'files').mkdir();(root/'nginx.conf').write_text(configuration)
            for path,content in (files or {}).items():
                target=root/'files'/path;target.parent.mkdir(parents=True,exist_ok=True)
                target.write_bytes(content if isinstance(content,bytes) else content.encode())
            docker('network','create',name)
            if backend is not None:
                (root/'backend.py').write_text(backend)
                (root/'backend.Dockerfile').write_text(f'FROM {PYTHON}\nCOPY backend.py /backend.py\nCMD ["python", "/backend.py"]\n')
                docker('build','--network','none','-f',str(root/'backend.Dockerfile'),'-t',tags[1],str(root))
                docker('run','-d','--name',containers[1],'--network',name,'--network-alias','backend',tags[1])
                wait(lambda:docker('exec',containers[1],'python','-c','import socket;s=socket.create_connection(("127.0.0.1",8081),timeout=.5);s.close()',check=False).returncode==0)
            (root/'Dockerfile').write_text(f'FROM {NGINX}\nCOPY nginx.conf /etc/nginx/nginx.conf\nCOPY files /srv\n')
            docker('build','--network','none','-t',tags[0],str(root))
            docker('run','--rm','--network',name,tags[0],'nginx','-t')
            published=[arg for value in (8080,*extra_ports) for arg in ('-p','127.0.0.1::'+str(value))]
            docker('run','-d','--name',containers[0],'--network',name,*published,tags[0])
            details=json.loads(docker('inspect',containers[0]).stdout)[0]
            binding=details['NetworkSettings']['Ports']['8080/tcp'][0];assert binding['HostIp']=='127.0.0.1';port=int(binding['HostPort'])
            def ready():
                connection=socket.create_connection(('127.0.0.1',port),timeout=.5);connection.close();return True
            wait(ready)
            def request(path,method='GET',headers=None,body=None):
                connection=http.client.HTTPConnection('127.0.0.1',port,timeout=5)
                try:
                    connection.request(method,path,body=body,headers=headers or {})
                    response=connection.getresponse();return response.status,{k.lower():v for k,v in response.getheaders()},response.read()
                finally:connection.close()
            request.port=port;request.network=name
            request.ports={int(key.split('/')[0]):int(value[0]['HostPort']) for key,value in details['NetworkSettings']['Ports'].items() if value}
            yield containers[0],request
    finally:
        docker('rm','-f',*containers,check=False);docker('image','rm',*tags,check=False);docker('network','rm',name,check=False)

def config(locations,extra=''):
    return 'worker_processes 1;\nerror_log /tmp/error.log notice;\nevents {}\nhttp {\naccess_log off;\n'+extra+'\nserver { listen 8080; root /srv;\n'+locations+'\n}\n}\n'
backend=r'''
from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer
from urllib.parse import urlsplit,parse_qs
import itertools,json,threading
lock=threading.Lock();ids=itertools.count(1);counts={'A':0,'B':0};active={'A':0,'B':0};faults={'A':200,'B':200};gates={}
class Handler(BaseHTTPRequestHandler):
    protocol_version='HTTP/1.1'
    def setup(self):super().setup();self.ident=next(ids)
    def log_message(self,*args):pass
    def do_GET(self):
        parsed=urlsplit(self.path);label=self.server.label;code=200
        if label=='control':
            query=parse_qs(parsed.query)
            with lock:
                if parsed.path=='/fail':faults[query['peer'][0]]=int(query['status'][0])
                if parsed.path=='/release':gates[query['path'][0]].set()
                data={'counts':dict(counts),'active':dict(active),'faults':dict(faults),'held':list(gates)}
        else:
            with lock:
                counts[label]+=1;active[label]+=1;code=faults[label]
                if parsed.path.startswith('/hold/'):gates[parsed.path]=threading.Event()
            if parsed.path.startswith('/hold/'):assert gates[parsed.path].wait(20)
            data={'peer':label,'path':self.path,'connection':self.ident}
        body=json.dumps(data,sort_keys=True).encode();self.send_response(code)
        self.send_header('Content-Type','application/json');self.send_header('Content-Length',str(len(body)))
        self.end_headers();self.wfile.write(body)
        if label!='control':
            with lock:active[label]-=1
for port,label in [(8081,'A'),(8082,'B'),(8083,'control')]:
    server=ThreadingHTTPServer(('0.0.0.0',port),Handler);server.label=label
    threading.Thread(target=server.serve_forever,daemon=True).start()
threading.Event().wait()
'''
from collections import Counter
extra='''
upstream round { server backend:8081; server backend:8082; }
upstream weighted { server backend:8081 weight=2; server backend:8082; }
upstream by_ip { ip_hash; server backend:8081; server backend:8082; }
upstream by_key { hash $arg_key consistent; server backend:8081; server backend:8082; }
add_header X-Upstream $upstream_addr always; add_header X-Peer $remote_addr always;
'''
locations='\n'.join(f'location /{path}/ {{ proxy_pass http://{group}/; }}' for path,group in
    [('round','round'),('weighted','weighted'),('sticky','by_ip'),('key','by_key')])
with lab(config(locations,extra),backend=backend) as (_,request):
    def get(path,headers=None):
        status,h,b=request(path,headers=headers);assert status==200;data=json.loads(b)
        assert h['x-upstream'].endswith(':8081' if data['peer']=='A' else ':8082')
        return data['peer'],h
    results={}
    for mode,expected in [('round',{'A':6,'B':6}),('weighted',{'A':8,'B':4})]:
        chosen=[get('/'+mode+'/item')[0] for _ in range(12)]
        assert dict(Counter(chosen))==expected,chosen;results[mode]={'sequence':chosen,'counts':dict(Counter(chosen))}
    sticky=[get('/sticky/item',{'X-Forwarded-For':'192.0.2.'+str(i)}) for i in range(1,9)]
    assert len({r[0] for r in sticky})==len({r[1]['x-peer'] for r in sticky})==1
    results['ip_hash']={'peers':[r[0] for r in sticky],'socket_peer':sticky[0][1]['x-peer']}
    groups={}
    for key in ['alpha','beta','gamma','delta','epsilon','zeta']:
        pair=[get('/key/item?key='+key)[0] for _ in range(2)];assert pair[0]==pair[1];groups[key]=pair[0]
    assert set(groups.values())=={'A','B'},groups;results['consistent_key_hash']=groups
    print(json.dumps(results,sort_keys=True))

Twelve sequential requests through the equally weighted round-robin group produce six responses from A and six from B. In this controlled run, they alternate. Each response’s backend marker is checked against the port in nginx’s upstream-address header, so the distribution is observed at the selected peer rather than inferred from the client’s connection to nginx.

With A assigned weight two and B weight one, twelve requests produce eight A responses and four B responses. This demonstrates the configured relative share for healthy peers under these serial requests. It does not promise that every arbitrary prefix of a traffic stream, a retried request set, or a fleet with unavailable peers will have exactly that ratio.

The IP-hash group receives eight requests from the same actual socket peer. Their fabricated X-Forwarded-For values vary, but every response still comes from one backend. No real-IP trust configuration converts those untrusted headers into the client address. The observed socket-peer field stays constant, making clear which identity the method is actually using in this setup.

The separate consistent-hash group uses a public query key. Each of six keys is requested twice, and both requests for a key choose the same backend; the chosen keys cover both peers. This checks stable placement for the current group. It does not test membership changes or prove that an arbitrary client-controlled key is an authenticated user identity.

Match the method to the workload and its identity

For similar independent requests, weighted round robin offers a simple relative distribution. For variable connection occupancy, least connections can steer around peers with more active connections, subject to its own metric limitations. For affinity, select a stable, appropriate key and decide what should happen when placement changes. Routing stickiness cannot replace replication, shared state, or an explicit session-loss policy.

nginx IP hashing uses the first three octets of an IPv4 address or the full IPv6 address. That documented granularity matters when estimating distribution; it is not always a full-address hash with one independent outcome per IPv4 host. Proxy address handling and NAT can further change how many distinct identities the balancer actually observes.

Finally, evaluate selection together with eligibility, retries, and per-peer response behavior. Compare upstream attempt addresses and timing with returned content, rather than assuming a slower peer proves the algorithm is wrong. A method change can redistribute work, but it does not repair a broken backend or make an unsafe request safe to retry on another node.

Sources