OpenRestyはどれくらいお気軽なウェブアプリ環境なのか。

More than 5 years have passed since last update.


OpenRestyはどれくらいお気軽なウェブアプリ環境なのか。

まとめの後に追記を追加。

OpenResty は Nginx をダイナミック・リバースプロキシーサーバに仕立て上げたり、テンプレートエンジンを仕込んでバックエンドの JSON API サーバにリクエストしたレスポンスデータを元にレンダリングして返したり、と色々便利に使えるお気楽ウェブアプリ環境なのだけれど、画像処理系のCPUに負荷のかかりそうなものでもお気軽にいけるのかなとふと疑問に思ったの実験してみる。

OpenResty や LuaRocks のインストールは homebrew でさっくり入るし、windows はパソコン初心者並の知識しかないのではしょる事にして、とりあえずテーマを決める。

「nginx 画像処理」でググってみると「簡単!リアルタイム画像変換をNginxだけで行う方法 | cloudrop」ってのが一番上にあったり「S3をバックエンドにngx_small_lightで画像を動的に ...」なんてサイトがあったり、気軽に出来そうな感じだし良いかもってことでこれいってみよ。

Note: ちなみに、作るー>文章を書くって流れだと確実に面倒に思って書かなくなってしまうので、書きながら作っていくことにする。


OpenRestyでイメージフィルタ|下準備

とりあえず以下のようなディレクトリ構成にする。

./img-server/

├── conf
│   ├── mime.types
│   └── nginx.conf
├── logs
│   ├── access.log
│   └── error.log
├── luahooks
│   ├── image.lua
│   └── init.lua
└── public
└── images
└── index.html

それから、その辺にころがってるファイルを元にしてテキトーに nginx.conf を以下のようにでっち上げる。

worker_processes    1;

events {
worker_connections 1024;
accept_mutex_delay 100ms;
}

http {
sendfile on;
tcp_nopush on;
include mime.types;
default_type text/html;
index index.html;

#
# log settings
#
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log logs/access.log main;

#
# lua global settings
#
lua_package_path '$prefix/luahooks/?.lua;;';
lua_check_client_abort on;
lua_code_cache on;

#
# initialize script
#
init_by_lua_file luahooks/init.lua;

#
# public
#
server {
listen 1080;
root public/images;

#
# content handler
#
location / {
content_by_lua_file luahooks/image.lua;
}
}
}

これで初期化フェーズは luahooks/image.lua コンテントフェーズを luahooks/image.lua でフックすることにする。けど、後で都合が悪くなったら変えるかもしれない。それじゃ、まずは動作確認ってことで luahooks/image.lua の中身を以下のように書いて定番の「hello world」を表示してみる。

ngx.say('hello world');

それからプリフィックスを指定して /path/to/command/nginx -p /path/to/img-server で nginx を起動してブラウザで表示確認。

問題なく確認できたので、次は写真を用意して public/images/image.jpg に置いてアクセスしてみる。が、もちろん表示されない。コンテントフェーズをフックしているからこれは当たり前なので、フックする箇所をアクセスフェーズに変更するように nginx.conf を書き換える。

location / {

access_by_lua_file luahooks/image.lua;
}

そして image.lua もエラーログにアクセスしてきた URL を出力するように書き換える。

ngx.log( ngx.ERR, ngx.var.uri );

これで表示されるようになった。次はこの写真を変換する処理を作ってみる。


OpenRestyでイメージフィルタ|実装

そんなわけでリクエストをどう処理するかなんだけれど、クエリパラメータが多いと面倒なので Gravater - http://en.gravatar.com っぽく s=<size> として正方形イメージに変換する。あともう一つ q=<quality> で画質も調整できるようにしておこう。ということで URL は http://localhost:1080/image.jpg?s=100&q=50 という風になる。クエリパラメータが無い時はそのままの画像を返す。

処理の流れは以下のような感じ。


  1. クエリパラメータをチェックする。

  2. リクエストされた画像が存在するかの確認。

  3. クエリパラメータに従って画像を変換する。

  4. 変換した画像を返す。

どうやるか決まったので、これらの処理に必要なライブラリをインストールする。変換処理には Imlib2 ライブラリを利用するので brew install imlib2 でインストールした後に luarocks install lua-imlib2 でモジュールをインストールする。それからリクエストされた画像の存在確認もする必要があるので、画像パスの存在確認用に luaposix モジュールも luarocks install luaposix としてインストールしておく。


