从头开始使用 Go 构建 Orchestrator(第 六部分:指标)

本章涵盖内容:

  • 解释为什么工作节点需要收集指标

  • 定义指标

  • 创建收集指标的流程

  • 在现有 API 上实现一个处理程序

想象一下,在一个周五的晚上,你是一家繁忙餐厅的负责人。你有六名服务员在为分散在餐厅各个桌子旁的顾客服务。每张桌子上的每位顾客都有不同的需求。一位顾客可能是来和一群许久未见的朋友一起喝点东西、吃点开胃菜;另一位顾客可能是来享用一顿完整的晚餐,包括开胃菜和甜点;还有一位顾客可能有严格的饮食要求,只吃素食。

这时,来了一位新顾客。这是一家四口:两位成年人和两个十几岁的孩子。你要把他们安排在哪里就座呢?是把他们安排在约翰负责区域的那张桌子上吗?约翰目前已经在服务三张桌子,每张桌子有四位顾客。还是把他们安排在吉尔负责的区域呢?吉尔负责的区域有六张桌子,每张桌子只有一位顾客。又或者把他们安排在威利负责的区域呢?威利负责的区域只有一张桌子,上面有三位顾客。

这种场景正是编排系统中的管理节点所面临的情况。这里不是六名服务员服务餐桌,而是六台机器;不是顾客,而是任务;任务所需要的也不是食物和饮料来满足饥饿感,而是像 CPU、内存和磁盘这样的计算资源。编排系统中管理节点的工作,就像餐厅负责人一样,是要把新进入的任务安排到最能满足任务资源需求的最佳工作节点机器上。

然而,为了让管理节点能够做好这项工作,它需要反映工作节点已经承担了多少工作的指标。这些指标是由工作节点提供的。

6.1 我们应该收集哪些指标?

在我们更深入地探讨指标之前,最好先从更高层面上回顾一下工作节点及其组件。如图 6.1 所示,我们将要处理的两个组件是 API 和指标(Metrics)。在前面的章节中,我们讨论了任务数据库(Task DB)、任务队列(Task Queue)和运行时(Runtime)组件。在上一章中,我们讨论了 API,最终构建了一个 API 服务器,它封装了工作节点用于启动和停止任务的底层操作。现在,我们想要更深入地研究指标组件,该组件将通过同一个 API 公开指标信息。

图 6.1 记住 Worker 组件的全貌

要让工作节点告知管理节点它当前的工作量有多少,哪些指标能够较为准确地反映实际情况呢?请记住,我们构建的并不是一个可投入生产使用的编排器。像博格(Borg)、 Kubernetes 和诺玛德(Nomad)这样的系统,在指标的数量和质量方面都会比我们的系统更好。但这也没关系。我们试图从基础层面去理解一个编排系统是如何工作的,而不是去取代现有的系统。

在思考这些指标时,让我们回顾一下清单 3.6,在其中我们定义了 Task 结构体。该结构体中有三个字段与本次讨论相关:CPU内存(Memory)磁盘(Disk)。这些字段表示一个任务在执行工作时所需的 CPU 资源量、内存大小以及磁盘空间大小。当我们像你我这样的人向系统提交任务时,这些值将由我们来指定。如果我们的任务要进行大量的繁重计算,那么它可能就需要大量的 CPU 和内存资源。如果我们的任务由于某种原因使用了特别大的 Docker 镜像,我们可能需要指定一个稍大一点的数值,以便有一定的余量,这样工作节点在启动任务时就有空间来下载该镜像。

如果这些就是用户在向系统提交任务时会指定的资源,那么我们从每个工作节点收集与这些资源相关的指标就是合理的。具体来说,我们对以下这些指标很感兴趣:

  • CPU 使用率(以百分比表示)
  • 内存总量
  • 可用内存
  • 磁盘总空间
  • 可用磁盘空间

6.2 可从 /proc 文件系统获取的指标

既然我们已经确定了想要收集的指标,那接下来就谈谈要如何收集它们。在 Linux 系统中,有一个名为 /proc 的伪文件系统,它包含了一系列关于系统状态的信息。对 /proc 文件系统进行深入探讨超出了本书的范围;如果你想了解更多细节,有很多不错的资料可以涵盖这个主题。就我们的目的而言,只需知道 /proc 是 Linux 操作系统的一部分,是一个特殊的文件系统,它保存着大量的信息,其中包括有关系统 CPU、内存和磁盘资源状态的信息。

