Uso avanzado de Docker

Docker nos provee de mecanismos para ejecutar nuestra aplicación en contenedores pero, además, nos permite simplificar el proceso de despliegue de nuestra aplicación en diferentes entornos mediante el uso de contrucción multi-stage.

Introducción

¿Qué es Docker?

Docker es una plataforma de virtualización a nivel de sistema operativo, es decir, no virtualiza el sistema operativo al completo por lo que su consumo de recursos es mínimo. Permite crear y empaquetar una aplicación junto con sus dependencias y librerías en un entorno aislado, controlado y con una configuración definida llamado contenedor. Este contenedor permite, a su vez, replicarse y ejecutarse de manera consistente en múltiples máquinas.

Docker permite crear un entorno aislado y predecible, ofreciendo el mismo comportamiento en cualquier máquina donde se replique. Estas características simplifican enormemente la labor de instalación, puesta en marcha y escalabilidad horizontal de un servicio o plataforma.

Docker y microservicios

Uno de los usos más demandados en la actualidad es la implementación de aplicaciones basadas en microservicios. Docker permite la implementación de microservicios haciendo uso de la convención sobre configuración, asumiendo que un microservicio será implementado en un único contenedor.

Recordemos que los microservicios son un enfoque arquitectónico y organizativo para el desarrollo de software donde el software está compuesto por pequeños servicios independientes que se comunican a través de APIs bien definidas.

Esta definición clásica y purista implica orquestar cada servicio de manera independiente, aislada y atómica que permita construir la infraestructura de nuestro negocio mediante la composición de dichos contenedores.

Si bien es un enfoque válido para la mayoría de casos, existen algunos en los que cabe un enfoque diferente.

Veamos un ejemplo:

Ejemplo: microservicio API

Supongamos que queremos implementar una API RESTful mediante PHP8 que sea escalable y eficiente. Así pues, nuestra aplicación estaría compuesta, como mínimo, por los siguientes servicios:

  • Servidor web NGINX
  • PHP8-FPM

Si siguiéramos la interpretación clásica de microservicio tendríamos, como mínimo, dos microservicios independientes cooperando entre sí para ofrecer nuestra API.

Ahora supongamos que dicho servicio está en producción y que empieza a sufrir un ataque DDoS y que, en el peor de los casos, el microservicio del servidor web deja de funcionar porque ha consumido todos los recursos disponibles. ¿Tiene sentido que el servicio PHP8-FPM esté activo y consumiendo recursos cuando el servidor web no puede servir la aplicación? Por contra, ¿qué pasaría si, por error, desplegamos una versión de código con errores en nuestra aplicación? ¿Tiene sentido que el servidor web sirva dicho contenido? La respuesta parece obvia: no, no tiene sentido.

Si analizamos esta situación desde el punto de vista de administración de sistemas tampoco parece lógico tener que dedicar recursos para administrar dos contenedores independientes a nivel de infraestructura, pero dependientes a nivel de lógica de negocio; teniendo que definir reglas de administración complejas con multitud de casuísticas. Además de aplicar despliegues condicionados para los diferentes entornos ya que la configuración aplicada para un entorno de desarrollo no tiene por qué ser la misma que para un entorno de producción.

LLegados a este punto cabría pensar que nuestra API tiene dichos servicios como dependencias de infraestrutura y de negocio, es decir, ambos servicios deberían comportarse y funcionar de manera única, como un ente aislado, atómico e independiente. O lo que es lo mismo: deberían comportarse como si ambos servicios fuesen uno sólo y, por tanto, implementarse en un mismo contenedor.

Recordemos que esta reinterpretación impacta con el convenio de tener contenedores con un único servicio.

¿Cómo crear imágenes de Docker para este tipo de servicios?

Implementar varios servicios en un único contenedor tiene algunos aspectos positivos, por ejemplo el reducir el número de contenedores de nuestra infraestuctura y, con ello, reducir la complejidad de nuestra plataforma pero, por contra, provoca que algunos contenedores tengan un mayo consumo de recursos.

Para intentar reducir al máximo este efecto negativo crearemos imágenes optimizadas sin recurrir a imágenes ya pre-definidas y generalistas que puedan incluir librerías o componentes que no necesitemos. Así pues, crear imágenes desde cero nos permite personalizar qué sistema operativo tendrán nuestros contenedores, las librerías y módulos a incorporar así como definir con mayor precisión el comportamiento de los mismos. Para ello partiremos de imágenes basadas en Alpine Linux, una distribución de sistema operativo enfocada a la seguridad siendo eficiente y liviana.

Para gestionar varios servicios dentro de un mismo contenedor existen múltiples opciones pero, por simplicidad de configuración y administración, haremos uso de Supervisord que no es más que una aplicación que permite administrar múltiples procesos de sistema con una interfaz simple e intuitiva.

