#9 「openresty の紹介」 tech.kayac.com Advent Calendar 2013

こんにちは、@tkuchiki です。

このエントリーは tech.kayac.com Advent Calendar 2013、9 日目のエントリーです。

本エントリーでは、弊社で主に利用している Web サーバ (Nginx) の bundle OpenResty について簡単に紹介したいと思います。

OpenResty

OpenResty (ngx_openresty) は、lua-nginx-module を始めとする多数の 3rd party module を内包した Nginx です。
作者は、lua-nginx-module のメンテナを務める agentzh さんです。

lua-nginx-module

Nginx を Lua / LuaJIT で処理するためのモジュールです。
API は 全て Non-Blocking I/O で書かれています。

使用したモジュール

以下のモジュールを使用しました。
各モジュールの詳細な API 仕様はそれぞれのページをご確認ください。

OpenResty の build

drizzle-nginx-module は、デフォルトで有効になっていないので、

--with-http_drizzle_module

をつけて build してください。
また、以下の例では使用していない module も組み込んでいますので、必要のないものは消したほうが速く build できるでしょう。

yum install -y pcre pcre-devel openssl openssl-devel zlib zlib-devel readline readline-devel libxml2 libxml2-devel libxslt-devel perl perl-devel perl-ExtUtils-Embed GeoIP-devel gd-devel

# install libdrizzle 
curl -O http://agentzh.org/misc/nginx/drizzle7-2011.07.21.tar.gz
tar zxvf drizzle7-2011.07.21.tar.gz
cd drizzle7-2011.07.21/
./configure --without-server
make libdrizzle-1.0
sudo make install-libdrizzle-1.0

# install ngx_openresty
export LIBDRIZZLE_LIB=/usr/local/lib
export LIBDRIZZLE_INC=/usr/local/include/libdrizzle-1.0
./configure \
    --prefix=/usr/local/openresty \
    --conf-path=/etc/nginx/nginx.conf \
    --sbin-path=/usr/sbin/nginx \
    --http-log-path=/var/log/nginx/access.log \
    --error-log-path=/var/log/nginx/error.log \
    --pid-path=/var/run/nginx.pid \
    --lock-path=/var/lock/subsys/nginx \
    --http-client-body-temp-path=/var/tmp/nginx/client_temp \
    --http-proxy-temp-path=/var/tmp/nginx/proxy_temp \
    --http-fastcgi-temp-path=/var/cache/nginx/fastcgi_temp \
    --http-uwsgi-temp-path=/var/cache/nginx/uwsgi_temp \
    --http-scgi-temp-path=/var/cache/nginx/scgi_temp \
    --user=nginx \
    --group=nginx \
    --with-file-aio \
    --with-ipv6 \
    --with-http_ssl_module \
    --with-http_realip_module \
    --with-http_addition_module \
    --with-http_xslt_module \
    --with-http_image_filter_module \
    --with-http_geoip_module \
    --with-http_sub_module \
    --with-http_dav_module \
    --with-http_flv_module \
    --with-http_mp4_module \
    --with-http_gzip_static_module \
    --with-http_random_index_module \
    --with-http_secure_link_module \
    --with-http_degradation_module \
    --with-http_stub_status_module \
    --with-http_perl_module \
    --with-http_iconv_module \
    --with-http_drizzle_module \
    --with-mail \
    --with-mail_ssl_module \
    --with-luajit

gmake
sudo gmake install

使用した設定ファイル(共通)

nginx.conf

今回使用する nginx.conf です。
各 Section で抜粋しながら説明を行います。

lua_package_path '/usr/local/openresty/lualib/resty/?.lua;/usr/local/openresty/lualib/magick/?.lua;;';
lua_shared_dict  upload_cnt 100k;
init_by_lua_file '/usr/local/openresty/lua/init.lua';

upstream backend {
    drizzle_server 127.0.0.1:3306 dbname=test_db
    password=test user=test_user protocol=mysql;
}

server {
    listen       80;
    server_name  localhost;

    client_max_body_size 20M;

    location /login {
        content_by_lua_file /usr/local/openresty/lua/login.lua;
    }

    location /login_query {
        internal;
        set_quote_sql_str $quoted_username $arg_username;
        set_quote_sql_str $quoted_password $arg_password;

        set $sql "SELECT COUNT(id) as cnt FROM users WHERE username=$quoted_username AND password=$quoted_password;";
        drizzle_query $sql;
        drizzle_pass backend;

        rds_json on;
    }

    location /upload {
        content_by_lua_file /usr/local/openresty/lua/file_upload.lua;
    }

    location ~ ^/img/(.+\.(gif|jpe?g|png)(\?.+)?$) {
        content_by_lua_file /usr/local/openresty/lua/resize.lua;
    }

    location ~ ^/(raw|s|m|l)/(.+\.(gif|jpe?g|png)(\?.+)?$) {
        alias /tmp/$1/$2;
    }
}

