Registres et images — Distribution et sécurité#

Hide code cell source

import matplotlib
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
import matplotlib.patheffects as pe
from matplotlib.patches import FancyBboxPatch, FancyArrowPatch
import numpy as np
import pandas as pd
import seaborn as sns
import json
import hashlib
import re
from collections import defaultdict

sns.set_theme(style="whitegrid", palette="muted")
plt.rcParams.update({
    "figure.dpi": 120,
    "font.family": "sans-serif",
    "axes.spines.top": False,
    "axes.spines.right": False,
})

Docker Hub : le registre public de référence#

Un registre est un serveur qui stocke et distribue des images Docker. Docker Hub (hub.docker.com) est le registre public par défaut — quand vous faites docker pull nginx, Docker va chercher l’image sur Docker Hub.

Catégories d’images sur Docker Hub#

Catégorie

Exemple

Caractéristique

Images officielles

nginx, postgres, python

Maintenues par Docker Inc. et les éditeurs, vérifiées, documentées

Images vérifiées

bitnami/postgresql, elastic/elasticsearch

Publiées par des éditeurs de confiance certifiés par Docker

Images communautaires

myuser/myapp

Publiées par n’importe qui — à utiliser avec précaution

Images d’organisation

mycompany/backend

Namespace privé d’une entreprise

Règle d’or pour les images de base

Ne faites jamais confiance à une image inconnue. Privilégiez toujours les images officielles ou les images vérifiées pour vos images de base. Une image malveillante peut contenir des backdoors ou des mineurs de cryptomonnaie.

Structure d’une référence d’image#

Une référence d’image Docker est plus complexe qu’elle n’y paraît. Voici sa structure complète :

registry/namespace/nom:tag@digest
    │        │       │    │    └─ SHA256 immuable de l'image
    │        │       │    └─ Version (latest, 3.12, alpine…)
    │        │       └─ Nom de l'image (nginx, python…)
    │        └─ Utilisateur ou organisation (monuser, bitnami…)
    └─ Registre (docker.io par défaut, ghcr.io, gcr.io…)

Exemples concrets :

Référence

Signification

nginx

docker.io/library/nginx:latest (tout implicite)

python:3.12-slim

docker.io/library/python:3.12-slim

myuser/myapp:v1.2.3

docker.io/myuser/myapp:v1.2.3

ghcr.io/org/api:main

GitHub Container Registry, namespace org

python@sha256:a1b2c3…

Image exacte identifiée par son digest SHA256

gcr.io/myproject/app:prod

Google Container Registry

Commandes de base pour les registres#

# S'authentifier sur Docker Hub
docker login

# S'authentifier sur un registre privé
docker login registry.mycompany.com

# Se déconnecter
docker logout registry.mycompany.com

# Télécharger une image
docker pull python:3.12-slim

# Télécharger avec son digest (immuable)
docker pull python@sha256:abc123def456...

# Tagger une image pour un registre
docker tag myapp:local registry.mycompany.com/backend/myapp:v2.1.0

# Pousser une image vers un registre
docker push registry.mycompany.com/backend/myapp:v2.1.0

# Rechercher sur Docker Hub
docker search nginx --filter is-official=true

# Lister les images locales
docker images

# Supprimer les images non utilisées
docker image prune

# Supprimer toutes les images inutilisées (y compris celles avec tag)
docker image prune -a

Registres privés : comparaison#

Quand votre entreprise ne peut pas utiliser Docker Hub (confidentialité, compliance, performance), elle déploie un registre privé :

fig, ax = plt.subplots(figsize=(13, 5.5))
ax.axis("off")
ax.set_facecolor("#f8f9fa")
fig.patch.set_facecolor("#f8f9fa")
ax.set_title("Comparaison des registres privés Docker", fontsize=13, fontweight="bold", pad=12)

registres = [
    ("Harbor\n(open-source)", ["Auto-hébergé", "UI web complète", "RBAC avancé", "Vulnerability scan", "Réplication", "Signatures (Notary)", "Proxy cache", "Gratuit"]),
    ("AWS ECR", ["Géré par AWS", "Intégré IAM", "Lifecycle policies", "Trivy scan natif", "Multi-région", "Immutable tags", "Pay-per-use", "Pas d'UI riche"]),
    ("GCP Artifact\nRegistry", ["Géré par GCP", "Multi-format", "VPC-SC support", "Binary auth", "Régional/global", "IAM intégré", "Scans Container Analysis", "Remplace GCR"]),
    ("Azure ACR", ["Géré par Azure", "Tâches ACR (CI)", "Geo-réplication", "Defender for Cloud", "Webhooks", "Token auth", "Intégration AKS", "Niveaux Basic/Standard/Premium"]),
]

