网络心智模型:从一个包的"一生"讲起
不讲完整网络教科书。讲应用开发 / SRE 最需要的 30% 原理——学完后再看
ip route/iptables/tcpdump输出,每个数字你都能告诉自己"它是啥、为啥这样"。本篇配套使用:01-output-reading.md 看懂输出 / 02-namespaces.md 容器底层 / 05-troubleshooting.md 故障排查。
这篇要回答什么
- 浏览器打开 https://example.com,到底发生了什么?
ip route输出每一行到底什么意思?- 同网段为啥"自动通"、跨网段为啥要网关?
- NAT 是什么、SNAT / DNAT / MASQUERADE 三者怎么区分?
- 为什么 K8s ClusterIP 不能 ping 通、但
curl通? CLOSE_WAIT堆积是哪一端的 bug?
- 跨节点 pod 偶发性丢包——从哪一层开始查?
- 应用一开始正常、跑半小时后慢——什么原因?
- 集群升级后部分节点 NotReady——网络层面查什么?
学完这篇,每一个问题你都有方向。
1. 一个包从浏览器到 example.com 的"一生"
完整流程(保存到脑子里):
sequenceDiagram
autonumber
participant U as 你 (192.168.1.5)
participant DNS as DNS (8.8.8.8)
participant ARP as ARP 模块
participant GW as 家路由器 (192.168.1.1)<br>公网 IP: 1.2.3.4
participant ISP as 运营商网关
participant SRV as example.com<br>(93.184.215.14)
Note over U: 浏览器: 我想访问 example.com
U->>DNS: A 记录查询: example.com?
DNS-->>U: 93.184.215.14
Note over U: 路由决策: 93.184.215.14<br>不在我的子网 → 走默认网关
U->>ARP: 192.168.1.1 的 MAC?
ARP-->>U: aa:bb:cc:dd:ee:ff
Note over U: 构造包: src=192.168.1.5<br>dst=93.184.215.14<br>L2: dst MAC = aa:bb:cc:..
U->>GW: 发送
Note over GW: SNAT: 改 src 为 1.2.3.4<br>记入 conntrack 表
GW->>ISP: 转发
ISP->>SRV: 经多跳路由
SRV-->>ISP: 回包 src=93.184.. dst=1.2.3.4
ISP-->>GW: 回包
Note over GW: 查 conntrack:<br>1.2.3.4:54321 ↔ 192.168.1.5:54321
Note over GW: 改 dst 回 192.168.1.5
GW-->>U: 包到达
每一步都对应一个命令 + 一段配置。下面拆开每一步。
关键 take-away
包在你电脑这边:
- IP 头不变(除非中间有 NAT 改写)
- MAC 头每跳一次换一次(每个路由器重写)
- 源 IP / 源 port 在 NAT 时被改写
- conntrack 表记着"谁连出去过",回包能找路
这 4 句话理解了,下面所有内容都好懂。
2. MAC vs IP —— 最容易混的两个概念
MAC 地址(第 2 层 / 链路层 / L2)
- 网卡硬件烧死的地址:
aa:bb:cc:dd:ee:ff(48 位 / 12 hex) - 只在同一个广播域内有意义(一个交换机连的所有口、一个 VLAN)
- 跨网段时被每跳路由器重写
$ ip link show eth0
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq state UP mode DEFAULT
link/ether aa:bb:cc:dd:ee:ff brd ff:ff:ff:ff:ff:ff
# ^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^
# (1) 本机 MAC (2) 广播地址 (全 F)
字段意义:
| 标记 | 含义 |
|---|---|
| (1) | 你的网卡 MAC,IEEE OUI 厂商号 + 序列号 |
| (2) | 这个网段的广播 MAC,发到这个 MAC = 整个 L2 域都收到 |
IP 地址(第 3 层 / 网络层 / L3)
- 软件分配的逻辑地址:
192.168.1.5、10.244.0.10 - 跨网段有意义——全网唯一(或私网内唯一)
- 路由就是基于 IP 决定的
$ ip addr show eth0
2: eth0: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc fq state UP
link/ether aa:bb:cc:dd:ee:ff brd ff:ff:ff:ff:ff:ff
inet 192.168.1.5/24 brd 192.168.1.255 scope global dynamic noprefixroute eth0
# ^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^ ^^^^^ ^^^^^^^ ^^^^^^^ ^^^^^^^^^^^^^^
# (1) (2) (3) (4) (5) (6)
valid_lft 84321sec preferred_lft 84321sec
# ^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^
# (7) (8)
逐字段解读:
| 标记 | 含义 |
|---|---|
| (1) | IP/CIDR:本机 IP + 子网掩码 24 位 |
| (2) | 广播地址:子网的"全 1"地址(192.168.1.255) |
| (3) | scope:global = 跨子网可达;link = 仅本子网;host = 仅本机回环 |
| (4) | dynamic:DHCP 分配的(static 就是静态配的) |
| (5) | noprefixroute:内核不自动加 prefix 路由(NetworkManager 风格) |
| (6) | 网卡名 |
| (7) | valid_lft:DHCP 租约总寿命(秒) |
| (8) | preferred_lft:DHCP 优先使用期 |
ARP —— L2 和 L3 的桥
数据包要发出去时,应用知道目标 IP、网卡知道目标 MAC。谁来翻译 IP → MAC?
ARP(Address Resolution Protocol):
sequenceDiagram
participant A as A: 192.168.1.5
participant B as 全网段广播
participant C as B: 192.168.1.10
participant D as 其它机器
Note over A: 要发包到 192.168.1.10<br>但只知道 IP、不知道 MAC
A->>B: ARP Request:<br>"谁是 192.168.1.10?"<br>(广播 dst MAC=ff:ff:...)
Note over D: 我不是、不响应
C->>A: ARP Reply:<br>"我是 192.168.1.10<br>MAC=11:22:33:44:55:66"
Note over A: 写入 ARP 缓存表
A->>C: 数据包(知道 MAC 了)
看本机 ARP 表:
$ ip neigh
192.168.1.1 dev eth0 lladdr aa:bb:cc:dd:ee:00 REACHABLE
192.168.1.10 dev eth0 lladdr 11:22:33:44:55:66 STALE
192.168.1.99 dev eth0 INCOMPLETE
# ^^^^^^^^^^^
# (1) (2)
字段:
| 标记 | 含义 | 状态机 |
|---|---|---|
REACHABLE | 最近通信验证过、缓存有效 | 健康 |
STALE | 缓存还在但旧、下次用时验证 | 健康 |
DELAY | 等几秒验证 | 短暂 |
PROBE | 正在主动验证 | 短暂 |
INCOMPLETE | 发 ARP request 没回应 | 网络问题! |
FAILED | 多次失败 | 目标不通 |
关键关系:包"穿过"网络的样子
应用层 "去访问 example.com" ← 域名
DNS ↓
↓ 解析
↓
网络层 "去访问 93.184.215.14" ← IP 不变,端到端
路由 ↓ 查路由表
↓ 下一跳: 192.168.1.1
ARP ↓ 192.168.1.1 的 MAC?
↓
链路层 包发到 aa:bb:cc:... ← MAC 每跳一次重写
物理层 电信号 / 光信号
反直觉的关键点
每一跳路由器都改 MAC、不改 IP(除非中间有 NAT)。源/目的 IP 是"端到端"的、源/目的 MAC 只在"这一跳"有意义。
例子:你 → 家路由器 → 运营商 → ... → example.com
| 节点 | src IP | dst IP | src MAC | dst MAC |
|---|---|---|---|---|
| 你出发时 | 192.168.1.5 | 93.184... | 你的 MAC | 路由器 MAC |
| 路由器转发后 | 192.168.1.5 (没改) | 93.184... | 路由器 WAN MAC | ISP 设备 MAC |
| 运营商转发后 | 192.168.1.5 (没改) | 93.184... | ISP MAC | 下一跳 MAC |
但 NAT 后 src IP 会被改成路由器的公网 IP。这是 NAT 的特殊行为,不是路由的常态。
进阶:Proxy ARP / Gratuitous ARP
Proxy ARP:路由器代替远程主机回 ARP,让两个不同子网的机器以为"同子网"。K8s 部分 CNI(如 calico)默认开启。
sysctl net.ipv4.conf.all.proxy_arp
# net.ipv4.conf.all.proxy_arp = 1
Gratuitous ARP:主动广播自己的 IP↔MAC 映射,常用于:
- VIP 漂移(kube-vip / keepalived 切换 leader 时,新 leader 主动广播让网络更新)
- IP 冲突检测
- 网卡新接入
arping -A -I eth0 192.168.1.5 # 主动通告
排错信号:节点切换后流量没切换 = gratuitous ARP 没发 / 被丢。
3. 子网 / CIDR / 网关 —— 二进制的真相
CIDR 表示法
192.168.1.5/24
└──┬──┘ │ └┬┘
│ │ └── 子网掩码 = 高 24 位
│ │
│ └── 主机 IP
│
└── IPv4 地址
二进制:
192.168.1.5 = 11000000.10101000.00000001.00000101
掩码 /24 = 11111111.11111111.11111111.00000000
↑ 这里分界
AND 操作得网络号:
192.168.1.0 = 11000000.10101000.00000001.00000000
子网范围: 192.168.1.0 ~ 192.168.1.255
- 192.168.1.0 = 网络号(不能给设备用)
- 192.168.1.255 = 广播地址(不能给设备用)
- 192.168.1.1 ~ 192.168.1.254 → 254 个可用 IP
常见 CIDR 速查
| CIDR | 总 IP | 可用主机 | 典型用途 |
|---|---|---|---|
/32 | 1 | 0 | 单 IP / loopback |
/31 | 2 | 2 | 点对点(RFC 3021) |
/30 | 4 | 2 | 点对点链路 |
/29 | 8 | 6 | 小段 |
/28 | 16 | 14 | 小段 |
/24 | 256 | 254 | 家庭 / K8s pod 单节点 |
/22 | 1024 | 1022 | 楼层网段 |
/16 | 65,536 | 65,534 | 企业 / K8s 集群 service |
/12 | ~100 万 | ~100 万 | 私网 10.0.0.0/8 切段 |
/8 | 1670 万 | 1670 万 | 私网整段 |
/0 | 全部 | - | "0.0.0.0/0" 默认路由 |
私网网段(RFC 1918)
10.0.0.0/8 10.x.x.x (1670 万 IP) - 数据中心 / K8s
172.16.0.0/12 172.16-31.x.x (100 万 IP) - 中型企业
192.168.0.0/16 192.168.x.x (6.5 万 IP) - 家庭 / 小办公
100.64.0.0/10 100.64-127.x.x (400 万 IP) - **CGNAT (运营商级 NAT)**
169.254.0.0/16 169.254.x.x - link-local (无 DHCP 时 / AWS metadata 169.254.169.254)
K8s 默认:
| 段 | 默认 CIDR |
|---|---|
| Pod CIDR | 10.244.0.0/16(flannel)/ 10.0.0.0/16(cilium) |
| Service CIDR | 10.96.0.0/12(kubeadm 默认) |
| Cluster DNS | 10.96.0.10 |
网关:为什么"同网段不需要、跨网段需要"
设你 = 192.168.1.5/24。对端:
| 对端 IP | 是否同子网 | 怎么发包 |
|---|---|---|
| 192.168.1.10 | ✅ 同 192.168.1.0/24 | 直接 ARP 拿 MAC、L2 送达 |
| 192.168.2.10 | ❌ 不同子网 | 找网关、网关转发 |
| 8.8.8.8 | ❌ 公网 | 找网关、一路路由 |
判断逻辑(内核做的):
对端 IP AND 我的掩码 == 我的网络号 ?
192.168.1.10 AND /24 = 192.168.1.0 ✅ → 同子网
192.168.2.10 AND /24 = 192.168.2.0 ❌ → 跨子网
网关必须和你同子网(否则你怎么直连它)。所以家里:
你 192.168.1.5/24
网关 192.168.1.1/24 ← 必须同段
反面教材
错误:以为 IP 离得近就同子网
你 = 10.244.0.5/32
对端 = 10.244.0.6/32
ping 10.244.0.6
# 不通!
/32 = 单 IP 子网,子网里只有"自己"。对端虽然 IP 看起来近,对内核来说 = 跨网段、要走路由。
K8s pod 默认用 /32 配置——pod 间通信全靠节点路由表 / 一个特殊的"node bridge 路由",不是同子网直连。详见 02-namespaces.md。
4. 路由表 —— 包"往哪走"的决策
路由表的工作就是回答:"给定一个目的 IP、下一跳走谁、走哪个网卡?"
RIB vs FIB(进阶但重要)
| 概念 | 含义 |
|---|---|
| RIB (Routing Information Base) | 控制面的所有候选路由(BGP / OSPF / static / direct) |
| FIB (Forwarding Information Base) | 数据面实际使用的路由(RIB 选优后下发) |
对单机 Linux:
ip route # 默认看 main 表(接近 FIB)
ip route show table all # 看所有路由表
ip rule show # 看路由规则(决定走哪个表)
多路由表场景(策略路由):
$ ip rule show
0: from all lookup local
32766: from all lookup main
32767: from all lookup default
local 表是内核自动维护的(本机各 IP 的回环路由)。main 是你平时改的。default 是兜底。
看路由表 + 逐行解读
$ ip route
default via 192.168.1.1 dev eth0 proto dhcp src 192.168.1.5 metric 100
# ↑ ↑ ↑ ↑ ↑ ↑
# (1) (2) (3) (4) (5) (6)
192.168.1.0/24 dev eth0 proto kernel scope link src 192.168.1.5
# ↑ ↑ ↑ ↑
# (7) (8) (9) (10)
10.244.0.0/24 via 10.0.24.31 dev eth0
10.244.1.0/24 via 10.0.24.32 dev eth0
字段解读:
| 标记 | 含义 |
|---|---|
| (1) | 默认路由(匹配所有不被前面规则匹配的目的 IP) |
| (2) | via = "通过下一跳" |
| (3) | 走 eth0 这个网卡 |
| (4) | 这条路由是 DHCP 学到的(其它取值:static / kernel / boot / dhcp / bgp) |
| (5) | 用这个 IP 作为发包时的源 IP |
| (6) | metric:多条路由竞争时的优先级(越小越优先) |
| (7) | 这是个直连路由(没 via)—— 192.168.1.0/24 这个网段是直连 |
| (8) | scope link = 二层直连可达(不通过路由器) |
| (9) | proto kernel = 内核自动加的(IP 配上 /24 时自动加) |
| (10) | 同 (5) |
最长前缀匹配(路由的根本规则)
多条路由都匹配时,前缀长度最长的赢。
例子:
$ ip route
default via 192.168.1.1 # /0 → 匹配 0 位
10.0.0.0/8 via 10.1.1.1 # /8 → 匹配 8 位
10.244.0.0/16 via 10.1.2.1 # /16 → 匹配 16 位
10.244.1.0/24 via 10.1.3.1 # /24 → 匹配 24 位
$ ip route get 10.244.1.5
10.244.1.5 via 10.1.3.1 dev eth0 # ← /24 最长前缀,赢
10.244.1.5 同时匹配 default、/8、/16、/24 四条,取最长前缀的那条。
flowchart TD
Start[目的 IP: 10.244.1.5] --> Match{有匹配的路由吗?}
Match -->|匹配多条| Pick[挑前缀最长的]
Match -->|只一条| Use[使用它]
Match -->|0 条| Default[使用 default 路由]
Pick --> Use
Use --> Decision{需要 ARP?}
Decision -->|同子网| ARP[ARP 拿 MAC]
Decision -->|跨子网| Gateway[查下一跳的 MAC]
Gateway --> ARP
ARP --> Send[发包]
ip route get —— 必学排错命令
$ ip route get 8.8.8.8
8.8.8.8 via 192.168.1.1 dev eth0 src 192.168.1.5 uid 0
# ↑ ↑ ↑ ↑ ↑
# 下一跳 出网卡 出网卡 实际用的源 IP 发包用户的 UID
怀疑路由错就跑这条——给你"如果发到这个 IP、会怎么走"的权威答案。
$ ip route get 10.244.1.5
10.244.1.5 via 10.0.24.32 dev eth0 src 10.0.24.28
# 路由器认为 10.244.1.5 在 worker 节点 m5 (10.0.24.32) 上
K8s 跨节点 pod 通信不通时,在节点上跑 ip route get ⟨pod-ip⟩ 立刻看出"包应该走哪个节点"——和实际跑的对照。
反面教材:以为路由不通 = ping 不通
$ ip route get 10.244.1.5
10.244.1.5 via 10.0.24.32 dev eth0 # 路由 OK
$ ping 10.244.1.5
# 不通
路由表说"该走 m5"≠ 包真能到 m5。可能:
- 中间防火墙挡(节点之间安全组、iptables)
- m5 上的 pod 已死(路由还在但 endpoint 没了)
- CNI 异常
路由 OK 只是说"路径已知",不保证"链路通"。
5. NAT —— 让私网通公网(理解 K8s 出网必备)
NAT 的三种类型
graph LR
subgraph SNAT[Source NAT / 出网场景]
A1[私网 192.168.1.5] -->|改 src| B1[网关公网 1.2.3.4]
B1 --> C1[公网]
end
subgraph DNAT[Destination NAT / 入网场景]
A2[外部访问 公网:8080] -->|改 dst| B2[内网 10.0.0.5:80]
end
subgraph MASQ[MASQUERADE / 动态 SNAT]
A3[多个内部客户端] -->|出口 IP 自动选| B3[公网]
end
style SNAT fill:#e1f5ff
style DNAT fill:#ffe1f5
style MASQ fill:#f5ffe1
SNAT (Source NAT) —— 你家路由器在做的事
你 → 路由器 → 公网
原始包: src=192.168.1.5:54321 dst=93.184.215.14:443
经路由器: src=1.2.3.4:54321 dst=93.184.215.14:443
^^^^^^^^^^^^^^^^^
src 被改成路由器公网 IP
回包: src=93.184.215.14:443 dst=1.2.3.4:54321
路由器查 conntrack 表:"这个 54321 端口给的是 192.168.1.5"
改回: src=93.184.215.14:443 dst=192.168.1.5:54321
DNAT (Destination NAT) —— 端口转发 / NodePort
外部 → 路由器:8080 → 内部:80
原始包: src=外部 IP dst=路由器公网:8080
DNAT: src=外部 IP dst=内部 10.0.0.5:80
^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^
没改 dst 改成内部 IP/port
K8s NodePort 就是 DNAT:
外部 → 节点 IP:30080 → DNAT → pod:8080
MASQUERADE —— 动态 SNAT(K8s 标配)
iptables -t nat -A POSTROUTING -o eth0 -j MASQUERADE
# 所有从 eth0 出去的包、src 改成 eth0 当时的 IP
跟静态 SNAT 的区别:
| 静态 SNAT | MASQUERADE | |
|---|---|---|
| 配置 | 指定改成哪个 IP | 动态选出口 IP |
| 用途 | 多 IP 选一个固定的 | 出口 IP 可能变化(如 DHCP) |
| K8s 用例 | - | 默认 |
K8s pod 出公网用的就是 MASQUERADE。pod IP(10.244.x.x)→ 节点 eth0 IP → 公网。
conntrack —— NAT 的"记忆"
NAT 不是无状态的。每个连接都被 conntrack 记录,回包才能找到正确的客户端。
$ cat /proc/net/nf_conntrack | head -3
ipv4 2 tcp 6 86399 ESTABLISHED src=192.168.1.5 dst=93.184.215.14 sport=54321 dport=443 \
src=93.184.215.14 dst=1.2.3.4 sport=443 dport=54321 [ASSURED] mark=0 use=2
# ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
# (1) 正向 tuple (2) 反向 tuple
# 用 conntrack 命令更友好
$ conntrack -L 2>/dev/null
tcp 6 86399 ESTABLISHED src=192.168.1.5 dst=93.184.215.14 sport=54321 dport=443 \
src=93.184.215.14 dst=1.2.3.4 sport=443 dport=54321 ...
每个 conntrack entry 含两个 tuple:
- 正向:客户端 → 服务端的视角
- 反向:服务端 → 客户端的视角
NAT 用正向 tuple 改包,回包来时用反向 tuple 查 → 还原回客户端 IP。
conntrack 表满 —— 生产事故 #1
# dmesg 出现:
nf_conntrack: nf_conntrack: table full, dropping packet
# 看当前
$ sysctl net.netfilter.nf_conntrack_count
net.netfilter.nf_conntrack_count = 524286
$ sysctl net.netfilter.nf_conntrack_max
net.netfilter.nf_conntrack_max = 524288
# ^^^^^^^^
# 满了
症状:业务偶发性超时、丢包、连接被拒。90% 应用 SRE 第一次见这个的反应是"是不是网络问题"——是网络问题,但根因是 conntrack 表。
修:
sysctl -w net.netfilter.nf_conntrack_max=1048576
# 持久化(详见 commands/sysctl.md)
echo 'net.netfilter.nf_conntrack_max = 1048576' >> /etc/sysctl.d/99-conntrack.conf
sysctl --system
或者降低 TIME_WAIT 类 entry 超时:
sysctl -w net.netfilter.nf_conntrack_tcp_timeout_time_wait=10
# 默认 120 秒、调小让 entry 早点回收
NAT 的副作用 —— 不是免费的
- 额外 CPU 开销:每个包过 NAT 表都要查 conntrack
- 状态有限:conntrack 表大小是上限
- 某些协议跑不动:FTP / SIP 等内嵌 IP 的协议 NAT 透明性差(要 helper / ALG)
- 端到端原则破坏:客户端的真实 IP 在 NAT 后不可见(要 X-Forwarded-For 等头)
K8s service mesh 的"绕开 NAT"(直接 pod-to-pod 通信)就是为了避开这些。
6. TCP 三次握手 + 状态机(看懂 ss 输出)
三次握手(为什么是 3 次)
sequenceDiagram
autonumber
participant C as Client
participant S as Server
Note over S: socket() + bind() + listen()<br>状态: LISTEN
Note over C: socket() + connect()
C->>S: SYN seq=x
Note over C: 状态: SYN-SENT
Note over S: 状态: SYN-RECV<br>(写入半连接队列)
S->>C: SYN-ACK seq=y, ack=x+1
Note over C: 状态: ESTABLISHED
C->>S: ACK ack=y+1
Note over S: 半连接队列出 → 全连接队列<br>accept() 取出<br>状态: ESTABLISHED
为什么必须 3 次:
- 1 次:client 不能确认 server 收到、server 不能确认 client 能收
- 2 次:server 不能确认 client 是否真要建连(旧 SYN 可能误触)
- 3 次:双向都确认对方"能收 + 能发"
状态机(所有 ss 输出的 State 列)
stateDiagram-v2
[*] --> CLOSED
CLOSED --> LISTEN: listen()
LISTEN --> SYN_RECV: 收 SYN, 发 SYN-ACK
SYN_RECV --> ESTABLISHED: 收 ACK
CLOSED --> SYN_SENT: connect()<br>发 SYN
SYN_SENT --> ESTABLISHED: 收 SYN-ACK<br>发 ACK
ESTABLISHED --> FIN_WAIT_1: close()<br>发 FIN
FIN_WAIT_1 --> FIN_WAIT_2: 收 ACK
FIN_WAIT_2 --> TIME_WAIT: 收 FIN
TIME_WAIT --> CLOSED: 等 2*MSL
ESTABLISHED --> CLOSE_WAIT: 对端 close()<br>收 FIN
CLOSE_WAIT --> LAST_ACK: 本端 close()<br>发 FIN
LAST_ACK --> CLOSED: 收 ACK
状态详解 + 生产意义
$ ss -tn
State Recv-Q Send-Q Local Address:Port Peer Address:Port
ESTAB 0 0 10.0.24.28:43210 10.0.24.29:6443
TIME-WAIT 0 0 10.0.24.28:42345 10.0.24.29:6443
CLOSE-WAIT 100 0 10.0.24.28:54321 10.0.24.30:8080
SYN-SENT 0 1 10.0.24.28:60000 10.0.24.99:443
| 状态 | 意义 | 生产看到时 | 排查 |
|---|---|---|---|
LISTEN | server 在等 | 正常 | ss -lntp |
ESTAB | 通信中 | 正常 | - |
TIME-WAIT | 主动关闭方等 2*MSL(60-120s) | 几千个正常 / 几万要查:短连接太多 / 没用连接池 | net.ipv4.tcp_tw_reuse=1 / tw_recycle(4.12+ 已删) |
CLOSE-WAIT | 对端 FIN 了、本端没 close | 应用 bug:没正确 close() 或 defer close() 漏 | 看应用代码 / 重启临时缓解 |
SYN-SENT | client 等 SYN-ACK | 目标没监听 / 防火墙挡 / SYN flood | nc -zv target port / tcpdump |
SYN-RECV | server 等 ACK | 短暂 / 堆积 = SYN flood 攻击 | sysctl net.ipv4.tcp_syncookies=1 |
FIN-WAIT-1/2 | 关闭中 | 短暂 | - |
LAST-ACK | 等最后 ACK | 短暂 | - |
TIME_WAIT 的真相
为什么要等 2*MSL (120 秒)?
1. **保证最后一个 ACK 真的到了对端**
如果丢了、对端会重传 FIN,需要本端还能回 ACK
2. **让网络中"漂着"的旧包消失**
防止新连接(用同样的 4-tuple)收到旧连接的延迟包
短连接场景(K8s pod 频繁起停 + HTTP 不复用)会让 TIME_WAIT 堆积:
$ ss -tn state time-wait | wc -l
50000
修法(按效果排序):
- 应用层用长连接 / 连接池(根本)
net.ipv4.tcp_tw_reuse = 1(让 TIME_WAIT 端口可被新连接复用,安全)(已被 4.12+ 内核删除——NAT 场景不安全)tcp_tw_recycle = 1net.ipv4.ip_local_port_range = 1024 65535(扩大端口池)
CLOSE_WAIT 堆积 —— 应用 bug
$ ss -tn state close-wait | wc -l
500
这是应用代码 bug——server 端收到 FIN(client 想关连接),本端代码没调用 close() 或 socket.close() 漏写。
经典场景:
# ❌ 异常路径漏 close
try:
data = sock.recv(1024)
process(data) # 这里抛异常
sock.close() # 永远到不了
except Exception:
pass # 静默吞异常、socket 没关
# → CLOSE_WAIT 堆积
# ✅ defer / finally / context manager
with socket.socket() as sock: # Python 自动管
...
// ❌ 漏 defer
conn, _ := net.Dial(...)
process(conn)
conn.Close() // 上面 panic 会跳过这里
// ✅ defer
conn, _ := net.Dial(...)
defer conn.Close()
process(conn)
Recv-Q / Send-Q 列
State Recv-Q Send-Q Local:Port Peer:Port
LISTEN 0 128 0.0.0.0:80
ESTAB 500 1000 1.2.3.4:80 5.6.7.8:54321
| 状态 | Recv-Q | Send-Q |
|---|---|---|
LISTEN | 半连接队列当前长度 | backlog 上限 |
ESTAB | 收到但应用没 read 的字节数 | 发了但对端没 ACK 的字节数 |
ESTAB 时 Recv-Q 持续大:应用消费慢、reader 卡了。Send-Q 大:网络丢包重传 / 对端处理慢。
LISTEN 状态下 Recv-Q 接近 Send-Q 上限:accept() 跟不上、半连接快堆满 → server 处理能力不足。
7. K8s Pod 网络底层 —— 看清楚才能排错
单节点视角
flowchart TB
subgraph PodA[Pod A: netns A]
EthA[eth0<br>10.244.0.5/32]
end
subgraph PodB[Pod B: netns B]
EthB[eth0<br>10.244.0.6/32]
end
subgraph HostNs[节点 host netns]
VethA[vethXXXX<br>peer]
VethB[vethYYYY<br>peer]
CNI[cni0 bridge<br>10.244.0.1/24]
HostEth[eth0<br>10.0.24.28]
Iptables[iptables NAT/Filter]
RT[路由表]
end
EthA -.veth pair.-> VethA
EthB -.veth pair.-> VethB
VethA --> CNI
VethB --> CNI
CNI --> Iptables
Iptables --> RT
RT --> HostEth
HostEth -->|MASQUERADE| Extern[外部]
每个组件:
- netns:网络命名空间,隔离的 IP / 路由 / iptables / socket。Pod 看到的是自己的 netns。
- veth pair:一对"虚拟网线"——一头在 pod netns、一头在 host netns。两头是连通的,写一头另一头收到。
- cni0 / bridge:虚拟交换机,把所有同节点 pod 的 veth 头串起来 + 给 pod 一个 default gateway。
- 节点 iptables:kube-proxy 生成的规则 + CNI 自己的规则。
跨节点视角(Overlay 模式)
sequenceDiagram
participant PA as Pod A<br>(10.244.0.5)
participant CN1 as 节点 m1<br>cni0
participant E1 as m1 eth0<br>(10.0.24.28)
participant E2 as m2 eth0<br>(10.0.24.29)
participant CN2 as 节点 m2<br>cni0
participant PB as Pod B<br>(10.244.1.6)
PA->>CN1: 发包 src=10.244.0.5 dst=10.244.1.6
Note over CN1: 查路由表:<br>10.244.1.0/24 走 m2 (10.0.24.29)
CN1->>E1: 转发
Note over E1: 封装 VXLAN:<br>外层 src=10.0.24.28 dst=10.0.24.29<br>内层 src=10.244.0.5 dst=10.244.1.6
E1->>E2: UDP 4789<br>(VXLAN 标准端口)
Note over E2: 解封装
E2->>CN2: 内层包
CN2->>PB: src=10.244.0.5 dst=10.244.1.6
VXLAN = pod 流量被包成 UDP 包、外层用节点 IP、内层是 pod IP。
Pod 的实际路由表
# 在某个 pod 里
$ ip route
default via 10.244.0.1 dev eth0
10.244.0.0/24 dev eth0 proto kernel scope link src 10.244.0.5
简单:所有非 10.244.0.0/24(自己同节点的 pod)的流量 → 走 default gateway 10.244.0.1(cni0 / bridge IP)。
# 在节点 host netns
$ ip route
default via 10.0.24.1 dev eth0
10.0.24.0/24 dev eth0 proto kernel scope link src 10.0.24.28
10.244.0.0/24 dev cni0 proto kernel scope link src 10.244.0.1 # 本节点 pod
10.244.1.0/24 via 10.0.24.32 dev eth0 # m2 节点 pod
10.244.2.0/24 via 10.0.24.30 dev eth0 # m3 节点 pod
每个 worker pod CIDR 一条路由、指向对应节点。这是 CNI controller 写进来的——节点加入 / 退出集群时自动更新。
看 pod IP 怎么走(排错神器)
# 在节点上
$ ip route get 10.244.1.5
10.244.1.5 via 10.0.24.32 dev eth0 src 10.0.24.28
# 解读: 这个 IP 应该经节点 m5 (10.0.24.32) 路由
# 如果 m5 上没有 pod 10.244.1.5 → 路由对、但 endpoint 错
# 如果路由不存在 → CNI / 集群路由同步问题
详细排查见 02-namespaces.md 和 05-troubleshooting.md。
8. Service 的本质 —— 它不是个进程
K8s 让你迷惑的点:Service 不监听任何端口。它只是一组 iptables 规则 + DNS 记录。
ClusterIP 流量怎么走(详解)
sequenceDiagram
autonumber
participant App as Pod A 应用
participant Resolver as Pod 内 DNS
participant CoreDNS
participant Kernel as Pod 内 iptables / kernel
participant Backend as Pod B (10.244.1.5)
App->>Resolver: getaddrinfo("my-svc")
Resolver->>CoreDNS: my-svc.default.svc.cluster.local
CoreDNS-->>Resolver: 10.96.1.5 (ClusterIP)
Resolver-->>App: 10.96.1.5
App->>Kernel: connect(10.96.1.5:80)
Note over Kernel: 经 iptables KUBE-SERVICES 链<br>DNAT: 10.96.1.5:80 → 10.244.1.5:8080
Kernel->>Backend: src=10.244.0.5 dst=10.244.1.5:8080
Backend-->>App: 回包(DNAT 反向)
10.96.1.5 没人监听——它只是 iptables DNAT 规则里的"标记"。
看实际生效的 iptables 规则:
$ iptables -t nat -L KUBE-SERVICES -n | grep my-svc
KUBE-SVC-XXXX tcp -- 0.0.0.0/0 10.96.1.5 tcp dpt:80 /* default/my-svc */
$ iptables -t nat -L KUBE-SVC-XXXX -n
KUBE-SEP-AAA /* probability 0.5 */
KUBE-SEP-BBB /* */
$ iptables -t nat -L KUBE-SEP-AAA -n
DNAT tcp ... to:10.244.1.5:8080
3 个 endpoint 就 3 条 KUBE-SEP 链、按 statistic probability 分流。
四种 Service 类型对比
| 类型 | 暴露范围 | 实现 |
|---|---|---|
| ClusterIP | 集群内 | iptables DNAT 到 pod |
| NodePort | 节点 IP:30000-32767 | iptables DNAT 到 pod(每个节点都开) |
| LoadBalancer | 公网 LB IP | 云 LB → NodePort → DNAT |
| ExternalName | DNS CNAME | CoreDNS 配置(不经 iptables) |
反面教材:试图 ping ClusterIP
$ ping 10.96.1.5
PING 10.96.1.5: 56 data bytes
^C
--- 10.96.1.5 ping statistics ---
5 packets transmitted, 0 packets received, 100% packet loss
这是正常的。ClusterIP 没人响应 ICMP(没人监听任何东西、它是 iptables 规则)。
要测 Service 通不通:
$ nc -zv 10.96.1.5 80
Connection to 10.96.1.5 80 port [tcp/*] succeeded!
# 或者
$ curl http://10.96.1.5:80
这个误解让很多人 K8s 网络排错时走错方向。
Headless Service —— 例外
spec:
clusterIP: None # ← Headless
selector: {...}
Headless Service 没有 ClusterIP——DNS 直接返回所有 endpoint pod IP(A 记录列表)。 适合需要"自己做负载均衡"的场景(StatefulSet / Cassandra / Kafka 客户端等)。
$ dig my-headless.default.svc.cluster.local +short
10.244.0.5
10.244.1.6
10.244.2.7
9. OSI 七层 —— 排错分层用
flowchart TD
L7[L7 应用层<br>HTTP / DNS / SSH] --> L6[L6 表示层<br>TLS / 编码]
L6 --> L5[L5 会话层<br>TCP session]
L5 --> L4[L4 传输层<br>TCP / UDP / 端口]
L4 --> L3[L3 网络层<br>IP / ICMP / 路由]
L3 --> L2[L2 链路层<br>MAC / VLAN / ARP]
L2 --> L1[L1 物理层<br>网线 / 光纤 / 信号]
每层对应的排错命令
| OSI 层 | 排错命令 | 看啥 |
|---|---|---|
| L7 应用 | curl -v, 应用日志 | HTTP 状态码 / 应用报错 |
| L6 表示 | openssl s_client | TLS 握手 / 证书 |
| L5/4 会话/传输 | ss -tn, nc -zv | socket 状态 / 端口连通 |
| L3 网络 | ping, mtr, ip route, traceroute | IP 可达 / 路径 / 路由 |
| L2 链路 | ip link, ip neigh, ethtool | 网卡 UP / ARP / link 状态 |
| L1 物理 | ethtool eth0 | 速率 / 双工 / 错误计数 |
包穿过每一层的样子
应用产生数据: "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"
↓
L6 加 TLS: [TLS record header] + 加密数据
↓
L5/4 加 TCP: [TCP header: src port, dst port, seq, ack] + [TLS payload]
↓
L3 加 IP: [IP header: src IP, dst IP, TTL] + [TCP segment]
↓
L2 加 Eth: [Ethernet: src MAC, dst MAC] + [IP packet] + [FCS 校验]
↓
L1: 010101010110...
每跳路由器剥到 L3、改 MAC、再封 L2 发。 每跳防火墙可以看到 L4(端口、状态)甚至 L7(HTTP / DNS 解析)。
排错从底层往上(反直觉但有效)
flowchart TD
Start[业务报错] --> L1{L1: 网卡 UP?}
L1 -->|否| Fix1[ethtool 看 link / 换网线]
L1 -->|是| L2{L2: MAC/ARP OK?}
L2 -->|否| Fix2[ip neigh / 看 ARP 表]
L2 -->|是| L3{L3: ping 通对端?}
L3 -->|否| Fix3[ip route / mtr / 防火墙]
L3 -->|是| L4{L4: 端口监听?}
L4 -->|否| Fix4[ss -lntp / 服务挂了]
L4 -->|是| L7{L7: 应用响应?}
L7 -->|否| Fix5[curl -v / 应用日志]
L7 -->|是| OK[✓ 没问题]
90% 的问题在 L3-L4。极少数到 L7。永远从下往上查。
反例:从上往下查的坑
"用户说 API 502"
→ 看应用日志(L7):没异常
→ 重启应用:还是 502
→ 半天后才发现 nginx upstream 配错了节点 IP(L3)
如果从下往上:
"502 → nginx upstream 是哪些"
→ curl nginx upstream IP 直接测:失败
→ ping 那个 IP:失败 → 节点根本不通
→ 改 upstream → 修复
5 分钟 vs 半天。
10. 反面教材合集(避坑指南)
❌ 误解 1:ping 通 = 没问题
$ ping example.com
PING example.com (93.184.215.14): 56 data bytes
64 bytes from 93.184.215.14: ... time=15.234 ms
$ nc -zv example.com 443
nc: connect to example.com port 443 (tcp) failed: Connection refused
ICMP(ping)和 TCP 走的是不同协议。很多防火墙放行 ICMP 但挡 TCP。
正确:测端口连通用 nc -zv / curl,不要单靠 ping。
❌ 误解 2:MTU 1500 是固定的
$ ip link show eth0 | grep mtu
mtu 1500
$ ip link show flannel.1 | grep mtu
mtu 1450 # ← VXLAN 占了 50 字节
不同链路 MTU 不同。VXLAN / IPIP 封装后 MTU 必须减小。
症状:跨节点 pod 大包(> 1450)丢、小包正常 → 应用偶发性卡顿、特征是"小请求 OK、大请求挂"。
修法:
# CNI 配置里设 MTU = 物理 MTU - 封装开销
# 例如:物理 1500 - VXLAN 50 = 1450
❌ 误解 3:K8s ClusterIP 能 ping
见上面 §8。ClusterIP 不响应 ICMP——这是正常的。用 curl / nc -zv。
❌ 误解 4:公网 IP 全球唯一
只在"路由可达"意义上唯一。CGNAT(运营商级 NAT)让多用户共享一个公网 IP。
# 在家里
$ curl ifconfig.me
1.2.3.4
$ curl https://api.ipify.org
1.2.3.4
# 在公司
$ curl ifconfig.me
1.2.3.4 # ← 不同设备、可能看到同一个公网 IP(CGNAT)
业务做"IP 限流"时容易误杀同 NAT 网关后的合法用户。
❌ 误解 5:"netstat 看到 ESTABLISHED 就是连接好的"
$ ss -tn state established
ESTAB 0 0 1.2.3.4:443 5.6.7.8:54321
ESTABLISHED 是 TCP 状态。双方是否还活着是另一回事——可能客户端断网了、server 还以为连接好的。这就是为什么需要 TCP keepalive:
sysctl net.ipv4.tcp_keepalive_time # 默认 7200 秒 = 2 小时
sysctl net.ipv4.tcp_keepalive_intvl # 默认 75 秒
sysctl net.ipv4.tcp_keepalive_probes # 默认 9 次
K8s 节点上长连接 + 中间 NAT 设备(防火墙超时 5 分钟)→ 连接半死、状态 ESTAB → 应用调用偶发性 timeout。
修:把 tcp_keepalive_time 调小:
sysctl -w net.ipv4.tcp_keepalive_time=60
或者应用层加 keep-alive 心跳。
❌ 误解 6:DNS 解析永远走 /etc/resolv.conf
$ cat /etc/resolv.conf
nameserver 10.96.0.10
search ns.svc.cluster.local svc.cluster.local cluster.local
options ndots:5
Go 程序、glibc、Node.js 各有自己的 resolver 实现 + cache。某些应用绕开 nsswitch 直接调远程 DNS。
详见 04-dns-deep.md。
❌ 误解 7:服务端口冲突就报错
# 进程 A bind 0.0.0.0:8080 成功
# 进程 B bind 127.0.0.1:8080 也成功??
Linux 允许"更具体的 bind 覆盖更宽的"。0.0.0.0 是 wildcard、127.0.0.1 更具体。 排查"为啥 curl localhost:8080 走到进程 A 不是进程 B"会迷惑。
$ ss -lntp '( sport = :8080 )'
LISTEN 0 128 0.0.0.0:8080 process A
LISTEN 0 128 127.0.0.1:8080 process B
最具体的 bind 优先。
❌ 误解 8:iptables -A 立即生效
-A 是追加到链尾。规则按顺序匹配、第一个匹配的赢。如果前面有 -j DROP 的全匹配规则,你的 -A ACCEPT 永远到不了。
iptables -A INPUT -j DROP # 先 DROP 一切
iptables -A INPUT -p tcp --dport 80 -j ACCEPT # 永远不会被命中
要插到前面用 -I(insert):
iptables -I INPUT 1 -p tcp --dport 80 -j ACCEPT
❌ 误解 9:/proc/net/tcp 列出的就是所有连接
$ cat /proc/net/tcp | wc -l
1000
$ ss -tn | wc -l
5000
/proc/net/tcp 只列 IPv4。IPv6 在 /proc/net/tcp6。容器里看 /proc/net/* 是当前 netns 视角、不是全节点。
K8s pod 里跑 ss 看到的只是 pod 自己 netns 的 socket。要看节点级用 nsenter -t 1 -n ss。
❌ 误解 10:包是按发送顺序到的
应用顺序发:包 1, 2, 3, 4, 5
对端可能收到顺序:1, 3, 2, 5, 4
IP 网络不保证顺序。TCP 在上层用 seq 重组、所以应用看到的是有序的。但UDP 不重组——如果你的应用基于 UDP(DNS / 自研协议),收包顺序是乱的。
业务排错时如果发现"应该按时间顺序的事件错乱",先想是不是中间网络乱序了。
11. 排查 cheatsheet(面对真实故障时的速查)
"连不上服务" 黄金 5 步
flowchart TD
A[业务报错: 连不上 X] --> B[1. ping X<br>L3 通吗?]
B -->|不通| B1[ip route get X<br>看路由]
B -->|通| C[2. nc -zv X port<br>L4 端口通吗?]
C -->|不通| C1[对端 ss -lntp<br>看监听]
C -->|通| D[3. curl -v X<br>L7 服务对吗?]
D -->|404/5xx| D1[看应用日志]
D -->|连接断| D2[mtr X<br>看路径丢包]
D -->|TLS 错| D3[openssl s_client]
D -->|超时| D4[tcpdump 抓包]
D -->|OK| E[✓ 没问题]
"节点 NotReady" 排查
# 1. 看 kubelet 状态
ssh m4 'systemctl status kubelet; journalctl -u kubelet --since "5 min ago" | tail -50'
# 2. 看到 cni / network plugin 错?
ssh m4 'ls /etc/cni/net.d/' # CNI 配置在不在
ssh m4 'crictl ps | grep cni' # CNI pod 跑没
# 3. 看 sysctl 关键参数
ssh m4 'sysctl net.bridge.bridge-nf-call-iptables net.ipv4.ip_forward'
# 都该是 1
# 4. 看 conntrack 满了没
ssh m4 'sysctl net.netfilter.nf_conntrack_count net.netfilter.nf_conntrack_max'
# 5. 看路由
ssh m4 'ip route' # 应该有指向其它节点 pod CIDR 的路由
"Pod 偶发性 timeout" 排查
# 1. 网络层抖动
ssh m4 'dmesg -T | grep -iE "drop|nf_conntrack|tcp"' # 内核报错
ssh m4 'mtr -rn -c 30 -T -P <port> <peer>' # 持续测路径
# 2. conntrack 表满
ssh m4 'sysctl net.netfilter.nf_conntrack_count nf_conntrack_max'
# 3. TIME_WAIT / CLOSE_WAIT 堆积
ssh m4 'ss -s' # 总览
ssh m4 'ss -tn state close-wait | wc -l' # 异常堆积?
# 4. 看应用日志找时间戳关联
"外部访问 NodePort 慢" 排查
# 1. 测节点本机
ssh m4 'curl -v http://localhost:30080'
# 2. 测节点 IP(外部模拟)
curl -v http://⟨m4-IP⟩:30080
# 3. 看 iptables KUBE-NODEPORTS 链是否有规则
ssh m4 'iptables -t nat -L KUBE-NODEPORTS -n -v | head'
# 4. 看 endpoint
kubectl get endpoints ⟨svc-name⟩
# 5. 抓包对比
ssh m4 'tcpdump -i any -nn host ⟨client-IP⟩ and port 30080'
12. 这篇之后
马上有用:
- 看
ip addr/ip route输出能逐行读懂了 - 知道为啥 ClusterIP 不能 ping
- 看到
CLOSE_WAIT堆积知道是哪一端 bug - 排错时知道从 L3-L4 开始
继续学习:
| 文档 | 学什么 |
|---|---|
| 01-output-reading.md | 把 ss / iptables / tcpdump 输出逐行讲透 |
| 02-namespaces.md | netns / veth / bridge 心智模型实操 |
| 03-k8s-network-deep.md | Service / NodePort / Ingress 完整流量走向 |
| 04-dns-deep.md | CoreDNS / resolv.conf / ndots:5 那个经典坑 |
| 05-troubleshooting.md | 把这篇的排查 cheatsheet 展开到 case study 级 |