OpenRestyでLuaを使った動的リバースプロキシを構築2

Docker

さて前回の続きです。Dockerfileはできたので、肝心のLuaスクリプトを実装します。

実装概要として以下。

  • プロキシ先の情報をMySQLに格納
  • 振り分け先の判定はドメインロケーション
  • memcachedでキャッシュする
  • ローカルネットワーク内のサービスは外部からアクセス禁止

まずMySQLですが、我が家はオンプレ稼働のMySQLサーバーがあるので、スキーマとテーブルだけ用意します。バージョンは8.0を使っていますが、特に新しい機能は使っていないので古くても大丈夫だと思います。

CREATE DATABASE IF NOT EXISTS `openresty` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
USE `openresty`;

CREATE TABLE `proxy` (
  `domain` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
  `location` varchar(128) COLLATE utf8mb4_general_ci NOT NULL DEFAULT '/',
  `host` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL,
  `port` int NOT NULL,
  `internal` tinyint(1) NOT NULL DEFAULT '0',
  `updated_at` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;

ALTER TABLE `proxy`
  ADD PRIMARY KEY (`domain`,`location`);

こんな感じでスキーマとテーブルを用意しておきます。

続いてmemcachedですが、こいつはDockerでたてます。

  openresty-cache:
    image: memcached:latest
    container_name: openresty-cache
    expose:
    - 11211
    network_mode: host
    command: ["-m", "32m"]
    restart: unless-stopped
    volumes:
    - /etc/localtime:/etc/localtime:ro
    environment:
    - TZ=Asia/Tokyo

メモリは32MB確保してますがそんなに使わないとは思います。

続いてLuaのほうですが、memcachedにつなげるモジュールはOpenRestyに標準で同梱されてるので以下のようにコードを書くだけで接続できます。値はJSON形式の文字列で保持するので、そのまま詰めますが、後ほど利用するcjsonというモジュールもついでにrequireしておきます。こいつも同梱されているのでインストールは不要です。

-- Load Memcached, cjson module
local cjson     = require "cjson"
local memcached = require "resty.memcached"
local memc      = memcached:new()
 
-- Set Memcached timeout(1 sec)
memc:set_timeout(1000)
 
-- Connect to Memcached daemon
local memc_ok, err  = memc:connect("127.0.0.1", 11211)
if not memc_ok then
    ngx.log(ngx.ALERT, "Failed to connect to Memcached: ", err)
end

-- Init variable
local domain     = ngx.var.host
local proxy_json = nil
 
if memc_ok then
    -- Query for host name in Memcached
    local _json, flags, err = memc:get(domain)
 
    if _json and type(_json) == 'string' then
        proxy_json = _json
        ngx.log(ngx.INFO, "Loading proxy from Memcached: ", proxy_json)
    end
end

「ngx.var.host」でnginx側からURLのFQDNを取得して、それをキーにmemcachedに問い合わせます。キャッシュから値が取れたかを判定するために変数に値を詰めておきます。

値が取れなかった場合はMySQLから取得します。MySQLのモジュールも同じく同梱されているので、下記のように記述するだけでMySQLに接続することができます。

if not proxy_json then
	-- Load MySQL module
	local mysql   = require "resty.mysql"
	local db      = mysql:new()

	-- Set MySQL timeout(1 sec)
	db:set_timeout(1000)

	-- Connect to MySQL
	local ok, err, errcode, sqlstate = db:connect{
	    host      = "mysql.host",
	    port      = 3306,
	    database  = "openresty",
	    user      = "user",
	    password  = "password"
	}

	if not ok then
	    ngx.log(ngx.ERR, "failed to connect: ", err, ": ", errcode, " ", sqlstate)
	    return ngx.exit(500)
	end

※接続先の情報は環境に合わせて変更してください。

MySQLに接続できたらクエリを投げて処理をします。

	-- Set variable and quote sql string
	local quoted_domain = ngx.quote_sql_str(domain)

	-- Execute an SQL query
	local sql = "SELECT location, host, port, internal FROM proxy WHERE domain = "..quoted_domain.." ORDER BY LENGTH(location) DESC"
	local res, err, errcode, sqlstate = db:query(sql)

	-- Save MySQL worker connection (max_idle_timeout 10 sec, pool 100)
	db:set_keepalive(10000, 100)

	if not res then
	    ngx.log(ngx.ERR, "bad result #1: ", err, ": ", errcode, " ", sqlstate)
	    return ngx.exit(404)
	end

	-- Set result
	proxy_json = cjson.encode(res)
	ngx.log(ngx.INFO, "Loading proxy from MySQL: ", proxy_json)

クエリ条件に使うdomainは外部からくる値なのでSQLインジェクション対策として「ngx.quote_sql_str(domain)」でクォート処理をかけましょう。シングルクォート等のエスケープ処理もしてくれます。

ロケーションの長さの降順で判定したいので「ORDER BY LENGTH(location) DESC」で取得して、cjsonモジュールを使ってJSON文字列に変換します。これはmemcachedから取得した値と揃えて判定処理を共通化するためです。

「db:set_keepalive(10000, 100)」でコネクションのキープアライブを入れてますが、これは必ずDBの処理が終わった後にしましょう。接続中のセッションが切断されます。

設定値が無事に取れたら「memc:set(domain, proxy_json, 300)」でMySQLから取得した値を5分(300秒)キャッシュします。

	if memc_ok then
	    -- Store the result from MySQL back in to Memcached with a lifetime of 5 minutes (300 seconds)
	    memc:set(domain, proxy_json, 300)
	end
end

最後にmemcachedとの接続もキープアライブします。ここまでで取得処理部分は完了です。

if memc_ok then
    -- Save Memcached worker connection (max_idle_timeout 10 sec, pool 100)
    memc:set_keepalive(10000, 100)
end

さて、いよいよ判定処理部分を実装します。

-- Decide result
local proxy_host     = nil
local proxy_port     = nil
local proxy_internal = 0
local uri            = ngx.var.request_uri
for i, proxy in pairs(cjson.decode(proxy_json)) do
    ngx.log(ngx.INFO, "Finding uri: ", uri)
 
    proxy_host     = proxy.host
    proxy_port     = proxy.port
    proxy_internal = tonumber(proxy.internal)
 
    if string.match(uri, "^"..proxy.location) then
        ngx.log(ngx.INFO, "Location match: ", proxy.host, ":", proxy.port, ":", proxy.location, ":", proxy.internal)
        break
    end
end

まず「ngx.var.request_uri」でnginx側からリクエストされているURLのパス部分を取得します。そうしたら取得処理部分で取得した設定値で判定していきます。

[
  {
     "location": "/hoge",
     "host": "hoge",
     "port": 8080,
     "internal": 1
  },
  {
     "location": "/",
     "host": "hoge2",
     "port": 80,
     "internal": 0
  }
]

取得したJSON文字列はこのようになっているので、cjson.decode(proxy_json)で配列に変換します。それをforループ処理で1件づづ処理します。

ロケーションの長さの降順で取得したのは、「https://host.domain/hogehoge」というリクエストの場合に前方一致で「/hoge」の設定を先にマッチさせるためなので、非常に重要です。なお、値をセットしてから判定処理をしているのは、もし設定を間違えてしまった場合にデフォルト値として最後の設定を活かすためにやっています。

-- Check internal
if proxy_internal == 1 then
    local remote_ip = ngx.var.remote_addr
    local ipmatcher = require("resty.ipmatcher")
    local ip = ipmatcher.new({
        "127.0.0.1",
        "192.168.0.0/16",
        "172.16.0.0/12",
        "10.0.0.0/8",
    })
 
    local ok, err = ip:match(remote_ip)
    if not ok then
        return ngx.exit(403)
    end
end

ロケーションでの判定が終わったら、ローカルネットワークの判定処理をします。これには前回で説明したCIDR形式でIP判定するためのモジュールを利用します。

ちなみに余談ですが、プライベートアドレスクラスBですが、“/12”を間違えないようにしましょう。海外には“172.”から始まるIPが割り当てられている国があって、そこからのアクセスをローカルネットワークとして判断してしまうと大変な目にあいますw

-- Set result
ngx.var.proxy_host = proxy_host
ngx.var.proxy_port = proxy_port

最後に判定処理したホスト名とポート番号をnginx側の変数に渡します。この変数はnginx側で初期定義しておきます。

あとは所定のフォルダにLuaスクリプトを置いて、nginx.confの「location」設定内で呼び出して「proxy_pass」の設定を入れます。

    set $proxy_host "";
    set $proxy_port "";

    location / {
        rewrite_by_lua_file lua/proxy.lua;
 
        proxy_pass http://$proxy_host:$proxy_port;
        break;
    }

スクリプトに問題がなければ前回のDockerfile内でコンパイルしたバイナリ版「*.luac」のほうを使いましょう。

        rewrite_by_lua_file lua/proxy.luac;

以上でDockerでたてたOpenRestyを使った動的リバースプロキシの完成です。

2回にわたり長々と紹介しましたが、Luaスクリプトの全文はGitHubに挙げているのでそちらを参照してください。

コメント

タイトルとURLをコピーしました