Continuous Deployment con Docker + Travis + Heroku

Javier Fernandes
13 min readJun 20, 2018

--

La idea de este post es contar UNA forma de configurar un proyecto para tener deploys automáticos usando Docker.

Esto eso, tener una infraestructura que reduzca al mínimo las tareas necesarias para que, una vez decidido que hay que generar una nueva versión, se ponga en producción, accesible a los usuarios.

Existen infinitas variantes a esto así que enumero acá las restricciones de las que partimos:

  • Que sea gratuito: dado que lo vamos a usar en una materia de la universidad pública en Argentina, no queremos usar servicios pagos. Ni siquiera servicios gratuitos que requieran ingresar una tarjeta de crédito (como podría ser AWS)
  • Que utilice docker: ya que venimos viendo Docker en temas anteriores, y ya tenemos nuestras apps dockerizadas, nos gustaría aprovechar las bondades de Docker para el deploy. Ya que ahora es tan simple acceder y ejecutar cada parte de nuestra app mediante docker, el deploy debería usar el mismo mecanismo.

Por estas 2 cosas decidimos usar Heroku (si conocen otro servicio que cumpla con estas 2 condiciones de mejor manera, sería muy bueno si pueden comentarlo :P)

La Aplicación

Nuestra aplicación es una app web con los siguientes módulos

  • Un frontend: hecho en ReactJS + Redux. GitHub repo
  • Un backend: API REST en NodeJS + Express + Mongoose + Mongo. GitHub repo
  • Una base de datos NoSQL: mongo
  • Un docker-compose: que nos permite orquestar la app levantando cada módulo de los anteriores y conectarlos entre sí. Usa una proxy traefik como front para ambos módulos (frontend y backend) GitHub Repo

Como se ve acá, nuestros módulos está en repositorios distintos. Aplicaciones “mono-repo” probablemente involucren un esquema un poco distinto a lo que vamos a contar a continuación (ó tal vez es simplemente hacer los mismos pasos ejecutando múltiples scripts para cada paso)

Lo que vamos a hacer

Esto es básicamente lo que vamos a hacer

Asumimos que las apps ya estan “dockerizadas”, es decir que tienen un Dockerfile que genera una imagen.

Entonces dividimos en 2 partes:

  • Configurar Travis/CI para que: 1) Buildee las imágenes, 2) publique las imágenes en DockerHub. Esto es lo que vamos a llamar “Docker CI”
  • Configurar Travis y Heroku para que haga deploy de las imágenes en Heroku automáticamente ante un push a master

CI: Travis + Docker

Queremos que travis buildee y publique (push) las imágenes de docker. Acá un resumen de los pasos.

  • Crearse una cuenta en docker hub
  • Poder ejecutar docker en travis
  • Logearse al registry de dockerhub (env vars DOCKER_USERNAME, DOCKER_PASSWORD)
  • Ejecutar el comando docker build con el nombre apropiado.
  • En el deploy, ejecutar docker push de la imagen

Y ahora sí, el paso-a-paso.

  1. Crearse una cuenta en DockerHub y quizás una organización si trabajan en equipo. Es importante anotar de este proceso cual es su “nombre de usuario/organización”.

Si usan usuario es esto que figura acá

(caripela x200)

Si usan organizaciones

También es importante recordar el usuario y contraseña :P

2. Necesitamos poder ejecutar el comando “docker” en el build de Travis (o el CI que elijan. Esto depende de mirar un poco la documentación del CI para ver si ya soporta el comando o qué requiere).

En travis esto se hace agregando esto (fuente)

sudo: required

services:
- docker

Ahora sí, vamos a loggearnos a dockerhub. Eso lo hacemos en .travis.yml. Vamos a evitar escribir usuario y password en este archivo, usando variables de ambiente. Esto es una práctica muy común y todos los servidios/herramientas de CI lo soportan.

Para eso agregamos la siguiente linea, que debería ser auto-explicativa

sudo: required
language: node_js
node_js:
- "9"
services:
- docker
before_install:
# login to docker registries (dockerhub + heroku)
- echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
install:
# install deps
- yarn install
script:
- yarn test
- yarn build

Y necesitamos definir estas env vars.

En travis se definen en settings

Luego definimos con nuestros valores

Mantenemos las variables en secreto para que no aparezcan en logs

Con esto podemos usar el comando “docker push”. Así que ahora sí vamos a agregar los dos pasos, buildear y pushear la imagen

sudo: required
language: node_js
node_js:
- "9"
services:
- docker
before_install:
# login to docker registries (dockerhub + heroku)
- echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
install:
# install deps
- yarn install
script:
- yarn test
- yarn build
# build docker images
- docker build -t unqpdes/todo-list-front .
deploy:
provider: script
script:
# push to dockerhub
docker push unqpdes/todo-list-front;
branch: master

