← Balanceamento de carga: tráfego e resiliência
09 / 12 · 60 MIN

Concorrência, seleção e limites

Compara algoritmos com pedidos em curso e observa limites de ligações, uso do backup e retoma após libertar capacidade.

Executar uma experiência delimitada

Guarda o código abaixo como run.py. Define DR_NGINX_BIN com o caminho de um executável NGINX e executa python3 run.py --output evidence.json. O ensaio registado usa NGINX 1.30.5 e Python 3.13.1 em macOS. Cria um processo NGINX com um worker e dois backends HTTP fictícios em IPv4 loopback. Desativa explicitamente keepalive upstream e não configura zone partilhada. Os pedidos mantidos esperam por eventos do script, com prazo limitado. Não executa operações financeiras, tráfego externo ou benchmark de throughput. Os processos, sockets e diretório temporário são encerrados no fim.

Comparar seleção com um pedido mantido

No grupo least_conn, o primeiro pedido fica em A. Enquanto continua pendente, seis pedidos curtos vão para B. No grupo round-robin, com pesos iguais e uma retenção semelhante, os seis pedidos dividem-se três por A e três por B. Prevê estes resultados antes de executar. A comparação demonstra o sinal usado para selecionar, sem medir CPU ou custo de negócio. A espera artificial em A não é prova de saturação de processador. Para escolher um algoritmo num portal real, mede latência, erros, ocupação e recursos com um perfil representativo.

Ocupar os dois limites

O grupo cap define max_conns=1 por backend. O script mantém um pedido em cada destino e envia um terceiro. Esse pedido recebe 502 sem novo evento nas aplicações, e o log do proxy indica ausência de upstream elegível. Não há fila configurada. Depois de libertar os pedidos mantidos, ambos concluem com 200 e uma nova prova também passa. A nova resposta não altera o 502 já entregue ao cliente anterior. Num incidente fictício, correlaciona ocupação, tentativas e resultados antes de atribuir ao backend um erro que foi produzido antes de encaminhar.

Observar backup com primário saudável

No grupo reserve, A tem max_conns=1 e B está marcado backup. Um pedido mantido ocupa A; o seguinte recebe 200 de B enquanto o primeiro permanece pendente. Quando o script o liberta, A conclui normalmente. A utilização de backup, neste caso, não prova falha de processo ou de aplicação. O primário estava sem capacidade de ligação elegível. Durante uma janela fictícia de manutenção, usa esta distinção no reporting: saúde, elegibilidade e ocupação precisam de sinais próprios. Também confirma a capacidade do backup, porque a mudança de destino não cria recursos adicionais.

Interpretar limites com workers e keepalive

Este ensaio usa apenas um worker, pelo que não prova um limite global numa instalação com vários workers. Sem zone partilhada, max_conns tem âmbito por worker. Ligações idle preservadas por keepalive também não devem ser confundidas com ligações ativas ou operações em execução. A configuração executada elimina essa variável ao desativar keepalive upstream. Antes de aplicar um valor real, identifica o estado partilhado, o número de workers, o padrão de reutilização e o recurso que pretendes proteger. Um limite de um foi escolhido para tornar o caso observável, sem constituir recomendação de dimensionamento.

Preparar a decisão de capacidade

Num projeto fictício, A será retirado durante o batch e B ficará como único destino disponível. A equipa precisa de demonstrar margem e comportamento de recusa sob a procura prevista, com critérios de interrupção e recuperação. Duplicar max_conns pode admitir mais trabalho sem aumentar processamento, agravando filas ou dependências. O gestor técnico deve ligar a mudança a métricas, responsáveis e capacidade remanescente. Usa os casos desta aula para preparar perguntas de go/no-go, depois obtém evidência representativa. Se essa prova não existe, regista a lacuna; os seis pedidos locais não substituem o dimensionamento do serviço.

"""Original DR NGINX concurrency and passive-state fixture. IPv4 loopback only."""
import argparse
import collections
import concurrent.futures
import datetime
import hashlib
import http.client
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
import json
import os
from pathlib import Path
import platform
import signal
import socket
import subprocess
import tempfile
import threading
import time

parser = argparse.ArgumentParser()
parser.add_argument('--output', default='evidence.json')
args = parser.parse_args()
nginx = os.environ.get('DR_NGINX_BIN')
if not nginx or not Path(nginx).is_file():
    parser.error('DR_NGINX_BIN must identify a local NGINX executable')
nginx = str(Path(nginx).resolve())
events, checks, details, commands = [], {}, {}, []
lock = threading.Lock()
held = {key: threading.Event() for key in ['lc', 'rr', 'cap1', 'cap2', 'backup']}
release = {key: threading.Event() for key in held}
recover = threading.Event()
servers, workers = [], []
process = None

def check(name, condition, **facts):
    checks[name] = {'passed': bool(condition), **facts}

def snapshot():
    with lock:
        return list(events)

def handler(name):
    class Backend(BaseHTTPRequestHandler):
        protocol_version = 'HTTP/1.1'
        def log_message(self, *unused):
            pass
        def do_GET(self):
            route = self.path.split('?')[0]
            token = self.path.split('hold=')[-1] if 'hold=' in self.path else None
            with lock:
                events.append({'backend': name, 'path': self.path, 'host': self.headers.get('Host')})
            if token in held:
                held[token].set()
                if not release[token].wait(15):
                    raise RuntimeError('Bounded held-request deadline exceeded')
            status = 200
            if name == 'A':
                if route == '/passive' and not recover.is_set():
                    status = 503
                if route in ['/noaccount', '/single']:
                    status = 503
                if route == '/notfound':
                    status = 404
                if route.startswith('/health') and self.headers.get('Host') == 'app.fund.test':
                    status = 503
            body = name.encode()
            self.send_response(status)
            self.send_header('Content-Length', str(len(body)))
            self.send_header('Connection', 'close')
            self.end_headers()
            self.wfile.write(body)
            self.close_connection = True
    return Backend

for name in ['A', 'B']:
    server = ThreadingHTTPServer(('127.0.0.1', 0), handler(name))
    thread = threading.Thread(target=server.serve_forever)
    thread.start()
    servers.append(server)
    workers.append(thread)
pa, pb = [s.server_port for s in servers]
with socket.socket() as reservation:
    reservation.bind(('127.0.0.1', 0))
    port = reservation.getsockname()[1]

def request(route):
    conn = http.client.HTTPConnection('127.0.0.1', port, timeout=20)
    try:
        conn.request('GET', route, headers={'Connection': 'close'})
        response = conn.getresponse()
        return {'status': response.status, 'body': response.read().decode()}
    finally:
        conn.close()

def run(*options):
    result = subprocess.run([nginx, *options], capture_output=True, text=True, timeout=15)
    commands.append({'args': list(options), 'exitCode': result.returncode,
                     'stdout': result.stdout, 'stderr': result.stderr})
    return result

