SSLオフロード構成でアプリからリダイレクトを返却する場合のTips

# はじめに
近年、Webサイトの常時SSL化が当たり前になっています。皆様が開発しているシステム・サービスも同様のことでしょう。
SSL/TLSの処理は重いので、サーバではなくLoad Balancerに担わせることも多いと思います。
これは一般的に、SSLターミネーション／SSLオフロードなどと呼ばれますが、この構成でつまづきやすいポイントについてまとめます。

# TL;DR;
- name-basedなvirtual hostの利用に注意
- アプリケーションサーバからのリダイレクトに気をつける
- アプリケーションサーバの手前にReverse Proxyを配置してヘッダを制御する


# 検証した環境
- AWS
- ELB (Classic Load Balancer) with SSL termination
- EC2
    - Amazon Linux
    - Apache 2.4.27
    - Spring Boot application (Including Embedded Tomcat)

通信フローは下記のようになります。
[クライアント]->[ELB]->[Apache]->[Tomcat]

それぞれのコンポーネント間の通信プロトコルは下記のようになります。

| 通信経路 | プロトコル/ポート番号 | 備考 |
|:-----------------|:------------------|:-------------------|
| [クライアント]->[ELB] | HTTPS/443 | ELBでSSL offloadします。|
| [ELB]->[Apache] | HTTP/8443 |Listenポートは専用のものを設けた方が良いです。|
| [Apache]->[Tomcat] | HTTP/8080 |nginx等、他のWebサーバでも勿論かまいません。|

ここで、ApacheはReverse Proxyとして動作することになります。
このApacheの設定をどのようにするべきか、が本稿のポイントとなります。

なお、本稿で用いるドメイン名は「example.com」若しくは「www.example.com」とします。

## Apache設定
種々の都合から、Webサービス提供に用いるドメインをname-basedなvirtual hostとして定義する必要があります。

この場合、ServerNameディレクティブで下記のように設定する必要があります。

```
<VirtualHost *:8443>
    ServerName https://www.example.com:443

    ProxyPass / http://127.0.0.1:8080/
    ProxyPassReverse / http://127.0.0.1:8080/

# ※他の設定は省略
</VirtualHost>
```

慣習上、下記のように設定したくなりますが、これは意図したとおりに動きません。

```
<VirtualHost *:8443>
    ServerName www.example.com

    ProxyPass / http://127.0.0.1:8080/
    ProxyPassReverse / http://127.0.0.1:8080/

# ※他の設定は省略
</VirtualHost>
```

Apacheの公式ドキュメントには下記のような記載があります。パラグラフごとに見ていきましょう。

core - Apache HTTP Server Version 2.4 -> ServerName Directive
http://httpd.apache.org/docs/current/en/mod/core.html#servername

> If no ServerName is specified, the server attempts to deduce the client visible hostname by first asking the operating system for the system hostname, and if that fails, performing a reverse lookup on an IP address present on the system.

> If no port is specified in the ServerName, then the server will use the port from the incoming request. For optimal reliability and predictability, you should specify an explicit hostname and port using the ServerName directive.

> If you are using name-based virtual hosts, the ServerName inside a <VirtualHost> section specifies what hostname must appear in the request's Host: header to match this virtual host.

> Sometimes, the server runs behind a device that processes SSL, such as a reverse proxy, load balancer or SSL offload appliance. When this is the case, specify the https:// scheme and the port number to which the clients connect in the ServerName directive to make sure that the server generates the correct self-referential URLs. 

ServerNameが指定されていない場合について、今回は関係ないので置いておきます。

port番号が指定されていない場合、リクエストを受け付けたポート番号が利用されます。
本稿の構成では、この指定が無い場合に8443が利用されることとなります。

name-basedなvirtual hostとして定義していますので、ServerNameに定義したドメイン名は、HTTPリクエストヘッダの「Host:」との照合に使われます。HTTPリクエストヘッダに一致したVirtualHostディレクティブの定義が利用されます。
ここで定義した値は、Reverse Proxyの挙動に関係してきます。

