c
Conceptos básicos de orquestación
Ahora tenemos una comprensión básica de Docker y podemos utilizarlo para fácilmente configurar, por ejemplo, una base de datos para nuestra aplicación. Ahora, cambiemos nuestro enfoque hacia el frontend.
React en un contenedor
A continuación, vamos a crear y a poner en un contenedor a una aplicación React. Comenzamos con los pasos habituales:
$ npm create vite@latest hello-front -- --template react
$ cd hello-front
$ npm install
El siguiente paso es convertir el código JavaScript y CSS en archivos estáticos listos para producción, Vite ya tiene un build como un script npm, así que usemos eso:
$ npm run build
...
Creating an optimized production build...
...
The build folder is ready to be deployed.
...
¡Excelente! El paso final es encontrar una forma de usar un servidor para servir los archivos estáticos. Como sabrás, podríamos usar nuestro express.static con el servidor Express para servir los archivos estáticos. Te lo dejo como ejercicio para que lo hagas en casa. En su lugar, seguiremos adelante y comenzaremos a escribir nuestro Dockerfile:
FROM node:20
WORKDIR /usr/src/app
COPY . .
RUN npm ci
RUN npm run build
Eso parece correcto. Hagamos el build y veamos si estamos en el camino correcto. Nuestro objetivo es que la compilación tenga éxito sin errores. Luego usaremos bash para verificar dentro del contenedor para ver si los archivos están allí.
$ docker build . -t hello-front
=> [4/5] RUN npm ci
=> [5/5] RUN npm run
...
=> => naming to docker.io/library/hello-front
$ docker run -it hello-front bash
root@98fa9483ee85:/usr/src/app# ls
Dockerfile README.md dist index.html node_modules package-lock.json package.json public src vite.config.js
root@98fa9483ee85:/usr/src/app# ls dist
assets index.html vite.svg
Una opción válida para servir archivos estáticos ahora que ya tenemos Node en el contenedor es serve. Intentemos instalar serve y servir los archivos estáticos mientras estamos dentro del contenedor.
root@98fa9483ee85:/usr/src/app# npm install -g serve
added 89 packages in 2s
root@98fa9483ee85:/usr/src/app# serve dist
┌────────────────────────────────────────┐
│ │
│ Serving! │
│ │
│ - Local: http://localhost:3000 │
│ - Network: http://172.17.0.2:3000 │
│ │
└────────────────────────────────────────┘
¡Excelente! Hagamos ctrl+c para salir y luego los agregaremos a nuestro Dockerfile.
La instalación de serve se convierte en un RUN en el Dockerfile. De esta manera, la dependencia se instala durante el proceso de compilación. El comando para servir el directorio dist se convertirá en el comando para iniciar el contenedor:
FROM node:20
WORKDIR /usr/src/app
COPY . .
RUN npm ci
RUN npm run build
RUN npm install -g serve
CMD ["serve", "dist"]
Nuestro CMD ahora incluye corchetes y, como resultado, usamos la forma exec de CMD. En realidad, hay tres formas diferentes para CMD, de las cuales se prefiere la forma exec. Lee la documentación para obtener más información.
Cuando ahora construimos la imagen con docker build . -t hello-front y la ejecutamos con docker run -p 5000:3000 hello-front, la aplicación estará disponible en http://localhost:5000.
Usando múltiples etapas
Si bien serve es una opción válida, podemos hacerlo mejor. Un buen objetivo es crear imágenes de Docker para que no contengan nada irrelevante. Con un número mínimo de dependencias, es menos probable que las imágenes se rompan o se vuelvan vulnerables con el tiempo.
Los builds de varias etapas están diseñadas para dividir el proceso de compilación en muchas etapas separadas, donde es posible limitar qué partes de los archivos de imagen se mueven entre las etapas. Eso abre posibilidades para limitar el tamaño de la imagen, ya que no todos los subproductos del build son necesarios para la imagen resultante. Las imágenes más pequeñas son más rápidas de cargar y descargar y ayudan a reducir la cantidad de vulnerabilidades que puede tener tu software.
Con builds de varias etapas, se puede usar una solución probada y robusta como Nginx para servir archivos estáticos sin muchos dolores de cabeza. La página de Nginx de Docker Hub nos brinda la información necesaria para abrir los puertos y "Alojamiento de contenido estático simple".
Usemos el Dockerfile anterior pero cambiemos FROM para incluir el nombre de la etapa:
# El primer FROM ahora es una etapa llamada build-stage
FROM node:20 AS build-stage
WORKDIR /usr/src/app
COPY . .
RUN npm ci
RUN npm run build
# Esta es una nueva etapa, todo lo anterior a esta linea ha desaparecido, excepto por los archivos que queremos COPIAR
FROM nginx:1.25-alpine
# COPIA el directorio dist de build-stage a /usr/share/nginx/html
# El destino fue encontrado en la pagina de Docker hub
COPY /usr/src/app/dist /usr/share/nginx/html
Hemos declarado también otra etapa, de donde solo se mueven los archivos relevantes de la primera etapa (el directorio dist, que contiene el contenido estático).
Después de que la construimos de nuevo, la imagen está lista para servir el contenido estático. El puerto predeterminado será 80 para Nginx, por lo que algo como -p 8000:80 funcionará, por lo que los parámetros del comando RUN deben cambiarse un poco.
Los builds de varias etapas también incluyen algunas optimizaciones internas que pueden afectar tus builds. Como ejemplo, los builds de varias etapas se saltan las etapas que no se utilizan. Si deseamos usar una etapa para reemplazar una parte de un pipeline de build, como pruebas o notificaciones, debemos pasar algunos datos a las siguientes etapas. En algunos casos esto está justificado: copia el código de la etapa de prueba a la etapa de build. Esto garantiza que estás haciendo el build con el código probado.
Desarrollo en contenedores
Movamos todo el desarrollo de la aplicación de tareas pendientes a un contenedor. Hay algunas razones por las que querrías hacer eso:
- Para mantener el entorno similar entre el desarrollo y la producción y así evitar errores que aparecen solo en el entorno de producción.
- Evitar diferencias entre los desarrolladores y sus entornos personales que generen dificultades en el desarrollo de aplicaciones.
- Para ayudar a los nuevos miembros del equipo a incorporarse, haciéndoles instalar el tiempo de ejecución del contenedor, sin necesidad de nada más.
Todas estas son buenas razones. La contrapartida es que podemos encontrarnos con algún comportamiento no convencional cuando no estamos ejecutando las aplicaciones como estamos acostumbrados. Tendremos que hacer al menos dos cosas para mover la aplicación a un contenedor:
- Iniciar la aplicación en modo de desarrollo
- Acceder a los archivos con VSCode
Comencemos con el frontend. Dado que el Dockerfile será significativamente diferente al Dockerfile de producción, creemos uno nuevo llamado dev.Dockerfile.
Nota usaremos el nombre dev.Dockerfile para las configuraciones de desarrollo y Dockerfile para lo demás.
Iniciar Vite en modo de desarrollo debería ser fácil. Comencemos con lo siguiente:
FROM node:20
WORKDIR /usr/src/app
COPY . .
# Cambia npm ci a npm install ya que vamos a estar en modo de desarrollo
RUN npm install
# npm run dev es el comando para iniciar la aplicación en modo de desarrollo
CMD ["npm", "run", "dev", "--", "--host"]
Nota los parámetros adicionales -- --host en el CMD. Esos son necesarios para exponer el servidor de desarrollo y hacerlo visible fuera de la red Docker. Por defecto, el servidor de desarrollo solo se expone a localhost, y a pesar de que accedemos al frontend todavía usando la dirección de localhost, en realidad está conectado a la red Docker.
Durante el build, se usará el indicador -f para indicar qué archivo usar; de lo contrario, el predeterminado sería Dockerfile, por lo que el siguiente comando hará el build de la imagen:
docker build -f ./dev.Dockerfile -t hello-front-dev .
Vite se servirá en el puerto 5173, por lo que puedes probar que funciona al ejecutar un contenedor con ese puerto publicado.
La segunda tarea, acceder a los archivos con VSCode, aún no se ha realizado. Hay al menos dos formas de hacer esto:
- Extensión de Visual Studio Code Remote - Containers
- Volúmenes, lo mismo que usamos para conservar los datos con la base de datos.
Repasemos esto último, ya que también funcionará con otros editores. Hagamos una ejecución de prueba con el indicador -v y, si funciona, moveremos la configuración a un archivo docker-compose. Para usar -v, necesitaremos decirle el directorio actual. El comando pwd debería generar la ruta al directorio actual. Intentemos esto con echo $(pwd) en la línea de comandos. Podemos usarlo con -v a la izquierda para asignar el directorio actual al interior del contenedor o podemos usar la ruta completa del directorio.
$ docker run -p 5173:5173 -v "$(pwd):/usr/src/app/" hello-front-dev
> todo-vite@0.0.0 dev
> vite --host
VITE v5.1.6 ready in 130 ms
Ahora podemos editar el archivo src/App.jsx, ¡y los cambios deberían cargarse de forma instantánea en el navegador!
Si tienes una Mac con procesador M1/M2, el comando anterior falla. En el mensaje de error, notamos lo siguiente:
Error: Cannot find module @rollup/rollup-linux-arm64-gnu
El problema es la librería rollup que tiene su propia versión para todos los sistemas operativos y arquitecturas de procesador. Debido al mapeo de volúmenes, el contenedor ahora está usando los node_modules del directorio de la máquina anfitriona donde está instalado @rollup/rollup-darwin-arm64 (la versión adecuada para Mac M1/M2), por lo que no se encuentra la versión correcta de la librería para el contenedor @rollup/rollup-linux-arm64-gnu.
Hay varias formas de solucionar el problema. Usemos quizás la más simple. Inicia el contenedor con bash como el comando, y ejecuta npm install dentro del contenedor:
$ docker run -it -v "$(pwd):/usr/src/app/" front-dev bash
root@b83e9040b91d:/usr/src/app# npm install
¡Ahora ambas versiones de la librería rollup están instaladas y el contenedor funciona!
A continuación, movamos la configuración al archivo docker-compose.dev.yml. Este archivo también debe estar en la raíz del proyecto:
services:
app:
image: hello-front-dev
build:
context: . # El contexto tomará este directorio como el "contexto del build"
dockerfile: dev.Dockerfile # Esto simplemente le indicará qué dockerfile leer
volumes:
- ./:/usr/src/app # La ruta puede ser relativa, por lo que ./ es suficiente para decir "la misma ubicación que el docker-compose.yml"
ports:
- 5173:5173
container_name: hello-front-dev # Esto nombrará el contenedor como hello-front-dev
Con esta configuración, docker compose -f docker-compose.dev.yml up puede ejecutar la aplicación en modo de desarrollo. ¡Ni siquiera necesitas tener Node instalado para trabajar en ella!
Nota usaremos el nombre docker-compose.dev.yml para los archivos de composición del entorno de desarrollo, y el nombre predeterminado docker-compose.yml en otros casos.
Instalar nuevas dependencias es un dolor de cabeza para una configuración de desarrollo como esta. Una de las mejores opciones es instalar la nueva dependencia dentro del contenedor. Entonces, en lugar de hacer, p.ej. npm install axios, debes hacerlo en el contenedor en ejecución, p.ej. docker exec hello-front-dev npm install axios, o agrégalo a package.json y ejecuta docker build nuevamente.
Comunicación entre contenedores en una red Docker
La herramienta Docker Compose configura una red entre los contenedores e incluye un DNS para conectar fácilmente dos contenedores. Agreguemos un nuevo servicio a Docker Compose y veremos cómo funcionan la red y el DNS.
Busybox es un pequeño ejecutable con varias herramientas que podrías necesitar. Se llama "La navaja suiza de Embedded Linux", y definitivamente podemos usarlo para nuestro beneficio.
Busybox puede ayudarnos a depurar nuestras configuraciones. Entonces, si te pierdes en los últimos ejercicios de esta sección, puedes usar Busybox para averiguar qué funciona y qué no. Usémoslo para explorar lo que se acaba de decir. Que los contenedores están dentro de una red y que puedes conectarte fácilmente entre ellos. Busybox se puede agregar a la mezcla cambiando docker-compose.dev.yml a:
services:
app:
image: hello-front-dev
build:
context: .
dockerfile: dev.Dockerfile
volumes:
- ./:/usr/src/app
ports:
- 5173:5173
container_name: hello-front-dev
debug-helper: image: busybox
El contenedor Busybox no tendrá ningún proceso ejecutándose dentro por lo que no podemos usar exec allí. Por eso, la salida de docker compose up también se verá así:
$ docker compose -f docker-compose.dev.yml up 0.0s
Attaching to front-dev, debug-helper-1
debug-helper-1 exited with code 0
front-dev |
front-dev | > todo-vite@0.0.0 dev
front-dev | > vite --host
front-dev |
front-dev |
front-dev | VITE v5.2.2 ready in 153 ms
Esto es de esperar ya que es solo una caja de herramientas. Usémoslo para enviar una solicitud a hello-front-dev y ver cómo funciona el DNS. Mientras se ejecuta hello-front-dev, podemos realizar la solicitud con wget ya que es una herramienta incluida en Busybox para enviar una solicitud desde el asistente de depuración a hello-front-dev.
Con Docker Compose podemos usar docker compose run SERVICE COMMAND para ejecutar un servicio con un comando específico. El comando wget requiere la bandera -O con - para enviar la respuesta a stdout:
$ docker compose -f docker-compose.dev.yml run debug-helper wget -O - http://app:5173
Connecting to app:5173 (192.168.240.3:5173)
writing to stdout
<!doctype html>
<html lang="en">
<head>
<script type="module">
...
La URL es la parte interesante aquí. Simplemente dijimos que se conecte al puerto 5173 del servicio app. app es el nombre del servicio especificado en el archivo docker-compose.dev.yml:
services:
app: image: hello-front-dev
build:
context: .
dockerfile: dev.Dockerfile
volumes:
- ./:/usr/src/app
ports:
- 5173:5173 container_name: hello-front-dev
El puerto utilizado es el puerto desde el cual la aplicación está disponible en ese contenedor. No es necesario publicar el puerto para que otros servicios de la misma red puedan conectarse a él. Los "puertos" en el archivo docker-compose son solo para acceso externo.
Cambiemos la configuración del puerto en docker-compose.dev.yml para enfatizar esto:
services:
app:
image: hello-front-dev
build:
context: .
dockerfile: dev.Dockerfile
volumes:
- ./:/usr/src/app
ports:
- 3210:5173 container_name: hello-front-dev
debug-helper:
image: busybox
Con docker compose up la aplicación está disponible en http://localhost:3210 en la máquina host, pero el comando
docker compose -f docker-compose.dev.yml run debug-helper wget -O - http://app:5173
funciona ya que el puerto sigue siendo 5173 dentro de la red docker.
La imagen de abajo ilustra lo que sucede. El comando docker compose run le pide a debug-helper que envíe la solicitud dentro de la red. Mientras que el navegador en la máquina host envía la solicitud desde fuera de la red.
Ahora que sabes lo fácil que es encontrar otros servicios en docker-compose.yml y no tenemos nada que depurar, podemos eliminar debug-helper y revertir los puertos a 5173:5173 en nuestro archivo compose.
Comunicaciones entre contenedores en un entorno más ambicioso
A continuación, agregaremos un proxy inverso a nuestro docker-compose.dev.yml. Según wikipedia
Un proxy inverso es un tipo de servidor proxy que recupera recursos en nombre de un cliente desde uno o más servidores. Estos recursos luego se devuelven al cliente, apareciendo como si se originaran en el propio servidor proxy inverso.
Entonces, en nuestro caso, el proxy inverso será el único punto de entrada a nuestra aplicación, y el objetivo final será establecer tanto el frontend de React como el backend de Express detrás del proxy inverso.
Hay múltiples opciones diferentes para una implementación de proxy inverso, como Traefik, Caddy, Nginx y Apache (ordenadas por versión inicial de más reciente a más antigua).
Nuestra elección es Nginx.
Ahora pongamos hello-frontend detrás del proxy inverso.
Crea un archivo nginx.dev.conf en la raíz del proyecto y usa la siguiente plantilla como punto de partida. Tendremos que hacer ediciones menores para que nuestra aplicación se ejecute:
# events es requerido, pero los valores por defecto están bien
events { }
# Un servidor http, escuchando en el puerto 80
http {
server {
listen 80;
# Requests comenzando con root (/) son manejados
location / {
# Las siguientes 3 lineas son requeridas para que el hot loading funcione (websocket).
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
# Requests son dirigidos a http://localhost:5173
proxy_pass http://localhost:5173;
}
}
}
Nota estamos usando la misma convención para nombres de archivos también para Nginx, nginx.dev.conf para configuraciones de desarrollo, y y el nombre por defecto nginx.conf para producción.
A continuación, crea un servicio Nginx en el archivo docker-compose.dev.yml. Agrega un volumen como se indica en la página de Docker Hub donde el lado derecho es :/etc/nginx/nginx.conf:ro, el ro final declara que el volumen será solo de lectura (read only):
services:
app:
# ...
nginx:
image: nginx:1.20.1
volumes:
- ./nginx.dev.conf:/etc/nginx/nginx.conf:ro
ports:
- 8080:80
container_name: reverse-proxy
depends_on:
- app # esperar a que el contenedor frontend arranque
con eso agregado, podemos ejecutar docker compose -f docker-compose.dev.yml up y ver qué sucede.
$ docker container ls
CONTAINER ID IMAGE COMMAND PORTS NAMES
a02ae58f3e8d nginx:1.20.1 ... 0.0.0.0:8080->80/tcp reverse-proxy
5ee0284566b4 hello-front-dev ... 0.0.0.0:5173->5173/tcp hello-front-dev
Conectarse a http://localhost:8080 conducirá a una página que se ve familiar con estado 502.
Esto se debe a que dirigir las solicitudes a http://localhost:5173 no conduce a ninguna parte, ya que el contenedor Nginx no tiene una aplicación ejecutándose en el puerto 5173. Por definición, localhost se refiere a la computadora actual utilizada para acceder a él. Con los contenedores, localhost es único para cada contenedor, lo que lleva al contenedor en sí.
Probemos esto ingresando al contenedor Nginx y usando curl para enviar una solicitud a la aplicación. En nuestro caso de uso, curl es similar a wget, pero no necesitará ninguna bandera.
$ docker exec -it reverse-proxy bash
root@374f9e62bfa8:\# curl http://localhost:80
<html>
<head><title>502 Bad Gateway</title></head>
...
Para ayudarnos, Docker Compose configuró una red cuando ejecutamos docker compose up. También agregó todos los contenedores en docker-compose.dev.yml a la red. Un DNS se asegura de que podamos encontrar el otro contenedor. Cada uno de los contenedores recibe dos nombres: el nombre del servicio y el nombre del contenedor.
Como estamos dentro del contenedor, ¡también podemos probar el DNS! Modifiquemos el nombre del servicio (app) en el puerto 5173
root@374f9e62bfa8:\# curl http://app:5173
<!doctype html>
<html lang="en">
<head>
<script type="module" src="/@vite/client"></script>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Vite + React</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
¡Eso es! Reemplacemos la dirección proxy_pass en nginx.conf con esa.
Una cosa más: agregamos una opción depends_on a la configuración que garantiza que el contenedor nginx no se inicie antes que el contenedor frontend app:
services:
app:
# ...
nginx:
image: nginx:1.20.1
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
ports:
- 8080:80
container_name: reverse-proxy
depends_on: - app
Si no hacemos cumplir el orden de inicio con depends_on, existe el riesgo de que Nginx falle en el inicio, ya que intenta recuperar todos los nombres de DNS a los que se hace referencia en el archivo de configuración:
http {
server {
listen 80;
location / {
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_pass http://app:5173; }
}
}
Ten en cuenta que depends_on no garantiza que el servicio en el contenedor dependiente esté listo para la acción, solo asegura que el contenedor se haya iniciado (y la entrada correspondiente se agregue al DNS). Si un servicio necesita esperar a que otro servicio esté listo antes del inicio, se deben usar otras soluciones.
Herramientas para la producción
Los contenedores son herramientas divertidas para usar en el desarrollo, pero el mejor caso de uso para ellos es en el entorno de producción. Hay muchas herramientas más potentes que Docker Compose para ejecutar contenedores en producción.
Herramientas de orquestación de contenedores pesados como Kubernetes nos permiten administrar contenedores en un nivel completamente nuevo. Estas herramientas ocultan las máquinas físicas y nos permiten a nosotros, los desarrolladores, preocuparnos menos por la infraestructura.
Si estás interesado en obtener más información sobre los contenedores, accede al curso DevOps con Docker y podrás encontrar más información sobre Kubernetes en el curso avanzado de 5 créditos DevOps con Kubernetes curso. ¡Ahora deberías tener las habilidades para completar ambos!