观察、决策、行动三个面板,旁边是关于CNAME 与 DNS 解析的标题。

CNAME 改变 DNS 解析路径,请求主机名仍保持原样

CNAME 记录把一个别名与另一个 DNS 名称关联起来。通过别名解析地址时,解析器沿目标继续查找,直到获得请求的数据或遇到失败。这可以让公开主机名保持稳定,同时改变它背后的 DNS 依赖。它不会让浏览器跳转到另一个 URL,也不会替换应用正在请求的主机名;是否改变客户端可见地址,应由相应应用协议行为决定。 坏掉的样子 别名解析完全正常,目标服务器仍可能返回错误网站。HTTP 继续标识原始主机名,HTTPS 证书验证也关联所请求的服务身份。如果只配置目标名称对应的虚拟主机或证书,原别名请求未必能够工作。DNS 提供的是获得地址的路径,选中的服务器还必须接受用户和应用真正请求的名称。仅检查最终 IP 相同,无法验证这项应用契约。 CNAME 不能与普通 A、AAAA、TXT 等数据共存于同一个所有者名称。DNSSEC 元数据存在特定例外,但这不会让 CNAME 与 A 共存变得有效。普通区域顶点本来就需要 SOA 和 NS,因此在顶点放传统 CNAME 会产生冲突。服务商提供的别名展平等功能,应依据其实际文档理解,不能把界面里类似的名称直接当成标准 CNAME 规则发生变化。 别名链可能终止于不存在的数据,也可能形成循环。每个依赖还有自己的缓存寿命与管理边界;缩短别名 TTL,不会缩短以另一个记录集缓存的目标地址。同时,并非每增加一个别名都必然增加一次网络往返:服务器可能在同一回答中附带多段链,解析器也可能已经缓存相关数据。延迟分析应结合实际问答,而不是机械按链长计数。 快速验证 将脚本保存为 check-cname.py,准备 Docker 后执行 python3 check-cname.py。固定环境使用 BIND 9.18.49、dnsmasq 2.90 和小型 HTTP 服务。所有名称均属于隔离测试区域;解析配置修改和候选区域文件留在可丢弃容器内部,不改公共域名或宿主解析器。运行结束后,脚本清理自己创建的实例和临时镜像标签: ...

七月 9, 2027 · 3 分钟 · XB Field Notes
DNS 地址选择文章封面,包含依次相连的 OBSERVE、DECIDE 与 ACT 面板。

多个 DNS 地址不保证流量均匀或端点健康

