Go-语言-DevOps-全-
Go 语言 DevOps(全)
原文:
annas-archive.org/md5/3bb23876803d0893c1924ba12cfd8f56译者:飞龙
前言
当你年纪渐长,我觉得大多数人都会反思自己的生活。他们是如何走到今天的,在哪些地方成功,在哪些地方失败。坦率地说,我可以说我在职业生涯中有过失败。我知道从失败开始一本书是很不寻常的,但我想,为什么要开始一本充满关于我超乎想象成功的谎言的书呢?
我的理想更像是吉米·巴菲特,而不是沃伦·巴菲特。保持对任何事情的兴趣超过几年对我来说是一种挑战,而我认为辛勤工作的表现就是在夏威夷的海滩上喝着皮纳科拉达。唉,我的梦想没有实现。我离这个梦想最近的一次,是为一个总是穿着夏威夷衬衫的老板工作,我觉得这算不上。
这一切“自动化专业知识”都源于我希望尽可能少做工作的需求。当我还是桌面支持技术员时,我需要找到一种方法,在几个小时内完成大量机器的构建,而不是手动安装 Windows 和应用程序。我想把我的时间花在办公室里玩视频游戏、读书,或者四处走走与人交谈。当我做网络工程师时,我希望人们在我舒适地睡在校园交换机机房里的时候,停止给我发送页面。于是,我写了一些工具,让其他人能够切换 VLAN 端口或清除网络端口的安全参数,而无需打电话给我。为什么每周都手动平衡 BGP 流量,反正我可以写一个程序,利用 SFLOW 数据来完成这项工作?
一切进展得很顺利,直到我变得有些雄心壮志,去了 Google。我写了几个工具,帮助自己让工作变得更轻松,比如弄清楚是否是因为正在进行的计划工作或程序导致的值班页面,或者是为数据中心的所有负载均衡器提供配置的程序。那时候,Google 有很多按摩椅和其他设施,我宁愿利用这些,而不是在与亚特兰大超负荷工作的硬件运维技术员通话的同时,在 IRC 频道里敲打为什么我的网络排水管仍然存在。
然而,随后人们开始想要使用我的工具。我的朋友 Adel 会问我,是否能做一些工具来编程设施路由器,或者验证 Force10 路由器是否设置正确。而且他是一个非常好的人,你根本无法拒绝。或者 Kirk 会过来问我们如何自动化边缘路由器的启用,因为他的团队已经超负荷工作了。结果,我没有让我的工作变得更轻松,反而花了更多时间帮别人简化工作!
希望我的失败能帮助你成功(我父亲曾经说过,没有人是完全没用的;他们总可以作为坏榜样)。
本书充满了我在职业生涯中使用过的许多方法论,并且有我认为目前最适合 DevOps 的语言——Go。
David(我的合著者,他稍后会自我介绍)和我来自 DevOps 世界的两个极端。我来自一个几乎不使用商业或标准开源软件的思潮。所有 DevOps 工具都是内部开发的,并且根据特定环境量身定制。David 则来自一个你尽可能多使用开源软件的思潮,比如 Kubernetes、GitHub、Docker、Terraform 等等……这样,你可以利用一系列可用且流行的工具,这些工具可能不是完全符合你需求的,但它们有支持网络和众多选项。雇佣已经熟悉行业标准工具的工程师比雇佣使用自定义工具集的工程师更容易。在这本书中,你将会看到这两种思想和方法的结合。我们认为,现成工具和自定义工具的混合能够为你带来最大的性价比。
我们真诚的希望这本书不仅能为你提供使用 Go 进行 DevOps 所需的指南,还能让你具备编写自己的工具或修改现有工具的能力,利用 Go 的强大功能来扩展任何公司的运营需求。如果没有其他收获,David 和我都会把我们的收入捐赠给“无国界医生”,所以如果你购买了这本书,哪怕没有其他收获,你也将帮助一个很有意义的事业。
但也许有一天你会坐在海滩上,收着工资单,而你的自动化流程正处理日常事务。我会继续为这个目标努力,所以如果你先达成了目标,替我喝一杯。
话虽如此,我想向大家介绍我的尊敬的合著者,David Justice。
正如 John 所提到的,我们来自不同的背景,但我们发现自己在处理相似的问题领域。我的背景是软件开发和软件工程,涉及从移动应用开发、网页开发、数据库优化,到机器学习和分布式系统等各个方面。我的重点从来都不是 DevOps。我可以算是一个偶然的 DevOps 从业者。我的 DevOps 技能是为了提供不断增长的商业价值的必要性所驱动的,这要求我自动化所有与交付新功能和修复缺陷无关的工作。发展 DevOps 技能的另一个动机是我希望能够持续部署代码并安稳地过夜。没有什么能像经营一家初创公司并且是唯一一个需要在凌晨三点解决高优先级问题的人那样,鼓励你去建立弹性系统和自动化流程。
我在这里描述的动机应该为我倾向于选择那些能够迅速应用且在开源社区中有大量支持的解决方案提供依据。如果我能找到一个拥有优质文档的开源解决方案,能够很好地完成我大部分的需求,那么我可以在需要时将其余部分拼接起来(如果深入挖掘,几乎每个解决方案的底层都可能是一些肮脏的 Bash 脚本)。为了让我或我的团队投入大量时间和精力去构建定制工具,我们需要获得相当可观的投资回报。而且,当我想到定制工具时,我也会考虑到持续的维护成本和对新团队成员的培训。指引新团队成员学习像 Terraform 这样的项目是简单的,那里有很好的文档和无数的博客文章,详细描述了每个可能遇到的场景。新团队成员也很有可能已经了解 Terraform,因为他们在前一份工作中就使用过它。这种理由促使我在批准一个构建定制工具的项目时需要有充分的证据。出于这些原因,我花了相当多的时间使用开源的 DevOps 工具,并且我也把自己作为一项业务,尽力在扩展这些工具上做到最好。
在本书中,你将找到使用 Go 和标准库完成任务的各种定制工具。然而,你也会发现几个如何使用现有开源工具来完成那些本来需要大量定制代码才能实现的任务的示例。我相信我们不同的方法为内容增加了价值,并为你提供了理解在发明自己的解决方案或扩展现有解决方案以解决常见 DevOps 任务时所涉及的权衡所需的工具。
正如 John 所说,我也希望这本书能帮助你达到一种类似禅宗的自动化掌控状态,以便你能跟随 John 的步伐,过上像 Jimmy Buffet 而非 Warren Buffet 那样的生活。
本书适用对象
本书适用于任何希望使用 Go 来开发自己的 DevOps 工具或与如 Kubernetes、GitHub Actions、HashiCorp Packer 和 Terraform 等 DevOps 工具集成自定义功能的人。你应该有某种编程语言的经验,但不一定是 Go。
本书内容
第一章,Go 语言基础,介绍了 Go 语言的基础知识。
第二章,Go 语言基础,介绍了 Go 语言的基本特性。
第三章,设置您的开发环境,解释了如何设置 Go 开发环境。
第四章,文件系统交互,探讨了如何使用 Go 与本地文件系统进行交互。
第五章,使用常见数据格式,讲解了如何使用 Go 读取和写入常见的文件格式。
第六章,与远程数据源交互,探讨了如何使用 Go 与 gRPC 和 REST 服务进行交互。
第七章,编写命令行工具,展示了如何用 Go 编写命令行工具。
第八章,自动化命令行任务,介绍了如何利用 Go 的 exec 和 SSH 包来自动化工作。
第九章,使用 OpenTelemetry 进行可观测性,探讨了如何使用 OpenTelemetry 与 Go 进行更好的仪表化和警报设置。
第十章,使用 GitHub Actions 自动化工作流,展示了如何使用 GitHub 进行持续集成、发布自动化和使用 Go 进行自定义操作。
第十一章,使用 ChatOps 提高效率,讲解了如何用 Go 编写 ChatOps 服务,以提供操作性洞察和有效地管理事件。
第十二章,使用 Packer 创建不可变基础设施,解释了如何自定义 HashiCorp 的 Packer,自动化在 AWS 上创建虚拟机镜像。
第十三章,使用 Terraform 进行基础设施即代码,展示了如何定义自己的自定义 Terraform 提供程序。
第十四章,在 Kubernetes 中部署和构建应用程序,探讨了如何编程和扩展 Kubernetes API。
第十五章,云编程,解释了如何使用 Go 来配置和交互云资源。
第十六章,为混乱设计,讨论了如何使用速率限制器、集中式工作流引擎和策略来减少爆炸半径。
为了充分利用本书
你需要具备一定的编程经验,但不一定是 Go 语言经验。你需要对任何支持的操作系统的命令行工具有基本了解。具有一些 DevOps 经验会更有帮助。

如果你使用的是这本书的数字版,建议你亲自输入代码或从书籍的 GitHub 仓库获取代码(链接将在下一节提供)。这样可以避免与复制粘贴代码相关的潜在错误。
本书大量依赖 Docker 和 Docker Compose,帮助你设置在 Linux 上原生运行的集群配置。虽然可以在 Windows 上使用 Windows Subsystem for Linux(WSL)来进行本书的操作,但作者并未对此进行测试。另外,许多练习也可以在其他符合 POSIX GNU 标准的操作系统上完成。 第十二章,使用 Packer 创建不可变基础设施,需要一个运行 Linux 虚拟机的 AWS 账户,第十三章,“基础设施即代码与 Terraform” 和 第十五章,“编程云端” 需要一个 Azure 账户。
下载示例代码文件
你可以从 GitHub 下载本书的示例代码文件,网址为 github.com/PacktPublishing/Go-for-DevOps。如果代码有更新,将在 GitHub 仓库中更新。
我们还提供来自我们丰富书籍和视频目录的其他代码包,可以在github.com/PacktPublishing/查看。快去看看吧!
下载彩色图片
我们还提供了一份 PDF 文件,里面有本书中使用的截图和图表的彩色图像。你可以在此下载:static.packt-cdn.com/downloads/9781801818896_ColorImages.pdf。
使用的约定
本书中使用了许多文本约定。
文本中的代码:表示文本中的代码词汇、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟网址、用户输入和 Twitter 账号。示例:“在你的用户主目录下设置一个名为 packer 的目录。”
代码块的格式如下:
packer {
required_plugins {
amazon = {
version = ">= 0.0.1"
当我们希望引起你对代码块中特定部分的注意时,相关的行或项目将以粗体显示:
source "amazon-ebs" "ubuntu" {
access_key = "your key"
secret_key = "your secret"
ami_name = "ubuntu-amd64"
instance_type = "t2.micro"
任何命令行输入或输出都写作以下格式:
sudo yum install -y yum-utils sudo yum-config-manager --add-repo https://rpm.releases.hashicorp.com/AmazonLinux/hashicorp.repo sudo yum -y install packer
粗体:表示一个新术语、重要词汇或屏幕上的内容。例如,菜单或对话框中的文字通常会以 粗体 显示。示例:“你需要在 GitHub 仓库的 设置 | 机密 中找到并点击提供的按钮,新建仓库机密。”
提示或重要说明
如下所示。
联系我们
我们始终欢迎读者的反馈。
customercare@packtpub.com,并在邮件主题中注明书名。
勘误:尽管我们已经尽力确保内容的准确性,但难免会出现错误。如果你发现本书中有错误,我们将非常感谢你向我们报告。请访问 www.packtpub.com/support/errata 并填写表单。
copyright@packt.com 以及相关链接。
如果你有兴趣成为作者:如果你在某个领域有专业知识,并且有兴趣写作或为书籍贡献内容,请访问 authors.packtpub.com。
分享你的想法
一旦你读完Go for DevOps,我们很想听听你的想法!请点击这里直接进入亚马逊书籍评论页面并分享你的反馈。
你的评价对我们以及技术社区非常重要,它将帮助我们确保提供优质的内容。
第一部分:开始使用 Go
Go 是一种类型安全的并发语言,易于开发且性能极佳。在本节中,我们将从学习 Go 语言的基础知识开始,例如类型、变量创建、函数和其他基本语言构造。接下来,我们将继续教授包括并发、context 包、测试和其他必要技能的核心内容。你将学习如何为你的操作系统设置 Go 环境,如何与本地文件系统交互,使用常见的数据格式,使用如 REST 和 gRPC 等方法与远程数据源进行通信。最后,我们将通过编写命令行工具,利用流行的包来实现自动化,并向本地和远程资源发出命令。
本节将涉及以下章节:
-
第一章,Go 语言基础
-
第二章,Go 语言必备知识
-
第三章,环境设置
-
第四章,文件系统交互
-
第五章,使用常见的数据格式
-
第六章,与远程数据源交互
-
第七章,编写命令行工具
-
第八章,自动化命令行任务
第一章:Go 语言基础
DevOps 是一个自 2000 年代初以来就存在的概念。它是依赖于编程技能的运维学科的普及,结合了 由敏捷开发推广的开发心理学。
站点可靠性工程 (SRE) 现在被认为是 DevOps 的一个子学科,尽管它可能是 DevOps 的前身,并且更加依赖软件技能和 服务级义务 (SLO)/服务级协议 (SLA) 建模。
在我早期在 Google 的工作中,像今天许多 DevOps 团队一样,我们大量使用 Python。我认为 C++ 对许多 SRE 来说太痛苦了,而我们有 Python 大腕 Guido van Rossum 和 Alex Martelli。
但随着时间的推移,许多使用 Python 的团队开始遇到扩展性问题。这些问题包括从 Python 内存耗尽(需要我们自己实现 malloc)到 全局解释器锁 (GIL) 阻止我们真正的多线程处理。在大规模应用中,我们发现缺乏静态类型导致了大量本应在编译时捕获的错误。这与生产环境中的服务多年前遇到的问题相似。
但 Python 带来的问题不仅仅是编译时和服务扩展的问题。仅仅将 Python 版本升级到新版本,就可能导致服务停止工作。Google 机器上运行的 Python 版本经常被升级,并暴露出旧版本中没有的代码 bug。与编译后的二进制文件不同,你不能仅仅回滚到旧版本。
我们中有几位来自不同组织的人,正在寻找不必使用 C++ 的方法来解决这些问题。就我个人的经历来说,我从我们悉尼办公室的同事那里听说了 Go(嘿,Ross!)。那时 Go 还在 1.0 之前,但他们说它已经显示出很多潜力。我不能说我当时完全相信我们需要的是另一种语言。
然而,大约 6 个月后,我已经完全接受了 Go 一见钟情。它拥有我们所需的一切,而没有我们不需要的东西。虽然那时还处于 1.0 之前的阶段,所以也有一些不太愉快的变化(比如发现 Russ Cox 在周末更改了 time 包,所以我不得不重写一大堆代码)。但是,在我写完第一个服务后,其好处是不可否认的。
接下来的 4 年里,我带领我的部门从完全使用 Python 转变为几乎完全使用 Go。我开始在全球范围内开设 Go 课程,面向运维工程师,重写 Go 的核心库,并做了相当多的推广。仅仅因为 Go 是在 Google 发明的,并不意味着工程师们愿意抛弃他们的 Python 代码并学习新的东西;有很多反对的声音。
现在,Go 已经成为云编排和软件(从 Kubernetes 到 Docker)中的事实标准语言。Go 自带了所有你需要的工具,可以显著提高你工具的可靠性和可扩展性。
因为许多云服务都是用 Go 编写的,你可以通过访问它们的包来满足自己的工具需求。这可以使为云编写工具变得更加简单。
在接下来的两章中,我将分享我在全球工程师中教授 Go 语言的 10+ 年经验,为你介绍 Go 语言的基础和要点。大部分内容基于我免费的 Go 基础视频培训课程 www.golangbasics.com。这本书稍有不同,更为精简。随着你阅读本书,我们将继续扩展你对 Go 语言标准库和第三方包的了解。
本章将涵盖以下主要内容:
-
使用 Go Playground
-
利用 Go 包
-
使用 Go 的变量类型
-
在 Go 中进行循环
-
使用条件语句
-
学习函数
-
定义公共和私有
-
使用数组和切片
-
了解结构体
-
理解 Go 指针
-
理解 Go 接口
现在,让我们把基础知识掌握好,让你上路!
技术要求
本章唯一的技术要求是使用Go Playground的现代 Web 浏览器。
使用 Go Playground
你可以在 play.golang.org/ 找到的 Go Playground 是一个在线代码编辑器和编译器,允许你在没有在本地安装 Go 的情况下运行 Go 代码。这是我们介绍章节的完美工具,让你可以在线保存你的工作,避免了安装 Go 工具和寻找代码编辑器的初期麻烦。
Go Playground 有四个重要部分:
-
代码编辑窗格
-
控制台窗口
-
运行按钮
-
分享按钮
代码编辑窗格是页面上黄色部分,允许你输入你的程序的 Go 代码。当你点击运行按钮时,代码将被编译并在控制台输出白色部分显示。
下面的屏幕截图展示了 Go Playground 的功能:

图 1.1 – Go Playground 代码编辑器
点击 play.golang.org 生成一个可共享的链接,比如 play.golang.org/p/HmnNoBf0p1z。这个链接是一个唯一的 URL,你可以收藏并分享给其他人。该链接中的代码不能被修改,但如果再次点击分享按钮,将会生成一个带有任何修改的新链接。
后续章节,从 第四章,文件系统交互,将需要为你的平台安装 Go 工具。
本节向你介绍了 Go Playground,以及如何使用它编写、查看、分享和运行你的 Go 代码。Playground 将在本书中广泛使用,用于分享可运行的代码示例。
现在,让我们开始编写 Go 代码,从 Go 如何定义包开始。
使用 Go 包
Go 提供了可以重复使用的代码块,可以通过包导入到其他代码中。Go 中的包等同于其他语言中的库或模块。包是 Go 程序的构建块,将内容划分为可理解的部分。
本节将讲解如何声明和导入包。我们将讨论如何处理包名冲突,探索包相关的规则,并将编写我们的第一个主包。
声明包
Go 将程序划分为 包,在其他语言中有时称为 模块 或 库。包位于一个路径上,这个路径看起来像 Unix 类文件系统中的目录路径。
目录中的所有 Go 文件必须属于同一个包。包通常被命名为它所在目录的名称。
在文件的顶部声明包,并且只应当被注释所先行。声明包就像下面这样简单:
// Package main is the entrance point for our binary.
// The double slashes provides a comment until the end of the line.
/*
This is a comment that lasts until the closing star slash.
*/
package main
package main 是特别的。其他所有包名声明的包必须导入到另一个包中才能使用。package main 将声明 func main(),这是二进制程序运行的起点。
目录中的所有 Go 文件必须具有相同的包头(由编译器强制执行)。这些文件在大多数实际应用中,作用上好像它们是连接在一起的。
假设你有以下的目录结构:
mypackage/
file1.go
file2.go
然后,file1.go 和 file2.go 应该具有以下内容:
package mypackage
当 mypackage 被另一个包导入时,它将包含 mypackage 目录中所有文件中声明的内容。
导入包
包大致分为两种类型:
-
标准库 (stdlib) 包
-
所有其他包
标准库包之所以显得特别,是因为它们的路径中不包含一些仓库信息,例如以下内容:
"fmt"
"encoding/json"
"archive/zip"
所有其他包通常会在路径前面列出仓库信息,如下所示:
"github.com/johnsiilver/golib/lru"
"github.com/kylelemons/godebug/pretty"
注意
完整的 stdlib 包列表可以在以下链接找到:golang.org/pkg/。
要导入包,我们使用 import 关键字。那么,让我们导入标准库中的 fmt 包和位于 github.com/devopsforgo/mypackage 的 mypackage 包:
package main
import (
"fmt"
"github.com/devopsforgo/mypackage"
)
需要注意的是,文件名不是包路径的一部分,只是目录路径的一部分。
使用包
一旦你导入了一个包,你可以通过在你想要访问的内容前加上包名和一个点,开始访问该包中声明的函数、类型或变量。
例如,fmt 包中有一个名为 Println() 的函数,可以用来打印一行到 stdout。如果我们想使用它,代码就像下面这样简单:
fmt.Println("Hello!")
包名冲突
假设你有两个名为 mypackage 的包。它们的名称相同,因此我们的程序无法判断我们在引用哪个包。你可以将包的导入重命名为任何你想要的名字:
import(
"github.com/devopsforgo/mypackage"
jpackage "github.com/johnsiilver/mypackage"
)
jpackage 声明在这个包中,我们将 github.com/johnsiilver/mypackage 称为 jpackage。
这个功能使我们能够像下面这样使用两个同名的包:
mypackage.Print()
jpackage.Send()
现在,我们来看一下与包相关的一个重要规则,这个规则能改善编译时间和二进制文件大小。
包必须被使用
让我们向你介绍以下规则:如果你导入了一个包,你必须使用它。
Go 的作者注意到,谷歌使用的许多其他编程语言常常有未使用的导入包。
这导致了编译时间比实际需要的更长,在某些情况下,二进制文件的大小比要求的要大得多。Python 文件会打包成专有格式以便在生产环境中传输,其中一些未使用的导入会增加几百兆字节的文件大小。
为了避免这些问题,Go 不会编译导入了一个包但没有使用它的程序,如下所示:
package main
import (
"fmt"
"sync"
)
func main() {
fmt.Println("Hello, playground")
}
上面的代码输出如下内容:
./prog.go:5:2: imported and not used: "sync"
在某些罕见情况下,你可能需要做一个 副作用 导入,即仅仅加载该包就会引发某些事情发生,但你并不直接使用该包。这应该 总是 在 package main 中完成,并且需要用下划线 (_) 前缀:
package main
import (
"fmt"
_ "sync" //Just an example
)
func main() {
fmt.Println("Hello, playground")
}
接下来,我们将声明一个主包并讨论编写 Go 程序的基本知识,程序将导入一个包。
一个 Go 的 Hello World
让我们写一个简单的 Hello World 程序,类似于 Go Playground 中的默认程序。这个示例将演示以下内容:
-
声明一个包
-
从标准库中导入
fmt包,它可以将内容打印到我们的屏幕上 -
声明一个程序的
main()函数 -
使用
:=运算符声明一个字符串变量 -
打印变量到屏幕
让我们看看这是什么样子的:
1 package main
2
3 import "fmt"
4
5 func main() {
6 hello := "Hello World!" fmt.Println(hello)
7
8 }
在第一行中,我们使用 package 关键字声明了我们的包名。任何 Go 二进制文件的入口点都是一个名为 main 的包,其中有一个名为 main() 的函数。
在第三行,我们导入了 fmt 包。fmt 包含一些函数,用于做字符串格式化和写入各种输出。
在第五行,我们声明了一个名为 main 的函数,它不接收任何参数,也不返回任何值。main() 是特殊的,因为当二进制文件运行时,它会从运行 main() 函数开始。
Go 使用 {} 来标明函数的开始和结束位置(类似于 C 语言)。
第六行使用 := 操作符声明了一个名为 hello 的变量。这个操作符表示我们希望在一行代码中创建一个新变量并为其赋值。这是最常见的声明变量的方式,但不是唯一的方式。
由于 Go 是类型化的,:= 将根据值来推断变量的类型。在这种情况下,它将是一个字符串,但如果值是 3,则会是 int 类型,如果是 2.4,则会是 float64 类型。如果我们想声明一个特定类型,比如 int8 或 float32,我们需要做一些修改(稍后我们会讨论)。
在第七行,我们调用了 fmt 包中的一个名为 Println 的函数。Println() 将打印 hello 变量的内容到 stdout,并附加一个换行符(\n)。
你会注意到,使用另一个包中声明的函数的方式是使用包名(不带引号) + 一个句点 + 函数名。在这个例子中,就是 fmt.Println()。
在这一部分,你已经学会了如何声明一个包、导入一个包、main 包的功能是什么,以及如何编写一个基本的 Go 程序并声明变量。在下一部分,我们将深入探讨如何声明和使用变量。
使用 Go 的变量类型
现代编程语言是基于称为类型的原始数据类型构建的。当你听到某个变量是 字符串 或 整数 时,你是在谈论变量的类型。
在当今的编程语言中,有两种常见的类型系统:
-
动态类型(也称为鸭子类型)
-
静态类型
Go 是一种静态类型语言。对于许多可能来自 Python、Perl 和 PHP 等语言的开发者来说,这些语言是动态类型语言。
在动态类型语言中,你可以创建一个变量并存储任何内容。在这些语言中,类型仅表示存储在变量中的内容。这里是一个 Python 的示例:
v = "hello"
v = 8
v = 2.5
在这种情况下,v 可以存储任何内容,而 v 持有的类型在没有使用运行时检查的情况下是未知的(运行时意味着它无法在编译时检查)。
在静态类型语言中,变量的类型在创建时就已确定,并且该类型不能更改。在这种语言中,类型既表示变量中存储的内容,也表示可以存储的内容。这里是 Go 的示例:
v := "hello" // also can do: var v string = "hello"
v 的值不能被设置为除了字符串之外的其他类型。
可能看起来 Python 更优,因为它可以在变量中存储任何内容。但实际上,这种不具特定性的缺点是,Python 必须等到程序运行时才能发现问题(我们称之为运行时错误)。与其在软件部署后才发现问题,最好在编译时就能找出问题。
让我们以一个函数示例来加法运算两个数字。
这是 Python 版本的示例:
def add(a, b):
return a+b
这是 Go 版本的示例:
func add(a int, b int) int {
return a + b
}
在 Python 版本中,我们可以看到 a 和 b 会被加在一起。但是,a 和 b 是什么类型呢?结果类型是什么?如果我传递一个整数和一个浮点数,或者一个整数和一个字符串,会发生什么?
在某些情况下,Python 中的两种类型不能相加,这将导致运行时异常,而且你永远无法确定结果类型会是什么。
注意
Python 已经为语言添加了 类型提示,以帮助避免这些问题。但实践经验告诉我们,像 JavaScript/Dart/TypeScript/Closure 这样的语言,虽然类型支持有时能提供帮助,但可选的类型支持意味着许多问题会被忽视。
我们的 Go 版本为参数和结果定义了确切的类型。你不能传递整数和浮点数,或者整数和字符串。你只会收到整数作为返回值。这允许我们的编译器在程序编译时发现任何类型错误。在 Python 中,这种错误可能会在任何时间出现,从程序运行的瞬间到 6 个月后,当某个代码路径被执行时才会发现。
注意
几年前,曾经进行过一项关于 Rosetta Code 仓库的研究,评估了几种主要编程语言在处理时间、内存使用和运行时故障方面的表现。在运行时故障方面,Go 的故障最少,Python 排名靠后。静态类型系统显然在其中起了作用。
该研究可以在此找到:arxiv.org/pdf/1409.0252.pdf。
Go 的类型
Go 拥有丰富的类型系统,不仅指定类型可能是整数,还指定了整数的大小。这使得 Go 程序员能够减少变量在内存中的大小,并在进行网络传输时进行编码。
下表显示了 Go 中最常用的类型:

表 1.1 – Go 中常用类型及其描述
我们将主要讨论上述类型;不过,以下表格列出了可以使用的类型的完整列表:

表 1.2 – Go 中可用类型的完整列表
Go 不仅提供这些基本类型;你还可以基于这些基本类型创建新的类型。这些自定义类型会成为自己的类型,并可以附加方法。
声明自定义类型使用 type 关键字,并将在讨论 struct 类型时讲解。目前,我们将继续讨论声明变量的基本知识。
现在我们已经讨论了变量类型,让我们看看如何创建它们。
声明变量
与大多数语言一样,声明变量会分配存储空间,用于存储某种类型的数据。在 Go 中,这些数据是有类型的,因此只能存储该类型的数据。由于 Go 提供了多种声明变量的方式,接下来的部分将讨论这些不同的声明方式。
声明变量的长方式
声明变量最具体的方式是使用var关键字。你可以在包级(即不在函数内部)和函数内部使用var声明变量。让我们来看一些使用var声明变量的示例:
var i int64
这声明了一个i变量,它可以保存int64类型的值。由于没有赋值,因此它被赋予了整数的零值,即0:
var i int = 3
这声明了一个i变量,它可以保存int类型的值。值3被赋给了i。
注意,int和int64类型是不同的。你不能将int类型作为int64类型使用,反之亦然。然而,你可以进行类型转换以允许这两种类型的互换。稍后会讨论这个话题:
var (
i int
word = "hello"
)
使用(),我们将一组声明放在一起。i可以保存int类型,并且其整数零值是0。word没有声明类型,但它的类型由右侧等号(=)运算符中的字符串值推断出来。
更简洁的方式
在前面的示例中,我们使用var关键字来创建变量,并使用=运算符赋值。如果没有=运算符,编译器会为该类型分配零值(稍后会详细讲解)。
重要的概念如下:
-
var创建了变量,但没有赋值。 -
=将一个值赋给变量。
在函数内(而不是在包级别),我们可以通过使用:=运算符进行创建并赋值。这既创建了一个新变量,又赋值给它:
i := 1 // i is the int type
word := "hello" // word is the string type
f := 3.2 // f is the float64 type
使用:=时需要记住的重要一点是,它意味着创建并赋值。如果变量已经存在,不能使用:=,而必须使用=,它仅仅是进行赋值操作。
变量作用域和变量遮蔽
作用域是程序中变量可见的部分。在 Go 语言中,我们有以下几种变量作用域:
-
包级作用域:可以被整个包访问,在函数外部声明
-
定义函数的
{} -
函数中的语句
{}(如for循环、if/else)
在下面的程序中,word变量在包级声明。它可以被包内定义的任何函数使用:
package main
import "fmt"
var word = "hello"
func main() {
fmt.Println(word)
}
在下面的程序中,word变量在main()函数内定义,只能在定义main的{}内使用。它在外部是未定义的:
package main
import "fmt"
func main() {
var word string = "hello"
fmt.Println(word)
}
最后,在这个程序中,i是语句作用域的。它可以在启动for循环的那一行和循环中的{}内使用,但在循环外不存在:
package main
import "fmt"
func main() {
for i := 0; i < 10; i++ {
fmt.Println(i)
}
}
最好的理解方式是,如果你的变量声明在包含{的那一行或位于一组{}内,那么它只能在这些{}内看到。
不能在同一作用域中重新声明一个变量
这一规则是,在同一作用域内不能声明两个同名的变量。
这意味着在同一作用域内,两个变量不能有相同的名字:
func main() {
var word = "hello"
var word = "world"
fmt.Println(word)
}
这个程序是无效的,会生成编译错误。一旦声明了word变量,你不能在相同的作用域内重新创建它。你可以将其值更改为新的值,但不能创建第二个同名的变量。
要给word赋新值,只需从这一行中去掉var。var表示在我们只想做赋值的地方创建变量:
func main() {
var word = "hello"
word = "world"
fmt.Println(word)
}
接下来,我们将看看在相同作用域内,但在不同代码块中声明两个相同名称的变量时会发生什么。
变量遮蔽
变量遮蔽发生在一个变量在你的变量作用域内,但不在你的局部作用域内时被重新声明。这导致局部作用域无法访问外部作用域的变量:
package main
import "fmt"
var word = "hello"
func main() {
var word = "world"
fmt.Println("inside main(): ", word)
printOutter()
}
func printOutter() {
fmt.Println("the package level 'word' var: ", word)
}
如你所见,word在包级别声明。但在main内部,我们定义了一个新的word变量,它遮蔽了包级别的变量。当我们现在引用word时,我们使用的是在main()中定义的那个。
调用了printOutter(),但它没有一个局部遮蔽的word变量(即在其{}之间声明的变量),因此使用了包级别的word变量。
这是该程序的输出:
inside main(): world
the package level 'word' var: hello
这是 Go 开发者中比较常见的一个 bug。
零值
在一些旧语言中,未赋值的变量声明具有未知的值。这是因为程序创建了一个内存位置来存储该值,但没有向其中放入任何东西。所以,表示该值的位被设置为在你创建变量之前,该内存空间中随机存在的内容。
这已经导致了许多不幸的 bug。因此,在 Go 中,声明一个变量但不进行赋值时,会自动赋一个被称为零值的值。以下是 Go 类型的零值列表:

表 1.3 – Go 类型的零值
现在我们了解了什么是零值,让我们来看一下 Go 如何在我们的代码中防止未使用的变量。
函数/语句变量必须被使用
这里的规则是,如果你在函数或语句中创建一个变量,它必须被使用。这与包导入的原因差不多;声明一个没有使用的变量几乎总是一个错误。
这种写法可以像导入一样放宽,使用_,但这种情况远不如前者常见。这将someVar中存储的值赋给了一个什么也不使用的变量:
_ = someVar
这将someFunc()返回的值赋给了什么也不使用的东西:
_ = someFunc()
这种用法最常见的场景是当一个函数返回多个值,但你只需要其中一个:
needed, _ := someFunc()
这里,我们创建并赋值给needed变量,但第二个值是我们不使用的,所以我们将其丢弃。
本节提供了 Go 的基本类型知识、不同的变量声明方式、变量作用域和遮蔽规则,以及 Go 的零值。
Go 中的循环
大多数语言都有几种不同类型的循环语句:for、while和do while。
Go 语言不同之处在于,只有一种循环类型,即for,它可以实现其他语言中所有类型的循环功能。
在这一节中,我们将讨论for循环及其多种用法。
C 风格
循环的最基本形式类似于 C 语言的语法:
for i := 0; i < 10; i++ {
fmt.Println(i)
}
这声明了一个i变量,它是一个整数,只在这个循环语句中有效。i := 0;是循环初始化语句;它只在循环开始前执行一次。i < 10;是条件语句;它在每次循环开始时执行,必须评估为true,否则循环结束。
i++是post语句;它会在每次循环结束时执行。i++表示将i变量增加1。Go 语言也有常见的语句,如i += 1和i--。
移除init语句
我们不需要init语句,如下面的例子所示:
var i int
for ;i < 10;i++ {
fmt.Println(i)
}
fmt.Println("i's final value: ", i)
在这里,我们在循环外部声明了i。这意味着i将在循环结束后仍然可以在外部访问,而不像我们之前的例子那样只能在循环内访问。
也可以去掉post语句,这样就变成了一个while循环
许多语言有while循环,用来简单地判断某个语句是否为真。我们可以通过去掉init和post语句来实现相同的功能:
var i int
for i < 10 {
i++
}
b := true
for b { // This will loop forever
fmt.Println("hello")
}
你可能会问,我们如何创建一个永远运行的循环? for循环可以解决这个问题。
创建一个无限循环
有时候你希望一个循环永远运行,或者直到循环内部的某些条件满足。创建一个无限循环只需去掉所有语句:
for {
fmt.Println("Hello World")
}
这通常对一些需要永远处理输入流的服务器很有用。
循环控制
使用循环时,你有时需要从循环内部控制循环的执行。这可能是因为你想退出循环,或者停止当前迭代并从顶部重新开始。
这是一个循环的示例,我们调用一个名为doSomething()的函数,如果循环需要结束,doSomething()将返回一个错误。在这个例子中,doSomething()做什么并不重要:
for {
if err := doSomething(); err != nil {
break
}
fmt.Println("keep going")
}
这里的break函数将会跳出循环。break也用于跳出其他语句,例如select或switch,所以重要的是要知道,break会跳出它所在的第一个语句。
如果我们希望在某个条件下停止循环并继续执行下一次循环,可以使用continue语句:
for i := 0; i < 10; i++ {
if i % 2 == 0 { // Only 0 for even numbers
continue
}
fmt.Println("Odd number: ", i)
}
这个循环将打印出从零到九的奇数。i % 2表示i 对 2 取模。取模操作是将第一个数字除以第二个数字,并返回余数。
循环的大括号
这个规则的介绍是:for循环的左大括号必须与for关键字位于同一行。
在许多编程语言中,关于循环/条件语句的大括号应该放在哪里一直存在争议。而在 Go 语言中,作者通过编译器检查来预防这些争论。在 Go 语言中,你可以这样做:
for {
fmt.Println("hello world")
}
然而,下面的写法是错误的,因为for循环的左大括号单独占一行:
for
{
fmt.Println("hello world")
}
在这一部分,我们学会了使用for循环作为 C 风格循环,也作为while循环使用。
使用条件语句
Go 支持两种类型的条件语句,如下所示:
-
if/else块 -
switch块
标准的if语句与其他语言类似,额外增加了一个可选的init语句,它是从标准 C 风格的for循环语法中借用来的。
switch语句提供了一个有时更简洁的替代方案来替代if。所以,让我们来深入了解if条件语句。
if 语句
if语句以一个在大多数语言中都能识别的熟悉格式开始:
if [expression that evaluates to boolean] {
...
}
这是一个简单的例子:
if x > 2 {
fmt.Println("x is greater than 2")
}
如果x的值大于2,if中的{}内的语句将被执行。
与大多数语言不同,Go 有能力在进行条件判断之前,在if作用域内执行一条语句:
if [init statement];[statement that evaluates to boolean] {
...
}
这是一个简单的例子,类似于for循环中的初始化语句:
if err := someFunction(); err != nil {
fmt.Println(err)
}
在这里,我们初始化了一个名为err的变量。它的作用域是if块。如果err变量不等于nil值(一个特殊的值,表示某些类型未被设置——稍后会详细介绍),它将打印出错误。
else
如果你需要在if语句的条件不满足时执行某些操作,可以使用else关键字:
if condition {
function1()
}else {
function2()
}
在这个例子中,如果if条件为真,function1将被执行。否则,function2将被执行。
应该注意的是,大多数情况下,else的使用可以被省略,以获得更简洁的代码。如果你的if条件通过使用return关键字从函数返回,你可以省略else。
下面是一个例子:
if v, err := someFunc(); err != nil {
return err
}else{
fmt.Println(v)
return nil
}
这可以简化为以下内容:
v, err := someFunc()
if err != nil {
return err
}
fmt.Println(v)
return nil
有时,你只希望在if条件不满足而另一个条件满足时才执行代码。我们接下来来看看这种情况。
else if
一个if块也可以包含else if,提供多层次的执行。第一个匹配的if或else if语句将被执行。
注意,Go 开发者通常选择使用switch语句作为这种类型条件语句的更简洁版本。
下面是一个例子:
if x > 0 {
fmt.Println("x is greater than 0")
} else if x < 0 {
fmt.Println("x is less than 0")
} else{
fmt.Println("x is equal to 0")
}
现在我们已经看到了这个条件语句的基础,接下来我们需要讨论一下大括号风格。
if/else 的大括号
现在是时候介绍这个规则了:if/else的左大括号必须与相关的关键字在同一行。如果链中有其他语句,它必须与前一个右大括号在同一行开始。
在许多语言中,关于循环/条件语句的大括号放置位置有很多争论。
在 Go 语言中,作者决定通过编译器检查来预防这些问题。在 Go 中,你不能这样做:
if x > 0
{ // This must go up on the previous line
fmt.Println("hello")
}
else { // This line must start on the previous line
fmt.Println("world")
}
所以,随着 Go 中关于大括号风格的争论已经解决,让我们来看看替代if/else的一个选项——switch语句。
switch 语句
switch语句比if/else块更优雅,它在使用上非常灵活。它可以用于精确匹配和多个真假评估。
精确匹配的 switch
以下是一个精确匹配的switch:
switch [value] {
case [match]:
[statement]
case [match], [match]:
[statement]
default:
[statement]
}
[value]会与每个case语句进行匹配。如果匹配,case语句就会执行。与某些语言不同,一旦匹配成功,其他的case将不会再被考虑。如果没有匹配,default语句就会执行。default语句是可选的。
这种语法比if/else更简洁,适合处理值可以是多个值的情况:
switch x {
case 3:
fmt.Println("x is 3")
case 4, 5: // executes if x is 4 or 5
fmt.Println("x is 4 or 5")
default:
fmt.Println("x is unknown")
}
switch也可以有一个init语句,类似于if语句:
switch x := someFunc(); x {
case 3:
fmt.Println("x is 3")
}
真/假评估开关
我们还可以省略[match],这样每个case语句就不再是精确匹配,而是一个真/假评估(就像if语句一样):
switch {
case x > 0:
fmt.Println("x is greater than 0")
case x < 0:
fmt.Println("x is less than 0")
default:
fmt.Println("x must be 0")
}
在本节结束时,你应该能够使用 Go 的条件语句根据某些标准在程序中分支代码执行,并处理没有匹配语句的情况。由于条件语句是软件的标准构建块之一,我们将在接下来的许多章节中使用它们。
学习函数
Go 中的函数符合现代编程语言的预期。使 Go 函数与众不同的只有少数几个特性:
-
支持多个返回值
-
可变参数
-
命名返回值
基本的函数签名如下:
func functionName([varName] [varType], ...) ([return value], [return value], ...){
}
让我们创建一个基本的函数,它将两个数字相加并返回结果:
func add(x int, y int) int {
return x + y
}
如你所见,这个函数接收两个整数,x和y,将它们相加并返回结果(这是一个整数)。让我们展示如何调用这个函数并打印它的输出:
result := add(2, 2)
fmt.Println(result)
我们可以通过使用单一的int关键字来简化函数签名,声明x和y的类型:
func add(x, y int) int {
return x + y
}
这与之前的内容是等效的。
返回多个值和命名结果
在 Go 中,我们可以返回多个值。例如,考虑一个将两个整数相除并返回两个变量(结果和余数)的函数,如下所示:
func divide(num, div int) (res, rem int) {
result = num / div
remainder = num % div
return res, rem
}
这段代码演示了我们函数中的一些新特性:
-
参数
num是被除数 -
参数
div是被除数 -
返回值
res是除法的结果 -
返回值
rem是除法的余数
第一个是res和rem。这些变量会自动创建,并且可以在函数内使用。
注意,我在为这些变量赋值时使用的是=而不是:=。这是因为这些变量已经存在,我们要做的是赋值(=)。:=表示创建并赋值,它只适用于创建不存在的新变量。你还会注意到,返回类型现在放在括号中。如果你使用多个返回值或命名返回值(或者在此情况中,两者都有),你将需要使用括号。
调用这个函数和之前调用add()一样简单,如下所示:
result, remainder := divide(3, 2)
fmt.Printf("Result: %d, Remainder %d", result, remainder)
严格来说,你不必使用return来返回值。然而,使用它会防止一些你最终会遇到的难看的错误。
接下来,我们将看看如何使函数接受可变数量的参数,从而创建像 fmt.Println() 这样的函数,你在本章中已经使用过它。
变参
0 到无限个参数。一个好的例子是计算整数的总和。如果没有变参,你可能会使用一个切片(可增长的数组类型,我们稍后会谈到),如下所示:
func sum(numbers []int) int {
sum := 0
for _, n := range numbers {
sum += n
}
return sum
}
尽管这样做是可以的,但使用起来有些繁琐:
args := []int{1,2,3,4,5}
fmt.Println(sum(args))
我们可以使用变参(...)符号完成相同的事情:
func sum(numbers ...int) int {
// Same code
}
numbers 仍然是 []int,但具有一种更优雅的调用约定:
fmt.Println(sum(1,2,3,4,5))
注意
你可以将变参与其他参数一起使用,但它必须是函数中的最后一个参数。
匿名函数
Go 具有匿名函数的概念,即没有名称的函数(也称为函数闭包)。
这对于利用一些特殊语句非常有用,这些语句尊重函数边界,例如 defer 或 goroutines。我们稍后将展示如何在 goroutines 中利用这些语句,但现在我们先展示如何执行匿名函数。这是一个人为的示例,仅用于教学概念:
func main() {
result := func(word1, word2 string) string {
return word1 + " " + word2
}("hello", "world")
fmt.Println(result)
}
这段代码执行了以下操作:
-
定义一个一次性函数(
func(word1, word2 string) string) -
使用
hello和world参数执行该函数 -
将
string返回值赋给result变量 -
打印
result
现在,我们已经到达了这一部分的结尾,我们学习了如何声明 Go 函数,如何使用多个返回值,如何简化函数调用的变参,以及匿名函数。多个返回值在后续章节中处理错误时非常重要,而匿名函数是我们未来 defer 语句和并发使用的关键组件。
在下一部分,我们将探讨公共和私有类型。
定义公共和私有
许多现代编程语言在声明常量/变量/函数/方法时提供了一组选项,详细说明了何时可以调用某个方法。
Go 将这些可见性选择简化为两种类型:
-
公共(已导出)
-
私有(未导出)
公共类型是可以在包外引用的类型。私有类型只能在包内引用。为了使常量/变量/函数/方法为公共,必须以大写字母开头。如果以小写字母开头,它就是私有的。
还有第三种可见性类型我们没有在这里讨论:internal/。这些包只能被父目录中的其他包使用。你可以在这里阅读相关内容:golang.org/doc/go1.4#internalpackages。
让我们声明一个包并创建一些公共和私有方法:
package say
import "fmt"
func PrintHello() {
fmt.Println("Hello")
}
func printWorld() {
fmt.Println("World")
}
func PrintHelloWorld() {
PrintHello()
printWorld()
}
我们有三个函数调用,其中两个是公共的(PrintHello() 和 PrintHelloWorld()),一个是私有的(printWorld())。现在,让我们创建 package main,导入 say 包,并调用我们的函数:
package main
import "github.com/repo/examples/say"
func main() {
say.PrintHello()
say.PrintHelloWorld()
}
现在,让我们编译并运行它:
$ go run main.go
Hello
Hello
World
之所以有效,是因为 PrintHello() 和 PrintHelloWorld() 都是 PrintHelloWorld() 调用了私有的 printWorld(),但这是合法的,因为它们位于同一个包中。
如果我们尝试将 say.printWorld() 添加到 func main() 中并运行,我们将得到以下结果:
./main.go:8:2: cannot refer to unexported name say.printWorld
公共和私有作用域适用于在函数/方法和类型声明外声明的变量。
到这节结束时,你已经掌握了 Go 语言的公共和私有类型。这将在你不希望在公共 API 中暴露类型的代码中非常有用。接下来,我们将学习数组和切片。
使用数组和切片
编程语言需要比基本类型更多的类型来存储数据。array 类型是底层语言中的核心构建块之一,提供基础的顺序数据类型。对于大多数日常使用,Go 的 slice 类型提供了一种灵活的数组,它可以根据数据需求增长,并且可以被切分成多个部分,以便共享数据的视图。
在这一节中,我们将讨论数组作为切片的构建块,二者的区别以及如何在代码中使用它们。
数组
Go 语言中的基础顺序类型是数组(这很重要,但很少使用)。数组的大小是静态的(如果你创建一个可以容纳 10 个 int 类型的数组,它将始终容纳恰好 10 个 int 类型)。
Go 提供了一个 array 类型,方法是在你希望创建数组的类型前加上 [size]。例如,var x [5]int 或 x := [5]int{} 创建一个包含五个整数的数组,索引从 0 到 4。
向数组赋值像选择索引一样简单。x[0] = 3 将 3 赋给索引 0。检索该值同样简单,只需引用该索引;fmt.Println(x[0] + 2) 将输出 5。
数组与切片不同,不是指针包装类型。将数组作为函数参数传递时会传递一个副本:
func changeValueAtZeroIndex(array [2]int) {
array[0] = 3
fmt.Println("inside: ", array[0]) // Will print 3
}
func main() {
x := [2]int{}
changeValueAtZeroIndex(x)
fmt.Println(x) // Will print 0
}
数组在 Go 语言中存在以下两个问题:
-
数组的类型由大小决定——
[2]int与[3]int是不同的。在需要[2]int的地方不能使用[3]int。 -
数组的大小是固定的。如果你需要更多空间,必须创建一个新的数组。
虽然了解数组是什么很重要,但在 Go 语言中最常用的顺序类型是切片。
切片
理解切片的最简单方法是将其看作是一种构建在数组之上的类型。切片是对数组的视图。在切片的视图中改变你能看到的内容会改变底层数组的值。切片的最基本用途像数组一样,但有两个例外:
-
切片不是静态大小的。
-
切片可以增长以容纳新的值。
切片会追踪它的数组,当需要更多空间时,它会创建一个新数组来容纳新值,并将当前数组中的值复制到新数组中。这个过程对用户是不可见的。
创建一个切片与创建数组类似,var x = []int或x := []int{}。这会创建一个长度为0的整数切片(没有空间存储值)。你可以使用len(x)来获取切片的大小。
我们可以轻松创建一个具有初始值的切片:x := []int{8,4,5,6}。现在,我们的len(x) == 4,索引范围从0到3。
类似于数组,我们可以通过简单地引用索引来更改某个值。x[2] = 12会将前面的切片改为[]int{8,4,12,6}。
与数组不同,我们可以使用append命令向切片中添加新值。x = append(x, 2)将导致底层的x数组引用被复制到一个新的数组中,并将新的数组视图返回给x。新值为[]int{8,4,12,6,2}。你也可以通过在append中添加多个用逗号分隔的值来追加多个值(例如,x = append(x, 2, 3, 4, 5))。
记住,切片只是数组的视图。我们可以创建数组的新有限视图。y := x[1:3]创建了数组的一个视图(y),返回[]int{4, 12}(1是包含的,3是不包含的,即[1:3])。更改y[0]的值会改变x[1]。通过y = append(y, 10)将一个新值附加到y,这会改变x[3],结果是[]int{8,4,12,10,2}。
这种用法并不常见(而且容易混淆),但重要的是要理解,切片只是数组的视图。
虽然切片是一个指针封装类型(传递给函数并更改切片中的值也会在调用者中发生变化),切片的视图本身不会发生改变。
func doAppend(sl []int) {
sl = append(sl, 100)
fmt.Println("inside: ", sl) // inside: [1 2 3 100]
}
func main() {
x := []int{1, 2, 3}
doAppend(x)
fmt.Println("outside: ", x) // outside: [1 2 3]
}
在这个例子中,sl和x变量都使用相同的底层数组(在两者中都发生了变化),但x的视图并没有在doAppend()中更新。要更新x以查看切片的变化,需要传递切片的指针(指针将在后续章节中讲解)或者像这里一样返回新的切片:
func doAppend(sl []int) []int {
return append(sl, 100)
}
func main() {
x := []int{1, 2, 3}
x = doAppend(x)
fmt.Println("outside: ", x) // outside: [1 2 3 100]
}
现在你已经了解了如何创建和添加到切片,接下来我们来看如何提取切片中的值。
提取所有值
要从切片中提取值,我们可以使用旧的 C 型for循环或更常见的for...range语法。
旧的 C 风格如下:
for i := 0; i < len(someSlice); i++{
fmt.Printf("slice entry %d: %s\n", i, someSlice[i])
}
Go 中更常见的方法是使用range:
for index, val := range someSlice {
fmt.Printf("slice entry %d: %s\n", index, val)
}
使用range时,我们通常只想使用值,而不关心索引。在 Go 中,你必须使用在函数中声明的变量,否则编译器会报错,提示如下:
index declared but not used
为了只提取值,我们可以使用_,(这告诉编译器不要存储输出),如下所示:
for _, val := range someSlice {
fmt.Printf("slice entry: %s\n", val)
}
在非常罕见的情况下,您可能只想打印出索引而不是值。这是不常见的,因为它只会从零开始计数到项目数。然而,这可以通过简单地从for语句中删除val来实现:for index := range someSlice。
在本节中,您已经了解了数组是什么,如何创建它们以及它们与切片的关系。此外,您已经掌握了创建切片、向切片添加数据和从切片中提取数据的技能。接下来让我们学习一下关于映射的知识。
理解映射
映射是用户可以使用的一组键值对,用于存储一些数据并使用键检索它。在某些语言中,这些被称为字典(Python)或哈希(Perl)。与数组/切片不同,映射中查找条目只需单个查找而不是迭代整个切片比较值。对于大量项目,这可以节省大量时间。
声明映射
有几种声明映射的方法。让我们首先看看使用make:
var counters = make(map[string]int, 10)
刚刚分享的示例创建了一个具有string键并存储数据类型为int的映射。10表示我们要为 10 个条目预设大小。映射可以超过 10 个条目,而10可以省略。
另一种声明映射的方法是使用复合文字:
modelToMake := map[string]string{
"prius": "toyota",
"chevelle": "chevy",
}
这将创建一个具有string键并存储string数据的映射。我们还会预先填充两个键值对的条目。您可以省略条目以获得空映射。
访问值
您可以按以下方式检索值:
carMake := modelToMake["chevelle"]
fmt.Println(carMake) // Prints "chevy"
这将把chevy的值赋给carMake。
但是如果键不在映射中会发生什么呢?在这种情况下,我们将收到数据类型的零值:
carMake := modelToMake["outback"]
fmt.Println(carMake)
上述代码将打印一个空字符串,这是作为我们映射中值使用的字符串类型的零值。
我们还可以检测值是否在映射中:
if carMake, ok := modelToMake["outback"]; ok {
fmt.Printf("car model \"outback\" has make %q", carMake)
}else{
fmt.Printf("car model \"outback\" has an unknown make")
}
这里我们分配了两个值。第一个(carMake)是存储在键中的数据(如果未设置,则为零值),第二个(ok)是一个布尔值,指示是否找到了键。
添加新值
添加新的键值对或更新键的值,方式是相同的:
modelToMake["outback"] = "subaru"
counters["pageHits"] = 10
现在我们可以更改键值对,让我们看看如何从映射中提取值。
提取所有值
要从映射中提取值,我们可以使用我们用于切片的for...range语法。与映射相关的有几个关键区别:
-
您将获得映射的键而不是索引。
-
映射具有非确定性顺序。
非确定性顺序意味着迭代数据将返回相同的数据,但顺序不同。
让我们打印出我们的carMake映射中的所有值:
for key, val := range modelToMake {
fmt.Printf("car model %q has make %q\n", key, val)
}
这将产生以下结果,但可能顺序不同:
car model "prius" has make "toyota"
car model "chevelle" has make "chevy"
car model "outback" has make "subaru"
注意
与切片类似,如果您不需要键,可以使用_。如果只需键,可以省略值val变量,例如for key := range modelToMake。
在本节中,你已经了解了map类型,如何声明它们,如何向其中添加值,最后如何从中提取值。现在让我们深入学习指针。
理解 Go 中的指针
dict、list和object类型是引用类型。
在本节中,我们将讲解指针是什么,如何声明它们,以及如何使用它们。
内存地址
在之前的章节中,我们讨论了用于存储某种类型数据的变量。例如,如果我们想创建一个名为x的变量来存储一个值为23的int类型,可以写作var x int = 23。
在幕后,内存分配器为我们分配了存储值的空间。这个空间通过一个唯一的内存地址来引用,看起来像0xc000122020。这有点类似于一个家庭地址;它是数据所在位置的引用。
我们可以通过在变量名之前加上&来查看变量存储的内存地址:
fmt.Println(&x)
这会打印出0xc000122020,即x存储的内存地址。
这引出了一个重要的概念:函数总是会复制传入的参数。
函数参数是副本
当我们调用一个函数并将一个变量作为函数参数传递时,函数内部得到的是该变量的副本。这个概念很重要,因为当你修改变量时,实际上只是在修改函数内的副本。
func changeValue(word string) {
word += "world"
}
在这段代码中,word是传入值的副本。word会在函数调用结束时不再存在。
func main() {
say := "hello"
changeValue(say)
fmt.Println(say)
}
这会打印出"hello"。将字符串传递并在函数中更改它不起作用,因为在函数内部我们操作的是副本。可以把每次函数调用看作是用复印机复制变量。编辑复印机出来的副本不会影响原始的变量。
指针来救场
Go 中的指针是存储值地址的类型,而不是值本身。所以,指针存储的是像0xc000122020这样的内存地址,而不是直接存储23,23在内存中的存储位置就是这个地址。
指针类型可以通过在类型名前加上*来声明。如果我们想创建一个intPtr变量来存储指向int的指针,可以这样做:
var intPtr *int
你不能将int存储在intPtr中;你只能存储int的地址。要获取一个现有int的地址,可以在表示int的变量前使用&符号。
让我们将intPtr赋值为之前x变量的地址:
intPtr = &x
intPtr now stores 0xc000122020\.
现在是大问题,这有什么用? 通过指针,我们可以引用内存中的一个值并改变它。我们通过对变量使用*操作符来实现这一点。
我们可以通过解引用指针来查看或更改存储在x中的值。下面是一个示例:
fmt.Println(x) // Will print 23
fmt.Println(*intPtr) // Will print 23, the value at x
*intPtr = 80 // Changes the value at x to 80
fmt.Println(x) // Will print 80
这在函数之间也同样适用。让我们修改changeValue()使其与指针一起工作:
func changeValue(word *string) {
// Add "world" to the string pointed to by 'word'
*word += "world"
}
func main() {
say := "hello"
changeValue(&say) // Pass a pointer
fmt.Println(say) // Prints "helloworld"
}
注意,像 * 这样的操作符被称为 * 表示指针类型,var intPtr *int。当用于变量时,* 表示解引用,fmt.Println(*intPtr)。当用于两个数字之间时,它表示乘法,y := 10 * 2。需要时间去记住在某些上下文中符号的含义。
但是,你不是说每个参数都是副本吗?!
我确实理解了。当你将指针传递给函数时,会创建指针的副本,但副本仍然持有相同的内存地址。因此,它仍然引用相同的内存。这就像是用复印机复制一张藏宝图;副本仍然指向你能找到宝藏的地方。你们中的一些人可能在想,但是地图和切片可以修改它们的值,那怎么回事?
它们是一种特殊类型,叫做 指针包装 类型。指针包装类型隐藏了内部的指针。
不要过于疯狂地使用指针
虽然在我们的例子中我们使用了基础类型的指针,但通常指针用于长生命周期的对象或存储大量数据的地方,因为复制这些数据非常昂贵。Go 的内存模型使用栈/堆模型。栈内存是为函数/方法调用专门创建的。栈上的分配比在堆上分配要快得多。
堆分配发生在 Go 中,当一个引用或指针无法被确定仅在函数调用栈中生存时。编译器通过逃逸分析来确定这一点。
通常,将副本通过参数传递到函数中并返回另一个副本,比使用指针要便宜得多。最后,要小心指针的数量。与 C 语言不同,在 Go 中很少看到指向指针的指针,比如 **someType,而且在超过 10 年的 Go 编码经验中,我只见过一次有效的 ***someType 用法。与电影《盗梦空间》不同,实际上没有理由深入下去。
总结这一部分,你已经理解了指针、如何声明指针、如何在代码中使用它们以及你可能应该在哪里使用它们。你将使用它们在长生命周期的对象或存储大量数据的类型中,因为复制这些数据的成本很高。接下来,让我们探索结构体。
了解结构体
string、int 和 float64)被归为一组。这个分组在 Go 中就是一个结构体。
声明一个结构体
有两种方法可以声明一个结构体。第一种方法在测试中比较常见,因为它不允许我们重用结构体的定义来创建更多的变量。但是,正如我们稍后在测试中看到的那样,我们在这里也会涵盖它:
var record = struct{
Name string
Age int
}{
Name: "John Doak",
Age: 100, // Yeah, not publishing the real one
}
在这里,我们创建了一个包含两个字段的结构体:
-
Name(string) -
Age(int)
然后我们创建了该结构体的一个实例,并设置了这些字段的值。要访问这些字段,我们可以使用点(.)操作符:
fmt.Printf("%s is %d years old\n", record.Name, record.Age)
这将输出 "John Doak is 100 years old"。
声明一次性结构体,像我们这里所做的,是很少见的。当结构体用于在 Go 中创建可重用的自定义类型时,它们变得更加有用。接下来我们来看一下如何实现这一点。
声明自定义类型
到目前为止,我们创建了一个一次性结构体,这通常不是很有用。在我们讨论更常见的做法之前,让我们先讨论一下如何创建自定义类型。
到目前为止,我们已经看到了语言中定义的基本类型和指针包装类型,例如:string、bool、map 和 slice。我们可以使用 type 关键字基于这些基本类型创建我们自己的类型。让我们创建一个基于 string 类型的新类型,叫做 CarModel:
type CarModel string
CarModel 现在是一个独立的类型,就像 string 一样。虽然 CarModel 是基于 string 类型的,但它是一个独立的类型。你不能用 CarModel 替代 string,反之亦然。
创建一个 CarModel 变量的方式与创建 string 类型变量类似:
var myCar CarModel = "Chevelle"
或者,通过使用类型转换,如下所示:
myCar = CarModel("Chevelle")
因为 CarModel 是基于 string 的,所以我们可以通过类型转换将 CarModel 转换回 string:
myCarAsString := string(myCar)
我们可以基于任何其他类型创建新类型,包括映射、切片和函数。这对于命名或为类型添加自定义方法非常有用(稍后我们会讨论这个问题)。
自定义结构体类型
声明结构体最常见的方式是使用 type 关键字。让我们再次创建那个记录,但这次通过声明一个类型来使它可重用:
type Record struct{
Name string
Age int
}
func main() {
david := Record{Name: "David Justice", Age: 28}
sarah := Record{Name: "Sarah Murphy", Age: 28}
fmt.Printf("%+v\n", david)
fmt.Printf("%+v\n", sarah)
}
通过使用 type,我们创建了一个名为 Record 的新类型,可以重复使用它来创建保存 Name 和 Age 的变量。
注意
与在一行中定义两个相同类型的变量类似,你也可以在 struct 类型中做同样的事,比如 First, Last string。
向类型添加方法
方法类似于函数,但它与类型绑定在一起,而不是独立的。例如,我们一直在使用 fmt.Println() 函数。那个函数独立于任何已声明的变量。
方法是绑定到变量上的函数。它只能用于某种类型的变量。让我们创建一个方法,返回我们之前创建的 Record 类型的字符串表示:
type Record struct{
Name string
Age int
}
// String returns a csv representing our record.
func (r Record) String() string {
return fmt.Sprintf("%s,%d", r.Name, r.Age)
}
注意 func (r Record),它将函数作为方法附加到 Record 结构体上。在这个方法内,你可以通过 r.<field> 访问 Record 的字段,例如 r.Name 或 r.Age。
这个方法不能在 Record 对象之外使用。以下是一个使用它的示例:
john := Record{Name: "John Doak", Age: 100}
fmt.Println(john.String())
让我们看看如何更改字段的值。
更改字段值
结构体的值可以通过使用变量属性后跟 = 和新值来更改。以下是一个示例:
myRecord.Name = "Peter Griffin"
fmt.Println(myRecord.Name) // Prints: Peter Griffin
重要的是要记住,结构体不是引用类型。如果你将一个表示结构体的变量传递给函数并在函数中更改一个字段,这个字段在外部不会发生变化。以下是一个示例:
func changeName(r Record) {
r.Name = "Peter"
fmt.Println("inside changeName: ", r.Name)
}
func main() {
rec := Record{Name: "John"}
changeName(rec)
fmt.Println("main: ", rec.Name)
}
这将输出如下内容:
Inside changeName: Peter
Main: John
正如我们在 指针 部分所学到的那样,这是因为变量是被复制的,我们在修改的是该副本。对于需要更改字段的结构体类型,我们通常会传递一个指针。让我们再试一次,使用指针:
func changeName(r *Record) {
r.Name = "Peter"
fmt.Println("inside changeName: ", r.Name)
}
func main() {
// Create a pointer to a Record
rec := &Record{Name: "John"}
changeName(rec)
fmt.Println("main: ", rec.Name)
}
Inside changeName: Peter
Main: Peter
这将输出如下内容:
Inside changeName: Peter
Main: Peter
请注意,. 是一个 魔术 操作符,它适用于 struct 或 *struct。
当我声明 rec 变量时,我没有设置 age。未设置的字段会被设为该类型的零值。对于 Age 类型(int),其零值为 0。
在方法中更改字段值
与函数不能修改非指针结构体类似,方法也不能修改它。如果我们有一个名为 IncrAge() 的方法,用于将记录中的年龄加一,这将不会达到你想要的效果:
func (r Record) IncrAge() {
r.Age++
}
上述代码传递了 Record 的副本,将副本的 Age 加一,然后返回。
要实际增加年龄,只需将 Record 变为指针,如下所示:
func (r *Record) IncrAge() {
r.Age++
}
这样就能按预期工作。
提示
这里有一条基本规则,能帮助你避免一些常见问题,尤其是当你刚开始学习这门语言时。如果 struct 类型应该是指针类型,那么所有的方法都应该是指针方法;如果不应该是指针类型,那么所有方法都应为非指针方法。不要混合使用。
构造函数
在许多编程语言中,构造函数是特别声明的方法或语法,用于初始化对象的字段,并有时会执行一些内部方法作为设置过程。Go 并没有提供专门的构造代码,而是使用简单的函数,通过 构造函数模式 来实现。
构造函数通常被命名为 New() 或 New[Type](),当声明公共构造函数时使用。如果包中没有其他类型(并且未来可能也不会有),使用 New()。
如果我们想要创建一个构造函数,用来生成前面部分的 Record,它可能看起来像这样:
func NewRecord(name string, age int) (*Record, error) {
if name == "" {
return nil, fmt.Errorf("name cannot be the empty string")
}
if age <= 0 {
return nil, fmt.Errorf("age cannot be <= 0")
}
return &Record{Name: name, Age: age}, nil
}
这个构造函数接受 name 和 age 参数,并返回一个指向 Record 的指针,且这些字段已被设置。如果我们为这些字段传递无效值,它将返回指针的零值(nil)和一个错误。使用这个构造函数的方式如下:
rec, err := NewRecord("John Doak", 100)
if err != nil {
return err
}
不用担心这个错误,我们将在本书的后续部分讨论它。
到现在为止,你已经学习了如何使用 struct,Go 的基础对象类型。这包括创建结构体、创建自定义结构体、添加方法、修改字段值以及创建构造函数。接下来,让我们看看如何使用 Go 接口来抽象类型。
理解 Go 接口
Go 提供了一种名为 interface 的类型,用于存储声明了一组方法的任何值。实现该接口的值必须声明并实现这一组方法。该值还可以有接口类型声明之外的其他方法。
如果你是第一次接触接口,理解它们可能会有些混乱。因此,我们将一步步进行讲解。
定义一个接口类型
接口最常见的定义方式是使用我们在之前结构体部分讨论过的type关键字。以下定义了一个返回表示数据的字符串的接口:
type Stringer interface {
String() string
}
注意
Stringer是标准库fmt包中定义的一个真实类型。实现了Stringer的类型将在传递给fmt包中的print函数时调用它们的String()方法。不要让相似的名字让你困惑;Stringer是接口类型的名字,它定义了一个名为String()的方法(大写字母区分于小写的string类型)。该方法返回一个string类型,应该提供数据的某种人类可读表示。
现在,我们有了一个新类型叫做Stringer。任何具有String() string方法的变量都可以存储在Stringer类型的变量中。以下是一个示例:
type Person struct {
First, Last string
}
func (p Person) String() string {
return fmt.Sprintf("%s,%s", p.Last, p.First)
}
Person代表一个人的记录,包括名字和姓氏。我们为其定义了String() string方法,因此Person实现了Stringer接口:
type StrList []string
func (s StrList) String() string {
return strings.Join(s, ",")
}
StrList是一个字符串切片。它也实现了Stringer接口。这里使用的strings.Join()函数接受一个字符串切片,并将切片中的每个条目通过逗号连接成一个单一的字符串:
// PrintStringer prints the value of a Stringer to stdout.
func PrintStringer(s Stringer) {
fmt.Println(s.String())
}
PrintStringer()允许我们打印任何实现了Stringer接口类型的String()方法的输出。我们上面创建的两个类型都实现了Stringer。
让我们看看实际效果:
func main() {
john := Person{First: "John", Last: "Doak"}
var nameList Stringer = StrList{"David", "Sarah"}
PrintStringer(john) // Prints: Doak,John
PrintStringer(nameList) // Prints: David,Sarah
}
如果没有接口,我们将不得不为每个我们想打印的类型编写一个单独的Print[Type]函数。接口使我们能够传递能够执行其方法中定义的常见操作的值。
接口的重要事项
关于接口,首先要注意的是,值必须实现接口中定义的每个方法。你的值可以有接口中未定义的方法,但反过来则不行。
新手 Go 开发者常遇到的另一个问题是,一旦类型存储在接口中,你就无法访问其字段,或者接口中未定义的任何方法。
空接口——Go 的通用值
让我们定义一个空接口变量:var i interface{}。i是一个没有定义任何方法的接口。那么,您可以将什么存储在其中呢?
没错,你可以存储任何东西。
interface{}是 Go 的通用值容器,可以用来将任何值传递给函数,然后再弄清楚它是什么以及如何处理它。让我们将一些东西放入i:
i = 3
i = "hello world"
i = 3.4
i = Person{First: "John"}
这一切都是合法的,因为这些值的类型都定义了接口所定义的所有方法(但接口没有方法)。这使我们能够在通用容器中传递值。这实际上是fmt.Printf()和fmt.Println()的工作方式。以下是它们在fmt包中的定义:
func Println(a ...interface{}) (n int, err error)
func Printf(format string, a ...interface{}) (n int, err error)
然而,由于接口没有定义任何方法,i在这种形式下并不有用。所以,这非常适合传递值,但不适合使用它们。
关于 1.18 版本中的interface{}的注意事项:
Go 1.18 引入了一个别名,替代了空的 interface{},称为 any。Go 标准库现在使用 any 代替 interface{}。然而,1.18 之前的所有包仍然使用 interface{}。两者是等价的,可以互换使用。
类型断言
接口可以通过 断言 将其值转换为另一个接口类型或其原始类型。这与类型转换不同,后者是将类型从一种类型转换为另一种类型。在这种情况下,我们是在说 它已经是这种类型了。
将 interface{} 值转换为我们可以操作的值。
这是两种常见方式中的第一种,使用 if 语法,如下所示:
if v, ok := i.(string); ok {
fmt.Println(v)
}
i.(string) 是在断言 i 是一个 string 类型的值。如果不是,ok == false。如果 ok == true,那么 v 将是 string 类型的值。
更常见的方式是使用 switch 语句和另一个 type 关键字的用法:
switch v := i.(type) {
case int:
fmt.Printf("i was %d\n", i)
case string:
fmt.Printf("i was %s\n", i)
case float:
fmt.Printf("i was %v\n", i)
case Person, *Person:
fmt.Printf("i was %v\n", i)
default:
// %T will print i's underlying type out
fmt.Printf("i was an unsupported type %T\n", i)
}
我们的 default 语句在没有匹配其他任何 case 时,会打印出 i 的底层类型。%T 用于打印类型信息。
在这一部分,我们学习了 Go 的 interface 类型,了解了它如何用于提供类型抽象,并将接口转换为其具体类型以便使用。
总结
在这一章中,你已经学习了 Go 语言的基础知识。这包括变量类型、函数、循环、方法、指针和接口。本章所学的技能为接下来深入探索 Go 语言的高级特性奠定了基础。
接下来,我们将研究 Go 语言的核心功能,例如错误处理、使用并发以及 Go 的测试框架。
第二章:Go 语言基础
在上一章,我们介绍了 Go 语言的基础知识。虽然与其他语言相比,某些语法是新的,但大多数概念对于来自其他语言的程序员来说是熟悉的。
这并不是说 Go 使用这些概念的方式不导致更容易阅读和推理的代码——只是大多数内容与其他语言并无显著不同。
在本章中,我们将讨论使 Go 与其他语言不同的关键部分,从 Go 更具实用性的错误处理,到其核心并发概念 goroutine,再到 Go 语言的最新特性:泛型。
以下是将要讨论的主要主题:
-
在 Go 中处理错误
-
利用 Go 常量
-
使用
defer、panic和recover -
利用 goroutine 实现并发
-
理解 Go 的
Context类型 -
利用 Go 的测试框架
-
泛型——新兴的技术
现在,让我们整理一下基本要点,帮助你顺利开始!
在 Go 中处理错误
许多开发者来自于使用 异常 处理 错误 的语言。Go 采用了不同的方法,将错误视为与其他数据类型一样的对象。这避免了基于异常的模型常见问题,例如异常从栈中逃逸的问题。
Go 语言有一个内建的错误类型,叫做 error。error 基于 interface 类型,具有以下定义:
type error interface {
Error() string
}
现在,让我们来看看如何创建一个错误。
创建一个错误
创建错误的最常见方法是使用 errors 包的 New() 方法或 fmt 包的 Errorf() 方法。当你不需要变量替换时,使用 errors.New(),当你需要变量替换时,使用 fmt.Errorf()。你可以在以下代码片段中看到这两种方法:
err := errors.New("this is an error")
err := fmt.Errorf("user %s had an error: %s", user, msg)
在前面的示例中,err 将是 error 类型。
使用错误
使用错误的最常见方式是将其作为函数或方法调用的最后一个返回值。调用者可以测试返回的错误是否为 nil,如果是,表示没有错误。
假设我们想要编写一个除法函数,并且希望检测除数是否为零。如果是这样,我们希望返回一个错误,因为计算机无法将一个数除以零。代码可能如下所示:
func Divide(num int, div int) (int, error) {
if div == 0 {
// We return the zero value of int (0) and an error.
return 0, errors.New("cannot divide by 0")
}
return num / div, nil
}
func main() {
divideBy := []int{0, 1, 2, 3, 4, 5, 6, 7, 8, 9}
for _, div := range divideBy {
res, err := Divide(100, div)
if err != nil {
fmt.Printf("100 by %d error: %s\n", div, err)
continue
}
fmt.Printf("100 divided by %d = %d\n", div, res)
}
}
上面的示例使用了 Go 语言的多值返回能力,返回了两个值:结果 和 错误。
在我们的 main 包中,现在可以进行除法运算并检查返回的 error 类型,看看它是否为 nil。如果是,我们知道发生了错误,应该忽略返回值。如果不是,我们知道操作已成功完成。
创建命名错误
有时,你可能希望创建表示特定类型错误的错误——比如网络错误与参数错误。这可以通过使用 var 关键字和 errors.New() 或 fmt.Errorf() 来创建特定类型的错误,以下是示例代码:
var (
ErrNetwork = errors.New("network error")
ErrInput = errors.New("input error")
)
我们可以使用errors包的Is()函数来检测错误类型,并在ErrNetwork上重试,而不在其他错误上重试,如下所示:
// The loop is for retrying if we have an ErrNetwork.
for {
err := someFunc("data")
if err == nil {
// Success so exit the loop
break
}
if errors.Is(err, ErrNetwork) {
log.Println("recoverable network error")
time.Sleep(1 * time.Second)
continue
}
log.Println("unrecoverable error")
break // exit loop, as retrying is useless
}
someFunc()在此未定义。你可以在此查看完整示例:
自定义错误
因为error类型本质上是一个接口,你可以实现自定义错误。以下是我们可以使用的更深入的网络错误:
const (
UnknownCode = 0
UnreachableCode = 1
AuthFailureCode = 2
)
type ErrNetwork struct {
Code int
Msg string
}
func (e ErrNetwork) Error() string {
return fmt.Sprintf("network error(%d): %s", e.Code, e.msg)
}
我们现在可以返回一个自定义的网络错误,例如身份验证失败,如下所示:
return ErrNetwork{
Code: AuthFailureCode,
Msg: "user unrecognized",
}
当我们收到一个错误时,我们可以使用errors.As()函数检测它是否是网络错误,如下所示:
var netErr ErrNetwork
if errors.As(err, &netErr) {
if netErr.Code == AuthFailureCode {
log.Println("unrecoverable auth failure: ", err)
break
}
log.Println("recoverable error: %s", netErr)
}
log.Println("unrecoverable error: %s", err)
break
你也可以在这里查看:play.golang.org/p/gZ5AK8-o4zA。
上述代码检测网络错误是否不可恢复,例如身份验证失败。任何其他网络错误都是可恢复的。如果不是网络错误,则是不可恢复的。
错误包装
很多时候,会有一个错误链,我们希望使用net/http包。在这种情况下,你可能希望将你进行的 REST 调用的相关信息与底层错误一起记录。
我们可以包装错误,这样不仅能包含更具体的信息,还能保留底层的错误,以便以后提取。
我们通过fmt.Errorf()并使用%w来进行变量替换,传入我们的错误类型。假设我们想要从另一个函数restCall()调用someFunc()并添加更多信息,代码示例如下:
func restCall(data) error {
if err := someFunc(data); err != nil {
return fmt.Errorf("restCall(%s) had an error: %w", data, err)
}
return nil
}
使用restCall()的人可以通过errors.As()检测并提取ErrNetwork,就像我们之前做的那样。以下代码片段提供了这个示例:
for {
if err := restCall(data); err != nil {
var netErr ErrNetwork
if errors.As(err, &netErr) {
log.Println("network error: ", err)
time.Sleep(1 * time.Second)
continue
}
log.Println("unrecoverable: ", err)
}
}
上述代码从被包装的error中提取ErrNetwork。无论错误被包装了多少层,这都能正常工作。
在本节中,你了解了 Go 如何处理错误,Go 的error类型,以及如何创建基本错误、如何创建自定义错误、如何检测特定错误类型以及如何包装/解包错误。因为良好的error处理是可靠软件的基础,所以这些知识对你编写的每一个 Go 程序都将非常有用。
利用 Go 常量
常量提供的是编译时设定的值,且无法更改。与此相对的是变量,它存储可以在运行时设置并且可以改变的值。常量提供的是不能被用户意外修改的类型,并且在软件启动时就分配使用,提供了一些速度优势和比变量声明更安全的特性。
常量可以用来存储以下内容:
-
布尔类型
-
字符
-
整数类型(
int、int8、uint16等) -
浮动类型(
float32/float64) -
复杂数据类型
-
字符串
在本节中,我们将讨论如何声明常量以及在代码中的常见用法。
声明常量
常量是使用const关键字声明的,如下代码片段所示:
const str = "hello world"
const num = 3
const num64 int64 = 3
常量与变量类型不同,它们有两种形式,如下所示:
-
未类型化常量
-
类型化常量
这看起来有点奇怪,因为常量存储的是一个类型化的值。但如果你没有声明确切的类型(如第三个示例中的num64,我们声明它为int64类型),则常量可以用于任何具有相同基础类型或类型家族的类型(例如整数)。这被称为未类型化常量。
例如,num可以用来设置int8、int16、int32、int64、uint8、uint16、uint32或uint64类型的值。所以,以下代码是有效的:
func add(x, y int8) int8 {
return x + y
}
func main() {
fmt.Println(add(num, num)) // Print: 6
}
虽然我们之前没有讨论过,但这就是我们写出像add(3, 3)这样的代码时发生的情况——3实际上是一个未类型化的常量。如果add的签名改为add(x, y int64),add(3, 3)仍然能工作,因为未类型化常量的这一特性。
这适用于任何基于该基本类型的类型。请看下面的示例:
type specialStr string
func printSpecial(str specialStr)
fmt.Println(string(str))
}
func main() {
const constHelloWorld = "hello world"
var varHelloWorld = "hello world"
printSpecial(varHelloWorld) // Won't compile
printSpecial(constHelloWorld) // Will compile
printSpecial("hello world") // Will compile
}
从前面的代码,你将会得到以下输出:
./prog.go:18:14: cannot use varHelloWorld (type string) as type specialStr in argument to printSpecial
这是因为varHelloWorld是一个string类型,而不是specialStr类型。但未类型化常量的独特特性允许constHelloWorld满足任何基于string的类型。
通过常量进行枚举
许多语言提供了枚举类型,为一些不可更改的值提供可读的名称。这通常用于整数常量,但你可以对任何类型的常量进行此操作。
对于整数常量,特别是有一个特殊的iota关键字,可以用来生成常量。它会为每个在分组中定义的常量递增1,如下代码片段所示:
const (
a = iota // 0
b = iota // 1
d = iota // 2
)
这也可以简化为只让第一个值使用iota,后续的值也会自动设置。该值也可以设置为一个公式,其中iota使用乘法器或其他数学运算。下面是这两个概念的示例:
const (
a = iota *2 // 0
b // 2
d // 4
)
使用iota进行枚举很棒,只要这些值永远不会被存储在磁盘上或传送到本地或远程的其他进程。如果常量的值是由代码中常量的顺序控制的,那么看一下如果我们在第一个示例中插入c会发生什么:
const (
a = iota // 0
b // 1
c // 2
d // 3
)
注意到d现在的值是3了吗?如果代码需要读取已经写入磁盘并需要重新读取的值,这会导致严重的错误。在这些值可能被其他进程使用的情况下,最佳实践是静态定义枚举值。
在 Go 中,枚举值打印出来时可能难以解释。也许你在使用它们作为错误码,并希望在打印值到日志或标准输出(stdout)时打印常量的名称。让我们看看如何能得到更好的输出。
打印枚举值
当以枚举名称而不是值来显示一个值时,枚举器的使用会更加简便。当常量是字符串时,如const toyota = "toyota",这可以轻松实现,但对于其他更高效的枚举器类型,如整数,打印该值将仅输出一个数字。
Go 具有内置的代码生成工具。这是一个比我们在这里讨论的内容更广泛的主题(可以在此阅读:blog.golang.org/generate)。
然而,我们将借用链接文档中的内容,展示如何利用它来设置枚举器为字符串值,以便自动打印,方法如下:
//go:generate stringer -type=Pill
type Pill int
const (
Placebo Pill = iota
Aspirin
Ibuprofen
Paracetamol
Acetaminophen = Paracetamol
)
注意
这需要安装 Go 的 stringer 二进制文件。
//go:generate stringer -type=Pill 是一种特殊的语法,表示当运行 go generate 命令时,它应调用 stringer 工具并传递 -type=Pill 标志,这表示读取我们的包代码并生成一个方法,该方法基于类型 Pill 将常量反转为字符串。这将被放置在名为 pill_string.go 的文件中。
在运行命令之前,fmt.Println(Aspirin) 会打印 1;之后,它会打印 Aspirin。
在本节中,你已经学习了常量如何提供不可变的值以供在代码中使用,如何使用它们创建枚举器,以及如何为枚举器生成文本输出以便更好地记录日志。在下一节中,我们将探讨如何使用 defer、panic 和 recover 方法。
使用 defer、panic 和 recover
现代编程语言需要提供某种方法,在一段代码执行完毕时运行某些操作。这在需要保证文件关闭或解锁互斥锁时非常有用。此外,有时程序需要停止执行并退出。这可能是由于无法访问关键资源、安全问题或其他需求导致的。
我们还需要能够从程序提前退出的情况中恢复,这种情况通常由我们无法控制的代码包引起。本节将涵盖 Go 中每种能力及其相互关系。
defer
defer 关键字允许你在包含 defer 的函数退出时执行一个函数。如果有多个 defer 语句,它们将按从后到前的顺序执行。
这对于调试、解锁互斥锁、递减计数器等非常有用。以下是一个示例:
func printStuff() (value string) {
defer fmt.Println("exiting")
defer func() {
value = "we returned this"
}()
fmt.Println("I am printing stuff")
return ""
}
func main() {
v := printStuff()
fmt.Println(v)
}
这将输出以下内容:
I am printing stuff
exiting
we returned this
你也可以通过以下链接查看:
如果你运行这个示例,你会注意到我们的 defer 语句在 printStuff() 的其余部分执行完后才执行。我们使用一个延迟的匿名函数来设置我们命名的返回值 value,然后退出。你将在后续章节中看到 defer 被频繁使用。
panic
panic 关键字用于停止程序的执行并退出,同时显示一些文本和堆栈跟踪。
使用 panic 只需调用以下内容:
panic("ran into some bug")
panic 用于程序无法或不应该继续执行时。这可能是因为存在安全问题,或者在启动时无法连接到所需的数据源。
在大多数情况下,用户应返回 error 而不是 panic。
一般来说,只有在 main 包中使用 panic。
恢复
在某些罕见的情况下,程序可能因为不可预见的 bug 或某个包不必要的 panic 而崩溃。在超过 10 年的 Go 编程经验中,我可以数出我需要从 panic 中恢复的次数。
使用 recover 来防止在 RPC 调用发生 panic 时导致服务器崩溃,并通知调用者问题。
如果像 RPC 框架一样,你需要捕获正在发生的 panic 或防止潜在的 panic,可以结合 defer 关键字使用 recover 关键字。以下是一个示例:
func someFunc() {
defer func() {
if r := recover(); r != nil {
log.Printf("called recover, panic was: %q", r)
}
}()
panic("oh no!!!")
}
你也可以在这里查看:play.golang.org/p/J8RfjOe1dMh。
这与其他语言的异常类型有相似之处,但你不应混淆这两者。Go 并不打算让你以这种方式使用 panic/defer/recover——这样做将会在未来给你带来问题。
现在你已经完成了这一部分,你学习了如何延迟执行函数,如何在 main 包中引发 panic,如何从不正常的包中恢复,以及何时应该使用这些功能。让我们进入本章相关的下一个话题:goroutines。
使用 goroutines 进行并发
在现代计算机时代,并发 是关键。在 2005 年之前的几年,计算机使用摩尔定律每 18 个月就将单个 中央处理单元(CPU)的速度加倍。多 CPU 消费者系统很少见,系统中每个 CPU 只有一个核心。高效利用多核的软体系统稀少。
随着时间的推移,增加单核处理器的速度变得更加昂贵,多核 CPU 已成为常态。每个 CPU 核心支持多个硬件线程,操作系统(OS)提供的 OS 线程映射到硬件线程,然后在进程之间共享。
编程语言可以利用这些操作系统线程以 并发 的方式运行函数,而不是像我们在代码中一直做的那样 串行 执行。
启动操作系统线程是一个昂贵的操作,要充分利用线程的时间,需要特别关注你正在做的事情。
Go 在这一点上超越了大多数语言,使用 goroutines。Go 构建了一个运行时调度器,将这些 goroutines 映射到操作系统线程,并切换哪个 goroutine 在哪个线程上运行,以优化 CPU 的使用。
这产生了易于使用且成本低廉的并发,减少了开发人员的心理负担。
启动一个 goroutine
Go 得名于go关键字,它用于启动一个 goroutine。通过在函数调用前加上go,您可以使该函数与其余代码并发执行。以下是一个示例,它创建了 10 个 goroutine,每个打印一个数字:
for i := 0; i < 10; i++ {
go fmt.Println(x) // This happens concurrently
}
fmt.Println("hello")
// This is used to prevent the program from exiting
// before our goroutines above run. We will talk about
// this later in the chapter.
select{}
输出将类似于但不一定与下面显示的顺序相同。...表示后面还有更多数字,但为简洁起见已省略。
Hello
2
0
5
3
...
fatal error: all goroutines are asleep - deadlock!
您可以在这里看到前面的例子:
注意
在运行后,您还会注意到此处出现错误。这是因为程序没有运行的 goroutines,这意味着程序实际上已经死了。它被 Go 的死锁检测器杀死。我们将在下一章更加优雅地处理这个问题。
运行此代码将以随机顺序打印出数字。为什么是随机的呢?因为一旦并发运行,您不能确定何时调度函数将执行。在任何给定时刻,将会有 0 到 10 个 goroutines 执行fmt.Println(x),还有另一个执行fmt.Println("hello")。没错,main()函数本身就是一个 goroutine。
一旦for循环结束,fmt.Println("hello")将会执行。hello可能会在任何数字之前、中间某处或者所有数字之后打印出来。这是因为它们都像赛马一样同时执行。我们知道所有赛马最终都会到达终点,但我们不知道哪匹会第一个到达。
同步
在进行并发编程时,有一个简单的规则:您可以同时读取一个变量而无需同步,但单个写入者需要同步。
这些是 Go 中最常见的同步方法:
-
用于在 goroutines 之间交换数据的
channel数据类型 -
来自
sync包的Mutex和RWMutex用于锁定数据访问 -
用于跟踪访问的
sync包中的WaitGroup
这些可以用来防止多个 goroutines 同时读写变量。如果尝试从多个 goroutines 同时读写同一变量,则结果是未定义的(换句话说,这是个坏主意)。
同时读写同一变量被称为数据竞争。Go 有一个数据竞争检测器,本书未涵盖这些问题,可以在这里阅读更多信息:golang.org/doc/articles/race_detector。
WaitGroups
WaitGroup是一个同步计数器,其值从 0 开始,只有正值。它通常用于指示某些任务完成后再执行依赖于这些任务的代码。
WaitGroup有几个方法,如下所述:
-
.Add(int): 用于向WaitGroup添加某个数字 -
.Done(): 从WaitGroup减去 1 -
.Wait(): 阻塞,直到WaitGroup为 0
在我们之前关于 goroutine 的部分,我们有一个示例在运行后发生了 panic。这是因为所有的 goroutine 都停止了。我们使用了select语句(本章会介绍)来阻塞直到永远,防止程序在 goroutine 运行之前退出,但我们可以使用WaitGroup来等待 goroutine 结束并优雅地退出。
我们再做一遍,如下所示:
func main() {
wg := sync.WaitGroup{}
for i := 0; i < 10; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Println(n)
}(i)
}
wg.Wait()
fmt.Println("All work done")
}
你也可以在这里看到这个示例:play.golang.org/p/cwA3kC-d3F6。
本示例使用WaitGroup来跟踪尚未完成的 goroutine 数量。我们在启动 goroutine 之前将wg加 1(不要在 goroutine 内部加)。当 goroutine 退出时,会调用defer语句,从计数器中减去 1。
重要提示
WaitGroup只能有正值。如果在WaitGroup为 0 时调用.Done(),将会引发 panic。由于它们的使用方式,创建者知道任何试图使其达到负值的操作都会是一个需要尽早捕获的严重 bug。
wg.Wait()等待所有的 goroutine 完成,调用defer wg.Done()会使我们的计数器递减直到为 0。此时,Wait()停止阻塞,程序退出main()函数。
重要提示
如果在函数或方法调用中传递WaitGroup,你需要使用wg := &sync.WaitGroup{}指针。否则,每个函数操作的是副本,而不是相同的值。如果WaitGroup在结构体中使用,则结构体或持有WaitGroup的字段必须是指针类型。
通道
通道提供了一种同步原语,其中数据由一个 goroutine 插入到通道中,并由另一个 goroutine 移除。通道可以是有缓冲区的,这意味着它可以在阻塞之前容纳一定量的数据;也可以是无缓冲区的,在这种情况下,发送方和接收方必须同时存在,数据才能在 goroutine 之间传递。
通道的常见类比是水流通过的管道。水被注入管道,然后从另一端流出。管道可以容纳的水量就是缓冲区的大小。在这里,你可以看到使用通道进行 goroutine 通信的示意图:

图 2.1 – 使用通道进行 goroutine 通信
通道用于将数据从一个 goroutine 传递到另一个 goroutine,其中传递数据的 goroutine 停止使用该数据。这允许你将控制从一个 goroutine 传递到另一个 goroutine,每次只允许一个 goroutine 访问。这提供了同步机制。
通道是有类型的,因此只能将该类型的数据发送到通道中。由于通道是类似于map和slice的指针作用域类型,因此我们使用make()来创建它们,如下所示:
ch := make(chan string, 1)
上述语句创建了一个名为ch的通道,该通道持有string类型的数据,且具有大小为 1 的缓冲区。如果省略", 1",则会创建一个无缓冲区的通道。
发送/接收
使用 <- 语法发送到通道。要将 string 类型发送到前述通道,我们可以这样做:ch <- "word"。这试图将 "word" 字符串放入 ch 通道中。如果通道有可用缓冲区,我们继续在此 goroutine 中执行。如果缓冲区已满,则阻塞,直到缓冲区变得可用或在无缓冲通道的情况下,goroutine 尝试从通道中取出。
接收类似于在通道的对面使用相同的语法。试图从通道中拉取值的 goroutine 将执行此操作:str := <-ch。这将通道上的下一个值分配给 str 变量。
更常见的情况是在接收变量时使用 for range 语法。这使我们可以从通道中取出所有值。使用我们前述的通道的示例可能如下所示:
for val := range ch { // Acts like a <-ch
fmt.Println(val)
}
通道可以关闭,这样将不会再向其发送数据。这是使用 close 关键字完成的。要关闭前述通道,我们可以执行 close(ch)。这应该 始终 由发送方执行。关闭通道将导致 for range 循环在通道上的所有值都被移除后退出。
让我们使用通道从一个 goroutine 发送单词到另一个 goroutine,如下所示:
func main() {
ch := make(chan string, 1)
go func() {
for _, word := range []string{"hello", "world"} {
ch <- word
close(ch)
}
}()
for word := range ch {
fmt.Println(word)
}
}
您还可以在此处看到前述示例:
重要提示
在通道关闭后,向通道发送值将导致 panic。
从关闭的通道接收将返回通道持有类型的零值。
通道可以是 nil。从 nil 通道发送或接收可能会永久阻塞。开发人员常常忘记在结构体中初始化通道,这是一个常见的 bug。
select 语句
select 语句类似于 switch 语句,但专注于监听多个通道。这使我们能够同时接收和处理多个输入。
下面的示例将监听几个通道,并在收到其中一个通道的值时执行 case 语句。在示例 case 中,我们启动一个 goroutine 来处理值,以便我们可以继续执行我们的循环以获取下一个值。如果通道上没有值,则会阻塞直到有值。如果多个通道上有值,则 select 使用伪随机方法选择要执行的 case:
for {
select {
case v := <-inCh1:
go fmt.Println("received(inCh1): ", v)
case v := <-inCh2:
go fmt.Println("received(inCh2): ", v)
}
}
使用 select 语句时,有时我们只想检查通道上是否有值,如果没有,我们希望继续执行。在这些情况下,我们可以使用 default 语句。如果没有其他 case 语句可以执行(与以前等待通道数据无限期的行为相反),则 default 会执行。您可以在以下代码片段中看到此示例:
select {
case s := <-ch:
fmt.Printf("had a string(%s) on the channel\n", s)
default:
fmt.Println("channel was empty")
}
select 还有一个我们之前见过但没有解释的用法。select{} 没有 case 语句和 default 语句,因此它会永远阻塞。这通常用于希望永远运行的服务器,防止 main() 函数退出,从而停止程序的执行。
通道作为事件信号
通道的一个常见用法是用于向另一个 goroutine 发送信号。通常,这是指示退出循环或其他某些执行的信号。
在之前的 select 示例中,我们使用了 for 循环,循环将永远继续下去,但我们可以使用通道来发出退出信号,如下所示:
func printWords(in1, in2 chan string, exit chan struct{}, wg *sync.WaitGroup) {
defer wg.Done()
for {
select{
case <-exit:
fmt.Println("exiting")
return
case str := <-in1:
fmt.Println("in1: ", str)
case str := <-in2:
fmt.Println("in2: ", str)
}
}
}
printWords() 从三个通道读取输入。如果输入来自 in1 或 in2,它会打印通道名称和发送的字符串。如果是 exit 通道,它会打印退出信息并返回。当返回时,wg 将调用 .Done(),使其值减 1:
func main() {
in1 := make(chan string)
in2 := make(chan string)
wg := &sync.WaitGroup{}
exit := make(chan struct{})
wg.Add(1)
go printWords(in1, in2, exit, wg)
in1 <- "hello"
in2 <- "world"
close(exit)
wg.Wait()
}
在这里,我们创建了 printWords() 所需的所有通道,并将 printWords 放入 goroutine 中执行。然后,我们通过输入通道发送输入,一旦输入完成,我们关闭 exit 通道以表明没有更多输入需要传递给 printWords。wg.Wait() 调用会阻止 main() 在 printWords 退出之前退出。
输出如下:
in1: hello
in2: world
exiting
你还可以通过以下链接查看前面的示例:
在这个示例中,exit 用于向 printWords() 发送信号,告诉它我们希望退出 for 循环。这得以实现是因为在关闭的通道上接收会返回该通道持有类型的零值。我们使用一个空的 struct{} 实例,因为它不占用内存。我们不将返回值存储在变量中,因为重要的是通道关闭时的信号。
Mutexes
一个名为 Mutex 的 sync 包。
这用于保护一个变量或一组变量,防止它们被多个 goroutine 同时访问。记住——如果一个 goroutine 尝试在另一个 goroutine 正在读取或写入同一个值时进行写入,变量必须通过同步原语来保护。
在以下示例中,我们将启动 10 个 goroutine 来向 sum 值添加数字。由于我们在多个 goroutine 中进行读写操作,必须保护 sum 值:
type sum struct {
mu sync.Mutex
sum int
}
func (s *sum) get() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.sum
}
func (s *sum) add(n int) {
s.mu.Lock()
defer s.mu.Unlock()
s.sum += n
}
func main() {
mySum := &sum{}
wg := sync.WaitGroup{}
for i := 0; i < 100; i++ {
wg.Add(1)
go func(x int) {
defer wg.Done()
mySum.add(x)
}(i)
}
wg.Wait()
fmt.Println("final sum: ", mySum.get())
}
你还可以通过以下链接查看此示例:
这段代码使用了一个名为 mu 的 Mutex,它是 sum 结构体的一部分,用于控制对 get() 和 add() 方法的访问。由于每次加锁,因此只有一个方法可以同时执行。我们使用 defer 语句在函数退出时解锁 Mutex。这可以防止在函数变长时忘记解锁 Mutex。
RWMutex
与 sync.Mutex 一起使用的是 sync.RWMutex。它通过提供读写锁来区分自己。可以同时持有任意数量的 mu.RLock() 读锁,但只有一个 mu.Lock() 写锁,且必须等待所有现有的读锁完成(新的 Rlock() 尝试会被阻塞),然后为写入者提供独占访问权限。
当并发读取量较大且写入操作不频繁时,这种方式证明更为高效。然而,标准的 Mutex 在通用情况下更为高效,因为它的实现更加简单。
在本节中,你已经掌握了使用 goroutine 进行并发操作的基本技能,了解了什么是同步以及何时需要使用同步,还了解了 Go 的各种同步和信号传递方法。让我们深入理解另一种类型,称为 context。
理解 Go 的 Context 类型
Go 提供了一个名为 context 的包,具有以下两个主要用途:
-
在某些事件(例如超时)发生后取消一连串的函数调用
-
在一系列函数调用中传递信息(例如用户信息)
Context 对象通常在 main() 函数中创建,或者在某些请求(如 RPC)被处理时创建(例如从我们的后台 Context 对象中创建 Context 对象,如下所示)。
import "context"
func main() {
ctx := context.Background()
}
context 包和 Context 类型是一个高级主题,但我想在这里介绍它,因为你会在 Go 生态系统中看到它的使用。
使用 Context 信号来表示超时
Context 通常用于传递计时器状态或终止等待条件——例如,当程序等待网络响应时。
假设我们想要调用一个函数来获取一些数据,但我们不希望等待超过 5 秒钟才能完成调用。我们可以通过 Context 来传达这一信号,如下所示:
ctx, cancel := context.WithTimeout(context.Background(), 5 * time.Second)
data, err := GatherData(ctx, args)
cancel()
if err != nil {
return err
}
context.WithTimeout() 创建一个新的 Context,它将在 5 秒后自动取消,并且创建一个可以取消该 Context 的函数(context.CancelFunc)。
每个 Context 都是从另一个 Context 派生出来的。在这里,我们从 context.Background() 派生我们的 ctx 对象。context.Background() 是我们的父 Context。新的 context 对象可以从 ctx 派生,形成一个链条,这些新的 Context 对象可以有不同的超时时间。
直接通过 cancel() 或通过超时或截止日期取消 Context 会导致该 Context 及其子 Context 被一起取消。
上面的代码执行了以下操作:
-
创建一个在 5 秒后取消的
Context。 -
调用
GatherData()并传递Context。 -
一旦调用完成,如果
Context还没有被取消,我们就会取消它。
现在,我们需要设置 GatherData() 以响应我们的 Context 取消请求。
在接收时遵守上下文
如果我们正在执行 GatherData() 函数,我们需要遵守这个上下文。可以通过几种方式来做到这一点,最基本的是调用 ctx.Err(),如下所示:
func GatherData(ctx context.Context, args Args) ([]file, error) {
if ctx.Err() != nil {
return nil, err
}
localCtx, localCancel := context.WithTimeout(ctx, 2 * time.Second)
local, err := getFilesLocal(localCtx, args.local)
localCancel()
if err != nil {
return nil, err
}
remoteCtx, remoteCancel := context.WithTimeout(ctx, 3 * time.Second)
remote, err := getFilesRemote(remoteCtx, args.remote)
remoteCancel()
if err != nil {
return nil, err
}
return append(local, remote), nil
}
GatherData()检查ctx.Err()的值,看是否返回错误。如果是的话,我们知道Context已经被取消,直接返回即可。
在这个例子中,我们派生了两个新的Context对象,它们共享ctx的父级。如果ctx被取消,localCtx和remoteCtx也会被取消。取消localCtx或remoteCtx不会影响ctx。在大多数情况下,传递ctx而不是派生新的Context对象是常见做法,但我们希望展示如何派生新的Context对象。
Context还支持.Done()方法,如果你需要在select语句中检查取消状态,可以使用.Done()。.Done()返回一个通道,如果该通道关闭,则表示已取消。使用它非常简单,如下所示:
select {
case <-ctx.Done():
return ctx.Err()
case data := <-ch:
return date, nil
}
现在我们已经展示了如何将Context添加到你的函数中,让我们来谈谈它是如何在标准库中工作的,以及为什么它与我们展示的例子不同。
标准库中的Context
context包是在Go 1.7中新增的,远晚于 Go 标准库的引入。不幸的是,这意味着它不得不被“黑客”加入标准库包中,以避免破坏 Go 1.0 的兼容性承诺。
这是 Go 语言中新增的一个特性,但它也带来了一些丑陋的部分。之前我们向你展示了如何使用Context,它应该作为函数的第一个参数ctx传入。然而,标准库并未按此方式实现。
在标准库中使用Context时,你会看到一个常见的模式,那就是通过方法将其添加进来。这里有一个例子,展示了如何使用Context与http.Client来获取www.golang.org并打印到屏幕上:
client := &http.Client{}
req, err := http.NewRequest("GET", "http://www.golang.org", nil)
if err != nil {
fmt.Println("error: ", err)
return
}
ctx, cancel := context.WithTimeout(context.Background(), 3 * time.Second)
// Attach it to our request.
req = req.WithContext(ctx)
// Get our resp.
resp, err := client.Do(req)
cancel()
if err != nil {
fmt.Println("error: ", err)
return
}
// Print the page to stdout
io.Copy(os.Stdout, resp.Body)
在这段代码中,我们做了以下操作:
-
创建一个
HTTP客户端 -
创建一个
*http.Request (req)以获取www.golang.org页面。 -
创建
Context(ctx)和CancelFunc(cancel),其中Context会在 3 秒后被取消。 -
将
ctx附加到req,以防止*http.Request超时超过 3 秒。 -
使用
cancel()来取消Context的内部 goroutine,该 goroutine 在client.Do()调用完成后追踪超时。
到目前为止,我们已经讨论了如何使用Context进行取消操作。现在让我们谈谈Context的另一个用途——在调用链中传递值。
用Context传递值
Context的另一个主要用途是传递值,目的是传递那些在每次调用时都有用的值,而非作为通用存储。
这两种情况是传递值给Context的最佳用途:
-
用户发起调用时的安全信息。
-
如OpenTelemetry中使用的数据类型等遥测信息。
在安全信息的情况下,你正在通知系统用户是谁,可能是通过OpenID Connect(OIDC)信息。这使得调用栈能够进行授权检查。
对于遥测,这允许服务记录与特定调用相关的信息,用于跟踪函数执行时间、数据库延迟、输入和错误。这可以调节用于调试服务问题。我们将在后续章节讨论遥测。
向 Context 传递一个值需要小心。存储在 Context 中的值是键值对,为了防止多个包之间的键被覆盖,我们需要创建自己的自定义键类型,该类型只能由我们的包实现。通过这种方式,不同包的键将具有不同的类型。实现此功能的代码如下所示:
type key int
const claimsKey key = 0
func NewContext(ctx context.Context, claims Claims)
context.Context {
return context.WithValue(ctx, claimsKey, claims)
}
func ClaimsFromContext(ctx context.Context) (Claims, bool)
{
// ctx.Value returns nil if ctx has no value for the key;
// the Claims type assertion returns ok=false for nil.
claims, ok := ctx.Value(userIPKey).(Claims)
return claims, ok
}
这段代码执行以下操作:
-
定义一个名为
key的私有类型,防止其他包实现它 -
定义一个类型为
key的claimsKey常量。它作为存储 OIDCIDToken声明的值的键 -
NewContext()提供一个函数,将Claim附加到我们的Context上 -
ClaimsFromContext()提供一个函数,从Context中提取Claims并指示是否找到了Claims
上面的代码可能存在于一个安全包中,因为 Claims 代表我们已验证的用户数据。NewContext() 允许我们在某些中间件中将此信息添加到上下文中,而 ClaimsFromContext() 允许我们在调用链中需要的地方提取该信息。
最佳实践
我建议所有公共函数和方法都应有一个初始参数 ctx context.Context。这样可以让你为公共函数/方法/接口添加未来兼容性,如果将来需要添加 Context 提供的功能,即使现在没有使用它,也能轻松做到。
重要提示
未来兼容方法/函数/接口是指添加当前未使用的参数和返回值,以防止将来某个时间点破坏它们(和用户)——例如,为一个当前无法返回错误的构造函数添加一个返回的 error,但将来可能会返回。
也许你不需要处理取消(执行过快或无法取消),但添加遥测功能可能在以后会派上用场。
在本节中,你了解了 Go 的 Context 对象及其如何用于发出取消信号并通过调用堆栈传递值。你将会在许多第三方包中看到它的使用。该章节的最后一部分将讨论 Go 的测试包。让我们立即深入探讨。
使用 Go 的测试框架
测试 是任何编程语言中最重要且最不受欢迎的部分之一。测试能让开发者知道某些功能是否按预期工作。我无法计数多少次编写单元测试证明某个函数或方法没有按预期工作。这为我节省了无数的调试时间。
为此,测试需要具备以下特性:
-
易于编写
-
执行速度快
-
简单重构
-
容易理解
为了满足这些需求,Go 通过以下方式处理测试:
-
将测试拆分到独立的文件中
-
提供一个简单的
testing包 -
使用一种名为 表驱动测试(TDTs)的测试方法论
在本节中,我们将介绍如何编写基本测试、Go 的标准 TDT 方法论、通过接口创建虚拟对象,以及—最后—我使用过的一些第三方包和一些流行但不一定推荐的包。
创建一个基本的测试文件
Go 测试包含在以 _test.go 结尾的包文件中。这些文件具有相同的包名,你可以根据需要包含任意数量的测试文件。通常的规则是,每个你想要测试的包文件写一个测试文件,以确保一一对应,便于清晰。
每个测试在测试文件中都是一个函数,函数名以 Test 为前缀,并且有一个参数 t *testing.T,没有返回值。代码如下所示:
func TestFuncName(t *testing.T) {
}
t 是由 go test 命令传递的,提供了执行测试所需的工具。常用的方法列举如下:
-
t.Error() -
t.Errorf() -
t.Fatalf() -
t.Log() -
t.Logf()
当测试执行时,如果没有调用 panic/Error/Errorf/Fatal/Fatalf,测试会被视为通过。如果调用了其中任何一个,测试就会失败。使用 Error/Errorf,测试会继续执行并积累这些错误信息。使用 Fatal/Fatalf,测试会立即失败。
Log()/Logf() 调用是信息性的,只有在测试失败时或传递了其他标志时才会显示。
创建一个简单的测试
借鉴 golang.org 的教程 (golang.org/doc/tutorial/add-a-test),让我们为一个名为 Greeter() 的函数创建一个简单的测试,该函数接受一个名字作为参数并返回 "Hello [name]"。代码如下所示:
package greetings
import (
"testing"
)
func TestGreet(t *testing.T) {
name := "Bob"
want := "Hello Bob"
got, err := Greet(name)
if got != want || err != nil {
t.Fatalf("TestGreet(%s): got %q/%v, want %q/nil", name, got, err, want)
}
}
你也可以在这里看到这个例子:play.golang.org/p/vjAhW0hfwHq。
要运行测试,我们只需在包目录中运行 go test。如果测试成功,我们应该看到以下内容:
=== RUN TestGreet
--- PASS: TestGreet (0.00s)
PASS
为了展示失败的情况,我将 want 改成了 Hello Sarah,同时保留了名字 Bob,如下所示:
=== RUN TestGreet
prog.go:21: TestGreet(Bob): got "Hello Bob"/<nil>, want "Hello Sarah"/nil
--- FAIL: TestGreet (0.00s)
FAIL
包含足够的信息来调试测试非常重要。我喜欢包括以下内容:
-
测试的名称
-
如果是表驱动的,执行的表格行的描述
-
我得到的结果(称为
got) -
我期望的结果(称为
want)
现在,让我们来谈谈 Go 的测试首选风格——TDTs。
表驱动测试(TDT)
对于非常简单的测试,上述方法可以正常工作,但通常,你需要测试一个函数的多种成功和失败类型,例如以下场景:
-
如果他们传递了一个错误的参数怎么办?
-
如果网络出现问题并返回错误怎么办?
-
如果数据不在磁盘上怎么办?
为每个条件编写一个测试会导致测试文件中出现大量变动,变得难以阅读和理解。TDT 来救场!TDT 使用了我们在第一章《Go 语言基础》中讨论过的非命名结构体概念。这是唯一一个常见使用它的地方。
这个概念是创建一个结构体列表,其中每个结构体条目表示我们希望看到的测试条件和结果。我们一次执行一个结构体条目来测试函数。
让我们将之前的测试转换为 TDT。在这种情况下,我们的Greet()函数的反应只有两种预期方式,如下所示:
-
我们为
name传递一个空字符串,导致出现错误。 -
其他任何情况都将返回
"Hello"和名字。
让我们编写一个处理这两种情况的 TDT,如下所示:
func TestGreet(t *testing.T) {
tests := []struct{
desc string // What we are testing
name string // The name we will pass
want string // What we expect to be returned
expectErr bool // Do we expect an error
}{
{
desc: "Error: name is an empty string",
expectErr: true,
// name and want are "", the zero value for string
},
{
desc: "Success",
name: "John",
want: "Hello John",
// expectErr is set to the zero value, false
},
}
// Executes each test.
for _, test := range tests {
got, err := Greet(test.name)
switch {
// We did not get an error, but expected one
case err == nil && test.expectErr:
t.Errorf("TestGreet(%s): got err == nil, want err != nil", test.desc)
continue
// We got an error but did not expect one
case err != nil && !test.expectErr:
t.Errorf("TestGreet(%s): got err == %s, want err == nil", test.desc, err)
continue
// We got an error we expected, so just go to the next test
case err != nil:
continue
}
// We did not get the result we expected
if got != test.want {
t.Errorf("TestGreet(%s): got result %q, want %q", test.desc, got, test.want)
}
}
}
这个示例也可以在以下链接找到:play.golang.org/p/vYWW-GiyT-M。
正如你所看到的,TDT 测试比较长,但具有明确的测试参数和清晰的错误输出。
与之前的示例不同,这个测试验证了当name == ""时,我们的错误条件会发生。对于这么简单的情况,使用 TDT 有些过度,但在编写针对 Go 中更复杂函数的测试时,它会成为工具箱中强大的工具。
使用接口创建假对象
测试通常应该是封闭的,也就是说,测试不应该使用位于机器上本地以外的资源。
如果我们正在测试一个客户端与 REST 服务的连接,它不应该实际调用 REST 服务。应该有集成测试来测试与服务的基本连接性,但这些测试应该是小型且少见的,我们在这里不会讨论这些。
为了测试远程资源的行为,我们使用接口创建所谓的假对象。让我们编写一个客户端,通过网络客户端与服务交互,获取用户记录。我们不想测试服务器的逻辑(我们之前测试过的那种逻辑),而是想测试如果 REST 客户端出现错误或从服务端获取到错误记录时会发生什么。
首先,假设我们在一个名为client的包中使用一个Fetch客户端,如下所示:
type Fetch struct{
// Some internals, like an http.Client
}
func (f *Fetch) Record(name string) (Record, error){
// Some code to talk to the server
}
我们在名为Greeter()的函数中使用Fetch来获取我们可能会用来更改对某人的响应的信息,如下所示:
func Greeter(name string, fetch *client.Fetch) (string, error) {
rec, err := fetch.Record(name)
if err != nil {
return "", err
}
if rec.Name != name {
return "", fmt.Errorf("server returned record for %s, not %s", rec.Name, name)
}
if rec.Age < 18 {
return "Greetings young one", nil
}
return fmt.Sprintf("Greetings %s", name), nil
}
由于Fetch是一个与服务通信的具体类型,这很难进行封闭式测试。然而,我们可以将其改为Fetch实现的接口,然后使用假对象。首先,让我们添加接口并更改Greeter的参数,如下所示:
type recorder interface {
Record(name string) (Record, error)
}
func Greeter(name string, fetch recorder) (string, error) {
现在,我们可以传递一个*client.Fetch实例,或者传递任何其他实现了recorder的东西。让我们创建一个实现了recorder的假对象,能够返回对测试有用的结果,如下所示:
type fakeRecorder struct {
data Record
err bool
}
func (f fakeRecorder) Record(name string) (Record, error) {
if f.err {
return "", errors.New("error")
}
return f.data, nil
}
现在,让我们将其集成到 TDT 中,像这样:
func TestGreeter(t *testing.T) {
tests := []struct{
desc string
name string
recorder recorder
want string
expectErr bool
}{
{
desc: "Error: recorder had some server error",
name: "John",
recorder: fakeRecorder{err: true},
expectErr: true,
},
{
desc: "Error: server returned wrong name",
name: "John",
recorder: fakeRecorder{
Record: Record{Name: "Bob", Age: 20},
},
expectErr: true,
},
{
desc: "Success",
name: "John",
recorder: fakeRecorder{
Record: Record{Name: "John", Age: 20},
},
want: "Greetings John",
},
}
for _, test := range tests {
got, err := Greeter(test.name)
switch {
case err == nil && test.expectErr:
t.Errorf("TestGreet(%s): got err == nil, want err != nil", test.desc)
continue
case err != nil && !test.expectErr:
t.Errorf("TestGreet(%s): got err == %s, want err == nil", test.desc, err)
continue
case err != nil:
continue
}
if got != test.want {
t.Errorf("TestGreet(%s): got result %q, want %q", test.desc, got, want)
}
}
}
这个示例可以在这里找到:play.golang.org/p/fjj2WrbGlKY。
现在我们只是模拟从真实客户端Fetch获取的响应。在使用Greeter()的代码中,他们可以简单地传入真实客户端,而在我们的测试中,我们传入fakeRecorder实例。这让我们能够控制测试环境,确保我们的函数以预期的方式处理每种类型的响应。这个测试缺少一个检查当返回Record实例且Age值设置为< 18时的结果。我们将这个作为练习留给你。
第三方测试包
当我写测试时,几乎只有一个工具是我常用的:pkg.go.dev/github.com/kylelemons/godebug/pretty?utm_source=godoc。
pretty允许我轻松地测试两个复杂的结构体/映射/切片是否等价。像这样在测试中使用它非常简单:
if diff := pretty.Compare(want, got); diff != "" {
t.Errorf("TestSomeFunc(%s): -want/+got:\n%s", diff)
}
这个输出以可读的格式显示了缺失的部分(前面加上-)和接收到的部分(前面加上+)。为了更好地控制比较的内容,包提供了一个可以自定义的Config类型。
这段代码更新不频繁,因为它已经能正常工作,但 Kyle 会回应 bug 请求,因此项目仍然在持续维护。
许多 Go 社区的人使用github.com/stretchr/testify这个包集,特别是assert和mock包。
我在这里列出它们是因为它们在 Go 社区中很受欢迎;然而,我会给出以下几点警告:
-
多年来,Go 中使用断言被认为是不好的实践。
-
Go 中的模拟框架通常有一些非常棘手的边缘情况。
Go 的最初作者认为,使用断言对于该语言来说是一种不好的实践且没有必要。当前的 Go 团队已经放宽了这一立场。Go 中的模拟框架通常大量依赖interface{},并且存在一些尖锐的边缘情况。我发现使用模拟对象会导致测试出一些不重要的行为(例如调用顺序或哪些调用被执行了),而不是测试给定输入是否导致预期输出。这对于代码的更改来说,负担较小且不易出错。
原始的模拟框架(github.com/golang/mock)在 Google 被认为是不安全的,因此其使用受到了限制。
总结这一部分,我们了解了 Go 的testing包,如何使用该包编写测试,TDT 方法论,以及我(John Doak)对第三方测试包的看法。
现在,既然你已经了解了如何进行测试,我们将着眼于 Go 1.18 版本中新增的一个重要特性——泛型。
泛型——新晋的“新秀”
泛型是 Go 1.18 中的一项新特性,它对 Go 的未来可能产生深远影响。泛型提供了一种新的方式,通过引入一个名为type的参数,来表示多种类型,从而使得函数可以处理多种类型的数据。
这与标准的interface{}不同,后者的操作总是在运行时进行,你必须将interface{}转换为具体类型才能进行操作。
泛型是一个新特性,因此我们只能给出一个非常概括的概述。目前,Go 社区和 Go 的开发者们还没有一套经过严格测试的最佳实践。这需要在使用特性时积累经验,而目前我们处于泛型的初期阶段,未来会有更多关于泛型的功能发布。
类型参数
类型参数可以添加到函数或struct类型中,以支持泛型类型。然而,一个关键的陷阱是它们不能用于方法!这是最被请求的特性;然而,它给语言带来了一些挑战,语言的开发者们目前还不确定如何处理这些问题(或者是否能够处理)。
类型参数在函数名后面用括号定义。我们来看一个基本的例子:
func sortIntsI int8 |int16 |int32 |int64 {
这样就创建了一个可以排序任何有符号整数类型的函数。I是类型参数,并且它被限制为括号中列出的类型。|管道符号充当or语句,表示I可以是int8或int16类型,依此类推。
一旦I被定义,我们就可以在函数参数中将其用作类型。我们的函数将基于I的切片类型进行操作。需要注意的是,I中的所有值必须是相同的类型;不能是int8和int64类型的混合。
让我们来看一下如何用一个简单的冒泡排序实现来演示这个过程,如下所示:
func sortIntsI int8 |int16 |int32 |int64 {
sorted := false
for !sorted {
sorted = true
for i := range slice[:len(slice)-1] {
if slice[i] > slice[i+1] {
sorted = false
slice[i], slice[i+1] = slice[i+1], slice[i]
}
}
}
}
你可以在这里看到这个例子:go.dev/play/p/jly7i9hz0YT。
我们现在有一个可以用来排序任何有符号整数类型的函数。如果我们没有泛型,这个函数需要一个interface{}类型的参数,并且需要根据切片类型进行类型转换。然后,我们还需要为每种类型编写相应的处理函数。你可以在这里看到一个例子:go.dev/play/p/lqVUk9GQFPX。
另一种选择是使用reflect包中的运行时反射,这种方式较慢且笨重。reflect是一个高级包,包含许多潜在的陷阱,除非绝对必要,否则应该避免使用。这里是这种方法的一个例子:go.dev/play/p/3euBYL9dcsU。
正如你所看到的,泛型版本的实现要简单得多,并且可以显著减少代码量。
让我们来看一下如何通过使用类型约束使代码更易读。
使用类型约束
在我们上一个例子中,int8 |int16 |int32 |int64是我们的类型约束。它限制了我们可以用于I值类型参数的类型,但每次都打出这些类型会显得繁琐,因此我们也可以定义命名的类型约束。
这是引入泛型可能会引起混淆的地方。类型约束是使用interface类型来定义的。以下是一个包含我们之前内容的类型约束的例子:
type SignedInt interface {
int8 |int16 |int32 |int64
}
我们现在可以在之前的代码中使用它,如下所示:
func sortIntsI SignedInt {
这减少了我们需要写的样板代码量。重要的是要注意,SignedInt是类型约束,而不是类型。I是一个已定义的类型参数,充当类型。我经常发现自己写这样的代码:
func sortIntsI SignedInt {
然而,那个语法是不正确的。这里的SignedInt仅仅是约束的定义,而不是一个可以使用的类型。I才是泛型函数中使用的类型。
另一个陷阱是SignedInt只能用于这里定义的确切基本类型。你可能会创建自己的类型,像这样:
type myInt8 int8
如果这样做,你不能将其作为SignedInt类型约束使用。但不用担心——如果我们希望它能够处理基于带符号整数的任何类型,我们可以将其改为以下内容:
type SignedInt interface {
~int8 |~int16 |~int32 |~int64
}
~表示我们希望允许基于此类型的任何类型。
现在,让我们看看如何编写我们的排序函数,以处理不仅仅是带符号整数的情况。
我们可以通过约束做得更好
我们在这里做的可以应用于不仅仅是带符号整数。我们可以更改支持的类型,而我们的函数在更大的切片类型集合上仍然可以正常工作。
我们的函数能工作的唯一条件是类型必须能够对共享相同类型的两个变量使用>操作。这就是if slice[i] > slice[i+1]语句能够工作的原因。
截至本文写作时,当前的 Go 版本没有定义一些计划在未来版本中发布的基本类型约束。这个未来的包,可能会叫做constraints,正在这里开发:pkg.go.dev/golang.org/x/exp/constraints。
它包括一个像这样的类型约束:
type Ordered interface {
~int | ~int8 | ~int16 | ~int32 | ~int64 |
~uint | ~uint8 | ~uint16 | ~uint32 | ~uint64 | ~uintptr |
~float32 | ~float64 |
~string
}
我们将在这里借用它并更改我们的函数签名,如下所示:
func sortSliceO constraints.Ordered {
现在,我们的函数可以排序任何可以使用>和<进行比较的切片类型。可以在这里看到它的工作效果:go.dev/play/p/PwrXXLk5rOT。
当前的内建约束
Go 当前有两个内建的约束,如下所示:
-
comparable -
any
comparable包含所有支持==或!=操作符的类型。这在编写使用map类型的泛型时特别有用。map类型的关键总是comparable类型。
any是interface{}的别名。Go 团队已经将 Go 标准库中所有的interface{}引用更改为any。你可以交替使用它们,并且any作为类型约束允许任何类型。
这是一个使用这些约束从map类型中提取所有键的函数示例:
func ExtractMapKeysK comparable, V any []K {
var keys = make([]K, 0, len(m))
for k := range m {
keys = append(keys, k)
}
return keys
}
这里它在 playground 中运行,试试看吧:go.dev/play/p/h8aKwoTaOLj。
让我们看看如果我们进行类型约束,并且通过要求方法来约束一个类型(如标准接口)会发生什么。
带方法的类型约束
类型约束可以像标准接口一样起作用,它可以要求方法附加到类型上。以下是一个例子:
type StringPrinter interface {
~string
Print()
}
这个类型约束只能由基于string的类型满足,并且该类型必须定义了Print()方法。
这里的一个关键要求是我们使用~string而不是string。标准的string类型永远无法拥有Print()方法,因此这个类型约束永远无法满足。
这是一个简单的约束使用示例:
func PrintStringsS StringPrinter {
for _, s := range slice {
s.Print()
}
}
现在,让我们来看看为什么你可能想要向结构体类型添加类型参数。
向结构体类型添加类型参数
之前,我们编写了一个名为SortSlice()的通用函数来排序切片,但它有一些局限性,因为它只能处理符合constraints.Ordered约束的类型的切片。通常,我们可能需要处理包含基于struct的类型的切片——例如,像下面这样一个类型:
type Record struct {
First, Last string
}
我们的SortSlice()函数无法处理[]Record,因此我们需要做一些不同的处理来应对这种类型的情况。
对于这个例子,我们想使用 Go 内置的sort.Sort()函数。这是一个经过高度优化的排序算法,它根据切片的大小使用多种排序算法。
要使用它,你需要一个实现了sort.Interface类型的类型。该interface类型定义如下:
type Interface interface {
Len() int
Less(i, j int) bool
Swap(i, j int)
}
在 Go 泛型之前,你需要为每个你想排序的类型实现一个适配器类型。例如,下面是一个用来排序[]int的适配器:
type intAdapter struct {
sl []int
}
func (in intAdapter) Len() int {
return len(in.sl)
}
func (in intAdapter) Swap(i, j int) {
in.sl[i], in.sl[j] = in.sl[j], in.sl[i]
}
func (in intAdapter) Less(i, j int) bool {
return in.sl[i] < in.sl[j]
}
你可以这样使用它:
ints := []int{5, 3, 7, 1}
sort.Sort(intAdapter{ints})
你可以在这里看到它的运行:go.dev/play/p/Yl6Al9ylEhd。
然后,你需要对每一个其他的有符号类型或你想排序的其他类型做类似的操作。想象一下,如果要为所有的int8、int16、int32和int64有符号整型做这些操作,你会怎么做?你还需要为所有你想排序的其他类型做同样的事情。
所以,我们想做的是使用泛型来给我们一个单一的适配器类型,这样我们就可以对任何元素类型的切片进行排序。
让我们在结构体上使用type参数,以便创建一个通用适配器,这样我们就可以将任何切片适配到sort.Interface类型,如下所示:
type sortableSlice[T any] struct {
slice []T
less func(T, T) bool
}
func (s sortableSlice[T]) Len() int {
return len(s.slice)
}
func (s sortableSlice[T]) Swap(i, j int) {
s.slice[i], s.slice[j] = s.slice[j], s.slice[i]
}
func (s sortableSlice[T]) Less(i, j int) bool {
return s.less(s.slice[i], s.slice[j])
}
这与之前的intAdapter非常相似,主要有两个区别,具体如下:
-
切片元素是一个
T类型参数,它可以是任何值。 -
我们添加了一个
less字段,它是一个函数,当调用Less()时执行比较操作。
让我们创建一个能够实现func(T, T) bool的函数,适用于我们的Record类型。这个函数会首先比较姓氏,然后再比较全名。代码如下所示:
func recordLess(a, b Record) bool {
aCmp := a.Last + a.First
bCmp := b.Last + b.First
return aCmp < bCmp
}
最后,我们可以使用sortableSlice来编写一个通用排序函数,利用现有的sort.Sort()函数对我们可以进行比较的任何切片进行排序。以下是我们需要执行的代码:
func SortSliceT any bool) {
sort.Sort(sortableSlice[T]{slice: slice, less: less})
}
这是它的实际应用:go.dev/play/p/6Gd7DLgVQ_y。
你会注意到,当我们创建 sortableSlice 实例时,我们在语法中使用了 [T]。这用于告诉 Go T 将是什么类型,在本例中就是传递给 SortSlice 的泛型 T 类型。如果你尝试删除 [T],你会收到以下信息:
cannot use generic type sortableSlice[T any] without instantiation
我们将在下一节中讨论这个问题。
当然,如果你想在不使用 sort.Sort() 函数的情况下进行泛型排序,
这样做可以更少的复杂性。这里是一个使用泛型的快速排序算法的泛型版本:go.dev/play/p/gvPl9jHtAS4。
现在,我们将讨论在 Go 无法推断出泛型函数所使用的类型时,如何调用泛型函数。
调用泛型函数时指定类型
到目前为止,所有泛型案例直到 sortableSlice 函数都允许 Go 编译器推断出将使用的类型,因此也知道如何处理调用该函数。
但是 Go 并不总是能够推断出它需要使用哪种类型。我们可以在上一节中看到,我们告诉 sortableSlice 使用我们定义的 T 泛型类型。
让我们创建一个可以与 SortSlice() 一起使用的函数,用于在类型为 constraints.Ordered 时进行小于比较。代码如下所示:
func orderedCmpO constraints.Ordered bool {
return a < b
}
有了这个,我们可以调用 SortSlice(),并传入任何包含在 constraints.Ordered 中的类型切片,以及我们的新 orderedCmp 泛型函数来排序切片。
让我们试试看,具体如下:
strings := []string{"hello", "I", "must", "be", "going"}
SortSlice(strings, orderedCmp)
哎呀—Go 似乎无法做到这一点,因为我们收到了以下信息:
cannot use generic function orderedCmp without instantiation
这是因为我们传递的是函数,而不是调用该函数。Go 的推断只会在查看接收到的调用类型时进行。目前它不会在 SortSlice() 内部推断 orderedCmp() 被调用并传递 string 类型。所以,要使用它,我们需要告诉它在调用时将使用哪种类型。
相比之下,SortSlice() 不需要这样做,因为它是直接调用的,并且可以从查看传入的参数 strings 推断出 T 将是 string 类型。
通过使用 [string],我们可以为 orderedCmp 提供更多信息,从而使其工作,具体如下:
SortSlice(strings, orderedCmp[string])
现在它知道我们将比较 string 类型,它已经准备好工作了,正如你在这里看到的:go.dev/play/p/kd6sylV17Jz。
如果我们想非常详细地写出来,我们可以做如下操作:
SortSlicestring
现在,让我们来看看一些你在尝试使用泛型时可能会遇到的常见陷阱。
需要注意的陷阱
当你在玩泛型时,有很多陷阱,其中错误信息并不总是很清楚。所以,让我们谈谈其中的一些,以便你能够避免我曾经犯过的错误。
首先是不可行的类型约束。看看你能否在以下代码中找到问题:
type Values interface {
int8 | int16 | int32 |int64
string | []byte
}
func PrintV Values {
fmt.Println(v)
}
func main() {
Printstring
}
如果你运行这个,你会得到以下结果:
cannot implement Values (empty type set)
这是因为 Values 被错误地定义了。我忘记在 int64 后加上 |。没有这个,约束条件就会说值必须是 int8、int16、int32、int64、string 或 []byte 类型。这是一个不可能的类型,也就是说没有东西能实现它。你可以在这里看到这个问题:go.dev/play/p/Nxsz4HKxdc4。
下一个难点是返回实现类型参数的 struct 类型时的实例化问题。以下是一个示例:
type ValueType interface {
string | bool | int
}
type Value[T ValueType] struct {
val T
}
func NewT ValueType Value {
return Value[T]{val: v}
}
func (v Value[T]) Value() T {
return v.val
}
尝试编译此代码时,将显示以下消息:
cannot use generic type Value[T ValueType] without instantiation
一段时间内我没有弄清楚问题出在哪里。结果发现,我还需要在返回值上添加 type 参数。下面是修改后的版本:
func NewT ValueType Value[T] {
有了这个修改,一切正常。尝试这个坏掉的版本(go.dev/play/p/EGTr2zd7qZW)并通过前面提到的修改修复它,以便熟悉操作。
我预期在不久的将来,我们的开发工具会提供更好的错误信息和更好的检测功能。
现在我们已经介绍了泛型的基础知识,接下来讨论一下你应该在什么情况下考虑使用泛型。
何时使用泛型
目前唯一的指导原则来自 Go 泛型功能的创造者 Ian Taylor,如下所示:
"如果你发现自己在多次编写相同代码,唯一的区别只是代码使用了不同的类型,考虑一下是否可以使用类型参数。"
我发现这转化为以下内容:
如果你的函数需要对泛型类型进行 switch 语句,可能应该使用标准接口而不是泛型。
在总结泛型时,我想给你留下一点思考:这是一项新的语言特性,关于它的最佳使用方式还没有定论。我能给出的最佳建议是,在使用这一特性时要保持谨慎。
总结
在这一章中,你已经学习了 Go 语言的核心部分。内容包括错误处理、使用 Go 并发、利用 Go 的测试框架,以及 Go 最新特性泛型的介绍。通过本章学到的技能,对未来的章节至关重要。
现在你应该具备了阅读本书剩余部分中的 Go 代码的能力。此外,本章还给你提供了编写 Go 代码所需的必要技能。我们将利用这些技能操作文件系统中的文件、在远程机器上执行命令,以及构建可以执行各种任务的 RPC 服务。你还将构建聊天机器人以进行 基于聊天的操作(ChatOps),并编写软件来扩展 Kubernetes。这些学到的东西是真正的基础。
接下来,我们将介绍如何设置你的 Go 环境,以便在本地机器上编译代码。让我们开始吧!
第三章:设置你的开发环境
本章我们将讨论如何设置Go开发环境,以便在我们未来的章节中使用,并为未来开发你自己的 Go 软件做准备。
我们将覆盖以下主要内容:
-
在你的计算机上安装 Go
-
本地构建代码
在我们开始之前,先简要介绍一下你需要了解的技术要求,然后再继续阅读。
技术要求
本章节的唯一技术要求如下:
-
一台 Go 工具支持的操作系统的计算机
-
需要互联网连接和网页浏览器来下载 Go 工具
在你的计算机上安装 Go
Go 编译器和工具集可以在 golang.org/dl/ 上找到。在这里,你可以找到适用于 macOS、Windows 和 Linux 平台的多个计算平台的版本。
最常见的平台是AMD64架构,适用于任何 x86 系统。对于 macOS,重要的是要注意,如果你使用的是非 Intel CPU 的机器,如 Apple M1,你需要使用arm64 版本。
在接下来的章节中,我们将描述如何为主要操作系统安装 Go。你应该跳到你打算安装的操作系统部分。
使用安装包安装 macOS
安装 Go 工具集的最简单方法是使用 .pkg 安装包。下载页面提供了 .tar.gz 构建包和 .pkg 安装包。使用 tar 包时,你必须将文件解压到一个位置,并将该位置添加到路径中。这样也意味着你需要手动处理升级。只有在有高级需求时,你才应该选择这种方式。
.pkg 文件使得安装和升级变得简单。只需双击 .pkg 文件并按照屏幕上的提示进行安装。安装过程中可能需要你输入凭据。
安装完成后,打开 Applications/Utilities/terminal.app 终端,并输入 go version,应该会显示类似以下内容:
$ go version
go version go1.17.5 linux/amd64
请注意,版本输出将取决于你下载的 Go 版本和你所运行的平台。
通过 Homebrew 安装 macOS
许多 macOS 开发者更喜欢使用流行的Homebrew (brew.sh) 来安装 Go。如果你是 Homebrew 用户,安装 Go 只需要简单的两步过程,具体内容将在以下章节中进行说明。
安装 Xcode
Go 依赖于 Apple 的Xcode,需要安装 Xcode 才能正常工作。要查看是否已经安装 Xcode,请输入以下命令:
$ xcode-select -p
输出应该类似于以下内容:
$ /Library/Developer/CommandLineTools
如果出现错误,你需要通过访问此链接在 App Store 中安装 Xcode:itunes.apple.com/us/app/xcode/id497799835?mt=12&ign-mpt=uo%3D2。
安装完成后,你可以通过以下命令安装单独的命令行工具:
$ xcode-select --install
现在,让我们来看一下下一步。
更新 Homebrew 并安装 Go
使用以下命令更新 Homebrew 并安装最新的 Go 工具:
$ brew update
$ brew install golang
你可以通过$ go version来验证 Go 的版本。
接下来,我们将查看 Windows 上的安装方法。
使用 MSI 安装 Windows
Windows 的安装类似于其他 Windows 应用程序的安装,使用Microsoft Installer(MSI)文件。只需下载 MSI 文件并按照屏幕上的指示进行操作。默认情况下,这将把 Go 工具安装到Program Files或Program Files (x86)中。
要验证 Go 是否正确安装,请点击开始菜单,在搜索框中输入cmd,然后命令提示符窗口应该会出现。输入go version,它应该会显示已安装的 Go 版本。
接下来,我们将查看 Linux 上的安装方法。
Linux
Linux 的包管理可能会成为一系列书籍的主题,正如 Linus 所指出的,这是 Linux 作为桌面系统如此惨败的原因之一。
如果你正在使用 Linux 进行开发,可能已经知道如何为你的发行版安装软件包。由于我们不能涵盖 Linux 上所有可能的安装方法,接下来我们将介绍如何通过apt、Snap 和tarball进行安装。
在 Ubuntu 上通过 APT 安装 Linux
APT是一个在多个发行版中使用的包管理器。通过 APT 安装 Go 相当简单。
更新并升级 APT 到最新版本,方法如下:
$ sudo apt update
$ sudo apt upgrade
按照以下步骤安装 Go 包:
sudo apt install golang-go
现在,在终端中输入go version,它应该会显示已安装的 Go 版本。
通过 Snap 在 Ubuntu 上安装 Linux
Snap是一个通用的包管理器,旨在通过将所有必要的文件包含在包中,使得在多个发行版或版本中安装软件包变得简单。
如果你已经安装了 Snap,你可以直接使用snap info go来查找可以安装的 Go 版本:

图 3.1 – 显示 snap info go 命令输出的截图
你可以选择通过输入以下命令安装最新的稳定版本的 Go:
sudo snap install go
现在,在终端中输入go version,它应该会显示已安装的 Go 版本。
请注意,您可能会收到关于 Go 包是使用具有经典限制的 Snap 版本构建的警告。在这种情况下,要通过 Snap 安装,您可能需要添加--classic,如下所示:
sudo snap install go --classic
通过 tarball 安装 Linux
为了做到这一点,你需要下载适用于 Linux 和你的平台的包。我们的示例将使用go1.16.5.linux-amd64.tar.gz。你会注意到,文件名中包含了 Go 版本(1.16.5)、操作系统(Linux)和架构(AMD64)。你需要将 Go 的当前版本和你的架构下载到一个目录中。
接下来的这些指令将使用终端。
我们希望将我们的版本安装到/usr/local/go并删除任何以前的安装。这可以通过以下方式实现:
rm -rf /usr/local/go && tar -C /usr/local -xzf go1.16.5.linux-amd64.tar.gz
现在,让我们将目录添加到 PATH 中,以便可以找到 Go 工具。这可以通过以下方式实现:
export PATH=$PATH:/usr/local/go/bin
对于大多数 shell,变化不会立即生效。最简单的方式是打开一个新的 shell 来使 PATH 更新。你也可以使用 source 命令重新加载 shell 的配置文件,前提是你知道配置文件的名称/路径——例如 source $HOME/.profile。
要测试你的 PATH 是否已正确更新,请输入 go version,应返回如下信息:
$ go version
go version go1.16.5 linux/amd64
那么在其他平台上安装 Go 呢?
其他平台
Go 确实可以安装在其他平台上,比如FreeBSD,但这些内容不在本书范围内。请参阅 Go 的安装文档了解其他平台的安装方式。
关于 Go 编译器版本兼容性的说明
Go 项目由 Go 兼容性承诺管理:golang.org/doc/go1compat。其核心是,除非有重大语义版本号变更(1.x.x 到 2.x.x),否则 Go 将保持向后兼容。虽然你可能会听到人们谈论 Go 2.0,但作者们已经明确表示,他们没有计划跳过版本 1。
这意味着为Go 1.0.0编写的软件在最新的Go 1.17.5版本中可以运行。这对于 Go 社区的稳定性来说是一个重大胜利。本书将使用 Go 1.17.5 版本进行修订。
在本节结束时,你应该已经安装了 Go 工具并测试了该工具是否适用于你选择的操作系统。接下来,我们将讨论如何在你的计算机上构建代码。
本地构建代码
当前的 Go 生态系统(Go 1.13 及以后的版本)和工具链允许你在文件系统的任何位置编写 Go 代码。大多数用户选择为其包设置本地 Git 仓库,并在该目录中进行开发。
这是通过 Go 模块实现的,Go 团队将其描述为“存储在文件树中的 Go 包集合,根目录有一个 go.mod 文件。” Go 模块通常代表一个 GitHub 仓库,例如 github.com/user/repository。
大多数 Go 开发者会使用命令行在文件系统环境中移动,并与 Go 工具链进行交互。在本节中,我们将集中讨论如何使用 Unix 命令来访问文件系统以及使用 Go 编译器工具。Go 编译器命令在各操作系统间是相同的,但文件系统命令可能不同,文件路径也可能不同,例如 Windows 使用 \ 作为路径分隔符,而不是 /。
创建模块目录和 go.mod 文件
该目录可以是文件系统中任何你可以访问的地方。godev/ 是一个不错的目录名,并且将其放在你的主目录中(主目录因操作系统而异)是一个合理的选择,这样便于查找。
在该目录中,我将为我的包创建一个新目录。以这个示例为例,我将创建一个名为 hello/ 的目录,表示我的 Go 模块:
$ cd ~
$ mkdir -p ~/godev/hello
$ cd ~/godev/hello
创建我们的模块,我们只需要创建一个包含模块名的 go.mod 文件。模块名通常是 Git 路径,比如 github.com/johnsiilver/fs。
如果你有一个 GitHub 仓库,想把这个示例存储在其中,你可以在我们的命令中替换成你的仓库地址:
$ go mod init example.com/hello
go: creating new go.mod: module example.com/hello
这个 go.mod 文件将包含几个值得注意的关键部分:
module example.com/hello
go 1.17
第一行定义了我们的模块,这是指向 Git 仓库根目录的路径。第二行定义了可以用来编译此模块的 Go 最低版本。根据你使用的功能,模块可能兼容较早版本的 Go,你可以修改此设置来使用较低版本的 Go。
虽然这个示例中没有任何第三方包,但值得注意的是,大多数 go.mod 文件都会有一个 require 部分,列出你模块所导入的包及其版本。
添加依赖时更新模块
当添加第三方包时,你的 go.mod 文件需要修改以包含依赖信息。虽然这会是一个繁琐的任务,但 Go 提供了 go mod tidy 命令来帮助你自动处理。
运行 go mod tidy 会自动检查所有的包导入,并将它们添加到你的 go.mod 文件中。记得在添加任何外部依赖后运行此命令。
添加 hello world
为了学习如何编译和运行 Go 代码,我们将创建一个 hello world 应用程序。在 Go 中,所有的 Go 源文件都以 .go 后缀结尾。
在目录中使用你喜欢的文本编辑器创建一个名为 hello.go 的文件,并插入以下代码:
package main
import "fmt"
func main() {
fmt.Println("Hello World")
}
接下来,让我们运行我们的第一个程序。
运行我们的第一个程序
一旦你保存了这个文件,接下来我们来尝试编译并运行这段代码:
$ go run hello.go
Hello World
$
这将编译我们的源文件并作为二进制运行。你只能在名为 main 的包中使用 go run。
如果我们想为这个操作系统和架构创建一个二进制文件,我们只需运行以下命令:
$ go build hello.go # Builds a program called hello
$ ./hello # Executes the hello binary
Hello World
现在有一个名为 hello 的二进制文件,可以在相同类型的任何操作系统/架构上运行。如果我们的包不是叫做 main,这将编译该包并报告遇到的任何错误,但不会创建二进制文件。
总结
你现在已经创建了你的第一个 Go 模块,初始化了第一个 go.mod 文件,创建了一个 Go 程序,使用 go run 运行了这个程序,并为你的操作系统构建了 Go 可执行文件。本章让你掌握了创建基础 Go 模块所需的技能,以及使用 Go 命令行工具的基本知识,这些工具用于运行 Go 包和构建 Go 程序。Go 开发者在日常工作中都会用到这些技能。
在下一章,我们将介绍 Go 语言的基础知识,包括包的工作方式、测试以及更多的基本内容。
第四章:文件系统交互
每个开发者生活中的一个基本部分就是与文件的交互。文件代表了必须处理和配置的系统数据,缓存项可以被提供,还有许多其他用途。
Go 最强大的特点之一就是它对文件接口的抽象,使得一套通用工具能够与来自磁盘和网络的数据流交互。这些接口设定了一个共同标准,所有主要包都使用它们来导出数据流。不同接口间的转换变成了一个简单的任务,只需用必要的凭证访问文件系统即可。
与特定数据格式相关的包,如 CSV、JSON、YAML、TOML 和 XML,基于这些通用的文件接口构建。这些包使用标准库定义的接口从磁盘或 HTTP 流中读取这些类型的文件。
由于 Go 是跨平台的,你可能希望编写能够在不同操作系统上运行的软件。Go 提供了可以检测操作系统并处理操作系统路径差异的包。
本章将覆盖以下主题:
-
Go 中的所有 I/O 都是文件
-
读取和写入文件
-
流式文件内容
-
跨操作系统路径
-
跨操作系统文件系统
完成本章内容后,你应当掌握一套与各种介质中存储的数据交互的技能,这对你作为 DevOps 工程师的日常工作非常有帮助。
Go 中的所有 I/O 都是文件
Go 提供了一个基于文件的输入输出(I/O)系统。这一点并不令人惊讶,因为 Go 是两位杰出工程师 Rob Pike 和 Ken Thompson 的心血结晶,他们在 贝尔实验室 时设计了 UNIX 和 Plan 9 操作系统——这两个系统几乎把所有事物都视作文件。
Go 提供了 io 包,其中包含与 I/O 基本操作进行交互的接口,如磁盘文件、远程文件和网络服务。
I/O 接口
I/O 的基本单元是 byte,一个 8 位值。I/O 使用字节流来实现读写操作。对于某些 I/O,你只能按顺序从头到尾读取流(如网络 I/O)。一些 I/O,如磁盘,允许你在文件中进行定位。
我们在与字节流交互时进行的一些常见操作包括读取、写入、在字节流中定位某个位置,以及在完成工作后关闭流。
Go 为这些基本操作提供了以下接口:
// Read from an I/O stream.
type Reader interface {
Read(p []byte) (n int, err error)
}
// Write to an I/O stream.
type Writer interface {
Write(p []byte) (n int, err error)
}
// Seek to a certain spot in the I/O stream.
type Seeker interface {
Seek(offset int64, whence int) (int64, error)
}
// Close the I/O stream.
type Closer interface {
Close() error
}
io 包还包含复合接口,如 ReadWriter 和 ReadWriteCloser。这些接口在允许与文件或网络交互的包中很常见。通过这些接口,你可以使用常见的工具,无论底层是什么(例如本地文件系统、远程文件系统或 HTTP 连接)。
在本节中,我们已经了解到 Go 文件交互是基于[]byte类型的,并介绍了基本的 I/O 接口。接下来,我们将学习如何利用这些接口的方法来读取和写入文件。
读取和写入文件
在 DevOps 工具中最常见的场景是需要操作文件:读取、写入、重新格式化或分析这些文件中的数据。这些文件可以有多种格式——JSON、YAML、XML、CSV 等格式,应该都对你来说并不陌生。它们用于配置本地服务以及与云网络提供商进行交互。
在本节中,我们将介绍读取和写入整个文件的基础知识。
读取本地文件
让我们开始使用os.Readfile()函数读取本地磁盘上的配置文件:
data, err := os.ReadFile("path/to/file")
ReadFile()方法从其函数参数中读取文件位置并返回该文件的内容。返回值会存储在 data 变量中。如果文件无法读取,则会返回一个错误。有关错误处理的更多信息,请参阅第二章中关于Go 中的错误处理部分,Go 语言基础。
ReadFile()是一个辅助函数,它调用os.Open()并获取一个io.Reader。io.ReadAll()函数用于读取io.Reader的全部内容。
data是[]byte类型,因此如果你想将其作为string使用,可以通过 s := string(data)简单地将其转换成字符串。这被称为类型转换,即我们将一种类型转换为另一种类型。在 Go 中,只有某些类型可以进行转换。关于转换规则的完整列表,可以在golang.org/ref/spec#Conversions.strings中找到。可以使用b := []byte(s)将其转换回字节数组。其他大多数类型则需要使用一个叫做strconv的包来进行字符串转换(pkg.go.dev/strconv)。
如果文件中表示的数据是 JSON、YAML 等常见格式,那么我们可以高效地检索和写入这些数据。
写入本地文件
写入本地磁盘最常见的方式是使用os.Writefile()。这个方法会将整个文件写入磁盘。如果必要,WriteFile会创建文件,并且如果文件已存在,会将其截断:
if err := os.WriteFile(“path/to/fi”, data, 0644); err != nil {
return err
}
上述代码以 Unix 风格的权限0644将数据写入path/to/fi。如果你之前没接触过 Unix 风格的权限,快速查阅一下网络资料就能帮助你理解。
如果你的数据存储在string中,可以通过[]byte(data)轻松地将其转换为[]byte。WriteFile()是一个封装了os.OpenFile()的函数,它处理文件标志和模式,并在写入完成后关闭文件。
读取远程文件
远程文件的读取方式将取决于具体的实现。不过,这些概念仍然会基于我们之前讨论的io接口。
例如,假设我们想连接到一个存储在 HTTP 服务器上的文本文件,以收集常见的文本格式信息,比如应用程序的指标数据。我们可以连接到该服务器,并以一种与前面示例非常相似的方式检索该文件:
client := &http.Client{}
req, err := http.NewRequest("GET", "http://myserver.mydomain/myfile", nil)
if err != nil {
return err
}
req = req.WithContext(ctx)
resp, err := client.Do(req)
cancel()
if err != nil {
return err
}
// resp contains an io.ReadCloser that we can read as a file.
// Let's use io.ReadAll() to read the entire content to data.
data, err := io.ReadAll(resp.Body)
正如你所看到的,获取io.ReadCloser的设置依赖于我们的 I/O 目标,但它返回的只是io包中的接口,我们可以在任何支持这些接口的函数中使用。
因为它使用了io接口,我们可以做一些非常巧妙的操作,比如将内容直接流式传输到本地文件,而不是将整个文件复制到内存中再写入磁盘。这种方式更快且更加节省内存,因为每次读取的内容都会立即写入磁盘。
让我们使用os.OpenFile()打开一个文件进行写入,并将内容从网页服务器流式传输到文件中:
flags := os.O_CREATE|os.O_WRONLY|os.O_TRUNC
f, err := os.OpenFile("path/to/file", flags, 0644)
if err != nil {
return err
}
defer f.Close()
if err := io.Copy(f, resp.Body); err != nil {
return err
}
OpenFile()是一个更复杂的文件打开方法,适用于你需要写入文件或更精确地控制文件操作的情况。如果你只需要从本地文件读取数据,应该使用os.Open()。这里的标志是标准的类 Unix 位掩码,其作用如下:
-
如果文件不存在,则创建该文件:
os.O_CREATE。 -
向文件写入数据:
os.O_WRONLY。 -
如果文件已存在,则截断文件而不是追加:
os.O_TRUNC。
标志列表可以在这里找到:pkg.go.dev/os#pkg-constants。
io.Copy()从io.Reader读取并写入io.Writer,直到Reader为空。这将文件从 HTTP 服务器复制到本地磁盘。
在本节中,你学习了如何使用os.ReadFile()读取整个文件,如何将[]byte类型转换为string,以及如何使用os.WriteFile()将整个文件写入磁盘。我们还了解了os.Open()与os.OpenFile()之间的区别,并展示了如何使用像io.Copy()和io.ReadAll()这样的实用函数。最后,我们学习了 HTTP 客户端如何将数据流暴露为io接口,并通过这些相同的工具读取数据。
接下来,我们将查看如何将这些文件接口作为流来操作,而不是一次性读取和写入整个文件。
流式传输文件内容
在前面的章节中,我们学习了如何使用os.ReadFile()和os.WriteFile()以大块数据进行读取和写入。
当文件较小时,这种方式效果很好,通常在进行 DevOps 自动化时会遇到这种情况。然而,有时我们想读取的文件非常大——在大多数情况下,你不会希望将一个 2 GiB 的文件全部读入内存。在这种情况下,我们希望以可管理的块流式传输文件内容,这样我们可以在保持较低内存使用的同时进行操作。
这种最基本的版本在上一节中已经展示过。在那里,我们使用了两个流来复制文件:io.ReadCloser来自 HTTP 客户端,io.WriteCloser用于写入本地磁盘。我们使用了io.Copy()函数来将网络文件复制到磁盘文件。
Go 的io接口还允许我们流式处理文件,复制内容,搜索内容,操作输入输出等。
Stdin/Stdout/Stderr 只是文件
在本书中,你会看到我们使用fmt.Println()或fmt.Printf()这两个来自fmt包的函数来向控制台写入数据。这些函数实际上是向表示终端的文件读写数据。
这些函数使用一个名为os.Stdout的io.Writer。当我们在log包中使用相同的函数时,通常是向os.Stderr写入数据。
你可以使用我们一直在使用的相同接口来读写其他文件,也来读写这些文件。当我们想要复制一个文件并将其内容输出到终端时,我们可以这样做:
f, err := os.Open("path/to/file")
if err != nil {
return err
}
if err := io.Copy(os.Stdout, f); err != nil {
return err
}
虽然我们不会详细探讨,os.Stdin只是一个io.Reader。你可以使用io和bufio包从中读取数据。
从流中读取数据
如果我们想读取一个表示用户记录的流,并通过通道返回它们,该怎么办呢?
假设记录是简单的<user>:<id>文本,每条记录由换行符(\n)分隔。这些记录可能存储在 HTTP 服务器或本地磁盘上。这对我们来说并不重要,因为它只是一个接口背后的流。假设我们接收到这个流作为一个io.Reader。
首先,我们将定义一个User结构体:
type User struct{
Name string
ID int
}
接下来,让我们定义一个函数,来拆分我们接收到的每一行:
func getUser(s string) (User, error) {
sp := strings.Split(s, ":")
if len(sp) != 2 {
return User{}, fmt.Errorf("record(%s) was not in the correct format", s)
}
id, err := strconv.Atoi(sp[1])
if err != nil {
return User{}, fmt.Errorf("record(%s) had non-numeric ID", s)
}
return User{Name: strings.TrimSpace(sp[0]), ID: id}, nil
}
getUser()接收一个字符串并返回一个User。我们使用strings包的Split()函数将字符串按:作为分隔符拆分成[]string。
Split()应该返回两个值;如果不是,我们将返回一个错误。
由于我们在拆分字符串,用户 ID 被存储为string类型。但我们希望在User记录中使用整数值。在这里,我们可以使用strconv包的Atoi()方法,将字符串形式的数字转换为整数。如果它不是整数,则说明输入无效,我们将返回一个错误。
现在,让我们创建一个函数,读取流并将User记录写入通道:
func decodeUsers(ctx context.Context, r io.Reader) chan User {
ch := make(chan User, 1)
go func() {
defer close(ch)
scanner := bufio.NewScanner(r)
for scanner.Scan() {
if ctx.Err() != nil {
ch <- User{err: ctx.Err()}
return
}
u, err := getUser(scanner.Text())
if err != nil {
u.err = err
ch <- u
return
}
ch <- u
}
}()
return ch
}
这里,我们使用的是bufio包的Scanner类型。Scanner允许我们获取一个io.Reader并扫描它,直到找到分隔符。默认情况下,分隔符是\n,但你可以使用.Split()方法来更改它。Scan()将在读取器输出结束前一直返回true。请注意,当io.Reader到达流的末尾时,会返回一个错误io.EOF。
每次调用Scan()后,扫描器会存储读取的字节,你可以通过.Text()方法将其作为string提取出来。.Text()中的内容会在每次调用.Scan()时发生变化。同时,请注意,我们会检查Context对象,如果它被取消,则停止执行。
我们将该string的内容传递给我们之前定义的getUser()。如果我们收到一个error,我们将把它返回给User记录,以通知调用者错误。否则,我们返回包含所有信息的User记录。
现在,让我们对一个文件进行调用:
f, err := os.Open("path/to/file/with/users")
if err != nil {
return err
}
defer f.Close()
for user := range decodeUsers(ctx, f) {
if user.err != nil {
fmt.Println("Error: ", user.err)
return err
}
fmt.Println(user)
}
在这里,我们打开磁盘上的文件并将其传递给decodeUsers()。我们从输出通道接收一个User记录,并在读取文件流的同时并发地将用户打印到屏幕上。
我们本可以通过http.Client打开文件并将其传递给decodeUsers(),而不是使用os.Open()。完整的代码可以在这里找到:play.golang.org/p/OxehTsHT6Qj。
向流写入数据
向流写入数据更加简单——我们只需将User转换为string并将其写入io.Writer。如下所示:
func writeUser(ctx context.Context, w io.Writer, u User) error {
if ctx.Err() != nil {
return ctx.Err()
}
if _, err := w.Write([]byte(user.String())); err != nil {
return err
}
return nil
}
在这里,我们接受一个io.Writer,它代表了写入的目标位置,以及一个我们想写入该输出的User记录。我们可以使用它将数据写入磁盘上的文件:
f, err := os.OpenFile("file", flags, 0644); err != nil{
return err
}
defer f.Close()
for i, u := range users {
// Write a carriage return before the next entry, except
// the first entry.
if i != 0 {
if err := w.Write([]byte("\n")); err != nil {
return err
}
}
if err := writeUser(ctx, w, u); err != nil {
return err
}
}
在这里,我们打开了本地磁盘上的一个文件。当我们包含的函数(未显示)返回时,文件将被关闭。然后,我们将存储在变量 users([]Users)中的User记录逐个写入文件。最后,我们在每条记录之前(除了第一条记录)写入了一个回车符("\n")。
您可以在这里查看实际演示:play.golang.org/p/bxuFyPT5nSk。我们提供了一个使用通道的流式版本,您可以在这里找到:play.golang.org/p/njuE1n7dyOM。
在下一部分,我们将学习如何使用path/filepath包编写适用于多个操作系统的软件,这些操作系统使用不同的路径分隔符。
操作系统无关的路径处理
Go 语言的一个最大优势是其多平台支持。开发人员可以在 Linux 工作站上开发,并将相同的 Go 程序重新编译为本地代码后,在 Windows 服务器上运行。
开发跨多个操作系统运行的软件时,访问文件是一个难点。每个操作系统的路径格式稍有不同。最明显的例子是不同操作系统的文件分隔符:Windows 上是\,而类 Unix 系统上是/。更不明显的是如何在特定操作系统上转义特殊字符,甚至在 Unix 类操作系统之间也可能有所不同。
path/filepath包提供了访问函数的功能,允许您处理本地操作系统的路径。这不应与根path包混淆,后者看起来类似,但处理的是更通用的 URL 样式路径。
我正在运行哪个操作系统/平台?
虽然我们将讨论如何使用无关操作系统的函数获取文件访问权限并执行路径处理,但了解您运行的操作系统仍然非常重要。您可能会根据运行的操作系统使用不同的文件位置。
使用runtime包,您可以检测到您运行的操作系统和平台:
fmt.Println(runtime.GOOS) // linux, darwin, ...
fmt.Println(runtime.GOARCH) // amd64, arm64, ...
这将为您提供正在运行的操作系统。我们可以使用go tool dist list命令打印出 Go 支持的当前操作系统类型和硬件架构列表。
使用 filepath
使用filepath,在处理路径时可以忽略所在操作系统的路径规则。路径被分为以下几个部分:
-
路径中的目录
-
路径中的文件
文件路径的最终目录或文件称为基础路径。你的二进制文件运行所在的路径称为工作目录。
连接文件路径
假设我们想访问一个名为config.json的配置文件,它存储在与我们的二进制文件相同目录下的config/目录中。让我们使用os和path/filepath以一种适用于所有操作系统的方式来读取该文件:
wd, err := os.Getwd()
if err != nil {
return err
}
content, err := os.ReadFile(filepath.Join(wd, "config", "config.json"))
在这个例子中,我们首先获取工作目录。这允许我们相对于我们的二进制文件所在位置进行调用。
filepath.Join() 允许我们将路径的各个组成部分连接成一个单一的路径。它会为你填充操作系统特定的目录分隔符,并使用本地的路径规则。在类 Unix 系统上,可能是 /home/jdoak/bin/config/config.json,而在 Windows 上,则可能是 C:\Documents and Settings\jdoak\go\bin\config\config.json。
拆分文件路径
在某些情况下,根据路径分隔符将文件路径拆分开来是很重要的。filepath 提供了以下功能:
-
Base(): 返回路径的最后一个元素 -
Ext(): 返回文件扩展名(如果有) -
Split(): 返回分割后的目录和文件
我们可以使用这些来获取路径的各个部分。当我们希望将文件复制到另一个目录并保留文件名时,这可能会很有用。
让我们将一个文件从其位置复制到我们操作系统的TMPDIR:
fileName := filepath.Base(fp)
if fileName == "." {
// Path is empty
return nil
}
newPath := filepath.Join(os.TempDir(), fileName)
r, err := os.Open(fp)
if err != nil {
return err
}
defer r.Close()
w, err := os.OpenFile(newPath, O_WRONLY | O_CREATE, 0644)
if err != nil {
return err
}
defer w.Close()
// Copies the file to the temporary file.
_, err := io.Copy(w, r)
return err
现在,是时候看一下可以用来引用文件的不同路径选项,以及filepath包如何帮助你了。
相对和绝对路径处理
访问文件系统时有两种类型的路径处理方式:
-
绝对路径: 从根目录到文件的路径处理
-
相对路径: 在文件系统中从当前位置进行路径处理
在开发过程中,将相对路径转换为绝对路径及其反向转换通常很方便。
filepath 提供了几个函数来帮助处理这些问题:
-
Abs(): 返回绝对路径。如果它不是绝对路径,则返回工作目录以及路径。 -
Rel(): 返回路径相对于基本路径的相对路径。
我们将让你自己尝试使用这些功能。
在本节中,我们学习了如何使用path/filepath和runtime包处理不同操作系统的文件路径。我们介绍了runtime.GOOS来帮助您检测用户正在使用的操作系统,os.Getwd()来确定程序在文件系统中的位置。我们还介绍了os.TempDir()来定位您的操作系统中用于临时文件的位置。最后,我们学习了path/filepath中的函数,这些函数允许您在不考虑操作系统的情况下组合和拆分文件路径,并输出特定于操作系统的结果。
接下来,我们将看看 Go 的新 io/fs 包,它是在版本 1.16 中引入的。它通过类似于 io 对文件的处理方式,引入了新的接口来抽象文件系统。
操作系统无关的文件系统
在最新的 Go 版本中,最令人兴奋的两项新功能是新的 io/fs 和 embed 包,它们是在 Go 1.16 中引入的。
尽管我们已经展示了通过 os 包访问本地文件系统的通用方式,并通过 filepath 进行通用的文件路径操作,但我们还没有看到访问整个文件系统的通用方法。
在云计算时代,文件也很可能存储在远程数据中心的文件系统中,比如 Microsoft Azure 的 Blob 存储、Google Cloud 的 Filestore 或 Amazon AWS 的 EFS,就像它们存储在本地磁盘上一样。
这些文件系统每个都有一个用于在 Go 中访问文件的客户端,但它们是特定于该网络服务的。我们不能像处理本地文件系统一样处理这些文件。io/fs 旨在提供一个基础,帮助解决这个问题。
另一个问题是许多文件必须与二进制文件一起打包,通常是在容器定义中。这些文件在程序的生命周期内不会改变。将它们包含在二进制文件中并通过文件系统接口访问会更方便。一个需要图像、HTML 和 CSS 文件的简单 Web 应用程序就是这种用例的一个典型示例。新的 embed 包旨在解决这个问题。
io.fs 文件系统
我们新的 io/fs 文件系统导出了可以由文件系统提供者实现的接口。根接口 FS 的定义最简单:
type FS interface {
Open(name string) (File, error)
}
这让你可以打开任何文件,其中 File 被定义如下:
type File interface {
Stat() (FileInfo, error)
Read([]byte) (int, error)
Close() error
}
这提供了最简单的文件系统。你可以打开路径中的文件,并获得文件的信息或读取文件。由于文件系统在功能上有差异,这是所有给定文件系统之间唯一共享的功能。
一个 FS(如 ReadDirFS 和 StatFS),它允许进行文件遍历并提供目录信息。注意,FS 对象缺少可写性。你必须自己提供一个,因为 Go 作者没有将其定义为标准库的一部分。
embed
embed 包允许你使用 //go:embed 指令将文件直接嵌入到二进制文件中。
embed 可以通过三种方式嵌入文件,如下所示:
-
作为字节
-
作为一个字符串
-
转换为
embed.FS(它实现了fs.FS)
前两个功能通过将指令放置在特定的变量类型上完成:
import _ "embed"
//go:embed hello.txt
var s string
//go:embed world.txt
var b []byte
//go:embed hello.txt 表示 Go 指令,指示编译器获取名为 hello.txt 的文件并将其存储在变量中。
在import行上的_指示编译器忽略我们没有直接使用embed的事实。这称为匿名导入,即我们需要加载一个包但不直接使用其功能。没有_时,如果未使用导入的包,我们将收到编译错误。
使用 embed.FS 的最终方法在你希望将多个文件嵌入文件系统时非常有用:
// The lines beginning with //go: are not comments, but compiler directives
//go:embed image/*
//go:embed index.html
var content embed.FS
现在我们有一个 fs.FS,它存储了我们 image 目录中的所有文件和 index.html 文件。这些文件在我们发布二进制文件时不再需要包含在容器文件系统中。
遍历我们的文件系统
io/fs 包提供了一种与文件系统无关的遍历方法,前提是文件系统支持该功能。在前面的示例中,我们有一个嵌入式文件系统中的目录,里面存放着图像文件。我们可以使用目录遍历器打印出所有 .jpg 文件:
err := fs.WalkDir(
content,
".",
func(path string, d fs.DirEntry, err error) error {
if err != nil {
return err
}
if !d.IsDir() && filepath.Ext(path) == ".jpg" {
fmt.Println("jpeg file: ", path)
}
return nil
},
)
上述函数遍历了我们嵌入式文件系统(content)的目录结构(从根目录 "." 开始),并调用了已定义的函数,将文件路径、目录条目和错误(如果有)传递给它。
在我们的函数中,如果文件不是目录且具有 .jpg 扩展名,我们只是打印文件的路径。
那么,使用 io/fs 访问其他类型文件系统的包怎么办呢?
io/fs 的未来
在写作时,io/fs 的主要用户是 embed。然而,我们开始看到第三方包实现了这个接口。
absfs 为他们的 boltfs/memfs/os 文件系统包提供了一个 io.FS 钩子(github.com/absfs)。这些包中的几个封装了流行的 afero 文件系统包(github.com/spf13/afero)。Azure 有一个非官方的包,支持 Blob 存储(github.com/element-of-surprise/azfs)。
还有一些包可以访问Redis、GroupCache、memfs、本地文件系统以及 github.com/gopherfs/fs 上的工具支持。
注意
github.com/element-of-surprise 和 github.com/gopherfs 由作者拥有。
在这一节中,你了解了 Go 的 io/fs 包,以及它如何成为与文件系统交互的标准。你还学会了如何使用 embed 包将文件嵌入到二进制文件中,并通过 io/fs 接口访问它们。
我们只是触及了表面
我强烈建议你阅读标准库的 GoDoc 页面,以便熟悉其功能。以下是本章涉及的 GoDocs。在这里,你可以找到许多处理文件的有用工具:
-
文件接口和基本 I/O 函数:
pkg.go.dev/io -
缓冲 I/O 包:
pkg.go.dev/bufio -
将字符串转换为/从其他类型转换:
pkg.go.dev/strconv -
操作字符串的包:
pkg.go.dev/strings -
操作字节的包:
pkg.go.dev/bytes -
操作操作系统交互的包:
pkg.go.dev/os -
用于正斜杠路径(如 URL)的包:
pkg.go.dev/path -
文件路径包:
pkg.go.dev/path/filepath -
文件系统接口:https://pkg.go.dev/io/fs
-
嵌入式文件系统:https://pkg.go.dev/embed
在这一节中,我们学习了如何使用io接口将数据流进流出文件,以及os包的Stdin/Stdout/Stderr实现,用于读取和写入程序的输入/输出。我们还学习了如何使用bufio包按分隔符读取数据,以及如何使用strings包拆分字符串内容。
概述
本章为你提供了 Go 语言中处理文件 I/O 的基础。你学习了io包及其文件抽象,并了解了如何将文件读写到磁盘。接着,你学习了如何流式传输文件内容,以便与网络合作并提高内存效率。然后,你了解了path/filepath包,它可以帮助你处理多种操作系统。最后,你了解了 Go 的文件系统无关接口,用于与任何文件系统进行交互,从新的embed文件系统开始。
在下一章,你将学习如何使用流行的 Go 包与常见数据类型和存储交互。在那里,你将需要依赖本章中的文件和文件系统包来与数据类型进行交互。
与数据和存储系统的交互对于 DevOps 工作至关重要。它使我们能够读取和更改软件配置,存储数据并使其可搜索,要求系统代我们执行工作,并生成报告。
那么,让我们开始吧!
第五章:使用常见的数据格式
DevOps 工程师需要的关键技能之一是能够跨各种存储介质操作数据。
在上一章中,我们与本地文件系统交互,读取和流式传输文件。这为我们在本章中将要学习的技能打下了基础。
本章将重点讲解如何操作工程师常用的常见数据格式。这些格式用于配置服务、结构化日志数据,以及导出度量数据,当然还有很多其他用途。
在本章中,你将学习如何使用struct字段标签来存储有关字段的元数据。此外,你还将学习如何在处理大量数据时高效地流式传输这些格式。
掌握这些技能将使你能够通过操作配置文件、查找可能包含日志或度量数据的记录,以及将数据导出到 Excel 中进行报告,从而与服务进行交互。
本章将涉及以下主题:
-
CSV 文件
-
流行的编码格式
在接下来的章节中,我们将深入探讨如何使用最古老的格式之一——CSV 来处理数据。
让我们开始吧!
技术要求
本章的代码文件可以从github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/5下载
CSV 文件
CSV 是 DevOps 工程师最常遇到的常见数据源之一。
这种简单的格式长期以来一直是企业界的主流,是将数据从系统中导出进行处理,并再导入数据存储的最简单方式之一。
许多大型云服务提供商的关键系统,如 Google 的 GCP 和 Microsoft 的 Azure,依赖 CSV 格式的关键数据源和系统。我们已经看到像网络建模和关键数据报告这样的系统存储在 CSV 中。
数据科学家喜欢 CSV,因为它易于搜索和流式处理。能够在软件中快速可视化数据的额外优点更是增加了它的吸引力。
和许多其他格式一样,它是人类可读的,这使得数据可以手动操作。
在本节中,我们将专注于使用以下方法导入和导出 CSV 数据:
-
strings包和bytes包 -
encoding/csv包
此外,我们还将探讨如何使用excelize包将数据导入和导出到流行的 Excel 电子表格格式。excelize是一个广泛使用的 Microsoft Excel 工具包。
现在,让我们讨论如何使用简单的字符串/字节操作包来读写 CSV 文件。
使用 strings 包进行基本的值分隔
Go 提供了几个在操作string和[]byte类型时非常有用的包:
-
strings -
bytes
这些包提供了类似的功能,如以下内容:
-
分割数据的函数,如
strings.Split() -
合并带分隔符数据的函数,如
strings.Join() -
实现了
io包接口的缓冲区类型,例如bytes.Buffer和strings.Builder
处理 CSV 文件时,开发人员可以选择流式读取数据或一次性读取整个文件。
许多开发人员更喜欢将整个文件读取到内存中,并将其从[]byte类型转换为string类型。字符串对开发人员来说更容易理解连接和拆分规则。
然而,这在转换过程中会创建一个副本,这可能会导致效率低下,因为你需要使用双倍的内存并且占用一些 CPU 进行复制。当出现这个问题时,开发人员通常会使用bytes和bufio包。虽然这些包稍微难以使用,但它们避免了不必要的转换开销。
让我们看看如何读取整个文件并将条目转换成结构化记录。
读取整个文件后的转换
在进行基本的 CSV 操作时,有时候更简单的做法是使用换行符拆分数据,然后根据逗号或其他分隔符来拆分每一行。假设我们有一个包含名字和姓氏的 CSV 文件,我们将这个 CSV 文件拆分为记录:
type record []string
func (r record) validate() error {
if len(r) != 2 {
return errors.New("data format is incorrect")
}
return nil
}
func (r record) first() string {
return r[0]
}
func (r record) last() string {
return r[1]
}
func readRecs() ([]record, error) {
b, err := os.ReadFile("data.csv")
if err != nil {
return nil, err
}
content := string(b)
lines := strings.Split(content, "\n") // Split by line
var records []record
for i, line := range lines {
// Skip empty lines
if strings.Trimspace(line) == "" {
continue
}
var rec record = strings.Split(line, ",")
if err := rec.validate(); err != nil {
return nil, fmt.Errorf("entry at line %d was invalid: %w", i, err)
}
records = append(records, rec)
}
return records, nil
}
上面的代码执行以下操作:
-
它基于一个字符串切片
[]string定义了一个record类型。 -
我们可以通过调用
validate()方法来检查一个record类型是否有效。 -
可以使用
first()方法获取记录的名字。 -
可以使用
last()方法获取记录的姓氏。 -
它定义了一个
readRecs()函数来读取名为data.csv的文件。 -
它将整个文件读取到内存中,并转换为名为
content的字符串。 -
content通过换行符\n拆分,每个条目代表一行。 -
它通过逗号(
,)来拆分每一行。 -
它将
Split返回的每个结果(一个[]string类型)赋值给record类型。 -
它将所有记录汇总到一个记录切片
[]record中。
你可以在play.golang.org/p/CVgQZzScO8Z查看此代码的运行情况。
按行转换
如果文件较大且我们希望提高效率,可以使用bufio和bytes包:
func readRecs() ([]record, error) {
file, err := os.Open("data.csv")
if err != nil {
return nil, err
}
defer file.Close()
scanner := bufio.NewScanner(fakeFile)
var records []record
lineNum := 0
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == "" {
continue
}
var rec record = strings.Split(line, ",")
if err := rec.validate(); err != nil {
return nil, fmt.Errorf("entry at line %d was invalid: %w", lineNum, err)
}
records = append(records, rec)
lineNum++
}
return records, scanner.Err()
}
这与之前的代码不同,因为以下情况发生了:
-
我们逐行读取每一行,使用
bufio.Scanner,而不是读取整个文件。 -
scanner.Scan()会读取下一组内容,直到遇到\n。 -
该内容可以通过
scanner.Text()获取。
你可以在play.golang.org/p/2JPaNTchaKV查看此代码的运行情况。
在这个版本中,我们仍然对每一行进行[]byte转换为string类型。如果你对不做这种转换的版本感兴趣,请参考play.golang.org/p/RwsTHzM2dPC。
写入记录
使用我们之前测试过的方法,写入 CSV 记录是相当简单的。如果在读取记录之后,我们希望对其进行排序并将其写回文件,可以使用以下代码实现:
func writeRecs(recs []record) error {
file, err := os.OpenFile("data-sorted.csv", os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0644)
if err != nil {
return err
}
defer file.Close()
// Sort by last name
sort.Slice(
recs,
func(i, j int) bool {
return recs[i].last() < recs[j].last()
},
)
for _, rec := range recs {
_, err := file.Write(rec.csv())
if err != nil {
return err
}
}
return nil
}
我们还可以修改record类型,添加这个新方法:
// csv outputs the data in CSV format.
func (r record) csv() []byte {
b := bytes.Buffer{}
for _, field := range r {
b.WriteString(field + ",")
}
b.WriteString("\n")
return b.Bytes()
}
你可以在play.golang.org/p/qBCDAsOSgS6看到这段代码的运行情况。
writeRecs()函数执行以下操作:
-
它打开
data-sorted.csv进行写入。 -
它使用
sort包中的sort.Slice()对记录进行排序。 -
它循环遍历记录并写出 CSV 文件,这是由新的
csv()方法生成的。
csv()方法执行以下操作:
-
它创建了一个
bytes.Buffer接口,类似于一个内存中的文件。 -
它循环遍历记录中的每个字段,并写入字段值,后跟逗号。
-
它在 CSV 行的内容后写入回车符。
-
它返回一个
[]bytes类型的缓冲区,现在表示单行数据。
使用encoding/csv包
为了处理符合 RFC 4180 标准的 CSV 编码,www.rfc-editor.org/rfc/rfc4180.html,标准库提供了encoding/csv包。
开发者应选择使用此包处理符合此规范的 CSV。
这个包提供了两种类型来处理 CSV:
-
Reader用于读取 CSV。 -
Writer用于写入 CSV。
在本节中,我们将解决与之前相同的问题,但我们将使用Reader和Writer类型。
一行一行读取
与之前一样,我们想要一次读取文件中的每个 CSV 条目,并将其处理为record类型:
func readRecs() ([]record, error) {
file, err := os.Open("data.csv")
if err != nil {
return nil, err
}
defer file.Close()
reader := csv.NewReader(file)
reader.FieldsPerRecord = 2
reader.TrimLeadingSpace = true
var recs []record
for {
data, err := reader.Read()
if err != nil {
if err == io.EOF{
break
}
return nil, err
}
rec := record(data)
recs = append(recs, rec)
}
return recs, nil
}
你可以在go.dev/play/p/Sf6A1AbbQAq查看这段代码的实际运行情况。
这个函数利用我们的 reader 执行以下操作:
-
将文件传递给我们的
NewReader()构造函数。 -
设置 reader 要求每条记录有两个字段。
-
删除行首的空格。
-
读取每条记录并将其存储在
[]record切片中。
Reader类型还有其他字段可以改变数据的读取方式。更多信息请参考pkg.go.dev/encoding/csv。
此外,Reader提供了一个ReadAll()方法,可以一次性读取所有记录。
一行一行写入
CSV 的Reader类型的伴侣,Writer,使得写入文件变得简单。让我们替换之前writeRecs()函数中的写入部分:
w := csv.NewWriter(file)
defer w.Flush()
for _, rec := range recs {
if err := w.Write(rec); err != nil {
return err
}
}
return nil
这是可运行的代码:play.golang.org/p/7-dLDzI4b3M
上面的代码执行以下操作:
-
它生成一个新的
Writer类型,写入我们的文件。 -
它在函数退出时将内容刷新到文件。
-
它将每条记录写出为 CSV 文件,每行一条。
在处理 Excel 时使用 excelize
微软的 Excel 自 1980 年代以来一直是可视化数据的流行工具。尽管该程序的功能不断增强,但它的简易性帮助电子表格成为大多数企业中常见的工具。
虽然 Excel 不是 CSV 格式,但它可以导入和导出 CSV 数据。对于基本用法,你可以使用本章前面详细介绍的encoding/csv包。
然而,如果你的组织使用 Excel,使用其原生格式来写入数据并提供数据的可视化展示会更有帮助。excelize 是一个第三方 Go 包,可以帮助你完成这项工作。
包含该包的地址为 github.com/qax-os/excelize/tree/v2。此外,官方文档可以在 xuri.me/excelize/ 查阅。
还有一个 Excel 的在线版本,作为微软 Office 365 的一部分。你可以直接在那儿操作电子表格;不过,我发现离线操作电子表格然后再导入会更方便。
如果你对 REST API 感兴趣,可以在 docs.microsoft.com/en-us/sharepoint/dev/general-development/excel-services-rest-api 阅读相关内容。
创建一个 .xlsx 文件并添加一些数据
Excel 有一些特性,对于理解它非常有帮助:
-
一个 Excel 文件具有
.xlsx扩展名。 -
每个
.xlsx文件包含工作表。 -
每个工作表包括一组行和列。
-
.xlsx文件有一个默认的工作表,称为 Sheet1。 -
一行和一列的交点称为单元格。
-
列从字母 A 开始。
-
行从数字 1 开始。
我们将添加一些代表虚构设备舰队的服务器数据。这些数据包括服务器名称、硬件代数、获取时间以及 CPU 厂商:
func main() {
const sheet = "Sheet1"
xlsx := excelize.NewFile()
xlsx.SetCellValue(sheet, "A1", "Server Name")
xlsx.SetCellValue(sheet, "B1", "Generation")
xlsx.SetCellValue(sheet, "C1", "Acquisition Date")
xlsx.SetCellValue(sheet, "D1", "CPU Vendor")
xlsx.SetCellValue(sheet, "A2", "svlaa01")
xlsx.SetCellValue(sheet, "B2", 12)
xlsx.SetCellValue(sheet, "C2", mustParse("10/27/2021"))
xlsx.SetCellValue(sheet, "D2", "Intel")
xlsx.SetCellValue(sheet, "A3", "svlac14")
xlsx.SetCellValue(sheet, "B3", 13)
xlsx.SetCellValue(sheet, "C3", mustParse("12/13/2021"))
xlsx.SetCellValue(sheet, "D3", "AMD")
if err := xlsx.SaveAs("./Book1.xlsx"); err != nil {
panic(err)
}
}
上面的代码执行了以下操作:
-
它创建了一个 Excel 电子表格。
-
它添加了列标签。
-
它添加了两个服务器,
slvaa01和slvac14。 -
它保存了 Excel 文件。
有一个 mustParse() 函数(上面使用但未定义),它将表示日期的字符串转换为 time.Time。在 Go 中,当你看到函数名之前有 must 时,按惯例如果函数遇到错误,它会引发 panic。
你可以在 github.com/PacktPublishing/Go-for-DevOps/blob/rev0/chapter/5/excel/simple/excel.go 仓库中找到可运行的代码。
这个示例是向工作表添加数据的最简单方式。然而,它的可扩展性不强。让我们创建一个更具可扩展性的方法:
type serverSheet struct {
mu sync.Mutex
sheetName string
xlsx *excelize.File
nextRow int
}
func newServerSheet() (*serverSheet, error) {
s := &serverSheet{
sheetName: "Sheet1",
xlsx: excelize.NewFile(),
nextRow: 2,
}
s.xlsx.SetCellValue(s.sheetName, "A1", "Server Name")
s.xlsx.SetCellValue(s.sheetName, "B1", "Generation")
s.xlsx.SetCellValue(s.sheetName, "C1", "Acquisition")
s.xlsx.SetCellValue(s.sheetName, "D1", "CPU Vendor")
return s, nil
}
上面的代码执行了以下操作:
-
它为管理我们的 Excel 工作表创建了一个
serverSheet类型。 -
它有一个构造函数来添加我们的列标签。
现在我们需要一些方法来添加数据:
func (s *serverSheet) add(name string, gen int, acquisition time.Time, vendor CPUVendor) error {
s.mu.Lock()
defer s.mu.Unlock()
if name == "" {
return errors.New("name cannot be blank")
}
if gen < 1 || gen > 13 {
return errors.New("gen was not in range")
}
if acquisition.IsZero() {
return errors.New("acquisition must be set")
}
if !validCPUVendors[vendor] {
return errors.New("vendor is not valid )
}
s.xlsx.SetCellValue(s.sheetName, "A" +
strconv.Itoa(s.nextRow), name)
s.xlsx.SetCellValue(s.sheetName, "B" + strconv.Itoa(s.nextRow), gen)
s.xlsx.SetCellValue(s.sheetName, "C" + strconv.Itoa(s.nextRow), acquisition)
s.xlsx.SetCellValue(s.sheetName, "D" + strconv.Itoa(s.nextRow), vendor)
s.nextRow++
return nil
}
这段代码执行了以下操作:
-
它使用锁来防止多次调用。
-
它执行非常基础的数据验证检查。
-
它添加了一行,并递增我们的内部
nextRow计数器。
现在我们有了一个更具可扩展性的方法来向工作表添加数据。接下来,让我们讨论如何总结数据。
数据汇总
有两种方式可以总结添加的数据:
-
在我们的对象中跟踪汇总数据
-
Excel 数据透视表
对于我们的示例,我将使用第一种方法。这个方法有几个优点:
-
它更容易实现。
-
它执行更快的计算。
-
它从电子表格中删除了复杂的计算。
然而,它有一个显著的缺点:
- 数据变化不会影响汇总。
为了跟踪我们的数据汇总,让我们添加一个struct类型:
type summaries struct {
cpuVendor cpuVendorSum
}
type cpuVendorSum struct {
unknown, intel, amd int
}
让我们修改之前写的add()方法来总结我们的表格:
...
s.xlsx.SetCellValue(s.sheetName, "D" + strconv.Itoa(s.nextRow), vendor)
switch vendor {
case Intel:
s.sum.cpuVendor.intel++
case AMD:
s.sum.cpuVendor.amd++
default:
s.sum.cpuVndor.unknown++
}
s.nextRow++
return nil
}
func (s *serverSheet) writeSummaries() {
s.xlsx.SetCellValue(s.sheetName, "F1", "Vendor Summary")
s.xlsx.SetCellValue(s.sheetName, "F2", "Vendor")
s.xlsx.SetCellValue(s.sheetName, "G2", "Total")
s.xlsx.SetCellValue(s.sheetName, "F3", Intel)
s.xlsx.SetCellValue(s.sheetName, "G3", s.summaries.cpuVendor.intel)
s.xlsx.SetCellValue(s.sheetName, "F4", AMD)
s.xlsx.SetCellValue(s.sheetName, "G4", s.summaries.cpuVendor.amd)
}
上面的代码执行以下操作:
-
它查看我们的供应商并将其添加到我们的汇总计数器中。
-
它添加了一个方法,将我们的汇总写入工作表。
接下来,让我们讨论如何使用这些数据添加可视化。
添加可视化
使用 Excel 而不是 CSV 进行输出的原因之一是添加可视化元素。这使得你可以快速生成用户可以查看的报告,这些报告比 CSV 更具吸引力,而且比网页更容易编写。
添加图表是通过AddChart()方法完成的。AddChart()接受一个表示 JSON 的字符串,用于指示如何构建图表。在我们的示例中,你将看到一个名为chart的包,它提取了excelize中的私有类型,这些类型用于表示图表,并将其转换为公共类型。通过这种方式,我们可以使用一个类型化的数据结构,而不是已经转换成该结构的 JSON。这样也方便了发现你可能想要设置的值:
func (s *serverSheet) createCPUChart() error {
c := chart.New()
c.Type = "pie3D"
c.Dimension = chart.FormatChartDimension{640, 480}
c.Title = chart.FormatChartTitle{Name: "Server CPU Vendor Breakdown"}
c.Format = chart.FormatPicture{
FPrintsWithSheet: true,
NoChangeAspect: false,
FLocksWithSheet: false,
OffsetX: 15,
OffsetY: 10,
XScale: 1.0,
YScale: 1.0,
}
c.Legend = chart.FormatChartLegend{
Position: "bottom",
ShowLegendKey: true,
}
c.Plotarea.ShowBubbleSize = true
c.Plotarea.ShowCatName = true
c.Plotarea.ShowLeaderLines = false
c.Plotarea.ShowPercent = true
c.Plotarea.ShowSerName = true
c.ShowBlanksAs = "zero"
c.Series = append(
c.Series,
chart.FormatChartSeries{
Name: `%s!$F$1`,
Categories: fmt.Sprintf(`%s!$F$3:$F$4`, s.sheetName),
Values: fmt.Sprintf(`%s!$G$3:$G$4`, s.sheetName),
},
)
b, err := json.Marshal(c)
if err != nil {
return err
}
if err := s.xlsx.AddChart(s.sheetName, "I1", string(b)); err != nil {
return err
}
return nil
}
这段代码执行以下操作:
-
它创建了一种新的 3D 饼图类型。
-
它设置了尺寸、标题和图例。
-
它应用了图表的值和类别。
-
它将图表的指令转化为 JSON 格式。
-
它调用
AddChart将图表插入到工作表中。
你可以在以下代码库中找到可运行的代码:github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/5/excel/visualization
因此,我们已经涵盖了使用 Excel 输出报告的基本要求。还有许多其他选项,包括插入图片、数据透视表和高级格式化指令。尽管我们不推荐使用 Excel 作为系统的数据输入或数据存储格式,但它对于汇总和查看数据来说,是一个有用的数据输出系统。
流行的编码格式
CSV 是 DevOps 工程师会遇到的最基础的人类可读编码格式之一,但它绝不是唯一的。在过去的二十年里,出现了几种新格式,它们用于传输信息或为应用程序提供配置。
JavaScript 对象表示法(JSON)是一种数据序列化格式,旨在将 JavaScript 对象转换为文本表示形式,以便保存或传输。由于其简洁性和清晰性,这种标记法已经被几乎所有语言采纳,用于数据传输。
另一种标记语言(YAML)是一种数据序列化格式,常用于存储服务的配置信息。YAML 是 Kubernetes 集群的主要配置语言。
在这一部分,我们将讨论如何将数据从 Go 类型转换为这些格式,再从这些格式转换回 Go 类型的方式。
Go 字段标签
Go 有一个叫做字段标签(field tags)的功能,允许开发人员向 struct 字段添加字符串标签。这使得 Go 程序在执行操作之前,可以查看有关字段的额外元数据。标签是键/值对:
type Record struct {
Last string `json:"last_name"`
}
在前面的代码片段中,你可以看到一个 struct 类型,包含一个名为 Last 的字段,该字段具有字段标签。字段标签是一个内联原始字符串。原始字符串用反引号表示。这将生成一个键为 "json"、值为 "last_name" 的标签。
Go 包可以使用 reflect 包来读取这些标签。这些标签使得包能够根据标签数据更改操作的行为。在这个例子中,它告诉我们的 JSON 编码器包在写入 JSON 数据时使用 last_name 而不是 Last,反之亦然。
这个特性对处理数据序列化的包至关重要。
JSON
在过去的十年中,JSON 格式已经成为数据编码到磁盘并通过 RPC 与服务通信的事实标准。在云领域,没有支持 JSON 的语言是无法成功的。
开发人员可能会遇到将 JSON 作为应用程序配置语言的情况,但由于以下原因,它并不适合这个任务:
-
缺乏多行字符串
-
无法添加注释
-
对标点符号的苛求(也就是说,机器适用,人类不适用)
对于数据交换,JSON 在一些小缺点的情况下仍然非常有用,以下是其中的一些:
-
无模式
-
非二进制格式
-
缺乏字节数组支持
架构(schema)是对消息内容的定义,它存在于代码之外。
无模式意味着没有严格的定义来说明一个消息包含什么内容。这意味着,对于每种受支持的语言,我们必须为该语言创建消息的定义。诸如协议缓冲区(protocol buffers)等格式已经进入这个领域,提供了一个可以用来为任何语言生成代码的架构。
JSON 也是一种人类可读的格式。这类格式在大小和速度上不如二进制格式高效。通常,当试图扩展大规模服务时,这一点很重要。然而,许多人更喜欢人类可读的格式,因为它们易于调试。
JSON 不支持字节数组也是一个缺陷。虽然 JSON 仍然可以传输原始字节,但它需要使用 base64 编码对字节进行编码和解码,并将其存储在 JSON 的 string 类型中。这需要额外的编码步骤,而这些步骤本不应存在。有几种 JSON 的超集(例如二进制 JSON,简称 BSON)包含字节数组类型,但它们并未广泛支持。
JSON 通过多种方式传递给用户:
-
作为一个可以包含子消息的单一消息
-
作为 JSON 消息的数组
-
作为 JSON 消息流
JSON 最初的起源是作为一种格式,用于简单地编码 JavaScript 对象以进行传输。然而,随着使用场景的增多,发送大消息或消息流的需求也成为了一个实际应用场景。
单个大消息可能很难解码。通常,JSON 解码器会读取整个消息到内存中,并验证消息的内容。
为了简化大量的消息集或流式内容,你可能会遇到一个被括号[]包围的消息集合,或者是由回车符分隔的单个消息。这些不是按预期的有效 JSON,但已经成为处理大量数据作为小的、单独的消息组成整个流的事实标准。
因为 JSON 是云生态系统中的标准部分,Go 语言在标准库的encoding/json包中提供了内置支持。在接下来的部分中,我们将详细介绍使用 JSON 包的最常见方法。
对map进行编码和解码
由于 JSON 没有固定的模式,因此在流或文件中可能存在不同类型的消息。这通常是不可取的,最好有一个顶级消息来包含这些不同类型的消息。
当你需要处理多种消息类型或对消息进行发现时,Go 允许你将消息解码成map[string]interface{},其中string键表示字段名,interface{}表示值。
让我们来看一个将文件解码成map的示例:
b, err := os.ReadFile("data.json")
if err != nil {
return "",
err
}
data := map[string]interface{}{}
if err := json.Unmarshal(b, &data); err != nil {
return "", err
}
v, ok := data["user"]
if !ok {
return "", errors.New("json does not contain key 'user'")
}
switch user := v.(type) {
case string:
return user, nil
}
return "", fmt.Errorf("key 'user' is not a string, was %T", v)
上面的示例执行了以下操作:
-
它将
data.json文件的内容读取到变量b中。 -
它创建一个名为
data的map,用于存储我们的 JSON 内容。 -
它将原始字节解码为 JSON,存储到
data中。 -
它查找
data中的user键。 -
如果
user不存在,我们返回一个错误。 -
如果确实存在,我们通过
type assert来确定值的类型。 -
如果值是字符串,我们返回其内容。
-
如果值不是字符串,我们返回一个错误。
使用map,我们可以探索数据中的值以发现消息类型,type assert 将interface{}值断言为具体类型,然后使用该具体值。记住,类型断言将interface变量转换为另一个interface变量或具体类型,例如string或int64。
使用map是 JSON 数据解码中最复杂的方法。仅在 JSON 不可预测且无法控制数据提供者的情况下推荐使用这种方法。通常,最好是让数据提供者改变其行为,而不是以这种方式进行解码。
将map编码为 JSON 很简单:
if err := json.Marshal(data); err != nil {
return err
}
json.Marshal会读取我们的map并输出有效的 JSON 内容。[]byte字段会自动以base64编码转换为 JSON 的string类型。
对结构体进行编码和解码
JSON 解码的首选方法是在 Go 的 struct 类型中进行,这个类型表示数据。以下是创建用户记录结构体的示例,我们将使用它来解码 JSON 流:
type Record struct {
Name string `json:"user_name"`
User string `json:"user"`
ID int
Age int `json:"-"`
}
func main() {
rec := Record{
Name: "John Doak",
User: "jdoak",
ID: 23,
}
b, err := json.Marshal(rec)
if err != nil {
panic(err)
}
fmt.Printf("%s\n", b)
}
上述代码输出 {"user_name":"John Doak","user":"jdoak","ID":23}。你可以在 play.golang.org/p/LzoUpOeEN9y 找到可运行的代码。
这段代码做了以下操作:
-
它定义了一个
Record类型。 -
它使用字段标签告诉 JSON 输出字段映射应为怎样。
-
它在
Age字段上使用了-的字段标签,以便该字段不会被序列化。 -
它创建了一个名为
rec的Record类型。 -
它将
rec序列化为 JSON。 -
它打印出了 JSON。
请注意,Name 字段被转换为 user_name,User 转换为 user。ID 字段在输出中没有改变,因为我们没有使用字段标签。Age 字段没有输出,因为我们使用了 - 的字段标签。
由于以小写字母开头的字段是私有的,因此无法导出。这是因为 JSON 序列化器在另一个包中,无法看到当前包中的私有类型。
你可以在 encoding/json GoDoc 中阅读 JSON 支持的字段标签,位于 Marshal() 下 (pkg.go.dev/encoding/json#Marshal)。
JSON 包还包括 MarshalIndent(),它可以用来输出更易读的 JSON,其中字段之间有行分隔符和缩进。
将数据解码为 struct 类型(例如前面的 Record)可以如下进行:
rec := Record{}
if err := json.Unmarshal(b, &rec); err != nil {
return err
}
这将表示 JSON 的文本转换为存储在 rec 变量中的 Record 类型。你可以在 play.golang.org/p/DD8TrKgTUwE 找到可运行的代码。
序列化和反序列化大型消息
有时,我们可能会收到包含 JSON 消息列表的 JSON 消息流或文件。
Go 提供了 json.Decoder 来处理一系列消息。以下是借自 GoDoc 的示例,其中每条消息由回车符分隔:
const jsonStream = `
{"Name": "Ed", "Text": "Knock knock."}
{"Name": "Sam", "Text": "Who's there?"}
`
type Message struct {
Name, Text string
}
reader := strings.NewReader(jsonStream)
dec := json.NewDecoder(reader)
msgs := make(chan Message, 1)
errs := make(chan error, 1)
// Parse the messages concurrently with printing the message.
go func() {
defer close(msgs)
defer close(errs)
for {
var m Message
if err := dec.Decode(&m); err == io.EOF {
break
} else if err != nil {
errs <- err
return
}
msgs <- m
}
}()
// This will print the messages as we decode them.
for m := range msgs {
fmt.Printf("%+v\n", m)
}
if err := <-errs; err != nil {
fmt.Println("stream error: ", err)
}
你可以在 play.golang.org/p/kqmSvfdK4EG 查看此运行中的代码。
这个例子做了以下操作:
-
它定义了一个
Message结构体。 -
它通过
strings.NewReader()将jsonStream原始输出包装在io.Reader中。 -
它启动了一个 goroutine,解码消息并将其放入通道中。
-
它读取所有发送的消息,直到输出通道被关闭。
-
它打印出遇到的任何错误。
有时,这种流格式会在消息周围加上括号 [],并使用逗号作为条目之间的分隔符。
在这种情况下,我们可以利用解码器的另一个特性,dec.Token(),来安全地移除它们:
const jsonStream = `[
{"Name": "Ed", "Text": "Knock knock."},
{"Name": "Sam", "Text": "Who's there?"}
]`
dec := json.NewDecoder(reader)
_, err := dec.Token() // Reads [
if err != nil {
return fmt.Errorf(`outer [ is missing`))
}
for dec.More() {
var m Message
// decode an array value (Message)
err := dec.Decode(&m)
if err != nil {
return err
}
fmt.Printf("%+v\n", m)
}
_, err = dec.Token() // Reads ]
if err != nil {
return fmt.Errorf(`final ] is missing`)
}
你可以在 play.golang.org/p/_PrUVUy4zRv 查看此运行中的代码。
这段代码以相同的方式工作,只是它移除了外括号,并且要求使用逗号分隔的列表。
在流中编码数据与解码非常相似。我们可以将 JSON 消息写入 io.Writer,以输出到流。以下是一个示例:
func encodeMsgs(in chan Message, output io.Writer) chan error {
errs := make(chan error, 1)
go func() {
defer close(errs)
enc := json.NewEncoder(output)
for msg := range in {
if err := enc.Encode(msg); err != nil {
errs <- err
return
}
}
}()
return errs
}
你可以在 play.golang.org/p/ELICEC4lcax 查看这段代码的运行情况。
这段代码的作用如下:
-
它从
Message类型的channel中读取数据。 -
它写入
io.Writer。 -
它返回一个信号通道,表示编码器完成处理。
-
如果返回了错误,意味着编码器遇到了问题。
这将 JSON 输出为分隔的值,不带括号。
JSON 的最终思考
encoding/json 包支持其他解码方法,这些方法在这里未涉及。你可以将 map[string]interface{} 混合到 struct 类型中,反之亦然,或者你可以逐个解码每个字段和数值。
然而,最佳的使用场景是那些直接的 struct 类型,作为单个值或值流。
这就是为什么 encoding/json 是我在编码或解码 JSON 值时的首选方法。它不是最快的,但它是最灵活的。
还有其他第三方库可以提高吞吐量,但会牺牲一些灵活性。这里列出了一些你可能想考虑的包:
YAML 编码
YAML(另一个标记语言/YAML 不等于标记语言)是一种常用于编写配置的语言。
YAML 是 Kubernetes 等服务的默认语言,用于保存配置,作为 DevOps 工程师,你很可能会在各种应用中遇到它。
YAML 相对于 JSON 在配置使用中有一些优势:
-
支持注释
-
对人类更灵活,如不带引号的字符串和带引号的字符串
-
多行字符串
-
使用锚点和引用来避免重复相同的文本数据
YAML 经常被认为有以下缺点:
-
它是无模式的。
-
规范很庞大,某些功能可能令人困惑。
-
大文件可能会出现缩进错误而未被注意到。
-
某些语言中的实现可能会意外执行嵌入在 YAML 中的代码。这可能会导致软件项目中的一些安全补丁。
Go 标准库并不原生支持 YAML,但它有一个第三方库,已经成为 YAML 序列化的事实标准包,名为 go-yaml (github.com/go-yaml/yaml)。
接下来,让我们讨论如何读取这些 YAML 文件来读取我们的配置。
对映射的序列化和反序列化
YAML 和 JSON 一样是无模式的,并且有相同的缺点。然而,与 JSON 不同的是,YAML 旨在表示配置,因此我们不需要像处理 JSON 那样去流式处理内容。
对于 YAML,一般的使用情况将涉及编码/解码为 struct 类型而不是 map。然而,如果您需要消息发现,YAML 可以像我们处理 JSON 一样处理 map 解码。
让我们看一个将文件解组为 map 的示例:
data := map[string]interface{}{}
if err := yaml.Unmarshal(yamlContent, &data); err != nil {
return "", err
}
v, ok := data["user"]
if !ok {
return "", errors.New("'user' key not found")
}
前面的示例做了以下几件事情:
-
它创建了一个名为
data的map来存储我们的 YAML 内容。 -
它将表示 YAML 的原始字节解组为
data。 -
它查找
data中的user键。 -
如果
user不存在,则返回错误。
要查看更完整的示例,请参阅 play.golang.org/p/wkHkmu47e6V。
将 map 编组为 YAML 是简单的:
if err := yaml.Marshal(data); err != nil {
return err
}
在这里,yaml.Marshal() 将读取我们的 map 并为其内容输出有效的 YAML。
编组和解组为结构体
struct 序列化是处理 YAML 的首选方式。由于 YAML 是一种配置语言,程序必须预先知道可用的字段以设置程序参数。
YAML 序列化的工作方式与 JSON 序列化类似,您会在大多数数据序列化包中找到这种相似性:
type Config struct {
Jobs []Job
}
type Job struct {
Name string
Interval time.Duration
Cmd string
}
func main() {
c := Config{
Jobs: []Job{
{
Name: "Clear tmp",
Interval: 24 * time.Hour,
Cmd: "rm -rf " + os.TempDir(),
},
},
}
b, err := yaml.Marshal(c)
if err != nil {
panic(err)
}
fmt.Printf("%s\n", b)
}
您可以在 play.golang.org/p/SvJHLKBsdUP 上看到这段正在运行的代码。
这将输出以下内容:
jobs:
- name: Clear tmp dir
interval: 24h0m0s
cmd: rm -rf /tmp
前面的代码做了以下几件事情:
-
它创建了一个名为
Config的顶级配置。 -
它创建了一个名为
Job的子消息列表。 -
它将示例编组为文本表示。
解组同样简单:
data := []byte(`
jobs:
- name: Clear tmp
interval: 24h0m0s
whatever: is not in the Job type
cmd: rm -rf /tmp
`)
c := Config{}
if err := yaml.Unmarshal(data, &c); err != nil {
panic(err)
}
for _, job := range c.Jobs {
fmt.Println("Name: ", job.Name)
fmt.Println("Interval: ", job.Interval)
}
前面的代码做了以下几件事情:
-
它接受由数据表示的 YAML 配置。
-
它将其转换为
Config类型。 -
它打印出包含的
Job信息。 -
它忽略了
whatever字段。
此代码将忽略未知的 whatever 字段。然而,在许多情况下,您不希望忽略可能拼写错误的字段。在这些情况下,我们可以使用 UnmarshalStrict()。
这将导致此代码失败,并显示以下消息:
line 5: field whaterver not found in type main.Job
使用 UnmarshalStrict() 时,您必须在将其添加到配置文件之前将新字段支持到您的程序中并部署它们,否则会导致旧的二进制文件失败。
YAML 最终思考
github.com/go-yaml/yaml 包支持其他我们这里不会涵盖的序列化方法。其中最常用的一种是解码为 yaml.Node 对象,以保留注释,然后更改内容并重新写入配置。然而,这相对不常见。
在本节中,您已经学会如何使用 JSON 和 YAML 读取和写入它们各自的数据格式。在下一节中,我们将探讨如何与常用于存储数据的 SQL 数据源进行交互。
总结
这也标志着我们关于使用常见数据格式的章节结束。我们已经涵盖了如何读取和写入 CSV 文件以及 Excel 报告。此外,我们还学习了如何在 JSON 和 YAML 格式中进行数据编码和解码。本章节展示了如何在流中解码数据,同时强化了使用 goroutine 并发读取和使用数据的方法。
你刚学到的 JSON 技能将在我们下一章立即派上用场。在那一章中,我们将学习如何连接 SQL 数据库并与 RPC 服务进行交互。由于 REST RPC 服务和像 Postgres 这样的数据库可以使用 JSON,这项技能将非常有用。
那么,我们开始吧!
第六章:与远程数据源交互
在上一章中,我们讨论了如何处理常见的数据格式,并展示了如何读取和写入这些格式的数据。但在那一章中,我们仅仅处理了可以通过文件系统访问的数据。
虽然文件系统实际上可能通过网络文件系统(NFS)或服务器消息块(SMB)等服务,拥有存储在远程设备上的文件,但也存在其他远程数据源。
在本章中,我们将讨论一些常见的方式,用于在远程数据源中发送和接收数据。本章将重点介绍如何使用结构化查询语言(SQL)、表述性状态转移(REST)和Google 远程过程调用(gRPC)来访问远程系统中的数据。你将学习如何访问常见的 SQL 数据存储,重点是 PostgreSQL。我们还将探讨如何使用 REST 和 gRPC 风格的 RPC 方法创建和查询远程过程调用(RPC)服务。
通过在这里学到的技能,你将能够连接并查询 SQL 数据库中的数据,向数据库添加新条目,向服务请求远程操作,并从远程服务获取信息。
本章我们将讨论以下主题:
-
访问 SQL 数据库
-
开发 REST 服务和客户端
-
开发 gRPC 服务和客户端
在下一节中,我们将深入探讨使用最古老的数据格式之一——逗号分隔值(CSV)。
让我们开始吧!
技术要求
本章的代码文件可以从github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/6/grpc下载。
访问 SQL 数据库
DevOps 工程师通常需要访问存储在数据库系统中的数据。SQL 是与数据库系统通信的标准,是 DevOps 工程师在日常工作中会遇到的内容。
Go 提供了一个标准库,用于与基于 SQL 的系统交互,称为database/sql。该包提供的接口,结合数据库驱动程序,允许用户与多种不同的 SQL 数据库进行操作。
在本节中,我们将学习如何使用 Go 访问 Postgres 数据库,以执行基本的 SQL 操作。
重要提示
本节中的示例将要求你设置一个 Postgres 数据库。此内容超出了本书的范围。本书并不讲解 SQL。需要具备一些基本的 SQL 知识。
你可以在www.postgresql.org/download/找到关于如何为你的操作系统安装 Postgres 的信息。如果你更倾向于在本地 Docker 容器中运行 Postgres,可以在hub.docker.com/_/postgres找到相关信息。
连接到 Postgres 数据库
要连接到 Postgres 数据库,需要使用 Postgres 的数据库驱动程序。目前推荐的第三方包是github.com/jackc/pgx。该包实现了一个适用于database/sql的 SQL 驱动程序,并提供了自己的方法/类型来支持 Postgres 特定功能。
使用database/sql或 Postgres 特定类型的选择取决于是否需要确保不同数据库之间的兼容性。使用database/sql允许你编写适用于任何 SQL 数据库的函数,而使用 Postgres 特定功能则移除了兼容性,使迁移到其他数据库变得更加困难。我们将讨论如何使用这两种方法执行我们的示例。
下面是如何使用标准 SQL 包连接而不使用额外的 Postgres 功能:
/*
dbURL might look like:
"postgres://username:password@localhost:5432/database_name"
*/
conn, err := sql.Open("pgx", dbURL)
if err != nil {
return fmt.Errorf("connect to db error: %s\n", err)
}
defer conn.Close()
ctx, cancel := context.WithTimeout(
context.Background(),
2 * time.Second
)
if err := conn.PingContext(ctx); err != nil {
return err
}
cancel()
在这里,我们使用pgx驱动程序打开一个 Postgres 连接,该驱动程序将在导入以下包时注册:
_ "github.com/jackc/pgx/v4/stdlib"
这是一个匿名导入,意味着我们没有直接使用stdlib。这是在我们想要产生副作用时使用的,例如在使用database/sql包注册驱动程序时。
Open()调用不会测试我们的连接。你会看到conn.PingContext()来测试我们是否能够向数据库发起请求。
当你想要使用pgx-specific类型来操作 Postgres 时,设置略有不同,从不同的包导入开始:
"github.com/jackc/pgx/v4/pgxpool"
要创建该连接,请键入以下内容:
conn, err := pgxpool.Connect(ctx, dbURL)
if err != nil {
return fmt.Errorf("connect to db error: %s\n", err)
}
defer conn.Close(ctx)
这使用连接池连接到数据库,以提高性能。你会注意到我们没有PingContext()调用,因为本地连接会在Connect()过程中自动测试连接。
现在你知道如何连接到 Postgres 了,接下来我们来看看如何进行查询。
查询 Postgres 数据库
让我们考虑一下如何向你的 SQL 数据库发起请求,获取存储在表中的用户信息。
使用标准库,键入以下内容:
type UserRec struct {
User string
DisplayName string
ID int
}
func GetUser(ctx context.Context, conn *sql.DB, id int) (UserRec, error) {
const query = `SELECT "User","DisplayName" FROM users WHERE "ID" = $1`
u := UserRec{ID: id}
err := conn.QueryRowContext(ctx, query, id).Scan(&u)
return u, err
}
这个示例执行了以下操作:
-
创建
UserRec以存储用户的 SQL 数据 -
创建一个名为
query的查询语句 -
查询我们的数据库,获取请求的 ID 对应的用户
-
返回
UserRec和一个错误(如果有的话)
我们可以通过在对象中使用预准备语句,而不是仅仅使用函数来提高此示例的效率:
type Storage struct {
conn *sql.DB
getUserStmt *sql.Stmt
}
func NewStorage(ctx context.Context, conn *sql.DB) *Storage
{
return &Storage{
getUserStmt: conn.PrepareContext(
ctx,
`SELECT "User","DisplayName" FROM users WHERE "ID" = $1`,
)
}
}
func (s *Storage) GetUser(ctx context.Context, id int) (UserRec, error) {
u := UserRec{ID: id}
err := s.getUserStmt.QueryRow(id).Scan(&u)
return u, err
}
这个示例执行了以下操作:
-
创建一个可重用的对象
-
存储
*sql.Stmt,这可以提高重复查询时的效率 -
定义一个
NewStorage构造函数来创建我们的对象
由于使用标准库的通用性,在这些示例中,任何*sql.DB的实现都可以使用。只要 MariaDB 有相同的表名和格式,切换 Postgres 为 MariaDB 也是可行的。
如果我们使用 Postgres 特定的库,代码将如下所示:
err = conn.QueryRow(ctx, query).Scan(&u)
return u, err
这种实现方式看起来和工作方式与标准库类似。但这里的 conn 对象是一个不同的非接口类型 pgxpool.Conn,而不是 sql.Conn。尽管功能相似,pgxpool.Conn 对象支持 Postgres 特有的类型和语法,例如 jsonb,而 sql.Conn 不支持。
在使用 Postgres 特有的调用时,不需要为非事务操作使用预处理语句。调用信息会自动缓存。
上面的示例比较简单,我们只是拉取了一个特定的条目。如果我们想要一个方法来检索所有 ID 在两个数字之间的用户呢?我们可以使用标准库来定义:
/*
stmt contains `SELECT "User","DisplayName","ID" FROM users
WHERE "ID" >= $1 AND "ID" < $2`
*/
func (s *Storage) UsersBetween(ctx context.Context, start, end int) ([]UserRec, error) {
recs := []UserRec{}
rows, err := s.usersBetweenStmt(ctx, start, end)
defer rows.Close()
for rows.Next() {
rec := UserRec{}
if err := rows.Scan(&rec); err != nil {
return nil, err
}
recs = append(recs, rec)
}
return recs, nil
}
Postgres 特有的语法保持不变,只是将 s.usersBetweenStmt() 替换成了 conn.QueryRow()。
空值
SQL 有一个空值概念,适用于布尔值、字符串和 int32 等基本类型。Go 没有这个约定;相反,它为这些类型提供了零值。
当 SQL 允许某列具有空值时,标准库提供了在 database/sql 中的特殊空类型:
-
sql.NullBool -
sql.NullByte -
sql.NullFloat64 -
sql.NullInt16 -
sql.NullInt32 -
sql.NullInt64 -
sql.NullString -
sql.NullTime
当设计你的模式时,最好使用零值而不是空值。但有时,你需要区分一个值是否已设置与零值。在这种情况下,你可以使用这些特殊类型代替标准类型。
例如,如果我们的 UserRec 可能有一个空的 DisplayName,我们可以将 string 类型更改为 sql.NullString:
type UserRec struct {
User string
DisplayName sql.NullString
ID int
}
你可以在这里查看服务器如何根据 DisplayName 列的值设置这些值的示例:go.dev/play/p/KOkYdhcjhdf。
向 Postgres 写入数据
向数据库写入数据很简单,但需要考虑语法。用户写入数据时最常见的两个操作如下:
-
更新现有条目
-
插入新条目
在标准 SQL 中,你不能执行 如果存在则更新条目;如果不存在则插入。由于这是一个常见操作,每个数据库都有自己独特的语法来完成此操作。在使用标准库时,你必须在执行更新或插入之间做出选择。如果你不知道条目是否存在,你将需要使用事务,稍后我们会详细说明。
执行更新或插入只是使用不同的 SQL 语法和 ExecContext() 调用:
func (s *Storage) AddUser(ctx context.Context, u UserRec) error {
_, err := s.addUserStmt.ExecContext(
ctx,
u.User,
u.DisplayName,
u.ID,
)
return err
}
func (s *Storage) UpdateDisplayName(ctx context.Context, id int, name string) error {
_, err := s.updateDisplayName.ExecContext(
ctx,
name,
id,
)
return err
}
在这个示例中,我们添加了两个方法:
-
AddUser()将新用户添加到系统中。 -
UpdateDisplayName()更新具有特定 ID 的用户的显示名称。 -
两者都使用
sql.Stmt类型,它将作为对象中的一个字段,类似于getUserStmt。
使用 Postgres 原生包实现时的主要区别在于调用的方法名称,以及没有预处理语句。实现 AddUser() 的代码如下:
func (s *Storage) AddUser(ctx context.Context, u UserRec) error {
const stmt = `INSERT INTO users (User,DisplayName,ID)
VALUES ($1, $2, $3)`
_, err := s.conn.Exec(
ctx,
stmt,
u.User,
u.DisplayName,
u.ID,
)
return err
}
有时候,仅仅对数据库进行读写操作是不够的。有时,我们需要原子性地执行多个操作,并将它们视为一个整体。因此,在下一节中,我们将讨论如何使用事务来实现这一点。
事务
事务提供了一系列在服务器上作为一个整体执行的 SQL 操作。它通常用于提供某种类型的原子操作,其中需要执行读和写,或者在执行写操作之前先读取数据。
在 Go 中,事务很容易创建。让我们创建一个 AddOrUpdateUser() 调用,在添加或更新数据之前检查用户是否存在:
func (s *Storage) AddOrUpdateUser(ctx context.Context, u UserRec) (err error) {
const (
getStmt = `SELECT "ID" FROM users WHERE "User" = $1`
insertStmt = `INSERT INTO users (User,DisplayName,ID)
VALUES ($1, $2, $3)`
updateStmt = `UPDATE "users" SET "User" = $1,
"DisplayName" = $2 WHERE "ID" = 3`
)
tx, err := s.conn.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelSerializable})
if err != nil {
return err
}
defer func() {
if err != nil {
tx.Rollback()
return
}
err = tx.Commit()
}()
_, err := tx.QueryRowContext(ctx, getStmt, u.User)
if err != nil {
if err == sql.ErrNoRows {
_, err = tx.ExecContext(ctx, insertStmt, u.User, u.DisplayName, u.ID)
if err != nil {
return err
}
}
return err
}
_, err = tx.ExecContext(ctx, updateStmt, u.User, u.DisplayName, u.ID))
return err
}
这段代码执行了以下操作:
-
创建一个隔离级别为
LevelSerializable的事务。 -
使用
defer语句来判断是否发生了错误:-
如果找到了用户,我们会回滚整个事务。
-
如果没有找到用户,我们尝试提交事务。
-
-
查询用于查找用户是否存在:
-
它通过检查错误类型来确定这一点。
-
如果错误是
sql.ErrNoRows,说明我们没有找到该用户。 -
如果错误是其他类型,则表示系统错误。
-
-
如果我们没有找到该用户,则执行插入语句。
-
如果我们找到了该用户,则执行更新语句。
事务的关键要素如下:
-
conn.BeginTx,用于开始事务 -
tx.Commit(),用于提交我们的更改 -
tx.Rollback(),用于回滚我们的更改
defer 语句是一种很好的方式来处理创建事务后执行 Commit() 或 Rollback()。它确保在函数结束时,无论如何都会执行其中一个操作。
隔离级别对事务很重要,因为它会影响系统的性能和可靠性。Go 提供了多个隔离级别;然而,并非所有数据库系统都支持所有的隔离级别。
你可以在这里了解更多关于隔离级别的内容:en.wikipedia.org/wiki/Isolation_(database_systems)#Isolation_levels。
特定于 Postgres 的类型
到目前为止,我们已经向你展示了如何使用标准库和特定于 Postgres 的对象与 Postgres 交互。但我们还没有真正展示使用 Postgres 对象的充分理由。
当你需要使用不是 SQL 标准的一部分的类型或功能时,Postgres 对象非常有用。让我们重写我们的事务示例,但这次我们不通过标准列来存储数据,而是让我们的 Postgres 数据库只包含两个列:
-
int类型的 ID -
jsonb类型的数据
jsonb 不是 SQL 标准的一部分,不能使用标准 SQL 库实现。jsonb 可以极大简化你的工作,因为它允许你在查询时使用 JSON 字段来存储 JSON 数据:
func (s *Storage) AddOrUpdateUser(ctx context.Context, u UserRec) (err error) {
const (
getStmt = `SELECT "ID" FROM "users" WHERE "ID" = $1`
updateStmt = `UPDATE "users" SET "Data" = $1 WHERE "ID" = $2`
addStmt = `INSERT INTO "users" (ID,Data) VALUES ($1, $2)`
)
tx, err := conn.BeginTx(
ctx ,
pgx.TxOptions{
IsoLevel: pgx.Serializable,
AccessMode: pgx.ReadWrite,
DeferableMode: pgx.NotDeferrable,
},
)
defer func() {
if err != nil {
tx.Rollback()
return
}
err = tx.Commit()
}()
_, err := tx.QueryRow(ctx, getUserStmt, u.ID)
if err != nil {
if err == sql.ErrNoRows {
_, err = tx.ExecContext(ctx, insertStmt, u.ID, u)
if err != nil {
return err
}
}
return err
}
_, err = tx.Exec(ctx, updateStmt, u.ID, u)
return err
}
这个示例在几个方面有所不同:
-
它有额外的
AccessMode和DeferableMode参数。 -
我们可以将我们的对象
UserRec作为Datajsonb列传递。
访问模式和可延迟模式增加了额外的约束,这些约束在标准库中无法直接使用。
使用jsonb是一个福音。现在,我们可以在我们的表上使用WHERE子句进行搜索,这些子句可以过滤jsonb字段的值。
你还会注意到,pgx足够聪明,能够识别我们的列类型,并自动将我们的UserRec转换为 JSON。
如果你想了解更多关于 Postgres 值类型的信息,可以访问www.postgresql.org/docs/9.5/datatype.html。
如果你想了解更多关于jsonb以及访问其值的函数,可以访问www.postgresql.org/docs/9.5/functions-json.html。
其他选项
除了标准库和特定数据库的包之外,对象关系映射(ORMs)也是常见的工具。ORM 是管理服务和数据存储之间数据的流行模式。
Go 最流行的 ORM 叫做GORM,你可以在这里找到:gorm.io/index.html。
另一个流行的框架是 Beego,它也包括对 REST 和 Web 服务的支持,你可以在这里找到:github.com/beego/beego。
存储抽象
许多开发者倾向于直接在代码中使用存储系统,传递一个数据库连接。这并不是最优的做法,因为它可能会导致以下问题:
-
在存储访问之前添加缓存层。
-
为你的服务迁移到一个新的存储系统。
抽象存储在一个内部应用程序编程接口(API)接口的背后将允许你通过简单地实现接口并使用新的后端,来以后更换存储层。你可以随时插入新的后端。
这里有一个简单的例子,可能是为获取用户数据添加一个接口:
type UserStorage interface {
User(ctx context.Context, id string) (UserRec, error)
AddUser(ctx context.Context, u UserRec) error
UpdateDisplayName(ctx context.Context, id string, name string) error
}
这个接口允许你使用 Postgres、本地文件、SQLite、Azure Cosmos DB、内存数据结构或任何其他存储介质来实现你的存储后端。
这样做的好处是能够通过插入一个新的实现来实现从一个存储介质到另一个存储介质的迁移。作为附加好处,你可以将测试与数据库解耦。大多数测试可以使用内存数据结构,这样可以测试你的功能,而无需启动和拆除基础设施,这对于真实数据库是必要的。
添加缓存层变成了一个简单的练习,只需要编写一个UserStorage实现,它在读取时调用缓存,当缓存中未找到时,调用你的数据存储实现。你可以替换原有实现,一切保持正常工作。
请注意,这里描述的所有关于通过接口进行抽象的内容适用于对服务数据的访问。SQL API 应该仅用于您的应用程序存储和读取数据。其他服务应使用稳定的 RPC 接口。这提供了相同类型的抽象,使您可以在不迁移用户的情况下更改数据后端。
案例研究——一个编排系统的数据迁移——Google
在我任职于 Google 期间,我参与的一个系统是用于自动化网络变更的编排系统。该系统接收自动化指令并执行这些指令,针对各种目标进行操作。这些操作可能包括通过安全文件传输协议(SFTP)推送文件、与网络路由器交互、更新权威数据存储或运行状态验证。
在操作中,确保表示工作流状态的数据始终是最新的至关重要。这不仅包括当前正在运行的工作流,还包括用于创建新工作流的先前工作流的状态。
为了减轻我们的操作负担,我们希望将工作流的存储系统从 Bigtable 迁移到 Spanner。Bigtable 需要更复杂的设置来处理发生问题时的故障切换到备份单元,而 Spanner 的设计就包括了这一功能。这使我们在单元出现问题时无需干预。
存储层被隐藏在存储接口背后。存储在我们的main()函数中初始化,并传递给其他需要它的模块。这意味着我们可以用新的实现替换存储层。
我们实现了一个新的存储接口,将数据同时写入 Bigtable 和 Spanner,并从它们两个中读取数据,使用最新的数据戳,并在需要时更新记录。
这使得我们在历史数据传输期间可以同时使用两个数据存储。一旦同步完成,我们将二进制文件迁移到仅包含 Spanner 实现的版本。我们的迁移完成了,且在成千上万的关键操作运行时没有发生服务停机。
到目前为止,在本章中,我们已经学习了如何使用database/sql访问通用数据存储,并特别讨论了 Postgres。我们学习了如何读取和写入 Postgres,并实现事务。我们还讨论了使用database/sql与使用数据库特定库(如pgx)之间的优劣。最后,我们展示了如何通过将实现隐藏在接口抽象背后,使您能够更轻松地更改存储后端,并对依赖于存储的代码进行封闭测试。
接下来,我们将研究如何使用 REST 或 gRPC 访问 RPC 服务。
开发 REST 服务和客户端
在互联网和现如今充斥云空间的分布式系统之前,系统间通信的标准并未广泛应用。这种通信通常称为 RPC。简单来说,这意味着一台机器上的程序调用运行在另一台机器上的函数并接收输出。
单体应用曾是常态,服务器通常要么按应用程序进行隔离并垂直扩展,要么作为作业运行在更大、更专业的硬件上,这些硬件来自 IBM、Sun、SGI 或 Cray 等公司。当系统需要相互通信时,它们往往使用自己的定制协议格式,例如你在 Microsoft SQL Server 中看到的格式。
随着 2000 年代互联网的定义,单体大系统无法以合理的成本为像 Google 搜索或 Facebook 这样的服务提供计算能力。为了为这些服务提供支持,企业需要将大量标准 PC 视为一个整体系统。单一系统可以使用 Unix 套接字或共享内存调用在进程之间进行通信,但企业需要在不同机器上运行的进程之间有共同且安全的通信方式。
随着 HTTP 成为系统间通信的事实标准,当今的 RPC 机制使用某种形式的 HTTP 来进行数据传输。这使得 RPC 可以更轻松地穿越系统,如负载均衡器,并轻松利用安全标准,如传输层安全性(TLS)。这还意味着,随着 HTTP 传输的升级,这些 RPC 框架可以借助数百甚至成千上万名工程师的努力。
在这一部分,我们将讨论最流行的 RPC 机制之一——REST。REST 使用 HTTP 调用和你选择的任何消息格式,尽管大多数情况下使用 JSON 作为消息格式。
用于 RPC 的 REST
在 Go 中编写 REST 客户端相当简单。如果你在过去的 10 年里一直在开发应用程序,那么你要么已经使用过 REST 客户端,要么已经编写过一个。像 Google Cloud Platform 的 Cloud Spanner、Microsoft 的 Azure Data Explorer 或 Amazon DynamoDB 等云服务的 API 都使用 REST 通过它们的客户端库与服务进行通信。
REST 客户端可以执行以下操作:
-
使用
GET、POST、PATCH或任何其他类型的 HTTP 方法。 -
支持任何序列化格式(尽管通常是 JSON)。
-
允许数据流传输。
-
支持查询变量。
-
使用 URL 标准支持多个版本的 API。
Go 中的 REST 也不需要任何框架即可在服务器端实现。所需的一切都包含在标准库中。
编写一个 REST 客户端
让我们编写一个简单的 REST 客户端,访问服务器并接收一个 POST 请求 – /v1/qotd。
首先,让我们定义需要发送到服务器的消息:
type getReq struct {
Author string `json:"author"`
}
type getResp struct {
Quote string `json:"quote"`
Error *Error `json:"error"`
}
让我们来讨论一下这些操作的作用:
-
getReq详细说明了服务器/v1/qotd函数调用的参数。 -
getResp是我们期望从服务器函数调用中返回的内容。
我们使用字段标签来允许将小写键转换为我们的公共变量(这些变量首字母大写)。为了让encoding/json包能看到这些值并进行序列化,它们必须是公共的。私有字段将无法被序列化:
type Error struct {
Code ErrCode
Msg string
}
func (e *Error) Error() string {
return fmt.Errorf("(code %v): %s", e.Code, e.Msg)
}
这定义了一个自定义错误类型。通过这种方式,我们可以存储错误代码并返回给用户。这个代码在我们的响应对象旁边定义,但直到稍后的代码中才会使用。
现在,让我们定义一个 QOTD 客户端和一个构造函数,进行一些基本的地址检查并创建一个 HTTP 客户端,以便我们能够向服务器发送数据:
type QOTD struct {
addr string
client *http.Client
}
func New(addr string) (*QOTD, error) {
if _, _, err := net.SplitHostPort(addr); err != nil {
return nil, err
}
return &QOTD{addr: addr, client: &http.Client{}}
}
下一步是创建一个通用函数,用于进行 REST 调用。由于 REST 非常开放,难以编写一个可以处理所有类型 REST 调用的函数。编写 REST 服务器时的最佳实践是只支持POST方法;永远不要使用查询变量和简单的 URL。然而,在实际应用中,如果你不控制服务,你将处理各种各样的 REST 调用类型:
func (q *QOTD) restCall(ctx context.Context, endpoint string, req, resp interface{}) error {
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithDeadline(ctx, 2 * time.Second)
defer cancel()
}
b, err := json.Marshal(req)
if err != nil {
return err
}
hReq, err := http.NewRequestWithContext(
ctx,
http.POST,
endpoint,
bytes.NewBuffer(b),
)
if err != nil {
return err
}
resp, err := q.client.Do(hReq)
if err != nil {
return err
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
return json.Unmarshal(b, resp)
}
这段代码执行了以下操作:
-
检查我们的上下文是否有截止时间:
-
如果它有值,则会被尊重。
-
如果没有设置,则会设置一个默认值。
-
在调用完成后,调用
cancel()。
-
-
将请求转换为 JSON。
-
创建一个新的
*http.Request,执行以下操作:-
使用
POST方法。 -
与端点进行通信。
-
拥有一个
io.Reader,用于存储 JSON 请求。
-
-
使用客户端发送请求并获取响应。
-
从
http.Response的正文中获取响应。 -
将 JSON 反序列化为响应对象。
你会注意到req和resp都是interface{}类型。这使得我们可以将这个例程与任何表示 JSON 请求或响应的结构体一起使用。
现在,我们将在一个方法中使用它,通过作者获取 QOTD(每日名言):
func (q *QOTD) Get(ctx context.Context, author string) (string, error) {
const endpoint = `/v1/qotd`
resp := getResp{}
err := q.restCall(ctx, path.Join(q.addr, endpoint), getReq{Author: author}), &resp)
switch {
case err != nil:
return "", err
case resp.Error != nil:
return "", resp.Error
}
return resp.Quote, nil
}
这段代码执行了以下操作:
-
为我们的
get函数定义一个端点。 -
调用我们的
restCall()方法,执行以下操作:-
使用
path.Join()将我们的服务器地址和 URL 端点连接起来。 -
创建一个
getReq对象作为restCall()的req参数。 -
将响应读取到我们的
resp响应对象中。 -
如果
*http.Client返回一个错误,我们就返回那个错误。 -
如果
resp.Error被设置,我们返回它。
-
-
返回响应中的引用内容。
要查看它的运行效果,你可以访问这里:play.golang.org/p/Th0PxpglnXw。
我们在这里展示了如何使用 HTTP POST请求和 JSON 来创建一个基础的 REST 客户端。然而,我们仅仅触及了创建 REST 客户端的表面。你可能需要向头部添加认证信息,使用JSON Web Token(JWT)。这使用了 HTTP 而不是 HTTPS,因此没有传输安全性。我们没有尝试使用压缩,如 Deflate 或 Gzip。
虽然使用http.Client很容易,但你可能需要一个更智能的封装器,它为你处理这些功能。值得一看的一个库是resty,可以在这里找到:github.com/go-resty/resty。
编写一个 REST 服务
现在我们已经写好了客户端,接下来我们写一个 REST 服务端点来接收请求并将输出发送给用户:
type server struct {
serv *http.Server
quotes map[string][]string
}
这段代码执行了以下操作:
-
创建了服务器
struct,which将充当我们的服务器 -
使用
*http.Server来提供 HTTP 内容 -
有
quotes,它存储作者作为键,值是一个名言切片
现在,我们需要一个构造函数:
func newServer(port int) (*server, error) {
s := &server{
serv: &http.Server{
Addr: ":" + strconv.Itoa(port),
},
quotes: map[string][]string{
// Add quotes here
},
}
mux := http.NewServeMux()
mux.HandleFunc(`/qotd/v1/get`, s.qotdGet)
// The muxer implements http.Handler
// and we assign it for our server’s URL handling.
s.serv.Handler = mux
return s, nil
}
func (s *server) start() error {
return s.serv.ListenAndServe()
}
这段代码执行了以下操作:
-
创建了一个
newServer构造函数:- 这有一个
port参数,指定运行服务器的端口。
- 这有一个
-
创建一个
server实例:-
创建一个
*http.Server实例,运行在:[port]端口上 -
填充我们的
quotes map
-
-
添加
*http.ServeMux来将 URL 映射到方法。注意
我们稍后将创建
qotdGet方法。 -
创建一个名为
start()的方法,用于启动我们的 HTTP 服务器。
*http.ServeMux实现了http.Handler接口,*http.Server使用该接口。ServeMux通过模式匹配来决定哪个方法与哪个 URL 匹配。你可以在这里了解模式匹配的语法:pkg.go.dev/net/http#ServeMux。
现在,让我们创建一个方法来回答我们的 REST 端点请求:
func (s *server) qotdGet(w http.ResponseWriter, r *http.Request) {
req := getReq{}
if err := req.fromReader(r.Body); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
var quotes []string
if req.Author == "" {
// Map access is random, this will randomly choose a // set of quotes from an author.
for _, quotes = range s.quotes {
break
}
} else {
var ok bool
quotes, ok = s.quotes[req.Author]
if !ok {
b, err := json.Marshal(
getResp{
Error: &Error{
Code: UnknownAuthor,
Msg: fmt.Sprintf("Author %q was not found", req.Author),
},
},
)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Write(b)
return
}
}
i := rand.Intn(len(quotes))
b, err := json.Marshal(getResp{Quote: quotes[i]})
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
w.Write(b)
return
这段代码执行了以下操作:
-
实现了
http.Handler接口。 -
读取 HTTP 请求体并将其序列化到我们的
getReq中:- 如果请求有问题,这段代码会使用
http.Error()返回 HTTP 错误代码。
- 如果请求有问题,这段代码会使用
-
如果请求中没有包含“author”字段,则随机选择一个作者的名言。
-
否则,查找作者并获取他们的名言:
- 如果该作者不存在,则响应
getResp并包含一个错误信息。
- 如果该作者不存在,则响应
-
随机选择一句名言并返回给客户端。
现在,我们有了一个 REST 端点,它能够回答客户端的 RPC 请求。你可以在这里看到这段代码的运行:play.golang.org/p/Th0PxpglnXw。
这只是构建 REST 服务的一个简单示例。你可以在此基础上构建认证、压缩、性能追踪等功能。
为了帮助快速启动并减少一些样板代码,以下是一些可能有用的第三方包:
-
Gin:
github.com/gin-gonic/gin:- 一个 REST 示例:
golang.org/doc/tutorial/web-service-gin
- 一个 REST 示例:
-
Revel:
revel.github.io
既然我们已经讨论过使用 REST 进行 RPC,那么让我们来看看被全球大公司广泛采用的更快速的替代方案——gRPC。
开发 gRPC 服务和客户端
gRPC 提供了一个基于 HTTP 的 RPC 框架,并使用 Google 的协议缓冲区格式,这是一种二进制格式,可以转换为 JSON,但提供了架构,并且在许多情况下比 JSON 提供了 10 倍的性能提升。
这个领域还有其他格式,例如 Apache 的 Thrift、Cap'n Proto 和 Google 的 FlatBuffers。然而,这些格式并不如协议缓冲区流行且得到广泛支持,或者只满足某些特定领域的需求,而且也较难使用。
gRPC 和 REST 一样,是一种客户端/服务器框架,用于发起 RPC 调用。gRPC 的不同之处在于,它更倾向于使用一种叫做协议缓冲区(简称proto)的二进制消息格式。
该格式有一个存储在 .proto 文件中的架构,用于通过编译器生成客户端、服务器和消息,以适应你选择的语言的本地库。当 proto 消息被序列化用于在网络上传输时,二进制表示在所有语言中都是一样的。
让我们深入了解协议缓冲区,gRPC 选择的消息格式。
协议缓冲区
协议缓冲区在一个位置定义 RPC 消息和服务,并可以使用 proto 编译器为每种语言生成一个库。协议缓冲区具有以下优点:
-
它们编写一次,生成适用于每种语言的代码。
-
消息可以转换为 JSON 以及二进制格式。
-
gRPC 可以使用反向代理来提供 REST 端点,这对于 Web 应用程序来说非常好。
-
二进制协议缓冲区更小,且编码/解码速度是 JSON 的 10 倍。
然而,协议缓冲区也有一些缺点:
-
你必须在对
.proto文件进行任何更改后重新生成消息,才能获得更改。 -
Google 的标准 proto 编译器使用起来既痛苦又令人困惑。
-
JavaScript 原生不支持 gRPC,尽管它支持协议缓冲区。
工具可以帮助解决一些负面问题,我们将使用新的Buf工具,buf.build,来帮助生成 proto 文件。
让我们看看一个用于 QOTD 服务的协议缓冲区 .proto 文件长什么样:
syntax = "proto3";
package qotd;
option go_package = "github.com/[repo]/proto/qotd";
message GetReq {
string author = 1;
}
message GetResp {
string author = 1;
string quote = 2;
}
service QOTD {
rpc GetQOTD(GetReq) returns (GetResp) {};
}
syntax 关键字定义了我们正在使用的 proto 语言的版本。最常见的版本是 proto3,它是该语言的第三个版本。三者的 wire 格式相同,但具有不同的特性集,且生成不同的语言包。
package 定义了 proto 包名,这使得该协议缓冲区可以被其他包导入。我们用 [repo] 作为占位符来表示 GitHub 仓库。
go_package 在生成 Go 文件时专门定义了包名。尽管这被标记为 option,但在为 Go 编译时它是必需的。
message定义了一种新的消息类型,在 Go 中被生成为struct。message中的条目详细说明了字段。string author = 1在struct GetReq中创建了一个名为Author的string类型字段。1是 proto 中的字段位置。你不能在消息中有重复的字段号,字段号永远不应该更改,字段也不应该被删除(尽管可以弃用)。
service定义了一个 gRPC 服务,包含一个 RPC 端点GetQOTD。这个调用接收GetReq并返回GetResp。
既然我们已经定义了这个协议缓冲文件,我们可以使用 proto 编译器为我们感兴趣的语言生成相应的包。这将包含我们所有的消息以及使用 gRPC 客户端和服务器所需的代码。
让我们来看一下如何从协议缓冲文件生成 Go 包。
说明先决条件
在本教程中使用协议缓冲时,您需要安装以下内容:
-
Buf 工具:
docs.buf.build/installation
安装了这些之后,您将能够为 C++和 Go 生成代码。其他语言需要额外的插件。
生成您的包
我们需要创建的第一个文件是buf.yaml文件。我们可以通过进入proto目录并执行以下命令来生成buf.yaml文件:
buf config init
这应该生成一个包含以下内容的文件:
version: v1
lint:
use:
- DEFAULT
breaking:
use:
- FILE
接下来,我们需要一个文件来告诉我们生成什么输出。创建一个名为buf.gen.yaml的文件,并给它以下内容:
version: v1
plugins:
- name: go
out: ./
opt:
- paths=source_relative
- name: go-grpc
out: ./
opt:
- paths=source_relative
这表示我们应该在与.proto文件相同的目录中生成go和go-grpc文件。
现在,我们应该测试我们的 proto 文件是否能成功构建。我们可以通过执行以下命令来做到这一点:
buf build
如果没有输出,那么我们的 proto 文件应该能够成功编译。否则,我们将得到一个错误列表,需要我们去修复。
最后,让我们生成我们的 proto 文件:
buf generate
如果你将 proto 文件命名为qotd.proto,这将生成以下内容:
-
qotd.pb.go,它将包含你所有的消息 -
qotd_grpc.pb.go,它将包含所有的 gRPC 存根
现在我们有了 proto 包,让我们构建一个客户端。
编写一个 gRPC 客户端
在您仓库的根目录中,让我们创建两个目录:
-
client/,它将包含我们的客户端代码 -
internal/server/,它将包含我们的服务器代码
现在,让我们创建一个client/client.go文件,内容如下:
package client
import (
"context"
"time"
"google.golang.org/grpc"
pb "[repo]/grpc/proto"
)
type Client struct {
client pb.QOTDClient
conn *grpc.ClientConn
}
func New(addr string) (*Client, error) {
conn, err := grpc.Dial(addr, grpc.WithInsecure())
if err != nil {
return nil, err
}
return &Client{
client: pb.NewQOTDClient(conn),
conn: conn,
}, nil
}
func (c *Client) QOTD(ctx context.Context, wantAuthor string) (author, quote string, err error) {
if _, ok := ctx.Deadline(); !ok {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(ctx, 2 * time.Second)
defer cancel()
}
resp, err := c.client.GetQOTD(ctx, &pb.GetReq{Author: wantAuthor})
if err != nil {
return "", "", err
}
return resp.Author, resp.Quote, nil
}
这是围绕生成的客户端的一个简单包装,我们通过New()构造函数建立了与服务器的连接:
-
grpc.Dial()连接到服务器的地址:grpc.WithInsecure()允许我们不使用 TLS。(在实际服务中,您需要使用 TLS!)
-
pb.NewQOTDClient()接受一个 gRPC 连接并返回我们生成的客户端。 -
QOTD()使用客户端调用我们在GetQOTD()原型中定义的接口:-
如果没有定义超时,这里会定义一个超时。服务器接收这个超时设置。
-
这会使用生成的客户端来调用服务器。
-
创建一个包装器作为客户端并非严格必要。许多开发者更喜欢让用户直接使用生成的客户端与服务进行交互。
在我们看来,这对于简单的客户端来说是没问题的。更复杂的客户端通常应通过将逻辑移动到服务器或使用更符合语言习惯的自定义客户端包装器来减轻负担。
现在我们已经定义了客户端,让我们创建我们的服务器包。
编写一个 gRPC 服务器
让我们在internal/server/server.go创建一个服务器文件。
现在,让我们添加以下内容:
package server
import (
"context"
"fmt"
"math/rand"
"net"
"sync"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
pb "[repo]/grpc/proto"
)
type API struct {
pb.UnimplementedQOTDServer
addr string
quotes map[string][]string
mu sync.Mutex
grpcServer *grpc.Server
}
func New(addr string) (*API, error) {
var opts []grpc.ServerOption
a := &API{
addr: addr,
quotes: map[string][]string{
// Insert your quote mappings here
},
grpcServer: grpc.NewServer(opts...),
}
a.grpcServer.RegisterService(&pb.QOTD_ServiceDesc, a)
return a, nil
}
这段代码执行以下操作:
-
定义我们的 API 服务器:
-
pb.UnimplementedQOTDServer是一个生成的接口,包含我们服务器必须实现的所有方法。这是必需的。 -
addr是我们服务器将要运行的地址。 -
quotes包含服务器存储的引用。
-
-
定义一个
New()构造函数:-
这将创建一个我们
API服务器的实例。 -
这将实例注册到我们的
grpcServer中。
-
现在,让我们添加启动和停止API服务器的方法:
func (a *API) Start() error {
a.mu.Lock()
defer a.mu.Unlock()
lis, err := net.Listen("tcp", a.addr)
if err != nil {
return err
}
return a.grpcServer.Serve(lis)
}
func (a *API) Stop() {
a.mu.Lock()
defer a.mu.Unlock()
a.grpcServer.Stop()
}
这段代码执行以下操作:
-
定义
Start()方法来启动服务器,该方法执行以下操作:-
使用
Mutex来防止同时停止和启动 -
在
New()中传递的地址上创建一个 TCP 监听器 -
使用我们的监听器启动 gRPC 服务器
-
-
定义
Stop()方法来停止服务器,该方法执行以下操作:-
使用
Mutex来防止同时停止和启动 -
告诉 gRPC 服务器优雅地停止
-
现在,让我们实现GetQOTD()方法:
func (a *API) GetQOTD(ctx context.Context, req *pb.GetReq) (*pb.GetResp, error) {
var (
author string
quotes []string
)
if req.Author == "" {
for author, quotes = range s.quotes {
break
}
} else {
author = req.Author
var ok bool
quotes, ok = s.quotes[req.Author]
if !ok {
return nil, status.Error(
codes.NotFound,
fmt.Sprintf("author %q not found", req.author),
)
}
}
return &pb.GetResp{
Author: author,
Quote: quotes[rand.Intn(len(quotes))],
}, nil
}
这段代码执行以下操作:
-
定义客户端将调用的
GetQOTD()方法 -
包含与我们的 REST 服务器类似的逻辑
-
使用 gRPC 的错误类型,该类型在
google.golang.org/grpc/status包中定义,用于返回 gRPC 错误代码
现在我们已经有了客户端和服务器包,让我们创建一个二进制文件来运行我们的服务。
创建服务器二进制文件
创建一个名为qotd.go的文件,保存我们服务器的main()函数:
package main
import (
"flag"
"log"
"github.com/[repo]/internal/server"
pb "[repo]/proto"
)
var addr = flag.String("addr", "127.0.0.1:80", "The address to run on.")
func main() {
flag.Parse()
s, err := server.New(*addr)
if err != nil {
panic(err)
}
done := make(chan error, 1)
log.Println("Starting server at: ", *addr)
go func() {
defer close(done)
done <-s.Start()
}()
err <- done
log.Println("Server exited with error: ", err)
}
这段代码执行以下操作:
-
创建一个标志
addr,调用者传递此标志来设置服务器运行的地址。 -
创建我们的服务器实例。
-
写明我们正在启动服务器。
-
启动服务器。
-
如果服务器已存在,错误会被打印到屏幕上:
- 这可能是某些提示,说明端口已被占用。
你可以使用以下命令运行这个二进制文件:
go run qotd.go --addr="127.0.0.1:2562"
如果你没有传递--addr标志,它将默认使用127.0.0.1:80。
你应该在屏幕上看到以下内容:
Starting server at: 127.0.0.1:2562
现在,让我们创建一个二进制文件,使用客户端获取 QOTD。
创建客户端二进制文件
创建一个名为client/bin/qotd.go的文件,然后添加以下内容:
package main
import (
"context"
"flag"
"fmt"
"github.com/devopsforgo/book/book/code/1/4/grpc/client"
)
var (
addr = flag.String("addr", "127.0.0.1:80", "The address of the server.")
author = flag.String("author", "", "The author whose quote to get")
)
func main() {
flag.Parse()
c, err := client.New(*addr)
if err != nil {
panic(err)
}
a, q, err := c.QOTD(context.Background(), *author)
if err != nil {
panic(err)
}
fmt.Println("Author: ", a)
fmt.Printf("Quote of the Day: %q\n", q)
}
这段代码执行以下操作:
-
设置一个标志用于服务器的地址
-
设置一个标志用于引用你想要的名言的作者
-
创建一个新的
client.QOTD实例 -
使用
QOTD()客户端方法调用服务器 -
将结果或错误打印到终端
你可以使用以下命令运行这个二进制文件:
go run qotd.go --addr="127.0.0.1:2562"
这将联系运行在此地址的服务器。如果你在其他地址运行服务器,你需要更改此地址以匹配。
如果没有传递--author标志,将随机选择一个作者。
你应该在屏幕上看到以下内容:
Author: [some author]
Quote: [some quote]
现在我们已经看到了如何使用 gRPC 创建一个简单的客户端和服务器应用程序。但这只是 gRPC 功能的开始。
我们仅仅是刚刚触及表面
gRPC 是云技术(如 Kubernetes)的关键基础设施组件。它是在多年的 Stubby 经验后构建的,Stubby 是谷歌的内部前身。我们仅仅触及了 gRPC 可以做的冰山一角。这里有一些额外的功能:
-
运行 gRPC 网关以导出 REST 端点
-
提供能够处理安全性和其他需求的拦截器
-
提供流式数据
-
支持 TLS
-
用于附加信息的元数据和尾部信息
-
客户端服务器负载均衡
以下是已经进行切换的一些大型公司:
-
Square
-
Netflix
-
IBM
-
CoreOS
-
Docker
-
CockroachDB
-
Cisco
-
Juniper Networks
-
Spotify
-
Zalando
-
Dropbox
让我们来聊聊如何在公司内部最佳地提供 REST 或 gRPC 服务。
公司标准的 RPC 客户端和服务器
谷歌技术栈成功的关键之一是围绕技术的整合。尽管技术中确实存在大量重复,谷歌在某些软件和基础设施组件上进行了标准化。在谷歌内部,很少看到不使用 Stubby(谷歌的内部 gRPC)的客户端/服务器。
工程师用于 RPC 的库被编写成在每种语言中都能一致工作。近年来,站点可靠性工程(SRE)组织推动围绕 Stubby 构建的包装器,提供一系列功能和最佳实践,以防止每个团队重新发明轮子。这些功能包括:
-
身份验证
-
压缩处理
-
分布式服务速率限制
-
带回退的重试(或断路器)
通过让客户端在没有回退的情况下重试,这消除了许多基础设施的威胁,去除了团队自行设计安全模型的成本,并允许专家对这些项目进行修复。对这些库的修改使每个人受益,降低了发现已经构建好的服务的成本。
作为一个可能携带寻呼机的 DevOps 工程师或 SRE,推动 RPC 层的标准化可以带来无数的好处,例如避免接到寻呼机!
尽管选择常常被视为好事,有限的选择可以让开发团队和运维人员继续专注于他们的产品,而不是基础设施,这对于打造健壮的产品至关重要。
如果你决定提供一个 REST 框架,以下是一些推荐的实践:
-
仅使用
POST。 -
不要使用查询变量。
-
仅使用 JSON。
-
确保所有的参数都在你的请求中。
这将大大减少你在框架中需要编写的代码量。
在本节中,我们学习了什么是 RPC 服务以及如何使用两种流行的方法,REST 和 gRPC,来编写客户端。你还了解了 REST 有一套较为宽松的指南,而 gRPC 更倾向于使用 schema 类型,并自动生成使用系统所需的组件。
总结
本章结束了我们关于与远程数据源交互的内容。我们讨论了如何通过使用 Postgres 的示例连接到 SQL 数据库,了解了 RPC 是什么,并讨论了两种最流行的 RPC 服务类型,REST 和 gRPC。最后,我们为这两种框架编写了服务器和客户端。
本章使你能够连接到最流行的数据库和云服务,以获取和检索数据。现在你可以编写自己的 RPC 服务来开发云应用。
在下一章中,我们将利用这些知识构建工具,控制远程机器上的任务。
所以,不再多说,让我们直接进入如何编写命令行工具。
第七章:编写命令行工具
访问任何 DevOps 工程师,你会发现他们的屏幕上充满了执行 命令行界面 (CLI) 应用程序的终端。
作为 DevOps 工程师,我们不只是希望使用别人为我们制作的应用程序;我们希望能够编写自己的 CLI 应用程序。这些应用程序可以通过 REST 或 gRPC 与各种系统通信,正如我们在前一章中讨论的那样。或者你可能想要执行各种应用程序,并通过自定义处理运行它们的输出。一个应用程序甚至可以设置开发环境并启动新发布的测试周期。
无论你的用例是什么,你都需要使用一些常见的包来帮助你管理应用程序的输入和输出处理。
在本章中,你将学习如何使用 flag 和 os 包编写简单的 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.json 和 file2.json,并输出重新格式化的文本。
这里,我们接收 wget 调用的输出,并通过 STDIN 将其读取到我们的 reformat 二进制文件中。这类似于 cat 和 grep 的工作方式。当我们的参数为空时,它们会直接从 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)
}
这段代码执行了以下操作:
-
定义了一个名为
URLValue的flag.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 人员每天使用的一种不太常见的输入方法是将输入通过管道传递到程序中。
工具如cat、xargs、sed、awk和grep允许你将一个工具的输出传递给下一个工具的输入以完成任务。一个简单的例子可能是查找我们从网络获取的文件中包含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.Command的Context -
使用
--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通道来接收信号 -
订阅
SIGINT、SIGTERM和SIGQUIT类型的信号 -
使用一个 goroutine 来处理传入的信号,执行以下操作:
-
调用
cleanup()函数来处理程序的清理工作 -
在收到
SIGINT和SIGTERM时以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中创建从0到29的文件,除非在写入文件时发生问题或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) 的主要目标之一是消除重复的手动过程。那段时间可以用来读一本好书(比如这本),整理袜子抽屉,或者处理下一个问题。第二个好处是可以消除过程中的人为错误。打错字或复制粘贴错误都很容易发生。最后,它是大规模运营的核心基础。通过本地自动化结合书中详细描述的其他技术,可以在大规模上进行变更。
自动化生命周期通常分为三个阶段,从手动工作到自动化,如下所示:
-
第一阶段涉及由经验丰富的工程师手动执行命令。虽然这本身不是自动化,但它启动了一个以某种形式的自动化结束的循环。
-
第二阶段通常涉及将这些步骤记录下来,以便文档化过程,允许多人分担工作。这可能是一个
git仓库。 -
第三阶段通常是编写脚本使任务可重复执行。
一旦公司变得更大,这些阶段通常会合并为开发一个服务,在识别到需要时以完全自动化的方式处理任务。
一个很好的例子可能是在 Kubernetes 集群上部署 pods 或向 Kubernetes 配置中添加新的 pod 配置。这些操作是通过调用命令行应用程序如 kubectl 和 git 来驱动的。这些类型的工作一开始是手动的;最终,它们会被文档化,并最终以某种方式实现自动化。某个时候,这可能会转移到 持续集成/持续部署 (CI/CD) 系统中,由其为你处理这些任务。
在本地自动化工具的关键是 os/exec 包。该包允许执行其他工具并控制它们的 STDIN / STDOUT / STDERR 流。
让我们更仔细地看一下。
确定必需工具的可用性
在编写调用系统上其他应用程序的应用程序时,关键是要在开始执行命令之前,确定所需的工具是否在系统中可用。没有什么比在执行过程中发现缺少关键工具更糟糕的了。
exec 包提供了 LookPath() 函数来帮助确定一个二进制文件是否存在。如果只提供了二进制文件的名称,则会查阅 PATH 环境变量,并在这些路径中搜索该二进制文件。如果名称中包含 /,则仅查阅该路径。
假设我们正在编写一个工具,需要安装 kubectl 和 git 才能正常工作。我们可以通过执行以下代码来测试这些工具是否在我们的 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():运行命令并返回STDOUT和STDERR的合并输出。 -
.Output():运行命令并返回STDOUT的输出。 -
.Run():运行程序并等待其退出。若有问题,则返回错误。 -
.Start():运行命令但不阻塞。用于你希望在命令运行时与其交互的场景。
.CombinedOuput() 和 .Output() 是启动程序最常见的方法。用户在终端中看到的输出通常来自 STDOUT 和 STDERR。选择使用哪一个取决于你希望如何响应程序的输出。
.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@host"uname -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.Reachable为false,我们会将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。
这种情况发生在有人使用终端模式时。在这些情况下,如果必须处理这种情况,你将需要使用 goexpect 或 go-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二进制文件
- 这通过 SSH 登录到一台机器并运行
-
执行
killPIDs()函数-
这使用 SSH 执行
kill命令来终止我们的进程 -
使用信号 15 或
TERM作为软终止
-
-
执行
waitForDeath()函数-
这使用 SSH 等待
cp操作 -
如果没有,执行带信号 9 或
KILL的killPIDs(),并再次执行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 包。atomic 是 sync 的一个子包,它允许我们在不使用 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来控制进程执行,我们将使用systemd的Service指令来提供容器化。这些细节可以在我们的代码库中的文件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中存储struct、map或slice,你不能修改键/字段——你必须制作一个包含所有键/索引/字段的新副本并存储,而不是修改单个键/字段。
当值较小时,这比使用互斥锁更适合读取,当存储少量计数器时非常完美。
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-cpu的expvar.Var类型-
我们的变量类型是
func() interface{},它实现了expvar.Func -
这只是读取由
collectCPU()函数设置的atomic.Value- 当有人查询我们位于
/debug/vars的网页时,会发生读取操作
- 当有人查询我们位于
-
-
每 10 秒刷新我们的数据收集
expvar定义了其他一些简单的类型,例如String、Float、Map等。然而,我更喜欢使用协议缓冲区(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包在远程系统上运行命令,或使用ssh和goexpect包与复杂的程序进行交互。我们将这部分与前几章的 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 的参考架构
接下来,让我们看看OpenTelemetry(OTel)的概念性参考架构图:

图 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,并提供对复杂系统中发生事件的整体视图:

图 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。
我们不会深入讨论日志输出的位置(如文件系统、STDOUT和STDERR),而是假设我们希望摄取的应用程序日志将具有文件表示形式。
现在我们在应用程序中生成了结构化日志,可以切换到使用 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-crio 与 parser-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 ID 和 Span 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
要运行此演示,请转到章节源代码,cd到tracing目录,运行 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 的方式调用 initTraceProvider 和 shutdown。在初始化追踪提供者后,服务器 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)
图 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_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() 接受一个计量器并返回一个客户端使用的仪表结构。一个仪表用于记录和聚合测量值。这个函数设置了两个 Int64Counter 和 Int64Histogram 仪表。每个仪表都以一个描述清晰的名称来定义,以便于在后端度量系统中进行分析。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:

图 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和两个新的键,alerting和rule_files。alerting键描述了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 页面看到如下内容:

图 9.6 – Prometheus 中的 HighRequestLatency 告警
上面的截图显示了在 Prometheus 中注册的告警规则。如您所见,HighRequestLatency 告警是通过我们在 ./rules/demo-server 文件中配置的命令注册的。
大约运行 5 分钟后,您应该能看到如下内容:

图 9.7 – HighRequestLatency 告警触发
在上面的截图中,您可以看到 HighRequestLatency 告警被触发。这是 Prometheus 在平均请求延迟超过 0.2 秒时触发告警。告警随后会发送到 Alertmanager,Alertmanager 会将其委派给相应的接收器。接收器将告警发送到配置的服务,可能是 PagerDuty,或者是您配置的其他接收器。您现在已经建立了一个告警流程,当您的应用程序进入异常状态时,能够通知您或团队的其他成员。
在这一节中,您学习了如何配置 Prometheus 告警规则,部署 Alertmanager,并配置 Alertmanager 将告警发送到您选择的通知服务。通过这些知识,您应该能够为应用程序定义规范行为的规则,并在应用程序行为超出这些范围时提醒您或您的团队。
告警是响应应用程序异常行为的关键组成部分。通过适当的指标,您现在可以在应用程序未达到预期时主动响应,而不是在收到客户投诉时才做出反应。
摘要
在本章中,我们探讨了 OpenTelemetry 的基础知识,如何对您的应用程序和基础设施进行监控,并如何将这些遥测数据导出到后端可视化和分析工具,如 Jaeger 和 Prometheus。我们还通过集成告警规则扩展了指标的优势,以便在应用程序操作超出预期行为参数时,主动通知我们。通过应用所学知识,您将在支持电话中避免措手不及。您将拥有数据来诊断和解决复杂系统中的问题。更棒的是,您将能在客户提出问题之前就了解这些问题。
我们还建立了一些相对简单的指标、追踪和告警。通过这些知识,您将能够实现自己的追踪、指标和告警,帮助您和您的团队在生产环境中迅速有效地应对故障。
在下一章,我们将讨论如何使用 GitHub Actions 自动化工作流。我们将了解 GitHub Actions 的基础,并在此基础上构建自己的基于 Go 的 GitHub Actions,赋能您使用任何图灵完备语言编写自动化任务。
第十章:使用 GitHub Actions 自动化工作流
你是否曾参与过一个需要完成例行、单调任务的项目?你是否曾坐下来发布软件,阅读项目的 wiki 页面,却发现需要执行 15 个手动步骤,复制、粘贴并祈祷?当轮到你完成这些任务时,感觉如何?
这样的任务被称为 繁重工作 —— 缓慢 和 困难。这种工作会降低我们团队的开发速度,而且更为关键的是,随着时间的推移,它会消磨 DevOps 或 站点可靠性工程 (SRE) 团队的士气。繁重任务是手动的,手动任务天生容易出错。如果我们不试图用适当的自动化来替换这些任务,更多的繁重工作将会积累,情况会变得更糟。
作为一名 DevOps 工程师,你是驱动自动化并减少繁重工作的反熵力量。在本章中,我们将学习如何使用 GitHub Actions 来自动化工作流,以减少繁重工作并提高项目速度。
GitHub Actions 提供了一个强大的平台,用于创建可定制的自动化工作流,并且对于任何开源项目都是免费的。GitHub Actions 将强大、可定制的工作流引擎与同样强大的事件模型结合,触发自动化。本章中使用的模式和实践将利用 GitHub Actions,但也可以转移到许多其他开发者工作流自动化工具,如 Jenkins 和 GitLab CI。选择使用 GitHub Actions 的原因是它为开源开发者提供了普遍的访问权限,并且能够接触到广泛的社区贡献的 Actions,极大提升了生产力。
在本章中,你将从学习 GitHub Actions 的基础知识开始。你将运用这些技能构建一个持续集成工作流,用于验证拉取请求。然后,你将扩展该工作流,添加发布自动化以发布 GitHub 版本。最后,你将使用 Go 构建自己的自定义 GitHub Action,并将其发布到 GitHub Marketplace。
本章将涵盖以下主题:
-
了解 GitHub Actions 的基础知识
-
构建持续集成工作流
-
构建发布工作流
-
使用 Go 创建自定义 GitHub Action
-
发布自定义 Go GitHub Action
技术要求
在本章中,你需要在计算机上安装 Docker、Git 和 Go 工具。本章的代码位于 github.com/PacktPublishing/B18275-09-Automating-Workflows-with-GitHub-Actions-Code-Files。
本章的代码文件可以从 github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/10 下载。
让我们开始构建我们的第一个 GitHub Action。
了解 GitHub Actions 的基础知识
GitHub Actions 是事件驱动的自动化任务,存在于 GitHub 仓库中。像拉取请求这样的事件可以触发一组任务的执行。一个示例是拉取请求触发一组任务来克隆 Git 仓库并执行 go test 来运行 Go 测试。
GitHub Actions 极为灵活,使开发者能够编写各种自动化任务,甚至是一些你通常不会与传统的持续集成/发布管道联系在一起的自动化。Actions 也具有可组合性,使得任务组可以作为已发布的 Action 打包在一起,并与其他 Actions 一起用于工作流。
在本节中,你将了解 GitHub Action 的组成部分:工作流、事件、上下文和表达式、作业、步骤以及动作。在介绍这些组件后,我们将构建并触发我们的第一个 GitHub Action。
探索 GitHub Action 的组成部分
理解 GitHub Action 的组成部分、它们之间的关系,以及它们如何交互,是理解如何编写自己的自动化的关键。让我们从探索 Action 的组成部分开始。
工作流
工作流是一个以 YAML 编写的自动化文件,存放在 GitHub 仓库的 ./github/workflows/ 文件夹中。一个工作流由一个或多个作业组成,可以按计划或通过事件触发。工作流是 GitHub Action 的最高级别组件。
工作流语法
工作流需要开发者通过 on 键指定触发自动化的事件,并通过 jobs 键指定自动化触发后执行的作业。通常,name 关键字还会指定一个名称,否则,工作流将使用包含工作流 YAML 文件的短名称。例如,在 ./github/workflows/foo.yaml 中定义的工作流将默认名称为 foo。
工作流结构的示例
以下是一个命名工作流的示例,定义了最小的键集。但是,这不是一个有效的工作流,因为我们还没有定义任何触发工作流的事件,也没有定义任何在触发后执行的作业:
name: my-workflow # (optional) The name of your workflow;
# defaults to the file name.
on: # Events that will trigger the workflow
jobs: # Jobs to run when the event is triggered
接下来,让我们讨论如何触发工作流。
事件
事件是一个触发器,它使工作流开始执行。事件有多种类型:Webhook 事件、定时事件和手动触发事件。
Webhook 事件可以来自仓库中的活动。例如,触发活动包括提交推送、创建拉取请求或创建新问题。来自仓库交互的事件是工作流最常见的触发器。Webhook 事件也可以通过外部系统创建,并通过仓库调度 Webhook 转发到 GitHub。
定时事件类似于 cron 作业。这些事件会在定义的时间表上触发工作流。定时事件是自动化重复性任务的一种方式,例如,在 GitHub 上执行旧问题的维护或运行夜间报告作业。
手动调度事件并非通过仓库活动触发,而是手动触发。例如,一个项目可能与其 Twitter 账户关联,项目维护者可能希望能够发送一条关于新功能的推文,但又不希望共享 Twitter 的认证密钥。一个临时事件将使得自动化可以代表项目发送推文。
事件语法
事件要求开发者为on:键指定事件类型。事件类型通常具有子键值对,用于定义其行为。
单个事件示例
可以指定一个事件来触发自动化:
# the workflow will be triggered when a commit
# is pushed to any branch
on: push
on: push
多个事件示例
可以指定多个事件来触发自动化:
# the workflow will execute when a commit is pushed
# to any branch or pull request is opened
on: [push, pull_request]
定时事件示例
定时事件调度使用便携式操作系统接口(POSIX)的 cron 语法:
on:
scheduled:
- cron: '0,1,*,*,*' # run every day at 01:00:00
手动事件示例
手动事件通过用户交互触发,并且可以包括输入字段:
# a manually triggered event with a
# single "message" user input field
on:
workflow_dispatch:
inputs:
message:
description: 'message you want to tweet'
required: true
上下文和表达式
GitHub Actions 提供了一组丰富的上下文变量、表达式、函数和条件语句,用以增强工作流的表现力。这将不是对所有这些项的详尽研究,但我们将重点介绍最关键的内容。
上下文变量
上下文变量提供了一种访问工作流运行、环境、步骤、密钥等信息的方式。最常见的上下文变量有github、env、secrets和matrix。这些变量被视为映射,可以通过变量名和属性名进行索引。例如,env['foo']解析为foo环境键的值。
github上下文变量提供关于工作流运行的信息,包含如工作流正在执行的ref等信息。如果你希望在构建时将该信息注入到应用程序中,这非常有用。你可以通过使用github['ref']或github.ref来访问这些信息。
env上下文变量包含为工作流运行指定的环境变量。这些值可以通过索引语法进行访问。
secrets上下文变量包含工作流运行中可用的密钥。这些值也可以通过索引语法进行访问。注意,这些值在日志中会被隐藏,因此密钥值不会暴露。
matrix上下文变量包含你为当前任务配置的矩阵参数信息。例如,如果你希望在多个操作系统上运行构建并使用多个版本的 Go,matrix变量允许你指定每一个操作系统和 Go 版本的列表,这可以用于执行一组并行任务,使用每一种操作系统和 Go 版本的组合。我们将在讨论任务时更详细地介绍这一点。
表达式
表达式的语法是${{ expression }}。表达式由变量、字面量、运算符和函数组成。我们来看下面的示例:
jobs:
job_with_secrets:
if: contains(github.event.pull_request.labels.*.name, 'safe to test')
前述任务仅会在拉取请求被标记为safe to test时执行。if条件将评估github.event.pull_request.labels.*.name上下文变量,并确认拉取请求上的标签中是否有一个名为safe to test的标签。如果你想确保工作流只在仓库维护者确认拉取请求是安全的后才执行,这非常有用。
表达式也可以用作输入。我们来看下面的示例:
env:
GIT_SHA: ${{ github.sha }}
这个 YAML 片段展示了如何将名为GIT_SHA的环境变量设置为github.sha上下文变量的值。现在,GIT_SHA环境变量将对所有在任务内运行的操作可用。使用上下文变量作为输入对于定制在工作流中执行的脚本或操作非常有用。
任务
一个任务是执行一组步骤的集合,这些步骤在一个独立的计算实例或运行器上运行。你可以将运行器视为运行任务的虚拟机。任务默认是并行执行的,因此如果工作流定义了多个任务,并且有足够的运行器可用,它们将并行执行。任务有依赖关系的概念,一个任务可以依赖于另一个任务,这样可以确保任务按顺序执行,而不是并行执行。
任务语法
任务要求开发者指定任务的 ID、任务将在其上执行的运行器类型(通过runs-on:键),以及任务将执行的一系列步骤(通过steps:键)。runs-on:键对我们特别重要,因为它用于在不同的操作系统(OS)平台上执行任务,例如多个版本的 Ubuntu、macOS 和 Windows。
使用runs-on:键,可以让任务在指定平台上运行,但这并不能让我们创建一个任务矩阵来在多个平台上并行执行。为了使任务在配置矩阵中执行,必须使用strategy:键和表达式。通过配置策略,我们可以构建一个执行相同任务配置的任务矩阵。你将在下面的示例中看到这种配置的例子。
还有许多其他选项可以定制任务的执行以及任务执行的环境,但我们不会深入探讨这些选项。
在多个平台上执行任务
这个示例展示了两个名为job_one和job_two的任务。在这里,job_one是一个矩阵任务,它将在 Ubuntu、macOS 和 Windows 的最新版本上并行运行六个模板化任务,每个任务都会回显1.17和1.16。在 Ubuntu 18.04 上,job_two将与job_one并行运行,并回显"hello world!":
jobs:
job_one:
strategy:
matrix:
os: [ubuntu-latest, macos-latest, windows-latest]
go_version: [1.17, 1.16]
runs_on: ${{ matrix.os }}
steps:
- run: echo "${{ matrix.go_version }}"
job_two:
runs_on: ubuntu-18.04
steps:
- run: echo "hello world!"
步骤
步骤是在作业上下文中运行的任务,并在与该作业关联的运行器上下文中执行。步骤可以是一个 shell 命令或一个动作。由于步骤在同一个运行器中执行,它们可以共享数据。例如,如果你在前一个步骤中在运行器的文件系统上创建了一个文件,那么后续步骤将能够访问该文件。你可以将一个步骤看作是在它自己的进程中运行,且任何环境变量的更改都不会传递到下一个步骤。
步骤语法
步骤要求开发者使用 uses: 键来指定一个动作,或使用 run: 键来指定要运行的 shell 命令。可选的输入允许你使用 env: 键自定义环境变量,使用 working-directory: 键自定义工作目录,也可以通过使用 name 键更改在 GitHub 用户界面中显示的步骤名称。还有许多其他选项可以定制步骤的执行方式,但我们不会深入讨论这些选项。
使用动作安装 Go 的步骤
这个示例展示了一个没有名称的步骤,使用 actions/setup-go 的 v2 版本来安装 Go 版本 1.17.0 或更高版本。这个动作可以在 github.com/actions/setup-go 找到。这个示例很好地展示了一个公开可用的动作,你可以用它为你的自动化添加功能。你可以在 github.com/marketplace?type=actions 上找到几乎任何任务的动作。在后面的章节中,我们将讨论如何构建你自己的动作并将其发布到 GitHub 市场:
steps:
- uses: actions/setup-go@v2
with:
go-version: '¹.17.0'
含有多行命令的步骤
在这个示例中,我们扩展了前面的示例,新增了一个 Run go mod download and test 步骤,它运行 go 工具,而这个工具是通过 actions/setup-go@v2 安装的。运行命令的第一行使用 | 来表示 YAML 中多行字符串的开始:
steps:
- uses: actions/setup-go@v2
with:
go-version: '¹.17.0'
- name: Run go mod download and test
run: |
go mod download
go test
动作
一个动作是由一组步骤组合而成的可重用命令,这些步骤可以有输入和输出。例如,actions/setup-go 动作用于执行一系列步骤,在运行器上安装 Go 的某个版本。然后,Go 工具链可以在同一作业中的后续步骤中使用。
GitHub Actions 名字起得很恰当,因为动作是 GitHub Actions 的超级功能。动作通常是公开发布的,允许开发者利用现有的方案来快速构建复杂的自动化。动作类似于开源的 Go 库,帮助开发者更快地构建 Go 应用。当我们构建自己的动作时,你会很快看到这个功能的强大之处。
如果你有兴趣查看 actions/setup-go 的源代码,请访问 github.com/actions/setup-go。在本章后面,我们将构建自己的 Go 动作并将其发布到 GitHub 市场。
如何构建和触发你的第一个 GitHub Action
现在我们大致了解了 Action 的组成部分,接下来让我们创建一个并探索这些组件如何构建、结构化及相互作用。
创建并克隆 GitHub 仓库
如果这是你第一次创建和克隆一个代码库,你可以参考以下链接:
在创建仓库时,我通常会添加 README.md、.gitignore 和一个麻省理工学院(MIT)许可证文件。一旦你创建并克隆了仓库,你应该会有一个本地项目目录,如下所示:
$ tree . -a -I '\.git'
.
├── .gitignore
├── LICENSE
└── README.md
创建你的第一个工作流
记住,工作流文件存放在 .github/workflows 目录中。第一步是创建该目录。下一步是在 .github/workflows 目录中创建工作流文件:
mkdir -p .github/workflows
touch .github/workflows/first.yaml
打开 .github/workflows/first.yaml 文件,使用你喜欢的编辑器,并添加以下工作流 YAML:
name: first-workflow
on: push
jobs:
echo:
runs-on: ubuntu-latest
steps:
- name: echo step
run: echo 'hello world!'
上述工作流名为 first-workflow。它将在最新版本的 Ubuntu 上执行一个名为 echo 的单一作业,并执行一个步骤,使用系统默认的 shell 输出 hello world!。你还可以通过 shell: 键指定你想要使用的 shell。
保存 .github/workflows/first.yaml。提交并将工作流推送到 GitHub:
git add .
git commit -am 'my first action'
git push origin main
通常,你会先创建一个分支,然后打开一个拉取请求(pull request),而不是直接提交和推送到主分支。但对于你的第一个工作流,这是查看结果的最快方法。
当你推送完提交后,你应该能够在浏览器中打开你的 GitHub 仓库并点击Actions选项卡。你应该看到你的第一个工作流成功执行的视图。它应当类似如下:

图 10.1 – 所有工作流视图
注意左侧的工作流列表,里面有一个名为first-workflow的工作流。我们可以看到,该工作流的第一次运行是针对我们的提交,提交信息为my first action。
如果你点击my first action的工作流运行记录,你应该能看到如下内容:

图 10.2 – 工作流作业视图
注意左侧的Jobs列表中,echo作业旁边有一个绿色的勾,表示该作业已成功执行。在右侧,你可以看到执行的详细信息。
你可以点击echo作业,查看它的输出以及执行的步骤:

图 10.3 – echo 作业输出视图
注意作业设置,它提供了关于执行作业的 runner 和环境的详细信息。同时,注意到 echo 'Hello World!' 这一单一 shell 命令,并将 "Hello World!" 字符串输出到控制台日志。最后,作业成功完成,因为 echo step 在完成时返回了 0 错误码。
在本节中,你已学会了 GitHub Actions 的基础知识,并创建了你的第一个简单自动化。现在,你具备了开始构建更复杂自动化所需的工具,这些自动化将消除我们在本章早些时候讨论的繁琐任务。在接下来的章节中,你将学会如何利用这些技能构建持续集成和发布工作流,之后还将学会如何编写自己用 Go 编写的自定义操作。
构建持续集成工作流
在本节中,我们将使用 GitHub Actions 执行持续集成自动化,当拉取请求被打开或代码被推送到仓库时。如果你不熟悉持续集成,它是指将来自多个贡献者的代码变更自动集成到代码仓库中的实践。持续集成自动化任务包括在特定提交时克隆仓库、代码检查、构建和测试代码,并评估测试覆盖率的变化。持续集成自动化的目标是防止代码变更降低项目质量或违反自动化中规定的规则。
在本节中,你将学习如何创建持续集成工作流。在你的持续集成工作流中,你将学会如何在多个操作系统之间并行执行任务。你将把构建工具安装到工作执行器上,用于构建软件项目。你将使用一个操作来克隆项目的源代码。最后,你将通过运行代码检查工具和执行单元测试来确保测试通过并保持代码质量。
介绍 tweeter 命令行工具
你不能没有软件项目就创建持续集成工作流。我们将使用一个简单的 Go 命令行工具,名为 tweeter。该项目的源代码可以在 github.com/PacktPublishing/B18275-08-Automating-Workflows-with-GitHub-Actions-Code-Files 找到。
Tweeter 是一个简单的 Go 命令行工具,它会向 Twitter 发送推文。源代码由两个包组成,main 和 tweeter。tweeter 包包含将由我们的持续集成工作流执行的 Go 测试。
克隆并测试 tweeter
从模板创建一个新的仓库:github.com/PacktPublishing/B18275-08-Automating-Workflows-with-GitHub-Actions-Code-Files,点击 {your-account} 并用你的账户名创建:
git clone https://github.com/{your-account}/B18275-08-Automating-Workflows-with-GitHub-Actions-Code-Files
cd B18275-08-Automating-Workflows-with-GitHub-Actions-Code-Files
go test ./...
执行 tweeter命令并带上-h参数将提供使用文档:
$ go run . -h
Usage of /tmp/go-build3731631588/b001/exe/github-actions:
--accessToken string twitter access token
--accessTokenSecret string twitter access token secret
--apiKey string twitter api key
--apiKeySecret string twitter api key secret
--dryRun if true or if env var DRY_RUN=true, then a tweet will not be sent
--message string message you'd like to send to twitter
--version output the version of tweeter
pflag: help requested
exit status 2
不要求使用 Twitter
如果你不倾向于使用社交媒体,tweeter 也允许用户模拟发送推文。当指定--dryRun时,消息内容将输出到STDOUT,而不是作为推文发送到 Twitter。
接下来,我们将构建一个持续集成工作流来测试 tweeter。
tweeter 持续集成工作流的目标
在构建持续集成工作流之前,您应考虑希望通过工作流实现什么。对于 tweeter 工作流,我们的目标如下:
-
在推送到
main分支和格式化为语义版本的标签(例如v1.2.3)时触发工作流,进行构建和验证。 -
针对
main分支的拉取请求必须进行构建和验证。 -
Tweeter 必须同时在 Ubuntu、macOS 和 Windows 上进行构建和验证。
-
Tweeter 必须同时使用 Go 1.16 和 1.17 进行构建和验证。
-
Tweeter 源代码必须通过代码风格检查。
tweeter 的持续集成工作流
在我们确定了 tweeter 持续集成工作流的目标后,我们可以构建一个工作流来实现这些目标。以下是实现每个目标的持续集成工作流:
name: tweeter-automation
on:
push:
tags:
- 'v[0-9]+.[0-9]+.*'
branches:
- main
pull_request:
branches:
- main
jobs:
test:
strategy:
matrix:
go-version: [ 1.16.x, 1.17.x ]
os: [ ubuntu-latest, macos-latest, windows-latest ]
runs-on: ${{ matrix.os }}
steps:
- name: install go
uses: actions/setup-go@v2
with:
go-version: ${{ matrix.go-version }}
- uses: actions/checkout@v2
- name: lint with golangci-lint
uses: golangci/golangci-lint-action@v2
- name: run go test
run: go test ./...
上述工作流一开始可能有些复杂。不过,如果我们将工作流分解,行为会变得清晰。
触发工作流
tweeter 持续集成工作流的前两个目标如下:
-
推送到
main分支以及与v[0-9]+.[0-9]+.*匹配的标签必须进行构建和验证。 -
针对
main分支的拉取请求必须进行构建和验证。
通过指定以下事件触发器来实现这些目标:
on:
push:
tags:
- 'v[0-9]+.[0-9]+.*'
branches:
- main
pull_request:
branches:
- main
push:触发器将在推送标签匹配v[0-9]+.[0-9]+.*时执行工作流——例如,v1.2.3会匹配该模式。push:触发器也会在向main推送提交时执行工作流。pull_request触发器将在任何针对main分支的拉取请求更改时执行工作流。
请注意,使用pull_request触发器将允许我们更新工作流,并在每次推送拉取请求时查看工作流的变化。这是开发工作流时希望的行为,但它也可能使自动化面临恶意行为者的威胁。例如,恶意行为者可以打开新的拉取请求,篡改工作流以窃取其中暴露的机密。为了防止这种情况,有多种缓解措施可以应用,根据项目的安全需求,可以独立或一起使用这些措施:
-
仅允许维护者触发工作流。
-
使用
pull_request_target事件来触发工作流,这将使用拉取请求基准中定义的工作流,而不管拉取请求中工作流的更改。 -
添加标签保护,以便只有在维护者为拉取请求添加标签时,工作流才会执行。例如,拉取请求可以由维护者进行审查,如果用户和代码更改是安全的,维护者会应用
safe-to-test标签,从而允许任务继续进行。
接下来,我们将扩展自动化,涵盖多个平台和 Go 版本。
进入矩阵
Tweeter 持续集成工作流的接下来的两个目标如下:
-
Tweeter 必须同时在 Ubuntu、macOS 和 Windows 上进行构建和验证。
-
Tweeter 必须同时使用 Go 1.16 和 1.17 进行构建和验证。
这些目标是通过指定以下matrix配置来完成的:
jobs:
test:
strategy:
matrix:
go-version: [ 1.16.x, 1.17.x ]
os: [ ubuntu-latest, macos-latest, windows-latest ]
runs-on: ${{ matrix.os }}
steps:
- name: install go
uses: actions/setup-go@v2
with:
go-version: ${{ matrix.go-version }}
test任务指定了一个矩阵策略,包含两个维度,go-version和os。指定了两个 Go 版本和三个操作系统。这个变量组合将创建六个并行任务,[(ubuntu-latest, 1.16.x), (ubuntu-latest, 1.17.x), (macos-latest, 1.16.x), (macos-latest, 1.17.x), (windows-latest, 1.16.x)和(windows-latest, 1.17.x)]。矩阵的值将被替换到runs-on:和go-version:中,以执行并行任务,满足在每个平台和 Go 版本组合上运行的目标:

图 10.4 – 显示矩阵构建的拉取请求
在上图中,可以看到每个矩阵任务并行执行。注意,每个任务都指定了任务名称test和该任务的矩阵变量。
构建、测试和 lint 检查
最后三个目标之间存在构建、测试和 lint 的重叠:
-
Tweeter 必须同时在 Ubuntu、macOS 和 Windows 上进行构建和验证。
-
Tweeter 必须同时使用 Go 1.16 和 1.17 进行构建和验证。
-
Tweeter 的源代码必须通过代码质量检查。
以下步骤将满足这些要求:
steps:
- name: install go
uses: actions/setup-go@v2
with:
go-version: ${{ matrix.go-version }}
- uses: actions/checkout@v2
- name: lint with golangci-lint
uses: golangci/golangci-lint-action@v2
- name: run go test
run: go test ./...
在前面的步骤中,发生了以下情况:
-
Go 通过
actions/setup-go@v2动作安装,使用矩阵指定的 Go 版本。这个动作对所有 GitHub 用户可用,并通过 GitHub Marketplace 发布。Marketplace 中有许多可以简化工作流编写的动作。 -
当前
ref的源代码是通过actions/checkout@v2动作在当前工作目录中克隆的。注意,动作没有指定名称。对于常用的动作,通常不提供名称。 -
Lint 检查使用
golangci/golangci-lint-action@v2执行,该动作会在代码库的源代码上安装并执行golangci-lint工具,满足确保代码通过 lint 质量检查的目标。这个特定的动作包括多个子 lint 工具,能够严格检查常见的 Go 性能和风格错误。 -
通过运行一个临时的
go test ./...脚本来对代码进行功能验证,该脚本递归地测试仓库中的所有包。请注意,在前面的步骤中,Go 工具已经被安装并可供后续步骤使用。
通过前面的步骤,我们已经实现了持续集成工作流的目标。通过之前的工作流,我们执行了一个并发作业矩阵,安装了构建工具,克隆了源代码,进行了代码检查和测试了变更集。在这个示例中,我们学习了如何为 Go 项目构建一个持续集成工作流,但任何语言和工具集都可以用来创建持续集成工作流。
在下一节中,我们将构建一个发布工作流,自动化构建和发布 tweeter 项目的新版本过程。
构建发布工作流
在本节中,我们将把发布新版本的手动繁琐过程转化为 GitHub 工作流自动化,通过将标签推送到仓库来触发。此自动化将导致一个包含构建说明和发布工件的 GitHub 发布,适用于已标记的、语义版本的 tweeter 命令行工具。自动化手动过程,如发布,减少了手动错误的可能性,并提高了项目维护者的生产力。
在本节中,你将学习如何创建发布自动化工作流。你将学习如何在成功完成依赖自动化后触发自动化运行。你将学习如何构建面向多个平台的二进制文件。最后,你将自动化创建 GitHub 发布,包括自动生成的发布说明。
GitHub 发布
GitHub 发布是基于 Git 标签的仓库可部署软件迭代。发布声明向世界表明该软件的新版本已可用。一个发布包含一个标题、一个可选的描述和一组可选的工件。标题为发布提供一个名称。描述用于提供对发布内容的洞察——例如,发布中包含了哪些新功能或拉取请求,以及哪些 GitHub 贡献者参与了发布。描述采用 GitHub Markdown 格式。发布工件是与发布相关的文件,用户可以下载——例如,一个命令行应用可能会发布已编译的二进制文件,供下载和使用。
Git 标签
Git 标签是指向 Git 仓库中特定引用的命名指针,通常采用语义版本格式,如 v1.2.3。语义版本是一种为标签命名的约定,它提供了关于新版本重要性的某些信息。语义版本标签的格式为 Major.Minor.Patch。通过递增各个字段来表达以下行为:
-
Major:当发生不兼容的 API 更改时(例如破坏性更改),递增此字段。 -
Minor:在向后兼容的方式中添加功能时递增,例如新增功能。 -
Patch:在进行向后兼容的 bug 修复时递增。
推特的发布自动化
在 推特的持续集成工作流 部分中,我们为推特命令行工具创建了 CI 自动化。我们将在 CI 自动化的基础上添加推特的发布自动化。
自动化目标
在我们的发布自动化中,我们将完成以下目标:
-
当仓库被标记为语义版本时触发自动化
-
在创建发布之前运行单元测试和验证
-
将发布的语义版本注入推特应用程序
-
构建推特应用程序的跨平台版本
-
从发布中的拉取请求生成发布说明
-
在发布中标记贡献者
-
创建一个包含以下内容的 GitHub 发布:
-
包含发布语义版本的标题
-
包含生成的发布说明的描述
-
由跨平台二进制文件组成的工件
-
接下来,我们将创建发布自动化以满足这些要求。
创建发布自动化
在明确了推特发布自动化的目标后,我们准备好扩展在上一节中构建的现有持续集成工作流,并添加发布作业以实现这些目标。由于发布作业比持续集成工作流要长,因此我们将逐步处理每个部分。
触发自动化
推特发布工作流的第一个目标是当仓库被标记为语义版本时触发自动化:
name: tweeter-automation
on:
push:
tags:
- 'v[0-9]+.[0-9]+.*'
branches:
- main
pull_request:
branches:
- main
上面的 YAML 片段与持续集成工作流保持不变。它将在任何与语义版本匹配的标签(如 v1.2.3)上触发工作流。但是,工作流也会在拉取请求和推送时触发。我们希望持续集成工作流在拉取请求和推送时执行,但我们不希望每次都执行发布。我们需要限制发布作业的执行,只在执行 tag 推送时触发。
限制发布执行
推特发布工作流的第一个和第二个目标如下:
-
当仓库被标记为语义版本时触发自动化
-
在创建发布之前运行单元测试和验证
让我们确保发布作业仅在仓库被标记时执行:
jobs:
test:
# continuous integration job omitted for brevity
release:
needs: test
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
steps:
上述作业定义完成了第一个目标,即只有在推送以 v 开头的标签时才执行发布,通过指定 if 语句验证 github.ref 上下文变量是否以 refs/tags/v 开头。确保 test 作业在尝试执行 release 作业之前成功执行的第二个目标通过指定 needs: test 达成。如果没有在 release 作业上指定 needs: test,两个作业将并行执行,这可能会导致在没有通过验证的情况下创建发布。
工作区和环境设置
为了实现其余的自动化目标,我们需要设置工作区:
# Previous config of the release job omitted for brevity
steps:
- uses: actions/checkout@v2
- name: Set RELEASE_VERSION ENV var
run: echo "RELEASE_VERSION=${GITHUB_REF:10}" >> $GITHUB_ENV
- name: install go
uses: actions/setup-go@v2
with:
go-version: 1.17.x
上面的代码执行以下操作:
-
在与标签相关的 Git 引用处签出源代码
-
创建一个包含标签的
RELEASE_VERSION环境变量,例如v1.2.3 -
安装 Go 1.17 工具
构建跨平台二进制文件并进行版本注入
Tweeter 发布流程的第三和第四个目标如下:
-
将发布的语义版本注入到 Tweeter 应用中。
-
构建 Tweeter 应用的跨平台版本。
让我们从将发布的语义版本注入到编译后的二进制文件开始:
steps:
# Previous steps of the release job omitted for brevity
- name: install gox
run: go install github.com/mitchellh/gox@v1.0.1
- name: build cross-platform binaries
env:
PLATFORMS: darwin/amd64 darwin/arm64 windows/amd64 linux/amd64 linux/arm64
VERSION_INJECT: github.com/devopsforgo/github-actions/pkg/tweeter.Version
OUTPUT_PATH_FORMAT: ./bin/${{ env.RELEASE_VERSION }}/{{.OS}}/{{.Arch}}/tweeter
run: |
gox -osarch="${PLATFORMS}" -ldflags "-X
${VERSION_INJECT}=${RELEASE_VERSION}" -output
"${OUTPUT_PATH_FORMAT}"
上述步骤执行以下操作:
-
安装
gox命令行工具,以简化 Go 跨平台编译。 -
为每个指定的平台/架构构建跨平台的二进制文件,同时将
RELEASE_VERSION环境变量注入到 Go 的ldflag中。ldflag -X会将github.com/devopsforgo/github-actions/pkg/tweeter包中Version变量的默认值替换为构建的语义版本标签。gox的输出按OUTPUT_PATH_FORMAT结构化——例如,输出目录看起来如下:$ tree ./bin/ ./bin/ └── v1.0.0 ├── darwin │ ├── amd64 │ │ └── tweeter │ └── arm64 │ └── tweeter └── linux └── amd64 └── tweeter
使用 Golang 构建应用程序的一个最具吸引力的理由是相对容易构建跨平台的静态链接二进制文件。通过几个步骤,我们可以为 Linux、Windows、macOS 构建针对 AMD64 和 ARM64 以及许多其他平台和架构的 Tweeter 版本。这些小巧的静态链接二进制文件简单易分发,并且可以在各个平台和架构上执行。
通过前面的步骤,发布作业已将发布的语义版本编译成特定平台和架构的静态链接二进制文件。在下一步中,我们将使用语义版本来生成发布说明。
生成发布说明
我们有以下生成发布说明的目标:
-
从发布中的拉取请求生成发布说明
-
在发布中标记贡献者。
-
创建一个包含以下内容的 GitHub 发布:
- 包含生成的发布说明的描述
有一个好消息!只需稍作配置和标签管理,发布说明的生成就能由 GitHub 自动处理。我们将从往仓库中添加一个新文件 ./.github/release.yml 开始,内容如下:
changelog:
exclude:
labels:
- ignore-for-release
categories:
- title: Breaking Changes
labels:
- breaking-change
- title: New Features
labels:
- enhancement
- title: Bug Fixes
labels:
- bug-fix
- title: Other Changes
labels:
- "*"
上述发布配置将告诉 GitHub 基于应用的标签来筛选和分类拉取请求。例如,标有 ignore-for-release 标签的拉取请求将被排除在发布说明之外,但标有 enhancement 标签的拉取请求将会被归类到发布说明中的 新功能 下面:
steps:
# Previous steps of the release job omitted for brevity
- name: generate release notes
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
gh api -X POST 'repos/{owner}/{repo}/releases/generate-notes' \
-F commitish=${{ env.RELEASE_VERSION }} \
-F tag_name=${{ env.RELEASE_VERSION }} \
> tmp-release-notes.json
上一步生成了发布说明。该步骤执行一个 API 调用,向 GitHub API 请求生成给定标签的发布说明。命令将响应的 JSON 内容保存为 tmp-release-notes.json 文件。请注意,gh 需要 GitHub 令牌才能与 GitHub API 交互。GitHub 密钥被传递到 GITHUB_TOKEN 环境变量中,并由 gh 用于身份验证。
以下是 generate-notes API 调用返回的 JSON 示例:
{
"name": "name of the release",
"body": "markdown body containing the release notes"
}
我们将在下一步中使用 tmp-release-notes.json 来创建发布。
创建 GitHub 发布
创建发布自动化的最终目标如下:
-
包含发布语义版本的标题
-
包含生成的发布说明的描述
-
包含跨平台二进制文件的工件
让我们开始创建我们的发布自动化:
steps:
# Previous steps of the release job omitted for brevity
- name: gzip the bins
env:
DARWIN_BASE: ./bin/${{ env.RELEASE_VERSION }}/darwin
WIN_BASE: ./bin/${{ env.RELEASE_VERSION }}/windows
LINUX_BASE: ./bin/${{ env.RELEASE_VERSION }}/linux
run: |
tar -czvf "${DARWIN_BASE}/amd64/tweeter_darwin_amd64.tar.gz" -C "${DARWIN_BASE}/amd64" tweeter
tar -czvf "${DARWIN_BASE}/arm64/tweeter_darwin_arm64.tar.gz" -C "${DARWIN_BASE}/arm64" tweeter
tar -czvf "${WIN_BASE}/amd64/tweeter_windows_amd64.tar.gz" -C "${WIN_BASE}/amd64" tweeter.exe
tar -czvf "${LINUX_BASE}/amd64/tweeter_linux_amd64.tar.gz" -C "${LINUX_BASE}/amd64" tweeter
tar -czvf "${LINUX_BASE}/arm64/tweeter_linux_arm64.tar.gz" -C "${LINUX_BASE}/arm64" tweeter
- name: create release
env:
OUT_BASE: ./bin/${{ env.RELEASE_VERSION }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
jq -r .body tmp-release-notes.json > tmp-release-notes.md
gh release create ${{ env.RELEASE_VERSION }} \
-t "$(jq -r .name tmp-release-notes.json)" \
-F tmp-release-notes.md \
"${OUT_BASE}/darwin/amd64/tweeter_darwin_amd64.tar.gz#tweeter_osx_amd64" \
"${OUT_BASE}/darwin/arm64/tweeter_darwin_arm64.tar.gz#tweeter_osx_arm64" \
"${OUT_BASE}/windows/amd64/tweeter_windows_amd64.tar.gz#tweeter_windows_amd64" \
"${OUT_BASE}/linux/amd64/tweeter_linux_amd64.tar.gz#tweeter_linux_amd64" \
"${OUT_BASE}/linux/arm64/tweeter_linux_arm64.tar.gz#tweeter_linux_arm64"
前面的步骤执行了以下操作:
-
执行
tar和gzip命令对二进制文件进行压缩。使用 Go 1.17,推特二进制文件大约为 6.5 MB。经过gzip压缩后,每个工件小于 4 MB。 -
使用
gh命令行工具创建 GitHub 发布,该工具在所有 GitHub 作业执行器上都可用。gh需要 GitHub 令牌才能与 GitHub API 交互。GitHub 密钥被传递到GITHUB_TOKEN环境变量中,并由gh用于身份验证。gh release create会创建一个发布并上传所有在参数后指定的文件。每个上传的文件都会成为发布的一个工件。请注意每个工件文件路径后面的#。#后的文本是工件在 GitHub UI 中显示的名称。我们还使用捕获到的tmp-release-notes.json和jq来解析并选择 JSON 内容,以指定标题和发布说明。
此时,我们已经创建了一个面向多个平台和架构的发布版本,满足了我们对自动化的所有目标。让我们开始发布并查看结果。
创建推特发布
现在我们已经构建了一个发布作业来自动化推特发布,我们可以对仓库进行标签标记并发布应用程序的版本。为了启动发布自动化,我们将通过执行以下操作来创建并推送 v0.0.1 标签到仓库:
git tag v0.0.1
git push origin v0.0.1
在标签推送后,您应该能够进入 GitHub 仓库的 Actions 标签页,并看到标签工作流正在执行。如果您进入工作流页面,应该会看到如下内容:

图 10.5 – 显示依赖测试和发布作业的工作流作业视图
如前图所示,测试已经执行,随后发布作业也已经执行。如果您进入 release 作业页面,您应该会看到如下内容:

图 10.6 – 发布作业输出视图
如前图所示,发布任务已成功执行每个步骤,且发布已创建。如果你进入仓库的首页,你应该会看到一个新发布已经创建。如果你点击该发布,你应该会看到如下内容:

图 10.7 – 发布视图,包含资产、发布说明和语义版本标题
在前面的图中,你可以看到名为v0.0.1的发布已经自动生成,并附带了分类的发布说明,这些说明链接到拉取请求、贡献者以及每个平台/架构组合的工件。
通过前面的步骤,我们已经达成了发布自动化任务的目标。在测试执行后,我们触发了发布任务,以确保发布在发布之前始终通过我们的验证。我们使用gox为每个指定的平台/架构组合构建了静态链接的二进制文件。我们利用 GitHub 发布说明自动生成工具创建了格式美观的发布说明。最后,我们创建了一个发布,其中包含了构建过程中生成的说明和工件。
在这个例子中,我们学习了如何为 Go 项目构建发布自动化任务,但任何语言和工具集都可以类似地用于为任何语言创建发布自动化。
我们不再需要手动发布 tweeter 项目。所需的唯一操作是将标签推送到仓库。我们使用开源操作增强了创建这些自动化的能力。在下一部分,我们将学习如何创建自己的打包操作,以便其他人使用我们编写的操作。
使用 Go 创建自定义 GitHub 操作
在本节中,我们将在之前的工作基础上扩展,将 tweeter 命令行转换为 GitHub 操作。这将允许 GitHub 上的任何人使用 tweeter 在他们自己的流水线中发布推文。此外,我们将使用我们的 tweeter 操作在发布新版本时发推,方法是将发布任务扩展为使用我们的新操作。
在本节中,你将学习编写 GitHub 操作的基础知识。你将使用 Go 创建一个自定义 GitHub 操作。然后,你将通过创建一个容器镜像来优化自定义操作的启动时间。
自定义操作的基础
自定义操作是将一组相关任务封装起来的单独任务。自定义操作可以作为工作流中的独立任务执行,并且可以与 GitHub 社区共享。
操作类型
有三种类型的动作:容器动作、JavaScript 动作和复合动作。基于容器的动作使用 Dockerfile 或容器镜像引用作为入口点,即动作的执行起点,适用于你希望在 JavaScript 或现有动作以外的其他语言中编写动作的情况。基于容器的动作提供了定制执行环境的灵活性,但代价是启动时间。如果容器动作依赖于一个大型容器镜像或一个构建缓慢的 Dockerfile,那么动作的启动时间将受到不利影响。JavaScript 动作可以直接在运行器机器上执行,是动作的本地表现形式。JavaScript 动作启动迅速,并可以利用 GitHub Actions 工具包,这是一个 JavaScript 包集合,使创建动作更加简单。复合动作是一个包含多个步骤的封装动作。它们使得作者能够将一组不同的步骤组合成更高阶的行为。
动作元数据
要定义一个动作,你必须在 GitHub 仓库中创建一个 action.yaml 文件。如果该动作是要公开共享的,action.yaml 文件应当放在仓库的根目录中。如果该动作不打算公开共享,建议将 action.yaml 文件放在 ./.github/{name-of-action}/action.yaml 路径中,其中 {name-of-action} 应替换为该动作的名称。例如,如果 Tweeter 动作仅用于内部使用,则动作元数据的路径应为 ./.github/tweeter/action.yaml:
name: Name of the Action
author: @author
description: Description of your action
branding:
icon: message-circle
color: blue
inputs:
sample:
description: sample description
required: true
outputs:
sampleOutput:
description: some sample output
runs:
using: docker
image: Dockerfile
args:
- --sample
- "${{ inputs.sample }}"
上述 action.yaml 定义了以下内容:
-
将在 GitHub 用户界面中显示的动作名称
-
动作的作者
-
动作的描述
-
将在 GitHub 用户界面中用于该动作的品牌标识
-
输入该动作将接受
-
输出该动作将返回
-
runs部分,描述了动作如何执行
在这个示例中,我们使用了一个 Dockerfile,它将从 Dockerfile 构建一个容器,并使用指定的参数执行容器的入口点。注意如何使用 inputs.sample 上下文变量将输入映射为命令行参数。
上述动作可以通过以下步骤执行:
jobs:
sample-job:
runs-on: ubuntu-latest
steps:
- name: Sample action step
id: sample
uses: devopsforgo/sample-action@v1
with:
sample: 'Hello from the sample!'
# Use the output from the `sample` step
- name: Get the sample message
run: echo "The message is ${{
steps.sample.outputs.sampleOutput }}"
上述示例执行的操作如下:
-
使用示例动作执行一个步骤,假设该动作在
devopsforgo/sample-action仓库中已标记为v1,且该仓库的根目录下有action.yaml文件,并指定了所需的输入变量sample。 -
回显
sampleOutput变量。
接下来,我们将讨论如何标记动作发布版本。
动作发布管理
在我们所有的工作流示例中,Action 的 uses: 值始终包含 Action 的版本。例如,在上述示例中,我们使用 devopsforgo/sample-action@v1 来指定我们希望使用 v1 的 Git 标签版本。通过指定该版本,我们告诉工作流使用该标签指向的 Git 引用。按照约定,Action 的 v1 标签可以指向任何符合 v1.x.x 语义版本范围的 Git 引用。这意味着 v1 标签是一个浮动标签而非静态标签,并且随着新的 v1.x.x 版本的发布而推进。回想一下本章早些时候关于语义版本的描述,主版本号的递增表示存在破坏性变更。Action 的作者向用户承诺,任何标记为 v1 的版本都不会包含破坏性变更。
用于版本控制的约定可能会在 Action 与同一仓库中的另一个版本化软件项目一起使用时造成摩擦。建议考虑 Action 版本控制的影响,并考虑为 Action 创建一个专门的仓库,而不是将其创建在包含其他版本化项目的仓库中。
tweeter 自定义 GitHub Action 的目标
在我们的 tweeter 自定义 GitHub Action 中,我们将完成以下任务:
-
构建一个用于构建和运行 tweeter 命令行工具的 Dockerfile。
-
为自定义 Action 创建一个 Action 元数据文件。
-
扩展持续集成任务以测试 Action。
-
创建一个图像发布工作流,用于发布 tweeter 容器镜像。
-
通过使用发布的容器镜像来优化 tweeter 自定义 Action。
接下来,我们将使用 Dockerfile 创建一个自定义 Go Action。
创建 tweeter Action
在明确了 tweeter 自定义 Action 的目标后,我们准备创建运行 tweeter 所需的 Dockerfile,定义 Action 的元数据,以映射来自 tweeter 命令行工具的输入和输出,扩展我们的持续集成任务来测试 Action,最后,通过在自定义 Action 中使用预构建的容器镜像来优化 Action 的启动时间。我们将分解每个步骤并创建我们自定义的 Go Action。
定义一个 Dockerfile
tweeter 自定义 GitHub Action 的第一个目标是构建一个用于构建和运行 tweeter 命令行工具的 Dockerfile。
让我们从构建一个 Dockerfile 开始,该 Dockerfile 位于 tweeter 仓库的根目录,用于构建容器镜像:
FROM golang:1.17 as builder
WORKDIR /workspace
# Copy the Go Modules manifests
COPY go.mod go.mod
COPY go.sum go.sum
# Cache deps before building and copying source
# so that we don't need to re-download as much
# and so that source changes don't invalidate
# our downloaded layer
RUN go mod download
# Copy the sources
COPY ./ ./
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 \
go build -a -ldflags '-extldflags "-static"' \
-o tweeter .
# Copy the action into a thin image
FROM gcr.io/distroless/static:latest
WORKDIR /
COPY --from=builder /workspace/tweeter .
ENTRYPOINT ["/tweeter"]
上述 Dockerfile 的功能如下:
-
使用
golang:1.17镜像作为中间构建容器,包含编译 tweeter 命令行工具所需的 Go 构建工具。使用构建者模式创建一个中间容器,包含构建工具和源代码,这些在最终产品中不会被使用。它为我们提供了一个构建静态链接的 Go 应用程序的临时区域,构建完成后可以将其添加到精简版的容器中。这样,最终的容器只会包含 Go 应用程序,而没有其他内容。 -
构建过程然后复制
go.mod和go.sum,然后下载 tweeter 应用程序所需的 Go 依赖。 -
tweeter 应用程序的源代码被复制到构建容器中,并编译为静态链接的二进制文件。
-
生产镜像是从
gcr.io/distroless/static:latest基础镜像创建的,tweeter 应用程序则从中间构建容器中复制过来。 -
最后,默认入口点被设置为 tweeter 二进制文件,这将使我们能够运行容器并直接执行 tweeter 应用程序。
要构建并执行上述 Dockerfile,您可以运行以下命令:
$ docker build . -t tweeter
# output from the docker build
$ docker run tweeter -h
pflag: help requested
Usage of /tweeter:
--accessToken string twitter access token
# More help text removed for brevity.
上述脚本执行以下操作:
-
构建 Dockerfile 并标记为
tweeter名称 -
运行标记的 tweeter 容器镜像,向 tweeter 应用程序传递
-h参数,导致 tweeter 应用程序打印帮助文本
现在我们有了一个有效的 Dockerfile,可以使用它来定义 action.yaml 中定义的自定义容器操作。
创建操作元数据
tweeter 自定义 GitHub Action 的第二个目标是为自定义操作创建一个操作元数据文件。
现在我们已经定义了 Dockerfile,可以在仓库根目录中的 action.yaml 文件中编写自定义操作的操作元数据:
name: Tweeter Action
author: DevOps for Go
description: Action to send a tweet via a GitHub Action.
inputs:
message:
description: 'message you want to tweet'
required: true
apiKey:
description: 'api key for Twitter api'
required: true
apiKeySecret:
description: 'api key secret for Twitter api'
required: true
accessToken:
description: 'access token for Twitter api'
required: true
accessTokenSecret:
description: 'access token secret for Twitter api'
required: true
outputs:
errorMessage:
description: 'if something went wrong, the error message'
sentMessage:
description: 'the message sent to Twitter'
runs:
using: docker
image: Dockerfile
args:
- --message
- "${{ inputs.message }}"
- --apiKey
- ${{ inputs.apiKey }}
- --apiKeySecret
- ${{ inputs.apiKeySecret }}
- --accessToken
- ${{ inputs.accessToken }}
- --accessTokenSecret
- ${{ inputs.accessTokenSecret }}
上述操作元数据执行以下操作:
-
定义操作名称、作者和描述元数据
-
定义操作的预期输入
-
为操作定义输出变量
-
执行 Dockerfile,将操作的输入映射到 tweeter 应用程序的
args中
输入变量如何映射到 tweeter 的 args 命令行是显而易见的,因为输入被映射到参数中,但输出变量如何映射则不太清楚。输出变量通过在 Go 应用程序中将变量特别编码到 STDOUT 来映射:
func printOutput(key, message string) {
fmt.Printf("::set-output name=%s::%s\n", key, message)
}
上述函数将输出变量的键和值打印到 STDOUT。为了返回 sentMessage 输出变量,Go 应用程序调用 printOutput("sendMessage", message)。操作运行时将读取 STDOUT,识别编码,并将其填充到 steps.{action.id}.outputs.sentMessage 的上下文变量中。
在定义了操作元数据后,我们现在准备通过扩展 tweeter 持续集成工作流,来测试在本地仓库中执行该操作。
测试操作
推特自定义 GitHub Action 的第三个目标是将持续集成任务扩展为测试该动作。
编写好action.yaml文件后,我们可以添加一个工作流任务来测试该动作:
test-action:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: test the tweeter action in DRY_RUN
id: tweeterAction
env:
DRY_RUN: true
uses: ./
with:
message: hello world!
accessToken: fake
accessTokenSecret: fake
apiKey: fake
apiKeySecret: fake
- run: echo ${{ steps.tweeterAction.outputs.sentMessage
}} from dry run test
上面的test-action任务执行了以下操作:
-
将代码签出到本地工作区
-
执行本地动作,指定所有必需的输入,并将
DRY_RUN环境变量设置为true,这样该动作就不会尝试发送消息到 Twitter -
运行
echo命令,获取从动作中回显的输出
让我们看看触发此工作流时会发生什么:

图 10.8 – 具有新 test-action 任务的工作流运行情况
在上面的截图中,你可以看到test-action任务现在是推特自动化的一部分,它将验证这个动作。注意执行任务的运行时为 54 秒。调用命令行应用程序似乎花费了很长时间:

图 10.9 – test-action 任务输出
在上面的截图中,你可以看到推特操作的测试占用了 49 秒,任务的总运行时间为 54 秒。几乎所有的时间都花在了编译推特和构建docker镜像上,然后才执行这个动作。在接下来的部分中,我们将通过引用预构建版本的推特容器镜像来优化动作执行时间。
创建容器镜像发布工作流
推特自定义 GitHub Action 的第四个目标是创建一个图像发布工作流,用于发布推特容器镜像。
正如我们在上一节中看到的,构建 Dockerfile 所需的时间相当长。没有理由在每次执行操作时都这么做,这可以通过将容器镜像发布到容器注册表中,然后在 Dockerfile 位置使用该注册表镜像来避免:
name: release image
on:
# push events for tags matching image-v for version
(image-v1.0, etc)
push:
tags:
- 'image-v*'
permissions:
contents: read
packages: write
jobs:
image:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: set env
# refs/tags/image-v1.0.0 substring starting at 1.0.0
run: echo "RELEASE_VERSION=${GITHUB_REF:17}" >> $GITHUB_ENV
- name: setup buildx
uses: docker/setup-buildx-action@v1
- name: login to GitHub container registry
uses: docker/login-action@v1
with:
registry: ghcr.io
username: ${{ github.repository_owner }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: build and push
uses: docker/build-push-action@v2
with:
push: true
tags: |
ghcr.io/devopsforgo/tweeter:${{ env.RELEASE_VERSION }}
ghcr.io/devopsforgo/tweeter:latest
上面的工作流定义执行了以下操作:
-
仅在推送以
image-v开头的标签时触发 -
请求对
ghcr.io镜像库的写权限以及对 Git 仓库的读取权限 -
包含单个容器镜像构建和发布镜像的步骤。
-
签出代码库
-
根据标签格式构建
RELEASE_VERSION环境变量 -
设置
buildx以构建容器镜像 -
登录到
ghcr.io,GitHub 容器注册表 -
构建并推送标记为发布版本和最新版本的容器镜像
有了上述工作流后,我们可以使用以下命令标记代码库,并将容器镜像发布到 GitHub 容器注册表,以便在推特动作中使用:
git tag image-v1.0.0
git push origin image-v1.0.0
让我们看看我们的图像发布工作流的结果:

图 10.10 – 图像发布任务的工作流视图
上面的截图展示了通过推送 image-v1.0.0 标签触发的 release image 工作流。以下截图详细说明了每个步骤的结果:

图 10.11 – 图像发布作业输出
上述工作流的结果是,我们现在将容器镜像推送到了 ghcr.io/devopsforgo/tweeter,并标记为 v1.0.0 和 latest。现在,我们可以更新 action 的元数据,使用标记的镜像版本。
优化自定义 Go action
本节的最终目标是通过使用已发布的容器镜像来优化 tweeter 自定义 action。
现在我们已经将镜像发布到 ghcr.io,我们可以用已发布的镜像引用替换 Dockerfile:
# omitted the previous portion of the action.yaml
runs:
using: docker
image: docker://ghcr.io/devopsforgo/tweeter:1.0.0
# omitted the subsequent portion of the action.yaml
上面 action.yaml 文件的部分展示了如何将 Dockerfile 替换为已发布的 tweeter 容器镜像。既然 Dockerfile 已被替换,让我们运行工作流,看看性能优化的实际效果:

图 10.12 – 显示测试动作作业速度提升的工作流视图
上面的截图展示了使用预构建容器镜像带来的好处。回想一下,当使用 Dockerfile 时,工作流执行时间为 54 秒。而现在,使用来自注册表的 tweeter 容器镜像,工作流在 11 秒内执行完毕。这是一个显著的优化,应该在可能的情况下使用。
在这一部分,我们学习了如何使用 Go 构建自定义 actions,这使得 DevOps 工程师能够构建复杂的 actions,并将它们打包成易于访问的自动化单元。我们还学习了如何在本地测试和优化这些 actions,确保当自定义 actions 发布时,它们能够按预期工作。
在下一部分,我们将基于编写自定义 actions 的能力,发布一个 action 给整个 GitHub 社区。通过将 action 发布到 GitHub Marketplace,action 可以成为其他 DevOps 工程师编写自动化工具的关键工具。
发布自定义 Go GitHub Action
GitHub Actions 的超级力量在于社区以及社区发布到 GitHub Marketplace 的 actions。试想,如果没有社区 actions 可用,我们在前面的章节中需要做多少额外的工作。我们的工作流将不得不从基础开始,编写冗长且繁琐的脚本来完成那些我们现在能够用少量 YAML 表达的任务。
开源软件不仅仅是拥有免费的软件,还包括回馈社区。我们将学习如何通过将 action 发布到 GitHub Marketplace 来回馈 GitHub Actions 社区。这将使整个 GitHub 用户社区都能受益。
在本节中,您将学习如何将自定义动作发布到 GitHub 市场。您将了解发布动作的基本知识。掌握基础知识后,您将学习如何自动化发布动作的版本管理。您将学习如何使用 Twitter 动作发布新版本的公告到 Twitter。最后,您将学习如何将您的动作发布到 GitHub 市场,以便全球其他 GitHub 社区的成员可以使用。
发布动作的基础知识
将动作发布到 GitHub 市场需要满足一些要求和最佳实践,这对于我们在上一节中构建的本地动作是不适用的。例如,仓库的 README 将是动作在市场中的落地页,因此您需要提供仓库 README 的描述和使用指导。
以下是将动作发布到 GitHub 市场的要求:
-
该动作必须位于公共 GitHub 仓库中。
-
仓库的根目录中必须有一个名为
action.yaml或action.yml的单个动作文件。 -
action.yaml中的动作名称必须是 GitHub 市场中唯一的。该名称不得与任何 GitHub 特性、产品或 GitHub 保留的其他名称重叠。 -
公共动作应遵循
v1和v1.2.3的语义版本规范,以便用户可以指定完整的语义版本,或者仅使用v1来表示v1这一大版本系列中的最新版本。
发布 Twitter 自定义操作的目标
以下是发布 Twitter 自定义操作的目标:
-
设置一个发布触发的工作流来处理语义版本管理。
-
将 Twitter 动作发布到 GitHub 市场。
管理动作的语义版本
发布 Twitter 自定义操作到市场的前两个目标如下:
-
设置一个发布触发的工作流来处理语义版本管理。
-
使用此操作发布新版本的动作到 Twitter。
我们将构建一个工作流来更新大版本标签——例如,v1——指向 v1.x.x 语义版本系列中的最新发布版本。该工作流还将负责在发布新大版本时创建新的大版本标签:
name: Release new tweeter version
on:
release:
types: [released]
workflow_dispatch:
inputs:
TAG_NAME:
description: 'Tag name that the major tag will point to'
required: true
permissions:
contents: write
env:
TAG_NAME: ${{ github.event.inputs.TAG_NAME || github.event.release.tag_name }}
jobs:
update_tag:
name: Update the major tag to include the ${{ env.TAG_NAME }} changes
runs-on: ubuntu-latest
steps:
- name: Update the ${{ env.TAG_NAME }} tag
uses: actions/publish-action@v0.1.0
with:
source-tag: ${{ env.TAG_NAME }}
- uses: actions/checkout@v2
- name: Tweet about the release
uses: ./
with:
message: Hey folks, we just released the ${{ env.TAG_NAME }} for the tweeter GitHub Action!!
accessToken: ${{ secrets.ACCESS_TOKEN }}
accessTokenSecret: ${{ secrets.ACCESS_TOKEN_SECRET }}
apiKey: ${{ secrets.API_KEY }}
apiKeySecret: ${{ secrets.API_KEY_SECRET }}
上述工作流执行以下操作:
-
在发布版本或手动 UI 提交时触发。这意味着项目维护者可以通过 GitHub UI 来触发工作流,如果需要临时执行的话。
-
声明工作流需要有对仓库的写权限。此权限用于写入标签。
-
声明
TAG_NAME环境变量,该变量可以是临时作业输入或发布的标签。 -
update_tag采用v1.2.3格式的标签,并将标签的主语义版本更新为该主版本中的最新版本。例如,如果新发布的标签是v1.2.3,那么v1标签将指向与v1.2.3相同的 Git 引用。 -
使用
actions/checkout@v2克隆源代码。 -
使用嵌入在 GitHub 仓库秘钥中的 Twitter 开发者凭证发布关于新发布的推文。要设置 Twitter 开发者凭证,请参见
developer.twitter.com/en/portal/dashboard并设置账户和应用程序。收集凭证后,您可以将它们添加到设置选项卡下的仓库秘钥中,如下截图所示:

图 10.13 – 仓库秘钥
使用上述工作流,在我们应用标签(例如v1.2.3)时,仓库也将以相同的 Git ref标记为v1。标签设置完成后,tweeter 动作将执行,向全球宣布发布。
从前一节回顾,当我们使用语义版本为 tweeter 仓库打上标签时,将触发发布工作流程,从而创建新的发布。然后,此工作流将触发动作版本更新发布工作流,该工作流将以主版本标签动作,并通过 Twitter 宣布动作发布可用。
唯一剩下的事情是将动作发布到 GitHub Marketplace。这只需要在首次发布动作时完成。
将 tweeter 动作发布到 GitHub Marketplace
发布 tweeter 自定义动作的最终目标是将 tweeter 动作发布到 GitHub Marketplace。您的 GitHub 动作的首次发布是一个手动过程,可以通过以下指南完成:docs.github.com/en/actions/creating-actions/publishing-actions-in-github-marketplace。完成这些首次手动步骤后,未来发布时无需重复。
摘要
GitHub Actions 是项目维护者自动化繁琐流程的强大系统,提升开发者满意度和项目速度。在本章中,我们选择了 Go 作为 GitHub Actions 的首选语言,因为它具有类型安全性、低内存开销和高速度。我们认为这是编写 GitHub Actions 的最佳选择。然而,这里教授的许多技能也可迁移到其他语言。每个模式,持续集成,发布管道,语义版本控制和动作创建都可以应用于您接触的任何项目中。
本章的关键是理解 GitHub Marketplace 中社区贡献的影响。通过使用、构建和贡献于 Marketplace,工程师可以使他们的自动化更加可组合,并通过社区的贡献,赋能社区成员解决更复杂的问题。
我们学习了 GitHub Actions 的基础知识,重点介绍了它的功能,使我们能够快速投入使用。凭借这些基本技能,我们成功构建了一个持续集成的自动化工作流,用于克隆、构建、静态检查和测试 tweeter 项目。我们进一步扩展了持续集成自动化,创建了一个从 Git 标签触发的发布管道。发布管道将手动任务,如编写发布说明,转变为自动化的一部分。最后,我们创建并发布了一个自定义的 Go GitHub Action,可以供整个社区使用。
我希望在本章结束时,你能自信地掌握创建自动化的能力,从而消除那些困扰你团队日常工作的繁琐任务。记住,如果你能自动化一个每周发生一次且需要一个小时的任务,你就相当于为你的团队成员节省了一整周的工作时间!这些时间很可能能更好地用来为你的业务增值。
在下一章,我们将学习 ChatOps。你将学习如何使用聊天应用程序,如 Slack,当事件发生时触发自动化和警报,为你和你的团队提供一个互动的机器人 DevOps 合作伙伴。
第十一章:使用 ChatOps 提高效率
作为 DevOps 工程师,我们通常是一个由工程师组成的团队的一部分,帮助管理网络、服务基础设施以及面向公众的服务。这意味着需要协调大量活动和沟通,特别是在紧急情况下。
ChatOps 为团队提供了一个集中式的工具界面,可以询问当前状态并与其他 DevOps 工具互动,同时记录这些互动以备后续查看。这可以改善反馈循环和团队间的实时沟通,帮助有效管理事故。
我们的同事 Sarah Murphy 有一句话——不要和公交司机说话。作为 Facebook 早期的发布工程师,她负责在其数据中心发布 Facebook。这是一项高压且注重细节的工作,需要她全神贯注。许多工程师想知道他们的功能或补丁是否包含在当前发布中,当然,他们都会问发布工程师。
正如任何做过高影响力发布的工程师所说,你需要专注。成百上千的工程师关于他们特定补丁的状态向你询问并不是理想的情况。这时,ChatOps 就派上了用场。通过实现 ChatOps,可以提供一个集中式平台,在这个平台上,关于发布状态以及当前版本的更新情况可以减少那些成百上千的问题。对 Sarah 来说,这确实起到了作用。
在本章中,我们将深入讨论如何为 Slack 构建 ChatOps 机器人。我们将展示如何使用该机器人查询服务状态。我们还将展示如何使用机器人获取部署信息。最后,我们将展示如何使用机器人来部署我们的软件。
本章将涵盖以下内容:
-
环境架构
-
使用 Ops 服务
-
构建一个基本的聊天机器人
-
创建事件处理程序
-
创建我们的 Slack 应用
技术要求
本章的前提条件如下:
-
一个 Slack 用户账户:如果你没有 Slack 账户,可以按照这里的说明创建:
slack.com/get-started#/createnew。 -
一个 Slack 工作区用于实验:创建 Slack 工作区的说明请参考:
slack.com/help/articles/206845317-Create-a-Slack-workspace。 -
创建一个 Slack 应用。
强烈建议你使用自己控制的工作区,而不是公司工作区。公司工作区的设置需要管理员批准。
你还需要创建一个 Slack 应用,但这将在后面的章节中介绍。
本章的代码文件可以从github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/11下载
环境架构
我们的示例 ChatOps 程序需要与多个服务进行交互,以便向用户提供信息。
为了实现这一点,我们构建了一个更强大的版本的 Petstore 应用程序,这是我们在之前章节中构建的版本。这个版本具有以下功能:
-
实现 创建、读取、更新和删除 (CRUD)。
-
基于 gRPC。
-
具有更深入的 Open Telemetry 跟踪,这些跟踪通过 RPC 调用流动并记录事件。
-
可以用于告警的更深层度指标,供 Prometheus 使用。
-
使用跟踪事件替代日志记录。
-
所有错误都会自动添加到跟踪中。
-
客户端可以启用跟踪。
-
跟踪默认会被采样,但可以通过 RPC 更改。
你可以在这里找到这个新的 Petstore:github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/11/petstore。如果你想深入了解架构,可以查看 README 文件,但本章并不需要你深入了解。
我们的新 Petstore 功能更强大,将通过结合本章的其他课程,展示 ChatOps 能提供的一些强大功能。
以下是我们的服务架构示意图:

图 11.1 – ChatOps 和 Petstore 架构
归属
由 gstudioimagen 创建的贵宾犬矢量图 - www.freepik.com
Gophers by Egon Elbe:github.com/egonelbre/gophers
我们将专注于创建的两个服务是:
- Ops 服务:Ops 服务完成实际工作,如与 Jaeger、Prometheus 交互,运行作业,或执行其他必要任务。这使得我们能够并行运行多个 ChatOps 服务(例如,如果你的公司从 Slack 迁移到 Microsoft Teams,可能就需要这样)。
这种架构的好处是,允许其他团队使用任何他们选择的编程语言编写工具,利用这些功能。
slack-go包,可以在github.com/slack-go/slack找到。
让我们深入了解 Ops 服务的基本细节。
使用 Ops 服务
我们不会对该服务进行详细讲解,因为我们在前几章已经涵盖了 gRPC 的工作原理。由于该服务只是向其他服务发起 gRPC 或 REST 调用,因此让我们讨论一下需要实现的调用。
协议缓冲服务定义如下:
service Ops {
rpc ListTraces(ListTracesReq) returns (ListTracesResp) {};
rpc ShowTrace(ShowTraceReq) returns (ShowTraceResp) {};
rpc ChangeSampling(ChangeSamplingReq) returns (ChangeSamplingResp) {};
rpc DeployedVersion(DeployedVersionReq) returns (DeployedVersionResp) {};
rpc Alerts(AlertsReq) returns (AlertsResp) {};
}
对于我们的示例服务,这些 RPC 目标是单一部署实例,但在生产环境中,这将作用于站点上存在的多个实体。
这使得用户能够快速获取一些信息,例如:
-
查看我们在特定时间段内的跟踪,并可以通过标签(如
error)进行过滤。 -
根据跟踪 ID 检索基本跟踪数据和 Jaeger 跟踪的 URL。
-
更改服务中跟踪的采样类型和速率。
-
根据 Prometheus,告诉我们部署了哪个版本。
-
显示 Prometheus 显示的任何触发的警报。
您可以在这里查看如何实现这段代码:github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/11/ops。
我们包括了一个 README 文件,介绍了基本架构,但它是您的标准 gRPC 服务,通过 gRPC 调用 Petstore 服务/Jaeger,并通过 REST 调用 Prometheus。
现在,让我们开始编写一个新的基础 Slack 机器人。
构建一个基础的聊天机器人
Go 有一些客户端,可以与流行的聊天服务(如 Slack)进行交互,既可以作为通用 Slack 客户端,也可以作为专注于 ChatOps 的机器人。
我们发现,最好采用一种将机器人与您想要执行的操作分开的架构。这使得其他语言的工具也能够访问这些功能。
通过将聊天机器人与其他部分分离,您可以专注于单一类型的聊天服务,并使用它的所有功能,而不是仅使用每个聊天服务客户端共享的功能。
因此,我们将使用 slack-go 包与 Slack 进行交互。
我们的机器人将非常基础,只需监听是否有人在消息中提到我们的机器人。这被称为 AppMention 事件。Slack 支持其他事件,并且有专门针对命令的事件,您可以安装它们。在我们的例子中,我们只希望在有人提到我们的机器人时作出回应,但 slack-go 还有许多其他功能我们不会在此探讨。
让我们创建一个名为 bot 的包并添加一些导入:
package bot
import (
"log"
"context"
"regexp"
"encoding/json"
"github.com/slack-go/slack"
"github.com/slack-go/slack/slackevents"
"github.com/slack-go/slack/socketmode"
)
我们的第三方包的详细信息如下:
-
slack是用来构建基础客户端的。 -
slackevents详细说明了我们可以接收到的各种事件。 -
socketmode提供了一种从防火墙后的机器人连接到 Slack 的方法。
让我们创建一个类型来处理我们接收到的事件:
type HandleFunc func(ctx context.Context, m Message)
type register struct{
r *regexp.Regexp
h HandleFunc
}
HandleFunc 接收一条消息,可以用于向频道发送消息并获取关于接收到的消息的信息。
我们还定义了一个注册类型,用于将 HandleFunc 注册到 HandleFunc。
让我们定义 Message 类型:
type Message struct {
User *slack.User
AppMention *slackevents.AppMentionEvent
Text string
}
这包含了发送消息的 Slack 用户的信息、AppMention 事件的信息以及用户发送的清理后的文本(去除 @User 文本以及前后空格)。
现在,让我们定义我们的 Bot 类型及其构造函数:
type Bot struct {
api *slack.Client
client *socketmode.Client
ctx context.Context
cancel context.CancelFunc
defaultHandler HandleFunc
reg []register
}
func New(api *slack.Client, client *socketmode.Client) (*Bot, error) {
b := &Bot{
api: api,
client: client,
ctx: ctx,
cancel: cancel,
}
return b, nil
}
这段代码包含了我们将用于与 Slack 交互的客户端、用于取消我们机器人 goroutine 的上下文、defaultHandler 用于处理没有匹配正则表达式的情况,以及我们在接收任何消息时检查的注册列表。
现在我们需要一些方法来启动和停止我们的机器人:
func (b *Bot) Start() {
b.ctx, b.cancel = context.WithCancel(context.Background())
go b.loop()
b.client.RunContext(b.ctx)
}
func (b *Bot) Stop() {
b.cancel()
b.ctx = nil
b.cancel = nil
}
这只是启动我们的事件循环,并调用 RunContext 来监听我们的事件流。我们使用提供的 context.Bot 来取消我们的机器人。Start() 会阻塞,直到调用 Stop()。
我们的下一个方法将允许我们注册我们的正则表达式及其处理程序:
func (b *Bot) Register(r *regexp.Regexp, h HandleFunc) {
if h == nil {
panic("HandleFunc cannot be nil")
}
if r == nil {
if b.defaultHandle != nil {
panic("cannot add two default handles")
}
b.defaultHandle = h
return
}
b.reg = append(b.reg, register{r, h})
}
在这段代码中,如果我们没有提供正则表达式,则HandleFunc作为默认处理程序,在没有匹配正则表达式时使用。你只能拥有一个默认处理程序。当机器人检查消息时,它会按添加顺序匹配正则表达式,第一个匹配的胜出。
现在,让我们来看看我们的事件循环:
func (b *Bot) loop() {
for {
select {
case <-b.ctx.Done():
return
case evt := <-b.client.Events:
switch evt.Type {
case socketmode.EventTypeConnectionError:
log.Println("connection failed. Retrying later...")
case socketmode.EventTypeEventsAPI:
data, ok := evt.Data.(slackevents.EventsAPIEvent)
if !ok {
log.Println("bug: got type(%v) which should be a slackevents.EventsAPIEvent, was %T", evt.Data)
continue
}
b.client.Ack(*evt.Request)
go b.appMentioned(data)
}
}
}
}
在这里,我们从socketmode客户端中提取事件。我们根据事件类型进行切换。对于我们的目的,我们只关心两种类型的事件:
-
连接 WebSocket 时出错
-
EventTypeEventsAPI事件
EventTypeEventsAPI类型是一个接口,我们将其转换为具体类型slackevents.EventsAPIEvent。我们确认接收到事件,并将事件发送到由appMentioned()方法处理。
还有其他你可能感兴趣的事件。你可以在这里找到 Slack 支持的官方事件列表:api.slack.com/events。
Go 包事件支持可能会略有不同,可以在这里找到:pkg.go.dev/github.com/slack-go/slack/slackevents#pkg-constants。
现在,让我们构建appMentioned():
func (b *Bot) appMentioned(ctx context.Context, data slackevents.EventsAPIEvent) {
switch data.Type {
case slackevents.CallbackEvent:
callback := data.Data.(*slackevents.EventsAPICallbackEvent)
switch ev := data.InnerEvent.Data.(type) {
case *slackevents.AppMentionEvent:
msg, err := b.makeMsg(ev)
if err != nil {
log.Println(err)
return
}
for _, reg := range b.reg {
if reg.r.MatchString(m.Text){
reg.h(ctx, b.api, b.client, m)
return
}
}
if b.defaultHandler != nil {
b.defaultHandler(ctx, m)
}
}
default:
b.client.Debugf("unsupported Events API event received")
}
Slack 事件是嵌套在事件中的事件,因此需要进行一些解码才能获取到你需要的信息。这个代码查看事件数据类型,并利用这些信息来确定解码的类型。
对于appMentioned(),它应该始终是slackevents.CallbackEvent,该类型将其.Data字段解码为*slackevents.EventsAPICallbackEvent类型。
它有.InnerEvent,可以解码成其他几种事件类型。我们只关心它是否解码为*slackevents.AppMentionEvent。
如果是这样,我们调用另一个内部方法makeMsg(),该方法返回我们之前定义的消息类型。我们将跳过makeMsg()的实现,因为它涉及一些复杂的 JSON 数据转换,JSON 的特性使得它有点繁琐且无趣。你可以直接从链接的代码中提取它。
然后,我们通过正则表达式循环查找匹配项。如果找到匹配项,我们在该消息上调用HandleFunc并停止处理。如果没有找到匹配项,则调用defaultHandler,如果存在的话。
现在,我们有了一个可以监听何时在消息中提到它的机器人,并将消息分发到处理程序。让我们将其与调用 Ops 服务结合起来。
创建事件处理程序
我们在上一部分定义的HandleFunc类型处理了我们功能的核心。这也是我们决定如何将一堆文本转换为要运行的命令的地方。
有几种方法可以解释原始文本:
-
通过
regexp包使用正则表达式 -
通过
strings包进行字符串操作 -
设计或使用词法分析器和解析器
正则表达式和字符串操作是这种类型的应用程序中最快的方式,因为我们处理的是单行文本。
当你需要处理复杂的输入或多行文本,并且不能容忍错误时,词法分析器和语法分析器非常有用。这是编译器用来将你的文本代码读入指令并最终生成编译二进制文件的方法。Rob Pike 有一个很棒的关于在 Go 中编写词法分析器和语法分析器的讲座,你可以在这里观看:www.youtube.com/watch?v=HxaD_trXwRE。缺点是它们很繁琐且难以训练新人员。如果你需要看几遍这个视频才能理解概念,你并不孤单。
案例研究——正则表达式与词法分析器和语法分析器
网络自动化的最大挑战之一是从不同厂商制造的不同设备中获取信息。有些厂商通过简单网络管理协议(SNMP)提供信息,但对于许多类型的信息或调试,你必须通过 CLI 来获取数据。
在较新的平台上,这可能以 JSON 或 XML 的形式出现。许多平台没有结构化的输出,有时 XML 格式错误到无法使用结构化数据时,反而更容易使用非结构化数据。
在 Google,我们从使用正则表达式(regexes)的写作工具开始。正则表达式被埋在每一个单独的工具中,导致了对相同数据进行多次数据处理的实现。这是巨大的工作浪费,并且给不同的工具引入了不同的 bug。
路由器的输出可能很复杂,因此最终开发了一个专门的正则表达式引擎来处理这些复杂的多行正则表达式,并创建了一个中央存储库,在那里可以找到命令输出的正则表达式。
不幸的是,我们当时在尝试使用一个不适合此任务的工具。那个包非常复杂,开发时需要自己的调试器。更重要的是,它会在没有任何提示的情况下失败,当厂商在新的操作系统版本中稍微改变输出时,它会在字段中输入零值。这在生产中导致了一些不小的问题。
我们最终转向了一个词法分析器和语法分析器,它可以始终检测到输出是否与预期不符。我们不希望它像一个完整的词法分析器和语法分析器那样复杂,所以我们编写了一个包,允许非常有限的正则表达式使用,并验证许多数据字段。
当你必须使用这个包来解释新的数据时,大家对它有一定的爱恨情仇。最棒的地方是它不会在变更时静默失败,执行速度飞快,更新需要的工作量很小,并且内存占用极少。
但要真正理解这些概念需要一些时间,并且编写匹配项需要更长的时间。我在离开 Google 后重新制作了一个公开版本,名为 Half-Pike,你可以在这里找到:github.com/johnsiilver/halfpike。
对于我们的第一个处理器,我们想要返回一个追踪列表给用户。主要命令是 list traces,后面跟可选参数。对于选项,我们需要以下内容:
-
operation=<operation name> -
start=<mm/dd/yyyy-hh:mm> -
end=<mm/dd/yyyy-hh:mm, now> -
limit=<number of items> -
tags=<[tag1,tag2]>
这些选项允许我们限制查看的追踪范围。也许我们只想查看某个特定时期的追踪,并且只想看到我们标记为error的追踪。这让我们能够进行筛选的诊断。
使用这个命令的一个简单示例如下:
list traces operation=AddPets() limit=25
我们的所有处理程序将通过 gRPC 与 Ops 服务进行通信。我们将创建一个类型,能够保存我们定义的所有HandleFunc类型及它们需要的客户端来访问我们的 Ops 服务和 Slack:
type Ops struct {
OpsClient *client.Ops
API *slack.Client
SMClient *socketmode.Client
}
func (o Ops) write(m bot.Message, s string, i ...interface{}) error {
_, _, err := o.API.PostMessage(
m.AppMention.Channel,
slack.MsgOptionText(fmt.Sprintf(s, i...), false),
)
return err
}
这定义了我们的基本类型,它将保存单个客户端与我们的 Ops 服务。我们将附加实现HandleFunc类型的方法。它还定义了一个write()方法,用于将文本写回到 Slack 用户端。
现在,我们需要定义一个包级变量,用于正则表达式,它帮助我们解析选项。我们在包级别定义它,这样我们只需编译一次:
var listTracesRE = regexp.MustCompile(`(\S+)=(?:(\S+))`)
type opt struct {
key string
val string
}
你可以看到我们的正则表达式如何匹配一个以=分隔的键值对。opt类型用于在我们用正则表达式解析后保存我们的选项键和值。
现在是处理程序,它列出我们通过过滤器指定的追踪:
func (o Ops) ListTraces(ctx context.Context, m bot.Message) {
sp := strings.Split(m.Text, "list traces")
if len(sp) != 2 {
o.write(m, "The 'list traces' command is malformed")
return
}
t := strings.TrimSpace(sp[1])
kvOpts := []opt{}
matches := listTracesRE.FindAllStringSubmatch(t, -1)
for _, match := range matches {
kvOpts = append(
kvOpts,
opt{
strings.TrimSpace(match[1]),
strings.TrimSpace(match[2]),
},
)
}
ListTraces实现了我们之前创建的HandleFunc类型。我们从用户发送的Message.Text中分割出列表追踪文本,并使用strings.TrimSpace()去除前后多余的空格。然后,我们使用正则表达式创建所有的选项。
现在,我们需要处理这些选项,以便将它们发送到 Ops 服务器:
options := []client.CallOption{}
for _, opt := range kvOpts {
switch opt.key {
case "operation":
options = append(
options,
client.WithOperation(opt.val),
)
case "start":
t, err := time.Parse(
`01/02/2006-15:04:05`, opt.val,
)
if err != nil {
o.write(m, "The start option must be in the form `01/02/2006-15:04:05` for UTC")
return
}
options = append(options, client.WithStart(t))
case "end":
if opt.val == "now" {
continue
}
t, err := time.Parse(
`01/02/2006-15:04:05`, opt.val,
)
if err != nil {
o.write(m, "The end option must be in the form `01/02/2006-15:04:05` for UTC")
return
}
options = append(options, client.WithEnd(t))
case "limit":
i, err := strconv.Atoi(opt.val)
if err != nil {
o.write(m, "The limit option must be an integer")
return
}
if i > 100 {
o.write(m, "Cannot request more than 100 traces")
return
}
options = append(options, client.WithLimit(int32(i)))
case "tags":
tags, err := convertList(opt.val)
if err != nil {
o.write(m, "tags: must enclosed in [], like tags=[tag,tag2]")
return
}
options = append(options, client.WithLabels(tags))
default:
o.write(m, "don't understand an option type(%s)", opt.key)
return
}
}
这段代码循环遍历我们从命令中解析出的选项,并附加调用选项以发送给 Ops 服务。如果有任何错误,我们会写入 Slack,通知他们出现了问题。
最后,让我们调用 gRPC 来请求 Ops 服务:
traces, err := o.OpsClient.ListTraces(ctx, options...)
if err != nil {
o.write(m, "Ops server had an error: %s", err)
return
}
b := strings.Builder{}
b.WriteString("Here are the traces you requested:\n")
table := tablewriter.NewWriter(&b)
table.SetHeader([]string{"Start Time(UTC)", "Trace ID"})
for _, item := range traces {
table.Append(
[]string{
item.Start.Format("01/02/2006 04:05"),
"http://127.0.0.1:16686/trace/" + item.ID,
},
)
}
table.Render()
o.write(m, b.String())
}
这段代码使用我们的 Ops 服务客户端获取带有我们传递选项的追踪列表。我们使用一个 ASCII 表格写入包(github.com/olekukonko/tablewriter)来输出我们的追踪表格。
但用户如何知道他们可以发送哪些命令呢?这是通过为机器人提供帮助处理程序来解决的。我们将创建一个映射,存储我们各种帮助消息,以及另一个变量,存储所有命令的字母顺序列表:
var help = map[string]string{
"list traces": `
list traces <opt1=val1 op2=val2>
Ex: list traces operation=AddPets() limit=5
...
`,
}
var cmdList string
func init() {
cmds := []string{}
for k := range help {
cmds = append(cmds, k)
}
sort.Strings(cmds)
b := strings.Builder{}
for _, cmd := range cmds {
b.WriteString(cmd + "\n")
}
b.WriteString("You can get more help by saying `help <cmd>` with a command from above.\n")
cmdList = b.String()
}
我们的帮助文本索引保存在我们的help映射中。init()在程序初始化时设置一个完整的命令列表cmdList。
现在,让我们在一个处理程序中使用这些命令,如果用户向我们的机器人传递了help,则提供帮助文本:
func (o Ops) Help(ctx context.Context, m bot.Message) {
sp := strings.Split(m.Text, "help")
if len(sp) < 2 {
o.write(m, "%s,\nYou have to give me a command you want help with", m.User.Name)
return
}
cmd := strings.TrimSpace(strings.Join(sp[1:], ""))
if cmd == "" {
o.write(m, "Here are all the commands that I can help you with:\n%s", cmdList)
return
}
if v, ok := help[cmd]; ok {
o.write(m, "I can help you with that:\n%s", v)
return
}
o.write(m, "%s,\nI don't know what %q is to give you help", m.User.Name, cmd)
}
这段代码接收用户请求帮助的命令作为输入,并在存在帮助文本时输出。如果用户没有传递命令,它将简单地打印我们支持的命令列表。
如果我们没有处理特定命令的处理程序(可能是他们拼写错误了命令),我们需要一个作为最后手段的处理程序:
func (o Ops) lastResort(ctx context.Context, m bot.Message) {
o.write(m, "%s,\nI don't have anything that handles what you sent. Try the 'help' command", m.User.Name)
}
这只是通知用户我们不知道他们想要什么,因为这是我们不支持的内容。
我们已经有了最基本的处理器集,但仍然需要一种方式将其与机器人注册:
func (o Ops) Register(b *bot.Bot) {
b.Register(regexp.MustCompile(`^\s*help`), o.Help)
b.Register(regexp.MustCompile(`^\s*list traces`), o.ListTraces)
b.Register(nil, o.lastResort)
}
这会接收一个机器人并注册我们三个使用正则表达式的处理器,用于确定应该使用哪个处理器。
现在,到了我们main()函数的时间:
func main() {
... // Other setup like slack client init
b, err := bot.New(api, client)
if err != nil {
panic(err)
}
h := handlers.Ops{
OpsClient: opsClient,
API: api,
SMClient: smClient,
}
h.Register(b)
b.Start()
}
这会创建我们的 Ops 对象,并注册我们与机器人创建的任何HandleFunc类型。你可以在这里找到 ChatOps 机器人的完整代码:github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/11/chatbot/。
现在我们已经了解了编写机器人代码的基础,接下来设置我们的 Slack 应用并运行示例代码。
创建我们的 Slack 应用
为了让机器人与 Slack 进行交互,我们需要设置一个 Slack 应用:
- 在浏览器中访问
api.slack.com/apps。
在这里,你需要点击以下按钮:

图 11.2 – 创建新应用按钮
系统接着会显示以下对话框:

图 11.3 – 创建应用选项
- 选择从应用清单创建选项。系统将展示以下内容:

图 11.4 – 选择工作区
-
选择你在本节开始时创建的工作区,然后点击创建应用。点击下一步按钮。
-
从
github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/11/chatbot/slack.manifest的文件中复制文本,并将其粘贴到以下显示的页面中,格式为 YAML:

图 11.5 – 应用清单配置
- 你在前面的图中看到的文本应替换为文件中的文本。点击下一步按钮。
系统会显示一个关于机器人权限的摘要,如下所示:

图 11.6 – 机器人创建摘要
-
点击创建按钮。
-
这将带你进入一个名为基本信息的页面。向下滚动页面,直到看到应用级令牌,如下图所示:

图 11.7 – 应用级令牌列表
- 点击生成令牌和作用域按钮。这将引导你进入以下对话框:

图 11.8 – 应用令牌创建
-
将令牌名称设置为
petstore-bot。 -
在
connections:write和authorizations:read中提供这些作用域。现在,点击生成。 -
在下一个页面,你将获得一个应用级令牌。你需要点击复制按钮,并将令牌暂时保存到某个地方。

图 11.9 – 应用令牌信息
在生产环境中,你应该将其存储在某种类型的安全密钥库中,如 Azure Key Vault 或 AWS Key Management Service。你需要将其放入一个名为.env的文件中,并且绝不能将此文件提交到代码库中。我们将在运行应用程序部分中介绍如何制作此文件。
注意
这里的密钥是一个在截图之后被删除的机器人密钥。
-
点击完成按钮。
-
在左侧菜单栏中,选择OAuth 和权限。在下面显示的屏幕中,点击安装到工作区:
![图 11.10 – 在你的工作区安装令牌]
](https://github.com/OpenDocCN/freelearn-devops-pt3-zh/raw/master/docs/go-dop/img/B17626_11_010.jpg)
图 11.10 – 在你的工作区安装令牌
- 会弹出一个对话框,询问要将应用发布到哪个频道。选择你喜欢的任何频道并点击允许。
你现在回到OAuth 和权限页面,但你会看到你的机器人身份验证令牌已列出。点击复制按钮,并将其存储在你之前存储应用令牌的位置。
运行应用程序
在这里,我们将使用 Docker Compose 启动我们的 Open Telemetry 服务、Jaeger、Prometheus 和我们的 Petstore 应用程序。启动这些服务后,我们将使用 Go 编译并运行实现与 Slack 连接的聊天机器人服务(ChatOps):
-
在
Go-for-DevOps代码库(github.com/PacktPublishing/Go-for-DevOps/)中,进入chapter/11目录。 -
启动 Docker 容器:
docker-compose up -d -
一旦环境启动,切换到
chapter/11/chatops目录。 -
你需要在此目录中创建一个
.env文件,其中包含以下内容:AUTH_TOKEN=xoxb-[the rest of the token] APP_TOKEN=xapp-[the rest of the token]
这些是我们在设置 Slack 应用程序时生成的。
-
使用以下命令运行 ChatOps 服务器:
go run chatbot.go -
你应该能够看到以下消息输出到标准输出:
Bot started
在后台,有一个演示客户端正在向宠物商店添加宠物并进行宠物搜索(某些搜索可能会导致错误)。服务设置为浮动采样,因此并非每次调用都会生成跟踪。
在另一个终端中,你可以通过使用 CLI 应用程序与宠物商店进行交互。这将允许你添加宠物、删除宠物以及使用过滤器搜索宠物。该客户端可以在以下路径找到:chapter/11/petstore/client/cli/petstore。你可以通过运行以下命令找到使用说明:
go run go run petstore.go --help
可以在http://127.0.0.1:16686/search观察到跟踪。
可以在http://127.0.0.1:9090/graph查询 Prometheus 指标。
要与我们的 ChatOps 机器人进行交互,你需要打开 Slack 并将机器人添加到一个频道中。你可以通过在频道中提到@PetStore来做到这一点。Slack 会询问你是否希望将机器人添加到频道中。
一旦发生这种情况,你可以尝试各种操作。首先,可以向机器人请求帮助,操作如下:
![图 11.11 – 基本帮助命令输出]
](https://github.com/OpenDocCN/freelearn-devops-pt3-zh/raw/master/docs/go-dop/img/B17626_11_011.jpg)
图 11.11 – 基本帮助命令输出
让我们请求一些帮助,看看如何列出一些跟踪:

图 11.12 – 列出跟踪命令的帮助输出
那我们不妨请求系统给我们五个最近的追踪记录:

图 11.13 – 列出最后五个追踪记录的命令输出
我们也可以查询某个特定的追踪记录:

图 11.14 – 显示特定追踪数据的输出
注意
你不能直接粘贴从列出跟踪中复制的追踪 ID。因为这些是超链接;如果你想直接粘贴到 show trace 中,需要移除 ID 中的富文本。
机器人还有更多选项供你玩耍。试试看吧。
这个 ChatOps 应用只是冰山一角。你可以将 ChatOps 应用做得比我们这里的更强大。你可以让它显示图表,从服务的 pprof 转储中抓取配置文件信息并给你一个链接来查看,部署新版本的应用程序,或者回滚版本。只需将文件拖入 Slack 窗口(例如配置更改),即可将文件推送到服务。像警报这样的重要事件可以通过让 Ops 服务向 ChatOps 服务发送消息的方式广播给值班人员,使用 ChatOps 还能增加对服务运行情况和所执行操作的可观察性。
另外,和必须在笔记本电脑或台式机上运行的工具不同,Slack 和许多其他聊天应用程序都有移动版,因此你可以通过手机与之交互或进行紧急操作,而无需额外的开发成本。
摘要
在 第九章,通过 OpenTelemetry 实现可观察性,我们探讨了如何使用 Open Telemetry 提供对应用程序及其依赖应用程序的可观察性。我们讨论了如何使用两种最流行的后端:Jaeger 和 Prometheus(这两者都是用 Go 语言编写的)来为应用程序设置遥测。在 第十章,使用 GitHub Actions 自动化工作流,我们展示了如何使用 GitHub Actions 自动化代码部署,并使用 Go 添加自定义操作。最后,在本章中,我们研究了与服务交互的架构。我们使用 Slack 构建了一个交互层,进行诸如过滤追踪记录、获取当前部署版本以及显示警报等操作。
在接下来的章节中,我们将讨论如何使用 Go 语言及其编写的工具来减轻在云端工作的负担。内容将涵盖构建可以部署到虚拟机或其他节点基础设施的标准镜像。我们还将展示如何扩展 Kubernetes,当前市场上最流行的容器编排系统。最后,我们将指导你如何设计 DevOps 工作流和系统,以保护自己免受在基础设施上运行操作时所固有的混乱。
第三部分:云就绪的 Go
本节讨论了发布工程的实践,使用常见工具创建准备好部署的服务构建,并使用领先的工具来部署分布式应用程序。
除非你一直生活在深山老林里,否则你应该已经注意到,绝大多数新系统的部署已经从企业数据中心迁移到了像 Amazon Web Services(AWS)、Azure 和 Google Cloud 这样的云服务提供商。将现有的内部应用程序迁移的过程正在进行中,从金融行业到电信服务商。DevOps 工程师需要精通构建托管的分布式平台,帮助他们的公司在云、多云和混合云环境中运营。
在本节中,我们将向您展示如何使用 Packer 在 AWS 平台上自动化创建系统镜像的过程,如何使用 Go 和 Terraform 创建您自己的自定义 Terraform 提供程序,如何编程 Kubernetes API 扩展其功能以满足您的需求,如何使用 Azure 的云 SDK 配置资源,以及如何设计能够避免大型云服务提供商已经犯过的错误的弹性 DevOps 软件。
本节将涵盖以下章节:
-
第十二章,使用 Packer 创建不可变基础设施
-
第十三章,使用 Terraform 进行基础设施即代码管理
-
第十四章,在 Kubernetes 中部署和构建应用程序
-
第十五章,云编程
-
第十六章,为混乱设计
第十二章:使用 Packer 创建不可变基础设施
即使在云计算时代,管理计算基础设施仍然是一个挑战。随着容器化、虚拟机 (VMs) 和无服务器计算的创新,开发人员可能认为计算基础设施已经是一个解决了的问题。
事实远非如此。对于云服务提供商或其他运营自己的数据中心的公司,裸金属机器(操作系统未在虚拟化中运行)仍然需要管理。在云计算时代,这变得更加复杂。你的服务提供商不仅需要管理其操作系统的发布和修补,云客户在运行大量虚拟机和容器时也需要这样做。像 Kubernetes 这样的容器编排系统仍然需要提供包含操作系统镜像的容器镜像。
在云中,就像在物理数据中心一样,强制所有容器和虚拟机遵守操作系统合规性是非常重要的。允许任何人运行任何他们想要的操作系统是安全漏洞的入口。为了为开发人员提供一个安全的平台,你必须提供一个在所有部署中都标准化的最小化操作系统。
在一个集群中标准化操作系统的使用,不仅带来全是好处,且几乎没有缺点。当你的公司还很小的时候,标准化操作系统镜像是最容易的。对于那些在早期未能做到这一点的大公司,包括云服务提供商,他们经历了大规模的项目来在后期实现操作系统镜像的标准化。
在本节中,我们将讨论如何使用 Packer,这是由 HashiCorp 编写的一个用 Go 语言开发的软件包,用于管理虚拟机和容器镜像的创建和修补。HashiCorp 是推动 基础设施即代码 (IaC) 趋势的行业领导者。
Packer 让我们使用 YAML 和 Go 提供一种一致的方式,在多个平台上构建镜像。无论是虚拟机镜像、Docker 镜像,还是裸金属镜像,Packer 都可以为你的工作负载提供一致的运行环境。
当我们编写 Packer 配置文件并使用 Packer 二进制文件时,你将开始看到 Packer 的编写方式。Packer 定义的许多交互都是使用我们之前讨论过的 os/exec 等库编写的。也许你将编写下一个在 DevOps 社区中广泛应用的 Packer!
本章将涵盖以下主题:
-
构建亚马逊机器镜像
-
使用 Goss 验证镜像
-
使用插件自定义 Packer
技术要求
本章的先决条件如下:
-
一个 AWS 账户
-
在 AMD64 平台上运行的 AWS Linux 虚拟机
-
一个具有管理员访问权限和访问其秘密的 AWS 用户账户
-
在 AWS Linux 虚拟机上安装 Packer
-
在 AWS Linux 虚拟机上安装 Goss
-
访问本书的 GitHub 仓库
完成本章练习需要一个 AWS 账户。这将使用 AWS 上的计算时间和存储资源,这会产生费用,尽管你可能能够使用 AWS 免费套餐账户(aws.amazon.com/free/)。目前写本书时,作者们与 Amazon 没有任何关系。我们没有任何经济利益。如果有的话,开发本章内容所需的 AWS 费用由我们自己承担。
在运行 Packer 时,我们建议在 Linux 上运行,无论是云镜像还是 Docker 镜像。Windows 是云计算的特殊领域,Microsoft 为处理 Windows 镜像提供了自己的工具集。我们不建议在 Mac 上运行这些工具,因为转向 Apple Silicon 及其与多个工具之间的兼容性可能会导致较长的调试时间。虽然 macOS 是 POSIX 兼容的,但它仍然不是 Linux,而 Linux 才是这些工具的主要目标。
设置 AWS 账户、配置 Linux 虚拟机和设置用户账户超出了本书的范围。有关帮助,请参阅 AWS 文档。在本练习中,请选择 Amazon Linux 或 Ubuntu 发行版之一。
用户设置是使用 AWS IAM 工具完成的,用户名可以是你选择的任何名称。你还需要为此用户获取访问密钥和密钥对。请勿将这些信息存储在代码仓库或任何公开可访问的地方,因为它们相当于用户名/密码。用户需要执行以下操作:
-
属于一个设置了
AdministratorAccess权限的组。 -
附加现有的策略
AmazonSSMAutomationRole。
我们建议为本次练习使用个人账户,因为这个访问权限相当广泛。你也可以设置特定的权限集,或者使用权限不那么开放的其他方法。有关这些方法的说明可以在这里找到:www.packer.io/docs/builders/amazon。
登录到你的虚拟机后,你需要安装 Packer。这将取决于你使用的 Linux 版本。
以下内容适用于 Amazon Linux:
sudo yum install -y yum-utils
sudo yum-config-manager --add-repo https://rpm.releases.hashicorp.com/AmazonLinux/hashicorp.repo
sudo yum -y install packer
以下内容适用于 Ubuntu:
curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo apt-key add -
sudo apt-add-repository "deb [arch=amd64] https://apt.releases.hashicorp.com $(lsb_release -cs) main"
sudo apt-get update && sudo apt-get install packer
对于其他 Linux 版本,请参见以下内容:
learn.hashicorp.com/tutorials/packer/get-started-install-cli.
要测试 Packer 是否安装成功,请运行以下命令:
packer version
这应该会输出你所安装的 Packer 版本。
安装 Packer 后,执行以下命令:
mkdir packer
cd packer
touch amazon.pkr.hcl
mkdir files
cd files
ssh-keygen -t rsa -N "" -C "agent.pem" -f agent
mv agent ~/.ssh/agent.pem
wget https://raw.githubusercontent.com/PacktPublishing/Go-for-DevOps/rev0/chapter/8/agent/bin/linux_amd64/agent
wget https://raw.githubusercontent.com/PacktPublishing/Go-for-DevOps/rev0/chapter/12/agent.service
cd ..
这些命令执行了以下操作:
-
在你的用户主目录中设置一个名为
packer的目录 -
创建一个
amazon.pkr.hcl文件,用于存储我们的 Packer 配置 -
创建一个
packer/files目录 -
为用户
agent生成一个 SSH 密钥对,我们将把它添加到镜像中 -
将
agent.pem私钥移动到我们的.ssh目录中 -
从 Git 仓库复制我们的系统代理
-
从 Git 仓库复制一个
systemd服务配置文件,用于系统代理
既然我们已经处理完了先决条件,接下来看看如何为 AWS 构建自定义Ubuntu镜像。
本章的代码文件可以从github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/12下载。
构建亚马逊机器镜像
Packer 支持多种插件,程序使用这些插件来定位特定的镜像格式。对于我们的示例,我们将针对亚马逊机器镜像(AMI)格式。
还有其他的构建目标,适用于 Docker、Azure、Google Cloud 等。你可以在这里找到其他构建目标的列表:www.packer.io/docs/builders/。
对于用于云环境中的镜像,Packer 插件通常会获取一个现有的云提供商镜像,允许你重新打包并将该镜像上传到服务中。
如果你需要为多个云提供商构建多个镜像,Packer 可以同时进行多个构建。
对于 Amazon,目前有四种方法来构建 AMI:
-
亚马逊弹性块存储(EBS)启动一个源 AMI,进行配置,然后重新打包它。
-
亚马逊实例虚拟服务器,它启动一个实例虚拟机,重新打包它,然后将其上传到 S3(一个亚马逊对象存储服务)。
另外两种方法适用于高级用例。由于这是介绍如何使用 AWS 的 Packer,因此我们将避免使用这些方法。不过,你可以在这里阅读所有这些方法:www.packer.io/docs/builders/amazon。
Packer 使用两种配置文件格式:
-
JavaScript 对象表示法(JSON)
-
HashiCorp 配置语言 2(HCL 2)
由于 JSON 已被弃用,我们将使用HCL2。此格式由 HashiCorp 创建,你可以在这里找到他们的 Go 解析器:github.com/hashicorp/hcl2。如果你希望围绕 Packer 编写自己的工具,或者想在HCL2中支持自己的配置,解析器将非常有用。
现在,让我们创建一个 Packer 配置文件,用来访问亚马逊插件。
打开我们创建的packer/目录中的amazon.pkr.hcl文件。
添加以下内容:
packer {
required_plugins {
amazon = {
version = ">= 0.0.1"
source = "github.com/hashicorp/amazon"
}
}
}
这告诉 Packer 以下内容:
-
我们需要
amazon插件。 -
我们需要的插件版本,即必须比版本
0.0.1更新的最新插件。 -
source位置,用于获取插件。
由于我们使用的是云提供商,因此需要设置 AWS 源信息。
设置 AWS 源
我们将使用Amazon EBS构建方法,因为这是在 AWS 上部署的最简单方法。将以下内容添加到文件中:
source "amazon-ebs" "ubuntu" {
access_key = "your key"
secret_key = "your secret"
ami_name = "ubuntu-amd64"
instance_type = "t2.micro"
region = "us-east-2"
source_ami_filter {
filters = {
name = "ubuntu/images/*ubuntu-xenial-16.04-amd64-server-*"
root-device-type = "ebs"
virtualization-type = "hvm"
}
most_recent = true
owners = ["099720109477"]
}
ssh_username = "ubuntu"
}
这里有一些关键信息,我们将逐步进行:
source "amazon-ebs" "ubuntu" {
这设置了我们基础镜像的源。由于我们使用的是 amazon 插件,因此源将包含与该插件相关的字段。你可以在这里找到完整的字段列表:www.packer.io/docs/builders/amazon/ebs。
这一行将我们的源命名为包含两部分,amazon-ebs 和 ubuntu。当我们在 build 块中引用此源时,它将被称为 source.amazon-ebs.ubuntu。
现在,我们有几个字段值:
-
access_key是要使用的 IAM 用户密钥。 -
secret_key是要使用的 IAM 用户的密钥。 -
ami_name是 AWS 控制台中生成的 AMI 名称。 -
instance_type是用于构建 AMI 的 AWS 实例类型。 -
region是构建实例所在的 AWS 区域。 -
source_ami_filter用于过滤 AMI 镜像,以找到要应用的镜像。 -
filters包含了一种过滤我们基础 AMI 镜像的方法。 -
name给出了 AMI 镜像的名称。它可以是该 API 返回的任何匹配名称:docs.aws.amazon.com/AWSEC2/latest/APIReference/API_DescribeImages.html。 -
root-device-type指定我们使用ebs作为我们的源。 -
virtualization-type指示使用哪种 AMI 虚拟化技术,hvm或pv。由于hvm的增强,现在推荐使用它。 -
most_recent表示使用找到的最新镜像。 -
owners必须列出我们使用的基础镜像 AMI 所有者的 ID。"099720109477"是对 Canonical(Ubuntu 的制造商)的引用。 -
ssh_username是用来通过 SSH 登录镜像的用户名。ubuntu是默认用户名。
作为此处身份验证方法的替代方案,你可以使用 IAM 角色、共享凭证或其他方法。然而,其他方法过于复杂,本书无法涵盖。如果你希望使用其他方法,请参见 技术要求 部分中的链接。
secret_key 需要像密码一样安全。在生产环境中,你将希望使用 IAM 角色来避免使用 secret_key,或者从安全密码服务(如 AWS Secrets Manager、Azure Key Vault 或 GCP Secret Manager)中获取密钥,并使用环境变量方法让 Packer 使用该密钥。
接下来,我们需要定义一个 build 块,以允许我们将镜像从基础镜像更改为我们定制的镜像。
定义一个 build 块并添加一些 provisioners
Packer 定义了一个 build 块,引用我们在前一节中定义的源,并对该镜像进行我们想要的更改。
为此,Packer 在 build 中使用 provisioner 配置。Provisioners 让你通过使用 shell、Ansible、Chef、Puppet、文件或其他方法对镜像进行更改。
你可以在这里找到完整的 provisioners 列表:
www.packer.io/docs/provisioners。
对于长期维护你的运行基础设施,Chef 或 Puppet 已经是许多安装的选择。这样可以在不等待实例重启的情况下更新整个群集到最新的镜像。
通过与 Packer 集成,你可以确保在构建过程中应用最新的补丁到你的镜像上。
尽管这确实有所帮助,但我们无法在本章中探索这些内容。设置 Chef 或 Puppet 只是超出我们目前能做的范围。但是对于长期维护,值得探索这些配置器。
作为我们的示例,我们将执行以下操作:
-
安装 Go 1.17.5 环境。
-
添加一个用户,
agent,到系统中。 -
复制 SSH 密钥到系统中对应的用户。
-
从前面的章节中添加我们的系统代理。
-
设置 systemd 以
agent用户运行代理。
让我们从使用 shell 配置器开始,使用 wget 安装 Go 的 1.17.5 版本。
让我们添加以下内容:
build {
name = "goBook"
sources = [
"source.amazon-ebs.ubuntu"
]
provisioner "shell" {
inline = [
"cd ~",
"mkdir tmp",
"cd tmp",
"wget https://golang.org/dl/go1.17.5.linux-amd64.tar.gz",
"sudo tar -C /usr/local -xzf go1.17.5.linux-amd64.tar.gz",
"echo 'export PATH=$PATH:/usr/local/go/bin' >> ~/.profile",
". ~/.profile",
"go version",
"cd ~/",
"rm -rf tmp/*",
"rmdir tmp",
]
}
}
我们的 build 块包含以下内容:
-
name,给这个块命名。 -
sources,这是一个包含的源块列表。这包括我们刚刚定义的源。 -
provisioner "shell"表明我们将使用shell配置器,通过 shell 登录执行工作。你可以有多个这种类型或其他类型的配置器块。 -
inline设置要在一个 Shell 脚本中依次运行的命令集合。这组 Shell 命令下载 Go 版本 1.17.5,安装它,测试它,并移除安装文件。
应注意,你也可以使用 file provisioner(稍后我们将展示),从本地复制文件而不是使用 wget 检索它。但我们想展示如何仅使用标准的 Linux 工具从可信库中拉取。
接下来,我们将在 build 中添加另一个配置 内部 的提供程序,用于向系统添加一个用户:
// Setup user "agent" with SSH key file
provisioner "shell" {
inline = [
"sudo adduser --disabled-password --gecos '' agent",
]
}
provisioner "file" {
source = "./files/agent.pub"
destination = "/tmp/agent.pub"
}
provisioner "shell" {
inline = [
"sudo mkdir /home/agent/.ssh",
"sudo mv /tmp/agent.pub /home/agent/.ssh/authorized_keys",
"sudo chown agent:agent /home/agent/.ssh",
"sudo chown agent:agent /home/agent/.ssh/authorized_keys",
"sudo chmod 400 .ssh/authorized_keys",
]
}
前面的代码块结构如下:
-
第一个
shell块:添加一个名为agent的用户,禁用密码。 -
第二个
file块:复制一个本地文件./files/agent.pub到/tmp,因为我们不能直接使用file provisioner将其复制到ubuntu以外的用户。 -
第三个 shell 块:
-
创建我们新用户的
.ssh目录。 -
将
agent.pub文件从/tmp移到.ssh/authorized_keys。 -
修改所有目录和文件以具备正确的所有者和权限。
-
现在,让我们添加配置器来安装我们的系统代理并设置 systemd 来管理它。以下部分使用 shell 配置器安装 dbus,它用于与 systemd 通信。我们设置了一个环境变量,以防止在使用 apt-get 安装时出现一些讨厌的 Debian 交互式问题:
// Setup agent binary running with systemd file.
provisioner "shell" { // This installs dbus-launch
environment_vars = [
"DEBIAN_FRONTEND=noninteractive",
]
inline = [
"sudo apt-get install -y dbus",
"sudo apt-get install -y dbus-x11",
]
}
使用文件配置器将我们想要运行的代理从源文件复制到镜像的 /tmp/agent 位置:
provisioner "file" {
source = "./files/agent"
destination = "/tmp/agent"
}
接下来的部分在用户代理的主目录中创建一个名为bin的目录,并将我们在前一部分复制过来的代理文件移动到其中。剩下的是一些必要的权限和所有权更改:
provisioner "shell" {
inline = [
"sudo mkdir /home/agent/bin",
"sudo chown agent:agent /home/agent/bin",
"sudo chmod ug+rwx /home/agent/bin",
"sudo mv /tmp/agent /home/agent/bin/agent",
"sudo chown agent:agent /home/agent/bin/agent",
"sudo chmod 0770 /home/agent/bin/agent",
]
}
这将把systemd文件从源目录复制到我们的镜像中:
provisioner "file" {
source = "./files/agent.service"
destination = "/tmp/agent.service"
}
最后一部分将agent.service文件移动到最终位置,告诉systemd启用agent.service中描述的服务,并验证它是否处于活动状态。sleep参数的作用是允许守护进程在检查之前启动:
provisioner "shell" {
inline = [
"sudo mv /tmp/agent.service /etc/systemd/system/agent.service",
"sudo systemctl enable agent.service",
"sudo systemctl daemon-reload",
"sudo systemctl start agent.service",
"sleep 10",
"sudo systemctl is-enabled agent.service",
"sudo systemctl is-active agent.service",
]
}
最后,让我们添加 Goss 工具,我们将在下一部分中使用它:
provisioner "shell" {
inline = [
"cd ~",
"sudo curl -L https://github.com/aelsabbahy/goss/ releases/latest/download/goss-linux-amd64 -o /usr/local/bin/ goss",
"sudo chmod +rx /usr/local/bin/goss",
"goss -v",
]
}
这将下载最新的 Goss 工具,设置其为可执行,并测试它是否能正常工作。
现在,让我们看看如何执行 Packer 构建来创建一个镜像。
执行 Packer 构建
Packer 构建有四个阶段:
-
初始化 Packer 以下载插件
-
验证构建
-
格式化 Packer 配置文件
-
构建镜像
第一步是初始化我们的插件。为此,只需输入以下内容:
packer init .
注意
如果你看到类似Error: Unsupported block type的消息,很可能是你把provisioner块放在了build块外面。
安装插件后,我们需要验证我们的构建:
packer validate .
这应该会显示The configuration is valid。如果没有,你需要编辑文件以修复错误。
现在,让我们格式化 Packer 模板文件。这是我相信 HashiCorp 借用自 Go 的go fmt命令的概念,工作方式也相同。让我们尝试一下:
packer fmt .
最后,是时候进行我们的构建了:
packer build .
这里会有相当多的输出。如果一切顺利,你将看到如下信息:
Build 'goBook.amazon-ebs.ubuntu' finished after 5 minutes 11 seconds.
==> Wait completed after 5 minutes 11 seconds
==> Builds finished. The artifacts of successful builds are:
--> goBook.amazon-ebs.ubuntu: AMIs were created:
us-east-2: ami-0f481c1107e74d987
注意
如果你看到关于权限的错误,这将与你的用户账户设置有关。请参见本章早些时候列出的必要权限。
现在,你已经在 AWS 上拥有了一个 AMI 镜像。你可以启动使用该镜像的 AWS 虚拟机,它们将运行我们的系统代理。随意启动一个 VM 并设置为你的新 AMI,玩玩代理。你可以通过ssh agent@[host]从你的 Linux 设备访问该代理,其中[host]是 AWS 主机的 IP 或 DNS 地址。
既然我们可以使用 Packer 来打包镜像,让我们看看如何使用 Goss 来验证镜像。
使用 Goss 验证图片
Goss 是一个用于检查服务器配置的工具,使用的是 YAML 编写的规格文件。通过这种方式,你可以测试服务器是否按预期工作。这可以是测试通过 SSH 使用预期密钥访问服务器,也可以是验证各种进程是否正在运行。
Goss 不仅可以测试你的服务器是否符合要求,它还可以与 Packer 集成。这样,我们就可以在提供服务的步骤和部署之前,测试服务器是否按预期运行。
让我们看看如何创建一个 Goss 规格文件。
创建规格文件
规格文件是一组指令,告诉 Goss 需要测试什么。
有几种方法可以为 Goss 创建规范文件。规范文件用于告诉 Goss 需要测试什么。
虽然你可以手动编写,但最有效的方法是使用 Goss 的两个命令之一:
-
goss add -
goss autoadd
使用 Goss 的最有效方法是启动一个包含自定义 AMI 的机器,使用 ubuntu 用户登录,并使用 autoadd 生成 YAML 文件。
登录到你的 AMI 实例后,让我们运行以下命令:
goss -g process.yaml autoadd sshd
这将生成一个 process.yaml 文件,内容如下:
service:
sshd:
enabled: true
running: true
user:
sshd:
exists: true
uid: 110
gid: 65534
groups:
- nogroup
home: /var/run/sshd
shell: /usr/sbin/nologin
process:
sshd:
running: true
这表示我们预期以下内容:
-
一个名为
sshd的系统服务应该通过 systemd 启用并运行。 -
服务应该以用户
sshd运行:-
用户 ID 为
110。 -
组 ID 为
65534。 -
不属于其他任何组。
-
用户的主目录应该是
/var/run/sshd。 -
用户应该没有登录 shell。
-
-
一个名为
sshd的进程应该正在运行。
让我们添加我们部署的代理服务:
goss -g process.yaml autoadd agent
这将向 YAML 文件中添加类似的行。
现在,让我们验证代理位置:
goss -g files.yaml autoadd /home/agent/bin/agent
这将添加如下所示的部分:
file:
/home/agent/bin/agent:
exists: true
mode: "0700"
size: 14429561
owner: agent
group: agent
filetype: file
contains: []
这表示以下内容:
-
/home/agent/bin/agent文件必须存在。 -
必须是模式
0700。 -
必须有
14429561字节的大小。 -
必须由
agent:agent拥有。 -
是文件,而不是目录或
symlink。
让我们添加另一个,但更具体一些,使用 goss add:
goss -g files.yaml add file /home/agent/.ssh/authorized_keys
与 autoadd 自动猜测参数不同,我们必须明确指定它是一个文件。这将生成与 autoadd 相同的条目。对于这个文件,我们来验证 authorized_keys 文件的内容。为此,我们将使用 SHA256 哈希。首先,我们可以通过运行以下命令来获取哈希:
sha256sum /home/agent/.ssh/authorized_keys
这将返回文件的哈希值。在 YAML 文件中 authorized_keys 的 file 条目里,添加以下内容:
sha256: theFileHashJustGenerated
不幸的是,Goss 没有简单地添加整个目录文件或自动将 SHA256 添加到条目的功能。一个例子可能是验证 Go 的 1.17.5 版本的所有文件是否按预期出现在我们的镜像中。
你可能会想尝试如下操作:
find /usr/local/go -print0 | xargs -0 -I{} goss -g golang.yaml add file {}
然而,这样做速度相当慢,因为 goss 每次运行时都会读取 YAML 文件。你可能会想使用 xargs -P 0 来加速,但这样会导致其他问题。
如果你需要包含大量文件和 SHA256 哈希,你需要编写一个自定义脚本/程序来处理这些内容。幸运的是,我们有 Go,因此编写一个可以做到这一点的程序非常容易。而且因为 Goss 是用 Go 编写的,我们可以重用程序中的数据结构。你可以在这里看到一个示例工具:github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/12/goss/allfiles。
你可以直接针对目录结构(编译后)运行它,如下所示:
allfiles /usr/local/go > goinstall_files.yaml
这将输出一个 goinstall_files.yaml 文件,该文件提供了一个 Goss 配置,用于检查这些文件及其 SHA256 哈希。
还记得我们安装了dbus吗?让我们验证一下我们的dbus包是否已安装:
goss -g dbus.yaml add package dbus
goss -g dbus.yaml add package dbus-x11
这现在确保我们的dbus和dbus-x11包已安装。-g dbus.yaml文件将此写入另一个名为dbus.yaml的文件,而不是默认的goss.yaml。
现在我们需要创建一个goss.yaml文件,引用我们创建的其他文件。我们本可以在不加-g选项的情况下运行goss,但这样可以使事情更有条理。让我们创建我们的根文件:
goss add goss process.yaml
goss add goss files.yaml
goss add goss dbus.yaml
这会创建一个goss.yaml文件,该文件引用我们所有的其他文件。
让我们用它来验证所有内容:
goss validate
这将输出类似于以下内容的文本:
..........................
Total Duration: 0.031s
Count: 26, Failed: 0, Skipped: 0
请注意,是的,它确实在不到一秒钟的时间内运行完成!
添加 Packer 预配置器
我们能够验证已有的内容很棒,但我们真正想要的是验证每一个镜像构建。为此,我们可以使用耶鲁大学开发的自定义 Packer 预配置器。
为了做到这一点,我们需要将 YAML 文件从镜像中提取并传送到我们的构建机器上。
从构建机器上,执行以下命令(替换[]中的内容):
cd /home/[user]/packer/files
mkdir goss
cd goss
scp ubuntu@[ip of AMI machine]:/home/ubuntu/*.yaml ./
你需要将[user]替换为构建机器上的用户名,将[ip of AMI machine]替换为你启动的 AMI 机器的 IP 地址或 DNS 条目。你还可能需要在scp之后提供-i [pem 文件的位置]。
由于 Goss 预配置器没有内建,我们需要从耶鲁大学的 GitHub 仓库下载该版本并进行安装:
mkdir ~/tmp
cd ~/tmp
wget https://github.com/YaleUniversity/packer-provisioner-goss/releases/download/v3.1.2/packer-provisioner-goss-v3.1.2-linux-amd64.tar.gz
sudo tar -xzf packer-provisioner-goss-v3.1.2-linux-amd64.tar.gz
cp sudo packer-provisioner-goss /usr/bin/packer-provisioner-goss
rm -rf ~/tmp
安装完预配置器后,我们可以将配置添加到amazon.pkr.hcl文件中:
// Setup Goss for validating an image.
provisioner "file" {
source = "./files/goss/*"
destination = "/home/ubuntu/"
}
provisioner "goss" {
retry_timeout = "30s"
tests = [
"files/goss/goss.yaml",
"files/goss/files.yaml",
"files/goss/dbus.yaml",
"files/goss/process.yaml",
]
}
你可以在github.com/YaleUniversity/packer-provisioner-goss找到更多 Goss 的provisioner设置。
让我们重新格式化我们的 Packer 文件:
packer fmt .
我们还不能构建packer镜像,因为它将与我们已上传到 AWS 的镜像同名。我们有两个选择:从 AWS 中删除我们之前构建的 AMI 镜像,或者将我们 Packer 文件中的名称更改为以下内容:
ami_name = "ubuntu-amd64"
两种选择都可以。
现在,让我们构建我们的 AMI 镜像:
packer build .
当你这次运行它时,输出中应该会看到类似以下的内容:
==> goBook.amazon-ebs.ubuntu: Running goss tests...
==> goBook.amazon-ebs.ubuntu: Running GOSS render command: cd /tmp/goss && /tmp/goss-0.3.9-linux-amd64 render > /tmp/goss-spec.yaml
==> goBook.amazon-ebs.ubuntu: Goss render ran successfully
==> goBook.amazon-ebs.ubuntu: Running GOSS render debug command: cd /tmp/goss && /tmp/goss-0.3.9-linux-amd64 render -d > /tmp/debug-goss-spec.yaml
==> goBook.amazon-ebs.ubuntu: Goss render debug ran successfully
==> goBook.amazon-ebs.ubuntu: Running GOSS validate command: cd /tmp/goss && /tmp/goss-0.3.9-linux-amd64 validate --retry-timeout 30s --sleep 1s
goBook.amazon-ebs.ubuntu: ..........................
goBook.amazon-ebs.ubuntu:
goBook.amazon-ebs.ubuntu: Total Duration: 0.029s
goBook.amazon-ebs.ubuntu: Count: 26, Failed: 0, Skipped: 0
==> goBook.amazon-ebs.ubuntu: Goss validate ran successfully
这表示 Goss 测试成功运行。如果 Goss 失败,将会下载调试输出到本地目录。
你可以在这里找到最终版的 Packer 文件:
github.com/PacktPublishing/Go-for-DevOps/blob/rev0/chapter/12/packer/amazon.final.pkr.hcl。
你现在已经看到了如何使用 Goss 工具为你的镜像构建验证并将其集成到 Packer 中。还有更多的功能可以探索,你可以在这里阅读:github.com/aelsabbahy/goss。
现在我们已经使用了 Goss 作为预配置器,那么如何编写我们自己的呢?
使用插件自定义 Packer
我们使用的内建提供程序非常强大。通过提供 shell 访问和文件上传,几乎可以在 Packer 提供程序中做任何事情。
对于大规模构建,这可能会非常繁琐。而且,如果这种情况是常见的,您可能想让自己的 Go 应用程序为您完成这项工作。
Packer 允许构建可以用于以下场景的插件:
-
一个 Packer 构建器
-
一个 Packer 提供程序
-
一个 Packer 后处理器
构建器在您需要与将使用您图像的系统进行交互时使用:Docker、AWS、GCP、Azure 或其他系统。由于这种用法在云提供商或像 VMware 这样的公司增加支持之外并不常见,因此我们将不做详细介绍。
后处理器通常用于将图像推送到上传之前生成的工件。由于这不是常见的用法,我们将不做详细介绍。
提供程序是最常见的,因为它们是构建过程中输出图像的一部分。
Packer 有两种编写这些插件的方式:
-
单一插件
-
多插件
单一插件是编写插件的旧方式。Goss 提供程序就是用旧方式编写的,这也是我们手动安装它的原因。
使用更新的方式,packer init 可以用来下载插件。此外,一个插件可以在一个插件中注册多个构建器、提供程序或后处理器。这是编写插件的推荐方式。
不幸的是,截至撰写本文时,关于多插件和支持 packer init 的发布的官方文档不完整。按照这些说明操作,无法生成可以通过他们建议的过程发布的插件。
这里包含的说明将填补空白,允许构建一个多插件,用户可以通过 packer init 安装它。
现在让我们来看看如何编写自定义插件。
编写您自己的插件
提供程序是 Packer 应用程序的强大扩展。它们允许我们自定义应用程序,做任何我们需要的事情。
我们已经看到提供程序如何执行 Goss 来验证我们的构建。这使我们能够确保未来的构建遵循图像的规范。
要编写一个自定义 provisioner,我们必须实现以下接口:
type Provisioner interface {
ConfigSpec() hcldec.ObjectSpec
Prepare(...interface{}) error
Provision(context.Context, Ui, Communicator,
map[string] interface{}) error
}
上面的代码描述如下:
-
ConfigSpec()返回一个表示您提供程序 HCL2 规范的对象。Packer 将使用它将用户的配置转换为 Go 语言中的结构化对象。 -
Prepare()准备您的插件运行,并接收一个interface{}切片,表示配置。通常,配置作为单个map[string]interface{}传递。Prepare()应该执行诸如从源拉取信息或验证配置等准备工作,应该在尝试运行之前就导致失败。这不应有副作用,也就是说,它不应通过创建文件、实例化虚拟机或对系统进行任何其他更改来改变任何状态。 -
Provision()执行大部分工作。它接收一个Ui对象,用于与用户进行通信,还有一个Communicator对象,用于与正在运行的机器进行通信。提供了一个map,其中包含由构建器设置的值。然而,依赖于其中的值可能会将你绑定到一个builder类型。
对于我们的示例提供程序,我们将打包 Go 环境并将其安装到机器上。虽然 Linux 发行版通常会打包 Go 环境,但它们通常会落后几个版本。之前,我们可以通过使用file和shell(这些实际上几乎可以做任何事情)来完成,但如果你是应用程序提供商,想要为其他 Packer 用户在多个平台上实现可重复的操作,那么自定义提供程序是最佳选择。
添加我们的提供程序配置
为了让用户配置我们的插件,我们需要定义一个配置。我们希望支持的配置选项如下:Version (string)[optional],下载的特定版本默认为latest。
我们将在子包中定义这个:internal/config/config.go。
在该文件中,我们将添加以下内容:
package config
//go:generate packer-sdc mapstructure-to-hcl2 -type Provisioner
// Provisioner is our provisioner configuration.
type Provisioner struct {
Version string
}
// Default inputs default values.
func (p *Provisioner) Defaults() {
if p.Version == "" {
p.Version = "latest"
}
}
不幸的是,我们现在需要能够从hcldec.ObjectSpec文件中读取这些内容。这比较复杂,因此 HashiCorp 创建了一个代码生成器来为我们完成这项工作。要使用它,你必须安装他们的packer-sdc工具:
go install github.com/hashicorp/packer-plugin-sdk/cmd/packer-sdc@latest
为了生成文件,我们可以在internal/config目录中执行以下操作:
go generate ./
这将输出一个config.hcl2spec.go文件,其中包含我们需要的代码。它使用文件中定义的//go:generate行。
定义插件的配置规范
在插件的位置根目录,我们创建一个名为goenv.go的文件。
所以,我们首先定义用户将输入的配置:
package main
import (
...
"[repo location]/packer/goenv/internal/config"
"github.com/hashicorp/packer-plugin-sdk/packer"
"github.com/hashicorp/packer-plugin-sdk/plugin"
"github.com/hashicorp/packer-plugin-sdk/version"
packerConfig "github.com/hashicorp/packer-plugin-sdk/ template/config"
...
)
这将导入以下内容:
-
我们刚才定义的
config包 -
构建我们插件所需的三个包:
-
packer -
plugin -
version
-
-
一个用于处理 HCL2 配置的
packerConfig包注意
...表示标准库包和一些其他包,为了简洁起见省略了它们。你可以在仓库版本中看到它们的全部内容。
现在,我们需要定义我们的提供程序:
// Provisioner implements packer.Provisioner.
type Provisioner struct{
packer.Provisioner // Embed the interface.
conf *config.Provisioner
content []byte
fileName string
}
这将保存我们的配置、一部分文件内容以及 Go tarball 文件名。我们将在这个结构体上实现我们的Provisioner接口。
现在,是时候添加所需的方法了。
定义ConfigSpec()函数
ConfigSpec()是为 Packer 的内部使用而定义的。我们只需要提供规格,以便 Packer 可以读取配置。
让我们使用之前生成的config.hcl2spec.go来实现ConfigSpec():
func (p *Provisioner) ConfigSpec() hcldec.ObjectSpec {
return new(config.FlatProvisioner).HCL2Spec()
}
这将返回ObjectSpec,用于处理读取我们的 HCL2 配置。
现在这些工作已经完成,我们需要准备好插件以供使用。
定义Prepare()方法
请记住,Prepare()方法仅需要解释 HCL2 配置的中间表示并验证条目。它不应该改变任何事物的状态。
以下是该示例的样子:
func (p *Provisioner) Prepare(raws ...interface{}) error {
c := config.Provisioner{}
if err := packerConfig.Decode(&c, nil, raws...); err != nil {
return err
}
c.Defaults()
p.conf = &c
return nil
}
这段代码执行了以下操作:
-
创建我们的空配置。
-
将原始配置项解码为我们的内部表示形式。
-
如果没有设置值,默认值会被放入配置中。
-
验证我们的配置。
我们还可以利用这段时间连接服务或进行任何其他所需的准备工作。最重要的是不要改变任何状态。
经过所有准备工作后,是时候迎接大结局了。
定义 Provision() 方法。
Provision() 是所有魔法发生的地方。让我们将其分成一些逻辑部分:
-
获取我们的版本信息。
-
将一个 tarball 推送到镜像中。
-
解压 tarball 文件。
-
测试我们的 Go 工具安装情况。
以下代码封装了其他方法,以相同的顺序执行逻辑部分:
func (p *Provisioner) Provision(ctx context.Context, u packer. Ui, c packer.Communicator, m map[string]interface{}) error {
u.Message("Begin Go environment install")
if err := p.fetch(ctx, u, c); err != nil {
u.Error(fmt.Sprintf("Error: %s", err))
return err
}
if err := p.push(ctx, u, c); err != nil {
u.Error(fmt.Sprintf("Error: %s", err))
return err
}
if err := p.unpack(ctx, u, c); err != nil {
u.Error(fmt.Sprintf("Error: %s", err))
return err
}
if err := p.test(ctx, u, c); err != nil {
u.Error(fmt.Sprintf("Error: %s", err))
return err
}
u.Message("Go environment install finished")
return nil
}
这段代码调用了所有阶段(我们稍后会定义)并将一些消息输出到用户界面。Ui 接口定义如下:
type Ui interface {
Ask(string) (string, error)
Say(string)
Message(string)
Error(string)
Machine(string, ...string)
getter.ProgressTracker
}
不幸的是,UI 在代码或文档中没有很好的记录。以下是详细说明:
-
你可以使用
Ask()向用户提问并获得回应。一般来说,应该避免使用这个方法,因为它会破坏自动化流程。最好让用户将其放入配置中。 -
Say()和Message()都是将字符串打印到屏幕上。 -
Error()输出一条错误信息。 -
Machine()只是通过fmt.Printf()将一条语句输出到机器生成的日志中,并以machine readable:为前缀。 -
getter.ProgressTracker()被Communicator用来跟踪下载进度,你不需要担心它。
现在我们已经涵盖了 UI,接下来讲解 Communicator:
type Communicator interface {
Start(context.Context, *RemoteCmd) error
Upload(string, io.Reader, *os.FileInfo) error
UploadDir(dst string, src string, exclude []string) error
Download(string, io.Writer) error
DownloadDir(src string, dst string, exclude []string) error
}
前面代码块中的方法如下所示:
-
Start()在镜像上运行一个命令。你传递*RemoteCmd,它类似于我们在前面章节中使用的os/exec中的Cmd类型。 -
Upload()将文件上传到机器镜像。 -
UploadDir()递归地将本地目录上传到机器镜像。 -
Download()从机器镜像中下载文件。这允许你捕获调试日志,例如。 -
DownloadDir()从机器递归地下载一个目录到本地目的地。你可以排除某些文件。
你可以在这里查看完整的接口注释:pkg.go.dev/github.com/hashicorp/packer-plugin-sdk/packer?utm_source=godoc#Communicator。
让我们来看一下构建第一个助手 p.fetch()。以下代码决定了下载 Go 工具使用的 URL。我们的工具面向 Linux,但我们支持为多个平台安装不同版本。我们使用 Go 的 runtime 包来确定我们当前运行的架构(386、ARM 或 AMD 64),以此决定下载哪个包。用户可以指定一个特定版本或 latest。对于 latest,我们查询 Google 提供的 URL,该 URL 返回 Go 的最新版本。然后,我们利用这个版本信息构造下载 URL:
func (p *Provisioner) fetch(ctx context.Context, u Ui,
c Communicator) error {
const (
goURL = `https://golang.org/dl/go%s.linux-%s.tar.gz`
name = `go%s.linux-%s.tar.gz`
)
platform := runtime.GOARCH
if p.conf.Version == "latest" {
u.Message("Determining latest Go version")
resp, err := http.Get("https://golang.org/VERSION?m=text")
if err != nil {
u.Error("http get problem: " + err.Error())
return fmt.Errorf("problem asking Google for latest Go version: %s", err)
}
ver, err := io.ReadAll(resp.Body)
if err != nil {
u.Error("io read problem: " + err.Error())
return fmt.Errorf("problem reading latest Go version: %s", err)
}
p.conf.Version = strings.TrimPrefix(string(ver), "go")
u.Message("Latest Go version: " + p.conf.Version)
} else {
u.Message("Go version to use is: " + p.conf.Version)
}
这段代码发起 Go tarball 的 HTTP 请求,并将其存储在 .content 中:
url := fmt.Sprintf(goURL, p.conf.Version, platform)
u.Message("Downloading Go version: " + url)
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("problem reaching golang.org for version(%s): %s)", p.conf.Version, err)
}
defer resp.Body.Close()
p.content, err = io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("problem downloading file: %s", err)
}
p.fileName = fmt.Sprintf(name, p.conf.Version, platform)
u.Message("Downloading complete")
return nil
}
现在我们已经获取了 Go tarball内容,让我们将其推送到机器上:
func (p *Provisioner) push(ctx context.Context, u Ui,
c Communicator) error {
u.Message("Pushing Go tarball")
fs := simple.New()
fs.WriteFile("/tarball", p.content, 0700)
fi, _ := fs.Stat("/tarball")
err := c.Upload(
"/tmp/"+p.fileName,
bytes.NewReader(p.content),
&fi,
)
if err != nil {
return err
}
u.Message("Go tarball delivered to: /tmp/" + p.fileName)
return nil
}
上述代码将我们的内容上传到镜像中。Upload()要求我们提供*os.FileInfo,但我们没有一个,因为我们的文件在磁盘上并不存在。所以,我们使用一个技巧,将内容写入内存中的文件系统中,然后获取*os.FileInfo。这样,我们就避免了将不必要的文件写入磁盘。
注意
Communicator.Upload()的一个奇怪之处在于它接受一个指向interface (*os.FileInfo)的指针。这几乎总是作者的一个错误。不要在你的代码中这样做。
接下来需要做的是在镜像中解压此内容:
func (p *Provisioner) unpack(ctx context.Context, u Ui,
c Communicator) error {
const cmd = `sudo tar -C /usr/local -xzf /tmp/%s`
u.Message("Unpacking Go tarball to /usr/local")
b := bytes.Buffer{}
rc := &packer.RemoteCmd{
Command: fmt.Sprintf(cmd, p.fileName),
Stdout: &b,
Stderr: &b,
}
if err := c.Start(rc); err != nil {
return fmt.Errorf("problem unpacking tarball(%s):\n%s", err, b.String())
}
u.Message("Unpacked Go tarball")
return nil
}
这段代码执行以下操作:
-
定义一个命令来解压我们的 tarball 并安装到
/usr/local。 -
将该命令包装在
*packerRemoteCmd中并捕获STDOUT和STDERR。 -
使用
Communicator运行命令:如果失败,返回错误和STDOUT/STDERR用于调试。
Provisioner的最后一步是测试它是否已安装:
func (p *Provisioner) test(ctx context.Context, u Ui,
c Communicator) error {
u.Message("Testing Go install")
b := bytes.Buffer{}
rc := &packer.RemoteCmd{
Command: `/usr/local/go/bin/go version`,
Stdout: &b,
Stderr: &b,
}
if err := c.Start(rc); err != nil {
return fmt.Errorf("problem testing Go install(%s):\n%s", err, b.String())
}
u.Message("Go installed successfully")
return nil
}
这段代码执行以下操作:
-
运行
/usr/local/go/bin/go version来获取输出。 -
如果失败,返回错误和
STDOUT/STDERR用于调试。
现在,插件的最后部分是编写main():
const (
ver = "0.0.1"
release = "dev"
)
var pv *version.PluginVersion
func init() {
pv = version.InitializePluginVersion(ver, release)
}
func main() {
set := plugin.NewSet()
set.SetVersion(pv)
set.RegisterProvisioner("goenv", &Provisioner{})
err := set.Run()
if err != nil {
fmt.Fprintln(os.Stderr, err.Error())
os.Exit(1)
}
}
这段代码执行以下操作:
-
将我们的发布版本定义为
"0.0.1"。 -
将发布定义为
"dev"版本,但这里可以使用任何名称。生产版本应使用""。 -
初始化
pv,它保存插件版本信息。这样做是在init()中,因为包注释指出应该这样做,而不是在main()中,这样如果存在问题,可以在最早时触发 panic。 -
创建一个新的 Packer
plugin.Set:-
设置版本信息。如果未设置,所有 GitHub 发布将失败。
-
使用
"goenv"插件名称注册我们的配置器:-
可用于注册其他配置器。
-
可用于注册构建器,
set.RegisterBuilder(),以及后处理器,set.RegisterPostProcessor()。
-
-
-
运行我们创建的
Set并在任何错误时退出。
我们可以使用常规名称进行注册,这样名称会附加到插件名称上。如果使用plugin.DEFAULT_NAME,我们的配置器可以简单地通过插件名称来引用。
因此,如果我们的插件命名为packer-plugin-goenv,我们可以将插件称为goenv。如果使用plugin.DEFAULT_NAME以外的名称,例如example,则我们的插件将被称为goenv-example。
我们现在有了一个插件,但要使其有用,我们必须允许人们初始化它。让我们来看一下如何通过 GitHub 发布我们的插件。
测试插件
在这个练习中,我们不讨论测试 Packer 插件。发布时,尚无相关的测试文档。然而,Packer 的 GoDoc 页面有公开的类型,可以模拟 Packer 中的各种类型,帮助测试你的插件。
这包括模拟Provisioner、Ui和Communicator类型,以便进行测试。你可以在这里找到这些内容:pkg.go.dev/github.com/hashicorp/packer-plugin-sdk/packer。
发布插件
Packer 对允许packer二进制文件查找和使用插件有严格的发布要求。为了使插件可下载,必须满足以下要求:
-
必须在 GitHub 上发布;不允许使用其他来源。
-
你的仓库名称必须是
packer-plugin-*,其中*是你的插件名称。 -
只能使用连字符,而不是下划线。
-
必须有一个插件发布,其中包括我们将描述的某些资产。
官方发布文档可以在这里找到:www.packer.io/docs/plugins/creation#creating-a-github-release。
HashiCorp 还有一个 30 分钟的视频,展示如何将发布文档发布到 Packer 网站,视频链接如下:www.hashicorp.com/resources/publishing-packer-plugins-to-the-masses。
生成发布的第一步是创建一个GNU 隐私保护(GPG)密钥以签署发布版本。GitHub 的相关指令可以在这里找到(但请先阅读下面的注意事项):docs.github.com/en/authentication/managing-commit-signature-verification/generating-a-new-gpg-key。
在遵循该文档之前,请记住在执行指令时注意以下事项:
-
确保将公钥添加到你的 GitHub 个人资料中。
-
请不要在密码短语中使用
$或任何其他符号,因为这会导致问题。
一旦完成,你需要将私钥添加到你的仓库中,这样我们定义的 GitHub Actions 才能签署发布版本。你需要进入 GitHub 仓库的| Secrets。点击提供的New Repository Secret按钮。
选择名称GPG_PRIVATE_KEY。
在值部分,你需要粘贴你的 GPG 私钥,你可以使用以下命令导出该私钥:
gpg --armor --export-secret-keys [key ID or email]
[key ID 或 email]是你为密钥提供的身份,通常是你的电子邮件地址。
现在,我们需要添加你的 GPG 密钥的密码短语。你可以将其作为一个名为GPG_PASSPHRASE的密钥添加。值应该是 GPG 密钥的密码短语。
一旦完成,你需要下载 HashiCorp 提供的 GoReleaser 脚手架。你可以通过以下方式完成:
curl -L -o ".goreleaser.yml" \
https://raw.githubusercontent.com/hashicorp/packer-plugin-scaffolding/main/.goreleaser.yml
现在,我们需要在你的仓库中设置 HashiCorp 提供的 GitHub Actions 工作流。你可以通过以下方式完成:
mkdir -p .github/workflows &&
curl -L -o ".github/workflows/release.yml" \
https://raw.githubusercontent.com/hashicorp/packer-plugin-scaffolding/main/.github/workflows/release.yml
最后,我们需要下载GNUmakefile,这是脚手架使用的文件。我们来下载它:
curl -L -o "GNUmakefile" \
https://raw.githubusercontent.com/hashicorp/packer-plugin-scaffolding/main/GNUmakefile
我们的插件仅适用于 Linux 系统。.goreleaser.yml文件定义了多个平台的发布版本。你可以通过修改.goreleaser.yml中的builds部分来限制它。你可以在这里查看一个示例:github.com/johnsiilver/packer-plugin-goenv/blob/main/.goreleaser.yml。
当你的代码可以构建并且这些文件已包含时,你需要将这些文件提交到你的仓库中。
下一步将是创建一个发布版本。这个版本需要使用语义化版本标记,类似于你在插件的main文件中设置的ver变量。稍有不同的是,虽然ver string中将严格使用数字和点,但在 GitHub 上标记时会加上v。例如,ver = "0.0.1"将成为 GitHub 发布版本v0.0.1。GitHub 发布的文档可以在此查看:docs.github.com/en/repositories/releasing-projects-on-github/managing-releases-in-a-repository。
一旦你创建了发布版本,你可以通过查看Actions标签来查看正在执行的操作。这将展示结果并详细说明操作过程中遇到的任何问题。
在构建中使用我们的插件
要在构建中使用我们的插件,我们需要修改 HCL2 配置。首先,我们需要修改packer.required_plugins以要求我们的插件:
packer {
required_plugins {
amazon = {
version = ">= 0.0.1"
source = "github.com/hashicorp/amazon"
}
installGo = {
version = ">= 0.0.1"
source = "github.com/johnsiilver/goenv"
}
}
}
这做了几件事:
-
创建一个新的变量
installGo,该变量提供访问我们多插件中定义的所有插件的权限。这里只有一个插件:goenv。 -
设置使用的版本必须大于或等于
0.0.1。 -
提供插件的源路径。你会注意到路径中没有
packer-plugin-,因为这是每个插件的标准命名,它们移除了这个部分的输入。注意
你会发现源地址与我们代码的位置不同。这是因为我们希望将代码保留在常规位置,但 Packer 要求插件必须有自己的仓库。代码在这两个位置都有。你可以在这里查看代码副本:
github.com/johnsiilver/packer-plugin-goenv。
现在,我们需要删除build.provisioner下安装 Go 的shell部分,并用以下内容替换:
provisioner "goenv-goenv" {
version = "1.17.5"
}
最后,你需要更新 AMI 名称,以便将其存储到新的位置。
作为替代方案,你也可以在此下载修改后的 HCL2 文件:github.com/PacktPublishing/Go-for-DevOps/blob/rev0/chapter/12/packer/amazon.goenv.pkr.hcl。
在终端中,格式化文件并使用以下命令下载我们的插件:
packer fmt .
packer init .
这应该会导致我们的插件下载,并输出类似于以下内容的文本:
Installed plugin github.com/johnsiilver/goenv v0.0.1 in "/home/ec2-user/.config/packer/plugins/github.com/johnsiilver/goenv/packer-plugin-goenv_v0.0.1_x5.0_linux_amd64"
我们最终可以通过以下命令构建我们的镜像:
packer build .
如果成功,您应该在 Packer 输出中看到以下内容:
goBook.amazon-ebs.ubuntu: Begin Go environment install
goBook.amazon-ebs.ubuntu: Go version to use is: 1.17.5
goBook.amazon-ebs.ubuntu: Downloading Go version: https://golang.org/dl/go1.17.5.linux-amd64.tar.gz
goBook.amazon-ebs.ubuntu: Downloading complete
goBook.amazon-ebs.ubuntu: Pushing Go tarball
goBook.amazon-ebs.ubuntu: Go tarball delivered to: /tmp/go1.17.5.linux-amd64.tar.gz
goBook.amazon-ebs.ubuntu: Unpacking Go tarball to /usr/local
goBook.amazon-ebs.ubuntu: Unpacked Go tarball
goBook.amazon-ebs.ubuntu: Testing Go install
goBook.amazon-ebs.ubuntu: Go installed successfully
goBook.amazon-ebs.ubuntu: Go environment install finished
这个插件已经过预先测试。让我们来看看如果插件失败,您可以做些什么。
调试 Packer 插件
当packer build .失败时,您可能会在 UI 输出中获得或没有获得相关信息。这取决于问题是恐慌(panic)还是错误(error)。
恐慌会返回一个Unexpected EOF消息,因为插件崩溃,而 Packer 应用程序只知道它没有在 Unix 套接字上接收到 RPC 消息。
我们可以通过运行时提供这个选项来请求 Packer 帮助我们:
packer build -debug
如果构建崩溃,它将输出一个crash.log文件。它还会在每一步之间使用press enter,并且一次只允许运行一个packer构建。
您可能会看到其他文件出现,因为一些插件(如 Goss)检测到debug选项并输出调试配置文件和日志。
您可能还想启用日志记录,以便记录您或其他插件写入的日志消息。这可以通过设置几个环境变量来完成:
PACKER_LOG=1 PACKER_LOG_PATH="./packerlog.txt" packer build .
这解决了大多数调试需求。然而,有时所需的调试信息是系统日志的一部分,而不是插件本身。在这种情况下,您可能希望在检测到错误时使用通信器的Download()或DownloadDir()方法来检索文件。
获取更多调试信息,请访问官方调试文档:www.packer.io/docs/debugging。
在本节中,我们详细介绍了如何构建一个 Packer 多插件,展示了如何在 GitHub 上设置插件以与packer init一起使用,并更新了我们的 Packer 配置以使用该插件。此外,我们还讨论了调试 Packer 插件的基础知识。
摘要
本章教会了您使用 Packer 构建机器镜像的基础知识,以 Amazon AWS 为目标。我们介绍了 Packer 提供的最重要插件,以自定义 AMI。然后,我们构建了一个自定义镜像,通过 apt 工具安装了多个软件包,下载并安装了其他工具,设置了目录和用户,最后设置了一个系统代理来与 systemd 一起运行。
我们已经介绍了如何使用 Goss 工具验证您的镜像,以及如何通过耶鲁大学开发的插件将 Goss 集成到 Packer 中。
最后,我们展示了如何创建您自己的插件,以扩展 Packer 的功能。
现在,是时候谈谈 IaC 以及 HashiCorp 的另一个工具如何在 DevOps 世界中掀起风潮了。我们来谈谈 Terraform。
第十三章:使用 Terraform 实现基础设施即代码
基础设施即代码(IaC)是使用机器可读的声明性规范或命令式代码来配置计算基础设施的实践,而不是使用交互式配置工具。随着云计算的兴起,IaC 越来越流行。以前负责维护长期存在的基础设施的基础设施管理员,发现自己在公司采用云基础设施后,既需要在敏捷性上提高,又需要在容量上扩展。
请记住,在这时,软件团队和基础设施团队通常不会紧密合作,直到需要部署软件项目时。IaC 通过建立一套共享的文档,描述了软件项目所需的基础设施,从而为基础设施管理员和软件开发人员架起了桥梁。IaC 规范或代码通常存在于项目内部或与项目并行存放。通过在软件开发人员和基础设施管理员之间建立这种共享上下文,这两个团队能够在软件开发生命周期的早期就开始合作,并为基础设施建立共同的愿景。
在本章中,我们将首先学习 Terraform 如何处理 IaC 及其基本用法。在我们掌握 Terraform 的工作原理之后,我们将讨论 Terraform 提供者,并看看如何通过丰富的提供者生态系统来描述和配置各种资源,而不仅仅是计算基础设施,如虚拟机。最后,我们将学习如何通过构建我们自己的宠物商店 Terraform 提供者来扩展 Terraform。
本章将涵盖以下主题:
-
IaC 简介
-
了解 Terraform 的基础知识
-
了解 Terraform 提供者的基础知识
-
构建宠物商店 Terraform 提供者
技术要求
在本章中,你需要具备以下内容:
-
Docker
-
Git
-
Go
-
Terraform CLI:
learn.hashicorp.com/tutorials/terraform/install-cli -
Azure CLI:
docs.microsoft.com/en-us/cli/azure/install-azure-cli -
本章的代码:
github.com/PacktPublishing/Go-for-DevOps/tree/main/chapter/13/petstore-provider
让我们从学习一些 Terraform 基础知识开始。
本章的代码文件可以从 github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/13/petstore-provider 下载
IaC 简介
IaC 不仅促进了基础设施与软件开发团队的合作,还使得项目基础设施的部署变得更加简便和安全。通过定义基础设施并将规范存储在软件项目中,基础设施代码可以像测试软件项目一样进行测试。与测试代码一样,持续测试基础设施代码能够减少缺陷、发现低效之处,并提高对基础设施部署过程的信心。
今天我们已经习惯了这一点,但在许多组织中,与基础设施管理员合作为一个复杂的应用程序构建集群可能需要几周时间。将这一经验压缩成少数几个文件,然后能够在几分钟内部署集群,这一变化具有革命性意义。
市面上有许多 IaC 工具,每个工具在描述和配置基础设施时都有自己独特的方式。虽然它们各有不同,但每个工具都可以通过两个方面来分类:一是作者如何指定代码,二是工具如何处理代码的变更。最重要的分类是基础设施代码如何被指定。具体而言,代码是一种声明式的规范,描述所需的状态(要配置什么),或者代码是用编程语言描述的一系列命令性步骤(如何配置)。第二个分类是工具如何应用基础设施,推送(Push)或拉取(Pull)。拉取式 IaC 工具会监视中央仓库中代码的变化,推送式 IaC 工具则将其更改应用到目标系统中。
IaC 是在编写、交付和运维软件之间架起桥梁的关键实践之一。它是开发与运维交集的关键领域之一。掌握这一实践将使您的团队能够更快、更灵活、更可靠地交付软件。
理解 Terraform 的基础知识
Terraform (www.terraform.io/) 是一个由 HashiCorp 创建、用 Go 编写的开源 IaC 工具,提供一致的命令行体验来管理各种资源。通过 Terraform,基础设施工程师可以使用声明式的 Terraform 配置文件或命令式代码(www.terraform.io/cdktf)定义一组层次化资源的期望状态,从而生成 Terraform 配置文件。这些配置文件就是 IaC 中的代码。它们可以用于管理资源的整个生命周期,包括创建、修改和销毁资源,计划并预测资源变更,提供复杂资源拓扑中的依赖关系图,并存储系统的最后观察状态。
Terraform 非常容易上手,并且有一个相对平缓的学习曲线。在本章中,我们不会涵盖 Terraform 的许多功能,但这些功能在你深入使用工具时会非常有用。本章的目标不是让你成为 Terraform 的专家,而是帮助你快速上手并高效使用。
在这一部分,你将学习 Terraform 如何运作的基础知识,以及如何使用 Terraform CLI。我们将从一个简单的示例开始,并讨论执行时发生的事情。到本节结束时,你应该能够熟练地使用 Terraform CLI 定义资源、初始化并应用。
使用 Terraform 初始化和应用基础设施规范
在本节的第一部分,我们将讨论资源而不是基础设施组件。讨论资源和组件较为抽象。让我们用一个具体的例子来解释使用 Terraform 的正常操作流程。
对于我们的第一个示例,我们将使用如下所示的目录结构:
.
├── main.tf
在上面的代码块中,我们有一个目录,里面有一个名为 main.tf 的文件。在该文件中,我们将添加以下内容:
resource "local_file" "foo" {
content = "foo!"
filename = "${path.module}/foo.txt"
}
在上述 Terraform main.tf 配置文件中,我们定义了一个名为 foo 的 local_file 资源,并将其内容设置为 foo!,该文件位于 ${path.module}/foo.txt。${path.module} 是模块的文件系统路径,在此例中为 ./foo.txt。
我们可以简单地运行以下命令来初始化 Terraform 并应用所需的状态:
$ terraform init && terraform apply
上面的terraform init命令将检查main.tf的有效性,拉取所需的提供程序,并初始化项目的本地状态。在执行init命令后,将执行apply命令。我们将这两个命令分为两部分来讨论,首先是init,然后是apply。init命令应输出以下内容:
$ terraform init && terraform apply
Initializing the backend...
Initializing provider plugins...
- Finding latest version of hashicorp/local...
- Installing hashicorp/local v2.2.2...
- Installed hashicorp/local v2.2.2 (signed by HashiCorp)
Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made preceding. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.
Terraform has been successfully initialized!
You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.
If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.
如上面的输出所示,Terraform 安装了特定版本的hashicorp/local提供程序。然后,Terraform 将该版本保存到本地锁定文件.terraform.lock.hcl中,以确保未来使用相同的版本,从而确保可重现的构建。最后,Terraform 提供了使用terraform plan查看 Terraform 将如何执行以达到main.tf中描述的所需状态的指令。
初始化后,运行terraform apply将触发 Terraform 确定当前所需的状态,并与main.tf中资源的已知状态进行比较。terraform apply会向操作员呈现即将执行的操作计划。经操作员批准计划后,Terraform 执行该计划并保存资源的更新状态。我们来看一下terraform apply的输出:
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# local_file.foo will be created
+ resource "local_file" "foo" {
+ content = "foo!"
+ directory_permission = "0777"
+ file_permission = "0777"
+ filename = "./foo.txt"
+ id = (known after apply)
}
Plan: 1 to add, 0 to change, 0 to destroy.
Do you want to perform these actions?
Terraform will perform the actions described preceding.
Only 'yes' will be accepted to approve.
Enter a value: yes
local_file.foo: Creating...
local_file.foo: Creation complete after 0s [id=4bf3e335199107182c6f7638efaad377acc7f452]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
在确认计划并输入yes后,Terraform 已应用所需的状态并创建了一个本地文件资源。目录应如下所示:
.
├── .terraform
│ └── providers
│ └── registry.terraform.io
│ └── hashicorp
│ └── local
│ └── 2.2.2
│ └── darwin_arm64
│ └── terraform-provider-local_v2.2.2_x5
├── .terraform.lock.hcl
├── foo.txt
├── main.tf
└── terraform.tfstate
在上面的目录结构中,我们可以看到 Terraform 用于配置文件的本地提供程序、Terraform 锁文件、foo.txt文件和terraform.tfstate文件。让我们探索一下foo.txt和terraform.tfstate文件:
$ cat foo.txt
foo!
正如我们在main.tf中描述的那样,Terraform 已经创建了包含foo!内容的foo.txt。接下来,让我们看看terraform.tfstate:
$ cat terraform.tfstate
{
"version": 4,
"terraform_version": "1.1.7",
"serial": 1,
"lineage": "384e96a1-5878-ed22-5368-9795a3231a00",
"outputs": {},
"resources": [
{
"mode": "managed",
"type": "local_file",
"name": "foo",
"provider": "provider[\"registry.terraform.io/hashicorp/local\"]",
"instances": [
{
"schema_version": 0,
"attributes": {
"content": "foo!",
"content_base64": null,
"directory_permission": "0777",
"file_permission": "0777",
"filename": "./foo.txt",
"id": "4bf3e335199107182c6f7638efaad377acc7f452",
"sensitive_content": null,
"source": null
},
"sensitive_attributes": [],
"private": "bnVsbA=="
}
]
}
]
}
terraform.tfstate文件比foo.txt更为有趣。tfstate文件是 Terraform 存储计划中已应用资源的最后已知状态的地方。这使得 Terraform 能够检查与最后已知状态的差异,并在未来所需状态发生变化时,生成更新资源的计划。
接下来,让我们在main.tf中更改所需的状态,并查看再次应用配置时会发生什么。我们将main.tf更新为如下:
resource "local_file" "foo" {
content = "foo changed!"
filename = "${path.module}/foo.txt"
file_permissions = "0644"
}
请注意,我们已更改了foo.txt的内容,并为该资源添加了文件权限。现在,让我们应用所需状态,看看会发生什么:
$ terraform apply -auto-approve
local_file.foo: Refreshing state... [id=4bf3e335199107182c6f7638efaad377acc7f452]
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
-/+ destroy and then create replacement
Terraform will perform the following actions:
# local_file.foo must be replaced
-/+ resource "local_file" "foo" {
~ content = "foo!" -> "foo changed!" # forces replacement
~ file_permission = "0777" -> "0644" # forces replacement
~ id = "4bf3e335199107182c6f7638efaad377acc7f452" -> (known after apply)
# (2 unchanged attributes hidden)
}
Plan: 1 to add, 0 to change, 1 to destroy.
local_file.foo: Destroying... [id=4bf3e335199107182c6f7638efaad377acc7f452]
local_file.foo: Destruction complete after 0s
local_file.foo: Creating...
local_file.foo: Creation complete after 0s [id=5d6b2d23a15b5391d798c9c6a6b69f9a57c41aa5]
Apply complete! Resources: 1 added, 0 changed, 1 destroyed.
Terraform 能够确定资源已更改的属性,并为达到所需状态创建计划。正如计划输出中所示,1个添加,0个更改,1个销毁,表示本地的foo.txt文件将被删除并重新创建,因为文件权限的更改迫使该文件被替换。这个例子说明了,单一属性的更改可能会(但不总是)导致资源的删除和重建。请注意,我们为apply命令添加了-auto-approve标志。顾名思义,这将不会在应用计划之前提示审批。在使用该标志时,你可能需要小心,因为检查计划确保你期望的操作与计划中描述的操作一致是一个好习惯。
让我们看看foo.txt的新内容:
$ cat foo.txt
foo changed!
如你所见,foo.txt的内容已经更新,以反映所需的状态。现在,让我们检查一下目录:
.
├── foo.txt
├── main.tf
├── terraform.tfstate
└── terraform.tfstate.backup
请注意,创建了一个新文件,terraform.tfstate.backup。这是之前tfstate文件的副本,以防新的tfstate文件损坏或丢失。
默认情况下,tfstate文件是存储在本地的。在个人工作时,这完全没问题;然而,在团队合作时,就会变得难以与其他人共享最新的状态。此时,远程状态(www.terraform.io/language/state/remote)变得非常有用。我们在这里不讨论这一功能,但你应该了解它。
最后,我们将销毁我们已创建的资源:
$ terraform destroy
local_file.foo: Refreshing state... [id=5d6b2d23a15b5391d798c9c6a6b69f9a57c41aa5]
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
- destroy
Terraform will perform the following actions:
# local_file.foo will be destroyed
- resource "local_file" "foo" {
- content = "foo changed!" -> null
- directory_permission = "0777" -> null
- file_permission = "0644" -> null
- filename = "./foo.txt" -> null
- id = "5d6b2d23a15b5391d798c9c6a6b69f9a57c41aa5" -> null
}
Plan: 0 to add, 0 to change, 1 to destroy.
Do you really want to destroy all resources?
Terraform will destroy all your managed infrastructure, as shown above.
There is no undo. Only 'yes' will be accepted to confirm.
Enter a value: yes
local_file.foo: Destroying... [id=5d6b2d23a15b5391d798c9c6a6b69f9a57c41aa5]
local_file.foo: Destruction complete after 0s
Destroy complete! Resources: 1 destroyed.
运行terraform destroy将清理所有在所需状态中描述的资源。如果你检查你的目录,你会发现foo.txt文件已被删除。
恭喜!你已经掌握了 Terraform 的基础知识。我们从高层次了解了 Terraform 是如何操作的以及如何使用 Terraform CLI。我们创建了一个简单的本地文件资源,修改了它,并销毁了它。在下一节中,我们将讨论 Terraform 提供商,并探索利用这些提供商所打开的广阔世界。
理解 Terraform 提供商的基础知识
从本质上讲,Terraform 是一个平台,用于将表达的期望状态与外部系统进行对比。Terraform 与外部 API 交互的方式是通过名为 提供商 的插件。提供商负责描述其公开资源的架构,并实现与外部 API 的 创建、读取、更新和删除(CRUD)交互。提供商使 Terraform 能够将几乎所有外部 API 的资源表示为 Terraform 资源。
通过其成千上万的社区和验证过的提供商,Terraform 能够管理包括 Redis、Cassandra 和 MongoDB 等数据库,所有主要云服务提供商的云基础设施,Discord 和 SendGrid 等通信和消息服务,以及大量其他提供商。如果你有兴趣,可以在 Terraform 注册表中查看它们的列表 (registry.terraform.io/)。你只需编写、规划并应用,即可实现你所期望的基础设施。
在本节中,我们将基于使用本地提供商的经验,并将我们学到的知识扩展到使用与外部 API 交互的提供商。我们将为一组云资源定义期望的状态并进行配置。
定义和配置云资源
假设我们想要将基础设施部署到我们的云服务提供商。此时,我们将通过 hashicorp/azurerm 提供商使用 Microsoft Azure。在一个空目录中,让我们从编写一个简单的 main.tf 文件开始,如下所示:
# Configure the Azure provider
terraform {
required_providers {
azurerm = {
source = "hashicorp/azurerm"
version = "~> 3.0"
}
}
}
provider "azurerm" {
features {}
}
resource "azurerm_resource_group" "mygroup" {
name = "mygroup"
location = "southcentralus"
}
上述 Terraform 配置文件需要 hashicorp/azurerm 提供商,并在 southcentralus 区域定义了一个名为 mygroup 的资源组(资源组是 Azure 的一个概念,用于将基础设施资源组合在一起)。
要运行本节中的其他示例,你需要一个 Azure 账户。如果你没有 Azure 账户,可以注册一个免费账户,获得 $200 的 Azure 信用: azure.microsoft.com/en-us/free/。
一旦你拥有账户,请使用 Azure CLI 登录:
$ az login
上述命令将使你登录到 Azure 账户,并将默认上下文设置为你的主 Azure 订阅。要查看当前活跃的订阅,可以运行以下命令:
$ az account show
{
"environmentName": "AzureCloud",
"isDefault": true,
"managedByTenants": [],
"name": "mysubscription",
"state": "Enabled",
"tenantId": "888bf....db93",
"user": {
...
}
}
上述命令的输出显示了订阅名称和 Azure CLI 当前上下文的其他详细信息。azurerm 提供商将使用 Azure CLI 的认证上下文与 Azure API 进行交互。
现在我们已经在 Azure CLI 上完成了身份验证的 Azure 会话,接下来让我们使用init和apply来创建我们期望的状态。在包含main.tf文件的目录下,运行以下命令:
$ terraform init && terraform apply
terraform init将初始化目录,并下载最新的azurerm提供程序。通过指定~> 3.0版本约束,Terraform 会安装3.0.x系列中的最新版本。您应该会看到类似于以下的init输出:
Initializing the backend...
Initializing provider plugins...
- Finding hashicorp/azurerm versions matching "~> 3.0"...
- Installing hashicorp/azurerm v3.0.2...
- Installed hashicorp/azurerm v3.0.2 (signed by HashiCorp)
Terraform has created a lock file .terraform.lock.hcl to record the provider
selections it made above. Include this file in your version control repository
so that Terraform can guarantee to make the same selections by default when
you run "terraform init" in the future.
Terraform has been successfully initialized!
You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.
If you ever set or change modules or backend configuration for Terraform,
rerun this command to reinitialize your working directory. If you forget, other
commands will detect it and remind you to do so if necessary.
这段输出应该在使用 Terraform 初始化和应用基础设施规范部分中看过。初始化完成后,您将再次看到创建所需资源的计划。计划获得批准后,所需资源会被创建。输出应该像下面这样:
Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# azurerm_resource_group.rg will be created
+ resource "azurerm_resource_group" "mygroup" {
+ id = (known after apply)
+ location = "southcentralus"
+ name = "mygroup"
}
Plan: 1 to add, 0 to change, 0 to destroy.
Do you want to perform these actions?
Terraform will perform the actions described above.
Only 'yes' will be accepted to approve.
Enter a value: yes
azurerm_resource_group.mygroup: Creating...
azurerm_resource_group.mygroup: Creation complete after 2s [id=/subscriptions/8ec-...-24a/resourceGroups/mygroup]
从上面的输出中可以看到,资源组已经创建。
注意
如果您使用的是免费的 Azure 账户,可能在 southcentralus区域没有配额。您可能需要使用其他区域,如centralus或northeurope。要了解更多关于适合您的区域的信息,可以查看 Azure 地理位置指南:azure.microsoft.com/en-us/global-infrastructure/geographies/#geographies。
打开 Azure 门户并导航到资源组视图,您应该能看到以下内容:

图 13.1 – 在 Azure 中创建的资源组
在上面的截图中,我们可以看到我们新创建的 Azure 资源组,mygroup。
让我们看看在运行init和apply后,哪些新文件被添加到了本地目录中:
.
├── .terraform
│ └── providers
│ └── registry.terraform.io
│ └── hashicorp
│ └── azurerm
│ └── 3.0.2
│ └── darwin_arm64
│ └── terraform-provider-azurerm_v3.0.2_x5
├── .terraform.lock.hcl
├── main.tf
└── terraform.tfstate
与前面的部分类似,我们可以看到 Terraform 的锁定文件和状态文件。然而,在providers目录中,我们现在可以看到安装了azurerm提供程序。
让我们添加一些资源并应用它们。您可以在 Azure 提供程序文档中找到所有受支持资源的列表(registry.terraform.io/providers/hashicorp/azurerm/latest/docs)。我们将更新main.tf文件,包含以下资源:
resource "azurerm_resource_group" "mygroup" {
name = "mygroup"
location = "southcentralus"
}
resource "azurerm_service_plan" "myplan" {
name = "myplan"
resource_group_name = azurerm_resource_group.mygroup.name
location = azurerm_resource_group.mygroup.location
os_type = "Linux"
sku_name = "S1"
}
resource "random_integer" "ri" {
min = 10000
max = 99999
}
resource "azurerm_linux_web_app" "myapp" {
name = "myapp-${random_integer.ri.result}"
resource_group_name = azurerm_resource_group.mygroup.name
location = azurerm_service_plan.myplan.location
service_plan_id = azurerm_service_plan.myplan.id
site_config {
application_stack {
docker_image = "nginxdemos/hello"
docker_image_tag = "latest"
}
}
}
output "host_name" {
value = azurerm_linux_web_app.myapp.default_hostname
}
添加到前面的main.tf文件中的资源包括两个 Azure 资源,一个应用服务计划,一个 Linux Web 应用,以及一个random_integer资源。Azure 应用服务计划定义了一个区域性的计算基础设施部署,用于运行基于 Linux 的 Web 应用。Azure Linux Web 应用与 Azure 应用服务计划相关联,并配置为运行一个 hello world NGINX 演示容器镜像。random_integer资源需要提供一些随机输入,用于完全限定域名(FQDN)的配置,供 Linux Web 应用使用。
请注意变量的使用。例如,我们使用azurerm_resource_group.mygroup.name为azure_service_plan资源中的resource_group_name提供值。使用变量有助于最小化配置文件中的字符串字面值数量。这在进行修改时很有帮助,因为你可以在一个地方进行修改,而不是在每个字符串出现的地方修改。
此外,请注意使用输出变量host_name。这指示 Terraform 在terraform apply完成后输出host_name键,值为azurerm_linux_web_app.myapp.default_hostname。我们将使用此输出,以便在网站部署后更方便地打开它。
让我们再次运行terraform apply,看看会发生什么:
$ terraform apply
│
│ Error: Inconsistent dependency lock file
│
│ The following dependency selections recorded in the lock file are inconsistent with the current configuration:
│ - provider registry.terraform.io/hashicorp/random: required by this configuration but no version is selected
│
│ To update the locked dependency selections to match a changed configuration, run:
│ terraform init -upgrade
│
哎呀!terraform apply返回了一个错误,提示我们在配置中添加了一个新提供者,而上次没有这个。运行terraform init -upgrade,random模块将被添加:
$ terraform init -upgrade
Initializing the backend...
Initializing provider plugins...
- Finding latest version of hashicorp/random...
- Finding hashicorp/azurerm versions matching "~> 3.0"...
- Installing hashicorp/random v3.1.2...
- Installed hashicorp/random v3.1.2 (signed by HashiCorp)
- Using previously-installed hashicorp/azurerm v3.0.2
你应该会看到类似上面的输出,显示 Terraform 正在安装最新版本的hashicorp/random提供者。让我们看看在添加了提供者后,我们的目录现在是什么样子的:
.
├── .terraform
│ └── providers
│ └── registry.terraform.io
│ └── hashicorp
│ ├── azurerm
│ │ └── 3.0.2
│ │ └── darwin_arm64
│ │ └── terraform-provider-azurerm_v3.0.2_x5
│ └── random
│ └── 3.1.2
│ └── darwin_arm64
│ └── terraform-provider-random_v3.1.2_x5
如你所见,random提供者现在已安装。我们应该可以再次使用apply了:
$ terraform apply -auto-approve
azurerm_resource_group.mygroup: Refreshing state...
...
Plan: 3 to add, 0 to change, 0 to destroy.
Changes to Outputs:
+ host_name = (known after apply)
random_integer.ri: Creating...
random_integer.ri: Creation complete after 0s [id=18515]
azurerm_service_plan.myplan: Creating...
azurerm_service_plan.myplan: Still creating... [10s elapsed]
azurerm_service_plan.myplan: Creation complete after 12s [id=/subscriptions/8ec-...-24a/resourceGroups/mygroup/providers/Microsoft.Web/serverfarms/myplan]
azurerm_linux_web_app.myapp: Creating...
azurerm_linux_web_app.myapp: Still creating... [10s elapsed]
azurerm_linux_web_app.myapp: Still creating... [20s elapsed]
azurerm_linux_web_app.myapp: Creation complete after 28s [id=/subscriptions/8ec-...-24a/resourceGroups/mygroup/providers/Microsoft.Web/sites/myapp-18515]
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
Outputs:
host_name = "myapp-18515.azurewebsites.net"
我们省略了terraform apply的部分输出。需要注意的是,我们正在创建main.tf中描述的每个资源,它们已经成功配置,并且host_name包含了一个通用资源标识符(URI),用于访问新部署的 Web 应用。
获取host_name的 URI 并在浏览器中打开。你应该会看到如下内容:

图 13.2 – NGINX 运行在 Azure 应用服务中
如果你返回到 Azure 门户,你也会看到在你的资源组内创建的资源。
我希望你能花点时间通过定义和应用其他资源进行实验。一旦你掌握了使用提供者和一些基本语法,Terraform 将变得非常愉快。当你完成资源配置后,只需运行terraform destroy,它们将被删除。
在本节中,我们学习了使用提供者来操作云资源的一些基础知识。我们只需要使用几个提供者,但正如本节开头所讨论的那样,世上有成千上万的提供者。你很可能能够找到一个提供者来解决你的问题。然而,也可能有一些你希望用 Terraform 管理的 API 和资源,而没有现成的提供者。在下一节中,我们将为一个虚构的宠物商店构建一个 Terraform 提供者。
构建宠物商店 Terraform 提供者
即使 Terraform 提供商注册表 (registry.terraform.io/) 几乎涵盖了您能想到的每个提供商,但您可能需要的提供商尚未存在。也许您希望使用 Terraform 与公司内部的专有 API 资源进行交互。如果您想管理尚不存在于 Terraform 提供商生态系统中的资源,您将需要为该 API 编写一个提供商。好消息是,编写 Terraform 提供商相对简单。HashiCorp 的负责人提供了出色的文档、SDK 和工具,使构建提供商变得轻而易举。
在之前的章节中,我们学习了 Terraform 的基础知识以及如何使用提供商与本地和外部系统中的资源进行交互。我们能够构建云资源以部署运行在容器中的 Linux Web 应用程序。
在本节中,我们将在前几节的基础上构建,并学习如何构建我们自己的提供商。我们在本节中构建的 Terraform 提供商将暴露宠物资源,并与本地 docker-compose-hosted 宠物店服务交互,以模拟外部 API。
您将学习如何定义具有强大模式和验证的自定义资源,创建数据源,并为我们的宠物资源实施 CRUD 交互。最后,我们将讨论通过 Terraform 提供商注册表发布供全球使用的模块。
构建自定义提供商的资源
HashiCorp 提供了一套广泛的教程,用于构建自定义提供商 (learn.hashicorp.com/collections/terraform/providers)。如果您打算构建自己的自定义提供商,我强烈推荐查阅这些内容。
本节的代码位于 github.com/PacktPublishing/Go-for-DevOps/tree/main/chapter/13/petstore-provider。我们不会覆盖所有代码,但我们将深入探讨最有趣的部分。我尽力保持只保留最简单的实现;然而,简单并不总是优雅。
此外,我们的宠物店自定义提供商使用的是 Terraform 插件 SDK v2 (www.terraform.io/plugin/sdkv2/sdkv2-intro),而不是新的(在撰写本文时)Terraform 插件框架。我选择这条路线是因为大多数现有的提供商都使用 SDK v2,而 Terraform 插件框架 (www.terraform.io/plugin/framework) 尚未达到稳定性。如果您对权衡利弊感兴趣,请阅读 HashiCorp 的 Which SDK Should I Use? 文章 (www.terraform.io/plugin/which-sdk)。
现在我们已经建立了内容和学习的基础,让我们继续进行代码编写。
宠物店提供商
我们的宠物商店 Terraform 提供者只是另一个 Go 应用程序。Terraform 和提供者之间的大部分交互都是在 Terraform SDK 层面处理的,很少有东西会干扰到提供者开发者。让我们首先来看一下提供者的目录结构:
.
├── Makefile
├── docker-compose.yml
├── examples
│ └── main.tf
├── go.mod
├── go.sum
├── internal
│ ├── client # contains the grpc pet store API client
│ │ └── ...
│ ├── data_source_pet.go
│ ├── provider.go
│ ├── resource_pets.go
│ └── schema.go
└── main.go
就像我之前说的,这是一个标准的 Go 应用程序,入口点在main.go中。让我们从顶部开始,逐步查看文件。列表中的第一个是 Makefile:
HOSTNAME=example.com
NAMESPACE=gofordevops
NAME=petstore
BINARY=terraform-provider-${NAME}
VERSION=0.1.0
GOARCH := $(shell go env GOARCH)
GOOS := $(shell go env GOOS)
default: install
build:
go build -o ${BINARY}
install: build
mkdir -p ~/.terraform.d/plugins/${HOSTNAME}/${NAMESPACE}/${NAME}/${VERSION}/${GOOS}_${GOARCH}
mv ${BINARY} ~/.terraform.d/plugins/${HOSTNAME}/${NAMESPACE}/${NAME}/${VERSION}/${GOOS}_${GOARCH}
test:
go test ./... -v
testacc:
TF_ACC=1 go test ./... -v $(TESTARGS) -timeout 120m
上面的 Makefile 提供了一些有用的构建任务和环境配置。例如,make或make install会根据当前架构构建提供者,并将其放置在~/.terraform.d/plugins目录树中,这样我们就可以在本地使用该提供者,而无需将其发布到注册表中。
接下来,我们有docker-compose.yml文件。让我们看一下:
version: '3.7'
services:
petstore:
build:
context: ../../10/petstore/.
command:
- /go/bin/petstore
- --localDebug
ports:
- "6742:6742"
docker-compose.yml文件运行来自第十章的宠物商店服务,使用 GitHub Actions 自动化工作流,并在端口6742上暴露 gRPC 服务。宠物商店服务将宠物存储在内存中,因此要清除当前存储的宠物,只需重新启动该服务即可。稍后我们将在本节中讨论如何启动和停止服务。
接下来,我们来看examples/main.tf。让我们看看定义我们宠物资源的示例:
terraform {
required_providers {
petstore = {
version = "0.1.0"
source = "example.com/gofordevops/petstore"
}
}
}
...
resource "petstore_pet" "thor" {
name = "Thor"
type = "dog"
birthday = "2021-04-01T00:00:00Z"
}
resource "petstore_pet" "tron" {
name = "Tron"
type = "cat"
birthday = "2020-06-25T00:00:00Z"
}
data "petstore_pet" "all" {
depends_on = [petstore_pet.thor, petstore_pet.tron]
}
在前面的main.tf文件中,我们可以看到提供者已注册并配置为使用本地宠物商店服务。我们还可以看到定义了两个petstore_pet资源,分别是Thor和Tron。在这些资源之后,我们定义了一个petstore_pet数据源。稍后我们将更详细地介绍文件的各个部分。
我希望你在进入代码之前先看到main.tf,因为它会给你一个关于我们希望在提供者实现中实现的接口的概念。我相信看到提供者的使用将帮助你更好地理解提供者的实现。
剩下的源代码全部是 Go 语言编写的,所以与其从上到下查看,我打算跳到main.go中的入口点,深入了解实际的实现:
package main
import (
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/schema"
"github.com/hashicorp/terraform-plugin-sdk/v2/plugin"
petstore "github.com/PacktPublishing/Go-for-DevOps/chapter/13/petstore-provider/internal"
)
func main() {
plugin.Serve(&plugin.ServeOpts{
ProviderFunc: func() *schema.Provider {
return petstore.Provider()
},
})
}
好的,main.go是足够简单的。在main中,我们所做的就是通过 Terraform 插件 SDK v2 启动一个插件服务器,并为其提供我们的宠物商店提供者的实现。接下来,让我们看看internal/provider.go中的petstore.Provider实现:
// Provider is the entry point for defining the Terraform provider, and will create a new Pet Store provider.
func Provider() *schema.Provider {
return &schema.Provider{
Schema: map[string]*schema.Schema{
"host": {
Type: schema.TypeString,
Optional: true,
DefaultFunc: schema.EnvDefaultFunc("PETSTORE_HOST", nil),
},
},
ResourcesMap: map[string]*schema.Resource{
"petstore_pet": resourcePet(),
},
DataSourcesMap: map[string]*schema.Resource{
"petstore_pet": dataSourcePet(),
},
ConfigureContextFunc: configure,
}
}
provider.go 中只有两个函数。Provider 函数创建一个 *schema.Provider,该提供者描述了配置提供者的架构、提供者的资源、提供者的数据源以及用于初始化提供者的配置函数。提供者的资源映射通过字符串名称包含资源及其架构。每个结构的架构描述了与 Terraform 交互的领域特定语言,以操作其字段和资源层次结构。我们将在稍后详细查看这些结构的架构。
接下来,让我们来看看 provider.go 中的 configure 函数:
// configure builds a new Pet Store client the provider will use to interact with the Pet Store service
func configure(_ context.Context, data *schema.ResourceData) (interface{}, diag.Diagnostics) {
// Warning or errors can be collected in a slice type
var diags diag.Diagnostics
host, ok := data.Get("host").(string)
if !ok {
return nil, diag.Errorf("the host (127.0.0.1:443) must be provided explicitly or via env var PETSTORE_HOST")
}
c, err := client.New(host)
if err != nil {
return nil, append(diags, diag.Diagnostic{
Severity: diag.Error,
Summary: "Unable to create Pet Store client",
Detail: "Unable to connect to the Pet Store service",
})
}
return c, diags
}
configure 函数负责处理提供者配置。请注意,前面 Provider 架构中描述的 host 数据通过 data 参数提供。这是你将在整个提供者中看到的常见模式。我们使用 host 配置数据来构建宠物店服务的客户端。如果无法构建宠物店客户端,我们会将一个 diag.Diagnostic 结构附加到 diag.Diagnostics 切片中。这些诊断结构会通知 Terraform 提供者中发生的不同严重性的事件。在这种情况下,如果我们无法构建客户端,则会发生错误,并应将此信息反馈给用户。如果一切顺利,我们将返回 client 实例和一个空的 diag.Diagnostics 切片。
接下来,让我们来查看宠物店数据源。
实现宠物店数据源
宠物店数据源比资源实现简单一些,因为数据源是 Terraform 用来从外部 API 拉取数据的方式,并且在这种情况下是只读的。宠物店数据源定义在 internal/data_source_pet.go 中。
宠物店数据源有三个主要函数。我们将逐个查看它们。首先从 dataSourcePet 函数开始:
func dataSourcePet() *schema.Resource {
return &schema.Resource{
ReadContext: dataSourcePetRead,
Schema: getPetDataSchema(),
}
}
上述函数通过提供一个 getPetDataSchema 的数据架构来创建 *schema.Resource 数据源。ReadContext 期望一个函数,该函数负责翻译输入架构,查询外部 API,并返回与架构中定义的结构匹配的数据给 Terraform。
getPetDataSchema 的定义位于 internal/schema.go 中,查看它会对理解 dataSourcePetRead 中的代码有帮助。我们将把该函数分为两部分,输入部分和计算出的输出部分:
func getPetDataSchema() map[string]*schema.Schema {
return map[string]*schema.Schema{
"pet_id": {
Type: schema.TypeString,
Optional: true,
},
"name": {
Type: schema.TypeString,
Optional: true,
ValidateDiagFunc: validateName(),
},
"type": {
Type: schema.TypeString,
Optional: true,
ValidateDiagFunc: validateType(),
},
"birthday": {
Type: schema.TypeString,
Optional: true,
ValidateDiagFunc: validateBirthday(),
},
上述架构描述了宠物店宠物数据源的数据结构。每个顶级键都标记为可选,并将用于过滤数据源。例如,name 键指定它是可选的,类型为 string,并且应通过 validateName 函数进行验证。我们将在后续部分详细讨论验证。
以下是数据源输出的架构:
"pets": {
Type: schema.TypeList,
Computed: true,
Elem: &schema.Resource{
Schema: map[string]*schema.Schema{
"id": {
Type: schema.TypeString,
Computed: true,
},
"name": {
Type: schema.TypeString,
Computed: true,
},
"type": {
Type: schema.TypeString,
Computed: true,
},
"birthday": {
Type: schema.TypeString,
Computed: true,
},
},
},
},
}
}
pets 键包含所有 Computed 值,这意味着每个值都是只读的。它们表示查询的列表结果。
现在我们对使用的数据模式有了更好的理解,让我们继续实现 dataSourcePetRead:
// dataSourcePetRead finds pets in the pet store given an ID
func dataSourcePetRead(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics {
psClient, err := clientFromMeta(meta)
if err != nil {
return diag.FromErr(err)
}
pets, err := findPetsInStore(ctx, psClient, findPetsRequest{
Name: data.Get("name").(string),
Birthday: data.Get("birthday").(string),
Type: PetType(data.Get("type").(string)),
ID: data.Get("pet_id").(string),
})
if err != nil {
return diag.FromErr(err)
}
// always run
data.SetId(strconv.FormatInt(time.Now().Unix(), 10))
if err := data.Set("pets", flattenPets(pets)); err != nil {
return diag.FromErr(err)
}
return nil
}
在 dataSourcePetRead 中,我们为宠物店服务实例化了一个客户端,从提供的数据模式中填充过滤条件,然后将 pets 键在 data 参数中设置为从宠物店服务返回的宠物数据,格式为模式中指定的键值格式。flattenPets 函数负责将我们从宠物店服务接收到的 protobuf 结构转换为模式所期望的格式。如果你对实现感兴趣,它并不是特别优雅,但很简单。
我故意没有提到 data.SetId 函数。我们将它的值设置为一个每次都会从宠物店服务中获取数据的值。当该数据的 ID 改变时,Terraform 会识别数据发生了变化。这确保了每次执行该函数时,ID 都会发生变化。
在 configure 函数中,我们创建了宠物店客户端,那么我们是如何在数据源中访问该客户端的呢?我们可以在 clientFromMeta 函数中找到答案:
// clientFromMeta casts meta into a Pet Store client or returns an error
func clientFromMeta(meta interface{}) (*client.Client, error) {
psClient, ok := meta.(*client.Client)
if !ok {
return nil, errors.New("meta does not contain a Pet Store client")
}
return psClient, nil
}
clientFromMeta 函数接收传入 ReadContext 函数的 meta interface{} 参数,并将其转换为宠物店客户端。meta 变量包含在 configure 函数中返回的变量。这一点可能不像我们希望的那样直观,但它是有效的。
使用之前描述的代码和来自 internal/data_source_pet.go 的一些帮助函数,我们实现了一个过滤的数据源,连接到宠物店 API,可以在 Terraform 配置文件中使用。
接下来,让我们来看一下我们是如何处理宠物资源的 CRUD 操作的。
实现宠物资源
宠物资源的实现遵循了与宠物店数据源类似的许多模式,但对于宠物资源,我们还需要实现创建、更新和删除操作,而不仅仅是读取操作。除非另有说明,我们在讲解宠物资源实现时,代码都位于 internal/resource_pet.go 中。
我们先从检查 resourcePet 函数开始,该函数是在创建提供程序模式时被调用的:
func resourcePet() *schema.Resource {
return &schema.Resource{
CreateContext: resourcePetCreate,
ReadContext: resourcePetRead,
UpdateContext: resourcePetUpdate,
DeleteContext: resourcePetDelete,
Schema: getPetResourceSchema(),
Importer: &schema.ResourceImporter{
StateContext: schema.ImportStatePassthroughContext,
},
}
}
和宠物店数据源一样,宠物资源也定义了每个 CRUD 操作的处理程序以及一个模式。在讨论 CRUD 操作之前,我们先来看一下模式,它位于 internal/schema.go 中:
func getPetResourceSchema() map[string]*schema.Schema {
return map[string]*schema.Schema{
"id": {
Type: schema.TypeString,
Optional: true,
Computed: true,
},
"name": {
Type: schema.TypeString,
Required: true,
ValidateDiagFunc: validateName(),
},
"type": {
Type: schema.TypeString,
Required: true,
ValidateDiagFunc: validateType(),
},
"birthday": {
Type: schema.TypeString,
Required: true,
ValidateDiagFunc: validateBirthday(),
},
}
}
这里定义的模式比数据源模式更简单,因为我们没有定义查询过滤条件。请注意,id 键是计算得出的,但其他键都不是。id 值由宠物店服务生成,不需要由用户指定。
由于这些值是由用户以字符串形式指定的,因此验证变得更加重要。为了提供更好的用户体验,我们希望在值无效时向用户提供反馈。让我们来看一下如何通过validateType函数验证type字段:
func validateType() schema.SchemaValidateDiagFunc {
return validateDiagFunc(validation.StringInSlice([]string{
string(DogPetType),
string(CatPetType),
string(ReptilePetType),
string(BirdPetType),
}, true))
}
validateType函数返回一个通过每个有效的枚举值构造的验证。这防止用户输入宠物类型的字符串值,而该类型在宠物商店中不受支持。其余的验证采取了类似的方法来验证输入值的范围。
现在我们已经探讨了模式,准备开始探索 CRUD 操作。让我们从read操作开始:
// resourcePetRead finds a pet in the pet store by ID and populate the resource data
func resourcePetRead(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics {
psClient, err := clientFromMeta(meta)
if err != nil {
return diag.FromErr(err)
}
pets, err := findPetsInStore(ctx, psClient, findPetsRequest{ID: data.Id()})
if err != nil {
return diag.FromErr(err)
}
if len(pets) == 0 {
return nil
}
return setDataFromPet(pets[0], data)
}
resourcePetRead函数从meta参数获取宠物商店客户端,然后通过 ID 在商店中查找宠物。如果找到宠物,data参数将使用来自宠物的数据进行更新。
这足够简单。接下来,让我们看一下创建操作:
// resourcePetCreate creates a pet in the pet store
func resourcePetCreate(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics {
psClient, err := clientFromMeta(meta)
if err != nil {
return diag.FromErr(err)
}
pet := &client.Pet{Pet: &pb.Pet{}}
diags := fillPetFromData(pet, data)
ids, err := psClient.AddPets(ctx, []*pb.Pet{pet.Pet})
if err != nil {
return append(diags, diag.FromErr(err)...)
}
data.SetId(ids[0])
return diags
}
resourcePetCreate函数遵循类似的模式。不同之处在于宠物是从data参数中的字段构造的,然后调用宠物商店 API 将宠物添加到商店。最后,设置新宠物的 ID。
接下来,让我们看一下更新操作:
// resourcePetUpdate updates a pet in the pet store by ID
func resourcePetUpdate(ctx context.Context, data *schema.ResourceData, meta interface{}) diag.Diagnostics {
psClient, err := clientFromMeta(meta)
if err != nil {
return diag.FromErr(err)
}
pets, err := findPetsInStore(ctx, psClient, findPetsRequest{ID: data.Id()})
if err != nil {
return diag.FromErr(err)
}
if len(pets) == 0 {
return diag.Diagnostics{
{
Severity: diag.Error,
Summary: "no pet was found",
Detail: "no pet was found when trying to update the pet by ID",
},
}
}
pet := pets[0]
diags := fillPetFromData(pet, data)
if diags.HasError() {
return diags
}
if err := psClient.UpdatePets(ctx, []*pb.Pet{pet.Pet}); err != nil {
return append(diags, diag.FromErr(err)...)
}
return diags
}
resourcePetUpdate函数结合了读取和创建的部分。首先,我们需要检查宠物是否在商店中,并获取宠物数据。如果没有找到宠物,则返回错误。如果找到宠物,则更新宠物的字段,并调用宠物商店客户端上的UpdatePets。
删除操作相对简单,因此我在这里不再深入讨论。如果你愿意,你可以查看resourcePetDelete自己了解。
到目前为止,我们已经实现了宠物资源,并准备好查看我们的 Terraform 提供者如何运作。
运行宠物商店提供者
现在我们已经有了一个完整实现的宠物商店提供者,接下来有趣的部分就是运行它。从宠物商店提供者的根目录,运行以下命令。请确保 Docker 已经在运行:
$ docker-compose up -d
$ make
$ cd examples
$ terraform init && terraform apply
上述命令将使用docker-compose启动宠物商店服务,构建并安装提供者,将其移动到示例目录,最后使用init和apply来创建包含宠物的期望状态。
当init执行时,你应该看到如下内容:
Initializing the backend...
Initializing provider plugins...
- Finding example.com/gofordevops/petstore versions matching "0.1.0"...
- Installing example.com/gofordevops/petstore v0.1.0...
- Installed example.com/gofordevops/petstore v0.1.0 (unauthenticated)
耶!提供者已安装,Terraform 已准备好应用我们的资源。
在 Terraform 应用了资源后,你应该看到如下输出:
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Outputs:
all_pets = {
"birthday" = tostring(null)
"id" = "1648955761"
"name" = tostring(null)
"pet_id" = tostring(null)
"pets" = tolist([
{
"birthday" = "2020-06-25T00:00:00Z"
"id" = "495b1c94-6f67-46f2-9d4d-e84cc182d523"
"name" = "Tron"
"type" = "cat"
},
{
"birthday" = "2021-04-01T00:00:00Z"
"id" = "36e65cb2-18ea-4aec-a410-7bad64d7b00d"
"name" = "Thor"
"type" = "dog"
},
])
"type" = tostring(null)
}
thor = {
"36e65cb2-18ea-4aec-a410-7bad64d7b00d" = {
"birthday" = "2021-04-01T00:00:00Z"
"id" = "36e65cb2-18ea-4aec-a410-7bad64d7b00d"
"name" = "Thor"
"type" = "dog"
}
}
从之前的输出中,我们可以看到我们的两个资源,Tron和Thor,都已添加,并且在没有过滤器的情况下查询的数据源返回了每只宠物。最后,我们可以看到返回了thor输出,包含了Thor的数据。
我们再看看examples/main.tf,看看thor输出来自哪里:
variable "pet_name" {
type = string
default = "Thor"
}
data "petstore_pet" "all" {
depends_on = [petstore_pet.thor, petstore_pet.tron]
}
# Only returns Thor by name
output "thor" {
value = {
for pet in data.petstore_pet.all.pets :
pet.id => pet
if pet.name == var.pet_name
}
}
在之前的 main.tf 文件中,我们定义了一个值为 Thor 的 pet_name 变量。然后,我们查询了宠物商店数据源,没有提供过滤器,而是依赖文件中两个资源的完成。最后,我们输出了一个键为 thor 的值,这个查询仅在 pet.name 等于 var.pet_name 时才会匹配。这样,我们就过滤出了名为 Thor 的宠物数据。
现在,你可以使用到目前为止学到的任何 Terraform 技能来操作宠物商店资源。实际上,实现这一切的代码并不多。
发布自定义提供者
任何人都可以通过使用 GitHub 账户登录来将提供者发布到 Terraform 注册表。HashiCorp 提供了出色的文档,指导如何发布提供者。我们在本书中不会逐步讲解这个过程,因为《发布并将提供者发布到 Terraform 注册表》(learn.hashicorp.com/tutorials/terraform/provider-release-publish) 的文档如果你已经走到这一步,应该足够帮助你构建自己的 Terraform 提供者。
总结
在本章中,我们了解了基础设施即代码(IaC)的历史以及利用这一实践将软件开发与运维结合的优势,通过设置共享的上下文来表达并持续测试基础设施。我们了解了 Terraform 在 IaC 工具生态系统中的位置,以及如何使用它来描述期望的基础设施状态、修改现有基础设施、部署云基础设施,最后,创建我们自己的资源以自动化外部 API。你现在应该准备好了所需的工具,以改进自己的软件项目。
在下一章,我们将学习如何使用 Go 将应用部署到 Kubernetes,并基于此知识了解如何通过 Go 扩展它。我们将使 Kubernetes 用户能够将宠物作为自定义 Kubernetes 资源进行协调。
第十四章:在 Kubernetes 中部署和构建应用程序
很难夸大 Kubernetes 对 DevOps 世界的影响。自从 2014 年由 Google 开源以来,Kubernetes 在这几年中经历了迅猛的流行。在此期间,Kubernetes 已经成为编排云原生容器工作负载的主要解决方案,将其与 Apache Mesos 和 Docker Swarm 等编排工具区分开来。通过在异构环境上提供统一的 API,Kubernetes 已经成为跨云和混合环境部署应用程序的通用工具。
那么,Kubernetes 究竟是什么?根据它的文档,"Kubernetes 是一个可移植、可扩展的开源平台,用于管理容器化的工作负载和服务,既支持声明式配置也支持自动化"(kubernetes.io/docs/concepts/overview/what-is-kubernetes/)。这有很多内容需要解读。我会用不同的方式总结这一声明。Kubernetes 是一组 API 和抽象层,使得运行容器化应用程序变得更加容易。它提供了诸如服务发现、负载均衡、存储抽象与编排、自动化发布与回滚、自愈功能,以及密钥、证书和配置管理等服务。此外,如果 Kubernetes 没有直接提供你所需要的某些特定功能,可能在围绕 Kubernetes 核心构建的充满活力的开源生态系统中就有解决方案可用。Kubernetes 生态系统是一个庞大的工具集,可以帮助你实现运营目标,而无需重新发明轮子。
上述所有功能都通过 Kubernetes API 暴露出来,且具有无限的可编程性。
本章不会深入探讨 Kubernetes 的各个方面。要深入全面地探索 Kubernetes 需要多本书的内容。好消息是,关于 Kubernetes 有许多很棒的书籍:www.packtpub.com/catalogsearch/result?q=kubernetes。此外,Kubernetes 的社区驱动文档(kubernetes.io/docs/home/)是一个宝贵的资源,可以帮助你更深入地了解 Kubernetes。
本章的目标是为你使用 Go 编程 Kubernetes 提供一个起点。我们将从创建一个简单的 Go 程序开始,将 Kubernetes 资源部署到本地 Kubernetes 集群中,运行一个负载均衡的 HTTP 服务。然后,我们将学习如何通过自定义资源扩展 Kubernetes API,展示如何利用 Kubernetes 协同管理任何外部资源。我们将构建自定义的宠物资源,这些资源将存储在集群中运行的宠物商店服务中,以此说明管理外部资源的概念。通过本章学习,你将掌握有效使用 Kubernetes API 的知识,并理解 Kubernetes 的一些核心设计原则。
本章将涵盖以下主题:
-
与 Kubernetes API 交互
-
使用 Go 部署一个负载均衡的 HTTP 应用
-
使用自定义资源和操作员扩展 Kubernetes
-
构建一个宠物商店操作员
技术要求
本章将需要以下工具:
-
Docker:
docs.docker.com/get-docker/ -
operator-sdk:
sdk.operatorframework.io/docs/installation/ -
Tilt.dev:
docs.tilt.dev/install.html
本章的代码文件可以从 github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/14 下载
与 Kubernetes API 交互
在介绍中,我们将 Kubernetes API 当作一个整体进行讨论,尽管从某种意义上讲,它确实可以被这样理解。然而,我们一直在讨论的 Kubernetes API 实际上是由 Kubernetes 核心部分——控制平面 API 服务器提供的多个 API 的聚合。API 服务器暴露了一个 HTTP API,公开了聚合后的 API,并允许查询和操作如 Pods、Deployments、Services 和 Namespaces 等 API 对象。
在本节中,我们将学习如何使用 KinD 创建一个本地集群。我们将使用本地集群通过 kubectl 操作一个命名空间资源。我们将研究 Kubernetes 资源的基本结构,并查看如何通过它们的 Group、Version、Kind、Name,通常还有 Namespace,来定位单个资源。最后,我们将讨论身份验证和 kubeconfig 文件。本节将为我们通过 Go 在更低层次上与 Kubernetes API 进行交互做准备。
创建一个 KinD 集群
在开始与 Kubernetes API 交互之前,让我们使用KinD构建一个本地 Kubernetes 集群。这是一个工具,允许我们通过 Docker 在本地创建 Kubernetes 集群,而不是作为主机上的服务运行。要创建集群,请运行以下命令:
$ kind create cluster
上述命令将创建一个名为kind的集群。它将构建一个 Kubernetes 控制平面,并将kubectl的当前上下文设置为指向新创建的集群。
你可以通过运行以下命令列出kind创建的集群:
$ kind get clusters
kind
从get clusters的输出中可以看到,创建了一个名为kind的新集群。
使用 kubectl 与 API 交互
Kubernetes 提供了一个命令行工具用于与 API 交互,即kubectl。kubectl提供了一些很好的开发者体验功能,但其主要用途是执行kubectl操作:
$ kubectl create namespace petstore
上述命令创建了一个名为petstore的命名空间:
$ cat <<EOF | kubectl create -f -
apiVersion: v1
kind: Namespace
metadata:
name: petstore
EOF
上述命令通过内联 YAML 文档创建了相同的命名空间。接下来,让我们使用kubectl获取该命名空间的 YAML 文件:
$ kubectl get namespace petstore -o yaml
apiVersion: v1
kind: Namespace
metadata:
creationTimestamp: "2022-03-06T15:55:09Z"
labels:
kubernetes.io/metadata.name: petstore
name: petstore
resourceVersion: "2162"
uid: cddb2eb8-9c46-4089-9c99-e31259dfcd1c
spec:
finalizers:
- kubernetes
status:
phase: Active
上述命令获取了petstore命名空间并以.yaml格式输出了整个资源。请特别注意顶级键apiVersion、kind、metadata、spec和status。这些键中的值和结构在 Kubernetes 中的所有资源中都是通用的。
分组版本种类(GVK)命名空间名称
在 Kubernetes API 中,你可以通过其分组、种类、版本、名称以及通常的命名空间的组合来标识任何资源。我说“通常的命名空间”是因为并非所有资源都属于命名空间。命名空间是存在于命名空间之外的资源的一个例子(还有其他低级资源,如节点和持久卷)。然而,大多数其他资源,如 Pods、Services 和 Deployments,存在于命名空间中。在前一部分中提到的命名空间示例中,分组被省略了,因为它位于 Kubernetes 核心 API 中,并由 API 服务器假定。实际上,petstore命名空间的标识符是apiVersion: v1、kind: Namespace和metadata.name: petstore。
内化组、版本、种类、命名空间和名称的概念。这对于理解如何与 Kubernetes API 交互至关重要。
spec和status部分
Kubernetes 中的每个资源都有spec和status部分。资源的spec部分是描述资源期望状态的结构。Kubernetes 的任务是将系统的状态调整为该期望状态。在某些情况下,spec会描述外部系统的期望状态。例如,spec可以描述一个负载均衡器,包括期望的外部 IP。该资源的调和器将负责创建网络接口并设置路由,以确保 IP 路由到该特定的网络接口。
status部分是资源的一个结构,描述了资源的当前状态。它应该由 Kubernetes 进行修改,而不是由用户修改。例如,Deployment 的status包含给定 Deployment 的就绪副本数量。Deployment 的spec将包含所需的副本数。Kubernetes 的任务是朝着所需状态推进,并用资源的当前状态更新status。
随着本章的深入,我们将更多地了解spec和status。
身份验证
到目前为止,我们只是假设能够访问 Kubernetes 集群,但实际上这一点是由kind处理的,它能为kubectl设置默认上下文。kubectl的默认上下文存储在你的主目录中。你可以通过运行以下命令查看已设置的上下文:
$ cat ~/.kube/config
apiVersion: v1
clusters:
- cluster:
certificate-authority-data:
server: https://127.0.0.1:55451
name: kind-kind
contexts:
- context:
cluster: kind-kind
user: kind-kind
name: kind-kind
current-context: kind-kind
kind: Config
preferences: {}
users:
- name: kind-kind
user:
client-certificate-data:
client-key-data:
在上面的输出中,我省略了证书数据,以便提供更简洁的配置视图。它包含了我们建立与本地集群实例的安全连接所需的所有信息。请注意服务的地址、集群的名称以及用户的名称。
通过运行以下命令,我们可以获得kind集群的kubeconfig:
$ kind get kubeconfig --name kind > .tmp-kubeconfig
如果你cat该文件的内容,你会看到~/.kube/config中有一个非常相似的结构。kubeconfig文件是一个便捷的方式,用来封装与 API 服务器进行身份验证所需的信息,并与 Kubernetes 生态系统中的许多工具一起使用。例如,你可以通过以下命令覆盖kubectl的上下文,使用不同的kubeconfig:
$ KUBECONFIG=./.tmp-kubeconfig kubectl get namespaces
上述命令将列出kind集群中的所有命名空间,但它将使用我们刚刚创建的本地kubeconfig文件。
有多种工具可以用来管理你所使用的集群。其中一个很棒的例子是 Ahmet Alp Balkan 的kubectx(ahmet.im/blog/kubectx/),它可以帮助你流畅地管理多个集群。正如我之前提到的,充满活力的开源生态系统提供了各种各样的工具,让你使用 Kubernetes 的体验更加愉快。
最后,让我们清理petstore命名空间并删除我们的kind集群:
$ kubectl delete namespace petstore
$ kind delete cluster --name kind
在这一部分,我们学习了与 Kubernetes API 交互的基础知识以及 Kubernetes 资源的基本结构。我们能够创建本地的 Kubernetes 体验,并且已经准备好使用 Go 来构建与 Kubernetes 交互的应用程序。
在下一部分,我们将利用我们所学的 Kubernetes API 知识,构建一个 Go 应用程序,用于部署一个负载均衡的 HTTP 应用。
使用 Go 部署一个负载均衡的 HTTP 应用程序
现在我们对 Kubernetes API 及其暴露的资源有了更深入的了解,可以开始从kubectl转向使用 Go。
在本节中,我们将使用 Go 完成许多在上一节中使用kubectl做的相同操作。我们将使用默认上下文进行身份验证,并创建一个命名空间。然而,我们不会停在那里。我们将向集群部署一个负载均衡的 HTTP 应用程序,并在向服务发送请求时,查看日志如何流式输出到 STDOUT。
本节的代码可以在github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/14/workloads找到。我们接下来要讲解的示例可以通过以下命令执行:
$ kind create cluster --name workloads --config kind-config.yaml
$ kubectl apply -f https://raw.githubusercontent.com/kubernetes/ingress-nginx/main/deploy/static/provider/kind/deploy.yaml
$ kubectl wait --namespace ingress-nginx \
--for=condition=ready pod \
--selector=app.kubernetes.io/component=controller \
--timeout=90s
$ go run .
前面的命令将创建一个名为workloads的 KinD 集群,并使用一个配置文件来启用集群的主机网络入口。我们将使用入口来公开运行在集群中的服务,地址是localhost:port。然后,命令将部署 NGINX 入口控制器,并等待它准备就绪。最后,我们运行 Go 程序来部署我们的应用程序。在服务部署并运行后,打开浏览器并访问http://localhost:8080/hello。你应该会看到如下内容:

图 14.1 – 部署的 NGINX hello world
你应该能够看到请求日志流输出到 STDOUT。它们应该如下所示:
10.244.0.7 - - [07/Mar/2022:02:34:59 +0000] "GET /hello HTTP/1.1" 200 7252 "-" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/15.3 Safari/605.1.15" "172.22.0.1"
如果你刷新页面,你应该看到服务器名称变化,表明请求正在跨部署中的两个 Pod 副本进行负载均衡。按Ctrl + C来终止 Go 程序。
要销毁集群,请运行以下命令:
$ kind delete cluster --name workloads
前面的命令将删除名为workloads的kind集群。接下来,让我们探索这个 Go 应用程序,了解刚刚发生了什么。
一切从main开始
让我们直接进入代码,看看这个 Go 程序到底在做什么:
func main() {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
clientSet := getClientSet()
nsFoo := createNamespace(ctx, clientSet, "foo")
defer func() {
deleteNamespace(ctx, clientSet, nsFoo)
}()
deployNginx(ctx, clientSet, nsFoo, "hello-world")
fmt.Printf("You can now see your running service: http://localhost:8080/hello\n\n")
listenToPodLogs(ctx, clientSet, nsFoo, "hello-world")
// wait for ctrl-c to exit the program
waitForExitSignal()
}
在前面的代码中,我们建立了一个从背景上下文派生的上下文。在这个场景中,这基本上没有什么效果,但如果你需要取消一个正在运行时间过长的请求,它将是一个非常强大的工具。接下来,我们创建了clientSet,它是一个强类型的客户端,用来与 Kubernetes API 进行交互。然后我们在createNamespace、deployNginx和listenToPodLogs中使用了clientSet。最后,我们等待一个信号来终止程序。就这样!
接下来,让我们深入探讨每个函数,从getClientSet开始。
创建 ClientSet
让我们看看getClientSet:
func getClientSet() *kubernetes.Clientset {
var kubeconfig *string
if home := homedir.HomeDir(); home != "" {
kubeconfig = flag.String(
"kubeconfig",
filepath.Join(home, ".kube", "config"),
"(optional) absolute path to the kubeconfig file",
)
} else {
kubeconfig = flag.String(
"kubeconfig",
"",
"absolute path to the kubeconfig file",
)
}
flag.Parse()
// use the current context in kubeconfig
config, err := clientcmd.BuildConfigFromFlags(
"",
*kubeconfig,
)
panicIfError(err)
// create the clientSet
cs, err := kubernetes.NewForConfig(config)
panicIfError(err)
return cs
}
在前面的代码中,你可以看到我们构建了标志绑定,用来使用现有的~/.kube/config上下文,或者通过绝对文件路径接受kubeconfig文件。然后,我们使用这个标志或默认值构建配置。接着,这个配置被用来创建*kubernetes.ClientSet。正如我们在kubectl部分所学到的,kubeconfig包含了我们连接和认证服务器所需的所有信息。现在我们有了一个客户端,准备与 Kubernetes 集群进行交互。
接下来,让我们看看 ClientSet 的实际操作。
创建一个命名空间
现在我们有了一个 ClientSet,可以用它来创建我们需要部署的资源,以运行负载均衡的 HTTP 应用程序。我们来看看 createNamespace:
func createNamespace(
ctx context.Context,
clientSet *kubernetes.Clientset,
name string,
) *corev1.Namespace {
fmt.Printf("Creating namespace %q.\n\n", name)
ns := &corev1.Namespace{
ObjectMeta: metav1.ObjectMeta{
Name: name,
},
}
ns, err := clientSet.CoreV1().
Namespaces().
Create(ctx, ns, metav1.CreateOptions{})
panicIfError(err)
return ns
}
在上述代码中,我们构建了一个 corev1.Namespace 结构体,在 ObjectMeta 字段中提供名称。如果你还记得我们之前使用 kubectl 创建命名空间的 YAML 示例,这个字段对应的是 metadata.name。Kubernetes 资源的 Go 结构与它们的 YAML 表现非常接近。最后,我们使用 clientSet 通过 Kubernetes API 服务器创建命名空间并返回命名空间。metav1.CreateOptions 包含一些选项,用于更改 create 操作的行为,但我们在本书中不会探讨这个结构。
我们现在已经创建了用于部署应用程序的命名空间。接下来,让我们看看如何部署应用程序。
将应用程序部署到命名空间中
现在我们已经创建了 clientSet 和命名空间,准备好部署将代表我们应用程序的资源。我们来看看 deployNginx 函数:
func deployNginx(
ctx context.Context,
clientSet *kubernetes.Clientset,
ns *corev1.Namespace,
name string,
) {
deployment := createNginxDeployment(
ctx,
clientSet,
ns,
name,
)
waitForReadyReplicas(ctx, clientSet, deployment)
createNginxService(ctx, clientSet, ns, name)
createNginxIngress(ctx, clientSet, ns, name)
}
在上述代码中,我们创建了 NGINX 部署资源,并等待部署的副本准备就绪。部署就绪后,代码创建了服务资源,以便在部署中的 pods 之间进行负载均衡。最后,我们创建了 ingress 资源,以便在本地主机端口上公开该服务。
接下来,让我们查看这些函数,了解它们在做什么。
创建 NGINX 部署
部署应用程序的第一个函数是 createNginxDeployment:
func createNginxDeployment(
ctx context.Context,
clientSet *kubernetes.Clientset,
ns *corev1.Namespace,
name string,
) *appv1.Deployment {
var (
matchLabel = map[string]string{"app": "nginx"}
objMeta = metav1.ObjectMeta{
Name: name,
Namespace: ns.Name,
Labels: matchLabel,
}
[...]
)
deployment := &appv1.Deployment{
ObjectMeta: objMeta,
Spec: appv1.DeploymentSpec{
Replicas: to.Int32Ptr(2),
Selector: &metav1.LabelSelector{
MatchLabels: matchLabel,
},
Template: template,
},
}
deployment, err := clientSet.
AppsV1().
Deployments(ns.Name).
Create(ctx, deployment, metav1.CreateOptions{})
panicIfError(err)
return deployment
}
上述代码初始化了 matchLabel,它是一个键值对,将用于将 Deployment 与 Service 连接。我们还为 Deployment 资源初始化了 ObjectMeta,使用命名空间和 matchLabel。接下来,我们构建了一个包含规范的 Deployment 结构,期望有两个副本,使用我们之前构建的 matchLabel 的 LabelSelector,并且有一个 Pod 模板,运行一个容器,使用 nginxdemos/hello:latest 镜像,并在容器上暴露端口 80。最后,我们创建了部署,指定了命名空间和我们构建的 Deployment 结构。
现在我们已经创建了 Deployment,让我们看看如何等待 Deployment 中的 pods 变为就绪状态。
等待准备就绪的副本与期望副本匹配
当创建一个 Deployment 时,需要为每个副本创建 pod,并使其开始运行,才能处理请求。我们编写的 Kubernetes 或 API 请求并没有要求我们等待这些 pods。这只是为了提供一些用户反馈,并展示资源状态部分的用法。我们来看看如何等待 Deployment 的状态与期望的状态匹配:
func waitForReadyReplicas(
ctx context.Context,
clientSet *kubernetes.Clientset,
deployment *appv1.Deployment,
) {
fmt.Printf("Waiting for ready replicas in: %q\n", deployment.Name)
for {
expectedReplicas := *deployment.Spec.Replicas
readyReplicas := getReadyReplicasForDeployment(
ctx,
clientSet,
deployment,
)
if readyReplicas == expectedReplicas {
fmt.Printf("replicas are ready!\n\n")
return
}
fmt.Printf("replicas are not ready yet. %d/%d\n",
readyReplicas, expectedReplicas)
time.Sleep(1 * time.Second)
}
}
func getReadyReplicasForDeployment(
ctx context.Context,
clientSet *kubernetes.Clientset,
deployment *appv1.Deployment,
) int32 {
dep, err := clientSet.
AppsV1().
Deployments(deployment.Namespace).
Get(ctx, deployment.Name, metav1.GetOptions{})
panicIfError(err)
return dep.Status.ReadyReplicas
}
在前面的代码中,我们通过循环检查期望的副本数是否与就绪副本数匹配,若匹配则返回。如果不匹配,则等待一秒钟再试。这个代码并不非常健壮,但它展示了 Kubernetes 操作的目标导向性质。
现在我们已经有了一个正在运行的部署,我们可以构建一个 Service,以在部署中的 Pods 之间进行负载均衡。
创建用于负载均衡的 Service
部署中的两个 Pod 副本现在在80端口运行 NGINX 演示,但每个副本都有自己的接口。我们可以将流量定向到每个副本,但更方便的方法是定向到一个地址并进行负载均衡请求。让我们创建一个 Service 资源来实现这一点:
func createNginxService(
ctx context.Context,
clientSet *kubernetes.Clientset,
ns *corev1.Namespace,
name string,
) {
var (
matchLabel = map[string]string{"app": "nginx"}
objMeta = metav1.ObjectMeta{
Name: name,
Namespace: ns.Name,
Labels: matchLabel,
}
)
service := &corev1.Service{
ObjectMeta: objMeta,
Spec: corev1.ServiceSpec{
Selector: matchLabel,
Ports: []corev1.ServicePort{
{
Port: 80,
Protocol: corev1.ProtocolTCP,
Name: "http",
},
},
},
}
service, err := clientSet.
CoreV1().
Services(ns.Name).
Create(ctx, service, metav1.CreateOptions{})
panicIfError(err)
}
在前面的代码中,我们初始化了与部署中相同的matchLabel和ObjectMeta。然而,我们并没有创建一个 Deployment 资源,而是创建了一个 Service 资源结构,指定了要匹配的 Selector 和要暴露的传输控制协议(TCP)端口。Selector 标签是确保负载均衡器的后端池中包含正确 Pods 的关键。最后,我们像其他 Kubernetes 资源一样创建了 Service。
我们只剩下一步了。我们需要通过入口来暴露我们的服务,这样我们就可以通过本地机器上的端口将流量发送到集群中。
创建入口以在本地主机端口暴露我们的应用程序
此时,我们无法通过localhost:port访问我们的服务。我们可以通过kubectl将流量转发到集群中,但这个部分留给你自己探索。接下来我们将创建一个入口并在本地主机网络上打开一个端口。让我们来看一下如何创建入口资源:
func createNginxIngress(
ctx context.Context,
clientSet *kubernetes.Clientset,
ns *corev1.Namespace,
name string,
) {
var (
prefix = netv1.PathTypePrefix
objMeta = metav1.ObjectMeta{
Name: name,
Namespace: ns.Name,
}
ingressPath = netv1.HTTPIngressPath{
PathType: &prefix,
Path: "/hello",
Backend: netv1.IngressBackend{
Service: &netv1.IngressServiceBackend{
Name: name,
Port: netv1.ServiceBackendPort{
Name: "http",
},
},
},
}
ingress := &netv1.Ingress{
ObjectMeta: objMeta,
Spec: netv1.IngressSpec{
Rules: rules,
},
}
ingress, err := clientSet.
NetworkingV1().
Ingresses(ns.Name).
Create(ctx, ingress, metav1.CreateOptions{})
panicIfError(err)
}
在前面的代码中,我们初始化了一个前缀,与之前相同的objMeta,以及ingressPath,它将路径前缀/hello映射到我们创建的服务名和端口名。是的,Kubernetes 为我们做了将网络连接起来的“魔法”!接下来,我们按照之前结构的方式构建 Ingress 结构,并使用clientSet创建入口。通过这最后一步,我们使用 Go 和 Kubernetes API 部署了整个应用程序堆栈。
接下来,让我们回到main.go,看看如何使用 Kubernetes 流式传输 Pods 的日志,展示程序运行时的 HTTP 请求。
为 NGINX 应用程序流式传输 Pod 日志
Kubernetes API 提供了许多出色的功能来运行工作负载。其中最基础和最有用的功能之一就是能够访问正在运行的 Pods 的日志。让我们来看一下如何将多个运行中的 Pods 的日志流式传输到 STDOUT:
func listenToPodLogs(
ctx context.Context,
clientSet *kubernetes.Clientset,
ns *corev1.Namespace,
containerName string,
) {
// list all the pods in namespace foo
podList := listPods(ctx, clientSet, ns)
for _, pod := range podList.Items {
podName := pod.Name
go func() {
opts := &corev1.PodLogOptions{
Container: containerName,
Follow: true,
}
podLogs, err := clientSet.
CoreV1().
Pods(ns.Name).
GetLogs(podName, opts).
Stream(ctx)
panicIfError(err)
_, _ = os.Stdout.ReadFrom(podLogs)
}()
}
}
func listPods(
ctx context.Context,
clientSet *kubernetes.Clientset,
ns *corev1.Namespace,
) *corev1.PodList {
podList, err := clientSet.
CoreV1().
Pods(ns.Name).
List(ctx, metav1.ListOptions{})
panicIfError(err)
/* omitted some logging for brevity */
return podList
}
在前面的代码中,listenToPodLogs 列出了给定命名空间中的 pods,然后为每个 pod 启动了 go func。在 go func 中,我们使用 Kubernetes API 请求一个 podLogs 的流,它返回一个 io.ReadCloser,可以从 pod 中实时读取日志。然后我们告诉 STDOUT 从这个管道中读取,日志就会被输出到我们的 STDOUT。
如果你认为从正在运行的工作负载中获取日志会比这更困难,我想你不会是唯一一个这样想的人。Kubernetes 确实非常复杂,但由于一切都以 API 的形式暴露出来,这使得该平台极其灵活和可编程。
我们已经探索了除了 waitForExitSignal 的所有功能,这个功能相对简单,并没有为此处讲述的 Kubernetes 相关内容增添什么。如果你想了解它,可以参考源代码仓库。
通过这个使用 Kubernetes API 来以 Go 编程方式部署应用的示例,我希望你能从中获得一种力量感,去学习、构建,并且在与 Kubernetes API 交互时感到相对舒适。Kubernetes API 的内容远不止这些,而且它还在不断发展。事实上,在接下来的部分,我们将开始讨论如何通过自定义资源扩展 Kubernetes API。
扩展 Kubernetes 与自定义资源和操作符
在前面的部分,我们已经了解到 Kubernetes API 不仅仅是一个单一的 API,而是由一系列聚合的 API 组成,这些 API 由名为 操作符 和 控制器 的协作服务支持。操作符是对 Kubernetes 的扩展,它们利用自定义资源通过控制器来管理系统和应用。控制器是操作符的组件,执行某种资源的控制循环。自定义资源的控制循环是一个迭代过程,它观察资源的期望状态,并可能通过多个循环来推动系统状态朝着期望的状态发展。
之前的句子比较抽象。我喜欢换个方式总结。Kubernetes 是一个自动化平台。自动化是一系列步骤和决策树,驱动实现最终目标。我喜欢以类似的方式看待操作符。我认为编写操作符就像是将一份操作手册——人类完成操作活动的步骤——转化为让计算机执行的自动化。操作符和控制器就像是将操作知识结晶成代码,在 Kubernetes 中运行。
自定义资源可以表示任何内容。它们可以是与 Kubernetes 资源相关的事物,也可以是完全与 Kubernetes 无关的外部事物。举个例子,关于集群工作负载的自定义资源,在第九章,使用 OpenTelemetry 进行可观察性中,我们讨论了 OTel 收集器并通过其容器镜像在 docker-compose 中部署它,但我们也可以使用 Kubernetes 操作器来做同样的事情,在 Kubernetes 集群中运行它。OTel 操作器暴露了一个自定义资源,像下面这样:
apiVersion: opentelemetry.io/v1alpha1
kind: OpenTelemetryCollector
metadata:
name: simplest
spec:
config: |
receivers:
otlp:
protocols:
grpc:
http:
processors:
exporters:
logging:
service:
pipelines:
traces:
receivers: [otlp]
processors: []
exporters: [logging]
在前面的代码块中,我们看到一个自定义资源,描述了来自 github.com/open-telemetry/opentelemetry-operator 的 OTel 收集器。这个自定义资源以特定领域的语言描述了 OpenTelemetry 操作器应该如何配置和运行 OpenTelemetry 收集器。然而,一个自定义资源也可以像我们在下一部分看到的那样,轻松表示一个自定义的 Pet 资源,用于表示宠物店中的宠物。
你还记得如何识别前述资源的组、版本、类型、命名空间和名称吗?答案是 group: opentelemetry.io、version: v1alpha1、kind: OpenTelemetryCollector、namespace: default 和 name: simplest。
在这一部分,我想强调的是,如果有人去除掉 Pods、节点、存储、网络以及 Kubernetes 容器工作负载调度的其他部分,只剩下 Kubernetes API 服务器,它仍然会是一个极其有用的软件。在这一部分,我们将介绍一些关于操作器、自定义资源定义(CRDs)、控制器以及 Kubernetes API 服务器强大功能的背景知识。我们无法深入覆盖所有内容,但这次概览将有助于我们实现第一个操作器,并希望能激励你深入学习如何扩展 Kubernetes API。
自定义资源定义
CRDs 是可以应用于 Kubernetes 集群的资源,用于为自定义资源创建新的 RESTful 资源路径。让我们来看一下 Kubernetes 文档中关于 CronJob 的示例:kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definitions/#create-a-customresourcedefinition。
apiVersion: apiextensions.k8s.io/v1
kind: CustomResourceDefinition
metadata:
# name must be in the form: <plural>.<group>
name: crontabs.stable.example.com
spec:
# group name to use for REST API: /apis/<group>/<version>
group: stable.example.com
# list of versions supported by this CustomResourceDefinition
versions:
- name: v1
# Each version can be enabled/disabled by Served flag.
served: true
# only one version must be marked as the storage version.
storage: true
schema:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
cronSpec:
type: string
image:
type: string
replicas:
type: integer
# either Namespaced or Cluster
scope: Namespaced
names:
plural: crontabs
singular: crontab
kind: CronTab
shortNames:
- ct
正如你从之前的 YAML 中看到的,CRD 被指定为 Kubernetes 中的任何资源。CRD 资源有group、version、kind和name,但在spec中,你可以看到描述新资源类型的元数据,并使用 OpenAPI V3 来描述模式。同样,注意到 spec 包含了自定义资源的 group、version 和 kind。如 YAML 结构所示,自定义资源可以在任何给定时间提供多个版本,但只能有一个版本标记为存储版本。
在下一节中,我们将讨论 Kubernetes 如何能够仅存储一个版本但提供多个版本。
自定义资源版本管理和转换
如前节所述,Kubernetes 仅存储资源的一个版本。资源的新版本通常在资源的模式发生变化时引入——例如,添加了一个新字段或对模式进行了其他变更。在这种情况下,Kubernetes 需要某种方法来在资源版本之间进行转换。Kubernetes 的做法是使用转换 Webhook。这意味着你可以注册一个 Webhook,将资源的存储版本转换为请求的版本。这形成了一个中心和分支模型,其中中心是存储版本,分支是其他受支持的版本。你可以在 Kubernetes 文档中看到一个示例:kubernetes.io/docs/tasks/extend-kubernetes/custom-resources/custom-resource-definition-versioning/#configure-customresourcedefinition-to-use-conversion-webhooks。
稍微消化一下这个概念。这是任何 API 平台都应该提供的强大功能。拥有一种标准化的方法来将一个 API 版本转换为另一个版本,使得在微服务环境中更容易平滑地采用组件。
结构化模式、验证和默认值
正如我们在前面的 CronJob CRD 规格示例中看到的,我们可以使用 OpenAPI 来描述资源的强类型模式。这对于为需要与 API 交互的编程语言生成 API 客户端非常有益。此外,我们还可以描述各种验证,以确保资源的结构和值的各个方面。例如,我们可以描述哪些字段是必需的,值的有效范围,字符串的有效模式,以及结构和值的许多其他方面。此外,我们还可以为字段提供默认值并在模式中指定它们。
除了架构之外,API 服务器还暴露了验证和变更的 Webhooks,这些 Webhooks 可以填补架构失败的空白——例如,如果你想基于某些超出架构范围的逻辑来验证或更改资源。这些 Webhooks 可以被用来改善开发者在使用自定义资源时的体验,比起接受可能无效的资源,或者默认某些难以计算的值,从而避免用户需要提供这些值。
控制器
协调的核心是控制器,它为特定资源类型执行控制循环。控制器监视 Kubernetes API 中的资源类型,并注意到资源发生了变化。控制器接收到资源的新版本,观察期望的状态,观察它控制的系统的当前状态,并试图推进系统状态朝着资源中表达的期望状态发生变化。控制器并不是根据资源版本之间的差异来操作,而是根据当前的期望状态进行操作。我注意到,对于新接触控制器开发的人来说,通常会倾向于只考虑基于两个资源版本之间的差异来行动,但这并不推荐。
通常,控制器能够并发地协调多个资源,但永远不会并发地协调相同的资源。这简化了协调模型的实现。
此外,大多数控制器一次只会有一个领导者。例如,如果有两个操作符实例在运行,只有一个会成为领导者,另一个则处于空闲状态,等待当另一个进程崩溃时成为领导者。
站在巨人的肩膀上
我相信这听起来非常复杂,实际上也确实如此。然而,我们可以幸运地依赖一些开创性的项目,这些项目使得构建操作符、控制器和 CRD 变得更加容易。Kubernetes 操作符有着一个充满活力且日益壮大的生态系统。
在下一节中,我们将依赖的项目包括 controller-runtime (github.com/kubernetes-sigs/controller-runtime),kubebuilder (github.com/kubernetes-sigs/kubebuilder) 和 operator-sdk (github.com/operator-framework/operator-sdk)。controller-runtime 提供了一组 Go 库,使得构建控制器更加简单,且在 kubebuilder 和 operator-sdk 中都有使用。kubebuilder 是一个用于构建 Kubernetes API 的框架,提供了一套工具,可以轻松生成 API 结构、控制器和 Kubernetes API 相关的清单。operator-sdk 是操作员框架 (github.com/operator-framework) 的一个组件,它扩展自 kubebuilder,并试图解决操作员开发者面临的生命周期、发布和其他更高层次的问题。
如果你对一个雄心勃勃的项目感兴趣,想要扩展 Kubernetes API 来创建声明式集群基础设施,并使 Kubernetes 能够构建新的 Kubernetes 集群,我鼓励你查看 Cluster API (github.com/kubernetes-sigs/cluster-api)。
我希望本节内容让你对 Kubernetes API 的强大功能感到惊叹,并激发你想要进一步学习的兴趣。我相信我们已经涵盖了扩展 Kubernetes API 的基础知识,因此我们可以毫不费力地着手构建自己的协调器。在接下来的章节中,我们将使用 operator-sdk 来构建一个 Pet 资源和操作员,以协调宠物商店服务中的宠物。
构建一个宠物商店操作员
本节中,我们将基于上一节中关于 CRD、操作员和控制器的背景信息,来实现我们自己的操作员。该操作员将只有一个 CRD,Pet,并且只有一个控制器来协调这些 Pet 资源。Pet 的期望状态将与我们在前几章中使用的宠物商店服务进行协调。
正如我们在上一节中讨论的,这将是一个使用 Kubernetes 控制循环来协调一个与 Kubernetes 中其他资源无依赖关系的资源状态的示例。记住,你可以在 CRD 中建模任何内容,并使用 Kubernetes 作为构建任何类型资源的强大 API 的工具。
在本节中,你将学习如何从头开始构建一个操作符。你将定义一个新的 CRD 和控制器。你将研究构建工具和不同的代码生成工具,用于消除大部分的模板代码。你将把控制器和宠物商店服务部署到本地的kind集群,并学习如何使用Tilt.dev实现更快的内循环开发周期。此仓库的代码位于github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/14/petstore-operator。
初始化新操作符
在本节中,我们将使用operator-sdk命令行工具初始化新的操作符。这将用于为我们的操作符搭建项目结构:
$ operator-sdk init --domain example.com --repo github.com/Go-for-DevOps/chapter/14/petstore-operator
Writing kustomize manifests for you to edit...
Writing scaffold for you to edit...
Get controller runtime:
$ go get sigs.k8s.io/controller-runtime@v0.11.0
Update dependencies:
$ go mod tidy
Next: define a resource with:
$ operator-sdk create api
通过执行前面的命令,operator-sdk将使用一个示例域来搭建一个新的操作符项目,这将形成我们未来 CRD 的组名后缀。–repo标志基于书本代码的仓库,但你应该希望它反映你项目的仓库路径,或者省略它并让它使用默认值。让我们在搭建之后看看仓库中有什么:
$ ls -al
total 368
-rw------- 1 david staff 776 Feb 27 10:15 Dockerfile
-rw------- 1 david staff 9884 Feb 27 10:16 Makefile
-rw------- 1 david staff 261 Feb 27 10:16 PROJECT
drwx------ 8 david staff 256 Feb 27 10:16 config/
-rw------- 1 david staff 3258 Feb 27 10:16 go.mod
-rw-r--r-- 1 david staff 94793 Feb 27 10:16 go.sum
drwx------ 3 david staff 96 Feb 27 10:15 hack/
-rw------- 1 david staff 2791 Feb 27 10:15 main.go
前面的列表展示了项目的顶层结构。Dockerfile 包含构建控制器镜像的命令。Makefile 包含各种有用的任务;然而,在本教程中我们不会多加使用它。PROJECT文件包含关于操作符的元数据。config目录包含描述和部署操作符及 CRD 到 Kubernetes 所需的清单。hack目录包含一个模板许可头,将被添加到生成的文件中,它是放置有用的开发或构建脚本的好地方。其余的文件只是常规的 Go 应用程序代码。
现在我们对为我们搭建的结构有了大致了解,可以继续生成我们的Pet资源和控制器:
$ operator-sdk create api --group petstore --version v1alpha1 --kind Pet --resource --controller
Writing kustomize manifests for you to edit...
Writing scaffold for you to edit...
api/v1alpha1/pet_types.go
controllers/pet_controller.go
Update dependencies:
$ go mod tidy
Running make:
$ make generate
go: creating new go.mod: module tmp
# ... lots of go mod output ...
Next: implement your new API and generate the manifests (e.g. CRDs,CRs) with:
$ make manifests
通过执行前面的命令,我已经指示operator-sdk在petstore组中创建一个新的 API,使用Pet种类的v1alpha1版本,并生成该类型的 CRD 和控制器。请注意,命令创建了api/v1alpha1/pet_types.go和controllers/pet_controller.go,然后运行了make generate和make manifests。很快,我们将看到code注释在这两个 Go 文件中导致make generate和make manifests生成 CRD 清单以及 Kubernetes 的基于角色的授权控制(RBAC)用于控制器。操作符的 RBAC 条目将赋予控制器对新生成的资源执行 CRUD 操作的权限。CRD 清单将包含我们新创建资源的架构。
接下来,让我们快速查看已更改的文件:
$ git status
M PROJECT
A api/v1alpha1/groupversion_info.go
A api/v1alpha1/pet_types.go
A api/v1alpha1/zz_generated.deepcopy.go
A config/crd/bases/petstore.example.com_pets.yaml
A config/crd/kustomization.yaml
A config/crd/kustomizeconfig.yaml
A config/crd/patches/cainjection_in_pets.yaml
A config/crd/patches/webhook_in_pets.yaml
A config/rbac/pet_editor_role.yaml
A config/rbac/pet_viewer_role.yaml
A config/samples/kustomization.yaml
A config/samples/petstore_v1alpha1_pet.yaml
A controllers/pet_controller.go
A controllers/suite_test.go
M go.mod
M main.go
正如我们所见,文件进行了相当多的更改。我不会深入讨论每一项更改。最值得注意的是生成了config/crd/bases/petstore.example.com_pets.yaml,它包含了我们的Pet资源的 CRD。在运维项目中,通常将 API 中的资源描述放在api/目录下,Kubernetes 清单文件放在config/目录下,控制器放在controllers/目录下。
接下来,让我们看看在api/v1alpha1/pet_types.go中生成了什么内容:
// EDIT THIS FILE! THIS IS SCAFFOLDING FOR YOU TO OWN!
// NOTE: json tags are required. Any new fields you add must have json tags for the fields to be serialized.
// PetSpec defines the desired state of Pet
type PetSpec struct {
// INSERT ADDITIONAL SPEC FIELDS - desired state of cluster
// Important: Run "make" to regenerate code after modifying this file
// Foo is an example field of Pet. Edit pet_types.go to remove/update
Foo string `json:"foo,omitempty"`
}
// PetStatus defines the observed state of Pet
type PetStatus struct {
// INSERT ADDITIONAL STATUS FIELD - define observed state of cluster
// Important: Run "make" to regenerate code after modifying this file
}
上面的代码展示了pet_types.go文件中的一个代码片段。create api命令生成了一个包含spec和status的Pet资源。PetSpec包含一个名为Foo的字段,该字段会序列化为键foo,并且在创建或更新资源时是可选的。status目前为空。
请注意文件中的注释。它们指示我们在这里添加新字段到类型中,并在完成后运行make,以确保config/目录下的 CRD 清单文件得到更新。
现在,让我们来看一下文件的其余部分:
//+kubebuilder:object:root=true
//+kubebuilder:subresource:status
// Pet is the Schema for the pets API
type Pet struct {
metav1.TypeMeta `json:",inline"`
metav1.ObjectMeta `json:"metadata,omitempty"`
Spec PetSpec `json:"spec,omitempty"`
Status PetStatus `json:"status,omitempty"`
}
//+kubebuilder:object:root=true
// PetList contains a list of Pet
type PetList struct {
metav1.TypeMeta `json:",inline"`
metav1.ListMeta `json:"metadata,omitempty"`
Items []Pet `json:"items"`
}
func init() {
SchemeBuilder.Register(&Pet{}, &PetList{})
}
在这里,我们可以看到Pet和PetList的定义,它们都会在接下来的架构构建器中注册。请注意//+kubebuilder build注释。这些构建注释指示kubebuilder如何生成 CRD 清单文件。
请注意,Pet已经定义了带有json标签的spec和status,这些标签我们在之前处理过的其他 Kubernetes 资源中也见过。Pet还包括TypeMeta,它提供了 Kubernetes 的组版本种类信息,以及ObjectMeta,它包含了资源的名称、命名空间和其他元数据。
使用这些结构,我们已经拥有一个功能完全的自定义资源。然而,当前的资源并没有表示我们希望用来表示宠物资源的字段,因此需要更新以更好地表示我们的宠物结构。
接下来,让我们看看在controllers/pet_controller.go中为PetReconciler生成了什么内容,PetReconciler是运行对宠物资源进行对账的控制循环的控制器:
type PetReconciler struct {
client.Client
Scheme *runtime.Scheme
}
//+kubebuilder:rbac:groups=petstore.example.com,resources=pets,verbs=get;list;watch;create;update;patch;delete
//+kubebuilder:rbac:groups=petstore.example.com,resources=pets/status,verbs=get;update;patch
//+kubebuilder:rbac:groups=petstore.example.com,resources=pets/finalizers,verbs=update
func (r *PetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (ctrl.Result, error) {
_ = log.FromContext(ctx)
return ctrl.Result{}, nil
}
func (r *PetReconciler) SetupWithManager(mgr ctrl.Manager) error {
return ctrl.NewControllerManagedBy(mgr).
For(&petstorev1alpha1.Pet{}).
Complete(r)
}
在上面的代码中,我们可以看到一个PetReconciler类型,它嵌入了一个client.Client,这是一个通用的 Kubernetes API 客户端,还有一个*runtime.Scheme,它包含已注册的已知类型和架构。如果我们继续往下看,可以看到一系列//+kubebuilder:rbac build注释,它们指示代码生成器为控制器创建 RBAC 权限,以便它能够操作Pet资源。接下来,我们可以看到Reconcile func,它会在每次资源发生更改且需要与宠物商店对账时被调用。最后,我们可以看到SetupWithManager函数,它从main.go中被调用,以启动控制器并告知它和管理器控制器将会对哪个资源进行对账。
我们已经覆盖了脚手架过程中的重要变化。接下来我们可以继续实现我们的Pet资源,以反映宠物商店中的领域模型。我们在宠物商店中的pet实体有三个可变的必填属性,分别是Name、Type和Birthday,以及一个只读属性ID。我们需要将这些属性添加到我们的Pet资源中,以便暴露给 API:
// PetType is the type of the pet. For example, a dog.
// +kubebuilder:validation:Enum=dog;cat;bird;reptile
type PetType string
const (
DogPetType PetType = "dog"
CatPetType PetType = "cat"
BirdPetType PetType = "bird"
ReptilePetType PetType = "reptile"
)
// PetSpec defines the desired state of Pet
type PetSpec struct {
// Name is the name of the pet
Name string `json:"name"`
// Type is the type of pet Type PetType `json:"type"`
// Birthday is the date the pet was born
Birthday metav1.Time `json:"birthday"`
}
// PetStatus defines the observed state of Pet
type PetStatus struct {
// ID is the unique ID for the pet
ID string `json:"id,omitempty"`
}
以上是我对Pet所做的代码修改,目的是反映宠物商店服务的领域模型。请注意PetType类型前面的// +kubebuilder:validation:Enum注解。这是告诉 CRD 清单生成器,模式应当添加验证,确保PetSpec中的Type字段只能提供这些字符串。此外,注意spec中的每个字段都没有omitempty JSON 标签。这将告诉 CRD 清单生成器,这些字段是必填的。
Pet的状态只有一个ID字段,这个字段可以为空。它将存储从宠物商店服务返回的唯一标识符。
现在我们已经定义了我们的Pet,让我们在控制器循环中协调pet和宠物商店:
// Reconcile moves the current state of the pet to be the desired state described in the pet.spec.
func (r *PetReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, errResult error) {
logger := log.FromContext(ctx)
pet := &petstorev1.Pet{}
if err := r.Get(ctx, req.NamespacedName, pet); err != nil {
if apierrors.IsNotFound(err) {
logger.Info("object was not found")
return reconcile.Result{}, nil
}
logger.Error(err, "failed to fetch pet from API server")
// this will cause this pet resource to be requeued
return ctrl.Result{}, err
}
helper, err := patch.NewHelper(pet, r.Client)
if err != nil {
return ctrl.Result{}, errors.Wrap(err, "failed to create patch helper")
}
defer func() {
// patch the resource
if err := helper.Patch(ctx, pet); err != nil {
errResult = err
}
}()
if pet.DeletionTimestamp.IsZero() {
// the pet is not marked for delete
return r.ReconcileNormal(ctx, pet)
}
// pet has been marked for delete
return r.ReconcileDelete(ctx, pet)
}
上述代码已被添加到对pet资源的协调中。当我们从 API 服务器接收到变化时,我们并没有获得很多信息。我们只会得到变化的宠物的NamespacedName。NamespacedName包含了发生变化的宠物的命名空间和名称。记住,PetReconciler嵌入了一个client.Client。它为我们提供了访问 Kubernetes API 服务器的权限。我们使用Get方法请求需要协调的宠物。如果没有找到该宠物,我们会返回一个空的协调结果和nil错误。这通知控制器等待另一次变化发生。如果请求过程中发生了错误,我们会返回空的协调结果和错误。如果错误不为nil,协调器会再次尝试并进行指数退避。
如果我们能够成功获取宠物信息,我们将创建一个补丁助手,这将允许我们在协调循环过程中追踪Pet资源的变化,并在协调循环结束时将资源变化补丁回传给 Kubernetes API 服务器。defer确保我们会在Reconcile函数的最后进行补丁操作。
如果宠物没有设置删除时间戳,那么我们知道 Kubernetes 并未标记该资源为删除状态,因此我们调用ReconcileNormal,在这个过程中我们会尝试将期望的状态持久化到宠物商店中。否则,我们会调用ReconcileDelete将宠物从宠物商店中删除。
接下来我们来看一下ReconcileNormal,并理解当我们遇到非删除的宠物资源状态变化时应该做什么:
func (r *PetReconciler) ReconcileNormal(ctx context.Context, pet *petstorev1.Pet) (ctrl.Result, error) {
controllerutil.AddFinalizer(pet, PetFinalizer)
psc, err := getPetstoreClient()
if err != nil {
return ctrl.Result{}, errors.Wrap(err, "unable to construct petstore client")
}
psPet, err := findPetInStore(ctx, psc, pet)
if err != nil {
return ctrl.Result{}, errors.Wrap(err, "failed trying to find pet in pet store")
}
if psPet == nil {
// no pet was found, create a pet in the store
err := createPetInStore(ctx, pet, psc)
return ctrl.Result{}, err
}
// pet was found, update the pet in the store
if err := updatePetInStore(ctx, psc, pet, psPet.Pet); err != nil {
return ctrl.Result{}, err
}
return ctrl.Result{}, nil
}
在ReconcileNormal中,我们始终确保PetFinalizer已经被添加到资源中。Finalizer 是 Kubernetes 知道何时可以垃圾回收资源的一种方式。如果资源上仍然有 finalizer,Kubernetes 将不会删除该资源。在控制器中,当资源具有需要在删除之前清理的外部资源时,finalizer 非常有用。在这种情况下,我们需要在 Kubernetes Pet资源被删除之前从宠物商店中移除Pet。如果我们不这么做,可能会在宠物商店中留下从未被删除的宠物。
在设置 finalizer 之后,我们构建一个宠物商店客户端。我们在这里不做更多细节说明,但可以简单说,它构建了一个用于宠物商店服务的 gRPC 客户端。通过宠物商店客户端,我们查询商店中的宠物。如果找不到该宠物,我们会在商店中创建一个;否则,我们会更新商店中的宠物,以反映 Kubernetes Pet资源中指定的期望状态。
让我们快速看一下createPetInStore函数:
func createPetInStore(ctx context.Context, pet *petstorev1.Pet, psc *psclient.Client) error {
pbPet := &pb.Pet{
Name: pet.Spec.Name,
Type: petTypeToProtoPetType(pet.Spec.Type),
Birthday: timeToPbDate(pet.Spec.Birthday),
}
ids, err := psc.AddPets(ctx, []*pb.Pet{pbPet})
if err != nil {
return errors.Wrap(err, "failed to create new pet")
}
pet.Status.ID = ids[0]
return nil
}
当我们在宠物商店创建宠物时,我们会在 gRPC 客户端上调用AddPets,传递 Kubernetes Pet资源的期望状态,并在 Kubernetes Pet资源的状态中记录ID。
让我们继续看updatePetInStore函数:
func updatePetInStore(ctx context.Context, psc *psclient.Client, pet *petstorev1.Pet, pbPet *pb.Pet) error {
pbPet.Name = pet.Spec.Name
pbPet.Type = petTypeToProtoPetType(pet.Spec.Type)
pbPet.Birthday = timeToPbDate(pet.Spec.Birthday)
if err := psc.UpdatePets(ctx, []*pb.Pet{pbPet}); err != nil {
return errors.Wrap(err, "failed to update the pet in the store")
}
return nil
}
当我们更新商店中的宠物时,我们会使用获取的商店宠物,并使用 Kubernetes Pet资源中的期望状态更新字段。
如果在流程的任何一点遇到错误,我们会将错误传递到Reconcile,在那里它会触发重新排队的对账循环,并且会进行指数回退。ReconcileNormal中的操作是幂等的。它们可以重复运行,以达到相同的状态,并且在面对错误时会重试。对账循环对于失败通常是非常具有弹性的。
这就是ReconcileNormal的全部内容。让我们看看在ReconcileDelete中发生了什么:
// ReconcileDelete deletes the pet from the petstore and removes the finalizer.
func (r *PetReconciler) ReconcileDelete(ctx context.Context, pet *petstorev1.Pet) (ctrl.Result, error) {
psc, err := getPetstoreClient()
if err != nil {
return ctrl.Result{}, errors.Wrap(err, "unable to construct petstore client")
}
if pet.Status.ID != "" {
if err := psc.DeletePets(ctx, []string{pet.Status.ID}); err != nil {
return ctrl.Result{}, errors.Wrap(err, "failed to delete pet")
}
}
// remove finalizer, so K8s can garbage collect the resource.
controllerutil.RemoveFinalizer(pet, PetFinalizer)
return ctrl.Result{}, nil
}
在上面的代码块中的ReconcileDelete中,我们获取一个宠物商店客户端来与宠物商店交互。如果pet.Status.ID不为空,我们尝试从宠物商店删除该宠物。如果该操作成功,我们将移除 finalizer,通知 Kubernetes 它可以删除该资源。
你已经扩展了 Kubernetes 并创建了你的第一个 CRD 和控制器!让我们试运行一下。
要启动项目并查看你的 Kubernetes 运算符在运行中,执行以下命令:
$ ctlptl create cluster kind --name kind-petstore --registry=ctlptl-registry
$ tilt up
前面的命令将创建一个kind集群和本地的Tilt.dev。一旦你完成,你应该会看到类似如下的内容:

图 14.2 – Tilt 的所有资源 Web 视图
等待左侧面板上的每个服务变为绿色。一旦它们变绿,意味着宠物商店操作员和服务已经成功部署。如果你点击左侧列出的其中一个服务,它将显示该组件的日志输出。petstore-operator-controller-manager是你的 Kubernetes 控制器。接下来,我们将一些宠物应用到 Kubernetes 集群中,看看会发生什么。
让我们首先看看我们要应用的宠物样本。样本位于config/samples/petstore_v1alpha1_pet.yaml:
---
apiVersion: petstore.example.com/v1alpha1
kind: Pet
metadata:
name: pet-sample1
spec:
name: Thor
type: dog
birthday: 2021-04-01T00:00:00Z
---
apiVersion: petstore.example.com/v1alpha1
kind: Pet
metadata:
name: pet-sample2
spec:
name: Tron
type: cat
birthday: 2020-06-25T00:00:00Z
我们有两个宠物,Thor和Tron。我们可以通过以下命令应用它们:
$ kubectl apply -f config/samples/petstore_v1alpha1_pet.yaml
这应该已经回复了它们被创建的消息,接下来你应该可以通过运行以下命令来获取它们:
$ kubectl get pets
NAME AGE
pet-sample1 2m17s
pet-sample2 2m17s
我们可以看到我们定义了两个宠物。让我们确保它们有 ID。运行以下命令:
$ kubectl get pets -o yaml
apiVersion: petstore.example.com/v1alpha1
kind: Pet
metadata:
finalizers:
- pet.petstore.example.com
name: pet-sample2
namespace: default
spec:
birthday: "2020-06-25T00:00:00Z"
name: Tron
type: cat
status:
id: 23743da5-34fe-46f6-bed8-1f5bdbaabbe6
我已省略了前面代码中的一些噪声内容,但这大致是你应该看到的。Tron 有一个由宠物商店服务生成的 ID;它被应用到 Kubernetes 的Pet资源状态中。
现在,让我们通过将Thor的名称更改为Thorbert来测试我们的同步循环:
$ kubectl edit pets pet-sample1
这将打开你的默认编辑器。你可以去更改Thor的值为Thorbert,以触发一个新的同步循环。
你应该能在浏览器中看到类似的输出,并且 Tilt 中会有宠物商店操作员的日志。
[manager] 1.6466368389433222e+09 INFO controller.pet finding pets in store {"reconciler group": "petstore.example.com", "reconciler kind": "Pet", "name": "pet-sample1", "namespace": "default", "pet": "Thorbert", "id": "cef9499f-6214-4227-b217-265fd8f196e6"}
正如你从前面的代码中看到的,Thor现在已经更改为Thorbert。
最后,让我们通过运行以下命令来删除这些宠物:
$ kubectl delete pets --all
pet.petstore.example.com "pet-sample1" deleted
pet.petstore.example.com "pet-sample2" deleted
删除资源后,你应该能够在 Tilt 中查看日志输出,反映delete操作已成功。
在本节中,你学习了如何从零开始构建操作员,扩展 Kubernetes API 并创建一个自定义资源,该资源能够将状态同步到外部服务,并在此过程中使用了一些非常有用的工具。
总结
在本章中,我们学习了如何使用 Go 部署和操作 Kubernetes 中的资源。我们在此基础上扩展了 Kubernetes,创建了自定义的Pet资源,并学会了如何持续同步宠物的期望状态与宠物商店的状态。我们学到了如何扩展 Kubernetes 以表示任何外部资源,并且它提供了一个强大的平台来描述几乎任何领域。
你应该能够将本章中学到的内容应用于自动化与 Kubernetes 资源的交互,并通过 Kubernetes API 将 Kubernetes 扩展到原生暴露你自己的资源。我敢打赌,你能想到你公司里的一些服务和资源,你希望能够通过简单地将一些 YAML 应用到 Kubernetes 集群中来管理它们。你现在已经具备了解决这些问题的知识。
在下一章,我们将学习如何使用 Go 编程来管理云端资源。我们将学习如何通过 Go 客户端库与云服务提供商的 API 进行交互,以便修改云资源,并在资源配置完成后,如何使用这些云服务和基础设施。
第十五章:编程云
你可能听过“云就是别人家的电脑”这句话。虽然有一定的道理,但也有些偏离实际。云服务提供商提供的是运行在其数据中心的虚拟机,你可以付费使用,从这个角度看,确实是在使用别人家的电脑。然而,这并没有呈现出云服务提供商的整体面貌。云服务提供商是由数百个应用托管、数据、合规性和计算基础设施服务组成的,这些服务分布在全球数百个数据中心,并通过完全可编程的 API 进行暴露。
在本章中,我们将学习如何使用 Microsoft Azure 与云 API 进行交互。我们将从了解 API 的本质开始,包括如何描述 API 以及在哪里找到有关它们的更多文档。接下来,我们将学习身份、身份验证和授权的基础知识。然后,我们将通过使用 Azure SDK for Go 的一系列示例,应用我们学到的知识,构建云基础设施并利用其他云服务。
到本章结束时,你将掌握有效使用 Microsoft Azure 的知识,并获得与其他云服务提供商合作的可转移技能。
本章将涵盖以下主题:
-
什么是云?
-
学习 Azure API 的基础知识
-
使用 Azure 资源管理器构建基础设施
-
使用已配置的 Azure 基础设施
技术要求
本章将需要以下工具:
-
Go
-
Azure CLI:
docs.microsoft.com/en-us/cli/azure/install-azure-cli -
从 GitHub 下载的代码文件:
github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/15
什么是云?
亚马逊、微软和谷歌云物理计算基础设施的资本投资规模是巨大的。试想一下,建设 200 多个物理数据中心,配备多个冗余的电力和冷却系统,并且具备先进的物理安全设施,需要多少投资。这些数据中心在面临自然灾害时仍能保持韧性。即便如此,你也只是在触及冰山一角。
这些数据中心需要全球最大的互联网络之一将它们连接起来。所有这些基础设施在没有大量电力和冷却的支持下无法运作,最好是来自可持续的能源来源。例如,Azure 自 2012 年以来一直实现碳中和,并承诺到 2030 年实现碳负排放。当人们谈论超大规模云时,他们指的是这些云服务提供商的全球范围运营。
有没有想过访问这些数据中心会是怎样的体验?例如,要访问 Azure 的数据中心,你必须通过多个安全层级。你首先需要申请进入数据中心并提供有效的商业理由。如果获得批准,当你到达数据中心的外围访问点时,你会注意到周围有众多摄像头、高大的钢铁围栏以及混凝土围墙。你需要验证身份,并进入建筑物的入口。在建筑物入口,你会遇到安保人员,他们会再次通过双重身份验证(包括生物识别)来确认你的身份。通过生物识别扫描后,他们会引导你进入数据中心的特定区域。在进入数据中心的过程中,你还需要通过全身金属探测器筛查,确保你没有携带不该带出的物品。这些数据中心的安全措施非常严格。
仍然觉得这像是别人的电脑吗?
云服务提供商的物理基础设施令人敬畏。然而,我们应该将焦点从云服务提供商的运营规模转向云服务是如何暴露给开发者的。正如我们最初提到的,云服务提供商通过 API 暴露云的功能,开发者可以使用这些 API 来管理运行在云上的基础设施和应用程序。我们可以利用这些 API 构建应用程序,借助超大规模的云基础设施,使其具备全球规模。
学习 Azure API 的基础知识
现在我们知道,编程云的路径是通过 API,让我们深入了解一下这些 API。了解如何将大量的 API 组合在一起,形成一致的编程接口是非常重要的。我们还将学习在遇到挑战时,如何找到相关的代码和文档。
在这一部分,我们将讨论主要云服务如何定义 API,并为云 API 编程提供软件开发工具包(SDK)。我们将了解在哪里可以找到这些 SDK,以及如何查找 API 和 SDK 的文档。
我们还将学习关于身份、基于角色的访问控制(RBAC)以及资源层级的知识,特别是在微软 Azure 中。最后,我们将创建并登录一个免费的 Azure 账户,在后续章节中我们将使用它来进行云编程。
云 API 和 SDK 的背景知识
正如我们在上一节中讨论的那样,云服务提供商会暴露用于管理和访问数百个服务的 API,这些服务分布在众多区域内。这些 API 通常采用 表述性状态转移 (REST) 或 谷歌远程过程调用 (gRPC) 实现。在每个云服务提供商内部,很可能有同等数量的工程团队负责构建这些 API。为了提供一致的资源表示,至关重要的是这些 API 在整体上能为每个服务提供相似的行为。每个云服务提供商在解决这个问题时都有自己的方法。例如,在微软 Azure 中,定义 REST API 的规则由 微软 Azure REST API 指南(github.com/microsoft/api-guidelines/blob/vNext/azure/Guidelines.md)明确规定。这些规则为服务团队提供了指导。
开发者通常不会直接通过 HTTP 使用云 API,而是通过使用 SDK。这些 SDK 是一组库,提供了对特定编程语言的 API 访问。
例如,Azure (github.com/Azure/azure-sdk-for-go)、AWS (https://github.com/aws/aws-sdk-go) 和 Google (github.com/googleapis/google-api-go-client) 都为它们的云服务提供了 Go SDK 以及多种其他语言的 SDK。这些 SDK 力求消除访问云 API 所需的样板代码,简化开发者编写与之交互的程序代码。除了云服务提供商发布的文档外,始终记得 GoDocs 是你的朋友。例如,Azure Blob 存储服务的 GoDocs (github.com/Azure/azure-kusto-go) 提供了使用该 SDK 的有用信息。
这些 SDK 大多数是基于机器可读的 API 规范生成的。当你拥有数百个服务和多种编程语言时,依靠大量人工编写 SDK 无法有效扩展。每个云服务提供商都有自己解决这个问题的方法。
例如,微软 Azure 几乎所有的 Azure API 参考文档(docs.microsoft.com/en-us/rest/api/azure/)和 SDK 都是使用 Azure REST API 规范库中的 OpenAPI 规范生成的(github.com/Azure/azure-rest-api-specs)。生成文档和 SDK 的整个过程托管在 GitHub 上,并由 AutoRest 代码生成器等开源工具提供支持(github.com/Azure/autorest)。
趣味提示
本书的其中一位作者 David Justice 在 Azure 建立了这一过程,并首次提交了 Azure REST API 规格仓库的代码(github.com/Azure/azure-rest-api-specs/commit/8c42e6392618a878d5286b8735b99bbde693c0a2)。
Microsoft Azure 身份、RBAC 和资源层次结构
为了准备与 Azure API 进行交互,我们需要了解一些基础知识——身份、RBAC 和资源层次结构。身份确定了与 API 交互的用户或主体。RBAC 定义了身份在 API 内可以做什么。资源层次结构描述了 Azure 云中资源之间的关系。RBAC 角色和权限描述了主体可以对给定的资源或资源层次结构做什么。例如,用户可以被分配 Azure 订阅的贡献者权限,从而能够修改该订阅中的资源。
Azure 中的身份存储在Azure Active Directory(AAD)中。这是一个企业身份与访问管理服务,提供单点登录、多因素认证和条件访问等功能。AAD 中的身份存在于一个或多个租户中,租户包含多个身份。身份可以是用户身份,表示人类,并具有交互式身份验证流程,或者可以是服务主体,表示非人类身份,如没有交互式身份验证流程的应用程序。
Azure 中资源的根源是 Azure 订阅。订阅是一个逻辑容器,包含 Azure 资源组。每个资源,如虚拟机、存储账户或虚拟网络,都位于某个资源组内。资源组是一个逻辑实体,将多个 Azure 资源关联在一起,方便你将它们作为一个整体进行管理。
身份会被授予 RBAC 角色和权限,以便与 Azure 订阅和资源进行交互。你可以将 AAD 和 Azure 看作是两个由 RBAC 权限和角色绑定在一起的独立系统。我们不会深入探讨每个 RBAC 角色或权限,但你可以在 Azure 内置角色文档中找到更多信息(docs.microsoft.com/en-us/azure/role-based-access-control/built-in-roles)。
现在我们对将要使用的云环境有了一些基本了解,接下来我们开始吧。
创建 Azure 账户并访问 API
为了运行本章其余的示例,你需要一个 Azure 账户。如果你没有 Azure 账户,你可以注册一个免费账户,并获得 200 美元的 Azure 积分(azure.microsoft.com/en-us/free/)。
一旦你有了账户,使用 Azure CLI 登录:
$ az login
该命令将登录到您的 Azure 账户,并为您的主 Azure 订阅设置默认上下文。默认情况下,当您创建 Azure 账户时,您的身份将被授予订阅中的 owner 角色。owner 角色授予完全访问权限来管理所有资源,包括在 Azure RBAC 中分配角色的能力。要查看当前活动的订阅,请运行以下命令:
$ az account show
{
"environmentName": "AzureCloud",
"isDefault": true,
"managedByTenants": [],
"name": "mysubscription",
"state": "Enabled",
"tenantId": "888bf....db93",
"user": {
...
}
}
上述命令的输出显示了订阅的名称以及当前 Azure CLI 上下文的其他详细信息。在接下来的命令中,我们将使用 az CLI 直接与 Azure API 进行交互:
az rest --method get --uri "/subscriptions?api-version=2019-03-01"
上述命令将列出您的身份通过 RBAC 权限访问的订阅。请注意,作为 Azure REST API 指南的一部分,所有 Azure API 必须使用 api-version 查询参数。这是强制性的,确保 API 消费者始终可以依赖于请求和响应格式的稳定性。API 更新频繁,如果没有指定某个 API 的 api-version 查询参数,消费者可能会面临 API 的重大变化。
接下来,让我们使用 debug 标志运行相同的请求:
az rest --method get --uri "/subscriptions?api-version=2019-03-01" --debug
使用 Azure CLI 执行任何命令时,添加--debug标志将输出 HTTP 请求的详细信息,显示类似以下内容的输出:
Request URL: 'https://management.azure.com/subscriptions?apiversion=2019-03-01'
Request method: 'GET'
Request headers:
'User-Agent': 'python/3.10.2 (macOS-12.3.1-arm64-arm-64bit) AZURECLI/2.34.1 (HOMEBREW)'
urllib3.connectionpool: Starting new HTTPS connection (1): management.azure.com:443
urllib3.connectionpool: https://management.azure.com:443 "GET /subscriptions?api-version=2019-03-01 HTTP/1.1" 200 6079
Response status: 200
Response headers:
'Content-Type': 'application/json; charset=utf-8'
'x-ms-ratelimit-remaining-tenant-reads': '11999'
'x-ms-request-id': 'aebed1f6-75f9-48c2-ae0b-1dd18ae5ec46'
'x-ms-correlation-request-id': 'aebed1f6-75f9-48c2-ae0b-
'Date': 'Sat, 09 Apr 2022 22:52:32 GMT'
'Content-Length': '6079'
该输出对于查看发送到 Azure API 的 HTTP 内容非常有用。另外,注意 URI https://management.azure.com/... 对应于Azure 资源管理器(ARM)。ARM 是由每个 Azure 资源的资源提供服务组成的复合服务,负责在其中变更资源。
在这一部分,我们了解了主要云平台如何定义 API 并为 API 提供 SDK。我们还特别学习了 Azure 身份、RBAC 和资源层次结构。尽管这些信息可能特定于 Azure,但所有主要云平台遵循相同的模式。一旦你了解了某个云平台如何处理身份与访问管理(IAM),它的大致方法也可以迁移到其他云平台。最后,我们登录到 Azure 账户,供后续章节使用,并学习了如何通过 Azure CLI 直接访问 Azure REST API。
在下一部分,我们将使用 Azure SDK for Go 来变更云基础设施。让我们开始用 Go 编程操作 Azure 云。
使用 Azure 资源管理器构建基础设施
云 API 分为两类:管理平面和数据平面。管理平面是一个控制基础设施创建、删除和变更的 API。数据平面是由配置好的基础设施暴露的 API。
例如,管理平面将用于创建 SQL 数据库。SQL 数据库资源的数据平面则是用于操作数据库内数据和结构的 SQL 协议。
管理平面由云资源 API 提供服务,数据平面由已配置服务暴露的 API 提供服务。
在本节中,我们将学习如何使用 Azure Go SDK 在 Azure 中配置基础设施。我们将学习如何创建和销毁资源组、虚拟网络、子网、公有 IP、虚拟机和数据库。本节的目标是让大家熟悉 Azure Go SDK 以及如何与 ARM 进行交互。
Azure Go SDK
正如我们在上一节中讨论的,云 SDK 简化了指定语言与云服务提供商 API 之间的交互。对于 Azure,我们将使用 Azure Go SDK(github.com/Azure/azure-sdk-for-go/)来与 Azure API 进行交互。具体来说,我们将使用该 SDK 的最新版本(github.com/Azure/azure-sdk-for-go#management-new-releases),该版本已经重新设计,以遵循 Azure 为 Go 语言制定的设计指南(azure.github.io/azure-sdk/golang_introduction.html)。有关最新的包和文档信息,请务必查看 Azure SDK 发布页面(azure.github.io/azure-sdk/releases/latest/mgmt/go.html)。
本节的代码位于 GitHub 的本章代码文件夹中:github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/15。
设置本地环境
要运行本节的代码,您需要设置一个 .env 文件。在仓库的 ./chapter/15 目录下运行以下命令:
$ mkdir .ssh
$ ssh-keygen -t rsa -b 4096 -f ./.ssh/id_rsa -q -N ""
$ chmod 600 ./.ssh/id_rsa*
此命令将在 ./chapter/15 中创建一个 .ssh 目录,在该目录下生成一个 SSH 密钥对,并确保对该密钥对设置了正确的权限。
注意
上述命令会创建一个没有密码的 SSH 密钥。我们仅使用这个密钥对作为示例。在实际使用中,您应该为密钥设置强密码。
接下来,让我们设置一个本地的 .env 文件,用于存储示例中使用的环境变量:
echo -e "AZURE_SUBSCRIPTION_ID=$(az account show --query 'id' -o tsv)\nSSH_PUBLIC_KEY_PATH=./.ssh/id_rsa.pub" >> .env
现在,这个命令将创建一个 .env 文件,文件中包含两个环境变量,AZURE_SUBSCRIPTION_ID 和 SSH_PUBLIC_KEY_PATH。我们通过 Azure CLI 的当前活动订阅来推导出 Azure 订阅 ID 的值。
现在我们已经设置好了本地环境,接下来构建一个 cloud-init 配置脚本,并通过公有 IP 提供 SSH 访问。
构建 Azure 虚拟机
我们先从运行示例开始,然后再深入研究构建基础设施的代码。要运行示例,请执行以下命令:
$ go run ./cmd/compute/main.go
Staring to build Azure resources...
Building an Azure Resource Group named "fragrant-violet"...
Building an Azure Network Security Group named "fragrant-violet-nsg"...
Building an Azure Virtual Network named "fragrant-violet-vnet"...
Building an Azure Virtual Machine named "fragrant-violet-vm"...
Fetching the first Network Interface named "fragrant-violet-nic-6d8bb6ea" connected to the VM...
Fetching the Public IP Address named "fragrant-violet-pip-6d8bb6ea" connected to the VM...
Connect with: `ssh -i ./.ssh/id_rsa devops@20.225.222.128`
Press enter to delete the infrastructure.
在运行 go run ./cmd/compute/main.go 后,你应该看到与前一个命令块中显示的类似的内容。从输出中可以看到,程序构建了多个基础设施组件,包括一个 Azure 资源组、网络安全组、虚拟网络和虚拟机。稍后我们将更详细地讨论这些基础设施的每个部分。
正如输出所示,你还可以使用 SSH 访问虚拟机,具体操作见输出内容。我们将使用此方法来检查虚拟机的预配置状态,以确认 cloud-init 配置脚本是否按预期运行。
如果你访问 Azure 门户,应该能看到以下内容:

图 15.1 – Azure 门户虚拟机基础设施
在前面的截图中,你可以看到资源组以及所有已创建的基础设施。接下来,我们来看看为这些基础设施提供服务的代码。
使用 Go 配置 Azure 基础设施
在这些示例中,你将看到如何构建 Azure API 客户端,查询用于访问 API 的凭证,并修改基础设施。这些示例中的许多使用了简化的错误处理方式,以使代码尽可能简洁,便于说明。panic 不是你的朋友。请根据需要适当地包装和传递错误。
让我们从 go run ./cmd/compute/main.go 的入口点开始,学习如何使用 Go 来配置云基础设施:
func main() {
_ = godotenv.Load()
ctx := context.Background()
subscriptionID := helpers.MustGetenv(
"AZURE_SUBSCRIPTION_ID",
)
sshPubKeyPath := helpers.MustGetenv("SSH_PUBLIC_KEY_PATH")
factory := mgmt.NewVirtualMachineFactory(
subscriptionID,
sshPubKeyPath,
)
fmt.Println("Staring to build Azure resources...")
stack := factory.CreateVirtualMachineStack(
ctx,
"southcentralus",
)
admin := stack.VirtualMachine.Properties.OSProfile.AdminUsername
ipAddress := stack.PublicIP.Properties.IPAddress
sshIdentityPath := strings.TrimRight(sshPubKeyPath, ".pub")
fmt.Printf(
"Connect with: `ssh -i %s %s@%s`\n\n",
sshIdentityPath, *admin, *ipAddress,
)
fmt.Println("Press enter to delete the infrastructure.")
reader := bufio.NewReader(os.Stdin)
_, _ = reader.ReadString('\n')
factory.DestroyVirtualMachineStack(context.Background(), stack)
}
在前面的代码中,我们使用 godotenv.Load() 加载本地 .env 文件中的环境变量。在 main 函数中,我们创建一个新的 VirtualMachineFactory 来管理 Azure 基础设施的创建和删除。基础设施在 factory.CreateVirtualMachineStack 中创建后,我们打印出 SSH 连接详情,并提示用户确认是否删除基础设施堆栈。
接下来,让我们深入了解虚拟机工厂,看看虚拟机堆栈中包含了哪些内容:
type VirtualMachineFactory struct {
subscriptionID string
sshPubKeyPath string
cred azcore.TokenCredential
groupsClient *armresources.ResourceGroupsClient
vmClient *armcompute.VirtualMachinesClient
vnetClient *armnetwork.VirtualNetworksClient
subnetClient *armnetwork.SubnetsClient
nicClient *armnetwork.InterfacesClient
nsgClient *armnetwork.SecurityGroupsClient
pipClient *armnetwork.PublicIPAddressesClient
}
这段代码定义了 VirtualMachineFactory 的结构,它负责创建和访问 Azure SDK API 客户端。我们使用 NewVirtualMachineFactory 函数来实例化这些客户端,如下所示:
func NewVirtualMachineFactory(subscriptionID, sshPubKeyPath string) *VirtualMachineFactory {
cred := HandleErrWithResult(azidentity.NewDefaultAzureCredential(nil))
return &VirtualMachineFactory{
cred: cred,
subscriptionID: subscriptionID,
sshPubKeyPath: sshPubKeyPath,
groupsClient: BuildClient(subscriptionID, cred, armresources.NewResourceGroupsClient),
vmClient: BuildClient(subscriptionID, cred, armcompute.NewVirtualMachinesClient),
vnetClient: BuildClient(subscriptionID, cred, armnetwork.NewVirtualNetworksClient),
subnetClient: BuildClient(subscriptionID, cred, armnetwork.NewSubnetsClient),
nsgClient: BuildClient(subscriptionID, cred, armnetwork.NewSecurityGroupsClient),
nicClient: BuildClient(subscriptionID, cred, armnetwork.NewInterfacesClient),
pipClient: BuildClient(subscriptionID, cred, armnetwork.NewPublicIPAddressesClient),
}
}
这段代码构建了一个新的默认 Azure 身份凭证。该凭证用于验证客户端对 Azure API 的身份。默认情况下,该凭证会检查多个来源以寻找可用的身份。默认凭证首先会检查环境变量,然后尝试使用 Azure 托管身份(docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview),最后,如果没有找到身份,则会回退到使用 Azure CLI 的用户身份。对于本示例,我们依赖 Azure CLI 身份与 Azure API 交互。这对开发非常方便,但不应在已部署的应用程序或脚本中使用。非交互式身份验证需要使用 Azure 服务主体(docs.microsoft.com/en-us/azure/active-directory/develop/app-objects-and-service-principals)或 Azure 托管身份。
VM 工厂使用 subscriptionID、凭证以及每个客户端的 New* 函数构建每个 Azure API 客户端。BuildClient() 构建每个客户端。
既然我们已经了解了如何实例化凭证和 API 客户端,接下来让我们深入了解 CreateVirtualMachineStack 中的基础设施创建:
func (vmf *VirtualMachineFactory) CreateVirtualMachineStack(ctx context.Context, location string) *VirtualMachineStack {
stack := &VirtualMachineStack{
Location: location,
name: haiku.Haikunate(),
sshKeyPath: HandleErrWithResult(homedir.Expand(vmf.sshPubKeyPath)),
}
stack.ResourceGroup = vmf.createResourceGroup(ctx, stack.name, stack.Location)
stack.SecurityGroup = vmf.createSecurityGroup(ctx, stack.name, stack.Location)
stack.VirtualNetwork = vmf.createVirtualNetwork(ctx, stack)
stack.VirtualMachine = vmf.createVirtualMachine(ctx, stack)
stack.NetworkInterface = vmf.getFirstNetworkInterface(ctx, stack)
stack.PublicIP = vmf.getPublicIPAddress(ctx, stack)
return stack
}
在前面的代码中,我们创建了一个堆栈的概念——一组相关的基础设施。我们使用给定的位置、一个人类可读的名称和 SSH 公钥路径的内容创建了一个新的堆栈。随后,我们创建了每个 Azure 资源,以便为虚拟机提供公共 SSH 访问权限。
让我们来探讨一下 CreateVirtualMachineStack 中的 create 和 get 函数:
func (vmf *VirtualMachineFactory) createResourceGroup(ctx context.Context, name, location string) armresources.ResourceGroup {
param := armresources.ResourceGroup{
Location: to.Ptr(location),
}
fmt.Printf("Building an Azure Resource Group named %q...\n", name)
res, err := vmf.groupsClient.CreateOrUpdate(ctx, name, param, nil)
HandleErr(err)
return res.ResourceGroup
}
在前面的代码中,createResourceGroup 在 groupsClient 上调用 CreateOrUpdate,在指定位置创建一个 Azure 资源组。Azure 资源组是 Azure 资源的逻辑容器。我们将使用这个资源组作为其余资源的容器。
接下来,让我们深入了解网络安全组创建函数 createSecurityGroup:
func (vmf *VirtualMachineFactory) createSecurityGroup(ctx context.Context, name, location string) armnetwork.SecurityGroup {
param := armnetwork.SecurityGroup{
Location: to.Ptr(location),
Name: to.Ptr(name + "-nsg"),
Properties: &armnetwork.SecurityGroupPropertiesFormat{
SecurityRules: []*armnetwork.SecurityRule{
{
Name: to.Ptr("ssh"),
Properties: &armnetwork.SecurityRulePropertiesFormat{
Access: to.Ptr(armnetwork.SecurityRuleAccessAllow),
Direction: to.Ptr(armnetwork.SecurityRuleDirectionInbound),
Protocol: to.Ptr(armnetwork.SecurityRuleProtocolAsterisk),
Description: to.Ptr("allow ssh on 22"),
DestinationAddressPrefix: to.Ptr("*"),
DestinationPortRange: to.Ptr("22"),
Priority: to.Ptr(int32(101)),
SourcePortRange: to.Ptr("*"),
SourceAddressPrefix: to.Ptr("*"),
},
},
},
},
}
fmt.Printf("Building an Azure Network Security Group named %q...\n", *param.Name)
poller, err := vmf.nsgClient.BeginCreateOrUpdate(ctx, name, *param.Name, param, nil)
HandleErr(err)
res := HandleErrPoller(ctx, poller)
return res.SecurityGroup
}
在前面的代码中,我们构建了一个 Azure 网络安全组,该安全组包含一个单独的安全规则,允许在端口 22 上的网络流量,从而为虚拟机启用 SSH 访问。请注意,我们调用的是 BeginCreateOrUpdate,而不是 CreateOrUpdate,后者会向 Azure API 发送 PUT 或 PATCH 请求,并启动一个长期运行的操作。
在 Azure 中,长时间运行的操作是指—在初始变更被接受后—执行直到到达终态。例如,在创建网络安全组时,API 接收初始变更,然后开始构建基础设施。基础设施构建完成后,API 会通过操作状态或配置状态表示完成。poller负责跟踪这个长时间运行的操作直到完成。在HandleErrPoller中,我们跟踪轮询直到完成,并返回资源的最终状态。
接下来,让我们通过createVirtualNetwork来探讨虚拟网络的创建:
func (vmf *VirtualMachineFactory) createVirtualNetwork(ctx context.Context, vmStack *VirtualMachineStack) armnetwork.VirtualNetwork {
param := armnetwork.VirtualNetwork{
Location: to.Ptr(vmStack.Location),
Name: to.Ptr(vmStack.name + "-vnet"),
Properties: &armnetwork.VirtualNetworkPropertiesFormat{
AddressSpace: &armnetwork.AddressSpace{
AddressPrefixes: []*string{to.Ptr("10.0.0.0/16")},
},
Subnets: []*armnetwork.Subnet{
{
Name: to.Ptr("subnet1"),
Properties: &armnetwork.SubnetPropertiesFormat{
AddressPrefix: to.Ptr("10.0.0.0/24"),
NetworkSecurityGroup: &vmStack.SecurityGroup,
},
},
},
},
}
fmt.Printf("Building an Azure Virtual Network named %q...\n", *param.Name)
poller, err := vmf.vnetClient.BeginCreateOrUpdate(ctx, vmStack.name, *param.Name, param, nil)
HandleErr(err)
res := HandleErrPoller(ctx, poller)
return res.VirtualNetwork
}
在前一个代码块中,我们为虚拟机构建了一个 Azure 虚拟网络。该虚拟网络的 CIDR 设置为10.0.0.0/16 10.0.0.0/24。子网引用了我们在前一个代码块中构建的网络安全组,这导致网络安全组中的规则会被强制执行在子网上。
现在我们已经为虚拟机构建好了网络,接下来让我们通过createVirtualMachine来构建虚拟机:
func (vmf *VirtualMachineFactory) createVirtualMachine(ctx context.Context, vmStack *VirtualMachineStack) armcompute.VirtualMachine {
param := linuxVM(vmStack)
fmt.Printf("Building an Azure Virtual Machine named %q...\n", *param.Name)
poller, err := vmf.vmClient.BeginCreateOrUpdate(ctx, vmStack.name, *param.Name, param, nil)
HandleErr(err)
res := HandleErrPoller(ctx, poller)
return res.VirtualMachine
}
createVirtualMachine()并没有太多可展示的内容。如您所见,创建资源的相同模式通过长时间运行的 API 调用应用在这段代码中。值得注意的是在linuxVM()中的一些细节:
func linuxVM(vmStack *VirtualMachineStack) armcompute.VirtualMachine {
return armcompute.VirtualMachine{
Location: to.Ptr(vmStack.Location),
Name: to.Ptr(vmStack.name + "-vm"),
Properties: &armcompute.VirtualMachineProperties{
HardwareProfile: &armcompute.HardwareProfile{
VMSize: to.Ptr(armcompute.VirtualMachineSizeTypesStandardD2SV3),
},
StorageProfile: &armcompute.StorageProfile{
ImageReference: &armcompute.ImageReference{
Publisher: to.Ptr("Canonical"),
Offer: to.Ptr("UbuntuServer"),
SKU: to.Ptr("18.04-LTS"),
Version: to.Ptr("latest"),
},
},
NetworkProfile: networkProfile(vmStack),
OSProfile: linuxOSProfile(vmStack),
},
}
}
在linuxVM中,我们指定了虚拟机的位置、名称以及属性。在属性中,我们指定了希望配置的硬件类型。在这种情况下,我们配置的是 Standard D3v2(您可以在docs.microsoft.com/en-us/azure/virtual-machines/dv3-dsv3-series)硬件库存单位(SKU)。
我们还指定了我们的StorageProfile,用于指定操作系统以及我们希望附加到虚拟机的数据磁盘。在这个例子中,我们指定了要运行最新版本的 Ubuntu 18.04。由于NetworkProfile和OSProfile的复杂性太高,无法在此函数中包含,所以我们将在以下代码块中分别探讨它们:
func networkProfile(vmStack *VirtualMachineStack) *armcompute.NetworkProfile {
firstSubnet := vmStack.VirtualNetwork.Properties.Subnets[0]
return &armcompute.NetworkProfile{
NetworkAPIVersion: to.Ptr(armcompute.NetworkAPIVersionTwoThousandTwenty1101),
NetworkInterfaceConfigurations: []*armcompute.VirtualMachineNetworkInterfaceConfiguration{
{
Name: to.Ptr(vmStack.name + "-nic"),
Properties: &armcompute.VirtualMachineNetworkInterfaceConfigurationProperties{
IPConfigurations: []*armcompute.VirtualMachineNetworkInterfaceIPConfiguration{
{
Name: to.Ptr(vmStack.name + "-nic-conf"),
Properties: &armcompute.VirtualMachineNetworkInterfaceIPConfigurationProperties{
Primary: to.Ptr(true),
Subnet: &armcompute.SubResource{
ID: firstSubnet.ID,
},
PublicIPAddress Configuration: &armcompute.VirtualMachinePublicIPAddress Configuration{
Name: to.Ptr(vmStack.name + "-pip"),
Properties: &armcompute.VirtualMachinePublicIPAddressConfiguration Properties{
PublicIPAllocationMethod: to.Ptr(armcompute.PublicIPAllocation MethodStatic),
PublicIPAddressVersion: to.Ptr(armcompute.IPVersionsIPv4),
},
},
},
},
},
Primary: to.Ptr(true),
},
},
},
}
}
在networkProfile()中,我们创建了NetworkProfile,它指定虚拟机应该使用 IPv4 并通过公共 IP 进行暴露,同时该虚拟机应只有一个网络接口。网络接口应分配到我们在createVirtualNetwork()中创建的子网。
接下来,让我们通过以下代码块探讨OSProfile配置,具体通过linuxOSProfile()进行配置:
func linuxOSProfile(vmStack *VirtualMachineStack) *armcompute.OSProfile {
sshKeyData := HandleErrWithResult(ioutil.ReadFile(vmStack.sshKeyPath))
cloudInitContent := HandleErrWithResult(ioutil.ReadFile("./cloud-init/init.yml"))
b64EncodedInitScript := base64.StdEncoding.EncodeToString(cloudInitContent)
return &armcompute.OSProfile{
AdminUsername: to.Ptr("devops"),
ComputerName: to.Ptr(vmStack.name),
CustomData: to.Ptr(b64EncodedInitScript),
LinuxConfiguration: &armcompute.LinuxConfiguration{
DisablePasswordAuthentication: to.Ptr(true),
SSH: &armcompute.SSHConfiguration{
PublicKeys: []*armcompute.SSHPublicKey{
{
Path: to.Ptr("/home/devops/.ssh/authorized_keys"),
KeyData: to.Ptr(string(sshKeyData)),
},
},
},
},
}
}
在linuxOSProfile中,我们创建了一个OSProfile,其中包括了管理员用户名、计算机名以及 SSH 配置等细节。请注意,CustomData字段用于指定 Base64 编码的cloud-init YAML,该 YAML 文件用于执行虚拟机的初始配置。
让我们探讨一下在cloud-init YAML 文件中我们正在做什么:
#cloud-config
package_upgrade: true
packages:
- nginx
- golang
runcmd:
- echo "hello world"
一旦虚拟机(VM)创建完成,以下的cloud-init指令将被执行:
-
首先,升级 Ubuntu 机器上的软件包。
-
接下来,
nginx和golang包通过高级包工具(APT)安装。 -
最后,
runcmd echos "hello world"。
cloud-init对于引导虚拟机非常有用。如果你以前没有使用过它,我强烈建议你进一步探索它(cloudinit.readthedocs.io/en/latest/)。
我们可以通过 SSH 访问虚拟机并执行类似下面的命令来验证cloud-init是否执行。记住,你的 IP 地址与这里显示的不同:
$ ssh -i ./.ssh/id_rsa devops@20.225.222.128
devops@fragrant-violet:~$ which go
/usr/bin/go
devops@fragrant-violet:~$ which nginx
/usr/sbin/nginx
cat /var/log/cloud-init-output.log
如你所见,nginx和go已经安装。你还应该能在已配置的虚拟机的/var/log/cloud-init-output.log中看到 APT 变更和hello world。
你已经配置并创建了一个 Azure 虚拟机及相关基础设施!现在,让我们销毁整个基础设施堆栈。你应该能够在运行go run ./cmd/compute/main.go的 shell 中按下Enter键。
让我们看看在调用factory.DestroyVirtualMachineStack时发生了什么:
func (vmf *VirtualMachineFactory) DestroyVirtualMachineStack(ctx context.Context, vmStack *VirtualMachineStack) {
_, err := vmf.groupsClient.BeginDelete(ctx, vmStack.name, nil)
HandleErr(err)
}
在DestroyVirtualMachineStack中,我们简单地在组的客户端上调用BeginDelete(),并指定资源组名称。然而,与其他示例不同,我们并没有等待轮询器完成。我们将DELETE HTTP请求发送到 Azure。我们不等待基础设施完全删除,而是相信delete请求的接受意味着它最终会达到删除的终端状态。
我们现在已经使用 Azure SDK for Go 构建并清理了一堆基础设施。我们学会了如何创建资源组、虚拟网络、子网、公有 IP 和虚拟机,以及如何将这种模式扩展到 Azure 中的任何资源。此外,这些技能适用于每个主要云平台,不仅仅是 Azure。AWS 和 GCP 也有类似的概念和 API 访问模式。
在下一节中,我们将构建一个 Azure 存储账户,并通过上传文件然后提供受限访问来下载这些文件,了解如何使用云服务的数据平面。
使用已配置的 Azure 基础设施
在上一节中,我们构建了一堆计算和网络基础设施来说明如何操作云基础设施。在这一节中,我们将把已配置的基础设施与 Azure 控制平面配对,并通过已配置服务的数据平面使用这些基础设施。
在这一部分,我们将构建一个云存储基础设施。我们将使用 Azure 存储来存储文件,并通过共享访问签名(docs.microsoft.com/en-us/azure/storage/common/storage-sas-overview)为这些文件提供受限访问。我们将学习如何使用 ARM 获取账户密钥,并使用这些密钥为存储资源提供受限访问。
构建一个 Azure 存储账户
让我们通过运行示例开始,然后我们将深入研究如何构建基础设施和使用配置的存储账户。要执行示例,请运行以下命令:
$ go run ./cmd/storage/main.go
Staring to build Azure resources...
Building an Azure Resource Group named "falling-rain"...
Building an Azure Storage Account named "fallingrain"...
Fetching the Azure Storage Account shared key...
Creating a new container "jd-imgs" in the Storage Account...
Reading all files ./blobs...
Uploading file "img1.jpeg" to container jd-imgs...
Uploading file "img2.jpeg" to container jd-imgs...
Uploading file "img3.jpeg" to container jd-imgs...
Uploading file "img4.jpeg" to container jd-imgs...
Generating readonly links to blobs that expire in 2 hours...
https://fallingrain.blob.core.windows.net/jd-imgs/img1.jpeg?se=2022-04-20T21%3A50%3A25Z&sig=MrwCXziwLLQeepLZjrW93IeEkTLxJ%2BEX16rmGa2w548%3D&sp=r&sr=b&st=2022-04-20T19%3A50%3A25Z&sv=2019-12-12
...
Press enter to delete the infrastructure.
如你在之前的输出中看到的,示例创建了一个资源组和一个存储账户,获取了账户密钥,然后将./blobs中的所有图片上传到云端。最后,示例通过共享访问签名打印出每张图片的 URI。如果你点击其中一个 URI,你应该能够下载我们上传到存储账户中的图片。
当你尝试在没有查询字符串的情况下下载img1.jpeg时会发生什么——例如,使用https://fallingrain.blob.core.windows.net/jd-imgs/img1.jpeg链接?你应该会看到访问被拒绝的消息。
让我们看看如何使用 Azure 存储上传文件并限制访问权限。
使用 Go 配置 Azure 存储
在这个示例中,我们将配置一个 Azure 资源组和一个 Azure 存储账户。为了让代码尽可能简洁以便说明,我们使用了简化的错误处理行为。正如我在上一节所说,panic 不是你的朋友。请适当包装和抛出错误。
让我们从 Go 的入口点run ./cmd/storage/main.go开始,学习如何使用 Go 来配置存储账户:
func init() {
_ = godotenv.Load()
}
func main() {
subscriptionID := MustGetenv("AZURE_SUBSCRIPTION_ID")
factory := mgmt.NewStorageFactory(subscriptionID)
fmt.Println("Staring to build Azure resources...")
stack := factory.CreateStorageStack(
context.Background(),
"southcentralus”,
)
uploadBlobs(stack)
printSASUris(stack)
fmt.Println("Press enter to delete the infrastructure.")
reader := bufio.NewReader(os.Stdin)
_, _ = reader.ReadString('\n')
factory.DestroyStorageStack(context.Background(), stack)
}
类似于上一节中的虚拟机基础设施示例,我们使用NewStorageFactory()创建StorageFactory,然后使用它来创建和销毁存储堆栈。在中间,我们调用uploadBlobs()上传图片文件,并调用printSASUris()为每个上传的文件生成并打印共享访问签名。
首先,我们来看看如何配置存储基础设施:
type StorageFactory struct {
subscriptionID string
cred azcore.TokenCredential
groupsClient *armresources.ResourceGroupsClient
storageClient *armstorage.AccountsClient
}
func NewStorageFactory(subscriptionID string) *StorageFactory {
cred := HandleErrWithResult(
azidentity. NewDefaultAzureCredential(nil),
)
return &StorageFactory{
cred: cred,
subscriptionID: subscriptionID,
groupsClient: BuildClient(subscriptionID, cred, armresources.NewResourceGroupsClient),
storageClient: BuildClient(subscriptionID, cred, armstorage.NewAccountsClient),
}
}
存储工厂看起来类似于上一节中的VirtualMachineFactory。然而,存储工厂只使用资源组和存储客户端。
接下来,让我们探索CreateStorageStack(),看看我们是如何创建 Azure 存储账户的:
func (sf *StorageFactory) CreateStorageStack(ctx context.Context, location string) *StorageStack {
stack := &StorageStack{
name: haiku.Haikunate(),
}
stack.ResourceGroup = sf.createResourceGroup(ctx, stack.name, location)
stack.Account = sf.createStorageAccount(ctx, stack.name, location)
stack.AccountKey = sf.getPrimaryAccountKey(ctx, stack)
return stack
}
在前面的代码中,我们为堆栈创建了一个人类可读的名称,用它来命名资源组和存储账户。然后我们将已创建的资源填充到堆栈字段中。
我将不会介绍createResourceGroup(),因为它已在上一节中讲解过。然而,createStorageAccount()和getPrimaryAccountKey()很有意思。让我们探讨一下它们的功能:
// createStorageAccount creates an Azure Storage Account
func (sf *StorageFactory) createStorageAccount(ctx context.Context, name, location string) armstorage.Account {
param := armstorage.AccountCreateParameters{
Location: to.Ptr(location),
Kind: to.Ptr(armstorage.KindBlockBlobStorage),
SKU: &armstorage.SKU{
Name: to.Ptr(armstorage.SKUNamePremiumLRS),
Tier: to.Ptr(armstorage.SKUTierPremium),
},
}
accountName := strings.Replace(name, "-", "", -1)
fmt.Printf("Building an Azure Storage Account named %q...\n", accountName)
poller, err := sf.storageClient.BeginCreate(ctx, name, accountName, param, nil)
HandleErr(err)
res := HandleErrPoller(ctx, poller)
return res.Account
}
在前面的代码中,createStorageAccount()创建了一个新的块 blob,具有高级性能层,并且是本地冗余的 Azure 存储帐户。块 blob (docs.microsoft.com/en-us/rest/api/storageservices/understanding-block-blobs--append-blobs--and-page-blobs#about-block-blobs) 优化了大数据量的上传,正如其名称所示,它被分成了任意大小的块。本地冗余存储 (docs.microsoft.com/en-us/azure/storage/common/storage-redundancy#locally-redundant-storage) 意味着每个块会在同一个数据中心内复制 3 次,并且在给定的一年内保证提供 99.999999999%(11 个 9!)的耐用性。最后,Azure 存储的高级层级 (docs.microsoft.com/en-us/azure/storage/blobs/scalability-targets-premium-block-blobs) 表示存储帐户将针对那些持续需要低延迟和高交易吞吐量的块 blob 变更的应用程序进行优化。
除了存储帐户的配置外,其他资源的配置与我们到目前为止配置的资源类似。
为了生成上传 blob 的共享访问签名,我们需要获取一个存储帐户密钥,该密钥是在存储帐户创建时配置的。让我们看看如何请求存储帐户密钥:
func (sf *StorageFactory) getPrimaryAccountKey(ctx context.Context, stack *StorageStack) *armstorage.AccountKey {
fmt.Printf("Fetching the Azure Storage Account shared key...\n")
res, err := sf.storageClient.ListKeys(ctx, stack.name, *stack.Account.Name, nil)
HandleErr(err)
return res.Keys[0]
}
在这段代码中,我们通过在存储客户端上调用ListKeys来获取帐户密钥,并返回第一个返回的帐户密钥。
现在我们已经配置好了存储基础设施并获取了存储帐户密钥,我们可以使用存储服务上传文件并提供对文件的受限访问。
使用 Azure 存储
让我们使用uploadBlobs函数将./blobs中的文件上传到我们的存储帐户:
func uploadBlobs(stack *mgmt.StorageStack) {
serviceClient := stack.ServiceClient()
containerClient, err := serviceClient.NewContainerClient("jd-imgs")
HandleErr(err)
fmt.Printf("Creating a new container \"jd-imgs\" in the Storage Account...\n")
_, err = containerClient.Create(context.Background(), nil)
HandleErr(err)
fmt.Printf("Reading all files ./blobs...\n")
files, err := ioutil.ReadDir("./blobs")
HandleErr(err)
for _, file := range files {
fmt.Printf("Uploading file %q to container jd-imgs...\n", file.Name())
blobClient := HandleErrWithResult(containerClient.NewBlockBlobClient(file.Name()))
osFile := HandleErrWithResult(os.Open(path.Join("./blobs", file.Name())))
_ = HandleErrWithResult(blobClient.UploadFile(context.Background(), osFile, azblob.UploadOption{}))
}
}
在前面的代码中,我们创建了一个服务客户端来与存储服务客户端进行交互。通过serviceClient,我们可以定义一个名为jd-imgs的新存储容器。你可以把存储容器看作是一个类似于目录的实体。在指定容器后,我们调用create来请求存储服务创建该容器。一旦我们有了容器,我们就可以遍历./blobs目录中的每个图像,并使用块 blob 客户端将它们上传。
到目前为止,我们一直在使用 Azure CLI 身份作为与 Azure 服务交互的凭证。然而,当我们实例化serviceClient时,我们开始使用 Azure 存储帐户密钥与我们的存储帐户进行交互。让我们看看ServiceClient():
func (ss *StorageStack) ServiceClient() *azblob.ServiceClient {
cred := HandleErrWithResult(azblob.NewSharedKeyCredential(*ss.Account.Name, *ss.AccountKey.Value))
blobURI := *ss.Account.Properties.PrimaryEndpoints.Blob
client, err := azblob.NewServiceClientWithSharedKey(blobURI, cred, nil)
HandleErr(err)
return client
}
在前面的代码中,我们使用存储账户名称和账户密钥的值创建了一个新的凭证。我们构建了 ServiceClient,使用存储账户的 blob 终结点和新构建的共享密钥凭证。共享密钥凭证将被用于所有从服务客户端派生的客户端。
现在我们已经将文件上传为块 blob,让我们看看如何创建签名 URI 来提供受限访问:
func printSASUris(stack *mgmt.StorageStack) {
serviceClient := stack.ServiceClient()
containerClient, err := serviceClient.NewContainerClient("jd-imgs")
HandleErr(err)
fmt.Printf("\nGenerating readonly links to blobs that expire in 2 hours...\n")
files := HandleErrWithResult(ioutil.ReadDir("./blobs"))
for _, file := range files {
blobClient := HandleErrWithResult(containerClient.NewBlockBlobClient(file.Name()))
permissions := azblob.BlobSASPermissions{
Read: true,
}
now := time.Now().UTC()
sasQuery := HandleErrWithResult(blobClient.GetSASToken(permissions, now, now.Add(2*time.Hour)))
fmt.Println(blobClient.URL() + "?" + sasQuery.Encode())
}
}
我们在前面的代码块中构建了 ServiceClient 并建立了一个容器客户端。然后,我们遍历本地 ./blobs 目录中的每个文件,并创建一个 blob 客户端。
blob 客户端有一个有用的方法,叫做 GetSASToken,它会根据 blob 访问权限和有效期生成共享访问令牌。在我们的案例中,我们授予的访问权限是立即生效并在 2 小时后过期的读取权限。为了创建一个完整的 URI 以访问 blob,我们需要将 blob URL 和共享访问令牌生成的查询字符串组合起来。我们通过 blobClient.URL()、"?" 和 sasQuery.Encode() 来实现这一点。现在,任何拥有签名 URI 的人都可以访问该文件。
在最后一节中,我们构建并使用了云存储基础设施来存储文件,并通过使用共享访问签名(SAS)提供对这些文件的受限访问。我们学习了如何获取账户密钥,并使用它们来提供对存储资源的受限访问。通过这些技能,你可以结合权限和其他约束来定制访问方式。以这种方式提供受限访问是一个强大的工具。例如,你可以创建一个仅写的 URI,指向一个尚未创建的 blob,将 URI 传递给客户端,然后让他们上传文件,而无需访问存储账户中的任何其他文件。
总结
Azure 存储仅是你可以用来构建云端应用的数百项服务中的一种。每个云服务提供商都有类似的存储服务,这些服务的操作方式相似。本章中展示的示例特定于 Microsoft Azure,但可以轻松地模仿其他云服务。
Azure 存储示例有助于说明云管理平面和数据平面之间的区别。如果仔细观察,你会发现 创建、读取、更新和删除(CRUD)资源操作在使用 ARM 时与与 Azure 存储服务、容器和 blob 客户端的交互非常相似。云中的资源管理是统一的,而数据库、存储服务和内容分发网络的数据平面则很少统一,通常通过专门构建的 API 来暴露。
在本章中,我们学到了云不仅仅是“别人的计算机”。云是一个跨越行星规模的高安全性数据中心网络,里面充满了计算、网络和存储硬件。我们还学习了身份、认证和授权的基础知识,并结合了 Microsoft Azure 的具体实例。我们简要介绍了 Azure RBAC 及其与 AAD 身份的关系。最后,我们学习了如何使用 Microsoft Azure 配置和使用云资源。
你应该能够将你在这里学到的知识应用到云服务的配置和使用中,以实现你的目标。这些技能主要集中在 Microsoft Azure,但在这里学到的技能很容易转移到 AWS 或 Google 云平台。
在下一章中,我们将探讨当软件在不完美条件下运行时会发生什么。我们将学习如何为混乱设计。
第十六章:为混乱而设计
编写在完美条件下工作的程序是容易的。如果你永远不需要担心网络延迟、服务超时、存储故障、应用程序行为异常、用户发送错误参数、安全问题,或者我们在现实生活中遇到的任何其他场景,那就好了。
根据我的经验,故障通常有以下三种方式:
-
立即
-
渐进地
-
壮观地
立即通常是应用代码发生变化,导致服务在启动时或接收到请求时崩溃的结果。大多数开发测试环境或金丝雀发布能够在生产中发生任何实际问题之前捕捉到这些。这种类型的问题通常很容易修复和预防。
渐进性通常是由于某种类型的内存泄漏、线程/协程泄漏,或忽视设计限制。这些问题随着时间的推移积累,开始引发问题,导致服务崩溃或延迟增长到无法接受的水平。很多时候,一旦问题被识别出来,这些问题可以在金丝雀发布过程中轻松解决。对于设计问题,修复可能需要几个月的密集工作来解决。某些罕见版本的这种问题,会出现我所称之为“悬崖故障”:渐进性增长遇到一个无法通过增加更多资源来克服的限制。这类问题属于下一个类别。
那种类别是壮观的。这就是你在生产环境中发现一个问题,导致大规模故障,而几分钟前一切都正常工作。手机到处响起警报,仪表盘变红,狗和猫开始一起生活——大规模的恐慌!这可能是一个有缺陷的服务上线,压垮了你的网络,依赖的缓存服务崩溃,或是某种查询导致你的服务崩溃。这些停机造成大规模恐慌,考验你在团队之间有效沟通的能力,且通常会出现在新闻报道中。
本章将重点讨论如何设计能够应对混乱的基础设施工具。大型云公司最壮观的故障往往是基础设施工具的结果,从Google 站点可靠性工程(Google SRE)擦除他们集群卫星上的所有磁盘,到亚马逊云服务(AWS)用基础设施工具远程过程调用(RPCs)压垮其网络。
本章将探讨第一响应者(FRs)如何停止自动化的安全方法,如何编写幂等的工作流工具,失败的 RPC 的增量回退包,推出时的节奏限制器等内容。
为此,我们将介绍一些概念和包,这些包将构建到一个通用的工作流系统中,供你进一步学习使用。该系统能够接受请求来执行某种工作,验证参数是否正确,按照一组策略验证请求,然后执行该工作。
在此模型中,客户端(可以是命令行界面(CLI)应用程序或服务)通过协议缓冲区详细描述要执行的工作,并将其发送到服务器。工作流系统执行所有实际工作。
本章将涵盖以下主要主题:
-
使用过载防护机制
-
使用速率限制器防止工作流失控
-
构建可重复且不会丢失的工作流
-
使用策略限制工具
-
构建具有紧急停止功能的系统
技术要求
本章有与前几章相同的要求,只是增加了访问以下 GitHub 仓库的需求:github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/16/workflow。
话虽如此,让我们进入第一章,讨论如何使用过载防护机制,在出现问题时保持网络和服务的健康。
使用过载防护机制
当你拥有一小组服务时,应用程序异常通常只会造成小问题。这是因为数据中心内通常有过剩的网络容量来吸收异常行为的应用程序,并且在服务数量较少的情况下,通常可以直观地找出问题的根源。
当你运行大量应用程序时,通常网络和机器会出现超载现象。超载意味着你的网络和系统无法处理所有应用程序在 100% 运行的情况。超载在网络或集群中非常常见,用来控制成本。之所以可行,是因为在任何给定时刻,大多数应用程序的流量、中央处理单元(CPU)和内存都会随着网络流量的波动而波动。
如果应用程序突然遇到某种类型的错误,可能会进入重试循环,迅速使服务崩溃。此外,如果发生了某种灾难性事件,导致服务下线,尝试将应用程序重新上线可能会因为所有客户端的请求排队而导致服务崩溃。
更糟糕的是网络可能发生的情况。如果网络被压垮,或者云设备的每秒查询数(QPS)被超出,其他应用程序的流量可能会受到负面影响。这可能掩盖问题的真正原因。
防止这些类型问题的方式有几种,最常见的两种方法如下:
-
电路断路器
-
回退实现
这些预防机制的思路是相同的:当发生故障时,防止重试请求压垮服务。
基础设施服务通常是这些防护机制的一个被忽视的应用场景。我们很多时候关注的是公共服务,但基础设施服务同样重要。如果该服务是关键服务且被压垮,恢复它可能非常困难,除非手动调整其他服务以减轻负载。
让我们来看一下其中一种更流行的方法:断路器。
案例研究 – AWS 客户请求压垮了网络
当一个行为不当的应用开始在客户网络与其核心网络之间的网络边界上发送过多流量时,AWS 发生了全球性故障,影响了全球的 AWS 客户。虽然这次故障仅限于其us-east-1区域,但多个地点的客户都受到了影响。
问题有两个方面,包含以下因素:
-
一个行为不当的应用发送了过多的请求。
-
它们的客户端在故障时没有退避。
正是第二个问题导致了长时间的故障。AWS 在使用标准客户端进行 RPC 时做得是正确的,当请求失败时,会进行递增的退避。然而,由于某种原因,在这个案例中,客户端库没有按预期表现。
这意味着,当终端被压垮时,负载并没有自我减少,而是进入了某种类型的无限循环,持续增加受影响系统的负载并压垮了它们的网络交叉连接。这种交叉连接的压垮禁用了它们的监控,并使得他们无法看到问题的根源。结果是,他们不得不通过缩减应用流量来尝试减少网络负载,同时尽量不影响仍在正常工作的客户服务——这是一项我不愿意面对的任务。
这个案例突显了在故障发生时防止应用重试的重要性。如需阅读更多关于此方面的内容,请访问以下网页:aws.amazon.com/message/12721/。
使用断路器
断路器的工作原理是将 RPC 调用包装在一个客户端中,一旦达到阈值,任何尝试都会自动失败。然后,所有的调用都会返回失败,而不会实际尝试,持续一段时间。
断路器有三种模式,如下:
-
闭合
-
打开
-
半开
当一切正常时,断路器处于闭合状态。这是正常状态。
当一些故障导致断路器跳闸时,断路器处于打开状态。在此状态下,所有请求都会自动失败,而无需尝试发送消息。此状态持续一段时间。建议这段时间设置为一定的时长,并加入一些随机性,以防止自发的同步。
断路器在处于打开状态一段时间后,会进入半开状态。一旦进入半开状态,部分请求会被实际尝试。如果超过某个成功阈值,断路器会重新进入闭合状态。如果没有,断路器会再次进入打开状态。
你可以找到几种不同的 Go 语言断路器实现,但其中一个最受欢迎的是索尼开发的,叫做gobreaker(github.com/sony/gobreaker)。
让我们来看一下如何使用它来限制HTTP查询的重试,如下所示:
type HTTP struct {
client *http.Client
cb *gobreaker.CircuitBreaker
}
func New(client *http.Client) *HTTP {
return &HTTP{
client: client,
cb: gobreaker.NewCircuitBreaker(
gobreaker.Settings{
MaxRequests: 1,
Interval: 30 * time.Second,
Timeout: 10 * time.Second,
ReadyToTrip: func(c gobreaker.Counts) bool {
return c.ConsecutiveFailures > 5
},
},
),
}
}
func (h *HTTP) Get(req *http.Request) (*http.Response, error) {
if _, ok := req.Context().Deadline(); !ok {
return nil, fmt.Errorf("all requests must have a Context deadline set")
}
r, err := h.cb.Execute(
func() (interface{}, error) {
resp, err := h.client.Do(req)
if resp.StatusCode != 200 {
return nil, fmt.Errorf("non-200 response code")
}
return resp, err
},
)
if err != nil {
return nil, err
}
return r.(*http.Response), nil
}
上面的代码定义了以下内容:
-
一种包含这两者的 HTTP 类型:
-
用于发送 HTTP 请求的
http.Client -
一个用于 HTTP 请求的断路器
-
-
为我们的
HTTP类型创建一个New()构造函数。它创建一个断路器,带有强制执行以下内容的设置:-
在半开放状态时每次允许一个请求
-
在关闭状态后,我们将进入一个 30 秒的半开放状态
-
有一个持续 10 秒的关闭状态
-
如果连续五次失败,则进入关闭状态
-
HTTP上的Get()方法执行以下操作: -
检查
*http.Request是否定义了超时 -
调用我们
client.Do()方法上的断路器 -
将返回的
interface{}转换为底层的*http.Response
-
这段代码给我们提供了一个强大的 HTTP 客户端,包装了一个断路器。这个更好的版本可能会将设置传递给构造函数,但我希望它为示例打包得更加简洁。
如果你想看到断路器实际运行的演示,可以在这里看到:
使用回退实现
回退实现包装了 RPC 客户端,客户端将在尝试之间进行重试,并且每次重试之间都会有一段暂停时间。这些暂停时间会越来越长,直到达到某个最大值。
回退实现可以有多种计算时间段的方法。在本章中,我们将集中讨论指数回退。
指数回退简单地在每次尝试中增加延迟,这些延迟会随着失败次数的增加而指数增长。与断路器一样,有许多包提供回退实现。在这个例子中,我们将使用pkg.go.dev/github.com/cenk/backoff,这是谷歌 HTTP 回退库的一个实现,适用于 Java。
这个回退实现提供了许多谷歌在多年研究服务失败中发现有用的重要特性。库中最重要的特性之一是向重试之间的睡眠时间添加随机值,这可以防止多个客户端同步它们的重试操作。
其他重要特性包括能够尊重上下文取消操作并提供最大重试次数。
让我们来看一下如何使用它来限制 HTTP 查询的重试,如下所示:
type HTTP struct {
client *http.Client
}
func New(client *http.Client) *HTTP {
return &HTTP{
client: client,
}
}
func (h *HTTP) Get(req *http.Request) (*http.Response, error) {
if _, ok := req.Context().Deadline(); !ok {
return nil, fmt.Errorf("all requests must have a Context deadline set")
}
var resp *http.Response
op := func() error {
var err error
resp, err = h.client.Do(req)
if err != nil {
return err
}
if resp.StatusCode != 200 {
return fmt.Errorf("non-200 response code")
}
return nil
}
err := backoff.Retry(
op,
backoff.WithContext(
backoff.NewExponentialBackOff(),
req.Context(),
),
)
if err != nil {
return nil, err
}
return resp, nil
}
上面的代码定义了以下内容:
-
一种包含这两者的 HTTP 类型:
-
用于发送 HTTP 请求的
http.Client -
一个用于 HTTP 请求的指数回退
-
-
为我们的
HTTP类型创建一个New()构造函数 -
HTTP上的Get()方法 -
它还做了以下事情:
-
创建一个
func()错误,尝试我们的请求,名为op -
以重试和指数延迟的方式运行
op -
创建一个具有默认值的指数回退
-
将该回退包装在
BackOffContext中,以尊重我们的上下文截止时间
-
对于ExponentialBackoff的默认值列表,请参见以下网页:
pkg.go.dev/github.com/cenkalti/backoff?utm_source=godoc#ExponentialBackOff
如果你想看到这个退避机制的实际演示,你可以在这里查看:
将电路断路器与退避机制结合
在选择预防实现时,另一种选择是将电路断路器与退避机制结合起来,以实现更强大的实现。
退避实现可以设置最大重试时间。将其封装在电路断路器内,使一组失败的尝试触发我们的电路断路器,不仅可以通过减缓请求来潜在地减少我们的负载,还可以通过电路断路器停止这些尝试。
如果你想看到一个结合这两者的实现,你可以访问以下网页:
在本节中,我们讨论了防止网络和服务过载的机制的必要性。我们还讨论了一个 AWS 宕机事件,部分原因是由于这些机制的失败。你已了解了电路断路器和退避机制,以防止此类故障的发生。最后,我们展示了两个常用的包来实现这些机制,并附带了示例。
在我们的工作流引擎中,我们将为Google RPC(gRPC)客户端实现这些预防机制,以防止与服务器通信时出现问题。你可以在这里看到:
github.com/PacktPublishing/Go-for-DevOps/blob/rev0/chapter/16/workflow/client/client.go
在我们的下一节中,我们将研究如何使用限流器防止工作流执行得太快。为工作流的操作强制执行节奏,并防止同一类型的工作流同时执行过多,这是非常重要的。
使用限流器来防止失控的工作流
DevOps 工程师可能负责由数十个微服务组成的服务。这些微服务可能会在全球的数据中心中运行成千上万个实例。一旦一个服务包含多个实例,就需要某种形式的速率控制,以防止不良的发布或配置更改导致大规模破坏。
一种限流器,用于带有强制暂停间隔的工作,对于防止失控的基础设施变更至关重要。
限流容易实现,但限流器的作用范围将取决于你的工作流所做的事情。对于服务,你可能希望一次只发生一种类型的变化,或者一次只影响一些实例。
第一种速率限制方式是防止同一类型的工作流同时运行。例如,你可能希望每次只能进行一个卫星磁盘擦除操作。
第二种方式是限制能够同时受影响的设备、服务等的数量。例如,你可能只希望允许同一区域中的两个路由器进行固件升级。
为了使速率限制器有效,拥有一个执行一组服务操作的单一系统可以大大简化这些工作。这使得可以集中执行速率限制等政策。
让我们来看看 Go 中使用通道实现的最简单速率限制器。
案例研究——谷歌卫星磁盘擦除
在早期,谷歌并不拥有如今所有的数据中心空间——我们租用了大量空间并使用了大量机器。然而在一些地方,这样的成本非常高。为了加速这些地方的连接速度,我们会租用小型空间,这些地方可以放置缓存机器,终止 HTTP 连接并将流量回传到数据中心。我们称这些地方为卫星。
谷歌有一个自动化的机器退役流程,其中一部分就是磁盘擦除,机器的磁盘会被清空。
该软件是用来获取卫星机器列表并过滤掉其他机器的。不幸的是,如果你在一个卫星上运行它两次,过滤器将不会生效,你的机器列表将会包含每个卫星中的所有机器。
磁盘擦除非常高效,在所有卫星中的所有机器都被同时加入磁盘擦除队列,直到操作完成。
如果你需要更详细的分析,可以阅读sre.google/workbook/postmortem-culture/,在那里,几位站点可靠性工程师(SREs)提供了更多关于事后分析的细节。
我们可以查看代码中的过滤部分并讨论糟糕的设计,但总会有编写不良的工具和错误的输入。即使你当前有一个良好的代码审查文化,也总会有疏漏。在工程师快速增长的时期,这类问题可能会露出丑陋的面目。
一些在少数经验丰富的工程师手中已知的危险工具,在新工程师手中可能会很安全使用,但没有经验的工程师或缺乏适当警觉的工程师,可能会迅速摧毁你的基础设施。
在这种情况以及许多其他情况下,集中执行并配合速率限制和其他强制性安全机制,可以让新手编写可能危险但影响范围有限的工具。
基于通道的速率限制器
基于通道的速率限制器在一个程序处理自动化任务时非常有用。在这种情况下,你可以创建一个基于通道大小的限制器。让我们来实现一个只允许在同一时间处理固定数量项目的限制器,如下所示:
limit := make(chan struct{}, 3)
我们现在有了一个可以限制可处理项目数量的工具。
让我们定义一个简单的类型,表示要执行的某些操作,如下所示:
type Job interface {
Validate(job *pb.Job) error
Run(ctx context.Context, job *pb.Job) error
}
这定义了一个可以执行以下操作的Job:
-
验证传递给我们的
pb.Job定义 -
使用该定义运行任务
这是一个非常简单的示例,展示了如何执行一组包含在名为“块”的容器中的任务,块只是一个Job切片的容器:
go
wg := sync.WaitGroup{}
for _, block := range work.Blocks {
limit := make(chan struct{}, req.Limit)
for _, job := range block.Jobs {
job := job
limit <- struct{}{}
wg.Add()
go func() {
defer wg.Done()
defer func() {
<-limit
}()
job()
}()
}
}
wg.Wait()
在上面的代码片段中,发生了以下事情:
-
我们循环遍历
work.Blocks变量中的Block切片。 -
我们循环遍历
block.Jobs变量中的Jobs切片。 -
如果我们已经有
req.limit个项目在运行,limit <- struct{}{}将会阻塞。 -
它并发执行我们的任务。
-
当我们的 goroutine 结束时,我们从
workLimit队列中移除一个项目。 -
我们等待所有 goroutine 结束。
这段代码防止同时处理超过req.limit个项目。如果这是一个服务器,你可以将limit设为所有用户共享的变量,并防止系统中同时发生超过三个项目的工作。或者,你可以为不同类别的工作设置不同的限制器。
关于job := job部分的说明。它正在创建一个job的遮蔽变量。这可以防止job变量在 goroutine 内被更改,避免在循环和 goroutine 并行运行时修改原变量,而是将变量的副本放在 goroutine 相同作用域内。这是 Go 新手常见的并发错误,通常被称为for 循环陷阱。这是一个你可以使用的游乐场,用来理解为什么这是必要的:go.dev/play/p/O9DcUIKuGBv。
我们已在游乐场完成了以下示例,您可以在其中操作以探索这些概念:
你可以在runJobs()方法中的工作流服务中看到基于通道的速率限制器的实际应用:
令牌桶速率限制器
令牌桶通常用于为服务提供可突发的流量管理。令牌桶有几种类型,最常见的是标准令牌桶和泄漏令牌桶。
这些通常不用于基础设施工具的部署,因为客户端通常是内部的,且比面向外部的服务更可预测,但一种有用的令牌桶类型可以用于提供速率控制。标准令牌桶只是保存一些固定的令牌集,这些令牌会在某个时间间隔后重新填充。
这是一个示例:
type bucket struct {
tokens chan struct{}
}
func newbucket(size, incr int, interval time.Duration) (*bucket, error) {
b := bucket{tokens: make(chan struct{}, size)}
go func() {
for _ = range time.Tick(interval) {
for i := 0; i < incr; i++ {
select{
case <-b.tokens:
continue
default:
}
break
}
}
}()
return &b, nil
}
func (b *bucket) token(ctx context.Context) error {
select {
case <-ctx.Done():
return ctx.Err()
case b.tokens <-struct{}{}:
}
return nil
}
上面的代码片段执行了以下操作:
-
定义一个
bucket类型来保存我们的令牌 -
有
newBucket(),它创建一个带有以下属性的新bucket实例: -
size,即可以存储的令牌总数-
incr,即每次添加的令牌数量 -
interval,即向桶中添加的时间间隔
-
它还执行以下操作:
-
启动一个 goroutine,以间隔填充桶
-
只会填充到最大
size值 -
定义了
token(),它用来获取一个令牌:-
如果没有可用的令牌,我们将等待一个。
-
如果
Context被取消,我们将返回一个错误。
-
这是一个相当稳健的标准令牌桶实现。你可能能使用atomic包实现一个更快速的版本,但这样做会更复杂。
一个带有输入检查并且能够停止通过newBucket()创建的 goroutine 的实现可以在这里找到:
如果需要,我们可以使用令牌桶来限制执行的速率,只允许按照我们定义的速率执行。这可以用于限制某个操作的执行速度,或者在某段时间内仅允许一定数量的工作流实例发生。我们将在下一节中使用它来限制某个特定工作流的执行时机。
我们的通用工作流系统在这里有一个令牌桶包:
github.com/PacktPublishing/Go-for-DevOps/blob/rev0/chapter/16/workflow/internal/token/token.go
在这一节中,我们探讨了如何使用速率限制器来防止工作流失控。我们以谷歌的卫星磁盘擦除为案例研究,讨论了这一类事件。我们展示了如何实现基于通道的速率限制器,以控制并发操作。我们还讨论了如何使用令牌桶来限制一定时间内的执行次数。
本节还为我们构建的工作流系统示例中,定义为作业的操作执行奠定了基础。
现在我们有了一些关于如何限制操作速率的思路,接下来我们来看看如何开发可重复且不会被客户端丢失的工作流。
构建可重复且永不丢失的工作流
作为 DevOps 工程师,我们经常编写工具。在小型公司中,很多时候这些工具是脚本集合。而在大型公司中,这些工具通常是复杂的系统。
正如你从前言中可能已经看出来的那样,我认为工具的执行应该始终发生在一个集中式的服务中,无论规模大小。一个基础服务很容易编写,你可以随着新需求的出现进行扩展和替换。
但是,要使工作流服务正常工作,你创建的工作流必须满足以下两个关键概念:
-
它们必须是可重复的。
-
它们不能丢失。
第一个概念是,在相同基础设施上运行工作流多次应该产生相同的结果。我们称之为幂等性,借用了计算机科学中的术语。
第二点是,工作流不能丢失。如果一个工具创建了一个要由系统执行的工作流,而该工具崩溃了,那么该工具必须能够知道工作流正在运行并恢复监控。
构建幂等工作流
幂等性是一个概念,如果你使用相同的参数多次调用,你将得到相同的结果。这是编写某些类型的软件时非常重要的概念。
在基础设施中,我们稍微修改了这个定义:幂等操作是指如果使用相同的参数重复执行,并且在此调用之外的基础设施没有变化,它将返回相同的结果。
幂等性是使工作流在工作流系统崩溃时仍能恢复的关键。简单的工作流系统可以直接重复整个工作流。更复杂的系统可以从中断的位置重新启动。
许多时候,开发人员没有深入考虑幂等性。例如,让我们来看一个简单的操作,将一些内容复制到一个文件。这里是一个简单的实现:
func CopyToFile(content []byte, p string) error {
return io.WriteFile(p, content)
}
上述代码包含以下内容:
-
一个表示文件内容的
content参数 -
一个
p参数,表示文件的路径
它还执行以下操作:
- 将
content写入路径为p的文件
这看起来最初是幂等的。如果我们的工作流在调用CopyToFile()之后但在调用io.WriteFile()之前被杀死,我们可以重复这个操作,最初看起来如果我们调用两次,我们仍然会得到相同的结果。
但是如果文件不存在,我们创建了它,但是没有权限编辑现有文件呢?如果我们的程序在记录io.WriteFile()的结果之前崩溃了,但在更改已经发生之后,重复此操作会报告错误,因为基础设施没有发生变化,因此该操作不是幂等的。
让我们修改这个代码,使其具备幂等性,如下所示:
func CopyToFile(content []byte, p string) error {
if _, ok := os.Stat(p); ok {
f, err := os.Open(p)
if err != nil {
return err
}
h0 := sha256.New()
io.Copy(h0, f)
h1 := sha256.New()
h1.Write(content)
if h0.Sum(nil) == h1.Sum(nil) {
return nil
}
}
return io.WriteFile(p, content)
}
这段代码检查文件是否存在,然后执行以下操作:
-
如果文件已存在并且已有内容,它不做任何操作。
-
如果没有,它会写入内容。
这使用标准库的sha256包来计算校验和哈希值,以验证内容是否相同。
提供幂等性的关键通常只是检查工作是否已经完成。
这引出了一个叫做三次握手的概念。这个概念可以在需要通过 RPC 与其他系统交互时提供幂等性。我们将讨论如何在执行工作流时使用这一概念,但它也可以用于与其他服务交互的幂等操作。
使用三次握手防止工作流丢失
当我们编写一个与工作流服务交互的应用程序时,重要的是该应用程序永远不能失去对我们服务上运行的工作流的追踪。
三次握手是我从传输控制协议(TCP)借用的名称。TCP 有一个握手过程,用来在两台机器之间建立一个套接字。它包括以下内容:
-
SYNchonize(SYN),请求打开连接
-
ACKnowledge(ACK),对请求的确认
-
SYN-ACK,对 ACK 的确认
当客户端发送请求执行工作流时,我们不希望工作流服务执行一个客户端因为崩溃而不知道存在的工作流。
这种情况可能发生在客户端程序崩溃或客户端运行的机器发生故障时。如果我们发送了一个工作流,并且服务在一个单一 RPC 后开始执行,客户端可能在发送 RPC 后但在收到工作流标识符(ID)之前崩溃。
这将导致一种情况,当客户端重启时,它不知道工作流服务已经在运行该工作流,并且可能会发送另一个工作流,执行相同的操作。
为了避免这种情况,工作流应有一个三次握手,而不是单个 RPC 来执行工作流,三次握手的过程包括以下内容:
-
将工作流发送到服务
-
接收工作流 ID
-
向服务发送请求,执行带有 ID 的工作流
这允许客户端在执行之前记录工作流的 ID。如果客户端在记录 ID 之前崩溃,服务将只拥有一个未运行的工作流记录。如果客户端在服务开始执行后崩溃,当客户端重启时,它可以检查工作流的状态。如果正在运行,它可以简单地监控。如果没有运行,它可以请求再次执行。
对于我们的工作流服务,让我们创建一个支持三次握手的服务定义,使用 gRPC,如下所示:
service Workflow {
rpc Submit(WorkReq) returns (WorkResp) {};
rpc Exec(ExecReq) returns (ExecResp) {};
rpc Status(StatusReq) returns (StatusResp) {};
}
这定义了一个包含以下调用的服务:
-
Submit提交一个描述待处理工作的WorkReq消息。 -
Exec执行之前通过Submit发送到服务器的WorkReq。 -
Status检索WorkReq的状态。
这些服务调用的消息内容将在下一节中详细讨论,但关键是,在Submit()时,WorkResp将返回一个 ID,但工作流不会执行。当调用Exec()时,我们将发送从Submit()调用中收到的 ID,而Status()调用让我们能够检查任何工作流的状态。
我们现在有了工作流服务的基本定义,包括一个三次握手,防止我们的客户端丢失任何工作流。
在本节中,我们已经涵盖了不可丢失的可重复工作流的基础知识。我们讲解了幂等性以及它如何导致可重复的工作流。我们还展示了三次握手如何帮助我们防止正在运行的工作流变得丢失。
我们还定义了将在我们构建的工作流系统中使用的服务调用。
现在,我们希望了解工具如何理解正在执行的工作范围(SOW),以提供防止工具失控的保护。为此,让我们探索构建一个策略引擎。
使用策略来限制工具
限速对于防止一个坏的工具运行摧毁一个服务很有效,尤其是当所有工作项相等时。但并非所有工作项都是相等的,因为一些机器服务比其他服务更为重要和脆弱(例如,服务的数据库系统)。此外,机器或服务可能需要按逻辑分组,只能在某些有限的数量中进行。可以按站点、地理区域等进行划分。
该逻辑通常是特定于某一组工作项的。这种打包,我们称之为 SOW,可能会非常复杂。
要安全地执行工作,必须理解你的工作范围。这可能是如何安全地更新特定服务的数据库架构,或一个网络区域中一次可以修改多少个路由反射器。
为了在 SOW(工作说明书)中实现安全性,我们将引入策略的概念。策略将用于检查进入系统的一组工作是否符合合规性要求。如果不符合,它将被拒绝。
例如,我们将查看类似于谷歌磁盘擦除案例的磁盘擦除处理。这里是我们将添加的一些保护措施:
-
每小时只允许进行一次卫星磁盘擦除
-
限速,以便我们一次只能擦除五台机器
-
每执行五台机器的擦除后,必须暂停 1 分钟
为了能够构建一个策略引擎,我们必须有一种通用的方式来定义将要执行的工作类型、执行顺序以及并发度。
我们还希望工具工程师仅定义要执行的工作,并将其提交给一个单独的服务来执行。这样可以实现控制的集中化。
让我们定义一个可以在 gRPC 中执行的服务。
定义 gRPC 工作流服务
在前面的章节中,我们讨论了一个定义三次握手的服务定义。让我们看看这些调用的参数,以了解我们的客户端将向工作流服务发送什么,如下所示:
message WorkReq {
string name = 1;
string desc = 2;
repeated Block blocks = 3;
}
message WorkResp {
string id = 1;
}
message Block {
string desc = 1;
int32 rate_limit = 2;
repeated Job jobs = 3;
}
message Job {
string name = 1;
map args = 2;
}
这些消息用于定义客户端希望服务器执行的工作,并包含以下属性:
-
WorkReq消息包含工作名称和组成工作流的所有Block消息。 -
Block消息描述工作流中的一项工作;每个Block一次执行一个,并具有以下属性:-
有一组
Job消息,描述要执行的工作 -
执行
Job消息描述的工作的并发度 -
Job消息描述服务器上的工作类型,调用时使用的参数。
-
-
WorkResp消息返回与该WorkReq相关的 ID:-
使用
UUIDv1ID,封装时间信息到 ID 中,以便我们知道它何时提交到系统 -
使用时间机制防止在某个过期时间之前没有调用
Exec()RPC时执行
-
Exec消息提供你要执行的 ID,如下所示:
message ExecReq {
string id = 1;
}
message ExecResp {}
有更多的消息和enums,以允许进行Status调用。你可以在这里找到完整的协议缓冲区定义:
github.com/PacktPublishing/Go-for-DevOps/blob/rev0/chapter/16/workflow/proto/diskerase.proto
现在我们有了描述待处理工作的消息,让我们来看一下如何创建策略引擎。
创建策略引擎
策略会检查我们的工作,以确保某些参数是被允许的。在我们的案例中,这些参数位于pb.WorkReq实例内部。我们希望策略是通用的,这样它们就可以在多个由pb.WorkReq描述的工作类型中重用。定义完成后,我们将有一个policy.json文件,定义哪些策略应用于特定命名的pb.WorkReq。
为了实现这一点,每个策略都需要接收应应用于特定工作流的策略设置。让我们定义两个接口来描述策略及其设置,如下所示:
type Settings interface{
Validate() error
}
type Policy interface {
Run(ctx context.Context, name string, req *pb.WorkReq, settings Settings) error
}
Settings将始终作为某种结构体实现。它的Validate()方法将用于验证该结构体的字段是否设置为有效值。
Policy根据提供的设置运行我们的实现,作用于pb.WorkReq。
提交的每个WorkReq都将有一个要应用的策略列表。这个列表定义如下:
type PolicyArgs struct {
Name string
Settings Settings
}
Name是要调用的策略的名称。Settings是该调用的设置。
配置文件将详细列出一组PolicyArgs参数以供执行。每个策略都需要在系统中注册。我们将跳过策略注册方法的部分,但这就是策略注册的位置:
var policies = map[string]registration{}
type registration struct {
Policy Policy
Settings Settings
}
当pb.WorkReq进入系统时,我们希望同时对该pb.WorkReq调用这些策略。让我们看看这是如何工作的:
func Run(ctx context.Context, req *pb.WorkReq, args ...PolicyArgs) error {
if len(args) == 0 {
return nil
}
var cancel context.CancelFunc
ctx, cancel = context.WithCancel(ctx)
defer cancel()
// Make a deep clone so that no policy is able to make changes.
creq := proto.Clone(req).(*pb.WorkReq)
runners := make([]func() error, 0, len(args))
for _, arg := range args {
r, ok := policies[arg.Name]
if !ok {
return fmt.Errorf("policy(%s) does not exist", arg.Name)
}
runners = append(
runners,
func() error {
return r.Policy.Run(ctx, arg.Name, creq, arg.Settings)
},
)
}
wg := sync.WaitGroup{}
ch := make(chan error, 1)
wg.Add(len(runners))
for _, r := range runners {
r := r
go func() {
defer wg.Done()
if err := r(); err != nil {
select {
case ch <- err:
cancel()
default:
}
return
}
}()
}
wg.Wait()
select {
case err := <-ch:
return err
default:
}
if !proto.Equal(req, creq) {
return fmt.Errorf("a policy tried to modify a request: this is not allowed as it is a security violation")
}
return nil
}
上述代码定义了以下内容:
-
如果
pb.WorkReq的配置没有策略,则返回。 -
创建一个
Context对象,以便在出现错误时取消正在运行的策略。 -
克隆我们的
pb.WorkReq,使其无法被Policy更改。 -
确保每个被命名的
Policy实际存在。 -
使用我们所提供的设置运行所有策略。
-
如果其中任何一个出现错误,记录该错误并取消所有正在运行的策略。
-
确保
pb.WorkReq的副本与提交的内容相同。
我们现在已经具备了策略引擎的主要部分。完整的引擎可以在这里找到:
github.com/PacktPublishing/Go-for-DevOps/blob/rev0/chapter/16/workflow/internal/policy/policy.go
用于读取我们policy.json文件的Reader类型,在这里进行了详细描述:
让我们来看一下编写一个策略,以便我们引擎使用。
编写策略
你可以在工作流中定义的最基本策略之一是限制该工作流中允许的作业类型。
这可以防止在工作流中引入某些新类型的工作,而这些工作没有考虑到需要应用于该Job的策略。
对于我们的第一个Policy实现,让我们编写一个检查pb.WorkReq的策略,只允许我们在策略配置中定义的Job类型。如果接收到一个未预料的Job,我们将拒绝该pb.WorkReq。
让我们为我们的Policy定义设置,具体如下:
type Settings struct {
AllowedJobs []string
}
func (s Settings) Validate() error {
for _, n := range s.AllowedJobs {
_, err := jobs.GetJob(n)
if err != nil {
return fmt.Errorf("allowed job(%s) is not defined in the proto")
}
}
return nil
}
func (s Settings) allowed(name string) bool {
for _, jn := range s.AllowedJobs {
if jn == name {
return true
}
}
return false
}
以上代码包含以下内容:
-
我们特定的
Settings,实现了policy.Settings -
AllowedJobs,即我们允许的作业名称 -
一个
Validate()方法,用于验证列出的Jobs是否存在 -
一个
allowed()方法,用于检查给定的名称是否符合我们允许的内容 -
它还使用我们的
jobs包来执行这些检查
通过这些设置,用户可以在我们的配置文件中为任何工作流定义一个策略,指定允许哪些Job类型。
让我们定义一个实现Policy接口的类型,具体如下:
type Policy struct{}
func New() (Policy, error) {
return Policy{}, nil
}
func (p Policy) Run(ctx context.Context, name string, req *pb.WorkReq, settings policy.Settings) error {
const errMsg = "policy(%s): block(%d)/job(%d) is a type(%s) that is not allowed"
s, ok := settings.(Settings)
if !ok {
return fmt.Errorf("settings were not valid")
}
for blockNum, block := range req.Blocks {
for jobNum, job := range block.Jobs {
if ctx.Err() != nil {
return ctx.Err()
}
if !s.allowed(job.Name) {
return fmt.Errorf(errMsg, blockNum, jobNum, job.name)
}
}
}
return nil
}
以上代码执行以下操作:
-
定义我们的策略,实施
policy.Policy接口 -
定义了一个
New()构造函数 -
实现了
policy.Policy.Run()方法 -
验证传入的
policy.Settings值是否是此Policy的Settings -
遍历所有
req.Blocks并获取我们的Job实例 -
检查每个
Job是否具有允许的名称
我们现在有一个可以应用的策略,限制pb.WorkReq中的Job类型。我们可以在配置文件中将其应用于执行卫星磁盘擦除的工作流,如下所示:
{
"Name": "SatelliteDiskErase",
"Policies": [
{
"Name": "restrictJobTypes",
"Settings": {
"AllowedJobs": [
"validateDecom",
"diskErase",
"sleep",
"getTokenFromBucket"
]
}
}
]
}
该策略具有以下属性:
-
仅应用于名为
"SatelliteDiskErase"的工作流 -
应用了一条单一策略,
"restrictJobTypes",这是我们定义的 -
只允许以下名称之一的
Job类型:-
"validateDecom" -
"diskErase" -
"sleep" -
"getTokenFromBucket"
-
你可以在此查看完整的Policy实现:
你可以在以下目录中找到我们定义的其他策略:
github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/16/workflow/internal/policy/register
你可以在此处查看当前定义的策略配置:
关于策略引擎的警告
在继续之前,我想提醒一句警告。
简单性是可持续软件的关键。我定义的可持续软件具有以下特征:
-
容易调试
-
用户最多可以在几个小时内理解如何使用它
策略引擎在防止重大问题方面可能非常有效,充当对一组操作的理智性进行的二次检查。与安全性一样,它应该在仅引入少量负担的同时,提供显著的好处。
策略引擎容易被过度开发,带着 100%保护的崇高目标,同时引入大量的复杂性和负担。通常,我会看到那些没有紧密结合单一工作流系统的策略引擎。相反,工程师们会设计一个通用系统,试图应对多个工具系统。
如果你的策略语句开始像编程语言(if语句、循环、函数)那样,说明你正朝着复杂性迈进。随着策略引擎变得通用,它们变得更复杂,难以处理。如果你在多个地方需要策略强制执行,这也是另一个警告信号。
并非所有工作流都能通过通用策略实现安全。当你拥有复杂的工作流时,可以自由设计一个为单个工作流做深度检查的策略。将你的if语句、循环和函数放在代码中,而不是配置中。
我见过工程师编写大量过于复杂的安全系统。专注于提供易于编写和更新的保护机制,覆盖 80%的情况,而不是 100%的情况。通过将创建执行操作的程序和验证这些操作是否符合策略的服务分离,你将不太可能在未来发生磁盘擦除类型的事件,更重要的是,你将能够保持开发速度。
在本节中,我们讨论了什么是 SOW。为了让我们的工作流服务理解 SOW 并强制执行它,我们设计了一个策略引擎,并创建了第一个可以应用于提交给我们系统的工作流的策略。
即使有策略,还是会出错。这可能只是一些事件的巧合,导致一个通常安全的操作变得不安全。为了能够快速响应这些类型的事件,让我们来看看如何引入紧急停止功能。
构建具有紧急停止功能的系统
系统将会失控。这是你在基础设施工具开发初期就需要接受的一个简单事实。
当你是一个小公司时,通常只有一小部分人非常了解系统,并监督任何更改以处理问题。如果这些人足够优秀,他们可以迅速响应问题。通常,这些人是软件的开发者。
随着公司规模的增长,工作开始变得更加专业化。公司越大,工作越专业化。当这种情况发生时,处理重大问题的第一响应者通常没有足够的访问权限或知识来处理这些问题。
这可能会在识别到重大问题和阻止问题恶化之间创建一个关键的时间差。
这就是允许紧急响应人员停止更改的功能所在。我们称之为紧急停止功能。
理解紧急停止
构建紧急停止系统有多种方式,但基本原理相同。软件将检查一个包含正在执行的工作流名称以及紧急停止状态的数据存储。
紧急停止系统的最简单版本有两种模式,如下所示:
-
Go -
Stop
执行任何工作类型的软件需要定期引用该系统。如果它找不到自己列出,或者系统表明处于Stop状态,则软件终止,或者如果是执行系统,则终止该工作流。
更复杂的版本可能包含站点信息,以便停止在站点上运行的所有工具,或者它可能包括其他状态,如Pause。这些实现起来更复杂,因此我们在这里将坚持使用这种简单形式。
让我们看看实现可能是什么样子。
构建一个紧急停止包
我们首先需要定义数据格式的样子。对于这个练习,我们将使用etcd。虽然我这里使用的是 JSON,但它也可以是数据库中的一个表格或协议缓冲区。
让我们定义工作流可能具有的状态,如下所示:
// Status indicates the emergency stop status.
type Status string
const (
Unknown Status = ""
Go Status = "go"
Stop Status = "stop"
)
这定义了几个状态,如下所示:
-
Unknown,表示状态未设置 -
Go,表示工作流可以执行 -
Stop,表示工作流应停止
关键是要知道,任何不是Go的状态都被视为Stop。
现在,让我们定义一个可以转换为 JSON 并从 JSON 转换的紧急停止入口,如下所示:
type Info struct {
// Name is the workflow name.
Name string
// Status is the emergency stop status.
Status Status
}
它包含以下字段:
-
Name,表示工作流的唯一名称 -
Status,表示此工作流的紧急停止状态
紧急停止包的另一个关键点是,每个工作流必须有一个入口。如果检查到一个没有命名的入口,它会被视为设置为Stop。
现在,我们需要验证一个入口。以下是处理方法:
func (i Info) validate() error {
i.Name = strings.TrimSpace(i.Name)
if i.Name == "" {
return fmt.Errorf(“es.json: rule with empty name”)
}
switch i.Status {
case Go, Stop:
default:
return fmt.Errorf("es.json: rule(%s) has invalid Status(%s), ignored", i.Name, i.Status)
}
return nil
}
上述代码执行以下操作:
-
移除工作流名称周围的任何空格。
-
如果
Name值为空,则表示错误。 -
如果
Status值既不是Go也不是Stop,则表示错误。
我们将这些错误视为规则不存在的错误。如果规则不存在,则工作流被认为处于Stop状态。
我们现在需要某些东西,以便定期读取此紧急停止文件或接收变化的通知。如果服务在短时间内无法访问保存我们紧急停止信息的数据存储,它应该报告Stop状态。
让我们定义一个Reader类型,用于访问我们的紧急停止数据,如下所示:
var Data *Reader
func init() {
r, err := newReader()
if err != nil {
panic(fmt.Sprintf("es error: %s", err))
}
Data = r
}
type Reader struct {
entries atomic.Value // map[string]Info
mu sync.Mutex
subscribers map[string][]chan Status
}
func newReader() (*Reader, error) {...}
func (r *Reader) Subscribe(name string) (chan Status, Cancel){...}
func (r *Reader) Status(name string) Status {...}
上述代码执行以下操作:
-
提供一个
Data变量,这是我们Reader类型的唯一访问点 -
提供一个
init()函数,在程序启动时访问我们的紧急停止数据。 -
提供了一个
Reader类型,允许我们读取紧急停止状态。 -
提供了一个
Subscribe()函数,返回工作流的状态变化,以及一个Cancel()函数,当你不再想订阅时调用。 -
提供一个
Status()函数,返回一次性状态。 -
提供了
newReader,这是我们的Reader构造函数。
这里没有提供完整代码,但可以在以下链接找到:
github.com/PacktPublishing/Go-for-DevOps/blob/rev0/chapter/16/workflow/internal/es/es.go
我们只允许通过Data访问紧急停止信息,它充当单例模式。这防止了多个实例同时轮询相同的数据。我更喜欢通过变量访问单例,以清楚表明只有一个实例存在。
我们现在有一个包,可以告诉我们紧急停止状态。让我们看看如何使用它来取消某些操作。
使用紧急停止包。
现在我们有了一个可以读取紧急停止数据的包,让我们展示如何使用它,如下所示:
type Job interface{
Run(ctx context.Context)
}
type Work struct {
name string
jobs []Job
}
func (w *work) Exec(ctx context.Context) error{
esCh, cancelES := es.Data.Subscribe(w.name)
defer cancelES() // Stop subscribing
if <-esCh != es.Go { // The initial state
return fmt.Errorf("es in Stop state")
}
var cancel context.CancelFunc
ctx, cancel = context.WithCancel(ctx)
defer cancel()
// If we get an emergency stop, cancel our context.
// If the context gets cancelled, then just exit.
go func() {
select {
case <-ctx.Done():
return
case <-esCh:
cancel()
}
}()
for _, job := range w.jobs {
if err := job(ctx); err != nil {
return err
}
}
return nil
}
上述代码执行了以下操作:
-
创建一个
Job,执行我们想要执行的某个操作。 -
创建了一个
Work类型,执行一组Jobs。 -
定义了
Exec(),它执行所有的Jobs。 -
使用给定的工作流名称订阅紧急停止。
-
如果我们没有从
Go状态开始,它会返回一个错误。 -
执行一个 goroutine,如果我们收到
Stop Status类型,它会调用cancel()。 -
执行保存在工作
.jobs中的 Job 实例。
这是一个简单的示例,使用context.Context对象来停止在调用cancel()时正在执行的任何Job。如果我们收到紧急停止状态变化(始终为Stop),我们会调用cancel()。
使用es包的更完整示例可以在这两个文件中找到:
一个示例的es.json文件,存储了紧急停止数据,可以在这里找到:
github.com/PacktPublishing/Go-for-DevOps/blob/rev0/chapter/16/workflow/configs/es.json
你可以在以下链接看到它作为我们Work.Run()方法的一部分,集成到我们的工作流系统中:
案例研究 – 谷歌的网络骨干紧急停止
在一次网络工具问题的早期事后分析中,发现负责处理重大事件的值班工程师需要一种停止自动化操作的方式。当时,我们有许多小工具可能会在任何时候与网络执行操作。值班工程师发现问题时,并没有合适的方式来阻止其他工程师执行工作或停止一个失控的程序。
第一个紧急停止包是通过这次事后分析创建的,并集成到现有的工具中。其工作原理是获取工具的订阅者名称,并将其与紧急停止文件中包含的正则表达式(regexes)进行匹配。每当文件发生更改或工具执行开始时,都会进行此检查。
这个方法曾被用来停止几项自动化任务,避免了问题的蔓延。然而,这种实现方式对于我们这种快速增长的组织来说存在缺陷。
首先,它要求每个工具开发人员都集成紧急停止包。当更多的团队在初始核心团队之外开发工具时,他们可能并不知道这是一个必需的要求。这导致了不受控的工具开发。而随着谷歌开发自己的网络设备,工具开发跨越了多个部门,这些部门在许多方面并没有协调。这意味着许多工具从未集成紧急停止,或者是在一个独立的系统中完成的。
即使在工具中集成了紧急停止,有时这种实现也存在缺陷,无法正常工作。每次集成都依赖工程师正确执行操作。
最终,紧急停止系统有一个默认的Go状态。因此,如果没有规则与您的订阅者 ID 匹配,则假定其处于Go状态。这意味着许多时候,您只能停止所有操作,或者必须翻阅代码找出订阅者 ID,以便重新启用除了问题工具之外的所有内容。
为了解决我们网络骨干中的这些问题,我们将骨干工作的执行集中到一个中央系统中。这为我们提供了一个单一且经过充分测试的紧急停止实现,经过长时间的审计后,我们将紧急停止包切换为停止任何不符合规则的操作。
这为我们的应急响应人员提供了在发生重大问题时停止骨干自动化和工具的能力。如果我们发现有问题的工具,我们可以允许其他所有工具继续运行,直到对该工具进行适当修复。
在这一部分中,你已经学习了什么是紧急停止系统,为什么它很重要,如何实现一个基础的系统,以及如何将紧急停止包集成到工具中。
总结
本章提供了如何编写在面对混乱时能够提供安全保障的工具的基本理解。我们展示了如何通过断路器或指数退避技术,在发生意外问题时避免网络和服务的过载。我们展示了如何通过速率限制自动化,在响应者还未作出反应前防止工作流失控。你已经了解了通过集中式策略引擎进行工具作用域管理,如何在不加重开发者负担的情况下提供第二层安全保障。我们学习了幂等性工作流的重要性,以便实现工作流的恢复。最后,我们介绍了如何通过紧急停机系统,帮助首批响应者在调查问题时,快速限制自动化系统的损害。
此时,如果你还没有玩过我们一直在开发的工作流系统,应该去探索代码并尝试示例。README.md 文件将帮助你入门。你可以通过以下链接找到它:
github.com/PacktPublishing/Go-for-DevOps/blob/rev0/chapter/16/workflow/README.md

订阅我们的在线数字图书馆,全面访问超过 7000 本书籍和视频,以及帮助你规划个人发展并推动职业发展的行业领先工具。更多信息,请访问我们的网站。
第十八章:为什么订阅?
-
花更少的时间学习,更多的时间编程,享受超过 4000 名行业专业人士提供的实用电子书和视频
-
利用专为你量身定制的技能计划提升学习
-
每月获取一本免费的电子书或视频
-
完全可搜索,方便访问重要信息
-
复制和粘贴、打印以及收藏内容
你知道 Packt 提供每本出版书籍的电子书版本,并提供 PDF 和 ePub 文件吗?你可以在packt.com升级为电子书版本,作为印刷书籍的顾客,你有权享受电子书版本的折扣。欲了解更多详情,请通过customercare@packtpub.com与我们联系。
在www.packt.com,你还可以阅读一系列免费的技术文章,注册各种免费的新闻通讯,并获得 Packt 书籍和电子书的独家折扣和优惠。
你可能喜欢的其他书籍
如果你喜欢这本书,你可能也会对 Packt 的其他书籍感兴趣:
学习 DevOps - 第二版
Mikael Krief
ISBN: 9781801818964
-
了解基础设施即代码模式和实践的基础
-
获取 Git 命令和 Git 流程的概述
-
安装并编写 Packer、Terraform 和 Ansible 代码,以基于 Azure 示例进行云基础设施的配置和提供
-
使用 Vagrant 创建本地开发环境
-
使用 Docker 和 Kubernetes 将应用容器化
-
应用 DevSecOps 进行测试合规性检查并保护 DevOps 基础设施
-
使用 Jenkins、Azure Pipelines 和 GitLab CI 构建 DevOps CI/CD 管道
-
探索蓝绿部署和开源项目的 DevOps 实践
DevOps 职业手册
John Knight, Nate Swenson
ISBN: 9781803230948
-
了解 DevOps 从业者的不同角色和职业路径
-
发现立足申请过程的有效技巧
-
为面试的各个阶段做准备,从电话筛选到技术挑战,再到现场面试
-
有效地建立人脉,帮助你的职业朝正确方向发展
-
为特定的 DevOps 角色量身定制简历
-
了解如何在获得录用后进行谈判
Packt 正在寻找像你这样的作者
如果你有兴趣成为 Packt 的作者,请访问authors.packtpub.com并立即申请。我们已经与成千上万的开发者和技术专家合作,帮助他们与全球技术社区分享见解。你可以提交通用申请,申请我们正在招聘的特定热门话题的作者,或者提交你自己的想法。
分享你的想法
现在你已经完成了Go for DevOps,我们非常希望听到你的想法!如果你是从亚马逊购买的这本书,请点击这里直接前往亚马逊的书评页面并分享你的反馈,或者在你购买书籍的网站上留下评论。
你的评价对我们和技术社区非常重要,将帮助我们确保提供优质的内容。


浙公网安备 33010602011771号