Go 语言 DevOps(四)

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

译者:飞龙

协议:CC BY-NC-SA 4.0

第十章:使用 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 提供了一组丰富的上下文变量、表达式、函数和条件语句,用以增强工作流的表现力。这将不是对所有这些项的详尽研究,但我们将重点介绍最关键的内容。

上下文变量

上下文变量提供了一种访问工作流运行、环境、步骤、密钥等信息的方式。最常见的上下文变量有githubenvsecretsmatrix。这些变量被视为映射,可以通过变量名和属性名进行索引。例如,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_onejob_two的任务。在这里,job_one是一个矩阵任务,它将在 Ubuntu、macOS 和 Windows 的最新版本上并行运行六个模板化任务,每个任务都会回显1.171.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选项卡。你应该看到你的第一个工作流成功执行的视图。它应当类似如下:

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

图 10.1 – 所有工作流视图

注意左侧的工作流列表,里面有一个名为first-workflow的工作流。我们可以看到,该工作流的第一次运行是针对我们的提交,提交信息为my first action

如果你点击my first action的工作流运行记录,你应该能看到如下内容:

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

图 10.2 – 工作流作业视图

注意左侧的Jobs列表中,echo作业旁边有一个绿色的勾,表示该作业已成功执行。在右侧,你可以看到执行的详细信息。

你可以点击echo作业,查看它的输出以及执行的步骤:

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

图 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 发送推文。源代码由两个包组成,maintweetertweeter 包包含将由我们的持续集成工作流执行的 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-versionos。指定了两个 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 版本组合上运行的目标:

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

图 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 ./...

在前面的步骤中,发生了以下情况:

  1. Go 通过actions/setup-go@v2动作安装,使用矩阵指定的 Go 版本。这个动作对所有 GitHub 用户可用,并通过 GitHub Marketplace 发布。Marketplace 中有许多可以简化工作流编写的动作。

  2. 当前ref的源代码是通过actions/checkout@v2动作在当前工作目录中克隆的。注意,动作没有指定名称。对于常用的动作,通常不提供名称。

  3. Lint 检查使用golangci/golangci-lint-action@v2执行,该动作会在代码库的源代码上安装并执行golangci-lint工具,满足确保代码通过 lint 质量检查的目标。这个特定的动作包括多个子 lint 工具,能够严格检查常见的 Go 性能和风格错误。

  4. 通过运行一个临时的 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}"

上述步骤执行以下操作:

  1. 安装 gox 命令行工具,以简化 Go 跨平台编译。

  2. 为每个指定的平台/架构构建跨平台的二进制文件,同时将 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"

前面的步骤执行了以下操作:

  • 执行 targzip 命令对二进制文件进行压缩。使用 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.jsonjq 来解析并选择 JSON 内容,以指定标题和发布说明。

此时,我们已经创建了一个面向多个平台和架构的发布版本,满足了我们对自动化的所有目标。让我们开始发布并查看结果。

创建推特发布

现在我们已经构建了一个发布作业来自动化推特发布,我们可以对仓库进行标签标记并发布应用程序的版本。为了启动发布自动化,我们将通过执行以下操作来创建并推送 v0.0.1 标签到仓库:

git tag v0.0.1
git push origin v0.0.1

在标签推送后,您应该能够进入 GitHub 仓库的 Actions 标签页,并看到标签工作流正在执行。如果您进入工作流页面,应该会看到如下内容:

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

图 10.5 – 显示依赖测试和发布作业的工作流作业视图

如前图所示,测试已经执行,随后发布作业也已经执行。如果您进入 release 作业页面,您应该会看到如下内容:

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

图 10.6 – 发布作业输出视图

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

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

图 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 的功能如下:

  1. 使用 golang:1.17 镜像作为中间构建容器,包含编译 tweeter 命令行工具所需的 Go 构建工具。使用构建者模式创建一个中间容器,包含构建工具和源代码,这些在最终产品中不会被使用。它为我们提供了一个构建静态链接的 Go 应用程序的临时区域,构建完成后可以将其添加到精简版的容器中。这样,最终的容器只会包含 Go 应用程序,而没有其他内容。

  2. 构建过程然后复制 go.modgo.sum,然后下载 tweeter 应用程序所需的 Go 依赖。

  3. tweeter 应用程序的源代码被复制到构建容器中,并编译为静态链接的二进制文件。

  4. 生产镜像是从 gcr.io/distroless/static:latest 基础镜像创建的,tweeter 应用程序则从中间构建容器中复制过来。

  5. 最后,默认入口点被设置为 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命令,获取从动作中回显的输出

让我们看看触发此工作流时会发生什么:

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

图 10.8 – 具有新 test-action 任务的工作流运行情况

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

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

图 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

让我们看看我们的图像发布工作流的结果:

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

图 10.10 – 图像发布任务的工作流视图

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

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

图 10.11 – 图像发布作业输出

上述工作流的结果是,我们现在将容器镜像推送到了 ghcr.io/devopsforgo/tweeter,并标记为 v1.0.0latest。现在,我们可以更新 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 已被替换,让我们运行工作流,看看性能优化的实际效果:

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

图 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.yamlaction.yml 的单个动作文件。

  • action.yaml 中的动作名称必须是 GitHub 市场中唯一的。该名称不得与任何 GitHub 特性、产品或 GitHub 保留的其他名称重叠。

  • 公共动作应遵循 v1v1.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并设置账户和应用程序。收集凭证后,您可以将它们添加到设置选项卡下的仓库秘钥中,如下截图所示:

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