同一名称下的多条 A 或 AAAA 记录提供一个地址集合。DNS 服务器可能轮换返回顺序,但收到集合后如何使用,由解析器和应用决定。普通地址记录不携带请求负载测量或应用健康结果;仅仅存在多个地址,不能保证均匀流量、自动故障转移,甚至不能保证不同实现看到相同的第一个地址。应将地址发现与后续选择分别理解。 坏掉的样子 DNS 回答分布与应用请求分布的分母不同。一个递归解析器可以用缓存回答许多客户端,一个应用也可能只查询一次,随后发出数千次请求。连接池会进一步强化这种差异。即便权威回答中的首个地址完美交替,实际服务器请求量仍可能严重不均。只统计 DNS 查询数量,不能证明用户业务已经按同样比例分配。 普通静态记录集中的端点失败后,其地址仍会保留,直到 DNS 数据被修改或另一个系统主动修改它。DNS 服务器不会仅因返回地址就知道应用健康状态。有的客户端连接失败后会尝试其他地址,有的会耗尽截止时间或者直接向调用方报错。是否重试、何时重试及哪些操作可重试,都应针对实际客户端和业务验证,不能默认由 DNS 完成。 把这种机制描述成保证公平的抛硬币,也不准确。顺序可能由配置决定、被随机化、被缓存保留,或者被地址选择策略重排。客户端可能选择首项、并发尝试,或复用先前结果。要解释一次分布观察,除了后端请求计数,还需要知道 DNS 配置、解析器行为、连接复用和工作负载;少了这些条件,无法判断不均衡出现在哪一层。 快速验证 将脚本保存为 check-dns-addresses.py,准备 Docker 后执行 python3 check-dns-addresses.py。固定环境使用 BIND 9.18.49,并明确把 rrset-order 设置为循环顺序。两个真实 HTTP 监听在隔离容器中绑定不同回环地址、使用相同临时端口,响应正文标识真正处理请求的端点。脚本不依赖公网域名,也不会访问现有业务服务器: from pathlib import Path from tempfile import TemporaryDirectory import json,subprocess,uuid recipe='FROM python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84\nRUN apt-get update && apt-get install -y --no-install-recommends bind9=1:9.18.49-1~deb12u2 bind9-dnsutils=1:9.18.49-1~deb12u2 dnsmasq-base=2.90-4~deb12u2 python3-dnspython=2.3.0-1 && rm -rf /var/lib/apt/lists/*\nCMD ["/usr/bin/python3"]\n' program=r""" from contextlib import ExitStack,contextmanager from pathlib import Path from tempfile import TemporaryDirectory import json,re,signal,subprocess,time import dns.exception,dns.message,dns.query,dns.rcode,dns.rdatatype def run(*args,check=True): result=subprocess.run(args,capture_output=True,text=True,timeout=15) if check:assert result.returncode==0,(args,result.returncode,result.stdout,result.stderr) return result def query(name,kind='A',port=1053): return dns.query.udp(dns.message.make_query(name,kind),'127.0.0.1',port=port,timeout=1) def values(response,kind='A'): wanted=dns.rdatatype.from_text(kind) return [item.to_text() for rrset in response.answer if rrset.rdtype==wanted for item in rrset] def show(response): def section(records):return [{'name':str(rr.name),'type':dns.rdatatype.to_text(rr.rdtype),'ttl':rr.ttl,'data':[item.to_text() for item in rr]} for rr in records] return {'rcode':dns.rcode.to_text(response.rcode()),'answer':section(response.answer),'authority':section(response.authority)} def wait(predicate,timeout=15): deadline=time.monotonic()+timeout while time.monotonic()<deadline: try: if predicate():return except (OSError,dns.exception.Timeout):pass time.sleep(.05) raise AssertionError('DNS fixture condition did not become true') def stop(process): if process.poll() is None: process.terminate() try:process.wait(timeout=5) except subprocess.TimeoutExpired:process.kill();process.wait(timeout=5) @contextmanager def lab(records,soa_ttl=8,minimum=4,extra=''): with TemporaryDirectory(prefix='xbcatm-dns-') as directory,ExitStack() as stack: root=Path(directory);zone=root/'zone';conf=root/'named.conf';log=root/'named.log';serial=1 def save(body): zone.write_text(f'$ORIGIN lab.test.\n$TTL 30\n@ {soa_ttl} IN SOA ns.lab.test. hostmaster.lab.test. ({serial} 60 60 3600 {minimum})\n@ IN NS ns.lab.test.\nns IN A 127.0.0.1\n'+body+'\n') run('named-checkzone','lab.test',str(zone)) save(records) conf.write_text(f'''options {{ directory "{root}"; listen-on port 1053 {{127.0.0.1;}}; listen-on-v6 {{none;}}; recursion no; dnssec-validation no; notify no; querylog yes; max-cache-size 8m; pid-file "{root}/named.pid"; session-keyfile none; {extra} }}; controls {{}}; zone "lab.test" {{ type primary; file "{zone}"; }}; ''') run('named-checkconf',str(conf)) output=stack.enter_context(log.open('w')) authority=subprocess.Popen(['named','-g','-c',str(conf),'-n','1','-U','1'],stdout=output,stderr=subprocess.STDOUT) stack.callback(stop,authority) def ready(): assert authority.poll() is None,log.read_text() response=query('lab.test.','SOA');return response.rcode()==0 and response.answer[0][0].serial==serial wait(ready) def update(body): nonlocal serial serial+=1;save(body);authority.send_signal(signal.SIGHUP);wait(ready) def cache(port=1054,upstream=1053,size=150,options=()): logfile=root/('cache-'+str(port)+'.log');stream=stack.enter_context(logfile.open('w')) command=['dnsmasq','--no-daemon','--conf-file=/dev/null','--no-resolv','--no-hosts','--bind-interfaces', '--listen-address=127.0.0.1','--port='+str(port),'--server=127.0.0.1#'+str(upstream), '--cache-size='+str(size),'--log-queries','--log-facility=-',*options] process=subprocess.Popen(command,stdout=stream,stderr=subprocess.STDOUT);stack.callback(stop,process) def cache_ready(): assert process.poll() is None,logfile.read_text() return query('lab.test.','SOA',port).rcode()==0 wait(cache_ready);return process,logfile def queries(name,kind='A'): return sum(bool(re.search(r'query: '+re.escape(name.rstrip('.'))+r' IN '+kind+r'\b',line)) for line in log.read_text().splitlines()) yield root,update,cache,queries from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer import http.client,threading class Handler(BaseHTTPRequestHandler): def log_message(self,*args):pass def do_GET(self): body=self.server.server_address[0].encode();self.server.hits+=1 self.send_response(200);self.send_header('Content-Length',str(len(body)));self.end_headers();self.wfile.write(body) def request(address,port): connection=http.client.HTTPConnection(address,port,timeout=.5) try: connection.request('GET','/');response=connection.getresponse();assert response.status==200;return response.read().decode() finally:connection.close() with lab('pool 30 IN A 127.0.0.2\npool 30 IN A 127.0.0.3',extra='rrset-order { order cyclic; };') as (_,update,cache,queries): servers=[];threads=[] try: for address in ['127.0.0.2','127.0.0.3']: server=ThreadingHTTPServer((address,0 if not servers else servers[0].server_port),Handler);server.hits=0 thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start();servers.append(server);threads.append(thread) port=servers[0].server_port;orders=[values(query('pool.lab.test.')) for _ in range(6)] assert all(set(order)=={'127.0.0.2','127.0.0.3'} for order in orders) assert {tuple(order) for order in orders}=={('127.0.0.2','127.0.0.3'),('127.0.0.3','127.0.0.2')} saved=values(query('pool.lab.test.'));selected=saved[0] hits=[request(selected,port) for _ in range(12)];assert hits==[selected]*12 distribution={s.server_address[0]:s.hits for s in servers};assert sorted(distribution.values())==[0,12] victim=next(s for s in servers if s.server_address[0]==selected);victim.shutdown();victim.server_close() still_published=values(query('pool.lab.test.'));assert selected in still_published try:request(selected,port);raise AssertionError('stopped HTTP server accepted a connection') except OSError as error:failure=type(error).__name__ alternate=next(a for a in saved if a!=selected);assert request(alternate,port)==alternate print(json.dumps({'cyclic_answer_orders':orders,'one_lookup_twelve_requests':distribution,'after_peer_stopped':still_published, 'selected_address_error':failure,'explicit_alternate_succeeded':alternate},sort_keys=True)) finally: for server in servers:server.shutdown();server.server_close() for thread in threads:thread.join(timeout=2) """ name='xbcatm-dns-'+uuid.uuid4().hex[:10] def docker(*args,**kwargs):return subprocess.run(['docker',*args],capture_output=True,text=True,timeout=300,**kwargs) try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory);(root/'Dockerfile').write_text(recipe) built=docker('build','-t',name,str(root));assert built.returncode==0,built.stderr result=docker('run','--rm','-i','--name',name,'--network','none','--memory','384m','--pids-limit','128',name, '/usr/bin/python3','-',input=program) assert result.returncode==0,result.stderr print(json.dumps(json.loads(result.stdout),sort_keys=True)) finally: docker('rm','-f',name);docker('image','rm',name) 对 pool.lab.test 进行六次直接查询,实测每次都返回 127.0.0.2 与 127.0.0.3,只是顺序交替。所有回答都包含完整地址集,断言同时检查成员与顺序变化。这证明的是当前权威服务器显式配置后的行为,不代表所有 DNS 服务器都必须轮换,也不保证递归缓存或最终应用会保持这个顺序。完整集合与首项顺序是不同检查点。 ...

