Go 语言 DevOps(三)

原文:annas-archive.org/md5/3bb23876803d0893c1924ba12cfd8f56

译者:飞龙

协议:CC BY-NC-SA 4.0

第七章:编写命令行工具

访问任何 DevOps 工程师,你会发现他们的屏幕上充满了执行 命令行界面 (CLI) 应用程序的终端。

作为 DevOps 工程师,我们不只是希望使用别人为我们制作的应用程序;我们希望能够编写自己的 CLI 应用程序。这些应用程序可以通过 REST 或 gRPC 与各种系统通信,正如我们在前一章中讨论的那样。或者你可能想要执行各种应用程序,并通过自定义处理运行它们的输出。一个应用程序甚至可以设置开发环境并启动新发布的测试周期。

无论你的用例是什么,你都需要使用一些常见的包来帮助你管理应用程序的输入和输出处理。

在本章中,你将学习如何使用 flagos 包编写简单的 CLI 应用程序。对于更复杂的应用程序,你将学习如何使用 Cobra 包。这些技能,加上我们之前章节中学到的技能,将让你能够为你自己或你客户的需求构建各种应用程序。

本章将涵盖以下主要主题:

  • 实现应用程序的 I/O

  • 使用 Cobra 构建高级 CLI 应用程序

  • 处理操作系统信号

在本节中,我们将深入探讨如何使用标准库的 flag 包来构建基本的命令行程序。让我们开始吧!

技术要求

本章的代码文件可从 github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/7 下载。

实现应用程序的 I/O

CLI 应用程序需要一种方式来理解你希望它们执行的方式。这可能包括要读取哪些文件,要联系哪些服务器以及要使用哪些凭据。

有几种方式可以启动一个应用程序,以满足它所需的参数:

  • 使用 flag 包定义命令行标志

  • 使用 os.Args 读取未定义的参数

当你有一个命令行参数具有严格定义时,flag 包将对你有所帮助。这可能是一个定义所需服务端点的参数。程序可能希望在生产环境中有一个默认值,但在测试时允许覆盖。这对于标志非常合适。

例如,一个程序可能会查询我们之前创建的 每日引语 (QOTD) 服务器。我们可能希望它自动使用我们的生产端点,除非我们指定使用另一个地址。这可能看起来像这样:

qotd

这简单地联系到我们的生产服务器并获取我们的报价。--endpoint 标志,默认使用我们的生产地址,将使用下面的另一个地址:

qotd --endpoint="127.0.0.1:3850"

有时,应用程序的参数就足够了。以一个用于重新格式化 JSON 数据以供人类可读的应用程序为例。如果没有提供文件,我们可能只希望从 STDIN 读取数据。在这种情况下,直接从命令行读取值就足够了,可以使用 os 包。这将使我们的执行结果如下所示:

reformat file1.json file2.json

这里,我们正在读取 file1.jsonfile2.json,并输出重新格式化的文本。

这里,我们接收 wget 调用的输出,并通过 STDIN 将其读取到我们的 reformat 二进制文件中。这类似于 catgrep 的工作方式。当我们的参数为空时,它们会直接从 STDIN 中读取:

wget "http://some.server.com" | reformat

有时候,我们可能希望将标志和参数混合使用。flag 包也可以帮助处理这种情况。

那么,让我们开始使用 flag 包吧。

flag

为了处理命令行参数,Go 提供了标准库中的 flag 包。使用 flag,你可以为标志设置默认值、提供标志描述,并允许用户在命令行中覆盖默认值。

使用 flag 包的标志通常以 -- 开头,类似于 --endpoint。值可以是紧跟在端点后的连续字符串或用引号括起来的字符串。虽然你可以使用单个 - 替代 --,但在处理布尔型标志时有一些特殊情况,我建议在所有情况下使用 --

你可以在这里找到 flag 包的文档:pkg.go.dev/flag

让我们展示一个标志的实际应用:

var endpoint = flag.String(
     "endpoint", 
     "myserver.aws.com", 
     "The server this app will contact",
)

这段代码的作用是:

  • 定义一个 endpoint 变量来存储标志

  • 使用 String 标志

  • 将标志定义为 endpoint

  • 设置标志的默认值为 myserver.aws.com

  • 设置标志的描述

如果我们不传递--endpoint,代码将使用默认值。为了让我们的程序读取该值,我们只需执行以下操作:

func main() {
     flag.Parse()
     fmt.Println("server endpoint is: ", *endpoint)
}

重要提示

flag.String() 返回 *string,因此上面的 *endpoint

flag.Parse() 对于使标志在应用程序中可用至关重要。这个方法应该只在 main() 包内调用。

提示

Go 中的最佳实践是从不在 main 包外定义标志。只需将值作为函数参数或在对象构造函数中传递。

除了 String()flag 还定义了其他一些标志函数:

  • Bool() 用于捕获 bool

  • Int() 用于捕获 int

  • Int64() 用于捕获 int64

  • Uint() 用于捕获 uint

  • Uint64() 用于捕获 uint64

  • Float64() 用于捕获 float64

  • Duration() 用于捕获 time.Duration,例如 3m10s

现在我们已经看过基本类型,接下来我们来谈谈自定义标志类型。

自定义标志

有时,我们希望将值放入 flag 包中未定义的类型中。

要使用自定义标志,必须定义一个实现了 flag.Value 接口的类型,接口定义如下:

type Value interface {
     String() string
     Set(string) error
}

接下来,我们将借用一个来自 Godoc 的示例,展示一个名为 URLValue 的自定义值,用于处理表示 URL 的标志,并将其存储在我们的标准 *url.URL 类型中:

type URLValue struct {
    URL *url.URL
}
func (v URLValue) String() string {
    if v.URL != nil {
        return v.URL.String()
    }
    return ""
}
func (v URLValue) Set(s string) error {
    if u, err := url.Parse(s); err != nil {
        return err
    } else {
        *v.URL = *u
    }
    return nil
}
var u = &url.URL{}
func init() {
    flag.Var(&URLValue{u}, "url", "URL to parse")
}
func main() {
    flag.Parse()
    if reflect.ValueOf(*u).IsZero() {
        panic("did not pass an URL")
    }
    fmt.Printf(`{scheme: %q, host: %q, path: %q}`, 
                 u.Scheme, u.Host, u.Path)
}

这段代码执行了以下操作:

  • 定义了一个名为URLValueflag.Value类型。

  • 创建一个名为-url的标志,用于读取有效的 URL。

  • 使用URLValue包装器将 URL 存储在*url.URL变量中。

  • 使用reflect包来判断struct是否为空。

通过在类型上定义Set()方法,像我们之前所做的那样,你可以读取任何自定义值。

现在我们已经掌握了标志类型的使用,接下来看看一些基本的错误处理。

基本的标志错误处理

当我们输入不兼容的标志或具有错误值的标志时,通常我们希望程序输出错误的标志及其值。

这可以通过PrintDefaults()选项来实现。以下是一个示例:

var (
     useProd = flag.Bool("prod", true, "Use a production endpoint")
     useDev = flag.Bool("dev", false, "Use a development endpoint")
     help = flag.Bool("help", false, "Display help text")
)
func main() {
     flag.Parse()
     if *help {
          flag.PrintDefaults()
          return
     }
     switch {
     case *useProd && *useDev:
          log.Println("Error: --prod and --dev cannot both be set")
          flag.PrintDefaults()
          os.Exit(1)
     case !(*useProd || *useDev):
          log.Println("Error: either --prod or --dev must be set")
          flag.PrintDefaults()
          os.Exit(1)
     }
}

这段代码执行了以下操作:

  • 定义一个--help标志,如果设置了该标志,则只打印默认值。

  • 定义了另外两个标志,--prod--dev

  • 如果设置了--prod--dev,则输出错误信息和默认标志值。

  • 如果两个标志都没有设置,则输出错误信息和默认值。

下面是输出的示例:

Error: --prod and --dev cannot both be set
  -dev
         Use a development endpoint (default false)
  -prod
         Use a production endpoint (default true)

这段代码演示了如何拥有有效默认值的标志,但如果这些值被更改导致错误,我们可以检测并处理这个错误。按照良好的命令行工具精神,我们提供了--help,允许用户查看可以使用的标志。

简写标志

在前面的示例中,我们有一个--help标志。但是通常,你可能希望提供一个简写标志,比如-h,供用户使用。它们需要具有相同的默认值,并且都需要设置相同的变量,因此它们不能有两个不同的值。

我们可以使用flag.[Type]Var()调用来帮助我们实现这一点:

var (
    useProd = flag.Bool("prod", true, 
                  "Use a production endpoint")
    useDev = flag.Bool("dev", false, 
                  "Use a development endpoint")
    help = new(bool)
)
func init() {
    flag.BoolVar(help, "help", false, "Display help text")
    flag.BoolVar(help, "h", false, 
                 "Display help text (shorthand)")
}     

在这里,我们将--help--h的结果存储在help变量中。我们使用init()进行初始化,因为BoolVar()不会返回变量;因此,它不能在var()语句中使用。

现在我们已经了解了简写标志的工作原理,让我们来看一下非标志参数。

访问非标志参数

Go 中的参数可以通过几种方式读取。你可以使用os.Args读取原始参数,它也会包含所有的标志。若没有使用标志,这是非常方便的。

使用标志时,flag.Args()可以用来仅检索非标志参数。如果我们想将一份作者列表发送到开发服务器,并为每个作者获取 QOTD,命令可能是这样的:

qotd --dev "abraham lincoln" "martin king" "mark twain"

在这个列表中,我们使用--dev标志来指示我们希望使用开发服务器。在标志之后,我们列出了参数。让我们来获取这些参数:

func main() {
     flag.Parse()
     authors := flag.Args
     if len(authors) == 0 {
          log.Println("did not pass any authors")
          os.Exit(1)
     }
     ...

在这段代码中,我们执行了以下操作:

  • 使用flag.Args()获取非标志参数。

  • 测试我们是否收到了至少一个作者,否则就退出并显示错误。

我们已经看到如何获取作为参数或标志传入的输入。这可以用来定义如何联系服务器或打开哪些文件。现在让我们看一下如何从流中接收输入。

从 STDIN 获取输入

现在,DevOps 社区中编写的大多数应用程序倾向于围绕标志和参数展开,正如之前所见。DevOps 人员每天使用的一种不太常见的输入方法是将输入通过管道传递到程序中。

工具如catxargssedawkgrep允许你将一个工具的输出传递给下一个工具的输入以完成任务。一个简单的例子可能是查找我们从网络获取的文件中包含error字样的行:

wget http://server/log | grep -i "error" > only_errors.txt 

cat这样的程序在未指定文件时从STDIN读取输入。我们在这里复制了这个行为,编写一个程序来查找每一行中的error并打印出来:

var errRE = regexp.MustCompile(`(?i)error`)
func main() { 
    var s *bufio.Scanner 
    switch len(os.Args) { 
  case 1:
          log.Println("No file specified, using STDIN")
          s = bufio.NewScanner(os.Stdin)
  case 2:
          f, err := os.Open(os.Args[1])
          if err != nil {
                  log.Println(err)
                  os.Exit(1)
          }
          s = bufio.NewScanner(f)
  default:
          log.Println("too many arguments provided")
          os.Exit(1)
  }
  for s.Scan() {
          line := s.Bytes()
          if errRE.Match(line) {
                  fmt.Printf("%s\n", line)
          }
  }
  if err := s.Err(); err != nil {
          log.Println("Error: ", err)
          os.Exit(1)
  }
}

这段代码做了以下事情:

  • 使用regexp包编译一个正则表达式来查找包含error的行——匹配是大小写不敏感的。

  • 使用os.Args()读取我们的参数列表。我们使用这个而不是flag.Args(),因为我们没有定义任何标志。

  • 如果我们只有一个参数(程序名),它会使用os.Stdin,这是一个io.Reader,我们将其包装在一个bufio.Scanner中。

  • 如果我们有一个文件参数,它会打开文件,并将io.Reader包装在一个bufio.Scanner对象中。

  • 如果我们有更多的参数,它将返回一个错误。

  • 一行一行地读取输入,并将每一行包含error字样的行打印到os.Stdout

  • 检查我们是否有输入错误——io.EOF不被视为错误,因此不会触发if语句。

你可以在这个代码库中找到此代码:github.com/PacktPublishing/Go-for-DevOps/blob/rev0/chapter/7/filter_errors/main.go

使用编译后的filter_errors代码,我们可以用它来扫描wget输入(或任何传入的输入),查找包含error字样的行,然后使用grep过滤特定的错误代码,如401(未授权):

wget http://server/log | filter_errors | grep 401

或者我们也可以以相同的方式搜索日志文件:

filter_errors log.txt | grep 401

这是一个简单的例子,借助现有工具很容易实现,但它展示了如何构建类似的工具。

在这一节中,我们已经学习了如何从命令行读取不同的输入形式,包括标志和参数。我们看到了共享状态的简短标志与长格式标志。你也看到了如何创建自定义类型来用作标志。最后,我们学习了如何成功使用 STDIN 来读取通过管道传送的输入。

接下来,我们将学习如何使用第三方包 Cobra 来创建更复杂的命令行应用程序。

使用 Cobra 进行高级 CLI 应用程序

Cobra 是一组软件包,允许开发人员创建更复杂的 CLI 应用程序。当应用程序的复杂性导致标志列表变得繁多时,它比标准的flag包更有用。

在这一节中,我们将讨论如何使用 Cobra 创建结构化的 CLI 应用程序,这些应用程序对开发人员友好,便于添加功能,并使用户能够了解应用程序中可用的功能。

Cobra 提供的几个功能如下:

  • 嵌套子命令

  • 命令建议

  • 为命令创建别名,以便你在不破坏用户的情况下进行更改

  • 从标志和命令生成帮助文本

  • 为各种 shell 生成自动补全代码

  • 手册页创建

本节将大量引用 Cobra 文档,你可以在这里找到:github.com/spf13/cobra/blob/master/user_guide.md

代码组织

为了有效使用 Cobra,并使开发人员更容易理解在哪里添加和更改命令,Cobra 建议使用以下结构:

 appName/
     cmd/
          add.go
          your.go
          commands.go
          here.go
     main.go

这个结构将你的主要 main.go 可执行文件放在顶层目录下,所有命令都位于 cmd/ 目录下。

Cobra 应用程序的主文件主要用于初始化 Cobra 并让其执行命令。该文件将如下所示:

package main
import (
     "{pathToYourApp}/cmd"
)
func main() {
     cmd.Execute()
}

接下来,我们将使用 Cobra 生成器应用程序来生成基础代码。

可选的 Cobra 生成器

Cobra 提供了一个可以为我们的应用程序生成基础代码的应用程序。要开始使用生成器,我们将在根目录创建一个名为 ~/.cobra.yaml 的配置文件:

author: John Doak myemail@somedomain.com
year: 2021
license: MIT

这将处理打印我们的 MIT 许可证。你可以使用以下任何一个内置许可证值:

  • GPLv2

  • GPLv3

  • LGPL

  • AGPL

  • 2-Clause BSD

  • 3-Clause BSD

如果你需要这里没有的许可证,可以在这里找到如何提供自定义许可证的说明:github.com/spf13/cobra-cli/blob/main/README.md#configuring-the-cobra-generator

默认情况下,Cobra 会使用来自你 home 目录的配置文件。如果你需要不同的许可证,请将配置文件放入你的仓库,并使用 cobra --config="config/location.yaml" 来使用替代的配置文件。

要下载 Cobra 并使用 Cobra 生成器进行构建,请在命令行中键入以下内容:

go get github.com/spf13/cobra/cobra
go install github.com/spf13/cobra/cobra

现在,为了初始化应用程序,请确保你处于新应用程序的根目录,并执行以下操作:

cobra init --pkg-name [repo path]

重要提示

[repo path] 将是类似 github.com/spf13/newApp 的路径。

让我们为我们的应用程序创建几个命令:

cobra add serve
cobra add config
cobra add create -p 'configCmd'

这将为我们提供以下内容:

app/
      cmd/
         serve.go
         config.go
         create.go
       main.go

重要提示

你需要使用 camelCase 格式来命名命令。如果不这样做,将会遇到错误。

create-p 选项用于将其作为 config 的子命令。后面的字符串是父命令的名称加上 Cmd。所有其他 add 调用都将 -p 设置为 rootCmd

在你执行 go build 后,我们可以像这样运行它:

  • app

  • app serve

  • app config

  • app config create

  • app help serve

在基础代码框架完成后,我们只需要配置要执行的命令。

命令包

在生成的 cmd 包中,你会找到每个可执行命令的文件。我们需要修改每个文件,以便提供正确的帮助文本、使用标志并执行命令。

我们将查看一个通过以下命令创建的应用程序生成的 cmd/get.go 文件:

cobra init --pkg-name [repo path]
cobra add get

这个应用程序将与我们在 第六章 中创建的 QOTD 服务器进行交互,与远程数据源交互

生成的 cmd/get.go 文件将类似于以下内容:

var getCmd = &cobra.Command{
        Use:   "get",
        Short: "A brief description of your command",
        Long: `A longer description that spans multiple lines and likely contains examples and usage of using your command.`,
        Run: func(cmd *cobra.Command, args []string) {
                fmt.Println("get called")
        },
}
func init() {
        rootCmd.AddCommand(getCmd)
}

这段代码执行以下操作:

  • 创建一个名为 serveCmd 的变量:

    • 变量名是基于命令名称加上 Cmd 后缀。

    • Use 是命令行中的参数名称。

    • Short 是简要描述。

    • Long 是一个更长的描述,包含示例。

    • Run 是你要执行的代码的入口点。

  • 定义 init(),它执行以下操作:

    • 将此命令添加到 rootCmd 对象中。

让我们用这个来编写我们的 QOTD CLI:

     ...
     Run: func(cmd *cobra.Command, args []string) {
        const devAddr = "127.0.0.1:3450"
        fs := cmd.Flags()
        addr := mustString(fs, "addr")
        if mustBool(fs, "dev") {
                addr = devAddr
        }
        c, err := client.New(addr)
        if err != nil {
                fmt.Println("error: ", err)
                os.Exit(1)
        }
        a, q, err := c.QOTD(cmd.Context(), mustString(fs, "author"))
        if err != nil {
                fmt.Println("error: ", err)
                os.Exit(1)
        }
        switch {
        case mustBool(fs, "json"):
                b, err := json.Marshal(
                        struct{
                                Author string
                                Quote string
                        }{a, q},
                )
                if err != nil {
                        panic(err)
                }
                fmt.Printf("%s\n", b)
        default:
                fmt.Println("Author: ", a)
                fmt.Println("Quote: ", q)
        }
     },
}

