インフラ問題： CORSを使わずにSPAアプリをAWSにホスティングする


久々にインフラ周りの作業を行ったので備忘を兼ねて、やったことのメモを残しておきます。  
インフラエンジニア向けの課題として、そこそこ面白い内容だと思うのでそれっぽい形式で書いてみます。

## 命題
APIサーバと連携するSPAアプリケーションをAWSでホスティングしたい。
そのために必要な設定を行いなさい。

## アプリケーション条件
- この文書内ではアプリケーションのドメインは`hoge.com`とする
- アプリケーションは顧客企業毎にサブドメインを割り当てる仕組み
  - `aaa.hoge.com`とか`bbb.hoge.com`など複数のドメインがある
- SPAはルートにindex.htmlがあり、そこから参照されるJS, CSS, 画像などがサブディレクトリにある
- SPAのルーティングはHistory(ブラウザのパスが切り替わる)
- APIサーバはすでにELBを構成済み。APIサーバのエンドポイントは`api.hoge.com`
- APIサーバのエンドポイントはすべて `/api/`で始まる
- APIサーバはSPA以外からも単独で使用されることがある(かもしれないので想定しておく)
- SSL必須
- **CORSは使えない**
  - SPAからのAPIリクエストは`/api/hoge`のようにホスト名を含まない形で行われる
  - APIサーバ側でもCORSの設定はない

正直CORSを使えないという条件は無理ゲーなんじゃないかと途中から思ってましたけど、今回任されている役割はインフラなのでそれでできるところまでやってみることにしました。

結果、一応動くところまでは持っていけましたけど色々と問題もあるのでおいおいCORS対応して、インフラも構成し直すと思います。

## 解答例
以下の内容は今回自分が行った設定に過ぎず、もちろん唯一解ではないです。(むしろ他の方法があるなら知りたい)

基本方針はCloudfrontを使ってAPIとコンテンツのリクエストを振り分けるやり方をとってますが、各種設定の細かいところには触れておらずポイントのみを示しています。

---
ここから文体変わります

## ACM
CloudfrontとAPIサーバに証明書が必要なので、 `*.hoge.com`の証明書を発行する。
Cloudfrontは何故かバージニアの証明書しか使えないので、東京都バージニアの両方で同じ証明書を発行する必要がある。

## Route53

- `api.hoge.com` -> ELB
- `*.hoge.com` -> Cloudfront

のAレコードをエイリアスを使って登録する。

## S3
新しいBucketを作ってSPAの各種ファイルの置き場所とする。
ルートにindex.htmlを置き、各種サブフォルダもアプリの構成に従って配置。
ACLは`public-read`とする。

また**Static Website Hosting**を有効にして、インデックスドキュメント、エラードキュメントの両方を`index.html`にする。

### Static Website Hostingとは？
S3はそのままでも、staticなコンテンツサーバとして機能するが、それにちょっとだけ便利な機能を追加したものと理解すると良い。

Static Website Hosting向けには `BUCKETNAME.s3-website-ap-northeast-1.amazonaws.com`のようにS3とは別のエンドポイントが発行される。

今回使った機能としては以下がある。

- インデックスドキュメント - ホストの `/`にアクセスした時に表示するコンテンツ
- エラードキュメント - 存在しないパスにアクセスした時に表示するコンテンツ

他にもカスタムリダイレクトルールを定義できたりするが、今回は使用していない。

ここでのポイントはエラードキュメントに`index.html`を指定していること。
SPAでは画面遷移した後にリロードすると存在しないパスに対してリクエストが発行されることになるが、その場合でもindex.htmlを返すことでリロードにも対応できるようにしている。
(残念ながらStatusコードは403のままとなるがここで返したHTMLはブラウザで解釈されるので、動作自体には問題はない。)

## Cloudfront
Generalの設定でポイントとなる項目は以下

- Aternative Domain Names -> `*.hoge.com`
- SSL Certificate -> CustomでACMで作成した証明書を選択
  - バージニアで作成したものしか選べない
- Default Root Object -> index.html
  - ルートにアクセスした時にindex.htmlが表示されるようにする
  - S3側でも設定しているので不要な気もするがまぁ一応

ログ等は必要に応じてよしなに。

次にS3向けのオリジンとELB向けのオリジンの2つを作成する。
WebConsole上では`Origin Domain Name`の項目はドロップダウンから既存のS3 BucketやELBを選択することができるが、どちらもそこから**選択してはいけない**。

- S3 -> Static Website Hostingで発行されたエンドポイントを手入力する
  - こうしないとインデックスドキュメントやエラードキュメントの設定が有効にならない
