🐳

リバースプロキシをDocker Compose環境で実現する

2021/12/01に公開

この記事は、 GAOGAO Advent Calendar 2021 ことしもGAOGAOまつりです の 1日目の記事として公開されています。

こんにちは。 GAOGAO にてスタートアップスタジオのエンジニアをしております @mass-min と申します。 GAOGAO では秀吉と呼ばれています。どうぞよろしくお願いいたします。

結論

リバースプロキシ環境の構築は nginx-proxy というとても便利な Docker image によりカンタンに行うことができます。
また Docker 自体の設定をうまく使うことにより、別環境間での API 通信や CORS が絡む AJAX コールなんかも再現できます。圧倒的感謝 🙏

まえがき

例えば、アプリケーション A とアプリケーション B が以下のような設定で動いているとします。
example environment

AWS を本番環境で使用している場合、上記のような振り分けは Elastic Load Balancing(ELB) の中の1つである Application Load Balancer(ALB) を使って行うケースが多いかと思います。これは L7(アプリケーションレイヤー)リバースプロキシに相当します。
私は AWS 以外のパブリッククラウドは現時点では触ったことがなく知見がありませんが、GCP や Azure にも同じような機能はあるはずです。

ここで、ローカル環境で api.example.com に生えている API を www.example.com から叩くテストをするにはどうしたらいいのでしょうか?つまり、「このリバースプロキシをローカルにも構築し、複数環境間の疎通確認をするにはどうしたらよいのか?」 ということです。

実は Microsoft のソフトウェア開発者である Jason Wilder さんをはじめとする数々の OSS コントリビューター達が、 nginx-proxy というとても便利な Docker image を開発しています。この Docker imageを使うことにより、とてもカンタンにリバースプロキシ環境が構築できます。

ここでは上記のような構成を Docker Compose 環境で再現していきます。

今回構築する環境

今回は簡単のため、以下のようにアプリケーション部分はすべて Nginx のみの構成とします。サンプルコードは Nginx 止まりですが、必要に応じて Nginx を Apache に変えたり、 Laravel や Rails への接続をしたりしていただければ最初に提示した環境が完コピできます。

local environment

リポジトリ構成の概要は以下です。今回はサンプルコードのためモノレポ構成にしていますが、もちろん appA、appB ディレクトリは別リポジトリとして切る形でも構いません。

repository

なお、最終形のコードは GitHub リポジトリ に上げています。適宜ご活用ください。

ベースの構築

今回使用する nginx-proxy は jwilder/nginx-proxy として DockerHub 上で公開されていますので、こちらを使用します。
空のディレクトリ reverse-proxy-sample を作成し、まずは下記のように docker-compose.yml を記述します。

$ mkdir reverse-proxy-sample
$ vi docker-compose.yml
docker-compose.yml
version: '3'

services:
  nginx-proxy:
    image: jwilder/nginx-proxy:latest
    ports:
      - '80:80'
    volumes:
      - /var/run/docker.sock:/tmp/docker.sock:ro

networks:
  default:
    name: sample-network

早速 docker-compose up してみましょう。Docker コンテナを起動しステータスを確認してみます。

$ docker-compose up -d && docker-compose ps
[+] Running 2/2
 ⠿ Network sample-network                        Created                                                           0.0s
 ⠿ Container reverse-proxy-sample-nginx-proxy-1  Started                                                           0.4s
NAME                                 COMMAND                  SERVICE             STATUS              PORTS
reverse-proxy-sample-nginx-proxy-1   "/app/docker-entrypo…"   nginx-proxy         running             0.0.0.0:80->80/tcp

ちゃんと起動できました。これをベースとして、上記環境を構築していきます。今回の記事内では、このベースに加えて環境 A、環境 B と合計3つの Docker Compose 環境が同時に動きます。

ドメインレベルで完全に振り分けができる部分の構築

まずはドメインレベルでの振り分けで済む部分の構築です。今回の環境のうち、環境 A に向く admin.example.localapi.example.local、環境 B に向く nice.example.local がこれに相当します。

環境 A の構築

まずは環境 A の構築です。

$ mkdir applicationA
$ vi applicationA/docker-compose.yml
applicationA/docker-compose.yml
version: '3'

services:
  nginx-a:
    image: nginx:latest
    environment:
      VIRTUAL_HOST: 'admin.example.local,api.example.local'

networks:
  default:
    external: true
    name: sample-network

まず、networks ではベースの docker-compose.yml で指定したネットワークを default として指定します。networks の指定がない場合、または external の指定がない or false の場合は docker-compose up を実行するとネットワークが作成されますが、ここではすでに作成されているネットワークに所属させるため external: true の指定を入れます。
Nginx コンテナには環境変数として VIRTUAL_HOST='admin.example.local,api.example.local' を設定します。README の Usage に記述がある通り、VIRTUAL_HOST にホスト名を記述すると、このホスト名でのアクセスを nginx-proxy コンテナが自動的に振り分けてくれます。環境 A のように指定したいホスト名が複数ある場合は、カンマ区切りで値を記述します。(Multiple Hosts 参照)
またこのとき、環境 A、B ともに Nginx コンテナにポートフォワーディング設定をする必要はありません。したがって、 Nginx コンテナの複数立ち上げによって 80 番や 443 番のポートがバッティングする心配もありません。Docker ネットワーク内でのコンテナ間アクセスも nginx-proxy コンテナが自動で行ってくれます。

ここで、あらかじめローカル環境にホスト名指定でアクセスできるよう設定を済ませておきましょう。

sudo vi /etc/hosts

以下を /etc/hosts に記述

/etc/hosts
127.0.0.1 www.example.local
127.0.0.1 admin.example.local
127.0.0.1 api.example.local
127.0.0.1 nice.example.local

これで www.example.localadmin.example.localapi.example.localnice.example.local にアクセスした際はローカル環境に向くようになりました。

環境 A の Nginx コンテナを起動する前に http://admin.example.local にアクセスすると、503が返ってきます。

image

では Docker コンテナを起動して、アクセスができるか確認しましょう。

$ cd applicationA
$ docker-compose up -d && docker-compose ps
[+] Running 1/1
 ⠿ Container applicationa-nginx-a-1  Started                                                                         0.4s
NAME                     COMMAND                  SERVICE             STATUS              PORTS
applicationa-nginx-a-1   "/docker-entrypoint.…"   nginx               running             80/tcp

image

無事にアクセスできました。
http://api.example.local も環境 A に向けていますので、アクセスができるはずです。確認してみましょう。

image

http://nice.example.local は環境 B を向く予定でまだ設定をしていません。したがってこの時点ではアクセスができないはずです。確認してみましょう。

image

正しい挙動になっていますね。

環境 B の構築

では環境 B についても構築を進めましょう。

$ cd ..
$ mkdir applicationB
$ vi applicationB/docker-compose.yml
applicationB/docker-compose.yml
version: '3'

services:
  nginx-b:
    image: nginx:latest
    environment:
      VIRTUAL_HOST: 'nice.example.local'

networks:
  default:
    external: true
    name: sample-network

Docker コンテナを起動して、アクセスができるか確認します。

$ cd applicationB
$ docker-compose up -d && docker-compose ps
[+] Running 1/1
 ⠿ Container applicationb-nginx-b-1  Started                                                                         0.6s
NAME                     COMMAND                  SERVICE             STATUS              PORTS
applicationb-nginx-b-1   "/docker-entrypoint.…"   nginx               running             80/tcp

image

無事にアクセスができるようになりました。

見分けがつくようにする

今後どちらの環境の Nginx にアクセスが届いているのかを分かりやすくするため、 Nginx が表示する index.html の内容を書き換えましょう。
まずは環境 A について、新しく index.html を作成します。

$ cd ..
$ cd applicationA
$ vi index.html
index.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Application A</title>
</head>
<body>
<h1>Application A</h1>
</body>
</html>

この index.html を表示するよう、docker-compose.yml を書き換えます。

$ vi docker-compose.yml
applicationA/docker-compose.yml
version: '3'

services:
  nginx-a:
    image: nginx:latest
    volumes:                                          # <- ここを追加
      - ./index.html:/usr/share/nginx/html/index.html # <- ここを追加
    environment:
      VIRTUAL_HOST: 'admin.example.local,api.example.local'

networks:
  default:
    external: true
    name: sample-network

書き換え後、環境 A の Docker コンテナを再起動し、アクセス確認をします。docker-compose restart だと docker-compose.yml の内容書き換えが反映されないので、一度 stop してから up するようにしましょう。

$ docker-compose stop && docker-compose up -d

image

無事表示されました。環境 B についても同様 HTML を書き換えます。

$ cd ..
$ cd applicationB
$ vi index.html
index.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Application B</title>
</head>
<body>
<h1>Application B</h1>
</body>
</html>

この index.html を表示するよう、docker-compose.yml を書き換えます。

$ vi docker-compose.yml
applicationB/docker-compose.yml
version: '3'

services:
  nginx-b:
    image: nginx:latest
    volumes:                                          # <- ここを追加
      - ./index.html:/usr/share/nginx/html/index.html # <- ここを追加
    environment:
      VIRTUAL_HOST: 'nice.example.local'

networks:
  default:
    external: true
    name: sample-network

書き換え後、環境 B の Docker コンテナを再起動し、環境 B に向いているはずの http://nice.example.local にアクセスし確認します。

$ docker-compose stop && docker-compose up -d

image

これでどちらの環境の Nginx コンテナにアクセスしているか分かるようになりました。

パスレベルで振り分けされている部分の構築

次はパスレベルでの振り分け部分の構築です。今回の環境のうち、主に環境 A に向く www.example.local と、そのうち環境 B に向く www.example.local/nicepage.html がこれに相当します。

環境 A の構築

まずは環境 A から構築を行います。
環境 A の docker-compose.yml に、ホスト名として www.example.local を追記します。

applicationA/docker-compose.yml
version: '3'

services:
  nginx-a:
    image: nginx:latest
    volumes:
      - ./index.html:/usr/share/nginx/html/index.html
    environment:
      VIRTUAL_HOST: 'www.example.local,admin.example.local,api.example.local' # <- www.example.local を追記

networks:
  default:
    external: true
    name: sample-network

この状態で環境 A の Docker コンテナを再起動し、 http://www.example.local にアクセスしてみましょう。

image

環境 A に向けてアクセスができました。
では、両環境に nicepage.html を追加してみます。

applicationA/nicepage.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>A's NICE PAGE</title>
</head>
<body>
<h1>A's NICE PAGE</h1>
</body>
</html>
applicationA/docker-compose.yml
version: '3'

services:
  nginx-a:
    image: nginx:latest
    volumes:
      - ./index.html:/usr/share/nginx/html/index.html
      - ./nicepage.html:/usr/share/nginx/html/nicepage.html # <- ここを追加
    environment:
      VIRTUAL_HOST: 'www.example.local,admin.example.local,api.example.local'

networks:
  default:
    external: true
    name: sample-network
applicationB/nicepage.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>B's NICE PAGE</title>
</head>
<body>
<h1>B's NICE PAGE</h1>
</body>
</html>
applicationB/docker-compose.yml
version: '3'

services:
  nginx-b:
    image: nginx:latest
    volumes:
      - ./index.html:/usr/share/nginx/html/index.html
      - ./nicepage.html:/usr/share/nginx/html/nicepage.html # <- ここを追加
    environment:
      VIRTUAL_HOST: 'nice.example.local'

networks:
  default:
    external: true
    name: sample-network

もちろんこのとき http://www.example.local/nicepage.html にアクセスしても、まだ設定を行っていないので環境 A へのアクセスとなります。試しに両環境 Docker コンテナを再起動したのち http://www.example.local/nicepage.html にアクセスしてみましょう。

環境 A へのアクセスがなされています。

一部環境 B ヘ向くよう設定

では http://www.example.local/nicepage.html へのアクセスが、ドメインはそのままの状態で環境 B へと向くよう設定していきます。
nginx-proxy のベース設定としては、www.example.local へのアクセスはすべて環境 A へ向くようになっています。これを特定のパスについて向き先を書き換えるため、nginx-proxy コンテナに対し Nginx の config を追加します。
nginx-proxy README の Custom Nginx Configuration の項にもあるとおり、以下のように特定の向き先に変更したいパスを含むホスト名(ここでは www.example.local)で Nginx の config ファイルを作成します。

www.example.local
location /nicepage.html {
    proxy_pass http://nice.example.local/nicepage.html;
}

上記のファイルが Docker コンテナ内に配置されるように、 docker-compose.yml を書き換えます。

