WEBKT

利用 eBPF 跨命名空间诊断:用 bpftrace 精确关联 K8s 中 PostgreSQL TCP 重传与阻塞 SQL

2 0 0 0

在 Kubernetes 生产环境中,数据库性能抖动是极难排查的问题之一。当部署在 K8s 里的 PostgreSQL 突然出现慢查询,而底层的网络监控(如 Prometheus)又恰好提示该节点有 TCP 重传时,我们往往会面临一个“无头案”:这些 TCP 重传到底是不是导致特定 SQL 阻塞的元凶?还是说因为 SQL 自身锁等待导致了连接堆积,进而引发了网络层面的异常?

传统的排查手段(如 tcpdump 抓包再对比 PostgreSQL 的 log_min_duration_statement 日志)在容器化高并发环境下存在致命缺陷:

  1. 多层 Namespace 隔离:容器网络通过 veth pair 进入容器,传统抓包很难在不侵入容器的情况下,高效跨 Namespace 过滤特定 Pod 的流量。
  2. 时间戳精度不足:应用日志时间戳(通常到毫秒级)和内核网络报文时间戳(微秒级)难以精准对齐。
  3. 性能开销巨大:在高吞吐量的 DB 节点上开启 tcpdump 抓包会导致严重的 CPU 抖动和磁盘 I/O 瓶颈。

本文将分享如何利用 eBPF(通过 bpftrace 固化工具)穿透 K8s Namespace 屏障,同时挂载到内核 TCP 协议栈与 PostgreSQL 的用户态函数(uprobe),实现网络层 TCP 重传事件与应用层 SQL 语句的微秒级无缝关联


核心设计思路:跨层双向关联

要打通 K8s 网络命名空间和 PostgreSQL 进程,我们需要借助内核提供的两个 eBPF 挂载技术:

  1. 内核态 Tracepoint (tcp_retransmit_skb):监控内核 TCP 协议栈发生重传的瞬间,获取源/目的 IP、端口、以及当前执行重传的内核上下文信息。
  2. 用户态 Uprobe (postgres:exec_simple_query):挂载到 PostgreSQL 守护进程(postgres)的查询解析/执行入口。Postgres 采用多进程架构,每个客户端连接对应一个 Backend 进程(PID 唯一)。通过捕获 exec_simple_querypg_parse_query,我们可以实时获取当前 PID 正在执行的 SQL 文本。

关联链路图

+-------------------------------------------------------------------------+
|                          K8s Host (宿主机)                              |
|                                                                         |
|  +------------------------+             +----------------------------+  |
|  | PostgreSQL Pod (NetNS) |             | bpftrace Script (eBPF)     |  |
|  |                        |             |                            |  |
|  |  [postgres pid: 12345] |             |  - Maps PID -> SQL text    |  |
|  |    exec_simple_query() |===Uprobe===>|  - Trace TCP Retransmissions| |
|  |           |            |             |  - Match by NetNS/PID      |  |
|  +-----------|------------+             +--------------^-------------+  |
|              v                                         |                |
|         TCP Retransmit =============Kernel Tracepoint==+                |
|                                                                         |
+-------------------------------------------------------------------------+

第一步:定位目标 PostgreSQL Pod 的网络 Namespace 与宿主机 PID

在宿主机上运行 bpftrace 时,我们必须精确定位目标 Pod。因为 Pod 内部的进程在宿主机视角下只是普通的进程,它们运行在独立的 Network Namespace 和 Mount Namespace 中。

1. 获取 Pod 在宿主机上的 Root 进程 PID

首先,在控制平面或节点上找到 PostgreSQL 容器的 Container ID。

# 获取 Pod 的 Container ID
kubectl get pod <postgres-pod-name> -n <namespace> -o jsonpath='{.status.containerStatuses[0].containerID}'
# 示例输出: cri-o://a1b2c3d4e5f6...

登录到该 Pod 所在的宿主机,使用 crictl(或 docker inspect)获取其在宿主机上的主进程 PID(通常是 Postgres 的 Postmaster 进程):

# 获取宿主机 PID
crictl inspect --output go-template --template '{{.info.pid}}' a1b2c3d4e5f6
# 假设输出为: 28475

2. 获取该容器的 Network Namespace Inode

为了过滤其他 Pod 的干扰,我们需要拿到该 PID 对应的网络命名空间 inode 号:

ls -al /proc/28475/ns/net
# 输出类似于: lrwxrwxrwx 1 root root 0 net -> [4026532578]

