Inek

Jugando con Dockerfiles y Drogon

¡Tu mensaje fue recibido! Una vez que sea aprobado, estará visible para los demás visitantes.

Cerrar

24 Apr 2022

Si tomaste contacto con Drogon y te estás preguntando cómo desplegar tu aplicación con contenedores, este post es para vos. Y para mi.

En un post anterior realizamos nuestra primera aplicación en Drogon. Ahora vamos a ver cómo desplegarla con contenedores, ya sea para usarla en nuestra propia PC o Kubernetes, Heroku, ECS o algún entorno en el que podamos sacar partido de Docker.

Este es el repositorio de la aplicación invoicer que usé como ejemplo. De momento no tiene funcionalidades. Apenas dispone de un endpoint /v1/invoices que retorna un json, y si lo invocamos sin path, o con /index.html, retorna un archivo html.

También les dejo el link al pull request con el código de este post.

Enfoque 1: usar la imagen oficial

El camino más obvio quizás es combinar la imagen oficial de Drogon con nuestro código, de modo que podamos compilar y ejecutar la aplicación usando la misma imagen.

En la raíz del repositorio de la aplicación voy a crear una carpeta llamada “docker” y dentro de esa carpeta, crearé otra carpeta con el nombre with-official-image. En esta última carpeta vamos a crear un archivo, llamado Dockerfile, con el siguiente contenido:

FROM drogonframework/drogon:latest

COPY . /code
WORKDIR /code

RUN rm -rf build \
  && mkdir build \
  && cd build \
  && cmake .. \
  && make

EXPOSE 80

ENTRYPOINT ["/code/docker/with-official-image/entrypoint.sh"]

La primer línea de este Dockerfile indica que usaremos como base la imagen oficial de Drogon.

La línea COPY . /code copia nuestro código a la carpeta /code del nuevo contenedor.

Podrías estar preguntándote por qué efectuamos el COPY desde el directorio actual, indicado por el punto, siendo que estamos parados en la carpeta docker/with-official-image. La razón de usar el punto, es que este Dockerfile, así como los otros que voy a añadir en este mismo post, estén pensados para ser ejecutados desde el directorio raíz del repositorio.

Con la línea WORKDIR /code indicamos el directorio donde estaremos situados en el contenedor.

A continuación viene el comando RUN, que contiene los comandos para eliminar y volver a crear la carpeta build, para asegurarnos que esté vacía al ejecutar los comandos cmake .. && make.

La línea EXPOSE 80 indica que ese será el puerto que va a exponer el contenedor, que se corresponde con el puerto por default de la aplicación que configuramos en otro post.

Finalmente, definimos el ENTRYPOINT, que es el comando que se ejecutará al iniciar el contenedor. El entrypoint de esta imagen es un pequeño archivo en bash que ejecuta la aplicación desde la carpeta build:

#! /bin/bash

cd build
./invoicer

Es importante que este archivo tenga permisos de ejecución.

Docker compose

Para validar nuestro Dockerfile en local, vamos a crear el siguiente docker-compose.yml en la misma capreta donde tenemos el Dockerfile:

version: "3.9"
services:
  app:
    build:
      context: ../..
      dockerfile: "${PWD}/Dockerfile"
    ports:
      - "8081:80"

Vale la pena notar el valor de context: Cuando se ejecute build con el Dockerfile, estaremos posicionados dos directorios arriba. Esto es, en el raíz.

Ejecutamos nuestro docker-compose con el comando docker-compose up -d --build. El flag --build es para reconstruir las imágenes antes de iniciar los contenedores.

Si todo ha ido bien, el comando docker-compose ps debería mostrarnos nuestro contenedor, escuchando el puerto que hemos definido en docker-compose.yml. En mi caso es el 8081:

$ docker-compose ps
          Name                         Command               State                  Ports                
---------------------------------------------------------------------------------------------------------
with-official-image_app_1   /code/docker/with-official ...   Up      0.0.0.0:8081->80/tcp,:::8081->80/tcp

Si ponemos en nuestro navegador la url localhost:8081 deberíamos ver el html de nuestra aplicación y la interacción con la api rest.

Enfoque 2: crear nuestra imagen a medida

En la sección anterior vimos cómo usar la imagen oficial de Drogon para construir y ejecutar un contenedor con nuestra aplicación. Si ejecutas docker image ls, verás información sobre la imagen:

docker image ls --filter "reference=with*" 
REPOSITORY                      TAG       IMAGE ID       CREATED          SIZE
with-official-image_app         latest    c202be423ea4   31 minutes ago   1.19GB

Allí podemos observar que la imagen resultante pesa 1.19Gb. Es un tamaño considerable, teniendo en cuenta que nuestro ejecutable pesa unos 8 mb. Para una práctica de Docker y Drogon, 1.19Gb puede no representar problema alguno. Sin embargo, si nuestra imagen va a ser almacenada en algún sistema pago, será conveniente optimizar el peso de la imagen resultante.

En esta sección vamos a crear un Dockerfile que resulte en una imagen de menor tamaño, haciendo uso de los builds multietapa (en inglés, multi-stage) de Docker.

Un build multietapa consiste en un Dockerfile con más de una línea FROM que dan inicio a las etapas en el multietapa. La etapa final es la imagen definitiva que se usará para crear los contenedores.

Esta será la estructura de nuestro Dockerfile:

# Etapa "common" - Instalamos las librerías comunes a ambas etapas en la etapa que denominaremos "common":

FROM ubuntu:20.04 as common

RUN apt-get update -yqq && \
        apt-get install -y libssl-dev libjsoncpp-dev uuid-dev zlib1g-dev libc-ares-dev

# Etapa "builder" - Configuramos la imagen encargada de compilar la aplicación:

FROM common as builder

ENV TZ=UTC
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

RUN apt-get install -yqq --no-install-recommends software-properties-common \
    curl wget cmake make pkg-config locales git gcc-10 g++-10 \
    openssl \
    && rm -rf /var/lib/apt/lists/* \
    && locale-gen en_US.UTF-8

ENV LANG=en_US.UTF-8 \
    LANGUAGE=en_US:en \
    LC_ALL=en_US.UTF-8 \
    CC=gcc-10 \
    CXX=g++-10 \
    AR=gcc-ar-10 \
    RANLIB=gcc-ranlib-10 \
    IROOT=/install

ENV DROGON_ROOT="$IROOT/drogon"

ADD https://api.github.com/repos/an-tao/drogon/git/refs/heads/master $IROOT/version.json
RUN git clone https://github.com/an-tao/drogon $DROGON_ROOT

WORKDIR $DROGON_ROOT

RUN ./build.sh

COPY . /code
WORKDIR /code

RUN rm -rf build \
  && mkdir build \
  && cd build \
  && cmake .. \
  && make

# Etapa "runtime" - Creamos la imagen que va a contener la carpeta build generada en la etapa "builder":

FROM common as runtime

WORKDIR /app
COPY --from=builder /code/build /app

EXPOSE 80

ENTRYPOINT ["./invoicer"]

La etapa “builder” está basada en la propia imagen oficial, quitando librerías que nuestra aplicación no necesita y añadiendo las líneas que copian nuestro código a la carpeta /code y efectúan la compilación.

Si usamos este Dockerfile con el mismo docker-compose.yml de la sección anterior, arrancaremos la aplicación con nuestra nueva imagen.

Si ahora vemos la imagen en el listado docker image ls, veremos que esta nueva versión pesará bastante menos que la anterior:

$ docker image ls --filter "reference=with*" 
REPOSITORY                      TAG       IMAGE ID       CREATED          SIZE
with-custom-runtime-image_app   latest    05dabea8e824   25 minutes ago   153MB
with-official-image_app         latest    c202be423ea4   31 minutes ago   1.19GB

El gran punto flojo de este enfoque es que el proceso de crear la imagen final con nuestra aplicación compilada tarda bastante más que cuando usamos la imagen oficial. Esta situación podría mitigarse con más de un Dockerfile, generando una imagen por cada etapa y persistiéndolas en algún repositorio para reutilizarlas en nuestro Dockerfile correspondiente al runtime, como veremos a continuación.

Enfoque 3: mantener imágenes separadas para las etapas common, build y runtime

En la sección anterior vimos cómo reducir el tamaño de la imagen final, partiendo nuestro Dockerfile en 3 etapas, aprovechando los builds multietapa de Docker. También observamos que el tiempo de compilación siguiendo este enfoque es bastante mayor al enfoque en donde utilizamos la imagen oficial, debido a que construimos más imágenes.

En este enfoque, exploramos una opción similar al multistage, pero persistiendo cada etapa en algún repositorio de imágenes con el que podamos contar. Para este blog, utilizaré el repositorio de imágenes del propio docker, aprovechando que las imágenes serán open source.

Vamos a iniciar sesión en DockerHub y creamos dos repositorios:

El próximo paso será construir las imágenes para cada repositorio.

Vamos a crear un archivo llamado Dockerfile-common con el siguiente contenido:

FROM ubuntu:20.04 as common

RUN apt-get update -yqq && \
        apt-get install -y libssl-dev libjsoncpp-dev uuid-dev zlib1g-dev libc-ares-dev

Para poder subir una imagen al repositorio de imágenes, deberemos iniciar sesión, luego construir la imagen y efectuar un push:

docker login --username=imefisto -p <contraseña>
docker build -t imefisto/invoicer_common -f Dockerfile-common .
docker push imefisto/invoicer_common

Reemplaza imefisto con tu usuario.

A continuación, creamos el archivo Dockerfile para nuestro builder, al que llamaremos Dockerfile-builder:

FROM invoicer_common

ENV TZ=UTC
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone

RUN apt-get install -yqq --no-install-recommends software-properties-common \
    curl wget cmake make pkg-config locales git gcc-10 g++-10 \
    openssl \
    && rm -rf /var/lib/apt/lists/* \
    && locale-gen en_US.UTF-8

ENV LANG=en_US.UTF-8 \
    LANGUAGE=en_US:en \
    LC_ALL=en_US.UTF-8 \
    CC=gcc-10 \
    CXX=g++-10 \
    AR=gcc-ar-10 \
    RANLIB=gcc-ranlib-10 \
    IROOT=/install

ENV DROGON_ROOT="$IROOT/drogon"

ADD https://api.github.com/repos/an-tao/drogon/git/refs/heads/master $IROOT/version.json
RUN git clone https://github.com/an-tao/drogon $DROGON_ROOT

WORKDIR $DROGON_ROOT

RUN ./build.sh

Construimos la imagen y la subimos a nuestro repositorio:

docker build -t imefisto/invoicer_builder -f Dockerfile-builder .
docker push imefisto/invoicer_builder

Ahora estamos listos para usar las imágenes, previamente almacenadas en nuestra cuenta de docker, en nuestro último Dockerfile:

FROM imefisto/invoicer_builder as builder

COPY . /code
WORKDIR /code

RUN rm -rf build \
  && mkdir build \
  && cd build \
  && cmake .. \
  && make

FROM imefisto/invoicer_common as runtime

WORKDIR /app
COPY --from=builder /code/build /app

EXPOSE 80

ENTRYPOINT ["./invoicer"]

Reemplaza imefisto con tu username para usar tus propias imágenes. De este modo, cada vez que hagamos un cambio en nuestro código, en tanto dichos cambios no implique modificar las librerías citadas en la imagen invoicer_common, podremos ahorrar tiempo y a la vez tener una imagen relativamente liviana, en comparación al peso de la imagen conseguido en el primer enfoque. Si en algún caso necesitamos actualiar alguna librería, bastará con que volvamos a generar la invoicer_common y, a continuación, invoicer_builder y volvamos a subirlas al repositorio de imágenes.

Validamos nuestro último Dockerfile creando el docker-compose.yml como hicimos en los otros enfoques:

version: "3.9"
services:
  app:
    build:
      context: ../..
      dockerfile: "${PWD}/Dockerfile"
    ports:
      - "8081:80"

Tras ejecutar docker-compose up -d, deberemos ser capaces de cargar la url http://localhost:8081 y todo debería ir bien.

Recapitulando

En este post hemos explorado 3 enfoques para construir una imagen para nuestra aplicación basada en Drogon. En el primer enfoque, utilizamos la imagen oficial, en cuyo caso, la aplicación se construía muy rápido, pero la imagen resultante era algo pesada. En el enfoque siguiente, aprovechamos el build multistage de Docker y conseguimos que la imagen final fuese bastante más ligera que la anterior.

Finalmente, en el último enfoque probamos alojar las etapas common y builder como imágenes separadas en un repositorio online, para utilizarlas en nuestro Dockerfile final.

Cada enfoque tiene sus pros y sus contras y dependiendo la situación y el entorno de ejecución, nos resultará más conveniente uno u otro.

Referencias

https://ml-cfd.com/openfoam/docker/2020/12/29/minimizing-docker-images.html

https://www.jmoisio.eu/en/blog/2020/06/01/building-cpp-containers-using-docker-and-cmake/

¿Qué te pareció el post?

No hay comentarios.