Crear imágenes Docker multi-arquitectura con Azure Devops

Ahora que los contenedores windows empiezan a funcionar decentemente, nos puede interesar crear imágenes Docker multi-arquitectura para que se puedan desplegar en contenedores Windows o Linux dependiendo de las necesidades. En este post te cuento como hacerlo usando Azure Devops.

Vamos a ver primero qué es una imagen multi-arquitectura, como crearla con la CLI de Docker y finalmente como hacerlo desde Azure Devops 🙂

¿Qué es una imagen multi-arquitectura?

Docker no es un sistema de virtualización, eso significa que un host Docker solo puede ejecutar contenedores cuyos binarios sean de su misma arquitectura. En este caso por «arquitectura» no me refiero solo a la arquitectura hardware (si es x86, ARM o x64 p. ej.) si no también el sistema operativo. Eso significa que un host Windows no puede ejecutar contenedores Linux y viceversa.

Si intentas descargarte una imagen que no es de la arquitectura correcta, vas a recibir un error. Hasta hace relativamente poco para diferenciar entre arquitecturas distitntas se usaban distintos tags de la imagen Docker. Así, p. ej. podía tener una imagen con los tags 1.0-linux y 1.0-windows. Y cada cual debía usar el tag que necesitaba en función del sistema operativo.

Eso genera ciertas fricciones. P. ej. las imágenes de ASP.NET Core se proporcionan para ambos sistemas operativos. Si yo creo una imagen a partir de ASP.NET Core, dependiendo del tag que use en mi Dockerfile, mi imagen generada será para Windows o para Linux. Esto es contraproducente, ya que p. ej. desarrolladores Linux y Windows no pueden compartir las imágenes a pesar de ASP.NET Core multiplataforma. Hay técnicas para lidiar con ello (como usar argumentos de build) pero no es la situación ideal. Para solucionar este problema se creó el concepto de imagen multi-arquitectura. La idea es que una misma imagen Docker contiene dos o más arquitecturas. De este modo ya no debes tener tags para cada arquitectura: si un usuario de Windows hace un docker pull de tu imagen se descargará la versión Windows y si es uno de Linux, se descargará la de Linux (de hecho, recuerda que eso incluye la arquitectura hardware también: si un usuario de Linux bajo ARM hace el docker pull de descargará la versión Linux ARM). Si la versión correspondiente no existe, el usuario recibirá un error.

Docker Hub te informa de si una imágen es multi-arquitectura:

Docker hub muestra, para cada tag, sus arquitecturas

Observa como los tags master, dev y latest, tienen los iconos de Linux y Windows, indicando que están disponibles para esos dos sistemas operativos.

Vale, a pesar de que las llamamos «imágenes multiarquitectura», en el fondo eso no existe. Una imagen de Docker sigue siendo para una sola arquitectura. Realmente lo que se hace es crear un manifiesto que asocia un tag virtual uno o más imágenes.  Por ejemplo, el tag «dev» de la imagen anterior está asociado a imágenes reales (de los tags «linux-dev» y «win-dev»). De este modo, cuando el usuario hace un docker pull usando el tag dev, la imagen real que se descargará, será la de linux-devwin-dev, en función de su arquitectura. Básicamente lo que ocurre es que cuando Docker se va a descargar el tag dev, descubre que no es una imagen real, si no un manifiesto que le indica que imagen debe bajarse para cada plataforma, y entonces se descarga dicha imagen.

De hecho, y eso es importante, el manifiesto no contiene referencia a los tags subyacentes si no a los identificadores (SHA256) de las imágenes. Eso puede parecer que no importa demasiado, pero significa que el manifiesto debe recrearse cada vez que se sube una de las imágenes subyacentes, ya que en caso contrario quedaría desfasado.

Crear un manifiesto (imagen multi-arquitectura)

Para crear una imagen multi-arquitectura se debe usar el comando docker manifest. Este comando es experimental, así que debes habilitar los comandos experimentales en Docker. Pero ojo, no en el daemon si no en la CLI. Tener seleccionada la casilla «Experimental features» en la pestaña «Daemon» de las opciones de Docker no te va a servir. En su lugar debes editar el fichero ~/.docker/config.json (si no existe lo creas) y agregar:

"experimental": "enabled"

Ahora el comando «docker manifest» ya debe funcionarte.

La sintaxis es muy sencilla:

docker manifest create <imagen>:<tag> <imagen>:<tag-linux> <imagen>:<tag-windows> <imagen>:<otro-tag-para-otra-arquitectura>

