标签 Makefile 下的文章

使用Docker Compose构建一键启动的运行环境

本文永久链接 – https://tonybai.com/2021/11/26/build-all-in-one-runtime-environment-with-docker-compose

如今,不管你是否喜欢,不管你是否承认,微服务架构模式的流行就摆在那里。作为架构师的你,如果再将系统设计成个大单体结构,那么即便不懂技术的领导,都会给你送上几次白眼。好吧,妥协了!开拆!“没吃过猪肉,还没见过猪跑吗!”。拆不出40-50个服务,我就不信还拆不出4-5个服务^_^。

终于拆出了几个服务,但又犯难了:以前单体程序,搭建一个运行环境十分easy,程序往一个主机上一扔,配置配置,启动就ok了;但自从拆成服务后,开发人员的调试环境、集成环境、测试环境等搭建就变得异常困难。

有人会说,现在都云原生了?你不知道云原生操作系统k8s的存在么?让运维帮你在k8s上整环境啊。 一般小厂,运维人员不多且很忙,开发人员只能“自力更生,丰衣足食”。开发人员自己整k8s?别扯了!没看到这两年k8s变得越来越复杂了吗!如果有一年不紧跟k8s的演进,新版本中的概念你就可能很陌生,不知源自何方。一般开发人员根本搞不定(如果你想搞定,可以看看我的k8s实战课程哦,包教包会^_^)。

那怎么办呢?角落里曾经的没落云原生贵族docker发话了:要不让我兄弟试试!

1. docker compose

docker虽然成了“过气网红”,但docker依然是容器界的主流。至少对于非docker界的开发人员来说,一提到容器,大家首先想到的还是docker。

docker公司的产品推出不少,开发人员对多数都不买账也是现实,但我们也不能一棒子打死,毕竟docker是可用的,还有一个可用的,那就是docker的兄弟:docker compose

Compose是一个用于定义和运行多容器Docker应用程序的工具。使用Compose,我们可以使用一个YAML文件来配置应用程序的所有服务组件。然后,只需一条命令,我们就可以创建并启动配置中的所有服务。

这不正是我们想要的工具么! Compose与k8s很像,都算是容器编排工具,最大的不同:Compose更适合在单节点上的调试或集成环境中(虽然也支持跨主机,基于被淘汰的docker swarm)。Compose可以大幅提升开发人员以及测试人员搭建应用运行环境的效率。

2. 选版本

使用docker compose搭建运行环境,我们仅需一个yml文件。但docker compose工具也经历了多年演化,这个文件的语法规范也有多个版本,截至目前,docker compose的配置文件的语法版本就有2、2.x和3.x三种。并且不同规范版本支持的docker引擎版本还不同,这个对应关系如下图。图来自docker compose文件规范页面

选版本是最闹心的。选哪个呢?设定两个条件:

  • docker引擎版本怎么也得是17.xx
  • 规范版本怎么也得是3.x吧

这样一来,版本3.2是最低要求的了。我们就选3.2:

// docker-compose.yml
version: "3.2"

3. 选网络

docker compose默认会为docker-compose.yml中的各个service创建一个bridge网络,所有service在这个网络里可以相互访问。以下面docker-compose.yml为例:

// demo1/docker-compose.yml
version: "3.2"
services:
  srv1:
    image: nginx:latest
    container_name: srv1
  srv2:
    image: nginx:latest
    container_name: srv2

启动这个yml中的服务:

# docker-compose -f docker-compose.yml up -d
Creating network "demo1_default" with the default driver
... ...

docker compose会为这组容器创建一个名为demo1_default的桥接网络:

# docker network ls
NETWORK ID          NAME                     DRIVER              SCOPE
f9a6ac1af020        bridge                   bridge              local
7099c68b39ec        demo1_default            bridge              local
... ...

关于demo1_default网络的细节,可以通过docker network inspect 7099c68b39ec获得。

对于这样的网络中的服务,我们在外部是无法访问的。如果要访问其中服务,我们需要对其中的服务做端口映射,比如如果我们要将srv1暴露到外部,我们可以将srv1监听的服务端口80映射到主机上的某个端口,这里用8080,修改后的docker-compose.yml如下:

