Go 进程诊断工具 gops

本文转自

在类 Unix 系统中,我们常常会使用 ps 命令来查看系统当前所运行的进程信息,该命令为我们提供了较大的帮助,能够快速的定位到某些进程的运行情况和状态。
而在 Go 语言中,也有类似的命令工具,那就是 gops[1](Go Process Status)。
gops 是由 Google 官方出品的一个命令行工具,与 ps 命令的功能类似,能够查看并诊断当前系统中 Go 程序的运行状态及内部情况,在一些使用场景中具有较大的存在意义,属于常用工具。
在本文中我们将对 gops 进行全面的使用和介绍。

基本使用

我们先创建一个示例项目,然后在项目根目录执行下述模块安装命令:

$ go get -u github.com/google/gops

写入如下启动代码:

package main

import (
	"github.com/google/gops/agent"
	"log"
	"net/http"
)

func main() {
	// 创建并监听 gops agent,gops 命令会通过连接 agent 来读取进程信息
	// 若需要远程访问,可配置 agent.Options{Addr: "0.0.0.0:6060"},否则默认仅允许本地访问
	if err := agent.Listen(agent.Options{}); err != nil {
		log.Fatalf("agent.Listen err: %v", err)
	}

	http.HandleFunc("/hello", func(w http.ResponseWriter, r *http.Request) {
		_, _ = w.Write([]byte(`Go666`))
	})
	_ = http.ListenAndServe(":6060", http.DefaultServeMux)
}

在完成示例启动代码的写入后,我们启动该程序,并在命令行执行 gops 命令进行查看:

$ gops
72209 72184 gops                           go1.15.5 /Users/.../go/bin/gops
72178 71996 main                         * go1.15.5 /Users/.../go/src/.../test/main
23226 23220 clashr-darwin-amd64            go1.15.2 /Applications/VME.app/Contents/Resources/clash-binaries/clashr-darwin-amd64
1286  995   com.docker.backend             go1.15.2 /Applications/Docker.app/Contents/MacOS/com.docker.backend
1311  1300  com.docker.driver.amd64-linux  go1.15.2 /Applications/Docker.app/Contents/MacOS/com.docker.driver.amd64-linux
160   1     com.docker.vmnetd              go1.14.7 /Library/PrivilegedHelperTools/com.docker.vmnetd
828   1     grafana-server                 go1.13.8 /usr/local/Cellar/grafana/6.6.2/bin/grafana-server
1312  1300  docker                         go1.15.3 /Applications/Docker.app/Contents/Resources/bin/docker
1310  1300  vpnkit-bridge                  go1.15.2 /Applications/Docker.app/Contents/MacOS/vpnkit-bridge
1300  995   com.docker.supervisor          go1.15.2 /Applications/Docker.app/Contents/MacOS/com.docker.supervisor
800   1     etcd                           go1.13.4 /usr/local/Cellar/etcd/3.4.3/bin/etcd

在上述输出中,你很快就发现有一点不一样,那就是为什么某一行的输出结果中会包含一个 * 符号,如下:

72178 71996 main                         * go1.15.5 /Users/.../go/src/.../test/main

这实际上代表着该 Go 进程,包含了 agent,因此它可以启用更强大的诊断功能,包括当前堆栈跟踪,Go版本,内存统计信息等等。
在最后也有一个 main 的 Go 进程,它不包含 * 符号,这意味着它是一个普通的 Go 程序,也就是没有植入 agent,只能使用最基本的功能。

常规命令

gops 工具包含了大量的分析命令,我们可以通过 gops help 进行查看:

$ gops
gops is a tool to list and diagnose Go processes.

Usage:
  gops <cmd> <pid|addr> ...
  gops <pid> # displays process info
  gops help  # displays this help message

Commands:
  stack      Prints the stack trace.(打印堆栈跟踪)
  gc         Runs the garbage collector and blocks until successful.(运行垃圾回收器并阻塞,直到成功为止)
  setgc      Sets the garbage collection target percentage.(设置垃圾收集目标百分比)
  memstats   Prints the allocation and garbage collection stats.(打印分配和垃圾收集统计信息)
  version    Prints the Go version used to build the program.(打印用于生成程序的GO版本)
  stats      Prints runtime stats.(打印运行时统计信息。)
  trace      Runs the runtime tracer for 5 secs and launches "go tool trace".(运行运行时跟踪程序5秒,并启动“Go Tool TRACE”)
  pprof-heap Reads the heap profile and launches "go tool pprof".(读取堆配置文件并启动“Go tool pprof”。)
  pprof-cpu  Reads the CPU profile and launches "go tool pprof".(读取CPU配置文件并启动“Go Tool pprof”。)

