CloudFrontなしでS3をHTTPSカスタムドメインでホスティングしてみた

API Gateway proxyのKV

こんにちは、エンジニアインターンの佐藤です。
前回の記事 に引き続きAWSを使ったサーバーレス環境の構築記事です。
今回はS3のホスティングについて焦点を当てた記事となっています。
S3の静的ホスティングでHTTPSやカスタムドメインを設定する際はRoute53とCloudFrontを組み合わせることが多いですが、今回はCloudFrontを使用せずにHTTPS化してみました。

  1. S3の静的ホスティング
  2. プロキシとは
    1. フォワードプロキシ
    2. リバースプロキシ
  3. API Gatewayのプロキシ
    1. インタフェース上の設定方法
      1. デフォルト設定の問題点
      2. 解決策
    2. S3の静的ホスティングURLを接続する
  4. S3の静的ホスティングを再現する
    1. インデックスドキュメントを再現する
    2. エラードキュメントを再現する
  5. SAMを用いたデプロイ
    1. Template.yamlとswagger.yamlの役割
  6. 注意点
    1. 料金
    2. API Gateway + S3を採用するケース
  7. まとめ
  8. 参考

 ※この方法は特殊なので、一般的なプロジェクトではCloudFrontを使用することをお勧めします。(理由は後述します)

S3の静的ホスティング

S3はストレージサービスとして有名ですが、バケットに入れたファイルをホスティングする機能も備わっています。S3の静的ホスティングのやり方は多くの記事があるため、今回は割愛させていただきます。
静的ホスティングでは以下の設定画面でインデックスドキュメントエラードキュメントを設定することができます。この設定すると、どんな場合でもindex.htmlを返すような設定にすることができます。(S3のバケットにあるオブジェクは除く)

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点あります。


  • ・amazonaws.comのサブドメインで、独自ドメインではない

    ・HTTPSではない

まず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もプロキシの機能を使用することができます。API Gateway自身がプロキシとなって他のサービスの代理を行うためリバースプロキシとして機能します。

インタフェース上の設定方法

まずAWSインタフェースからの作成方法を見てみます。
アクションからリソースの作成を押すと下図の画面が表示されます。「プロキシリソースとして設定する」のチェックボックスにチェックすると{proxy+}が自動で入力されます。

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}
proxyリソース作成2


完了すると下図の画面になります。

proxy


そしてこの設定でデプロイします。「アクション」から「APIのデプロイ」を選択してデプロイできます。API Gatewayをデプロイする際はステージが必須なので、ステージを選択または作成してデプロイします。

API Gatewayのデプロイ


デプロイが完了するとステージエディターに移動します。表示されているURLからアクセス可能です。

devステージの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つあります。


  • ・index.html以外のファイルを全てステージ名のディレクトリに含める

  • ・カスタムドメイン


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を表示するように設定できました。
この問題を解決するにはどのようにすればいいのでしょうか?

S3の静的ホスティングURLを接続する

先ほどのindex.htmlにアクセスする時、URLにファイル名を含めないといけない問題の一番簡単な方法はS3の静的ホスティングURLを接続することです。

今まで、カスタムドメインを使うことでファイルパスにステージ名を含めないで動作することが確認できました。また、API GatewayのプロキシはAPI GatewayとS3の接続が可能です。そして、S3の静的ホスティングではすでにホスティング機能が備わっています。
これらの機能を利用し、S3の静的ホスティングURLをAPI GatewayのproxyのエンドポイントURLにし、カスタムドメインを設定すれば、API GatewayのURLからS3の静的ホスティングをカスタムドメインでステージ名かつ、ファイル名を指定せずにアクセスし、S3のホスティングを実現することができます。
しかしこの方法は接続をプライベートにできないという問題があります。

