Serverless Architectures on AWS ~CloudFront + Lambda@Edge + API Gateway~

こんにちは。katoです。

今回はAWS環境におけるサーバレスアーキテクチャの構築をご紹介させていただきます。

 

概要

今回構築するシステムは下記の様になります。

AWS環境におけるサーバレスアーキテクチャの構築

 

S3 + API Gateway + Lambdaを利用した一般的なサーバレスアーキテクチャの構成に、Basic認証を設定するためのCloudFrontとLambda@Edgeを組み込んだものになります。

今回作成するアプリケーションは、Cloud FormationのJSONテンプレートを簡易的に作成するツールとなります。
作成したJSONファイルはS3のバケットに保存される仕組みとなります。

 

手順

それでは、さっそく構築を行っていきましょう。

まずはじめに、S3バケットを作成します。
サイトページ用とJSONファイル保存用の2つのバケットを作成します。
適当な名前でバケットを作成していただいて構いません。
なお、静的ウェブサイトホスティングの有効化は不要です。

次にCloudFrontの設定を行い、作成したS3バケットに関連付けていきます。

CloudFrontサービスページからCreate Distributionを選択し、下記の内容にてDistributionを作成します。

 

[Origin Settings]
Origin Domain Name: 先程作成したS3バケットを指定
Restrict Bucket Access: Yes
Origin Access Identity: Create a New Identity
Grant Read Permissions on: Yes, Update Bucket Policy

[Default Cache Behavior Settings]
Viewer Protocol Policy: HTTP and HTTPS ※API Gatewayの前段にCloudFrontを配置する場合には「Redirect HTTP to HTTPS」の設定が必要になる場合があります
Allowed HTTP Methods: GET, HEAD ※API Gatewayの前段にCloudFrontを配置し、POSTメソッドを使用する場合には「GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE」を選択してください Object Caching: Customize
Minimum TTL: 0
Maximum TTL: 0
Default TTL: 0
 

[Distribution Settings]
Default Root Object: index.html

記載のない設定に関しましてはデフォルトのままで構いません。
なお、Lambda@Edgeの組み込みは後から行いますので、現時点では設定不要です。

 

次に、JSONテンプレートの作成を行うLambda関数の作成を行います。
今回はPython 3.6にてコードを記述しております。

 

#-*- coding: utf-8 -*-
import boto3
import json
from collections import OrderedDict
import os
import sys

