无侵入观测服务拓扑四元组的一种实现

最近有了些时间,继续整理下之前的项目。服务四元组的信息对于故障处置、根因定位等都有重要意义。使用eBPF可以做到无侵入用户代码获取服务四元组信息的功能。这一点在工程应用上很有意义。笔者在这方面投入了一些精力,这里做一下简单的总结。

服务四元组指的是[caller, caller_func, callee, callee_func]四元组。如下图是一个调用示例,站在服务A的角度,就存在如下两个四元组: [A, /a, B, /b],[A, /a, C, /c]。站在服务B, C的角度,也存在两个四元组(可能有不同的理解): [B, /b, none, none], [C, /c, none, none]。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
                       service call                  
                                                     
     ,-------.          ,-.          ,-.          ,-.
     |outisde|          |A|          |B|          |C|
     `---+---'          `+'          `+'          `+'
         |      /a       |            |            | 
         |-------------->|            |            | 
         |               |            |            | 
         |               |    /b      |            | 
         |               |----------->|            | 
         |               |            |            | 
         |               |           /c            | 
         |               |------------------------>| 
     ,---+---.          ,+.          ,+.          ,+.
     |outisde|          |A|          |B|          |C|
     `-------'          `-'          `-'          `-'

在弄清楚四元组是什么之后,下面进入今天的话题:如何使用BPF来采集四元组。需要说明的是,笔者这里的语言使用的是golang-1.16golang不同语言版本间的区别,见:golang-1.17+调用规约
值得注意的是,关于观测服务数据,是有很多解决方案的。本文仅是笔者实践的一种解决方案,在文末会简单提到这种方案的优缺点。
按照惯例,先看下效果吧:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
# 启动采集
bpftrace ./http.bt
Attaching 2 probes...  # 未触发请求前,停止在这里
caller:                # 触发请求后,输出
  	caller_path: /handle
callee:
  	method: GET
  	host: 0.0.0.0:9932
  	url: /echo
  
caller:
  	caller_path: /echo
callee: none
  
# 开始服务
./http_demo &
# 触发请求
curl http://0.0.0.0:9932/handle

# 一段golang代码示例

下面是一段golanghttp服务的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
package main

import (
	"fmt"
	"io/ioutil"
	"net/http"

	"github.com/gin-gonic/gin"
)

type Resp struct {
	Errno  int    `json:"errno"`
	Errmsg string `json:"errmsg"`
}

//go:noinline
func echo(c *gin.Context) {
	c.JSON(http.StatusOK, &Resp{
		Errno:  0,
		Errmsg: "ok",
	})
	return
}

//go:noinline
func handle(c *gin.Context) {
	client := http.Client{}
	req, _ := http.NewRequest(http.MethodGet, "http://0.0.0.0:9932/echo", nil)
	resp, err := client.Do(req)
	if err != nil {
		fmt.Println("failed to request", err.Error())
		c.JSON(http.StatusOK, &Resp{
			Errno:  1,
			Errmsg: "failed to request",
		})
		return
	}
	respB, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		fmt.Println("read resp failed")
		c.JSON(http.StatusOK, &Resp{
			Errno:  2,
			Errmsg: "failed to read request",
		})
		return
	}
	defer resp.Body.Close()
	fmt.Println("resp: ", string(respB))
	c.JSON(http.StatusOK, &Resp{
		Errno:  0,
		Errmsg: "request okay",
	})
	return
}

func main() {
	s := http.Server{
		Addr: "0.0.0.0:9932",
	}
	r := gin.Default()
	r.GET("/echo", echo)
	r.GET("/handle", handle)
	s.Handler = r
	if err := s.ListenAndServe(); err != nil {
		fmt.Println("error, ", err.Error())
	}
}

这是一段比较简单的golang代码。需要注意的是,这里的四元组是:[local, /handle, local, /echo]。为了便于示例说明,这里的handle的逻辑和请求下游的逻辑是串行的,没有开新的goroutine。这一点很重要,后面会说明。

# 采集的逻辑

下面是采集的逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
/*
func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
...
}
type Request struct {
	Method string
	URL *url.URL
}
type URL struct {
	Scheme      string
	Opaque      string    // encoded opaque data
	User        *Userinfo // username and password information
	Host        string    // host or host:port
	Path        string    // path (relative paths may omit leading slash)
	RawPath     string    // encoded path hint (see EscapedPath method)
	ForceQuery  bool      // append a query ('?') even if RawQuery is empty
	RawQuery    string    // encoded query values, without '?'
	Fragment    string    // fragment for references, without '#'
	RawFragment string    // encoded fragment hint (see EscapedFragment method)
}
*/
uprobe:./http_demo:net/http.serverHandler.ServeHTTP
{
    $req_addr = sarg3;

    $url_addr = *(uint64*)($req_addr+16);

    $path_addr = *(uint64*)($url_addr+56);
    $path_len  = *(uint64*)($url_addr+64);

	// http请求触发处,依据pid将caller_func存储起来
    @caller_path_addr[pid] = $path_addr;
    @caller_path_len[pid]  = $path_len;
    @callee_set[pid] = 0;
}


