こんにちは、エンジニアインターンの佐藤です。
前回の記事に引き続きAWSを使ったサーバーレス環境の構築記事です。今回はS3のホスティングについて焦点を当てた記事となっています。
S3の静的ホスティングでHTTPSやカスタムドメインを設定する際はRoute53とCloudFrontを組み合わせることが多いですが、今回はCloudFrontを使用せずにHTTPS化してみました。
※この方法は特殊なので、一般的なプロジェクトではCloudFrontを使用することをお勧めします。(理由は後述します)
S3はストレージサービスとして有名ですが、バケットに入れたファイルをホスティングする機能も備わっています。S3の静的ホスティングのやり方は多くの記事があるため、今回は割愛させていただきます。
静的ホスティングでは以下の設定画面でインデックスドキュメントとエラードキュメントを設定することができます。この設定すると、どんな場合でもindex.htmlを返すような設定にすることができます。(S3のバケットにあるオブジェクは除く)
S3で静的ホスティングした時、ホスティング先のURLは以下のようになっていると思います。
http://<bucket_name>.s3-website-<region_name>.amazonaws.com
どんな場合でもindex.htmlを返すというのは
http://<bucket_name>.s3-website-<region_name>.amazonaws.com/
http://<bucket_name>.s3-website-<region_name>.amazonaws.com/user
上2つのどちらの場合も同じindex.htmlを返すということです。(userというファイルはS3にないと仮定します)
ここで、実際にこのS3のホスティングを運用していく上で、気になる点が2点あります。
まず1つ目です。今回はSPAを構築しており、フロントのホスティングにS3、バックエンドのlambdaのパスはAPI Gatewayが担当しています。システムの運用を考えるとこの2つのドメインは同じにしておきたいです。そしてカスタムドメインを設定する場合、どちらの場合もRoute53を使用することで解決できます。
そして、2つ目です。これについては公式ドキュメント にも記述してあるのですが、S3のウェブサイトエンドポイントではSSL接続をサポートしていません。そのためS3の静的ホスティングをHTTPSにする場合はCloudFrontが推奨されています。
ですが、API Gatewayのプロキシを取り入れることで、CloudFrontなしで上の2点の問題を解消することができます。
プロキシは日本語で「代理人」を意味し、文字通りサーバーの代理を行ってくれます。そして大きく分けてフォワードプロキシとリバースプロキシの2種類存在します。
フォワードプロキシは、クライアントのネットワークに置かれます。この場合、プロキシサーバーはクライアントの代理をしていると言えます。なぜならwebサーバーから見ると実際にやりとりしているのはクライアントではなく、間にあるプロキシサーバーに見えるためです。
フォワードプロキシの場合、プロキシサーバーにアクセスできるのはプロキシサーバーのIPアドレスとポートを知っているクライアントだけです。
例えばプロキシサーバーは企業のネットワークで使用されることがあります。
Webサーバーから見るとプロキシがやりとりしているように見えるため、その後ろにあるクライアントの情報を隠すことができます。
また、クライアントはプロキシを経由してサイトにアクセスするため、プロキシサーバーのログからクライアントのアクセスログを確認したり、プロキシサーバーがサイトの制限をかけることで、クライアントにアクセス制限をすることもできます。また、プロキシのキャッシュ機能を用いればサイトの表示を高速化することができます。
リバースプロキシは、webサーバーのネットワークに置かれます。この場合、リバースプロキシサーバーはWebサーバーの代理をしていると言えます。これは先ほどと同様にクライアントはWebサーバーにアクセスしても、実際にやりとりしているのはWebサーバーではなく、リバースプロキシサーバーだからです。
リバースプロキシの場合、プロキシサーバーにアクセスしてくるのは不特定多数です。
用途としては、複数台で構成されたWebサーバーへの負荷分散です。
複数あるWebサーバーをリバースプロキシが振り分けることで、集中アクセスなどでサーバーがダウンするのを防ぐことができます。
API Gatewayもプロキシの機能を使用することができます。API Gateway自身がプロキシとなって他のサービスの代理を行うためリバースプロキシとして機能します。
まずAWSインタフェースからの作成方法を見てみます。
アクションからリソースの作成を押すと下図の画面が表示されます。「プロキシリソースとして設定する」のチェックボックスにチェックすると{proxy+}が自動で入力されます。
そして、「統合タイプ」にHTTPプロキシを選択します。エンドポイントURLには以下の形で入力します。今回はS3のindex.htmlをエンドポイントにしたいので、index.htmlのS3オブジェクトURLを指定します。S3_bucket_nameはホスティングしたいS3バケットを、region_nameはS3のリージョンを入力してください。
https://s3.<region_name>.amazonaws.com/<S3_bucket_name>/{proxy}
完了すると下図の画面になります。
そしてこの設定でデプロイします。「アクション」から「APIのデプロイ」を選択してデプロイできます。API Gatewayをデプロイする際はステージが必須なので、ステージを選択または作成してデプロイします。
デプロイが完了するとステージエディターに移動します。表示されているURLからアクセス可能です。
しかし、上の方法はデフォルトのAPI Gatewayの設定ではうまくいきません。例えばS3に以下のVueのHello Worldをビルドしたファイルが入っているとします。
/dist
├── css
│ └── app.fb0c6e1c.css
├── favicon.ico
├── img
│ └── logo.82b9c7a5.png
├── index.html
└── js
├── app.2b3f1705.js
├── app.2b3f1705.js.map
├── chunk-vendors.c5cf1606.js
└── chunk-vendors.c5cf1606.js.map
この時、API GatewayからS3にアクセスできるはずです。以下のようなデフォルトのAPI GatewayのURLにデプロイしたステージ名とアクセスしたいファイル名を含めたURLでアクセスしてみます。
https://xxxxx.execute-api.yyyyy.amazonaws.com/dev/index.html
しかし、おそらくアクセスしても何も表示されないと思います。なぜならデベロッパーツールのコンソールを確認してみると下図のようになっているはずだからです。
これはローカルでビルドした際にAPI Gatewayのステージがパスに含まれていないのが原因です。そのため例えば以下のURLならば、
https://xxxxx.execute-api.yyyyy.amazonaws.com/favicon.ico
以下のようにパスにステージ名を埋め込む必要があります。
https://xxxxx.execute-api.yyyyy.amazonaws.com/dev/favicon.ico
この時、URLからステージ名をなくす解決策が2つあります。
1つ目ですが、index.htmlに埋め込まれたパスをAPI Gatewayが読み込むパスに合わせる方法です。手取り早くできますが、ステージ名のディレクトリを作成する必要があるので不気味なディレクトリ構造になってしまいます。
2つ目ですが、Route53でドメインを取得してからAPIのカスタムドメインを使用する方法です。この記事ではRoute53でのドメイン取得方法とACMでのSSL証明書取得方法は割愛しますが、API Gatewayのカスタムドメイン設定のAPIマッピングでAPIとステージ名を紐づけることができます。URLにステージの概念がなくなるため、先程のエラーが起きずにHello Worldを表示することができます。そしてカスタムドメインにSSL証明書が対応しているのでHTTPSにすることができます。
この時index.htmlにアクセスする場合は以下のようなURLになります。
https://<カスタムドメイン>/index.html
ですが、この方法ではURLにS3のリソース名を入力する必要があります。前述しましたが、実際のS3の静的ホスティングではインデックスドキュメントとエラードキュメントが設定してあるためindex.htmlを指定せずともindex.htmlを表示するように設定できました。
この問題を解決するにはどのようにすればいいのでしょうか?
先ほどのindex.htmlにアクセスする時、URLにファイル名を含めないといけない問題の一番簡単な方法はS3の静的ホスティングURLを接続することです。
今まで、カスタムドメインを使うことでファイルパスにステージ名を含めないで動作することが確認できました。また、API GatewayのプロキシはAPI GatewayとS3の接続が可能です。そして、S3の静的ホスティングではすでにホスティング機能が備わっています。
これらの機能を利用し、S3の静的ホスティングURLをAPI GatewayのproxyのエンドポイントURLにし、カスタムドメインを設定すれば、API GatewayのURLからS3の静的ホスティングをカスタムドメインでステージ名かつ、ファイル名を指定せずにアクセスし、S3のホスティングを実現することができます。
しかしこの方法は接続をプライベートにできないという問題があります。
S3の静的ホスティングはプライベートアクセスすることができません。そのためこの設定を行うとアクセス方法がS3の静的ホスティングURLとAPI GatewayのURLの2通りになってしまいます。また、S3の静的ホスティングURLのプロトコルはHTTPです。そのため、一度インターネットに出てからAPI Gatewayにアクセスするので余計なコストがかかります。
そこで今回はS3の静的ホスティングの仕組みをAPI Gatewayで再現することでホスティングを実現しました。
ここで再びS3の静的ホスティングの設定を確認してみます。
S3の静的ホスティングを再現する上で最も重要である、インデックスドキュメントとエラードキュメントをもう一度確認します。設定にも記述してありますが、インデックスドキュメントはホスティングサイトのデフォルトページを指定しています。
次にエラードキュメントは、渡されたパスにS3に存在しないオブジェクトが渡された時返すファイルを指定しています。
この2つの設定を行うことでどんな場合でもindex.htmlを返すような設定になっています。
それではこの2つをAPI Gatewayで再現してみましょう。
※カスタムドメインを設定してあることが前提です。
API Gatewayはルートパスにもメソッドを付与することができます。ルートパスにGETメソッドを付与することで、S3のindex.htmlを読み取ることができます。
この場合、「統合タイプ」を「AWSサービス」の「S3」にして以下のような設定にします。
「パスの上書き」を設定することで、ルートパスにアクセスしたときに、そのパスにアクセスするように設定できます。今回であればindex.htmlにアクセスして欲しいので、<S3バケット名>/index.htmlと指定します。
またデフォルトのままだとContent-Typeが全てapplication/jsonにマッピングされてしまいます。この時「統合レスポンス」か「メソッドレスポンス」を修正することで解決することができます。
統合レスポンスの場合は、レスポンスヘッダーのContent-Typeに対してintegration.response.header.Content-Typeを指定します。
また、メソッドレスポンスの場合は以下のようにコンテンツタイプにtext/htmlを指定します。
これで、URLにindex.htmlを含めずにルートパスだけでindex.htmlにアクセスすることができました。
次にエラードキュメントをindex.htmlに設定する方法を説明します。
S3の静的ホスティングでは、S3に存在するファイルにアクセスした場合はそのファイルを表示し、存在しないファイルにアクセスした場合のNotFoundや403エラーの場合はindex.htmlを表示します。
しかし、これを1つのproxyパスで再現することは難しいです。そのため今回は存在しないパスにアクセスした場合のproxyパスとS3に存在するファイルにアクセスした場合のproxyパスを分けることで実現しました。
S3に存在するファイルにアクセスした場合を考えます。まずindex.html以外のファイルを1つのディレクトリにまとめます。今回はまとめるディレクトリを/resourcesとし、以下の図のようにビルドさせます。
/dist
├── index.html
└── resources
├── css
│ └── app.fb0c6e1c.css
├── favicon.ico
├── img
│ └── logo.82b9c7a5.png
└── js
├── app.2b3f1705.js
├── app.2b3f1705.js.map
├── chunk-vendors.c5cf1606.js
└── chunk-vendors.c5cf1606.js.map
そしてAPI Gatewayのパスもこのディレクトリ 構造に合わせます。resourcesパスを作成し、その中にproxyパスを作成します。
「統合リクエスト」から、「統合タイプ」に「AWSサービス」の「S3」を指定し、パス上書きに<s3_bucket_name>/resources/{proxy}と指定します。
また、受け取ったproxyパラメータをパスに埋め込むため、「統合リクエスト」の「URLパスパラメータ」にmethod.request.path.proxyを指定する必要があります。これをしないとAPI Gateway内部で以下のエラーが発生します。受け取ったproxyパラメータがマッピングされず、正しいURLでS3からオブジェクトを取得できなくなるので注意してください。
Execution failed: URI/URL syntax error encountered in signing process
次にこれまでと同様にContent-Typeを正しい形にするために、「統合レスポンス」の「レスポンスヘッダー」でintegration.response.header.Content-Typeを指定するか、「メソッドレスポンス」にContent-Typeをそれぞれ指定します。「メソッドレスポンス」で指定する場合は、resourcesディレクトリ に含まれる全てのContent-Typeを記述する必要があります。
これによってS3に存在するファイルにアクセスした場合はresourceパスのproxyを通ってS3から取り出すように設定できました。
そして次に存在しないファイルにアクセスした時index.htmlを返す設定をします。
まず、ルート直下のproxyリソースに対して「統合リクエスト」の「統合タイプ」を「AWSサービス」の「S3」に指定し、「パス上書き」を<bucket_name>/index.htmlにします。
この場合はパスの上書きにproxyを指定してないので「URLパスパラメータ」の設定は不要です。
そして「統合レスポンス」の「レスポンスヘッダー」でintegration.response.header.Content-Typeを指定するか、「メソッドレスポンス」にContent-Typeをそれぞれ指定します。これで設定完了です。
この2つの設定をすることで、存在しないパスが呼び出された時はルートパス直下のproxyからindex.htmlが呼び出され、index.html以外のファイルはresourcesパスのproxy経由で呼び出されます。これにより、S3のエラードキュメントをAPI Gatewayで実現することができました。
次に前回の記事で紹介したSAMを利用したデプロイ方法について紹介します。
SAMでproxyリソースをデプロイする場合はswagger.yamlが必要です。既に作成したAPIからswaggerファイルを取得する場合は、ステージ項目のエクスポートからファイルを取得することができます。今回は中央のSwagger + API Gatewayの拡張形式を使用しました。
SAMでSwaggerを適用させる場合は、APIリソースのDefinitionBodyにファイル名を記述します。ファイル名指定以外にもファイルの内容をベタがきすることも可能です。
Resources:
OwlProjectApi:
Type: AWS::Serverless::Api
Properties:
Name: owl-project-api
StageName: !Ref EnvName
DefinitionBody:
Fn::Transform:
Name: AWS::Include
Parameters:
Location: swagger.yaml
Locationの部分にswaggerのファイルを記述するのですが、S3にあるswaggerファイルを参照することも可能です。その場合は、s3://<S3バケット名>/swagger.yamlと記述します。
SAMでビルドしてからデプロイを実行します。コマンドは以下です。前回の記事でも説明しましたが、buildのuser-containerオプションはホストマシンにビルドしたいランタイムの言語がインストールされていない場合に使用します。
sam build --use-container -t template.yaml
デプロイをしたことがなければguidedオプションを指定してください。
sam deploy --guided
以下の文章が出るので指示に従い入力を行います。
Configuring SAM deploy
======================
Looking for config file [samconfig.toml] : Not found
Setting default arguments for 'sam deploy'
=========================================
Stack Name [sam-app]: owl-project
AWS Region [ap-northeast-1]:
#Shows you resources changes to be deployed and require a 'Y' to initiate deploy
Confirm changes before deploy [y/N]: N
#SAM needs permission to be able to create roles to connect to the resources in your template
Allow SAM CLI IAM role creation [Y/n]: n
Capabilities [['CAPABILITY_IAM']]:
HelloWorldFunction may not have authorization defined, Is this okay? [y/N]: y
Save arguments to configuration file [Y/n]: Y
SAM configuration file [samconfig.toml]:
SAM configuration environment [default]:
--guiedeオプションを指定することでSAMをデプロイするためのconfigファイルを作成されます。上の入力が終わるとsamconfig.tomlというファイルが生成されます。samconfig.tomlの中身は以下のようになっており、先ほど入力した内容と同じです。
version = 0.1
[default]
[default.deploy]
[default.deploy.parameters]
stack_name = "owl-project"
s3_bucket = "aws-sam-cli-managed-default-samclisourcebucket-xxxxxx"
s3_prefix = "owl-project"
region = "ap-northeast-1"
capabilities = "CAPABILITY_IAM"
samcofig.tomlでsam deployのオプションを記述して、--config-fileオプションにsamconfig.tomlを指定すればdeployコマンドの際に--config-file以外のオプションを記述する必要がなくなります。ですが、コマンドライン上で指定するパラメータはsamconfig.yamlよりも優先されるので注意してください。詳しくはこちらのドキュメント を参考にしてみてください。
デプロイが始まるとsamconfig.yamlで指定したスタック名でCloudFormationのスタックに記録され、デプロイ完了です。(SAMのTemplate.yamlはCloudFormationの拡張です)
swagger.yamlを導入する前はtemplate.yamlのみでlambdaのリソース本体と、API Gatewayを設定することができました。しかしswagger.yamlを導入することで、template.yamlの役割が変わります。結論から言うと、swagger.yamlはAPI Gatewayのパスの設計、template.yamlはlambda本体の設計を担当しています。
例えば、以下の場合を考えます。
template.yaml
Resources:
Api:
Type: AWS::Serverless::Api
Properties:
Name: test-api
StageName: dev
TestFunction:
Type: AWS::Serverless::Function
Properties:
CodeUri: lambda_functions/test-function
FunctionName: test-function
Role: <aws-lambda-role>
Events:
GenerateAdminToken:
Type: Api
Properties:
Path: /test
Method: get
RestApiId: !Ref Api
swagger.yaml
swagger: "2.0"
info:
version: "1.0"
title: "test-api"
host: "xxxxx"
basePath: "/dev"
schemes:
- "https"
paths:
/test:
get:
responses: {}
x-amazon-apigateway-integration:
httpMethod: "GET"
uri: "arn:aws:apigateway:ap-northeast-1:lambda:path/2015-03-31/functions/arn:aws:lambda:xxxx:test-function:dev/invocations"
passthroughBehavior: "when_no_match"
type: "aws_proxy"
options:
consumes:
- "application/json"
produces:
- "application/json"
responses:
"200":
description: "200 response"
headers:
Access-Control-Allow-Origin:
type: "string"
Access-Control-Allow-Methods:
type: "string"
Access-Control-Allow-Headers:
type: "string"
x-amazon-apigateway-integration:
responses:
default:
statusCode: "200"
responseParameters:
method.response.header.Access-Control-Allow-Methods: "'POST, GET, OPTIONS,\
\ DELETE, PATCH, PUT'"
method.response.header.Access-Control-Allow-Headers: "'Origin, Authorization,\
\ Accept, X-Requested-With, Content-Type, x-amz-date, X-Amz-Security-Token,\
\ role, type, authorizationToken, methodArn'"
method.response.header.Access-Control-Allow-Origin: "'*'"
responseTemplates:
application/json: "{}\n"
requestTemplates:
application/json: "{\n \"statusCode\" : 200\n}\n"
passthroughBehavior: "when_no_match"
type: "mock"
この場合、どちらのファイルにも同じlambdaのパスと情報が記述されています。
では、swagger.yamlはそのままでtemplate.yamlからTestFunctionを削除してデプロイするとどうなるでしょうか?
結果はAPI Gatewayのパスとしては存在しますが、lambda本体は存在しない状態となります。
また、API Gatewayをローカルで起動する sam local start-apiコマンドでパスにアクセスした場合、以下のエラーがでます。
{"message":"No function defined for resource method"}
文字通りlambdaが存在しないためエラーが出ます。そのため、swagger.yamlを導入してもswagger.yamlとtemplate.yamlのどちらにもしっかり記述する必要があるので注意してください
一般的なCloudFront + S3の構成と今回扱ったAPI Gateway + S3の料金を比較してみました。着目しているのはホスティングのみです。
まず、CloudFront + S3でコストが発生するのは
の3点です。(S3からCloudFrontへの転送料金は2014年から無料になっています)
次にAPI Gateway + S3でコストが発生するのは、
の4点です。
まとめると以下のようになります。
CloudFront + S3の場合
S3のストレージ料 (/GB) | CloudFrontへのリクエスト (/1万リクエスト) | CloudFrontのインターネットデータ転送 (/GB ) | |
CloudFront + S3 | 0.025 USD |
HTTP : 0.009 USD HTTPS: 0.012 USD |
0.114 USD(10TBまで) |
API Gateway + S3の場合
S3のストレージ料 (/GB) | S3へのリクエスト (/1万リクエスト) | S3のAPI Gatewayへの転送 (/GB) |
API GatewayへのAPIコール (/1万リクエスト) | |
API Gateway + S3 | 0.025 USD |
0.0037 USD (GETの場合) |
0.114 USD (1GB〜9.999TBまで) |
0.0425 USD |
月間1万リクエストで、S3またはCloudFrontからの送信データ量が100GBだった場合で想定すると以下のようになります。(S3のストレージ料は共通部分のため計算に含んでいません)
・CloudFront + S3 (HTTPS)
0.012×100 + 0.114×100 = 12.60(USD/month) = 1388.38(円/month)
・API Gateway + S3
0.037×100 + 0.114×100 + 0.0425×100 = 19.35(USD/month) = 2132.16(円/month)
という結果になりました。
API Gatewayを使用したホスティングを用いるときは以下のケースの場合があります。
前述した通り、CloudFrontよりもAPI Gatewayは料金が高いです。そのためAPI Gatewayでのホスティングを採用するならば、まずアクセス数が少ないことが大前提です。
2つ目にS3の静的ホスティングをコードを使って管理することができます。AWSのインタフェースを確認することなく、コードで記述して共有することが可能です。
今回はAPI Gatewayのプロキシを使用したS3ホスティングを説明しました。ユースケースは特殊ですが、この記事を参考にしていただければ幸いです。
[AWS][やってみた] API Gateway を用いて、S3 で静的ウェブサイトホスティングで公開したVue アプリをHTTPS化してみた。
※2021年8月2日時点