使用 GitHub Webhooks 自动部署 Web 应用程序
已发表: 2022-03-11任何开发 Web 应用程序并尝试在自己的非托管服务器上运行它们的人都知道部署应用程序和推送未来更新所涉及的繁琐过程。 平台即服务 (PaaS) 提供商可以轻松部署 Web 应用程序,而无需通过配置和配置单个服务器的过程,以换取成本略有增加和灵活性降低。 PaaS 可能使事情变得更容易,但有时我们仍然需要或想要在我们自己的非托管服务器上部署应用程序。 自动化将 Web 应用程序部署到您的服务器的过程一开始可能听起来让人不知所措,但实际上想出一个简单的工具来自动化这个过程可能比您想象的要容易。 实现此工具的难易程度很大程度上取决于您的需求有多简单,但实现起来肯定不难,并且通过执行 Web 应用程序的繁琐重复部分可能有助于节省大量时间和精力部署。
许多开发人员提出了自己的方法来自动化其 Web 应用程序的部署过程。 由于您部署 Web 应用程序的方式很大程度上取决于所使用的确切技术堆栈,因此这些自动化解决方案之间存在差异。 例如,自动部署 PHP 网站所涉及的步骤与部署 Node.js Web 应用程序不同。 存在其他解决方案,例如 Dokku,它们非常通用,并且这些东西(称为 buildpacks)适用于更广泛的技术堆栈。
在本教程中,我们将了解一个简单工具背后的基本思想,您可以使用 GitHub webhooks、buildpacks 和 Procfiles 构建该工具以自动化您的 Web 应用程序部署。 我们将在本文中探讨的原型程序的源代码可在 GitHub 上找到。
Web 应用程序入门
为了自动部署我们的 Web 应用程序,我们将编写一个简单的 Go 程序。 如果您不熟悉 Go,请不要犹豫,因为本文中使用的代码结构相当简单,应该很容易理解。 如果您愿意,您可以很容易地将整个程序移植到您选择的语言中。
在开始之前,请确保您的系统上安装了 Go 发行版。 要安装 Go,您可以按照官方文档中列出的步骤进行操作。
接下来,您可以通过克隆 GitHub 存储库来下载此工具的源代码。 由于本文中的代码片段标有相应的文件名,因此您应该可以轻松地进行操作。 如果您愿意,可以立即尝试。
在这个程序中使用 Go 的一个主要优点是我们可以以一种外部依赖最少的方式构建它。 在我们的例子中,要在服务器上运行这个程序,我们只需要确保我们已经安装了 Git 和 Bash。 由于 Go 程序被编译为静态链接的二进制文件,因此您可以在计算机上编译程序,将其上传到服务器,然后几乎零努力地运行它。 对于当今大多数其他流行的语言,这将需要在服务器上安装一些庞大的运行时环境或解释器来运行您的部署自动化程序。 如果做得好,Go 程序也可以很容易地在 CPU 和 RAM 上运行——这是你想要从这样的程序中获得的东西。
GitHub 网络钩子
使用 GitHub Webhooks,可以将 GitHub 存储库配置为在每次存储库中发生更改或某些用户在托管存储库上执行特定操作时发出事件。 这允许用户订阅这些事件,并通过在您的存储库周围发生的各种事件的 URL 调用得到通知。
创建 webhook 非常简单:
- 导航到存储库的设置页面
- 点击左侧导航菜单中的“Webhooks & Services”
- 单击“添加网络钩子”按钮
- 设置一个 URL,以及一个可选的秘密(这将允许接收者验证有效负载)
- 根据需要在表格上做出其他选择
- 单击绿色的“添加 webhook”按钮提交表单
GitHub 提供了关于 Webhook 的大量文档以及它们的工作原理、在负载中传递了哪些信息以响应各种事件等。就本文而言,我们对每次有人发出的“推送”事件特别感兴趣推送到任何存储库分支。
构建包
如今,Buildpacks 几乎是标准的。 许多 PaaS 提供商使用 buildpacks,您可以在部署应用程序之前指定堆栈的配置方式。 为您的 Web 应用程序编写 buildpack 非常容易,但通常在 Web 上进行快速搜索可以找到一个无需任何修改即可用于您的 Web 应用程序的 buildpack。
如果您已将应用程序部署到 Heroku 之类的 PaaS,您可能已经知道什么是 buildpack 以及在哪里可以找到它们。 Heroku 有一些关于构建包结构的综合文档,以及一些构建良好的流行构建包的列表。
我们的自动化程序将在启动应用程序之前使用编译脚本来准备应用程序。 例如,由 Heroku 构建的 Node.js 会解析 package.json 文件,下载适当版本的 Node.js,并下载应用程序的 NPM 依赖项。 值得注意的是,为了简单起见,我们的原型程序中不会对 buildpacks 提供广泛的支持。 现在,我们假设 buildpack 脚本编写为与 Bash 一起运行,并且它们将按原样在全新的 Ubuntu 安装上运行。 如有必要,您可以在未来轻松扩展它以解决更深奥的需求。
档案
Procfile 是简单的文本文件,允许您定义应用程序中的各种类型的进程。 对于大多数简单的应用程序,理想情况下您将拥有一个“Web”进程,该进程将是处理 HTTP 请求的进程。
编写 Procfiles 很容易。 每行定义一个进程类型,方法是键入其名称,后跟一个冒号,然后是生成该进程的命令:
<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 配置为仅发出“push”事件,您仍然可以期望在您的 hook 端点接收到至少一种其他类型的事件:“ping”。 此事件的目的是确定 webhook 是否已在 GitHub 上成功配置。
接下来,我们读取传入请求的整个正文,使用与配置 webhook 相同的秘密计算其 HMAC-SHA1,并通过将传入有效负载与包含在标头中的签名进行比较来确定传入有效负载的有效性。要求。 在我们的程序中,如果未配置密钥,我们将忽略此验证步骤。 附带说明一下,在没有至少对我们要在这里处理的数据量有某种上限的情况下阅读整个正文可能不是一个明智的主意,但是让我们保持简单,专注于关键方面这个工具的。
然后我们使用来自 GitHub 客户端库的结构来解组传入的有效负载。 由于我们知道这是一个“推送”事件,我们可以使用 PushEvent 结构。 然后,我们使用标准 json 编码库将有效负载解组到结构的实例中。 我们执行了几次健全性检查,如果一切正常,我们调用开始更新应用程序的函数。
更新应用程序
一旦我们在 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 struct 上的这个方法可以用来初始化本地仓库,其机制极其简单:
- 如果本地存储库不存在,则为它创建一个目录。
- 使用“git init”命令创建一个裸仓库。
- 将远程存储库的 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 中的“检测”脚本来确定应用程序是否是我们可以处理的。 然后,如果需要,我们为 buildpack 编译过程创建一个“缓存”目录。 由于此目录在构建中持续存在,因此我们可能不必创建新目录,因为在以前的编译过程中已经存在该目录。 此时,我们可以从 buildpack 调用“编译”脚本,并让它在启动之前为应用程序准备好所有必要的东西。 当 buildpacks 正常运行时,它们可以自己处理以前缓存的资源的缓存和重用。

重新启动应用程序
在我们实施这个自动化部署过程中,我们将在开始编译过程之前停止旧进程,然后在编译阶段完成后启动新进程。 尽管这使得该工具的实施变得容易,但它留下了一些改进自动化部署过程的潜在惊人方法。 要改进此原型,您可能可以从确保更新期间的零停机时间开始。 现在,我们将继续使用更简单的方法:
// 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 对应的进程。 另外两个是“Node”结构上的两个方法:“Start”和“Stop”。 通过通过这个每个节点的 Go 例程正在监视的特定通道传递“消息”来启动或停止进程。 您可以传递一条消息来启动该过程,也可以传递一条不同的消息来停止它。 由于启动或停止进程所涉及的实际步骤发生在单个 Go 例程中,因此没有机会获得竞争条件。
Go 例程启动一个无限循环,在该循环中它通过“stateCh”通道等待“消息”。 如果传递到此通道的消息请求节点启动进程(在“case StateUp”中),它使用 Bash 执行命令。 在执行此操作时,它会将命令配置为使用用户定义的环境变量。 它还将标准输出和错误流重定向到预定义的日志文件。
另一方面,要停止一个进程(在“case StateDown”中),它只是简单地杀死它。 这是您可能发挥创造力的地方,而不是立即终止进程,而是立即向它发送一个 SIGTERM 并在实际终止它之前等待几秒钟,让进程有机会优雅地停止。
“开始”和“停止”方法可以很容易地将适当的消息传递给通道。 与“Start”方法不同,“Stop”方法实际上是在返回之前等待进程被杀死。 “开始”只是将消息传递给通道以启动进程并返回。
结合这一切
最后,我们需要做的就是将所有内容连接到程序的主要功能中。 在这里我们将加载和解析配置文件,更新 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/toptal-hopper go install github.com/hjr265/toptal-hopper
这一系列命令将创建一个名为 hopper 的目录,将其设置为 GOPATH,从 GitHub 获取代码以及必要的 Go 库,并将程序编译成二进制文件,您可以在“$GOPATH/bin”目录中找到该文件。 在我们可以在服务器上使用它之前,我们需要创建一个简单的 Web 应用程序来测试它。 为方便起见,我创建了一个类似“Hello, world”的简单 Node.js Web 应用程序,并将其上传到另一个 GitHub 存储库,您可以在此测试中 fork 和重用它。 接下来,我们需要将编译好的二进制文件上传到服务器,并在同一目录下创建一个配置文件:
# 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”处侦听“push”事件有效负载。 设置 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 这样的东西来包含进程而不是直接运行它们。 弄清楚您的具体情况到底需要什么,并为此制定一个自动化程序可能会更明智。 或者也许使用互联网上可用的其他一些更稳定、经过时间考验的解决方案。 但是,如果您想推出一些非常定制的东西,我希望本文能帮助您做到这一点,并展示通过自动化 Web 应用程序部署过程从长远来看可以节省多少时间和精力。