try:
    with tempfile.TemporaryDirectory(prefix='dr-lb-capacity-') as directory:
        root = Path(directory)
        (root/'logs').mkdir()
        config = f'''daemon off;
master_process on;
worker_processes 1;
pid {root}/nginx.pid;
error_log {root}/error.log notice;
events {{ worker_connections 128; }}
http {{
 log_format evidence escape=json '{{"path":"$request_uri","status":"$status","upstream":"$upstream_addr","upstreamStatus":"$upstream_status","worker":"$pid"}}';
 access_log {root}/access.jsonl evidence;
 proxy_http_version 1.1;
 proxy_set_header Connection close;
 proxy_read_timeout 20s;
 proxy_next_upstream_tries 2;
 upstream lc {{ least_conn; keepalive 0; server 127.0.0.1:{pa}; server 127.0.0.1:{pb}; }}
 upstream rr {{ keepalive 0; server 127.0.0.1:{pa}; server 127.0.0.1:{pb}; }}
 upstream cap {{ keepalive 0; server 127.0.0.1:{pa} max_conns=1; server 127.0.0.1:{pb} max_conns=1; }}
 upstream reserve {{ keepalive 0; server 127.0.0.1:{pa} max_conns=1; server 127.0.0.1:{pb} backup; }}
 upstream passive {{ keepalive 0; server 127.0.0.1:{pa} max_fails=1 fail_timeout=1s; server 127.0.0.1:{pb} backup; }}
 upstream noaccount {{ keepalive 0; server 127.0.0.1:{pa} max_fails=0; server 127.0.0.1:{pb} backup; }}
 upstream single {{ keepalive 0; server 127.0.0.1:{pa} max_fails=1 fail_timeout=60s; }}
 upstream notfound {{ keepalive 0; server 127.0.0.1:{pa} max_fails=1 fail_timeout=60s; server 127.0.0.1:{pb} backup; }}
 upstream health {{ keepalive 0; server 127.0.0.1:{pa}; }}
 server {{ listen 127.0.0.1:{port};
  location /ready {{ proxy_pass http://health; }}
  location /lc {{ proxy_pass http://lc; }}
  location /rr {{ proxy_pass http://rr; }}
  location /cap {{ proxy_pass http://cap; }}
  location /backup {{ proxy_pass http://reserve; }}
  location /passive {{ proxy_pass http://passive; proxy_next_upstream http_503; }}
  location /noaccount {{ proxy_pass http://noaccount; proxy_next_upstream http_503; }}
  location /single {{ proxy_pass http://single; proxy_next_upstream http_503; }}
  location /notfound {{ proxy_pass http://notfound; proxy_next_upstream http_404; }}
  location /health-generic {{ proxy_pass http://health; proxy_set_header Host default.fund.test; }}
  location /health-app {{ proxy_pass http://health; proxy_set_header Host app.fund.test; }}
 }}
}}
'''
        (root/'nginx.conf').write_text(config)
        version = run('-V')
        assert version.returncode == 0
        validation = run('-p', directory+'/', '-c', str(root/'nginx.conf'), '-t')
        assert validation.returncode == 0, validation.stderr
        with (root/'console.log').open('w') as console:
            process = subprocess.Popen([nginx, '-p', directory+'/', '-c', str(root/'nginx.conf')], stdout=console, stderr=console)
            for attempt in range(100):
                assert process.poll() is None
                try:
                    if request('/ready')['status'] == 200:
                        break
                except OSError:
                    pass
                time.sleep(.03)
            else:
                raise RuntimeError('Local NGINX did not become ready')
            with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool:
                for label in ['lc', 'rr']:
                    pending = pool.submit(request, '/'+label+'?hold='+label)
                    assert held[label].wait(3)
                    holder = next(e['backend'] for e in snapshot() if e['path']=='/'+label+'?hold='+label)
                    samples = [request('/'+label)['body'] for _ in range(6)]
                    before_release = not pending.done()
                    release[label].set()
                    completion = pending.result(timeout=3)
                    details[label] = {'heldBackend': holder, 'samples': samples, 'completion': completion}
                    if label == 'lc':
                        check('least-conn-avoids-the-held-backend', holder == 'A' and samples == ['B']*6 and before_release,
                              heldBackend=holder, otherBackendResponses=samples.count('B'), heldStillPending=before_release)
                    else:
                        counts = dict(collections.Counter(samples))
                        check('round-robin-retains-its-selection-pattern-during-held-work', counts == {'A':3,'B':3} and before_release,
                              counts=counts, heldStillPending=before_release)
                pending1 = pool.submit(request, '/cap?hold=cap1'); assert held['cap1'].wait(3)
                pending2 = pool.submit(request, '/cap?hold=cap2'); assert held['cap2'].wait(3)
                before = len(snapshot()); overflow = request('/cap'); after = len(snapshot())
                check('max-conns-exhaustion-is-not-an-automatic-queue', overflow['status']==502 and before==after,
                      status=overflow['status'], newBackendRequests=after-before, activeHeldRequests=2, queueConfigured=False)
                release['cap1'].set(); release['cap2'].set()
                completions = [pending1.result(timeout=3),pending2.result(timeout=3)]
                after_release = request('/cap')
                check('released-capacity-accepts-a-new-request', all(x['status']==200 for x in completions) and after_release['status']==200,
                      completedHeldRequests=sum(x['status']==200 for x in completions), nextStatus=after_release['status'])
                pending = pool.submit(request, '/backup?hold=backup'); assert held['backup'].wait(3)
                reserve = request('/backup'); still_held = not pending.done(); release['backup'].set(); primary = pending.result(timeout=3)
                check('backup-can-serve-when-primary-reaches-connection-limit', reserve=={'status':200,'body':'B'} and primary['body']=='A' and still_held,
                      primaryHeld=still_held, backupBody=reserve['body'], backupStatus=reserve['status'])
            before = len(snapshot()); first=request('/passive'); first_events=snapshot()[before:]
            before = len(snapshot()); second=request('/passive'); second_events=snapshot()[before:]
            check('passive-failure-excludes-primary-for-subsequent-request', first['body']=='B' and second['body']=='B' and [x['backend'] for x in first_events]==['A','B'] and [x['backend'] for x in second_events]==['B'],
                  firstBackends=[x['backend'] for x in first_events], nextBackends=[x['backend'] for x in second_events])
            recover.set(); before = len(snapshot()); start=time.monotonic(); time.sleep(2.2); elapsed=time.monotonic()-start
            check('passive-idle-period-does-not-run-an-active-health-probe', len(snapshot())==before,
                  newBackendRequests=len(snapshot())-before, configuredFailTimeoutSeconds=1, waitedBeyondTimeout=elapsed>2)
            before = len(snapshot()); reentry=request('/passive'); reentry_events=snapshot()[before:]
            check('recovered-primary-is-retried-after-passive-timeout', reentry=={'status':200,'body':'A'} and [x['backend'] for x in reentry_events]==['A'],
                  status=reentry['status'], backend=reentry['body'], attempts=len(reentry_events))
            for route, expected_status, backends in [('noaccount',200,['A','B','A','B']),('single',503,['A','A']),('notfound',200,['A','B','A','B'])]:
                before=len(snapshot()); values=[request('/'+route),request('/'+route)]; observed=[x['backend'] for x in snapshot()[before:]]
                name={'noaccount':'max-fails-zero-disables-exclusion-accounting','single':'single-server-group-is-not-passively-excluded','notfound':'retrying-404-does-not-count-it-as-passive-failure'}[route]
                check(name, observed==backends and all(x['status']==expected_status for x in values),
                      backends=observed, statuses=[x['status'] for x in values])
            generic=request('/health-generic'); representative=request('/health-app')
            check('default-host-health-can-hide-application-failure', generic['status']==200 and representative['status']==503,
                  genericStatus=generic['status'], applicationHostStatus=representative['status'], activeHealthModuleUsed=False)
            process.send_signal(signal.SIGQUIT); process.wait(timeout=10); assert process.returncode==0
        access=[json.loads(line) for line in (root/'access.jsonl').read_text().splitlines()]
        error=(root/'error.log').read_text()
        assert 'no live upstreams' in error
        result={'executedAt':datetime.datetime.now(datetime.timezone.utc).isoformat(),
                'pythonVersion':platform.python_version(),'platform':platform.platform(),
                'nginxVersion':version.stderr.strip(),'binarySha256':hashlib.sha256(Path(nginx).read_bytes()).hexdigest(),
                'scriptSha256':hashlib.sha256(Path(__file__).read_bytes()).hexdigest(),
                'checks':checks,'details':details,'commands':commands,'configuration':config,
                'backendEvents':snapshot(),'accessLog':access,'errorLog':error,
                'scope':'Actual NGINX with one worker, no shared upstream zone, upstream keepalive explicitly disabled, and two synthetic HTTP backends on IPv4 loopback. Bounded concurrency observations, not a throughput benchmark. No TLS, cloud load balancer, Kubernetes, HAProxy, active health module, multi-worker limit validation, real business writes, production integration or human workshop.'}