All commands require the agent running on the Go process.
"*" indicates the process is running the agent.

查看指定进程信息

$ gops <pid>

$ gops 72178 
parent PID:     71996
threads:        6
memory usage:   0.012%
cpu usage:      0.000%
username:       songzhibin
cmd+args:       ./main
elapsed time:   38:31
local/remote:   127.0.0.1:52981 <-> :0 (LISTEN)
local/remote:   *:6060 <-> :0 (LISTEN)

获取 Go 进程的概要信息,包括父级PID、线程数、内存/CPU使用率、运行者的账户名、进程的启动命令行参数、启动后所经过的时间以及 gops 的 agent 监听信息(若无植入 agent,则没有这项信息)。

查看调用栈信息

$ gops stack <pid>

$ gops stack 72178
goroutine 6 [running]:
runtime/pprof.writeGoroutineStacks(0x1329ee0, 0xc000010040, 0x30, 0x1574638)
        /usr/local/Cellar/go/1.15.5/libexec/src/runtime/pprof/pprof.go:693 +0x9f
runtime/pprof.writeGoroutine(0x1329ee0, 0xc000010040, 0x2, 0x0, 0x0)
        /usr/local/Cellar/go/1.15.5/libexec/src/runtime/pprof/pprof.go:682 +0x45
runtime/pprof.(*Profile).WriteTo(0x14a4d00, 0x1329ee0, 0xc000010040, 0x2, 0xc000010040, 0x0)
        /usr/local/Cellar/go/1.15.5/libexec/src/runtime/pprof/pprof.go:331 +0x3f2
github.com/google/gops/agent.handle(0x8b55fa0, 0xc000010040, 0xc00008e000, 0x1, 0x1, 0x0, 0x0)
        /Users/songzhibin/go/src/github.com/google/gops/agent/agent.go:189 +0x1b8
github.com/google/gops/agent.listen()
        /Users/songzhibin/go/src/github.com/google/gops/agent/agent.go:133 +0x2bd
created by github.com/google/gops/agent.Listen
        /Users/songzhibin/go/src/github.com/google/gops/agent/agent.go:111 +0x36d

goroutine 1 [IO wait]:
internal/poll.runtime_pollWait(0x8b55dd0, 0x72, 0x0)
        /usr/local/Cellar/go/1.15.5/libexec/src/runtime/netpoll.go:222 +0x55
internal/poll.(*pollDesc).wait(0xc000146098, 0x72, 0x0, 0x0, 0x12d3b5b)
        /usr/local/Cellar/go/1.15.5/libexec/src/internal/poll/fd_poll_runtime.go:87 +0x45
internal/poll.(*pollDesc).waitRead(...)
        /usr/local/Cellar/go/1.15.5/libexec/src/internal/poll/fd_poll_runtime.go:92
internal/poll.(*FD).Accept(0xc000146080, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0)
        /usr/local/Cellar/go/1.15.5/libexec/src/internal/poll/fd_unix.go:394 +0x1fc
net.(*netFD).accept(0xc000146080, 0xc00007cdb0, 0x10100d8, 0x14b0100)
        /usr/local/Cellar/go/1.15.5/libexec/src/net/fd_unix.go:172 +0x45
net.(*TCPListener).accept(0xc00000e300, 0xc00005bd88, 0x10100d8, 0x30)
        /usr/local/Cellar/go/1.15.5/libexec/src/net/tcpsock_posix.go:139 +0x32
net.(*TCPListener).Accept(0xc00000e300, 0x12b49a0, 0xc00007cdb0, 0x1287480, 0x14a3040)
        /usr/local/Cellar/go/1.15.5/libexec/src/net/tcpsock.go:261 +0x65
net/http.(*Server).Serve(0xc00014a000, 0x132e300, 0xc00000e300, 0x0, 0x0)
        /usr/local/Cellar/go/1.15.5/libexec/src/net/http/server.go:2937 +0x266
net/http.(*Server).ListenAndServe(0xc00014a000, 0xc00014a000, 0x6)
        /usr/local/Cellar/go/1.15.5/libexec/src/net/http/server.go:2866 +0xb7
net/http.ListenAndServe(...)
        /usr/local/Cellar/go/1.15.5/libexec/src/net/http/server.go:3120
main.main()
        /Users/.../go/src/.../test/main.go:19 +0x12e