version: "3.2"
services:
  srv1:
    image: nginx:latest
    container_name: srv1
    ports:
    - "8080:80"
  srv2:
    image: nginx:latest
    container_name: srv2

这样启动该组容器后,我们通过curl localhost:8080就可以访问到容器中的srv1服务。不过这种情况下,服务间的相互发现比较麻烦,要么借助于外部的发现服务,要么通过容器间的link来做。

开发人员大多只有一个环境,不同服务的服务端口亦不相同,让容器使用host网络要比单独创建一个bridge网络来的更加方便。通过network_mode我们可以指定服务使用host网络,就像下面这样:

version: "3.2"
services:
  srv1:
    image: bigwhite/srv1:1.0.0
    container_name: srv1
    network_mode: "host"

在host网络下,容器监听的端口就是主机上的端口,各个服务间通过端口区别各个服务实例(前提是端口各不相同),ip使用localhost即可。

使用host网络还有一个好处,那就是我们在该环境之外的主机上访问环境中的服务也十分方便,比如查看prometheus的面板等。

4. 依赖的中间件先启动,预置配置次之

如今的微服务架构系统,除了自身实现的服务外,外围还有大量其依赖的中间件,比如:redis、kafka(mq)、nacos/etcd(服务发现与注册)、prometheus(时序度量数据服务)、mysql(关系型数据库)、jaeger server(trace服务器)、elastic(日志中心)、pyroscope-server(持续profiling服务)等。

这些中间件若没有启动成功,我们自己的服务多半启动都要失败,因此我们要保证这些中间件服务都启动成功后,再来启动我们自己的服务。

如何做呢?compose规范中有一个迷惑人的“depends_on”,比如下面配置文件中srv1依赖redis和nacos两个service:

version: "3.2"
services:
  srv1:
    image: bigwhite/srv1:1.0.0
    container_name: srv1
    network_mode: "host"
    depends_on:
      - "redis"
      - "nacos"
    environment:
      - NACOS_SERVICE_ADDR=127.0.0.1:8848
      - REDIS_SERVICE_ADDR=127.0.0.1:6379
    restart: on-failure

不深入了解,很多人会认为depends_on可以保证先启动依赖项redis和nacos,并等依赖项ready后再启动我们自己的服务srv1。但实际上,depends_on仅能保证先启动依赖项,后启动我们的服务。但它不会探测依赖项redis或nacos是否ready,也不会等依赖项ready后,才启动我们的服务。于是你会看到srv1启动后依旧出现各种的报错,包括无法与redis、nacos建立连接等。

要想真正实现依赖项ready后才启动我们自己的服务,我们需要借助外部工具了,docker compose文档对此有说明。其中一个方法是使用wait-for-it脚本

我们可以改变一下自由服务的容器镜像,将其entrypoint从执行服务的可执行文件变为执行一个start.sh的脚本:

// Dockerfile
... ...
ENTRYPOINT ["/bin/bash", "./start.sh"]

这样我们就可以在start.sh脚本中“定制”我们的启动逻辑了。下面是一个start.sh脚本的示例:

#! /bin/sh

./wait_for_it.sh $NACOS_SERVICE_ADDR -t 60 --strict -- echo "nacos is up" && \
./wait_for_it.sh $REDIS_SERVICE_ADDR -- echo "redis is up" && \
exec ./srv1

我们看到,在start.sh脚本中,我们使用wait_for_it.sh脚本等待nacos和redis启动,如果在限定时间内等待失败,根据restart策略,我们的服务还会被docker compose重新拉起,直到nacos与redis都ready,我们的服务才会真正开始执行启动过程。

在exec ./srv1之前,很多时候我们还需要进行一些配置初始化操作,比如向nacos中写入预置的srv1服务的配置文件内容以保证srv1启动后能从nacos中读取到自己的配置文件,下面是加了配置初始化的start.sh:

#! /bin/sh

