☰
网络排障实战:从DNS到MTU,一次Docker拉镜像故障的底层拆解
2026/10/9 3:16:37 网站建设 项目流程

一台服务器上docker pull一直报error response from daemon: Get "https://registry-1.docker.io/v2/": net/http...这类错误,我第一反应就是网络栈出了问题。排查了半天,最后发现既不是镜像源的问题,也不是 Docker 配置的问题,而是 DNS 解析和 MTU 分片叠加的连锁反应。这种经历多了之后,我越来越觉得,搞懂 DNS、HTTP、TCP/IP、ICMP、NAT/NAPT 这些基础知识,根本不是学院派的要求,而是每一个跟网络打交道的人绕不过去的生存技能。

这篇就是把我在实际排障中反复用到的知识点完整梳理一遍,从 DNS 解析原理到 HTTP 连接复用,从 ICMP 抓包到 NAT/NAPT 转换,每个环节都结合真实的故障场景来拆解。适合刚入行的运维、后端开发、嵌入式网络开发,也适合那些能跑通 demo 但遇到线上问题就抓瞎的朋友。

1. DNS解析:一切网络访问的起点,也是故障高发的第一站

DNS(Domain Name System)本质上就是个"电话簿",负责把人类好记的域名(比如registry-1.docker.io)翻译成机器能用的 IP 地址。整个解析过程虽然没有 TCP 握手那么复杂,但它的故障影响面往往比 TCP 层的问题更大——你连 IP 都拿不到,后续什么 HTTP、TCP 全都无从谈起。

1.1 一条域名从输入到解析成功的完整路径

当你在浏览器输入一个域名,或者 Docker 客户端试图连接registry-1.docker.io时,系统会先查本地 DNS 缓存(Windows 上是ipconfig /displaydns,Linux 上是systemd-resolved或nscd管理)。缓存没命中,才会走完整的递归查询链路:

  1. 请求发给本地 DNS 服务器:这个服务器通常是运营商分配的(DHCP 自动下发),也可能是你手动配置的114.114.114.114、8.8.8.8或公司内部的 DNS。
  2. 本地 DNS 做递归查询:它先问根域名服务器".io的权威服务器是谁",再问.io权威服务器"docker.io的权威服务器是谁",最后问docker.io的权威服务器"registry-1.docker.io的 A 记录是什么"。
  3. 拿到结果并缓存:本地 DNS 收到 A 记录后,会按 TTL(Time To Live)缓存一段时间,同时把结果返回给你的机器,你的机器也会缓存一份。

这里有个大家容易忽略的点:DNS 查询默认走 UDP 端口 53,单个 UDP 报文最多承载 512 字节(去掉 DNS 头部后实际可用更少)。如果响应数据太大,DNS 服务器会设置 TC(Truncation)标志位,客户端收到后需要改用 TCP 53 重发查询。我在排查 Docker 拉镜像失败时,就见过因为防火墙只放行了 UDP 53 而没放行 TCP 53,导致 DNSSEC 启用后大响应全部被丢弃的情况。

1.2 实测中最容易翻车的两类 DNS 问题

第一类是 DNS 缓存污染或缓存过期不一致。比如你改了 /etc/resolv.conf 之后,明明配置的是新 DNS,但systemd-resolved还在用之前缓存的旧记录。遇到这种情况,别急着怪网络,先sudo systemd-resolve --flush-caches清一下缓存,或者直接dig @8.8.8.8 registry-1.docker.io绕过本地 DNS 验证权威结果是啥。

第二类是"假 DNS"问题。有些内网环境为了安全,会部署 DNS 拦截设备,对特定域名返回一个黑洞 IP。这类问题最阴险的地方在于:ping域名能通,Telnet IP 的 443 端口也通,但就是业务报错。因为那个 IP 是设备虚拟出来的,只对特定协议放行。

我个人的习惯是三步定位:

# 第一步:确认系统拿到的是哪个DNS、能不能解析 cat /etc/resolv.conf nslookup registry-1.docker.io # 第二步:跳过系统配置,直接问公共DNS,排除中间链路问题 dig @8.8.8.8 registry-1.docker.io A +short # 第三步:抓包看本机发出的DNS请求是否真的到达了服务器 sudo tcpdump -i eth0 -nn port 53

这三步走完,DNS 有没有问题基本可以盖棺定论了。多说一句,dig里的+trace参数也很实用,可以完整跟踪从根到权威的整个链路,特别适合排查"权威服务器上记录明明是好的,但客户端就是解析出旧 IP"这种诡异问题。

2. TCP/IP与HTTP:分层模型不是考试题,是排障地图

很多人把 TCP/IP 五层模型背得滚瓜烂熟,但遇到真实故障就懵。原因很简单:你背的是概念,没建立"每一层出了问题,外在表现是什么"的行为映射。我排障时习惯反向走:先看应用层报什么错,再推断是传输层还是网络层的问题。

2.1 HTTP报文在TCP/IP各层的"封装-传递-解封装"之旅

拿一次典型的HTTP GET请求来说,数据从你发起到服务器收到,要经历这样的过程:

  1. 应用层(HTTP):浏览器构造请求报文,包括请求行(GET /v2/ HTTP/1.1)、请求头(Host、User-Agent、Authorization 等)、请求体。这段数据对 TCP 来说就是一堆字节流。
  2. 传输层(TCP):把 HTTP 报文切成合适的段(Segment),加上源端口、目的端口(默认 443)、序列号、确认号。这里的关键是端口——它决定了数据到达服务器后交给哪个进程。
  3. 网络层(IP):给每个 TCP 段加上源 IP、目的 IP,封装成 IP 报文(Packet)。这里要做路由选择,决定从哪张网卡出去、下一跳是谁。
  4. 数据链路层(以太网):把 IP 报文封装成以太网帧(Frame),加上源 MAC、目的 MAC。这里有个核心问题:目的 IP 和目的 MAC 不是一回事,要先通过 ARP 拿到下一跳设备的 MAC 地址。
  5. 物理层:把帧变成电信号或光信号发出去。

数据到达服务器后,从链路层一路解封装回应用层,然后处理完请求再按同样的方式封装回去。理解这个封装链路最大的价值在于:当抓包看到 TCP 三次握手都成功了,但 HTTP 请求迟迟没有响应,问题一定不出在网络层,而在应用层自己的逻辑里。这个判断方法能帮你砍掉一多半无关排查方向。

2.2 HTTP连接复用:为什么 Keep-Alive 能把性能拉满

HTTP 早期版本是"请求一次,断开一次",每个请求都要经历完整的 TCP 三次握手和四次挥手。这个设计在网页只有简单文本的时代无所谓,但现在一个页面动辄几十个资源,如果每个资源都新建连接,光握手开销就够受的。

于是出现了 Keep-Alive(HTTP/1.1 默认开启),让多个请求复用同一个 TCP 连接。HTTP/2 更进一步,引入了多路复用(Multiplexing),在一个连接上并发交错多个请求,从根上解决了 HTTP/1.1 的队头阻塞问题。

但连接复用有个坑:连接不一定永远可用。服务器可能因为空闲超时主动断开,中间设备(防火墙、NAT网关)也可能把长期空闲的连接会话从状态表里踢掉。客户端拿着一个"看起来还活着"的连接发请求,结果服务器根本没收到。Docker 守护进程偶尔报net/http: HTTP/1.x transport connection broken就是这个原因。

