Автоматическое развертывание веб-приложений с помощью веб-перехватчиков GitHub

Опубликовано: 2022-03-11

Любой, кто разрабатывает веб-приложения и пытается запускать их на своих собственных неуправляемых серверах, знает об утомительном процессе, связанном с развертыванием их приложений и отправкой будущих обновлений. Поставщики платформы как услуги (PaaS) упростили развертывание веб-приложений без необходимости проходить процесс подготовки и настройки отдельных серверов в обмен на небольшое увеличение затрат и снижение гибкости. Возможно, PaaS упростил задачу, но иногда нам все еще нужно или мы хотим развертывать приложения на наших собственных неуправляемых серверах. Автоматизация этого процесса развертывания веб-приложений на вашем сервере поначалу может показаться сложной задачей, но на самом деле придумать простой инструмент для автоматизации может быть проще, чем вы думаете. Насколько легко будет внедрить этот инструмент, во многом зависит от того, насколько просты ваши потребности, но его, безусловно, несложно достичь, и, вероятно, он может помочь сэкономить много времени и усилий, выполняя утомительные повторяющиеся части веб-приложения. развертывания.

Многие разработчики придумали собственные способы автоматизации процессов развертывания своих веб-приложений. Поскольку то, как вы развертываете свои веб-приложения, во многом зависит от конкретного используемого стека технологий, эти решения автоматизации различаются между собой. Например, шаги, необходимые для автоматического развертывания веб-сайта PHP, отличаются от развертывания веб-приложения Node.js. Существуют и другие решения, такие как Dokku, которые являются довольно общими, и эти вещи (называемые сборочными пакетами) хорошо работают с более широким набором технологических стеков.

веб-приложения и вебхуки

В этом руководстве мы рассмотрим основные идеи простого инструмента, который вы можете создать для автоматизации развертывания веб-приложений с помощью веб-перехватчиков GitHub, сборочных пакетов и Procfiles. Исходный код программы-прототипа, которую мы рассмотрим в этой статье, доступен на GitHub.

Начало работы с веб-приложениями

Чтобы автоматизировать развертывание нашего веб-приложения, мы напишем простую программу Go. Если вы не знакомы с Go, не стесняйтесь продолжать, так как конструкции кода, используемые в этой статье, довольно просты и должны быть понятными. Если вам так хочется, вы, вероятно, можете довольно легко перенести всю программу на язык по вашему выбору.

Перед началом убедитесь, что в вашей системе установлен дистрибутив Go. Чтобы установить Go, вы можете выполнить шаги, описанные в официальной документации.

Далее вы можете загрузить исходный код этого инструмента, клонировав репозиторий GitHub. Это должно облегчить вам работу, так как фрагменты кода в этой статье помечены соответствующими именами файлов. Если вы хотите, вы можете попробовать это прямо сейчас.

Одним из основных преимуществ использования Go для этой программы является то, что мы можем построить ее таким образом, чтобы у нас было минимальное количество внешних зависимостей. В нашем случае, чтобы запустить эту программу на сервере, нам просто нужно убедиться, что у нас установлены Git и Bash. Поскольку программы Go скомпилированы в статически связанные двоичные файлы, вы можете скомпилировать программу на своем компьютере, загрузить ее на сервер и запустить практически без усилий. Для большинства других популярных сегодня языков потребуется какая-то гигантская среда выполнения или интерпретатор, установленный на сервере только для запуска вашего автомата развертывания. Программы Go, если все сделано правильно, также могут очень легко загружаться с ЦП и ОЗУ — это то, чего вы хотите от программ, подобных этой.

Веб-хуки GitHub

С помощью GitHub Webhooks можно настроить репозиторий GitHub для отправки событий каждый раз, когда что-то изменяется в репозитории или какой-либо пользователь выполняет определенные действия в размещенном репозитории. Это позволяет пользователям подписываться на эти события и получать уведомления через вызовы URL о различных событиях, происходящих в вашем репозитории.

Создать вебхук очень просто:

  1. Перейдите на страницу настроек вашего репозитория.
  2. Нажмите «Веб-хуки и сервисы» в левом навигационном меню.
  3. Нажмите на кнопку «Добавить вебхук».
  4. Установите URL-адрес и, возможно, секрет (который позволит получателю проверить полезную нагрузку)
  5. При необходимости сделайте другие выборы в форме
  6. Отправьте форму, нажав зеленую кнопку «Добавить вебхук».

Веб-хуки Github

GitHub предоставляет обширную документацию по веб-перехватчикам и тому, как именно они работают, какая информация доставляется в полезной нагрузке в ответ на различные события и т. д. Для целей этой статьи нас особенно интересует событие «push», которое генерируется каждый раз, когда кто-то отправляет в любую ветку репозитория.

Сборки

В наши дни билдпаки в значительной степени стандартны. Пакеты сборки, используемые многими поставщиками PaaS, позволяют указать, как будет настроен стек до развертывания приложения. Написание сборочных пакетов для вашего веб-приложения очень просто, но чаще всего быстрый поиск в Интернете может найти вам сборочный пакет, который вы можете использовать для своего веб-приложения без каких-либо изменений.

Если вы развернули приложение на PaaS, например Heroku, возможно, вы уже знаете, что такое пакеты сборки и где их найти. В Heroku есть исчерпывающая документация о структуре сборочных пакетов и список некоторых хорошо построенных популярных сборочных пакетов.

Наша программа автоматизации будет использовать сценарий компиляции для подготовки приложения перед его запуском. Например, сборка Node.js от Heroku анализирует файл package.json, загружает соответствующую версию Node.js и загружает зависимости NPM для приложения. Стоит отметить, что для простоты у нас не будет расширенной поддержки сборочных пакетов в нашей программе-прототипе. На данный момент мы предполагаем, что скрипты buildpack написаны для запуска с Bash и что они будут работать на новой установке Ubuntu без изменений. При необходимости вы можете легко расширить это в будущем, чтобы удовлетворить более эзотерические потребности.

Профайлы

Procfiles — это простые текстовые файлы, которые позволяют вам определять различные типы процессов, которые есть в вашем приложении. Для большинства простых приложений в идеале у вас должен быть один «веб-процесс», который будет обрабатывать HTTP-запросы.

Написать Procfiles легко. Определите один тип процесса в строке, введя его имя, затем двоеточие, а затем команду, которая создаст процесс:

 <type>: <command>

Например, если вы работали с веб-приложением на основе Node.js, для запуска веб-сервера вы должны выполнить команду «node index.js». Вы можете просто создать Procfile в базовом каталоге кода и назвать его «Procfile» со следующим:

 web: node index.js

Нам потребуется, чтобы приложения определяли типы процессов в Procfiles, чтобы мы могли запускать их автоматически после извлечения кода.

Обработка событий

В нашей программе мы должны включить HTTP-сервер, который позволит нам получать входящие запросы POST от GitHub. Нам нужно будет выделить некоторый 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 } }) }

Начнем с проверки типа события, сгенерировавшего эту полезную нагрузку. Поскольку нас интересует только событие «push», мы можем игнорировать все остальные события. Даже если вы настроите веб-перехватчик только для отправки событий «push», все равно будет по крайней мере одно другое событие, которое вы можете ожидать в конечной точке перехватчика: «ping». Цель этого события — определить, успешно ли настроен веб-перехватчик на GitHub.

Затем мы читаем все тело входящего запроса, вычисляем его HMAC-SHA1, используя тот же секрет, который мы будем использовать для настройки нашего веб-перехватчика, и определяем достоверность входящей полезной нагрузки, сравнивая ее с подписью, включенной в заголовок запроса. запрос. В нашей программе мы игнорируем этот шаг проверки, если секрет не настроен. С другой стороны, может быть неразумной идеей читать все тело, не имея хотя бы какого-то верхнего предела того, сколько данных мы хотим иметь здесь, но давайте не будем усложнять, чтобы сосредоточиться на критических аспектах. этого инструмента.

Затем мы используем структуру из клиентской библиотеки GitHub для Go, чтобы разобрать входящую полезную нагрузку. Поскольку мы знаем, что это событие «push», мы можем использовать структуру PushEvent. Затем мы используем стандартную библиотеку кодирования json, чтобы преобразовать полезную нагрузку в экземпляр структуры. Мы выполняем пару проверок работоспособности и, если все в порядке, вызываем функцию, которая запускает обновление нашего приложения.

Обновление приложения

Как только мы получим уведомление о событии на нашей конечной точке веб-перехватчика, мы можем начать обновление нашего приложения. В этой статье мы рассмотрим достаточно простую реализацию этого механизма, и наверняка найдется место для доработок. Тем не менее, это должно быть что-то, что поможет нам начать с некоторого базового автоматизированного процесса развертывания.

блок-схема приложения веб-перехватчика

Инициализация локального репозитория

Этот процесс начнется с простой проверки, чтобы определить, пытаемся ли мы развернуть приложение впервые. Мы сделаем это, проверив, существует ли локальный каталог репозитория. Если он не существует, мы сначала инициализируем наш локальный репозиторий:

 // 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 не может перемотать вперед в определенных сценариях. Не то чтобы принудительная выборка — это то, на что вы должны полагаться, но если вам нужно выполнить принудительную отправку в удаленный репозиторий, это справится с этим с изяществом.

Компиляция приложения

Поскольку мы используем скрипты из сборочных пакетов для компиляции наших развертываемых приложений, наша задача здесь относительно проста:

 // 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() }

Начнем с удаления нашего предыдущего каталога приложений (если есть). Затем мы создаем новый и выгружаем в него содержимое основной ветки. Затем мы используем скрипт «обнаружение» из настроенного пакета сборки, чтобы определить, можно ли с этим приложением справиться. Затем мы создаем каталог «cache» для процесса компиляции 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»: «Start» и «Stop». Процесс запускается или останавливается путем передачи «сообщения» по определенному каналу, за которым следит эта процедура Go для каждого узла. Вы можете либо передать сообщение, чтобы запустить процесс, либо другое сообщение, чтобы остановить его. Поскольку фактические шаги, связанные с запуском или остановкой процесса, выполняются в одной подпрограмме Go, вероятность возникновения условий гонки исключена.

