CDNサービスをCloudFrontに移行した

## はじめに

最近、弊社で利用していたCDNサービスをAWSのCloudFrontに移行しました。  
ここでは主に移行作業として何をしたかを順に追って触れつつ、反省点等について述べます。  
CloudFrontに限らず、これからCDNサービスを移行または導入をすることが決定し、その作業を担当することになった方の参考に少しでもなれば幸いです。

## 何故移行したのか

弊社が運営しているサービスの特性上大規模なトラフィックを扱う必要があり、コストの問題から他社のベンダ製品を利用していましたが、  
長年の利用でエッジロジックが複雑化しメンテナンスコストが上がっていたことと  
ネット上に事例もあまりなく、公式のリファレンスを見ながらの作業で構築難易度も高い状況でした。
ユーザー様からお問い合わせをいただいた際に原因がAWS側なのかCDN側なのかの切り分けも難しく、
またCDNサーバの監視や障害対応がベンダ任せとなっており、ビジネスインパクトにつながる懸念を抱えていました。
この度社内でコスト的な調整ができたこともあり、設定の棚卸しと上記課題の解決に向けてCloudFrontへの移行を決定しました。

## 移行作業で行ったこと

今回、特定のドメイン(以下、ドメインAと書く)の移行可能性が特に重要でした。  
そのため、そのドメインAに対しての調査や検証にとても工数がかかりました。

1. 旧CDNサービスの使用状況の調査
2. ドメインAに注目したコスト計算
3. CloudFrontでの実装方法の検討と調査
4. 負荷試験実施と結果の分析
5. 各対象ドメイン分の実装
6. 本番のトラフィックを流しての試験実施と結果の分析
7. 移行後の想定年間コストの計算
8. 各ドメイン分のリリース作業
9. 各サービス担当チームへの引き継ぎ

### 旧CDNサービスの使用状況の調査

使用状況として、

- 全ドメイン分のリクエスト数とアウトトラフィック量
- ドメインAについてのキャッシュの扱い方(キャッシュ戦略)
- ドメインAについてのオリジンが異なるパス毎のリクエスト数とサーバー負荷

等を調査しました。

全体のリクエスト数とアウトトラフィック量については、CloudFrontのランニングコストを算出するための情報です。  
また、コストに大きく影響し得る点としてLambda@Edgeがどうしても必要なリクエストについて、全体を占めるリクエスト数の割合についても確認しました。

ドメインAのキャッシュ戦略等の情報はコストだけでなく、移行が技術的、コスト的に実現可能かを確認するための情報です。

旧CDNサービスではキャッシュの削除にコストがかかりませんでしたが、CloudFrontにはコストがかかるため、キャッシュ戦略はとても重要でした。

### ドメインAに注目したコスト計算

まずは、ドメインAがコスト的に移行出来ないと作業自体進められないため、使用状況の調査結果を元に複数パターンのキャッシュ戦略を想定して現行とのコストの差分を計算をしました。  
少し詳細に書くと、キャッシュ戦略によって、オリジンのサーバー台数のコストとキャッシュ削除機能のコストが変わることがポイントでした。

考慮したキャッシュ戦略のパターンはおおよそ以下の通りです。

- 今まで通り
- 一部キャッシュ削除機能を使う
- 全くキャッシュ削除しない

このコスト計算でドメインAの今後のキャッシュ戦略を確定させました。  
おまけにCloudFrontの各機能がコストにどれだけ影響するのかが把握出来ました。

#### トラフィック量の単位とコスト

地味ですが結構重要な事としてCDNサービスによって、公式ドキュメントや管理画面に映っているトラフィック量の単位が「 **GB** 」なのか「 **GiB** 」なのかが変わります。

- $1{\rm GB} = 1000{\rm MB}$
- $1{\rm GiB} = 1024{\rm MB}$

という違いがあります。  
例えばGBだと思って計算したコストが1億だった場合、実はGiBだったので240万高くなるといった具合です。

しかしながら、サービスによってはGBと書いているのに、実際はGiBといったこともザラにあるのでよく確認すべきです。

※CloudFrontのトラフィック量、データ転送(OUT)の「GB」はGBであり、S3の使用量の「GB」はGiBです。

- [Amazon CloudFront の料金](https://aws.amazon.com/jp/cloudfront/pricing/)  
  ※ページにはGBであることが明記されてませんが、確認したらGBだそうです。
- [Amazon S3 の料金](https://aws.amazon.com/jp/s3/pricing/#S3_Pricing_Details)

### CloudFrontでの実装方法の検討と調査

旧CDNサービスで実装していた色々な処理は実質1画面で書けるような物でしたが、CloudFrontで同じ実装をするには関連する複数のリソースを組み合わせる必要がありました。  
例えば、キャッシュキーの設定やパス毎のオリジンの振り分け、認証等です。

また、それらの処理をCloudFront FunctionsやLambda@Edgeで実装することも可能ではありませんが、  
[CloudFront Functionsの制限](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/cloudfront-limits.html#limits-functions)と[Lambda@Edgeのコスト](https://aws.amazon.com/jp/cloudfront/pricing/)を考慮しないと、そもそも実装出来ない、運用コストがかかりすぎるという問題が発生し、移行できない可能性があります。

以下のような事を実装方法を検討する時に考えていました。

- [CDNでやりがちな処理をCloudFrontで実現するには](#cdnでやりがちな処理をcloudfrontで実現するには)

### 負荷試験実施と結果の分析

ドメインAについて、移行後のキャッシュ戦略の場合の

- オリジンへの負荷
- オリジンも含めたコスト

を確認するために負荷試験を実施しました。  
同じリクエストを送信し続けるような試験ではなく、実際に複数のユーザーがサービスを利用している想定での負荷を確認するためにヘッドレスブラウザを用いて行いました。

### 各対象ドメイン分の実装

ようやく実装です。旧CDNの時は特にIaC等は考慮されていませんでした。また、ドキュメントも一部ドメインに関しての手順書や説明はあれど、基本的にはありませんでした。  
それにより、CDNについては特定個人しかメンテナンス出来ないといった状態だったため、全ドメイン分のCloudFront関連のリソースについて以下の方針を取りました。

- 同じようなファイル構成でIaCで管理(Terraform)  
  ※ファイル構成については[CloudFront作成用Terraform構成例](#cloudfront作成用terraform構成例)をご覧ください。
- 同じデプロイ手順になるように作成
- 同じフォーマット、書きぶりでドキュメントをリポジトリ内に作成  
  ※ドキュメント作成については[後述](#各サービス担当チームへの引き継ぎ)

これにより、いつも対応する人が居ない時でも、最低限修正とデプロイが比較的安全に出来る状態になりましたし、  
新しくCDNを通したいドメインが出てきた時はその構成をコピペしつつ作れるようになりました。

#### 実装で特に気を付けるべきこと

どのCDNでも共通のことですが、オリジンにアプリケーションサーバーを含む場合、オリジンは個人情報を含むレスポンスを返す可能性があるということに注意しなければなりません。

時折、「クラウドサービスの設定を間違えて個人情報を流出してしまった」といったニュースが流れます。  
この原因の一つとして、オリジンがアプリケーションサーバーであるパスに対して、CDN側で意図しないキャッシュ設定をすることが考えられます。

インフラ(クラウド)担当者はそれほどアプリケーションのことを知らない(逆も然り)というのは良くあることだと思いますから、CDN側では **オリジンがアプリケーションサーバーであるパスに対しては、一律デフォルトのキャッシュ時間(デフォルトTTL)を0** にすると良いと思います。  
そして、オリジン側で **各ページに詳しいアプリケーション担当者が意図的に** `Cache-Control` ヘッダでキャッシュの制御をする方針とすれば **インフラ担当者が意図せず** 該当のページをキャッシュする設定をせずに済むのではないかと思います。

※オリジン側でキャッシュの制御をする時は[Set-Cookieヘッダ](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/Cookies.html)についても注意しましょう。

### 本番のトラフィックを流しての試験実施と結果の分析

実装と一通りの動作確認が済んだ後、実地試験として本番のトラフィックを一部CloudFrontに向けて試験をしました。思わぬ不具合が無いか、完全に切り替えた時にオリジン側のサーバーは耐えられそうかを確認しました。

ここで少し注意しなければいけないのはCloudFrontは1日、一部トラフィックを流しても無視できない程度のコストが発生し得るということです。  
実施する前に、実施する期間とどのくらいの割合を流すかを確定させ、その分のコストを確認した方が良いです。

ちなみに一部トラフィックを流す方法として、DNSとしてRoute53を使っている場合は[加重ルーティング](https://docs.aws.amazon.com/ja_jp/Route53/latest/DeveloperGuide/routing-policy-weighted.html)という機能が使えます。

### 移行後の想定年間コストの計算

一部とはいえ、本番のトラフィックを流した事で得た計測値を元に、より確度の高い計算が出来ます。  
一度コスト計算はしましたが、それが間違っていないかを確認するためにもこのタイミングで再度コスト計算を行いました。

(ここでもし計算結果が想定コストよりも遥かに高かったら・・・つらい)

### 各ドメイン分のリリース作業

1. 何日何時に何のドメインをリリースするかを決めて
2. それぞれ手順書を全て書いて
3. 各ドメインに関連するサービスの担当の方に確認
4. それぞれリリース

リリース作業時は何か起きた時に即判断して貰えるように各ドメインに関連するサービスの責任者(PdM等)に一緒に居てもらいましょう。

### 各サービス担当チームへの引き継ぎ

全ドメイン分の説明と作業手順を同じフォーマット、書きぶりでリポジトリ内に作成しました。  
旧CDNの時は特にドキュメントが乏しく、知る人ぞ知る状態になっていました。  
実装が簡単でドキュメントなんて要らないだろうと思われるドメイン分についても全て同じように準備しました。

そして各サービス担当チームへ説明と引き継ぎをして移行作業は一通り終わりました。

## こうしていて良かったこと

### リクエスト数とアウトトラフィック量を普段から把握しておく

リクエスト数とアウトトラフィック量はCloudFrontに限らず、色々なCDNサービスのコストに影響する数値です。  
常日頃から各時間帯、曜日、季節毎の数値の上がり下がりや、年単位での上がり下がりを把握しておくことで移行後のランニングコストが予測しやすくなります。

これによって決められた事は「リクエスト数とアウトトラフィック量の数値が、現時点から導入開始後の同期間で何倍くらいになりそうか」ということです。

※こうしていて良かったこととか言いつつ、私自身がそれをずっとやっていたわけではありません。このデータはとても参考になりました。

### コスト計算を複数回行ったこと

上述の通り、本格的に作業を始める前と一部本番トラフィックを流した後にコスト計算を行いました。  
移行完了後、万が一想定よりも遥かにコストがかかっていた場合、契約の都合上CDNサービスを戻すことは難しいと思われます。  
ですので一部本番トラフィックを流した後に、より確度の高いコストを算出し、問題が無いことを確認したのは良かったと思います。

## こうすれば良かったこと

### 予めヘッドレスブラウザによる負荷試験を出来る状態を作っておけば良かった

プロジェクト全体を通して、それぞれの作業がそれほど時間をかけずに行う必要がありました。  
しかしながら、ヘッドレスブラウザによる負荷試験を実施するための手順などが元々確立されていなかったため、ツールの選定、ケースの作成から実行環境の準備までその期間内で行っていました。

その結果、実際に負荷試験用のコードを実行するタイミングになってツールや実行環境の色々な問題が発生し、試験自体は色々妥協して結果を纏める時に辻褄を合わせることになってしまいました。

予め、特定のシナリオに限定したもので良いので負荷試験を何時でもすぐ準備、実行できるように

- テストコード
- 実行環境を構築するためのコード

を準備しておくと、CDN移行、導入に限らず色々な時に使えると思います。

## おまけ

良ければCloudFront移行作業をすることになった時にでも参考にしてください。

### CloudFront作成用Terraform構成例

こんな感じのTerraform(Terragrunt)の構成で全ドメイン分のリソース作成してました。

[shinjiroy/cloudfront-kit](https://github.com/shinjiroy/cloudfront-kit)

このコードを書く上で意識したことを書きます。

#### CDNサービスは将来変更されるものである

世の中には様々なCDNサービスがあります。コストや、配信しているコンテンツの都合等、色々な理由CDNサービスの移行を余儀なくされる時は良くある物だという想定です。  
そのため、同じリポジトリ内にオリジンにあたるS3バケットやアプリケーションサーバー等は管理していません。  
また、CDNサービス別でもリポジトリを別にすべきでしょう。

#### 1リポジトリ内で管理するリソース

この例ではCloudFront関連のリソース、Lambda(Lambda@Edge用)とAWS WAF(WebACL)のみを1リポジトリ内で管理する想定で書きました。  
オリジンにあたるS3バケットやアプリケーションサーバーについてはここでは管理しません。  
しかし、それらを除き、CloudFrontを動かすにはSSL証明書(AWSならACM)、DNSレコード(AWSならRoute53のレコード)が必要になります。  
SSL証明書やDNSレコードは必ずしもCloudFrontのために作成されるものでは無いため、CloudFront用リポジトリとしては管理しない方が良いと考えます。

#### CloudFront Functionsのminify

CloudFront Functionsは[クォータ](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/cloudfront-limits.html#limits-functions)の通り、最大関数サイズが **10KB** と言う縛りがあります。  
そのため、少しでも余裕を持たせるためデプロイする直前でminifyします。

#### エッジ関数用ディレクトリ

エッジ関数の原本が配置されるディレクトリはリポジトリのルートに作成し、万が一開発用、本番用のエッジ関数のコードが混ざらないように、デプロイする直前で環境別のディレクトリに配置しつつzipファイルも作成するようにしています。

また、Lambda@Edge用のコードは別リポジトリに配置すると都合が良いかもしれませんし、かえって管理しづらくなるかもしれません。どちらにもつらい点はあるのでどちらにするかはお任せします。

#### ドキュメントについて

`docs` ディレクトリ以下にドメイン別にディレクトリを切って、その下にMarkdownなりなんなりを配置すると良いです。別にMarkdownに限らなくても良いですが、リポジトリを分けるのは絶対やめた方が良いです。

### CDNでやりがちな処理をCloudFrontで実現するには

CDN(のエッジサーバー)で行っている処理をCloudFrontで実現するためにどの機能を採用するかはおおよそ以下の流れで判断すると良いでしょう。

#### 外部のデータリソースにアクセスする必要がある場合

```mermaid
graph TD
    B["データの総量は？"]
    C["CloudFront Functions, KeyValueStoreを採用する"]
    D["データの総量が少なく済むようにオリジン側も含め方法を変更する余裕がある？"]
    E["Lambda@Edgeを採用する"]

    B --> |"少ない"| C
    B --> |"多い"| D
    D --> |"余裕がある"| C
    D --> |"余裕がない"| E

```

#### 外部のAPI等を実行する必要がある場合

```mermaid
graph TD
    B["外部のAPI等を使用しないように方法を変更する余裕がある？"]
    C["CloudFront Functionsを採用する"]
    D["Lambda@Edgeを採用する"]

    B --> |"余裕がある"| C
    B --> |"余裕がない"| D
```

#### 認証処理がある場合

```mermaid
graph TD
    B["オリジン側でトークン生成処理を修正、実装する余裕がある？"]
    C["署名付きURLを採用する"]
    D["外部のデータリソースにアクセスする必要がある？"]
    E["データの総量は少ない？"]
    F["CloudFront Functions, KeyValueStoreを採用する"]
    G["データの総量が少なく済むようにオリジン側も含め方法を変更する余裕がある？"]
    H["Lambda@Edgeを採用する"]
    I["CloudFront Functionsを採用する"]

    B --> |"余裕がある"| C
    B --> |"余裕がない"| D
    D --> |"ある"| E
    E --> |"少ない"| F
    E --> |"多い"| G
    G --> |"余裕がある"| F
    G --> |"余裕がない"| H
    D --> |"ない"| I
```

#### リクエストデータに対して読み取りや書き込みしている場合

```mermaid
graph TD
    B["リクエストボディに対して読み取りや書き込みしている？"]
    C["Lambda@Edgeを採用する"]
    D["CloudFront Functionsを採用する"]

    B --> |"している"| C
    B --> |"リクエストボディ以外のみ"| D
```

#### レスポンスデータに対して読み取りや書き込みしている場合

CloudFront Functionsでやりましょう。

レスポンスデータならビューワーレスポンスでレスポンスボディにもアクセスできます。

#### 特定のパスでキャッシュ時間を設定している場合

```mermaid
graph TD
    B["オリジン側でCache-Controlを設定するよう修正する余裕がある？"]
    C["キャッシュポリシーのデフォルトTTLを0にする"]
    D["キャッシュポリシーのデフォルトTTLを短めの値にする"]

    B --> |"余裕がある"| C
    B --> |"余裕がない"| D
```

```mermaid
graph TD
    E["リクエストする時のクエリパラメータ等を追加するよう修正する余裕がある？"]
    D1["キャッシュポリシーのデフォルトTTLを長めの値にする"]
    F["キャッシュポリシーのデフォルトTTLを短めの値にする"]

    E --> |"余裕がある"| D1
    E --> |"余裕がない"| F
```

なるべくオリジンでCache-Controlを設定する方が良いですが、S3バケット等のデータストアがオリジンの場合はデフォルトTTLで調整した方が楽でしょう。

#### URLパスを変更している場合

CloudFront Functionsでやりましょう。

パスの書き換えはCloudFront Functionsで可能です。

#### 向き先のオリジンを変更している場合

```mermaid
graph TD
    B["何を基準に変更している？"]
    C["ディストリビューションのビヘイビアを追加する"]
    D["Lambda@Edgeを採用する※オリジンリクエスト"]

    B --> |"URLパス"| C
    B --> |"クエリパラメータ等、URLパス以外の値"| D
```

オリジンの書き換えはオリジンリクエストのエッジ関数でしか出来ません。そして、オリジンリクエストに設定出来るエッジ関数はLambda@Edgeのみです。

## 参考

- [CloudFront クォータ](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/cloudfront-limits.html)
- [CloudFront デベロッパーガイド](https://docs.aws.amazon.com/ja_jp/AmazonCloudFront/latest/DeveloperGuide/Introduction.html)