AWS SAM + DockerでAWSサーバーレス環境をローカル構築する

AWS SAM + Docker

こんにちは、エンジニアインターンの佐藤です。

今回は流行りのサーバーレスの代表例であるSPAをAWSで構築しました。
AWSのサーバーレスを構築するとなると、S3, DynamoDB, Lambdaなどが主に使用されます。ではこれらのツールをローカル開発環境で使用するにはどのようにすればいいのでしょうか。
この記事では、Dockerを使用したローカル開発環境構築でつまづいた点を踏まえて説明していこうと思います。

  1. 全体の構成
  2. 開発環境
    1. Vue.js
    2. webpack
    3. DynamoDB Local
    4. SAM
  3. SAMを使ってみる
    1. Lambda + API Gatewayの立ち上げ
  4. SAMの問題
    1. 名前解決できない
    2. host.docker.internal
  5. まとめ

全体の構成

AWS上の構成は以下のようになっています。
AWSでSPAを構成する一般的なアーキテクチャになっています。
S3で静的ホスティングをして、APIはLambda, データベースはDynamoDBを使用し、パスごとに使用するAPIを変更できるようにAPI Gatewayを使用しています。

owletの本番アーキテクチャ

開発環境

今回はDockerを使用して開発環境を作成していきます。
使用したツールは以下の通りです。

フロントエンド

    ・Vue.js

    ・webpack

バックエンド

    ・DynamoDB Local

    ・SAM(API Gateway + Lambda)

それではそれぞれのツールについて説明していきます。

Vue.js

vue_logo

S3にホスティングするフロントエンドはVue.jsでコーディングしました。 
VueはJavaScriptのフレームワークです。
Vue-routerを使用することで、単純な画面遷移を使ってSPAを構築することができます。

webpack

webpack_logo

webpackはJavaScriptなどの複数モジュールをバンドルすることができるツールです。
バンドルすることで読み込むスクリプトの回数が減り、Webページの速度を上げることができます。
また、webpackはrequire()などのモジュールを参照する関数を自動で検出し、結合対象にするファイルを自動的に、そして適切な順序で追加することが可能です。

DynamoDB Local

dynamodb_logo

DynamoDB LocalはAWSが出しているDynamoDBのDockerイメージです。

Dockerで実行するならこのようになります。

docker run -p 8000:8000 -v ./db:/db amazon/dynamodb-local -jar DynamoDBLocal.jar -dbPath /db -sharedDb 


注目して欲しいのはcommandの部分です。

command: -jar DynamoDBLocal.jar -dbPath /db -sharedDb 

DynamoDB LocalはAWSで使用されるAWS Linux 2で動いており、ワーキングディレクトリにあるDynamoDBLocal.jarを実行することでDBが起動します。
デフォルトだとinMemoryオプションがTrueになっており、データがメモリ上で保存されるため、コンテナを停止するとデータが消えてしまいます。そのためdbPathオプションでデータベースファイルを書き込むディレクトリを指定することで防ぐことができます。
また、sharedDBオプションを使用することでregionと認証情報ごとにデータベースファイルに分けずに1つのデータベースファイルで管理することが可能です。
加えて、optimizeDbBeforeStartupオプションも存在します。このオプションはDynamo DB Localを起動するたびに、データベーステーブルを最適化してくれるものです。
ですが、今回Dockerで構築した際にoptimizeDbBeforeStartupオプションを指定してDynamoDB Localを再起動した後、データを追加するとデータが重複するバグが発生しました。原因について言及された記事は存在しないものの、今回のDocker構築の際はこのオプションを避けることをお勧めします。

SAM

SAM_ICON

SAMはServerless Application Modelの略称でAWSが作成したオープンソースフレームワークです。
API GatewayやLambdaなどのAWSのサービスをローカル環境で構築することができます。
また、YAMLファイルを使用してサービスを指定することができ、コードベースで管理することが可能です。そして手軽にローカルでテストを行うことができるのも特徴です。


また、SAMと同様にlambdaとAPI Gatewayを作成するツールとしてserverless frameworkというものがあります。

serverless_icon

serverless frameworkの特徴は様々なプラグインが備わっており、AWSだけではなくGCPなど他のクラウドサービスを使った構築が可能です。
またSAMとは異なりnpmでインストールすることができ、Docker in Dockerにならずに環境を構築することができます。


今回はSAMを採用することにしました。
理由としては、まず今回使うクラウドサービスはAWSのみであるからです。AWSのみであるならAWS公式のSAMを使う方が信頼性が高いと判断しました。そして、参考記事もSAMの方が多く、そして新しい記事が多かったためSAMの方が開発を進めやすいと判断し、採用しました。

SAMを使ってみる

早速SAMを使ってみましょう。

まずはSAMをインストールします。

brew tap aws/tap
brew install aws-sam-cli


インストールできたら以下のコマンドを使ってサンプルプロジェクトを作成します。

sam init


カスタムで1から作成することもできますが、まずは動くものから試してみましょう。デプロイする際にはZipを使用し、Lambdaはnode.jsで動かすようにしています。

Which template source would you like to use?
	1 - AWS Quick Start Templates
	2 - Custom Template Location
Choice: 1
What package type would you like to use?
	1 - Zip (artifact is a zip uploaded to S3)
	2 - Image (artifact is an image uploaded to an ECR image repository)
Package type: 1

Which runtime would you like to use?
	1 - nodejs14.x
	2 - python3.8
	3 - ruby2.7
	4 - go1.x
	5 - java11
	6 - dotnetcore3.1
	7 - nodejs12.x
	8 - nodejs10.x
	9 - python3.7
	10 - python3.6
	11 - python2.7
	12 - ruby2.5
	13 - java8.al2
	14 - java8
	15 - dotnetcore2.1
Runtime: 1

Project name [sam-app]: owl-project

Cloning app templates from https://github.com/aws/aws-sam-cli-app-templates

AWS quick start application templates:
	1 - Hello World Example
	2 - Step Functions Sample App (Stock Trader)
	3 - Quick Start: From Scratch
	4 - Quick Start: Scheduled Events
	5 - Quick Start: S3
	6 - Quick Start: SNS
	7 - Quick Start: SQS
	8 - Quick Start: Web Backend
Template selection: 1

    -----------------------
    Generating application:
    -----------------------
    Name: owl-project
    Runtime: nodejs14.x
    Dependency Manager: npm
    Application Template: hello-world
    Output Directory: .

    Next steps can be found in the README file at ./owl-project/README.md


これでProject name(今回はowl-project)のディレクトリが作成されました。
ディレクトリの中身を確認してみましょう。

owl-project
├── README.md
├── events
│   └── event.json
├── hello-world
│   ├── app.js
│   ├── package.json
│   └── tests
│       └── unit
│           └── test-handler.js
└── template.yaml


ここで最重要なのはtemplate.yamlです。
このyamlファイルでAPI GatewayやLambdaの設定をResourcesとして記述し、管理しています。サンプルプロジェクトのtemplate.yamlは以下のようになっています。

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  owl-project

  Sample SAM Template for owl-project
  
# More info about Globals: https://github.com/awslabs/serverless-application-model/blob/master/docs/globals.rst
Globals:
  Function:
    Timeout: 3

Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function # More info about Function Resource: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#awsserverlessfunction
    Properties:
      CodeUri: hello-world/
      Handler: app.lambdaHandler
      Runtime: nodejs14.x
      Events:
        HelloWorld:
          Type: Api # More info about API Event Source: https://github.com/awslabs/serverless-application-model/blob/master/versions/2016-10-31.md#api
          Properties:
            Path: /hello
            Method: get

Outputs:
  # ServerlessRestApi is an implicit API created out of Events key under Serverless::Function
  # Find out more about other implicit resources you can reference within SAM
  # https://github.com/awslabs/serverless-application-model/blob/master/docs/internals/generated_resources.rst#api
  HelloWorldApi:
    Description: "API Gateway endpoint URL for Prod stage for Hello World function"
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/hello/"
  HelloWorldFunction:
    Description: "Hello World Lambda Function ARN"
    Value: !GetAtt HelloWorldFunction.Arn
  HelloWorldFunctionIamRole:
    Description: "Implicit IAM Role created for Hello World function"
    Value: !GetAtt HelloWorldFunctionRole.Arn


Globalsで全てのResorceに同一の設定を与えることができます。
例えばFunctionの場合、ResourcesのTypeがAWS::Serverless::Functionだと適応されます。

ResoucesにそれぞれのlambdaやAPI Gatewayについての詳細な設定を記述します。
また、Eventsの部分でLambdaのAPI Gatewayにおけるパスの設定を記述することができます。今回の場合は/helloパスにアクセスするとGETメソッドでHelloWorldFunctionが起動します。

template.yamlで記述できるResourceの詳細な設定はドキュメント から参照してみてください。

Lambda + API Gatewayの立ち上げ

まずはtemplate.yamlに記述した情報に基づいてbuildします。user-containerオプションを使用することでSAMがbuild用のイメージをプルしてコンテナ内でbuildを行うためホストマシンにnode.jsがなくてもbuildが可能です。
次にlocal start-apiコマンドを使用することでAPI Gatewayが立ち上がります。

sam build --use-container -t owl-project/template.yaml
sam local start-api -p 1111 -t owl-project/template.yaml


すると以下のログが出てきます。

Mounting HelloWorldFunction at http://127.0.0.1:1111/hello [GET]
You can now browse to the above endpoints to invoke your functions. You do not need to restart/reload SAM CLI while working on your functions, changes will be reflected instantly/automatically. You only need to restart SAM CLI if you update your AWS SAM template
2021-05-18 17:25:23  * Running on http://127.0.0.1:1111/ (Press CTRL+C to quit)


ログにも書いてある通り、HelloWorldFunctionを実行するファイル(今回だとhello-world/app.js)をコンテナにマウントしてくれるため、コンテナ立ち上げ中はtemplate.yamlを変更しない限りapp.jsの変更を自動で反映してくれます。

それでは、http://127.0.0.1:1111/helloにアクセスしてみましょう。
アクセスするとSAMがLambdaを実行するコンテナを立ち上げます。

Invoking app.lambdaHandler (nodejs14.x)
Skip pulling image and use local one: amazon/aws-sam-cli-emulation-image-nodejs14.x:rapid-1.22.0.

Mounting /Users/[パスなので省略]/owl-project/hello-world as /var/task:ro,delegated inside runtime container
START RequestId: 124a6a49-eee5-41ad-a91b-afbfa58e0ada Version: $LATEST
END RequestId: 124a6a49-eee5-41ad-a91b-afbfa58e0ada
REPORT RequestId: 124a6a49-eee5-41ad-a91b-afbfa58e0ada	Init Duration: 0.82 ms	Duration: 169.29 ms	Billed Duration: 200 ms	Memory Size: 128 MB	Max Memory Used: 128 MB
No Content-Type given. Defaulting to 'application/json'.
2021-05-18 17:28:46 127.0.0.1 - - [18/May/2021 17:28:46] "GET /hello HTTP/1.1" 200 -
2021-05-18 17:28:46 127.0.0.1 - - [18/May/2021 17:28:46] "GET /favicon.ico HTTP/1.1" 403 -
$ curl http://localhost:1111/hello
{"message":"hello world"}


確かにローカルでAPI GatewayとLambdaを再現することができました。
では今からこれをデータベースであるDynamoDBとS3にホスティングするコンテナに接続してローカルでSPAを再現していきたいと思います。

また、Vue.jsとDynamoDBのコンテナをdocker-composeでまとめると以下のようになります。

version: "3.7"

services:
    dev:
        image: owl-dev:latest
        build:
            context: .
            dockerfile: dev.Dockerfile
        container_name: owl-dev
        ports:
            - "30001:30001"
        volumes:
            - owl-node_modules:/app/node_modules
            - .:/app/
        stdin_open: true
        tty: true
        depends_on:
          - db
        networks:
          - owl-project-network
        env_file:
            - .env.dev

    db:
      image: amazon/dynamodb-local
      container_name: owl-db
      ports:
        - 30002:8000
      command: -jar DynamoDBLocal.jar -dbPath /db -sharedDb
      volumes:
        - ./db:/db
      networks: 
        - owl-project-network
      
volumes:
 owl-node_modules:

networks: 
  owl-project-network:
    driver: bridge

SAMの問題

しかしここで問題が発生します。
Lambdaをどうやってコンテナの中に組み込めばいいのでしょうか?
SAMはAPI Gatewayに指定したパスにアクセスするとLambdaのコンテナを自動で立ち上げます。もしDockerの中にSAMコマンドをインストールして、SAMを立ち上げると冒頭で説明したようにDocker in Dockerの複雑な形になってしまいます。

docker in docker

では、Docker in Dockerの形にせず私たちが管理できるコンテナはdocker-compose.yamlに記載したものだけにして、別のプロセスとしてSAMのコンテナを立ち上げるとしましょう。
しかしこれでも問題が発生します。

名前解決できない

docker-compose.yamlに記述されたコンテナはIPアドレスだけでなく、サービス名コンテナ名でも名前解決することができ、共有されたネットワーク上でアクセスすることが可能です。
例えば、devコンテナからdbコンテナにアクセスしたい場合は以下のように、
http://<Service名>:<コンテナ内部のポート番号>すればアクセスすることができます。

