Skip to content

Base CI and PROD images on hardened Python images - #73038

Open
potiuk wants to merge 19 commits into
mainfrom
hardened-python-base-images
Open

potiuk wants to merge 19 commits into
mainfrom
hardened-python-base-images

Conversation

@potiuk

@potiuk potiuk commented Sep 12, 2026 •

Copy link
Copy Markdown
Member

The CI image and the default PROD images now consume a Docker Hardened Image for Python on
Debian 13 "trixie" instead of compiling CPython from source on top of debian:bookworm-slim. The previous
way of building stays available for the PROD image as the deprecated legacy flavor.

Discussion: https://lists.apache.org/thread/y0mlxsx36kzc84tn31q1kyl4rdvgv1xx
Lazy consensus: https://lists.apache.org/thread/h0sxvd5g4drxfrph35dvlrk46zomsk0y

Image flavors
CI image PROD image (default) PROD image (legacy)
Python hardened base image hardened base image compiled from python.org sources, sigstore-verified
Debian trixie trixie bookworm (trixie also possible)
BASE_IMAGE ghcr.io/apache/airflow/base/python:<X.Y.Z>-debian13-dev same debian:bookworm-slim
  • AIRFLOW_IMAGE_FLAVOR (hardened by default, or legacy) selects the flavor in the PROD Dockerfile. The
    legacy build stage compiles Python into /usr/python and hands it to the final image through
    /python-dist, so the hardened image gets no extra layer content. The flavor is recorded in the
    org.apache.airflow.image.flavor label.
  • Breeze: breeze prod-image build --image-flavor hardened|legacy --debian-version trixie|bookworm. The
    legacy flavor builds on debian:<version>-slim. breeze ci-image build no longer takes
    --debian-version - the CI image is always hardened and trixie-based.
  • prod-image-build.yml gets an image-flavor input and defaults debian-version to trixie.
  • New scheduled-legacy-prod-image-canary.yml runs weekly (and on dispatch) and tests legacy images like
    regular ones. It runs the same selective checks as a scheduled CI build, publishes the constraints from
    the constraints branch as the usual constraints-<python> artifacts (so no CI image is needed), and builds
    legacy bookworm PROD images for every supported Python version through the reusable
    prod-image-build.yml (including prod-image verify). It then checks each image's flavor label and
    Debian codename, and runs the regular PROD image tests: additional-prod-image-tests.yml (docker-compose
    quick start, Task SDK integration, e2e suites, image extra checks) and the Kubernetes tests. Legacy
    images are not built in PRs or canary builds.
Debian trixie
  • Runtime libraries renamed in trixie's 64-bit time_t transition are picked per codename
    (libssl3t64, libldap2 on trixie; libssl3, libldap-2.5-0 on bookworm).
  • MariaDB publishes 10.11 only up to bookworm, so trixie gets the MariaDB 11.8 LTS repository, plus
    mariadb-client-compat, which provides the mysql named commands from 11.0 on. Bookworm keeps 10.11.
  • The Microsoft (MSSQL), PostgreSQL, Docker and Adoptium repositories all publish trixie suites.
  • The Debian 13 hardened images also configure Docker's own package repository (http://dhi.io/deb/debian/main,
    signed with /usr/share/keyrings/dhi-deb-main.gpg), whose +dhiN rebuilds take precedence over Debian's
    packages. It is accessible anonymously - apt-get update needs no credentials, unlike pulling the dhi.io
    images. The Debian 12 images have no such repository.
  • The Debian 13 hardened images install Python as Debian packages (python-3.13, libpython-3.13, …)
    under /usr, while the Debian 12 ones used /opt/python. The image now takes the base Python's prefix
    from sys.base_prefix, so /usr/python links to /usr on trixie. The system-Python guard only matches
    Debian's own libpython3.X packages, so it no longer fails on the base image's libpython-3.13. That
    guard failure is what broke the first trixie CI image build.
Base image distribution

