Reproducible GDAL Builds with pip Constraints and Hashes
Compile one lock per target platform with pip-compile --generate-hashes, install it with pip install --require-hashes --only-binary :all:, and gate the result on the wheel tags and GLIBC symbol versions the shared objects actually require. --require-hashes refuses to install anything not pinned with == and accompanied by a sha256 digest, so a silently republished wheel fails the build instead of shipping. What it will not do is tell you whether the vendored libgdal inside that wheel matches the runtime — that needs the ABI gate at the end of this page.
Context
The CI/CD pipeline sync for geo-dependencies discipline exists because a geospatial layer that rebuilds differently on Tuesday than it did on Monday is not a build artifact, it is a lottery ticket. Locking the direct dependencies is the easy half. The hard half is the transitive tree: rasterio pulls affine, attrs, certifi, click, cligj, click-plugins, numpy and pyparsing, and a version bump in any of them changes the bytes of your layer without changing a single line you wrote.
Hash pinning closes that gap in a way version pinning cannot. A version pin trusts the index to keep serving the same artifact for that version; a sha256 pin verifies it. That distinction matters more for compiled geospatial wheels than for pure Python, because the failure is not an API change you can read in a diff — it is a different libgdal inside rasterio.libs, linked against different symbol versions, presenting as a cold-start ImportError that looks identical to the environment misconfiguration described in the native library compilation guide.
What --require-hashes refuses
Hash-checking mode is deliberately unforgiving, and the refusals are the feature. Turning it on means every requirement pip is asked to install must be pinned with == and must carry at least one --hash=sha256:..., including every transitive dependency and every build requirement. Editable installs are rejected outright. A URL requirement with no digest is rejected. And a requirement pip resolves but cannot find a digest for stops the install with Hashes are required in --require-hashes mode, but they are missing from some requirements.
If the digest is present but wrong, the message is different and much louder: THESE PACKAGES DO NOT MATCH THE HASHES FROM THE REQUIREMENTS FILE. If you have updated the package versions, please update the hashes. Either way the build stops before a byte reaches the layer, which is the entire point — the alternative is discovering the substitution at a cold start in production.
Prerequisites
- Runtime: Python 3.11 on AWS Lambda, targeting both
x86_64andarm64 - Tooling:
pip24.0 or newer,pip-tools7.4.1 (pip-compile),binutilsforobjdump, Docker with theaarch64binfmt handler registered if the host isx86_64 - Build containers:
public.ecr.aws/lambda/python:3.11pulled for bothlinux/amd64andlinux/arm64, so each lock is compiled inside the environment it will be installed in - Direct dependencies, and only these, in
requirements.in:rasterio==1.4.3,pyproj==3.7.0,shapely==2.0.6 - Committed artifacts:
requirements.in,locks/amd64.txt,locks/arm64.txt, and the mergedconstraints.txt— CI must never re-resolve fromrequirements.inoutside the lock step - IAM for the publish step:
lambda:PublishLayerVersionands3:PutObjecton the staging bucket - Runtime environment variables for the resulting layer, set on the function:
GDAL_DATA=/opt/python/lib/python3.11/site-packages/rasterio/gdal_data PROJ_LIB=/opt/python/lib/python3.11/site-packages/pyproj/proj_dir/share/proj LD_LIBRARY_PATH=/opt/python/lib/python3.11/site-packages/rasterio.libs
Implementation
One script produces the locks and installs from them. The critical detail is that pip-compile resolves for the interpreter and platform it is running on, so it runs twice — once inside each target container — and the results are merged.
#!/usr/bin/env bash
# lock_geo_stack.sh — one hashed lock per target, merged into one constraints file.
set -euo pipefail
SITE="python/lib/python3.11/site-packages"
mkdir -p locks layer
cat > requirements.in <<'EOF'
# Only what the handler imports directly. Everything else is derived.
rasterio==1.4.3
pyproj==3.7.0
shapely==2.0.6
EOF
# pip-compile records a sha256 for every *file* it would download, and it only
# sees the files that match the running interpreter. Compiling on the host would
# produce a lock with x86_64 digests that cannot install on Graviton, so each
# pass runs inside the container it is locking for.
for arch in amd64 arm64; do
docker run --rm --platform "linux/${arch}" -v "$(pwd):/w" -w /w \
public.ecr.aws/lambda/python:3.11 bash -c "
pip install --quiet --disable-pip-version-check pip-tools==7.4.1
pip-compile requirements.in \
--generate-hashes \
--no-header \
--output-file locks/${arch}.txt
"
done
# A requirement line may carry as many --hash entries as it likes, so the union
# of both passes is a single file that installs correctly on either target.
python3 - <<'PY' > constraints.txt
import re, pathlib
from collections import OrderedDict
pins = OrderedDict()
for f in ("locks/amd64.txt", "locks/arm64.txt"):
text = pathlib.Path(f).read_text().replace("\\\n", " ")
for line in text.splitlines():
if "==" not in line or line.lstrip().startswith("#"):
continue
name = re.split(r"==", line, 1)[0].strip()
version = re.search(r"==([^\s;]+)", line).group(1)
hashes = set(re.findall(r"--hash=(sha256:[0-9a-f]{64})", line))
key = f"{name}=={version}"
pins.setdefault(key, set()).update(hashes)
for key, hashes in pins.items():
print(key + " \\")
print(" \\\n".join(f" --hash={h}" for h in sorted(hashes)))
PY
# --require-hashes needs every requirement it installs to carry a digest, so the
# merged file is passed as -r here. --only-binary :all: is mandatory alongside
# it: without it pip may fall back to an sdist whose hash matches but whose
# *build* is not reproducible.
docker run --rm --platform linux/amd64 -v "$(pwd):/w" -w /w \
public.ecr.aws/lambda/python:3.11 \
pip install \
--require-hashes \
--only-binary :all: \
--no-cache-dir \
--target "layer/${SITE}" \
-r constraints.txt
# A second layer installed with --no-deps passes the SAME file as -c. A
# constraints file installs nothing; it only caps what the resolver may pick,
# which is exactly how two layers stay on one NumPy without duplicating it.
docker run --rm --platform linux/amd64 -v "$(pwd):/w" -w /w \
public.ecr.aws/lambda/python:3.11 \
pip install --no-deps -c constraints.txt --target "layer-extra/${SITE}" fiona==1.10.1
The two targets differ in exactly one thing that matters, and it is worth seeing laid out, because a lock that is missing one column fails at install time rather than at review time.
Verification
A passing pip install proves the digests matched. It does not prove the wheels can run on the target. The gate below reads what each wheel claims — its platform tag — and what each bundled shared object actually demands — the highest GLIBC_x.y symbol version referenced in its dynamic symbol table — and refuses anything above the runtime’s floor.
# verify_wheel_abi.py — run inside the target container against layer/.
import pathlib
import re
import subprocess
import sys
TARGET_GLIBC = (2, 34) # Amazon Linux 2023
ALLOWED_TAGS = ("manylinux_2_28_x86_64", "manylinux2014_x86_64", "any")
SITE = pathlib.Path(sys.argv[1])
failures, so_count, highest = [], 0, (2, 0)
for wheel_meta in sorted(SITE.glob("*.dist-info/WHEEL")):
dist = wheel_meta.parent.name.rsplit("-", 1)[0]
# The WHEEL file records the tag pip actually chose, which is the only
# durable record of which manylinux policy this build depends on.
tags = re.findall(r"^Tag: (.+)$", wheel_meta.read_text(), re.M)
ok = any(t.rsplit("-", 1)[-1] in ALLOWED_TAGS for t in tags)
print(f"{dist:<28} tag={tags[0]:<38} {'ok' if ok else 'REJECTED'}")
if not ok:
failures.append(f"{dist}: no acceptable platform tag in {tags}")
for so in sorted(SITE.rglob("*.so*")):
so_count += 1
# objdump -T lists versioned symbol references; the highest GLIBC_x.y here
# is the real floor this object imposes, regardless of the wheel's tag.
out = subprocess.run(["objdump", "-T", str(so)],
capture_output=True, text=True).stdout
versions = [tuple(int(p) for p in m.split("."))
for m in re.findall(r"GLIBC_(\d+\.\d+)", out)]
if not versions:
continue
highest = max(highest, max(versions))
if max(versions) > TARGET_GLIBC:
failures.append(
f"{so.relative_to(SITE)}: needs GLIBC_"
f"{'.'.join(str(p) for p in max(versions))} > target"
)
if failures:
print("\nABI GATE FAILED")
for f in failures:
print(f" {f}")
sys.exit(1)
print(f"\nABI GATE PASSED — {so_count} shared objects, highest required symbol "
f"GLIBC_{'.'.join(str(p) for p in highest)}, "
f"target GLIBC_{'.'.join(str(p) for p in TARGET_GLIBC)}")
Expected output:
pyproj-3.7.0 tag=cp311-cp311-manylinux_2_28_x86_64 ok
rasterio-1.4.3 tag=cp311-cp311-manylinux_2_28_x86_64 ok
shapely-2.0.6 tag=cp311-cp311-manylinux_2_28_x86_64 ok
ABI GATE PASSED — 31 shared objects, highest required symbol GLIBC_2.28, target GLIBC_2.34
The interesting failure is a wheel whose tag is acceptable but whose objects are not — a manylinux_2_28 wheel is allowed to require up to GLIBC_2.28, and a mis-repaired one can slip past auditwheel requiring more. Reading objdump -T catches that in CI rather than at the first cold start, and it is the check that distinguishes a reproducible build from a merely repeatable one.
Gotchas and Edge Cases
--require-hashesfails on transitive dependencies you never named. The moment it is enabled,certifi,attrs,cligjand every other derived package must appear pinned and hashed. That is why the input topip installis the compiled lock, neverrequirements.in. If the error names a package you have not heard of, the fix is to recompile, not to add a one-off--hashby hand.- A matching hash does not make an sdist build reproducible. If no wheel matches the target, pip will happily download an sdist whose digest is correct and then compile it against whatever headers happen to be in the container. Always pair
--require-hasheswith--only-binary :all:so the fallback is a hard failure rather than a silent source build — the same reasoning behind the platform-tag discipline in building rasterio Lambda layers on Amazon Linux 2023. - The lock is per platform, and Graviton is a different platform. Digests recorded on
x86_64do not include themanylinux_2_28_aarch64files, so anarm64build fails with a missing-hash error even though nothing changed. Compile inside each target container as the script does, which is the same reason cross-compiling GEOS for arm64 Graviton Lambda treatsarm64as a second build chain rather than a flag. - Hashes pin bytes, not compatibility. Two wheels can both be exactly what their digests promise and still disagree about GEOS:
shapelyvendors onelibgeos_cand a source-built GDAL links another. Hash pinning guarantees the inputs; keeping the linked versions in agreement is the job of pinning GDAL and PROJ versions across build and runtime, and the two checks are complements rather than substitutes.
Frequently Asked Questions
What is the difference between a requirements file and a constraints file?
A requirements file is a list of things to install. A constraints file installs nothing — it only caps which version the resolver may choose if something else pulls that package in. That difference is load-bearing for a split layer: the base layer installs the compiled lock with -r, and the geo layer installs with --no-deps against the same file passed as -c, so both agree on NumPy and certifi without the base layer’s packages being written twice. Because hash-checking mode requires a digest for everything it installs, the compiled file is passed as -r where it installs and as -c where it must merely agree.
Does --require-hashes protect me from a GDAL ABI change?
No, and treating it as if it does is the most common way this setup gives false confidence. A digest pins the bytes of a wheel, so you get the same rasterio with the same vendored libgdal. It says nothing about whether that libgdal agrees with a system libgdal elsewhere in the layer, or whether the wheel’s manylinux policy is satisfied by the runtime’s glibc. Hashes make the build reproducible; the wheel-tag and objdump -T gate makes it correct.
Why does pip-compile produce a lock that fails on Graviton?
Because it resolves for the interpreter and platform it runs on. A compile pass on an x86_64 host records --hash lines for manylinux_2_28_x86_64 files, and the aarch64 wheel pip wants inside an arm64 container has no digest in that file, so the install refuses. Compile once inside each target container and merge — a requirement line accepts as many --hash entries as you give it, so one file can legitimately cover both architectures.
Related
- CI/CD Pipeline Sync for Geo-Dependencies — the surrounding pipeline this lock step slots into
- Pinning GDAL and PROJ Versions Across Build and Runtime — the native-version half that hashes cannot cover
- Caching GDAL Build Artifacts in GitHub Actions — why the merged lock belongs in the cache key
- Cross-Compiling GEOS for arm64 Graviton Lambda — the second target that forces a second compile pass
- Deduplicating NumPy Across Geospatial Lambda Layers — the split layout the
-cinstall exists to serve