$ docker exec -it dev bash 

root@efa76c26d5a6:/app# curl http://db:8000
{"__type":"com.amazonaws.dynamodb.v20120810#MissingAuthenticationToken","Message":"Request must contain either a valid (registered) AWS access key ID or X.509 certificate."}


最初私はSAMとdocker-composeのコンテナの関係が下図のようになっていると思っていました。

sam dockerネットワークなし


上図のようにdevコンテナから、ネットワークに繋がっていないSAMのAPI Gatewayのドメイン名やポート番号はわかりません。
そこでSAMには既に存在しているDocker networkに接続するオプションである--docker-networkオプションを使用することを考えました。
私の考えていた図からSAMにdocker-composeのネットワークを接続すると下図のようになります。

sam ネットワーク接続後

SAMのAPI Gateway側からみるとDocker network内のdocker-composeで管理されたコンテナのドメイン名はわかりますが、docker-composeで管理された2つのコンテナからはAPI Gatewayのドメイン名はわかりません。また、接続するためにはポート番号も必要であるためDocker内部でのAPI Gatwayのポート番号も知る必要があります。

調査のためにSAMが立ち上げるAPI Gatewayについて知る必要あります。
まずはdev, dbコンテナとSAMを立ち上げた状態でdocker psで現在動いているdockerプロセスを確認します。

$ docker-compose up -d dev

$ sam local start-api -p 1111 -t owl-project/template.yaml --docker-network owl-project-network
$ docker ps


SAMを立ち上げるとlocalhost:1111でAPI Gatewayが立ち上がるはずなのですがdocker psコマンドからは何も出力されませんでした。

また、docker inspectコマンドでSAMに接続したDocker networkを確認してもAPI Gatewayらしきものを見つけることはできませんでした。

$ docker network inspect owlet_project_network
[
...
        "Containers": {
            "0b4c638126a36b1468ea9890e27acce69cb184d01be369013864573dc41f5bce": {
                "Name": "owl-dev",
                "EndpointID": "30facbebcfd34d4c9a2ff9064b7f6232ae73f0719f3b5f99e52196c4c2037463",
                "MacAddress": "02:42:ac:18:00:03",
                "IPv4Address": "172.24.0.3/16",
                "IPv6Address": ""
            },
            "1cbda503a651da1a65f252a0c445b5e62495e317fd9526a05bc57818fea69fd4": {
                "Name": "owl-db",
                "EndpointID": "850b6570567f9ed5a1492ab0c1c9620036350ed55e2fa45409b48e1232356f03",
                "MacAddress": "02:42:ac:18:00:02",
                "IPv4Address": "172.24.0.2/16",
                "IPv6Address": ""
            }
        },
...
]


そこで、sam local start-apiコマンドのオプションをよく確認してみると--warm-containersオプションというものがありました。
このオプションはAPI GatewayのパスにアクセスせずともLambdaコンテナを常時立ち上がらせることができるものです。一旦このオプションを指定してSAMが立ち上げるコンテナを調べてみましょう。

$ sam local start-api -p 1111 -t owl-project/template.yaml --warm-containers EAGER --docker-network owl-project-network

Initializing the lambda functions containers.
Skip pulling image and use local one: amazon/aws-sam-cli-emulation-image-nodejs14.x:rapid-1.22.0.

Mounting /Users/[パスなので省略]/.aws-sam/build/HelloWorldFunction as /var/task:ro,delegated inside runtime container
Containers Initialization is done.
Mounting HelloWorldFunction at http://127.0.0.1:1111/hello [GET]
You can now browse to the above endpoints to invoke your functions. You do not need to restart/reload SAM CLI while working on your functions, changes will be reflected instantly/automatically. You only need to restart SAM CLI if you update your AWS SAM template
2021-05-22 17:27:33  * Running on http://127.0.0.1:1111/ (Press CTRL+C to quit)


先ほどと同様にdocker psとdocker inspectで調べてみます。

$ docker ps

