个性化阅读
专注于IT技术分析

使用GitHub Webhooks自动部署Web应用程序

本文概述

任何开发Web应用程序并尝试在自己的非托管服务器上运行它们的人都知道部署应用程序和推送未来更新所涉及的繁琐过程。平台即服务(PaaS)提供程序使部署Web应用程序变得容易, 而无需经历供应和配置单个服务器的过程, 以换取成本的轻微增加和灵活性的降低。 PaaS可能使事情变得更容易, 但是有时我们仍然需要或想要在我们自己的非托管服务器上部署应用程序。最初, 自动化将Web应用程序部署到服务器的过程听起来有些困难, 但实际上, 想出一个简单的工具来实现此自动化可能比你想象的要容易。实施此工具的难易程度在很大程度上取决于你的需求有多简单, 但是实现它当然并不难, 并且可以通过做一些繁琐的Web应用程序重复工作来节省很多时间和精力。部署。

许多开发人员想出了自己的方法来自动化其Web应用程序的部署过程。由于部署Web应用程序的方式在很大程度上取决于所使用的确切技术堆栈, 因此这些自动化解决方案彼此之间有所不同。例如, 自动部署PHP网站所涉及的步骤与部署Node.js Web应用程序不同。还存在其他解决方案, 例如Dokku, 这些解决方案非常通用, 这些东西(称为buildpacks)可以在更广泛的技术堆栈中正常工作。

Web应用程序和webhooks

在本教程中, 我们将研究一个简单工具背后的基本思想, 你可以构建该工具来使用GitHub webhooks, buildpacks和Procfiles自动执行Web应用程序部署。我们将在本文中探讨的原型程序的源代码可在GitHub上找到。

Web应用程序入门

为了自动化Web应用程序的部署, 我们将编写一个简单的Go程序。如果你不熟悉Go, 请不要犹豫, 因为本文中使用的代码结构非常简单, 应该很容易理解。如果你愿意, 可以很容易地将整个程序移植到你选择的语言中。

在开始之前, 请确保已在系统上安装Go发行版。要安装Go, 你可以按照官方文档中概述的步骤进行操作。

接下来, 你可以通过克隆GitHub存储库下载此工具的源代码。这应该使你易于理解, 因为本文中的代码片段带有相应的文件名。如果你愿意, 可以立即尝试。

在该程序中使用Go的一个主要优点是, 我们可以以对外部依赖性最小的方式来构建它。就我们而言, 要在服务器上运行该程序, 我们只需要确保已安装了Git和Bash。由于Go程序被编译为静态链接的二进制文件, 因此你可以在计算机上编译该程序, 将其上传到服务器, 然后以几乎不费吹灰之力运行它。对于当今的其他大多数流行语言, 这仅需要在服务器上安装一些庞大的运行时环境或解释器即可运行你的部署自动化器。正确执行Go程序后, 在CPU和RAM上运行也非常容易-这是你想要的此类程序中的东西。

GitHub Webhooks

使用GitHub Webhooks, 可以将GitHub存储库配置为在存储库中发生任何更改或某些用户对托管存储库执行特定操作时发出事件。这使用户可以订阅这些事件, 并通过URL调用来通知围绕存储库发生的各种事件。

创建一个webhook非常简单:

  1. 导航到存储库的设置页面
  2. 点击左侧导航菜单中的” Webhooks&Services”
  3. 点击”添加webhook”按钮
  4. 设置一个URL, 并可选地设置一个秘密(这将使收件人可以验证有效载荷)
  5. 根据需要在表单上进行其他选择
  6. 单击绿色的”添加网络挂钩”按钮提交表单
Github Webhooks

GitHub提供了有关Webhooks的大量文档, 以及它们如何工作, 响应各种事件在有效负载中传递了哪些信息等。就本文而言, 我们对每次有人发出的”推送”事件特别感兴趣。推送到任何存储库分支。

构建包

如今, 构建包几乎是标准的。由许多PaaS提供程序使用的buildpack允许你指定在部署应用程序之前如何配置堆栈。为你的Web应用程序编写构建包确实很容易, 但是在Web上进行快速搜索通常可以找到一个无需任何修改即可用于Web应用程序的构建包。

如果你已经将应用程序像Heroku一样部署到PaaS, 那么你可能已经知道什么是buildpack, 以及在哪里可以找到它们。 Heroku有一些有关构建包结构的全面文档, 以及一些构建良好的流行构建包的列表。

