1. 从ax这个标题说起一个被低估的Kubernetes Agent CLI工具第一次看到ax这个标题的时候我脑子里蹦出来的第一反应是这名字也太短了。但恰恰是这种极简命名在Kubernetes生态里往往意味着一个定位非常明确的小工具——它不试图做平台不试图做全家桶只解决一个具体问题。结合热搜词里的Kubernetes、agent、CLI、gRPC这几个关键词基本可以判断ax是一个跑在Kubernetes环境里、以命令行方式交互、通过gRPC协议通信的Agent工具。那它到底解决什么问题我个人的理解是在Kubernetes集群里我们经常需要把一些轻量级的Agent部署到节点上用来采集信息、执行任务、或者做健康检查。传统做法是写一个DaemonSet塞一个二进制进去然后靠kubectl exec或者日志来交互。这种方式在调试阶段极其痛苦——你想看Agent当前状态得exec进去你想让Agent执行一个动作得靠信号或者临时文件你想拿到结构化结果得自己解析日志。ax这类工具的出现本质上就是把Agent与外部世界的交互标准化了CLI负责下发指令和展示结果gRPC负责高效通信Kubernetes负责调度和生命周期管理。这篇文章适合谁看如果你正在做Kubernetes相关的Agent开发或者你手头有一个需要部署到集群里、又需要频繁交互的小工具再或者你只是想搞清楚CLI gRPC Kubernetes Agent这套组合拳怎么打那这篇内容应该能给你一些可以直接抄作业的思路。我会从整体设计、核心细节、实操过程、问题排查四个维度展开尽量把每个选择背后的为什么讲清楚。2. 整体设计与思路拆解为什么是CLI gRPC Agent这套组合2.1 为什么不用REST而选gRPC很多人做Agent通信的第一反应是HTTP REST简单、通用、调试方便。但放到Kubernetes Agent这个场景里REST有几个绕不开的痛点。第一是性能Agent和CLI之间往往是高频小消息交互REST每次都要建连接、传Header、解析JSON开销不小。第二是流式支持Agent经常需要持续上报状态或者接收指令流REST做Server-Sent Events或者WebSocket虽然可以但协议层面不够原生。第三是接口契约REST的接口定义靠文档容易漂移gRPC靠proto文件编译期就能发现不匹配。gRPC基于HTTP/2天然支持多路复用、双向流、头部压缩对于Agent这种长连接小消息偶尔流式的场景非常合适。而且proto文件本身就是最好的接口文档CLI和Agent两端各自生成代码类型安全改接口的时候编译器会直接报错不会等到运行时才发现字段对不上。我在实际项目里踩过的坑是早期用REST做Agent通信接口改了三次每次都要手动同步两端的字段名后来换gRPC之后这类问题基本消失了。当然gRPC也不是没有代价。调试不如curl直观需要grpcurl或者自己写客户端浏览器直接调用不方便需要grpc-webproto文件的管理需要额外规范。但对于Kubernetes Agent这种偏后端、偏内部的场景这些代价完全可以接受。2.2 Agent为什么跑在Kubernetes里而不是独立进程这个问题其实是在问ax的Agent为什么选择以Kubernetes工作负载的形式运行。答案有几个层面。第一是调度和自愈Kubernetes本身就能保证Agent的副本数、重启策略、节点亲和性不需要自己写守护逻辑。第二是资源隔离Agent跑在容器里CPU、内存、网络都有边界不会因为一个Agent跑飞了影响宿主机。第三是配置和密钥管理ConfigMap和Secret天然适合给Agent下发配置。第四是服务发现CLI要找到Agent直接走Kubernetes Service就行不需要自己维护地址列表。但这里有个细节值得展开Agent到底用DaemonSet还是Deployment如果Agent需要感知节点级别的信息比如节点上的设备、网络接口、本地存储那DaemonSet更合适每个节点一个Pod。如果Agent是逻辑上的单点或者需要多副本做高可用那Deployment更合适。ax这个场景下我倾向于DaemonSet因为Agent通常和节点绑定。不过具体选哪个还是要看Agent的职责边界。2.3 CLI的角色定位不只是命令行客户端很多人把CLI简单理解成发请求的工具但在ax这套设计里CLI承担的责任远不止于此。它至少要做四件事第一是服务发现CLI需要知道当前集群里有哪些Agent实例、分别跑在哪个节点上第二是连接管理gRPC连接需要维护、重连、超时控制第三是结果渲染Agent返回的可能是结构化数据CLI要把它变成人可读的表格或者JSON第四是批量操作比如对所有节点上的Agent执行同一个指令CLI要负责并发控制和结果聚合。这就解释了为什么ax不直接做一个Web UI。Web UI当然更友好但CLI的优势在于可脚本化、可组合、可嵌入CI/CD流程。你可以把ax的命令写进Makefile、写进Jenkins Pipeline、写进运维脚本这是Web UI做不到的。而且对于Kubernetes场景运维人员本来就习惯kubectl这种CLI交互方式ax的CLI形态和他们的工作流是无缝衔接的。2.4 整体架构的分层与边界把上面的分析串起来ax的整体架构可以分成三层。最底层是Kubernetes层负责Agent的调度、网络、存储、配置。中间是Agent层每个Agent实例通过gRPC暴露服务处理具体业务逻辑。最上层是CLI层负责用户交互、服务发现、连接管理、结果展示。这三层之间的边界要清晰。Agent不应该关心CLI怎么展示结果CLI不应该关心Agent内部怎么实现业务逻辑Kubernetes层不应该被Agent的业务代码污染。我见过一些项目把这三层揉在一起Agent里直接读kubectl的配置CLI里直接操作Kubernetes API短期看省事长期看维护成本极高。ax这种分层清晰的设计虽然前期要多写一些胶水代码但后期扩展和排查问题的时候会轻松很多。3. 核心细节解析与实操要点从proto定义到Agent部署3.1 proto文件设计接口契约的起点gRPC的核心是proto文件ax的proto设计直接决定了CLI和Agent之间的交互能力。一个典型的Agent服务proto大概长这样syntax proto3; package ax.v1; service AgentService { rpc GetStatus(StatusRequest) returns (StatusResponse); rpc ExecuteCommand(CommandRequest) returns (stream CommandResponse); rpc StreamEvents(EventRequest) returns (stream Event); } message StatusRequest { string agent_id 1; } message StatusResponse { string agent_id 1; string node_name 2; string version 3; int64 uptime_seconds 4; mapstring, string labels 5; } message CommandRequest { string command 1; repeated string args 2; int32 timeout_seconds 3; } message CommandResponse { string stream 1; // stdout or stderr bytes data 2; int32 exit_code 3; } message EventRequest { repeated string event_types 1; } message Event { string type 1; int64 timestamp 2; string payload 3; }这里有几个设计决策值得说明。第一GetStatus是Unary RPC一问一答适合状态查询。第二ExecuteCommand是Server Streaming因为命令执行可能产生持续输出用流式返回更自然。第三StreamEvents也是Server StreamingAgent主动推送事件给CLI。第四CommandResponse里区分stdout和stderr这样CLI可以分别渲染不会混在一起。注意proto的package命名建议带上版本号比如ax.v1这样后续接口升级的时候可以平滑过渡不会因为字段变更导致老客户端直接挂掉。3.2 Agent端的gRPC服务实现要点Agent端用Go实现gRPC服务是比较常见的选择因为Go的gRPC生态成熟编译出来的二进制也小适合塞进容器。核心实现大概分几块服务注册、请求处理、流式推送、优雅退出。服务注册这块Agent启动的时候要监听一个端口把AgentService注册进去。端口建议从环境变量读不要硬编码这样Kubernetes里可以通过ConfigMap灵活调整。请求处理这块每个RPC方法要有独立的超时控制和错误处理不要让一个慢请求拖垮整个Agent。流式推送这块要注意背压问题如果CLI消费慢Agent不能无限往channel里塞数据要有缓冲和丢弃策略。优雅退出这块收到SIGTERM之后要先停止接收新请求等正在处理的请求完成再关闭gRPC server。func main() { lis, err : net.Listen(tcp, fmt.Sprintf(:%s, os.Getenv(AX_PORT))) if err ! nil { log.Fatalf(failed to listen: %v, err) } s : grpc.NewServer( grpc.MaxRecvMsgSize(16*1024*1024), grpc.MaxSendMsgSize(16*1024*1024), ) pb.RegisterAgentServiceServer(s, agentServer{}) go func() { if err : s.Serve(lis); err ! nil { log.Fatalf(failed to serve: %v, err) } }() sigCh : make(chan os.Signal, 1) signal.Notify(sigCh, syscall.SIGTERM, syscall.SIGINT) -sigCh s.GracefulStop() }这段代码里MaxRecvMsgSize和MaxSendMsgSize设成16MB是因为Agent可能返回比较大的数据比如日志片段默认的4MB不够用。GracefulStop保证退出的时候不会切断正在进行的流。3.3 CLI端的服务发现与连接管理CLI要找到Agent最直接的方式是通过Kubernetes Service。如果Agent是DaemonSet那每个节点上都有一个PodService可以做成Headless ServiceCLI通过DNS SRV记录拿到所有Pod的地址。如果Agent是Deployment那Service就是普通的ClusterIPCLI连Service就行。但这里有个实际问题CLI不一定跑在集群内部。如果CLI跑在开发机上要访问集群内的Agent就需要通过kubectl port-forward或者API Server代理。ax的CLI通常会封装这层逻辑让用户感觉不到差异。我的做法是CLI先检测当前环境如果在集群内就直接连Service如果在集群外就自动建立port-forward隧道。连接管理这块gRPC的ClientConn是并发安全的可以复用。但要注意设置合理的超时和重试策略。比如连接超时设5秒请求超时设30秒重试用指数退避。另外如果Agent重启了ClientConn会自动重连但正在进行的流会断掉CLI要能感知并提示用户。conn, err : grpc.DialContext(ctx, target, grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithBlock(), grpc.WithTimeout(5*time.Second), grpc.WithDefaultServiceConfig({ methodConfig: [{ name: [{service: ax.v1.AgentService}], retryPolicy: { MaxAttempts: 4, InitialBackoff: 0.1s, MaxBackoff: 1s, BackoffMultiplier: 2, RetryableStatusCodes: [UNAVAILABLE] } }] }), )3.4 Kubernetes部署清单的关键参数Agent的Kubernetes部署清单里有几个参数直接影响到稳定性和可观测性。资源限制这块requests和limits都要设requests保证调度时有资源limits防止Agent跑飞。健康检查这块livenessProbe和readinessProbe都要配liveness失败会重启Podreadiness失败会从Service摘除。日志这块建议输出到stdout让Kubernetes的日志系统统一收集。apiVersion: apps/v1 kind: DaemonSet metadata: name: ax-agent spec: selector: matchLabels: app: ax-agent template: metadata: labels: app: ax-agent spec: hostNetwork: true containers: - name: agent image: ax-agent:latest ports: - containerPort: 9090 name: grpc env: - name: AX_PORT value: 9090 - name: NODE_NAME valueFrom: fieldRef: fieldPath: spec.nodeName resources: requests: cpu: 100m memory: 128Mi limits: cpu: 500m memory: 256Mi livenessProbe: grpc: port: 9090 initialDelaySeconds: 10 periodSeconds: 30 readinessProbe: grpc: port: 9090 initialDelaySeconds: 5 periodSeconds: 10hostNetwork设成true是因为Agent可能需要访问节点级别的网络信息。NODE_NAME通过Downward API注入这样Agent知道自己跑在哪个节点上。livenessProbe和readinessProbe都用gRPC探针Kubernetes 1.24之后原生支持不需要额外装工具。4. 实操过程与核心环节实现从零跑通一个ax Agent4.1 环境准备与依赖安装先说一下我用的环境Kubernetes 1.28Go 1.21protoc 3.21。如果你本地没有Kubernetes可以用kind或者minikube起一个单节点集群足够验证功能。Go的安装不展开重点说protoc和gRPC插件的安装。# 安装protoc brew install protobuf # macOS # 或者 apt install -y protobuf-compiler # Ubuntu # 安装Go的gRPC插件 go install google.golang.org/protobuf/cmd/protoc-gen-golatest go install google.golang.org/grpc/cmd/protoc-gen-go-grpclatest # 确认版本 protoc --version protoc-gen-go --version protoc-gen-go-grpc --version这里有个坑protoc-gen-go和protoc-gen-go-grpc的版本要和google.golang.org/protobuf、google.golang.org/grpc的版本匹配否则生成的代码可能编译不过。我的经验是go.mod里锁定版本之后用go install安装对应版本的插件。4.2 生成gRPC代码并验证proto文件写完之后用protoc生成Go代码protoc --go_out. --go_optpathssource_relative \ --go-grpc_out. --go-grpc_optpathssource_relative \ proto/ax/v1/agent.proto生成之后会得到agent.pb.go和agent_grpc.pb.go两个文件。前者是消息类型的序列化代码后者是服务接口和客户端代码。验证生成是否成功可以写一个最简单的helloworld测试Agent端实现GetStatus返回一个固定值CLI端调用并打印。// agent端 func (s *agentServer) GetStatus(ctx context.Context, req *pb.StatusRequest) (*pb.StatusResponse, error) { return pb.StatusResponse{ AgentId: req.AgentId, NodeName: os.Getenv(NODE_NAME), Version: 0.1.0, UptimeSeconds: int64(time.Since(startTime).Seconds()), }, nil } // CLI端 resp, err : client.GetStatus(ctx, pb.StatusRequest{AgentId: test}) if err ! nil { log.Fatalf(GetStatus failed: %v, err) } fmt.Printf(Agent %s on node %s, version %s, uptime %ds\n, resp.AgentId, resp.NodeName, resp.Version, resp.UptimeSeconds)这一步跑通之后说明proto定义、代码生成、gRPC通信这条链路是通的。接下来再往上加功能。4.3 实现流式命令执行流式命令执行是ax比较核心的功能。Agent端收到命令之后启动一个子进程把stdout和stderr分别读出来通过流式响应发回去。CLI端收到之后实时打印。func (s *agentServer) ExecuteCommand(req *pb.CommandRequest, stream pb.AgentService_ExecuteCommandServer) error { ctx, cancel : context.WithTimeout(stream.Context(), time.Duration(req.TimeoutSeconds)*time.Second) defer cancel() cmd : exec.CommandContext(ctx, req.Command, req.Args...) stdout, _ : cmd.StdoutPipe() stderr, _ : cmd.StderrPipe() if err : cmd.Start(); err ! nil { return status.Errorf(codes.Internal, start command failed: %v, err) } var wg sync.WaitGroup wg.Add(2) go func() { defer wg.Done() buf : make([]byte, 4096) for { n, err : stdout.Read(buf) if n 0 { stream.Send(pb.CommandResponse{Stream: stdout, Data: buf[:n]}) } if err ! nil { break } } }() go func() { defer wg.Done() buf : make([]byte, 4096) for { n, err : stderr.Read(buf) if n 0 { stream.Send(pb.CommandResponse{Stream: stderr, Data: buf[:n]}) } if err ! nil { break } } }() wg.Wait() err : cmd.Wait() exitCode : 0 if exitErr, ok : err.(*exec.ExitError); ok { exitCode exitErr.ExitCode() } return stream.Send(pb.CommandResponse{ExitCode: int32(exitCode)}) }这里有几个细节。第一用CommandContext超时之后子进程会被kill避免僵尸进程。第二stdout和stderr用两个goroutine并发读避免管道缓冲区满导致子进程阻塞。第三最后发一个带exit_code的响应CLI据此判断命令是否成功。4.4 CLI端的批量执行与结果聚合CLI要支持对所有Agent执行同一个命令这就需要并发调用多个Agent的ExecuteCommand然后把结果聚合起来。我的做法是用errgroup限制并发数避免同时打开太多连接。func executeOnAll(ctx context.Context, agents []string, cmd string, args []string) error { g, ctx : errgroup.WithContext(ctx) g.SetLimit(10) results : make(map[string]*pb.CommandResponse) var mu sync.Mutex for _, agent : range agents { agent : agent g.Go(func() error { conn, err : dialAgent(ctx, agent) if err ! nil { return err } defer conn.Close() client : pb.NewAgentServiceClient(conn) stream, err : client.ExecuteCommand(ctx, pb.CommandRequest{ Command: cmd, Args: args, TimeoutSeconds: 60, }) if err ! nil { return err } var output bytes.Buffer for { resp, err : stream.Recv() if err io.EOF { break } if err ! nil { return err } output.Write(resp.Data) } mu.Lock() results[agent] pb.CommandResponse{Data: output.Bytes()} mu.Unlock() return nil }) } if err : g.Wait(); err ! nil { return err } // 渲染结果 for agent, resp : range results { fmt.Printf( %s \n%s\n, agent, resp.Data) } return nil }SetLimit(10)是经验值具体设多少要看Agent数量和网络状况。太多会打满本地文件描述符太少会拖慢整体速度。5. 常见问题与排查技巧实录5.1 gRPC连接失败从DNS到防火墙的排查路径CLI连不上Agent是最常见的问题。我的排查顺序是这样的第一步确认Agent Pod是否Runningkubectl get pods -l appax-agent。第二步确认Service是否存在kubectl get svc ax-agent。第三步从CLI所在环境测试DNS解析nslookup ax-agent.default.svc.cluster.local。第四步测试TCP连通性nc -zv ax-agent 9090。第五步用grpcurl直接调grpcurl -plaintext ax-agent:9090 ax.v1.AgentService/GetStatus。大部分问题在前三步就能定位。如果DNS解析失败检查Service的selector是否匹配Pod的label。如果TCP不通检查NetworkPolicy是否拦截了流量。如果grpcurl能通但CLI不通那就是CLI的代码问题重点看DialContext的参数。提示Kubernetes的Service DNS有几种形式ax-agent同namespace、ax-agent.default跨namespace、ax-agent.default.svc.cluster.local完整域名。CLI里建议用完整域名避免namespace切换的时候解析错误。5.2 流式响应中断背压与超时的处理流式命令执行的时候偶尔会遇到响应中断。原因通常有两个一是Agent端发送太快CLI端消费太慢gRPC的流控窗口满了之后Agent会阻塞如果阻塞时间超过超时设置流就被取消。二是网络抖动导致连接断开gRPC自动重连但正在进行的流不会自动恢复。解决办法Agent端在Send之前检查stream.Context()是否已取消如果取消就停止发送。CLI端在Recv返回错误的时候区分是io.EOF正常结束还是其他错误异常中断异常中断要提示用户重试。另外超时时间不要设得太短命令执行类操作建议至少60秒。5.3 Agent内存泄漏goroutine和连接池的坑Agent跑久了内存涨大概率是goroutine泄漏。常见原因流式RPC的goroutine没有正确退出或者gRPC连接没有关闭。排查方法是用pprof看goroutine数量如果持续增长那就是泄漏。import _ net/http/pprof go func() { log.Println(http.ListenAndServe(localhost:6060, nil)) }()然后在Agent Pod里执行go tool pprof http://localhost:6060/debug/pprof/goroutine看哪些goroutine堆积了。我踩过的一个坑是流式RPC里启动了goroutine读stdout但没等goroutine结束就return了导致goroutine永远阻塞在Read上。修复方法是用context控制goroutine生命周期context取消的时候Read会返回错误goroutine就能退出。5.4 常见问题速查表问题现象可能原因排查方法解决方案CLI连不上AgentService selector不匹配kubectl describe svc修正selectorgRPC返回UnavailableAgent未就绪kubectl logs检查readinessProbe流式响应中断超时设置过短查看CLI错误信息增大timeoutAgent内存持续增长goroutine泄漏pprof goroutine用context控制生命周期命令执行无输出stdout未flush手动测试命令加stdbuf或pty批量执行部分失败并发过高查看errgroup错误降低SetLimitproto编译报错插件版本不匹配protoc-gen-go --version锁定版本Agent重启后CLI断连连接未重连查看ClientConn状态配置重试策略5.5 几个我踩过的坑和对应的经验第一个坑是proto字段编号。有一次我删了一个字段然后把新字段用了同一个编号结果老客户端解析出错。proto的字段编号一旦用过就不能复用删除字段的时候要加reserved标记。第二个坑是gRPC的最大消息大小。默认4MBAgent返回大日志的时候直接报错。解决办法是在Server和Client两端都调大MaxRecvMsgSize和MaxSendMsgSize但也不要无限大16MB到64MB比较合理。第三个坑是Kubernetes的gRPC探针。早期版本不支持需要用exec探针调一个健康检查命令。1.24之后原生支持grpc探针但要求Agent实现标准的health check服务。我建议直接用grpc_health_probe这个二进制省得自己实现。第四个坑是CLI的port-forward。如果CLI跑在集群外每次都要手动kubectl port-forward很麻烦。我的做法是在CLI里集成client-go自动建立port-forward隧道用户无感知。但要注意port-forward的稳定性断了要自动重连。6. 关于ax这类工具的一些个人体会做Kubernetes Agent工具这几年我最大的体会是简单的东西往往最难做。ax这个名字很短功能看起来也不复杂但要把CLI、gRPC、Kubernetes这三块揉在一起还不出问题需要处理的边界情况非常多。比如Agent的版本升级怎么保证CLI和老版本Agent兼容比如集群规模大了之后CLI怎么快速发现所有Agent比如网络分区的时候CLI怎么给用户清晰的错误提示。这些都不是proto定义能解决的需要在工程上一点点打磨。另一个体会是gRPC虽然好但不要滥用。如果Agent和CLI之间的交互很简单就是几个请求响应那REST可能更合适至少调试方便。gRPC的优势在流式和性能如果你的场景用不到这些没必要为了技术而技术。最后分享一个小技巧ax的CLI可以加一个--dry-run参数只打印将要执行的操作不真正发送请求。这个在批量操作的时候特别有用能避免误操作。实现起来也简单就是在发送请求之前判断一下flag打印完就return。这个功能我加完之后团队里再也没人因为手滑把命令发到生产集群的所有节点上了。
