ついにCloud FunctionsにRuby Runtime登場!

今さら感がないと言えば嘘になるけど、

Ruby comes to Cloud Functions | Google Cloud Blog

ついに、Cloud Functions で Ruby が使えるようになりました! いやー長かったな。

個人的には closed beta の段階で申し込みはしてて動かすことだけはできていたのに結局試す時間がないまま public beta になってしまって申し訳ないという気持ち半分、いやでも単純に嬉しいという気持ち半分です。

何はともあれ、これで Ruby の実行環境にカジュアルな FaaS の二つ目1が加わったわけです! いやめでたい。

というわけでこれまでの他の言語での経験と Ruby / Rack / Ruby版 Functions Framework の特徴をざっと眺めて、実際のプロダクトコードを書く際に気を付けることなどに触れて紹介に代えたいと、思います!

Functions Frameworkというものがあるよ

Ruby で Function を書く際には Functions Framework というものを利用する。

GoogleCloudPlatform/functions-framework-ruby: FaaS (Function as a service) framework for writing portable Ruby functions

closed beta の頃にはすでに存在してて、こいつが何をするものかというと、

  • Cloud Functions, Cloud Run または Knative ベースの仕組みの上で動かせる
    • Cloud Run は Knative ベース
  • local での開発にも利用できる

という代物で、先行している Node.js や Python 版と同様の役割を果たす。すでにこれらの言語で経験のある人には説明の必要はないんだけど、

特徴的なのは GCP 上のイベント(代表的なものは PubSub)に対応する関数も HTTP 関数も同等に扱える

点。

どういうことかと言うと、Functions Framework の中身を見ると分かるのだがイベントも頑張って Rack::Request に変換してくれているおかげで、対応するすべてのものを Rack アプリで処理できるようになっている。

つまり Ruby で HTTP のサーバサイドを扱う人には最も馴染みの方法で HTTP 関数だけでなく様々なアプリを書けるようになっているということです。素晴らしい。

Functions FrameworkやFunctionsの不便な点

ただ、Functions Framework は Node.js なんかもそうなんだけど、注力しているのはこの request の source の抽象化であって、実際のアプリ内の話については重視していない。そこで以下にいくつか不便な点を挙げていこうと思う。

reloaderの問題

Ruby では多くの場合でフレームワーク側で reloader を用意してくれていて、Sinatra も Rails も開発環境ではいい具合にソースコードの変更に応じて reload してくれるのだが、Functions Framework はそういう部分をケアしてくれるものではない。

ではどうするかと言うと、方法は大きく二つあって、

  1. Functions Framework を Sinatra など reloader を持つ framework と組み合わせる
  2. nodemon のような汎用の reloader ( restarter ) を利用する

1 の方がたぶん慣れている人には分かりやすい。具体的にどのように Function を書くかについては公式に記述があって、

File: Writing Functions — Functions

にあるように、Sinatra などと組み合わせることができる。使い方としては要は Rack アプリに対して Rack アプリが期待する env を渡してやるだけである。公式の Sinatra サンプルは class ベースの modular style アプリだが、class を定義しない classic style でも同様に利用できる。

そのうえで、Sinatra のアプリケーションコード内で sinatra/reloader を require してやれば普通に development 環境では reload が有効になる。

ただ、後述するが実際には典型的な人間向けの Web アプリの実装に向いているこれらのフレームワークを利用する機会はそんなに多くないように思う。その際はフレームワークではなくこのあとに述べる reloader と組み合わせるとよい。

nodemon

nodemon はもとは node コマンドを置き換えて

$ node app.js

の代わりに

$ nodemon app.js

のように使うとファイルの変更を検知して自動的にサーバを restart してくれるというものだったが、watch する拡張子と実行するコマンドを指定すると Ruby アプリでも問題なく利用できる。具体的には

$ nodemon -e rb --exec "functions-framework-ruby -t <func>"

のようにしてあげるとよい。ここに書いたものは npm run や bundle exec は省略してあるので必要に応じて適宜追加してほしい。

functions-framework-ruby 0.7時点でのNode.js版1.6との違い

functions-framework-nodejs は body-parser を含んでいたりするので、素の express の request, response を扱うこととは異なり、もう少し便利な機能が備わっている。

対して functions-framework-ruby 0.7 時点では今のところ追加の支援はなさそうなので、かなり生々しい Rack オブジェクトを扱うことになる。現実的なアプリを書くには、例えば比較的全部入りのアプリケーションフレームワークと組み合わせる2か、あるいは

を使いつつ

