Dockerfile y su aplicabilidad

Author

Gustavo Fresno, Julián Blanco, Matías Semelman, Gerónimo Giovenale y Marcelo Albarracín

El Dockerfile es la unidad fundamental del ecosistema Docker. Describe los pasos para crear una imagen de Docker. El flujo de información sigue este esquema central:

image (109).png

El Dockerfile define los pasos a seguir para crear una nueva imagen. Hay que entender que se empieza siempre con una imagen base existente. La nueva imagen nace de la imagen base. Además, hay ciertos cambios puntuales.

¿Cómo funciona un Dockerfile?

En el fondo, el Dockerfile es un archivo de texto totalmente normal. El Dockerfile contiene un conjunto de instrucciones, cada una en una línea distinta. Para crear una Docker Image, las instrucciones se ejecutan una tras otra. Quizás te suene este esquema de la ejecución de un script por lotes. Durante la ejecución, se añaden paso por paso más capas a la imagen.

¿Cómo se crea una imagen?

Una imagen Docker se crea ejecutando las instrucciones de un Dockerfile. Este paso se conoce como el proceso build y empieza con la ejecución del comando “docker build”. El contexto de construcción es un concepto crucial: define a qué archivos y directorios tiene acceso el proceso de construcción, donde un directorio local hace las veces de fuente. El contenido del directorio fuente se transfiere al Docker Daemon al accionar “docker build”. Las instrucciones contenidas en el Dockerfile reciben acceso a los archivos y directorios contenidos en el contexto de construcción.

A veces no queremos iniciar todos los archivos del directorio fuente local en el contexto build. Para estos casos existe el archivo .dockerignore, que sirve para excluir archivos y directorios del contexto de construcción y cuyo nombre se basa en el archivo .gitignore de Git. El punto antes del nombre del archivo indica que se trata de un archivo oculto.

Instrucciones Dockerfile

FROM

Cada Dockerfile debe partir de una base, la cual definimos utilizando la palabra clave FROM seguida del nombre de la imagen. Esta base recibirá las subsecuentes instrucciones que vamos a declarar en el archivo.

    FROM ingeniahub/hello-devify

Se puede especificar el tag de la imagen, pero si no se hace, Docker buscará automáticamente aquella versión con el tag latest.

Es posible tener más de un FROM en un Dockerfile. Esta característica permite hacer un multi-stage build.

COPY vs ADD

ADD y COPY se utilizan para añadir directorios y archivos a la imagen Docker.

    # Copiamos el proyecto entero
    COPY . .

    # Copiamos el contenido de pepe en rosa
    COPY /pepe /rosa

Es recomendable utilizar COPY por sobre ADD, debido a que la última añade características extra que la hacen más impredecible. COPY ofrece una solución más directa para trasladar archivos y directorios a la imagen.

ENV

ENV se utiliza para definir variables de entorno. Se pueden definir variables disponibles para el contenedor. Es decir que al construir la imagen y levantar un contenedor vas a encontrar la variable disponible y con el valor que le asignaste en el Dockerfile.

Por otro lado, se puede especificar variables de entorno para ser utilizadas en instrucciones siguientes del Dockerfile.

RUN

RUN ejecutará comandos que especifiques. RUN tiene dos formas:

  • RUN <comando> (shell)

  • RUN ["ejecutable", "param1", "param2"] (exec)

    RUN npm install
    RUN npm run build
    

VOLUME

La instrucción VOLUME le dice a Docker que el contenido que almacenes en ese directorio específico debe guardarse en el host y no en el contenedor. Esto implica que el contenido guardado allí persistirá incluso si el contenedor es destruido.

USER

USER especifica el usuario con el que se correrán las instrucciones subsecuentes.

WORKDIR

WORKDIR define el directorio en el que deseamos situarnos, útil para instrucciones como CMD, ENTRYPOINT, COPY y ADD. Si el directorio no existe, Docker lo crea automáticamente.

    WORKDIR /app

EXPOSE

EXPOSE sirve para exponer los puertos en los que escuchará el contenedor que levante la imagen.

CMD Y ENTRYPOINT

CMD es la instrucción que especifica qué componente será ejecutado por la imagen con los argumentos en la siguiente forma CMD [“ejecutable”, “param1”, “param2”…]. Sólo se puede especificar un CMD por Dockerfile.

Especificar un ENTRYPOINT define el ejecutable principal de la imagen. En este caso, lo que se especifique en el CMD se añade al ENTRYPOINT como parámetro.

