希望在标题上尽量包含更多的信息,原本命题为: Lambda + API Gateway 创建需 API Key 验证的 API(Docker + Python + Terraform), 但是觉得太长了,于是只取了前半部份。仍然要在开头部分强调一下本文件打算要实现什么
- 在 AWS 用 Lambda 和 API Gateway 创建 API
- 创建的 API 是 public 的,需要用 x-api-key 来验证
- Lambda 的实现代码打包在了一个 Docker 镜像中
- 整个 AWS 的基础架构(包括 ECR, Lambda, API Gateway 及权限等)是由 Terraform 脚本创建管理的
目标明确,我们直冲到代码的目录结构来,项目目录为 api-gateway-demo, Github 上的链接为 api-gateway-demo. 后面详叙还会把其中每一个文件的内部给列出来
1 2 3 4 5 6 7 8 9 10 11 12 |
api-gateway-demo ├── python │ ├── Dockerfile │ ├── app.py │ └── requirements.txt └── terraform ├── ecr.tf ├── lambda-api-gateway │ ├── api-gateway.tf │ ├── iam.tf │ └── lambda.tf └── main.tf |
由于创建 Lambda 的时候需经指定 Docker 镜像 的 hash, 而非 tag 名称,所以执行分以下几步
- Terraform 创建 ECR
- 创建 Docker 镜像并推送到上一步创建的 ECR 中
- 创建 Lambda 及 API Gateway 诸要素
也就是为什么要把其中三个 *.tf 文件放在一个子目录中去的缘故。也有人通过 Terraform 的 null_resource
配置 provisioner "local-exec"
在新建好 ECR 后自动创建 Docker 镜像及推送到 ECR 中去,但本文还是让事情更简单一些
创建 ECR
需要进到 terraform
目录,用到 ecr.tf
和 main.tf
, 并把对模块 lambda-api-gateway
的引用用 count = 0
禁用掉。
ecr.tf
1 2 3 4 5 6 7 |
resource "aws_ecr_repository" "apidemo-lambda" { name = "apidemo-lambda" } output ecr-url { value = aws_ecr_repository.apidemo-lambda.repository_url } |
main.tf
1 2 3 4 5 6 7 8 9 |
provider "aws" { region = "us-east-1" } module "lambda-api-gateway" { count = 0 source = "./lambda-api-gateway" ecr = aws_ecr_repository.apidemo-lambda } |
执行 terraform apply 后得到新建 ECR 的 url 如
$ terraform apply --auto-approve
......
Outputs:
ecr-url = "123456789088.dkr.ecr.us-east-1.amazonaws.com/apidemo-lambda"
创建并推着 Docker 镜像
进到 python
目录, 先浏览一下其下的三个文件
app.py
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 |
import json import uuid def build_response(body: object, status_code=200): return { 'headers': { "Content-type": "application/json" }, 'statusCode': status_code, 'body': json.dumps(body, indent=4) + '\n' } def get_job_info(event): job_id = event['pathParameters']['jobId'] return build_response({'message': f'job[{job_id}] is done'}) def create_job(event): job_name = json.loads(event['body'])['name'] job_id = str(uuid.uuid4()) return build_response({'message': f'job[{job_id}] submitted'}, 201) def handler(event, context): resource = event['resource'] http_method = event['httpMethod'] if resource == '/jobs/{jobId}' and http_method == 'GET': return get_job_info(event) elif resource == '/jobs' and http_method == 'POST': return create_job(event) else: return build_response({'message': 'Not found'}, 404) |
其中用来处理两种类型的请求,分别是
- GET /jobs/{jobId}
- POST /jobs
由 resource 和 http_method 来路由请求到不同的方法,返回数据的格式特别要注意,必须是一个 API Gateway 能理解的格式,如上面的包含 header
, statusCode
, 和 body
的 Python 字典。API Gatewy 收到 Lambda 的返回,提取出相应的字段组装成一个 HTTP 响应包,如果 Lambda 端随意就可能见到 "malformed Lambda proxy response" 的问题。
requirements.txt
1 |
boto3 |
其中定义了本 Python 项目用到的第三方包(要是用到的话)
Dockerfile
1 2 3 4 5 6 7 8 9 10 |
FROM public.ecr.aws/lambda/python:3.9 RUN yum update -y && yum upgrade -y && rm -Rf /var/cache/yum COPY app.py ${LAMBDA_TASK_ROOT} COPY requirements.txt . RUN pip3 install --no-cache-dir -r requirements.txt --target "${LAMBDA_TASK_ROOT}" CMD [ "app.handler" ] |
基本镜像用 AWS 官方提供的,它为我们设定了下列内容
- "WorkingDir": "/var/task"
- "Env": ["LAMBDA_TASK_ROOT"="/var/task"]
- "Entrypoint": ["/lambda-entrypoint.sh"] 我们的 app.handler 将作为它的参数
执行命令
$ aws ecr get-login-password --region us-east-1 | docker login --username AWS --password-stdin 123456789088.dkr.ecr.us-east-1.amazonaws.com
Login Succeeded
$ docker build -t 123456789088.dkr.ecr.us-east-1.amazonaws.com/apidemo-lambda:1.0.0 .
$ docker push 123456789088.dkr.ecr.us-east-1.amazonaws.com/apidemo-lambda:1.0.0
注:关于创建 Lambda Docker 镜像及本地测试请参见:Creating Lambda container images, 此非本文的内容
创建 Lambda 及 API Gateway 基础设施
现在再回到 terraform
目录,执行前需把 main.tf
中设置 count = 1
开启对模块 lambda-api-gateway
的调用。照旧,先看下其中三个文件的内容
lambda-api-gateway/iam.tf
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 |
resource "aws_iam_role" "apidemo_lambda_role" { name = "apidemo_lambda_role" assume_role_policy = <<EOF { "Version": "2012-10-17", "Statement": [ { "Action": "sts:AssumeRole", "Principal": { "Service": "lambda.amazonaws.com" }, "Effect": "Allow", "Sid": "" } ] } EOF } resource aws_iam_role_policy_attachment attach-lambda_basic_access_execution_role { role = aws_iam_role.apidemo_lambda_role.id policy_arn = data.aws_iam_policy.lambdaBasicExecutionRole.arn } data "aws_iam_policy" "lambdaBasicExecutionRole" { name = "AWSLambdaBasicExecutionRole" } |
在 Lambda 最基本的角色权限,能够在 CloudWatch 中创建 Log Group, Log Stream, 并往上写日志
lambda-api-gateway/lambda.tf
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
resource "aws_lambda_function" "apidemo-lambda" { function_name = "apidemo-lambda" description = "Demo API Gateway with Lambda" timeout = 300 role = aws_iam_role.apidemo_lambda_role.arn package_type = "Image" image_uri = "${var.ecr.repository_url}@${data.aws_ecr_image.lambda_image.id}" } # this docker image must be present, or else can't create the Lambda function data aws_ecr_image lambda_image { repository_name = var.ecr.name image_tag = var.image-tag } variable image-tag { default = "1.0.0" } variable "ecr" { } |
创建一个 Lambda 并关联相应的 IAM role 和 Docker 镜像
lambda-api-gateway/api-gateway.tf
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 |
resource "aws_api_gateway_rest_api" "demo-gateway-api" { name = "demoapi" api_key_source = "HEADER" description = "Define REST APIs for demo" } resource "aws_api_gateway_api_key" "demoapi-apikey" { name = "demoapi-key" } resource "aws_api_gateway_resource" "job_resource" { rest_api_id = local.rest_api.id parent_id = local.rest_api.root_resource_id path_part = "jobs" } resource "aws_api_gateway_resource" "jobid_resource" { parent_id = aws_api_gateway_resource.job_resource.id path_part = "{jobId}" rest_api_id = local.rest_api.id } resource "aws_api_gateway_method" "post_job" { authorization = "NONE" http_method = "POST" resource_id = aws_api_gateway_resource.job_resource.id rest_api_id = local.rest_api.id api_key_required = true } resource "aws_api_gateway_method" get_job_status { http_method = "GET" resource_id = aws_api_gateway_resource.jobid_resource.id rest_api_id = local.rest_api.id authorization = "NONE" api_key_required = true } resource aws_api_gateway_integration integration { count = length(local.resource_methods) rest_api_id = local.rest_api.id resource_id = local.resource_methods[count.index].resource_id http_method = local.resource_methods[count.index].http_method integration_http_method = "POST" type = "AWS_PROXY" uri = aws_lambda_function.apidemo-lambda.invoke_arn } resource "aws_api_gateway_deployment" "latest" { rest_api_id = local.rest_api.id stage_name = "stg" description = "Deploy driveapi to staging" depends_on = [ aws_api_gateway_integration.integration[0], aws_api_gateway_integration.integration[1] ] } resource "aws_api_gateway_usage_plan" "demoapi_usage_plan" { name = "demoapi-limit-access" api_stages { api_id = local.rest_api.id stage = "stg" } depends_on = [ aws_api_gateway_deployment.latest ] } resource "aws_api_gateway_usage_plan_key" "plan_2_key" { key_id = aws_api_gateway_api_key.demoapi-apikey.id key_type = "API_KEY" usage_plan_id = aws_api_gateway_usage_plan.demoapi_usage_plan.id } resource "aws_lambda_permission" "allow_api_gatewall" { count = length(local.api_path_levels) action = "lambda:InvokeFunction" function_name = aws_lambda_function.apidemo-lambda.function_name principal = "apigateway.amazonaws.com" source_arn = "${local.rest_api.execution_arn}/${local.api_path_levels[count.index]}" } locals { api_path_levels = [ "*/${aws_api_gateway_method.get_job_status.http_method}${aws_api_gateway_resource.jobid_resource.path}", "*/${aws_api_gateway_method.post_job.http_method}${aws_api_gateway_resource.job_resource.path}" ] rest_api = aws_api_gateway_rest_api.demo-gateway-api resource_methods = [ aws_api_gateway_method.get_job_status, aws_api_gateway_method.post_job ] } |
终于来到本文最重要也是最复杂的地方了, 当然是最需要加以解释的,由后面的效果有助我们理解这一大段 Terraform 脚本。基本上就是创建一个使用 Docker 镜像的 Lambda, 在 API Gateway 中创建了一个 API demoapi
, 因为是 REST API, 相应的资源是 /jobs
和 /jobs/{jobId}
,并设置为 Lambda 的触发器,调用 API 需要在头上加上 x-api-key
。
API 定义参照 REST API, 什么是资源,什么又是资源上的操作方法,然后把操作方法代理到相应的 Lambda,这一步叫做集成(Integration)。通过 API Gateway 想要调用 Lambda,需要在 Lambda 那一段加上相应的调用权限,最后通过使用计划(Usage Plan) 的方式把对资源的操作与 API Key 关联了起来。这一套建立起来之后,再最后就是部署到某个环境中去,API Gateway 就会为所定义的 API 生成一个 URL,开始使用了。
最终效果及测试
执行 terraform apply --auto-approve
, 如果一切顺利的话(运气不好的话,terraform 执行中可能用依赖的问题,解决办法可多执行几次,或加上 depends_on
),就可以开始调用前面定义的 APIs 了
可以在 API Gateway 的 Resources 里去测试这两个 API,但这儿会跳过 API key 的验证。所以应该找到 Stages 里 API 的 URL 来测试
在这个页面我们找到 endpoint 是 https://q0dejwgby4.execute-api.us-east-1.amazonaws.com/stg/*, 用 curl 命令来测试下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 |
$ curl -i https://q0dejwgby4.execute-api.us-east-1.amazonaws.com/stg/jobs/12345 HTTP/2 403 content-type: application/json content-length: 23 ...... {"message":"Forbidden"}% $ $ curl -i -H "x-api-key:ZBM8yE3p7N8KZZ5MUkeKm730LZaag1l31tYJaaOp" \ > https://q0dejwgby4.execute-api.us-east-1.amazonaws.com/stg/jobs/12345 HTTP/2 200 content-type: application/json content-length: 40 ...... { "message": "job[12345] is done" } $ $ curl -i -X POST -H "Content-Type:application/json" \ > https://q0dejwgby4.execute-api.us-east-1.amazonaws.com/stg/jobs --data '{"name": "new_job_xyz"}' HTTP/2 403 content-type: application/json content-length: 23 ...... {"message":"Forbidden"}% $ $ curl -i -X POST -H "x-api-key:ZBM8yE3p7N8KZZ5MUkeKm730LZaag1l31tYJaaOp" -H "Content-Type:application/json" \ > https://q0dejwgby4.execute-api.us-east-1.amazonaws.com/stg/jobs --data '{"name": "new_job_xyz"}' HTTP/2 201 content-type: application/json content-length: 73 ...... { "message": "job[7ed815c9-27e6-4e87-833b-c484d0696a8c] submitted" } |
不提供 x-api-key
就不允许调用相应的 API, 得到 403: Forbidden 的消息。那么 x-api-key
从哪儿来的呢?我们前面自己定义的,可在 API Keys 里找到
这也验证了 API Key 是生效了的,当我们对 aws_api_gateway_method
设置了 api_key_required
时,相应的资源上就会标记为 API Key Required
.
API Gateway 的 API Key 不仅仅是用来允不允许对某个资源的访问,还能用来限制对 API 的访问配额,所以是通过 aws_api_gateway_usage_plan
让 API 定义与 API Key 进行关联的。它可以控制对某个 API 一秒之内最多访问的次数,或一天(周) 之内最多访问多少次,前面的 Terraform 脚本中没有进行这样的配置限制。
最后别忘了 Lambda + API Gateway 中的 Lambda 这一主要劳动力,看看它发生了什么变化,这得上一张大图
每一个 REST API 对应一个 Lambda 的触发器,并有相应的权限,同时 API Key 在这里也能看到,所以对像 POST /jobs
和 GET /jobs/{jobId}
可以使用不同的 API Key.
如果在 API Gateway 的 Resource 中新加了一个 API, 也部署了,但在 Lambda 端未加上相应的权限,调用时也是得到 401: Forbidden
记得前面我们用 Terraform 生成的 API Gateway Resources 中,请求方法的 Method Response
显示为
而通过 AWS 控制台页面创建的一个请求方法的 Method Response
有些不一样,是这样子的
那么这有什么影响呢?我也不确定,反正 Integration Request
那个卡片里的 Type 都是 LAMBDA_PROXY
。之前碰过好像 Terraform 创建的 Resource 会出现 401: Forbidden 的情况,但后来又消失了。想要达成与 Web 控制台创建的一样效果的话,在 Terraform 中还要加上以下的 aws_api_gateway_method_response
和 aws_api_gateway_integration_response
两个声明
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
resource "aws_api_gateway_method_response" "method_response" { count = length(local.resource_methods) rest_api_id = local.rest_api.id resource_id = local.resource_methods[count.index].resource_id http_method = local.resource_methods[count.index].http_method status_code = "200" response_models = { "application/json" = "Empty" } } resource "aws_api_gateway_integration_response" "integration_response" { count = length(local.resource_methods) rest_api_id = local.rest_api.id resource_id = local.resource_methods[count.index].resource_id http_method = local.resource_methods[count.index].http_method status_code = "200" response_templates = { "application/json" = "" } } |
这两个一加,在执行 Terraform 时更容易出现依赖的问题。一个办法是这两个语句可以在后期补上,再执行 Terraform 脚本,或让它们去依赖 aws_api_gateway_deployment
。
另外在 Lambda 中处理请求与响应时还有不少东西需要不断深入,比如说
- 像 /job/{jobId} 中 pathParameter 怎么从 event 中取
- 像 ?key1=value1 中的 queryParameter 怎么从 event 中取
- post body 如何从 event 中取,以及它与请求时的
Content-Type
的关系 - post body 中如何获得 multipart/form-data 文件上传表单数据及文件内容
- 如何处理请求与响应数据的压缩,Content-Encoding, Accept-Encoding
- 如何进行文件下载,Content-Type: application/octet-stream, Content-Disposition: attachment; filename="abc.zip"
Lambda + API Gateway 的 {"message":"Forbidden"}
的情况很容易让人抓狂,其中有一个原因居然与本地 DNS 缓冲有关,必要时须清一下它
Mac OS X Yosemite and later:
sudo killall -HUP mDNSResponder
Windows:
ipconfig /flushdns
链接:
- API GATEWAY INVOKING LAMBDA FUNCTION WITH TERRAFORM - LAMBDA CONTAINER
- Using AWS Lambda with API Gateway and Terraform
- Document how to use a LAMBDA_PROXY #10494
- How do I troubleshoot HTTP 403 Forbidden errors from API Gateway?
- How do I resolve API Gateway "malformed Lambda proxy response" errors or 502 status codes?
- Output format of a Lambda function for proxy integration
本文链接 https://yanbin.blog/lambda-api-gateway-with-api-key/, 来自 隔叶黄莺 Yanbin Blog
[版权声明] 本文采用 署名-非商业性使用-相同方式共享 4.0 国际 (CC BY-NC-SA 4.0) 进行许可。
[…] on Lambda + API Gateway 创建需 API Key 验证的 APIv2 用于 HTTP/Websocket, 用来调用 Lambda 还得研究一下,它不需要逐步定义 […]
只是调用 lambda 的话我记得 v2 只要两个 resource (integration + lambda permission)
v2 用于 HTTP/Websocket, 用来调用 Lambda 还得研究一下,它不需要逐步定义 Resources, 而是用简单定义的 Routes 替代了。v2 需要的元素是 routes - integration - stage - lambda permission. 它对 Lambda 的返回值要求也没那么严格了。