这段代码执行以下操作:

  • 设置一个 addr 变量来保存我们的服务器地址:

    • 如果传递了 --dev,它会将 addr 设置为 devAddr

    • 否则,它使用 --addr 标志的值。

    • --addr 默认为 127.0.0.1:80

  • 创建一个新的客户端,用于连接我们的 QOTD 服务器

  • 调用 QOTD 服务器:

    • 使用传递给 *cobra.CommandContext

    • 使用 --author 标志的值,默认为空字符串

  • 使用 --json 标志来决定输出是否为 JSON 格式:

    • 如果是 JSON 格式,它会将内联定义的结构体输出为 JSON。

    • 否则,它只是将其漂亮地打印到屏幕上。

      重要提示

      你将看到 mustBool()mustString() 函数。这些函数只是返回传递的标志名称对应的值。如果标志未定义,它会触发 panic。这样可以去掉许多冗长的代码,因为这部分内容必须在 CLI 应用程序中始终有效。这些函数位于代码库版本中。

      你看到的标志并非来自标准库的 flag 包。相反,这个包使用了来自 github.com/spf13/pflag 的标志类型。这个包比标准 flag 包有更多的内置类型和方法。

现在,我们需要在 Run 函数中定义我们正在使用的标志:

func init() {
        rootCmd.AddCommand(getCmd)
        getCmd.Flags().BoolP("dev", "d", false, 
            "Uses the dev server instead of prod")
        getCmd.Flags().String("addr", "127.0.0.1:80", 
            "Set the QOTD server to use, 
            defaults to production")
        getCmd.Flags().StringP("author", "a", "", 
            "Specify the author to 
            get a quote for")
        getCmd.Flags().Bool("json", false, 
            "Output is in JSON format")
}

这段代码执行以下操作:

  • 添加一个名为 --dev 的标志,可以缩写为 -d,默认为 false

  • 添加一个名为 --addr 的标志,默认为 "127.0.0.1:80"

  • 添加一个名为 --author 的标志,可以缩写为 -a

  • 添加一个名为 --json 的标志,默认为 false

    重要提示

    P 结尾的方法,例如 BoolP(),定义了缩写的标志以及长标志名称。

我们定义的标志仅在执行 get 命令时可用。如果我们在 get 命令下创建子命令,这些标志只会在没有定义子命令的 get 命令中可用。

要添加适用于所有子命令的标志,请使用 .PersistentFlags() 而不是 .Flags()

这个客户端的代码可以在以下仓库中找到:github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/7/cobra/app/

现在,我们可以运行我们的应用程序并调用这个命令。在这些示例中,你需要运行 gRPC 章节中的 QOTD 服务器,像这样:

$ go run qotd.go --addr=127.0.0.1:3560
$ go run main.go get --addr=127.0.0.1:3560 --author="Eleanor Roosevelt" –json 

这将使用位于127.0.0.1:3560地址的服务器运行我们的应用程序,并请求一个来自埃莉诺·罗斯福的引用,输出为 JSON 格式:

{"Author":"Eleanor Roosevelt","Quote":"The future belongs to
those who believe in the beauty of their dreams"}

这个示例从地址为127.0.0.1:3560的服务器获取一个随机的引用:

$ go run main.go get --addr=127.0.0.1:3560 
Author: Mark Twain 
Quote: Golf is a good walk spoiled

在这一部分中,我们学习了 Cobra 包是什么,如何使用 Cobra 生成器工具来引导 CLI 应用程序,最后,如何使用这个包为你的应用程序构建命令。

接下来,我们将讨论如何处理信号,在退出应用程序之前进行清理。

处理操作系统信号

在编写 CLI 应用程序时,开发者有时需要处理操作系统信号。最常见的例子是用户试图退出程序,通常通过快捷键来实现。

在这些情况下,你可能想在退出之前进行一些文件清理,或取消对远程系统的调用。

在这一部分中,我们将讨论如何捕获并响应这些事件,以使你的应用程序更加强健。

捕获操作系统信号

Go 处理两种类型的操作系统信号:

  • 同步

  • 异步

同步信号通常与程序错误相关。Go 将这些信号视为运行时的恐慌,因此可以使用defer语句来处理这些信号的拦截。

根据平台的不同,异步信号有所不同,但对于 Go 程序员来说,最相关的信号如下:

  • SIGHUP:连接的终端断开。

  • SIGTERM:请退出并进行清理(由程序生成)。

  • SIGINT:与SIGTERM相同(从终端发送)。

  • SIGQUIT:与SIGTERM相同,外加一个核心转储(从终端发送)。

  • SIGKILL:程序必须退出;此信号无法捕获。

在出现这些情况时,拦截信号可以非常有用,这样你就可以取消正在进行的操作并在退出前进行清理。需要注意的是,SIGKILL无法被拦截,而SIGHUP只是表明一个进程失去了其终端连接,并不一定意味着进程被取消。这可能是因为进程被移动到后台或发生了其他类似事件。

要捕获一个信号,我们可以使用os/signal包。这个包允许程序接收来自操作系统的信号通知并做出响应。下面是一个简单的示例:

signals := make(chan os.Signal, 1)
signal.Notify(
     signals,
     syscall.SIGINT,
     syscall.SIGTERM,
     syscall.SIGQUIT,
)
go func() {
     switch <-signals {
     case syscall.SIGINT, syscall.SIGTERM:
          cleanup()
          os.Exit(1)
     case syscall.SIGQUIT:     
          cleanup()
          panic("SIGQUIT called")
     }
}()

这段代码执行以下操作:

  • 创建一个signals通道来接收信号

  • 订阅SIGINTSIGTERMSIGQUIT类型的信号

  • 使用一个 goroutine 来处理传入的信号,执行以下操作:

    • 调用cleanup()函数来处理程序的清理工作

    • 在收到SIGINTSIGTERM时以1代码退出

    • SIGQUIT信号下发生 panic,产生基本的核心转储

信号处理代码应该写在main包中。cleanup()函数应包含处理未完成项目的函数调用,例如远程调用取消和文件清理。

重要提示

你可以通过环境变量GOTRACEBACK来控制核心转储的数据量和生成方式。你可以在这里阅读相关内容:pkg.go.dev/runtime#hdr-Environment_Variables

使用Context进行取消

在 Go 中,停止操作的关键方法是使用 Go 的context.Context对象的上下文取消功能。该对象在第二章,《Go 语言精要》中有讨论,如果你需要复习。

只需在main()中创建一个带取消功能的Context对象,并将其传递给所有函数调用,我们就可以有效地取消所有正在进行的工作。当用户按下Ctrl + C时,这对于停止处理并进行清理非常有用。

我们将展示一种高级信号处理方法,程序的功能如下:

  • 每 1 秒创建一个新临时文件,持续 30 秒

  • 如果程序被取消,则清理文件

让我们首先创建一个处理信号的函数:

func handleSignal(cancel context.CancelFunc) chan os.Signal {
        out := make(chan os.Signal, 1)
        notify := make(chan os.Signal, 10)
        signal.Notify(
                notify,
                syscall.SIGINT,
                syscall.SIGTERM,
                syscall.SIGQUIT,
        )
        go func() {
                defer close(out)
                for {
                        sig := <-notify
                        switch sig {
                        case syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT:
                                cancel()
                                out <- sig
                                return
                        default:
                                log.Println("unhandled signal: ", sig)
                        }
                }
        }()
        return out
}

这段代码执行以下操作:

  • 创建一个名为handleSignal()的新函数

  • 有一个名为cancel的参数,用于信号函数链停止处理

  • 创建一个out通道,用来返回接收到的信号

  • 创建一个notify通道,用于接收信号通知

  • 创建一个 goroutine 来接收信号:

    • 如果信号是退出信号,则调用cancel()

    • 返回通知我们退出的信号。

    • 如果是其他信号,仅记录日志。

现在,让我们创建一个函数来创建文件:

func createFiles(ctx context.Context, tmpFiles string) error {
        for i := 0; i < 30; i++ {
                if err := ctx.Err(); err != nil {
                        return ctx.Err()
                }
                _, err := os.Create(filepath.Join(tmpFiles, strconv.Itoa(i)))
                if err != nil {
                        return err
                }
                fmt.Println("Created file: ", i)
                time.Sleep(1 * time.Second)
        }
        return nil
}

这段代码执行以下操作:

  • 循环 30 次,执行以下操作:

    • 检查我们的ctx是否已取消

    • 如果是错误,则返回该错误

    • 否则,在tmpFiles中创建文件

    • 每隔 1 秒创建一个新文件,持续 30 秒

这段代码将在tmpFiles中创建从029的文件,除非在写入文件时发生问题或Context被取消。

现在,我们需要一些代码来清理文件,如果我们收到quit信号。如果没有收到,文件将被保留:

func cleanup(tmpFiles string) {
        if err := os.RemoveAll(tmpFiles); err != nil {
                fmt.Println("problem doing file cleanup: ", err)
                return
        }
        fmt.Println("cleanup done")
}

这段代码执行以下操作:

  • 使用os.RemoveAll()删除文件:

    • 同时移除临时目录
  • 通知用户清理已完成

让我们将这些代码整合到我们的main()中:

func main() {
        tmpFiles, err := os.MkdirTemp("", "myApp_*")
        if err != nil {
                log.Println("error creating temp file directory: ", err)
                os.Exit(1)
        }
        fmt.Println("temp files located at: ", tmpFiles)
        ctx, cancel := context.WithCancel(context.Background())
        recvSig := handleSignal(cancel)
        if err := createFiles(ctx, tmpFiles); err != nil {
                cleanup(tmpFiles)
                select {
                case sig := <-recvSig:
                        if sig == syscall.SIGQUIT {
                                panic("SIGQUIT called")
                        }
                default:
                // Prevents waiting on a
                // signal if none exists.
                }
                log.Println("error: ", err)
                os.Exit(1)
        }
        fmt.Println("Done")
}

这段代码执行以下操作:

  • 创建临时文件目录

  • 创建一个根Context对象,ctx

    • ctx可以通过cancel()取消。
  • 调用我们的handleSignal()来处理任何退出信号

  • 执行我们的createFiles()函数:

    • 如果我们遇到错误,调用cleanup()

    • 清理后,我们查看是否收到了信号,而不仅仅是错误。

    • 如果是信号并且是SIGQUIT,我们调用panic()。这是因为根据定义,SIGQUIT应该产生核心转储。

    • 如果只是一个错误,打印错误并返回错误代码。

此代码的完整内容可以在此仓库找到:github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/7/signals

重要提示

代码必须通过go build构建并作为二进制文件运行。不能使用go run,因为go二进制文件在分叉我们的程序时会先拦截信号,导致我们的程序无法接收到信号。

在 Go 中,可以创建多种类型的核心转储,这些类型由环境变量控制。这个变量由GOTRACEBACK控制。你可以在这里阅读相关内容:pkg.go.dev/runtime#hdr-Environment_Variables

与 Cobra 一起的取消处理

当 Cobra 最初创建时,context包还不存在。在 2020 年,该程序经过修补,允许将Context对象传递给cobra.Command。但不幸的是,Cobra 生成器没有更新以生成必要的模板代码。

要像之前那样添加信号处理,我们只需进行几个修改 —— 首先是修改main.go文件:

func main() {
	ctx, cancel := context.WithCancel(context.Background())
	var sigCh chan os.Signal
	go func() {
		handleSignal(ctx, cancel)
	}()
	cmd.Execute(ctx)
	cancel()
	if sig := <-sigCh; sig == syscall.SIGQUIT {
		panic("SIGQUIT")
	}
}

我们还需要修改handleSignal()。你可以在这里看到这些更改:go.dev/play/p/F4SdN-xC-V_L

最后,你必须像这样修改cmd/root.go文件:

func Execute(ctx context.Context) {
        cobra.CheckErr(rootCmd.ExecuteContext(ctx))
}

现在我们有了信号处理。当编写我们的Run函数时,我们可以使用cmd.Context()来获取Context对象并检查是否有取消操作。

案例研究 – 缺乏取消处理导致死循环

早期的 Google 系统之一,旨在帮助自动化网络管理的系统叫做 Chipmunk。Chipmunk 包含网络上的权威数据,并根据这些数据生成路由器配置。

像大多数软件一样,Chipmunk 最初运行迅速并节省大量时间。随着网络每年增长十倍,设计和语言选择的局限性开始显现。

Chipmunk 是基于 Django 和 Python 构建的,并没有为横向扩展设计。当系统变得繁忙时,配置请求开始需要 30 分钟或更长时间。对于这些请求的计时器设置的最大时限为 30 分钟。

当生成接近这些限制时,设计存在一个致命缺陷 —— 如果请求被取消,取消信号不会传递给正在运行的配置生成器。

这意味着,如果生成过程花了 25 分钟,但在 1 分钟时被取消,那么生成器将继续工作 24 分钟,却没有人来接收这些工作。

当调用达到时间限制时,调用者会超时并重试。但生成器仍在处理前一个调用。这会导致级联失败,因为正在运行多个计算密集型计算,其中一些不再有接收者。这将推动新调用超过时间限制,因为 Python 的全局解释器锁GIL wiki.python.org/moin/GlobalInterpreterLock)阻止了真正的多线程,并且每次调用都会使 CPU 使用率加倍。

处理此类失败场景的关键之一是能够取消不再需要的作业。这就是为什么在整个函数调用链中传递context.Context对象并在逻辑点查找取消的重要性所在。这可以极大地减少达到阈值的系统负载,并减少分布式拒绝服务DDoS)攻击的损害。

本节讨论了程序如何拦截操作系统信号并对这些信号进行响应。它提供了使用Context处理取消执行的示例,可用于任何应用程序。我们讨论了如何将其集成到使用 Cobra 生成器生成的程序中。

摘要

本章为你提供了编写基本和高级命令行应用程序的技能。我们讨论了如何使用flag包和os包接收来自用户的标志和参数形式的信号。我们还讨论了如何从os.Stdin读取数据,这使你可以将多个可执行文件串联起来进行处理。

我们还讨论了更高级的应用程序,特别是 Cobra 包及其附带的生成器二进制文件,用于构建带有帮助文本、快捷方式和子命令的高级命令行工具。

最后,我们讨论了如何处理信号并在这些信号的取消时提供清理工作。这包括一个关于为何取消可能至关重要的案例研究。

你在这里学到的技能将在未来编写工具时至关重要,从与本地文件的交互到与服务的交互。

在下一章中,我们将讨论如何自动化与本地设备或远程设备上命令行的交互。

第八章:自动化命令行任务

大多数工作最初都是由工程师执行的某种手动操作。随着时间的推移,这些操作应成为已记录的程序,并且具有最佳实践,最终,这项工作应该由软件来完成,软件可以根据最佳实践高效地执行,而这种效率只有机器才能提供。

开发运维DevOps)工程师的核心任务之一是自动化这些任务。任务可能从简单的运行几条命令到在成千上万台机器上更改配置。

自动化系统通常需要通过命令行操作系统并调用其他本地工具(操作系统OS))。这可能包括使用RPM 包管理器RPM)/Debian 包dpkg)安装软件包,使用常见工具获取系统统计信息,或配置网络路由器。

一名 DevOps 工程师可能希望在本地自动化通常手动执行的一系列步骤(例如,自动化 Kubernetes 的kubectl工具),或者远程在数百台机器上同时执行命令。本章将讨论如何使用 Go 完成这些任务。

在本章中,您将学习如何在本地机器上执行命令行工具以实现自动化目标。要访问远程机器,我们将学习如何使用安全外壳SSH)和 Expect 包。但了解如何调用机器上的可执行文件只是技能的一部分。我们还将讨论更改的结构以及如何安全地进行并发更改。

本章将涵盖以下主题:

  • 使用os/exec来自动化本地更改

  • 在 Go 中使用 SSH 自动化远程更改

  • 设计安全的并发更改自动化

  • 编写系统代理

技术要求

本章要求您安装最新的 Go 工具,并可以访问 Linux 系统以运行我们创建的任何服务二进制文件。本章中的所有工具都将面向控制 Linux 系统,因为它是最受欢迎的云计算平台。

对于远程机器访问要求,远程 Linux 系统需要运行 SSH,以允许远程连接。

要在本章的最后部分使用系统代理,您还需要使用已安装systemd的 Linux 发行版。大多数现代发行版使用systemd

本章中编写的代码可以在这里找到:

github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/8

使用os/exec来自动化本地更改

自动化执行本地机器上的工具可以为最终用户提供一系列好处。其中第一个是它可以减少团队的繁琐工作。DevOps 和 站点可靠性工程师 (SRE) 的主要目标之一是消除重复的手动过程。那段时间可以用来读一本好书(比如这本),整理袜子抽屉,或者处理下一个问题。第二个好处是可以消除过程中的人为错误。打错字或复制粘贴错误都很容易发生。最后,它是大规模运营的核心基础。通过本地自动化结合书中详细描述的其他技术,可以在大规模上进行变更。

自动化生命周期通常分为三个阶段,从手动工作到自动化,如下所示:

  1. 第一阶段涉及由经验丰富的工程师手动执行命令。虽然这本身不是自动化,但它启动了一个以某种形式的自动化结束的循环。

  2. 第二阶段通常涉及将这些步骤记录下来,以便文档化过程,允许多人分担工作。这可能是一个 git 仓库。

  3. 第三阶段通常是编写脚本使任务可重复执行。

一旦公司变得更大,这些阶段通常会合并为开发一个服务,在识别到需要时以完全自动化的方式处理任务。

一个很好的例子可能是在 Kubernetes 集群上部署 pods 或向 Kubernetes 配置中添加新的 pod 配置。这些操作是通过调用命令行应用程序如 kubectlgit 来驱动的。这些类型的工作一开始是手动的;最终,它们会被文档化,并最终以某种方式实现自动化。某个时候,这可能会转移到 持续集成/持续部署 (CI/CD) 系统中,由其为你处理这些任务。

在本地自动化工具的关键是 os/exec 包。该包允许执行其他工具并控制它们的 STDIN / STDOUT / STDERR 流。

让我们更仔细地看一下。