如果你想了解有关 /proc 文件系统的更多信息,网上有许多可用的资源。以下是一些可供参考的资料:

tldp.org/LDP/sag/htm...
mng.bz/27do

/proc 文件系统的优点在于它看起来和其他任何文件系统一样,这意味着用户可以像与其他普通文件系统交互那样与它进行交互。/proc 中的条目都以文件形式呈现,这就表明诸如 lscat 这样的标准工具也可以用于这些条目。

我们将要用到的 /proc 文件系统中的文件如下:

  • /proc/stat ------ 包含有关系统上正在运行的进程的信息。

  • /proc/meminfo ------ 包含有关内存使用情况的信息。

  • /proc/loadavg ------ 包含有关系统平均负载的信息。

这些文件就是你在许多 Linux 命令(如 psstattop)中所看到的数据来源。

为了了解这些文件中包含的数据,你可以使用 cat 命令来查看它们的内容。例如,在我的笔记本电脑(运行的是 Manjaro Linux 系统)上运行命令 cat /proc/stat,我会看到一堆关于每个 CPU 的数据。根据 proc 的手册页(查看 man 5 proc),每一行包含 10 个值,这些值表示在各种状态下所花费的时间。这些状态分别是:

  • user ------ 在用户模式下花费的时间。
  • nice ------ 在低优先级(nice)的用户模式下花费的时间。
  • system ------ 在系统模式下花费的时间。
  • idle ------ 在空闲任务上花费的时间。
  • iowait ------ 等待 I/O 完成所花费的时间。
  • irq ------ 处理中断所花费的时间。
  • softirq ------ 处理软中断所花费的时间。
  • steal ------ 被偷走的时间,即在虚拟化环境中运行时在其他操作系统上花费的时间。
  • guest ------ 在 Linux 内核控制下为客户操作系统运行虚拟 CPU 所花费的时间。
  • guest_nice ------ 运行低优先级客户机所花费的时间。

code 6.1 Using cat to look at the /proc/stat file

yaml 复制代码
$ cat /proc/stat 
cpu 724661 181 374910 105390121 4468 59434 23083 0 0 0 
cpu0 59580 33 29642 8786508 191 3560 2244 0 0 0 
cpu1 60502 3 31300 8779359 150 9016 2729 0 0 0 
cpu2 58574 7 32002 8785331 139 3688 3159 0 0 0 
cpu3 59564 9 30935 8787000 137 3259 2017 0 0 0 
cpu4 59555 6 29208 8786670 369 3312 1950 0 0 0 
cpu5 63148 16 37486 8755311 430 16914 2993 0 0 0 
cpu6 60653 76 31349 8780196 483 4168 2040 0 0 0 
cpu7 62622 2 33386 8781129 533 3402 1325 0 0 0 
cpu8 60286 1 31729 8783928 542 3175 1219 0 0 0 
cpu9 59229 2 29664 8787395 571 3038 1118 0 0 0 
cpu10 59550 1 28925 8789436 468 2945 1100 0 0 0 
cpu11 61392 18 29278 8787854 449 2952 1184 0 0 0

运行命令 cat /proc/meminfo 会显示出关于我系统内存使用情况的数据。这些数据会被像 free 这样的命令所使用。就我们的目的而言,我们将重点关注 /proc/meminfo 提供的两个值(更多详细信息,请查看 man 5 proc 中的 /proc/meminfo 相关内容):

  • MemTotal ------ 可用的总内存(即物理内存减去一些保留位和内核二进制代码)。
  • MemAvailable ------ 对在不进行交换(swap)的情况下可用于启动新应用程序的内存量的一个估算值。

code 6.2 Using cat to look at the /proc/meminfo file

makefile 复制代码
$ cat /proc/meminfo 
MemTotal: 32488372 kB 
MemFree: 21697264 kB 
MemAvailable: 25975132 kB 
Buffers: 512724 kB 
Cached: 5829084 kB 
SwapCached: 0 kB 
Active: 1978056 kB 
Inactive: 6165696 kB 
Active(anon): 18368 kB 
Inactive(anon): 3766080 kB 
Active(file): 1959688 kB 
Inactive(file): 2399616 kB 
Unevictable: 1836208 kB 
Mlocked: 32 kB 
SwapTotal: 0 kB 
SwapFree: 0 kB 
Dirty: 0 kB 
[additional data truncated]

