实现k8s应用远程调试

分享:  

最近在支持团队测试左移、测试规范、CI/CD流水线方面的一些工作,针对开发人员发现问题、定位问题、修复问题这个过程产生了一点新的想法,尤其是在整体上云后的大背景下,我认为值得和大家一起探讨下,如何借助远程调试更快速地定位k8s应用问题。

1 问题背景

现在公司大部分业务都上云了,服务部署基本上也k8s容器化了,解决了一些老大难的问题,本文不讨论k8s的好处,我们讨论下开发k8s应用(比如微服务)时开发侧可能碰到的一些问题。前段时间我开发了一个统计模调接口成功率的服务,部署在123平台,因为服务代码有些隐晦的bug排查修复花了些时间,这个过程中就开始思考为什么我对go、trpc很熟的情况下定位问题还这么麻烦呢?于是就有了接下来的探索以及这篇总结。

2 经常遇到的问题

如果一件事情,短时间内需要人肉重复很多遍,就很容易让人抓狂,比如这次开发体验很快就让我抓狂了:

  • 测试服务发现服务表现不正常,准备定位bug
  • 查看错误日志初步锁定问题范围,重新走读代码进一步缩小问题范围
  • 修改代码、提交代码,构建镜像、发布,然后重新测试

排查问题不只是这么轻描淡写,简单3步就搞定了:

  • 可能是bug不容易定位,在外部数据满足某种条件下或者遇到某种事件时才会触发bug;
  • 可能是测试用例覆盖不够,通过上述方法定位并解决了问题1,但是后面发现了问题2;
  • 可能是日志信息不够,错误异常分支没有日志,或者日志信息不全;
  • 微服务架构事务处理会跨越多个服务,可能要结合tracing、logging、metrics多个系统联合排查;
  • 修改代码后提交并构建发布,可能涉及到CI/CD环节,流水线耗时较长;
  • 123平台提供了dtools来快速构建、patch,但是破坏了测试环境稳定性(缺少镜像)
  • TKE有同学基于七彩石配置下发实现的替换工具,存在dtools类似的问题;
  • ……

总之,这个排查过程可能要涉及到多轮人肉操作,作为一个VIM党连上下移动都觉得用滑动鼠标、按上下左右是种低效的操作,更不用说让我在各个系统中间(而且系统衔接当前也还有较大空间)切来切去了。做了这么多“额外”的操作,才能渐渐去逼近真相、解决真正的问题。

3 降低问题复杂度

这里先声明下,并不是说通过日志这种方式排查问题不好,主要还是要看待解决问题的复杂度,如果加几行日志就可以轻松缩小问题域并解决,那自然是好的。但是实际情况是,并不是所有问题都这么容易解决,而且也确实需要多考虑一些其他影响,比如dtools对测试环境的破坏(覆盖了镜像、忘了打镜像怎么办),频繁测试严格走CI/CD的耗时,等等。

包括现在提倡的测试左移,单测覆盖、接口测试、集成测试等等,测试是门艺术,远比我之前肤浅的认识要重要,除了质量也要关注效率,做这么多其实也是像在投入时间、迭代效率、软件质量、团队构成等因素之间寻求一种平衡,降低整体复杂度。现在有些团队已经不再将单测覆盖率当做硬指标了,而是通过结合多种测试手段来寻求这种平衡。

很多人对TDD有认识,但是在实际开发过程中并不会真的去做,代码语句覆盖、分支覆盖情况可能并不高,而且对某些corner cases可能靠正常思路也很难构造出来,可能要结合fuzz testing来协助。

说这么多,只是为了说明一点,发现问题、走查日志、走查代码、修复提交、构建发布、重新测试,可能是我们每个开发同学解决定位解决bug时高频出现的操作流程,没有好的问题定位工具支持,对宝贵的开发资源就是种浪费。

我看了下外部的很多团队在k8s开发方面的一些实践,这个过程感觉是可以优化的,那就是让开发k8s应用的我们获得本地调试能力,让远程的一些“不确定性的因素”变成本地“肉眼可见”的观测,问题解决起来就方便多了。尤其是对某些testcase,可能需要反复往前、往后翻代码才能定位到问题,这种在将mozilla rr(record and replay)作为调试器backend的加成下优势会非常明显。

4 远程调试k8s应用

