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:
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
-
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")
- 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
- 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.
Segundo ejemplo
Utilizando C++
-
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;
}
- 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
- 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
-
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.
-
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!
- 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
- 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 **
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.
-
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
- Para verificar el funcionamiento, debemos abrir un browser y dirigirnos a la siguiente URL: http://localhost:8100/docs
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”
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.
-
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"]
- 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
-
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.