运行命令 cat /proc/loadavg 会显示有关系统平均负载的数据(见清单 6.3)。输出结果中的前三个字段应该看起来很熟悉,因为它们与你运行 uptime 命令时看到的内容是一样的。这些数字表示在运行队列(状态为 R)中或等待磁盘 I/O 的作业数量,分别是在 1 分钟、5 分钟和 15 分钟内的平均值。第四个字段包含两个由斜杠分隔的值(请注意,这不是一个分数):第一个值是当前可运行的内核进程或线程的数量,第二个值是系统上当前存在的内核进程和线程的总数(请查看 man 5 proc 中关于 /proc/loadavg 的内容)。

code 6.3 Using cat to look at the /proc/loadavg file

shell 复制代码
$ cat /proc/loadavg 
0.14 0.16 0.18 1/1787 176550

虽然我们会使用 /proc 文件系统来获取 CPU 和内存相关指标,但不会用它来收集磁盘指标。其实我们本可以使用它来获取磁盘指标的,因为存在 /proc/diskstats 文件。不过,我们将采用一种不同的方式来收集磁盘指标,稍后我们会详细讨论这一点。

由于我们感兴趣的 CPU 和内存指标可以从 /proc 文件系统中获取,我们本可以编写自己的代码来与 /proc 交互并提取所需的数据。然而,我们不会选择这条路。相反,我们将使用一个名为 goprocinfo 的第三方库。

6.3 使用 goprocinfo 收集指标

goprocinfo 库(github.com/c9s/goproci...)提供了一系列类型,使我们能够与 /proc 文件系统进行交互。就我们的目的而言,我们将重点关注四种类型,它们将极大地简化我们的工作。具体如下:

  • LoadAvgmng.bz/Rm0R),它提供了 ReadLoadAvg() 方法,并能获取来自 /proc/loadavg 的数据。

  • CpuStatmng.bz/ZRDN),它提供了 ReadStat() 方法,并能获取来自 /proc/stat 的数据。

  • MemInfomng.bz/A8me),它提供了 ReadMemInfo() 方法,并能获取来自 /proc/meminfo 的数据。

  • Diskmng.bz/PRD8),它提供了 ReadDisk() 方法,并使用 Go 标准库中的 syscall 包来获取与磁盘相关的数据。

我们不会使用这些类型中包含的每一项数据,很快我们就会看到这一点。

为了便于使用这些指标,让我们围绕它们创建一个包装器。我们首先将清单 6.4 中所示的 Stats 结构体添加到 worker 目录下的 stats.go 文件中。这个包装器类型包含五个字段,将为我们提供所需的所有信息。MemStats 字段将保存我们所需的所有与内存相关的数据,并且它将是一个指向 goprocinfo 库中 MemInfo 类型的指针。DiskStats 字段将保存所有必要的与磁盘相关的数据,并且它将是一个指向 goprocinfo 库中 Disk 类型的指针。CpuStats 字段将包含所有与 CPU 相关的数据,并且它将是一个指向 goprocinfo 库中 CPUStat 类型的指针。最后,LoadStats 字段将保存相关的与负载相关的数据,并且它将是一个指向 goprocinfo 库中 LoadAvg 类型的指针。

code 6.4 The Stats type we'll use to hold all the worker's metrics

go 复制代码
type Stats struct {
    MemStats *linux.MemInfo
    DiskStats *linux.Disk
    CpuStats  *linux.CPUStat
    LoadStats *linux.LoadAvg
}

既然我们已经定义了 Stats 类型,那让我们回过头来思考一下哪些类型的指标可能会有用。从内存方面开始考虑,我们可能会对哪些信息感兴趣呢?了解工作节点拥有的总内存量是很有必要的。知道有多少内存可供新程序使用可能也会很有用。了解正在被使用的内存量同样也很不错。类似地,了解已使用内存占总内存的百分比也会很有帮助。

在确定了这些内存指标之后,让我们给 Stats 类型添加一些辅助方法,这样就能快速、轻松地获取这些数据。我们从一个名为 MemTotalKb 的方法开始,如下面的清单所示。这个方法只是简单地返回 MemStats.MemTotal 字段的值。我们在方法名后面加上后缀 Kb,以便快速提醒我们所使用的单位。

code 6.5 The MemTotalKb() helper method

go 复制代码
func (s *Stats) MemTotalKb() uint64 {
    return s.MemStats.MemTotal
}

