Docker Compose
Bien, ahora llegamos a una de las partes más importantes del libro, ¡el sensacional y completo Docker Compose!
Docker Compose no es más que una forma de poder escribir en un único archivo todos los detalles del entorno de su aplicación. Antes usábamos el dockerfile solo para crear imágenes, ya sea de mi aplicación, de mi BD o de mi webserver, pero siempre de forma unitaria, pues tengo un dockerfile para cada "tipo" de container: uno para mi app, otro para mi BD y así sucesivamente.
Con Docker Compose hablamos del entorno completo. Por ejemplo, en Docker Compose definimos qué services deseamos crear y cuáles son las características de cada service (cantidad de containers bajo ese service, volúmenes, network, secrets, etc.).
El estándar que siguen los compose files es YML, supersimple y de fácil comprensión, sin embargo siempre es bueno estar atento a la sintaxis que el estándar YML impone. ;)
Bien, ¡dejemos de hablar y empecemos la diversión!
Antes necesitábamos instalar Docker Compose para utilizarlo. Sin embargo, hoy tenemos el subcomando "docker stack", ya disponible junto a la instalación de Docker. Es responsable de realizar el deploy de nuestros services a través de Docker Compose de manera simple, rápida y muy efectiva.
¡Vamos a empezar! Lo primero que debemos hacer es la propia creación del compose file. Comencemos por uno más simple y vamos aumentando la complejidad a medida que evolucionamos.
Recuerde: para que podamos continuar con los próximos ejemplos, su cluster swarm deberá estar funcionando perfectamente. Por lo tanto, si aún no tiene el swarm activo, ejecute:
# docker swarm init
Vamos a crear un directorio llamado "Composes", solo para que podamos organizar mejor nuestros archivos.
# mkdir /root/Composes
# mkdir /root/Composes/1
# cd /root/Composes/1
# vim docker-compose.yml
version: "3"
services:
web:
image: nginx
deploy:
replicas: 5
resources:
limits:
cpus: "0.1"
memory: 50M
restart_policy:
condition: on-failure
ports:
- "8080:80"
networks:
- webserver
networks:
webserver:
¡Listo! Ahora ya tenemos nuestro primer docker-compose. Lo que necesitamos ahora es realizar el deploy, pero antes vamos a conocer algunas opciones que utilizamos anteriormente:
-
version: "3" -- Versión del compose que estamos utilizando.
-
services: -- Inicio de la definición de mi servicio.
-
web: -- Nombre del servicio.
-
image: nginx -- Imagen que vamos a utilizar.
-
deploy: -- Inicio de la estrategia de deploy.
-
replicas: 5 -- Cantidad de réplicas.
-
resources: -- Inicio de la estrategia de utilización de recursos.
-
limits: -- Límites.
-
cpus: "0.1" -- Límite de CPU.
-
memory: 50M -- Límite de memoria.
-
restart_policy: -- Políticas de restart.
-
condition: on-failure -- Solo "reiniciará" el container en caso de fallo.
-
ports: -- Qué puertos deseamos exponer.
-
- "8080:80" -- Puertos expuestos y "bindeados".
-
networks: -- Definición de las redes que utilizaré en este servicio.
-
- webserver -- Nombre de la red de este servicio.
-
networks: -- Declarando las redes que usaremos en este docker-compose.
-
webserver: -- Nombre de la red a crear, si no existe.
Simple como volar, ¿no? :D
15.1. El comando docker stack
Ahora necesitamos realizar el deploy de este service a través del compose file que creamos. Para eso, vamos a utilizar el sensacional "docker stack":
root@linuxtips-01:~/Composes/1# docker stack deploy -c docker-compose.yml primeiro
Creating network primeiro_webserver
Creating service primeiro_web
root@linuxtips-01:~/Composes/1#
Así de simple, y nuestro service ya está disponible para uso. Ahora vamos a verificar si realmente el service se levantó y está respondiendo como se esperaba:
root@linuxtips-01:~/Composes/1# curl 0:8080
<!DOCTYPE html>
<html>
<head>
<title>Welcome to nginx!</title>
<style>
body {
width: 35em;
margin: 0 auto;
font-family: Tahoma, Verdana, Arial, sans-serif;
}
</style>
</head>
<body>
<h1>Welcome to nginx!</h1>
<p>If you see this page, the nginx web server is successfully installed and working. Further configuration is required.</p>
<p>For online documentation and support please refer to
<a href="http://nginx.org/">nginx.org</a>.<br/>
Commercial support is available at
<a href="http://nginx.com/">nginx.com</a>.</p>
<p><em>Thank you for using nginx.</em></p>
</body>
</html>
root@linuxtips-01:~/Composes/1#
¡Sensacional, nuestro service está en pie, pues recibimos la página de bienvenida de Nginx!
Vamos a verificar si todo está correcto con el service:
root@linuxtips-01:~/Composes/1# docker service ls
ID NAME MODE REPLICAS IMAGE PORTS
mw95t primeiro_web replicated 5/5 nginx:latest *:8080->80/tcp
root@linuxtips-01:~/Composes/1# docker service ps primeiro_web
ID NAME IMAGE NODE DESIRED STATE CURRENT STATE ERROR PORTS
lrcqo8ifultq primeiro_web.1 nginx:latest LINUXtips-02 Running Running 2 minutes ago
ty16mkcqdwyl primeiro_web.2 nginx:latest LINUXtips-03 Running Running 2 minutes ago
dv670shw22o2 primeiro_web.3 nginx:latest LINUXtips-01 Running Running 2 minutes ago
sp0k1tnjftnr primeiro_web.4 nginx:latest LINUXtips-01 Running Running 2 minutes ago
4fpl35llq1ih primeiro_web.5 nginx:latest LINUXtips-03 Running Running 2 minutes ago
root@linuxtips-01:~/Composes/1#
Para listar todos los stacks creados, basta ejecutar:
root@linuxtips-01:~/Composes/1# docker stack ls
NAME SERVICES
primeiro 1
root@linuxtips-01:~/Composes/1#
Observe: la salida dice que tenemos solo un stack creado y ese stack posee un service, que es exactamente nuestro Nginx.
Para visualizar los services que existen en determinado stack, ejecute:
root@linuxtips-01:~/Composes/1# docker stack services primeiro
ID NAME MODE REPLICAS IMAGE PORTS
mx0p4vbrzfuj primeiro_web replicated 5/5 nginx:latest *:8080->80/tcp
root@linuxtips-01:~/Composes/1#
Podemos verificar los detalles de nuestro stack creado a través del comando siguiente:
root@linuxtips-01:~/Composes/1# docker stack ps primeiro
ID NAME IMAGE NODE DESIRED STATE CURRENT STATE ERROR PORTS
x3u03509w9u3 primeiro_web.1 nginx:latest LINUXtips-03 Running Running 5 seconds ago
3hpu5lo6yvld primeiro_web.2 nginx:latest LINUXtips-02 Running Running 5 seconds ago
m82wbwuwoza0 primeiro_web.3 nginx:latest LINUXtips-03 Running Running 5 seconds ago
y7vizedqvust primeiro_web.4 nginx:latest LINUXtips-02 Running Running 5 seconds ago
wk0acjnyl6jm primeiro_web.5 nginx:latest LINUXtips-01 Running Running 5 seconds ago
root@linuxtips-01:~/Composes/1#
¡Maravilloso! ¡Nuestro service está UP y todo está en paz!
En pocos minutos levantamos nuestro service de Nginx en nuestro cluster utilizando docker-compose y "docker stack", ¡simple como volar!
Ahora imaginemos que quiero eliminar este service. ¿Cómo lo hago? Simple:
root@linuxtips-01:~/Composes/1# docker stack rm primeiro
Removing service primeiro_web
Removing network primeiro_webserver
root@linuxtips-01:~/Composes/1#
Para verificar si realmente eliminó el service:
root@linuxtips-01:~/Composes/1# docker service ls
ID NAME MODE REPLICAS IMAGE PORTS
root@linuxtips-01:~/Composes/1#
¡Listo! ¡Nuestro service está eliminado!
Vamos a aumentar un poco la complejidad en la creación de nuestro docker-compose en este nuevo ejemplo.
Vamos a crear un directorio más, donde crearemos nuestro nuevo compose file:
root@linuxtips-01:~/Composes# mkdir 2
root@linuxtips-01:~/Composes# cd 2
root@linuxtips-01:~/Composes# vim docker-compose.yml
version: '3'
services:
db:
image: mysql:5.7
volumes:
- db_data:/var/lib/mysql
environment:
MYSQL_ROOT_PASSWORD: somewordpress
MYSQL_DATABASE: wordpress
MYSQL_USER: wordpress
MYSQL_PASSWORD: wordpress
wordpress:
depends_on:
- db
image: wordpress:latest
ports:
- "8000:80"
environment:
WORDPRESS_DB_HOST: db:3306
WORDPRESS_DB_USER: wordpress
WORDPRESS_DB_PASSWORD: wordpress
volumes:
db_data:
¡Perfecto!
En este ejemplo estamos conociendo algunas opciones más que podemos utilizar en docker-compose. Son:
-
volumes: -- Definición de los volúmenes utilizados por el service.
-
- db_data:/var/lib/mysql -- Volumen y destino.
-
environment: -- Definición de variables de entorno utilizadas por el service.
-
MYSQL_ROOT_PASSWORD: somewordpress -- Variable y valor.
-
MYSQL_DATABASE: wordpress -- Variable y valor.
-
MYSQL_USER: wordpress -- Variable y valor.
-
MYSQL_PASSWORD: wordpress -- Variable y valor.
-
depends_on: -- Indica que este service depende de otro para levantarse.
-
- db -- Nombre del service necesario para su ejecución.
¡Muy simple, ¿no?!
Ahora vamos a realizar el deploy de este ejemplo. Como se puede percibir, nuestro stack está compuesto por dos services, Wordpress y MySQL.
root@linuxtips-01:~/Composes/2# docker stack deploy -c docker-compose.yml segundo
Creating network segundo_default
Creating service segundo_db
Creating service segundo_wordpress
root@linuxtips-01:~/Composes/2#
Como se esperaba, realizó la creación de los dos services y de la red del stack.
Para acceder a su Wordpress, basta acceder en un navegador:
http://TU_IP:8000
¡Su Wordpress está listo para usar!
Para verificar si todo salió bien con los services, recuerde los comandos:
root@linuxtips-01:~/Composes/1# docker stack ls
root@linuxtips-01:~/Composes/1# docker stack services segundo
root@linuxtips-01:~/Composes/1# docker service ls
root@linuxtips-01:~/Composes/1# docker service ps segundo_db
root@linuxtips-01:~/Composes/1# docker service ps segundo_wordpress
Para visualizar los logs de determinado service:
root@linuxtips-01:~/Composes/2# docker service logs segundo_wordpress
segundo_wordpress.1.r6reuq8fsil0@LINUXtips-01 | WordPress not found in /var/www/html - copying now...
segundo_wordpress.1.r6reuq8fsil0@LINUXtips-01 | Complete! WordPress has been successfully copied to /var/www/html
segundo_wordpress.1.r6reuq8fsil0@LINUXtips-01 | AH00558: apache2: Could not reliably determine the server's fully qualified domain name, using 10.0.4.5. Set the 'ServerName' directive globally to suppress this message
segundo_wordpress.1.r6reuq8fsil0@LINUXtips-01 | AH00558: apache2: Could not reliably determine the server's fully qualified domain name, using 10.0.4.5. Set the 'ServerName' directive globally to suppress this message
segundo_wordpress.1.r6reuq8fsil0@LINUXtips-01 | [Sun Jun 11 10:32:47.392836 2017] [mpm_prefork:notice] [pid 1] AH00163: Apache/2.4.10 (Debian) PHP/5.6.30 configured -- resuming normal operations
segundo_wordpress.1.r6reuq8fsil0@LINUXtips-01 | [Sun Jun 11 10:32:47.392937 2017] [core:notice] [pid 1] AH00094: Command line: 'apache2 -D FOREGROUND'
root@linuxtips-01:~/Composes/2#
Y si es necesaria una modificación en mi stack y luego un re-deploy, ¿cómo lo hago? ¿Es posible?
¡Claro! ¡Después de todo, Docker es mucha vida!
root@linuxtips-01:~/Composes# mkdir 3
root@linuxtips-01:~/Composes# cd 3
root@linuxtips-01:~/Composes/3# vim docker-compose.yml
version: "3"
services:
web:
image: nginx
deploy:
replicas: 5
resources:
limits:
cpus: "0.1"
memory: 50M
restart_policy:
condition: on-failure
ports:
- "8080:80"
networks:
- webserver
visualizer:
image: dockersamples/visualizer:stable
ports:
- "8888:8080"
volumes:
- "/var/run/docker.sock:/var/run/docker.sock"
deploy:
placement:
constraints: [node.role == manager]
networks:
- webserver
networks:
webserver:
Observe que solo agregamos un nuevo service a nuestro stack, el visualizer. La idea es realizar el update solo en el stack para agregar el visualizer, sin dejar indisponible el service web.
Antes de realizar el update de este stack, vamos a conocer las nuevas opciones que están en el compose file de este ejemplo:
deploy:
-
placement: -- Usado para definir la ubicación de nuestro service.
-
constraints: [node.role == manager] -- Regla que obliga la creación de este service solo en los nodes manager.
Ahora vamos a actualizar nuestro stack:
root@linuxtips-01:~/Composes/3# docker stack deploy -c docker-compose.yml primeiro
Creating service primeiro_visualizer
Updating service primeiro_web (id: mx0p4vbrzfujk087c3xe2sjvo)
root@linuxtips-01:~/Composes/3#
Observe que, para realizar el update del stack, utilizamos el mismo comando que usamos para realizar el primer deploy del stack, el "docker stack deploy".
¿Qué tal aumentar aún más la complejidad y el número de services de un stack? ¿Vamos?
Para este ejemplo, vamos a utilizar un proyecto del propio Docker (https://github.com/dockersamples/example-voting-app), donde tendremos diversos services. Vamos a crear un directorio más para recibir nuestro proyecto:
root@linuxtips-01:~/Composes# mkdir 4
root@linuxtips-01:~/Composes# cd 4
root@linuxtips-01:~/Composes/4# vim compose-file.yml
version: "3"
services:
redis:
image: redis:alpine
ports:
- "6379"
networks:
- frontend
deploy:
replicas: 2
update_config:
parallelism: 2
delay: 10s
restart_policy:
condition: on-failure
db:
image: postgres:9.4
volumes:
- db-data:/var/lib/postgresql/data
networks:
- backend
deploy:
placement:
constraints: [node.role == manager]
vote:
image: dockersamples/examplevotingapp_vote:before
ports:
- 5000:80
networks:
- frontend
depends_on:
- redis
deploy:
replicas: 2
update_config:
parallelism: 2
restart_policy:
condition: on-failure
result:
image: dockersamples/examplevotingapp_result:before
ports:
- 5001:80
networks:
- backend
depends_on:
- db
deploy:
replicas: 1
update_config:
parallelism: 2
delay: 10s
restart_policy:
condition: on-failure
worker:
image: dockersamples/examplevotingapp_worker
networks:
- frontend
- backend
deploy:
mode: replicated
replicas: 1
labels: [APP=VOTING]
restart_policy:
condition: on-failure
delay: 10s
max_attempts: 3
window: 120s
placement:
constraints: [node.role == manager]
visualizer:
image: dockersamples/visualizer:stable
ports:
- "8080:8080"
stop_grace_period: 1m30s
volumes:
- "/var/run/docker.sock:/var/run/docker.sock"
deploy:
placement:
constraints: [node.role == manager]
networks:
frontend:
backend:
volumes:
db-data:
¿Se volvió más complejo o no? ¡Creo que no, pues en Docker todo es bastante simple!
Tenemos algunas opciones nuevas en este ejemplo, vamos a conocerlas:
deploy:
- mode: replicated -- ¿Cuál es el tipo de deployment? Tenemos dos, el global y el replicated. En replicated eliges la cantidad de réplicas de tu service, ya en global no eliges la cantidad de réplicas, levantará una réplica por node de tu cluster (una réplica en cada node de tu cluster).
update_config:
-
parallelism: 2 -- Cómo ocurrirán las actualizaciones (en este caso, de 2 en 2).
-
delay: 10s -- Con intervalo de 10 segundos.
restart_policy:
-
condition: on-failure -- En caso de fallo, restart.
-
delay: 10s -- Con intervalo de 10 segundos.
-
max_attempts: 3 -- Con un máximo de tres intentos.
-
window: 120s -- Tiempo para definir si el restart del container ocurrió con éxito.
Ahora vamos a realizar el deploy de nuestro stack:
root@linuxtips-01:~/Composes/4# docker stack deploy -c docker-compose.yml quarto
Creating network quarto_default
Creating network quarto_frontend
Creating network quarto_backend
Creating service quarto_worker
Creating service quarto_visualizer
Creating service quarto_redis
Creating service quarto_db
Creating service quarto_vote
Creating service quarto_result
root@linuxtips-01:~/Composes/4#
Verificando los services:
root@linuxtips-01:~/Composes/4# docker service ls
ID NAME MODE REPLICAS IMAGE PORTS
3hi3sx2on3t5 quarto_worker replicated 1/1 dockersamples/examplevotingapp_worker:latest
hbsp4fdcvgnz quarto_visualizer replicated 1/1 dockersamples/visualizer:stable :8080->8080/tcp
k6xuqbq7g55a quarto_db replicated 1/1 postgres:9.4
p2reijydxnsw quarto_result replicated 1/1 dockersamples/examplevotingapp_result:before :5001->80/tcp
rtwnnkwftg9u quarto_redis replicated 2/2 redis:alpine :0->6379/tcp
w2ritqiklpok quarto_vote replicated 2/2 dockersamples/examplevotingapp_vote:before :5000->80/tcp
root@linuxtips-01:~/Composes/4#
Recuerde utilizar siempre los comandos que ya conocemos para visualizar stack, services, volúmenes, container, etc.
Para acceder a los services en ejecución, abra un navegador y vaya a las siguientes direcciones:
-
Visualizar la página de votación: http://IP_CLUSTER:5000/
-
Visualizar la página de resultados: http://IP_CLUSTER:5001/
-
Visualizar la página con los containers y sus nodes: http://IP_CLUSTER:8080/
Vamos a otro ejemplo más. Ahora vamos a realizar el deploy de un stack completo de monitoreo para nuestro cluster y todas las demás máquinas de nuestra infraestructura. En este ejemplo vamos a utilizar un archivo YML que realizará el deploy de diversos containers para que podamos tener las siguientes herramientas integradas:
-
Prometheus -- Para almacenar todas las métricas de nuestro entorno.
-
cAdvisor -- Para recolectar información de los containers.
-
Node Exporter -- Para recolectar información de los nodes del cluster y demás máquinas del entorno.
-
Netdata -- Para recolectar más de 5 mil métricas de nuestras máquinas, además de proveer un dashboard sensacional.
-
Rocket.Chat -- Para que podamos comunicarnos con otros equipos y personas y también para integrarlo al sistema de monitoreo, notificando cuando los alertas ocurren. Rocket.Chat es una excelente alternativa a Slack.
-
AlertManager -- Integrado a Prometheus y a Rocket.Chat, es el responsable de gestionar nuestras alertas.
-
Grafana -- Integrado a nuestra solución de monitoreo, es el responsable de los dashboards que se producen a través de las métricas que están almacenadas en Prometheus.
Con este stack es posible monitorear containers, VMs y máquinas físicas. Sin embargo, nuestro foco ahora es solamente lo que se refiere al libro y a este capítulo, es decir, las informaciones contenidas en el compose file que definirán nuestro stack.
Para más detalles en relación a Giropops-Monitoring, acceda al repositorio en la dirección: https://github.com/badtuxx/giropops-monitoring.
Antes de conocer nuestro compose file, necesitamos realizar el clone del proyecto:
# git clone https://github.com/badtuxx/giropops-monitoring.git
Acceda al directorio "giropops-monitoring":
# cd giropops-monitoring
Nuestro foco aquí será en tres cosas: el archivo "grafana.config", el directorio "conf" y nuestro querido e idolatrado "docker-compose.yml".
El archivo "grafana.config" contiene variables que queremos pasar a nuestro Grafana. En este momento la única información importante es el password del admin, usuario que utilizaremos para acceder a la interfaz web de Grafana.
El directorio "conf" posee los archivos necesarios para que la integración entre las aplicaciones de nuestro stack funcionen correctamente.
Ya nuestro compose file trae todas las informaciones necesarias para que podamos realizar el deploy de nuestro stack.
Como nuestro foco es el compose file, ¡vamos a conocerlo!
# cat docker-compose.yml
version: '3.3'
services:
prometheus:
image: linuxtips/prometheus_alpine
volumes:
- ./conf/prometheus/:/etc/prometheus/
- prometheus_data:/var/lib/prometheus
networks:
- backend
ports:
- 9090:9090
node-exporter:
image: linuxtips/node-exporter_alpine
hostname: {% raw %}'{{.Node.ID}}'{% endraw %}
volumes:
- /proc:/usr/proc
- /sys:/usr/sys
- /:/rootfs
deploy:
mode: global
networks:
- backend
ports:
- 9100:9100
alertmanager:
image: linuxtips/alertmanager_alpine
volumes:
- ./conf/alertmanager/:/etc/alertmanager/
networks:
- backend
ports:
- 9093:9093
cadvisor:
image: google/cadvisor
hostname: {% raw %}'{{.Node.ID}}'{% endraw %}
volumes:
- /:/rootfs:ro
- /var/run:/var/run:rw
- /sys:/sys:ro
- /var/lib/docker/:/var/lib/docker:ro
- /var/run/docker.sock:/var/run/docker.sock:ro
networks:
- backend
deploy:
mode: global
ports:
- 8080:8080
grafana:
image: nopp/grafana_alpine
depends_on:
- prometheus
volumes:
- ./conf/grafana/grafana.db:/grafana/data/grafana.db
env_file:
- grafana.config
networks:
- backend
- frontend
ports:
- 3000:3000
# If you already has a RocketChat instance running, just comment the code of rocketchat, mongo and mongo-init-replica services bellow
rocketchat:
image: rocketchat/rocket.chat:latest
volumes:
- rocket_uploads:/app/uploads
environment:
- PORT=3080
- ROOT_URL=http://YOUR_IP:3080
- MONGO_URL=mongodb://giropops_mongo:27017/rocketchat
- MONGO_OPLOG_URL=mongodb://giropops_mongo:27017/local
depends_on:
- giropops_mongo
ports:
- 3080:3080
mongo:
image: mongo:3.2
volumes:
- mongodb_data:/data/db
#- ./data/dump:/dump
command: mongod --smallfiles --oplogSize 128 --replSet rs0
mongo-init-replica:
image: mongo:3.2
command: 'mongo giropops_mongo/rocketchat --eval "rs.initiate({_id: ''rs0'', members: [ { _id: 0, host: ''localhost:27017''} ]})"'
depends_on:
- giropops_mongo
networks:
frontend:
backend:
volumes:
prometheus_data:
grafana_data:
rocket_uploads:
mongodb_data:
Observe que ya conocemos todas las opciones que están en este ejemplo, nada nuevo. :D
Lo que necesitamos ahora es realizar el deploy de nuestro stack:
# docker stack deploy -c docker-compose.yml giropops
Creating network giropops_backend
Creating network giropops_frontend
Creating network giropops_default
Creating service giropops_grafana
Creating service giropops_rocketchat
Creating service giropops_mongo
Creating service giropops_mongo-init-replica
Creating service giropops_prometheus
Creating service giropops_node-exporter
Creating service giropops_alertmanager
Creating service giropops_cadvisor
En caso de que quiera verificar si los services están en ejecución:
# docker service ls
Para listar los stacks:
# docker stack ls
Para acceder a los servicios de los cuales acabamos de realizar el deploy, basta acceder a las siguientes direcciones:
-
Prometheus: http://TU_IP:9090
-
AlertManager: http://TU_IP:9093
-
Grafana: http://TU_IP:3000
-
Node_Exporter: http://TU_IP:9100
-
Rocket.Chat: http://TU_IP:3080
-
cAdivisor: http://TU_IP:8080
Para eliminar el stack:
# docker stack rm giropops
Recordando: para conocer más sobre giropops-monitoring acceda al repositorio en GitHub y vea la serie de videos en que Jeferson habla detalladamente de cómo montó esta solución:
Y así termina nuestro viaje en el mundo de Docker. Esperamos que haya aprendido y, más que eso, que le haya gustado compartir este tiempo con nosotros para hablar sobre lo que más amamos, ¡la tecnología!
15.2. ¿Y ya terminó? :(
¡Esperamos que haya disfrutado viajar con nosotros durante su aprendizaje sobre containers y principalmente sobre el ecosistema de Docker, que es sensacional!
¡No deje de aprender más sobre Docker! Continúe acompañando el Canal LinuxTips en https://www.youtube.com/linuxtips y esté atento al sitio de Docker, pues siempre tiene novedades y excelente documentación!
¡Únase a nosotros en Discord para que pueda acompañar y aclarar dudas que puedan haber surgido durante sus estudios!
#VAIIII