前言
一年前写过一篇 Lambda 运行 Flask 应用的博文:
https://lpwmm.blog.csdn.net/article/details/139756140
当时使用的是 ZIP 包方式部署应用代码, 对于简单的 API 开发用起来还是可以的, 但是如果需要集成到 CI/CD pipeline 里面就有点不太优雅. 本文将介绍使用容器方式部署 Flask 应用到 Lambda, 并实现通过 API Gateway 进行访问.
开发一个简单的 Flask 应用
使用 uv 作为项目管理工具, 如果你还不了解 uv, 可以参考之前的这篇文章:
https://lpwmm.blog.csdn.net/article/details/146774376
完整的项目代码开源在 Gitee:
https://gitee.com/lpwm/flask-on-lambda
主要涉及到以下常用的场景:
- 静态文件访问, 模板中引入了自定义的 CSS 样式文件
- 表单处理
- 路由重定向
实现效果:
容器化封装
Dockerfile
bash
# 使用 ECR 提供的 Alpine 环境的 Python 3.12
FROM public.ecr.aws/docker/library/python:3.12-alpine
# [重要] 添加 Lambda Web Adapter (LWA)
COPY --from=public.ecr.aws/awsguru/aws-lambda-adapter:0.9.1 /lambda-adapter /opt/extensions/lambda-adapter
# 使用清华源安装 uv
RUN sed -i 's#https\?://dl-cdn.alpinelinux.org/alpine#https://mirrors.tuna.tsinghua.edu.cn/alpine#g' /etc/apk/repositories \
&& apk add --no-cache uv
# [重要] 配置 uv 的缓存文件夹路径, Lambda 中只有 /tmp 具有 RW 权限
ENV UV_CACHE_DIR="/tmp"
# 配置 uv 使用清华源
ENV UV_DEFAULT_INDEX="https://pypi.tuna.tsinghua.edu.cn/simple"
WORKDIR /var/task
# 先将 uv 项目相关的文件复制并初始化 .venv 和依赖
COPY pyproject.toml uv.lock .python-version ./
RUN uv sync
# 再将其他文件复制, 这样可以有效减少后面代码发生更新时重新 build 镜像所需要的操作时间
COPY static ./static
COPY templates ./templates
COPY app.py ./
# Lambda 执行时只能在一个运行环境中跑一个 Worker, 所以注意加参数 -w=1, 监听端口直接用 LWA 默认的 8080, 不用再改 LWA 的参数了
CMD ["uv", "run", "gunicorn", "-b=:8080", "-w=1", "app:app"]
测试容器
bash
docker build -t flask-on-lambda .
docker run -it --rm -p 8080:8080 flask-on-lambda
AWS 资源创建
ECR & Lambda
bash
REPO_NAME=flask-on-lambda
# 创建 ECR repository
aws ecr create-repository --repository-name $REPO_NAME
# 将 ECR repository 的 URI 存入变量, 方便后面调用
REPO_URI=$(aws ecr describe-repositories --repository-names $REPO_NAME --query 'repositories[0].repositoryUri' --output text)
# 从 URI 拆分出来 ECR 的主域名, 用于 Docker 登录访问
ECR_HOST=$(echo $REPO_URI | awk -F'/' '{print $1}')
# Docker 登录 ECR
aws ecr get-login-password --region cn-northwest-1 | docker login --username AWS --password-stdin $ECR_HOST
# 推送 Docker image 到 ECR
docker tag $REPO_NAME:latest $REPO_URI:latest
docker push $REPO_URI:latest
# [可选] 获取最新 Image 的哈希值
LATEST_DIGEST=$(aws ecr describe-images --repository-name $REPO_NAME --query 'sort_by(imageDetails,& imagePushedAt)[-1].imageDigest' --output text)
# [可选] 更新 Lambda
aws lambda update-function-code --function-name $REPO_NAME --image-uri $REPO_URI@$LATEST_DIGEST --no-cli-pager
# 创建 IAM Role
aws iam create-role \
--role-name lambda-execution-role-$REPO_NAME \
--assume-role-policy-document '{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"Service":"lambda.amazonaws.com"},"Action":"sts:AssumeRole"}]}' \
&& aws iam attach-role-policy \
--role-name lambda-execution-role-$REPO_NAME \
--policy-arn arn:aws-cn:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
# 获取 Role ARN
ROLE_ARN=$(aws iam get-role --role-name lambda-execution-role-$REPO_NAME --query 'Role.Arn' --output text)
# 创建和 REPO 相同名称的 Lambda
aws lambda create-function \
--function-name $REPO_NAME \
--package-type Image \
--code ImageUri=$REPO_URI:latest \
--role $ROLE_ARN
测试 Lambda 调用
bash
aws lambda invoke \
--function-name flask-on-lambda \
--payload '{
"httpMethod": "GET",
"path": "/",
"headers": {
"Host": "example.com",
"User-Agent": "curl/7.68.0"
},
"requestContext": {
"resourcePath": "/",
"httpMethod": "GET"
},
"body": null,
"isBase64Encoded": false
}' \
--cli-binary-format raw-in-base64-out \
/dev/stdout
预期响应:
json
{
"statusCode": 200,
"headers": {},
"multiValueHeaders": {
"server": ["gunicorn"],
"date": ["Sun, 13 Jul 2025 12:02:04 GMT"],
"connection": ["close"],
"content-type": ["text/html; charset=utf-8"],
"content-length": ["585"]
},
"body": "<html>\n\n<head>\n <title>Flask on Lambda</title>\n <link rel=\"stylesheet\" type=\"text/css\" href=\"/static/style.css\">\n</head>\n\n<body>\n <section>\n <h1>Welcome to the Flask on Lambda</h1>\n <p>This is a simple Flask application powered by Lambda.</p>\n </section>\n <section>\n <form action=\"\" method=\"post\">\n <label for=\"name\">Name:</label>\n <input type=\"text\" id=\"name\" name=\"name\" required placeholder=\"Enter your name\">\n <br>\n <button type=\"submit\">Submit</button>\n </form>\n </section>\n</body>\n\n</html>",
"isBase64Encoded": false
}
后面关于 API Gateway 的配置用 CLI 会很麻烦, 就都在 Console 操作了
API Gateway - HTTP API
- 创建 HTTP API
- 添加 Lambda 集成
- 修改路由:
Method:ANY
Resource path:/{proxy+}
- 使用默认 Stage
- 完成创建
- 在 Deploy > Stages 中找到 Invoke URL
- 使用浏览器访问测试, 受到 Lambda 的 Cold start 机制的影响, 首次加载和交互的速度会有点慢.
后面刷新后再次交互速度就很快了.
性能优化
为了保证用户能在首次访问的时候也有友好的体验, 我们可以为 Lambda 配置 Provisioned concurrency (额外收费的哟)
- 首先为 Lambda function 创建 Version
- 在 Version 视图中编辑 Provisioned concurrency
- 此时 Status 为 In progress, 需要等几分钟
状态变成 Ready 就好了
- 复制当前 Version 界面的 Function ARN
- 回到 HTTP API 控制台修改 Integration, 将 Lambda function 对应的 ARN 更新为上面复制的带有 Version 信息的
- 确认目前使用的集成设置中 Lambda 包含了版本信息(后面多了
:1
)
因为 HTTP API 默认开启了 Auto deploy 的选项, 所以这种修改都不需要手动重新 Deploy 操作. 再次使用浏览器访问测试, 速度嘎嘎的~
当然, 我们前面配置的 Provisioned concurrency = 1, 对于生产环境业务负载较高的场景, 可以酌情提升.
结尾
至此, 我们成功使用 Docker 容器的方式将一个 Flask 应用部署到了 Lambda 上, 并通过 API Gateway (HTTP API) 对外提供了可访问的 URL 地址, 实现了 Serverless 部署传统 Web 应用. 🎉🎉🎉
由于应用全部都封装在了 ECR 镜像, 所以在实际项目中, 也可以很方便的融入到 CI/CD pipeline 中.
关于之前撰稿期间使用 REST API 踩坑的经历, 有兴趣可以继续阅览. 😂
REST API 踩坑记录
由于 REST API 生成的 Stage URL 中必然会包含 stage 名称, 而经过 LWA 转发到后面的 Lambda 在进行路由地址生成的时候, 并不会包含这个 stage 的名称. 例如: stage = default
第一次请求的地址: https://api.com/default/
, 页面中 Flask 跳转后本来应该是定向到 https://api.com/default/success/abc
但是实际跳转后的地址是 https://api.com/success/abc
, 由于缺少了 stage 信息, 所以就 4XX 了. 如果 stage 名称是固定的, 那么其实也可以在 Flask 应用里面直接写死, 跟 REST API 传来的保持一致, 理论上应该也能解决. 不过懒得折腾了...下面是之前配置 REST API 的记录, 归档了.
- 添加 Trigger
- 创建新的 REST API
- 打开自动创建好的 API
- 删除自动创建的资源路径
- 在根路径下创建资源
- 创建 Proxy 资源
- 编辑集成
- Execution role 可以留空
- 测试 GET 方法
- 部署 API
- 继续返回 Lambda function 设置, 添加环境变量
AWS_LWA_REMOVE_BASE_PATH
, Value 值为 REST API 中的 Stage 名称
REST API 存在问题
完成上面的配置后, 如果从浏览器直接访问 Stage URL 根路径报错:
访问子路径 success/变量
可以加载出来页面
但是静态 CSS 文件加载失败, 因为请求路径中并没有包含 stage 的名称
先来解决直接访问 Stage 根路径报错的问题. 这是因为前面只给 /{proxy+}
创建了 ANY 方法和集成, 对于 /
来说, 还是空的设置. 再单独选中 /
资源路径, 创建 ANY 方法, 相同的方式配置 Lambda proxy 集成
重新部署后就可以访问到了:
当提交表单后, 重新定向的 URL 又出现了和 CSS 加载相同的问题, Stage 名称丢失了: