Cloud9のローカルテストでLocalStackを使えるようにする #reinvent

2017.12.22

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

こんにちは、やっとEchoを入手した城岸です。今回はCloud9のローカルテストでLocalStackを使えるようにしてみようと思います。あらかじめ言っておきますが、結構無理やりな感じで実現しています。

Cloud9やLocalStack、あとCloud9の中で利用されているSAM Localの内容については以下の記事などをご覧ください。

ちょっと量が多いですが、上記ブログに目を通していただいた後に本ブログを見ていただけると理解しやすいと思います。

前提知識:Cloud9環境の実態とローカルテスト

Cloud9環境の実態

Cloud9はバックエンドでEC2インスタンス(もしくはそれと同等のリソース)が動いていてます。ブラウザ上のEnvironmentに表示されているファイルの実態はEC2インスタンス上に存在しています。

ローカルテスト

Cloud9では作成したLambdaファンクションに対しローカルテストが実行できます。ローカルテストは現在『Lambdaファンクションのテスト』と、『擬似的なAPI Gatewayを経由したLambdaファンクションのテスト』の2パターンが実行できます。名前の通りですがこのテストはEC2インスタンス上で実行されます。

テスト実行時にコンテナが起動していたので「おやっ」と思ったのですが、実態としてはSAM Localが利用されているようです。

$ docker ps
CONTAINER ID        IMAGE                     COMMAND                  CREATED             STATUS              PORTS               NAMES
66d7f31b0590        lambci/lambda:python3.6   "/var/lang/bin/pyt..."   4 seconds ago       Up 3 seconds                            trusting_wilson

$ docker images
REPOSITORY                 TAG                 IMAGE ID            CREATED             SIZE
lambci/lambda              nodejs4.3           74d482955025        2 months ago        933 MB
lambci/lambda              python2.7           64cb3bf41370        2 months ago        965 MB
lambci/lambda              nodejs6.10          711874a983f1        2 months ago        1.01 GB
lambci/lambda              python3.6           bcad631a6973        2 months ago        1.09 GB

$ ps aux | grep sam
ec2-user 25491  0.1  0.1 208580 14256 ?        Sl   10:23   0:00 sam local start-api --template template.yaml --port 3039 --skip-pull-image

テスト対象のLambdaファンクション内でAWSのリソースを操作している場合、特に考慮しなければそのままAWSのエンドポイントに対し実行されます。まぁこれはこれでしょうがない気がしますが、ちょっと都合が悪い時があります。

はいっ、そこで登場LocalStack。ローカルテストの時にはLocalStackのリソース使っちゃおうぜ!!

ということで、なんか前振りが長くなってしまいましたがこれが今回のやりたいことになります。

Cloud9環境の構築

Cloud9環境は以下のブログを参考に構築します。EC2インスタンスはm4.large、lambdaはPython3.6、Function triggerはAPI Gatewayを選択しています。

LocalStackのインストール

EC2インスタンス上で以下のコマンドを実行し、LocalStackをインストールしていきます。

$ sudo pip install docker-compose
$ git clone https://github.com/atlassian/localstack.git
$ cd localstack

lambdaの作成

今回はこのコードを使ってテストをしてきます。

lambda_function.py

import boto3
import json

def set_endpoint(event):
    if '127.0.0.1' in event['requestContext']['identity']['sourceIp']:
        dynamodb = boto3.resource('dynamodb', endpoint_url='http://XXX.XXX.XXX.XXX:4569/')
    else:
        dynamodb = boto3.resource('dynamodb')
    return dynamodb

def lambda_handler(event, context):

    body_data=json.loads(event['body'])
    first_name=body_data['first_name']
    last_name=body_data['last_name']
    
    dynamodb = set_endpoint(event)
    table = dynamodb.Table('users')

    response = table.put_item(
        Item={
            'username': (first_name+last_name).lower(),
            'first_name': first_name,
            'last_name': last_name
            }
    )

    lambda_response = {
        'statusCode': response['ResponseMetadata']['HTTPStatusCode'],
        'body': json.dumps(response)
    }
    return lambda_response

4行目から9行目の部分がエンドポイントを変えているところになります。

event実行のソースIPがローカルであれば向き先をLocalStackに変更するようにしています。正直Lambdaファンクション内にこのような分岐のコードができるのはあまり好きじゃないですが、Cloud9でエンドポイントを変えるにはこうする他ないのかなと思っています。他に良い方法を知っている方がいたら教えてください。
6行目のendpoint_urlのXXX.XXX.XXX.XXXの部分は、EC2のプライベートIPを設定します。SAM LocalのコンテナとLocalStackのコンテナが同一のネットワーク上にいれば、LocalStackのIPでもいけると思います。

ローカルテストの実行

