如何开发一个 Packer 插件

Packer 概述

什么是 Packer

简单一句话概括就是:Packer 是一个自动化构建镜像流程的工具。

使用 Packer,我们可以编写简单的描述文件,就可以一行命令构建我们需要的镜像,可以是系统镜像,也可以是 Docker 镜像。

使用 Packer

以本文所围绕的系统镜像为例,很多时候会需要从官方的 ISO 来构建自己所需要的模板镜像,这些模板镜像往往包含了 cloud-init 服务、VM Tools,或者一些其他的服务,使得我么可以快速从模板启动自己所需要的操作系统。

Packer 就是抽象了这一构建过程,将之分为了「Build」、「Provision」、「Post-Process」。用户在不同的阶段使用对应的 Plugin,就能做到完全自动化置备了。

在「Build」阶段,会从 ISO 或其它物料(如基础模板等)构建出新的 Vm,待到系统安装完成后,执行「Provision」,通过 Shell、Ansible 等方式对系统进行修改,最后执行 Post-Process(如果有的话,例如计算校验和等)。

Packer 根据描述文件,一气呵成完成上面说到的步骤,而我们就可以得到新鲜出炉的模板镜像了。

谁需要开发 Packer Plugin

其实大部分情况下我们并不需要开发 Packer Plugin,大多数常见的平台都有提供 Plugin。普通用户只需要根据自己使用的云平台(如 AWS、vSphere 等)在官方网站或者 Github 查找相应的插件来使用即可。

当有用到一个比较冷门的云平台或者虚拟化平台,或者自己开发了虚拟化软件,或者公司在做的云平台产品等,那就需要自行实现一个 Plugin,来提供 Vm 创建、SSH 并置备、导出镜像的能力了。

如何开发插件

定个小目标

在本文中,会讲解如何实现一个 Packer Builder,对接到自己的云平台。

我们假设有一份 HCL 描述文件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
source "mycloud-iso" "basic-example" {
iso_url = "http://example.com/rockylinux-8.iso"
cpu = 1
memory = 2
disk = 10

boot_command = [
"<awit2s>",
"<up><wait><tab>",
" inst.ks=http://example.com/kickstart.cfg<enter><wait>"
]

ssh_username = "packer"
ssh_password = "packer"
}

build {
sources = ["sources.mycloud-iso.basic-example"]

provisioner "shell" {
inline = [
"echo \"example\" > example"
]
}
}

我们期望的效果是:

  1. 下载 ISO
  2. 使用 URL 中的 ISO 创建一个 Vm
  3. 通过 VNC 操作 Vm,通过模拟按键,传入一些命令序列,使用我们预定义的 Kickstart 来自动化安装操作系统
  4. 通过 SSH 连接到安装好的 Vm
  5. 执行 echo 命令,写一个文件
  6. 关机,导出 QCOW2 镜像

下面就开始讲解实现这个目标的大致思路~

实现插件

Packer 插件本质是一个供「packer」程序调用的二进制程序。这个二进制程序接收特定的参数,并执行具体的操作,返回具体的信息。

Packer 官方提供了一个脚手架 https://github.com/hashicorp/packer-plugin-scaffolding,可以使用 Golang 直接开发 Plugin。

代码组织

项目的根目录主要有几个目录:

  • builder:构建镜像的插件,也是本文要实现的内容
  • datasource:如果不实现该类型的插件可以删除
  • post-processor:如果不实现该类型的插件可以删除
  • provisioner:如果不实现该类型的插件可以删除
  • example:放置用于测试或者展示的 hcl 文件,比如上面目标章节里的内容
  • main.go:定义插件运行的入口文件

在 builder 下有一个「scaffolding」目录,这也就是我们置备的 builder 代码根目录,我们可以更名为 mycloud,表示是我们自己的云平台的 builder。

mycloud 目录下可以看到一些 golang 代码文件。如果只有一种 Builder,那么可以直接保留原来的代码结构。不过如果有多中置备方式,如既支持 ISO 置备镜像,也支持 Template 制备镜像,那么可以继续分几个目录,公共的代码再在同级增加一个 common 目录,大致结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
packer-plugin-mycloud
├── README.md
├── builder
│   └── mycloud
│   ├── common
│   │   ├── client.go
│   │   ├── constant.go
│   │   ├── helper.go
│   │   ├── ssh_config.go
│   │   ├── step_export.go
│   │   ├── step_shutdown.go
│   │   ├── step_vnc_boot_command.go
│   │   └── step_vnc_connect.go
│   └── iso
│   ├── artifact.go
│   ├── builder.go
│   ├── config.go
│   ├── config.hcl2spec.go
│   ├── iso_config.go
│   ├── step_create_vm.go
│   └── step_prepare_iso.go
├── example
│   ├── README.md
│   ├── build.pkr.hcl
│   ├── data.pkr.hcl
│   └── variables.pkr.hcl
├── go.mod
├── go.sum
├── main.go
└── version
└── version.go

在 Packer 中还有一些约定的规则,主要是插件名称以及 source 名:

  • 二进制名称需要是 packer-plugin-[mycloud],这里的 project 通常指代某个平台
  • 在 main.go 中注册的插件,比如注册了一个 iso,那在 HCL 描述文件中的 source 就会是 mycloud-iso

设计思路

一个 Builder Plugin,是由多个「Step」组成。Packer 在执行的时候,按照 Step 的顺序,一步一步执行,如果某一步骤出错就会抛出错误,而所有步骤执行完成,就可以返回一个 Artifact 对象了,这个 Artifact 对象表示了最终构建完成的产物。

从代码层面来看:

  • Builder 是一个实现了 Builder(packer-plugin-sdk/packer/builder.go)接口的对象
  • Step 是实现了 Step(packer-plugin-sdk/multistep/multistep.go)接口的对象
  • Artifact 是实现了 Artifact(packer-plugin-sdk/packer/artifact.go)接口的对象

Builder

Builder 中的 Run 方法定义了一个 multistep.Step 数组,来描述一个 Builder 的所有步骤,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
type Builder struct {}

// ...

func (b *Builder) Run(ctx context.Context, ui packersdk.Ui, hook packersdk.Hook) (packersdk.Artifact, error) {
steps := []multistep.Step{
&StepPrepareISO{},
&StepCreateVM{},
&common.StepVNCConnect{},
&common.StepVNCBootCommand{},
&communicator.StepConnect{},
&commonsteps.StepProvision{},
&common.StepShutdown{},
&common.StepExport{},
}
}

// ...

Builder 最终返回的是 Artifact 对象或错误。

Step

每一个 Step 通过 Run 方法实现具体的功能,并返回是否继续下一步(Halt / Continue),例如创建虚拟机

1
2
3
4
5
6
7
8
func (s *StepCreateVM) Run(ctx context.Context, state multistep.StateBag) multistep.StepAction {
ui := state.Get(common.PackerUIStateKey).(packersdk.Ui)
config := state.Get(common.BuilderConfigStateKey).(*Config)

// code for create VM...

return multistep.ActionContinue
}

可以看到有一个 state 对象,我们可以在整个 Build 过程中,任意往里面存储任意的对象,并在需要的时候读取,从而实现一些跨 Step 通讯的需求。

可以看到,我们按部就班实现 Builder、Step 就可以组装成一个插件了。

Config

配置是从 HCL 描述文件中读取到的内容,最终解析为我们每个 Step 所支持的参数,用来为 Step 执行提供输入。

每个 Step 的 Config 我都单独写了一个结构体,并且最终组合到 iso/config.go 的 Config struct 中,进而生成扁平的 config.hcl2spec.go 文件。

Packer SDK

Packer 提供了丰富的 SDK 能力,甚至可以让我们可以轻松实现一些复杂的功能

  • UI:Packer 提供了一个 ui 的 package,可以用来向控制台输出消息、进度等
  • VNC:Packer SDK 内置了 VNC 相关的操作,我们可以传入 Command,即可操作 VNC,来实现一些简单的操作和输入:比如上下移动 Boot 菜单,修改一个选项,增加 HTTP Kickstart 文件 后启动
  • Provision Connector:可以定义一个函数来获取 SSH 的 Host,当获取到 Host 后,Packer 就可以通过 SSH 等方式进行连接,
  • Provision:加入这个步骤,Packer 就会去自动执行描述文件中 Provision 的内容

代码调试

Packer 的代码调试很简单,可以写单元测试针对性地测试每一个 Step,也可以编译好之后使用插件来进行调试。对于我们自己开发的插件,Packer 会在运行目录或者 Packer 安装目录按照约定进行插件的查找:比如我们的 HCL 文件中 source 为 mycloud-iso,packer 就会找有没有 packer-plugin-mycloud 这个二进制文件,没有就会报错;如果找到了,就确认下这个二进制文件有没有注册 iso 这个 Builder,如果注册了就会使用这个 Builder 来进行构建了。

踩过的坑

配置生成

每个 Builder 中有一个 config.hcl2spec.go 文件,它是将我们写的 config.go 中的配置做了扁平化处理后,以建立 HCL 与配置之间的映射关系的代码文件。该文件的生成需要用 1.18 及以下的 Golang 版本,否则会出错。

IP 获取

每次创建的 Vm,单纯靠操作 VNC 来做自动化的置备是一件难度比较大,且容易出错的,因此最稳妥的方式是获取到 VM IP,通过 SSH 等方式连接到 VM 后再进行操作。对于 IP 的获取就相对比较麻烦,而且往往都有较多的限制。这里列一下当时想到的一些思路:

  • 原始镜像本身就携带 VM Tools 或其它自启服务,能够启动后自动向云平台或虚拟化软件上报自己的 IP,这是一种比较可靠的方式,不过需要定制镜像,内置一些服务,同时外部有服务能够接收并记录 VM 的 IP。
  • 在同一网段中运行一个服务,持续捕获网络中的 ARP 与 DHCP 数据包,并在构建时根据 MAC 地址找到对应的 IP,这种方式会有一些不准确的因素存在,并且必须抓包服务跟 VM 在同一网络下。
  • 从支持 cloud-init 等的模板来创建镜像,并在安装的时候就配置静态 IP,在一些场景下需要操作后重置 cloud-init,以便新模板能够继续使用 cloud-init

最终我使用了第二种方式,相对简单一些,并且自由度高一些,顺便还写了一个玩具项目来做这个事,这就等再写一篇文章来介绍了。

Config Prepare

每个配置文件可以通过自定义一个 Prepare 方法,来做一些配置的预处理,比如设置默认值等,具体可以参考 vmware Builder 中的实现了。在开发过程中,目前主要是使用 communicator/config.go 中的 Config 类型需要做一下 Prepare。

相关链接