nginx でコンテキストパス未対応サービスをリバースプロキシさせてみた(proxy_redirect + sub_filter)

# 要約

* proxy_redirect ディレクティブでレスポンス中の絶対パスは書き換えられない
* proxy_redirect の書き方はプロキシ転送先のサービス次第
* sub_filter ディレクティブで絶対パスを書き換える
* sub_filter ディレクティブを効かすには Accept-Encoding をキャンセルする

# 背景

コンテキストパスなしのWebサービスは多い。そもそも、コンテキストパス非対応のWebアプリも少なくない。（メジャーなWebアプリ以外は非対応と思った方がいい。）
社内だとサービスごとにWebサーバーが構築され、次のいずれかのURL形式でサービス提供されていることも多いはず。

| ID | URL形式 | 説明 |
|----|----|----|
| (A) | `http(s)://host-name/` | ホスト名のままでサービス提供
| (B) | `http(s)://host-name:9000/` | ポート番号を変えて1サーバーで複数サービスを提供
| (C) | `http://service-cname/` | 社内 DNS に CNAME レコードを登録してサービス提供

仮想化が当たり前の昨今だが、上記 (A) のようにサービスごとにVMを構築するのは無駄が多い。そこで、docker エンジンを導入してコンテナで各サービスを提供したりするだろう。
その場合、(B) が一番簡単。SSL証明書も1つで済む。でも、ポート番号が増えると覚えられないし、セキュリティ面から利用するポート番号は最小限に制限しておきたい。
(C) は許可するポート番号を増やさずに済むが、ネットワーク管理者にDNSレコードを登録してもらう手間が掛かるし、SSL対応も面倒。

そこで、リバースプロキシを導入して `https://reverse-proxy.your-domain.com/context-path/` のようにコンテキストパスで複数サービスを提供したくなる。利用するポート番号は増えず、SSL証明書も1つで済む。

このように、リバースプロキシとコンテキストパスを併用したWebサービス提供が理想形なのだが、そのリバースプロキシ設定にはいろいろな技が必要になる。

# よくある課題

リバースプロキシには nginx の出番となるが、ネットの情報を頼りに `location` と `proxy_path` ディレクティブを設定しても、それだけではうまくいかない場合が多い。
よくある問題現象として、css スタイルが適用されていない文字だけの html ページが表示されてしまったりする。

なお、今回は `location` と `proxy_path`、そして SSL 証明書関連の設定はクリアできていることを前提としている。そこまでの設定は、ネットの情報も多いのでどうにかクリアできるはず。

## 問題原因

問題の主な原因は次の2つ。

* レスポンスの HTML に絶対パスのURLが含まれていること
* リダイレクトの `Location` がずれてしまうこと

リクエスト元のクライアントからはコンテキストパス付きURLでアクセスするが、リバースプロキシ先のWebサービスからのレスポンスでは絶対パスのURLにコンテキストパスが付かない。そのため、クライアントは最初のレスポンス(index.htmlなど)は取得できても、レスポンスに記述されている css ファイルなどを追加取得しようとすると 404 エラーとなってしまう。
だからと言って、HTML ファイルに記述するURLをすべて相対パスにするのは無理だ。css ファイルは `href="/css/styles.css"` のように絶対パスで記述してスタイルを統一するのが定石だろう。 

また、Webアプリの場合、ログイン時にリダイレクトが使用されたりするが、リダイレクトを指示するレスポンスでは、`Location` や `Refresh` に絶対パスが記述されていることが多い。
この `Location` と `Refresh` の書き換えには、nginx の `proxy_redirect` ディレクティブで対処できるのだが、絶対パスの値がWebアプリによって異なるためその値に応じて合わせこみが必要になる。

## 対策

### リバースプロキシ先のWebアプリがコンテキストパス対応の場合

* リバースプロキシ先のWebアプリがコンテキストパス対応の場合、Webアプリをコンテキストパス付きでセットアップし、リバースプロキシ側でも同じコンテキストパスを利用する。

    コンテキストパスが同じであれば、`proxy_path` ディレクティブと `proxy_redirect default;` の一行で概ね課題は解消できる。

```
location /some-service/ {
    proxy_pass          http://192.168.1.10/some-service/;
    proxy_redirect      default;

    ...
}
```

* あとは、`proxy_set_header` ディレクティブでプロキシ転送時に必要なヘッダー情報を加えておく。

```
    proxy_set_header    Upgrade             $http_upgrade;
    proxy_set_header    Connection          $connection_upgrade;
    proxy_set_header    Host                $host;
    proxy_set_header    X-Real-IP           $remote_addr;
    proxy_set_header    X-Forwarded-For     $proxy_add_x_forwarded_for;
    proxy_set_header    X-Forwarded-Proto   $scheme;
    proxy_set_header    X-Forwarded-Host    $http_host;
    proxy_set_header    X-Forwarded-Port    $server_port;
```

* なお、コンテキストパス対応のWebアプリであっても、運用途中でコンテキストパス付きに切り替えることは困難なため、その場合はコンテキストパス非対応の場合と同様に対応する。

### コンテキストパス非対応の場合

* `proxy_redirect` ディレクティブではレスポンスヘッダーの `Location` と `Refresh` しか書き換えられない。レスポンス中の絶対パスは `sub_filter` ディレクティブを利用して書き換える。
* 書き換え箇所は `http(s)://host-name/` と `/` から始まる両方の絶対パス。
* `http(s)://host-name/` から始まる絶対パスは `https://reverse-proxy.your-domain.com/context-path/` に書き換える。

