Image Docker scratch avec Python

Publié le 2026-03-14

En complément de l'article précédent sur les images "distroless", voici comment se passer complètement des images fournies par Google. L'idée principale est d'inclure le strict minimum pour obtenir une image minimaliste viable afin d'exécuter une application Python. Une application de ce type repose sur l'interpréteur Python et des librairies tierces (Polars, Flask, etc...). La problématique pourrait être facilement résolue avec l'utilisation des venv si ceux-ci étaient directement utilisable dans une image scratch.

Python et ses dépendances

Pour visualiser les dépendances de l'interpréteur Python (ici en version 3.14):

ldd /usr/bin/python
        linux-vdso.so.1 (0x00007fb462031000)
        libpython3.14.so.1.0 => /lib64/libpython3.14.so.1.0 (0x00007fb461800000)
        libc.so.6 => /lib64/libc.so.6 (0x00007fb46160d000)
        libm.so.6 => /lib64/libm.so.6 (0x00007fb461f15000)
        /lib64/ld-linux-x86-64.so.2 (0x00007fb462033000)

Il est possible de lister les dépendances systèmes des imports en utilisant la variable d'environnement LD_DEBUG=libs lors de l'exécution d'une simple ligne qui importe les librairies requises:

LD_DEBUG=libs /usr/bin/python -c "import uuid"
     25207:     find library=libpython3.14.so.1.0 [0]; searching
     25207:      search cache=/etc/ld.so.cache
     25207:       trying file=/lib64/libpython3.14.so.1.0
     25207:
     25207:     find library=libc.so.6 [0]; searching
     25207:      search cache=/etc/ld.so.cache
     25207:       trying file=/lib64/libc.so.6
     25207:
     25207:     find library=libm.so.6 [0]; searching
     25207:      search cache=/etc/ld.so.cache
     25207:       trying file=/lib64/libm.so.6
     25207:
     25207:
     25207:     calling init: /lib64/ld-linux-x86-64.so.2
     25207:
     25207:
     25207:     calling init: /lib64/libc.so.6
     25207:
     25207:
     25207:     calling init: /lib64/libm.so.6
     25207:
     25207:
     25207:     calling init: /lib64/libpython3.14.so.1.0
     25207:
     25207:
     25207:     initialize program: /usr/bin/python
     25207:
     25207:
     25207:     transferring control: /usr/bin/python
     25207:
     25207:     find library=libuuid.so.1 [0]; searching
     25207:      search cache=/etc/ld.so.cache
     25207:       trying file=/lib64/libuuid.so.1
     25207:
     25207:
     25207:     calling init: /lib64/libuuid.so.1
     25207:
     25207:
     25207:     calling init: /usr/lib64/python3.14/lib-dynload/_uuid.cpython-314-x86_64-linux-gnu.so
     25207:
     25207:
     25207:     calling fini:  [0]
     25207:
     25207:
     25207:     calling fini: /lib64/libpython3.14.so.1.0 [0]
     25207:
     25207:
     25207:     calling fini: /lib64/libm.so.6 [0]
     25207:
     25207:
     25207:     calling fini: /usr/lib64/python3.14/lib-dynload/_uuid.cpython-314-x86_64-linux-gnu.so [0]
     25207:
     25207:
     25207:     calling fini: /lib64/libuuid.so.1 [0]
     25207:
     25207:
     25207:     calling fini: /lib64/libc.so.6 [0]
     25207:
     25207:
     25207:     calling fini: /lib64/ld-linux-x86-64.so.2 [0]
     25207:

Pour obtenir une liste épurée:

LD_DEBUG=libs /usr/bin/python -c "import uuid" 2>&1 | grep "calling init" | awk '{print $NF}'
/lib64/ld-linux-x86-64.so.2
/lib64/libc.so.6
/lib64/libm.so.6
/lib64/libpython3.14.so.1.0
/lib64/libuuid.so.1
/usr/lib64/python3.14/lib-dynload/_uuid.cpython-314-x86_64-linux-gnu.so

Nous pouvons mettre en évidence les librairies systèmes requise par les import (ici uuid) en comparant avec une exécution sans import:

LD_DEBUG=libs /usr/bin/python -c "" 2>&1 | grep "calling init" | awk '{print $NF}'
/lib64/ld-linux-x86-64.so.2
/lib64/libc.so.6
/lib64/libm.so.6
/lib64/libpython3.14.so.1.0

Le résultat est identique à la commande ldd /usr/bin/python à l'exception de linux-vdso.so.1 qui un fichier particulier. Il est à noter que les librairies systèmes possèdent des dépendances entre elles qui ne sont pas visibles avec notre méthode. L'outil libtree permet de visualiser ces liens:

libtree /usr/lib64/python3.14/lib-dynload/_uuid.cpython-314-x86_64-linux-gnu.so -vvv -p
/usr/lib64/python3.14/lib-dynload/_uuid.cpython-314-x86_64-linux-gnu.so 
├── /lib64/libuuid.so.1 [default path]
│   └── /lib64/libc.so.6 [default path]
│       └── /lib64/ld-linux-x86-64.so.2 [default path]
└── /lib64/libc.so.6 [default path]
    └── /lib64/ld-linux-x86-64.so.2 [default path]

Construire l'image

Nous avons maintenant un moyen de déterminer l'ensemble des librairies systèmes requises pour l'exécution de notre application. Il nous reste désormais à établir un Dockerfile intégrant la récupération de ces librairies (script distroclean.sh), notre application et ses imports (venv) ainsi que l'interpréteur et ses librairies standards.

L'arborescence de notre projet:

├── Dockerfile
├── requirements.txt
└── sources
    ├── distroclean.sh
    └── app.py

Script app.py:

import uuid
print("OK")

Le script distroclean.sh (qui prend en paramètre le chemin vers le venv):

#!/bin/bash
set -euo pipefail

OUTPUT="/tmp/package"
VENV_PATH=$(realpath "$1")

# Récupère les librairies appelées lors des imports dans chaque fichier source (en excluant le venv)
IMPORTS=$(find ~+ -type f -name "*.py" -not -path "$VENV_PATH/*" -print0 | xargs -0 grep -E "^import | import " | sort -u | awk '{print $2}' | paste -s -d ',')
LD_DEBUG=libs "$VENV_PATH/bin/python3" -c "import $IMPORTS" 2>&1 | grep "calling init" | awk '{print $NF}' | sort -u > /tmp/libs.txt

# Copie des librairies identifiées dans /tmp/package
while read f; do
  # Exclusion des librairies du venv car elles seront récupérées avec le venv au complet
  if [[ ! "$f" =~ ^"$VENV_PATH" ]]; then
    echo "$f"
    mkdir -p "$OUTPUT$(dirname $f)"
    cp $f "$OUTPUT$f"
  fi
done </tmp/libs.txt

Le Dockerfile:

FROM python:3.14.3-slim-trixie AS builder
RUN set -xe; \
    useradd --uid 10000 --home-dir /opt/app --shell /usr/bin/bash app; \
    apt-get update; \
    apt-get upgrade --yes

USER app
WORKDIR /opt/app
COPY --chown=app:app sources/ requirements.txt /opt/app/

RUN set -xe; \
    python3 -m venv /opt/app/venv; \
    /opt/app/venv/bin/python3 -m pip install --no-cache-dir -r requirements.txt; \
    bash distroclean.sh /opt/app/venv


FROM scratch
COPY<<EOF /etc/group
root:x:0:
app:x:10000:
EOF
COPY <<EOF /etc/passwd
root:x:0:0:root:/root:/sbin/nologin
app:x:10000:10000:app:/opt/app:/sbin/nologin
EOF

USER app
COPY --from=builder --chown=app:app --exclude=**/pip* --exclude=**/activate* --exclude=**/Activate* --exclude=**/*.sh --exclude=**/*.c --exclude=**/*.h --exclude=**/__pycache__ /opt/app /opt/app
COPY --from=builder --chown=app:app /tmp/package /
COPY --from=builder --chown=app:app --exclude=**/pip* /usr/local/lib/python3.14 /usr/local/lib/python3.14
COPY --from=builder --chown=app:app /usr/local/bin/python3 /usr/local/bin/python3

ENTRYPOINT ["/opt/app/venv/bin/python3", "app.py"]

Les fichiers /etc/group et /etc/passwd sont générés pour avoir un utilisateur autre que root car l'image scratch ne contient pas le nécessaire. Par ailleurs il pourrait être nécessaire d'ajouter les certificats contenus dans /etc/ssl/certs/ si votre application utilise SSL et également /usr/share/zoneinfo/ pour les fuseaux horaires.

Construction de l'image:

docker build . -t scratch-python:1.0.0

Pour lancer l'interpréteur Python interactif depuis l'image générée et lancer l'application pour vérifier que tout fonctionne bien:

docker run --rm -it --entrypoint /opt/app/venv/bin/python3 scratch-python:1.0.0
exec(open("/opt/app/app.py").read())

L'image résultante occupe 27,3 Mo contre 41,3 Mo pour l'image parente python:3.14.3-slim-trixie et ne comprend rien d'autre que le nécessaire pour exécuter l'application (plus de gestionnaire Apt, Bash, etc.).

D'autres tests avec des applications plus complexes important des librairies comme Polars et Arrow montrent un gain d'espace de 130 Mo. Cette méthode reste utilisable pour d'autres exécutables que Python.

Liens complémentaires