クエリーパラメータの処理と画像ファイルの存在確認

インストールが終わったら空っぽだった init.lua に以下のように書いてライブラリを読み込む。別に image.lua に書いてもいいんだけど、後々ファイルが増えたりした場合とか管理のわかりやすさの面から個人的に分けちゃうくせが付いてるだけなんだけどね。

require('posix');

require('imlib2');

クエリパラメータと画像の存在確認部分までの処理を image.lua に書いてみる。

local args = ngx.req.get_uri_args();

local size = tonumber( args.s );
local quality = tonumber( args.q );

if size or quality then
local img = posix.realpath( ngx.var.document_root .. '/' .. ngx.var.uri );

if img then
ngx.log( ngx.ERR, img, ' size:', size, ' quality:', quality );
end
end

これでブラウザから画像ファイルにアクセスすると、エラーログに画像ファイルの実パスとサイズと画質の指定値のログが吐き出されるのが確認できた。


画像ファイルの変換と保存

ここまできたら後は変換してブラウザに返すだけなんだけど lua-imlib2 には画質を設定する項目がなかった。。。なんか面倒になって放置しようかと思ったけど、ふて寝して晩ご飯食べたらせっかくだしとやる気が復活。というわけで luarocks remove lua-imlib2 で削除して、代わりにシンプルな lua-thumbnailer - https://github.com/mah0x211/lua-thumbnailer というモジュールを書いたので、これを luarocks install https://raw.githubusercontent.com/mah0x211/lua-thumbnailer/master/thumbnailer-scm-1.rockspec てな感じでインストールして init.lua も以下のように変更しとく。

thumbnailer = require('thumbnailer');

それから image.lua の処理を以下のようにクエリパラメータの調整やエラー処理も追加したりして、ごそっと書き換えます。

local args = ngx.req.get_uri_args();

local size = tonumber( args.s );
local quality = tonumber( args.q );

-- size value should be larger than 0
size = size and size > 0 and size or nil;
-- quality value should be 1 to 100
quality = quality and quality > 0 and quality <= 100 and quality or nil;

if size or quality then
local img = posix.realpath( ngx.var.document_root .. '/' .. ngx.var.uri );

if img then
local filename, name, ext = ngx.var.uri:match( '(([^/.]+)%.(%w+))$' );
local uri = ngx.var.uri:gsub( filename .. '$',
table.concat({name, size, quality}, '-' )
.. '.' .. ext );

-- set thumbnail-uri if it exists
if posix.realpath( ngx.var.document_root .. '/' .. uri ) then
ngx.req.set_uri( uri );
else
local err;
img, err = thumbnailer.load( img );

if img then

if size then
-- resize( width, height, crop, horizontal_align, vertical_align )
img:resize( size, size, true, thumbnailer.ALIGN_CENTER,
thumbnailer.ALIGN_MIDDLE );
end

if quality then
img:quality( quality );
end

err = img:save( ngx.var.document_root .. uri );
if not err then
ngx.req.set_uri( uri );
end
end

-- got error
if err then
ngx.log( ngx.ERR, err );
end
end
end
end

これで画像のURL http://localhost:1080/image.jpg?s=100&q=50 にアクセスするとサイズが100x100で画質が50のサムネイルが生成される。


OpenRestyでイメージフィルタ|まとめ

そんなこんなで、結局 C でモジュール書くはめになっちゃって休日を一日使ってしまってお気軽とはいかなかったけど、普通に nginx モジュールを書いたりするよりはお手軽ではないかと・・(苦)それと、今回作ったファイル一式は github に上げてるので触ってみたい人は気軽にどうぞ。

experimental-resty-imagefilter - https://github.com/mah0x211/experimental-resty-imagefilter

ちなみに、画像を保存してる箇所がボトルネックになってるので、次回はこの辺をどうにか出来ないか実験してみたいと思う。


OpenRestyでイメージフィルタ|追記