ENTRYPOINT ["git"]


CMD ["--help"]

Listado de ejemplos

Primer ejemplo

Utilizando Python

  1. Para el ejemplo crearemos 2 archivos muy simples, uno de ellos es el dockerfile y el otro es el código Python

    DockerFile

Esta Dockerfile usa una imagen base de Python 3.7 y luego copia todos los archivos de la aplicación en el contenedor. Finalmente, ejecuta la aplicación con el comando "python hello.py".

```
FROM python:3.7-slim

# Copiamos el proyecto
COPY . .

# Ejecutamos el codigo
CMD ["python", "hello.py"]
```

hello.py

print("Hola Devify!\r\n")
  1. Una vez creados ambos archivos procedemos a la creación de la imagen con el siguiente comando docker (ver en el artículo ¿Por qué utilizar Docker?).
devify@dev:~/devify-docker-ej1-python$ docker build . -t python_ej

Sending build context to Docker daemon  65.54kB

Step 1/3 : FROM python:3.7-slim

3.7-slim: Pulling from library/python

025c56f98b67: Pull complete

778656c04542: Pull complete

ccfacd89fdc8: Pull complete

a31d9f4391ba: Pull complete

786f718cded7: Pull complete

Digest: sha256:17e795bf6c32fb5c9906cf151f8034b81f45abd879335749410d8e204e650d8d

Status: Downloaded newer image for python:3.7-slim

---> e3ed369fb876

Step 2/3 : COPY . .

---> 4d1c75ef4336

Step 3/3 : CMD ["python", "hello.py"]

---> Running in 3889b1584bc0

Removing intermediate container 3889b1584bc0

---> 53c792ff6179

Successfully built 53c792ff6179

Successfully tagged python_ej:latest
  1. Una vez que termina el proceso del build procedemos a correr el contenedor con el siguiente comando docker
devify@dev:~/Ejemplos/devify-docker-ej1-python$ docker run python_ej

Hola Devify!

Comparando tamaño de las imagenes Docker (Python)

Si querés inspeccionar vos mismo las imágenes de docker y compararlas, prueba esto.

    docker pull python:3.8

    docker pull python:3.8.3

    docker pull python:3.8.3-slim

    docker pull python:3.8.3-alpine

    docker images

Vas a ver que hay grandes diferencias entre las imágenes default y las versiones slim y alpine.

image (110).png

cuadro docker.png

Segundo ejemplo

Utilizando C++

  1. Para el ejemplo crearemos 2 archivos muy simples, uno de ellos es el dockerfile y el otro es el código C++

    DockerFile

    Esta Dockerfile usa una imagen base de C++ 4.9 y luego copia todos los archivos de la aplicación en el contenedor. Finalmente, ejecuta la aplicación con el comando "g++ hello.c -o hello.e".

    FROM gcc:4.9
    
    # Copiamos el proyecto
    COPY . .
    
    # Compilamos el codigo
    RUN g++ hello.c -o hello.e
    
    # Ejecutamos el codigo
    CMD ["./hello.e"]
    

    hello.c

   #include <stdio.h>

   int main()
   {
      printf("Hola Devify!\r\n");

      return 0;
   }
  1. Una vez creados ambos archivos procedemos a la creación de la imagen con el siguiente comando docker (ver en el artículo: ¿Por qué utilizar Docker?)
devify@dev:~/Ejemplos/devify-docker-ej1$ docker build . -t cpp_ej

Sending build context to Docker daemon   68.1kB

Step 1/4 : FROM gcc:4.9

---> 1b3de68a7ff8

Step 2/4 : COPY . .

---> 0a01153ae1d6

Step 3/4 : RUN g++ hello.c -o hello.e

---> Running in 3ab6b9511f8f

Removing intermediate container 3ab6b9511f8f

---> b90492eb7aa3

Step 4/4 : CMD ["./hello.e"]

---> Running in c53c82bec32f

Removing intermediate container c53c82bec32f

---> 7a9f935e2c17

Successfully built 7a9f935e2c17

Successfully tagged cpp_ej:latest

  1. Una vez que termina el proceso del build procedemos a correr el contenedor
devify@dev:~/Ejemplos/devify-docker-ej1$ docker run cpp_ej

Hola Devify!

Ejemplo con volúmenes con Java

El ejemplo a continuación mostrará una simple implementación de una API REST, implementada con el framework Spring Boot sobre Java y el uso de volúmenes en Docker.