finally:
    for flag in release.values():flag.set()
    if process and process.poll() is None:
        process.send_signal(signal.SIGQUIT)
        try:process.wait(timeout=5)
        except subprocess.TimeoutExpired:process.kill();process.wait()
    for server in servers:server.shutdown();server.server_close()
    for thread in workers:thread.join(timeout=3)
result['childExited']=process.returncode==0
result['backendThreadsStopped']=all(not t.is_alive() for t in workers)
result['temporaryDirectoryRemoved']=not root.exists()
result['passed']=sum(v['passed'] for v in checks.values())
result['failed']=len(checks)-result['passed']
Path(args.output).write_text(json.dumps(result,indent=2)+'\n')
print(json.dumps({'passed':result['passed'],'failed':result['failed'],'failedChecks':[k for k,v in checks.items() if not v['passed']],'childExited':result['childExited'],'backendThreadsStopped':result['backendThreadsStopped']}))
raise SystemExit(bool(result['failed']))
NA PRÁTICA

Exercício: prevê backend e estado para seis pedidos curtos com A mantido, depois ocupa os dois limites e explica o 502 sem inventar uma falha das aplicações.

Armadilhas comuns

Confundir contagem com custo, backup com processo morto, max_conns com fila ou um worker local com limite global de produção.

Tópicos relacionados: Algoritmos, afinidade e estado · Health checks e prontidão · Capacidade e falhas em cascata

Leva esta ideia contigo

Seleção e admissão usam sinais concretos. A capacidade real exige medições representativas e recuperação observada, além de uma configuração válida.

Criar conta

Referência: NGINX upstream selection and limits · DR load balancing 2026-09; selected NGINX, HAProxy 3.2, Kubernetes and AWS ALB behavior