lua-thumbnailer はあまり考え無しで書いたので保存出来るのはクロップ・リサイズのみだったけど、今回はちょっとコードを見直して冗長な定数名の変更やデフォルトアライメントを水平垂直センターに変更、それからストレッチ・リサイズとクロップ・リサイズ、アスペクト・リサイズ、トリム・リサイズの4種類の保存方法を追加した。


  • ストレッチ:リサイズ領域に合わせて引き延ばす。

  • クロップ:アスペクト比を固定したままリサイズ領域に高さ・幅どちらかが合うようにリサイズし、リサイズ領域からはみだした画像は切り取られる。

  • アスペクト:アスペクト比を固定したままリサイズ領域に収まるようにリサイズし、高さ・幅どちらかの余った部分はHSLaで指定した色(デフォルトは黒色)で塗りつぶされる。

  • トリム:アスペクト比を固定したままリサイズ領域に収まるようにリサイズし、高さ・幅どちらかの余った部分は切り取られる。

この変更に伴ってAPIも変わったのでまずはその部分を修正しようと思うけど、その他にも構成を見直したり、コードの見通しがあまりよろしくないので処理の分割、エラー処理コードの追加、共有メモリやロック機構を利用するコードも追加していきたいと思う。


OpenRestyでイメージフィルタ|構成の見直し

ということで、まずは構成を見直して以下のようなディレクトリ構成にする。

./img-server/

├── conf
│ ├── mime.types
│ └── nginx.conf
├── logs
│ ├── access.log
│ └── error.log
├── luahooks
│ ├── image.lua
│ └── init.lua
└── public
└── images
├── _cached/
└── image.jpg

違いは _cached ディレクトリがあるかないかって点だけで、ここにサムネイル画像を生成する。それから、このキャッシュディレクトリに直接アクセスされるのもよろしくないのでアクセス制限をかけたり、ファイルシステムへのアクセスを必要最低限にするために共有メモリを利用したり、同時アクセス時に同じサムネイル画像を生成するのを防ぐためにロック機構を利用するので nginx.conf の設定も修正する。

worker_processes    2;

events {
worker_connections 1024;
accept_mutex_delay 100ms;
}

http {
sendfile on;
tcp_nopush on;
open_file_cache off; # max=100;
include mime.types;
default_type text/html;
index index.html;

#
# log settings
#
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
'$status $body_bytes_sent "$http_referer" '
'"$http_user_agent" "$http_x_forwarded_for"';
access_log logs/access.log main;

#
# lua global settings
#
lua_package_path '$prefix/luahooks/?.lua;;';
lua_check_client_abort on;
lua_code_cache on;
lua_shared_dict thumbnails 10m;
lua_shared_dict resty_lock 1m;

#
# initialize script
#
init_by_lua_file luahooks/init.lua;

#
# public
#
server {
listen 1080;
root public/images;

#
# variables
#
set $cache_dir '/_cached/';

#
# restrict cache directory
#
location ~ ^/_cached/ {
internal;
}

#
# access handler for .jpg
#
location ~* \.(jpg)$ {
access_by_lua_file luahooks/image.lua;
}
}
}

この lua_shared_dict thumbnails 10m;lua_shared_dict resty_lock 1m; という箇所が共有メモリの設定箇所。サムネイルの存在確認用に 10MB とロック用に 1MB を割り当てる。もちろん値はテキトーに決めている。

きちんとやりたい時は、サムネイル画像URIからハッシュ値を生成してハッシュ文字列の長さx取り扱うファイル数分のメモリを割り当てるとよいかも。

それから set $cache_dir '/_cached/'; で nginx の変数を宣言してる。これは image.lua にハードコードしたくない時にはこうする感じ。

次に location ~ ^/_cached/ でキャッシュディレクトリへのアクセス制限を internal でかけておく。この方が楽だし的な。

あと、画像ファイル以外のリクエストの場合にも変換処理が走るにはまずいので location ~* \.(jpg)$ で拡張子が jpg のみのリクエストに制限。

ちなみに open_file_cache off; # max=100; という箇所は、試行錯誤してる時に nginx にキャッシュされてるやりずらいという理由でオフにしてるだけ。


OpenRestyでイメージフィルタ|ソースコードの修正

早速ソースコードを修正していくけど、lua-thumbnailer に API を追加したので画像のリサイズオプションとして a=<stretch|aspect|trim> というクエリーパラメータを一つ追加する。指定しない場合はクロップ・リサイズになる。


resty.lock を利用する

サムネイルの同時生成防止にロック機構を利用するのだけれど、それでプロセスが止まると困る。どうしたものかなとドキュメント見てたら resty.lock というモジュールが用意されてたのでこれを利用する。init.lua を以下のように修正する。

require('posix');

thumbnailer = require('thumbnailer');
restyLock = require('resty.lock');

resty.lock の実装を覗いてみると while ループの中にノンブロックなスリープ API ngx.sleep を挟んでる感じ。色々オプションもあるけどとりあえずデフォルト設定のままで利用する。もっと知りたい人は http://github.com/agentzh/lua-resty-lock に詳しく書いてありますよ。

次は image.lua を大幅に修正してく。


リサイズオプション・クエリーパラメータチェック用のテーブル

これは個人的なスタイルなのであってもなくても良いのだけれど、新しく追加したリサイズオプションのチェック時に if-elseif-else を書き下してくのが面倒なので事前にテーブルで定義しておく。

local IMG_SAVE_AS = {

stretch = 'stretch',
aspect = 'aspect',
trim = 'trim'
};


ロックとアンロック

local function acquireLock( key )

local lock = restyLock:new( 'resty_lock' );
local elapsed, err = lock:lock( key );

if err then
lock = nil;
ngx.log( ngx.ERR, 'failed to acquire the lock: ', err );
end

return lock, elapsed, err;
end

acquireLock 関数は以下の引数で呼び出すことでロック変数(?)を返し、失敗した場合はエラーを返す。



  • key: ロックキー

ちなみに 'resty_lock' というのが nginx.conf に書いたロック用の共有メモリの名前。

local function releaseLock( lock )

local ok, err = lock:unlock();

if err then
ngx.log( ngx.ERR, 'failed to release the lock: ', err );
end

return ok, err;
end

releaseLock 関数は以下の引数で呼び出すことでロックを解除し true を返し、失敗した場合はエラーを返す。ロックをしたら必ず解除しないといけないけど、デフォルトで30秒でタイムアウトするようになってるっぽいのでロックされっぱなしということはなさそう。


サムネイル画像の作成

local function createThumbnail( src, uri, qry )

local img, err = thumbnailer.load( src );

if img then
img:size( qry.size, qry.size );

if qry.quality then
img:quality( qry.quality );
end

if qry.asa == 'stretch' then
err = img:save( ngx.var.document_root .. uri );
elseif qry.asa == 'aspect' then
err = img:saveAspect( ngx.var.document_root .. uri );
elseif qry.asa == 'trim' then
err = img:saveTrim( ngx.var.document_root .. uri );
else
err = img:saveCrop( ngx.var.document_root .. uri );
end
-- remove internal buffer
img:free();
end

-- got error
if err then
ngx.log( ngx.ERR, 'failed to create thumbnail: ', err );
return false;
end

return true;
end

createThumbnail 関数は以下の引数で呼び出すことでドキュメントルート配下の uri パスへサムネイル画像を生成し true を返し、失敗した場合は false を返す。



  • src: ソースパス


  • uri: サムネイル画像のURI


  • qry: クエリーテーブル



    • size: 縦横のサイズ値


    • quality: 画質は 1 から 100 までの値を指定。デフォルトは 100 になる。


    • asa: リサイズ方法 stretch'aspect' または 'trim' を指定できる。デフォルトは 'crop' になる。



ちなみに、ngx.var というのは nginx の変数にアクセスするためのテーブル。画像の保存は nginx がアクセス可能なファイルシステム上のパスを指定する必要があるので ngx.var.document_root .. uri でドキュメントルートと uri を結合している。でも uri../../ こんな文字列が含まれてると危険なので呼び出す際には注意。


ソースファイルの存在確認

local function getSourcePath()

local src, err = posix.realpath( ngx.var.document_root .. '/' ..
ngx.var.uri );

if err then
ngx.log( ngx.WARN, 'failed to get realpath: ', err );
end

return src;
end

getSourcePath 関数は posix.realpath を利用してリクエストされた画像の存在確認をする。存在するならファイルシステム上のパスを返し、存在しない場合やその他のエラーがあれば nilを返す。


サムネイル画像の存在確認

local function hasThumbnail( uri )

local lock, elapsed, err = acquireLock( 'thumblock' );

if lock then
local ok;

-- check the cache
ok, err = ngx.shared.thumbnails:get( uri );
if err then
ngx.log( ngx.ERR, 'failed to check the cache: ', err );
releaseLock( lock );
-- already exists
elseif ok then
ok, err = releaseLock( lock );
if ok then
return true;
end

lock = nil;
end
end

return false, lock;
end

hasThumbnail 関数は以下の引数で呼び出すことでサムネイル画像が存在するかチェックする。前のコードでは存在チェックのために毎回 posix.realpath を使っていたのでそれを共有メモリを利用するように変更。存在する場合は true を返し、存在しない場合は false とロック変数 lock を返す。ロックに失敗した場合は falsenil を返す。



  • uri: サムネイル画像のURI


サムネイル画像URIの構築

local function genThumbnailURI( qry )

local filename, name, ext = ngx.var.uri:match( '(([^/]+)(%.%w+))$' );
local tbl = { name };

table.foreachi( { 'size', 'quality', 'asa' }, function( i, v )
if qry[v] then
tbl[#tbl+1] = qry[v];
end
end);

-- to change /path/name.ext to /path/cached/name-size-quality-asa.ext
return ngx.var.uri:gsub(
filename .. '$', ngx.var.cache_dir .. table.concat( tbl, '-' ) .. ext
);
end

genThumbnailURI 関数は以下の引数で呼び出すことでリクエストURIを元にしてサムネイル画像URIを生成して返す。今回はテキトーに /path/to/image.jpg?s=100&q=100&a=trim というリクエストなら生成されるサムネイル画像URIは /_cached/path/to/image-100-100-trim.jpg という風に変換している。



  • qry: クエリーテーブル


リクエスト URI をサムネイル画像 URI に変更

local function switchToThumbnail( qry )

local uri = genThumbnailURI( qry );
local ok, lock, err = hasThumbnail( uri );

-- set thumbnail-uri if it exists
if ok then
ngx.req.set_uri( uri );
elseif lock then
local src = getSourcePath();

-- source file exists
if src then
-- set thumbnail-uri if created
if createThumbnail( src, uri, qry ) then
ngx.shared.thumbnails:set( uri, true );
ngx.req.set_uri( uri );
end
end

releaseLock( lock );
end
end

switchToThumbnail 関数は以下の引数で呼び出すことでサムネイル画像の存在確認をし、存在するならリクエスト URI にサムネイル画像URIに設定する。

存在しなければ、ソースファイルの存在確認をしてサムネイル画像を生成、存在確認用の共有メモリへサムネイル画像 URI をキーにしてテキトーな値を保存。その後、リクエスト URI にサムネイル画像 URI に設定してロックを解除する。

こんな風にリクエスト URI を変更することで nginx にファイルの読込みや送信、キャッシュ周りの処理を全て委譲することができる。



  • qry: クエリーテーブル


クエリーパラメータのチェック

local function getQuery()

local args = ngx.req.get_uri_args();
local size = tonumber( args.s );
local quality = tonumber( args.q );

-- size value should be larger than 0
size = size and size > 0 and size < math.huge and size or nil;
-- quality value should be 1 to 100
quality = quality and quality > 0 and quality <= 100 and quality or nil;

return {
size = size,
quality = quality,
asa = IMG_SAVE_AS[args.a]
};
end

getQuery 関数を呼び出すことでクエリーパラメータの値をチェックしてテーブルを返す。まぁ、コードのみたまま感じ。

ちなみに、size < math.huge としてるのでサイズ値に 30000 とか入れられちゃうとあっとゆーまにメモリが枯渇、スワッピングが起こりファンが高速回転しだすのでとっても危険です。なのでお試しに動かす時でも制限かけておいた方がよいです。


リクエストのチェック

local function checkRequest()

local qry = getQuery();

if qry.size then
switchToThumbnail( qry );
end
end

checkRequest();

checkRequest 関数を呼び出すことでクエリーパラメータの size 値があるなら switchToThumbnail を呼び出す。

checkRequest 関数を作る必要は特にないんだけど、個人的にうっかり別の関数から変数にアクセスしちゃうコード書いちゃいそうなんで変数をごにょごにょして渡すって処理を書く時は javascript 書く時も perl 書く時もこんな風に書いちゃうクセが付いてるだけです。


OpenRestyでイメージフィルタ|追記のまとめ

ということで、説明なげーよ!ってな感じで無駄に長文すぎて、まったくもってお気軽感がなくなってしまった感がある。

OpenResty にはここで使ったもの以外にも色々な機能が用意されてるので、気になった人は https://github.com/openresty/lua-nginx-module/ を読むのが一番のオススメです。

またなんか思いついたら書きます。