确定必需工具的可用性

在编写调用系统上其他应用程序的应用程序时,关键是要在开始执行命令之前,确定所需的工具是否在系统中可用。没有什么比在执行过程中发现缺少关键工具更糟糕的了。

exec 包提供了 LookPath() 函数来帮助确定一个二进制文件是否存在。如果只提供了二进制文件的名称,则会查阅 PATH 环境变量,并在这些路径中搜索该二进制文件。如果名称中包含 /,则仅查阅该路径。

假设我们正在编写一个工具,需要安装 kubectlgit 才能正常工作。我们可以通过执行以下代码来测试这些工具是否在我们的 PATH 变量中可用:

const (
    kubectl = "kubectl"
    git = "git"
)
_, err := exec.LookPath(kubectl)
if err != nil {
    return fmt.Errorf("cannot find kubectl in our PATH")
}
_, err := exec.LookPath(git)
if err != nil {
    return fmt.Errorf("cannot find git in our PATH")
}

这段代码执行以下操作:

  • 为我们的二进制文件名称定义常量

  • 使用 LookPath() 来判断这些二进制文件是否存在于我们的 PATH 变量中

在这段代码中,如果我们找不到工具,就直接返回一个错误。还有其他选择,比如尝试通过本地包管理器安装这些工具。根据我们的环境配置,我们可能希望测试部署的版本,并且仅在版本兼容时才继续。

我们来看一下如何使用 exec.CommandContext 类型来调用二进制文件。

使用 exec 包执行二进制文件

exec 包允许我们使用 exec.Cmd 类型执行二进制文件。要创建其中一个,我们可以使用 exec.CommandContext() 构造函数。它接收要执行的二进制文件名称和传递给二进制文件的参数,如下面的代码片段所示:

cmd := exec.CommandContext(ctx, kubectl, "apply", "-f", config)

这创建了一个命令,将运行 kubectl 工具的 apply 函数,并指示它应用存储在 config 变量中的路径上的配置。

这个命令的语法是不是很熟悉?它应该是!kubectl 是使用我们上一章介绍的 Cobra 编写的!

我们可以通过多种方法执行 cmd 上的这个命令,如下所示:

  • .CombinedOutput():运行命令并返回 STDOUTSTDERR 的合并输出。

  • .Output():运行命令并返回 STDOUT 的输出。

  • .Run():运行程序并等待其退出。若有问题,则返回错误。

  • .Start():运行命令但不阻塞。用于你希望在命令运行时与其交互的场景。

.CombinedOuput().Output() 是启动程序最常见的方法。用户在终端中看到的输出通常来自 STDOUTSTDERR。选择使用哪一个取决于你希望如何响应程序的输出。

.Run() 用于当你只需要知道退出状态而不需要任何输出时。

使用 .Start() 有两个主要原因,如下所述:

  • 需要在 STDIN 上对 STDOUT 的输出做出响应。

  • 程序执行需要一段时间,你希望将其内容输出到屏幕上,而不是等待程序完成。

如果你需要对程序的输出在 STDIN 上做出响应,使用 Google 的 goexpect 包(github.com/google/goexpect)或 Netflix 的 go-expect 包(github.com/Netflix/go-expect)可能是更好的选择。这些包延续了工具命令语言TCL)Expect 扩展的光荣传统(en.wikipedia.org/wiki/Expect),并将其移植到其他语言中。

让我们编写一个简单的程序,测试我们在子网内登录主机的能力。我们将使用ping工具和ssh客户端程序来测试连通性。我们将依赖你的主机识别你的 SSH 密钥(这里不使用密码认证,因为那更复杂)。最后,我们将在远程机器上使用uname来确定操作系统。代码在以下片段中有所展示:

func hostAlive(ctx context.Context, host net.IP) bool {
        cmd := exec.CommandContext(ctx, ping, "-c", "1", "-t", "2", host.String())
        if err := cmd.Run(); err != nil {
            return false
        }
        return true
}

注意

uname是一个在类 Unix 系统中可用的程序,用于显示当前操作系统及其运行硬件的信息。只有 Linux 和 Darwin 机器可能拥有uname。由于 SSH 只是一个连接协议,我们可能会得到一个错误。此外,某些 Linux 发行版可能没有安装uname。不同版本的常见工具在类似平台上可能有细微差别。Linux 的ping和 OS X 的ping工具共享一些标志,但也有不同的标志。Windows 通常有完全不同的工具来完成相同的任务。如果你想通过一个使用exec的工具支持所有平台,你需要使用构建约束(pkg.go.dev/cmd/go#hdr-Build_constraints)或使用runtime包来在不同的平台上运行不同的工具。

这段代码执行了以下操作:

  • 创建一个*Cmd来对主机进行 ping 操作

    • -c 1发送一个单独的-t 2会在 2 秒后超时。
  • 运行命令

    • 如果出错,则说明 ping 操作失败。

    • 否则,主机响应了 ping 请求。

现在让我们使用ssh工具向远程机器发送一个命令,如下所示:

func runUname(ctx context.Context, host net.IP, user string) 
(string, error) {
        if _, ok := ctx.Deadline(); !ok {
                var cancel context.CancelFunc
                ctx, cancel = context.WithTimeout(ctx, 5*time.Second)
                defer cancel()
        }
        login := fmt.Sprintf("%s@%s", user, host)
        cmd := exec.CommandContext(
                ctx,
                ssh,
                "-o StrictHostKeyChecking=no",
                "-o BatchMode=yes",
                login,
                "uname -a",
        )
        out, err := cmd.CombinedOutput()
        if err != nil {
                return "", err
        }
        return string(out), nil
}

这段代码执行了以下操作:

  • 如果ctx没有设置,设置 5 秒的超时

  • 创建一个user@host的登录行

  • 创建一个*CMD,发出命令:ssh user@hostuname -a

    • StrictHostKeyChecking选项会自动添加主机密钥。

    • BatchMode选项防止提示输入密码。

  • 运行命令并捕获来自STDOUT的输出

    • 如果成功,它会运行uname -a并返回输出。

    • 主机必须拥有用户的 SSH 密钥才能正常工作。

      • 密码认证需要sshpass工具或 Expect 包。

我们需要一个类型来存储我们收集的数据。让我们创建它,如下所示:

type record struct{
    Host net.IP
    Reachable bool
    LoginSSH bool
    Uname string
}

现在,我们需要一些代码来接收一个包含互联网协议IP)地址的通道,这些地址需要扫描。我们希望并行执行,因此我们将使用 goroutines,如下片段所示:

func scanPrefixes(ipCh chan net.IP) chan record {
        ch := make(chan record, 1)
        go func() {
                defer close(ch)
                limit := make(chan struct{}, 100)
                wg := sync.WaitGroup{}
                for ip := range ipCh {
                        limit <- struct{}{}
                        wg.Add(1)
                        go func(ip net.IP) {
                                defer func() { <-limit }()
                                defer wg.Done()
                                ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second))
                                defer cancel()
                                rec := record{Host: ip}
                                if hostAlive(ctx, ip) {
                                        rec.Reachable = true
                                }
                                ch <- rec
                        }(ip)
                }
                wg.Wait()
        }()
        return ch
}

这段代码执行了以下操作:

  • 接收一个net.IP类型的通道

  • 创建一个通道来放置记录

  • 启动一个 goroutine 来执行所有扫描操作

    • 延迟关闭我们的输出通道

    • 遍历所有进入通道的 IP 地址

    • 使用limit通道限制最多并发 100 个 ping 操作

    • 为每个 ping 操作启动一个 goroutine

      • 完成后减少限流器的数量

      • 为我们的 ping 操作设置 2 秒的超时时间

      • 调用我们的hostAlive()函数

      • 将结果输出到我们的ch输出通道

    • 等待所有的 ping 命令完成,使用WaitGroup

  • 返回通道

我们现在有了一个异步并行 ping 主机并将结果放入通道的函数。

我们的ssh函数的函数签名与scanPrefixes相似,如下所示:

func unamePrefixes(user string, recs chan record) chan record

为了简洁起见,我们不会在这里包含代码,但你可以在练习结束时提供的代码库中查看它。

这些是scanPrefixes()unamePrefixes()之间的主要区别:

  • 我们接收到一个record的通道,这是scanPrefixes()的输出。

  • 如果rec.Reachablefalse,我们会将rec直接放入输出通道,而不将操作系统信息添加到字段中。

  • 否则,我们调用runUname()而不是hostAlive()

现在,让我们设置main()函数,具体如下:

func main() {
    _, err := exec.LookPath(ping)
    if err != nil {
        log.Fatal("cannot find ping in our PATH")
    }
    _, err := exec.LookPath(ssh)
    if err != nil {
        log.Fatal("cannot find ssh in our PATH")
    }
    if len(os.Args) != 2 {
        log.Fatal("error: only one argument allowed, the network CIDR to scan")
    }
    ipCh, err := hosts(os.Args[1])
    if err != nil {
            log.Fatalf("error: CIDR address did not parse: %s", err)
    }
    u, err := user.Current()
    if err != nil {
        log.Fatal(err)
    }

这段代码做了以下几件事:

  • 检查我们的二进制文件是否存在于路径中

  • 检查我们是否有正确数量的参数,即1

    • 我们检查len(os.Args) == 2,因为第一个参数是二进制文件名。
  • 检索传递给参数的网络中 IP 的通道

    • hosts()函数的实现没有在这里详细说明,但你可以在代码库中找到它。
  • 获取当前用户的登录名

现在,我们需要扫描我们的前缀并通过进行登录并获取uname输出来并行处理结果,具体如下:

    scanResults := scanPrefixes(ipCh)
    unameResults := unamePrefixes(u.Username, scanResults)
    for rec := range unameResults {
        b, _ := json.Marshal(rec)
        fmt.Printf("%s\n", b)
    }
}

这段代码做了以下几件事:

  • 发送 IP 的通道到scanPrefixes()

  • scanResults上接收结果

  • 将结果通道发送到unamePrefixes()

  • 打印STDOUT

这段代码的关键在于在scanPrefixes()unamePrefixes()中的for range循环中读取通道。当所有的 IP 都已发送,ipCh将被关闭。那将停止scanPrefixes()中的for range循环,进而关闭它的输出通道。这会导致unamePrefixes看到关闭并关闭它的输出通道。这又会关闭for rec := range unameResults循环并停止打印。

使用这种链式并发模型,我们将同时扫描最多 100 个 IP,通过 SSH 连接最多 100 个主机,并将结果同时打印到屏幕上。

我们已经将uname -a的输出存储在我们的record变量中,但它是未经解析的格式。我们可以使用词法分析器/解析器或struct。如果你需要使用执行的二进制文件的输出,建议寻找可以输出结构化格式(如 JSON)的工具,而不是自己进行解析。

你可以在以下链接中查看这段代码:

github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/8/scanner

使用 exec 包的注意事项

使用exec时需要注意一些问题。一个主要的问题是,如果被调用的二进制文件控制了终端。例如,ssh会这么做,从用户那里获取密码。我们在示例中抑制了这一行为,但发生这种情况时,它会绕过你正在读取的正常 STDOUT。

这种情况发生在有人使用终端模式时。在这些情况下,如果必须处理这种情况,你将需要使用 goexpectgo-expect。通常来说,这是一个你希望找到替代方案的地方。然而,一些软件和各种路由设备会实现基于菜单的系统,并使用无法避免的终端模式。

在本节中,我们讨论了如何使用 exec 包自动化命令行。现在你已经掌握了检查系统中二进制文件并执行这些二进制文件的技能。你可以检查错误条件并获取输出。

在下一节中,我们将讨论 Go 中 SSH 的基础知识。虽然在本节中,我们展示了如何使用 ssh 二进制文件,接下来我们将讨论如何使用 ssh 包来使用 SSH 而不依赖 SSH 库。这种方法更快,并且相较于调用二进制文件,具有一定的优势。

注意

一般来说,始终使用包而不是二进制文件,特别是在有可用包的情况下。这可以保持系统依赖性较低,并使代码更具可移植性。

使用 Go 中的 SSH 自动化远程更改

SSH 只是一个网络协议,可用于保障两台主机之间的通信安全。

虽然大多数人认为 ssh 二进制文件允许你从一个主机的终端连接到另一个主机的终端,但这只是其中的一种用法。SSH 还可以用于保护如Google 远程过程调用gRPC)这样的服务的连接,或者用于隧道化图形界面,如X 窗口系统X11)。

在本节中,我们将讨论如何使用 SSH 包(pkg.go.dev/golang.org/x/crypto/ssh)来创建客户端和服务器。

连接到另一台系统

SSH 的最基本用法是连接到另一台系统,并发送一个命令或调用一个 shell 并执行命令。SSH 只是一个传输机制,因此 SSH 还有很多其他用途,如连接隧道或封装远程过程调用RPCs)。我们不会在这里讨论这些内容,因为它们超出了常规 DevOps 工作的使用范围。

和大多数连接技术一样,使用 SSH 客户端连接系统时,最难的部分是解决身份验证问题。最常见的 SSH 身份验证方式在这里进行了概述:

  • 用户名/密码:用户名/密码是最常见的实现方式。它是默认选项,因此人们往往会选择使用它。在网络设备中,有时这是唯一的方式。使用此方法时,密码数据库可能存储在本地系统中,或者系统会将密码哈希传递到另一个系统进行验证。

  • 公钥认证:公钥认证是用户在自己的机器上创建一对公钥/私钥,并可以选择设置密码短语。服务器为用户安装了公钥,而你的 SSH 客户端则配置为使用私钥。

  • 挑战-响应认证:SSH 有多种类型的挑战-响应认证。这通常用于通过设备(如 Yubikey)实现二因素认证2FA)。

我们将专注于使用前两种方法,并假设远程端会使用 OpenSSH。虽然安装应该转向使用二次身份验证(2FA),但这个设置超出了我们这里的讨论范围。

我们将使用 Go 的优秀 SSH 包:golang.org/x/crypto/ssh

首先需要做的是设置我们的认证方式。我这里将展示的初始方法是使用用户名/密码,如下所示:

auth := ssh.Password("password")

这已经足够简单了。

注意

如果你正在编写一个命令行应用程序,使用标志或参数来获取密码是不安全的。你也不希望将密码回显到屏幕上。密码应该来自一个只有当前用户可以访问的文件,或者通过控制终端。SSH 包有一个终端包(golang.org/x/crypto/ssh/terminal),它可以提供帮助:

fmt.Printf("SSH 密码: ")

password, err := terminal.ReadPassword(int(os.Stdin.Fd()))

对于公钥,稍微复杂一点,如下所示:

func publicKey(privateKeyFile string) (ssh.AuthMethod, error) {
    k, err := os.ReadFile(privateKeyFile)
    if err != nil {
            return nil, err
    }
    signer, err := ssh.ParsePrivateKey(k)
    if err != nil {
            return nil, err
    }
    return ssh.PublicKeys(signer), nil
}

这段代码执行以下操作:

  • 读取我们的私钥文件

  • 解析我们的私钥

  • 返回一个公钥授权实现的ssh.AuthMethod

现在,我们只需将我们的私钥提供给程序即可进行授权。许多时候,你的密钥并不是本地存储的,而是存储在云服务中,如 Microsoft Azure 的 Key Vault。在这种情况下,你只需更改os.ReadFile()以使用云服务。

既然我们的授权已经解决了,接下来让我们创建一个 SSH 配置,如下所示:

config := &ssh.ClientConfig {
    User: user,
    Auth: []ssh.AuthMethod{auth},
    HostKeyCallback: ssh.InsecureIgnoreHostKey(),
    Timeout: 5 * time.Second,
}

这段代码执行以下操作:

  • 创建一个新的*ssh.ClientConfig配置

    • 使用存储在user变量中的用户名

    • 提供一个AuthMethod,但你可以使用多个AuthMethod(s)

    • 忽略主机密钥

      • 设置 5 秒的拨号超时

      重要提示

      使用ssh.InsecureIgnoreHostKey()来忽略主机密钥是不安全的。这可能导致你错误地将信息发送到一个你无法控制的系统。这个系统可能伪装成你的一台设备,试图让你在终端中输入某些内容,比如密码。在生产环境中,至关重要的是不要忽略主机密钥,并存储一个有效的主机密钥列表以供验证。

让我们连接到主机,如下所示:

conn, err := ssh.Dial("tcp", host, config)
if err != nil {
    fmt.Println("Error: could not dial host: ", err)
    os.Exit(1)
}
defer conn.Close()

现在我们已经建立了一个 SSH 连接,接下来让我们创建一个函数来运行一个简单的命令,如下所示:

func combinedOutput(conn *ssh.Client, cmd string) (string, error) {
    sess, err := conn.NewSession()
    if err != nil {
            return "", err
    }
    defer sess.Close()
    b, err := sess.Output(cmd)
    if err != nil {
            return "", err
    }
    return string(b), nil
}

这段代码执行以下操作:

  • 创建一个 SSH 会话

    • 每个命令需要一个会话
  • 在会话中运行命令并返回输出

    • 这会将 STDOUT 和 STDERR 合并为一个输出

此代码将允许您针对使用 OpenSSH 或类似 SSH 实现的系统发出命令。最佳实践是在为设备发出所有命令之前保持conn对象的打开状态。

您可以在此处查看此代码:

github.com/PacktPublishing/Go-for-DevOps/blob/rev0/chapter/8/ssh/client/remotecmd/remotecmd.go

在可以简单地向远端发出命令并让其运行的情况下非常有用。但是如果程序需要一定程度的交互怎么办?在通过 SSH 与路由平台交互时,通常需要更多的交互。

当这种需求出现时,Expect 库可以提供帮助。接下来,让我们看看其中一个比较流行的库。

用于复杂交互的 Expect

expect包提供处理命令输出的能力,例如以下内容:would you like to continue[y/n]

使用expect的最流行的包来自 Google。您可以在这里找到:github.com/google/goexpect

这是一个expect脚本示例,用于在 Ubuntu 主机上使用高级包装工具APT)包管理器安装原始的 TCL expect工具。请注意,这不是最佳实践,只是一个简单的示例。

让我们首先配置我们的expect客户端以使用 SSH 客户端,如下所示:

config := &ssh.ClientConfig {
    User:            user,
    Auth:            []ssh.AuthMethod{auth},
    HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}
conn, err := ssh.Dial("tcp", host, config)
if err != nil {
    return err
}
e, _, err := expect.SpawnSSH(conn, 5 * time.Second)
if err != nil {
    return err
}
defer e.Close()