GitLab Link: https://gitlab.com/thecodingpeople/techdoc/devify-docker-volume

  1. Para persistir datos en un entorno Dockerizado, debemos recurrir a los volúmenes. En pocas palabras, un volumen es un espacio de almacenamiento que está separado del resto de los archivos del contenedor. Si detenemos o eliminamos ese contenedor, el volumen seguirá existiendo, y podremos continuar trabajando con esos datos si decidimos levantarlo nuevamente.

    Existen varias formas de crear un volumen. Una de ellas es crearlo cuando corremos una imagen.

      docker run -d \
      
      -v devify-volume-name:/app \
      
      ingeniahub/devify-ej-vol
    

    La sintaxis para declarar un volumen es:

    -v origen:destino
    

Siendo destino la ruta dentro del contenedor cuyos elementos queremos persistir. Origen, por otro lado, puede asociarse a dos cosas. Bien puede ser una ruta de nuestro sistema de archivos local, permitiendo que al momento de levantar el contenedor todos los archivos que estén allí se copien a la dirección que especifiquemos dentro del contenedor, o bien puede ser el nombre de un volumen ya creado.

  1. En este ejemplo, vamos a correr una imagen Docker de una aplicación REST sencilla hecha en java, donde pasaremos en el body de la request un jugador. Ese jugador se irá añadiendo a una lista, y la respuesta de la solicitud será la lista completa de jugadores.

    docker run -dp 8080:8080 \
    
    –name ingeniahub/devify-ej-vol \
    
    docker-ej-volumen
    

    A medida que vamos agregando jugadores, esta lista se va alargando, con todo lo que fuimos agregando antes. Pero, ¿qué pasa si quitamos el contenedor y lo iniciamos nuevamente?

    ¡Perdimos todo lo que habíamos cargado!

    1. La solución para esto es levantar el contenedor con un volumen que apunte hacia donde estemos persistiendo los datos. En este caso, es un simple archivo de texto.
    docker run -dp 8080:8080 \
    –name ingeniahub/devify-ej-vol \
    -v devify-vol:/app \
    docker-ej-volumen
    

    En este caso, devify-vol es el volumen que contendrá la data y /app es el directorio del contenedor que persistiremos.

    De esta forma, por más que borremos y reiniciemos el contenedor, nuestros datos seguirán allí siempre y cuando el volumen también lo esté.

    Dockerfile

    FROM amazoncorretto:17.0.1-alpine3.15
    
    WORKDIR /app
    
    COPY /pom.xml /pom.xml
    COPY target/*.jar /app.jar
    
    EXPOSE 8080
    
    ENTRYPOINT ["java","-jar","/app.jar"]
    

Ejemplo API con python

El siguiente ejemplo mostrará una simple implementación de una API REST, implementada con la librería “fastapi” para python.

GitLab Link: https://gitlab.com/thecodingpeople/techdoc/devify-docker-api

  1. El ejemplo cuenta con 3 archivos importantes:
  • Poc_api.py : donde se encontrara el codigo base para implementar una api en python

  • Dockerfile : Archivo dockerfile

  • Requirements.txt : Archivo donde se listan todas las librerías que hay que instalar en la imagen docker.

    poc_api.py

    from fastapi import FastAPI
    import uvicorn
    
    app = FastAPI()
    
    @app.post( "/hello")
    async def module_action( Nombre:str):
       print("API POST Param:" + str(Nombre))
       return "Hola " + str(Nombre)
    
    uvicorn.run(app, host="0.0.0.0", port=8100)
    

    En el siguiente código podemos observar que debemos correr un simple servidor (unicorn), para que podamos hacer llamado al API por medio de fastapi. Ahora bien para poder hacer el llamado vamos a necesitar una URL:

    **http://localhost:8100 **

my computer docker.png

La URL esta compuesta por la ip y el puerto, Aqui es donde tendremos el inconveniente que si colocamos este codigo dentro de una unidad docker no tendremos forma de acceder al puerto de la imagen. Para ello de alguna forma tendremos que exponer este puerto hacia fuera.

  1. La forma que utiliza docker para exponer los puertos es con el siguiente comando que debemos agregar en el dockerfile:

    EXPOSE 8100
    

    Dockerfile

    # Start from python
    FROM debian:11.0-slim
    
    # Create a working directory
    WORKDIR /app
    
    RUN apt-get update
    RUN apt-get -y upgrade
    
    #Instalamos Python
    RUN apt install -y python3-pip
    
    COPY requirements.txt requirements.txt
    RUN pip install -r requirements.txt
    
    # Exponemos el puerto 8100
    EXPOSE 8100
    
    # Copiamos todos los archivos a la imagen docker
    COPY . .
    CMD ["python3", "poc_api.py"]
    

    requirements.txt

    fastapi==0.68.0
    uvicorn
  1. Para verificar el funcionamiento, debemos abrir un browser y dirigirnos a la siguiente URL: http://localhost:8100/docs

image (112).png

FastApi provee una interfaz web para verificar el correcto funcionamiento del API.

Cliqueamos en el api y luego en “Try it out”, ahi cargaremos el campo nombre que se pasara por query param para que el api devuelva en el body “Hola NOMBRE”

image (113).png

Ejemplo Multi-stage con python

Para el ejemplo crearemos un app muy simple con fastApi donde nos devuelve un item dado en la url y una query si es q se la pasamos.

GitLab Link: https://gitlab.com/thecodingpeople/techdoc/devify-docker-multistage.

  1. La instalación de un compilador en una parte del archivo Docker hace que la imagen resultante sea mucho más grande. Si queremos imágenes más pequeñas, la mejor solución suele ser un multi-stage. Vamos a ver cómo es esto con un ejemplo

    También generamos un requirements.txt donde están todas las librerías necesarias para el correcto funcionamiento de la app y un Dockerfile.

    Dockerfile

    En este dockerfile usamos python:3.9-slim para reducir el tamaño total lo más que podamos. Esto lo dividimos en dos etapas la primera la llamamos etapa de construcción donde usamos la imagen antes dicha como compile-image donde instalamos todas las dependencias necesarias para luego implementarla. En esta etapa copiamos el archivo requirements.txt e instalamos las librerías necesarias para la app. Luego en la etapa de implementación hacemos uso de todo lo instalado previamente en la etapa de construcción, copiamos los archivos de la app, exponemos al puerto 80 para poder usarlo.

    FROM python:3.9-slim AS compile-image
    
    RUN apt-get update
    RUN apt-get install -y
    
    COPY requirements.txt .
    RUN pip install --user -r requirements.txt
    
    FROM python:3.9-slim AS build-image
    COPY --from=compile-image /root/.local /root/.local
    
    COPY /app /app
    
    EXPOSE 80
    CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "80"]
    
    
    1. Una vez creado la app y el Dockerfile procedemos a la creación de la imagen con el siguiente comando docker
    docker build -t my-app .
    => [internal] load build definition from Dockerfile                                                                        0.0s 
    => => transferring dockerfile: 32B                                                                                         0.0s 
    => [internal] load .dockerignore                                                                                           0.0s 
    => => transferring context: 2B                                                                                             0.0s 
    => [internal] load metadata for docker.io/library/python:3.9-slim                                                          0.8s 
    => [internal] load build context                                                                                           0.1s 
    => => transferring context: 123B                                                                                           0.0s 
    => [compile-image 1/5] FROM docker.io/library/python:3.9-slim@sha256:9e0b4391fc41bc35c16caef4740736b6b349f6626fd14eba3279  0.0s 
    => CACHED [compile-image 2/5] RUN apt-get update                                                                           0.0s 
    => CACHED [compile-image 3/5] RUN apt-get install -y                                                                       0.0s 
    => CACHED [compile-image 4/5] COPY requirements.txt .                                                                      0.0s 
    => CACHED [compile-image 5/5] RUN pip install --user -r requirements.txt                                                   0.0s 
    => CACHED [build-image 2/3] COPY --from=compile-image /root/.local /root/.local                                            0.0s 
    => CACHED [build-image 3/3] COPY /app /app                                                                                 0.0s 
    => exporting to image                                                                                                      0.0s 
    => => exporting layers                                                                                                     0.0s 
    => => writing image
    sha256:2a3e02fd83f81e263ad71067a75fe2ba7427ddf511f5a21a90081680b69fa6d1                                0.0s 
    => => naming to docker.io/library/my-app    
    
  2. Una vez que termina el proceso del build procedemos a correr el contenedor

    docker run -d --name mycontainer -p 80:80 my-app
    
    be97c69c1d1d4f9be1a48318d000e605579edc0364b6e85459d428df2d372452
    

    Podemos ver q este todo funcionando desde la URL

    http://127.0.0.1/items/5?q=somequery

    Y obtendremos:

    {"item_id":5,"q":"somequery"}
    

También podemos ir a http://127.0.0.1/docs donde podemos ver la documentación de la API de fastApi en swagger.