ip addr 敲下去,内核把所有网卡的地址、状态、MTU 一次吐给你——这条信息是怎么从内核传到用户态的?很多人第一反应是 procfs 或 ioctl,但现代 Linux 网络栈的答案统一是 netlink 套接字:一个专门为「内核 ↔ 用户态双向通信」设计的 socket 协议族(AF_NETLINK)。它不只是 ip 命令的底层:udev 用它收内核的设备事件(udevadm monitor 看到的每一行都来自 netlink 多播)、Kubernetes CNI 插件用它管理容器网络接口、Prometheus 的 node_exporter 用它采集网卡统计、守护进程用它监听网卡的 up/down 事件实现毫秒级故障感知。相比「读 /proc 文件」或「发 ioctl」这两个老办法,netlink 的核心优势是支持内核主动推送——内核可以在事件发生的瞬间(网卡拔出、地址变更、路由更新)把消息多播给所有订阅者,而不是等用户态来轮询。这一篇讲透 netlink 的协议格式、多播组机制,并用真实代码演示一个网卡事件监听器的实现。
一、为什么不是 ioctl 或 procfs
在 netlink(2.2 内核时代逐步成型)之前,内核参数查询与配置走两条路:procfs/sysfs(读文本文件)与 ioctl(每项功能一个命令字)。两条路都有结构性缺陷。procfs 的缺陷是单向且脆弱:只能内核到用户态单向输出,无法配置;内容是人读的文本格式,解析要写脆弱的正则(/proc/net/dev 的格式改过几次,分析脚本跟着崩);性能差(每次读都要在内核里拼字符串)。ioctl 的缺陷是** Pull-only 且扩展困难:只能用户态主动调用,内核没法主动通知「网卡 down 了」;每个新功能要分配新的 ioctl 号并改动内核系统调用分发逻辑,ioctl 号是稀缺的命名空间;请求结构体的版本兼容是老大难(不同内核版本结构体大小与字段含义都可能变)。netlink 的设计针对这些痛点逐条回应:它是双向的——用户态可以发请求(GET 查询、NEW/MOD/DEL 配置),内核也可以主动多播事件;它是协议化的——消息是结构化的 nlmsghdr + 属性流(TLV 格式),不是文本,解析有明确规范;它是多播组化的——事件按类型(链路变化、地址变化、路由变化)分组,进程订阅感兴趣的组,内核按组投递;它天然支持请求-响应 + 异步事件**混合模式,还继承了套接字的全部能力(非阻塞、epoll、异步 IO)——一个进程可以同时在多个 netlink socket 上做事件循环。代价是协议本身有点繁琐(消息格式与对齐规则自成一派),所以实践中要么用 libnl/mnl 这类 C 库,要么用 pyroute2(Python)这类封装,但理解协议本身仍然是排障的基础。
flowchart TD
subgraph U["用户态"]
A["ip / iw / atop 命令"] --> B["工具库层<br/>libmnl / pyroute2 / Go netlink"]
C["事件监听进程<br/>CNI agent / keepalived"] --> B
B --> S1["AF_NETLINK socket<br/>请求:RTM_GETLINK (NLM_F_DUMP)"]
B --> S2["订阅多播组<br/>RTMGRP_LINK / RTMGRP_IPV4_ROUTE"]
end
subgraph K["内核态"]
N1["rtnetlink (NETLINK_ROUTE)"] --> N2["generic netlink<br/>nl80211 / TASKSTATS / OVS"]
N3["事件源<br/>网卡 up/down / 地址变更 / 路由更新"] -- "多播投递(尽力而为,可丢 ENOBUFS)" --> S2
end
S1 -- "查询/配置(需 CAP_NET_ADMIN 才能写)" --> N1
二、协议格式:nlmsghdr 与属性流
netlink 消息的骨架是 nlmsghdr(16 字节)+ 载荷,载荷再由「固定头 + TLV 属性流」构成。头部的四个关键字段:nlmsg_len(整条消息长度)、nlmsg_type(消息类型:RTM_NEWLINK/RTM_NEWADDR/RTM_NEWROUTE 等分别对应链路/地址/路由)、nlmsg_flags(NLM_F_REQUEST 请求、NLM_F_MULTI 分片、NLM_F_DUMP 列举全量、NLM_F_ACK 要求确认)、nlmsg_seq/nlmsg_pid(序列号与端口 ID,用于把响应与请求配对)。属性流(TLV:Type-Length-Value)是载荷的延伸,每个属性由 nlattr(4 字节头)+ 值组成。有一条最容易踩的对齐规则:所有消息与属性都按 4 字节对齐,长度不足要补齐(NLMSG_ALIGN(len)),解析时「按 len 逐条跳过、跳之前先对齐」是标准写法——漏了对齐,消息流会在某个边界错位,解析出乱码。看一条真实消息的分解(strace ip addr 的第一部分就能看到 netlink socket 的创建):
$ strace -f -e trace=socket,bind,sendto,recvmsg ip addr show lo
socket(AF_NETLINK, SOCK_RAW|SOCK_CLOEXEC, NETLINK_ROUTE) = 3
bind(3, {sa_family=AF_NETLINK, nl_pid=0, groups=00000000}, 12) = 0
sendto(3, {{len=20, type=RTM_GETLINK, flags=NLM_F_REQUEST|NLM_F_DUMP,
seq=1699...}, {ifi_family=AF_UNSPEC, ...}}, 20, ...) = 20
recvmsg(3, {{len=1056, type=RTM_NEWLINK, ...}}, ...) = 1056
一次 ip addr show 的完整流程清晰可见:创建 AF_NETLINK/NETLINK_ROUTE 的原始套接字(类型 SOCK_RAW)、bind、发一条 RTM_GETLINK 的 DUMP 请求(20 字节,只有头部 + 固定头)、然后收一系列 RTM_NEWLINK 响应(1056 字节一条,含链路状态 + IP 地址属性)。nl_pid=0 的 bind 表示「让内核给我分配端口 ID」(通常等于进程 PID),groups=00000000 表示不订阅任何多播组——只做请求-响应,不听事件。要收事件,把 groups 改成多播组位掩码即可。
三、NETLINK_ROUTE 与多播组:事件监听实战
NETLINK_ROUTE(即 rtnetlink)是 netlink 最常用的子协议,管理网络栈的全部对象:链路(RTM_GETLINK/NEWLINK)、地址(RTM_GETADDR)、路由(RTM_GETROUTE)、邻居表(RTM_GETNEIGH)、规则(RTM_GETRULE)……读和写是同一套消息格式(GET 查询、NEW 创建、DEL 删除),ip link set eth0 up 底层就是一条 RTM_NEWLINK 消息。它的事件多播组是位掩码常量(linux/rtnetlink.h):RTMGRP_LINK(链路 up/down/改名/MTU 变化)、RTMGRP_IPV4_IFADDR(IPv4 地址增删)、RTMGRP_IPV4_ROUTE(路由表变化)、RTMGRP_IPV6_IFADDR 等。订阅多个组就是把这些位 OR 起来放进 bind 的 groups 字段。下面是一个完整的网卡事件监听器(纯 Python 标准库,零依赖,可直接运行):
import socket, struct, select
RTMGRP_LINK = 1 # 订阅链路事件多播组
s = socket.socket(socket.AF_NETLINK, socket.SOCK_RAW, socket.NETLINK_ROUTE)
s.bind((0, RTMGRP_LINK)) # nl_pid=0 让内核分配,groups=RTMGRP_LINK
RTM_NEWLINK, RTM_DELLINK = 16, 17
IFF_UP = 1
def parse(data):
off = 0
while off + 16 <= len(data):
(nlmsg_len, nlmsg_type, flags, seq, pid) = struct.unpack_from("=IHHii", data, off)
if nlmsg_len < 16: break
if nlmsg_type == RTM_NEWLINK:
# ifinfomsg 固定头 16 字节,其后是 IFLA 属性
ifi_flags = struct.unpack_from("=I", data, off + 16 + 4)[0]
# 属性区跳过 4 个固定字段,从 off+32 开始解析 IFLA_IFNAME (type=3)
p = off + 16 + 16
while p + 4 <= off + nlmsg_len:
rta_len, rta_type = struct.unpack_from("=HH", data, p)
if rta_len < 4: break
if rta_type == 3: # IFLA_IFNAME
name = data[p+4:p+rta_len-1].decode()
up = bool(ifi_flags & IFF_UP)
print(f"{name}: {'UP' if up else 'DOWN'}")
p += (rta_len + 3) & ~3 # 4 字节对齐
off += (nlmsg_len + 3) & ~3
while True:
parse(select.select([s], [], [])[0][0].recv(65536))
跑起来后手动 ip link set eth0 down,监听器在毫秒级内输出 eth0: DOWN——对比轮询方案(比如每秒读一次 /sys/class/net/eth0/operstate),感知延迟从平均 500ms 缩短到几毫秒,还省掉了持续的轮询 CPU。这就是「内核主动推送」的价值,也是为什么 CNI 插件、SDN agent、keepalived 这类网络敏感组件全部基于 netlink 事件驱动。多播组机制还有个值得知道的细节:内核对多播组是「投递给所有订阅者」,消息里带 nlmsg_pid 标记事件源端口(通常为 0,表示来自内核);用户态进程之间也可以用同一机制互发(多播组 1-31 是内核保留给 rtnetlink 等的,32 以上可自定义)——不过进程间通信一般直接用 Unix 域套接字更合适(本系列早前讲过),netlink 的用武之地始终是内核态↔用户态这一跳。
事件驱动还有一个必须处理的生产级问题:多播消息会丢,recv 报 ENOBUFS 就是被丢了。netlink 的多播投递走每个 socket 的接收队列(受 SO_RCVBUF 限制,netlink 默认约 212992 字节),监听进程消费不及时(GC 停顿、调度延迟、事件风暴——比如一次网络抖动让路由表每秒变化上百条)时,内核不会阻塞投递,而是丢弃整条消息并给 socket 置 ENOBUFS 错误:下次 recvmsg 返回 -1 且 errno 是 ENOBUFS。丢失的消息不会补发,多播语义就是尽力而为。所以工业级监听器(libnl 的文档、Cilium 等源码里都能看到这个模式)的标配是收到 ENOBUFS 后做全量 resync:丢弃增量状态,重新发一轮 DUMP 请求(RTM_GETLINK + NLM_F_DUMP)重建完整视图。这个「增量事件 + 定期/出错时全量对账」的双轨是所有内核事件订阅系统的通用范式——netlink 如此,inotify 如此,Kubernetes 的 watch + list 也是同一个设计。排障时还有一个冷知识:netlink socket 的队列深度可以从 /proc/net/netlink 看到(每行一个 netlink 端口,sk 列是内核 socket 指针、R 列是当前接收队列字节数、drops 列是累计丢弃数)——drops 非零就是监听器消费跟不上事件的直接证据,第一反应不该是调大 RCVBUF 掩盖,而是查监听器为什么卡了。
四、generic netlink 与工具生态
早期每个内核子系统都想加自己的 netlink 协议族,NETLINK_* 编号很快不够用,于是 2.6.23 引入 generic netlink:一个「协议族注册器」,子系统动态注册一个家族名(如 "nl80211"、"TASKSTATS"),用户态先发 CTRL_CMD_GETFAMILY 查询拿家族 ID 和操作列表,再按操作 ID 发请求——编号不再是稀缺资源,消息格式也统一由 genl 管理。无线配置(nl80211,iw 命令的底层)、任务统计(TASKSTATS, atop 记录每进程 IO 的来源)、Open vSwitch(OVS datapath 控制通道)、ACPI 事件、审计日志(NETLINK_AUDIT)等都架在 genl 之上。应用工程师接触 genl 最多的场景是性能观测:atop 采集的每进程磁盘 IO、iotop 的实时排序,都依赖 TASKSTATS 家族的 netlink 推送——理解这一点,遇到 atop 数据缺失时就知道该去查 CONFIG_TASKSTATS 内核配置与 genl 家族注册状态。
用户态工具生态有三层。C 库:libnl(最完整、API 繁琐)、libmnl(轻量,内核社区主推,iproute2 自身用它)、libnetlink(iproute2 内嵌的极简封装)。Python:pyroute2 是事实标准,把 rtnetlink/genl 全部对象化,用 IPRoute() 读接口、NetNS() 操作其他 netns 的网络栈、IPQ() 收内核排队的数据包——几行代码就能替代一份 C 工程。Go:vishvananda/netlink 库(CNI 插件如 Cilium、Calico 的网络配置层几乎都架在它上面),netlink.LinkByName("eth0")、netlink.AddrAdd() 这类调用直接映射 rtnetlink 消息。权限边界要记清:读操作(dump 链路/地址/路由)大多数不需要特权——普通用户跑 ip addr 就是证据;写操作(改接口、加路由、动防火墙)需要 CAP_NET_ADMIN——在目标 netns 内持有即可,这正是容器里 CNI 插件能配置容器网络的原因(容器进程在自己的 netns 里有 NET_ADMIN)。订阅多播组本身不需要特权,但收到的消息范围受能力影响:无 NET_ADMIN 时某些对象(如完整的 neigh dump)会被内核过滤——排障时「监听器收不到事件」先查能力再看多播组。
小结
Netlink 是 Linux 内核与用户态之间的专用通信通道,三个设计优势决定了它成为现代网络栈的统一总线:双向通信(内核可主动多播事件,告别轮询)、结构化协议(nlmsghdr + TLV 属性流,告别文本解析)、套接字语义(天然融入 epoll 事件循环,支持非阻塞与异步)。NETLINK_ROUTE 覆盖网络栈全部对象的查询与配置——ip 命令的全部功能都是它的封装;多播组机制让事件按类型订阅,网卡事件的毫秒级感知只需一段十几行的监听代码。generic netlink 解决了协议族编号的扩展性问题,nl80211、TASKSTATS、OVS 都架在它上面——atop/iotop 的每进程 IO 数据来源就在这里。用户态生态三层封装(libmnl/libnl → pyroute2 → Go netlink),CNI 插件的容器网络配置全部落在这些库上。权限边界:读基本自由,写需要目标 netns 内的 CAP_NET_ADMIN,多播订阅自由但收到的内容受能力过滤——监听器「收不到事件」先查能力集。对应用工程师,netlink 的价值在两个时刻显现:需要毫秒级网络事件感知时(轮询方案出局),需要程序化配置网络栈时(shell 拼 ip 命令的解析地狱出局)——这两种时刻在云原生时代的后端工程里越来越常见。
读者留言
COMMENTS 暂无还没有留言,来说第一句?