此代码执行以下操作:

  • 设置一个*ssh.ClientConfig配置

  • 使用它建立连接

  • 将连接传递给expect客户端

现在我们已经通过 SSH 登录了一个expect客户端,请确保我们有一个提示符,如下所示:

var (
    promptRE = regexp.MustCompile(`\$ `)
    aptCont = regexp.MustCompile(`Do you want to continue\? \[Y/n\] `)
    aptAtNewest = regexp.MustCompile(`is already the newest`)
)
_, _, err = e.Expect(promptRE, 10*time.Second)
if err != nil {
        return fmt.Errorf("did not get shell prompt")
}

此代码执行以下操作:

  • 编译$正则表达式以期望我们的提示符

  • 调用Expect()等待最多 10 秒的提示符

现在,让我们发送我们的命令通过apt-get工具安装expect。我们将使用sudo以 root 权限执行此命令。代码如下所示:

if err := e.Send("sudo apt-get install expect\n"); err != nil {
        return fmt.Errorf("error on send command: %s", err)
}

apt-get将提示我们是否可以安装或告诉我们它已经安装。让我们处理这两种情况,如下所示:

f _, _, ecase, err := e.ExpectSwitchCase(
    []expect.Caser{
            &expect.Case{
                    R: aptCont,
                    T: expect.OK(),
            },
            &expect.Case{
                    R: aptAtNewest,
                    T: expect.OK(),
            },
    },
    10*time.Second,
)
if err != nil {
        return fmt.Errorf("apt-get install did not send what we expected")
}

此代码执行以下操作:

  • 等待显示以下内容之一:

    • Do you want to continue\? [Y/n]

    • is already the newest

  • 如果两者都没有发生,它将给出一个错误

  • ecase将包含详细说明发生的条件的case类型

如果我们得到继续提示,我们需要发送Y到终端,执行以下代码:

switch ecase{
case 0:
        if err := e.Send("Y\n"); err != nil {
                return err
        }
}

最后,我们只需确保通过执行以下代码再次收到提示:

_, _, err = e.Expect(promptRE, 10*time.Second)
if err != nil {
        return fmt.Errorf("did not get shell prompt")
}
return nil

您可以在调试模式下查看此代码:

github.com/PacktPublishing/Go-for-DevOps/blob/rev0/chapter/8/ssh/client/expect/expect.go

本节展示了如何在纯 Go 中启动一个 SSH 会话,使用它发送命令,然后获取输出。最后,我们还探讨了如何使用 goexpect 与应用程序进行交互。

现在,我们将展示如何利用这些知识编写工具,以便在多个系统上运行命令。

设计安全的并发变更自动化

到目前为止,我们已经展示了如何在本地或远程执行命令。

在现代,我们经常需要在多个系统上运行一组命令,以实现某个最终状态。根据规模的不同,你可能希望运行诸如 Ansible 或 Jenkins 这样的系统来尝试自动化这些过程。

对于某些工作,直接使用 Go 在一组系统上执行更改会更简单。这使得 DevOps 团队只需理解 Go 语言和少量代码,而无需理解像 Ansible 这样的工作流系统的复杂性,后者需要自己的技能集、系统更新等。

在本节中,我们将讨论如何更改一组系统的组成部分,达成这一目标的框架,以及一个示例应用程序来应用一组更改。

更改的组成部分

在编写一个进行更改的系统时,必须处理几种类型的操作。广义上来说,我将它们定义为以下几种:

  • 全局前提条件:全局前提条件是一组必须为真的条件才能继续前进。在进行网络自动化时,这可能是网络丢包率低于某个阈值。对于设备来说,这可能意味着在继续操作之前,服务处于绿色状态。没有人愿意在出现问题时推送更改。

  • 本地前提条件:本地前提条件是指单个工作单元(例如服务器)必须处于某种状态才能继续。

  • 操作:操作是将改变工作单元状态的操作。

  • 操作验证:用于验证操作是否成功的检查。

  • 本地后置条件:本地后置条件是检查工作单元是否处于所需的配置状态并满足某些条件。这可能是它仍然可达,可能正在处理流量或没有处理流量,无论最终状态应该是什么。

  • 全局后置条件:全局后置条件是在执行后条件的状态,通常类似于全局前提条件。

并非每一组跨多个系统的更改都需要这些所有条件,但至少需要其中的一部分。

让我们来看一下如何在单一数据中心的一组 虚拟机 (VMs) 上进行作业的部署。对于机器数量有限的小型公司来说,当你没有足够大到可以使用像 Kubernetes 这样的工具,但又无法满足像 Azure Functions 或亚马逊的 弹性容器服务 (ECS) 的限制时,这样的设置可能就足够了。或者,也可能是你在自己的机器上运行,而不是使用云服务提供商。

编写一个并发任务

让我们来处理我们想要执行的操作。我们想要做以下操作:

  • 从负载均衡器中移除我们的任务

  • 杀死虚拟机或服务器上的任务

  • 将新的软件复制到服务器

  • 启动我们的服务

  • 检查服务是否可达

  • 将任务重新添加到负载均衡器

从本质上讲,这正是 Kubernetes 在大规模微服务安装中的作用。我们将在即将到来的章节中讨论这一点。但在小规模应用中,即使基础设施由云服务提供商管理,运行 Kubernetes 集群的复杂性通常也不是最好的选择。

让我们定义执行我们操作的代码的总体结构,如下所示:

type stateFn func(ctx context.Context) (stateFn, error)
type actions struct {
    ... // Some set of attributes
}
func (s *actions) run(ctx context.Context) (err error) {
    fn := s.rmBackend
    if s.failedState != nil {
        fn = s.failedState
    }
    s.started = true
    for {
        if ctx.Err() != nil {
            s.err = ctx.Err()
            return ctx.Err()
        }
        fn, err = fn(ctx)
        if err != nil {
            s.failedState = fn
            s.err = err
            return err
        }
        if fn == nil {
            return nil
        }
    }
}
func (a *actions) rmBackend(ctx context.Context) (stateFn, error) {...}
func (a *actions) jobKill(ctx context.Context) (stateFn, error) {...}
func (a *actions) cp(ctx context.Context) (stateFn, error) {...}
func (a *actions) jobStart(ctx context.Context) (stateFn, error) {...}
func (a *actions) reachable(ctx context.Context) (stateFn, error) {...}
func (a *actions) addBackend(ctx context.Context) (stateFn, error) {...}

注意

这一部分大多是骨架代码—我们稍后将实现这些方法。

这段代码执行以下操作:

  • 定义一个stateFn类型

    • 如果返回错误,停止处理。

    • 如果没有并且返回一个非空的stateFn类型,执行它。

    • 如果返回一个空的stateFn类型并且没有错误,我们就完成了。

  • 定义一个actions类型

    • 这是一个用于服务器操作的状态机

    • 调用run()会执行以下操作:

      • 一次执行一个stateFn类型,直到出现错误或stateFn == nil
    • rmBackend()jobKill()cp()以及其他将定义的都是stateFn类型。

    • .failedState用于允许在多次调用.run()时重试失败的状态。

我们有一个简单的状态机,将执行操作。这将使我们完成系统上执行此类操作所需的所有状态。

让我们看看在实现时,几个stateFn类型会是什么样子,如下所示:

func (a *actions) rmBackend(ctx context.Context) (stateFn, error) {
    err := a.lb.RemoveBackend(ctx, a.config.Pattern, a.backend)
    if err != nil {
        return nil, fmt.Errorf("problem removing backend from pool: %w", err)
    }
    return a.jobKill, nil
}

这段代码执行以下操作:

  • 调用客户端的网络负载均衡器以移除我们的服务器端点

  • 如果成功,返回jobKill作为下一个要执行的状态

  • 如果不成功,返回我们的错误

s.lb.RemoveBackend()在云端可能会调用REST服务,通知它移除我们的服务端点。或者,在你自己的数据中心,它可能是一个网络负载均衡器,你通过 SSH 客户端登录并发出命令。

一旦完成,它会告诉run()执行jobKill()。让我们看看实现后的样子,如下所示:

func (a *actions) jobKill(ctx context.Context) (stateFn, error) {
    pids, err := a.findPIDs(ctx)
    if err != nil {
        return nil, fmt.Errorf("problem finding existing PIDs: %w", err)
    }
    if len(pids) == 0 {
        return a.cp, nil
    }
    if err := a.killPIDs(ctx, pids, 15); err != nil {
        return nil, fmt.Errorf("failed to kill existing PIDs: %w", err)
    }
    if err := a.waitForDeath(ctx, pids, 30*time.Second); err != nil {
        if err := a.killPIDs(ctx, pids, 9); err != nil {
            return nil, fmt.Errorf("failed to kill existing PIDs: %w", err)
        }
        if err := a.waitForDeath(ctx, pids, 10*time.Second); err != nil {
            return nil, fmt.Errorf("failed to kill existing PIDs after -9: %w", err)
        }
        return a.cp, nil
    }
    return a.cp, nil
}

这段代码执行以下操作:

  • 执行findPIDs()函数

    • 这通过 SSH 登录到一台机器并运行pidof二进制文件
  • 执行killPIDs()函数

    • 这使用 SSH 执行kill命令来终止我们的进程

    • 使用信号 15 或TERM作为软终止

  • 执行waitForDeath()函数

    • 这使用 SSH 等待cp操作

    • 如果没有,执行带信号 9 或KILLkillPIDs(),并再次执行waitForDeath()函数

    • 如果失败,返回一个错误

    • 如果成功,我们返回下一个状态,cp

这段代码实际上是在我们复制新的二进制文件并启动它之前,先杀死服务器上的任务。

其余的代码将在我们的代码库中(稍后将在本节提供链接)。现在,假设我们已经为我们的状态机编写了其余的操作。

现在我们需要执行所有操作。我们将创建一个具有基本结构的workflow结构体:

type workflow struct {
    config *config
    lb     *client.Client
    failures int32
    endState endState
    actions []*actions
}

这段代码执行以下操作:

  • *config,将详细描述我们的发布设置

  • 创建与负载均衡器的连接

  • 跟踪我们遇到的失败次数

  • 输出最终的结束状态,这是文件中的一个枚举值

  • 创建所有操作的列表

一个典型的发布过程有两个阶段,如下所示:

  • 金丝雀:金丝雀阶段是测试少量样本,以确保发布过程正常工作。在此阶段,您需要一次测试一个样本,并在继续下一个金丝雀测试之前等待一段时间。这为管理员提供了时间,以防发布过程未能检测到潜在问题。

  • 一般发布:一般发布发生在金丝雀阶段之后。通常会设置一定的并发数和最大失败次数。根据环境的大小,失败可能很常见,因为环境在不断变化。这可能意味着您会容忍一定数量的失败,并继续重试这些失败,直到成功,但如果失败次数达到某个最大值,则停止。

    注意

    根据环境的不同,您可以使用更复杂的部署方案,但对于较小的环境,这通常已经足够。在进行并发发布时,失败的次数可能会超过您的最大失败设置,这取决于设置的具体情况。如果我们设置了最大失败次数,并且并发数设置为 5,那么可能会发生 5 到 9 次的失败。在处理并发发布时,请记住这一点。

处理发布过程的工作流中的主要方法叫做run()。它的任务是运行我们的前置检查,然后运行我们的金丝雀测试,最后以某种并发级别运行主要任务。如果问题太多,我们应该退出。我们来看一下,具体如下:

func (w *workflow) run(ctx context.Context) error {
    preCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
    if err := w.checkLBState(preCtx); err != nil {
        w.endState = esPreconditionFailure
        return fmt.Errorf("checkLBState precondition fail: %s", err)
    }
    cancel()

这部分代码执行以下操作:

  • 运行我们的checkLBState()前置条件代码

  • 如果失败,记录一个esPreconditionFailure结束状态

    注意

    您可能会注意到在创建带有超时的Context对象时,会创建一个cancel()函数。这个函数可以在任何时候取消我们的Context对象。最佳实践是在使用后立即取消带有超时的Context对象,以退出正在后台运行并倒计时到超时的 Go 例程。

这是在我们对系统进行任何更改之前运行的。我们不希望在系统已经不健康时进行更改。

接下来,我们需要运行我们的金丝雀测试,如下所示:

for i := 0; i < len(w.actions) && 
int32(i) < w.config.CanaryNum; i++ {
    color.Green("Running canary on: %s", w.actions[i].endpoint)
    ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
    err := w.actions[i].run(ctx)
    cancel()
    if err != nil {
        w.endState = esCanaryFailure
        return fmt.Errorf("canary failure on endpoint(%s): %w\n", w.actions[i].endpoint, err)
    }
    color.Yellow("Sleeping after canary for 1 minutes")
    time.Sleep(1 * time.Minute)
}

这段代码执行以下操作:

  • 运行若干个金丝雀测试

  • 一次执行一个操作

  • 每次等待 1 分钟

这些设置将在定义的配置文件中进行配置。休眠时间可以根据服务的需求进行配置,以便在工作流未检测到问题时能作出响应。您甚至可以定义在所有金丝雀测试和一般发布之间的休眠时间。

现在,我们需要在一定的并发水平下进行发布,同时检查失败的最大数量。让我们按照以下方式查看这一点:

limit := make(chan struct{}, w.config.Concurrency)
wg := sync.WaitGroup{}
for i := w.config.CanaryNum; int(i) < len(w.actions); i++ {
    i := i
    limit <- struct{}{}
    if atomic.LoadInt32(&w.failures) > w.config.MaxFailures {
        break
    }
    wg.Add(1)
    go func() {
        defer func(){<-limit}()
        defer wg.Done()
        ctx, cancel := context.WithTimeout(ctx, 10*time.Minute)
        color.Green("Upgrading endpoint: %s", 
w.actions[i]. endpoint)
        err := w.actions[i].run(ctx)
        cancel()
        if err != nil {
            color.Red("Endpoint(%s) had upgrade error: %s", w.actions[i].endpoint, err)
            atomic.AddInt32(&w.failures, 1)
        }
    }()
}
wg.Wait()

这段代码完成了以下操作:

  • 启动运行我们操作的 goroutine。

  • 并发通过我们的 limit 通道进行限制。

  • 失败情况由我们的 .failures 属性检查进行限制。

这是我们第一次展示 atomic 包。atomicsync 的一个子包,它允许我们在不使用 sync.Mutex 的情况下进行线程安全的数字操作。这对于计数器非常有用,因为它为这种特定类型的操作提供了类似 sync.Mutex 的功能。

我们现在展示了 workflow 结构体的 .run() 基本用法。您可以在 github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/8/rollout 找到这个版本应用的完整代码。

这个应用的代码只需要您的 SSH 密钥、描述发布的文件和要发布到服务器的二进制文件。该文件看起来应该是这样的:

{
    "Concurrency": 2,
    "CanaryNum": 1,
    "MaxFailures": 2,
    "Src": "/home/[user]/rollout/webserver",
    "Dst": "/home/[user]/webserver",
    "LB": "10.0.0.4:8081",
    "Pattern": "/",
    "Backends": [
            "10.0.0.5",
            "10.0.0.6",
            "10.0.0.7",
            "10.0.0.8",
            "10.0.0.9"
    ],
    "BackendUser": "azureuser",
    "BinaryPort": 8082
}

这描述了应用程序进行简单发布所需做的一切。

当然,我们可以使这个应用更具通用性,让它记录运行状态和最终状态到存储中,添加标志来忽略初始状态,以便我们可以进行回滚,将其放到 gRPC 服务后面,等等……

在不到 1,000 行代码的情况下,我们提供了一个简单的替代方案,用于当 Kubernetes 等系统不可用或您的规模不足以支持它们时的选择。

注意

这并没有解决程序崩溃时需要重新启动二进制文件的问题,比如通过 systemd 等软件实现的重启。在这种情况下,最好是创建一个代理程序,运行在设备上并提供 RPC 来控制本地服务,比如 systemd

案例研究——网络发布

这里阐述的原则已经成为谷歌 B2 骨干网络上网络设备配置发布的核心,已有十年之久。

在此之前,我们仅仅是用脚本处理手工配置或生成的配置,将它们应用到网络上,同时操作员观察进度并处理可能出现的问题。

在大规模应用中,这成为了一个问题。SRE 服务团队开始远离类似的模型,因为它们的复杂性往往比网络增长得更快。

网络工程逐渐转向一个更为正式化的系统,以集中执行骨干网中的工作,为我们提供一个监控的地方,并在紧急情况下有一个中央位置来停止发布操作。

此外,还需要对所有发布操作进行正式化,以确保它们总是以相同的方式执行,并且具有相同的自动化检查,而不是依赖人工来做正确的操作。

我主导设计和实现的编排系统,实际上是一个更复杂且可插拔的版本,类似于这里所展示的内容。各个团队将它们的操作集成到系统中,而该系统则根据传递的参数执行这些操作,完成一系列任务。

在我离开 Google 时,采用这种方法已经实现了自动化零故障(这与零发布失败不同)。据我了解,当我写这篇文章时,你的猫咪视频仍然在这个系统上得到安全保存。

在本节中,我们了解了变更的组件以及使用 Go 实现这些变更的方式,并编写了一个示例发布应用程序,应用了这些原则。

接下来,我们将讨论编写一个系统代理,该代理可以部署在系统上,从而允许进行系统监控到控制本地发布的所有操作。

编写系统代理

到目前为止,当我们在设备上进行自动化操作时,我们要么是在本地执行的应用程序中做,要么是通过 SSH 远程运行命令。

但如果我们考虑管理一小部分机器集群,编写一个在设备上运行的服务,通过 RPC 连接进行控制可能会更为实际。利用我们在前面章节中讨论的 gRPC 服务知识,我们可以将这些概念结合起来,以更统一的方式控制我们的机器。

以下是我们可以使用系统代理的一些用途:

  • 安装和运行服务

  • 收集机器运行状态

  • 收集机器库存信息

其中一些是 Kubernetes 使用其系统代理所做的事情。其他的,比如库存信息,对于运行健康的机器集群至关重要,尤其是在较小的环境中经常被忽视。即使在 Kubernetes 环境中,为某些任务运行自己的代理也可能带来优势。

系统代理可以提供多个优势。如果我们使用 gRPC 定义一个 应用程序编程接口 (API),我们可以让多个操作系统和不同的代理实现相同的 RPC,从而以统一的方式控制我们的机器集群,而不管操作系统是什么。而且因为 Go 几乎可以在任何平台上运行,你可以使用相同的语言编写不同的代理。

设计系统代理

对于我们的示例系统代理,我们将特别针对 Linux,但我们会使我们的 API 通用,以便其他操作系统也能实现相同的 API。我们来谈谈一些可能感兴趣的内容。我们可以考虑以下内容:

  • 使用 systemd 安装/移除二进制文件

  • 导出系统和已安装二进制文件的性能数据

  • 允许拉取应用程序日志

  • 将我们的应用程序容器化

对于不熟悉 systemd 的朋友,它是一个在后台运行软件服务的 Linux 守护进程。利用 systemd 可以实现应用程序失败后的自动重启,并通过 journald 实现日志轮转。

容器化,简单来说,是在一个自包含的空间内执行应用程序,只访问你希望其访问的操作系统部分。这与所谓的沙盒化(sandboxing)概念相似。容器化已经被 Docker 等软件所流行,并且催生了类似虚拟机的容器格式,这些容器内包含了整个操作系统镜像。然而,要在 Linux 上容器化一个应用程序,并不需要这些容器格式和工具。

由于我们将使用systemd来控制进程执行,我们将使用systemdService指令来提供容器化。这些细节可以在我们的代码库中的文件github.com/PacktPublishing/Go-for-DevOps/blob/rev0/chapter/8/agent/internal/service/unit_file.go中查看。

为了导出统计数据,我们将使用expvar Go 标准库包。这个包允许我们发布统计数据,expvar的统计数据是一个 JSON 对象,具有映射到代表我们的统计信息或数据的值的字符串键。系统内置的统计数据将自动提供,同时我们也会定义一些新的统计数据。

这使得你可以通过收集器或简单地使用网页浏览器或命令行工具(如wget)快速收集统计数据。

输出的一个expvar页面可能返回以下内容:

{
    "cmdline": ["/tmp/go-build7781/c0021/exe/main"],
    "cpu": "8",
    "goroutines": "16",
}

在我们示例中的书籍部分,我们将重点介绍安装和移除二进制文件导出系统性能数据,以展示我们如何使用 RPC 服务进行交互调用,以及使用 HTTP 获取只读信息。我们代码库中的版本将实现比书中所能涵盖的更多功能。

现在我们已经讨论了系统代理要做的事情,接下来让我们为我们的服务设计 proto,具体如下:

syntax = "proto3";
package system.agent;
option go_package = "github.com/[repo]/proto/agent";
message InstallReq {
    string name = 1;
    bytes package = 2;
    string binary = 3;
    repeated string args = 4;
}
message InstallResp {}
message CPUPerfs {
    int32 resolutionSecs = 1;
    int64 unix_time_nano = 2;
    repeated CPUPerf cpu = 3;
}
message CPUPerf {
    string id = 1;
    int32 user = 2;
    int32 system = 3;
    int32 idle = 4;
    int32 io_wait = 5;
    int32 irq = 6;
}
message MemPerf {
    int32 resolutionSecs = 1;
    int64 unix_time_nano = 2;
    int32 total = 3;
    int32 free = 4;
    int32 avail = 5;
}
service Agent {
   rpc Install(InstallReq) returns (InstallResp) {};
}

现在我们已经有了 RPC 的通用框架,接下来我们来看一下如何为我们的Install RPC 实现一个方法。

实现安装功能

在 Linux 上实现安装将需要一个多步骤的过程。首先,我们将在代理的用户主目录下的sa/packages/[InstallReq.Name]目录中安装该包。InstallReq.Name需要是一个包含字母和数字的单一名称。如果该名称已经存在,我们将关闭现有的工作并在其位置安装新的包。Linux 上的InstallReq.Package将是一个 ZIP 文件,该文件将在该目录中解压。

InstallReq.Binary是根目录中要执行的二进制文件的名称。InstallReq.Args是要传递给二进制文件的参数列表。

我们将使用一个第三方包来访问systemd。你可以在这里找到该包:github.com/coreos/go-systemd/tree/main/dbus

让我们来看一下这部分的实现:

func (a *Agent) Install(ctx context.Context, req 
*pb.InstallReq) (*pb.InstallResp, error) {
    if err := req.Validate(); err != nil {
        return nil, status.Error(codes.InvalidArgument, 
err.Error())
    }
    a.lock(req.Name)
    defer a.unlock(req.Name, false)
    loc, err := a.unpack(req.Name, req.Package)
    if err != nil {
        return nil, err
    }
    if err := a.migrate(req, loc); err != nil {
        return nil, err
    }
    if err := a.startProgram(ctx, req.Name); err != nil {
        return nil, err
    }
    return &pb.InstallResp{}, nil
}

这段代码执行以下操作:

  • 验证我们传入的请求以确保其有效

    • 实现代码位于代码库中
  • 为这个特定的安装名称加锁

    • 这可以防止多个相同名称的安装同时进行

    • 实现代码在仓库中

  • 将我们的 ZIP 文件解压到临时目录

    • 返回临时目录的位置

    • 验证我们的req.Binary二进制文件是否存在

    • 实现代码在仓库中

  • 将我们的临时目录迁移到req.Name位置

    • 如果systemd单元已存在,则将其关闭

    • /home/[user]/.config/systemd/user/下创建一个systemd单元文件

    • 如果最终路径已存在,则删除它

    • 将临时目录移动到最终位置

    • 实现代码在仓库中

  • 启动我们的二进制文件

    • 确保它已启动并运行 30 秒

这是设置我们 gRPC 服务的一个简单示例,用于设置和运行一个systemd服务。我们跳过了各种实现细节,但你可以在本章末尾列出的仓库中找到它们。

现在我们完成了Install,接下来让我们实现SystemPerf

实现 SystemPerf

为了收集我们的系统信息,我们将使用goprocinfo包,您可以在这里找到它:github.com/c9s/goprocinfo/tree/master/linux

我们希望每 10 秒更新一次,因此我们将在一个循环中实现数据收集,所有调用者都从相同的数据中读取。

让我们首先收集系统的中央处理单元CPU)数据,如下所示:

