Docker MTU不匹配引发大包超时:一次完整的网络排障复盘
那几天我们内部群最常说的一句话是OpenClaw 又沉默了。这个服务在我们这边扮演的是自动回复助手的角色外部系统通过 HTTP 把问题发进来它调用模型接口生成回复再把文本返回给调用方。它跑在 Docker 容器里前置一个反向代理老老实实部署了大半年一直很安静。直到有一天调用方开始频繁报超时而我们把所有配置翻了个底朝天端口没错、密钥没错、反代路径没错、日志也显示它已经生成了完整回复——可调用方就是等不到那串响应。这篇文章就是那次排障的完整复盘。我会按时间顺序讲清楚为什么一开始会被“所有配置都对”的表象骗住又是在哪一步从应用层排查切换到了网络抓包最后怎么定位到 Docker 网桥 MTU 与底层链路不一致这个根因以及修复之后做了哪些配套加固。如果你也遇到过“配置全对但服务不回包”这种案子这篇应该能帮你省下不少时间。1. 故障现象不是彻底挂掉而是“大回复必超时重启后短暂自愈”先把故障现象写清楚因为现象本身就已经透露了大半线索只是当时我没读懂。1.1 我们的调用拓扑反向代理 - Docker 容器 - 上游模型接口先交代一下环境。OpenClaw 是一台云主机上的容器服务对外通过开源反向代理Nginx 这类软件暴露 80/443 端口。调用方主要分两类一类是定时任务系统每天固定发一批结构化请求另一类是内部聊天机器人里的一个指令入口用户触发后向 OpenClaw 提问并等待文本回复。数据流大致是调用方 - 反向代理80/443- docker0 网桥 - OpenClaw 容器8080- 模型接口链路其实很短当时我还觉得“这么简单还能有什么网络问题”。这个想法后来被现实狠狠教育了一下越是看起来简单的链路出问题时越容易让人忽略网络本身。1.2 症状记录从小请求秒回到大请求稳定超时第一天只是零星超时。请求一个几百字的回复客户端 60 秒超时一次重试就能成功。翻 OpenClaw 日志它明明在 30 秒内已经生成了完整回复所以我第一反应是“模型生成慢”直接把调用方的超时从 60 秒调到了 120 秒。第二天情况开始恶化。短回复一切正常一两秒就返回但只要请求 2000 字以上的长文绝大多数请求会卡住直到超时只有偶尔碰巧成功。更迷惑的是重启 OpenClaw 容器之后前五到十分钟一切恢复正常长文也能秒回然后症状又慢慢回来。这个“重启自愈”的现象差点把整个排查方向带进沟里。我当时一度认为是服务进程在累积什么问题开始查内存、连接数、文件描述符和线程池结果全部正常。现在回头看“重启后短暂恢复、随后再次超时”其实是网络链路上“大包持续被丢、TCP 重传堆积”这类问题很典型的表现只是当时没有往这个方向想。1.3 为什么最初完全没怀疑网络层开头的半天时间被浪费根源在于我默认排除了网络问题而且理由看起来非常充分日志显示请求进来了上游模型接口也返回了OpenClaw 把自己生成的响应完整打印到了日志里反向代理日志也显示它已经接上了 OpenClaw 的 8080 端口只是迟迟读不到响应。应用层一切正常“能收到请求却发不出响应”给人的直觉是进程卡住了是应用层问题。但实际上这里有个容易被忽略的认知盲区日志只能告诉你进程把数据交给了内核之后数据包在网络里发生了什么日志一概不知。这个认知是我后来切换排查思路的起点也是整篇文章里我最想先讲出来的体会。2. 配置核查全部通过为什么仍然超时——第一轮排查复盘确定不是应用进程问题之后我做了一件最顺手的事把 OpenClaw 相关的配置从头到尾过了一遍。结果和标题写的一模一样每一样都正确。2.1 配置清单端口、密钥、路由、安全组全对先列一张我当时逐项核查过的表各位读者可以对照自己的环境配置项期望值实测结果端口映射容器 8080 映射到宿主机 8080ss -ltnp确认容器监听 8080宿主机 curl 127.0.0.1:8080 能通正常反向代理/api/ 前缀转发到 openclaw:8080反代日志能看到请求精确落到 openclaw:8080正常API Key / TokenOpenClaw 配置里的 Token 与调用方一致用该 Token 直接 curl OpenClaw返回 200正常DNS 解析容器名 openclaw 解析到容器 IPdocker exec openclaw getent hosts openclaw返回正确正常防火墙 / 安全组80/443/8080 对外放行从调用方机器 telnet 三个端口都通正常超时配置调用方 120s、反代 60s、OpenClaw 到模型 90s三处配置读下来逻辑自洽正常日志级别debug能打印完整请求与响应正常这张表看起来无懈可击。每一个配置项单独拿出来都是对的组合起来也符合预期。但“每个配置项都对”和“一条完整链路能通”之间还隔着很远。这轮检查证明的只是“配置没有写错”它并没有证明“一个特定大小的请求能从 A 完整走到 B再让响应完整走回来”。2.2 两端的日志能对齐唯独响应“消失”我又把同一时间的日志拉出来做了一次三方对比用同一个 request_id 去对齐调用方、反向代理、OpenClaw 三边的记录OpenClaw 访问日志请求进来经过约 28 秒模型调用成功拿到 3.8KB 的回复文本debug 日志里能看到文本末尾。反向代理访问日志请求转发到了 openclaw:8080上游连接建立然后一直等待响应最终触发读取超时。调用方日志连接建立成功、发送成功、接收卡死最终 read timed out。如果 OpenClaw 已经把完整回复生成出来了那它一定已经把这串文字交给了内核去发送再往后就是网络传输的范畴。这个推断其实当时已经摆在面前但我仍然不敢确认——总觉得“网络传输不至于把这么大一个包丢了吧”于是又花了几小时去查防火墙规则、查内核参数、查容器网络模式全都一无所获。2.3 第一轮排查的盲区只检查“配置”没验证“传送”事后复盘第一轮排查的本质是“拿着静态配置做逻辑推理”。配置文件是描述性的它描述了系统应该长什么样但故障发生在动态的数据通道里。配置正确最多只能说明“这道门没有锁错”但不能证明“货物一定能搬过去”。真正能证明链路连通性的只有让数据包实际走一遍并且在不同大小、不同路径点上观察它的行为。想明白这一点后我决定不再读任何配置开始做实验。3. 关键拐点从“看日志”切换到“看数据包”MTU 黑洞现形当我开始用实验代替读配置问题定位的速度一下子快了起来。3.1 对照实验把响应体大小当成唯一变量第一个实验很朴素同一台调用方机器、同一个 Token、同一个 OpenClaw 地址分别发小请求和大请求记录耗时与成功率。# 小请求只要求一句简短回复 curl -s -o /dev/null -w HTTP %{http_code} time%{time_total}s size%{size_download}\n \ -H Authorization: Bearer token \ -d {query:你好} \ https://openclaw.example/api/reply # 大请求请求一个 2000 字以上的长文档 curl -s -o /dev/null -w HTTP %{http_code} time%{time_total}s size%{size_download}\n \ -H Authorization: Bearer token \ -d {query:请用2000字介绍...} \ https://openclaw.example/api/reply结果非常有规律小请求全部在 2 秒内返回大请求绝大多数卡到超时极少数成功的一次下载体积也明显偏小。再把请求体固定为极小内容、只把要求的回复字数调大超时依然稳定复现。也就是说影响成功率的核心变量不是请求体大小、不是路径、不是 Token而是“响应体有多大”。到这一步MTU 的嫌疑已经升到第一位。因为只有网络链路对“包大小”敏感应用层很少会因为“回复文本超过多少字节”而把整个请求卡死到超时。一个 HTTP 响应分成多个 TCP 段传输某个段超过链路能力被丢掉接收方永远等不齐整个响应表现就是连接超时。3.2 两端同时抓包超过某个长度后对端不再确认我分别在容器内和宿主机上同时抓包用同一个大请求做对照。命令并不复杂# 在宿主机上抓两个网口 sudo tcpdump -i docker0 -w /tmp/docker0.pcap host 容器IP and tcp port 8080 sudo tcpdump -i eth0 -w /tmp/eth0.pcap host 容器IP and tcp port 8080 # 进入容器抓容器内的 eth0 docker exec openclaw tcpdump -i eth0 -w /tmp/container.pcap host 反向代理IP and tcp port 8080抓到的现象非常明确容器内和 docker0 上都能看到 OpenClaw 把很大的 TCP 段交给了网卡发出之后源端开始等待对端 ACK随后这些大包触发 TCP 重传重传的包同样有去无回。而对端反向代理的抓包里只能看到小于某个长度的一批包超过阈值的数据包从未到达。我把 pcap 按 TCP segment length 排了个序发现一个近乎硬切的分界能顺利到达对端的包大部分字节长度在 1400 以下而所有长度在 1500 附近的包几乎全部失踪。这个规律已经很清晰了——不是偶发丢包是“尺寸到了某个线就被丢”。3.3 ping 大包实验把 MTU 嫌疑变成实锤抓包给了方向还需要一个独立且更简单的验证。我用了标准的 MTU 探测法ping 指定不分片-M do依次增大包体看宿主机到对端路径上能承载的最大包是多少。# 1472 28 1500标准以太网 MTU 的最大 ICMP 载荷 ping -M do -s 1472 -c 3 对端IP # 1372 28 1400 ping -M do -s 1372 -c 3 对端IP # 1322 28 1350 ping -M do -s 1322 -c 3 对端IP测试结果是1472 字节的包全丢1372 字节的包正常1322 字节的包也正常。也就是说这条链路实际上只能稳定承载约 1400 字节的 IP 包远低于大多数人默认的 1500。配合抓包里“超过 1400 就消失”的现象根因已经非常明确不是应用没回复而是“回包太大链路装不下”。4. 根因拆解Docker 网桥 MTU 和下层链路不一致TCP 为什么会傻等到这里“为什么配置全对却超时”的全貌基本浮出水面。下面从原理层面拆开讲顺便说说 TCP 为什么不自动规避这个坑。4.1 MTU 是什么用快递类比一次讲透MTU 是网络接口单次能承载的最大 IP 包大小以太网最常见的是 1500 字节。注意 ping 命令的-s参数指定的是 ICMP 载荷大小实际 IP 包还要加上 20 字节 IP 头和 8 字节 ICMP 头所以想测 1500 字节的包要写-s 1472。路径上的每一跳都有自己的 MTU整条链路能传的最大包由最小的那一跳决定。这就像搬运货物时“最窄的走廊”决定了箱子尺寸。当一个超过路径上限的包出现时按标准流程中间设备应该向发送方回一个 ICMP 消息告诉它“把包改小”。但很多云网络和中间安全设备会屏蔽这类 ICMP或者干脆静默丢包。TCP 为了避免 IP 层分片带来的混乱默认会在包里设置 DFDont Fragment不分片标志。于是发送方根本不知道自己的包已经被丢了只能不断重传重传又继续被丢一直重传到应用层的读取超时被触发。这就是“配置都对但就是超时”的最典型网络成因。4.2 为什么 Docker 默认 1500 会在云主机上出事Docker 在创建 docker0 网桥和 veth 虚拟网卡时默认 MTU 是 1500。如果宿主机物理网卡也是 1500但底层云网络因为要承载虚拟化封装实际只能传 1400 或 1450 的包矛盾就出现了。完整链条是这样的容器内进程生成约 1500 字节的 TCP 段交给 veth 虚拟网卡。veth 和 docker0 都按 1500 放行把包转发到宿主机物理出口。物理网卡把包发出去底层网络设备因为包太大直接丢弃并且不返回 ICMP 通知。容器侧 TCP 一直等不到 ACK开始重传重传照样被丢。反向代理读不到完整响应直到超时。这个坑之所以隐蔽是因为它完全绕过了应用层和配置层进程、端口、Token、防火墙全部正常唯独链路尺寸不匹配。而且症状天然带着“大小敏感性”——小回复刚好能装进链路允许的尺寸就顺利大回复必须拆成多个 TCP 段其中某些段超过链路能力于是表现成“回复越长越容易超时”。4.3 事后确认看 MTU 数值和抓包证据而不是靠感觉实锤之后我在机器上按顺序收集了下面这些证据后续遇到同类问题可以直接复用# 看物理网卡与 docker0 的 MTU ip link show eth0 ip link show docker0 # 进入容器看容器内网卡 MTU docker exec openclaw ip link show eth0 # 看 Docker 网络实际配置 docker network inspect bridge --format {{json .Options}}当时的结果是物理网卡和底层路径实际能承载约 1400而 docker0 和容器内 veth 都是 1500。中间那约 100 字节的差距就是所有超时的来源。换句话说OpenClaw 从头到尾都没有“不回复”它一直在回复只是回复的某些包裹在运输途中因为超宽被扔掉了。5. 最终修复与日常加固MTU 统一、监控补位、超时分层定位到根因之后修复本身很快但真正值钱的是接下来一系列加固动作。5.1 把 Docker 的 MTU 改成与底层链路一致并重建容器我选择把 Docker 的网络 MTU 统一设置为 1400。修改/etc/docker/daemon.json{ mtu: 1400 }然后重启 Docker 并重建容器。这里有个容易踩的坑daemon.json 里的 mtu 只影响之后创建的容器网络已经存在的网桥和容器必须重建才会生效。执行sudo systemctl restart docker docker compose down docker compose up -d如果用的是 docker compose也可以在 compose 文件的网络段显式指定networks: default: driver_opts: com.docker.network.driver.mtu: 1400重建后立即做两组验证第一进容器看ip link show eth0的 MTU 是否为 1400第二从宿主机 ping 对端再用同一组 curl 大请求测试。我当时连续发了 50 个 2000 字以上的长文档请求全部成功耗时也回到正常范围。确认无误后才把容器切回生产流量。为什么不选 1450 而是选 1400因为实测链路对 1372 的包稳定、对 1472 的包不稳定。选 1400 等于在能用的最大值下面多留几十字节余量避免各种封装头变化时再次踩线。除非你能确认链路稳定支持 1450否则宁可保守一点。5.2 把“大小包探活”和“响应大小监控”补进告警系统这次故障给我的教训之一是光有“端口能通”的探活远远不够。端口能通不代表大包能通。我在监控里加了两个维度的探活小请求探活每分钟发一句简单请求确认 OpenClaw 基础可用。大请求探活每五分钟请求一次 1500 字以上的长文回复一旦连续两次超时就告警。这个探活专门用来捕捉 MTU 黑洞、链路限速这类“只对大数据敏感”的网络问题。脚本逻辑大概是这样的def check_openclaw(payload: str) - bool: try: resp requests.post( https://openclaw.example/api/reply, json{query: payload}, headers{Authorization: fBearer {token}}, timeout30, ) return resp.status_code 200 and len(resp.content) 0 except requests.Timeout: return False另外数据包层面的 TCP 重传率也进入了日常观察项。当监控面板显示重传率突增、尤其是同一批包反复重传时第一时间就该怀疑 MTU 黑洞而不是又在应用层翻来找去。5.3 顺带把超时链路上的三层参数重新分层排查过程中还发现三层超时配置虽然各自“合理”但彼此之间缺乏明显冗余。原来的配置是OpenClaw 调用模型接口 90 秒反向代理等待上游响应 60 秒调用方等反向代理 60 秒。一旦模型生成耗时超过 60 秒反向代理先掐断连接调用方重试又带来额外的堆积请求非常容易形成排队雪崩。修复后的分层原则是每一层内层的超时都要明显小于外层超时并留出至少 30% 的余量。我最终调成了这样层级修复前修复后说明模型接口90s90s根据模型实际生成时间定的上限反向代理等待上游60s110s必须大于模型接口耗时调用方总超时60s150s必须大于反向代理等待时间三层递增看起来是个小调整但在慢请求场景下它决定了“一次生成失败会不会演变成整个调用方超时雪崩”。配合客户端在读取超时后进行有限重试整体稳定性提升很明显。6. 给同样被“配置都对”坑过的人一张超时排障行动清单最后把这次排障过程压缩成一张可以照着做的清单。下次再遇到“配置都对但超时”按这个顺序执行大概率能少走弯路。6.1 可复制的排查步骤从现象到根因的执行顺序步骤操作目的1确认是偶发还是稳定小请求与大请求是否表现不同把“大小敏感”这个关键特征提炼出来2调用方、反向代理、服务端日志按 request_id 对齐确认问题到底发生在哪一跳之后3用 curl 控制变量同地址、同 Token只改回复大小判断是否是应用层参数导致4容器内、宿主机、入口同时抓包观察数据包在哪一段消失5ping -M do 递增包体做 MTU 探测快速定位链路能承载的最大包长6检查各网卡 MTU 与 Docker 网络 MTU确认容器网络与物理链路是否一致7修改 MTU 并验证先不上生产用大量大请求验证修复效果8补监控、调超时分层防止同类问题回归6.2 这次故障里我学到的三条判断准则第一条日志对齐只能证明“两端应用各看到了什么”不能证明“中间的链路传送了什么”。看到回包从进程里消失下一步应该去抓包而不是继续读配置文件。第二条所有“配置都对”的结论都必须接受“数据包能不能走通”的检验。配置是静态描述故障是动态行为两者不能互相替代。第三条当症状带有“小请求行、大请求不行”或“间歇性超时、重启短暂恢复”这类特征时MTU 相关问题是第一候选而不是最后一个。这三条说起来简单但当时是用被浪费的一整天换来的。6.3 比排障更值钱的建议把 MTU 这个知识点变成团队常识这次之后我做了三件小事把 MTU 探测命令写进了值班手册在新建云主机和容器的初始化脚本里强制检查物理网卡 MTU 与 Docker 默认 MTU 的差距不一致就直接告警容器栈的上线检查项里增加“大包连通性测试”用ping -M do -s 1372 对端IP这类命令作为冒烟测试的一部分。另外我也建议同事写探活脚本时别再只发小请求了带一个较大 body 的探活能提前暴露一大类链路问题。这次排障给我最大的感受是真正难的不是查不出问题而是被“配置都对”这层表象拖住了太久。OpenClaw 它一直在正常回复只是我看日志、看端口、看防火墙唯独没有去看数据包本身。把视角从“配置”切到“传送”之后剩下的事情反而快得出奇。