Paso a paso

Tres recetas para construir y ejecutar tus proyectos de Node con Docker

Comúnmente puedes meter todo tu proyecto en un contenedor de Docker y ejecutarlo con el comando que usas localmente con tu aplicación (npm start, meteor run, etc…) y hay quien incluso se atreve a usar en un servidor comandos hechos sólo para desarrollo local (nodemon, babel-node). Yo creo que podemos hacer mejor las cosas y para eso es este post, para que recuerdes que aún podemos separar la construcción de la ejecución de nuestros proyectos y sin necesidad de otra cosa más que Docker.

Todas las recetas que se mencionan a continuación son fruto de tener una política con nuestros servidores de Jenkins: “lo único que necesitamos para compilar, construir y ejecutar nuestras aplicaciones es Docker”. De esta forma no tenemos que instalar los binarios y sus diferentes versiones de node, npm, serverless, etc. en nuestros servidores encargados de construir las aplicaciones.

Para entender las recetas es necesario tener un conocimiento básico de Docker pero, sobre todo, tener claro el uso de volúmenes en la ejecución de contenedores. Los volúmenes nos darán la flexibilidad de ejecutar un proceso en un contenedor y obtener los resultados en la máquina host.

El objetivo, al final, es el mismo en todas las recetas: tener una imagen de Docker lo más optimizada posible para servir nuestra aplicación y usarla para tareas afines a la misma.

Meteor

Repo del ejemplo: //github.com/eloyvega/build_meteor

Meteor es un framework que facilita mucho el desarrollo de aplicaciones web y móviles. Sin embargo, una vez terminado el desarrollo es un poco difícil poner en marcha la aplicación dentro de un servidor. Yo recomiendo descomponer nuestra aplicación en un proyecto Node y después ejecutarlo dentro de un contenedor.

Para este ejemplo utilizaré la aplicación simple-todos del tutorial de la página oficial de Meteor. Y para “compilar” la aplicación hacia Node, haremos uso del comando meteor build.

Primero creamos una imagen de Docker con Meteor. Al momento de escribir este post la versión 1.5.2 es la más nueva, sin embargo pueden modificar el Dockerfile para instalar la versión que necesiten:

Usaremos la imagen Docker oficial de node como base de nuestros contenedores. Para saber que versión necesita tu instalación de Meteor usa el comando:

$ meteor node -v
v4.8.4

En el caso de Meteor 1.5.2, usamos como base la imagen node:4.8.4

FROM node:4.8.4

RUN apt-get update
RUN apt-get install -y build-essential
RUN curl "//install.meteor.com/?release=1.5.2" | sh

Y construimos la imagen con:

$ docker build -t meteor-build:1.5.2

En nuestro proyecto creamos la carpeta container donde pondremos los archivos necesarios para “compilar” nuestro proyecto y guardarlo en una imagen de Docker listo para ser ejecutado como una aplicación de Node.

build.sh:

#!/bin/bash
PROJECT_ROOT=$(cd ..; pwd)
docker run -i --rm -v $PROJECT_ROOT:/srv --entrypoint="/bin/bash" meteor-build:1.5.2 -c "cd /srv; npm install; meteor build /srv/dist --architecture os.linux.x86_64 --allow-superuser"
cp $PROJECT_ROOT/dist/srv.tar.gz .
docker build -t $1 .

Y le damos permiso de ejecución con:

$ chmod +x build.sh

El archivo build.sh funciona de esta forma:

  1. Guardamos el directorio de nuestro proyecto como una variable
  2. Ejecutamos nuestro contenedor de meteor-build con un volumen de nuestro proyecto hacia el contenedor; instalamos las dependencias y ejecutamos el comando meteor build para guardar el resultado en nuestra máquina dentro del directorio dist
  3. Tomamos el archivo resultante srv.tar.gz y lo movemos a la carpeta containerpara poder utilizarlo con nuestro Dockerfile
  4. Ejecutamos la construcción de nuestro contenedor de Docker que sólo contiene la aplicación Node necesaria para ejecutar nuestra app, el nombre de nuestra imagen será el que le pasemos como argumento a build.sh

Ahora creamos nuestro Dockerfile que contendrá nuestra aplicación:

FROM node:4.8.4
EXPOSE 3000
ENV USER app
ENV APP_HOME /opt/$USER
ENV ROOT_URL //localhost
ENV PORT 3000
WORKDIR $APP_HOME
ADD srv.tar.gz $APP_HOME
WORKDIR $APP_HOME/bundle
RUN (cd programs/server && npm install)
RUN useradd -d $APP_HOME $USER
RUN chown -R $USER:$USER $APP_HOME
USER $USER
ENTRYPOINT ["node","main.js"]

Este contenedor tendrá el resultado de descomprimir srv.tar.gz y descargar las dependencias. Finalmente se ejecuta con el comando node main.js lo que indica que nos deshicimos del framework de Meteor en nuestro contenedor de ejecución, ya tenemos una app lista para ponerla en un servidor. Construimos nuestra imagen desde la carpeta container con:

$ ./build.sh my-meteor-app

¿Y qué hay de Mongo? Ya no contamos con Meteor para que nos proporcione un Mongo, así que debemos pasar la variable de entorno $MONGO_URL al ejecutar nuestro contenedor my-meteor-app para que ésta funcione correctamente. Claro que en este ejemplo también lo haremos con Docker, ¿quién descarga binarios y los ejecuta a mano en pleno 2017?

$ docker run -d --name mymongo mongo

Este Mongo es para probar nuestra app, en producción usaras la URL, usuario y password para acceder a tu base de datos. Es más, el siguiente comando utiliza la característica obsoleta de link de Docker:

$ docker run -p 3000:3000 --link mymongo:mymongo -e "MONGO_URL=mongodb://mymongo:27017/todos" my-meteor-app

Nuestra aplicación corre de maravilla y podemos entrar a localhost:3000 para comprobarlo:

NodeJS y Babel

Repo del ejemplo: //github.com/eloyvega/build_nodejs_babel

Para este ejemplo usamos el “Hello, World!” de express usando sintaxis de ES6 no soportada por NodeJS 6.11.2 (LTS al momento de escribir este post). Nuestro archivo package.json queda de la siguiente forma:

{
  "name": "build_nodejs_babel",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "start": "npm run babel-node -- ./index.js",
    "babel-node": "babel-node --ignore='node_modules'",
    "build": "babel . --out-dir dist --ignore node_modules,.git,.gitignore --source-maps --copy-files"
  },
  "devDependencies": {
    "babel-cli": "6.14.0",
    "babel-preset-es2015": "6.14.0"
  },
  "dependencies": {
    "express": "^4.15.4",
  }
}

Podemos observar lo siguiente:

  • Utilizamos babel para hacer traspiling de nuestros archivos fuente escritos con sintaxis de ES6.
  • El script npm start está hecho para desarrollo debido a que ocupa el comando babel-node. El gasto de utilizar babel en producción puede ser de hasta 10 veces más en uso de memoria.
  • Tenemos un script build que convierte nuestros archivos javascript y los pone en una carpeta dist. Este script es nuestra pequeña estrella y nos ahorrará dolores de cabeza.
  • Nuestras dependencias estan claramente divididas entre las que se necesitan sólo para desarrollo y construcción; y las que son vitales para la ejecución del proyecto, bien ahí.

En la carpeta container nos encontramos con nuestro script build.sh y el archivo Dockerfile.

build.sh:

#!/bin/bash
PROJECT_ROOT=$(cd ..; pwd)
docker run -i --rm -v $PROJECT_ROOT:/srv/ -w="/srv/" --entrypoint="/bin/bash" node:6.11.2 -c "npm install --only=dev; npm run build"
tar -cvf app.tar -C ../dist/ .
docker build -t $1 .

Nuevamente nuestro archivo build es el encargado de crear nuestra carpeta distinstalando únicamente las librerías de desarrollo y utilizando el comando npm run build. El resultado se comprime en un archivo .tar y se copia a nuestra carpeta container para después usarlo con nuestro archivo Dockerfile:

FROM node:6.11.2
RUN apt-get update
RUN apt-get install -y build-essential
EXPOSE 3000
ENV USER app
ENV APP_HOME /opt/$USER
ADD app.tar $APP_HOME
WORKDIR $APP_HOME
RUN npm install --production
RUN useradd -d $APP_HOME $USER
RUN chown -R $USER:$USER $APP_HOME
USER $USER
ENTRYPOINT ["node", "index.js"]

Ejecutamos nuestro archivo build.sh:

$ build.sh my-node-app

Ya que hemos hecho el transpiling y empaquetado los archivos resultantes en nuestra imagen, sólo es necesario correr nuestro contenedor:

