この記事はRubyアドベントカレンダー2022の15日目の記事です。
以下のような話をお送りします:
- AWS Lambda + API Gatewayを使ってる話
- LFAというWeb frameworkをガッと書いた話
- LFAでLambdaの手元開発が楽になるのではという話
- Ruby 3.1で導入された
Kernel#load
の便利機能が超便利という話 - Lambda関数ごとに環境変数
ENV
の内容を変えたいという話
最近AWS Lambdaべったりな話
最近自分でサービスを作ってるんですが、サーバサイドは全部AWS Lambda + API Gatewayでやっつけてます。 EC2を常時起動して管理するのもやだし、大した規模でもないからゴツいビルドパイプラインをセットアップしてコンテナイメージをビルドするのも面倒だし、みたいなことを考えると、コードをそのまま送り付けるとあとは動きつづけてくれるAWS Lambda + API Gatewayの組み合わせが便利だなってことで。ただしRubyのバージョンが2.7だけなのがちょっとアレですよね。
アーキテクチャは手元がM1 macなのと、なんとなく以前所属した感慨もあってarmにしてるんですが、bundleするライブラリにmysql2を使ったところネイティブビルドされた部分の読み込みが実行時にちょっと理解不能なエラーを起こしてうまく動作しませんでした*1。これを追ったりデプロイ用の環境をガチャガチャやったりというのがちょっと面倒だったんで、pure Rubyなruby-mysql
を使ったりしてます。(4.0に上げるのはこれからだけど)使ってますよ!!! Pure Rubyなライブラリはすばらしい。JITで速くなるともっといいんだけど……。
で、LambdaなのはAPIハンドラの数もリクエスト数も少ない今のうちはいいんだけど、もしサービス当たってアプリケーションが複雑化したら大量にあるLambdaの管理が大変だし、コストも高くなるし、そうなったときのことを考えてるの? みたいな話は出てくると思います。
回答としては「敢えて考えてない」です。超初期のスタートアップ*2では現在の開発効率だけを考えるのが鉄則で、当たったら当たったときに考えりゃいいんです。不確かな先々のことを考えて今の開発を非効率にする理由は全くありません。
とはいえ、いざという時のことを考えてしまう
まあね、とはいえね、自分も前の会社で経験があるんですけど、いざってときにやろうと思ってても、その時はその時で最優先のサービス開発の優先度設定があって、結局その瞬間にプロダクトの価値向上に寄与しない作業はどうしても後回しになるんですよね。で、後回しにされるほどツケが貯まっていって、日々の開発効率はどんどん落ちるし、そうはいっても動いてるサービスは止められないし、みたいなことは起きる。ランニングコストも高止まりする。開発者体験も悪化する。ああ嫌だ。
みたいなことを考えると、数あるLambdaをとりあえずどうにかする方法があるといいよなあ、Lambdaとして書いたコード、そのまま普通のアプリケーションサーバに持っていけないかなあ、ということを妄想します。
LFAを作った
ということで、LambdaをそのままmountしてRackアプリケーションにしてしまえるフレームワークを作ってみました。これがあれば、Lambdaに日々デプロイしているコードをそっくりそのままUnicornやPuma上で動かせます。もちろん複数のLambda functionを1プロセス上で併存させられます。
YAMLの設定ファイルとして、API Gatewayのリソース設定みたいなやつと、あと使う関数のリストを書きます。
# config.yaml --- resources: - path: /api resources: - path: /country methods: GET: myfunc-countries - path: /data resources: - path: /csv methods: GET: myfunc-data-csv - path: /json methods: GET: myfunc-data-json functions: - name: myfunc-countries handler: myfunc.Countries.process env: DATABASE_HOSTNAME: mydb.local DATABASE_PASSWORD: this-is-the-top-secret - name: myfunc-data-csv handler: myfunc.Data.process env: OUTPUT_DATA_TYPE: csv - name: myfunc-data-json handler: myfunc.Data.process env: OUTPUT_DATA_TYPE: json
あとはそれをRack用のconfig.ru
から、LFAを使って読み込めば、お好きなアプリケーションサーバで起動できるはずです。
require 'LFA' run LFA.ignition!('config.yaml')
動かすLambda関数のファイルは設定ファイルと同じディレクトリに置いておきます。またその他必要なライブラリはGemfileに書いてBundler経由で起動されていれば普通に使えるはず*3。
構想にAsakusa.rb数週間分、実装は2日でガッ、という感じでなんとなく動くかな? という状況なので、rubygemsにもまだリリースしていません。READMEにもありますが足りない機能もいくつもあるのと、何しろテストが皆無なので、そのへんを毎週のAsakusaなどで少しずつやってからリリースの予定です。
実際使いものになるの?
もちろん現状では駄目です。
いっぽうでAPI Gateway的なルーティングをやるだけなら実はあまり大した機能がいらないので、ちゃんと作りきれば、LambdaなコードをEC2に持っていってリライトの時間ぶんだけ延命、みたいな用途にも十分役立つんじゃないかなと思ってます。大量のLambdaを管理していたのをひとつのサーバあるいはコンテナの管理に集約できるので、それだけでも嬉しい用途がもしかしてあるかもしれません。
あと開発中に気付いたのが、これってAPI Gateway + Lambdaでホストしてるコードの手元開発用にも使えるのではという。現状だとLambdaのコードをまず書き上げて、んでAPI Gatewayでリソースの設定して、それからやっとリクエストを投げるとLambdaのコードのうっかりミスが見付かる、みたいな世界です。それを、まず手元でLFA + puma (+DB)で動作するところまで書いて、それから実際のLambda関数 + API Gatewayリソースを設定、デプロイ、という順序にできる。
普段の開発においても、コード書き換えてLambdaにアップロードしてえいやで試す*4んじゃなくて、手元でちゃんとHTTPリクエスト投げて想定の結果が帰ってくることを確認してからデプロイできるのは、普通に便利なのではという気がしてます。
LFAの実装の話
ここからRubyの面白テクニックの話です。
Kernel#load
を使って複数のLambda関数を読み込む
複数のLambda関数のコードをひとつのRubyプロセスに読み込むとき、まず考える必要があるのは、互いに衝突する(かもしれない)それぞれの関数をどうすれば衝突させずに読み込めるか。また、Lambda関数のコード*5はキャッシュなどのステートを持っている可能性があるので、例えば異なるLambda関数が同じクラス/モジュール/オブジェクトを使用していると、AWS Lambda上ではプロセス*6が異なっていたから問題なくても、1プロセス上で動作するRackアプリケーションとしては困ります。
なので、Lambda関数のハンドラfunc.Modname.method
におけるファイル名func
を、関数ごとに異なる名前空間で読み込めないかな、ということをまず考えます。この方法を、やっつけな方法*7から邪悪な方法*8まであれこれ考えてたんですが、基本に戻って関連しそうなメソッドのドキュメントでも再確認してみるかとeval
系、Kernel#require
、Kernel#load
と見ていくと、見慣れないものをKernel#load
に発見しました。第2引数 wrap
。
load(filename, wrap=false) → true
(snip)
If the optional wrap parameter is
true
, the loaded script will be executed under an anonymous module, protecting the calling program’s global namespace. If the optional wrap parameter is a module, the loaded script will be executed under the given module. In no circumstance will any local variables in the loaded file be propagated to the loading environment.
これじゃん! まさに欲しかったものじゃん!! 超便利!!!!! Kernel#load
はrequire
と違って同じファイルでも何度も読み直してくれることはもちろんみなさんご存知のことだと思いますが、まさかこんな機能があったなんて。あったっけ????????*9
ということで以下のようなコードで試してみる*10と、完全に期待通りの動作です。勝った!!!
# func.rb module Function def self.process {statusCode: 200} end end # test.rb m1 = Module.new load('func.rb', m1) m1.const_get(:Function) #=> Function m1.const_get(:Function).process #=> {statusCode: 200} Function #=> NameError (想定通り) m2 = Module.new load('func.rb', m2) m1.const_get(:Function).object_id == m2.const_get(:Function).object_id # => false # つまりDataがふたつ、異なるインスタンスとして読み込まれている
しかしこんな機能あったっけ、と調べてみたら、なんと3.1の新機能でした。なんてこった。目にしてたはずなのに、こんな超絶便利機能に気付いていなかったなんて……。
プロと読み解く Ruby 3.1 NEWS - クックパッド開発者ブログによると「DSL に使える、のかなぁ?」だそうですが、いやいやいや、こいつは何にでも使えそうな気配がしますよ。だって別空間でload
して主名前空間への影響を避けつつAbstractSyntaxTree.of(anonMod.const_get(:Target).method)
とかできるんでしょ。便利!*11
これで複数のLambdaを別々に1プロセスに読み込める、と言いたいところですが、ちょっと問題があります。load
されたファイル内でrequire
されているファイルの内容は、wrap
しているモジュール以下に読み込まれると同時に主名前空間にも読み込まれてしまいます。また複数のLambda関数で同じファイルをrequire
(またはrequire_relative
)している場合、requireされた結果のモジュール・クラス等は共有されてしまいます。これは昨日のhsbtさんのエントリで「ライブラリを複数バージョン同時にロードする機構」のあたりでも言及されてます。
ただしこれは、LFAの用途に限ってはあまり問題じゃないかなと思います。複数のLambda関数があるといっても、ひとつのディレクトリツリーにあるなら普通はライブラリや自作クラスを共有しているはずだからです。まあそれでもハマるケースとハマらないケースがありますが、多くは問題ないでしょう*12。
Lambda関数ごとに別々のENVを見せる
AWS Lambdaでは多くの場合、環境変数を用いて設定すると思います。これはもちろんLambda関数ごとに別々のものを指定できなければならず、同じLambda関数ハンドラfuncfile.Modname.method
であっても、別関数なら別の環境変数セットを持っている、みたいなこともあると思います。これもどう対応しようかなと思ってましたが、Kernel#load
のwrap
機能を見たらとりあえず以下のような対応が思い付きました。
m = Module.new dummy_env = { "VARNAME" => "value", } m.const_set(:ENV, dummy_env) load('funcfile.rb, m)
これでfuncfile.rb
がloadされるときにはトップレベルENV
として、作成しておいたダミーの環境変数が参照されます。解決。……と思ったら、もちろん、load
されたファイル内でrequire
されている側で参照されているENV
には効果が及ばず。どうしようかなと思ったんですが、しょうがないので大元のENV
をゴニョってやることにします。
まず次のように標準添付のdelegate
ライブラリを使ってENV
を置き換えるクラスを作ります。
require 'delegate' module LFA class Adapter class EnvMimic < Delegator def initialize @is_active = false @box = nil @env = ENV end def __getobj__ if @is_active @box else @env end end def __setobj__(obj) @box = obj end def mimic!(env) @box = env.dup @is_active = true yield ensure @box = nil @is_active = false end end end end
これは普段はすべてのメソッド呼び出しを単に本来ENV
だったものに移譲しますが、mimic!(env) do ... end
メソッドで指定したブロック内では、env
で指定したHashに対して移譲します。これでこのブロック呼び出しを抜けるまでは、ENV
を参照するとダミーの内容が返ってくるという。便利! 環境変数ENV
の内容をオーバーライドしたいとか人類誰でも一度は考えると思うので、これだけ切り出しても便利かもなあ。
で、あとはこれで本来のENV
を置き換えてやります。普通にやると警告が出るので、警告を放置しない良きプログラマのたしなみとして無効化しておきます。
# 必ず読まれるどこかのファイルのトップレベルで __original_warning_level = $VERBOSE begin $VERBOSE = nil ::ENV = LFA::Adapter::EnvMimic.new ensure $VERBOSE = __original_warning_level end
これでだいたい解決するんですが、これでもrequire
対象ファイルのトップレベルやモジュール・クラス宣言部でENV
を参照しているケースだけがカバーできません。load
をENV.mimic!
でくくっても、異なるLambda関数が同じファイルをrequire
している場合には2回目には読み込みが発生しないためです。困ったなあ、ということでこれもとりあえず注意して回避してね、ということになります。
ということで
Lambdaで楽をするぞ! と思っていたら不要不急の(?)OSSがひとつできていました。おっかしいなあ……。いやこれは趣味だから! 仕事でやったわけじゃないから!
*1:多分libmysqlclientまわりの話だと思う
*2:とも言えない個人開発なのが現状だけど
*3:自信がない書き方にちゃんとしたテストがされていないことが明らか
*4:まあstageとかblue-greenとかいろいろ使うんでしょうけど
*5:モジュールもしくはその他オブジェクト
*6:実際にはコンテナ単位での分離なのでもっと強いですが
*7:ファイルを文字列として読み込んだ上で外側をmodule Anon1; ...; end でくくってevalする
*8:TracePointを使って何とかしてゴニョゴニョする、マクロ的な方法でなんかやる、etc
*9:後述しますが3.1の新機能でした
*10:どうでもいいけど、おれ、なんかいっつもModule.newしてるなってLFA書きながら思いましたね
*11:正確には、第2引数をtrueにして無名空間に対してloadすること自体は3.0以前でもできていたようで、その場合でもObjectSpaceを経由してloadされたものを取得して……とかできなくはなかったみたいだけど
*12:あとでREADMEにLimitationセクションを追加する予定です