```
    sub_filter  'http://192.168.1.10/' '$scheme://$host/some-service/';
    sub_filter  'http:\/\/192.168.1.10\/' '$scheme:\/\/$host\/some-service\/';
```

* `/` から始まる絶対パスは `href`, `src`, `action`, `url` の箇所を対象に書き換える

```
    sub_filter  'href="/' 'href="/some-service/';
    sub_filter  'src="/' 'src="/some-service/';
    sub_filter  'action="/' 'action="/some-service/';
    sub_filter  'url("/' 'url("/some-service/';
```

* Javascript 中では `\/\/hostname\/` のようにエスケープ表記されているパスも書き換える。

```
    sub_filter  'http:\/\/192.168.1.10\/' '$scheme:\/\/$host\/some-service\/';
```

* 絶対パスの `http(s)://host-name/` の部分はWebアプリがプロキシ転送ヘッダ情報(`X-Forwarded-Host`, `X-Forwarded-Port`, `X-Forwarded-Proto`)を解釈するか否かによって変わってくる。そのため、`proxy_set_header` ディレクティブでプロキシ転送ヘッダ情報を設定したうえで、レスポンスの中身を確認しながら絶対パスを書き換える。

    書き換え対象の絶対パスを把握するには、一時的に `proxy_redirect off;` を記述して書き換え前の値を現物確認するとよい。

```
    # proxy_redirect    off;                                    # 書き換え対象のレスポンス確認用
    # proxy_redirect    $scheme://$host/ /some-service/;          # Webアプリのレスポンスに応じて追加
    proxy_redirect      / /some-service/;
    proxy_redirect      default;

    proxy_set_header    Upgrade             $http_upgrade;
    proxy_set_header    Connection          $connection_upgrade;
    proxy_set_header    Host                $host;
    proxy_set_header    X-Real-IP           $remote_addr;
    proxy_set_header    X-Forwarded-For     $proxy_add_x_forwarded_for;
    proxy_set_header    X-Forwarded-Proto   $scheme;
    proxy_set_header    X-Forwarded-Host    $http_host;
    proxy_set_header    X-Forwarded-Port    $server_port;
```

* `sub_filter` ディレクティブによる書き換えが複数箇所に適用されるように次の設定を加える。

```
    sub_filter_once off;
````

* レスポンスが gzip 圧縮されていると `sub_filter` ディレクティブで書き換えできないため、リクエストを転送時に `proxy_set_header Accept-Encoding "";` を加えてレスポンスを非圧縮で取得する。

```
    proxy_set_header    Accept-Encoding     "";
```

* リバースプロキシからクライアントに転送するレスポンスを gzip 圧縮するためには、`gzip on;` ほかの設定を加える。

```
    gzip            on;
    gzip_vary       on;
    gzip_min_length 1000;
    gzip_types      text/css text/javascript application/javascript application/xml application/json;
```

# 設定例

```conf
location /some-service/ {
    set $context "/some-service/";

    # See http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_pass
    proxy_pass          http://192.168.1.10/;

    # See http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_redirect
    # proxy_redirect    $scheme://$host/    $context;       # Webアプリのレスポンスに応じて追加
    proxy_redirect      / $context;
    proxy_redirect      default;
    # proxy_redirect    http://192.168.1.10/ $context;      # default と同義

    proxy_set_header    Upgrade             $http_upgrade;
    proxy_set_header    Connection          $connection_upgrade;
    # See https://qiita.com/no1zy_sec/items/dfccd91cf76bf73b7754
    proxy_set_header    Host                $host;
    proxy_set_header    X-Real-IP           $remote_addr;
    proxy_set_header    X-Forwarded-For     $proxy_add_x_forwarded_for;
    proxy_set_header    X-Forwarded-Proto   $scheme;
    proxy_set_header    X-Forwarded-Host    $http_host;
    proxy_set_header    X-Forwarded-Port    $server_port;
    proxy_set_header    Accept-Encoding     "";             # sub_filter のためにレスポンス非圧縮

    # See http://nginx.org/en/docs/http/ngx_http_sub_module.html
    sub_filter  'href="/' 'href="$context';
    sub_filter  'src="/' 'src="$context';
    sub_filter  'url("/' 'url("$context';
    sub_filter  'action="/' 'action="$context';
    sub_filter  'href=\'/' 'href=\'$context';
    sub_filter  'src=\'/' 'src=\'$context';
    sub_filter  'url(\'/' 'url(\'$context';
    sub_filter  'action=\'/' 'action=\'$context';
    sub_filter  'http://$proxy_host/' '$scheme://$host$context';
    sub_filter  'http:\/\/$proxy_host\/' '$scheme:\/\/$host\/some-service\/';
    # sub_filter  '//$host/' '//$host$context';             # Webアプリのレスポンスに応じて選択
    # sub_filter  '\/\/$host\/' '\/\/$host\/some-service\/';
    sub_filter_once off;

    # See https://qiita.com/cubicdaiya/items/2763ba2240476ab1d9dd
    # See also http://nginx.org/en/docs/http/ngx_http_gzip_module.html
    gzip            on;
    gzip_vary       on;
    gzip_min_length 1000;
    gzip_types      text/css text/javascript application/javascript application/xml application/json;
}
```

# 参考

* http://nginx.org/en/docs/http/ngx_http_proxy_module.html#proxy_redirect
* http://nginx.org/en/docs/http/ngx_http_sub_module.html#sub_filter
* https://stackoverflow.com/questions/31893211/http-sub-module-sub-filter-of-nginx-and-reverse-proxy-not-working
* https://qiita.com/no1zy_sec/items/dfccd91cf76bf73b7754
* https://qiita.com/cubicdaiya/items/2763ba2240476ab1d9dd

■