Amazon API GatewayとAWS Lambdaを組み合わせて、サーバーレスアーキテクチャを実現する件について書きます。Pythonで。
API Gateway
API Gatewayとは
API GatewayはRestful WebAPIを提供するために、ラッパーになってくれるサービス。
自身もCloudFrontと結合されているというか、そのラッパーとして実装されているようです。CloudFrontといえばCDNというイメージですが、AWS WAFもCloudFrontと結合されているあたり、CDNに限らずLayer7のネットワークサービスという位置づけになったのかもしれません。CDNだけだと競合も多く、ある意味コモディティ化していきそうですしね。
API Gatewayの構成要素
- API
- 作成するAPI(の器)。
- Resource
- APIのパス。いわゆるルーティングの定義です。
- example.com/hoge/fugaのhogeとかfugaの部分。
- (当たり前だが)example.com/hoge/fugaのfugaとexample.com/fugaのfugaは別物として定義します。
- "var"ではなく"{var}"のように定義すると、パス変数になります。
- Method
- HTTP Method(GET/POST/PUT, etc.)を定義します。
- 一つのResourceに対して複数定義できます。
- Integration
- API Gatewayから見たBackendの定義。CloudFrontのOriginに対応しているのかと。
- Methodと1対1
- HTTPでアクセス可能なエンドポイントのほか、本題のLambdaも起動可能です。
- Methodから渡されるRequest Bodyを変換することが可能。
- content-type毎に定義します。
- フォーマット変換はまさかのVTL(Velocity Template Language)で書きます。
- Integration Response
- IntegrationからのResponse(Status Code、Response Body)と作成するAPIのResponseを対応づけます。
- 一つにMethodに対して複数指定可能(クライアントに返したいステータスコードの数だけ)。
- Status Codeのマッピングと、Response Bodyのフォーマット変換が可能。
- Method Response
- Integration ResponseのStatus CodeとResponse Model(Json Schema)を紐づける
- 初期状態で二つのModelが定義されています。
- Empty(パススルー用)
- Error(エラーメッセージ用)
サンプル
API Gateway
せっかくなのでPythonからAPIを作成。冒頭のregion, function, roleを適宜変更してください。
# -*- coding: utf-8 -*-
import boto3
client = boto3.client('apigateway')
region = 'ap-northeast-1'
function = 'arn:aws:lambda:ap-northeast-1:AWS_ACCOUNT_ID:function:YOUR_LAMBDA_FUNCTION'
role = 'arn:aws:iam::AWS_ACCOUNT_ID:role/YOUR_IAM_ROLE_FOR_INVOCATION'
def create_api():
rest_api = client.create_rest_api(
name='sample01',
description='sample api',
)
return rest_api['id']
def create_resource(rest_api_id):
for resource in client.get_resources(
restApiId=rest_api_id
)['items']:
if resource['path'] == '/':
path_root_id = resource['id']
new_resource = client.create_resource(
restApiId=rest_api_id,
parentId=path_root_id,
pathPart='{hoge}',
)
return new_resource['id']
def setup_method(rest_api_id, resource_id):
client.put_method(
restApiId=rest_api_id,
resourceId=resource_id,
httpMethod='GET',
authorizationType='NONE',
)
uri = 'arn:aws:apigateway:' + region + ':lambda:path/2015-03-31/functions/' + function + '/invocations'
client.put_integration(
restApiId=rest_api_id,
resourceId=resource_id,
httpMethod='GET',
type='AWS',
integrationHttpMethod='POST',
uri=uri,
credentials=role,
requestTemplates={
'application/json': get_request_template()
},
)
client.put_integration_response(
restApiId=rest_api_id,
resourceId=resource_id,
httpMethod='GET',
statusCode='200',
responseTemplates={
'application/json': '',
},
)
client.put_method_response(
restApiId=rest_api_id,
resourceId=resource_id,
httpMethod='GET',
statusCode='200',
responseModels={
'application/json': 'Empty',
},
)
client.put_integration_response(
restApiId=rest_api_id,
resourceId=resource_id,
httpMethod='GET',
statusCode='400',
selectionPattern='^\[400:.*',
responseTemplates={
'application/json': get_response_template(),
},
)
client.put_method_response(
restApiId=rest_api_id,
resourceId=resource_id,
httpMethod='GET',
statusCode='400',
responseModels={
'application/json': 'Error',
},
)
def get_request_template():
"""
ref. http://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html
"""
return """
{
"pathParams": {
#foreach ($key in $input.params().path.keySet())
"$key": "$util.escapeJavaScript($input.params().path.get($key))"#if ($foreach.hasNext),#end
#end
}
}
"""
def get_response_template():
"""
ref. http://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-mapping-template-reference.html
"""
return """
#set($data = $input.path('$'))
{
"message" : "${data.errorMessage}"
}
"""
def deploy(rest_api_id):
client.create_deployment(
restApiId=rest_api_id,
stageName='snapshot',
stageDescription='snapshot stage',
)
client.update_stage(
restApiId=rest_api_id,
stageName='snapshot',
patchOperations=[
{
'op': 'replace',
'path': '/*/*/logging/loglevel',
'value': 'INFO',
},
],
)
if __name__ == '__main__':
rest_api_id = create_api()
resource_id = create_resource(rest_api_id)
setup_method(rest_api_id, resource_id)
deploy(rest_api_id)
api_url = 'https://' + rest_api_id + '.execute-api.' + region + '.amazonaws.com/snapshot/'
print 'OK : {0}'.format(api_url + 'hoge')
print 'NG : {0}'.format(api_url + 'fuga')
Lambda
Lambdaのデプロイ方法については本題から外れるので、コードだけ。
# -*- coding: utf-8 -*-
def lambda_handler(event, context):
hoge = event['pathParams']['hoge']
if hoge == 'hoge':
return {'message': 'hogehoge'}
else:
raise NotHogeError(hoge)
class NotHogeError(Exception):
def __init__(self, hoge):
self.hoge = hoge
def __str__(self):
return '[400:BadRequest] {0} is not hoge'.format(self.hoge)
動作確認
上のcreateapi.pyを実行すると、(バグってなければ)こんな感じで二つURLが表示されます。
OK : https://xxxxx.execute-api.somewhere.amazonaws.com/snapshot/hoge
NG : https://xxxxx.execute-api.somewhere.amazonaws.com/snapshot/fuga
OKの方にアクセスすると
$ curl https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/snapshot/hoge
{"message": "hogehoge"}
NGの方だと、怒られます。
$ curl https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/snapshot/fuga -w '%{http_code}\n'
{
"message" : "[400:BadRequest] fuga is not hoge"
}
400
説明
Integration
LambdaのURI
バックエンドとなるLambdaのURIを指定する必要があります。
複雑ですが仕様が決まっているようで、リージョンとLambda Function ARNから決まります。
最後の「invocations」を抜かしていると「500 internal server error」を返してくれます。地味なはまりポイント。
uri = 'arn:aws:apigateway:' + region + ':lambda:path/2015-03-31/functions/' + function + '/invocations'
リクエストフォーマット変換
Velocityテンプレートを使ってLambda用のリクエスト(json)を作ります。
使用可能な変数はリファレンスを参照してください。
サンプルではハードコードしてますが、実運用ではテンプレートファイルとして外出しするのが良いかと思います。
{
"pathParams": {
#foreach ($key in $input.params().path.keySet())
"$key": "$util.escapeJavaScript($input.params().path.get($key))"#if ($foreach.hasNext),#end
#end
}
}
Lambdaの作り方
リクエスト
上記でフォーマット変換したデータはPythonの場合、eventにdictとして入ってます。
hoge = event['pathParams']['hoge']
レスポンス
LambdaからはAPI Gatewayにレスポンスを返す際は、returnかraiseを使います。
return {'message': 'hogehoge'}
raise NotHogeError(hoge)
次に説明しますがreturnで返してしまうと、クライアントにはステータスコード200で返ります。
(正確にはデフォルトのステータスコードですが。。。)
Integration Response
Lambdaからのレスポンスをクライアントレスポンスに変換します。
- 正規表現(デフォルトにする場合はなし)
- ステータスコード
- content-type
- テンプレート(VTL)
の組で指定します。
テンプレートを指定しない(''にする)場合、lambdaからのレスポンスがそのまま帰ります。
この場合はMethod Responseでresponse modelをEmptyにしておきます。
ステータスコードを200で返すだけなら簡単なのですが、クライアントエラー(400番台)やサーバーエラー(500番台)、あるいはリダイレクトしたい(300番台)等で返すことも必要になります。
そのためには、どのようなレスポンスの時にどのステータスコードにするのか、正規表現で対応づけることになります。
正規表現はどこを見ているのか
ここでサンプルのLambdaを書き換えてみます。
else:
#raise NotHogeError(hoge)
return '[400:BadRequest] {0} is not hoge'.format(hoge)
$ curl https://xxxxx.execute-api.ap-northeast-1.amazonaws.com/snapshot/fuga -w '%{http_code}\n'
"[400:BadRequest] fuga is not hoge"200
ステータスコードが200になってしまいました。。。
実はLambda handlerの返り値を見ているわけではないんですね。
return {'message' : '[400:BadRequest] {0} is not hoge'.format(hoge)}
return {'errorMessage' : '[400:BadRequest] {0} is not hoge'.format(hoge)}
色々やってたどり着いたのがこれです。
class NotHogeError(Exception):
def __init__(self, hoge):
self.hoge = hoge
def __str__(self):
"""ここ!"""
return '[400:BadRequest] {0} is not hoge'.format(self.hoge)
ということで、ステータスコードを変えたいときは例外を投げようということらしいです。
レスポンスフォーマット変換
正常系の場合はLambdaのレスポンスをパススルーで返せばよいのですが、jsonではなくXMLやHTMLで返す場合は変換する必要があります。また、異常系の場合、パススルーだとStackTraceも返ってしまい情けないことになるので、エラーメッセージだけ返すようにします。
元々ErrorというResponse Modelが定義されているので、それに合わせてテンプレートを作ります。
#set($data = $input.path('$'))
{
"message" : "${data.errorMessage}"
}
まとめ
API Gatewayってそんなに簡単じゃなくねって思いましたが、そもそもちゃんとAPI設計するならこれくらい考えろってことな気もしてきました。