colors = ["#d5f5e3", "#fde8d8", "#d6eaf8", "#fdf2f8"]
x_starts = [0.02, 0.26, 0.52, 0.76]

for (name, features), color, x in zip(registres, colors, x_starts):
    box = FancyBboxPatch((x, 0.05), 0.22, 0.88, boxstyle="round,pad=0.01",
                          facecolor=color, edgecolor="#555", linewidth=1.5,
                          transform=ax.transAxes, clip_on=False)
    ax.add_patch(box)
    ax.text(x + 0.11, 0.88, name, ha="center", va="center", fontsize=10,
            fontweight="bold", color="#2c3e50", transform=ax.transAxes)
    for i, feat in enumerate(features):
        y_pos = 0.78 - i * 0.092
        ax.text(x + 0.02, y_pos, f"• {feat}", ha="left", va="center",
                fontsize=8.5, color="#34495e", transform=ax.transAxes)

plt.tight_layout()
plt.show()
_images/457c3028f738f000ad004ad982965bb80d7d13fc64560207fbbbe8cace1cfedc.png

Distribution d’images : manifestes et layers#

Comment une image est-elle stockée et distribuée ?#

Une image Docker n’est pas un seul fichier monolithique. Elle est composée de layers (couches), et un manifest décrit l’ensemble :

# Simulation d'un manifest d'image Docker (Image Manifest v2, Schema 2)
manifest_exemple = {
    "schemaVersion": 2,
    "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
    "config": {
        "mediaType": "application/vnd.docker.container.image.v1+json",
        "size": 7023,
        "digest": "sha256:b5b2b2c507a0944348e0303114d8d93aaaa081732b86451d9bce1f432a537bc7"
    },
    "layers": [
        {
            "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
            "size": 31366757,
            "digest": "sha256:f1b5933fe4b5f49bbe8258745cf396afe07e625bdab3168e364daf7c956b6b81"
        },
        {
            "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
            "size": 1073,
            "digest": "sha256:9b9b7f3d56a01e3d9076874990c62e7a516cc4032f784f421574d06b18ef9aa4"
        },
        {
            "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
            "size": 11691195,
            "digest": "sha256:14ca88e9f6723ce82bc14b96ccfc5123742032c1f8a2b7ab5a11ea3894b5b308"
        }
    ]
}

print("Manifest Image Manifest v2 :")
print(json.dumps(manifest_exemple, indent=2))
print()

total_size = sum(l["size"] for l in manifest_exemple["layers"])
print(f"Nombre de layers : {len(manifest_exemple['layers'])}")
print(f"Taille totale des layers : {total_size / 1024 / 1024:.1f} MB")
print(f"Taille de la config : {manifest_exemple['config']['size']} bytes")
print()
print("Principe de déduplication :")
print("  Si deux images partagent un layer (même digest SHA256),")
print("  Docker ne le télécharge et ne le stocke QU'UNE SEULE FOIS.")
Manifest Image Manifest v2 :
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.docker.distribution.manifest.v2+json",
  "config": {
    "mediaType": "application/vnd.docker.container.image.v1+json",
    "size": 7023,
    "digest": "sha256:b5b2b2c507a0944348e0303114d8d93aaaa081732b86451d9bce1f432a537bc7"
  },
  "layers": [
    {
      "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
      "size": 31366757,
      "digest": "sha256:f1b5933fe4b5f49bbe8258745cf396afe07e625bdab3168e364daf7c956b6b81"
    },
    {
      "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
      "size": 1073,
      "digest": "sha256:9b9b7f3d56a01e3d9076874990c62e7a516cc4032f784f421574d06b18ef9aa4"
    },
    {
      "mediaType": "application/vnd.docker.image.rootfs.diff.tar.gzip",
      "size": 11691195,
      "digest": "sha256:14ca88e9f6723ce82bc14b96ccfc5123742032c1f8a2b7ab5a11ea3894b5b308"
    }
  ]
}

Nombre de layers : 3
Taille totale des layers : 41.1 MB
Taille de la config : 7023 bytes

Principe de déduplication :
  Si deux images partagent un layer (même digest SHA256),
  Docker ne le télécharge et ne le stocke QU'UNE SEULE FOIS.

Multi-arch : une image, plusieurs architectures#

