Implementar aplicaciones web automáticamente mediante webhooks de GitHub
Publicado: 2022-03-11Cualquiera que desarrolle aplicaciones web e intente ejecutarlas en sus propios servidores no administrados es consciente del tedioso proceso que implica implementar su aplicación y enviar futuras actualizaciones. Los proveedores de plataforma como servicio (PaaS) han facilitado la implementación de aplicaciones web sin tener que pasar por el proceso de aprovisionamiento y configuración de servidores individuales, a cambio de un ligero aumento en los costos y una disminución de la flexibilidad. PaaS puede haber facilitado las cosas, pero a veces aún necesitamos o queremos implementar aplicaciones en nuestros propios servidores no administrados. Automatizar este proceso de implementación de aplicaciones web en su servidor puede parecer abrumador al principio, pero en realidad, encontrar una herramienta simple para automatizar esto puede ser más fácil de lo que piensa. Lo fácil que será implementar esta herramienta depende mucho de cuán simples sean sus necesidades, pero ciertamente no es difícil de lograr y probablemente pueda ayudar a ahorrar mucho tiempo y esfuerzo al hacer las tediosas y repetitivas partes de la aplicación web. implementaciones.
Muchos desarrolladores han ideado sus propias formas de automatizar los procesos de implementación de sus aplicaciones web. Dado que la forma en que implementa sus aplicaciones web depende mucho de la pila de tecnología exacta que se utiliza, estas soluciones de automatización varían entre sí. Por ejemplo, los pasos involucrados en la implementación automática de un sitio web PHP son diferentes a los de la implementación de una aplicación web Node.js. Existen otras soluciones, como Dokku, que son bastante genéricas y estas cosas (llamadas paquetes de compilación) funcionan bien con una gama más amplia de tecnología.
En este tutorial, veremos las ideas fundamentales detrás de una herramienta simple que puede crear para automatizar sus implementaciones de aplicaciones web mediante webhooks, paquetes de compilación y Procfiles de GitHub. El código fuente del programa prototipo que exploraremos en este artículo está disponible en GitHub.
Primeros pasos con las aplicaciones web
Para automatizar la implementación de nuestra aplicación web, escribiremos un programa Go simple. Si no está familiarizado con Go, no dude en seguirlo, ya que las construcciones de código utilizadas a lo largo de este artículo son bastante simples y deberían ser fáciles de entender. Si lo desea, probablemente pueda portar todo el programa a un idioma de su elección con bastante facilidad.
Antes de comenzar, asegúrese de tener la distribución Go instalada en su sistema. Para instalar Go, puede seguir los pasos descritos en la documentación oficial.
A continuación, puede descargar el código fuente de esta herramienta clonando el repositorio de GitHub. Esto debería facilitarle el seguimiento, ya que los fragmentos de código de este artículo están etiquetados con sus nombres de archivo correspondientes. Si quieres, puedes probarlo ahora mismo.
Una de las principales ventajas de usar Go para este programa es que podemos construirlo de una manera en la que tengamos dependencias externas mínimas. En nuestro caso, para ejecutar este programa en un servidor solo debemos asegurarnos de tener instalados Git y Bash. Dado que los programas de Go se compilan en archivos binarios vinculados estáticamente, puede compilar el programa en su computadora, cargarlo en el servidor y ejecutarlo casi sin esfuerzo. Para la mayoría de los otros lenguajes populares de hoy, esto requeriría un entorno de tiempo de ejecución gigantesco o un intérprete instalado en el servidor solo para ejecutar su sistema automático de implementación. Los programas Go, cuando se hacen correctamente, también pueden ser muy fáciles de usar en la CPU y la RAM, que es algo que desea de programas como este.
Webhooks de GitHub
Con GitHub Webhooks, es posible configurar su repositorio de GitHub para emitir eventos cada vez que algo cambia dentro del repositorio o algún usuario realiza acciones particulares en el repositorio alojado. Esto permite a los usuarios suscribirse a estos eventos y recibir notificaciones a través de invocaciones de URL de los diversos eventos que tienen lugar en su repositorio.
Crear un webhook es muy simple:
- Navega a la página de configuración de tu repositorio
- Haga clic en "Webhooks y servicios" en el menú de navegación izquierdo
- Haga clic en el botón "Agregar webhook"
- Establezca una URL y, opcionalmente, un secreto (que permitirá al destinatario verificar la carga útil)
- Hacer otras elecciones en el formulario, según sea necesario
- Envíe el formulario haciendo clic en el botón verde "Agregar webhook"
GitHub proporciona documentación extensa sobre Webhooks y cómo funcionan exactamente, qué información se entrega en la carga útil en respuesta a varios eventos, etc. A los fines de este artículo, estamos particularmente interesados en el evento "push" que se emite cada vez que alguien empuja a cualquier rama del repositorio.
Paquetes de compilación
Los Buildpacks son bastante estándar en estos días. Utilizados por muchos proveedores de PaaS, los paquetes de compilación le permiten especificar cómo se configurará la pila antes de implementar una aplicación. Escribir paquetes de compilación para su aplicación web es realmente fácil, pero la mayoría de las veces una búsqueda rápida en la web puede encontrar un paquete de compilación que puede usar para su aplicación web sin ninguna modificación.
Si ha implementado una aplicación en PaaS como Heroku, es posible que ya sepa qué son los paquetes de compilación y dónde encontrarlos. Heroku tiene una documentación completa sobre la estructura de los paquetes de compilación y una lista de algunos paquetes de compilación populares bien construidos.
Nuestro programa de automatización utilizará un script de compilación para preparar la aplicación antes de ejecutarla. Por ejemplo, una compilación de Node.js de Heroku analiza el archivo package.json, descarga una versión adecuada de Node.js y descarga las dependencias de NPM para la aplicación. Vale la pena señalar que, para simplificar las cosas, no tendremos un amplio soporte para buildpacks en nuestro programa prototipo. Por ahora, supondremos que los scripts del paquete de compilación están escritos para ejecutarse con Bash y que se ejecutarán en una instalación nueva de Ubuntu tal como está. Si es necesario, puede extender esto fácilmente en el futuro para abordar necesidades más esotéricas.
Perfiles
Los archivos de proceso son archivos de texto simples que le permiten definir los distintos tipos de procesos que tiene en su aplicación. Para la mayoría de las aplicaciones simples, lo ideal sería tener un solo proceso "web" que sería el proceso que maneja las solicitudes HTTP.
Escribir perfiles es fácil. Defina un tipo de proceso por línea escribiendo su nombre, seguido de dos puntos, seguido del comando que generará el proceso:
<type>: <command>
Por ejemplo, si estuviera trabajando con una aplicación web basada en Node.js, para iniciar el servidor web, ejecutaría el comando "node index.js". Simplemente puede crear un Procfile en el directorio base del código y nombrarlo "Procfile" con lo siguiente:
web: node index.js
Requeriremos que las aplicaciones definan tipos de procesos en Procfiles para que podamos iniciarlos automáticamente después de ingresar el código.
Gestión de eventos
Dentro de nuestro programa, debemos incluir un servidor HTTP que nos permitirá recibir solicitudes POST entrantes de GitHub. Tendremos que dedicar alguna ruta de URL para manejar estas solicitudes de GitHub. La función que manejará estas cargas útiles entrantes se verá así:
// 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 } }) }
Comenzamos verificando el tipo de evento que ha generado este payload. Dado que solo estamos interesados en el evento "push", podemos ignorar todos los demás eventos. Incluso si configura el webhook para que solo emita eventos "push", todavía habrá al menos otro tipo de evento que puede esperar recibir en el punto final de su enlace: "ping". El propósito de este evento es determinar si el webhook se configuró correctamente en GitHub.
A continuación, leemos el cuerpo completo de la solicitud entrante, calculamos su HMAC-SHA1 usando el mismo secreto que usaremos para configurar nuestro webhook y determinamos la validez de la carga útil comparándola con la firma incluida en el encabezado de la solicitud. En nuestro programa, ignoramos este paso de validación si el secreto no está configurado. Como nota al margen, puede que no sea una buena idea leer todo el cuerpo sin tener al menos algún tipo de límite superior sobre la cantidad de datos que querremos tratar aquí, pero simplifiquemos las cosas para centrarnos en los aspectos críticos. de esta herramienta.
Luego usamos una estructura de la biblioteca cliente de GitHub para que Go desmarque la carga útil entrante. Como sabemos que es un evento "push", podemos usar la estructura PushEvent. Luego usamos la biblioteca de codificación json estándar para descomponer la carga útil en una instancia de la estructura. Realizamos un par de controles de cordura y, si todo está bien, invocamos la función que inicia la actualización de nuestra aplicación.
Actualización de la aplicación
Una vez que recibimos una notificación de evento en nuestro punto final de webhook, podemos comenzar a actualizar nuestra aplicación. En este artículo, veremos una implementación bastante simple de este mecanismo, y ciertamente habrá espacio para mejoras. Sin embargo, debería ser algo que nos ayude a comenzar con algún proceso básico de implementación automatizada.
Inicializando el Repositorio Local
Este proceso comenzará con una simple verificación para determinar si es la primera vez que intentamos implementar la aplicación. Lo haremos comprobando si existe el directorio del repositorio local. Si no existe, primero inicializaremos nuestro repositorio local:
// 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 en la estructura de la aplicación se puede usar para inicializar el repositorio local y sus mecanismos son extremadamente simples:
- Cree un directorio para el repositorio local si no existe.
- Use el comando "git init" para crear un repositorio básico.
- Agregue una URL para el repositorio remoto a nuestro repositorio local y asígnele el nombre "origen".
Una vez que tengamos un repositorio inicializado, buscar cambios debería ser simple.
Obtener cambios
Para obtener cambios del repositorio remoto, solo necesitamos invocar un 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() }
Al hacer una "búsqueda de git" para nuestro repositorio local de esta manera, podemos evitar problemas con Git que no puede avanzar rápidamente en ciertos escenarios. No es que las recuperaciones forzadas sean algo en lo que deba confiar, pero si necesita hacer un empuje forzado a su repositorio remoto, esto lo manejará con gracia.

