ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

kube-apiserver远程调试实战:用Delve精准定位认证与准入问题

kube-apiserver远程调试实战:用Delve精准定位认证与准入问题 调试 kube-apiserver 这件事日志能给你的信息其实非常有限。即使你把它加到--v6打出来的也只是请求参数、调用链、响应状态真正关键的变量值在某个条件分支里被改成了什么为什么用户请求被 401 拒绝为什么 RBAC 规则明明放行了却还是 403这些靠翻日志很难定位。我之前排查一个自定义准入插件的问题花了整整两天看日志最后用调试器在 Admit 入口设了个断点十分钟就找到了原因——某个 annotation 被之前的插件改写了。如果你的工作涉及 Kubernetes 控制面组件定制、写准入控制器或者要排查 apiserver 的认证授权问题那我强烈建议你掌握 kube-apiserver 的远程调试。简单说就是在目标机器上用 Delve 以 headless 模式启动带调试符号的 kube-apiserver本地 IDE 或命令行客户端远程连上去设断点、看变量、翻调用栈整个过程跟调试普通 Go 程序没有区别。这篇文章按我实际操作的顺序从环境准备、编译参数、启动连接、断点实战到高频问题排查一次性讲透。1. 调试前的准备为什么远程调试以及环境选型1.1 本地调试的痛点与远程调试的适用场景Kubernetes 的所有组件里kube-apiserver 是最难在本地“跑起来就调”的一个。它依赖 etcd、依赖证书体系、依赖 ServiceAccount还要和 controller-manager、kubelet 配合才能看到一个完整的请求生命周期。在本地开发机上完整起一套环境不是不行但成本高、版本容易漂移而且你本地网络里没有真实的 Node、没有 CNI 插件很多问题根本复现不出来。分布式环境的另一个麻烦是代码在本地集群在远处你没法直接把本地 IDE 的断点打到远端进程上。远程调试恰好解决了这个矛盾。它的核心思路是把带调试信息的二进制放到目标服务器上用调试器在服务器端监听一个端口本地 IDE 通过网络连接过去。这样做有三个明显的好处第一调试环境是真实的etcd 版本、证书、网络插件都和线上一致问题复现概率高第二不需要改动集群整体结构只在目标节点上运行一个调试版 apiserver不影响其它组件第三断点命中后你可以直接观察运行时状态而不是靠日志间接推理。不过远程调试也有适用边界。带调试符号的 apiserver 性能会比正常版本慢 20%-30%高 qps 场景下不要直接拿现网进程开刀最好单独起一个调试进程或者用影子节点。多实例 apiserver 部署时也要小心断点会卡住对应进程的请求处理健康检查探活可能因此超时进而触发集群误判。这些坑后面我会专门展开。1.2 基础环境清单与版本对齐远程调试最怕的问题就是“断点不命中”和“变量值对不上”这两件事的根源基本都是版本不一致。所以动手之前先把四个版本对齐Kubernetes 源码版本、Go 版本、Delve 版本、etcd 版本。源码版本必须跟目标集群的 apiserver 版本完全一致否则编译出来的二进制行号和本地源码对不上断点会被设置到一个偏移过的位置。Go 版本按 k8s 仓库根目录 go.mod 里约束的版本来我写这篇文章时常用的 v1.28.x 对应 Go 1.20。Delve 建议直接用 go install 装最新稳定版老版本对 Go 1.21 的 DWARF 调试信息支持可能有问题。组件推荐版本说明Kubernetes 源码与目标集群 apiserver 一致例如 v1.28.5git checkout 对应 tagGo按 go.mod 约束例如 1.20.x版本过高或过低都会编译失败Delvev1.21go install 最新稳定版etcd与目标集群 etcd 一致例如 3.5.x虽然不影响调试器本身但影响启动行为还有一点容易忽略你本地负责编译的机器的架构、操作系统要跟目标服务器一致。如果本地是 macOS ARM64目标服务器是 linux amd64交叉编译出来的二进制在服务器上跑没问题但调试时 Delve 的汇编级操作会受限。所以我的习惯是直接在目标服务器或同一架构的 Linux 机器上编译省掉这些隐藏问题。2. 编译带调试信息的 kube-apiserver 二进制2.1 获取源码并锁定版本第一步先把 Kubernetes 源码仓库克隆到工作目录。源码体积比较大建议直接指定--depth1和--branch拉取对应 tag避免把整个历史都拉到本地。举个例子我要调试一个 v1.28.5 的集群命令是这样的git clone --depth1 --branch v1.28.5 https://github.com/kubernetes/kubernetes.git cd kubernetes仓库里有个值得注意的结构apiserver 核心框架在staging/src/k8s.io/apiserver存储相关在staging/src/k8s.io/apiserver/pkg/registry而 RBAC、Node 授权这些插件在plugin/pkg/auth。它们虽然分布在 staging 和 plugin 目录但编译时都会打到一个二进制里断点可以随便设置在任意位置不需要额外处理模块依赖。编译前建议先看一眼go.mod里的 Go 版本号然后检查当前机器的 Go 版本是否匹配。Go 版本不对的话编译过程会报一堆莫名其妙的错误而且有时候能编译成功运行时却出现内存布局问题这种问题在调试器里几乎没法查。2.2 go build 编译参数怎么选编译 kube-apiserver 时最重要的参数是-gcflagsall-N -l。这里面的-N表示禁止编译器优化-l表示禁止函数内联。为什么要同时加这两项Go 编译器默认会把小函数内联到调用点会把局部变量优化到寄存器甚至直接消除。如果没禁掉这些优化调试器可能看到一个变量“不存在”或者断点被挪到奇怪的位置甚至某些函数整个消失。命令一般长这样go build -a -gcflagsall-N -l -o /tmp/kube-apiserver ./cmd/kube-apiserver-a的作用是强制重新编译所有依赖包。如果你不加这个参数本地 build 缓存里可能会有优化过的产出导致部分包不是按调试模式编译的。断点可能在一些包上生效在另一些包上不生效排查起来非常头疼。加上-a之后编译时间会变长我在 16 核机器上大概要 5 分钟机器差的话 10 分钟也正常耐心等就行。这里补充一个更保守的做法如果排查的 bug 涉及 Kubernetes 的 vendor 依赖比如某个 crd 扩展库我建议用-gcflagsall-N -l的all前缀它会把当前模块的所有包都按调试模式编译。有些教程会写成-gcflags-N -l不带all前缀这只会影响当前命令行包依赖包依然是优化过的遇到函数内联还是会踩坑。2.3 部署到目标机的两种方式编译产物拿到手之后怎么放到目标机器上也有讲究。最直接的方式是 scp 到固定目录比如/opt/k8s-debug/kube-apiserver然后在这个目录下配好证书文件的读取权限。这种方式最简单也最可控我日常工作基本都用它。如果你所在的环境不允许直接往服务器上放二进制只能走容器那可以把调试二进制打进一个定制镜像通过覆盖容器 command 的方式运行。但容器化调试有额外的坑Delve 连接目标进程需要 ptrace 权限容器默认的 securityContext 通常不允许你需要在 Pod YAML 里加上privileged: true或者至少放开capabilities: [SYS_PTRACE]。另外容器重启之后 dlv 进程也跟着消失断点状态全部丢失所以除非环境限制否则不推荐容器方式做长期调试。3. 启动远程调试会话并接入 IDE3.1 用 Delve headless 模式启动调试进程Delve 有两种运行方式交互式dlv debug会把进程启动在本地终端里另一个就是 headless 模式相当于在目标机器上开一个调试服务端等待外部接入。远程调试必须用 headless 模式启动命令如下nohup dlv exec /opt/k8s-debug/kube-apiserver \ --headless --listen:2345 --api-version2 --accept-multiclient --log \ -- \ --advertise-address192.168.1.10 \ --secure-port6443 \ --etcd-servershttp://127.0.0.1:2379 \ --service-cluster-ip-range10.96.0.0/12 \ --authorization-modeRBAC,Node \ --service-account-key-file/etc/kubernetes/pki/sa.pub \ --service-account-signing-key-file/etc/kubernetes/pki/sa.key \ --service-account-issuerhttps://kubernetes.default.svc.cluster.local \ --tls-cert-file/etc/kubernetes/pki/apiserver.crt \ --tls-private-key-file/etc/kubernetes/pki/apiserver.key \ --client-ca-file/etc/kubernetes/pki/ca.crt几个参数逐一说明。--headless是开启服务模式--listen:2345指定调试端口占用 2345 是 Delve 的默认习惯你也可以改成别的端口比如 40000--api-version2是 IDE 连接需要的 DAP 协议版本--accept-multiclient允许 IDE 断线重连后重新接入不加这个参数的话前端一旦断开调试会话就结束了。--log会把 Delve 自身的日志打到 stdout排查连接问题时有用。后面的--之后是传给 kube-apiserver 的参数这些参数从哪里来最好直接从目标机器上/etc/kubernetes/manifests/kube-apiserver.yaml这个 Static Pod 清单里抄把多余的资源配置项去掉只留命令行参数。如果你只是临时调试也可以直接用本机/etc/kubernetes/admin.conf里已有的证书路径不用重新生成。这里有一个新手必踩的坑如果你忘了传--service-account-issuer和对应的签名公钥ServiceAccount token 在认证阶段就会失败你调了半天的认证逻辑却发现不是代码问题是启动参数不对。这类参数尽量保持和原 apiserver 清单完全一致别自作聪明精简。3.2 从 GoLand / VS Code 配置远程连接调试服务端起来之后本地这边就可以接入了。我用 GoLand 比较多它的配置方式是这样的打开 Run - Edit Configurations左上角加号选择 Go RemoteHost 填目标服务器的 IPPort 填 2345然后点 Debug 按钮。GoLand 会自动向远端的 Delve 发起连接连接成功后本地源码文件上打的断点才会真正生效。VS Code 的配置也类似需要在.vscode/launch.json里新建一个调试配置核心字段如下{ name: Remote kube-apiserver, type: go, request: attach, mode: remote, remotePath: /opt/k8s-debug/kube-apiserver, port: 2345, host: 192.168.1.10, debugAdapter: legacy }这里要注意remotePath填的是远端二进制的绝对路径。IDE 会把本地打开的源码目录和远端二进制里记录的文件路径做映射如果本地源码路径和编译时不一致断点会变成灰色或者提示找不到文件。解决办法是用路径替换GoLand 里叫 Path MappingVS Code 里可以通过substitutePath配置解决。最简单的规避方式是在本地克隆源码时的目录层级尽量保持编译时的原始路径我一般固定用$GOPATH/src/k8s.io/kubernetes不随便换目录。3.3 不使用 IDE 时的命令行操作远程环境不一定有图形界面也不一定能开 IDE这时直接用命令行连上去也能完成大部分调试动作。在本地终端执行dlv connect 127.0.0.1:2345进入调试器交互界面后再用命令操作。最常用的命令其实就几个break设置断点、continue继续执行、print打印变量、goroutines查看协程、stack查看调用栈。举个例子我想看看 Pod 创建请求走到认证阶段时的 header 内容(dlv) break staging/src/k8s.io/apiserver/pkg/authentication/request/bearertoken/bearertoken.go:56 (dlv) continue [kube-apiserver] bearertoken.go:56 (hits goroutine(67):1 total:1) (PC: 0x2d7a5c1) (dlv) print req.Header.Get(Authorization) Bearer eyJhbGciOiJSUzI1... (dlv) goroutines (dlv) stack命令行调试适合快速验证某个断点位置有没有逻辑问题但如果你要同时观察多个变量、设置条件断点、步进跟踪图形界面的体验还是要好很多。我的习惯是先用命令行确认断点能命中再回到 IDE 做细致分析。4. 实战案例在核心请求链路中下断点4.1 认证阶段的断点kube-apiserver 对每个请求都会先执行认证链认证成功后才进入授权和准入。这意味着如果你想排查 401 问题认证链上的断点是最直接的手段。常用的断点位置有两个一个在staging/src/k8s.io/apiserver/pkg/authentication/request/bearertoken/bearertoken.go的Authenticate方法另一个在上层staging/src/k8s.io/apiserver/pkg/server/config.go的认证 handler 入口。当我用 kubectl 带 token 发请求时断点命中后可以先确认 Authorization 头是否按预期到达token 有没有被正确解析失败原因会留在返回的resp结构的 err 字段里。我在实战中最常看到的不是认证逻辑本身写错了而是 kubeconfig 里的 token 过期了或者用户属性映射不对。这类问题在断点处一眼就能看出来不需要翻大量认证日志。如果你调的版本里bearertoken.go文件名或行号对不上不要死记行号直接用函数名下断点更稳。Delve 支持break k8s.io/apiserver/pkg/authentication/request/bearertoken.(*Authenticator).Authenticate这种形式函数名不会因为小版本变化而漂移。4.2 授权与准入阶段断点认证通过之后进入授权链RBAC 的评估函数在plugin/pkg/auth/authorizer/rbac/rbac.go的Authorize方法。在这个位置设断点可以清晰看到请求携带的 user、group、resource、verb对照 RBAC 规则就能定位为什么放行或拒绝。我遇到过不少 403 病例最后在Authorize断点里发现 RequestInfo 里的 resource 被写成了带复数形式而 RBAC 规则里定义的是单数这种细节靠日志根本看不出来。比授权更值得下断点的是准入控制阶段尤其当你写了自定义 Admission Webhook。staging/src/k8s.io/apiserver/pkg/admission/plugins.go里的Admit入口几乎会被每个写请求经过你可以在这里观察对象在写进 etcd 之前被哪些准入插件修改过。如果只想调 Pod 创建请求建议在断点条件里加过滤比如req.Resource.Resource pods否则你会被各种资源的心跳更新打断到怀疑人生。条件断点在 GoLand 里直接在断点上右键编辑条件命令行则用break ... if condition。准入链路上还有一个很有用的断点位置是staging/src/k8s.io/apiserver/pkg/admission/configuration/validating_admission_policy.go这是 Kubernetes 1.28 之后内置的 ValidatingAdmissionPolicy 的评估函数。你在那里可以查看 Webhook 返回的allowed字段以及status.message如果自定义策略没有生效通常就是 status 条件或 namespaceSelector 没写对。4.3 存储与 ListWatch 链路断点很多折磨人的 apiserver 问题其实发生在存储层比如 etcd 写入慢、watch 事件丢失、resourceVersion 跳变。存储层核心代码在staging/src/k8s.io/apiserver/pkg/registry/generic/registry/store.go里面Store.Create、Store.Update、Store.Watch都是标准断点位置。拿Store.Create来说当用 kubectl create 创建一个对象时断点会命中在对象写入 etcd 之前的最后一步。你可以看到对象经过默认值填充、最终器处理、生成 UID 和 resourceVersion 之后长什么样对比它刚进入 apiserver 时的原始状态中间任何一步被篡改都会暴露得很清楚。我之前排查过一个 finalizer 丢失的问题就是在Store.Create断点里发现某个自定义控制器在 mutating webhook 阶段把 finalizer 从 metadata 里剥离了。ListWatch 链路则适合排查事件重复或丢失。Reflector 的list和watch方法分别在staging/src/k8s.io/client-go/tools/cache/reflector.go里。这里设置的断点会命中所有客户端发起的 List 和 Watch 请求你可以观察请求的 resourceVersion 和 watch 事件的分批发送行为定位是 etcd 返回的版本号问题还是客户端重连导致的重复。5. 高频问题与排查技巧实录5.1 断点不命中的原因排查远程调试遇到最多的问题就是断点明明设置了但怎么跑都不命中。根据我的经验出现这种症状的原因一般只有四种版本不一致、编译参数不全、函数被内联、缓存残留。版本不一致会导致断点位置偏移编译参数不全和函数内联会让调试器没法把断点打到正确的指令上缓存残留则是部分包没带调试符号。怀疑是缓存问题时处理方式很简单把之前编译生成的二进制删掉重新执行go build -a -gcflagsall-N -l。如果你习惯了增量编译记得把-a写进编译命令否则 Go 的 build cache 会安静地给你一个优化过的包表面看起来编译成功实际调试不了。断点不命中还有一个比较隐蔽的原因函数可能被调用多次但你的断点设置在了一个分支里而这个分支在当前请求路径上不会走到。这种情况下先在入口函数设断点确认请求确实走到了这一层再逐步往下推进。5.2 连接中断与性能影响Delve 远程连接偶尔会空闲一段时间后断掉这通常跟目标机的防火墙策略、网络超时有关。启动时加上--accept-multiclient可以缓解一部分问题但更稳妥的做法是给调试连接做一层 SSH 隧道。SSH 隧道有两个额外好处一是调试端口不直接暴露安全风险降低二是我实际测试下来比裸 TCP 连接稳定很多长时间挂着不容易掉线。性能方面带调试符号的 apiserver 在高并发下的表现确实会差一些。如果你只是验证逻辑问题不大但如果要做压力测试用调试版 apiserver 的结论完全没有参考意义一定要切回正常二进制再压。另外健康检查探活带来的额外流量也是一个隐藏干扰项kubelet 每 10 秒就会探一次/healthz这个请求同样会经过认证和授权链如果你在这些阶段设了断点探活线程会被卡住然后 kubelet 认为 apiserver 挂掉进而触发重启或者节点 NotReady。调试前最好把目标节点的健康检查告警临时关掉调完再恢复。5.3 Delve 远程调试的安全加固远程调试端口默认没有任何认证这一点务必注意。在共享的开发环境里别人如果知道你的端口连上来就能控制调试进程相当于拿到了一个可控的代码执行能力。我见过不少团队因为图省事把 2345 直接暴露在公网这是非常危险的。最推荐的加固方式是 SSH 隧道。目标机上只让 Delve 监听回环地址启动参数改成--listen127.0.0.1:2345然后本地执行ssh -N -L 2345:127.0.0.1:2345 usertarget-server这样本地 2345 端口收到的流量会通过 SSH 加密隧道转发到目标机的 127.0.0.1:2345外部机器无法直接访问调试端口。如果你不想开 SSH也可以退而求其次用防火墙规则只放行固定来源 IP但相比之下 SSH 隧道还是更干净、更可控。5.4 常见问题速查现象可能原因处理方式dlv 启动报 permission denied当前用户无 ptrace 权限切换到 root 用户或调大/proc/sys/kernel/yama/ptrace_scope为 0断点设置成功但不命中二进制和源码版本不一致或包被优化重新 checkout 对应 tag加-a -gcflagsall-N -l重编函数找不到函数被内联重新编译并确认-gcflagsall-N -l生效IDE 连接后断点灰色源码路径映射不对设置 Path Mapping / substitutePathapiserver 启动即退出etcd 连接失败或证书路径错误先不带调试器直接运行二进制确认启动参数无误请求命中后 IDE 卡死高频率请求反复命中断点加条件断点过滤 namespace / resource长时间空闲后无法重新连接Delve 单客户端会话已结束启动时加--accept-multiclient调试进程被 kubelet 杀掉健康检查探活被断点卡住临时关闭该节点健康检查或只调独立进程我实际操作下来最大的体会是远程调试 kube-apiserver 不是工具层面的问题而是“断点设置在哪个文件哪个函数”的领域知识问题。新手从认证和准入这两个环节入手是最友好的这两个阶段断点命中率高、变量语义清晰debug 完一次就能理解请求在 apiserver 里的完整路径再往存储层深入就会轻松很多。另外调试前我建议准备好一份最小化的 kubeconfig单独创建调试用 ServiceAccount固定在一个测试 namespace不要用 admin 账号在集群里跑各种请求。这样调试时断点命中频率是可控的变量和堆栈也不会被噪声淹没。最后一个小提醒调试任务结束后记得把临时二进制、Delve 进程清理干净别让一个监听在外的调试后门留在共享环境里。
返回列表