lxml es una librería de Python muy buena para parsear XML. Rápida, potente, y con dependencias en C que a veces son un dolor de instalar.

Eso último es el problema con Docker. lxml necesita sus binarios compilados en la imagen final, así que mantener esa imagen pequeña cuesta más de lo normal, y con una build multietapa se complica un poco más todavía.

Tiene arreglo. Este es el Dockerfile que uso:

FROM python:3.8-alpine as base

RUN apk update && \
    apk upgrade

FROM base as build_lxml

RUN apk add --no-cache build-base gcc musl-dev python3-dev libffi-dev libxml2-dev libxslt-dev
RUN python -OO -m pip install --no-cache-dir -U pip && \
    python -OO -m pip wheel --no-cache-dir --wheel-dir=/root/lxml_wheel lxml

FROM base
COPY --from=build_lxml /root/lxml_wheel /root/lxml_wheel

# dependencias binarias de lxml
COPY --from=build_lxml /usr/lib/libxslt.so.1 /usr/lib/libxslt.so.1
COPY --from=build_lxml /usr/lib/libexslt.so.0 /usr/lib/libexslt.so.0
COPY --from=build_lxml /usr/lib/libxml2.so.2 /usr/lib/libxml2.so.2
COPY --from=build_lxml /usr/lib/libgcrypt.so.20 /usr/lib/libgcrypt.so.20
COPY --from=build_lxml /usr/lib/libgpg-error.so.0 /usr/lib/libgpg-error.so.0

RUN python -OO -m pip install --no-cache --no-index --find-links=/root/lxml_wheel/*

Paso a paso, esto es lo que hace.

Crear la etapa de compilación

Hay una etapa build_lxml. Lleva los paquetes de desarrollo que hacen falta para compilar lxml, y nada de ahí llega a la imagen final si no lo copiamos nosotros:

RUN apk add --no-cache build-base gcc musl-dev python3-dev libffi-dev libxml2-dev libxslt-dev

Compilar lxml y optimizar el wheel

En vez de instalar lxml, construimos un wheel con él, el mismo truco que en el post sobre imágenes de Python de menos de 50 MB. Lanzar pip con python -OO compila el bytecode con las optimizaciones activadas, y el wheel acaba en /root/lxml_wheel:

RUN python -OO -m pip install --no-cache-dir -U pip && \
    python -OO -m pip wheel --no-cache-dir --wheel-dir=/root/lxml_wheel lxml

Copiar las dependencias binarias

Este es el paso que importa. Con el wheel solo no basta: lxml enlaza contra unos cuantos .so que solo existen en la etapa de compilación, así que la etapa final tiene que copiarlos a mano.

# dependencias binarias de lxml
COPY --from=build_lxml /usr/lib/libxslt.so.1 /usr/lib/libxslt.so.1
COPY --from=build_lxml /usr/lib/libexslt.so.0 /usr/lib/libexslt.so.0
COPY --from=build_lxml /usr/lib/libxml2.so.2 /usr/lib/libxml2.so.2
COPY --from=build_lxml /usr/lib/libgcrypt.so.20 /usr/lib/libgcrypt.so.20
COPY --from=build_lxml /usr/lib/libgpg-error.so.0 /usr/lib/libgpg-error.so.0

Instalar el wheel

La última línea instala el wheel sin conexión, desde la carpeta que copiamos y sin consultar ningún índice:

RUN python -OO -m pip install --no-cache --no-index --find-links=/root/lxml_wheel/*

Y ya está. Una imagen final que no sabe nada de compiladores y aun así ejecuta lxml.