docker-compose.yml
version: '3'

services:
  nginx-proxy:
    image: jwilder/nginx-proxy:latest
    ports:
      - '80:80'
    volumes:
      - /var/run/docker.sock:/tmp/docker.sock:ro
      - ./www.example.local:/etc/nginx/vhost.d/www.example.local # <- ここを追加

networks:
  default:
    name: sample-network

ベース環境の Docker コンテナを再起動して、再度 http://www.example.local/nicepage.html にアクセスしましょう。

これで環境 B を向くようになりました。ルートパスへのアクセスはどうでしょうか。

こちらは今まで通り環境 A を向いていますね。期待される挙動になっています。

特定ドメインから別ドメインへのアクセス環境構築

AJAX リクエスト

最後に、別ドメインへのアクセス環境を構築します。これは例えば www.example.local 上から api.example.local ドメインに対し API リクエストを投げるような場合に使います。
まずは www.example.local のルートパス上で www.example.local/nicepage.html に対して AJAX リクエストを投げます。この場合は同一ドメインですから、問題なくレスポンスが返るはずです。
以下のように Axios を使ってリクエストを投げてみます。

applicationA/index.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Application A</title>
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
    <script type="text/javascript" src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
</head>
<body>
<h1>Application A</h1>
<script>
  const url = 'http://www.example.local/nicepage.html'

  const main = () => {
    axios
      .get(url)
      .then((res) => {
        console.log(res.data)
      })
      .catch((err) => {
        console.error('some error')
      })
  }

  window.onload = main
</script>

</body>
</html>

問題なくレスポンスが返ってきています。先程設定した通り、環境 B の nicepage.html がレスポンスとして返ってきていますね。

それでは次に、 www.example.local のルートパス上で api.example.local/nicepage.html に対して AJAX リクエストを投げます。api.example.local/nicepage.html にブラウザで直接アクセスした場合は問題なくページが見られます。

しかし、www.example.local のルートパス上で api.example.local/nicepage.html に対して AJAX リクエストを投げた場合は、ドメインが異なるので CORS で弾かれてしまいます。
試しに Axios を使ってリクエストを投げてみます。

applicationA/index.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Application A</title>
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
    <script type="text/javascript" src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
</head>
<body>
<h1>Application A</h1>
<script>
  const url = 'http://api.example.local/nicepage.html' // <- ドメインを変更

  const main = () => {
    axios
      .get(url)
      .then((res) => {
        console.log(res.data)
      })
      .catch((err) => {
        console.error('some error')
      })
  }

  window.onload = main
</script>

</body>
</html>

CORS で弾かれました。
これを回避するため、CORS Policy の設定を行います。先程同様、ホスト名で設定ファイルを作成します。

api.example.local
add_header Access-Control-Allow-Origin *;
docker-compose.yml
version: '3'

services:
  nginx-proxy:
    image: jwilder/nginx-proxy:latest
    ports:
      - '80:80'
    volumes:
      - /var/run/docker.sock:/tmp/docker.sock:ro
      - ./www.example.local:/etc/nginx/vhost.d/www.example.local
      - ./api.example.local:/etc/nginx/vhost.d/api.example.local # <- ここを追記
networks:
  default:
    name: sample-network

ベースの Docker コンテナを再起動し、再度 www.example.local のルートパス上から api.example.local/nicepage.html に対して AJAX リクエストを投げてレスポンスが返ってくるか確認しましょう。

OKそうですね。では念のため、環境 B 上にある nice.example.local のルートパス上からも同様に AJAX リクエストを投げてレスポンスが返ってくるか確認しましょう。
以下のように環境 B の index.html を変更し、http://nice.example.local にアクセスします。

applicationB/index.html
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Application B</title>
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
    <script type="text/javascript" src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
</head>
<body>
<h1>Application B</h1>
<script>
  const url = 'http://api.example.local/nicepage.html'

  const main = () => {
    axios
      .get(url)
      .then((res) => {
        console.log(res.data)
      })
      .catch((err) => {
        console.error('some error')
      })
  }

  window.onload = main
</script>

</body>
</html>

こちらも問題なさそうです。

サーバー間での API リクエスト

