两行各含十二次顺序请求,按节点分组计数:原文夹具中,同权得到六个 A、六个 B,权重 2:1 得到八个 A、四个 B。

按 nginx 均衡方法实际做出的决定来选择策略

upstream 组列出候选后端端点,均衡方法从符合条件的节点中选择目标。轮询按权重分配选择,最少连接考虑活动连接与权重,哈希方法则根据指定 key 建立亲和性。这些决定描述路由策略,不会自动测量 CPU 利用率,不保证响应时间相等,也不会在节点消失时让本地会话状态自动获得持久性。选择方法之前,应先明确希望它依据什么信号决策。 坏掉的样子 常见测量错误是看错地址。curl 的 remote IP 标识 curl 实际连接的服务器,在这种架构里通常就是代理。访问日志里的客户端地址字段,同样不是后端分布报告。要评估节点选择,应查看真正的上游地址或受控后端身份;如果重试可能访问多个节点,还要保留各次尝试信息。不能把入口连接信息当成后端选择结果。 请求数均匀不代表工作量均匀。长报表和很小的缓存响应都算一个请求,却可能占用完全不同的资源。配置权重表达预期相对份额,不是自动测得的机器容量。把权重翻倍,不能证明不同后端的每核负载或延迟已经相等。权重是否合理,需要代表性请求成本和节点能力支持,而不是只依据某个硬件参数作推断。 亲和性有另一组失败方式。很多用户可能共享同一源地址,所以 IP 哈希可能集中流量,而不是按个人均匀分布。节点失败或成员变化会改变落点,本地会话状态不会仅因为请求换了机器就一起移动。ip_hash 也不应被描述成 nginx 可配置的一致性哈希环;显式 hash ... consistent 是另一种独立方法,不能把两种策略的性质混用。 统计真正回答的后端。 依据原文与明确限制静态绘制;未为本图重跑原文夹具。 快速验证 下文集成实验的结果保留自历史运行记录;本次修订没有重新执行该实验,不能将这些记录当成本次环境的新实测。 将脚本保存为 check-balancing.py,准备 Python、Docker 和固定镜像后执行。记录环境使用单个 nginx 1.28.0 worker,以及一个可丢弃后端容器中不同端口上的两个可识别 HTTP 节点。共享机器是有意安排:实验测量选择结果,不比较硬件容量。客户端只通过宿主回环访问,结束后清理自己的资源: ...

五月 18, 2027 · 3 分钟 · XB Field Notes
两行上传均在 65536 字节中发送 1024 字节后暂停。开启请求缓冲时尚未进入后端处理器;关闭时提前转发,处理器读到一字节。

请求缓冲决定后端何时收到上传正文

