
最近折腾 Python Lambda 的可观测性,踩了几个挺典型的坑:日志格式不统一、链路和请求对不上、指标维度一不留神就把成本拉高。这篇把 Lambda Powertools for Python 的用法一次说清楚,包括结构化日志、链路追踪、业务指标、部署和本地测试。
Lambda Powertools for Python 能直接给函数补上结构化日志、链路追踪和指标,不需要自己再造一套小型日志框架。但我在 Python 项目的 Lambda 代码里经常看到同一套现状:几个 print()、某处塞着一个 logging.basicConfig(),一旦出问题,只能到 CloudWatch 里搜文本片段。
短期看没毛病,直到凌晨两点线上报错,却没人能判断哪一行日志对应哪个请求。
下面我会一步步实现一个虚构的共享单车租赁函数:日志以 JSON(JavaScript 对象表示法)格式输出,链路数据发往 AWS X-Ray,业务指标进入 CloudWatch。部署使用 Serverless Framework,依赖交给 uv 管理,测试则用 pytest(Python 测试框架)完成,整个测试过程不需要 AWS 账号。
我之前写过 TypeScript 版本,这篇专门讲 Python,顺便把 Python 侧容易踩的坑也一起交代清楚。
先说结论
Powertools for AWS Lambda(Python)提供 Logger、Tracer 和 Metrics 三个装饰器,可以把一个几乎没有可观测性的函数改造成可追踪、可度量的服务。最省事的接入方式是官方 Lambda Layer(层),层 ARN(Amazon Resource Name,亚马逊资源名称)可以从 Parameter Store 读取。
最常见、也最容易付出真金白银的坑,是给指标设置基数过高的维度。
这篇会解决这些问题:
Powertools for AWS Lambda 是由 AWS(Amazon Web Services,亚马逊云服务)维护的开源库,同时支持 Python、TypeScript、Java 和 .NET。它把无服务器函数中已经验证过的实践拆成了几个小模块。
Python 当前的大版本是 3,本文示例基于 3.39 版本验证。其核心由三部分组成。
Logger 会把每行日志写成一个 JSON 对象,也就是结构化日志。你得到的不是“rental 42 started”这样一段自由文本,而是包含 level、message、service 以及自定义字段的对象。后续可以直接按字段筛选和聚合,不必再靠正则表达式搜日志。
Tracer 为 AWS X-Ray 创建链路。一条链路会展示请求在系统各环节的耗时,例如价格计算用了 4 ms,访问 DynamoDB 用了 18 ms。Tracer 本质上是 X-Ray SDK(软件开发工具包)上的一层薄封装,并会自动为 boto3 等受支持的库注入追踪逻辑。
Metrics 使用 EMF(Embedded Metric Format,嵌入式指标格式)创建 CloudWatch 自定义指标。业务函数不需要调用任何指标 API,只需向日志写入一段结构特殊的 JSON,CloudWatch 随后会异步提取并转换为指标。这种方式速度快,也避免了 handler 内部多余的 API 请求。