./wait_for_it.sh $NACOS_SERVICE_ADDR -t 60 --strict -- echo "nacos is up" && \
./wait_for_it.sh $REDIS_SERVICE_ADDR -- echo "redis is up" && \
curl -X POST --header 'Content-Type: application/x-www-form-urlencoded' -d dataId=srv1.yml --data-urlencode content@./conf/srv1.yml "http://127.0.0.1:8848/nacos/v1/cs/configs?group=MY_GROUP" && \
exec ./srv1

我们通过curl将打入镜像的./conf/srv1.yml配置写入已经启动了的nacos中供后续srv1启动时读取。

5. 全家桶,一应俱全

就像前面提到的,如今的系统对外部的中间件“依存度”很高,好在主流中间件都提供了基于docker启动的官方支持。这样我们的开发环境也可以是一个一应俱全的“全家桶”。不过要有一个很容易满足的前提:你的机器配置足够高,才能把这些中间件全部运行起来。

有了这些全家桶,我们无论是诊断问题(看log、看trace、看度量数据),还是作性能优化(看持续profiling的数据),都方便的不要不要的。

6. 结合Makefile,简化命令行输入

docker-compose这个工具有一个“严重缺陷”,那就是名字太长^_^。这导致我们每次操作都要敲入很多命令字符,当你使用的compose配置文件名字不为docker-compose.yml时,更是如此,我们还需要通过-f选项指定配置文件路径。

为了简化命令行输入,减少键盘敲击次数,我们可以将复杂的docker-compose命令与Makefile相结合,通过定制命令行命令并将其赋予简单的make target名字来实现这一简化目标,比如:

// Makefile

pull:
    docker-compose -f my-docker-compose.yml pull

pull-my-system:
    docker-compose -f my-docker-compose.yml pull srv1 srv2 srv3

up: pull-my-system
    docker-compose -f my-docker-compose.yml up

upd: pull-my-system
    docker-compose -f my-docker-compose.yml up -d

up2log: pull-my-system
    docker-compose -f my-docker-compose.yml up > up.log 2>&1

down:
    docker-compose -f my-docker-compose.yml down

ps:
    docker-compose -f my-docker-compose.yml ps -a

log:
    docker-compose -f my-docker-compose.yml logs -f

# usage example: make upsrv service=srv1
service=
upsrv:
    docker-compose -f my-docker-compose.yml up -d ${service}

config:
    docker-compose -f my-docker-compose.yml config

另外服务依赖的中间件一般都时启动与运行开销较大的系统,每次和我们的服务一起启停十分浪费时间,我们可以将这些依赖与我们的服务分别放在不同的compose配置文件中管理,这样我们每次重启自己的服务时,没有必要重新启动这些依赖,这样可以节省大量“等待”时间。

7. .env文件

有些时候,我们需要在compose的配置文件中放置一些“变量”,我们通常使用环境变量来实现“变量”的功能,比如:我们将srv1的镜像版本改为一个环境变量:

version: "3.2"
services:
  srv1:
    image: bigwhite/srv1:${SRV1_VER}
    container_name: srv1
    network_mode: "host"
  ... ...

docker compose支持通过同路径下的.env文件的方式docker-compose.yml中环境变量的值,比如:

// .env
SRV1_VER=dev

这样docker compose在启动srv1时会将.env中SRV1_VER的值读取出来并替换掉compose配置文件中的相应环境变量。通过这种方式,我们可以灵活的修改我们使用的镜像版本。

8. 优点与不足

使用docker compose工具,我们可以轻松拥有并快速启动一个all-in-one的运行环境,大幅度加速了部署、调试与测试的效率,在特定的工程环节,它可以给予开发与测试人员很大帮助。

不过这样的运行环境也有一些不足,比如:

  • 对部署的机器/虚拟机配置要求较高;
  • 这样的运行环境有局限,用在功能测试、持续集成、验收测试的场景下可以,但不能用来执行压测或者说即便压测也只是摸底,数据不算数的,因为所有服务放在一起,相互干扰;
  • 服务或中间件多了以后,完全启动一次也要耐心等待一段时间。

