前言

让我们从一个简单的程序开始,注册prom处理程序并监听8080端口:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
package main

import (
    "log"
    "net/http"

    "github.com/prometheus/client_golang/prometheus/promhttp"
)

func main() {
    http.Handle("/metrics", promhttp.Handler())
    log.Fatal( http.ListenAndServe(":8080", nil))
}

当你点击你的metrics端点时,你会得到类似的东西:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
# HELP go_gc_duration_seconds A summary of the GC invocation durations

# TYPE go_gc_duration_seconds summary

go_gc_duration_seconds{quantile="0"} 3.5101e-05

# HELP go_goroutines Number of goroutines that currently exist

# TYPE go_goroutines gauge

go_goroutines 6
...
process_open_fds 12

# HELP process_resident_memory_bytes Resident memory size in bytes

# TYPE process_resident_memory_bytes gauge

process_resident_memory_bytes 1.1272192e+07

# HELP process_virtual_memory_bytes Virtual memory size in bytes

# TYPE process_virtual_memory_bytes gauge

process_virtual_memory_bytes 4.74484736e+08

在初始化时client_golang注册了2个Prometheus收集器。

  • 进程收集器 - 收集基本的Linux进程信息,如CPU、内存、文件描述符的使用和启动时间。
  • Go收集器–收集有关Go运行时的信息,如GC的细节、gouroutines的数量和OS线程。

进程收集器

这个收集器所做的是读取proc文件系统。proc文件系统暴露了内部的内核数据结构,用来获取系统的信息。

所以普罗米修斯客户端读取/proc/PID/stat文件,它看起来像这样。

1
1 (sh) S 0 1 1 34816 8 4194560 674 43 9 1 5 0 0 0 20 0 1 0 89724 1581056 209 18446744073709551615 94672542621696 94672543427732 140730737801568 0 0 0 0 2637828 65538 1 0 0 17 3 0 0 0 0 0 94672545527192 94672545542787 94672557428736 140730737807231 140730737807234 140730737807234 140730737807344 0

你可以使用cat /proc/PID/status来获得这些信息的人类可读变体。

process_cpu_seconds_total - 它使用utime - 在用户模式下执行代码的次数,以jiffies衡量,stime - 在系统模式下花费的jiffies,代表进程执行代码(比如做系统调用)。一个jiffy是系统定时器中断的两个ticks之间的时间。

process_cpu_seconds_total 等于utime和stime的总和,并除以USER_HZ。这是有道理的,因为用调度器的刻度数除以Hz(每秒的刻度数)可以得出操作系统运行进程的总时间(秒)。

process_virtual_memory_bytes - 使用vsize - 虚拟内存大小是一个进程所管理的地址空间的数量。这包括所有类型的内存,包括RAM中的和换出的。

process_resident_memory_bytes - 乘以rss - 驻留集内存大小是进程在真实内存中的内存页数,页数为4。这导致了专门属于该进程的内存量,单位是字节。这不包括被交换出去的内存页。

process_start_time_seconds - 使用start_time - 进程在系统启动后开始的时间,以jiffies表示,btime来自/proc/stat,显示系统自Unix epoch以来启动的时间,以秒为单位。

process_open_fds - 计算/proc/PID/fd目录下的文件数量。它显示当前打开的常规文件、套接字、伪终端等。

process_max_fds - 读取/proc/{PID}/limits,并使用 “Max Open Files “行的Soft Limit。这里有趣的一点是,/limits列出了软限制和硬限制。

事实证明,软限制是内核对相应资源强制执行的值,而硬限制则作为软限制的上限。

一个没有特权的进程只能把它的软限制设置为一个不超过硬限制的值,并且(不可逆转地)降低它的硬限制。

在Go中,你可以使用err = syscall.Setrlimit(syscall.RLIMIT_NOFILE, &syscall.Rlimit{Cur: 9, Max: 10})来设置限制。

Go收集器

Go Collector的大部分指标都取自runtime、runtime/debug包。

go_goroutines - 调用 runtime.NumGoroutine(),它根据调度器结构和全局 allglen 变量计算出数值。由于调度器结构中的所有值都可以同时改变,所以有一个有趣的检查,如果计算值小于1,就变成1。

go_threads - 调用runtime.CreateThreadProfile(),它读取全局allm变量。

go_gc_duration_seconds - 调用debug.ReadGCStats(),PauseQuantile设置为5,它返回我们最小、25%、50%、75%和最大的暂停时间。然后它从暂停量值、NumGC var和PauseTotal seconds手动创建一个Summary类型。GCStats结构很适合prom的Summary类型,这很酷。

go_info - 这为我们提供了Go版本。这很聪明,它调用runtime.Version()并将其设置为版本标签,然后总是为这个度量指标返回1的值。

内存

Go Collector为我们提供了很多关于内存和GC的指标。

所有这些指标都来自runtime.ReadMemStats(),它从MemStats结构中为我们提供指标。

有一件事让我很担心,那就是runtime.ReadMemStats()有一个明确的调用,让世界暂停。

所以我想知道这个暂停会带来多少实际成本?

因为在stop-the-world暂停期间,所有的goroutines都暂停了,所以GC可以运行。我可能会在以后的文章中对有和没有监控的应用程序做一个比较。

我们已经看到Linux为我们提供了rss/vsize的内存统计指标,那么问题自然就来了,到底是使用MemStats中提供的指标还是rss/vsize?

关于驻留集大小和虚拟内存大小的好处是,它是基于Linux原语的,并且与编程语言无关。

所以在理论上,你可以对任何程序进行检测,你会知道它消耗了多少内存(只要你的指标名称一致,即process_virtual_memory_bytes和process_resident_memory_bytes)。

然而,在实践中,当Go进程启动时,它事先需要大量的虚拟内存,像上面这样一个简单的程序在我的机器(x86_64 Ubuntu)上需要高达544MiB的vsize,这有点令人困惑。RSS显示大约7mib,这更接近实际使用情况。

另一方面,使用基于Go运行时的指标可以提供更细化的信息,了解正在运行的应用程序中发生了什么。

你应该能够更容易地发现你的程序是否有内存泄漏,GC花了多长时间,它回收了多少。

另外,当你优化程序的内存分配时,它应该为你指出正确的方向。

我还没有详细研究过Go的GC和内存模型是如何工作的,它的并发模型8的一部分,所以这一点对我来说还是很新鲜的。

那么让我们来看看这些指标:

go_memstats_alloc_bytes - 一个显示在堆上为对象分配多少字节内存的指标。其值与go_memstats_heap_alloc_bytes相同。这个指标计算所有可到达的堆对象加上不可到达的对象,GC还没有释放。

go_memstats_alloc_bytes_total - 这个指标随着对象在堆中的分配而增加,但当它们被释放时并没有减少。我认为它非常有用,因为它只是一个增加的数字,并且具有和Prometheus Counter一样的良好特性。对它进行rate()处理,应该可以显示出应用程序消耗了多少字节/秒的内存,并且在重启和读取指标失败时是 “持久 “的。

go_memstats_sys_bytes - 这是一个指标,用来衡量Go从系统中占用了多少字节的内存。它总结了下面描述的所有系统指标。

go_memstats_lookups_total - 计算发生了多少次指针解除引用。这是一个计数器值,所以你可以使用rate()来查找/s。

go_memstats_mallocs_total - 显示有多少个堆对象被分配。这是一个计数器的值,所以你可以使用rate()来计算分配的对象/s。

go_memstats_frees_total - 显示有多少堆对象被释放。这是一个计数器的值,所以你可以使用rate()来计算分配的对象。注意你可以用go_memstats_mallocs_total - go_memstats_frees_total来获得活对象的数量。

事实证明,Go将内存组织成span,即8K或更大的连续内存区域。有3种类型的span。

  1. idle - span,没有对象,可以释放回操作系统,或重新用于堆分配,或重新用于堆栈内存。 2)in use - span,至少有一个堆对象,可能有更多空间。 3)stack – span,用于goroutine堆栈。这个跨度可以存在于堆栈或堆中,但不能同时存在。

堆内存度量

go_memstats_heap_alloc_bytes - 与go_memstats_alloc_bytes相同。显示在堆上为对象分配多少字节内存的指标。这个指标计算所有可到达的堆对象加上不可到达的对象,GC还没有释放。

go_memstats_heap_sys_bytes - 从操作系统获得的堆内存的字节数。这包括已被重发但尚未使用的虚拟地址空间,以及在未使用后被退回给操作系统的虚拟地址空间。这个指标估计了堆的最大尺寸。

go_memstats_heap_idle_bytes - 显示有多少字节处于空闲span。

go_memstats_heap_idle_bytes 减去 go_memstats_heap_released_bytes 估计有多少字节的内存可以被释放,但被运行时保留,所以运行时可以在堆上分配对象而不向操作系统索取更多的内存。

go_memstats_heap_inuse_bytes - 显示有多少字节在in-use spans.

go_memstats_heap_inuse_bytes 减去 go_memstats_heap_alloc_bytes 显示有多少字节的内存已经分配给堆,但当前没有使用。

go_memstats_heap_released_bytes - 显示有多少字节的闲置跨度被返回给操作系统。

go_memstats_heap_objects - 显示有多少对象被分配在堆上。这随着GC的执行和新对象的分配而变化。

堆栈内存度量

go_memstats_stack_inuse_bytes - 显示堆栈内存跨度使用了多少字节的内存,其中至少有一个对象。Go文档说,堆栈内存跨度只能用于其他堆栈跨度,也就是说,在一个内存跨度中不能混合使用堆对象和堆栈对象。

go_memstats_stack_sys_bytes - 显示从操作系统获得多少字节的堆栈内存。它是go_memstats_stack_inuse_bytes加上为操作系统线程堆栈获得的任何内存。

没有go_memstats_stack_idle_bytes,因为未使用的堆栈跨度被计入go_memstats_heap_idle_bytes。

堆外内存指标

这些指标是分配给运行时内部结构的字节,这些结构没有在堆上分配,因为它们实现了堆。

go_memstats_mspan_inuse_bytes - 显示mspan结构使用了多少字节。

go_memstats_mspan_sys_bytes - 显示mspan结构从操作系统获得的字节数。

go_memstats_mcache_inuse_bytes - 显示有多少字节被mcache结构使用。

go_memstats_mcache_sys_bytes - 显示mcache结构从操作系统获得的字节数。

go_memstats_buck_hash_sys_bytes - 显示有多少字节的内存在bucket哈希表中,用于分析。

go_memstats_gc_sys_bytes - 显示在垃圾收集元数据中的数量。

go_memstats_other_sys_bytes - go_memstats_other_sys_bytes 显示有多少字节的内存被用于其他运行时分配。

go_memstats_next_gc_bytes - 显示下一个GC周期的目标堆大小。GC的目标是保持go_memstats_heap_alloc_bytes小于这个值。

go_memstats_last_gc_time_seconds - 包含上次GC结束的unix时间戳。

go_memstats_last_gc_cpu_fraction - 显示自程序开始以来,GC所使用的CPU时间占该程序可用时间的比例。

这个指标也在GODEBUG=gctrace=1中提供。

玩转数字

因此,这是一个很多的指标和很多的信息。

我认为学习的最好方法就是玩一玩,所以在这一部分我就这么做。

所以我将使用上面的同一个程序。

下面是来自/metrics的转储信息(为节省空间而编辑),我将使用这些信息。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
process_resident_memory_bytes 1.09568e+07

process_virtual_memory_bytes 6.46668288e+08

go_memstats_heap_alloc_bytes 2.24344e+06

go_memstats_heap_idle_bytes 6.3643648e+07

go_memstats_heap_inuse_bytes 3.039232e+06

go_memstats_heap_objects 6498

go_memstats_heap_released_bytes 0

go_memstats_heap_sys_bytes 6.668288e+07

go_memstats_lookups_total 0

go_memstats_frees_total 12209

go_memstats_mallocs_total 18707

go_memstats_buck_hash_sys_bytes 1.443899e+06

go_memstats_mcache_inuse_bytes 6912

go_memstats_mcache_sys_bytes 16384

go_memstats_mspan_inuse_bytes 25840

go_memstats_mspan_sys_bytes 32768

go_memstats_other_sys_bytes 1.310909e+06

go_memstats_stack_inuse_bytes 425984

go_memstats_stack_sys_bytes 425984

go_memstats_sys_bytes 7.2284408e+07

go_memstats_next_gc_bytes 4.194304e+06

go_memstats_gc_cpu_fraction 1.421928536233557e-06

go_memstats_gc_sys_bytes 2.371584e+06

go_memstats_last_gc_time_seconds 1.5235057190167596e+09
rss = 1.09568e+07 = 10956800 bytes = 10700 KiB = 10.4 MiB

vsize = 6.46668288e+08 = 646668288 bytes = 631512 KiB = 616.7 MiB

heap_alloc_bytes = 2.24344e+06 = 2243440 = 2190 KiB = 2.1 MiB

heap_inuse_bytes = 3.039232e+06 = 3039232 = 2968 KiB = 2,9 MiB

heap_idle_bytes = 6.3643648e+07 = 63643648 = 62152 KiB = 60.6 MiB

heap_released_bytes = 0

heap_sys_bytes = 6.668288e+07 = 66682880 = 65120 KiB = 63.6 MiB

frees_total = 12209

mallocs_total = 18707

mspan_inuse_bytes = 25840 = 25.2 KiB

mspan_sys_bytes = 32768 = 32 KiB

mcache_inuse_bytes = 6912 = 6.8 KiB

mcache_sys_bytes = 16384 = 12 KiB

buck_hash_sys_bytes = 1.443899e+06 = 1443899 = 1410 KiB = 1.4 MiB

gc_sys_bytes = 2.371584e+06 = 2371584 = 2316 KiB = 2.3 MiB

other_sys_bytes = 1.310909e+06 = 1310909 = 1280,2 KiB = 1.3MiB

stack_inuse_bytes = 425984 = 416 KiB

stack_sys_bytes = 425984 = 416 KiB

sys_bytes = 7.2284408e+07 = 72284408 = 70590.2 KiB = 68.9 MiB

next_gc_bytes = 4.194304e+06 = 4194304 = 4096 KiB = 4 MiB

gc_cpu_fraction = 1.421928536233557e-06 = 0.000001

last_gc_time_seconds = 1.5235057190167596e+09 = Thu, 12 Apr 2018 05:47:59 GMT

有趣的一点是,heap_inuse_bytes比heap_alloc_bytes多。 我认为heap_alloc_bytes显示的是对象方面的字节数,heap_inuse_bytes显示的是跨度方面的内存字节数。

用heap_inuse_bytes除以span的大小,可以得到。3039232 / 8192 = 371 span。

heap_inuse_bytes减去heap_alloc_bytes,应该显示我们在使用跨度中的自由空间数量,即2,9 MiB - 2.1 MiB = 0.8 MiB。

这大概意味着我们可以在堆上分配0.8 MiB的对象而不使用新的内存跨度。

但是,我们应该记住内存碎片的问题。

想象一下,如果你有一个10K字节的新字节片,内存可能处于这样的位置,它没有一个10K字节+片头的连续块,所以它需要使用一个新的跨度,而不是重复使用

heap_idle_bytes减去heap_released_byte表明,我们有大约60.6 MiB的未使用跨度,这些跨度是由操作系统保留的,可以返回给操作系统。这是63643648/8192 = 7769个跨度。

heap_sys_bytes,估计是63.6MB,是堆的最大尺寸。它是66682880/8192=8140跨度。

mallocs_total显示,我们分配了18707个对象,释放了12209个。因此,目前我们有18707-12209=6498个对象。我们可以找到对象的平均大小,将heap_alloc_bytes除以实时对象,即6498。结果是2243440 / 6498 = 345.3字节。

(这可能是一个愚蠢的指标,因为对象的大小变化很大,我们应该做直方图来代替。)

所以sys_bytes应该是所有*sys指标的总和。所以我们来检查一下。 sys_bytes == mspan_sys_bytes + mcache_sys_bytes + buck_hash_sys_bytes + gc_sys_bytes + other_sys_bytes + stack_sys_bytes + heap_sys_bytes。 因此,我们有72284408 == 32768 + 16384 + 1443899 + 2371584 + 1310909 + 425984 + 66682880,这就是72284408 == 72284408,这是正确的。

关于sys_bytes,有趣的细节是,它是68,9 MiB,它是来自操作系统的总共多少个字节的内存。同时,操作系统的vsize给了你616,7 MiB,rss给了你10.4 MiB。因此,所有这些数字并不完全吻合。

根据我的理解,我们的部分内存可能在操作系统的内存页中,这些内存页在交换或文件系统中(而不是在RAM中),所以这可以解释为什么rss比sys_bytes小。

而vsize包含了很多东西,比如映射的libc、pthreads libs等。你可以浏览/proc/PID/maps和/proc/PID/smaps文件,看看当前被映射的是什么。

gc_cpu_fraction运行得很低,0.000001的CPU时间被用于GC。这真的是非常非常酷。(虽然这个程序并没有产生多少垃圾)

next_gc_bytes显示GC的目标是将heap_alloc_bytes保持在4 MiB以下,因为heap_alloc_bytes目前是2.1 MiB,目标已经实现。

MemStats & GCStats

关于内存分配的情况,最简单的方式是利用 runtime 包的 MemStats。

上面这种是不修改一行代码的情况下,完全使用外部工具/参数,无侵入式的 GC 监控。

另一种办法是直接读取 runtime.MemStats (runtime/mstats.go) 的内容。其实上面这种办法也是读取了 runtime.memstats (跟 runtime.MemStats 是同一个东西,一个对内,一个对外)。这也意味着要修改我们的程序代码。

代码基本都是这样:

1
2
    memStats := &runtime.MemStats{}
    runtime.ReadMemStats(memStats)

如果希望获取 gcstats:

1
2
  gcstats := &debug.GCStats{PauseQuantiles: make([]time.Duration, 100)}
  debug.ReadGCStats(gcstats)

如果你用了 open-falcon 作为监控工具的话,还可以用 github.com/niean/goperfcounter, 配置一下即可使用。

1
2
3
{
    "bases": [“runtime”, “debug”], // 分别对应 runtime.MemStats, debug.GCStats
}

如果读者看过 ReadMemStats 的实现的话,应该知道里面调用了 stopTheWorld。

Russ Cox 说:

We use ReadMemStats internally at Google. I am not sure of the period but it’s something like what you’re talking about (maybe up to once a minute, I forget).

Stopping the world is not a huge problem; stopping the world for a long time is. ReadMemStats stops the world for only a fixed, very short amount of time. So calling it every 10-20 seconds should be fine.

Don’t take my word for it: measure how long it takes and decide whether you’re willing to give up that much of every 10-20 seconds. I expect it would be under 1/1000th of that time (10 ms). refer: https://groups.google.com/forum/#!searchin/golang-nuts/ReadMemStats/golang-nuts/mTnw5k4pZdo/rpK69Fns2MsJ

另外, https://github.com/rcrowley/go-metrics 也提到了(go-metrics/runtime.go L:68)

1
runtime.ReadMemStats(&memStats) // This takes 50-200us.

我觉得一般业务,只要对性能没有很变态的要求,1毫秒内都还能接受吧,也看你读取的频率有多高。

总结

我喜欢Go,喜欢它在它的包中公开了这么多有用的信息,像你我这样的用户只需调用一个函数就可以获得这些信息。另外,Prometheus确实是一个监控应用程序的好工具。

你想在普罗米修斯方面做得更好吗?看看《用普罗米修斯监控系统和服务》。我绝对推荐这个模块。

玩耍和阅读关于Linux和Go的文章真的很爽,所以我想做这篇文章的第二部分。也许可以研究一下cAdvisor提供的指标,或者展示如何在Prometheus的仪表盘/警报中使用这里描述的一些指标。

另外,一旦vgo被集成(我真的非常希望它被集成,因为它是我使用过的最好的软件包管理器)。然后,我们应该能够从一些go运行时包中检查依赖关系,这将是非常酷的! 想象一下,写一个自定义的prom收集器,它可以检查你所有的依赖关系,检查新的版本,如果发现会给你一个过时的pkgs的数量,类似go_num_outdated_pkgs的指标。

这样,如果你的服务严重过时,你可以写一个警报。或者检查你的实时依赖性哈希值是否与当前的哈希值不一致?

参考

EXPLORING PROMETHEUS GO CLIENT METRICS

https://toutiao.io/posts/p8iu03/preview