Avec docker buildx, vous pouvez créer une image qui fonctionne sur différentes architectures (x86_64, ARM64, ARMv7…). Un manifest list (ou « OCI image index ») pointe vers plusieurs manifestes :

# Créer un builder multi-plateforme
docker buildx create --name multiarch --use

# Construire et pousser pour x86_64 et ARM64
docker buildx build \
  --platform linux/amd64,linux/arm64 \
  --tag myregistry/myapp:v1.2.0 \
  --push \
  .

# Inspecter un manifest multi-arch
docker manifest inspect python:3.12-slim
fig, axes = plt.subplots(1, 2, figsize=(13, 5.5))

# --- Structure du manifest multi-arch ---
ax1 = axes[0]
ax1.set_xlim(0, 10)
ax1.set_ylim(0, 9)
ax1.axis("off")
ax1.set_facecolor("#f8f9fa")
ax1.set_title("Manifest list multi-architecture", fontsize=11, fontweight="bold", pad=8)

# Manifest list principal
ml_box = FancyBboxPatch((2.5, 7.0), 5.0, 1.5, boxstyle="round,pad=0.15",
                         facecolor="#d6eaf8", edgecolor="#2980b9", linewidth=2)
ax1.add_patch(ml_box)
ax1.text(5, 7.75, "Manifest List", ha="center", va="center", fontsize=11, fontweight="bold", color="#1a5276")
ax1.text(5, 7.2, "python:3.12-slim", ha="center", va="center", fontsize=9, color="#555",
         bbox=dict(boxstyle="round,pad=0.2", facecolor="white", edgecolor="#aaa"))

# Manifestes par arch
arch_data = [
    (1.0, 4.8, "linux/amd64", "sha256:a1b2c3…", "#d5f5e3"),
    (4.5, 4.8, "linux/arm64", "sha256:d4e5f6…", "#fde8d8"),
    (8.0, 4.8, "linux/arm/v7", "sha256:g7h8i9…", "#fdf2f8"),
]
for x, y, arch, digest, color in arch_data:
    box = FancyBboxPatch((x - 1.3, y - 0.5), 2.6, 1.6, boxstyle="round,pad=0.1",
                          facecolor=color, edgecolor="#555", linewidth=1.5)
    ax1.add_patch(box)
    ax1.text(x, y + 0.65, arch, ha="center", va="center", fontsize=9.5, fontweight="bold", color="#2c3e50")
    ax1.text(x, y + 0.1, digest, ha="center", va="center", fontsize=7.5, color="#555")
    ax1.text(x, y - 0.2, "Manifest v2", ha="center", va="center", fontsize=7.5, color="#888")
    ax1.annotate("", xy=(x, y + 1.1), xytext=(5, 7.0),
                 arrowprops=dict(arrowstyle="-|>", color="#555", lw=1.5))

# Layers sous chaque manifest
for x, y, _, _, color in arch_data:
    for j, (size, name) in enumerate([(32, "OS base"), (2, "Python bins"), (5, "pip packages")]):
        ly = y - 1.4 - j * 0.55
        ly_box = FancyBboxPatch((x - 1.2, ly - 0.22), 2.4, 0.42, boxstyle="round,pad=0.05",
                                 facecolor="white", edgecolor="#bbb", linewidth=1)
        ax1.add_patch(ly_box)
        ax1.text(x, ly, f"Layer: {name} ({size}MB)", ha="center", va="center", fontsize=7, color="#555")

# --- Déduplication des layers ---
ax2 = axes[1]
ax2.set_facecolor("#f8f9fa")
ax2.set_title("Déduplication des layers entre images", fontsize=11, fontweight="bold", pad=8)

images = ["myapp:v1.0", "myapp:v1.1", "myapp:v2.0"]
layers_content = [
    ["OS debian (120MB)", "Python 3.12 (45MB)", "deps v1 (23MB)", "app code v1 (2MB)"],
    ["OS debian (120MB)", "Python 3.12 (45MB)", "deps v1 (23MB)", "app code v2 (2.1MB)"],
    ["OS debian (120MB)", "Python 3.12 (45MB)", "deps v2 (28MB)", "app code v3 (2.3MB)"],
]
layer_colors = {
    "OS debian (120MB)": "#aed6f1",
    "Python 3.12 (45MB)": "#a9dfbf",
    "deps v1 (23MB)": "#f9e79f",
    "deps v2 (28MB)": "#f5cba7",
    "app code v1 (2MB)": "#d7bde2",
    "app code v2 (2.1MB)": "#c39bd3",
    "app code v3 (2.3MB)": "#af7ac5",
}

