Empezando con Docker 🐳

Una guía de cómo usar Docker para entornos de desarrollo y producción.

Si quieres saltarte la introducción porque ya sabes a donde apunta esto, puedes saltarte al uso de Docker.

Introducción

Cuando estamos en el desarrollo de una aplicación, es bastante común enfrentarse a problemas relacionados con las dependencias de librerías y otras herramientas de software.

Lo que uno suele hacer es escribir instrucciones en los README.md sobre cómo preparar el entorno o programando scripts en bash que preparen lo que necesitamos.

$ chmod +x ./setup.sh
$ ./setup.sh

Esta forma de atacar el problema tiene sus consecuencias, como el overhead que añade al lanzar la aplicación, así como problemas de compatibilidad con los distintos entornos donde vivirá la aplicación.

Otro problema igual de importante es la eventual existencia de colisiones entre distintas versiones de un software o en recursos compartidos.

¿A qué me refiero con esto?

Pónganse en cualquiera de estos casos:

  • Un mismo servicio es ejecutado en distintos sistemas operativos Linux, Unix y/o Microsoft.
  • Una misma máquina tiene que ejecutar concurrentemente un software que utiliza Python 2.7 y otro software que solo soporta Python 3.4.
  • Un sistema de servicios con alta dependencia entre ellos requiere cierto orden de inicio para funcionar correctamente.
  • Un sistema de servicios usan de manera compartida un mismo servicio.
  • Una aplicación es actualizada constantemente y cada vez sus dependencias cambian
  • Se quiere una aplicación que sea portable y replicable en distintas máquinas.
  • Una máquina está constantemente instalando y desinstalando distintos servicios.
Créditos: Walid Ahmad

Virtualización

Una solución a esto es el uso de tecnologías de virtualización.

En palabras simples, esto permite la creación de una máquina virtual, que ejecuta algún sistema operativo (por lo general Linux) dentro de nuestro equipo.

La ventaja de esto, es que cada entorno virtual es ajeno a los demás.

Es posible comunicar aplicaciones que se ejecuten dentro de estos a través de TCP (principalmente HTTP), ya que conocemos las IP de estas máquinas virtuales.

El software insignia de esto es Vagrant.

La desventaja de esta tecnología es que cada máquina virtual es muy pesada. Pues estas incorporan un sistema operativo completo. Esto agrega una capa de abstracción demasiado grande por cada pieza de software, por cada máquina.

Contenedores

A modo de solucionar lo anterior nacen los contenedores (containers). La idea de esto es remover la necesidad de un sistema operativo completo, pues nuestra máquina que hospeda el sistema tiene las partes del OS necesarias para hacer las llamadas al sistema y demases. Así también nos saltamos el paso de tener que emular hardware.

Si bien estos contenedores son más livianos y rápidos que una virtualización, hay que tener en consideración que no presentan el mismo grado de aislamiento.

Fuente: https://www.docker.com/what-docker

El software emblema de esto es Docker.

Docker

Lo esencial es instalar Docker Engine, pero este solo corre en Linux, así que deberás instalar Docker for Windows o Docker for Mac respectivamente.

Asegúrate de leer las instrucciones de instalación.

Uso

Partamos mostrando cómo funciona con una aplicación en Node.js.

Ni siquiera es necesario que instales Node.js en tu computador.

Vamos a usar un proyecto muy simple ya creado en: github.com/mrpatiwi/node-in-docker

git clone https://github.com/mrpatiwi/node-in-docker.git
cd node-in-docker

Vemos que esta aplicación espera interacción HTTP en el puerto 3000:

// index.js
'use strict';
const app = require('./src/app');
const PORT = 3000;
app.listen(PORT, () => {
console.log(`App listening on port ${PORT}!`);
});

Ahora, para configurar una imagen en Docker basta crear un archivo llamado Dockerfile. Este tiene la receta de cocina para crear la imagen.

# Dockerfile
FROM node:6-onbuild
EXPOSE 3000

En palabra simples esto dice:

  • Toma una imagen previa que tenga Node.js instalado
  • Expone el puerto 3000 al exterior para que puedan comunicarse conmigo.
¿De dónde sale esta imagen previa?

Existe un servicio llamado Dockerhub, este contiene muchas imágenes oficiales de varios software. Cada imagen está construida sobre otra imagen, hasta llegar a las más primitivas.

