Crear Imágenes Multi-Arquitectura en Docker con buildx


Antes de comenzar, quería contarles que hay una promoción en DigitalOcean donde te dan un crédito de USD 100.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:

DigitalOcean Referral Badge

O a través del siguiente enlace: https://bit.ly/digitalocean-itsm


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.


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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
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.


Los comandos a continuación dan por sentado que estás utilizando Ubuntu, para cualquier otra distro te aconsejo instalar los paquetes correspondientes.

Instalamos los paquetes necesarios de QEMU con el siguiente comando:

1
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:

1
ls -l /usr/bin/qemu-aarch64-static

Deberia mostrar la siguiente salida:

1
-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:

1
qemu-aarch64-static --version

Y la siguiente salida:

1
2
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:

1
update-binfmts --version

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:

1
docker run -it --rm --privileged multiarch/qemu-user-static --credential yes --persistent yes

Vamos a configurar ahora buildx para poder construir nuestras imagenes. Si

Primero, creamos nuestra instancia de buildx:

1
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:

1
docker buildx use mybuilder

Verificamos nuestra instancia de buildx creada con el comando:

1
docker buildx inspect --bootstrap

Nos debería dar la siguiente salida:

1
2
3
4
5
6
7
8
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:

1
docker buildx ls

Ya tenemos todo listo, construyamos la imagen ahora si:

1
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:

1
2
3
export ARM64=linux/arm64

docker buildx build . --platform $ARM64 --tag usuario/imagen:arm64 --push

Para amd64:

1
2
3
export AMD64=linux/amd64

docker buildx build . --platform $AMD64 --tag usuario/imagen:amd64 --push

Para i386:

1
2
3
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:

1
2
3
4
5
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:

1
docker manifest push usuario/imagen:latest

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:

/images/docker-multiarch/gitlab-0.png
CI/CD Variables

  • DOCKERHUB_USERNAME: nuestro nombre de usuario de DockerHub

/images/docker-multiarch/gitlab-1.png
Usuario de DockerHub

  • DOCKERHUB_TOKEN: nuestra contraseña en DockerHub

/images/docker-multiarch/gitlab-2.png
Configuramos nuestra contraseña

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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
---
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:

1
2
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:

1
2
3
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.

1
2
3
4
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.

1
2
3
4
5
6
7
8
  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

Información
La variable $CI_PROJECT_NAME es interna de GitLab, con esto le indicamos que el nombre de la imagen sea la misma del nombre de nuestro proyecto.

Por último, comprobemos el CI/CD y si la imagen se generó correctamente. Para ello vamos en GitLab al menu CI/CD -> Pipelines:

/images/docker-multiarch/gitlab-3.png
Observemos nuestro Pipeline

Observamos que se ha ejecutado con éxito, por lo que vamos a DockerHub y comprobemos que nuestra imagen está allí:

/images/docker-multiarch/dockerhub-0.png
Nuestra imagen en DockerHub

Si hacemos una rápida comprobación por linea de comandos:

1
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:

1
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!



Otros Articulos