Aplicación de compilación
Dado que estamos utilizando scripts de paquetes de compilación para compilar nuestras aplicaciones que se están implementando, nuestra tarea aquí es 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() }
Comenzamos eliminando nuestro directorio de aplicaciones anterior (si corresponde). A continuación, creamos uno nuevo y le extraemos el contenido de la rama maestra. Luego usamos el script de "detección" del paquete de compilación configurado para determinar si la aplicación es algo que podemos manejar. Luego, creamos un directorio de "caché" para el proceso de compilación del paquete de compilación si es necesario. Dado que este directorio persiste entre compilaciones, puede suceder que no tengamos que crear un nuevo directorio porque ya existirá uno de algún proceso de compilación anterior. En este punto, podemos invocar el script de "compilación" del paquete de compilación y hacer que prepare todo lo necesario para la aplicación antes del lanzamiento. Cuando los paquetes de compilación se ejecutan correctamente, pueden manejar el almacenamiento en caché y la reutilización de recursos previamente almacenados en caché por sí mismos.
Reinicio de la aplicación
En nuestra implementación de este proceso de implementación automatizado, detendremos los procesos antiguos antes de comenzar el proceso de compilación y luego iniciaremos los nuevos procesos una vez que se complete la fase de compilación. Aunque esto facilita la implementación de la herramienta, deja algunas formas potencialmente sorprendentes de mejorar el proceso de implementación automatizado. Para mejorar este prototipo, probablemente pueda comenzar asegurándose de que no haya tiempo de inactividad durante las actualizaciones. Por ahora, continuaremos con el enfoque más simple:
// 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 }
En nuestro prototipo, detenemos e iniciamos los diversos procesos iterando sobre una matriz de nodos, donde cada nodo es un proceso correspondiente a una de las instancias de la aplicación (como se configuró antes de iniciar esta herramienta en el servidor). Dentro de nuestra herramienta, hacemos un seguimiento del estado actual del proceso para cada nodo. También mantenemos archivos de registro individuales para ellos. Antes de que se inicien todos los nodos, a cada uno se le asigna un puerto único a partir de un número de puerto determinado:
// 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 }
De un vistazo, esto puede parecer un poco más complicado que lo que hemos hecho hasta ahora. Para facilitar la comprensión, dividamos el código anterior en cuatro partes. Los dos primeros están dentro de la función “NewNode”. Cuando se llama, completa una instancia de la estructura "Nodo" y genera una rutina Go que ayuda a iniciar y detener el proceso correspondiente a este Nodo. Los otros dos son los dos métodos en la estructura "Nodo": "Iniciar" y "Detener". Un proceso se inicia o se detiene al pasar un "mensaje" a través de un canal particular que esta rutina Go por nodo está vigilando. Puede pasar un mensaje para iniciar el proceso o un mensaje diferente para detenerlo. Dado que los pasos reales involucrados en iniciar o detener un proceso ocurren en una sola rutina Go, no hay posibilidad de obtener condiciones de carrera.
La rutina Go inicia un bucle infinito donde espera un "mensaje" a través del canal "stateCh". Si el mensaje pasado a este canal solicita que el nodo inicie el proceso (dentro de “case StateUp”), usa Bash para ejecutar el comando. Al hacerlo, configura el comando para usar las variables de entorno definidas por el usuario. También redirige la salida estándar y los flujos de error a un archivo de registro predefinido.
Por otro lado, para detener un proceso (dentro del “case StateDown”), simplemente lo mata. Aquí es donde probablemente podría ser creativo y, en lugar de cancelar el proceso, envíele inmediatamente un SIGTERM y espere unos segundos antes de cancelarlo, dándole al proceso la oportunidad de detenerse correctamente.
Los métodos "Iniciar" y "Detener" facilitan la transmisión del mensaje adecuado al canal. A diferencia del método "Iniciar", el método "Detener" en realidad espera a que los procesos finalicen antes de regresar. “Iniciar” simplemente pasa un mensaje al canal para iniciar el proceso y regresa.
combinándolo todo
Finalmente, todo lo que tenemos que hacer es conectar todo dentro de la función principal del programa. Aquí es donde cargaremos y analizaremos el archivo de configuración, actualizaremos el paquete de compilación, intentaremos actualizar nuestra aplicación una vez e iniciaremos el servidor web para escuchar las cargas útiles de eventos "push" entrantes de 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) }
Dado que requerimos que los paquetes de compilación sean repositorios de Git simples, "UpdateBuildpack" (implementado en buildpack.go) simplemente realiza un "clon de git" y un "extracción de git" según sea necesario con la URL del repositorio para actualizar la copia local del paquete de compilación.
probandolo
En caso de que aún no haya clonado el repositorio, puede hacerlo ahora. Si tiene instalada la distribución Go, debería ser posible compilar el programa de inmediato.
mkdir hopper cd hopper export GOPATH=`pwd` go get github.com/hjr265/toptal-hopper go install github.com/hjr265/toptal-hopper
Esta secuencia de comandos creará un directorio llamado hopper, lo configurará como GOPATH, obtendrá el código de GitHub junto con las bibliotecas Go necesarias y compilará el programa en un binario que puede encontrar en el directorio "$GOPATH/bin". Antes de que podamos usar esto en un servidor, necesitamos crear una aplicación web simple para probar esto. Para mayor comodidad, he creado una aplicación web Node.js similar a "Hola, mundo" simple y la he subido a otro repositorio de GitHub que puede bifurcar y reutilizar para esta prueba. A continuación, debemos cargar el binario compilado en un servidor y crear un archivo de configuración en el mismo directorio:
# 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 = ""
La primera opción en nuestro archivo de configuración, “core.addr” es la que nos permite configurar el puerto HTTP del servidor web interno de nuestro programa. En el ejemplo anterior, lo hemos configurado en “:26590”, lo que hará que el programa escuche las cargas útiles de eventos “push” en “http://{host}:26590/hook”. Al configurar el webhook de GitHub, simplemente reemplace "{host}" con el nombre de dominio o la dirección IP que apunta a su servidor. Asegúrese de que el puerto esté abierto en caso de que esté utilizando algún tipo de firewall.
A continuación, elegimos un paquete de compilación configurando su URL de Git. Aquí estamos usando el paquete de compilación Node.js de Heroku.
En "aplicación", establecemos "repo" en el nombre completo de su repositorio de GitHub que aloja el código de la aplicación. Dado que estoy alojando la aplicación de ejemplo en "https://github.com/hjr265/hopper-hello.js", el nombre completo del repositorio es "hjr265/hopper-hello.js".
Luego establecemos algunas variables de entorno para la aplicación y la cantidad de cada tipo de procesos que necesitamos. Y finalmente, elegimos un secreto, para que podamos verificar las cargas útiles de eventos "push" entrantes.
Ahora podemos iniciar nuestro programa de automatización en el servidor. Si todo está configurado correctamente (incluida la implementación de claves SSH, de modo que se pueda acceder al repositorio desde el servidor), el programa debe obtener el código, preparar el entorno con el paquete de compilación e iniciar la aplicación. Ahora todo lo que tenemos que hacer es configurar un webhook en el repositorio de GitHub para emitir eventos push y apuntarlo a "http://{host}:26590/hook". Asegúrese de reemplazar "{host}" con el nombre de dominio o la dirección IP que apunta a su servidor.
Para probarlo finalmente, realice algunos cambios en la aplicación de ejemplo y envíelos a GitHub. Notará que la herramienta de automatización entrará en acción de inmediato y actualizará el repositorio en el servidor, compilará la aplicación y la reiniciará.
Conclusión
De la mayoría de nuestras experiencias, podemos decir que esto es algo bastante útil. Es posible que la aplicación prototipo que hemos preparado en este artículo no sea algo que desee utilizar en un sistema de producción tal como es. Hay un montón de espacio para mejorar. Una herramienta como esta debería tener un mejor manejo de errores, admitir apagados/reinicios correctos, y es posible que desee usar algo como Docker para contener los procesos en lugar de ejecutarlos directamente. Puede ser más inteligente averiguar qué es exactamente lo que necesita para su caso específico y crear un programa de automatización para eso. O tal vez use alguna otra solución mucho más estable y comprobada disponible en Internet. Pero en caso de que desee implementar algo muy personalizado, espero que este artículo lo ayude a hacerlo y le muestre cuánto tiempo y esfuerzo podría ahorrar a largo plazo al automatizar el proceso de implementación de su aplicación web.