のような機能を追加して対応していく感じになるのかな。こっちのアプローチの方が express っぽいけど、Rails も Sinatra もあまり軽くはないので、特にリソースの制約の厳しい Functions 環境ではこれくらいの生々しさや軽量さへのこだわりはあってもいいかも。

cf.

Functions Framework独自の情報をどう引き回すか問題

これは Functions Framework が担う部分と従来の Web アプリケーション Framework の担う部分の違いの話。

Rails などの通常の Web アプリケーションフレームワークは request, response の入出力の方法からアプリケーションロジックに当たる部分、DBMS の読み書きの部分までトータルにサポートするのが一般的3と言える。

対して Functions Framework の責任の範囲はあくまで HTTP と CloudEvents を抽象化し、Rack 互換のアプリケーションで処理できるようにするための繋ぎの部分であって、それ以外は対象外となっている。

そのうえで、Functions Framework 固有の情報もある。例えば global, set_global というメソッドは Functions Framework 内だけで利用できる global 変数のようなもので、これを利用すると以下のように request を受けるたびに実行するには重たい初期化処理を行ったり、その結果の情報を保持しておくことができる。

FunctionsFramework.on_startup do |function|
  set_global :config, heavy_initializing_process()
end

FunctionsFramework.http <name> do |request|
  config = global :config
  ...
end

ただこれが通用するのは Functions Framework 内の話であって、例えば先ほど挙げたように Sinatra と組み合わせるような場合には Sinatra の中ではこの global にアクセスすることはできない。

そこでどうするかというと、Sinatra アプリに唯一渡せるのは rack env オブジェクトなので、この env の中に global をぶら下げる形になる。先ほどの例で言うと、

FunctionsFramework.http <name> do |request|
  config = global :config
  env = request.env
  env.config = config

  Sinatra::Application.call env
end

のようになる。

Functionを書く際に考えなければいけないことは意外に多くなる

これも別に言語は関係なくて、今まで FaaS を使っていた人にとっては割と当たり前の話。

実際に Cloud Functions を書く際には恐らく従来の人間向けの Web アプリよりも小規模で、かつ逆にインフラにより近いものを直接扱うようになると思う。インフラというのは例えば Cloud Storage, Firestore などのストレージだったり、PubSub や他の Function など、Google Cloud 上のフルマネージドインフラ。Functions を選ぶということは GCE ではないということで、それはつまりアプリケーションの実行環境から直接同じ OS 上で管理できるような伝統的なインフラが存在していない、という意味になる。だからフルマネージドインフラのお世話になるだろうという推測である。

で、そうなると Sinatra や Rails などのアプリケーションフレームワークの機能や構造の分け方などはあまり役に立たない。これらの便利機能は主に

  • Cookie や Session
  • HTML や response の文字列(JSONなど)の生成
  • (DBMSアクセスの抽象化)

は担ってくれるが、それ以外のインフラを扱う部分に対しては基本的には管轄外となってしまう。

例えば Memcached や Redis を利用する際にそのコードをどのように配置すべきかについては Rails には答えはなくて、利用用途がほぼ cache だろうから Rails.cache のバックエンドに置くことを支援してくれるまでに留まっている。実際には cache を扱うコードをどこに置くべきかについてはガイドがないので、作成および更新処理と読み込む処理が分散してコントロールが難しくなってしまうという問題を抱えていたりする。

こうした課題への対策には恐らくいわゆるアプケーションアーキテクチャを考えることになるだろう。いわゆるオニオンアーキテクチャやクリーンアーキテクチャと呼ばれる類のアレだ。

Functions は周知の通り実行時間や利用できるメモリに制限があり、あまり複雑な機能を実装することはできないが、代わりにいわゆる Web MVC のようなシンプルな構成とは異なり、アプリケーションの扱わなければいけないインフラが複雑になりやすい傾向があるので、その分で考えることは増える。

特に異なるインフラを扱うコードが密に結合してしまうと「本番でしかテストできません」みたいなことが容易に起き得るので注意が必要である。

注意深くインフラとロジックを分離し、依存の方向に気をつけて DI で組み立てていくようにする、そういうコードの量が増えるはず。

参考

  1. 一つ目はもちろん AWS Lambda 

  2. 当然、spin upが重すぎるのでFunctionsには向かない 

  3. Ruby を日常的に利用している人には当たり前の話になるが、基本的に request, response は Rack の作法に従うことでその部分の独自実装をあまり行わないようにするのが一般的であり、フレームワーク独自の部分はあまりない。 

More