- ELB -> `api.hoge.com`を手入力する
  - こうしないと証明書不一致でアクセスできない
  - (APIサーバはSPA以外からも使用されるので、ELBには`*.hoge.com`の証明書が設定されている)

Terraformで作成する場合は`origin_protocol_policy`を

- S3 -> `http-only`
- ELB -> `https-only`

とする。
WebConsole上ではS3のStatic Website Hosting URLを指定した場合、`origin_protocol_policy`は表示されないがTerraformからは設定できるっぽい。
(Static Website HostingにはSSLはないので`https-only`にしているとエラーとなる)

最後にBehaviorの設定を行う

#### Behavior - S3側(Default)

- Origin -> S3
- Viewer Protocol Policy -> Redirect HTTP to HTTPS
  - `http://`でアクセスしてきた時に自動的に`https://`にリダイレクト
- Allowed HTTP Methods -> GET, HEAD
  - S3に対するリクエストではPOSTやDELETEはない

#### Behavior - ELB側
- Origin -> ELB
- Path Pattern -> /api/*
  - `/api/*`へのリクエストはELBに流す
- Viewer Protocol Policy -> HTTPS Only
  - Redirectでも良いけど
- Allowed HTTP Methods -> GET, HEAD, OPTIONS, PUT, POST, PATCH, DELETE
- Forward Cookies -> All
  - NoneだとCookieが送られなくてセッションが維持されない
- Query String Forwarding and Caching -> All
  - APIサーバではGET時のquery文字列はもちろん必要

キャッシュ周りの設定はどう設定するのが適切なのか検証しきれてないので割愛。
まぁ、APIサーバ側は一切キャッシュしなくても良いと思う。

---
## 総評
以上、ここまでの設定で一応当初の命題/条件はクリアしていると思います。
しかし、いくつか問題もあるのでここではそれらを列挙します。

### リロード時のStatusコードが403
これが一番気になるところです。
今回の設定は色々と試行錯誤の末にたどり着いたものですが、SPAのリロード周りを考慮するのが一番厄介でした。

ちなみにネット上ではCloudfront側のError Pagesの設定で、404エラー時にindex.htmlを200で返す設定が紹介されてたりいますが、この方法はうまく行きません。

何故なら、この設定はAPIサーバ側にも効いてしまうので、APIサーバが404を返した時にも200 with index.htmlに書き換えられてしまうから。。。(--

### Static Website Hostingのエンドポイントでコンテンツが公開されている
`*.hoge.com`ではなく、`BUCKETNAME.s3-website-ap-northeast-1.amazonaws.com`にアクセスした場合にもSPAのコンテンツにアクセスできてしまいます。

APIはコールできないのでアプリ自体が動作するわけではないですが気にはなります。

先のError Pagesの設定がBehavior毎に行えるものであれば、Static Websit Hosting(のエラードキュメント機能)を使う必要がなくなるので、Cloudfront経由以外のコンテンツアクセスを完全に禁止できて幸せになれそうな気がしますが。。。。

AWSさん、是非機能拡張を。

### APIサーバへのSSLアクセスが中間で一度解かれる
CloudfrontはリージョンをまたがるのでそこからELBへのアクセスはインターネットを経由するという理解です。
なので、SSLは必須。

しかし、この場合Cloudfront側で一度SSLが解かれて、再度APIサーバにSSLアクセスしているはず。
そのコストが若干気になります。

まぁ、世のプロキシーサーバはだいたい同じことしてると思うのであんまり気にしなくてもよいのかもしれませんが。

## 結論
今回インフラ技術だけで問題を解決しようと色々と試行錯誤してみましたが、リロード403問題だけはクリアできませんでした。
Lambdaを使うことも考えましたが、Cloudfrontでやる方が素直な気がするので試してません。

この構成で一応動作はするもののもちろんこのままで本番適用するわけにはいかず、対応としては、まぁ素直にアプリ側でCORS対応するのかなと思ってます。

その場合の構成変更及びメリットは以下のようになると思われます。

- S3ではStatic Website Hosting不要
  - Cloudfront経由以外のコンテンツアクセスを不可にできる
- CloudfrontのオリジンはS3のみでとてもシンプル
  - SPA対応はError Pagesで404を200 with index.htmlに設定すれば良いだけ
- APIサーバへのアクセスはProxyを経由しない直接アクセスになるので高速化

技術課題としてはとても面白かったです。
誰かリロード403問題をクリアする方法を知ってれば教えて下さい。