我们的自动化程序将在启动之前使用编译脚本来准备应用程序。例如, 由Heroku构建的Node.js解析package.json文件, 下载适当版本的Node.js, 并下载该应用程序的NPM依赖项。值得注意的是, 为了简单起见, 我们的原型程序中不会对buildpack提供广泛的支持。现在, 我们将假定buildpack脚本被编写为可与Bash一起运行, 并且它们将按原样在全新的Ubuntu安装上运行。如有必要, 你可以在将来轻松扩展此功能, 以满足更深奥的需求。

程序文件

Procfile是简单的文本文件, 可让你定义应用程序中具有的各种类型的进程。对于大多数简单的应用程序, 理想情况下, 你将具有一个” Web”流程, 该流程将处理HTTP请求。

编写Procfile很容易。每行定义一种进程类型, 方法是键入其名称, 后跟冒号, 再生成该进程的命令:

<type>: <command>

例如, 如果你正在使用基于Node.js的Web应用程序, 则要启动Web服务器, 你将执行命令” node index.js”。你只需在代码的基本目录中创建一个Procfile, 并使用以下命令将其命名为” Procfile”:

web: node index.js

我们将要求应用程序在Procfiles中定义进程类型, 以便我们在拉入代码后可以自动启动它们。

处理事件

在我们的程序中, 我们必须包括一个HTTP服务器, 该服务器将允许我们从GitHub接收传入的POST请求。我们将需要专用一些URL路径来处理来自GitHub的请求。处理这些传入有效负载的函数将如下所示:

// hook.go

type HookOptions struct {
	App    *App
	Secret string
}

func NewHookHandler(o *HookOptions) http.Handler {
	return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
		evName := r.Header.Get("X-Github-Event")
		if evName != "push" {
			log.Printf("Ignoring '%s' event", evName)
			return
		}

		body, err := ioutil.ReadAll(r.Body)
		if err != nil {
			http.Error(w, "Internal Server Error", http.StatusInternalServerError)
			return
		}

		if o.Secret != "" {
			ok := false
			for _, sig := range strings.Fields(r.Header.Get("X-Hub-Signature")) {
				if !strings.HasPrefix(sig, "sha1=") {
					continue
				}
				sig = strings.TrimPrefix(sig, "sha1=")
				mac := hmac.New(sha1.New, []byte(o.Secret))
				mac.Write(body)
				if sig == hex.EncodeToString(mac.Sum(nil)) {
					ok = true
					break
				}
			}
			if !ok {
				log.Printf("Ignoring '%s' event with incorrect signature", evName)
				return
			}
		}

		ev := github.PushEvent{}
		err = json.Unmarshal(body, &ev)
		if err != nil {
			log.Printf("Ignoring '%s' event with invalid payload", evName)
			http.Error(w, "Bad Request", http.StatusBadRequest)
			return
		}

		if ev.Repo.FullName == nil || *ev.Repo.FullName != o.App.Repo {
			log.Printf("Ignoring '%s' event with incorrect repository name", evName)
			http.Error(w, "Bad Request", http.StatusBadRequest)
			return
		}

		log.Printf("Handling '%s' event for %s", evName, o.App.Repo)

		err = o.App.Update()
		if err != nil {
			return
		}
	})
}

我们首先验证生成此有效负载的事件的类型。由于我们仅对”推送”事件感兴趣, 因此我们可以忽略所有其他事件。即使你将Webhook配置为仅发出”推送”事件, 你仍有望在挂钩端点收到至少另一种事件:” ping”。此事件的目的是确定Webhook是否已在GitHub上成功配置。

接下来, 我们读取传入请求的整个主体, 使用与配置Webhook相同的秘密来计算其HMAC-SHA1, 并通过将传入有效负载与包含在请求头中的签名进行比较来确定传入有效负载的有效性。请求。在我们的程序中, 如果未配置密码, 我们将忽略此验证步骤。顺便说一句, 在没有至少要在此处处理多少数据的上限的情况下读取整个正文可能不是一个明智的主意, 但让我们保持简单以专注于关键方面这个工具

然后, 我们使用GitHub客户端库中的struct进行Go解组传入的有效负载。因为我们知道这是一个” push”事件, 所以我们可以使用PushEvent结构。然后, 我们使用标准的json编码库将有效载荷解组到struct的实例中。我们执行了两次健全性检查, 如果一切正常, 我们将调用开始更新应用程序的功能。

更新申请

在Webhook端点收到事件通知后, 就可以开始更新应用程序了。在本文中, 我们将研究此机制的相当简单的实现, 并且肯定会有改进的空间。但是, 它应该可以使我们开始一些基本的自动化部署过程。

webhook应用流程图

初始化本地存储库

这个过程将从简单的检查开始, 以确定这是否是我们第一次尝试部署该应用程序。我们将通过检查本地存储库目录是否存在来实现。如果不存在, 我们将首先初始化本地存储库:

// app.go

func (a *App) initRepo() error {
	log.Print("Initializing repository")

	err := os.MkdirAll(a.repoDir, 0755)
	// Check err

	cmd := exec.Command("git", "--git-dir="+a.repoDir, "init")
	cmd.Stderr = os.Stderr
	err = cmd.Run()
	// Check err

	cmd = exec.Command("git", "--git-dir="+a.repoDir, "remote", "add", "origin", fmt.Sprintf("[email protected]:%s.git", a.Repo))
	cmd.Stderr = os.Stderr
	err = cmd.Run()
	// Check err

	return nil
}

App结构上的此方法可用于初始化本地存储库, 其机制非常简单:

  1. 如果本地存储库不存在, 请为其创建目录。
  2. 使用” git init”命令创建一个裸仓库。
  3. 将远程存储库的URL添加到我们的本地存储库, 并将其命名为” origin”。

有了初始化的存储库后, 获取更改应该很简单。

取得变更

要从远程存储库中获取更改, 我们只需要调用一个命令:

// app.go

func (a *App) fetchChanges() error {
	log.Print("Fetching changes")

	cmd := exec.Command("git", "--git-dir="+a.repoDir, "fetch", "-f", "origin", "master:master")
	cmd.Stderr = os.Stderr
	return cmd.Run()
}

通过以这种方式对本地存储库进行” git fetch”, 我们可以避免Git在某些情况下无法快速转发的问题。并不是说强制获取是你应该依靠的东西, 但是如果你需要强制推送到远程存储库, 则可以轻松处理它。

编译应用

由于我们使用buildpacks中的脚本来编译正在部署的应用程序, 因此我们的任务相对简单:

// app.go

func (a *App) compileApp() error {
	log.Print("Compiling application")

	_, err := os.Stat(a.appDir)
	if !os.IsNotExist(err) {
		err = os.RemoveAll(a.appDir)
		// Check err
	}
	err = os.MkdirAll(a.appDir, 0755)
	// Check err
	cmd := exec.Command("git", "--git-dir="+a.repoDir, "--work-tree="+a.appDir, "checkout", "-f", "master")
	cmd.Dir = a.appDir
	cmd.Stderr = os.Stderr
	err = cmd.Run()
	// Check err

	buildpackDir, err := filepath.Abs("buildpack")
	// Check err

	cmd = exec.Command("bash", filepath.Join(buildpackDir, "bin", "detect"), a.appDir)
	cmd.Dir = buildpackDir
	cmd.Stderr = os.Stderr
	err = cmd.Run()
	// Check err

	cacheDir, err := filepath.Abs("cache")
	// Check err
	err = os.MkdirAll(cacheDir, 0755)
	// Check err

	cmd = exec.Command("bash", filepath.Join(buildpackDir, "bin", "compile"), a.appDir, cacheDir)
	cmd.Dir = a.appDir
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	return cmd.Run()
}

我们首先删除先前的应用程序目录(如果有)。接下来, 我们创建一个新分支, 并检出master分支的内容。然后, 我们使用已配置的buildpack中的” detect”脚本来确定应用程序是否可以处理。然后, 如果需要, 我们为buildpack编译过程创建一个”缓存”目录。由于此目录在所有构建中均存在, 因此可能不必创建新目录, 因为先前的一些编译过程中已经存在该目录。此时, 我们可以从buildpack中调用” compile”脚本, 并在启动前使其准备好应用程序所需的一切。当buildpack正常运行时, 它们可以自行处理以前缓存的资源的缓存和重用。

重新启动应用

在实现此自动部署过程的过程中, 我们将在开始编译过程之前停止旧过程, 然后在编译阶段完成后再开始新过程。尽管这使该工具的实现变得容易, 但是它留下了一些潜在的令人惊奇的方法来改进自动部署过程。为了改进该原型, 你可以首先确保在更新期间零停机。现在, 我们将继续使用更简单的方法:

// app.go

func (a *App) stopProcs() error {
	log.Print(".. stopping processes")

	for _, n := range a.nodes {
		err := n.Stop()
		if err != nil {
			return err
		}
	}

	return nil
}

func (a *App) startProcs() error {
	log.Print("Starting processes")

	err := a.readProcfile()
	if err != nil {
		return err
	}

	for _, n := range a.nodes {
		err = n.Start()
		if err != nil {
			return err
		}
	}

	return nil
}