Pulling dhi.io requires a Docker Hub login. Rather than force credentials on every contributor and CI
job, the tags are mirrored to ghcr.io/apache/airflow/base/python and BASE_IMAGE defaults there, so
building needs no registry credentials at all. The images are Apache-2.0 licensed, so redistribution is
fine. breeze release-management mirror-base-images plus a weekly workflow keep the mirror current; the
mirror publishes the pinned (3.13.16-debian13-dev) and floating (3.13-debian13-dev) tags for both
debian13 and debian12. The debian13 tags are already mirrored, and their digests are identical to
dhi.io's.

One patch-level pin serves both Debian releases. The upgrade check reads Docker's catalog for both and
only moves the pin to a patch level published for each of them, so a bump never points one of them at a
tag that does not exist. Pins: 3.11.17 / 3.12.15 / 3.13.16 / 3.14.8.

Gaps the hardened base has versus debian:*-slim

The hardened images ship a deliberately minimal /etc and toolset. What had to be restored - only for the
hardened flavor - lives in restore_debian_base_files and link_python:

Missing Symptom Handling
base-passwd accounts sasl2-bin: install: invalid group 'sasl' install base-passwd, run update-passwd
/etc/shells tmux: add-shell aborts create it
libpam-runtime adduser --gecos → chfn: PAM: Critical error install it; it generates the /etc/pam.d/common-* files the shipped PAM configs @include
dash libgcrypt20 postinst (#!/bin/dash) exits 127 install it first
/usr/local, gzip ln: /usr/local/bin/pip3: No such file; tar: gzip: Cannot exec mkdir -p in link_python; gzip in dev deps

lsb-release is dropped entirely - install_mysql/mssql/postgres.sh now read /etc/os-release through
common::debian_codename / common::debian_release. That package pulled in usrmerge, whose postinst
removed /lib64 and broke the build; it was the blocker in #60123.

Differences between the hardened base image and the previous images

Investigating this PR's CI failures turned up behaviour differences in Docker's hardened Python
(definitions: https://github.com/docker-hardened-images/catalog, package/python and image/python):

  • Patched CPython. Docker applies 020-CVE-2026-3479-pkgutil-get-data.patch, so pkgutil.get_data()
    rejects .. resource paths (upstream CPython only documents this). moto < 5.2.2 loads its EC2 data via
    ../resources/…, which broke the lowest-dependency Providers[amazon] tests on every Python version.
    → moto floor raised to 5.2.2.
  • 1 MiB thread stack. The hardened Python is built with -DTHREAD_STACK_SIZE=0x100000 (previous
    images: the 8 MiB glibc default). On 3.12/3.13 the fixed C-recursion depth then overflows, crashing an
    xdist worker on test_login.py::…[deeply_nested]. → hardened images install
    airflow-thread-stack-size.pth restoring 8 MiB thread stacks.
  • No PGO. Built with --with-lto only (previous: --enable-optimizations --with-lto), so CPU-bound
    Python is slower.
  • Modified /etc/debian_version. A base-files upgrade stopped at dpkg's conffile prompt. → hardened
    images ship /etc/dpkg/dpkg.cfg.d/airflow-keep-conffiles (force-confdef, force-confold).
  • No which. → common.sh uses command -v.
  • No .pyc for the standard library. → the hardened build compiles it, keeping the Do not remove .pyc and .pyo files after building Python #58944 fix.

These are documented in docker-stack-docs/build.rst ("Properties of the hardened base image", plus a new
"Debian trixie and the legacy image flavor" section).

PYTHON_LTO is removed: FIPS builds point BASE_IMAGE at a FIPS variant of the hardened image, and the
legacy flavor always compiles with LTO.

Drive-by fixes
  • libxmlsec1-openssl added to the runtime deps. libxmlsec1 ships no crypto engine of its own, so
    import xmlsec (via python3-saml) failed with libxmlsec1-openssl.so.1: cannot open shared object file. Pre-existing - reproduced on debian:bookworm-slim with the same package set.
  • scripts/ci/prek/upgrade_important_versions.py reads the supported Python versions from
    global_constants.py instead of a hard-coded list that had left the 3.14 pin stale since March.
Verification
  • Rebased onto main (which dropped Python 3.10 since the last run); the previous run's only failure,
    test_local_executor.py::…test_actual_worker_death_after_start_releases_slot, was a timing assertion
    already loosened on main by Increase worker startup timeout in LocalExecutor process death test #74292.
  • Package lists dry-run installed in debian:bookworm-slim and debian:trixie-slim; the MariaDB 11.8
    trixie repository resolves the dev and prod client package sets.
  • Breeze and scripts unit tests pass, including new tests for the flavor/Debian base image selection,
    the per-distro mirror and the patch-level pin across both catalogs.
Follow-ups deliberately not in this PR
  • Publishing the legacy images under the -legacy tag suffix with an entrypoint deprecation warning - part
    of the release tooling, to land before 3.4.0.
  • Verifying Docker's signatures/attestations on the hardened images before the mirror copies them; the
    legacy flavor keeps the sigstore verification of the Python sources.
  • The shell-less runtime variant for the PROD main stage - it would mean rewriting the entrypoint away
    from bash and dropping the ADDITIONAL_RUNTIME_APT_DEPS customization contract, and it is coupled to
    Helm Chart: Directly Start Airflow Containers #65384.

closes: #59625


Was generative AI tooling used to co-author this PR?
  • Yes — Claude Code (Opus 5)

Generated-by: Claude Code (Opus 5) following the guidelines

🤖 Generated with Claude Code

@potiuk potiuk added the all versions If set, the CI build will be forced to use all versions of Python/K8S/DBs label Sep 12, 2026
@potiuk
potiuk force-pushed the hardened-python-base-images branch 2 times, most recently from 97e9fc1 to a8356c1 Compare October 4, 2026 23:53
@potiuk
potiuk requested a review from o-nikolas as a code owner October 5, 2026 07:44
@potiuk
potiuk force-pushed the hardened-python-base-images branch from 200581a to 658a6c3 Compare October 5, 2026 09:00
@github-actions

github-actions Bot commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

uv.lock on main just moved via #74429 ("Prepare providers release 2026-10-06"), commit 57ad5d3 and this PR currently conflicts.

Quickest fix:

git fetch upstream main && git rebase upstream/main
rm uv.lock && uv lock
git add uv.lock && git rebase --continue
git push --force-with-lease

Automated nudge — ignore if you're not ready to rebase. This comment is updated in place on future uv.lock bumps.

Squashed from 19 commits (backup branch: backup/hardened-python-base-images-pre-rebase) before rebasing onto main.

Generated-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014np1fVAxHmGaBFt3qebJ8x
The 3.4.x line keeps publishing the previous images - Python compiled from sources on bookworm - as a deprecated legacy flavor, so the PROD image has to be buildable both ways while the CI image only needs the hardened trixie one. A weekly canary builds and checks the legacy images so they do not rot between releases.

Generated-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014np1fVAxHmGaBFt3qebJ8x
@potiuk
potiuk force-pushed the hardened-python-base-images branch from 7ba3045 to b4e7582 Compare October 10, 2026 10:11
…egacy images like regular ones

The Debian 13 hardened images install Python as Debian packages under /usr instead of /opt/python, so the image has to find the base Python's prefix and the system-Python guard must not mistake those packages for Debian's own Python. The legacy canary now runs the regular PROD image tests, not just a smoke check, so the legacy images are covered as well as the default ones.

Generated-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014np1fVAxHmGaBFt3qebJ8x
The CI image is trixie-only now: libgcc-11-dev was a bookworm-era pin (build-essential brings the matching libgcc-14-dev) and software-properties-common was removed in Debian 13; nothing uses its add-apt-repository.

Generated-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014np1fVAxHmGaBFt3qebJ8x
Microsoft signs its Debian 13 package repository with its 2025 general signing key rather than the 2015 release key, so apt on trixie rejected the repository as unsigned.

Generated-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014np1fVAxHmGaBFt3qebJ8x
The Debian 13 hardened images ship libpam-runtime and passwd already installed but without their /etc/pam.d files, so installing them restored nothing and adding the CI image's airflow user failed with pam_start() errors.

Generated-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014np1fVAxHmGaBFt3qebJ8x
potiuk added 12 commits October 10, 2026 13:50
The trixie hardened images add Docker's own package repository, whose rebuilt packages take precedence over Debian's - users extending the image should know where their packages come from, and that no credentials are needed for it.

Generated-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014np1fVAxHmGaBFt3qebJ8x
On trixie, libenchant-2-dev's build dependencies need python3, which Docker's package repository satisfies with its own python-3.13 - a second interpreter in the 3.12 image that broke building C extensions. pyenchant only needs the runtime library, which it finds through the ldconfig cache, and the guard now fails early on any hardened Python other than the image's own.

Generated-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014np1fVAxHmGaBFt3qebJ8x
Dropping the packages that only existed to compile Python left the legacy flavor compiling a Python without sqlite3, lzma or readline - CPython builds those modules only when their headers are present, and the image could not even run airflow. The build now also fails when one of them is missing.

Generated-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014np1fVAxHmGaBFt3qebJ8x
The hardened images ship libc-bin without its dpkg trigger, so libraries installed by apt stayed invisible to ctypes.util.find_library() - the CI image's docs build failed with "The 'enchant' C library was not found". A dpkg post-invoke hook runs ldconfig after every apt run, also in images extending ours.

Generated-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014np1fVAxHmGaBFt3qebJ8x
Extending a hardened image with apt packages that depend on python3 silently installs another Python next to the image's own, and C extensions then build against the wrong one - users need to know to check before adding such packages.

Generated-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014np1fVAxHmGaBFt3qebJ8x
OpenSSH 10 in trixie prints a '# host:port' banner comment per connection to ssh-keyscan's stdout, so the line count never matched the three host keys and every test job waited until it timed out.

Generated-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014np1fVAxHmGaBFt3qebJ8x
The uv cache restored into CI image builds held wheels compiled against bookworm's libraries - an xmlsec wheel built against bookworm's MD5-enabled libxmlsec1 failed to import on trixie, which dropped MD5. The cache key does not encode the base OS, so it has to be bumped when that changes.

Generated-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014np1fVAxHmGaBFt3qebJ8x
Debian trixie ships no Java 17. The Java SDK e2e image keeps Java 17 for Spark 3.5 by taking it from the Temurin repository, and the docs example uses trixie's Java 21.

Generated-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014np1fVAxHmGaBFt3qebJ8x
GCC 14 turns incompatible pointer types, integer conversions and implicit declarations into errors, and the hardened Python passes -Werror=implicit-function-declaration on to extensions - the lowest-dependency tests' old sdists (xmlsec 1.3.14, ibm-db 3.2.0) only built because those were warnings.

Generated-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014np1fVAxHmGaBFt3qebJ8x
Users extending the image with old source distributions hit the same GCC 14 errors as CI, and need to know how to relax them.

Generated-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014np1fVAxHmGaBFt3qebJ8x
…out test

With a 3s timeout only 2s were left for spawning the extraction process next to its 1s on-start stall, which the hardened Python - built without PGO - no longer always met on a loaded runner; 4s still kills the 5s on-complete stall.

Generated-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014np1fVAxHmGaBFt3qebJ8x
@potiuk
potiuk requested a review from mobuchowski as a code owner October 11, 2026 09:47
A failing lang-SDK helm upgrade raised past the log export that every other step of the complete K8S test run does, so the cluster was deleted with nothing saved and the job's KinD logs artifact stayed empty.

Generated-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_014np1fVAxHmGaBFt3qebJ8x

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

all versions If set, the CI build will be forced to use all versions of Python/K8S/DBs area:dev-tools area:production-image Production image improvements and fixes kind:documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Switch to hardened Docker images in Dockerfiles

2 participants