x_positions = [1.5, 3.5, 5.5]
bar_width = 1.4

for xi, (img_name, layers) in zip(x_positions, zip(images, layers_content)):
    y_bottom = 0
    for layer in layers:
        size_mb = float(re.search(r"([\d.]+)MB", layer).group(1))
        color = layer_colors[layer]
        ax2.bar(xi, size_mb, bottom=y_bottom, width=bar_width, color=color,
                edgecolor="white", linewidth=1.5)
        if size_mb > 10:
            ax2.text(xi, y_bottom + size_mb / 2, layer.split("(")[0].strip(),
                     ha="center", va="center", fontsize=7.5, color="#2c3e50")
        y_bottom += size_mb

    ax2.text(xi, -8, img_name, ha="center", va="top", fontsize=9.5, fontweight="bold", color="#2c3e50")

# Annotation déduplication
ax2.annotate("Partagé !\n(téléchargé 1×)", xy=(3.5, 90), xytext=(6.5, 110),
             arrowprops=dict(arrowstyle="->", color="#e74c3c", lw=1.5),
             fontsize=9, color="#e74c3c", fontweight="bold")
ax2.set_ylabel("Taille (MB)", fontsize=10)
ax2.set_xticks([])
ax2.set_title("Déduplication des layers entre images", fontsize=11, fontweight="bold", pad=8)

# Légende des layers partagés
total_no_dedup = sum(sum(float(re.search(r"([\d.]+)MB", l).group(1)) for l in layers)
                     for layers in layers_content)
unique_layers = set(l for layers in layers_content for l in layers)
total_dedup = sum(float(re.search(r"([\d.]+)MB", l).group(1)) for l in unique_layers)
ax2.text(0.97, 0.97, f"Sans dédup : {total_no_dedup:.0f} MB\nAvec dédup : {total_dedup:.0f} MB\nEconomie : {100*(1-total_dedup/total_no_dedup):.0f}%",
         ha="right", va="top", transform=ax2.transAxes, fontsize=9,
         bbox=dict(boxstyle="round,pad=0.4", facecolor="#eafaf1", edgecolor="#27ae60"))

plt.tight_layout()
plt.show()
_images/49afcac24ee24ac2b705349845be4e4b547ff5b560aeccd3c035c98a711eeeb0.png

Tags, versions et immutabilité#

Conventions de nommage des tags#

Le problème de latest

Le tag latest ne signifie pas la dernière version stable — c’est juste un tag comme les autres. Si vous oubliez de spécifier un tag, Docker utilise latest par défaut. En production, utilisez toujours un tag précis (v2.1.3 ou un digest SHA256) pour garantir la reproductibilité.

Convention

Exemple

Usage

latest

nginx:latest

Dev uniquement — changeant, non reproductible

SemVer

python:3.12.3

Production — version précise

SemVer tronqué

python:3.12

Reçoit les patches de 3.12.x

Variante

python:3.12-slim

Version allégée

SHA court

myapp:abc1234

Hash du commit Git — très courant en CI/CD

Digest

python@sha256:…

100% immuable — la meilleure garantie

# Épingler par digest — jamais surpris par un changement
FROM python@sha256:a1b2c507a0944348e0303114d8d93aaaa081732b86451d9bce1f432a537bc7

Vulnerability scanning : analyser la sécurité des images#

Un scanner de vulnérabilités analyse les packages installés dans une image et les compare aux bases de données CVE (Common Vulnerabilities and Exposures).

# Trivy — scanner open-source très populaire
trivy image python:3.12-slim

# Docker Scout — intégré dans Docker Desktop
docker scout cves python:3.12-slim

