Tutorial: Django + Docker

Introducción

En esta guia vamos a continuar trabajando sobre Docker, implementaremos un Dockerfile para proyectos desarrollados en Python con Django. En próximas entregas avanzaremos con el armado de Integración Contínua y Despliegue Contínuo de estas aplicaciones. Se puede ver el desarrollo final en el siguiente repositorio.

Recordar ver este post sobre Docker

Empecemos!

Primeros Pasos

Para comenzar a preparar nuestro Dockerfile, debemos tener en cuenta los siguientes puntos sobre Django:

  • ¿Cuál es la versión de Python y Django?
    En nuestro caso utilizaremos las versiones de Python 3.7 y Django 3.1.5

  • ¿Cómo es la estructura de carpetas de Django?
    Existen múltiples maneras de estructurar los proyectos en Django, esta guia explica muy detalladamente la estructura que seguiremos en este tutorial.
    Vamos a destacar las siguientes carpetas:
  • root/
    • app_name/
      • settings/
      • templates/
      • static/
    • apps
    • requirements
  • root: Carpeta que contiene a toda la aplicacion.
  • app_name: carpeta principal de la aplicacion que contiene las configuraciones base, urls, entre otras cosas.
  • settings: carpeta con configuraciones, divididas en base.py, local.py y production.py, que utilizará la imágen con su respectivo ambiente.
  • templates: carpeta que contiene los htmls/templates de Django.
  • static: carpeta que contiene los archivos compilados(css y js) usados en el frontend, también tiene los documentos importados a Django.
  • apps: carpeta que contiene las apps de django, a diferencia de tener las apps sueltas en la carpeta root.
  • requirements: carpeta con las dependencias de django entre otras, divididas en base.txt, local.txt y production.txt.

  • ¿Qué dependencias del sistema son indispensables?
  • ¿Cuáles son dispensables? Por ejemplo, sólo para desarrollo
  • ¿Qué Base de Datos se utilizará? MariaDB, PostgreSQL, SQLite

En este caso, al utilizar Python 3.7 podemos usar la Imágen Base de Docker python:3.7-alpine(Ubuntu), la cuál ya contiene los requerimientos base del sistema. Sólo faltaría agregar las que son necesarias para trabajar con Django: por ejemplo, python3-dev, build-base y gettext.
Una dependencia innecesaria del sistema puede ser: bash, esta agrega una consola con más funcionalidades dentro del contenedor resultante.
Por otro lado, las dependencias a instalar dentro de la imágen van a variar según el tipo de imágen, por ejemplo, para una imágen productiva se utilizarán las dependencias del archivo production.txt y para dependencias locales local.txt.
Por último, en este caso se usará MariaDB como base de datos por lo cuál se requiere esta dependencia del sistema. También se podría utilizar postgres, etc.

  • Tipos de Imágenes a crear: Desarrollo y Productiva.
    Desde nuestro Dockerfile vamos a construir dos tipos de imágenes, una para Desarrollo local y otra lista para un entorno Productivo. Las diferencias principales entre ellas son, la instalación de dependencias y el inicio de la aplicación. Y se crearán pasando una variable de ambiente al momento de generar la imágen: ENVIRONMENT.

Armado de Dockerfile

Una vez resuelto esas incógnitas, comenzamos a preparar el Dockerfile.
Para esto vamos a utilizar una técnica muy útil en el mundo de Docker: Multi Stage Builds, la cual nos permite reducir en casi un 80% el tamaño final de nuestra aplicación Dockerizada, esto puede no ser un problema en algunos entornos, pero creemos que es una buena práctica reducir el tamaño de nuestras imágenes siempre que sea posible.

¿Qué es «Multi Stage Builds»?

Docker está preparado para empaquetar todos los archivos completos creados en el Dockerfile, pero en algunos casos los archivos copiados o clonados no son necesariamente útiles para la aplicación final, por ejemplo, al instalar dependencias de una aplicación JS y finalmente compilarlas en archivos estáticos html, css y js. En este caso, podemos ver que las dependencias instaladas pueden desaparecer una vez finalizado ese proceso.