def lambda_handler(event, context):
    jsondata = {}
    data = event['body']
    datas = data.split("&")
    for num in range(len(datas)):
        param = datas[num].split('=')
        jsondata[param[0]] = param[1]
    print(jsondata)
    
    template = OrderedDict()
    template
    ##共通
    template["AWSTemplateFormatVersion"] = "2010-09-09"
    template["Description"] = "AWS VPN Network"
    template["Resources"] = {}
    
    ##VPC
    template["Resources"]["VPC"] = {}
    template["Resources"]["VPC"]["Type"] = "AWS::EC2::VPC"
    template["Resources"]["VPC"]["Properties"] = {}
    template["Resources"]["VPC"]["Properties"]["CidrBlock"] = jsondata["vpccidr"] + "/16"
    template["Resources"]["VPC"]["Properties"]["Tags"] = []
    template["Resources"]["VPC"]["Properties"]["Tags"].append("")
    template["Resources"]["VPC"]["Properties"]["Tags"][0] = {"Key": "Name", "Value": jsondata["vpcname"]}
    
    ##IGW
    template["Resources"]["IGW"] = {}
    template["Resources"]["IGW"]["Type"] = "AWS::EC2::InternetGateway"
    template["Resources"]["IGW"]["Properties"] = {}
    template["Resources"]["IGW"]["Properties"]["Tags"] = []
    template["Resources"]["IGW"]["Properties"]["Tags"].append("")
    template["Resources"]["IGW"]["Properties"]["Tags"][0] = {"Key" : "Name", "Value" : "igw-create-by-cf"}
    
    template["Resources"]["AttachIGW"] = {}
    template["Resources"]["AttachIGW"]["Type"] = "AWS::EC2::VPCGatewayAttachment"
    template["Resources"]["AttachIGW"]["Properties"] = {}
    template["Resources"]["AttachIGW"]["Properties"]["VpcId"] = { "Ref" : "VPC" }
    template["Resources"]["AttachIGW"]["Properties"]["InternetGatewayId"] = { "Ref" : "IGW" }
    
    ##RouteTable
    template["Resources"]["PublicRouteTable"] = {}
    template["Resources"]["PublicRouteTable"]["Type"] = "AWS::EC2::RouteTable"
    template["Resources"]["PublicRouteTable"]["Properties"] = {}
    template["Resources"]["PublicRouteTable"]["Properties"]["VpcId"] = {"Ref" : "VPC"}
    template["Resources"]["PublicRouteTable"]["Properties"]["Tags"] = []
    template["Resources"]["PublicRouteTable"]["Properties"]["Tags"].append("")
    template["Resources"]["PublicRouteTable"]["Properties"]["Tags"][0] = {"Key" : "Name", "Value" : "Public-RouteTable" }
    
    template["Resources"]["PublicRoute"] = {}
    template["Resources"]["PublicRoute"]["Type"] = "AWS::EC2::Route"
    template["Resources"]["PublicRoute"]["Properties"] = {}
    template["Resources"]["PublicRoute"]["Properties"]["RouteTableId"] = { "Ref" : "PublicRouteTable" }
    template["Resources"]["PublicRoute"]["Properties"]["DestinationCidrBlock"] = "0.0.0.0/0"
    template["Resources"]["PublicRoute"]["Properties"]["GatewayId"] = { "Ref" : "IGW" }
    
    template["Resources"]["PrivateRouteTable"] = {}
    template["Resources"]["PrivateRouteTable"]["Type"] = "AWS::EC2::RouteTable"
    template["Resources"]["PrivateRouteTable"]["Properties"] = {}
    template["Resources"]["PrivateRouteTable"]["Properties"]["VpcId"] = {"Ref" : "VPC"}
    template["Resources"]["PrivateRouteTable"]["Properties"]["Tags"] = []
    template["Resources"]["PrivateRouteTable"]["Properties"]["Tags"].append("")
    template["Resources"]["PrivateRouteTable"]["Properties"]["Tags"][0] = {"Key" : "Name", "Value" : "Private-RouteTable" }
    
    ##PublicSubnet
    subcidr = jsondata["vpccidr"].split(".")
    for num in range(int(jsondata["pubnum"])):
        subcidr[2] = str(num)
        subcidr[3] = "0/24"
        pubsubcidr = '.'.join(subcidr)
        if num % 2 == 0:
            az = jsondata["region"] + "a"
        else:
            az = jsondata["region"] + "c"
        pubsub = "PublicSubnet" + str(num+1)
        template["Resources"][pubsub] = {}
        template["Resources"][pubsub]["Type"] = "AWS::EC2::Subnet"
        template["Resources"][pubsub]["Properties"] = {}
        template["Resources"][pubsub]["Properties"]["VpcId"] = { "Ref" : "VPC" }
        template["Resources"][pubsub]["Properties"]["CidrBlock"] = pubsubcidr
        template["Resources"][pubsub]["Properties"]["AvailabilityZone"] = az
        template["Resources"][pubsub]["Properties"]["Tags"] = []
        template["Resources"][pubsub]["Properties"]["Tags"].append("")
        template["Resources"][pubsub]["Properties"]["Tags"][0] = {"Key" : "Name", "Value" : pubsub }
        
        pubsubroute = "PublicSubnet" + str(num+1) + "RouteTable"
        template["Resources"][pubsubroute] = {}
        template["Resources"][pubsubroute]["Type"] = "AWS::EC2::SubnetRouteTableAssociation"
        template["Resources"][pubsubroute]["Properties"] = {}
        template["Resources"][pubsubroute]["Properties"]["SubnetId"] = { "Ref" : pubsub }
        template["Resources"][pubsubroute]["Properties"]["RouteTableId"] = { "Ref" : "PublicRouteTable" }
        
    ##PrivateSubnet
    for num2 in range(int(jsondata["prinum"])):
        subcidr[2] = str(int(jsondata["prinum"]) + num2)
        subcidr[3] = "0/24"
        prisubcidr = '.'.join(subcidr)
        prisub = "PrivateSubnet" + str(num2+1)
        if num2 % 2 == 0:
            az = jsondata["region"] + "a"
        else:
            az = jsondata["region"] + "c"
        prisub = "PrivateSubnet" + str(num2+1)
        template["Resources"][prisub] = {}
        template["Resources"][prisub]["Type"] = "AWS::EC2::Subnet"
        template["Resources"][prisub]["Properties"] = {}
        template["Resources"][prisub]["Properties"]["VpcId"] = { "Ref" : "VPC" }
        template["Resources"][prisub]["Properties"]["CidrBlock"] = prisubcidr
        template["Resources"][prisub]["Properties"]["AvailabilityZone"] = az
        template["Resources"][prisub]["Properties"]["Tags"] = []
        template["Resources"][prisub]["Properties"]["Tags"].append("")
        template["Resources"][prisub]["Properties"]["Tags"][0] = {"Key" : "Name", "Value" : prisub }
        
        prisubroute = "PrivateSubnet" + str(num2+1) + "RouteTable"
        template["Resources"][prisubroute] = {}
        template["Resources"][prisubroute]["Type"] = "AWS::EC2::SubnetRouteTableAssociation"
        template["Resources"][prisubroute]["Properties"] = {}
        template["Resources"][prisubroute]["Properties"]["SubnetId"] = { "Ref" : prisub }
        template["Resources"][prisubroute]["Properties"]["RouteTableId"] = { "Ref" : "PrivateRouteTable" }
    
    dict_data = dict(template.items())
    str_data = json.dumps(dict_data, indent=2)
    filename = "/tmp/" + str(jsondata["name"]) + ".json"
    s3file = str(jsondata["name"]) + ".json"
    f = open(filename, 'w')
    f.write(str_data)
    f.close()
    
    s3 = boto3.resource('s3')
    client = s3.Bucket('s3-bucket-name')
    with open(filename, 'rb') as f:
        response = client.put_object(
            Key=s3file,
            Body=f
        )
    
    os.remove(filename)
    
    return {'location': 'https://xxxxxxxxxxxxxx.cloudfront.net/'}

 