# Scan dans un pipeline CI (GitHub Actions)
# - uses: aquasecurity/trivy-action@master
#   with:
#     image-ref: 'myapp:${{ github.sha }}'
#     severity: 'CRITICAL,HIGH'
#     exit-code: '1'   # Fail le pipeline si vulnérabilités critiques
# Simulation d'un rapport de vulnérabilités
vuln_data = {
    "image": "python:3.11-slim",
    "digest": "sha256:b5b2b2c5…",
    "scan_date": "2026-03-21",
    "vulnerabilities": [
        {"id": "CVE-2023-4563", "pkg": "libssl1.1", "severity": "CRITICAL", "fixed_in": "1.1.1w-0+deb11u2"},
        {"id": "CVE-2024-0727", "pkg": "openssl", "severity": "HIGH", "fixed_in": "3.0.12"},
        {"id": "CVE-2023-5752", "pkg": "pip", "severity": "HIGH", "fixed_in": "23.3"},
        {"id": "CVE-2024-3094", "pkg": "xz-utils", "severity": "CRITICAL", "fixed_in": "5.6.2"},
        {"id": "CVE-2023-6246", "pkg": "libc6", "severity": "HIGH", "fixed_in": "2.36-9+deb12u4"},
        {"id": "CVE-2024-0646", "pkg": "linux-libc-dev", "severity": "MEDIUM", "fixed_in": "6.1.76-1"},
        {"id": "CVE-2023-5678", "pkg": "libssl1.1", "severity": "MEDIUM", "fixed_in": "1.1.1w-0+deb11u2"},
        {"id": "CVE-2024-2236", "pkg": "libgcrypt20", "severity": "MEDIUM", "fixed_in": "1.10.3-2"},
        {"id": "CVE-2023-7104", "pkg": "libsqlite3-0", "severity": "LOW", "fixed_in": "3.40.1-2"},
        {"id": "CVE-2023-4016", "pkg": "procps", "severity": "LOW", "fixed_in": "2:4.0.2-3"},
    ]
}

# Affichage du rapport
severity_colors = {"CRITICAL": "#c0392b", "HIGH": "#e67e22", "MEDIUM": "#f1c40f", "LOW": "#27ae60"}
severity_order = ["CRITICAL", "HIGH", "MEDIUM", "LOW"]

counts = defaultdict(int)
for v in vuln_data["vulnerabilities"]:
    counts[v["severity"]] += 1

print(f"Rapport de vulnérabilités — {vuln_data['image']}")
print(f"Scané le : {vuln_data['scan_date']}")
print(f"Digest   : {vuln_data['digest']}")
print()
print(f"{'Sévérité':<12} {'Nombre':>8}  {'Barre'}")
print("-" * 45)
for sev in severity_order:
    n = counts.get(sev, 0)
    bar = "█" * n
    print(f"  {sev:<10} {n:>6}   {bar}")

print()
print(f"{'CVE':<20} {'Package':<20} {'Sévérité':<10} {'Corrigé dans'}")
print("-" * 70)
for v in sorted(vuln_data["vulnerabilities"],
                key=lambda x: severity_order.index(x["severity"])):
    print(f"  {v['id']:<18} {v['pkg']:<20} {v['severity']:<10} {v['fixed_in']}")
Rapport de vulnérabilités — python:3.11-slim
Scané le : 2026-03-21
Digest   : sha256:b5b2b2c5…

Sévérité       Nombre  Barre
---------------------------------------------
  CRITICAL        2   ██
  HIGH            3   ███
  MEDIUM          3   ███
  LOW             2   ██

CVE                  Package              Sévérité   Corrigé dans
----------------------------------------------------------------------
  CVE-2023-4563      libssl1.1            CRITICAL   1.1.1w-0+deb11u2
  CVE-2024-3094      xz-utils             CRITICAL   5.6.2
  CVE-2024-0727      openssl              HIGH       3.0.12
  CVE-2023-5752      pip                  HIGH       23.3
  CVE-2023-6246      libc6                HIGH       2.36-9+deb12u4
  CVE-2024-0646      linux-libc-dev       MEDIUM     6.1.76-1
  CVE-2023-5678      libssl1.1            MEDIUM     1.1.1w-0+deb11u2
  CVE-2024-2236      libgcrypt20          MEDIUM     1.10.3-2
  CVE-2023-7104      libsqlite3-0         LOW        3.40.1-2
  CVE-2023-4016      procps               LOW        2:4.0.2-3
fig, axes = plt.subplots(1, 2, figsize=(13, 5))

# --- Camembert des vulnérabilités ---
ax1 = axes[0]
labels = [s for s in severity_order if counts.get(s, 0) > 0]
sizes = [counts[s] for s in labels]
colors_pie = ["#c0392b", "#e67e22", "#f1c40f", "#27ae60"][:len(labels)]
explode = [0.05 if s in ("CRITICAL", "HIGH") else 0 for s in labels]

wedges, texts, autotexts = ax1.pie(
    sizes, labels=labels, colors=colors_pie, explode=explode,
    autopct="%1.0f%%", startangle=90, textprops={"fontsize": 11}
)
for autotext in autotexts:
    autotext.set_fontsize(10)
    autotext.set_fontweight("bold")
ax1.set_title(f"Vulnérabilités par sévérité\n{vuln_data['image']}", fontsize=11, fontweight="bold")

