今回の内容について
メドレー開発本部の田中です。 先日、Proxy層をElastic Beanstalk上のNginxで、App層をEC2インスタンスで構築する機会がありました。ここだけ見るととても普通に見えますが、後述する制約から苦労した点もあり、制約を乗り越えるための工夫も含めてお話できる限り共有させていただきます(先にお伝えしておくと、特殊な事情がなければ今回のようなケースではALBで対応するECSサービスにPath Based Routingしてやるのが良いと思います)。
技術要素として、Nginx(OpenResty)/ Route53 Private DNS / EC2 / Systems Manager パラメータストア あたりに触れたいと思います。(BeanstalkはMulticontainer Dockerを使用し、それも慣れるまでちょっとクセあったなぁと思ったのですが分量が多くなりそうなのでまた別の機会に共有させて頂きます)
まず前編としてProxy層、主にNginxを使用したDynamic Path Based Routingについてお話して、後編はApp層について、EC2とSystems Manager パラメータストアあたりについて共有させていただければと思います。
設計/構築する上での前提と方針
対象となる案件を進める上での要件・制限内容は諸事情あり、ざっとまとめるとこのような感じです。
- 環境はAWSを使用する
- サーバアプリケーション、クライアントアプリケーションはユーザ毎で、サーバアプリケーションは共用できない(ユーザが増える度にクライアント/サーバのセットが増えるイメージ)
- ただし、クライアントからの接続先となるEndpointは同じだが、Host Based Routingは訳あって利用できない
- クライアント認証を使用する
上記から、以下の設計方針で進める事にしました。
- Proxy層でクライアント認証を行い、Path Based Routingで対象となるサーバにリクエストをproxyする。Path部分にクライアント別の識別IDを含め、その値を元にPrivate DNSで名前解決する
- 例)
https://example.com/a-client/api
=>http://a-client.local/api
- App層は個別EC2インスタンスとする
- 例)
設計する上で悩んだ点
主に2点ありますが、まずはProxy層です。出来るだけAWSのマネージド・サービスで済ませたかったので、クライアント認証とPath Based Routingが可能でやりたい事に合うかどうか調べましたが以下の理由で断念し、普通(?)にELB + Nginxを利用することにしました。
- ALB: クライアント認証に非対応。またSSL終端となるのでNginx側でクライアント認証が出来ない
- API GW: クライアント認証は対応しておりRouting部分もがんばればいけるかも?、と思ったがProxy先が動的に増えたリするので管理ふくめ難しそうであった
次にApp層の構成をどうするかでした。集積度を高めるためにコンテナ利用も検討したのですが、使用するアプリケーションの必要スペックや要件などからいまいちフィットせず、個別のEC2インスタンスにすることにしました(今でももっと良い方法がないか悩んでたりします)
全体構成
出来上がった全体構成のイメージは以下となります。なお台数は実環境と異なり、今回の内容と関係ない部分などは省略しています。
次に、今回の本題となるProxy層の構成について触れたいと思います。
Proxy層の構成
Proxy層の方針等はまとめると以下の通りで、proxy先の動的判定と名前解決する箇所がキモとなります。
- App層のインスタンスは、起動時に自身の内部IPとTagに設定したクライアント識別IDを元にRoute53のPrivateDNSに登録する
- クライアント識別IDがa-clientの場合、a-client.localのように登録
- Proxy層のNginxはクライアント認証を行い、リクエストパスから取り出したクライアント識別IDを元に転送先Endpointを生成し、backendにproxyする
Nginxはbackendが増えても起動しっぱなしで動的に名前解決して動作させたかったため、lua-nginx-moduleを導入し balancer_by_lua
ディレクティブと lua-resty-dns
モジュールを使用することとし、構築の手間の関係からOpenRestyを導入することにしました。
lua-nginx-moduleを使用したconfファイル
confファイル全体としては以下となります(関係ない箇所は省いています)。ポイントと記載した部分についての説明は後述します。
http { upstream app { # ポイント1. # Private DNSで設定したIP(CNAMEに設定)を元に動的Routing balancer_by_lua_block { local balancer = require "ngx.balancer" local host = ngx.ctx.upstream_server.cname local port = '8888' local ok, err = balancer.set_current_peer(host, port) if not ok then return ngx.exit(500) end } } server { listen 443 ssl; set $proxy_upstream_host ''; set $proxy_upstream_domain '.local'; location ^~ /api/ { rewrite_by_lua_block { -- pathからクライアント識別IDを取得し、Private DNSに設定したドメインを生成 -- https://example.com/<id>/api という形式のリクエストから、<id>.local というドメインを生成して -- ngx.var.proxy_upstream_host変数に格納 local ngx_re = require "ngx.re" local res, err = ngx_re.split(ngx.var.request_uri, "/", nil, {pos = 0}) local id = res[3] ngx.var.proxy_upstream_host = id..ngx.var.proxy_upstream_domain; -- resolver設定 local resolver = require "resty.dns.resolver" local r, err = resolver:new{ nameservers = {{"x.x.x.x", 53}}, -- 使用するnameserver } if not r then ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) end -- ポイント2. -- 生成したドメイン名(<id>.local)を元に名前解決し、取得した結果をngx.ctxにセット -- (balancer_by_lua_blockで使用する) local answers, err = r:query(ngx.var.proxy_upstream_host, { qtype = r.TYPE_CNAME }) if not answers then ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) end if answers.errcode then ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) end ngx.ctx.upstream_server = answers[1] } 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; -- http://<id>.local/api に proxy rewrite ^/api/(.+)$ /api/ break; proxy_pass http://app; } } }
ポイント1. 動的Routing
balancer.set_current_peer
にてproxy先を動的に設定します。
host
部分にはドメインを直接指定することができないため、ポイント2. で ngx.ctx
にセットしたDNSの値からIP(Route53にCNAMEレコードとして設定している)を指定しています。
balancer_by_lua_block { local balancer = require "ngx.balancer" -- ngx.ctxにセットしていた、Private DNSから取得した内部IPをセット local host = ngx.ctx.upstream_server.cname local port = '8888' -- proxy先セット。hostにドメインは直接指定できない local ok, err = balancer.set_current_peer(host, port) if not ok then return ngx.exit(500) end }
詳細についてはOpenRestyのドキュメントを参照してください
ポイント2. 動的名前解決
r:query
にて、生成したドメイン名(r
部分は resolver:new
でnameserverを指定したresolverとなります。
なお、nameserverに指定するIPは今回はRoute53のPrivate DNSを指定するため、外部nameserverではなくローカルのnameserver(10.0.0.2など)を指定することになります。
問い合わせ結果のanswers
部分はLua table形式の配列となります。今回の例でいうと対象は1件となるので、その値をbalancer_by_lua_block
で使用するためにngx.ctx
にセットしています。
local answers, err = r:query(ngx.var.proxy_upstream_host, { qtype = r.TYPE_CNAME }) if not answers then ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) end if answers.errcode then ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR) end ngx.ctx.upstream_server = answers[1]
詳細についてはOpenRestyのドキュメントを参照してください
今回のまとめ
upstream先を動的に判定してproxyするという要件はそうそう無いかもしれませんし、途中までは複雑な構成になりそうだなぁとドキドキしてしましたが、結果としてはそれなりにシンプルになったかなと思います。今更ながらNginx(とlua module)は柔軟で良く出来てるなぁという感想でした。
後編はApp層について、EC2とSystems Manager パラメータストアあたりについて共有させていただければと思います。
参考リンク
構築にあたり、下記記事を参考にさせていただきました。ありがとうございます。
お知らせ
メドレーでは、医師たちがつくるオンライン医療事典「MEDLEY」、オンライン診療アプリ「CLINICS」、医療介護の求人サイト「ジョブメドレー」、口コミで探せる介護施設の検索サイト「介護のほんね」などのプロダクトを提供しています。これらのサービスの拡大を受けて、その成長を支えるエンジニア・デザイナーを募集しています。
メドレーで一緒に医療体験を変えるプロダクト作りに関わりたい方のご連絡お待ちしております。