文章标题旁是标有 OBSERVE、DECIDE、ACT 的三个通用面板,以及箭头和圆形仪表。

Service 给一组 Pod 一个稳定的虚拟地址

选择器让 Service 从一段固定列表变成动态视图:新增移除都在后台完成,Pod 重启时客户端无需改任何配置。 先声明,再分配地址 普通 ClusterIP Service 在对象存续期间提供虚拟地址,并在集群 DNS 支持下提供稳定发现名称。带选择器的 Service 还声明本命名空间中哪些 Pod 是预期后端。其他形态不同:无头服务不分配虚拟 IP,ExternalName 使用 DNS 间接引用,无选择器服务可以配合单独管理的 EndpointSlice。不能把每种 Service 都套进同一种分配流程。 地址稳定仅限 Service 对象存续期间,不是某个名称出现之后永远占用同一个地址。删除重建可能得到新地址。客户端应遵守约定的发现方式,并适当重新解析,而不是把抄下来的 ClusterIP 当成永久基础设施身份。这层稳定性主要隔离后端更替,不能消除对象本身重建和发现系统变化带来的影响。 选择器就是实时成员 EndpointSlice 控制器异步协调匹配 Pod 与端点条件。未就绪 Pod 可以继续出现在对象中,只是 ready 为 false,并非一定从所有端点对象里消失。消费者根据这些条件选择路由目标。publishNotReadyAddresses 会改变普通就绪解释,终止中端点又有额外处理细节,不能仅凭列表存在地址就断言后端应接收普通流量。 标签一致也不能证明请求健康。端口错误、不适当探针或过期数据路径仍然可能导致失败。应同时检查预期选择器与实际 EndpointSlice,再验证应用行为。自动协调减少手工维护后端名单,但不承诺瞬间更新,也不会消除所有漂移。控制面对象、节点转发和客户端连接各有自己的状态及更新时序。 Pod 变了地址依旧稳定 稳定目的地允许替换后端,而不必向调用方逐个公布 Pod IP,但不保证每次请求都不中断。已有连接可能仍关联旧后端,新连接依赖已经收敛的路由状态;没有可用端点的 Service 也不能制造缺失容量。不能因为名称没有改变,就认为所有连接已经自动迁移到新进程。 ...

二月 9, 2027 · 2 分钟 · XB Field Notes
观察、决策、行动三个方框,旁边提示MSS 可帮助 TCP,但 MTU 反馈仍可能失败。

调整 MSS 可以恢复一条 TCP 路径,却不会修复 MTU 反馈

TCP 最大段大小选项 MSS,按照其定义的开销规则,通告接收端可接受的 TCP 数据大小。两个方向分别通告,数值不必相同。路由器转发 SYN 消息时可以减小这些值,使后续数据段适应受限路径。这改变的是相关连接的 TCP 行为,不会修改底层链路 MTU。把选项调整与网络本身修复区分开,才能准确说明它解决了什么。 坏掉的样子 中间瓶颈可能比两个端点接口都小。如果经典路径 MTU 反馈被过滤,连接可以成功建立,却在较大数据上停滞。降低 MSS 能帮助该 TCP 流量避开超大报文,但缺失的反馈仍然缺失。若把这个变通措施当作完整网络修复,其他协议与流量模式仍可能暴露原有问题,健康检查也可能继续遗漏受影响场景。 MSS 不是完整 IP 包大小。IPv4 路径 MTU 为 1280、普通 IPv4 与 TCP 基础头各占 20 字节时,基本计算得到通告 MSS 1240。形成真实数据段时,还要为实际 IP 或 TCP 选项另外留空间。把通告值直接等同于最终线路报文长度,可能使看似保守的设置仍然超限。应明确每个数字包含哪些头部,而不是只套一个固定减法。 方向和时机也很重要。只修改一个 SYN 方向,另一端通告仍保持原样;更新规则,也不会改写已有连接早先交换过的选项。真正的限制应降低过大通告,而不是抬高对端原本更小的上限。策略应针对真实瓶颈和相关流量,不能把一个硬编码值视为所有网络都合适,也不能假定旧连接会自动采用新配置。 快速验证 将脚本保存为 check-mss-clamp.py,准备 Docker 后运行 python3 check-mss-clamp.py。一次性环境使用 Python 3.11.16、iproute2 6.1.0、nftables 1.0.6 和 tcpdump 4.99.3,在 Linux 6.12.76 上验证。NET_ADMIN 与 SYS_ADMIN 支持四个嵌套命名空间。端点链路 MTU 为 1500,两台路由器之间的链路为 1280: ...