S3の静的ホスティングはプライベートアクセスすることができません。そのためこの設定を行うとアクセス方法がS3の静的ホスティングURLAPI GatewayのURLの2通りになってしまいます。また、S3の静的ホスティングURLのプロトコルはHTTPです。そのため、一度インターネットに出てからAPI Gatewayにアクセスするので余計なコストがかかります。
そこで今回はS3の静的ホスティングの仕組みをAPI Gatewayで再現することでホスティングを実現しました。

S3の静的ホスティングを再現する

ここで再びS3の静的ホスティングの設定を確認してみます。

S3の静的ホスティング


S3の静的ホスティングを再現する上で最も重要である、インデックスドキュメントエラードキュメントをもう一度確認します。
設定にも記述してありますが、インデックスドキュメントはホスティングサイトのデフォルトページを指定しています。
次にエラードキュメントは、渡されたパスにS3に存在しないオブジェクトが渡された時返すファイルを指定しています。
この2つの設定を行うことでどんな場合でもindex.htmlを返すような設定になっています。
それではこの2つをAPI Gatewayで再現してみましょう。

インデックスドキュメントを再現する

※カスタムドメインを設定してあることが前提です。
API Gatewayはルートパスにもメソッドを付与することができます。ルートパスにGETメソッドを付与することで、S3のindex.htmlを読み取ることができます。
この場合、「統合タイプ」を「AWSサービス」の「S3」にして以下のような設定にします。
「パスの上書き」を設定することで、ルートパスにアクセスしたときに、そのパスにアクセスするように設定できます。今回であればindex.htmlにアクセスして欲しいので、<S3バケット名>/index.htmlと指定します。

ルートを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パス設定


また、受け取った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の設定


この場合はパスの上書きにproxyを指定してないので「URLパスパラメータ」の設定は不要です。
そして「統合レスポンス」の「レスポンスヘッダー」でintegration.response.header.Content-Typeを指定するか、「メソッドレスポンス」にContent-Typeをそれぞれ指定します。
これで設定完了です。

この2つの設定をすることで、存在しないパスが呼び出された時はルートパス直下のproxyからindex.htmlが呼び出され、index.html以外のファイルはresourcesパスのproxy経由で呼び出されます。これにより、S3のエラードキュメントをAPI Gatewayで実現することができました。

SAMを用いたデプロイ

次に前回の記事で紹介したSAMを利用したデプロイ方法について紹介します。
SAMでproxyリソースをデプロイする場合はswagger.yamlが必要です。既に作成したAPIからswaggerファイルを取得する場合は、ステージ項目のエクスポートからファイルを取得することができます。今回は中央のSwagger + API Gatewayの拡張形式を使用しました。

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の拡張です)


Template.yamlとswagger.yamlの役割

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でコストが発生するのは

  • 1.  S3のストレージ料金

    2.  CloudFrontのリクエスト

    3.  CloudFrontのインターネットデータ転送

の3点です。(S3からCloudFrontへの転送料金は2014年から無料になっています)

次にAPI Gateway + S3でコストが発生するのは

  • 1.  S3のストレージ料金

    2.  S3のAPI Gatewayへの転送

    3.  S3へのリクエスト料金 (今回はGETのみ)

    4.  API GatewayへのAPIコール

の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 + S3を採用するケース

API Gatewayを使用したホスティングを用いるときは以下のケースの場合があります。


  • ・API Gatewayへのアクセスが少ない

    ・S3のホスティングをコードを使って管理したい

前述した通り、CloudFrontよりもAPI Gatewayは料金が高いです。そのためAPI Gatewayでのホスティングを採用するならば、まずアクセス数が少ないことが大前提です。
2つ目にS3の静的ホスティングをコードを使って管理することができます。AWSのインタフェースを確認することなく、コードで記述して共有することが可能です。

まとめ

今回はAPI Gatewayのプロキシを使用したS3ホスティングを説明しました。
ユースケースは特殊ですが、この記事を参考にしていただければ幸いです。

参考

[AWS][やってみた] API Gateway を用いて、S3 で静的ウェブサイトホスティングで公開したVue アプリをHTTPS化してみた。

Techブログ 新着記事一覧