前面我们说了开发定位bug时大致的手段,并且强调了在问题比较复杂时,我们可能会通过多次“修改代码、构建发布、测试”这样的流程来逼近bug,这种场景下,如果能用调试器来跟踪下应用的执行过程,观测下执行流程、变量信息等是否符合预期,缩小问题域的过程会快很多,扩展阅读部分给出了一些业界团队的实践,供参考。

调试器基础

对于一般的应用程序调试器大致可以分为两种类型:指令级调试器、符号级调试器,大家平时调试用的gdb、delve等就是符号级调试器,当然他们也具备一定的指令级调试能力。现代符号级调试器架构一般分为frontend、backend,二者通过service层进行通信(如借助rpc或者pipe),frontend主要是完成与用户的交互、展示,backend完成对tracee(被调试线程)的实际控制。

以go语言的符号级调试器go-delve/delve为例,其分为frontend、backend,frontend可以是命令行、gdlv图形界面、vscode、goland等,backend针对不同的平台有多种实现,如借助平台的delve native实现、借助其他调试器能力gdbserver、mozilla rr等。本地调试frontend和backend之间通信通过net.pipe,远程通信通过json-rpc,如果是考虑到与vscode、goland集成的话则需要考虑类似DAP(debugger adapter protocol)的方式。

ps: 这里不过多展开了,对调试器设计实现感兴趣的朋友,可以参考我的电子书(最后一章待完成):https://www.hitzhangjie.pro/debugger101.io/

大家用的vscode插件、go-delve/delve调试器,其实就是先起个dlv backend以server的形式监听,协议就是DAP,通过vscode调试的时候,会通过DAP协议与dlv backend交互,dlv backend对被调试进程进行控制(如控制单步执行、读写内存等),大致就是这样的,对于远程调试k8s应用,也是这样的过程。

远程调试演示

首先给大家简单实操演示下远程调试k8s应用,最后大家会意识到这个是完全可以实现的,而且可以做的更方便易用。

示例工程说明

下面以一个简单的工程作为示例,实操下如何远程调试k8s应用,这个工程只有这么几个文件:

/Volumes/kubernetes/debugging-go-app-in-k8s $ tree .
.
├── Dockerfile
├── Makefile
├── app
└── main.go

0 directories, 4 files

这里的main.go,是一个http服务,模拟我们的微服务,可以长期运行并接受用户请求。

package main

import (
	"log"
	"net/http"
	"os"
)

// DefaultPort is the default port to use if once is not specified by the SERVER_PORT environment variable
const DefaultPort = "8080"

func getServerPort() string {
	port := os.Getenv("SERVER_PORT")
	if port != "" {
		return port
	}

	return DefaultPort
}

// EchoHandler echos back the request as a response
func EchoHandler(writer http.ResponseWriter, request *http.Request) {

	log.Println("Echoing back request made to " + request.URL.Path + " to client (" + request.RemoteAddr + ")")

	writer.Header().Set("Access-Control-Allow-Origin", "*")

	// allow pre-flight headers
	writer.Header().Set("Access-Control-Allow-Headers", "Content-Range, Content-Disposition, Content-Type, ETag")

	request.Write(writer)
}

func main() {

	log.Println("starting server, listening on port " + getServerPort())

	http.HandleFunc("/", EchoHandler)
	http.ListenAndServe(":"+getServerPort(), nil)
}

Makefile完成应用程序的构建:

all:
	GOOS=linux GOARCH=amd64 go build -gcflags 'all=-N -l' -o ./app ./main.go

Dockerfile完成镜像构建:

FROM golang:1.16.3

ENV GO111MODULE=on

RUN go get github.com/go-delve/delve/cmd/dlv@v1.8.2
RUN mkdir app

WORKDIR /app
COPY app .

EXPOSE 10000
EXPOSE 8080

ENTRYPOINT ["/go/bin/dlv", "--listen=:10000", "--headless=true", "--api-version=2", "exec", "./app"]

注意这里我们直接dlv、app加到了镜像里,并且应用程序直接是以被调试模式运行的。实际真的考虑到便利性的话,dlv以sidecar的形式提供,并且接收dlv connect请求后attach到running process的方式更合适,现在是完全可以做到这点的,dlv作者Derek Parker曾经做过一期分享,专门介绍k8s cluster应用调试,感兴趣的可以从文末参考链接中找到。

示例工程部署

后续内容假定大家已经安装了docker、minikube,如果没有请自行了解下如何安装,然后启动docker、minikube start启动本地k8s cluster。

构建镜像前先将docker registry指向minikube默认的registry:

eval $(minikube -p minikube docker-env) 

然后再构建这个镜像,这样kubectl就能引用到本地镜像:

docker build -t debugging-go-app-in-k8s:latest .

k8s部署并运行这个镜像:

kubectl run --rm -i debugging-go-app-in-k8s:latest --image-pull-policy=Never 

运行并查看容器是否起来:

kubectl get pods

应该能看到debugging-go-app-in-k8s,起来就ok了。

minikube有点特殊,正常来说可以通过minikube service来创建个tunnel以与容器中的程序通信,比如测试http接口。

也可以通过kubectl port-forward来实现:

kubectl port-forward debugging-go-app-in-k8s 10000:10000       // 这个是dlv backend端口
kubectl port-forward debugging-go-app-in-k8s 8080:8080          // 这个是http服务端口

一个支持调试的k8s应用已经准备好了,然后可以通过dlv或者IDE进行调试了。

远程调试测试

以dlv命令行调试为例:

// 先连接到远程k8s pod中的debugger backend
dlv connect localhost:10000
dlv> _

// 准备个断点,比如接口处理函数
dlv> break EchoHandler
dlv> _

然后给http服务发个请求 curl http://localhost:8080,此时就会发现dlv frontend已经停在EchoHandler这个位置了,此时我们可以正常进行调试了。当然也可以通过vscode里面的Run>Connect and Debug来设置remote address为localhost:8080后在vscode中进行调试。

与容器平台结合

开发同学如果觉得有用的话,不妨主动贡献一下,腾讯内部的主流容器平台是TKE(Tencent Kubernetes Engine),PCG 123平台也是在TKE基础上构建。理论上我们围绕容器平台提供些调试器插件即可实现远程调试能力。最终远程调试能力支持的话,可能最终形态应该是这样的。

在同一个pod中部署一个delve,delve对同一个pod中的go-app进行调试,然后开发人员通过debugger frontend通过json-rpc或者DAP与pod中的delve(debugger backend)进行交互,完成对服务go-app的调试。

由于源代码存放路径差异的问题,dlv提供了一种解决思路substitute-path,这样在涉及到源码相关的转换时(比如基于file lineno添加断点等)依然可以顺利调试。

debugging goapp in k8s with vscode

5 本文小结

开发过程中对问题定位的一点思考,了解了下业界一些实践,对于k8s、微服务这种情况,还是很值得建设远程调试能力的。业界很多头部企业都有这方面的实践,比如Google、RHEL等,以Google Cloud的Cloud Code为例,支持watch本地代码更新并自动发布、重新启动调试,其他的可以通过下面的扩展阅读了解。

研发效率、工具建设是一项长跑,我们要倾听一线用户的真实诉求,也要多去探索优秀团队的实践来反哺自身。

6 扩展阅读

  • Google Cloud: Cloud Code调试k8s应用,https://cloud.google.com/code/docs/vscode/debug
  • solo-io/Squash: The debuggers for microservices,https://github.com/solo-io/squash
  • vscode-kubernetes-tools/debug,https://github.com/vscode-kubernetes-tools/vscode-kubernetes-tools/blob/master/debug-on-kubernetes.md
  • setlog/debug-k8s: how to debug a go-service in k8s, https://github.com/setlog/debug-k8s
  • remote debugging on k8s using vscode, https://www.youtube.com/watch?v=nMm-vaFcG9c&list=LL&index=2
  • bug on a cluster by derek parker - 20220317, https://www.youtube.com/watch?v=TKPmvy6xGlQ&t=340s
  • bug on a cluster by derek parker - 20220406,https://www.meetup.com/ChicagoGo/events/284436038
  • debugging go applications inside k8s,https://hackernoon.com/debugging-go-application-inside-kubernetes-from-ide-h5683xeb
  • telepresence: making the remote local: faster feedback, collaboration and debugging,https://www.telepresence.io/docs/latest/concepts/faster/
  • Debugging Go Microservices in Kubernetes with VScode,https://blog.getambassador.io/debugging-go-microservices-in-kubernetes-with-vscode-a36beb48ef1