いろいろ書いていますが、行っていることは単純で下記の内容のみになります。

API Gatewayからのリクエスト受信と配列変換
・OrderedDictを利用したJSONテンプレートの作成
JSONのS3保存

最後に記載しているreturnは処理の実行後にリクエストページに戻るよう設定するためにCloudFrontのドメインURLを指定しています。

なお、上記コードに関しましては本ブログ用途で簡易的に作成したものとなりますので、入力値の判定等の処理は入れておりませんのでご注意ください。

Lambda関数の作成が完了したら、API Gatewayの設定を行います。

適当な名前でAPIを作成し、リソースの作成を行います。

リソースを作成したらPOSTメソッドを追加し、先程作成したLambda関数のARNを指定します。

 

Lambda関数のARNを指定

 

POSTメソッドの追加が完了したら、リクエストとレスポンスに関する設定を行っていきます。

統合リクエストを選択し、下記の本文マッピングテンプレートを追加します。

Content-Type: application/x-www-form-urlencoded

 

{
  "body": $util.urlDecode($input.json("$"))
}

 

マッピングテンプレートを追加

 

次にメソッドレスポンスを選択し、既存のレスポンスを削除、下記のレスポンス追加を行います。

ステータスコード: 302
ヘッダー: Location

 

レスポンス追加

 

次に統合レスポンスの設定を行います。
こちらもメソッドレスポンスと同様に既存の統合レスポンスを削除し、下記の統合レスポンスを追加します。

Lambda エラーの正規表現: .*
メソッドレスポンスのステータス: 302
コンテンツの処理: パススルー

作成した統合レスポンスのヘッダーのマッピングを選択し、下記マッピングの値を設定します。

integration.response.body.location

 

統合レスポンスのヘッダーのマッピング

 

上記設定が完了したら、APIのデプロイを行い、一度動作確認を行ってみたいと思います。

下記内容を記載したindex.htmlファイルをS3バケットにアップロードし、CloudFrontのドメインURLにアクセスしてみます。

 

<!DOCTYPE html>
<html lang="ja">
<meta charset="UFT-8">
<title>CloudFormation Template</title>

