gops 工作原理

目录


网上已经有一篇《gops 工作原理》了,但我觉得其阐之未尽,想以自己的理解来讲述一下。

gops 是什么

gops(Go Process Status) 是 Google 出品的一个命令行工具,类似于 linux 自带的 ps 命令,gops 命令用于显示当前系统中 go 开发的程序的进程状态。形如:

$ gops
983   980    uplink-soecks  go1.9   /usr/local/bin/uplink-soecks
52697 52695  gops           go1.10  /Users/jbd/bin/gops
4132  4130   foops        * go1.9   /Users/jbd/bin/foops
51130 51128  gocode         go1.9.2 /Users/jbd/bin/gocode

指定进程号可以显示更详细的信息:

$ gops <pid>
parent PID:	5985
threads:	27
memory usage:	0.199%
cpu usage:	0.139%
username:	jbd
cmd+args:	/Applications/Splice.app/Contents/Resources/Splice Helper.app/Contents/MacOS/Splice Helper -pid 5985
local/remote:	127.0.0.1:56765 <-> :0 (LISTEN)
local/remote:	127.0.0.1:56765 <-> 127.0.0.1:50955 (ESTABLISHED)
local/remote:	100.76.175.164:52353 <-> 54.241.191.232:443 (ESTABLISHED)

目前为止,上述功能都是非侵入式的,golang 程序无需添加额外的代码或配置,gops 便能正确地识别出它们,并显示进程信息。

然而,如果愿意做一点“小小的牺牲”,加一点侵入式的代码,即引入一个 agent,形如:

package main

import (
	"log"
	"time"

	"github.com/google/gops/agent"
)

func main() {
	if err := agent.Listen(agent.Options{}); err != nil {
		log.Fatal(err)
	}
	time.Sleep(time.Hour)
}

那么,程序对应的进程就会在 gops 的显示列表中额外标记一个 *,例如上述的:

4132  4130   foops        * go1.9   /Users/jbd/bin/foops

对于被标记 * 的进程,可以启用更强大的诊断功能,包括但不仅限于堆栈分析、内存分析、GC分析等。

那么,问题来了,gops 是如何工作的?它如何识别出哪些进程是 golang 开发的程序?

gops 的原理

首先排除一下干扰,对于引入了 agent 的情况,原理是很容易想到的:agent 作为“内鬼”,将程序运行中的各项信息输出来,既可以写入到文件方便本地访问,也可输出到端口方便远程访问。具体的实现虽然复杂,但理解起来并不困难。所以这里不对其做深入的探讨,只需要知道在具体实现上,引入了 agent 的程序会将当前运行信息写入到路径为 {$GOPS_CONFIG_DIR}/{$PID}{$HOME}/.config/gops/{$PID} 的目录下,自然,当 gops 命令想对某进程做深入诊断,检查有没有目标进程号对应的文件夹,并读取其中保存的信息即可。

排除了这个干扰,现在问题缩小为,在没有 agent 帮忙里应外合的情况下,gops 如何知道当前系统有哪些 golang 的进程?要知道,对于操作系统而言,golang 进程与其他进程并没有什么两样,操作系统既不区别对待,也不知道如何区别。

那操作系统知道啥?作为操作系统,有一项功能是必然要向用户或用户程序提供的,即告知当前系统有多少进程以及所有进程的运行信息(当然在权限满足的情况下),比如 linux(事实上我也只知道 linux)会将进程的所有运行信息按照特定结构写入 /proc 目录下,参阅 GNU/Linux下的/proc/[pid]目录下的文件分析

那么现在可以确认,gops 可以通过扫描 /proc 知道所有进程号,在通过读 /proc/[pid] 目录下的文件,就可以知道该进程的所有信息(事实上这也是 ps 的原理)。现在问题进一步缩小:gops 如何确认一个进程是 golang 进程甚至知道其 golang 版本号的?

其实想到这里也很容易想通了,还能怎么办,通过进程信息确认可执行文件位置,读可执行文件呗,可执行文件作为 golang 编译器的编译结果,肯定会留有一些信息指示编译器版本,可以通过 gdb 来证实:

$ gdb gops
……
(gdb) p 'runtime.buildVersion'
$1 = {
  str = 0x5c90ac "go1.10.2infinityinvalid loopbackmemstatsno anodereadfromreadlinkrecvfromresponserunnableruntime.scavengesendfilesignal: socket:[strconv.timeout:unixgramunknown( (forced) -> node= blocked= defersc= in "..., len = 8}
(gdb) q

$ gdb obfs-server
……
(gdb) p 'runtime.buildVersion'
No symbol "runtime.buildVersion" in current context.
(gdb) q

可确认文件 gops 是 golang 程序,编译器版本为 go1.10.2。而 obfs-server 不是 golang 程序。

至此,我们可以确认, gops 的工作原理是通过扫描系统当前所有的进程的可执行文件,确认是否为 golang 程序并获取编译器版本,对于 golang 程序进程则进一步检查 {$GOPS_CONFIG_DIR}/{$PID}{$HOME}/.config/gops/{$PID} 下是否有对应文件,如果有则说明该进程引入了 agent,则在显示该进程时添加额外标记。gops 从操作系统接口获取进程的运行信息,从 agent 输出的文件中读取 golang runtime 信息。

相关代码可以在 gops/goprocess/gp.go 中看到。

最后

最后还有一个问题,有没有可能程序是 golang 写的,但是 gops 却没有发现呢?当然是可能的。如果 golang 程序编译时刻意抹去了编译信息,或者对编译结果加壳,gops 便可能无法正常工作

做个小实验说明一下,现有一个 golang 程序,代码为:

package main

import "time"

func main() {
        time.Sleep(time.Hour)
}

正常编译并运行,此时 gops 可以正常工作:

$ go build -o out main.go
$ ./out &
[1] 32227
$ gops
32227 29900 out                 go1.10.2           /root/test/out
32235 29900 gops                go1.10.2           /root/go/bin/gops
$ kill 32227

编译时指示删除调试符号,gops 则无法确认编译器版本,但仍能识别出是 golang 程序:

$ go build -ldflags "-s -w" -o out main.go
$ ./out &
[1] 32493
$ gops
32493 29900 out                 unknown Go version /root/test/out
32505 29900 gops                go1.10.2           /root/go/bin/gops
$ kill 32493

正常编译,编译结果再使用 upx 加壳,gops 已经完全不能识别出为 golang 程序了:

$ go build -o out main.go
$ upx out
                       Ultimate Packer for eXecutables
                          Copyright (C) 1996 - 2017
UPX 3.94        Markus Oberhumer, Laszlo Molnar & John Reiser   May 12th 2017

        File size         Ratio      Format      Name
   --------------------   ------   -----------   -----------
   1179122 ->    440232   37.34%   linux/amd64   out

Packed 1 file.
$ ./out &
[1] 32744
$ gops
32763 29900 gops                go1.10.2           /root/go/bin/gops
$ kill 32744