“Gopher部落”知识星球正式转正(从试运营星球变成了正式星球)!“gopher部落”旨在打造一个精品Go学习和进阶社群!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!部落目前虽小,但持续力很强,欢迎大家加入!

img{512x368}

img{512x368}
img{512x368}
img{512x368}

我爱发短信:企业级短信平台定制开发专家 https://51smspush.com/。smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。2020年4月8日,中国三大电信运营商联合发布《5G消息白皮书》,51短信平台也会全新升级到“51商用消息平台”,全面支持5G RCS消息。

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

  • 微博:https://weibo.com/bigwhite20xx
  • 微信公众号:iamtonybai
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • “Gopher部落”知识星球:https://public.zsxq.com/groups/51284458844544

微信赞赏:
img{512x368}

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

Go函数调用链跟踪的一种实现思路

img{512x368}

这篇文章的初衷是想解答知乎上的一位知友提出的问题。没想到完成一种实现后,这个问题居然被删除了。那么既然实现了,就分享出来吧。问题的原文找不到了,问题大致是这样的:

一个程序中存在多个函数调用链都调用了函数D:

A1 -> B1 > C1 -> D

A2 -> B2 > C2 -> D

A3 -> B3 -> C3 -> D

... ...

那么,如果某次函数D被调用时出现了问题,那么怎么知道这个D是哪个函数调用链里的D呢?

有些gopher可能会说通过Delve在线调试打印函数调用栈可以知晓D的调用链,还有些gopher可能会说通过各个函数中输出的业务日志可以查明出问题的D归属的函数调用链,这些都是可行的思路。

不过当遇到这个问题时,我大脑中的第一反应却是能否像跟踪分布式服务调用链那样跟踪函数调用链呢?于是就有了本文对这种思路的一个非生产级的实现以及其演化过程。

1. 利用defer实现函数出入口的跟踪

