読者です 読者をやめる 読者になる 読者になる

LIFULL Creators Blog

「株式会社LIFULL(ライフル)」の社員によるブログです。

mruby + ngx_mrubyでアプリケーションを実装するという選択肢

はじめまして、技術基盤部の相原(kaihar4)です!

今回は、アプリケーションのクラウドサービスへの移行の一環で、 Amazon S3から取得した画像URLを含むファイルを元に、そのURLの外部画像を取得して返す機能mrubyで書き直してAWSに移行した話をしていきたいと思います。

この機能は元々モノリシックなアプリケーションの一機能として動いていたもので、これを切り出してAWSに移行するというのが今回私に与えられたミッションでした。 このアプリケーションは歴史が長く、その間ほとんどメンテナンスされていませんでした。 ディストリビューションは古くPHPのバージョンも4系、したがってそのまま持っていくという選択肢はなく、AWS上に新規にインスタンスを構築することになります。 弊社にはAPI部分をPHPからRubyに移行する方針があるということもあり、Amazon Linux上にRubyで書き直したこの機能を移行するというのが妥当な線でした。 しかしアプリケーションの性質上Unicornはプロセスモデル的に適さないですし、そもそもこれだけの機能のためにアプリケーションサーバを用意するということにも違和感がありました。

そこであがってきたのが、mrubyでこの機能を実装しngx_mrubyで動かすという選択肢です。 mrubyであればRubyと(ほぼ)同じシンタックスで実装することができ、それをngx_mrubyで動かすことでウェブサーバがそのままアプリケーションサーバとして振舞うことができます。 開発者を多く確保できるmrubyと弊社での運用実績があるnginx、これらを使う旨を運用チームへ相談の末、このプランで行くことに決まりました。

このように置き換わるイメージです。 元々キャッシュをするように作られていなかったので、この機会にRedisによるキャッシュも入れました。 f:id:nextdeveloper:20160810122507p:plain ロゴ: Amazon S3, redis

ここからはmrubyngx_mrubyについてと、いかにこれらでこの機能を実装したかについて書いていきます。 本エントリを通してmruby + ngx_mrubyでアプリケーションを実装するという選択肢の現実味を感じていただければと思います。

mrubyとは

mrubyとはISOを元に実装されたRuby1.9互換のシンタックスを持つ組み込み向けの軽量言語です。

様々な分野に活用事例があり、ウェブの領域でもmod_mrubyngx_mrubyなどで利用されています。 最近ではh2oに採用されたことも記憶に新しいですね。

この言語の最大の特徴は軽量言語ゆえの省メモリな設計です。 mruby本体には必要最低限の機能しか実装されておらず、例えばファイルの読み書きやHTTPのリクエストなどはサポートされていません。 これが組み込み向けとされる所以で、これのお陰で非常に少ないメモリで動作することが可能です。

そして、この必要最低限の機能を支える仕組みがmrbgemsです。 mrbgemsとはRubyで言うところのRubyGemsにあたるもので、有志がmrubyにない機能を実装しmrbgemsとして多数公開しています。 このC言語またはmruby自身で書かれたmrbgemsをmrubyと共にコンパイルすることで、それらが起動時に読み込まれ、その機能を利用できるという仕組みです。 mgem-listに登録されたmrbgemsであれば、mruby側が自動で依存関係を解決してくれるという強力な仕組みも備わっています。

これらを利用することにより、mrubyには一般的なプログラミング言語と遜色ない機能が実現されているのです。

ngx_mrubyとは

次は、先ほどmrubyの活用事例としても紹介したngx_mrubyです。 ngx_mrubyは、nginx上でmrubyを実行できるようにするnginxの拡張です。 同じく先ほど活用事例として紹介したmod_mrubyはこれをhttpdで実現したものとなります。

ngx_mrubyを使うことで、以下のようにnginx内でmrubyを実行することができます。

location /hello {
  mruby_content_handler_code '
    proc = Proc.new do |env|
      [200, { "Content-Type" => "text/plain;charset=utf-8" }, ["Hello World"]]
    end
    run proc
  ';
}

これらを使ってmrubyでウェブサーバの設定ファイルを記述することで、他のmrubyを実行できるウェブサーバと設定を共有することができます。 秘伝のrewriteルールなどもmrubyで記述することによってウェブサーバ間で使い回すことができ、さらにはテストコードを書く事で保守しやすいものへとリファクタリング可能です。

このウェブサーバ上でmrubyを実行できる仕組みを使ってアプリケーションを実装するというのが今回の主題です。

mruby + ngx_mrubyでアプリケーションを実装するには