Nótese que usamos como nombre de imagen organizacion/artefacto (ó bien usuario/artefacto ) Esto tiene que coincidir con el usuario/organización de docker para que nos permita pushear la imagen.

También vean que estamos haciendo el push como parte de la sección “deploy” de travis. Esto le da cierta semántica a la tarea. Por ej travis sabrá que sólo se debe deployar si todos los pasos anteriores se ejecutaron exitósamente. También permite definir condiciones. En este caso sólo lo hacemos si estamos buildeando un cambio al branch “master”.

Una cosa importante a notar es que travis soporta ya hacer deploys a varias plataformas. Entre ellas a heroku. Sin embargo como nosotros queremos trabajar con imágenes de docker estamos usando el tipo de “deploy” más genérico, llamado “script”. Esto nos permite ejecutar un comando bash, lo cual nos permite correr docker :)

Listo, con estos 3 simples cambios al travis.yml y las variables de ambiente correctas, el build publicará la imagen en dockerhub automáticamente. Lo que sería una especie de “continuous building (?)” de imágenes. Útil ya que ahora desde cualquier máquina con docker podemos ejecutar la última versión de la app.

En nuestra aplicación haríamos estos pasos para buildear 2 módulos: tanto front como backend.

CD: Travis + Heroku

Y ahora lo más interesante. Queremos que además, use esa imagen buildeada para deployarla en Heroku.

De nuevo acá los pasos resumidos:

  • Crearse una cuenta en Heroku.
  • travis.yml debe instalar el heroku toolbelt CLI(para ejecutar “release” luego)
  • travis.yml debe loggearse al registry docker de Heroku (username=_ password=UN_TOKEN).
  • Crear una app en heroku (anotar nombre)
  • Taggear la imagen buildeada con el nombre que le gusta a Heroku ( registry.heroku.com/${HEROKU_APP_NAME}/web )
  • Pushear la imagen al registry de Heroku
  • Ejecutar Heroku “release” (necesita una env varHEROKU_API_KEY)
  • Hacer los cambios necesarios a nuestra app para que use la env var $PORT para escuchar (tanto front como back)

Nota sobre cómo deployar en Heroku con Docker

Es importante entender esto para poder comprender lo que vamos a hacer en el paso a paso.

Heroku permite que deployemos una app en él, de diferentes formas. Incluso la más simple es conectarlo con el repo github (con apenas unos clicks) y definer en nuestro proyecto un archivito Procfile que indique “qué comando correr” y listo. Ante cada push a github Heroku haría un release y reejecutaría la app.

Settings de Heroku. Se ve que soporta varias formas. Nosotros vamos a usar la última “Container Registry”

El tema es que eso es un poco “mágico”. Funcionaría porque Heroku “entiende” ejecutar apps nodejs. Sin embargo no estaríamos usando la idea de Docker.

Entonces vamos a usar la opción de Container Registry. Si van a esa sección en una app de heroku van a ver algunas instrucciones (que usan 100% el comando heroku. Nosotros en cambio vamos a usar el comando docker para hacer algunas cosas).

Pero básicamente la forma de deployar una imagen de docker a heroku consiste en 2 pasos:

  • Pushear la imagen a una registry (server) propio de Heroku siguiendo una convención de nombre
  • Ejecutar un comando “release” del CLI de heroku para que haga efectiva esa imagen.

El primer paso es similar a lo que ya hicimos con DockerHub, sólo que a una registry propia de Heroku. Que también va a requerir que nos loggemos con docker login).

Paso a paso

Ahora sí, los pasos. Editamos travis yml para instalar heroku CLI y loggearnos al registry de Heroku (en negrita los agregados)

before_install:
# install heroku CLI
- wget -qO- https://toolbelt.heroku.com/install.sh | sh

# login to docker registries (dockerhub + heroku)
- echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
- echo "$HEROKU_PASSWORD" | docker login -u "$HEROKU_USERNAME" --password-stdin registry.heroku.com

Nótese que agregamos 2 env vars más que deberemos definir en travis . “HEROKU_USERNAME”, puede (debe?) ser simplemente un guión bajo (“_”), ya que a la registry uno se loggea con un token de Heroku y no con su user/password.

Generamos un token que vamos a usar como HEROKU_PASSWORD usando el CLI de heroku (conviene instalar localmente)

  • heroku authorizations:create

Con eso ya podemos definir las env vars en travis.

Ahora vamos a Heroku, creamos una app y anotamos el nombre

Este nombre lo vamos a definir también como una env var en travis: $HEROKU_APP_NAME. Así no repetimos en travis.yml

Y ahora lo que tenemos que entender es que para poder pushear la imagen a Heroku, ésta tiene que tener un nombre especial con esta forma (acá la doc oficial). Esta convención permite a heroku vincular la imagen con la app heroku y controlar permisos, etc.