Simplemente entras primero el nombre de la imagen y el tag «virtual» que quieras que sea multi-arquitectura y luego entras todas y cada una de las imagenes reales. No es necesario que tengas las imágenes en disco, solo que estén en Docker Hub:

Uso de docker manifest create (sin tener las imágenes en local)

Observa como puedo crear el manifiesto eshop/webmvc:dev a partir de las imágenes eshop/webmvc:linux-deveshop/webmvc:win-dev sin necesidad de tener dichas imágenes descargadas.

Esto ha creado el manifiesto, que lo tengo en local. Los manifiestos se guardan en ~/.docker/manifests:

Contenido de un manifiesto

El comando docker manifest inspect <imagen>:<tag> te da la información del manifiesto. Si el manifiesto lo tienes en local, te da la información del manifiesto local, en caso contrario te da la información del manifiesto que está en Docker Hub.

Una vez tienes el manifiesto en local, puedes subirlo a Docker Hub, con un docker manifest push, como si fuese una imagen normal y corriente.

Importante: Dado que el manifiesto contiene los SHA256 de las imágenes debes recrear y volver subir (con docker manifest push) el manifiesto cada vez que subas (actualices) una de las imágenes subyacentes.

Crear una imagen multi-arquitectura con Azure Devops

Vamos a suponer una build que usa Azure Devops para construir una imagen tanto en Linux como en Windows:

variables:
    registryEndpoint: eshop-registry
jobs:
- job: BuildLinux
  pool:
    vmImage: 'ubuntu-16.04'    
  steps:
  - task: DockerCompose@0
    displayName: Compose build webmvc
    inputs:
      dockerComposeCommand: 'build webmvc'
      containerregistrytype: Container Registry
      dockerRegistryEndpoint: $(registryEndpoint)
      dockerComposeFile: docker-compose.yml
      qualifyImageNames: true
      projectName: ""
      dockerComposeFileArgs: |
        TAG=$(Build.SourceBranchName)
  - task: DockerCompose@0
    displayName: Compose push webmvc
    inputs:
      dockerComposeCommand: 'push webmvc'
      containerregistrytype: Container Registry
      dockerRegistryEndpoint: $(registryEndpoint)
      dockerComposeFile: docker-compose.yml
      qualifyImageNames: true
      projectName: ""
      dockerComposeFileArgs: |
        TAG=$(Build.SourceBranchName)
- job: BuildWindows
  pool:
    vmImage: 'windows-2019'
  steps:
  - task: DockerCompose@0
    displayName: Compose build webmvc
    inputs:
      dockerComposeCommand: 'build webmvc'
      containerregistrytype: Container Registry
      dockerRegistryEndpoint: $(registryEndpoint)
      dockerComposeFile: docker-compose.yml
      qualifyImageNames: true
      projectName: ""
      dockerComposeFileArgs: |
        TAG=$(Build.SourceBranchName)
        PLATFORM=win
  - task: DockerCompose@0
    displayName: Compose push webmvc
    inputs:
      dockerComposeCommand: 'push webmvc'
      containerregistrytype: Container Registry
      dockerRegistryEndpoint: $(registryEndpoint)
      dockerComposeFile: docker-compose.yml
      qualifyImageNames: true
      projectName: ""
      dockerComposeFileArgs: |
        TAG=$(Build.SourceBranchName)
        PLATFORM=win
- template: ../multiarch.yaml
  parameters:
    image: webmvc
    branch: $(Build.SourceBranchName)
    registryEndpoint: $(registryEndpoint)

