GitHubWebhookを使用してWebアプリケーションを自動的にデプロイする
公開: 2022-03-11Webアプリケーションを開発し、管理されていない独自のサーバーで実行しようとする人は誰でも、アプリケーションの展開と将来の更新のプッシュに伴う面倒なプロセスを認識しています。 Platform as a Service(PaaS)プロバイダーは、コストのわずかな増加と柔軟性の低下と引き換えに、個々のサーバーのプロビジョニングと構成のプロセスを経ることなく、Webアプリケーションを簡単に展開できるようにしました。 PaaSによって作業が簡単になった可能性がありますが、それでも、管理されていない独自のサーバーにアプリケーションをデプロイする必要がある場合や、デプロイしたい場合があります。 Webアプリケーションをサーバーにデプロイするこのプロセスを自動化することは、最初は圧倒されるように聞こえるかもしれませんが、実際には、これを自動化するためのシンプルなツールを考え出すのは、思ったより簡単かもしれません。 このツールの実装がどれほど簡単になるかは、ニーズがどれほど単純かによって大きく異なりますが、達成するのは確かに難しくはなく、面倒な繰り返しのWebアプリケーションを実行することで、多くの時間と労力を節約できる可能性があります。展開。
多くの開発者は、Webアプリケーションの展開プロセスを自動化する独自の方法を考え出しました。 Webアプリケーションの展開方法は、使用されている正確なテクノロジースタックに大きく依存するため、これらの自動化ソリューションは互いに異なります。 たとえば、PHP Webサイトの自動デプロイに関連する手順は、Node.jsWebアプリケーションのデプロイとは異なります。 Dokkuなど、かなり一般的な他のソリューションが存在し、これらのもの(ビルドパックと呼ばれる)は、より幅広いテクノロジースタックでうまく機能します。
このチュートリアルでは、GitHub Webhook、ビルドパック、およびProcfilesを使用してWebアプリケーションのデプロイを自動化するために構築できるシンプルなツールの背後にある基本的なアイデアを見ていきます。 この記事で説明するプロトタイププログラムのソースコードは、GitHubで入手できます。
Webアプリケーション入門
Webアプリケーションのデプロイを自動化するために、簡単なGoプログラムを作成します。 Goに慣れていない場合は、この記事全体で使用されているコード構造はかなり単純で理解しやすいものであるため、遠慮なくフォローしてください。 気になる場合は、プログラム全体を選択した言語に簡単に移植できます。
開始する前に、システムにGoディストリビューションがインストールされていることを確認してください。 Goをインストールするには、公式ドキュメントに概説されている手順に従うことができます。
次に、GitHubリポジトリのクローンを作成して、このツールのソースコードをダウンロードできます。 これにより、この記事のコードスニペットには対応するファイル名のラベルが付けられているため、簡単に理解できるはずです。 必要に応じて、すぐに試すことができます。
このプログラムにGoを使用する主な利点の1つは、外部依存関係を最小限に抑えてプログラムを構築できることです。 この場合、サーバーでこのプログラムを実行するには、GitとBashがインストールされていることを確認する必要があります。 Goプログラムは静的にリンクされたバイナリにコンパイルされるため、コンピューターでプログラムをコンパイルしてサーバーにアップロードし、ほとんど労力をかけずに実行できます。 今日の他のほとんどの一般的な言語では、デプロイメントオートマターを実行するためだけに、サーバーに巨大なランタイム環境またはインタープリターをインストールする必要があります。 Goプログラムは、正しく実行されれば、CPUとRAMで非常に簡単に実行できます。これは、このようなプログラムに求められるものです。
GitHub Webhook
GitHub Webhookを使用すると、リポジトリ内で何かが変更されたり、一部のユーザーがホストされたリポジトリで特定のアクションを実行したりするたびにイベントを発行するようにGitHubリポジトリを構成できます。 これにより、ユーザーはこれらのイベントをサブスクライブし、リポジトリ周辺で発生するさまざまなイベントのURL呼び出しを通じて通知を受けることができます。
Webhookの作成は非常に簡単です。
- リポジトリの設定ページに移動します
- 左側のナビゲーションメニューの[Webhookとサービス]をクリックします
- 「Webhookを追加」ボタンをクリックします
- URLを設定し、オプションでシークレット(受信者がペイロードを確認できるようにする)を設定します
- 必要に応じて、フォームで他の選択を行います
- 緑色の[Webhookを追加]ボタンをクリックしてフォームを送信します
GitHubは、Webhookとその正確な動作、さまざまなイベントに応じてペイロードで配信される情報などに関する広範なドキュメントを提供します。この記事では、誰かが毎回発行する「プッシュ」イベントに特に関心があります。任意のリポジトリブランチにプッシュします。
ビルドパック
最近のビルドパックはかなり標準的です。 多くのPaaSプロバイダーで使用されているビルドパックを使用すると、アプリケーションをデプロイする前にスタックを構成する方法を指定できます。 Webアプリケーションのビルドパックを作成するのは非常に簡単ですが、多くの場合、Webをすばやく検索すると、変更を加えずにWebアプリケーションに使用できるビルドパックを見つけることができます。
HerokuのようなPaaSにアプリケーションをデプロイした場合、ビルドパックとは何か、そしてそれらをどこで見つけるかをすでに知っているかもしれません。 Herokuには、ビルドパックの構造に関する包括的なドキュメントと、よく構築された人気のあるビルドパックのリストがあります。
自動化プログラムは、コンパイルスクリプトを使用して、アプリケーションを起動する前にアプリケーションを準備します。 たとえば、HerokuによってビルドされたNode.jsは、package.jsonファイルを解析し、適切なバージョンのNode.jsをダウンロードして、アプリケーションのNPM依存関係をダウンロードします。 物事を単純にするために、プロトタイププログラムではビルドパックを広範囲にサポートしないことに注意してください。 今のところ、ビルドパックスクリプトはBashで実行されるように記述されており、Ubuntuの新規インストールでそのまま実行されると想定します。 必要に応じて、将来的にこれを簡単に拡張して、より難解なニーズに対応できます。
Procfiles
Procfileは、アプリケーションにあるさまざまなタイプのプロセスを定義できる単純なテキストファイルです。 ほとんどの単純なアプリケーションでは、理想的には、HTTPリクエストを処理するプロセスである単一の「Web」プロセスがあります。
Procfilesの作成は簡単です。 名前、コロン、プロセスを生成するコマンドを入力して、1行に1つのプロセスタイプを定義します。
<type>: <command>
たとえば、Node.jsベースのWebアプリケーションを使用している場合、Webサーバーを起動するには、コマンド「nodeindex.js」を実行します。 コードのベースディレクトリにProcfileを作成し、次のように「Procfile」という名前を付けることができます。
web: node index.js
コードをプルした後に自動的に開始できるように、アプリケーションでProcfilesにプロセスタイプを定義する必要があります。
イベントの処理
プログラム内に、GitHubからの着信POSTリクエストを受信できるようにするHTTPサーバーを含める必要があります。 GitHubからのこれらのリクエストを処理するために、いくつかのURLパスを専用にする必要があります。 これらの着信ペイロードを処理する関数は、次のようになります。
// 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」が少なくとも1つあります。 このイベントの目的は、GitHubでWebhookが正常に構成されているかどうかを判断することです。
次に、着信リクエストの本文全体を読み取り、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構造体のこのメソッドは、ローカルリポジトリを初期化するために使用でき、そのメカニズムは非常に単純です。
- ローカルリポジトリが存在しない場合は、ディレクトリを作成します。
- 「gitinit」コマンドを使用して、ベアリポジトリを作成します。
- リモートリポジトリのURLをローカルリポジトリに追加し、「origin」という名前を付けます。
初期化されたリポジトリがあれば、変更のフェッチは簡単です。
変更の取得
リモートリポジトリから変更をフェッチするには、次の1つのコマンドを呼び出すだけです。
// 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() }
この方法でローカルリポジトリに対して「gitfetch」を実行することで、特定のシナリオでGitが早送りできないという問題を回避できます。 強制フェッチは信頼できるものではありませんが、リモートリポジトリに強制プッシュする必要がある場合は、これで問題なく処理できます。
アプリケーションのコンパイル
ビルドパックのスクリプトを使用して、デプロイされているアプリケーションをコンパイルしているため、ここでのタスクは比較的簡単です。
// 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() }
最初に、以前のアプリケーションディレクトリ(存在する場合)を削除します。 次に、新しいブランチを作成し、マスターブランチの内容をチェックアウトします。 次に、構成されたビルドパックの「検出」スクリプトを使用して、アプリケーションが処理できるものであるかどうかを判断します。 次に、必要に応じて、ビルドパックのコンパイルプロセス用の「キャッシュ」ディレクトリを作成します。 このディレクトリはビルド間で存続するため、以前のコンパイルプロセスで既に存在しているため、新しいディレクトリを作成する必要がない場合があります。 この時点で、ビルドパックから「コンパイル」スクリプトを呼び出して、起動前にアプリケーションに必要なすべてのものを準備させることができます。 ビルドパックが適切に実行されると、以前にキャッシュされたリソースのキャッシュと再利用を独自に処理できます。