# Annotation centre
total = sum(sizes)
ax1.text(0, 0, f"{total}\ntotal", ha="center", va="center", fontsize=12, fontweight="bold", color="#2c3e50")

# --- Score de sécurité comparatif ---
ax2 = axes[1]
images_compare = [
    ("python:3.9", 3, 8, 12, 5),
    ("python:3.11-slim", 2, 4, 4, 2),
    ("python:3.12-slim", 0, 1, 3, 1),
    ("python:3.12-alpine", 0, 0, 1, 0),
]
severities = ["CRITICAL", "HIGH", "MEDIUM", "LOW"]
sev_colors_bar = ["#c0392b", "#e67e22", "#f1c40f", "#a9dfbf"]

img_names = [img[0] for img in images_compare]
x = np.arange(len(img_names))
width = 0.2

for i, (sev, col) in enumerate(zip(severities, sev_colors_bar)):
    vals = [img[i + 1] for img in images_compare]
    bars = ax2.bar(x + i * width, vals, width, label=sev, color=col, edgecolor="white", lw=1.2)

ax2.set_xticks(x + width * 1.5)
ax2.set_xticklabels(img_names, rotation=20, ha="right", fontsize=9.5)
ax2.set_ylabel("Nombre de vulnérabilités", fontsize=10)
ax2.set_title("Comparaison de la surface d'attaque\nselon l'image de base", fontsize=11, fontweight="bold")
ax2.legend(title="Sévérité", fontsize=9)
ax2.set_ylim(0, 18)

ax2.annotate("Alpine = surface\nd'attaque minimale", xy=(3 + width*1.5, 1.5),
             xytext=(2.5, 12),
             arrowprops=dict(arrowstyle="->", color="#27ae60", lw=1.5),
             fontsize=9, color="#27ae60", fontweight="bold")

plt.tight_layout()
plt.show()
_images/d2b081267525b5e492d48a034e90b99e1229448708666566fb8ede5318e13e8c.png

Signatures d’images et chaîne de confiance#

Cosign et Sigstore#

Cosign (projet Sigstore) permet de signer cryptographiquement des images. Cela garantit que l’image que vous déployez est bien celle qui a été construite par votre pipeline CI, et qu’elle n’a pas été altérée.

# Générer une paire de clés
cosign generate-key-pair

# Signer une image après le push
cosign sign --key cosign.key myregistry/myapp:v1.2.0

# Vérifier la signature avant le déploiement
cosign verify --key cosign.pub myregistry/myapp:v1.2.0

# Signer avec une identité OIDC (keyless — GitHub Actions, etc.)
cosign sign myregistry/myapp:v1.2.0   # Utilise l'identité OIDC du runner CI

# Attacher un SBOM (Software Bill of Materials)
cosign attach sbom --sbom sbom.json myregistry/myapp:v1.2.0

SBOM : l’inventaire de votre image

Un SBOM (Software Bill of Materials) est un inventaire exhaustif de tous les composants d’une image : packages système, bibliothèques Python, licences… C’est devenu une exigence légale dans certains secteurs (gouvernement américain, infrastructures critiques). Outils : syft, trivy sbom, docker sbom.

Code Python : simulation d’un registre minimaliste#

import hashlib
import json
from datetime import datetime, timezone
from typing import Optional

class Layer:
    """Représente une couche (layer) d'image."""
    def __init__(self, content: bytes, media_type: str = "application/vnd.oci.image.layer.v1.tar+gzip"):
        self.content = content
        self.media_type = media_type
        self.digest = self._compute_digest()
        self.size = len(content)

    def _compute_digest(self) -> str:
        return "sha256:" + hashlib.sha256(self.content).hexdigest()

    def __repr__(self):
        return f"Layer(digest={self.digest[:19]}…, size={self.size}B)"


class ImageManifest:
    """Manifest d'une image (simplifié)."""
    def __init__(self, config: dict, layers: list[Layer]):
        self.config = config
        self.layers = layers
        self.created_at = datetime.now(timezone.utc).isoformat()
        self._raw = self._build()
        self.digest = "sha256:" + hashlib.sha256(json.dumps(self._raw).encode()).hexdigest()

    def _build(self) -> dict:
        return {
            "schemaVersion": 2,
            "mediaType": "application/vnd.oci.image.manifest.v1+json",
            "config": {
                "mediaType": "application/vnd.oci.image.config.v1+json",
                "digest": "sha256:" + hashlib.sha256(json.dumps(self.config).encode()).hexdigest(),
                "size": len(json.dumps(self.config).encode()),
            },
            "layers": [
                {"mediaType": l.media_type, "digest": l.digest, "size": l.size}
                for l in self.layers
            ],
        }

    def to_dict(self) -> dict:
        return self._raw