一次上传经过两条连接:客户端到 nginx,以及 nginx 到应用。proxy_request_buffering on 会先读完客户端请求正文,再把该请求发送给上游。关闭它后,nginx 可以随着正文到达逐步转发。这个选择改变应用何时开始占用资源,却不会创造无限容量,也不会使慢发送方消失。应先明确应用是否需要边收边处理,再决定正文在哪一层等待。 坏掉的样子 常见错误是关错指令。proxy_buffering 控制响应,proxy_request_buffering 控制上传请求。仅关闭响应缓冲,流式上传仍可能停在代理处。反过来,如果应用原本应接收完整的小型提交,却在全站关闭请求缓冲,又没有考虑后端并发能力,慢客户端就可能长时间占住应用处理资源。两个方向的策略不能因为名称相似就混为一谈。 缓冲把资源压力转移到 nginx。请求正文超过配置的内存缓冲后,可以使用客户端正文临时存储。因此,大量并发上传会占用代理连接,并可能消耗磁盘空间或磁盘读写能力。增大某个缓冲值不能代替正文大小、上传超时、并发限制和临时文件系统容量规划。缓冲区也不需要一律扩大到能在内存中容纳完整上传。 流式转发还改变失败处理。nginx 一旦已经开始向上游发送请求正文,就不能简单把这个流式请求交给下一台服务器。应用可能已经处理前缀数据,甚至产生副作用。缓冲可以为某些重试路径保留正文,但不能让原本不安全的业务操作突然变得可安全重放。是否允许重试,还取决于实际方法、应用语义以及已经完成的工作。 未完成的上传在哪里等待?。 依据原文与明确限制静态绘制;未为本图重跑原文夹具。 快速验证 下文集成实验的结果保留自历史运行记录;本次修订没有重新执行该实验,不能将这些记录当成本次环境的新实测。 将脚本保存为 check-upload.py,先取得其中固定摘要的镜像,再用宿主 Python 和 Docker 执行。记录环境为 Linux arm64 上的 nginx 1.28.0、Python 3.11.16 容器。脚本建立专用后端网络,只把 nginx 发布到宿主回环地址,最后清理自己的容器、镜像标签和网络;已有服务不参与实验: 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): 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') docker('run','-d','--name',containers[0],'--network',name,'-p','127.0.0.1::8080',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 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' import hashlib backend=r''' from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer import hashlib,json,threading state={};lock=threading.Lock() class Handler(BaseHTTPRequestHandler): protocol_version='HTTP/1.1' def log_message(self,*args):pass def reply(self,data): data=json.dumps(data).encode();self.send_response(200) self.send_header('Content-Length',str(len(data)));self.end_headers();self.wfile.write(data) def do_GET(self): with lock:self.reply(dict(state)) def do_POST(self): length=int(self.headers['Content-Length']) with lock:state[self.path]={'entered':True,'bytes':0} first=self.rfile.read(1) with lock:state[self.path]['bytes']=len(first) body=first+self.rfile.read(length-1) with lock:state[self.path]['bytes']=len(body) self.reply({'length':len(body),'sha256':hashlib.sha256(body).hexdigest()}) ThreadingHTTPServer(('0.0.0.0',8081),Handler).serve_forever() ''' locations=''' location /on { proxy_request_buffering on; proxy_pass http://backend:8081; } location /off { proxy_request_buffering off; proxy_pass http://backend:8081; } location /state { proxy_pass http://backend:8081; } ''' with lab(config(locations,'client_max_body_size 1m; proxy_http_version 1.1;'),backend=backend) as (_,request): payload=b'x'*65536;results={} def state():return json.loads(request('/state')[2]) for mode in ('on','off'): connection=http.client.HTTPConnection('127.0.0.1',request.port,timeout=10) connection.putrequest('POST','/'+mode);connection.putheader('Content-Length',str(len(payload))) connection.endheaders();connection.send(payload[:1024]) if mode=='off':wait(lambda:state().get('/off',{}).get('bytes')==1) else: until=time.monotonic()+1 while time.monotonic()<until: assert '/on' not in state();time.sleep(.05) before=state().get('/'+mode) connection.send(payload[1024:]);response=connection.getresponse();data=json.loads(response.read());connection.close() assert response.status==200 and data=={'length':65536,'sha256':hashlib.sha256(payload).hexdigest()} results[mode]={'before_completion':before,'result':data} connection=http.client.HTTPConnection('127.0.0.1',request.port,timeout=5) connection.putrequest('POST','/off-too-large');connection.putheader('Content-Length',str(2*1024*1024)) connection.endheaders();response=connection.getresponse();response.read();connection.close() assert response.status==413 and '/off-too-large' not in state() results['oversize_status']=response.status print(json.dumps(results,sort_keys=True)) 客户端声明正文共 65,536 字节,起初只发送 1,024 字节。开启请求缓冲时,在受控暂停期间反复读取后端状态,都看不到上传处理器被进入。客户端已经开始发送,但应用还没有被要求消费不完整正文。直接观察后端状态,比只比较整个请求耗时更能说明等待发生在哪一层,也避免把一次快速响应误当成流式处理证据。 ...

四月 13, 2027 · 2 分钟 · XB Field Notes
两列对齐展示 /replace/ 被 /v1/ 替换,而 item?x=1 保持不变;这是字面代理 URI 的前缀替换。

proxy_pass 既选择上游,也映射请求 URI

nginx proxy_pass 选择上游目标,也影响实际发送的请求 URI。关键区别是指令是否包含 URI 部分,而不只是文本末尾有没有斜杠。在普通前缀 location 中,字面目标 URI 会替换匹配到的规范化前缀;仅含地址的目标,在请求没有被重写时,可以传递原始请求 URI。理解这两个分支,才能准确预测后端看到的路径。 坏掉的样子 应用只处理 /users,代理却转发 /api/users,即使网络连接成功,也可能得到 404。相反,应用期待公共前缀,而代理把它移除,也会出现错误。只比较客户端状态和上游可达性,无法定位这类问题;需要检查后端真正收到的方法、路径、查询和主机名。成功建立连接并不等于协议内容符合应用契约。 斜杠边界属于字面替换的一部分。以 /v1 结尾的目标仍然包含 URI,所以在 /join/item 中替换 /join/ 后会得到 /v1item,不会因为目标没有末尾斜杠就保留原路径。按被替换前缀和剩余后缀推演,比“有没有末尾斜杠”这句通用口诀更准确,也能解释多段内部路径的映射。 变量会引入另一条文档规定的分支。变量提供的代理 URL 包含 URI 时,使用的是给定 URI,而不是普通字面前缀替换规则。正则和命名 location 也没有同样明确的前缀边界。在普通前缀中工作的配置,不能不检查契约就直接复制到这些上下文,否则语法或运行行为都可能发生变化。 替换前缀,接上后缀。 依据原文与明确限制静态绘制;未为本图重跑原文夹具。 快速验证 下文集成实验的结果保留自历史运行记录;本次修订没有重新执行该实验,不能将这些记录当成本次环境的新实测。 ...

四月 2, 2027 · 2 分钟 · XB Field Notes
单 worker 配置 keepalive 2,先保持四条活动上游连接,放行后保留两条空闲连接;空闲保留数不是活动连接上限。

上游 keepalive 缓存空闲连接,不限制活动容量

nginx 上游 keepalive 缓存会保留符合条件的空闲后端连接,供后续请求复用。在本次配置中,keepalive 2 允许每个 worker 为该上游缓存保留两条空闲连接,并不把 worker 的活动后端连接限制为两条。复用可以减少重复建立连接的开销,却不会自动施加应用并发预算,也不能保证每个请求的延迟都下降。空闲保留与活动容量需要分别理解。 坏掉的样子 把空闲缓存大小误当成连接上限,会让后端同时承受的工作量超过预期。忙碌 worker 可以继续新建活动连接,而保留的空闲缓存仍很小。存在多个 worker 时,各自的空闲缓存也需要计入资源。将 nginx 配置与后端真实连接容量比较时,应分别统计活动与空闲资源,不能只拿一个 keepalive 数字作为全部连接预算。 复用要求协议交换本身可以继续使用。在这个 nginx 1.28.0 HTTP 示例中,上游 HTTP/1.1 和清除默认 Connection: close 都是显式配置。仅设置 Host 并不能建立持久连接。后端仍可能主动关闭连接,不完整或分帧错误的消息,也不能仅因为配置了缓存,就被当成干净可复用的交换。连接是否适合归还空闲缓存,需要实际通信满足条件。 客户端持久连接属于另一组连接关系。新的客户端连接可以使用之前缓存的上游连接;长时间存在的客户端也可能触发多条后端连接。假设两者生命周期总是一致,会产生误导性测量。建立连接的节省也取决于后端使用明文 HTTP 还是 TLS;本次 nginx 后面采用明文 HTTP,因此没有把 TLS 握手节省写成已测结果。 四条活动连接,随后保留两条。 依据原文与明确限制静态绘制;未为本图重跑原文夹具。 快速验证 下文集成实验的结果保留自历史运行记录;本次修订没有重新执行该实验,不能将这些记录当成本次环境的新实测。 ...

三月 15, 2027 · 2 分钟 · XB Field Notes
历史 nginx 1.28.0 实验:连接失败记为 502,备用上游尝试成功为 200,客户端最终响应为 200。

访问日志应区分客户端结果与上游尝试