func (a *Agent) collectCPU(resolution int) error {
    stat, err := linuxproc.ReadStat("/proc/stat")
    if err != nil {
        return err
    }
    v := &pb.CPUPerfs{
        ResolutionSecs: resolution,
        UnixTimeNano:   time.Now().UnixNano(),
    }
    for _, p := range stat.CPUStats {
        c := &pb.CPUPerf{
            Id:     p.Id,
            User:   int32(p.User),
            System: int32(p.System),
            Idle:   int32(p.Idle),
            IoWait: int32(p.IOWait),
            Irq:    int32(p.IRQ),
        }
        v.Cpu = append(v.Cpu, c)
    }
    a.cpuData.Store(v)
    return nil
}

这段代码执行以下操作:

  • 读取我们的 CPU 状态数据

  • 将其写入协议缓冲区

  • 将数据存储在.cpuData

.cpuData将是atomic.Value类型。当你希望同步整个值,而不是修改值时,这种类型非常有用。每次我们更新a.cpuData时,我们都会把一个新值放入其中。如果你在atomic.Value中存储structmapslice,你不能修改键/字段——你必须制作一个包含所有键/索引/字段的新副本并存储,而不是修改单个键/字段。

当值较小时,这比使用互斥锁更适合读取,当存储少量计数器时非常完美。

collectMem内存收集器类似于collectCPU,并在仓库代码中有详细说明。

让我们来看看在New()构造函数中启动的用于收集性能数据的循环,如下所示:

func (a *Agent) perfLoop() error {
    const resolutionSecs = 10
    if err := a.collectCPU(resolutionSecs); err != nil {
        return err
    }
    expvar.Publish(
        "system-cpu",
        expvar.Func(
            func() interface{} {
                return a.cpuData.Load().(*pb.CPUPerfs)
            },
        ),
    )
    go func() {
        for {
            time.Sleep(resolutionSecs * time.Second)
            if err := a.collectCPU(resolutionSecs); err != nil {
                log.Println(err)
            }
        }
    }()
        return nil
}

这段代码执行以下操作:

  • 收集我们初始的 CPU 统计信息

  • 发布system-cpuexpvar.Var类型

    • 我们的变量类型是func() interface{},它实现了expvar.Func

    • 这只是读取由collectCPU()函数设置的atomic.Value

      • 当有人查询我们位于/debug/vars的网页时,会发生读取操作
  • 每 10 秒刷新我们的数据收集

expvar定义了其他一些简单的类型,例如StringFloatMap等。然而,我更喜欢使用协议缓冲区(proto)而不是Map来将内容分组到一个单一的、可共享的消息类型中,这种消息类型可以在任何语言中使用。因为 proto 是 JSON 可序列化的,它可以在expvar.Func的返回值中使用,只需借助protojson包即可。在代码库中,那个辅助代码位于agent/proto/extra.go

这段代码仅共享最新的数据收集。重要的是不要在每次调用时直接从统计文件中读取数据,因为这可能会轻易导致系统过载。

当你访问/debug/vars的 Web 端点时,现在可以看到以下内容:

"system-cpu": {"resolutionSecs":10,"unixTimeNano":"1635015190106788056","cpu":[{"id":"cpu0","user":13637,"system":10706,"idle":17557545,"ioWait":6663},{"id":"cpu1","user":12881,"system":22465,"idle":17539705,"ioWait":2997}]},
"system-mem": {"resolutionSecs":10,"unixTimeNano":"163501519010
6904757","total":8152984,"free":6594776,"avail":7576540}

还有一些其他的统计信息是针对系统代理本身的,这些在调试代理时可能会有用。这些是由expvar自动导出的。通过使用连接并读取这些统计信息的收集器,可以查看这些统计数据随时间的趋势。

我们现在有一个每 10 秒获取一次性能数据的代理,这为我们提供了一个有效的系统代理。值得注意的是,我们在讨论 RPC 系统时避免谈论认证、授权和计账AAA)。gRPC 支持传输层安全TLS),既可以保护传输过程,也可以实现互信 TLS。你还可以实现用户/密码、开放授权OAuth)或任何你感兴趣的 AAA 系统。

Web 服务可以为类似expvar的内容实现自己的安全性。expvar会在/debug/vars上发布它的统计信息,因此最好不要将这些信息暴露给外部世界。可以通过防止所有负载均衡器导出,或者在端点上实现某种类型的安全措施来保护这些信息。

你可以在这里找到我们系统代理的完整代码:github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/8/agent

在我们的完整代码中,我们决定通过 SSH 实现我们的系统代理。这使我们可以使用已经存在的授权系统,并提供强大的传输安全性。此外,gRPC 服务通过私有 Unix 域套接字导出服务,因此非root的本地服务无法访问该服务。

你还会发现代码会将我们通过systemd指令安装的应用容器化。这提供了本地隔离,有助于保护系统。

在本节中,我们学习了系统代理的可能用途,构建系统代理的基本设计指南,并最终介绍了如何在 Linux 上实现一个基本的代理。我们还讨论了我们的 gRPC 接口是如何被设计成通用的,以便可以实现其他操作系统的代理。

在构建代理的过程中,我们简要介绍了如何使用expvar导出变量。在下一章中,我们将讨论expvar的“大哥”——Prometheus 包。

总结

本章是对自动化命令行的介绍。我们已经展示了如何使用exec包在设备上本地执行命令。当需要将一组已有工具串联起来时,这非常有用。我们还展示了如何使用ssh包在远程系统上运行命令,或使用sshgoexpect包与复杂的程序进行交互。我们将这部分与前几章的 Go 知识结合,实施了一个基本的工作流应用程序,该程序能够并行且安全地在多个系统上升级二进制文件。最后,在本章中,我们学习了如何创建一个在设备上运行的系统代理,使我们能够收集重要数据并将其导出。我们还通过使用该代理控制 Linux 设备上的systemd,进一步提高了安装程序的能力。

本章已经为你提供了新的技能,使你能够控制本地命令行应用程序,在任意数量的机器上执行远程应用程序,并处理交互式应用程序。你还获得了构建工作流应用程序的基本理解,学习了如何开发可以控制本地机器的 RPC 服务,以及如何使用 Go 的expvar包导出统计数据。

在下一章中,我们将讨论如何观察正在运行的软件,以便在问题变成故障之前及时检测,并在事件发生时进行故障诊断。

第九章:第二节:仪表化、观察和响应

任何 DevOps 工程师的噩梦就是凌晨三点的电话通知,告诉他们依赖的系统出现了故障。为了应对这些问题,掌握信息至关重要,这些信息能为你和你的团队提供洞察,帮助迅速诊断和修复问题。更好的是,能否通过自动化完全避免这种情况?

本章将介绍使用 OpenTelemetry 在分布式应用程序中实现可观察性的概念,并减少对日志分析的依赖。我们将通过演示如何使用 Go 和 GitHub Actions 自动化应用发布工作流程,消除可能导致停机的人工操作,继续我们的探索之旅。最后,我们将探讨如何通过 ChatOps 和 Slack 实现跨团队的洞察,并减少工程师在部署任务中的繁琐工作。

本节将涵盖以下章节*:*

  • 第九章*,使用 OpenTelemetry 实现可观察性*

  • 第十章*,通过 GitHub Actions 自动化工作流程*

  • 第十一章*,使用 ChatOps 提高效率*

第九章:使用 OpenTelemetry 进行可观察性

在清晨,你正安然入睡时,手机突然响起。这不是你为朋友和家人设置的正常铃声,而是你为紧急情况设置的红色警报铃声。被铃声惊醒后,你开始逐渐清醒。你想到公司最近发布了新的应用程序,心中充满了一种不祥的预感。你接起电话,自动语音告知你需要加入一个优先级视频会议,会议中有一个团队正在调试新发布版本的在线问题。你迅速起床并加入了会议。

一旦接到电话,你会看到接诊团队的成员正在等待你。接诊团队告诉你,应用程序正遇到一次影响公司最大客户之一的服务故障,而该客户的损失占公司收入的很大一部分。这个故障已经被客户上报到了公司最高层,连 CEO 都知道这件事。接诊团队无法确定故障的原因,已经请你来帮助缓解问题,并找出故障的根本原因。

你去工作是为了确定根本原因。你打开应用程序的管理仪表盘,却发现没有关于应用程序的任何信息。没有日志,没有追踪,没有指标。应用程序没有发送遥测数据来帮助你调试故障。你基本上对应用程序的运行时行为以及造成故障的原因一无所知。你感到一种无法抗拒的恐惧,害怕如果找不到故障原因,这可能意味着公司将面临终结。

就在这时,我醒了过来。我刚才描述的,正是我经常做的噩梦:醒来时发现系统出现故障,而我没有足够的信息来确定应用程序的运行时状态。

如果无法查看应用程序的运行时状态,你就无法洞察可能导致应用程序异常行为的原因。你无法诊断并迅速缓解问题。在故障发生时,这种情况会让你感到非常无助和恐惧。

可观察性是通过测量应用程序和基础设施的输出,来了解应用程序的内部状态。我们将重点关注应用程序的三种输出:日志、追踪和指标。在这一章中,你将学习如何为应用程序添加监控,生成、收集并导出遥测数据,这样你就再也不会陷入无法了解应用程序运行时行为的境地。我们将使用 OpenTelemetry SDK 来为 Go 客户端和服务器添加监控,使应用程序能将遥测数据发送到 OpenTelemetry Collector 服务。OpenTelemetry Collector 服务将转换并导出这些遥测数据到后端系统,便于可视化、分析和告警。

本章将涵盖以下主题:

  • OpenTelemetry 简介

  • 带上下文的日志记录

  • 用于分布式追踪的工具化

  • 用于指标的工具化

  • 针对指标异常的告警

技术要求

本章需要 Docker 和 Docker Compose。

让我们从了解 OpenTelemetry、其组件以及 OpenTelemetry 如何使得观察性采取与供应商无关的方式开始。本章中使用的代码源自 github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/examples/demo,并进行了一些更改,以提供额外的清晰度。

本章的代码文件可以从 github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/9 下载

OpenTelemetry 简介

OpenTelemetry 最初是一个将 OpenTracing 和 OpenCensus 项目合并的项目,旨在创建一个单一项目,完成它们共同的使命——为所有提供高质量的遥测数据。OpenTelemetry 是一套与供应商无关的规范、API、SDK 和工具,旨在用于遥测数据的创建和管理。OpenTelemetry 使项目能够收集、转换并导出日志、追踪和指标等遥测数据到所选择的后端系统。

OpenTelemetry 具备以下功能:

  • 为最流行的编程语言提供的工具库,支持自动和手动工具化

  • 一个可以以多种方式部署的单一采集器二进制文件

  • 用于收集、转换和导出遥测数据的管道

  • 一套开放标准,防止供应商锁定

在本节中,我们将了解 OpenTelemetry 技术栈以及我们可以用来使复杂系统可观测的组件。

OpenTelemetry 的参考架构

接下来,让我们看看OpenTelemetryOTel)的概念性参考架构图:

https://github.com/OpenDocCN/freelearn-devops-pt3-zh/raw/master/docs/go-dop/img/B17626_09_001.jpg

图 9.1 – OpenTelemetry 参考架构

上述参考架构图展示了两个应用程序,这些应用程序使用 OTel 库并运行在主机上,同时 OTel Collector 被部署为主机上的代理。OTel Collector 代理收集来自应用程序的跟踪和度量数据以及来自主机的日志数据。左侧主机上的 OTel Collector 正在将遥测数据导出到 Backend 1 和 Backend 2。在右侧,OTel Collector 代理从 OTel 仪表化的应用程序接收遥测数据,收集来自主机的遥测数据,然后将遥测数据转发给作为服务运行的 OTel Collector。作为服务运行的 OTel Collector 将遥测数据导出到 Backend 1 和 Backend 2。此参考架构图示了 OTel Collector 如何既可以作为主机上的代理部署,也可以作为服务部署,用于收集、转换和导出遥测数据。

参考架构图中故意没有显示遥测数据传输所用的网络协议,因为 OTel Collector 能够接收多种遥测输入格式。对于现有应用程序,接受如 Prometheus、Jaeger 和 Fluent Bit 等现有格式,可以使迁移到 OpenTelemetry 更加容易。对于新应用程序,推荐使用 OpenTelemetry 网络协议,它简化了遥测数据摄取的收集器配置。

OpenTelemetry 组件