跟踪函数调用,我们首先想到的就是跟踪函数的出入口,而完成这一任务,当仁不让的就是利用defer。对于我这样的从C语言转到Go的gopher而言,defer是我十分喜欢的Go“语法糖”,因为它可以简化代码的实现,让代码逻辑更清晰,具有更好地可读性(关于defer让代码更清晰的系统描述,可参考我的Go进阶技术专栏文章:https://www.imooc.com/read/87/article/2421)。

下面我们就来看看第一版函数跟踪实现的代码:

// github.com/bigwhite/experiments/blob/master/trace-function-call-chain/trace1/trace.go
func trace() func() {
    pc, _, _, ok := runtime.Caller(1)
    if !ok {
        panic("not found caller")
    }

    fn := runtime.FuncForPC(pc)
    name := fn.Name()

    fmt.Printf("enter: %s\n", name)
    return func() { fmt.Printf("exit: %s\n", name) }
}

// github.com/bigwhite/experiments/blob/master/trace-function-call-chain/trace1/main.go
func A1() {
    defer trace()()
    B1()
}

func B1() {
    defer trace()()
    C1()
}

func C1() {
    defer trace()()
    D()
}

func D() {
    defer trace()()
}

func main() {
    A1()
}

我们看到:以A1实现为例,当执行流来带defer语句时,首先会对defer后面的表达式进行求值。trace函数会执行,输出函数入口信息,并返回一个“打印出口信息”的匿名函数。该函数在此并不会执行,而是被注册到函数A1的defer函数栈中,待A1函数执行结束后才会被弹出执行。也就是在A1结束后,会有一条函数的出口信息被输出。

下面我们来真实运行一下上面的trace1示例(Go 1.14, macOS 10.14.6):

// github.com/bigwhite/experiments/trace-function-call-chain/trace1
$go build
$./functrace-demo
enter: main.A1
enter: main.B1
enter: main.C1
enter: main.D
exit: main.D
exit: main.C1
exit: main.B1
exit: main.A1

我们看到各个函数的出入口信息都被输出了,在单Goroutine的情况下,我们从执行顺序上能识别出D究竟是归属于哪个调用链的。

2. 添加trace开关

对函数调用链进行Trace是有一定性能损耗的,我们可能并不想在所有场合都开启trace,那么我们来给Trace添加一个“开关”,我们利用go build tags来实现这个功能特性:

// github.com/bigwhite/experiments/blob/master/trace-function-call-chain/trace2/trace.go

// +build trace

package main
... ...

// github.com/bigwhite/experiments/blob/master/trace-function-call-chain/trace2/trace_nop.go

// +build !trace

package main

func trace() func() {
    return func() {

    }
}

我们新增一个名为trace_nop.go的文件,里面包含了一个trace函数的空实现,即在trace函数与其返回的匿名函数中什么都不做。该源文件增加了一个build指示器(directive):

// +build !trace

即在关闭trace开关时,使用该文件中的trace函数。而原trace.go文件中也增加了一个build指示器:

// +build trace

即只有在打开trace开关的情况下,才会使用该源文件。

我们来对比一下在trace开关打开和关闭下的执行结果:

// github.com/bigwhite/experiments/trace-function-call-chain/trace2
// trace开关关闭
$go build
$./functrace-demo

vs.

// trace开关打开
$go build -tags trace
$./functrace-demo
enter: main.A1
enter: main.B1
enter: main.C1
enter: main.D
exit: main.D
exit: main.C1
exit: main.B1
exit: main.A1

不过这里的实现还是有一个问题的,那就是即便不开启trace开关,trace_nop.go中的trace函数也是会被编译到可执行程序中的。利用go tool compile -S查看汇编代码,trace_nop.go中的trace函数以及其返回的匿名函数都没有被inline掉。这会带来一定的运行时开销,这个问题我们先记下并留到后面解决。

3. 增加对多goroutine函数调用链的跟踪支持

前面的实现面对只有一个goroutine的时候还是可以支撑的,但当程序中并发运行多个goroutine的时候,多个函数调用链的出入口信息输出就会混杂在一起无法分辨。下面我们就来改造一下实现,增加对多goroutine函数调用链的跟踪支持。我们的方案就是在输出函数出入口信息时,带上一个在程序每次执行时能唯一区分goroutine的goroutine id:

// github.com/bigwhite/experiments/blob/master/trace-function-call-chain/trace3/trace.go
func getGID() uint64 {
    b := make([]byte, 64)
    b = b[:runtime.Stack(b, false)]
    b = bytes.TrimPrefix(b, []byte("goroutine "))
    b = b[:bytes.IndexByte(b, ' ')]
    n, _ := strconv.ParseUint(string(b), 10, 64)
    return n
}

func trace() func() {
    pc, _, _, ok := runtime.Caller(1)
    if !ok {
        panic("not found caller")
    }

    fn := runtime.FuncForPC(pc)
    name := fn.Name()

    id := getGID()
    fmt.Printf("g[%02d]: enter %s\n", id, name)
    return func() { fmt.Printf("g[%02d]: exit %s\n", id, name) }
}

main.go也改成了启动多个Goroutine:

// github.com/bigwhite/experiments/blob/master/trace-function-call-chain/trace3/main.go

func A1() {
    defer trace()()
    B1()
}

func B1() {
    defer trace()()
    C1()
}

func C1() {
    defer trace()()
    D()
}

func D() {
    defer trace()()
}

func A2() {
    defer trace()()
    B2()
}
func B2() {
    defer trace()()
    C2()
}
func C2() {
    defer trace()()
    D()
}

func main() {
    var wg sync.WaitGroup
    wg.Add(1)
    go func() {
        A2()
        wg.Done()
    }()

    time.Sleep(time.Millisecond * 50)
    A1()
    wg.Wait()
}

在trace功能开关打开的前提下,运行上面例子:

// github.com/bigwhite/experiments/trace-function-call-chain/trace3
$go build -tags trace
$./functrace-demo
g[18]: enter main.A2
g[18]: enter main.B2
g[18]: enter main.C2
g[18]: enter main.D
g[18]: exit main.D
g[18]: exit main.C2
g[18]: exit main.B2
g[18]: exit main.A2
g[01]: enter main.A1
g[01]: enter main.B1
g[01]: enter main.C1
g[01]: enter main.D
g[01]: exit main.D
g[01]: exit main.C1
g[01]: exit main.B1
g[01]: exit main.A1

4. 让输出更美观一些

了解分布式服务调用跟踪的童鞋都知道,通过带有层次感的输出,我们可以很容易识别出某个服务在哪个环节被调用。而上面我们的Trace输出太扁平,没有层次感,不容易识别,我们这里就来美化一下输出。我们将trace.go做如下改造:

// github.com/bigwhite/experiments/trace-function-call-chain/trace4/trace.go

var mu sync.Mutex
var m = make(map[uint64]int)

func printTrace(id uint64, name, typ string, indent int) {
    indents := ""
    for i := 0; i < indent; i++ {
        indents += "\t"
    }
    fmt.Printf("g[%02d]:%s%s%s\n", id, indents, typ, name)
}

func trace() func() {
    pc, _, _, ok := runtime.Caller(1)
    if !ok {
        panic("not found caller")
    }

    id := getGID()
    fn := runtime.FuncForPC(pc)
    name := fn.Name()

    mu.Lock()
    v := m[id]
    m[id] = v + 1
    mu.Unlock()
    printTrace(id, name, "->", v+1)
    return func() {
        mu.Lock()
        v := m[id]
        m[id] = v - 1
        mu.Unlock()
        printTrace(id, name, "<-", v)
    }
}

编译运行:

// github.com/bigwhite/experiments/trace-function-call-chain/trace4
$go build -tags trace
$./functrace-demo
g[18]:  ->main.A2
g[18]:      ->main.B2
g[18]:          ->main.C2
g[18]:              ->main.D
g[18]:              <-main.D
g[18]:          <-main.C2
g[18]:      <-main.B2
g[18]:  <-main.A2
g[01]:  ->main.A1
g[01]:      ->main.B1
g[01]:          ->main.C1
g[01]:              ->main.D
g[01]:              <-main.D
g[01]:          <-main.C1
g[01]:      <-main.B1
g[01]:  <-main.A1

这回显然好看多了,也更容易定位问题了!(当多个goroutine的函数跟踪输出混在一起时,我们还可以用grep工具将特定id的goroutine的函数跟踪输出过滤出来,比如:functrace-demo|grep “01″)。

5. 利用代码生成将trace代码注入到各个函数中

在前面我们提到过上面实现的一个问题,那就是一旦将trace写死到各个函数代码中,即便在trace开关未打开的情况下,依然是有性能损耗的。并且,上面的实现存在着对业务代码的较强的“代码侵入性”。那么我们能否减少侵入,像分布式服务跟踪那样将“跟踪”的设施注入(instrumenting)到需要跟踪的函数中呢?下面我们就来尝试一下。

1) 将trace单独打包为一个module

