故障注入: 代码级故障模拟(Go)

2,939 阅读5分钟

背景

对于一个大型的分布式的微服务系统,模拟各个环节、各个服务调用链过程中的异常十分必要。且这些故障模拟必须做到无侵入地集成到系统中,可以手工激活这些故障观测系统表现是否符合期望。

方案期望

  • 功能诉求:
  1. 微服务中某个服务出现随机延迟、某个服务不可用。
  2. 存储系统磁盘 I/O 延迟增加、I/O 吞吐量过低、落盘时间长。
  3. 调度系统中出现热点,某个调度指令失败。
  4. 充值系统中模拟第三方重复请求充值成功回调接口。
  5. 游戏开发中模拟玩家网络不稳定、掉帧、延迟过大等,以及各种异常输入(外挂请求)情况下系统是否正确工作。
  • 方案诉求
  1. 不能影响正常功能逻辑,不能对功能代码有任何侵入
  2. 故障注入的代码最终不能出现在最终发行的二进制文件中。
  3. 故障注入的代码必须是易读、易写并且能引入编译器检测。
  4. 支持并行测试,可以通过指定控制某个故障点是否激活

方案选型

Go故障注入解决方案中有两个项目应用比较广泛 即etcd团队的gofail以及PingCap公司的failpoint 经过实际调研与体验,gofail存在一些问题,包括:

  • 故障注入代码以注释的形式注入在代码中,编译器无法检查其语法正确性,且转换后的代码可读性基本为0;
  • 无法进行精确控制,开启注入后即激活所有故障点,对并行测试不友好;
  • 编译后的代码可能会影响代码行数,即原本在第10行的代码,转换后可能变到了第12行,对于故障排查与代码定位不优化

综上,选择更加人性化的failpoint方案;

Failpoint 项目有PingCap公司开发,它是 FreeBSD failpoints 的 Golang 实现,允许在代码中注入错误或异常行为, 并由环境变量或代码动态激活来触发这些异常行为。Failpoint 能用于各种复杂系统中模拟错误处理来提高系统的容错性、正确性和稳定性;对于任何一个 Golang 代码的源文件,可以通过解析出这个文件的语法树,遍历整个语法树,找出所有 failpoint 注入点,然后对语法树重写,转换成想要的逻辑。

原理

宏的本质是什么?如果追本溯源,发现其实可以通过 AST 重写在 Golang 中实现满足以上条件的 failpoint,原理如下图所示: 对于任何一个 Golang 代码的源文件,可以通过解析出这个文件的语法树,遍历整个语法树,找出所有 failpoint 注入点,然后对语法树重写,转换成想要的逻辑。

failpoint环境搭建

cd $GOPATH/src

mkdir -p github.com/pingcap

cd github.com/pingcap

git clone https://github.com/pingcap/failpoint.git
 
cd failpoint

make
GO111MODULE=on CGO_ENABLED=0 GO111MODULE=on go build  -ldflags '-X "github.com/pingcap/failpoint/failpoint-ctl/version.releaseVersion=12f4ac2-dev" -X "github.com/pingcap/failpoint/failpoint-ctl/version.buildTS=2019-11-15 09:41:49" -X "github.com/pingcap/failpoint/failpoint-ctl/version.gitHash=12f4ac2fd11dfc6b2f7018b00bb90f61a5b6b692" -X "github.com/pingcap/failpoint/failpoint-ctl/version.gitBranch=master" -X "github.com/pingcap/failpoint/failpoint-ctl/version.goVersion=go version go1.13 darwin/amd64"' -o bin/failpoint-ctl failpoint-ctl/main.go
failpoint-ctl build successfully :-) !

编译后,生成可执行文件failpoint-ctl:

ll bin
total 6840
-rwxr-xr-x  1 lanyang  staff   3.3M 11 15 17:41 failpoint-ctl

故障注入与激活

1.注入故障代码

package main

import "github.com/pingcap/failpoint"

func main() {
    failpoint.Inject("testPanic", func() {
        panic("failpoint triggerd")
    })
}

2.代码转换

将代码转换为故障注入代码

$GOPATH/src/github.com/pingcap/failpoint/bin/failpoint-ctl enable

启用后,会生成以下文件,且故障点代码进行转换 将代码还原

$GOPATH/src/github.com/pingcap/failpoint/bin/failpoint-ctl disable

还原后,额外生成的文件被删除,代码还原

3.代码执行&激活故障

正常执行

./your-program

激活故障

GO_FAILPOINTS="main/testPanic=return(true)" ./your-program //可以指定要激活那些故障点

进阶-精细控制

有时为了进行并行测试,即激活故障注入点不影响其他人的测试,可以通过context.Context增加一个Hook 这样就可以精细化控制failpoint;

通过WithHook函数包装一个回调函数,通过内置一些判断逻辑可以判断是否需要命中故障点;改回调函数入参为context和故障点名称,内部可以通过context的Value判断,也可以通过其他方式判断,这里可以根据需求自行控制即可;

demo代码如下:

sctx := failpoint.WithHook(ctx, func(ctx context.Context, fpname string) bool {
    //fmt.Printf("hook ctx %v,%v\n",ctx,fpname) // ctx可以省略
    //return ctx.Value(fpname) != nil // Determine by ctx key
    if c.Ctx.Request.URL.RawQuery == "mock=true"{
        return true
    }else{
        return false
    }
})

failpoint.InjectContext(sctx,"common_info",func(val failpoint.Value) {
    fmt.Printf("mock error 2: %v\n", val)
    c.ResponseSuccess("ping mock point2")
})

附录: failpoint Maker函数

Marker 函数

AST 重写阶段标记需要被重写的部分,主要有以下功能:

  • 提示 Rewriter 重写为一个相等的 IF 语句。
    • 标记函数的参数是重写过程中需要用到的参数。
    • 标记函数是一个空函数,编译过程会被 inline,进一步被消除。
    • 标记函数中注入的 failpoint 是一个闭包,如果闭包访问外部作用于变量,闭包语法允许捕获外部作用域变量,不会出现编译错误, 同时转换后的的代码是一个 IF 语句,IF 语句访问外部作用域变量不会产生任何问题,所以闭包捕获只是为了语法合法,最终不会有任何额外开销。
  • 简单、易读、易写。
  • 引入编译器检测,如果 Marker 函数的参数不正确,程序不能通过编译的,进而保证转换后的代码正确性。

目前支持的 Marker 函数列表:

func Inject(fpname string, fpblock func(val Value)) {}
func InjectContext(fpname string, ctx context.Context, fpblock func(val Value)) {}
func Break(label ...string) {}
func Goto(label string) {}
func Continue(label ...string) {}
func Fallthrough() {}
func Return(results ...interface{}) {}
func Label(label string) {}

关于其他的Maker函数的用法可以参考"PingCap官方文档"

系列文章

"混沌工程: 系统级故障模拟"

"故障注入: 代码级故障模拟"