Skip to content

feat(lambda-python-alpha): opt-in local (docker-less) bundling for PythonFunction#37858

Open
CoreOxide wants to merge 10 commits into
aws:mainfrom
CoreOxide:RFC609
Open

feat(lambda-python-alpha): opt-in local (docker-less) bundling for PythonFunction#37858
CoreOxide wants to merge 10 commits into
aws:mainfrom
CoreOxide:RFC609

Conversation

@CoreOxide
Copy link
Copy Markdown

@CoreOxide CoreOxide commented May 13, 2026

Fixes #18290.

Motivation

PythonFunction / PythonLayerVersion currently require a running Docker daemon to bundle dependencies: the module shells out to docker build on every synth to construct a Lambda-compatible image and runs pip install inside it. That's a hard blocker for:

  • Contributors and CI agents that don't have Docker available (sandboxed runners, WSL-less Windows, locked-down corp machines).
  • Dcoker-in-docker failures, where a runner CI agent is already running in a container.
  • Fast dev-loop iteration where starting Docker on every cdk synth dominates wall time.
  • Users who already have a suitable python3 + pip on PATH and just want the bundler to use it (i.e. caching of results).

This PR adds an opt-in bundling.local: boolean flag that, when enabled, installs dependencies directly on the host via pip, producing a binary-compatible artifact for the target Lambda runtime regardless of the host OS / CPU architecture.
This feature also allows cross building, and can be expanded to support any and all runtimes, such that a singular runner can build Python, Golang and JS functions.

Design

The opt-in surface

new python.PythonFunction(this, 'fn', {
  entry: '/path/to/function',
  runtime: Runtime.PYTHON_3_12,
  bundling: {
    local: true,                                       // opt in
    manyLinuxTags: ['musllinux_1_2_x86_64', ...],      // optional, overrides default tag priority
  },
});

Defaults to false, so existing apps are unchanged — no feature flag needed.
manulinux tags also have defaults for each Python version.

How local bundling works

