Implante aplicativos da Web automaticamente usando Webhooks do GitHub
Publicados: 2022-03-11Qualquer pessoa que desenvolva aplicativos da Web e tente executá-los em seus próprios servidores não gerenciados está ciente do processo tedioso envolvido na implantação de seus aplicativos e no envio de atualizações futuras. Os provedores de plataforma como serviço (PaaS) facilitaram a implantação de aplicativos da Web sem ter que passar pelo processo de provisionamento e configuração de servidores individuais, em troca de um pequeno aumento nos custos e diminuição da flexibilidade. A PaaS pode ter facilitado as coisas, mas às vezes ainda precisamos ou queremos implantar aplicativos em nossos próprios servidores não gerenciados. Automatizar esse processo de implantação de aplicativos da Web em seu servidor pode parecer complicado no início, mas, na realidade, criar uma ferramenta simples para automatizar isso pode ser mais fácil do que você pensa. O quão fácil será implementar esta ferramenta depende muito de quão simples são suas necessidades, mas certamente não é difícil de alcançar, e provavelmente pode ajudar a economizar muito tempo e esforço fazendo as tediosas partes repetitivas do aplicativo da web implantações.
Muitos desenvolvedores criaram suas próprias maneiras de automatizar os processos de implantação de seus aplicativos da web. Como você implanta seus aplicativos da Web depende muito da pilha de tecnologia exata que está sendo usada, essas soluções de automação variam entre si. Por exemplo, as etapas envolvidas na implantação automática de um site PHP são diferentes da implantação de um aplicativo Web Node.js. Existem outras soluções, como o Dokku, que são bastante genéricas e essas coisas (chamadas buildpacks) funcionam bem com uma gama mais ampla de pilhas de tecnologia.
Neste tutorial, veremos as ideias fundamentais por trás de uma ferramenta simples que você pode criar para automatizar suas implantações de aplicativos da Web usando webhooks, buildpacks e Procfiles do GitHub. O código-fonte do programa protótipo que exploraremos neste artigo está disponível no GitHub.
Introdução aos aplicativos da Web
Para automatizar a implantação de nosso aplicativo da Web, escreveremos um programa Go simples. Se você não estiver familiarizado com o Go, não hesite em acompanhar, pois as construções de código usadas neste artigo são bastante simples e devem ser fáceis de entender. Se você quiser, provavelmente poderá portar o programa inteiro para um idioma de sua escolha com bastante facilidade.
Antes de começar, certifique-se de ter a distribuição Go instalada em seu sistema. Para instalar o Go, você pode seguir os passos descritos na documentação oficial.
Em seguida, você pode baixar o código-fonte dessa ferramenta clonando o repositório do GitHub. Isso deve facilitar o acompanhamento, pois os trechos de código neste artigo são rotulados com seus nomes de arquivo correspondentes. Se você quiser, você pode experimentá-lo imediatamente.
Uma grande vantagem de usar Go para este programa é que podemos construí-lo de uma maneira em que tenhamos dependências externas mínimas. No nosso caso, para rodar este programa em um servidor só precisamos garantir que temos Git e Bash instalados. Como os programas Go são compilados em binários vinculados estaticamente, você pode compilar o programa em seu computador, carregá-lo no servidor e executá-lo com quase nenhum esforço. Para a maioria das outras linguagens populares de hoje, isso exigiria algum ambiente de tempo de execução gigantesco ou interpretador instalado no servidor apenas para executar seu automatizador de implantação. Os programas Go, quando bem feitos, também podem ser muito fáceis de usar na CPU e na RAM - o que é algo que você deseja em programas como este.
Webhooks do GitHub
Com o GitHub Webhooks, é possível configurar seu repositório GitHub para emitir eventos sempre que algo for alterado dentro do repositório ou algum usuário realizar ações específicas no repositório hospedado. Isso permite que os usuários assinem esses eventos e sejam notificados por meio de invocações de URL dos vários eventos que ocorrem em seu repositório.
Criar um webhook é muito simples:
- Navegue até a página de configurações do seu repositório
- Clique em “Webhooks & Services” no menu de navegação à esquerda
- Clique no botão “Adicionar webhook”
- Defina um URL e, opcionalmente, um segredo (o que permitirá que o destinatário verifique a carga útil)
- Faça outras escolhas no formulário, conforme necessário
- Envie o formulário clicando no botão verde “Adicionar webhook”
O GitHub fornece extensa documentação sobre Webhooks e como eles funcionam exatamente, quais informações são entregues na carga útil em resposta a vários eventos, etc. Para os propósitos deste artigo, estamos particularmente interessados no evento “push” que é emitido toda vez que alguém envia para qualquer branch do repositório.
Pacotes de compilação
Os Buildpacks são praticamente padrão nos dias de hoje. Usados por muitos provedores de PaaS, os buildpacks permitem especificar como a pilha será configurada antes que um aplicativo seja implantado. Escrever buildpacks para seu aplicativo da web é muito fácil, mas na maioria das vezes uma pesquisa rápida na web pode encontrar um buildpack que você pode usar para seu aplicativo da web sem qualquer modificação.
Se você implantou um aplicativo para PaaS como o Heroku, já deve saber o que são buildpacks e onde encontrá-los. Heroku tem alguma documentação abrangente sobre a estrutura de buildpacks e uma lista de alguns buildpacks populares bem construídos.
Nosso programa de automação usará o script de compilação para preparar o aplicativo antes de iniciá-lo. Por exemplo, uma compilação Node.js da Heroku analisa o arquivo package.json, baixa uma versão apropriada do Node.js e baixa as dependências do NPM para o aplicativo. Vale a pena notar que para manter as coisas simples, não teremos suporte extensivo para buildpacks em nosso programa de protótipo. Por enquanto, vamos assumir que os scripts de buildpack são escritos para serem executados com Bash e que eles serão executados em uma nova instalação do Ubuntu como está. Se necessário, você pode facilmente estender isso no futuro para atender a necessidades mais esotéricas.
Percfiles
Procfiles são arquivos de texto simples que permitem definir os vários tipos de processos que você possui em seu aplicativo. Para a maioria dos aplicativos simples, o ideal seria ter um único processo “web”, que seria o processo que trata de solicitações HTTP.
Escrever Procfiles é fácil. Defina um tipo de processo por linha digitando seu nome, seguido de dois pontos, seguido do comando que gerará o processo:
<type>: <command>
Por exemplo, se você estivesse trabalhando com um aplicativo web baseado em Node.js, para iniciar o servidor web você executaria o comando “node index.js”. Você pode simplesmente criar um Procfile no diretório base do código e nomeá-lo “Procfile” com o seguinte:
web: node index.js
Exigiremos que os aplicativos definam os tipos de processo em Procfiles para que possamos iniciá-los automaticamente após extrair o código.
Manipulação de eventos
Dentro do nosso programa, devemos incluir um servidor HTTP que nos permitirá receber solicitações POST do GitHub. Precisaremos dedicar algum caminho de URL para lidar com essas solicitações do GitHub. A função que irá lidar com esses payloads de entrada será algo assim:
// 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 } }) }
Começamos verificando o tipo de evento que gerou essa carga. Como estamos interessados apenas no evento “push”, podemos ignorar todos os outros eventos. Mesmo se você configurar o webhook para emitir apenas eventos “push”, ainda haverá pelo menos um outro tipo de evento que você pode esperar receber em seu terminal de gancho: “ping”. O objetivo deste evento é determinar se o webhook foi configurado com sucesso no GitHub.
Em seguida, lemos todo o corpo da solicitação recebida, calculamos seu HMAC-SHA1 usando o mesmo segredo que usaremos para configurar nosso webhook e determinamos a validade da carga útil recebida comparando-a com a assinatura incluída no cabeçalho do solicitação. Em nosso programa, ignoramos essa etapa de validação se o segredo não estiver configurado. Em uma nota lateral, pode não ser uma ideia sábia ler o corpo inteiro sem pelo menos ter algum tipo de limite superior de quantos dados queremos lidar aqui, mas vamos manter as coisas simples para focar nos aspectos críticos desta ferramenta.
Em seguida, usamos um struct da biblioteca cliente do GitHub para Go para desempacotar a carga de entrada. Como sabemos que é um evento “push”, podemos usar a estrutura PushEvent. Em seguida, usamos a biblioteca de codificação json padrão para desempacotar a carga útil em uma instância do struct. Realizamos algumas verificações de sanidade e, se tudo estiver bem, invocamos a função que inicia a atualização de nosso aplicativo.
Atualizando aplicativo
Assim que recebermos uma notificação de evento em nosso endpoint de webhook, podemos começar a atualizar nosso aplicativo. Neste artigo, veremos uma implementação bastante simples desse mecanismo e certamente haverá espaço para melhorias. No entanto, deve ser algo que nos fará começar com algum processo básico de implantação automatizada.
Inicializando o repositório local
Esse processo começará com uma simples verificação para determinar se esta é a primeira vez que estamos tentando implantar o aplicativo. Faremos isso verificando se o diretório do repositório local existe. Se não existir, inicializaremos nosso repositório local primeiro:
// 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 }
Este método na estrutura App pode ser usado para inicializar o repositório local, e seus mecanismos são extremamente simples:
- Crie um diretório para o repositório local se ele não existir.
- Use o comando “git init” para criar um repositório vazio.
- Adicione um URL para o repositório remoto ao nosso repositório local e nomeie-o como “origem”.
Uma vez que tenhamos um repositório inicializado, buscar mudanças deve ser simples.
Buscando alterações
Para buscar alterações do repositório remoto, precisamos apenas invocar um comando:
// 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() }
Ao fazer um “git fetch” para nosso repositório local dessa maneira, podemos evitar problemas com o Git não conseguir avançar em determinados cenários. Não que as buscas forçadas sejam algo em que você deva confiar, mas se você precisar fazer um push forçado para o seu repositório remoto, isso irá lidar com isso com graça.

Compilando o Aplicativo
Como estamos usando scripts de buildpacks para compilar nossos aplicativos que estão sendo implantados, nossa tarefa aqui é relativamente fácil:
// 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() }
Começamos removendo nosso diretório de aplicativos anterior (se houver). Em seguida, criamos um novo e fazemos check-out do conteúdo do branch master para ele. Em seguida, usamos o script “detect” do buildpack configurado para determinar se o aplicativo é algo que podemos manipular. Em seguida, criamos um diretório “cache” para o processo de compilação do buildpack, se necessário. Como esse diretório persiste em todas as compilações, pode acontecer que não precisemos criar um novo diretório porque já existe um de algum processo de compilação anterior. Neste ponto, podemos invocar o script “compile” do buildpack e preparar tudo o que for necessário para o aplicativo antes do lançamento. Quando os buildpacks são executados corretamente, eles podem lidar sozinhos com o armazenamento em cache e a reutilização de recursos armazenados em cache anteriormente.
Reiniciando o aplicativo
Em nossa implementação desse processo de implantação automatizado, interromperemos os processos antigos antes de iniciar o processo de compilação e, em seguida, iniciaremos os novos processos quando a fase de compilação estiver concluída. Embora isso facilite a implementação da ferramenta, deixa algumas maneiras potencialmente incríveis de melhorar o processo de implantação automatizada. Para melhorar este protótipo, você provavelmente pode começar garantindo zero tempo de inatividade durante as atualizações. Por enquanto, continuaremos com a abordagem mais simples:
// 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 }
Em nosso protótipo, paramos e iniciamos os vários processos iterando sobre uma matriz de nós, onde cada nó é um processo correspondente a uma das instâncias do aplicativo (conforme configurado antes de iniciar essa ferramenta no servidor). Dentro de nossa ferramenta, acompanhamos o estado atual do processo para cada nó. Também mantemos arquivos de log individuais para eles. Antes de todos os nós serem iniciados, cada um recebe uma porta exclusiva a partir de um determinado número de porta:
// 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 }
À primeira vista, isso pode parecer um pouco mais complicado do que o que fizemos até agora. Para tornar as coisas fáceis de entender, vamos dividir o código acima em quatro partes. Os dois primeiros estão dentro da função “NewNode”. Quando chamado, ele preenche uma instância da estrutura “Node” e gera uma rotina Go que ajuda a iniciar e parar o processo correspondente a este Node. Os outros dois são os dois métodos na estrutura “Node”: “Start” e “Stop”. Um processo é iniciado ou interrompido passando uma “mensagem” por meio de um canal específico que essa rotina Go por nó está vigiando. Você pode passar uma mensagem para iniciar o processo ou uma mensagem diferente para interrompê-lo. Como as etapas reais envolvidas em iniciar ou parar um processo acontecem em uma única rotina Go, não há chance de obter condições de corrida.
A rotina Go inicia um loop infinito onde espera uma “mensagem” através do canal “stateCh”. Se a mensagem passada para este canal solicitar que o nó inicie o processo (dentro do “case StateUp”), ele usa o Bash para executar o comando. Ao fazer isso, ele configura o comando para usar as variáveis de ambiente definidas pelo usuário. Ele também redireciona a saída padrão e os fluxos de erro para um arquivo de log predefinido.
Por outro lado, para parar um processo (dentro do “case StateDown”), ele simplesmente o mata. É aqui que você provavelmente pode ser criativo e, em vez de matar o processo, envie imediatamente um SIGTERM e espere alguns segundos antes de realmente matá-lo, dando ao processo a chance de parar normalmente.
Os métodos “Iniciar” e “Parar” facilitam a transmissão da mensagem apropriada ao canal. Ao contrário do método “Start”, o método “Stop” realmente espera que os processos sejam mortos antes de retornar. “Iniciar” simplesmente passa uma mensagem para o canal iniciar o processo e retorna.
Combinando tudo
Finalmente, tudo o que precisamos fazer é conectar tudo dentro da função principal do programa. É aqui que carregaremos e analisaremos o arquivo de configuração, atualizaremos o buildpack, tentaremos atualizar nosso aplicativo uma vez e iniciaremos o servidor da Web para ouvir as cargas úteis do evento “push” recebidas do 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) }
Como exigimos que os buildpacks sejam repositórios Git simples, “UpdateBuildpack” (implementado em buildpack.go) apenas executa um “git clone” e um “git pull” conforme necessário com a URL do repositório para atualizar a cópia local do buildpack.
Experimentando
Caso você ainda não tenha clonado o repositório, pode fazê-lo agora. Se você tiver a distribuição Go instalada, deve ser possível compilar o programa imediatamente.
mkdir hopper cd hopper export GOPATH=`pwd` go get github.com/hjr265/toptal-hopper go install github.com/hjr265/toptal-hopper
Esta sequência de comandos irá criar um diretório chamado hopper, defini-lo como GOPATH, buscar o código do GitHub junto com as bibliotecas Go necessárias e compilar o programa em um binário que você pode encontrar no diretório “$GOPATH/bin”. Antes de podermos usar isso em um servidor, precisamos criar um aplicativo web simples para testar isso. Por conveniência, criei um aplicativo web simples do tipo “Hello, world” Node.js e o carreguei para outro repositório GitHub que você pode bifurcar e reutilizar para este teste. Em seguida, precisamos fazer o upload do binário compilado para um servidor e criar um arquivo de configuração no mesmo diretório:
# 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 = ""
A primeira opção em nosso arquivo de configuração, “core.addr” é o que nos permite configurar a porta HTTP do servidor web interno do nosso programa. No exemplo acima, definimos como “:26590”, o que fará com que o programa escute as cargas úteis do evento “push” em “http://{host}:26590/hook”. Ao configurar o webhook do GitHub, basta substituir “{host}” pelo nome de domínio ou endereço IP que aponta para o seu servidor. Certifique-se de que a porta esteja aberta caso você esteja usando algum tipo de firewall.
Em seguida, escolhemos um buildpack definindo sua URL do Git. Aqui estamos usando o buildpack Node.js do Heroku.
Em “app”, definimos “repo” como o nome completo do seu repositório GitHub que hospeda o código do aplicativo. Como estou hospedando o aplicativo de exemplo em “https://github.com/hjr265/hopper-hello.js”, o nome completo do repositório é “hjr265/hopper-hello.js”.
Em seguida, definimos algumas variáveis de ambiente para o aplicativo e o número de cada tipo de processo que precisamos. E, finalmente, escolhemos um segredo, para que possamos verificar as cargas úteis do evento “push” de entrada.
Agora podemos iniciar nosso programa de automação no servidor. Se tudo estiver configurado corretamente (incluindo implantar chaves SSH, para que o repositório seja acessível a partir do servidor), o programa deve buscar o código, preparar o ambiente usando o buildpack e iniciar o aplicativo. Agora tudo o que precisamos fazer é configurar um webhook no repositório do GitHub para emitir eventos push e apontá-lo para “http://{host}:26590/hook”. Certifique-se de substituir “{host}” pelo nome de domínio ou endereço IP que aponta para o seu servidor.
Para finalmente testá-lo, faça algumas alterações no aplicativo de exemplo e envie-as para o GitHub. Você notará que a ferramenta de automação entrará em ação imediatamente e atualizará o repositório no servidor, compilará o aplicativo e o reiniciará.
Conclusão
Da maioria de nossas experiências, podemos dizer que isso é algo bastante útil. O aplicativo protótipo que preparamos neste artigo pode não ser algo que você queira usar em um sistema de produção como está. Há uma tonelada de espaço para melhorias. Uma ferramenta como essa deve ter um melhor tratamento de erros, dar suporte a desligamentos/reinicializações graciosas e você pode querer usar algo como o Docker para conter os processos em vez de executá-los diretamente. Pode ser mais sensato descobrir exatamente o que você precisa para o seu caso específico e criar um programa de automação para isso. Ou talvez use alguma outra solução muito mais estável e testada pelo tempo disponível em toda a Internet. Mas caso você queira implementar algo muito personalizado, espero que este artigo o ajude a fazer isso e mostre quanto tempo e esforço você poderia economizar a longo prazo automatizando o processo de implantação de seu aplicativo da web.