Docker で HTTPS に対応したリバースプロキシをサクッと作る方法

この記事は [Docker Advent Calendar 2020](https://qiita.com/advent-calendar/2020/docker) の23日目のエントリーです。
ちなみにエントリーしたのは 12/24 でして、カレンダーの23日だけが空いていたので滑り込みました。

さて、今どき Webサイトは HTTPS が当たり前の時代ですよね。
でも HTTPS 対応するのって結構手間がかかるんですよね。お金も。
なんとか簡単に出来ないのかなって、ずっと考えていました。

**WebサービスのHTTPS対応にあたっての困りごと**

- SSL証明証を発行してもらい、定期的に更新していかなくてはならない（この時点でもう面倒くさい）
- WebアプリケーションフレームワークにHTTPSの設定をしないといけない（普段やらないので不慣れでよくわからない）
- もっとアプリケーションの構築作業に集中したい（それが本業だもんね）
- サーバ負荷が高まれば、アプリケーションサーバを複数台にして負荷分散させたい（夢は大きく）
- でも詳しい人がいない（インフラ周りはよく分かんないよね）

こういった課題に対応するための Docker コンテナ型サービスを作ってみました。

## EzGate とは

[EzGate](https://github.com/neogenia-jp/EzGate) を使えば、HTTPS に対応したリバースプロキシを簡単に構築できます。
いわゆる SSL アクセラレータってやつです。

**特長**

- nginx でリバースプロキシ機能を提供
- HTTPS のためのSSL証明書には無料の [Let'sEncrypt](https://letsencrypt.org/ja/) を利用
- 証明書の更新期限が近づくと自動的に更新
- HTTP/2 にも対応
- すでにSSL証明書がある場合や、開発環境のための証明書を個別に指定することも可能
- 中継先サーバが複数台の場合にも対応可能（負荷分散構成）
- ドメインが複数の場合にも対応可能（マルチテナント構成）

## 単純な例

まずは Webサーバが１台だけの最もシンプルな構成で、リバースプロキシを立てる手順を見てみましょう。

**構成図**
![スクリーンショット 2020-12-24 23.24.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/205835/7f459561-469f-b03c-cfe7-3ffffbad977e.png)

Webサーバの IP アドレスは `172.17.0.4` であり、80番ポートで待ち受けしているとしましょう。

この場合は、EzGate コンテナに環境変数を指定するだけでリバースプロキシのコンフィグが完了します。

ここからは Google Compute Engine の無料枠を使って、実際に動かしてみたので手順を紹介します。
なお仮想マシンには事前に docker をインストールしておく必要があります。
OSは Ubuntuです。Cent OS でも同じ手順で試行可能と思います。

```sh
# 疎通確認用のWebサーバとして wordpress コンテナを起動
$ sudo docker run -d --rm --name server1 wordpress:5.6.0-apache

# コンテナの起動確認
$ sudo docker ps
CONTAINER ID        IMAGE                     COMMAND                  CREATED             STATUS              PORTS     NAMES
c0f0d145d86d        wordpress:5.6.0-apache    "docker-entrypoint..."   18 seconds ago      Up 16 seconds       80/tcp    server1

# コンテナのIPアドレスを確認、変数にセット
$ SERVER1_IP=`sudo docker inspect --format '{{ .NetworkSettings.IPAddress }}' server1|tee /dev/stderr`
172.17.0.4

# 残りの設定情報
$ DOMAIN=test.35.197.56.116.sslip.io  # このサーバのドメイン。35.197.56.116 の部分は実際のグローバルIDを指定。
$ MAIL=your@email.com                 # SSL証明書取得に使用するメールアドレス。自分のものに変えてください。

# リバースプロキシを起動
$ sudo docker run -ti -d --name ez-gate -p80:80 -p443:443 \
    -e PROXY_TO=$DOMAIN,$SERVER1_IP:80 \
    -e CERT_EMAIL=$MAIL \
    neogenia/ez-gate:latest

# コンテナログを確認
$ sudo docker logs -f ez-gate
```

コンテナのログがずらずらっと出てきて、以下のようになれば起動成功です。

```
----- finish all setups successfully ----- 
--- START MONIT -----
 * Starting daemon monitor monit                                         [ OK ] 
==> /var/log/monit.log <==
[UTC Dec 24 09:56:08] info     :  New Monit id: f439d27cdf8d377e03482d877b043d2e
 Stored in '/var/lib/monit/id'
[UTC Dec 24 09:56:08] info     : Starting Monit 5.25.1 daemon
[UTC Dec 24 09:56:08] info     : 'c91b02ea822e' Monit 5.25.1 started
[UTC Dec 24 09:56:08] error    : 'crond' process is not running
[UTC Dec 24 09:56:08] info     : 'crond' trying to restart
[UTC Dec 24 09:56:08] info     : 'crond' start: '/etc/init.d/cron start'

==> /var/log/nginx/error.log <==

==> /var/log/nginx/error_test_35_197_56_116_sslip_io.log <==
```

Webブラウザを開き、 `$DOMAIN` で設定したドメインにアクセスしてください。
Wordpress の初期設定画面が表示されたら成功です。
![スクリーンショット 2020-12-24 21.01.28.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/205835/cbec5e4e-5eb9-8fa8-d0f6-ffc8804bfeda.png)

SSL証明書も正しくセットされています。
![スクリーンショット 2020-12-24 21.02.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/205835/c2b5e194-6993-3789-5deb-84927a5c6261.png)

このように、ドメインと中継先を設定するだけであとは **自動的に SSL 証明書を取得してくれて** リバースプロキシとして稼働します。
中継先は Dockerコンテナでなくとも、別のサーバでも、TCPでアクセス可能であれば何でも構いません。

### 環境変数の説明

コンテナ起動コマンドをもう一度見てみましょう。

```sh
# リバースプロキシを起動
$ sudo docker run -ti -d --name ez-gate -p80:80 -p443:443 \
    -e PROXY_TO=$DOMAIN,$SERVER1_IP:80 \
    -e CERT_EMAIL=$MAIL \
    neogenia/ez-gate:latest
```

`PROXY_TO` には、中継するドメインと、中継先サーバをカンマ区切りで設定します。
中継先には `:80` というようにポート番号を指定することも可能です（省略時は `80`）。

`CERT_EMAIL` には、SSL証明書の取得に使用するメールアドレスを設定します。
不正なメールアドレスだと、エラーになる可能性があります。

設定情報として必要なのはたったこれだけです。

### エラーになる場合

1: 以下のようなエラーになる場合

```
Traceback (most recent call last):
        7: from /var/scripts/reload_config.rb:227:in `<main>'
        6: from /var/scripts/reload_config.rb:216:in `backup_dir'
        5: from /var/scripts/reload_config.rb:236:in `block in <main>'
        4: from /var/scripts/reload_config.rb:236:in `each'
        3: from /var/scripts/reload_config.rb:238:in `block (2 levels) in <main>'
        2: from /var/scripts/reload_config.rb:96:in `setup_ssl'
        1: from /var/scripts/reload_config.rb:28:in `setup'
/var/scripts/reload_config.rb:19:in `shell_exec': ## ERROR ## exit status: 1 command_line: 'APP_DOMAIN=test.35.197.56.116.sslip.io LETS_ENCRYPT_CERT_MAIL=your@email.com /var/scripts/setup_letsencrypt.sh' (RuntimeError)
```

設定に誤りがあります。
例えば `CERT_EMAIL` 環境変数に正しいメールアドレスが設定されていない可能性があります。


2: 以下のようなエラーになる場合

```
Failed authorization procedure. test.35.197.56.116.sslip.io (http-01): urn:ietf:params:acme:error:dns :: No valid IP addresses found for test.35.197.56.116.sslip.io
```

Let's Encrypt のサーバ側で名前解決に失敗してる可能性があります。
`sslip.io` はダウンしてるときもあるため、時間をおいてリトライしてみるか、別のフリードメインで試してみてください。
また、ドメインを自分で割り当てることが出来る人は、なにか適当なサブドメインを割り当てるのが一番確実です。


3: 以下のようなエラーになる場合

```
An unexpected error occurred:
There were too many requests of a given type :: Error creating new order :: too many certificates already issued for: nip.io: see https://letsencrypt.org/docs/rate-limits/
```

Let's Encrypt のエラーです。
そのドメインでの SSL証明書発行回数が上限に達しています。
`nip.io` などの人気のあるワイルドカードDNSでは、すでに多くの人がSSL証明書発行を試行しているため、このようなエラーになることが多いです[^1]。

[^1]: 本記事執筆時点では、`sslip.io` ならエラーが出ませんでしたが、この記事を見て試す人が増えてくるとエラーが出る可能性が高まります。

Freenom などのフリードメインを取得して試してみるか、ドメインを自分で割り当てることが出来る人は、なにか適当なサブドメインを割り当てるのが一番確実です。

なお、短時間のうちにあまり何度も証明書発行を繰り返していると Let's Encrypt の回数制限に引っかかってしまってエラーになることもあるので注意が必要です[^2]。

[^2]: 2020年末の時点では、SSL証明書の更新回数は 1アカウント1ホストにつき1時間に5回までとなっています。

また、`ez-gate` コンテナを起動し直す場合は、先にコンテナを停止して `rm` しておく必要があります。

```sh
$ sudo docker kill ez-gate
$ sudo docker rm ez-gate
```

## 開発環境のための証明書を使用する

ローカル開発環境など（グローバルIPで外部からアクセスできない環境）では、Let's Encrypt の証明書発行ができませんので、
[mkcert](https://github.com/FiloSottile/mkcert) を使って、ローカル開発環境のためのSSL証明書を使用することができます。

もう「自己署名入り証明書」（通称：オレオレ証明書）を使わなくて良いんです！

### mkcert のインストール

mkcert のインストールはとても簡単です。
macOS なら Homebrew を使ってインストールできます。

```sh
brew install mkcert

# Firefox ユーザなら以下も必要
brew install nss 
```

詳しくは、[mkcert 公式の README](https://github.com/FiloSottile/mkcert#installation) を参照してください。

### mkcert で証明書を作成

開発環境のURLが `https://localhost/` の場合で説明します。
他のIPアドレスでアクセスしたい場合は、 `localhost` の部分をIPアドレスに読み替えてください。

```sh
# 証明書格納用フォルダを作成
$ mkdir certs

# mkcertを使って localhost 向けの証明書ファイルを生成
$ mkcert -install  # 初回のみ
$ mkcert -key-file certs/key.pem -cert-file certs/cert.pem localhost
```

#### リバースプロキシに証明書を指定して起動

```sh
# 証明書格納用フォルダをボリュームマウントし、環境変数でそれらのファイルを指定
$ sudo docker run -ti -d --name ez-gate -p80:80 -p443:443 \
    -e PROXY_TO=localhost,$SERVER1_IP:80 \
    -e CERT_FILE=/mnt/cert.pem \
    -e KEY_FILE=/mnt/key.pem \
    -v `pwd`/certs:/mnt \
    neogenia/ez-gate:latest

# コンテナログを確認
$ sudo docker logs -f ezgat
```

正常に起動できたら、Webブラウザを開き `https://localhost/` にアクセスしてください。
SSL証明書のエラーが出なければ成功です。

### 環境変数の説明

`CERT_FILE` には、mkcert で生成された cert-file のパスを指定します（コンテナ内でのパス）。
`KEY_FILE` には、mkcert で生成された key-file のパスを指定します（コンテナ内でのパス）。

上記の環境変数を指定することで、Let's Encrypt のSSL証明書取得は行われなくなり、指定された証明書を使って HTTPS リバースプロキシが稼働します。

## サーバ複数台構成の場合（負荷分散）

中継先サーバが複数台の場合は、環境変数で指定する方法がないため、設定ファイルを書く必要があります。

**構成図**
![スクリーンショット 2020-12-25 0.37.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/205835/8094a463-83b5-b94f-ec26-72f306205396.png)

では再び、GCEで実際に試してみた例を紹介します。

```sh
# Webサーバとして wordpress コンテナを２つ起動 (本当はセッションやDBを共有させてクラスタ構成にする必要がある)
$ sudo docker run -d --rm --name server1 wordpress:5.6.0-apache
$ sudo docker run -d --rm --name server2 wordpress:5.6.0-apache

# コンテナの起動確認
$ sudo docker ps
CONTAINER ID        IMAGE                    COMMAND                  CREATED             STATUS        PORTS     NAMES
a8c41d95a57d        wordpress:5.6.0-apache   "docker-entrypoint..."   2 minutes ago       Up 2 minutes  80/tcp    server2
c0f0d145d86d        wordpress:5.6.0-apache   "docker-entrypoint..."   3 hours ago         Up 3 hours    80/tcp    server1

# それぞれのコンテナのIPアドレスを確認
$ sudo docker inspect --format '{{ .NetworkSettings.IPAddress }}' server1
172.17.0.4
$ sudo docker inspect --format '{{ .NetworkSettings.IPAddress }}' server2
172.17.0.5

# 設定ファイルを新規作成しエディタで開く
$ mkdir mnt
$ vi mnt/config 

# 以下の内容を書く
domain('test2.35.197.56.116.sslip.io') {
  proxy_to "172.17.0.4", "172.17.0.5"    # 中継先サーバ。カンマ区切りで複数指定可能
  cert_email 'your@email.com'            # SSL証明書取得に使用するメールアドレス。自分のものに変えてください。
}

# 書き終わったらエディタを終了

# コンフィグファイルを指定してリバースプロキシを起動
$ sudo docker run -ti -d --name ez-gate -p80:80 -p443:443 \
    -v `pwd`/mnt:/mnt \
    -e CONFIG_PATH=/mnt/config \
    neogenia/ez-gate:latest

# コンテナログを確認
$ sudo docker logs -f ez-gate
```

正常に起動できたら、Webブラウザを開いてアクセスしてみてください。
負荷分散されていることを確認するため、Webサーバ側のログを開いておきましょう。

```sh
# server1 のログを監視
$ sudo docker logs -f server1

# 別のターミナルを開いて server2 のログも監視
$ sudo docker logs -f server2
```

この状態でブラウザリロードすると、双方のサーバへアクセスが来ることが確認できます。

## 複数ドメインでのマルチテナント型

更に複雑な構成として、ドメインを複数割り当てて、それぞれに中継先を設定することが出来ます。

例えば、`www.example.com` でアクセスされたら `www` というサーバに中継し、
`redmine.example.com` でアクセスされたら `redmine` というサーバに中継する、
といったようなイメージです。
いわゆるマルチテナント（マルチドメイン）構成です。

**構成図**
![スクリーンショット 2020-12-25 0.34.20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/205835/beb8f434-3304-1f4c-ec2e-3ef18eee8f86.png)


### 設定ファイルの例

以下のようにドメインごとに中継先を記述するだけです。

```ruby
domain('www.example.com') {
  proxy_to "www"                # 中継先サーバリスト。名前解決できればIPでなくとも良い
  cert_email 'your@email.com'   # SSL証明書取得に使用するメールアドレス
}

domain('redmine.example.com') {
  proxy_to "redmine"            # 中継先サーバリスト。名前解決できればIPでなくとも良い
  cert_email 'your@email.com'   # SSL証明書取得に使用するメールアドレス
}
```

## 設定ファイルの書き方

ここからは [公式README](https://github.com/neogenia-jp/EzGate/blob/master/README.ja.md#%E8%A8%AD%E5%AE%9A%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB%E3%81%AE%E6%9B%B8%E3%81%8D%E6%96%B9) からの引用です。

設定ファイルの基本構文は以下です。

```ruby
domain('www.example.com') {
  proxy_to "webapp1", "webapp2", ...
}
```

複数の `domain()` を記述することが可能です。

さらに、以下のように `cert_email` `nginx_config` のオプション指定が可能です。

```ruby
domain('www2.example.com') {
  proxy_to "apache1", "apache2"
  cert_email 'your@email.com'

  nginx_config <<~_CONFIG_
    # change upload size max
    client_max_body_size 100M;
  _CONFIG_
}
```

`cert_email` はコンテナの環境変数 `CERT_EMAIL` の指定があればそちらが優先されます。
nginx の設定ファイルに追記したい内容があれば、`nginx_config` に指定してください。

この設定ファイルは Ruby の内部DSLであり、プログラムとして解釈されますので、別ファイルの読み込みや環境変数の参照なども可能です。

## 更に高度な設定
※2022-06-24 追記

### アクセス元IPによって中継先を切り替える

複数サーバでの負荷分散のためにリバースプロキシを使用する場合、ある特定のサーバだけを切り離して検証したい場合があります。 そういった利用シーンを想定し、EzGateではある特定のPCからアクセスした場合のみ、切り離したサーバに中継させることが出来ます。

まず、アプリケーションサーバを２台で負荷分散する場合、以下のように `proxy_to` にカンマ区切りで中継先を指定します。

```ruby
domain('myservice.example.com') {
  # アプリケーションサーバ2台で負荷分散
  proxy_to 'apserver1', 'apserver2'

  cert_file '/mnt/cert.pem'
  key_file '/mnt/key.pem'
}
```

ここで、`apserver1` をメンテナンスのために切り離し、自社のネットワークのグローバルIPからアクセスした時だけ `apserver1` につながるようにしたい場合は、以下のように `proxy_to` のオプション引数 `from:` を指定します。

```ruby
domain('myservice.example.com') {
  # アクセス元IPアドレスが '11.22.33.44' の時だけ、`apserver1` へ中継
  proxy_to 'apserver1', from: '11.22.33.44'
  # それ以外は `apserver2` へ中継
  proxy_to 'apserver2', from: :all   # `from: :all` は省略可能
}
```

[GitHub リポジトリの example3/ ディレクトリ](https://github.com/neogenia-jp/EzGate/blob/master/example3) にサンプルが入っています。

### リダイレクト

中継させるのではなくリダイレクト応答させることも出来ます[^redirect]。
`proxy_to` の代わりに `redirect_to` でリダイレクト先を設定するだけです。
ドメインの移行時などに便利です。
[^redirect]: コンテナイメージ `neogenia/ez-gate:20220621` 以降のバージョンが必要です。

```ruby
domain('old.example.com') {
  redirect_to "new.example.com"  # 新しいドメインにリダイレクト
  cert_email 'your@email.com'
}
```

[GitHub リポジトリの example4/ ディレクトリ](https://github.com/neogenia-jp/EzGate/blob/master/example4) にサンプルが入っています。

### ロケーションごとに中継先を切り替える

ある特定のパスにアクセスされた時だけ、中継先を切り替えることが出来ます。

例えば、 通常のアクセスは `webapp1` サーバに中継し、 `/map_api` にアクセスされた時だけ `webapp2` サーバに中継する、といった設定が簡単に出来ます。
ドメイン内でパスごとに複数のサーバで運用することが出来るので、既存サイトに別システムを付け加えたり、システムの五月雨式な移行を行う際に便利です。

```ruby
SERVER_IP = '192.168.11.22'

domain("#{SERVER_IP}.nip.io") {
  # デフォルトの中継先
  proxy_to 'webapp1'

  # 特定のパスにアクセスされた時だけ、別サーバに中継する
  location('/map_api') {
    proxy_to 'webapp2'
  }

  cert_file '/mnt/cert.pem'
  key_file '/mnt/key.pem'
}
```

`location() { }` で囲って `proxy_to` を指定することにより、特定のパスだけに絞って中継先を上書きすることが出来ます。 また、`location() { }` の中では、`nginx_config` を指定することも出来ます。
`location` で指定可能なロケーションは、 nginx の `location ディレクティブ` と同じです。

例えば `location('~* \.(gif|jpg|jpeg)$') { }` と書いた場合、`nginx` の設定ファイルには以下のように展開されます。
```
location ~* \.(gif|jpg|jpeg)$ {
}
```
[GitHubリポジトリの example5/ ディレクトリ](https://github.com/neogenia-jp/EzGate/blob/master/example5) にサンプルが入っています。

## 設定ファイルのリロード

設定ファイルを変更した場合、以下のようにリロードコマンドを実行することで リバースプロキシを停止することなく設定ファイルの内容を反映させることが出来ます。

```sh
docker exec -ti ez-gate /var/scripts/reload_config.rb
```

もし設定ファイルにエラーがあった場合は、リロードは失敗し nginx のコンフィグは書き換えられず、動き続けますので安心です。

## docker-compose での利用

実際の開発現場では docker 単体ではなく docker-compose などの構成管理ツールを使うケースが多いでしょうから、構成設定ファイルに環境変数を定義しておくことでこれまでのサンプルと同等の設定が出来ます。
docker-compose での YAMLファイルのサンプルは [公式のREADME](https://github.com/neogenia-jp/EzGate/blob/master/README.ja.md#%E4%BE%8B2) にあります。

EzGateコンテナの上げ下げのたびに 証明書取得が行われてしまうという問題がありますので、証明書ファイルが保存されるディレクトリを Docker volume に割り当てて永続化する方法があります。
`/etc/letsencrypt` を volume に割り当てればオッケーです。

# まとめ

今回は自社での開発現場での困りごとから生み出されたインフラ支援サービスとも言えるようなサービスを紹介しました。

実際に弊社では、このようなマルチテナント構成で 会社のWebサイト、Redmine、Mattermost などをコンテナで起動しておき、
それぞれのドメインを割り当て、１つのサーバに集約してホスティングしています。

他にも、弊社の開発現場では汎用性の高いものは機能単位で切り出して流用性を高め、
マイクロサービスとして使えるように整備していくような取り込みをしています。

[EzGate](https://github.com/neogenia-jp/EzGate) はオープンソースとして開発しています。
いくつかのプロジェクトでの稼働実績もあり、とても良いプロダクトなのですが、
ドキュメントが少なく、まだまだ世間一般に広まるには整備しないといけないことが沢山残っていますが、
もし興味がありましたら使ってみてください。issue や pull request も大歓迎です。

現場からは以上です。