Подпрограмма Go запускает бесконечный цикл, в котором она ожидает «сообщения» через канал «stateCh». Если сообщение, переданное в этот канал, запрашивает у узла запуск процесса (внутри «кейса StateUp»), он использует Bash для выполнения команды. При этом он настраивает команду на использование пользовательских переменных среды. Он также перенаправляет стандартный вывод и потоки ошибок в предварительно определенный файл журнала.

С другой стороны, чтобы остановить процесс (внутри case StateDown), он просто убивает его. Здесь вы, вероятно, могли бы проявить творческий подход и вместо того, чтобы убивать процесс, немедленно отправить ему SIGTERM и подождать несколько секунд, прежде чем фактически убить его, давая процессу возможность изящно остановиться.

Методы «Старт» и «Стоп» упрощают передачу соответствующего сообщения в канал. В отличие от метода «Start», метод «Stop» фактически ожидает завершения процессов перед возвратом. «Старт» просто передает сообщение в канал для запуска процесса и возврата.

Сочетание всего

Наконец, все, что нам нужно сделать, это подключить все в рамках основной функции программы. Здесь мы загрузим и проанализируем файл конфигурации, обновим пакет сборки, попытаемся обновить наше приложение один раз и запустим веб-сервер для прослушивания входящих полезных данных события «push» из 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) просто выполняет «git clone» и «git pull» по мере необходимости с URL-адресом репозитория для обновления локальной копии пакета сборки.

Пробуем

Если вы еще не клонировали репозиторий, вы можете сделать это сейчас. Если у вас установлен дистрибутив 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». Прежде чем мы сможем использовать это на сервере, нам нужно создать простое веб-приложение, чтобы протестировать это. Для удобства я создал простое веб-приложение Node.js, похожее на «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», позволяет нам настроить HTTP-порт внутреннего веб-сервера нашей программы. В приведенном выше примере мы установили его на «: 26590», что заставит программу прослушивать полезные данные события «push» по адресу «http://{host}:26590/hook». При настройке веб-перехватчика GitHub просто замените «{host}» доменным именем или IP-адресом, указывающим на ваш сервер. Убедитесь, что порт открыт, если вы используете какой-либо брандмауэр.

Затем мы выбираем пакет сборки, указав его URL-адрес Git. Здесь мы используем сборочный пакет Node.js от Heroku.

В разделе «приложение» мы устанавливаем «репо» на полное имя вашего репозитория GitHub, в котором размещен код приложения. Поскольку я размещаю пример приложения по адресу «https://github.com/hjr265/hopper-hello.js», полное имя репозитория — «hjr265/hopper-hello.js».

Затем мы устанавливаем некоторые переменные среды для приложения и количество необходимых нам процессов каждого типа. И, наконец, мы выбираем секрет, чтобы мы могли проверять входящие полезные данные события «push».

Теперь мы можем запустить нашу программу автоматизации на сервере. Если все настроено правильно (включая развертывание SSH-ключей, чтобы репозиторий был доступен с сервера), программа должна получить код, подготовить среду с помощью билдпака и запустить приложение. Теперь все, что нам нужно сделать, это настроить веб-хук в репозитории GitHub для отправки push-событий и указать ему «http://{host}:26590/hook». Убедитесь, что вы заменили «{host}» доменным именем или IP-адресом, указывающим на ваш сервер.

Чтобы окончательно протестировать его, внесите некоторые изменения в пример приложения и отправьте их на GitHub. Вы заметите, что инструмент автоматизации немедленно начнет действовать и обновит репозиторий на сервере, скомпилирует приложение и перезапустит его.

Заключение

Из большей части нашего опыта мы можем сказать, что это что-то весьма полезное. Приложение-прототип, которое мы подготовили в этой статье, может быть не тем, что вы захотите использовать в производственной системе в его нынешнем виде. Есть масса возможностей для улучшения. Подобный инструмент должен иметь лучшую обработку ошибок, поддерживать плавное завершение работы/перезагрузку, и вы можете использовать что-то вроде Docker для содержания процессов вместо их непосредственного запуска. Возможно, будет разумнее разобраться, что именно вам нужно для вашего конкретного случая, и придумать для этого программу автоматизации. Или, возможно, использовать какое-то другое, гораздо более стабильное, проверенное временем решение, доступное по всему Интернету. Но если вы хотите развернуть что-то очень индивидуальное, я надеюсь, что эта статья поможет вам в этом и покажет, сколько времени и усилий вы могли бы сэкономить в долгосрочной перспективе, автоматизировав процесс развертывания веб-приложения.

Связанный: Объяснение расширенного потока Git