En este caso estamos usando una variante de una imagen de Node.js sacada de https://hub.docker.com/_/node/. Dicha imagen internamente instala las dependencias con npm install y ejecuta la aplicación con npm start.

Como esta es una aplicación autocontenida, puedes construirla y ejecutarla con Docker sin tener que instalar Node.js directamente a tu máquina.

Construimos la imagen llamándola express-image con:

docker build --tag express-image .

Creamos un container con el nombre express-container:

docker run -p 5000:3000 --name express-container express-image

Notemos que mapeamos el puerto 5000 de nuestra máquina al puerto 3000 del container.

Ahora es posible acceder a la aplicación en http://localhost:5000

Visitamos http://localhost:5000

Es importante mencionar que si olvidamos declarar el puerto expuesto en el Dockerfile y también olvidamos pasar el argumento de puerto (-p), nuestra aplicación queda incomunicada con el exterior. Esto puede ser bueno o malo, dependiendo qué es lo que queremos hacer.

Finalmente podemos detener y borrar el contenedor con:

# Parar un contenedor
docker stop express-container
# Volver a encender un contenedor
docker start express-container
# Destruir un contenedor (debe estar detenido primero)
docker rm express-container
# Para ver si hay otro contenedor corriendo
docker ps
# Para ver todos los contenedores (corriendo o apagados)
docker ps -a
# Para ver todas las imagenes que tengamos
docker images

Otros usos

Ya que tenemos una imagen con Node.js, podemos abrir la REPL y ejecutar comandos directamente en un contenedor.

# Crea un container desde la imagen 'node'
# y ejecuta el comando 'node' de manera interactiva.
# -it => No cierres la shell.
# --rm => Destruye el container después de terminar.
docker run -it --rm node node
$ docker run -it — rm node node

También podemos ejecutar bash dentro del container:

docker run -it --rm node bash
$ docker run -it — rm node bash

Orquestando contenedores

Supongamos que tenemos una aplicación en Ruby on Rails que necesita tener acceso a una base de datos PostgreSQL.

La mejor manera de hacer esto es usando Docker-compose.

Asegúrate de leer las instrucciones de instalación.

Aplicación de ejemplo

El código fuente está en: https://github.com/mrpatiwi/rails-in-docker. Pero puedes seguir paso por paso la guía aquí.

Crearemos una aplicación Ruby on Rails 5 en modo API (pues es más simple):

rails --version
# -> Rails 5.0.0.1
rails new --api --database=postgresql railsapi
cd railsapi

Hagamos rápidamente un scaffold de un recurso Comentarios:

rails g scaffold Comment title:string body:text

Añadamos un mensaje cuando se visite la raíz (root):

# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
def index
render json: { 'message' => 'Hello world' }
end
end

Configuremos las rutas:

# config/routes.rb
Rails.application.routes.draw do
root 'application#index'
resources :comments
end

Finalmente unos seeds para partir con algunos datos:

# db/seeds.rb
Comment.create!(title: '#1 Comment', body: 'Lorem ipsum')
Comment.create!(title: 'Hello world!', body: 'Excepteur sint')

Si tienes Postgres corriendo localmente, todo debería funcionar con:

# Si quieres probar
rails db:create
rails db:migrate
rails db:seed
rails server
Visitamos http://localhost:3000/comments

Llevando esto a producción

Necesitamos un contenedor con Ruby y otro con Postgres. Además deben estar comunicados de cierta forma.

Pero primero, creamos el siguiente archivo en nuestra aplicación:

touch Dockerfile

Con el siguiente contenido:

Una consideración importante: la imagen de Rails será deprecada y recomienda cambiarse a una imagen de Ruby.

This image is officially deprecated in favor of the standard ruby image, and will receive no further updates after 2016–12–31 (Dec 31, 2016). Please adjust your usage accordingly. — https://hub.docker.com/_/rails/

Entonces el nuevo Dockerfile recomendado es:

Ahora, la parte más delicada es configurar cómo se comunica. Para esto vamos a config/database.yml y configuraremos la parte de ‘production’.

¿Por qué estos valores?

Te darás cuenta ahora. Crearemos un docker-compose.yml