接下来,让我们添加 MemAvailableKb 方法,如下面的清单所示。和 MemTotalKb 方法一样,它只是简单地返回 MemStats 字段中某个字段的值 ------ 在这种情况下,是 MemAvailable 字段的值。

code 6.6 The MemAvaiableKb() helper method

go 复制代码
func (s *Stats) MemAvaiableKb() uint64 {
    return s.MemStats.MemAvaiable
}

MemTotalKbMemAvailableKb 方法使我们能够算出我们确定的最后两个与内存相关的指标:以绝对值表示的已使用内存量,以及已使用内存占总内存的百分比。以下清单中的 MemUsedKbMemUsedPercent 方法提供了这些指标。

code 6.7 The MemUsedKb() and MemUsedPercent() helper methods

go 复制代码
func (s *Stats) MemUsedKb() uint64 {
    return s.MemStats.MemTotal - s.MemStats.MemAvaiable
}

func (s *Stats) MemUsedPercent() uint64 {
    return s.MemStats.MemAvaiable / s.MemStats.MemTotal
}

现在让我们把注意力转向与磁盘相关的指标。和内存指标类似,了解工作节点机器上的磁盘总容量、可用容量以及已使用容量会很有帮助。与内存相关的方法不同,我们的磁盘相关方法无需进行任何计算。这些数据可以直接从 goprocinfo 库的 Disk 类型中获取。因此,让我们创建 DiskTotalDiskFreeDiskUsed 方法,如下列清单所示。

go 复制代码
func (s *Stats) DiskTotal() uint64 {
    return s.DiskStats.All
}

func (s *Stats) DiskFree() uint64 {
    return s.DiskStats.Free
}

func (s *Stats) DiskUsed() uint64 {
    return s.DiskStats.Used
}

最后,让我们来谈谈与 CPU 相关的指标。最常用的两个指标是平均负载和使用率。正如我们之前提到的,平均负载可以在 uptime 命令的输出中看到,这些数据来自于 /proc/loadavg

shell 复制代码
$ uptime
14:38:18 up 6 days, 22:39, 2 users, load average: 0.43, 0.32, 0.33

$ cat /proc/loadavg 
0.43 0.32 0.33 1/2462 865995

然而,对于 CPU 使用率而言,情况要稍微复杂一些。在讨论 CPU 使用率时,我们通常用百分比来表述。例如,在我的笔记本电脑上,当前 CPU 使用率是 2%。但这意味着什么呢?

正如我们之前所讨论的,在 Linux 操作系统中,CPU 会在不同的状态下花费时间。此外,我们可以通过查看 /proc/stat 文件,了解 CPU 在各个状态(用户态、低优先级用户态、系统态、空闲态等)分别花费了多少时间。了解 CPU 在这些单独状态下花费的时间固然不错,但这并不能直接转化为我们所说的 "CPU 使用率为 2%" 这样一个单一的百分比数值。

遗憾的是,goprocinfo 库提供的 CPUStat 类型并没有为我们提供任何用于计算 CPU 使用率的有用辅助方法,它仅仅是提供了 CPUStat 类型。

go 复制代码
type CPUStat struct {
    Id string `json:"id"`
    User uint64 `json:"user"`
    Nice uint64 `json:"nice"`
    System uint64 `json:"idle"`
    IOWait uint64 `json:"iowait"`
    IRQ   uint64  `json:"irq"`
    SoftIRQ uint64 `json:"softirq"`
    Steal uint64   `json:"steal"`
    Guest uint64 `json:"guest"`
    GuestNice uint64 `json:"guest_nice"`
}

所以,得由我们自己来计算这个百分比。幸运的是,我们不用做太多工作,因为这个问题已经在 Stack Overflow 上一篇题为 "在 Linux 中准确计算以百分比表示的 CPU 使用率"(mng.bz/ xj17)的帖子里被讨论过了。根据这篇帖子,执行此计算的通用算法如下:

  1. 对空闲状态下的值求和。

  2. 对非空闲状态下的值求和。

  3. 求出空闲和非空闲状态值的总和。

  4. 用总和减去空闲状态的值,再将结果除以总和。

因此,我们可以按照以下代码清单来实现这个算法。

code 6.9 Using the CpuUsage() method to get CPU usage as a percentage

go 复制代码
func (s *Stats) CpuUsage() float64 {
    idle := s.CpuStats.Idle + s.CpuStats.IOWait
    nonIdle := s.CpuStats.User + s.CpuStats.Nice + s.CpuStats.System + s.CpuStats.IRQ + s.CpuStats.SoftIRQ + s.CpuStats.Steal
    total := idle + nonIdle
    
    if total == 0 {
        return 0.00
    }
    
    return (float64(total) - float64(idle)) / float64(total)
}

