En este artículo vamos a usar buildx para crear imágenes de Docker para distintas arquitecturas de CPU (mientras la imagen base lo permita) usando qemu y publicándolas como una sola imagen a DockerHub.
PROMO DigitalOcean#
Antes de comenzar, quería contarles que hay una promoción en DigitalOcean donde te dan un crédito de USD 200.00 durante 60 días para que puedas probar los servicios que este Proveedor Cloud ofrece. Lo único que tienes que hacer es suscribirte a DigitalOcean con el siguiente botón:
O a través del siguiente enlace: https://bit.ly/digitalocean-itsm
Proyecto de Ejemplo#
Imaginemos que tenemos un proyecto que se ejecuta en distintos servidores con distintas arquitecturas de CPU (arm64, ppc, etc) y queremos pasarlo a Docker. Queremos que esta imagen resultante se ejecute en las siguientes arquitecturas:
- linux/amd64
- linux/arm64/v8
- linux/arm/v7
- linux/arm/v6
- linux/ppc64le
- linux/s390x
La construcción de esta imagen la podemos realizar en paralelo para cada arquitectura de CPU, pero solo publicando la imagen con todas estas arquitecturas en una sola.
Aqui tenemos una aplicación de ejemplo que es un servidor que solo muestra un hello world en nodejs:
// app.js
const http = require('http');
const port = 3000;
const server = http.createServer((req, res) => {
res.statusCode = 200;
res.setHeader('Content-Type', 'text/plain');
res.end('Hola Mundo');
});
server.listen(port, () => {
console.log(`Servicio ejecutándose en %j`, server.address());
});
La guardamos como app.js y procedemos a escribir su Dockerfile:
FROM node:16-alpine
RUN apk add --no-cache tini
ENTRYPOINT ["/sbin/tini", "--"]
WORKDIR /app
COPY ./app.js ./app.js
CMD [ "node", "/app/app.js" ]
EXPOSE 3000
Bien sencillo, imagen base node:16-alpine, instalamos tini con el gestor de paquetes de alpine, creamos una carpeta en la imagen /app y copiamos el archivo que va a levantar el servidor app.js e inicializamos que cada vez que se entre al contenedor el comando a ejecutar será "node", "/app/app.js"
, y para finalizar exponemos el puerto 3000.
Configurando Nuestro Sistema#
Los comandos a continuación dan por sentado que estás utilizando Ubuntu, para cualquier otra distro te aconsejo instalar los paquetes correspondientes.
QEMU#
Instalamos los paquetes necesarios de QEMU con el siguiente comando:
sudo apt install qemu-user-static binfmt-support
Para verificar las arquitecturas que se han instalado (por ejemplo, ARM 64 Bits (aarch64)) lo podremos hacer con el comando:
ls -l /usr/bin/qemu-aarch64-static
Deberia mostrar la siguiente salida:
-rwxr-xr-x 1 root root 3621200 Oct 15 09:23 /usr/bin/qemu-aarch64-static
Verificamos la versión del ejecutavle qemu-aarch64-static:
qemu-aarch64-static --version
Y la siguiente salida:
qemu-aarch64 version 2.11.1(Debian 1:2.11+dfsg-1ubuntu7.21)
Copyright © 2003–2017 Fabrice Bellard and the QEMU Project developers
Verificamos la version de update-binfmts:
update-binfmts --version
Alternativa: Instalación de las Herramientas por una Imagen Docker#
Alternativamente, podemos instalar las herramientas de QEMU para generar las imágenes a través de un contenedor Docker, que contiene todos los simuladores de QEMU y su propio script para configurarlas en el kernel del host. Los simuladores de QEMU permanecerán registrados y se pueden utilizar siempre y cuando el host se encuentre encendido, por lo que en caso de reiniciar el sistema, tendremos que levantar manualmente el contenedor (a menos que le apliquemos un Docker Compose).
Iniciamos entonces el contenedor con el siguiente comando:
docker run -it --rm --privileged multiarch/qemu-user-static --credential yes --persistent yes
Construyendo Nuestra Imagen Multi-Arch#
Vamos a configurar ahora buildx para poder construir nuestras imagenes. Si
Primero, creamos nuestra instancia de buildx:
docker buildx create --name mybuilder
El nombre es indistinto, podemos colocarle cualquier nombre despues del argumento --name
. Seguido de esto, vamos a invocarla con el argumento use
:
docker buildx use mybuilder
Verificamos nuestra instancia de buildx creada con el comando:
docker buildx inspect --bootstrap
Nos debería dar la siguiente salida:
Name: mybuilder
Driver: docker-container
Nodes:
Name: mybuilder0
Endpoint: unix:///var/run/docker.sock
Status: running
Platforms: linux/amd64, linux/amd64/v2, linux/amd64/v3, linux/arm64, linux/386, linux/riscv64, linux/ppc64le, linux/s390x, linux/mips64le, linux/mips64
Observa la linea que dice Platforms. En esta linea podemos ver las múltiples arquitecturas no nativas que se han instalado a través de QEMU. Si solo muestra las arquitecturas para linux/amd64 y linux/386, es que aún no se han instalado los paquetes correspondientes. En este último caso, ejecuta el comando docker buildx rm
y vuelve a crearlo.
También podemos visualizar la información de la instancia de buildx mybuilder
con el comando:
docker buildx ls
Ya tenemos todo listo, construyamos la imagen ahora si:
docker buildx build . --platform linux/amd64,linux/arm64,linux/386 --tag usuario/imagen:latest --push
Esto nos creará la imagen para 3 arquitecturas distintas: amd64, arm64 e i386. Podemos simplificar un poco exportando variables de entorno para facilitar la construcción:
Para arm64:
export ARM64=linux/arm64
docker buildx build . --platform $ARM64 --tag usuario/imagen:arm64 --push
Para amd64:
export AMD64=linux/amd64
docker buildx build . --platform $AMD64 --tag usuario/imagen:amd64 --push
Para i386:
export 386=linux/386
docker buildx build . --platform $386 --tag usuario/imagen:386 --push
Como construímos estas imágenes por separado ahora las podemos juntar y enviarlas a nuestro DockerHub. Voy
Preparamos el manifiesto:
docker manifest create --insecure
usuario/imagen:latest
usuario/imagen:amd64
usuario/imagen:arm64
usuario/imagen:386
Compilamos y enviamos nuestras imágenes como una sola:
docker manifest push usuario/imagen:latest
Configurando CI/CD#
Que gracia tendría perder tiempo con todos estos comandos si podemos directamente automatizar todo el proceso mediante CI/CD y que se construyan y desplieguen nuestras imágenes cada cierto tiempo. En este caso lo voy a hacer con GitLab pero pueden escoger cualquier otra herramienta (Jenkins, GitHub Actions, etc)
Tenemos que tener dos variables configuradas en nuestro repositorio, yendo al repositorio creado, Configuracion -> CI/CD, desplegamos la opción Variables:
- DOCKERHUB_USERNAME: nuestro nombre de usuario de DockerHub
- DOCKERHUB_TOKEN: nuestra contraseña en DockerHub
Dentro del directorio donde veníamos trabajando y tenemos nuestro Dockerfile y el archivo de nuestra aplicación app.js
, vamos a crear otro llamado .gitlab-ci.yml
con el siguiente contenido:
---
services:
- docker:dind
stages:
- build
- package
build_amd64:
stage: build
variables:
PLATFORM: amd64
image: jdrouet/docker-with-buildx:stable
#only:
# - master # This pipeline stage will run on this branch alone
before_script:
# Login on DockerHub
- echo "Login on DockerHub"
- echo "$DOCKERHUB_TOKEN" | docker login --username $DOCKERHUB_USERNAME --password-stdin
script:
- echo "building image and pushing on DockerHub"
- docker buildx create --use
- docker buildx build .
--platform linux/$PLATFORM
--tag $DOCKERHUB_USERNAME/$CI_PROJECT_NAME:$PLATFORM
--push
build_arm64:
stage: build
variables:
PLATFORM: arm64
image: jdrouet/docker-with-buildx:stable
#only:
# - master # This pipeline stage will run on this branch alone
before_script:
# Login on DockerHub
- echo "Login on DockerHub"
- echo "$DOCKERHUB_TOKEN" | docker login --username $DOCKERHUB_USERNAME --password-stdin
script:
- echo "building image and pushing on DockerHub"
- docker buildx create --use
- docker buildx build .
--platform linux/$PLATFORM
--tag $DOCKERHUB_USERNAME/$CI_PROJECT_NAME:$PLATFORM
--push
build_armv7:
stage: build
variables:
PLATFORM: arm/v7
image: jdrouet/docker-with-buildx:stable
#only:
# - master # This pipeline stage will run on this branch alone
before_script:
# Login on DockerHub
- echo "Login on DockerHub"
- echo "$DOCKERHUB_TOKEN" | docker login --username $DOCKERHUB_USERNAME --password-stdin
script:
- echo "building image and pushing on DockerHub"
- docker buildx create --use
- docker buildx build .
--platform linux/$PLATFORM
--tag $DOCKERHUB_USERNAME/$CI_PROJECT_NAME:arm-v7
--push
package:
stage: package
image: jdrouet/docker-with-buildx:stable
services:
- name: docker:dind
command: ["--experimental"]
before_script:
- export DOCKER_CLI_EXPERIMENTAL=enabled
# Login on DockerHub
- echo "Login on DockerHub"
- echo "$DOCKERHUB_TOKEN" | docker login --username $DOCKERHUB_USERNAME --password-stdin
script:
- docker manifest create --insecure
$DOCKERHUB_USERNAME/$CI_PROJECT_NAME:latest
$DOCKERHUB_USERNAME/$CI_PROJECT_NAME:amd64
$DOCKERHUB_USERNAME/$CI_PROJECT_NAME:arm64
$DOCKERHUB_USERNAME/$CI_PROJECT_NAME:arm-v7
- docker manifest push $DOCKERHUB_USERNAME/$CI_PROJECT_NAME:latest
Todas las tareas tienen casi la misma estructura.
Hay dos etapas: build y package. En build vamos a construir nuestras imágenes por separado y en package las vamos a empaquetar en una sola imagen.
La clave:
services:
- docker:dind
Le indicamos a GitLab que use un servicio docker in docker, así nos permite utilizar comandos del cliente docker en la instancia de docker, ya que la imagen no necesariamente sería un instancia de docker pura y por ello necesitamos que GitLab nos proporcione ese servicio (suena confuso, pero así sería)
Las claves:
variables:
PLATFORM: amd64
image: jdrouet/docker-with-buildx:stable
Le indicamos en forma de variable la plataforma en la cual va a generar la imagen. En cada build hay una arquitectura distinta (amd64, arm64 y arm/v7) como veníamos trabajando por linea de comandos. La segunda es la imagen que vamos a utilizar que ya viene con el buildx habilitado por defecto.
before_script:
# Login on DockerHub
- echo "Login on DockerHub"
- echo "$DOCKERHUB_TOKEN" | docker login --username $DOCKERHUB_USERNAME --password-stdin
Con la clave before_script le decimos a GitLab que antes de ejecutar las tareas principales, haga, digamos, un calentamiento, o tareas que normalmente serian repetitivas (como autenticación), le pasamos el usuario y clave al comando docker login
y de esta manera iniciamos sesión en DockerHub.
script:
- echo "building image and pushing on DockerHub"
- docker buildx create --use
- docker buildx build .
--platform linux/$PLATFORM
--tag $DOCKERHUB_USERNAME/$CI_PROJECT_NAME:$PLATFORM
--push
Ya en la clave script estaríamos derechamente construyendo la imagen y enviándola directamente a DockerHub.
En nuestra segunda etapa, seria el package. No hay ningún secreto ni complejidad, nos autenticamos y lo que hacemos es crear el manifiesto con cada una de las imágenes generadas y compilar el set de imágenes en una sola para enviarla a DockerHub con la etiqueta latest
Comprobando#
Por último, comprobemos el CI/CD y si la imagen se generó correctamente. Para ello vamos en GitLab al menu CI/CD -> Pipelines:
Observamos que se ha ejecutado con éxito, por lo que vamos a DockerHub y comprobemos que nuestra imagen está allí:
Si hacemos una rápida comprobación por linea de comandos:
docker run --rm usuario/docker-multiarch-ejemplo:latest
Veremos una salida similar que valida que el servicio se encuentra ejecutándose en el puerto 3000:
Servidor ejecutándose en {"address":"::","family":"IPv6","port":3000}
Todos los archivos de ejemplo se encontrarán en el siguiente repositorio: https://gitlab.com/enmanuelmoreira/docker-multiarch-ejemplo/
Hasta aquí el artículo, espero les haya gustado, ¡hasta la próxima!
Referencias#
- Documentación de buildx: https://docs.docker.com/buildx/working-with-buildx/
Artículos sobre Docker#
- Como Instalar Docker en Linux
- Como Instalar Portainer: El Mejor Gestor Gráfico de Docker en Linux
- Conceptos y Comandos Básicos en Docker
- Construyendo Imágenes Personalizadas en Docker con Dockerfile
- Desplegando Aplicaciones con Docker Compose
- Como Configurar un Registro Privado de Docker en Linux
- SupervisorD: Gestionando Procesos en Docker
- Buenas Prácticas al Escribir Dockerfiles
- Crear Imágenes Multi-Arquitectura en Docker con buildx