さて前回の続きです。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に挙げているのでそちらを参照してください。
コメント