Python后端部署排障09:应用明明活着,Nginx为什么返回502或504
如果直接访问应用容器能得到200,经过统一入口却失败,继续检查业务公式通常没有意义。请求在到达业务代码前还要经过监听端口、服务名解析、TCP连接和代理规则。本课让Nginx成为可以观察、可以验证、可以恢复的一环,通过受控故障回答502与504究竟差在哪一层。
E08答案:数据保存了,代理地址也要刷新
下面的答案先保存完整报价,再重建当前服务,等待它自己的ready成功,最后调用共享的reload_edge测试配置、发送reload并等待新旧worker交接。它没有假设重建后仍使用旧IP,也没有把一次连接失败误判成数据丢失。重新读取的对象必须与原对象完全相等,不只检查金额,这能发现状态或版本被意外重置的情况。共享屏障在ops/common.py,答案输出worker_handoff供复核。
lessons/09/answer_previous.py
"""E08: recreate the serving container and verify the same quote from its volume."""importjsonimportsubprocessimportsysimporttimefrompathlibimportPathfromurllib.requestimportProxyHandler,Request,build_opener ROOT=Path(__file__).resolve().parents[2]HTTP=build_opener(ProxyHandler({}))defcommand(*args):returnsubprocess.check_output(args,cwd=ROOT,text=True,timeout=60)defrequest(base,path,body=None):data=NoneifbodyisNoneelsejson.dumps(body).encode()req=Request(base+path,data=data,headers={"Content-Type":"application/json"})withHTTP.open(req,timeout=5)asresponse:returnjson.load(response)defmain():sys.path.insert(0,str(ROOT/"ops"))fromcommonimportreload_edge state=json.loads(command(sys.executable,"ops/labctl.py","status"))base=state["http_url"]quote=request(base,"/quotes",{"sku":"E08","supplier":"lab","quantity":3,"unit_cents":1299,"shipping_cents":500,},)assertquote["total_cents"]==4397service="app_"+state["active"]old_id=state["containers"][service]compose=["docker","compose","--env-file",state["env_file"],"-p",state["run_id"],"-f",state["compose_file"],]command(*compose,"up","-d","--no-deps","--force-recreate",service)new_id=command(*compose,"ps","-q",service).strip()assertnew_idandnew_id!=old_idforattemptinrange(30):probe=subprocess.run(["docker","exec",new_id,"python","-c","import urllib.request;urllib.request.urlopen('http://127.0.0.1:8000/ready',timeout=1)",],capture_output=True,timeout=5,)ifprobe.returncode==0:breaktime.sleep(0.5)else:raiseAssertionError("Recreated application never became ready")# Static upstream names resolve at configuration load; refresh after recreation.handoff=reload_edge(state)forattemptinrange(20):try:fetched=request(base,"/quotes/"+quote["id"])breakexceptOSError:time.sleep(0.2)else:raiseAssertionError("Edge did not recover after reload")assertfetched==quoteprint(json.dumps({"status":"PASS","old_container":old_id,"new_container":new_id,"worker_handoff":handoff,"quote":fetched,},indent=2,))if__name__=="__main__":main()从课程根目录执行python3 lessons/09/answer_previous.py。重建期间存在短暂中断,这是练习观察对象,不代表后面发布流程必须这样做。业务写请求只发一次;恢复阶段重试的是GET,因为只读探测适合做有界等待。重试次数和每次超时都有上限,脚本不会永远卡住。
proxy_pass并不是把客户端原样搬到另一个端口
客户端与edge之间是一条连接,edge与应用之间是另一条连接。代理需要决定上游地址、请求路径、转发头、等待时间和失败响应。upstream quote_backend给目标起了名字,proxy_pass http://quote_backend引用它。当前location匹配根路径,proxy_pass没有追加URI部分,因此业务路径保持为/quotes/...。给proxy_pass随手补一个斜杠,在带前缀的location里可能改变URI替换语义,排查404时应检查实际路径而不只看端口。
应用使用X-Request-ID关联日志。Nginx只接受格式受限的客户端编号,否则生成新的编号,避免把任意长度和特殊字符直接带入日志。Host传递当前主机名,X-Forwarded-Proto记录客户端访问edge使用的协议。X-Forwarded-For直接设置为edge看到的远端地址,不继承任意来路声称的地址链;本实验没有额外可信代理层,不能把未经校验的头当成真实身份。
deploy/nginx.conf
user nginx; worker_processes 1; pid /var/run/nginx.pid; # Request-level error lines can contain the original query string. error_log /dev/stderr crit; events { worker_connections 256; } http { include /etc/nginx/mime.types; default_type application/octet-stream; server_tokens off; map $http_x_request_id $trace_id { default $request_id; "~^[A-Za-z0-9_-]{1,64}$" $http_x_request_id; } log_format bounded escape=json '{"time":"$time_iso8601","request_id":"$trace_id",' '"method":"$request_method","path":"$uri","status":$status,' '"duration":$request_time,"upstream":"$upstream_addr",' '"upstream_status":"$upstream_status","upstream_time":"$upstream_response_time"}'; access_log /dev/stdout bounded; include /etc/nginx/runtime/active-upstream.conf; client_max_body_size 32k; client_body_timeout 5s; send_timeout 5s; keepalive_timeout 10s; proxy_connect_timeout 1s; proxy_read_timeout 2s; proxy_send_timeout 2s; proxy_next_upstream off; server { listen 8080; listen 8443 ssl; server_name quoteops.test; ssl_certificate /etc/nginx/runtime/tls/server.crt; ssl_certificate_key /etc/nginx/runtime/tls/server.key; ssl_protocols TLSv1.2 TLSv1.3; location / { proxy_pass http://quote_backend; proxy_http_version 1.1; proxy_set_header Host $host; proxy_set_header X-Request-ID $trace_id; proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header Connection ""; } } }主配置把上游定义放到独立文件:include /etc/nginx/runtime/active-upstream.conf。当前文件例如只有upstream quote_backend { server app_blue:8000; }。我们将整个runtime目录只读挂入容器,发布脚本在宿主通过同目录临时文件与原子替换更新它。若只挂载单个文件,替换后的宿主路径可能指向新inode,而容器仍看着旧文件,这会造成“文件改了、reload了、流量还没变”的难查现象。
502先查连接,504先查等待发生在哪段
把上游改为当前网络里没有监听的端口,edge连接被拒绝,可以得到502。这个响应由代理生成,并不表示业务代码抛出了500。排查时应检查edge日志的upstream地址、上游容器状态和监听端口,再从edge所在网络做连通性验证。宿主能连某个端口,不足以证明edge命名空间能连同一个写法的地址。
504则在本实验中通过独立慢响应服务复现:上游接受连接,却在Nginx的读取预算内不给出响应。慢服务只作为临时故障容器运行,不往生产应用增加任意sleep、执行命令或破坏数据库的入口。这样诊断能力不会变成业务服务的危险能力。演练结束恢复原上游,并删除本次创建的故障资源。
连接超时、发送超时、读取超时并不是一个总秒表。尤其proxy_read_timeout约束的是相邻读取操作之间的等待,不是简单的整个HTTP请求总耗时。应用持续缓慢输出时,整体时长可能超过这个值。客户端还有自己的总超时,外层如果更早放弃,就可能先看到客户端超时而不是代理504。讨论“超时两秒”时必须补充是哪一层、哪一个动作的两秒。
python3 ops/labctl.py drill nginx-502 python3 ops/labctl.py drill nginx-504 python3 ops/labctl.py check演练报告记录故障响应与恢复响应。命令返回PASS只在两个方向都验证完成后成立:能制造错误是前半程,恢复旧上游并重新读到业务结果才是后半程。若恢复失败,保留错误和现有state,不能打印一个预先写死的成功结论。实际运行结果见reports/remote,每次资源名与端口不同,不影响故障层次。
为什么关闭代理自动换上游重试
客户端发出确认报价后,上游可能已经提交事务,却在返回响应前断开连接。此时edge无法仅凭断开判断写入有没有完成。如果自动把同一个写请求转发到另一个实例,就可能执行第二次业务动作。当前业务有版本保护,重复旧版本会返回409,但这不能替所有业务提供幂等保证,也不能把重复执行当作无害习惯。
配置中的proxy_next_upstream off明确禁止代理擅自转发下一上游。业务读请求需要重试时,应设置明确预算并说明条件;业务写请求是否重试则由接口幂等合同、业务键和结果查询能力决定。把GET与POST统一套进“失败就再试三次”,会把网络故障扩散成数据问题。这个原则在支付、库存和发送通知等场景尤其直接。
reload前后分别要证明什么
nginx -t检查配置能否解析、引用文件能否读取等启动条件,但它不会替你证明业务上游ready。一个语法正确、端口写错的upstream照样可能通过配置测试。发布前需要先从候选容器验证ready,再测试Nginx配置,随后reload,最后从真实入口检查版本与业务读回。四步针对不同风险,没有哪一步可以包办其他步骤。
reload给master发送重载信号,通常建立新worker并让旧worker优雅完成已有连接;不是杀掉所有连接再重启。命令返回成功只证明信号发出,仍要观察worker交接、入口响应和错误日志。共享屏障读取/var/run/nginx.pid,再读该master的/proc/PID/task/PID/children;发送reload后,在十五秒预算内反复检查旧child全部消失、新child非空,master必须保持不变。轮询间隔只是降低查询频率,成功依据是进程集合变化,绝不是固定睡一秒就宣布完成。
本轮Linux实测确实捕获了这个竞态:同一秒访问日志依次出现错误端口502、blue的ready200、blue的报价200,随后又出现错误端口502。原脚本已经打印PASS,下一条独立GET却失败;继续观察才稳定回到blue。这组请求编号与upstream证据说明一次成功采样不足以证明reload交接结束。加入worker屏障后,控制器才允许下一次配置切换继续,并且仍保留入口ready与业务读回。长连接导致旧worker迟迟不退出时,屏障超时明确失败,而不伪装成已经平滑完成。
日志能定位,也必须控制字段
访问日志只记录时间、请求编号、方法、规范化路径、状态和上游耗时,不使用$request或$request_uri,后两者会带查询串。应用关闭Uvicorn默认访问日志,继续输出受控结构化事件。Nginx一般错误日志在连接失败时可能写出完整请求行,因此本实验将error_log阈值设为crit,保留严重运行错误,常规502/504通过访问日志里的upstream字段和受控探测定位。
这个取舍意味着普通连接失败的详细错误行不会出现,不能一边删掉字段一边声称拥有所有调试信息。需要更深诊断时,应在隔离实验里使用不带敏感查询参数的请求短暂提高日志级别,结束恢复配置。日志不记录query也不代表URL路径适合放密码或令牌,客户端协议设计仍要避免这种用法。
一条能复用的代理排查路径
先从客户端保存状态码、请求编号与耗时,确认响应确实来自本次入口。再读取edge的访问日志,检查upstream是否为空、目标地址是否符合当前颜色、上游状态是否出现。若客户端根本连不上edge,问题还在监听或宿主映射阶段;若edge返回502,继续检查它到上游的连接;若返回业务JSON形式的503,则转向应用ready、存储日志和数据库资源。响应正文格式也是线索,但不能单凭页面样式断言来源。
复现504时要同时记录耗时预算与慢服务行为。一个连接拒绝通常会很快结束,而一个接受后不返回数据的请求会等待读取超时;两者在实际耗时上可以形成辅助证据,但时间只能辅助,不是绝对分类器。DNS异常、连接丢包和不同代理配置都可能改变等待过程,最后仍需上游地址、状态和受控故障条件共同支撑结论。
恢复后再做两层检查:ready确认基础依赖准备好,check确认创建、确认、旧版本冲突与读回合同成立。只恢复一个静态200页面不足以证明有状态服务恢复。对既有报价做GET还能避免在每次诊断中增加无意义写入;如果确实需要创建探测数据,应使用明确的实验标识并记录ID。
面试叙述可以按“入口证据、缩小层次、单项修复、恢复验证”讲清楚。例如先观察502与错误upstream端口,再在当前网络确认该端口无人监听,恢复旧上游并配置测试、reload,最后从入口读回同一报价。这个过程比“重启Nginx就好了”更容易让听者判断你的结论是否可重复,也更容易让接班同事在相同故障中复用。
E09:把502从猜测变成双向证据
执行受控错误upstream实验,确认入口确实出现502,然后恢复原上游,确认ready回到200且业务检查仍通过。记录错误发生在连接阶段,而不是业务接口的500。再用自己的话解释:一次写请求出现连接中断时,为什么不能根据502就断言“肯定没写入”,又为什么关闭盲目重试有助于保持边界清楚。
下一课给出完整答案,并在同一个edge前增加TLS。届时TCP可连接、HTTP代理可用仍然不够,证书身份和信任链会成为新的独立检查项。
参考:Nginx proxy模块、上游模块、Nginx控制信号、访问日志变量。
完整边界模块:命令成功与入口恢复分别检查
common把容易散落在答案里的边界集中起来。run把参数作为列表交给子进程,不经过shell拼接用户输入;compose每次都带本次env、project和文件路径;container要求恰好一个目标,避免空字符串误操作。request保留HTTP错误响应供实验断言,禁用环境代理避免回环请求被送去代理。wait_http只在有限时间内探测GET,绝不自动重试写报价。状态用临时文件和os.replace提交,让读者不会看到写到一半的JSON。具体业务判断仍在答案与checks中,不藏进通用工具。
ops/common.py
"""控制器共享边界:精确命令参数、原子状态文件与本次Compose资源定位。"""from__future__importannotationsimportjsonimportosfrompathlibimportPathimportsubprocessimporttempfileimporttimeimporturllib.errorimporturllib.request ROOT=Path(__file__).resolve().parents[1]LAB=ROOT/".lab"STATE=LAB/"state.json"REPORTS=ROOT/"reports"/"remote"defrun(args,*,timeout=60,check=True,env=None,input=None):"""不经过shell解释业务参数;非零返回携带诊断,调用方决定能否恢复。"""result=subprocess.run(list(map(str,args)),cwd=ROOT,text=True,encoding="utf-8",capture_output=True,timeout=timeout,env=env,input=input)ifcheckandresult.returncode:raiseRuntimeError(f"command failed{result.returncode}:{args}\n{result.stdout}\n{result.stderr}")returnresultdefatomic_json(path,data):path.parent.mkdir(parents=True,exist_ok=True)temporary=path.with_suffix(path.suffix+".tmp")temporary.write_text(json.dumps(data,ensure_ascii=False,indent=2),encoding="utf-8")os.replace(temporary,path)defload_state():ifnotSTATE.exists():raiseRuntimeError("No lab state. Run: python3 ops/labctl.py up")returnjson.loads(STATE.read_text(encoding="utf-8"))defsave_state(state):atomic_json(STATE,state)defcompose(state,*args,**kwargs):returnrun(["docker","compose","--env-file",LAB/"compose.env","-p",state["run_id"],"-f",ROOT/"deploy/compose.yml",*args],**kwargs)defreload_edge(state):"""确认worker完成交接;reload命令成功只证明信号发送,不证明配置已生效。"""# 固定字面量shell,只读取容器内master与children;不插入用户或状态文本。snapshot=('master=$(cat /var/run/nginx.pid) && ''printf "%s\\n" "$master" && ''cat "/proc/$master/task/$master/children"')defworkers():result=compose(state,"exec","-T","edge","sh","-c",snapshot,timeout=5)lines=result.stdout.strip().splitlines()ifnotlinesornotlines[0].isdigit():raiseRuntimeError("Invalid Nginx master PID snapshot")children={int(value)forlineinlines[1:]forvalueinline.split()}returnint(lines[0]),children master,previous=workers()ifnotprevious:raiseRuntimeError("Nginx has no workers before reload")compose(state,"exec","-T","edge","nginx","-t",timeout=5)compose(state,"exec","-T","edge","nginx","-s","reload",timeout=5)deadline=time.monotonic()+15current=previouswhiletime.monotonic()<deadline:observed_master,current=workers()ifobserved_master!=master:raiseRuntimeError("Nginx master changed during reload")ifcurrentandprevious.isdisjoint(current):return{"master_pid":master,"old_workers":sorted(previous),"new_workers":sorted(current)}time.sleep(.1)raiseRuntimeError(f"Nginx worker handoff timeout: old={sorted(previous)}, current={sorted(current)}")defcontainer(state,service):value=compose(state,"ps","-aq",service).stdout.strip()ifnotvalueor"\n"invalue:raiseRuntimeError(f"Expected exactly one{service}container, got{value!r}")returnvaluedefrequest(state,path,*,method="GET",body=None,headers=None):data=json.dumps(body).encode()ifbodyisnotNoneelseNonerequest_headers={"Content-Type":"application/json",**(headersor{})}req=urllib.request.Request(state["http_url"]+path,data=data,method=method,headers=request_headers)opener=urllib.request.build_opener(urllib.request.ProxyHandler({}))try:response=opener.open(req,timeout=6)excepturllib.error.HTTPErroraserror:response=errorwithresponse:raw=response.read().decode()try:payload=json.loads(raw)exceptjson.JSONDecodeError:payload=rawreturnresponse.status,dict(response.headers),payloaddefwait_http(state,path="/ready",expected=200,seconds=30):deadline=time.monotonic()+seconds last=Nonewhiletime.monotonic()<deadline:try:last=request(state,path)iflast[0]==expected:returnlastexcept(OSError,urllib.error.URLError)aserror:last=type(error).__name__ time.sleep(.2)raiseRuntimeError(f"HTTP condition timeout:{path}expected{expected}, last{last}")defwrite_report(name,value):atomic_json(REPORTS/f"{name}.json",value)returnvaluedefstatus(state):return{key:state[key]forkeyin("run_id","http_url","https_url","ca_file","active","phase")}|{"compose_project":state["run_id"],"containers":{service:container(state,service)forservicein("app_blue","app_green","edge")},"compose_file":str(ROOT/"deploy/compose.yml"),"env_file":str(LAB/"compose.env")}课程目录:《Linux、网络与后端部署排障:从“接口能跑”走到“服务能维护”》 · 上一篇:第08篇 · 下一篇:第10篇