さて、ようやく本題です。

今回実装する機能の要件は以下の通りです。 アプリケーションを実装する際によくある要件だと思います。

  1. YAMLから後述のRedisに接続するための情報を取得する
  2. 与えられたクエリストリングを元にAmazon S3からファイルを取得する
  3. Amazon S3から取得したファイルからURLをパースする
  4. そのURLをRedisにキャッシュする
  5. そのURLにリクエストして画像を取得して返す

これらをmrubyでどうのように実現するのか順に追っていきましょう。

YAMLから情報を取得する

アプリケーションを開発する際には、大抵設定ファイルを読み込んでRDBMSなどへの接続情報を得るといったことが必要になると思います。 これをmrubyで実装するとどのようになるか見ていきましょう。

これは以下のような処理で実現することができます。

YAML.load(File.open('/path/to/yml').read)

使用しているのは以下のmrbgemsです。

mruby-yamlでは YAML.load_file は実装されていないため、mruby-ioFile.openIO#read を使っていますが、Rubyでも動く完全Ruby互換のコードとなっています。

ただ、これをRedisに繋ぐたびに実行するのはio的にコストなので、初回だけの実行にしたいところです。 しかし、ngx_mrubyではリクエストごとにmrubyのオブジェクトが初期化されてしまいます。

そこで使うのがmruby-userdataという、mrb_state構造体を介してオブジェクトをmrubyプログラム間で共有するためのmrbgemsです。 初回にmruby-userdataを使用して接続情報をmrb_stateに入れておき、以降はそこから取り出すことで無駄な処理をなくすことができます。

ngx_mrubyには mruby_init という起動時にのみ実行されるディレクティブがあるので、以下の処理を記述したファイルを指定することでこれを実現することができます。

mruby_init /path/to/init.rb;

server {
}
# init.rb
config = Userdata.new('config')
config_file = YAML.load(File.open('/path/to/yml').read)
environment_config = config_file[ENV['ENV']]
config.redis = environment_config['redis'].map {|k, v| [k.to_sym, v] }.to_h

mruby-envを使えば、 ENV 定数によって環境変数を取り扱うことができるので、環境変数によって読み込む設定を切り替えるといったことも可能です。

環境変数は単純に /etc/sysconfig/nginx から渡すことができます。

# /etc/sysconfig/nginx
export ENV=production

クエリストリングを元にAmazon S3からファイルを取得する

次はAmazon S3へリクエストを投げる部分です。 ngx_mrubyではrack-based-apiを採用しているので、以下のようなファイルを mruby_content_handler ディレクティブに指定することでmrubyを実行することができます。 末尾の cache をつけるとmrubyのコードをnginxがキャッシュするようになります。

location /hello {
  mruby_content_handler /path/to/client.rb cache;
}
# client.rb
class Client
  def call(env)
  end
end

run Client.new

Kernel.#run に渡すオブジェクトは #call メソッドを実装している必要があるのでProcオブジェクトでも可能です。 そして、その #call メソッドの引数として得られる env オブジェクトにクエリストリングをはじめとした情報が格納されています。 env['QUERY_STRING'] に丸ごと入っているので以下のようにパースすると扱いやすいです。

# client.rb
class Client
  def call(env)
    params = env['QUERY_STRING'].split('&').map {|kv| kv.split('=') }.to_h
  end
end

run Client.new

次にAmazon S3に繋ぐ処理ですが、今回はAmazon Linuxを使用しているため、IAM RoleからAccess Tokenを取得してきてそれを使いたいところです。

http://169.254.169.254からHTTPで取得するため、mruby-simplehttpを使います。 またこれも同様に毎回取得する必要はないので、先ほどのmruby-userdataを使って init.rb で初回時にのみ実行するようにしましょう。 IAM Roleの名前は可変なのでmruby-envを使って ENV 定数により環境変数から与えることにしています。

# init.rb
.
.
.
metadata = Userdata.new('metadata')
metadata.iam_role = ENV['IAM_ROLE']
json_credentials = SimpleHttp.new(
  'http',
  '169.254.169.254',
  80
).get("/latest/meta-data/iam/security-credentials/#{metadata.iam_role}").body
metadata.credentials = JSON.parse(json_credentials)
# /etc/sysconfig/nginx
.
.
.
export IAM_ROLE=remote_image

mrubyにはもちろん標準でjsonモジュールはありませんので、レスポンスのjsonのパースにはmrbgemsを使います。 類似のmrbgemsが多数存在しますが、ここではmruby-iijsonを使っています。