先程は HTML 上からのリクエストでしたが、サーバー上からの API リクエストとなるとまた話が変わってきます。
例えば、nice.example.local でアクセスできる Laravel アプリケーションから api.example.local の API を叩く、といったケースの話です。
Docker ネットワークでは、通常ホスト名でのアクセスしか受け付けません。nginx-proxy コンテナであれば、他の Docker コンテナから nginx-proxy コンテナ内にあるパスへのアクセスは http://nginx-proxy/path のようになります。

試しに、環境 B の Nginx コンテナ(nginx-b)から環境 A の Nginx コンテナ(nginx-a)にアクセスをしてみます。

$ cd applicationB
$ docker-compose exec nginx-b bash

# curl http://nginx-a
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Application A</title>
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
    <script type="text/javascript" src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
</head>
<body>
<h1>Application A</h1>
<script>
  const url = 'http://api.example.local/nicepage.html'

  const main = () => {
    axios
      .get(url)
      .then((res) => {
        console.log(res.data)
      })
      .catch((err) => {
        console.error('some error')
      })
  }

  window.onload = main
</script>

</body>
</html>

ホスト名で curl すると、環境 A のトップページが返ってきました。では、api.example.local でアクセスした場合はどうでしょうか?

# curl http://api.example.local
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Application B</title>
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
    <script type="text/javascript" src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
</head>
<body>
<h1>Application B</h1>
<script>
  const url = 'http://api.example.local/nicepage.html'

  const main = () => {
    axios
      .get(url)
      .then((res) => {
        console.log(res.data)
      })
      .catch((err) => {
        console.error('some error')
      })
  }

  window.onload = main
</script>

</body>
</html>

今度は環境 B のトップページが返ってきてしまいました。環境 B の Nginx コンテナからすると、api.example.local というホスト名を持つコンテナは存在しないため、仕方なく自身の ルートパスにリダイレクトし、結果 Nginx のデフォルトルートファイルである index.html を表示する、という挙動になっています。そのため環境 B の index.html の内容が表示されています。

このように、http://nginxhttp://php のように Docker ネットワーク内でのホスト名しかアクセスを受け付けないとなると、api.example.local のようにドメイン名でアクセスしたい時に困りますね。そういった時に使うのが、Docker の alias オプションです。
環境 A の docker-compose.yml に以下のように追記すると、default の Docker ネットワーク(sample-network)内からは設定したエイリアス名(=ドメイン名)で nginx-a コンテナにアクセスができるようになります。

applicationA/docker-compose.yml
version: '3'

services:
  nginx-a:
    image: nginx:latest
    volumes:
      - ./index.html:/usr/share/nginx/html/index.html
      - ./nicepage.html:/usr/share/nginx/html/nicepage.html
    environment:
      VIRTUAL_HOST: 'www.example.local,admin.example.local,api.example.local'
    networks:                 # <= ここを追記
      default:          # <= ここを追記
        aliases:         # <= ここを追記
          - api.example.local # <= ここを追記

networks:
  default:
    external: true
    name: sample-network

環境 A の Docker コンテナを再起動し、再び環境 B の Nginx コンテナ内から環境 A の Nginx コンテナへとアクセスしてみましょう。

# curl http://api.example.local
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>Application A</title>
    <script src="https://unpkg.com/axios/dist/axios.min.js"></script>
    <script type="text/javascript" src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
</head>
<body>
<h1>Application A</h1>
<script>
  const url = 'http://api.example.local/nicepage.html'

  const main = () => {
    axios
      .get(url)
      .then((res) => {
        console.log(res.data)
      })
      .catch((err) => {
        console.error('some error')
      })
  }

  window.onload = main
</script>

</body>
</html>

今度はちゃんと環境 A の index.html が表示されていますね。このように、アクセスしたいホスト名を networks の alias として指定してあげることで、ドメイン名を使ったサーバー間の通信も再現することができます。今は curl コマンドを直接叩いていますが、これが Laravel アプリケーションだったら Guzzle 使って API 叩く部分に置き換わると思ってもらえればよいです。

まとめ

リバースプロキシ環境の構築は nginx-proxy というとても便利な Docker image によりカンタンに行うことができます。
また Docker 自体の設定をうまく使うことにより、別環境間での API 通信や CORS が絡む AJAX コールなんかも再現できます。圧倒的感謝 🙏

Discussion