$ docker run -p 3000:3000 my-node-app

Y verificar que todo funciona:

React

Repo del ejemplo: //github.com/eloyvega/build_react

Este proyecto es un básico “Hello, world!” hecho con React. La diferencia con las 2 recetas anteriores es que primero construiremos el contenedor de la aplicación y después usaremos esta imagen para construir nuestros archivos estáticos y servirlos desde un CDN, o algo parecido.

El setup a grandes rasgos se compone de:

  • Webpack para hacer el bundle Javascript
  • Express para servir el archivo index.html que usa el bundle desde un CDN
  • Un script de npm para construir los archivos estáticos con webpack

El archivo package.json es:

{
  "name": "build_react",
  "version": "1.0.0",
  "main": "index.js",
  "scripts": {
    "start": "node index.js",
    "build": "npm run build:scripts",
    "build:scripts": "webpack --config webpack.config.js"
  },
  "dependencies": {
    "express": "^4.15.4",
    "path": "^0.12.7",
    "swig": "^1.4.2"
  },
  "devDependencies": {
    "react": "^15.6.1",
    "react-dom": "^15.6.1",
    "webpack": "^3.5.5",
    "babel-core": "^6.26.0",
    "babel-loader": "^7.1.2",
    "babel-preset-es2015": "^6.24.1",
    "babel-preset-react": "^6.24.1"
  }
}
  1. Las dependencias de producción son únicamente para servir los archivos html y las de desarrollo se ocupan durante la construcción de estáticos.
  2. El script de run build sirve para construir los estáticos con webpack

En esta ocasión nuestro archivo build.sh sólo sirve para crear un archivo .tar que incluye nuestra app y después construye el contenedor de Docker:

#!/bin/bash
tar --exclude ".git/" --exclude "node_modules/" -cvf app.tar ../
docker build -t $1 .

El respectivo Dockerfile:

FROM node:6.11.2
RUN apt-get update
RUN apt-get install -y build-essential
EXPOSE 3000
ENV USER app
ENV APP_HOME /opt/$USER
ADD app.tar $APP_HOME
WORKDIR $APP_HOME
RUN npm install --production
RUN useradd -d $APP_HOME $USER
RUN chown -R $USER:$USER $APP_HOME
USER $USER
ENTRYPOINT ["node", "index.js"]

El contenedor sólo cuenta con las dependencias de producción, esto hace que sea ligero y justo con lo necesario. Sólo falta construirlo:

$ ./build.sh my-react-app

Ahora ocuparemos nuestro contenedor para construir los archivos estáticos con un script llamado build_statics.sh:

#!/bin/bash
PROJECT_ROOT=$(cd ..; pwd)
docker run -i -v $PROJECT_ROOT/public/:/opt/app/public/ --entrypoint=/bin/bash --rm=true $1 -c "npm install --only=dev; npm run build"

Se ocupa el contenedor recién creado para descargar las librerías de desarrollo y ejecutar el comando npm run build. Se ejecuta de la siguiente forma:

$ ./build_statics.sh my-react-app

En este punto tenemos en nuestro proyecto los estáticos dentro de la carpeta public/dist/, podemos subir los archivos a S3 y servirlos con Cloudfront. Pero para este ejemplo sólo ocuparemos un contenedor de Nginx para simular la URL de un CDN y pasarla como variable de entorno a nuestra contenedor my-react-app, y desde la carpeta raíz del proyecto ejecutamos:

$ docker run -p 3200:80 --name some-nginx -v $(pwd)/public/:/usr/share/nginx/html:ro -d nginx

Y usando nuestra dirección IP local (en mi caso 192.168.1.66) como URL podemos ejecutar nuestra app:

$ docker run -p 3000:3000 -e "CDN=192.168.1.66:3200" my-react-app

La aplicación corre perfectamente y toma nuestro bundle desde nginx:


Puede que te parezca demasiado trabajo para aislar la tarea de construcción, pero creeme, cuando tratas con demasiados servidores de Integración Continua lo último que quieres es tener varias versiones de lenguajes y librerías que mantener.

Es importante que en tu proceso de desarrollo del día a día sigas usando tus herramientas que te hacen tan productivo: nodemon, webpack dev server, browser sync, etc. Estas recetas son únicamente para automatizar tu proceso de contruir y desplegar la aplicación a un ambiente más controlado.