あとはここで取得したクレデンシャルを用いてAmazon S3にリクエストするのみとなります。 init.rb で起動時にmruby-userdataに入れてあるので取り出してリクエスト時に使用します。 Amazon S3へのリクエストにはちょうどいいmrbgems、mruby-aws-s3があるのでこれを使いましょう。 #3にてIAM Roleから得られるSecurity Tokenに対応したので今回のようなパターンでも利用することができます。

便宜上、先ほどパースしたクエリストリング内にAmazon S3から取得する 画像URLを含むファイル へのパスが含まれているものとします。

# client.rb
class Client
  def call(env)
    params = env['QUERY_STRING'].split('&').map {|kv| kv.split('=') }.to_h
    metadata = Userdata.new('metadata')
    credentials = metadata.credentials
    s3 = AWS::S3.new(credentials['AccessKeyId'], credentials['SecretAccessKey'], credentials['Token'])
    response = s3.download(params['path'])
  end
end

run Client.new

これでAmazon S3から 画像URLを含むファイル を取得することができました。

ここでの注意点として、初回時にのみクレデンシャルを取得するようにしていると、これらのクレデンシャルが期限切れになった場合に対応することができません。 Access Key IdもしくはSecret Access Keyが異なっている場合は 400 が、Tokenが異なっている場合は 403repsponse.code として得られるのでクレデンシャルの再取得の処理が必要です。

取得したURLをRedisにキャッシュする

Amazon S3から取得した 画像URLを含むファイル はjsonなので、ファイルから外部画像のURLをパースする処理は、先ほどと同じ手順で response.body をパースするだけなので割愛します。

ここではAmazon S3へのリクエスト数を減らすためにRedisでキャッシュを行います。 これもよくあるパターンですが、もちろんmrubyでも実現可能です。

これにはmruby-redisを使います。 接続情報は先ほど init.rb にて取得してあるのでそれを使います。

# client.rb
class Client
  def call(env)
    .
    .
    .
    config = Userdata.new('config')
    redis = Redis.new(config.redis[:host], config.redis[:port])
    redis.set(key, target_url)
  end
end

run Client.new

実際にはRedisのキャッシュの有無を確認してAmazon S3へリクエストを送るかどうかを判定する必要がありますが、ここでは割愛します。

今回はRedisにAmazon ElastiCacheを利用していてバックエンドのRedisが複数台いるため、負荷分散のために都度コネクションを張っていますが、特にそういった事情がない場合は、Redisのコネクション自体をmruby-userdataで共有して使いまわすことでコネクション開始時のオーバーヘッドを無くすことができます。

取得したURLにリクエストして画像を取得して返す

最後の処理です。

HTTPのリクエストを送るために、先ほどクレデンシャルを取得するのに使用したmruby-simplehttpを使います。 またこの例では、mruby-httpを使用してクエリを組み立てています。

ngx_mrubyが採用しているrack-based-apiでは、Kernel.#run メソッドの引数に与えるオブジェクトが #call メソッドを実装している必要があることに加え、レスポンスとして返す #call メソッドの返り値が以下の例にあるような status codeheaderbody を含んだ配列でなければならないという制約があります。

# client.rb
class Client
  def call(env)
    .
    .
    .
    uri = HTTP::Parser.new.parse_url(target_url)
    request_query = uri.query ? "#{uri.path}?#{uri.query}" : uri.path
    response = SimpleHttp.new(uri.schema, uri.host, uri.port).get(request_query)
    unless response.code.to_i == 200
      return [404, { 'Content-Type' => 'text/plain;charset=utf-8' }, ['not found']]
    end
    [200, { 'Content-Type' => 'image/jpeg', 'Content-Disposition' => 'inline' }, [response.body]]
  end
end

run Client.new

このように配列をメソッド内で返すことで、ついに取得した画像をレスポンスとして返すことができました。

長くなりましたが、以上でこのアプリケーションのおおまかな処理は完成です。

まとめ

このように、軽量言語のmrubyでも強力なmrbgemsを駆使することで一般的なプログラミング言語と遜色ない処理を実行させることができました。

言語自体の単純な実行速度はRubyと比べると一長一短*1で単純にどちらが早いとは言い切れませんが、より少ないメモリで動作し、別途アプリケーションサーバを必要としないmrubyの採用は一考の価値がありそうです。

弊社ではすでにこのアプリケーションが本番環境で動いており、200万req/dayを受けながら安定稼働しています。 mrubyでこういったアプリケーションを書くことの是非は今後の運用を通して判断していきたいです。

*1:RubyではC言語で実装されていたメソッドがmrubyだとmruby自身で実装されているケースがあり、その場合はmrubyの方が実行速度が遅い