lua の変数定義

モジュールの require など、共通して使いたい変数は以下のように定義しました。
nginx.conf の init_by_lua_file ‘/usr/local/openresty/lua/init.lua’; で読み込んでいます。

# init.lua
cjson      = require "cjson"
magick     = require "magick"
resty_sha1 = require "resty.sha1"
resty_str  = require "resty.string"
upload     = require "resty.upload"

img_dir = '/tmp'
raw_dir = img_dir .. "/raw"
s_dir   = img_dir .. "/s"
m_dir   = img_dir .. "/m"
l_dir   = img_dir .. "/l"
size_s  = "25%x25%"
size_m  = "50%x50%"
size_l  = "100%x100%"

MySQL アクセス

Drizzle は、MySQL から fork してクラウドに最適化して開発されたプロダクトです。
どのようなものかは、クラウドに最適化したMySQLのフォーク「Drizzle」が正式版公開Introducing the Drizzle Project が参考になります。

今回使うのは Drizzle ではなく、OpenResty build 前にインストールした libdrizzle を使います。
libdrizzle は Drizzle と MySQL プロトコルに対応したライブラリで、これを使って、Nginx から MySQL にアクセスします。
使用した MySQL のバージョンは、5.5.35 です(MySQL 5.6 では動作しませんでした)。

MySQL の準備

# setup MySQL
$ mysql -u root
mysql> CREATE DATABASE test_db DEFAULT CHARACTER SET utf8;
mysql> CREATE TABLE test_db.users (id int(11) unsigned AUTO_INCREMENT, username varchar(512), password varchar(512), PRIMARY KEY (id));
mysql> INSERT INTO test_db.users (username, password) VALUES ('foo', '0beec7b5ea3f0fdbc95d0dd47f3c5bc275da8a33'), ('bar', '62cdb7020ff920e5aa642c3d4066950dd1f01f4d');
 
mysql> GRANT ALL PRIVILEGES ON *.* TO test_user@127.0.0.1 IDENTIFIED BY 'test' WITH GRANT OPTION;
mysql> FLUSH PRIVILEGES;

nginx.conf(抜粋)

# in http directive
upstream backend {
    drizzle_server 127.0.0.1:3306 dbname=test_db
    password=test user=test_user protocol=mysql;
}

# in server directive
location /login {
    content_by_lua_file /usr/local/openresty/lua/login.lua;
}

location /login_query {
    internal;
    set_quote_sql_str $quoted_username $arg_username;
    set_quote_sql_str $quoted_password $arg_password;

    set $sql "SELECT COUNT(id) as cnt FROM users WHERE username=$quoted_username AND password=$quoted_password;";
    drizzle_query $sql;
    drizzle_pass backend;

    rds_json on;
}

login.lua

drizzle-nginx-module を使って MySQLにアクセスして、ログインの判定を行っています。

  • upstream backend
    • MySQL の接続情報を書いておく
  • drizzle_pass
    • upstream を参照
  • drizzle_query
    • クエリ実行
  • ngx.location.capture
    • location を 擬似 http 通信でアクセスして結果を受け取る
    • internal をつけて内部からしかアクセス出来ない状態にした /login_query を実行して結果を受け取っている
  • rds_json on
    • MySQL に投げたクエリの結果を JSON 形式で出力
  • set_quote_sql_str
    • 第二引数に渡した文字列をクォートして第一引数に代入
ngx.req.read_body()
local post_args = ngx.req.get_post_args()

local _sha1 = resty_sha1:new()
      _sha1:update(post_args["password"])

local password = _sha1:final()
      password = resty_str.to_hex(password)

local username = post_args["username"]

local res = ngx.location.capture("/login_query?username=" .. username .. "&password=" .. password)

local result = string.match(res.body, '%[%{"cnt":(.+)%}%]')

-- login fail
if result == "0" then
  -- do something

-- login success
else
  -- do something
end

画像アップロード・変換

画像に便利な、lua-resty-upload モジュールを使います。
lua-resty-upload は、file upload 時の header や body を 簡単に読み取るためのモジュールです。
例では、アップロードした画像を ImageMagick で加工しています。

Lua の ImageMagick バインディングには 公式サイト(ImageMagick: Application Program Interfaces) に載っている、magick を使用しました。

nginx.conf(抜粋)

# in server directive

client_max_body_size 20M;

location /upload {
    content_by_lua_file /usr/local/openresty/lua/file_upload.lua;
}

location ~ ^/img/(.+\.(gif|jpe?g|png)(\?.+)?$) {
    content_by_lua_file /usr/local/openresty/lua/resize.lua;
}

