upstream 组列出候选后端端点,均衡方法从符合条件的节点中选择目标。轮询按权重分配选择,最少连接考虑活动连接与权重,哈希方法则根据指定 key 建立亲和性。这些决定描述路由策略,不会自动测量 CPU 利用率,不保证响应时间相等,也不会在节点消失时让本地会话状态自动获得持久性。选择方法之前,应先明确希望它依据什么信号决策。
坏掉的样子
常见测量错误是看错地址。curl 的 remote IP 标识 curl 实际连接的服务器,在这种架构里通常就是代理。访问日志里的客户端地址字段,同样不是后端分布报告。要评估节点选择,应查看真正的上游地址或受控后端身份;如果重试可能访问多个节点,还要保留各次尝试信息。不能把入口连接信息当成后端选择结果。
请求数均匀不代表工作量均匀。长报表和很小的缓存响应都算一个请求,却可能占用完全不同的资源。配置权重表达预期相对份额,不是自动测得的机器容量。把权重翻倍,不能证明不同后端的每核负载或延迟已经相等。权重是否合理,需要代表性请求成本和节点能力支持,而不是只依据某个硬件参数作推断。
亲和性有另一组失败方式。很多用户可能共享同一源地址,所以 IP 哈希可能集中流量,而不是按个人均匀分布。节点失败或成员变化会改变落点,本地会话状态不会仅因为请求换了机器就一起移动。ip_hash 也不应被描述成 nginx 可配置的一致性哈希环;显式 hash ... consistent 是另一种独立方法,不能把两种策略的性质混用。
快速验证
下文集成实验的结果保留自历史运行记录;本次修订没有重新执行该实验,不能将这些记录当成本次环境的新实测。
将脚本保存为 check-balancing.py,准备 Python、Docker 和固定镜像后执行。记录环境使用单个 nginx 1.28.0 worker,以及一个可丢弃后端容器中不同端口上的两个可识别 HTTP 节点。共享机器是有意安排:实验测量选择结果,不比较硬件容量。客户端只通过宿主回环访问,结束后清理自己的资源:
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))
对同权轮询组顺序发送十二次请求,A、B 各返回六次,本次受控运行中两者交替出现。每个响应的后端标记都会与 nginx 上游地址头中的端口对应检查,因此观察的是实际选中节点,而不是从客户端连接 nginx 的地址推测分布。即使所有请求都经过同一入口,也能明确区分最终由哪个后端回答。
A 权重设为二、B 设为一后,十二次请求产生八个 A 响应和四个 B 响应。这验证了健康节点、顺序请求条件下配置的相对份额,不保证任意流量前缀、包含重试的请求集合,或者存在不可用节点的集群都恰好保持相同比例。衡量权重时应写明节点资格与观察窗口,否则单个小样本容易被过度解释。
IP 哈希组收到来自同一真实套接字对端的八个请求。请求中伪造的 X-Forwarded-For 不断变化,但所有响应仍来自同一后端。这里没有通过 real-IP 信任配置把不可信头转换成客户端地址;观察到的套接字对端字段也保持不变,从而明确本次方法实际使用哪个身份,而不是把声明的转发地址当成已验证来源。
另一个一致性哈希组使用公开查询 key。六个 key 各请求两次,同一 key 的两次访问选择同一后端,所选 key 合计覆盖两个节点。这检查的是当前成员集合下的稳定落点,没有测试成员变更,也没有证明任意客户端可控 key 已经是经过认证的用户身份。稳定映射与身份可信是不同要求。
让方法匹配工作负载及其身份
相近且相互独立的请求,可以使用加权轮询表达简单相对分布。连接占用时间差异较大时,最少连接可以绕开活动连接更多的节点,但仍受自身指标局限约束。需要亲和时,应选择稳定且合适的 key,并明确落点变化后如何处理。路由粘性不能替代复制、共享状态或明确的会话丢失策略,尤其不能当成节点故障后的数据恢复机制。
nginx IP 哈希使用 IPv4 地址的前三个字节,或完整 IPv6 地址。这个文档规定的粒度会影响分布估计,并不总是对完整地址哈希、让每个 IPv4 主机都有独立结果。代理地址处理和 NAT 还会继续改变均衡器看到多少种身份。因此,评估亲和分布之前,应先检查实际进入算法的地址与其粒度。
最后,要把选择策略与节点资格、重试和各节点响应行为一起评估。比较上游尝试地址、耗时与返回内容,而不是看到某个节点慢就断定算法选错。更换方法可以重新分配工作,却不会修复损坏后端,也不能让不安全请求变得适合在另一台机器重试。观测必须能解释真实调用路径,而不仅是一张总请求数图表。
延伸阅读
来源
- nginx upstream module,权重、ip_hash、一致性 key 哈希与节点选择,已核验 2026-10-15。
- nginx HTTP load balancing guide,轮询、最少连接、亲和性与被动失败处理,已核验 2026-10-15。
- nginx proxy module,上游转发与重试条件,已核验 2026-10-15。