一次使用 ebpf 来解决 k8s 网络通信故障记录

最近在客户环境部署的边缘服务器集群中,出现了一个的 k8s 网络连通性问题:

  • ✅ Node 节点之间网络连通正常
  • ✅ Pod 到对端 Node 网络连通正常
  • ❌ Pod 之间网络通信异常,pod 之间无法 ping 通,tcp 无法建连

网络方案使用的是 Flannel VXLAN 覆盖网络,做了一下经过初步排查:尝试随便发送一些数据到对端的 vxlan 监听的 8472 端口,是可以抓到包的,说明网络链路是通的。

但是我们 vxlan 发送的 udp 包对端却始终收不到,于是找到了其中一个 vxlan 包(如下图所示),来手动发送。

yaml 复制代码
nc -u 10.0.14.34 8472 < udp_data.txt

使用 nc 手动发送,通过抓包确认对端没有收到,但是随便换一个内容(里面是普通 ascii 字符串)发送,对端是可以立刻抓到包的。

yaml 复制代码
nc -u 10.0.14.34 8472 < other_content.txt

通过分析分析两边的丢包(使用 dropwatch、systemtap 等工具),并没有看到 udp 相关的丢包,因此推断问题可能出现在了 vxlan 包的特点上,大概率是被中间交换机等设备拦截。

反向推断,拦截设备如何可以精准识别 vxlan 的包?一定是 vxlan 包有一定的特点。

vxlan

通过 rfc(datatracker.ietf.org/doc/html/rf... vxlan 包的包头是固定的 0x08,中间交换机大概率是根据这个来判断的,于是初步的测试,把一直发送不成功的包,稍微改一下。

不出意外的,只改了一个字节,对端就能收到了,vxlan 的 flag 也变为了 0x88。

现在知道了问题,就有几种方案可以选择:

  • 联系客户进行处理
  • 切换 k8s 的网络模型
  • 使用 iptables 或者 ebpf 等歪门邪道 hack

使用 ebpf 来欺骗中间交换机

接下来使用 ebpf 来把每个发出去的 vxlan 包的第一个字节从 0x08 改为 0x88(或者其它),收到对端的 vxlan 包以后,再把 0x88 还原为 0x08,交给内核处理 vxlan 包。

这里使用 rust aya 来快速开发 ebpf 程序,Aya 是一个用 Rust 编写的 eBPF 开发框架,专注于简化 Linux eBPF 程序的开发过程。它允许开发者直接在 Rust 中编写 eBPF 程序,无需使用 C 语言。通过 rustc 把 rust 直接 llvm 编译到 ebpf 字节码。同时提供了高级 API 封装,使用起来非常方便。

因为我们要同时处理出站和入站的流量,我们需要使用 ebpf 的 tc 模块。完整的代码见: github.com/arthur-zhan...

先来处理出站流量(egress),代码如下,核心逻辑就是把 vxlan 包中第一个字节从 0x08 改为 0x88

rust 复制代码
const FAKE_VXLAN_FLAG_BYTE: u8 = 0x88;
const REAL_VXLAN_FLAG_BYTE: u8 = 0x08;

#[classifier]
pub fn tc_egress(ctx: TcContext) -> i32 {
    let _ = try_tc_egress(ctx);
    TC_ACT_PIPE
}

fn try_tc_egress(mut ctx: TcContext) -> Result<(), ()> {
    let first_byte: u8 = get_vxlan_flag(&ctx)?;
    if first_byte != REAL_VXLAN_FLAG_BYTE {
        return Ok(());
    }
    if let Err(err) = ctx.store(PAYLOAD_OFFSET, &FAKE_VXLAN_FLAG_BYTE, 0u64) {
        info!(&ctx, "try_tc_egress bpf_skb_store_bytes failed");
        return Err(());
    }
    Ok(())
}


fn get_vxlan_flag(ctx: &TcContext) -> Result<u8, ()> {

    let eth_hdr: EthHdr = ctx.load(0).map_err(|_| ())?;

    let eth_type = eth_hdr.ether_type;

    if eth_type != EtherType::Ipv4 {
        return Err(());
    }

    // load the IPv4 header
    let ipv4hdr: Ipv4Hdr = ctx.load(EthHdr::LEN).map_err(|_| ())?;

    if ipv4hdr.proto != IpProto::Udp {
        return Err(());
    }
    // load the UDP header
    let udp_hdr: UdpHdr = ctx.load(EthHdr::LEN + Ipv4Hdr::LEN).map_err(|_| ())?;

    // check if the destination port is VXLAN
    let dst_port = u16::from_be(udp_hdr.dest);
    if dst_port != VXLAN_PORT {
        return Err(());
    }
    // load vxlan flag byte
    let first_byte: u8 = ctx.load(PAYLOAD_OFFSET).map_err(|_| ())?;
    Ok(first_byte)
}

ingress 流量处理是完全一样的,把 0x88 改为 0x80

rust 复制代码
fn try_tc_ingress(mut ctx: TcContext) -> Result<(), ()> {
    let first_byte: u8 = get_vxlan_flag(&ctx)?;

    if first_byte != FAKE_VXLAN_FLAG_BYTE {
        return Ok(());
    }

    if let Err(err) = ctx.store(PAYLOAD_OFFSET, &REAL_VXLAN_FLAG_BYTE, 0u64) {
        info!(&ctx, "try_tc_ingress store failed");
        return Err(());
    }

    if let Err(err) = ctx.l4_csum_replace(UDP_CSUM_OFF as usize, FAKE_VXLAN_FLAG_BYTE as u64,
                                          REAL_VXLAN_FLAG_BYTE as u64, 2) {
        info!(&ctx, "try_tc_ingress bpf_l4_csum_replace failed");
        return Err(());
    }

    Ok(())
}

userspace 的程序比较简单,就是挂载 bpf 程序,精简的逻辑如下:

rust 复制代码
#[tokio::main]
async fn main() -> anyhow::Result<()> {
    let bpf_dir = std::env::var("BPF_DIR").unwrap_or("/tmp".to_string());
    let Opt { iface } = opt;

    let bpf_path = format!("{}/{}", bpf_dir, "vxlan-hack-tc-ebpf");
    let bpf_data = std::fs::read(bpf_path).unwrap();;
    let mut ebpf = aya::Ebpf::load(&bpf_data[..])?;

    BpfLogger::init(&mut ebpf);
    
    let _ = tc::qdisc_add_clsact(&iface);
    let program: &mut SchedClassifier =
        ebpf.program_mut("tc_egress").unwrap().try_into()?;
    program.load()?;
    program.attach(&iface, TcAttachType::Egress)?;

    let ingress_program: &mut SchedClassifier =
        ebpf.program_mut("tc_ingress").unwrap().try_into()?;
    ingress_program.load()?;
    ingress_program.attach(&iface, TcAttachType::Ingress)?;


    let ctrl_c = signal::ctrl_c();
    println!("Waiting for Ctrl-C...");
    ctrl_c.await?;
    Ok(())
}

这样在两个机器上运行这个 bpf 程序,我们可以看到两个 pod 可以正常通信了。

这里其实有一个坑,就是更新了 udp 包以后需要更新它的 checksum,不然可能会被操作系统处理时丢弃掉。

在没有做 chucksum 更新时,通信异常,而且 netstat 的 InCsumErrors 值持续增长。

markdown 复制代码
# netstat -s | grep InCsumErrors
    InCsumErrors: 46668

以下面收到的包(ingress)为例

可以看到这个包的 checksum 为 0xbc8d,vxlan 的 header 位 0x88。

如果把 flag 改为 0x08,这对应的 checksum 应该为 0x3c8e,手动计算的代码如下:

rust 复制代码
let payload = [
    //vxlan header
    0x08, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, 0x00,
    0xe2, 0x3e, 0xf8, 0xd7, 0x7c, 0xd4, 0x46, 0x86,
    0x25, 0x4e, 0xcb, 0x76, 0x08, 0x00,

    0x45, 0x00, 0x00, 0x28, 0x00, 0x00, 0x40, 0x00,
    0x3f, 0x06, 0x26, 0x4d, 0x0a, 0x2a, 0x01, 0x03,
    0x0a, 0x2a, 0x00, 0x2d,

    0x27, 0x0f, 0xa8, 0xf4, 0x00, 0x00, 0x00, 0x00,
    0x03, 0xc4, 0x8f, 0x50, 0x50, 0x14, 0x00, 0x00,
    0x37, 0x35, 0x00, 0x00

];

let mut buffer = vec![0u8; 1500];
let udp_packet_size = udp::MutableUdpPacket::minimum_packet_size() + payload.len();
let mut udp_packet = MutableUdpPacket::new(&mut buffer[..udp_packet_size]).unwrap();
udp_packet.set_source(59107);
udp_packet.set_destination(8472);
udp_packet.set_payload(&payload);
udp_packet.set_checksum(0);
udp_packet.set_length(payload.len() as u16 + 8);
let udp_packet = udp_packet.consume_to_immutable();

let checksum = ipv4_checksum(&udp_packet,
                             &Ipv4Addr::from_str("10.0.14.34").unwrap(),
                             &Ipv4Addr::from_str("10.0.14.30").unwrap(),
);
println!("checksum: 0x{:x}", checksum);

我们需要使用 bpf 的 bpf_l4_csum_replace 更新 udp 的 checksum 字段,这样改过的 udp 包就可以正常被操作系统接收了。

那发出去的包(egress 包)我们需要手动更新 checksum 吗?egress 的包可以不用更新checksum,在包发出去之前,由硬件 NIC 重新计算。

小结

bpf 是一个好东西

相关推荐
苍何6 小时前
开源微信流 Windows,微信聊天记录,可以直接给 Codex 和 Obsidan 了
后端
代码什么用7 小时前
Spring基础使用
java·后端·spring
明月_清风8 小时前
Deno 终局来了:从挑战 Node 到被 Cloudflare 收编
前端·后端·node.js
蜗牛互联网8 小时前
Java 17 HttpClient调用文件转写API的超时与失败回退
java·人工智能·后端
Dawson Zhu10 小时前
《Agentic Design Patterns》第 9 章导读:学习与适应(Learning and Adaptation)
人工智能·语言模型·架构·aigc·agi
用户693717500138411 小时前
2026,程序员的时代拐点到了
android·前端·后端
姜鱼问生11 小时前
Linux 日志增量统计:inode + offset 方案(不丢不重)
架构
惜鸟11 小时前
从源码看 pi 的上下文管理:原始数据永久保留,模型视角按需裁剪
后端
软件工程师_罗小东11 小时前
我把这套 AI 落地方案,讲成一条任务闭环
架构
量化分析码农11 小时前
【Python量化策略评估体系 #05】极端行情扛得住吗?2008/2015/2020 三场景压力测试
后端