株式会社メドレーDeveloper Portal

2017-08-24

クライアント認証と 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 => https://a-client.local/api
    • App 層は個別 EC2 インスタンスとする

設計する上で悩んだ点

主に 2 点ありますが、まずは Proxy 層です。出来るだけ AWS のマネージド・サービスで済ませたかったので、クライアント認証と Path Based Routing が可能でやりたい事に合うかどうか調べましたが以下の理由で断念し、普通(?)に ELB + Nginx を利用することにしました。

  • ALB: クライアント認証に非対応。また SSL 終端となるので Nginx 側でクライアント認証が出来ない
  • API GW: クライアント認証は対応しており Routing 部分もがんばればいけるかも?、と思ったが Proxy 先が動的に増えたリするので管理ふくめ難しそうであった

次に App 層の構成をどうするかでした。集積度を高めるためにコンテナ利用も検討したのですが、使用するアプリケーションの必要スペックや要件などからいまいちフィットせず、個別の EC2 インスタンスにすることにしました(今でももっと良い方法がないか悩んでたりします)

全体構成

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

20170823180401.png

次に、今回の本題となる 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;


           -- https://<id>.local/api に proxy
           rewrite ^/api/(.+)$ /api/ break;
           proxy_pass https://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 にて、生成したドメイン名(<id>.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 パラメータストアあたりについて共有させていただければと思います。

https://developer.medley.jp/entry/2017/08/24/120000_02

参考リンク

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

https://qiita.com/toritori0318/items/a9305d528b52936c0573

お知らせ

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

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

https://www.medley.jp/recruit/creative.html

株式会社メドレーDeveloper Portal

© 2016 MEDLEY, INC.