registry.heroku.com/$HEROKU_APP_NAME/web

Como nosotros ya estamos buildeando la imagen, entonces simplemente vamos a correr el comando docker tag, para taggearla con otro nombre.

Agregamos entonces el comando tag y también el push en deploy de esa misma imagen.

sudo: required
language: node_js
node_js:
- "9"
services:
- docker

before_install:
# install heroku CLI
- wget -qO- https://toolbelt.heroku.com/install.sh | sh
# login to docker registries (dockerhub + heroku)
- echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
- echo "$HEROKU_PASSWORD" | docker login -u "$HEROKU_USERNAME" --password-stdin registry.heroku.com

install:
# install deps
- yarn install

script:
- yarn test
- yarn build
# build docker images
- docker build -t unqpdes/todo-list-front .
- docker tag unqpdes/todo-list-front registry.heroku.com/$HEROKU_APP_NAME/web

deploy:
provider: script
script:
# push to dockerhub & heroku
docker push unqpdes/todo-list-front;
docker push registry.heroku.com/$HEROKU_APP_NAME/web;
on:
branch: master

Ahora sí, si definimos bien todas las variables en travis como se muestra, deberíamos poder pushear y ver en el log como publica la imgen en Heroku. Todavía no vamos a ver la app funcionando.

(HEROKU_API_KEY la vamos a definir a continuación)

Release

Finalmente nos falta sólo ejecutar el comando heroku release luego de hacer el push. Esto usa el CLI de heroku y requiere que estemos autenticados. En lugar de ejecutar heroku login y usar nuestro usuario y passwd, vamos a aprovechar que heroku soporta tomar una env var que se llame HEROKU_API_KEY para autenticarnos.

Entonces vamos a Heroku y en nuestras account settings

Obtenemos la key

Y la setamos en travis como variable de ambiente.

Ahora sí, podemos agregar el último comando a deploy. Versión final entonces

sudo: required
language: node_js
node_js:
- "9"
services:
- docker

before_install:
# install heroku CLI
- wget -qO- https://toolbelt.heroku.com/install.sh | sh
# login to docker registries (dockerhub + heroku)
- echo "$DOCKER_PASSWORD" | docker login -u "$DOCKER_USERNAME" --password-stdin
- echo "$HEROKU_PASSWORD" | docker login -u "$HEROKU_USERNAME" --password-stdin registry.heroku.com

install:
# install deps
- yarn install

script:
- yarn test
- yarn build
# build docker images
- docker build -t unqpdes/todo-list-front .
- docker tag unqpdes/todo-list-front registry.heroku.com/$HEROKU_APP_NAME/web

deploy:
provider: script
script:
# push to dockerhub & heroku
docker push unqpdes/todo-list-front;
docker push registry.heroku.com/$HEROKU_APP_NAME/web;
heroku container:release web --app $HEROKU_APP_NAME

on:
branch: master

(detalle nefasto: travis permite definir varios comandos en la sección principal “script”, sin embargo en la sección “deploy/script” sólo uno. Así que usamos acá la opción de separar comandos bash con punto y coma)

Ahora sí luego de buildear con travis, podemos ir a heroku y clickear en “Open App” para ver la aplicación en funcionamiento.

Cambios en las apps

Un detalle importante es que Heroku es quien estará a cargo de ejecutar las imágenes de docker. Esto quiere decir que es el que va a definir qué puertos va a “abrir” para hacer accesibles desde fuera. Con lo cual, por más que nuestra app puede abrir tantos puertos como quiera, no todos van a ser accesibles. La restricción de Heroku es que el sólo va a abrir un puerto, dado por la variable de ambiente $PORT que va a setear al momento de ejecutar nuestra imagen.

Entonces un paso fundamental es asegurarnos que nuestra app, ya sea el front o el back no esten abriendo un puerto arbitrario, sino que esten tomando esa variable para abrir el puerto.

Por ejemplo, en el backend de ejemplo tuvimos que hacer este cambio en index.js

const port = process.env.PORT || 3001

Notas de Troubleshooting

  • Si travis no buildea imagenes podemos checkear la opción “Requests” que muestra las llamadas que hace github a travis ante cada push. A veces aparecen errores ahí si no se puede conectar.
Debuggear cuando no buildea travis

Particularidades del Backend: Conexión a Mongo

Nuestro backend requiere una base de datos mongo. La app y por ende la imagen de docker soportan recibir una env var llamada MONGO_URL a la cual conectarse. Esto también es fundamental para que podamos conectar el back funcionando en Heroku con una base mongo.

Vamos a utilizar un servicio público (limitado pero suficiente) que nos da un mongo. MongoLabs