Para crear las imágenes específicas para cada entorno de despliegue haremos uso del proceso de contrucción de imágenes multi-estado (multi-stage) que provee Docker. Este proceso nos permite, con un mismo fichero Dockerfile, definir cómo serán las imágenes para cada entorno mediante la herencia de configuración de estados.

Retomando nuestro ejemplo de la API veamos cómo crear imágenes para cada entorno de despliegue. Para simplificar el proceso consideraremos únicamente dos entornos:

  • Entorno de desarrollo: aquí incluiremos todas las herramientas que faciliten el desarrollo de nuestra aplicación como por ejemplo: PHPUnit, Composer, xDebug...
  • Entorno de producción: aquí desplegaremos la aplicación optimizada sin dependencias de desarrollo alguna.

Detalles

Estructura de directorios y distribución de archivos:

api                             ; Carpeta principal del proyecto
├ docker                        ; Carpeta de nuestras imágenes de proyecto
| └ images
|   └ api
|       etc                     ; Ficheros de configuración generales
|       ├ nginx
|       | └ nginx.conf          ; Config. gral. de NGINX
|       ├ php8
|       | └ php-fpm.d
|       |   └ www.conf          ; Config. gral. de PHP-FPM
|       ├ supervisor
|       | └ conf.d
|       |   ├ nginx.conf        ; Config. de NGINX dentro de Supervisor
|       |   ├ php-fpm.conf      ; Config. de PHP-FPM dentro de Supervisor
|       |   └ supervisord.conf  ; Config. de Supervisor
|       ├ Dockerfile            ; Multi-stage Dockerfile
|       └ Makefile
├ etc                           ; Ficheros de configuración específicos
| └ nginx
|   └ conf.d
|     ├ api.local.conf          ; Config. de nuestro dominio en local
|     └ api.production.conf     ; Config. de nuestro dominio en producción
├ src                           ; Código fuente de nuestra aplicación
├ docker-compose.yml            ; Fichero de composición de Docker
├ Dockerfile                    ; Dockerfile para producción
└ Makefile

Multi-stage Dockerfile

