Construire et publier une image multi-architecture
Pourquoi
Le cours Construire des images de conteneurs a assemblé dans un workflow toute la chaîne d'une image : construction reproductible, SBOM, analyse, signature, publication. Il l'a fait pour une architecture, amd64. Cette leçon ne refait pas ce travail : elle traite de ce que GitHub Actions apporte en propre quand l'image doit exister pour plusieurs architectures.
Le besoin est devenu courant. Les processeurs Arm se sont imposés dans les serveurs, où ils sont souvent moins chers à puissance égale et plus sobres en énergie ; ils sont aussi dans les postes des développeurs, avec les Mac à puce Apple. Une image amd64 seule s'exécute mal (par émulation) ou pas du tout sur ces machines. Une image multi-architecture contient une variante par architecture, et chaque machine récupère automatiquement la sienne.
Construire pour une architecture que l'on n'a pas sous la main est le vrai sujet. Il y a trois façons de le faire, et GitHub Actions rend la meilleure accessible depuis début 2025 : des runners Arm hébergés, gratuits pour les dépôts publics.
Les concepts
L'index d'image
Une image multi-architecture n'est pas une image : c'est un index (image index dans la spécification OCI, manifest list chez Docker), un petit document qui liste plusieurs manifestes d'image, un par plateforme. Quand docker pull ou Kubernetes récupère signalements:1.2.0, le registre renvoie l'index, et le client choisit le manifeste qui correspond à sa plateforme.
flowchart TB
T["signalements:main-6ef8842"] --> I["Index<br/>sha256:4979…"]
I --> A["Manifeste linux/amd64<br/>sha256:0209…"]
I --> R["Manifeste linux/arm64<br/>sha256:275f…"]
I --> PA["Attestation (amd64)"]
I --> PR["Attestation (arm64)"]
Chaque objet a sa propre empreinte. L'étiquette pointe vers l'index ; l'index pointe vers les manifestes par empreinte. On peut donc construire chaque architecture séparément, la publier sans étiquette (par empreinte seulement), puis créer l'index qui les réunit et lui donner les étiquettes : c'est la technique de cette leçon.
Trois façons de construire pour une autre architecture
| Méthode | Principe | Vitesse | On peut tester l'image ? |
|---|---|---|---|
| Émulation (QEMU) | Le noyau exécute les binaires Arm par traduction, instruction par instruction | Lente, souvent plusieurs fois plus | Oui, sous émulation |
| Construction croisée | La machine de construction prépare les fichiers pour l'architecture cible sans les exécuter | Rapide | Non, pas sur cette machine |
| Runners natifs | Chaque architecture est construite sur une machine de cette architecture | Rapide | Oui, nativement |
L'émulation est la plus simple à mettre en place (docker/setup-qemu-action puis platforms: linux/amd64,linux/arm64), mais chaque RUN de la variante Arm est émulé : une installation de dépendances qui compile du code peut prendre des dizaines de minutes.
La construction croisée est rapide quand la chaîne d'outils s'y prête : un compilateur Go ou Rust produit un binaire Arm depuis une machine x86 (c'est ce que faisait l'outil d'export du cours Construire des images de conteneurs). Pour une application Python, il faut télécharger des paquets précompilés pour l'autre architecture, et c'est fragile, comme on va le voir.
Les runners natifs combinent la vitesse et la possibilité de tester. Depuis 2025, GitHub fournit des runners Arm hébergés (ubuntu-24.04-arm), gratuits pour les dépôts publics, et facturés 0,005 dollar la minute pour les dépôts privés, un peu moins que les runners x86 (0,006 dollar). Leur seul coût est une organisation en deux temps : une construction par architecture, puis un job qui assemble l'index.
Les étiquettes
docker/metadata-action calcule les étiquettes et les annotations OCI à partir du contexte Git, selon des règles déclarées :
| Règle | Événement | Étiquette produite |
|---|---|---|
type=sha,prefix=main-,enable={{is_default_branch}} | poussée sur main | main-6ef8842 |
type=semver,pattern={{version}} | étiquette Git v1.2.0 | 1.2.0, et latest (voir plus bas) |
type=semver,pattern={{major}}.{{minor}} | étiquette Git v1.2.0 | 1.2 |
type=ref,event=pr | demande de fusion n° 7 | pr-7 |
Le format main-<sha court> est celui qu'utilise le workflow de ce site, et qu'Argo CD suit. L'action normalise aussi le nom de l'image en minuscules, une exigence des registres.
Un comportement par défaut mérite d'être connu : le réglage flavor de l'action vaut latest=auto, ce qui ajoute l'étiquette latest à toute image produite par une règle type=semver (ou type=ref,event=tag). Une version v1.2.0 publie donc 1.2.0, 1.2 et latest, sans que le workflow le dise. Pour l'éviter, on écrit flavor: latest=false.
Le cache de construction type=gha
BuildKit peut ranger son cache de couches dans le service de cache de GitHub Actions (cache-to: type=gha), et le relire au run suivant (cache-from: type=gha). Les règles de la leçon 5 s'appliquent toutes : portée par branche, 10 Go par dépôt, lecture seule pour les déclencheurs à faible confiance. Deux réglages comptent :
mode=maxmet en cache les couches de toutes les étapes de construction, pas seulement celles de l'image finale. Indispensable avec une construction multi-étapes.scopesépare des caches qui, sinon, se marcheraient dessus. Sans lui, les deux architectures écrivent sous la même portée (buildkitpar défaut) et la dernière qui écrit l'emporte : à chaque run, l'autre repart de zéro. On donne donc à chaque architecture son proprescope.
En pratique
La construction croisée, et sa limite
Pour comprendre ce que l'on gagne avec des runners natifs, commençons par ce que l'on peut faire sans eux et sans émulation. La variante suivante du Dockerfile de Signalements installe les dépendances depuis l'architecture de la machine de construction (BUILDPLATFORM), en demandant à pip les paquets précompilés de l'architecture cible (TARGETARCH), puis les copie dans l'image finale, qui ne contient aucun RUN :
# syntax=docker/dockerfile:1
# Variante sans émulation : les dépendances sont installées par l'architecture de
# la machine de construction (BUILDPLATFORM), sous forme de roues précompilées
# pour l'architecture cible (TARGETARCH). L'image finale ne contient aucun RUN.
FROM --platform=$BUILDPLATFORM python:3.14-slim AS dependances
ARG TARGETARCH
COPY requirements.txt .
# pip n'accepte que les étiquettes de plateforme données : on liste toutes celles
# que la glibc de l'image finale (Debian 13, glibc 2.41) sait charger.
RUN case "$TARGETARCH" in \
amd64) arch=x86_64 ;; \
arm64) arch=aarch64 ;; \
*) echo "architecture non prise en charge : $TARGETARCH" >&2; exit 1 ;; \
esac && \
pip install --no-cache-dir --disable-pip-version-check --root-user-action=ignore \
--platform "manylinux_2_28_$arch" --platform "manylinux_2_17_$arch" \
--only-binary=:all: --python-version 3.14 --implementation cp \
--target /deps -r requirements.txt
FROM python:3.14-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
PYTHONPATH=/deps
WORKDIR /app
COPY --from=dependances /deps /deps
COPY app.py .
ARG VERSION=dev
ENV APP_VERSION=$VERSION
USER 10001
EXPOSE 8000
CMD ["python", "-m", "gunicorn", "--bind", "0.0.0.0:8000", "--workers", "2", "--no-control-socket", "app:app"]La première version de ce fichier ne donnait qu'une étiquette de plateforme par architecture, manylinux_2_28. La construction Arm a réussi, la construction x86 a échoué :
$ docker buildx build --platform linux/amd64 -f Dockerfile.croise .
...
ERROR: Could not find a version that satisfies the requirement psycopg-binary==3.3.6; implementation_name != "pypy" and extra == "binary" (from psycopg[binary]) (from versions: none)
ERROR: No matching distribution found for psycopg-binary==3.3.6; implementation_name != "pypy" and extra == "binary"
L'explication est dans les noms des paquets publiés pour Python 3.14 :
$ curl -s https://pypi.org/pypi/psycopg-binary/3.3.6/json | python3 -c 'import json,sys
> for u in json.load(sys.stdin)["urls"]:
> if "cp314-cp314-" in u["filename"] and "linux" in u["filename"]: print(u["filename"])'
psycopg_binary-3.3.6-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl
psycopg_binary-3.3.6-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl
psycopg_binary-3.3.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl
psycopg_binary-3.3.6-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl
psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_aarch64.whl
psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_ppc64le.whl
psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_riscv64.whl
psycopg_binary-3.3.6-cp314-cp314-musllinux_1_2_x86_64.whl
Le paquet x86 est étiqueté manylinux_2_17, le paquet Arm manylinux_2_28. Sur une vraie machine, pip sait qu'une glibc récente charge aussi les paquets plus anciens ; avec --platform, il n'accepte que les étiquettes données. D'où la seconde ligne --platform. La leçon dépasse ce cas : la construction croisée d'une application Python dépend du détail de la publication de chaque dépendance, et elle casse au premier paquet qui n'a pas de version précompilée pour l'architecture visée.
Et l'image produite ne peut pas être testée sur la machine qui l'a construite :
$ docker image inspect signalements:arm64-essai --format '{{.Os}}/{{.Architecture}}'
linux/arm64
$ docker run --rm --entrypoint /bin/ls signalements:arm64-essai /deps
exec /bin/ls: exec format error
exec format error : le noyau x86 refuse d'exécuter un binaire Arm. Sans émulation, impossible de lancer ne serait-ce qu'un test de fumée. C'est le principal argument pour les runners natifs.
Publier par empreinte, assembler l'index
Avant d'écrire le workflow, voici sa mécanique, exécutée sur un poste avec un registre local (docker run -d -p 127.0.0.1:5000:5000 registry:3) et un constructeur BuildKit qui peut le joindre (docker buildx create --name croise --driver docker-container --driver-opt network=host). Chaque architecture est construite séparément et publiée sans étiquette :
$ docker buildx build --builder croise --platform linux/amd64 -f Dockerfile.croise \
--build-arg VERSION=6ef8842 \
--output type=image,name=localhost:5000/signalements,push-by-digest=true,name-canonical=true,push=true \
--metadata-file empreintes/amd64.json .
$ jq -r '."containerimage.digest"' empreintes/amd64.json
sha256:c7f50822dbfdbddfbb6f77db26a13b27886e8c24b4654c7710bed0fc7e32ff43
(et de même pour linux/arm64, qui donne sha256:38771a2e…). push-by-digest=true publie l'image sans étiquette ; name-canonical=true l'adresse par son nom complet. Puis on assemble l'index et on l'étiquette :
$ docker buildx imagetools create --tag localhost:5000/signalements:main-6ef8842 \
localhost:5000/signalements@sha256:c7f50822dbfdbddfbb6f77db26a13b27886e8c24b4654c7710bed0fc7e32ff43 \
localhost:5000/signalements@sha256:38771a2e24dec97d5bb3e1732d68f01ef3aba96119c00be16efb5e89714f57ec
$ docker buildx imagetools inspect localhost:5000/signalements:main-6ef8842
Name: localhost:5000/signalements:main-6ef8842
MediaType: application/vnd.oci.image.index.v1+json
Digest: sha256:4979059411ed0ba4e8dce36790f62fb4649f3e1b10d78b957fee8e00e180eb19
Manifests:
Name: localhost:5000/signalements:main-6ef8842@sha256:02092faf37e6b9b43f573c4e4cf37487f0dd157ef95ba2fe6728149470442eeb
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: linux/amd64
Name: localhost:5000/signalements:main-6ef8842@sha256:9f1452aa6c275fbeaca4f0d245aba4b4ffbceff86fc66004b8bd0d3040a1c683
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: unknown/unknown
Annotations:
vnd.docker.reference.digest: sha256:02092faf37e6b9b43f573c4e4cf37487f0dd157ef95ba2fe6728149470442eeb
vnd.docker.reference.type: attestation-manifest
Name: localhost:5000/signalements:main-6ef8842@sha256:275f7d175dcc31822d824b1a1e11e0a28bc4e90a46117c26bc3da6b04750ac7b
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: linux/arm64
Name: localhost:5000/signalements:main-6ef8842@sha256:017c1a17ee16d4ec9c9304e7645e2d2cb4bd80259673d11c02418d84f40336da
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: unknown/unknown
Annotations:
vnd.docker.reference.digest: sha256:275f7d175dcc31822d824b1a1e11e0a28bc4e90a46117c26bc3da6b04750ac7b
vnd.docker.reference.type: attestation-manifest
L'index liste quatre manifestes : les deux images, et deux manifestes unknown/unknown. Ce sont les attestations de provenance que BuildKit ajoute par défaut à chaque image construite, rattachées à leur image par l'annotation vnd.docker.reference.digest. Un client qui récupère l'image les ignore ; un outil de vérification les lit.
Enfin, l'étiquette fonctionne comme n'importe quelle image : Docker récupère l'index, choisit la variante amd64, et l'application répond avec la version figée à la construction :
$ docker run -d -p 127.0.0.1:8099:8000 localhost:5000/signalements:main-6ef8842
$ curl -s 127.0.0.1:8099/
{"application":"signalements","conteneur":"05fb6face5a5","environnement":"local","stockage":"memoire","version":"6ef8842"}
Le workflow
Le workflow image.yml applique cette mécanique avec le Dockerfile habituel de Signalements (celui du cours Construire des images de conteneurs, sans construction croisée), chaque architecture étant construite sur un runner de son architecture :
name: Image
on:
push:
branches: [main]
tags: ["v*"]
pull_request:
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
permissions:
contents: read
defaults:
run:
shell: bash
jobs:
construire:
name: Construire (${{ matrix.plateforme }})
runs-on: ${{ matrix.runner }}
timeout-minutes: 20
permissions:
contents: read
packages: write
strategy:
fail-fast: true
matrix:
include:
- plateforme: linux/amd64
runner: ubuntu-24.04
- plateforme: linux/arm64
runner: ubuntu-24.04-arm
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- name: Préparer les noms
env:
PLATEFORME: ${{ matrix.plateforme }}
run: |
echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
echo "PAIRE=${PLATEFORME//\//-}" >> "$GITHUB_ENV"
- uses: docker/setup-buildx-action@v4
- name: Se connecter à GHCR
if: github.event_name != 'pull_request'
uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Construire pour la demande de fusion
if: github.event_name == 'pull_request'
uses: docker/build-push-action@v7
with:
context: .
platforms: ${{ matrix.plateforme }}
load: true
tags: signalements:test
cache-from: type=gha,scope=${{ env.PAIRE }}
- name: Construire et publier par empreinte
id: publier
if: github.event_name != 'pull_request'
uses: docker/build-push-action@v7
with:
context: .
platforms: ${{ matrix.plateforme }}
build-args: VERSION=${{ github.sha }}
outputs: type=image,name=${{ env.IMAGE }},push-by-digest=true,name-canonical=true,push=true
cache-from: type=gha,scope=${{ env.PAIRE }}
cache-to: type=gha,scope=${{ env.PAIRE }},mode=max
- name: Test de fumée sur l'architecture native
env:
REFERENCE: ${{ case(github.event_name == 'pull_request', 'signalements:test', format('{0}@{1}', env.IMAGE, steps.publier.outputs.digest)) }}
run: |
docker run -d --name signalements -p 127.0.0.1:8000:8000 "$REFERENCE"
python3 ci/fumee.py http://127.0.0.1:8000/sante
docker exec signalements python -c "import platform; print('architecture :', platform.machine())"
- name: Noter l'empreinte
if: github.event_name != 'pull_request'
env:
EMPREINTE: ${{ steps.publier.outputs.digest }}
run: |
mkdir -p empreintes
touch "empreintes/${EMPREINTE#sha256:}"
- name: Transmettre l'empreinte
if: github.event_name != 'pull_request'
uses: actions/upload-artifact@v7
with:
name: empreinte-${{ env.PAIRE }}
path: empreintes/*
if-no-files-found: error
retention-days: 1
assembler:
name: Assembler l'index multi-architecture
needs: construire
if: github.event_name != 'pull_request'
runs-on: ubuntu-24.04
timeout-minutes: 10
permissions:
contents: read
packages: write
id-token: write
attestations: write
artifact-metadata: write
steps:
- name: Préparer le nom
run: echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" >> "$GITHUB_ENV"
- uses: actions/download-artifact@v8
with:
pattern: empreinte-*
path: empreintes
merge-multiple: true
- uses: docker/setup-buildx-action@v4
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Étiquettes
id: meta
uses: docker/metadata-action@v6
with:
images: ${{ env.IMAGE }}
tags: |
type=sha,prefix=main-,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}
- name: Créer l'index et l'étiqueter
working-directory: empreintes
run: |
mapfile -t etiquettes < <(jq -r '.tags[] | "--tag=" + .' <<<"$DOCKER_METADATA_OUTPUT_JSON")
mapfile -t sources < <(for e in *; do echo "$IMAGE@sha256:$e"; done)
docker buildx imagetools create "${etiquettes[@]}" "${sources[@]}"
- name: Lire l'empreinte de l'index
id: index
run: |
reference=$(jq -r '.tags[0]' <<<"$DOCKER_METADATA_OUTPUT_JSON")
docker buildx imagetools inspect "$reference"
empreinte=$(docker buildx imagetools inspect "$reference" --format '{{json .Manifest}}' | jq -r .digest)
echo "empreinte=$empreinte" >> "$GITHUB_OUTPUT"
- name: Attester la provenance
uses: actions/attest@v4
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ steps.index.outputs.empreinte }}
push-to-registry: trueLes choix, dans l'ordre du fichier :
La matrice associe chaque plateforme à son runner.
includesans dimension de base crée exactement les combinaisons listées : deux jobs.fail-fast: true(la valeur par défaut, écrite pour être explicite) : si une architecture échoue, inutile de finir l'autre, l'index ne sera pas assemblé.Les permissions sont données job par job. Seuls les jobs qui publient reçoivent
packages: write. Le workflow n'a quecontents: readpar défaut.${GITHUB_REPOSITORY,,}met le nom du dépôt en minuscules (une expansion de Bash), parce que les registres refusent les majuscules et que les expressions de GitHub n'ont pas de fonction pour le faire. Pour un dépôtLyneko-Formation/Signalements, on obtientghcr.io/lyneko-formation/signalements.${PLATEFORME//\//-}remplace la barre oblique delinux/arm64par un tiret, pour un nom d'artefact et de portée de cache valide.GHCR et le
GITHUB_TOKEN. Le registre de GitHub accepte le jeton du workflow, à condition que le job aitpackages: write: aucun secret à gérer. La connexion est sautée pour une demande de fusion, qui ne publie rien.Deux étapes de construction, selon l'événement. Pour une demande de fusion, l'image est construite et chargée dans le Docker local du runner (
load: true), sans rien publier, et elle lit le cache sans l'écrire. Sinon, elle est publiée par empreinte, avec le commit complet comme version.Le test de fumée vise l'artefact publié. Après publication,
REFERENCEest l'image par empreinte, téléchargée depuis le registre : on teste exactement ce qui sera étiqueté, sur l'architecture native, et la dernière commande affiche l'architecture réellement exécutée. Le même script, exécuté localement sur l'empreinteamd64:$ docker run -d --name signalements -p 127.0.0.1:8000:8000 "$REFERENCE" $ python3 ci/fumee.py http://127.0.0.1:8000/sante 200 {"etat":"ok"} $ docker exec signalements python -c "import platform; print('architecture :', platform.machine())" architecture : x86_64Sur le runner
ubuntu-24.04-arm, la même commande afficheaarch64.Les empreintes voyagent par artefact. Une sortie de job n'a qu'une valeur pour l'ensemble de la matrice : on ne peut pas y recueillir une empreinte par architecture. Chaque job écrit donc un fichier vide nommé d'après son empreinte, et le téléverse ; le job d'assemblage les récupère tous dans un même répertoire (
merge-multiple: true, sans risque ici puisque les noms diffèrent). Rétention d'un jour : ces artefacts ne servent qu'à ce run.L'assemblage reprend exactement les commandes exécutées plus haut, avec les étiquettes calculées par
metadata-action(exposées dans la variableDOCKER_METADATA_OUTPUT_JSON). Les tableaux Bash (mapfile) évitent les problèmes de découpage de mots qu'aurait un$(...)non protégé.L'attestation est produite par
actions/attest, l'action de GitHub qui crée une attestation de provenance au format in-toto, signée avec un certificat Sigstore éphémère obtenu grâce au jeton OIDC du job (id-token: write, détaillé à la leçon 10), et publiée à côté de l'image (push-to-registry: true). Elle porte sur l'index, dont l'empreinte est lue juste avant.
Les scripts des étapes Préparer les noms, Créer l'index et Lire l'empreinte de l'index ont été exécutés localement, avec le registre de test à la place de GHCR et des valeurs posées à la main là où GitHub les fournirait :
$ export GITHUB_REPOSITORY=Lyneko-Formation/Signalements GITHUB_OUTPUT=$PWD/sortie
$ echo "IMAGE=ghcr.io/${GITHUB_REPOSITORY,,}" # Préparer les noms
IMAGE=ghcr.io/lyneko-formation/signalements
$ export IMAGE=localhost:5000/signalements # le registre de test, à la place de GHCR
$ export DOCKER_METADATA_OUTPUT_JSON='{"tags":["localhost:5000/signalements:main-6ef8842"],"labels":{}}'
$ cd empreintes && ls # ce qu'a déposé download-artifact
38771a2e24dec97d5bb3e1732d68f01ef3aba96119c00be16efb5e89714f57ec
c7f50822dbfdbddfbb6f77db26a13b27886e8c24b4654c7710bed0fc7e32ff43
$ mapfile -t etiquettes < <(jq -r '.tags[] | "--tag=" + .' <<<"$DOCKER_METADATA_OUTPUT_JSON")
$ mapfile -t sources < <(for e in *; do echo "$IMAGE@sha256:$e"; done)
$ printf "%s\n" "${etiquettes[@]}" "${sources[@]}"
--tag=localhost:5000/signalements:main-6ef8842
localhost:5000/signalements@sha256:38771a2e24dec97d5bb3e1732d68f01ef3aba96119c00be16efb5e89714f57ec
localhost:5000/signalements@sha256:c7f50822dbfdbddfbb6f77db26a13b27886e8c24b4654c7710bed0fc7e32ff43
$ docker buildx imagetools create "${etiquettes[@]}" "${sources[@]}" # localement : --builder croise
$ reference=$(jq -r '.tags[0]' <<<"$DOCKER_METADATA_OUTPUT_JSON")
$ empreinte=$(docker buildx imagetools inspect "$reference" --format '{{json .Manifest}}' | jq -r .digest)
$ echo "empreinte=$empreinte" >> "$GITHUB_OUTPUT" && cat "$GITHUB_OUTPUT"
empreinte=sha256:c178385b7d0b82e44b9f4d026df4b616fadcf7684a2e841c0bf023d9e9717861
Remarquez que l'empreinte de l'index (c178…) diffère de celle obtenue plus haut à la main (4979…) avec les mêmes deux images : l'ordre des manifestes fait partie de l'index, et le motif * les a listés dans l'ordre alphabétique de leurs empreintes, l'inverse de l'ordre tapé à la main. Les deux index sont équivalents pour un client, mais ce ne sont pas les mêmes objets : ce qu'on signe ou atteste, c'est une empreinte précise.
Vérifier l'attestation
Une fois l'image publiée, n'importe qui peut vérifier qu'elle a été produite par ce workflow, dans ce dépôt, avec la ligne de commande de GitHub :
$ gh attestation verify oci://ghcr.io/lyneko-formation/signalements:main-6ef8842 \
-R lyneko-formation/signalements
La commande télécharge l'attestation, vérifie sa signature et la chaîne de certificats Sigstore, et contrôle que l'identité du signataire correspond au dépôt indiqué. Les attestations de GitHub sont disponibles pour les dépôts publics sur tous les plans, et pour les dépôts privés seulement avec GitHub Enterprise Cloud. Pour un dépôt privé sur un autre plan, la signature avec cosign décrite dans le cours sur les images reste la voie à suivre.
Publier sur le registre Scaleway
Les applications de Lyneko publient sur le registre de conteneurs de Scaleway, pas sur GHCR. Seule la connexion change. Le workflow de ce site l'écrit ainsi :
- name: Log in to Scaleway Container Registry
if: github.event_name != 'pull_request'
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: nologin
password: ${{ secrets.SCW_SECRET_KEY }}Le registre Scaleway attend le nom d'utilisateur nologin et une clé d'API comme mot de passe. Cette clé est un secret de longue durée : où la ranger, qui peut l'utiliser, et comment s'en passer quand le fournisseur le permet, ce sont les sujets des leçons 9 et 10. (Ce workflow utilise encore docker/login-action@v3 : une montée de version est due, et la leçon 11 montre comment l'automatiser.)
L'alternative empaquetée
Docker publie depuis 2025 des workflows réutilisables officiels, docker/github-builder (version 1.17 en août 2026), qui font exactement ce que cette leçon a construit à la main : une construction par architecture sur son runner natif (ubuntu-24.04-arm pour Arm), puis l'assemblage de l'index, avec les étiquettes de metadata-action. On les appelle avec quelques lignes :
jobs:
image:
uses: docker/github-builder/.github/workflows/build.yml@v1.17.0
permissions:
contents: read
id-token: write
with:
output: image
push: ${{ github.event_name != 'pull_request' }}
platforms: linux/amd64,linux/arm64
meta-images: ghcr.io/lyneko-formation/signalements(On y ajouterait l'authentification au registre, transmise par le paramètre secret registry-auths documenté par Docker.) Savoir le faire à la main reste utile pour comprendre ce qu'il fait, le diagnostiquer, et ajouter ce qu'il ne fait pas, comme le test de fumée natif de notre workflow. La leçon suivante explique ce qu'est un workflow réutilisable, et pourquoi construire dans un workflow partagé renforce la provenance.
Sous le capot
push-by-digest=true demande à BuildKit de pousser les couches et le manifeste sans créer d'étiquette dans le registre. L'image n'est joignable que par son empreinte, ce qui convient : personne ne doit l'utiliser seule, elle n'existe que pour être référencée par l'index. docker buildx imagetools create n'envoie aucune couche : il lit les manifestes des sources, construit le document d'index qui les référence, et le pousse sous les étiquettes demandées. C'est pour cela qu'il s'exécute en une fraction de seconde sur une machine qui n'a jamais vu les images.
Les deux manifestes unknown/unknown de l'index sont des attestations de provenance au format in-toto, que Buildx ajoute par défaut aux images publiées (niveau min), sauf si l'on pose BUILDX_NO_DEFAULT_ATTESTATIONS ou provenance: false. Ils sont rangés dans l'index avec une plateforme inconnue pour qu'aucun client ne les choisisse par erreur. actions/attest, lui, produit une autre attestation, signée par Sigstore avec l'identité du workflow, et la range à part dans le registre (et dans l'API d'attestations de GitHub). Les deux sont complémentaires : la première décrit la construction, la seconde prouve qui l'a faite.
Le runner Arm hébergé est une machine virtuelle Arm64 complète, avec une image logicielle comparable à celle de son équivalent x86 (Docker, Buildx, jq, Python), maintenue séparément : son contenu exact peut différer. Sur un dépôt public, elle a 4 processeurs et 16 Go de mémoire ; sur un dépôt privé, 2 et 8.
Pièges courants
invalid reference format: repository name (Lyneko-Formation/Signalements) must be lowercase. Le nom du dépôt contient des majuscules et a été utilisé tel quel. Passez par ${GITHUB_REPOSITORY,,} ou par metadata-action, qui normalise.
exec format error dans un test. L'image testée n'est pas de l'architecture du runner : l'étape a visé l'index au lieu de l'empreinte de sa propre architecture, ou la matrice associe la mauvaise plateforme au runner.
Le cache ne sert jamais pour l'une des architectures. Les deux écrivent sous la même portée. Donnez un scope par architecture.
denied: installation not allowed to Write organization package. Le job n'a pas packages: write, ou le paquet GHCR existe déjà et n'est pas relié à ce dépôt : dans les réglages du paquet, donnez au dépôt l'accès en écriture.
La demande de fusion d'une bifurcation échoue à la publication. Son jeton est en lecture seule (leçon 11) : c'est pourquoi le workflow ne publie jamais sur pull_request.
La construction croisée échoue sur un paquet. No matching distribution found avec --platform : pip n'accepte que les étiquettes données. Listez-les toutes, ou passez aux runners natifs.
L'empreinte de l'index change d'un run à l'autre pour les mêmes images. L'ordre des sources, ou les annotations, diffèrent. C'est sans conséquence pour l'exécution, mais une attestation ou une signature porte sur une empreinte précise : vérifiez toujours l'empreinte que le workflow a réellement produite.
Sécurité
Les droits d'écriture au plus près. packages: write permet de publier, et donc de remplacer une étiquette par une image arbitraire. Il ne doit exister que sur les jobs qui publient, jamais au niveau du workflow, et jamais sur un déclencheur à faible confiance. Le jeton de GHCR est limité aux paquets du dépôt.
Ne rien publier depuis une demande de fusion. Une image construite depuis une demande de fusion contient du code non relu ; la publier sous une étiquette, même pr-7, la rend récupérable par n'importe quel système qui suivrait cette étiquette.
Référencer par empreinte en aval. L'index est étiqueté main-6ef8842, mais un déploiement sûr épingle l'empreinte de l'index : une étiquette peut être déplacée par quiconque a le droit d'écrire dans le registre.
L'attestation ne vaut que si on la vérifie. Produire une attestation ne protège de rien tant que le déploiement n'exige pas sa vérification. Côté Kubernetes, c'est le rôle d'un contrôleur d'admission (Kyverno, Sigstore policy-controller) ; le cours Construire des images de conteneurs et le chapitre Sécuriser y reviennent.
Le cache de construction est partagé. Les règles d'empoisonnement de la leçon 5 s'appliquent au cache type=gha : les demandes de fusion le lisent sans l'écrire, et une construction de version publiée pourrait s'en passer entièrement (no-cache: true), au prix de quelques minutes.
En production
Le coût. Sur un dépôt public, les runners Arm sont gratuits. Sur un dépôt privé, deux jobs de construction en parallèle plus un job d'assemblage d'une minute coûtent un peu plus qu'une construction unique émulée quand les dépendances sont précompilées ; quand elles se compilent, l'émulation coûte au contraire plus cher et dure beaucoup plus longtemps : une construction émulée de vingt minutes est facturée vingt minutes.
Faut-il vraiment plusieurs architectures ? Pour une application qui ne tourne que sur un cluster amd64, non : l'image Arm ne servirait qu'aux postes des développeurs, qui peuvent la construire eux-mêmes. Le multi-architecture se justifie quand la production a, ou va avoir, des nœuds Arm, ou quand l'image est distribuée à des tiers.
Tester ce qui part en production. Le test de fumée natif de chaque architecture est la garantie minimale. Si la production tourne sur Arm, les tests d'intégration de la leçon 4 devraient aussi tourner sur ubuntu-24.04-arm, par une dimension de plus dans la matrice.
La provenance renforcée. Construire dans un workflow réutilisable partagé, que les équipes ne peuvent pas modifier, permet d'atteindre le niveau 3 de construction SLSA avec les attestations de GitHub, contre le niveau 2 pour un workflow propre au dépôt. C'est l'un des arguments de la leçon suivante.
Exercices
1. Un collègue propose de remplacer les deux jobs de construction par un seul job avec docker/setup-qemu-action et platforms: linux/amd64,linux/arm64. Donnez deux avantages et deux inconvénients pour Signalements.
Solution
Avantages : un seul job, sans job d'assemblage ni artefacts d'empreintes, donc un workflow plus court à lire ; une seule étape de publication qui crée directement l'index étiqueté. Inconvénients : chaque RUN de la variante Arm est émulé, ce qui allonge la construction (modestement pour Signalements, dont les dépendances sont précompilées, beaucoup pour un projet qui compile) ; et le test de fumée de la variante Arm s'exécuterait lui aussi sous émulation, ce qui teste moins fidèlement le comportement réel sur un processeur Arm.
2. Pourquoi le job construire n'utilise-t-il pas une sortie de job pour transmettre l'empreinte au job assembler ? Proposez une autre solution que les artefacts.
Solution
Une sortie de job a une seule valeur pour tout le job, alors que la matrice en produit deux (une par architecture) : on ne peut pas y recueillir les deux empreintes. Autre solution : ne pas transmettre les empreintes du tout, et faire publier chaque architecture sous une étiquette temporaire propre au run (par exemple run-<numéro>-arm64), que le job d'assemblage lit, assemble, puis supprime. Plus fragile (étiquettes à nettoyer, visibles dans le registre) : les artefacts restent la solution la plus propre.
3. Un collègue constate que l'image publiée pour v1.2.0 porte aussi l'étiquette latest, alors que le workflow ne la demande nulle part. D'où vient-elle ? Modifiez metadata-action pour qu'aucune image ne porte latest.
Solution
Du réglage par défaut flavor: latest=auto, qui ajoute latest aux images produites par une règle type=semver. Pour la supprimer :
flavor: |
latest=false
tags: |
type=sha,prefix=main-,enable={{is_default_branch}}
type=semver,pattern={{version}}
type=semver,pattern={{major}}.{{minor}}On peut discuter l'intérêt de latest : c'est une étiquette mouvante par définition, que les déploiements ne doivent pas suivre. Elle sert surtout aux personnes qui essaient l'image à la main ; si on la garde, c'est un choix, pas un effet de bord.
4. Dans le Dockerfile croisé, que se passerait-il si l'on ajoutait une dépendance qui ne publie pas de paquet précompilé pour aarch64 ? Et avec le workflow à runners natifs ?
Solution
Avec la construction croisée, --only-binary=:all: interdit de compiler depuis les sources : pip échoue avec No matching distribution found, et il n'y a pas de solution simple (il faudrait compiler pour Arm sur une machine x86, donc une chaîne de compilation croisée pour l'extension C). Avec les runners natifs, la construction Arm s'exécute sur une machine Arm : pip télécharge les sources et compile l'extension nativement (à condition que l'image contienne un compilateur), plus lentement mais sans erreur. C'est l'avantage de fond des runners natifs : ils n'imposent rien aux dépendances.
5. Votre organisation est sur le plan gratuit, et Signalements est un dépôt privé. Calculez le coût mensuel du workflow image.yml pour 22 jours ouvrés, avec 10 fusions sur main par jour, si chaque construction dure 3 minutes et l'assemblage 1 minute. Les demandes de fusion construisent aussi les deux architectures (sans assemblage), à raison de 30 runs par jour.
Solution
Par fusion : 3 minutes x86 + 3 minutes Arm + 1 minute x86 d'assemblage. Par demande de fusion : 3 + 3. Par jour : 10 × 7 = 70 minutes pour main et 30 × 6 = 180 pour les demandes de fusion, soit 250 minutes, dont 120 sur Arm (10 × 3 + 30 × 3) et 130 sur x86. Sur 22 jours : 2 860 minutes x86 et 2 640 minutes Arm, soit 5 500 minutes, dont 3 500 au-delà des 2 000 incluses. Le coût du dépassement dépend des minutes que couvre le forfait : si elles couvrent d'abord le x86, il reste 860 minutes x86 (5,16 dollars) et 2 640 minutes Arm (13,20 dollars), soit 18,36 dollars ; si elles couvrent d'abord l'Arm, il reste 640 minutes Arm (3,20 dollars) et 2 860 minutes x86 (17,16 dollars), soit 20,36 dollars. Environ 20 dollars par mois, donc. Un dépôt public ne coûterait rien.
Récapitulatif
- Une image multi-architecture est un index qui référence une image par plateforme ; chaque client choisit la sienne.
- Trois façons de construire pour une autre architecture : émulation (simple, lente), construction croisée (rapide, fragile, intestable sur place), runners natifs (rapide et testable).
- Avec des runners natifs : construire chaque architecture sur son runner, publier par empreinte, transmettre les empreintes par artefact, assembler l'index avec
imagetools create, puis l'étiqueter. - Tester l'image publiée par empreinte, sur son architecture native, avant de l'étiqueter.
packages: writeseulement sur les jobs qui publient ; rien n'est publié depuis une demande de fusion ; noms d'images en minuscules.- Cache
type=ghaavecmode=maxet unscopepar architecture. actions/attestatteste l'index ;gh attestation verifyle vérifie (dépôts publics, ou Enterprise Cloud).
Pour aller plus loin
- Docker Docs, Multi-platform builds : les trois stratégies, en détail.
- docker/github-builder : les workflows réutilisables officiels de Docker.
- OCI Image Specification, Image Index : le format de l'index.
- Leçon suivante : Actions composites et workflows réutilisables.
Sources
- Docker Docs, Multi-platform image with GitHub Actions
- Docker Docs, Multi-platform builds (émulation, nœuds natifs, compilation croisée)
- Docker Docs, Cache management with GitHub Actions (type=gha)
- docker/metadata-action, README (types d'étiquettes, normalisation des noms)
- docker/github-builder, workflows réutilisables officiels de Docker
- GitHub Docs, GitHub-hosted runners reference (runners Arm)
- GitHub Docs, Working with the Container registry (GHCR, GITHUB_TOKEN)
- GitHub Docs, Using artifact attestations
- OCI Image Specification, Image Index
- pip, documentation de pip install : --platform et --only-binary