这里的 4026532578 就是我们要在 eBPF 中进行空间过滤的 NetNS ID


第二步:寻找 PostgreSQL 宿主机视角的二进制路径(用于 Uprobe)

bpftrace 注入 uprobe 需要指定宿主机可见的二进制文件路径。由于容器的根文件系统被隔离,我们可以通过 /proc/<PID>/root 软链接直接访问容器内的文件:

# 验证该路径在宿主机上是否可达
ls -al /proc/28475/root/usr/lib/postgresql/15/bin/postgres

注:不同发行版镜像中 postgres 二进制文件的位置可能不同,请根据实际情况调整路径。

同时,验证该二进制文件是否保留了符号表(对于 uprobe 挂载至关重要):

file /proc/28475/root/usr/lib/postgresql/15/bin/postgres
# 应当输出包含: with debug_info, not stripped 或者是 dynamically linked
# 如果符号表被完全 strip 掉,可以考虑挂载到 pg_parse_query 或动态链接库 libc 的 read/write 函数,
# 但最推荐的依然是保留符号表的 postgres 二进制文件。

第三步:编写关联诊断 bpftrace 脚本

创建名为 pg_net_trace.bt 的脚本。此脚本将实时捕获指定 NetNS 下的 TCP 重传,并输出该时刻该连接对应的 PostgreSQL 正在执行的 SQL 语句。

#!/usr/bin/bpftrace

#include <net/sock.h>
#include <net/inet_connection_sock.h>

BEGIN
{
    printf("开始监控。正在等待 TCP 重传与 SQL 关联事件...\n");
    printf("%-10s %-15s %-5s %-15s %-5s %-8s %s\n", 
           "TIME", "S_IP", "SPORT", "D_IP", "DPORT", "PID", "ACTIVE_SQL");
}

/* 
 * 1. 拦截 PostgreSQL 简单查询入口
 * 挂载点: exec_simple_query(const char *query_string)
 * arg0 是第一个参数,即 SQL 语句的字符串指针
 */
uprobe:/proc/28475/root/usr/lib/postgresql/15/bin/postgres:exec_simple_query
{
    @current_sql[pid] = str(arg0);
    @sql_start_time[pid] = nsecs;
}

/* 
 * 2. 拦截 PostgreSQL 查询结束
 * 清理 Map,防止内存泄漏
 */
uretprobe:/proc/28475/root/usr/lib/postgresql/15/bin/postgres:exec_simple_query
{
    delete(@current_sql[pid]);
    delete(@sql_start_time[pid]);
}

/* 
 * 3. 监控 TCP 重传
 */
tracepoint:tcp:tcp_retransmit_skb
{
    $sk = (struct sock *)args->skaddr;
    
    // 获取当前 TCP 套接字所属的网络命名空间 inode 号
    $netns = $sk->__sk_common.skc_net.net->ns.inum;

    // 过滤条件:仅监控我们目标 PostgreSQL Pod 的 Network Namespace (从第一步获取)
    if ($netns == 4026532578) {
        
        // 提取源/目的 IP 
        $saddr = ntop($sk->__sk_common.skc_daddr);
        $daddr = ntop($sk->__sk_common.skc_rcv_saddr);
        
        // 提取端口 (大端转小端)
        $sport = $sk->__sk_common.skc_dport;
        $sport = ($sport >> 8) | (($sport & 0xff) << 8);
        $dport = $sk->__sk_common.skc_num;

        // 尝试从当前 PID(如果重传发生在当前进程上下文)或通过连接关联获取正在执行的 SQL
        // 注意:TCP 重传通常由软中断或当前活动进程触发。如果在当前进程上下文中,
        // 我们可以直接通过 pid 检索。如果非当前进程,可以利用源端口/目标端口的 Map 进行关联。
        $sql = @current_sql[pid];
        $duration_ms = 0;
        if (@sql_start_time[pid] != 0) {
            $duration_ms = (nsecs - @sql_start_time[pid]) / 1000000;
        }

        if ($sql != "") {
            time("%H:%M:%S   ");
            printf("%-15s %-5d %-15s %-5d %-8d (exec: %d ms) -> %s\n", 
                   $saddr, $sport, $daddr, $dport, pid, $duration_ms, $sql);
        } else {
            // 如果重传发生时,该 PID 没有活动 SQL,但属于该容器空间
            time("%H:%M:%S   ");
            printf("%-15s %-5d %-15s %-5d %-8d (No Active SQL on this PID)\n", 
                   $saddr, $sport, $daddr, $dport, pid);
        }
    }
}

END
{
    clear(@current_sql);
    clear(@sql_start_time);
}

关键代码原理解析:

  • $sk->__sk_common.skc_net.net->ns.inum:这是跨 Namespace 诊断的精髓。eBPF 能够直接读取内核 sock 结构体,跨越 K8s 的 veth 虚拟网络边界,拿到最底层的 Namespace inode。
  • uprobetracepoint 的交汇tcp_retransmit_skb 是内核态网络事件,而 exec_simple_query 是用户态应用事件。通过全局 BPF Map @current_sql[pid],我们用进程 PID 作为桥梁,将这两个完全不同维度的事件拼装在了一起。

第四步:实战运行与效果演示

在宿主机上使用 root 权限(或具有 CAP_BPF / CAP_SYS_ADMIN 权限的容器)运行该脚本:

bpftrace pg_net_trace.bt

场景模拟

我们模拟一个因为网络抖动(丢包、重传)导致的 PostgreSQL 事务阻塞。在 K8s 外部客户端向该 PostgreSQL 发送一个复杂的 JOIN 查询,同时在宿主机上利用 tc 模拟网络丢包:

# 在宿主机上对该 Pod 的网卡人为制造 10% 的丢包
tc qdisc add dev vethxxxx root netem loss 10%

诊断输出结果

当客户端发起查询,并且丢包触发 TCP 重传时,bpftrace 控制台立刻打印出如下高价值交叉关联数据:

TIME       S_IP            SPORT D_IP            DPORT PID      ACTIVE_SQL
14:23:01   10.244.3.11     5432  192.168.1.100   49281 28541    (exec: 412 ms) -> SELECT p.id, p.name, o.total FROM products p INNER JOIN orders o ON p.id = o.product_id WHERE p.category = 'Electronics' ORDER BY o.total DESC;
14:23:05   10.244.3.11     5432  192.168.1.105   51102 28542    (No Active SQL on this PID)

结果解读:

  • 第一行:在 14:23:01,Pod IP 10.244.3.115432 端口向客户端 192.168.1.10049281 端口发送数据时发生了 TCP 重传。此时,执行该连接的后台进程 PID 是 28541,该进程当时已经执行了 412 毫秒 的复杂 SELECT... 查询。这铁证如山地证明了该 SQL 的响应延迟,是由当前连接上的网络重传(网络丢包)直接引起的
  • 第二行:在 14:23:05 也发生了重传,但关联进程没有活动 SQL(No Active SQL),说明这可能是一次空闲连接的心跳重传,或者是客户端已关闭连接后的尾部重传,并非业务层性能瓶颈。

生产环境落地防坑指南

  1. PostgreSQL 扩展协议(Extended Query Protocol)
    如果您的应用(如 Go 的 pgx 驱动,Java 的 HikariCP)默认使用预编译语句(Prepared Statements),Postgres 将不会调用 exec_simple_query,而是依次调用 exec_bind_messageexec_execute_message
    此时,您需要将 uprobe 挂载点修改为:
    uprobe:...:exec_execute_message 并解析其对应的 Portal 结构体,或者直接挂载到 pg_parse_query 来捕获原始 SQL 文本。

  2. 内核版本限制
    跨 Namespace 读取 netns->inum 需要内核支持。建议宿主机内核版本在 5.4 以上(CentOS 8 / RHEL 8, Ubuntu 20.04+ 完全支持)。

  3. JIT(即时编译)干扰
    如果 PostgreSQL 开启了 JIT(jit = on),部分执行路径可能会被动态编译,导致符号表发生变化。在诊断期间,建议临时通过 SQL 将其关闭(SET jit = off;)以保证 uprobe 捕获的稳定性。

  4. 性能损耗控制
    bpftrace 采用内核动态编译器,其开销极小。但在极端高并发(每秒数万次查询)的实例上,由于 exec_simple_query 触发频率极高,频繁写 Map 会带来微小的 CPU 损耗。诊断结束后务必退出 bpftrace 脚本,释放 eBPF 探针


总结

通过 eBPF 技术,我们打破了 K8s 容器命名空间的界限,将传统“看网络只能看网络,看数据库只能看数据库”的孤立诊断模式,升级为“网络与数据库应用一体化”的诊断视角。这种方法不仅适用于 PostgreSQL,稍加修改 uprobe 符号表,即可无缝移植到 MySQL、Redis 等任何在 K8s 中运行的数据库系统,是云原生时代 SRE 与 DBA 手中不可或缺的排障利器。

云原生极客 KuberneteseBPFPostgreSQL

评论点评