前言
一年前寫過一篇 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
# 使用 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"]
測試容器
docker build -t flask-on-lambda .
docker run -it --rm -p 8080:8080 flask-on-lambda
AWS 資源創建
ECR & Lambda
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 調用
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
預期響應:
{"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 名稱丟失了: