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 还会继续改变均衡器看到多少种身份。因此,评估亲和分布之前,应先检查实际进入算法的地址与其粒度。

最后,要把选择策略与节点资格、重试和各节点响应行为一起评估。比较上游尝试地址、耗时与返回内容,而不是看到某个节点慢就断定算法选错。更换方法可以重新分配工作,却不会修复损坏后端,也不能让不安全请求变得适合在另一台机器重试。观测必须能解释真实调用路径,而不仅是一张总请求数图表。

延伸阅读

来源