location ~ ^/(raw|s|m|l)/(.+\.(gif|jpe?g|png)(\?.+)?$) {
    alias /tmp/$1/$2;
}

lua code

image_upload.lua

  • form:read()
    • HTTP Header や Body を読み込む
  • ngx.shared.VALUE:{get,set}()
    • Nginx で共有する変数を get, set
    • nginx.conf に lua_shared_dict upload_cnt 100k; のように書いておく必要あり
    • os.time() は、同じ時間(秒単位)でリクエストがくると同じ値を返すので、ngx.shared.VALUE をインクリメントして重複しないようにしている
  • magick.thumb
    • ImageMagick で画像をリサイズ
local chunk_size = 4096
local form       = upload:new(chunk_size)
local sha1       = resty_sha1:new()
local file, file_name, file_path, cnt

while true do
  local typ, res, err = form:read()

  if not typ then
    ngx.say("failed to read: ", err)
    return
  end

  if typ == "header" then
    local image_type = string.match(res[2], "image/(.+)")

    if image_type then
      cnt = ngx.shared.upload_cnt:get("value") and ngx.shared.upload_cnt:get("value") or 0
      ngx.shared.upload_cnt:set("value", cnt + 1)

      math.randomseed(os.time())
      sha1:update(tostring(os.time()) .. tostring(cnt))
      local binary_digest = sha1:final()
      local hex           = resty_str.to_hex(binary_digest)
      
      if image_type == "jpeg" then
        file_name = hex .. ".jpg"
      elseif image_type == "png" then
        file_name = hex .. ".png"
      elseif image_type == "gif" then
        file_name = hex .. ".gif"
      end
      file_path = raw_dir .. "/" .. file_name
    end
 
    if file_path then
      file = io.open(file_path, "w+")
      if not file then
        ngx.say("failed to open file : ", file_path)
        return
      end
    end

  elseif typ == "body" then
    if file then
      file:write(res)
    end

  elseif typ == "part_end" then
    file:close()
    sha1:reset()
    file = nil
    if cnt > 100000 then
      ngx.shared.upload_cnt:set("value", 0)
    end

  elseif typ == "eof" then
    magick.thumb(file_path, "25%x25%",   s_dir .. "/" .. file_name)
    magick.thumb(file_path, "50%x50%",   m_dir .. "/" .. file_name)
    
    ngx.say(cjson.encode({image = file_name}))
    break

  end
end

resize.lua

  • ngx.var[‘arg_GET_PARAM’] – GET パラメータを取得
  • string.gsub(ngx.var.request_uri, “(./)(.)(%?.*)”, “%2”) – URI から ファイル名だけを抽出
  • io.open(file_path, “r”) – ファイルが無ければ size= で指定したサイズにリサイズ
local size = ngx.var['arg_size']
local file_name, file_path, resize

-- type あり
if size then

  file_name = string.gsub(ngx.var.request_uri, "(.*/)(.*)(%?.*)", "%2")
 
  -- type から file_path を生成 
  if string.find(size, "[sml]") then
    file_path = img_dir .. "/" .. size .. "/" .. file_name
  else
    ngx.header.content_type = 'text/plain'
    ngx.say('invalid parameter "type=' .. size .. '"')
    ngx.exit(ngx.HTTP_INTERNAL_SERVER_ERROR)
  end
  
  -- 画像がなければ生成する 
  if io.open(file_path, "r") == nil then
    if size == "s" then
      resize = size_s
    elseif size == "m" then
      resize = size_m
    elseif size == "l" then
      resize = size_l
    end
 
    magick.thumb(raw_dir .. "/" .. file_name, resize, file_path)
  end
  
  ngx.exec("/" .. size .. "/" .. file_name)

-- type なし
else

  file_name = string.gsub(ngx.var.request_uri, "(.*/)(.*)", "%2")
  ngx.exec('/raw/' .. file_name)

end

今回の例では、ファイルを一つ送る場合のみに対応しています。
複数ファイルを読み取るには、工夫が必要なようです。

まとめ

nginx から MySQL にアクセスする例と、nginx だけでアップロードした画像の保存、リサイズする例を交えながら、
使用したモジュールの紹介を行いました。
画像加工はハードウェアリソースの消費が激しく、画像を頻繁に加工するシステムではフロントで処理が遅くなってしまう可能性があるので、実際は Nginx ではなくバックエンドで行うのが望ましいと思いますが、通常 perl や ruby を動かす必要がある部分を Nginx で任せられるのは面白いと思いました。

他にも redis や memcached にアクセスするモジュールや、lua-nginx-module 自体にも多くの API が用意されていますので、
興味のある方はドキュメントを一読してみるところから初めてみてはいかがでしょうか?

明日は、ISUCON3 出題者のお一人 @acidlemon さんです。