图 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 应用,但这将在后面的章节中介绍。

本章的代码文件可以从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 能提供的一些强大功能。

以下是我们的服务架构示意图:

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

图 11.1 – ChatOps 和 Petstore 架构

归属

gstudioimagen 创建的贵宾犬矢量图 - www.freepik.com

Gophers by Egon Elbe:github.com/egonelbre/gophers

我们将专注于创建的两个服务是:

  • Ops 服务:Ops 服务完成实际工作,如与 Jaeger、Prometheus 交互,运行作业,或执行其他必要任务。这使得我们能够并行运行多个 ChatOps 服务(例如,如果你的公司从 Slack 迁移到 Microsoft Teams,可能就需要这样)。

这种架构的好处是,允许其他团队使用任何他们选择的编程语言编写工具,利用这些功能。

让我们深入了解 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 应用:

  1. 在浏览器中访问api.slack.com/apps

在这里,你需要点击以下按钮:

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

图 11.2 – 创建新应用按钮

系统接着会显示以下对话框:

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

图 11.3 – 创建应用选项

  1. 选择从应用清单创建选项。系统将展示以下内容:

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

图 11.4 – 选择工作区

  1. 选择你在本节开始时创建的工作区,然后点击创建应用。点击下一步按钮。

  2. github.com/PacktPublishing/Go-for-DevOps/tree/rev0/chapter/11/chatbot/slack.manifest的文件中复制文本,并将其粘贴到以下显示的页面中,格式为 YAML:

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

图 11.5 – 应用清单配置

  1. 你在前面的图中看到的文本应替换为文件中的文本。点击下一步按钮。

系统会显示一个关于机器人权限的摘要,如下所示:

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

图 11.6 – 机器人创建摘要

  1. 点击创建按钮。

  2. 这将带你进入一个名为基本信息的页面。向下滚动页面,直到看到应用级令牌,如下图所示:

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

图 11.7 – 应用级令牌列表

  1. 点击生成令牌和作用域按钮。这将引导你进入以下对话框:

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

图 11.8 – 应用令牌创建

  1. 将令牌名称设置为petstore-bot

  2. connections:writeauthorizations:read中提供这些作用域。现在,点击生成

  3. 在下一个页面,你将获得一个应用级令牌。你需要点击复制按钮,并将令牌暂时保存到某个地方。

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

图 11.9 – 应用令牌信息

在生产环境中,你应该将其存储在某种类型的安全密钥库中,如 Azure Key Vault 或 AWS Key Management Service。你需要将其放入一个名为.env的文件中,并且绝不能将此文件提交到代码库中。我们将在运行应用程序部分中介绍如何制作此文件。

注意

这里的密钥是一个在截图之后被删除的机器人密钥。

  1. 点击完成按钮。

  2. 在左侧菜单栏中,选择OAuth 和权限。在下面显示的屏幕中,点击安装到工作区

![图 11.10 – 在你的工作区安装令牌]

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

图 11.10 – 在你的工作区安装令牌

  1. 会弹出一个对话框,询问要将应用发布到哪个频道。选择你喜欢的任何频道并点击允许

你现在回到OAuth 和权限页面,但你会看到你的机器人身份验证令牌已列出。点击复制按钮,并将其存储在你之前存储应用令牌的位置。

运行应用程序

在这里,我们将使用 Docker Compose 启动我们的 Open Telemetry 服务、Jaeger、Prometheus 和我们的 Petstore 应用程序。启动这些服务后,我们将使用 Go 编译并运行实现与 Slack 连接的聊天机器人服务(ChatOps):

  1. Go-for-DevOps代码库(github.com/PacktPublishing/Go-for-DevOps/)中,进入chapter/11目录。

  2. 启动 Docker 容器:

    docker-compose up -d
    
  3. 一旦环境启动,切换到chapter/11/chatops目录。

  4. 你需要在此目录中创建一个.env文件,其中包含以下内容:

    AUTH_TOKEN=xoxb-[the rest of the token]
    APP_TOKEN=xapp-[the rest of the token]
    

这些是我们在设置 Slack 应用程序时生成的。

  1. 使用以下命令运行 ChatOps 服务器:

    go run chatbot.go
    
  2. 你应该能够看到以下消息输出到标准输出:

    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 – 基本帮助命令输出

让我们请求一些帮助,看看如何列出一些跟踪:

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

图 11.12 – 列出跟踪命令的帮助输出

那我们不妨请求系统给我们五个最近的追踪记录:

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

图 11.13 – 列出最后五个追踪记录的命令输出

我们也可以查询某个特定的追踪记录:

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

图 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 配置语言 2HCL 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-ebsubuntu。当我们在 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 虚拟化技术,hvmpv。由于 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_keysfile 条目里,添加以下内容:

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

这现在确保我们的dbusdbus-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 环境,但它们通常会落后几个版本。之前,我们可以通过使用fileshell(这些实际上几乎可以做任何事情)来完成,但如果你是应用程序提供商,想要为其他 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中并捕获STDOUTSTDERR

  • 使用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 中的各种类型,帮助测试你的插件。

这包括模拟ProvisionerUiCommunicator类型,以便进行测试。你可以在这里找到这些内容: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。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值