Este ejemplo esté sacado de eShopOnContainers (https://github.com/dotnet-architecture/eShopOnContainers/tree/dev/build/azure-devops/webmvc). Solo que aquí he eliminado lo que no es relevante para este post.

La build define dos jobs (BuildLinuxBuildWindows) que usan tareas de Docker Compose para construir las dos imágenes. Cada uno de los jobs se ejecuta en un agente distinto, ya que necesitamos un agente Linux para construir la imagen Linux y un agente Windows para construir la imagen Windows.

El fichero compose de eShopOnContainers espera un parámetro llamado «PLATFORM» que puede valer Linux o Windows (por defecto asume Linux). Dicho parámetro se usa básicamente para generar tags distintos de las imágenes (el tag se genera con la forma PLATFORM-TAG). Así generamos tags «win-dev» o «linux-dev».

Lo interesante viene en el paso final, donde se invoca a un template que recibe tres parámetros:

  • image: El nombre de la imagen
  • branch: La rama. Se usa para los nombres de los tags
  • registryEndpoint: Conexión de Azure Devops al registro de docker a usar

Este template es el que crea el manifiesto y lo publica, definiendo un job adicional:

parameters:
  image: ''
  branch: ''
  registry: 'eshop'
  registryEndpoint: ''

jobs:
- job: manifest
  condition: and(succeeded(),ne('${{ variables['Build.Reason'] }}', 'PullRequest'))
  dependsOn:
    - BuildWindows
    - BuildLinux  
  pool: 
    vmImage: 'Ubuntu 16.04'
  steps:
  - task: Docker@1
    displayName: Docker Login
    inputs:
      command: login
      containerregistrytype: 'Container Registry'
      dockerRegistryEndpoint: ${{ parameters.registryEndpoint }}
  - bash: |
      mkdir -p ~/.docker
      sed '$ s/.$//' $DOCKER_CONFIG/config.json > ~/.docker/config.json
      echo ',"experimental": "enabled" }' >> ~/.docker/config.json
      docker --config ~/.docker manifest create ${{ parameters.registry }}/${{ parameters.image }}:${{ parameters.branch }} ${{ parameters.registry }}/${{ parameters.image }}:linux-${{ parameters.branch }} ${{ parameters.registry }}/${{ parameters.image }}:win-${{ parameters.branch }}
      docker --config ~/.docker manifest create ${{ parameters.registry }}/${{ parameters.image }}:latest ${{ parameters.registry }}/${{ parameters.image }}:linux-latest ${{ parameters.registry }}/${{ parameters.image }}:win-latest
      docker --config ~/.docker manifest push ${{ parameters.registry }}/${{ parameters.image }}:${{ parameters.branch }}
      docker --config ~/.docker manifest push ${{ parameters.registry }}/${{ parameters.image }}:latest
    displayName: Create multiarch manifest

Es bastante sencillo:

  1. Usa una tarea de Docker@2 para hacer el Docker Login. Por eso necesitamos pasar la conexión del registro. Si usáramos «docker login» desde una tarea bash, deberíamos pasarle el login y el password, pero usando la tarea podemos usar la conexión de Azure Devops. La tarea de Docker@2, crea un fichero de config temporal de Docker donde guarda el token de autorización. La ubicación de este fichero está en la variable de entorno DOCKER_CONFIG
  2. Luego la tarea bash lo que hace es:
    1. Crear una copia del fichero de configuración temporal que nos ha creado la tarea Docker@2 , pero quitando el último carácter (usando sed), que es la llave de cierre de JSON. Esta copia se ubica en ~/.docker
    2. Añadir a esta copia el valor «experimental» a «enabled», para así habilitar el comando «docker manifest»
    3. Usar «docker manifest create» para crear el manifiesto (usando este nuevo fichero de configuración)
    4. Usar «docker manifest push» para subir el manifiesto (usando este nuevo fichero de configuración)
  3. Finalmente el «dependsOn» nos asegura que este job solo se ejecutará una vez el BuildWindows y BuildLinux haya terminado. Si no, se podría ejecutar antes y entonces subiríamos el manifiesto antes de la imagen real (y estaría desactualizado).

Puede parecer un poco complejo el usar una tarea Docker@2 para el login y luego tener que hacer la guarrada esa del sed y poder añadir la entrada «experimental» en el fichero de configuración, pero creo que es un poco mejor que usar una tarea bash con un «docker login», ya que entonces deberíamos pasarle el usuario y el password (y no podríamos usar una conexión de Azure Devops que es más segura).

Observa la sintaxis ${{ … }} que usamos dentro de los templates. La verdad es que esta sintaxis es muy potente, permitiendo no solo acceder a los parámetros si no también iterar sobre ellos para generar el yaml final. En nuestro caso solo lo usamos para acceder a los parámetros. Pero es un sistema bastante potente.

¡Y listos! Ahora la build ejecuta los dos jobs «BuildWindows» y «BuildLinux» que construyen y suben las imagenes de Windows y Linux al registro de Docker y finalmente, se crea el manifiesto y se sube al registro. Oberva como el agente que ejecuta el último job, no tiene porque ser el mismo que ha ejecutado los anteriores (recuerda que para crear un manifiesto no tienes por qué tener las imágenes en local… cosa obvia, ya que no vas a poder tener imágenes de varias arquitecturas a la vez!).

Espero que os haya sido útil!

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *