作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.
马哈茂德·里德万的头像

Mahmud Ridwan

Mahmud是一名软件开发人员,拥有多年的经验和效率诀窍, scalability, 稳定的解.

工作经验

13

Share

任何开发web应用程序并试图在自己的非托管服务器上运行它们的人都意识到部署应用程序和推送未来更新所涉及的繁琐过程. 平台即服务(PaaS)提供商使得部署web应用程序变得容易,而不必经历供应和配置单个服务器的过程, 代价是成本略微增加,灵活性下降. PaaS可能使事情变得更容易, 但有时我们仍然需要或想要在我们自己的非托管服务器上部署应用程序. 一开始,将web应用程序部署到服务器的自动化过程可能听起来难以承受, 但实际上,想出一个简单的工具来实现自动化可能比你想象的要容易. 实现这个工具有多容易,很大程度上取决于您的需求有多简单, 但这并不难实现, 并且可以通过执行web应用程序部署的繁琐重复部分来帮助节省大量的时间和精力.

Many developers 是否已经提出了自己的方法来自动化web应用程序的部署过程. 因为如何部署web应用程序在很大程度上取决于所使用的确切技术堆栈, 这些自动化解决方案各不相同. 例如,自动涉及的步骤 部署PHP网站 不同于 部署节点.Js web应用. 还有其他解决方案,例如 Dokku, 这是相当通用的,这些东西(称为构建包)可以很好地与更广泛的技术堆栈一起工作.

Web应用程序和webhook

在本教程中, 我们将看看一个简单的工具背后的基本思想,你可以构建一个使用GitHub webhook自动化web应用程序部署的工具, buildpacks, and Procfiles. 我们将在本文中探索的原型程序的源代码是 可以在GitHub上找到.

入门Web应用程序

为了自动部署我们的web应用程序,我们将编写一个简单的Go程序. 如果你不熟悉围棋, 不要犹豫跟随, 因为本文中使用的代码结构相当简单,应该很容易理解. 如果你喜欢的话, 您可以很容易地将整个程序移植到您选择的语言中.

在开始之前,确保您的系统上安装了Go发行版. 要安装Go,您可以按照 官方文档中概述的步骤.

接下来,您可以通过克隆工具来下载该工具的源代码 GitHub库. 这将使您更容易跟随本文中的代码片段,因为本文中的代码片段都标有相应的文件名. 如果你想,你可以 try it out right away.

在这个程序中使用Go的一个主要优点是,我们可以以一种外部依赖最小的方式构建它. In our case, 要在服务器上运行这个程序,我们只需要确保安装了Git和Bash. 因为Go程序被编译成静态链接的二进制文件, 你可以在你的电脑上编译这个程序, 将其上传到服务器, 运行起来几乎不需要任何努力. 对于今天大多数流行的语言来说, 这将需要在服务器上安装一些庞大的运行时环境或解释器来运行您的部署自动化程序. Go程序,如果做得好,也可以很容易在CPU和 RAM -这是你想从这样的节目中得到的东西.

GitHub人

使用GitHub人, 可以将GitHub存储库配置为每次存储库中的某些更改或某些用户在托管存储库上执行特定操作时都会发出事件. 这允许用户订阅这些事件,并通过URL调用通知存储库周围发生的各种事件.

创建webhook非常简单:

  1. 导航到存储库的设置页面
  2. 点击“Webhooks” & 左侧导航菜单上的“服务”
  3. 点击“添加网钩”按钮
  4. 设置一个URL,也可以设置一个秘密(这将允许接收方验证有效负载)
  5. 根据需要在表单上做出其他选择
  6. 点击绿色的“Add webhook”按钮提交表单

Github人

GitHub提供 关于Webhooks的大量文档 以及它们是如何工作的, 响应各种事件,在有效负载中传递什么信息, etc. 在本文中,我们特别感兴趣的是 “push” event 每次有人推送到任何存储库分支时都会发出哪个.

Buildpacks

如今,构建包几乎是标准的. 被许多PaaS提供商使用, 构建包允许您指定在部署应用程序之前如何配置堆栈. 为您的web应用程序编写构建包真的很容易, 但通常情况下,在网上快速搜索可以找到一个构建包,您可以在不做任何修改的情况下将其用于您的web应用程序.

如果您已经将应用程序部署到PaaS,如Heroku, 您可能已经知道什么是构建包以及在哪里可以找到它们. Heroku有一些综合 关于构建包结构的文档, and a 一些构建良好的流行构建包列表.

我们的自动化程序将在启动应用程序之前使用编译脚本来准备应用程序. 例如Node.由Heroku构建的js解析包.json文件,下载相应版本的Node.js,并下载应用的NPM依赖项. 值得注意的是,要保持事情简单, 在我们的原型程序中,我们不会对构建包提供广泛的支持. For now, 我们将假设构建包脚本是为使用Bash运行而编写的, 并且它们将在新的Ubuntu安装上运行. 如果有必要,您可以在将来轻松地扩展它以满足更深奥的需求.

Procfiles

配置文件是简单的文本文件,允许您定义应用程序中各种类型的进程. 对于大多数简单的应用程序, 理想情况下,你会有一个单一的“web”进程,它将是处理HTTP请求的进程.

写个人简介很容易. 通过键入进程的名称,每行定义一个进程类型, 后面跟一个冒号, 然后是将生成进程的命令:

: 

例如,如果您正在使用Node.基于Js的web应用程序,要启动web服务器,你可以执行命令“节点索引”.js”. 你可以简单地在代码的基本目录下创建一个Procfile,并将其命名为“Procfile”,如下所示:

Web:节点索引.js

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

处理事件

在我们的项目中, 我们必须包含一个HTTP服务器,它将允许我们接收来自GitHub的传入POST请求. 我们将需要专门一些URL路径来处理来自GitHub的这些请求. 处理这些传入有效负载的函数看起来像这样:

// hook.go

类型HookOptions struct {
	App    *App
	Secret string
}

函数NewHookHandler(o *HookOptions) http.Handler {
	return http.http HandlerFunc (func (w.ResponseWriter, r *http.Request) {
		evName := r.Header.Get(“X-Github-Event”)
		if evName != "push" {
			log.Printf("忽略'%s'事件",evName)
			return
		}

		Body, err:= ioutl.ReadAll(r.Body)
		if err != nil {
			http.错误(w, "内部服务器错误",http.StatusInternalServerError)
			return
		}

		if o.Secret != "" {
			ok := false
			对于_,sig:= range字符串.Fields(r.Header.(“X-Hub-Signature”)){
				if !strings.HasPrefix(sig, "sha1=") {
					continue
				}
				sig = strings.TrimPrefix(团体,“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("忽略签名不正确的'%s'事件",evName)
				return
			}
		}

		ev := github.PushEvent{}
		err = json.Unmarshal(身体, &ev)
		if err != nil {
			log.Printf("忽略无效负载的'%s'事件",evName)
			http.错误(w,“错误的请求”,http.StatusBadRequest)
			return
		}

		if ev.Repo.FullName == nil || *ev.Repo.FullName != o.App.Repo {
			log.Printf("忽略存储库名称不正确的'%s'事件",evName)
			http.错误(w,“错误的请求”,http.StatusBadRequest)
			return
		}

		log.Printf("处理%s的'%s'事件",evName, 0.App.Repo)

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

我们首先验证生成此有效负载的事件类型. 因为我们只对“push”事件感兴趣,所以我们可以忽略所有其他事件. 即使你将webhook配置为只发出“push”事件, 至少还有一种其他类型的事件,你可以期望在钩子端点接收到:" ping ". 此事件的目的是确定webhook是否已在GitHub上成功配置.

Next, 我们读取传入请求的整个主体, 使用我们用来配置webhook的相同秘密来计算它的HMAC-SHA1, 并通过将传入的有效负载与请求标头中包含的签名进行比较来确定其有效性. 在我们的程序中,如果没有配置密钥,我们将忽略这个验证步骤. 顺便说一句, 阅读整个身体而对我们想要处理的数据量没有某种上限可能不是一个明智的主意, 但是让我们保持简单,专注于这个工具的关键方面.

类中的结构体 用于Go的GitHub客户端库 解除传入有效载荷的编组. 因为我们知道这是一个“push”事件,所以我们可以使用 PushEvent结构. 然后,我们使用标准json编码库将有效负载解编组到结构的实例中. 我们执行几个完整性检查, 如果一切都好, 我们调用开始更新应用程序的函数.

更新应用程序

一旦我们在webhook端点收到事件通知,我们就可以开始更新应用程序了. 在本文中, 我们将看一下这个机制的一个相当简单的实现, 当然还有改进的空间. However, 它应该让我们开始一些基本的自动化部署过程.

Webhook应用程序流程图

初始化本地存储库

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

// app.go

函数(a *App) initRepo()错误{
	log.Print(“初始化库”)

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

	cmd := exec.命令(“git”、”——git-dir = " + a.repoDir,“init”)
	cmd.Stderr = os.Stderr
	err = cmd.Run()
	// Check err

	cmd = exec.命令(“git”、”——git-dir = " + a.repoDir, remote, add, origin, fmt.Sprintf(“git@github.com:%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

function (a *App) fetchChanges() error {
	log.打印(“抓取变化”)

	cmd := exec.命令(“git”、”——git-dir = " + a.repoDir,“取”,“- f”,“起源”、“主:主”)
	cmd.Stderr = os.Stderr
	return cmd.Run()
}

通过以这种方式对本地存储库执行“git fetch”, 我们可以避免Git在某些情况下无法快速前进的问题. 并不是说你应该依赖强制抓取, 但是如果您需要对远程存储库进行强制推送, 这个会处理得很好.

编译应用程序

因为我们正在使用构建包中的脚本来编译正在部署的应用程序, 我们的任务相对简单:

// app.go

函数c (a *App) compileApp()错误{
	log.打印(“编译应用程序”)

	_, 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.命令(“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.命令(“bash”,filepath.Join(buildpackDir, "bin", "detect").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.命令(“bash”,filepath.Join(buildpackDir, "bin", "compile").appDir cacheDir)
	cmd.Dir = a.appDir
	cmd.Stdout = os.Stdout
	cmd.Stderr = os.Stderr
	return cmd.Run()
}

我们首先删除之前的应用程序目录(如果有的话). 接下来,我们创建一个新的分支,并将主分支的内容签入其中. 然后,我们使用配置的构建包中的“detect”脚本来确定应用程序是否可以处理. 然后,如果需要,我们为构建包编译过程创建一个“缓存”目录. 因为该目录在构建期间持续存在, 我们可能不需要创建一个新目录,因为在以前的编译过程中已经存在一个新目录. At this point, 我们可以从构建包中调用“编译”脚本,并让它在启动前准备好应用程序所需的一切. 当构建包正常运行时, 它们可以自己处理先前缓存的资源的缓存和重用.

重新启动应用程序

在我们实现这个自动化部署过程中, 我们将在开始编译过程之前停止旧进程, 然后在编译阶段完成后启动新进程. 尽管这使得实现该工具变得很容易, 它为改进自动化部署过程留下了一些潜在的惊人方法. 为了改进这个原型,您可以从确保更新期间的零停机时间开始. 现在,我们将继续使用更简单的方法:

// app.go

函数c (a *App) stopProcs()错误{
	log.Print(".. 停止流程”)

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

	return nil
}

函数c (a *App) startProcs()错误{
	log.打印(“启动过程”)

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

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

	return nil
}

在我们的原型中, 我们通过迭代节点数组来停止和启动各种进程, 其中每个节点是一个进程,对应于应用程序的一个实例(在服务器上启动此工具之前进行了配置). 在我们的工具中,我们跟踪每个节点的流程的当前状态. 我们还为它们维护单独的日志文件. 在所有节点启动之前,每个节点被分配一个唯一的端口,从给定的端口号开始:

// node.go

function NewNode(app * app,名称字符串,没有int,端口int) (*Node,错误){
	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 {
		返回nil, err
	}

	n := &Node{
		App:     app,
		名称:姓名、
		No:      no,
		端口:港口,
		stateCh: make(chan NextState);
		日志文件:日志文件,
	}

	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.正在启动进程%s.%d", n.Name, n.No)

				cmd := exec.命令("bash", "-c", "for f in ") .profile.d/*; do source $f; done; "+n.Cmd)
				cmd.Env = append(cmd . exe.Env, fmt.Sprintf(“家= % s”,n.App.appDir))
				cmd.Env = append(cmd . exe.Env, fmt.Sprintf(“端口= % d”,n.Port))
				cmd.Env = append(cmd . exe.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 (" % 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 (" % s的过程.%d exited", n.Name, n.No)
						n.stateCh <- NextState{
							状态:StateDown,
						}
					}
				}()

			案例StateDown:
				log.正在停止进程%s.%d", n.Name, n.No)

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

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

	return n, nil
}

函数c (n *Node) Start() error {
	n.stateCh <- NextState{
		状态:StateUp,
	}
	return nil
}

函数c (n *Node)停止()错误{
	doneCh:= make(chan int)
	n.stateCh <- NextState{
		状态:StateDown,
		doneCh: doneCh,
	}
	<-doneCh
	return nil
}

乍一看,这似乎比我们目前所做的要复杂一些. 为了便于理解,让我们将上面的代码分解为四个部分. 前两个在“NewNode”函数中. When called, 它填充一个“Node”结构的实例,并生成一个Go例程,帮助启动和停止与该Node对应的进程. 另外两个是Node结构体上的两个方法:Start和Stop. 一个进程的启动或停止,是通过一个特定的通道传递一个“消息”,该通道由每个节点的Go例程监视. 您可以传递一条消息来启动该流程,也可以传递另一条消息来停止该流程. 因为启动或停止进程所涉及的实际步骤发生在单个Go例程中, 没有机会得到竞态条件.

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

另一方面,要停止一个进程(在“case StateDown”中),它只需杀死它. 这是你可以发挥创意的地方, 不是立即终止进程,而是发送SIGTERM,并在实际终止进程之前等待几秒钟, 给进程一个优雅地停止的机会.

“Start”和“Stop”方法可以很容易地将适当的消息传递给通道. 不像" Start "方法, “Stop”方法实际上等待进程在返回之前被杀死. “Start”只是将消息传递给通道以启动流程并返回.

结合起来

最后,我们需要做的就是在程序的main函数中连接所有的东西. 这是我们加载和解析配置文件的地方, 更新构建包, 尝试更新我们的应用程序一次, 并启动web服务器来侦听来自GitHub的传入“push”事件负载:

// main.go

函数main() {
	. Cfg, err:= toml.LoadFile(“配置.tml")
	catch(err)

	Url:= CFG.Get (" buildpack.url").(string)
	if !ok {
		log.致命的(“buildpack.Url未定义")
	}
	err = UpdateBuildpack(url)
	catch(err)

	//读取配置选项到变量repo (string), Env ([]string)和procs (map[string]int)
	// ...

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

	err = app.Update()
	catch(err)

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

	http.处理(“/钩”,NewHookHandler (&HookOptions{
		App:    app,
		秘密:秘密,
	}))

	地址:= CFG.Get("core.addr").(string)
	if !ok {
		log.Fatal("core.地址未定义")
	}

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

由于我们要求构建包是简单的Git存储库,“UpdateBuildpack”(在 buildpack.go)只是执行“git克隆”和“git拉”,必要时使用存储库URL更新本地副本的构建包.

Trying It Out

以防你没有克隆 存储库 然而,你现在就可以做到. 如果您安装了Go发行版,应该可以立即编译该程序.

mkdir hopper
cd hopper
出口GOPATH = ' pwd '
go get github.com/hjr265/toptal-hopper
去安装github.com/hjr265/toptal-hopper

这个命令序列将创建一个名为hopper的目录, 设置为GOPATH, 从GitHub获取代码以及必要的Go库, 并将程序编译成二进制文件,可以在“$GOPATH/bin”目录下找到. 在我们在服务器上使用它之前,我们需要创建一个简单的web应用程序来测试它. 为方便起见,我创建了一个简单的类似“Hello, world”的Node.Js web应用程序,并将其上传到 另一个GitHub存储库 您可以为这个测试fork和重用哪个. Next, 我们需要将编译好的二进制文件上传到服务器,并在同一目录下创建一个配置文件:

# config.tml 

[core]
地址= ":26590"
[buildpack]
Url = "http://github ".com/heroku/heroku-buildpack-nodejs.git"

[app]
Repo = "hjr265/hop -hello ..js"

	[app.env]
	GREETING =“你好”

	[app.procs]
	web = 1

[hook]
secret = ""

配置文件中的第一个选项“core.是让我们配置我们的程序的内部web服务器的HTTP端口. 在上面的例子中, 我们将其设置为:26590, 这将使程序在“http://{host}:26590/hook”监听“push”事件有效载荷。. 在设置GitHub webhook时, 只需将“{host}”替换为指向您的服务器的域名或IP地址. 如果您正在使用某种防火墙,请确保端口是打开的.

接下来,我们通过设置它的Git URL来选择一个构建包. 这里我们使用 Heroku’s Node.js buildpack.

在“app”下,我们将“repo”设置为托管应用程序代码的GitHub存储库的全称. 由于我在“http://github”托管示例应用程序.com/hjr265/hopper-hello.. Js”,存储库的全名为“hjr265/hopper-hello”.js”.

然后我们为应用程序设置一些环境变量,以及每个环境变量的数量 流程类型 we need. 最后,我们选择一个秘密,这样我们就可以验证传入的“推送”事件有效载荷.

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

To finally test 然后,对示例应用程序进行一些更改并将其推送到GitHub. 您将注意到自动化工具将立即启动并更新服务器上的存储库, 编译应用程序, 然后重新启动.

Conclusion

从我们的大多数经验来看,我们可以看出这是非常有用的. 我们在本文中准备的原型应用程序可能不是您想要在生产系统上原样使用的. 还有很大的改进空间. 这样的工具应该有更好的错误处理, 支持安全关机/重启, 你可能想使用像Docker这样的东西来包含进程,而不是直接运行它们. 弄清楚你具体需要什么可能是更明智的, 并为此设计一个自动化程序. 或者可以使用其他一些更稳定、经过时间考验的解决方案,这些解决方案在互联网上随处可见. 但如果你想推出一些非常定制的东西, 我希望本文能够帮助您做到这一点,并向您展示通过自动化web应用程序部署过程,从长远来看可以节省多少时间和精力.

聘请Toptal这方面的专家.
Hire Now
马哈茂德·里德万的头像
Mahmud Ridwan

Located in 达卡,达卡区,孟加拉国

Member since 2014年1月16日

作者简介

Mahmud是一名软件开发人员,拥有多年的经验和效率诀窍, scalability, 稳定的解.

Toptal作者都是各自领域经过审查的专家,并撰写他们有经验的主题. 我们所有的内容都经过同行评审,并由同一领域的Toptal专家验证.

工作经验

13

世界级的文章,每周发一次.

订阅意味着同意我们的 隐私政策

世界级的文章,每周发一次.

订阅意味着同意我们的 隐私政策

Toptal开发者

加入总冠军® community.