Hay 2 formas de crearse una base para usar en Heroku.

  1. A mano, vamos a mLabs y nos registramos, y creamos una base y tomamos de ahí la URL
  2. Desde Heroku, agregamos el “add-on” buscando “mlab”. Esto hará lo mismo que hacemos en la opción 1), pero ya configurará una env var en Heroku (sí !! Heroku, como travis permite definir env vars que se van a usar para ejecutar la app).

Lo más fácil y corto parece ser 2 . Así que vamos por esa. En Heroku agregamos el add-on

Asegurarse de elegir el plan FREE :P

Luego vamos a los settings de la app y renombramos la env var a MONGO_URL ya que es lo que espera nuestra app (en realidad no se banca renombrar, así que hay que crear una nueva copiando el valor :S)

Reverse Proxy

Prometo que es el último paso :)

Ya deberíamos tener tanto el front como el back funcionando en Heroku, como aplicaciones separadas. Sin embargo la aplicación no va a ser usable, porque el frontend está hecho de forma de que espera hacer pedidos al back, usando la misma URL en la que está él mismo (front), pero bajo el path /todos (otra practica común es proxiar el back bajo algo más explícito como /api/.. )

Con lo cual nuestra arquitectura necesita tener un proxy delante que exponga tanto front como back bajo una misma URL y tenga la inteligencia de routear los pedidos según esta regla (/todos/* al backend, todo lo demás al front, en nuestro caso).

Hasta ahora en el docker-compose lo que hacíamos era levantar una instancia de traefik reverse-proxy que hacía este trabajo.

Vamos a hacer lo mismo, pero, como vimos ya, la forma de poner algo a correr en Heroku es usando imagenes de docker! Eso es Dockerfiles, no docker-compose !

Entonces una opción es construirle un proyecto propio al “proxy” que siga toda la infraestructura que ya hicimos, pero que simplemente tenga un Dockerfile que herede de traefik y lo configure. Dos cosas:

  1. Le configure las reglas igual que en el compose, mediante el archivo traefik.toml
  2. Permite levantar traefik pisando el puerto con el valor de $PORT (recuerden que estamos obligados a escuchar en ese puerto que setea Heroku).

Para eso varios pasos resumidos acá

  • Crear un repo en Github
  • (al menos yo tuve que hacer esto, quizás no era necesario), crearle un proyectito node bien simple que no haga nada, para poder configurar travis (ya que no hay un tipo de build de travis “docker”)
  • Crear un archivo traefik.toml que tenga las reglas, y apunto a las URLs de las aplicaciones de Heroku !! las mismas que podíamos probar haciendo click en “Open App”.
  • Crear un archivito bash que será el que se ejecute y utilice $PORT
  • Crear un DockerFile

Hasta acá muestro las dos cosas importantes (el proyecto completo está acá).

traefik.toml

[entryPoints]
[entryPoints.http]
address = ":80" # will be overriden

[frontends]
[frontends.frontend1]
backend = "backend1"

[frontends.frontend2]
backend = "backend2"
[frontends.frontend2.routes.test_1]
rule = "Path:/todos"

[backends]
[backends.backend1]
[backends.backend1.servers.server1]
url = "https://pdes-2018-todo-list-front.herokuapp.com/"
[backends.backend2]
[backends.backend2.servers.server1]
url = "https://pdes-2018-todo-list-back.herokuapp.com/"

Lo que cambia respecto del compose son las URLs. Que deberán setear sus propias URLs obviamente :P

entrypoint.sh

#!/bin/sh
echo "Starting traefik on port $PORT!"

traefik --file --entrypoints="NAME:http Address::$PORT"

Esto parece medio críptico, pero se puede ver en la docu de traefik. Es la forma que tenemos de sobrescribir cierta parte de la configuración desde los argumentos.

Y el Dockerfile

FROM traefik:alpine

COPY traefik/traefik.toml /traefik.toml
ADD entrypoint.sh /

CMD ["/entrypoint.sh"]
ENTRYPOINT [ "/entrypoint.sh" ]

Esto como vemos hereda de traefik, copia ambos archivos y define el comando a ejecutar.

Así ya podríamos probar todo localmente, buildeando la imagen con docker y ejecutándola. Debería funcionar la app completa, ya que le pega a las instancias de Heroku.

Luego los pasos sería hacer lo mismo que hicimos para los otros proyectos para tener continuous deployment

  • Hacerle un job de travis que cree, publique y libere la imagen en Heroku
  • (para eso) Crear una app para el proxy en heroku.

Pueden ver el travis.yml para esto, pero es más de lo mismo.

Ahora sí, terminamos. La app finalmente se accede a través de está última app de Heroku, el proxy.

Links

--

--

Javier Fernandes

Software Engineer@SCVSoft. Professor@UNQ (Public National University, Argentina). Creator of Wollok Programming Language. Always learning & creating things :)