使用 GitHub Webhooks 自動部署 Web 應用程序

已發表: 2022-03-11

任何開發 Web 應用程序並嘗試在自己的非託管服務器上運行它們的人都知道部署應用程序和推送未來更新所涉及的繁瑣過程。 平台即服務 (PaaS) 提供商可以輕鬆部署 Web 應用程序,而無需通過配置和配置單個服務器的過程,以換取成本略有增加和靈活性降低。 PaaS 可能使事情變得更容易,但有時我們仍然需要或想要在我們自己的非託管服務器上部署應用程序。 自動化將 Web 應用程序部署到您的服務器的過程一開始可能聽起來讓人不知所措,但實際上想出一個簡單的工具來自動化這個過程可能比您想像的要容易。 實現此工具的難易程度很大程度上取決於您的需求有多簡單,但實現起來肯定不難,並且通過執行 Web 應用程序的繁瑣重複部分可能有助於節省大量時間和精力部署。

許多開發人員提出了自己的方法來自動化其 Web 應用程序的部署過程。 由於您部署 Web 應用程序的方式很大程度上取決於所使用的確切技術堆棧,因此這些自動化解決方案之間存在差異。 例如,自動部署 PHP 網站所涉及的步驟與部署 Node.js Web 應用程序不同。 存在其他解決方案,例如 Dokku,它們非常通用,並且這些東西(稱為 buildpacks)適用於更廣泛的技術堆棧。

Web 應用程序和 Webhook

在本教程中,我們將了解一個簡單工具背後的基本思想,您可以使用 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 非常簡單:

  1. 導航到存儲庫的設置頁面
  2. 點擊左側導航菜單中的“Webhooks & Services”
  3. 單擊“添加網絡鉤子”按鈕
  4. 設置一個 URL,以及一個可選的秘密(這將允許接收者驗證有效負載)
  5. 根據需要在表格上做出其他選擇
  6. 單擊綠色的“添加 webhook”按鈕提交表單

Github 網絡鉤子

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 端點收到事件通知,我們就可以開始更新我們的應用程序。 在本文中,我們將看一下這種機制的一個相當簡單的實現,並且肯定會有改進的空間。 但是,它應該可以讓我們開始一些基本的自動化部署過程。

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 上的這個方法可以用來初始化本地倉庫,其機制極其簡單:

  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 中的“檢測”腳本來確定應用程序是否是我們可以處理的。 然後,如果需要,我們為 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 應用程序部署過程從長遠來看可以節省多少時間和精力。

相關:增強的 Git 流程解釋