目录
网上已经有一篇《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