Para esto, Docker permite utilizar múltiples comandos FROM dentro de un mismo Dockerfile lo que provoca que en cada conjunto de instrucciones de un FROM, se genere una nueva Imágen intermedia, dejando al último FROM como la Imágen final a exponer.

Cada FROM es una Imágen Docker autocontenida capáz de ser utilizada en cualquier otro lado. ¿Cómo se puede utilizar desde otro FROM? Gracias al comando COPY, ya que permite copiar archivos o carpetas generadas en otras Imágenes intermedias.

Esto nos permite copiar entre diferentes Imágenes los archivos necesarios para el resultado final.

Continuando

En nuestro caso, vamos a aprovechar esta técnica para empaquetar las dependencias de la aplicación utilizando wheel y descartando el resto de dependencias innecesarias instaladas en la imágen base generada.

Este sería nuestro Dockerfile resultante:

FROM python:3.7-alpine as base
ENV PYTHONUNBUFFERED 1
ARG ENVIRONMENT=local
ENV ENVIRONMENT=$ENVIRONMENT

ENV HOME=/home/app
ENV SRC_HOME=$HOME/app
ENV APP_HOME=$HOME/web

FROM base as builder

WORKDIR $SRC_HOME

COPY requirements/base.txt requirements/$ENVIRONMENT.txt ./

RUN set -ex && \
   apk add --no-cache --virtual .build-deps \
   python3-dev build-base mariadb-dev

RUN pip wheel --no-cache-dir --no-deps --wheel-dir $SRC_HOME/wheels -r $ENVIRONMENT.txt

FROM base

WORKDIR $APP_HOME

RUN mkdir -p $APP_HOME/static && mkdir -p $APP_HOME/uploads
ADD https://github.com/Cambalab/docker-compose-wait/releases/download/2.6.0/wait /wait
RUN chmod +x /wait