/*
type Request struct {
	Method string
	URL *url.URL
}

func (c *Client) do(req *Request) (retres *Response, reterr error) {
...
}

*/
uprobe:./http_demo:"net/http.(*Client).do"
{
	// 依据 pid 获取 caller 信息
    printf("caller: 
  caller_path: %s
",
           str(@caller_path_addr[pid], @caller_path_len[pid]));

    $req_addr = sarg1;

	// 获取 callee 信息
    $addr = *(uint64*)($req_addr);
    $len  = *(uint64*)($req_addr + 8);
    printf("callee: 
  method: %s
", str($addr, $len));

    $url_addr = *(uint64*)($req_addr + 16);

    $addr = *(uint64*)($url_addr + 40);
    $len  = *(uint64*)($url_addr + 48);
    printf("  host: %s
", str($addr, $len));

    $addr = *(uint64*)($url_addr + 56);
    $len  = *(uint64*)($url_addr + 64);
    printf("  url: %s

", str($addr, $len));

    @callee_set[pid] = 1
}

uprobe:./http_demo:"net/http.(*response).finishRequest"
{
	// 如果没有下游请求,单独输出
    if (@callee_set[pid] == 0){
        printf("caller: 
  caller_path: %s
",
               str(@caller_path_addr[pid], @caller_path_len[pid]));
        printf("callee: none

");
        @callee_set[pid] = 1;
    }
}

到这里就基本上把主要思路介绍清楚了。需要说明的是,示例里使用的是pid作为caller_map里的key,当存在并发时,pid肯定是不够的。对于golang语言,可以使用goid作为caller_mapkey。目前对于使用golang常规的使用来说,就足够了。引入goid的另一个问题是,业务代码里可能使用新的goroutine来进行callee的请求、处理。这里就需要引入goroutine的派生关系维护,或者session trace。关于session trace,可以参见基于ebpf实现的gls这部分的逻辑,思路都是一致的。
但是session trace能够覆盖所有的场景么?看一下下面的逻辑:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
var(
	info = make(chan interface{}, 1000)
)

func handle(info chan interface{}){
	for{
    	select{
    		case inf,ok <- info:
        	// do some request
    		...
    	}
    }
}

type Resp struct{
	Code int 	`json:"code"`
    Msg string 	`json:"msg"`
}

func Handler(ctx *gin.Context){
	info <- ctx
    c.JSON(http.StatusOk, &Resp{Code: 0, Msg: "okay"})
}

func main(){
	go handle(info)
    // normal http register and start 
    ...
}

以上是一种http请求的处理方式,大抵的意思是对于每个请求,handleFunc并没有立即有效响应,而是通过channel将一部分的请求信息传递到其他goroutine里处理。这样虽然callerFunc的响应客观上触发了callee的请求,但是handle()所在的goroutine并不是handleFunc派生的。这种场景下,trace也就断掉了。同理,如果是开了goroutine pool来处理,也会丢失。

# 方案的优缺点

  1. 优点。一如笔者在示例及demo中介绍的,对于方案trace能够覆盖的通信类型,callerFunc, callee, calleeFunc等的获取可以直接通过解析函数的参数来获取。对比基于kprobe的报文解析方案,即通过hook tcp_send tcp_rcv等来获取传输层报文,不需要进行复杂的报文解析。这就使得整个解析的触发次数接近O(n),即一次http交互,一次probe的触发。此外,hook kprobe显然会对机器上所有会调用这个kprobe的进程造成影响,因为其他进程也会等待着调用kprobe。但是本方案里涉及的还仅是目标程序启动后的进程受到影响,并不会从调用角度来影响其他进程(但是CPU的抢占等是会产生轻微影响的)。
  2. 缺点。本方案的缺点同样很明显:它把语言及框架的依赖引入进来了。相对于kprobe可以直接面向协议进行解析,本方案需要考虑各种语言。同时,如果同一个语言中存在多种http的实现,也需要进行逐个适配。从这一角度而言,golang天然贴合本方案:其拥有官方统一维护的net/http库,同时,下游的请求方式也一并维护了。

以上是本次介绍的全部内容。在ebpf落地上,笔者还有很多内容需要探索,期望将来能够落地更多有价值的场景。周末愉快~

Hello, World!
使用 Hugo 构建
主题 StackJimmy 设计