我们首先要做的就是将trace相关的代码单独提取打包为一个module。这里我将上面的trace.go和trace_nop.go放入了一个路径为github.com/bigwhite/functrace的module中:

$tree -F -L 2 functrace
functrace
├── LICENSE
... ...
├── README.md
├── example_test.go
├── go.mod
├── go.sum
├── trace.go
└── trace_nop.go

有了这个module,你可以以“侵入式”的方式为你的代码添加函数链调用跟踪,就像上面repo中example_test.go中的那样:

// https://github.com/bigwhite/functrace/blob/main/example_test.go
import (
    "github.com/bigwhite/functrace"
)

func a() {
    defer functrace.Trace()()
    b()
}

func b() {
    defer functrace.Trace()()
    c()
}

func c() {
    defer functrace.Trace()()
    d()
}

func d() {
    defer functrace.Trace()()
}

func ExampleTrace() {
    a()
    // Output:
    // g[01]:   ->github.com/bigwhite/functrace_test.a
    // g[01]:       ->github.com/bigwhite/functrace_test.b
    // g[01]:           ->github.com/bigwhite/functrace_test.c
    // g[01]:               ->github.com/bigwhite/functrace_test.d
    // g[01]:               <-github.com/bigwhite/functrace_test.d
    // g[01]:           <-github.com/bigwhite/functrace_test.c
    // g[01]:       <-github.com/bigwhite/functrace_test.b
    // g[01]:   <-github.com/bigwhite/functrace_test.a
}