至此,我们已经为收集能反映单个工作节点工作量的指标奠定了基础。现在只需将我们的工作封装成几个函数,这些函数会返回一个填充完整的 Stats 类型实例,我们就可以在工作节点的 API 中使用它了。

其中第一个函数是清单 6.10 里的 GetStats() 函数。该函数通过调用相应的辅助函数,为 Stats 结构体中的 MemStatsDiskStatsCpuStatsLoadStats 字段赋值。这样,它会填充一个 Stats 类型的实例,并将指向该实例的指针返回给调用者。

code 6.10 The GetStats() function

go 复制代码
func GetStats() *Stats {
    return &Stats{
        MemStats: GetMemoryInfo(),
        DiskStats: GetDiskInfo(),
        CpuStats: GetCpuStats(),
        LoadStats: GetLoadAvg(),
    }
}

GetStats 函数中使用的每个辅助函数都采用类似的格式。它首先调用 goprocinfo 库中的相关函数,然后检查该函数调用是否返回了错误,最后返回相关结构体中的数据。

值得注意的是,如果在调用 goprocinfo 相关函数时出现错误,我们只需打印一条错误消息,并返回一个指向适当类型的指针(例如 &linux.MemInfo{}),如清单 6.11 所示。返回的类型将用相应的零值填充(即字符串类型为 空字符串 "",数字类型为 0)。除了 GetDiskInfo() 函数外,其他辅助函数都是从 /proc 文件系统获取指标。实际上,GetDiskInfo() 函数使用的是 Go 标准库中的 syscall 包。

code 6.11 Helper functions used by GetStats()

go 复制代码
func GetMemoryInfo() *linux.MemInfo {
    memstats, err := linux.ReadMemInfo("/proc/meminfo")
    if err != nil {
        log.Printf("error reading from /proc/meminfo")
        return &linux.MemInfo{}
    }
    return memstats
}

// GetDiskInfo See https://godoc.org/github.com/c9s/goprocinfo/linux#Disk
func GetDiskInfo() *linux.Disk {
    diskstats, err := linux.ReadDisk("/")
    if err != nil {
        log.Printf("Error reading from /")
        return &linux.Disk{}
    }
    
    return diskstats
}

// GetCpuInfo See https://godoc.org/github.com/c9s/goprocinfo/linux#CPUStat
func GetCpuStats() *linux.CPUStat {
    stats, err := linux.ReadStat("/proc/stat")
    if err != nil {
        log.Printf("Error reading from /proc/stat")
        return &linux.CPUStat{}
    }
    return &stats.CPUStatAll
}

// GetLoadAvg See https://godoc.org/github.com/c9s/goprocinfo/linux#LoadAvg
func GetLoadAvg() *linux.LoadAvg {
    loadavg, err := inux.ReadLoadAvg("/proc/loadavg")
    if err != nil {
        log.Printf("error reading from /proc/loadavg")
        return &linux.LoadAvg{}
    }
    return loadavg
}

这里提供一个可以在 mac 上收集指标的代码:

go 复制代码
package worker

import (
	"os/exec"
	"strconv"
	"strings"
)

type MemStats struct {
	Total     uint64
	Used      uint64
	Free      uint64
	Available uint64
}

type DiskStats struct {
	Total uint64
	Used  uint64
	Free  uint64
}

type CpuStats struct {
	Usage float64
}

type LoadAvgStats struct {
	Load1  float64
	Load5  float64
	Load15 float64
}

type Stats struct {
	Memory    MemStats
	Disk      DiskStats
	CPU       CpuStats
	Load      LoadAvgStats
	TaskCount int
}

func (s *Stats) MemTotalKb() uint64 {
	return s.Memory.Total / 1024
}

func (s *Stats) MemAvaiableKb() uint64 {
	return s.Memory.Available / 1024
}

func (s *Stats) MemUsedKb() uint64 {
	return s.Memory.Used / 1024
}

func (s *Stats) MemUsedPercent() uint64 {
	if s.Memory.Total == 0 {
		return 0
	}
	return uint64((float64(s.Memory.Used) / float64(s.Memory.Total)) * 100)
}

func (s *Stats) DiskTotal() uint64 {
	return s.Disk.Total
}