OpenTelemetry 由几个组件组成,构成了遥测堆栈。

OpenTelemetry 规范

OpenTelemetry 规范描述了跨语言实现的期望和要求,并使用以下术语进行说明:

  • API:定义了用于生成和关联跟踪、度量和日志的数据类型和操作。

  • SDK:定义了在特定语言中实现 API 的方式,包括配置、处理和导出。

  • 数据:定义了 OpenTelemetry 行协议OTLP),这是一个与供应商无关的用于传输遥测数据的协议。

欲了解更多关于规范的信息,请参见 opentelemetry.io/docs/reference/specification/

OpenTelemetry Collector

OTel Collector 是一个与供应商无关的代理,可以接收多种格式的遥测数据,进行转换和处理,并以多种格式导出,以供多个后端(例如 Jaeger、Prometheus、其他开源后端以及许多专有后端)使用。OTel Collector 由以下部分组成:

  • 接收器:用于收集数据的推送或拉取型处理器

  • 处理器:负责转换和过滤数据

  • 出口器:用于导出数据的推送或拉取型处理器

上述每个组件都通过 YAML 配置中描述的管道来启用。要了解更多关于数据收集的信息,请参见 opentelemetry.io/docs/concepts/data-collection/

语言 SDK 和自动仪表化

OpenTelemetry 中支持的每种语言都提供一个 SDK,帮助应用程序开发人员将他们的应用程序仪表化以发出遥测数据。SDK 还提供一些常见组件,帮助仪表化应用程序。例如,在 Go SDK 中,有用于 HTTP 处理程序的包装器,能够开箱即用地提供仪表化功能。此外,一些语言实现还提供自动仪表化,能够利用特定语言的特性收集遥测数据,而无需手动仪表化应用程序代码。

有关应用程序仪表化的更多信息,请参见 opentelemetry.io/docs/concepts/instrumenting-library/

遥测的关联性

遥测数据的关联性是任何遥测堆栈的核心特性。遥测数据的关联使我们能够确定跨越应用边界的事件之间的关系,这是构建复杂系统洞察的关键。例如,假设我们有一个由多个相互依赖的微服务组成的系统。每个服务可能运行在多个不同的主机上,并且可能使用不同的编程语言开发。我们需要能够关联一个给定的 HTTP 请求以及随后的所有请求,跨越我们的多个服务。这就是 OpenTelemetry 中遥测关联的作用。我们可以依靠 OpenTelemetry 在这些不同的服务之间建立一个关联 ID,并提供对复杂系统中发生事件的整体视图:

https://github.com/OpenDocCN/freelearn-devops-pt3-zh/raw/master/docs/go-dop/img/B17626_09_002.jpg

图 9.2 – 关联遥测

在本节中,我们介绍了 OpenTelemetry 堆栈中的主要概念。在接下来的章节中,我们将深入学习日志记录、追踪和度量,以及如何使用 OpenTelemetry 创建一个可观察的系统。

带上下文的日志记录

日志记录可能是最熟悉的遥测形式。当你第一次编写程序时,可能就通过打印 Hello World!STDOUT 来开始记录日志。日志记录是向观察者提供应用程序内部状态数据的最自然的第一步。想想你有多少次在应用程序中添加打印语句来确定变量的值。你在做的就是日志记录。

打印简单的日志语句,例如 Hello World!,对初学者可能有帮助,但它并没有提供我们操作复杂系统所需的关键数据。当日志被丰富以提供描述事件的上下文时,日志可以成为遥测数据的强大来源。例如,如果我们的日志条目中包含一个关联 ID,我们可以使用该数据将日志条目与其他可观察性数据关联起来。

应用程序或系统日志通常由带时间戳的文本记录组成。这些记录具有不同的结构,从完全无结构的文本到附带元数据的高度结构化模式都有。日志可以通过多种方式输出——单个文件、旋转文件,甚至输出到STDOUT。我们需要能够从多个来源收集日志,转换并提取可消费格式的日志数据,然后将转换后的数据导出以供消费/索引。

在本节中,我们将讨论如何改进日志记录,从纯文本到结构化日志格式的过渡,以及如何使用 OpenTelemetry 消费和导出各种日志格式。我们将使用 Go 语言进行学习,但所介绍的概念适用于任何语言。

我们的第一条日志语句

我们从使用标准的 Go 日志开始,输出Hello World!

package main
import "log"
func main() {
     log.Println("Hello World!")
}
// Outputs: 2009/11/10 23:00:00 Hello World!

上述的Println语句在go.dev/play/p/XH5JstbL7Ul中运行时输出2009/11/10 23:00:00 Hello World!。观察输出的纯文本结构,并思考需要做什么才能解析文本并提取结构化的输出。解析起来可能是一个相对简单的正则表达式,但随着新数据的加入,解析结构会发生变化,导致解析器出错。此外,输出中几乎没有关于事件或该事件发生时上下文的任何信息。

Go 标准库的日志记录器有几个其他可用的功能,但我们在这里不会深入探讨。如果你有兴趣了解更多,我建议你阅读pkg.go.dev/log。在本节的其余部分,我们将专注于结构化和分级日志记录器以及由github.com/go-logr/logr描述的 API。

使用 Zap 的结构化和分级日志

结构化日志记录器相比文本日志记录器有几个优势。结构化日志具有定义的键值模式,比纯文本更容易解析。你可以利用这些键值嵌入丰富的信息,例如关联 ID 或其他有用的上下文信息。此外,你可以过滤掉在特定日志上下文中可能不适用的键。

V 级别是控制日志中信息量的简单方法。例如,一个应用程序可能在-1 级别输出极为冗长的调试日志,而在 4 级别时仅输出关键错误。

在 Go 社区中,已有一个运动旨在通过github.com/go-logr/logr标准化结构化和分级日志接口。许多库实现了logr项目中描述的 API。为了我们的目的,我们将专注于一个结构化日志库——Zap,它也实现了logr API(github.com/go-logr/zapr)。

让我们来看一下 Zap 日志记录器接口中的关键功能:

// Debug will log a Debug level event
func (log *Logger) Debug(msg string, fields ...Field)
// Info will log an Info level event
func (log *Logger) Info(msg string, fields ...Field)
// Error will log an Error level event
func (log *Logger) Error(msg string, fields ...Field)
// With will return a logger that will log the keys and values specified for future log events
func (log *Logger) With(fields ...Field) *Logger
// Named will return a logger with a given name
func (log *Logger) Named(s string) *Logger

上述接口提供了一组易于使用且强类型的日志记录原语。让我们看看使用 Zap 进行结构化日志记录的示例:

package main
import (
     "time"
     "go.uber.org/zap"
)
func main() {
     logger, _ := zap.NewProduction()
     defer logger.Sync()
     logger = logger.Named("my-app")
     logger.Info
          ("failed to fetch URL",
          zap.String("url", "https://github.com"),
          zap.Int("attempt", 3),
          zap.Duration("backoff", time.Second),
     )
}
// Outputs: {"level":"info","ts":1257894000,"logger":"my
// app","caller":"sandbox4253963123/prog.go:15",
// "msg":"failed to fetch URL",
// "url":"https://github.com","attempt":3,"backoff":1}

日志记录器的 JSON 结构化输出通过强类型的键值对提供有用、易于解析的上下文信息。在本章的追踪部分,我们将使用这些额外的键值对来嵌入关联 ID,以便将我们的分布式追踪与日志关联。如果你想尝试一下,可以查看go.dev/play/p/EVQPjTdAwX_U

我们不会深入讨论日志输出的位置(如文件系统、STDOUTSTDERR),而是假设我们希望摄取的应用程序日志将具有文件表示形式。

现在我们在应用程序中生成了结构化日志,可以切换到使用 OpenTelemetry 来摄取、转换和导出日志。

使用 OpenTelemetry 摄取、转换和导出日志

在这个使用 OpenTelemetry 来摄取、转换和导出日志的示例中,我们将使用docker-compose来设置一个环境,模拟一个 Kubernetes 主机,日志存储在/var/logs/pods/*/*/*.log路径下。OTel Collector 将作为在主机上运行的代理。日志将从日志路径中的文件中摄取,路由到filelog接收器中的适当操作员,按其特定格式进行解析,解析后的属性将标准化,然后通过logging导出器导出到STDOUT

本次演示将使用以下代码:github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/9/logging。现在,让我们快速查看一下演示目录的布局:

.
├── README.md
├── docker-compose.yml
├── otel-collector-config.yml
└── varlogpods
    ├── containerd_logs
0_000011112222333344445555666677778888
    │   └── logs
    │       └── 0.log
    ├── crio_logs-0_111122223333444455556666777788889999
    │   └── logs
    │       └── 0.log
    ├── docker_logs-0_222233334444555566667777888899990000
    │   └── logs
    │       └── 0.log
    └── otel_otel_888877776666555544443333222211110000
        └── otel-collector
            └── 0.log

docker-compose.yml文件包含了我们将运行 OTel Collector 的服务定义,并且挂载了 Collector 配置文件和日志文件目录varlogpods,以模拟 Collector 在 Kubernetes 主机上的运行。让我们来看看docker-compose.yml

version: "3"
services:
  opentelemetry-collector-contrib:
    image: otelcontribcol
    command: ["--config=/etc/otel-collector-config.yml"]
    volumes:
      - ./otel-collector-config.yml:/etc/otel-collector-config.yml
      - ./varlogpods:/var/log/pods

要运行此演示,请进入章节的源代码,cd进入logging目录,然后运行docker-compose up

OTel Collector 配置

OTel Collector 配置文件包含了代理如何摄取、处理和导出日志的指令。让我们深入了解配置并逐步解析:

receivers:
  filelog:
    include:
      - /var/log/pods/*/*/*.log
    exclude:
      # Exclude logs from all containers named otel-collector
      - /var/log/pods/*/otel-collector/*.log
    start_at: beginning
    include_file_path: true
    include_file_name: false

receivers部分包含一个单一的filelog接收器,指定了要包含和排除的目录。filelog接收器将从每个日志文件的开头开始,并在操作符中包含文件路径以提取元数据。接下来,让我们继续看看操作符部分:

    operators:
      # Find out which format is used by kubernetes
      - type: router
        id: get-format
        routes:
          - output: parser-docker
            expr: '$$body matches "^\\{"'
          - output: parser-crio
            expr: '$$body matches "^[^ Z]+ "'
          - output: parser-containerd
            expr: '$$body matches "^[^ Z]+Z"'

filelog 操作符定义了一系列用于处理日志文件的步骤。初始步骤是一个路由操作,它将根据日志文件的主体内容,确定哪个解析器处理操作符输出中指定的日志主体条目。每个解析器操作符将根据日志条目的特定格式,从每个记录中提取时间戳。现在让我们继续看解析器,看看一旦路由完成,解析器如何从每个日志条目中提取信息:

      # Parse CRI-O format
      - type: regex_parser
        id: parser-crio
        regex: '^(?P<time>[^ Z]+) (?Pstdout|stderr) (?P<logtag>[^ ]*) (?P<log>.*)$'
        output: extract_metadata_from_filepath
        timestamp:
          parse_from: time
          layout_type: gotime
          layout: '2006-01-02T15:04:05.000000000-07:00'
      # Parse CRI-Containerd format
      - type: regex_parser
        id: parser-containerd
        regex: '^(?P<time>[^ ^Z]+Z) (?Pstdout|stderr) (?P<logtag>[^ ]*) (?P<log>.*)$'
        output: extract_metadata_from_filepath
        timestamp:
          parse_from: time
          layout: '%Y-%m-%dT%H:%M:%S.%LZ'
      # Parse Docker format
      - type: json_parser
        id: parser-docker
        output: extract_metadata_from_filepath
        timestamp:
          parse_from: time
          layout: '%Y-%m-%dT%H:%M:%S.%LZ'
      # Extract metadata from file path
      - type: regex_parser
        id: extract_metadata_from_filepath
        regex: '^.*\/(?P<namespace>[^_]+)_(?P<pod_name>[^_]+)_(?P<uid>[a-f0-9\-]{36})\/(?P<container_name>[^\._]+)\/(?P<restart_count>\d+)\.log$'
        parse_from: $$attributes["file.path"]
      # Move out attributes to Attributes
      - type: metadata
        attributes:
          stream: 'EXPR($.stream)'
          k8s.container.name: 'EXPR($.container_name)'
          k8s.namespace.name: 'EXPR($.namespace)'
          k8s.pod.name: 'EXPR($.pod_name)'
          k8s.container.restart_count: 'EXPR($.restart_count)'
          k8s.pod.uid: 'EXPR($.uid)'
      # Clean up log body
      - type: restructure
        id: clean-up-log-body
        ops:
          - move:
              from: log
              to: $

例如,parser-crio 操作符将对每个日志条目执行正则表达式,从条目中解析出时间变量,并指定提取字符串的时间格式。将 parser-crioparser-docker 操作符进行对比,后者使用 JSON 结构化日志格式,每个日志条目中都有一个 time 的 JSON 键。parser-docker 操作符只提供 JSON 条目的键和字符串的布局。结构化日志不需要正则表达式。每个解析器的输出都传送到 extract_metadata_from_filepath,该操作通过正则表达式从文件路径中提取属性。在解析并提取文件路径信息之后,metadata 操作会执行,将从解析步骤中收集的属性添加到上下文中,以便将来查询。最后,restructure 操作将从每个解析日志条目中提取的日志键移到提取结构的 Body 属性中。

让我们来看看 CRI-O 日志格式:

2021-02-16T08:59:31.252009327+00:00 stdout F example: 11 Tue Feb 16 08:59:31 UTC 2021

现在,让我们来看看 Docker 日志格式:

{"log":"example: 12 Tue Feb 16 09:15:12 UTC
2021\n","stream":"stdout","time":"2021-02-16T09:15:12.50286486Z"}

在运行示例时,你应该会看到如下输出:

opentelemetry-collector-contrib_1  | LogRecord #19
opentelemetry-collector-contrib_1  | Timestamp: 2021-02-16 09:15:17.511829776 +0000 UTC
opentelemetry-collector-contrib_1  | Severity:
opentelemetry-collector-contrib_1  | ShortName:
opentelemetry-collector-contrib_1  | Body: example: 17 Tue Feb 16 09:15:17 UTC 2021
opentelemetry-collector-contrib_1  |
opentelemetry-collector-contrib_1  | Attributes:
opentelemetry-collector-contrib_1  |      -> k8s.container.name: STRING(logs)
opentelemetry-collector-contrib_1  |      -> k8s.container.restart_count: STRING(0)
opentelemetry-collector-contrib_1  |      -> k8s.namespace.name: STRING(docker)
opentelemetry-collector-contrib_1  |      -> k8s.pod.name: STRING(logs-0)
opentelemetry-collector-contrib_1  |      -> k8s.pod.uid: STRING(222233334444555566667777888899990000)
opentelemetry-collector-contrib_1  |      -> stream: STRING(stdout)
opentelemetry-collector-contrib_1  | Trace ID:
opentelemetry-collector-contrib_1  | Span ID:
opentelemetry-collector-contrib_1  | Flags: 0

正如你从前面的输出中看到的,OTel 收集器已经从 metadata 操作符中提取了时间戳、主体和指定的属性,构建了导出日志数据的标准化结构,并将标准化结构导出到 STDOUT

我们已经完成了日志遥测的摄取、转换和提取目标,但你还应该问自己,我们如何才能与这些遥测数据建立更强的关联性。到目前为止,我们唯一的关联是时间、Pod 和容器。我们很难确定导致该日志条目的 HTTP 请求或其他具体信息。请注意,在前面的输出中,Trace IDSpan ID 是空的。在接下来的部分,我们将讨论追踪,并看看如何在我们的应用程序中建立日志与请求之间更强的关联。

用于分布式追踪的仪器化

跟踪用于追踪应用程序中单个活动的进展。例如,一个活动可以是用户在应用程序中发起一个请求。如果一个跟踪仅仅追踪单个进程或系统中一个组件的活动进展,那么它的价值是有限的。然而,如果一个跟踪可以跨多个组件传播,它将变得更加有用。能够在系统中跨组件传播的跟踪被称为分布式跟踪。分布式跟踪和活动相关性分析是确定复杂系统中因果关系的强大工具。

跟踪(Trace)由表示应用程序内工作单元的跨度(span)组成。每个跟踪和跨度都可以被唯一标识,每个跨度包含一个上下文,该上下文包括请求错误持续时间等度量。一个跟踪包含一个具有单一根跨度的跨度树。例如,假设用户在你公司电商网站上点击结账按钮。根跨度将包含整个请求/响应周期,正如用户点击结账按钮时所感知的那样。对于这个单一根跨度,可能会有许多子跨度,例如查询产品数据、信用卡支付和数据库更新。也许其中还会有一个与根跨度中的某个底层跨度相关的错误。每个跨度都有与之相关的元数据,如名称、开始和结束时间戳、事件和状态。通过创建一个包含这些元数据的跨度树,我们能够深入检查复杂应用程序的状态。

在本节中,我们将学习如何使用 OpenTelemetry 对 Go 应用程序进行仪器化,以发出分布式跟踪遥测数据,并使用 Jaeger(一款用于可视化和查询分布式跟踪的开源工具)来检查这些数据。

分布式跟踪的生命周期

在我们深入代码之前,让我们首先讨论分布式跟踪的工作原理。假设我们有两个服务,A 和 B。服务 A 提供网页并从服务 B 请求数据。当服务 A 收到页面请求时,服务启动一个根 span。然后,服务 A 请求服务 B 的一些数据来完成请求。服务 A 将跟踪和 span 上下文编码到请求头中,以发送给服务 B。当服务 B 收到请求时,服务 B 从请求头中提取跟踪和 span 信息,并从请求创建一个子 span。如果服务 B 没有收到跟踪/span 头,则会创建一个新的根 span。服务 B 继续处理请求,根据需要从数据库请求数据创建新的子 span。服务 B 收集完所请求的信息后,响应服务 A 并将其 span 发送给跟踪聚合器。然后服务 A 收到来自服务 B 的响应,并向用户响应页面。活动结束时,服务 A 标记根 span 为完成,并将其 span 发送给跟踪聚合器。跟踪聚合器构建一个树,其中包含来自服务 A 和服务 B 的 span 的共享相关性,从而形成分布式跟踪。

要了解 OpenTelemetry 跟踪规范的更多细节,请参阅 opentelemetry.io/docs/reference/specification/overview/#tracing-signal

使用 OpenTelemetry 进行客户端/服务器分布式跟踪

在此示例中,我们将部署并检查一个使用 OpenTelemetry 进行分布式跟踪的客户端/服务器应用程序,并使用 Jaeger 查看分布式跟踪。客户端应用程序定期向服务器发送请求,这些请求将在 Jaeger 中生成跟踪。 github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/9/tracing 目录包含以下内容:

.
├── readme.md
├── client
│   ├── Dockerfile
│   ├── go.mod
│   ├── go.sum
│   └── main.go
├── docker-compose.yaml
├── otel-collector-config.yaml
└── server
    ├── Dockerfile
    ├── go.mod
    ├── go.sum
    └── main.go

要运行此演示,请转到章节源代码,cdtracing目录,运行 docker-compose up -d,并打开 http://localhost:16686 查看 Jaeger 分布式跟踪。

让我们首先浏览 docker-compose.yaml 文件,看看我们正在部署的每个服务:

version: "2"
services:
  # Jaeger
  jaeger-all-in-one:
    image: jaegertracing/all-in-one:latest
    ports:
      - "16686:16686"
      - "14268"
      - "14250"
  # Collector
  otel-collector:
    image: ${OTELCOL_IMG}
    command: ["--config=/etc/otel-collector-config.yaml", "${OTELCOL_ARGS}"]
    volumes:
      - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
    ports:
      - "13133:13133" # health_check extension
    depends_on:
      - jaeger-all-in-one
  demo-client:
    build:
      dockerfile: Dockerfile
      context: ./client
    environment:
      - OTEL_EXPORTER_OTLP_ENDPOINT=otel-collector:4317
      - DEMO_SERVER_ENDPOINT=http://demo-server:7080/hello
    depends_on:
      - demo-server
  demo-server:
    build:
      dockerfile: Dockerfile
      context: ./server
    environment:
      - OTEL_EXPORTER_OTLP_ENDPOINT=otel-collector:4317
    ports:
      - "7080"
    depends_on:
      - otel-collector

前面的 docker-compose.yaml 文件部署了一个 Jaeger all-in-one 实例,一个 OTel 收集器,一个客户端 Go 应用程序,以及一个服务器 Go 应用程序。这些组件略有不同于 OpenTelemetry 演示:github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/examples/demo

接下来,让我们查看 OTel 收集器的配置,以更好地理解其部署模型和配置行为:

receivers:
  otlp:
    protocols:
      grpc:
exporters:
  jaeger:
    endpoint: jaeger-all-in-one:14250
    tls:
      insecure: true
processors:
  batch:
service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [jaeger]

前述 OTel 收集器配置指定收集器将侦听14250端口。

接下来,让我们分解客户端 main.go 的重要部分:

func main() {
     shutdown := initTraceProvider()
     defer shutdown()

     continuouslySendRequests()
}

func main() 初始化跟踪提供者,并返回一个关闭函数,该函数将在 func main() 退出时延迟执行。main() 函数接着调用 continuouslySendRequests 向服务器应用发送一个连续的、定期的请求流。接下来,让我们看看 initTraceProvider 函数:

func initTraceProvider() func() {
	ctx := context.Background()
	cancel = context.CancelFunc
	timeout := 1 * time.Second
	endPointEnv := "OTEL_EXPORTER_OTLP_ ENDPOINT"
	otelAgentAddr, ok := os.LookupEnv(endPointEnv)
	if !ok {
		otelAgentAddr = "0.0.0.0:4317"
	}
	closeTraces := initTracer(ctx, otelAgentAddr)
	return func() {
		ctx, cancel = context.WithTimeout(ctx, time.Second)
		defer cancel()
		// pushes any last exports to the receiver
		closeTraces(doneCtx)
	}
}

initTraceProvider() 从环境变量中查找 OTLP 跟踪端点,或者默认为 0.0.0.0:4317。在设置好跟踪端点地址后,代码调用 initTracer 来初始化跟踪器,并返回一个名为 closeTraces 的函数,该函数用于关闭跟踪器。最后,initTraceProvider() 返回一个可用于刷新和关闭跟踪器的函数。接下来,让我们看看 initTracer() 中发生了什么:

func initTracer(ctx context.Context, otelAgentAddr string) func(context.Context) {
     traceClient := otlptracegrpc.NewClient(
          otlptracegrpc.WithInsecure(),
          otlptracegrpc.WithEndpoint(otelAgentAddr),
          otlptracegrpc.WithDialOption(grpc.WithBlock()))
     traceExp, err := otlptrace.New(ctx, traceClient)
     handleErr(err, "Failed to create the collector trace exporter")
     res, err := resource.New(
          ctx,
          resource.WithFromEnv(),
          resource.WithProcess(),
          resource.WithTelemetrySDK(),
          resource.WithHost(),
          resource.WithAttributes(
               semconv.ServiceNameKey.String("demo-client"),
          ),
     )
     handleErr(err, "failed to create resource")
     bsp := sdktrace.NewBatchSpanProcessor(traceExp)
     tracerProvider := sdktrace.NewTracerProvider(
          sdktrace.WithSampler(sdktrace.AlwaysSample()),
          sdktrace.WithResource(res),
          sdktrace.WithSpanProcessor(bsp),
     )
     // set global propagator to tracecontext (the default is no-op).
     otel.SetTextMapPropagator(propagation.TraceContext{})
     otel.SetTracerProvider(tracerProvider)
     return func(doneCtx context.Context) {
          if err := traceExp.Shutdown(doneCtx); err != nil {
               otel.Handle(err)
          }
     }
}

initTracer() 构建了一个连接到 OTLP 端点的 trace 客户端。然后,使用该 trace 客户端构建一个 trace 导出器,该导出器用于批量处理和导出 spans。批量 span 处理器随后用于创建一个跟踪提供者,该提供者被配置为跟踪所有 spans,并且被标识为 "demo-client" 资源。跟踪提供者可以配置为以随机方式或使用自定义采样策略进行采样。然后,跟踪提供者被添加到全局 OTel 上下文中。最后,返回一个函数,该函数将关闭并刷新 trace 导出器。

现在我们已经探讨了如何设置跟踪器,接下来让我们继续讨论在 continuouslySendRequests 函数中发送和跟踪请求:

func continuouslySendRequests() {
     tracer := otel.Tracer("demo-client-tracer")
     for {
          ctx, span := tracer.Start(context.Background(), "ExecuteRequest")
          makeRequest(ctx)
          span.End()
          time.Sleep(time.Duration(1) * time.Second)
     }
}

顾名思义,continuouslySendRequests 函数从全局 OTel 上下文中创建一个命名的跟踪器,我们在本章早些时候已经初始化了它。otel.Tracer 接口只有一个函数,Start(ctx context.Context, spanName string, opts ...SpanStartOption) (context.Context, Span),用于在 context.Context 值包中没有现有 span 时启动一个新的 span。main 中的 for 循环将无限期地创建新的 span,向服务器发出请求,进行一些工作,最后休眠 1 秒:

func makeRequest(ctx context.Context) {
     demoServerAddr, ok := os.LookupEnv("DEMO_SERVER_ENDPOINT")
     if !ok {
          demoServerAddr = "http://0.0.0.0:7080/hello"
     }
     // Trace an HTTP client by wrapping the transport
     client := http.Client{
          Transport: otelhttp.NewTransport(http.DefaultTransport),
     }
     // Make sure we pass the context to the request to avoid broken traces.
     req, err := http.NewRequestWithContext(ctx, "GET", demoServerAddr, nil)
     if err != nil {
          handleErr(err, "failed to http request")
     }
     // All requests made with this client will create spans.
     res, err := client.Do(req)
     if err != nil {
          panic(err)
     }
     res.Body.Close()
}

makeRequest() 对那些使用过 Go http 库的人来说应该很熟悉。与未进行 OTel 仪表化的 HTTP 请求相比,有一个显著的区别:client 的传输已被包装在 otelhttp.NewTransport() 中。otelhttp 传输在 Roundtrip 实现中使用 request.Context() 来提取上下文中的现有 span,然后 otelhttp.Transport 将 span 信息添加到 HTTP 头中,以便将 span 数据传播到服务器应用。

现在我们已经涵盖了客户端部分,接下来让我们看看服务器端的 main.go。该部分的代码可以在这里找到:github.com/PacktPublishing/Go-for-DevOps/blob/rev0/chapter/9/tracing/server/main.go

func main() { 
    shutdown := initTraceProvider() 
    defer shutdown()
     handler := handleRequestWithRandomSleep()
     wrappedHandler := otelhttp.NewHandler(handler, "/hello")
     http.Handle("/hello", wrappedHandler)
     http.ListenAndServe(":7080", nil)
}

func main.go 以类似于客户端 main.go 的方式调用 initTraceProvidershutdown。在初始化追踪提供者后,服务器 main.go 代码创建了一个 HTTP 服务器,处理端口 7080 上的 "/hello" 请求。关键部分是 wrappedHandler := otelhttp.NewHandler(handler, "/hello")wrappedHandler() 从 HTTP 头中提取跨度上下文,并将从客户端跨度派生的跨度填充到请求的 context.Context 中。在 handleRequestWithRandomSleep() 中,代码使用传播的跨度上下文继续分布式追踪。让我们来探讨一下 handleRequestWithRandomSleep()

func handleRequestWithRandomSleep() http.HandlerFunc {
     commonLabels := []attribute.KeyValue{
          attribute.String("server-attribute", "foo"),
     }
     return func(w http.ResponseWriter, req *http.Request) {
          //  random sleep to simulate latency
          var sleep int64
          switch modulus := time.Now().Unix() % 5; modulus {
          case 0:
               sleep = rng.Int63n(2000)
          case 1:
               sleep = rng.Int63n(15)
          case 2:
               sleep = rng.Int63n(917)
          case 3:
               sleep = rng.Int63n(87)
          case 4:
               sleep = rng.Int63n(1173)
          }
          time.Sleep(time.Duration(sleep) * time.Millisecond)
          ctx := req.Context()
          span := trace.SpanFromContext(ctx)
          span.SetAttributes(commonLabels...)
          w.Write([]byte("Hello World"))
     }
}

handleRequestWithRandomSleep() 中,请求被处理,同时引入了一个随机延迟以模拟延迟。trace.SpanFromContext(ctx) 使用由 wrappedHandler 填充的跨度,然后在分布式跨度上设置属性。

在 Jaeger 中可查看的结果位于 http://localhost:16686,如下所示:

https://github.com/OpenDocCN/freelearn-devops-pt3-zh/raw/master/docs/go-dop/img/B17626_09_003.jpg

](https://github.com/OpenDocCN/freelearn-devops-pt3-zh/raw/master/docs/go-dop/img/B17626_09_003.jpg)

图 9.3 – Jaeger 客户端/服务器分布式追踪

在前面的截图中,你可以看到客户端和服务器之间的分布式追踪,包括在请求/响应周期中创建的每个跨度。这是一个简单的例子,但你可以想象如何将这个简单的例子扩展到更复杂的系统中,从而提供对难以调试场景的洞察。追踪提供了获取错误以及更细微的性能问题所需的信息。

关联追踪与日志

上下文日志记录 部分,我们讨论了日志条目与活动的关联。如果没有与特定追踪和跨度的关联,你将无法确定哪些日志事件源自特定的活动。请记住,日志条目本身并不包含追踪和跨度数据,这些数据帮助我们构建关联的追踪视图,正如我们在 Jaeger 中所看到的那样。然而,我们可以扩展日志条目以包括这些数据,并启用与特定活动的强关联:

func WithCorrelation(span trace.Span, log *zap.Logger) *zap.Logger {
     return log.With(
          zap.String("span_id", convertTraceID(span.SpanContext().SpanID().String())),
          zap.String("trace_id", convertTraceID(span.SpanContext().TraceID().String())),
     )
}
func convertTraceID(id string) string {
     if len(id) < 16 {
          return ""
     }
     if len(id) > 16 {
          id = id[16:]
     }
     intValue, err := strconv.ParseUint(id, 16, 64)
     if err != nil {
          return ""
     }
     return strconv.FormatUint(intValue, 10)
}

在前面的代码中,我们使用 zap 结构化日志记录器将跨度和追踪 ID 添加到日志记录器中,因此每个由增强了 WithCorrelation() 的日志记录器写入的日志条目将与给定的活动保持强关联。

向跨度添加日志条目

关联日志与追踪对于构建日志与活动的关联非常有效,但你可以更进一步。你可以将日志事件直接添加到跨度中,而不是仅仅依赖于日志的关联:

func SuccessfullyFinishedRequestEvent(span trace.Span, opts ...trace.EventOption) {
     opts = append(opts, trace.WithAttributes(attribute.String("someKey", "someValue")))
     span.AddEvent("successfully finished request operation", opts...)
}

SuccessfullyFinishedRequestEvent() 将会用一个事件条目装饰跨度,这个事件会作为日志条目出现在 Jaeger 中。如果我们在客户端的 main.go 中调用这个函数,在完成请求后,会向客户端请求的跨度添加一个日志事件:

https://github.com/OpenDocCN/freelearn-devops-pt3-zh/raw/master/docs/go-dop/img/B17626_09_003.jpg

](https://github.com/OpenDocCN/freelearn-devops-pt3-zh/raw/master/docs/go-dop/img/B17626_09_004.jpg)

图 9.4 – Jaeger 客户端/服务器分布式追踪与日志条目

如你所见,日志条目被嵌入到 Jaeger 中可视化的跨度内。将日志条目添加到跨度中为分布式追踪提供了更多上下文,帮助你更容易理解应用程序的运行状况。

在下一节中,我们将通过度量仪表化这个示例,使用 Prometheus 提供应用程序的聚合视图。

进行度量仪表化

度量是应用程序在运行时某个特定方面在某一时刻的测量值。每次捕获的结果称为 度量事件,它由时间戳、测量值和相关的元数据组成。度量事件用于提供应用程序运行时行为的聚合视图。例如,度量事件可以是每当服务处理请求时,计数器加 1。单个事件本身并不特别有用,但当它们聚合成一段时间内的请求总数时,就能反映出服务在该时间段内处理了多少请求。

OpenTelemetry API 不允许自定义聚合,但提供了一些常见的聚合方法,如求和、计数、最后一个值和直方图,这些方法被 Prometheus 等后端可视化和分析软件所支持。

为了让你更清楚地了解度量何时有用,以下是一些示例场景:

  • 提供一个进程中读取或写入的位数的总和

  • 提供 CPU 或内存使用情况

  • 提供一段时间内的请求数量

  • 提供一段时间内的错误数量

  • 提供请求持续时间以形成请求处理时间的统计分布

OpenTelemetry 提供三种类型的度量:

  • counter:在一段时间内计数一个值,例如请求的数量

  • measure:对一段时间内的值进行求和或其他聚合,例如每分钟读取多少字节

  • observer:定期捕获某个值,例如每分钟的内存使用情况

在本节中,我们将学习如何使用 OpenTelemetry 对 Go 应用进行仪表化,以发出度量遥测数据,并使用 Prometheus 这一开源工具进行可视化和分析。

度量的生命周期

在深入代码之前,让我们先讨论度量是如何定义和使用的。在你可以记录或观察一个度量之前,它必须被定义。例如,请求延迟的直方图可以这样定义:

meter := global.Meter("demo-client-meter")
requestLatency := metric.Must(meter).NewFloat64Histogram(
	"demo_client/request_latency",
	metric.WithDescription(
		"The latency of requests processed"
	),
)
requestCount := metric.Must(meter).NewInt64Counter(
	"demo_client/request_counts",
	metric.WithDescription("The number of requests processed"),
)

上述代码获取一个名为 demo-client-meter 的全局计量器,然后注册一个新的直方图仪表 demo_client/reqeust_latency 和一个计数器仪表 demo_client/request_counts,这两个仪表都包含了它们所收集内容的描述。为度量提供描述性名称和说明非常重要,因为在后续分析数据时,如果没有清晰的命名,可能会导致混淆。

一旦仪表已定义,就可以用来记录度量数据,具体如下:

meter.RecordBatch(
    ctx,
    commonLabels,
    requestLatency.Measurement(latencyMs),
    requestCount.Measurement(1),
)

上述代码使用了我们之前定义的全局计量器来记录两个度量值:请求延迟和请求数量的增量。请注意,ctx被包括在内,它将包含关联信息,用以将活动与度量值关联起来。

在事件被记录后,它们将根据MeterProvider的配置进行导出,接下来我们将探讨这一部分。

使用 OpenTelemetry 的客户端/服务器指标

我们将扩展在为分布式追踪仪表化部分中描述的相同客户端/服务器应用程序。此部分的代码可以在这里找到:github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/9/metrics。该目录的结构如下:

.
├── readme.md
├── client
│   ├── Dockerfile
│   ├── go.mod
│   ├── go.sum
│   └── main.go
├── .env
├── docker-compose.yaml
├── otel-collector-config.yaml
├── prometheus.yaml
└── server
    ├── Dockerfile
    ├── go.mod
    ├── go.sum
    └── main.go

上述内容中唯一的新增部分是prometheus.yaml文件,内容如下:

scrape_configs:
  - job_name: 'otel-collector'
    scrape_interval: 10s
    static_configs:
      - targets: ['otel-collector:8889']
      - targets: ['otel-collector:8888']

上述配置告知 Prometheus 抓取 OTel 收集器中的端点以收集指标数据。接下来,让我们看一下需要更新的内容,以将 Prometheus 添加到docker-compose.yaml文件中:

version: "2"
services:
  # omitted Jaeger config
  # Collector
  otel-collector:
    image: ${OTELCOL_IMG}
    command: ["--config=/etc/otel-collector-config.yaml", "${OTELCOL_ARGS}"]
    volumes:
      - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
    ports:
      - "8888:8888"   # Prometheus metrics exposed by the collector
      - "8889:8889"   # Prometheus exporter metrics
      - "4317"        # OTLP gRPC receiver
    depends_on:
      - jaeger-all-in-one
  # omitted demo-client and demo-server
  prometheus:
    container_name: prometheus
    image: prom/prometheus:latest
    volumes:
      - ./prometheus.yaml:/etc/prometheus/prometheus.yml
    ports:
      - "9090:9090"

如你所见,我们已经为 OTel 收集器添加了一些额外的端口供 Prometheus 抓取,并且 Prometheus 服务已经将prometheus.yaml挂载到容器中。接下来,让我们查看更新后的 OTel 收集器配置:

receivers:
  otlp:
    protocols:
      grpc:
exporters:
  prometheus:
    endpoint: "0.0.0.0:8889"
    const_labels:
      label1: value1
  logging:
  # omitted jaeger exporter
processors:
  batch:
service:
  pipelines:
    # omitted tracing pipeline
    metrics:
      receivers: [otlp]
      processors: [batch]
      exporters: [logging, prometheus]

上述配置省略了在为分布式追踪仪表化部分中使用的 Jaeger 配置,为了简洁起见。新增的部分是 Prometheus 的导出器以及指标管道。Prometheus 导出器将暴露端口8889,以便 Prometheus 抓取 OTel 收集器收集的指标数据。

接下来,让我们分解客户端main.go中的重要部分:

func main() {
     shutdown := initTraceAndMetricsProvider()
     defer shutdown()
     continuouslySendRequests()
}

我们之前在本章中探讨的追踪版本与此处的唯一区别是,代码现在调用initTraceAndMetricsProvider来初始化追踪和指标提供者,而不是调用initTraceProvider。接下来,让我们探讨initTraceAndMetricsProvider()

func initTraceAndMetricsProvider() func() {
	ctx := context.Background()
	var cancel context.CancelFunc
	timeout := 1 * time.Second
	endpoint := "OTEL_EXPORTER_OTLP_ ENDPOINT"
	otelAgentAddr, ok := os.LookupEnv(endpoint)
	if !ok {
		otelAgentAddr = "0.0.0.0:4317"
	}
	closeMetrics := initMetrics(ctx, otelAgentAddr)
	closeTraces := initTracer(ctx, otelAgentAddr)
	return func() {
		ctx, cancel = context.WithTimeout(ctx, timeout)
		defer cancel()
		closeTraces(doneCtx)
		closeMetrics(doneCtx)
	}
}

initTraceAndMetricsProvider中的代码建立了 OTel 代理地址,并初始化了指标和追踪提供者。最后,返回一个关闭并刷新指标和追踪的函数。接下来,让我们探讨initMetrics()

func initMetrics(ctx context.Context, otelAgentAddr string) func(context.Context) {
     metricClient := otlpmetricgrpc.NewClient(
          otlpmetricgrpc.WithInsecure(),
          otlpmetricgrpc.WithEndpoint(otelAgentAddr))
     metricExp, err := otlpmetric.New(ctx, metricClient)
     handleErr(err, "Failed to create the collector metric exporter")
     pusher := controller.New(
          processor.NewFactory(
               simple.NewWithHistogramDistribution(),
               metricExp,
          ),
          controller.WithExporter(metricExp),
          controller.WithCollectPeriod(2*time.Second),
     )
     global.SetMeterProvider(pusher)
     err = pusher.Start(ctx)
     handleErr(err, "Failed to start metric pusher")
     return func(doneCtx context.Context) {
          // pushes any last exports to the receiver
          if err := pusher.Stop(doneCtx); err != nil {
               otel.Handle(err)
          }
     }
}

initMetrics()中,我们创建了一个新的metricClient来将指标从客户端以 OTLP 格式传输到 OTel 收集器。设置好metricClient后,我们创建pusher来管理将指标导出到 OTel 收集器,注册pusher为全局的MeterProvider,并启动pusher以将指标导出到 OTel 收集器。最后,我们创建一个闭包来关闭pusher。现在,让我们继续探讨客户端main.go中的continuouslySendRequests()

func continuouslySendRequests() {
     var (
          meter        = global.Meter("demo-client-meter")
          instruments  = NewClientInstruments(meter)
          commonLabels = []attribute.KeyValue{
               attribute.String("method", "repl"),
               attribute.String("client", "cli"),
          }
          rng = rand.New(rand.NewSource(time.Now().UnixNano()))
     )
     for {
          startTime := time.Now()
          ctx, span := tracer.Start(context.Background(), "ExecuteRequest")
          makeRequest(ctx)
          span.End()
          latencyMs := float64(time.Since(startTime)) / 1e6
          nr := int(rng.Int31n(7))
          for i := 0; i < nr; i++ {
               randLineLength := rng.Int63n(999)
               meter.RecordBatch(
                    ctx,
                    commonLabels,
                    instruments.LineCounts.Measurement(1),
                    instruments.LineLengths.Measurement(
  randLineLength
),
               )
               fmt.Printf("#%d: LineLength: %dBy\n", i, randLineLength)
          }
          meter.RecordBatch(
               ctx,
               commonLabels,
               instruments.RequestLatency.Measurement(
  latencyMs
),
               instruments.RequestCount.Measurement(1),
          )
          fmt.Printf("Latency: %.3fms\n", latencyMs)
          time.Sleep(time.Duration(1) * time.Second)
     }
}

我们首先创建一个名为 demo-client-meter 的度量计量器,定义用于测量此函数中度量的仪表,并添加一组公共标签到收集到的度量数据中。这些标签使得可以按范围查询度量数据。初始化人工延迟的随机数生成器后,客户端进入 for 循环,记录请求的开始时间,向服务器发起请求,并将 makeRequest 的持续时间作为延迟(以毫秒为单位)记录下来。在执行 makeRequest 后,客户端执行 0 到 7 次的随机迭代以生成一个随机行长度,并在每次迭代中记录一批度量事件,测量执行次数和随机行长度。最后,客户端记录一批度量事件,测量 makeRequest 的延迟和一次请求的计数。

那么,我们是如何定义前面代码中使用的仪表的呢?让我们来探索一下 NewClientInstruments,并了解如何定义计数器和直方图仪表:

func NewClientInstruments(meter metric.Meter) 
ClientInstruments {
     return ClientInstruments{
          RequestLatency: metric.Must(meter).
               NewFloat64Histogram(
                    "demo_client/request_latency",
                    metric.WithDescription("The latency of requests processed"),
               ),
          RequestCount: metric.Must(meter).
               NewInt64Counter(
                    "demo_client/request_counts",
                    metric.WithDescription("The number of requests processed"),
               ),
          LineLengths: metric.Must(meter).
               NewInt64Histogram(
                    "demo_client/line_lengths",
                    metric.WithDescription("The lengths of the various lines in"),
               ),
          LineCounts: metric.Must(meter).
               NewInt64Counter(
                    "demo_client/line_counts",
                    metric.WithDescription("The counts of the lines in"),
               ),
     }
}

NewClientInstruments() 接受一个计量器并返回一个客户端使用的仪表结构。一个仪表用于记录和聚合测量值。这个函数设置了两个 Int64CounterInt64Histogram 仪表。每个仪表都以一个描述清晰的名称来定义,以便于在后端度量系统中进行分析。Int64Counter 仪表会单调递增,而 Int64Histogram 会记录 int64 类型的值并在推送到度量后端之前进行预聚合。

现在我们已经涵盖了客户端的部分,让我们来看看服务器的 main.go

func main() {
     shutdown := initProvider()
     defer shutdown()
     // create a handler wrapped in OpenTelemetry instrumentation
     handler := handleRequestWithRandomSleep()
     wrappedHandler := otelhttp.NewHandler(handler, "/hello")
     http.Handle("/hello", wrappedHandler)
     http.ListenAndServe(":7080", nil)
}

服务器的 main.go 以类似于客户端 main.go 的方式调用 initProvider()shutdown()。有趣的度量指标发生在 handleRequestWithRandomSleep() 中。接下来,让我们导出 handleRequestWithRandomSleep()

func handleRequestWithRandomSleep() http.HandlerFunc {
     var (
          meter        = global.Meter("demo-server-meter")
          instruments  = NewServerInstruments(meter)
          commonLabels = []attribute.KeyValue{
               attribute.String("server-attribute", "foo"),
          }
     )
     return func(w http.ResponseWriter, req *http.Request) {
          var sleep int64
          switch modulus := time.Now().Unix() % 5; modulus {
          case 0:
               sleep = rng.Int63n(2000)
          case 1:
               sleep = rng.Int63n(15)
          case 2:
               sleep = rng.Int63n(917)
          case 3:
               sleep = rng.Int63n(87)
          case 4:
               sleep = rng.Int63n(1173)
          }
          time.Sleep(time.Duration(sleep) * time.Millisecond)
          ctx := req.Context()
          meter.RecordBatch(
               ctx,
               commonLabels,
               instruments.RequestCount.Measurement(1),
          )
          span := trace.SpanFromContext(ctx)
          span.SetAttributes(commonLabels...)
          w.Write([]byte("Hello World"))
     }
}

在前面的代码中,handleRequestWithRandomSleep() 从全局的 OTel 上下文中创建了一个命名的计量器,类似于客户端示例的方式初始化了服务器的仪表,并定义了一组自定义属性。最后,该函数返回一个处理函数,它引入了一个随机延迟并记录请求计数。

结果可以在 Prometheus 中查看,网址为 http://localhost:9090/graph?g0.expr=rate(demo_server_request_counts%5B2m%5D)&g0.tab=0&g0.stacked=0&g0.show_exemplars=0&g0.range_input=1h

https://github.com/OpenDocCN/freelearn-devops-pt3-zh/raw/master/docs/go-dop/img/B17626_09_005.jpg

图 9.5 – Prometheus 服务器请求速率

在前面的截图中,你可以看到 Prometheus 中服务器应用程序的平均每秒请求数。在截图的底部,你会看到在 main.go 文件中为服务器添加的常用标签和其他关联的元数据。Prometheus 提供了强大的查询语言来分析和对指标进行警报。花点时间探索一下你在 Prometheus UI 中能做些什么。如果你想了解更多关于 Prometheus 的信息,请参见prometheus.io/docs/introduction/overview/

在本节中,我们学习了如何为 Go 应用程序添加监控代码,将指标导出到 OTel 收集器,配置 Prometheus 从 OTel 收集器抓取指标,并开始分析 Prometheus 中的指标遥测数据。通过这些新获得的技能,你将能够更深入地了解应用程序的运行时特性。

接下来,我们来看看如何在指标显示出可能指示问题的异常时添加警报。

对指标异常进行警报

指标提供了我们应用程序和基础设施行为的时间序列测量,但它们在这些测量偏离应用程序预期行为时并不会发出通知。为了能够对应用程序中的异常行为做出反应,我们需要建立关于什么是应用程序正常行为的规则,并且在我们的应用程序偏离这些行为时如何接收通知。

对指标进行警报可以让我们定义行为规范,并指定在我们的应用程序表现出异常行为时应该如何接收通知。例如,如果我们预期应用程序的 HTTP 响应时间在 100 毫秒以内,而我们观察到 5 分钟的时间段内应用程序的响应时间超过了 100 毫秒,那么我们希望能够收到偏离预期行为的通知。

在本节中,我们将学习如何扩展当前的服务配置,加入一个 Alertmanager (prometheus.io/docs/alerting/latest/alertmanager/) 服务,以便在观察到的行为偏离预期规范时提供警报。我们将学习如何定义警报规则,并指定在应用程序出现异常行为时将通知发送到何处。

本节的代码在这里:github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/9/alerting

添加和配置 Alertmanager

我们将从将 Alertmanager 服务添加到 docker-compose.yaml 文件开始。让我们看看需要更新哪些内容来将 Prometheus 添加到 docker-compose.yaml 文件中:

version: "2"
services:
  # omitted previous configurations
  prometheus:
    container_name: prometheus
    image: prom/prometheus:latest
    volumes:
      - ./prometheus.yaml:/etc/prometheus/prometheus.yml
      - ./rules:/etc/prometheus/rules
    ports:
      - "9090:9090"
  alertmanager:
    container_name: alertmanager
    image: prom/alertmanager:latest
    restart: unless-stopped
    ports:
      - "9093:9093"
    volumes:
      - ./alertmanager.yml:/config/alertmanager.yaml
      - alertmanager-data:/data
    command: --config.file=/config/alertmanager.yaml -- log.level=debug
volumes:
  alertmanager-data:

如前所述,我们为prometheus服务添加了一个rules文件夹,一个新的服务alertmanager,以及一个名为alertmanager-data的卷,用于存储alertmanager的数据。我们稍后将在本节中讨论 Prometheus 的./rules卷挂载及其内容,但目前知道它包含我们为 Prometheus 定义的警报规则。新的alertmanager服务暴露了一个 HTTP 端点http://localhost:9093,并挂载了一个alertmanager.yml配置文件以及一个数据目录。接下来,让我们探索alertmanager.yml文件的内容,看看 Alertmanager 是如何配置的:

route:
  receiver: default
  group_by: [ alertname ]
  routes:
    - match:
        exported_job: demo-server
      receiver: demo-server
receivers:
  - name: default
    pagerduty_configs:
      - service_key: "**Primary-Integration-Key**"
  - name: demo-server
    pagerduty_configs:
      - service_key: "**Server-Team-Integration-Key**"

Alertmanager 的配置主要由路由(routes)和接收器(receivers)组成。路由描述了根据是否为默认路由或符合某些条件将警报发送到哪里。例如,在前面的 Alertmanager 配置中,我们有一个默认路由和一个专门的路由。默认路由将在警报的exported_job属性与值"demo-server"不匹配时,将警报发送到默认接收器。如果警报的exported_job属性与值"demo-server"匹配,则警报将被路由到demo-server接收器,该接收器在接收器部分中描述。

在这个 Alertmanager 接收器的示例中,我们使用了 PagerDuty(www.pagerduty.com),但还有许多其他接收器可以进行配置。例如,你可以为 Slack、Teams、Webhooks 等配置接收器。请注意,每个接收器的service_key值需要一个 PagerDuty 集成密钥,设置方法可以参考将 Prometheus 与 PagerDuty 集成的文档(www.pagerduty.com/docs/guides/prometheus-integration-guide/)。如果你希望使用其他接收器,比如电子邮件,可以按照 Prometheus 的电子邮件配置指南(prometheus.io/docs/alerting/latest/configuration/#email_config)随意更改接收器配置为电子邮件。

接下来,我们将查看需要对 Prometheus 配置文件./prometheus.yaml进行的更改,以便让 Prometheus 识别 Alertmanager 服务和将警报发送到 Alertmanager 服务的规则:

scrape_configs:
  - job_name: 'otel-collector'
    scrape_interval: 10s
    static_configs:
      - targets: ['otel-collector:8889']
      - targets: ['otel-collector:8888']
alerting:
  alertmanagers:
    - scheme: http
      static_configs:
        - targets: [ 'alertmanager:9093' ]
rule_files:
  - /etc/prometheus/rules/*

在前面的./prometheus.yaml中,我们看到了原始的scrape_config和两个新的键,alertingrule_filesalerting键描述了alertmanager服务以发送警报以及连接到这些服务的连接细节。rule_files键描述了选择包含警报规则文件的 glob 规则。这些规则可以在 Prometheus 的 UI 中设置,但最佳实践是以声明式代码的方式定义这些规则,这样它们对团队的其他成员来说既清晰又可见,像源代码一样。

接下来,让我们查看rules文件,看看我们是如何在./rules/demo-server.yml中描述警报规则的:

groups:
  - name: demo-server
    rules:
      - alert: HighRequestLatency
        expr: |
          histogram_quantile(0.5, rate(http_server_duration_bucket{exported_job="demo-server"}[5m])) > 200000
        labels:
          severity: page
        annotations:
          summary: High request latency

rule_files 中的规则按组分类。在前面的示例中,我们看到一个名为 demo-server 的组,指定了一个名为 HighRequestLatency 的规则。该规则指定了一个表达式,这是一个 Prometheus 查询。前面的查询在平均请求延迟超过 200,000 微秒或 0.2 秒时触发。告警会触发,并标记为 page 严重性,并附有 High request latency 的注释摘要。

现在,让我们运行以下命令来启动服务:

$ docker-compose up -d

服务启动后,我们应该能在 Prometheus 的 http://localhost:9090/alerts 页面看到如下内容:

https://github.com/OpenDocCN/freelearn-devops-pt3-zh/raw/master/docs/go-dop/img/B17626_09_006.jpg

图 9.6 – Prometheus 中的 HighRequestLatency 告警

上面的截图显示了在 Prometheus 中注册的告警规则。如您所见,HighRequestLatency 告警是通过我们在 ./rules/demo-server 文件中配置的命令注册的。

大约运行 5 分钟后,您应该能看到如下内容:

https://github.com/OpenDocCN/freelearn-devops-pt3-zh/raw/master/docs/go-dop/img/B17626_09_007.jpg

图 9.7 – HighRequestLatency 告警触发

在上面的截图中,您可以看到 HighRequestLatency 告警被触发。这是 Prometheus 在平均请求延迟超过 0.2 秒时触发告警。告警随后会发送到 Alertmanager,Alertmanager 会将其委派给相应的接收器。接收器将告警发送到配置的服务,可能是 PagerDuty,或者是您配置的其他接收器。您现在已经建立了一个告警流程,当您的应用程序进入异常状态时,能够通知您或团队的其他成员。

在这一节中,您学习了如何配置 Prometheus 告警规则,部署 Alertmanager,并配置 Alertmanager 将告警发送到您选择的通知服务。通过这些知识,您应该能够为应用程序定义规范行为的规则,并在应用程序行为超出这些范围时提醒您或您的团队。

告警是响应应用程序异常行为的关键组成部分。通过适当的指标,您现在可以在应用程序未达到预期时主动响应,而不是在收到客户投诉时才做出反应。

摘要

在本章中,我们探讨了 OpenTelemetry 的基础知识,如何对您的应用程序和基础设施进行监控,并如何将这些遥测数据导出到后端可视化和分析工具,如 Jaeger 和 Prometheus。我们还通过集成告警规则扩展了指标的优势,以便在应用程序操作超出预期行为参数时,主动通知我们。通过应用所学知识,您将在支持电话中避免措手不及。您将拥有数据来诊断和解决复杂系统中的问题。更棒的是,您将能在客户提出问题之前就了解这些问题。

我们还建立了一些相对简单的指标、追踪和告警。通过这些知识,您将能够实现自己的追踪、指标和告警,帮助您和您的团队在生产环境中迅速有效地应对故障。

在下一章,我们将讨论如何使用 GitHub Actions 自动化工作流。我们将了解 GitHub Actions 的基础,并在此基础上构建自己的基于 Go 的 GitHub Actions,赋能您使用任何图灵完备语言编写自动化任务。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值