RUN apk update && apk add bash gettext mariadb-dev
COPY --from=builder $SRC_HOME/wheels /wheels
COPY --from=builder $SRC_HOME/$ENVIRONMENT.txt .
RUN pip install --no-cache /wheels/*

COPY . $APP_HOME

ENTRYPOINT ["/docker-entrypoint.sh"]

Explicación Por partes

Sección Base o Stage Base

FROM python:3.7-alpine as base
ENV PYTHONUNBUFFERED 1
ARG ENVIRONMENT=local
ENV ENVIRONMENT=$ENVIRONMENT

ENV HOME=/home/app
ENV SRC_HOME=$HOME/app
ENV APP_HOME=$HOME/web
...

La parte inicial del Dockerfile define la imágen base de Docker a utilizar, algunas variables de ambiente y una variable argumento del mismo.
Para destacar:

  • FROM python:3.7-alpine as base, como hablamos anteriormente, en este caso trataremos de reducir al máximo el tamaño de nuestra Imágen resultante por lo cuál usaremos una versión alpine(ubuntu) de Python 3.7.
  • ARG ENVIRONMENT=local y ENV ENVIRONMENT=$ENVIRONMENT, definimos nuestra variable para construir una imágen para desarrollo local o de production. Algo interesante de esto, es que definir una ARG permite utilizar la variable dentro del scope de la Imágen Base pero no en los diferentes «stages», para resolver esto creamos una variable de ambiente, utilizando el argumento.

Sección Builder o Stage Builder

...
FROM base as builder

WORKDIR $SRC_HOME

COPY requirements/base.txt requirements/$ENVIRONMENT.txt ./

RUN set -ex && \
   apk add --no-cache --virtual .build-deps \
   python3-dev build-base mariadb-dev

RUN pip wheel --no-cache-dir --no-deps --wheel-dir $SRC_HOME/wheels -r $ENVIRONMENT.txt
...

Esta sección define el «armado» de una Imágen intermedia con la instalación de dependencias específicas del proyecto tanto de Python como del Sistema Operativo, y el «empaquetado» final utilizando wheels.
Para destacar:

  • FROM base as builder, creamos un stage intermedio llamado builder basado en base, recordar que cada FROM crea y cachea una Imágen intermedia con el contenido heredado de la Imágen basada y el contenido generado dentro de ese stage.
  • COPY requirements/base.txt requirements/$ENVIRONMENT.txt ./, la diferencia principal entre una Imágen local de una productiva son las dependencias, por lo tanto en esta linea instalamos las dependencias definidas en el archivo local.txt o production.txt.
  • RUN set -ex && \ ..., un punto en contra o a favor de las Imágenes alpine es que le «faltan» muchas dependencias, incluso dentro de python para que funcione un proyecto Django por ejemplo. Por esta razón, se debe investigar que dependencias del sistema es necesario instalar cada vez que agregamos una nueva dependencia de Python a la aplicación, en nuestro caso, agregamos la dependencia de mariadb-dev necesaria para «empaquetar» la app ya que utilizamos ese motor de base de datos.
  • RUN pip wheel --no-cache-dir --no-deps --wheel-dir $SRC_HOME/wheels -r $ENVIRONMENT.txt, finalmente «empaquetamos» nuestra aplicación utilizando wheel

Sección Final o Stage Final

...
FROM base

WORKDIR $APP_HOME

RUN mkdir -p $APP_HOME/static && mkdir -p $APP_HOME/uploads
ADD https://github.com/Cambalab/docker-compose-wait/releases/download/2.6.0/wait /wait
RUN chmod +x /wait

RUN apk update && apk add bash gettext mariadb-dev
COPY --from=builder $SRC_HOME/wheels /wheels
COPY --from=builder $SRC_HOME/$ENVIRONMENT.txt .
RUN pip install --no-cache /wheels/*

COPY . $APP_HOME

ENTRYPOINT ["/docker-entrypoint.sh"]

Para destacar:

  • FROM base, creamos un último stage de nuestra Imágen Docker, al ser el último paso, este será el resultante del Dockerfile.
  • ADD https://github.com/Cambalab/docker-compose-wait/releases/download/2.6.0/wait /wait y RUN chmod +x /wait, en esta linea agregamos una herramienta específica utilizada en el despliegue final de la Imágen Docker producida, particularmente para ser usada junto a Docker Compose. Este programa permite «esperar» o detener el inicio de nuestra aplicación hasta que un servicio, la base de datos por ejemplo, se encuentre activa y funcional.
  • RUN apk update && apk add bash gettext mariadb-dev, instalamos dependencias del sistema que dispondremos dentro del contenedor.
  • COPY --from=builder $SRC_HOME/wheels /wheels, COPY --from=builder $SRC_HOME/$ENVIRONMENT.txt . y RUN pip install --no-cache /wheels/*, finalmente utilizamos el empaqueado generado en stages anteriores para instalarlo usando wheel.
  • ENTRYPOINT ["/docker-entrypoint.sh"], recuerden que entrypoint es el script que se ejecutara cada vez que se inicia el contenedor resultante de esta Imágen.

Conclusiones

Para finalizar, esta guía explica la creación de un Dockerfile para aplicaciones Django con características muy particulares. Por ejemplo:
En nuestro caso, queremos reducir el tamaño de la imágen resultante, ¿De cuánto es la mejora con respecto a una imágen sin reducir el tamaño?, ¿Se puede medir?
También, estamos implementando dos tipos de imágenes, locales y productivas, ¿Qué cambios se necesitan para implementar otro tipo de imágenes, por ejemplo de staging, testing, etc?
Además, esta Imágen se encuentra atada al tipo de despliegue (por la herramienta wait), ¿Es posible cambiar esta Imágen para que pueda ser usada en diferentes despliegues, sea docker-compose, docker swarm o kubernetes?
Con esto concluimos este tutorial de implementar un Dockerfile para aplicaciones desarrolladas en Django, recuerden ver el resultado final en este repositorio.

Es todo!

Referencias

  • https://djangobook.com/mdj2-django-structure/
  • https://realpython.com/django-development-with-docker-compose-and-machine/
  • https://realpython.com/python-wheels/

Deja una respuesta