アプリケーションを再起動します
この自動展開プロセスの実装では、コンパイルプロセスを開始する前に古いプロセスを停止し、コンパイルフェーズが完了したら新しいプロセスを開始します。 これによりツールの実装が容易になりますが、自動展開プロセスを改善するための驚くべき方法がいくつか残されています。 このプロトタイプを改善するには、更新中のダウンタイムをゼロにすることから始めることができます。 今のところ、より単純なアプローチを続けます。
// 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 }
プロトタイプでは、ノードの配列を反復処理することでさまざまなプロセスを停止および開始します。各ノードは、アプリケーションのインスタンスの1つに対応するプロセスです(サーバーでこのツールを起動する前に構成されます)。 ツール内で、各ノードのプロセスの現在の状態を追跡します。 また、それらの個別のログファイルも保持しています。 すべてのノードが開始される前に、各ノードには、指定されたポート番号から始まる一意のポートが割り当てられます。
// 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 }
一見すると、これはこれまでに行ったことよりも少し複雑に見えるかもしれません。 わかりやすくするために、上記のコードを4つの部分に分けてみましょう。 最初の2つは「NewNode」関数内にあります。 呼び出されると、「Node」構造体のインスタンスにデータが入力され、このノードに対応するプロセスの開始と停止に役立つGoルーチンが生成されます。 他の2つは、「Node」構造体の2つのメソッド、「Start」と「Stop」です。 プロセスは、このノードごとのGoルーチンが監視している特定のチャネルを介して「メッセージ」を渡すことによって開始または停止されます。 メッセージを渡してプロセスを開始するか、別のメッセージを渡してプロセスを停止することができます。 プロセスの開始または停止に関連する実際のステップは単一のGoルーチンで行われるため、競合状態になる可能性はありません。
Goルーチンは、「stateCh」チャネルを介した「メッセージ」を待機する無限ループを開始します。 このチャネルに渡されたメッセージがノードにプロセスの開始を要求する場合(「caseStateUp」内)、Bashを使用してコマンドを実行します。 その間、ユーザー定義の環境変数を使用するようにコマンドを構成します。 また、標準出力とエラーストリームを事前定義されたログファイルにリダイレクトします。
一方、プロセスを停止するには(「caseStateDown」内)、プロセスを強制終了します。 これはおそらく創造性を発揮できる場所であり、プロセスを強制終了する代わりに、すぐにSIGTERMを送信し、実際に強制終了する前に数秒待って、プロセスを正常に停止する機会を与えます。
「Start」メソッドと「Stop」メソッドを使用すると、適切なメッセージをチャネルに簡単に渡すことができます。 「Start」メソッドとは異なり、「Stop」メソッドは実際にはプロセスが強制終了されるのを待ってから戻ります。 「開始」は、プロセスを開始するためのメッセージをチャネルに渡すだけで、戻ります。
すべてを組み合わせる
最後に、私たちがする必要があるのは、プログラムのメイン機能内にすべてを接続することです。 ここで、構成ファイルを読み込んで解析し、ビルドパックを更新し、アプリケーションの更新を1回試行し、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) }
ビルドパックは単純なGitリポジトリである必要があるため、「UpdateBuildpack」(buildpack.goに実装)は、必要に応じてリポジトリURLで「gitclone」と「gitpull」を実行してビルドパックのローカルコピーを更新します。
試してみる
リポジトリのクローンをまだ作成していない場合は、今すぐ作成できます。 Goディストリビューションをインストールしている場合は、プログラムをすぐにコンパイルできるはずです。
mkdir hopper cd hopper export GOPATH=`pwd` go get github.com/hjr265/toptal-hopper go install github.com/hjr265/toptal-hopper
この一連のコマンドは、hopperという名前のディレクトリを作成し、それをGOPATHとして設定し、必要なGoライブラリとともにGitHubからコードをフェッチし、プログラムを「$ GOPATH/bin」ディレクトリにあるバイナリにコンパイルします。 これをサーバーで使用する前に、これをテストするための単純なWebアプリケーションを作成する必要があります。 便宜上、Node.js Webアプリケーションのような単純な「Hello、world」を作成し、フォークしてこのテストに再利用できる別の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アドレスに置き換えるだけです。 ある種のファイアウォールを使用している場合に備えて、ポートが開いていることを確認してください。
次に、GitURLを設定してビルドパックを選択します。 ここでは、HerokuのNode.jsビルドパックを使用しています。
「app」の下で、「repo」をアプリケーションコードをホストしているGitHubリポジトリのフルネームに設定します。 サンプルアプリケーションを「https://github.com/hjr265/hopper-hello.js」でホストしているため、リポジトリのフルネームは「hjr265/hopper-hello.js」です。
次に、アプリケーションのいくつかの環境変数と、必要な各タイプのプロセスの数を設定します。 そして最後に、着信する「プッシュ」イベントのペイロードを検証できるように、シークレットを選択します。
これで、サーバーで自動化プログラムを開始できます。 すべてが正しく構成されている場合(サーバーからリポジトリにアクセスできるようにSSHキーをデプロイすることを含む)、プログラムはコードをフェッチし、ビルドパックを使用して環境を準備し、アプリケーションを起動する必要があります。 ここで行う必要があるのは、GitHubリポジトリにWebhookを設定して、プッシュイベントを発行し、それを「http:// {host}:26590/hook」にポイントすることです。 「{host}」は、サーバーを指すドメイン名またはIPアドレスに置き換えてください。
最終的にテストするには、サンプルアプリケーションにいくつかの変更を加えて、GitHubにプッシュします。 自動化ツールがすぐに動作し、サーバー上のリポジトリを更新し、アプリケーションをコンパイルして再起動することに気付くでしょう。
結論
私たちの経験のほとんどから、これは非常に有用なものであることがわかります。 この記事で準備したプロトタイプアプリケーションは、本番システムでそのまま使用したいものではない場合があります。 改善の余地はたくさんあります。 このようなツールは、より優れたエラー処理を備え、適切なシャットダウン/再起動をサポートする必要があります。プロセスを直接実行するのではなく、Dockerなどを使用してプロセスを含めることができます。 特定のケースに何が必要かを正確に把握し、そのための自動化プログラムを考え出す方が賢明かもしれません。 または、インターネット全体で利用できる、他のはるかに安定した、実績のあるソリューションを使用することもできます。 ただし、非常にカスタマイズされたものを展開したい場合は、この記事がそれを実行し、Webアプリケーションの展開プロセスを自動化することで長期的にどれだけの時間と労力を節約できるかを示すのに役立つことを願っています。