リバースプロキシをDocker Compose環境で実現する
この記事は、 GAOGAO Advent Calendar 2021 ことしもGAOGAOまつりです の 1日目の記事として公開されています。
こんにちは。 GAOGAO にてスタートアップスタジオのエンジニアをしております @mass-min と申します。 GAOGAO では秀吉と呼ばれています。どうぞよろしくお願いいたします。
結論
リバースプロキシ環境の構築は nginx-proxy というとても便利な Docker image によりカンタンに行うことができます。
また Docker 自体の設定をうまく使うことにより、別環境間での API 通信や CORS が絡む AJAX コールなんかも再現できます。圧倒的感謝 🙏
まえがき
例えば、アプリケーション A とアプリケーション B が以下のような設定で動いているとします。
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 への接続をしたりしていただければ最初に提示した環境が完コピできます。
リポジトリ構成の概要は以下です。今回はサンプルコードのためモノレポ構成にしていますが、もちろん appA、appB ディレクトリは別リポジトリとして切る形でも構いません。
なお、最終形のコードは GitHub リポジトリ に上げています。適宜ご活用ください。
ベースの構築
今回使用する nginx-proxy は jwilder/nginx-proxy として DockerHub 上で公開されていますので、こちらを使用します。
空のディレクトリ reverse-proxy-sample
を作成し、まずは下記のように docker-compose.yml
を記述します。
$ mkdir reverse-proxy-sample
$ vi 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.local
と api.example.local
、環境 B に向く nice.example.local
がこれに相当します。
環境 A の構築
まずは環境 A の構築です。
$ mkdir applicationA
$ vi 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 に記述
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.local
、admin.example.local
、api.example.local
、nice.example.local
にアクセスした際はローカル環境に向くようになりました。
環境 A の Nginx コンテナを起動する前に http://admin.example.local
にアクセスすると、503が返ってきます。
では 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
無事にアクセスできました。
http://api.example.local
も環境 A に向けていますので、アクセスができるはずです。確認してみましょう。
http://nice.example.local
は環境 B を向く予定でまだ設定をしていません。したがってこの時点ではアクセスができないはずです。確認してみましょう。
正しい挙動になっていますね。
環境 B の構築
では環境 B についても構築を進めましょう。
$ cd ..
$ mkdir applicationB
$ vi 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
無事にアクセスができるようになりました。
見分けがつくようにする
今後どちらの環境の Nginx にアクセスが届いているのかを分かりやすくするため、 Nginx が表示する index.html
の内容を書き換えましょう。
まずは環境 A について、新しく index.html
を作成します。
$ cd ..
$ cd applicationA
$ vi 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
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
無事表示されました。環境 B についても同様 HTML を書き換えます。
$ cd ..
$ cd applicationB
$ vi 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
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
これでどちらの環境の Nginx コンテナにアクセスしているか分かるようになりました。
パスレベルで振り分けされている部分の構築
次はパスレベルでの振り分け部分の構築です。今回の環境のうち、主に環境 A に向く www.example.local
と、そのうち環境 B に向く www.example.local/nicepage.html
がこれに相当します。
環境 A の構築
まずは環境 A から構築を行います。
環境 A の docker-compose.yml
に、ホスト名として www.example.local
を追記します。
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
にアクセスしてみましょう。
環境 A に向けてアクセスができました。
では、両環境に 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>
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
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>B's NICE PAGE</title>
</head>
<body>
<h1>B's NICE PAGE</h1>
</body>
</html>
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 ファイルを作成します。
location /nicepage.html {
proxy_pass http://nice.example.local/nicepage.html;
}
上記のファイルが Docker コンテナ内に配置されるように、 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 を使ってリクエストを投げてみます。
<!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 を使ってリクエストを投げてみます。
<!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 の設定を行います。先程同様、ホスト名で設定ファイルを作成します。
add_header Access-Control-Allow-Origin *;
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
にアクセスします。
<!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://nginx
や http://php
のように Docker ネットワーク内でのホスト名しかアクセスを受け付けないとなると、api.example.local
のようにドメイン名でアクセスしたい時に困りますね。そういった時に使うのが、Docker の alias
オプションです。
環境 A の docker-compose.yml
に以下のように追記すると、default の Docker ネットワーク(sample-network)内からは設定したエイリアス名(=ドメイン名)で nginx-a コンテナにアクセスができるようになります。
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