lxml in multi-stage Docker images
lxml is a very good Python library for parsing XML. Fast, powerful, and with C dependencies that are sometimes a pain to install.
That last part is the problem with Docker. lxml needs its compiled binaries in the final image, so keeping that image small is harder than usual, and with a multi-stage build it gets a bit trickier still.
It has a fix, though. This is the Dockerfile I use:
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
# lxml binary dependencies
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/*
Step by step, this is what it does.
Create the build stage
There is a build_lxml stage. It holds the development packages that compiling lxml needs, and nothing from it reaches the final image unless we copy it over:
RUN apk add --no-cache build-base gcc musl-dev python3-dev libffi-dev libxml2-dev libxslt-dev
Compile lxml and optimise the wheel
Instead of installing lxml, we build a wheel from it, the same trick as in the post on Python images under 50 MB. Running pip with python -OO compiles the bytecode with optimisations on, and the wheel ends up in /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
Copy the binary dependencies
This is the step that matters. The wheel alone is not enough: lxml links against a handful of .so files that only exist in the build stage, so the final stage has to copy them across by hand.
# lxml binary dependencies
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
Install the wheel
The last line installs the wheel offline, from the folder we copied, with no index lookup:
RUN python -OO -m pip install --no-cache --no-index --find-links=/root/lxml_wheel/*
And that is all. A final image that knows nothing about compilers and still runs lxml.