func (s *Stats) DiskFree() uint64 {
	return s.Disk.Free
}

func (s *Stats) DiskUsed() uint64 {
	return s.Disk.Used
}

func (s *Stats) CpuUsage() float64 {
	return s.CPU.Usage
}

// GetMemoryInfo retrieves memory statistics for macOS
func GetMemoryInfo() MemStats {
	cmd := exec.Command("vm_stat")
	output, err := cmd.Output()
	if err != nil {
		return MemStats{}
	}

	lines := strings.Split(string(output), "\n")
	pageSize := uint64(4096) // Default page size for macOS

	var memStats MemStats
	for _, line := range lines {
		if strings.Contains(line, "Pages free:") {
			parts := strings.Split(line, ":")
			if len(parts) == 2 {
				val, err := strconv.ParseUint(strings.TrimSpace(strings.Replace(parts[1], ".", "", -1)), 10, 64)
				if err == nil {
					memStats.Free = val * pageSize
				}
			}
		} else if strings.Contains(line, "Pages active:") ||
			strings.Contains(line, "Pages inactive:") ||
			strings.Contains(line, "Pages speculative:") ||
			strings.Contains(line, "Pages wired down:") {
			parts := strings.Split(line, ":")
			if len(parts) == 2 {
				val, err := strconv.ParseUint(strings.TrimSpace(strings.Replace(parts[1], ".", "", -1)), 10, 64)
				if err == nil {
					memStats.Used += val * pageSize
				}
			}
		}
	}

	// Get total physical memory
	cmd = exec.Command("sysctl", "-n", "hw.memsize")
	output, err = cmd.Output()
	if err == nil {
		total, err := strconv.ParseUint(strings.TrimSpace(string(output)), 10, 64)
		if err == nil {
			memStats.Total = total
		}
	}

	memStats.Available = memStats.Total - memStats.Used

	return memStats
}

// GetDiskInfo retrieves disk statistics for macOS
func GetDiskInfo() DiskStats {
	cmd := exec.Command("df", "-k", "/")
	output, err := cmd.Output()
	if err != nil {
		return DiskStats{}
	}

	lines := strings.Split(string(output), "\n")
	if len(lines) < 2 {
		return DiskStats{}
	}

	fields := strings.Fields(lines[1])
	if len(fields) < 4 {
		return DiskStats{}
	}

	var diskStats DiskStats
	total, err := strconv.ParseUint(fields[1], 10, 64)
	if err == nil {
		diskStats.Total = total * 1024 // Convert from KB to bytes
	}

	used, err := strconv.ParseUint(fields[2], 10, 64)
	if err == nil {
		diskStats.Used = used * 1024 // Convert from KB to bytes
	}

	free, err := strconv.ParseUint(fields[3], 10, 64)
	if err == nil {
		diskStats.Free = free * 1024 // Convert from KB to bytes
	}

	return diskStats
}

// GetCpuInfo retrieves CPU usage for macOS
func GetCpuInfo() CpuStats {
	cmd := exec.Command("top", "-l", "1", "-n", "0")
	output, err := cmd.Output()
	if err != nil {
		return CpuStats{}
	}

	lines := strings.Split(string(output), "\n")
	for _, line := range lines {
		if strings.Contains(line, "CPU usage") {
			parts := strings.Split(line, ":")
			if len(parts) < 2 {
				continue
			}

			usageParts := strings.Split(parts[1], ",")
			if len(usageParts) < 1 {
				continue
			}

			userPart := strings.TrimSpace(usageParts[0])
			if !strings.HasSuffix(userPart, "% user") {
				continue
			}

			userPercent, err := strconv.ParseFloat(strings.TrimSuffix(userPart, "% user"), 64)
			if err != nil {
				continue
			}

			// For simplicity, we'll just use the user CPU percentage
			// A more accurate implementation would add user + system usage
			return CpuStats{Usage: userPercent}
		}
	}

	return CpuStats{}
}

// GetLoadAvgInfo retrieves load average information for macOS
func GetLoadAvgInfo() LoadAvgStats {
	cmd := exec.Command("sysctl", "-n", "vm.loadavg")
	output, err := cmd.Output()
	if err != nil {
		return LoadAvgStats{}
	}

	// Output format is typically like "{ 1.23 0.45 0.67 }"
	outputStr := strings.TrimSpace(string(output))
	outputStr = strings.Trim(outputStr, "{ }")
	parts := strings.Fields(outputStr)

	var loadStats LoadAvgStats
	if len(parts) >= 3 {
		loadStats.Load1, _ = strconv.ParseFloat(parts[0], 64)
		loadStats.Load5, _ = strconv.ParseFloat(parts[1], 64)
		loadStats.Load15, _ = strconv.ParseFloat(parts[2], 64)
	}

	return loadStats
}