nginx 访问日志在请求处理末尾执行记录。选择合适字段,可以区分客户端请求了什么、最终由哪个 URI 处理、发送了什么响应,以及上游尝试期间发生了什么。这些是不同观察。有用格式会保留区别,而不是假定一个状态、耗时或路径就能独立解释整个请求。字段越准确地对应事件,事故期间越容易沿实际处理链定位问题。 nginx 1.28.0 历史观察,非本次新运行。尝试结果、地址与耗时保持对应;缺失耗时不等于零,响应字节计数也不是完整网络开销统计。采集与保留需要独立证据。 坏掉的样子 最终客户端状态可以隐藏较早上游失败。如果 nginx 成功重试其他服务器,客户端可能得到 200,但尝试列表仍记录第一次连接失败。反过来,本地生成的响应没有上游操作可计时。把每个请求都当成恰好一次后端响应,会丢失解释重试和本地处理所需的信息,也可能把成功率与上游健康情况混在一起。 内部跳转后,原始和当前 URI 字段也不同。$request 保留请求行,$request_uri 保留原 URI 和查询,$uri 则可能反映后来的规范化或重写路径。只记录最终 URI,调查者可能不知道哪个公共地址触发它;只记录原始路径,又可能看不到最终处理器上下文。两类字段需要按各自含义使用。 时间字段不是总能直接相减并得到精确诊断的独立延迟组件。请求时间覆盖到 nginx 日志采用的客户端侧完成点,上游时间字段则描述自己的阶段,还可能包含多次尝试。分辨率、缺失值和边界都重要;毫秒显示看起来为零,不证明没有执行工作。粗粒度测量与真实无耗时不能混为一谈。 快速验证 下文集成实验的结果保留自历史运行记录;本次修订没有重新执行该实验,不能将这些记录当成本次环境的新实测。 使用宿主 Python 和可丢弃的本地 Docker 守护进程,并确认具有构建、运行权限。历史运行报告为 Linux arm64 上的 nginx 1.28.0、后端 Python 3.11.16、Docker 29.3.1。固定镜像须兼容所用平台;构建的 --network none 不会阻止镜像仓库解析。脚本配置真实后端和容器内无人监听的端口,检查 nginx 配置,只发布宿主回环代理端口。清理会被尝试,但没有验证成功,运行者须检查结果。不要指向远程或生产守护进程: ...

三月 1, 2027 · 2 分钟 · XB Field Notes
计入的甲 503 尝试接到乙 200 与最终成功,另有摘除后真实流量路径;不表示存在周期探测。

被动上游健康判断来自失败流量,不来自探测定时器