2) 增加代码注入功能

我们在github.com/bigwhite/functrace仓库中增加了一个名为gen的工具。利用该工具我们可以将functrace中的trace基础设施代码自动注入(instrumenting)到目标源文件的各个函数定义中。这个工具调用的核心算法在github.com/bigwhite/functrace/pkg/generator中:

// github.com/bigwhite/functrace/blob/main/pkg/generator/rewrite.go
func Rewrite(filename string) ([]byte, error) {
        fset := token.NewFileSet()
        oldAST, err := parser.ParseFile(fset, filename, nil, 0)
        if err != nil {
                return nil, fmt.Errorf("error parsing %s: %w", filename, err)
        }

        if !hasFuncDecl(oldAST) {
                return nil, nil
        }

        // add import declaration
        astutil.AddImport(fset, oldAST, "github.com/bigwhite/functrace")

        // inject code into each function declaration
        addDeferTraceIntoFuncDecls(oldAST)

        buf := &bytes.Buffer{}
        err = format.Node(buf, fset, oldAST)
        if err != nil {
                return nil, fmt.Errorf("error formatting new code: %w", err)
        }
        return buf.Bytes(), nil
}

我们看到这个包的Rewrite函数使用了Go项目提供的go/ast包以及Go扩展项目提供的ast(抽象语法树)操作工具包golang.org/x/tools/go/ast/astutil对目标源文件进行解析、修改并重建的。go/ast包的内容较多,其本身就具备单独写几篇文章了,这里不赘述。有兴趣的童鞋可以移步本文后面的参考资料,或查看go官方文档了解。

为了帮助大家了解如何使用gen生成带有trace的代码,我还在functrace这个repo中建立了一个demo:examples/gen-demo:

$tree examples/gen-demo
examples/gen-demo
├── Makefile
├── go.mod
├── go.sum
└── main.go

在该demo中,我们利用go generate生成带有跟踪代码的目标代码:

// https://github.com/bigwhite/functrace/blob/main/examples/gen-demo/main.go
package main

//go:generate ../../gen -w main.go

... ...

构建该demo并运行(为了方便构建,我建立了Makefile):

// Makefile
all:
    go generate
    go build -tags trace

$make
go generate
[../../gen -w main.go]
add trace for main.go ok
go build -tags trace

$./functrace-demo
g[01]:  ->main.main
g[01]:      ->main.A2
g[01]:          ->main.B2
g[01]:              ->main.C2
g[01]:                  ->main.D
g[01]:                  <-main.D
g[01]:              <-main.C2
g[01]:          <-main.B2
g[01]:      <-main.A2
g[18]:  ->main.A1
g[18]:      ->main.B1
g[18]:          ->main.C1
g[18]:              ->main.D
g[18]:              <-main.D
g[18]:          <-main.C1
g[18]:      <-main.B1
g[18]:  <-main.A1
g[01]:  <-main.main

我们看到,我们通过ast将跟踪代码注入到目标代码并运行的思路成功实现了!

6. 小结

functrace module中Trace函数的实现比较简单,目前仅是输出日志,但实际上我们可以在Trace函数中以及Trace函数返回的匿名函数中通过各种方式输出我们想要的数据,比如,像分布式服务跟踪那样,将数据发送到一个集中的后端做统一存储、分析和展示。但鉴于篇幅和需求不同,这里仅给出满足演示的实现,大家可以自行fork该repo以实现满足你们自己需求的实现。