func GetStats() *Stats {
	return &Stats{
		Memory: GetMemoryInfo(),
		Disk:   GetDiskInfo(),
		CPU:    GetCpuInfo(),
		Load:   GetLoadAvgInfo(),
	}
}

6.4 在 API 上公开指标

既然我们已经完成了所有的繁重工作,要在工作节点的 API 上公开其指标,就只剩下三件事要做了:

  1. 给工作节点添加一个定期收集指标的方法。
  2. 给 API 添加一个处理方法。
  3. 给 API 添加一个 /stats 路由。

为了定期收集指标,我们在 worker.go 文件里给工作节点添加一个名为 CollectStats 的方法。如清单 6.12 所示,这个方法使用了一个无限循环,在循环内部调用了我们之前创建的 GetStats() 函数。注意,我们还设置了工作节点的 TaskCount 字段。最后,程序会休眠 15 秒。为什么要休眠 15 秒呢?这是一个随意的决定,主要是为了降低系统执行操作的频率,这样我们人类就能观察到系统的运行情况。在实际的生产系统中,用户每分钟可能会提交数十、数百甚至数千个任务,那时我们就需要以更实时的方式来收集指标。

code 6.12 The worker's new CollectStats() method

go 复制代码
func (w *Worker) CollectStats() {
    for {
        log.Println("Collecting stats")
        w.Stats = GetStats()
        w.Stats.TaskCount = w.TaskCount
        time.Sleep(15 * time.Second)
    }
}

接下来,让我们在 handlers.go 文件中为 API 添加一个名为 GetStatsHandler 的新处理方法。和我们在第 5 章创建的其他处理方法一样,这个方法接受两个参数:一个名为 whttp.ResponseWriter 类型参数,以及一个名为 r 的指向 http.Request 的指针。该方法的主体相当简单。它将 Content-Type 响应头设置为 application/json,以此告知调用者响应内容是经过 JSON 编码的。然后,它将响应状态码设置为 200。最后,它对工作节点的 Stats 字段进行编码。所以,GetStatsHandler 方法只是对工作节点 Stats 字段中的指标进行编码并返回,而这个 Stats 字段会由 CollectStats 方法每 15 秒刷新一次。API 的新 GetStatsHandler() 方法将用于处理接下来清单中创建的新 /stats 路由的请求。

code 6.13 The API's new GetStatsHandler() method

go 复制代码
func (a *Api) GetStatsHandler(w http.ResponseWriter, r *http.Request) {
    w.Header().Set("Content-Type", "application/json")
    w.WriteHeader(200)
    json.NewEncoder(w).Encode(a.Worker.Stats)
}

最后要做的事情是更新 api.go 文件中 API 的路由。在这里,我们将创建一个新的路由 /stats。这个路由仅支持 GET 请求,并且会调用我们之前创建的 GetStatsHandler 函数。

code 6.14 Adding the new /stats route to the api.go file

go 复制代码
a.Router.Route("/stats", func(r chi.Router) {
    r.Get("/", a.GetStatsHandler)
})

由于我们已经在 API 中添加了这个新路由,那么让我们更新一下第 5 章中的路由表,以便完整呈现它现在的样子,如下表 6.1 所示。

表 6.1 我们为 Worker API 更新的路由表

6.5 整合所有内容

在我们将所有内容整合起来并进行测试运行之前,让我们快速回顾一下我们所做的工作:

  1. 我们创建了一个新文件 stats.go

  2. stats.go 中,我们创建了一个新的 Stats 类型,用于保存工作节点的指标数据。

  3. 同样在 stats.go 中,我们创建了一个 GetStats() 函数,该函数使用 goprocinfo 库来收集指标数据,并将数据填充到 Stats 类型中。

  4. 我们向工作节点添加了 CollectStats 方法,该方法将在无限循环中调用 GetStats() 函数。

  5. 我们在 handlers.go 文件中向工作节点的处理程序添加了 GetStatsHandler 方法。

  6. 我们向工作节点的 API 添加了一个新的路由 /stats

从概念层面来看,这项工作如图 6.2 所示。