获取对应进程的代码调用堆栈信息,可用于分析调用链路。

查看内存使用情况

$ gops memstats <pid>

$ gops memstats 72178
alloc: 1.23MB (1286432 bytes)
total-alloc: 1.23MB (1286432 bytes)
sys: 68.33MB (71650312 bytes)
lookups: 0
mallocs: 630
frees: 11
heap-alloc: 1.23MB (1286432 bytes)
heap-sys: 63.69MB (66781184 bytes)
heap-idle: 62.11MB (65126400 bytes)
heap-in-use: 1.58MB (1654784 bytes)
heap-released: 62.11MB (65126400 bytes)
heap-objects: 619
stack-in-use: 320.00KB (327680 bytes)
stack-sys: 320.00KB (327680 bytes)
stack-mspan-inuse: 19.52KB (19992 bytes)
stack-mspan-sys: 32.00KB (32768 bytes)
stack-mcache-inuse: 20.34KB (20832 bytes)
stack-mcache-sys: 32.00KB (32768 bytes)
other-sys: 708.72KB (725733 bytes)
gc-sys: 3.57MB (3746256 bytes)
next-gc: when heap-alloc >= 4.27MB (4473924 bytes)
last-gc: -
gc-pause-total: 0s
gc-pause: 0
gc-pause-end: 0
num-gc: 0
num-forced-gc: 0
gc-cpu-fraction: 0
enable-gc: true
debug-gc: false

获取 Go 在运行时的当前内存使用情况,主要是 runtime.MemStats[2] 的相关字段信息

查看运行时信息

$ gops stats <pid>

$ gops stats 72178
goroutines: 2
OS threads: 7
GOMAXPROCS: 12
num CPU: 12

获取 Go 运行时的基本信息,包括当前的 Goroutine 数量、系统线程、GOMAXPROCS 数值以及当前系统的 CPU 核数。

查看 trace 信息

$ gops trace <pid>

$ gops trace 72178
Tracing now, will take 5 secs...
Trace dump saved to: /var/folders/tb/88fwsdpx3w100gd9hk_lw41r0000gn/T/trace426546523
2020/12/02 20:49:52 Parsing trace...
2020/12/02 20:49:52 Splitting trace...
2020/12/02 20:49:52 Opening browser. Trace viewer is listening on http://127.0.0.1:57910

go tool trace 作用基本一致。

查看 profile 信息

$ gops pprof-cpu <pid>


$ gops pprof-cpu 72178
Profiling CPU now, will take 30 secs...
Profile dump saved to: /var/folders/tb/88fwsdpx3w100gd9hk_lw41r0000gn/T/cpu_profile458327579
Binary file saved to: /var/folders/tb/88fwsdpx3w100gd9hk_lw41r0000gn/T/binary866419902
File: binary866419902
Type: cpu
Time: Dec 2, 2020 at 8:51pm (CST)
Duration: 30s, Total samples = 0 
No samples were found with the default sample value type.
Try "sample_index" command to analyze different sample values.
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) 


$ gops pprof-heap <pid>
$ gops pprof-heap 72178
Profile dump saved to: /var/folders/tb/88fwsdpx3w100gd9hk_lw41r0000gn/T/heap_profile032243698
Binary file saved to: /var/folders/tb/88fwsdpx3w100gd9hk_lw41r0000gn/T/binary543986857
File: binary543986857
Type: inuse_space
Time: Dec 2, 2020 at 8:53pm (CST)
Entering interactive mode (type "help" for commands, "o" for options)
(pprof) 

go tool pprof 作用基本一致。

你怎么知道我是谁

在学习了 gops 的使用后,我们突然发现一个问题,那就是 gops 是怎么知道哪些进程是与 Go 相关的进程?

如果是植入了 agent 的应用程序还好说,可以理解为埋入了识别点。但实际情况是,没有植入 agentGo 程序也被识别到了,说明 gops 本身并不是这么实现的,考虑植入agent 应当只是用于诊断信息的拓展使用,并不是一个识别点,那么 gops 到底是怎么发现哪些进程是 Go 相关的呢?

我们回归问题的前置需求,假设我们想知道哪些进程与 Go 相关,那么第一步我们要先知道我们当前系统中都运行了哪些进程,这些记录在哪里有?

认真思考一下,答案也就呼之欲出了,假设是 Linux 相关的系统下,其会将进程所有的相关信息都按照约定的数据结构写入 /proc 目录下,因此我们有充分的怀疑认为 gops 就是从 /proc 目录下读取到相关信息的,源代码如下:

func PidsWithContext(ctx context.Context) ([]int32, error) {
 var ret []int32

 d, err := os.Open(common.HostProc())
 if err != nil {
  return nil, err
 }
 defer d.Close()

 fnames, err := d.Readdirnames(-1)
 if err != nil {
  return nil, err
 }
 for _, fname := range fnames {
  pid, err := strconv.ParseInt(fname, 10, 32)
  if err != nil {
   continue
  }
  ret = append(ret, int32(pid))
 }

 return ret, nil
}

// common.HostProc
func HostProc(combineWith ...string) string {
 return GetEnv("HOST_PROC", "/proc", combineWith...)
}

在上述代码中,该方法通过调用 os.Open 方法打开了 proc 目录,并利用 Readdirnames 方法对该目录进行了扫描,最终获取到了所有需要 pid,最终完成其使命,返回了所有 pid

在确定了 gops 是通过扫描 /proc 目录得到的进程信息后,我们又遇到了一个新的疑问点,那就是 gops 是怎么确定这个进程是 Go 进程,又怎么知道它的具体版本信息的呢,源代码如下:

func isGo(pr ps.Process) (path, version string, agent, ok bool, err error) {
 ...
 path, _ = pr.Path()
 if err != nil {
  return
 }
 var versionInfo goversion.Version
 versionInfo, err = goversion.ReadExe(path)
 if err != nil {
  return
 }
 ok = true
 version = versionInfo.Release
 pidfile, err := internal.PIDFile(pr.Pid())
 if err == nil {
  _, err := os.Stat(pidfile)
  agent = err == nil
 }
 return path, version, agent, ok, nil
}

我们可以看到该方法的主要作用是根据扫描 /proc 目录所得到的二进制文件地址中查找相关的标识,用于判断其是否 Go 程序,如果是 Go 程序,那么它将会返回该进程的 pid、二进制文件的名称以及二进制文件的完整存储路径,判断的标识如下:

    if name == "runtime.main" || name == "main.main" {
        isGo = true
    }
    if name == "runtime.buildVersion" {
        isGo = true
    }

而关于所编译的 Go 语言的版本,Go 编译器会在二进制文件中打入 runtime.buildVersion标识,这个标识能够快速我们快速识别它的编译信息,而 gops 也正正是利用了这一点。

我们可以利用 gdb 来进行查看 Go 所编译的二进制文件的版本信息,如下:

$ export GOFLAGS="-ldflags=-compressdwarf=false" && go build .

$ gdb awesomeProject 
...
(gdb) p 'runtime.buildVersion'
$1 = 0x131bbb0 "go1.14"

在上述输出中,我们先对示例项目进行了编译,然后利用 gdb 中查看了 runtime.buildVersion 变量,最终可得知编译这个 Go 程序的版本是 Go1.14。

但在编译时,有一点需要注意,就是我们在编译时指定了 export GOFLAGS="-ldflags=-compressdwarf=false" 参数。

如果不进行指定的话,就会出现 Reading symbols from awesomeProject...(no debugging symbols found)...done. 的相关报错,会将会影响部分功能使用。

这是因为在 Go1.11 版本开始,进行了调试信息的压缩,目的是为了减小所编译的二进制文件大小,但 Mac 上的 gdb 无法理解压缩的 DWARF,因此会产生问题。

需要进行指定在调试时不进行 DWARF 的压缩,便于 Mac 上的 gdb 使用。

需要注意的一点

假设我们在一些特殊场景下希望对 Go 所编译的二进制文件进行压缩,那么在最后我们常常会使用到 upx 工具来减少其整体大小,命令如下:

$ upx awesomeProject

这时候我们再重新运行所编译的 awesomeProject 文件,这时候需要思考的是,gops 能不能识别到它是一个 Go 程序呢?

答案是不行的,经过 upx 压缩后的二进制文件将无法被识别为 Go 程序,并且在我所使用的 gops v0.3.7版本中,由于这类加壳进程的存在,执行 gops 命令直接出现了空指针调用的恐慌(panic),显然,这是一个 BUG,大家在实际环境中需要多加留意,如果要使用 gops 则尽量不要使用 upx 进行压缩。

参考资料
[1]
gops: https://github.com/google/gops

[2]
runtime.MemStats: https://golang.org/pkg/runtime/#MemStats

posted @ 2020-12-02 21:02  Binb  阅读(1481)  评论(0编辑  收藏  举报