01: #------------------------------------------------------------------------------
02: # ENVIRONMENT: PRODUCTION
03: #------------------------------------------------------------------------------
04:
05: FROM alpine:latest AS base
06:
07: RUN apk --update add \
08:         #------------------------ Install dependencies, 3rd-Party apps...
09:         bash \
10:         ca-certificates \
11:         curl \
12:         gnu-libiconv \
13:         nginx \
14:         supervisor \
15:         #------------------------ Install PHP8, required modules...
16:         php8 \
17:         php8-fpm \
18:         # ...
19:         php8-zip \
20:         php8-zlib \
21:     && rm -rf /tmp/* /var/cache/apk/* \
22:     && ln -s /usr/bin/php8 /usr/bin/php \
23:     && rm /etc/nginx/conf.d/default.conf && mkdir -p /run/nginx
24:
25: ENV LD_PRELOAD /usr/lib/preloadable_libiconv.so
26:
27: # supervisor - service(s) setup
28:
29: COPY ./etc/supervisor/conf.d/supervisord.conf /etc/supervisor/conf.d/supervisord.conf
30: COPY ./etc/supervisor/conf.d/php-fpm.conf /etc/supervisor/conf.d/php-fpm.conf
31: COPY ./etc/supervisor/conf.d/nginx.conf /etc/supervisor/conf.d/nginx.conf
32:
33: # nginx - setup
34:
35: COPY ./etc/nginx/nginx.conf /etc/nginx/nginx.conf
36:
37: # php-fpm - setup
38:
39: COPY ./etc/php8/php-fpm.d/www.conf /etc/php8/php-fpm.d/www.conf
40:
41: # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
42:
43: ENTRYPOINT ["supervisord", "--configuration", "/etc/supervisor/conf.d/supervisord.conf"]
44:
45: #------------------------------------------------------------------------------
46: # ENVIRONMENT: DEVELOPMENT
47: #------------------------------------------------------------------------------
48:
49: FROM base AS development
50:
51: RUN curl -sS https://getcomposer.org/installer | php -- --install-dir=/usr/bin --filename=composer

Configuración entorno de producción

Primero crearemos una imagen base con la configuración mínima y funcional común a ambos entornos.

Linea Segmento Comentario
5 FROM alpine:latest Aquí indicamos que la imagen se va a basar en la última versión estable de Alpine Linux
5 AS base Nombre del estado que definimos para esta fase de creación.
13 nginx \ Instalamos NGINX dentro del contenedor
14 supervisor \ Instalamos SupervisorD dentro del contenedor
17 php8-fpm \ Instalamos PHP8-FPM dentro del contenedor
29 COPY ... Copiamos la configuración de SupervisorD dentro del contenedor
30 COPY ... Copiamos la configuración de SupervisorD para PHP-FPM dentro del contenedor
31 COPY ... Copiamos la configuración de SupervisorD para NGINX dentro del contenedor
35 COPY ... Copiamos la configuración por defecto del servidor NGINX
39 COPY ... Copiamos la configuración por defecto del servicio PHP8-FPM
43 ENTRYPOINT ... Indicamos al contenedor cual es el servicio que debe arrancar

Así pues, tenemos un contenedor con tres servicios: SupervisorD, NGINX y PHP8-FPM pero SupervisorD será el servicio gestionado por el contenedor Docker como servicio principal, mientras que los otros dos servicios estarán administrados por el propio SupervisorD.

Si quisiéramos tener múltiples servicios dentro de un mismo contenedor el proceso sería el mismo, incluyendo tantas configuraciones de servicios a gestionar por SupervisorD como sean necesarios.

Configuración para entorno de desarrollo

En este entorno incluiremos, además de la configuración base, todas aquellas herramientas que sean últiles y relevantes para desarrollar nuestra aplicación. Así pues, tenemos:

Linea Segmento Comentario
49 FROM base Aquí indicamos que la imágen se va a basar en la imagen base del Dockerfile, haciendo uso de la composición de imágenes.
49 AS development Nombre del estado que definimos para este punto del proceso.
51 RUN ... Copiamos Composer dentro del contenedor como dependencia de desarrollo. Para incluir más dependencias tales como xDebug, XHGui... bastaría con incluirlas en esta sección.

Construcción de imagenes

Veamos ahora cómo construir las imágenes para cada entorno:

$ docker build --target base --tag api:base .
$ docker build --target development --tag api:development .

Así pues, tendremos las siguientes imágenes creadas:

$ docker images
REPOSITORY   TAG           IMAGE ID       CREATED          SIZE
api          development   21350905d8cf   7 seconds ago    187MB
api          base          662eba85656d   47 seconds ago   185MB
alpine       latest        28f6e2705743   9 days ago       5.61MB

Como podemos observar la imagen alpine:latest ocupa únicamente 5.61MB, mientras que nuestra imagen api:base ocupa 185MB que corresponde a SupervisorD + NGINX + PHP8 y sus dependencias.

La imagen api:development difiere únicamente 2MB con respecto a la imagen api:base que corresponde a lo que ocupa Composer.

Despliegue en local

Veamos cómo desplegar nuestra imagen en nuestra máquina local y que haga uso del estado development.

docker-compose.yml
01: version: '3.7'
02:
03: services:
04:     api:
05:         container_name: api
06:         build:
07:             context: ./docker/images/api/
08:             dockerfile: Dockerfile
09:             target: development
10:         ports:
11:             - "80:80"
12:         restart: unless-stopped
13:         tty: true
14:         volumes:
15:             - ./etc/nginx/conf.d/api.local.conf:/etc/nginx/conf.d/default.conf
16:             - ./src:/code/api
17:         working_dir: /code/api/
Linea Segmento Comentario
7 context: ./docker/images/api/ Aquí indicamos que nuestro contenedor se creará desde el Dockerfile multi-stage ubicado dentro de dichas carpetas.
9 target: development Dentro de dicho Dockerfile, queremos hacer uso del estado development que representa nuestro estado para desarrollo.
15 - Montamos dinámicamente el fichero de configuración del dominio a través del cual accederemos a nuestra aplicación via HTTP
16 - Montamos dinámicamente nuestro proyecto dentro del contenedor; así podremos modificar los ficheros en nuestra máquina y que éstos se actualicen en el contenedor de manera transparente.
Arrancando el contenedor

Si arrancamos nuestro proyecto podremos ver que dicho contenedor arrancará con la imagen de desarrollo, haciendo uso de SupervisorD y éste, de NGINX y de PHP8-FPM

$ docker-compose up
Creating network "docker_default" with the default driver
Creating api ... done
Attaching to api
api    | 2021-02-22 12:40:20,381 INFO Included extra file "/etc/supervisor/conf.d/nginx.conf" during parsing
api    | 2021-02-22 12:40:20,381 INFO Included extra file "/etc/supervisor/conf.d/nginx.conf" during parsing
api    | 2021-02-22 12:40:20,381 INFO Included extra file "/etc/supervisor/conf.d/php-fpm.conf" during parsing
api    | 2021-02-22 12:40:20,381 INFO Included extra file "/etc/supervisor/conf.d/php-fpm.conf" during parsing
api    | 2021-02-22 12:40:20,381 INFO Included extra file "/etc/supervisor/conf.d/supervisord.conf" during parsing
api    | 2021-02-22 12:40:20,381 INFO Included extra file "/etc/supervisor/conf.d/supervisord.conf" during parsing
api    | 2021-02-22 12:40:20,381 INFO Set uid to user 0 succeeded
api    | 2021-02-22 12:40:20,381 INFO Set uid to user 0 succeeded
api    | 2021-02-22 12:40:20,384 INFO RPC interface 'supervisor' initialized
api    | 2021-02-22 12:40:20,384 INFO RPC interface 'supervisor' initialized
api    | 2021-02-22 12:40:20,384 CRIT Server 'unix_http_server' running without any HTTP authentication checking
api    | 2021-02-22 12:40:20,384 CRIT Server 'unix_http_server' running without any HTTP authentication checking
api    | 2021-02-22 12:40:20,384 INFO supervisord started with pid 1
api    | 2021-02-22 12:40:20,384 INFO supervisord started with pid 1
api    | 2021-02-22 12:40:21,388 INFO spawned: 'php8-fpm' with pid 9
api    | 2021-02-22 12:40:21,388 INFO spawned: 'php8-fpm' with pid 9
api    | 2021-02-22 12:40:21,389 INFO spawned: 'nginx' with pid 10
api    | 2021-02-22 12:40:21,389 INFO spawned: 'nginx' with pid 10
api    | 2021/02/22 12:40:21 [notice] 10#10: using the "epoll" event method
api    | 2021/02/22 12:40:21 [notice] 10#10: nginx/1.18.0
api    | 2021/02/22 12:40:21 [notice] 10#10: OS: Linux 5.8.0-7642-generic
api    | 2021/02/22 12:40:21 [notice] 10#10: getrlimit(RLIMIT_NOFILE): 1048576:1048576
api    | 2021/02/22 12:40:21 [notice] 10#10: start worker processes
api    | 2021-02-22 12:40:21,395 INFO success: nginx entered RUNNING state, process has stayed up for > than 0 seconds (startsecs)
api    | 2021-02-22 12:40:21,395 INFO success: nginx entered RUNNING state, process has stayed up for > than 0 seconds (startsecs)
api    | 2021/02/22 12:40:21 [notice] 10#10: start worker process 11
api    | 2021/02/22 12:40:21 [notice] 10#10: start worker process 12
api    | 2021/02/22 12:40:21 [notice] 10#10: start worker process 13
api    | 2021/02/22 12:40:21 [notice] 10#10: start worker process 14
api    | 2021/02/22 12:40:21 [notice] 10#10: start worker process 15
api    | 2021/02/22 12:40:21 [notice] 10#10: start worker process 16
api    | 2021/02/22 12:40:21 [notice] 10#10: start worker process 17
api    | 2021/02/22 12:40:21 [notice] 10#10: start worker process 18
api    | 2021/02/22 12:40:21 [notice] 10#10: start worker process 19
api    | 2021/02/22 12:40:21 [notice] 10#10: start worker process 20
api    | 2021/02/22 12:40:21 [notice] 10#10: start worker process 21
api    | 2021/02/22 12:40:21 [notice] 10#10: start worker process 22
api    | 2021-02-22 12:40:22,415 INFO success: php8-fpm entered RUNNING state, process has stayed up for > than 1 seconds (startsecs)
api    | 2021-02-22 12:40:22,415 INFO success: php8-fpm entered RUNNING state, process has stayed up for > than 1 seconds (startsecs)

Despliegue en producción

El despliegue en producción difiere del proceso del despliegue en entorno local ya que para producción no montaremos nuestro proyecto de aplicación como un volumen dinámico sino que lo empaquetaremos y enviaremos a nuestro repositorio de imágenes Docker (véase Docker Hub, Google Cloud Registry, etc.)

Dockerfile para producción
1: FROM api:base
2:
3: # nginx domain
4:
5: COPY ./apps/api/etc/nginx/conf.d/api.conf /etc/nginx/conf.d/api.conf
6:
7: # source code & dependencies
8:
9: COPY ./apps/api/src /code/api
Linea Segmento Comentario
1 FROM api:base Aquí indicamos que nuestro contenedor se basará en la imagen api:base
5 COPY ... Copiamos el fichero de configuración de nuestro dominio de producción dentro de la configuración de NGINX
9 COPY ... Copiamos el código fuente de nuestra aplicación dentro del contenedor, es decir, creamos un snapshot de nuestra aplicación dentro de la imagen.
docker build & push

Ahora sólo queda crear la imagen base según nuestro Dockerfile específico para despliegue en producción y hacer un push de la misma en nuestro repositorio de imágenes Docker:

$ docker build -t api:base ../.. -f Dockerfile
$ docker push api:xxxx

Y ya teniendo nuestra imagen registrada con una copia de nuestra aplicación podremos actualizar nuestro entorno de producción sin problemas.

Descarga

Puedes descargar los ficheros de ejemplo desde aquí.

Versión del documento

[^v1.0]: Última Modificación: 02/03/2021