こんにちは、エンジニアインターンの佐藤です。
今回は流行りのサーバーレスの代表例であるSPAをAWSで構築しました。AWSのサーバーレスを構築するとなると、S3, DynamoDB, Lambdaなどが主に使用されます。ではこれらのツールをローカル開発環境で使用するにはどのようにすればいいのでしょうか。
この記事では、Dockerを使用したローカル開発環境構築でつまづいた点を踏まえて説明していこうと思います。
AWS上の構成は以下のようになっています。
AWSでSPAを構成する一般的なアーキテクチャになっています。
S3で静的ホスティングをして、APIはLambda, データベースはDynamoDBを使用し、パスごとに使用するAPIを変更できるようにAPI Gatewayを使用しています。
今回はDockerを使用して開発環境を作成していきます。使用したツールは以下の通りです。
フロントエンド
・Vue.js
・webpack
バックエンド
・DynamoDB Local
・SAM(API Gateway + Lambda)
それではそれぞれのツールについて説明していきます。
S3にホスティングするフロントエンドはVue.jsでコーディングしました。VueはJavaScriptのフレームワークです。
Vue-routerを使用することで、単純な画面遷移を使ってSPAを構築することができます。
webpackはJavaScriptなどの複数モジュールをバンドルすることができるツールです。
バンドルすることで読み込むスクリプトの回数が減り、Webページの速度を上げることができます。
また、webpackはrequire()などのモジュールを参照する関数を自動で検出し、結合対象にするファイルを自動的に、そして適切な順序で追加することが可能です。
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はServerless Application Modelの略称でAWSが作成したオープンソースフレームワークです。
API GatewayやLambdaなどのAWSのサービスをローカル環境で構築することができます。
また、YAMLファイルを使用してサービスを指定することができ、コードベースで管理することが可能です。そして手軽にローカルでテストを行うことができるのも特徴です。
また、SAMと同様にlambdaとAPI Gatewayを作成するツールとしてserverless frameworkというものがあります。
serverless frameworkの特徴は様々なプラグインが備わっており、AWSだけではなくGCPなど他のクラウドサービスを使った構築が可能です。
またSAMとは異なりnpmでインストールすることができ、Docker in Dockerにならずに環境を構築することができます。
今回はSAMを採用することにしました。
理由としては、まず今回使うクラウドサービスはAWSのみであるからです。AWSのみであるならAWS公式の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の詳細な設定はドキュメントから参照してみてください。
まずは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
しかしここで問題が発生します。
Lambdaをどうやってコンテナの中に組み込めばいいのでしょうか?
SAMはAPI Gatewayに指定したパスにアクセスするとLambdaのコンテナを自動で立ち上げます。もしDockerの中にSAMコマンドをインストールして、SAMを立ち上げると冒頭で説明したように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のコンテナの関係が下図のようになっていると思っていました。
上図のようにdevコンテナから、ネットワークに繋がっていないSAMのAPI Gatewayのドメイン名やポート番号はわかりません。
そこでSAMには既に存在しているDocker networkに接続するオプションである--docker-networkオプションを使用することを考えました。
私の考えていた図からSAMにdocker-composeのネットワークを接続すると下図のようになります。
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に繋がっていない最初の図を書き換えると以下のようになっていることになります。
ではdevコンテナからホストマシンにアクセスしてAPI Gatewayにアクセスする場合どうすればいいのでしょうか?
そこで今回はDockerの仕組みを利用して解決させました。
host.docker.internalはdocker for Mac, docker for Windowsでサポートされている特殊なドメイン名です。
用途としてはコンテナからホストのネットワークにアクセスする際に使用します。
そのため、指定するポート番号はコンテナ内部のものではなく、ホストマシンのポート番号を使用します。
これによってコンテナ内からlocalhostを経由してネットワークに繋がってないコンテナにもアクセスすることができます。
docker-composeで管理されたコンテナ同士で通信をする場合はサービス名をドメイン名に使用し、管理されたコンテナからLambdaコンテナ、またはその逆にアクセスするときは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を使ってくれるので非常に便利だったので今後も活用していきたいと思います。
※2021年6月1日時点