4パラグラフ目が一番重要です。この記載の通りに、「https://」スキームとport番号（この場合は443）を指定しないと、恐らくWebアプリはまともに動かないでしょう。


# 要素技術のポイント
## SSL offload/termination
技術的には、ApacheにSSLサーバ証明書をインストールし、HTTPS通信を実現することは可能です。
しかしながら昨今、商用サービスを提供するシステムでこのような構成にすることは稀です。

それは大きく下記2つの理由によります。

- SSL/TLSは処理量が多く、負荷が高い
- サーバがスケールアウトすればするほど、SSLサーバ証明書の管理が煩雑になる


上記の課題を解決するため、下記の構成が用いられます。
この構成は、Load Balancer製品によって呼び名は違いますが、一般的にSSL offloadやSSL terminationと呼ばれます。

- SSL/TLSはクライアント⇔Load Balancer間で完結させる。そのためにSSLサーバ証明書とその秘密鍵をLoad Balancerにインストールする
- Load Balancerとサーバ間は通常のHTTP通信を行う


ELBにおけるSSL/TLSの扱いに関する公式ドキュメントは下記になります。

HTTPS Listeners for Your Classic Load Balancer
https://docs.aws.amazon.com/elasticloadbalancing/latest/classic/elb-https-load-balancers.html

## name-basedなvirtual host
virtual hostとは、1台のサーバで複数のWebサイト（＝ドメイン）をホストする技術です。

昨今では仮想サーバ・コンテナの技術が発展してこなれてきたため、積極的にこれらを活用する理由はありませんが、それらの技術が一般的ではなかった時代には良く使われていました。

virtual hostを実現するためには、大きく2つ、IP-basedとname-basedの方式があります。

IP-basedのvirtual hostは、ドメイン名の数だけIPアドレスが必要となります。IPv4アドレスは貴重な資源ですので、特別な理由が無い限りはこの方式を採用することはないでしょう。（HTTPS通信でSNIが使えないケースでvirtual hostを実現する必要がある場合ぐらいだと思われます）

name-basedのvirtual hostは、クライアントのHTTPリクエストの中の「Host:」というヘッダを元に、クライアントに見せるコンテンツを切り替える方式です。
1台のサーバ・1つのIPアドレスで複数ドメインをホストする場合、そのクライアントがどのドメイン宛にアクセスしてきているのか、サーバ側は知る必要があります。
クライアントからのリクエスト内に、そのドメイン名を入れることで、サーバ側はこれを識別することができ、適切にハンドリングすることができるようになります。

HTTPS通信の場合は、SNIと呼ばれる仕組みが利用されます。
詳細は割愛しますが、これはHTTPSネゴシエーション内で、どのドメイン名へのアクセスなのか、クライアントからサーバへ通知する仕組みとなります。

より具体的な仕組みについては下記ドキュメントが参考になります。

Name-based Virtual Host Support - Apache HTTP Server Version 2.4
https://httpd.apache.org/docs/2.4/en/vhosts/name-based.html


## Reverse Proxyとは
クライアントとAPサーバ間に配置するProxyサーバです。
クライアントからのHTTP/HTTPS接続を受け付け、一方でバックエンドサーバにHTTP/HTTPSリクエストを行うサーバとなります。

技術的には、これが無くてもWebサービスの提供は可能です。
機能要件を満たすという意味では、[ELB]→[Tomcat]という構成でも十分に可能です。

では何故、Reverse Proxyが必要になるのでしょうか？
幾つかの観点がありますが、概ね以下の利点があります。

1. 柔軟なURL構成が実現できる
    1. URLのフィルタリング／リダイレクトが容易
    2. 高可用な構成／負荷分散構成の実現が可能
2. HTTPヘッダをきめ細かに制御できる
3. Staticコンテンツをキャッシュし、システムのパフォーマンスを向上させることができる

1つ目のケースとして、あるパスへのアクセスはあるAPサーバ群に振り分ける、別のパスへのアクセスはまた別のAPサーバ群に振り分ける、といった制御が出来ます。ALBで行えることと同じですね。
2つ目のケースとしては、CORSの制御、Cache-Control関連ヘッダの制御などに用いられます。
3つ目の利点は言わずもがな。

上記のようなことは勿論、Reverse Proxyを用いずにTomcat/Javaアプリで実現することは可能だと思います。
しかしながら、それを実現するメリットは全くありません。単にアプリの実装が複雑化し、パフォーマンスが落ちるだけです。

アプリはビジネスロジックの実行に専念し、URLのマッピング・HTTPヘッダの制御という仕事からは分離されるべきです。
そうすることでシステム全体の複雑性が低減し、保守性が向上します。

## Reverse Proxyの重要なポイント
Reverse Proxyサーバの挙動で、一つ重要なことがあります。
それは、**バックエンドサーバが返却するリダイレクトを適切に扱う**、という点です

以下のケースを考えてみましょう。
話をシンプルにするために、単一のサーバでアプリが稼働しているとします。

- 単一サーバ内でApacheとTomcatが稼働している
- Tomcatは127.0.0.1:8080でListenしている
- Apacheは127.0.0.1:8080宛にHTTPリクエストを行う
- 対象サーバにはGlobal IPアドレスが付与されている
- Apacheは当該Global IPアドレスのTCP/80ポートをListenしている
- クライアントからは当該Global IPアドレスにアクセスする

ここでTomcatがリダイレクト（HTTP Status Code 301/302など）を返却するケースを考えます。

Tomcat自体は、クライアントからのリクエストが127.0.0.1:8080宛に行われたと認識していますので、HTTPレスポンスヘッダのLocation:に「http://127.0.0.1:8080/～～」から始まるURLをセットして返却します。
このURLがそのまま、本当のクライアントに返却されても困ります。Internet経由では到達不可能なアドレスです。

そこで、ApacheはこのLocation:ヘッダのURLのうち、ドメイン名・ポート番号部分を書き換えます。
書き換え後の値は、本物のクライアントからのアクセスを受け付けたIPアドレス:ポート番号の組み合わせとなります。
このケースでは、Global IPアドレス:80に書き換えることとなります。
書き換え後のLocation:ヘッダであれば、本物のクライアントも到達可能となります。


# ケーススタディ
## 問題にならないケース
httpから始まるURLにアクセスすると、自動的にhttpsにリダイレクトされるサイトは多いと思います。
以下のような遷移を考えてみましょう。

- http://example.com/ → https://example.com/
- http://example.com/anyurl → https://example.com/anyurl

上記のように、単純にスキームをhttpからhttpsに機械的に変換するのなら簡単です。
これはApacheのmod_redirectで簡単に実現できます。


## 問題となるケース
アプリケーションサーバからリダイレクト（HTTP Status Code 301/302）を返却する場合に考慮が必要です。
トップURLにアクセスすると、自動的にログイン画面に遷移する場合を考えてみましょう。

https://example.com/ → https://example.com/login

上記のようなリダイレクトは一般的に、アプリのフレームワークで行われるでしょう。
Spring BootであればSpring Securityなどが利用されるかと思います。


具体的な動きを、バックエンド側から見ていきましょう。

まず、Apache→Tomcatは、127.0.0.1:8080宛に通信が行われています。
したがって、TomcatはHTTPレスポンスヘッダのLocation:に、「http://127.0.0.1:8080/～～」から始まるURLをセットして返却します。

Apacheはこれを受け取り、ELBへHTTPレスポンスを返却する際に、Location:ヘッダのURLを変換します。
この時、以下のパターンでそれぞれ挙動が変わってくることとなります。

1. ServerNameディレクティブを定義していない（※未検証）
2. ServerNameにてドメイン名のみを定義している
3. ServerNameにてスキーム・ドメイン名・ポート名を定義している

それぞれのケースを具体的にみてみましょう。

### 1. ServerNameディレクティブを定義していない（※未検証）
恐らく問題なく動くパターンですが、この構成を試したことはありません。

ELBからのリクエストを受け付けた際のIPアドレス:ポート番号と置き換えます。
ELBとEC2間は通常、Private IPアドレスで通信します。したがって、変換後のIPアドレスはこのPrivate IPアドレス、ポート番号は8443となります。

ELBはこのHTTPレスポンスを受け取ると、クライアントに返却する前にLocation:ヘッダのURLを変換します。
この先は未確認ですが、恐らくリクエストが行われたドメイン名・ポート番号に変換されるものと思います。

クライアントはこのHTTPレスポンスを受け取り、無事にリダイレクト先にアクセスできることとなります。

Location:ヘッダの遷移を整理すると下記のようになるかと思います。

| 通信経路 | Location:ヘッダ | 備考 |
|:-----------------|:------------------|:-------------------|:-------------------|
| [Apache]<-[Tomcat] | http://127.0.0.1:8080/login～～ ||
| [ELB]<-[Apaceh] | http://10.1.2.3:8443/login～～ |Apacheが稼働しているEC2インスタンスのPrivate IPを10.1.2.3と仮定します。|
| [クライアント]<-[ELB] | https://www.example.com/login～～ ||

### 2. ServerNameにてドメイン名のみを定義している
上手く動かないパターンです。

Location:ヘッダの変換後のURLのドメイン部分にはServerNameに指定されたものが利用され、Port番号・スキームは、リクエスト時のものを利用します。
したがって、変換後のLocation:ヘッダのURLは「http://www.example.com:8443/～～」となります。

ELBはこのHTTPレスポンスを受け取ると、また同様にLocation:ヘッダのURLを変換します。
ドメイン名部分がELBによる変換の範疇外のものになっているため、このままスルーします。
詳細な挙動は不明ですが、ポート番号だけが変換される（というより除去される）ようです。

結果、クライアントには「http://www.example.com/～～」といったURLが返却されます
リダイレクト先URLにアクセスしたところで、http/httpsのスキームが違うためCookieも共用されず、その後の操作を行うことができなくなります。

Location:ヘッダの遷移を整理すると下記のようになります。

| 通信経路 | Location:ヘッダ | 備考 |
|:-----------------|:------------------|:-------------------|:-------------------|
| [Apache]<-[Tomcat] | http://127.0.0.1:8080/login～～ ||
| [ELB]<-[Apaceh] | http://www.example.com:8443/login～～ |ServerNameに指定されたドメイン名を利用|
| [クライアント]<-[ELB] | http://www.example.com/login～～ ||

### 3. ServerNameにてスキーム・ドメイン名・ポート名を定義している
上手く動くパターンです。

Location:ヘッダの変換後のURLのドメイン名部分・ポート番号・スキームにはServerNameに指定されたものが利用されます。
したがって、変換後のLocation:ヘッダのURLは「https://www.example.com/～～」となります。

ELBはこのHTTPレスポンスを受け取りますが、ELBによる変換の範疇外のURLであるため、そのままスルーします。
クライアントはこのリダイレクト先URLにアクセスすることで、その後の操作を継続することができます。

Location:ヘッダの遷移を整理すると下記のようになります。

| 通信経路 | Location:ヘッダ | 備考 |
|:-----------------|:------------------|:-------------------|:-------------------|
| [Apache]<-[Tomcat] | http://127.0.0.1:8080/login～～ ||
| [ELB]<-[Apaceh] | https://www.example.com:443/login～～ |ServerNameに指定されたドメイン名・ポート番号・スキームを利用|
| [クライアント]<-[ELB] | https://www.example.com/login～～ |ポート番号部分は除去される模様。|

# おわりに
HTTPは単純そうに見えて、いくつもの要素が重なると複雑な挙動を示します。
一つ一つを正確にとらえ、理解し、適切な構成を実現していく必要があります。