CONTAINER ID        IMAGE                                                        COMMAND                  CREATED             STATUS              PORTS                      NAMES
c80fc40bcdad        amazon/aws-sam-cli-emulation-image-nodejs14.x:rapid-1.22.0   "/var/rapid/aws-lamb…"   About an hour ago   Up About an hour    127.0.0.1:6018->8080/tcp   sad_liskov
$ docker network inspect owlet_project_network
[
    ...
        "Containers": {
            "035a63db8f7347f46177a432f0e55886a1f9e717bcf584c1c900f29a83055adc": {
                "Name": "owl-dev",
                "EndpointID": "c266e8e599931c22b4d427ceed9ae4b0c6de940504bbdbf5b2f4b31b330f162f",
                "MacAddress": "02:42:ac:1b:00:03",
                "IPv4Address": "172.27.0.3/16",
                "IPv6Address": ""
            },
            "0af6416560072149f32fc8c72d2eac9933210c34d3edd67bc9915392899317d9": {
                "Name": "sad_liskov",
                "EndpointID": "ca7e87b3653a275fbbe3b94d46880a97ebae2a0c318df0f5bac85b5150a711b0",
                "MacAddress": "02:42:ac:1b:00:04",
                "IPv4Address": "172.27.0.4/16",
                "IPv6Address": ""
            },
            "122706834ddb907253f2707c06cc99e25c22d8250bac6022cad9445ae5ae45a2": {
                "Name": "owl-db",
                "EndpointID": "682b6eae7a2d48bac7c66a91fb41b8681af03b519591221004377370fc27b85b",
                "MacAddress": "02:42:ac:1b:00:02",
                "IPv4Address": "172.27.0.2/16",
                "IPv6Address": ""
            }
        },
        ...
]


先ほどなかったはずのコンテナがSAMに接続したDocker network上に追加されていました!
つまり、sam local start-apiで指定したDocker networkに接続しているのはSAMが立ち上げたAPI Gatewayではなく、API Gatewayで設定したパスに対応したLambdaのコンテナだけでした。
また、このLambdaコンテナは立ち上がるたびにコンテナ名、IPアドレス、ホストマシン上のポート番号は変化しました。そのためAPI Gateway内部でこれらの情報を取得し、特定のパスにアクセスがあればそのパスに対応したLambdaコンテナを立ち上げてアクセスするシステムがあると考えられます。

調査で分かったことをまとめると以下のようになります。

  • ・Dockerネットワークオプションを指定した時に接続されるのはAPI GatewayではなくLambda

    ・SAMで立ち上げたlambdaコンテナはポート番号, IP Addressが動的に生成される


では、API Gatewayはどこにあるのでしょうか?
仮説にはなりますが、API GatewayはDockerではなく、aws-sam-cliそのものです。なので先ほどdocker psコマンドでコンテナ一覧を見てもAPI Gatewayのコンテナは存在しませんでした。aws-sam-cliそのものであるならAPI Gatewayの場所はホストのネットワーク上です。
以上を踏まえて、Docker networkに繋がっていない最初の図を書き換えると以下のようになっていることになります。

改善したSAMのネットワーク図

ではdevコンテナからホストマシンにアクセスしてAPI Gatewayにアクセスする場合どうすればいいのでしょうか?
そこで今回はDockerの仕組みを利用して解決させました。

host.docker.internal

host.docker.internalはdocker for Mac, docker for Windowsでサポートされている特殊なドメイン名です。
用途としてはコンテナからホストのネットワークにアクセスする際に使用します。
そのため、指定するポート番号はコンテナ内部のものではなく、ホストマシンのポート番号を使用します。
これによってコンテナ内からlocalhostを経由してネットワークに繋がってないコンテナにもアクセスすることができます。

docker-composeで管理されたコンテナ同士で通信をする場合はサービス名をドメイン名に使用し、管理されたコンテナからLambdaコンテナ、またはその逆にアクセスするときはhost.docker.internalをドメイン名にすることでコンテナ間通信が可能となります。
host.docker.internalを使用するとネットワークは以下の図のようになります。

host.docker.internalを使った図

それでは実際にこの特殊なドメイン名を指定してdevコンテナからlocalhostにあるSAMのAPI Gatewayにアクセスしてみましょう。

まずは、SAMでAPI Gatewayを立ち上げます。この時先ほど指定した--docker-networkオプションは不要です。

$ sam local start-api -p 1111 -t owl-project/template.yaml


そしてコンテナ内部に入ってAPI Gatewayで設定したLambdaが実行されるパスにアクセスしてみます。

$ docker exec -it dev bash
>> curl http://host.docker.internal:1111/hello
{"message":"hello world"}

コンテナ内部からlocalhostにあるAPI Gatewayを経由してLambdaコンテナの実行結果を得ることができました!

まとめ

Dockerを使ってSPAを構築するためのAWS環境をローカルで再現することができました。またSAMを使うことでローカルの環境構築だけではなくデプロイを実行すれば自動でCloud Formationを使ってくれるので非常に便利だったので今後も活用していきたいと思います。

Techブログ 新着記事一覧