LocalBundling.tryBundle(outputDir) performs:

  1. Windows guard — if the project uses pipenv/poetry/uv, fail fast with PythonLocalBundlingWindowsExportUnsupported (the export commands embed POSIX && / rm -rf / > redirection that doesn't survive cmd.exe). Plain requirements.txt still works on Windows. Support can be added if demand arises, in a subsequent PR.
  2. Resolve Python — probe python3 first, fall back to python. Cached at module scope so N functions in a stack do one probe.
  3. Copy sources via FileSystem.copyDirectory (reuses the existing CDK ignore-strategy) — always follows symlinks, honours assetExcludes.
  4. Run beforeBundling hooks with cwd=outputDir — hooks operate on the staged copy, never on the user's source tree. All hook strings joined with && into a single shell invocation to match the documented ICommandHooks contract.
  5. Export lockfile → requirements.txt if the project uses pipenv/poetry/uv. The existing Packaging.fromEntry(...) result is computed once in the Bundling constructor and threaded into LocalBundling so Docker and local paths can't disagree on which packaging tool to use (avoids a TOCTOU race between constructor time and tryBundle time).
  6. pip install with:
    • --platform manylinux_2_28_{arch} then manylinux2014_{arch} for py3.12+ (AL2023 base image), manylinux2014_{arch} alone for py3.7–3.11 (AL2 base image).
    • --python-version X.Y --abi cpXY --implementation cp derived from Runtime.
    • --only-binary=:all: so source-only distributions (which would build against the host OS/arch) are rejected rather than silently producing an incompatible artifact. A sweep test asserts this flag rides on every runtime in the matrix.
  7. Run afterBundling hooks.

If local bundling fails for any reason (missing tools, no compatible wheel, hook error) synthesis fails — there is no silent fallback to Docker. That's by design and documented in the README.

Docker-image short-circuit

The Bundling constructor previously called DockerImage.fromBuild(...) unconditionally, which shells out to docker build at synth time. That made local: true useless without Docker (exactly the case it's supposed to cover). Now, when local === true and the user hasn't supplied a custom image, we use DockerImage.fromRegistry(runtime.bundlingImage.image) — a lazy factory that never touches the daemon. Covered by a regression test.

Default manylinux tag mapping

Derived from the runtime's Lambda base image:

Runtime Base image Default tag priority
python3.7 … python3.11 Amazon Linux 2 (glibc 2.26) manylinux2014_{arch}
python3.12 … python3.14 Amazon Linux 2023 (glibc 2.34) manylinux_2_28_{arch}, falling back to manylinux2014_{arch}

Users can override with manyLinuxTags — e.g. a dependency only published as musllinux → supply ['musllinux_1_2_x86_64', 'manylinux_2_28_x86_64'] and pip will try each in order.

Error model

All new failures surface as UnscopedValidationError with lit-tagged codes per the repo's error-handling convention:

  • PythonLocalBundlingPythonMissing
  • PythonLocalBundlingCommandFailed (pip / export / hook subprocess failures — stderr/stdout captured in message)
  • PythonLocalBundlingWindowsExportUnsupported
  • PythonLocalBundlingUnsupportedArchitecture
  • PythonLocalBundlingUnsupportedRuntime
  • PythonLocalBundlingUnparseableRuntime

What didn't change

  • The Docker path is the default and is untouched for the overwhelming majority of callers.
  • BundlingOptions gets two new optional props (local, manyLinuxTags); no public API breakage.
  • No new runtime dependencies. No new CloudFormation surface.

Alternatives considered

  • Auto-fall-back to Docker on local failure. Rejected — silent fallback produces confusingly long synths when the user thought they were on the fast path; explicit failure with a specific error code is easier to diagnose.
  • Auto-detect docker and pick local when Docker is absent. Rejected — too much magic, and host Python/pip versions vary widely; explicit opt-in is safer.
  • local: boolean | ILocalBundling union. Rejected — jsii doesn't allow union types in public props. Users who want to fully customize the local path can still wrap Bundling themselves.

How I verified this works

Unit tests — 97 new tests, 98.57% coverage

  • test/local-bundling.test.ts (40 tests) — covers AL2 & AL2023 tag paths, both arches, the python3/python fallback, &&-joined hooks, cwd=outputDir for both hooks, post-copy ordering, the Windows export guard, the manyLinuxTags override, all six error codes, and the pip install invariant --only-binary=:all: that runs as part of the runtime sweep.
  • test/platform.test.ts (26 tests) — runtimeToPythonVersion / runtimeToAbiTag / defaultManyLinuxTags / validateArchitecture across py3.6–3.14.
  • test/bundling.test.ts — added 4 wiring tests:
    • local: true exposes a local bundler with a tryBundle function
    • local: true does not trigger DockerImage.fromBuild (regression for the bug below)
    • local: false / default leaves local undefined

Integration test — test/integ.function.local.ts, deployed and invoked

11-cell matrix deployed to us-east-1 and each function invoked via integ.assertions.invokeFunction, expecting Payload: '200':

Tier Runtime Architecture
AL2 (manylinux2014) python3.10 x86_64, arm64
AL2 (manylinux2014) python3.11 x86_64, arm64
AL2023 (manylinux_2_28 → manylinux2014) python3.12 x86_64, arm64
AL2023 (manylinux_2_28 → manylinux2014) python3.13 x86_64, arm64
AL2023 (manylinux_2_28 → manylinux2014) python3.14 x86_64, arm64
manyLinuxTags override python3.12 arm64 (musllinux_1_2_aarch64 first, manylinux_2_28_aarch64 fallback)

Last run: 1 passed, 242s. Snapshot checked in at test/integ.function.local.js.snapshot/. The integ file carries /// !cdk-integ pragma:disable-update-workflow and stackUpdateWorkflow: false, matching the sibling integ tests in this module (python bundling asset hashes change frequently and the snapshot would otherwise churn).

End-to-end smoke (off-PR, macOS arm64 host)

Built a standalone CDK app outside the repo with a requests-using handler, yarn link-ed the alpha and aws-cdk-lib in, and ran cdk synth for every matrix cell. Each produced a distinct bundled asset. For each, I verified:

  • No Docker daemon was touched (docker ps intentionally failed on the host).
  • The bundled .so files carried the correct ABI + arch tag (e.g. *.cpython-312-aarch64-linux-gnu.so for py3.12+arm64).
  • Cross-compile works (arm64 host producing x86_64 wheels and vice versa).
  • With manyLinuxTags: ['musllinux_1_2_aarch64', 'manylinux_2_28_aarch64'], charset_normalizer was selected as the musl wheel (charset_normalizer/cd.cpython-312-aarch64-linux-musl.so) while other deps fell through to the manylinux wheel — the tag-priority fallback works as documented.

Failure mode spot-checks

Exercised against the real host (not just mocked spawnSync) to confirm the error surface matches the unit tests:

  • Handler with a source-only dependency (pysqlcipher3) → No matching distribution found from pip, surfaced via PythonLocalBundlingCommandFailed.
  • Stripped-PATH synth (no python3/python) → PythonLocalBundlingPythonMissing with both candidates listed.
  • Pipfile handler with pipenv missing → PythonLocalBundlingCommandFailed with the pipenv command in the message.
  • os.platform stub returning win32 on a pipenv project → PythonLocalBundlingWindowsExportUnsupported.
  • Architecture.custom('weird', 'linux/mips64')PythonLocalBundlingUnsupportedArchitecture.

Build & lint

  • yarn build under packages/@aws-cdk/aws-lambda-python-alpha — clean (jsii, eslint, awslint, pkglint).
  • yarn test — all non-Docker-dependent tests pass.

Files changed at a glance

New:

  • lib/local-bundling.tsLocalBundling implementation of ILocalBundling.
  • lib/platform.ts — runtime/architecture helpers (runtimeToPythonVersion, runtimeToAbiTag, defaultManyLinuxTags, validateArchitecture).
  • lib/util.tsrunCommand wrapper around spawnSync that surfaces stdout/stderr in the validation error.
  • test/integ.function.local.ts + snapshot.
  • test/local-bundling.test.ts, test/platform.test.ts.

Modified:

  • lib/bundling.ts — precomputes Packaging + excludes once; wires LocalBundling; short-circuits the Docker image build when local: true.
  • lib/types.ts — adds local and manyLinuxTags props with full JSDoc.
  • test/bundling.test.ts — wiring tests.
  • README.md — new Local Bundling (Docker-less) section.

Checklist


By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license

@github-actions github-actions Bot added effort/medium Medium work item – several days of effort feature-request A feature should be added or improved. p2 beginning-contributor [Pilot] contributed between 0-2 PRs to the CDK labels May 13, 2026
@CoreOxide CoreOxide marked this pull request as draft May 13, 2026 07:56
Copy link
Copy Markdown
Collaborator

@aws-cdk-automation aws-cdk-automation left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

(This review is outdated)

@CoreOxide CoreOxide changed the title feat(aws-lambda-python-alpha): opt-in local (docker-less) bundling for PythonFunction feat(lambda-python-alpha): opt-in local (docker-less) bundling for PythonFunction May 13, 2026
@aws-cdk-automation aws-cdk-automation dismissed their stale review May 13, 2026 10:31

✅ Updated pull request passes all PRLinter validations. Dismissing previous PRLinter review.

@github-actions
Copy link
Copy Markdown
Contributor

Comments on closed issues and PRs are hard for our team to see.
If you need help, please open a new issue that references this one.

@github-actions
Copy link
Copy Markdown
Contributor

This issue has been reopened and is now available for discussion.

@github-actions github-actions Bot locked as resolved and limited conversation to collaborators May 14, 2026
@github-actions github-actions Bot unlocked this conversation May 14, 2026
@CoreOxide CoreOxide marked this pull request as draft May 14, 2026 08:13
@CoreOxide CoreOxide marked this pull request as ready for review May 14, 2026 08:13
@aws-cdk-automation aws-cdk-automation added the pr/needs-community-review This PR needs a review from a Trusted Community Member or Core Team Member. label May 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

beginning-contributor [Pilot] contributed between 0-2 PRs to the CDK effort/medium Medium work item – several days of effort feature-request A feature should be added or improved. p2 pr/needs-community-review This PR needs a review from a Trusted Community Member or Core Team Member.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

(lambda-python): add local bundling to PythonFunction

2 participants