Docker 101 - Desde cero hasta containerizar nuestra primera aplicación.
August
30th,
2017
·
Tiempo estimado de lectura: 35 minutos.
En este post vamos a aprender qué es Docker, cómo utilizarlo, cuándo es apropiado hacerlo y crearemos nuestro primer contenedor para aislar una aplicación ficticia basada en un escenario de producción.
Antes de adentrarnos en el mundo de Docker, debemos tener claros unos conceptos básicos.
Explicado de forma muy simplificada: Un contenedor es un híbrido entre una versión más avanzada de chroot y una alternativa ligera a la virtualización.
Un contenedor es una instancia aislada a nivel de usuario del sistema operativo. Desde el punto de vista de una aplicación que se ejecuta en un contenedor, esta instancia es un ordenador independiente; y sólo tiene acceso a los recursos explícitamente asignados al contenedor.
Los contenedores comparten el núcleo y la API del sistema con el sistema operativo anfitrión, reduciendo así los recursos necesarios para ejecutar un contenedor comparado con la virtualización tradicional. La desventaja de este diseño es que el contenedor debe ejecutar el mismo sistema operativo que el anfitrión, no puedes lanzar un contenedor de una aplicación Windows en un sistema anfitrión basado en Linux.
Ventajas y desventajas de utilizar contenedores.
Las principales ventajas del uso de contenedores son las siguientes:
Podemos empaquetar aplicaciones junto con sus dependencias, creando así una versión portable de la aplicación y eliminando el temido “pero en mi máquina funciona”. Esto nos ayuda a simplificar el proceso de despliegue de una aplicación, y es un paso más para eliminar el metafórico muro que separa Operaciones y Desarrollo.
Requiere de pocos recursos de sistema adicionales, comparado con ejecutar la aplicación directamente. Esto nos permite conseguir una mayor densidad y aprovechamiento de los recursos de hardware comparado con la virtualización tradicional.
Reduce el esfuerzo necesario para mantener el entorno de ejecución, junto con su complejidad. En un contenedor nuestra aplicación o servicio se convierte en un paquete redistribuible, garantizando así que su ejecución será la misma en un entorno de pruebas que en uno de producción.
Dado que el entorno de ejecución se declara en texto plano, nos beneficiamos de las ventajas de IaC: Ahora podemos versionar el entorno y la configuración de la aplicación, revirtiendo y re-desplegando con alta velocidad y fiabilidad.
Por supuesto, no todo son ventajas:
Un contenedor consume recursos adicionales comparado con la ejecución directa tradicional.
Los contenedores comparten el núcleo con el anfitrión. Cualquier bug o glitch en el núcleo afecta a todos los contenedores.
La gestión de un alto número de contenedores es compleja. Existen herramientas para mitigar este aspecto, como Docker Swarm y Google Kubernetes.
Las aplicaciones con interfaz gráfico no son fácilmente containerizables. Los contenedores están orientados al aislamiento de servicios. Aunque podemos utilizar soluciones alternativas como redirección de X11, no es fácil y tiene su propio set de desventajas.
Ejemplos de aplicaciones reales ejecutándose en un contenedor.
Docker es un producto software que proporciona soporte para contenedores. Gracias a Docker, podemos empaquetar aplicaciones junto con sus dependencias, librerías y configuración necesarias para su ejecución de forma segura y aislada.
Entre otras funcionalidades, Docker permite la creación de redes aisladas y la conexión directa de dispositivos de hardware al entorno del contenedor. Docker también proporciona la descarga de imágenes preparadas desde Docker Hub o repositorios privados.
Qué no es Docker.
Docker no es una solución general al problema del empaquetado y distribución de una aplicación. No todas las aplicaciones pueden ser containerizadas de forma efectiva, y no siempre tiene sentido invertir tiempo y esfuerzo y hacerlo.
Un ejemplo obvio, ¿tendría sentido distribuir Skype en un contenedor? No.
Skype es una aplicación monolítica, y dividirla en pequeños componentes containerizados no proporcionaría beneficios en la práctica.
Skype necesita acceso a las facilidades del sistema anfitrión para entrada y salida de audio, y no existe un interfaz que lo proporcione. Si el servidor de sonido lo soporta (PulseAudio, por ejemplo) podríamos ejecutar otra instancia del servidor de sonido dentro del contenedor y redirigir su salida hacia el sistema operativo anfitrión; con la desventaja de un consumo adicional de recursos.
Un contenedor no tiene comunicación nativa con el servidor de interfaz gráfica. Podríamos utilizar X11-forwarding a través de la red, o compartir el socket X11 aunque esto rompería el aislamiento del contenedor.
Si tras el estudio preliminar, hubiésemos decidido que distribuir Skype nos proporciona todos o la mayoría de beneficios que proporcionan los contenedores (facilidad de despliegue, aislamiento, incorporación en una pipeline de integración continua) entonces estas desventajas podrían haber sido aceptables.
Instalación de Docker
Vamos a instalar Docker 17.06.1-ce en Ubuntu 17.04.
El primer paso es asegurarnos de que disponemos de las utilidades necesarias para añadir repositorios HTTPS a los orígienes de paquetes.
Vamos a importar la clave GPG de Docker para verificar la integridad de los paquetes, y a continuación comprobamos que la clave es la misma.
Docker tiene tres posibles ramas dependiendo de la estabilidad deseada: stable, edge y test. A no ser que realmente necesitemos funcionalidad o parches que no estén disponibles en la rama estable, es recomendable instalar stable ya que es la única que recibe soporte activo y que se considera lista para producción.
Para cambiar de rama, añade la palabra edge o test despues de stable en el comando anterior.
Refrescamos la lista de paquetes disponibles e iniciamos la instalación de Docker.
Actualmente los contenedores de Docker tienen que ser controlados por el usuario root (o el equivalente en tu plataforma). Este requisito no es opcional actualmente, aunque está planeado eliminarlo en un futuro.
Como consecuencia, debemos otorgar acceso al servicio de Docker y sus herramientas asociadas sólo a usuarios de confianza.
Pasos opciones tras la instalación.
Creación del grupo de usuarios docker.
Para no obligarnos a usar sudo para interactuar con Docker, podemos crear un grupo llamado docker y añadir usuarios al mismo. Cuando el servicio de Docker se inicia, otorga permisos de lectura/escritura en su socket al grupo docker.
Advertencia: Los usuarios miembros del grupo docker tienen, en la práctica, privilegios equivalentes a root. Docker publica un análisis ampliado sobre el impacto en la seguridad del sistema en su página web, en inglés: Docker Daemon Attack Surface.
Creamos el grupo de docker, y le añadimos el usuario actual.
Los cambios se harán efectivos cuando el usuario cierre sesión y vuelva a iniciarla.
Inicio del servicio de Docker con el arranque del sistema.
Auto-completado de comandos y parámetros para Docker.
Docker contiene una gran variedad de comandos, y las imágenes y contenedores se identifican por su ID (aunque podemos añadir manualmente un nombre personalizado).
En este post vamos a activar el auto-completado para Bash en Ubuntu 16.04. Hay disponibles instrucciones actualizadas para Bash y para otros shells en la web de Docker.
Como requisito, tenemos que tener instalado el paquete bash-completion. En el caso, muy poco probable, de que no esté instalado lo instalaremos el siguiente comando.
Ahora descargamos el script de auto-completado y lo guardamos en /etc/bash_completion.d/.
Desde este momento, cada nueva instancia de bash usará el esquema ampliado de auto-completado.
Lanzando nuestro primer contenedor, y un vistazo al interior de Docker.
Hola mundo.
Para testear nuestra instalación de Docker vamos a ejecutar un contenedor simple proporcionado por el mismo Docker. Su única función es mostrar un mensaje de bienvenida.
Su salida en el terminal es la siguiente, comentada para su mejor comprensión.
¿Qué acaba de pasar? ¿Qué es una imagen, y por qué Docker acaba de descargarla de Internet?
Para poder entenderlo, tenemos que dar un paso atrás y examinar el proceso de creación de un contenedor.
Flujo de trabajo con Docker, y ciclo de vida de un contenedor.
Un contenedor tiene dos capas: El entorno base en el que se ejecutará la aplicación, llamado image o imagen; y la aplicación o servicio en si misma que será ejecutada. Ambos se definen en un archivo Dockerfile, que no es más que un archivo de texto plano llamado Dockerfile.
Por ejemplo, vamos a examinar de cerca el Dockerfile de “Hola Mundo” que acabamos de ejecutar.
Todas las instrucciones contenidas en un Dockerfile se refieren al proceso de composición de la imagen padre, excepto la instrucción CMD que se refiere al comando que se ejecutará al lanzar el contenedor.
FROM especifica el entorno base en el que se ejecutarán el resto de las instrucciones. En este caso hemos usado un entorno mínimo llamado scratch, pero podemos especificar otro entorno diferente como Ubuntu o CentOS.
COPY copia el archivo especifiado al destino especificado. En este caso, el binario hello se copia a la raíz del sistema de archivos.
CMD ejecuta un binario llamado hello al iniciar el contenedor.
Vamos a añadir un poco de complejidad al Dockerfile. Vamos a comenzar con un Debian Jessie, quitarle todos los componentes de Python que contiene y reemplazarlos con Python 3.4.3. Al lanzar el contenedor, mostraremos “Hola mundo.” en pantalla usando la nueva instalación de Python.
Precaución: No confundir RUN con CMD. RUN se ejecuta sólo al construir la imagen padre, y CMD sólo se ejecuta al lanzar el contenedor.
El siguiente paso es construir la imagen. Al hacerlo, Docker incluirá todos los archivos y carpetas presentes en el directorio actual.
Al finalizar el proceso, lanzamos un contenedor basado en esa imagen.
Un contenedor no tiene almacenamiento persistente, cualquier cambio se descarta al parar la ejecución. Si quisiésemos conservar el estado de una aplicación o servicio, por ejemplo una base de datos, ni que decir tiene que el descarte no es deseable.
La solución de Docker a este problema se llama mounts.
Mounts
Hay tres tipos de mounts, y dos de ellas pueden utilizarse para montar carpetas en el contenedor cuyo contenido “vive” fuera del mismo.
Volumes (volúmenes): gestionados por docker, existen fuera del ciclo de vida de un contenedor y son portables. Apropiados para compartir datos entre contedores, y la opción recomendada para el almacenamiento de los mismos. Los volúmenes se almacenan en el sistema de archivos del anfitrión, en /var/lib/docker/volumes.
Bind mounts (“puntos de montaje asociados”): son carpetas tradicionales en el sistema anfitrión que se montan en una ruta específica en el contenedor. Son útiles para compartir datos entre el anfitrión y el contenedor.
Puntos de montaje TMPFS: Sistemas de archivos en RAM que funcionan de manera idéntica a los puntos de montaje TMPFS convencionales. Sólo deben utilizarse para archivos temporales, y tienen la ventaja de ser mucho más rápidos que el almacenamiento en disco.
Qué tipo de mount debo elegir: volume, bind mount o TMPFS.
Un volume puede ser compartido entre varios contenedores, son portables y pueden ser fácilmente copiados/restaurados. Es posible copiar el contenido adecuado en los volúmenes durante el despliegue, y se pueden montar como solo lectura.
Por ejemplo, podríamos hostear un sitio web y permitir la modificación del mismo a través de FTP. Para ello, /var/www se encontraría en un volumen accesible para el contenedor de Apache y para el del servidor FTP.
Un volumen puede ser declarado en un Dockerfile con la siguiente sintáxis: VOLUME /var/www.
Bind mounts son adecuados cuando necesitamos compartir datos entre el anfitrion y el contenedor. Por ejemplo, queremos almacenar los logs del servicio en una carpeta del anfitrión o mantener el /etc/resolf.conf sincronizado entre los dos.
Las bind mounts no pueden ser declaradas desde el Dockerfile, ya que el contenedor debe ser portable y puede que esos directorios no existan al ejecutar el contenedor en otra máquina. Una bind mount se declara con el parámetro –mount al lanzar el contenedor, como veremos más adelante.
tmpfs mounts son preferibles para guardar el estado no persistente de la aplicación, en caso de que necesitemos eliminar el cuello de botella del disco duro a la hora de trabajar con esos archivos.
Redes.
Docker nos ofrece la capacidad de desplegar redes personalizadas, y por defecto define tres: bridge (puente) que es la red por defecto para todos los contenedores y está aislada del anfitrión, none (ninguna) que significa que el contenedor no tendra conectividad; y host que asocia el contenedor a la red del anfitrión, rompiendo así el aislamiento.
Red host signfica que el contenedor será accesible desde fuera del anfitrión. Por ejemplo, un servidor web ejecutándose en el puerto 80 de un contenedor será automáticamente visible en el puerto 80 del anfitrión.
Docker Engine soporta dos tipos de redes: red puente, limitada a un host; y redes overlay, que se extienden a través de varios anfitriones.
En este post utilizaremos la red puente por defecto, para desplegar nuestra aplicación de forma aislada.
El manual completo sobre el funcionamiento de las redes de Docker se encuentra disponible aquí.
Ejemplo: Containerizando una hipotética aplicación en producción.
Descripción del escenario
Nuestra empresa, WidgetMaker S.A., distribuye dispositivos embebidos basados en Linux para el control de maquinaría industrial. Para desplegar el código de nuestra aplicación en estos dispositivos, se utiliza una aplicación personalizada hecha por encargo hace unos años. Esta aplicación necesita:
Ubuntu 12.04, con una serie de librerías en una versión específica para que funcione correctamente.
La aplicación flashea un paquete de firmware completo que contiene nuestra aplicación de control a través de un dispositivo conectado al puerto serie del equipo.
Necesita poder servir un panel web en el puerto 80, para controlar el proceso y mostrar información del progreso. En su momento la seguridad del programa no era una preocupación importante, lo que significa que un agente malicioso podría interferir con el proceso si tuviese acceso al puerto 80 de la máquina.
La aplicación de flasheo no funciona correctamente si se ejecutan múltiples instancias al mismo tiempo, lo que ocasiona que sólo podamos flashear un dispositivo cada vez. El procedimiento actual para acelerar este proceso es tener varias máquinas físicas con la configuración necesaria, y realizar el flasheo de forma manual en ellas.
Modificar la aplicación, aunque es técnicamente posible, no es una solución apropiada por presupuesto y por coste de oportunidad; hay fuegos más grandes que apagar primero.
Si pudiésemos integrar esta aplicación en nuestra pipeline DevOps, podríamos ahorrar tiempo y dinero:
Podemos sobrevivir a cualquier fallo imprevisto de hardware: El entorno de ejecución de la aplicación es inmutable y facílmente re-desplegable. En caso de fallo, estamos a meros minutos de operar con normalidad de nuevo.
Podemos aislar la ejecución de la aplicación, y otorgar acceso a ella utilizando controles de acceso adecuados.
Podríamos automatizar el proceso de despliegue: En lugar de operarlo manualmente, podemos iniciar el programa con el input adecuado cuando el operario enchufe el dispositivo al cable serie y notificarle cuando el proceso de flasheo esté completado.
Podemos someter los dispositivos a pruebas unitarias de funcionamiento (con Jenkins por ejemplo), y de esta forma asegurarnos de que el flasheo se realiza correctamente y la aplicación desplegada funciona.
Diseñando el contenedor.
Analizando los requisitos.
Ubuntu 12.04, con librerías y configuración específicas.
Acceso directo a /dev/ttyACM0 desde el contenedor.
Red aislada, con el puerto 80 accesible sólo desde nuestra máquina.
Acceso a anfitrión:/workspace/widgetOS/deployment-image/ para obtener la última versión de las aplicaciones y módulos para el despliegue.
Idealmente, tendríamos un repositorio del que clonar esta información y poder garantizar que todos los componentes están actualizados. Dado que queremos aprender a compartir información con el contenedor, no lo haremos de esa forma
Contruyendo la imagen base.
Docker Hub tiene disponible una imagen de Ubuntu 12.04, pero vamos a recrearla de cero. Si quisiéramos usar la imagen de Docker Hub, basta con ejecutar docker pull ubuntu:12.04 para descargarla. En este caso concreto podemos verificar que la imagen proviene directamente de Canonical, pero Docker Hub no ofrece garantías al respecto y cualquiera puede subir imágenes y fingir que son de confianza.
Dado que estamos tratanto con un escenario de producción, no se recomienda el uso de Docker Hub.
Vamos a utilizar debootstrap para instalar un sistema Ubuntu 12.04 mínimo en /tmp/precise-pangolin, y luego importaremos ese sistema a Docker para utilizarlo como imagen base.
Primero, importamos las claves GPG necesarias para autenticar los paquetes. En este caso, tenemos que instalar ubuntu-keyring e indicarle a deboostrap dónde debe buscar para encontrar las claves de una distribución de Ubuntu antigua.
Deboostrap procederá a descargar los paquetes necesarios. Tras finalizar el proceso, importamos el resultado en Docker para usarlo como base.
Llegados a este punto, podemos listar la imágenes disponibles para comprobar que nuestra imagen de Precise Pangolin está lista para ser utilizada.
Esta será nuestra imagen base de Ubuntu 12.04. Vamos a usarla como imagen padre, y construiremos sobre ella para aplicar las modificaciones requeridas por nuestra aplicación.
Aplicar modificaciones a través de un Dockerfile.
Vamos a escribir un Dockerfile que contemple los requisitos necesarios para la correcta ejecución de nuestra app, y estos serán aplicados a la imagen base de Ubuntu.
Docker construye las imágenes a base de capas. En términos de control de versiones, cada capa es un commit. Si realizas cambios en el Dockerfile sólo hay que reconstruir las líneas siguientes ya que las capas previas no han cambiado.
Dos de nuestros requisitos no pueden ser expresados en el Dockerfile: carpetas compartidas a través de puntos de montaje y acceso a dispositivos de hardware. Estos serán contemplados a la hora de lanzar el contenedor.
Para recrear este escenario, necesitarás un archivo llamado requirements.txt situado en la misma carpeta que el Dockerfile y con el siguiente contenido.
Y este es el contenido del Dockerfile, junto con comentarios explicando las diferentes directivas y como nos ayudan a cumplir los requisitos de la aplicación.
Constuyendo y ejecutando el contenedor.
Para construir el contenedor, lanzamos el proceso desde el directorio que contiene el Dockerfile. A este contenedor lo llamaremos widget-deployer.
Nota: El proceso de construcción copia el contenido del directorio actual al contenedor.
Accediendo carpetas y dispositivos del anfitrión.
Compartir una carpeta entre el anfitrión y el contenedor.
Tal y como hemos visto anteriormente, los puntos de montaje no pueden definirse dentro del Dockerfile debido a que deben ser portables. Por lo tanto, definiremos un punto de montaje lectura/escritura al ejecutar el contenedor. En este caso, queremos compartir la carpeta /workspace.
La sintáxis del parámetro –mount es la siguiente.
--mount type=bind,source=<ruta absoluta en el anfitrión>,target=<ruta absoluta destino en el contenedor>
Nota: Es posible que te encuentres la sintáxis -v o –volume en documentación antigua de Docker al hablar de almacenamiento de datos. Esta sintáxis se considera en desuso, y ha sido oficialmente reemplazada por –mount que es la que usaremos en este post.
La explicación completa oficial está disponible en la web de Docker.
Compartir un dispositivo físico con el contenedor.
Para dar acceso a un dispositivo físico desde el contenedor, lo hacemos con el parámetro –device.
--device <ruta de acceso al dispositivo>
Ejecutando el contenedor.
Combinando todo lo anterior, ejecutamos el contenedor con la siguiente línea de comando.
Una vez se está ejecutando, podemos examinar su estado y metadatos con el comando inspect.
La aplicación está lista para usarse.
Ejecutando más de una instancia de la aplicación.
Para ejecutar varias instancias de nuestra aplicación de forma paralela, necesitamos:
Un adaptador de puerto serie por cada instancia, especificado con el parámetro –device. /dev/ttyACM1 por ejemplo.
Redirigir el puerto 80 del servidor web a otro puerto diferente del anfitrión, por ejemplo al puerto 1080 de la siguiente forma:
docker run –device /dev/ttyACM1 –mount type=bind,source=/workspace,target=/workspace -p 1080:80 widget-deployer
Por ejemplo, esta es la lista de contenedores en ejecución tras lanzar 4 instancias de la aplicación.
Abrir una shell dentro de un contenedor.
Para lanzar una shell bash en un contenedor e inspeccionar su contenido, especificamos un comando concreto a la hora de lanzar el contenedor. En este caso, una shell.
No olvides que cualquier cambio se descarta al detener el contenedor, y que dichas modificacionesno se aplican a otras instancias del mismo.
Eliminando todas las imágenes, volúmenes y contenedores en ejecución.
Conclusiones.
Los contenedores son una poderosa herramienta adicional para nuestro flujo DevOps, y aunque no son una solución universal sí que ofrecen ventajas significativas ante otras alternativas para aislar aplicaciones.
En un futuro post exploraremos como usar Docker Swarm para lanzar y mantener una red de contenedores que contenga aplicaciones web, balanceadores de carga y bases de datos.