まずは、EC2インスタンスでLocalStackを起動し初期データを登録します。今回は事前にDynamoDBを作成します。

$ docker-compose up

別のターミナルで以下を実行します。

$ aws --endpoint-url=http://localhost:4569 dynamodb create-table  --table-name users \
--attribute-definitions AttributeName=username,AttributeType=S \
--key-schema AttributeName=username,KeyType=HASH \
--provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5

ようやく実行の準備が整いました。2パターンあるテストのうち『Lambdaファンクションのテスト』から実行していきます。 画面左側のRUNを押下後、対象をLambda(local)、Payloadに以下を入力後、画面右側のRUNを実行します。

{
    "body": "{\"first_name\":\"jane\",\"last_name\":\"Doe\"}",
    "requestContext": {
        "identity": {
            "sourceIp": "127.0.0.1:0000"
        }
    }
}

するとレスポンスとしてstatusCodeの200が返ってきていることが確認できると思います。また、以下のコマンドでLocalStack内にリソースが格納されていることも確認できます。

$ aws --endpoint-url=http://localhost:4569 dynamodb  \
get-item --table-name users --key '{"username":{"S": "janedoe" }}'   
{
    "Item": {
        "username": {
            "S": "janedoe"
        }, 
        "first_name": {
            "S": "jane"
        }, 
        "last_name": {
            "S": "Doe"
        }
    }
}

次に『擬似的なAPI Gatewayを経由したLambdaファンクションのテスト』を実行していきます。 対象をAPI Gateway(local)とし、Payloadに以下を入力後、RUNを実行します。明示的に設定するPayloadが先ほどより少ないのは、 擬似的なAPI Gatewayによりeventが付与されるためです。

{
    "first_name":"jane",
    "last_name":"Doe2"
}

event.json

{
    "httpMethod": "PUT",
    "body": "{\n    \"first_name\":\"jane\",\n    \"last_name\":\"Doe2\"\n}",
    "resource": "/testpath",
    "requestContext": {
        "resourceId": "",
        "apiId": "",
        "resourcePath": "/testpath",
        "httpMethod": "",
        "requestId": "",
        "accountId": "",
        "identity": {
            "apiKey": "",
            "userArn": "",
            "cognitoAuthenticationType": "",
            "caller": "",
            "userAgent": "",
            "user": "",
            "cognitoIdentityPoolId": "",
            "cognitoIdentityId": "",
            "cognitoAuthenticationProvider": "",
            "sourceIp": "127.0.0.1:43002",
            "accountId": ""
        }
    },
    "queryStringParameters": {},
    "headers": {
        "Accept": "*/*",
        "Content-Length": "51",
        "Content-Type": "application/json",
        "User-Agent": "curl/7.53.1"
    },
    "pathParameters": {},
    "stageVariables": null,
    "path": "/testpath"
}

こちらの方もレスポンスとしてHTTPStatusCodeの200が返ってきていること、LocalStack内にリソースが格納されていることが確認できます。

$ aws --endpoint-url=http://localhost:4569 dynamodb  \
get-item --table-name users --key '{"username":{"S": "janedoe2" }}'   
{
    "Item": {
        "username": {
            "S": "janedoe2"
        }, 
        "first_name": {
            "S": "jane"
        }, 
        "last_name": {
            "S": "Doe2"
        }
    }
}

デプロイ、API Gatewayからの実行

ローカルでのテストが完了したので、次はAWS環境にデプロイしてテストをしていきます。まず、画面右側のメニュよりLambdaファンクションをデプロイします。

次に以下のコマンドを実行し、AWS環境にDynamoDBを作成します。endpointは指定しません。

aws dynamodb create-table  --table-name users \
--attribute-definitions AttributeName=username,AttributeType=S \
--key-schema AttributeName=username,KeyType=HASH \
--provisioned-throughput ReadCapacityUnits=5,WriteCapacityUnits=5

そして、デプロイされたLambdaに対する権限も変更します。今回はDynamoDBを操作するためLambdaに付与されているIAMロールにAmazonDynamoDBFullAccessのポリシーをアタッチしました。

ラストです。対象をAPI Gateway(remote)とし、Payloadに以下を入力後、RUNを実行します。

{
    "first_name": "jane",
    "last_name": "doe3"
}

DynamoDBにデータが格納されましたー!!大成功!!

まとめ

この辺りAWSとして整備されていくと嬉しいのですが、今のところは利用者側で頑張るしかありません。 また、今回はやりたいことは実現できたのですがベストプラクティスではないかなと思っているので他にもっといいやり方あるよ!っていう方がいたら是非連絡ください。
また、分岐のロジックに若干不安があるので、実際に本番利用するのであれば少なくともCloud9操作者のIAMロールはCloud9以外のリソースにアクセスできないよう制限しておいた方が無難かと思います。