最近有了些时间,继续整理下之前的项目。服务四元组的信息对于故障处置、根因定位等都有重要意义。使用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.16
。golang
不同语言版本间的区别,见: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代码示例
下面是一段golang
的http
服务的代码:
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_map
的key
。目前对于使用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
来处理,也会丢失。
#
方案的优缺点
- 优点。一如笔者在示例及
demo
中介绍的,对于方案trace
能够覆盖的通信类型,callerFunc, callee, calleeFunc
等的获取可以直接通过解析函数的参数来获取。对比基于kprobe
的报文解析方案,即通过hook tcp_send tcp_rcv
等来获取传输层报文,不需要进行复杂的报文解析。这就使得整个解析的触发次数接近O(n)
,即一次http
交互,一次probe
的触发。此外,hook kprobe
显然会对机器上所有会调用这个kprobe
的进程造成影响,因为其他进程也会等待着调用kprobe
。但是本方案里涉及的还仅是目标程序启动后的进程受到影响,并不会从调用角度来影响其他进程(但是CPU
的抢占等是会产生轻微影响的)。 - 缺点。本方案的缺点同样很明显:它把语言及框架的依赖引入进来了。相对于
kprobe
可以直接面向协议进行解析,本方案需要考虑各种语言。同时,如果同一个语言中存在多种http
的实现,也需要进行逐个适配。从这一角度而言,golang
天然贴合本方案:其拥有官方统一维护的net/http
库,同时,下游的请求方式也一并维护了。
以上是本次介绍的全部内容。在ebpf
落地上,笔者还有很多内容需要探索,期望将来能够落地更多有价值的场景。周末愉快~