Este es un tema que quería tratar desde hace varios años. Docker es la plataforma más conocida para implementar contenedores. Además de Docker, existen otros software de contenedores como Podman, rkt, LXC y Kubernetes. Aunque este último no es estrictamente una plataforma de contenedores, es un sistema de orquestación de contenedores utilizado para automatizar la implementación, escalado y gestión de aplicaciones contenerizadas.
En este artículo, aprenderemos a trabajar con Docker a través de ejemplos prácticos, creando diferentes entornos, tanto de un solo contenedor como de múltiples contenedores. Terminaremos creando una aplicación web con Apache y PHP que mostrará registros almacenados en una base de datos MariaDB alojada en otro contenedor. He decidido dejar para el próximo artículo la creación de contenedores con Dockerfile y Docker Compose, con el fin de explicar de manera más sencilla cómo funciona Docker, de una forma más "artesanal". Así, podrás adquirir las bases necesarias para abordar estos temas con mayor profundidad y de manera más avanzada en el siguiente artículo, habiendo obtenido una experiencia mínima que facilitará su comprensión.
Para la instalación, desde su página oficial se explica muy claro como hacerlo según tu sistema operativo y si prefieres la versión GUI o CLI (entorno gráfico de ventanas o por comandos en la consola). Pulsa aquí para consultar la documentación oficial que explica con todo detalle cómo instalar Docker en todas las plataformas compatibles.
Para servidores web, optaremos por instalar Docker Engine. Para ordenadores con entorno gráfico, Docker Desktop es la elección adecuada.
En esta guía siempre utilizaremos la terminal para operar con Docker. Por eso, en mi caso, inicio sesión en una instancia de Ubuntu en OCI a la que previamente he instalado Docker Engine siguiendo las claras instrucciones de la documentación oficial. Si prefieres usar Docker en un ordenador local con un entorno gráfico, debes instalar Docker Desktop para Windows, Mac o Linux y ejecutar los comandos desde la terminal. En el caso de Windows, para seguir este curso de Docker por comandos se recomienda usar PowerShell o Git Bash.
En Ubuntu, puedo verificar que el servicio está instalado y en ejecución ingresando:
sudo systemctl status docker
En la documentación se menciona que puedes ingresar docker --version
para verificar si está instalado. Este método es más recomendable porque funciona en cualquier sistema operativo.
~$ docker --version
Docker version 26.1.1, build 4cf5afa
También es muy práctico ingresar simplemente docker
, lo cual mostrará una breve ayuda con los parámetros y opciones disponibles.
~$ docker
Usage: docker [OPTIONS] COMMAND
A self-sufficient runtime for containers
Common Commands:
run Create and run a new container from an image
exec Execute a command in a running container
ps List containers
build Build an image from a Dockerfile
pull Download an image from a registry
push Upload an image to a registry
images List images
login Log in to a registry
logout Log out from a registry
search Search Docker Hub for images
version Show the Docker version information
info Display system-wide information
Management Commands: ...
...
*Extracto. La lista es muy grande
En Linux, para operar con Docker, debemos ingresar los comandos con privilegios de sudo. Por comodidad, si lo prefieres, puedes añadir tu usuario al grupo "docker" y así no será necesario usar sudo en cada comando.
sudo usermod -aG docker nombre_usuario
Esta sentencia modifica los atributos del usuario especificado. Se utiliza la opción "-aG docker" para agregar al usuario al grupo "docker". Es importante cerrar y abrir la sesión nuevamente para que los cambios surtan efecto.
Los contenedores resuelven un problema clave en informática al permitir la ejecución de múltiples aplicaciones en un solo ordenador sin enfrentar problemas de compatibilidad entre versiones de sistemas operativos o librerías, y también evitan que si una tarea empieza a requerir muchos recursos, no ralentice al resto debido a su independencia.
Otra situación que podía darse, aunque era más común en tiempos pasados y probablemente no ocurra ahora, era que existieran ordenadores ejecutando una sola tarea, con servidores dedicados para correo, bases de datos, colas de impresión, entre otros. De esta manera, muchos de ellos permanecían frecuentemente al 0% de utilización desaprovechándolos y además teniendo un mayor gasto en equipos y mantenimiento.
Un contenedor no es una máquina virtual (VM), aunque tienen mucho en común y ambos son sistemas de virtualización.
El hecho de que contenedor y anfitrión compartan el kernel puede hacer que pensemos que en un anfitrión Windows no podemos ejecutar un contenedor basado en Linux. Sin embargo, para ello se utilizan tecnologías de virtualización como Docker Desktop for Windows o WSL 2 (Windows Subsystem for Linux 2). Estas tecnologías permiten ejecutar un sistema operativo Linux dentro de una máquina virtual liviana en Windows, lo que proporciona la infraestructura necesaria para ejecutar contenedores Linux en un entorno Windows. En términos de rendimiento, puede haber una afectación debido al uso de un emulador, aunque funciona bien para la mayoría de las aplicaciones. Lo ideal es ejecutar contenedores sobre anfitriones que compartan el mismo sistema operativo.
Las imágenes base son las plantillas con las que se inicia un contenedor Docker. Pueden ser desde un ejecutable hasta un sistema operativo completo con todo lo necesario para ejecutar una aplicación. Haciendo un simil con la programación orientada a objetos, una imagen es la clase y sus contenedores las instancias.
Las imágenes se descargan libremente desde el repositorio oficial hub.docker.com o también de otros servidores. Una vez registrados en hub.docker.com podemos buscar imágenes o subir las nuestras.
Las imágenes en Docker Hub cubren prácticamente cualquier tipo de aplicación o servicio que podamos necesitar. Algunos ejemplos comunes de tipos de imágenes disponibles incluyen imágenes base de sistemas operativos como punto de partida para construir imágenes personalizadas, servidores Apache, Nginx, Tomcat y más, bases de datos MySQL, MongoDB, PostgreSQL, entre otros. También hay contenedores de servicios como WordPress, Joomla, Drupal, y herramientas y utilidades de desarrollo, monitoreo y seguridad. En resumen, Docker Hub abarca prácticamente todo lo que se nos ocurra en el contexto del software universal.
Desde hub.docker.com, en el campo de búsqueda, ingresamos unas palabras sobre lo que buscamos y nos devolverá una lista de imágenes relacionadas con eso. Dependiendo del caso, incluso para una misma imagen, tendremos la opción de elegir entre varias versiones. Por ejemplo, si buscamos imágenes base de Ubuntu, obtendremos una lista de imágenes relacionadas con este sistema operativo, entre las cuales la primera suele ser la imagen oficial, disponible en múltiples versiones.
Para nuestro primer ejemplo, optaremos por la sencillez y buscaremos la imagen "Hello world". A estas alturas no hace falta aclarar para qué sirve, pero por si algún recién llegado a la programación lee esto, debe saber que un "hello world" es la primera aplicación que los desarrolladores suelen escribir para familiarizarse con la sintaxis básica del lenguaje y confirmar que el entorno de desarrollo está configurado correctamente. El objetivo principal es imprimir el mensaje "Hello, World!" en la pantalla.
La primera imagen que se encuentra es la oficial. Al hacer clic en una imagen, se carga una pantalla con información variada sobre
ella, como guías de referencia o su utilidad. Debemos buscar en la pantalla el comando de Docker que ejecutaremos en la terminal.
En la zona de arriba a la derecha copiamos
la sentencia docker pull hello-world
y la introducimos en la terminal.
~$ docker pull hello-world
Using default tag: latest
latest: Pulling from library/hello-world
478afc919002: Pull complete
Digest: sha256:a26bff933ddc26d5cdf7faa98b4ae1e3ec20c4985e6f87ac0973052224d24302
Status: Downloaded newer image for hello-world:latest
docker.io/library/hello-world:latest
Como resultado, nos informa de que la imagen ha sido descargada utilizando la etiqueta "latest" (tag: latest), una firma de seguridad SHA256 y el directorio de origen.
Todavía no hemos llegado al momento en el que se muestra el mensaje en pantalla. No se ha ejecutado nada por el momento.
Con docker images
lista las imágenes descargardas en una tabla de 5 columnas con información detallada.
~$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
hello-world latest ee301c921b8a 12 months ago 9.14kB
REPOSITORY: Indica el nombre del repositorio de la imagen. El repositorio es básicamente el nombre de la imagen que se utiliza para almacenar y organizar imágenes en un registro de Docker. Podemos tener varias versiones de una misma imagen en un repositorio.
TAG: Indica la etiqueta (tag) asociada con la imagen. La etiqueta es una versión específica de la imagen dentro del repositorio. En este caso, la etiqueta es "latest", lo que indica que esta es la última versión de la imagen disponible en el repositorio. Sin embargo, podrían existir otras versiones etiquetadas con diferentes nombres, como "v1.0" o "testing".
IMAGE ID: Muestra el ID único de la imagen. Cada imagen en Docker tiene un identificador único asociado que se utiliza internamente para identificarla. El ID de la imagen es una cadena alfanumérica larga que identifica de manera única la imagen.
CREATED: Indica cuándo se creó la imagen. En este caso, indica que la imagen se creó hace "12 meses" a partir de la fecha en que se generó en el repositorio y se descargó.
SIZE: Muestra el tamaño de la imagen. En este caso, la imagen tiene un tamaño de "9.14kB". Esto indica el tamaño en bytes de la imagen cuando se descargue y almacene localmente.
Una imagen no se ejecuta. Son sus contenedores los que pueden ser levantados. A partir de una imagen, podemos crear uno o más contenedores, y estos sí pueden ser levantados de varias maneras. En esta fase inicial de aprendizaje veremos 2 formas sencillas de crear y levantar un contenedor.
Es la forma más directa de crear y arrancar un contenedor. Se utiliza el comando docker run
, proporcionando el nombre de la imagen o su ID. Con este comando, Docker primero buscará localmente si existe una imagen con ese nombre. Si no existe, la descargará del repositorio central utilizando el comando pull
. Luego, creará el contenedor (comando create
) y lo ejecutará (comando run
). A continuación, veremos cómo realizar estos tres pasos por separado.
Siguiendo con el mismo ejemplo, como ya tenemos una imagen llamada "hello-world", este comando creará y ejecutará el contenedor, pero la imagen no será descargada nuevamente porque ya existe en el sistema.
~$ docker run hello-world
Hello from Docker!
Ya tenemos un contenedor creado a partir de esta imagen y lo hemos levantado sin dificultad. Para listar los contenedores en ejecución se utiliza docker ps
, y para mostrar todos, tanto los detenidos como los que están en ejecución, se emplea docker ps -a
.
~$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
8f49139f600b hello-world "/hello" 2 minutes ago Exited (0) 2 minutes ago vigilant_margulis
El contenido de cada columna es el siguiente:
¿Qué pasaría si vuelvo a introducir la sentencia docker run hello-world
?
Se creará un nuevo contenedor basado en la imagen "hello-world", que aparecerá reflejado en la lista. Sin embargo,
crear múltiples contenedores a partir de esta misma imagen podría tener poco valor práctico. Es un simple contenedor
que devuelve un saludo.
~$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
3cfa0c177809 hello-world "/hello" 5 seconds ago Exited (0) 4 seconds ago crazy_dijkstra
8f49139f600b hello-world "/hello" 2 minutes ago Exited (0) 2 minutes ago vigilant_margulis
El nuevo contenedor comparte la imagen, pero tiene un ID y un nombre diferentes. Si no especificamos un nombre al crearlo, Docker le asigna uno con dos palabras al azar. Enseguida aprenderemos que podemos referirnos a un contenedor para realizar operaciones sobre él, como detener, eliminar, iniciar y otras más, tanto por nombre como por ID.
El comando docker create
crea un contenedor a partir de una imagen sin ejecutarlo.
Admite varias configuraciones comunes para personalizar su comportamiento. Esto da flexibilidad para preparar un
contenedor con la configuración deseada antes de iniciarlo. Algunas de las opciones más comunes incluyen:
-i, --interactive
: Mantiene STDIN abierto incluso si no está adjunto. STDIN (acrónimo de Standard Input) es un flujo de entrada de datos estándar que permite que un programa reciba información desde algún dispositivo de entrada, típicamente el teclado o un archivo.-t, --tty
: Asocia un pseudo-TTY(pseudo terminal) al contenedor, lo que facilita la interacción con él a través de la terminal.--name
: Asigna un nombre al contenedor en lugar de que Docker genere uno automáticamente.-v, --volume
: Monta un volumen en el contenedor, permitiendo el acceso a archivos o directorios en el host.-p, --publish
: Mapea puertos del contenedor al host, permitiendo acceder a servicios dentro del contenedor desde fuera. Ejemplo -p 8080:80 mapea al puerto 80 del contenedor el puerto 8080 del host.--network
: Especifica la red a la que se conectará el contenedor.--env, -e
: Establece variables de entorno dentro del contenedor.--detach, -d
: Ejecuta el contenedor en segundo plano y devuelve el ID del contenedor.--privileged
: Otorga al contenedor acceso a todos los dispositivos en el host.Una gran parte de las opciones disponibles en docker create
también están disponibles en docker run
porque este último combina en un solo comando las operaciones de "pull", "create" y "run".
Siguiendo con la misma imagen ya descargada, si utilizamos docker create
en
su forma simple sin parámetros, se creará un nuevo contenedor y devolverá como respuesta el ID completo del contenedor:
~$ docker create hello-world
88d46d3803d53d98517161b4ad7e000fdf3e27260f25adaec9c76f3c38d6c477
NOTA: Si la imagen no existe en local, la descargará automaticamente con docker pull
tal como vimos al principio del curso.
Al observar la nueva lista de contenedores, este último aparece con el ESTADO "creado", lo que indica que existe pero nunca se ha iniciado.
~$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
88d46d3803d5 hello-world "/hello" 16 seconds ago Created trusting_franklin
3cfa0c177809 hello-world "/hello" 24 minutes ago Exited (0) 24 minutes ago crazy_dijkstra
8f49139f600b hello-world "/hello" 59 minutes ago Exited (0) 59 minutes ago vigilant_margulis
Para crear un contenedor especificando el nombre, utilizaremos la siguiente forma:
docker create --name minombre_decontenedor hello-world
.
Un contenedor se arranca con docker start
proporcionando como argumento el Id o el nombre. En el caso del Id, No es necesario introducirlo entero. Normalmente será suficiente con los 2 primeros caracteres,
siempre y cuando sean únicos y no exista otro ID que comience de la misma manera.
Por comodidad, voy a iniciarlo usando solo los 2 primeros caracteres del Id, osea "88"
~$ docker start 88
88
La respuesta que confirma que ha sido levantado es la fracción del ID que se proporcionó como argumento. Sin embargo,
no aparece el mensaje Hello from Docker!
que se muestra al ejecutar
docker run
. Aún no tengo una explicación clara sobre este comportamiento, pero dado
que esta imagen es muy simple y básica, diseñada solo como una prueba de instalación, pasaremos por alto este detalle
por el momento. Al menos, pude comprobar con docker logs 88
que el contenedor se
inició, ya que el saludo se registra en el log del contenedor aunque no lo muestra por consola.
~$ docker logs 88
Hello from Docker!
Pronto volveremos al tema de los estados de los contenedores con una imagen más compleja. Hasta el momento, hemos visto contenedores en los estados "Created" y también "Exited" al finalizar su ejecución. Antes de conocer el resto de estados posibles, aprenderemos cómo borrar imágenes y contenedores para dejar el sistema limpio de elementos de Docker.
Para borrar contenedores se utiliza el comando docker rm
, seguido del ID o una lista de
IDs separados por espacios.
En este ejemplo, elimino 3 contenedores proporcionando el inicio del ID:
~$ docker rm 8f 3c 88
8f
3c
88
Si obtengo como respuesta el ID o la fracción introducida, significa que ha sido eliminado correctamente.
Para eliminar imágenes se utiliza docker rmi
de manera muy similar al borrado de contenedores.
Antes de borrar una imagen es necesario que no hayan contenedores asociados o Docker lanzará un error de imagen en uso por uno o más contenedores.
Un ejemplo de intento de borrado de imagen con resultado de error:
~$ docker rmi ee
Error response from daemon: conflict: unable to delete ee301c921b8a (must be forced)
- image is being used by stopped container 237187396eef
El mensaje de error es muy claro. Advierte que el contenedor "23" está utilizando la imagen que queremos borrar. Por lo tanto, primero vamos a eliminar el contenedor y luego procederemos a borrar la imagen. Finalmente, listaremos las imágenes disponibles en el sistema para verificar que no aparezca ningún elemento:
~$ docker rm 23
23
~$ docker rmi ee
Untagged: hello-world:latest
Untagged: hello-world@sha256:a26bff933ddc26d5cdf7faa98b4ae1e3ec20c4985e6f87ac0973052224d24302
Deleted: sha256:ee301c921b8aadc002973b2e0c3da17d701dcd994b606769a7e6eaa100b81d44
Deleted: sha256:12660636fe55438cc3ae7424da7ac56e845cdb52493ff9cf949c47a7f57f8b43
~$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
Ya hemos eliminado todo rastro de la imagen "hello-world". En la siguiente parte del artículo, instalaremos un contenedor del sistema Ubuntu, que ofrece muchas más opciones para practicar con Docker.
Como práctica, vamos a crear y arrancar un contenedor de Ubuntu sin utilizar docker run. Esto implica utilizar los comandos docker pull, docker create y docker start.
El procedimiento para crear un contenedor de Ubuntu, cualquier otra distribución de Linux y en general cualquier contenedor es muy
similar y con todo lo visto hasta ahora ya sabríamos hacerlo. Entramos en el repositorio oficial
https://hub.docker.com/_/ubuntu/tags y buscamos por "Ubuntu".
En el apartado de "TAGS" copiamos el comando docker pull
con la versión que nos interese. Para
este ejemplo, voy a escoger docker pull ubuntu:24.04
(última versión estable de abril 2024).
Podemos observar también que dentro de cada versión del contenedor aparece la información de la arquitectura del procesador. Ese
detalle no debe preocuparnos porque Docker se encarga de escoger el más indicado para la máquina anfitriona. En mi caso, que voy
a instalarlo en una instancia OCI con Ubuntu aarch64, se descargará esta. Si el sistema sobre el que está Docker fuera Windows,
seguramente se descargaría Amd64, que es la arquitectura más común en Windows.
~$ docker pull ubuntu:24.04
24.04: Pulling from library/ubuntu
9502885e1cbc: Pull complete
Digest: sha256:3f85b7caad41a95462cf5b787d8a04604c8262cdcdf9a472b8c52ef83375fe15
Status: Downloaded newer image for ubuntu:24.04
docker.io/library/ubuntu:24.04
A continuación, crearemos el contenedor con algunas opciones:
~$ docker create --name ubuntu_2404 -ti ubuntu:24.04
52574227fd31cb89549cc29f7d516516560933f436d18ab74cadaf435a5b9c2b
Ejecuto el contenedor con docker start ubuntu_2404
y realizo un listado de contenedores:
~$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
5d22d35e295f ubuntu:24.04 "/bin/bash" 17 minutes ago Up 2 minutes ubuntu_2404
Al verificar el estado del contenedor, me doy cuenta de que no he mapeado ningún puerto al crearlo. Es muy probable que los contenedores ejecuten servicios que requieran escuchar en puertos específicos. Por ejemplo, un servidor web o una base de datos son servicios comunes para ejecutar en un contenedor. Sin embargo, debido a este olvido, es posible que el contenedor no funcione correctamente.
Por simplicidad, opto por eliminarlo y volver a crearlo con el mapeo de puertos corregido. Si esta solución no es conveniente debido al trabajo realizado dentro del contenedor, recomiendo consultar https://www.baeldung.com/ops/assign-port-docker-container, que presenta varias soluciones alternativas.
Tras eliminarlo, lo vuelvo a crear, esta vez con el mapeo de puertos correctamente configurado.
~$ docker create --name ubuntu_2404 -ti -p 8080:80 ubuntu:24.04
02fc04a180695a98a34f5e0bfb22111562b7b2fc9a82dee274565955c7ee88f4
Lo inicio invocándolo por su nombre:
~$ docker start ubuntu_2404
ubuntu_2404
Y ahora ya si aparece el mapeo de puertos:
~$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
02fc04a18069 ubuntu:24.04 "/bin/bash" 57 seconds ago Up 7 seconds 0.0.0.0:8080->80/tcp, :::8080->80/tcp ubuntu_2404
Se muestra en la columna PORTS 0.0.0.0:8080->80/tcp, :::8080->80/tcp. "0.0.0.0" es una dirección IP especial que se utiliza para indicar todas las direcciones IP disponibles en el sistema. Cuando un servicio en un contenedor Docker escucha en "0.0.0.0", significa que está disponible para ser accesible desde cualquier dirección IP en el host, lo que incluye direcciones IPv4 e IPv6.
Para entender mejor el desarrollo de estas prácticas, conviene comentar brevemente los estados de un contenedor y los comandos para actuar sobre esos estados.
Los estados de un contenedor en Docker pueden ser los siguientes:
Comandos más comunes para gestionar el estado de los contenedores:
Seguimos avanzando en el uso de contenedores. Si deseamos acceder a la terminal del contenedor de Ubuntu creado anteriormente,
debemos utilizar el comando docker exec
, que ejecuta comandos dentro de un contenedor.
El comando que nos interesa, siendo un Linux Ubuntu, es bash
para abrir el shell o terminal
que trae predeterminado. Además, incluimos la opción -ti
, comentada anteriormente, para dar interactividad.
Otro parámetro necesario es el nombre del contenedor sobre el cual se ejecuta el comando. La sentencia completa queda así:
ubuntu@ubuntusrv2:~$ docker exec -it ubuntu_2404 bash
root@02fc04a18069:/#
Como puedes ver, ahora el prompt indica que somos el usuario "root", que estamos dentro del contenedor con el ID mostrado y en el directorio raíz simbolizado por "/".
Tenemos la capacidad de ejecutar comandos dentro del contenedor, lo que nos permite interactuar con él de manera similar a como lo haríamos con una máquina virtual.
Recuerda que el contenedor debe estar en ejecución para poder ingresar a él utilizando docker exec. Si el contenedor se detiene, tendrás que reiniciarlo con los comandos docker start ubuntu_2404
o docker restart ubuntu_2404
antes de poder ingresar nuevamente.
Para salir del contenedor, podemos usar el comando exit
y regresaremos al host.
La filosofía de Docker sigue en muchos aspectos la de los microservicios por su modularidad. En lugar de instalar múltiples servicios en un solo contenedor, es más adecuado utilizar contenedores dedicados para cada servicio. Por ejemplo, en vez de instalar Apache en un contenedor Linux, se prefieren contenedores individuales para Apache, Ngix, MySQL, PHP, entre otros, que están disponibles en el repositorio oficial de Docker. La práctica recomendada es trabajar con varios contenedores conectados a una red común, cada uno proporcionando un servicio específico para el que fue diseñado. Aunque aún no hemos abordado cómo crear contenedores usando archivos Dockerfile ni cómo orquestarlos con Docker Compose, en este curso nos centraremos en la instalación de algunos servicios en contenedores mediante comandos de Docker, con fines didácticos. Los detalles sobre Dockerfile y Docker Compose se cubrirán en la próxima parte del curso para evitar una extensión excesiva en este artículo.
La instalación del servidor Apache HTTP ya la vimos en el artículo dedicado a la pila LAMP sobre Ubuntu. En el contenedor seguiremos los mismos pasos. Primero actualizar lista de paquetes de los repositorios y actualizar los instalados que lo requieran:
root@02fc04a18069:/# apt update && apt upgrade -y
# Si recibes algún error por tema de permisos prueba con: sudo apt update && sudo apt upgrade -y
Para instalar el servidor web Apache se usa el nombre apache2:
root@02fc04a18069:/# apt install apache2
Para verificar el estado del servicio usamos el comando:
root@02fc04a18069:/# service apache2 status
* apache2 is not running
Inicialmente aparece que no está en ejecución. Para arrancar el servicio:
root@02fc04a18069:/# service apache2 start
* Starting Apache httpd web server apache2
AH00558: apache2: Could not reliably determine the server's fully qualified domain name, using 172.17.0.2.
Set the 'ServerName' directive globally to suppress this message
Con esto se inicia el servicio web aunque sale un mensaje de advertencia que indica que Apache2 no puede determinar de forma fiable el nombre de dominio completamente cualificado del servidor y está utilizando la dirección IP 172.17.0.2 como sustituto. Esta Ip suele ser la Ip local predeterminada en contenedores. Además contamos también con el loopback 127.0.0.1 o "localhost". El loopback es una interfaz de red virtual que está presente en la mayoría de los sistemas operativos. Su propósito principal es permitir la comunicación entre procesos dentro del mismo dispositivo o entre diferentes aplicaciones que se ejecutan en el mismo sistema.
Desde dentro del contenedor, podemos comprobar que la página web predeterminada de Apache funciona con el comando curl
, aunque probablemente primero tendremos que instalarlo:
root@02fc04a18069:/# apt install curl
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
The following NEW packages will be installed:
curl
0 upgraded, 1 newly installed, 0 to remove and 0 not upgraded.
Una vez instalado, podemos usar la dirección de loopback o la dirección de IP local. Ambas darán el mismo resultado: la página web predeterminada de Apache que se encuentra en /var/www/html/index.html
.
Si quieres editar esta página web y darle un toque personal, tendrás que instalar el editor nano o cualquier otro que prefieras:
# Instalar nano
apt install nano
# Editar archivo web
nano /var/www/html/index.html
Estoy reemplazando la página web por defecto de Apache HTTP por esta otra para verificar que al hacer las pruebas del servidor realmente estoy consumiendo servicios web de este contenedor.
Es el turno para comprobar que todo funciona. Empezamos desde dentro del contenedor:
# por loopback
root@02fc04a18069:/# curl 127.0.0.1
<html>
<body>
<h1>Hola desde el contenedor Docker Ubuntu + Apache2</h1>
</body>
</html>
# por ip local del contenedor
root@02fc04a18069:/# curl 172.17.0.2
<html>
<body>
<h1>Hola desde el contenedor Docker Ubuntu + Apache2</h1>
</body>
</html>
Con esto queda demostrado que la web funciona desde dentro del contenedor. Pero, ¿cómo accedemos a la web desde el Host
que hospeda este contenedor? Salimos del contenedor con exit
y desde la terminal de OCI repetimos el curl a la ip local del contenedor.
ubuntu@ubuntusrv2:~$ curl 172.17.0.2
<html>
<body>
<h1>Hola desde el contenedor Docker Ubuntu + Apache2</h1>
</body>
</html>
Bueno, la página web también funciona. Es importante aclarar que este procedimiento de configuración del servidor web no es convencional, solo se realiza con fines didácticos. En el repositorio de Docker existen imágenes con servidores Apache, NGINX, Tomcat, MySQL, etc., por lo que no es necesario instalar estos paquetes en una imagen de servidor Linux. Incluso existe la imagen oficial ubuntu/apache2 creada por la empresa que está detrás del mantenimiento y desarrollo de Ubuntu. Además, en la mayoría de las aplicaciones, los contenedores y el host estarán en redes aisladas y, en cualquier caso, si es necesario acceder a algo dentro del contenedor desde el host, se hará a través del mapeo de puertos y no accediendo directamente a su red. En este ejemplo, los archivos de la web están dentro del contenedor. Tampoco esto es correcto en un proyecto de producción. Lo más común es que los archivos que componen la web, al igual que los archivos de bases de datos, estén en volúmenes externos para garantizar la persistencia de datos en caso de borrar o actualizar el contenedor.
Podemos crear nuestras propias imágenes adaptadas a nuestras necesidades para utilizarlas en otros entornos o compartirlas con otros usuarios. Hay 2 maneras de distribuir las imágenes:
docker commit
, docker save
y docker load
docker commit
y docker push
. Obvio decir que se necesita estar registrado en la web (gratis).
Para crear una nueva imagen a partir de un contenedor en ejecución, se utiliza el comando
docker commit
, que captura el estado actual del contenedor y lo guarda como una imagen.
Esta nueva imagen contendrá todos los cambios realizados en el contenedor, como la instalación de software adicional,
archivos de configuración y otros archivos. Usaremos el comando con 2 parámetros:
~$ docker commit 02 miubuntuhttp:v1
sha256:b33b1507e3734de0d5a2abb29502fdc2b0c9fd694aba0fddea57e6ca571619ec
Para guardar esa imagen en un archivo ".tar" con el comando docker save
ponemos como primer parámetro la imagen y como segundo el operador ">" seguido del nombre de la nueva imagen con extensión ".tar".:
~$ docker save miubuntuhttp:v1 > ubuntuserverhttp.tar
El archivo lo podemos distribuir y, cuando queramos cargar esa imagen al repositorio local, usaremos el comando docker load -i
seguido del nombre del archivo. El parámetro -i significa que la carga se hará a partir de un archivo, en lugar de la entrada estándar (stdin).
~$ docker load -i ubuntuserverhttp.tar
Loaded image: miubuntuhttp:v1
Si listamos las imágenes nos aparece la nueva recien creada:
~$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
miubuntuhttp v1 b33b1507e373 14 minutes ago 252MB
Ya podemos crear nuevos contenedores a partir de esta imagen personaliza que incluye el servicio HTTP y una sencilla web de ejemplo.
$ docker run -d -p 8081:80 --name nuevohttp miubuntuhttp:v1 apache2ctl -D FOREGROUND
ad64e3c1209ef6ef78ef72092f0e4a7c092a791c35773016b96afcf269aeb847
Los parámetros utilizados en el comando run son:
-d
: Indica que el contenedor se ejecutará en segundo plano, es decir, en modo demonio. De esta manera, una vez que el contenedor se inicia, no bloquea la terminal y puedes seguir utilizando la línea de comandos.
-p 8081:80
: Mapea el puerto 80 del contenedor al puerto 8081 del host. Esto significa que cualquier tráfico enviado al puerto 8081 del host será redirigido al puerto 80 del contenedor, permitiendo así que los servicios web en el contenedor sean accesibles desde el exterior.
--name nuevohttp
: Asigna el nombre "nuevohttp" al contenedor que se está creando.
miubuntuhttp:v1
: Especifica el nombre de la imagen de Docker que se utilizará para crear el contenedor..
apache2ctl -D FOREGROUND
: Este parámetro no se había comentado antes en el artículo. Si se omite el contenedor se arranca y para a continuación porque no hay ninguna tarea ni comando que realizar.
apache2ctl
es el comando de control de Apache y -D FOREGROUND
es una opción que indica a Apache que se ejecute en primer plano y permanezca en ejecución de
manera continua. Esto es importante para mantener el contenedor en ejecución.
Si consultamos los contenedores en ejecución, veremos que este último está levantado y también podemos acceder a la web que contiene.
~$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
ad64e3c1209e miubuntuhttp:v1 "apache2ctl -D FOREG…" 5 seconds ago Up 4 seconds 0.0.0.0:8081->80/tcp, :::8081->80/tcp nuevohttp
Yendo un paso más allá de lo que corresponde al curso de Docker, he abierto el puerto 8081 en la instancia OCI que aloja el servidor HTTP. El resultado es totalmente positivo al acceder desde el navegador a esta dirección con el puerto mapeado.
El contenedor también se puede crear a través de los comandos docker create
y docker start
de manera similar. La única diferencia es que no se usa el parámetro "-d". Un punto importante a tener en cuenta al crear contenedores es que deben tener un nombre único y no pueden compartir el puerto mapeado.
# Crear contenedor
docker create -p 8082:80 --name nuevohttp2 miubuntuhttp:v1 apache2ctl -D FOREGROUND
# Iniciar contenedor
docker start nuevohttp2
En este momento, hemos logrado implementar dos servidores web en contenedores separados, todos operando en el mismo host. Esto demuestra claramente el gran potencial y las numerosas ventajas de usar Docker, como la eficiencia en el uso de recursos, la facilidad de despliegue y la capacidad de aislar aplicaciones para un mejor rendimiento y seguridad.
Para usar la distribución desde hub.docker.com, debemos registrarnos. Con una cuenta gratuita, podemos cubrir nuestras necesidades para aprendizaje y proyectos personales.
Para subir una imagen, iniciamos sesión en el host que contiene la imagen a subir. Desde la terminal, introducimos docker login
, lo que nos pedirá el "usuario" y la "contraseña".
~$ docker login
Log in with your Docker ID or email address to push and pull images from Docker Hub.
Username: mi_usuario
Password: xxxx
WARNING! Your password will be stored unencrypted in /home/ubuntu/.docker/config.json.
Login Succeeded
Para subir la imagen, se utiliza el comando docker push
seguido del nombre de la imagen.
La imagen debe estar etiquetada con el nombre de usuario de Docker Hub. Para etiquetarla, se utiliza el comando docker tag
, colocando el nombre completo de la imagen como primer parámetro y el nuevo nombre de la imagen con la etiqueta como segundo parámetro. Siguiendo con el ejemplo de la imagen "miubuntuhttp:v1", el comando sería el siguiente:
~$ docker tag miubuntuhttp:v1 agriarte/miubuntuhttp:v1
Listamos las imágenes para comprobar el etiquetado. Ahora tenemos 2 imágenes idénticas de contenido, con mismo Id y por tanto, solo ocupa una vez el espacio de memoria. Difieren en el nombre:
docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
agriarte/miubuntuhttp v1 b33b1507e373 27 hours ago 252MB
miubuntuhttp v1 b33b1507e373 28 hours ago 252MB
Por último, se sube la imagen etiquetada con el usuario:
~$ docker push agriarte/miubuntuhttp:v1
The push refers to repository [docker.io/agriarte/miubuntuhttp]
cb250232ba26: Pushed
03fdf04efd9e: Pushed
v1: digest: sha256:9366639ecb797a0461b75b4d303e9487245634ddae5eeff8de123fa4aba3f683 size: 741
Ahora es posible subir la nueva imagen con el comando docker push agriarte/miubuntuhttp:v1
.
Siguiendo el ejercicio, borraré todos los contenedores creados a partir de la imagen y las imágenes también, para comprobar que se descargan correctamente. No muestro el código usado porque ya lo hemos visto varias veces durante este curso. Como sabrás, se usarán los comandos de Docker stop, rm y rmi. Una vez limpio de imágenes, procederemos a su descarga:
~$ docker pull agriarte/miubuntuhttp:v1
v1: Pulling from agriarte/miubuntuhttp
ca6fd4ca81e0: Already exists
f670ed93a66b: Pull complete
Digest: sha256:9366639ecb797a0461b75b4d303e9487245634ddae5eeff8de123fa4aba3f683
Status: Downloaded newer image for agriarte/miubuntuhttp:v1
docker.io/agriarte/miubuntuhttp:v1
La imagen se descarga y ya es visible en la lista de imágenes. Es importante fijarse en el detalle de que esta imagen se compone de dos capas con IDs diferentes. Al realizar el pull, me avisa de que una parte no necesita descargarse porque es común con otra imagen que ya está en mi repositorio local. Una vez más, Docker demuestra que optimiza el espacio evitando duplicidades de archivos.
Voy a borrar todas las imágenes que contiene mi repositorio local para mostrar cómo se comporta Docker
al ejecutar el docker run
que utilizado anteriormente para crear y
ejecutar el contenedor pero ahora no existe en local y tendrá que descargarse.
~$ docker run -d -p 8081:80 --name nuevohttp agriarte/miubuntuhttp:v1 apache2ctl -D FOREGROUND
Unable to find image 'agriarte/miubuntuhttp:v1' locally
v1: Pulling from agriarte/miubuntuhttp
ca6fd4ca81e0: Pull complete
f670ed93a66b: Pull complete
Digest: sha256:9366639ecb797a0461b75b4d303e9487245634ddae5eeff8de123fa4aba3f683
Status: Downloaded newer image for agriarte/miubuntuhttp:v1
7f2ae0feaec49a555e606e74c7dbac884d834e8e2c75708a9cf6ed0b96fb71c2
Nota que al nombre de la imagen creada le precede el nombre de usuario "agriarte/". La etiqueta "v1" es opcional, si se omite Docker buscará la etiquetada como "latest". De no hacerlo recibiremos el error de "Imagen no encontrada".
~$ docker run -d -p 8081:80 --name nuevohttp agriarte/miubuntuhttp apache2ctl -D FOREGROUND
Unable to find image 'agriarte/miubuntuhttp:latest' locally
docker: Error response from daemon: manifest for agriarte/miubuntuhttp:latest not found: manifest unknown: manifest unknown.
See 'docker run --help'.
Si queremos facilitar a los usuarios bajar la última versión sin que especifiquen la etiqueta, tendremos que crearla localmente y hacer un "push" al repositorio.
~$ docker tag agriarte/miubuntuhttp:v1 agriarte/miubuntuhttp:latest
Recuerda que para subir imágenes debes tener iniciada la sesión en hub.docker.com
~$ docker push agriarte/miubuntuhttp:latest
The push refers to repository [docker.io/agriarte/miubuntuhttp]
cb250232ba26: Layer already exists
03fdf04efd9e: Layer already exists
latest: digest: sha256:9366639ecb797a0461b75b4d303e9487245634ddae5eeff8de123fa4aba3f683 size: 741
Si consultamos las imágenes locales y las de nuestro repositorio, en ambos lugares aparece la imagen en sus versiones "v1" y "latest", aunque realmente se trata de la misma imagen.
~$ docker images
REPOSITORY TAG IMAGE ID CREATED SIZE
agriarte/miubuntuhttp latest b33b1507e373 40 hours ago 252MB
agriarte/miubuntuhttp v1 b33b1507e373 40 hours ago 252MB
Las imágenes quedan publicadas en Docker Hub así:
Finalmente, desde la web podemos rellenar el apartado "Overview" con instrucciones de uso y una descripción del contenido de la imagen, y clasificarla según el tema. Esto ayudará a informar al público en general y facilitará que la encuentren.
Para crear datos persistentes existen los volúmenes y los "bind mounts" (traducido literalmente como montajes enlazados). Los volúmenes son la forma preferida y recomendable para entornos de producción, mientras que los bind mounts ofrecen más flexibilidad en entornos de desarrollo o en algunos usos específicos. Aquí trataremos principalmente los volúmenes administrados por Docker, aunque cuando lleguemos al tema de copias y restauración de volúmenes haremos uso temporalmente de bind mounts.
Un volumen es un directorio del host visible desde el contenedor como si formara parte de él. Los volúmenes son gestionados completamente por Docker y se almacenan en un directorio específico en el host. Por defecto, los volumenes se guardan en /var/lib/docker/volumes y aunque se puede configurar para que sea otra ubicación, vamos a seguir el procedimiento lo más simple y estándar posible.
Al usar contenedores con volúmenes, tenemos dos escenarios. Uno en el que ya existe el contenedor y queremos añadirle un volumen, y otro escenario en el que al crear un nuevo contenedor tendrá volumen incorporado.
El comando de Docker para crear un volumen es docker volume create
, con el nombre que queremos darle al volumen como parámetro. Vamos a la terminal y escribimos:
$ docker volume create mivolumen_01
mivolumen_01
Docker creará automáticamente el directorio vacío en /mivolumen_01/_data dentro del host en el directorio por defecto /var/lib/docker/volumes.
Este volumen todavía no pertenece a ningún contenedor.
Para listar los volúmenes en el host:
$ docker volume ls
DRIVER VOLUME NAME
local mivolumen_01
Con el comando inspect obtenemos información útil del volumen:
$ docker inspect mivolumen_01
[
{
"CreatedAt": "2024-06-05T15:10:55Z",
"Driver": "local",
"Labels": null,
"Mountpoint": "/var/lib/docker/volumes/mivolumen_01/_data",
"Name": "mivolumen_01",
"Options": null,
"Scope": "local"
}
]
El siguiente paso es adjuntar el volumen a un contenedor. Quizás todavía conserves en tu host el contenedor que creamos anteriormente llamado "nuevohttp", creado a partir de la imagen "agriarte/miubuntuhttp:v1". Puedes usar este o cualquier otro contenedor para practicar cómo añadir un volumen a un contenedor que ya existe. En este ejemplo, voy a utilizar el contenedor "nuevohttp" que casualmente tengo ahora en ejecución.
El contenedor debe estar detenido. Procedemos:
$ docker stop nuevohttp
nuevohttp
Hacemos un commit para crear una nueva imagen a partir del contenedor. He llamado a la nueva imagen "nuevohttpconvolumen".
$ docker commit nuevohttp nuevohttpconvolumen
sha256:b072de4e0433a85122682bae05b1c5ce959b3ccda691675cc85453782f67c542
Al iniciar el contenedor con docker run, se creará a partir de la nueva imagen. Hemos añadido el parámetro -v mivolumen_01:/home/ubuntu, siendo "mivolumen_01" el volumen existente y "/home/ubuntu" el directorio del contenedor vinculado con el directorio del host. El resto de los parámetros ya los conocemos y los vimos anteriormente: el mapeo de puertos, ejecutar en segundo plano, etc. Todo es igual, lo único que cambia son los nombres.
NOTA: Si el volumen no existe, Docker lo creará automáticamente, por lo que el paso docker volume create si todavía no lo has ejecutado, lo puedes obviar.
$ docker run -d -p 8081:80 --name contenedor_con_volumen -v mivolumen_01:/home/ubuntu nuevohttpconvolumen apache2ctl -D FOREGROUND
eb54c2c125fd3254921e54a2735d2ea6b0ef8bc372a5591f83dfc38673745c6e
Verificamos que el contenedor se haya levantado:
$ docker ps -a
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
eb54c2c125fd nuevohttpconvolumen "apache2ctl -D FOREG…" 3 minutes ago Up 3 minutes 0.0.0.0:8081->80/tcp, :::8081->80/tcp contenedor_con_volumen
El volumen se encuentra vacío en el host. Recuerda que en este ejemplo es el directorio "/var/lib/docker/volumes/mivolumen_01/_data". Vamos a crear o copiar un archivo, y este debería aparecer también en el lado del contenedor:
Un detalle muy importante del directorio que forma el volumen es su propietario y los permisos de escritura. Por defecto, solamente el usuario "root" tiene acceso a este directorio. En un entorno real, debemos configurar permisos y propietarios de una manera más específica para evitar el uso de "root" y así no comprometer la seguridad. En Tallerdeapps.com encontrarás tutoriales que abordan los temas de usuarios, permisos y propietarios, y detallan cómo ajustar estas características con los comandos chmod y chown.
Aquí trataremos principalmente los volúmenes administrados por Docker, aunque cuando lleguemos al tema de copias y restauración de volúmenes haremos uso temporalmente de bind mounts.En este ejemplo, por sencillez, cambiaré al usuario "root" para poder escribir un archivo dentro del directorio de los volúmenes, a pesar de no ser del todo correcto. Solo por simplicidad.
Creamos un archivo de texto con echo y el operador de redirección >, y después cerraremos la sesión del usuario "root" para volver a otro usuario con menos privilegios:
ubuntu@ubuntusrv2:~$ sudo -i
root@ubuntusrv2:/home/ubuntu# echo "hola desde el host" > /var/lib/docker/volumes/mivolumen_01/_data/textohost.txt
root@ubuntusrv2:/home/ubuntu# cat /var/lib/docker/volumes/mivolumen_01/_data/textohost.txt
hola desde el host
root@ubuntusrv2:~# exit
logout
ubuntu@ubuntusrv2:~$
Estas operaciones eran en el lado del host. Pero, ¿se verán reflejadas desde el lado del contenedor? Lo veremos enseguida. Abrimos la consola del contenedor:
ubuntu@ubuntusrv2:~$ docker exec -it contenedor_con_volumen bash
root@eb54c2c125fd:/#
Y desde el contenedor intentamos mostrar por consola el archivo contenido en el volumen:
root@eb54c2c125fd:/# cat /home/ubuntu/textohost.txt
hola desde el host
¡Funciona! ¿Y qué pasaría si hacemos cambios en el directorio del contenedor? ¿Se verán reflejados en el host? Vamos a comprobarlo creando un archivo en el contenedor para tratar de acceder a él desde el host:
root@eb54c2c125fd:/# echo "hola desde el contenedor" > /home/ubuntu/textocontenedor.txt
root@eb54c2c125fd:/# exit
exit
ubuntu@ubuntusrv2:~$ sudo -i
root@ubuntusrv2:~# cat /var/lib/docker/volumes/mivolumen_01/_data/textocontenedor.txt
hola desde el contenedor
Ya hemos aprendido cómo utilizar volúmenes desde los contenedores. Con todo lo visto, ahora podemos crear un servidor web Apache con Docker que tenga los archivos del sitio web fuera del contenedor. Esto tiene la ventaja de que podemos cambiar o actualizar el servidor HTTP y hacerlo funcionar con la misma página web. Los volúmenes serán casi siempre el método elegido para guardar ficheros externos con datos.
Nos ha faltado comentar el uso de los "bind mounts", el otro tipo de volumen de Docker que la documentación oficial enseña para ser usado en la copia y restauración de volúmenes. De entrada, suena extraño. Un volumen se restaura en un "bind mount".
A lo largo del artículo, en al menos dos ocasiones hemos usado el comando commit para crear una imagen de un contenedor en su estado actual, que podemos usar como respaldo de los contenedores. Es crucial tener copias de seguridad de los volúmenes porque ahí es donde se encuentran las páginas web, bases de datos y otros archivos que utilizan los servicios del contenedor. En caso de tener que restaurar el host, poder desplegar los contenedores y sus datos con poco esfuerzo es fundamental.
Docker Desktop (la versión GUI) ya incorpora una opción de restauración. Desde la consola, existen algunas extensiones de terceros que ayudan en esta tarea. Aquí veremos la forma más "artesanal" que se explica en la documentación oficial.
En resumen, lo que proponen en Docker Docs es crear un contenedor temporal donde usaremos el comando de Linux tar para crear un archivo de backup comprimido con los volúmenes. El directorio donde se crea y guarda el archivo backup es un "bind mount". El directorio puede ser cualquiera que se elija en el host y estará "enlazado" con otro en el contenedor. Una vez creado y almacenado el backup se autoelimina el contenedor que lo creó. El archivo de backup, almacenado en el host, podrá ser restaurado en otros contenedores con sentencias de Docker.
De entrada, uno podría pensar que es extraño tener que crear un contenedor temporal cuando los volúmenes se encuentran en directorios dentro del host y podríamos crear el backup directamente desde el host sin usar un contenedor. Por ejemplo, ¿por qué no usar WinSCP o alguna otra herramienta de transferencia de archivos? ¿O quizás el comando cp -r [origen] [destino]
? La razón es que Docker gestiona los volúmenes y, desde el host, no siempre tenemos garantizado el acceso directo al directorio de volúmenes.
Tomando como base la documentación oficial de Docker Engine (es decir, Docker por terminal de comandos), adaptamos el comando para crear el archivo de backup a la configuración de nuestro servidor. Reconozco que la línea tiene cierta complejidad y me tomó varias lecturas entenderla en su totalidad:
$ docker run --rm --volumes-from contenedor_con_volumen -v $(pwd)/misvolumenesbackup:/backup ubuntu tar cvf /backup/backup.tar -C /home/ubuntu .
./
./textohost.txt
./.bashrc
./.profile
./.bash_logout
./textocontenedor.txt
Vamos a desglosar qué hace realmente cada parte de esta sentencia. :
Lo que falta es la ejecución del programa tar con sus parámetros para seleccionar el directorio de origen y destino y los archivos a comprimir:
Tras la sentencia, podemos comprobar que el directorio /misvolumenesbackup se ha creado (si no existía previamente) y que contiene la copia de seguridad.
$ tree /home/ubuntu/misvolumenesbackup/
misvolumenesbackup/
└── backup.tar
Para restaurar el volumen de un contenedor, la técnica utilizada es muy similar a la empleada para crear la copia de seguridad. Nuevamente, usaremos un contenedor temporal, pero esta vez para descomprimir el archivo con la copia de seguridad (llamado backup.tar en este ejemplo). Los pasos a seguir en una hipotética restauración total debido a fallo de sistema, migración o cualquier otra causa que nos obligue a crear el contenedor y el volumen desde cero son los siguientes:
create docker mivolumen_02
docker run -d -p 8080:80 --name nuevohttp_02 -v mivolumen_02:/home/ubuntu miubuntuhttp:v1 apache2ctl -D FOREGROUND
docker run --rm --volumes-from nuevohttp_02 -v /home/ubuntu/misvolumenesbackup:/backup ubuntu tar xvf /backup/backup.tar -C /home/ubuntu
El comando es largo y, aunque requiere una atenta lectura, la estructura principal es muy parecida a la vista anteriormente para crear el archivo backup.tar:
tar
para descomprimir la imagen. El parámetro para descomprimir es la x incluida en "xvf". "/backup/backup.tar" es la ruta local en el contenedor del archivo a descomprimir. Por último, "-C /home/ubuntu" es la ruta destino en el contenedor que, además, por ser un volumen, se encuentra físicamente en el host.
Con esto, tendríamos un contenedor completamente restaurado tanto a nivel de sistema como de datos. Aunque el ejemplo es sencillo y quizás poco realista, esta técnica se puede utilizar para restaurar servidores de bases de datos, servidores web o cualquier otro tipo de servicio. Esto nos da una idea del gran potencial que tiene Docker para desplegar sistemas con enorme rapidez y facilidad. Y eso que todavía estamos trabajando a un nivel muy básico, sin usar Dockerfiles ni plataformas avanzadas para el despliegue de contenedores como Kubernetes o Podman.
Para eliminar un volumen se usa el comando docker volume rm añadiendo como prámetro el nombre o ID del parámetro.
$ docker volume rm mivolumen_01
mivolumen_01
Es obligatorio que el volumen no esté en uso antes de ejecutar la sentencia o Docker dará un error. Si está montado en un contenedor, no será suficiente con detenerlo. También tendremos que eliminarlo. Si estamos seguros de que no está en uso y seguimos recibiendo el error, podemos intentar con el parámetro -f para forzar su borrado.
Las variables de entorno son pares clave-valor almacenados en el sistema operativo. Se utilizan para configurar y personalizar el entorno de ejecución de programas y scripts.
Una de las variables de entorno más comunes es PATH, la cual especifica una lista de directorios que el sistema operativo debe buscar para encontrar ejecutables.
Es importante aclarar que las variables de entorno no son exclusivas de los contenedores; forman parte de cualquier sistema operativo, ya sea en una máquina física, instancia virtual, contenedor, etc. Además, estas variables son completamente privadas de su entorno. Como ejemplo, comparemos el contenido de PATH en un contenedor y en su host.
root@b21a1a1b8000:/# echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
root@b21a1a1b8000:/# exit
exit
ubuntu@ubuntusrv2:~$ echo $PATH
/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/usr/games:/usr/local/games:/snap/bin:/usr/local/hestia/bin
En entornos Linux para mostrar la variable se pone delante el símbobo $ mientras que en Windows se usa el símbolo % delante y detrás de la variable.
Para crear una variable de entorno en un contenedor añadimos la opción -e o --env seguido del nombre de variable y su valor. Ejemplo:
ubuntu@ubuntusrv2:~$ docker run -it --name ubuntudemo -e CLAVE=1234 ubuntu bash
root@b5fa5bf33b44:/# echo $CLAVE
1234
Si tenemos muchas variables de entorno o queremos ocultar su valor, podemos usar un archivo con extensión .env donde añadimos los pares clave-valor de cada variable de entorno. En el siguiente ejemplo, creamos el archivo de texto usando el comando echo y las redirecciones de salida > y >> para crear el archivo y añadir contenido al final del archivo. Para crear el contenedor, usaremos la opción --env-file nombredearchivo.env. Finalmente, abrimos una sesión en el contenedor y con el comando linux env
mostramos por consola todas las variables de entorno.
# crear archivo con variable
ubuntu@ubuntusrv2:~$ echo "DB_HOST=localhost" > mi_env_file.env
# añadir una variable al final del archivo
ubuntu@ubuntusrv2:~$ echo "DB_USER=usuario" >> mi_env_file.env
# crear contenedor de ubuntu con con variables de entorno en un archivo
ubuntu@ubuntusrv2:~$ docker run -it --name ubuntudemo2 --env-file mi_env_file.env ubuntu bash
# en la sesión del contenedor mostramos las variables de entorno
root@5bc6c02a5b68:/# env
HOSTNAME=5bc6c02a5b68
PWD=/
DB_USER=usuario
HOME=/root
TERM=xterm
DB_HOST=localhost
SHLVL=1
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
_=/usr/bin/env
Las redes en Docker son un aspecto fundamental para que los contenedores puedan conectarse entre sí o con el mundo exterior. Docker proporciona las herramientas necesarias para crear y gestionar redes, garantizando flexibilidad en las comunicaciones. Si has seguido los ejemplos del curso, todos los contenedores creados hasta ahora han utilizado la red por defecto "bridge", aunque este dato no se había mencionado antes. Cuando se crea un contenedor, si no se especifica una red, este se conecta automáticamente a la red "bridge".
En Docker existen varios tipos de redes o "drivers", como los llama la documentación oficial:
Antes de poner en práctica algunos ejercicios comentamos por encima los principales comandos para trabajar con redes:
docker network connect mired micontainer
docker network disconnect mired micontainer
docker network create --driver bridge miredbridge
docker network inspect mired
docker network ls
docker network prune
docker network rm mired
Tras esta pequeña introducción teórica sobre redes Docker, vamos a examinar la red partiendo de un entorno limpio de imágenes y contenedores. Si has seguido las prácticas hasta aquí, borrando todo lo creado tendrás un entorno muy similar al mío.
Al listar las redes actuales con docker network ls obtenemos:
$ docker network ls
NETWORK ID NAME DRIVER SCOPE
35c4c5b9eea8 bridge bridge local
a0d9179948ca host host local
e3d87d772646 none null local
Para conocer con más detalle las características de una red:
$ docker inspect bridge
[
{
"Name": "bridge",
"Id": "a4de55198a8e55e44c0479a9aa316e505b5561fa81492b60bb29ba7018b78e57",
"Created": "2024-06-27T08:16:04.51516154Z",
"Scope": "local",
"Driver": "bridge",
"EnableIPv6": false,
"IPAM": {
"Driver": "default",
"Options": null,
"Config": [
{
"Subnet": "172.17.0.0/16",
"Gateway": "172.17.0.1"
}
]
},
"Internal": false,
"Attachable": false,
"Ingress": false,
"ConfigFrom": {
"Network": ""
},
"ConfigOnly": false,
"Containers": {},
"Options": {
"com.docker.network.bridge.default_bridge": "true",
"com.docker.network.bridge.enable_icc": "true",
"com.docker.network.bridge.enable_ip_masquerade": "true",
"com.docker.network.bridge.host_binding_ipv4": "0.0.0.0",
"com.docker.network.bridge.name": "docker0",
"com.docker.network.driver.mtu": "1500"
},
"Labels": {}
}
]
Esta información es muy útil para conocer el rango de red, la gateway, el driver utilizado y más detalles. Si esta red tuviera contenedores o estuviera conectada con otras redes, también se reflejaría en el informe.
Para crear una nueva red, simplemente usaremos el comando docker network create y pasaremos un nombre para la red. Si no especificamos el driver, será de tipo bridge de manera predeterminada.
$ docker network create mired_01
02b7db88d8409304d3f26848ebe1ec6518018e8602ed765f15478fdaaa9f26fd
Para tratar con más profundidad las redes en Docker, utilizaremos un entorno multicontenedor que desarrollaremos a continuación.
Todos los contenedores que hemos creado a lo largo de este artículo estaban dentro de la red predeterminada "bridge". En este proyecto, los contenedores estarán aislados en una nueva red de tipo "bridge" creada por nosotros. Voy a aprovechar la red "mired_01" que se mencionó anteriormente en el artículo. En principio, vamos a crear dos contenedores en esta red. Uno será un servidor Apache con una pequeña web en PHP, y el otro contenedor tendrá MySQL o el motor de bases de datos que prefieras. Además, se crearán volúmenes con los archivos de la web y de la base de datos.
El siguiente esquema ilustra la idea del proyecto:
Antes de ejecutar el comando "docker run" para crear el contenedor, preparo un directorio en el host que contendrá una simple página web de prueba en PHP para verificar que el contenedor de Apache con PHP funcione correctamente.
Anteriormente comentamos que es recomendable utilizar volúmenes para alojar datos persistentes, como los archivos de una página web o una base de datos.
Para la ubicación del directorio del volumen del contenedor, he decidido crear un directorio llamado "/contenedor_phpweb_01". A su vez, dentro de este, crearé otro directorio llamado "/www" para alojar el sitio web. Todo esto se ubicará dentro del directorio personal del usuario con el que me conecto a la instancia de Oracle Cloud.
Desde la terminal, una vez ubicados en la carpeta "home" del usuario, introducimos los siguientes comandos para crear la estructura de directorios y situarnos en el directorio creado.
ubuntu@ubuntusrv2:~$ mkdir -p contenedor_phpweb_01/www
ubuntu@ubuntusrv2:~$ cd contenedor_phpweb_01/www
ubuntu@ubuntusrv2:~/contenedor_phpweb_01/www$
La organización de archivos debe quedar así:
ubuntu@ubuntusrv2:~$ tree
.
├── contenedor_phpweb_01
└── www
En este directorio "www" recién creado alojaremos la página web. Como primer ejemplo, escribiremos una sencilla página que servirá para probar que el servidor web funciona con el lenguaje PHP.
Entre las muchas maneras de crear el archivo, o incluso descargarlo de otra ubicación, elijo crear un archivo nuevo llamado index.php con mi editor favorito "nano" y pegar el siguiente código:
Si quieres pegar el código te lo facilito:
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Saludo y PHP Info</title>
</head>
<body>
<h1>Mini web en PHP</h1>
<?php
// Mostrar un saludo en negrita
echo "<b>¡Hola, Mundo!</b>";
// Imprimir información de PHP
phpinfo();
?>
</body>
</html>
Llega el momento de buscar la imagen base en hub.docker.com. Tenemos muchas opciones de imágenes que incluyen servidor web Apache y PHP. Las hay oficiales y hechas por usuarios o desarrolladores independientes. Incluso podríamos crear una nosotros mismos de la misma forma que aprendimos para hacer un servidor web con Ubuntu.
En el campo de búsqueda, ingresamos "php" y en las imágenes mostradas seleccionamos la oficial de PHP, que a mí me aparece primero. Entramos en la pestaña "Tags" y nos muestra tal cantidad de imágenes que nos obliga a usar el campo de "Filtros" para acotar la respuesta. Filtrando por "Apache y Ubuntu" no encontramos nada. Al parecer, la mayoría de los contenedores están basados en Alpine, una distro de Linux muy popular por su ligereza y bajo consumo de recursos. También encontraremos muchas imágenes basadas en Debian (muy similar a Ubuntu), un sistema muy estable, recomendable para producción y altamente compatible con la mayoría del software.
He decidido filtrar por la palabra "Apache" para buscar imágenes basadas en Debian. No esperes encontrar la palabra "Debian" en el nombre de la etiqueta, ya que suelen usar el nombre de la versión para referirse a la última versión estable. Al momento de escribir estas líneas, la versión 12, llamada "Bookworm", es la más reciente versión estable. Si prefieres una versión anterior para mantener mayor compatibilidad con otro software y evitar posibles incompatibilidades al utilizar la versión más reciente, elige la versión 11, llamada "Bullseye". Como curiosidad, Debian utiliza nombres de personajes de la película Toy Story para nombrar sus versiones.
Debian es una excelente opción si estás familiarizado con Ubuntu. Por otro lado, Alpine es muy recomendable si tienes limitaciones de recursos, aunque presenta el inconveniente de ser menos compatible, por lo que necesitarás familiarizarte con su sistema.
Al elegir una imagen, también es importante considerar factores como el tiempo transcurrido desde la última actualización y las vulnerabilidades detectadas. En mi caso, he decidido optar por php:apache-bookworm, que en junio de 2024 me ha parecido la opción más equilibrada en términos de compatibilidad, estabilidad y seguridad. Esta imagen incluye Debian 12, el servidor Apache y PHP 8.3.10.
Fíjate en la columna "Digest": las etiquetas "apache-bookworm" y "apache" comparten el mismo identificador único (hash). Esto significa que se trata de la misma imagen etiquetada de manera diferente. Con esta etiqueta, tanto si optas por "apache" como por "apache-bookworm", descargarás la misma versión. Esto indica que "apache" es la última versión estable basada en Debian 12. En el futuro, "apache" podría corresponder a nuevas versiones de Debian o, quizás, a otra distribución Linux que se elija como la predeterminada para Apache.
La sentencia run
incluye una serie de parámetros que ya hemos comentado anteriormente en otros contenedores creados.
Presta mucha atención al directorio desde el cual se ejecuta la sentencia. El contenedor enlaza el directorio por defecto de Apache con el directorio actual en el que nos encontramos, utilizando la variable de entorno "$PWD". Por lo tanto, debemos estar dentro del directorio "~/contenedor_phpweb_01/www". De no hacerlo, configuraremos el contenedor para que muestre una página web en un directorio equivocado.
ubuntu@ubuntusrv2:~/contenedor_phpweb_01/www$ docker run -d --name miservidorphp \
--network mired_01 \
-v "$PWD":/var/www/html \
-p 8080:80 \
php:apache-bookworm
Unable to find image 'php:apache-bookworm' locally
apache-bookworm: Pulling from library/php
aa6fbc30c84e: Pull complete
3b0c74a3f697: Pull complete
0538d13b6c86: Pull complete
1e2f923a1b30: Pull complete
19a3613229c8: Pull complete
d02e54bc2f72: Pull complete
e80d480f72e9: Pull complete
897b50a15cf8: Pull complete
15f9a3beb2da: Pull complete
36163cf6096e: Pull complete
d5204f8e6efa: Pull complete
8c4535ce4f9b: Pull complete
7a2f60d41544: Pull complete
Digest: sha256:bec863a30ab37f03d5ef0b238a1cfbdac2f695a809bd0db485f4a59f699adeda
Status: Downloaded newer image for php:apache-bookworm
43dd12ea3ff12a1144cb08ad94487147c40e88b358a1fe3cfaee07c9799eb66f
ubuntu@ubuntusrv2:~/contenedor_phpweb_01/www$
Repasamos que hace cada parámetro:
"$PWD"
, que es una variable de entorno del host que representa el directorio de trabajo actual) en el directorio /var/www/html
dentro del contenedor. Recuerda que los bind mounts los trabajamos anteriormente, el primer directorio es del host y el segundo del contenedor, estando ambos enlazados.Observa que no he añadido en la sentencia run
el comando para levantar el servicio httpd porque esta imagen ya viene preparada para que el servicio web esté levantado por defecto. En cambio, en los primeros contenedores de apache que creamos al principio de este artículo, se incluía apache2ctl -D FOREGROUND
al crear el contenedor.
Muchas imágenes de Docker están en constante actualización y precisamente esta de PHP con Apache es una de ellas. Estaba redactando el texto de esta sección usando PHP con Apache sobre Debian 11 como última versión para producción y apareció la 12. Este es un caso práctico para demostrar las ventajas de usar contenedores con volúmenes externos, porque podríamos cambiar el contenedor por otro más reciente con muy pocas operaciones. Los archivos web, bases de datos, etc. seguirían en el mismo sitio del host y solo se cambiaría el contenedor que brinda los servicios.
Nos falta comprobar que el servidor web está activo y muestra la página web que guardamos en el volumen. La manera más directa y definitiva sería poner en un navegador con salida a Internet la ip_pública_del_Host:8080
Si no te funciona y no recibiste ningún error al crear el contenedor, puedes hacer algunas comprobaciones desde la consola del contenedor para verificar que el servicio web está activo y que el contenido del volumen está en el directorio predeterminado de la página web. Si esto está correcto, lo siguiente que yo comprobaría es si el puerto de escucha de la web está abierto en el cortafuegos del Host. Anteriormente, vimos cómo crear "Grupos de Seguridad" en Oracle Cloud. Debes permitir el puerto 8080 o el que hayas elegido.
Los contenedores muchas veces son minimalistas y comandos de control de servicios pueden no estar disponibles.
En este caso, para comprobar si Apache está levantado no funcionan los comandos habituales
systemctl status apache2
ni apache2ctl status
. En su lugar podemos usar ps aux
, poderosa herramienta en sistemas Unix y Linux para mostrar información sobre los procesos en ejecución. El parámetro aux indica:
ubuntu@ubuntusrv2:~/contenedor_phpweb_01/www$ docker exec -it miservidorphp bash
root@3afd7a06a1aa:/var/www/html# ps aux
USER PID %CPU %MEM VSZ RSS TTY STAT START TIME COMMAND
root 1 0.0 0.1 81632 23832 ? Ss 18:07 0:00 apache2 -DFOREGROUND
www-data 17 0.0 0.1 81876 15312 ? S 18:07 0:00 apache2 -DFOREGROUND
www-data 18 0.0 0.1 81828 15312 ? S 18:07 0:00 apache2 -DFOREGROUND
www-data 19 0.0 0.0 81712 7912 ? S 18:07 0:00 apache2 -DFOREGROUND
www-data 20 0.0 0.1 81720 14940 ? S 18:07 0:00 apache2 -DFOREGROUND
www-data 21 0.0 0.1 81720 14940 ? S 18:07 0:00 apache2 -DFOREGROUND
www-data 22 0.0 0.1 81828 15276 ? S 18:07 0:00 apache2 -DFOREGROUND
root 64 0.1 0.0 3952 3052 pts/0 Ss 18:51 0:00 bash
root 69 0.0 0.0 6440 2464 pts/0 R+ 18:51 0:00 ps aux
En la salida de ps aux
vemos referencias a servicios apache2 que prueban que el servicio está levantado.
También podemos probar con el comando curl localhost
para hacer solicitudes HTTP y probar el servidor.
root@3afd7a06a1aa:/var/www/html# curl localhost
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Saludo y PHP Info</title>
</head>
<body>
<h1>Mini web en PHP</h1>
.
.
.
Como puedes ver, crear un servidor web con PHP es sumamente sencillo usando Docker. El siguiente paso es configurar otro contenedor para interactuar con una base de datos.
En hub.docker.com tenemos imágenes base para una gran variedad de bases de datos. La elección de una u otra dependerá de las necesidades del proyecto. En este sencillo ejemplo, vamos a optar por MariaDB, una versión totalmente libre y abierta del conocido MySQL. Hemos elegido MariaDB porque tanto el motor de la base de datos como el sistema operativo Ubuntu son totalmente software libre. Podríamos usar un contenedor MySQL, pero generalmente está basado en una imagen de Oracle Linux (una distribución basada en Red Hat Enterprise Linux, RHEL), y tanto la base de datos como el sistema operativo pueden tener limitaciones si se utilizan sin pagar una licencia. Mi objetivo es mostrar cómo usar bases de datos MySQL o compatibles, ya que son bases de datos relacionales muy utilizadas, con gran documentación y soporte en internet. Además, son aplicables en una multitud de aplicaciones web. Actualmente, también goza de gran popularidad MongoDB, una base de datos no relacional con un diseño flexible que almacena los datos en formato JSON. Como esto no es un curso de bases de datos, no hay espacio para profundizar más en el tema.
Al elegir una imagen de MariaDB, no tendremos tantas dudas ni opciones como las que surgieron al seleccionar un contenedor con PHP. Los primeros resultados recientes ya nos ofrecen imágenes de MariaDB sobre Ubuntu, que es el sistema con el que estoy más familiarizado. La versión "Latest" me encaja perfectamente: ha sido actualizada recientemente y se ejecuta sobre Ubuntu. Quizás, como aspecto negativo, parece que tiene algunas vulnerabilidades, pero al consultar otras versiones, parece que todas las tienen actualmente.
Un detalle que no se ha mencionado, pero que seguramente ya habrás descubierto, es que al pulsar sobre el ID del "Digest" aparece toda la secuencia de comandos del Dockerfile. Ahí podemos encontrar información como la versión de MariaDB y el sistema operativo que incluye. En este caso, se trata de Ubuntu 24.04 con MariaDB 11.4.3.
Insisto nuevamente en la recomendación, que casi se convierte en una obligación, de crear un volumen para garantizar que los datos sean persistentes. De esta manera, no perderemos las bases de datos aunque se borre el contenedor. Nombré este volumen "mariadb_ordenadoresretro" porque planeo alojar una pequeña colección de ordenadores domésticos de los años 80 del siglo pasado (una reflexión sobre lo viejo que soy). La sentencia es bastante sencilla:
docker volume create mariadb_ordenadoresretro
En este punto, tenemos al menos dos escenarios posibles: que queramos usar una base de datos que tenemos en un archivo o que no dispongamos de una base de datos y necesitemos crearla desde cero.
Si ya disponemos de una base de datos exportada en un archivo con extensión .sql, podremos importarlo al contenedor de MariaDb con unas operaciones que veremos más adelante.
En este caso, vamos a partir del supuesto de que no tenemos ninguna base de datos y la crearemos mediante código SQL. Para ello, será necesario crear un archivo llamado init.sql en el directorio desde donde ejecutamos el comando docker run. La ubicación del archivo init.sql no es obligatoria. Si lo prefieres, puedes usar otro directorio. Yo lo hago así porque, al introducir el parámetro en la sentencia docker run, utilizo el directorio actual con la variable de entorno $PWD para indicar la ruta al archivo init.sql.
Aunque pueda parecer evidente, es importante aclararlo, ya que hay detalles que, cuando se revisan después de cierto tiempo, pueden generar dudas sobre por qué se hizo de esa manera.
El archivo init.sql contendrá el código SQL necesario para crear una base de datos y una tabla, si no existen previamente. A continuación, insertará varios registros con 2 campos.
CREATE DATABASE IF NOT EXISTS mibasededatos_pruebas01
CHARACTER SET utf8mb4
COLLATE utf8mb4_spanish_ci;
USE mibasededatos_pruebas01;
CREATE TABLE IF NOT EXISTS ordenadoresretro (
id INT AUTO_INCREMENT PRIMARY KEY,
ordenador VARCHAR(100) NOT NULL,
año INT NOT NULL
) CHARACTER SET utf8mb4
COLLATE utf8mb4_spanish_ci;
INSERT INTO ordenadoresretro (ordenador, año) VALUES
('Sinclair ZX Spectrum', 1982),
('Commodore 64', 1982),
('Amstrad CPC 464', 1984),
('Apple Macintosh', 1984),
('Atari ST', 1985),
('Commodore Amiga 500', 1987);
Este sencillo código SQL se entiende fácilmente con solo leerlo. Crea la base de datos "mibasededatos_pruebas01" con una tabla llamada "ordenadoresretro", a la que añade 5 registros en 2 columnas, que contienen el nombre del ordenador y el año de lanzamiento. Quizás se podría mejorar para evitar la duplicación de registros si se ejecuta repetidamente la sentencia docker run
. Por el momento, cumple su función, que es la de crear una base de datos de ejemplo.
En este punto, tenemos un volumen y un script para la creación de la base de datos. Ya podemos crear el contenedor de la imagen mariadb:latest.
docker run -d --name mibasededatos \
--network mired_01 \
-e MYSQL_ROOT_PASSWORD=rootpassword \
-e MYSQL_DATABASE=ordenadoresretro \
-e MYSQL_USER=usuario \
-e MYSQL_PASSWORD=contrasenya \
-v mariadb_ordenadoresretro:/var/lib/mysql \
-v "$PWD/init.sql":/docker-entrypoint-initdb.d/init.sql \
mariadb:latest
Repasamos que hace cada parámetro:
/var/lib/mysql
, dentro del contenedor.
Al ejecutar la sentencia docker run
, se iniciará el servicio MariaDB, que servirá una base de datos almacenada en un volumen. Este volumen estará vinculado al contenedor en el directorio predeterminado para bases de datos, que en MariaDB y MySQL es /var/lib/mysql
dentro del contenedor. En el host, los datos se guardarán en /var/lib/docker/volumes/mariadb_ordenadoresretro/_data
.
Nuestro siguiente objetivo es modificar la página web creada anteriormente para que se conecte a la base de datos y la muestre en pantalla.
A nivel de red, los dos contenedores son visibles entre sí y pueden comunicarse. Sin embargo, pronto descubriré un problema no previsto al crear contenedores complejos que requieren muchos parámetros y modificaciones sin utilizar un archivo de configuración Dockerfile. Si aún insisto en crear contenedores sin Dockerfile, es simplemente por fines didácticos. Seguramente, la solución planteada en este ejercicio no es la más cómoda, pero servirá para entender cómo funciona Docker y para que valoremos mucho más lo conveniente que es crear contenedores complejos con un Dockerfile.
En PHP, las bases de datos se pueden gestionar utilizando la clásica librería o extensión "mysqli" o la más moderna PDO_MYSQL. Existen otras extensiones para conectar con bases de datos, pero estas dos son las más comunes y, además, están recomendadas en la documentación oficial de PHP. Esperaba que algunas de estas extensiones vinieran instaladas de manera predeterminada con la imagen oficial de PHP, pero no fue así.
Como primera opción, vamos a intentar conectar usando la extensión moderna "PDO_MYSQL". Podemos modificar el archivo index.php que ya teníamos o crear uno nuevo llamado "index2.php". La tarea parece fácil. Desde el directorio web, escribo nano index2.php
y copio el código PHP que ya tenía previamente preparado. Es un código lo más sencillo posible: realiza la conexión a la base de datos y, a continuación, lee los registros uno a uno, rellenando una tabla HTML.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contenedor PHP</title>
</head>
<body>
<h1>Mostrar ordenadores retro</h1>
<p>Lectura de datos con extensión "pdo_mysql" desde un contenedor PHP a otro contenedor MariaDB</p>
<?php
// Conexión a la base de datos con PDO_MYSQL
$dsn = "mysql:host=mibasededatos;dbname=mibasededatos_pruebas01;charset=utf8mb4";
$username = "root"; // Cambia esto si es necesario
$password = "rootpassword"; // Cambia esto si es necesario
try {
$pdo = new PDO($dsn, $username, $password);
$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
// Consulta para obtener los datos de la tabla ordenadoresretro
$stmt = $pdo->query("SELECT ordenador, año FROM ordenadoresretro");
echo "<table border='1'>";
echo "<tr><th>Ordenador</th><th>Año</th></tr>";
while ($row = $stmt->fetch(PDO::FETCH_ASSOC)) {
echo "<tr><td>" . htmlspecialchars($row['ordenador']) . "</td><td>" . htmlspecialchars($row['año']) . "</td></tr>";
}
echo "</table>";
} catch (PDOException $e) {
echo "Error: " . $e->getMessage();
}
?>
</body>
</html>
El resultado no es el que esperaba. Parece que no tenemos el driver "pdo_mysql" instalado en el contenedor.
Si el driver "pdo_mysql" no está disponible, es posible que el driver "mysqli", más clásico y tradicional, sí esté presente. Para no borrar la primera prueba con la conexión "pdo_mysql", crearé un nuevo archivo PHP y así podré probar ambas extensiones. En el directorio web del servidor, crearé el archivo index3.php
usando code
, que se encargará de conectar y mostrar todos los registros en la web utilizando la extensión "mysqli":
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Contenedor PHP</title>
</head>
<body>
<h1>Mostrar ordenadores retro</h1>
<p>Lectura de datos con extensión "mysqli" desde un contenedor PHP a otro contenedor MariaDB</p>
<?php
// Conexión a la base de datos con mysqli
$host = "mibasededatos";
$dbname = "mibasededatos_pruebas01";
$username = "root"; // Cambia esto si es necesario
$password = "rootpassword"; // Cambia esto si es necesario
// Crear conexión
$mysqli = new mysqli($host, $username, $password, $dbname);
// Verificar la conexión
if ($mysqli->connect_error) {
die("Conexión fallida: " . $mysqli->connect_error);
}
// Consulta para obtener los datos de la tabla ordenadoresretro
$query = "SELECT ordenador, año FROM ordenadoresretro";
if ($result = $mysqli->query($query)) {
echo "<table border='1'>";
echo "<tr><th>Ordenador</th><th>Año</th></tr>";
// Fetch and display the results
while ($row = $result->fetch_assoc()) {
echo "<tr><td>" . htmlspecialchars($row['ordenador']) . "</td><td>" . htmlspecialchars($row['año']) . "</td></tr>";
}
echo "</table>";
// Free result set
$result->free();
} else {
echo "Error: " . $mysqli->error;
}
// Cerrar la conexión
$mysqli->close();
?>
</body>
</html>
El código es correcto; como dirección de host, podemos usar la IP del contenedor o su nombre. Sin embargo, aparece un error indicando que no se encuentra la clase "mysqli". Tenemos un problema, ya que ni la clase "mysqli" ni "pdo_mysql" vienen preinstaladas en la imagen oficial de PHP disponible en Docker Hub.
Para operar con bases de datos MySQL necesitamos al menos una de las dos extensiones: `mysqli` o `pdo_mysql`. Este problema no lo esperaba; pensaba que era posible conectar con la base de datos de forma predeterminada, pero me ha llevado a investigar un poco más. Ahora, voy a crear un nuevo archivo web llamado "index4.php" para mostrar en pantalla las extensiones que vienen preinstaladas en el contenedor PHP.
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Extensiones PHP</title>
</head>
<body>
<h1>Mostrar extensiones PHP instaladas</h1>
<?php
// Verifica específicamente si la extensión mysqli está cargada
if (extension_loaded('mysqli')) {
echo "<p style='color: green;'>La extensión <b>mysqli</b> está cargada.</p>";
} else {
echo "<p style='color: red;'>La extensión <b>mysqli</b> NO está cargada.</p>";
}
// Verifica si la extensión PDO_MYSQL está cargada
if (extension_loaded('pdo_mysql')) {
echo "<p style='color: green;'>La extensión <b>pdo_mysql</b> está cargada.</p>";
} else {
echo "<p style='color: red;'>La extensión <b>pdo_mysql</b> NO está cargada.</p>";
}
// Muestra todas las extensiones cargadas en PHP
$extensions = get_loaded_extensions();
echo "<ol>";
foreach ($extensions as $extension) {
echo "<li>" . $extension . "</li>";
}
echo "</ol>";
// Imprimir información de PHP
phpinfo();
?>
</body>
</html>
Esta web confirma lo que parece evidente. Ninguna de las 2 extensiones vienen preinstaladas en el contenedor PHP.
En mis primeros intentos, traté de instalar los paquetes y reiniciar el servicio apache2
dentro del contenedor para que los cambios se aplicaran. Sin embargo, experimenté un problema en el que el contenedor se cerraba inesperadamente cada vez que reiniciaba el servicio. Se ponía en estado "Exited" y no era posible levantarlo. No estoy seguro si el problema se debe a una configuración incorrecta, a problemas con el contenedor en sí o a conflictos entre servicios.
Otro detalle que descubrí al consultar foros como Stack Overflow es que, cuando se trabaja con contenedores, es preferible utilizar comandos específicos de Docker, como docker-php-ext-install
, para instalar extensiones en lugar de recurrir a apt-get
. Estos comandos están diseñados específicamente para asegurar una mayor compatibilidad con el entorno del contenedor, lo que ayuda a evitar conflictos de versiones y otros problemas que pueden surgir al instalar paquetes de forma manual o desde repositorios genéricos. De hecho, experimenté numerosos problemas al intentar encontrar repositorios compatibles con la versión de mi contenedor, hasta el punto de que parecía una tarea casi imposible.
Como sigo intentando realizar todo sin usar un Dockerfile, la solución que encontré para resolver el problema de las detenciones inesperadas del contenedor fue hacer un commit
del contenedor justo después de instalar las extensiones necesarias para PHP y MySQL. Esto me permitió crear una nueva imagen que incluía todas las modificaciones realizadas. Posteriormente, recreé el contenedor a partir de esta nueva imagen. De este modo, al iniciar un nuevo contenedor con docker run
, las extensiones necesarias ya estaban activas desde el principio, evitando así la necesidad de reinstalarlas manualmente cada vez que se creaba un contenedor nuevo.
FUNCIONA PARA INSTALAR SQL EN PHP
Debemos abrir sesión en el contenedor y aplicar los comandos
$ docker exec -it miservidorphp bash
root@c44b9fc46289:/var/www/html# docker-php-ext-install mysqli pdo_mysql
# salir del contenedor
root@c44b9fc46289:/var/www/html# exit
Desde el host hacer el commit para crear la nueva imagen
$ docker commit miservidorphp imagen_phpsql
En estos momentos ya tengo la nueva imagen PHP adaptada para que incluya las extensiones necesarias para conectarse a bases de datos mysql. Por tanto, puedo parar y borrar el contenedor "miservidorphp" ya que no lo necesito.
$ docker stop miservidorphp
$ docker rm miservidorphp
Finalmente, puedo crear el nuevo contenedor de la imagen personalizada. No olvides ejecutar la sentencia desde el directorio donde se encuentra la página web.
ubuntu@ubuntusrv2:~/contenedor_phpweb_01$ docker run -d --name miservidorphp \
--network mired_01 \
-v "$PWD" :/var/www/html \
-p 8080:80 \
imagen_phpsql
Y ahora si están disponibles las extensiones:
Y se muestra la base de datos tanto en la web que usa mysqli como pdo_mysql:
Ha sido una tarea algo complicada debido a que no seguí los procedimientos habituales, como usar un Dockerfile. Al desviarme de las prácticas recomendadas, fue difícil encontrar documentación clara que lo explique. En el próximo artículo, veremos cómo lograr lo mismo utilizando un Dockerfile y lo sencillo que es crear entornos multicontenedor con Docker Compose. Quiero recalcar que esto ha sido más un experimento con fines didácticos que un desarrollo estándar orientado a producción.
Un último apunte antes de concluir esta parte es mencionar que tener dos contenedores corriendo en su propia red es una buena oportunidad para probar los comandos de Docker relacionados con redes y comprobar la conectividad entre los contenedores y el host. No profundizaremos en estos detalles ahora, solo quiero mostrar el resultado del comando docker network inspect mired_01
. Este comando muestra las IPs locales, lo que permite verificar si hay conexión entre los contenedores, ya sea por IP, resolución de nombres, o para realizar cualquier otra prueba que se nos ocurra.
docker network inspect mired_01
[
{
"Name": "mired_01",
"Id": "02b7db88d8409304d3f26848ebe1ec6518018e8602ed765f15478fdaaa9f26fd",
"Created": "2024-06-04T19:14:48.209943376Z",
"Scope": "local",
"Driver": "bridge",
"EnableIPv6": false,
"IPAM": {
"Driver": "default",
"Options": {},
"Config": [
{
"Subnet": "172.18.0.0/16",
"Gateway": "172.18.0.1"
}
]
},
"Internal": false,
"Attachable": false,
"Ingress": false,
"ConfigFrom": {
"Network": ""
},
"ConfigOnly": false,
"Containers": {
"60eb4a724b83cd53ef4e986e7f6df785dead3b90d01fe9392a99e65dadccd410": {
"Name": "mibasededatos",
"EndpointID": "ef815c78e46fc404dd4019bbbac32a76639d9666885b808fc9dbcf2a938e5cd6",
"MacAddress": "02:42:ac:12:00:03",
"IPv4Address": "172.18.0.3/16",
"IPv6Address": ""
},
"c44b9fc46289440e1db64f4df3c93390144a9e7dcbcba85459fc5cf6131733ff": {
"Name": "miservidorphp",
"EndpointID": "594cc06a2c94f2584aa162152f9b0667a507e9f7727bc776ae354587b3f2550e",
"MacAddress": "02:42:ac:12:00:02",
"IPv4Address": "172.18.0.2/16",
"IPv6Address": ""
}
},
"Options": {},
"Labels": {}
}
]
En el artículo sobre la creación de entornos multicontenedor con Dockerfile y Docker Compose, retomaremos el tema de las redes.