7. 参考资料

  • https://mattermost.com/blog/instrumenting-go-code-via-ast/
  • https://developers.mattermost.com/blog/open-tracing/
  • https://blog.gopheracademy.com/code-generation-from-the-ast/
  • http://www.go2live.cn/nocate/golang-ast语法树使用教程及示例.html
  • https://www.ctolib.com/topics-80234.html
  • https://github.com/yuroyoro/goast-viewer
  • https://liudanking.com/performance/golang-%e8%8e%b7%e5%8f%96-goroutine-id-%e5%ae%8c%e5%85%a8%e6%8c%87%e5%8d%97/

本文中涉及到的示例源码可以到这里下载 https://github.com/bigwhite/experiments/tree/master/trace-function-call-chain。


“Gopher部落”知识星球开球了!高品质首发Go技术文章,“三天”首发阅读权,每年两期Go语言发展现状分析,每天提前1小时阅读到新鲜的Gopher日报,网课、技术专栏、图书内容前瞻,六小时内必答保证等满足你关于Go语言生态的所有需求!星球首开,福利自然是少不了的!2020年年底之前,8.8折(很吉利吧^_^)加入星球,下方图片扫起来吧!

我的Go技术专栏:“改善Go语⾔编程质量的50个有效实践”上线了,欢迎大家订阅学习!

img{512x368}

我的网课“Kubernetes实战:高可用集群搭建、配置、运维与应用”在慕课网热卖中,欢迎小伙伴们订阅学习!

img{512x368}

我爱发短信:企业级短信平台定制开发专家 https://51smspush.com/
smspush : 可部署在企业内部的定制化短信平台,三网覆盖,不惧大并发接入,可定制扩展; 短信内容你来定,不再受约束, 接口丰富,支持长短信,签名可选。

2020年4月8日,中国三大电信运营商联合发布《5G消息白皮书》,51短信平台也会全新升级到“51商用消息平台”,全面支持5G RCS消息。

著名云主机服务厂商DigitalOcean发布最新的主机计划,入门级Droplet配置升级为:1 core CPU、1G内存、25G高速SSD,价格5$/月。有使用DigitalOcean需求的朋友,可以打开这个链接地址:https://m.do.co/c/bff6eed92687 开启你的DO主机之路。

Gopher Daily(Gopher每日新闻)归档仓库 – https://github.com/bigwhite/gopherdaily

我的联系方式:

  • 微博:https://weibo.com/bigwhite20xx
  • 微信公众号:iamtonybai
  • 博客:tonybai.com
  • github: https://github.com/bigwhite
  • “Gopher部落”知识星球:https://public.zsxq.com/groups/51284458844544

微信赞赏:
img{512x368}

商务合作方式:撰稿、出书、培训、在线课程、合伙创业、咨询、广告合作。

如发现本站页面被黑,比如:挂载广告、挖矿等恶意代码,请朋友们及时联系我。十分感谢! Go语言第一课 商务合作请联系bigwhite.cn AT aliyun.com

欢迎使用邮件订阅我的博客

输入邮箱订阅本站,只要有新文章发布,就会第一时间发送邮件通知你哦!

这里是 Tony Bai的个人Blog,欢迎访问、订阅和留言! 订阅Feed请点击上面图片

如果您觉得这里的文章对您有帮助,请扫描上方二维码进行捐赠 ,加油后的Tony Bai将会为您呈现更多精彩的文章,谢谢!

如果您希望通过微信捐赠,请用微信客户端扫描下方赞赏码:

如果您希望通过比特币或以太币捐赠,可以扫描下方二维码:

比特币:

以太币:

如果您喜欢通过微信浏览本站内容,可以扫描下方二维码,订阅本站官方微信订阅号“iamtonybai”;点击二维码,可直达本人官方微博主页^_^:
本站Powered by Digital Ocean VPS。
选择Digital Ocean VPS主机,即可获得10美元现金充值,可 免费使用两个月哟! 著名主机提供商Linode 10$优惠码:linode10,在 这里注册即可免费获 得。阿里云推荐码: 1WFZ0V立享9折!


View Tony Bai's profile on LinkedIn

文章

评论

  • 正在加载...

分类

标签

归档



View My Stats