Skip to content

Commit 30892c8

Browse files
perf(layer): precompile Python sources in Lambda layers (#8520)
perf(layer): precompile Python sources with checked hashes
1 parent 3772407 commit 30892c8

8 files changed

Lines changed: 319 additions & 34 deletions

File tree

‎.github/workflows/quality_check.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ on:
1616
paths:
1717
- "aws_lambda_powertools/**"
1818
- "tests/**"
19+
- "layer_v3/docker/**"
20+
- "layer_v3/layer/canary/**"
1921
- "examples/**"
2022
- "pyproject.toml"
2123
- "uv.lock"
@@ -36,6 +38,8 @@ on:
3638
paths:
3739
- "aws_lambda_powertools/**"
3840
- "tests/**"
41+
- "layer_v3/docker/**"
42+
- "layer_v3/layer/canary/**"
3943
- "examples/**"
4044
- "pyproject.toml"
4145
- "uv.lock"

‎.github/workflows/quality_code_cdk_constructor.yml‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ on:
1616
pull_request:
1717
paths:
1818
- "layer_v3/layer_constructors/**"
19+
- "layer_v3/docker/**"
1920
- "layer_v3/pyproject.toml"
2021
- "layer_v3/uv.lock"
2122
- ".github/workflows/quality_code_cdk_constructor.yml"
@@ -24,6 +25,7 @@ on:
2425
push:
2526
paths:
2627
- "layer_v3/layer_constructors/**"
28+
- "layer_v3/docker/**"
2729
- "layer_v3/pyproject.toml"
2830
- "layer_v3/uv.lock"
2931
- ".github/workflows/quality_code_cdk_constructor.yml"

‎layer_v3/README.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,21 @@ You can pass it as a context variable when running `synth` or `deploy`,
1212
cdk synth --context version=3.0.0 --pythonVersion=3.12
1313
```
1414

15+
The build precompiles Python sources for the selected runtime and retains the `.py` files for source inspection and fallback.
16+
Bytecode uses checked hashes so ZIP timestamp changes do not invalidate the cache.
17+
This increases the layer size.
18+
1519
## Canary stack
1620

1721
We use a canary stack to verify that the deployment is successful and we can use the layer by adding it to a newly created Lambda function.
1822
The canary is deployed after the layer construct. Because the layer ARN is created during the deploy we need to pass this information async via SSM parameter.
1923
To achieve that we use SSM parameter store to pass the layer ARN to the canary.
2024
The layer stack writes the layer ARN after the deployment as SSM parameter and the canary stacks reads this information and adds the layer to the function.
2125

26+
On creation, the canary verifies parsing, validation, source inspection, and that imports use the layer's bytecode.
27+
Recompilation of layer sources fails the canary before it sends a version-tracking notification.
28+
The cache check does not prevent deletion of the custom resource during rollback.
29+
2230
## Version tracking
2331

2432
AWS Lambda versions Lambda layers by incrementing a number at the end of the ARN.

‎layer_v3/docker/Dockerfile‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,7 @@ RUN cd /asset/python && \
4343
find . -wholename "*/tests/*" -type f -delete && \
4444
# remove python bytecode
4545
find . -regex '^.*\(__pycache__\|\.py[co]\)$' -delete
46+
47+
# Hash validation survives ZIP timestamp changes; retain sources for inspection and fallback.
48+
RUN python -m compileall -q -j 2 --invalidation-mode checked-hash \
49+
-s /asset -p /opt /asset/python

‎layer_v3/layer/canary/app.py‎

Lines changed: 69 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,64 @@
11
import datetime
2+
import inspect
23
import json
34
import os
45
import platform
56
from importlib.metadata import version
67

7-
import boto3
8-
from pydantic import HttpUrl
8+
from bytecode_monitor import track_layer_compilation, verify_layer_bytecode
9+
10+
# Defer cache failures to Create so CloudFormation can still delete the resource.
11+
with track_layer_compilation() as import_compilations:
12+
import boto3
13+
from pydantic import HttpUrl
14+
15+
from aws_lambda_powertools import Logger, Metrics, Tracer
16+
from aws_lambda_powertools.event_handler import APIGatewayRestResolver
17+
from aws_lambda_powertools.utilities.data_masking import DataMasking
18+
from aws_lambda_powertools.utilities.parser import BaseModel, envelopes, event_parser
19+
from aws_lambda_powertools.utilities.typing import LambdaContext
20+
from aws_lambda_powertools.utilities.validation import validator
21+
22+
logger = Logger(service="version-track")
23+
tracer = Tracer() # this checks for aws-xray-sdk presence
24+
metrics = Metrics(namespace="powertools-layer-canary", service="PowertoolsLayerCanary")
25+
data_masker = DataMasking()
26+
app = APIGatewayRestResolver()
27+
28+
# Model to check parser imports correctly, tests for pydantic
29+
class OrderItem(BaseModel):
30+
order_id: int
31+
quantity: int
32+
description: str
33+
url: HttpUrl
34+
35+
# Tests for jmespath presence
36+
@event_parser(model=OrderItem, envelope=envelopes.EventBridgeEnvelope)
37+
def envelope_handler(event: OrderItem, context: LambdaContext):
38+
return event
39+
40+
# Tests for fastjsonschema presence
41+
@validator(
42+
inbound_schema={"type": "object", "required": ["order_id", "quantity", "description", "url"]},
43+
envelope="detail",
44+
)
45+
def validator_handler(event, context: LambdaContext):
46+
pass
947

10-
from aws_lambda_powertools import Logger, Metrics, Tracer
11-
from aws_lambda_powertools.event_handler import APIGatewayRestResolver
12-
from aws_lambda_powertools.utilities.data_masking import DataMasking
13-
from aws_lambda_powertools.utilities.parser import BaseModel, envelopes, event_parser
14-
from aws_lambda_powertools.utilities.typing import LambdaContext
15-
from aws_lambda_powertools.utilities.validation import validator
16-
17-
logger = Logger(service="version-track")
18-
tracer = Tracer() # this checks for aws-xray-sdk presence
19-
metrics = Metrics(namespace="powertools-layer-canary", service="PowertoolsLayerCanary")
20-
data_masker = DataMasking()
21-
app = APIGatewayRestResolver()
2248

2349
layer_arn = os.getenv("POWERTOOLS_LAYER_ARN")
2450
powertools_version = os.getenv("POWERTOOLS_VERSION")
2551
stage = os.getenv("LAYER_PIPELINE_STAGE")
2652
event_bus_arn = os.getenv("VERSION_TRACKING_EVENT_BUS_ARN")
2753

2854

29-
# Model to check parser imports correctly, tests for pydantic
30-
class OrderItem(BaseModel):
31-
order_id: int
32-
quantity: int
33-
description: str
34-
url: HttpUrl
35-
36-
37-
# Tests for jmespath presence
38-
@event_parser(model=OrderItem, envelope=envelopes.EventBridgeEnvelope)
39-
def envelope_handler(event: OrderItem, context: LambdaContext):
40-
assert event.order_id != 1
41-
42-
43-
# Tests for fastjsonschema presence
44-
@validator(inbound_schema={}, envelope="detail")
45-
def validator_handler(event, context: LambdaContext):
46-
pass
47-
48-
4955
def handler(event):
5056
logger.info("Running checks")
5157
check_envs()
5258
verify_powertools_version()
59+
with track_layer_compilation() as check_compilations:
60+
verify_layer_functionality()
61+
verify_layer_bytecode(import_compilations + check_compilations)
5362
send_notification()
5463
return True
5564

@@ -98,6 +107,32 @@ def verify_powertools_version() -> None:
98107
logger.info(f"Current Powertools version is: {current_version} [{_get_architecture()}]")
99108

100109

110+
def verify_layer_functionality() -> None:
111+
event = {
112+
"version": "0",
113+
"id": "12345678-1234-1234-1234-123456789012",
114+
"source": "powertools.layer.canary",
115+
"account": "123456789012",
116+
"time": "2026-01-01T00:00:00Z",
117+
"region": "us-east-1",
118+
"resources": [],
119+
"detail-type": "Canary order",
120+
"detail": {
121+
"order_id": "2",
122+
"quantity": 1,
123+
"description": "Layer canary",
124+
"url": "https://example.com",
125+
},
126+
}
127+
order = envelope_handler(event, None)
128+
if not isinstance(order, OrderItem) or order.order_id != 2:
129+
raise ValueError("Layer failed to parse the canary event")
130+
validator_handler(event, None)
131+
if not inspect.getsource(Logger):
132+
raise ValueError("Layer Python sources are unavailable")
133+
logger.info("Layer parsing, validation, and source inspection passed")
134+
135+
101136
def send_notification():
102137
"""
103138
sends an event to version tracking event bridge
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
from __future__ import annotations
2+
3+
from contextlib import contextmanager
4+
from importlib.machinery import SourceFileLoader
5+
from typing import TYPE_CHECKING
6+
7+
if TYPE_CHECKING:
8+
from collections.abc import Iterator
9+
10+
11+
@contextmanager
12+
def track_layer_compilation(layer_root: str = "/opt/python") -> Iterator[list[str]]:
13+
"""Record source compilation in the layer, restoring the loader even on failure."""
14+
compiled_files: list[str] = []
15+
source_to_code = SourceFileLoader.source_to_code
16+
prefix = f"{layer_root.rstrip('/')}/"
17+
18+
def track(loader, data, path, *, _optimize=-1):
19+
if path.startswith(prefix):
20+
compiled_files.append(path)
21+
return source_to_code(loader, data, path, _optimize=_optimize)
22+
23+
SourceFileLoader.source_to_code = track # type: ignore[method-assign]
24+
try:
25+
yield compiled_files
26+
finally:
27+
SourceFileLoader.source_to_code = source_to_code # type: ignore[method-assign]
28+
29+
30+
def verify_layer_bytecode(compiled_files: list[str]) -> None:
31+
if compiled_files:
32+
sample = ", ".join(compiled_files[:5])
33+
raise ValueError(f"Layer recompiled {len(compiled_files)} source files instead of using bytecode: {sample}")
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import calendar
2+
import importlib.machinery
3+
import os
4+
import py_compile
5+
import types
6+
import zipfile
7+
8+
import pytest
9+
10+
from layer_v3.layer.canary.bytecode_monitor import track_layer_compilation, verify_layer_bytecode
11+
12+
13+
def load_module(source):
14+
loader = importlib.machinery.SourceFileLoader("canary_bytecode_test", str(source))
15+
module = types.ModuleType(loader.name)
16+
exec(loader.get_code(loader.name), module.__dict__)
17+
return module
18+
19+
20+
@pytest.mark.parametrize("archive_roundtrip", [False, True])
21+
@pytest.mark.parametrize("mode", ["source", "timestamp", "checked_hash"])
22+
def test_layer_bytecode_after_packaging(tmp_path, mode, archive_roundtrip):
23+
layer = tmp_path / "build"
24+
layer.mkdir()
25+
source = layer / "sample.py"
26+
source.write_text("value = 42\n")
27+
# ZIP timestamps cannot represent this odd second.
28+
os.utime(source, (1_700_000_001, 1_700_000_001))
29+
if mode != "source":
30+
invalidation = {
31+
"timestamp": py_compile.PycInvalidationMode.TIMESTAMP,
32+
"checked_hash": py_compile.PycInvalidationMode.CHECKED_HASH,
33+
}[mode]
34+
py_compile.compile(str(source), doraise=True, invalidation_mode=invalidation)
35+
36+
if archive_roundtrip:
37+
archive_path = tmp_path / "layer.zip"
38+
with zipfile.ZipFile(archive_path, "w") as archive:
39+
for path in layer.rglob("*"):
40+
if path.is_file():
41+
archive.write(path, path.relative_to(layer))
42+
layer = tmp_path / "extracted"
43+
with zipfile.ZipFile(archive_path) as archive:
44+
archive.extractall(layer)
45+
for info in archive.infolist():
46+
timestamp = calendar.timegm(info.date_time)
47+
os.utime(layer / info.filename, (timestamp, timestamp))
48+
source = layer / "sample.py"
49+
50+
original_loader = importlib.machinery.SourceFileLoader.source_to_code
51+
with track_layer_compilation(str(layer)) as compiled:
52+
module = load_module(source)
53+
assert module.value == 42
54+
assert source.read_text() == "value = 42\n"
55+
assert importlib.machinery.SourceFileLoader.source_to_code is original_loader
56+
if mode == "source" or (mode == "timestamp" and archive_roundtrip):
57+
assert compiled == [str(source)]
58+
with pytest.raises(ValueError, match="Layer recompiled 1 source files"):
59+
verify_layer_bytecode(compiled)
60+
else:
61+
assert compiled == []
62+
verify_layer_bytecode(compiled)
63+
64+
65+
def test_checked_hash_rejects_stale_code_with_same_size_and_timestamp(tmp_path):
66+
source = tmp_path / "sample.py"
67+
source.write_text("value = 1\n")
68+
timestamp = source.stat().st_mtime_ns
69+
py_compile.compile(str(source), doraise=True, invalidation_mode=py_compile.PycInvalidationMode.CHECKED_HASH)
70+
source.write_text("value = 2\n")
71+
os.utime(source, ns=(timestamp, timestamp))
72+
73+
with track_layer_compilation(str(tmp_path)) as compiled:
74+
module = load_module(source)
75+
76+
assert module.value == 2
77+
assert compiled == [str(source)]
78+
79+
80+
def test_unrelated_imports_are_not_layer_compilations(tmp_path):
81+
layer = tmp_path / "layer"
82+
layer.mkdir()
83+
neighbor = tmp_path / "layer-other"
84+
neighbor.mkdir()
85+
source = neighbor / "sample.py"
86+
source.write_text("value = 42\n")
87+
88+
with track_layer_compilation(str(layer)) as compiled:
89+
assert load_module(source).value == 42
90+
91+
assert compiled == []
92+
93+
94+
def test_loader_is_restored_after_failed_import(tmp_path):
95+
source = tmp_path / "invalid.py"
96+
source.write_text("def invalid(\n")
97+
original_loader = importlib.machinery.SourceFileLoader.source_to_code
98+
99+
with pytest.raises(SyntaxError), track_layer_compilation(str(tmp_path)) as compiled:
100+
load_module(source)
101+
102+
assert compiled == [str(source)]
103+
assert importlib.machinery.SourceFileLoader.source_to_code is original_loader

0 commit comments

Comments
 (0)