图 6.2 :工作节点运行在一台 Linux 机器上,并提供一个包含 /stats 端点的 API。在 /stats 端点上提供的指标数据是使用 goprocinfo 库收集的,该库直接与 Linux 的 /proc 文件系统进行交互。

现在,为了让我们的工作实际运转起来,我们需要编写一个程序,就像我们在前面的章节中所做的那样,这个程序要把我们所有的工作整合在一起。在这种情况下,我们可以复用第 5 章中编写的程序。我们只需要做一处小改动。打开我们在第 5 章中使用的 main.go 程序。在 main() 函数中,在调用 runTasks 函数之后,添加对工作节点新的 CollectStats 方法的调用。并且,和调用 runTasks 函数一样,在一个单独的 goroutine 中执行对 CollectStats 方法的调用。

6.15 Updating the main() function from our main.go file

go 复制代码
func main() {
    host := os.Getenv("CUBE_HOST")
    port, _ := strconv.Atoi(os.Getenv("CUBE_PORT"))
    
    fmt.Println("Starting Cube Worker")
    
    w := worker.Worker{
        Queue: *queue.New(),
        Db: make(map[uuid.UUID]*task.Task),
    }
    api := worker.Api{Address: host, Port: port, Worker: &w}
    
    go runTasks(&w)
    go w.CollectStats()
    api.Start()
}

更新 main.go 文件后,按照第 5 章中的方式启动 API。你会注意到,API 的日志输出显示它正在每隔 15 秒收集一次统计信息:

go 复制代码
$ CUBE_HOST=localhost CUBE_PORT=5555 go run main.go
Starting Cube Worker
2025/03/12 16:15:26 No tasks in the queue
2025/03/12 16:15:26 Sleeping for 10 second
2025/03/12 16:15:26 Collecting stats
2025/03/12 16:15:36 No tasks in the queue
2025/03/12 16:15:36 Sleeping for 10 second
2025/03/12 16:15:43 Collecting stats

现在 API 已经运行,请从另一个终端查询新的 stats 端点。你应该会看到内存、磁盘和 CPU 使用率的输出:

bash 复制代码
$ curl 127.0.0.1:5555/stats|jq .

{
  "Memory": {
    "Total": 19327352832,
    "Used": 2565980160,
    "Free": 17297408,
    "Available": 16761372672
  },
  "Disk": {
    "Total": 494384795648,
    "Used": 11155202048,
    "Free": 85339365376
  },
  "CPU": {
    "Usage": 10.33
  },
  "Load": {
    "Load1": 9.87,
    "Load5": 5.93,
    "Load15": 5.1
  },
  "TaskCount": 0
}

总结

工作节点会公开其所在运行机器的状态指标。这些有关 CPU、内存和磁盘使用情况的指标将被管理器用于做出调度决策。

为了更轻松地收集指标,我们使用了一个名为 goprocinfo 的第三方库。这个库处理了从 /proc 文件系统获取指标所需的大部分底层工作。

这些指标可以在我们在第 5 章构建的同一个 API 上获取。因此,管理器将有一种统一的方式与工作节点进行交互:通过向 /tasks 发送 HTTP 调用以执行任务操作,以及向 /stats 发送调用以收集有关工作节点当前状态的指标。

相关推荐
Super_man54188几秒前
k8s之service解释以及定义
java·开发语言·云原生·容器·kubernetes
hwj运维之路3 分钟前
k8s监控方案实践(一):部署Prometheus与Node Exporter
容器·kubernetes·prometheus
和计算机搏斗的每一天4 分钟前
k8s术语之DaemonSet
云原生·容器·kubernetes
喝养乐多长不高1 小时前
Spring Web MVC基础理论和使用
java·前端·后端·spring·mvc·springmvc
莫轻言舞1 小时前
SpringBoot整合PDF导出功能
spring boot·后端·pdf
玄武后端技术栈2 小时前
什么是死信队列?死信队列是如何导致的?
后端·rabbitmq·死信队列
老兵发新帖4 小时前
NestJS 框架深度解析
后端·node.js
斯普信专业组4 小时前
基于Kubernetes的Apache Pulsar云原生架构解析与集群部署指南(上)
云原生·kubernetes·apache
码出钞能力5 小时前
对golang中CSP的理解
开发语言·后端·golang
金融数据出海5 小时前
黄金、碳排放期货市场API接口文档
java·开发语言·spring boot·后端·金融·区块链