线上 Pod 报 502,应用日志只有一行 connection refused。你下意识敲下 kubectl exec -it <pod> -- sh,结果返回一句冰冷的 exec: "sh": executable file not found in $PATH——这是个 distroless 镜像。想装 curl?镜像里没有包管理器。想抓包?没有 tcpdump。想看连接?netstat、ss 统统不存在。更糟的情况是 Pod 处于 CrashLoopBackOff,容器活不过三秒,exec 连上去的瞬间进程就没了。
这种时候,大部分人的选择是改 Dockerfile、重新构建镜像、推仓库、滚动更新——为了排查一个可能五分钟就能定位的问题,走了一遍完整的发布流程,还污染了生产镜像。
其实你根本不需要进容器。在节点上跑一次 eBPF 工具,就能看见这个节点上所有 Pod 的系统调用、网络连接和文件事件。
一句话点透原理
eBPF 在内核里挂探针(kprobe / tracepoint / uprobe),而同一个节点上的所有容器共享同一个内核。容器之间的隔离靠的是 namespace 和 cgroup,隔离的是“视图”,不是“内核路径”。所以无论目标进程躲在哪个 netns、哪个 mntns 里,它发起的 connect()、openat()、execve() 最终都要经过同一份内核代码。你在节点上(或者一个有特权的 DaemonSet Pod 里)挂一个探针,内核执行到那里就会回调你的程序,顺手把当前进程的 cgroup ID 一并报出来——于是你不仅能看到事件,还能知道它是哪个 Pod 的。
这就是为什么 eBPF 能“不进容器看到容器”:观测点选在内核,而不是容器内。不改镜像、不进容器、不重启 Pod,全程只读。
工具选型对比
| 工具 |
形态 |
典型用途 |
内核要求 |
前置条件 |
| kubectl gadget(Inspektor Gadget) |
K8s 原生,DaemonSet 部署,CR 或 CLI 驱动 |
DNS、TCP、exec、signal、oomkill、block-io、快照类 |
建议 ≥ 5.8,最低 4.19 可用部分能力 |
privileged DaemonSet,需 hostPID;BTF 非强制但强烈建议 |
| bcc 工具集 |
节点上的 Python/C 前端,命令即工具 |
tcpconnect、tcpretrans、biosnoop、execsnoop、opensnoop |
≥ 4.9,推荐 ≥ 5.4 |
节点可登录,root + SYS_ADMIN,需内核头文件(无 BTF 时) |
| bpftrace |
一行式脚本语言,awk 风格 |
临时统计、延迟分布、自定义聚合 |
≥ 4.9,推荐 ≥ 5.4 |
root,BTF 用于结构体自动解析 |
| pwru |
单一用途,跟踪 skb 在内核网络栈的走向 |
定位包被哪一层丢掉(netfilter、tc、路由、socket) |
≥ 5.4,推荐 ≥ 5.15 |
root + SYS_ADMIN,必须开启 BTF |
| Cilium Hubble |
网络可观测平台(Cilium CNI 自带) |
L3/L4/L7 流、丢包原因、服务依赖图 |
≥ 5.4(Cilium 要求) |
需以 Cilium 为 CNI |
| NetObserv(FlowCollector) |
eBPF + 流采集 + 可视化 |
集群级网络流拓扑、按 namespace 聚合 |
≥ 5.8 |
需安装 FlowCollector Operator |
选型建议:日常排障首选 kubectl gadget,它是 K8s 原生的,自带 Pod/namespace 过滤,输出直接带 Pod 名,不用你去翻译 cgroup ID;需要看“包在内核里被谁丢了”用 pwru,这是 tcpdump 做不到的;临时搞个自定义统计用 bpftrace 一行流;已经有 Cilium 就直接用 Hubble,别再叠一层。
安装与跑通
前置检查(这一步千万别跳)
# 1. 内核版本,5.8+ 体验最好
uname -r
# 2. 内核是否开启 BTF(这是 CO-RE 一次编译到处运行的前提)
cat /boot/config-$(uname -r) | grep BTF
# 期望看到 CONFIG_DEBUG_INFO_BTF=y
# 3. 运行时确认 BTF 文件真的挂出来了
ls -l /sys/kernel/btf/vmlinux
# 4. 集群里所有节点内核版本是否一致(混部是最大的坑)
kubectl get nodes -o jsonpath='{range .items}{.metadata.name}{"\t"}{.status.nodeInfo.kernelVersion}{"\t"}{.status.nodeInfo.containerRuntimeVersion}{"\n"}{end}'
第 4 条极其重要。如果你集群里同时有 5.4 和 5.15 的节点,gadget 的 DaemonSet 会在低版本节点上 CrashLoop,你会误以为是工具坏了。先确认内核一致,再决定要不要加 nodeSelector 只在一批节点上跑。
部署 kubectl gadget
# 安装 krew 插件
kubectl krew install gadget
# 部署 DaemonSet(默认 namespace: gadget)
kubectl gadget deploy
# 验证:客户端版本 + 每个节点上的 gadget pod 状态
kubectl gadget version
kubectl get pods -n gadget -o wide
kubectl gadget version 会依次列出每个节点的 gadget pod 状态,如果某个节点显示不可用,八成是内核版本或 BTF 的问题,而不是网络问题。kubectl gadget deploy 支持 --node-selector、--tolerate-all 等参数,生产环境建议只部署到可疑节点所在的节点池:
kubectl gadget deploy --node-selector="nodepool=debug" --image-pull-policy=IfNotPresent
用完记得卸载,别把 privileged DaemonSet 常驻在生产集群:
kubectl gadget undeploy
快速验证能抓到东西
kubectl gadget trace exec -n kube-system --timeout 10
只要能看到事件刷出来,说明整条链路是通的。
六大场景实战
下面所有命令中的 -n prod -p <pod> 换成你自己的 namespace 和 Pod 名。永远带上过滤条件,这是生产环境的纪律。
场景一:抓 DNS 解析
distroless 里没有 nslookup,但 DNS 请求在内核 UDP 层一览无余。
kubectl gadget trace dns -n prod -p payment-api-7d9f8b6c5-x2klm
输出样例:
NODE NAMESPACE POD PID TID COMM QR QTYPE NAME RCODE LATENCY_NS
node-03 prod payment-api-7d9f8b6c5-x2klm 1 128 java Q A redis.prod.svc... 0
node-03 prod payment-api-7d9f8b6c5-x2klm 1 128 java Q AAAA redis.prod.svc... 0
node-03 prod payment-api-7d9f8b6c5-x2klm 1 128 java R A redis.prod.svc... 0 1247321
node-03 prod payment-api-7d9f8b6c5-x2klm 1 128 java R AAAA redis.prod.svc... 3 5012388471
怎么读:QR 列的 Q 是查询、R 是响应;RCODE 里 0 是成功、3 是 NXDOMAIN、2 是 SERVFAIL;LATENCY_NS 单位是纳秒。上面这份输出里 AAAA 记录耗时 5 秒才回来——Java 应用默认走 IPv6 优先,AAAA 超时会拖垮整次解析,这就是“应用日志只有 connection refused”的真凶。排查技巧是先盯 LATENCY_NS 异常的行,再看 RCODE,不要一上来就找失败。
场景二:抓 TCP 连接
kubectl gadget trace tcp -n prod -p payment-api-7d9f8b6c5-x2klm
kubectl gadget trace tcpconnect -n prod
trace tcp 输出(节选):
NODE NAMESPACE POD PID COMM IP SADDR DADDR SPORT DPORT TYPE WITH RSTACK
node-03 prod payment-api... 128 java 4 10.42.1.33 10.42.9.12 45678 6379 connect -
node-03 prod payment-api... 128 java 4 10.42.9.12 10.42.1.33 6379 45678 accept - -
node-03 prod payment-api... 128 java 4 10.42.1.33 10.42.9.12 45678 6379 close - -
trace tcp 覆盖 connect / accept / close 全生命周期,能看出“连上了但立刻被关”这类问题;trace tcpconnect 只盯主动外连,输出更干净。判断谁主动断连的关键是看 close 事件里哪一端先发,配合 RSTACK 列能看出 RST 是被谁发的。如果你看到大量 connect 后几十毫秒内就 close,且 DPORT 是数据库端口,那多半是连接池配置问题,而不是网络问题。
场景三:抓丢包与重传
这是 tcpdump 无能为力的场景:包在内核里被丢了,网卡上根本抓不到。
# pwru:跟踪目的端口 8080 的包在内核网络栈里的每一步
pwru --filter-dst-port 8080
# bcc:重传统计
/usr/share/bcc/tools/tcpretrans
tcpretrans 输出:
TIME PID IP LADDR:LPORT RADDR:RPORT STATE
09:14:22 0 4 10.42.1.33:45678 10.42.9.12:6379 ESTABLISHED
09:14:22 0 4 10.42.1.33:45678 10.42.9.12:6379 ESTABLISHED
重传密集说明链路有丢包或拥塞。要看具体丢在哪一层,就得上 pwru:它会逐行打印 skb 经过的内核函数(nf_hook_slow、tc_egress、ip_output、dev_queue_xmit……),你看到哪一行的下一跳不见了,丢包点就在那里。常见结论有两个:一是 netfilter 规则(NetworkPolicy / iptables)静默 DROP,二是 MTU 不匹配导致分片被丢。
场景四:抓命令执行与进程退出(SIGKILL / OOM)
kubectl gadget trace exec -n prod
kubectl gadget trace signal -n prod -p payment-api-7d9f8b6c5-x2klm
kubectl gadget trace oomkill -A
trace signal 输出:
NODE NAMESPACE POD PID COMM SIGNAL TPID RET MOUNT_NS
node-03 prod payment-api... 128 java SIGKILL 128 0 4026532210
trace signal 是排查“进程为什么没了”的第一工具。看到 SIGKILL 且发送方 PID 是内核(常见表现为 TPID 为 0 或来自内核线程),基本可以锁定 OOM Killer,而不是应用崩溃、也不是 liveness 探针失败。紧接着跑 trace oomkill 确认:
NODE NAMESPACE POD TPID TPCOMM PAGES MOUNT_NS
node-03 prod payment-api... 128 java 65536 4026532210
PAGES 乘以页大小约等于被杀进程当时占用的内存,能帮你判断是内存泄漏还是 limit 设小了。
场景五:抓文件与 IO
# 谁在打开什么文件(distroless 里没有 lsof,这是平替)
kubectl gadget trace open -n prod -p payment-api-7d9f8b6c5-x2klm
# 谁在监听端口(替代 netstat -tlnp)
kubectl gadget trace bind -A
# 块设备 IO(替代 iostat -x 的 per-Pod 版本)
kubectl gadget trace block-io -n prod
trace open 输出:
NODE NAMESPACE POD PID COMM FD ERR FLAGS FNAME
node-03 prod payment-api... 128 java 7 0 O_RDONLY /etc/ssl/certs/ca.crt
node-03 prod payment-api... 128 java -1 2 O_RDONLY /app/config/secret.yaml
ERR 非 0 的行要重点看——上面第二行是 ENOENT(文件不存在),典型原因是 ConfigMap 挂载路径写错,或者 Secret 被删了却没人知道。trace bind 能回答“这个端口到底有没有人监听”,比 exec 进去试要可靠得多。
场景六:抓慢系统调用(strace 无侵入版)
kubectl gadget trace syscall -n prod -p payment-api-7d9f8b6c5-x2klm
这相当于给目标 Pod 挂了个不需要 ptrace、不需要进容器的 strace。注意这是开销最大的一类操作,务必限定到单个 Pod,并且设好 --timeout,不要让它一直跑。
快照类:一次性拿到全貌
# 替代 ps:列出 Pod 内所有进程
kubectl gadget snapshot process -n prod -p payment-api-7d9f8b6c5-x2klm
# 替代 netstat:列出 Pod 所有 socket
kubectl gadget snapshot socket -n prod -p payment-api-7d9f8b6c5-x2klm
快照类是一次采集、立刻退出,开销极低,适合作为“第一眼”。snapshot socket 尤其好用:一个连接泄露的 Pod,你会看到 ESTABLISHED 状态的 socket 数量随时间单调增长。
bpftrace 一行流
以下 5 条都是可以直接复制执行的。在节点上执行需要有 root 和 SYS_ADMIN 权限。
# 1. 统计某 Pod 内 read 系统调用的延迟分布(微秒,按 2 的幂分桶)
bpftrace -e 'kprobe:vfs_read /cgroup == ... / { @start[tid] = nsecs; }
kretprobe:vfs_read /@start[tid]/ { @us = hist((nsecs - @start[tid]) / 1000); delete(@start[tid]); }'
# 2. 统计 TCP SYN 重传次数,按目的地址聚合
bpftrace -e 'kprobe:tcp_retransmit_skb { @[ntop(AF_INET, *(uint32*)(arg0 + 20))] = count(); }'
# 3. 统计每秒 exec 调用次数,按进程名聚合
bpftrace -e 'tracepoint:syscalls:sys_enter_execve { @[comm] = count(); } interval:s:1 { print(@); clear(@); }'
# 4. 找出打开文件时报错最多的进程
bpftrace -e 'tracepoint:syscalls:sys_exit_openat /args->ret < 0/ { @[comm, args->ret] = count(); }'
# 5. 统计某 cgroup 内各系统调用的耗时总和,找出最慢的那一个
bpftrace -e 'tracepoint:syscalls:sys_enter_* { @s[probe] = count(); }' -c 'sleep 5'
第 1 条里的 /cgroup == .../ 是 cgroup ID 过滤,实际使用时先 kubectl gadget snapshot process 拿到目标 Pod 的 cgroup,或者直接用 --filter 类参数(gadget CLI 内部已封装这一层,能用 gadget 就用 gadget,bpftrace 留给 gadget 覆盖不到的自定义统计)。
生产环境注意事项
内核与 BTF:5.8 以上体验最好,5.4 能用但部分高级 gadget 会退化,4.19 以下基本只能跑最基础的 bcc 工具。没有 BTF 时,CO-RE 失效,要么在目标节点上现场编译(需要装内核头文件),要么降级到非 CO-RE 的工具版本。混部不同内核版本的集群,务必用 nodeSelector 把 gadget 限定在同构节点池。
权限与合规:eBPF 程序需要 privileged 或至少 SYS_ADMIN + SYS_BPF capability,同时 gadget DaemonSet 需要 hostPID 才能把内核事件映射回 Pod。这意味着它具备观测节点上一切的能力——用完必须卸载(kubectl gadget undeploy),并且把使用记录写进变更工单或审计日志。不要把它当成常驻组件装在不需要排查的节点上。
开销评估:探针类型的开销从低到高是 tracepoint < kprobe < uprobe(uprobe 因为要处理用户态地址空间,开销最高)。绝对不要在业务高峰期对全集群开 trace syscall。经验值:单个 Pod 的 syscall trace 会让该 Pod 所在节点的 CPU 上涨数个到数十个百分点,取决于事件频率。所有命令都必须带 filter——namespace、pod、port、pid,能加几个加几个。同时用 --timeout 限制运行时长,避免你接了个电话回来发现它跑了半小时。
只读原则:本文所有工具都是观测型的,不要用来修改内核行为。eBPF 有能力做更激进的事(改返回值、丢包、重定向),但那是故障注入和网络插件的领域,排障时越界会把问题搞得更复杂。
运行时与托管集群差异:containerd 和 cri-o 在 gadget 里都能被正确识别,但要注意容器 ID 与 PID 的映射方式不同,老版本工具在 cri-o 上可能出现 Pod 名为空的情况,升级到较新的 gadget 版本可解决。托管集群方面,GKE 的 Ubuntu 节点和 Container-Optimized OS 节点内核版本不同,后者常缺 BTF;EKS 的 Amazon Linux 2 内核较老(5.4/5.10),建议直接选 Bottlerocket 或较新的 AL2023 节点;部分托管集群禁止部署 privileged DaemonSet,此时只能在有节点登录权限的自建集群上用,或者申请临时提权。
踩坑实录
案例一:distroless 镜像抓不了包,三分钟定位 AAAA 超时
某支付服务偶发 502,镜像是 gcr.io/distroless/java,里面什么都没有。运维同学已经准备改 Dockerfile 加 curl 了。改用 kubectl gadget trace dns -n prod -p payment-api-xxx,刷新十秒就看到 A 记录 1.2ms 返回,而 AAAA 记录耗时 5.01 秒且 RCODE=3。根因是集群 DNS 上游对 AAAA 的处理慢,Java 双栈解析被拖死。修复方式是在 JVM 参数加 -Djava.net.preferIPv4Stack=true,一行配置,没有动镜像也没有重启集群组件。
案例二:Pod 反复被杀,所有人都误判成 liveness 探针
一个 Pod 每隔十几分钟重启一次,kubectl describe 里 Last State 是 Terminated,原因空白。值班同学按经验调大了 liveness 的 initialDelaySeconds 和 failureThreshold,毫无效果,还让故障暴露得更慢。跑 kubectl gadget trace signal -n prod -p <pod>,抓到一条 SIGKILL,再跑 kubectl gadget trace oomkill,明确看到该 Pod 的 java 进程被 OOM Killer 选中,PAGES 换算出来约 256MB——正好撞上容器 memory limit。真相是 OOM,不是探针。把 limit 从 256Mi 提到 512Mi 后稳定。教训:Exit Code 137 不等于探针失败,SIGKILL 的来源要用 signal 和 oomkill 两个 gadget 交叉确认。
案例三:全量 syscall trace 把节点 CPU 打高 30%
新人为了“看得全一点”,跑了一条不带任何 filter 的 kubectl gadget trace syscall -A,然后去吃饭了。二十分钟后监控告警:某节点 CPU 从 20% 涨到 50%,P99 延迟翻倍。值班同学第一反应是“业务有异常”,查了半天应用指标才发现是观测工具自己在烧 CPU。事后定的规矩有三条:一是所有 trace 命令必须带 namespace 或 pod 过滤;二是必须带 --timeout,默认不超过 60 秒;三是排查动作要提前在群里报备,避免自己和同事互相惊吓。
速查表与 Checklist
该用哪个 gadget 子命令
| 你想知道什么 |
用这条 |
替代了什么命令 |
| DNS 有没有解析失败 / 慢 |
trace dns |
nslookup / dig |
| 谁在连谁、谁先断 |
trace tcp / trace tcpconnect |
netstat / ss |
| Pod 里有哪些 socket |
snapshot socket |
netstat -anp |
| Pod 里有哪些进程 |
snapshot process |
ps aux |
| 谁在监听端口 |
trace bind |
netstat -tlnp |
| 谁在打开文件、哪些失败了 |
trace open |
lsof / strace -e open |
| 进程为什么没了 |
trace signal |
无(dmesg 可得部分信息) |
| 是不是 OOM |
trace oomkill |
dmesg |
| 谁在执行命令 |
trace exec |
auditd / execsnoop |
| 磁盘 IO 慢在哪 |
trace block-io |
iostat -x / biosnoop |
| 某个系统调用特别慢 |
trace syscall |
strace |
| 包在内核里被谁丢了 |
pwru |
tcpdump(抓不到内核丢包) |
排查 Checklist
- 先确认
kubectl exec 是否真的不可用,能进就先简单手段,不要一上来就上 eBPF。
- 确认目标 Pod 所在节点,把 gadget 部署范围收敛到该节点或节点池。
- 检查内核版本一致性:
kubectl get nodes -o jsonpath 对比所有节点。
- 检查 BTF:
ls /sys/kernel/btf/vmlinux,同时确认 CONFIG_DEBUG_INFO_BTF=y。
- 先跑快照类(
snapshot process / snapshot socket)建立基线,开销最低。
- 网络问题按 DNS → TCP 连接 → 重传/丢包 的顺序排查,不要跳步。
- 进程退出问题先
trace signal 再 trace oomkill,两者交叉验证。
- 所有 trace 命令必须带
-n 和 -p,并加 --timeout。
- 高峰期只做快照类,trace 类放到低峰或先摘流量。
- 抓到输出后先看异常列(RCODE、ERR、LATENCY_NS、SIGNAL),再逐行读。
- 定位完成后立刻
kubectl gadget undeploy,并记录到变更审计。
- 若集群已用 Cilium,优先 Hubble,避免重复部署观测组件。
- 修复动作落到配置层(JVM 参数、limit、NetworkPolicy、DNS 配置),绝不改镜像加调试工具。
面试加分点
eBPF 为什么能“不进容器看到容器”? 因为容器的隔离是 namespace 级别的视图隔离,不是内核级别的隔离。同节点所有容器共享同一个内核镜像,任何系统调用都要经过同一份内核代码路径。eBPF 探针挂在内核函数或 tracepoint 上,属于“上游观测点”,天然能看到所有 namespace 里的事件。而事件里携带的 cgroup ID / PID 又能被反查成 K8s 的 Pod 与 Container,于是“站在节点上看容器”就成立了。这也解释了为什么跨节点的流量你看不到——eBPF 的观测边界是内核,也就是单节点。
kprobe 与 tracepoint 的稳定性差异:tracepoint 是内核开发者在源码里埋好的静态钩子点,属于稳定的 ABI,名字和参数语义跨版本保持一致,升级内核不会让你的脚本失效。kprobe 是动态挂载到任意内核函数入口的,依赖内核符号名和结构体布局,内核一升级,函数名可能改、结构体字段可能挪位——脚本会挂不上去,或者更糟:挂上去了但读错了字段,给出错误结论。所以生产环境能用 tracepoint 就用 tracepoint;必须用 kprobe 时,用 CO-RE + BTF 让字段偏移在加载时自动重定位,这才是现代 eBPF 工具(包括 gadget 和 pwru)强调 BTF 的根本原因。
总结
distroless、CrashLoop、内核态丢包、文件被谁删了——这些“进不去容器”的场景,本质上是排查位置和问题位置错配了。问题发生在内核里,你却想在容器里找答案。eBPF 把观测点放回内核,顺手把 K8s 的语义(Pod、Namespace、Container)贴回事件上,于是不改镜像、不进容器、不重启 Pod 也能看清全貌。
记住三条纪律:先快照后 trace,永远带 filter,用完就卸载。工具给你的是上帝视角,别把它变成另一个故障源。这类内核级排障经验在云栈社区有不少实战分享,遇到疑难杂症不妨去翻翻。