一月 24, 2027 · 3 分钟 · XB Field Notes
浅色网格上显示文章标题,旁侧是依次标注 Observe、Decide、Act 的三个相连方框。

gRPC 用一条 HTTP/2 连接复用多条流

gRPC 的 HTTP/2 传输允许多个 RPC 尝试共享一条连接,同时保留每条流内部的消息顺序。这对短调用和长时间交换都可能有用,但并不意味着每个调用获得了一条无限且完全独立的传输通道。带宽、TCP 交付、连接级流控以及实现中的资源仍然共享,理解这些边界才能准确判断哪些阻塞能够隔离,哪些会影响其他调用。 让调用形式匹配实际工作 一次基于 HTTP/2 的 RPC 尝试占用一条流;重试会发起另一次尝试,而不是把字节重新写入已经结束的流。复用连接能够摊薄建连和 TLS 的成本,但一个 channel 可以管理多条连接,访问不同后端也不会自动共用同一个 TCP 端点。每次调用仍然包含序列化、元数据、帧处理、调度和业务工作,不能把额外成本说成只剩几个帧头。 gRPC 提供单次请求响应、客户端流、服务器流和双向流四类方法。只有双向流允许双方在同一次 RPC 内独立发送消息序列。每个方向的消息顺序得以保留,但不要求每条请求消息都对应一条回复。应当根据应用契约选择调用形式,而不是认为只要采用 gRPC,所有方法就自然具备双向持续推送能力;什么时候读取和写入,仍需应用合理安排。 HTTP/1.1 可以复用持久连接,WebSocket 本身也支持双向消息,二者都不要求每发送一条应用消息就重新握手。因此,不能用虚构的“一次消息一次连接”来证明 gRPC 总是更省。更有意义的比较包括 RPC 契约、生成接口、状态模型和 HTTP/2 多路复用,再结合完整负载和运维约束测量,才能判断实际延迟、内存和连接开销。 分开调用期限与共享传输 截止时间属于某次 RPC,并不会自动成为同一连接上所有流的统一期限。应通过语言支持的上下文机制向下游传递剩余预算;每经过一层就重新领取完整超时,可能让工作持续到调用方早已不愿等待之后。取消还需要业务代码配合停止工作,已经提交的副作用不会因此自动回滚,客户端未收到成功也不等于服务端没有完成修改。 HTTP/2 对 DATA 帧同时实施流级和连接级流控。某条流的窗口耗尽时,只要连接信用仍足够,其他流就可能继续传输。某个方向的连接信用耗尽,则会限制该方向上各条流继续发送 DATA,但并不禁止所有控制帧。接收端实现可以独立补充连接信用,所以一个慢读者并不必然拖到整条连接都没有额度;应观察真实实现如何管理缓冲和更新。 TCP 丢包仍然可能影响多个 HTTP/2 流共享的字节交付。队列、处理器和内存也会在协议流抽象之外造成耦合。应用向库写入成功,可能只是数据进入了库的缓冲,并不能证明已经送达对端或被业务消费。缓冲应当有界,并在实际客户端和服务器库上验证慢读者场景,不能仅凭连接仍然存活就认为吞吐和资源使用都正常。 ...

一月 16, 2027 · 2 分钟 · XB Field Notes
浅色网格上显示文章标题,旁侧是依次标注 Observe、Decide、Act 的三个相连方框。

有用的主机名应区分本地身份、服务名称与查询范围

有用的名称为人和软件提供一个可以跨地址变化继续使用的引用,但这个引用需要明确范围与负责人。机器的本地主机名、服务 DNS 名称和实例标识可以有意不同。清楚命名有助于理解日志和部署,而不是假定一个标签自动建立系统中的所有其他身份。规划名称时,应先说明它标识的是哪类对象,以及希望保持稳定多久。 坏掉的样子 设置本地主机名,本身不会向权威区域发布 A 或 AAAA。注册可能由独立部署或网络管理软件完成,但那条流程必须实际存在。反过来,修改 DNS 地址也不会重命名内核的本地主机名。混淆两者,会产生本地标签看似正确、客户端却无法发现服务的机器。排障时应分别检查本地状态、注册动作和权威数据,不能用其中一个代替其他证据。 短名称依赖查询上下文。即使两台机器都使用正常 DNS 配置,service 在不同搜索域下仍可能指向不同目的地。复制的运行手册命令因此可能到达与作者测试不同的环境。配置中不能容忍歧义时,应使用完整目标 DNS 名称;交互操作使用短名时,也应保留解释它所需的上下文。名称看起来一样,并不意味着它在所有进程里具有相同范围。 人类可读的含义也有限制。app-prod-01 可以提示角色、环境与实例,却不是权限、归属或当前工作负载的权威证据。复用旧实例名称还可能混淆历史日志。服务名称与替换机器的生命周期不同时,应分别命名,并使用运维元数据记录变化事实,不要试图把所有信息都塞进主机名。方便阅读的前缀也不能成为授予访问权限的依据。 快速验证 将脚本保存为 check-hostnames.py,准备 Docker 后执行 python3 check-hostnames.py。固定环境使用 BIND 9.18.49、dnsmasq 2.90 和 Python 3.11,解析器及 NSS 配置都位于可丢弃容器中。本实验关闭转发缓存,使搜索域和地址变化可以直接观察,不必等待上一代缓存寿命;这项设置用于隔离变量,不代表所有生产客户端都应关闭缓存: 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 import socket records='service 30 IN A 127.0.0.2\nservice.branch 30 IN A 127.0.0.3\n_echo._tcp 30 IN SRV 0 0 8080 service.lab.test.' with lab(records) as (root,update,cache,queries): cache(port=53,size=0) nss=Path('/etc/nsswitch.conf');nss.write_text(re.sub(r'^hosts:.*$','hosts: dns',nss.read_text(),flags=re.M)) def search(domain):Path('/etc/resolv.conf').write_text('nameserver 127.0.0.1\nsearch '+domain+'\noptions timeout:1 attempts:1\n') def address(name): return json.loads(run('/usr/bin/python3','-c','import socket,json,sys;print(json.dumps(sorted({r[4][0] for r in socket.getaddrinfo(sys.argv[1],80,socket.AF_INET,socket.SOCK_STREAM)})))',name).stdout) local_name=socket.gethostname();unpublished=query(local_name+'.lab.test.');assert unpublished.rcode()==dns.rcode.NXDOMAIN lower=query('service.lab.test.');mixed=query('SeRvIcE.LaB.TeSt.');assert values(lower)==values(mixed)==['127.0.0.2'] search('lab.test');first=address('service');absolute_first=address('service.lab.test.');assert first==absolute_first==['127.0.0.2'] search('branch.lab.test');second=address('service');absolute_second=address('service.lab.test.');assert second==['127.0.0.3'] and absolute_second==first update(records.replace('service 30 IN A 127.0.0.2','service 30 IN A 127.0.0.4'));changed=address('service.lab.test.') assert changed==['127.0.0.4'] and socket.gethostname()==local_name invalid=root/'invalid.zone';invalid.write_text((root/'zone').read_text()+'bad_name IN A 127.0.0.1\n') check=run('named-checkzone','-k','fail','lab.test',str(invalid),check=False) assert check.returncode!=0 and 'bad owner name' in check.stdout+check.stderr assert values(query('_echo._tcp.lab.test.','SRV'),'SRV')==['0 0 8080 service.lab.test.'] print(json.dumps({'local_hostname_published_in_zone':False,'case_insensitive_A':values(mixed), 'short_name_by_search_domain':{'lab.test':first,'branch.lab.test':second},'absolute_name_unchanged_by_search':[absolute_first,absolute_second], 'DNS_address_after_change':changed,'local_hostname_unchanged':True,'invalid_host_owner_rejected':check.returncode,'underscore_SRV_owner_accepted':True},sort_keys=True)) """ 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) 脚本读取容器本地主机名,再查询该标签位于 lab.test 下的名称。由于没有发布相应区域记录,权威返回 NXDOMAIN。这说明当前环境缺少这项注册,不是在断言所有安装都不会自动注册主机名。对 service.lab.test 分别使用全小写与混合大小写查询,都返回 127.0.0.2,展示 DNS 中 ASCII 名称比较不区分大小写。 ...

十二月 1, 2026 · 3 分钟 · XB Field Notes
关于IPv6 文本规范化的标题卡,配有箭头连接的通用 Observe、Decide、Act 面板。

IPv6 文本规范化保留地址,端点上下文仍须另外处理

IPv6 地址包含一百二十八位,通常写成八组十六进制数字。文本可以省略组内前导零,并用一次 :: 代替连续全零组。这些规则缩短写法而不丢失地址。RFC 4291 定义有效形式,RFC 5952 推荐一致的显示与交换形式。语法是否有效,和拼写是否符合推荐规范,是相关但不同的问题,不能用一个判断替代另一个。 坏掉的样子 直接比较原始字符串,可能把同一个地址当成多个身份。大写字母、前导零和展开的零组都能改变文本,却不改变底层位值,从而拆散资产记录,或破坏应用原本想进行的地址比较。应先使用适合输入类型的库解析,再比较地址值。仅转换成小写,不会处理所有零组和压缩差异,因此不能称为完整规范化。 压缩本身也有限制。:: 最多出现一次,否则无法确定各个省略段的长度。推荐表示应压缩最长连续零组;长度相同时选择最前一组,并且不要压缩单个零字段。一般 IPv6 语法允许部分推荐显示规则不采用的写法。规范化工具应区分有效但非首选的形式与真正非法输入,而不是把两者都报告成相同错误。 地址也未必构成完整端点。URL 中的 IPv6 字面量需要括号,将地址内部冒号与端口分隔符区别开。链路本地目的地还可能需要区域标识,以选择正确接口范围。如果没有明确比较策略就删除区域,或把映射 IPv4 地址扁平化,可能丢失调用方建立正确连接所需的信息。字符串变短,不代表端点含义仍完整。 快速验证 将脚本保存为 check-ipv6-text.py,使用 Python 3.12 或更新版本执行 python3 check-ipv6-text.py。本轮用 Python 3.12.14 原样运行此脚本,只调用标准库解析,不产生网络流量。程序比较有效变体、拒绝错误输入并检查端点组成,没有配置示例地址、发送 IPv6 报文或证明这些地址具有可用路由。解析验证与网络可达性在这里明确分开: from ipaddress import IPv6Address from urllib.parse import urlsplit import json variants=['2001:0DB8:0000:0000:0000:ff00:0042:8329','2001:db8:0:0:0:ff00:42:8329','2001:db8::ff00:42:8329'] parsed=[IPv6Address(v) for v in variants];assert len(set(parsed))==1 and len(set(variants))==3 tie=IPv6Address('2001:0:0:1:0:0:1:1').compressed;assert tie=='2001::1:0:0:1:1' single=IPv6Address('2001:db8::1:2:3:4:5').compressed;assert single=='2001:db8:0:1:2:3:4:5' bad=[] for text in ['2001::db8::1','2001:db8:1:2:3:4:5:6:7','2001:db8::gg','2001:db8::1/64']: try:IPv6Address(text);raise AssertionError('invalid address accepted') except ValueError:bad.append(text) url=urlsplit('https://[2001:db8::1]:8443/health');assert IPv6Address(url.hostname)==IPv6Address('2001:db8::1') and url.port==8443 try:urlsplit('https://2001:db8::1:8443/health').port;raise AssertionError('unbracketed endpoint accepted') except ValueError:bad_url=True mapped=IPv6Address('::ffff:192.0.2.1');assert str(mapped.ipv4_mapped)=='192.0.2.1' zone_a=IPv6Address('fe80::1%eth0');zone_b=IPv6Address('fe80::1%eth1') assert zone_a!=zone_b and zone_a.packed==zone_b.packed print(json.dumps({'variants':variants,'same_128_bits':len(set(parsed))==1,'normalized':str(parsed[0]), 'leftmost_tie':tie,'single_zero_retained':single,'rejected':bad,'url_host':url.hostname,'url_port':url.port, 'unbracketed_url_port_rejected':bad_url,'mapped_python_text':mapped.compressed,'mapped_ipv4':str(mapped.ipv4_mapped), 'zone_scope_ids':[zone_a.scope_id,zone_b.scope_id],'same_bits_different_scopes':True})) 三种字符串表示同一地址,包括展开的大写形式 2001:0DB8:0000:0000:0000:ff00:0042:8329 与 2001:db8::ff00:42:8329。原始字符串集合有三项,解析后的地址集合只有一项,规范输出为后者。这个对照直接说明:如果应用身份指的是底层地址,原始拼写就不是可靠索引键,不能因为文字不同便建立多个独立对象。 ...

十一月 30, 2026 · 1 分钟 · XB Field Notes
TC 标志说明回答不完整,对应 TCP 交换才能取得完整记录。

UDP 回答被截断时,DNS 需要可工作的 TCP 路径

DNS 同时支持 UDP 和 TCP。服务器因可用消息空间而截断响应时,会设置 TC 标志。客户端收到该标志,需要处理结果不完整的情况,通常通过 TCP 重新查询。这涉及当前使用的 DNS 响应限制,并不直接证明物理网络无法承载任何更大的 UDP 数据报。排障时应先确认协议层发生了什么,再讨论传输路径的实际容量。 坏掉的样子 允许 UDP 五十三端口却阻止 TCP 五十三端口的网络,可能小查询正常,大回答失败。DNSSEC 数据和较大记录集容易暴露这种差异,但 TCP 并不只用于这些特殊情况。通用 DNS 实现需要支持 TCP,客户端也可以直接选择它。因此,一次小型 UDP 查询成功,不能证明整个 DNS 服务的传输路径完整,尤其不能代替对 TCP 监听和访问规则的检查。 被截断的回答仍可能包含部分记录。如果忽略 TC,把这些记录当成完整集合,就可能漏掉有效目的地或必需数据。NOERROR 不会抵消截断标志。当应用看到的记录比另一种传输方式下的权威查询少时,应保留完整问题、响应标志和记录数量,不要只提取一个地址或文本值。仅凭有内容返回,并不能判断这次 DNS 交换已经完整结束。 TCP 也没有取消所有 DNS 消息大小限制。TCP 流中,每个 DNS 消息之前都有两字节长度字段,因此单个消息最多六万五千五百三十五字节;区域传送可以由多个消息组成。流读取可能拆开长度字段或正文,程序应循环读取所需字节数,不能假定一次 recv 就返回整个回答。传输可靠与应用消息边界明确,是两项不同责任。 ...

十一月 19, 2026 · 3 分钟 · XB Field Notes
标题卡旁有三个方框,分别标注 Observe、Decide 和 Act。

NAT 通过独立连接映射共享一个地址

网络地址转换会在数据包经过边界时改写地址信息。常见的多个客户端共用一个地址,实际采用网络地址和端口转换:路由器维护映射,区分同时存在的流量。远端 TCP 服务器可能看到相同源 IP,却看到不同源端口。这个共享地址代表转换边界,不一定代表同一台计算机、同一个账号或同一个人,不能直接替代身份判断。 坏掉的样子 转换不能代替路由。客户端仍需要到网关的路径,网关必须允许转发,转换后的地址也要具有返回路径。反过来,在能够路由相应前缀的内部网络中,使用私有地址并不必然要求 NAT。缺少返回路由和缺少转换规则都可能表现为超时,但两者对应不同配置决策。应先确定目标看到什么来源,再检查回复如何返回。 有状态 NAT 还会使规则变更中的已有连接与新连接表现不同。nftables 查询 NAT 链以建立流量映射,后续数据包复用保存的绑定。因此,移除 SNAT 规则不一定撤销已经建立的转换连接。这依赖连接跟踪继续工作、绑定仍然有效,不能把一次规则编辑理解成无条件清除全部会话。测试对象是否复用了旧连接,会直接影响结论。 端口映射与连接跟踪容量都是有限资源。资源耗尽、状态到期,或者故障切换设备缺少相关状态,都可能在对外地址未变时中断通信。可用转换源端口需要结合协议和相关连接元组判断,不能用一个对所有目的地、所有负载都通用的端口总数估算容量。NAT 与决定哪些流量被允许的防火墙策略,也属于不同职责。 快速验证 历史集成示例保持原样,本次没有重跑;当前不能使用 Docker、网络命名空间、特权挂载或防火墙变更。 将脚本保存为 check-nat.py,准备 Docker 后运行 python3 check-nat.py。它构建包含 Python 3.11.16 和 nftables 1.0.6 的一次性环境,通过 NET_ADMIN 与 SYS_ADMIN 创建四个嵌套网络命名空间及临时 proc 挂载。实验在 Linux 6.12.76 上执行;Docker 共享宿主内核,必须只在另行获准的一次性虚拟机里运行;这些权限与共享内核意味着仅用容器不足以保证隔离。实时软件源也不保证未来还能取得这些固定包版本: ...

十一月 18, 2026 · 3 分钟 · XB Field Notes
标题卡写着“回环地址属于自身命名空间”,配有标注 OBSERVE、DECIDE、ACT 的三个相连方框。

回环地址返回调用方自己的网络命名空间

回环地址让进程通过本地 IP 协议栈通信,无须把交换送到物理网络链路。IPv4 为回环保留 127.0.0.0/8,其中最熟悉的是 127.0.0.1;IPv6 使用 ::1。在 Linux 中,相关的本地网络范围是网络命名空间。因此,同一台物理机器上的两个进程,可能对“自己的回环服务”具有不同答案,不能只按机器归属判断。 坏掉的样子 容器内客户端把数据库地址配置为 127.0.0.1,却希望访问宿主或另一容器时,经常失败。客户端实际请求的是自己命名空间中的监听,地址拼写没有错,拓扑理解却错了。宿主发布端口、容器接口地址和另一进程的回环,是不同目的地。选择连接配置前,应先确定客户端运行在哪里,以及真正服务由哪个网络范围提供。 回环也不保证任何探测都成功。目标端口需要监听,预期地址族需要可用,本地网络策略还可能影响交付。IPv4 请求成功不代表服务也监听 ::1;连接被拒绝,则可能只是调用方命名空间内对应地址与端口没有服务。不能因回环无须外部网线,就把协议栈、监听状态和访问规则当成始终有效的前提。 本地交付没有消除 IP 协议栈。回环流量仍经过套接字、协议处理和本地路由决策,只是没有沿外部物理网络绕行。测量一次回环请求,不能验证交换机配置、远程路由、互联网防火墙或公共服务名称的证书。解释健康探测时,应把这些路径分开,否则内部服务正常可能被误写成所有用户都能成功访问。 快速验证 将脚本保存为 check-loopback.py,准备 Docker 后执行 python3 check-loopback.py。程序用相同固定 Python 3.11.16 镜像创建两个一次性容器,均关闭 Docker 网络接入且不发布宿主端口。容器 A 在一万九千零九十一端口启动 IPv4 和 IPv6 监听,容器 B 起初没有该端口的服务。所有测试完成后,两个容器都会被移除: import json,subprocess,time,uuid image='python@sha256:528257d48c1da0dcecc2e725d1ae34498d60c965f1241e39cd6a85a8859bdf84' names=['xbcatm-loop-'+uuid.uuid4().hex[:10] for _ in range(2)] server=r''' import json,os,selectors,socket,sys selector=selectors.DefaultSelector() for family,address in [(socket.AF_INET,'127.0.0.1'),(socket.AF_INET6,'::1')]: listener=socket.socket(family,socket.SOCK_STREAM) if family==socket.AF_INET6:listener.setsockopt(socket.IPPROTO_IPV6,socket.IPV6_V6ONLY,1) listener.bind((address,19091));listener.listen(8);selector.register(listener,selectors.EVENT_READ) print('ready',flush=True) while True: for key,events in selector.select(): connection,peer=key.fileobj.accept() with connection: connection.settimeout(1) connection.sendall(json.dumps({'service':sys.argv[1],'netns':os.readlink('/proc/self/ns/net')}).encode()) ''' probe=r''' import json,os,socket answers={} for address in ['127.0.0.1','::1']: try: with socket.create_connection((address,19091),timeout=1) as stream: pieces=[] while True: piece=stream.recv(1024) if not piece:break pieces.append(piece) answers[address]=json.loads(b''.join(pieces)) except ConnectionRefusedError:answers[address]={'error':'ECONNREFUSED'} print(json.dumps({'netns':os.readlink('/proc/self/ns/net'),'interfaces':socket.if_nameindex(),'answers':answers})) ''' def docker(*args): result=subprocess.run(['docker',*args],capture_output=True,text=True,timeout=30) assert result.returncode==0,result.stderr return result.stdout def inspect(name):return json.loads(docker('exec',name,'python3','-c',probe)) def wait_for(name,identity): deadline=time.monotonic()+5 while time.monotonic()<deadline: result=inspect(name) if all(answer.get('service')==identity for answer in result['answers'].values()):return result time.sleep(.05) raise AssertionError('listener did not become ready') try: for name,command in [(names[0],['python3','-u','-c',server,'A']), (names[1],['python3','-c','import time;time.sleep(120)'])]: docker('run','-d','--rm','--name',name,'--network','none','--memory','128m','--pids-limit','64',image,*command) a=wait_for(names[0],'A');b_before=inspect(names[1]) assert a['netns']!=b_before['netns'] assert all(v=={'error':'ECONNREFUSED'} for v in b_before['answers'].values()) docker('exec','-d',names[1],'python3','-u','-c',server,'B') b_after=wait_for(names[1],'B');a_after=inspect(names[0]) assert all(v.get('service')=='A' for v in a_after['answers'].values()) for result in [a,b_after,a_after]: assert all(answer['netns']==result['netns'] for answer in result['answers'].values()) assert all(any(interface[1]=='lo' for interface in result['interfaces']) for result in [a,b_before,b_after]) print(json.dumps({'A':a,'B_before_listener':b_before,'B_after_listener':b_after,'A_still_A':a_after},sort_keys=True)) finally: for name in names:subprocess.run(['docker','rm','-f',name],capture_output=True,text=True,timeout=10) 在 A 内执行的探测访问 127.0.0.1 和 ::1,都读到服务身份 A。响应同时携带服务器的网络命名空间标识,与探测进程自身标识一致。这样把成功的应用响应与客户端所在网络范围关联起来,而不是假定宿主上所有看起来本地的地址都共享一个监听。地址、上下文和返回身份共同组成证据。 ...

十一月 17, 2026 · 2 分钟 · XB Field Notes