Medley Developer Blog

株式会社メドレーのエンジニア・デザイナーによるブログです

クライアント認証とPath Based Routingが必要なサーバをAWSで構築(前編:Proxy層)

今回の内容について

メドレー開発本部の田中です。 先日、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インスタンスにすることにしました(今でももっと良い方法がないか悩んでたりします)

全体構成

出来上がった全体構成のイメージは以下となります。なお台数は実環境と異なり、今回の内容と関係ない部分などは省略しています。

f:id:medley_inc:20170823180401p:plain

次に、今回の本題となるProxy層の構成について触れたいと思います。

Proxy層の構成

Proxy層の方針等はまとめると以下の通りで、proxy先の動的判定と名前解決する箇所がキモとなります。

  • App層のインスタンスは、起動時に自身の内部IPとTagに設定したクライアント識別IDを元にRoute53のPrivateDNSに登録する
    • クライアント識別IDがa-clientの場合、a-client.localのように登録
  • Proxy層のNginxはクライアント認証を行い、リクエストパスから取り出したクライアント識別IDを元に転送先Endpointを生成し、backendにproxyする
    • App層のインスタンスは動的に増えるため、リクエスト時に名前解決したい(インスタンスが増える度に自動でNginxのconfを編集することも検討したが追加数が読めず、confがふくれあがるのもなぁ、、、という思いがあり止めました)

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 にて、生成したドメイン名(.local)を問い合わせます。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 パラメータストアあたりについて共有させていただければと思います。

developer.medley.jp

参考リンク

構築にあたり、下記記事を参考にさせていただきました。ありがとうございます。

qiita.com

お知らせ

メドレーでは、医師たちがつくるオンライン医療事典「MEDLEY」、オンライン診療アプリ「CLINICS」、医療介護の求人サイト「ジョブメドレー」、口コミで探せる介護施設の検索サイト「介護のほんね」などのプロダクトを提供しています。これらのサービスの拡大を受けて、その成長を支えるエンジニア・デザイナーを募集しています。

メドレーで一緒に医療体験を変えるプロダクト作りに関わりたい方のご連絡お待ちしております。

www.medley.jp