nginx 被动上游健康判断利用真实请求结果,决定某个节点是否暂时停止接收流量。max_fails 设置失败尝试阈值,fail_timeout 同时规定计数区间和不可用时长。这些配置不会安排周期性的应用探测。主动检查需要被支持的检查能力与额外配置,不能把真实请求结果策略直接称作一个已经运行的探活定时器。 坏掉的样子 不是每个看起来像错误的 HTTP 响应,都算一次失败上游尝试。代理重试配置参与定义这种分类。在本次设置中,只有列入 http_503,HTTP 503 才参与相应被动失败处理。如果认为所有 5xx 都会自动导致健康失败,就可能期待节点已被摘除,而 nginx 仍依据真实规则继续选择它。必须先核对什么结果被配置认定为失败。 另一个错误是把超时窗口结束当成恢复证明。不可用时段结束后,节点可以重新获得真实流量资格,未必先通过独立健康探测。如果后端仍坏着,这次请求仍会失败。least_conn 依据活动连接改变选择,并不提供渐进恢复权重的升温过程,也不能代替受支持的 slow-start 机制。资格恢复、健康证明和逐渐加量需要分别理解。 重试也有边界。响应数据已经发送给客户端后,更换节点不能透明修复中途错误;重放有副作用的操作还可能产生重复。下面只使用 GET,并在成功正文发送之前产生失败,且明确限制尝试次数。它没有为所有应用方法证明安全重试语义,不能把这个成功切换案例直接用于任何写操作。 摘除窗口不是恢复探测。四次仅乙请求依赖两秒窗口;真实流量恢复、主动检查与安全重试分别判断。 快速验证 下文结果归属于历史文章报告,本轮没有重跑;保留的验证文件中未找到原始集成运行日志。运行需备好本地固定镜像并启用 Python 断言;清理仅尽力执行,不能据此保证所有资源已删除。 将脚本保存为 check-passive-health.py,准备 Python、Docker 和固定镜像后运行。记录服务器为单 worker 的 nginx 1.28.0。可丢弃容器里的两个 HTTP 端点报告自身身份,并能受控返回 503。另一个控制监听提供计数,查询它不会通过被均衡组产生业务请求,便于区分观察动作与真实探测流量: 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() ''' extra=''' upstream passive { server backend:8081 max_fails=1 fail_timeout=2s; server backend:8082; } upstream plain { server backend:8081 max_fails=1 fail_timeout=30s; server backend:8082; } upstream single { server backend:8081 max_fails=1 fail_timeout=30s; } add_header X-Upstream $upstream_addr always; add_header X-Attempts $upstream_status always; ''' locations=''' location /passive { proxy_next_upstream error timeout http_503; proxy_next_upstream_tries 2; proxy_pass http://passive; } location /plain { proxy_next_upstream error timeout; proxy_pass http://plain; } location /single { proxy_next_upstream error timeout http_503; proxy_pass http://single; } location /control/ { proxy_pass http://backend:8083/; } ''' active_config=config('location / { proxy_pass http://peers; health_check; }', 'upstream peers { zone peers 64k; server backend:8081; server backend:8082; }') with lab(config(locations,extra),{'active.conf':active_config},backend) as (proxy,request): def control(path): status,_,body=request('/control/'+path);assert status==200;return json.loads(body) def call(path): status,h,b=request(path);return {'status':status,'attempts':h['x-attempts'],'upstream':h['x-upstream'],'peer':json.loads(b)['peer']} control('fail?peer=A&status=503');results={} plain=[call('/plain') for _ in range(6)] assert [r['status'] for r in plain].count(503)==3 and all(',' not in r['attempts'] for r in plain) results['unlisted_http_503']=plain for _ in range(2): first=call('/passive') if first['attempts']=='503, 200':break assert first['status']==200 and first['attempts']=='503, 200' and first['peer']=='B',first results['failed_attempt_then_success']=first after_failure=control('state')['counts']['A'] excluded=[call('/passive') for _ in range(4)] assert all(r['peer']=='B' and r['attempts']=='200' for r in excluded) assert control('state')['counts']['A']==after_failure time.sleep(.4);assert control('state')['counts']['A']==after_failure results['during_exclusion']=excluded;results['background_probe_count']=0 control('fail?peer=A&status=200');deadline=time.monotonic()+6;recovered=None while time.monotonic()<deadline: observed=call('/passive') if observed['peer']=='A':recovered=observed;break time.sleep(.1) assert recovered and recovered['status']==200;results['live_request_after_window']=recovered control('fail?peer=A&status=503');before=control('state')['counts']['A'] single=[call('/single') for _ in range(2)] assert all(r['status']==503 and r['attempts']=='503' for r in single) assert control('state')['counts']['A']==before+2;results['single_peer_still_attempted']=single probe=docker('exec',proxy,'nginx','-t','-c','/srv/active.conf',check=False) assert probe.returncode!=0 and 'unknown directive "health_check"' in probe.stderr results['active_health_check_directive_supported']=False print(json.dumps(results,sort_keys=True)) 首先让 A 返回真实 HTTP 503,B 返回 200。一个对照组只对连接错误和超时重试。六次请求仍各选择 A、B 三次,没有多次尝试的响应。A 的 max_fails=1 不会因为这个未列入的 HTTP 结果把它移除。实验检查状态与所选上游地址,不只查看最终正文,从而避免把应用返回值与节点资格变化混淆。 ...

一月 5, 2027 · 3 分钟 · XB Field Notes