在我们的原型中, 我们通过遍历节点阵列来停止和启动各种进程, 其中每个节点都是与应用程序实例之一相对应的进程(在服务器上启动此工具之前进行配置)。在我们的工具中, 我们跟踪每个节点的流程当前状态。我们还为其维护单个日志文件。在启动所有节点之前, 将从给定的端口号开始为每个节点分配一个唯一的端口:

// node.go

func NewNode(app *App, name string, no int, port int) (*Node, error) {
	logFile, err := os.OpenFile(filepath.Join(app.logsDir, fmt.Sprintf("%s.%d.txt", name, no)), os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
	if err != nil {
		return nil, err
	}

	n := &Node{
		App:     app, Name:    name, No:      no, Port:    port, stateCh: make(chan NextState), logFile: logFile, }

	go func() {
		for {
			next := <-n.stateCh
			if n.State == next.State {
				if next.doneCh != nil {
					close(next.doneCh)
				}
				continue
			}

			switch next.State {
			case StateUp:
				log.Printf("Starting process %s.%d", n.Name, n.No)

				cmd := exec.Command("bash", "-c", "for f in .profile.d/*; do source $f; done; "+n.Cmd)
				cmd.Env = append(cmd.Env, fmt.Sprintf("HOME=%s", n.App.appDir))
				cmd.Env = append(cmd.Env, fmt.Sprintf("PORT=%d", n.Port))
				cmd.Env = append(cmd.Env, n.App.Env...)
				cmd.Dir = n.App.appDir
				cmd.Stdout = n.logFile
				cmd.Stderr = n.logFile
				err := cmd.Start()
				if err != nil {
					log.Printf("Process %s.%d exited", n.Name, n.No)
					n.State = StateUp

				} else {
					n.Process = cmd.Process
					n.State = StateUp
				}

				if next.doneCh != nil {
					close(next.doneCh)
				}

				go func() {
					err := cmd.Wait()
					if err != nil {
						log.Printf("Process %s.%d exited", n.Name, n.No)
						n.stateCh <- NextState{
							State: StateDown, }
					}
				}()

			case StateDown:
				log.Printf("Stopping process %s.%d", n.Name, n.No)

				if n.Process != nil {
					n.Process.Kill()
					n.Process = nil
				}

				n.State = StateDown
				if next.doneCh != nil {
					close(next.doneCh)
				}
			}
		}
	}()

	return n, nil
}

func (n *Node) Start() error {
	n.stateCh <- NextState{
		State: StateUp, }
	return nil
}

func (n *Node) Stop() error {
	doneCh := make(chan int)
	n.stateCh <- NextState{
		State:  StateDown, doneCh: doneCh, }
	<-doneCh
	return nil
}

乍一看, 这似乎比我们到目前为止所做的复杂。为了使事情易于理解, 让我们将上面的代码分为四个部分。前两个在” NewNode”功能内。调用时, 它将填充” Node”结构的实例, 并生成Go例程, 该例程有助于启动和停止与此Node对应的进程。另外两个是”节点”结构上的两种方法:”开始”和”停止”。通过使”消息”通过该按节点执行例程正在监视的特定通道来启动或停止进程。你可以传递一条消息来启动该过程, 也可以传递另一条消息来停止该过程。由于启动或停止过程所涉及的实际步骤是在单个Go例程中进行的, 因此没有机会获得竞争条件。

Go例程启动一个无限循环, 在该循环中等待通过” stateCh”通道的”消息”。如果传递到此通道的消息请求节点启动进程(在” case StateUp”中), 则它将使用Bash执行命令。这样做时, 它会将命令配置为使用用户定义的环境变量。它还将标准输出和错误流重定向到预定义的日志文件。

另一方面, 要停止一个进程(在” case StateDown”内部), 只需将其杀死即可。在这里, 你可能会很有创造力, 而不是立即终止该进程, 而不是立即终止该进程, 而是发送一个SIGTERM并等待几秒钟, 从而使进程有机会优雅地停止。

使用”开始”和”停止”方法可以轻松地将适当的消息传递到通道。与”开始”方法不同, “停止”方法实际上在返回之前等待进程被杀死。 “开始”只是将消息传递到通道以开始该过程并返回。

结合所有

最后, 我们所需要做的就是将所有内容连接到程序的主要功能中。在这里, 我们将加载和解析配置文件, 更新buildpack, 尝试一次更新我们的应用程序, 并启动Web服务器以侦听来自GitHub的传入”推送”事件有效负载:

// main.go

func main() {
	cfg, err := toml.LoadFile("config.tml")
	catch(err)

	url, ok := cfg.Get("buildpack.url").(string)
	if !ok {
		log.Fatal("buildpack.url not defined")
	}
	err = UpdateBuildpack(url)
	catch(err)

	// Read configuration options into variables repo (string), env ([]string) and procs (map[string]int)
	// ...

	app, err := NewApp(repo, env, procs)
	catch(err)

	err = app.Update()
	catch(err)

	secret, _ := cfg.Get("hook.secret").(string)

	http.Handle("/hook", NewHookHandler(&HookOptions{
		App:    app, Secret: secret, }))

	addr, ok := cfg.Get("core.addr").(string)
	if !ok {
		log.Fatal("core.addr not defined")
	}

	err = http.ListenAndServe(addr, nil)
	catch(err)
}

由于我们要求buildpacks是简单的Git存储库, 因此” UpdateBuildpack”(在buildpack.go中实现)仅对存储库URL进行必要的” git clone”和” git pull”操作, 以更新buildpack的本地副本。

尝试一下

如果你尚未克隆存储库, 则可以立即进行。如果你安装了Go发行版, 则应该可以立即编译程序。

mkdir hopper
cd hopper
export GOPATH=`pwd`
go get github.com/hjr265/srcmini-hopper
go install github.com/hjr265/srcmini-hopper

此命令序列将创建一个名为hopper的目录, 将其设置为GOPATH, 从GitHub中获取代码以及必要的Go库, 然后将该程序编译为二进制文件, 你可以在” $ GOPATH / bin”目录中找到该二进制文件。在我们可以在服务器上使用它之前, 我们需要创建一个简单的Web应用程序进行测试。为了方便起见, 我创建了一个简单的类似” Hello, world”的Node.js Web应用程序, 并将其上传到另一个GitHub存储库, 你可以在该存储库中派生并重复使用此测试。接下来, 我们需要将编译后的二进制文件上传到服务器, 并在同一目录中创建一个配置文件:

# config.tml 

[core]
addr = ":26590"
[buildpack]
url = "https://github.com/heroku/heroku-buildpack-nodejs.git"

[app]
repo = "hjr265/hopper-hello.js"

	[app.env]
	GREETING = "Hello"

	[app.procs]
	web = 1

[hook]
secret = ""

配置文件中的第一个选项” core.addr”使我们可以配置程序内部Web服务器的HTTP端口。在上面的示例中, 我们将其设置为”:26590″, 这将使程序在” http:// {host}:26590 / hook”上侦听”推送”事件有效负载。设置GitHub Webhook时, 只需将” {host}”替换为指向你服务器的域名或IP地址即可。如果使用某种防火墙, 请确保端口是开放的。

接下来, 我们通过设置其Git URL选择一个buildpack。在这里, 我们使用Heroku的Node.js buildpack。

在” app”下, 我们将” repo”设置为托管应用程序代码的GitHub存储库的全名。因为我将示例应用程序托管在” https://github.com/hjr265/hopper-hello.js”, 所以存储库的全名是” hjr265 / hopper-hello.js”。

然后, 我们为应用程序设置一些环境变量, 以及我们需要的每种类型的进程的数量。最后, 我们选择一个秘密, 以便我们可以验证传入的”推送”事件有效负载。

现在, 我们可以在服务器上启动自动化程序。如果一切配置正确(包括部署SSH密钥, 以便可以从服务器访问存储库), 则程序应获取代码, 使用buildpack准备环境, 然后启动应用程序。现在, 我们需要做的就是在GitHub存储库中设置一个webhook来发出推送事件, 并将其指向” http:// {host}:26590 / hook”。确保将” {host}”替换为指向服务器的域名或IP地址。

为了最终对其进行测试, 请对示例应用程序进行一些更改, 然后将其推送到GitHub。你会注意到, 自动化工具将立即生效并更新服务器上的存储库, 编译应用程序, 然后重新启动它。

总结

根据我们的大多数经验, 我们可以说这是非常有用的。我们在本文中准备的原型应用程序可能不是你想要在生产系统上使用的原样。有大量的改进空间。像这样的工具应该具有更好的错误处理, 支持正常的关闭/重新启动, 并且你可能想使用类似Docker的东西来包含进程, 而不是直接运行它们。弄清楚特定情况下你真正需要什么, 并为此提出一个自动化程序, 可能更明智。或者, 也许使用其他一些更稳定, 经过时间考验的解决方案, 这些解决方案可以在Internet上找到。但是, 如果你想推出一些非常定制的内容, 希望本文能够为你提供帮助, 并说明通过自动化Web应用程序部署过程从长远来看可以节省多少时间和精力。

赞(0)
未经允许不得转载:srcmini » 使用GitHub Webhooks自动部署Web应用程序

评论 抢沙发

评论前必须登录!