七月 6, 2027 · 3 分钟 · XB Field Notes
文章标题旁是标有 OBSERVE、DECIDE、ACT 的三个通用面板,以及箭头和圆形仪表。

NodePort 通过符合配置的节点地址开放 Service

NodePort 类型的 Service 分配一个集群范围的端口,并通过符合配置的节点地址转发流量。它适用于外部负载均衡、开发环境及有明确用途的服务暴露,但不保证每台机器的每个地址都能访问,也不保证每台节点都有一个普通应用监听套接字。路由、防火墙、代理实现与流量策略仍然决定真实访问路径,不能从 Service 类型推断全部网络条件。 一次分配,多种转发路径 默认分配范围为 30000–32767,管理员可以配置其他范围。请求指定 nodePort 时,只有分配与校验允许才会成功;端口冲突会拒绝 API 事务,通常不是先创建一个对象,再悄悄留下不可用端口。应分别核对命令退出状态与实际保存的对象,不能把创建失败解释成运行期网络故障。 Service port、targetPort 与 nodePort 各有职责:前者是虚拟服务入口,中间值指定后端端口,最后一个是节点侧入口。多端口 Service 可以占用多个号码。LoadBalancer 类型也可能分配 NodePort,除非配置与实现允许省略。因此,仅统计类型为 NodePort 的对象,会漏掉地址池的部分实际使用者,第一端口也不代表整份清单。 转发可能由 iptables、IPVS、nftables 或其他实现完成。规则可以引导数据包,而不需要普通用户态程序在该端口接收连接。所以,套接字列表不能证明 NodePort 数据路径健康,也不能凭列表为空宣布服务失效。节点地址筛选同样重要:主地址可达,不代表次要地址或回环地址具有相同行为,具体范围要看代理配置。 流量策略改变候选端点 externalTrafficPolicy 为 Cluster 时,可以选择其他节点上的合适端点。常见 kube-proxy 路径可能执行源地址转换,因此不能假定后端一定看到最初客户端地址。Local 则为外部流量选择本地合适端点,并在这段路径保留源地址。但上游代理或负载均衡可能已经改写过来源,Local 不会自动恢复之前丢失的信息。 没有合适本地端点的节点,无法处理普通 Local 策略流量。外部均衡必须考虑副本分布,不能长期平均发送到全部节点,再期待平台补回缺失的本地副本。终止中端点的处理和代理版本还有额外细节。单副本、三节点只能说明一种放置场景,不能替完整可用性模型,更不能省略真实数据路径测试。 暴露范围取决于实际后端 NodePort 本身提供传输层转发,不提供 HTTP 授权或 TLS 终止。如果它直接选择应用 Pod,确实可能绕过 Ingress 的认证、路由和 WAF;如果选择的就是 Ingress 控制器,那么经过该端口的流量并没有天然绕开控制器。必须先画清真实链路,再判断每项安全控制是否仍处于请求路径中。 ...

五月 11, 2027 · 1 分钟 · XB Field Notes
观察、决策、行动三步示意图,旁边是空闲属于策略而不是故障证明的英文标题。

空闲超时回收边缘连接

空闲超时会在一段时间没有符合定义的活动后关闭连接。它是一项资源保留策略,不是客户端已经消失的证明。健康客户端也可能暂时安静,关键是说清哪种活动重置哪只计时器,以及连接关闭之后,协议允许调用方如何恢复;不能仅凭“连接还在,但没有字节”就判断它应当被立即回收。 空闲属于某个明确的边界 TCP 连接会占用文件描述符、内核缓冲,以及应用或代理记录的连接状态,但通常不需要为每条连接建立独立的路由表项。回收暂时不用的连接,可以限制保留的资源;复用健康连接则能减少重新建连的成本。策略应服务于工作负载,而不是把所有沉默连接都理解成已经被遗弃的泄漏。 Go HTTP Server.IdleTimeout 约束的是持久连接上等待下一次请求的时间。处理器正在工作、但尚未发送任何字节时,并不会仅仅因为超过这个空闲值就被它终止。读取请求头、传输正文、写响应和执行应用逻辑,都需要各自适用的控制项。名字中出现 idle,并不意味着它已经覆盖完整请求的所有阶段。 Nginx 也把不同边界分开:客户端侧的 keepalive_timeout 管理保留的持久连接;proxy_read_timeout 则针对从上游响应读取数据时,两次相邻读取之间的等待,而不是整个响应的总耗时。它们观察代理两侧的不同活动。数值写成一样,并不能让含义也相同,更不能据此断言整条链路已经具有相同的截止点。 零值也不能跨实现直接套用。Nginx 的 keepalive_timeout 设为零,会禁用客户端持久连接;Go 的 IdleTimeout 为零时,则使用 ReadTimeout 的值。如果后者也没有正的限制,才没有建立空闲期限。需要按具体实现核对“零”的契约,不能统一理解成关闭功能,或理解成立即关闭套接字。 坏掉的样子 阈值短于正常静默间隔,会迫使客户端重新建连,或者中断这个计时器实际覆盖的流量;阈值过于宽松,则可能保留超出服务承受范围的套接字。应记录连接数量、关闭原因、复用情况和实际工作模式。安静只能说明被观察的活动没有发生,并不能独立识别坏客户端,也不能告诉你最合适的秒数是多少。 HTTP keep-alive 指的是复用连接,不是定期发送的应用心跳。TCP keepalive 探针又是另一种机制,不会变成服务器读取到的应用请求字节。应用心跳也只有在某个端点确实把它算作活动时,才会影响该端点的计时器。客户端、代理与上游不一定观察到同一批事件,流式传输和多路复用协议尤其需要逐层核对,不能用一个“保活间隔”解释全部行为。 关闭还可能与客户端发起下一次请求发生竞争。连接池中的旧连接需要恢复路径,但重新建连不代表任意请求都可以安全重放。HTTP 明确允许异步关闭连接,是否重试仍取决于请求语义,以及此前副作用是否可能已经发生。“服务器稍微早关一点没有关系”,对修改数据的请求并不是可靠的普遍结论。 快速验证 将代码保存为 idle_probe.go,执行 go run idle_probe.go。程序只使用 Go 标准库,本次在 macOS 的 Go 1.27.0 上验证。测试服务器监听本机回环地址,发送的是普通 HTTP/1.1 请求;没有访问公网边缘节点,也没有启动 Nginx、使用证书或修改任何生产配置。 ...