Logger 和 Metrics 最终都会写入 stdout,CloudWatch 会自动把其中的 EMF 数据块提取成指标。
npm install -g serverless)示例保持得很小:HTTP 接口 POST /rentals 接收租赁请求,计算价格,并把记录写入 DynamoDB。最终项目结构如下:
bike-rentals/
├── pyproject.toml
├── serverless.yml
├── src/
│ └── handler.py
└── tests/ ├── conftest.py └── test_handler.py
创建目录和 pyproject.toml。注意 Powertools 只出现在 dev 依赖组中。运行时依赖来自官方 Layer,不需要放进部署包;本地仍然要安装它,编辑器和测试才能正常使用。
boto3 同样只用于本地开发,因为 Lambda 运行时已经内置了 AWS SDK。
# pyproject.toml
[project]
name = "bike-rentals"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = [] [dependency-groups]
dev = [ "aws-lambda-powertools[tracer]>=3.39", "boto3>=1.40", "pytest>=8.4",
] [tool.pytest.ini_options]
testpaths = ["tests"]
mkdir -p bike-rentals/src bike-rentals/tests && cd bike-rentals
# create pyproject.toml as shown above, then:
uv sync
# alternative without uv:
# pip install "aws-lambda-powertools[tracer]" boto3 pytest
[tracer] extra 会一并安装 X-Ray SDK。缺少它时,代码在本地仍可能启动,但导入 Tracer 会失败。官方 Layer 已经包含全部运行时依赖。
真正的业务代码来了。Logger、Tracer 和 Metrics 都只在模块级别创建一次,不要在 handler 内部反复实例化。这样它们能跨同一执行环境中的多次调用复用,只有冷启动时才承担初始化成本。
服务名和指标命名空间来自环境变量,因此构造函数不需要传参。
# src/handler.py
import json
import os
import uuid
from datetime import datetime, timezone import boto3
from aws_lambda_powertools import Logger, Metrics, Tracer
from aws_lambda_powertools.logging import correlation_paths
from aws_lambda_powertools.metrics import MetricUnit
from aws_lambda_powertools.utilities.typing import LambdaContext logger = Logger()
tracer = Tracer()
metrics = Metrics() TABLE_NAME = os.environ.get("RENTALS_TABLE", "bike-rentals-local")
PRICE_PER_HOUR_CENTS = {"city": 300, "ebike": 650, "cargo": 900} _table = None def rentals_table(): """Creates the DynamoDB table on first access (fast cold start, replaceable in tests).""" global _table if _table is None: _table = boto3.resource("dynamodb").Table(TABLE_NAME) return _table class InvalidRequest(Exception): pass def parse_request(event: dict) -> dict: try: body = json.loads(event.get("body") or "{}") except json.JSONDecodeError as err: raise InvalidRequest("Body is not valid JSON") from err bike_type = body.get("bikeType") station = body.get("station") hours = body.get("hours") if bike_type not in PRICE_PER_HOUR_CENTS: raise InvalidRequest(f"Unknown bike type: {bike_type}") if not isinstance(station, str) or not station: raise InvalidRequest("Station is missing") if not isinstance(hours, int) or not 1 <= hours <= 24: raise InvalidRequest("hours must be between 1 and 24") return {"bike_type": bike_type, "station": station, "hours": hours} @tracer.capture_method
def calculate_price(bike_type: str, hours: int) -> int: price = PRICE_PER_HOUR_CENTS[bike_type] * hours if hours >= 8: price = int(price * 0.8) # day discount return price @tracer.capture_method
def save_rental(rental: dict) -> None: rentals_table().put_item(Item=rental) def response(status: int, body: dict) -> dict: return { "statusCode": status, "headers": {"Content-Type": "application/json"}, "body": json.dumps(body), } @logger.inject_lambda_context(correlation_id_path=correlation_paths.API_GATEWAY_HTTP)
@tracer.capture_lambda_handler
@metrics.log_metrics(capture_cold_start_metric=True)
def lambda_handler(event: dict, context: LambdaContext) -> dict: try: request = parse_request(event) except InvalidRequest as err: logger.warning("Invalid request", reason=str(err)) metrics.add_metric(name="InvalidRequest", unit=MetricUnit.Count, value=1) return response(400, {"message": str(err)}) rental_id = str(uuid.uuid4()) logger.append_keys(rental_id=rental_id, station=request["station"]) tracer.put_annotation(key="station", value=request["station"]) price_cents = calculate_price(request["bike_type"], request["hours"]) rental = { "rentalId": rental_id, "bikeType": request["bike_type"], "station": request["station"], "hours": request["hours"], "priceCents": price_cents, "startedAt": datetime.now(timezone.utc).isoformat(), } try: save_rental(rental) except Exception: logger.exception("Could not save rental") metrics.add_metric(name="RentalSaveFailed", unit=MetricUnit.Count, value=1) return response(500, {"message": "Please try again later"}) metrics.add_dimension(name="bike_type", value=request["bike_type"]) metrics.add_metric(name="RentalStarted", unit=MetricUnit.Count, value=1) metrics.add_metric(name="RentalRevenue", unit=MetricUnit.Count, value=price_cents) logger.info("Rental started", price_cents=price_cents) return response(201, {"rentalId": rental_id, "priceCents": price_cents})
这里有几处值得单独说。
三个装饰器的叠加顺序不是随便写的:最外层 Logger 负责注入 Lambda 上下文和关联 ID;中间的 Tracer 负责开启追踪片段;最内层 Metrics 负责收集指标,并在调用结束时统一刷新。
Logger 会通过 JMESPath(JSON 查询语言)从 requestContext.requestId 中读取关联 ID,常量 correlation_paths.API_GATEWAY_HTTP 省掉了手写路径的麻烦。
logger.append_keys() 会把租赁 ID 和站点附加到本次调用后续的所有日志中。即使日志来自更深层的函数,也能按同一个租赁 ID 找全。tracer.put_annotation() 则让站点信息可以在 X-Ray 中检索。
Annotation 会建立索引,Metadata 不会。需要过滤和查询的值应该放在 Annotation 中。
指标里的 bike_type 维度只有三个取值,问题不大。但如果换成 rental_id,每次租赁都会生成一条新指标,账单会很快失控。另外,RentalRevenue 实际记录的是以分为单位的收入,使用 Count 并不算语义完美,但用于 CloudWatch 求和没有问题。
不要把 Layer 版本写死,可以从公开的 SSM 参数读取当前 ARN。AWS 会按架构和 Python 版本发布最新的 Powertools Layer,Serverless Framework 会在部署时解析 ${ssm:…}。
# serverless.yml
service: bike-rentals
frameworkVersion: "4" provider: name: aws runtime: python3.13 architecture: arm64 region: eu-central-1 memorySize: 256 timeout: 10 tracing: lambda: true environment: POWERTOOLS_SERVICE_NAME: bike-rentals POWERTOOLS_METRICS_NAMESPACE: BikeRental POWERTOOLS_LOG_LEVEL: INFO RENTALS_TABLE: ${self:service}-${sls:stage}-rentals iam: role: statements: - Effect: Allow Action: - dynamodb:PutItem Resource: !GetAtt RentalsTable.Arn - Effect: Allow Action: - xray:PutTraceSegments - xray:PutTelemetryRecords Resource: "*" package: patterns: - "!**" - "src/**" functions: startRental: handler: src/handler.lambda_handler layers: - ${ssm:/aws/service/powertools/python/arm64/python3.13/latest} events: - httpApi: path: /rentals method: post resources: Resources: RentalsTable: Type: AWS::DynamoDB::Table Properties: TableName: ${self:provider.environment.RENTALS_TABLE} BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: rentalId AttributeType: S KeySchema: - AttributeName: rentalId KeyType: HASH
这里有三个关键点。
第一,函数架构和 Layer 必须一致:provider 中是 arm64,SSM 参数路径中也必须是 arm64。
第二,tracing.lambda: true 会开启主动追踪。角色中明确列出两个 X-Ray 权限,不需要部署时再靠猜。
第三,package.patterns 只打包 src 目录。测试、虚拟环境和锁文件都不会进入部署包,最终体积只有几 KB。
至于版本锁定,latest 确实方便,但下一次部署可能在毫无提示的情况下拉取新 Layer。生产环境更适合固定具体 ARN,通过自动更新拉取请求或发布流程有意识地升级。入门阶段直接使用这个参数没问题。
掌握三个细节后,Lambda Powertools for Python 很好测试。
第一,Tracer 在 Lambda 环境外会自动禁用。
第二,Metrics 必须设置命名空间,否则刷新指标时会抛出 SchemaValidationError。
第三,Metrics 会在多次调用之间保留内存数据,所以每个测试之间都要重置状态。
conftest.py 负责处理这些工作,同时提供一个假的 Lambda 上下文。
# tests/conftest.py
import os
import sys
from dataclasses import dataclass import pytest os.environ.setdefault("POWERTOOLS_SERVICE_NAME", "bike-rentals")
os.environ.setdefault("POWERTOOLS_METRICS_NAMESPACE", "BikeRental")
os.environ.setdefault("AWS_DEFAULT_REGION", "eu-central-1")
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) @dataclass
class FakeContext: function_name: str = "start-rental" memory_limit_in_mb: int = 256 invoked_function_arn: str = "arn:aws:lambda:eu-central-1:123456789012:function:start-rental" aws_request_id: str = "11111111-2222-3333-4444-555555555555" @pytest.fixture
def context(): return FakeContext() @pytest.fixture(autouse=True)
def reset_metrics(): import handler from aws_lambda_powertools.metrics.provider import cold_start handler.metrics.clear_metrics() handler.metrics.clear_default_dimensions() cold_start.is_cold_start = True # The logger is created at import time; point its output to the real stdout so capfd sees it handler.logger.registered_handler.setStream(sys.__stdout__) yield
setStream 是这里最隐蔽的坑。
Logger 会记住导入模块时的 sys.stdout,但 pytest 会在每个测试中替换 stdout,结果日志就不知道被写到哪里去了。把 Logger 重新指向真实 stdout,再配合 capfd fixture,日志和 EMF 数据块才能稳定进入测试断言。
# tests/test_handler.py
import json import handler class FakeTable: def __init__(self, fail: bool = False): self.items = [] self.fail = fail def put_item(self, Item): if self.fail: raise RuntimeError("DynamoDB unreachable") self.items.append(Item) def http_event(body: dict) -> dict: return {"body": json.dumps(body), "requestContext": {"requestId": "req-42"}} def output_lines(capfd) -> list[dict]: return [json.loads(line) for line in capfd.readouterr().out.splitlines() if line.strip()] def metric_names(lines: list[dict]) -> set[str]: names = set() for line in lines: for block in line.get("_aws", {}).get("CloudWatchMetrics", []): names.update(m["Name"] for m in block["Metrics"]) return names def test_rental_is_saved_and_metrics_are_emitted(monkeypatch, capfd, context): table = FakeTable() monkeypatch.setattr(handler, "rentals_table", lambda: table) result = handler.lambda_handler(http_event({"bikeType": "ebike", "station": "Central Station", "hours": 2}), context) assert result["statusCode"] == 201 assert table.items[0]["priceCents"] == 1300 lines = output_lines(capfd) assert {"ColdStart", "RentalStarted", "RentalRevenue"} <= metric_names(lines) log = next(line for line in lines if line.get("message") == "Rental started") assert log["correlation_id"] == "req-42" assert log["station"] == "Central Station" assert log["service"] == "bike-rentals" def test_day_discount_applies_from_eight_hours(): assert handler.calculate_price("city", 8) == 1920 def test_invalid_request_returns_400(monkeypatch, capfd, context): monkeypatch.setattr(handler, "rentals_table", lambda: FakeTable()) result = handler.lambda_handler(http_event({"bikeType": "tandem", "station": "Harbour", "hours": 1}), context) assert result["statusCode"] == 400 assert "InvalidRequest" in metric_names(output_lines(capfd)) def test_storage_error_is_logged_with_stack_trace(monkeypatch, capfd, context): monkeypatch.setattr(handler, "rentals_table", lambda: FakeTable(fail=True)) result = handler.lambda_handler(http_event({"bikeType": "city", "station": "Harbour", "hours": 1}), context) assert result["statusCode"] == 500 lines = output_lines(capfd) error = next(line for line in lines if line.get("level") == "ERROR") assert error["exception_name"] == "RuntimeError" assert "RentalSaveFailed" in metric_names(lines)
uv run pytest -q
预期结果:
.... [100%]
4 passed in 0.59s
测试通过 monkeypatch 把 DynamoDB 表替换成一个很小的假对象。由于 handler 只会在首次访问时创建真实表资源,测试既不需要凭证,也不需要访问网络。
如果还想校验事件结构,可以使用 Powertools Parser(事件解析器)检查事件是否合法。
serverless deploy --stage dev curl -s -X POST "https://<your-api-id>.execute-api.eu-central-1.amazonaws.com/rentals" \ -H "Content-Type: application/json" \ -d '{"bikeType":"ebike","station":"Central Station","hours":2}'
响应会包含新的租赁 ID 和 1300 分的价格。还可以发送一个错误请求,例如使用 "bikeType":"tandem"。接口会返回 400 状态码,稍后 CloudWatch 中会出现 InvalidRequest 指标。
{
"rentalId": "1c5ddae2-314f-409d-bac8-ba64f4d8a92e", "priceCents": 1300
}
🎬 Lambda Powertools for Python 请求流程:这个动态演示图只会显示在我的网站上。
函数日志大致如下,内容做了缩短。cold_start、function_request_id 和 correlation_id 来自装饰器,rental_id 和 station 来自 append_keys。
{ "level": "INFO", "location": "lambda_handler:110", "message": "Rental started", "service": "bike-rentals", "cold_start": true, "function_name": "bike-rentals-dev-startRental", "function_request_id": "11111111-2222-3333-4444-555555555555", "correlation_id": "req-42", "rental_id": "1c5ddae2-314f-409d-bac8-ba64f4d8a92e", "station": "Central Station", "price_cents": 1300
}
在 Logs Insights 中可以直接按字段过滤。下面这条查询会列出服务的所有错误,同时显示站点和关联 ID:
fields @timestamp, message, station, correlation_id, exception_name
| filter service = "bike-rentals" and level = "ERROR"
| sort @timestamp desc
| limit 20
在 CloudWatch 链路视图中,每次调用都有一个函数片段,下面还会出现 calculate_price、save_rental 以及 DynamoDB 调用的子片段。Tracer 还会自动添加 ColdStart 和 Service Annotation。
使用 annotation.station = "Central Station" 过滤,就能找到某个站点的全部租赁请求。
等待一两分钟,RentalStarted、RentalRevenue、InvalidRequest 和 ColdStart 会出现在 BikeRental 命名空间下。
Powertools 会把冷启动指标写进单独的 EMF 数据块,其中带有 function_name 和 service 两个维度。Python 冷启动到底会损失多少性能,以及如何降低这部分开销,可以继续研究 SnapStart 的实际效果。
如果没有设置 POWERTOOLS_METRICS_NAMESPACE,也没有传入 namespace 参数,Metrics 在刷新数据时会抛出 SchemaValidationError。
生产环境里,这个错误最终表现为 API 返回 500。provider 和测试环境里都应提前设置该变量,让问题在部署之前暴露。
x86 Layer 部署到 arm64 函数上时,不一定会在部署阶段报错,通常要等到加载包含编译代码的模块才会暴露,例如依赖 pydantic-core 的 Parser。
务必确认函数架构和 SSM 参数路径使用同一个值。官方文档中的 Serverless Framework 示例,甚至也出现过 arm64 函数搭配 x86 Layer 的情况。
CloudWatch 会把指标名称和维度值的每个唯一组合视为一条独立指标,自定义指标还会分别计费。
因此,加入 rental_id 维度后,每次租赁都会创建一条新指标。对于这类值,可以使用 metrics.add_metadata():数据会进入日志,但不会成为指标维度。
我的经验法则是:如果一个维度的取值无法用一只手或一张短列表列出来,就不应该把它设为维度。
如果启用了 Lambda 的高级日志控制,Lambda 会设置 AWS_LAMBDA_LOG_LEVEL。它的优先级高于 POWERTOOLS_LOG_LEVEL。
例如 Lambda 日志级别是 WARN,那么无论 serverless.yml 中怎么配置,都看不到 INFO 日志。最好同步维护两个配置,否则 Powertools 会在日志中给出提示。
Tracer 默认会把返回值和异常保存为链路 Metadata。对于包含个人信息的响应,或者超过大约 64 KB 的响应,应在装饰器上设置 capture_response=False。
Logger 的 log_event=True 也一样:调试时很方便,但在生产环境中,它会把完整请求乃至访问令牌直接写进日志。
Powertools 本身免费,但日志、指标和链路的服务端并不免费。
JSON 日志通常比自由文本更长,会增加 CloudWatch Logs 的数据量。每条自定义指标都有固定的月度价格,X-Ray 超过免费额度后也会按记录量收费。
流量较大时,需要检查 X-Ray 采样率,并通过 POWERTOOLS_LOGGER_SAMPLE_RATE 只为一部分调用开启 DEBUG 日志。
安全方面要坚持最小权限原则。示例中的角色只允许对指定表执行 dynamodb:PutItem。日志往往包含敏感信息,应设置合理的保留周期,并限制日志组的访问权限。
在我看来,Powertools 应该成为每个生产环境 Python Lambda 函数的标配。接入成本很低,但第一次真实故障发生时,差异会非常明显:一个关联 ID 能串起请求的全部日志,省下来的时间往往比任何运行时优化都多。
不过没必要一次性把所有能力全部打开。
建议先接入 Logger,它的收益最大。再选择两三个确实有人查看的业务指标。等系统涉及多个服务后,再加入 Tracer。
如果团队已经全面采用 OpenTelemetry,可以评估 AWS Distro for OpenTelemetry 是否更合适。Powertools 刻意依赖 X-Ray SDK,主要是为了控制冷启动成本。另外,在装饰器的使用体验上,Python 版本的一些细节确实比 TypeScript 版本更顺手。
不需要。官方 Layer 已经包含运行时需要的库和 X-Ray SDK。本地只安装一份用于编辑器提示和测试即可,例如通过 uv 放进 dev 依赖组。
可以。Logger 和 Metrics 不依赖 X-Ray。可以直接移除 Tracer,也可以设置 POWERTOOLS_TRACE_DISABLED=true。
先把命名空间和服务名设置为环境变量,再通过 capfd 或 capsys 读取输出,将每一行解析为 JSON。测试之间使用 clear_metrics() 重置指标,同时重置冷启动标记。
根据安装文档,Python 3.10 到 3.14 都有对应的 Layer,并提供 x86_64 和 arm64 两种架构。Python 3.9 Layer 停留在版本 27。
找一个现有的 Python 函数,只给它加上带关联 ID 的 Logger。整个过程大约半小时,但下次线上出问题时,它大概率能直接帮你省下排障时间。
完成之后,再挑一个真正有业务价值的指标。想想看,你最重要的函数最应该监控哪个值?
如果还要解决依赖打包和 Lambda 架构匹配问题,可以继续了解如何使用 uv 为 AWS Lambda 打包 Python 依赖。