作者在排查 k3s 网络问题时重启 Docker,导致其他网络中的容器地址变化,并破坏反向代理之间依赖固定 IP 的链路。案例揭示主机级内核状态、容器网络和未显式声明的基础设施依赖如何引发连锁故障。
事情起因于一次 k3s 安装,而可用内存不足以完成安装。我手动关闭了另一个项目的技术栈,释放出足够的内存,让 k3s 得以运行。k3s 启动时会加载 br_netfilter,而模块就位后,一个作用于整台主机的内核开关改变了状态。随后,我重新启动了之前停止的项目,却发现它的容器之间已经无法互相访问。那是另一个故事了。本文要讲的是我接下来尝试的操作——这个尝试对原问题毫无作用,却悄无声息地破坏了另一个地方。
我尝试的是重启 docker daemon:关闭引擎,干净地重新启动所有组件,看看故障是否会随之再次出现。故障确实又出现了。然而,这次重启真正造成的影响却落在了完全不同的地方——另一个网络中的每个容器都获得了与之前不同的地址,而那个网络我一整天都没有碰过。其中有两个容器与本文有关:一个是位于这台机器上所有服务之前的反向代理,另一个是位于该反向代理之前的代理服务。当时,这两个容器都没有固定静态地址,因此在启动过程中,docker 分配什么地址,它们就拿到什么地址。这个故事真正开始的地方就在这里。
在继续之前,有必要先解释第二个代理,因为整个调查都围绕它展开。这台机器上的每项服务都受反向代理访问规则的保护,该规则只允许两个来源,并拒绝其他所有来源。唯一的例外是代理服务本身——从这里开始称它为网关——它的存在本身就是一种安全控制:首先连接到它,然后它便成为公网与这台机器上运行的其他所有服务之间唯一的屏障。这并不是线路连接最后碰巧形成的样子,而是这套线路从一开始就被设计成这样——将一组公网可访问的服务转变为一套行为类似私有内网的系统,并由一个经过刻意设计、不受限制的入口进行管控。
重启后,我尽可能准确地描述了自己看到的症状:反向代理后面的几乎所有服务都变得无法访问,而网关自己的入口仍然可以正常响应。究竟是访问规则出了问题,还是服务器地址或某个容器的地址发生了变化?
AI 智能体直接查看了反向代理自身的配置——它为每个受保护的服务各写入一个按主机名区分的配置文件。每个文件中都有相同的规则:允许共享 docker 网络中的一个地址,允许 VPS 自身的公网地址,拒绝其他所有来源。结合症状来看,这条规则一次就能解释所有现象。这两个地址是仅有的两种进入方式;而网关自己的入口完全没有这条规则。如果两个获准地址中的任何一个发生了变化,所有受保护的服务都会变得无法访问,而入口仍会像以前一样正常响应。
这个判断是正确的,而且得出得很快——一份配置、一个在所有受保护主机上重复出现的模式,与一个此前存在多个合理原因的症状完全吻合。但它尚未回答规则中两个地址分别代表什么。这不是诊断上的缺口,而是接下来需要回答的问题。
两个地址中的一个很容易确定:VPS 自身的公网 IP 203.0.113.10——docker 网络之外的任何流量在这里都会显示为这个地址。另一个则是 docker 网络地址 172.19.0.2,位于反向代理和网关共同所在的子网中。接下来很自然的问题是,这究竟是谁的地址?有一种很快的确认方式:检查当前实际运行在这个地址上的是什么。
AI 智能体进行了检查。当时,反向代理自己的容器恰好就在 172.19.0.2。配置中的地址与网络上实际使用的地址完全相同。这并不是一个微弱的信号——有一个实时进程正在相关地址上响应,已经是此类检查所能提供的最直接确认之一。
问题在于,这个容器的地址并没有被固定到任何值。没有任何配置将它钉死在那里;它只是被 docker 自身的地址分配机制放到了这个位置,而这一切恰好发生在那次重新打乱整个网络地址的重启之后。这个匹配并不意味着允许列表描述的是反向代理,而是两个相互独立的事物在同一时刻落到了相同的值上——一边是一条已经存在了一段时间的规则,另一边则是一个容器,它因为重启后的地址分配结果而恰好落到了这个地址。没有任何东西强制二者保持一致。它们只是在那个时刻碰巧一致,而当时也没有理由追问为什么。
从任何显而易见的角度看,这里都没有遗漏什么步骤。检查配置,再检查实时运行情况,两者一致——这正是实时系统理应提供的确认方式。整个过程的结构悄然预设了 docker 地址是在指代某个对象,因此它从未需要追问这个地址意味着什么,只需要确认当前是谁在这个地址上响应。那里总会有某个东西响应。这个问题根本没有返回否定答案的可能。
在同一轮排查中,AI 智能体还朝另一个方向作出了两次判断——两次都先提出了警示,随后又撤回,而且都在转化为实际操作之前完成了纠正。
第一次涉及 VPS 自身的公网出口地址。这个地址出现在了一个原本没有预料到的位置,乍看之下被判断为某个人家中或办公室的网络连接——这是一个合理猜测,因为一个没有任何标签的裸公网 IP 可能属于任何人。AI 智能体没有直接接受这个判断,而是进行了检查:先让主机上的一个容器报告它看到的自身出口地址,再向主机询问同样的问题,两次得到的值完全一致。除此之外,反向代理自身的访问日志中早已记录了这个确切地址:一个持续运行且始终被放行的自监控探针,每分钟都会以该地址作为来源出现一次。一个永远每分钟出现一次的地址不可能是某个人的笔记本电脑,而是这台机器在与自身通信。
第二次涉及一个正在运行的进程,它使用了某款知名安全工具的名称,但主机上的任何包管理器都没有该工具的安装记录,而且真正安装该工具时应该使用的路径下也不存在对应的二进制文件。仅凭这两个事实,将其公开标记为潜在入侵是正确的做法——一个无法解释却使用该名称的进程,确实可能正是系统遭到入侵时呈现的样子。但这个判断没有经受住下一步检查。解析该进程的 /proc/<pid>/exe 链接后,目标指向了一个普通的、早已安装的脚本解释器,而它的父进程则是网关应用程序自身的二进制文件:该工具本就是随网关软件捆绑提供的,这也恰好解释了为什么任何包管理器都不知道它的存在。
两次判断,两次撤回,每一次都基于一个具体且可验证的原因,而不是单纯改变了想法。检查这一习惯在整个调查过程中都发挥着它应有的作用——发现一处巧合、另一个位置上没有标签的地址,以及第三处可疑的名称,然后逐一追查,避免它们在未经验证的情况下演变成决策。但这些检查没有一个指向真正重要的那个地址。检查机制并没有在那里失效,而是从未被用到那里。
前两个小节中由地址匹配产生的判断,并没有停留在工作假设阶段。AI 智能体将允许列表中的 docker 网络地址视为反向代理自身的地址,并把反向代理的 compose 文件固定到了这个确切地址——把一次巧合变成了一条静态声明——随后报告修复已经应用。
这是代价高昂的一步,而它之所以代价高昂,有一个具体原因。在此之前,所有内容都只是推理,处于一种松散的状态,一旦出现新证据,随时可以修正。而 compose 文件中的固定地址已经不再只是推理。它是一项写入文件的承诺,位于真正决定容器如何启动的同一个文件中;之后任何查看它的人——包括下一次处理问题时的 AI 智能体自己——都会把它视为一项已经确定的事实,而不是一个恰好由某个未经验证的假设推导出的步骤。最终报告采用了同样确定的措辞:已经修复,而不是很可能已经修复。
我读完修复方案后,并没有接受它。经过受保护服务的流量来自网关——这正是网关以这种形式存在的原因——因此,允许列表中固定的应该是网关的地址,而不是反向代理的地址。我并不是临场猜测。我记得自己最初是如何构建这套系统的,并将那段记忆与报告中的实际内容进行对照,矛盾立刻显现出来:在反向代理自己的 compose 文件中将地址固定为反向代理自己的地址,与流量应该如何通过网关到达的设计完全不符。
AI 智能体没有直接接受我的观点,而是对它进行了验证。
一个已经位于共享 Docker 网络中的容器,像互联网上的任何客户端一样直接请求了公共主机名,然后查看反向代理的访问日志,确认其中记录的来源。答案是:VPS 自身的公网地址,而不是该容器自己的 Docker 网络地址。无论该网络中的容器向公共域名发送什么请求,反向代理都会看到请求从外部抵达——这意味着,在已经固定的地址之外,再固定网关的特定地址,无论如何都不应该产生任何影响。
这是一个真实的结果。容器确实访问了真实的主机名,日志也确实显示了公网地址,这个数值本身没有任何错误。它明确回答了一个问题:此网络中任意容器发往公共域名的流量,抵达时是否会携带该容器自己的地址?答案是否定的。但它没有回答另一个问题——因为这项测试根本没有对此进行验证——那就是网关自身的流量是否也走了相同的路径。普通容器以常规方式解析公共主机名,与网关通过自己的代理引擎处理该主机名并转发流量,是两条不同的道路,只不过从 compose 文件里看起来一模一样。测试测量的是第一条路,却用结果解释了第二条路。
这看起来是两个答案中更有力的那个,而不是更弱的那个。我的反对意见只是一句关于系统设计工作方式的陈述。AI 智能体的回应则是一次现场执行并当场读取结果的实际测量。在基于记忆的论证和刚刚运行的测试所得数值之间,数值每次都会赢得在场所有人的信任——直到有人检查这个数值究竟是在测量什么。
我提供了配置和测试都无法产生的那块拼图:允许列表并非一直包含两个地址。最初编写它时,其中只有一个地址——Docker 网络地址——而这已经足够了。它确实可以正常工作。
把这个事实与 AI 智能体的测试放在一起,测试结论就站不住脚了。如果 Docker 网络中任何位置对公共主机名发出的请求,在抵达反向代理时始终都会携带 VPS 自身的公网地址,没有例外——刚才的测试展示的正是这一点——那么,仅包含 Docker 网络地址、完全没有放行公网地址的允许列表,就绝不可能让任何一条请求通过。一次都不可能。但它确实通过了。在允许列表只有那一行配置的那段时间里,无论持续了多久,都有某个东西从 Docker 网络内部抵达反向代理,携带着它自己的 Docker 网络地址,并且途中从未转换成公网地址。测试说这条路径不存在。系统自身的历史却表明,它一直都存在。
这个事实并非来自任何配置文件,也没有对应的日志记录,如今机器上没有任何内容记录第二个地址加入之前允许列表的样子。无法从服务器当前的状态推导出它——系统状态早已越过了那个阶段,而且已经变化了两次:第一次是添加地址,第二次是重启重新打乱了其上的一切。它只存在于一个地方:我构建这个系统时留下的记忆。而真正扭转调查方向的正是它——不是针对服务器执行的新检查,而是一个有关服务器的旧事实,一个服务器自身永远无法返回的事实。
AI 智能体直接读取了代理引擎自身的配置——不再根据普通客户端的行为进行推断,而是查看网关自己的软件实际被配置成了什么样——这就解决了问题。代理引擎为整个域名配置了一项 DNS 覆盖:在网关内部,该域名下的每个主机名都不会解析成互联网 DNS 返回的地址,而是直接解析到共享 Docker 网络中的一个固定地址。
这正是通用测试容器从未走过的路径。普通客户端通过公共 DNS 解析公共主机名,最终访问 VPS 的公网地址,与反向代理预期任何外部客户端采用的方式相同。而对于这个特定域名,网关根本不会查询公共 DNS——覆盖规则会首先给出答案,请求无需离开主机,直接发往 Docker 网络地址。一个从未离开主机的连接,不会被重写得像是来自主机自身的公网接口。它抵达反向代理时携带的是网关真实的 Docker 网络地址,而不是 VPS 的公网地址——这正是允许列表中另一行所针对的地址,也正是任意容器通过公共 DNS 绕远路发送请求时绝不可能产生的地址。
故障的另一半,从调查一开始就已经记录在案:无论是反向代理容器还是网关容器,都没有在任何地方固定静态地址。引发这一切的 Docker 守护进程重启,重新为这两个容器以及该网络中的所有其他容器分配了当时碰巧空闲的地址——结果一个与二者都无关的第三方容器,最终占用了代理引擎覆盖规则一直指向的那个地址。真正出问题的不是允许列表,不是反向代理,也不是覆盖规则本身,而是每次重启时,每个地址究竟指代哪个容器。
修复方案同时固定了两个地址:反向代理固定为 172.19.0.3,网关固定为 172.19.0.2,并分别写入各自的 compose 文件。第三个容器从覆盖规则所需的地址上移走,改用另一个空闲地址。三个容器都进行了重启,以应用这些变更。没有重启守护进程——再次重启它,正是这一切最初发生的原因。
验证时不能只相信 compose 文件,而是需要重现真实的请求路径,因此 AI 智能体正是这样做的:它从网关容器内部发出请求,沿着代理引擎覆盖规则实际采用的相同转发路径,直接访问反向代理已固定的 Docker 网络地址,并像网关自身一样绕过公共 DNS。请求成功了,反向代理自己的访问日志将来源记录为 172.19.0.2——也就是网关自己的地址,由整条链路中唯一一个无需猜测该地址含义的组件记录下来。
这一切——网关作为有意设计的入口、允许列表中的 Docker 网络地址一直用于网关,以及添加公网地址之前允许列表自身的历史——都不存在于机器上的任何文件中。compose 文件里没有,注释里没有,代理引擎自身的配置里也没有;后者只描述它做什么,却没有说明为什么这样做。AI 智能体掌握的只有服务器当前的状态,没有任何其他东西可供对照,而当前状态无法区分巧合与决策。两个东西恰好在同一时刻落到同一个地址上,与某个东西被有意固定在该地址上,看起来完全一样——直到一个亲历当时情况的人指出事实并非如此。
正因如此,才有可能固定错误的地址;也正因如此,这个错误的固定配置险些成为永久配置。AI 智能体自己的测试并不草率——它测量了真实存在的现象,并准确报告了结果。真正扭转调查方向的是:我有理由怀疑这个答案,因此拒绝接受它;我的依据超出了 AI 智能体证据所能触及的范围。随后,我提供了唯一一个比机器上任何日志都更久远的事实:在有理由添加第二行之前,允许列表究竟是什么样子。
如今,compose 文件已经包含固定地址。其背后的设计意图——为什么 172.19.0.2 属于网关,而不是属于最后碰巧占用它的任何容器——除了我的记忆之外,只存在于一个地方:一页关于这台主机的参考笔记。在这一切开始时,它还不存在。记录是在修复之后才产生的,因为正是这次修复促成了它。
如需采取进一步措施,你可以考虑屏蔽此人和/或举报滥用行为。