四月 26, 2027 · 3 分钟 · XB Field Notes
文章标题旁的三个相连面板,依次标注观察、决策与行动。

默认端口是约定,不负责协议识别或安全认证

默认端口让客户端在协议或配置已经给出数字时省略它。HTTP 常用 TCP 八十,HTTPS 常用 TCP 四百四十三,SSH 常用 TCP 二十二。这些分配减少了配置工作,却不会检查监听发送的字节,也不会建立对端身份。一个端点还包含地址和传输协议;客户端最终使用什么应用协议,必须与监听真正提供的能力一致。 坏掉的样子 把服务迁移到其他端口,不会让现有客户端自动发现变化。URL、数据库连接配置、健康探测、代理和访问规则,可能各自保存目的地。只改服务器,可能让探测仍检查旧监听,或让客户端连接一个已经无人使用的端口。需要记录预期映射,并检查所有受影响的真实调用方,不能仅凭新服务启动成功就宣布迁移完成。 熟悉的数字也容易造成错误信任。进程可以在 TCP 四百四十三提供明文 HTTP,也可以在 TCP 八十提供其他协议。选择 HTTPS 意味着实际执行 TLS 和预期证书检查,单独选择四百四十三不会增加加密。同样,TCP 建连成功既不证明下一步对话符合应用协议,也不证明对端获得了服务该身份的授权。 服务名称数据库描述映射,不管理服务生命周期。Linux 的 /etc/services 将名称映射到端口与传输协议,添加记录不会启动守护进程,也不会打开防火墙。该文件可能不同于另一台主机的数据库或当前 IANA 登记内容。把服务名称转换成端口,与把主机名称解析成地址,是连接建立之前两项不同输入,不能混为同一种发现机制。 快速验证 将脚本保存为 check-default-ports.py,准备 Docker 后执行 python3 check-default-ports.py。固定 Linux 镜像中的 Python 3.11.16 不发布宿主端口。实验有意在 TCP 四百四十三启动明文 HTTP,随后让 UDP 套接字绑定同一个数字。所有监听都可丢弃,不会替换宿主已有的生产 HTTPS 服务,也不会要求修改生产端口分配: ...

四月 14, 2027 · 2 分钟 · XB Field Notes
绿色网格上的标题为‘退避需要重试预算’,旁侧是观察、判断、行动三个步骤。

带抖动与退避的重试

重试意味着再增加一份工作,不是自动提高可靠性的操作。它可能帮助恢复暂时失败,也可能重复一个不安全的业务行动,或把额外需求送到已经过载的依赖。选择延迟公式之前,应先确定哪些操作允许重复、总共能投入多少次尝试与多长时间,以及这些额外工作由哪一层负责。 先约束尝试次数,再安排时间 需要区分尝试和重试:总共三次尝试,是第一次调用加最多两次重试。还要确定恢复责任属于哪一层。如果嵌套的三层各自允许三次尝试,一次外层行动就可能对持续失败的最底层发起二十七次调用。抖动只能改变这些调用的时间,不能消除次数相乘的结果。 Google SRE 同时讨论单请求预算与更广范围的重试预算。即使每个请求都有限次尝试,大量失败的客户端仍可能制造相当可观的额外工作。应把重试流量与原始需求分别统计,并核对 SDK、代理和应用层已经存在的政策,避免每一层都独立认为自己只增加了少量调用。 指数退避增长的是上限 封顶指数退避让等待上限逐步增大,直到达到最大值。没有抖动时,同时失败的客户端可能算出相同的下次时间,甚至在到达上限后继续保持同样间隔。等待更久,并不必然打破同步,不能把周期变长当成请求已经分散的证据。 完全抖动会在零与当前上限之间采样。因为取值随机,即使上限正在增长,下一次实际等待仍可能比上一次短。因此,“每次失败后一定比之前多等一会儿”不是这种算法的准确描述;需要区分退避上限的变化与本次真正取到的延迟。 AWS 的文章比较了完全抖动、等量抖动和去相关抖动,并报告特定竞争模拟的结果。这些结果能帮助理解取舍,却不保证某种政策在所有工作负载下都具有最低延迟,也不保证完全没有突发。相同随机种子、定时器粒度和大量客户端,仍可能形成聚集。 整条操作共用一个截止期限 预算必须同时包括执行、等待和后续尝试。每次调用都重新拿到完整超时时长,会悄悄扩大原调用方允许的生命周期。应把剩余预算传给下游,并在准备进行更多工作之前再次检查,而不是只限制两次调用之间睡多久。 进程内计算经过时间,应使用单调时钟。客户端超时,也不证明服务器已经停止或从未提交;取消与远端截止期限需要真实的传输层支持,业务仍然需要重试或幂等契约。不能因为本地函数返回错误,就把未知结果当成未执行。 Retry-After 等服务端等待提示会增加另一项约束。不能因为本地抖动公式允许较小值,就采样出早于该边界的请求时间。如果必须等待的时间已经无法放进剩余期限,应在当前操作中放弃重试,而不是为了凑预算缩短服务端要求。 坏掉的样子 永久性的参数验证错误,不会因为重复发送就变得合法。非幂等操作在结果不明时重放,可能再次产生同一业务效果。应明确选择可重试的失败类别;捕获所有异常后循环重试,既会掩盖编程错误,也可能把一次局部故障推成持续过载。 只有退避上限,没有次数或总耗时限制,仍然允许无限工作;但期限过短,又可能让所有昂贵却合法的操作一直失败。需要一起观察完成工作量、重试次数、总延迟和放弃原因。仅看到错误率下降,可能忽略为了获得成功而越来越高的重试成本。 快速验证 保存为 retry_check.py,用 Python 3.10 以上版本执行 python3 retry_check.py。本轮以 Python 3.12.14 完整运行,只使用标准库。时钟和下游调用都是确定性模型,不真实等待,不启动服务,也不访问依赖。示例政策只允许 GET、HEAD 在超时或 503 后重试,不是所有 HTTP 客户端行为的实现。 from collections import Counter import random class Clock: def __init__(self): self.now = 0.0 def advance(self, seconds): self.now += seconds class ScriptedCall: def __init__(self, clock, steps): self.clock, self.steps, self.timeouts = clock, iter(steps), [] def __call__(self, timeout): self.timeouts.append(timeout) status, duration = next(self.steps) self.clock.advance(min(duration, timeout)) if duration >= timeout: raise TimeoutError("the cooperative fixture used the remaining budget") return status def retry(call, clock, budget, sample, method="GET", max_attempts=3): deadline = clock.now + budget for attempt in range(1, max_attempts + 1): remaining = deadline - clock.now if remaining <= 0: return "deadline", attempt - 1 try: status = call(remaining) except TimeoutError: status = None if clock.now >= deadline: return "deadline", attempt if status == 200: return "success", attempt if status not in (None, 503) or method not in ("GET", "HEAD"): return "not-retryable", attempt if attempt == max_attempts: return "attempt-limit", attempt ceiling = 2.0 ** min(attempt - 1, 3) delay = ceiling * sample() assert 0 <= delay <= ceiling if delay >= deadline - clock.now: return "wait-exceeds-budget", attempt clock.advance(delay) raise AssertionError("positive attempt limit required") clock = Clock() call = ScriptedCall(clock, [(503, 0.25), (503, 0.25), (200, 0.25)]) assert retry(call, clock, 5, lambda: 0.5) == ("success", 3) assert clock.now == 2.25 and call.timeouts == [5, 4.25, 3] print("three attempts finish at 2.25; propagated timeouts shrink from 5 to 4.25 to 3") class EarlyTimeout: def __init__(self, clock): self.clock, self.timeouts = clock, [] def __call__(self, timeout): self.timeouts.append(timeout) self.clock.advance(0.25) if len(self.timeouts) == 1: raise TimeoutError("a transient failure before the deadline") return 200 clock = Clock() call = EarlyTimeout(clock) assert retry(call, clock, 5, lambda: 0.5) == ("success", 2) assert clock.now == 1.0 and call.timeouts == [5, 4.25] print("an early timeout can recover without resetting the shared deadline") for method, status in (("POST", 503), ("GET", 400)): clock = Clock() call = ScriptedCall(clock, [(status, 0.25)]) assert retry(call, clock, 5, lambda: 0.5, method) == ("not-retryable", 1) clock = Clock() call = ScriptedCall(clock, [(503, 0.25)] * 4) assert retry(call, clock, 5, lambda: 0.5) == ("attempt-limit", 3) assert len(call.timeouts) == 3 print("POST, permanent failure, and the total-attempt cap stop further work") clock = Clock() call = ScriptedCall(clock, [(503, 0.25)] * 7) assert retry(call, clock, 100, lambda: 1.0, max_attempts=7) == ("attempt-limit", 7) assert clock.now == 32.75 # Seven 0.25 calls plus waits 1, 2, 4, 8, 8, 8. print("the exponential ceiling stops at 8 rather than growing without bound") clock = Clock() call = ScriptedCall(clock, [(503, 0.1)]) assert retry(call, clock, 0.5, lambda: 1.0) == ("wait-exceeds-budget", 1) assert clock.now == 0.1 and len(call.timeouts) == 1 clock = Clock() call = ScriptedCall(clock, [(503, 99)]) assert retry(call, clock, 0.5, lambda: 0.5) == ("deadline", 1) assert clock.now == 0.5 clock = Clock() call = ScriptedCall(clock, []) assert retry(call, clock, 0, lambda: 0.5) == ("deadline", 0) and call.timeouts == [] print("a wait that cannot fit is abandoned; cooperative execution cannot consume a fresh budget per attempt") rng = random.Random(7) # Repeatable samples for this experiment, not a production seed. delays = [rng.random() for _ in range(100)] bins = Counter(int(delay * 10) for delay in delays) fixed_bins = Counter(int(1.0 * 10) for _ in range(100)) assert all(0 <= delay < 1 for delay in delays) and sum(bins.values()) == 100 assert max(bins.values()) < 100 assert max(fixed_bins.values()) == 100 print("first-retry 0.1-second-bin peak: fixed delay", max(fixed_bins.values()), "; full-jitter sample", max(bins.values())) leaf_calls = 0 class Unavailable(Exception): pass def nested(depth): global leaf_calls if depth == 0: leaf_calls += 1 raise Unavailable for _ in range(3): try: return nested(depth - 1) except Unavailable: pass raise Unavailable try: nested(3) except Unavailable: pass assert leaf_calls == 27 print("three retrying layers, each allowing three total attempts, invoke the failing leaf 27 times") 两次失败后成功,最终模型时间为 2.25。传给各次调用的超时依次缩短为五秒、4.25 秒和三秒,没有每次重新领取一份完整预算。额外用例确认提前超时仍能在同一预算内恢复;指数先封顶再计算,避免总尝试上限很大时先生成过大的幂。其他断言覆盖总尝试次数、八秒退避上限、永久错误、POST 不自动重放、放不进预算的等待,以及开始时就已经耗尽的期限。 ...

四月 10, 2027 · 4 分钟 · XB Field Notes
观察、决策、行动三个面板,旁边是关于绑定地址与本地目的地址的标题。

绑定地址选择本地目的地,可达性还取决于完整路径

服务器的绑定地址决定套接字可以接受哪些本地目的地址。IPv4 监听绑定到 127.0.0.1,与绑定到通配地址 0.0.0.0,具有不同匹配范围。但任何一个设置都不能独立描述完整访问策略。路由、网络命名空间、包过滤、端口发布、代理和应用认证,共同决定请求怎样到达监听,以及连接之后能够执行什么操作。 坏掉的样子 通配监听不等于互联网已经能够访问服务。进程可能位于没有外部接口的隔离命名空间,也可能受到防火墙限制。反过来,回环监听也可以被接受远程请求的反向代理或隧道间接访问。判断谁能使用服务前,需要检查这些转发路径。不能看到监听地址就把网络路径、调用者身份和业务权限合并成一个结论。 IPv6 通配地址 :: 还涉及地址族选择。在 Linux 中,IPV6_V6ONLY 控制 IPv6 套接字是否也接受映射形式的 IPv4 连接。默认值取决于环境与应用,所以监听列表里只有一条 IPv6 项,并不能证明 IPv4 不可用。服务契约要求明确地址族边界时,应显式设置这个选项,并分别建立连接验证,而不是从显示格式推测行为。 指定地址也可能让服务在启动阶段失败。地址与端口被冲突套接字占用,和地址根本没有在本地配置,是不同错误。重用选项和非本地绑定机制会改变部分场景,本例没有启用这些机制。如果把所有绑定失败都解释成端口被占用,就可能去停止无关进程,却没有修复真正缺失的地址配置。错误码应与启动参数一起保留。 快速验证 将脚本保存为 check-bind-address.py,准备 Docker 后运行 python3 check-bind-address.py。固定 Python 3.11.16 镜像在一次性容器内使用 Linux 套接字,没有发布宿主端口。实验先比较两个 IPv4 回环地址,再显式改变 IPV6_V6ONLY。它不连接公网目标,也不修改宿主防火墙;所有监听和客户端都位于同一个可丢弃网络命名空间: import json,subprocess,uuid image="python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84" program=r""" from contextlib import ExitStack import errno,json,socket def read_exact(stream,length): result=b'' while len(result)<length: part=stream.recv(length-len(result));assert part,'unexpected end of stream' result+=part return result def listener(stack,address,family=socket.AF_INET,v6only=None): stream=stack.enter_context(socket.socket(family,socket.SOCK_STREAM)) if v6only is not None: stream.setsockopt(socket.IPPROTO_IPV6,socket.IPV6_V6ONLY,v6only) stream.settimeout(1);stream.bind((address,0));stream.listen(4) return stream def exchange(server,address): with socket.create_connection((address,server.getsockname()[1]),timeout=1) as client: connection,peer=server.accept() with connection: connection.settimeout(1);client.sendall(b'ping');assert read_exact(connection,4)==b'ping' connection.sendall(b'pong');assert read_exact(client,4)==b'pong' return {'listener':server.getsockname(),'accepted_local':connection.getsockname(),'peer':peer} def refused(server,address): try: with socket.create_connection((address,server.getsockname()[1]),timeout=1):pass except ConnectionRefusedError:return 'ECONNREFUSED' raise AssertionError('unexpectedly connected') with ExitStack() as stack: narrow=listener(stack,'127.0.0.1') one=exchange(narrow,'127.0.0.1');other=refused(narrow,'127.0.0.2') duplicate=stack.enter_context(socket.socket()) try:duplicate.bind(narrow.getsockname());raise AssertionError('duplicate bind succeeded') except OSError as error:assert error.errno==errno.EADDRINUSE unassigned=stack.enter_context(socket.socket()) try:unassigned.bind(('192.0.2.99',0));raise AssertionError('unassigned bind succeeded') except OSError as error:assert error.errno==errno.EADDRNOTAVAIL wildcard=listener(stack,'0.0.0.0') both=[exchange(wildcard,a) for a in ['127.0.0.1','127.0.0.2']] narrow.close();wildcard.close() only6=listener(stack,'::',socket.AF_INET6,1) six=exchange(only6,'::1');four_rejected=refused(only6,'127.0.0.1') dual=listener(stack,'::',socket.AF_INET6,0) mapped=exchange(dual,'127.0.0.1');assert mapped['peer'][0].startswith('::ffff:') print(json.dumps({'narrow':one,'other_loopback':other,'duplicate':'EADDRINUSE','unassigned':'EADDRNOTAVAIL', 'wildcard':both,'ipv6_only':six,'ipv6_only_v4':four_rejected,'dual_stack':mapped})) """ name='xbcatm-socket-'+uuid.uuid4().hex[:10] try: result=subprocess.run(['docker','run','--rm','-i','--name',name,'--network','none', '--memory','128m','--pids-limit','64',image,'python3','-'],input=program, capture_output=True,text=True,timeout=60) assert result.returncode==0,result.stderr print(json.dumps(json.loads(result.stdout),ensure_ascii=False,sort_keys=True)) finally: subprocess.run(['docker','rm','-f',name],capture_output=True,text=True,timeout=10) 指定地址监听接受发往 127.0.0.1 的请求并返回 pong,同端口发往 127.0.0.2 的连接被拒绝。两个目的地都属于回环,因此这个比较只隔离地址匹配行为,没有声称验证外部网络过滤。各监听使用端口零,让内核选择可用端口;程序随后读取实际端口再连接,避免硬编码端口恰好被其他测试占用。 ...