class Registry:
    """Registre d'images minimaliste (en mémoire)."""

    def __init__(self, name: str):
        self.name = name
        self._blobs: dict[str, bytes] = {}           # digest → contenu binaire
        self._manifests: dict[str, ImageManifest] = {} # digest → manifest
        self._tags: dict[str, dict[str, str]] = {}   # namespace/image → {tag: digest}
        self._pull_count: dict[str, int] = defaultdict(int)

    def push(self, namespace: str, name: str, tag: str, manifest: ImageManifest) -> str:
        """Pousse une image (manifest + layers) dans le registre."""
        # Stocker les layers (déduplication automatique par digest)
        for layer in manifest.layers:
            if layer.digest not in self._blobs:
                self._blobs[layer.digest] = layer.content
                print(f"  → Nouveau layer stocké : {layer.digest[:23]}… ({layer.size}B)")
            else:
                print(f"  → Layer déjà présent : {layer.digest[:23]}… (SKIP)")

        # Stocker le manifest
        self._manifests[manifest.digest] = manifest

        # Mettre à jour le tag
        repo = f"{namespace}/{name}"
        if repo not in self._tags:
            self._tags[repo] = {}
        self._tags[repo][tag] = manifest.digest

        ref = f"{self.name}/{repo}:{tag}"
        print(f"  ✓ Image poussée : {ref}")
        print(f"    Digest : {manifest.digest[:30]}…")
        return manifest.digest

    def pull(self, namespace: str, name: str, tag: str) -> Optional[ImageManifest]:
        """Télécharge un manifest depuis le registre."""
        repo = f"{namespace}/{name}"
        if repo not in self._tags or tag not in self._tags[repo]:
            print(f"  ✗ Image introuvable : {self.name}/{repo}:{tag}")
            return None
        digest = self._tags[repo][tag]
        self._pull_count[f"{repo}:{tag}"] += 1
        return self._manifests[digest]

    def tag(self, namespace: str, name: str, src_tag: str, dst_tag: str) -> bool:
        """Ajoute un tag à une image existante."""
        repo = f"{namespace}/{name}"
        if repo not in self._tags or src_tag not in self._tags[repo]:
            return False
        self._tags[repo][dst_tag] = self._tags[repo][src_tag]
        print(f"  ✓ Tag ajouté : {repo}:{src_tag}{repo}:{dst_tag}")
        return True

    def list_tags(self, namespace: str, name: str) -> list[str]:
        repo = f"{namespace}/{name}"
        return list(self._tags.get(repo, {}).keys())

    def stats(self):
        total_blob_size = sum(len(v) for v in self._blobs.values())
        print(f"\nRegistre : {self.name}")
        print(f"  Blobs (layers uniques) : {len(self._blobs)}{total_blob_size}B")
        print(f"  Manifests             : {len(self._manifests)}")
        print(f"  Repositories          : {len(self._tags)}")
        for repo, tags in self._tags.items():
            print(f"    {repo}")
            for tag, digest in tags.items():
                pulls = self._pull_count.get(f"{repo}:{tag}", 0)
                print(f"      :{tag:<15} {digest[:26]}… (pulls: {pulls})")


# === Démonstration ===
print("=" * 60)
print("Simulation d'un registre Docker minimaliste")
print("=" * 60)

registry = Registry("registry.example.com")

# Layer partagé entre v1 et v2 (OS de base)
layer_os = Layer(b"debian-bookworm-slim-base-layer-content-x86_64" * 100)
layer_python = Layer(b"python-3.12-binaries-and-stdlib-content" * 50)
layer_deps_v1 = Layer(b"pip-dependencies-v1-requirements-txt-hash-abc" * 20)
layer_app_v1 = Layer(b"myapp-source-code-version-1.0.0-compiled")
layer_deps_v2 = Layer(b"pip-dependencies-v2-requirements-txt-hash-def" * 20)
layer_app_v2 = Layer(b"myapp-source-code-version-2.0.0-compiled-with-new-feature")

config_v1 = {"author": "CI/CD", "architecture": "amd64", "os": "linux",
             "config": {"Cmd": ["python", "app.py"], "ExposedPorts": {"8000/tcp": {}}}}
