Saltar al contenido

c

Conceptos básicos de orquestación

React in container

Vamos a crear y contenerizar una aplicación React a continuación. Elijamos npm como administrador de paquetes aunque create-react-app tenga como valor predeterminado yarn.

$ npx create-react-app hello-front --use-npm
  ...

  Happy hacking!

create-react-app ya instaló todas las dependencias para nosotros, por lo que no necesitamos ejecutar npm install aquí.

El siguiente paso es convertir el código JavaScript y CSS en archivos estáticos listos para producción, create-react-app ya tiene 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:16

WORKDIR /usr/src/app

COPY . .

RUN npm ci

RUN npm run build

Eso parece correcto. Construyámoslo 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
  [+] Building 172.4s (10/10) FINISHED 

$ docker run -it hello-front bash

root@98fa9483ee85:/usr/src/app# ls
  Dockerfile  README.md  build  node_modules  package-lock.json  package.json  public  src

root@98fa9483ee85:/usr/src/app# ls build/
  asset-manifest.json  favicon.ico  index.html  logo192.png  logo512.png  manifest.json  robots.txt  static

Una opción válida para servir archivos estáticos ahora que ya tenemos Node en el contenedor es serve. Intentemos instalar el servicio y servir los archivos estáticos mientras estamos dentro del contenedor.

root@98fa9483ee85:/usr/src/app# npm install -g serve

  added 88 packages, and audited 89 packages in 6s

root@98fa9483ee85:/usr/src/app# serve build

   ┌───────────────────────────────────┐
   │                                   │
   │   Serving!                        │
   │                                   │
   │   Local:  http://localhost:5000   │
   │                                   │
   └───────────────────────────────────┘

¡Excelente! Hagamos ctrl+c y salgamos y luego agréguelos a nuestro Dockerfile.

La instalación de serve se convierte en RUN en el Dockerfile. De esta manera, la dependencia se instala durante el proceso de compilación. El comando para servir el directorio de compilación se convertirá en el comando para iniciar el contenedor:

FROM node:16

WORKDIR /usr/src/app

COPY . .

RUN npm ci

RUN npm run build

RUN npm install -g serve
CMD ["serve", "build"]

Nuestro CMD ahora incluye corchetes y, como resultado, usamos el llamado exec form de CMD. En realidad, hay tres formas diferentes para el CMD de las cuales se prefiere la forma exec. Lea 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.

Compilaciones 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 de la construcción 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 su software.

Con compilaciones de varias etapas, se puede usar una solución probada y verdadera 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:

# The first FROM is now a stage called build-stage
FROM node:16 AS build-stage
WORKDIR /usr/src/app

COPY . .

RUN npm ci

RUN npm run build

# This is a new stage, everything before this is gone, except the files we want to COPY
FROM nginx:1.20-alpine
# COPY the directory build from build-stage to /usr/share/nginx/html
# The target location here was found from the docker hub page
COPY --from=build-stage /usr/src/app/build /usr/share/nginx/html

Hemos declarado también otra etapa donde solo se mueven los archivos relevantes de la primera etapa (el directorio build, 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 de ejecución deben cambiarse un poco.

Las compilaciones de varias etapas también incluyen algunas optimizaciones internas que pueden afectar sus compilaciones. Como ejemplo, las compilaciones de varias etapas se saltan las etapas que no se utilizan. Si deseamos usar una etapa para reemplazar una parte de una canalización de compilación, como pruebas o notificaciones, debemos pasar algunos datos a las siguientes etapas. En algunos casos esto está justificado: copie el código de la etapa de prueba a la etapa de construcción. Esto garantiza que está compilando 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 para 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 a las que estamos acostumbrados. Tendremos que hacer al menos dos cosas para mover la aplicación a un contenedor:

  • Inicie la aplicación en modo de desarrollo
  • Accede a los archivos con VSCode

Comencemos con la interfaz. Dado que el Dockerfile será significativamente diferente al Dockerfile de producción, creemos uno nuevo llamado dev.Dockerfile.

Iniciar la aplicación create-react-app en modo de desarrollo debería ser fácil. Comencemos con lo siguiente:

FROM node:16

WORKDIR /usr/src/app

COPY . .

# Change npm ci to npm install since we are going to be in development mode
RUN npm install

# npm start is the command to start the application in development mode
CMD ["npm", "start"]

Durante la compilación, se usará el indicador -f para indicar qué archivo usar; de lo contrario, sería Dockerfile predeterminado, por lo que docker build -f ./dev.Dockerfile -t hello-front-dev . compilará la imagen. La aplicación create-react se servirá en el puerto 3000, por lo que puede probar que funciona ejecutando 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:

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. Intente esto con echo $(pwd) en su línea de comando. Podemos usarlo con -v a la izquierda para asignar el directorio actual al interior del contenedor o puede usar la ruta completa del directorio.

$ docker run -p 3000:3000 -v "$(pwd):/usr/src/app/" hello-front-dev

  Compiled successfully!

  You can now view hello-front in the browser.

Ahora podemos editar el archivo src/App.js, ¡y los cambios deben cargarse de forma instantanea en el navegador!

A continuación, movamos la configuración a docker-compose.yml. Ese archivo también debe estar en la raíz del proyecto:

services:
  app:
    image: hello-front-dev
    build:
      context: . # The context will pick this directory as the "build context"
      dockerfile: dev.Dockerfile # This will simply tell which dockerfile to read
    volumes:
      - ./:/usr/src/app # The path can be relative, so ./ is enough to say "the same location as the docker-compose.yml"
    ports:
      - 3000:3000
    container_name: hello-front-dev # This will name the container hello-front-dev

Con esta configuración, docker-compose up puede ejecutar la aplicación en modo de desarrollo. ¡Ni siquiera necesita Node instalado para desarrollarlo!

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. npm install axios, debe hacerlo en el contenedor en ejecución, p. docker exec hello-front-dev npm instale axios, o agréguelo a package.json y ejecute 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 puede 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 se pierde en los ejercicios posteriores de esta sección, debe 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 puede conectarse fácilmente entre ellos. Busybox se puede agregar a la mezcla cambiando docker-compose.yml a:

services:
  app:
    image: hello-front-dev
    build:
      context: .
      dockerfile: dev.Dockerfile
    volumes:
      - ./:/usr/src/app
    ports:
      - 3000:3000
    container_name: hello-front-dev
  debug-helper:    image: busybox

El contenedor Busybox no tendrá ningún proceso ejecutándose dentro para que podamos ejecutar allí. Por eso, la salida de docker-compose up también se verá así:

$ docker-compose up
  Pulling debug-helper (busybox:)...
  latest: Pulling from library/busybox
  8ec32b265e94: Pull complete
  Digest: sha256:b37dd066f59a4961024cf4bed74cae5e68ac26b48807292bd12198afa3ecb778
  Status: Downloaded newer image for busybox:latest
  Starting hello-front-dev          ... done
  Creating react-app_debug-helper_1 ... done
  Attaching to react-app_debug-helper_1, hello-front-dev
  react-app_debug-helper_1 exited with code 0
  
  hello-front-dev | 
  hello-front-dev | > react-app@0.1.0 start
  hello-front-dev | > react-scripts start

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 hola-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 generar la respuesta a la salida estándar:

$ docker-compose run debug-helper wget -O - http://app:3000

  Creating react-app_debug-helper_run ... done
  Connecting to hello-front-dev:3000 (172.26.0.2:3000)
  writing to stdout
  <!DOCTYPE html>
  <html lang="en">
    <head>
      <meta charset="utf-8" />
      ...

La URL es la parte interesante aquí. Simplemente dijimos que se conectará al servicio hello-front-dev y a ese puerto 3000. El hello-front-dev es el nombre del contenedor, que fue dado por nosotros usando container_name en el archivo docker-compose. Y 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.yml para enfatizar esto:

services:
  app:
    image: hello-front-dev
    build:
      context: .
      dockerfile: dev.Dockerfile
    volumes:
      - ./:/usr/src/app
    ports:
      - 3210:3000    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 aun así docker-compose ejecuta debug-helper wget -O - http://app: 3000 funciona ya que el puerto sigue siendo 3000 dentro de la red docker.

fullstack content

Como ilustra la imagen de arriba, 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 sabe lo fácil que es encontrar otros servicios en docker-compose.yml y no tenemos nada que depurar, podemos eliminar el asistente de depuración y revertir los puertos a 3000:3000 en nuestro < i>docker-compose.yml.

Comunicaciones entre contenedores en un entorno más ambicioso

A continuación, agregaremos un proxy inverso a nuestro docker-compose.yml. segun wikipedia

Un proxy inverso es un tipo de servidor proxy que recupera recursos en nombre de un cliente de 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.

Cree un archivo nginx.conf en la raíz del proyecto y tome la siguiente plantilla como punto de partida. Tendremos que hacer ediciones menores para que nuestra aplicación se ejecute:

# events is required, but defaults are ok
events { }

# A http server, listening at port 80
http {
  server {
    listen 80;

    # Requests starting with root (/) are handled
    location / {
      # The following 3 lines are required for the hot loading to work (websocket).
      proxy_http_version 1.1;
      proxy_set_header Upgrade $http_upgrade;
      proxy_set_header Connection 'upgrade';
      
      # Requests are directed to http://localhost:3000
      proxy_pass http://localhost:3000;
    }
  }
}

A continuación, cree un servicio Nginx en el archivo docker-compose.yml. Agregue 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á de solo lectura:

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 # wait for the frontend container to be started

Con eso agregado, podemos ejecutar docker-compose up y ver qué sucede.

$ docker container ls
CONTAINER ID   IMAGE             COMMAND                  CREATED         STATUS         PORTS                                       NAMES
a02ae58f3e8d   nginx:1.20.1      "/docker-entrypoint.…"   4 minutes ago   Up 4 minutes   0.0.0.0:8080->80/tcp, :::8080->80/tcp       reverse-proxy
5ee0284566b4   hello-front-dev   "docker-entrypoint.s…"   4 minutes ago   Up 4 minutes   0.0.0.0:3000->3000/tcp, :::3000->3000/tcp   hello-front-dev

Conectarse a http://localhost:8080 conducirá a una página familiar con estado 502.

Esto se debe a que dirigir las solicitudes a http://localhost:3000 no conduce a ninguna parte, ya que el contenedor Nginx no tiene una aplicación ejecutándose en el puerto 3000. 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 misma. En nuestro 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.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 (aplicación) en el puerto 3000

root@374f9e62bfa8:/# curl http://app:3000
  <!DOCTYPE html>
  <html lang="en">
    <head>
    ...
    <meta
      name="description"
      content="Web site created using create-react-app"
    />
    ...

¡Eso es! Reemplacemos la dirección proxy_pass en nginx.conf con esa.

Si todavía se encuentra con 502, asegúrese de que la aplicación create-react-app se haya creado primero. Puede leer la salida de registros desde docker-compose up.

Una cosa más: agregamos una opción depends_on a la configuración que garantiza que el contenedor nginx no se inicie antes se mira 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:3000;    }
  }
}

Tenga 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 a 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á interesado en obtener más información sobre los contenedores, acceda al curso DevOps con Docker y podrá 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!