<body>
<form action="https://xxxxxx.execute-api.ap-northeast-1.amazonaws.com/xxxxx/xxxxxx" method="post">
<p>VPC CIDR<p><input type="text" name="vpccidr">/16</br>
<p>VPC NAME<p><input type="text" name="vpcname"></br>
<p>Region<p><input type="text" name="region"></br>
<p>Public Subnet num<p><input type="text" name="pubnum"></br>
<p>Private Subnet num<p><input type="text" name="prinum"></br>
<p>Output JSON File Name<p><input type="text" name="name">.json</br>
<br>
<input type="submit" value="post"></br>
</form>

</body>

</html>

 

正常に設定が行われている場合には以下の様な画面が表示されると思います。

 

Serverless Architectures on AWS 正常に設定

 

適当な内容を入力し、postをクリックしてみてサイトが再読み込みされれば正常です。
Lambda関数で指定したS3バケットJSONのデータが格納されているはずです。

では、最後にLambda@Edgeを組み込み、Basicn認証の設定を導入したいと思います。

ここで利用するコードは以下の内容を参考にさせていただきました。

 

https://gist.github.com/lmakarov/e5984ec16a76548ff2b278c06027f1a4

 

コード自体はそのままでも利用できるのですが、数点注意点があるのでご確認ください。

・ID/Passwordに関して
コード内部でのべた書きとなります(Lambda環境変数を試しましたが対応していないとのエラーでした)。

タイムアウト値に関して
デフォルトの3秒でOK。
反対に1分とか5分を指定するとCloudFront導入時に怒られるので注意。

 

・作成リージョンに関して
CloudFrontに組み込む形となるので、Lambda関数はus-east-1(バージニア北部)に作成する必要があります。

上記のLambda関数を作成したら「アクション」から「新しいバージョンを発行」を選択し、CloudFrontに組み込めるよう設定します。

CloudFrontサービスページにアクセスし、作成済みのDistributionを選択します。
Behaviorsタブを選択し、既存の設定を編集します。

一番下のLmabdaの設定箇所に下記内容を指定します。

Event Type: Viewer Request
Lambda Function ARN: 先程作成したLambda@EdgeのARN(arn:aws:lambda:us-east-1:123456789123:function:xxxxxxxxxxx:1)

 

Lmabdaの設定箇所

 

以上でBasic認証の組み込みも完了し、すべての作業が完了となります。

最後にBasic認証の動作確認をしてみます。

先程と同様にCloudFrontのドメインURLにアクセスしてみると認証情報を聞かれます。

 

CloudFrontのドメインURLにアクセス

 

ここでlamdba@Edgeのコードに記載したユーザ名とパスワードを指定することで、S3のコンテンツにアクセスできるようになります。

 

おまけ(API Gatewayのリソースポリシー)

今回ご紹介したlambda@Edgeを利用したBasic認証の仕組みは、CloudFront経由のアクセスに認証をかける形となります。
API GatewayのデプロイURLを直接指定されると、バックエンドのLambdaにリクエストが届いてしまいます。

上記のような事象を防ぐために、API Gatewayへのアクセスを制限する必要があります。

今回は、API Gatewayのリソースポリシーを利用したIP制限をBasic認証と合わせて導入しております。

 

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": "*",
            "Action": "execute-api:Invoke",
            "Resource": "arn:aws:execute-api:ap-northeast-1:123456789123:xxxxxxxx/*",
            "Condition": {
                "IpAddress": {
                    "aws:SourceIp": "xxx.xxx.xxx.xxx/32"
                }
            }
        }
    ]
}

なお、API Gatewayへのアクセスを前段のCloudFrontまたはS3バケットからのみに制限する形を本当は取りたかったのですが、思ったような動作が得られず断念しました。

 

まとめ

今回はAWS環境におけるサーバレスアーキテクチャの構築に関してご説明しました。

単純なアプリケーションなどであれば利用コストを削減することも可能で、運用やバックアップなどのタスクも削減できます。

サーバレス化の向き不向きもありますがメリットが大きいので、AWSでのシステム構築時にはサーバレスアーキテクチャもお考えになってみてはいかがでしょうか。

 

 

 

AWS相談会