config_v2 = {**config_v1, "config": {**config_v1["config"], "Labels": {"version": "2.0.0"}}}

manifest_v1 = ImageManifest(config_v1, [layer_os, layer_python, layer_deps_v1, layer_app_v1])
manifest_v2 = ImageManifest(config_v2, [layer_os, layer_python, layer_deps_v2, layer_app_v2])

print("\n--- Push myapp:v1.0.0 ---")
registry.push("myorg", "myapp", "v1.0.0", manifest_v1)

print("\n--- Push myapp:v2.0.0 (layer OS et Python déjà présents) ---")
registry.push("myorg", "myapp", "v2.0.0", manifest_v2)

print("\n--- Tag latest → v2.0.0 ---")
registry.tag("myorg", "myapp", "v2.0.0", "latest")

print("\n--- Pull myapp:latest ---")
m = registry.pull("myorg", "myapp", "latest")
if m:
    print(f"  Manifest reçu : {len(m.layers)} layers")

print("\n--- Pull myapp:latest (2ème fois) ---")
registry.pull("myorg", "myapp", "latest")

registry.stats()
============================================================
Simulation d'un registre Docker minimaliste
============================================================

--- Push myapp:v1.0.0 ---
  → Nouveau layer stocké : sha256:df5b199c0e8fc3f8… (4600B)
  → Nouveau layer stocké : sha256:6b9ec5f561ebb4ca… (1950B)
  → Nouveau layer stocké : sha256:da328dea50c2d583… (900B)
  → Nouveau layer stocké : sha256:00223c90ce4bb87f… (40B)
  ✓ Image poussée : registry.example.com/myorg/myapp:v1.0.0
    Digest : sha256:55d937cd5cebe052d8642f7…

--- Push myapp:v2.0.0 (layer OS et Python déjà présents) ---
  → Layer déjà présent : sha256:df5b199c0e8fc3f8… (SKIP)
  → Layer déjà présent : sha256:6b9ec5f561ebb4ca… (SKIP)
  → Nouveau layer stocké : sha256:cd4e9e8a11c8a4d8… (900B)
  → Nouveau layer stocké : sha256:6bd6b99a502eeb98… (57B)
  ✓ Image poussée : registry.example.com/myorg/myapp:v2.0.0
    Digest : sha256:87ef1221de4d33188fd2613…

--- Tag latest → v2.0.0 ---
  ✓ Tag ajouté : myorg/myapp:v2.0.0 → myorg/myapp:latest

--- Pull myapp:latest ---
  Manifest reçu : 4 layers

--- Pull myapp:latest (2ème fois) ---

Registre : registry.example.com
  Blobs (layers uniques) : 6 — 8447B
  Manifests             : 2
  Repositories          : 1
    myorg/myapp
      :v1.0.0          sha256:55d937cd5cebe052d86… (pulls: 0)
      :v2.0.0          sha256:87ef1221de4d33188fd… (pulls: 0)
      :latest          sha256:87ef1221de4d33188fd… (pulls: 2)

Nettoyage et politique de rétention#

# Supprimer les images non taggées localement
docker image prune

# Supprimer TOUTES les images inutilisées (y compris avec tag)
docker image prune -a

# Supprimer une image précise
docker rmi myapp:v1.0.0

# Nettoyer tout (images + conteneurs arrêtés + réseaux + cache build)
docker system prune -a

# Voir l'utilisation disque
docker system df
docker system df -v   # Détail par image

Dans Harbor ou les registres cloud, vous pouvez définir des politiques de rétention :

# Exemple de politique de rétention Harbor (résumé)
# Garder les 10 derniers tags triés par date de push
# pour les repos matchant myorg/**
rules:
  - repositories: matching "**"
    retain: 10
    by: lastPushed
    exclude_tags: ["latest", "stable"]

Points clés à retenir#

Résumé du chapitre

  1. Docker Hub est le registre public — préférez les images officielles et vérifiées

  2. Une référence d’image complète : registry/namespace/nom:tag@digest

  3. Les layers sont dédupliqués — deux images partageant les mêmes couches ne les stockent qu’une fois

  4. Ne jamais utiliser latest en production — utilisez des tags précis ou des digests

  5. Multi-arch : docker buildx permet de construire une image pour x86_64 et ARM64 simultanément

  6. Vulnerability scanning (Trivy, Docker Scout) : à intégrer dans votre CI/CD

  7. Cosign/Sigstore : signer vos images pour garantir leur intégrité

  8. Images Alpine : surface d’attaque minimale — à privilégier pour les images de production