一次使用 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 是一个好东西

相关推荐
debug 小菜鸟25 分钟前
MySQL 主从复制与一主多从架构实战详解
数据库·mysql·架构
.生产的驴29 分钟前
SpringBoot 服务器监控 监控系统开销 获取服务器系统的信息用户信息 运行信息 保持稳定
服务器·spring boot·分布式·后端·spring·spring cloud·信息可视化
jakeswang44 分钟前
Java 项目中实现统一的 追踪ID,traceId实现分布式系统追踪
java·后端·架构
白总Server1 小时前
Golang dig框架与GraphQL的完美结合
java·大数据·前端·javascript·后端·go·graphql
BoomHe2 小时前
Android 搭建模块化项目流程及建议
android·架构·gradle
Aurora_NeAr2 小时前
Spark RDD 及性能调优
大数据·后端·spark
程序员岳焱2 小时前
Stream 流式编程在实际项目中的落地:从业务场景到代码优化
java·后端·程序员
八苦2 小时前
VKProxy已提供命令行工具,镜像和简单的ui
后端
David爱编程2 小时前
Docker Daemon 调优全解,打造高性能守护进程配置!
后端·docker·容器
Guheyunyi3 小时前
AI集成运维管理平台的架构与核心构成解析
大数据·运维·人工智能·科技·安全·架构