四月 2, 2027 · 1 分钟 · XB Field Notes
SRV 服务发现文章封面,包含依次相连的 OBSERVE、DECIDE 与 ACT 面板。

SRV 提供目标和端口,客户端必须理解其选择规则

SRV 通过优先级、权重、端口和目标主机名描述服务位置。它的所有者通常组合服务标签、传输标签与域名,例如 _echo._tcp.lab.test。理解该服务的客户端可以先查 SRV,再通过 A 或 AAAA 取得目标地址,连接记录给出的端口。普通主机名查询不会自动执行这套应用相关的发现流程,服务名与查询方式必须由调用方契约明确。 坏掉的样子 发布 SRV 不会让所有现有客户端自动采用它。应用需要规定服务标签,并实际支持相应发现行为。只接受普通主机名和配置端口的浏览器或库,可能根本不询问 SRV。因此,手工查询 SRV 成功,不能证明应用已经改变端点选择,也不能据此宣布今后不再需要更新配置。验证应沿真实调用方进行,不能止于区域里已有记录。 优先级和权重不是可互换字段。在可达目标中,较低数值优先级更优先;权重只影响相同优先级记录之间的选择。较高数值备机即使具有很大权重,也不能越过可用的低数值目标。把所有记录按权重统一排序,或直接采用报文中的首项,都可能反转服务所有者设计的主备关系。解释结果前,应确认客户端是否正确使用这些字段。 发现也不证明健康。监听停止后,目标仍可继续被发布,SRV 与目标地址记录集还具有各自缓存寿命。客户端需要连接截止时间与适当重试策略。连接被拒绝后重试读取,与重复可能已产生副作用的操作不同;端点故障转移必须符合应用协议,不能因为还有候选项,就无条件把每个业务动作再执行一遍。 快速验证 将脚本保存为 check-srv-discovery.py,准备 Docker 后执行 python3 check-srv-discovery.py。固定 BIND 9.18.49 环境发布测试 _echo._tcp 服务,两个真实 HTTP 监听在不同临时端口提供可识别的只读响应。HTTP 只是实验中便于观察的载荷;示例是自定义 SRV 客户端,不代表通用 HTTP 客户端本来就实现了这个服务标签: from pathlib import Path from tempfile import TemporaryDirectory import json,subprocess,uuid recipe='FROM python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84\nRUN apt-get update && apt-get install -y --no-install-recommends bind9=1:9.18.49-1~deb12u2 bind9-dnsutils=1:9.18.49-1~deb12u2 dnsmasq-base=2.90-4~deb12u2 python3-dnspython=2.3.0-1 && rm -rf /var/lib/apt/lists/*\nCMD ["/usr/bin/python3"]\n' program=r""" from contextlib import ExitStack,contextmanager from pathlib import Path from tempfile import TemporaryDirectory import json,re,signal,subprocess,time import dns.exception,dns.message,dns.query,dns.rcode,dns.rdatatype def run(*args,check=True): result=subprocess.run(args,capture_output=True,text=True,timeout=15) if check:assert result.returncode==0,(args,result.returncode,result.stdout,result.stderr) return result def query(name,kind='A',port=1053): return dns.query.udp(dns.message.make_query(name,kind),'127.0.0.1',port=port,timeout=1) def values(response,kind='A'): wanted=dns.rdatatype.from_text(kind) return [item.to_text() for rrset in response.answer if rrset.rdtype==wanted for item in rrset] def show(response): def section(records):return [{'name':str(rr.name),'type':dns.rdatatype.to_text(rr.rdtype),'ttl':rr.ttl,'data':[item.to_text() for item in rr]} for rr in records] return {'rcode':dns.rcode.to_text(response.rcode()),'answer':section(response.answer),'authority':section(response.authority)} def wait(predicate,timeout=15): deadline=time.monotonic()+timeout while time.monotonic()<deadline: try: if predicate():return except (OSError,dns.exception.Timeout):pass time.sleep(.05) raise AssertionError('DNS fixture condition did not become true') def stop(process): if process.poll() is None: process.terminate() try:process.wait(timeout=5) except subprocess.TimeoutExpired:process.kill();process.wait(timeout=5) @contextmanager def lab(records,soa_ttl=8,minimum=4,extra=''): with TemporaryDirectory(prefix='xbcatm-dns-') as directory,ExitStack() as stack: root=Path(directory);zone=root/'zone';conf=root/'named.conf';log=root/'named.log';serial=1 def save(body): zone.write_text(f'$ORIGIN lab.test.\n$TTL 30\n@ {soa_ttl} IN SOA ns.lab.test. hostmaster.lab.test. ({serial} 60 60 3600 {minimum})\n@ IN NS ns.lab.test.\nns IN A 127.0.0.1\n'+body+'\n') run('named-checkzone','lab.test',str(zone)) save(records) conf.write_text(f'''options {{ directory "{root}"; listen-on port 1053 {{127.0.0.1;}}; listen-on-v6 {{none;}}; recursion no; dnssec-validation no; notify no; querylog yes; max-cache-size 8m; pid-file "{root}/named.pid"; session-keyfile none; {extra} }}; controls {{}}; zone "lab.test" {{ type primary; file "{zone}"; }}; ''') run('named-checkconf',str(conf)) output=stack.enter_context(log.open('w')) authority=subprocess.Popen(['named','-g','-c',str(conf),'-n','1','-U','1'],stdout=output,stderr=subprocess.STDOUT) stack.callback(stop,authority) def ready(): assert authority.poll() is None,log.read_text() response=query('lab.test.','SOA');return response.rcode()==0 and response.answer[0][0].serial==serial wait(ready) def update(body): nonlocal serial serial+=1;save(body);authority.send_signal(signal.SIGHUP);wait(ready) def cache(port=1054,upstream=1053,size=150,options=()): logfile=root/('cache-'+str(port)+'.log');stream=stack.enter_context(logfile.open('w')) command=['dnsmasq','--no-daemon','--conf-file=/dev/null','--no-resolv','--no-hosts','--bind-interfaces', '--listen-address=127.0.0.1','--port='+str(port),'--server=127.0.0.1#'+str(upstream), '--cache-size='+str(size),'--log-queries','--log-facility=-',*options] process=subprocess.Popen(command,stdout=stream,stderr=subprocess.STDOUT);stack.callback(stop,process) def cache_ready(): assert process.poll() is None,logfile.read_text() return query('lab.test.','SOA',port).rcode()==0 wait(cache_ready);return process,logfile def queries(name,kind='A'): return sum(bool(re.search(r'query: '+re.escape(name.rstrip('.'))+r' IN '+kind+r'\b',line)) for line in log.read_text().splitlines()) yield root,update,cache,queries @contextmanager def named_process(root,config): root.mkdir(exist_ok=True);conf=root/'named.conf';log=root/'named.log';conf.write_text(config) run('named-checkconf',str(conf)) with log.open('w') as output: process=subprocess.Popen(['named','-g','-c',str(conf),'-n','1','-U','1'],stdout=output,stderr=subprocess.STDOUT) try: def ready(): assert process.poll() is None,log.read_text() return re.search(r'\brunning\s*$',log.read_text(),re.M) is not None wait(ready);yield process,log finally:stop(process) from http.server import BaseHTTPRequestHandler,ThreadingHTTPServer import http.client,threading class Handler(BaseHTTPRequestHandler): def log_message(self,*args):pass def do_GET(self): body=self.server.identity.encode();self.send_response(200);self.send_header('Content-Length',str(len(body)));self.end_headers();self.wfile.write(body) servers=[];threads=[] try: for identity in ('primary','backup'): server=ThreadingHTTPServer(('127.0.0.1',0),Handler);server.identity=identity thread=threading.Thread(target=server.serve_forever,daemon=True);thread.start();servers.append(server);threads.append(thread) records=f'''_echo._tcp 30 IN SRV 10 1 {servers[0].server_port} first.lab.test. _echo._tcp 30 IN SRV 20 65535 {servers[1].server_port} second.lab.test. _absent._tcp 30 IN SRV 0 0 0 . first 30 IN A 127.0.0.1 second 30 IN A 127.0.0.1''' with lab(records) as (_,update,cache,queries): srv=query('_echo._tcp.lab.test.','SRV');entries=sorted(srv.answer[0],key=lambda r:r.priority) assert [r.priority for r in entries]==[10,20] and [r.weight for r in entries]==[1,65535] def discover(): attempts=[] for record in entries: addresses=values(query(str(record.target))) for address in addresses: client=http.client.HTTPConnection(address,record.port,timeout=.5) try: attempts.append(str(record.target));client.request('GET','/');response=client.getresponse();body=response.read().decode() assert response.status==200;return {'identity':body,'attempted_targets':attempts,'selected_port':record.port} except OSError:continue finally:client.close() raise AssertionError('all service endpoints failed') first=discover();assert first['identity']=='primary' and first['attempted_targets']==['first.lab.test.'] servers[0].shutdown();servers[0].server_close() assert len(query('_echo._tcp.lab.test.','SRV').answer[0])==2 failed_over=discover();assert failed_over['identity']=='backup' and len(failed_over['attempted_targets'])==2 absent=query('_absent._tcp.lab.test.','SRV');assert str(absent.answer[0][0].target)=='.' ordinary=query('lab.test.','A');assert ordinary.rcode()==0 and not ordinary.answer print(json.dumps({'SRV':show(srv),'healthy_selection':first,'after_primary_stopped':failed_over, 'ordinary_A_lookup':show(ordinary),'unavailable_service':show(absent)},sort_keys=True)) finally: for server in servers:server.shutdown();server.server_close() for thread in threads:thread.join(timeout=2) """ name='xbcatm-dns-'+uuid.uuid4().hex[:10] def docker(*args,**kwargs):return subprocess.run(['docker',*args],capture_output=True,text=True,timeout=300,**kwargs) try: with TemporaryDirectory(prefix=name+'-') as directory: root=Path(directory);(root/'Dockerfile').write_text(recipe) built=docker('build','-t',name,str(root));assert built.returncode==0,built.stderr result=docker('run','--rm','-i','--name',name,'--network','none','--memory','384m','--pids-limit','128',name, '/usr/bin/python3','-',input=program) assert result.returncode==0,result.stderr print(json.dumps(json.loads(result.stdout),sort_keys=True)) finally: docker('rm','-f',name);docker('image','rm',name) 第一目标优先级为十、权重为一,备机优先级为二十、权重为六万五千五百三十五。两者分别公布真实监听端口,目标名都解析到回环地址。客户端读取 SRV,按不同优先级组排序,再解析目标并连接记录端口。两个监听都健康时,只访问第一目标并收到主节点身份,说明备机的大权重不会跨越优先级组改变这次选择。 ...

三月 13, 2027 · 3 分钟 · XB Field Notes