Este archivo tiene toda la información para lanzar nuestro sistema que está compuesto por distintos componentes como servicios que nacerán desde las imágenes. Una buena idea es mantener este archivo en un control de versiones como Git para poder distribuirlo a las máquinas de deployment.

Notar lo siguiente:

  • A un contenedor lo llamamos webapi y lo construimos en el directorio actual (pues ahí está el Dockerfile)
  • Tenemos un contenedor llamado postgresql que se crea a partir la de imagen oficial postgres.
  • Configuramos las variables de entorno. Por ejemplo con RAILS_ENV=production definimos que se ejecutará en modo producción. Las variables no definidas como SECRET_KEY_BASE y POSTGRES_PASSWORD se tomarán del entorno actual, así no quedan hardcodeados sus valores.
  • Finalmente, hacemos un link en webapi a postgresql para que tengamos el host postgresql disponible.

Es por esto que en database.yml configuramos:

# config/database.yml
# ...
# Tiene el mismo nombre del host (link) en webapi
host: postgresql
# Toma la variable de entorno
password: <%= ENV['POSTGRES_PASSWORD'] %>

En caso de dudas, las variables no-hardcodeadas se definen en la terminal como variables de entorno:

export POSTGRES_PASSWORD=mypassword
export SECRET_KEY_BASE=mysecretkey

Ejecutamos todo esto con:

# -d => Lo tira a segundo plano
docker-compose up -d

En Rails debemos correr las migraciones y demases, así que podemos:

# Correr migraciones y seeds
docker-compose exec webapi rails db:setup
# Cuando solo necesitemos ejecutar las migraciones:
docker-compose exec webapi rails db:migrate

Estas herramientas son muy poderosas, por esta razón es bueno leer la documentación para saber qué podemos hacer.

Podemos ver los logs de los servicios:

# Todos los logs
docker-compose logs
# Verlos en tiempo real
docker-compose logs --follow
# De un servicio en particular
docker-compose logs --follow webapi
$ docker-compose logs — follow webapi

Si queremos detener o borrar los servicios:

# Detener todos los servicios
docker-compose stop
# Detener un servicio en particular
docker-compose stop webapi
# Borrar todos contenedores de todos los servicios
docker-compose rm
# Borrar todos los contenedores de un servicio en particular
docker-compose rm webapi

También podemos ver qué cosas están corriendo:

# Mostrar activos
docker ps
# Mostrar todos
docker ps -a
# Detener un proceso
docker stop <CONTAINER ID | NAME>
# Matar un proceso detenido
docker rm <CONTAINER ID | NAME>

Finalmente, existe una segunda versión de Docker-compose. Sin embargo debemos migrar nuestro archivo. Este quedaría así:

Notar que ahora removimos link, lo que sucede es que los servicios que vivan dentro de un mismo namespace son accesibles entre ellos. No obstante, todavía podemos crear una dependencias entre servicios que afecten el orden de construcción y ejecución, esta es la nueva keyword depends_on.

Seguiremos usando la línea de comandos como siempre:

docker-compose up -d
docker-compose logs

Escalando los servicios

Una de las ventajas de usar docker-compose, es que podemos fácilmente escalar los servicios haciendo réplicas de contenedores.

A modo de ejemplo, ejecutaremos dos contenedores del servicio webapi en paralelo.

Ya que solo puede existir un servicio por puerto de la máquina (en este caso el puerto 80), vamos a poner un balanceador de carga antes de la aplicación en Rails. En particular usaremos una versión de HAProxy automatiza prácticamente todo el trabajo en un entorno con Docker.

Primero detengamos lo que teníamos antes:

docker-compose stop

Actualizamos el docker-compose.yml a:

Notemos que ahora tenemos un servicio llamado loadbalancer y este está expuesto en el puerto 80 (HTTP). También volvimos a tener que usar link, pues así funciona este contenedor.

Echamos a correr el sistema completo nuevamente:

docker-compose up -d

Ahora, para crear una réplica ejecutamos lo siguiente:

docker-compose scale webapi=2

Podemos ver en los logs que las requests se distribuyen entre los contenedores que creamos:

docker-compose logs --follow webapi
Visitamos http://localhost/comments
$ docker-compose logs — follow webapi

Podemos ver cómo las requests se reparten entre las distintas instancias de la aplicación en Rails.