我在写 HTTP 客户端代码时(不管是 Go 的http.Client、Python 的requests.Session还是 C# 的HttpClient),都会强制设置传输层的连接复用参数:

// Go 标准库示例 transport := &http.Transport{ MaxIdleConns: 100, MaxIdleConnsPerHost: 10, IdleConnTimeout: 30 * time.Second, TLSHandshakeTimeout: 10 * time.Second, ResponseHeaderTimeout: 10 * time.Second, } client := &http.Client{Transport: transport}

这些参数背后是对 TCP 连接生命周期管理的理解:连接空闲超过IdleConnTimeout就主动关闭,避免使用失效连接;设置握手超时避免服务器不响应时无限等待。分享一个真实案例:我之前排查过一个 Java 接口偶发超时的问题,抓包发现客户端和服务器之间的 NAT 设备把空闲 60 秒的连接标记失效了,而 Java 端的HttpClient默认连接池保持时间是 120 秒。客户端检测不到连接已死,请求发出去后 NAT 直接回了RST,接口随即超时。后来把连接池保持时间调到了 30 秒,问题消失。

2.3 TCP三次握手与四次挥手的本质,以及被问烂的 TIME_WAIT

三次握手(SYN → SYN+ACK → ACK)的本质是"双方确认各自的发送和接收能力都正常"。很多人只记了发什么包,没理解一个关键点:第三次握手 (ACK) 可以携带数据吗?可以。因为此刻客户端已经确认服务器的收发能力正常,理论上可以立即在 ACK 里带上 HTTP 请求,省一个 RTT。但实际中极少有实现这么做,因为 HTTP 请求过程往往先做 TLS 握手,优化空间被 TLS 层吃掉了。

四次挥手(FIN → ACK → FIN → ACK)之所以"四次",是因为 TCP 是全双工的:每一侧都要独立关闭自己的发送通道。收到对方的 FIN 只代表对方不再发数据,你还可以继续发数据(这叫半关闭)。这就衍生出一个经典问题:为什么要 TIME_WAIT?

TIME_WAIT 的是主动关闭方在收到对方 FIN 后,保持 2MSL(Maximum Segment Lifetime,最长报文段寿命,通常 2 分钟)状态。两个作用:

  • 确保最后一次 ACK 能到对方手中。如果 ACK 丢了,对方重发 FIN,发送方还能再回一个 ACK。
  • 让旧连接的延迟报文在网络中自然消失,避免干扰使用相同四元组的新连接。

实践中的问题是:高并发短连接场景(比如服务端频繁主动关闭,或客户端短轮询),服务器上会堆积大量 TIME_WAIT 连接。我在调优时一般这样处理:

# 查看当前连接状态分布 ss -ant | awk '{print $1}' | sort | uniq -c # sysctl 参数配置(谨慎修改,先理解再动手) net.ipv4.tcp_tw_reuse = 1 # 仅对客户端有效,允许复用TIME_WAIT连接 net.ipv4.tcp_fin_timeout = 30 # 降低FIN-WAIT-2状态的等待时间

这里必须提醒一下:tcp_tw_recycle这个参数在 Linux 4.12 之后已经被移除了,而且它本来就有个毛病——同一个 NAT 后面如果有多个不同时间戳的设备,会导致连接被随机丢弃。网上很多老教程还在推荐,我只能说:别用。

3. ICMP协议:网络不可达时的"侦察兵"与抓包实操

如果说 TCP/UDP 是"运输队",ICMP(Internet Control Message Protocol)就是"侦察兵和报告员"。它不传输业务数据,专门负责报告网络状态和错误信息。ping命令用的就是它。

3.1 回显请求与回显应答:ping 通不代表网络好

ping发送的是 ICMP Echo Request(Type 8),目标主机正常情况下会回 ICMP Echo Reply(Type 0)。很多人把"能 ping 通"等同于"网络没问题",这是天大的误解。我至少碰到过三种"ping 不通但业务正常"和"ping 通但业务异常"的案例:

  • 目标主机禁 ping(防火墙丢弃 ICMP),但 TCP 443 端口正常开放。
  • ICMP 被中间设备限速,丢包率看着很高,但实际 TCP 数据传输非常稳定。
  • 防火墙只放行了 ICMP,TCP 端口全被拦。

所以我的经验是:ping 适合探测主机存活性,不适合判断端口可用性。要确认某个端口是否从服务端可达,用nc -zv 192.168.1.10 443或telnet 192.168.1.10 443更可靠。

3.2 科来抓包演示:如何用 ICMP 包定位 MTU 问题

ICMP 里最容易被忽视但实战价值极高的是ICMP 分片错误报文(Destination Unreachable, Fragmentation Needed, Type 3 Code 4)。它专门用来告诉发送方:你的包太大了,超过了路径 MTU,而且我不允许分片,请缩小包大小重发。

我当时排查 Docker 镜像拉取失败时就是这么定位到 MTU 问题的:

# 用带禁止分片标志的大包测试路径MTU ping -M do -s 1472 192.168.1.1 ping -M do -s 1400 192.168.1.1

这里-s 1472的意思是 ICMP 负载 1472 字节,加上 20 字节 IP 头加 8 字节 ICMP 头,正好 1500 字节标准 MTU。如果 ping 不通,说明路径上某个链路 MTU 小于 1500。把-s逐步降到 1400、1300,直到 ping 通,就能算出实际可用 MTU。

城域网或 VXLAN 场景下 MTU 经常是 1450 或 1400。当 Docker 要跟外部 registry 通信,发出的 TCP 段很大,交换机不回 ICMP 分片错误(很多设备为了安全会过滤 ICMP),发送方就一直重传,表现为"连接建立成功但数据一动不动"。把 Docker 默认网桥的 MTU 改成 1400 后问题立刻消失:

# /etc/docker/daemon.json { "mtu": 1400 }

用科来这类图形化抓包工具时,注意看 ICMP 报文的 Type 和 Code 字段,以及触发 ICMP 错误的"原始报文头"——它会带上导致错误的原始 IP 头信息,方便你反查是哪条流的包触发的。命令行党可以看这里:

sudo tcpdump -i eth0 -nn "icmp[icmptype] == icmp-unreach"

留意frag-needed相关的包,出现它就是路径 MTU 黑洞的实锤。

4. NAT/NAPT:让私有IP上网的"翻译官",以及它的隐形成本

IPv4 地址数量不够用,NAT 就承担了"一个公网 IP 背后挂一整个内网"的角色。NAT 和 NAPT 的区别很多人分不清:NAT 只翻译 IP,NAPT 还要翻译端口(即 PAT)。实际家用路由器用的几乎都是 NAPT。

4.1 数据包进出 NAT 时的"五元组翻译"

假设内网主机192.168.1.100:12345要访问registry-1.docker.io:443。在 NAT 网关上,这张表会记录:

原始五元组转换后五元组
src 192.168.1.100:12345, dst 8.8.8.8:443src 公网IP:54321, dst 8.8.8.8:443

出方向:替换源 IP 和源端口。响应回来时:根据会话表反查,把目的 IP 和端口换回192.168.1.100:12345。

这个翻译过程对终端是透明的,但 NAT 引入了一个关键问题:外部主动发起的连接进不来。因为没有预先的会话记录,NAT 网关不知道把公网 IP 的哪个端口映射到内网哪台机器。这就是我们需要端口映射(Port Forwarding)或者 UPnP 的原因。

4.2 NAT 超时、端口冲突与 TCP 连接复用那点事

NAT 会话表不是永久有效的,它有个老化时间。不同协议类型老化时间不同:TCP 通常 5 到 30 分钟,UDP 可能只有几十秒。这直接解释了前面提到的 HTTP 连接复用为何会失效——NAT 会话超时后,连接双方还以为是通的,但网关已经忘了这个映射。之后任何一方发数据包,网关要么丢弃,要么回 RST。

遇到这类问题,定位思路是先确认"连接断在哪里",用抓包看是否有 RST 或 ICMP 错误,再看 NAT 配置的老化时间。如果服务对长连接有硬需求(比如 IM 心跳),建议:

  • 客户端定时(小于 NAT 老化时间)发送心跳包保活;
  • 服务端 TCP KeepAlive 周期调短(Linux 默认 2 小时,对 NAT 场景偏长);
  • 实在不行就改走 WebSocket 或 MQTT 这类设计上天然支持长连接、自带心跳的协议。

另一个容易踩的坑是 NAT 端口冲突。NAPT 网关在分配公网端口时,如果网络内同时有大量短连接,会用尽端口,表现为"内网机器间歇性无法上网"。这时用netstat -s看 NAT 网关的端口统计,会看到大量端口分配失败计数。解决办法是缩短 TCP TIME_WAIT、增加公网端口池,或者使用较大的源端口范围。

NAT 类型也是背后要了解的常识——完整锥形、限制锥形、端口限制锥形、对称型。P2P 打洞能不能成功直接取决于 NAT 类型。对称型 NAT 基本无法打洞,只能靠中继。做音视频通话或游戏联机的同学,这个点要重点记录。

5. 把 DNS、TCP/IP、ICMP、NAT 串成一条排障链路

日常排障最容易犯的错,就是一上来就怀疑最外层业务,或者上来就抓包,抓完不知道怎么往下走。我总结了一条"自底向上+自顶向下交叉验证"的排查链路,每次都能快速收敛问题范围。

5.1 完整排障流程:从docker pull报错说起

回到开头的场景,docker pull报net/http: TLS handshake timeout或者是连接被 reset,我的排查顺序固定是这样:

  1. 看解析:nslookup registry-1.docker.io,如果解析出的 IP 是旧的或者解析超时,走 DNS 方向的排查。
  2. 看路由:ip route get 8.8.8.8,确认默认网关、出网接口对不对。多网卡机器经常栽在这里——路由表指错了出口。
  3. 看链路:ping -M do -s 1400 registry-1.docker.io或者对应的 IP,确认路径 MTU 是否够大。如果小包能过、大包过不了,极大的概率是 MTU 黑洞。
  4. 看端口:nc -zv registry-1.docker.io 443,确认 TCP 层能不能连通。这一步能过滤掉"目标主机本身挂了""防火墙拦了源 IP"等问题。
  5. 抓包看应用层:tcpdump -i eth0 -nn host registry-1.docker.io and tcp port 443 -w capture.pcap,重点看 TCP 握手之后的 HTTP/TLS 过程,如果握手都完成了但 TLS ClientHello 发出后一直收不到回应,问题很可能出在中间设备的 MSS 钳制或 NAT 会话老化上。

这套流程走下来,五个环节里基本能定位到至少四个环节的状态。真实的排障不是在单一层海里捞针,而是用不同工具交叉验证,把范围一点点缩小。

5.2 不同工具的定位视角差异

工具/命令观察的协议/层次最适合的场景
dig/nslookupDNS(应用层/UDP 53)域名解析相关所有问题
ping(ICMP)网络层主机存活、路径 MTU、基础连通性
tracerouteICMP/UDP 递增 TTL定位链路断点在哪里
nc/telnetTCP 传输层判断某个端口是否可达、服务是否监听
curl -vHTTP/HTTPS 应用层复现请求、观察 TLS 握手和响应头
tcpdump/Wireshark/科来全协议终极手段,看实际报文确认每一步
ss/netstatTCP 连接状态排查连接堆积、TIME_WAIT 过多、端口占用

这几个工具的定位差异清楚了,排障会更省力。比如curl -v显示Connected to ... port 443但之后没有响应,问题就锁死在 TLS 握手或代理层;相反如果curl报Could not resolve host,压根不用抓包,直接转 DNS 排查。

5.3 嵌入式场景与 Windows/Web 场景的额外注意点

热搜词里有 STM32 的 HTTP 库、C# 的 HTTP 客户端、WinForm 的 HTTP 实现,这些场景和服务器端排障有个显著差异:终点设备性能弱或系统裁剪严重,很多成熟的 TCP 优化在它们身上不可用。

  • STM32 这类 MCU 跑 HTTP 客户端时,TCP 缓冲区通常只有几 K,一次收不完完整的 HTTP 响应,就要靠应用层配合Content-Length分块读。如果服务器的响应里有Transfer-Encoding: chunked,还要手动解析分块边界,否则拿到的是半截数据。
  • 嵌入式系统经常没有完整的 DNS 客户端,需要自己在代码里实现或直接静态配置 IP + DNS。我见过不少"以太网通信不稳定"的问题,最后查出来是 DNS 服务器地址配错或 DNS 服务器不支持嵌入式设备发出的非递归查询。
  • Windows 下写 HTTP 客户端(不管是 .NET 的HttpClient还是 WinINet),要注意系统代理设置。默认情况下HttpClient会走系统代理,内网环境里这经常导致 "Access denied" 或连接被代理服务器接管,直接指定HttpClientHandler.UseProxy = false反而通了。

C# 的HttpClient还有一个"坑中之王":它实现了 IDisposable,但每次 new 一个出来用完就 Dispose 是错误用法。频繁创建会导致每次都新建 TCP 连接,还会把 TIME_WAIT 打满。正确的做法是整个应用生命周期里复用同一个HttpClient(或者用IHttpClientFactory),这个和前面讲的 HTTP 连接复用完全同一个道理。

6. 一些能帮你在关键时候"救命"的底层参数与内核调优建议

最后这部分,我分享几个压箱底的参数和排查技巧。它们不是天天用,但每次用到都是关键时刻。

6.1 TCP 内核参数里最值得记住的几个

# 查看当前所有TCP相关参数 sysctl -a | grep -E 'tcp_(keepalive|syn|retries|fin)' # 常用调优项(按需修改,生产环境要验证后再上线) net.ipv4.tcp_keepalive_time = 600 # 空闲多少秒开始探测,默认7200太长 net.ipv4.tcp_keepalive_intvl = 75 # 每次探测间隔 net.ipv4.tcp_keepalive_probes = 9 # 探测失败多少次判定连接死亡 net.ipv4.tcp_syn_retries = 3 # SYN重试次数,减少无效等待 net.ipv4.tcp_max_syn_backlog = 8192 # 半连接队列长度,抗突发连接 net.core.somaxconn = 4096 # 与listen()的backlog参数配合

keepalive_time从 7200 改到 600 的典型场景:服务器和下游设备之间有 NAT 超时约为 5 分钟(300 秒),如果 TCP 自身探测周期是 2 小时,中间 NAT 早就把会话删了,双方还互不知情。改小 keepalive 让内核在 10 分钟级别探测,比应用层心跳更可靠。

6.2 "抓包看不到 ICMP 分片错误"的排查思路

路径 MTU 黑洞的经典特征是:服务器有个大包一直重传(TCP 报文Len接近 1448),但从来没收到过 ICMP Type 3 Code 4。查证手法除了前面ping -M do之外,还可以这样收敛:

# 看当前TCP连接的发送段大小(MSS) ip route get 1.2.3.4 # 输出里通常会带 mtu 信息,如果接近1500但中途设备不支持,就要考虑手动钳制 # 手工收紧路由MTU(临时测试) ip route add 1.2.3.0/24 via 192.168.1.1 mtu 1400

如果确认是中间设备的策略问题,与其费劲让交换机放行 ICMP,不如直接在出口网卡上把 MSS 钳住。Linux 上 iptables 修改 SYN 包的 MSS 选项是常规操作:

iptables -t mangle -A FORWARD -p tcp --tcp-flags SYN,RST SYN -j TCPMSS --clamp-mss-to-pmtu

--clamp-mss-to-pmtu会自动把 MSS 设为当前路由 MTU 减去 40 字节,简单有效,推荐优先使用。不过现在很多新版内核直接用nftables,用法稍不同但思路一致。

6.3 HTTP Content-Type 的一个实用心法

很多人一遇到 HTTP 接口报错,第一反应就是查后端代码,但我遇到的真实案例里,有相当一部分是Content-Type没配对。上传文件用到了multipart/form-data,发 JSON 用了application/x-www-form-urlencoded而 Key 里又有中文字符,结果服务端解析失败。

判断标准很简单:

  • 只传简单键值对:application/x-www-form-urlencoded
  • 传递嵌套结构或数组:application/json; charset=utf-8
  • 上传文件:multipart/form-data; boundary=...(不需要手动指定 boundary,框架会自动生成)

如果服务端是你自己写的,解析时建议先用curl -i看响应头里的Content-Type,再决定用Request.ParseForm()还是Request.Form["..."]还是直接读 Body。这个不用抓包也能解决,但理解了 HTTP 报文结构,你才知道Content-Type只是头字段之一,跟报文体的编码方式配合起来才能正确解出数据。

结尾

这些知识单独看都不算新鲜,但把它们串成一个完整的排障链路后,价值就完全不一样了。我自己从"遇到网络错误就靠重启大法"到"能在几分钟之内判断问题出在 DNS、TCP、MTU 还是 NAT 会话",靠的就是把每一个协议层的行为表现和常见故障点记在心里。

再分享一个小技巧:建议你平时排查问题时,养成一个习惯——每定位一个问题,就在本子上画一下"这个问题发生在哪一层,它的前后层是什么"。画得多了,网络栈在你脑海里就不再是课本上的分层模型,而是一张有血有肉的地图。下次再遇到docker pull超时、Access denied、net/http: connection broken这类问题的时候,你就知道该从哪里下手,而不是对着屏幕干瞪眼了。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询